Compare commits

...

37 Commits

Author SHA1 Message Date
5d3597f973 improvement: icons added to prosthesis types catalog. 2026-09-05 01:29:53 +03:30
4d7a4b390f Merge pull request 'improvement/prosthesis-types-overhaul' (#72) from improvement/prosthesis-types-overhaul into master
All checks were successful
Registry — build, push, deploy / build-and-push (push) Successful in 1h5m11s
Registry — build, push, deploy / deploy (push) Successful in 2m4s
Production — tag build, push, deploy / build-and-push (push) Successful in 4m18s
Production — tag build, push, deploy / deploy (push) Successful in 12m48s
Reviewed-on: #72
Reviewed-by: rameen <rameen.naghdi@gmail.com>
2026-09-04 20:23:58 +03:30
e57fe2d959 Merge branch 'master' into improvement/prosthesis-types-overhaul 2026-09-04 17:07:59 +03:30
72f885d9dd improvement: all confirmed suggestions implemented 2026-09-04 17:04:48 +03:30
7f92e735fb improvement: Some improvements done. some bugs fixed. 2026-09-02 17:24:01 +03:30
c23785fbf4 Merge pull request 'feature/ci-cd' (#71) from feature/ci-cd into master
All checks were successful
Registry — build, push, deploy / build-and-push (push) Successful in 1m8s
Registry — build, push, deploy / deploy (push) Successful in 1m39s
Reviewed-on: #71
Reviewed-by: admin <admin@localhost>
2026-09-02 11:05:18 +03:30
a3c14a18c1 improvement: new prosthesis type data structure implemented and finally working! 2026-09-01 21:03:04 +03:30
60be656cf1 Allow Gitea registry pushes through gitea.wixur.ir: nginx 50M body limit 413s Docker layers.
All checks were successful
Production — tag build, push, deploy / build-and-push (push) Successful in 47s
Production — tag build, push, deploy / deploy (push) Successful in 14m17s
2026-09-01 12:40:26 +03:30
6465af11ee Expose AdminJS on nudentic.ir: nginx only proxied /api, so /admin never reached Nest. 2026-09-01 11:49:14 +03:30
dc71c73ced Merge pull request 'feature/ci-cd' (#70) from feature/ci-cd into master
All checks were successful
Registry — build, push, deploy / build-and-push (push) Successful in 58s
Registry — build, push, deploy / deploy (push) Successful in 1m27s
Reviewed-on: #70
Reviewed-by: admin <admin@localhost>
2026-09-01 09:56:02 +03:30
19364ad5c2 resolve failed handshake connection with https://wixur.ir:3000
All checks were successful
Production — tag build, push, deploy / build-and-push (push) Successful in 42s
Production — tag build, push, deploy / deploy (push) Successful in 21s
2026-08-31 10:04:30 +03:30
50eda34e8e The app is now wired to GlitchTip with the Sentry SDKs
Some checks failed
Production — tag build, push, deploy / build-and-push (push) Successful in 1h13m36s
Production — tag build, push, deploy / deploy (push) Failing after 1m1s
2026-08-31 07:34:07 +03:30
573e5e0886 auto deploy on production server now finalized ready for smoke test.
All checks were successful
Production — tag build, push, deploy / build-and-push (push) Successful in 4m23s
Production — tag build, push, deploy / deploy (push) Successful in 11m52s
2026-08-29 16:25:27 +03:30
23c37519b5 windows ssl 2026-08-29 14:46:41 +03:30
2e2c910d22 Document staging HTTPS on wixur.ir (Windows nginx + Let's Encrypt).
Mobinnet holds public port 80, so staging is served on 443. Examples and the host nginx template now match FRONTEND_URL=https://wixur.ir.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 14:46:05 +03:30
c40e276b8a migrate from http://178.131.50.201:8088 to wixur.ir 2026-08-29 12:09:50 +03:30
d8efaa6f2f Merge pull request 'Fix voice extract timeout for production ASR+LLM pipeline' (#69) from feature/ci-cd into master
All checks were successful
Registry — build, push, deploy / build-and-push (push) Successful in 1m37s
Registry — build, push, deploy / deploy (push) Successful in 38s
Reviewed-on: http://host.docker.internal:3000/admin/dyolink/pulls/69
Reviewed-by: admin <admin@localhost>
2026-08-25 14:42:23 +03:30
eee2506adf ASR + LLM routinely exceeds the default 10s axios timeout; match slow upload endpoints. 2026-08-25 10:21:55 +03:30
9f5f696fb4 Merge pull request 'Bind staging Docker to 18088 so portproxy can own 8088.' (#68) from feature/ci-cd into master
All checks were successful
Registry — build, push, deploy / build-and-push (push) Successful in 17s
Registry — build, push, deploy / deploy (push) Successful in 45s
Reviewed-on: http://host.docker.internal:3000/admin/dyolink/pulls/68
Reviewed-by: admin <admin@localhost>
2026-08-24 12:14:51 +03:30
5ac3a4a708 Bind staging Docker to 18088 so portproxy can own 8088. 2026-08-24 12:06:13 +03:30
2a0f9dbfee Merge pull request 'feat(treatment): add voice detail entry' (#65) from feat/voice-treatment-entry into master
Some checks failed
Registry — build, push, deploy / build-and-push (push) Successful in 7m32s
Registry — build, push, deploy / deploy (push) Failing after 45s
Reviewed-on: http://host.docker.internal:3000/admin/dyolink/pulls/65
Reviewed-by: admin <admin@localhost>
2026-08-24 11:13:45 +03:30
dc9401e13f Merge pull request 'feature/ci-cd' (#67) from feature/ci-cd into master
Some checks failed
Registry — build, push, deploy / deploy (push) Has been cancelled
Registry — build, push, deploy / build-and-push (push) Has been cancelled
Reviewed-on: http://host.docker.internal:3000/admin/dyolink/pulls/67
Reviewed-by: admin <admin@localhost>
2026-08-24 11:09:38 +03:30
809c72af0f Merge branch 'master' into feature/ci-cd 2026-08-23 19:09:52 +03:30
201300f446 staging deploy md doc. 2026-08-23 19:06:13 +03:30
24c4feb11c Enable Gitea Actions staging deploy on master push 2026-08-23 19:06:13 +03:30
3a16f04c51 package-locks and prisma migration update. 2026-08-23 19:06:13 +03:30
6d49fd0b89 Bind staging nginx to localhost for Windows Docker Desktop
Publish port 8088 on 127.0.0.1 only so netsh portproxy can expose staging
to LAN/public IPs; document the one-time portproxy setup on Windows.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-23 17:35:54 +03:30
e1c0434ed1 Fix frontend staging healthcheck for locale redirect
Accept 2xx/3xx on / so next-intl redirect to /en no longer marks the
container unhealthy and blocks nginx from starting.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-23 16:34:58 +03:30
eff27e359b Fix staging containers failing on Windows Docker builds
Strip CRLF from entrypoint scripts in backend/frontend images, use Docker
DNS in staging nginx config, and wait for healthy app services before nginx.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-23 16:16:14 +03:30
2b2cca8361 fix docker url for making connection with gitea. 2026-08-23 15:20:14 +03:30
0e8e3a4a27 local ip set instead of static public ip server to find it in network. 2026-08-23 14:13:29 +03:30
a43c433d55 lable self-hosted change to windows 2026-08-23 11:34:10 +03:30
8b462ea45a Merge branch 'master' into feature/ci-cd 2026-08-23 11:11:27 +03:30
ebd2de39cf staging deploy md doc. 2026-08-23 11:11:07 +03:30
1258ecf899 Enable Gitea Actions staging deploy on master push 2026-08-23 11:10:28 +03:30
b779a57cba remove ./public from dockerfile. 2026-08-21 14:58:21 +03:30
e6c236f1a0 package-locks and prisma migration update. 2026-08-20 11:30:14 +03:30
165 changed files with 9457 additions and 1848 deletions

View File

@@ -40,7 +40,7 @@ QR + URL for **sent** cases; focus page `/lab-case/[token]`. Auth redirect via `
## API errors
Logical failures: `AppException(ErrorCode.X)` → `errors.X` in en/fa/nl. UI: `getUserFacingError`. Skill: `.cursor/skills/api-errors/SKILL.md`. Appointments/working hours: client IANA `timeZone` (never Node local `getHours()`).
Logical failures: `AppException(ErrorCode.X)` → `errors.X` in en/fa/nl. UI: `getUserFacingError`. Unexpected 500s / client crashes: GlitchTip via Sentry SDKs (`SENTRY_DSN` / `NEXT_PUBLIC_SENTRY_DSN`). Skill: `.cursor/skills/api-errors/SKILL.md`. Appointments/working hours: client IANA `timeZone` (never Node local `getHours()`).
## Notifications (inbox + live tabs)

View File

@@ -0,0 +1,20 @@
---
description: GlitchTip error tracking via Sentry SDKs — 500s only, no PHI
alwaysApply: true
---
# Error tracking (GlitchTip)
Self-hosted GlitchTip at `https://errors.wixur.ir`. Apps use official Sentry SDKs (`@sentry/nestjs`, `@sentry/nextjs`) pointed at that DSN — not sentry.io.
## What to report
- Backend: HTTP **500+** in `HttpExceptionFilter` only. Do **not** send `AppException` 4xx.
- Frontend: render crashes (`error.tsx` / `global-error.tsx`) and axios **5xx / network** failures only.
- No session replay. Scrub cookies, JWT, request bodies, emails.
## Config
- Backend DSN: `SENTRY_DSN` in `backend.env` / `backend.staging.env` (runtime).
- Frontend DSN: `NEXT_PUBLIC_SENTRY_DSN` baked at Docker build (Gitea var `NEXT_PUBLIC_SENTRY_DSN`).
- Empty DSN = tracking off (local default).

View File

@@ -9,4 +9,5 @@ alwaysApply: false
- **Brand** (wordmark + logo): `frontend/src/assets/brand/*.svg`. Inline in `BrandWordmark` / `BrandLogo` / `BrandLockup` so fills can use `currentColor`. After editing an SVG, run `frontend/scripts/build-brand-components.mjs`.
- **Favicon:** `frontend/src/app/icon.svg` (hex fills — `currentColor` does not work in tab icons).
- **FDI tooth sources:** `frontend/src/assets/fdi/`. The chart uses inlined paths in `realisticToothAssets.ts`; regenerate with `frontend/scripts/extract-tooth-svgs.mjs`.
- **`public/`** is only for files that must be fetched by URL (e.g. og images). Do not put themeable brand/FDI SVGs there — `<img src>` cannot inherit `currentColor`.
- **Prosthesis catalog illustrations:** source `frontend/src/assets/prosthesis-catalog/*.svg` (painted navy, not `currentColor`). Serve the same filenames from `frontend/public/prosthesis-catalog/` as `<img>` — Next cannot import these as URLs under Turbopack 16.1, and denture/veneer are too large to inline. Map codes in `prosthesisCatalogIcons.ts`. Copy into `public/` when adding a file.
- **`public/`** is only for files that must be fetched by URL (e.g. og images, prosthesis catalog icons). Do not put themeable brand/FDI SVGs there — `<img src>` cannot inherit `currentColor`.

View File

@@ -1,14 +1,15 @@
---
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
globs: frontend/src/app/**/lab-case/**,frontend/src/components/ui/lab/CaseTasksFocusView.tsx,frontend/src/components/ui/lab/LabCaseShareQr*.tsx,frontend/src/components/ui/treatment/LabCasesDispatchPanel.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,backend/src/modules/treatments/treatments.service.ts
alwaysApply: false
---
# Lab case share link
- **Token:** `LabCase.accessToken` on first ship; `shareUrl` on case detail only when sent.
- **Token:** `LabCase.accessToken` on first ship; `shareUrl` on lab case detail **and** clinic treatment lab-case drafts 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.
- **Treatment dispatch:** same thumb + dialog on sent cases (`LabCasesDispatchPanel`). Dest/jobs share a row with the QR; tracker + comments are full width below.
- **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.

View File

@@ -1,6 +1,6 @@
---
description: Lab Cases tab — prosthesis filter, auto-select, list cards, Case Sheet PDF
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/ui/lab/CaseSheet*.tsx,frontend/src/components/lab/caseSheetPdf.ts,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/ProsthesisStackedTypeLabel.tsx,frontend/src/components/ui/lab/LabCaseShareQr*.tsx,frontend/src/components/ui/lab/CaseSheet*.tsx,frontend/src/components/lab/caseSheetPdf.ts,frontend/src/components/lab/caseDetailUtils.ts,frontend/src/components/treatment/prosthesisJobPath.ts,backend/src/modules/cases/**
alwaysApply: false
---
@@ -8,7 +8,7 @@ alwaysApply: false
- **Filter by prosthesis**, not treatment type — `prosthesisTypeCode`; matches cases with **any task** of that type.
- **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.
- **List cards:** `LabCaseProsthesisGroupsList` (catalog color + teeth); same component as Treatment shipments rail; no patient mobile on card. Connected same-type teeth share a group; stacked types on one tooth split by `+` in the type key. Job titles are the picker path to the **leaf** (`prosthesisJobPath.ts` / `ProsthesisStackedTypeLabel`) — e.g. Crowns · PFM Crown, never the category alone. Indirect omits Veneer/Inlay/Onlay/Overlay (Inlay · Layered ceramic). Join ancestors/leaf with `PROSTHESIS_JOB_PATH_SEP` (`\u00a0·\u00a0`) so the gap does not collapse between inline spans.
- **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`.
- **Case Sheet PDF:** client A4 via `jspdf` + `html2canvas`; print layout must stay **hex-only** (Tailwind `lab()`/`oklch` breaks capture). Optional `externalCode` replaces PDF order number when set.

View File

@@ -1,14 +1,14 @@
---
description: Lab Tasks tab — sort, grouping, filters, prosthesis colors
globs: frontend/src/components/ui/lab/TasksPage.tsx,frontend/src/components/ui/lab/Task*.tsx,frontend/src/components/ui/lab/Case*.tsx,frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx,frontend/src/components/lab/taskListGrouping.ts,frontend/src/components/lab/labCaseDueDateDisplay.ts,frontend/src/components/shared/formSelectStyles.ts,frontend/src/components/treatment/prosthesisTypeDisplay.ts,frontend/src/components/shared/catalog-type-colors.ts,backend/src/modules/tasks/**,backend/src/common/lab-case-due-date.ts,backend/src/modules/treatments/**
globs: frontend/src/components/ui/lab/TasksPage.tsx,frontend/src/components/ui/lab/Task*.tsx,frontend/src/components/ui/lab/Case*.tsx,frontend/src/components/ui/lab/ProsthesisStackedTypeLabel.tsx,frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx,frontend/src/components/lab/taskListGrouping.ts,frontend/src/components/lab/labCaseDueDateDisplay.ts,frontend/src/components/shared/formSelectStyles.ts,frontend/src/components/treatment/prosthesisTypeDisplay.ts,frontend/src/components/treatment/prosthesisJobPath.ts,frontend/src/components/shared/catalog-type-colors.ts,backend/src/modules/tasks/**,backend/src/common/lab-case-due-date.ts,backend/src/modules/treatments/**
alwaysApply: false
---
# Lab Tasks
- **Default sort:** newest `labCase.sentAt` first; `stepOrder` asc within prosthesis group (backend `buildOrderBy`).
- **Grouping:** only when `sortBy=date`; flat list + hint for other sorts.
- **Prosthesis colors:** `PROSTHESIS_TYPE_COLORS` + `prosthesisTypeBadgeStyleFromCatalog` — never row index.
- **Grouping:** only when `sortBy=date`; flat list + hint for other sorts. Unconnected teeth stay one pipeline; **connected** teeth that share a type share a pipeline; `partial_denture` is one pipeline for all selected teeth.
- **Prosthesis colors:** `PROSTHESIS_TYPE_COLORS` + `prosthesisTypeColorFromCatalog` as text (no type pills) — never row index. Stacked `code+code` labels use `ProsthesisStackedTypeLabel` (one color per type). Titles are the picker path to the leaf (`prosthesisJobPath.ts`), not the category name alone. Indirect omits the Veneer/Inlay/Onlay/Overlay parent. Path joins use `PROSTHESIS_JOB_PATH_SEP` (`\u00a0·\u00a0`).
- **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.

View File

@@ -8,19 +8,19 @@ alwaysApply: false
- **Browse mode** (`selectedPreviewId` set): preview only; banner + **Load into workspace**; form unchanged until load.
- **Current draft** preview: omitted in live editing (form is the source). History browse still uses preview + **Load into workspace**.
- **Entry:** Type dropdown + `TreatmentDetailAttachmentsStrip` on one row, bordered chart (Cases chrome), then full-width auto-growing Notes. No stepper unless prosthesis — then `WizardStepper` Treatment → Lab with Back/Next. Chip switches reset to the treatment form unless `pendingEntryStepRef` requests Lab (shipments rail open). Lab-dependent chips use colored sent/unsent text; sent date is on Lab dispatch. Lab dispatch keeps comments.
- **Entry:** Type dropdown + `TreatmentDetailAttachmentsStrip` on one row (plus **Lab dispatch** toggle when the type is lab-dependent), bordered chart (Cases chrome), then full-width auto-growing Notes. Prosthesis types live on the chart (teeth, or Upper/Lower arch for jaw products). Picker: `4fr` wrapping category grid / `1fr` add-ons with a vertical `border-e` divider; category/subcategory (and matching leaf) tiles use `prosthesis-catalog` SVGs; empty crown slot is plus + dashed chip in a full-height cell; after a crown is picked the slot stays as the filled type chip (so it can be changed or cleared). Children stay a wrapping grid with an L-rail sized to the **first child card**; parent-bar back arrow is black (not family tint). Category parent is the heaviest family tone, children lighter. **Add detail**, Lab dispatch, and Chart share the same control width (`WorkspaceActionLabel`). Lab dispatch is primary, Chart is outline. Chart header: Full View + Reset as shared `Button` `sm`. Arch labels: dashed outline + plus. Picker Upper/Lower/Both remaps `UA`/`LA` (`retargetArchJobs`: Both→Upper drops lower). Implant or post & core suggests a **crown** slot; a veneer/inlay/onlay/overlay hides that slot; Post & core is disabled after a crown/implant; Implant is disabled after post & core; screw-retained does not suggest another crown and disables Crown / Indirect. Partial denture is selected on FDI teeth. Lab dispatch **replaces the chart** in the same slot (toggle beside attachments) — not stacked underneath. Rail / Go to dispatch opens that view (`pendingScrollToLabRef`). Prosthesis teeth **always have jobs**; hydrate/save prune selections without `toothProsthesis`. Lab-dependent chips use colored sent/unsent text; sent date is on Lab dispatch. Lab dispatch keeps comments.
- **Tooth hits:** unrotated full cell, `pointerdown` only (not `click` too — double-toggles). Glyph `pointer-events-none`; nest scale/hover inside the rotate wrapper. Groups: `toothSelectionGroups.ts` (never 1-tooth connected); prune lab `toothProsthesis` on change.
- **Day strip:** `ScheduleDayPicker` `compact` centered in the header. Timed cards use purpose banners. Unscheduled cards use the same banner from the **first details type only** (live draft for the open card); empty first line keeps chip theming. Trash inherits banner ink. Strip-delete only when `areUnscheduledDetailsStripDeletable` (blank lines or `[]`); persist `[]` then `DELETE /treatments/:id`.
- **Lab dispatch UI:** due date end-aligned beside title (`sm:flex-row` + `justify-between`; stacks on mobile). Prosthesis type: stacked below `md`, 50/50 same-row from `md`. Content clinical field = **Notes** (not case comments).
- **Lab dispatch UI:** due date end-aligned beside title (`sm:flex-row` + `justify-between`; stacks on mobile). Job list is `LabCaseToothJobsList`: connected same-type teeth share a row; unconnected teeth stay one row (stacked types as colored catalog text). Content clinical field = **Notes** (not case comments). Sent cases: dest/jobs share a row with the Cases QR thumb; tracker + comments are **full width below** the QR.
- **Lab comments:** shared `LabCaseCommentsPanel` (newest-first; sent=`justify-start`, received=`justify-end`; `viewerSide`) across Treatment / Cases / Tasks / share. Use logical `text-start`/`text-end`, not left/right.
- **Detail chrome:** chips (type + teeth) + Add at top; **Remove** = trash on chip (unsent, including last line; disabled when day-locked / no edit / uploading). Last-line confirm: plan will be empty until Add. Empty `[]` shows `noDetails` (not the type-first overlay). New treatment seeds one blank detail. No delete in type/notes fields.
- **Patient search:** `PatientSearchCombobox` in the **page header** (workspace-wide). Opens todays strip visit if any, else latest history. No visit/history → inline editor empty state pointing to **New treatment** in the rail (never a dialog, never auto-create). **New treatment** stays at the top of the left rail; selected-patient card sits under it. Picker: Walk-in first, then a matching full-width card for the current named patient (name + mobile/email, else hint), then search. Never auto-create from the appointment card.
- **Lab dispatch attention:** `LabDispatchAttentionPanel` — unsent lab-dependent details; quick jump to dispatch.
- **History API:** patient-scoped; non-owners filtered by provider on treatment or appointment; org owners see all.
- **History API:** patient-scoped; filtered by provider on treatment or appointment owners are not exempt.
- **History filters (client-side):** `PastTreatmentsPanel` — “Not shipped to lab” + single date; helpers in `treatmentHistoryFilters.ts`.
- **Lab shipment block:** lab-dependent detail with no teeth saves but cannot ship — same inline amber banner on Treatment and Lab (`labBlockedBannerClass`); toast on add shipment.
- **Lab shipment block:** lab-dependent detail with no teeth and no arch job (`UA`/`LA`) saves but cannot ship — inline amber banner (`labBlockedBannerClass`).
- **Edit gating:** `canEditTreatmentForDay` = permission + appointment + not past day + `live` mode. Sent detail locks that line; Add still OK same day. Upload rejects sent (`TREATMENT_DETAIL_SENT`).
- **Lab search:** `LinkedOrganizationSearchCombobox`; invite lab via `/organizations?action=invite-lab` when permitted. New dispatch lines start empty (lab + apply-all / per-tooth prosthesis type). Chips = last 3 **sent** labs (`labDispatchDefaults.ts`) — never auto-select. Clear the search box when switching details.
- **Lab search:** `LinkedOrganizationSearchCombobox`; invite lab via `/organizations?action=invite-lab` when permitted. New dispatch lines start empty (lab only — types live on the chart). Chips = last 3 **sent** labs (`labDispatchDefaults.ts`) — never auto-select. Clear the search box when switching details.
- **Scroll:** `scrollWithinMainScrollContainer`; shared `Checkbox` only.
Full map: `.cursor/skills/treatment-workspace/SKILL.md`

View File

@@ -11,8 +11,8 @@ Shipped cases get a stable **access token** and share URL. QR + link open a focu
- **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`).
- **Backfill:** `LabCaseAccessService.ensureAccessToken()` for older sent cases when building case detail `shareUrl`; clinic drafts use `TreatmentsService.ensureSentLabCaseAccessTokens()` on getDraft.
- **URL:** `buildLabCaseShareUrl(token, locale)``{FRONTEND_URL}/{locale}/lab-case/{token}` (`backend/src/common/lab-case-access-token.ts`). Clinic lab-case payloads include the same `shareUrl`.
## Backend API (`LabCaseAccessController`)
@@ -41,11 +41,13 @@ Task status updates use **`PATCH /tasks/:id`** (not token routes) — same assig
|-------|------|
| 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 UI | `LabCaseShareQrCode`, `LabCaseShareQrDialog`, thumb in `CaseDetailPanel` **and** Treatment `LabCasesDispatchPanel` |
| 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).
**Treatment lab dispatch:** after send, the same thumb + dialog (same `shareUrl` / token) so the dentist can scan to the focus page. Dest/jobs share a row with the QR; tracker + comments span the card **below** it (not beside it).
**Share focus page:** grouped tasks (reuse `TaskRow`, `TaskCaseGroupHeader`); comments via `LabCaseCommentsPanel` + token API (`viewerSide` from `session.accessMode`). Newest-first; sent/received use logical start/end. Access denied → inline message (`asApiError` for `LAB_CASE_ACCESS_DENIED`).
## Auth redirect (logged out → login → back)

View File

@@ -12,15 +12,16 @@ description: Lab Cases tab — list, filters, detail panel, assignment, share QR
## List behavior
- **Default sort:** `startedAt` / `sentAt` desc (newest first). Lab-origin drafts (`origin === LAB_INTERNAL && !startedAt`) show a **Draft** badge. All list/detail cards also show **Received** (`CLINIC_DISPATCH`) or **Generated** (`LAB_INTERNAL`) via `LabCaseOriginBadge`.
- **Task group headers:** prosthesis type is **colored catalog text** (`prosthesisTypeColorFromCatalog`), not a Badge pill. Same on Tasks rows/headers. Titles use the picker path to the **leaf** (`prosthesisJobPath.ts` via `ProsthesisStackedTypeLabel`) — e.g. Crowns · PFM Crown, not “Crowns” alone. Indirect jobs skip the long parent title: Inlay · Layered ceramic, not Veneer/Inlay/Onlay/Overlay · Inlay · …. Join ancestors/leaf with `PROSTHESIS_JOB_PATH_SEP` (`\u00a0·\u00a0`).
- **Page size:** `PAGE_SIZE = 10` in `CasesPage.tsx`.
- **Auto-select:** On tab open / after filter reload, select first list item if none selected; keep selection when still in list; `?caseId=` URL wins.
- **Add case:** `TAB_CASES_EDIT``POST /cases` then replace the **right pane** with `CaseCreatePanel` (inline wizard, not a modal). Prosthesis-only lines, interactive `FdiToothChart` (same connect/Shift rules as Treatment). Header: referring clinic/dentist, patient name/mobile, optional ACTIVE partner clinic, due date (`AppDateInput`). **Start** (`POST /cases/:id/start`) generates tasks; no `LabCaseSend`, no clinic inbox/`CASE_SENT`. **Delete** (`DELETE /cases/:id`) only for unstarted lab-origin drafts (`LAB_INTERNAL` && `!startedAt`); confirm in the create-panel header next to Start. Received clinic cases and started generated cases cannot be deleted.
- **Add case:** `TAB_CASES_EDIT``POST /cases` then replace the **right pane** with `CaseCreatePanel` (inline wizard, not a modal). Prosthesis-only lines, `ProsthesisAssignChart` (same picker/stacking as Treatment). Summary rows: `LabCaseToothJobsList` (connected same-type teeth share a row; otherwise tooth · types). Header: referring clinic/dentist, patient name/mobile, optional ACTIVE partner clinic, due date (`AppDateInput`). **Start** (`POST /cases/:id/start`) generates tasks; no `LabCaseSend`, no clinic inbox/`CASE_SENT`. **Delete** (`DELETE /cases/:id`) only for unstarted lab-origin drafts (`LAB_INTERNAL` && `!startedAt`); confirm in the create-panel header next to Start. Received clinic cases and started generated cases cannot be deleted.
- **Right panel:** Draft lab-origin → wizard; started/received → `CaseDetailPanel`.
- **Left rail layout:** section is `flex flex-col` + `lg:min-h-[420px]` / `h-full` with `lg:items-stretch` on the grid; case list wrapper is `flex-1 min-h-0 overflow-y-auto` (do **not** use a fixed `max-h-[55vh]` — that leaves empty space above pagination).
List `where`: received clinic cases (`sends` + `sentAt`) **OR** `origin = LAB_INTERNAL` and `destinationOrganizationId = lab`. Map clinic/patient from `treatment` **or** snapshots / partner org. Search includes snapshot names.
Share QR remains clinic-sent only (`sentAt` / `shareUrl`). Lab-origin has no share link in this pass.
Share QR remains clinic-sent only (`sentAt` / `shareUrl`). Lab-origin has no share link in this pass. Clinic Treatment **Lab dispatch** shows the same QR after send (see lab-case-share-link skill).
## Filters (`GET /cases`)
@@ -34,9 +35,9 @@ Filter options: `GET /cases/filter-options` → `clinics`, `prosthesisTypes` (di
## List card UI
Match Treatment shipment cards: patient name, clinic, **colored prosthesis groups + teeth** (`LabCaseProsthesisGroupsList`), sent date, progress bar, due-date badge, unread dot. **No patient mobile** on list cards. Prosthesis row order: **type · teeth**, then Connected badge when that group is a bridge.
Match Treatment shipment cards: patient name, clinic, **colored prosthesis groups + teeth** (`LabCaseProsthesisGroupsList`), sent date, progress bar, due-date badge, unread dot. **No patient mobile** on list cards. Prosthesis row order: **type · teeth**, then Connected badge when that group is a bridge. Type text is the picker path to the leaf (`prosthesisJobPath.ts`).
List item shape: `prosthesisGroups: { prosthesisTypeCode, teeth[] }[]` from task teeth aggregation.
List item shape: `prosthesisGroups: { prosthesisTypeCode, teeth[] }[]` from task teeth aggregation. Unconnected teeth stay one group each (stacked types on that tooth share one `prosthesisTypeCode` key like `crown+abutment`). **Connected** teeth that share a type are one group whose `teeth` lists the bridge. **Partial denture** is one group whose `teeth` is every selected unit.
## Detail panel
@@ -62,7 +63,7 @@ Stock `html2canvas` **cannot** parse `lab()` / `oklch()` from app CSS. Do **not*
| Hex/rgb **inline styles** only in print layout | Tailwind color utilities / CSS variables in capture |
| [`CaseSheetFdiChart`](frontend/src/components/ui/lab/CaseSheetFdiChart.tsx) (SVG, catalog hex colors) | Reuse interactive `FdiToothChart` for PDF |
| `onclone`: strip `style` + `link[rel=stylesheet]` | Rely on global stylesheets during capture |
| Prosthesis labels from **catalog** (then task label) | Fallback `code.replace(/_/g,' ')` alone (breaks casing like `ti base…`) |
| Prosthesis labels from **`prosthesisJobPathLabel`** (category · … · leaf) | Fallback `code.replace(/_/g,' ')` alone (breaks casing like `ti base…`) |
### Layout conventions

View File

@@ -13,15 +13,15 @@ Backend: [`backend/src/modules/tasks/`](backend/src/modules/tasks/)
`sortBy=date` + `sortDir=desc`:
1. `labCase.sentAt` desc (newest case first)
2. `labCaseId`, `treatmentDetailId`, `prosthesisTypeCode` asc (stable grouping)
3. `stepOrder` asc (steps 1→N within prosthesis group)
2. `labCaseId`, `sourceKey`, `tooth`, `prosthesisTypeCode` asc (one pipeline per unconnected tooth; connected same-type teeth share a pipeline; `partial_denture` is one pipeline for all selected teeth)
3. `stepOrder` asc (steps 1→N within that tooth)
4. `id` asc
Other sorts use flat list on the frontend; `stepOrder asc` is still a tiebreaker.
## Case grouping (frontend)
- **`sortBy === 'date'`** → grouped view via [`taskListGrouping.ts`](frontend/src/components/lab/taskListGrouping.ts): case header → prosthesis sub-header → task rows.
- **`sortBy === 'date'`** → grouped view via [`taskListGrouping.ts`](frontend/src/components/lab/taskListGrouping.ts): case header → prosthesis sub-header (one per pipeline: unconnected tooth, or connected same-type bridge) → task rows (`treatmentDetailId:teeth:prosthesisTypeCode`).
- **Other sorts** → flat list; show muted hint (`groupingOff*` i18n keys). Each row keeps clinic/patient/teeth context.
Components: `TaskCaseGroupHeader`, `TaskProsthesisGroupHeader`, `TaskRow`.
@@ -31,7 +31,8 @@ Components: `TaskCaseGroupHeader`, `TaskProsthesisGroupHeader`, `TaskRow`.
## Prosthesis colors
- Map: [`catalog-type-colors.ts`](frontend/src/components/shared/catalog-type-colors.ts) → `PROSTHESIS_TYPE_COLORS` (one hex per catalog code).
- Resolve with [`prosthesisTypeDisplay.ts`](frontend/src/components/treatment/prosthesisTypeDisplay.ts) — use `prosthesisTypeBadgeStyleFromCatalog(code, catalog)`, **not** list row index.
- Resolve with [`prosthesisTypeDisplay.ts`](frontend/src/components/treatment/prosthesisTypeDisplay.ts) — use `prosthesisTypeColorFromCatalog(code, catalog)` as **text color** (no type pills), **not** list row index.
- Job titles: picker path to the last node ([`prosthesisJobPath.ts`](frontend/src/components/treatment/prosthesisJobPath.ts) / `ProsthesisStackedTypeLabel`) so technicians see e.g. Implants · Screw Retained, not “Implants”. Indirect skips the long parent: Inlay · Layered ceramic. Join with `PROSTHESIS_JOB_PATH_SEP` (`\u00a0·\u00a0`) so the gap around · does not collapse.
- Load catalog via `prosthesisCatalogApi.list()` on Tasks/Cases/Today dashboard.
## Filters

View File

@@ -28,7 +28,7 @@ Thin route: `app/[locale]/(dashboard)/treatment/page.tsx` (supports `?appointmen
3. **Treatment history**`PastTreatmentsPanel.tsx` (past saved plans for patient; **client-side** filters in `treatmentHistoryFilters.ts`)
4. **Editor** — detail chips + Add (`TreatmentDetailsEditor`); type-first form with chart + notes; lab send sheet only for prosthesis (`LabCasesDispatchPanel`)
4. **Editor** — detail chips + Add (`TreatmentDetailsEditor`); type-first form with chart **or** lab dispatch in the same slot (`LabCasesDispatchPanel` replaces the chart; not stacked underneath)
@@ -36,17 +36,17 @@ Thin route: `app/[locale]/(dashboard)/treatment/page.tsx` (supports `?appointmen
Right-column entry is **not** a three-step wizard. Type dropdown + `TreatmentDetailAttachmentsStrip` on one row (same height as `Dropdown`; paperclip | divider | thumbs grouped image → pdf → other; upload progress in one square; click thumb → preview/remove dialog). Compact FDI chart (same scale as Cases), then full-width auto-growing **Notes** (`rows={1}`). Chart is dimmed until a type is chosen. Lab-dependent chips show colored sent/unsent **text** (not badge pills); sent date stays on the Lab dispatch tab.
Right-column entry is **not** a three-step wizard. Type dropdown + `TreatmentDetailAttachmentsStrip` on one row (same height as `Dropdown`; paperclip | divider | thumbs grouped image → pdf → other; upload progress in one square; click thumb → preview/remove dialog). When the active type is lab-dependent, a **Lab dispatch** / **Chart** button sits beside attachments. Compact FDI chart (same scale as Cases) **or** `LabCasesDispatchPanel` in that same slot, then full-width auto-growing **Notes** (`rows={1}`). Chart is dimmed until a type is chosen. Lab-dependent chips show colored sent/unsent **text** (not badge pills); sent date stays on the Lab dispatch panel.
| Stage | UI | When |
|-------|-----|------|
| **Treatment** | Type dropdown + `TreatmentDetailAttachmentsStrip`, `FdiToothChart` (Cases scale), full-width Notes | Always |
| **Lab** | `LabCasesDispatchPanel` | Only when active detail type is lab-dependent. Entering Lab auto-ensures a shipment draft. **No default lab or prosthesis type** on a new detail (including siblings in the same plan). Last **3 sent** labs appear as chips under search — pick is explicit. Comments stay on the dispatch panel. |
| **Treatment** | Type dropdown + `TreatmentDetailAttachmentsStrip`, `FdiToothChart` / `ProsthesisAssignChart`, full-width Notes | Default |
| **Lab** | `LabCasesDispatchPanel` in the chart slot | Lab-dependent type + user clicks **Lab dispatch** (or rail / Go to dispatch). Auto-ensures a shipment draft (teeth/arch not required to create the draft). **No default lab or prosthesis type** on a new detail (including siblings in the same plan). Last **3 sent** labs appear as chips under search — pick is explicit. Comments stay on the dispatch panel. After send, the same case QR as lab Cases is shown (`shareUrl`): dest/jobs share a row with the thumb; tracker + comments are full width below. |
- Prosthesis uses `WizardStepper` (Treatment → Lab) with Back/Next. Lab dispatch keeps comments.
- Prosthesis types are assigned on the chart (`ProsthesisAssignChart` + `ProsthesisJobPopover` in `prosthesisTree.ts`). The picker is two columns (`4fr` wrapping category grid / `1fr` add-ons) with a vertical `border-e` divider. Category and subcategory tiles (and matching leaves) show SVGs from `src/assets/prosthesis-catalog` (served from `public/prosthesis-catalog`) via `prosthesisCatalogIcons.ts`. Category tiles keep the wrapping `minmax(8rem, 1fr)` grid and stretch to fill the overlay; expanded children use `minmax(10.2rem, 1fr)` with a parent-colored **L** rail sized to the first child card (not a per-card tree). Parent-bar back arrow is black. Child labels stay one line and ellipsize (`…`) when they overflow. **Add detail**, Lab dispatch, and Chart share one control width (`WorkspaceActionLabel` in `TreatmentDetailsEditor`). Indirect children are **Veneer → Inlay → Onlay → Overlay** (same order as the category title). Crown leaves without a dedicated SVG use the monolithic zirconia drawing, not the Crown parent icon. Empty crown suggestion: plus + dashed chip (`addonCrownCanBeAdded`) inside a full-height slot; after a crown is picked the slot stays as the filled type chip. Arch Upper/Lower/Both is a compact `h-8` segmented control at half the tree column width — it **is** the assignment (`retargetArchJobs`): Both→Upper/Lower drops the other jaw; Upper/Lower→Both copies the type onto the empty jaw; Upper↔Lower moves the job. Chart Upper/Lower arch labels are dashed outline buttons with plus (open the picker; control shows current jobs, or the clicked jaw if none). Category parents use the heaviest family pastel; children only lighten. One **restoration** per tooth (crown / veneer-inlay-onlay-overlay). **Screw-retained** is implant (`stackGroup: implant`, paints the crown) and is itself the restoration — no crown suggestion slot, and Crown / Indirect are disabled. Implant or post & core (without a non-crown restoration) shows a **crown** suggestion slot. A veneer/inlay/onlay/overlay hides the suggestion slot. **Post & core** category is visible but disabled when a restoration or implant is on the tooth. **Implant** category is disabled when post & core is on the tooth. Complete denture / overdenture / appliances / digital use **Upper arch / Lower arch** (`UA`/`LA`). **Partial denture** is tooth-level (select FDI teeth, Removable in the tooth picker); after send it is **one lab job** for all those teeth. Picker leaves are filtered by `chartRegion` so Removable appears in both tooth and arch pickers. Prosthesis FDI teeth **must** have jobs — never persist selected teeth without `toothProsthesis` (`pruneDetailTeethToJobs`). Catalog has no `addonKind` — stacking uses `stackGroup` plus the crown suggestion slot.
- Detail chips show **type + teeth**, not “Detail N”. Lab-dependent chips use colored sent/unsent text (same size as the label); sent date stays on Lab dispatch.
- Detail type may differ from appointment purpose. Purpose seeds the first line of an empty **appointment** draft (first open, and **Add detail** when the plan is `[]`). Later **Add detail** starts with an empty type. Unscheduled / New treatment still seeds a blank first line.
- Switching `activeDetailId` resets to the treatment form, unless `pendingEntryStepRef` is set to `lab` first (lab shipments rail / “Go to dispatch” / load-with-focus).
- Lab shipments rail / “Go to dispatch” / load-with-focus **opens the dispatch view** in the chart slot (`pendingScrollToLabRef` + `labPanelRef`).
- Live draft is **not** duplicated in the left rail preview; preview is for history browse only.
@@ -55,13 +55,14 @@ Right-column entry is **not** a three-step wizard. Type dropdown + `TreatmentDet
Helpers: `frontend/src/components/treatment/toothSelectionGroups.ts`. Persisted as `toothSelectionGroups` on the detail. Lab **dispatch** rows are 1:1 with selection groups; after send, Cases/Tasks **merge** teeth by prosthesis type (not by selection group).
Helpers: `frontend/src/components/treatment/toothSelectionGroups.ts`. Persisted as `toothSelectionGroups` on the detail. Lab **dispatch** lists connected teeth that share a type as **one row** (`LabCaseToothJobsList`); unconnected teeth stay one row per tooth (stacked codes stay together). After send, Cases/Tasks match that: a connected same-type bridge is one pipeline; stacked types on a **single** tooth **union** their workflow steps; `partial_denture` is one task set per `sourceKey`. `LabCaseTask` unique key includes `tooth` (first tooth of a merged bridge).
Chart hit-testing (`FdiToothChart.tsx`): the hit target is the **unrotated full cell** (`pointerdown` + keyboard). Do **not** bind both `pointerdown` and `click` (double-toggle looks like a miss). Visual glyph is `pointer-events-none`; tilt lives on the outer wrapper; **scale/hover is nested inside** so inline `transform` does not kill scale. Horizontal inset (~22%) leaves a dead zone between teeth; FDI numbers are also clickable (`ToothNumber`).
- **Plain click:** add/remove single; deselecting a tooth in a bridge removes it and splits/shrinks remaining sides (never a 1-tooth connected group).
- **Plain click (non-prosthesis chart):** add/remove single; deselecting a tooth in a bridge removes it and splits/shrinks remaining sides (never a 1-tooth connected group).
- **Neighbor circles:** when two arch-adjacent teeth are both selected, an empty circle appears **between** them (not per-tooth). Click empty → connect; click filled → disconnect (teeth stay selected). Transitive AB + BC = one bridge.
- **Shift+click:** inclusive same-arch range → all selected as **singles** (empty circles); overlapping existing bridges are absorbed as singles too. Midline neighbors (1121, 4131) allowed.
- **Shift+click (non-prosthesis chart):** inclusive same-arch range → all selected as **singles** (empty circles); overlapping existing bridges are absorbed as singles too. Midline neighbors (1121, 4131) allowed.
- **Prosthesis assign chart (Exocad-style):** plain click **opens the picker** and does not select the tooth until a type is picked. **Done** stores that tooths job set as the copy brush. **Ctrl/Cmd+click** or drag copies the brush onto one tooth. **Shift+click** copies the brush onto the inclusive same-arch range as **singles** (no auto-connect; neighbor circles still make bridges). Arch products open from the Upper/Lower arch labels and store `UA`/`LA` (not 16 FDI teeth). Partial denture is assigned from selected teeth (not the arch labels). `pruneToothProsthesisForGroups` must keep those sentinels. Hydrate/save prune FDI teeth that have no job (`pruneDetailTeethToJobs`).
- Prevent browser selection artifacts (`select-none`, Shift `preventDefault` on mousedown).
- On group change, prune/remap `labCase.toothProsthesis` via `pruneToothProsthesisForGroups`.
- Connected UI label: `ConnectedSelectionBadge` (shared `Badge` + primary tint) in dispatch + lab case lists.
@@ -79,7 +80,7 @@ Chart hit-testing (`FdiToothChart.tsx`): the hit target is the **unrotated full
| **Preview** | `previewTreatment`; `selectedPreviewId !== null` = **browse mode** | History click updates preview only |
| **Form** | `details[]`, `labCaseDrafts[]` | Appointment change → draft API; **Load into workspace** → hydrate |
| **Form** | `details[]`, `labCaseDrafts[]` | Appointment change → draft API; **Load into workspace** → hydrate. Autosave and strip-switch **flush both** `saveDraft` then `saveLabCases`. Dirty includes the lab-case snapshot (jobs, dest, due date), not only details. Non-prosthesis plans keep empty drafts and do not create lab rows. |
@@ -114,15 +115,15 @@ Helpers: `frontend/src/components/treatment/treatmentHistoryFilters.ts`.
## Lab shipment without teeth
## Lab shipment without teeth or arch jobs
Saved lab-dependent detail with **no teeth** can autosave but **cannot** create a lab shipment.
Saved lab-dependent detail with **no FDI teeth and no arch jobs** (`UA`/`LA`) can autosave but **cannot** create a lab shipment.
- Inline amber banner in `TreatmentDetailsEditor` on **both** Treatment and Lab steps when the active detail qualifies (`isLabDependentDetailMissingTeeth`). Do not use a separate notice card.
- `handleAddLabCase` shows toast with `labShipmentBlockedBody`.
- Dispatch panel only appears when a detail passes `isDetailReadyForLabDispatch` (persisted + lab-dependent + teeth).
- Dispatch panel only appears when a detail passes `isDetailReadyForLabDispatch` (persisted + lab-dependent + teeth **or** arch jobs).
@@ -131,7 +132,7 @@ Saved lab-dependent detail with **no teeth** can autosave but **cannot** create
Comments for a shipment live in the Lab dispatch panel (and Cases/Tasks/share), not on Content notes.
- Unsent: deferred composer in `DetailLabCaseCommentsSection` (posts with Send to lab).
- Sent / in progress: live composer until tasks complete (`canPostComments`).
- Sent / in progress: live composer until tasks complete (`canPostComments`). Same-card share QR sits beside dest/jobs only; tracker + comments stay full width below (`LabCasesDispatchPanel`).
- UI: `DetailLabCaseCommentsSection``LabCaseCommentsPanel` + `treatmentsApi` comment endpoints (`viewerSide="CLINIC"`).
- Backend includes `tasks: { select: { id, status } }` on lab cases; `mapDetail` exposes `taskProgress: { completed, total }`.
@@ -155,7 +156,7 @@ On today: in-progress slot first, else nearest start time to `now`. Other days:
`LinkedOrganizationSearchCombobox` in `LabCasesDispatchPanel` — search-only results (no dropdown). Chips under the search are the last **3 labs this clinic sent a case to** (`rememberRecentLab` after successful send). They are shortcuts, not defaults: a new details lab and prosthesis type (apply-all and per-tooth) stay empty until the user chooses. Switching details clears the search box. No match + org tab access → **Invite a lab** navigates to `/organizations?action=invite-lab`. No org access → show permission message; dispatch stops.
`LinkedOrganizationSearchCombobox` in `LabCasesDispatchPanel` — search-only results (no dropdown). Chips under the search are the last **3 labs this clinic sent a case to** (`rememberRecentLab` after successful send). They are shortcuts, not defaults: a new details lab stays empty until the user chooses (types stay on the chart). Switching details clears the search box. No match + org tab access → **Invite a lab** navigates to `/organizations?action=invite-lab`. No org access → show permission message; dispatch stops.

2
.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
# Shell scripts must use LF — CRLF breaks Alpine entrypoints ("No such file or directory").
*.sh text eol=lf

View File

@@ -0,0 +1,228 @@
# Production: build images for https://nudentic.ir, push :v* to Gitea (not :latest),
# SCP compose to Linux, docker login wixur.ir:3000, compose pull + up.
#
# Triggers: git tag v1.0.1 (and v*.*.*), or Actions → this workflow → Run (input tag).
# Staging stays on master: .gitea/workflows/registry-build-deploy.yml
#
# Do NOT push :latest — staging already uses :latest with the wixur.ir frontend bake.
#
# Variables:
# REGISTRY_HOST Windows Docker push host, e.g. host.docker.internal:3000
# (not gitea.wixur.ir unless nginx client_max_body_size 0)
# REGISTRY_OWNER Gitea user/org for packages
# CLONE_HOST git clone, e.g. 127.0.0.1:3000
# PROD_PUBLIC_BASE_URL https://nudentic.ir (no trailing slash)
# NEXT_PUBLIC_SENTRY_DSN GlitchTip frontend project DSN
# PROD_REGISTRY_HOST Linux pull host, e.g. wixur.ir:3000 (HTTP; insecure-registries on the VPS)
# PROD_INFRA_DIR /opt/dyolink/infrastructure
#
# Secrets (in addition to REGISTRY_USERNAME / REGISTRY_PASSWORD):
# PROD_SSH_HOST Linux public IP or hostname
# PROD_SSH_USER e.g. dyolink
# PROD_SSH_KEY OpenSSH private key (full PEM)
# PROD_SSH_PORT optional, default 22
#
# One-time on Linux: insecure-registries ["wixur.ir:3000"], .env REGISTRY_PREFIX + DOMAIN=nudentic.ir
# See infrastructure/DEPLOY.md
name: Production — tag build, push, deploy
on:
push:
tags:
- 'v*.*.*'
workflow_dispatch:
inputs:
tag:
description: 'Release tag to build and deploy (e.g. v1.0.1)'
required: true
defaults:
run:
shell: powershell
jobs:
build-and-push:
runs-on: windows
outputs:
image_tag: ${{ steps.meta.outputs.image_tag }}
steps:
- name: Checkout (this Gitea)
run: |
$ErrorActionPreference = 'Stop'
$cloneHost = '${{ vars.CLONE_HOST }}'.Trim()
if ([string]::IsNullOrWhiteSpace($cloneHost)) {
$Server = "${{ github.server_url }}".TrimEnd('/')
} elseif ($cloneHost -match '^https?://') {
$Server = $cloneHost.TrimEnd('/')
} else {
$Server = 'http://' + $cloneHost
}
$Repo = "${{ github.repository }}"
$dispatchTag = '${{ github.event.inputs.tag }}'.Trim()
if (-not [string]::IsNullOrWhiteSpace($dispatchTag)) {
$Branch = $dispatchTag
} else {
$Branch = "${{ github.ref_name }}"
}
$Token = "${{ github.token }}"
$Actor = "${{ github.actor }}"
$hp = $Server -replace '^https?://', ''
if ($Server.StartsWith('https')) {
$cloneUrl = 'https://' + $Actor + ':' + $Token + '@' + $hp + '/' + $Repo + '.git'
} else {
$cloneUrl = 'http://' + $Actor + ':' + $Token + '@' + $hp + '/' + $Repo + '.git'
}
$env:GIT_TERMINAL_PROMPT = '0'
git clone --depth 1 --branch $Branch $cloneUrl .
- name: Resolve image tag (v*.*.* only)
id: meta
run: |
$ErrorActionPreference = 'Stop'
$dispatchTag = '${{ github.event.inputs.tag }}'.Trim()
if (-not [string]::IsNullOrWhiteSpace($dispatchTag)) {
$tag = $dispatchTag
} else {
$tag = "${{ github.ref_name }}"
}
if ($tag -notmatch '^v\d+\.\d+\.\d+') {
Write-Host "Production images must be tagged vMAJOR.MINOR.PATCH (got: $tag)"
exit 1
}
$utf8 = New-Object System.Text.UTF8Encoding $false
[System.IO.File]::AppendAllText($env:GITHUB_OUTPUT, "image_tag=$tag`n", $utf8)
$prefix = "${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_OWNER }}"
[System.IO.File]::AppendAllText($env:GITHUB_ENV, "REGISTRY_PREFIX=$prefix`n", $utf8)
Write-Host "image_tag=$tag REGISTRY_PREFIX=$prefix"
- name: Log in to container registry
run: |
$ErrorActionPreference = 'Stop'
$pass = @'
${{ secrets.REGISTRY_PASSWORD }}
'@
$pass.Trim() | docker login "${{ vars.REGISTRY_HOST }}" -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
- name: Build and push backend (tag only, not :latest)
run: |
$ErrorActionPreference = 'Stop'
$tag = "${{ steps.meta.outputs.image_tag }}"
docker build -t "$env:REGISTRY_PREFIX/dyolink-backend:$tag" ./backend
docker push "$env:REGISTRY_PREFIX/dyolink-backend:$tag"
- name: Build and push frontend (nudentic.ir baked in)
env:
PROD_PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
NEXT_PUBLIC_SENTRY_DSN: ${{ vars.NEXT_PUBLIC_SENTRY_DSN }}
run: |
$ErrorActionPreference = 'Stop'
$tag = "${{ steps.meta.outputs.image_tag }}"
$base = $env:PROD_PUBLIC_BASE_URL.Trim()
if ([string]::IsNullOrWhiteSpace($base)) { $base = 'https://nudentic.ir' }
$base = $base.TrimEnd('/')
docker build `
--build-arg "NEXT_PUBLIC_API_URL=$base/api" `
--build-arg "NEXT_PUBLIC_APP_URL=$base" `
--build-arg "NEXT_PUBLIC_APP_NAME=Dyolink" `
--build-arg "NEXT_PUBLIC_SENTRY_DSN=$env:NEXT_PUBLIC_SENTRY_DSN" `
--build-arg "NEXT_PUBLIC_SENTRY_ENVIRONMENT=production" `
-t "$env:REGISTRY_PREFIX/dyolink-frontend:$tag" `
./frontend
docker push "$env:REGISTRY_PREFIX/dyolink-frontend:$tag"
deploy:
needs: build-and-push
runs-on: windows
steps:
- name: Checkout (tagged tree for compose file)
run: |
$ErrorActionPreference = 'Stop'
$cloneHost = '${{ vars.CLONE_HOST }}'.Trim()
if ([string]::IsNullOrWhiteSpace($cloneHost)) {
$Server = "${{ github.server_url }}".TrimEnd('/')
} elseif ($cloneHost -match '^https?://') {
$Server = $cloneHost.TrimEnd('/')
} else {
$Server = 'http://' + $cloneHost
}
$Repo = "${{ github.repository }}"
$dispatchTag = '${{ github.event.inputs.tag }}'.Trim()
if (-not [string]::IsNullOrWhiteSpace($dispatchTag)) {
$Branch = $dispatchTag
} else {
$Branch = "${{ github.ref_name }}"
}
$Token = "${{ github.token }}"
$Actor = "${{ github.actor }}"
$hp = $Server -replace '^https?://', ''
if ($Server.StartsWith('https')) {
$cloneUrl = 'https://' + $Actor + ':' + $Token + '@' + $hp + '/' + $Repo + '.git'
} else {
$cloneUrl = 'http://' + $Actor + ':' + $Token + '@' + $hp + '/' + $Repo + '.git'
}
$env:GIT_TERMINAL_PROMPT = '0'
git clone --depth 1 --branch $Branch $cloneUrl .
- name: Write SSH key
run: |
$ErrorActionPreference = 'Stop'
$raw = @'
${{ secrets.PROD_SSH_KEY }}
'@
$key = $raw.Trim()
if ($key -notmatch 'BEGIN') {
Write-Host "Set secret PROD_SSH_KEY to an OpenSSH private key"
exit 1
}
$keyPath = Join-Path $env:RUNNER_TEMP 'prod_ssh'
$normalized = $key.Replace("`r`n", "`n").TrimEnd() + "`n"
[System.IO.File]::WriteAllText($keyPath, $normalized)
icacls $keyPath /inheritance:r | Out-Null
icacls $keyPath /grant:r "$($env:USERNAME):(R)" | Out-Null
[System.IO.File]::AppendAllText($env:GITHUB_ENV, "PROD_SSH_KEY_PATH=$keyPath`n")
- name: Copy compose + deploy script to Linux
run: |
$ErrorActionPreference = 'Stop'
$hostName = '${{ secrets.PROD_SSH_HOST }}'.Trim()
$user = '${{ secrets.PROD_SSH_USER }}'.Trim()
$port = '${{ secrets.PROD_SSH_PORT }}'.Trim()
if ([string]::IsNullOrWhiteSpace($port)) { $port = '22' }
$infra = '${{ vars.PROD_INFRA_DIR }}'.Trim()
if ([string]::IsNullOrWhiteSpace($infra)) { $infra = '/opt/dyolink/infrastructure' }
$ssh = @('-i', $env:PROD_SSH_KEY_PATH, '-o', 'StrictHostKeyChecking=accept-new')
ssh.exe @ssh -p $port "${user}@${hostName}" "mkdir -p $infra/scripts $infra/nginx"
scp.exe @ssh -P $port `
infrastructure/docker-compose.prod.yml `
"${user}@${hostName}:${infra}/docker-compose.prod.yml"
scp.exe @ssh -P $port `
infrastructure/scripts/prod-remote-deploy.sh `
"${user}@${hostName}:${infra}/scripts/prod-remote-deploy.sh"
scp.exe @ssh -P $port `
infrastructure/scripts/render-nginx-ssl.sh `
"${user}@${hostName}:${infra}/scripts/render-nginx-ssl.sh"
scp.exe @ssh -P $port `
infrastructure/nginx/nginx.ssl.conf.template `
"${user}@${hostName}:${infra}/nginx/nginx.ssl.conf.template"
ssh.exe @ssh -p $port "${user}@${hostName}" "chmod +x $infra/scripts/prod-remote-deploy.sh $infra/scripts/render-nginx-ssl.sh"
- name: Login on Linux and deploy tag
run: |
$ErrorActionPreference = 'Stop'
$hostName = '${{ secrets.PROD_SSH_HOST }}'.Trim()
$user = '${{ secrets.PROD_SSH_USER }}'.Trim()
$port = '${{ secrets.PROD_SSH_PORT }}'.Trim()
if ([string]::IsNullOrWhiteSpace($port)) { $port = '22' }
$infra = '${{ vars.PROD_INFRA_DIR }}'.Trim()
if ([string]::IsNullOrWhiteSpace($infra)) { $infra = '/opt/dyolink/infrastructure' }
$regHost = '${{ vars.PROD_REGISTRY_HOST }}'.Trim()
if ([string]::IsNullOrWhiteSpace($regHost)) { $regHost = 'wixur.ir:3000' }
$tag = "${{ needs.build-and-push.outputs.image_tag }}"
$pass = @'
${{ secrets.REGISTRY_PASSWORD }}
'@
$regUser = '${{ secrets.REGISTRY_USERNAME }}'
$ssh = @('-i', $env:PROD_SSH_KEY_PATH, '-o', 'StrictHostKeyChecking=accept-new')
$remote = "docker login $regHost -u $regUser --password-stdin && PROD_INFRA_DIR=$infra $infra/scripts/prod-remote-deploy.sh $tag"
$pass.Trim() | ssh.exe @ssh -p $port "${user}@${hostName}" $remote

View File

@@ -1,30 +1,38 @@
# Build backend/frontend images, push to Gitea Container Registry, deploy with pull-only compose.
# Staging: build backend/frontend images, push to Gitea Container Registry, deploy on self-hosted runner.
#
# Repository Variables (Settings → Actions → Variables) — non-secret:
# REGISTRY_HOST e.g. 178.131.50.201:3000 (no http/https)
# REGISTRY_OWNER Gitea user or org that owns the packages (same as image namespace)
# PUBLIC_BASE_URL URL users open in browser, e.g. http://178.131.50.201:8088 (no trailing slash)
# Triggers: push to master/main, or manual workflow_dispatch.
# Production (nudentic.ir / git tags): .gitea/workflows/prod-tag-deploy.yml — this file is Windows staging only.
#
# Repository Variables (Settings → Actions → Variables):
# REGISTRY_HOST Docker registry host:port (no http/https).
# Windows Docker Desktop → host.docker.internal:3000
# Do NOT use gitea.wixur.ir unless Windows nginx for that
# host has client_max_body_size 0 — Docker layer PUTs 413 otherwise.
# REGISTRY_OWNER Gitea user or org that owns the packages
# PUBLIC_BASE_URL URL users open in the browser, e.g. http://wixur.ir (no trailing slash, no :8088)
# NEXT_PUBLIC_SENTRY_DSN GlitchTip frontend project DSN (https://…@errors.wixur.ir/…)
# DEPLOY_SECRETS_DIR absolute path on runner, e.g. C:/dyolink/secrets
#
# Optional:
# STAGING_HTTP_PORT public HTTP port (default 80) — Windows portproxy listens here → 18088
# STAGING_LOCAL_PORT Docker bind on 127.0.0.1 (default 18088) — must not equal the public port if portproxy owns it
# CLONE_HOST git clone host when runner = Gitea host → 127.0.0.1:3000
#
# Same Windows PC runs Gitea + runner + deploy:
# CLONE_HOST → 127.0.0.1:3000 (git runs on Windows host)
# REGISTRY_HOST → host.docker.internal:3000 (docker commands run inside Docker Desktop VM)
# Gitea app.ini ROOT_URL → http://host.docker.internal:3000/ (Docker registry auth; browsers use http://wixur.ir:3000)
# PUBLIC_BASE_URL → http://wixur.ir (staging app; Gitea stays on :3000)
#
# Add host.docker.internal:3000 (and wixur.ir:3000 if you pull by public hostname) to Docker Desktop insecure-registries.
#
# Repository Secrets (Settings → Actions → Secrets):
# REGISTRY_USERNAME Gitea username for docker login
# REGISTRY_PASSWORD Gitea access token (packages:read/write) or account password
#
# HTTP registry (typical self-hosted Gitea): Docker defaults to HTTPS. If login/push fails with
# "server gave HTTP response to HTTPS client", add REGISTRY_HOST (e.g. 192.168.1.100:3000) to the
# Docker daemon "insecure-registries" on the RUNNER machine, then restart Docker (Docker Desktop
# → Settings → Docker Engine → JSON → "insecure-registries": ["host:port"]).
# Docker on runner: insecure-registries e.g. ["host.docker.internal:3000","wixur.ir:3000"]
#
# Optional:
# STAGING_HTTP_PORT host port for nginx (default 8088)
#
# Required for deploy job (absolute path on the runner host):
# DEPLOY_SECRETS_DIR folder containing database.staging.env + backend.staging.env
#
# Runner: self-hosted with Docker. Default shell is powershell (Windows act_runner often has no WSL bash).
# For a Linux runner, change defaults.run.shell to bash and restore bash syntax if needed.
#
# We do NOT use gitea.com/actions/checkout — many restricted networks cannot reach gitea.com.
# Checkout is a plain git clone from the same Gitea host.
# Runner: self-hosted with Docker + git. Default shell is powershell (Windows act_runner).
name: Registry — build, push, deploy
@@ -38,189 +46,155 @@ defaults:
shell: powershell
jobs:
temp-success:
runs-on: self-hosted
build-and-push:
runs-on: windows
outputs:
image_tag: ${{ steps.meta.outputs.image_tag }}
steps:
- name: Temporary placeholder (always success)
- name: Checkout (clone from this Gitea — no gitea.com)
run: |
$ErrorActionPreference = 'Stop'
Write-Host "Temporary workflow is active."
Write-Host "Trigger: ${{ github.event_name }}"
Write-Host "Branch: ${{ github.ref_name }}"
Write-Host "Commit: ${{ github.sha }}"
Write-Host "Production build/push/deploy steps are intentionally commented."
exit 0
$cloneHost = '${{ vars.CLONE_HOST }}'.Trim()
if ([string]::IsNullOrWhiteSpace($cloneHost)) {
$Server = "${{ github.server_url }}".TrimEnd('/')
} elseif ($cloneHost -match '^https?://') {
$Server = $cloneHost.TrimEnd('/')
} else {
$Server = 'http://' + $cloneHost
}
$Repo = "${{ github.repository }}"
$Branch = "${{ github.ref_name }}"
$Token = "${{ github.token }}"
$Actor = "${{ github.actor }}"
$hp = $Server -replace '^https?://', ''
if ($Server.StartsWith('https')) {
$cloneUrl = 'https://' + $Actor + ':' + $Token + '@' + $hp + '/' + $Repo + '.git'
} else {
$cloneUrl = 'http://' + $Actor + ':' + $Token + '@' + $hp + '/' + $Repo + '.git'
}
$env:GIT_TERMINAL_PROMPT = '0'
git clone --depth 1 --branch $Branch $cloneUrl .
# ---------------------------------------------------------------------------
# Production pipeline is temporarily disabled.
# Uncomment these jobs after split-DNS / registry reachability is fixed.
# ---------------------------------------------------------------------------
#
# build-and-push:
# runs-on: self-hosted
# outputs:
# image_tag: ${{ steps.meta.outputs.image_tag }}
# steps:
# - name: Checkout (clone from this Gitea — no gitea.com)
# - name: Image tag and registry prefix
# - name: Log in to container registry
# - name: Build and push backend
# - name: Build and push frontend
#
# deploy:
# needs: build-and-push
# runs-on: self-hosted
# steps:
# - name: Checkout (shallow clone from this Gitea — no gitea.com)
# - name: Write deploy.registry.env and validate secrets path
# - name: Log in to container registry (for pull)
# - name: Pull and start stack
- name: Image tag and registry prefix
id: meta
run: |
$ErrorActionPreference = 'Stop'
$short = (git rev-parse --short HEAD).Trim()
$utf8 = New-Object System.Text.UTF8Encoding $false
[System.IO.File]::AppendAllText($env:GITHUB_OUTPUT, "image_tag=$short`n", $utf8)
$prefix = "${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_OWNER }}"
[System.IO.File]::AppendAllText($env:GITHUB_ENV, "REGISTRY_PREFIX=$prefix`n", $utf8)
- name: Log in to container registry
run: |
$ErrorActionPreference = 'Stop'
$pass = @'
${{ secrets.REGISTRY_PASSWORD }}
'@
$pass.Trim() | docker login "${{ vars.REGISTRY_HOST }}" -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
- name: Build and push backend
run: |
$ErrorActionPreference = 'Stop'
$tag = "${{ steps.meta.outputs.image_tag }}"
docker build `
-t "$env:REGISTRY_PREFIX/dyolink-backend:$tag" `
-t "$env:REGISTRY_PREFIX/dyolink-backend:latest" `
./backend
docker push "$env:REGISTRY_PREFIX/dyolink-backend:$tag"
docker push "$env:REGISTRY_PREFIX/dyolink-backend:latest"
####SAMPLE
# name: Registry — build, push, deploy
- name: Build and push frontend
env:
PUBLIC_BASE_URL: ${{ vars.PUBLIC_BASE_URL }}
NEXT_PUBLIC_SENTRY_DSN: ${{ vars.NEXT_PUBLIC_SENTRY_DSN }}
run: |
$ErrorActionPreference = 'Stop'
$tag = "${{ steps.meta.outputs.image_tag }}"
$base = $env:PUBLIC_BASE_URL
docker build `
--build-arg "NEXT_PUBLIC_API_URL=$base/api" `
--build-arg "NEXT_PUBLIC_APP_URL=$base" `
--build-arg "NEXT_PUBLIC_APP_NAME=Dyolink" `
--build-arg "NEXT_PUBLIC_SENTRY_DSN=$env:NEXT_PUBLIC_SENTRY_DSN" `
--build-arg "NEXT_PUBLIC_SENTRY_ENVIRONMENT=staging" `
-t "$env:REGISTRY_PREFIX/dyolink-frontend:$tag" `
-t "$env:REGISTRY_PREFIX/dyolink-frontend:latest" `
./frontend
docker push "$env:REGISTRY_PREFIX/dyolink-frontend:$tag"
docker push "$env:REGISTRY_PREFIX/dyolink-frontend:latest"
# on:
# push:
# branches: [main, master]
# workflow_dispatch:
deploy:
needs: build-and-push
runs-on: windows
steps:
- name: Checkout (shallow clone from this Gitea — no gitea.com)
run: |
$ErrorActionPreference = 'Stop'
$cloneHost = '${{ vars.CLONE_HOST }}'.Trim()
if ([string]::IsNullOrWhiteSpace($cloneHost)) {
$Server = "${{ github.server_url }}".TrimEnd('/')
} elseif ($cloneHost -match '^https?://') {
$Server = $cloneHost.TrimEnd('/')
} else {
$Server = 'http://' + $cloneHost
}
$Repo = "${{ github.repository }}"
$Branch = "${{ github.ref_name }}"
$Token = "${{ github.token }}"
$Actor = "${{ github.actor }}"
$hp = $Server -replace '^https?://', ''
if ($Server.StartsWith('https')) {
$cloneUrl = 'https://' + $Actor + ':' + $Token + '@' + $hp + '/' + $Repo + '.git'
} else {
$cloneUrl = 'http://' + $Actor + ':' + $Token + '@' + $hp + '/' + $Repo + '.git'
}
$env:GIT_TERMINAL_PROMPT = '0'
git clone --depth 1 --branch $Branch $cloneUrl .
# defaults:
# run:
# shell: powershell
- name: Write deploy.registry.env and validate secrets path
run: |
$ErrorActionPreference = 'Stop'
$SD = '${{ vars.DEPLOY_SECRETS_DIR }}'.Trim()
if ([string]::IsNullOrWhiteSpace($SD)) {
Write-Host "Set repository variable DEPLOY_SECRETS_DIR to the absolute path on this runner"
Write-Host "where database.staging.env and backend.staging.env live (not in git)."
exit 1
}
if (-not (Test-Path (Join-Path $SD "database.staging.env"))) {
Write-Host "Missing $(Join-Path $SD 'database.staging.env')"
exit 1
}
if (-not (Test-Path (Join-Path $SD "backend.staging.env"))) {
Write-Host "Missing $(Join-Path $SD 'backend.staging.env')"
exit 1
}
$stagingPort = '${{ vars.STAGING_HTTP_PORT }}'.Trim()
if ([string]::IsNullOrWhiteSpace($stagingPort)) { $stagingPort = '80' }
$localPort = '${{ vars.STAGING_LOCAL_PORT }}'.Trim()
if ([string]::IsNullOrWhiteSpace($localPort)) { $localPort = '18088' }
$imageTag = "${{ needs.build-and-push.outputs.image_tag }}"
$lines = @(
"REGISTRY_PREFIX=${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_OWNER }}",
"IMAGE_TAG=$imageTag",
"STAGING_HTTP_PORT=$stagingPort",
"STAGING_LOCAL_PORT=$localPort",
"DEPLOY_SECRETS_DIR=$SD"
)
Set-Location infrastructure
$lines | Set-Content -Path deploy.registry.env -Encoding utf8
# jobs:
# build-and-push:
# runs-on: self-hosted
# outputs:
# image_tag: ${{ steps.meta.outputs.image_tag }}
# steps:
# - name: Checkout (clone from this Gitea — no gitea.com)
# run: |
# $ErrorActionPreference = 'Stop'
# $Server = "${{ github.server_url }}".TrimEnd('/')
# $Repo = "${{ github.repository }}"
# $Branch = "${{ github.ref_name }}"
# $Token = "${{ github.token }}"
# $Actor = "${{ github.actor }}"
# $hp = $Server -replace '^https?://', ''
# if ($Server.StartsWith('https')) {
# $cloneUrl = 'https://' + $Actor + ':' + $Token + '@' + $hp + '/' + $Repo + '.git'
# } else {
# $cloneUrl = 'http://' + $Actor + ':' + $Token + '@' + $hp + '/' + $Repo + '.git'
# }
# $env:GIT_TERMINAL_PROMPT = '0'
# git clone --depth 1 --branch $Branch $cloneUrl .
# - name: Image tag and registry prefix
# id: meta
# run: |
# $ErrorActionPreference = 'Stop'
# $short = (git rev-parse --short HEAD).Trim()
# $utf8 = New-Object System.Text.UTF8Encoding $false
# [System.IO.File]::AppendAllText($env:GITHUB_OUTPUT, "image_tag=$short`n", $utf8)
# $prefix = "${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_OWNER }}"
# [System.IO.File]::AppendAllText($env:GITHUB_ENV, "REGISTRY_PREFIX=$prefix`n", $utf8)
# - name: Log in to container registry
# run: |
# $ErrorActionPreference = 'Stop'
# $pass = @'
# ${{ secrets.REGISTRY_PASSWORD }}
# '@
# $pass.Trim() | docker login "${{ vars.REGISTRY_HOST }}" -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
# - name: Build and push backend
# run: |
# $ErrorActionPreference = 'Stop'
# $tag = "${{ steps.meta.outputs.image_tag }}"
# docker build `
# -t "$env:REGISTRY_PREFIX/dyolink-backend:$tag" `
# -t "$env:REGISTRY_PREFIX/dyolink-backend:latest" `
# ./backend
# docker push "$env:REGISTRY_PREFIX/dyolink-backend:$tag"
# docker push "$env:REGISTRY_PREFIX/dyolink-backend:latest"
# - name: Build and push frontend
# env:
# PUBLIC_BASE_URL: ${{ vars.PUBLIC_BASE_URL }}
# run: |
# $ErrorActionPreference = 'Stop'
# $tag = "${{ steps.meta.outputs.image_tag }}"
# $base = $env:PUBLIC_BASE_URL
# docker build `
# --build-arg "NEXT_PUBLIC_API_URL=$base/api" `
# --build-arg "NEXT_PUBLIC_APP_URL=$base" `
# --build-arg "NEXT_PUBLIC_APP_NAME=Dyolink" `
# -t "$env:REGISTRY_PREFIX/dyolink-frontend:$tag" `
# -t "$env:REGISTRY_PREFIX/dyolink-frontend:latest" `
# ./frontend
# docker push "$env:REGISTRY_PREFIX/dyolink-frontend:$tag"
# docker push "$env:REGISTRY_PREFIX/dyolink-frontend:latest"
# deploy:
# needs: build-and-push
# runs-on: self-hosted
# steps:
# - name: Checkout (shallow clone from this Gitea — no gitea.com)
# run: |
# $ErrorActionPreference = 'Stop'
# $Server = "${{ github.server_url }}".TrimEnd('/')
# $Repo = "${{ github.repository }}"
# $Branch = "${{ github.ref_name }}"
# $Token = "${{ github.token }}"
# $Actor = "${{ github.actor }}"
# $hp = $Server -replace '^https?://', ''
# if ($Server.StartsWith('https')) {
# $cloneUrl = 'https://' + $Actor + ':' + $Token + '@' + $hp + '/' + $Repo + '.git'
# } else {
# $cloneUrl = 'http://' + $Actor + ':' + $Token + '@' + $hp + '/' + $Repo + '.git'
# }
# $env:GIT_TERMINAL_PROMPT = '0'
# git clone --depth 1 --branch $Branch $cloneUrl .
# - name: Write deploy.registry.env and validate secrets path
# run: |
# $ErrorActionPreference = 'Stop'
# $SD = '${{ vars.DEPLOY_SECRETS_DIR }}'.Trim()
# if ([string]::IsNullOrWhiteSpace($SD)) {
# Write-Host "Set repository variable DEPLOY_SECRETS_DIR to the absolute path on this runner"
# Write-Host "where database.staging.env and backend.staging.env live (not in git)."
# exit 1
# }
# if (-not (Test-Path (Join-Path $SD "database.staging.env"))) {
# Write-Host "Missing $(Join-Path $SD 'database.staging.env')"
# exit 1
# }
# if (-not (Test-Path (Join-Path $SD "backend.staging.env"))) {
# Write-Host "Missing $(Join-Path $SD 'backend.staging.env')"
# exit 1
# }
# $stagingPort = '${{ vars.STAGING_HTTP_PORT }}'.Trim()
# if ([string]::IsNullOrWhiteSpace($stagingPort)) { $stagingPort = '8088' }
# $imageTag = "${{ needs.build-and-push.outputs.image_tag }}"
# $lines = @(
# "REGISTRY_PREFIX=${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_OWNER }}",
# "IMAGE_TAG=$imageTag",
# "STAGING_HTTP_PORT=$stagingPort",
# "DEPLOY_SECRETS_DIR=$SD"
# )
# Set-Location infrastructure
# $lines | Set-Content -Path deploy.registry.env -Encoding utf8
# - name: Log in to container registry (for pull)
# run: |
# $ErrorActionPreference = 'Stop'
# $pass = @'
# ${{ secrets.REGISTRY_PASSWORD }}
# '@
# $pass.Trim() | docker login "${{ vars.REGISTRY_HOST }}" -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
# - name: Pull and start stack
# run: |
# $ErrorActionPreference = 'Stop'
# Set-Location infrastructure
# docker compose -f docker-compose.registry.yml --env-file deploy.registry.env pull backend frontend
# docker compose -f docker-compose.registry.yml --env-file deploy.registry.env up -d
- name: Log in to container registry (for pull)
run: |
$ErrorActionPreference = 'Stop'
$pass = @'
${{ secrets.REGISTRY_PASSWORD }}
'@
$pass.Trim() | docker login "${{ vars.REGISTRY_HOST }}" -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
- name: Pull and start stack
run: |
$ErrorActionPreference = 'Stop'
Set-Location infrastructure
docker compose -f docker-compose.registry.yml --env-file deploy.registry.env pull backend frontend
docker compose -f docker-compose.registry.yml --env-file deploy.registry.env up -d

View File

@@ -10,7 +10,7 @@ Dental clinic ↔ lab platform (monorepo):
|------|--------|
| `backend/` | NestJS, Prisma, PostgreSQL |
| `frontend/` | Next.js 16, React 19, next-intl, Tailwind |
| `infrastructure/` | Docker, nginx, deploy scripts |
| `infrastructure/` | Docker — staging `https://wixur.ir` (Windows, `STAGING-DEPLOY.md`); prod `https://nudentic.ir` (Linux, `DEPLOY.md`) |
**Organization types:** `CLINIC` (patients, appointments, treatment) and `LAB` (cases, tasks). Many features are org-type-specific. Permissions use `TAB_*_READ` / `TAB_*_EDIT` codes — see `backend/src/common/permissions.ts`.
@@ -47,31 +47,31 @@ frontend/src/
- Day/mode gate: editable only for live draft on today/future (`canEditTreatmentForDay`). Past day / historical load → read-only form. Patient search sits in the **page header** (workspace-wide). If the patient has a visit on the day strip, that visit opens; else the latest history plan loads. If neither exists, the editor shows an inline empty state (no dialog) pointing to **New treatment** in the rail — do not auto-create. **New treatment** is at the top of the left rail (selected-patient card below it) and opens a picker: Walk-in first, then a matching card for the current named patient (name + mobile/email), then search. It does not auto-copy the appointment cards patient. New treatment seeds one blank detail; a persisted empty plan loads as `[]` until Add.
- **Unscheduled strip cards** use the same treatment-type banner as appointments from the **first details type only** (`unscheduledStripColorCode`; live draft for the open card). Empty first line → chip theming even if later lines are typed. Strip trash only when every line is blank (no type/teeth/notes/attachments, including `[]`); typed cards need chip-delete first. Backend `DELETE /treatments/:id` is empty-only (`TREATMENT_HAS_DETAILS`).
- Sent-to-lab detail locks that line; **Add detail** still OK same day; **Remove detail** = trash on chip (unsent, including last line; day/edit gates apply). Empty details persist as `[]`. Attachment upload blocked when sent (`TREATMENT_DETAIL_SENT`).
- **Entry:** Type dropdown + compact attachments strip (`TreatmentDetailAttachmentsStrip`) on one row, then bordered FDI chart (Cases chrome/scale), then full-width auto-growing **Notes**. No wizard for non-lab types. Prosthesis: `WizardStepper` Treatment → Lab with Back/Next. Detail chips show type + teeth; lab-dependent chips use colored sent/unsent text (sent date on Lab tab). Switching chips resets to the treatment form unless `pendingEntryStepRef` requests Lab (shipments rail). Lab dispatch keeps comments.
- **Tooth selection:** Hit target is the unrotated cell (`pointerdown` only — do not also bind `click`). Glyph is `pointer-events-none`; nest hover/selected scale inside the rotate wrapper. Neighbor empty/filled circles between selected adjacent teeth connect/disconnect bridges; Shift+range selects only (empty circles; overlap absorbs as singles); midline 1121 / 4131 allowed. Plain click selects/deselects (deselect splits bridges). Never a 1-tooth connected. Helpers: `toothSelectionGroups.ts`. Connected label: `ConnectedSelectionBadge`. After send, Cases/Tasks merge teeth by prosthesis type.
- **Lab dispatch layout:** due date beside title (`justify-between`, logical start/end for RTL); prosthesis type on same row as teeth from `md:` up (stacked on mobile).
- **Entry:** Type dropdown + compact attachments strip (`TreatmentDetailAttachmentsStrip`) on one row (plus **Lab dispatch** / **Chart** toggle when the type is lab-dependent; dispatch is primary, Chart is outline; **Add detail** uses the same width), then bordered FDI chart (Cases chrome/scale) **or** the lab dispatch panel in that same slot, then full-width auto-growing **Notes**. Prosthesis types are assigned on the chart (teeth, or Upper/Lower arch labels for jaw products — dashed outline + plus). Arch Upper/Lower/Both remaps existing `UA`/`LA` jobs (Both→Upper clears lower). Picker: `4fr` wrapping category grid / `1fr` add-ons with a vertical divider; category/subcategory (and matching leaf) tiles use `prosthesis-catalog` SVGs; expanded children use a parent-colored **L** rail sized to the first child card; parent-bar back arrow is black; empty crown slot is plus + dashed “Crown can be added”; filled crown slot stays visible so it can be changed. Implant/post & core show a crown suggestion slot; a veneer/inlay/onlay/overlay hides it; Post & core is disabled after crown or implant; Implant is disabled after post & core; screw-retained does not suggest another crown. Prosthesis teeth always have jobs (hydrate/save prune). Lab-rail / Go to dispatch **opens the dispatch view** (`pendingScrollToLabRef`). Detail chips show type + teeth; lab-dependent chips use colored sent/unsent text (sent date on dispatch). Lab dispatch keeps comments.
- **Tooth selection:** Hit target is the unrotated cell (`pointerdown` only — do not also bind `click`). Glyph is `pointer-events-none`; nest hover/selected scale inside the rotate wrapper. Neighbor empty/filled circles between selected adjacent teeth connect/disconnect bridges; midline 1121 / 4131 allowed. Never a 1-tooth connected. On the non-prosthesis chart, Shift+range selects as singles. **Prosthesis assign chart (Exocad-style):** plain click opens the picker (tooth is not selected until a type is picked); **Done** stores that tooths jobs as the copy brush; **Ctrl/Cmd+click** or drag copies those jobs onto one tooth; **Shift+click** copies onto the inclusive same-arch range as **singles** (connect with neighbor circles if it is a bridge). Helpers: `toothSelectionGroups.ts`. Connected label: `ConnectedSelectionBadge`. Unconnected teeth stay **one item per tooth** (stacked types on that tooth union their lab steps). **Connected** teeth that share a prosthesis type are **one job** (teeth listed together); an extra type on one unit of the bridge is its own row. `UA`/`LA` → Upper/Lower arch. **Partial denture** is tooth-selected and becomes **one lab job** for all those teeth.
- **Lab dispatch layout:** due date beside title (`justify-between`, logical start/end for RTL); job rows from `LabCaseToothJobsList` (connected same-type teeth share a row; stacked types on a single tooth stay on that tooth). Sent cases: dest/jobs share a row with the Cases **QR** thumb; tracker + comments are full width below.
- **Schedule date:** Treatment strip and Appointments page headers use `ScheduleDayPicker` `compact` (date centered in a 3-col header; no “Schedule date” label; Today on the navigator).
**Treatment lab rules (quick ref):**
- Lab-dependent details (e.g. prosthesis) **without teeth** can save but **cannot ship** — same inline amber banner (`labShipmentBlockedBody`) on Treatment **and** Lab steps; toast on dispatch add.
- New prosthesis dispatch lines start **empty** (no default lab, no apply-all / per-tooth prosthesis type), even for siblings in the same plan. Recent-lab chips are the last **3 sent** destinations — pick is explicit, never auto-selected.
- Lab-dependent details (e.g. prosthesis) **without teeth or an arch product** can save but **cannot ship** — same inline amber banner (`labShipmentBlockedBody`). Arch jobs store as `UA`/`LA` on `LabCaseToothProsthesis.tooth` (not on `detail.teeth`).
- New prosthesis dispatch lines start **empty** (no default lab). Types are assigned only on the chart (`ProsthesisAssignChart` / `ProsthesisJobPopover`). Recent-lab chips are the last **3 sent** destinations — pick is explicit, never auto-selected.
- Detail treatment type need **not** match appointment purpose — purpose only seeds the **first** line of an empty **appointment** draft (first open, and **Add detail** when the plan is `[]`). Further **Add detail** starts with an empty type. Unscheduled / New treatment still seeds a blank first line.
- **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 for **this clinician's cases only**, includes patient name). Opening a case from the rail jumps to the **Lab** send sheet.
- **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). Opening a case from the rail **opens the lab dispatch view** (same slot as the chart).
- **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).
- **Live lab rail**: `notification.created``notifyTabBadgesChanged()` silently refreshes patient lab cases + unread rail (does **not** clear draft/form state).
- **Lab shipment progress + comments**: shown in **Lab dispatch panel** for the active shipment; expanding activity / opening comments marks that case read. Shared UI: `LabCaseCommentsPanel` — newest first; sent = start / received = end (`text-start`/`justify-start`, RTL-safe); pass `viewerSide`.
**Appointments (quick ref):** Do not delete (or change patient) when `hasTreatment`; codes `APPOINTMENT_HAS_TREATMENT` / `APPOINTMENT_PATIENT_LOCKED`. Past days: no new bookings; edit/delete OK without treatment; with treatment → toast. Appointment delete does not cascade-delete treatments. Working hours: client IANA `timeZone` on create/update — never `Date#getHours()`/`getDay()` on the UTC server. Logical API errors: `AppException` + `errors.*` (never Nest English throws). See `.cursor/rules/appointments.mdc`, `.cursor/skills/api-errors/SKILL.md`.
**Lab Tasks tab:** Newest case first; steps ordered 1→N; case grouping when sorted by date; `stepCompleted` filter; filter by case source (`origin`: received vs generated); 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; completing **`intraoral_scan`** completes every scan task in that case (case-scoped; catalog first step for all prosthesis types); **mobile:** larger task status controls, sticky case header when grouped; **tab badges:** `LabCaseActivity` + `GET /notifications/tab-counts` (lab Cases/Tasks split, clinic Treatment) — live via inbox Socket.IO → `notifyTabBadgesChanged()` + soft list refresh — see `.cursor/skills/lab-tasks/SKILL.md`, `.cursor/skills/tab-badges/SKILL.md`, `.cursor/skills/notifications-inbox/SKILL.md`.
**Lab Tasks tab:** Newest case first; steps ordered 1→N; case grouping when sorted by date; `stepCompleted` filter; filter by case source (`origin`: received vs generated); prosthesis colors from catalog; job titles show the picker path to the leaf (`prosthesisJobPath.ts`); 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; completing **`intraoral_scan`** completes every scan task in that case (case-scoped; catalog first step for all prosthesis types); **mobile:** larger task status controls, sticky case header when grouped; **tab badges:** `LabCaseActivity` + `GET /notifications/tab-counts` (lab Cases/Tasks split, clinic Treatment) — live via inbox Socket.IO → `notifyTabBadgesChanged()` + soft list refresh — see `.cursor/skills/lab-tasks/SKILL.md`, `.cursor/skills/tab-badges/SKILL.md`, `.cursor/skills/notifications-inbox/SKILL.md`.
**Lab Cases tab:** Filter by **prosthesis type** (not treatment type); auto-select newest case on open; list **10 per page**; left rail list fills column height (`flex-1 overflow-y-auto`); list cards use `LabCaseProsthesisGroupsList` (colored type + teeth, shared with Treatment rail) plus **Received** / **Generated** origin badges. Deep link: `?caseId=`, `?clinicOrganizationId=`. **Share link:** QR + URL on sent cases (attachment left, QR right); opens `/lab-case/[token]` focus page. **Case Sheet PDF:** client A4 (`jspdf`/`html2canvas`); hex-only print layout; optional `externalCode` replaces order number. Lab-origin Start has no clinic send; comments and mark-read still work (no clinic-visibility toggle). Unstarted generated drafts can be deleted (`DELETE /cases/:id`). **Live:** inbox Socket.IO → `notifyTabBadgesChanged()` soft-refreshes list + selected detail (no remount). See `.cursor/skills/lab-cases/SKILL.md` and `.cursor/skills/lab-case-share-link/SKILL.md`.
**Lab Cases tab:** Filter by **prosthesis type** (not treatment type); auto-select newest case on open; list **10 per page**; left rail list fills column height (`flex-1 overflow-y-auto`); list cards use `LabCaseProsthesisGroupsList` (colored type + teeth, shared with Treatment rail) plus **Received** / **Generated** origin badges. Job titles show the picker path to the **leaf** (`prosthesisJobPath.ts` — e.g. Crowns · PFM Crown, not “Crowns”; indirect is Inlay · Layered ceramic, not the long Veneer/Inlay/Onlay/Overlay parent). Deep link: `?caseId=`, `?clinicOrganizationId=`. **Share link:** QR + URL on sent cases (attachment left, QR right); opens `/lab-case/[token]` focus page. **Case Sheet PDF:** client A4 (`jspdf`/`html2canvas`); hex-only print layout; optional `externalCode` replaces order number. Lab-origin Start has no clinic send; comments and mark-read still work (no clinic-visibility toggle). Unstarted generated drafts can be deleted (`DELETE /cases/:id`). **Live:** inbox Socket.IO → `notifyTabBadgesChanged()` soft-refreshes list + selected detail (no remount). 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.
- **Clinic:** treatment **provider** with `TAB_TREATMENT_EDIT` — read-only tasks, can comment. Same QR as lab Cases appears on Treatment **Lab dispatch** after send (dest/jobs beside the thumb; tracker + comments full width below).
- Logged out → login with `?from=``storeAuthRedirectFromPath` then `useEnterAppWhenAuthenticated` (`consumeAuthRedirect` once after org ready — not inside `useAuth.login()` / `registerTrial`). Trial register uses the same hook; staff/org invite accept then `login()` + `navigateIntoAppIfOrgSelected`. See `.cursor/rules/post-auth-navigation.mdc`.
**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`.
@@ -85,7 +85,7 @@ backend/src/
prisma/ → schema, migrations, seed
```
Errors: `AppException` + `ErrorCode` → frontend `getUserFacingError()`. Never throw raw strings for user-facing failures.
Errors: `AppException` + `ErrorCode` → frontend `getUserFacingError()`. Unexpected 500s: GlitchTip (`SENTRY_DSN`). Never throw raw strings for user-facing failures.
## Git & commits

View File

@@ -25,6 +25,7 @@ There is **no root `package.json`**. Every npm command runs inside `backend/` or
| `npm run lint` | ESLint with `--fix` |
| `docker compose -f docker-compose.postgres.yml up -d` | Dev Postgres (host port from `POSTGRES_PORT` in `.env`) |
| `npm run prisma:generate` / `prisma:migrate` / `prisma:seed` | Client, dev migration, reference-data upsert (seed never wipes) |
| `npm run prisma:export-prosthesis-catalog` | Rewrite `docs/prosthesis-catalog.xlsx` from `catalog-seed-data.ts` |
| `npx prisma migrate reset` | Dev clean slate — drop, re-migrate, re-seed. Never against staging/prod |
| `npm run prisma:wipe-app-data` / `prisma:reset-treatment` / `prisma:regenerate-tasks` | Targeted dev data scripts |

131
README.md
View File

@@ -8,137 +8,23 @@ Local development: see **`backend/README.md`** and **`frontend/README.md`**.
---
## Production deploy (Docker Hub + HTTPS + Let's Encrypt)
## Production deploy (`https://nudentic.ir`)
**Full step-by-step guide:** [`infrastructure/DEPLOY.md`](infrastructure/DEPLOY.md)
**Full guide:** [`infrastructure/DEPLOY.md`](infrastructure/DEPLOY.md)
Minimal server setup: install Docker, create `.env` + `secrets/`, `docker login`, run one script.
Git tag **`v1.0.1`** → Gitea workflow [`.gitea/workflows/prod-tag-deploy.yml`](.gitea/workflows/prod-tag-deploy.yml) builds images (URLs baked for nudentic.ir), pushes to Gitea registry, SSH-deploys the Linux stack.
| On server (once) | In repo / Docker |
|------------------|------------------|
| DNS A record → server IP | `docker-compose.prod.yml`, nginx, certbot |
| `docker login` (private Hub) | Build & push images from dev machine |
| `secrets/database.env`, `secrets/backend.env` | Examples: `database.prod.env.example`, `backend.prod.env.example` |
| `infrastructure/.env` (`DOMAIN`, `LETSENCRYPT_EMAIL`) | `deploy.prod.env.example` |
**Dev machine** — build frontend with the public domain baked in, push to Docker Hub:
```bash
./infrastructure/scripts/build-and-push-prod.sh wixur.ir latest
```
**Server** — from `infrastructure/`:
```bash
cp deploy.prod.env.example .env # edit DOMAIN, paths
mkdir -p ../secrets && cp database.prod.env.example ../secrets/database.env
cp backend.prod.env.example ../secrets/backend.env # set passwords + FRONTEND_URL
docker login
chmod +x scripts/*.sh
./scripts/deploy-prod.sh
```
SSL is issued automatically via **Certbot** (`scripts/init-letsencrypt.sh`). Nginx config is generated from `DOMAIN` in `.env`. When you move to another domain (e.g. `dyolink.com`), update `.env` + `backend.env`, re-run `init-letsencrypt.sh`, and **rebuild the frontend image** with the new URL.
First-time SSL / Hub fallback: same `DEPLOY.md`.
---
## Deploy on your own server (Docker + Gitea)
## Staging deploy (`https://wixur.ir`)
High level: **build container images → push to a registry → server pulls images and runs Compose**. Optionally **Gitea Actions** automates that on every merge to `main` / `master`.
**Full guide:** [`infrastructure/STAGING-DEPLOY.md`](infrastructure/STAGING-DEPLOY.md)
### 1. One-time server preparation
Merge or push to **`master`** → Gitea Actions builds images → deploys to `https://wixur.ir` (Gitea: `http://wixur.ir:3000`).
1. Install **Docker** and **Docker Compose** on the server.
2. Run **Gitea** with the **container registry** enabled (same host/port you use for `docker login`, e.g. `178.131.50.201:3000`).
3. Copy the repo (or deploy only `infrastructure/` + secrets). You need at least:
- `infrastructure/docker-compose.registry.yml`
- `infrastructure/nginx/` configs referenced by that compose file
- `infrastructure/database/init.sql` if used by your Postgres service
4. **Secrets on the server** (never commit real values):
- Copy `infrastructure/database.staging.env.example`**`database.staging.env`** (Postgres user/password/db).
- Copy `infrastructure/backend.staging.env.example`**`backend.staging.env`** (e.g. `DATABASE_URL`, JWT, pointing at the compose Postgres service name).
- Put both files in one directory on the server, e.g. `/opt/dyolink/secrets/`.
5. **Registry login from the server** (same credentials you use for `docker push`):
```bash
docker login <registry-host>:<port> -u <user>
```
For HTTP registries, Docker may require **`insecure-registries`** on the daemon.
### 2. Manual deploy (build images elsewhere, run on server)
On your **dev machine** (after successful local builds):
```powershell
$REG = "<registry-host>:<port>"
$OWN = "<registry-owner>"
$TAG = "manual"
docker build -t "${REG}/${OWN}/dyolink-backend:${TAG}" -t "${REG}/${OWN}/dyolink-backend:latest" ./backend
docker build `
--build-arg NEXT_PUBLIC_API_URL="http://<your-public-ip>:<nginx-port>/api" `
--build-arg NEXT_PUBLIC_APP_URL="http://<your-public-ip>:<nginx-port>" `
--build-arg NEXT_PUBLIC_APP_NAME="Dyolink" `
-t "${REG}/${OWN}/dyolink-frontend:${TAG}" `
-t "${REG}/${OWN}/dyolink-frontend:latest" `
./frontend
docker push "${REG}/${OWN}/dyolink-backend:${TAG}"
docker push "${REG}/${OWN}/dyolink-backend:latest"
docker push "${REG}/${OWN}/dyolink-frontend:${TAG}"
docker push "${REG}/${OWN}/dyolink-frontend:latest"
```
On the **server**, from `infrastructure/`:
1. Create **`deploy.registry.env`** (see `infrastructure/deploy.registry.env.example`):
- `REGISTRY_PREFIX=<host>:<port>/<owner>` (no `http://`, no trailing slash)
- `IMAGE_TAG=latest` or the tag you pushed
- `STAGING_HTTP_PORT=<host port>` (e.g. `8088` — browser uses `http://<ip>:8088`)
2. Set **`DEPLOY_SECRETS_DIR`** to the absolute path of the folder containing `database.staging.env` and `backend.staging.env` (you can export it in the shell or add it to `deploy.registry.env` if your Compose setup expects it).
3. Pull and start:
```bash
docker compose -f docker-compose.registry.yml --env-file deploy.registry.env pull backend frontend
docker compose -f docker-compose.registry.yml --env-file deploy.registry.env up -d
```
The backend container runs **`prisma migrate deploy`** on startup (via entrypoint) when `NODE_ENV=production`, so schema updates apply after you deploy a new image that includes new migrations.
### 3. Automatic deploy (Gitea Actions)
Workflow file: **`.gitea/workflows/registry-build-deploy.yml`**.
**Requirements:**
- **Gitea Actions** enabled for the repository.
- A **self-hosted runner** (with Docker) registered to Gitea — the workflow uses `runs-on: self-hosted`.
- **Windows runners:** the workflow uses **PowerShell** (not Bash). Giteas runner was failing with `execvpe(/bin/bash) failed` when Bash was routed through WSL without a real `/bin/bash`. If your runner is **Linux**, switch `.gitea/workflows/registry-build-deploy.yml` to `defaults.run.shell: bash` and use Bash syntax instead.
- **Repository → Actions → Variables** (examples):
- `REGISTRY_HOST` — e.g. `178.131.50.201:3000`
- `REGISTRY_OWNER` — image namespace (same as Docker image path after the host), e.g. `admin`
- `PUBLIC_BASE_URL` — URL users open in the browser, e.g. `http://178.131.50.201:8088` (no trailing slash)
- `DEPLOY_SECRETS_DIR` — **absolute path on the runner machine** to the folder containing `database.staging.env` and `backend.staging.env`
- Optional: `STAGING_HTTP_PORT` (defaults to `8088`)
- **Repository → Actions → Secrets:**
- `REGISTRY_USERNAME`
- `REGISTRY_PASSWORD` — access token with package read/write (or equivalent)
**Trigger:** push to **`main`** or **`master`**, or run the workflow manually (**workflow_dispatch**).
The pipeline clones from your Gitea instance, builds and pushes backend/frontend images, then on the runner runs **`docker compose pull`** and **`up -d`** using `infrastructure/docker-compose.registry.yml`.
Workflow: [`.gitea/workflows/registry-build-deploy.yml`](.gitea/workflows/registry-build-deploy.yml)
---
@@ -148,5 +34,6 @@ The pipeline clones from your Gitea instance, builds and pushes backend/frontend
|------|------|
| `backend/Dockerfile` | API image |
| `frontend/Dockerfile` | Web image |
| `infrastructure/STAGING-DEPLOY.md` | Staging setup, CI variables, testing |
| `infrastructure/docker-compose.registry.yml` | Pull-only staging stack (registry images + nginx + postgres) |
| `infrastructure/deploy.registry.env.example` | Template for `deploy.registry.env` |

View File

@@ -29,6 +29,10 @@ FRONTEND_URL=http://localhost:3001
# Set true when the app is served over HTTPS (required for Secure auth cookies)
COOKIE_SECURE=false
# AdminJS panel at http://localhost:3000/admin (not under /api)
# ADMINJS_EMAIL=admin@dyolink.com
# ADMINJS_PASSWORD=admin123
# OAuth (optional — uncomment when configured)
# GOOGLE_CLIENT_ID=your-google-client-id
# GOOGLE_CLIENT_SECRET=your-google-client-secret
@@ -72,3 +76,7 @@ OPENROUTER_API_KEY=
# plus processing takes ten seconds at minimum — so it is purely an abuse guard.
# VOICE_THROTTLE_TTL=60
# VOICE_THROTTLE_LIMIT=6
# GlitchTip (optional). Leave empty for local unless you want to send events.
# SENTRY_DSN=https://PUBLIC_KEY@errors.wixur.ir/1
# SENTRY_ENVIRONMENT=development

View File

@@ -47,7 +47,8 @@ COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Windows git/build context may use CRLF; strip before chmod (fixes dumb-init "No such file or directory").
RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh && chmod +x /usr/local/bin/docker-entrypoint.sh
RUN mkdir -p /app/logs && \
chown -R dyolink:nodejs /app

View File

@@ -25,6 +25,7 @@
"@nestjs/throttler": "^6.5.0",
"@nestjs/websockets": "^11.1.28",
"@prisma/client": "^6.19.2",
"@sentry/nestjs": "^10.72.0",
"adminjs": "^7.8.17",
"axios": "^1.13.5",
"bcrypt": "^6.0.0",
@@ -4649,6 +4650,119 @@
"npm": ">=5.10.0"
}
},
"node_modules/@opentelemetry/api": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz",
"integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==",
"license": "Apache-2.0",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/@opentelemetry/api-logs": {
"version": "0.220.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.220.0.tgz",
"integrity": "sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api": "^1.3.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/@opentelemetry/core": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.10.0.tgz",
"integrity": "sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.0.0 <1.10.0"
}
},
"node_modules/@opentelemetry/instrumentation": {
"version": "0.220.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.220.0.tgz",
"integrity": "sha512-xQx3E2WxP1mDvKzxLxX+CTCtNLa560YJZ3087qYHerl2YmiKpv7AH+dAy7vmx+eVrZ5BwhfWUAVoKOoxCNHcpw==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api-logs": "0.220.0",
"import-in-the-middle": "^3.0.0",
"require-in-the-middle": "^8.0.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": "^1.3.0"
}
},
"node_modules/@opentelemetry/resources": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.10.0.tgz",
"integrity": "sha512-q6MMm2zhggzsHVNbabYwut+a6nbuQQe3URUoxaojM/8K1IBfwwPzvxIjNi2/lI1TFe+fMHMW9MWhrtDLEXEnkA==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.10.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.3.0 <1.10.0"
}
},
"node_modules/@opentelemetry/sdk-trace": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace/-/sdk-trace-2.10.0.tgz",
"integrity": "sha512-MfQGq3GRmTh5fM/y+OjaO0vj6+luCB1XO2gfXCalKCfgKw0eHL++sm75DNweC6ohlp+aFvACqeE0fYayqdRaoQ==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.10.0",
"@opentelemetry/resources": "2.10.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.3.0 <1.10.0"
}
},
"node_modules/@opentelemetry/sdk-trace-base": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.10.0.tgz",
"integrity": "sha512-GuYQQT7QD2EeO8lcZLRQzcbOyhqAzL+6WWTKTU9mSUBYBazkEDl+VrQcXQhbB08OWM9anD1aHleVadzulpOaUQ==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.10.0",
"@opentelemetry/resources": "2.10.0",
"@opentelemetry/sdk-trace": "2.10.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.3.0 <1.10.0"
}
},
"node_modules/@opentelemetry/semantic-conventions": {
"version": "1.43.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz",
"integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==",
"license": "Apache-2.0",
"engines": {
"node": ">=14"
}
},
"node_modules/@paralleldrive/cuid2": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz",
@@ -5333,6 +5447,138 @@
"hasInstallScript": true,
"license": "Apache-2.0"
},
"node_modules/@sentry/conventions": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.16.0.tgz",
"integrity": "sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ==",
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/@sentry/core": {
"version": "10.72.0",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.72.0.tgz",
"integrity": "sha512-UJMHZfbjP4qk+g4AQhZmzosMdICC2D9p0/hLrm1LofPsp+WBfcSnv9jsY1a9TfmpS4WCAmTx8nANYTIXifcBjA==",
"license": "MIT",
"dependencies": {
"@sentry/conventions": "^0.16.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/nestjs": {
"version": "10.72.0",
"resolved": "https://registry.npmjs.org/@sentry/nestjs/-/nestjs-10.72.0.tgz",
"integrity": "sha512-+1wdX+fkv8zIZ+lI66Se1DKwPXoa6edxoHuAgWDwcqv3bKF8R75NqzXj+4U7FcgBI05i6HFiZJuyvx9BCECy8g==",
"license": "MIT",
"dependencies": {
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/instrumentation": "^0.220.0",
"@sentry/conventions": "^0.16.0",
"@sentry/core": "10.72.0",
"@sentry/node": "10.72.0",
"@sentry/server-utils": "10.72.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
"@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0"
}
},
"node_modules/@sentry/node": {
"version": "10.72.0",
"resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.72.0.tgz",
"integrity": "sha512-eQHQFxSX26MhG/nB+tAY4QRslnPvJse7pww/hd3zXAqNULRa5lFtjSLALcHx/KUVDCZdTPdUVZEj4nGidcHrow==",
"license": "MIT",
"dependencies": {
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/instrumentation": "^0.220.0",
"@opentelemetry/sdk-trace-base": "^2.9.0",
"@sentry/conventions": "^0.16.0",
"@sentry/core": "10.72.0",
"@sentry/node-core": "10.72.0",
"@sentry/opentelemetry": "10.72.0",
"@sentry/server-utils": "10.72.0",
"import-in-the-middle": "^3.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/node-core": {
"version": "10.72.0",
"resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.72.0.tgz",
"integrity": "sha512-xYuYWmWEWnN8h3YHa7mds212ANFgrxfrbmLvVg0p0wf9m6MFjLowOTmjaiK0XW20sM/veSelicre650bx8UFtQ==",
"license": "MIT",
"dependencies": {
"@sentry/conventions": "^0.16.0",
"@sentry/core": "10.72.0",
"@sentry/opentelemetry": "10.72.0",
"import-in-the-middle": "^3.0.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/core": "^1.30.1 || ^2.1.0",
"@opentelemetry/exporter-trace-otlp-http": ">=0.57.0 <1",
"@opentelemetry/instrumentation": ">=0.57.1 <1",
"@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
},
"@opentelemetry/core": {
"optional": true
},
"@opentelemetry/exporter-trace-otlp-http": {
"optional": true
},
"@opentelemetry/instrumentation": {
"optional": true
},
"@opentelemetry/sdk-trace-base": {
"optional": true
}
}
},
"node_modules/@sentry/opentelemetry": {
"version": "10.72.0",
"resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.72.0.tgz",
"integrity": "sha512-ZVbAM1rGU/awN7cH/jvC87WpQw0NOJx5id6dKnnefStXpP/kUnZXYhtX9gfBnofYvJWa5I5VUSeuKCLUcKL75w==",
"license": "MIT",
"dependencies": {
"@sentry/conventions": "^0.16.0",
"@sentry/core": "10.72.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/core": "^1.30.1 || ^2.1.0",
"@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0"
}
},
"node_modules/@sentry/server-utils": {
"version": "10.72.0",
"resolved": "https://registry.npmjs.org/@sentry/server-utils/-/server-utils-10.72.0.tgz",
"integrity": "sha512-CWpHMYW81RDBSVBdnxulGUvvBhkBb/OpSr72ubSe1UkKTcgT0NDMCWxE3dLFXqSxzT4DaF5UlUVv9ii5Sse53w==",
"license": "MIT",
"dependencies": {
"@sentry/conventions": "^0.16.0",
"@sentry/core": "10.72.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sinclair/typebox": {
"version": "0.34.49",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz",
@@ -8352,7 +8598,6 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz",
"integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==",
"dev": true,
"license": "MIT"
},
"node_modules/class-transformer": {
@@ -9222,10 +9467,9 @@
}
},
"node_modules/es-module-lexer": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
"integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==",
"dev": true,
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
"integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
"license": "MIT"
},
"node_modules/es-object-atoms": {
@@ -10649,6 +10893,20 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/import-in-the-middle": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.3.3.tgz",
"integrity": "sha512-AiohS3H80sXO6owEltjGX+glb7qXaDhBoJb9XcQVH4UI207xu/bDLUcadVKp7Qe576reg9yr/PXZjV5qx8gfbA==",
"license": "Apache-2.0",
"dependencies": {
"cjs-module-lexer": "^2.2.0",
"es-module-lexer": "^2.2.0",
"module-details-from-path": "^1.0.4"
},
"engines": {
"node": ">=18"
}
},
"node_modules/import-local": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz",
@@ -12517,6 +12775,12 @@
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/module-details-from-path": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz",
"integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -14205,6 +14469,19 @@
"node": ">=0.10.0"
}
},
"node_modules/require-in-the-middle": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz",
"integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.3.5",
"module-details-from-path": "^1.0.3"
},
"engines": {
"node": ">=9.3.0 || >=8.10.0 <9.0.0"
}
},
"node_modules/resolve": {
"version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",

View File

@@ -24,7 +24,8 @@
"prisma:seed": "prisma db seed",
"prisma:reset-treatment": "ts-node prisma/reset-treatment-data.ts",
"prisma:wipe-app-data": "ts-node prisma/wipe-app-data.ts",
"prisma:regenerate-tasks": "ts-node prisma/regenerate-lab-tasks.ts"
"prisma:regenerate-tasks": "ts-node prisma/regenerate-lab-tasks.ts",
"prisma:export-prosthesis-catalog": "ts-node --transpile-only prisma/export-prosthesis-catalog.ts"
},
"prisma": {
"seed": "ts-node prisma/seed.ts"
@@ -46,6 +47,7 @@
"@nestjs/throttler": "^6.5.0",
"@nestjs/websockets": "^11.1.28",
"@prisma/client": "^6.19.2",
"@sentry/nestjs": "^10.72.0",
"adminjs": "^7.8.17",
"axios": "^1.13.5",
"bcrypt": "^6.0.0",

View File

@@ -29,7 +29,6 @@ export const TREATMENT_TYPES: readonly TreatmentTypeSeed[] = [
{ code: 'perio', labDependent: false, sortOrder: 9 },
{ code: 'pediatrics', labDependent: false, sortOrder: 10 },
{ code: 'extraction', labDependent: false, sortOrder: 11 },
// Appointment-only: not real treatment plan details.
{
code: 'clinic_visit',
labDependent: false,
@@ -54,79 +53,337 @@ export const LEGACY_TREATMENT_TYPES: readonly TreatmentTypeSeed[] = [
export const LAB_WORKFLOW_STEPS = [
{ code: 'intraoral_scan', sortOrder: 1 },
{ code: 'design', sortOrder: 2 },
{ code: 'milling_dry', sortOrder: 3 },
{ code: 'milling_wet', sortOrder: 4 },
{ code: 'printer_resin', sortOrder: 5 },
{ code: 'printer_metal', sortOrder: 6 },
{ code: 'sinter', sortOrder: 7 },
{ code: 'build_up', sortOrder: 8 },
{ code: 'stain', sortOrder: 9 },
{ code: 'glaze', sortOrder: 10 },
{ code: 'polish_prep', sortOrder: 11 },
{ code: 'packing', sortOrder: 12 },
{ code: 'shipping', sortOrder: 13 },
{ code: 'pressing', sortOrder: 14 },
{ code: 'choosing_abutment', sortOrder: 2 },
{ code: 'design', sortOrder: 3 },
{ code: 'model_create', sortOrder: 4 },
{ code: 'milling_dry', sortOrder: 5 },
{ code: 'milling_wet', sortOrder: 6 },
{ code: 'printer_resin', sortOrder: 7 },
{ code: 'printer_metal', sortOrder: 8 },
{ code: 'pressing', sortOrder: 9 },
{ code: 'sinter', sortOrder: 10 },
{ code: 'build_up', sortOrder: 11 },
{ code: 'stain', sortOrder: 12 },
{ code: 'glaze', sortOrder: 13 },
{ code: 'polish_prep', sortOrder: 14 },
{ code: 'packing', sortOrder: 15 },
{ code: 'shipping', sortOrder: 16 },
] as const;
export type ProsthesisChartRegion = 'crown' | 'root' | 'arch';
export type ProsthesisStackGroup = 'restoration' | 'implant' | 'post_core' | 'arch';
export type ProsthesisTypeSeed = {
code: string;
sortOrder: number;
skipPackingShipping?: boolean;
/** Manufacturing steps between design and packing (exclusive of universal scan/design/pack/ship). */
isActive?: boolean;
category: string;
subcategory?: string;
chartRegion: ProsthesisChartRegion;
stackGroup: ProsthesisStackGroup;
/** Manufacturing sequence. Packing and shipping are appended except for smile_design. */
manufacturingSteps: readonly string[];
};
const SCAN_DESIGN_MODEL = ['intraoral_scan', 'design', 'model_create'] as const;
const INDIRECT_FULL = [...SCAN_DESIGN_MODEL, 'milling_wet', 'sinter', 'stain', 'glaze'] as const;
const INDIRECT_LAYERED = [
...SCAN_DESIGN_MODEL,
'milling_wet',
'sinter',
'build_up',
'stain',
'glaze',
] as const;
/** Unlisted types keep scan+design plus todays manufacturing (no model-create). */
const KEEP_FULL_CONTOUR = [
'intraoral_scan',
'design',
'milling_wet',
'stain',
'glaze',
'polish_prep',
] as const;
const KEEP_LAYERED = [
'intraoral_scan',
'design',
'milling_dry',
'sinter',
'build_up',
'stain',
'glaze',
'polish_prep',
] as const;
const DENTURE = ['intraoral_scan', 'design', 'printer_resin', 'polish_prep'] as const;
const APPLIANCE = ['intraoral_scan', 'design', 'printer_resin', 'polish_prep'] as const;
const POST_CORE = ['intraoral_scan', 'design', 'milling_wet', 'polish_prep'] as const;
function crown(
code: string,
sortOrder: number,
manufacturingSteps: readonly string[],
extra?: Partial<ProsthesisTypeSeed>,
): ProsthesisTypeSeed {
return {
code,
sortOrder,
category: 'crown',
chartRegion: 'crown',
stackGroup: 'restoration',
manufacturingSteps,
...extra,
};
}
function implant(
code: string,
sortOrder: number,
manufacturingSteps: readonly string[],
extra?: Partial<ProsthesisTypeSeed>,
): ProsthesisTypeSeed {
return {
code,
sortOrder,
category: 'implant',
chartRegion: 'root',
stackGroup: 'implant',
manufacturingSteps,
...extra,
};
}
function indirect(
indication: string,
technique: 'full_contour' | 'layered',
sortOrder: number,
): ProsthesisTypeSeed {
return {
code: `${indication}_${technique}`,
sortOrder,
category: 'indirect',
subcategory: indication,
chartRegion: 'crown',
stackGroup: 'restoration',
manufacturingSteps: technique === 'layered' ? INDIRECT_LAYERED : INDIRECT_FULL,
};
}
export const PROSTHESIS_TYPES: ProsthesisTypeSeed[] = [
crown('pfm_crown', 1, [
...SCAN_DESIGN_MODEL,
'milling_wet',
'sinter',
'build_up',
'stain',
'glaze',
]),
crown('pfz_crown', 2, [
...SCAN_DESIGN_MODEL,
'milling_dry',
'sinter',
'build_up',
'stain',
'glaze',
]),
crown('monolithic_zirconia', 3, [
...SCAN_DESIGN_MODEL,
'milling_dry',
'sinter',
'stain',
'glaze',
]),
crown('glass_ceramic_crown', 4, [
...SCAN_DESIGN_MODEL,
'milling_dry',
'sinter',
'stain',
'glaze',
]),
crown('full_metal_crown', 5, [...SCAN_DESIGN_MODEL, 'milling_wet', 'polish_prep']),
crown('temporary_resin_crown', 6, ['intraoral_scan', 'design', 'milling_wet', 'polish_prep']),
crown('pmma', 7, ['intraoral_scan', 'design', 'milling_dry', 'polish_prep']),
crown('peek_crown', 8, ['intraoral_scan', 'design', 'milling_dry', 'polish_prep']),
crown('press_ceramic', 9, [
'intraoral_scan',
'design',
'printer_resin',
'pressing',
'stain',
'glaze',
'polish_prep',
]),
indirect('veneer', 'full_contour', 20),
indirect('veneer', 'layered', 21),
indirect('inlay', 'full_contour', 22),
indirect('inlay', 'layered', 23),
indirect('onlay', 'full_contour', 24),
indirect('onlay', 'layered', 25),
indirect('overlay', 'full_contour', 26),
indirect('overlay', 'layered', 27),
{
code: 'pfm_crown',
sortOrder: 1,
manufacturingSteps: ['milling_wet', 'build_up', 'stain', 'glaze', 'polish_prep'],
code: 'cast_post_core',
sortOrder: 30,
category: 'post_core',
chartRegion: 'root',
stackGroup: 'post_core',
manufacturingSteps: POST_CORE,
},
{
code: 'pfz_crown',
sortOrder: 2,
manufacturingSteps: ['milling_dry', 'sinter', 'build_up', 'stain', 'glaze', 'polish_prep'],
code: 'fiber_post_core',
sortOrder: 31,
category: 'post_core',
chartRegion: 'root',
stackGroup: 'post_core',
manufacturingSteps: POST_CORE,
},
implant('prefabricated_abutment', 40, [
'intraoral_scan',
'choosing_abutment',
'model_create',
'polish_prep',
]),
implant('ti_base_abutment', 41, ['intraoral_scan', 'model_create', 'polish_prep']),
implant('multi_unit_abutment', 42, [
'intraoral_scan',
'choosing_abutment',
'model_create',
'polish_prep',
]),
implant('customized_abutment', 43, [
...SCAN_DESIGN_MODEL,
'milling_wet',
'polish_prep',
]),
implant('zirconia_abutment', 44, [...SCAN_DESIGN_MODEL, 'milling_dry', 'sinter']),
implant(
'screw_retained',
45,
[...SCAN_DESIGN_MODEL, 'milling_wet', 'polish_prep'],
{ chartRegion: 'crown' },
),
{
code: 'complete_denture',
sortOrder: 50,
category: 'removable',
chartRegion: 'arch',
stackGroup: 'arch',
manufacturingSteps: DENTURE,
},
{
code: 'monolithic_zirconia',
sortOrder: 3,
manufacturingSteps: ['milling_dry', 'sinter', 'stain', 'glaze', 'polish_prep'],
code: 'partial_denture',
sortOrder: 51,
category: 'removable',
chartRegion: 'crown',
stackGroup: 'arch',
manufacturingSteps: DENTURE,
},
{
code: 'glass_ceramic_crown',
sortOrder: 4,
manufacturingSteps: ['milling_wet', 'stain', 'glaze', 'polish_prep'],
code: 'overdenture',
sortOrder: 52,
category: 'removable',
chartRegion: 'arch',
stackGroup: 'arch',
manufacturingSteps: DENTURE,
},
{
code: 'full_metal_crown',
sortOrder: 5,
manufacturingSteps: ['milling_wet', 'polish_prep'],
code: 'night_guard_soft',
sortOrder: 60,
category: 'appliance',
subcategory: 'night_guard',
chartRegion: 'arch',
stackGroup: 'arch',
manufacturingSteps: APPLIANCE,
},
{
code: 'temporary_resin_crown',
sortOrder: 6,
manufacturingSteps: ['milling_wet', 'polish_prep'],
code: 'night_guard_hard',
sortOrder: 61,
category: 'appliance',
subcategory: 'night_guard',
chartRegion: 'arch',
stackGroup: 'arch',
manufacturingSteps: APPLIANCE,
},
{
code: 'pmma',
sortOrder: 7,
manufacturingSteps: ['milling_dry', 'polish_prep'],
code: 'night_guard_dual',
sortOrder: 62,
category: 'appliance',
subcategory: 'night_guard',
chartRegion: 'arch',
stackGroup: 'arch',
manufacturingSteps: APPLIANCE,
},
{
code: 'peek_crown',
sortOrder: 8,
manufacturingSteps: ['milling_dry', 'polish_prep'],
code: 'bleaching_tray',
sortOrder: 63,
category: 'appliance',
chartRegion: 'arch',
stackGroup: 'arch',
manufacturingSteps: APPLIANCE,
},
{
code: 'clear_aligner',
sortOrder: 64,
category: 'appliance',
chartRegion: 'arch',
stackGroup: 'arch',
manufacturingSteps: APPLIANCE,
},
{
code: 'soft_structure',
sortOrder: 65,
category: 'appliance',
chartRegion: 'arch',
stackGroup: 'arch',
manufacturingSteps: ['intraoral_scan', 'design', 'milling_dry', 'sinter'],
},
{
code: 'surgical_guide',
sortOrder: 70,
category: 'digital',
chartRegion: 'arch',
stackGroup: 'arch',
manufacturingSteps: APPLIANCE,
},
{
code: 'smile_design',
sortOrder: 71,
category: 'digital',
chartRegion: 'arch',
stackGroup: 'arch',
skipPackingShipping: true,
manufacturingSteps: ['intraoral_scan', 'design'],
},
{
code: 'mockup',
sortOrder: 72,
category: 'digital',
chartRegion: 'arch',
stackGroup: 'arch',
manufacturingSteps: ['intraoral_scan', 'design', 'model_create'],
},
// Legacy leaves — labels for historical cases; hidden from the picker.
{
code: 'veneer_zirconia',
sortOrder: 9,
manufacturingSteps: ['milling_dry', 'sinter', 'build_up', 'stain', 'glaze', 'polish_prep'],
sortOrder: 200,
isActive: false,
category: 'indirect',
subcategory: 'veneer',
chartRegion: 'crown',
stackGroup: 'restoration',
manufacturingSteps: KEEP_LAYERED,
},
{
code: 'veneer_ips_press',
sortOrder: 10,
sortOrder: 201,
isActive: false,
category: 'indirect',
subcategory: 'veneer',
chartRegion: 'crown',
stackGroup: 'restoration',
manufacturingSteps: [
'intraoral_scan',
'design',
'printer_resin',
'build_up',
'stain',
@@ -137,86 +394,60 @@ export const PROSTHESIS_TYPES: ProsthesisTypeSeed[] = [
},
{
code: 'veneer_ips_cad',
sortOrder: 11,
manufacturingSteps: ['milling_wet', 'stain', 'glaze', 'polish_prep'],
sortOrder: 202,
isActive: false,
category: 'indirect',
subcategory: 'veneer',
chartRegion: 'crown',
stackGroup: 'restoration',
manufacturingSteps: KEEP_FULL_CONTOUR,
},
{
code: 'soft_structure',
sortOrder: 12,
manufacturingSteps: ['milling_dry', 'sinter'],
},
{
code: 'customized_abutment',
sortOrder: 13,
manufacturingSteps: ['milling_wet', 'polish_prep'],
},
{
code: 'prefabricated_abutment',
sortOrder: 14,
manufacturingSteps: ['polish_prep'],
},
{
code: 'ti_base_abutment',
sortOrder: 15,
manufacturingSteps: ['polish_prep'],
},
{
code: 'multi_unit_abutment',
sortOrder: 16,
manufacturingSteps: ['polish_prep'],
},
{
code: 'zirconia_abutment',
sortOrder: 17,
manufacturingSteps: ['milling_dry', 'sinter', 'polish_prep'],
},
{
code: 'screw_retained',
sortOrder: 18,
code: 'zirconia_overlay',
sortOrder: 203,
isActive: false,
category: 'indirect',
subcategory: 'overlay',
chartRegion: 'crown',
stackGroup: 'restoration',
manufacturingSteps: [
'milling_wet',
'printer_metal',
'intraoral_scan',
'design',
'milling_dry',
'sinter',
'build_up',
'stain',
'glaze',
'polish_prep',
],
},
{
code: 'zirconia_overlay',
sortOrder: 19,
manufacturingSteps: ['milling_dry', 'sinter', 'stain', 'glaze', 'polish_prep'],
},
{
code: 'ips_overlay',
sortOrder: 20,
manufacturingSteps: ['milling_wet', 'stain', 'glaze', 'polish_prep'],
},
{
code: 'smile_design',
sortOrder: 21,
skipPackingShipping: true,
manufacturingSteps: ['printer_resin'],
},
{
code: 'mockup',
sortOrder: 22,
manufacturingSteps: ['printer_resin'],
sortOrder: 204,
isActive: false,
category: 'indirect',
subcategory: 'overlay',
chartRegion: 'crown',
stackGroup: 'restoration',
manufacturingSteps: KEEP_FULL_CONTOUR,
},
];
const UNIVERSAL_PREFIX = ['intraoral_scan', 'design'] as const;
const UNIVERSAL_SUFFIX = ['packing', 'shipping'] as const;
const PACK_SHIP = ['packing', 'shipping'] as const;
export function buildProsthesisStepCodes(type: ProsthesisTypeSeed): string[] {
const steps = [...UNIVERSAL_PREFIX, ...type.manufacturingSteps];
const steps = [...type.manufacturingSteps];
if (!type.skipPackingShipping) {
steps.push(...UNIVERSAL_SUFFIX);
steps.push(...PACK_SHIP);
}
return steps;
}
export {
unionWorkflowStepCodes,
prosthesisGroupKey,
splitProsthesisGroupKey,
} from '../src/common/prosthesis-group';
const TREATMENT_LABELS: Record<string, Record<string, string>> = {
restoration: { en: 'Restoration', fa: 'ترمیم', nl: 'Restauratie' },
specialized_restoration: { en: 'Specialized Restoration', fa: 'ترمیم تخصصی', nl: 'Gespecialiseerde Restauratie' },
@@ -237,7 +468,7 @@ const TREATMENT_LABELS: Record<string, Record<string, string>> = {
hygiene: { en: 'Hygiene', fa: 'بهداشت', nl: 'Hygiëne' },
};
const PROSTHESIS_LABELS: Record<string, Record<string, string>> = {
export const PROSTHESIS_LABELS: Record<string, Record<string, string>> = {
pfm_crown: { en: 'PFM Crown', fa: 'روکش PFM', nl: 'PFM Kroon' },
pfz_crown: { en: 'PFZ Crown', fa: 'روکش PFZ', nl: 'PFZ Kroon' },
monolithic_zirconia: { en: 'Monolithic Zirconia', fa: 'زیرکونیا مونولیتیک', nl: 'Monolithisch Zirconia' },
@@ -246,25 +477,51 @@ const PROSTHESIS_LABELS: Record<string, Record<string, string>> = {
temporary_resin_crown: { en: 'Temporary Resin Crown', fa: 'روکش موقت رزینی', nl: 'Tijdelijke Harskroon' },
pmma: { en: 'PMMA', fa: 'PMMA', nl: 'PMMA' },
peek_crown: { en: 'PEEK Crown', fa: 'روکش PEEK', nl: 'PEEK Kroon' },
veneer_zirconia: { en: 'Veneer Zirconia', fa: 'ونیر زیرکونیا', nl: 'Veneer Zirconia' },
veneer_ips_press: { en: 'Veneer IPS Press', fa: 'ونیر IPS پرس', nl: 'Veneer IPS Press' },
veneer_ips_cad: { en: 'Veneer IPS CAD', fa: 'ونیر IPS CAD', nl: 'Veneer IPS CAD' },
soft_structure: { en: 'Soft Structure', fa: 'ساختار نرم', nl: 'Zachte Structuur' },
customized_abutment: { en: 'Customized Abutment', fa: 'اباتمنت سفارشی', nl: 'Aangepast Abutment' },
press_ceramic: { en: 'Pressed Ceramic / IPS e.max', fa: 'سرامیک پرس / IPS e.max', nl: 'Perskeramiek / IPS e.max' },
veneer_full_contour: { en: 'Veneer · Full contour', fa: 'ونیر · تمام‌کانتور', nl: 'Veneer · Full contour' },
veneer_layered: { en: 'Veneer · Layered ceramic', fa: 'ونیر · سرامیک لایه‌ای', nl: 'Veneer · Gelaagd keramiek' },
inlay_full_contour: { en: 'Inlay · Full contour', fa: 'اینلی · تمام‌کانتور', nl: 'Inlay · Full contour' },
inlay_layered: { en: 'Inlay · Layered ceramic', fa: 'اینلی · سرامیک لایه‌ای', nl: 'Inlay · Gelaagd keramiek' },
onlay_full_contour: { en: 'Onlay · Full contour', fa: 'آنلی · تمام‌کانتور', nl: 'Onlay · Full contour' },
onlay_layered: { en: 'Onlay · Layered ceramic', fa: 'آنلی · سرامیک لایه‌ای', nl: 'Onlay · Gelaagd keramiek' },
overlay_full_contour: { en: 'Overlay · Full contour', fa: 'اورلی · تمام‌کانتور', nl: 'Overlay · Full contour' },
overlay_layered: { en: 'Overlay · Layered ceramic', fa: 'اورلی · سرامیک لایه‌ای', nl: 'Overlay · Gelaagd keramiek' },
cast_post_core: { en: 'Cast Post & Core', fa: 'پست و کور ریختگی', nl: 'Gegoten Stiftopbouw' },
fiber_post_core: { en: 'Fiber Post & Core', fa: 'پست و کور فایبر', nl: 'Fiber Stiftopbouw' },
customized_abutment: { en: 'Custom Abutment (Titanium)', fa: 'اباتمنت سفارشی (تیتانیوم)', nl: 'Aangepast abutment (titanium)' },
prefabricated_abutment: { en: 'Prefabricated Abutment', fa: 'اباتمنت آماده', nl: 'Prefab Abutment' },
ti_base_abutment: { en: 'Ti Base Abutment', fa: 'اباتمنت پایه تیتانیوم', nl: 'Ti Basis Abutment' },
multi_unit_abutment: { en: 'Multi Unit Abutment', fa: 'اباتمنت مولتی یونیت', nl: 'Multi Unit Abutment' },
zirconia_abutment: { en: 'Zirconia Abutment', fa: 'اباتمنت زیرکونیا', nl: 'Zirconia Abutment' },
zirconia_abutment: { en: 'Custom Abutment (Zirconia)', fa: 'اباتمنت سفارشی (زیرکونیا)', nl: 'Aangepast abutment (zirconia)' },
screw_retained: { en: 'Screw Retained', fa: 'پیچی', nl: 'Schroefgehouden' },
zirconia_overlay: { en: 'Zirconia Overlay', fa: 'اورلی زیرکونیا', nl: 'Zirconia Overlay' },
ips_overlay: { en: 'IPS Overlay', fa: 'اورلی IPS', nl: 'IPS Overlay' },
complete_denture: { en: 'Complete Denture', fa: 'دنچر کامل', nl: 'Volledige prothese' },
partial_denture: { en: 'Partial Denture', fa: 'دنچر پارسیل', nl: 'Partiële prothese' },
overdenture: { en: 'Overdenture', fa: 'اوردنچر', nl: 'Overkappingsprothese' },
night_guard_soft: { en: 'Night Guard · Soft', fa: 'نایت گارد · نرم', nl: 'Nachtbeugel · Zacht' },
night_guard_hard: { en: 'Night Guard · Hard', fa: 'نایت گارد · سخت', nl: 'Nachtbeugel · Hard' },
night_guard_dual: { en: 'Night Guard · Dual laminate', fa: 'نایت گارد · دو لایه', nl: 'Nachtbeugel · Dual laminate' },
bleaching_tray: { en: 'Bleaching Tray', fa: 'تری بلیچینگ', nl: 'Bleeklepel' },
clear_aligner: { en: 'Clear Aligner', fa: 'الاینر شفاف', nl: 'Clear aligner' },
soft_structure: { en: 'Soft Structure', fa: 'ساختار نرم', nl: 'Zachte Structuur' },
surgical_guide: { en: 'Surgical Guide', fa: 'گاید جراحی', nl: 'Chirurgische mal' },
smile_design: { en: 'Smile Design', fa: 'طراحی لبخند', nl: 'Smile Design' },
mockup: { en: 'Mockup', fa: 'ماکاپ', nl: 'Mockup' },
veneer_zirconia: { en: 'Veneer Zirconia', fa: 'ونیر زیرکونیا', nl: 'Veneer Zirconia' },
veneer_ips_press: { en: 'Veneer IPS Press', fa: 'ونیر IPS پرس', nl: 'Veneer IPS Press' },
veneer_ips_cad: { en: 'Veneer IPS CAD', fa: 'ونیر IPS CAD', nl: 'Veneer IPS CAD' },
zirconia_overlay: { en: 'Zirconia Overlay', fa: 'اورلی زیرکونیا', nl: 'Zirconia Overlay' },
ips_overlay: { en: 'IPS Overlay', fa: 'اورلی IPS', nl: 'IPS Overlay' },
};
const WORKFLOW_STEP_LABELS: Record<string, Record<string, string>> = {
export const WORKFLOW_STEP_LABELS: Record<string, Record<string, string>> = {
intraoral_scan: { en: 'Intraoral Scan', fa: 'اسکن داخل دهان', nl: 'Intraorale Scan' },
choosing_abutment: {
en: 'Choosing Abutment',
fa: 'انتخاب اباتمنت',
nl: 'Abutment kiezen',
},
design: { en: 'Design', fa: 'طراحی', nl: 'Ontwerp' },
model_create: { en: 'Model Create', fa: 'ساخت مدل', nl: 'Model maken' },
milling_dry: { en: 'Milling Dry', fa: 'فرز خشک', nl: 'Droog Frezen' },
milling_wet: { en: 'Milling Wet', fa: 'فرز تر', nl: 'Nat Frezen' },
printer_resin: { en: 'Printer Resin', fa: 'پرینتر رزین', nl: 'Harsprinter' },

View File

@@ -0,0 +1,298 @@
/**
* Regenerates docs/prosthesis-catalog.xlsx from catalog-seed-data.ts.
*
* cd backend && npx ts-node --transpile-only prisma/export-prosthesis-catalog.ts
*/
import * as fs from 'fs';
import * as path from 'path';
import * as zlib from 'zlib';
import {
PROSTHESIS_TYPES,
PROSTHESIS_LABELS,
WORKFLOW_STEP_LABELS,
buildProsthesisStepCodes,
} from './catalog-seed-data';
const CATEGORY_LABELS: Record<string, string> = {
crown: 'Crowns',
indirect: 'Veneer/Inlay/Onlay/Overlay',
implant: 'Implants',
post_core: 'Post & core',
removable: 'Removable',
appliance: 'Appliances',
digital: 'Digital',
};
const SUBCATEGORY_LABELS: Record<string, string> = {
full_contour: 'Full contour',
layered: 'Layered',
veneer: 'Veneer',
inlay: 'Inlay',
onlay: 'Onlay',
overlay: 'Overlay',
complete_denture: 'Complete denture',
partial_denture: 'Partial denture',
night_guard: 'Night guard',
};
const STACK_RULES = [
['Slot', 'Rule'],
[
'restoration',
'One per tooth: crown, veneer, inlay, onlay, or overlay.',
],
['implant', 'One implant type per tooth. Disabled when post & core is on the tooth.'],
[
'post_core',
'One post & core. Disabled when a restoration or implant is already on the tooth.',
],
[
'arch',
'Complete denture, overdenture, appliances, and digital use Upper/Lower/Both (UA/LA). The picker control remaps jobs (Both→Upper drops lower).',
],
[
'partial_denture',
'Tooth-selected (FDI), not the arch labels. All selected teeth become one lab job.',
],
[
'screw_retained',
'Implant that paints the crown. No crown suggestion slot; Crown and Indirect are disabled. Does not stack with another restoration.',
],
[
'crown_suggestion',
'Implant or post & core (not screw-retained) shows a crown suggestion slot. A veneer/inlay/onlay/overlay hides it. After a crown is picked the slot stays filled so it can be changed.',
],
[
'connected',
'Connected teeth that share a type are one lab job. An extra type on one unit of the bridge is its own job. Unconnected teeth stay separate even if they share a type.',
],
['two_crowns', 'Two crowns cannot stack.'],
];
function xmlEscape(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function colLetter(index: number): string {
let n = index + 1;
let s = '';
while (n > 0) {
const r = (n - 1) % 26;
s = String.fromCharCode(65 + r) + s;
n = Math.floor((n - 1) / 26);
}
return s;
}
function sheetXml(rows: string[][]): string {
const cells = rows
.map((row, r) => {
const rowNum = r + 1;
const inner = row
.map((value, c) => {
const ref = `${colLetter(c)}${rowNum}`;
return `<c r="${ref}" t="inlineStr"><is><t xml:space="preserve">${xmlEscape(value)}</t></is></c>`;
})
.join('');
return `<row r="${rowNum}">${inner}</row>`;
})
.join('');
const dim = `A1:${colLetter(Math.max(0, ...(rows.map((r) => r.length - 1))))}${rows.length || 1}`;
return (
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
`<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">` +
`<dimension ref="${dim}"/>` +
`<sheetData>${cells}</sheetData>` +
`</worksheet>`
);
}
const CRC_TABLE = (() => {
const table = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
table[i] = c >>> 0;
}
return table;
})();
function crc32(buf: Buffer): number {
let crc = 0xffffffff;
for (let i = 0; i < buf.length; i++) {
crc = CRC_TABLE[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8);
}
return (crc ^ 0xffffffff) >>> 0;
}
function zipStore(files: Array<{ name: string; data: Buffer }>): Buffer {
const locals: Buffer[] = [];
const centrals: Buffer[] = [];
let offset = 0;
for (const file of files) {
const name = Buffer.from(file.name, 'utf8');
const compressed = zlib.deflateRawSync(file.data);
const crc = crc32(file.data);
const local = Buffer.alloc(30 + name.length);
local.writeUInt32LE(0x04034b50, 0);
local.writeUInt16LE(20, 4);
local.writeUInt16LE(0, 6);
local.writeUInt16LE(8, 8);
local.writeUInt16LE(0, 10);
local.writeUInt16LE(0, 12);
local.writeUInt32LE(crc, 14);
local.writeUInt32LE(compressed.length, 18);
local.writeUInt32LE(file.data.length, 22);
local.writeUInt16LE(name.length, 26);
local.writeUInt16LE(0, 28);
name.copy(local, 30);
locals.push(local, compressed);
const central = Buffer.alloc(46 + name.length);
central.writeUInt32LE(0x02014b50, 0);
central.writeUInt16LE(20, 4);
central.writeUInt16LE(20, 6);
central.writeUInt16LE(0, 8);
central.writeUInt16LE(8, 10);
central.writeUInt16LE(0, 12);
central.writeUInt16LE(0, 14);
central.writeUInt32LE(crc, 16);
central.writeUInt32LE(compressed.length, 20);
central.writeUInt32LE(file.data.length, 24);
central.writeUInt16LE(name.length, 28);
central.writeUInt16LE(0, 30);
central.writeUInt16LE(0, 32);
central.writeUInt16LE(0, 34);
central.writeUInt16LE(0, 36);
central.writeUInt32LE(0, 38);
central.writeUInt32LE(offset, 42);
name.copy(central, 46);
centrals.push(central);
offset += local.length + compressed.length;
}
const centralDir = Buffer.concat(centrals);
const eocd = Buffer.alloc(22);
eocd.writeUInt32LE(0x06054b50, 0);
eocd.writeUInt16LE(0, 4);
eocd.writeUInt16LE(0, 6);
eocd.writeUInt16LE(files.length, 8);
eocd.writeUInt16LE(files.length, 10);
eocd.writeUInt32LE(centralDir.length, 12);
eocd.writeUInt32LE(offset, 16);
eocd.writeUInt16LE(0, 20);
return Buffer.concat([...locals, centralDir, eocd]);
}
function buildWorkbook(): Buffer {
const typeRows: string[][] = [
[
'category',
'category_label',
'subcategory',
'subcategory_label',
'code',
'label_en',
'label_fa',
'label_nl',
'chartRegion',
'stackGroup',
'active',
],
];
for (const type of PROSTHESIS_TYPES) {
const labels = PROSTHESIS_LABELS[type.code] ?? {};
typeRows.push([
type.category,
CATEGORY_LABELS[type.category] ?? type.category,
type.subcategory ?? '',
type.subcategory ? SUBCATEGORY_LABELS[type.subcategory] ?? type.subcategory : '',
type.code,
labels.en ?? type.code,
labels.fa ?? '',
labels.nl ?? '',
type.chartRegion,
type.stackGroup,
type.isActive === false ? 'false' : 'true',
]);
}
const stepRows: string[][] = [
['code', 'label_en', 'step_order', 'step_code', 'step_en', 'step_fa', 'step_nl'],
];
for (const type of PROSTHESIS_TYPES) {
if (type.isActive === false) continue;
const labels = PROSTHESIS_LABELS[type.code] ?? {};
const steps = buildProsthesisStepCodes(type);
steps.forEach((stepCode, index) => {
const stepLabels = WORKFLOW_STEP_LABELS[stepCode] ?? {};
stepRows.push([
type.code,
labels.en ?? type.code,
String(index + 1),
stepCode,
stepLabels.en ?? stepCode,
stepLabels.fa ?? '',
stepLabels.nl ?? '',
]);
});
}
const contentTypes =
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
`<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">` +
`<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>` +
`<Default Extension="xml" ContentType="application/xml"/>` +
`<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>` +
`<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>` +
`<Override PartName="/xl/worksheets/sheet2.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>` +
`<Override PartName="/xl/worksheets/sheet3.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>` +
`</Types>`;
const rels =
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
`<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>` +
`</Relationships>`;
const workbook =
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
`<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" ` +
`xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">` +
`<sheets>` +
`<sheet name="Types" sheetId="1" r:id="rId1"/>` +
`<sheet name="Steps" sheetId="2" r:id="rId2"/>` +
`<sheet name="Stack rules" sheetId="3" r:id="rId3"/>` +
`</sheets>` +
`</workbook>`;
const workbookRels =
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
`<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>` +
`<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet2.xml"/>` +
`<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet3.xml"/>` +
`</Relationships>`;
return zipStore([
{ name: '[Content_Types].xml', data: Buffer.from(contentTypes, 'utf8') },
{ name: '_rels/.rels', data: Buffer.from(rels, 'utf8') },
{ name: 'xl/workbook.xml', data: Buffer.from(workbook, 'utf8') },
{ name: 'xl/_rels/workbook.xml.rels', data: Buffer.from(workbookRels, 'utf8') },
{ name: 'xl/worksheets/sheet1.xml', data: Buffer.from(sheetXml(typeRows), 'utf8') },
{ name: 'xl/worksheets/sheet2.xml', data: Buffer.from(sheetXml(stepRows), 'utf8') },
{ name: 'xl/worksheets/sheet3.xml', data: Buffer.from(sheetXml(STACK_RULES), 'utf8') },
]);
}
const outPath = path.resolve(__dirname, '../../docs/prosthesis-catalog.xlsx');
fs.mkdirSync(path.dirname(outPath), { recursive: true });
fs.writeFileSync(outPath, buildWorkbook());
console.log(`Wrote ${outPath}`);

View File

@@ -0,0 +1,2 @@
-- DropIndex
DROP INDEX "lab_cases_dueDate_idx";

View File

@@ -0,0 +1,26 @@
-- Prosthesis type tree fields + multiple work items per tooth.
ALTER TABLE "prosthesis_types"
ADD COLUMN IF NOT EXISTS "category" TEXT NOT NULL DEFAULT 'crown',
ADD COLUMN IF NOT EXISTS "subcategory" TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS "chartRegion" TEXT NOT NULL DEFAULT 'crown',
ADD COLUMN IF NOT EXISTS "stackGroup" TEXT NOT NULL DEFAULT 'restoration',
ADD COLUMN IF NOT EXISTS "addonKind" TEXT NOT NULL DEFAULT '';
ALTER TABLE "lab_case_tooth_prosthesis"
DROP CONSTRAINT IF EXISTS "lab_case_tooth_prosthesis_labCaseId_sourceKey_tooth_key";
DROP INDEX IF EXISTS "lab_case_tooth_prosthesis_labCaseId_sourceKey_tooth_key";
-- Deduplicate stacked rows that would collide after adding prosthesisTypeCode to the unique key
-- (legacy unique already forbade two types per tooth, so this is a no-op for current data).
DELETE FROM "lab_case_tooth_prosthesis" AS a
USING "lab_case_tooth_prosthesis" AS b
WHERE a."id" > b."id"
AND a."labCaseId" = b."labCaseId"
AND a."sourceKey" = b."sourceKey"
AND a."tooth" = b."tooth"
AND a."prosthesisTypeCode" = b."prosthesisTypeCode";
CREATE UNIQUE INDEX IF NOT EXISTS "lab_case_tooth_prosthesis_case_source_tooth_type_key"
ON "lab_case_tooth_prosthesis"("labCaseId", "sourceKey", "tooth", "prosthesisTypeCode");

View File

@@ -0,0 +1,12 @@
-- One LabCaseTask pipeline per tooth: unique must include tooth.
ALTER TABLE "lab_case_tasks" ADD COLUMN IF NOT EXISTS "tooth" TEXT NOT NULL DEFAULT '';
UPDATE "lab_case_tasks"
SET "tooth" = COALESCE("teeth"->>0, '')
WHERE "tooth" = '';
ALTER TABLE "lab_case_tasks" DROP CONSTRAINT IF EXISTS "lab_case_tasks_case_source_prosthesis_step_key";
DROP INDEX IF EXISTS "lab_case_tasks_case_source_prosthesis_step_key";
CREATE UNIQUE INDEX IF NOT EXISTS "lab_case_tasks_case_source_tooth_prosthesis_step_key"
ON "lab_case_tasks"("labCaseId", "sourceKey", "tooth", "prosthesisTypeCode", "stepOrder");

View File

@@ -0,0 +1,3 @@
-- Crown suggestion slot replaced catalog addonKind (implant / post_core add-on lists).
ALTER TABLE "prosthesis_types" DROP COLUMN IF EXISTS "addonKind";

View File

@@ -351,6 +351,14 @@ model ProsthesisType {
sortOrder Int @default(0)
isActive Boolean @default(true)
skipPackingShipping Boolean @default(false)
/// Picker column: crown | indirect | implant | post_core | removable | appliance | digital
category String @default("crown")
/// Optional second drill (veneer/inlay/onlay/overlay, night_guard, …). Empty if none.
subcategory String @default("")
/// Chart tint: crown | root | arch
chartRegion String @default("crown")
/// Mutual exclusion group: restoration | implant | post_core | arch
stackGroup String @default("restoration")
steps ProsthesisTypeStep[]
@@ -398,7 +406,7 @@ model LabCaseToothProsthesis {
detail TreatmentDetail? @relation(fields: [treatmentDetailId], references: [id], onDelete: Cascade)
line LabCaseLine? @relation(fields: [lineId], references: [id], onDelete: Cascade)
@@unique([labCaseId, sourceKey, tooth])
@@unique([labCaseId, sourceKey, tooth, prosthesisTypeCode], map: "lab_case_tooth_prosthesis_case_source_tooth_type_key")
@@map("lab_case_tooth_prosthesis")
}
@@ -409,6 +417,8 @@ model LabCaseTask {
lineId String?
/// treatmentDetailId (clinic) or lineId (lab-origin) — required unique grouping key.
sourceKey String
/// Single FDI tooth or arch sentinel (UA/LA). One task pipeline per tooth.
tooth String @default("")
teeth Json
treatmentType String
prosthesisTypeCode String
@@ -433,7 +443,7 @@ model LabCaseTask {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([labCaseId, sourceKey, prosthesisTypeCode, stepOrder], map: "lab_case_tasks_case_source_prosthesis_step_key")
@@unique([labCaseId, sourceKey, tooth, prosthesisTypeCode, stepOrder], map: "lab_case_tasks_case_source_tooth_prosthesis_step_key")
@@index([labCaseId, status])
@@index([assigneeUserId])
@@map("lab_case_tasks")

View File

@@ -183,22 +183,29 @@ async function main() {
);
for (const type of PROSTHESIS_TYPES) {
const treeFields = {
sortOrder: type.sortOrder,
skipPackingShipping: type.skipPackingShipping ?? false,
isActive: type.isActive ?? true,
category: type.category,
subcategory: type.subcategory ?? '',
chartRegion: type.chartRegion,
stackGroup: type.stackGroup,
};
const prosthesisType = await prisma.prosthesisType.upsert({
where: { code: type.code },
update: {
sortOrder: type.sortOrder,
skipPackingShipping: type.skipPackingShipping ?? false,
isActive: true,
},
update: treeFields,
create: {
id: randomUUID(),
code: type.code,
sortOrder: type.sortOrder,
skipPackingShipping: type.skipPackingShipping ?? false,
isActive: true,
...treeFields,
},
});
await prisma.prosthesisTypeStep.deleteMany({
where: { prosthesisTypeId: prosthesisType.id },
});
const stepCodes = buildProsthesisStepCodes(type);
for (const [index, stepCode] of stepCodes.entries()) {
const labWorkflowStepId = workflowStepByCode.get(stepCode);
@@ -206,15 +213,8 @@ async function main() {
throw new Error(`Unknown workflow step code: ${stepCode}`);
}
await prisma.prosthesisTypeStep.upsert({
where: {
prosthesisTypeId_stepOrder: {
prosthesisTypeId: prosthesisType.id,
stepOrder: index + 1,
},
},
update: { labWorkflowStepId },
create: {
await prisma.prosthesisTypeStep.create({
data: {
id: randomUUID(),
prosthesisTypeId: prosthesisType.id,
labWorkflowStepId,

View File

@@ -17,7 +17,10 @@ export class AdminModule {
const { AdminModule: AdminJSModule } = await import('@adminjs/nestjs');
const authenticate = async (email: string, password: string) => {
if (email === 'admin@dyolink.com' && password === 'admin123') {
const adminEmail =
process.env.ADMINJS_EMAIL?.trim() || 'admin@dyolink.com';
const adminPassword = process.env.ADMINJS_PASSWORD || 'admin123';
if (email === adminEmail && password === adminPassword) {
return { email, role: 'admin' };
}
return null;
@@ -30,6 +33,19 @@ export class AdminModule {
imports: [ConfigModule],
inject: [PrismaService, ConfigService],
useFactory: (prisma: PrismaService, config: ConfigService) => {
const cookieSecure = config.get<boolean>('cookie.secure') === true;
const sessionSecret =
config.get<string>('jwt.secret') ||
config.get('JWT_SECRET') ||
'secret-key-change-this';
if (
process.env.NODE_ENV === 'production' &&
!process.env.ADMINJS_PASSWORD
) {
console.warn(
'⚠️ ADMINJS_PASSWORD is unset; AdminJS is using the local default. Set it in backend.env.',
);
}
return {
adminJsOptions: {
rootPath: '/admin',
@@ -93,12 +109,17 @@ export class AdminModule {
auth: {
authenticate,
cookieName: 'dyolink-admin',
cookiePassword: config.get('JWT_SECRET') || 'secret-key-change-this',
cookiePassword: sessionSecret,
},
sessionOptions: {
resave: false,
saveUninitialized: false,
secret: config.get('JWT_SECRET') || 'secret-key-change-this',
secret: sessionSecret,
cookie: {
httpOnly: true,
sameSite: 'lax' as const,
secure: cookieSecure,
},
},
};
},

View File

@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ThrottlerModule } from '@nestjs/throttler';
import { SentryModule } from '@sentry/nestjs/setup';
import configurations from './configs/configurations';
import { AuthModule } from './modules/auth/auth.module';
import { AppController } from './app.controller';
@@ -25,6 +26,7 @@ import { VoiceModule } from './modules/voice/voice.module';
@Module({
imports: [
SentryModule.forRoot(),
ConfigModule.forRoot({
isGlobal: true,
load: [configurations],

View File

@@ -6,6 +6,7 @@ import {
HttpStatus,
Logger,
} from '@nestjs/common';
import * as Sentry from '@sentry/nestjs';
import type { Response } from 'express';
import { AppException, type AppErrorResponse } from './app.exception';
import { ErrorCode, type ErrorCodeValue } from './error-codes';
@@ -42,6 +43,9 @@ export class HttpExceptionFilter implements ExceptionFilter {
this.logger.error(
exception instanceof Error ? exception.stack : String(exception),
);
if (Sentry.getClient()) {
Sentry.captureException(exception);
}
}
response.status(statusCode).json(body);

View File

@@ -0,0 +1,102 @@
import type { Prisma } from '@prisma/client';
const UNION_PINNED_PREFIX = [
'intraoral_scan',
'choosing_abutment',
'design',
'model_create',
] as const;
const UNION_SUFFIX = ['packing', 'shipping'] as const;
const UNION_PINNED = new Set<string>(UNION_PINNED_PREFIX);
const UNION_TAIL = new Set<string>(UNION_SUFFIX);
/** Remaining manufacturing steps — matches catalog LAB_WORKFLOW_STEPS order. */
const UNION_MIDDLE_ORDER: Record<string, number> = {
milling_dry: 1,
milling_wet: 2,
printer_resin: 3,
printer_metal: 4,
pressing: 5,
sinter: 6,
build_up: 7,
stain: 8,
glaze: 9,
polish_prep: 10,
};
/** Union stacked workflows: pin scan → abutment → design → model, then catalog order, pack/ship last. */
export function unionWorkflowStepCodes(lists: readonly (readonly string[])[]): string[] {
const present = new Set<string>();
for (const list of lists) {
for (const code of list) {
if (code) present.add(code);
}
}
const out: string[] = [];
for (const code of UNION_PINNED_PREFIX) {
if (present.has(code)) out.push(code);
}
const middle = [...present].filter((code) => !UNION_PINNED.has(code) && !UNION_TAIL.has(code));
middle.sort((a, b) => {
const da = UNION_MIDDLE_ORDER[a] ?? 100;
const db = UNION_MIDDLE_ORDER[b] ?? 100;
if (da !== db) return da - db;
return a.localeCompare(b);
});
out.push(...middle);
for (const code of UNION_SUFFIX) {
if (present.has(code)) out.push(code);
}
return out;
}
export function prosthesisGroupKey(codes: readonly string[]): string {
return [...new Set(codes.filter(Boolean))].sort().join('+');
}
export function splitProsthesisGroupKey(key: string): string[] {
return key.split('+').map((part) => part.trim()).filter(Boolean);
}
export function prosthesisTypeTaskWhere(code: string): Prisma.LabCaseTaskWhereInput {
return {
OR: [
{ prosthesisTypeCode: code },
{ prosthesisTypeCode: { startsWith: `${code}+` } },
{ prosthesisTypeCode: { endsWith: `+${code}` } },
{ prosthesisTypeCode: { contains: `+${code}+` } },
],
};
}
export function prosthesisTypeCaseWhere(code: string): Prisma.LabCaseWhereInput {
return {
OR: [
{ toothProsthesis: { some: { prosthesisTypeCode: code } } },
{ tasks: { some: prosthesisTypeTaskWhere(code) } },
],
};
}
export function atomicProsthesisCodes(codes: Iterable<string>): string[] {
const out = new Set<string>();
for (const code of codes) {
if (!code) continue;
for (const part of splitProsthesisGroupKey(code)) out.add(part);
}
return [...out];
}
export function prosthesisGroupLabel(
groupKey: string,
labels: ReadonlyMap<string, string>,
): string {
const parts = splitProsthesisGroupKey(groupKey);
if (parts.length === 0) return groupKey;
return parts.map((code) => labels.get(code) ?? code).join(' + ');
}

36
backend/src/instrument.ts Normal file
View File

@@ -0,0 +1,36 @@
import { config } from 'dotenv';
import * as Sentry from '@sentry/nestjs';
config();
const dsn = process.env.SENTRY_DSN?.trim();
if (dsn) {
Sentry.init({
dsn,
environment:
process.env.SENTRY_ENVIRONMENT?.trim() ||
process.env.NODE_ENV ||
'development',
release: process.env.SENTRY_RELEASE?.trim() || undefined,
sendDefaultPii: false,
tracesSampleRate: 0,
beforeSend(event) {
if (event.request) {
delete event.request.cookies;
delete event.request.data;
if (event.request.headers) {
delete event.request.headers.cookie;
delete event.request.headers.authorization;
delete event.request.headers.Authorization;
}
}
if (event.user) {
delete event.user.email;
delete event.user.ip_address;
delete event.user.username;
}
return event;
},
});
}

View File

@@ -1,4 +1,5 @@
// backend/src/main.ts
import './instrument';
import { NestFactory } from '@nestjs/core';
import { urlencoded } from 'express';
import { AppModule } from './app.module';
@@ -29,6 +30,9 @@ async function bootstrap() {
// Nest's built-in one would otherwise reject a voice recording at 100 kb.
const app = await NestFactory.create(AppModule, { bodyParser: false });
// Nginx terminates TLS; AdminJS sessions and Secure cookies need the real proto/host.
app.getHttpAdapter().getInstance().set('trust proxy', 1);
// Voice needs a larger JSON limit than everything else; see body-parsers.ts.
app.use(createJsonBodyParser());
app.use(urlencoded({ extended: true }));

View File

@@ -20,12 +20,21 @@ import { generateLabCaseTasks } from './lab-case-task.generator';
import {
isLabCaseOverdue,
} from '../../common/lab-case-due-date';
import { normalizeTaskTeeth } from './lab-case-task.util';
import {
aggregateProsthesisGroupsFromTasks,
connectedSelectionGroupIds,
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 { UserNotificationService } from '../notifications/user-notification.service';
import { LabCaseAccessService } from './lab-case-access.service';
import {
atomicProsthesisCodes,
prosthesisGroupLabel,
prosthesisTypeCaseWhere,
} from '../../common/prosthesis-group';
const labCaseListInclude = {
treatment: {
@@ -215,7 +224,7 @@ export class CasesService {
}
}
const typeCodes = new Set(taskRows.map((row) => row.prosthesisTypeCode));
const typeCodes = new Set(atomicProsthesisCodes(taskRows.map((row) => row.prosthesisTypeCode)));
const catalog = await this.prosthesisCatalog.list();
const prosthesisTypes = catalog
.filter((entry) => typeCodes.has(entry.code))
@@ -984,13 +993,7 @@ export class CasesService {
AND: [
{ OR: [received, ownedInternal] },
...(query.prosthesisTypeCode
? [
{
tasks: {
some: { prosthesisTypeCode: query.prosthesisTypeCode },
},
} satisfies Prisma.LabCaseWhereInput,
]
? [prosthesisTypeCaseWhere(query.prosthesisTypeCode)]
: []),
...(query.q?.trim() ? [this.buildSearchWhere(query.q.trim())] : []),
],
@@ -1111,7 +1114,7 @@ export class CasesService {
lc.details[0]?.detail?.treatmentType ?? lc.lines[0]?.treatmentType ?? null;
const link = lc.details[0];
const { clinic, patient } = this.resolveClinicAndPatient(lc);
const prosthesisCodes = [...new Set(lc.tasks.map((t) => t.prosthesisTypeCode).filter(Boolean))];
const prosthesisCodes = atomicProsthesisCodes(lc.tasks.map((t) => t.prosthesisTypeCode));
const prosthesisLabels = await this.catalogLabels.resolveLabels(
CatalogEntityKind.PROSTHESIS_TYPE,
prosthesisCodes,
@@ -1208,35 +1211,7 @@ export class CasesService {
connected?: boolean;
selectionGroupId?: string;
}> {
const prosthesisByGroup = new Map<
string,
{ prosthesisTypeCode: string; teeth: string[]; selectionGroupId: string }
>();
for (const task of tasks) {
if (!task.prosthesisTypeCode) continue;
const selectionGroupId = task.selectionGroupId ?? '';
const key = `${selectionGroupId}::${task.prosthesisTypeCode}`;
const teeth = normalizeTaskTeeth(task.teeth);
const entry = prosthesisByGroup.get(key) ?? {
prosthesisTypeCode: task.prosthesisTypeCode,
teeth: [],
selectionGroupId,
};
entry.teeth.push(...teeth);
prosthesisByGroup.set(key, entry);
}
return [...prosthesisByGroup.values()]
.map((group) => ({
prosthesisTypeCode: group.prosthesisTypeCode,
teeth: [...new Set(group.teeth)].sort((a, b) =>
a.localeCompare(b, undefined, { numeric: true }),
),
selectionGroupId: group.selectionGroupId || undefined,
connected: Boolean(group.selectionGroupId) && [...new Set(group.teeth)].length > 1,
}))
.sort((a, b) => a.prosthesisTypeCode.localeCompare(b.prosthesisTypeCode));
return aggregateProsthesisGroupsFromTasks(tasks);
}
private groupTasks(
@@ -1256,16 +1231,18 @@ export class CasesService {
}
>();
const connectedIds = connectedSelectionGroupIds(tasks);
for (const task of tasks) {
const selectionGroupId = task.selectionGroupId ?? '';
const key = `${task.sourceKey ?? task.treatmentDetailId ?? task.lineId}:${selectionGroupId}:${task.prosthesisTypeCode}`;
const key = `${task.sourceKey ?? task.treatmentDetailId ?? task.lineId}:${normalizeTaskTeeth(task.teeth).join(',')}:${task.prosthesisTypeCode}`;
const entry = groups.get(key) ?? {
treatmentDetailId: task.treatmentDetailId ?? task.sourceKey ?? task.lineId ?? '',
teeth: normalizeTaskTeeth(task.teeth),
treatmentType: task.treatmentType,
prosthesisTypeCode: task.prosthesisTypeCode,
prosthesisTypeLabel:
prosthesisLabels.get(task.prosthesisTypeCode) ?? task.prosthesisTypeCode,
prosthesisGroupLabel(task.prosthesisTypeCode, prosthesisLabels),
selectionGroupId,
tasks: [],
};
@@ -1280,7 +1257,7 @@ export class CasesService {
prosthesisTypeCode: group.prosthesisTypeCode,
prosthesisTypeLabel: group.prosthesisTypeLabel,
selectionGroupId: group.selectionGroupId || undefined,
connected: Boolean(group.selectionGroupId) && [...new Set(group.teeth)].length > 1,
connected: Boolean(group.selectionGroupId) && connectedIds.has(group.selectionGroupId),
tasks: group.tasks,
}));
}
@@ -1296,7 +1273,7 @@ export class CasesService {
treatmentType: task.treatmentType,
prosthesisTypeCode: task.prosthesisTypeCode,
prosthesisTypeLabel:
prosthesisLabels.get(task.prosthesisTypeCode) ?? task.prosthesisTypeCode,
prosthesisGroupLabel(task.prosthesisTypeCode, prosthesisLabels),
workflowStepCode: task.workflowStepCode ?? '',
stepOrder: task.stepOrder,
stepLabel: task.stepLabel,

View File

@@ -15,8 +15,12 @@ import {
normalizeCatalogLocale,
} from '../catalog/catalog-label.service';
import { isActorTreatmentProvider } from '../../common/treatment-provider-scope';
import { normalizeTaskTeeth } from './lab-case-task.util';
import { aggregateProsthesisGroupsFromTasks } from './lab-case-task.util';
import { isLabCaseOverdue } from '../../common/lab-case-due-date';
import {
atomicProsthesisCodes,
prosthesisGroupLabel,
} from '../../common/prosthesis-group';
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';
@@ -95,18 +99,14 @@ export class LabCaseAccessService {
}
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 prosthesisCodes = atomicProsthesisCodes(
(
await this.prisma.labCaseTask.findMany({
where: { labCaseId: labCase.id },
select: { prosthesisTypeCode: true },
})
).map((t) => t.prosthesisTypeCode),
);
const prosthesisLabels = await this.catalogLabels.resolveLabels(
CatalogEntityKind.PROSTHESIS_TYPE,
prosthesisCodes,
@@ -303,37 +303,11 @@ export class LabCaseAccessService {
orderBy: [{ prosthesisTypeCode: 'asc' }],
});
const byGroup = new Map<
string,
{ prosthesisTypeCode: string; teeth: string[]; selectionGroupId: string }
>();
for (const task of tasks) {
if (!task.prosthesisTypeCode) continue;
const selectionGroupId = task.selectionGroupId ?? '';
const key = `${selectionGroupId}::${task.prosthesisTypeCode}`;
const teeth = normalizeTaskTeeth(task.teeth);
const entry = byGroup.get(key) ?? {
prosthesisTypeCode: task.prosthesisTypeCode,
teeth: [],
selectionGroupId,
};
entry.teeth.push(...teeth);
byGroup.set(key, entry);
}
return [...byGroup.values()].map((group) => {
const teeth = [...new Set(group.teeth)].sort((a, b) =>
a.localeCompare(b, undefined, { numeric: true }),
);
return {
prosthesisTypeCode: group.prosthesisTypeCode,
prosthesisTypeLabel:
prosthesisLabels.get(group.prosthesisTypeCode) ?? group.prosthesisTypeCode,
teeth,
selectionGroupId: group.selectionGroupId || undefined,
connected: Boolean(group.selectionGroupId) && teeth.length > 1,
};
});
return aggregateProsthesisGroupsFromTasks(tasks).map((group) => ({
...group,
prosthesisTypeLabel:
prosthesisGroupLabel(group.prosthesisTypeCode, prosthesisLabels),
}));
}
private async getMembership(userId: string, organizationId: string) {

View File

@@ -133,7 +133,7 @@ describe('generateLabCaseTasks', () => {
expect(stepCodes).toContain('milling_wet');
});
it('groups teeth sharing a prosthesis in one detail, and keeps other prosthesis separate', async () => {
it('keeps one task set per tooth even when they share a prosthesis', async () => {
const pfmSteps = stepsFromSeed('pfm_crown');
const zirconiaSteps = stepsFromSeed('monolithic_zirconia');
const { tx, created } = buildMockTx({
@@ -150,16 +150,24 @@ describe('generateLabCaseTasks', () => {
const count = await generateLabCaseTasks(tx as never, 'lab-case-group', 'en');
expect(count).toBe(pfmSteps.length + zirconiaSteps.length);
expect(count).toBe(pfmSteps.length * 2 + zirconiaSteps.length);
const rows = created as Array<{ teeth: string[]; prosthesisTypeCode: string }>;
const pfmRows = rows.filter((r) => r.prosthesisTypeCode === 'pfm_crown');
const pfm14 = rows.filter(
(r) => r.prosthesisTypeCode === 'pfm_crown' && r.teeth.join() === '14',
);
const pfm15 = rows.filter(
(r) => r.prosthesisTypeCode === 'pfm_crown' && r.teeth.join() === '15',
);
const zirconiaRows = rows.filter((r) => r.prosthesisTypeCode === 'monolithic_zirconia');
expect(pfmRows).toHaveLength(pfmSteps.length);
expect(pfm14).toHaveLength(pfmSteps.length);
expect(pfm15).toHaveLength(pfmSteps.length);
expect(zirconiaRows).toHaveLength(zirconiaSteps.length);
// Teeth sharing the prosthesis in the same detail are merged and sorted.
expect(pfmRows.every((r) => JSON.stringify(r.teeth) === JSON.stringify(['14', '15']))).toBe(true);
expect(zirconiaRows.every((r) => JSON.stringify(r.teeth) === JSON.stringify(['16']))).toBe(true);
const uniqueKeys = new Set(
rows.map((r) => `${r.teeth.join()}:${r.prosthesisTypeCode}`),
);
expect(uniqueKeys.size).toBe(3);
});
it('omits packing and shipping for smile_design', async () => {
@@ -181,12 +189,12 @@ describe('generateLabCaseTasks', () => {
const stepCodes = (created as Array<{ workflowStepCode: string }>).map(
(row) => row.workflowStepCode,
);
expect(stepCodes).toEqual(['intraoral_scan', 'design']);
expect(stepCodes).not.toContain('packing');
expect(stepCodes).not.toContain('shipping');
expect(stepCodes).toContain('printer_resin');
});
it('merges teeth with the same prosthesis type across selection groups', async () => {
it('merges connected teeth that share a prosthesis type into one task set', async () => {
const pfmSteps = stepsFromSeed('pfm_crown');
const { tx, created } = buildMockTx({
toothProsthesisRows: [
@@ -213,12 +221,59 @@ describe('generateLabCaseTasks', () => {
});
const count = await generateLabCaseTasks(tx as never, 'lab-case-merge', 'en');
expect(count).toBe(pfmSteps.length);
expect(count).toBe(pfmSteps.length * 2);
const rows = created as Array<{ teeth: string[]; prosthesisTypeCode: string }>;
expect(rows).toHaveLength(pfmSteps.length);
expect(rows.every((r) => JSON.stringify(r.teeth) === JSON.stringify(['14', '15', '21']))).toBe(
true,
);
const teethSets = new Set(rows.map((r) => r.teeth.join(',')));
expect(teethSets).toEqual(new Set(['14,15', '21']));
});
it('keeps an extra type on one connected tooth as its own task set', async () => {
const pfmSteps = stepsFromSeed('pfm_crown');
const postSteps = stepsFromSeed('fiber_post_core');
const { tx, created } = buildMockTx({
toothProsthesisRows: [
{
treatmentDetailId: 'detail-1',
tooth: '14',
prosthesisTypeCode: 'pfm_crown',
selectionGroupId: 'connected-1',
},
{
treatmentDetailId: 'detail-1',
tooth: '14',
prosthesisTypeCode: 'fiber_post_core',
selectionGroupId: 'connected-1',
},
{
treatmentDetailId: 'detail-1',
tooth: '15',
prosthesisTypeCode: 'pfm_crown',
selectionGroupId: 'connected-1',
},
{
treatmentDetailId: 'detail-1',
tooth: '16',
prosthesisTypeCode: 'pfm_crown',
selectionGroupId: 'connected-1',
},
],
prosthesisTypes: [
{ code: 'pfm_crown', steps: pfmSteps },
{ code: 'fiber_post_core', steps: postSteps },
],
});
await generateLabCaseTasks(tx as never, 'lab-case-bridge-addon', 'en');
const rows = created as Array<{
teeth: string[];
prosthesisTypeCode: string;
}>;
const byType = new Map<string, string>();
for (const row of rows) {
byType.set(row.prosthesisTypeCode, row.teeth.join(','));
}
expect(byType.get('pfm_crown')).toBe('14,15,16');
expect(byType.get('fiber_post_core')).toBe('14');
});
it('skips generation when tasks already exist', async () => {
@@ -255,12 +310,172 @@ describe('generateLabCaseTasks', () => {
});
const count = await generateLabCaseTasks(tx as never, 'lab-internal-1', 'en');
expect(count).toBe(pfmSteps.length);
expect(count).toBe(pfmSteps.length * 2);
const rows = created as Array<{ teeth: string[]; lineId: string }>;
const teethSets = new Set(rows.map((r) => r.teeth.join(',')));
expect(teethSets).toEqual(new Set(['11', '21']));
expect(created[0]).toMatchObject({
lineId: 'line-1',
sourceKey: 'line-1',
treatmentDetailId: null,
teeth: ['11', '21'],
});
});
it('unions stacked types on the same tooth and de-dupes scan/pack/ship', async () => {
const tiSteps = stepsFromSeed('ti_base_abutment');
const zrSteps = stepsFromSeed('monolithic_zirconia');
const { tx, created } = buildMockTx({
toothProsthesisRows: [
{
treatmentDetailId: 'detail-1',
tooth: '46',
prosthesisTypeCode: 'ti_base_abutment',
},
{
treatmentDetailId: 'detail-1',
tooth: '46',
prosthesisTypeCode: 'monolithic_zirconia',
},
],
prosthesisTypes: [
{ code: 'ti_base_abutment', steps: tiSteps },
{ code: 'monolithic_zirconia', steps: zrSteps },
],
});
const count = await generateLabCaseTasks(tx as never, 'lab-case-stack', 'en');
const rows = created as Array<{
workflowStepCode: string;
prosthesisTypeCode: string;
teeth: string[];
}>;
expect(count).toBe(rows.length);
expect(rows.every((r) => r.teeth.join() === '46')).toBe(true);
expect(rows[0].prosthesisTypeCode).toBe('monolithic_zirconia+ti_base_abutment');
const stepCodes = rows.map((r) => r.workflowStepCode);
expect(stepCodes.filter((c) => c === 'intraoral_scan')).toHaveLength(1);
expect(stepCodes.filter((c) => c === 'packing')).toHaveLength(1);
expect(stepCodes.filter((c) => c === 'shipping')).toHaveLength(1);
expect(stepCodes[0]).toBe('intraoral_scan');
expect(stepCodes[stepCodes.length - 1]).toBe('shipping');
expect(stepCodes).toContain('milling_dry');
expect(stepCodes).toContain('sinter');
expect(stepCodes).toContain('polish_prep');
expect(stepCodes).toContain('model_create');
expect(stepCodes).toContain('design');
expect(stepCodes.indexOf('model_create')).toBeGreaterThan(stepCodes.indexOf('design'));
});
it('omits design for prefab abutments and inserts choosing_abutment before model_create', async () => {
const prefabSteps = stepsFromSeed('prefabricated_abutment');
const { tx, created } = buildMockTx({
toothProsthesisRows: [
{
treatmentDetailId: 'detail-1',
tooth: '36',
prosthesisTypeCode: 'prefabricated_abutment',
},
],
prosthesisTypes: [{ code: 'prefabricated_abutment', steps: prefabSteps }],
});
await generateLabCaseTasks(tx as never, 'lab-case-prefab', 'en');
const stepCodes = (created as Array<{ workflowStepCode: string }>).map(
(row) => row.workflowStepCode,
);
expect(stepCodes).toEqual([
'intraoral_scan',
'choosing_abutment',
'model_create',
'polish_prep',
'packing',
'shipping',
]);
expect(stepCodes).not.toContain('design');
});
it('pins choosing_abutment before design when stacking prefab with a crown', async () => {
const prefabSteps = stepsFromSeed('prefabricated_abutment');
const zrSteps = stepsFromSeed('monolithic_zirconia');
const { tx, created } = buildMockTx({
toothProsthesisRows: [
{
treatmentDetailId: 'detail-1',
tooth: '46',
prosthesisTypeCode: 'prefabricated_abutment',
},
{
treatmentDetailId: 'detail-1',
tooth: '46',
prosthesisTypeCode: 'monolithic_zirconia',
},
],
prosthesisTypes: [
{ code: 'prefabricated_abutment', steps: prefabSteps },
{ code: 'monolithic_zirconia', steps: zrSteps },
],
});
await generateLabCaseTasks(tx as never, 'lab-case-union-pin', 'en');
const stepCodes = (created as Array<{ workflowStepCode: string }>).map(
(row) => row.workflowStepCode,
);
expect(stepCodes[0]).toBe('intraoral_scan');
expect(stepCodes.indexOf('choosing_abutment')).toBeLessThan(stepCodes.indexOf('design'));
expect(stepCodes.indexOf('design')).toBeLessThan(stepCodes.indexOf('model_create'));
expect(stepCodes.filter((c) => c === 'intraoral_scan')).toHaveLength(1);
});
it('merges partial_denture rows on the same sourceKey into one task set', async () => {
const partialSteps = stepsFromSeed('partial_denture');
const { tx, created } = buildMockTx({
toothProsthesisRows: [
{ treatmentDetailId: 'detail-1', tooth: '14', prosthesisTypeCode: 'partial_denture' },
{ treatmentDetailId: 'detail-1', tooth: '15', prosthesisTypeCode: 'partial_denture' },
{ treatmentDetailId: 'detail-1', tooth: '16', prosthesisTypeCode: 'partial_denture' },
{ treatmentDetailId: 'detail-1', tooth: '17', prosthesisTypeCode: 'partial_denture' },
{ treatmentDetailId: 'detail-1', tooth: '18', prosthesisTypeCode: 'partial_denture' },
],
prosthesisTypes: [{ code: 'partial_denture', steps: partialSteps }],
});
const count = await generateLabCaseTasks(tx as never, 'lab-case-partial', 'en');
expect(count).toBe(partialSteps.length);
const rows = created as Array<{
teeth: string[];
tooth: string;
prosthesisTypeCode: string;
}>;
expect(rows.every((r) => r.prosthesisTypeCode === 'partial_denture')).toBe(true);
expect(rows.every((r) => r.teeth.join(',') === '14,15,16,17,18')).toBe(true);
expect(new Set(rows.map((r) => r.tooth)).size).toBe(1);
});
it('keeps crowns on abutment teeth as separate per-tooth jobs beside a merged partial', async () => {
const partialSteps = stepsFromSeed('partial_denture');
const pfmSteps = stepsFromSeed('pfm_crown');
const { tx, created } = buildMockTx({
toothProsthesisRows: [
{ treatmentDetailId: 'detail-1', tooth: '14', prosthesisTypeCode: 'partial_denture' },
{ treatmentDetailId: 'detail-1', tooth: '15', prosthesisTypeCode: 'partial_denture' },
{ treatmentDetailId: 'detail-1', tooth: '14', prosthesisTypeCode: 'pfm_crown' },
],
prosthesisTypes: [
{ code: 'partial_denture', steps: partialSteps },
{ code: 'pfm_crown', steps: pfmSteps },
],
});
await generateLabCaseTasks(tx as never, 'lab-case-partial-crown', 'en');
const rows = created as Array<{
teeth: string[];
prosthesisTypeCode: string;
}>;
const partialRows = rows.filter((r) => r.prosthesisTypeCode === 'partial_denture');
const crownRows = rows.filter((r) => r.prosthesisTypeCode === 'pfm_crown');
expect(partialRows).toHaveLength(partialSteps.length);
expect(partialRows.every((r) => r.teeth.join(',') === '14,15')).toBe(true);
expect(crownRows).toHaveLength(pfmSteps.length);
expect(crownRows.every((r) => r.teeth.join(',') === '14')).toBe(true);
});
});

View File

@@ -1,5 +1,12 @@
import { CatalogEntityKind, LabTaskStatus, Prisma } from '@prisma/client';
import { normalizeCatalogLocale } from '../catalog/catalog-label.service';
import {
prosthesisGroupKey,
unionWorkflowStepCodes,
} from '../../common/prosthesis-group';
import { sortProsthesisTeeth } from './lab-case-task.util';
const PARTIAL_DENTURE_CODE = 'partial_denture';
type TransactionClient = Prisma.TransactionClient;
@@ -30,7 +37,7 @@ export async function generateLabCaseTasks(
const prosthesisCodes = [...new Set(toothProsthesisRows.map((r) => r.prosthesisTypeCode))];
const prosthesisTypes = await tx.prosthesisType.findMany({
where: { code: { in: prosthesisCodes }, isActive: true },
where: { code: { in: prosthesisCodes } },
include: {
steps: {
orderBy: { stepOrder: 'asc' },
@@ -59,21 +66,21 @@ export async function generateLabCaseTasks(
const stepLabels = await resolveStepLabels(tx, allStepCodes, locale);
// Merge all teeth that share the same prosthesis type on a detail into one
// task set (preselection-group behavior). selectionGroupId is stored for
// UI/history only — not part of the grouping key.
const groups = new Map<
string,
{
sourceKey: string;
treatmentDetailId: string | null;
lineId: string | null;
treatmentType: string;
prosthesisTypeCode: string;
selectionGroupId: string;
teeth: string[];
}
>();
// Per tooth: stacked types become one group key; steps are the union.
// Unconnected teeth stay separate even when they share a type.
// Connected (bridge) teeth that share a type merge into one pipeline; an extra
// type on one unit of that bridge is its own pipeline. partial_denture is one
// lab item per sourceKey.
type ToothBucket = {
sourceKey: string;
treatmentDetailId: string | null;
lineId: string | null;
treatmentType: string;
selectionGroupId: string;
codes: Set<string>;
};
const byTooth = new Map<string, ToothBucket>();
for (const row of toothProsthesisRows) {
const sourceKey =
@@ -82,9 +89,9 @@ export async function generateLabCaseTasks(
row.lineId ||
'';
if (!sourceKey) continue;
const key = `${sourceKey}::${row.prosthesisTypeCode}`;
const toothKey = `${sourceKey}::${row.tooth}`;
const selectionGroupId = row.selectionGroupId?.trim() || '';
const group = groups.get(key) ?? {
const bucket = byTooth.get(toothKey) ?? {
sourceKey,
treatmentDetailId: row.treatmentDetailId,
lineId: row.lineId,
@@ -93,44 +100,176 @@ export async function generateLabCaseTasks(
row.line?.treatmentType ??
row.treatmentType ??
'prosthesis',
prosthesisTypeCode: row.prosthesisTypeCode,
selectionGroupId,
teeth: [],
codes: new Set<string>(),
};
// Keep first non-empty selectionGroupId for persistence; grouping ignores it.
if (!group.selectionGroupId && selectionGroupId) {
group.selectionGroupId = selectionGroupId;
if (!bucket.selectionGroupId && selectionGroupId) {
bucket.selectionGroupId = selectionGroupId;
}
group.teeth.push(row.tooth);
groups.set(key, group);
bucket.codes.add(row.prosthesisTypeCode);
byTooth.set(toothKey, bucket);
}
const teethByBridge = new Map<string, Set<string>>();
for (const [toothKey, bucket] of byTooth) {
if (!bucket.selectionGroupId) continue;
const tooth = toothKey.slice(bucket.sourceKey.length + 2);
const bridgeKey = `${bucket.sourceKey}::${bucket.selectionGroupId}`;
const set = teethByBridge.get(bridgeKey) ?? new Set<string>();
set.add(tooth);
teethByBridge.set(bridgeKey, set);
}
const bridgeKeys = new Set(
[...teethByBridge.entries()]
.filter(([, teeth]) => teeth.size > 1)
.map(([key]) => key),
);
type Pipeline = {
sourceKey: string;
treatmentDetailId: string | null;
lineId: string | null;
treatmentType: string;
selectionGroupId: string;
tooth: string;
teeth: string[];
codes: string[];
};
type PartialMerge = {
sourceKey: string;
treatmentDetailId: string | null;
lineId: string | null;
treatmentType: string;
teeth: string[];
groupIds: Set<string>;
};
type ConnectedTypeMerge = {
sourceKey: string;
treatmentDetailId: string | null;
lineId: string | null;
treatmentType: string;
selectionGroupId: string;
code: string;
teeth: string[];
};
const pipelines: Pipeline[] = [];
const partialMerges = new Map<string, PartialMerge>();
const connectedTypeMerges = new Map<string, ConnectedTypeMerge>();
for (const [toothKey, bucket] of byTooth) {
const tooth = toothKey.slice(bucket.sourceKey.length + 2);
const codes = new Set(bucket.codes);
if (codes.has(PARTIAL_DENTURE_CODE)) {
codes.delete(PARTIAL_DENTURE_CODE);
const merge = partialMerges.get(bucket.sourceKey) ?? {
sourceKey: bucket.sourceKey,
treatmentDetailId: bucket.treatmentDetailId,
lineId: bucket.lineId,
treatmentType: bucket.treatmentType,
teeth: [],
groupIds: new Set<string>(),
};
merge.teeth.push(tooth);
if (bucket.selectionGroupId) merge.groupIds.add(bucket.selectionGroupId);
partialMerges.set(bucket.sourceKey, merge);
}
if (codes.size === 0) continue;
const bridgeKey = bucket.selectionGroupId
? `${bucket.sourceKey}::${bucket.selectionGroupId}`
: '';
if (bridgeKey && bridgeKeys.has(bridgeKey)) {
for (const code of codes) {
const typeKey = `${bridgeKey}::${code}`;
const merge = connectedTypeMerges.get(typeKey) ?? {
sourceKey: bucket.sourceKey,
treatmentDetailId: bucket.treatmentDetailId,
lineId: bucket.lineId,
treatmentType: bucket.treatmentType,
selectionGroupId: bucket.selectionGroupId,
code,
teeth: [],
};
if (!merge.teeth.includes(tooth)) merge.teeth.push(tooth);
connectedTypeMerges.set(typeKey, merge);
}
continue;
}
pipelines.push({
sourceKey: bucket.sourceKey,
treatmentDetailId: bucket.treatmentDetailId,
lineId: bucket.lineId,
treatmentType: bucket.treatmentType,
selectionGroupId: bucket.selectionGroupId,
tooth,
teeth: [tooth],
codes: [...codes],
});
}
for (const merge of connectedTypeMerges.values()) {
const teeth = sortProsthesisTeeth(merge.teeth);
pipelines.push({
sourceKey: merge.sourceKey,
treatmentDetailId: merge.treatmentDetailId,
lineId: merge.lineId,
treatmentType: merge.treatmentType,
selectionGroupId: merge.selectionGroupId,
tooth: teeth[0] ?? '',
teeth,
codes: [merge.code],
});
}
for (const merge of partialMerges.values()) {
const teeth = sortProsthesisTeeth(merge.teeth);
const sharedGroup = merge.groupIds.size === 1 ? [...merge.groupIds][0] : '';
pipelines.push({
sourceKey: merge.sourceKey,
treatmentDetailId: merge.treatmentDetailId,
lineId: merge.lineId,
treatmentType: merge.treatmentType,
selectionGroupId: sharedGroup,
tooth: teeth[0] ?? '',
teeth,
codes: [PARTIAL_DENTURE_CODE],
});
}
const taskRows: Prisma.LabCaseTaskCreateManyInput[] = [];
for (const group of groups.values()) {
const typeSteps = stepsByProsthesisCode.get(group.prosthesisTypeCode) ?? [];
if (typeSteps.length === 0) {
for (const pipeline of pipelines) {
const lists = pipeline.codes.map((code) =>
(stepsByProsthesisCode.get(code) ?? []).map((s) => s.workflowStepCode),
);
const unioned = unionWorkflowStepCodes(lists);
if (unioned.length === 0) {
continue;
}
const teeth = sortTeeth(group.teeth);
const groupCode = prosthesisGroupKey(pipeline.codes);
for (const step of typeSteps) {
unioned.forEach((workflowStepCode, index) => {
taskRows.push({
labCaseId,
treatmentDetailId: group.treatmentDetailId,
lineId: group.lineId,
sourceKey: group.sourceKey,
teeth,
treatmentType: group.treatmentType,
prosthesisTypeCode: group.prosthesisTypeCode,
selectionGroupId: group.selectionGroupId,
workflowStepCode: step.workflowStepCode,
stepOrder: step.stepOrder,
stepLabel: stepLabels.get(step.workflowStepCode) ?? step.workflowStepCode,
treatmentDetailId: pipeline.treatmentDetailId,
lineId: pipeline.lineId,
sourceKey: pipeline.sourceKey,
tooth: pipeline.tooth,
teeth: pipeline.teeth,
treatmentType: pipeline.treatmentType,
prosthesisTypeCode: groupCode,
selectionGroupId: pipeline.selectionGroupId,
workflowStepCode,
stepOrder: index + 1,
stepLabel: stepLabels.get(workflowStepCode) ?? workflowStepCode,
status: LabTaskStatus.IN_PROGRESS,
});
}
});
}
if (taskRows.length === 0) {
@@ -141,15 +280,6 @@ export async function generateLabCaseTasks(
return taskRows.length;
}
function sortTeeth(teeth: string[]): string[] {
return [...new Set(teeth)].sort((a, b) => {
const na = Number(a);
const nb = Number(b);
if (!Number.isNaN(na) && !Number.isNaN(nb)) return na - nb;
return a.localeCompare(b);
});
}
async function resolveStepLabels(
tx: TransactionClient,
stepCodes: string[],

View File

@@ -9,3 +9,87 @@ export function normalizeTaskTeeth(value: Prisma.JsonValue | null | undefined):
.filter((v): v is string | number => typeof v === 'string' || typeof v === 'number')
.map((v) => String(v));
}
export function sortProsthesisTeeth(teeth: readonly string[]): string[] {
return [...teeth].sort((a, b) => {
const rank = (tooth: string) => (tooth === 'UA' ? 0 : tooth === 'LA' ? 1 : 2);
const diff = rank(a) - rank(b);
if (diff !== 0) return diff;
return a.localeCompare(b, undefined, { numeric: true });
});
}
/** Group ids that span more than one tooth (bridges). */
export function connectedSelectionGroupIds(
rows: Array<{
selectionGroupId?: string | null;
tooth?: string;
teeth?: Prisma.JsonValue;
}>,
): Set<string> {
const byGroup = new Map<string, Set<string>>();
for (const row of rows) {
const id = row.selectionGroupId?.trim();
if (!id) continue;
const teeth =
row.tooth != null && row.tooth !== ''
? [row.tooth]
: normalizeTaskTeeth(row.teeth);
const set = byGroup.get(id) ?? new Set<string>();
for (const tooth of teeth) set.add(tooth);
byGroup.set(id, set);
}
const connected = new Set<string>();
for (const [id, teeth] of byGroup) {
if (teeth.size > 1) connected.add(id);
}
return connected;
}
export function aggregateProsthesisGroupsFromTasks(
tasks: Array<{
prosthesisTypeCode: string;
teeth: Prisma.JsonValue;
selectionGroupId?: string | null;
}>,
): Array<{
prosthesisTypeCode: string;
teeth: string[];
connected?: boolean;
selectionGroupId?: string;
}> {
const connectedIds = connectedSelectionGroupIds(tasks);
const byKey = new Map<
string,
{ prosthesisTypeCode: string; teeth: string[]; selectionGroupId: string }
>();
for (const task of tasks) {
if (!task.prosthesisTypeCode) continue;
const selectionGroupId = task.selectionGroupId?.trim() ?? '';
const teeth = normalizeTaskTeeth(task.teeth);
const key = `${teeth.join(',')}::${task.prosthesisTypeCode}`;
const entry = byKey.get(key) ?? {
prosthesisTypeCode: task.prosthesisTypeCode,
teeth: [],
selectionGroupId,
};
entry.teeth.push(...teeth);
if (!entry.selectionGroupId && selectionGroupId) {
entry.selectionGroupId = selectionGroupId;
}
byKey.set(key, entry);
}
return [...byKey.values()]
.map((group) => {
const teeth = sortProsthesisTeeth([...new Set(group.teeth)]);
return {
prosthesisTypeCode: group.prosthesisTypeCode,
teeth,
selectionGroupId: group.selectionGroupId || undefined,
connected: Boolean(group.selectionGroupId) && connectedIds.has(group.selectionGroupId),
};
})
.sort((a, b) => a.prosthesisTypeCode.localeCompare(b.prosthesisTypeCode));
}

View File

@@ -12,12 +12,24 @@ export type ProsthesisTypeCatalogEntry = {
code: string;
sortOrder: number;
label: string;
category: string;
subcategory: string;
chartRegion: string;
stackGroup: string;
};
type CatalogRow = {
sortOrder: number;
category: string;
subcategory: string;
chartRegion: string;
stackGroup: string;
};
@Injectable()
export class ProsthesisCatalogService implements OnModuleInit {
private loaded = false;
private byCode = new Map<string, { sortOrder: number }>();
private byCode = new Map<string, CatalogRow>();
constructor(
private readonly prisma: PrismaService,
@@ -32,15 +44,33 @@ export class ProsthesisCatalogService implements OnModuleInit {
const rows = await this.prisma.prosthesisType.findMany({
where: { isActive: true },
orderBy: [{ sortOrder: 'asc' }, { code: 'asc' }],
select: { code: true, sortOrder: true },
select: {
code: true,
sortOrder: true,
category: true,
subcategory: true,
chartRegion: true,
stackGroup: true,
},
});
this.byCode = new Map(rows.map((row) => [row.code, { sortOrder: row.sortOrder }]));
this.byCode = new Map(
rows.map((row) => [
row.code,
{
sortOrder: row.sortOrder,
category: row.category,
subcategory: row.subcategory,
chartRegion: row.chartRegion,
stackGroup: row.stackGroup,
},
]),
);
this.loaded = true;
}
async list(localeInput?: string | null): Promise<ProsthesisTypeCatalogEntry[]> {
this.ensureLoaded();
await this.refresh();
const locale = normalizeCatalogLocale(localeInput);
const codes = [...this.byCode.keys()];
const labels = await this.catalogLabels.resolveLabels(
@@ -50,11 +80,18 @@ export class ProsthesisCatalogService implements OnModuleInit {
);
return codes
.map((code) => ({
code,
sortOrder: this.byCode.get(code)!.sortOrder,
label: labels.get(code) ?? code,
}))
.map((code) => {
const row = this.byCode.get(code)!;
return {
code,
sortOrder: row.sortOrder,
label: labels.get(code) ?? code,
category: row.category,
subcategory: row.subcategory,
chartRegion: row.chartRegion,
stackGroup: row.stackGroup,
};
})
.sort((a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code));
}

View File

@@ -13,6 +13,11 @@ import {
import { normalizeTaskTeeth } from '../cases/lab-case-task.util';
import { isLabCaseOverdue, startOfUtcDay } from '../../common/lab-case-due-date';
import { ListLabTasksDto, LocateTaskPageDto, UpdateLabTaskDto } from './dto/tasks.dto';
import {
atomicProsthesisCodes,
prosthesisGroupLabel,
prosthesisTypeTaskWhere,
} from '../../common/prosthesis-group';
import { hasEffectivePermission } from '../../common/membership-permissions';
import { LabCaseActivityService } from '../notifications/lab-case-activity.service';
import { UserNotificationService } from '../notifications/user-notification.service';
@@ -79,7 +84,7 @@ export class TasksService {
]);
const locale = normalizeCatalogLocale(localeInput);
const prosthesisCodes = [...new Set(items.map((t) => t.prosthesisTypeCode).filter(Boolean))];
const prosthesisCodes = atomicProsthesisCodes(items.map((t) => t.prosthesisTypeCode));
const prosthesisLabels = await this.catalogLabels.resolveLabels(
CatalogEntityKind.PROSTHESIS_TYPE,
prosthesisCodes,
@@ -137,7 +142,7 @@ export class TasksService {
});
const locale = normalizeCatalogLocale(localeInput);
const prosthesisCodes = [...new Set(items.map((t) => t.prosthesisTypeCode).filter(Boolean))];
const prosthesisCodes = atomicProsthesisCodes(items.map((t) => t.prosthesisTypeCode));
const prosthesisLabels = await this.catalogLabels.resolveLabels(
CatalogEntityKind.PROSTHESIS_TYPE,
prosthesisCodes,
@@ -196,6 +201,7 @@ export class TasksService {
sentAt: effectiveAt,
labCaseId: target.labCaseId,
sourceKey: target.sourceKey,
tooth: target.tooth,
prosthesisTypeCode: target.prosthesisTypeCode,
stepOrder: target.stepOrder,
id: target.id,
@@ -347,7 +353,7 @@ export class TasksService {
const locale = normalizeCatalogLocale(localeInput);
const prosthesisLabels = await this.catalogLabels.resolveLabels(
CatalogEntityKind.PROSTHESIS_TYPE,
[updated.prosthesisTypeCode],
atomicProsthesisCodes([updated.prosthesisTypeCode]),
locale,
);
@@ -486,7 +492,7 @@ export class TasksService {
...(query.assignedToMe ? { assigneeUserId: actorUserId } : {}),
...(query.unassignedOnly ? { assigneeUserId: null } : {}),
...(query.prosthesisTypeCode?.trim()
? { prosthesisTypeCode: query.prosthesisTypeCode.trim() }
? prosthesisTypeTaskWhere(query.prosthesisTypeCode.trim())
: {}),
};
@@ -496,7 +502,7 @@ export class TasksService {
}
const completedGroups = await this.prisma.labCaseTask.groupBy({
by: ['labCaseId', 'sourceKey', 'prosthesisTypeCode'],
by: ['labCaseId', 'sourceKey', 'tooth', 'prosthesisTypeCode'],
where: {
workflowStepCode: stepCompleted,
status: LabTaskStatus.COMPLETED,
@@ -515,6 +521,7 @@ export class TasksService {
OR: completedGroups.map((group) => ({
labCaseId: group.labCaseId,
sourceKey: group.sourceKey,
tooth: group.tooth,
prosthesisTypeCode: group.prosthesisTypeCode,
})),
},
@@ -552,6 +559,7 @@ export class TasksService {
sentAt: Date;
labCaseId: string;
sourceKey: string;
tooth: string;
prosthesisTypeCode: string;
stepOrder: number;
id: string;
@@ -586,6 +594,15 @@ export class TasksService {
sameSentAt,
{ labCaseId: target.labCaseId },
{ sourceKey: target.sourceKey },
{ tooth: { lt: target.tooth } },
],
},
{
AND: [
sameSentAt,
{ labCaseId: target.labCaseId },
{ sourceKey: target.sourceKey },
{ tooth: target.tooth },
{ prosthesisTypeCode: { lt: target.prosthesisTypeCode } },
],
},
@@ -594,6 +611,7 @@ export class TasksService {
sameSentAt,
{ labCaseId: target.labCaseId },
{ sourceKey: target.sourceKey },
{ tooth: target.tooth },
{ prosthesisTypeCode: target.prosthesisTypeCode },
{ stepOrder: { lt: target.stepOrder } },
],
@@ -603,6 +621,7 @@ export class TasksService {
sameSentAt,
{ labCaseId: target.labCaseId },
{ sourceKey: target.sourceKey },
{ tooth: target.tooth },
{ prosthesisTypeCode: target.prosthesisTypeCode },
{ stepOrder: target.stepOrder },
{ id: { lt: target.id } },
@@ -729,6 +748,7 @@ export class TasksService {
{ labCase: { sentAt: 'desc' } },
{ labCaseId: 'asc' },
{ sourceKey: 'asc' },
{ tooth: 'asc' },
{ prosthesisTypeCode: 'asc' },
{ stepOrder: 'asc' },
{ id: 'asc' },
@@ -741,6 +761,7 @@ export class TasksService {
{ labCase: { sentAt: dir } },
{ labCaseId: 'asc' },
{ sourceKey: 'asc' },
{ tooth: 'asc' },
{ prosthesisTypeCode: 'asc' },
{ stepOrder: 'asc' },
{ id: 'asc' },
@@ -781,7 +802,7 @@ export class TasksService {
treatmentType: task.treatmentType,
prosthesisTypeCode: task.prosthesisTypeCode,
prosthesisTypeLabel:
prosthesisLabels.get(task.prosthesisTypeCode) ?? task.prosthesisTypeCode,
prosthesisGroupLabel(task.prosthesisTypeCode, prosthesisLabels),
workflowStepCode: task.workflowStepCode,
stepOrder: task.stepOrder,
stepLabel: task.stepLabel,

View File

@@ -35,6 +35,36 @@ describe('assertCompleteToothProsthesisMap', () => {
).not.toThrow();
});
it('passes when there are no FDI teeth but an arch sentinel is mapped', () => {
expect(() =>
assertCompleteToothProsthesisMap({
details: [
{
treatmentDetailId: prosthesisDetailId,
detail: { id: prosthesisDetailId, treatmentType: 'prosthesis', teeth: [] },
},
],
toothProsthesis: [
{ treatmentDetailId: prosthesisDetailId, tooth: 'UA', prosthesisTypeCode: 'full_denture' },
],
}),
).not.toThrow();
});
it('throws when a prosthesis detail has neither teeth nor an arch job', () => {
expect(() =>
assertCompleteToothProsthesisMap({
details: [
{
treatmentDetailId: prosthesisDetailId,
detail: { id: prosthesisDetailId, treatmentType: 'prosthesis', teeth: [] },
},
],
toothProsthesis: [],
}),
).toThrow(AppException);
});
it('throws when a prosthesis tooth is missing from the map', () => {
expect(() =>
assertCompleteToothProsthesisMap({

View File

@@ -32,5 +32,16 @@ export function assertCompleteToothProsthesisMap(labCase: {
throw new AppException(ErrorCode.TREATMENT_TOOTH_PROSTHESIS_INCOMPLETE, HttpStatus.BAD_REQUEST);
}
}
if (teeth.length === 0) {
const hasArchJob = labCase.toothProsthesis.some(
(tp) =>
tp.treatmentDetailId === link.treatmentDetailId &&
(tp.tooth === 'UA' || tp.tooth === 'LA') &&
Boolean(tp.prosthesisTypeCode),
);
if (!hasArchJob) {
throw new AppException(ErrorCode.TREATMENT_TOOTH_PROSTHESIS_INCOMPLETE, HttpStatus.BAD_REQUEST);
}
}
}
}

View File

@@ -115,13 +115,14 @@ export class TreatmentsController {
@ApiOperation({ summary: 'Get draft treatment for an appointment (TAB_TREATMENT_READ)' })
getDraft(
@Param('appointmentId') appointmentId: string,
@Req() req: { user: { id: string; organizationId?: string } },
@Req() req: { user: { id: string; organizationId?: string; language?: string | null } },
) {
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
return this.treatmentsService.getDraftForAppointment(
appointmentId,
organizationId,
req.user.id,
req.user.language,
);
}
@@ -130,7 +131,7 @@ export class TreatmentsController {
saveDraft(
@Param('appointmentId') appointmentId: string,
@Body() dto: SaveTreatmentDraftDto,
@Req() req: { user: { id: string; organizationId?: string } },
@Req() req: { user: { id: string; organizationId?: string; language?: string | null } },
) {
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
return this.treatmentsService.saveDraftForAppointment(
@@ -138,6 +139,7 @@ export class TreatmentsController {
dto,
organizationId,
req.user.id,
req.user.language,
);
}
@@ -146,7 +148,7 @@ export class TreatmentsController {
saveLabCases(
@Param('appointmentId') appointmentId: string,
@Body() dto: SaveTreatmentLabCasesDto,
@Req() req: { user: { id: string; organizationId?: string } },
@Req() req: { user: { id: string; organizationId?: string; language?: string | null } },
) {
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
return this.treatmentsService.saveLabCasesForAppointment(
@@ -154,6 +156,7 @@ export class TreatmentsController {
dto,
organizationId,
req.user.id,
req.user.language,
);
}
@@ -301,13 +304,14 @@ export class TreatmentsController {
@ApiOperation({ summary: 'Get a treatment draft by treatment id (TAB_TREATMENT_READ)' })
getDraftByTreatment(
@Param('treatmentId') treatmentId: string,
@Req() req: { user: { id: string; organizationId?: string } },
@Req() req: { user: { id: string; organizationId?: string; language?: string | null } },
) {
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
return this.treatmentsService.getDraftForTreatment(
treatmentId,
organizationId,
req.user.id,
req.user.language,
);
}
@@ -316,7 +320,7 @@ export class TreatmentsController {
saveDraftByTreatment(
@Param('treatmentId') treatmentId: string,
@Body() dto: SaveTreatmentDraftDto,
@Req() req: { user: { id: string; organizationId?: string } },
@Req() req: { user: { id: string; organizationId?: string; language?: string | null } },
) {
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
return this.treatmentsService.saveDraftForTreatment(
@@ -324,6 +328,7 @@ export class TreatmentsController {
dto,
organizationId,
req.user.id,
req.user.language,
);
}
@@ -332,7 +337,7 @@ export class TreatmentsController {
saveLabCasesByTreatment(
@Param('treatmentId') treatmentId: string,
@Body() dto: SaveTreatmentLabCasesDto,
@Req() req: { user: { id: string; organizationId?: string } },
@Req() req: { user: { id: string; organizationId?: string; language?: string | null } },
) {
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
return this.treatmentsService.saveLabCasesForTreatment(
@@ -340,6 +345,7 @@ export class TreatmentsController {
dto,
organizationId,
req.user.id,
req.user.language,
);
}

View File

@@ -7,7 +7,10 @@ import { LabCaseActivityType, LabTaskStatus, LinkStatus, Prisma, UserNotificatio
import { createReadStream, existsSync, mkdirSync } from 'fs';
import { join } from 'path';
import { randomUUID } from 'crypto';
import { generateLabCaseAccessToken } from '../../common/lab-case-access-token';
import {
buildLabCaseShareUrl,
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';
@@ -477,6 +480,7 @@ export class TreatmentsService {
appointmentId: string,
organizationId: string,
actorUserId: string,
actorLanguage?: string | null,
) {
await this.assertCanReadTreatment(actorUserId, organizationId);
const appointment = await this.ensureAppointmentProvider(
@@ -493,16 +497,25 @@ export class TreatmentsService {
include: treatmentInclude,
});
return { success: true, data: treatment ? this.mapTreatment(treatment) : null };
if (treatment) {
await this.ensureSentLabCaseAccessTokens(treatment.labCases);
}
return {
success: true,
data: treatment ? this.mapTreatment(treatment, actorLanguage) : null,
};
}
async getDraftForTreatment(
treatmentId: string,
organizationId: string,
actorUserId: string,
actorLanguage?: string | null,
) {
const treatment = await this.ensureTreatmentProvider(treatmentId, organizationId, actorUserId);
return { success: true, data: this.mapTreatment(treatment) };
await this.ensureSentLabCaseAccessTokens(treatment.labCases);
return { success: true, data: this.mapTreatment(treatment, actorLanguage) };
}
async saveDraftForAppointment(
@@ -510,6 +523,7 @@ export class TreatmentsService {
dto: SaveTreatmentDraftDto,
organizationId: string,
actorUserId: string,
actorLanguage?: string | null,
) {
await this.assertCanEditTreatment(actorUserId, organizationId);
const appointment = await this.ensureAppointmentProvider(
@@ -549,7 +563,7 @@ export class TreatmentsService {
patientId: appointment.patientId,
});
return { success: true, data: this.mapTreatment(saved) };
return { success: true, data: this.mapTreatment(saved, actorLanguage) };
}
async saveDraftForTreatment(
@@ -557,6 +571,7 @@ export class TreatmentsService {
dto: SaveTreatmentDraftDto,
organizationId: string,
actorUserId: string,
actorLanguage?: string | null,
) {
await this.assertCanEditTreatment(actorUserId, organizationId);
const existing = await this.ensureTreatmentProvider(treatmentId, organizationId, actorUserId);
@@ -572,7 +587,7 @@ export class TreatmentsService {
patientId: existing.patientId,
});
return { success: true, data: this.mapTreatment(saved) };
return { success: true, data: this.mapTreatment(saved, actorLanguage) };
}
private async persistDraftDetails(args: {
@@ -719,6 +734,7 @@ export class TreatmentsService {
dto: SaveTreatmentLabCasesDto,
organizationId: string,
actorUserId: string,
actorLanguage?: string | null,
) {
await this.assertCanEditTreatment(actorUserId, organizationId);
const appointment = await this.ensureAppointmentProvider(
@@ -736,7 +752,13 @@ export class TreatmentsService {
throw new AppException(ErrorCode.TREATMENT_SAVE_DETAILS_BEFORE_LAB, HttpStatus.NOT_FOUND);
}
return this.saveLabCasesForTreatment(treatment.id, dto, organizationId, actorUserId);
return this.saveLabCasesForTreatment(
treatment.id,
dto,
organizationId,
actorUserId,
actorLanguage,
);
}
async saveLabCasesForTreatment(
@@ -744,6 +766,7 @@ export class TreatmentsService {
dto: SaveTreatmentLabCasesDto,
organizationId: string,
actorUserId: string,
actorLanguage?: string | null,
) {
await this.assertCanEditTreatment(actorUserId, organizationId);
await this.ensureTreatmentProvider(treatmentId, organizationId, actorUserId);
@@ -785,7 +808,8 @@ export class TreatmentsService {
throw new AppException(ErrorCode.TREATMENT_TOOTH_UNKNOWN_DETAIL, HttpStatus.BAD_REQUEST);
}
const teeth = normalizeTeeth(detail.teeth);
if (!teeth.includes(row.tooth)) {
const isArchSentinel = row.tooth === 'UA' || row.tooth === 'LA';
if (!isArchSentinel && !teeth.includes(row.tooth)) {
throw new AppException(ErrorCode.TREATMENT_TOOTH_NOT_ON_DETAIL, HttpStatus.BAD_REQUEST);
}
this.prosthesisCatalog.assertKnownProsthesisType(row.prosthesisTypeCode);
@@ -906,7 +930,7 @@ export class TreatmentsService {
});
});
return { success: true, data: this.mapTreatment(saved) };
return { success: true, data: this.mapTreatment(saved, actorLanguage) };
}
async sendLabCase(
@@ -1048,7 +1072,7 @@ export class TreatmentsService {
},
});
return { success: true, data: this.mapLabCase(refreshed) };
return { success: true, data: this.mapLabCase(refreshed, actorLanguage) };
}
async updateLabCaseDueDate(
@@ -1410,7 +1434,7 @@ export class TreatmentsService {
organization: { id: string; name: string };
}>;
}>;
}) {
}, locale?: string | null) {
const documents = treatment.details.flatMap((d) =>
d.attachments.map((a) => this.mapAttachment(a)),
);
@@ -1433,7 +1457,7 @@ export class TreatmentsService {
}
: null,
details: treatment.details.map((d) => this.mapDetail(d)),
labCases: treatment.labCases.map((lc) => this.mapLabCase(lc)),
labCases: treatment.labCases.map((lc) => this.mapLabCase(lc, locale)),
documents,
};
}
@@ -1496,6 +1520,7 @@ export class TreatmentsService {
destinationOrganizationId?: string | null;
sentAt?: Date | null;
dueDate?: Date | null;
accessToken?: string | null;
details?: Array<{
treatmentDetailId: string;
detail?: {
@@ -1527,7 +1552,7 @@ export class TreatmentsService {
};
}>;
tasks?: Array<{ id: string; status: LabTaskStatus }>;
}) {
}, locale?: string | null) {
const taskProgress = this.mapTaskProgress(lc.tasks ?? []);
const detailTeeth = lc.details?.[0]?.detail
? normalizeTeeth(lc.details[0].detail.teeth)
@@ -1571,9 +1596,32 @@ export class TreatmentsService {
organizationName: s.organization?.name ?? 'Unknown organization',
sentAt: s.sentAt.toISOString(),
})) ?? [],
shareUrl: this.labCaseShareUrl(lc, locale),
};
}
private labCaseShareUrl(
lc: { sentAt?: Date | null; accessToken?: string | null },
locale?: string | null,
): string | null {
if (!lc.sentAt || !lc.accessToken) return null;
return buildLabCaseShareUrl(lc.accessToken, locale ?? 'en');
}
private async ensureSentLabCaseAccessTokens(
labCases: Array<{ id: string; sentAt: Date | null; accessToken: string | null }>,
) {
for (const lc of labCases) {
if (!lc.sentAt || lc.accessToken) continue;
const accessToken = generateLabCaseAccessToken();
await this.prisma.labCase.update({
where: { id: lc.id },
data: { accessToken },
});
lc.accessToken = accessToken;
}
}
private mapTaskProgress(tasks: Array<{ status: LabTaskStatus }>) {
const total = tasks.length;
const completed = tasks.filter((task) => task.status === LabTaskStatus.COMPLETED).length;

Binary file not shown.

View File

@@ -5,3 +5,7 @@ NEXT_PUBLIC_API_URL=http://localhost:3000/api
NEXT_PUBLIC_APP_NAME=DyoLink
# URL where users open the frontend (used for metadata, images, etc.)
NEXT_PUBLIC_APP_URL=http://localhost:3001
# GlitchTip frontend project DSN (optional locally). Baked into the Docker image in CI.
# NEXT_PUBLIC_SENTRY_DSN=https://PUBLIC_KEY@errors.wixur.ir/2
# NEXT_PUBLIC_SENTRY_ENVIRONMENT=development

View File

@@ -18,12 +18,16 @@ COPY . .
ARG NEXT_PUBLIC_API_URL
ARG NEXT_PUBLIC_APP_URL
ARG NEXT_PUBLIC_APP_NAME
ARG NEXT_PUBLIC_SENTRY_DSN
ARG NEXT_PUBLIC_SENTRY_ENVIRONMENT
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
ENV NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL}
ENV NEXT_PUBLIC_APP_NAME=${NEXT_PUBLIC_APP_NAME}
ENV NEXT_PUBLIC_SENTRY_DSN=${NEXT_PUBLIC_SENTRY_DSN}
ENV NEXT_PUBLIC_SENTRY_ENVIRONMENT=${NEXT_PUBLIC_SENTRY_ENVIRONMENT}
RUN npm run build
@@ -41,19 +45,19 @@ ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
COPY --from=builder /app/public ./public
COPY --from=builder --chown=dyolink:nodejs /app/.next/standalone ./
COPY --from=builder --chown=dyolink:nodejs /app/.next/static ./.next/static
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Windows git/build context may use CRLF; strip before chmod (fixes dumb-init "No such file or directory").
RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh && chmod +x /usr/local/bin/docker-entrypoint.sh
USER dyolink
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
CMD node -e "require('http').get('http://127.0.0.1:3000/', (r) => {if(r.statusCode!==200)process.exit(1)})"
CMD node -e "require('http').get('http://127.0.0.1:3000/', (r) => { process.exit(r.statusCode >= 200 && r.statusCode < 400 ? 0 : 1); }).on('error', () => process.exit(1))"
ENTRYPOINT ["dumb-init", "--", "docker-entrypoint.sh"]

View File

@@ -18,6 +18,9 @@
"redirecting": "Redirecting…",
"readOnlyAccess": "Read-only access for this organization.",
"errorGeneric": "Something went wrong",
"pageErrorTitle": "Something went wrong",
"pageErrorBody": "This page failed to load. You can try again.",
"tryAgain": "Try again",
"loadingEllipsis": "Loading...",
"search": "Search",
"action": "Action",
@@ -722,6 +725,8 @@
"addDetail": "Add detail",
"confirmRemoveDetail": "Remove this treatment detail?",
"confirmRemoveLastDetail": "This is the last detail. Removing it leaves the plan empty until you add another. Continue?",
"confirmLeaveProsthesis": "Leaving prosthesis removes the tooth chart and assigned lab types on this detail. To keep that work and add another type in the same plan, use Add detail first, then change type on the new line. Continue?",
"confirmResetChart": "Clear all selected teeth and assigned types on this chart?",
"removeDetailAria": "Remove detail {n}",
"detailLabel": "Detail {n}",
"detailSentBadge": "sent",
@@ -752,7 +757,9 @@
"noOrgInvitePermission": "You do not have permission to invite labs. Contact your organization owner.",
"sendThisCase": "Send this case",
"labDispatchTitle": "Lab dispatch",
"labDispatchSubtitle": "Choose a lab, set prosthesis types, and send.",
"showLabDispatch": "Lab dispatch",
"showChart": "Chart",
"labDispatchSubtitle": "Choose a lab and send.",
"labDispatchSendHint": "Shipment grouping is saved when you send to the lab.",
"addLabShipment": "Add lab shipment",
"labShipmentLabel": "Shipment {n}",
@@ -764,12 +771,12 @@
"noLabDetails": "No prosthesis treatment details yet. Add prosthesis in treatment details above.",
"labDispatchEmpty": "Add a lab shipment to group details and send them to a lab.",
"prosthesisTypesTitle": "Prosthesis types",
"prosthesisApplyAll": "Apply to all teeth",
"prosthesisSelectPlaceholder": "Select prosthesis type…",
"prosthesisColTooth": "Tooth",
"prosthesisColDetail": "Detail",
"prosthesisColType": "Prosthesis type",
"prosthesisUnassigned": "Unassigned",
"prosthesisEditOnChart": "Choose types on the chart — click a tooth or an arch label to add or change work.",
"prosthesisMissingOnChart": "Click each tooth on the chart to assign a type before sending.",
"selectLab": "Destination lab",
"selectLabPlaceholder": "Choose a linked lab…",
"sendToLab": "Send to lab",
@@ -796,7 +803,7 @@
"historyClearFilters": "Remove filters",
"historyFilterEmpty": "No treatments match these filters.",
"labShipmentBlockedTitle": "Lab shipment not available yet",
"labShipmentBlockedBody": "Select at least one tooth on this prosthesis detail before you can create a lab shipment.",
"labShipmentBlockedBody": "Assign at least one tooth or an arch product on this prosthesis detail before you can send it to the lab.",
"labCaseCommentsTitle": "Lab case comments",
"labCaseCommentsHint": "Message the lab while this case is still in progress. Comments close when all lab tasks are completed.",
"labShipmentsTitle": "Lab shipments",
@@ -864,22 +871,15 @@
"noActiveOrgs": "No active linked organizations.",
"confirmSend": "Confirm send",
"toothChartTitle": "FDI tooth chart",
"entryWizardLabel": "Treatment entry steps",
"entryStepTeeth": "Teeth",
"entryStepContent": "Content",
"entryStepLab": "Lab",
"entryStepTreatment": "Treatment",
"entryStepBack": "Back",
"entryStepNext": "Next",
"continueToLab": "Continue to lab",
"backToTreatment": "Back to treatment",
"selectTypeBeforeTeeth": "Choose a treatment type before selecting teeth.",
"labDispatchNotSent": "Not sent",
"labDispatchLastLab": "Last lab: {name}",
"labCommentsMovedHint": "Progress and comments are in Lab shipments.",
"entryStepLabUnavailable": "Lab dispatch is available after you save a lab-dependent detail with teeth selected.",
"toothChartTitleCompact": "Tooth chart",
"toothChartHint": "Tap teeth to select or deselect. When two neighbors are selected, an empty circle appears between them — tap it to connect (or tap a filled circle to disconnect). Shift-click a range to select all in between (not connected until you fill the circles).",
"toothChartHint": "Click a tooth to select or deselect it. Hold Shift and click another tooth to select a range. Click the circle between neighbors to connect or disconnect a bridge.",
"toothChartHintAssign": "Click a tooth to assign work. Shift-click a second tooth to select a range — the picker opens for that span. Click a selected tooth with no type to deselect it; Clear removes the whole range. Hold Ctrl or drag to copy the last type set. Click Upper arch or Lower arch to assign a jaw-level product. Click the circle between neighbors to connect or disconnect a bridge.",
"toothChartHelpAria": "How to select teeth",
"toothConnectedHint": "Connected selection",
"toothLinkHint": "Connect these teeth",
@@ -890,12 +890,17 @@
"connectedBadge": "Connected",
"prosthesisConnectedLabel": "Connected teeth prosthesis type",
"toothChartWholePlan": "Full View",
"resetChart": "Reset",
"resetChartAria": "Clear all teeth on this chart",
"labShipmentAttachments": "Files for the lab",
"labShipmentAttachmentsHint": "All files are included. Uncheck any you do not want to send.",
"selectedLabel": "Selected:",
"selectedEmpty": "—",
"selectedArchUpper": "Upper arch",
"selectedArchLower": "Lower arch",
"upperArch": "Upper arch",
"lowerArch": "Lower arch",
"archAssignHint": "Click to assign a jaw-level product",
"toothAria": "FDI tooth {fdi}",
"toothSelectedSuffix": ", selected",
"sentToAt": "Sent to {orgName} at {datetime}",
@@ -1096,6 +1101,46 @@
"destStaff": "Open Staff",
"destOpen": "Open"
},
"prosthesis": {
"pickerTitle": "Assign work",
"arch_upper": "Upper",
"arch_lower": "Lower",
"arch_both": "Both",
"category_crown": "Crowns",
"category_indirect": "Veneer/Inlay/Onlay/Overlay",
"category_implant": "Implants",
"category_post_core": "Post & core",
"category_removable": "Removable",
"category_appliance": "Appliances",
"category_digital": "Digital",
"sub_full_contour": "Full contour",
"sub_layered": "Layered",
"sub_veneer": "Veneer",
"sub_inlay": "Inlay",
"sub_onlay": "Onlay",
"sub_overlay": "Overlay",
"sub_complete_denture": "Complete denture",
"sub_partial_denture": "Partial denture",
"sub_night_guard": "Night guard",
"addonNone": "None",
"addonCrownCanBeAdded": "Crown can be added",
"clear": "Clear this tooth",
"done": "Done",
"pickerTeeth": "Teeth {teeth}",
"pickerArchUpper": "Upper arch",
"pickerArchLower": "Lower arch",
"pickerArchBoth": "Both arches",
"clearArch": "Clear this arch",
"clearHintArch": "Clear removes the jaw-level product. Teeth on this detail stay as they are.",
"noneYet": "None yet",
"clearHint": "Clear this tooth removes it from the case. Use × on a chip to drop one type.",
"removeJob": "Remove this type",
"pickerTooth": "Tooth {tooth}",
"slotCrown": "Crown",
"suggestionsLabel": "Suggested add-ons",
"pickerBack": "Back",
"categoryDisabledHint": "This category cannot be combined with the types already on this tooth."
},
"errors": {
"GENERIC": "Something went wrong. Please try again.",
"NETWORK_ERROR": "Could not reach the server. Check your connection and try again.",

View File

@@ -18,6 +18,9 @@
"redirecting": "در حال انتقال...",
"readOnlyAccess": "دسترسی فقط خواندنی برای این سازمان.",
"errorGeneric": "خطایی رخ داده است",
"pageErrorTitle": "خطایی رخ داده است",
"pageErrorBody": "این صفحه بارگذاری نشد. می‌توانید دوباره تلاش کنید.",
"tryAgain": "تلاش دوباره",
"loadingEllipsis": "در حال بارگذاری...",
"search": "جستجو",
"action": "عملیات",
@@ -723,6 +726,8 @@
"addDetail": "افزودن جزئیات",
"confirmRemoveDetail": "این جزئیات درمان حذف شود؟",
"confirmRemoveLastDetail": "این آخرین جزئیات است. با حذف آن برنامه خالی می‌ماند تا جزئیات جدیدی اضافه کنید. ادامه می‌دهید؟",
"confirmLeaveProsthesis": "خروج از پروتز، انتخاب دندان‌ها و انواع اختصاص‌داده‌شده این جزئیات را پاک می‌کند. برای نگه داشتن این کار و افزودن نوع دیگر در همین برنامه، ابتدا «افزودن جزئیات» را بزنید و نوع را روی خط جدید عوض کنید. ادامه می‌دهید؟",
"confirmResetChart": "همه دندان‌های انتخاب‌شده و انواع اختصاص‌داده‌شده این نمودار پاک شود؟",
"removeDetailAria": "حذف جزئیات {n}",
"detailLabel": "جزئیات {n}",
"detailSentBadge": "ارسال‌شده",
@@ -753,7 +758,9 @@
"noOrgInvitePermission": "شما مجوز دعوت از آزمایشگاه را ندارید. با مالک سازمان تماس بگیرید.",
"sendThisCase": "ارسال این پرونده",
"labDispatchTitle": "ارسال به لابراتوار",
"labDispatchSubtitle": "لابراتوار را انتخاب کنید، نوع پروتز را مشخص کنید و ارسال کنید.",
"showLabDispatch": "ارسال به لابراتوار",
"showChart": "نمودار",
"labDispatchSubtitle": "لابراتوار را انتخاب کنید و ارسال کنید.",
"labDispatchSendHint": "گروه‌بندی محموله هنگام ارسال به لاب ذخیره می‌شود.",
"addLabShipment": "افزودن محموله لاب",
"labShipmentLabel": "محموله {n}",
@@ -765,12 +772,12 @@
"noLabDetails": "هنوز جزئیات پروتز وجود ندارد. پروتز را در جزئیات درمان بالا اضافه کنید.",
"labDispatchEmpty": "یک محموله لاب اضافه کنید تا جزئیات را گروه‌بندی و ارسال کنید.",
"prosthesisTypesTitle": "انواع پروتز",
"prosthesisApplyAll": "اعمال برای همه دندان‌ها",
"prosthesisSelectPlaceholder": "نوع پروتز را انتخاب کنید…",
"prosthesisColTooth": "دندان",
"prosthesisColDetail": "جزئیات",
"prosthesisColType": "نوع پروتز",
"prosthesisUnassigned": "تخصیص‌داده‌نشده",
"prosthesisEditOnChart": "انواع را روی چارت انتخاب کنید — روی دندان یا برچسب قوس کلیک کنید تا کار اضافه یا تغییر شود.",
"prosthesisMissingOnChart": "قبل از ارسال، روی هر دندان در چارت کلیک کنید و نوع را اختصاص دهید.",
"selectLab": "لابراتوار مقصد",
"selectLabPlaceholder": "یک لابراتوار متصل انتخاب کنید…",
"sendToLab": "ارسال به لابراتوار",
@@ -797,7 +804,7 @@
"historyClearFilters": "حذف فیلترها",
"historyFilterEmpty": "هیچ درمانی با این فیلترها یافت نشد.",
"labShipmentBlockedTitle": "ارسال به لابراتوار هنوز ممکن نیست",
"labShipmentBlockedBody": "قبل از ایجاد ارسال لابراتوار، حداقل یک دندان برای این جزئیات پروتز انتخاب کنید.",
"labShipmentBlockedBody": "قبل از ارسال به لابراتوار، حداقل یک دندان یا یک محصول فکی برای این جزئیات پروتز اختصاص دهید.",
"labCaseCommentsTitle": "نظرات پرونده لابراتوار",
"labCaseCommentsHint": "تا زمانی که پرونده در لابراتوار در حال انجام است با لابراتوار پیام بگذارید. پس از تکمیل همه کارها، نظردهی بسته می‌شود.",
"labShipmentsTitle": "ارسال‌های لابراتوار",
@@ -866,7 +873,8 @@
"confirmSend": "تأیید ارسال",
"toothChartTitle": "نمودار دندان‌ها FDI",
"toothChartTitleCompact": "نمودار دندان",
"toothChartHint": "برای انتخاب یا لغو انتخاب روی دندان ضربه بزنید. وقتی دو دندان همسایه انتخاب شوند، دایرهٔ خالی بینشان ظاهر می‌شود — برای اتصال روی آن بزنید (یا روی دایرهٔ پر برای قطع اتصال). Shift+کلیک روی یک بازه، همه را انتخاب می‌کند (تا وقتی دایره‌ها را پر نکنید متصل نیستند).",
"toothChartHint": "برای انتخاب یا لغو انتخاب روی دندان کلیک کنید. Shift را نگه دارید و روی دندان دیگر کلیک کنید تا یک بازه انتخاب شود. روی دایره بین همسایه‌ها کلیک کنید تا بریج وصل یا قطع شود.",
"toothChartHintAssign": "برای اختصاص کار روی دندان کلیک کنید. Shift را نگه دارید و روی دندان دوم کلیک کنید تا یک بازه انتخاب شود — انتخاب‌گر برای همان بازه باز می‌شود. روی دندان انتخاب‌شدهٔ بدون نوع کلیک کنید تا از انتخاب خارج شود؛ پاک‌کردن کل بازه را برمی‌دارد. Ctrl را نگه دارید یا بکشید تا آخرین مجموعه نوع کپی شود. روی «قوس بالا» یا «قوس پایین» کلیک کنید تا محصول فکی اختصاص داده شود. روی دایره بین همسایه‌ها کلیک کنید تا بریج وصل یا قطع شود.",
"toothChartHelpAria": "راهنمای انتخاب دندان",
"toothConnectedHint": "انتخاب متصل",
"toothLinkHint": "اتصال این دندان‌ها",
@@ -876,27 +884,24 @@
"toothConnectedSuffix": " (متصل)",
"connectedBadge": "متصل",
"prosthesisConnectedLabel": "نوع پروتز دندان‌های متصل",
"entryWizardLabel": "مراحل ثبت درمان",
"entryStepTeeth": "دندان‌ها",
"entryStepContent": "محتوا",
"entryStepLab": "لابراتوار",
"entryStepTreatment": "درمان",
"entryStepBack": "قبلی",
"entryStepNext": "بعدی",
"continueToLab": "ادامه به لابراتوار",
"backToTreatment": "بازگشت به درمان",
"selectTypeBeforeTeeth": "قبل از انتخاب دندان، نوع درمان را انتخاب کنید.",
"labDispatchNotSent": "ارسال نشده",
"labDispatchLastLab": "آخرین لاب: {name}",
"labCommentsMovedHint": "پیشرفت و پیام‌ها در بخش محموله‌های لاب هستند.",
"entryStepLabUnavailable": "ارسال به لابراتوار پس از ذخیره جزئیات وابسته به لاب با انتخاب دندان در دسترس است.",
"toothChartWholePlan": "نمایش کامل",
"resetChart": "بازنشانی",
"resetChartAria": "پاک کردن همه دندان‌های این نمودار",
"labShipmentAttachments": "فایل‌ها برای لابراتوار",
"labShipmentAttachmentsHint": "همه فایل‌ها پیوست می‌شوند. برای حذف از ارسال، تیک را بردارید.",
"selectedLabel": "انتخاب شده:",
"selectedEmpty": "—",
"selectedArchUpper": "قوس بالا",
"selectedArchLower": "قوس پایین",
"upperArch": "قوس بالا",
"lowerArch": "قوس پایین",
"archAssignHint": "برای اختصاص محصول فکی کلیک کنید",
"toothAria": "دندان FDI {fdi}",
"toothSelectedSuffix": "، انتخاب شده",
"sentToAt": "ارسال به {orgName} در {datetime}",
@@ -1097,6 +1102,46 @@
"destStaff": "باز کردن کارکنان",
"destOpen": "باز کردن"
},
"prosthesis": {
"pickerTitle": "اختصاص کار",
"arch_upper": "فک بالا",
"arch_lower": "فک پایین",
"arch_both": "هر دو",
"category_crown": "روکش‌ها",
"category_indirect": "ونیر/اینلی/آنلی/اورلی",
"category_implant": "ایمپلنت",
"category_post_core": "پست و کور",
"category_removable": "متحرک",
"category_appliance": "اپلاینس‌ها",
"category_digital": "دیجیتال",
"sub_full_contour": "کانتور کامل",
"sub_layered": "لایه‌ای",
"sub_veneer": "ونیر",
"sub_inlay": "اینلی",
"sub_onlay": "آنلی",
"sub_overlay": "اورلی",
"sub_complete_denture": "دست دندان کامل",
"sub_partial_denture": "پارسیل",
"sub_night_guard": "نایت گارد",
"addonNone": "هیچ‌کدام",
"addonCrownCanBeAdded": "روکش می‌تواند افزوده شود",
"clear": "پاک کردن این دندان",
"done": "تمام",
"pickerTeeth": "دندان‌های {teeth}",
"pickerArchUpper": "قوس بالا",
"pickerArchLower": "قوس پایین",
"pickerArchBoth": "هر دو قوس",
"clearArch": "پاک کردن این قوس",
"clearHintArch": "پاک کردن، محصول فکی را برمی‌دارد. دندان‌های این جزئیات باقی می‌مانند.",
"noneYet": "هنوز هیچ",
"clearHint": "«پاک کردن این دندان» آن را از کیس حذف می‌کند. با × روی تراشه یک نوع را بردارید.",
"removeJob": "حذف این نوع",
"pickerTooth": "دندان {tooth}",
"slotCrown": "روکش",
"suggestionsLabel": "افزودنی‌های پیشنهادی",
"pickerBack": "بازگشت",
"categoryDisabledHint": "این دسته با انواعی که روی این دندان هستند قابل ترکیب نیست."
},
"errors": {
"GENERIC": "مشکلی پیش آمد. لطفاً دوباره تلاش کنید.",
"NETWORK_ERROR": "اتصال به سرور برقرار نشد. اتصال اینترنت را بررسی کنید.",

View File

@@ -18,6 +18,9 @@
"redirecting": "Bezig met doorsturen...",
"readOnlyAccess": "Alleen-lezen toegang voor deze organisatie.",
"errorGeneric": "Er is iets misgegaan",
"pageErrorTitle": "Er is iets misgegaan",
"pageErrorBody": "Deze pagina kon niet worden geladen. U kunt het opnieuw proberen.",
"tryAgain": "Opnieuw proberen",
"loadingEllipsis": "Laden...",
"search": "Zoeken",
"action": "Actie",
@@ -722,6 +725,8 @@
"addDetail": "Detail toevoegen",
"confirmRemoveDetail": "Dit behandelingsdetail verwijderen?",
"confirmRemoveLastDetail": "Dit is het laatste detail. Als u het verwijdert, blijft het plan leeg tot u een nieuw detail toevoegt. Doorgaan?",
"confirmLeaveProsthesis": "Als u prothese verlaat, worden het tanddiagram en de toegewezen labtypen van dit detail gewist. Om dat werk te behouden en een ander type in hetzelfde plan toe te voegen, gebruikt u eerst Detail toevoegen en wijzigt u het type op de nieuwe regel. Doorgaan?",
"confirmResetChart": "Alle geselecteerde tanden en toegewezen typen op dit diagram wissen?",
"removeDetailAria": "Detail {n} verwijderen",
"detailLabel": "Detail {n}",
"detailSentBadge": "verzonden",
@@ -752,7 +757,9 @@
"noOrgInvitePermission": "U heeft geen toestemming om labs uit te nodigen. Neem contact op met de organisatie-eigenaar.",
"sendThisCase": "Verzend deze case",
"labDispatchTitle": "Lab-dispatch",
"labDispatchSubtitle": "Kies een lab, stel prothesetypes in en verstuur.",
"showLabDispatch": "Lab-dispatch",
"showChart": "Gebitskaart",
"labDispatchSubtitle": "Kies een lab en verstuur.",
"labDispatchSendHint": "Groepering wordt opgeslagen wanneer u naar het lab verzendt.",
"addLabShipment": "Labzending toevoegen",
"labShipmentLabel": "Zending {n}",
@@ -764,12 +771,12 @@
"noLabDetails": "Nog geen prothese-details. Voeg prothese toe in de behandeldetails hierboven.",
"labDispatchEmpty": "Voeg een labzending toe om details te groeperen en naar een lab te sturen.",
"prosthesisTypesTitle": "Prothesetypes",
"prosthesisApplyAll": "Toepassen op alle tanden",
"prosthesisSelectPlaceholder": "Selecteer prothesetype…",
"prosthesisColTooth": "Tand",
"prosthesisColDetail": "Detail",
"prosthesisColType": "Prothesetype",
"prosthesisUnassigned": "Niet toegewezen",
"prosthesisEditOnChart": "Kies types op het schema — klik op een tand of een booglabel om werk toe te voegen of te wijzigen.",
"prosthesisMissingOnChart": "Klik op elke tand op het schema om een type toe te wijzen voordat u verstuurt.",
"selectLab": "Bestemmingslab",
"selectLabPlaceholder": "Kies een gekoppeld lab…",
"sendToLab": "Versturen naar lab",
@@ -796,7 +803,7 @@
"historyClearFilters": "Filters verwijderen",
"historyFilterEmpty": "Geen behandelingen komen overeen met deze filters.",
"labShipmentBlockedTitle": "Labverzending nog niet beschikbaar",
"labShipmentBlockedBody": "Selecteer minstens één tand voor dit prothesedetail voordat u een labverzending kunt aanmaken.",
"labShipmentBlockedBody": "Wijs minstens één tand of een boogproduct toe aan dit prothesedetail voordat u het naar het lab kunt sturen.",
"labCaseCommentsTitle": "Opmerkingen labcase",
"labCaseCommentsHint": "Stuur berichten naar het lab terwijl deze case nog in behandeling is. Opmerkingen sluiten wanneer alle labtaken zijn afgerond.",
"labShipmentsTitle": "Labzendingen",
@@ -865,7 +872,8 @@
"confirmSend": "Bevestig verzending",
"toothChartTitle": "FDI-tanddiagram",
"toothChartTitleCompact": "Tanddiagram",
"toothChartHint": "Tik op tanden om te selecteren of deselecteren. Bij twee geselecteerde buren verschijnt een lege cirkel ertussen — tik om te verbinden (of tik op een gevulde cirkel om te ontkoppelen). Shift-klik een bereik om alles ertussen te selecteren (nog niet verbonden tot je de cirkels vult).",
"toothChartHint": "Klik op een tand om deze te selecteren of te deselecteren. Houd Shift ingedrukt en klik op een andere tand om een bereik te selecteren. Klik op de cirkel tussen buren om een brug te verbinden of te ontkoppelen.",
"toothChartHintAssign": "Klik op een tand om werk toe te wijzen. Shift-klik op een tweede tand om een bereik te selecteren — de kiezer opent voor dat bereik. Klik op een geselecteerde tand zonder type om die te deselecteren; Wissen verwijdert het hele bereik. Houd Ctrl ingedrukt of sleep om de laatste typeset te kopiëren. Klik op Bovenboog of Onderboog voor een boogproduct. Klik op de cirkel tussen buren om een brug te verbinden of te ontkoppelen.",
"toothChartHelpAria": "Tanden selecteren",
"toothConnectedHint": "Verbonden selectie",
"toothLinkHint": "Deze tanden verbinden",
@@ -875,27 +883,24 @@
"toothConnectedSuffix": " (verbonden)",
"connectedBadge": "Verbonden",
"prosthesisConnectedLabel": "Prothesetype voor verbonden tanden",
"entryWizardLabel": "Behandeling invoerstappen",
"entryStepTeeth": "Tanden",
"entryStepContent": "Inhoud",
"entryStepLab": "Lab",
"entryStepTreatment": "Behandeling",
"entryStepBack": "Terug",
"entryStepNext": "Volgende",
"continueToLab": "Verder naar lab",
"backToTreatment": "Terug naar behandeling",
"selectTypeBeforeTeeth": "Kies een behandeltype voordat u tanden selecteert.",
"labDispatchNotSent": "Niet verzonden",
"labDispatchLastLab": "Laatste lab: {name}",
"labCommentsMovedHint": "Voortgang en berichten staan bij Labzendingen.",
"entryStepLabUnavailable": "Labverzending is beschikbaar nadat u een lab-afhankelijk detail met geselecteerde tanden hebt opgeslagen.",
"toothChartWholePlan": "Volledig overzicht",
"resetChart": "Resetten",
"resetChartAria": "Alle tanden op dit diagram wissen",
"labShipmentAttachments": "Bestanden voor het lab",
"labShipmentAttachmentsHint": "Alle bestanden gaan mee. Vink uit wat u niet wilt meesturen.",
"selectedLabel": "Geselecteerd:",
"selectedEmpty": "—",
"selectedArchUpper": "Bovenboog",
"selectedArchLower": "Onderboog",
"upperArch": "Bovenboog",
"lowerArch": "Onderboog",
"archAssignHint": "Klik om een boogproduct toe te wijzen",
"toothAria": "FDI-tand {fdi}",
"toothSelectedSuffix": ", geselecteerd",
"sentToAt": "Verzonden naar {orgName} op {datetime}",
@@ -1096,6 +1101,46 @@
"destStaff": "Personeel openen",
"destOpen": "Openen"
},
"prosthesis": {
"pickerTitle": "Werk toewijzen",
"arch_upper": "Boven",
"arch_lower": "Onder",
"arch_both": "Beide",
"category_crown": "Kronen",
"category_indirect": "Veneer/Inlay/Onlay/Overlay",
"category_implant": "Implantaten",
"category_post_core": "Stiftopbouw",
"category_removable": "Uitneembaar",
"category_appliance": "Apparatuur",
"category_digital": "Digitaal",
"sub_full_contour": "Volledige contour",
"sub_layered": "Gelaagd",
"sub_veneer": "Veneer",
"sub_inlay": "Inlay",
"sub_onlay": "Onlay",
"sub_overlay": "Overlay",
"sub_complete_denture": "Volledige prothese",
"sub_partial_denture": "Partiële prothese",
"sub_night_guard": "Nachtbeugel",
"addonNone": "Geen",
"addonCrownCanBeAdded": "Kroon kan worden toegevoegd",
"clear": "Deze tand wissen",
"done": "Klaar",
"pickerTeeth": "Tanden {teeth}",
"pickerArchUpper": "Bovenboog",
"pickerArchLower": "Onderboog",
"pickerArchBoth": "Beide bogen",
"clearArch": "Deze boog wissen",
"clearHintArch": "Wissen verwijdert het boogproduct. Tanden op dit detail blijven staan.",
"noneYet": "Nog geen",
"clearHint": "Deze tand wissen verwijdert hem uit de case. Gebruik × op een chip om één type te verwijderen.",
"removeJob": "Dit type verwijderen",
"pickerTooth": "Tand {tooth}",
"slotCrown": "Kroon",
"suggestionsLabel": "Voorgestelde toevoegingen",
"pickerBack": "Terug",
"categoryDisabledHint": "Deze categorie kan niet worden gecombineerd met de types die al op deze tand staan."
},
"errors": {
"GENERIC": "Er is iets misgegaan. Probeer het opnieuw.",
"NETWORK_ERROR": "Kan de server niet bereiken. Controleer uw verbinding.",

View File

@@ -1,4 +1,5 @@
import type { NextConfig } from 'next';
import { withSentryConfig } from '@sentry/nextjs';
import createNextIntlPlugin from 'next-intl/plugin';
const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
@@ -35,9 +36,15 @@ const nextConfig: NextConfig = {
NEXT_PUBLIC_APP_NAME: process.env.NEXT_PUBLIC_APP_NAME,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN,
NEXT_PUBLIC_SENTRY_ENVIRONMENT: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT,
},
output: 'standalone',
compress: true,
};
export default withNextIntl(nextConfig);
export default withSentryConfig(withNextIntl(nextConfig), {
silent: true,
sourcemaps: { disable: true },
disableLogger: true,
});

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,7 @@
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@sentry/nextjs": "^10.72.0",
"@tanstack/react-query": "^5.90.21",
"axios": "^1.13.6",
"js-cookie": "^3.0.5",

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 28 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 23 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 306 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.7 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.8 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.6 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 33 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 41 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -0,0 +1,87 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 330">
<!-- Complete tooth silhouette: one continuous, non-crossing contour -->
<path d="
M29 62
C38 41 57 31 77 34
C89 36 98 42 110 42
C122 42 131 36 143 34
C163 31 182 41 191 62
C201 86 198 116 187 142
C181 156 174 168 168 177
C163 185 160 194 159 205
C157 232 153 260 146 285
C140 304 132 316 124 319
C119 321 116 317 117 310
C120 289 119 270 116 249
C114 237 111 228 110 223
C109 228 106 237 104 249
C101 270 100 289 103 310
C104 317 101 321 96 319
C88 316 80 304 74 285
C67 260 63 232 61 205
C60 194 57 185 52 177
C46 168 39 156 33 142
C22 116 19 86 29 62 Z"
fill="#F3F3F3"
stroke="#243549"
stroke-width="4"
stroke-linecap="round"
stroke-linejoin="round"/>
<!-- Crown overlay; same clean upper contour, no duplicated/crossing segment -->
<path d="
M29 62
C38 41 57 31 77 34
C89 36 98 42 110 42
C122 42 131 36 143 34
C163 31 182 41 191 62
C201 86 198 116 187 142
C181 156 174 168 168 177
C164 183 160 187 154 187
H145
L138 137
C137 131 133 128 127 128
H93
C87 128 83 131 82 137
L75 187
H66
C60 187 56 183 52 177
C46 168 39 156 33 142
C22 116 19 86 29 62 Z"
fill="#FFFFFF"
stroke="#243549"
stroke-width="4"
stroke-linecap="round"
stroke-linejoin="round"/>
<!-- Core -->
<path d="
M82 137
C83 131 87 128 93 128
H127
C133 128 137 131 138 137
L145 187
H75 Z"
fill="#9A9A9A"
stroke="#243549"
stroke-width="4"
stroke-linecap="round"
stroke-linejoin="round"/>
<!-- Post -->
<path d="
M94 187
H126
L121 257
C120 273 116 282 110 282
C104 282 100 273 99 257
Z"
fill="#8A8A8A"
stroke="#243549"
stroke-width="4"
stroke-linecap="round"
stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 20 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 306 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 31 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,30 @@
'use client';
import { useEffect } from 'react';
import { useTranslations } from 'next-intl';
import * as Sentry from '@sentry/nextjs';
import { Button } from '@/components/ui/shared/Button';
export default function LocaleError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
const t = useTranslations('common');
useEffect(() => {
Sentry.captureException(error);
}, [error]);
return (
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4 px-4 text-center">
<h1 className="text-lg font-semibold">{t('pageErrorTitle')}</h1>
<p className="max-w-md text-sm text-text-secondary">{t('pageErrorBody')}</p>
<Button type="button" onClick={() => reset()}>
{t('tryAgain')}
</Button>
</div>
);
}

View File

@@ -0,0 +1,44 @@
'use client';
import { useEffect } from 'react';
import * as Sentry from '@sentry/nextjs';
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
Sentry.captureException(error);
}, [error]);
return (
<html lang="en">
<body>
<div
style={{
display: 'flex',
minHeight: '100vh',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '1rem',
padding: '1rem',
textAlign: 'center',
fontFamily: 'system-ui, sans-serif',
}}
>
<h1 style={{ fontSize: '1.125rem', fontWeight: 600 }}>Something went wrong</h1>
<p style={{ maxWidth: '28rem', fontSize: '0.875rem' }}>
The page failed to load. You can try again.
</p>
<button type="button" onClick={() => reset()}>
Try again
</button>
</div>
</body>
</html>
);
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 23 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 306 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.7 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.8 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.6 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 33 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 41 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 18 KiB

Some files were not shown because too many files have changed in this diff Show More