improvement/ux-overhaul up #61

Merged
rameen merged 24 commits from improvement/ux-overhaul into master 2026-07-14 22:46:19 +03:30
7 changed files with 106 additions and 60 deletions
Showing only changes of commit a7ba33fb68 - Show all commits

View File

@@ -21,7 +21,7 @@ Frontend hook: [`frontend/src/lib/hooks/useTabBadgeCounts.ts`](frontend/src/lib/
|-----|-----|----------------| |-----|-----|----------------|
| LAB | Cases | `CASE_SENT`, `CLINIC_COMMENT`, `CASE_IMPORTANT` | | LAB | Cases | `CASE_SENT`, `CLINIC_COMMENT`, `CASE_IMPORTANT` |
| LAB | Tasks | `TASK_COMPLETED`, `LAB_COMMENT` | | 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`. Counts exclude events where `actorUserId === current user`. Clinic `LAB_COMMENT` counts only when `payload.visibleToClinic === true`.

View File

@@ -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 days 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. **UI filters** (not shipped, date) are client-side only — do not add API params unless product explicitly requires server-side filtering.

View File

@@ -45,8 +45,8 @@ frontend/src/
**Treatment lab rules (quick ref):** **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. - 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. - **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). - **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 (per-case read cursor) and clears when a case is opened/marked read (not on tab visit). - **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 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`. **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`.

View 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;
}

View File

@@ -7,6 +7,7 @@ import { LabCaseCommentSide, LabCaseActivityType, Prisma } from '@prisma/client'
import { PrismaService } from '../../../prisma/prisma.service'; import { PrismaService } from '../../../prisma/prisma.service';
import { CreateLabCaseCommentDto } from './dto/lab-case-comment.dto'; import { CreateLabCaseCommentDto } from './dto/lab-case-comment.dto';
import { hasEffectivePermission } from '../../common/membership-permissions'; import { hasEffectivePermission } from '../../common/membership-permissions';
import { treatmentProviderScopeWhere } from '../../common/treatment-provider-scope';
import { LabCaseActivityService } from '../notifications/lab-case-activity.service'; import { LabCaseActivityService } from '../notifications/lab-case-activity.service';
const commentInclude = { const commentInclude = {
@@ -273,7 +274,20 @@ export class LabCaseCommentsService {
clinicOrganizationId: string, clinicOrganizationId: string,
actorUserId: 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({ const membership = await this.prisma.membership.findFirst({
where: { where: {
userId: actorUserId, userId: actorUserId,

View File

@@ -15,6 +15,7 @@ import {
LAB_CASES_TAB_ACTIVITY_TYPES, LAB_CASES_TAB_ACTIVITY_TYPES,
LAB_TASKS_TAB_ACTIVITY_TYPES, LAB_TASKS_TAB_ACTIVITY_TYPES,
} from '../../common/lab-case-activity'; } from '../../common/lab-case-activity';
import { treatmentProviderScopeWhere } from '../../common/treatment-provider-scope';
type TxClient = Prisma.TransactionClient; type TxClient = Prisma.TransactionClient;
@@ -126,7 +127,10 @@ export class LabCaseActivityService {
CLINIC_TREATMENT_TAB_ACTIVITY_TYPES, CLINIC_TREATMENT_TAB_ACTIVITY_TYPES,
{ {
sentAt: { not: null }, sentAt: { not: null },
treatment: { organizationId }, treatment: {
organizationId,
...treatmentProviderScopeWhere(userId),
},
}, },
'CLINIC', 'CLINIC',
); );
@@ -448,7 +452,11 @@ export class LabCaseActivityService {
} }
: { : {
id: labCaseId, id: labCaseId,
treatment: { organizationId }, sentAt: { not: null },
treatment: {
organizationId,
...treatmentProviderScopeWhere(userId),
},
}, },
select: { id: true }, select: { id: true },
}); });

View File

@@ -30,6 +30,10 @@ import {
} from './treatment.utils'; } from './treatment.utils';
import { assertCompleteToothProsthesisMap } from './lab-case-send.validation'; import { assertCompleteToothProsthesisMap } from './lab-case-send.validation';
import { hasEffectivePermission } from '../../common/membership-permissions'; import { hasEffectivePermission } from '../../common/membership-permissions';
import {
isActorTreatmentProvider,
treatmentProviderScopeWhere,
} from '../../common/treatment-provider-scope';
const sentLabCaseInclude = { const sentLabCaseInclude = {
treatment: { treatment: {
@@ -164,22 +168,12 @@ export class TreatmentsService {
await this.assertCanReadTreatment(actorUserId, organizationId); await this.assertCanReadTreatment(actorUserId, organizationId);
await this.ensurePatientExists(patientId); await this.ensurePatientExists(patientId);
const membership = await this.getMembership(actorUserId, organizationId);
const isOwner = membership?.isOwner ?? false;
const items = await this.prisma.treatment.findMany({ const items = await this.prisma.treatment.findMany({
where: { where: {
patientId, patientId,
organizationId, organizationId,
details: { some: {} }, details: { some: {} },
...(isOwner ...treatmentProviderScopeWhere(actorUserId),
? {}
: {
OR: [
{ providerUserId: actorUserId },
{ appointment: { is: { providerUserId: actorUserId } } },
],
}),
}, },
include: treatmentInclude, include: treatmentInclude,
orderBy: [{ treatmentAt: 'desc' }, { createdAt: 'desc' }], orderBy: [{ treatmentAt: 'desc' }, { createdAt: 'desc' }],
@@ -197,16 +191,13 @@ export class TreatmentsService {
await this.assertCanReadTreatment(actorUserId, organizationId); await this.assertCanReadTreatment(actorUserId, organizationId);
await this.ensurePatientExists(patientId); await this.ensurePatientExists(patientId);
const membership = await this.getMembership(actorUserId, organizationId);
const isOwner = membership?.isOwner ?? false;
const labCases = await this.prisma.labCase.findMany({ const labCases = await this.prisma.labCase.findMany({
where: { where: {
sentAt: { not: null }, sentAt: { not: null },
treatment: { treatment: {
patientId, patientId,
organizationId, organizationId,
...this.treatmentAccessFilter(isOwner, actorUserId), ...treatmentProviderScopeWhere(actorUserId),
}, },
}, },
include: sentLabCaseInclude, include: sentLabCaseInclude,
@@ -224,15 +215,12 @@ export class TreatmentsService {
async listUnreadLabCases(organizationId: string, actorUserId: string) { async listUnreadLabCases(organizationId: string, actorUserId: string) {
await this.assertCanReadTreatment(actorUserId, organizationId); await this.assertCanReadTreatment(actorUserId, organizationId);
const membership = await this.getMembership(actorUserId, organizationId);
const isOwner = membership?.isOwner ?? false;
const labCases = await this.prisma.labCase.findMany({ const labCases = await this.prisma.labCase.findMany({
where: { where: {
sentAt: { not: null }, sentAt: { not: null },
treatment: { treatment: {
organizationId, organizationId,
...this.treatmentAccessFilter(isOwner, actorUserId), ...treatmentProviderScopeWhere(actorUserId),
}, },
}, },
include: sentLabCaseInclude, include: sentLabCaseInclude,
@@ -258,17 +246,6 @@ export class TreatmentsService {
return { success: true, data: this.sortLabCaseSummaries(summaries) }; 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( private async mapSentLabCaseSummaries(
labCases: SentLabCaseRow[], labCases: SentLabCaseRow[],
actorUserId: string, actorUserId: string,
@@ -367,7 +344,6 @@ export class TreatmentsService {
appointmentId, appointmentId,
organizationId, organizationId,
actorUserId, actorUserId,
false,
); );
const treatment = await this.prisma.treatment.findFirst({ const treatment = await this.prisma.treatment.findFirst({
@@ -392,7 +368,6 @@ export class TreatmentsService {
appointmentId, appointmentId,
organizationId, organizationId,
actorUserId, actorUserId,
true,
); );
for (const d of dto.details) { for (const d of dto.details) {
@@ -537,7 +512,6 @@ export class TreatmentsService {
appointmentId, appointmentId,
organizationId, organizationId,
actorUserId, actorUserId,
true,
); );
const treatment = await this.prisma.treatment.findFirst({ const treatment = await this.prisma.treatment.findFirst({
@@ -615,7 +589,13 @@ export class TreatmentsService {
for (const [index, lc] of dto.labCases.entries()) { for (const [index, lc] of dto.labCases.entries()) {
if (lc.id && sentLabCaseIds.has(lc.id)) { if (lc.id && sentLabCaseIds.has(lc.id)) {
if (lc.dueDate !== undefined) { if (lc.dueDate !== undefined) {
await this.updateLabCaseDueDateInTx(tx, lc.id, organizationId, lc.dueDate); await this.updateLabCaseDueDateInTx(
tx,
lc.id,
organizationId,
actorUserId,
lc.dueDate,
);
} }
continue; continue;
} }
@@ -710,7 +690,12 @@ export class TreatmentsService {
treatment: { organizationId }, treatment: { organizationId },
}, },
include: { include: {
treatment: { select: { providerUserId: true } }, treatment: {
select: {
providerUserId: true,
appointment: { select: { providerUserId: true } },
},
},
sends: { select: { organizationId: true } }, sends: { select: { organizationId: true } },
details: { details: {
include: { include: {
@@ -739,11 +724,8 @@ export class TreatmentsService {
assertCompleteToothProsthesisMap(labCase); assertCompleteToothProsthesisMap(labCase);
if (labCase.treatment.providerUserId !== actorUserId) { if (!isActorTreatmentProvider(labCase.treatment, actorUserId)) {
const membership = await this.getMembership(actorUserId, organizationId); throw new ForbiddenException('Only the treatment provider can send this lab case');
if (!membership?.isOwner) {
throw new ForbiddenException('Only the appointment provider can send this lab case');
}
} }
const linkedOrgIds = await this.getActiveLinkedOrganizationIds(organizationId); const linkedOrgIds = await this.getActiveLinkedOrganizationIds(organizationId);
@@ -821,7 +803,13 @@ export class TreatmentsService {
await this.assertCanEditTreatment(actorUserId, organizationId); await this.assertCanEditTreatment(actorUserId, organizationId);
const updated = await this.prisma.$transaction(async (tx) => { 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({ return tx.labCase.findFirstOrThrow({
where: { id: labCaseId }, where: { id: labCaseId },
include: { include: {
@@ -862,12 +850,16 @@ export class TreatmentsService {
tx: Prisma.TransactionClient, tx: Prisma.TransactionClient,
labCaseId: string, labCaseId: string,
organizationId: string, organizationId: string,
actorUserId: string,
dueDateInput?: string | null, dueDateInput?: string | null,
) { ) {
const labCase = await tx.labCase.findFirst({ const labCase = await tx.labCase.findFirst({
where: { where: {
id: labCaseId, id: labCaseId,
treatment: { organizationId }, treatment: {
organizationId,
...treatmentProviderScopeWhere(actorUserId),
},
sentAt: { not: null }, sentAt: { not: null },
}, },
include: { tasks: { select: { status: true } } }, include: { tasks: { select: { status: true } } },
@@ -902,7 +894,7 @@ export class TreatmentsService {
actorUserId: string, actorUserId: string,
) { ) {
await this.assertCanEditTreatment(actorUserId, organizationId); await this.assertCanEditTreatment(actorUserId, organizationId);
await this.ensureAppointmentProvider(appointmentId, organizationId, actorUserId, true); await this.ensureAppointmentProvider(appointmentId, organizationId, actorUserId);
if (!detailClientKey?.trim()) { if (!detailClientKey?.trim()) {
throw new BadRequestException('detailClientKey is required'); throw new BadRequestException('detailClientKey is required');
@@ -956,7 +948,14 @@ export class TreatmentsService {
where: { where: {
id: attachmentId, id: attachmentId,
OR: [ OR: [
{ detail: { treatment: { organizationId } } }, {
detail: {
treatment: {
organizationId,
...treatmentProviderScopeWhere(actorUserId),
},
},
},
{ appointmentId: { not: null } }, { appointmentId: { not: null } },
], ],
}, },
@@ -975,7 +974,11 @@ export class TreatmentsService {
if (!attachment.detail && attachment.appointmentId) { if (!attachment.detail && attachment.appointmentId) {
const appointment = await this.prisma.appointment.findFirst({ const appointment = await this.prisma.appointment.findFirst({
where: { id: attachment.appointmentId, organizationId }, where: {
id: attachment.appointmentId,
organizationId,
providerUserId: actorUserId,
},
select: { id: true }, select: { id: true },
}); });
if (!appointment) { if (!appointment) {
@@ -1228,7 +1231,6 @@ export class TreatmentsService {
appointmentId: string, appointmentId: string,
organizationId: string, organizationId: string,
actorUserId: string, actorUserId: string,
requireProviderMatch: boolean,
) { ) {
const appointment = await this.prisma.appointment.findFirst({ const appointment = await this.prisma.appointment.findFirst({
where: { id: appointmentId, organizationId }, where: { id: appointmentId, organizationId },
@@ -1244,12 +1246,8 @@ export class TreatmentsService {
throw new NotFoundException('Appointment not found'); throw new NotFoundException('Appointment not found');
} }
if (requireProviderMatch) { if (appointment.providerUserId !== actorUserId) {
const membership = await this.getMembership(actorUserId, organizationId); throw new ForbiddenException('You are not the provider for this appointment');
const isOwner = membership?.isOwner ?? false;
if (!isOwner && appointment.providerUserId !== actorUserId) {
throw new ForbiddenException('You are not the provider for this appointment');
}
} }
return appointment; return appointment;