Compare commits

...

19 Commits

Author SHA1 Message Date
62d5d94121 review notification logic and fix minor issue.
All checks were successful
Production — tag build, push, deploy / build-and-push (push) Successful in 39m57s
Production — tag build, push, deploy / deploy (push) Successful in 13m26s
2026-09-07 23:18:00 +03:30
880809fdbe improvement: users get notified whenever a task is done completely, not per step. 2026-09-07 22:36:33 +03:30
e52701dba3 Merge pull request 'update adminjs with current db schema.' (#77) from feature/ci-cd into master
All checks were successful
Registry — build, push, deploy / build-and-push (push) Successful in 1m26s
Registry — build, push, deploy / deploy (push) Successful in 2m43s
Reviewed-on: #77
Reviewed-by: admin <admin@localhost>
2026-09-07 21:40:17 +03:30
b1bcfc69e4 Lab Case Detail datatset load faliure fixed.
All checks were successful
Production — tag build, push, deploy / build-and-push (push) Successful in 26m42s
Production — tag build, push, deploy / deploy (push) Successful in 13m27s
2026-09-07 20:41:18 +03:30
0d2c279f4b Merge branch 'master' into feature/ci-cd
All checks were successful
Production — tag build, push, deploy / build-and-push (push) Successful in 36s
Production — tag build, push, deploy / deploy (push) Successful in 12m8s
2026-09-07 18:33:04 +03:30
fb59aed2f8 Merge pull request 'improvement: Frontend Dockerfile updated so that aseests in public folder loads on the website.' (#76) from improvements/subscription-v1 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: #76
Reviewed-by: rameen <rameen.naghdi@gmail.com>
2026-09-07 18:31:29 +03:30
4add3ab859 update adming js 2026-09-07 18:21:30 +03:30
19b5671b0d improvement: Frontend Dockerfile updated so that aseests in public folder loads on the website.
All checks were successful
Production — tag build, push, deploy / build-and-push (push) Successful in 4m51s
Production — tag build, push, deploy / deploy (push) Successful in 2m47s
2026-09-06 14:20:28 +03:30
ea6976351a Merge pull request 'improvements/subscription-v1' (#75) from improvements/subscription-v1 into master
All checks were successful
Registry — build, push, deploy / build-and-push (push) Successful in 31m6s
Registry — build, push, deploy / deploy (push) Successful in 2m1s
Reviewed-on: #75
Reviewed-by: aminmsvi <amn.mousavi@gmail.com>
2026-09-06 12:55:39 +03:30
9f6eacd0fb Merge branch 'master' into improvements/subscription-v1
All checks were successful
Production — tag build, push, deploy / build-and-push (push) Successful in 4m22s
Production — tag build, push, deploy / deploy (push) Successful in 17m31s
2026-09-05 23:07:49 +03:30
52c4480c5d Merge pull request 'Fix Ci/Cd: Get Node another way — try Gitea cache, then Arvan / AWS / GCR, and only then Docker Hub; once pulled, store it on Gitea for next time.' (#74) from feature/ci-cd into master
All checks were successful
Registry — build, push, deploy / build-and-push (push) Successful in 43s
Registry — build, push, deploy / deploy (push) Successful in 1m25s
Reviewed-on: #74
Reviewed-by: admin <admin@localhost>
2026-09-05 23:00:29 +03:30
1ca8e6178e improvement: Nothing done is related to subscriptions!!! 2026-09-05 20:44:16 +03:30
6c7e0574b9 fix: omit ExtraSources when NODE_IMAGE_SOURCE is empty
All checks were successful
Production — tag build, push, deploy / build-and-push (push) Successful in 5m17s
Production — tag build, push, deploy / deploy (push) Successful in 13m34s
2026-09-05 20:18:06 +03:30
feafc3a219 fix: pass empty ExtraSources when NODE_IMAGE_SOURCE is unset 2026-09-05 20:11:18 +03:30
8fa856a980 fix: pull node:20-alpine from Arvan/ECR when Docker Hub is blocked 2026-09-05 19:59:45 +03:30
005e0b0394 fix: parse ci-resolve-node-image.ps1 on Windows PowerShell 5.1 2026-09-05 19:48:46 +03:30
c1846f8e22 fix: mirror node:20-alpine to Gitea when Docker Hub TLS times out
BuildKit was failing on registry-1.docker.io even when a local base
image existed. CI now prefers a Gitea-hosted NODE_IMAGE and retries
docker build.
2026-09-05 19:12:37 +03:30
663eafa3e8 fix: fail Gitea deploy when Docker image push does not succeed
Windows PowerShell ignored docker exit codes, so build-and-push looked
green while :sha tags were never pushed and deploy pulled missing images.
2026-09-05 19:03:06 +03:30
5cd3436d0c Merge pull request 'improvement: icons added to prosthesis types catalog.' (#73) from improvements/prosthesis-catalog-icons into master
Some checks failed
Registry — build, push, deploy / build-and-push (push) Successful in 41s
Registry — build, push, deploy / deploy (push) Failing after 10s
Reviewed-on: #73
Reviewed-by: aminmsvi <amn.mousavi@gmail.com>
2026-09-05 13:45:25 +03:30
62 changed files with 1197 additions and 259 deletions

29
.cursor/rules/adminjs.mdc Normal file
View File

@@ -0,0 +1,29 @@
---
description: AdminJS panel must stay in sync with Prisma schema changes
globs: backend/src/admin/**,backend/prisma/schema.prisma,backend/prisma/migrations/**
alwaysApply: false
---
# AdminJS ↔ Prisma sync (required)
Ops panel at `/admin` (`backend/src/admin/`). Resources are a **manual allowlist** — Prisma does **not** auto-update AdminJS.
## When you change `schema.prisma`
**Before finishing the task**, update AdminJS:
1. Open [`backend/src/admin/resources.ts`](backend/src/admin/resources.ts) (`buildAdminResources`).
2. **New model** ops may need to inspect/fix → add `resource(...)` + navigation group + hide secrets.
3. **Renamed / removed model** → update or remove the matching resource (broken `getModelByName` breaks `/admin` boot).
4. **New secret fields** (hashes, tokens, share tokens) → hide via `isVisible: false` (list/filter/show/edit).
5. **Catalog-like reference data** → list/show/edit only; disable `new` / `delete` / `bulkDelete`.
6. **Composite `@@id` only** (no single `@id`) → **do not register** — AdminJS list returns 500 (`Resource does not have an id property`). Examples: `LabCaseDetail`, `MembershipPermission`, read-cursor tables.
7. Skip pure join/cursor tables unless ops need them and they have a single id.
Auth: `ADMINJS_EMAIL` / `ADMINJS_PASSWORD` — production login disabled if password missing or still `admin123`.
Production Docker: `ADMIN_JS_TMP_DIR=/app/adminjs-tmp` (not `.adminjs`) so `components.bundle.js` can be written/served.
## Secrets to hide
`passwordHash`, session `token`/`refreshToken`, invite/OTP `tokenHash`/`codeHash`, `LabCase.accessToken`.

View File

@@ -7,6 +7,7 @@ alwaysApply: false
# Appointments # Appointments
- List includes `hasTreatment` when a `Treatment` row is linked (`appointmentId`). - List includes `hasTreatment` when a `Treatment` row is linked (`appointmentId`).
- **Patient:** create/update must use a **named** patient of this org (`createdByOrganizationId`, not walk-in). Helper `ensurePatientInOrg`. Do not book another clinics patient UUID.
- **Patient change** blocked while linked → `APPOINTMENT_PATIENT_LOCKED` (UI: `patientLockedHint`). - **Patient change** blocked while linked → `APPOINTMENT_PATIENT_LOCKED` (UI: `patientLockedHint`).
- **Delete** blocked while linked → `APPOINTMENT_HAS_TREATMENT` (hide delete + `deleteBlockedHint`). Empty appointments (no treatment yet) remain deletable. - **Delete** blocked while linked → `APPOINTMENT_HAS_TREATMENT` (hide delete + `deleteBlockedHint`). Empty appointments (no treatment yet) remain deletable.
- **Past days:** new bookings stay blocked. Existing appointments **without** treatment can be edited/deleted; with treatment → toast `infoEditBlockedHasTreatment` (no modal). Banner clicks are not gated by `canBook` (slots still are). - **Past days:** new bookings stay blocked. Existing appointments **without** treatment can be edited/deleted; with treatment → toast `infoEditBlockedHasTreatment` (no modal). Banner clicks are not gated by `canBook` (slots still are).

View File

@@ -35,6 +35,7 @@ throw new AppException(ErrorCode.PERMISSION_DENIED, HttpStatus.FORBIDDEN);
- Schema: `backend/prisma/schema.prisma` - Schema: `backend/prisma/schema.prisma`
- Always add a migration for schema changes (`npm run prisma:migrate` in backend). - Always add a migration for schema changes (`npm run prisma:migrate` in backend).
- Seed permissions stay in sync with `ALL_TAB_PERMISSIONS` in `common/permissions.ts`. - Seed permissions stay in sync with `ALL_TAB_PERMISSIONS` in `common/permissions.ts`.
- **Schema change ⇒ AdminJS:** update `backend/src/admin/resources.ts` in the same change (add/rename/remove resources, hide new secrets). See `.cursor/rules/adminjs.mdc`.
## API responses ## API responses

View File

@@ -11,6 +11,7 @@ Monorepo: `backend/` (NestJS + Prisma), `frontend/` (Next.js + next-intl), `infr
- **CLINIC** orgs: patients, appointments, treatment, staff. - **CLINIC** orgs: patients, appointments, treatment, staff.
- **LAB** orgs: cases, tasks, lab workflows. - **LAB** orgs: cases, tasks, lab workflows.
- Named **patients** are scoped to `createdByOrganizationId`. `mobile` stays globally unique — other-org / walk-in hit `PATIENT_MOBILE_UNAVAILABLE` (no shared row).
- Tab access: `TAB_*_READ` / `TAB_*_EDIT` in `backend/src/common/permissions.ts`. EDIT implies READ. - Tab access: `TAB_*_READ` / `TAB_*_EDIT` in `backend/src/common/permissions.ts`. EDIT implies READ.
## Agent behavior ## Agent behavior
@@ -19,6 +20,7 @@ Monorepo: `backend/` (NestJS + Prisma), `frontend/` (Next.js + next-intl), `infr
- **Never commit or push** unless the user explicitly asks. - **Never commit or push** unless the user explicitly asks.
- Prefer minimal diffs; reuse existing components and API patterns. - Prefer minimal diffs; reuse existing components and API patterns.
- After cross-cutting changes: `backend` → `npm run build`; `frontend` → `npx tsc --noEmit`. - After cross-cutting changes: `backend` → `npm run build`; `frontend` → `npx tsc --noEmit`.
- Prisma `schema.prisma` changes ⇒ update AdminJS allowlist (`backend/src/admin/resources.ts`) — `.cursor/rules/adminjs.mdc`.
## i18n ## i18n
@@ -44,4 +46,4 @@ Logical failures: `AppException(ErrorCode.X)` → `errors.X` in en/fa/nl. UI: `g
## Notifications (inbox + live tabs) ## Notifications (inbox + live tabs)
Header bell: `UserNotification` + Socket.IO. Same `notification.created` also drives sidebar tab badges and soft list refresh on **currently open** Cases/Tasks/Treatment/Orgs pages. Skills: `.cursor/skills/notifications-inbox/SKILL.md`, `.cursor/skills/tab-badges/SKILL.md`. Header bell: `UserNotification` + Socket.IO. Same `notification.created` also drives sidebar tab badges and soft list refresh on **currently open** Cases/Tasks/Treatment/Orgs pages. Inbox + Tasks/Treatment badges use **`CASE_COMPLETED`** (every task in the case done); per-step `TASK_COMPLETED` is timeline-only. Skills: `.cursor/skills/notifications-inbox/SKILL.md`, `.cursor/skills/tab-badges/SKILL.md`.

View File

@@ -1,6 +1,6 @@
--- ---
description: Brand and FDI SVG sources live under frontend/src/assets, not public/ description: Brand and FDI SVG sources live under frontend/src/assets, not public/
globs: frontend/src/assets/**,frontend/src/components/ui/shared/Brand*.tsx,frontend/public/**,frontend/scripts/** globs: frontend/src/assets/**,frontend/src/components/ui/shared/Brand*.tsx,frontend/public/**,frontend/scripts/**,frontend/Dockerfile
alwaysApply: false alwaysApply: false
--- ---
@@ -11,3 +11,4 @@ alwaysApply: false
- **FDI tooth sources:** `frontend/src/assets/fdi/`. The chart uses inlined paths in `realisticToothAssets.ts`; regenerate with `frontend/scripts/extract-tooth-svgs.mjs`. - **FDI tooth sources:** `frontend/src/assets/fdi/`. The chart uses inlined paths in `realisticToothAssets.ts`; regenerate with `frontend/scripts/extract-tooth-svgs.mjs`.
- **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. - **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`. - **`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`.
- **Docker:** Next `output: 'standalone'` does **not** include `public/`. `frontend/Dockerfile` must `COPY` builder `/app/public` to `./public` next to `server.js` (after the standalone copy). Missing this 404s `/prosthesis-catalog/*.svg` in staging/prod.

View File

@@ -13,7 +13,7 @@ alwaysApply: false
- **Route:** `/lab-case/[token]` → `CaseTasksFocusView` (dashboard layout, auth required). - **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`. - **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. - **Task status on link page:** same assignee rule as Tasks — `canEditLabTaskStatus`; backend `PATCH /tasks/:id` enforces assignee.
- **Auth redirect:** `postAuthRedirect.ts`; dashboard stores path on logout redirect; login stores `?from=` **then** `useEnterAppWhenAuthenticated` consumes **once** after org ready — ❌ do not consume in `useAuth.login()` / `registerTrial`. Invites: `login()` then `navigateIntoAppIfOrgSelected` (no enter-app hook on invite pages). - **Auth redirect:** `postAuthRedirect.ts`; dashboard stores path on logout redirect; login stores `?from=` **then** `useEnterAppWhenAuthenticated` consumes **once** after org ready — ❌ do not consume in `useAuth.login()` / `registerTrial`. Invites (join + `password_setup`): `login()` then `navigateIntoAppIfOrgSelected` (no enter-app hook on invite pages).
- **Login page:** wrap `useSearchParams` in `<Suspense>` for `next build`. - **Login page:** wrap `useSearchParams` in `<Suspense>` for `next build`.
Skill: `.cursor/skills/lab-case-share-link/SKILL.md` Skill: `.cursor/skills/lab-case-share-link/SKILL.md`

View File

@@ -17,5 +17,6 @@ alwaysApply: false
- **Mobile UX:** `LAB_TASK_STATUS_SELECT_CLASS` (44px tap target on small screens); `TaskCaseGroupHeader` sticky while scrolling grouped tasks; filter selects use same touch sizing on Tasks. - **Mobile UX:** `LAB_TASK_STATUS_SELECT_CLASS` (44px tap target on small screens); `TaskCaseGroupHeader` sticky while scrolling grouped tasks; filter selects use same touch sizing on Tasks.
- **Show in case:** `GET /tasks/locate-page` finds page in full list; highlight + scroll. - **Show in case:** `GET /tasks/locate-page` finds page in full list; highlight + scroll.
- **Today deep links:** `parseTasksSearchParams` + `prosthesisTypeCode` / `unassignedOnly` / `overdueOnly` query params on Tasks. - **Today deep links:** `parseTasksSearchParams` + `prosthesisTypeCode` / `unassignedOnly` / `overdueOnly` query params on Tasks.
- **Inbox / tab badges:** completing a step writes timeline `TASK_COMPLETED` only; inbox + Tasks/Treatment badges fire on `CASE_COMPLETED` when no in-progress tasks remain.
Full map: `.cursor/skills/lab-tasks/SKILL.md` Full map: `.cursor/skills/lab-tasks/SKILL.md`

View File

@@ -0,0 +1,12 @@
---
description: Clinic patients — org-scoped named records, globally unique mobile
globs: backend/src/modules/patients/**,backend/src/modules/appointments/appointments.service.ts,backend/src/modules/treatments/treatments.service.ts,frontend/src/components/ui/patient/**,frontend/src/lib/api/patients.ts
alwaysApply: false
---
# Patients
- List / get / update / create: **this org** + `isWalkIn: false` (`createdByOrganizationId`).
- `Patient.mobile` stays **globally unique**. Same-org named create returns `{ existing: true }`. Other org, walk-in, or null creator → `PATIENT_MOBILE_UNAVAILABLE` (409). Do **not** return or mention the other clinics row.
- Appointment create/update and `POST /treatments` named `patientId`: `ensurePatientInOrg` (named + this org). Walk-in sentinel is per clinic (`walk-in-patient.ts`), hidden from Patients/search/booking.
- History / lab-case lists still query treatments for **this** `organizationId` even if the patient UUID is guessed.

View File

@@ -10,7 +10,7 @@ alwaysApply: false
- **Login + register:** `useEnterAppWhenAuthenticated` after org ready → `appPathAfterAuth()` (`consumeAuthRedirect()` once, else `/today`). - **Login + register:** `useEnterAppWhenAuthenticated` after org ready → `appPathAfterAuth()` (`consumeAuthRedirect()` once, else `/today`).
- **Login `?from=`:** `storeAuthRedirectFromPath` **before** that hook (effect order). - **Login `?from=`:** `storeAuthRedirectFromPath` **before** that hook (effect order).
- **Staff / org invite:** accept → `login(email, password)` → `navigateIntoAppIfOrgSelected`. ❌ Do not put the hook on invite pages (logged-in visitors must finish accept). - **Staff / org invite:** accept → `login(email, password)` → `navigateIntoAppIfOrgSelected`. Same for `/accept-invite` `mode: password_setup` (password fields only). ❌ Do not put the hook on invite pages (logged-in visitors must finish accept). Login does not special-case `passwordHash: null` — those users cannot sign in until they set a password via the setup link.
- **Forgot password:** navigates itself to `/settings/account?reset=1`. ❌ Do not add the enter-app hook there. - **Forgot password:** navigates itself to `/settings/account?reset=1`. ❌ Do not add the enter-app hook there.
- **Multi-org:** redirect stays in sessionStorage until `selectOrganization()` → `appPathAfterAuth()`. - **Multi-org:** redirect stays in sessionStorage until `selectOrganization()` → `appPathAfterAuth()`.
- ❌ Never `consumeAuthRedirect()` inside `useAuth.login()` or `registerTrial`. - ❌ Never `consumeAuthRedirect()` inside `useAuth.login()` or `registerTrial`.

13
.cursor/rules/staff.mdc Normal file
View File

@@ -0,0 +1,13 @@
---
description: Staff passwords — setup link only; never set another users password
globs: backend/src/modules/staff/**,frontend/src/components/ui/staff/**,frontend/src/app/**/accept-invite/**,frontend/src/lib/api/staff.ts
alwaysApply: false
---
# Staff passwords
- Owner / `TAB_STAFF_EDIT` may **clear** a password, never set one for someone else.
- `POST /staff/members/:membershipId/clear-password`: `passwordHash: null`, delete sessions, revoke unused invites on that membership, mint a 7-day `/accept-invite` URL. Refuse owner, self, pending, disabled (`STAFF_CANNOT_CLEAR_OWN_PASSWORD`, `STAFF_PASSWORD_CLEAR_ACTIVE_ONLY`).
- List DTO: `hasPassword` boolean only (never the hash). `previewInvite` `mode`: `join` | `password_setup` from `membership.isActive`.
- Login page unchanged — null hash is invalid credentials until they set a password on the setup link.
- `/accept-invite` `password_setup`: password fields only; then `login()` + `navigateIntoAppIfOrgSelected` (no enter-app hook).

View File

@@ -6,7 +6,7 @@ alwaysApply: false
# Tab badges (Cases / Tasks / Treatment) # Tab badges (Cases / Tasks / Treatment)
- **Split counts (Option B):** Lab Cases = sent + clinic comments + important; Lab Tasks = completions + lab comments + assignments (assignee-only); Clinic Treatment = visible lab comments + completions. - **Split counts (Option B):** Lab Cases = sent + clinic comments + important; Lab Tasks = case fully completed + lab comments + assignments (assignee-only); Clinic Treatment = visible lab comments + case fully completed. Per-step `TASK_COMPLETED` is timeline-only (not badges or inbox).
- **API:** `GET /notifications/tab-counts`; **Cases + Treatment** use per-case read + `hasUnread` on list cards; Tasks marks read on tab visit. Treatment rail uses `TreatmentLabCasesPanel` + `LabCaseTrackerCard` + activity feed. - **API:** `GET /notifications/tab-counts`; **Cases + Treatment** use per-case read + `hasUnread` on list cards; Tasks marks read on tab visit. Treatment rail uses `TreatmentLabCasesPanel` + `LabCaseTrackerCard` + activity feed.
- **Pattern:** `useTabBadgeCounts` + `notifyTabBadgesChanged()` — same shape as `usePendingConnectionsCount`. - **Pattern:** `useTabBadgeCounts` + `notifyTabBadgesChanged()` — same shape as `usePendingConnectionsCount`.
- **Live:** inbox Socket.IO `notification.created` → `notifyTabBadgesChanged()` (and org pending event when relevant). **Mounted** Cases/Tasks/Treatment/Orgs pages soft-refetch lists; unmounted tabs do not. Sidebar badge counts always refetch (hook is always mounted). - **Live:** inbox Socket.IO `notification.created` → `notifyTabBadgesChanged()` (and org pending event when relevant). **Mounted** Cases/Tasks/Treatment/Orgs pages soft-refetch lists; unmounted tabs do not. Sidebar badge counts always refetch (hook is always mounted).

View File

@@ -11,7 +11,7 @@ Follow this checklist. Adapt steps if the feature is read-only or org-type-speci
``` ```
- [ ] 1. Permissions & org type - [ ] 1. Permissions & org type
- [ ] 2. Backend module - [ ] 2. Backend module (+ Prisma / AdminJS if new models)
- [ ] 3. Frontend UI + thin page - [ ] 3. Frontend UI + thin page
- [ ] 4. i18n (en, fa, nl) - [ ] 4. i18n (en, fa, nl)
- [ ] 5. Verify build / tsc - [ ] 5. Verify build / tsc
@@ -40,6 +40,7 @@ backend/src/modules/{feature}/
- Service-level permission checks with `hasEffectivePermission`. - Service-level permission checks with `hasEffectivePermission`.
- DTOs use `ErrorCode` validation messages. - DTOs use `ErrorCode` validation messages.
- Register in `app.module.ts`. - Register in `app.module.ts`.
- If you add/change Prisma models: update AdminJS allowlist in `backend/src/admin/resources.ts` (same PR). See `.cursor/rules/adminjs.mdc`.
## 3. Frontend ## 3. Frontend

View File

@@ -56,7 +56,7 @@ Helpers: `lib/auth/postAuthRedirect.ts` (`sessionStorage` key `authRedirect`).
1. Logged-out user hits `/lab-case/{token}` → dashboard layout stores path + `router.replace('/login?from=…')`. 1. Logged-out user hits `/lab-case/{token}` → dashboard layout stores path + `router.replace('/login?from=…')`.
2. Login page `useSearchParams` (inside **Suspense**) calls `storeAuthRedirectFromPath(from)` **before** `useEnterAppWhenAuthenticated`. 2. Login page `useSearchParams` (inside **Suspense**) calls `storeAuthRedirectFromPath(from)` **before** `useEnterAppWhenAuthenticated`.
3. After login/register + org ready: **one** consume via `appPathAfterAuth()` in that hook. Staff/org invite: `login()` then `navigateIntoAppIfOrgSelected` (❌ no hook on invite pages). 3. After login/register + org ready: **one** consume via `appPathAfterAuth()` in that hook. Staff/org invite (join + `password_setup`): `login()` then `navigateIntoAppIfOrgSelected` (❌ no hook on invite pages).
4. **Do not** `consumeAuthRedirect()` inside `useAuth.login()` or `registerTrial` — double consume sends user to `/today`. 4. **Do not** `consumeAuthRedirect()` inside `useAuth.login()` or `registerTrial` — double consume sends user to `/today`.
5. Multi-org: redirect stays in storage until `selectOrganization()``appPathAfterAuth()`. 5. Multi-org: redirect stays in storage until `selectOrganization()``appPathAfterAuth()`.
6. Forgot-password navigates to account reset itself — do not add the enter-app hook there. 6. Forgot-password navigates to account reset itself — do not add the enter-app hook there.

View File

@@ -95,7 +95,7 @@ Keep changes minimal — match existing `sm:` breakpoint patterns elsewhere in t
## Tab badges + live soft refresh ## Tab badges + live soft refresh
See `.cursor/skills/tab-badges/SKILL.md` — split lab Cases/Tasks counts, clinic Treatment; `useTabBadgeCounts` + `notifyTabBadgesChanged`. See `.cursor/skills/tab-badges/SKILL.md` — split lab Cases/Tasks counts, clinic Treatment; `useTabBadgeCounts` + `notifyTabBadgesChanged`. Inbox and Tasks/Treatment badges fire on `CASE_COMPLETED` (all tasks done), not each workflow step. The case timeline still records `TASK_COMPLETED` per step.
Inbox Socket.IO `notification.created``notifyTabBadgesChanged()` → silent `loadTasks({ silent: true })` on an open Tasks page (filters preserved; no remount). Details: `.cursor/skills/notifications-inbox/SKILL.md`. Inbox Socket.IO `notification.created``notifyTabBadgesChanged()` → silent `loadTasks({ silent: true })` on an open Tasks page (filters preserved; no remount). Details: `.cursor/skills/notifications-inbox/SKILL.md`.

View File

@@ -43,7 +43,7 @@ Full tab-badge map: `.cursor/skills/tab-badges/SKILL.md`.
## Emit sites (parallel to LabCaseActivity) ## Emit sites (parallel to LabCaseActivity)
CASE_SENT, CLINIC_COMMENT, LAB_COMMENT (+ LAB_COMMENT_CLINIC), CASE_IMPORTANT, TASK_COMPLETED, TASK_ASSIGNED (assignee only), CONNECTION_REQUEST, STAFF_INVITE — see service call sites. CASE_SENT, CLINIC_COMMENT, LAB_COMMENT (+ LAB_COMMENT_CLINIC), CASE_IMPORTANT, CASE_COMPLETED (all tasks in the case done — not each step), TASK_ASSIGNED (assignee only), CONNECTION_REQUEST, STAFF_INVITE — see service call sites.
**Inbox card context** is denormalized inside `UserNotificationService.notify()` (`enrichInboxPayload`) from ids already on the payload (`labCaseId`, `taskId`, `fromOrganizationId`). Emit sites stay thin (`{ labCaseId }`, etc.). Inbox list/read does **not** join related tables. Older rows may lack these fields until new events are emitted. **Inbox card context** is denormalized inside `UserNotificationService.notify()` (`enrichInboxPayload`) from ids already on the payload (`labCaseId`, `taskId`, `fromOrganizationId`). Emit sites stay thin (`{ labCaseId }`, etc.). Inbox list/read does **not** join related tables. Older rows may lack these fields until new events are emitted.

View File

@@ -13,7 +13,7 @@ Frontend hook: [`frontend/src/lib/hooks/useTabBadgeCounts.ts`](frontend/src/lib/
## Models ## Models
- **`LabCaseActivity`** — append-only events: `CASE_SENT`, `CLINIC_COMMENT`, `LAB_COMMENT`, `CASE_IMPORTANT`, `CASE_AMENDED` (stub for Step 7), `TASK_COMPLETED`, `TASK_ASSIGNED` - **`LabCaseActivity`** — append-only events: `CASE_SENT`, `CLINIC_COMMENT`, `LAB_COMMENT`, `CASE_IMPORTANT`, `CASE_AMENDED` (stub for Step 7), `TASK_COMPLETED` (timeline only), `TASK_ASSIGNED`, `CASE_COMPLETED`
- **`LabCaseUserTabReadState`** — per user/org/tab cursor (`TASKS`) for sidebar badge clearing on tab visit. - **`LabCaseUserTabReadState`** — per user/org/tab cursor (`TASKS`) for sidebar badge clearing on tab visit.
- **`LabCaseUserReadState`** — per user/org/labCase cursor; drives Cases tab count and `hasUnread` on case list cards - **`LabCaseUserReadState`** — per user/org/labCase cursor; drives Cases tab count and `hasUnread` on case list cards
@@ -22,8 +22,8 @@ Frontend hook: [`frontend/src/lib/hooks/useTabBadgeCounts.ts`](frontend/src/lib/
| Org | Tab | Activity types | | Org | Tab | Activity types |
|-----|-----|----------------| |-----|-----|----------------|
| LAB | Cases | `CASE_SENT`, `CLINIC_COMMENT`, `CASE_IMPORTANT` | | LAB | Cases | `CASE_SENT`, `CLINIC_COMMENT`, `CASE_IMPORTANT` |
| LAB | Tasks | `TASK_COMPLETED`, `LAB_COMMENT`, `TASK_ASSIGNED` (assignee only) | | LAB | Tasks | `CASE_COMPLETED`, `LAB_COMMENT`, `TASK_ASSIGNED` (assignee only) |
| CLINIC | Treatment | `LAB_COMMENT` (only `visibleToClinic`), `TASK_COMPLETED`**only lab cases for treatments the user provided** | | CLINIC | Treatment | `LAB_COMMENT` (only `visibleToClinic`), `CASE_COMPLETED`**only lab cases for treatments the user provided** |
Counts exclude events where `actorUserId === current user`. Clinic `LAB_COMMENT` counts only when `payload.visibleToClinic === true`. Counts exclude events where `actorUserId === current user`. Clinic `LAB_COMMENT` counts only when `payload.visibleToClinic === true`.
@@ -43,7 +43,8 @@ Counts exclude events where `actorUserId === current user`. Clinic `LAB_COMMENT`
| First send | `treatments.service` `sendLabCase``CASE_SENT` | | First send | `treatments.service` `sendLabCase``CASE_SENT` |
| Comment | `lab-case-comments.service``CLINIC_COMMENT` / `LAB_COMMENT` | | Comment | `lab-case-comments.service``CLINIC_COMMENT` / `LAB_COMMENT` |
| Mark important | `cases.service` `updateImportant` (only when set true) → `CASE_IMPORTANT` | | Mark important | `cases.service` `updateImportant` (only when set true) → `CASE_IMPORTANT` |
| Task completed | `tasks.service` `updateStatus``TASK_COMPLETED` | | Task completed (step) | `tasks.service` `updateStatus``TASK_COMPLETED` **activity only** (case timeline; not inbox or tab badges) |
| Case fully completed | `tasks.service` `updateStatus` when no in-progress tasks remain → `CASE_COMPLETED` activity + inbox |
| Task assigned | `cases.service` `assignTask``TASK_ASSIGNED` (inbox + Tasks badge for **assignee**, including self-assign) | | Task assigned | `cases.service` `assignTask``TASK_ASSIGNED` (inbox + Tasks badge for **assignee**, including self-assign) |
After mutations, frontend calls `notifyTabBadgesChanged()` (window event). After mutations, frontend calls `notifyTabBadgesChanged()` (window event).

View File

@@ -43,7 +43,7 @@ Right-column entry is **not** a three-step wizard. Type dropdown + `TreatmentDet
| **Treatment** | Type dropdown + `TreatmentDetailAttachmentsStrip`, `FdiToothChart` / `ProsthesisAssignChart`, full-width Notes | Default | | **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. | | **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 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. - 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`. Production Docker must copy `public/` into the standalone image (see `.cursor/rules/frontend-assets.mdc`). 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 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. - 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.
- Lab shipments rail / “Go to dispatch” / load-with-focus **opens the dispatch view** in the chart slot (`pendingScrollToLabRef` + `labPanelRef`). - Lab shipments rail / “Go to dispatch” / load-with-focus **opens the dispatch view** in the chart slot (`pendingScrollToLabRef` + `labPanelRef`).
@@ -188,7 +188,7 @@ Use shared `Checkbox` (not native `<input type="checkbox">`) to avoid focus-driv
| `GET /treatments/day?from&to` | Standalone (unscheduled) strip cards | | `GET /treatments/day?from&to` | Standalone (unscheduled) strip cards |
| `POST /treatments` | Create standalone `{ patientId?, walkIn?, treatmentAt }` | | `POST /treatments` | Create standalone `{ patientId?, walkIn?, treatmentAt }`. Named `patientId` must be this org (`ensurePatientInOrg`). |
| `DELETE /treatments/:id` | Empty standalone only (`appointmentId` null, no detail rows). UI may `PUT` `{ details: [] }` first when the strip looks blank but autosave has not finished. | | `DELETE /treatments/:id` | Empty standalone only (`appointmentId` null, no detail rows). UI may `PUT` `{ details: [] }` first when the strip looks blank but autosave has not finished. |
@@ -202,7 +202,7 @@ Use shared `Checkbox` (not native `<input type="checkbox">`) to avoid focus-driv
Walk-in uses one sentinel `Patient` per clinic (`isWalkIn`, hidden from Patients/search/booking). Display via i18n, never the stored name. Patient search: same workspace patient with a live visit → no-op; else open todays strip visit if any; else load latest history into the editor; **no history and no strip visit → do not auto-create**. Detach the previous visit, keep the searched patient, and show an inline editor empty state (`noTreatmentFoundTitle` / `noTreatmentFoundBody`) that points to **New treatment** in the rail (Walk-in, current named patient card, or search). Walk-in uses one sentinel `Patient` per clinic (`isWalkIn`, hidden from Patients/search/booking). Display via i18n, never the stored name. Named patients are this-org only (`createdByOrganizationId`); another clinics mobile is `PATIENT_MOBILE_UNAVAILABLE`, not a shared row. Patient search: same workspace patient with a live visit → no-op; else open todays strip visit if any; else load latest history into the editor; **no history and no strip visit → do not auto-create**. Detach the previous visit, keep the searched patient, and show an inline editor empty state (`noTreatmentFoundTitle` / `noTreatmentFoundBody`) that points to **New treatment** in the rail (Walk-in, current named patient card, or search).
Draft writes for appointments require provider match (`ensureAppointmentProvider`). Standalone requires `treatment.providerUserId === actor`. Draft writes for appointments require provider match (`ensureAppointmentProvider`). Standalone requires `treatment.providerUserId === actor`.

View File

@@ -44,8 +44,6 @@ defaults:
jobs: jobs:
build-and-push: build-and-push:
runs-on: windows runs-on: windows
outputs:
image_tag: ${{ steps.meta.outputs.image_tag }}
steps: steps:
- name: Checkout (this Gitea) - name: Checkout (this Gitea)
run: | run: |
@@ -75,6 +73,7 @@ jobs:
} }
$env:GIT_TERMINAL_PROMPT = '0' $env:GIT_TERMINAL_PROMPT = '0'
git clone --depth 1 --branch $Branch $cloneUrl . git clone --depth 1 --branch $Branch $cloneUrl .
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Resolve image tag (v*.*.* only) - name: Resolve image tag (v*.*.* only)
id: meta id: meta
@@ -90,10 +89,7 @@ jobs:
Write-Host "Production images must be tagged vMAJOR.MINOR.PATCH (got: $tag)" Write-Host "Production images must be tagged vMAJOR.MINOR.PATCH (got: $tag)"
exit 1 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 }}" $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" Write-Host "image_tag=$tag REGISTRY_PREFIX=$prefix"
- name: Log in to container registry - name: Log in to container registry
@@ -103,13 +99,43 @@ jobs:
${{ secrets.REGISTRY_PASSWORD }} ${{ secrets.REGISTRY_PASSWORD }}
'@ '@
$pass.Trim() | docker login "${{ vars.REGISTRY_HOST }}" -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin $pass.Trim() | docker login "${{ vars.REGISTRY_HOST }}" -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Resolve node:20-alpine (Gitea, then mirrors, Hub last)
run: |
$ErrorActionPreference = 'Stop'
$prefix = "${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_OWNER }}".Trim()
$extra = '${{ vars.NODE_IMAGE_SOURCE }}'.Trim()
if ($extra -like '*NODE_IMAGE_SOURCE*') { $extra = '' }
# Do not nest powershell -File: empty -ExtraSources "$extra" is dropped and PS5.1 errors MissingArgument.
$scriptArgs = @{ RegistryPrefix = $prefix }
if (-not [string]::IsNullOrWhiteSpace($extra)) { $scriptArgs['ExtraSources'] = $extra }
& .\infrastructure\scripts\ci-resolve-node-image.ps1 @scriptArgs
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Build and push backend (tag only, not :latest) - name: Build and push backend (tag only, not :latest)
run: | run: |
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$tag = "${{ steps.meta.outputs.image_tag }}" $dispatchTag = '${{ github.event.inputs.tag }}'.Trim()
docker build -t "$env:REGISTRY_PREFIX/dyolink-backend:$tag" ./backend if (-not [string]::IsNullOrWhiteSpace($dispatchTag)) { $tag = $dispatchTag } else { $tag = "${{ github.ref_name }}" }
docker push "$env:REGISTRY_PREFIX/dyolink-backend:$tag" $prefix = "${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_OWNER }}".Trim()
$nodeImage = ([System.IO.File]::ReadAllText((Join-Path (Get-Location) '.ci-node-image'))).Trim()
if ([string]::IsNullOrWhiteSpace($nodeImage)) {
Write-Host "Missing .ci-node-image"
exit 1
}
if (Test-Path '.ci-use-legacy-builder') { $env:DOCKER_BUILDKIT = '0' }
Write-Host "Building $prefix/dyolink-backend:$tag (NODE_IMAGE=$nodeImage)"
$ok = $false
for ($i = 1; $i -le 3; $i++) {
Write-Host "docker build attempt $i/3"
docker build --build-arg "NODE_IMAGE=$nodeImage" -t "$prefix/dyolink-backend:$tag" ./backend
if ($LASTEXITCODE -eq 0) { $ok = $true; break }
if ($i -lt 3) { Start-Sleep -Seconds (20 * $i) }
}
if (-not $ok) { exit 1 }
docker push "$prefix/dyolink-backend:$tag"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Build and push frontend (nudentic.ir baked in) - name: Build and push frontend (nudentic.ir baked in)
env: env:
@@ -117,19 +143,37 @@ jobs:
NEXT_PUBLIC_SENTRY_DSN: ${{ vars.NEXT_PUBLIC_SENTRY_DSN }} NEXT_PUBLIC_SENTRY_DSN: ${{ vars.NEXT_PUBLIC_SENTRY_DSN }}
run: | run: |
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$tag = "${{ steps.meta.outputs.image_tag }}" $dispatchTag = '${{ github.event.inputs.tag }}'.Trim()
if (-not [string]::IsNullOrWhiteSpace($dispatchTag)) { $tag = $dispatchTag } else { $tag = "${{ github.ref_name }}" }
$prefix = "${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_OWNER }}".Trim()
$nodeImage = ([System.IO.File]::ReadAllText((Join-Path (Get-Location) '.ci-node-image'))).Trim()
if ([string]::IsNullOrWhiteSpace($nodeImage)) {
Write-Host "Missing .ci-node-image"
exit 1
}
if (Test-Path '.ci-use-legacy-builder') { $env:DOCKER_BUILDKIT = '0' }
$base = $env:PROD_PUBLIC_BASE_URL.Trim() $base = $env:PROD_PUBLIC_BASE_URL.Trim()
if ([string]::IsNullOrWhiteSpace($base)) { $base = 'https://nudentic.ir' } if ([string]::IsNullOrWhiteSpace($base)) { $base = 'https://nudentic.ir' }
$base = $base.TrimEnd('/') $base = $base.TrimEnd('/')
docker build ` Write-Host "Building $prefix/dyolink-frontend:$tag (NODE_IMAGE=$nodeImage)"
--build-arg "NEXT_PUBLIC_API_URL=$base/api" ` $ok = $false
--build-arg "NEXT_PUBLIC_APP_URL=$base" ` for ($i = 1; $i -le 3; $i++) {
--build-arg "NEXT_PUBLIC_APP_NAME=Dyolink" ` Write-Host "docker build attempt $i/3"
--build-arg "NEXT_PUBLIC_SENTRY_DSN=$env:NEXT_PUBLIC_SENTRY_DSN" ` docker build `
--build-arg "NEXT_PUBLIC_SENTRY_ENVIRONMENT=production" ` --build-arg "NEXT_PUBLIC_API_URL=$base/api" `
-t "$env:REGISTRY_PREFIX/dyolink-frontend:$tag" ` --build-arg "NEXT_PUBLIC_APP_URL=$base" `
./frontend --build-arg "NEXT_PUBLIC_APP_NAME=Dyolink" `
docker push "$env:REGISTRY_PREFIX/dyolink-frontend:$tag" --build-arg "NEXT_PUBLIC_SENTRY_DSN=$env:NEXT_PUBLIC_SENTRY_DSN" `
--build-arg "NEXT_PUBLIC_SENTRY_ENVIRONMENT=production" `
--build-arg "NODE_IMAGE=$nodeImage" `
-t "$prefix/dyolink-frontend:$tag" `
./frontend
if ($LASTEXITCODE -eq 0) { $ok = $true; break }
if ($i -lt 3) { Start-Sleep -Seconds (20 * $i) }
}
if (-not $ok) { exit 1 }
docker push "$prefix/dyolink-frontend:$tag"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
deploy: deploy:
needs: build-and-push needs: build-and-push
@@ -163,6 +207,7 @@ jobs:
} }
$env:GIT_TERMINAL_PROMPT = '0' $env:GIT_TERMINAL_PROMPT = '0'
git clone --depth 1 --branch $Branch $cloneUrl . git clone --depth 1 --branch $Branch $cloneUrl .
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Write SSH key - name: Write SSH key
run: | run: |
@@ -193,19 +238,25 @@ jobs:
if ([string]::IsNullOrWhiteSpace($infra)) { $infra = '/opt/dyolink/infrastructure' } if ([string]::IsNullOrWhiteSpace($infra)) { $infra = '/opt/dyolink/infrastructure' }
$ssh = @('-i', $env:PROD_SSH_KEY_PATH, '-o', 'StrictHostKeyChecking=accept-new') $ssh = @('-i', $env:PROD_SSH_KEY_PATH, '-o', 'StrictHostKeyChecking=accept-new')
ssh.exe @ssh -p $port "${user}@${hostName}" "mkdir -p $infra/scripts $infra/nginx" ssh.exe @ssh -p $port "${user}@${hostName}" "mkdir -p $infra/scripts $infra/nginx"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
scp.exe @ssh -P $port ` scp.exe @ssh -P $port `
infrastructure/docker-compose.prod.yml ` infrastructure/docker-compose.prod.yml `
"${user}@${hostName}:${infra}/docker-compose.prod.yml" "${user}@${hostName}:${infra}/docker-compose.prod.yml"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
scp.exe @ssh -P $port ` scp.exe @ssh -P $port `
infrastructure/scripts/prod-remote-deploy.sh ` infrastructure/scripts/prod-remote-deploy.sh `
"${user}@${hostName}:${infra}/scripts/prod-remote-deploy.sh" "${user}@${hostName}:${infra}/scripts/prod-remote-deploy.sh"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
scp.exe @ssh -P $port ` scp.exe @ssh -P $port `
infrastructure/scripts/render-nginx-ssl.sh ` infrastructure/scripts/render-nginx-ssl.sh `
"${user}@${hostName}:${infra}/scripts/render-nginx-ssl.sh" "${user}@${hostName}:${infra}/scripts/render-nginx-ssl.sh"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
scp.exe @ssh -P $port ` scp.exe @ssh -P $port `
infrastructure/nginx/nginx.ssl.conf.template ` infrastructure/nginx/nginx.ssl.conf.template `
"${user}@${hostName}:${infra}/nginx/nginx.ssl.conf.template" "${user}@${hostName}:${infra}/nginx/nginx.ssl.conf.template"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
ssh.exe @ssh -p $port "${user}@${hostName}" "chmod +x $infra/scripts/prod-remote-deploy.sh $infra/scripts/render-nginx-ssl.sh" ssh.exe @ssh -p $port "${user}@${hostName}" "chmod +x $infra/scripts/prod-remote-deploy.sh $infra/scripts/render-nginx-ssl.sh"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Login on Linux and deploy tag - name: Login on Linux and deploy tag
run: | run: |
@@ -218,7 +269,8 @@ jobs:
if ([string]::IsNullOrWhiteSpace($infra)) { $infra = '/opt/dyolink/infrastructure' } if ([string]::IsNullOrWhiteSpace($infra)) { $infra = '/opt/dyolink/infrastructure' }
$regHost = '${{ vars.PROD_REGISTRY_HOST }}'.Trim() $regHost = '${{ vars.PROD_REGISTRY_HOST }}'.Trim()
if ([string]::IsNullOrWhiteSpace($regHost)) { $regHost = 'wixur.ir:3000' } if ([string]::IsNullOrWhiteSpace($regHost)) { $regHost = 'wixur.ir:3000' }
$tag = "${{ needs.build-and-push.outputs.image_tag }}" $dispatchTag = '${{ github.event.inputs.tag }}'.Trim()
if (-not [string]::IsNullOrWhiteSpace($dispatchTag)) { $tag = $dispatchTag } else { $tag = "${{ github.ref_name }}" }
$pass = @' $pass = @'
${{ secrets.REGISTRY_PASSWORD }} ${{ secrets.REGISTRY_PASSWORD }}
'@ '@
@@ -226,3 +278,4 @@ jobs:
$ssh = @('-i', $env:PROD_SSH_KEY_PATH, '-o', 'StrictHostKeyChecking=accept-new') $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" $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 $pass.Trim() | ssh.exe @ssh -p $port "${user}@${hostName}" $remote
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }

View File

@@ -17,6 +17,8 @@
# STAGING_HTTP_PORT public HTTP port (default 80) — Windows portproxy listens here → 18088 # 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 # 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 # CLONE_HOST git clone host when runner = Gitea host → 127.0.0.1:3000
# NODE_IMAGE_SOURCE extra base-image ref(s), comma-separated, tried before built-in mirrors
# e.g. docker.arvancloud.ir/library/node:20-alpine
# #
# Same Windows PC runs Gitea + runner + deploy: # Same Windows PC runs Gitea + runner + deploy:
# CLONE_HOST → 127.0.0.1:3000 (git runs on Windows host) # CLONE_HOST → 127.0.0.1:3000 (git runs on Windows host)
@@ -33,6 +35,7 @@
# Docker on runner: insecure-registries e.g. ["host.docker.internal:3000","wixur.ir:3000"] # Docker on runner: insecure-registries e.g. ["host.docker.internal:3000","wixur.ir:3000"]
# #
# Runner: self-hosted with Docker + git. Default shell is powershell (Windows act_runner). # Runner: self-hosted with Docker + git. Default shell is powershell (Windows act_runner).
# Windows PowerShell 5.1 does not fail a step when docker/git return non-zero — always check $LASTEXITCODE.
name: Registry — build, push, deploy name: Registry — build, push, deploy
@@ -48,8 +51,6 @@ defaults:
jobs: jobs:
build-and-push: build-and-push:
runs-on: windows runs-on: windows
outputs:
image_tag: ${{ steps.meta.outputs.image_tag }}
steps: steps:
- name: Checkout (clone from this Gitea — no gitea.com) - name: Checkout (clone from this Gitea — no gitea.com)
run: | run: |
@@ -74,16 +75,7 @@ jobs:
} }
$env:GIT_TERMINAL_PROMPT = '0' $env:GIT_TERMINAL_PROMPT = '0'
git clone --depth 1 --branch $Branch $cloneUrl . git clone --depth 1 --branch $Branch $cloneUrl .
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- 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 - name: Log in to container registry
run: | run: |
@@ -92,17 +84,52 @@ jobs:
${{ secrets.REGISTRY_PASSWORD }} ${{ secrets.REGISTRY_PASSWORD }}
'@ '@
$pass.Trim() | docker login "${{ vars.REGISTRY_HOST }}" -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin $pass.Trim() | docker login "${{ vars.REGISTRY_HOST }}" -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Resolve node:20-alpine (Gitea, then mirrors, Hub last)
run: |
$ErrorActionPreference = 'Stop'
$prefix = "${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_OWNER }}".Trim()
$extra = '${{ vars.NODE_IMAGE_SOURCE }}'.Trim()
if ($extra -like '*NODE_IMAGE_SOURCE*') { $extra = '' }
# Do not nest powershell -File: empty -ExtraSources "$extra" is dropped and PS5.1 errors MissingArgument.
$scriptArgs = @{ RegistryPrefix = $prefix }
if (-not [string]::IsNullOrWhiteSpace($extra)) { $scriptArgs['ExtraSources'] = $extra }
& .\infrastructure\scripts\ci-resolve-node-image.ps1 @scriptArgs
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Build and push backend - name: Build and push backend
run: | run: |
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$tag = "${{ steps.meta.outputs.image_tag }}" $tag = "${{ github.sha }}".Substring(0, 7)
docker build ` $prefix = "${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_OWNER }}".Trim()
-t "$env:REGISTRY_PREFIX/dyolink-backend:$tag" ` if ([string]::IsNullOrWhiteSpace($tag) -or [string]::IsNullOrWhiteSpace($prefix)) {
-t "$env:REGISTRY_PREFIX/dyolink-backend:latest" ` Write-Host "Missing github.sha, REGISTRY_HOST, or REGISTRY_OWNER"
./backend exit 1
docker push "$env:REGISTRY_PREFIX/dyolink-backend:$tag" }
docker push "$env:REGISTRY_PREFIX/dyolink-backend:latest" $nodeImage = ([System.IO.File]::ReadAllText((Join-Path (Get-Location) '.ci-node-image'))).Trim()
if ([string]::IsNullOrWhiteSpace($nodeImage)) {
Write-Host "Missing .ci-node-image"
exit 1
}
if (Test-Path '.ci-use-legacy-builder') { $env:DOCKER_BUILDKIT = '0' }
Write-Host "Building $prefix/dyolink-backend:$tag (NODE_IMAGE=$nodeImage)"
$ok = $false
for ($i = 1; $i -le 3; $i++) {
Write-Host "docker build attempt $i/3"
docker build `
--build-arg "NODE_IMAGE=$nodeImage" `
-t "$prefix/dyolink-backend:$tag" `
-t "$prefix/dyolink-backend:latest" `
./backend
if ($LASTEXITCODE -eq 0) { $ok = $true; break }
if ($i -lt 3) { Start-Sleep -Seconds (20 * $i) }
}
if (-not $ok) { exit 1 }
docker push "$prefix/dyolink-backend:$tag"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
docker push "$prefix/dyolink-backend:latest"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Build and push frontend - name: Build and push frontend
env: env:
@@ -110,19 +137,37 @@ jobs:
NEXT_PUBLIC_SENTRY_DSN: ${{ vars.NEXT_PUBLIC_SENTRY_DSN }} NEXT_PUBLIC_SENTRY_DSN: ${{ vars.NEXT_PUBLIC_SENTRY_DSN }}
run: | run: |
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$tag = "${{ steps.meta.outputs.image_tag }}" $tag = "${{ github.sha }}".Substring(0, 7)
$prefix = "${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_OWNER }}".Trim()
$base = $env:PUBLIC_BASE_URL $base = $env:PUBLIC_BASE_URL
docker build ` $nodeImage = ([System.IO.File]::ReadAllText((Join-Path (Get-Location) '.ci-node-image'))).Trim()
--build-arg "NEXT_PUBLIC_API_URL=$base/api" ` if ([string]::IsNullOrWhiteSpace($nodeImage)) {
--build-arg "NEXT_PUBLIC_APP_URL=$base" ` Write-Host "Missing .ci-node-image"
--build-arg "NEXT_PUBLIC_APP_NAME=Dyolink" ` exit 1
--build-arg "NEXT_PUBLIC_SENTRY_DSN=$env:NEXT_PUBLIC_SENTRY_DSN" ` }
--build-arg "NEXT_PUBLIC_SENTRY_ENVIRONMENT=staging" ` if (Test-Path '.ci-use-legacy-builder') { $env:DOCKER_BUILDKIT = '0' }
-t "$env:REGISTRY_PREFIX/dyolink-frontend:$tag" ` Write-Host "Building $prefix/dyolink-frontend:$tag (NODE_IMAGE=$nodeImage)"
-t "$env:REGISTRY_PREFIX/dyolink-frontend:latest" ` $ok = $false
./frontend for ($i = 1; $i -le 3; $i++) {
docker push "$env:REGISTRY_PREFIX/dyolink-frontend:$tag" Write-Host "docker build attempt $i/3"
docker push "$env:REGISTRY_PREFIX/dyolink-frontend:latest" 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" `
--build-arg "NODE_IMAGE=$nodeImage" `
-t "$prefix/dyolink-frontend:$tag" `
-t "$prefix/dyolink-frontend:latest" `
./frontend
if ($LASTEXITCODE -eq 0) { $ok = $true; break }
if ($i -lt 3) { Start-Sleep -Seconds (20 * $i) }
}
if (-not $ok) { exit 1 }
docker push "$prefix/dyolink-frontend:$tag"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
docker push "$prefix/dyolink-frontend:latest"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
deploy: deploy:
needs: build-and-push needs: build-and-push
@@ -151,6 +196,7 @@ jobs:
} }
$env:GIT_TERMINAL_PROMPT = '0' $env:GIT_TERMINAL_PROMPT = '0'
git clone --depth 1 --branch $Branch $cloneUrl . git clone --depth 1 --branch $Branch $cloneUrl .
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Write deploy.registry.env and validate secrets path - name: Write deploy.registry.env and validate secrets path
run: | run: |
@@ -173,16 +219,19 @@ jobs:
if ([string]::IsNullOrWhiteSpace($stagingPort)) { $stagingPort = '80' } if ([string]::IsNullOrWhiteSpace($stagingPort)) { $stagingPort = '80' }
$localPort = '${{ vars.STAGING_LOCAL_PORT }}'.Trim() $localPort = '${{ vars.STAGING_LOCAL_PORT }}'.Trim()
if ([string]::IsNullOrWhiteSpace($localPort)) { $localPort = '18088' } if ([string]::IsNullOrWhiteSpace($localPort)) { $localPort = '18088' }
$imageTag = "${{ needs.build-and-push.outputs.image_tag }}" $imageTag = "${{ github.sha }}".Substring(0, 7)
$prefix = "${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_OWNER }}".Trim()
Write-Host "IMAGE_TAG=$imageTag REGISTRY_PREFIX=$prefix"
$lines = @( $lines = @(
"REGISTRY_PREFIX=${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_OWNER }}", "REGISTRY_PREFIX=$prefix",
"IMAGE_TAG=$imageTag", "IMAGE_TAG=$imageTag",
"STAGING_HTTP_PORT=$stagingPort", "STAGING_HTTP_PORT=$stagingPort",
"STAGING_LOCAL_PORT=$localPort", "STAGING_LOCAL_PORT=$localPort",
"DEPLOY_SECRETS_DIR=$SD" "DEPLOY_SECRETS_DIR=$SD"
) )
Set-Location infrastructure Set-Location infrastructure
$lines | Set-Content -Path deploy.registry.env -Encoding utf8 $utf8 = New-Object System.Text.UTF8Encoding $false
[System.IO.File]::WriteAllText((Join-Path (Get-Location) 'deploy.registry.env'), ($lines -join "`n") + "`n", $utf8)
- name: Log in to container registry (for pull) - name: Log in to container registry (for pull)
run: | run: |
@@ -191,10 +240,13 @@ jobs:
${{ secrets.REGISTRY_PASSWORD }} ${{ secrets.REGISTRY_PASSWORD }}
'@ '@
$pass.Trim() | docker login "${{ vars.REGISTRY_HOST }}" -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin $pass.Trim() | docker login "${{ vars.REGISTRY_HOST }}" -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Pull and start stack - name: Pull and start stack
run: | run: |
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
Set-Location infrastructure 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 pull backend frontend
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
docker compose -f docker-compose.registry.yml --env-file deploy.registry.env up -d docker compose -f docker-compose.registry.yml --env-file deploy.registry.env up -d
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }

View File

@@ -58,13 +58,17 @@ frontend/src/
- 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. - 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. - **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 **opens the lab dispatch view** (same slot as the chart). - **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). - **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). Completions count only when **every task in the case is done** (`CASE_COMPLETED`) — not each workflow step.
- **Live lab rail**: `notification.created``notifyTabBadgesChanged()` silently refreshes patient lab cases + unread rail (does **not** clear draft/form state). - **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`. - **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`. **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). Patient must belong to this org (`ensurePatientInOrg`). 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; 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`. **Patients (quick ref):** List/get/update/create are this-org named patients (`createdByOrganizationId`, `isWalkIn: false`). Mobile stays globally unique. Same-org mobile create returns `existing: true`; other org / walk-in / null creator → `PATIENT_MOBILE_UNAVAILABLE` (409, no leak). See `.cursor/rules/patients.mdc`.
**Staff (quick ref):** Owner / `TAB_STAFF_EDIT` can **remove** a password (`POST /staff/members/:id/clear-password`) and copy a setup link — never set one for someone else. Login page unchanged (`passwordHash: null` cannot sign in). `/accept-invite` `password_setup` is password-only. See `.cursor/rules/staff.mdc`.
**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 / inbox:** Tasks + Treatment badges and the header bell increment on **`CASE_COMPLETED`** (all tasks in the case done), not each step — the case timeline still lists `TASK_COMPLETED` per step; 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. 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 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`.
@@ -72,7 +76,7 @@ frontend/src/
- Token on first ship → `/{locale}/lab-case/{token}` after login. - Token on first ship → `/{locale}/lab-case/{token}` after login.
- **Lab:** view/edit tasks (assignee rules), comments + visibility toggle. - **Lab:** view/edit tasks (assignee rules), comments + visibility toggle.
- **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). - **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`. - 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 (including `password_setup`) 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`. **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`.
@@ -82,11 +86,14 @@ frontend/src/
backend/src/ backend/src/
modules/{feature}/ → controller, service, dto, module modules/{feature}/ → controller, service, dto, module
common/ → guards, permissions, errors, utils common/ → guards, permissions, errors, utils
admin/ → AdminJS `/admin` panel (curated Prisma resources)
prisma/ → schema, migrations, seed prisma/ → schema, migrations, seed
``` ```
Errors: `AppException` + `ErrorCode` → frontend `getUserFacingError()`. Unexpected 500s: GlitchTip (`SENTRY_DSN`). 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.
**AdminJS:** Manual resource allowlist in `backend/src/admin/resources.ts`. **Whenever `schema.prisma` changes**, update AdminJS resources in the same task (new/renamed/removed models, hide secrets). Models with only composite `@@id` must not be registered (list 500). Rule: `.cursor/rules/adminjs.mdc`.
## Git & commits ## Git & commits
- **Do not commit or push** unless the user explicitly asks. - **Do not commit or push** unless the user explicitly asks.

View File

@@ -96,6 +96,7 @@ Clinics may only dispatch to labs they are linked to: `OrganizationLink` (A↔B,
### Layout conventions worth knowing before you create a file ### Layout conventions worth knowing before you create a file
- **Prisma lives outside `src/`**: `backend/prisma/` holds `schema.prisma`, migrations, seeds *and* `prisma.module.ts` / `prisma.service.ts` — hence imports like `../../../prisma/prisma.service`. Register new Nest modules in `app.module.ts`. - **Prisma lives outside `src/`**: `backend/prisma/` holds `schema.prisma`, migrations, seeds *and* `prisma.module.ts` / `prisma.service.ts` — hence imports like `../../../prisma/prisma.service`. Register new Nest modules in `app.module.ts`.
- **AdminJS (`/admin`)** resources are a manual allowlist in `backend/src/admin/resources.ts` — update them whenever `schema.prisma` changes (see `.cursor/rules/adminjs.mdc`).
- **Frontend layering** (`.cursor/rules/frontend-components.mdc`): `app/**/page.tsx` is a thin wrapper only → route logic in `components/ui/{feature}/{Feature}Page.tsx` → JSX in `components/ui/**` → pure helpers in `components/{feature}/` or `components/shared/`. No JSX outside `ui/`, no pure helpers inside it. - **Frontend layering** (`.cursor/rules/frontend-components.mdc`): `app/**/page.tsx` is a thin wrapper only → route logic in `components/ui/{feature}/{Feature}Page.tsx` → JSX in `components/ui/**` → pure helpers in `components/{feature}/` or `components/shared/`. No JSX outside `ui/`, no pure helpers inside it.
- **i18n is mandatory, not a follow-up**: every user-visible string goes into `en.json`, `fa.json`, **and** `nl.json`. `fa` is RTL, so use logical `text-start`/`text-end`, never `text-left`/`text-right`. Dates/times/numbers go through `lib/i18n/format.ts`; form dates use `AppDateInput`, never a native date input. - **i18n is mandatory, not a follow-up**: every user-visible string goes into `en.json`, `fa.json`, **and** `nl.json`. `fa` is RTL, so use logical `text-start`/`text-end`, never `text-left`/`text-right`. Dates/times/numbers go through `lib/i18n/format.ts`; form dates use `AppDateInput`, never a native date input.
- Treatment attachments are written to disk at `backend/uploads/treatments` relative to `process.cwd()`. - Treatment attachments are written to disk at `backend/uploads/treatments` relative to `process.cwd()`.
@@ -106,4 +107,4 @@ Jest covers pure logic only — permission normalization, phone/timezone helpers
## Deployment ## Deployment
Images are built on a dev machine and pulled by the server; Compose files and scripts are in `infrastructure/` (`docker-compose.{prod,staging,registry}.yml`). Full guide: `infrastructure/DEPLOY.md`. Root `README.md` covers the Docker Hub + Let's Encrypt path and the Gitea registry path. Frontend `NEXT_PUBLIC_*` are **build args** — changing the public domain requires rebuilding the frontend image. Images are built on a dev machine and pulled by the server; Compose files and scripts are in `infrastructure/` (`docker-compose.{prod,staging,registry}.yml`). Full guide: `infrastructure/DEPLOY.md`. Root `README.md` covers the Docker Hub + Let's Encrypt path and the Gitea registry path. Frontend `NEXT_PUBLIC_*` are **build args** — changing the public domain requires rebuilding the frontend image. Next `output: 'standalone'` does **not** include `public/`; `frontend/Dockerfile` copies `/app/public` next to `server.js` (catalog icons at `/prosthesis-catalog/*.svg`). Production tags are immutable — CI clones `--branch $tag`; cut a new `v*` instead of moving an existing tag.

View File

@@ -33,7 +33,7 @@ Workflow: [`.gitea/workflows/registry-build-deploy.yml`](.gitea/workflows/regist
| Path | Role | | Path | Role |
|------|------| |------|------|
| `backend/Dockerfile` | API image | | `backend/Dockerfile` | API image |
| `frontend/Dockerfile` | Web image | | `frontend/Dockerfile` | Web image (`standalone` + copy `public/` for catalog icons) |
| `infrastructure/STAGING-DEPLOY.md` | Staging setup, CI variables, testing | | `infrastructure/STAGING-DEPLOY.md` | Staging setup, CI variables, testing |
| `infrastructure/docker-compose.registry.yml` | Pull-only staging stack (registry images + nginx + postgres) | | `infrastructure/docker-compose.registry.yml` | Pull-only staging stack (registry images + nginx + postgres) |
| `infrastructure/deploy.registry.env.example` | Template for `deploy.registry.env` | | `infrastructure/deploy.registry.env.example` | Template for `deploy.registry.env` |

View File

@@ -1,7 +1,10 @@
# CI can pass a Gitea-hosted mirror when Docker Hub TLS fails (see ci-resolve-node-image.ps1).
ARG NODE_IMAGE=node:20-alpine
# ============================================ # ============================================
# STAGE 1: BUILDER STAGE # STAGE 1: BUILDER STAGE
# ============================================ # ============================================
FROM node:20-alpine AS builder FROM ${NODE_IMAGE} AS builder
WORKDIR /app WORKDIR /app
@@ -30,7 +33,7 @@ RUN npm prune --omit=dev
# ============================================ # ============================================
# STAGE 2: PRODUCTION STAGE # STAGE 2: PRODUCTION STAGE
# ============================================ # ============================================
FROM node:20-alpine FROM ${NODE_IMAGE}
RUN apk add --no-cache dumb-init RUN apk add --no-cache dumb-init
@@ -50,7 +53,11 @@ COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
# Windows git/build context may use CRLF; strip before chmod (fixes dumb-init "No such file or directory"). # 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 sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh && chmod +x /usr/local/bin/docker-entrypoint.sh
RUN mkdir -p /app/logs && \ # AdminJS writes/serves the custom-components bundle here. Avoid the default
# ".adminjs" path — Express sendFile + nosniff can 500 on dot-directories.
ENV ADMIN_JS_TMP_DIR=/app/adminjs-tmp
RUN mkdir -p /app/logs /app/adminjs-tmp && \
chown -R dyolink:nodejs /app chown -R dyolink:nodejs /app
USER dyolink USER dyolink

View File

@@ -0,0 +1,8 @@
-- AlterEnum
ALTER TYPE "LabCaseActivityType" ADD VALUE 'CASE_COMPLETED';
-- AlterEnum
ALTER TYPE "UserNotificationType" ADD VALUE 'CASE_COMPLETED';
-- Step-level TASK_COMPLETED inbox rows were spam; case-complete uses CASE_COMPLETED going forward.
DELETE FROM "UserNotification" WHERE type = 'TASK_COMPLETED';

View File

@@ -492,6 +492,7 @@ enum LabCaseActivityType {
CASE_AMENDED CASE_AMENDED
TASK_COMPLETED TASK_COMPLETED
TASK_ASSIGNED TASK_ASSIGNED
CASE_COMPLETED
} }
enum LabCaseTabReadTarget { enum LabCaseTabReadTarget {
@@ -508,6 +509,7 @@ enum UserNotificationType {
CASE_IMPORTANT CASE_IMPORTANT
TASK_COMPLETED TASK_COMPLETED
TASK_ASSIGNED TASK_ASSIGNED
CASE_COMPLETED
CONNECTION_REQUEST CONNECTION_REQUEST
STAFF_INVITE STAFF_INVITE
} }

View File

@@ -1,14 +1,16 @@
// backend/src/admin/admin.module.ts
import { DynamicModule, Module } from '@nestjs/common'; import { DynamicModule, Module } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { componentLoader, Components } from './components'; import { componentLoader, Components } from './components';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { Database, Resource, getModelByName } from '@adminjs/prisma'; // 👈 Add getModelByName import { Database, Resource } from '@adminjs/prisma';
import AdminJS from 'adminjs'; import AdminJS from 'adminjs';
import { buildAdminResources } from './resources';
// Register the adapter
AdminJS.registerAdapter({ Database, Resource }); AdminJS.registerAdapter({ Database, Resource });
const LOCAL_DEFAULT_ADMIN_PASSWORD = 'admin123';
const LOCAL_DEFAULT_ADMIN_EMAIL = 'admin@dyolink.com';
@Module({ @Module({
imports: [ConfigModule], imports: [ConfigModule],
}) })
@@ -17,10 +19,24 @@ export class AdminModule {
const { AdminModule: AdminJSModule } = await import('@adminjs/nestjs'); const { AdminModule: AdminJSModule } = await import('@adminjs/nestjs');
const authenticate = async (email: string, password: string) => { const authenticate = async (email: string, password: string) => {
const isProduction = process.env.NODE_ENV === 'production';
const adminEmail = const adminEmail =
process.env.ADMINJS_EMAIL?.trim() || 'admin@dyolink.com'; process.env.ADMINJS_EMAIL?.trim() || LOCAL_DEFAULT_ADMIN_EMAIL;
const adminPassword = process.env.ADMINJS_PASSWORD || 'admin123'; const adminPassword = process.env.ADMINJS_PASSWORD;
if (email === adminEmail && password === adminPassword) {
if (isProduction) {
if (
!adminPassword ||
adminPassword === LOCAL_DEFAULT_ADMIN_PASSWORD
) {
return null;
}
}
const effectivePassword =
adminPassword || LOCAL_DEFAULT_ADMIN_PASSWORD;
if (email === adminEmail && password === effectivePassword) {
return { email, role: 'admin' }; return { email, role: 'admin' };
} }
return null; return null;
@@ -38,68 +54,54 @@ export class AdminModule {
config.get<string>('jwt.secret') || config.get<string>('jwt.secret') ||
config.get('JWT_SECRET') || config.get('JWT_SECRET') ||
'secret-key-change-this'; 'secret-key-change-this';
if (
process.env.NODE_ENV === 'production' && if (process.env.NODE_ENV === 'production') {
!process.env.ADMINJS_PASSWORD const adminPassword = process.env.ADMINJS_PASSWORD;
) { if (
console.warn( !adminPassword ||
'⚠️ ADMINJS_PASSWORD is unset; AdminJS is using the local default. Set it in backend.env.', adminPassword === LOCAL_DEFAULT_ADMIN_PASSWORD
); ) {
console.error(
'❌ AdminJS: ADMINJS_PASSWORD is missing or still the local default. Login is disabled until you set a strong password in backend.env.',
);
}
} }
return { return {
adminJsOptions: { adminJsOptions: {
rootPath: '/admin', rootPath: '/admin',
resources: [ resources: buildAdminResources(prisma),
// ✅ Use getModelByName helper
{
resource: {
model: getModelByName('User'),
client: prisma,
},
options: {
properties: {
passwordHash: { isVisible: false },
},
},
},
{
resource: {
model: getModelByName('Organization'),
client: prisma,
},
options: {},
},
{
resource: {
model: getModelByName('OrganizationType'),
client: prisma,
},
options: {},
},
{
resource: {
model: getModelByName('Plan'),
client: prisma,
},
options: {},
},
{
resource: {
model: getModelByName('Membership'),
client: prisma,
},
options: {},
},
{
resource: {
model: getModelByName('Session'),
client: prisma,
},
options: {},
},
],
componentLoader, componentLoader,
dashboard: { component: Components.Dashboard }, dashboard: {
component: Components.Dashboard,
handler: async () => {
const [clinicType, labType] = await Promise.all([
prisma.organizationType.findUnique({
where: { name: 'CLINIC' },
}),
prisma.organizationType.findUnique({
where: { name: 'LAB' },
}),
]);
const [clinics, labs, users, labCases] = await Promise.all([
clinicType
? prisma.organization.count({
where: { typeId: clinicType.id },
})
: Promise.resolve(0),
labType
? prisma.organization.count({
where: { typeId: labType.id },
})
: Promise.resolve(0),
prisma.user.count(),
prisma.labCase.count(),
]);
return { clinics, labs, users, labCases };
},
},
branding: { branding: {
companyName: 'DyoLink Admin', companyName: 'DyoLink Admin',
logo: false, logo: false,

View File

@@ -5,7 +5,6 @@ const componentLoader = new ComponentLoader();
const Components = { const Components = {
Dashboard: componentLoader.add('Dashboard', './dashboard'), Dashboard: componentLoader.add('Dashboard', './dashboard'),
// You can add more components here as needed
}; };
export { componentLoader, Components }; export { componentLoader, Components };

View File

@@ -1,29 +1,78 @@
// backend/src/admin/dashboard-simple.tsx
// @ts-nocheck // @ts-nocheck
import React from 'react'; import React, { useEffect, useState } from 'react';
import { Box, H2, Text, Badge } from '@adminjs/design-system'; import { Box, H2, Text } from '@adminjs/design-system';
import { ApiClient } from 'adminjs';
type DashboardStats = {
clinics: number;
labs: number;
users: number;
labCases: number;
};
const StatCard = ({
label,
value,
loading,
}: {
label: string;
value?: number;
loading: boolean;
}) => (
<Box p="lg" bg="primary20" style={{ flex: 1, minWidth: '140px' }}>
<Text>{label}</Text>
<Box mt="default" style={{ fontSize: '2rem', fontWeight: 'bold' }}>
{loading ? '…' : (value ?? '—')}
</Box>
</Box>
);
const Dashboard = () => { const Dashboard = () => {
const [stats, setStats] = useState<DashboardStats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const api = new ApiClient();
api
.getDashboard()
.then((response) => {
setStats(response.data as DashboardStats);
setError(null);
})
.catch(() => {
setError('Could not load dashboard stats.');
})
.finally(() => {
setLoading(false);
});
}, []);
return ( return (
<Box variant="grey"> <Box variant="grey">
<Box variant="white" p="xl"> <Box variant="white" p="xl">
<H2>Welcome to DyoLink Admin Panel</H2> <H2>DyoLink Admin</H2>
<Text>Manage your dental clinics, labs, users, and subscriptions.</Text> <Text>Manage clinics, labs, users, and production data.</Text>
<Box mt="xl" style={{ display: 'flex', gap: '20px' }}> {error ? (
<Box p="lg" bg="primary20" style={{ flex: 1 }}> <Box mt="xl">
<div style={{ fontSize: '1.5rem' }}>🏥 Clinics</div> <Text>{error}</Text>
<div style={{ fontSize: '2rem', fontWeight: 'bold' }}>12</div>
</Box> </Box>
<Box p="lg" bg="secondary20" style={{ flex: 1 }}> ) : (
<div style={{ fontSize: '1.5rem' }}>🔬 Labs</div> <Box
<div style={{ fontSize: '2rem', fontWeight: 'bold' }}>8</div> mt="xl"
style={{ display: 'flex', gap: '20px', flexWrap: 'wrap' }}
>
<StatCard label="Clinics" value={stats?.clinics} loading={loading} />
<StatCard label="Labs" value={stats?.labs} loading={loading} />
<StatCard label="Users" value={stats?.users} loading={loading} />
<StatCard
label="Lab cases"
value={stats?.labCases}
loading={loading}
/>
</Box> </Box>
<Box p="lg" bg="info20" style={{ flex: 1 }}> )}
<div style={{ fontSize: '1.5rem' }}>👥 Users</div>
<div style={{ fontSize: '2rem', fontWeight: 'bold' }}>45</div>
</Box>
</Box>
</Box> </Box>
</Box> </Box>
); );

View File

@@ -0,0 +1,166 @@
import { getModelByName } from '@adminjs/prisma';
import type { PrismaService } from '../../prisma/prisma.service';
type ResourceOptions = {
navigation?: { name: string; icon?: string };
properties?: Record<string, { isVisible?: boolean | { list?: boolean; filter?: boolean; show?: boolean; edit?: boolean } }>;
actions?: Record<
string,
{ isAccessible?: boolean }
>;
};
type AdminResource = {
resource: { model: ReturnType<typeof getModelByName>; client: PrismaService };
options: ResourceOptions;
};
const hide = (...propertyNames: string[]): ResourceOptions['properties'] =>
Object.fromEntries(
propertyNames.map((name) => [
name,
{ isVisible: { list: false, filter: false, show: false, edit: false } },
]),
);
const catalogActions: ResourceOptions['actions'] = {
new: { isAccessible: false },
delete: { isAccessible: false },
bulkDelete: { isAccessible: false },
};
function resource(
client: PrismaService,
modelName: string,
options: ResourceOptions = {},
): AdminResource {
return {
resource: {
model: getModelByName(modelName),
client,
},
options,
};
}
/** Curated AdminJS allowlist — keep in sync when adding ops-relevant Prisma models. */
export function buildAdminResources(prisma: PrismaService): AdminResource[] {
return [
// Identity
resource(prisma, 'User', {
navigation: { name: 'Identity', icon: 'User' },
properties: hide('passwordHash'),
}),
resource(prisma, 'Session', {
navigation: { name: 'Identity', icon: 'User' },
properties: hide('token', 'refreshToken'),
}),
resource(prisma, 'PhoneVerificationCode', {
navigation: { name: 'Identity', icon: 'User' },
properties: hide('codeHash'),
}),
// Orgs & access
resource(prisma, 'Organization', {
navigation: { name: 'Orgs & access', icon: 'Settings' },
}),
resource(prisma, 'OrganizationType', {
navigation: { name: 'Orgs & access', icon: 'Settings' },
}),
resource(prisma, 'Plan', {
navigation: { name: 'Orgs & access', icon: 'Settings' },
}),
resource(prisma, 'Membership', {
navigation: { name: 'Orgs & access', icon: 'Settings' },
}),
resource(prisma, 'Permission', {
navigation: { name: 'Orgs & access', icon: 'Settings' },
}),
// MembershipPermission omitted: composite @@id — AdminJS list 500s without a single id
resource(prisma, 'Feature', {
navigation: { name: 'Orgs & access', icon: 'Settings' },
}),
resource(prisma, 'StaffInvitation', {
navigation: { name: 'Orgs & access', icon: 'Settings' },
properties: hide('tokenHash'),
}),
resource(prisma, 'OrganizationLink', {
navigation: { name: 'Orgs & access', icon: 'Settings' },
}),
resource(prisma, 'OrganizationInvitation', {
navigation: { name: 'Orgs & access', icon: 'Settings' },
properties: hide('tokenHash'),
}),
// Clinic
resource(prisma, 'Patient', {
navigation: { name: 'Clinic', icon: 'Healthcare' },
}),
resource(prisma, 'Appointment', {
navigation: { name: 'Clinic', icon: 'Healthcare' },
}),
resource(prisma, 'Treatment', {
navigation: { name: 'Clinic', icon: 'Healthcare' },
}),
resource(prisma, 'TreatmentDetail', {
navigation: { name: 'Clinic', icon: 'Healthcare' },
}),
resource(prisma, 'TreatmentDetailAttachment', {
navigation: { name: 'Clinic', icon: 'Healthcare' },
}),
// Lab
resource(prisma, 'LabCase', {
navigation: { name: 'Lab', icon: 'Archive' },
properties: hide('accessToken'),
}),
resource(prisma, 'LabCaseLine', {
navigation: { name: 'Lab', icon: 'Archive' },
}),
// LabCaseDetail omitted: composite @@id — AdminJS list 500s without a single id
// (inspect LabCase + TreatmentDetail instead)
resource(prisma, 'LabCaseSend', {
navigation: { name: 'Lab', icon: 'Archive' },
}),
resource(prisma, 'LabCaseToothProsthesis', {
navigation: { name: 'Lab', icon: 'Archive' },
}),
resource(prisma, 'LabCaseTask', {
navigation: { name: 'Lab', icon: 'Archive' },
}),
resource(prisma, 'LabCaseTaskStatusEvent', {
navigation: { name: 'Lab', icon: 'Archive' },
}),
resource(prisma, 'LabCaseComment', {
navigation: { name: 'Lab', icon: 'Archive' },
}),
resource(prisma, 'LabCaseActivity', {
navigation: { name: 'Lab', icon: 'Archive' },
}),
resource(prisma, 'UserNotification', {
navigation: { name: 'Lab', icon: 'Archive' },
}),
// Catalog — edit OK; create/delete via seed/migrations
resource(prisma, 'TreatmentType', {
navigation: { name: 'Catalog', icon: 'Catalog' },
actions: catalogActions,
}),
resource(prisma, 'ProsthesisType', {
navigation: { name: 'Catalog', icon: 'Catalog' },
actions: catalogActions,
}),
resource(prisma, 'LabWorkflowStep', {
navigation: { name: 'Catalog', icon: 'Catalog' },
actions: catalogActions,
}),
resource(prisma, 'ProsthesisTypeStep', {
navigation: { name: 'Catalog', icon: 'Catalog' },
actions: catalogActions,
}),
resource(prisma, 'CatalogTranslation', {
navigation: { name: 'Catalog', icon: 'Catalog' },
actions: catalogActions,
}),
];
}

View File

@@ -84,6 +84,7 @@ export const ErrorCode = {
APPOINTMENT_NOT_PROVIDER: 'APPOINTMENT_NOT_PROVIDER', APPOINTMENT_NOT_PROVIDER: 'APPOINTMENT_NOT_PROVIDER',
PATIENT_NOT_FOUND: 'PATIENT_NOT_FOUND', PATIENT_NOT_FOUND: 'PATIENT_NOT_FOUND',
PATIENT_MOBILE_UNAVAILABLE: 'PATIENT_MOBILE_UNAVAILABLE',
WORKING_HOURS_INVALID: 'WORKING_HOURS_INVALID', WORKING_HOURS_INVALID: 'WORKING_HOURS_INVALID',
WORKING_HOURS_OWNER_NOT_ALLOWED: 'WORKING_HOURS_OWNER_NOT_ALLOWED', WORKING_HOURS_OWNER_NOT_ALLOWED: 'WORKING_HOURS_OWNER_NOT_ALLOWED',
@@ -110,6 +111,8 @@ export const ErrorCode = {
STAFF_CANNOT_ENABLE_OWNER: 'STAFF_CANNOT_ENABLE_OWNER', STAFF_CANNOT_ENABLE_OWNER: 'STAFF_CANNOT_ENABLE_OWNER',
STAFF_CANNOT_DISABLE_OWNER: 'STAFF_CANNOT_DISABLE_OWNER', STAFF_CANNOT_DISABLE_OWNER: 'STAFF_CANNOT_DISABLE_OWNER',
STAFF_CANNOT_REMOVE_OWNER: 'STAFF_CANNOT_REMOVE_OWNER', STAFF_CANNOT_REMOVE_OWNER: 'STAFF_CANNOT_REMOVE_OWNER',
STAFF_CANNOT_CLEAR_OWN_PASSWORD: 'STAFF_CANNOT_CLEAR_OWN_PASSWORD',
STAFF_PASSWORD_CLEAR_ACTIVE_ONLY: 'STAFF_PASSWORD_CLEAR_ACTIVE_ONLY',
ORG_CANNOT_LINK_SELF: 'ORG_CANNOT_LINK_SELF', ORG_CANNOT_LINK_SELF: 'ORG_CANNOT_LINK_SELF',
ORG_LINK_WRONG_TYPE: 'ORG_LINK_WRONG_TYPE', ORG_LINK_WRONG_TYPE: 'ORG_LINK_WRONG_TYPE',

View File

@@ -7,15 +7,15 @@ export const LAB_CASES_TAB_ACTIVITY_TYPES: LabCaseActivityType[] = [
LabCaseActivityType.CASE_IMPORTANT, LabCaseActivityType.CASE_IMPORTANT,
]; ];
/** Lab Tasks tab — task completions, lab-side comments, and assignments (assignee-scoped in counts). */ /** Lab Tasks tab — case fully completed, lab-side comments, and assignments (assignee-scoped in counts). */
export const LAB_TASKS_TAB_ACTIVITY_TYPES: LabCaseActivityType[] = [ export const LAB_TASKS_TAB_ACTIVITY_TYPES: LabCaseActivityType[] = [
LabCaseActivityType.TASK_COMPLETED, LabCaseActivityType.CASE_COMPLETED,
LabCaseActivityType.LAB_COMMENT, LabCaseActivityType.LAB_COMMENT,
LabCaseActivityType.TASK_ASSIGNED, LabCaseActivityType.TASK_ASSIGNED,
]; ];
/** Clinic Treatment tab — visible lab comments and task progress. */ /** Clinic Treatment tab — visible lab comments and case fully completed. */
export const CLINIC_TREATMENT_TAB_ACTIVITY_TYPES: LabCaseActivityType[] = [ export const CLINIC_TREATMENT_TAB_ACTIVITY_TYPES: LabCaseActivityType[] = [
LabCaseActivityType.LAB_COMMENT, LabCaseActivityType.LAB_COMMENT,
LabCaseActivityType.TASK_COMPLETED, LabCaseActivityType.CASE_COMPLETED,
]; ];

View File

@@ -341,12 +341,16 @@ export class AppointmentsService {
} }
} }
private async ensurePatientInOrg(patientId: string, _organizationId: string) { private async ensurePatientInOrg(patientId: string, organizationId: string) {
const patient = await this.prisma.patient.findUnique({ const patient = await this.prisma.patient.findFirst({
where: { id: patientId }, where: {
select: { id: true, isWalkIn: true }, id: patientId,
isWalkIn: false,
createdByOrganizationId: organizationId,
},
select: { id: true },
}); });
if (!patient || patient.isWalkIn) { if (!patient) {
throw new AppException(ErrorCode.PATIENT_NOT_FOUND, HttpStatus.NOT_FOUND); throw new AppException(ErrorCode.PATIENT_NOT_FOUND, HttpStatus.NOT_FOUND);
} }
} }

View File

@@ -25,16 +25,17 @@ export class PatientsController {
constructor(private readonly patientsService: PatientsService) {} constructor(private readonly patientsService: PatientsService) {}
@Post() @Post()
@ApiOperation({ summary: 'Create or return existing global patient by mobile' }) @ApiOperation({ summary: 'Create or return this clinics patient by mobile' })
create(@Body() createPatientDto: CreatePatientDto, @Req() req) { create(@Body() createPatientDto: CreatePatientDto, @Req() req) {
const organizationId = this.patientsService.getOrganizationIdFromUser(req.user); const organizationId = this.patientsService.getOrganizationIdFromUser(req.user);
return this.patientsService.create(createPatientDto, organizationId); return this.patientsService.create(createPatientDto, organizationId);
} }
@Get() @Get()
@ApiOperation({ summary: 'Search all patients globally' }) @ApiOperation({ summary: 'Search patients created by the current clinic' })
findAll(@Query() query: ListPatientsDto) { findAll(@Query() query: ListPatientsDto, @Req() req) {
return this.patientsService.findAll(query); const organizationId = this.patientsService.getOrganizationIdFromUser(req.user);
return this.patientsService.findAll(query, organizationId);
} }
@Get(':id/appointments') @Get(':id/appointments')
@@ -48,14 +49,20 @@ export class PatientsController {
} }
@Get(':id') @Get(':id')
@ApiOperation({ summary: 'Get one patient by id' }) @ApiOperation({ summary: 'Get one patient created by the current clinic' })
findOne(@Param('id') id: string) { findOne(@Param('id') id: string, @Req() req) {
return this.patientsService.findOne(id); const organizationId = this.patientsService.getOrganizationIdFromUser(req.user);
return this.patientsService.findOne(id, organizationId);
} }
@Patch(':id') @Patch(':id')
@ApiOperation({ summary: 'Update global patient record' }) @ApiOperation({ summary: 'Update a patient created by the current clinic' })
update(@Param('id') id: string, @Body() updatePatientDto: UpdatePatientDto) { update(
return this.patientsService.update(id, updatePatientDto); @Param('id') id: string,
@Body() updatePatientDto: UpdatePatientDto,
@Req() req,
) {
const organizationId = this.patientsService.getOrganizationIdFromUser(req.user);
return this.patientsService.update(id, updatePatientDto, organizationId);
} }
} }

View File

@@ -21,7 +21,13 @@ export class PatientsService {
}); });
if (existing) { if (existing) {
return { success: true, data: existing, existing: true as const }; if (
!existing.isWalkIn &&
existing.createdByOrganizationId === organizationId
) {
return { success: true, data: existing, existing: true as const };
}
throw new AppException(ErrorCode.PATIENT_MOBILE_UNAVAILABLE, HttpStatus.CONFLICT);
} }
const patient = await this.prisma.patient.create({ const patient = await this.prisma.patient.create({
@@ -39,12 +45,13 @@ export class PatientsService {
return { success: true, data: patient, existing: false as const }; return { success: true, data: patient, existing: false as const };
} }
async findAll(query: ListPatientsDto) { async findAll(query: ListPatientsDto, organizationId: string) {
const { page = 1, limit = 10, q } = query; const { page = 1, limit = 10, q } = query;
const skip = (page - 1) * limit; const skip = (page - 1) * limit;
const where = { const where = {
isWalkIn: false, isWalkIn: false,
createdByOrganizationId: organizationId,
...(q?.trim() ? this.buildSearchWhere(q.trim()) : {}), ...(q?.trim() ? this.buildSearchWhere(q.trim()) : {}),
}; };
@@ -72,27 +79,13 @@ export class PatientsService {
}; };
} }
async findOne(id: string) { async findOne(id: string, organizationId: string) {
const patient = await this.prisma.patient.findUnique({ const patient = await this.findNamedPatientInOrg(id, organizationId);
where: { id },
});
if (!patient || patient.isWalkIn) {
throw new AppException(ErrorCode.PATIENT_NOT_FOUND, HttpStatus.NOT_FOUND);
}
return { success: true, data: patient }; return { success: true, data: patient };
} }
async update(id: string, updatePatientDto: UpdatePatientDto) { async update(id: string, updatePatientDto: UpdatePatientDto, organizationId: string) {
await this.ensurePatient(id); await this.findNamedPatientInOrg(id, organizationId);
const patient = await this.prisma.patient.findUnique({
where: { id },
select: { isWalkIn: true },
});
if (patient?.isWalkIn) {
throw new AppException(ErrorCode.PATIENT_NOT_FOUND, HttpStatus.NOT_FOUND);
}
const data: { const data: {
firstName?: string; firstName?: string;
@@ -110,7 +103,15 @@ export class PatientsService {
data.lastName = this.requireNonEmptyName(updatePatientDto.lastName, 'lastName'); data.lastName = this.requireNonEmptyName(updatePatientDto.lastName, 'lastName');
} }
if (updatePatientDto.mobile !== undefined) { if (updatePatientDto.mobile !== undefined) {
data.mobile = this.resolveMobile(updatePatientDto.mobile); const mobile = this.resolveMobile(updatePatientDto.mobile);
const taken = await this.prisma.patient.findUnique({
where: { mobile },
select: { id: true, createdByOrganizationId: true, isWalkIn: true },
});
if (taken && taken.id !== id) {
throw new AppException(ErrorCode.PATIENT_MOBILE_UNAVAILABLE, HttpStatus.CONFLICT);
}
data.mobile = mobile;
} }
if (updatePatientDto.email !== undefined) { if (updatePatientDto.email !== undefined) {
data.email = updatePatientDto.email?.trim() || null; data.email = updatePatientDto.email?.trim() || null;
@@ -138,7 +139,7 @@ export class PatientsService {
actorUserId: string, actorUserId: string,
) { ) {
await this.assertCanViewPatients(actorUserId, organizationId); await this.assertCanViewPatients(actorUserId, organizationId);
await this.ensurePatient(patientId); await this.findNamedPatientInOrg(patientId, organizationId);
const items = await this.prisma.appointment.findMany({ const items = await this.prisma.appointment.findMany({
where: { organizationId, patientId }, where: { organizationId, patientId },
@@ -241,14 +242,18 @@ export class PatientsService {
} }
} }
private async ensurePatient(id: string) { private async findNamedPatientInOrg(id: string, organizationId: string) {
const patient = await this.prisma.patient.findUnique({ const patient = await this.prisma.patient.findFirst({
where: { id }, where: {
select: { id: true }, id,
isWalkIn: false,
createdByOrganizationId: organizationId,
},
}); });
if (!patient) { if (!patient) {
throw new AppException(ErrorCode.PATIENT_NOT_FOUND, HttpStatus.NOT_FOUND); throw new AppException(ErrorCode.PATIENT_NOT_FOUND, HttpStatus.NOT_FOUND);
} }
return patient;
} }
} }

View File

@@ -61,6 +61,20 @@ export class StaffController {
return this.staffService.invite(req.user.id, organizationId, dto); return this.staffService.invite(req.user.id, organizationId, dto);
} }
@Post('members/:membershipId/clear-password')
@UseGuards(JwtAuthGuard)
@ApiOperation({
summary:
'Clear a staff member password and return a setup link (owner or TAB_STAFF_EDIT; active members only)',
})
clearPassword(
@Req() req: { user: { id: string; organizationId?: string } },
@Param('membershipId') membershipId: string,
) {
const organizationId = this.staffService.getOrganizationIdFromUser(req.user);
return this.staffService.clearPassword(req.user.id, organizationId, membershipId);
}
@Post('members/:membershipId/invitation-link') @Post('members/:membershipId/invitation-link')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@ApiOperation({ @ApiOperation({

View File

@@ -49,7 +49,7 @@ export class StaffService {
this.prisma.membership.findMany({ this.prisma.membership.findMany({
where: { organizationId }, where: { organizationId },
include: { include: {
user: { select: { id: true, email: true, name: true } }, user: { select: { id: true, email: true, name: true, passwordHash: true } },
permissions: { include: { permission: true } }, permissions: { include: { permission: true } },
invitations: { invitations: {
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
@@ -80,6 +80,7 @@ export class StaffService {
isOwner: m.isOwner, isOwner: m.isOwner,
isActive: m.isOwner ? true : m.isActive, isActive: m.isOwner ? true : m.isActive,
invitationStatus: this.getInvitationStatus(m), invitationStatus: this.getInvitationStatus(m),
hasPassword: Boolean(m.user.passwordHash),
invitedAt: m.invitations[0]?.createdAt?.toISOString() || null, invitedAt: m.invitations[0]?.createdAt?.toISOString() || null,
acceptedAt: m.invitations[0]?.acceptedAt?.toISOString() || null, acceptedAt: m.invitations[0]?.acceptedAt?.toISOString() || null,
permissions: m.isOwner permissions: m.isOwner
@@ -310,6 +311,77 @@ export class StaffService {
organizationName: org.name, organizationName: org.name,
expiresAt: invitation.expiresAt.toISOString(), expiresAt: invitation.expiresAt.toISOString(),
status: invitation.acceptedAt ? 'ACCEPTED' : 'PENDING', status: invitation.acceptedAt ? 'ACCEPTED' : 'PENDING',
mode: invitation.membership.isActive ? 'password_setup' : 'join',
},
};
}
async clearPassword(
actorUserId: string,
organizationId: string,
membershipId: string,
) {
const actor = await this.getActorMembership(actorUserId, organizationId);
if (!actor || !this.canEditStaff(actor)) {
throw new AppException(ErrorCode.PERMISSION_EDIT_STAFF, HttpStatus.FORBIDDEN);
}
const membership = await this.prisma.membership.findFirst({
where: { id: membershipId, organizationId },
include: {
user: { select: { id: true, email: true } },
},
});
if (!membership) {
throw new AppException(ErrorCode.STAFF_MEMBER_NOT_FOUND, HttpStatus.NOT_FOUND);
}
if (membership.isOwner) {
throw new AppException(ErrorCode.STAFF_CANNOT_EDIT_OWNER, HttpStatus.FORBIDDEN);
}
if (membership.userId === actorUserId) {
throw new AppException(ErrorCode.STAFF_CANNOT_CLEAR_OWN_PASSWORD, HttpStatus.BAD_REQUEST);
}
if (!membership.isActive) {
throw new AppException(ErrorCode.STAFF_PASSWORD_CLEAR_ACTIVE_ONLY, HttpStatus.BAD_REQUEST);
}
const plainToken = this.generateInviteToken();
const tokenHash = this.hashInviteToken(plainToken);
const invitation = await this.prisma.$transaction(async (tx) => {
await tx.user.update({
where: { id: membership.userId },
data: { passwordHash: null },
});
await tx.session.deleteMany({
where: { userId: membership.userId },
});
await tx.staffInvitation.updateMany({
where: {
membershipId: membership.id,
acceptedAt: null,
revokedAt: null,
},
data: { revokedAt: new Date() },
});
return tx.staffInvitation.create({
data: {
membershipId: membership.id,
invitedById: actorUserId,
tokenHash,
expiresAt: this.getInviteExpiryDate(),
},
});
});
return {
success: true,
data: {
membershipId: membership.id,
invitationId: invitation.id,
email: membership.user.email,
invitationUrl: this.buildInviteUrl(plainToken),
}, },
}; };
} }

View File

@@ -303,6 +303,7 @@ export class TasksService {
} }
} }
let caseCompleted = false;
if (dto.status === LabTaskStatus.COMPLETED && task.status !== LabTaskStatus.COMPLETED) { if (dto.status === LabTaskStatus.COMPLETED && task.status !== LabTaskStatus.COMPLETED) {
await this.labCaseActivity.record( await this.labCaseActivity.record(
{ {
@@ -321,18 +322,37 @@ export class TasksService {
}, },
tx, tx,
); );
const remainingIncomplete = await tx.labCaseTask.count({
where: {
labCaseId: task.labCaseId,
status: { not: LabTaskStatus.COMPLETED },
},
});
caseCompleted = remainingIncomplete === 0;
if (caseCompleted) {
await this.labCaseActivity.record(
{
labCaseId: task.labCaseId,
type: LabCaseActivityType.CASE_COMPLETED,
actorUserId,
payload: { labCaseId: task.labCaseId },
},
tx,
);
}
} }
return result; return { result, caseCompleted };
}); });
if (dto.status === LabTaskStatus.COMPLETED && task.status !== LabTaskStatus.COMPLETED) { if (updated.caseCompleted) {
void this.userNotifications.notify({ void this.userNotifications.notify({
organizationId: labOrganizationId, organizationId: labOrganizationId,
type: UserNotificationType.TASK_COMPLETED, type: UserNotificationType.CASE_COMPLETED,
href: `/tasks?taskId=${encodeURIComponent(taskId)}&labCaseId=${encodeURIComponent(task.labCaseId)}`, href: `/cases?caseId=${encodeURIComponent(task.labCaseId)}`,
actorUserId, actorUserId,
payload: { labCaseId: task.labCaseId, taskId }, payload: { labCaseId: task.labCaseId },
requiredPermission: 'TAB_TASKS_READ', requiredPermission: 'TAB_TASKS_READ',
}); });
@@ -340,10 +360,10 @@ export class TasksService {
if (clinicOrgId) { if (clinicOrgId) {
void this.userNotifications.notify({ void this.userNotifications.notify({
organizationId: clinicOrgId, organizationId: clinicOrgId,
type: UserNotificationType.TASK_COMPLETED, type: UserNotificationType.CASE_COMPLETED,
href: `/treatment?labCaseId=${encodeURIComponent(task.labCaseId)}`, href: `/treatment?labCaseId=${encodeURIComponent(task.labCaseId)}`,
actorUserId, actorUserId,
payload: { labCaseId: task.labCaseId, taskId }, payload: { labCaseId: task.labCaseId },
requiredPermission: 'TAB_TREATMENT_READ', requiredPermission: 'TAB_TREATMENT_READ',
labCaseIdForProviderScope: task.labCaseId, labCaseIdForProviderScope: task.labCaseId,
}); });
@@ -353,11 +373,11 @@ export class TasksService {
const locale = normalizeCatalogLocale(localeInput); const locale = normalizeCatalogLocale(localeInput);
const prosthesisLabels = await this.catalogLabels.resolveLabels( const prosthesisLabels = await this.catalogLabels.resolveLabels(
CatalogEntityKind.PROSTHESIS_TYPE, CatalogEntityKind.PROSTHESIS_TYPE,
atomicProsthesisCodes([updated.prosthesisTypeCode]), atomicProsthesisCodes([updated.result.prosthesisTypeCode]),
locale, locale,
); );
return { success: true, data: this.mapTaskListItem(updated, prosthesisLabels) }; return { success: true, data: this.mapTaskListItem(updated.result, prosthesisLabels) };
} }
async listFilterOptions( async listFilterOptions(

View File

@@ -222,14 +222,7 @@ export class TreatmentsService {
const sentinel = await ensureWalkInPatient(this.prisma, organizationId); const sentinel = await ensureWalkInPatient(this.prisma, organizationId);
patientId = sentinel.id; patientId = sentinel.id;
} else { } else {
await this.ensurePatientExists(dto.patientId!); await this.ensurePatientInOrg(dto.patientId!, organizationId);
const patient = await this.prisma.patient.findUnique({
where: { id: dto.patientId! },
select: { isWalkIn: true },
});
if (patient?.isWalkIn) {
throw new AppException(ErrorCode.TREATMENT_PATIENT_OR_WALK_IN, HttpStatus.BAD_REQUEST);
}
patientId = dto.patientId!; patientId = dto.patientId!;
} }
@@ -1670,6 +1663,20 @@ export class TreatmentsService {
} }
} }
private async ensurePatientInOrg(patientId: string, organizationId: string) {
const patient = await this.prisma.patient.findFirst({
where: {
id: patientId,
isWalkIn: false,
createdByOrganizationId: organizationId,
},
select: { id: true },
});
if (!patient) {
throw new AppException(ErrorCode.PATIENT_NOT_FOUND, HttpStatus.NOT_FOUND);
}
}
private async ensureTreatmentProvider( private async ensureTreatmentProvider(
treatmentId: string, treatmentId: string,
organizationId: string, organizationId: string,

View File

@@ -1,5 +1,8 @@
# CI can pass a Gitea-hosted mirror when Docker Hub TLS fails (see ci-resolve-node-image.ps1).
ARG NODE_IMAGE=node:20-alpine
# Build stage — produces `.next/standalone` (see next.config.ts output: standalone) # Build stage — produces `.next/standalone` (see next.config.ts output: standalone)
FROM node:20-alpine AS builder FROM ${NODE_IMAGE} AS builder
WORKDIR /app WORKDIR /app
@@ -32,7 +35,7 @@ ENV NEXT_PUBLIC_SENTRY_ENVIRONMENT=${NEXT_PUBLIC_SENTRY_ENVIRONMENT}
RUN npm run build RUN npm run build
# Production — minimal runtime using Next.js standalone bundle # Production — minimal runtime using Next.js standalone bundle
FROM node:20-alpine AS runner FROM ${NODE_IMAGE} AS runner
RUN apk add --no-cache dumb-init RUN apk add --no-cache dumb-init
@@ -47,6 +50,8 @@ ENV HOSTNAME=0.0.0.0
COPY --from=builder --chown=dyolink:nodejs /app/.next/standalone ./ COPY --from=builder --chown=dyolink:nodejs /app/.next/standalone ./
COPY --from=builder --chown=dyolink:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=dyolink:nodejs /app/.next/static ./.next/static
# Standalone does not include public/; catalog icons are <img src="/prosthesis-catalog/*.svg">.
COPY --from=builder --chown=dyolink:nodejs /app/public ./public
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
# Windows git/build context may use CRLF; strip before chmod (fixes dumb-init "No such file or directory"). # Windows git/build context may use CRLF; strip before chmod (fixes dumb-init "No such file or directory").

View File

@@ -60,4 +60,4 @@ npm install
## Docker ## Docker
Image build and build-args (`NEXT_PUBLIC_*`) are documented in the **repository root `README.md`**. Image build and build-args (`NEXT_PUBLIC_*`) are documented in the **repository root `README.md`**. Next `standalone` does not include `public/` — `frontend/Dockerfile` copies it so `/prosthesis-catalog/*.svg` is served in production.

View File

@@ -122,6 +122,9 @@
"labelCreatePassword": "Create password", "labelCreatePassword": "Create password",
"labelConfirmPassword": "Confirm password", "labelConfirmPassword": "Confirm password",
"activateAccount": "Activate account", "activateAccount": "Activate account",
"setPasswordTitle": "Set your password",
"setPasswordSubmit": "Set password",
"passwordSetupAlreadyDone": "This password setup link is no longer valid. You can log in now.",
"invitationAcceptedRedirect": "Invitation accepted. Opening your workspace...", "invitationAcceptedRedirect": "Invitation accepted. Opening your workspace...",
"invitationAcceptedSignInFailed": "Account activated, but sign-in failed. Please log in with your password.", "invitationAcceptedSignInFailed": "Account activated, but sign-in failed. Please log in with your password.",
"errorAcceptInvitation": "Could not accept invitation", "errorAcceptInvitation": "Could not accept invitation",
@@ -156,9 +159,8 @@
"verifyFailed": "Invalid or expired verification code." "verifyFailed": "Invalid or expired verification code."
}, },
"landing": { "landing": {
"heroTitle": "Connect Dental Clinics & Labs", "heroTitle": "Nudentic is a digital workflow platform for modern dentistry.",
"heroHighlight": "Seamlessly", "heroSubtitle": "It brings clinical and laboratory workflows, case information, communication, and patient records into one structured environment.",
"heroSubtitle": "Streamline communication between dental professionals. Start with a 30-day free trial, no credit card required.",
"featureClinicsTitle": "For Clinics", "featureClinicsTitle": "For Clinics",
"featureClinicsDescription": "Manage patients, appointments, and send cases to labs instantly.", "featureClinicsDescription": "Manage patients, appointments, and send cases to labs instantly.",
"featureLabsTitle": "For Labs", "featureLabsTitle": "For Labs",
@@ -322,6 +324,18 @@
"disableBullet3": "Disabling frees one seat on your plan so you can invite someone else.", "disableBullet3": "Disabling frees one seat on your plan so you can invite someone else.",
"disableMemberButton": "Disable member", "disableMemberButton": "Disable member",
"editModalTitle": "Edit member", "editModalTitle": "Edit member",
"removePassword": "Remove password",
"copyPasswordSetupLink": "Copy password setup link",
"removePasswordModalTitle": "Remove password",
"removePasswordConfirm": "Remove the password for {name} ({email})?",
"removePasswordBullet1": "They will not be able to sign in until they set a new password with the setup link.",
"removePasswordBullet2": "You cannot choose their new password. Share the setup link with them.",
"removePasswordBullet3": "This signs them out of every organization they belong to.",
"removePasswordButton": "Remove password and copy link",
"passwordSetupLinkHeading": "Password setup link",
"passwordSetupShareHint": "Share this link so they can set a new password. Login will fail until they finish.",
"successPasswordCleared": "Password removed for {name}. Share the setup link with them.",
"errorClearPassword": "Could not remove the password.",
"loadingWorkingHours": "Loading working hours…", "loadingWorkingHours": "Loading working hours…",
"errorLoadStaff": "Failed to load staff.", "errorLoadStaff": "Failed to load staff.",
"errorCopyInvite": "Could not copy invitation link.", "errorCopyInvite": "Could not copy invitation link.",
@@ -832,6 +846,7 @@
"activityTaskCompleted": "{step} completed by {actor} · {date}", "activityTaskCompleted": "{step} completed by {actor} · {date}",
"activityTaskAssigned": "{step} assigned by {actor} · {date}", "activityTaskAssigned": "{step} assigned by {actor} · {date}",
"activityCaseImportant": "Marked important by {actor} · {date}", "activityCaseImportant": "Marked important by {actor} · {date}",
"activityCaseCompleted": "All lab work completed by {actor} · {date}",
"activityCaseAmended": "Case updated by {actor} · {date}", "activityCaseAmended": "Case updated by {actor} · {date}",
"activityGeneric": "Update · {date}", "activityGeneric": "Update · {date}",
"loadingHistory": "Loading history…", "loadingHistory": "Loading history…",
@@ -1091,6 +1106,7 @@
"typeCaseImportant": "Case marked as important", "typeCaseImportant": "Case marked as important",
"typeTaskCompleted": "Lab task completed", "typeTaskCompleted": "Lab task completed",
"typeTaskAssigned": "A task was assigned to you", "typeTaskAssigned": "A task was assigned to you",
"typeCaseCompleted": "Lab case completed",
"typeConnectionRequest": "New organization connection request", "typeConnectionRequest": "New organization connection request",
"typeStaffInvite": "Staff invitation created", "typeStaffInvite": "Staff invitation created",
"typeUnknown": "Notification", "typeUnknown": "Notification",
@@ -1214,6 +1230,7 @@
"APPOINTMENT_NOT_FOUND": "Appointment not found.", "APPOINTMENT_NOT_FOUND": "Appointment not found.",
"APPOINTMENT_NOT_PROVIDER": "You are not the provider for this appointment.", "APPOINTMENT_NOT_PROVIDER": "You are not the provider for this appointment.",
"PATIENT_NOT_FOUND": "Patient not found.", "PATIENT_NOT_FOUND": "Patient not found.",
"PATIENT_MOBILE_UNAVAILABLE": "This mobile number cannot be added for this clinic.",
"WORKING_HOURS_INVALID": "Working hours are invalid. Check that shifts do not overlap.", "WORKING_HOURS_INVALID": "Working hours are invalid. Check that shifts do not overlap.",
"WORKING_HOURS_OWNER_NOT_ALLOWED": "Set owner working hours from account settings.", "WORKING_HOURS_OWNER_NOT_ALLOWED": "Set owner working hours from account settings.",
"WORKING_HOURS_CONFLICTS_WITH_APPOINTMENTS": "These hours conflict with upcoming appointments. Reschedule or remove those appointments first.", "WORKING_HOURS_CONFLICTS_WITH_APPOINTMENTS": "These hours conflict with upcoming appointments. Reschedule or remove those appointments first.",
@@ -1237,6 +1254,8 @@
"STAFF_CANNOT_ENABLE_OWNER": "The organization owner cannot be enabled this way.", "STAFF_CANNOT_ENABLE_OWNER": "The organization owner cannot be enabled this way.",
"STAFF_CANNOT_DISABLE_OWNER": "The organization owner cannot be disabled.", "STAFF_CANNOT_DISABLE_OWNER": "The organization owner cannot be disabled.",
"STAFF_CANNOT_REMOVE_OWNER": "The organization owner cannot be removed.", "STAFF_CANNOT_REMOVE_OWNER": "The organization owner cannot be removed.",
"STAFF_CANNOT_CLEAR_OWN_PASSWORD": "You cannot remove your own password here. Use account settings or forgot password.",
"STAFF_PASSWORD_CLEAR_ACTIVE_ONLY": "Password can only be removed for active members. Pending members use the invitation link.",
"ORG_CANNOT_LINK_SELF": "You cannot link an organization to itself.", "ORG_CANNOT_LINK_SELF": "You cannot link an organization to itself.",
"ORG_LINK_WRONG_TYPE": "You can only link to the matching organization type (clinic or lab).", "ORG_LINK_WRONG_TYPE": "You can only link to the matching organization type (clinic or lab).",
"ORG_TARGET_NO_SUBSCRIPTION": "The other organization does not have an active subscription.", "ORG_TARGET_NO_SUBSCRIPTION": "The other organization does not have an active subscription.",

View File

@@ -122,6 +122,9 @@
"labelCreatePassword": "ایجاد رمز عبور", "labelCreatePassword": "ایجاد رمز عبور",
"labelConfirmPassword": "تأیید رمز عبور", "labelConfirmPassword": "تأیید رمز عبور",
"activateAccount": "فعال‌سازی حساب", "activateAccount": "فعال‌سازی حساب",
"setPasswordTitle": "رمز عبور خود را تنظیم کنید",
"setPasswordSubmit": "تنظیم رمز عبور",
"passwordSetupAlreadyDone": "این لینک تنظیم رمز دیگر معتبر نیست. اکنون می‌توانید وارد شوید.",
"invitationAcceptedRedirect": "دعوتنامه پذیرفته شد. در حال ورود به فضای کاری...", "invitationAcceptedRedirect": "دعوتنامه پذیرفته شد. در حال ورود به فضای کاری...",
"invitationAcceptedSignInFailed": "حساب فعال شد، اما ورود انجام نشد. لطفاً با رمز عبور خود وارد شوید.", "invitationAcceptedSignInFailed": "حساب فعال شد، اما ورود انجام نشد. لطفاً با رمز عبور خود وارد شوید.",
"errorAcceptInvitation": "پذیرش دعوتنامه امکان‌پذیر نبود", "errorAcceptInvitation": "پذیرش دعوتنامه امکان‌پذیر نبود",
@@ -156,9 +159,8 @@
"verifyFailed": "کد تأیید نامعتبر یا منقضی شده است." "verifyFailed": "کد تأیید نامعتبر یا منقضی شده است."
}, },
"landing": { "landing": {
"heroTitle": "اتصال کلینیک‌ها و لابراتوارهای دندانپزشکی", "heroTitle": "Nudentic یک پلتفرم گردش‌کار دیجیتال برای دندانپزشکی مدرن است.",
"heroHighlight": "به‌صورت یکپارچه", "heroSubtitle": "این پلتفرم گردش‌کارهای بالینی و لابراتواری، اطلاعات پرونده، ارتباط و سوابق بیمار را در یک محیط ساختاریافته کنار هم می‌آورد.",
"heroSubtitle": "ارتباط بین متخصصان دندانپزشکی را ساده و سریع کنید. با یک دوره آزمایشی رایگان ۳۰ روزه، بدون نیاز به کارت اعتباری، شروع کنید.",
"featureClinicsTitle": "برای کلینیک‌ها", "featureClinicsTitle": "برای کلینیک‌ها",
"featureClinicsDescription": "بیماران و نوبت‌ها را مدیریت کنید و پرونده‌ها را فوراً به لابراتوارها ارسال کنید.", "featureClinicsDescription": "بیماران و نوبت‌ها را مدیریت کنید و پرونده‌ها را فوراً به لابراتوارها ارسال کنید.",
"featureLabsTitle": "برای لابراتوارها", "featureLabsTitle": "برای لابراتوارها",
@@ -322,6 +324,18 @@
"disableBullet3": "غیرفعال‌سازی یک مجوز در طرح شما را آزاد می‌کند تا بتوانید شخص دیگری را دعوت کنید.", "disableBullet3": "غیرفعال‌سازی یک مجوز در طرح شما را آزاد می‌کند تا بتوانید شخص دیگری را دعوت کنید.",
"disableMemberButton": "غیرفعال‌سازی عضو", "disableMemberButton": "غیرفعال‌سازی عضو",
"editModalTitle": "ویرایش عضو", "editModalTitle": "ویرایش عضو",
"removePassword": "حذف رمز عبور",
"copyPasswordSetupLink": "کپی لینک تنظیم رمز",
"removePasswordModalTitle": "حذف رمز عبور",
"removePasswordConfirm": "رمز عبور {name} ({email}) حذف شود؟",
"removePasswordBullet1": "تا وقتی با لینک تنظیم، رمز جدید نگذارند، نمی‌توانند وارد شوند.",
"removePasswordBullet2": "شما رمز جدید را انتخاب نمی‌کنید. لینک تنظیم را برایشان بفرستید.",
"removePasswordBullet3": "از همه سازمان‌هایی که عضو آن هستند خارج می‌شوند.",
"removePasswordButton": "حذف رمز و کپی لینک",
"passwordSetupLinkHeading": "لینک تنظیم رمز عبور",
"passwordSetupShareHint": "این لینک را به اشتراک بگذارید تا رمز جدید بگذارند. تا تکمیل این کار ورود ناموفق است.",
"successPasswordCleared": "رمز {name} حذف شد. لینک تنظیم را برایشان بفرستید.",
"errorClearPassword": "حذف رمز عبور امکان‌پذیر نبود.",
"loadingWorkingHours": "در حال بارگذاری ساعات کاری...", "loadingWorkingHours": "در حال بارگذاری ساعات کاری...",
"errorLoadStaff": "بارگذاری کارکنان ناموفق بود.", "errorLoadStaff": "بارگذاری کارکنان ناموفق بود.",
"errorCopyInvite": "کپی لینک دعوتنامه امکان‌پذیر نبود.", "errorCopyInvite": "کپی لینک دعوتنامه امکان‌پذیر نبود.",
@@ -833,6 +847,7 @@
"activityTaskCompleted": "{step} توسط {actor} تکمیل شد · {date}", "activityTaskCompleted": "{step} توسط {actor} تکمیل شد · {date}",
"activityTaskAssigned": "{step} توسط {actor} اختصاص داده شد · {date}", "activityTaskAssigned": "{step} توسط {actor} اختصاص داده شد · {date}",
"activityCaseImportant": "مهم علامت‌گذاری شد توسط {actor} · {date}", "activityCaseImportant": "مهم علامت‌گذاری شد توسط {actor} · {date}",
"activityCaseCompleted": "تمام کارهای لابراتوار توسط {actor} تکمیل شد · {date}",
"activityCaseAmended": "پرونده به‌روزرسانی شد توسط {actor} · {date}", "activityCaseAmended": "پرونده به‌روزرسانی شد توسط {actor} · {date}",
"activityGeneric": "به‌روزرسانی · {date}", "activityGeneric": "به‌روزرسانی · {date}",
"loadingHistory": "در حال بارگذاری تاریخچه...", "loadingHistory": "در حال بارگذاری تاریخچه...",
@@ -1092,6 +1107,7 @@
"typeCaseImportant": "پرونده به‌عنوان مهم علامت خورد", "typeCaseImportant": "پرونده به‌عنوان مهم علامت خورد",
"typeTaskCompleted": "وظیفه لابراتوار تکمیل شد", "typeTaskCompleted": "وظیفه لابراتوار تکمیل شد",
"typeTaskAssigned": "یک وظیفه به شما اختصاص داده شد", "typeTaskAssigned": "یک وظیفه به شما اختصاص داده شد",
"typeCaseCompleted": "پرونده لابراتوار تکمیل شد",
"typeConnectionRequest": "درخواست اتصال سازمان جدید", "typeConnectionRequest": "درخواست اتصال سازمان جدید",
"typeStaffInvite": "دعوتنامه کارکنان ایجاد شد", "typeStaffInvite": "دعوتنامه کارکنان ایجاد شد",
"typeUnknown": "اعلان", "typeUnknown": "اعلان",
@@ -1215,6 +1231,7 @@
"APPOINTMENT_NOT_FOUND": "نوبت یافت نشد.", "APPOINTMENT_NOT_FOUND": "نوبت یافت نشد.",
"APPOINTMENT_NOT_PROVIDER": "شما ارائه‌دهنده این نوبت نیستید.", "APPOINTMENT_NOT_PROVIDER": "شما ارائه‌دهنده این نوبت نیستید.",
"PATIENT_NOT_FOUND": "بیمار یافت نشد.", "PATIENT_NOT_FOUND": "بیمار یافت نشد.",
"PATIENT_MOBILE_UNAVAILABLE": "این شماره موبایل را نمی‌توان برای این کلینیک ثبت کرد.",
"WORKING_HOURS_INVALID": "ساعات کاری نامعتبر است. هم‌پوشانی شیفت‌ها را بررسی کنید.", "WORKING_HOURS_INVALID": "ساعات کاری نامعتبر است. هم‌پوشانی شیفت‌ها را بررسی کنید.",
"WORKING_HOURS_OWNER_NOT_ALLOWED": "ساعات کاری مالک را از تنظیمات حساب تنظیم کنید.", "WORKING_HOURS_OWNER_NOT_ALLOWED": "ساعات کاری مالک را از تنظیمات حساب تنظیم کنید.",
"WORKING_HOURS_CONFLICTS_WITH_APPOINTMENTS": "این ساعات با نوبت‌های آینده تداخل دارد. ابتدا آن نوبت‌ها را تغییر دهید یا حذف کنید.", "WORKING_HOURS_CONFLICTS_WITH_APPOINTMENTS": "این ساعات با نوبت‌های آینده تداخل دارد. ابتدا آن نوبت‌ها را تغییر دهید یا حذف کنید.",
@@ -1238,6 +1255,8 @@
"STAFF_CANNOT_ENABLE_OWNER": "مالک سازمان را نمی‌توان این‌گونه فعال کرد.", "STAFF_CANNOT_ENABLE_OWNER": "مالک سازمان را نمی‌توان این‌گونه فعال کرد.",
"STAFF_CANNOT_DISABLE_OWNER": "مالک سازمان را نمی‌توان غیرفعال کرد.", "STAFF_CANNOT_DISABLE_OWNER": "مالک سازمان را نمی‌توان غیرفعال کرد.",
"STAFF_CANNOT_REMOVE_OWNER": "مالک سازمان را نمی‌توان حذف کرد.", "STAFF_CANNOT_REMOVE_OWNER": "مالک سازمان را نمی‌توان حذف کرد.",
"STAFF_CANNOT_CLEAR_OWN_PASSWORD": "نمی‌توانید رمز عبور خود را از اینجا حذف کنید. از تنظیمات حساب یا فراموشی رمز استفاده کنید.",
"STAFF_PASSWORD_CLEAR_ACTIVE_ONLY": "رمز عبور را فقط برای اعضای فعال می‌توان حذف کرد. اعضای در انتظار از لینک دعوت استفاده می‌کنند.",
"ORG_CANNOT_LINK_SELF": "نمی‌توانید سازمان را به خودش متصل کنید.", "ORG_CANNOT_LINK_SELF": "نمی‌توانید سازمان را به خودش متصل کنید.",
"ORG_LINK_WRONG_TYPE": "فقط می‌توانید به نوع سازمان متناظر (کلینیک یا لابراتوار) متصل شوید.", "ORG_LINK_WRONG_TYPE": "فقط می‌توانید به نوع سازمان متناظر (کلینیک یا لابراتوار) متصل شوید.",
"ORG_TARGET_NO_SUBSCRIPTION": "سازمان مقابل اشتراک فعال ندارد.", "ORG_TARGET_NO_SUBSCRIPTION": "سازمان مقابل اشتراک فعال ندارد.",

View File

@@ -122,6 +122,9 @@
"labelCreatePassword": "Wachtwoord aanmaken", "labelCreatePassword": "Wachtwoord aanmaken",
"labelConfirmPassword": "Bevestig wachtwoord", "labelConfirmPassword": "Bevestig wachtwoord",
"activateAccount": "Account activeren", "activateAccount": "Account activeren",
"setPasswordTitle": "Stel uw wachtwoord in",
"setPasswordSubmit": "Wachtwoord instellen",
"passwordSetupAlreadyDone": "Deze wachtwoordlink is niet meer geldig. U kunt nu inloggen.",
"invitationAcceptedRedirect": "Uitnodiging geaccepteerd. Uw werkruimte wordt geopend...", "invitationAcceptedRedirect": "Uitnodiging geaccepteerd. Uw werkruimte wordt geopend...",
"invitationAcceptedSignInFailed": "Account geactiveerd, maar aanmelden is mislukt. Log in met uw wachtwoord.", "invitationAcceptedSignInFailed": "Account geactiveerd, maar aanmelden is mislukt. Log in met uw wachtwoord.",
"errorAcceptInvitation": "Kon uitnodiging niet accepteren", "errorAcceptInvitation": "Kon uitnodiging niet accepteren",
@@ -156,9 +159,8 @@
"verifyFailed": "Ongeldige of verlopen verificatiecode." "verifyFailed": "Ongeldige of verlopen verificatiecode."
}, },
"landing": { "landing": {
"heroTitle": "Verbind Tandheelkundige Klinieken & Laboratoria", "heroTitle": "Nudentic is een digitaal workflowplatform voor de moderne tandheelkunde.",
"heroHighlight": "Naadloos", "heroSubtitle": "Het brengt klinische en laboratoriumworkflows, casusinformatie, communicatie en patiëntendossiers samen in één gestructureerde omgeving.",
"heroSubtitle": "Stroomlijn de communicatie tussen tandheelkundige professionals. Start met een gratis proefperiode van 30 dagen, zonder creditcard.",
"featureClinicsTitle": "Voor Klinieken", "featureClinicsTitle": "Voor Klinieken",
"featureClinicsDescription": "Beheer patiënten, afspraken en stuur casussen direct naar laboratoria.", "featureClinicsDescription": "Beheer patiënten, afspraken en stuur casussen direct naar laboratoria.",
"featureLabsTitle": "Voor Laboratoria", "featureLabsTitle": "Voor Laboratoria",
@@ -322,6 +324,18 @@
"disableBullet3": "Uitschakelen maakt één plaats vrij in uw abonnement, zodat u iemand anders kunt uitnodigen.", "disableBullet3": "Uitschakelen maakt één plaats vrij in uw abonnement, zodat u iemand anders kunt uitnodigen.",
"disableMemberButton": "Lid uitschakelen", "disableMemberButton": "Lid uitschakelen",
"editModalTitle": "Lid bewerken", "editModalTitle": "Lid bewerken",
"removePassword": "Wachtwoord verwijderen",
"copyPasswordSetupLink": "Wachtwoordlink kopiëren",
"removePasswordModalTitle": "Wachtwoord verwijderen",
"removePasswordConfirm": "Wachtwoord van {name} ({email}) verwijderen?",
"removePasswordBullet1": "Zij kunnen niet inloggen tot ze via de instellink een nieuw wachtwoord kiezen.",
"removePasswordBullet2": "U kunt hun nieuwe wachtwoord niet kiezen. Deel de instellink met hen.",
"removePasswordBullet3": "Dit meldt hen af bij elke organisatie waar zij lid van zijn.",
"removePasswordButton": "Wachtwoord verwijderen en link kopiëren",
"passwordSetupLinkHeading": "Wachtwoord-instellink",
"passwordSetupShareHint": "Deel deze link zodat zij een nieuw wachtwoord kunnen instellen. Inloggen mislukt tot dat is afgerond.",
"successPasswordCleared": "Wachtwoord van {name} is verwijderd. Deel de instellink met hen.",
"errorClearPassword": "Kon het wachtwoord niet verwijderen.",
"loadingWorkingHours": "Werktijden laden...", "loadingWorkingHours": "Werktijden laden...",
"errorLoadStaff": "Medewerkers laden mislukt.", "errorLoadStaff": "Medewerkers laden mislukt.",
"errorCopyInvite": "Kon uitnodigingslink niet kopiëren.", "errorCopyInvite": "Kon uitnodigingslink niet kopiëren.",
@@ -832,6 +846,7 @@
"activityTaskCompleted": "{step} voltooid door {actor} · {date}", "activityTaskCompleted": "{step} voltooid door {actor} · {date}",
"activityTaskAssigned": "{step} toegewezen door {actor} · {date}", "activityTaskAssigned": "{step} toegewezen door {actor} · {date}",
"activityCaseImportant": "Als belangrijk gemarkeerd door {actor} · {date}", "activityCaseImportant": "Als belangrijk gemarkeerd door {actor} · {date}",
"activityCaseCompleted": "Al het labwerk voltooid door {actor} · {date}",
"activityCaseAmended": "Case bijgewerkt door {actor} · {date}", "activityCaseAmended": "Case bijgewerkt door {actor} · {date}",
"activityGeneric": "Update · {date}", "activityGeneric": "Update · {date}",
"loadingHistory": "Geschiedenis laden...", "loadingHistory": "Geschiedenis laden...",
@@ -1091,6 +1106,7 @@
"typeCaseImportant": "Case gemarkeerd als belangrijk", "typeCaseImportant": "Case gemarkeerd als belangrijk",
"typeTaskCompleted": "Labtaak voltooid", "typeTaskCompleted": "Labtaak voltooid",
"typeTaskAssigned": "Er is een taak aan u toegewezen", "typeTaskAssigned": "Er is een taak aan u toegewezen",
"typeCaseCompleted": "Labcase voltooid",
"typeConnectionRequest": "Nieuw organisatieverzoek", "typeConnectionRequest": "Nieuw organisatieverzoek",
"typeStaffInvite": "Personeelsuitnodiging aangemaakt", "typeStaffInvite": "Personeelsuitnodiging aangemaakt",
"typeUnknown": "Melding", "typeUnknown": "Melding",
@@ -1214,6 +1230,7 @@
"APPOINTMENT_NOT_FOUND": "Afspraak niet gevonden.", "APPOINTMENT_NOT_FOUND": "Afspraak niet gevonden.",
"APPOINTMENT_NOT_PROVIDER": "U bent niet de zorgverlener van deze afspraak.", "APPOINTMENT_NOT_PROVIDER": "U bent niet de zorgverlener van deze afspraak.",
"PATIENT_NOT_FOUND": "Patiënt niet gevonden.", "PATIENT_NOT_FOUND": "Patiënt niet gevonden.",
"PATIENT_MOBILE_UNAVAILABLE": "Dit mobiele nummer kan niet voor deze kliniek worden toegevoegd.",
"WORKING_HOURS_INVALID": "De werktijden zijn ongeldig. Controleer of diensten niet overlappen.", "WORKING_HOURS_INVALID": "De werktijden zijn ongeldig. Controleer of diensten niet overlappen.",
"WORKING_HOURS_OWNER_NOT_ALLOWED": "Stel werktijden van de eigenaar in via accountinstellingen.", "WORKING_HOURS_OWNER_NOT_ALLOWED": "Stel werktijden van de eigenaar in via accountinstellingen.",
"WORKING_HOURS_CONFLICTS_WITH_APPOINTMENTS": "Deze tijden conflicteren met aankomende afspraken. Plan die eerst om of verwijder ze.", "WORKING_HOURS_CONFLICTS_WITH_APPOINTMENTS": "Deze tijden conflicteren met aankomende afspraken. Plan die eerst om of verwijder ze.",
@@ -1237,6 +1254,8 @@
"STAFF_CANNOT_ENABLE_OWNER": "De eigenaar kan op deze manier niet worden ingeschakeld.", "STAFF_CANNOT_ENABLE_OWNER": "De eigenaar kan op deze manier niet worden ingeschakeld.",
"STAFF_CANNOT_DISABLE_OWNER": "De eigenaar kan niet worden uitgeschakeld.", "STAFF_CANNOT_DISABLE_OWNER": "De eigenaar kan niet worden uitgeschakeld.",
"STAFF_CANNOT_REMOVE_OWNER": "De eigenaar kan niet worden verwijderd.", "STAFF_CANNOT_REMOVE_OWNER": "De eigenaar kan niet worden verwijderd.",
"STAFF_CANNOT_CLEAR_OWN_PASSWORD": "U kunt hier uw eigen wachtwoord niet verwijderen. Gebruik accountinstellingen of wachtwoord vergeten.",
"STAFF_PASSWORD_CLEAR_ACTIVE_ONLY": "Het wachtwoord kan alleen voor actieve leden worden verwijderd. Leden in afwachting gebruiken de uitnodigingslink.",
"ORG_CANNOT_LINK_SELF": "U kunt een organisatie niet aan zichzelf koppelen.", "ORG_CANNOT_LINK_SELF": "U kunt een organisatie niet aan zichzelf koppelen.",
"ORG_LINK_WRONG_TYPE": "U kunt alleen koppelen aan het bijbehorende type (kliniek of lab).", "ORG_LINK_WRONG_TYPE": "U kunt alleen koppelen aan het bijbehorende type (kliniek of lab).",
"ORG_TARGET_NO_SUBSCRIPTION": "De andere organisatie heeft geen actief abonnement.", "ORG_TARGET_NO_SUBSCRIPTION": "De andere organisatie heeft geen actief abonnement.",

View File

@@ -30,6 +30,7 @@ function AcceptInviteContent() {
organizationName: string; organizationName: string;
expiresAt: string; expiresAt: string;
status: 'PENDING' | 'ACCEPTED'; status: 'PENDING' | 'ACCEPTED';
mode: 'join' | 'password_setup';
} | null>(null); } | null>(null);
const [name, setName] = useState(''); const [name, setName] = useState('');
@@ -48,10 +49,17 @@ function AcceptInviteContent() {
setError(''); setError('');
try { try {
const res = await staffApi.previewInvite(token); const res = await staffApi.previewInvite(token);
setInviteInfo(res.data); setInviteInfo({
...res.data,
mode: res.data.mode === 'password_setup' ? 'password_setup' : 'join',
});
setName(res.data.name || ''); setName(res.data.name || '');
if (res.data.status === 'ACCEPTED') { if (res.data.status === 'ACCEPTED') {
setSuccess(t('invitationAlreadyAccepted')); setSuccess(
res.data.mode === 'password_setup'
? t('passwordSetupAlreadyDone')
: t('invitationAlreadyAccepted'),
);
} }
} catch (e: unknown) { } catch (e: unknown) {
setError(getUserFacingError(e, tErrors, t('errorLoadInvitation'))); setError(getUserFacingError(e, tErrors, t('errorLoadInvitation')));
@@ -65,7 +73,9 @@ function AcceptInviteContent() {
if (!token) return; if (!token) return;
setError(''); setError('');
setSuccess(''); setSuccess('');
if (!name.trim()) { const isPasswordSetup = inviteInfo?.mode === 'password_setup';
const nameToSubmit = isPasswordSetup ? (inviteInfo?.name || '').trim() : name.trim();
if (!isPasswordSetup && !nameToSubmit) {
setError(t('nameRequired')); setError(t('nameRequired'));
return; return;
} }
@@ -83,7 +93,7 @@ function AcceptInviteContent() {
try { try {
await staffApi.acceptInvite({ await staffApi.acceptInvite({
token, token,
name: name.trim(), name: nameToSubmit || inviteInfo?.name || '',
password, password,
}); });
accepted = true; accepted = true;
@@ -115,7 +125,9 @@ function AcceptInviteContent() {
return ( return (
<div className="min-h-[100dvh] app-web-bg flex items-center justify-center px-4 py-8"> <div className="min-h-[100dvh] app-web-bg flex items-center justify-center px-4 py-8">
<div className="w-full max-w-md surface-card p-4 sm:p-6 space-y-5"> <div className="w-full max-w-md surface-card p-4 sm:p-6 space-y-5">
<h1 className="text-lg sm:text-xl font-semibold text-text-primary">{t('acceptInviteTitle')}</h1> <h1 className="text-lg sm:text-xl font-semibold text-text-primary">
{inviteInfo?.mode === 'password_setup' ? t('setPasswordTitle') : t('acceptInviteTitle')}
</h1>
{loading ? ( {loading ? (
<p className="text-sm text-text-secondary">{t('loadingInvitation')}</p> <p className="text-sm text-text-secondary">{t('loadingInvitation')}</p>
@@ -147,7 +159,9 @@ function AcceptInviteContent() {
{inviteInfo?.status !== 'ACCEPTED' && ( {inviteInfo?.status !== 'ACCEPTED' && (
<div className="space-y-3"> <div className="space-y-3">
<Input label={t('labelName')} value={name} onChange={(e) => setName(e.target.value)} /> {inviteInfo?.mode !== 'password_setup' && (
<Input label={t('labelName')} value={name} onChange={(e) => setName(e.target.value)} />
)}
<Input <Input
label={t('labelCreatePassword')} label={t('labelCreatePassword')}
type="password" type="password"
@@ -163,7 +177,7 @@ function AcceptInviteContent() {
passwordToggleLabels={passwordToggleLabels} passwordToggleLabels={passwordToggleLabels}
/> />
<Button type="button" fullWidth isLoading={submitting} onClick={() => onAccept()}> <Button type="button" fullWidth isLoading={submitting} onClick={() => onAccept()}>
{t('activateAccount')} {inviteInfo?.mode === 'password_setup' ? t('setPasswordSubmit') : t('activateAccount')}
</Button> </Button>
</div> </div>
)} )}

View File

@@ -52,12 +52,11 @@ export default function HomePage() {
<main className="flex-1 container mx-auto px-4 pt-28 sm:pt-32 pb-16 sm:pb-20"> <main className="flex-1 container mx-auto px-4 pt-28 sm:pt-32 pb-16 sm:pb-20">
<div className="max-w-4xl mx-auto text-center"> <div className="max-w-4xl mx-auto text-center">
<h1 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-semibold mb-4 sm:mb-6 leading-tight"> <h1 className="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-semibold mb-4 sm:mb-6 leading-tight text-balance max-w-3xl mx-auto">
{t('heroTitle')} {t('heroTitle')}
<span className="text-primary"> {t('heroHighlight')}</span>
</h1> </h1>
<p className="text-base sm:text-lg text-text-secondary mb-6 sm:mb-8 max-w-2xl mx-auto"> <p className="text-base sm:text-lg text-text-secondary mb-6 sm:mb-8 max-w-3xl mx-auto">
{t('heroSubtitle')} {t('heroSubtitle')}
</p> </p>

View File

@@ -54,7 +54,7 @@ export default async function LocaleLayout({
setRequestLocale(locale); setRequestLocale(locale);
const messages = await getMessages(); const messages = await getMessages();
const themeInit = `(function(){try{var k=${JSON.stringify(THEME_STORAGE_KEY)};var t=localStorage.getItem(k);document.documentElement.setAttribute('data-theme',t==='light'||t==='dark'?t:'dark');}catch(e){document.documentElement.setAttribute('data-theme','dark');}})();`; const themeInit = `(function(){try{var k=${JSON.stringify(THEME_STORAGE_KEY)};var t=localStorage.getItem(k);document.documentElement.setAttribute('data-theme',t==='light'||t==='dark'?t:'light');}catch(e){document.documentElement.setAttribute('data-theme','light');}})();`;
const dir = isRtlLocale(locale) ? 'rtl' : 'ltr'; const dir = isRtlLocale(locale) ? 'rtl' : 'ltr';
const fontSans = isRtlLocale(locale) const fontSans = isRtlLocale(locale)
? 'var(--font-vazirmatn), var(--font-noto-sans-arabic), system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif' ? 'var(--font-vazirmatn), var(--font-noto-sans-arabic), system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif'
@@ -65,6 +65,7 @@ export default async function LocaleLayout({
lang={localeHtmlLang(locale)} lang={localeHtmlLang(locale)}
dir={dir} dir={dir}
data-locale={locale} data-locale={locale}
data-theme="light"
className={`${vazirmatn.variable} ${notoSansArabic.variable}`} className={`${vazirmatn.variable} ${notoSansArabic.variable}`}
style={{ ['--font-sans' as never]: fontSans }} style={{ ['--font-sans' as never]: fontSans }}
suppressHydrationWarning suppressHydrationWarning

View File

@@ -13,6 +13,7 @@ const TYPE_I18N: Record<UserNotificationType, string> = {
CASE_IMPORTANT: 'typeCaseImportant', CASE_IMPORTANT: 'typeCaseImportant',
TASK_COMPLETED: 'typeTaskCompleted', TASK_COMPLETED: 'typeTaskCompleted',
TASK_ASSIGNED: 'typeTaskAssigned', TASK_ASSIGNED: 'typeTaskAssigned',
CASE_COMPLETED: 'typeCaseCompleted',
CONNECTION_REQUEST: 'typeConnectionRequest', CONNECTION_REQUEST: 'typeConnectionRequest',
STAFF_INVITE: 'typeStaffInvite', STAFF_INVITE: 'typeStaffInvite',
}; };
@@ -74,6 +75,17 @@ export function notificationContextLine(
if (clinicName) parts.push(clinicName); if (clinicName) parts.push(clinicName);
if (prosthesisLabel) parts.push(prosthesisLabel); if (prosthesisLabel) parts.push(prosthesisLabel);
break; break;
case 'CASE_COMPLETED':
if (patientName) parts.push(patientName);
if (item.href.startsWith('/treatment')) {
if (labName) parts.push(labName);
} else if (clinicName) {
parts.push(clinicName);
} else if (labName) {
parts.push(labName);
}
if (prosthesisLabel) parts.push(prosthesisLabel);
break;
case 'LAB_COMMENT_CLINIC': case 'LAB_COMMENT_CLINIC':
if (patientName) parts.push(patientName); if (patientName) parts.push(patientName);
if (labName) parts.push(labName); if (labName) parts.push(labName);

View File

@@ -79,6 +79,10 @@ function canShareStaffInviteLink(member: StaffMemberDto): boolean {
); );
} }
function canIssuePasswordSetup(member: StaffMemberDto, actorUserId?: string): boolean {
return !member.isOwner && member.isActive && member.userId !== actorUserId;
}
function canDisableStaff(member: StaffMemberDto): boolean { function canDisableStaff(member: StaffMemberDto): boolean {
return !member.isOwner && member.isActive; return !member.isOwner && member.isActive;
} }
@@ -189,6 +193,12 @@ export function StaffPage() {
invitationStatus: 'PENDING' | 'ACCEPTED'; invitationStatus: 'PENDING' | 'ACCEPTED';
} | null>(null); } | null>(null);
const [pendingInviteLinks, setPendingInviteLinks] = useState<Record<string, StoredInviteLink>>({}); const [pendingInviteLinks, setPendingInviteLinks] = useState<Record<string, StoredInviteLink>>({});
const [lastPasswordSetupInfo, setLastPasswordSetupInfo] = useState<{
membershipId: string;
name: string;
email: string;
invitationUrl: string;
} | null>(null);
const [editing, setEditing] = useState<StaffMemberDto | null>(null); const [editing, setEditing] = useState<StaffMemberDto | null>(null);
const [editStep, setEditStep] = useState<1 | 2>(1); const [editStep, setEditStep] = useState<1 | 2>(1);
@@ -205,6 +215,8 @@ export function StaffPage() {
const [disablingMembershipId, setDisablingMembershipId] = useState<string | null>(null); const [disablingMembershipId, setDisablingMembershipId] = useState<string | null>(null);
const [enableTarget, setEnableTarget] = useState<StaffMemberDto | null>(null); const [enableTarget, setEnableTarget] = useState<StaffMemberDto | null>(null);
const [enablingMembershipId, setEnablingMembershipId] = useState<string | null>(null); const [enablingMembershipId, setEnablingMembershipId] = useState<string | null>(null);
const [clearPasswordTarget, setClearPasswordTarget] = useState<StaffMemberDto | null>(null);
const [clearingMembershipId, setClearingMembershipId] = useState<string | null>(null);
const canEdit = useMemo(() => canEditStaff(currentOrganization), [currentOrganization]); const canEdit = useMemo(() => canEditStaff(currentOrganization), [currentOrganization]);
const highlightMembershipIds = useMemo( const highlightMembershipIds = useMemo(
@@ -520,6 +532,45 @@ export function StaffPage() {
} }
} }
async function issuePasswordSetupLink(member: StaffMemberDto, copyToClipboard: boolean) {
setClearingMembershipId(member.id);
toast.setError('');
try {
const res = await staffApi.clearPassword(member.id);
setLastPasswordSetupInfo({
membershipId: member.id,
name: member.name,
email: member.email,
invitationUrl: res.data.invitationUrl,
});
setClearPasswordTarget(null);
setEditing(null);
setEditStep(1);
await load();
toast.showSuccess(t('successPasswordCleared', { name: member.name }));
if (copyToClipboard) {
try {
await navigator.clipboard.writeText(res.data.invitationUrl);
setCopiedInviteMembershipId(member.id);
setTimeout(() => setCopiedInviteMembershipId(null), 1500);
} catch {
/* banner still shows the URL */
}
}
} catch (e) {
toast.showError(getUserFacingError(e, tErrors, t('errorClearPassword')));
} finally {
setClearingMembershipId(null);
}
}
async function confirmClearPassword() {
if (!clearPasswordTarget || !canIssuePasswordSetup(clearPasswordTarget, user?.id)) {
return;
}
await issuePasswordSetupLink(clearPasswordTarget, true);
}
if (!currentOrganization || !canViewStaff(currentOrganization)) { if (!currentOrganization || !canViewStaff(currentOrganization)) {
return ( return (
<p className="text-sm text-text-secondary">{t('redirecting')}</p> <p className="text-sm text-text-secondary">{t('redirecting')}</p>
@@ -640,6 +691,54 @@ export function StaffPage() {
</div> </div>
)} )}
{lastPasswordSetupInfo && (
<div className="relative rounded-[var(--radius-md)] border border-border-strong bg-background-secondary/90 px-4 py-3 pr-12 shadow-[inset_0_1px_0_rgba(255,255,255,0.04)] space-y-3">
<button
type="button"
className="absolute right-2 top-2 p-1.5 rounded-[var(--radius-sm)] text-text-muted hover:text-text-primary hover:bg-background-card/80"
aria-label={tCommon('dismiss')}
onClick={() => setLastPasswordSetupInfo(null)}
>
<X className="w-4 h-4" />
</button>
<p className="text-sm text-text-primary pr-6">
{t('successPasswordCleared', { name: lastPasswordSetupInfo.name })}
</p>
<div className="space-y-2 pt-1 border-t border-border/60">
<p className="text-xs font-medium text-text-secondary uppercase tracking-wide">
{t('passwordSetupLinkHeading')}
</p>
<code className="block text-sm px-2 py-1.5 rounded-[var(--radius-sm)] bg-background-card border border-border font-mono break-all">
{lastPasswordSetupInfo.invitationUrl}
</code>
<Button
type="button"
variant="outline"
size="sm"
isLoading={clearingMembershipId === lastPasswordSetupInfo.membershipId}
onClick={async () => {
setClearingMembershipId(lastPasswordSetupInfo.membershipId);
toast.setError('');
try {
await navigator.clipboard.writeText(lastPasswordSetupInfo.invitationUrl);
setCopiedInviteMembershipId(lastPasswordSetupInfo.membershipId);
setTimeout(() => setCopiedInviteMembershipId(null), 1500);
} catch (e) {
toast.showError(getUserFacingError(e, tErrors, t('errorClearPassword')));
} finally {
setClearingMembershipId(null);
}
}}
>
{copiedInviteMembershipId === lastPasswordSetupInfo.membershipId
? tCommon('copied')
: tCommon('copyLink')}
</Button>
<p className="text-xs text-text-muted">{t('passwordSetupShareHint')}</p>
</div>
</div>
)}
{loading ? ( {loading ? (
<p className="text-sm text-text-secondary">{t('loadingTeam')}</p> <p className="text-sm text-text-secondary">{t('loadingTeam')}</p>
) : ( ) : (
@@ -1061,6 +1160,59 @@ export function StaffPage() {
</div> </div>
)} )}
{clearPasswordTarget && (
<div className="fixed inset-0 z-[60] flex items-end sm:items-center justify-center p-0 sm:p-4 bg-black/55">
<div
className="surface-card w-full sm:max-w-md max-h-[90dvh] overflow-y-auto p-4 sm:p-5 space-y-4 shadow-xl rounded-t-[var(--radius-lg)] sm:rounded-[var(--radius-lg)]"
role="dialog"
aria-modal="true"
aria-labelledby="clear-password-title"
>
<div className="flex items-start justify-between gap-2">
<h2 id="clear-password-title" className="text-lg font-semibold text-text-primary pr-2">
{t('removePasswordModalTitle')}
</h2>
<DialogCloseButton
onClick={() => {
if (clearingMembershipId) return;
setClearPasswordTarget(null);
}}
/>
</div>
<p className="text-sm text-text-secondary">
{t('removePasswordConfirm', {
name: clearPasswordTarget.name,
email: clearPasswordTarget.email,
})}
</p>
<ul className="text-sm text-text-secondary space-y-2 list-disc ps-5">
<li>{t('removePasswordBullet1')}</li>
<li>{t('removePasswordBullet2')}</li>
<li>{t('removePasswordBullet3')}</li>
</ul>
<div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 pt-1">
<Button
type="button"
variant="outline"
disabled={Boolean(clearingMembershipId)}
onClick={() => setClearPasswordTarget(null)}
>
{tCommon('cancel')}
</Button>
<Button
type="button"
variant="danger"
isLoading={clearingMembershipId === clearPasswordTarget.id}
disabled={Boolean(clearingMembershipId)}
onClick={() => confirmClearPassword()}
>
{t('removePasswordButton')}
</Button>
</div>
</div>
</div>
)}
{editing && ( {editing && (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-0 sm:p-4 bg-black/50"> <div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-0 sm:p-4 bg-black/50">
<div <div
@@ -1099,6 +1251,31 @@ export function StaffPage() {
organizationType={currentOrganization?.type} organizationType={currentOrganization?.type}
/> />
</div> </div>
{canEdit && canIssuePasswordSetup(editing, user?.id) && (
<div className="pt-3 border-t border-border/60">
{editing.hasPassword ? (
<Button
type="button"
variant="danger"
size="sm"
disabled={Boolean(clearingMembershipId)}
onClick={() => setClearPasswordTarget(editing)}
>
{t('removePassword')}
</Button>
) : (
<Button
type="button"
variant="outline"
size="sm"
isLoading={clearingMembershipId === editing.id}
onClick={() => void issuePasswordSetupLink(editing, true)}
>
{t('copyPasswordSetupLink')}
</Button>
)}
</div>
)}
</> </>
) : editLoadingWorkingHours ? ( ) : editLoadingWorkingHours ? (
<p className="text-sm text-text-secondary">{t('loadingWorkingHours')}</p> <p className="text-sm text-text-secondary">{t('loadingWorkingHours')}</p>

View File

@@ -11,6 +11,7 @@ export interface StaffMemberDto {
invitationStatus: 'ACTIVE' | 'PENDING' | 'EXPIRED' | 'DISABLED'; invitationStatus: 'ACTIVE' | 'PENDING' | 'EXPIRED' | 'DISABLED';
invitedAt: string | null; invitedAt: string | null;
acceptedAt: string | null; acceptedAt: string | null;
hasPassword: boolean;
permissions: string[] | null; permissions: string[] | null;
} }
@@ -46,6 +47,7 @@ export interface PreviewInviteResponse {
organizationName: string; organizationName: string;
expiresAt: string; expiresAt: string;
status: 'PENDING' | 'ACCEPTED'; status: 'PENDING' | 'ACCEPTED';
mode: 'join' | 'password_setup';
}; };
} }
@@ -79,6 +81,21 @@ export const staffApi = {
return response.data; return response.data;
}, },
clearPassword: async (
membershipId: string,
): Promise<{
success: boolean;
data: {
membershipId: string;
invitationId: string;
email: string;
invitationUrl: string;
};
}> => {
const response = await apiClient.post(`/staff/members/${membershipId}/clear-password`);
return response.data;
},
previewInvite: async (token: string): Promise<PreviewInviteResponse> => { previewInvite: async (token: string): Promise<PreviewInviteResponse> => {
const response = await apiClient.get(`/staff/invitations/preview?token=${encodeURIComponent(token)}`); const response = await apiClient.get(`/staff/invitations/preview?token=${encodeURIComponent(token)}`);
return response.data; return response.data;

View File

@@ -43,6 +43,8 @@ export function formatLabCaseActivityLine(
}); });
case 'CASE_IMPORTANT': case 'CASE_IMPORTANT':
return t('activityCaseImportant', { actor, date }); return t('activityCaseImportant', { actor, date });
case 'CASE_COMPLETED':
return t('activityCaseCompleted', { actor, date });
case 'CASE_AMENDED': case 'CASE_AMENDED':
return t('activityCaseAmended', { actor, date }); return t('activityCaseAmended', { actor, date });
default: default:

View File

@@ -3,14 +3,14 @@ export const THEME_STORAGE_KEY = 'dyolink-theme';
export type ThemeMode = 'light' | 'dark'; export type ThemeMode = 'light' | 'dark';
export function getStoredTheme(): ThemeMode { export function getStoredTheme(): ThemeMode {
if (typeof window === 'undefined') return 'dark'; if (typeof window === 'undefined') return 'light';
try { try {
const v = localStorage.getItem(THEME_STORAGE_KEY); const v = localStorage.getItem(THEME_STORAGE_KEY);
if (v === 'light' || v === 'dark') return v; if (v === 'light' || v === 'dark') return v;
} catch { } catch {
/* ignore */ /* ignore */
} }
return 'dark'; return 'light';
} }
export function applyTheme(mode: ThemeMode) { export function applyTheme(mode: ThemeMode) {

View File

@@ -262,13 +262,17 @@ body {
} }
html { html {
color-scheme: dark; color-scheme: light;
} }
html[data-theme='light'] { html[data-theme='light'] {
color-scheme: light; color-scheme: light;
} }
html[data-theme='dark'] {
color-scheme: dark;
}
/* Minimal RTL layer — refine incrementally. */ /* Minimal RTL layer — refine incrementally. */
html[dir='rtl'] body { html[dir='rtl'] body {
direction: rtl; direction: rtl;
@@ -338,9 +342,7 @@ select option {
} }
:root[data-theme='dark'] select.form-select, :root[data-theme='dark'] select.form-select,
:root[data-theme='dark'] select, :root[data-theme='dark'] select {
:root:not([data-theme='light']) select.form-select,
:root:not([data-theme='light']) select {
color-scheme: dark; color-scheme: dark;
} }
@@ -358,8 +360,7 @@ select option {
} }
} }
:root[data-theme='dark'] .surface-card, :root[data-theme='dark'] .surface-card {
:root:not([data-theme='light']) .surface-card {
background: color-mix(in srgb, var(--color-card-background) 82%, var(--color-background-primary)); background: color-mix(in srgb, var(--color-card-background) 82%, var(--color-background-primary));
} }

View File

@@ -5,7 +5,8 @@ export type LabCaseActivityType =
| 'CASE_IMPORTANT' | 'CASE_IMPORTANT'
| 'CASE_AMENDED' | 'CASE_AMENDED'
| 'TASK_COMPLETED' | 'TASK_COMPLETED'
| 'TASK_ASSIGNED'; | 'TASK_ASSIGNED'
| 'CASE_COMPLETED';
export interface LabCaseActivityItem { export interface LabCaseActivityItem {
id: string; id: string;

View File

@@ -6,6 +6,7 @@ export type UserNotificationType =
| 'CASE_IMPORTANT' | 'CASE_IMPORTANT'
| 'TASK_COMPLETED' | 'TASK_COMPLETED'
| 'TASK_ASSIGNED' | 'TASK_ASSIGNED'
| 'CASE_COMPLETED'
| 'CONNECTION_REQUEST' | 'CONNECTION_REQUEST'
| 'STAFF_INVITE'; | 'STAFF_INVITE';

View File

@@ -26,6 +26,8 @@ https://nudentic.ir
`:latest` is **staging only** (wixur.ir baked in). Production compose must pin `TAG=v1.0.1`. `:latest` is **staging only** (wixur.ir baked in). Production compose must pin `TAG=v1.0.1`.
**Tags are immutable.** CI clones `--branch $tag`. Do not move/reuse an existing `v*` to pick up a Dockerfile or copy fix — cut a new version. The frontend standalone image must `COPY` `public/` (`frontend/Dockerfile`); without it, `/prosthesis-catalog/*.svg` 404s.
### One-time on the Linux server ### One-time on the Linux server
1. **HTTP registry** — Gitea is `http://wixur.ir:3000`. In `/etc/docker/daemon.json`: 1. **HTTP registry** — Gitea is `http://wixur.ir:3000`. In `/etc/docker/daemon.json`:
@@ -136,7 +138,7 @@ This pushes:
- `dyolink/dyolink-backend:latest` - `dyolink/dyolink-backend:latest`
- `dyolink/dyolink-frontend:latest` - `dyolink/dyolink-frontend:latest`
**When to rebuild:** domain changes, frontend env (`NEXT_PUBLIC_*`) changes, or new app release. **When to rebuild:** domain changes, frontend env (`NEXT_PUBLIC_*`) changes, files under `frontend/public/`, or a new app release.
--- ---

View File

@@ -35,7 +35,7 @@ http://wixur.ir:3000 → Gitea (native, no Docker)
| frontend | `<REGISTRY_HOST>/<owner>/dyolink-frontend:<sha>` | | frontend | `<REGISTRY_HOST>/<owner>/dyolink-frontend:<sha>` |
| nginx | `nginx:alpine` | | nginx | `nginx:alpine` |
Frontend public URLs are **baked in at build time** via `PUBLIC_BASE_URL`. After changing the public URL, re-run the Gitea workflow (or push to `master`) and set `FRONTEND_URL` in `C:\dyolink\secrets\backend.staging.env` to the same origin. Frontend public URLs are **baked in at build time** via `PUBLIC_BASE_URL`. After changing the public URL, re-run the Gitea workflow (or push to `master`) and set `FRONTEND_URL` in `C:\dyolink\secrets\backend.staging.env` to the same origin. The same `frontend/Dockerfile` must copy `public/` into the standalone image (catalog icons).
--- ---
@@ -300,6 +300,7 @@ On the Windows host, from repo `infrastructure/`:
| Runner can't register on public IP | Use `http://127.0.0.1:3000` for `--instance` | | Runner can't register on public IP | Use `http://127.0.0.1:3000` for `--instance` |
| Variable name rejected in Gitea | No `GITEA_*` / `GITHUB_*` prefixes; use `CLONE_HOST` | | Variable name rejected in Gitea | No `GITEA_*` / `GITHUB_*` prefixes; use `CLONE_HOST` |
| `413 Request Entity Too Large` on `docker push` to `https://gitea.wixur.ir/v2/…/blobs/uploads` | Nginx (or Cloudflare) in front of Gitea is rejecting the image layer. **Fix the proxy** (then `nginx -s reload`): in the `server { server_name gitea.wixur.ir; }` block set `client_max_body_size 0;` and `proxy_request_buffering off;` — snippet: [`nginx/windows-gitea.wixur.snippet.conf`](nginx/windows-gitea.wixur.snippet.conf). **Or skip the proxy:** set `REGISTRY_HOST=host.docker.internal:3000` (and Gitea `ROOT_URL`) so CI pushes to `:3000`. If the hostname is orange-clouded on Cloudflare, grey-cloud it (free plan caps uploads at 100MB). | | `413 Request Entity Too Large` on `docker push` to `https://gitea.wixur.ir/v2/…/blobs/uploads` | Nginx (or Cloudflare) in front of Gitea is rejecting the image layer. **Fix the proxy** (then `nginx -s reload`): in the `server { server_name gitea.wixur.ir; }` block set `client_max_body_size 0;` and `proxy_request_buffering off;` — snippet: [`nginx/windows-gitea.wixur.snippet.conf`](nginx/windows-gitea.wixur.snippet.conf). **Or skip the proxy:** set `REGISTRY_HOST=host.docker.internal:3000` (and Gitea `ROOT_URL`) so CI pushes to `:3000`. If the hostname is orange-clouded on Cloudflare, grey-cloud it (free plan caps uploads at 100MB). |
| `TLS handshake timeout` to `registry-1.docker.io` / `node:20-alpine` | Docker Hub is blocked or slow from the Windows runner. CI pulls `node:20-alpine` from **Arvan / ECR Public / GCR**, then pushes `<REGISTRY_PREFIX>/node:20-alpine` to Gitea (later builds skip Hub). Optional variable `NODE_IMAGE_SOURCE` (comma-separated image refs). One-time on the runner: `docker pull docker.arvancloud.ir/library/node:20-alpine` then tag/push to Gitea. |
| `docker login` connection refused on `127.0.0.1:3000` | **Docker Desktop on Windows:** set `REGISTRY_HOST=host.docker.internal:3000`, add it to insecure-registries, set Gitea `ROOT_URL=http://host.docker.internal:3000/`. Keep `CLONE_HOST=127.0.0.1:3000` for git. | | `docker login` connection refused on `127.0.0.1:3000` | **Docker Desktop on Windows:** set `REGISTRY_HOST=host.docker.internal:3000`, add it to insecure-registries, set Gitea `ROOT_URL=http://host.docker.internal:3000/`. Keep `CLONE_HOST=127.0.0.1:3000` for git. |
| `docker login` / push denied, redirect to public IP | Set Gitea `ROOT_URL` to a host Docker can reach (`host.docker.internal:3000` on Windows Docker Desktop). | | `docker login` / push denied, redirect to public IP | Set Gitea `ROOT_URL` to a host Docker can reach (`host.docker.internal:3000` on Windows Docker Desktop). |
| `server gave HTTP response to HTTPS client` | Add registry host to Docker **insecure-registries**, restart Docker | | `server gave HTTP response to HTTPS client` | Add registry host to Docker **insecure-registries**, restart Docker |
@@ -330,6 +331,7 @@ docker logs dyolink_frontend_staging --tail 50
| Path | Role | | Path | Role |
|------|------| |------|------|
| `.gitea/workflows/registry-build-deploy.yml` | CI: build, push, deploy | | `.gitea/workflows/registry-build-deploy.yml` | CI: build, push, deploy |
| `infrastructure/scripts/ci-resolve-node-image.ps1` | CI: cache `node:20-alpine` on Gitea so builds do not depend on Docker Hub |
| `infrastructure/docker-compose.registry.yml` | Staging stack (pull-only images) | | `infrastructure/docker-compose.registry.yml` | Staging stack (pull-only images) |
| `infrastructure/deploy.registry.env.example` | Manual deploy env template | | `infrastructure/deploy.registry.env.example` | Manual deploy env template |
| `infrastructure/database.staging.env.example` | Postgres secrets template | | `infrastructure/database.staging.env.example` | Postgres secrets template |

View File

@@ -18,6 +18,8 @@ FRONTEND_URL=https://nudentic.ir
# Change these — the code defaults are only for local development. # Change these — the code defaults are only for local development.
ADMINJS_EMAIL=admin@nudentic.ir ADMINJS_EMAIL=admin@nudentic.ir
ADMINJS_PASSWORD=CHANGE_ME_STRONG_ADMINJS_PASSWORD ADMINJS_PASSWORD=CHANGE_ME_STRONG_ADMINJS_PASSWORD
# Optional override; Docker image sets /app/adminjs-tmp (do not use ".adminjs").
# ADMIN_JS_TMP_DIR=/app/adminjs-tmp
# Required for HTTPS — browsers reject Secure cookies over plain HTTP # Required for HTTPS — browsers reject Secure cookies over plain HTTP
COOKIE_SECURE=true COOKIE_SECURE=true

View File

@@ -15,6 +15,8 @@ FRONTEND_URL=https://wixur.ir
# AdminJS at https://wixur.ir/admin (nginx proxies /admin to the API). # AdminJS at https://wixur.ir/admin (nginx proxies /admin to the API).
ADMINJS_EMAIL=admin@wixur.ir ADMINJS_EMAIL=admin@wixur.ir
ADMINJS_PASSWORD=CHANGE_ME_STRONG_ADMINJS_PASSWORD ADMINJS_PASSWORD=CHANGE_ME_STRONG_ADMINJS_PASSWORD
# Optional override; Docker image sets /app/adminjs-tmp (do not use ".adminjs").
# ADMIN_JS_TMP_DIR=/app/adminjs-tmp
# TLS is terminated on Windows nginx :443 — cookies must be Secure # TLS is terminated on Windows nginx :443 — cookies must be Secure
COOKIE_SECURE=true COOKIE_SECURE=true

View File

@@ -48,6 +48,7 @@ services:
NODE_ENV: production NODE_ENV: production
TZ: UTC TZ: UTC
PORT: "3000" PORT: "3000"
ADMIN_JS_TMP_DIR: /app/adminjs-tmp
SENTRY_ENVIRONMENT: production SENTRY_ENVIRONMENT: production
SENTRY_RELEASE: ${TAG:-latest} SENTRY_RELEASE: ${TAG:-latest}
expose: expose:

View File

@@ -47,6 +47,7 @@ services:
NODE_ENV: production NODE_ENV: production
TZ: UTC TZ: UTC
PORT: "3000" PORT: "3000"
ADMIN_JS_TMP_DIR: /app/adminjs-tmp
SENTRY_ENVIRONMENT: staging SENTRY_ENVIRONMENT: staging
SENTRY_RELEASE: ${IMAGE_TAG:-latest} SENTRY_RELEASE: ${IMAGE_TAG:-latest}
expose: expose:

View File

@@ -43,6 +43,7 @@ services:
NODE_ENV: production NODE_ENV: production
TZ: UTC TZ: UTC
PORT: "3000" PORT: "3000"
ADMIN_JS_TMP_DIR: /app/adminjs-tmp
SENTRY_ENVIRONMENT: staging SENTRY_ENVIRONMENT: staging
expose: expose:
- "3000" - "3000"

View File

@@ -0,0 +1,100 @@
# Prefer a Gitea-hosted node:20-alpine so docker build does not HEAD registry-1.docker.io.
# Order: Gitea -> optional NODE_IMAGE_SOURCE -> regional/official mirrors -> Docker Hub last.
# ASCII only: Windows PowerShell 5.1 + act_runner mis-parses backtick escapes in this file.
param(
[Parameter(Mandatory = $true)][string]$RegistryPrefix,
[string]$OutFile = '.ci-node-image',
[string]$HubImage = 'node:20-alpine',
[AllowEmptyString()]
[string]$ExtraSources = ''
)
$ErrorActionPreference = 'Continue'
$mirror = "$RegistryPrefix/node:20-alpine"
$nl = [char]10
function Test-Image([string]$Name) {
docker image inspect $Name 2>&1 | Out-Null
return ($LASTEXITCODE -eq 0)
}
function Invoke-Pull([string]$Name, [int]$Attempts) {
for ($i = 1; $i -le $Attempts; $i++) {
Write-Host "docker pull $Name (attempt $i/$Attempts)"
docker pull $Name
if ($LASTEXITCODE -eq 0) { return $true }
if ($i -lt $Attempts) { Start-Sleep -Seconds 5 }
}
return $false
}
function Save-Choice([string]$Name) {
$utf8 = New-Object System.Text.UTF8Encoding $false
$path = Join-Path (Get-Location) $OutFile
[System.IO.File]::WriteAllText($path, ($Name + $nl), $utf8)
Write-Host "NODE_IMAGE=$Name"
}
function Publish-Mirror([string]$Src) {
Write-Host "Tagging $Src as $mirror"
docker tag $Src $mirror
if ($LASTEXITCODE -ne 0) { return $false }
docker push $mirror
if ($LASTEXITCODE -ne 0) {
Write-Host "Could not push $mirror - docker build will use the local tag (legacy builder)."
$flag = Join-Path (Get-Location) '.ci-use-legacy-builder'
New-Item -ItemType File -Path $flag -Force | Out-Null
}
Save-Choice $mirror
return $true
}
if (Test-Image $mirror) {
Write-Host "Using local $mirror"
Save-Choice $mirror
exit 0
}
if (Invoke-Pull $mirror 1) {
Save-Choice $mirror
exit 0
}
$sources = New-Object System.Collections.ArrayList
if (-not [string]::IsNullOrWhiteSpace($ExtraSources)) {
foreach ($part in ($ExtraSources -split ',')) {
$src = $part.Trim()
if ($src.Length -gt 0) { [void]$sources.Add($src) }
}
}
# Iran-reachable proxy of Docker Hub official images, then public official mirrors, Hub last.
foreach ($src in @(
'docker.arvancloud.ir/library/node:20-alpine',
'public.ecr.aws/docker/library/node:20-alpine',
'mirror.gcr.io/library/node:20-alpine',
$HubImage
)) {
if (-not $sources.Contains($src)) { [void]$sources.Add($src) }
}
foreach ($src in $sources) {
if (Test-Image $src) {
Write-Host "Found local $src"
if (Publish-Mirror $src) { exit 0 }
}
}
foreach ($src in $sources) {
if (Invoke-Pull $src 2) {
if (Publish-Mirror $src) { exit 0 }
}
}
Write-Host "Could not pull node:20-alpine from Gitea, mirrors, or Docker Hub."
Write-Host "Set repository variable NODE_IMAGE_SOURCE to a reachable image, for example:"
Write-Host " docker.arvancloud.ir/library/node:20-alpine"
Write-Host "Or on the Windows runner:"
Write-Host " docker pull docker.arvancloud.ir/library/node:20-alpine"
Write-Host " docker tag docker.arvancloud.ir/library/node:20-alpine $mirror"
Write-Host " docker push $mirror"
exit 1