diff --git a/.cursor/rules/dyolink-overview.mdc b/.cursor/rules/dyolink-overview.mdc index 92cb51c..47c0d4f 100644 --- a/.cursor/rules/dyolink-overview.mdc +++ b/.cursor/rules/dyolink-overview.mdc @@ -33,3 +33,7 @@ Treatment-type colors and labels: `components/shared/treatmentTypeDisplay.ts` + ## Today dashboard CLINIC + LAB KPIs/charts in `modules/today/today.service.ts`. Deep links: `components/today/today-deep-links.ts`. Task KPIs need `TAB_TASKS_READ` (no owner bypass). Skill: `.cursor/skills/today-dashboard/SKILL.md`. + +## Lab case share link + +QR + URL for **sent** cases; focus page `/lab-case/[token]`. Auth redirect via `postAuthRedirect.ts`. Skill: `.cursor/skills/lab-case-share-link/SKILL.md`. diff --git a/.cursor/rules/lab-case-share-link.mdc b/.cursor/rules/lab-case-share-link.mdc new file mode 100644 index 0000000..918f56c --- /dev/null +++ b/.cursor/rules/lab-case-share-link.mdc @@ -0,0 +1,18 @@ +--- +description: Lab case QR share link — token, access API, focus page, auth redirect +globs: frontend/src/app/**/lab-case/**,frontend/src/components/ui/lab/CaseTasksFocusView.tsx,frontend/src/components/ui/lab/LabCaseShareQr*.tsx,frontend/src/lib/api/lab-case-access.ts,frontend/src/lib/auth/postAuthRedirect.ts,frontend/src/app/**/login/page.tsx,backend/src/modules/cases/lab-case-access.*,backend/src/common/lab-case-access-token.ts +alwaysApply: false +--- + +# Lab case share link + +- **Token:** `LabCase.accessToken` on first ship; `shareUrl` on case detail only when sent. +- **QR:** client-side `react-qr-code` in frontend — ❌ no backend QR endpoint. +- **Cases panel:** attachment preview left, QR thumb right, one row; dialog for QR + copy link. +- **Route:** `/lab-case/[token]` → `CaseTasksFocusView` (dashboard layout, auth required). +- **Access:** lab (`TAB_TASKS_*`) or clinic treatment **provider** (`TAB_TREATMENT_EDIT` + `isActorTreatmentProvider`); else `LAB_CASE_ACCESS_DENIED`. +- **Task status on link page:** same assignee rule as Tasks — `canEditLabTaskStatus`; backend `PATCH /tasks/:id` enforces assignee. +- **Auth redirect:** `postAuthRedirect.ts`; dashboard stores path on logout redirect; login consumes **once** after org ready — ❌ do not consume in `useAuth.login()`. +- **Login page:** wrap `useSearchParams` in `` for `next build`. + +Skill: `.cursor/skills/lab-case-share-link/SKILL.md` diff --git a/.cursor/rules/lab-cases.mdc b/.cursor/rules/lab-cases.mdc index e7ab10c..61db4cf 100644 --- a/.cursor/rules/lab-cases.mdc +++ b/.cursor/rules/lab-cases.mdc @@ -1,6 +1,6 @@ --- description: Lab Cases tab — prosthesis filter, auto-select, list cards -globs: frontend/src/components/ui/lab/CasesPage.tsx,frontend/src/components/ui/lab/CaseDetailPanel.tsx,frontend/src/components/ui/lab/LabCaseProsthesisGroupsList.tsx,frontend/src/components/lab/caseDetailUtils.ts,backend/src/modules/cases/** +globs: frontend/src/components/ui/lab/CasesPage.tsx,frontend/src/components/ui/lab/CaseDetailPanel.tsx,frontend/src/components/ui/lab/LabCaseProsthesisGroupsList.tsx,frontend/src/components/ui/lab/LabCaseShareQr*.tsx,frontend/src/components/lab/caseDetailUtils.ts,backend/src/modules/cases/** alwaysApply: false --- @@ -10,5 +10,6 @@ alwaysApply: false - **Auto-select** newest case on tab open; right panel never empty when list has items; `?caseId=` deep link overrides. - **List cards:** `LabCaseProsthesisGroupsList` (catalog color + teeth); same component as Treatment shipments rail; no patient mobile on card. - **Assignment** in detail only (`TAB_CASES_EDIT`); task status changes on Tasks tab. +- **Share link:** QR + URL when case is sent; token API + focus page — see `.cursor/skills/lab-case-share-link/SKILL.md`. Skill: `.cursor/skills/lab-cases/SKILL.md` diff --git a/.cursor/rules/lab-tasks.mdc b/.cursor/rules/lab-tasks.mdc index 884a1a9..5f8114a 100644 --- a/.cursor/rules/lab-tasks.mdc +++ b/.cursor/rules/lab-tasks.mdc @@ -11,6 +11,8 @@ alwaysApply: false - **Prosthesis colors:** `PROSTHESIS_TYPE_COLORS` + `prosthesisTypeBadgeStyleFromCatalog` — never row index. - **Important first:** `pinImportant=true` (sort pin, not filter). - **Task assignment:** assign in Cases (`TAB_CASES_EDIT`); status edit on Tasks only for assignee or unassigned tasks; others see “Assigned to {name}”. +- **Grouped comments:** when `sortBy=date`, one comments icon on **case header** (not per row); flat sort keeps per-row comments. +- **Share link page** (`/lab-case/[token]`): reuses `TaskRow` + assignee rules; comments via token API — see `.cursor/skills/lab-case-share-link/SKILL.md`. - **Case due dates:** clinic sets in Treatment lab dispatch; lab sees on Cases/Tasks; `overdue` filter + `sortBy=dueDate` on Tasks. - **Mobile UX:** `LAB_TASK_STATUS_SELECT_CLASS` (44px tap target on small screens); `TaskCaseGroupHeader` sticky while scrolling grouped tasks; filter selects use same touch sizing on Tasks. - **Show in case:** `GET /tasks/locate-page` finds page in full list; highlight + scroll. diff --git a/.cursor/skills/lab-case-share-link/SKILL.md b/.cursor/skills/lab-case-share-link/SKILL.md new file mode 100644 index 0000000..9be6bbc --- /dev/null +++ b/.cursor/skills/lab-case-share-link/SKILL.md @@ -0,0 +1,74 @@ +--- +name: dyolink-lab-case-share-link +description: Lab case QR share link — access token, focus page, auth redirect, Cases QR UI. Use when changing share links, /lab-case/[token], lab-case-access API, or post-login redirect from share URLs. +--- + +# Lab case share link + +Shipped cases get a stable **access token** and share URL. QR + link open a focused tasks page with comments — not a public page; JWT + org context required. + +## Data & token lifecycle + +- **Schema:** `LabCase.accessToken` (`String?`, `@unique`). +- **On first ship:** `treatments.service` sets `accessToken` + `sentAt` in the same update. +- **Backfill:** `LabCaseAccessService.ensureAccessToken()` for older sent cases when building case detail `shareUrl`. +- **URL:** `buildLabCaseShareUrl(token, locale)` → `{FRONTEND_URL}/{locale}/lab-case/{token}` (`backend/src/common/lab-case-access-token.ts`). + +## Backend API (`LabCaseAccessController`) + +Base path: `/lab-cases/access/:token` (JWT + selected org required). + +| Route | Purpose | +|-------|---------| +| `GET :token` | Session metadata (access mode, permissions, patient, prosthesis groups) | +| `GET :token/tasks` | All case tasks (includes assignee for status rules) | +| `GET/POST :token/comments` | List / add comments | +| `PATCH :token/comments/:id/visibility` | Lab only — clinic visibility toggle | + +**Access resolution** (`lab-case-access.service.ts`): + +| Actor | View | Edit task status | Comments | +|-------|------|------------------|----------| +| Lab + `TAB_TASKS_READ`/`EDIT` | ✅ | ✅ if `TAB_TASKS_EDIT` + assignee rules | Post/toggle if `TAB_TASKS_EDIT` | +| Clinic + `TAB_TREATMENT_EDIT` + **treatment provider** | ✅ | ❌ read-only | Post only (no visibility toggle) | +| Everyone else | ❌ `LAB_CASE_ACCESS_DENIED` | | | + +Task status updates use **`PATCH /tasks/:id`** (not token routes) — same assignee rule as Tasks tab: unassigned or assigned-to-you only. + +## Frontend + +| Piece | Path | +|-------|------| +| Focus page | `app/[locale]/(dashboard)/lab-case/[token]/page.tsx` → `CaseTasksFocusView` | +| API client | `lib/api/lab-case-access.ts` | +| QR UI | `LabCaseShareQrCode`, `LabCaseShareQrDialog`, thumb in `CaseDetailPanel` | +| QR package | `react-qr-code` (frontend only — no backend QR generation) | + +**Cases detail header:** attachment preview **left**, QR thumb **right**, same row (`w-24 sm:w-32`). QR opens dialog (large QR + URL + copy); no inline copy on panel. Only when `shareUrl` present (sent case). + +**Share focus page:** grouped tasks (reuse `TaskRow`, `TaskCaseGroupHeader`); comments section via `LabCaseCommentsPanel` + token API adapters. Access denied → inline message (`asApiError` for `LAB_CASE_ACCESS_DENIED`). + +## Auth redirect (logged out → login → back) + +Helpers: `lib/auth/postAuthRedirect.ts` (`sessionStorage` key `authRedirect`). + +1. Logged-out user hits `/lab-case/{token}` → dashboard layout stores path + `router.replace('/login?from=…')`. +2. Login page `useSearchParams` (inside **Suspense**) calls `storeAuthRedirectFromPath(from)`. +3. After login + org ready: **one** `consumeAuthRedirect()` on login page (wait for `!isLoading` and org selected). +4. **Do not** `consumeAuthRedirect()` inside `useAuth.login()` — double consume sends user to `/today`. +5. Multi-org: redirect stays in storage until `selectOrganization()` consumes it. + +## Tasks tab interaction + +Grouped sort (`sortBy=date`): **one comments control on case header** (`expandedCommentsCaseId`), not per task row. Flat sort unchanged (`showCommentsButton={flatMode}`). + +## i18n + +- `cases.*` — QR dialog strings (`shareQrDialogTitle`, `copyShareLink`, …) +- `labCaseAccess.*` — focus page strings +- `errors.LAB_CASE_ACCESS_DENIED` — all three locales + +## Verify + +- Backend: `npm run build`; apply migration for `accessToken`. +- Frontend: `npx tsc --noEmit`; `next build` (login page Suspense for `useSearchParams`). diff --git a/.cursor/skills/lab-cases/SKILL.md b/.cursor/skills/lab-cases/SKILL.md index a29da96..586f6e6 100644 --- a/.cursor/skills/lab-cases/SKILL.md +++ b/.cursor/skills/lab-cases/SKILL.md @@ -1,6 +1,6 @@ --- name: dyolink-lab-cases -description: Lab Cases tab — list, filters, detail panel, assignment, card UX. Use when changing CasesPage, cases API, or case list cards. +description: Lab Cases tab — list, filters, detail panel, assignment, share QR, card UX. Use when changing CasesPage, cases API, CaseDetailPanel, or case list cards. --- # Lab Cases tab @@ -36,6 +36,7 @@ List item shape: `prosthesisGroups: { prosthesisTypeCode, teeth[] }[]` from task - Task assignment: `PATCH /cases/:caseId/tasks/:taskId/assign` (`TAB_CASES_EDIT`) - Comments: shared `LabCaseCommentsPanel` + `tasksApi` comment routes - Mark read: `POST /notifications/mark-case-read` on select (Cases tab badge) +- **Share link (sent cases):** `shareUrl` on detail; attachment preview left + QR thumb right; `LabCaseShareQrDialog` (`react-qr-code`). Full flow: `.cursor/skills/lab-case-share-link/SKILL.md`. ## Permissions diff --git a/.cursor/skills/lab-tasks/SKILL.md b/.cursor/skills/lab-tasks/SKILL.md index 6d1e4a8..425d0ba 100644 --- a/.cursor/skills/lab-tasks/SKILL.md +++ b/.cursor/skills/lab-tasks/SKILL.md @@ -26,6 +26,8 @@ Other sorts use flat list on the frontend; `stepOrder asc` is still a tiebreaker Components: `TaskCaseGroupHeader`, `TaskProsthesisGroupHeader`, `TaskRow`. +**Grouped comments (`sortBy=date`):** single comments button on `TaskCaseGroupHeader`; panel expands below header (`expandedCommentsCaseId`). Per-task comments button only in **flat** sort (`showCommentsButton={flatMode}`). + ## Prosthesis colors - Map: [`catalog-type-colors.ts`](frontend/src/components/shared/catalog-type-colors.ts) → `PROSTHESIS_TYPE_COLORS` (one hex per catalog code). @@ -48,7 +50,7 @@ Components: `TaskCaseGroupHeader`, `TaskProsthesisGroupHeader`, `TaskRow`. **Task assignment:** Managed in **Cases** (`TAB_CASES_EDIT`), not on Tasks tab. `PATCH /cases/:caseId/tasks/:taskId/assign`; assignable staff via `GET /cases/assignable-staff` (members with `TAB_TASKS_EDIT`, including participating owner). Case detail task row: step label, status badge, assign dropdown, and last-updated line on one compact row. -**Tasks visibility & status edit:** All tasks remain visible to every user with task access (no hiding assigned tasks). **Unassigned** tasks or tasks **assigned to you** → status dropdown when `TAB_TASKS_EDIT`. **Assigned to someone else** → read-only “Assigned to {name}” badge instead of the dropdown (backend rejects status PATCH). Managers assign/monitor in Cases. +**Tasks visibility & status edit:** All tasks remain visible to every user with task access (no hiding assigned tasks). **Unassigned** tasks or tasks **assigned to you** → status dropdown when `TAB_TASKS_EDIT`. **Assigned to someone else** → read-only “Assigned to {name}” badge instead of the dropdown (backend rejects status PATCH). Same rules on **lab case share link** page (`CaseTasksFocusView` + `canEditLabTaskStatus`). Managers assign/monitor in Cases. **Step completed filter:** Restricts to prosthesis groups `(labCaseId, treatmentDetailId, prosthesisTypeCode)` where that `workflowStepCode` task is `COMPLETED`. Combined with `status=IN_PROGRESS`, returns only in-progress tasks in those groups (completed step row hidden). diff --git a/AGENTS.md b/AGENTS.md index 3ace47a..e248ce1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,7 +51,13 @@ frontend/src/ **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 Cases tab:** Filter by **prosthesis type** (not treatment type); auto-select newest case on open; list cards use `LabCaseProsthesisGroupsList` (colored type + teeth, shared with Treatment rail). Deep link: `?caseId=`, `?clinicOrganizationId=`. See `.cursor/skills/lab-cases/SKILL.md`. +**Lab Cases tab:** Filter by **prosthesis type** (not treatment type); auto-select newest case on open; list cards use `LabCaseProsthesisGroupsList` (colored type + teeth, shared with Treatment rail). Deep link: `?caseId=`, `?clinicOrganizationId=`. **Share link:** QR + URL on sent cases (attachment left, QR right); opens `/lab-case/[token]` focus page. See `.cursor/skills/lab-cases/SKILL.md` and `.cursor/skills/lab-case-share-link/SKILL.md`. + +**Lab case share link (quick ref):** +- Token on first ship → `/{locale}/lab-case/{token}` after login. +- **Lab:** view/edit tasks (assignee rules), comments + visibility toggle. +- **Clinic:** treatment **provider** only — read-only tasks, can comment. +- Logged out → login with `?from=` → single `consumeAuthRedirect()` after org ready (not inside `useAuth.login()`). **Today dashboard:** KPIs + charts per org type/permissions; deep links via `today-deep-links.ts` (Tasks KPIs/charts, Staff highlight, case partners). See `.cursor/skills/today-dashboard/SKILL.md`. @@ -79,6 +85,7 @@ Errors: `AppException` + `ErrorCode` → frontend `getUserFacingError()`. Never | `.cursor/skills/treatment-workspace/` | Treatment tab: preview vs form, history, load flow, drafts | | `.cursor/skills/lab-tasks/` | Lab Tasks tab: sort, case grouping, step-completed filter, prosthesis colors | | `.cursor/skills/lab-cases/` | Lab Cases tab: prosthesis filter, auto-select, list cards, assignment | +| `.cursor/skills/lab-case-share-link/` | Case QR share link: access token, focus page, auth redirect, access rules | | `.cursor/skills/lab-notifications/` | Tab badges: LabCaseActivity, tab-counts API, read cursors | | `.cursor/skills/today-dashboard/` | Today tab: KPIs, charts, deep links, gadget registry | | `.cursor/skills/frontend-structure/` | Moving components, auditing folder layout | diff --git a/backend/package-lock.json b/backend/package-lock.json index 38d9f70..8757df9 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -41,6 +41,8 @@ "passport-local": "^1.0.0", "pg": "^8.18.0", "prisma": "^6.19.2", + "qrcode": "^1.5.4", + "react-qr-code": "^2.2.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "styled-components": "^6.3.11", @@ -59,6 +61,7 @@ "@types/multer": "^2.1.0", "@types/node": "^22.10.7", "@types/pg": "^8.16.0", + "@types/qrcode": "^1.5.5", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/styled-components": "^5.1.36", @@ -6290,6 +6293,16 @@ "pg-types": "^2.2.0" } }, + "node_modules/@types/qrcode": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@types/qrcode/-/qrcode-1.5.5.tgz", + "integrity": "sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/qs": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", @@ -7540,7 +7553,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7550,7 +7562,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -8163,7 +8174,6 @@ "version": "5.3.1", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -8441,7 +8451,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -8454,7 +8463,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -8777,6 +8785,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/dedent": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", @@ -8890,6 +8907,12 @@ "node": ">=0.3.1" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -9008,7 +9031,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/empathic": { @@ -10052,7 +10074,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -10670,7 +10691,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -12875,7 +12895,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -13189,6 +13208,15 @@ "node": ">=4" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/polished": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/polished/-/polished-4.3.1.tgz", @@ -13607,6 +13635,133 @@ ], "license": "MIT" }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmmirror.com/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode-generator": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/qrcode-generator/-/qrcode-generator-2.0.4.tgz", + "integrity": "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==", + "license": "MIT" + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/qrcode/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmmirror.com/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.15.1", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", @@ -13816,6 +13971,19 @@ "react-dom": "^16.8.0 || ^17 || ^18" } }, + "node_modules/react-qr-code": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/react-qr-code/-/react-qr-code-2.2.0.tgz", + "integrity": "sha512-e5nS0UUN22K3Nf8KBRUzemfdJ6OmnN5w+kbnj1lvJaol9RyVRFeGl05bCkxSN2ZegbLxjjYjX1+mmAoX9+fAhw==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.8.1", + "qrcode-generator": "^2.0.4" + }, + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-redux": { "version": "8.1.3", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-8.1.3.tgz", @@ -14041,7 +14209,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -14057,6 +14224,12 @@ "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -14341,6 +14514,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -14647,7 +14826,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -14678,7 +14856,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -16169,6 +16346,12 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -16190,7 +16373,6 @@ "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", diff --git a/backend/package.json b/backend/package.json index c4fcca5..9f1e5a4 100644 --- a/backend/package.json +++ b/backend/package.json @@ -80,8 +80,6 @@ "@types/multer": "^2.1.0", "@types/node": "^22.10.7", "@types/pg": "^8.16.0", - "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3", "@types/styled-components": "^5.1.36", "@types/supertest": "^6.0.2", "eslint": "^9.18.0", diff --git a/backend/prisma/migrations/20260714153000_lab_case_access_token/migration.sql b/backend/prisma/migrations/20260714153000_lab_case_access_token/migration.sql new file mode 100644 index 0000000..f12c6e1 --- /dev/null +++ b/backend/prisma/migrations/20260714153000_lab_case_access_token/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "lab_cases" ADD COLUMN "accessToken" TEXT; + +-- CreateIndex +CREATE UNIQUE INDEX "lab_cases_accessToken_key" ON "lab_cases"("accessToken"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 02be3d9..48d1409 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -217,6 +217,7 @@ model LabCase { sentAt DateTime? dueDate DateTime? isImportant Boolean @default(false) + accessToken String? @unique treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade) details LabCaseDetail[] diff --git a/backend/src/common/errors/error-codes.ts b/backend/src/common/errors/error-codes.ts index d1413d5..51932fa 100644 --- a/backend/src/common/errors/error-codes.ts +++ b/backend/src/common/errors/error-codes.ts @@ -42,6 +42,7 @@ export const ErrorCode = { PERMISSION_ACCESS_STAFF: 'PERMISSION_ACCESS_STAFF', PERMISSION_EDIT_STAFF: 'PERMISSION_EDIT_STAFF', PERMISSION_ORG_NOT_FOUND: 'PERMISSION_ORG_NOT_FOUND', + LAB_CASE_ACCESS_DENIED: 'LAB_CASE_ACCESS_DENIED', // Validation VALIDATION_FAILED: 'VALIDATION_FAILED', diff --git a/backend/src/common/lab-case-access-token.ts b/backend/src/common/lab-case-access-token.ts new file mode 100644 index 0000000..aec94b1 --- /dev/null +++ b/backend/src/common/lab-case-access-token.ts @@ -0,0 +1,11 @@ +import { randomBytes } from 'crypto'; + +export function generateLabCaseAccessToken(): string { + return randomBytes(32).toString('base64url'); +} + +export function buildLabCaseShareUrl(accessToken: string, locale = 'en'): string { + const appUrl = (process.env.FRONTEND_URL || 'http://localhost:3001').replace(/\/$/, ''); + const normalizedLocale = locale.trim() || 'en'; + return `${appUrl}/${normalizedLocale}/lab-case/${accessToken}`; +} diff --git a/backend/src/modules/cases/cases.module.ts b/backend/src/modules/cases/cases.module.ts index 2f7ddc5..f5ac49d 100644 --- a/backend/src/modules/cases/cases.module.ts +++ b/backend/src/modules/cases/cases.module.ts @@ -3,13 +3,18 @@ import { PrismaService } from '../../../prisma/prisma.service'; import { LabOrgGuard } from '../../common/guards/lab-org.guard'; import { NotificationsModule } from '../notifications/notifications.module'; import { ProsthesisCatalogModule } from '../prosthesis-catalog/prosthesis-catalog.module'; +import { TasksModule } from '../tasks/tasks.module'; +import { CatalogModule } from '../catalog/catalog.module'; +import { LabCaseCommentsModule } from '../lab-case-comments/lab-case-comments.module'; import { CasesController } from './cases.controller'; import { CasesService } from './cases.service'; +import { LabCaseAccessController } from './lab-case-access.controller'; +import { LabCaseAccessService } from './lab-case-access.service'; @Module({ - imports: [ProsthesisCatalogModule, NotificationsModule], - controllers: [CasesController], - providers: [CasesService, PrismaService, LabOrgGuard], - exports: [CasesService], + imports: [ProsthesisCatalogModule, NotificationsModule, TasksModule, CatalogModule, LabCaseCommentsModule], + controllers: [LabCaseAccessController, CasesController], + providers: [CasesService, LabCaseAccessService, PrismaService, LabOrgGuard], + exports: [CasesService, LabCaseAccessService], }) export class CasesModule {} diff --git a/backend/src/modules/cases/cases.service.ts b/backend/src/modules/cases/cases.service.ts index bba9f53..4f7b386 100644 --- a/backend/src/modules/cases/cases.service.ts +++ b/backend/src/modules/cases/cases.service.ts @@ -22,6 +22,7 @@ import { normalizeTaskTeeth } from './lab-case-task.util'; import { hasEffectivePermission } from '../../common/membership-permissions'; import { LAB_CASES_TAB_ACTIVITY_TYPES } from '../../common/lab-case-activity'; import { LabCaseActivityService } from '../notifications/lab-case-activity.service'; +import { LabCaseAccessService } from './lab-case-access.service'; const labCaseListInclude = { treatment: { @@ -95,6 +96,7 @@ export class CasesService { private readonly prosthesisCatalog: ProsthesisCatalogService, private readonly catalogLabels: CatalogLabelService, private readonly labCaseActivity: LabCaseActivityService, + private readonly labCaseAccess: LabCaseAccessService, ) {} getOrganizationIdFromUser(user: { organizationId?: string }) { @@ -605,6 +607,12 @@ export class CasesService { locale, ); const tasksByTooth = this.groupTasks(lc.tasks, prosthesisLabels); + const accessToken = lc.sentAt + ? await this.labCaseAccess.ensureAccessToken(lc.id) + : null; + const shareUrl = accessToken + ? this.labCaseAccess.buildShareUrl(accessToken, locale) + : null; return { id: lc.id, @@ -612,6 +620,7 @@ export class CasesService { dueDate: lc.dueDate?.toISOString() ?? null, isOverdue: isLabCaseOverdue(lc.dueDate, lc.tasks), isImportant: lc.isImportant, + shareUrl, clinic: lc.treatment.organization, patient: lc.treatment.patient, appointmentStartAt: lc.treatment.appointment?.startAt.toISOString() ?? null, diff --git a/backend/src/modules/cases/lab-case-access.controller.ts b/backend/src/modules/cases/lab-case-access.controller.ts new file mode 100644 index 0000000..9603afe --- /dev/null +++ b/backend/src/modules/cases/lab-case-access.controller.ts @@ -0,0 +1,89 @@ +import { Body, Controller, Get, HttpStatus, Param, Patch, Post, Req, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { AppException, ErrorCode } from '../../common/errors'; +import { LabCaseAccessService } from './lab-case-access.service'; +import { + CreateLabCaseCommentDto, + SetCommentVisibilityDto, +} from '../lab-case-comments/dto/lab-case-comment.dto'; + +@ApiTags('lab-cases') +@ApiBearerAuth('JWT-auth') +@UseGuards(JwtAuthGuard) +@Controller('lab-cases/access') +export class LabCaseAccessController { + constructor(private readonly accessService: LabCaseAccessService) {} + + private requireOrganizationId(req: { user?: { organizationId?: string } }): string { + const organizationId = req.user?.organizationId; + if (!organizationId) { + throw new AppException(ErrorCode.AUTH_ORG_NOT_SELECTED, HttpStatus.BAD_REQUEST); + } + return organizationId; + } + + @Get(':token') + @ApiOperation({ summary: 'Resolve a lab case share link for the current user' }) + resolve(@Param('token') token: string, @Req() req) { + return this.accessService.resolveByToken( + token, + req.user.id, + this.requireOrganizationId(req), + req.user.language, + ); + } + + @Get(':token/tasks') + @ApiOperation({ summary: 'List all tasks for a lab case share link' }) + listTasks(@Param('token') token: string, @Req() req) { + return this.accessService.listTasksByToken( + token, + req.user.id, + this.requireOrganizationId(req), + req.user.language, + ); + } + + @Get(':token/comments') + @ApiOperation({ summary: 'List comments for a lab case share link' }) + listComments(@Param('token') token: string, @Req() req) { + return this.accessService.listCommentsByToken( + token, + req.user.id, + this.requireOrganizationId(req), + ); + } + + @Post(':token/comments') + @ApiOperation({ summary: 'Add a comment via a lab case share link' }) + addComment( + @Param('token') token: string, + @Body() dto: CreateLabCaseCommentDto, + @Req() req, + ) { + return this.accessService.addCommentByToken( + token, + req.user.id, + this.requireOrganizationId(req), + dto, + ); + } + + @Patch(':token/comments/:commentId/visibility') + @ApiOperation({ summary: 'Toggle clinic visibility for a lab comment on a share link' }) + setCommentVisibility( + @Param('token') token: string, + @Param('commentId') commentId: string, + @Body() dto: SetCommentVisibilityDto, + @Req() req, + ) { + return this.accessService.setCommentVisibilityByToken( + token, + commentId, + req.user.id, + this.requireOrganizationId(req), + dto.visibleToClinic, + ); + } +} diff --git a/backend/src/modules/cases/lab-case-access.service.ts b/backend/src/modules/cases/lab-case-access.service.ts new file mode 100644 index 0000000..6ab178d --- /dev/null +++ b/backend/src/modules/cases/lab-case-access.service.ts @@ -0,0 +1,329 @@ +import { + HttpStatus, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { CatalogEntityKind, Prisma } from '@prisma/client'; +import { PrismaService } from '../../../prisma/prisma.service'; +import { + buildLabCaseShareUrl, + generateLabCaseAccessToken, +} from '../../common/lab-case-access-token'; +import { AppException, ErrorCode } from '../../common/errors'; +import { hasEffectivePermission } from '../../common/membership-permissions'; +import { + CatalogLabelService, + normalizeCatalogLocale, +} from '../catalog/catalog-label.service'; +import { isActorTreatmentProvider } from '../../common/treatment-provider-scope'; +import { normalizeTaskTeeth } from './lab-case-task.util'; +import { isLabCaseOverdue } from '../../common/lab-case-due-date'; +import { TasksService } from '../tasks/tasks.service'; +import { LabCaseCommentsService } from '../lab-case-comments/lab-case-comments.service'; +import { CreateLabCaseCommentDto } from '../lab-case-comments/dto/lab-case-comment.dto'; + +const accessCaseInclude = { + treatment: { + select: { + providerUserId: true, + organization: { select: { id: true, name: true } }, + patient: { select: { id: true, firstName: true, lastName: true } }, + appointment: { select: { providerUserId: true } }, + }, + }, + sends: { + orderBy: [{ sentAt: 'asc' as const }], + include: { organization: { select: { id: true, name: true } } }, + }, + tasks: { select: { status: true } }, +} satisfies Prisma.LabCaseInclude; + +type ResolvedAccess = + | { + kind: 'lab'; + canEditTaskStatus: boolean; + canPostComments: boolean; + canToggleCommentVisibility: boolean; + } + | { + kind: 'clinic'; + canEditTaskStatus: false; + canPostComments: boolean; + canToggleCommentVisibility: false; + } + | { kind: 'denied' }; + +@Injectable() +export class LabCaseAccessService { + constructor( + private readonly prisma: PrismaService, + private readonly catalogLabels: CatalogLabelService, + private readonly tasksService: TasksService, + private readonly commentsService: LabCaseCommentsService, + ) {} + + async ensureAccessToken(labCaseId: string): Promise { + const labCase = await this.prisma.labCase.findUnique({ + where: { id: labCaseId }, + select: { sentAt: true, accessToken: true }, + }); + if (!labCase?.sentAt) return null; + if (labCase.accessToken) return labCase.accessToken; + + const accessToken = generateLabCaseAccessToken(); + await this.prisma.labCase.update({ + where: { id: labCaseId }, + data: { accessToken }, + }); + return accessToken; + } + + buildShareUrl(accessToken: string, locale?: string | null): string { + return buildLabCaseShareUrl(accessToken, locale ?? 'en'); + } + + async resolveByToken( + token: string, + actorUserId: string, + organizationId: string, + localeInput?: string | null, + ) { + const labCase = await this.findCaseByToken(token); + const access = await this.resolveAccess(labCase, actorUserId, organizationId); + + if (access.kind === 'denied') { + throw new AppException(ErrorCode.LAB_CASE_ACCESS_DENIED, HttpStatus.FORBIDDEN); + } + + const locale = normalizeCatalogLocale(localeInput); + const prosthesisCodes = [ + ...new Set( + ( + await this.prisma.labCaseTask.findMany({ + where: { labCaseId: labCase.id }, + select: { prosthesisTypeCode: true }, + }) + ) + .map((t) => t.prosthesisTypeCode) + .filter(Boolean), + ), + ]; + const prosthesisLabels = await this.catalogLabels.resolveLabels( + CatalogEntityKind.PROSTHESIS_TYPE, + prosthesisCodes, + locale, + ); + + const prosthesisGroups = await this.buildProsthesisGroups(labCase.id, prosthesisLabels); + + return { + success: true, + data: { + labCaseId: labCase.id, + accessMode: access.kind, + canEditTaskStatus: access.canEditTaskStatus, + canPostComments: access.canPostComments, + canToggleCommentVisibility: access.canToggleCommentVisibility, + shareUrl: this.buildShareUrl(token, locale), + sentAt: labCase.sentAt?.toISOString() ?? null, + dueDate: labCase.dueDate?.toISOString() ?? null, + isOverdue: isLabCaseOverdue(labCase.dueDate, labCase.tasks), + isImportant: labCase.isImportant, + clinic: labCase.treatment.organization, + lab: labCase.sends[0]?.organization ?? null, + patient: labCase.treatment.patient, + prosthesisGroups, + }, + }; + } + + async listTasksByToken( + token: string, + actorUserId: string, + organizationId: string, + localeInput?: string | null, + ) { + const labCase = await this.findCaseByToken(token); + const access = await this.resolveAccess(labCase, actorUserId, organizationId); + + if (access.kind === 'denied') { + throw new AppException(ErrorCode.LAB_CASE_ACCESS_DENIED, HttpStatus.FORBIDDEN); + } + + return this.tasksService.listTasksForLabCaseId(labCase.id, localeInput); + } + + async listCommentsByToken( + token: string, + actorUserId: string, + organizationId: string, + ) { + const labCase = await this.findCaseByToken(token); + const access = await this.resolveAccess(labCase, actorUserId, organizationId); + + if (access.kind === 'denied') { + throw new AppException(ErrorCode.LAB_CASE_ACCESS_DENIED, HttpStatus.FORBIDDEN); + } + + if (access.kind === 'lab') { + return this.commentsService.listForLabViewer(labCase.id, organizationId, actorUserId); + } + + return this.commentsService.listForClinicTreatmentCase( + labCase.id, + organizationId, + actorUserId, + ); + } + + async addCommentByToken( + token: string, + actorUserId: string, + organizationId: string, + dto: CreateLabCaseCommentDto, + ) { + const labCase = await this.findCaseByToken(token); + const access = await this.resolveAccess(labCase, actorUserId, organizationId); + + if (access.kind === 'denied' || !access.canPostComments) { + throw new AppException(ErrorCode.LAB_CASE_ACCESS_DENIED, HttpStatus.FORBIDDEN); + } + + if (access.kind === 'lab') { + return this.commentsService.addForLab(labCase.id, organizationId, actorUserId, dto); + } + + return this.commentsService.addForClinicTreatmentCase( + labCase.id, + organizationId, + actorUserId, + dto, + ); + } + + async setCommentVisibilityByToken( + token: string, + commentId: string, + actorUserId: string, + organizationId: string, + visibleToClinic: boolean, + ) { + const labCase = await this.findCaseByToken(token); + const access = await this.resolveAccess(labCase, actorUserId, organizationId); + + if (access.kind !== 'lab' || !access.canToggleCommentVisibility) { + throw new AppException(ErrorCode.LAB_CASE_ACCESS_DENIED, HttpStatus.FORBIDDEN); + } + + return this.commentsService.setVisibility( + commentId, + organizationId, + actorUserId, + visibleToClinic, + ); + } + + private async findCaseByToken(token: string) { + const labCase = await this.prisma.labCase.findFirst({ + where: { accessToken: token, sentAt: { not: null } }, + include: accessCaseInclude, + }); + + if (!labCase) { + throw new NotFoundException('Case not found'); + } + + return labCase; + } + + private async resolveAccess( + labCase: Prisma.LabCaseGetPayload<{ include: typeof accessCaseInclude }>, + actorUserId: string, + organizationId: string, + ): Promise { + const clinicOrgId = labCase.treatment.organization.id; + const labOrgId = + labCase.destinationOrganizationId ?? labCase.sends[0]?.organizationId ?? null; + + if (!labOrgId) { + return { kind: 'denied' }; + } + + if (organizationId === labOrgId) { + const membership = await this.getMembership(actorUserId, labOrgId); + if (!membership) return { kind: 'denied' }; + if ( + hasEffectivePermission(membership, 'TAB_TASKS_READ') || + hasEffectivePermission(membership, 'TAB_TASKS_EDIT') + ) { + const canEdit = hasEffectivePermission(membership, 'TAB_TASKS_EDIT'); + return { + kind: 'lab', + canEditTaskStatus: canEdit, + canPostComments: canEdit, + canToggleCommentVisibility: canEdit, + }; + } + return { kind: 'denied' }; + } + + if (organizationId === clinicOrgId) { + const membership = await this.getMembership(actorUserId, clinicOrgId); + if (!membership) return { kind: 'denied' }; + if (!hasEffectivePermission(membership, 'TAB_TREATMENT_EDIT')) { + return { kind: 'denied' }; + } + if (!isActorTreatmentProvider(labCase.treatment, actorUserId)) { + return { kind: 'denied' }; + } + return { + kind: 'clinic', + canEditTaskStatus: false, + canPostComments: true, + canToggleCommentVisibility: false, + }; + } + + return { kind: 'denied' }; + } + + private async buildProsthesisGroups( + labCaseId: string, + prosthesisLabels: Map, + ) { + const tasks = await this.prisma.labCaseTask.findMany({ + where: { labCaseId }, + select: { prosthesisTypeCode: true, teeth: true }, + orderBy: [{ prosthesisTypeCode: 'asc' }], + }); + + const byCode = new Map(); + for (const task of tasks) { + if (!task.prosthesisTypeCode) continue; + const teeth = normalizeTaskTeeth(task.teeth); + const list = byCode.get(task.prosthesisTypeCode) ?? []; + list.push(...teeth); + byCode.set(task.prosthesisTypeCode, list); + } + + return [...byCode.entries()].map(([prosthesisTypeCode, teeth]) => ({ + prosthesisTypeCode, + prosthesisTypeLabel: prosthesisLabels.get(prosthesisTypeCode) ?? prosthesisTypeCode, + teeth: [...new Set(teeth)].sort((a, b) => a.localeCompare(b, undefined, { numeric: true })), + })); + } + + private async getMembership(userId: string, organizationId: string) { + return this.prisma.membership.findFirst({ + where: { + userId, + organizationId, + OR: [{ isOwner: true }, { isActive: true }], + }, + include: { + permissions: { include: { permission: true } }, + organization: { include: { type: true, plan: true } }, + }, + }); + } +} 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 f015204..60e5a9d 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 @@ -34,6 +34,12 @@ export class LabCaseCommentsService { return { success: true, data: comments.map((c) => this.mapComment(c, LabCaseCommentSide.LAB)) }; } + async listForLabViewer(caseId: string, labOrganizationId: string, actorUserId: string) { + await this.assertLabCanViewCase(caseId, labOrganizationId, actorUserId); + const comments = await this.fetchComments(caseId); + return { success: true, data: comments.map((c) => this.mapComment(c, LabCaseCommentSide.LAB)) }; + } + async addForLab( caseId: string, labOrganizationId: string, @@ -219,6 +225,18 @@ export class LabCaseCommentsService { caseId: string, labOrganizationId: string, actorUserId: string, + ) { + await this.assertLabCanViewCase(caseId, labOrganizationId, actorUserId); + const membership = await this.getLabMembership(actorUserId, labOrganizationId); + if (!hasEffectivePermission(membership, 'TAB_TASKS_EDIT')) { + throw new ForbiddenException('You do not have access to task comments'); + } + } + + private async assertLabCanViewCase( + caseId: string, + labOrganizationId: string, + actorUserId: string, ) { const labCase = await this.prisma.labCase.findFirst({ where: { @@ -232,10 +250,20 @@ export class LabCaseCommentsService { throw new NotFoundException('Case not found'); } + const membership = await this.getLabMembership(actorUserId, labOrganizationId); + if ( + !hasEffectivePermission(membership, 'TAB_TASKS_READ') && + !hasEffectivePermission(membership, 'TAB_TASKS_EDIT') + ) { + throw new ForbiddenException('You do not have access to tasks'); + } + } + + private async getLabMembership(userId: string, organizationId: string) { const membership = await this.prisma.membership.findFirst({ where: { - userId: actorUserId, - organizationId: labOrganizationId, + userId, + organizationId, OR: [{ isOwner: true }, { isActive: true }], }, include: { @@ -246,9 +274,7 @@ export class LabCaseCommentsService { if (!membership) { throw new ForbiddenException('You are not a member of this organization'); } - if (!hasEffectivePermission(membership, 'TAB_TASKS_EDIT')) { - throw new ForbiddenException('You do not have access to task comments'); - } + return membership; } private async assertClinicOwnsCase( diff --git a/backend/src/modules/tasks/tasks.module.ts b/backend/src/modules/tasks/tasks.module.ts index b26340a..74cc06e 100644 --- a/backend/src/modules/tasks/tasks.module.ts +++ b/backend/src/modules/tasks/tasks.module.ts @@ -8,5 +8,6 @@ import { TasksService } from './tasks.service'; imports: [CatalogModule, NotificationsModule], controllers: [TasksController], providers: [TasksService], + exports: [TasksService], }) export class TasksModule {} diff --git a/backend/src/modules/tasks/tasks.service.ts b/backend/src/modules/tasks/tasks.service.ts index c588e75..67d80a7 100644 --- a/backend/src/modules/tasks/tasks.service.ts +++ b/backend/src/modules/tasks/tasks.service.ts @@ -95,6 +95,58 @@ export class TasksService { }; } + async listForLabCase( + labCaseId: string, + organizationId: string, + actorUserId: string, + localeInput?: string | null, + ) { + await this.assertCanReadTasks(actorUserId, organizationId); + + const labCase = await this.prisma.labCase.findFirst({ + where: { + id: labCaseId, + sentAt: { not: null }, + sends: { some: { organizationId } }, + }, + select: { id: true }, + }); + + if (!labCase) { + throw new NotFoundException('Case not found'); + } + + return this.listTasksForLabCaseId(labCaseId, localeInput); + } + + async listTasksForLabCaseId(labCaseId: string, localeInput?: string | null) { + const items = await this.prisma.labCaseTask.findMany({ + where: { labCaseId }, + include: taskListInclude, + orderBy: [ + { treatmentDetailId: 'asc' }, + { prosthesisTypeCode: 'asc' }, + { stepOrder: 'asc' }, + { id: 'asc' }, + ], + }); + + const locale = normalizeCatalogLocale(localeInput); + const prosthesisCodes = [...new Set(items.map((t) => t.prosthesisTypeCode).filter(Boolean))]; + const prosthesisLabels = await this.catalogLabels.resolveLabels( + CatalogEntityKind.PROSTHESIS_TYPE, + prosthesisCodes, + locale, + ); + + return { + success: true, + data: { + items: items.map((task) => this.mapTaskListItem(task, prosthesisLabels)), + }, + }; + } + async locateTaskPage( labOrganizationId: string, actorUserId: string, diff --git a/backend/src/modules/treatments/treatments.service.ts b/backend/src/modules/treatments/treatments.service.ts index 8cb3f41..de5ba9a 100644 --- a/backend/src/modules/treatments/treatments.service.ts +++ b/backend/src/modules/treatments/treatments.service.ts @@ -8,6 +8,7 @@ import { LabCaseActivityType, LabTaskStatus, LinkStatus, Prisma } from '@prisma/ import { createReadStream, existsSync, mkdirSync } from 'fs'; import { join } from 'path'; import { randomUUID } from 'crypto'; +import { generateLabCaseAccessToken } from '../../common/lab-case-access-token'; import { PrismaService } from '../../../prisma/prisma.service'; import { generateLabCaseTasks } from '../cases/lab-case-task.generator'; import { ProsthesisCatalogService } from '../prosthesis-catalog/prosthesis-catalog.service'; @@ -754,7 +755,10 @@ export class TreatmentsService { if (!labCase.sentAt) { await tx.labCase.update({ where: { id: labCaseId }, - data: { sentAt: now }, + data: { + sentAt: now, + accessToken: generateLabCaseAccessToken(), + }, }); } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 51d2cb7..b248bab 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -466,7 +466,22 @@ "prevPage": "Previous", "nextPage": "Next", "pageSummary": "Page {page} of {totalPages} ({total} cases)", - "statusLabel": "Status" + "statusLabel": "Status", + "viewShareQr": "View case QR code and share link", + "shareQrDialogTitle": "Case share link", + "shareQrDialogSubtitle": "Scan the QR code or copy the link to open this case’s tasks.", + "copyShareLink": "Copy link", + "shareLinkCopied": "Link copied to clipboard", + "shareQrUnavailable": "QR preview unavailable" + }, + "labCaseAccess": { + "pageTitle": "Case tasks", + "loading": "Loading case…", + "accessDeniedTitle": "Access denied", + "accessDenied": "You do not have permission to view this case. Sign in with a lab or clinic account that has access, and make sure you are the treatment provider for clinic access.", + "emptyTasks": "No tasks found for this case.", + "caseSummary": "{clinic} · {patient}", + "labName": "Lab: {name}" }, "tasks": { "title": "Tasks", @@ -978,6 +993,7 @@ "PERMISSION_ACCESS_TASKS": "You do not have access to tasks.", "PERMISSION_EDIT_TASKS": "You cannot edit tasks.", "PERMISSION_ACCESS_CASES": "You do not have access to cases.", + "LAB_CASE_ACCESS_DENIED": "You do not have permission to view this shared case.", "PERMISSION_ACCESS_STAFF": "You do not have access to staff management.", "PERMISSION_EDIT_STAFF": "You cannot manage staff working hours.", "PERMISSION_ORG_NOT_FOUND": "Organization not found.", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index 500daaf..c3ee1ca 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -467,7 +467,22 @@ "prevPage": "قبلی", "nextPage": "بعدی", "pageSummary": "صفحه {page} از {totalPages} ({total} پرونده)", - "statusLabel": "وضعیت" + "statusLabel": "وضعیت", + "viewShareQr": "مشاهده QR و لینک اشتراک‌گذاری پرونده", + "shareQrDialogTitle": "لینک اشتراک‌گذاری پرونده", + "shareQrDialogSubtitle": "QR را اسکن کنید یا لینک را کپی کنید تا وظایف این پرونده باز شود.", + "copyShareLink": "کپی لینک", + "shareLinkCopied": "لینک در کلیپ‌بورد کپی شد", + "shareQrUnavailable": "پیش‌نمایش QR در دسترس نیست" + }, + "labCaseAccess": { + "pageTitle": "وظایف پرونده", + "loading": "در حال بارگذاری پرونده…", + "accessDeniedTitle": "دسترسی مجاز نیست", + "accessDenied": "اجازه مشاهده این پرونده را ندارید. با حساب لاب یا کلینیک دارای دسترسی وارد شوید و برای دسترسی کلینیک، ارائه‌دهنده همان درمان باشید.", + "emptyTasks": "وظیفه‌ای برای این پرونده یافت نشد.", + "caseSummary": "{clinic} · {patient}", + "labName": "لاب: {name}" }, "tasks": { "title": "وظایف", @@ -979,6 +994,7 @@ "PERMISSION_ACCESS_TASKS": "به وظایف دسترسی ندارید.", "PERMISSION_EDIT_TASKS": "نمی‌توانید وظایف را ویرایش کنید.", "PERMISSION_ACCESS_CASES": "به پرونده‌ها دسترسی ندارید.", + "LAB_CASE_ACCESS_DENIED": "اجازه مشاهده این پرونده اشتراک‌گذاری‌شده را ندارید.", "PERMISSION_ACCESS_STAFF": "به مدیریت پرسنل دسترسی ندارید.", "PERMISSION_EDIT_STAFF": "نمی‌توانید ساعات کاری پرسنل را مدیریت کنید.", "PERMISSION_ORG_NOT_FOUND": "سازمان یافت نشد.", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index 399dcd9..fa4556f 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -467,7 +467,22 @@ "prevPage": "Vorige", "nextPage": "Volgende", "pageSummary": "Pagina {page} van {totalPages} ({total} dossiers)", - "statusLabel": "Status" + "statusLabel": "Status", + "viewShareQr": "QR-code en deellink van dossier bekijken", + "shareQrDialogTitle": "Deellink dossier", + "shareQrDialogSubtitle": "Scan de QR-code of kopieer de link om de taken van dit dossier te openen.", + "copyShareLink": "Link kopiëren", + "shareLinkCopied": "Link gekopieerd naar klembord", + "shareQrUnavailable": "QR-voorbeeld niet beschikbaar" + }, + "labCaseAccess": { + "pageTitle": "Dossiertaken", + "loading": "Dossier laden…", + "accessDeniedTitle": "Geen toegang", + "accessDenied": "U hebt geen toestemming om dit dossier te bekijken. Meld u aan met een lab- of kliniekaccount met toegang en zorg dat u de behandelende zorgverlener bent voor kliniektoegang.", + "emptyTasks": "Geen taken gevonden voor dit dossier.", + "caseSummary": "{clinic} · {patient}", + "labName": "Lab: {name}" }, "tasks": { "title": "Taken", @@ -978,6 +993,7 @@ "PERMISSION_ACCESS_TASKS": "U hebt geen toegang tot taken.", "PERMISSION_EDIT_TASKS": "U kunt taken niet bewerken.", "PERMISSION_ACCESS_CASES": "U hebt geen toegang tot dossiers.", + "LAB_CASE_ACCESS_DENIED": "U hebt geen toestemming om dit gedeelde dossier te bekijken.", "PERMISSION_ACCESS_STAFF": "U hebt geen toegang tot personeelsbeheer.", "PERMISSION_EDIT_STAFF": "U kunt werktijden van personeel niet beheren.", "PERMISSION_ORG_NOT_FOUND": "Organisatie niet gevonden.", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f102b07..89c31d7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -18,6 +18,7 @@ "react": "19.2.3", "react-dom": "19.2.3", "react-hook-form": "^7.71.2", + "react-qr-code": "^2.0.15", "recharts": "^3.9.2", "zod": "^4.3.6" }, @@ -5433,7 +5434,6 @@ "version": "4.0.0", "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -5844,7 +5844,6 @@ "version": "1.4.0", "resolved": "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, "license": "MIT", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" @@ -6210,7 +6209,6 @@ "version": "4.1.1", "resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6515,7 +6513,6 @@ "version": "15.8.1", "resolved": "https://registry.npmmirror.com/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", @@ -6539,6 +6536,12 @@ "node": ">=6" } }, + "node_modules/qrcode-generator": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/qrcode-generator/-/qrcode-generator-2.0.4.tgz", + "integrity": "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==", + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -6607,6 +6610,19 @@ "license": "MIT", "peer": true }, + "node_modules/react-qr-code": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/react-qr-code/-/react-qr-code-2.2.0.tgz", + "integrity": "sha512-e5nS0UUN22K3Nf8KBRUzemfdJ6OmnN5w+kbnj1lvJaol9RyVRFeGl05bCkxSN2ZegbLxjjYjX1+mmAoX9+fAhw==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.8.1", + "qrcode-generator": "^2.0.4" + }, + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-redux": { "version": "9.3.0", "resolved": "https://registry.npmmirror.com/react-redux/-/react-redux-9.3.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 7a5c0d3..0ede802 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,6 +19,7 @@ "react": "19.2.3", "react-dom": "19.2.3", "react-hook-form": "^7.71.2", + "react-qr-code": "^2.0.15", "recharts": "^3.9.2", "zod": "^4.3.6" }, diff --git a/frontend/src/app/[locale]/(dashboard)/lab-case/[token]/page.tsx b/frontend/src/app/[locale]/(dashboard)/lab-case/[token]/page.tsx new file mode 100644 index 0000000..8568937 --- /dev/null +++ b/frontend/src/app/[locale]/(dashboard)/lab-case/[token]/page.tsx @@ -0,0 +1,10 @@ +import { CaseTasksFocusView } from '@/components/ui/lab/CaseTasksFocusView'; + +interface LabCaseAccessPageProps { + params: Promise<{ token: string }>; +} + +export default async function LabCaseAccessPage({ params }: LabCaseAccessPageProps) { + const { token } = await params; + return ; +} diff --git a/frontend/src/app/[locale]/(dashboard)/layout.tsx b/frontend/src/app/[locale]/(dashboard)/layout.tsx index 7879d56..c3dc289 100644 --- a/frontend/src/app/[locale]/(dashboard)/layout.tsx +++ b/frontend/src/app/[locale]/(dashboard)/layout.tsx @@ -5,6 +5,7 @@ import { useTranslations } from 'next-intl'; import { Menu } from 'lucide-react'; import { usePathname, useRouter } from '@/i18n/navigation'; import { useAuth } from '@/lib/hooks/useAuth'; +import { storeAuthRedirectFromPath } from '@/lib/auth/postAuthRedirect'; import Sidebar from '@/components/ui/shared/Sidebar'; import { TopBarControls } from '@/components/ui/shared/TopBarControls'; import { DashboardAccountMenu } from '@/components/ui/dashboard/DashboardAccountMenu'; @@ -40,7 +41,12 @@ export default function DashboardLayout({ children }: { children: React.ReactNod if (!isAuthReady) return; if (!user) { - router.replace('/login'); + if (pathname && pathname !== '/login') { + storeAuthRedirectFromPath(pathname); + router.replace(`/login?from=${encodeURIComponent(pathname)}`); + } else { + router.replace('/login'); + } return; } diff --git a/frontend/src/app/[locale]/(public)/login/page.tsx b/frontend/src/app/[locale]/(public)/login/page.tsx index 5e1487c..1cd8f93 100644 --- a/frontend/src/app/[locale]/(public)/login/page.tsx +++ b/frontend/src/app/[locale]/(public)/login/page.tsx @@ -1,6 +1,7 @@ 'use client'; -import { useState, useEffect, useMemo } from 'react'; +import { Suspense, useState, useEffect, useMemo } from 'react'; +import { useSearchParams } from 'next/navigation'; import { useRouter } from '@/i18n/navigation'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -15,6 +16,10 @@ import { AuthPageShell } from '@/components/ui/auth/AuthPageShell'; import { Button } from '@/components/ui/shared/Button'; import { Checkbox } from '@/components/ui/shared/Checkbox'; import { Input } from '@/components/ui/shared/Input'; +import { + consumeAuthRedirect, + storeAuthRedirectFromPath, +} from '@/lib/auth/postAuthRedirect'; type LoginForm = { email: string; @@ -22,13 +27,14 @@ type LoginForm = { rememberMe: boolean; }; -export default function LoginPage() { +function LoginPageContent() { const t = useTranslations('auth'); const tCommon = useTranslations('common'); const tValidation = useTranslations('validation'); const tErrors = useTranslations('errors'); - const { login, isLoading, user, isAuthReady } = useAuth(); + const { login, isLoading, user, isAuthReady, organizations, currentOrganization } = useAuth(); const router = useRouter(); + const searchParams = useSearchParams(); const [error, setError] = useState(null); const [savedEmail] = useState(() => getRememberedEmail()); @@ -43,10 +49,18 @@ export default function LoginPage() { ); useEffect(() => { - if (isAuthReady && user) { - router.push('/today'); + const from = searchParams.get('from'); + if (from) { + storeAuthRedirectFromPath(from); } - }, [user, isAuthReady, router]); + }, [searchParams]); + + useEffect(() => { + if (!isAuthReady || !user || isLoading) return; + const orgReady = organizations.length <= 1 || currentOrganization; + if (!orgReady) return; + router.push(consumeAuthRedirect() ?? '/today'); + }, [isAuthReady, user, isLoading, organizations, currentOrganization, router]); const { register, @@ -152,3 +166,21 @@ export default function LoginPage() { ); } + +function LoginPageFallback() { + const tCommon = useTranslations('common'); + + return ( +
+

