diff --git a/.cursor/skills/lab-notifications/SKILL.md b/.cursor/skills/lab-notifications/SKILL.md index e55315e..761efb8 100644 --- a/.cursor/skills/lab-notifications/SKILL.md +++ b/.cursor/skills/lab-notifications/SKILL.md @@ -21,7 +21,7 @@ Frontend hook: [`frontend/src/lib/hooks/useTabBadgeCounts.ts`](frontend/src/lib/ |-----|-----|----------------| | LAB | Cases | `CASE_SENT`, `CLINIC_COMMENT`, `CASE_IMPORTANT` | | LAB | Tasks | `TASK_COMPLETED`, `LAB_COMMENT` | -| CLINIC | Treatment | `LAB_COMMENT` (only `visibleToClinic`), `TASK_COMPLETED` | +| CLINIC | Treatment | `LAB_COMMENT` (only `visibleToClinic`), `TASK_COMPLETED` — **only lab cases for treatments the user provided** | Counts exclude events where `actorUserId === current user`. Clinic `LAB_COMMENT` counts only when `payload.visibleToClinic === true`. diff --git a/.cursor/skills/treatment-workspace/SKILL.md b/.cursor/skills/treatment-workspace/SKILL.md index 9b5ad29..2e07de0 100644 --- a/.cursor/skills/treatment-workspace/SKILL.md +++ b/.cursor/skills/treatment-workspace/SKILL.md @@ -58,7 +58,7 @@ Thin route: `app/[locale]/(dashboard)/treatment/page.tsx` (supports `?appointmen -`GET /treatments/patients/:id/history` returns saved treatments for **that patient** (not the whole day’s schedule). Non-owners see plans where `Treatment.providerUserId` or linked `Appointment.providerUserId` matches the logged-in user; org owners see all saved plans for the patient. New saves set `Treatment.providerUserId` to the logged-in clinician. +`GET /treatments/patients/:id/history` returns saved treatments for **that patient** scoped to the **logged-in clinician** (`Treatment.providerUserId` or linked `Appointment.providerUserId`). **Owners are not exempt** — each user only sees plans they created or own via their appointments. @@ -184,7 +184,7 @@ canEditTreatmentForDay = canEdit && selectedAppointment && !isViewingPastDay && -Filter in **backend** `listPatientHistory` on patient + org; provider scoping for non-owners. History is **per selected patient**, not per day or all appointments on the strip. +Filter in **backend** `listPatientHistory` / lab-case lists on patient + org + **provider scope** (`common/treatment-provider-scope.ts`). History is **per selected patient and per clinician**, not per day or all org plans. **UI filters** (not shipped, date) are client-side only — do not add API params unless product explicitly requires server-side filtering. diff --git a/AGENTS.md b/AGENTS.md index 038f64a..3ace47a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,8 +45,8 @@ frontend/src/ **Treatment lab rules (quick ref):** - Lab-dependent details (e.g. prosthesis) **without teeth** can save but **cannot ship** — show `LabShipmentBlockedNotice` + inline banner; toast on dispatch add. - **History filters** are client-side only (`treatmentHistoryFilters.ts`): “Not shipped to lab” + single date on already-fetched patient history; includes live current draft when filtering. -- **Lab shipments rail**: unified list with scope toggle **This patient** vs **All updates** (unread across org, includes patient name). -- **Unread semantics**: Treatment tab badge = count of unread cases (per-case read cursor) and clears when a case is opened/marked read (not on tab visit). +- **Lab shipments rail**: unified list with scope toggle **This patient** vs **All updates** (unread across org for **this clinician's cases only**, includes patient name). +- **Unread semantics**: Treatment tab badge = count of unread cases **for the user's own treatment plans** (per-case read cursor) and clears when a case is opened/marked read (not on tab visit). - **Lab shipment progress + comments**: shown in **Lab dispatch panel** for the active shipment; expanding activity / opening comments marks that case read. **Lab Tasks tab:** Newest case first; steps ordered 1→N; case grouping when sorted by date; `stepCompleted` filter; prosthesis colors from catalog; task assignment in **Cases** (compact row: status + assignee + last update); on **Tasks**, all staff see every task but only assignee (or unassigned pool) can change status — others see “Assigned to {name}” instead of the status dropdown; **case due dates** set/edited in clinic Treatment lab dispatch, shown on lab Cases/Tasks with overdue filter + sort; **mobile:** larger task status controls, sticky case header when grouped; **tab badges:** `LabCaseActivity` + `GET /notifications/tab-counts` (lab Cases/Tasks split, clinic Treatment) — see `.cursor/skills/lab-tasks/SKILL.md` and `.cursor/skills/lab-notifications/SKILL.md`. diff --git a/backend/src/common/treatment-provider-scope.ts b/backend/src/common/treatment-provider-scope.ts new file mode 100644 index 0000000..27519e5 --- /dev/null +++ b/backend/src/common/treatment-provider-scope.ts @@ -0,0 +1,26 @@ +import type { Prisma } from '@prisma/client'; + +/** Treatments the clinician owns — by plan provider or linked appointment provider. */ +export function treatmentProviderScopeWhere( + actorUserId: string, +): Pick { + return { + OR: [ + { providerUserId: actorUserId }, + { appointment: { is: { providerUserId: actorUserId } } }, + ], + }; +} + +type TreatmentProviderRow = { + providerUserId: string | null; + appointment?: { providerUserId: string } | null; +}; + +export function isActorTreatmentProvider( + treatment: TreatmentProviderRow, + actorUserId: string, +): boolean { + if (treatment.providerUserId === actorUserId) return true; + return treatment.appointment?.providerUserId === actorUserId; +} diff --git a/backend/src/modules/lab-case-comments/lab-case-comments.service.ts b/backend/src/modules/lab-case-comments/lab-case-comments.service.ts index 79f3d46..f015204 100644 --- a/backend/src/modules/lab-case-comments/lab-case-comments.service.ts +++ b/backend/src/modules/lab-case-comments/lab-case-comments.service.ts @@ -7,6 +7,7 @@ import { LabCaseCommentSide, LabCaseActivityType, Prisma } from '@prisma/client' import { PrismaService } from '../../../prisma/prisma.service'; import { CreateLabCaseCommentDto } from './dto/lab-case-comment.dto'; import { hasEffectivePermission } from '../../common/membership-permissions'; +import { treatmentProviderScopeWhere } from '../../common/treatment-provider-scope'; import { LabCaseActivityService } from '../notifications/lab-case-activity.service'; const commentInclude = { @@ -273,7 +274,20 @@ export class LabCaseCommentsService { clinicOrganizationId: string, actorUserId: string, ) { - await this.assertClinicOwnsCase(caseId, clinicOrganizationId); + const labCase = await this.prisma.labCase.findFirst({ + where: { + id: caseId, + treatment: { + organizationId: clinicOrganizationId, + ...treatmentProviderScopeWhere(actorUserId), + }, + }, + select: { id: true }, + }); + if (!labCase) { + throw new NotFoundException('Case not found'); + } + const membership = await this.prisma.membership.findFirst({ where: { userId: actorUserId, diff --git a/backend/src/modules/notifications/lab-case-activity.service.ts b/backend/src/modules/notifications/lab-case-activity.service.ts index 7a97ec3..bcf3e06 100644 --- a/backend/src/modules/notifications/lab-case-activity.service.ts +++ b/backend/src/modules/notifications/lab-case-activity.service.ts @@ -15,6 +15,7 @@ import { LAB_CASES_TAB_ACTIVITY_TYPES, LAB_TASKS_TAB_ACTIVITY_TYPES, } from '../../common/lab-case-activity'; +import { treatmentProviderScopeWhere } from '../../common/treatment-provider-scope'; type TxClient = Prisma.TransactionClient; @@ -126,7 +127,10 @@ export class LabCaseActivityService { CLINIC_TREATMENT_TAB_ACTIVITY_TYPES, { sentAt: { not: null }, - treatment: { organizationId }, + treatment: { + organizationId, + ...treatmentProviderScopeWhere(userId), + }, }, 'CLINIC', ); @@ -448,7 +452,11 @@ export class LabCaseActivityService { } : { id: labCaseId, - treatment: { organizationId }, + sentAt: { not: null }, + treatment: { + organizationId, + ...treatmentProviderScopeWhere(userId), + }, }, select: { id: true }, }); diff --git a/backend/src/modules/treatments/treatments.service.ts b/backend/src/modules/treatments/treatments.service.ts index 904c8ce..8cb3f41 100644 --- a/backend/src/modules/treatments/treatments.service.ts +++ b/backend/src/modules/treatments/treatments.service.ts @@ -30,6 +30,10 @@ import { } from './treatment.utils'; import { assertCompleteToothProsthesisMap } from './lab-case-send.validation'; import { hasEffectivePermission } from '../../common/membership-permissions'; +import { + isActorTreatmentProvider, + treatmentProviderScopeWhere, +} from '../../common/treatment-provider-scope'; const sentLabCaseInclude = { treatment: { @@ -164,22 +168,12 @@ export class TreatmentsService { await this.assertCanReadTreatment(actorUserId, organizationId); await this.ensurePatientExists(patientId); - const membership = await this.getMembership(actorUserId, organizationId); - const isOwner = membership?.isOwner ?? false; - const items = await this.prisma.treatment.findMany({ where: { patientId, organizationId, details: { some: {} }, - ...(isOwner - ? {} - : { - OR: [ - { providerUserId: actorUserId }, - { appointment: { is: { providerUserId: actorUserId } } }, - ], - }), + ...treatmentProviderScopeWhere(actorUserId), }, include: treatmentInclude, orderBy: [{ treatmentAt: 'desc' }, { createdAt: 'desc' }], @@ -197,16 +191,13 @@ export class TreatmentsService { await this.assertCanReadTreatment(actorUserId, organizationId); await this.ensurePatientExists(patientId); - const membership = await this.getMembership(actorUserId, organizationId); - const isOwner = membership?.isOwner ?? false; - const labCases = await this.prisma.labCase.findMany({ where: { sentAt: { not: null }, treatment: { patientId, organizationId, - ...this.treatmentAccessFilter(isOwner, actorUserId), + ...treatmentProviderScopeWhere(actorUserId), }, }, include: sentLabCaseInclude, @@ -224,15 +215,12 @@ export class TreatmentsService { async listUnreadLabCases(organizationId: string, actorUserId: string) { await this.assertCanReadTreatment(actorUserId, organizationId); - const membership = await this.getMembership(actorUserId, organizationId); - const isOwner = membership?.isOwner ?? false; - const labCases = await this.prisma.labCase.findMany({ where: { sentAt: { not: null }, treatment: { organizationId, - ...this.treatmentAccessFilter(isOwner, actorUserId), + ...treatmentProviderScopeWhere(actorUserId), }, }, include: sentLabCaseInclude, @@ -258,17 +246,6 @@ export class TreatmentsService { return { success: true, data: this.sortLabCaseSummaries(summaries) }; } - private treatmentAccessFilter(isOwner: boolean, actorUserId: string) { - return isOwner - ? {} - : { - OR: [ - { providerUserId: actorUserId }, - { appointment: { is: { providerUserId: actorUserId } } }, - ], - }; - } - private async mapSentLabCaseSummaries( labCases: SentLabCaseRow[], actorUserId: string, @@ -367,7 +344,6 @@ export class TreatmentsService { appointmentId, organizationId, actorUserId, - false, ); const treatment = await this.prisma.treatment.findFirst({ @@ -392,7 +368,6 @@ export class TreatmentsService { appointmentId, organizationId, actorUserId, - true, ); for (const d of dto.details) { @@ -537,7 +512,6 @@ export class TreatmentsService { appointmentId, organizationId, actorUserId, - true, ); const treatment = await this.prisma.treatment.findFirst({ @@ -615,7 +589,13 @@ export class TreatmentsService { for (const [index, lc] of dto.labCases.entries()) { if (lc.id && sentLabCaseIds.has(lc.id)) { if (lc.dueDate !== undefined) { - await this.updateLabCaseDueDateInTx(tx, lc.id, organizationId, lc.dueDate); + await this.updateLabCaseDueDateInTx( + tx, + lc.id, + organizationId, + actorUserId, + lc.dueDate, + ); } continue; } @@ -710,7 +690,12 @@ export class TreatmentsService { treatment: { organizationId }, }, include: { - treatment: { select: { providerUserId: true } }, + treatment: { + select: { + providerUserId: true, + appointment: { select: { providerUserId: true } }, + }, + }, sends: { select: { organizationId: true } }, details: { include: { @@ -739,11 +724,8 @@ export class TreatmentsService { assertCompleteToothProsthesisMap(labCase); - if (labCase.treatment.providerUserId !== actorUserId) { - const membership = await this.getMembership(actorUserId, organizationId); - if (!membership?.isOwner) { - throw new ForbiddenException('Only the appointment provider can send this lab case'); - } + if (!isActorTreatmentProvider(labCase.treatment, actorUserId)) { + throw new ForbiddenException('Only the treatment provider can send this lab case'); } const linkedOrgIds = await this.getActiveLinkedOrganizationIds(organizationId); @@ -821,7 +803,13 @@ export class TreatmentsService { await this.assertCanEditTreatment(actorUserId, organizationId); const updated = await this.prisma.$transaction(async (tx) => { - await this.updateLabCaseDueDateInTx(tx, labCaseId, organizationId, dto.dueDate ?? null); + await this.updateLabCaseDueDateInTx( + tx, + labCaseId, + organizationId, + actorUserId, + dto.dueDate ?? null, + ); return tx.labCase.findFirstOrThrow({ where: { id: labCaseId }, include: { @@ -862,12 +850,16 @@ export class TreatmentsService { tx: Prisma.TransactionClient, labCaseId: string, organizationId: string, + actorUserId: string, dueDateInput?: string | null, ) { const labCase = await tx.labCase.findFirst({ where: { id: labCaseId, - treatment: { organizationId }, + treatment: { + organizationId, + ...treatmentProviderScopeWhere(actorUserId), + }, sentAt: { not: null }, }, include: { tasks: { select: { status: true } } }, @@ -902,7 +894,7 @@ export class TreatmentsService { actorUserId: string, ) { await this.assertCanEditTreatment(actorUserId, organizationId); - await this.ensureAppointmentProvider(appointmentId, organizationId, actorUserId, true); + await this.ensureAppointmentProvider(appointmentId, organizationId, actorUserId); if (!detailClientKey?.trim()) { throw new BadRequestException('detailClientKey is required'); @@ -956,7 +948,14 @@ export class TreatmentsService { where: { id: attachmentId, OR: [ - { detail: { treatment: { organizationId } } }, + { + detail: { + treatment: { + organizationId, + ...treatmentProviderScopeWhere(actorUserId), + }, + }, + }, { appointmentId: { not: null } }, ], }, @@ -975,7 +974,11 @@ export class TreatmentsService { if (!attachment.detail && attachment.appointmentId) { const appointment = await this.prisma.appointment.findFirst({ - where: { id: attachment.appointmentId, organizationId }, + where: { + id: attachment.appointmentId, + organizationId, + providerUserId: actorUserId, + }, select: { id: true }, }); if (!appointment) { @@ -1228,7 +1231,6 @@ export class TreatmentsService { appointmentId: string, organizationId: string, actorUserId: string, - requireProviderMatch: boolean, ) { const appointment = await this.prisma.appointment.findFirst({ where: { id: appointmentId, organizationId }, @@ -1244,12 +1246,8 @@ export class TreatmentsService { throw new NotFoundException('Appointment not found'); } - if (requireProviderMatch) { - const membership = await this.getMembership(actorUserId, organizationId); - const isOwner = membership?.isOwner ?? false; - if (!isOwner && appointment.providerUserId !== actorUserId) { - throw new ForbiddenException('You are not the provider for this appointment'); - } + if (appointment.providerUserId !== actorUserId) { + throw new ForbiddenException('You are not the provider for this appointment'); } return appointment;