bugfix: clinic owner user access to other (dentist)staff's treatment plans terminated.
This commit is contained in:
@@ -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`.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
26
backend/src/common/treatment-provider-scope.ts
Normal file
26
backend/src/common/treatment-provider-scope.ts
Normal file
@@ -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<Prisma.TreatmentWhereInput, 'OR'> {
|
||||
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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user