{tCommon('loading')}

+
+ ); +} + +export default function LoginPage() { + return ( + }> + + + ); +} diff --git a/frontend/src/components/ui/lab/CaseDetailPanel.tsx b/frontend/src/components/ui/lab/CaseDetailPanel.tsx index 4356838..8662ac9 100644 --- a/frontend/src/components/ui/lab/CaseDetailPanel.tsx +++ b/frontend/src/components/ui/lab/CaseDetailPanel.tsx @@ -10,6 +10,10 @@ import { CaseToothChartPanel } from '@/components/ui/lab/CaseToothChartPanel'; import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles'; import { LabCaseAttachmentPreview } from '@/components/ui/lab/LabCaseAttachmentPreview'; import { LabCaseAttachmentsDialog } from '@/components/ui/lab/LabCaseAttachmentsDialog'; +import { + LabCaseShareQrDialog, + LabCaseShareQrThumb, +} from '@/components/ui/lab/LabCaseShareQrDialog'; import { labTaskStatusVariant } from '@/components/lab/labTaskStatusDisplay'; import { LabCaseDueDateBadge } from '@/components/lab/LabCaseDueDateBadge'; import { @@ -89,6 +93,7 @@ export function CaseDetailPanel({ }: CaseDetailPanelProps) { const t = useTranslations('cases'); const [attachmentsDialogOpen, setAttachmentsDialogOpen] = useState(false); + const [shareQrDialogOpen, setShareQrDialogOpen] = useState(false); const [prosthesisCatalog, setProsthesisCatalog] = useState([]); useEffect(() => { @@ -137,15 +142,7 @@ export function CaseDetailPanel({ -
- {showCommentsButton && onCommentsClick ? ( - - ) : null} +
{canEditImportant ? ( onImportantChange?.(checked)} /> ) : null} - {previewAttachment && labCase.attachments.length > 0 ? ( - + {showCommentsButton && onCommentsClick ? ( + + ) : null} + {labCase.shareUrl || (previewAttachment && labCase.attachments.length > 0) ? ( +
+ {previewAttachment && labCase.attachments.length > 0 ? ( + + ) : null} + {labCase.shareUrl ? ( + setShareQrDialogOpen(true)} + /> + ) : null} +
) : null}
@@ -279,6 +294,14 @@ export function CaseDetailPanel({ attachments={labCase.attachments} loadBlob={loadAttachmentBlob} /> + + {labCase.shareUrl ? ( + setShareQrDialogOpen(false)} + shareUrl={labCase.shareUrl} + /> + ) : null}
); } diff --git a/frontend/src/components/ui/lab/CaseTasksFocusView.tsx b/frontend/src/components/ui/lab/CaseTasksFocusView.tsx new file mode 100644 index 0000000..87931fc --- /dev/null +++ b/frontend/src/components/ui/lab/CaseTasksFocusView.tsx @@ -0,0 +1,216 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { TaskCaseGroupHeader } from '@/components/ui/lab/TaskCaseGroupHeader'; +import { TaskProsthesisGroupHeader } from '@/components/ui/lab/TaskProsthesisGroupHeader'; +import { TaskRow } from '@/components/ui/lab/TaskRow'; +import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel'; +import { groupTasksForDisplay } from '@/components/lab/taskListGrouping'; +import { canEditLabTaskStatus } from '@/components/lab/labTaskStatusDisplay'; +import { getUserFacingError } from '@/components/shared/formatApiError'; +import { asApiError } from '@/types/api'; +import { useAuth } from '@/lib/hooks/useAuth'; +import { useToast } from '@/lib/hooks/useToast'; +import { labCaseAccessApi, type LabCaseAccessSession } from '@/lib/api/lab-case-access'; +import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog'; +import { tasksApi } from '@/lib/api/tasks'; +import { notifyTabBadgesChanged } from '@/lib/tabBadgeUtils'; +import type { LabTaskListItem, LabTaskStatus } from '@/types/cases'; +import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog'; + +interface CaseTasksFocusViewProps { + token: string; +} + +export function CaseTasksFocusView({ token }: CaseTasksFocusViewProps) { + const t = useTranslations('labCaseAccess'); + const tTasks = useTranslations('tasks'); + const tErrors = useTranslations('errors'); + const { user, isAuthReady } = useAuth(); + const { showError, showSuccess } = useToast(); + + const [session, setSession] = useState(null); + const [tasks, setTasks] = useState([]); + const [loading, setLoading] = useState(true); + const [accessDenied, setAccessDenied] = useState(false); + const [updatingTaskId, setUpdatingTaskId] = useState(null); + const [prosthesisCatalog, setProsthesisCatalog] = useState([]); + + const locale = user?.language ?? 'en'; + const canEditTasks = session?.canEditTaskStatus ?? false; + const canPostComments = session?.canPostComments ?? false; + const canToggleCommentVisibility = session?.canToggleCommentVisibility ?? false; + + const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo( + () => [ + { value: 'IN_PROGRESS', label: tTasks('statusInProgress') }, + { value: 'COMPLETED', label: tTasks('statusCompleted') }, + ], + [tTasks], + ); + + const loadData = useCallback(async () => { + setLoading(true); + setAccessDenied(false); + try { + const [sessionRes, tasksRes] = await Promise.all([ + labCaseAccessApi.resolve(token), + labCaseAccessApi.listTasks(token), + ]); + setSession(sessionRes.data); + setTasks(tasksRes.data.items); + } catch (error: unknown) { + if (asApiError(error)?.code === 'LAB_CASE_ACCESS_DENIED') { + setAccessDenied(true); + } else { + showError(getUserFacingError(error, tErrors, t('accessDenied'))); + } + setSession(null); + setTasks([]); + } finally { + setLoading(false); + } + }, [showError, t, tErrors, token]); + + useEffect(() => { + if (!isAuthReady || !user) return; + void loadData(); + }, [isAuthReady, user, loadData]); + + useEffect(() => { + void prosthesisCatalogApi + .list() + .then((response) => setProsthesisCatalog(response.data)) + .catch(() => {}); + }, []); + + const displayModel = useMemo(() => groupTasksForDisplay(tasks, 'date'), [tasks]); + + const handleStatusUpdate = useCallback( + async (taskId: string, status: LabTaskStatus) => { + if (!canEditTasks || !user?.id) return; + const task = tasks.find((item) => item.id === taskId); + if (!task || !canEditLabTaskStatus(task, user.id, canEditTasks)) return; + setUpdatingTaskId(taskId); + try { + await tasksApi.updateStatus(taskId, status); + await loadData(); + notifyTabBadgesChanged(); + if (status === 'COMPLETED') { + showSuccess(tTasks('taskCompletedToast')); + } + } catch (error: unknown) { + showError(getUserFacingError(error, tErrors, tTasks('errorUpdateTask'))); + } finally { + setUpdatingTaskId(null); + } + }, + [canEditTasks, loadData, showError, showSuccess, tErrors, tTasks, tasks, user?.id], + ); + + if (!isAuthReady || loading) { + return

