Compare commits
11 Commits
improvemen
...
improvemen
| Author | SHA1 | Date | |
|---|---|---|---|
| 19b5671b0d | |||
| 9f6eacd0fb | |||
| 52c4480c5d | |||
| 1ca8e6178e | |||
| 6c7e0574b9 | |||
| feafc3a219 | |||
| 8fa856a980 | |||
| 005e0b0394 | |||
| c1846f8e22 | |||
| 663eafa3e8 | |||
| 5cd3436d0c |
@@ -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 clinic’s 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).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
12
.cursor/rules/patients.mdc
Normal file
12
.cursor/rules/patients.mdc
Normal 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 clinic’s 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.
|
||||||
@@ -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
13
.cursor/rules/staff.mdc
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
---
|
||||||
|
description: Staff passwords — setup link only; never set another user’s 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).
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 today’s 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 clinic’s mobile is `PATIENT_MOBILE_UNAVAILABLE`, not a shared row. Patient search: same workspace patient with a live visit → no-op; else open today’s 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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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('/')
|
||||||
|
Write-Host "Building $prefix/dyolink-frontend:$tag (NODE_IMAGE=$nodeImage)"
|
||||||
|
$ok = $false
|
||||||
|
for ($i = 1; $i -le 3; $i++) {
|
||||||
|
Write-Host "docker build attempt $i/3"
|
||||||
docker build `
|
docker build `
|
||||||
--build-arg "NEXT_PUBLIC_API_URL=$base/api" `
|
--build-arg "NEXT_PUBLIC_API_URL=$base/api" `
|
||||||
--build-arg "NEXT_PUBLIC_APP_URL=$base" `
|
--build-arg "NEXT_PUBLIC_APP_URL=$base" `
|
||||||
--build-arg "NEXT_PUBLIC_APP_NAME=Dyolink" `
|
--build-arg "NEXT_PUBLIC_APP_NAME=Dyolink" `
|
||||||
--build-arg "NEXT_PUBLIC_SENTRY_DSN=$env:NEXT_PUBLIC_SENTRY_DSN" `
|
--build-arg "NEXT_PUBLIC_SENTRY_DSN=$env:NEXT_PUBLIC_SENTRY_DSN" `
|
||||||
--build-arg "NEXT_PUBLIC_SENTRY_ENVIRONMENT=production" `
|
--build-arg "NEXT_PUBLIC_SENTRY_ENVIRONMENT=production" `
|
||||||
-t "$env:REGISTRY_PREFIX/dyolink-frontend:$tag" `
|
--build-arg "NODE_IMAGE=$nodeImage" `
|
||||||
|
-t "$prefix/dyolink-frontend:$tag" `
|
||||||
./frontend
|
./frontend
|
||||||
docker push "$env:REGISTRY_PREFIX/dyolink-frontend:$tag"
|
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 }
|
||||||
|
|||||||
@@ -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)
|
||||||
|
$prefix = "${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_OWNER }}".Trim()
|
||||||
|
if ([string]::IsNullOrWhiteSpace($tag) -or [string]::IsNullOrWhiteSpace($prefix)) {
|
||||||
|
Write-Host "Missing github.sha, REGISTRY_HOST, or REGISTRY_OWNER"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
$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 `
|
docker build `
|
||||||
-t "$env:REGISTRY_PREFIX/dyolink-backend:$tag" `
|
--build-arg "NODE_IMAGE=$nodeImage" `
|
||||||
-t "$env:REGISTRY_PREFIX/dyolink-backend:latest" `
|
-t "$prefix/dyolink-backend:$tag" `
|
||||||
|
-t "$prefix/dyolink-backend:latest" `
|
||||||
./backend
|
./backend
|
||||||
docker push "$env:REGISTRY_PREFIX/dyolink-backend:$tag"
|
if ($LASTEXITCODE -eq 0) { $ok = $true; break }
|
||||||
docker push "$env:REGISTRY_PREFIX/dyolink-backend:latest"
|
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
|
||||||
|
$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-frontend:$tag (NODE_IMAGE=$nodeImage)"
|
||||||
|
$ok = $false
|
||||||
|
for ($i = 1; $i -le 3; $i++) {
|
||||||
|
Write-Host "docker build attempt $i/3"
|
||||||
docker build `
|
docker build `
|
||||||
--build-arg "NEXT_PUBLIC_API_URL=$base/api" `
|
--build-arg "NEXT_PUBLIC_API_URL=$base/api" `
|
||||||
--build-arg "NEXT_PUBLIC_APP_URL=$base" `
|
--build-arg "NEXT_PUBLIC_APP_URL=$base" `
|
||||||
--build-arg "NEXT_PUBLIC_APP_NAME=Dyolink" `
|
--build-arg "NEXT_PUBLIC_APP_NAME=Dyolink" `
|
||||||
--build-arg "NEXT_PUBLIC_SENTRY_DSN=$env:NEXT_PUBLIC_SENTRY_DSN" `
|
--build-arg "NEXT_PUBLIC_SENTRY_DSN=$env:NEXT_PUBLIC_SENTRY_DSN" `
|
||||||
--build-arg "NEXT_PUBLIC_SENTRY_ENVIRONMENT=staging" `
|
--build-arg "NEXT_PUBLIC_SENTRY_ENVIRONMENT=staging" `
|
||||||
-t "$env:REGISTRY_PREFIX/dyolink-frontend:$tag" `
|
--build-arg "NODE_IMAGE=$nodeImage" `
|
||||||
-t "$env:REGISTRY_PREFIX/dyolink-frontend:latest" `
|
-t "$prefix/dyolink-frontend:$tag" `
|
||||||
|
-t "$prefix/dyolink-frontend:latest" `
|
||||||
./frontend
|
./frontend
|
||||||
docker push "$env:REGISTRY_PREFIX/dyolink-frontend:$tag"
|
if ($LASTEXITCODE -eq 0) { $ok = $true; break }
|
||||||
docker push "$env:REGISTRY_PREFIX/dyolink-frontend:latest"
|
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 }
|
||||||
|
|||||||
@@ -62,7 +62,11 @@ frontend/src/
|
|||||||
- **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`.
|
||||||
|
|
||||||
|
**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:** `LabCaseActivity` + `GET /notifications/tab-counts` (lab Cases/Tasks split, clinic Treatment) — live via inbox Socket.IO → `notifyTabBadgesChanged()` + soft list refresh — see `.cursor/skills/lab-tasks/SKILL.md`, `.cursor/skills/tab-badges/SKILL.md`, `.cursor/skills/notifications-inbox/SKILL.md`.
|
**Lab Tasks tab:** Newest case first; steps ordered 1→N; case grouping when sorted by date; `stepCompleted` filter; filter by case source (`origin`: received vs generated); prosthesis colors from catalog; job titles show the picker path to the leaf (`prosthesisJobPath.ts`); task assignment in **Cases** (compact row: status + assignee + last update); on **Tasks**, all staff see every task but only assignee (or unassigned pool) can change status — others see “Assigned to {name}” instead of the status dropdown; **case due dates** set/edited in clinic Treatment lab dispatch, shown on lab Cases/Tasks with overdue filter + sort; completing **`intraoral_scan`** completes every scan task in that case (case-scoped; catalog first step for all prosthesis types); **mobile:** larger task status controls, sticky case header when grouped; **tab badges:** `LabCaseActivity` + `GET /notifications/tab-counts` (lab Cases/Tasks split, clinic Treatment) — live via inbox Socket.IO → `notifyTabBadgesChanged()` + soft list refresh — see `.cursor/skills/lab-tasks/SKILL.md`, `.cursor/skills/tab-badges/SKILL.md`, `.cursor/skills/notifications-inbox/SKILL.md`.
|
||||||
|
|
||||||
@@ -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`.
|
||||||
|
|
||||||
|
|||||||
@@ -106,4 +106,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.
|
||||||
|
|||||||
@@ -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` |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 clinic’s 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,8 +21,14 @@ export class PatientsService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
if (
|
||||||
|
!existing.isWalkIn &&
|
||||||
|
existing.createdByOrganizationId === organizationId
|
||||||
|
) {
|
||||||
return { success: true, data: existing, existing: true as const };
|
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({
|
||||||
data: {
|
data: {
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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").
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.",
|
||||||
@@ -1214,6 +1228,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 +1252,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.",
|
||||||
|
|||||||
@@ -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": "کپی لینک دعوتنامه امکانپذیر نبود.",
|
||||||
@@ -1215,6 +1229,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 +1253,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": "سازمان مقابل اشتراک فعال ندارد.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
@@ -1214,6 +1228,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 +1252,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.",
|
||||||
|
|||||||
@@ -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">
|
||||||
|
{inviteInfo?.mode !== 'password_setup' && (
|
||||||
<Input label={t('labelName')} value={name} onChange={(e) => setName(e.target.value)} />
|
<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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
100
infrastructure/scripts/ci-resolve-node-image.ps1
Normal file
100
infrastructure/scripts/ci-resolve-node-image.ps1
Normal 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
|
||||||
Reference in New Issue
Block a user