Compare commits
27 Commits
feature/ci
...
e48eeb18c4
| Author | SHA1 | Date | |
|---|---|---|---|
| e48eeb18c4 | |||
| 63bad336b9 | |||
| 78e756b78f | |||
| 118853ce73 | |||
| d226a2b294 | |||
| 2f92f2745b | |||
| 4bf1bf4389 | |||
| 54e4fa8628 | |||
| 1b22dfa36d | |||
| 215aa1fd87 | |||
| 17d3c5ca25 | |||
| 647ff65b00 | |||
| 6ba1fad56c | |||
| 2a42f20bff | |||
| 7b50134d03 | |||
| 56d413944a | |||
| 8757a8952c | |||
| ff2bd09669 | |||
| 01b4ed7633 | |||
| de6e259932 | |||
| b4aff39797 | |||
| f3fb8736ab | |||
| 5fcb72508e | |||
| 5878bd62e4 | |||
| dfd376d97a | |||
| 4764401766 | |||
| ca4d28a976 |
@@ -40,7 +40,7 @@ QR + URL for **sent** cases; focus page `/lab-case/[token]`. Auth redirect via `
|
||||
|
||||
## API errors
|
||||
|
||||
Logical failures: `AppException(ErrorCode.X)` → `errors.X` in en/fa/nl. UI: `getUserFacingError`. Unexpected 500s / client crashes: GlitchTip via Sentry SDKs (`SENTRY_DSN` / `NEXT_PUBLIC_SENTRY_DSN`). Skill: `.cursor/skills/api-errors/SKILL.md`. Appointments/working hours: client IANA `timeZone` (never Node local `getHours()`).
|
||||
Logical failures: `AppException(ErrorCode.X)` → `errors.X` in en/fa/nl. UI: `getUserFacingError`. Skill: `.cursor/skills/api-errors/SKILL.md`. Appointments/working hours: client IANA `timeZone` (never Node local `getHours()`).
|
||||
|
||||
## Notifications (inbox + live tabs)
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
---
|
||||
description: GlitchTip error tracking via Sentry SDKs — 500s only, no PHI
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Error tracking (GlitchTip)
|
||||
|
||||
Self-hosted GlitchTip at `https://errors.wixur.ir`. Apps use official Sentry SDKs (`@sentry/nestjs`, `@sentry/nextjs`) pointed at that DSN — not sentry.io.
|
||||
|
||||
## What to report
|
||||
|
||||
- Backend: HTTP **500+** in `HttpExceptionFilter` only. Do **not** send `AppException` 4xx.
|
||||
- Frontend: render crashes (`error.tsx` / `global-error.tsx`) and axios **5xx / network** failures only.
|
||||
- No session replay. Scrub cookies, JWT, request bodies, emails.
|
||||
|
||||
## Config
|
||||
|
||||
- Backend DSN: `SENTRY_DSN` in `backend.env` / `backend.staging.env` (runtime).
|
||||
- Frontend DSN: `NEXT_PUBLIC_SENTRY_DSN` baked at Docker build (Gitea var `NEXT_PUBLIC_SENTRY_DSN`).
|
||||
- Empty DSN = tracking off (local default).
|
||||
@@ -10,17 +10,16 @@ alwaysApply: false
|
||||
- **Current draft** preview: omitted in live editing (form is the source). History browse still uses preview + **Load into workspace**.
|
||||
- **Entry:** Type dropdown + `TreatmentDetailAttachmentsStrip` on one row, bordered chart (Cases chrome), then full-width auto-growing Notes. No stepper unless prosthesis — then `WizardStepper` Treatment → Lab with Back/Next. Chip switches reset to the treatment form unless `pendingEntryStepRef` requests Lab (shipments rail open). Lab-dependent chips use colored sent/unsent text; sent date is on Lab dispatch. Lab dispatch keeps comments.
|
||||
- **Tooth hits:** unrotated full cell, `pointerdown` only (not `click` too — double-toggles). Glyph `pointer-events-none`; nest scale/hover inside the rotate wrapper. Groups: `toothSelectionGroups.ts` (never 1-tooth connected); prune lab `toothProsthesis` on change.
|
||||
- **Day strip:** `ScheduleDayPicker` `compact` centered in the header. Timed cards use purpose banners. Unscheduled cards use the same banner from the **first detail’s type only** (live draft for the open card); empty first line keeps chip theming. Trash inherits banner ink. Strip-delete only when `areUnscheduledDetailsStripDeletable` (blank lines or `[]`); persist `[]` then `DELETE /treatments/:id`.
|
||||
- **Day strip:** `ScheduleDayPicker` `compact` centered in the header. Timed cards use purpose banners. Unscheduled cards use the same banner once typed (sync live draft onto the open card); untyped keep chip theming. Trash inherits banner ink. Strip-delete only when `areUnscheduledDetailsStripDeletable` (blank lines or `[]`); persist `[]` then `DELETE /treatments/:id`.
|
||||
- **Lab dispatch UI:** due date end-aligned beside title (`sm:flex-row` + `justify-between`; stacks on mobile). Prosthesis type: stacked below `md`, 50/50 same-row from `md`. Content clinical field = **Notes** (not case comments).
|
||||
- **Lab comments:** shared `LabCaseCommentsPanel` (newest-first; sent=`justify-start`, received=`justify-end`; `viewerSide`) across Treatment / Cases / Tasks / share. Use logical `text-start`/`text-end`, not left/right.
|
||||
- **Detail chrome:** chips (type + teeth) + Add at top; **Remove** = trash on chip (unsent, including last line; disabled when day-locked / no edit / uploading). Last-line confirm: plan will be empty until Add. Empty `[]` shows `noDetails` (not the type-first overlay). New treatment seeds one blank detail. No delete in type/notes fields.
|
||||
- **Patient search:** `PatientSearchCombobox` in the **page header** (workspace-wide). Opens today’s strip visit if any, else latest history. No visit/history → inline editor empty state pointing to **New treatment** in the rail (never a dialog, never auto-create). **New treatment** stays at the top of the left rail; selected-patient card sits under it. Picker: Walk-in first, then a matching full-width card for the current named patient (name + mobile/email, else hint), then search. Never auto-create from the appointment card.
|
||||
- **Detail chrome:** chips (type + teeth) + Add at top; **Remove** = trash on chip (unsent, including last line; disabled when day-locked / no edit / uploading). Empty details persist as `[]`. New treatment seeds one blank detail; load of empty stays `[]`. No delete in type/notes fields.
|
||||
- **Lab dispatch attention:** `LabDispatchAttentionPanel` — unsent lab-dependent details; quick jump to dispatch.
|
||||
- **History API:** patient-scoped; non-owners filtered by provider on treatment or appointment; org owners see all.
|
||||
- **History filters (client-side):** `PastTreatmentsPanel` — “Not shipped to lab” + single date; helpers in `treatmentHistoryFilters.ts`.
|
||||
- **Lab shipment block:** lab-dependent detail with no teeth saves but cannot ship — same inline amber banner on Treatment and Lab (`labBlockedBannerClass`); toast on add shipment.
|
||||
- **Lab shipment block:** lab-dependent detail with no teeth saves but cannot ship — `LabShipmentBlockedNotice`, inline banner, toast on add shipment.
|
||||
- **Edit gating:** `canEditTreatmentForDay` = permission + appointment + not past day + `live` mode. Sent detail locks that line; Add still OK same day. Upload rejects sent (`TREATMENT_DETAIL_SENT`).
|
||||
- **Lab search:** `LinkedOrganizationSearchCombobox`; invite lab via `/organizations?action=invite-lab` when permitted. New dispatch lines start empty (lab + apply-all / per-tooth prosthesis type). Chips = last 3 **sent** labs (`labDispatchDefaults.ts`) — never auto-select. Clear the search box when switching details.
|
||||
- **Lab search:** `LinkedOrganizationSearchCombobox`; invite lab via `/organizations?action=invite-lab` when permitted.
|
||||
- **Scroll:** `scrollWithinMainScrollContainer`; shared `Checkbox` only.
|
||||
|
||||
Full map: `.cursor/skills/treatment-workspace/SKILL.md`
|
||||
|
||||
@@ -22,7 +22,7 @@ Thin route: `app/[locale]/(dashboard)/treatment/page.tsx` (supports `?appointmen
|
||||
|
||||
|
||||
|
||||
1. **Day strip** — `AppointmentsStrip.tsx` renders `DayStripItem[]` (`appointment` | `unscheduled`) via `DayStripCard`. Header uses **`ScheduleDayPicker` `compact`**: date is centered in a 3-col grid; no “Schedule date” label; **Today** sits on the navigator (`CalendarDaySelect` when the label row is hidden). Timed appointments keep treatment-type pastel banners. Unscheduled cards use the same banner from the **first detail’s type only** (`unscheduledStripColorCode`; live draft for the open card; `draftHydratingRef` must be set **before** strip/appointment pick so overlay does not paint the previous card’s type). Empty first line → chip theming even if later lines are typed. Trash inherits banner ink on typed cards. Strip trash only when `areUnscheduledDetailsStripDeletable` (no type/teeth/notes/attachments, including `[]`). Workspace fetches `GET /appointments` **and** `GET /treatments/day`. Patient search (`PatientSearchCombobox`) sits in the **page header** (workspace-wide). **New treatment** is one shared `Button` at the **top of the left rail**, with the selected-patient card under it: it opens `NewTreatmentPatientPicker` (Walk-in always first, then a matching full-width card for the current named patient with name + mobile/email or hint, then search). Creating happens only after an explicit patient choice — never from the selected appointment card. New treatment seeds one blank detail so the type field is ready; a persisted empty plan hydrates as `[]` until Add (`noDetails` copy — not the type-first overlay). Last-line chip delete confirms the plan will be empty until Add.
|
||||
1. **Day strip** — `AppointmentsStrip.tsx` renders `DayStripItem[]` (`appointment` | `unscheduled`) via `DayStripCard`. Header uses **`ScheduleDayPicker` `compact`**: date is centered in a 3-col grid; no “Schedule date” label; **Today** sits on the navigator (`CalendarDaySelect` when the label row is hidden). Timed appointments keep treatment-type pastel banners. Unscheduled cards use the same banner once a treatment type is selected (live draft for the open card; `draftHydratingRef` must be set **before** strip/appointment pick so overlay does not paint the previous card’s type). Until typed they keep chip theming. Trash inherits banner ink on typed cards. Strip trash only when `areUnscheduledDetailsStripDeletable` (no type/teeth/notes/attachments, including `[]`). Workspace fetches `GET /appointments` **and** `GET /treatments/day`. **New treatment** is one shared `Button`: it opens `NewTreatmentPatientPicker` (Walk-in always first, then search). Creating happens only after an explicit patient choice — never from the selected appointment card. New treatment seeds one blank detail so the type field is ready; a persisted empty plan hydrates as `[]` until Add.
|
||||
|
||||
2. **Treatment preview** — `TreatmentPreviewCard.tsx` (history browse only; omitted for the live draft)
|
||||
|
||||
@@ -41,11 +41,11 @@ Right-column entry is **not** a three-step wizard. Type dropdown + `TreatmentDet
|
||||
| Stage | UI | When |
|
||||
|-------|-----|------|
|
||||
| **Treatment** | Type dropdown + `TreatmentDetailAttachmentsStrip`, `FdiToothChart` (Cases scale), full-width Notes | Always |
|
||||
| **Lab** | `LabCasesDispatchPanel` | Only when active detail type is lab-dependent. Entering Lab auto-ensures a shipment draft. **No default lab or prosthesis type** on a new detail (including siblings in the same plan). Last **3 sent** labs appear as chips under search — pick is explicit. Comments stay on the dispatch panel. |
|
||||
| **Lab** | `LabCasesDispatchPanel` | Only when active detail type is lab-dependent. Entering Lab auto-ensures a shipment draft. Last-used lab and prosthesis type are remembered. Comments stay on the dispatch panel. |
|
||||
|
||||
- Prosthesis uses `WizardStepper` (Treatment → Lab) with Back/Next. Lab dispatch keeps comments.
|
||||
- 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 only defaults new details).
|
||||
- Switching `activeDetailId` resets to the treatment form, unless `pendingEntryStepRef` is set to `lab` first (lab shipments rail / “Go to dispatch” / load-with-focus).
|
||||
- Live draft is **not** duplicated in the left rail preview; preview is for history browse only.
|
||||
|
||||
@@ -120,7 +120,7 @@ Helpers: `frontend/src/components/treatment/treatmentHistoryFilters.ts`.
|
||||
|
||||
Saved lab-dependent detail with **no teeth** can autosave but **cannot** create a lab shipment.
|
||||
|
||||
- Inline amber banner in `TreatmentDetailsEditor` on **both** Treatment and Lab steps when the active detail qualifies (`isLabDependentDetailMissingTeeth`). Do not use a separate notice card.
|
||||
- Inline banner in `TreatmentDetailsEditor` + `LabShipmentBlockedNotice` above dispatch when active detail qualifies (`isLabDependentDetailMissingTeeth`).
|
||||
- `handleAddLabCase` shows toast with `labShipmentBlockedBody`.
|
||||
- Dispatch panel only appears when a detail passes `isDetailReadyForLabDispatch` (persisted + lab-dependent + teeth).
|
||||
|
||||
@@ -155,7 +155,7 @@ On today: in-progress slot first, else nearest start time to `now`. Other days:
|
||||
|
||||
|
||||
|
||||
`LinkedOrganizationSearchCombobox` in `LabCasesDispatchPanel` — search-only results (no dropdown). Chips under the search are the last **3 labs this clinic sent a case to** (`rememberRecentLab` after successful send). They are shortcuts, not defaults: a new detail’s lab and prosthesis type (apply-all and per-tooth) stay empty until the user chooses. Switching details clears the search box. No match + org tab access → **Invite a lab** navigates to `/organizations?action=invite-lab`. No org access → show permission message; dispatch stops.
|
||||
`LinkedOrganizationSearchCombobox` in `LabCasesDispatchPanel` — search-only results (no dropdown). No match + org tab access → **Invite a lab** navigates to `/organizations?action=invite-lab`. No org access → show permission message; dispatch stops.
|
||||
|
||||
|
||||
|
||||
@@ -201,7 +201,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. Patient search: same workspace patient → no-op; else load latest history into the editor; **no history → do not auto-create** (history rail empties; dentist uses **New treatment** and picks a patient, including Walk-in).
|
||||
|
||||
Draft writes for appointments require provider match (`ensureAppointmentProvider`). Standalone requires `treatment.providerUserId === actor`.
|
||||
|
||||
|
||||
2
.gitattributes
vendored
2
.gitattributes
vendored
@@ -1,2 +0,0 @@
|
||||
# Shell scripts must use LF — CRLF breaks Alpine entrypoints ("No such file or directory").
|
||||
*.sh text eol=lf
|
||||
@@ -1,228 +0,0 @@
|
||||
# Production: build images for https://nudentic.ir, push :v* to Gitea (not :latest),
|
||||
# SCP compose to Linux, docker login wixur.ir:3000, compose pull + up.
|
||||
#
|
||||
# Triggers: git tag v1.0.1 (and v*.*.*), or Actions → this workflow → Run (input tag).
|
||||
# Staging stays on master: .gitea/workflows/registry-build-deploy.yml
|
||||
#
|
||||
# Do NOT push :latest — staging already uses :latest with the wixur.ir frontend bake.
|
||||
#
|
||||
# Variables:
|
||||
# REGISTRY_HOST Windows Docker push host, e.g. host.docker.internal:3000
|
||||
# (not gitea.wixur.ir unless nginx client_max_body_size 0)
|
||||
# REGISTRY_OWNER Gitea user/org for packages
|
||||
# CLONE_HOST git clone, e.g. 127.0.0.1:3000
|
||||
# PROD_PUBLIC_BASE_URL https://nudentic.ir (no trailing slash)
|
||||
# NEXT_PUBLIC_SENTRY_DSN GlitchTip frontend project DSN
|
||||
# PROD_REGISTRY_HOST Linux pull host, e.g. wixur.ir:3000 (HTTP; insecure-registries on the VPS)
|
||||
# PROD_INFRA_DIR /opt/dyolink/infrastructure
|
||||
#
|
||||
# Secrets (in addition to REGISTRY_USERNAME / REGISTRY_PASSWORD):
|
||||
# PROD_SSH_HOST Linux public IP or hostname
|
||||
# PROD_SSH_USER e.g. dyolink
|
||||
# PROD_SSH_KEY OpenSSH private key (full PEM)
|
||||
# PROD_SSH_PORT optional, default 22
|
||||
#
|
||||
# One-time on Linux: insecure-registries ["wixur.ir:3000"], .env REGISTRY_PREFIX + DOMAIN=nudentic.ir
|
||||
# See infrastructure/DEPLOY.md
|
||||
|
||||
name: Production — tag build, push, deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag to build and deploy (e.g. v1.0.1)'
|
||||
required: true
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: powershell
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: windows
|
||||
outputs:
|
||||
image_tag: ${{ steps.meta.outputs.image_tag }}
|
||||
steps:
|
||||
- name: Checkout (this Gitea)
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$cloneHost = '${{ vars.CLONE_HOST }}'.Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($cloneHost)) {
|
||||
$Server = "${{ github.server_url }}".TrimEnd('/')
|
||||
} elseif ($cloneHost -match '^https?://') {
|
||||
$Server = $cloneHost.TrimEnd('/')
|
||||
} else {
|
||||
$Server = 'http://' + $cloneHost
|
||||
}
|
||||
$Repo = "${{ github.repository }}"
|
||||
$dispatchTag = '${{ github.event.inputs.tag }}'.Trim()
|
||||
if (-not [string]::IsNullOrWhiteSpace($dispatchTag)) {
|
||||
$Branch = $dispatchTag
|
||||
} else {
|
||||
$Branch = "${{ github.ref_name }}"
|
||||
}
|
||||
$Token = "${{ github.token }}"
|
||||
$Actor = "${{ github.actor }}"
|
||||
$hp = $Server -replace '^https?://', ''
|
||||
if ($Server.StartsWith('https')) {
|
||||
$cloneUrl = 'https://' + $Actor + ':' + $Token + '@' + $hp + '/' + $Repo + '.git'
|
||||
} else {
|
||||
$cloneUrl = 'http://' + $Actor + ':' + $Token + '@' + $hp + '/' + $Repo + '.git'
|
||||
}
|
||||
$env:GIT_TERMINAL_PROMPT = '0'
|
||||
git clone --depth 1 --branch $Branch $cloneUrl .
|
||||
|
||||
- name: Resolve image tag (v*.*.* only)
|
||||
id: meta
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$dispatchTag = '${{ github.event.inputs.tag }}'.Trim()
|
||||
if (-not [string]::IsNullOrWhiteSpace($dispatchTag)) {
|
||||
$tag = $dispatchTag
|
||||
} else {
|
||||
$tag = "${{ github.ref_name }}"
|
||||
}
|
||||
if ($tag -notmatch '^v\d+\.\d+\.\d+') {
|
||||
Write-Host "Production images must be tagged vMAJOR.MINOR.PATCH (got: $tag)"
|
||||
exit 1
|
||||
}
|
||||
$utf8 = New-Object System.Text.UTF8Encoding $false
|
||||
[System.IO.File]::AppendAllText($env:GITHUB_OUTPUT, "image_tag=$tag`n", $utf8)
|
||||
$prefix = "${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_OWNER }}"
|
||||
[System.IO.File]::AppendAllText($env:GITHUB_ENV, "REGISTRY_PREFIX=$prefix`n", $utf8)
|
||||
Write-Host "image_tag=$tag REGISTRY_PREFIX=$prefix"
|
||||
|
||||
- name: Log in to container registry
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$pass = @'
|
||||
${{ secrets.REGISTRY_PASSWORD }}
|
||||
'@
|
||||
$pass.Trim() | docker login "${{ vars.REGISTRY_HOST }}" -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
|
||||
|
||||
- name: Build and push backend (tag only, not :latest)
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$tag = "${{ steps.meta.outputs.image_tag }}"
|
||||
docker build -t "$env:REGISTRY_PREFIX/dyolink-backend:$tag" ./backend
|
||||
docker push "$env:REGISTRY_PREFIX/dyolink-backend:$tag"
|
||||
|
||||
- name: Build and push frontend (nudentic.ir baked in)
|
||||
env:
|
||||
PROD_PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
|
||||
NEXT_PUBLIC_SENTRY_DSN: ${{ vars.NEXT_PUBLIC_SENTRY_DSN }}
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$tag = "${{ steps.meta.outputs.image_tag }}"
|
||||
$base = $env:PROD_PUBLIC_BASE_URL.Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($base)) { $base = 'https://nudentic.ir' }
|
||||
$base = $base.TrimEnd('/')
|
||||
docker build `
|
||||
--build-arg "NEXT_PUBLIC_API_URL=$base/api" `
|
||||
--build-arg "NEXT_PUBLIC_APP_URL=$base" `
|
||||
--build-arg "NEXT_PUBLIC_APP_NAME=Dyolink" `
|
||||
--build-arg "NEXT_PUBLIC_SENTRY_DSN=$env:NEXT_PUBLIC_SENTRY_DSN" `
|
||||
--build-arg "NEXT_PUBLIC_SENTRY_ENVIRONMENT=production" `
|
||||
-t "$env:REGISTRY_PREFIX/dyolink-frontend:$tag" `
|
||||
./frontend
|
||||
docker push "$env:REGISTRY_PREFIX/dyolink-frontend:$tag"
|
||||
|
||||
deploy:
|
||||
needs: build-and-push
|
||||
runs-on: windows
|
||||
steps:
|
||||
- name: Checkout (tagged tree for compose file)
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$cloneHost = '${{ vars.CLONE_HOST }}'.Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($cloneHost)) {
|
||||
$Server = "${{ github.server_url }}".TrimEnd('/')
|
||||
} elseif ($cloneHost -match '^https?://') {
|
||||
$Server = $cloneHost.TrimEnd('/')
|
||||
} else {
|
||||
$Server = 'http://' + $cloneHost
|
||||
}
|
||||
$Repo = "${{ github.repository }}"
|
||||
$dispatchTag = '${{ github.event.inputs.tag }}'.Trim()
|
||||
if (-not [string]::IsNullOrWhiteSpace($dispatchTag)) {
|
||||
$Branch = $dispatchTag
|
||||
} else {
|
||||
$Branch = "${{ github.ref_name }}"
|
||||
}
|
||||
$Token = "${{ github.token }}"
|
||||
$Actor = "${{ github.actor }}"
|
||||
$hp = $Server -replace '^https?://', ''
|
||||
if ($Server.StartsWith('https')) {
|
||||
$cloneUrl = 'https://' + $Actor + ':' + $Token + '@' + $hp + '/' + $Repo + '.git'
|
||||
} else {
|
||||
$cloneUrl = 'http://' + $Actor + ':' + $Token + '@' + $hp + '/' + $Repo + '.git'
|
||||
}
|
||||
$env:GIT_TERMINAL_PROMPT = '0'
|
||||
git clone --depth 1 --branch $Branch $cloneUrl .
|
||||
|
||||
- name: Write SSH key
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$raw = @'
|
||||
${{ secrets.PROD_SSH_KEY }}
|
||||
'@
|
||||
$key = $raw.Trim()
|
||||
if ($key -notmatch 'BEGIN') {
|
||||
Write-Host "Set secret PROD_SSH_KEY to an OpenSSH private key"
|
||||
exit 1
|
||||
}
|
||||
$keyPath = Join-Path $env:RUNNER_TEMP 'prod_ssh'
|
||||
$normalized = $key.Replace("`r`n", "`n").TrimEnd() + "`n"
|
||||
[System.IO.File]::WriteAllText($keyPath, $normalized)
|
||||
icacls $keyPath /inheritance:r | Out-Null
|
||||
icacls $keyPath /grant:r "$($env:USERNAME):(R)" | Out-Null
|
||||
[System.IO.File]::AppendAllText($env:GITHUB_ENV, "PROD_SSH_KEY_PATH=$keyPath`n")
|
||||
|
||||
- name: Copy compose + deploy script to Linux
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$hostName = '${{ secrets.PROD_SSH_HOST }}'.Trim()
|
||||
$user = '${{ secrets.PROD_SSH_USER }}'.Trim()
|
||||
$port = '${{ secrets.PROD_SSH_PORT }}'.Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($port)) { $port = '22' }
|
||||
$infra = '${{ vars.PROD_INFRA_DIR }}'.Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($infra)) { $infra = '/opt/dyolink/infrastructure' }
|
||||
$ssh = @('-i', $env:PROD_SSH_KEY_PATH, '-o', 'StrictHostKeyChecking=accept-new')
|
||||
ssh.exe @ssh -p $port "${user}@${hostName}" "mkdir -p $infra/scripts $infra/nginx"
|
||||
scp.exe @ssh -P $port `
|
||||
infrastructure/docker-compose.prod.yml `
|
||||
"${user}@${hostName}:${infra}/docker-compose.prod.yml"
|
||||
scp.exe @ssh -P $port `
|
||||
infrastructure/scripts/prod-remote-deploy.sh `
|
||||
"${user}@${hostName}:${infra}/scripts/prod-remote-deploy.sh"
|
||||
scp.exe @ssh -P $port `
|
||||
infrastructure/scripts/render-nginx-ssl.sh `
|
||||
"${user}@${hostName}:${infra}/scripts/render-nginx-ssl.sh"
|
||||
scp.exe @ssh -P $port `
|
||||
infrastructure/nginx/nginx.ssl.conf.template `
|
||||
"${user}@${hostName}:${infra}/nginx/nginx.ssl.conf.template"
|
||||
ssh.exe @ssh -p $port "${user}@${hostName}" "chmod +x $infra/scripts/prod-remote-deploy.sh $infra/scripts/render-nginx-ssl.sh"
|
||||
|
||||
- name: Login on Linux and deploy tag
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$hostName = '${{ secrets.PROD_SSH_HOST }}'.Trim()
|
||||
$user = '${{ secrets.PROD_SSH_USER }}'.Trim()
|
||||
$port = '${{ secrets.PROD_SSH_PORT }}'.Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($port)) { $port = '22' }
|
||||
$infra = '${{ vars.PROD_INFRA_DIR }}'.Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($infra)) { $infra = '/opt/dyolink/infrastructure' }
|
||||
$regHost = '${{ vars.PROD_REGISTRY_HOST }}'.Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($regHost)) { $regHost = 'wixur.ir:3000' }
|
||||
$tag = "${{ needs.build-and-push.outputs.image_tag }}"
|
||||
$pass = @'
|
||||
${{ secrets.REGISTRY_PASSWORD }}
|
||||
'@
|
||||
$regUser = '${{ secrets.REGISTRY_USERNAME }}'
|
||||
$ssh = @('-i', $env:PROD_SSH_KEY_PATH, '-o', 'StrictHostKeyChecking=accept-new')
|
||||
$remote = "docker login $regHost -u $regUser --password-stdin && PROD_INFRA_DIR=$infra $infra/scripts/prod-remote-deploy.sh $tag"
|
||||
$pass.Trim() | ssh.exe @ssh -p $port "${user}@${hostName}" $remote
|
||||
@@ -1,38 +1,30 @@
|
||||
# Staging: build backend/frontend images, push to Gitea Container Registry, deploy on self-hosted runner.
|
||||
# Build backend/frontend images, push to Gitea Container Registry, deploy with pull-only compose.
|
||||
#
|
||||
# Triggers: push to master/main, or manual workflow_dispatch.
|
||||
# Production (nudentic.ir / git tags): .gitea/workflows/prod-tag-deploy.yml — this file is Windows staging only.
|
||||
#
|
||||
# Repository Variables (Settings → Actions → Variables):
|
||||
# REGISTRY_HOST Docker registry host:port (no http/https).
|
||||
# Windows Docker Desktop → host.docker.internal:3000
|
||||
# Do NOT use gitea.wixur.ir unless Windows nginx for that
|
||||
# host has client_max_body_size 0 — Docker layer PUTs 413 otherwise.
|
||||
# REGISTRY_OWNER Gitea user or org that owns the packages
|
||||
# PUBLIC_BASE_URL URL users open in the browser, e.g. http://wixur.ir (no trailing slash, no :8088)
|
||||
# NEXT_PUBLIC_SENTRY_DSN GlitchTip frontend project DSN (https://…@errors.wixur.ir/…)
|
||||
# DEPLOY_SECRETS_DIR absolute path on runner, e.g. C:/dyolink/secrets
|
||||
#
|
||||
# Optional:
|
||||
# STAGING_HTTP_PORT public HTTP port (default 80) — Windows portproxy listens here → 18088
|
||||
# STAGING_LOCAL_PORT Docker bind on 127.0.0.1 (default 18088) — must not equal the public port if portproxy owns it
|
||||
# CLONE_HOST git clone host when runner = Gitea host → 127.0.0.1:3000
|
||||
#
|
||||
# Same Windows PC runs Gitea + runner + deploy:
|
||||
# CLONE_HOST → 127.0.0.1:3000 (git runs on Windows host)
|
||||
# REGISTRY_HOST → host.docker.internal:3000 (docker commands run inside Docker Desktop VM)
|
||||
# Gitea app.ini ROOT_URL → http://host.docker.internal:3000/ (Docker registry auth; browsers use http://wixur.ir:3000)
|
||||
# PUBLIC_BASE_URL → http://wixur.ir (staging app; Gitea stays on :3000)
|
||||
#
|
||||
# Add host.docker.internal:3000 (and wixur.ir:3000 if you pull by public hostname) to Docker Desktop insecure-registries.
|
||||
# Repository Variables (Settings → Actions → Variables) — non-secret:
|
||||
# REGISTRY_HOST e.g. 178.131.50.201:3000 (no http/https)
|
||||
# REGISTRY_OWNER Gitea user or org that owns the packages (same as image namespace)
|
||||
# PUBLIC_BASE_URL URL users open in browser, e.g. http://178.131.50.201:8088 (no trailing slash)
|
||||
#
|
||||
# Repository Secrets (Settings → Actions → Secrets):
|
||||
# REGISTRY_USERNAME Gitea username for docker login
|
||||
# REGISTRY_PASSWORD Gitea access token (packages:read/write) or account password
|
||||
#
|
||||
# Docker on runner: insecure-registries e.g. ["host.docker.internal:3000","wixur.ir:3000"]
|
||||
# HTTP registry (typical self-hosted Gitea): Docker defaults to HTTPS. If login/push fails with
|
||||
# "server gave HTTP response to HTTPS client", add REGISTRY_HOST (e.g. 192.168.1.100:3000) to the
|
||||
# Docker daemon "insecure-registries" on the RUNNER machine, then restart Docker (Docker Desktop
|
||||
# → Settings → Docker Engine → JSON → "insecure-registries": ["host:port"]).
|
||||
#
|
||||
# Runner: self-hosted with Docker + git. Default shell is powershell (Windows act_runner).
|
||||
# Optional:
|
||||
# STAGING_HTTP_PORT host port for nginx (default 8088)
|
||||
#
|
||||
# Required for deploy job (absolute path on the runner host):
|
||||
# DEPLOY_SECRETS_DIR folder containing database.staging.env + backend.staging.env
|
||||
#
|
||||
# Runner: self-hosted with Docker. Default shell is powershell (Windows act_runner often has no WSL bash).
|
||||
# For a Linux runner, change defaults.run.shell to bash and restore bash syntax if needed.
|
||||
#
|
||||
# We do NOT use gitea.com/actions/checkout — many restricted networks cannot reach gitea.com.
|
||||
# Checkout is a plain git clone from the same Gitea host.
|
||||
|
||||
name: Registry — build, push, deploy
|
||||
|
||||
@@ -46,155 +38,189 @@ defaults:
|
||||
shell: powershell
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: windows
|
||||
outputs:
|
||||
image_tag: ${{ steps.meta.outputs.image_tag }}
|
||||
temp-success:
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
- name: Checkout (clone from this Gitea — no gitea.com)
|
||||
- name: Temporary placeholder (always success)
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$cloneHost = '${{ vars.CLONE_HOST }}'.Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($cloneHost)) {
|
||||
$Server = "${{ github.server_url }}".TrimEnd('/')
|
||||
} elseif ($cloneHost -match '^https?://') {
|
||||
$Server = $cloneHost.TrimEnd('/')
|
||||
} else {
|
||||
$Server = 'http://' + $cloneHost
|
||||
}
|
||||
$Repo = "${{ github.repository }}"
|
||||
$Branch = "${{ github.ref_name }}"
|
||||
$Token = "${{ github.token }}"
|
||||
$Actor = "${{ github.actor }}"
|
||||
$hp = $Server -replace '^https?://', ''
|
||||
if ($Server.StartsWith('https')) {
|
||||
$cloneUrl = 'https://' + $Actor + ':' + $Token + '@' + $hp + '/' + $Repo + '.git'
|
||||
} else {
|
||||
$cloneUrl = 'http://' + $Actor + ':' + $Token + '@' + $hp + '/' + $Repo + '.git'
|
||||
}
|
||||
$env:GIT_TERMINAL_PROMPT = '0'
|
||||
git clone --depth 1 --branch $Branch $cloneUrl .
|
||||
Write-Host "Temporary workflow is active."
|
||||
Write-Host "Trigger: ${{ github.event_name }}"
|
||||
Write-Host "Branch: ${{ github.ref_name }}"
|
||||
Write-Host "Commit: ${{ github.sha }}"
|
||||
Write-Host "Production build/push/deploy steps are intentionally commented."
|
||||
exit 0
|
||||
|
||||
- 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)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Production pipeline is temporarily disabled.
|
||||
# Uncomment these jobs after split-DNS / registry reachability is fixed.
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# build-and-push:
|
||||
# runs-on: self-hosted
|
||||
# outputs:
|
||||
# image_tag: ${{ steps.meta.outputs.image_tag }}
|
||||
# steps:
|
||||
# - name: Checkout (clone from this Gitea — no gitea.com)
|
||||
# - name: Image tag and registry prefix
|
||||
# - name: Log in to container registry
|
||||
# - name: Build and push backend
|
||||
# - name: Build and push frontend
|
||||
#
|
||||
# deploy:
|
||||
# needs: build-and-push
|
||||
# runs-on: self-hosted
|
||||
# steps:
|
||||
# - name: Checkout (shallow clone from this Gitea — no gitea.com)
|
||||
# - name: Write deploy.registry.env and validate secrets path
|
||||
# - name: Log in to container registry (for pull)
|
||||
# - name: Pull and start stack
|
||||
|
||||
- name: Log in to container registry
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$pass = @'
|
||||
${{ secrets.REGISTRY_PASSWORD }}
|
||||
'@
|
||||
$pass.Trim() | docker login "${{ vars.REGISTRY_HOST }}" -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
|
||||
|
||||
- name: Build and push backend
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$tag = "${{ steps.meta.outputs.image_tag }}"
|
||||
docker build `
|
||||
-t "$env:REGISTRY_PREFIX/dyolink-backend:$tag" `
|
||||
-t "$env:REGISTRY_PREFIX/dyolink-backend:latest" `
|
||||
./backend
|
||||
docker push "$env:REGISTRY_PREFIX/dyolink-backend:$tag"
|
||||
docker push "$env:REGISTRY_PREFIX/dyolink-backend:latest"
|
||||
|
||||
- name: Build and push frontend
|
||||
env:
|
||||
PUBLIC_BASE_URL: ${{ vars.PUBLIC_BASE_URL }}
|
||||
NEXT_PUBLIC_SENTRY_DSN: ${{ vars.NEXT_PUBLIC_SENTRY_DSN }}
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$tag = "${{ steps.meta.outputs.image_tag }}"
|
||||
$base = $env:PUBLIC_BASE_URL
|
||||
docker build `
|
||||
--build-arg "NEXT_PUBLIC_API_URL=$base/api" `
|
||||
--build-arg "NEXT_PUBLIC_APP_URL=$base" `
|
||||
--build-arg "NEXT_PUBLIC_APP_NAME=Dyolink" `
|
||||
--build-arg "NEXT_PUBLIC_SENTRY_DSN=$env:NEXT_PUBLIC_SENTRY_DSN" `
|
||||
--build-arg "NEXT_PUBLIC_SENTRY_ENVIRONMENT=staging" `
|
||||
-t "$env:REGISTRY_PREFIX/dyolink-frontend:$tag" `
|
||||
-t "$env:REGISTRY_PREFIX/dyolink-frontend:latest" `
|
||||
./frontend
|
||||
docker push "$env:REGISTRY_PREFIX/dyolink-frontend:$tag"
|
||||
docker push "$env:REGISTRY_PREFIX/dyolink-frontend:latest"
|
||||
####SAMPLE
|
||||
# name: Registry — build, push, deploy
|
||||
|
||||
deploy:
|
||||
needs: build-and-push
|
||||
runs-on: windows
|
||||
steps:
|
||||
- name: Checkout (shallow clone from this Gitea — no gitea.com)
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$cloneHost = '${{ vars.CLONE_HOST }}'.Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($cloneHost)) {
|
||||
$Server = "${{ github.server_url }}".TrimEnd('/')
|
||||
} elseif ($cloneHost -match '^https?://') {
|
||||
$Server = $cloneHost.TrimEnd('/')
|
||||
} else {
|
||||
$Server = 'http://' + $cloneHost
|
||||
}
|
||||
$Repo = "${{ github.repository }}"
|
||||
$Branch = "${{ github.ref_name }}"
|
||||
$Token = "${{ github.token }}"
|
||||
$Actor = "${{ github.actor }}"
|
||||
$hp = $Server -replace '^https?://', ''
|
||||
if ($Server.StartsWith('https')) {
|
||||
$cloneUrl = 'https://' + $Actor + ':' + $Token + '@' + $hp + '/' + $Repo + '.git'
|
||||
} else {
|
||||
$cloneUrl = 'http://' + $Actor + ':' + $Token + '@' + $hp + '/' + $Repo + '.git'
|
||||
}
|
||||
$env:GIT_TERMINAL_PROMPT = '0'
|
||||
git clone --depth 1 --branch $Branch $cloneUrl .
|
||||
# on:
|
||||
# push:
|
||||
# branches: [main, master]
|
||||
# workflow_dispatch:
|
||||
|
||||
- name: Write deploy.registry.env and validate secrets path
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$SD = '${{ vars.DEPLOY_SECRETS_DIR }}'.Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($SD)) {
|
||||
Write-Host "Set repository variable DEPLOY_SECRETS_DIR to the absolute path on this runner"
|
||||
Write-Host "where database.staging.env and backend.staging.env live (not in git)."
|
||||
exit 1
|
||||
}
|
||||
if (-not (Test-Path (Join-Path $SD "database.staging.env"))) {
|
||||
Write-Host "Missing $(Join-Path $SD 'database.staging.env')"
|
||||
exit 1
|
||||
}
|
||||
if (-not (Test-Path (Join-Path $SD "backend.staging.env"))) {
|
||||
Write-Host "Missing $(Join-Path $SD 'backend.staging.env')"
|
||||
exit 1
|
||||
}
|
||||
$stagingPort = '${{ vars.STAGING_HTTP_PORT }}'.Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($stagingPort)) { $stagingPort = '80' }
|
||||
$localPort = '${{ vars.STAGING_LOCAL_PORT }}'.Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($localPort)) { $localPort = '18088' }
|
||||
$imageTag = "${{ needs.build-and-push.outputs.image_tag }}"
|
||||
$lines = @(
|
||||
"REGISTRY_PREFIX=${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_OWNER }}",
|
||||
"IMAGE_TAG=$imageTag",
|
||||
"STAGING_HTTP_PORT=$stagingPort",
|
||||
"STAGING_LOCAL_PORT=$localPort",
|
||||
"DEPLOY_SECRETS_DIR=$SD"
|
||||
)
|
||||
Set-Location infrastructure
|
||||
$lines | Set-Content -Path deploy.registry.env -Encoding utf8
|
||||
# defaults:
|
||||
# run:
|
||||
# shell: powershell
|
||||
|
||||
- name: Log in to container registry (for pull)
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$pass = @'
|
||||
${{ secrets.REGISTRY_PASSWORD }}
|
||||
'@
|
||||
$pass.Trim() | docker login "${{ vars.REGISTRY_HOST }}" -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
|
||||
# jobs:
|
||||
# build-and-push:
|
||||
# runs-on: self-hosted
|
||||
# outputs:
|
||||
# image_tag: ${{ steps.meta.outputs.image_tag }}
|
||||
# steps:
|
||||
# - name: Checkout (clone from this Gitea — no gitea.com)
|
||||
# run: |
|
||||
# $ErrorActionPreference = 'Stop'
|
||||
# $Server = "${{ github.server_url }}".TrimEnd('/')
|
||||
# $Repo = "${{ github.repository }}"
|
||||
# $Branch = "${{ github.ref_name }}"
|
||||
# $Token = "${{ github.token }}"
|
||||
# $Actor = "${{ github.actor }}"
|
||||
# $hp = $Server -replace '^https?://', ''
|
||||
# if ($Server.StartsWith('https')) {
|
||||
# $cloneUrl = 'https://' + $Actor + ':' + $Token + '@' + $hp + '/' + $Repo + '.git'
|
||||
# } else {
|
||||
# $cloneUrl = 'http://' + $Actor + ':' + $Token + '@' + $hp + '/' + $Repo + '.git'
|
||||
# }
|
||||
# $env:GIT_TERMINAL_PROMPT = '0'
|
||||
# git clone --depth 1 --branch $Branch $cloneUrl .
|
||||
|
||||
# - name: Image tag and registry prefix
|
||||
# id: meta
|
||||
# run: |
|
||||
# $ErrorActionPreference = 'Stop'
|
||||
# $short = (git rev-parse --short HEAD).Trim()
|
||||
# $utf8 = New-Object System.Text.UTF8Encoding $false
|
||||
# [System.IO.File]::AppendAllText($env:GITHUB_OUTPUT, "image_tag=$short`n", $utf8)
|
||||
# $prefix = "${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_OWNER }}"
|
||||
# [System.IO.File]::AppendAllText($env:GITHUB_ENV, "REGISTRY_PREFIX=$prefix`n", $utf8)
|
||||
|
||||
# - name: Log in to container registry
|
||||
# run: |
|
||||
# $ErrorActionPreference = 'Stop'
|
||||
# $pass = @'
|
||||
# ${{ secrets.REGISTRY_PASSWORD }}
|
||||
# '@
|
||||
# $pass.Trim() | docker login "${{ vars.REGISTRY_HOST }}" -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
|
||||
|
||||
# - name: Build and push backend
|
||||
# run: |
|
||||
# $ErrorActionPreference = 'Stop'
|
||||
# $tag = "${{ steps.meta.outputs.image_tag }}"
|
||||
# docker build `
|
||||
# -t "$env:REGISTRY_PREFIX/dyolink-backend:$tag" `
|
||||
# -t "$env:REGISTRY_PREFIX/dyolink-backend:latest" `
|
||||
# ./backend
|
||||
# docker push "$env:REGISTRY_PREFIX/dyolink-backend:$tag"
|
||||
# docker push "$env:REGISTRY_PREFIX/dyolink-backend:latest"
|
||||
|
||||
# - name: Build and push frontend
|
||||
# env:
|
||||
# PUBLIC_BASE_URL: ${{ vars.PUBLIC_BASE_URL }}
|
||||
# run: |
|
||||
# $ErrorActionPreference = 'Stop'
|
||||
# $tag = "${{ steps.meta.outputs.image_tag }}"
|
||||
# $base = $env:PUBLIC_BASE_URL
|
||||
# docker build `
|
||||
# --build-arg "NEXT_PUBLIC_API_URL=$base/api" `
|
||||
# --build-arg "NEXT_PUBLIC_APP_URL=$base" `
|
||||
# --build-arg "NEXT_PUBLIC_APP_NAME=Dyolink" `
|
||||
# -t "$env:REGISTRY_PREFIX/dyolink-frontend:$tag" `
|
||||
# -t "$env:REGISTRY_PREFIX/dyolink-frontend:latest" `
|
||||
# ./frontend
|
||||
# docker push "$env:REGISTRY_PREFIX/dyolink-frontend:$tag"
|
||||
# docker push "$env:REGISTRY_PREFIX/dyolink-frontend:latest"
|
||||
|
||||
# deploy:
|
||||
# needs: build-and-push
|
||||
# runs-on: self-hosted
|
||||
# steps:
|
||||
# - name: Checkout (shallow clone from this Gitea — no gitea.com)
|
||||
# run: |
|
||||
# $ErrorActionPreference = 'Stop'
|
||||
# $Server = "${{ github.server_url }}".TrimEnd('/')
|
||||
# $Repo = "${{ github.repository }}"
|
||||
# $Branch = "${{ github.ref_name }}"
|
||||
# $Token = "${{ github.token }}"
|
||||
# $Actor = "${{ github.actor }}"
|
||||
# $hp = $Server -replace '^https?://', ''
|
||||
# if ($Server.StartsWith('https')) {
|
||||
# $cloneUrl = 'https://' + $Actor + ':' + $Token + '@' + $hp + '/' + $Repo + '.git'
|
||||
# } else {
|
||||
# $cloneUrl = 'http://' + $Actor + ':' + $Token + '@' + $hp + '/' + $Repo + '.git'
|
||||
# }
|
||||
# $env:GIT_TERMINAL_PROMPT = '0'
|
||||
# git clone --depth 1 --branch $Branch $cloneUrl .
|
||||
|
||||
# - name: Write deploy.registry.env and validate secrets path
|
||||
# run: |
|
||||
# $ErrorActionPreference = 'Stop'
|
||||
# $SD = '${{ vars.DEPLOY_SECRETS_DIR }}'.Trim()
|
||||
# if ([string]::IsNullOrWhiteSpace($SD)) {
|
||||
# Write-Host "Set repository variable DEPLOY_SECRETS_DIR to the absolute path on this runner"
|
||||
# Write-Host "where database.staging.env and backend.staging.env live (not in git)."
|
||||
# exit 1
|
||||
# }
|
||||
# if (-not (Test-Path (Join-Path $SD "database.staging.env"))) {
|
||||
# Write-Host "Missing $(Join-Path $SD 'database.staging.env')"
|
||||
# exit 1
|
||||
# }
|
||||
# if (-not (Test-Path (Join-Path $SD "backend.staging.env"))) {
|
||||
# Write-Host "Missing $(Join-Path $SD 'backend.staging.env')"
|
||||
# exit 1
|
||||
# }
|
||||
# $stagingPort = '${{ vars.STAGING_HTTP_PORT }}'.Trim()
|
||||
# if ([string]::IsNullOrWhiteSpace($stagingPort)) { $stagingPort = '8088' }
|
||||
# $imageTag = "${{ needs.build-and-push.outputs.image_tag }}"
|
||||
# $lines = @(
|
||||
# "REGISTRY_PREFIX=${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_OWNER }}",
|
||||
# "IMAGE_TAG=$imageTag",
|
||||
# "STAGING_HTTP_PORT=$stagingPort",
|
||||
# "DEPLOY_SECRETS_DIR=$SD"
|
||||
# )
|
||||
# Set-Location infrastructure
|
||||
# $lines | Set-Content -Path deploy.registry.env -Encoding utf8
|
||||
|
||||
# - name: Log in to container registry (for pull)
|
||||
# run: |
|
||||
# $ErrorActionPreference = 'Stop'
|
||||
# $pass = @'
|
||||
# ${{ secrets.REGISTRY_PASSWORD }}
|
||||
# '@
|
||||
# $pass.Trim() | docker login "${{ vars.REGISTRY_HOST }}" -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
|
||||
|
||||
# - name: Pull and start stack
|
||||
# run: |
|
||||
# $ErrorActionPreference = 'Stop'
|
||||
# Set-Location infrastructure
|
||||
# docker compose -f docker-compose.registry.yml --env-file deploy.registry.env pull backend frontend
|
||||
# docker compose -f docker-compose.registry.yml --env-file deploy.registry.env up -d
|
||||
|
||||
- name: Pull and start stack
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-Location infrastructure
|
||||
docker compose -f docker-compose.registry.yml --env-file deploy.registry.env pull backend frontend
|
||||
docker compose -f docker-compose.registry.yml --env-file deploy.registry.env up -d
|
||||
|
||||
13
AGENTS.md
13
AGENTS.md
@@ -10,7 +10,7 @@ Dental clinic ↔ lab platform (monorepo):
|
||||
|------|--------|
|
||||
| `backend/` | NestJS, Prisma, PostgreSQL |
|
||||
| `frontend/` | Next.js 16, React 19, next-intl, Tailwind |
|
||||
| `infrastructure/` | Docker — staging `https://wixur.ir` (Windows, `STAGING-DEPLOY.md`); prod `https://nudentic.ir` (Linux, `DEPLOY.md`) |
|
||||
| `infrastructure/` | Docker, nginx, deploy scripts |
|
||||
|
||||
**Organization types:** `CLINIC` (patients, appointments, treatment) and `LAB` (cases, tasks). Many features are org-type-specific. Permissions use `TAB_*_READ` / `TAB_*_EDIT` codes — see `backend/src/common/permissions.ts`.
|
||||
|
||||
@@ -44,8 +44,8 @@ frontend/src/
|
||||
**Treatment tab:** Preview and editable form are **separate** until the user clicks **Load into workspace** on a history item. See `.cursor/skills/treatment-workspace/SKILL.md` before changing that flow.
|
||||
|
||||
**Treatment edit / details (quick ref):**
|
||||
- Day/mode gate: editable only for live draft on today/future (`canEditTreatmentForDay`). Past day / historical load → read-only form. Patient search sits in the **page header** (workspace-wide). If the patient has a visit on the day strip, that visit opens; else the latest history plan loads. If neither exists, the editor shows an inline empty state (no dialog) pointing to **New treatment** in the rail — do not auto-create. **New treatment** is at the top of the left rail (selected-patient card below it) and opens a picker: Walk-in first, then a matching card for the current named patient (name + mobile/email), then search. It does not auto-copy the appointment card’s patient. New treatment seeds one blank detail; a persisted empty plan loads as `[]` until Add.
|
||||
- **Unscheduled strip cards** use the same treatment-type banner as appointments from the **first detail’s type only** (`unscheduledStripColorCode`; live draft for the open card). Empty first line → chip theming even if later lines are typed. Strip trash only when every line is blank (no type/teeth/notes/attachments, including `[]`); typed cards need chip-delete first. Backend `DELETE /treatments/:id` is empty-only (`TREATMENT_HAS_DETAILS`).
|
||||
- Day/mode gate: editable only for live draft on today/future (`canEditTreatmentForDay`). Past day / historical load → read-only form. **New treatment** opens a patient picker (Walk-in always visible); it does not copy the selected appointment’s patient. New treatment seeds one blank detail; a persisted empty plan loads as `[]` until Add.
|
||||
- **Unscheduled strip cards** use the same treatment-type banner as appointments once a type is selected (live draft for the open card). Strip trash only when every line is blank (no type/teeth/notes/attachments, including `[]`); typed cards need chip-delete first. Backend `DELETE /treatments/:id` is empty-only (`TREATMENT_HAS_DETAILS`).
|
||||
- Sent-to-lab detail locks that line; **Add detail** still OK same day; **Remove detail** = trash on chip (unsent, including last line; day/edit gates apply). Empty details persist as `[]`. Attachment upload blocked when sent (`TREATMENT_DETAIL_SENT`).
|
||||
- **Entry:** Type dropdown + compact attachments strip (`TreatmentDetailAttachmentsStrip`) on one row, then bordered FDI chart (Cases chrome/scale), then full-width auto-growing **Notes**. No wizard for non-lab types. Prosthesis: `WizardStepper` Treatment → Lab with Back/Next. Detail chips show type + teeth; lab-dependent chips use colored sent/unsent text (sent date on Lab tab). Switching chips resets to the treatment form unless `pendingEntryStepRef` requests Lab (shipments rail). Lab dispatch keeps comments.
|
||||
- **Tooth selection:** Hit target is the unrotated cell (`pointerdown` only — do not also bind `click`). Glyph is `pointer-events-none`; nest hover/selected scale inside the rotate wrapper. Neighbor empty/filled circles between selected adjacent teeth connect/disconnect bridges; Shift+range selects only (empty circles; overlap absorbs as singles); midline 11–21 / 41–31 allowed. Plain click selects/deselects (deselect splits bridges). Never a 1-tooth connected. Helpers: `toothSelectionGroups.ts`. Connected label: `ConnectedSelectionBadge`. After send, Cases/Tasks merge teeth by prosthesis type.
|
||||
@@ -53,9 +53,8 @@ frontend/src/
|
||||
- **Schedule date:** Treatment strip and Appointments page headers use `ScheduleDayPicker` `compact` (date centered in a 3-col header; no “Schedule date” label; Today on the navigator).
|
||||
|
||||
**Treatment lab rules (quick ref):**
|
||||
- Lab-dependent details (e.g. prosthesis) **without teeth** can save but **cannot ship** — same inline amber banner (`labShipmentBlockedBody`) on Treatment **and** Lab steps; toast on dispatch add.
|
||||
- New prosthesis dispatch lines start **empty** (no default lab, no apply-all / per-tooth prosthesis type), even for siblings in the same plan. Recent-lab chips are the last **3 sent** destinations — pick is explicit, never auto-selected.
|
||||
- Detail treatment type need **not** match appointment purpose — purpose only seeds the **first** line of an empty **appointment** draft (first open, and **Add detail** when the plan is `[]`). Further **Add detail** starts with an empty type. Unscheduled / New treatment still seeds a blank first line.
|
||||
- Lab-dependent details (e.g. prosthesis) **without teeth** can save but **cannot ship** — show `LabShipmentBlockedNotice` + inline banner; toast on dispatch add.
|
||||
- Detail treatment type need **not** match appointment purpose — purpose only pre-fills new details.
|
||||
- **History filters** are client-side only (`treatmentHistoryFilters.ts`): “Not shipped to lab” + single date on already-fetched patient history; includes live current draft when filtering.
|
||||
- **Lab shipments rail**: unified list with scope toggle **This patient** vs **All updates** (unread across org for **this clinician's cases only**, includes patient name). Opening a case from the rail jumps to the **Lab** send sheet.
|
||||
- **Unread semantics**: Treatment tab badge = count of unread cases **for the user's own treatment plans** (per-case read cursor) and clears when a case is opened/marked read (not on tab visit).
|
||||
@@ -85,7 +84,7 @@ backend/src/
|
||||
prisma/ → schema, migrations, seed
|
||||
```
|
||||
|
||||
Errors: `AppException` + `ErrorCode` → frontend `getUserFacingError()`. Unexpected 500s: GlitchTip (`SENTRY_DSN`). Never throw raw strings for user-facing failures.
|
||||
Errors: `AppException` + `ErrorCode` → frontend `getUserFacingError()`. Never throw raw strings for user-facing failures.
|
||||
|
||||
## Git & commits
|
||||
|
||||
|
||||
131
README.md
131
README.md
@@ -8,23 +8,137 @@ Local development: see **`backend/README.md`** and **`frontend/README.md`**.
|
||||
|
||||
---
|
||||
|
||||
## Production deploy (`https://nudentic.ir`)
|
||||
## Production deploy (Docker Hub + HTTPS + Let's Encrypt)
|
||||
|
||||
**Full guide:** [`infrastructure/DEPLOY.md`](infrastructure/DEPLOY.md)
|
||||
**Full step-by-step guide:** [`infrastructure/DEPLOY.md`](infrastructure/DEPLOY.md)
|
||||
|
||||
Git tag **`v1.0.1`** → Gitea workflow [`.gitea/workflows/prod-tag-deploy.yml`](.gitea/workflows/prod-tag-deploy.yml) builds images (URLs baked for nudentic.ir), pushes to Gitea registry, SSH-deploys the Linux stack.
|
||||
Minimal server setup: install Docker, create `.env` + `secrets/`, `docker login`, run one script.
|
||||
|
||||
First-time SSL / Hub fallback: same `DEPLOY.md`.
|
||||
| On server (once) | In repo / Docker |
|
||||
|------------------|------------------|
|
||||
| DNS A record → server IP | `docker-compose.prod.yml`, nginx, certbot |
|
||||
| `docker login` (private Hub) | Build & push images from dev machine |
|
||||
| `secrets/database.env`, `secrets/backend.env` | Examples: `database.prod.env.example`, `backend.prod.env.example` |
|
||||
| `infrastructure/.env` (`DOMAIN`, `LETSENCRYPT_EMAIL`) | `deploy.prod.env.example` |
|
||||
|
||||
**Dev machine** — build frontend with the public domain baked in, push to Docker Hub:
|
||||
|
||||
```bash
|
||||
./infrastructure/scripts/build-and-push-prod.sh wixur.ir latest
|
||||
```
|
||||
|
||||
**Server** — from `infrastructure/`:
|
||||
|
||||
```bash
|
||||
cp deploy.prod.env.example .env # edit DOMAIN, paths
|
||||
mkdir -p ../secrets && cp database.prod.env.example ../secrets/database.env
|
||||
cp backend.prod.env.example ../secrets/backend.env # set passwords + FRONTEND_URL
|
||||
docker login
|
||||
chmod +x scripts/*.sh
|
||||
./scripts/deploy-prod.sh
|
||||
```
|
||||
|
||||
SSL is issued automatically via **Certbot** (`scripts/init-letsencrypt.sh`). Nginx config is generated from `DOMAIN` in `.env`. When you move to another domain (e.g. `dyolink.com`), update `.env` + `backend.env`, re-run `init-letsencrypt.sh`, and **rebuild the frontend image** with the new URL.
|
||||
|
||||
---
|
||||
|
||||
## Staging deploy (`https://wixur.ir`)
|
||||
## Deploy on your own server (Docker + Gitea)
|
||||
|
||||
**Full guide:** [`infrastructure/STAGING-DEPLOY.md`](infrastructure/STAGING-DEPLOY.md)
|
||||
High level: **build container images → push to a registry → server pulls images and runs Compose**. Optionally **Gitea Actions** automates that on every merge to `main` / `master`.
|
||||
|
||||
Merge or push to **`master`** → Gitea Actions builds images → deploys to `https://wixur.ir` (Gitea: `http://wixur.ir:3000`).
|
||||
### 1. One-time server preparation
|
||||
|
||||
Workflow: [`.gitea/workflows/registry-build-deploy.yml`](.gitea/workflows/registry-build-deploy.yml)
|
||||
1. Install **Docker** and **Docker Compose** on the server.
|
||||
2. Run **Gitea** with the **container registry** enabled (same host/port you use for `docker login`, e.g. `178.131.50.201:3000`).
|
||||
3. Copy the repo (or deploy only `infrastructure/` + secrets). You need at least:
|
||||
|
||||
- `infrastructure/docker-compose.registry.yml`
|
||||
- `infrastructure/nginx/` configs referenced by that compose file
|
||||
- `infrastructure/database/init.sql` if used by your Postgres service
|
||||
|
||||
4. **Secrets on the server** (never commit real values):
|
||||
|
||||
- Copy `infrastructure/database.staging.env.example` → **`database.staging.env`** (Postgres user/password/db).
|
||||
- Copy `infrastructure/backend.staging.env.example` → **`backend.staging.env`** (e.g. `DATABASE_URL`, JWT, pointing at the compose Postgres service name).
|
||||
- Put both files in one directory on the server, e.g. `/opt/dyolink/secrets/`.
|
||||
|
||||
5. **Registry login from the server** (same credentials you use for `docker push`):
|
||||
|
||||
```bash
|
||||
docker login <registry-host>:<port> -u <user>
|
||||
```
|
||||
|
||||
For HTTP registries, Docker may require **`insecure-registries`** on the daemon.
|
||||
|
||||
### 2. Manual deploy (build images elsewhere, run on server)
|
||||
|
||||
On your **dev machine** (after successful local builds):
|
||||
|
||||
```powershell
|
||||
$REG = "<registry-host>:<port>"
|
||||
$OWN = "<registry-owner>"
|
||||
$TAG = "manual"
|
||||
|
||||
docker build -t "${REG}/${OWN}/dyolink-backend:${TAG}" -t "${REG}/${OWN}/dyolink-backend:latest" ./backend
|
||||
|
||||
docker build `
|
||||
--build-arg NEXT_PUBLIC_API_URL="http://<your-public-ip>:<nginx-port>/api" `
|
||||
--build-arg NEXT_PUBLIC_APP_URL="http://<your-public-ip>:<nginx-port>" `
|
||||
--build-arg NEXT_PUBLIC_APP_NAME="Dyolink" `
|
||||
-t "${REG}/${OWN}/dyolink-frontend:${TAG}" `
|
||||
-t "${REG}/${OWN}/dyolink-frontend:latest" `
|
||||
./frontend
|
||||
|
||||
docker push "${REG}/${OWN}/dyolink-backend:${TAG}"
|
||||
docker push "${REG}/${OWN}/dyolink-backend:latest"
|
||||
docker push "${REG}/${OWN}/dyolink-frontend:${TAG}"
|
||||
docker push "${REG}/${OWN}/dyolink-frontend:latest"
|
||||
```
|
||||
|
||||
On the **server**, from `infrastructure/`:
|
||||
|
||||
1. Create **`deploy.registry.env`** (see `infrastructure/deploy.registry.env.example`):
|
||||
|
||||
- `REGISTRY_PREFIX=<host>:<port>/<owner>` (no `http://`, no trailing slash)
|
||||
- `IMAGE_TAG=latest` or the tag you pushed
|
||||
- `STAGING_HTTP_PORT=<host port>` (e.g. `8088` — browser uses `http://<ip>:8088`)
|
||||
|
||||
2. Set **`DEPLOY_SECRETS_DIR`** to the absolute path of the folder containing `database.staging.env` and `backend.staging.env` (you can export it in the shell or add it to `deploy.registry.env` if your Compose setup expects it).
|
||||
|
||||
3. Pull and start:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.registry.yml --env-file deploy.registry.env pull backend frontend
|
||||
docker compose -f docker-compose.registry.yml --env-file deploy.registry.env up -d
|
||||
```
|
||||
|
||||
The backend container runs **`prisma migrate deploy`** on startup (via entrypoint) when `NODE_ENV=production`, so schema updates apply after you deploy a new image that includes new migrations.
|
||||
|
||||
### 3. Automatic deploy (Gitea Actions)
|
||||
|
||||
Workflow file: **`.gitea/workflows/registry-build-deploy.yml`**.
|
||||
|
||||
**Requirements:**
|
||||
|
||||
- **Gitea Actions** enabled for the repository.
|
||||
- A **self-hosted runner** (with Docker) registered to Gitea — the workflow uses `runs-on: self-hosted`.
|
||||
- **Windows runners:** the workflow uses **PowerShell** (not Bash). Gitea’s runner was failing with `execvpe(/bin/bash) failed` when Bash was routed through WSL without a real `/bin/bash`. If your runner is **Linux**, switch `.gitea/workflows/registry-build-deploy.yml` to `defaults.run.shell: bash` and use Bash syntax instead.
|
||||
- **Repository → Actions → Variables** (examples):
|
||||
|
||||
- `REGISTRY_HOST` — e.g. `178.131.50.201:3000`
|
||||
- `REGISTRY_OWNER` — image namespace (same as Docker image path after the host), e.g. `admin`
|
||||
- `PUBLIC_BASE_URL` — URL users open in the browser, e.g. `http://178.131.50.201:8088` (no trailing slash)
|
||||
- `DEPLOY_SECRETS_DIR` — **absolute path on the runner machine** to the folder containing `database.staging.env` and `backend.staging.env`
|
||||
- Optional: `STAGING_HTTP_PORT` (defaults to `8088`)
|
||||
|
||||
- **Repository → Actions → Secrets:**
|
||||
|
||||
- `REGISTRY_USERNAME`
|
||||
- `REGISTRY_PASSWORD` — access token with package read/write (or equivalent)
|
||||
|
||||
**Trigger:** push to **`main`** or **`master`**, or run the workflow manually (**workflow_dispatch**).
|
||||
|
||||
The pipeline clones from your Gitea instance, builds and pushes backend/frontend images, then on the runner runs **`docker compose pull`** and **`up -d`** using `infrastructure/docker-compose.registry.yml`.
|
||||
|
||||
---
|
||||
|
||||
@@ -34,6 +148,5 @@ Workflow: [`.gitea/workflows/registry-build-deploy.yml`](.gitea/workflows/regist
|
||||
|------|------|
|
||||
| `backend/Dockerfile` | API image |
|
||||
| `frontend/Dockerfile` | Web image |
|
||||
| `infrastructure/STAGING-DEPLOY.md` | Staging setup, CI variables, testing |
|
||||
| `infrastructure/docker-compose.registry.yml` | Pull-only staging stack (registry images + nginx + postgres) |
|
||||
| `infrastructure/deploy.registry.env.example` | Template for `deploy.registry.env` |
|
||||
|
||||
@@ -29,10 +29,6 @@ FRONTEND_URL=http://localhost:3001
|
||||
# Set true when the app is served over HTTPS (required for Secure auth cookies)
|
||||
COOKIE_SECURE=false
|
||||
|
||||
# AdminJS panel at http://localhost:3000/admin (not under /api)
|
||||
# ADMINJS_EMAIL=admin@dyolink.com
|
||||
# ADMINJS_PASSWORD=admin123
|
||||
|
||||
# OAuth (optional — uncomment when configured)
|
||||
# GOOGLE_CLIENT_ID=your-google-client-id
|
||||
# GOOGLE_CLIENT_SECRET=your-google-client-secret
|
||||
@@ -76,7 +72,3 @@ OPENROUTER_API_KEY=
|
||||
# plus processing takes ten seconds at minimum — so it is purely an abuse guard.
|
||||
# VOICE_THROTTLE_TTL=60
|
||||
# VOICE_THROTTLE_LIMIT=6
|
||||
|
||||
# GlitchTip (optional). Leave empty for local unless you want to send events.
|
||||
# SENTRY_DSN=https://PUBLIC_KEY@errors.wixur.ir/1
|
||||
# SENTRY_ENVIRONMENT=development
|
||||
|
||||
@@ -47,8 +47,7 @@ COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
|
||||
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").
|
||||
RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh && chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
RUN mkdir -p /app/logs && \
|
||||
chown -R dyolink:nodejs /app
|
||||
|
||||
287
backend/package-lock.json
generated
287
backend/package-lock.json
generated
@@ -25,7 +25,6 @@
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@nestjs/websockets": "^11.1.28",
|
||||
"@prisma/client": "^6.19.2",
|
||||
"@sentry/nestjs": "^10.72.0",
|
||||
"adminjs": "^7.8.17",
|
||||
"axios": "^1.13.5",
|
||||
"bcrypt": "^6.0.0",
|
||||
@@ -4650,119 +4649,6 @@
|
||||
"npm": ">=5.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/api": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz",
|
||||
"integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/api-logs": {
|
||||
"version": "0.220.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.220.0.tgz",
|
||||
"integrity": "sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/core": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.10.0.tgz",
|
||||
"integrity": "sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.0.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/instrumentation": {
|
||||
"version": "0.220.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.220.0.tgz",
|
||||
"integrity": "sha512-xQx3E2WxP1mDvKzxLxX+CTCtNLa560YJZ3087qYHerl2YmiKpv7AH+dAy7vmx+eVrZ5BwhfWUAVoKOoxCNHcpw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api-logs": "0.220.0",
|
||||
"import-in-the-middle": "^3.0.0",
|
||||
"require-in-the-middle": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/resources": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.10.0.tgz",
|
||||
"integrity": "sha512-q6MMm2zhggzsHVNbabYwut+a6nbuQQe3URUoxaojM/8K1IBfwwPzvxIjNi2/lI1TFe+fMHMW9MWhrtDLEXEnkA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.10.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.3.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/sdk-trace": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace/-/sdk-trace-2.10.0.tgz",
|
||||
"integrity": "sha512-MfQGq3GRmTh5fM/y+OjaO0vj6+luCB1XO2gfXCalKCfgKw0eHL++sm75DNweC6ohlp+aFvACqeE0fYayqdRaoQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.10.0",
|
||||
"@opentelemetry/resources": "2.10.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.3.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/sdk-trace-base": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.10.0.tgz",
|
||||
"integrity": "sha512-GuYQQT7QD2EeO8lcZLRQzcbOyhqAzL+6WWTKTU9mSUBYBazkEDl+VrQcXQhbB08OWM9anD1aHleVadzulpOaUQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.10.0",
|
||||
"@opentelemetry/resources": "2.10.0",
|
||||
"@opentelemetry/sdk-trace": "2.10.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.3.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/semantic-conventions": {
|
||||
"version": "1.43.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz",
|
||||
"integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@paralleldrive/cuid2": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz",
|
||||
@@ -5447,138 +5333,6 @@
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@sentry/conventions": {
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.16.0.tgz",
|
||||
"integrity": "sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/core": {
|
||||
"version": "10.72.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.72.0.tgz",
|
||||
"integrity": "sha512-UJMHZfbjP4qk+g4AQhZmzosMdICC2D9p0/hLrm1LofPsp+WBfcSnv9jsY1a9TfmpS4WCAmTx8nANYTIXifcBjA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/conventions": "^0.16.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/nestjs": {
|
||||
"version": "10.72.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/nestjs/-/nestjs-10.72.0.tgz",
|
||||
"integrity": "sha512-+1wdX+fkv8zIZ+lI66Se1DKwPXoa6edxoHuAgWDwcqv3bKF8R75NqzXj+4U7FcgBI05i6HFiZJuyvx9BCECy8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "^1.9.1",
|
||||
"@opentelemetry/instrumentation": "^0.220.0",
|
||||
"@sentry/conventions": "^0.16.0",
|
||||
"@sentry/core": "10.72.0",
|
||||
"@sentry/node": "10.72.0",
|
||||
"@sentry/server-utils": "10.72.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
|
||||
"@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/node": {
|
||||
"version": "10.72.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.72.0.tgz",
|
||||
"integrity": "sha512-eQHQFxSX26MhG/nB+tAY4QRslnPvJse7pww/hd3zXAqNULRa5lFtjSLALcHx/KUVDCZdTPdUVZEj4nGidcHrow==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "^1.9.1",
|
||||
"@opentelemetry/instrumentation": "^0.220.0",
|
||||
"@opentelemetry/sdk-trace-base": "^2.9.0",
|
||||
"@sentry/conventions": "^0.16.0",
|
||||
"@sentry/core": "10.72.0",
|
||||
"@sentry/node-core": "10.72.0",
|
||||
"@sentry/opentelemetry": "10.72.0",
|
||||
"@sentry/server-utils": "10.72.0",
|
||||
"import-in-the-middle": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/node-core": {
|
||||
"version": "10.72.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.72.0.tgz",
|
||||
"integrity": "sha512-xYuYWmWEWnN8h3YHa7mds212ANFgrxfrbmLvVg0p0wf9m6MFjLowOTmjaiK0XW20sM/veSelicre650bx8UFtQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/conventions": "^0.16.0",
|
||||
"@sentry/core": "10.72.0",
|
||||
"@sentry/opentelemetry": "10.72.0",
|
||||
"import-in-the-middle": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/core": "^1.30.1 || ^2.1.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": ">=0.57.0 <1",
|
||||
"@opentelemetry/instrumentation": ">=0.57.1 <1",
|
||||
"@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@opentelemetry/api": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentelemetry/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentelemetry/exporter-trace-otlp-http": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentelemetry/instrumentation": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentelemetry/sdk-trace-base": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/opentelemetry": {
|
||||
"version": "10.72.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.72.0.tgz",
|
||||
"integrity": "sha512-ZVbAM1rGU/awN7cH/jvC87WpQw0NOJx5id6dKnnefStXpP/kUnZXYhtX9gfBnofYvJWa5I5VUSeuKCLUcKL75w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/conventions": "^0.16.0",
|
||||
"@sentry/core": "10.72.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/core": "^1.30.1 || ^2.1.0",
|
||||
"@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/server-utils": {
|
||||
"version": "10.72.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/server-utils/-/server-utils-10.72.0.tgz",
|
||||
"integrity": "sha512-CWpHMYW81RDBSVBdnxulGUvvBhkBb/OpSr72ubSe1UkKTcgT0NDMCWxE3dLFXqSxzT4DaF5UlUVv9ii5Sse53w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/conventions": "^0.16.0",
|
||||
"@sentry/core": "10.72.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sinclair/typebox": {
|
||||
"version": "0.34.49",
|
||||
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz",
|
||||
@@ -8598,6 +8352,7 @@
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz",
|
||||
"integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/class-transformer": {
|
||||
@@ -9467,9 +9222,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
|
||||
"integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
|
||||
"integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
@@ -10893,20 +10649,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/import-in-the-middle": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.3.3.tgz",
|
||||
"integrity": "sha512-AiohS3H80sXO6owEltjGX+glb7qXaDhBoJb9XcQVH4UI207xu/bDLUcadVKp7Qe576reg9yr/PXZjV5qx8gfbA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"cjs-module-lexer": "^2.2.0",
|
||||
"es-module-lexer": "^2.2.0",
|
||||
"module-details-from-path": "^1.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/import-local": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz",
|
||||
@@ -12775,12 +12517,6 @@
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/module-details-from-path": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz",
|
||||
"integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -14469,19 +14205,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-in-the-middle": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz",
|
||||
"integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.3.5",
|
||||
"module-details-from-path": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=9.3.0 || >=8.10.0 <9.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.12",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
||||
|
||||
@@ -46,7 +46,6 @@
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@nestjs/websockets": "^11.1.28",
|
||||
"@prisma/client": "^6.19.2",
|
||||
"@sentry/nestjs": "^10.72.0",
|
||||
"adminjs": "^7.8.17",
|
||||
"axios": "^1.13.5",
|
||||
"bcrypt": "^6.0.0",
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
-- DropIndex
|
||||
DROP INDEX "lab_cases_dueDate_idx";
|
||||
@@ -17,10 +17,7 @@ export class AdminModule {
|
||||
const { AdminModule: AdminJSModule } = await import('@adminjs/nestjs');
|
||||
|
||||
const authenticate = async (email: string, password: string) => {
|
||||
const adminEmail =
|
||||
process.env.ADMINJS_EMAIL?.trim() || 'admin@dyolink.com';
|
||||
const adminPassword = process.env.ADMINJS_PASSWORD || 'admin123';
|
||||
if (email === adminEmail && password === adminPassword) {
|
||||
if (email === 'admin@dyolink.com' && password === 'admin123') {
|
||||
return { email, role: 'admin' };
|
||||
}
|
||||
return null;
|
||||
@@ -33,19 +30,6 @@ export class AdminModule {
|
||||
imports: [ConfigModule],
|
||||
inject: [PrismaService, ConfigService],
|
||||
useFactory: (prisma: PrismaService, config: ConfigService) => {
|
||||
const cookieSecure = config.get<boolean>('cookie.secure') === true;
|
||||
const sessionSecret =
|
||||
config.get<string>('jwt.secret') ||
|
||||
config.get('JWT_SECRET') ||
|
||||
'secret-key-change-this';
|
||||
if (
|
||||
process.env.NODE_ENV === 'production' &&
|
||||
!process.env.ADMINJS_PASSWORD
|
||||
) {
|
||||
console.warn(
|
||||
'⚠️ ADMINJS_PASSWORD is unset; AdminJS is using the local default. Set it in backend.env.',
|
||||
);
|
||||
}
|
||||
return {
|
||||
adminJsOptions: {
|
||||
rootPath: '/admin',
|
||||
@@ -109,17 +93,12 @@ export class AdminModule {
|
||||
auth: {
|
||||
authenticate,
|
||||
cookieName: 'dyolink-admin',
|
||||
cookiePassword: sessionSecret,
|
||||
cookiePassword: config.get('JWT_SECRET') || 'secret-key-change-this',
|
||||
},
|
||||
sessionOptions: {
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
secret: sessionSecret,
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax' as const,
|
||||
secure: cookieSecure,
|
||||
},
|
||||
secret: config.get('JWT_SECRET') || 'secret-key-change-this',
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { ThrottlerModule } from '@nestjs/throttler';
|
||||
import { SentryModule } from '@sentry/nestjs/setup';
|
||||
import configurations from './configs/configurations';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { AppController } from './app.controller';
|
||||
@@ -26,7 +25,6 @@ import { VoiceModule } from './modules/voice/voice.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
SentryModule.forRoot(),
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [configurations],
|
||||
|
||||
@@ -93,9 +93,6 @@ describe('createJsonBodyParser', () => {
|
||||
'/api/voice/extract/extra',
|
||||
'/api/voice',
|
||||
'/voice/extract',
|
||||
// Express ignores one trailing slash, not two — this one never routes, so it must
|
||||
// not get the large parser either.
|
||||
'/api/voice/extract//',
|
||||
]) {
|
||||
const res = await request(buildApp()).post(path).send(bodyOfKb(300));
|
||||
expect(res.status).toBe(413);
|
||||
|
||||
@@ -17,19 +17,21 @@ export const VOICE_BODY_LIMIT = '10mb';
|
||||
* of audio, so that one route needs a larger limit while every other endpoint keeps the
|
||||
* default — a large body should not become acceptable everywhere.
|
||||
*
|
||||
* Deliberately one middleware that *chooses* a parser, not a path-mounted parser stacked in
|
||||
* front of a default one: that arrangement depended on Express's mount-path stripping and on
|
||||
* body-parser skipping an already-parsed request, and silently stopped applying whenever the
|
||||
* middleware order shifted. One explicit branch has no such coupling.
|
||||
* Deliberately a single middleware that *chooses* a parser, rather than a path-mounted
|
||||
* parser stacked in front of a default one. That arrangement relied on Express's
|
||||
* mount-path stripping plus body-parser skipping an already-parsed request, and it
|
||||
* silently stopped applying when the surrounding middleware order shifted — at which point
|
||||
* the endpoint rejected every real recording with a 500. One explicit branch has no such
|
||||
* coupling, and is covered by body-parsers.spec.ts.
|
||||
*/
|
||||
/**
|
||||
* Express routes case-insensitively and ignores exactly one trailing slash, so
|
||||
* `/API/Voice/Extract/` reaches the same controller and must get the same limit — otherwise
|
||||
* it 413s every real recording, which reads as a broken microphone rather than a route.
|
||||
* Two slashes never route, so they must not buy a 10 MB buffer either.
|
||||
* Express routes case-insensitively and ignores a trailing slash unless configured
|
||||
* otherwise, so `/API/Voice/Extract/` reaches the same controller. Matching only the
|
||||
* canonical spelling would hand those requests the 100 kb parser and 413 every real
|
||||
* recording — a failure that looks like a broken microphone, not a routing detail.
|
||||
*/
|
||||
function isVoiceExtractPath(path: string): boolean {
|
||||
return path.toLowerCase().replace(/\/$/, '') === VOICE_EXTRACT_PATH;
|
||||
return path.toLowerCase().replace(/\/+$/, '') === VOICE_EXTRACT_PATH;
|
||||
}
|
||||
|
||||
export function createJsonBodyParser(): RequestHandler {
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
/**
|
||||
* Persian (Extended Arabic-Indic, U+06F0–U+06F9) zero, and Arabic-Indic (U+0660–U+0669)
|
||||
* zero. ASR output can carry either block, sometimes mixed with ASCII in one transcript.
|
||||
*/
|
||||
const PERSIAN_ZERO = 0x06f0;
|
||||
const ARABIC_INDIC_ZERO = 0x0660;
|
||||
|
||||
/**
|
||||
* Normalise Persian and Arabic-Indic digits to ASCII. Non-digits pass through.
|
||||
*
|
||||
* Both blocks, not just the Persian one the frontend handles: ASR output can carry either,
|
||||
* sometimes mixed with ASCII in a single transcript.
|
||||
* Deliberately wider than the frontend original, which only handles the Persian block:
|
||||
* this parses model/ASR output rather than keystrokes, so both blocks must be accepted
|
||||
* or a spoken date or tooth number silently degrades to "unresolved".
|
||||
*
|
||||
* Lives on its own rather than inside jalali.ts because tooth codes need it too, and a
|
||||
* tooth module reaching into the calendar module would read as an accident.
|
||||
*/
|
||||
export function toLatinDigits(value: string): string {
|
||||
return value.replace(/[۰-۹٠-٩]/g, (ch) => {
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
HttpStatus,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import * as Sentry from '@sentry/nestjs';
|
||||
import type { Response } from 'express';
|
||||
import { AppException, type AppErrorResponse } from './app.exception';
|
||||
import { ErrorCode, type ErrorCodeValue } from './error-codes';
|
||||
@@ -43,9 +42,6 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
||||
this.logger.error(
|
||||
exception instanceof Error ? exception.stack : String(exception),
|
||||
);
|
||||
if (Sentry.getClient()) {
|
||||
Sentry.captureException(exception);
|
||||
}
|
||||
}
|
||||
|
||||
response.status(statusCode).json(body);
|
||||
|
||||
@@ -14,9 +14,10 @@ export type Arch = 'upper' | 'lower';
|
||||
export type PatientSide = 'patient_right' | 'patient_left';
|
||||
|
||||
/**
|
||||
* Upper arch in chart order: patient's RIGHT (18) → midline → patient's LEFT (28) — the drawn
|
||||
* layout, which mirrors the patient's own sides. Never read a position off this array by
|
||||
* index; use `toFdi()`, which owns the side convention.
|
||||
* Upper arch in chart order: patient's RIGHT (18) → midline → patient's LEFT (28).
|
||||
* That is the drawn left-to-right layout, which is the mirror of the patient's own sides.
|
||||
* Do not read a tooth position off this array by index — use `toFdi()`, which owns the
|
||||
* side convention.
|
||||
*/
|
||||
export const FDI_UPPER_ARCH_ORDER = [
|
||||
'18',
|
||||
@@ -67,9 +68,12 @@ export function isFdiTooth(value: unknown): value is string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up a tooth code the model echoed back. It is reading Persian speech, so it can hand
|
||||
* back "۲۶" or "2 6" from digit-by-digit dictation; neither matches literally, and the
|
||||
* near-miss does not fail loudly — the tooth just turns into "not understood".
|
||||
* Clean up a tooth code the extraction model echoed back, before it is matched.
|
||||
*
|
||||
* The model is transcribing Persian speech, so it can hand back "۲۶" in Persian digits or
|
||||
* "2 6" from a digit-by-digit dictation. Neither matches an FDI code literally, and a
|
||||
* near-miss here does not fail loudly — the tooth quietly turns into "not understood".
|
||||
* Returns '' for anything that is not a string.
|
||||
*/
|
||||
export function normalizeFdiCode(value: unknown): string {
|
||||
if (typeof value !== 'string') return '';
|
||||
@@ -110,8 +114,9 @@ export function teethBetweenInclusive(a: string, b: string): string[] | null {
|
||||
/**
|
||||
* Arch + patient side + position (1 = central incisor … 8 = third molar) → FDI code.
|
||||
*
|
||||
* The single place the patient-right convention lives. Getting it backwards mirrors every
|
||||
* quadrant into a valid-looking code for the wrong tooth, which no schema check can catch.
|
||||
* This function is the single place the patient-right convention lives. Getting it
|
||||
* backwards mirrors every quadrant and produces a valid-looking code for the wrong tooth,
|
||||
* which no schema check can catch — hence the exhaustive test coverage.
|
||||
*/
|
||||
export function toFdi(
|
||||
arch: Arch,
|
||||
@@ -130,8 +135,8 @@ export function toFdi(
|
||||
}
|
||||
|
||||
/**
|
||||
* Along the arch, not lexically — a bridge reads 16-15-14, and 11 sits beside 21 across the
|
||||
* midline. Teeth from another arch sort to the end, stably.
|
||||
* Sort teeth along the arch, not lexically — a bridge reads 16-15-14, and 11 sits beside
|
||||
* 21 across the midline. Teeth from another arch (or unknown) sort to the end, stably.
|
||||
*/
|
||||
export function sortInArchOrder(teeth: readonly string[]): string[] {
|
||||
if (teeth.length === 0) return [];
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
/**
|
||||
* Jalali (Persian) calendar arithmetic, ported from
|
||||
* `frontend/src/lib/i18n/persianCalendar.ts` (itself jalaali-js, MIT). The backend needs it
|
||||
* because voice resolves spoken Jalali dates server-side, where the resolvers are tested.
|
||||
* Keep the two copies in step; the underlying calendar does not change.
|
||||
* Jalali (Persian) calendar arithmetic.
|
||||
*
|
||||
* Ported from `frontend/src/lib/i18n/persianCalendar.ts` (itself from jalaali-js, MIT).
|
||||
* The backend needs this because voice extraction resolves spoken Jalali dates into ISO
|
||||
* dates server-side, where the resolvers are unit-tested — the frontend has no test
|
||||
* runner. Keep the two copies in step; the underlying calendar does not change.
|
||||
*/
|
||||
|
||||
const BREAKS = [
|
||||
@@ -142,8 +144,11 @@ export function isJalaliLeapYear(jy: number): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Days in a Jalali month, or 0 when the year or month is not real. Zero rather than a throw:
|
||||
* every export here is reachable from model-supplied values, so the module degrades.
|
||||
* Days in a Jalali month, or 0 when the year or month is not real.
|
||||
*
|
||||
* Zero rather than a throw: every export here is reachable from model-supplied values, so
|
||||
* the whole module degrades instead of raising. Zero also makes `isValidJalaliDate`'s
|
||||
* `jd <= jalaliDaysInMonth(...)` naturally false.
|
||||
*/
|
||||
export function jalaliDaysInMonth(jy: number, jm: number): number {
|
||||
if (!isSupportedJalaliYear(jy)) return 0;
|
||||
@@ -165,8 +170,10 @@ export function isValidJalaliDate(jy: number, jm: number, jd: number): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Jalali triple → `YYYY-MM-DD`, or null when the date is not real. Null rather than a throw,
|
||||
* for the same reason: callers resolve model-supplied values, which may be nonsense.
|
||||
* Jalali triple → `YYYY-MM-DD`, or null when the date is not real.
|
||||
*
|
||||
* Returns null rather than throwing: callers resolve model-supplied values, which may be
|
||||
* nonsense, and an invalid date must degrade to "unresolved" rather than a 500.
|
||||
*/
|
||||
export function jalaliToIsoDate(
|
||||
jy: number,
|
||||
|
||||
@@ -55,12 +55,15 @@ export function civilDateJsWeekday(isoDate: string): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* Today's civil date (`YYYY-MM-DD`) in an IANA zone, so the server derives "today" from a
|
||||
* client-supplied *zone* rather than trusting a client-supplied date.
|
||||
* Today's civil date (`YYYY-MM-DD`) in an IANA zone.
|
||||
*
|
||||
* Lets the server derive "today" from a client-supplied time zone instead of trusting a
|
||||
* client-supplied date, which matters for relative deadlines like "by Thursday".
|
||||
*/
|
||||
export function civilDateInZone(date: Date, timeZone: string): string {
|
||||
// Intl throws RangeError on an unknown zone and this takes a client-supplied string;
|
||||
// callers validate first, this is the backstop.
|
||||
// Intl throws RangeError on an unknown zone, before any fallback below could help, and
|
||||
// this receives a client-supplied string. Callers validate first; this is the backstop
|
||||
// so a bad zone degrades to a date that is at most a day out rather than a 500.
|
||||
const zone = isValidIanaTimeZone(timeZone) ? timeZone : 'UTC';
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: zone,
|
||||
|
||||
@@ -186,9 +186,10 @@ function parseProviderId(
|
||||
}
|
||||
|
||||
/**
|
||||
* Every enabled locale gets its own ASR and LLM provider+model, each independently
|
||||
* overridable. They all point at the same OpenRouter models today; the per-locale
|
||||
* indirection stays so a locale can diverge by configuration rather than by code.
|
||||
* Every enabled locale gets its own ASR and LLM provider+model, each overridable
|
||||
* independently. They all point at the same OpenRouter models today; the per-locale
|
||||
* indirection is kept because Persian ASR is the weakest link and swapping only `fa` must
|
||||
* not be a code change.
|
||||
*/
|
||||
function buildVoiceConfig(
|
||||
getEnvVarWithDefault: (key: string, defaultValue: string) => string,
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { config } from 'dotenv';
|
||||
import * as Sentry from '@sentry/nestjs';
|
||||
|
||||
config();
|
||||
|
||||
const dsn = process.env.SENTRY_DSN?.trim();
|
||||
|
||||
if (dsn) {
|
||||
Sentry.init({
|
||||
dsn,
|
||||
environment:
|
||||
process.env.SENTRY_ENVIRONMENT?.trim() ||
|
||||
process.env.NODE_ENV ||
|
||||
'development',
|
||||
release: process.env.SENTRY_RELEASE?.trim() || undefined,
|
||||
sendDefaultPii: false,
|
||||
tracesSampleRate: 0,
|
||||
beforeSend(event) {
|
||||
if (event.request) {
|
||||
delete event.request.cookies;
|
||||
delete event.request.data;
|
||||
if (event.request.headers) {
|
||||
delete event.request.headers.cookie;
|
||||
delete event.request.headers.authorization;
|
||||
delete event.request.headers.Authorization;
|
||||
}
|
||||
}
|
||||
if (event.user) {
|
||||
delete event.user.email;
|
||||
delete event.user.ip_address;
|
||||
delete event.user.username;
|
||||
}
|
||||
return event;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
// backend/src/main.ts
|
||||
import './instrument';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { urlencoded } from 'express';
|
||||
import { AppModule } from './app.module';
|
||||
@@ -26,13 +25,11 @@ console.log = (...args) => {
|
||||
};
|
||||
|
||||
async function bootstrap() {
|
||||
// bodyParser is disabled so the JSON parsers can be registered in an explicit order below;
|
||||
// Nest's built-in one would otherwise reject a voice recording at 100 kb.
|
||||
// bodyParser is disabled here so the JSON parsers can be registered in an explicit
|
||||
// order below; Nest's built-in one is installed during create() and would otherwise
|
||||
// reject a voice recording at its 100 kb default before any later middleware ran.
|
||||
const app = await NestFactory.create(AppModule, { bodyParser: false });
|
||||
|
||||
// Nginx terminates TLS; AdminJS sessions and Secure cookies need the real proto/host.
|
||||
app.getHttpAdapter().getInstance().set('trust proxy', 1);
|
||||
|
||||
// Voice needs a larger JSON limit than everything else; see body-parsers.ts.
|
||||
app.use(createJsonBodyParser());
|
||||
app.use(urlencoded({ extended: true }));
|
||||
|
||||
@@ -70,7 +70,7 @@ type SentLabCaseRow = Prisma.LabCaseGetPayload<{ include: typeof sentLabCaseIncl
|
||||
|
||||
const treatmentInclude = {
|
||||
patient: {
|
||||
select: { id: true, firstName: true, lastName: true, isWalkIn: true, mobile: true, email: true },
|
||||
select: { id: true, firstName: true, lastName: true, isWalkIn: true },
|
||||
},
|
||||
details: {
|
||||
orderBy: [{ sortOrder: 'asc' as const }],
|
||||
@@ -1366,8 +1366,6 @@ export class TreatmentsService {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
isWalkIn: boolean;
|
||||
mobile?: string | null;
|
||||
email?: string | null;
|
||||
};
|
||||
details: Array<{
|
||||
id: string;
|
||||
@@ -1428,8 +1426,6 @@ export class TreatmentsService {
|
||||
firstName: treatment.patient.firstName,
|
||||
lastName: treatment.patient.lastName,
|
||||
isWalkIn: treatment.patient.isWalkIn,
|
||||
mobile: treatment.patient.mobile ?? null,
|
||||
email: treatment.patient.email ?? null,
|
||||
}
|
||||
: null,
|
||||
details: treatment.details.map((d) => this.mapDetail(d)),
|
||||
|
||||
@@ -26,32 +26,48 @@ export type VoiceAudioFormat = (typeof VOICE_AUDIO_FORMATS)[number];
|
||||
export const VOICE_LOCALES = ['en', 'fa', 'nl'] as const;
|
||||
|
||||
export class ExtractVoiceDto {
|
||||
/** Base64 audio, no data: prefix. Well above a 2-minute opus clip (~400 KB). */
|
||||
/**
|
||||
* Base64 audio, no data: prefix. Capped well above a 2-minute opus clip (~400 KB) but
|
||||
* far below OpenRouter's 25 MB ceiling, so an oversized upload is rejected before it
|
||||
* costs a vendor call.
|
||||
*/
|
||||
@IsString()
|
||||
@IsBase64()
|
||||
// Both name their own code: the shared map sends `maxLength` to
|
||||
// VALIDATION_FIELD_REQUIRED and `isIn` to VALIDATION_LANGUAGE_INVALID, neither of which
|
||||
// is true here.
|
||||
// Both constraints name their own code. Left to the default mapping, `maxLength` falls
|
||||
// through to VALIDATION_FIELD_REQUIRED and `isIn` resolves to
|
||||
// VALIDATION_LANGUAGE_INVALID — so an oversized recording told the clinician a field
|
||||
// was missing, and an unsupported container told them their language was invalid.
|
||||
@MaxLength(8_000_000, { message: ErrorCode.VOICE_CLIP_TOO_LONG })
|
||||
audio: string;
|
||||
|
||||
@IsIn(VOICE_AUDIO_FORMATS, { message: ErrorCode.VOICE_UNSUPPORTED_FORMAT })
|
||||
format: VoiceAudioFormat;
|
||||
|
||||
/** The clinician's IANA zone; "today" is derived from it, never sent by the client. */
|
||||
/**
|
||||
* The clinician's IANA zone. The server derives "today" from it rather than trusting a
|
||||
* client-supplied date, which is what relative deadlines resolve against.
|
||||
*/
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
timeZone: string;
|
||||
|
||||
/** Required, not optional — omitting it would bypass VOICE_MAX_RECORDING_MS entirely. */
|
||||
/**
|
||||
* Recording length as measured by the client.
|
||||
*
|
||||
* Required, not optional: an optional value means omitting it bypasses
|
||||
* VOICE_MAX_RECORDING_MS entirely, which would make the cap advisory.
|
||||
*/
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
durationMs: number;
|
||||
|
||||
/**
|
||||
* The locale the UI offered the microphone in. Sent explicitly because `user.language` can
|
||||
* diverge from the URL locale, and a mismatch transcribes Persian with an English hint and
|
||||
* anchors "next Thursday" to the wrong week start.
|
||||
* The locale the clinician is actually speaking, as the UI offered the microphone.
|
||||
*
|
||||
* Sent explicitly rather than read from `user.language`: the two can diverge (a
|
||||
* bookmarked /fa/ URL, a language toggle whose save failed), and a mismatch would
|
||||
* transcribe Persian with an English hint and anchor "next Thursday" to the wrong
|
||||
* week start. Gating the button and resolving the request must agree by construction.
|
||||
*/
|
||||
@IsIn(VOICE_LOCALES)
|
||||
locale: string;
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { DueIntent, UnresolvedItem, Weekday } from './voice.types';
|
||||
* to reason about instants.
|
||||
*/
|
||||
|
||||
/** JS `getUTCDay()` numbering: Sunday = 0. */
|
||||
const WEEKDAY_TO_JS: Record<Weekday, number> = {
|
||||
saturday: 6,
|
||||
sunday: 0,
|
||||
@@ -25,6 +26,7 @@ const WEEKDAY_TO_JS: Record<Weekday, number> = {
|
||||
friday: 5,
|
||||
};
|
||||
|
||||
/** Refuse absurd deadlines however they were arrived at. */
|
||||
const MAX_DAYS_AHEAD = 365 * 5;
|
||||
|
||||
export type DueResolution = {
|
||||
@@ -76,9 +78,12 @@ function unresolved(spoken: string): DueResolution {
|
||||
}
|
||||
|
||||
/**
|
||||
* What to quote back when a deadline could not be resolved. Every field is nullable on the
|
||||
* wire and `toVoiceIntent` casts rather than checks, and the sheet renders this verbatim —
|
||||
* so a half-classified deadline must fall back to '', not to `"null null"`.
|
||||
* What to quote back when a deadline could not be resolved.
|
||||
*
|
||||
* Every field here is nullable on the wire and `toVoiceIntent` casts rather than checks,
|
||||
* so a half-classified deadline arrives with nulls in it. The review sheet renders this
|
||||
* verbatim — `"null null" — not a usable date` in front of a clinician is worse than the
|
||||
* reason on its own, which the sheet already handles for a blank string.
|
||||
*/
|
||||
function describe(intent: DueIntent): string {
|
||||
const usable = (value: unknown): value is number =>
|
||||
@@ -103,7 +108,8 @@ function describe(intent: DueIntent): string {
|
||||
? `${intent.y}-${intent.m}-${intent.d}`
|
||||
: '';
|
||||
default: {
|
||||
// An unrecognised `kind`, already established as a string — echo what was heard.
|
||||
// Reaching here means an unrecognised `kind`, which resolveDueDate has already
|
||||
// established is a string — echo it so the review row names what was heard.
|
||||
const kind = (intent as { kind?: unknown })?.kind;
|
||||
return typeof kind === 'string' ? kind : '';
|
||||
}
|
||||
@@ -111,9 +117,11 @@ function describe(intent: DueIntent): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Which weekday starts the week, per locale.
|
||||
*
|
||||
* "Next Thursday" is week-relative, so this changes the answer: the Iranian week starts
|
||||
* Saturday, the Dutch and English week Monday. Hardcoding either puts the other locale's
|
||||
* deadline a week out.
|
||||
* Saturday, the Dutch and (European) English week starts Monday. Hardcoding Saturday
|
||||
* would put an en/nl clinician's deadline a week out.
|
||||
*/
|
||||
const WEEK_START_BY_LOCALE: Record<string, number> = {
|
||||
fa: WEEKDAY_TO_JS.saturday,
|
||||
@@ -127,18 +135,22 @@ export function weekStartForLocale(locale: string): number {
|
||||
return WEEK_START_BY_LOCALE[locale] ?? DEFAULT_WEEK_START;
|
||||
}
|
||||
|
||||
/** Most recent week-start day, counting today if today is that day. */
|
||||
function startOfWeek(iso: string, weekStartJs: number): string {
|
||||
const back = (civilDateJsWeekday(iso) - weekStartJs + 7) % 7;
|
||||
return addDays(iso, -back);
|
||||
}
|
||||
|
||||
/**
|
||||
* `'this'` is occurrence-anchored: the soonest occurrence strictly after today, so it can
|
||||
* never resolve into the past.
|
||||
* `'this'` is occurrence-anchored: the soonest occurrence strictly after today, so "by
|
||||
* Thursday" said on a Thursday means the next one — a deadline of today is almost never
|
||||
* what was meant, and this can never resolve into the past.
|
||||
*
|
||||
* `'next'` is *week*-anchored, not "this plus seven" — adding a week to `'this'` overshoots
|
||||
* by seven days whenever `'this'` has already rolled into next week. The two legitimately
|
||||
* coincide: said on a Thursday, "the coming Saturday" and "Saturday next week" are one day.
|
||||
* `'next'` is *week*-anchored, not "this plus seven". "Thursday next week" means the
|
||||
* Thursday of the Saturday-start week after this one; adding a week to `'this'` would
|
||||
* overshoot by seven days whenever `'this'` had already rolled into next week. The two
|
||||
* can legitimately coincide — said on a Thursday, "the coming Saturday" and "Saturday
|
||||
* next week" are the same day.
|
||||
*/
|
||||
function resolveWeekday(
|
||||
intent: Extract<DueIntent, { kind: 'weekday' }>,
|
||||
@@ -187,15 +199,18 @@ export function resolveDueDate(
|
||||
todayIso: string,
|
||||
weekStartJs: number = DEFAULT_WEEK_START,
|
||||
): DueResolution {
|
||||
// Absent is not an error — most utterances carry no deadline.
|
||||
// Absent is not an error — most utterances carry no deadline. Anything else that is not
|
||||
// an intent object is a deadline we failed to understand, and must be flagged rather
|
||||
// than silently dropped.
|
||||
if (intent === null || intent === undefined) {
|
||||
return { dueDate: null, unresolved: null };
|
||||
}
|
||||
if (typeof intent !== 'object') {
|
||||
return unresolved(String(intent).slice(0, 120));
|
||||
}
|
||||
// No `kind` at all says nothing about a deadline, so it is not "heard but lost". An
|
||||
// *unrecognised* kind did try to say something, and is flagged below.
|
||||
// An object carrying no `kind` at all says nothing about a deadline; flagging it would
|
||||
// put a blank "heard but lost" row in front of a clinician who never mentioned one. An
|
||||
// object with an *unrecognised* kind did try to say something, and is flagged below.
|
||||
if (typeof (intent as { kind?: unknown }).kind !== 'string') {
|
||||
return { dueDate: null, unresolved: null };
|
||||
}
|
||||
@@ -225,7 +240,8 @@ export function resolveDueDate(
|
||||
|
||||
if (!resolved) return unresolved(describe(intent));
|
||||
|
||||
// A date the model invented can land anywhere; past or decades away is not a deadline.
|
||||
// An absolute date the model invented can land anywhere; a deadline in the past or
|
||||
// decades away is not a deadline.
|
||||
const daysAhead = (utcMsOf(resolved) - utcMsOf(todayIso)) / 86_400_000;
|
||||
if (daysAhead < 0 || daysAhead > MAX_DAYS_AHEAD)
|
||||
return unresolved(describe(intent));
|
||||
|
||||
@@ -91,9 +91,12 @@ function mergeOverlapping(sets: string[][]): string[][] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Span teeth join the selection: "a bridge from 14 to 16" selects 15 though it was never
|
||||
* named. A cross-arch span is reported rather than guessed at, and a span collapsing to one
|
||||
* tooth degrades to a single — there is no one-tooth bridge.
|
||||
* Turn spoken bridge spans plus loose teeth into selection groups.
|
||||
*
|
||||
* Span teeth are added to the selection: saying "a bridge from 14 to 16" selects 15 even
|
||||
* though it was never named. A span whose endpoints are in different arches is impossible
|
||||
* and is reported rather than guessed at. A span that collapses to one tooth degrades to a
|
||||
* single — there is no such thing as a one-tooth bridge.
|
||||
*/
|
||||
export function resolveConnectedSpans(
|
||||
spans: readonly ConnectedSpanIntent[],
|
||||
@@ -166,8 +169,10 @@ export function resolveConnectedSpans(
|
||||
}
|
||||
|
||||
/**
|
||||
* A default across the selection, then per-tooth overrides — "همه زیرکونیا، ۲۶ پیافام" is
|
||||
* how clinicians actually speak.
|
||||
* Expand a default prosthesis type across the selection, then apply per-tooth overrides.
|
||||
*
|
||||
* "همه زیرکونیا، ۲۶ پیافام" is how clinicians actually speak, so the model names the type
|
||||
* once and overrides the exceptions.
|
||||
*/
|
||||
export function resolveProsthesis(
|
||||
intent: ProsthesisIntent | null | undefined,
|
||||
@@ -277,12 +282,14 @@ export function resolveVoiceIntent(
|
||||
? intent.comment.trim()
|
||||
: null;
|
||||
|
||||
// An invented lab id would ship a case to a lab the clinic never named. A rejected one is
|
||||
// reported, so it cannot look identical to "no lab was spoken".
|
||||
// A lab id the model invented is worse than none — it would ship a case to a lab the
|
||||
// clinic never named. Only ids from the list we supplied survive, and a rejected one is
|
||||
// reported: a hallucinated lab must not look identical to "no lab was spoken".
|
||||
const labId = resolveCatalogCode(intent?.labId, ctx.linkedLabIds);
|
||||
if (intent?.labId != null && !labId) {
|
||||
// `spoken` is what the clinician said — quoting an invented id back would put a raw
|
||||
// UUID in front of the user.
|
||||
// `spoken` means "what the clinician said". A rejected lab id is an opaque
|
||||
// identifier the model invented, so quoting it back would put a raw UUID in front
|
||||
// of the user; the reason alone carries the meaning.
|
||||
unresolved.push({ spoken: '', reason: 'unknown_catalog_code' });
|
||||
}
|
||||
|
||||
|
||||
@@ -10,10 +10,13 @@ import type {
|
||||
import { WEEKDAYS } from './voice.types';
|
||||
|
||||
/**
|
||||
* The shape the model actually emits, and its JSON schema.
|
||||
*
|
||||
* Deliberately flat: strict `json_schema` mode has poor support for discriminated unions,
|
||||
* so every variant field is present and nullable. `toVoiceIntent` narrows it into the
|
||||
* internal union and is total — anything it cannot classify becomes a shape the resolvers
|
||||
* report as unresolved rather than something that throws here.
|
||||
* so every variant field is present and nullable on the wire. `toVoiceIntent` narrows the
|
||||
* flat shape into the internal union the resolvers consume, and is total — anything it
|
||||
* cannot classify becomes a shape the resolvers will report as unresolved rather than
|
||||
* something that throws here.
|
||||
*/
|
||||
|
||||
export type WireToothIntent = {
|
||||
@@ -180,8 +183,9 @@ const FDI_SHAPE = /^[1-8][1-8]$/;
|
||||
|
||||
function toToothIntent(wire: WireToothIntent | undefined | null): ToothIntent {
|
||||
const spoken = typeof wire?.spoken === 'string' ? wire.spoken : '';
|
||||
// "۲۶" and "2 6" are FDI codes that do not match literally; unnormalised they fall
|
||||
// through to the positional branch with no quadrant and read as unresolved.
|
||||
// Persian digits and digit-by-digit dictation ("۲۶", "2 6") are FDI codes that do not
|
||||
// match literally; without normalising first they fall through to the positional branch
|
||||
// with no quadrant and are reported as unresolved.
|
||||
const fdi = normalizeFdiCode(wire?.fdi);
|
||||
// Only take the explicit branch for something actually FDI-shaped. A model that emits
|
||||
// fdi:"6" alongside correct arch/side/position would otherwise lose the tooth entirely.
|
||||
|
||||
@@ -124,8 +124,9 @@ export class OpenRouterExtractionProvider implements ExtractionProvider {
|
||||
body: JSON.stringify({
|
||||
model: this.config.model,
|
||||
temperature: 0,
|
||||
// Only route to endpoints that actually honour the JSON schema — otherwise OpenRouter may
|
||||
// pick a provider that treats it as a hint and returns prose, failing intermittently.
|
||||
// Only route to endpoints that actually honour the JSON schema. Without this,
|
||||
// OpenRouter may pick a provider that treats it as a hint and returns prose,
|
||||
// which fails parsing intermittently and unreproducibly.
|
||||
provider: { require_parameters: true },
|
||||
messages: buildExtractionPrompt(transcript, catalog, localeHint),
|
||||
response_format: {
|
||||
|
||||
@@ -18,14 +18,18 @@ export type ToothResolution = {
|
||||
|
||||
/** Everything here parses untrusted model output, so nothing may throw. */
|
||||
function normalizedFdi(intent: ToothIntent): string {
|
||||
// The same normalisation the wire layer used to pick this branch, so the two agree.
|
||||
// Same normalisation the wire layer used to pick this branch, so the two cannot
|
||||
// disagree: '14 ' is tooth 14 through the treatment API and '۲۶' is tooth 26, and
|
||||
// neither may be reported as malformed here.
|
||||
return normalizeFdiCode((intent as { fdi?: unknown }).fdi);
|
||||
}
|
||||
|
||||
/**
|
||||
* Never guesses and never clamps: position 9, a deciduous tooth or a malformed intent all
|
||||
* resolve to null, so the caller surfaces "not understood" rather than silently selecting a
|
||||
* neighbouring tooth.
|
||||
* Resolve one spoken tooth reference to an FDI code, or null.
|
||||
*
|
||||
* Never guesses and never clamps: a position of 9, a deciduous tooth, or a malformed
|
||||
* intent resolves to null so the caller can surface it as "not understood" rather than
|
||||
* silently selecting a neighbouring tooth.
|
||||
*/
|
||||
export function resolveToothIntent(intent: ToothIntent): string | null {
|
||||
if (!intent || typeof intent !== 'object') return null;
|
||||
@@ -61,8 +65,9 @@ function unresolvedReason(intent: ToothIntent): UnresolvedItem['reason'] {
|
||||
intent.position < 1 ||
|
||||
intent.position > 8;
|
||||
if (positionBad) return 'position_out_of_range';
|
||||
// The position was understood; the quadrant was never said. "دندون دو" names four
|
||||
// teeth, so "could not be read" would send the clinician after the wrong fault.
|
||||
// The position was understood, so the words were not the problem: the speaker never
|
||||
// said which quadrant. "دندون دو" names four teeth at once, and telling the
|
||||
// clinician it "could not be read" would send them looking for the wrong fault.
|
||||
const archMissing = intent.arch !== 'upper' && intent.arch !== 'lower';
|
||||
const sideMissing =
|
||||
intent.side !== 'patient_right' && intent.side !== 'patient_left';
|
||||
@@ -73,8 +78,11 @@ function unresolvedReason(intent: ToothIntent): UnresolvedItem['reason'] {
|
||||
}
|
||||
|
||||
/**
|
||||
* The teeth still consistent with what *was* heard — "دو" leaves four, "دو بالا" two. Not
|
||||
* a guess: the full set of readings, for the clinician to choose from.
|
||||
* The teeth still consistent with what *was* heard.
|
||||
*
|
||||
* Narrowed by whatever the clinician did say, so "دو" offers four and "دو بالا" offers
|
||||
* two. This is not a guess — it is the full set of readings, handed to the clinician to
|
||||
* choose from rather than picked on their behalf.
|
||||
*/
|
||||
function quadrantCandidates(intent: ToothIntent): string[] {
|
||||
if (intent.kind !== 'positional') return [];
|
||||
@@ -101,8 +109,11 @@ function spokenOf(intent: ToothIntent): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicates collapse; anything unresolvable is reported rather than dropped, so the sheet
|
||||
* can show which words were not understood.
|
||||
* Resolve a list of spoken tooth references.
|
||||
*
|
||||
* Duplicates collapse — a clinician may name the same tooth twice in one sentence — and
|
||||
* anything unresolvable is reported rather than dropped, so the review sheet can show the
|
||||
* user exactly which words were not understood.
|
||||
*/
|
||||
export function resolveToothIntents(
|
||||
intents: readonly ToothIntent[],
|
||||
@@ -127,8 +138,10 @@ export function resolveToothIntents(
|
||||
const spoken = spokenOf(intent);
|
||||
const candidates =
|
||||
reason === 'tooth_missing_quadrant' ? quadrantCandidates(intent) : [];
|
||||
// Only dedupe what we can tell apart: without `spoken`, two lost references collapse
|
||||
// into one blank row and a tooth vanishes. Candidates are part of the identity.
|
||||
// Only dedupe items we can actually tell apart. Without `spoken`, two distinct lost
|
||||
// references would collapse into one blank review row and a tooth would vanish. The
|
||||
// candidates are part of the identity: the same word with a different arch heard
|
||||
// offers a different choice.
|
||||
if (spoken) {
|
||||
const key = `${spoken}::${reason}::${candidates.join(',')}`;
|
||||
if (seenUnresolved.has(key)) continue;
|
||||
|
||||
@@ -3,9 +3,15 @@ import { ThrottlerGuard } from '@nestjs/throttler';
|
||||
import { AppException, ErrorCode } from '../../common/errors';
|
||||
|
||||
/**
|
||||
* Rate limits voice extraction per user, not per IP: the default tracker keys on `req.ip`,
|
||||
* which behind nginx means the whole deployment shares one bucket unless `trust proxy` is
|
||||
* set, and one clinic could then lock out every other.
|
||||
* Rate limits voice extraction per user rather than per IP.
|
||||
*
|
||||
* The default tracker keys on `req.ip`, which behind nginx means the whole deployment
|
||||
* shares one bucket unless `trust proxy` is set — and an abuser rotating IPs would bypass
|
||||
* it entirely. Since v1 ships with no plan gate, this is the only control on metered
|
||||
* vendor spend, so it has to key on something the client cannot change.
|
||||
*
|
||||
* Guard order matters: the controller's JwtAuthGuard runs before this method-level guard,
|
||||
* so `req.user` is populated by the time `getTracker` is called.
|
||||
*/
|
||||
@Injectable()
|
||||
export class VoiceThrottlerGuard extends ThrottlerGuard {
|
||||
|
||||
@@ -48,8 +48,9 @@ export class VoiceController {
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
@Body() dto: ExtractVoiceDto,
|
||||
) {
|
||||
// Cancelling in the browser closes the connection; propagate it as an abort so the vendor
|
||||
// call stops rather than settling unseen. It is metered per minute.
|
||||
// Cancelling in the browser closes the connection; propagate that as an abort so the
|
||||
// in-flight vendor call stops rather than settling and being discarded. It is metered
|
||||
// per minute, so letting it run costs real money for a result nobody will see.
|
||||
const aborter = new AbortController();
|
||||
res.on('close', () => {
|
||||
if (!res.writableFinished) aborter.abort();
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { VoiceIntent } from './voice.types';
|
||||
|
||||
/**
|
||||
* ASR and extraction are separate, independently swappable roles — they will not come from
|
||||
* the same vendor for every locale. Both resolve per locale from `config.voice.profiles`.
|
||||
* ASR and extraction are separate, independently swappable roles — they will not come
|
||||
* from the same vendor for every locale. Both are resolved per locale from
|
||||
* `config.voice.profiles`, so pointing `fa` at a Persian-specialist vendor while `en`
|
||||
* and `nl` keep OpenRouter is configuration, not code.
|
||||
*/
|
||||
|
||||
export type AudioInput = {
|
||||
|
||||
@@ -55,8 +55,10 @@ export class VoiceService {
|
||||
}
|
||||
|
||||
/**
|
||||
* What the frontend needs to decide whether to render the microphone. v1 is ungated beyond
|
||||
* a configured locale profile; the Plan.features design is deferred, not dropped.
|
||||
* What the frontend needs to decide whether to render the microphone at all.
|
||||
*
|
||||
* v1 ships ungated beyond a configured locale profile — no plan check. The
|
||||
* Plan.features design is deferred, not dropped.
|
||||
*/
|
||||
getAvailability(): VoiceAvailability {
|
||||
const voice = this.voiceConfig;
|
||||
@@ -105,9 +107,10 @@ export class VoiceService {
|
||||
throw this.toAppException(error, 'asr');
|
||||
}
|
||||
|
||||
// durationMs is client-reported, so not enforcement. usage.seconds is the vendor's own
|
||||
// measurement — a client under-reporting to slip past the cap is caught here, after the
|
||||
// ASR spend but before the more expensive extraction call.
|
||||
// durationMs is client-reported and therefore not enforcement. usage.seconds is the
|
||||
// vendor's own measurement of the audio it decoded, so a client under-reporting length
|
||||
// to slip past the cap is caught here — after the ASR spend, but before the extraction
|
||||
// call, and visibly in telemetry.
|
||||
if (asrSeconds != null) {
|
||||
this.assertWithinCap(asrSeconds * 1000);
|
||||
}
|
||||
@@ -208,9 +211,13 @@ export class VoiceService {
|
||||
}
|
||||
|
||||
/**
|
||||
* The client auto-stops at maxMs and only then measures, so a capped recording always
|
||||
* reports slightly over. Without this tolerance every auto-stopped recording — the exact
|
||||
* case the cap exists for — would be rejected as too long.
|
||||
* Grace above the configured cap.
|
||||
*
|
||||
* The client auto-stops when elapsed >= maxMs, then measures the final length after the
|
||||
* recorder has actually stopped — so a recording that runs to the cap always reports
|
||||
* slightly over it. Without this tolerance the auto-stop would guarantee a rejection,
|
||||
* discarding exactly the recording it was meant to save. The client still reports the
|
||||
* true length, so telemetry stays honest.
|
||||
*/
|
||||
private static readonly CAP_TOLERANCE_MS = 2_000;
|
||||
|
||||
@@ -312,7 +319,10 @@ export class VoiceService {
|
||||
);
|
||||
}
|
||||
|
||||
/** Structured and patient-free: never the transcript, never audio, never a patient id. */
|
||||
/**
|
||||
* Structured, patient-free. Never the transcript, never audio, never a patient id.
|
||||
* Log lines are the interim sink until this repo has metrics infrastructure.
|
||||
*/
|
||||
private logTelemetry(input: {
|
||||
locale: string;
|
||||
durationMs: number;
|
||||
|
||||
@@ -78,8 +78,10 @@ export type UnresolvedItem = {
|
||||
spoken: string;
|
||||
reason: UnresolvedReason;
|
||||
/**
|
||||
* FDI codes still consistent with what was heard — "دو" leaves four, "دو بالا" two. Only
|
||||
* `tooth_missing_quadrant` carries them; the sheet offers them as chips.
|
||||
* FDI codes still consistent with what was heard, when a choice would settle it.
|
||||
* Only `tooth_missing_quadrant` carries these: "دو" leaves four teeth on the table,
|
||||
* "دو بالا" leaves two. The review sheet offers them so an under-specified tooth is one
|
||||
* tap from resolved rather than a dead end.
|
||||
*/
|
||||
candidates?: string[];
|
||||
};
|
||||
|
||||
@@ -145,25 +145,9 @@ path**. `onAddDetail` is not called and not changed.
|
||||
`setEntryStep('treatment')` matters: `showChrome` is always on, so the control is visible
|
||||
during the **Lab** wizard step too. Confirming there returns to the treatment step.
|
||||
|
||||
**Confirm also saves.** The new detail is persisted immediately (`persistDraft({force:true})`),
|
||||
and when the result carries a lab, a due date or a prosthesis map the lab case is saved with it
|
||||
(`persistLabCases`). Not politeness — the autosave effect watches `details` only, so a lab draft
|
||||
left in component state alone loses the destination lab, the due date and the whole prosthesis
|
||||
map on the next reload. The detail survives, which is what makes that loss look like a
|
||||
successful save.
|
||||
|
||||
One guard on it: `persistDraft` returns a **preview** treatment instead of saving when any
|
||||
detail still lacks a treatment type — the blank chip the workspace opens with is enough — and a
|
||||
preview's detail id falls back to the client id. Confirm therefore checks *what came back*, not
|
||||
the precondition, and skips the lab-case save when it did not get a real id; posting a lab case
|
||||
against an id the server has never seen fails the whole save. Checking the result rather than
|
||||
the condition keeps this true for every early return `persistDraft` has.
|
||||
|
||||
> Accepted consequences:
|
||||
> - Tapping Add and then 🎤 leaves behind the blank chip that Add created. It carries the
|
||||
> usual trash affordance.
|
||||
> - That same blank chip blocks confirm's immediate lab-case save until it is given a type
|
||||
> or removed; the lab rows stay in local state until the ordinary Lab-step save.
|
||||
> - Dictating into an existing detail is not supported in v1 — voice always makes a new
|
||||
> one.
|
||||
|
||||
@@ -498,17 +482,6 @@ that justified this whole design.
|
||||
- any row carrying an unresolved item or an incomplete prosthesis map.
|
||||
- Unresolved items are shown with what was heard ("دندان شیری — بازشناسی نشد"), so the
|
||||
clinician can see what the system did not understand.
|
||||
- **The sheet is a contract: confirm fills exactly what it previewed — no more.** Any
|
||||
per-detail convenience that would top the case up afterwards has to be suppressed for a
|
||||
voice-created case, because a default that quietly adds a prosthesis type to a tooth the
|
||||
sheet never mentioned turns the confirmation step into a lie about what it was going to
|
||||
do — which is the whole reason the step exists.
|
||||
|
||||
> This branch carried an exemption for one such default, the dispatch panel's
|
||||
> remembered-prosthesis auto-fill. `origin/master` deleted that feature outright
|
||||
> (`f52ad6b`), so the exemption went with it in the rebase and nothing enforces this rule
|
||||
> in code today. It is a constraint on whatever gets added next, not a description of
|
||||
> something that exists.
|
||||
- An item that carries `candidates` renders them as **tappable chips** — the one place the
|
||||
sheet is interactive. Picking one folds the tooth into the result (`withChosenTeeth`) and
|
||||
ticks the teeth row, so an under-specified tooth is one tap from resolved instead of a
|
||||
@@ -543,8 +516,8 @@ who can edit treatments, in every configured locale. `Plan.features.voiceTreatme
|
||||
and the availability API stay documented here as the intended gate, deferred rather than
|
||||
dropped, so turning them on later is additive.
|
||||
|
||||
Consequence to accept deliberately: with no plan gate, the per-user throttle and the 2-minute
|
||||
recording cap are the **only** controls on metered vendor spend. See open item 14.
|
||||
Consequence to accept deliberately: with no plan gate and no duration cap (§2), the
|
||||
per-user throttle is the **only** control on metered vendor spend. See open item 14.
|
||||
|
||||
---
|
||||
|
||||
@@ -556,31 +529,15 @@ English Nest exception for a user-facing failure.
|
||||
|
||||
| Code | When |
|
||||
|---|---|
|
||||
| `VOICE_MIC_DENIED` | microphone permission actually refused, or no input device — **client-side only**: needs the `errors.X` key in all three message files, but no `ErrorCode` entry and no throw site. Reserved for a real permission failure: see the note below |
|
||||
| `VOICE_CLIP_TOO_LONG` | over `maxMs` (server-side re-check), over vendor limits, or a request body past the DTO's size cap |
|
||||
| `VOICE_UNSUPPORTED_FORMAT` | **the browser cannot record at all** — no `MediaRecorder`, no container both it and the API accept, or a recorder that throws after permission was granted; and server-side, a `format` outside `VOICE_AUDIO_FORMATS` |
|
||||
| `VOICE_MIC_DENIED` | browser permission refused — **client-side only**: needs the `errors.X` key in all three message files, but no `ErrorCode` entry and no throw site |
|
||||
| `VOICE_CLIP_TOO_LONG` | over `maxMs` (server-side re-check), or over vendor limits |
|
||||
| `VOICE_UNSUPPORTED_FORMAT` | recorder produced a container the profile rejects |
|
||||
| `VOICE_ASR_FAILED` | transcription stage failed |
|
||||
| `VOICE_EXTRACT_FAILED` | transcript obtained, structuring failed |
|
||||
| `VOICE_NOTHING_RECOGNIZED` | empty or unusable transcript |
|
||||
| `VOICE_NOT_AVAILABLE` | no profile for locale (v1); plan flag off, once enforced |
|
||||
| `VOICE_RATE_LIMITED` | throttle |
|
||||
|
||||
**Two of these are raised by DTO validation, not by a throw site.**
|
||||
`validationExceptionFactory` returns a constraint's `message` verbatim when the message is
|
||||
itself a known `ErrorCode`, so the voice DTO names its own failures:
|
||||
`@MaxLength(…, { message: ErrorCode.VOICE_CLIP_TOO_LONG })` and
|
||||
`@IsIn(…, { message: ErrorCode.VOICE_UNSUPPORTED_FORMAT })`. Left to the shared constraint map
|
||||
they fall through to `VALIDATION_FIELD_REQUIRED` and `VALIDATION_LANGUAGE_INVALID` — an
|
||||
oversized recording telling the clinician a field is missing, and an unsupported container
|
||||
telling them their language is invalid. Any new voice constraint should name its code the same
|
||||
way.
|
||||
|
||||
**`VOICE_MIC_DENIED` is only for a real permission failure.** Three client paths used to report
|
||||
it for something else entirely — no `MediaRecorder`, no acceptable container, and a recorder
|
||||
that throws after permission was already granted. All three are "this browser cannot record"
|
||||
and now report `VOICE_UNSUPPORTED_FORMAT`; blaming the microphone sends the clinician hunting
|
||||
in site settings for a permission nothing ever asked for.
|
||||
|
||||
**Transcript salvage — specified, NOT built.** The backend half exists: `VOICE_EXTRACT_FAILED`
|
||||
carries `details.transcript` and `HttpExceptionFilter` forwards it. The client half was
|
||||
never written — `onError` only resolves a message through `getUserFacingError`, which never
|
||||
@@ -738,16 +695,7 @@ enabling this for real clinics.
|
||||
- no layout shift in the header row on record start, stop, or the 2:00 auto-stop;
|
||||
- **hold past 2:00** → auto-stops and proceeds to processing, not an error;
|
||||
- **cancel during processing** → the vendor request is actually aborted;
|
||||
- **review sheet on mobile** → full-screen overlay; closing it leaves the draft intact;
|
||||
- **confirm with a lab, a due date or a prosthesis map, then reload** → all three are still
|
||||
there. They live on the lab case, which the autosave effect does not watch, so this is
|
||||
the check that catches a lab draft left unsaved in component state;
|
||||
- **record straight after opening a visit**, while the blank chip is still untyped, and
|
||||
confirm with a lab ticked → no error toast: confirm detects the preview treatment and
|
||||
skips the lab-case save rather than posting an id the server has never seen;
|
||||
- **dictate two different prosthesis types** ("۱۲ روکش PFM، ۱۳ روکش PFZ") → the form shows
|
||||
both, and the bulk «اعمال برای همه دندانها» select stays on its placeholder. Nothing may
|
||||
rewrite a per-tooth type the sheet already showed.
|
||||
- **review sheet on mobile** → full-screen overlay; closing it leaves the draft intact.
|
||||
|
||||
---
|
||||
|
||||
@@ -779,14 +727,6 @@ Settled in a grilling session on 2026-08-20.
|
||||
| 28 | Review sheet | Modal on desktop, full-screen overlay (not a route) on mobile; candidate chips are its only interactive part |
|
||||
| 29 | Cancel | Aborts the in-flight vendor call |
|
||||
| 30 | v1 gating | Open to everyone; `Plan.features` gate deferred, not dropped |
|
||||
|
||||
Added while getting the first live recordings working (2026-08-21):
|
||||
|
||||
| # | Question | Decision |
|
||||
|---|---|---|
|
||||
| 31 | Tooth numbering | A spoken number **is** its FDI code, in all three locales. A lone digit stays unresolved and offers its candidate teeth as chips (§6, §7) |
|
||||
| 32 | What confirm writes | Confirm persists the detail *and* its lab case, because autosave watches `details` only — but skips the lab-case save when it got a preview treatment back (§2) |
|
||||
| 33 | Preview as contract | Applying a voice result fills exactly what the sheet showed. Per-detail conveniences that would add more are suppressed for that case (§7) |
|
||||
| 15 | Gating | `Plan.features` flag — its first consumer |
|
||||
|
||||
UI placement settled in a second grilling session on 2026-08-20.
|
||||
|
||||
@@ -5,7 +5,3 @@ NEXT_PUBLIC_API_URL=http://localhost:3000/api
|
||||
NEXT_PUBLIC_APP_NAME=DyoLink
|
||||
# URL where users open the frontend (used for metadata, images, etc.)
|
||||
NEXT_PUBLIC_APP_URL=http://localhost:3001
|
||||
|
||||
# GlitchTip frontend project DSN (optional locally). Baked into the Docker image in CI.
|
||||
# NEXT_PUBLIC_SENTRY_DSN=https://PUBLIC_KEY@errors.wixur.ir/2
|
||||
# NEXT_PUBLIC_SENTRY_ENVIRONMENT=development
|
||||
|
||||
@@ -18,16 +18,12 @@ COPY . .
|
||||
ARG NEXT_PUBLIC_API_URL
|
||||
ARG NEXT_PUBLIC_APP_URL
|
||||
ARG NEXT_PUBLIC_APP_NAME
|
||||
ARG NEXT_PUBLIC_SENTRY_DSN
|
||||
ARG NEXT_PUBLIC_SENTRY_ENVIRONMENT
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
|
||||
ENV NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL}
|
||||
ENV NEXT_PUBLIC_APP_NAME=${NEXT_PUBLIC_APP_NAME}
|
||||
ENV NEXT_PUBLIC_SENTRY_DSN=${NEXT_PUBLIC_SENTRY_DSN}
|
||||
ENV NEXT_PUBLIC_SENTRY_ENVIRONMENT=${NEXT_PUBLIC_SENTRY_ENVIRONMENT}
|
||||
|
||||
RUN npm run build
|
||||
|
||||
@@ -45,19 +41,19 @@ ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=dyolink:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=dyolink:nodejs /app/.next/static ./.next/static
|
||||
|
||||
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
# Windows git/build context may use CRLF; strip before chmod (fixes dumb-init "No such file or directory").
|
||||
RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh && chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
USER dyolink
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
|
||||
CMD node -e "require('http').get('http://127.0.0.1:3000/', (r) => { process.exit(r.statusCode >= 200 && r.statusCode < 400 ? 0 : 1); }).on('error', () => process.exit(1))"
|
||||
CMD node -e "require('http').get('http://127.0.0.1:3000/', (r) => {if(r.statusCode!==200)process.exit(1)})"
|
||||
|
||||
ENTRYPOINT ["dumb-init", "--", "docker-entrypoint.sh"]
|
||||
|
||||
|
||||
@@ -18,9 +18,6 @@
|
||||
"redirecting": "Redirecting…",
|
||||
"readOnlyAccess": "Read-only access for this organization.",
|
||||
"errorGeneric": "Something went wrong",
|
||||
"pageErrorTitle": "Something went wrong",
|
||||
"pageErrorBody": "This page failed to load. You can try again.",
|
||||
"tryAgain": "Try again",
|
||||
"loadingEllipsis": "Loading...",
|
||||
"search": "Search",
|
||||
"action": "Action",
|
||||
@@ -686,9 +683,7 @@
|
||||
"selectedPatient": "Selected patient",
|
||||
"purposeLabel": "Purpose:",
|
||||
"loadingAppointments": "Loading appointments…",
|
||||
"selectDayWithAppointment": "Search for a patient or select a visit from the day strip.",
|
||||
"noTreatmentFoundTitle": "No treatment found",
|
||||
"noTreatmentFoundBody": "There is no treatment or visit for {name} yet. You can add one with the {action} button in the rail.",
|
||||
"selectDayWithAppointment": "Select a day with at least one appointment.",
|
||||
"confirmDiscard": "You have unsaved changes. Discard them and continue?",
|
||||
"errorChooseOrg": "Choose at least one active organization to send this case.",
|
||||
"successCaseSent": "Case sent to selected organizations.",
|
||||
@@ -712,8 +707,6 @@
|
||||
"walkIn": "Walk-in",
|
||||
"newTreatment": "New treatment",
|
||||
"newTreatmentPatientPrompt": "Who is this visit for?",
|
||||
"newTreatmentUseCurrent": "Use {name}",
|
||||
"newTreatmentUseCurrentHint": "Start a new visit for the patient already open on this page.",
|
||||
"walkInPickerHint": "No named patient — always available, no search needed.",
|
||||
"errorCreateTreatment": "Could not create treatment.",
|
||||
"deleteEmptyTreatment": "Delete empty treatment",
|
||||
@@ -724,7 +717,6 @@
|
||||
"detailsTitle": "Treatment details",
|
||||
"addDetail": "Add detail",
|
||||
"confirmRemoveDetail": "Remove this treatment detail?",
|
||||
"confirmRemoveLastDetail": "This is the last detail. Removing it leaves the plan empty until you add another. Continue?",
|
||||
"removeDetailAria": "Remove detail {n}",
|
||||
"detailLabel": "Detail {n}",
|
||||
"detailSentBadge": "sent",
|
||||
|
||||
@@ -18,9 +18,6 @@
|
||||
"redirecting": "در حال انتقال...",
|
||||
"readOnlyAccess": "دسترسی فقط خواندنی برای این سازمان.",
|
||||
"errorGeneric": "خطایی رخ داده است",
|
||||
"pageErrorTitle": "خطایی رخ داده است",
|
||||
"pageErrorBody": "این صفحه بارگذاری نشد. میتوانید دوباره تلاش کنید.",
|
||||
"tryAgain": "تلاش دوباره",
|
||||
"loadingEllipsis": "در حال بارگذاری...",
|
||||
"search": "جستجو",
|
||||
"action": "عملیات",
|
||||
@@ -687,9 +684,7 @@
|
||||
"selectedPatient": "بیمار انتخاب شده",
|
||||
"purposeLabel": "هدف:",
|
||||
"loadingAppointments": "در حال بارگذاری نوبتها...",
|
||||
"selectDayWithAppointment": "برای بیمار جستجو کنید یا یک ویزیت را از نوار روز انتخاب کنید.",
|
||||
"noTreatmentFoundTitle": "درمانی یافت نشد",
|
||||
"noTreatmentFoundBody": "هنوز درمان یا ویزیتی برای {name} ثبت نشده است. میتوانید با دکمه {action} در نوار کناری یکی اضافه کنید.",
|
||||
"selectDayWithAppointment": "روزی را انتخاب کنید که حداقل یک نوبت داشته باشد.",
|
||||
"confirmDiscard": "تغییرات ذخیرهنشده دارید. آنها را کنار بگذارید و ادامه دهید؟",
|
||||
"errorChooseOrg": "حداقل یک سازمان فعال را برای ارسال این پرونده انتخاب کنید.",
|
||||
"successCaseSent": "پرونده به سازمانهای انتخاب شده ارسال شد.",
|
||||
@@ -713,8 +708,6 @@
|
||||
"walkIn": "بدون نوبت (مراجع)",
|
||||
"newTreatment": "درمان جدید",
|
||||
"newTreatmentPatientPrompt": "این ویزیت برای چه کسی است؟",
|
||||
"newTreatmentUseCurrent": "استفاده از {name}",
|
||||
"newTreatmentUseCurrentHint": "ویزیت جدیدی برای بیماری که همین حالا در این صفحه باز است شروع کنید.",
|
||||
"walkInPickerHint": "بیمار نامدار نیست — همیشه در دسترس است و نیازی به جستجو ندارد.",
|
||||
"errorCreateTreatment": "ایجاد درمان ممکن نشد.",
|
||||
"deleteEmptyTreatment": "حذف درمان خالی",
|
||||
@@ -725,7 +718,6 @@
|
||||
"detailsTitle": "جزئیات درمان",
|
||||
"addDetail": "افزودن جزئیات",
|
||||
"confirmRemoveDetail": "این جزئیات درمان حذف شود؟",
|
||||
"confirmRemoveLastDetail": "این آخرین جزئیات است. با حذف آن برنامه خالی میماند تا جزئیات جدیدی اضافه کنید. ادامه میدهید؟",
|
||||
"removeDetailAria": "حذف جزئیات {n}",
|
||||
"detailLabel": "جزئیات {n}",
|
||||
"detailSentBadge": "ارسالشده",
|
||||
|
||||
@@ -18,9 +18,6 @@
|
||||
"redirecting": "Bezig met doorsturen...",
|
||||
"readOnlyAccess": "Alleen-lezen toegang voor deze organisatie.",
|
||||
"errorGeneric": "Er is iets misgegaan",
|
||||
"pageErrorTitle": "Er is iets misgegaan",
|
||||
"pageErrorBody": "Deze pagina kon niet worden geladen. U kunt het opnieuw proberen.",
|
||||
"tryAgain": "Opnieuw proberen",
|
||||
"loadingEllipsis": "Laden...",
|
||||
"search": "Zoeken",
|
||||
"action": "Actie",
|
||||
@@ -686,9 +683,7 @@
|
||||
"selectedPatient": "Geselecteerde patiënt",
|
||||
"purposeLabel": "Doel:",
|
||||
"loadingAppointments": "Afspraken laden...",
|
||||
"selectDayWithAppointment": "Zoek een patiënt of kies een bezoek uit de dagstrook.",
|
||||
"noTreatmentFoundTitle": "Geen behandeling gevonden",
|
||||
"noTreatmentFoundBody": "Er is nog geen behandeling of bezoek voor {name}. U kunt er een toevoegen met de knop {action} in de zijbalk.",
|
||||
"selectDayWithAppointment": "Selecteer een dag met ten minste één afspraak.",
|
||||
"confirmDiscard": "U heeft niet-opgeslagen wijzigingen. Wilt u deze negeren en doorgaan?",
|
||||
"errorChooseOrg": "Kies ten minste één actieve organisatie om deze case te verzenden.",
|
||||
"successCaseSent": "Case verzonden naar geselecteerde organisaties.",
|
||||
@@ -712,8 +707,6 @@
|
||||
"walkIn": "Inloop",
|
||||
"newTreatment": "Nieuwe behandeling",
|
||||
"newTreatmentPatientPrompt": "Voor wie is dit bezoek?",
|
||||
"newTreatmentUseCurrent": "{name} gebruiken",
|
||||
"newTreatmentUseCurrentHint": "Start een nieuw bezoek voor de patiënt die al op deze pagina openstaat.",
|
||||
"walkInPickerHint": "Geen benoemde patiënt — altijd beschikbaar, zonder zoeken.",
|
||||
"errorCreateTreatment": "Behandeling aanmaken is mislukt.",
|
||||
"deleteEmptyTreatment": "Lege behandeling verwijderen",
|
||||
@@ -724,7 +717,6 @@
|
||||
"detailsTitle": "Behandeldetails",
|
||||
"addDetail": "Detail toevoegen",
|
||||
"confirmRemoveDetail": "Dit behandelingsdetail verwijderen?",
|
||||
"confirmRemoveLastDetail": "Dit is het laatste detail. Als u het verwijdert, blijft het plan leeg tot u een nieuw detail toevoegt. Doorgaan?",
|
||||
"removeDetailAria": "Detail {n} verwijderen",
|
||||
"detailLabel": "Detail {n}",
|
||||
"detailSentBadge": "verzonden",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { NextConfig } from 'next';
|
||||
import { withSentryConfig } from '@sentry/nextjs';
|
||||
import createNextIntlPlugin from 'next-intl/plugin';
|
||||
|
||||
const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
|
||||
@@ -36,15 +35,9 @@ const nextConfig: NextConfig = {
|
||||
NEXT_PUBLIC_APP_NAME: process.env.NEXT_PUBLIC_APP_NAME,
|
||||
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
|
||||
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
|
||||
NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
NEXT_PUBLIC_SENTRY_ENVIRONMENT: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT,
|
||||
},
|
||||
output: 'standalone',
|
||||
compress: true,
|
||||
};
|
||||
|
||||
export default withSentryConfig(withNextIntl(nextConfig), {
|
||||
silent: true,
|
||||
sourcemaps: { disable: true },
|
||||
disableLogger: true,
|
||||
});
|
||||
export default withNextIntl(nextConfig);
|
||||
|
||||
2023
frontend/package-lock.json
generated
2023
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@sentry/nextjs": "^10.72.0",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"axios": "^1.13.6",
|
||||
"js-cookie": "^3.0.5",
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
import { Button } from '@/components/ui/shared/Button';
|
||||
|
||||
export default function LocaleError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
const t = useTranslations('common');
|
||||
|
||||
useEffect(() => {
|
||||
Sentry.captureException(error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4 px-4 text-center">
|
||||
<h1 className="text-lg font-semibold">{t('pageErrorTitle')}</h1>
|
||||
<p className="max-w-md text-sm text-text-secondary">{t('pageErrorBody')}</p>
|
||||
<Button type="button" onClick={() => reset()}>
|
||||
{t('tryAgain')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
|
||||
export default function GlobalError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
Sentry.captureException(error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
minHeight: '100vh',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '1rem',
|
||||
padding: '1rem',
|
||||
textAlign: 'center',
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
}}
|
||||
>
|
||||
<h1 style={{ fontSize: '1.125rem', fontWeight: 600 }}>Something went wrong</h1>
|
||||
<p style={{ maxWidth: '28rem', fontSize: '0.875rem' }}>
|
||||
The page failed to load. You can try again.
|
||||
</p>
|
||||
<button type="button" onClick={() => reset()}>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -12,10 +12,3 @@ export type DayStripItem = {
|
||||
subtitle: string;
|
||||
canDelete?: boolean;
|
||||
};
|
||||
|
||||
/** Unscheduled card banner: first line’s type only. Empty / missing type → no color. */
|
||||
export function unscheduledStripColorCode(
|
||||
details: readonly { treatmentType?: string | null }[],
|
||||
): string {
|
||||
return details[0]?.treatmentType?.trim() ?? '';
|
||||
}
|
||||
|
||||
@@ -1,60 +1,73 @@
|
||||
const STORAGE_PREFIX = 'dyolink.labDispatchDefaults.';
|
||||
|
||||
/** Chips under lab search — last destinations this clinic actually sent a case to. */
|
||||
export const MAX_RECENT_LABS = 3;
|
||||
export type LabDispatchDefaults = {
|
||||
lastLabId: string | null;
|
||||
lastProsthesisByLab: Record<string, string>;
|
||||
};
|
||||
|
||||
const EMPTY: LabDispatchDefaults = {
|
||||
lastLabId: null,
|
||||
lastProsthesisByLab: {},
|
||||
};
|
||||
|
||||
function storageKey(clinicOrganizationId: string): string {
|
||||
return `${STORAGE_PREFIX}${clinicOrganizationId}`;
|
||||
}
|
||||
|
||||
function uniqueIds(ids: string[]): string[] {
|
||||
const out: string[] = [];
|
||||
for (const id of ids) {
|
||||
if (id && !out.includes(id)) out.push(id);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function loadRecentLabIds(clinicOrganizationId: string | null | undefined): string[] {
|
||||
if (!clinicOrganizationId || typeof window === 'undefined') return [];
|
||||
export function loadLabDispatchDefaults(clinicOrganizationId: string | null | undefined): LabDispatchDefaults {
|
||||
if (!clinicOrganizationId || typeof window === 'undefined') return EMPTY;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(storageKey(clinicOrganizationId));
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw) as {
|
||||
recentLabIds?: unknown;
|
||||
lastLabId?: unknown;
|
||||
if (!raw) return EMPTY;
|
||||
const parsed = JSON.parse(raw) as Partial<LabDispatchDefaults>;
|
||||
return {
|
||||
lastLabId: typeof parsed.lastLabId === 'string' ? parsed.lastLabId : null,
|
||||
lastProsthesisByLab:
|
||||
parsed.lastProsthesisByLab && typeof parsed.lastProsthesisByLab === 'object'
|
||||
? parsed.lastProsthesisByLab
|
||||
: {},
|
||||
};
|
||||
const fromList = Array.isArray(parsed.recentLabIds)
|
||||
? parsed.recentLabIds.filter((id): id is string => typeof id === 'string')
|
||||
: [];
|
||||
// Older builds stored a single lastLabId used as an auto-selected default.
|
||||
const fromLegacy = typeof parsed.lastLabId === 'string' ? [parsed.lastLabId] : [];
|
||||
return uniqueIds([...fromList, ...fromLegacy]).slice(0, MAX_RECENT_LABS);
|
||||
} catch {
|
||||
return [];
|
||||
return EMPTY;
|
||||
}
|
||||
}
|
||||
|
||||
function writeRecentLabIds(clinicOrganizationId: string, recentLabIds: string[]): void {
|
||||
function writeDefaults(clinicOrganizationId: string, next: LabDispatchDefaults): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
storageKey(clinicOrganizationId),
|
||||
JSON.stringify({ recentLabIds }),
|
||||
);
|
||||
window.localStorage.setItem(storageKey(clinicOrganizationId), JSON.stringify(next));
|
||||
} catch {
|
||||
// Ignore quota / private-mode failures.
|
||||
}
|
||||
}
|
||||
|
||||
/** Record a lab after a successful send — suggestion chips only, never a form default. */
|
||||
export function rememberRecentLab(
|
||||
export function rememberLastLab(clinicOrganizationId: string | null | undefined, labId: string): void {
|
||||
if (!clinicOrganizationId || !labId) return;
|
||||
const current = loadLabDispatchDefaults(clinicOrganizationId);
|
||||
writeDefaults(clinicOrganizationId, { ...current, lastLabId: labId });
|
||||
}
|
||||
|
||||
export function rememberLastProsthesisType(
|
||||
clinicOrganizationId: string | null | undefined,
|
||||
labId: string,
|
||||
prosthesisTypeCode: string,
|
||||
): void {
|
||||
if (!clinicOrganizationId || !labId) return;
|
||||
writeRecentLabIds(
|
||||
clinicOrganizationId,
|
||||
uniqueIds([labId, ...loadRecentLabIds(clinicOrganizationId)]).slice(0, MAX_RECENT_LABS),
|
||||
);
|
||||
if (!clinicOrganizationId || !labId || !prosthesisTypeCode) return;
|
||||
const current = loadLabDispatchDefaults(clinicOrganizationId);
|
||||
writeDefaults(clinicOrganizationId, {
|
||||
...current,
|
||||
lastLabId: labId,
|
||||
lastProsthesisByLab: {
|
||||
...current.lastProsthesisByLab,
|
||||
[labId]: prosthesisTypeCode,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function lastProsthesisTypeForLab(
|
||||
clinicOrganizationId: string | null | undefined,
|
||||
labId: string | null | undefined,
|
||||
): string | null {
|
||||
if (!clinicOrganizationId || !labId) return null;
|
||||
return loadLabDispatchDefaults(clinicOrganizationId).lastProsthesisByLab[labId] ?? null;
|
||||
}
|
||||
|
||||
@@ -19,9 +19,14 @@ export function voiceRowAvailability(result: VoiceExtractionResult) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything available ticks itself, with two exceptions: an inexactly-matched lab, because
|
||||
* it is the one extracted value whose error leaves the building; and an incomplete
|
||||
* prosthesis map, which cannot ship at all and would just move the failure to dispatch.
|
||||
* Which rows start ticked.
|
||||
*
|
||||
* Everything available ticks itself, with two deliberate exceptions:
|
||||
*
|
||||
* - **lab, when the name only approximately matched.** Shipping a case to a lab is the one
|
||||
* extracted value whose error leaves the building, so it always requires a deliberate tick.
|
||||
* - **prosthesis, when the map is incomplete.** A prosthesis detail with an untyped tooth
|
||||
* cannot ship at all, so applying it would just move the failure to dispatch.
|
||||
*/
|
||||
export function initialVoiceSelection(result: VoiceExtractionResult): VoiceApplySelection {
|
||||
const available = voiceRowAvailability(result);
|
||||
@@ -36,8 +41,11 @@ export function initialVoiceSelection(result: VoiceExtractionResult): VoiceApply
|
||||
}
|
||||
|
||||
/**
|
||||
* Intersected with availability rather than counting ticks: a row can be ticked and then lose
|
||||
* its content, and "Apply 1 item" that applies nothing is worse than a wrong number.
|
||||
* How many rows will actually be applied — drives the confirm button's label.
|
||||
*
|
||||
* Intersected with availability rather than counting ticks: a row can be ticked and then
|
||||
* lose its content (the last candidate tooth un-picked), and "Apply 1 item" that applies
|
||||
* nothing is worse than a wrong number.
|
||||
*/
|
||||
export function countSelected(
|
||||
selection: VoiceApplySelection,
|
||||
@@ -58,10 +66,16 @@ function recheckProsthesis(
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold the candidate picks into the result, so nothing downstream has to know chips exist.
|
||||
* Fold the clinician's candidate picks into the extracted result.
|
||||
*
|
||||
* Union rather than toggle: a candidate can coincidentally be a tooth the recording already
|
||||
* produced ("۱۲ و دو"), and tapping it must not deselect that one.
|
||||
* Everything downstream reads a `VoiceExtractionResult` — row availability, the mini
|
||||
* chart, the prosthesis warning, `applyVoiceResult` — so resolving the picks into one here
|
||||
* means none of them has to know the chips exist.
|
||||
*
|
||||
* Union rather than toggle, for two reasons: a candidate can coincidentally be a tooth the
|
||||
* recording already produced ("۱۲ و دو"), where tapping it must not deselect that tooth;
|
||||
* and `groupsFromFlatTeeth` keeps the bridges intact while giving every remaining tooth a
|
||||
* single group, so no tooth can be lost on the way through.
|
||||
*/
|
||||
export function withChosenTeeth(
|
||||
result: VoiceExtractionResult,
|
||||
@@ -89,7 +103,12 @@ export function connectedTeethFromResult(result: VoiceExtractionResult): Set<Fdi
|
||||
return connected;
|
||||
}
|
||||
|
||||
/** A recording that produced nothing should say so, not show an empty form of checkboxes. */
|
||||
/**
|
||||
* Whether the sheet has anything worth showing.
|
||||
*
|
||||
* A recording that produced nothing usable should say so plainly rather than present an
|
||||
* empty form of checkboxes.
|
||||
*/
|
||||
export function hasAnythingToApply(result: VoiceExtractionResult): boolean {
|
||||
return Object.values(voiceRowAvailability(result)).some(Boolean);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
||||
|
||||
interface TodayWidgetErrorBoundaryProps {
|
||||
@@ -24,7 +23,6 @@ export class TodayWidgetErrorBoundary extends Component<
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error('Today widget render error:', error, info);
|
||||
Sentry.captureException(error, { extra: { componentStack: info.componentStack } });
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
@@ -24,12 +24,6 @@ const TOOTH_NUMBER_GAP = 'mt-1';
|
||||
const REALISTIC_NUMBER_GAP = '2mm';
|
||||
/** Tight interproximal gap between tooth columns. */
|
||||
const TOOTH_GAP = 'gap-x-px';
|
||||
/**
|
||||
* Centers a mark on the inline-end edge of a tooth column (between this tooth
|
||||
* and the next in flex order). Logical `end` + 0-width flex stays correct in LTR and RTL.
|
||||
*/
|
||||
const EDGE_MARK_ANCHOR =
|
||||
'absolute inset-y-0 end-0 z-10 w-0 flex items-center justify-center';
|
||||
|
||||
function quadrantMirrored(fdi: FdiToothId): boolean {
|
||||
const q = fdi[0];
|
||||
@@ -291,42 +285,40 @@ export function FdiToothChart({
|
||||
/>
|
||||
) : null}
|
||||
{renderEdge ? (
|
||||
<div className={EDGE_MARK_ANCHOR}>
|
||||
{linkInteractive ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isDisabled}
|
||||
title={linked ? t('toothUnlinkHint') : t('toothLinkHint')}
|
||||
aria-label={
|
||||
linkInteractive ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isDisabled}
|
||||
title={linked ? t('toothUnlinkHint') : t('toothLinkHint')}
|
||||
aria-label={
|
||||
linked
|
||||
? t('toothUnlinkAria', { a: fdi, b: next! })
|
||||
: t('toothLinkAria', { a: fdi, b: next! })
|
||||
}
|
||||
aria-pressed={linked}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onToggleLink?.(fdi, next!);
|
||||
}}
|
||||
className={`
|
||||
absolute right-0 z-10 translate-x-1/2 h-3.5 w-3.5 rounded-full border-2 transition-colors
|
||||
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50
|
||||
${
|
||||
linked
|
||||
? t('toothUnlinkAria', { a: fdi, b: next! })
|
||||
: t('toothLinkAria', { a: fdi, b: next! })
|
||||
? 'border-primary bg-primary shadow-sm'
|
||||
: 'border-primary bg-background-secondary hover:bg-primary/15'
|
||||
}
|
||||
aria-pressed={linked}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onToggleLink?.(fdi, next!);
|
||||
}}
|
||||
className={`
|
||||
h-3.5 w-3.5 shrink-0 rounded-full border-2 transition-colors
|
||||
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50
|
||||
${
|
||||
linked
|
||||
? 'border-primary bg-primary shadow-sm'
|
||||
: 'border-primary bg-background-secondary hover:bg-primary/15'
|
||||
}
|
||||
${isDisabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
|
||||
`}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="h-2.5 w-2.5 shrink-0 rounded-full bg-primary shadow-sm"
|
||||
title={t('toothConnectedHint')}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
${isDisabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
|
||||
`}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="absolute right-0 z-10 translate-x-1/2 h-2.5 w-2.5 rounded-full bg-primary shadow-sm"
|
||||
title={t('toothConnectedHint')}
|
||||
aria-hidden
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Button } from '@/components/ui/shared/Button';
|
||||
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
||||
@@ -16,6 +16,12 @@ import { LabCaseTrackerCard } from '@/components/ui/treatment/LabCaseTrackerCard
|
||||
import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay';
|
||||
import { treatmentsApi } from '@/lib/api/treatments';
|
||||
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
|
||||
import {
|
||||
lastProsthesisTypeForLab,
|
||||
loadLabDispatchDefaults,
|
||||
rememberLastLab,
|
||||
rememberLastProsthesisType,
|
||||
} from '@/components/treatment/labDispatchDefaults';
|
||||
import type { ProsthesisCatalogEntry, TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||
import type { LabCaseDraft, LinkedOrganizationOption, TreatmentDetailDraft } from '@/types/treatment';
|
||||
import type { PatientLabCaseSummary } from '@/types/lab-case-activity';
|
||||
@@ -28,6 +34,7 @@ interface LabCasesDispatchPanelProps {
|
||||
labCases: LabCaseDraft[];
|
||||
labDependentCodes: Set<string>;
|
||||
treatmentCatalog: TreatmentCatalogEntry[];
|
||||
clinicOrganizationId?: string | null;
|
||||
labCaseSummary?: PatientLabCaseSummary | null;
|
||||
locale: string;
|
||||
onLabCaseSummaryChange?: (summary: PatientLabCaseSummary) => void;
|
||||
@@ -41,6 +48,7 @@ interface LabCasesDispatchPanelProps {
|
||||
organizationSearch: string;
|
||||
onOrganizationSearchChange: (value: string) => void;
|
||||
recentOrganizationIds: string[];
|
||||
onRecentOrganizationPick: (orgId: string) => void;
|
||||
canInviteLab?: boolean;
|
||||
onInviteLab?: () => void;
|
||||
sendBusyId: string | null;
|
||||
@@ -116,6 +124,7 @@ export function LabCasesDispatchPanel({
|
||||
labCases,
|
||||
labDependentCodes,
|
||||
treatmentCatalog,
|
||||
clinicOrganizationId,
|
||||
labCaseSummary,
|
||||
locale,
|
||||
onLabCaseSummaryChange,
|
||||
@@ -129,6 +138,7 @@ export function LabCasesDispatchPanel({
|
||||
organizationSearch,
|
||||
onOrganizationSearchChange,
|
||||
recentOrganizationIds,
|
||||
onRecentOrganizationPick,
|
||||
canInviteLab = false,
|
||||
onInviteLab,
|
||||
sendBusyId,
|
||||
@@ -140,6 +150,7 @@ export function LabCasesDispatchPanel({
|
||||
const [prosthesisOptions, setProsthesisOptions] = useState<ProsthesisCatalogEntry[]>([]);
|
||||
const [applyAllProsthesis, setApplyAllProsthesis] = useState('');
|
||||
const [pendingComment, setPendingComment] = useState('');
|
||||
const autoFilledCaseRef = useRef<string | null>(null);
|
||||
const hasTrackerSummary = Boolean(labCaseSummary && labCaseSummary.labCaseId);
|
||||
|
||||
const activeLinkedOrganizations = orgs.filter((o) => o.active);
|
||||
@@ -157,14 +168,7 @@ export function LabCasesDispatchPanel({
|
||||
|
||||
const activeLabCase =
|
||||
labCaseForActiveDetail ??
|
||||
(activeLabCaseId
|
||||
? labCases.find(
|
||||
(lc) =>
|
||||
lc.clientId === activeLabCaseId &&
|
||||
(lc.detailClientId == null || lc.detailClientId === activeDetailId),
|
||||
)
|
||||
: null) ??
|
||||
null;
|
||||
(activeLabCaseId ? labCases.find((lc) => lc.clientId === activeLabCaseId) : null);
|
||||
|
||||
const sent = Boolean(activeLabCase?.sentAt);
|
||||
const activeDetailNumber = details.findIndex((d) => d.clientId === activeDetailId) + 1;
|
||||
@@ -204,7 +208,6 @@ export function LabCasesDispatchPanel({
|
||||
|
||||
useEffect(() => {
|
||||
setPendingComment('');
|
||||
setApplyAllProsthesis('');
|
||||
}, [activeLabCase?.clientId]);
|
||||
|
||||
function updateActiveLabCase(patch: Partial<LabCaseDraft>) {
|
||||
@@ -223,6 +226,45 @@ export function LabCasesDispatchPanel({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- only sync newly uploaded files
|
||||
}, [activeDetail?.attachmentMetas, activeLabCase?.clientId, sent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeLabCase || sent || activeLabCase.destinationOrganizationId) return;
|
||||
const lastLabId = loadLabDispatchDefaults(clinicOrganizationId).lastLabId;
|
||||
const lastLab = lastLabId
|
||||
? activeLinkedOrganizations.find((o) => o.id === lastLabId)
|
||||
: undefined;
|
||||
if (!lastLab) return;
|
||||
updateActiveLabCase({ destinationOrganizationId: lastLab.id, toothProsthesis: [] });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeLabCase?.clientId, clinicOrganizationId, sent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeLabCase || sent) return;
|
||||
if (!activeLabCase.destinationOrganizationId) return;
|
||||
if (prosthesisOptions.length === 0 || prosthesisRows.length === 0) return;
|
||||
const fillKey = `${activeLabCase.clientId}:${prosthesisRows.length}`;
|
||||
if (autoFilledCaseRef.current === fillKey) return;
|
||||
if (isProsthesisMapComplete(activeLabCase, prosthesisRows)) {
|
||||
autoFilledCaseRef.current = fillKey;
|
||||
return;
|
||||
}
|
||||
const lastCode = lastProsthesisTypeForLab(
|
||||
clinicOrganizationId,
|
||||
activeLabCase.destinationOrganizationId,
|
||||
);
|
||||
if (!lastCode || !prosthesisOptions.some((opt) => opt.code === lastCode)) return;
|
||||
autoFilledCaseRef.current = fillKey;
|
||||
setApplyAllProsthesis(lastCode);
|
||||
updateActiveLabCase({ toothProsthesis: toothProsthesisForRows(prosthesisRows, lastCode) });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
activeLabCase?.clientId,
|
||||
activeLabCase?.destinationOrganizationId,
|
||||
clinicOrganizationId,
|
||||
prosthesisOptions,
|
||||
prosthesisRows.length,
|
||||
sent,
|
||||
]);
|
||||
|
||||
if (!activeDetail || !isLabDependentDetail) {
|
||||
return null;
|
||||
}
|
||||
@@ -245,11 +287,25 @@ export function LabCasesDispatchPanel({
|
||||
]
|
||||
: rest;
|
||||
updateActiveLabCase({ toothProsthesis: next });
|
||||
if (prosthesisTypeCode && activeLabCase.destinationOrganizationId) {
|
||||
rememberLastProsthesisType(
|
||||
clinicOrganizationId,
|
||||
activeLabCase.destinationOrganizationId,
|
||||
prosthesisTypeCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function applyProsthesisToAll(code: string) {
|
||||
if (!activeLabCase || !code) return;
|
||||
updateActiveLabCase({ toothProsthesis: toothProsthesisForRows(prosthesisRows, code) });
|
||||
if (activeLabCase.destinationOrganizationId) {
|
||||
rememberLastProsthesisType(
|
||||
clinicOrganizationId,
|
||||
activeLabCase.destinationOrganizationId,
|
||||
code,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAttachmentInActiveLabCase(attachmentId: string, checked: boolean) {
|
||||
@@ -261,11 +317,13 @@ export function LabCasesDispatchPanel({
|
||||
}
|
||||
|
||||
function handleSelectOrganization(org: LinkedOrganizationOption) {
|
||||
autoFilledCaseRef.current = null;
|
||||
updateActiveLabCase({
|
||||
destinationOrganizationId: org.id,
|
||||
toothProsthesis: [],
|
||||
});
|
||||
setApplyAllProsthesis('');
|
||||
rememberLastLab(clinicOrganizationId, org.id);
|
||||
}
|
||||
|
||||
const caseFullyComplete = isLabCaseCompleted(activeLabCase?.taskProgress);
|
||||
@@ -405,7 +463,10 @@ export function LabCasesDispatchPanel({
|
||||
key={o.id}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => handleSelectOrganization(o)}
|
||||
onClick={() => {
|
||||
handleSelectOrganization(o);
|
||||
onRecentOrganizationPick(o.id);
|
||||
}}
|
||||
className="text-xs rounded-[var(--radius-sm)] border border-border/70 px-2 py-1 text-text-secondary hover:text-text-primary hover:border-border focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 disabled:opacity-50"
|
||||
>
|
||||
{o.name}
|
||||
@@ -554,7 +615,26 @@ export function LabCasesDispatchPanel({
|
||||
!prosthesisComplete
|
||||
}
|
||||
isLoading={sendBusyId === activeLabCase.clientId}
|
||||
onClick={() => onSendLabCase(activeLabCase, pendingComment.trim())}
|
||||
onClick={() => {
|
||||
if (activeLabCase.destinationOrganizationId) {
|
||||
rememberLastLab(clinicOrganizationId, activeLabCase.destinationOrganizationId);
|
||||
const codes = [
|
||||
...new Set(
|
||||
activeLabCase.toothProsthesis
|
||||
.map((tp) => tp.prosthesisTypeCode)
|
||||
.filter(Boolean),
|
||||
),
|
||||
];
|
||||
if (codes.length === 1) {
|
||||
rememberLastProsthesisType(
|
||||
clinicOrganizationId,
|
||||
activeLabCase.destinationOrganizationId,
|
||||
codes[0],
|
||||
);
|
||||
}
|
||||
}
|
||||
return onSendLabCase(activeLabCase, pendingComment.trim());
|
||||
}}
|
||||
>
|
||||
{t('sendToLab')}
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
export function LabShipmentBlockedNotice() {
|
||||
const t = useTranslations('treatment');
|
||||
|
||||
return (
|
||||
<div className="surface-card p-4 border border-amber-500/35 bg-amber-500/5">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-amber-600 dark:text-amber-400 shrink-0 mt-0.5 icon-flat" />
|
||||
<div className="min-w-0 space-y-1">
|
||||
<h3 className="text-sm font-semibold text-text-primary">{t('labShipmentBlockedTitle')}</h3>
|
||||
<p className="text-xs text-text-muted">{t('labShipmentBlockedBody')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,45 +4,19 @@ import { useTranslations } from 'next-intl';
|
||||
import { Button } from '@/components/ui/shared/Button';
|
||||
import { PatientSearchCombobox } from '@/components/ui/patient/PatientSearchCombobox';
|
||||
import { usePatientSearchQuery } from '@/lib/hooks/usePatientSearchQuery';
|
||||
import { formatMobileForDisplay } from '@/lib/phone';
|
||||
import type { Patient } from '@/types/patient';
|
||||
|
||||
const pickerChoiceClass =
|
||||
'w-full rounded-[var(--radius-md)] border border-primary/40 bg-primary-soft px-3 py-2 text-start transition-colors hover:border-primary disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45';
|
||||
|
||||
interface NewTreatmentPatientPickerProps {
|
||||
creating?: boolean;
|
||||
currentPatient?: {
|
||||
id: string;
|
||||
displayName: string;
|
||||
mobile?: string | null;
|
||||
email?: string | null;
|
||||
} | null;
|
||||
onSelectWalkIn: () => void | Promise<void>;
|
||||
onSelectPatient: (patient: Patient) => void | Promise<void>;
|
||||
onSelectCurrentPatient?: () => void | Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function contactLine(
|
||||
patient: { mobile?: string | null; email?: string | null },
|
||||
fallback: string,
|
||||
): string {
|
||||
const mobile = patient.mobile?.trim()
|
||||
? formatMobileForDisplay(patient.mobile.trim())
|
||||
: '';
|
||||
if (mobile) return mobile;
|
||||
const email = patient.email?.trim();
|
||||
if (email) return email;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function NewTreatmentPatientPicker({
|
||||
creating = false,
|
||||
currentPatient = null,
|
||||
onSelectWalkIn,
|
||||
onSelectPatient,
|
||||
onSelectCurrentPatient,
|
||||
onCancel,
|
||||
}: NewTreatmentPatientPickerProps) {
|
||||
const t = useTranslations('treatment');
|
||||
@@ -57,25 +31,11 @@ export function NewTreatmentPatientPicker({
|
||||
type="button"
|
||||
disabled={creating}
|
||||
onClick={() => void onSelectWalkIn()}
|
||||
className={pickerChoiceClass}
|
||||
className="w-full rounded-[var(--radius-md)] border border-primary/40 bg-primary-soft px-3 py-2 text-start transition-colors hover:border-primary disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45"
|
||||
>
|
||||
<p className="text-sm font-medium text-text-primary">{t('walkIn')}</p>
|
||||
<p className="text-xs text-text-muted mt-0.5">{t('walkInPickerHint')}</p>
|
||||
</button>
|
||||
{currentPatient && onSelectCurrentPatient ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={creating}
|
||||
onClick={() => void onSelectCurrentPatient()}
|
||||
aria-label={t('newTreatmentUseCurrent', { name: currentPatient.displayName })}
|
||||
className={pickerChoiceClass}
|
||||
>
|
||||
<p className="text-sm font-medium text-text-primary break-words">{currentPatient.displayName}</p>
|
||||
<p className="text-xs text-text-muted mt-0.5 break-words">
|
||||
{contactLine(currentPatient, t('newTreatmentUseCurrentHint'))}
|
||||
</p>
|
||||
</button>
|
||||
) : null}
|
||||
<PatientSearchCombobox
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
|
||||
@@ -225,12 +225,12 @@ export function TreatmentDetailsEditor({
|
||||
|
||||
{stepper && activeDetail ? <div className="pt-1">{stepper}</div> : null}
|
||||
|
||||
{activeDetail && showMissingTeethLabBlock ? (
|
||||
<p className={labBlockedBannerClass}>{t('labShipmentBlockedBody')}</p>
|
||||
) : null}
|
||||
|
||||
{showFields && activeDetail ? (
|
||||
<div className="space-y-3">
|
||||
{showMissingTeethLabBlock && (
|
||||
<p className={labBlockedBannerClass}>{t('labShipmentBlockedBody')}</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:items-end">
|
||||
<Dropdown
|
||||
label={t('treatmentType')}
|
||||
@@ -290,8 +290,6 @@ export function TreatmentDetailsEditor({
|
||||
|
||||
{footer ? <div className="pt-1">{footer}</div> : null}
|
||||
</div>
|
||||
) : showFields && !activeDetail ? (
|
||||
<p className="text-sm text-text-muted">{t('noDetails')}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
@@ -338,12 +336,17 @@ function NotesField({
|
||||
/**
|
||||
* "Add detail", split into two segments with the microphone at the logical end.
|
||||
*
|
||||
* Built like the detail chip's trash affordance in this same file — a wrapper holding two
|
||||
* raw `<button>`s divided by `border-s` — rather than two shared `Button`s, which hardcode
|
||||
* their own rounding and would fight a segmented control. `border-s` puts the microphone
|
||||
* visually right in en/nl and left in fa, on the same side as the chip's trash in both.
|
||||
* Built like the detail chip's trash affordance in this same file — an
|
||||
* `inline-flex items-stretch overflow-hidden rounded` wrapper holding two raw `<button>`s
|
||||
* divided by `border-s` — rather than two shared `Button`s, which each hardcode their own
|
||||
* rounding and would fight a segmented control.
|
||||
*
|
||||
* Add keeps its exact existing behaviour; the microphone is an independent action.
|
||||
* `border-s` puts the microphone at the *logical* end: visually right in en/nl, visually
|
||||
* left in fa, on the same side as the chip's trash in both directions.
|
||||
*
|
||||
* The two halves share a wrapper and nothing else. Add keeps its exact existing
|
||||
* behaviour; the microphone is an independent action that creates nothing until the
|
||||
* clinician confirms.
|
||||
*/
|
||||
function AddDetailWithVoice({
|
||||
addLabel,
|
||||
|
||||
@@ -15,6 +15,7 @@ import { AppointmentsStrip } from '@/components/ui/treatment/AppointmentsStrip';
|
||||
import { NewTreatmentPatientPicker } from '@/components/ui/treatment/NewTreatmentPatientPicker';
|
||||
import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart';
|
||||
import { LabCasesDispatchPanel } from '@/components/ui/treatment/LabCasesDispatchPanel';
|
||||
import { LabShipmentBlockedNotice } from '@/components/ui/treatment/LabShipmentBlockedNotice';
|
||||
import { LabDispatchAttentionPanel } from '@/components/ui/treatment/LabDispatchAttentionPanel';
|
||||
import { PastTreatmentsPanel } from '@/components/ui/treatment/PastTreatmentsPanel';
|
||||
import { TreatmentDetailsEditor } from '@/components/ui/treatment/TreatmentDetailsEditor';
|
||||
@@ -40,7 +41,7 @@ import type {
|
||||
import { treatmentsApi } from '@/lib/api/treatments';
|
||||
import { notificationsApi } from '@/lib/api/notifications';
|
||||
import { pickAutoAppointment } from '@/components/shared/treatmentSelection';
|
||||
import { unscheduledStripColorCode, type DayStripItem } from '@/components/treatment/dayStrip';
|
||||
import type { DayStripItem } from '@/components/treatment/dayStrip';
|
||||
import {
|
||||
areDetailsPersistable,
|
||||
defaultTreatmentTypeForAppointment,
|
||||
@@ -64,11 +65,7 @@ import {
|
||||
} from '@/components/treatment/toothSelectionGroups';
|
||||
import type { LabDispatchAttentionItem } from '@/components/treatment/labDispatchAttention';
|
||||
import { collectLabDispatchAttention } from '@/components/treatment/labDispatchAttention';
|
||||
import {
|
||||
loadRecentLabIds,
|
||||
MAX_RECENT_LABS,
|
||||
rememberRecentLab,
|
||||
} from '@/components/treatment/labDispatchDefaults';
|
||||
import { loadLabDispatchDefaults, rememberLastLab } from '@/components/treatment/labDispatchDefaults';
|
||||
import { canEditTreatment, canViewTreatment, canAccessDashboardRoute } from '@/components/shared/permissions';
|
||||
import { scrollWithinMainScrollContainer } from '@/components/shared/scrollWithinMain';
|
||||
import { useMarkTabReadOnVisit, useTabBadgeCounts } from '@/lib/hooks/useTabBadgeCounts';
|
||||
@@ -214,7 +211,6 @@ function mapAppointment(record: AppointmentRecord): TreatmentAppointment {
|
||||
patientId: record.patientId,
|
||||
patientFirstName: record.patient.firstName,
|
||||
patientLastName: record.patient.lastName,
|
||||
patientMobile: record.patient.mobile,
|
||||
providerUserId: record.providerUserId,
|
||||
startAt: record.startAt,
|
||||
endAt: record.endAt,
|
||||
@@ -395,12 +391,10 @@ export function TreatmentWorkspace({
|
||||
const [unreadLabCasesLoading, setUnreadLabCasesLoading] = useState(false);
|
||||
const [labCasesScope, setLabCasesScope] = useState<TreatmentLabCasesScope>('patient');
|
||||
const [selectedRailLabCaseId, setSelectedRailLabCaseId] = useState<string | null>(null);
|
||||
const [searchedPatient, setSearchedPatient] = useState<
|
||||
(Pick<Patient, 'id' | 'firstName' | 'lastName'> & {
|
||||
mobile?: string | null;
|
||||
email?: string | null;
|
||||
}) | null
|
||||
>(null);
|
||||
const [searchedPatient, setSearchedPatient] = useState<Pick<
|
||||
Patient,
|
||||
'id' | 'firstName' | 'lastName'
|
||||
> | null>(null);
|
||||
const [patientSearchBusy, setPatientSearchBusy] = useState(false);
|
||||
const [newTreatmentPickerOpen, setNewTreatmentPickerOpen] = useState(false);
|
||||
const [creatingStandalone, setCreatingStandalone] = useState(false);
|
||||
@@ -533,8 +527,6 @@ export function TreatmentWorkspace({
|
||||
id: selectedAppointment.patientId,
|
||||
firstName: selectedAppointment.patientFirstName,
|
||||
lastName: selectedAppointment.patientLastName,
|
||||
mobile: selectedAppointment.patientMobile ?? null,
|
||||
email: null,
|
||||
purpose: selectedAppointment.purpose,
|
||||
isWalkIn: false,
|
||||
};
|
||||
@@ -545,8 +537,6 @@ export function TreatmentWorkspace({
|
||||
id: selectedStandalone.patientId,
|
||||
firstName: isWalkIn ? walkInLabel : (selectedStandalone.patient?.firstName ?? ''),
|
||||
lastName: isWalkIn ? '' : (selectedStandalone.patient?.lastName ?? ''),
|
||||
mobile: isWalkIn ? null : (selectedStandalone.patient?.mobile ?? null),
|
||||
email: isWalkIn ? null : (selectedStandalone.patient?.email ?? null),
|
||||
purpose: selectedStandalone.details[0]?.treatmentType,
|
||||
isWalkIn,
|
||||
};
|
||||
@@ -556,9 +546,7 @@ export function TreatmentWorkspace({
|
||||
id: searchedPatient.id,
|
||||
firstName: searchedPatient.firstName,
|
||||
lastName: searchedPatient.lastName,
|
||||
mobile: searchedPatient.mobile ?? null,
|
||||
email: searchedPatient.email ?? null,
|
||||
purpose: undefined,
|
||||
purpose: undefined as string | undefined,
|
||||
isWalkIn: false,
|
||||
};
|
||||
}
|
||||
@@ -569,23 +557,6 @@ export function TreatmentWorkspace({
|
||||
const activePatientName = activePatient
|
||||
? `${activePatient.firstName} ${activePatient.lastName}`.trim()
|
||||
: null;
|
||||
const namedActivePatient =
|
||||
activePatient && !activePatient.isWalkIn && activePatient.id
|
||||
? {
|
||||
id: activePatient.id,
|
||||
displayName: activePatientName ?? '',
|
||||
mobile: activePatient.mobile ?? null,
|
||||
email: activePatient.email ?? null,
|
||||
}
|
||||
: null;
|
||||
const searchedWithoutLiveVisit = Boolean(searchedPatient) && !hasLiveContext;
|
||||
const showSearchedPatientLoading =
|
||||
searchedWithoutLiveVisit && (patientSearchBusy || historyLoading);
|
||||
const showNoTreatmentFound =
|
||||
searchedWithoutLiveVisit &&
|
||||
!patientSearchBusy &&
|
||||
!historyLoading &&
|
||||
history.length === 0;
|
||||
|
||||
const unreadUpdatesCount = unreadLabCases.length;
|
||||
const otherPatientsUnreadCount = useMemo(
|
||||
@@ -657,8 +628,8 @@ export function TreatmentWorkspace({
|
||||
setStandaloneTreatments((prev) => {
|
||||
const current = prev.find((row) => row.id === selectedStandaloneId);
|
||||
if (!current) return prev;
|
||||
const prevColor = unscheduledStripColorCode(current.details);
|
||||
const nextColor = unscheduledStripColorCode(nextDetails);
|
||||
const prevColor = current.details.find((d) => d.treatmentType?.trim())?.treatmentType?.trim() ?? '';
|
||||
const nextColor = nextDetails.find((d) => d.treatmentType?.trim())?.treatmentType?.trim() ?? '';
|
||||
if (
|
||||
prevColor === nextColor &&
|
||||
areUnscheduledDetailsStripDeletable(current.details) ===
|
||||
@@ -689,7 +660,8 @@ export function TreatmentWorkspace({
|
||||
const sourceDetails = tr.id === selectedStandaloneId && !draftHydratingRef.current
|
||||
? details
|
||||
: tr.details;
|
||||
const colorCode = unscheduledStripColorCode(sourceDetails);
|
||||
const typedDetail = sourceDetails.find((d) => Boolean(d.treatmentType?.trim()));
|
||||
const colorCode = typedDetail?.treatmentType?.trim() ?? '';
|
||||
return {
|
||||
kind: 'unscheduled' as const,
|
||||
id: tr.id,
|
||||
@@ -791,7 +763,7 @@ export function TreatmentWorkspace({
|
||||
mapped.length > 0
|
||||
? mapped
|
||||
: options?.seedBlankIfEmpty
|
||||
? [newDetail()]
|
||||
? [newDetail(defaultTreatmentTypeForAppointment(undefined, treatmentCatalog))]
|
||||
: [];
|
||||
setDetails(nextDetails);
|
||||
setActiveDetailId((prev) => {
|
||||
@@ -806,7 +778,7 @@ export function TreatmentWorkspace({
|
||||
setActiveLabCaseId(mappedLabCases[0]?.clientId ?? null);
|
||||
setOrganizationSearch('');
|
||||
setSaveStatus('idle');
|
||||
}, []);
|
||||
}, [treatmentCatalog]);
|
||||
|
||||
const selectedTeethSet = useMemo(() => new Set(activeDetail?.teeth ?? []), [activeDetail?.teeth]);
|
||||
const connectedSelectedTeeth = useMemo(
|
||||
@@ -859,7 +831,6 @@ export function TreatmentWorkspace({
|
||||
useEffect(() => {
|
||||
setShowWholeTreatmentPlan(false);
|
||||
rangeAnchorRef.current = null;
|
||||
setOrganizationSearch('');
|
||||
const pending = pendingEntryStepRef.current;
|
||||
pendingEntryStepRef.current = null;
|
||||
setEntryStep(pending ?? 'treatment');
|
||||
@@ -973,11 +944,11 @@ export function TreatmentWorkspace({
|
||||
setLabDependentCodes(
|
||||
new Set(catalogResponse.data.filter((entry) => entry.labDependent).map((entry) => entry.code)),
|
||||
);
|
||||
const recentIds = loadRecentLabIds(currentOrganization?.id).filter((id) =>
|
||||
orgsResponse.data.some((o) => o.id === id && o.active),
|
||||
);
|
||||
if (recentIds.length > 0) {
|
||||
setRecentOrganizationIds(recentIds);
|
||||
const lastLabId = loadLabDispatchDefaults(currentOrganization?.id).lastLabId;
|
||||
if (lastLabId && orgsResponse.data.some((o) => o.id === lastLabId && o.active)) {
|
||||
setRecentOrganizationIds((prev) =>
|
||||
prev.includes(lastLabId) ? prev : [lastLabId, ...prev].slice(0, 10),
|
||||
);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (!cancelled) {
|
||||
@@ -1612,51 +1583,24 @@ export function TreatmentWorkspace({
|
||||
return;
|
||||
}
|
||||
|
||||
const ok = await flushDraftSave();
|
||||
if (!ok) return;
|
||||
|
||||
setPatientSearchBusy(true);
|
||||
setSearchedPatient({
|
||||
id: patient.id,
|
||||
firstName: patient.firstName,
|
||||
lastName: patient.lastName,
|
||||
});
|
||||
try {
|
||||
const stripAppointment = appointments.find((row) => row.patientId === patient.id);
|
||||
const stripStandalone = standaloneTreatments.find(
|
||||
(row) => row.patientId === patient.id && !row.patient?.isWalkIn,
|
||||
);
|
||||
if (stripAppointment || stripStandalone) {
|
||||
draftHydratingRef.current = true;
|
||||
resetToLiveContext();
|
||||
setSearchedPatient(null);
|
||||
setNewTreatmentPickerOpen(false);
|
||||
setSelectionLocked(true);
|
||||
if (stripAppointment) {
|
||||
setSelectedAppointmentId(stripAppointment.id);
|
||||
setSelectedStandaloneId(null);
|
||||
} else if (stripStandalone) {
|
||||
setSelectedAppointmentId(null);
|
||||
setSelectedStandaloneId(stripStandalone.id);
|
||||
const response = await treatmentsApi.listPatientHistory(patient.id, 1);
|
||||
const latest = response.data[0];
|
||||
if (latest) {
|
||||
const ok = await loadTreatmentIntoWorkspace(latest);
|
||||
if (!ok) {
|
||||
setSearchedPatient(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setSearchedPatient({
|
||||
id: patient.id,
|
||||
firstName: patient.firstName,
|
||||
lastName: patient.lastName,
|
||||
mobile: patient.mobile,
|
||||
email: patient.email,
|
||||
});
|
||||
resetToLiveContext();
|
||||
setNewTreatmentPickerOpen(false);
|
||||
setSelectedAppointmentId(null);
|
||||
setSelectedStandaloneId(null);
|
||||
setSelectionLocked(true);
|
||||
setHistory([]);
|
||||
setHistoryLoading(true);
|
||||
|
||||
const response = await treatmentsApi.listPatientHistory(patient.id, 1);
|
||||
const latest = response.data[0];
|
||||
if (latest) {
|
||||
await loadTreatmentIntoWorkspace(latest);
|
||||
}
|
||||
await refreshHistory(patient.id);
|
||||
} catch (error: unknown) {
|
||||
showError(getUserFacingError(error, tErrors, t('errorLoadHistory')));
|
||||
setSearchedPatient(null);
|
||||
@@ -1670,11 +1614,8 @@ export function TreatmentWorkspace({
|
||||
selectedStandalone?.patientId,
|
||||
searchedPatient?.id,
|
||||
hasLiveContext,
|
||||
flushDraftSave,
|
||||
appointments,
|
||||
standaloneTreatments,
|
||||
resetToLiveContext,
|
||||
loadTreatmentIntoWorkspace,
|
||||
refreshHistory,
|
||||
showError,
|
||||
t,
|
||||
tErrors,
|
||||
@@ -2000,7 +1941,8 @@ export function TreatmentWorkspace({
|
||||
|
||||
const nextDetails = [...detailsRef.current, detail];
|
||||
setDetails(nextDetails);
|
||||
// persistDraft reads detailsRef, and setDetails has not rendered yet.
|
||||
// persistDraft reads detailsRef, and setDetails has not rendered yet. The codebase
|
||||
// already writes this ref imperatively after a save for the same reason.
|
||||
detailsRef.current = nextDetails;
|
||||
setActiveDetailId(detail.clientId);
|
||||
setEntryStep('treatment');
|
||||
@@ -2044,18 +1986,13 @@ export function TreatmentWorkspace({
|
||||
const updatedLabCases = [...labCaseDrafts, draft];
|
||||
setLabCaseDrafts(updatedLabCases);
|
||||
|
||||
// Autosave only watches `details`, so a lab draft left in state alone loses the
|
||||
// lab, the due date and the prosthesis map on reload — silently, because the
|
||||
// detail itself survives.
|
||||
// Every other path that creates a lab draft persists it immediately, and the
|
||||
// autosave effect only watches `details`. Left in state alone, the destination
|
||||
// lab, the due date and the whole prosthesis map vanish on the next reload —
|
||||
// silently, because the detail itself does survive.
|
||||
void (async () => {
|
||||
try {
|
||||
const saved = await persistDraft({ force: true });
|
||||
// persistDraft returns a *preview* when the details are not persistable — one
|
||||
// blank detail is enough — and a preview's detail id falls back to the client
|
||||
// id. Check what came back, not the precondition, so this holds for every early
|
||||
// return persistDraft has.
|
||||
const savedDetail = saved.details.find((d) => d.clientId === detail.clientId);
|
||||
if (!savedDetail?.id || savedDetail.id === detail.clientId) return;
|
||||
await persistLabCases(saved, updatedLabCases);
|
||||
} catch (error: unknown) {
|
||||
showError(getUserFacingError(error, tErrors, t('errorSaveLabShipments')));
|
||||
@@ -2084,9 +2021,7 @@ export function TreatmentWorkspace({
|
||||
if (idx < 0) return;
|
||||
const target = details[idx];
|
||||
if (!target || isDetailLocked(target)) return;
|
||||
if (!window.confirm(
|
||||
details.length <= 1 ? t('confirmRemoveLastDetail') : t('confirmRemoveDetail'),
|
||||
)) return;
|
||||
if (!window.confirm(t('confirmRemoveDetail'))) return;
|
||||
|
||||
const nextDetails = details.filter((d) => d.clientId !== detailClientId);
|
||||
const nextActive =
|
||||
@@ -2162,9 +2097,14 @@ export function TreatmentWorkspace({
|
||||
return;
|
||||
}
|
||||
|
||||
const lastLabId = loadLabDispatchDefaults(currentOrganization?.id).lastLabId;
|
||||
const lastLabStillActive = lastLabId
|
||||
? orgs.some((o) => o.id === lastLabId && o.active)
|
||||
: false;
|
||||
const next: LabCaseDraft = {
|
||||
...newLabCaseDraft(),
|
||||
detailClientId: shouldIncludeActive ? activeDetailId : null,
|
||||
destinationOrganizationId: lastLabStillActive ? lastLabId! : null,
|
||||
attachmentIds: activeDetail?.attachmentMetas.map((a) => a.id) ?? [],
|
||||
};
|
||||
const updatedLabCases = [...cleaned, next];
|
||||
@@ -2180,9 +2120,11 @@ export function TreatmentWorkspace({
|
||||
}, [
|
||||
activeDetailId,
|
||||
canEditTreatmentForDay,
|
||||
currentOrganization?.id,
|
||||
details,
|
||||
labCaseDrafts,
|
||||
labDependentCodes,
|
||||
orgs,
|
||||
persistDraft,
|
||||
persistLabCases,
|
||||
selectedAppointment,
|
||||
@@ -2302,8 +2244,8 @@ export function TreatmentWorkspace({
|
||||
|
||||
setRecentOrganizationIds((prev) => {
|
||||
const orgId = labCase.destinationOrganizationId!;
|
||||
rememberRecentLab(currentOrganization?.id, orgId);
|
||||
return [orgId, ...prev.filter((id) => id !== orgId)].slice(0, MAX_RECENT_LABS);
|
||||
rememberLastLab(currentOrganization?.id, orgId);
|
||||
return [orgId, ...prev.filter((id) => id !== orgId)].slice(0, 10);
|
||||
});
|
||||
showSuccess(t('successCaseSent'));
|
||||
notifyTabBadgesChanged();
|
||||
@@ -2344,24 +2286,11 @@ export function TreatmentWorkspace({
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<header className="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
|
||||
<div className="min-w-0 shrink-0">
|
||||
<h1 className="text-xl sm:text-2xl font-semibold text-text-primary">{t('title')}</h1>
|
||||
{!canEdit ? (
|
||||
<p className="text-sm text-text-secondary mt-1">{t('subtitleReadOnly')}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<PatientSearchCombobox
|
||||
search={patientSearch}
|
||||
onSearchChange={setPatientSearch}
|
||||
patients={patientSearchResults}
|
||||
loading={patientSearchLoading || patientSearchBusy}
|
||||
onSelectPatient={handleSelectSearchedPatient}
|
||||
placeholder={tPatients('searchPlaceholder')}
|
||||
emptyResultsMessage={tPatients('noResults')}
|
||||
/>
|
||||
</div>
|
||||
<header className="space-y-1">
|
||||
<h1 className="text-xl sm:text-2xl font-semibold text-text-primary">{t('title')}</h1>
|
||||
{!canEdit ? (
|
||||
<p className="text-sm text-text-secondary">{t('subtitleReadOnly')}</p>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<AppointmentsStrip
|
||||
@@ -2392,6 +2321,15 @@ export function TreatmentWorkspace({
|
||||
<div className="treatment-layout-grid grid grid-cols-1 xl:grid-cols-[minmax(300px,380px)_minmax(0,1fr)] gap-4 items-start">
|
||||
<div className="space-y-3 min-w-0 xl:max-w-[380px]">
|
||||
<div className="surface-card p-3 space-y-3">
|
||||
<PatientSearchCombobox
|
||||
search={patientSearch}
|
||||
onSearchChange={setPatientSearch}
|
||||
patients={patientSearchResults}
|
||||
loading={patientSearchLoading || patientSearchBusy}
|
||||
onSelectPatient={handleSelectSearchedPatient}
|
||||
placeholder={tPatients('searchPlaceholder')}
|
||||
emptyResultsMessage={tPatients('noResults')}
|
||||
/>
|
||||
{canEdit && !isViewingPastDay ? (
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
@@ -2406,16 +2344,10 @@ export function TreatmentWorkspace({
|
||||
{newTreatmentPickerOpen ? (
|
||||
<NewTreatmentPatientPicker
|
||||
creating={creatingStandalone}
|
||||
currentPatient={namedActivePatient}
|
||||
onSelectWalkIn={() => createStandaloneTreatment({ walkIn: true })}
|
||||
onSelectPatient={(patient) =>
|
||||
createStandaloneTreatment({ patientId: patient.id })
|
||||
}
|
||||
onSelectCurrentPatient={
|
||||
namedActivePatient
|
||||
? () => createStandaloneTreatment({ patientId: namedActivePatient.id })
|
||||
: undefined
|
||||
}
|
||||
onCancel={() => setNewTreatmentPickerOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
@@ -2423,14 +2355,8 @@ export function TreatmentWorkspace({
|
||||
) : null}
|
||||
|
||||
{activePatient ? (
|
||||
<div
|
||||
className={`space-y-0.5 ${
|
||||
canEdit && !isViewingPastDay ? 'border-t border-border/60 pt-3' : ''
|
||||
}`}
|
||||
>
|
||||
<p className="text-[10px] uppercase tracking-wide text-text-muted">
|
||||
{t('selectedPatient')}
|
||||
</p>
|
||||
<div className="space-y-0.5 border-t border-border/60 pt-3">
|
||||
<p className="text-[10px] uppercase tracking-wide text-text-muted">{t('selectedPatient')}</p>
|
||||
<p className="text-base font-semibold text-text-primary">{activePatientName}</p>
|
||||
{activePatient.purpose ? (
|
||||
<p className="text-[11px] text-text-secondary">
|
||||
@@ -2442,11 +2368,7 @@ export function TreatmentWorkspace({
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<p
|
||||
className={`text-sm text-text-muted ${
|
||||
canEdit && !isViewingPastDay ? 'border-t border-border/60 pt-3' : ''
|
||||
}`}
|
||||
>
|
||||
<p className="text-sm text-text-muted border-t border-border/60 pt-3">
|
||||
{apptsLoading ? t('loadingAppointments') : t('selectDayWithAppointment')}
|
||||
</p>
|
||||
)}
|
||||
@@ -2549,24 +2471,6 @@ export function TreatmentWorkspace({
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 min-w-0 w-full">
|
||||
{showSearchedPatientLoading ? (
|
||||
<div className="surface-card p-6">
|
||||
<p className="text-sm text-text-muted">{t('loading')}</p>
|
||||
</div>
|
||||
) : showNoTreatmentFound ? (
|
||||
<div className="surface-card w-full p-6 space-y-3">
|
||||
<h2 className="text-lg font-semibold text-text-primary">
|
||||
{t('noTreatmentFoundTitle')}
|
||||
</h2>
|
||||
<p className="text-sm text-text-secondary">
|
||||
{t('noTreatmentFoundBody', {
|
||||
name: activePatientName ?? '',
|
||||
action: t('newTreatment'),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<TreatmentDetailsEditor
|
||||
details={details}
|
||||
activeDetailId={activeDetailId}
|
||||
@@ -2580,14 +2484,9 @@ export function TreatmentWorkspace({
|
||||
saveStatus={saveStatus}
|
||||
uploadBusy={uploadBusyDetailId === activeDetailId}
|
||||
onAddDetail={() => {
|
||||
const seedFromAppointment =
|
||||
details.length === 0 && selectedAppointment
|
||||
? defaultTreatmentTypeForAppointment(
|
||||
selectedAppointment.purpose,
|
||||
treatmentCatalog,
|
||||
)
|
||||
: undefined;
|
||||
const next = newDetail(seedFromAppointment);
|
||||
const next = newDetail(
|
||||
defaultTreatmentTypeForAppointment(selectedAppointment?.purpose, treatmentCatalog),
|
||||
);
|
||||
setDetails((prev) => [...prev, next]);
|
||||
setActiveDetailId(next.clientId);
|
||||
setEntryStep('treatment');
|
||||
@@ -2827,6 +2726,7 @@ export function TreatmentWorkspace({
|
||||
|
||||
{entryStep === 'lab' ? (
|
||||
<div ref={labPanelRef} className="space-y-3">
|
||||
{showLabShipmentBlocked ? <LabShipmentBlockedNotice /> : null}
|
||||
{showLabDispatchPanel ? (
|
||||
<LabCasesDispatchPanel
|
||||
details={details}
|
||||
@@ -2834,6 +2734,7 @@ export function TreatmentWorkspace({
|
||||
labCases={labCaseDrafts}
|
||||
labDependentCodes={labDependentCodes}
|
||||
treatmentCatalog={treatmentCatalog}
|
||||
clinicOrganizationId={currentOrganization?.id}
|
||||
labCaseSummary={activeLabCaseSummary}
|
||||
locale={locale}
|
||||
onLabCaseSummaryChange={handleLabCaseSummaryChange}
|
||||
@@ -2851,21 +2752,33 @@ export function TreatmentWorkspace({
|
||||
organizationSearch={organizationSearch}
|
||||
onOrganizationSearchChange={setOrganizationSearch}
|
||||
recentOrganizationIds={recentOrganizationIds}
|
||||
onRecentOrganizationPick={(orgId) => {
|
||||
setLabCaseDrafts((prev) => {
|
||||
const targetId =
|
||||
activeLabCaseId ??
|
||||
prev.find((lc) => !lc.sentAt && lc.detailClientId === activeDetailId)
|
||||
?.clientId;
|
||||
if (!targetId) return prev;
|
||||
return prev.map((lc) =>
|
||||
lc.clientId === targetId && !lc.sentAt
|
||||
? { ...lc, destinationOrganizationId: orgId }
|
||||
: lc,
|
||||
);
|
||||
});
|
||||
}}
|
||||
sendBusyId={sendBusyId}
|
||||
onSendLabCase={(lc, comment) => handleSendLabCase(lc, comment)}
|
||||
onCommentError={showError}
|
||||
canInviteLab={canAccessOrganizations}
|
||||
onInviteLab={() => router.push('/organizations?action=invite-lab')}
|
||||
/>
|
||||
) : showLabShipmentBlocked ? null : (
|
||||
) : (
|
||||
<p className="text-sm text-text-muted surface-card p-4">
|
||||
{t('entryStepLabUnavailable')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{voiceResult ? (
|
||||
|
||||
@@ -16,9 +16,9 @@ function formatElapsed(ms: number): string {
|
||||
/**
|
||||
* Live recording / processing strip.
|
||||
*
|
||||
* Sits between the header row and the chip strip rather than inside the segmented control:
|
||||
* the header is `sm:justify-between`, so growing the button mid-recording would shift the
|
||||
* whole row.
|
||||
* Sits between the header row and the chip strip rather than inside the segmented
|
||||
* control: the header is `sm:justify-between`, so growing the button mid-recording would
|
||||
* shove the row on every start and every stop.
|
||||
*/
|
||||
export function VoiceRecordingBar({ voice }: { voice: VoiceCaptureState }) {
|
||||
const t = useTranslations('treatment');
|
||||
|
||||
@@ -38,8 +38,9 @@ interface VoiceReviewSheetProps {
|
||||
/**
|
||||
* Confirmation step between the model's output and the form.
|
||||
*
|
||||
* Modal on desktop, bottom sheet on mobile — an overlay and not a route, because navigating
|
||||
* would unmount TreatmentWorkspace and destroy the in-progress draft.
|
||||
* Modal on desktop, bottom sheet on mobile via ResponsiveDialog — deliberately an overlay
|
||||
* and not a route, because navigating would unmount TreatmentWorkspace and destroy the
|
||||
* in-progress draft.
|
||||
*/
|
||||
export function VoiceReviewSheet({
|
||||
result,
|
||||
@@ -77,9 +78,10 @@ export function VoiceReviewSheet({
|
||||
// The teeth row starts unticked whenever the recording produced no teeth of its own,
|
||||
// and a picked tooth that is not ticked applies nothing.
|
||||
teeth: true,
|
||||
// A picked tooth has no prosthesis type, so the map is no longer shippable — leaving the
|
||||
// row ticked would apply a map dispatch rejects. Only ever unticks; re-ticking is the
|
||||
// clinician's call.
|
||||
// The picked tooth has no prosthesis type, which makes the map unshippable. Leaving
|
||||
// the row ticked would apply a map that `assertCompleteToothProsthesisMap` rejects
|
||||
// at dispatch — the exact failure the never-auto-tick-incomplete rule exists to
|
||||
// prevent. Only ever unticks: re-ticking is the clinician's call.
|
||||
prosthesis:
|
||||
prev.prosthesis &&
|
||||
withChosenTeeth(result, nextChosen).prosthesis?.complete !== false,
|
||||
@@ -288,8 +290,9 @@ function Row({
|
||||
}
|
||||
|
||||
/**
|
||||
* `new Date('2025-10-17')` parses a civil date as UTC midnight, which renders as the 16th
|
||||
* west of Greenwich. Build it from its parts so it means the same day everywhere.
|
||||
* A bare `YYYY-MM-DD` is a *civil* date, but `new Date('2025-10-17')` parses it as UTC
|
||||
* midnight — which renders as the 16th for any viewer west of Greenwich. Build the date
|
||||
* from its parts so it means the same day everywhere.
|
||||
*/
|
||||
function civilDateToLocalDate(iso: string): Date {
|
||||
const [year, month, day] = iso.split('-').map(Number);
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
import { sentrySharedOptions } from '@/lib/error-tracking/sentrySharedOptions';
|
||||
|
||||
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN?.trim();
|
||||
|
||||
if (dsn) {
|
||||
Sentry.init({
|
||||
dsn,
|
||||
environment:
|
||||
process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT?.trim() ||
|
||||
process.env.NODE_ENV ||
|
||||
'development',
|
||||
...sentrySharedOptions(),
|
||||
});
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
|
||||
export async function register(): Promise<void> {
|
||||
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
||||
await import('./lib/error-tracking/sentry.server.config');
|
||||
}
|
||||
}
|
||||
|
||||
export const onRequestError = Sentry.captureRequestError;
|
||||
@@ -2,7 +2,6 @@
|
||||
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
|
||||
import type { ApiError } from '@/types/api';
|
||||
import { notifyAccessTokenRefreshed } from '@/lib/auth/accessTokenEvents';
|
||||
import { reportUnexpectedApiFailure } from '@/lib/error-tracking/reportUnexpectedApiFailure';
|
||||
|
||||
interface CustomAxiosRequestConfig extends InternalAxiosRequestConfig {
|
||||
_retry?: boolean;
|
||||
@@ -47,7 +46,6 @@ function shouldSkipRefreshRetry(url: string | undefined): boolean {
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error: AxiosError) => {
|
||||
reportUnexpectedApiFailure(error);
|
||||
const originalRequest = error.config as CustomAxiosRequestConfig;
|
||||
|
||||
if (
|
||||
|
||||
@@ -22,11 +22,9 @@ export const voiceApi = {
|
||||
payload: ExtractVoicePayload,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ success: boolean; data: VoiceExtractionResult }> => {
|
||||
// ASR + LLM routinely exceeds the default 10s axios timeout; match slow upload endpoints.
|
||||
const response = await apiClient.post('/voice/extract', payload, {
|
||||
signal,
|
||||
timeout: 120_000,
|
||||
});
|
||||
// The signal is forwarded so cancelling closes the connection, which aborts the
|
||||
// metered vendor call server-side rather than letting it settle unseen.
|
||||
const response = await apiClient.post('/voice/extract', payload, { signal });
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
import type { AxiosError } from 'axios';
|
||||
|
||||
/** Report network failures and HTTP 5xx only — not coded 4xx AppExceptions. */
|
||||
export function reportUnexpectedApiFailure(error: AxiosError): void {
|
||||
const status = error.response?.status;
|
||||
if (status !== undefined && status < 500) {
|
||||
return;
|
||||
}
|
||||
|
||||
Sentry.captureException(error, {
|
||||
tags: {
|
||||
api_status: status ? String(status) : 'network',
|
||||
},
|
||||
extra: {
|
||||
url: error.config?.url,
|
||||
method: error.config?.method,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
import { sentrySharedOptions } from '@/lib/error-tracking/sentrySharedOptions';
|
||||
|
||||
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN?.trim();
|
||||
|
||||
if (dsn) {
|
||||
Sentry.init({
|
||||
dsn,
|
||||
environment:
|
||||
process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT?.trim() ||
|
||||
process.env.NODE_ENV ||
|
||||
'development',
|
||||
...sentrySharedOptions(),
|
||||
});
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import type { ErrorEvent } from '@sentry/core';
|
||||
|
||||
/** Shared Sentry/GlitchTip options — no session replay, no PII in payloads. */
|
||||
export function sentrySharedOptions() {
|
||||
return {
|
||||
sendDefaultPii: false as const,
|
||||
tracesSampleRate: 0,
|
||||
replaysSessionSampleRate: 0,
|
||||
replaysOnErrorSampleRate: 0,
|
||||
beforeSend(event: ErrorEvent): ErrorEvent {
|
||||
if (event.request) {
|
||||
delete event.request.cookies;
|
||||
delete event.request.data;
|
||||
if (event.request.headers) {
|
||||
delete event.request.headers.cookie;
|
||||
delete event.request.headers.authorization;
|
||||
delete event.request.headers.Authorization;
|
||||
}
|
||||
}
|
||||
if (event.user) {
|
||||
delete event.user.email;
|
||||
delete event.user.ip_address;
|
||||
delete event.user.username;
|
||||
}
|
||||
return event;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -9,9 +9,11 @@ const PREFERRED_MIME_TYPES = [
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Pick a container this browser can record AND the backend accepts. Chrome and Android give
|
||||
* webm/opus, Safari and iPad mp4/aac; both go to the vendor unmodified, so there is no
|
||||
* transcode step and the list is an intersection, not a preference.
|
||||
* Pick a container this browser can record AND the backend accepts.
|
||||
*
|
||||
* Chrome and Android produce webm/opus; Safari and iPad produce mp4/aac. Both go to the
|
||||
* vendor unmodified, so there is no transcode step — but the choice still has to be made
|
||||
* at record time, and `isTypeSupported` is missing entirely on older Safari.
|
||||
*/
|
||||
export function pickRecordingMimeType(): string | null {
|
||||
if (typeof MediaRecorder === 'undefined') return null;
|
||||
|
||||
@@ -69,8 +69,9 @@ export function useVoiceCapture({
|
||||
/** getUserMedia is async; without this a permission granted after unmount leaks the mic. */
|
||||
const mountedRef = useRef(true);
|
||||
/**
|
||||
* Set synchronously on click: `phase` only becomes 'recording' once getUserMedia resolves,
|
||||
* so a second click during the permission prompt would orphan the first stream.
|
||||
* Set synchronously on click. `phase` does not become 'recording' until getUserMedia
|
||||
* resolves, so without this a second click during the permission prompt would start a
|
||||
* second stream and orphan the first — mic indicator lit, interval leaked.
|
||||
*/
|
||||
const startingRef = useRef(false);
|
||||
|
||||
@@ -138,14 +139,8 @@ export function useVoiceCapture({
|
||||
);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
// No recorder means nothing will fire `onstop`, so nothing else moves the phase.
|
||||
if (!recorderRef.current) {
|
||||
teardown();
|
||||
setPhase('idle');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
recorderRef.current.stop();
|
||||
recorderRef.current?.stop();
|
||||
} catch {
|
||||
teardown();
|
||||
setPhase('idle');
|
||||
@@ -155,10 +150,7 @@ export function useVoiceCapture({
|
||||
const onStart = useCallback(() => {
|
||||
if (phase !== 'idle' || startingRef.current) return;
|
||||
if (!isMediaRecorderSupported()) {
|
||||
// VOICE_UNSUPPORTED_FORMAT, not MIC_DENIED: nothing asked for a permission yet, and
|
||||
// blaming the microphone sends the clinician into site settings for no reason. Same
|
||||
// for the two paths below.
|
||||
onError(clientError('VOICE_UNSUPPORTED_FORMAT'));
|
||||
onError(clientError('VOICE_MIC_DENIED'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -186,7 +178,7 @@ export function useVoiceCapture({
|
||||
const mimeType = pickRecordingMimeType();
|
||||
if (mimeType === null) {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
onError(clientError('VOICE_UNSUPPORTED_FORMAT'));
|
||||
onError(clientError('VOICE_MIC_DENIED'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -228,11 +220,13 @@ export function useVoiceCapture({
|
||||
if (maxMs != null && elapsed >= maxMs) stop();
|
||||
}, LEVEL_POLL_MS);
|
||||
} catch {
|
||||
// `new MediaRecorder()` and `recorder.start()` both throw on some browsers, and by
|
||||
// then the stream is live — without this the mic indicator stays lit until unmount.
|
||||
// `new MediaRecorder(...)` and `recorder.start()` both throw on some browsers,
|
||||
// and by then the stream is already live. Without this the promise rejects
|
||||
// unhandled, the UI sits at 'idle' with nothing shown, and the browser's
|
||||
// recording indicator stays lit until the workspace unmounts.
|
||||
teardown();
|
||||
setPhase('idle');
|
||||
onError(clientError('VOICE_UNSUPPORTED_FORMAT'));
|
||||
onError(clientError('VOICE_MIC_DENIED'));
|
||||
} finally {
|
||||
startingRef.current = false;
|
||||
}
|
||||
@@ -241,7 +235,8 @@ export function useVoiceCapture({
|
||||
|
||||
const onCancel = useCallback(() => {
|
||||
cancelledRef.current = true;
|
||||
// Aborting closes the connection, which aborts the vendor call server-side.
|
||||
// Aborting closes the connection, which aborts the vendor call server-side. It is
|
||||
// metered per minute, so letting it settle costs money for a result nobody sees.
|
||||
abortRef.current?.abort();
|
||||
try {
|
||||
recorderRef.current?.stop();
|
||||
@@ -275,9 +270,10 @@ function attachLevelMeter(
|
||||
source.connect(analyser);
|
||||
|
||||
const data = new Uint8Array(analyser.frequencyBinCount);
|
||||
// Sample every frame so a transient is not missed, publish at LEVEL_POLL_MS. The hook
|
||||
// lives in TreatmentWorkspace, so an unthrottled setLevel is ~7,200 whole-tree renders
|
||||
// across a two-minute recording.
|
||||
// Sample every frame so a transient is not missed, but publish at LEVEL_POLL_MS.
|
||||
// This hook lives in TreatmentWorkspace, so an unthrottled setLevel re-renders the
|
||||
// details editor, the FDI chart and the lab panel on every animation frame — about
|
||||
// 7,200 whole-tree renders across a two-minute recording.
|
||||
let peakSinceEmit = 0;
|
||||
let lastEmit = 0;
|
||||
const tick = (now: number) => {
|
||||
|
||||
@@ -38,7 +38,6 @@ export interface TreatmentAppointment {
|
||||
patientId: string;
|
||||
patientFirstName: string;
|
||||
patientLastName: string;
|
||||
patientMobile?: string | null;
|
||||
providerUserId: string;
|
||||
startAt: string;
|
||||
endAt: string;
|
||||
@@ -140,8 +139,6 @@ export interface PastTreatment {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
isWalkIn: boolean;
|
||||
mobile?: string | null;
|
||||
email?: string | null;
|
||||
} | null;
|
||||
details: PastTreatmentDetail[];
|
||||
labCases: PastLabCase[];
|
||||
|
||||
@@ -1,97 +1,25 @@
|
||||
# Dyolink — Production Server Deploy Guide
|
||||
|
||||
Linux VPS serves **`https://nudentic.ir` only**. Images come from the **Gitea registry** (`wixur.ir:3000`) on git tags. Staging is Windows (`https://wixur.ir`) — see [`STAGING-DEPLOY.md`](STAGING-DEPLOY.md).
|
||||
Deploy the full stack (Postgres, NestJS API, Next.js, Nginx, Let's Encrypt) on a fresh Linux server using **Docker Hub** images.
|
||||
|
||||
**Automated path:** tag `v1.0.1` → [`.gitea/workflows/prod-tag-deploy.yml`](../.gitea/workflows/prod-tag-deploy.yml) (Windows runner builds, SSH to this server).
|
||||
|
||||
**Manual / first SSL:** Docker Hub + [`scripts/build-and-push-prod.sh`](scripts/build-and-push-prod.sh) is still documented below as a fallback.
|
||||
|
||||
**Example host:** `https://nudentic.ir` (do not point `wixur.ir` at this VPS).
|
||||
|
||||
---
|
||||
|
||||
## Automated deploy (Gitea tags)
|
||||
|
||||
```
|
||||
git tag v1.0.1 && git push origin v1.0.1
|
||||
↓
|
||||
Windows act_runner builds frontend with https://nudentic.ir
|
||||
↓
|
||||
Push wixur.ir:3000/<owner>/dyolink-*:v1.0.1 (never :latest)
|
||||
↓
|
||||
SSH → Linux docker login wixur.ir:3000 → compose pull + up
|
||||
↓
|
||||
https://nudentic.ir
|
||||
```
|
||||
|
||||
`:latest` is **staging only** (wixur.ir baked in). Production compose must pin `TAG=v1.0.1`.
|
||||
|
||||
### One-time on the Linux server
|
||||
|
||||
1. **HTTP registry** — Gitea is `http://wixur.ir:3000`. In `/etc/docker/daemon.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"insecure-registries": ["wixur.ir:3000"]
|
||||
}
|
||||
```
|
||||
|
||||
Then `sudo systemctl restart docker` (containers restart).
|
||||
|
||||
2. **`.env`** in `/opt/dyolink/infrastructure/` — from [`deploy.prod.env.example`](deploy.prod.env.example):
|
||||
|
||||
- `DOMAIN=nudentic.ir`
|
||||
- `REGISTRY_PREFIX=wixur.ir:3000/<gitea-owner>` (same owner as Gitea `REGISTRY_OWNER`)
|
||||
- `TAG=v1.0.1` (CI overrides per release)
|
||||
|
||||
3. **`secrets/backend.env`:** `FRONTEND_URL=https://nudentic.ir`, `COOKIE_SECURE=true`, plus `ADMINJS_EMAIL` / `ADMINJS_PASSWORD` for `https://nudentic.ir/admin`
|
||||
|
||||
4. **SSH** — user that can run `docker` (e.g. `dyolink` in the `docker` group). Put the matching **public** key in `~/.ssh/authorized_keys`.
|
||||
|
||||
5. **Test pull** (after a staging or prod image exists):
|
||||
|
||||
```bash
|
||||
docker login wixur.ir:3000 -u <gitea-user>
|
||||
docker pull wixur.ir:3000/<owner>/dyolink-backend:<sha-or-tag>
|
||||
```
|
||||
|
||||
### Gitea (same repo as staging)
|
||||
|
||||
**Variables:** `PROD_PUBLIC_BASE_URL=https://nudentic.ir`, `PROD_REGISTRY_HOST=wixur.ir:3000`, `PROD_INFRA_DIR=/opt/dyolink/infrastructure`, plus existing `REGISTRY_HOST` / `REGISTRY_OWNER` / `CLONE_HOST`.
|
||||
|
||||
**Secrets:** `PROD_SSH_HOST`, `PROD_SSH_USER`, `PROD_SSH_KEY` (private key). Reuse `REGISTRY_USERNAME` / `REGISTRY_PASSWORD`. Optional `PROD_SSH_PORT` (default 22).
|
||||
|
||||
OpenSSH must be on the Windows runner (`ssh.exe` / `scp.exe`).
|
||||
|
||||
### Release
|
||||
|
||||
```bash
|
||||
git tag v1.0.1
|
||||
git push origin v1.0.1
|
||||
```
|
||||
|
||||
Or Gitea → Actions → **Production — tag build, push, deploy** → Run → tag `v1.0.1`.
|
||||
|
||||
Check `https://nudentic.ir/api/health`. AdminJS is `https://nudentic.ir/admin`.
|
||||
**Example used in production:** `https://wixur.ir` on server `185.243.48.140`.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
|
||||
```
|
||||
Internet → Nginx (:80 / :443)
|
||||
├── / → frontend:3000 (Next.js)
|
||||
├── /api → backend:3000 (NestJS API)
|
||||
└── /admin → backend:3000 (AdminJS)
|
||||
├── / → frontend:3000 (Next.js)
|
||||
└── /api → backend:3000 (NestJS)
|
||||
└── postgres:5432
|
||||
```
|
||||
|
||||
| Service | Image | Notes |
|
||||
|-----------|------------------------------------|--------------------------------|
|
||||
| postgres | `postgres:15-alpine` | Data in Docker volume |
|
||||
| backend | `REGISTRY_PREFIX/dyolink-backend:TAG` | Gitea `v*` or Hub `dyolink/…` |
|
||||
| frontend | `REGISTRY_PREFIX/dyolink-frontend:TAG` | URLs baked in at **build time** |
|
||||
| backend | `dyolink/dyolink-backend:latest` | Runs migrations + seed on start |
|
||||
| frontend | `dyolink/dyolink-frontend:latest` | URLs baked in at **build time** |
|
||||
| nginx | `nginx:alpine` | SSL termination + reverse proxy |
|
||||
| certbot | `certbot/certbot` | Auto-renews certificates |
|
||||
|
||||
@@ -361,19 +289,16 @@ Open `https://YOUR_DOMAIN` in a browser.
|
||||
|
||||
## Updating the app (new release)
|
||||
|
||||
**Preferred:** `git tag vX.Y.Z && git push origin vX.Y.Z` (Gitea production workflow).
|
||||
|
||||
**Fallback (Docker Hub from a Mac):**
|
||||
**On Mac** — build & push:
|
||||
|
||||
```bash
|
||||
./infrastructure/scripts/build-and-push-prod.sh nudentic.ir v1.0.1
|
||||
./infrastructure/scripts/build-and-push-prod.sh wixur.ir latest
|
||||
```
|
||||
|
||||
**On server** (if not using CI):
|
||||
**On server:**
|
||||
|
||||
```bash
|
||||
cd /opt/dyolink/infrastructure
|
||||
# TAG in .env or: TAG=v1.0.1 docker compose -f docker-compose.prod.yml --env-file .env pull backend frontend
|
||||
docker compose -f docker-compose.prod.yml --env-file .env pull backend frontend
|
||||
docker compose -f docker-compose.prod.yml --env-file .env up -d
|
||||
```
|
||||
@@ -436,23 +361,6 @@ docker volume rm dyolink_postgres_data_prod
|
||||
./scripts/deploy-prod.sh
|
||||
```
|
||||
|
||||
### Docker login to `wixur.ir:3000`: `http response to HTTPS client`
|
||||
|
||||
Add `"insecure-registries": ["wixur.ir:3000"]` to `/etc/docker/daemon.json` and restart Docker.
|
||||
|
||||
### `connection reset by peer` while pulling `:v*` from Gitea
|
||||
|
||||
`docker login` succeeded, then `compose pull` failed on a frontend/backend manifest or layer from `wixur.ir:3000`. Auth is fine — the Windows Gitea registry dropped the TCP connection (common right after a large push, or when both images pull in parallel).
|
||||
|
||||
Images are already in the registry. Re-run only the **deploy** job, or on the VPS:
|
||||
|
||||
```bash
|
||||
cd /opt/dyolink/infrastructure
|
||||
./scripts/prod-remote-deploy.sh v1.0.2
|
||||
```
|
||||
|
||||
`prod-remote-deploy.sh` pulls backend then frontend with retries. If every attempt RSTs, check Gitea is up and `insecure-registries` includes `wixur.ir:3000`.
|
||||
|
||||
### View logs
|
||||
|
||||
```bash
|
||||
@@ -477,13 +385,12 @@ docker compose -f docker-compose.prod.yml --env-file .env exec frontend \
|
||||
|
||||
| Path on server | Purpose |
|
||||
|----------------|---------|
|
||||
| `/opt/dyolink/infrastructure/.env` | `DOMAIN`, `REGISTRY_PREFIX`, `TAG`, Let's Encrypt email |
|
||||
| `/opt/dyolink/infrastructure/.env` | Domain, Docker Hub user, Let's Encrypt email |
|
||||
| `/opt/dyolink/secrets/database.env` | Postgres credentials |
|
||||
| `/opt/dyolink/secrets/backend.env` | API secrets, DATABASE_URL, JWT, SMS, `FRONTEND_URL` |
|
||||
| `/opt/dyolink/secrets/backend.env` | API secrets, DATABASE_URL, JWT, SMS |
|
||||
| `/opt/dyolink/infrastructure/nginx/generated/default.conf` | Auto-generated nginx SSL config |
|
||||
| `/opt/dyolink/infrastructure/scripts/prod-remote-deploy.sh` | Tag deploy (CI SSH) |
|
||||
| `/opt/dyolink/infrastructure/scripts/deploy-prod.sh` | First-time SSL + stack up |
|
||||
| `.gitea/workflows/prod-tag-deploy.yml` | CI: build, push `:v*`, SSH deploy |
|
||||
| `/opt/dyolink/infrastructure/scripts/deploy-prod.sh` | Main deploy entry point |
|
||||
| `/opt/dyolink/infrastructure/scripts/build-and-push-prod.sh` | Build & push (run on Mac) |
|
||||
|
||||
---
|
||||
|
||||
@@ -497,7 +404,6 @@ docker compose -f docker-compose.prod.yml --env-file .env exec frontend \
|
||||
- [ ] `.env`, `database.env`, `backend.env` configured (real passwords + JWT)
|
||||
- [ ] `./scripts/deploy-prod.sh` completed
|
||||
- [ ] `curl https://DOMAIN/api/health` returns `{"status":"ok",...}`
|
||||
- [ ] `https://DOMAIN/admin` shows the AdminJS login (not the Next.js app)
|
||||
- [ ] App loads in browser
|
||||
|
||||
---
|
||||
|
||||
@@ -1,350 +0,0 @@
|
||||
# Dyolink — Staging deploy (Gitea + Windows)
|
||||
|
||||
Automatic staging on a **self-hosted Gitea** machine: merge (or push) to **`master`** → build Docker images → push to Gitea Container Registry → deploy on the same host.
|
||||
|
||||
| Public URL | What |
|
||||
|------------|------|
|
||||
| `https://wixur.ir` | Staging app (port **443** → Windows nginx → Docker). Mobinnet: public **80** is the modem. |
|
||||
| `http://wixur.ir:8088` | HTTP fallback |
|
||||
| `http://wixur.ir:3000` | Gitea + container registry |
|
||||
|
||||
DNS `wixur.ir` must point at the **Windows** host. Production (`https://nudentic.ir` on Linux) is separate — see [`DEPLOY.md`](DEPLOY.md). Do **not** point `wixur.ir` at the Linux VPS.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Push / merge to master
|
||||
↓
|
||||
Gitea Actions (self-hosted act_runner on Windows)
|
||||
↓
|
||||
Build backend + frontend → push to Gitea registry
|
||||
↓
|
||||
docker compose pull + up -d (docker-compose.registry.yml)
|
||||
↓
|
||||
https://wixur.ir → Windows nginx :443 → 127.0.0.1:18088 → Docker nginx → app
|
||||
http://wixur.ir:8088 → portproxy :8088 → 127.0.0.1:18088 (fallback)
|
||||
http://wixur.ir:3000 → Gitea (native, no Docker)
|
||||
```
|
||||
|
||||
| Service | Image source |
|
||||
|----------|---------------------------------------------------|
|
||||
| postgres | `postgres:15-alpine` (pulled from Docker Hub) |
|
||||
| backend | `<REGISTRY_HOST>/<owner>/dyolink-backend:<sha>` |
|
||||
| frontend | `<REGISTRY_HOST>/<owner>/dyolink-frontend:<sha>` |
|
||||
| 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.
|
||||
|
||||
---
|
||||
|
||||
## Cut over from `:8088` to `http://wixur.ir`
|
||||
|
||||
DNS A record for `wixur.ir` → Windows IP (already done if `http://wixur.ir:3000` and `:8088` work).
|
||||
|
||||
On Windows: host nginx on **80** proxies to Docker **18088** ([`nginx/windows-edge-http.conf`](nginx/windows-edge-http.conf)). Remove portproxy on **80** (keep **8088** as fallback). Router must forward **80**. See §7.
|
||||
|
||||
In Gitea → repo → **Settings → Actions → Variables**:
|
||||
|
||||
- `PUBLIC_BASE_URL` = `https://wixur.ir`
|
||||
- `STAGING_HTTP_PORT` = `80` (optional; workflow default is 80)
|
||||
|
||||
On disk: `FRONTEND_URL=https://wixur.ir` and `COOKIE_SECURE=true` in `C:\dyolink\secrets\backend.staging.env`.
|
||||
|
||||
Then run the **Registry — build, push, deploy** workflow so the frontend image is rebuilt without `:8088`.
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## Triggers
|
||||
|
||||
| Event | Staging deploy? |
|
||||
|-------|-----------------|
|
||||
| Push to `master` | Yes |
|
||||
| PR merged into `master` | Yes (merge = push to `master`) |
|
||||
| Push to other branches only | No |
|
||||
| Git tag (e.g. `v1.0.0`) | No — reserved for production later |
|
||||
| Manual | Gitea → Actions → **Registry — build, push, deploy** → Run workflow |
|
||||
|
||||
Workflow file: [`.gitea/workflows/registry-build-deploy.yml`](../.gitea/workflows/registry-build-deploy.yml)
|
||||
|
||||
---
|
||||
|
||||
## One-time setup (Windows host)
|
||||
|
||||
### 1. Docker Desktop
|
||||
|
||||
- Install and keep **Docker Desktop running** during builds/deploys.
|
||||
- **Settings → Docker Engine** — allow HTTP registry:
|
||||
|
||||
```json
|
||||
{
|
||||
"insecure-registries": [
|
||||
"host.docker.internal:3000",
|
||||
"127.0.0.1:3000",
|
||||
"wixur.ir:3000"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Apply & restart Docker Desktop.
|
||||
|
||||
**Why `host.docker.internal`?** Docker Desktop runs in a Linux VM. `docker login` runs from that VM — `127.0.0.1:3000` is the VM, not Gitea on Windows.
|
||||
|
||||
### 2b. Gitea `app.ini` — match registry URL
|
||||
|
||||
Edit `C:\gitea\custom\conf\app.ini`:
|
||||
|
||||
```ini
|
||||
[server]
|
||||
ROOT_URL = http://host.docker.internal:3000/
|
||||
```
|
||||
|
||||
Restart Gitea. Gitea uses `ROOT_URL` for Docker registry auth redirects.
|
||||
|
||||
### 2c. Gitea Actions runner
|
||||
|
||||
Download [act_runner](https://gitea.com/gitea/act_runner/releases) → e.g. `C:\gitea-runner\act_runner.exe`.
|
||||
|
||||
**Important — runner level:** the registration token decides which repos can use the runner.
|
||||
|
||||
| Token from | Runner level | Works for `admin/dyolink`? |
|
||||
|------------|--------------|----------------------------|
|
||||
| **Site Administration → Actions → Runners** | Instance (global) | Yes (recommended) |
|
||||
| **Repo → Settings → Actions → Runners** | Repository | Yes |
|
||||
| **User profile → Settings → Actions → Runners** | Individual | Often **no** — jobs stay queued with “no matching online runner” |
|
||||
|
||||
If your runner shows **Type: Individual** in Gitea but jobs never start, delete it and re-register with an **instance** or **repository** token (table above).
|
||||
|
||||
Register (use **localhost** when Gitea runs on the same PC — public IP often fails locally):
|
||||
|
||||
```powershell
|
||||
cd C:\gitea-runner
|
||||
# Stop daemon first (Ctrl+C) if running
|
||||
.\act_runner.exe register `
|
||||
--instance "http://127.0.0.1:3000" `
|
||||
--token "<token-from-Site-Admin-OR-repo-Runners-page>" `
|
||||
--name "windows-staging" `
|
||||
--labels "windows:host"
|
||||
```
|
||||
|
||||
Start (leave running, or install as a Windows service later):
|
||||
|
||||
```powershell
|
||||
.\act_runner.exe daemon
|
||||
```
|
||||
|
||||
**Verify:** open **your repo** → **Settings → Actions → Runners** — `windows-staging` must appear here as **Idle/Online** (not only under user settings).
|
||||
|
||||
**Requires:** `git` on PATH (for workflow checkout step).
|
||||
|
||||
### 3. Secret env files (not in git)
|
||||
|
||||
```powershell
|
||||
New-Item -ItemType Directory -Force -Path "C:\dyolink\secrets"
|
||||
```
|
||||
|
||||
Copy examples and edit:
|
||||
|
||||
- `infrastructure/database.staging.env.example` → `C:\dyolink\secrets\database.staging.env`
|
||||
- `infrastructure/backend.staging.env.example` → `C:\dyolink\secrets\backend.staging.env`
|
||||
|
||||
Rules:
|
||||
|
||||
- `DATABASE_URL` password must match `POSTGRES_PASSWORD`.
|
||||
- `FRONTEND_URL` must match `PUBLIC_BASE_URL` (`http://wixur.ir`).
|
||||
- Replace JWT secrets with long random values (not `CHANGE_ME`).
|
||||
|
||||
### 4. Gitea repository Variables
|
||||
|
||||
**Settings → Actions → Variables**
|
||||
|
||||
| Name | Example | Notes |
|
||||
|------|---------|--------|
|
||||
| `REGISTRY_HOST` | `host.docker.internal:3000` | **Windows + Docker Desktop:** Docker runs in a Linux VM — `127.0.0.1` is the VM, not Gitea. Use `host.docker.internal:3000`. Gitea `ROOT_URL` should match this so registry login from CI works. Browsers can use `http://wixur.ir:3000` or `https://gitea.wixur.ir`. **Do not** set this to `gitea.wixur.ir` unless that HTTPS proxy allows unlimited body size (see 413 below). |
|
||||
| `REGISTRY_OWNER` | `admin` | Gitea user/org owning packages |
|
||||
| `PUBLIC_BASE_URL` | `https://wixur.ir` | How **users** open staging (HTTPS on 443). No trailing slash. |
|
||||
| `DEPLOY_SECRETS_DIR` | `C:/dyolink/secrets` | Forward slashes OK on Windows |
|
||||
| `CLONE_HOST` | `127.0.0.1:3000` | Git clone (runs on Windows host, not inside Docker VM) |
|
||||
| `STAGING_HTTP_PORT` | `80` | Public HTTP port (portproxy). Optional; workflow default is 80. |
|
||||
| `STAGING_LOCAL_PORT` | `18088` | Docker bind on `127.0.0.1`. Must not be `80` if portproxy already uses 80. |
|
||||
|
||||
**Naming note:** Gitea rejects variable names starting with `GITEA_` or `GITHUB_`. Use `CLONE_HOST`, not `GITEA_CLONE_URL`.
|
||||
|
||||
### 5. Gitea repository Secrets
|
||||
|
||||
**Settings → Actions → Secrets**
|
||||
|
||||
| Name | Value |
|
||||
|------|--------|
|
||||
| `REGISTRY_USERNAME` | Gitea username |
|
||||
| `REGISTRY_PASSWORD` | Gitea access token with **package read/write** |
|
||||
|
||||
Create token: profile → **Settings → Applications → Generate New Token**.
|
||||
|
||||
### 6. Firewall (once)
|
||||
|
||||
```powershell
|
||||
New-NetFirewallRule -DisplayName "Dyolink Staging HTTP 80" -Direction Inbound -Protocol TCP -LocalPort 80 -Action Allow
|
||||
# Optional fallback while cutting over from :8088
|
||||
New-NetFirewallRule -DisplayName "Dyolink Staging 8088" -Direction Inbound -Protocol TCP -LocalPort 8088 -Action Allow
|
||||
```
|
||||
|
||||
### 7. Port 80 = host nginx (not portproxy)
|
||||
|
||||
Gitea stays on **:3000**. Docker staging nginx binds **127.0.0.1:18088**. A **Windows nginx** (the 1.29.x you already have) listens on **80** and proxies to 18088. Config: [`nginx/windows-edge-http.conf`](nginx/windows-edge-http.conf).
|
||||
|
||||
**Do not** run portproxy on port 80 at the same time — it will lose to nginx (404 / hang). Keep portproxy **8088 → 18088** as fallback.
|
||||
|
||||
```powershell
|
||||
# 1) Free port 80 from portproxy (nginx will bind 80)
|
||||
netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=80
|
||||
|
||||
# 2) Confirm Docker staging is up
|
||||
curl.exe http://127.0.0.1:18088/health
|
||||
|
||||
# 3) Install windows-edge-http.conf into host nginx, then:
|
||||
# nginx -t
|
||||
# nginx -s reload
|
||||
# (paths depend on where nginx is installed)
|
||||
|
||||
# 4) Must print "healthy" (not 404)
|
||||
curl.exe http://127.0.0.1/health
|
||||
```
|
||||
|
||||
Replace any **default_server** / leftover `server { listen 80; }` in the host nginx that returns 404, or this file will never win.
|
||||
|
||||
From another machine (after **router forward TCP 80** → this PC):
|
||||
|
||||
```bash
|
||||
curl http://wixur.ir/health
|
||||
```
|
||||
|
||||
**Mobinnet:** public **80** is the modem. Use **443** instead: router forward **TCP 443 → 192.168.1.100**, then `https://wixur.ir`. Let's Encrypt after mobile-data `/health` works (self-signed is enough for that test).
|
||||
|
||||
---
|
||||
|
||||
## Test the pipeline
|
||||
|
||||
### Before first run
|
||||
|
||||
- [ ] Docker Desktop running
|
||||
- [ ] `act_runner.exe daemon` running
|
||||
- [ ] All Variables + Secrets set (including `CLONE_HOST`)
|
||||
- [ ] Secret env files exist under `DEPLOY_SECRETS_DIR`
|
||||
- [ ] Workflow enabled on `master` (see repo)
|
||||
|
||||
### Option A — Manual workflow (safest first test)
|
||||
|
||||
1. Gitea → repo → **Actions**
|
||||
2. **Registry — build, push, deploy** → **Run workflow** → branch `master`
|
||||
3. Watch jobs: **build-and-push** → **deploy** (first run ~15–30 min)
|
||||
|
||||
### Option B — Push to master
|
||||
|
||||
```bash
|
||||
git push origin master
|
||||
```
|
||||
|
||||
Or merge a PR into `master` — same result.
|
||||
|
||||
### Verify success
|
||||
|
||||
**On Windows (PowerShell):**
|
||||
|
||||
```powershell
|
||||
docker ps
|
||||
```
|
||||
|
||||
Expect: `dyolink_nginx_staging`, `dyolink_backend_staging`, `dyolink_frontend_staging`, `dyolink_postgres_staging`.
|
||||
|
||||
**From browser or another machine:**
|
||||
|
||||
```text
|
||||
http://wixur.ir
|
||||
```
|
||||
|
||||
**Health check:**
|
||||
|
||||
```powershell
|
||||
curl http://wixur.ir/api/health
|
||||
```
|
||||
|
||||
Expected: `{"status":"ok",...}`
|
||||
|
||||
**Gitea packages:** profile/org → **Packages** — should list `dyolink-backend` and `dyolink-frontend` after first build.
|
||||
|
||||
---
|
||||
|
||||
## Manual deploy (without CI)
|
||||
|
||||
Useful when debugging registry/compose without re-running the full workflow.
|
||||
|
||||
On the Windows host, from repo `infrastructure/`:
|
||||
|
||||
1. Create `deploy.registry.env` from [`deploy.registry.env.example`](deploy.registry.env.example)
|
||||
2. Set `REGISTRY_PREFIX`, `IMAGE_TAG`, `STAGING_HTTP_PORT`, `DEPLOY_SECRETS_DIR`
|
||||
3. `docker login host.docker.internal:3000 -u <user>` (on Windows Docker Desktop; Linux prod will use `wixur.ir:3000`)
|
||||
4. `docker compose -f docker-compose.registry.yml --env-file deploy.registry.env pull backend frontend`
|
||||
5. `docker compose -f docker-compose.registry.yml --env-file deploy.registry.env up -d`
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Fix |
|
||||
|---------|-----|
|
||||
| `no matching online runner with label` | Runner **offline** → start `act_runner.exe daemon`. Or wrong **runner level** → re-register with token from **Site Administration → Actions → Runners** or **repo → Settings → Actions → Runners** (not user profile). Confirm runner appears on **repo** Runners page as Online. |
|
||||
| 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` |
|
||||
| `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). |
|
||||
| `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). |
|
||||
| `server gave HTTP response to HTTPS client` | Add registry host to Docker **insecure-registries**, restart Docker |
|
||||
| `Missing database.staging.env` | Check `DEPLOY_SECRETS_DIR` path and file names |
|
||||
| `docker login` denied | Token needs package permissions; check username/secret |
|
||||
| Git clone fails in workflow | Set `CLONE_HOST=127.0.0.1:3000` |
|
||||
| Port 80 bind forbidden / access permissions | Portproxy already owns 80. Bind Docker to `18088` and point portproxy **80 → 127.0.0.1:18088**. Free IIS if it holds 80. |
|
||||
| `http://wixur.ir` unreachable / empty reply | Docker Desktop: run **portproxy** (§7). `127.0.0.1:18088/health` must work on Windows first. Router must forward **80**. |
|
||||
| Port 8088 bind forbidden / access permissions | Portproxy already owns 8088. Bind Docker to `18088` and point portproxy **8088 → 127.0.0.1:18088**. |
|
||||
| Port 8088 unreachable from Mac / empty reply | Legacy URL. Prefer `http://wixur.ir`. Same 18088 backend; add portproxy 8088 only as fallback. |
|
||||
| Backend restart loop | JWT secrets still placeholder; fix `backend.staging.env` |
|
||||
| Backend DB auth error | `DATABASE_URL` password ≠ `POSTGRES_PASSWORD` |
|
||||
| `dumb-init docker-entrypoint.sh: No such file or directory` | Windows CRLF in shell scripts — fixed in Dockerfiles (rebuild images). |
|
||||
| `frontend is unhealthy` / deploy waits on frontend | Next.js `/` redirects to `/en` (3xx). Rebuild after healthcheck fix (accepts 2xx/3xx). |
|
||||
|
||||
**Logs:**
|
||||
|
||||
```powershell
|
||||
docker logs dyolink_backend_staging --tail 50
|
||||
docker logs dyolink_nginx_staging --tail 50
|
||||
docker logs dyolink_frontend_staging --tail 50
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File reference
|
||||
|
||||
| Path | Role |
|
||||
|------|------|
|
||||
| `.gitea/workflows/registry-build-deploy.yml` | CI: build, push, deploy |
|
||||
| `infrastructure/docker-compose.registry.yml` | Staging stack (pull-only images) |
|
||||
| `infrastructure/deploy.registry.env.example` | Manual deploy env template |
|
||||
| `infrastructure/database.staging.env.example` | Postgres secrets template |
|
||||
| `infrastructure/backend.staging.env.example` | API secrets template |
|
||||
| `infrastructure/nginx/http-only.conf` | HTTP reverse proxy **inside Docker** staging |
|
||||
| `infrastructure/nginx/windows-edge-http.conf` | Windows **host** nginx on port 80/443 → 18088 |
|
||||
| `infrastructure/nginx/windows-gitea.wixur.snippet.conf` | Windows nginx vhost for `https://gitea.wixur.ir` (unlimited body — Docker push) |
|
||||
|
||||
---
|
||||
|
||||
## Production (Linux + tags)
|
||||
|
||||
| Environment | Trigger | Host |
|
||||
|-------------|---------|------|
|
||||
| Staging | Push/merge to `master` | Windows + `https://wixur.ir` |
|
||||
| Production | Git tag `v*.*.*` | Linux + `https://nudentic.ir` |
|
||||
|
||||
Workflow: [`.gitea/workflows/prod-tag-deploy.yml`](../.gitea/workflows/prod-tag-deploy.yml). Do **not** push production images as `:latest`. Setup: [`DEPLOY.md`](DEPLOY.md).
|
||||
@@ -12,12 +12,7 @@ JWT_REFRESH_SECRET=replace_with_a_different_openssl_rand_hex_32_output
|
||||
JWT_REFRESH_EXPIRES_IN=30d
|
||||
|
||||
# Must match DOMAIN in .env — used for CORS, invite links, cookies
|
||||
FRONTEND_URL=https://nudentic.ir
|
||||
|
||||
# AdminJS at https://nudentic.ir/admin (nginx proxies /admin to the API).
|
||||
# Change these — the code defaults are only for local development.
|
||||
ADMINJS_EMAIL=admin@nudentic.ir
|
||||
ADMINJS_PASSWORD=CHANGE_ME_STRONG_ADMINJS_PASSWORD
|
||||
FRONTEND_URL=https://wixur.ir
|
||||
|
||||
# Required for HTTPS — browsers reject Secure cookies over plain HTTP
|
||||
COOKIE_SECURE=true
|
||||
@@ -25,7 +20,3 @@ COOKIE_SECURE=true
|
||||
# SMS (sms.ir)
|
||||
SMS_IR_API_KEY=CHANGE_ME_SMS_IR_API_KEY
|
||||
SMS_IR_TEMPLATE_ID=123456
|
||||
|
||||
# GlitchTip (Sentry SDK). Empty = disabled. Use the dyolink-backend project DSN.
|
||||
# SENTRY_DSN=https://PUBLIC_KEY@errors.wixur.ir/1
|
||||
# SENTRY_ENVIRONMENT=production
|
||||
|
||||
@@ -9,20 +9,12 @@ JWT_EXPIRES_IN=15m
|
||||
JWT_REFRESH_SECRET=another_long_random_secret_different_from_JWT_SECRET
|
||||
JWT_REFRESH_EXPIRES_IN=30d
|
||||
|
||||
# CORS, cookies, and invite links — must match how users open the app
|
||||
FRONTEND_URL=https://wixur.ir
|
||||
# CORS, cookies, and invite links — must match how users open the app (nginx host port)
|
||||
FRONTEND_URL=http://178.131.50.201:8088
|
||||
|
||||
# AdminJS at https://wixur.ir/admin (nginx proxies /admin to the API).
|
||||
ADMINJS_EMAIL=admin@wixur.ir
|
||||
ADMINJS_PASSWORD=CHANGE_ME_STRONG_ADMINJS_PASSWORD
|
||||
|
||||
# TLS is terminated on Windows nginx :443 — cookies must be Secure
|
||||
COOKIE_SECURE=true
|
||||
# HTTP staging — keep false unless you terminate TLS in front of the app
|
||||
COOKIE_SECURE=false
|
||||
|
||||
# SMS (sms.ir)
|
||||
SMS_IR_API_KEY=CHANGE_ME_SMS_IR_API_KEY
|
||||
SMS_IR_TEMPLATE_ID=123456
|
||||
|
||||
# GlitchTip (Sentry SDK). Empty = disabled. Use the dyolink-backend project DSN.
|
||||
# SENTRY_DSN=https://PUBLIC_KEY@errors.wixur.ir/1
|
||||
# SENTRY_ENVIRONMENT=staging
|
||||
|
||||
@@ -1,27 +1,15 @@
|
||||
# Copy to infrastructure/.env on the Linux server (not committed).
|
||||
# Copy to infrastructure/.env on the server (not committed).
|
||||
# docker compose -f docker-compose.prod.yml --env-file .env ...
|
||||
|
||||
DOMAIN=nudentic.ir
|
||||
|
||||
# Gitea Container Registry (tag auto-deploy). Same packages Windows CI pushes
|
||||
# via host.docker.internal:3000 — Linux pulls via this hostname.
|
||||
# Format: <host>:<port>/<owner> — no http://
|
||||
REGISTRY_PREFIX=wixur.ir:3000/admin
|
||||
|
||||
# Image tag — CI sets this per release (v1.0.1). Do not use latest for Gitea prod
|
||||
# (staging already owns :latest with the wixur.ir frontend bake).
|
||||
TAG=v1.0.1
|
||||
|
||||
# Hub-only fallback: comment out REGISTRY_PREFIX and use:
|
||||
# REGISTRY_PREFIX=dyolink
|
||||
# TAG=latest
|
||||
DOMAIN=wixur.ir
|
||||
DOCKER_USERNAME=dyolink
|
||||
TAG=latest
|
||||
|
||||
# Let's Encrypt — certificate issuance and renewal notices
|
||||
LETSENCRYPT_EMAIL=rameen.naghdi@gmail.com
|
||||
|
||||
# Optional: extra hostnames on the same cert (space-separated)
|
||||
# CERTBOT_EXTRA_DOMAINS=www.nudentic.ir
|
||||
# Optional: extra hostnames on the same cert (space-separated), e.g. www.wixur.ir
|
||||
# CERTBOT_EXTRA_DOMAINS=www.wixur.ir
|
||||
|
||||
# Optional: use Let's Encrypt staging while testing (avoids rate limits)
|
||||
# LETSENCRYPT_STAGING=1
|
||||
|
||||
@@ -6,20 +6,14 @@
|
||||
# --- Registry boundary (swap when moving Gitea → Docker Hub) ---
|
||||
# Gitea: REGISTRY_PREFIX = <host>:<port>/<owner>
|
||||
# Hub: REGISTRY_PREFIX = docker.io/<user> (or your username for implicit hub)
|
||||
# Windows CI pushes via host.docker.internal:3000; browsers/Linux use wixur.ir:3000 (same Gitea).
|
||||
REGISTRY_PREFIX=wixur.ir:3000/yourgiteauser
|
||||
REGISTRY_PREFIX=178.131.50.201:3000/yourgiteauser
|
||||
|
||||
# Short git SHA from CI, or "latest" after a manual pull of :latest
|
||||
IMAGE_TAG=latest
|
||||
|
||||
# Public HTTP port (router + Windows portproxy listen here) — 80 so users open http://wixur.ir
|
||||
STAGING_HTTP_PORT=80
|
||||
# Docker nginx bind on localhost only (must differ from 80 if portproxy owns 80)
|
||||
STAGING_LOCAL_PORT=18088
|
||||
# Host port published for nginx (URL = http://<your-ip>:<this-port>)
|
||||
STAGING_HTTP_PORT=8088
|
||||
|
||||
# Absolute path on the server where database.staging.env and backend.staging.env live.
|
||||
# Use forward slashes on Windows. Same path as Gitea Actions variable DEPLOY_SECRETS_DIR.
|
||||
# DEPLOY_SECRETS_DIR=C:/dyolink/secrets
|
||||
#
|
||||
# Gitea Actions also needs CLONE_HOST=127.0.0.1:3000 when runner and Gitea share one Windows host.
|
||||
# See STAGING-DEPLOY.md (do not use GITEA_* variable names — Gitea rejects them).
|
||||
# Use forward slashes on Windows. Same variable as Gitea Actions → DEPLOY_SECRETS_DIR.
|
||||
# DEPLOY_SECRETS_DIR=D:/dyolink/secrets
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
# Production stack — pull backend/frontend from Gitea (or Docker Hub fallback).
|
||||
# HTTPS via Let's Encrypt (certbot) on this host only (nudentic.ir).
|
||||
# Production stack — pull images from Docker Hub, HTTPS via Let's Encrypt (certbot).
|
||||
#
|
||||
# Gitea tag deploy: set REGISTRY_PREFIX=wixur.ir:3000/<owner> and TAG=v1.0.1 in .env
|
||||
# Hub fallback: omit REGISTRY_PREFIX, set DOCKER_USERNAME=dyolink (image dyolink/dyolink-*).
|
||||
# Server setup (minimal):
|
||||
# 1. Copy deploy.prod.env.example → .env (DOMAIN, DOCKER_USERNAME, LETSENCRYPT_EMAIL)
|
||||
# 2. Copy secrets/*.example → ../secrets/ (database.env, backend.env) — outside git
|
||||
# 3. docker login (private Docker Hub images)
|
||||
# 4. ./scripts/init-letsencrypt.sh (first time only)
|
||||
# 5. docker compose -f docker-compose.prod.yml --env-file .env up -d
|
||||
#
|
||||
# First SSL: ./scripts/init-letsencrypt.sh then up -d
|
||||
# Tag updates: CI runs scripts/prod-remote-deploy.sh (or pull + up locally)
|
||||
# Updates: docker compose pull && docker compose up -d
|
||||
|
||||
name: dyolink-prod
|
||||
|
||||
@@ -37,7 +39,7 @@ services:
|
||||
start_period: 40s
|
||||
|
||||
backend:
|
||||
image: ${REGISTRY_PREFIX:-dyolink}/dyolink-backend:${TAG:-latest}
|
||||
image: ${DOCKER_USERNAME}/dyolink-backend:${TAG:-latest}
|
||||
container_name: dyolink_backend_prod
|
||||
depends_on:
|
||||
postgres:
|
||||
@@ -48,8 +50,6 @@ services:
|
||||
NODE_ENV: production
|
||||
TZ: UTC
|
||||
PORT: "3000"
|
||||
SENTRY_ENVIRONMENT: production
|
||||
SENTRY_RELEASE: ${TAG:-latest}
|
||||
expose:
|
||||
- "3000"
|
||||
networks:
|
||||
@@ -68,7 +68,7 @@ services:
|
||||
start_period: 60s
|
||||
|
||||
frontend:
|
||||
image: ${REGISTRY_PREFIX:-dyolink}/dyolink-frontend:${TAG:-latest}
|
||||
image: ${DOCKER_USERNAME}/dyolink-frontend:${TAG:-latest}
|
||||
container_name: dyolink_frontend_prod
|
||||
depends_on:
|
||||
- backend
|
||||
@@ -88,8 +88,7 @@ services:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
healthcheck:
|
||||
# Next.js `/` redirects to `/en` — accept 2xx/3xx.
|
||||
test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:3000/', (r) => { process.exit(r.statusCode >= 200 && r.statusCode < 400 ? 0 : 1); }).on('error', () => process.exit(1))"]
|
||||
test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:3000/', (r) => {if(r.statusCode!==200)process.exit(1)})"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# No backend/frontend source on the deployment host except this compose file + config + secrets.
|
||||
#
|
||||
# Required env (see deploy.registry.env.example):
|
||||
# REGISTRY_PREFIX e.g. host.docker.internal:3000/admin (Windows CI) or wixur.ir:3000/admin (no protocol)
|
||||
# REGISTRY_PREFIX e.g. 178.131.50.201:3000/yourgiteauser (no protocol, no trailing slash)
|
||||
# IMAGE_TAG short sha or "latest" (CI sets this per deploy)
|
||||
# Optional:
|
||||
# DEPLOY_SECRETS_DIR absolute path on the server to database/backend *.env files (see below)
|
||||
@@ -47,8 +47,6 @@ services:
|
||||
NODE_ENV: production
|
||||
TZ: UTC
|
||||
PORT: "3000"
|
||||
SENTRY_ENVIRONMENT: staging
|
||||
SENTRY_RELEASE: ${IMAGE_TAG:-latest}
|
||||
expose:
|
||||
- "3000"
|
||||
networks:
|
||||
@@ -77,8 +75,7 @@ services:
|
||||
- dyolink_staging
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
# Root redirects to /en (next-intl) — accept 2xx/3xx as healthy.
|
||||
test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:3000/', (r) => { process.exit(r.statusCode >= 200 && r.statusCode < 400 ? 0 : 1); }).on('error', () => process.exit(1))"]
|
||||
test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:3000/', (r) => {if(r.statusCode!==200)process.exit(1)})"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
@@ -88,14 +85,10 @@ services:
|
||||
image: nginx:alpine
|
||||
container_name: dyolink_nginx_staging
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
frontend:
|
||||
condition: service_healthy
|
||||
- backend
|
||||
- frontend
|
||||
ports:
|
||||
# Bind a private localhost port — Windows portproxy owns public 80 (http://wixur.ir).
|
||||
# See STAGING-DEPLOY.md §7: portproxy 0.0.0.0:80 → 127.0.0.1:18088
|
||||
- "127.0.0.1:${STAGING_LOCAL_PORT:-18088}:80"
|
||||
- "${STAGING_HTTP_PORT:-8088}:80"
|
||||
volumes:
|
||||
- ./nginx/http-only.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
- ./logs/nginx-staging:/var/log/nginx
|
||||
|
||||
@@ -43,7 +43,6 @@ services:
|
||||
NODE_ENV: production
|
||||
TZ: UTC
|
||||
PORT: "3000"
|
||||
SENTRY_ENVIRONMENT: staging
|
||||
expose:
|
||||
- "3000"
|
||||
networks:
|
||||
@@ -61,11 +60,9 @@ services:
|
||||
context: ../frontend
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
NEXT_PUBLIC_API_URL: ${STAGING_NEXT_PUBLIC_API_URL:-http://wixur.ir/api}
|
||||
NEXT_PUBLIC_APP_URL: ${STAGING_NEXT_PUBLIC_APP_URL:-http://wixur.ir}
|
||||
NEXT_PUBLIC_API_URL: ${STAGING_NEXT_PUBLIC_API_URL:-http://178.131.50.201:8088/api}
|
||||
NEXT_PUBLIC_APP_URL: ${STAGING_NEXT_PUBLIC_APP_URL:-http://178.131.50.201:8088}
|
||||
NEXT_PUBLIC_APP_NAME: ${STAGING_NEXT_PUBLIC_APP_NAME:-Dyolink}
|
||||
NEXT_PUBLIC_SENTRY_DSN: ${NEXT_PUBLIC_SENTRY_DSN:-}
|
||||
NEXT_PUBLIC_SENTRY_ENVIRONMENT: staging
|
||||
container_name: dyolink_frontend_staging
|
||||
depends_on:
|
||||
- backend
|
||||
@@ -80,8 +77,7 @@ services:
|
||||
- dyolink_staging
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
# Root redirects to /en (next-intl) — accept 2xx/3xx as healthy.
|
||||
test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:3000/', (r) => { process.exit(r.statusCode >= 200 && r.statusCode < 400 ? 0 : 1); }).on('error', () => process.exit(1))"]
|
||||
test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:3000/', (r) => {if(r.statusCode!==200)process.exit(1)})"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
@@ -91,12 +87,10 @@ services:
|
||||
image: nginx:alpine
|
||||
container_name: dyolink_nginx_staging
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
frontend:
|
||||
condition: service_healthy
|
||||
- backend
|
||||
- frontend
|
||||
ports:
|
||||
- "127.0.0.1:${STAGING_LOCAL_PORT:-18088}:80"
|
||||
- "${STAGING_HTTP_PORT:-8088}:80"
|
||||
volumes:
|
||||
- ./nginx/http-only.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
- ./logs/nginx-staging:/var/log/nginx
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# Copy to .env.staging next to docker-compose.staging.yml (optional).
|
||||
# Used only for compose variable substitution (build args, host port).
|
||||
|
||||
STAGING_HTTP_PORT=80
|
||||
STAGING_HTTP_PORT=8088
|
||||
|
||||
# Public URLs baked into the frontend image at build time — must match how users open the app.
|
||||
STAGING_NEXT_PUBLIC_API_URL=http://wixur.ir/api
|
||||
STAGING_NEXT_PUBLIC_APP_URL=http://wixur.ir
|
||||
STAGING_NEXT_PUBLIC_API_URL=http://178.131.50.201:8088/api
|
||||
STAGING_NEXT_PUBLIC_APP_URL=http://178.131.50.201:8088
|
||||
STAGING_NEXT_PUBLIC_APP_NAME=Dyolink
|
||||
|
||||
# Must match how users open staging (http://wixur.ir).
|
||||
# Change the IP/port if your server address differs.
|
||||
|
||||
# Registry / pull-only deploy (see deploy.registry.env.example + docker-compose.registry.yml).
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
# HTTP only — local dev and IP-based staging (no TLS).
|
||||
# Use with: docker compose and map host port e.g. 8080:80 or 8088:80
|
||||
#
|
||||
# Dynamic proxy_pass via Docker DNS (127.0.0.11) so nginx starts even if
|
||||
# backend/frontend containers are not up yet (static upstream blocks fail at boot).
|
||||
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
upstream dyolink_backend {
|
||||
server backend:3000;
|
||||
keepalive 32;
|
||||
}
|
||||
|
||||
upstream dyolink_frontend {
|
||||
server frontend:3000;
|
||||
keepalive 32;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
@@ -14,8 +19,7 @@ server {
|
||||
client_max_body_size 50M;
|
||||
|
||||
location / {
|
||||
set $frontend_upstream http://frontend:3000;
|
||||
proxy_pass $frontend_upstream;
|
||||
proxy_pass http://dyolink_frontend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
@@ -29,24 +33,7 @@ server {
|
||||
}
|
||||
|
||||
location /api {
|
||||
set $backend_upstream http://backend:3000;
|
||||
proxy_pass $backend_upstream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_read_timeout 300;
|
||||
proxy_connect_timeout 300;
|
||||
}
|
||||
|
||||
# AdminJS (Nest, not under /api)
|
||||
location /admin {
|
||||
set $backend_upstream http://backend:3000;
|
||||
proxy_pass $backend_upstream;
|
||||
proxy_pass http://dyolink_backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
|
||||
@@ -46,21 +46,6 @@ server {
|
||||
proxy_connect_timeout 300;
|
||||
}
|
||||
|
||||
# AdminJS (Nest, not under /api)
|
||||
location /admin {
|
||||
proxy_pass http://dyolink_backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_read_timeout 300;
|
||||
proxy_connect_timeout 300;
|
||||
}
|
||||
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "healthy\n";
|
||||
|
||||
@@ -86,21 +86,6 @@ server {
|
||||
proxy_read_timeout 300;
|
||||
proxy_connect_timeout 300;
|
||||
}
|
||||
|
||||
# AdminJS (Nest, not under /api)
|
||||
location /admin {
|
||||
proxy_pass http://dyolink_backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_read_timeout 300;
|
||||
proxy_connect_timeout 300;
|
||||
}
|
||||
|
||||
# Health check endpoint (no logging)
|
||||
location /health {
|
||||
|
||||
@@ -83,21 +83,6 @@ server {
|
||||
proxy_connect_timeout 300;
|
||||
}
|
||||
|
||||
# AdminJS (Nest, not under /api) — https://nudentic.ir/admin
|
||||
location /admin {
|
||||
proxy_pass http://dyolink_backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_read_timeout 300;
|
||||
proxy_connect_timeout 300;
|
||||
}
|
||||
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "healthy\n";
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
# Windows host nginx.conf — paste over
|
||||
# C:\tools\nginx-1.29.5\conf\nginx.conf
|
||||
#
|
||||
# :80 — LAN / 127.0.0.1 only (Mobinnet keeps public :80 for the modem)
|
||||
# :443 — public https://wixur.ir (router must forward 443 → this PC)
|
||||
#
|
||||
# Put Let's Encrypt PEMs in conf\ssl\ (win-acme --pemfilesname wixur):
|
||||
# ssl\wixur-chain.pem
|
||||
# ssl\wixur-key.pem
|
||||
#
|
||||
# After save: nginx -t then restart nginx
|
||||
# curl.exe https://wixur.ir/health → healthy
|
||||
# (curl to 127.0.0.1:443 fails name-check; cert is for wixur.ir)
|
||||
|
||||
#user nobody;
|
||||
worker_processes 1;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
|
||||
http {
|
||||
include mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
# Unlimited at http{} so a gitea.wixur.ir vhost can inherit it (Docker layers
|
||||
# 413 with 50M). App server blocks below cap uploads at 50M.
|
||||
client_max_body_size 0;
|
||||
|
||||
# Shared proxy to Docker staging
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80 default_server;
|
||||
listen [::]:80 default_server;
|
||||
server_name wixur.ir www.wixur.ir localhost 127.0.0.1;
|
||||
|
||||
client_max_body_size 50M;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:18088;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 300;
|
||||
proxy_connect_timeout 300;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
server_name wixur.ir www.wixur.ir localhost 127.0.0.1;
|
||||
|
||||
ssl_certificate ssl/wixur-chain.pem;
|
||||
ssl_certificate_key ssl/wixur-key.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
|
||||
client_max_body_size 50M;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:18088;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 300;
|
||||
proxy_connect_timeout 300;
|
||||
}
|
||||
}
|
||||
|
||||
# Gitea + container registry (https://gitea.wixur.ir). Optional include —
|
||||
# copy windows-gitea.wixur.snippet.conf next to this file and uncomment:
|
||||
# include windows-gitea.wixur.snippet.conf;
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
# Fastest fix if you already have a gitea.wixur.ir server { } block: add only
|
||||
# client_max_body_size 0;
|
||||
# proxy_request_buffering off;
|
||||
# inside that server (or its location /), then nginx -t && nginx -s reload.
|
||||
# Do not add a second server_name gitea.wixur.ir — duplicate listen/ssl will fail.
|
||||
#
|
||||
# Full vhost (only if that host is not already in nginx.conf): paste inside http { }
|
||||
# of C:\tools\nginx-1.29.5\conf\nginx.conf. Needs the $connection_upgrade map from
|
||||
# windows-edge-http.conf.
|
||||
#
|
||||
# Certs: keep the ssl_certificate paths you already use for gitea.wixur.ir
|
||||
# (the names below are placeholders).
|
||||
#
|
||||
# After save: nginx -t then nginx -s reload
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name gitea.wixur.ir;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root html;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
server_name gitea.wixur.ir;
|
||||
|
||||
ssl_certificate ssl/gitea-chain.pem;
|
||||
ssl_certificate_key ssl/gitea-key.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
|
||||
# 0 = unlimited (Docker registry blob PUT)
|
||||
client_max_body_size 0;
|
||||
client_body_timeout 600s;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 600;
|
||||
proxy_connect_timeout 60;
|
||||
proxy_send_timeout 600;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
# After win-acme writes Let's Encrypt PEMs into conf/ssl/:
|
||||
# ssl_certificate ssl/wixur-chain.pem;
|
||||
# ssl_certificate_key ssl/wixur-key.pem;
|
||||
# (exact filenames: dir C:\tools\nginx-1.29.5\conf\ssl )
|
||||
@@ -1,60 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run on the Linux production host (CI SSHs here after pushing :v* images).
|
||||
# Usage: ./prod-remote-deploy.sh v1.0.1
|
||||
set -euo pipefail
|
||||
|
||||
TAG="${1:?usage: prod-remote-deploy.sh v1.0.1}"
|
||||
INFRA="${PROD_INFRA_DIR:-/opt/dyolink/infrastructure}"
|
||||
cd "$INFRA"
|
||||
|
||||
if [ ! -f .env ]; then
|
||||
echo "Missing $INFRA/.env — copy deploy.prod.env.example and set DOMAIN=nudentic.ir REGISTRY_PREFIX=wixur.ir:3000/<owner>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -q '^REGISTRY_PREFIX=.\+' .env; then
|
||||
echo "Set REGISTRY_PREFIX in .env (Gitea registry, e.g. wixur.ir:3000/admin). Do not use Docker Hub :latest for tag deploys."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export TAG
|
||||
|
||||
# Windows Gitea often RSTs concurrent or long pulls (connection reset by peer).
|
||||
# Pull one image at a time with backoff so a flake does not fail the whole tag.
|
||||
pull_one() {
|
||||
local service="$1"
|
||||
local attempt=1
|
||||
local max=5
|
||||
local delay=8
|
||||
while [ "$attempt" -le "$max" ]; do
|
||||
echo "Pulling $service :$TAG (attempt $attempt/$max)"
|
||||
if docker compose -f docker-compose.prod.yml --env-file .env pull "$service"; then
|
||||
return 0
|
||||
fi
|
||||
if [ "$attempt" -eq "$max" ]; then
|
||||
echo "Failed to pull $service after $max attempts"
|
||||
return 1
|
||||
fi
|
||||
echo "Pull of $service failed; retrying in ${delay}s..."
|
||||
sleep "$delay"
|
||||
delay=$((delay * 2))
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
}
|
||||
|
||||
echo "Pulling backend/frontend :$TAG from Gitea (REGISTRY_PREFIX in .env)"
|
||||
pull_one backend
|
||||
pull_one frontend
|
||||
|
||||
if [ -f nginx/nginx.ssl.conf.template ]; then
|
||||
echo "Rendering nginx SSL config (includes /admin → backend)"
|
||||
chmod +x scripts/render-nginx-ssl.sh
|
||||
./scripts/render-nginx-ssl.sh
|
||||
fi
|
||||
|
||||
docker compose -f docker-compose.prod.yml --env-file .env up -d --force-recreate nginx
|
||||
docker compose -f docker-compose.prod.yml --env-file .env up -d
|
||||
echo "=== Status ==="
|
||||
docker compose -f docker-compose.prod.yml --env-file .env ps
|
||||
echo "Health: https://nudentic.ir/api/health"
|
||||
echo "AdminJS: https://nudentic.ir/admin"
|
||||
@@ -1,11 +0,0 @@
|
||||
@echo off
|
||||
REM Run as Administrator. Stops nginx so win-acme can bind 443 (TLS-ALPN),
|
||||
REM renews, then starts nginx again. Point the win-acme scheduled task here
|
||||
REM instead of calling wacs.exe directly.
|
||||
cd /d C:\tools\nginx-1.29.5
|
||||
taskkill /F /IM nginx.exe >nul 2>&1
|
||||
timeout /t 2 /nobreak >nul
|
||||
cd /d C:\tools\win-acme
|
||||
wacs.exe --renew --closeonfinish
|
||||
cd /d C:\tools\nginx-1.29.5
|
||||
start "" nginx.exe
|
||||
Reference in New Issue
Block a user