{t('loading')}

; + } + + if (accessDenied) { + return ( +
+

{t('accessDeniedTitle')}

+

{t('accessDenied')}

+
+ ); + } + + if (!session || displayModel.mode !== 'grouped' || displayModel.cases.length === 0) { + return

{t('emptyTasks')}

; + } + + const caseGroup = displayModel.cases[0]; + + return ( +
+
+

{t('pageTitle')}

+

+ {t('caseSummary', { + clinic: session.clinic.name, + patient: `${session.patient.firstName} ${session.patient.lastName}`.trim(), + })} +

+ {session.lab ? ( +

+ {t('labName', { name: session.lab.name })} +

+ ) : null} +
+ +
+
+ + {caseGroup.prosthesisGroups.map((prosthesisGroup) => ( +
+ +
    + {prosthesisGroup.tasks.map((task) => ( + void handleStatusUpdate(id, status)} + onToggleComments={() => {}} + onCommentError={showError} + /> + ))} +
+
+ ))} +
+
+ +
+ { + const r = await labCaseAccessApi.listComments(token); + return r.data; + }} + onPost={async (body, visibleToClinic) => { + const r = await labCaseAccessApi.addComment(token, { + body, + visibleToClinic, + }); + notifyTabBadgesChanged(); + return r.data; + }} + onToggleVisibility={ + canToggleCommentVisibility + ? async (commentId, visible) => { + const r = await labCaseAccessApi.setCommentVisibility( + token, + commentId, + visible, + ); + return r.data; + } + : undefined + } + onError={showError} + /> +
+
+ ); +} diff --git a/frontend/src/components/ui/lab/LabCaseShareQrCode.tsx b/frontend/src/components/ui/lab/LabCaseShareQrCode.tsx new file mode 100644 index 0000000..389d935 --- /dev/null +++ b/frontend/src/components/ui/lab/LabCaseShareQrCode.tsx @@ -0,0 +1,21 @@ +'use client'; + +import QRCode from 'react-qr-code'; + +interface LabCaseShareQrCodeProps { + value: string; + size?: number; + className?: string; +} + +export function LabCaseShareQrCode({ value, size = 128, className }: LabCaseShareQrCodeProps) { + return ( + + ); +} diff --git a/frontend/src/components/ui/lab/LabCaseShareQrDialog.tsx b/frontend/src/components/ui/lab/LabCaseShareQrDialog.tsx new file mode 100644 index 0000000..7db4e82 --- /dev/null +++ b/frontend/src/components/ui/lab/LabCaseShareQrDialog.tsx @@ -0,0 +1,101 @@ +'use client'; + +import { useCallback, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { Copy } from 'lucide-react'; +import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton'; +import { + ResponsiveDialogOverlay, + ResponsiveDialogPanel, +} from '@/components/ui/shared/ResponsiveDialog'; +import { Button } from '@/components/ui/shared/Button'; +import { useToast } from '@/lib/hooks/useToast'; +import { LabCaseShareQrCode } from '@/components/ui/lab/LabCaseShareQrCode'; + +interface LabCaseShareQrDialogProps { + open: boolean; + onClose: () => void; + shareUrl: string; +} + +export function LabCaseShareQrDialog({ open, onClose, shareUrl }: LabCaseShareQrDialogProps) { + const t = useTranslations('cases'); + const { showSuccess } = useToast(); + const [copying, setCopying] = useState(false); + + const handleCopy = useCallback(async () => { + setCopying(true); + try { + await navigator.clipboard.writeText(shareUrl); + showSuccess(t('shareLinkCopied')); + } catch { + // ignore clipboard failures + } finally { + setCopying(false); + } + }, [shareUrl, showSuccess, t]); + + if (!open) return null; + + return ( + + +
+
+

+ {t('shareQrDialogTitle')} +

+

{t('shareQrDialogSubtitle')}

+
+ +
+ +
+
+ +
+

+ {shareUrl} +

+ +
+
+
+ ); +} + +interface LabCaseShareQrThumbProps { + shareUrl: string; + onClick: () => void; +} + +export function LabCaseShareQrThumb({ shareUrl, onClick }: LabCaseShareQrThumbProps) { + const t = useTranslations('cases'); + + return ( + + ); +} diff --git a/frontend/src/components/ui/lab/TaskCaseGroupHeader.tsx b/frontend/src/components/ui/lab/TaskCaseGroupHeader.tsx index 7fd1a18..76548d0 100644 --- a/frontend/src/components/ui/lab/TaskCaseGroupHeader.tsx +++ b/frontend/src/components/ui/lab/TaskCaseGroupHeader.tsx @@ -1,6 +1,7 @@ 'use client'; import { useTranslations } from 'next-intl'; +import { MessageSquare } from 'lucide-react'; import { Badge } from '@/components/ui/shared/Badge'; import { LabCaseDueDateBadge } from '@/components/lab/LabCaseDueDateBadge'; import type { CaseTaskGroup } from '@/components/lab/taskListGrouping'; @@ -14,9 +15,18 @@ function formatPatientName(patient: { firstName: string; lastName: string }) { interface TaskCaseGroupHeaderProps { caseGroup: CaseTaskGroup; locale: string; + showCommentsButton?: boolean; + commentsOpen?: boolean; + onToggleComments?: () => void; } -export function TaskCaseGroupHeader({ caseGroup, locale }: TaskCaseGroupHeaderProps) { +export function TaskCaseGroupHeader({ + caseGroup, + locale, + showCommentsButton = false, + commentsOpen = false, + onToggleComments, +}: TaskCaseGroupHeaderProps) { const t = useTranslations('tasks'); const progress = countCaseTaskProgress(caseGroup); @@ -26,7 +36,7 @@ export function TaskCaseGroupHeader({ caseGroup, locale }: TaskCaseGroupHeaderPr return (
-
+

{t('fromClinic', { name: caseGroup.clinic.name })} ·{' '} @@ -49,9 +59,26 @@ export function TaskCaseGroupHeader({ caseGroup, locale }: TaskCaseGroupHeaderPr

) : null}
-

- {t('caseTaskProgress', { completed: progress.completed, total: progress.total })} -

+
+ {showCommentsButton && onToggleComments ? ( + + ) : null} +

+ {t('caseTaskProgress', { completed: progress.completed, total: progress.total })} +

+
); } diff --git a/frontend/src/components/ui/lab/TaskRow.tsx b/frontend/src/components/ui/lab/TaskRow.tsx index 759d5c8..0f830d3 100644 --- a/frontend/src/components/ui/lab/TaskRow.tsx +++ b/frontend/src/components/ui/lab/TaskRow.tsx @@ -33,6 +33,7 @@ interface TaskRowProps { statusOptions: { value: LabTaskStatus; label: string }[]; updatingTaskId: string | null; commentsOpen: boolean; + showCommentsButton?: boolean; prosthesisCatalog: readonly ProsthesisCatalogEntry[]; onStatusUpdate: (taskId: string, status: LabTaskStatus) => void; onToggleComments: (taskId: string) => void; @@ -55,6 +56,7 @@ export function TaskRow({ statusOptions, updatingTaskId, commentsOpen, + showCommentsButton = true, prosthesisCatalog, onStatusUpdate, onToggleComments, @@ -78,7 +80,7 @@ export function TaskRow({ .join(' '); const commentsButton = - canEdit && !exiting ? ( + showCommentsButton && canEdit && !exiting ? (