Compare commits

..

83 Commits

Author SHA1 Message Date
6c7e0574b9 fix: omit ExtraSources when NODE_IMAGE_SOURCE is empty 2026-09-05 20:18:06 +03:30
feafc3a219 fix: pass empty ExtraSources when NODE_IMAGE_SOURCE is unset 2026-09-05 20:11:18 +03:30
8fa856a980 fix: pull node:20-alpine from Arvan/ECR when Docker Hub is blocked 2026-09-05 19:59:45 +03:30
005e0b0394 fix: parse ci-resolve-node-image.ps1 on Windows PowerShell 5.1 2026-09-05 19:48:46 +03:30
c1846f8e22 fix: mirror node:20-alpine to Gitea when Docker Hub TLS times out
BuildKit was failing on registry-1.docker.io even when a local base
image existed. CI now prefers a Gitea-hosted NODE_IMAGE and retries
docker build.
2026-09-05 19:12:37 +03:30
663eafa3e8 fix: fail Gitea deploy when Docker image push does not succeed
Windows PowerShell ignored docker exit codes, so build-and-push looked
green while :sha tags were never pushed and deploy pulled missing images.
2026-09-05 19:03:06 +03:30
5cd3436d0c Merge pull request 'improvement: icons added to prosthesis types catalog.' (#73) from improvements/prosthesis-catalog-icons into master
Some checks failed
Registry — build, push, deploy / build-and-push (push) Successful in 41s
Registry — build, push, deploy / deploy (push) Failing after 10s
Reviewed-on: #73
Reviewed-by: aminmsvi <amn.mousavi@gmail.com>
2026-09-05 13:45:25 +03:30
5d3597f973 improvement: icons added to prosthesis types catalog.
Some checks failed
Production — tag build, push, deploy / build-and-push (push) Failing after 21s
Production — tag build, push, deploy / deploy (push) Has been skipped
2026-09-05 01:29:53 +03:30
4d7a4b390f Merge pull request 'improvement/prosthesis-types-overhaul' (#72) from improvement/prosthesis-types-overhaul into master
Some checks failed
Production — tag build, push, deploy / build-and-push (push) Successful in 4m18s
Production — tag build, push, deploy / deploy (push) Successful in 12m48s
Registry — build, push, deploy / build-and-push (push) Successful in 36s
Registry — build, push, deploy / deploy (push) Failing after 11s
Reviewed-on: #72
Reviewed-by: rameen <rameen.naghdi@gmail.com>
2026-09-04 20:23:58 +03:30
e57fe2d959 Merge branch 'master' into improvement/prosthesis-types-overhaul 2026-09-04 17:07:59 +03:30
72f885d9dd improvement: all confirmed suggestions implemented 2026-09-04 17:04:48 +03:30
7f92e735fb improvement: Some improvements done. some bugs fixed. 2026-09-02 17:24:01 +03:30
c23785fbf4 Merge pull request 'feature/ci-cd' (#71) from feature/ci-cd into master
All checks were successful
Registry — build, push, deploy / build-and-push (push) Successful in 1m8s
Registry — build, push, deploy / deploy (push) Successful in 1m39s
Reviewed-on: #71
Reviewed-by: admin <admin@localhost>
2026-09-02 11:05:18 +03:30
a3c14a18c1 improvement: new prosthesis type data structure implemented and finally working! 2026-09-01 21:03:04 +03:30
60be656cf1 Allow Gitea registry pushes through gitea.wixur.ir: nginx 50M body limit 413s Docker layers.
All checks were successful
Production — tag build, push, deploy / build-and-push (push) Successful in 47s
Production — tag build, push, deploy / deploy (push) Successful in 14m17s
2026-09-01 12:40:26 +03:30
6465af11ee Expose AdminJS on nudentic.ir: nginx only proxied /api, so /admin never reached Nest. 2026-09-01 11:49:14 +03:30
dc71c73ced Merge pull request 'feature/ci-cd' (#70) from feature/ci-cd into master
All checks were successful
Registry — build, push, deploy / build-and-push (push) Successful in 58s
Registry — build, push, deploy / deploy (push) Successful in 1m27s
Reviewed-on: #70
Reviewed-by: admin <admin@localhost>
2026-09-01 09:56:02 +03:30
19364ad5c2 resolve failed handshake connection with https://wixur.ir:3000
All checks were successful
Production — tag build, push, deploy / build-and-push (push) Successful in 42s
Production — tag build, push, deploy / deploy (push) Successful in 21s
2026-08-31 10:04:30 +03:30
50eda34e8e The app is now wired to GlitchTip with the Sentry SDKs
Some checks failed
Production — tag build, push, deploy / build-and-push (push) Successful in 1h13m36s
Production — tag build, push, deploy / deploy (push) Failing after 1m1s
2026-08-31 07:34:07 +03:30
573e5e0886 auto deploy on production server now finalized ready for smoke test.
All checks were successful
Production — tag build, push, deploy / build-and-push (push) Successful in 4m23s
Production — tag build, push, deploy / deploy (push) Successful in 11m52s
2026-08-29 16:25:27 +03:30
23c37519b5 windows ssl 2026-08-29 14:46:41 +03:30
2e2c910d22 Document staging HTTPS on wixur.ir (Windows nginx + Let's Encrypt).
Mobinnet holds public port 80, so staging is served on 443. Examples and the host nginx template now match FRONTEND_URL=https://wixur.ir.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 14:46:05 +03:30
c40e276b8a migrate from http://178.131.50.201:8088 to wixur.ir 2026-08-29 12:09:50 +03:30
d8efaa6f2f Merge pull request 'Fix voice extract timeout for production ASR+LLM pipeline' (#69) from feature/ci-cd into master
All checks were successful
Registry — build, push, deploy / build-and-push (push) Successful in 1m37s
Registry — build, push, deploy / deploy (push) Successful in 38s
Reviewed-on: http://host.docker.internal:3000/admin/dyolink/pulls/69
Reviewed-by: admin <admin@localhost>
2026-08-25 14:42:23 +03:30
eee2506adf ASR + LLM routinely exceeds the default 10s axios timeout; match slow upload endpoints. 2026-08-25 10:21:55 +03:30
9f5f696fb4 Merge pull request 'Bind staging Docker to 18088 so portproxy can own 8088.' (#68) from feature/ci-cd into master
All checks were successful
Registry — build, push, deploy / build-and-push (push) Successful in 17s
Registry — build, push, deploy / deploy (push) Successful in 45s
Reviewed-on: http://host.docker.internal:3000/admin/dyolink/pulls/68
Reviewed-by: admin <admin@localhost>
2026-08-24 12:14:51 +03:30
5ac3a4a708 Bind staging Docker to 18088 so portproxy can own 8088. 2026-08-24 12:06:13 +03:30
2a0f9dbfee Merge pull request 'feat(treatment): add voice detail entry' (#65) from feat/voice-treatment-entry into master
Some checks failed
Registry — build, push, deploy / build-and-push (push) Successful in 7m32s
Registry — build, push, deploy / deploy (push) Failing after 45s
Reviewed-on: http://host.docker.internal:3000/admin/dyolink/pulls/65
Reviewed-by: admin <admin@localhost>
2026-08-24 11:13:45 +03:30
dc9401e13f Merge pull request 'feature/ci-cd' (#67) from feature/ci-cd into master
Some checks failed
Registry — build, push, deploy / deploy (push) Has been cancelled
Registry — build, push, deploy / build-and-push (push) Has been cancelled
Reviewed-on: http://host.docker.internal:3000/admin/dyolink/pulls/67
Reviewed-by: admin <admin@localhost>
2026-08-24 11:09:38 +03:30
8c16a46740 docs: drop the spec's reference to the auto-fill exemption
origin/master removed the dispatch panel's remembered-prosthesis auto-fill
(f52ad6b), so the exemption this branch carried for it went away in the
rebase. The spec named the mechanism and a verification step for a feature
that no longer exists.

The rule it existed to enforce still stands and is worth keeping written
down — confirm fills exactly what the sheet previewed — so it stays, marked
as a constraint on whatever gets added next rather than a description of
something in the code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:18:22 +03:30
dc10d8dbe3 docs: cut the comments that were not earning their place
I wrote 731 comment lines on this branch against 4,530 lines of code — 14%,
where the rest of the repo runs at 1.8%. CLAUDE.md asks for code that reads
like its surroundings, and this did not.

Removed by genre rather than by taste:

- restating the code, e.g. "JS getUTCDay() numbering: Sunday = 0" above the
  map that literally shows it, and a docblock on startOfWeek explaining that
  it returns the start of the week;
- narrating history — "this used to rebuild the whole map", "left the bar
  recording forever" — which the commit message and git blame already carry;
- saying the same thing in several places: the "cannot record is not a
  denied microphone" reason appeared three times in one file, and the
  "aborting stops a per-minute metered call" reason across three files. Each
  now lives once, where the behaviour it explains lives;
- defending decisions nobody would question, like why toLatinDigits is its
  own module;
- over-explaining defensive branches, three separate comments to distinguish
  null from missing-kind from unrecognised-kind.

What stays is what the code cannot say: the patient-right convention in
toFdi, whose failure mode is a valid code for the wrong tooth; the
"this"-vs-"next" week anchoring; StrictMode re-arming mountedRef; Safari
accepting no mimeType hint; and the invariants whose violation already cost
a bug — the body parser's middleware ordering and the dispatch panel's
auto-fill rules.

Comments only. The diff contains no non-comment line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:14:15 +03:30
562ef2ae6e docs: bring the voice spec in line with the flow as built
The spec still described the flow as designed, not as it works after the
first live recordings.

§2 gains what confirm actually writes: the detail and its lab case are
persisted on confirm, because the autosave effect watches `details` only
and a lab draft left in component state loses the lab, the due date and the
prosthesis map on reload — while the detail survives, which is what makes
that loss look like a save. Plus the guard: a preview treatment comes back
instead when any detail is still untyped, and confirm skips the lab-case
save rather than posting an id the server has never seen.

§9 corrects three codes: VOICE_MIC_DENIED is now only a real permission
failure, VOICE_UNSUPPORTED_FORMAT covers every "this browser cannot record"
path, and both it and VOICE_CLIP_TOO_LONG are named on their DTO
constraints rather than falling through the shared map to an unrelated
message. §8 no longer claims there is no duration cap — there is, 2
minutes, decided before implementation.

§12 gains the checks these changes need, including the reload that catches
an unsaved lab case, and decisions 31-33 record the three rules the live
testing settled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:07:53 +03:30
8f2d3f97ba fix: stop voice entry posting an unsaved detail id, and name failures right
Review findings on this branch.

The lab-case save could be posted against a detail the server has never
seen. persistDraft returns a *preview* treatment instead of saving when any
detail lacks a treatment type — the blank one the workspace opens with is
enough — and a preview's detail id falls back to the client id. Recording
straight after opening a visit and confirming a result with a lab or due
date would send that id and fail the whole save. It now checks what came
back rather than the precondition, so it holds for every early return
persistDraft has.

stop() optional-chained into a no-op when the recorder was already gone,
leaving the bar recording forever with a live timer and only Cancel as a
way out.

Three "this browser cannot record" paths reported VOICE_MIC_DENIED — no
MediaRecorder at all, no container the API accepts, and a recorder that
throws after permission was already granted. Telling clinicians their
microphone was denied sends them hunting for a permission nothing asked
for; they now report VOICE_UNSUPPORTED_FORMAT.

The voice route's large-body match stripped every trailing slash while
Express ignores exactly one, so '/api/voice/extract//' bought a 10 MB
buffer for a request that then 404s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:07:53 +03:30
bdf6da135b fix(frontend): stop the remembered prosthesis default rewriting the map
Dictating "12 روکش PFM, 13 روکش PFZ" previewed correctly and then landed in
the form as PFM on both teeth.

The stored data was never wrong — the dev database holds 12 → pfm_crown and
13 → pfz_crown with selectionGroupIds matching the detail's groups exactly,
and a page reload renders it correctly. The damage was live client state:
the dispatch panel's "last type used for this lab" default rebuilt the
*entire* map from one code, so a single row reading as unfilled destroyed
every type already set.

Two changes:

- It fills blanks now, and leaves every entry that already carries a type
  alone. The bulk "apply to all" select only pre-sets itself when the fill
  really did cover every tooth, instead of claiming one type while the rows
  below disagree.
- A lab case created by confirming a voice result is exempt from the
  default entirely. The review sheet is a contract: topping the case up
  with a type for a tooth the preview never showed makes the confirmation
  step a lie about what it was going to fill.

The exemption is tracked in workspace state rather than on LabCaseDraft
because a draft field is dropped by mapLabCaseDraftFromApi on the first
server round-trip — exactly the window this failure lives in.

isProsthesisMapComplete is deliberately untouched: its strict
selectionGroupId match succeeds on the real data, so loosening it would
have been a blind change to a working path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:07:53 +03:30
3fb7f02f43 docs: add CLAUDE.md for project guidance and conventions 2026-08-23 23:05:30 +03:30
f46ecbd771 docs: mark transcript salvage as specified but not built
The backend returns the transcript on VOICE_EXTRACT_FAILED and the client
never reads it, so dictation the clinic paid for is shipped in an error
body and dropped. The spec claimed the whole feature was implemented; it
now names the gap and the two ways out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:05:30 +03:30
3d64414dd3 fix(backend): stop showing the clinician null, NaN and the wrong failure
Two ways a voice failure described itself wrongly.

describe() built the quoted-back text from fields that are all nullable on
the wire, and toVoiceIntent casts rather than checks — so a half-classified
deadline rendered as “null null” — not a usable date, and an offset with no
amount as “+NaN day”. Blank is already handled by the sheet; it now falls
back to that.

The DTO's constraints resolved to unrelated codes: maxLength fell through
to VALIDATION_FIELD_REQUIRED, so an oversized recording said a field was
missing, and isIn maps to VALIDATION_LANGUAGE_INVALID, so an unsupported
container said the language was invalid. Both now name their own code —
the validation factory already returns a message verbatim when it is itself
a known ErrorCode, so this needs no change to the shared mapping.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:05:30 +03:30
6a4c0cb1bb fix(frontend): stop the level meter re-rendering the whole workspace
The meter wrote React state from a requestAnimationFrame loop, and the hook
lives in TreatmentWorkspace — so every frame re-rendered the details editor,
the FDI chart, the lab panel and the history rail. About 7,200 whole-tree
renders across a two-minute recording, while the user is dictating.

Now samples every frame but publishes at LEVEL_POLL_MS, the rate the
elapsed timer already used. Peaks between publishes are carried forward, so
the meter stays responsive to transients rather than sampling at 10 Hz.

Also adds the catch the start path never had: new MediaRecorder() and
recorder.start() both throw on some browsers, and by then the stream is
live. The rejection went unhandled, the UI sat at 'idle' showing nothing,
and the browser's recording indicator stayed lit until unmount.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:05:30 +03:30
3911477e42 fix(frontend): persist the lab case a voice result creates
applyVoiceResult put the lab draft in state and stopped there. Every other
path that creates a LabCaseDraft — handleContinueToLab, handleLabCasesChange
— immediately runs persistDraft + persistLabCases, and the autosave effect
only watches `details`. So applying a voice result carrying a lab, a due
date and a prosthesis map, then reloading, kept the detail and silently
dropped all three: the surviving detail made it look like the save worked.

applyVoiceResult moves below persistDraft/persistLabCases so it can call
them, and writes detailsRef itself before persisting — persistDraft reads
that ref, and setDetails has not rendered by the time the save runs. The
ref is already written imperatively elsewhere for the same reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:05:30 +03:30
62deff0523 fix(frontend): untick prosthesis when a picked tooth breaks its map
initialVoiceSelection deliberately never auto-ticks an incomplete
prosthesis map, because a detail with an untyped tooth cannot ship — it
fails at dispatch instead. Picking a candidate tooth walked straight
through that rule: the tick was seeded once, so a map that was complete at
extraction stayed ticked after a tooth with no prosthesis type joined it,
and Apply attached a map assertCompleteToothProsthesisMap rejects.

Recomputed on each pick, and only ever downwards — re-ticking is the
clinician's call, not a side effect of un-picking.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:05:30 +03:30
b7ee61433e docs: record the FDI-first tooth rule in the voice spec
The spec's tooth section described the design that the first live test
disproved — descriptive phrasing primary, bare numerals refused in en.
Rewrites §6 around the rule the product actually wants, records the chip
affordance in §7, and closes open item §11.5: a two-digit number is FDI in
all three locales, with the Universal-numbering trade-off written down
rather than left implied.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:05:30 +03:30
754efdee09 feat(frontend): let the clinician pick the tooth from the candidates
An under-specified tooth was a dead end: the sheet said what was missing
and the clinician had to leave and hunt for it on the chart. The readings
are enumerable, so the review sheet now renders them as chips — the one
interactive part of an otherwise read-only confirmation step.

A pick is folded into the result by withChosenTeeth() rather than tracked
alongside it, so the rows, the mini chart, the prosthesis warning and
applyVoiceResult all keep reading a single VoiceExtractionResult and none
of them has to know the chips exist. It unions rather than toggles: a
candidate can coincidentally be a tooth the recording already produced, and
tapping it must not deselect that one.

Two things that would otherwise make the chips look functional while
applying nothing: the teeth row is ticked on the first pick (it starts
unticked when the recording produced no teeth of its own), and the apply
count is now intersected with row availability so it cannot promise to
apply a row with nothing in it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:05:30 +03:30
82fad4ac02 feat(backend): offer the candidate teeth for an unspecified quadrant
A tooth the resolver refuses to guess at is currently a dead end: the sheet
says the quadrant was missing and the clinician has to leave and find the
tooth on the chart. But the readings are enumerable — "دو" is one of four
teeth, "دو بالا" one of two — so unresolved items now carry them.

Narrowed by whatever was actually said, so this stays a choice offered to
the clinician rather than a guess made for them. Only tooth_missing_quadrant
carries candidates; a wrong position or a deciduous tooth has nothing to
choose between.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:05:30 +03:30
52a7359b9a feat(backend): read a spoken tooth number as its FDI code
The prompt had this backwards. "Never output an FDI tooth code unless the
speaker used FDI notation. Prefer arch + side + position" pushed the model
to decompose speech into "upper / patient_right / six", so the clinician
effectively had to *describe* every tooth. Saying "دندون بیست و شش" — the
way a dentist actually dictates — was the unsupported path.

FDI is what clinicians speak, so the prompt now teaches the notation
instead of forbidding it: first digit = quadrant from the patient's own
point of view, second digit = position from the midline. arch/side/position
stays as the reading of a *described* tooth, where a single digit is a
position and the quadrant comes from words.

Two guards come with it, because bare numbers are now teeth: a single digit
alone still refuses to guess a quadrant, and dates, counts and quantities
are explicitly not teeth.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:05:30 +03:30
c09698aea2 fix(backend): read a tooth code whatever script its digits are in
The extraction model transcribes Persian speech, so it can hand back "۲۶"
in Persian digits or "2 6" from a digit-by-digit dictation. Both were
compared literally against /^[1-8][1-8]$/, missed, and fell through to the
positional branch with no quadrant — where the tooth was reported as "not
understood". The clinician loses a tooth and is told the words were the
problem.

normalizeFdiCode() now runs at both the branch choice and the final
validation, so the two cannot disagree. toLatinDigits moves out of
jalali.ts into common/digits.ts: it was exported but unused in production,
and a tooth module reaching into the calendar module would read as an
accident.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:05:30 +03:30
efff258910 fix(backend): apply the large-body limit to every spelling Express routes
req.path was compared to the canonical '/api/voice/extract' only, but
Express routes case-insensitively and ignores a trailing slash by default.
'/api/voice/extract/' therefore reached the controller with the 100 kb
parser, and 413'd every recording past ~20 seconds — a failure that reads
as a broken microphone rather than a routing detail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:05:30 +03:30
1be735a7ab fix(voice): say the quadrant is missing instead of "could not be read"
"ترمیم برای دندون دو" set the treatment type but reported the tooth as
unreadable. Nothing was misheard: position 2 arrived intact, with no
quadrant, because none was spoken — four teeth carry position 2 and the
resolver correctly refused to pick one. Only the label was wrong, and it
sent the clinician looking for a transcription fault.

Adds a tooth_missing_quadrant reason that names what is missing and shows
how to say it ("دو بالا راست"), and tells the model explicitly to report a
quadrant-less number with arch and side null rather than guessing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:05:30 +03:30
afb30691cf fix(backend): restore the large-body limit on the voice route
POST /voice/extract returned 500 for any real recording. The threshold was
exactly 100 kb — Express's body-parser default — which is about 20 seconds of
audio, so the endpoint was unusable at its own 2-minute cap.

The scoped parser was registered as a path-mounted json() stacked in front of a
default one, which relied on two implicit behaviours: Express stripping the
mount path, and body-parser skipping a request another parser had already
handled. That coupling broke when the surrounding middleware order shifted, and
it broke silently — the parser was still registered, just no longer the one that
ran. Bisected by dumping the Express layer stack and confirming the raw error was
`entity.too.large` with `limit: 102400`.

Replaced with a single middleware that picks a parser by path. No mount-path
stripping, no dependence on parser ordering. Extracted to common/body-parsers.ts
so it is covered by a unit test rather than only reachable through main.ts, which
createTestingModule never executes.

The test is mutation-checked: forcing the default parser fails 2 of its 5 cases.
It also pins that the larger limit does not leak app-wide, and that a merely
similar path (/api/voice/extract/extra) does not get it.

Verified against the compiled server: 300 kb now reaches /api/voice/extract,
/api/auth/login still rejects it, and ordinary requests are unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:05:30 +03:30
e330572ad6 docs: mark the voice spec implemented
Implemented across 12 commits on feat/voice-treatment-entry. Still blocked on
the Persian ASR spike before it is trustworthy in front of patients: nothing in
the implementation compensates for a bad transcript.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:05:30 +03:30
3f97940a16 feat: wire voice entry into the treatment workspace
Makes the feature reachable end to end: availability is fetched alongside the
catalogs, the capture hook drives the segmented control, and confirming the
review sheet appends a new detail.

Confirm always appends — it never edits an existing detail and never calls
onAddDetail. Ticked rows land on top of the seeded defaults, so unticking the
type row leaves the appointment-purpose default rather than a blank. Lab-side
rows ride on a lab case draft keyed by the detail's *client* id, so a brand-new
unsaved detail can carry a lab, due date and per-tooth prosthesis map.

Availability comes from the API rather than a NEXT_PUBLIC_* var, since those are
baked in at build time; a failure fetching it degrades to no microphone rather
than taking the treatment tab down.

From review of this commit:

- Unticking "teeth" while leaving "prosthesis" ticked attached prosthesis rows
  for teeth the detail does not contain. Nothing downstream filters them —
  assertCompleteToothProsthesisMap only checks detail-teeth ⊆ map, never the
  reverse — so they would have reached task generation as lab work for teeth
  nobody is treating. The map is now filtered to the detail's own teeth.
- The microphone was gated on the URL locale while the server resolved
  everything from req.user.language. Those diverge (a bookmarked /fa/ URL, a
  language toggle whose save failed), which would transcribe Persian with an
  English hint and anchor "next Thursday" to a Monday week instead of a Saturday
  one — or 403 from a visibly-enabled button. The client now sends the locale the
  microphone was offered in, so the gate and the request agree by construction.

Also fixed from the previous review: a civil YYYY-MM-DD date rendered a day
early west of Greenwich (parsed as UTC midnight); the missing-teeth list
hardcoded the Arabic comma for all locales; and voiceApply had no ICU plural, so
the common single-field case read "Apply 1 fields".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:05:30 +03:30
c07f550e00 feat(frontend): split Add detail into a segmented control with voice
The microphone becomes the second segment of the Add detail button, built like
the detail chip's trash affordance in the same file — an overflow-hidden rounded
wrapper holding two raw <button>s divided by border-s — rather than two shared
Buttons, which each hardcode their own rounding and would fight a segmented
control. border-s puts the mic 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 behaviour.

The control never changes size while recording; the timer and level meter live
in a bar between the header row and the chip strip, because the header is
sm:justify-between and growing the button would shove the row on every start and
stop. The meter exists to prove the microphone is actually hearing something —
silence and a dead mic look identical otherwise.

Voice reaches the editor as one optional `voice` prop, so its absence *is* the
unavailable state and the two cannot disagree.

Fixes from review of this commit:

- mountedRef was set false on unmount and never re-armed, so under StrictMode
  the hook was permanently "unmounted" in dev and recording silently never
  started.
- onStart guarded only on `phase`, which does not change until getUserMedia
  resolves; a second click during the permission prompt orphaned the first
  MediaStream, leaving the mic indicator lit.
- Week start is now per locale. "Next Thursday" is week-relative, and hardcoding
  Saturday put an en/nl clinician's deadline a week out.
- A missing `which` on a weekday intent is read as "this" rather than failing —
  a bare weekday carries no qualifier, and rejecting it discarded a real
  deadline.
- durationMs is client-reported and so is a claim, not enforcement; the cap is
  now also checked against the vendor's own usage.seconds.
- Blob type falls back to the recorder's actual mimeType before webm, so old
  Safari's mp4/aac clips are not mislabelled.

Two review findings were rejected as incorrect, both re-verified against live
sources: google/gemini-3.7-flash does exist on OpenRouter (1M context,
$0.375/$1.875 per M), and base64 JSON input_audio is the documented primary
path for /audio/transcriptions, with multipart as the OpenAI-compatible
alternative. The spec's stale "unverified" note is corrected, and the provider
now has unit tests covering the request shape, usage parsing, and that a vendor
error body never reaches the thrown message.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:05:30 +03:30
93c6513df6 feat(frontend): voice capture hook, API client and types
MediaRecorder handling and the API call live in lib/, not in ui/, so
TreatmentDetailsEditor can stay presentational and take only a `voice` prop.

Container choice is made at record time and needs no transcode: Chrome and
Android give webm/opus, Safari and iPad give mp4/aac, and the transcription
endpoint accepts both. Safari's `audio/mp4` is sent as `m4a`, the name the
vendor's container list actually uses, so iPad recordings do not fail while
Chrome works. Older Safari shipped MediaRecorder without isTypeSupported, so
that path lets the browser choose rather than refusing outright.

From review of this commit:

- The auto-stop at maxMs guaranteed a 413. The client measures the final length
  after the recorder has stopped, so a recording that runs to the cap always
  reports slightly over it, and the server rejected exactly the recording the
  auto-stop existed to save. The server now allows a documented 2s tolerance and
  the client keeps reporting the true length, so telemetry stays honest.
- getUserMedia is async, so a permission granted after unmount installed a live
  stream the cleanup effect had already run past — leaving the browser's
  recording indicator lit with nothing listening. Guarded with a mounted ref.
- Client-side failures are now ApiError-shaped ({code, statusCode}) rather than
  bare Errors, because getUserFacingError only resolves that shape; without it
  errors.VOICE_MIC_DENIED was dead in all three locales.

Cancelling aborts the request, which closes the connection and aborts the
metered vendor call server-side rather than letting it settle unseen. The level
meter is best-effort: a blocked AudioContext costs the meter, not the recording.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:05:30 +03:30
db9d7d280a feat(backend): voice extraction endpoint
POST /voice/extract behind JwtAuthGuard + ClinicOrgGuard, plus
GET /voice/availability so the frontend can decide whether to render the
microphone — it cannot learn that from NEXT_PUBLIC_*, which are baked in at
build time.

Audio is held in memory for the request only: never written to disk, never a
Prisma row. The transcript goes back to the client and is not persisted. What
is logged is structured and patient-free — clip length, which fields resolved,
unresolved count, vendor cost, outcome — with log lines as the interim sink
until this repo has metrics infrastructure.

On extraction failure the transcript still travels back in the error details,
so the words the clinician already paid for can be salvaged into a note.

v1 ships ungated beyond a configured locale profile; the Plan.features design
is deferred, not dropped.

From review of this commit, four of which were load-bearing:

- Express's 100 kb default body limit rejected any recording past ~20 seconds,
  making the endpoint unusable at its own 2-minute cap. Body parsers are now
  registered explicitly with a 10 MB limit scoped to the voice route only.
  Verified empirically: 600 KB reaches /api/voice/extract, while /api/auth/login
  still 413s.
- ThrottlerGuard keys on req.ip, so behind nginx the whole deployment would
  share one bucket and an abuser rotating IPs would bypass it. VoiceThrottlerGuard
  keys on the user id instead — with no plan gate, this is the only control on
  metered vendor spend.
- ThrottlerException had no 429 fallback and surfaced as INTERNAL_ERROR; the
  guard now throws VOICE_RATE_LIMITED directly.
- durationMs was optional, so omitting it bypassed VOICE_MAX_RECORDING_MS
  entirely. It is required.
- VOICE_UNSUPPORTED_FORMAT was dead code — the DTO's @IsIn already rejects
  unknown containers — so it is gone rather than left unreachable.

ThrottlerModule is deliberately not bound as a global APP_GUARD: a global
ThrottlerGuard rate-limits every route against every named throttler, which
would have capped the whole API at the voice limit.

All seven remaining VOICE_* codes have errors.* keys in en, fa and nl.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:05:30 +03:30
336fc76035 feat(backend): OpenRouter voice providers and per-locale registry
ASR and extraction are separate, independently swappable roles resolved per
locale from config. All three locales point at the same OpenRouter models today
(whisper-1, gemini-3.7-flash); the indirection stays because Persian ASR is the
weakest link and repointing only `fa` must not be a code change.

The model emits a deliberately flat wire shape rather than the internal
discriminated unions — strict json_schema mode has poor union support — and
toVoiceIntent narrows it. That normalizer is total: a missing or malformed
payload yields a shape the resolvers report as unresolved rather than one that
throws.

The prompt supplies catalog codes with labels in the actor's locale, so the
model matches spoken words rather than translating, and carries per-locale
tooth vocabulary. English gets an explicit warning that a bare two-digit number
is ambiguous under Universal numbering, and must not be treated as FDI unless
the speaker said so.

From review of this commit:
- only an actually FDI-shaped code takes the explicit branch; fdi:"6" alongside
  valid arch/side/position used to lose the tooth entirely
- an unrecognised due kind passes through to be flagged, instead of collapsing
  to null and looking like no deadline was ever spoken
- vendor error bodies stay out of the thrown message and the default log level;
  a 4xx can echo the request back, transcript included
- the chat call sets provider.require_parameters so OpenRouter only routes to
  endpoints that honour the JSON schema, rather than ones treating it as a hint
- an unknown locale in VOICE_ENABLED_LOCALES now fails at boot like an unknown
  provider id, instead of silently disabling the microphone everywhere

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:05:30 +03:30
ae7009534e feat(backend): assemble resolved extraction from voice intents
Composes the tooth, span, prosthesis, catalog and date resolvers into the
payload the review sheet renders.

Connected spans expand: "a bridge from 14 to 16" selects 15, which was never
spoken. Overlapping spans merge into one bridge, group teeth sort along the
arch (16-15-14, and 11 beside 21 across the midline), and a span collapsing to
a single tooth degrades to a single group without losing that tooth — there is
no such thing as a one-tooth bridge. A cross-arch span is impossible and is
reported rather than guessed at.

Prosthesis expands a default across the selection then applies per-tooth
overrides, because "همه زیرکونیا، ۲۶ پی‌اف‌ام" is how clinicians actually speak.
Completeness is computed here so an unshippable map surfaces at review rather
than failing later at dispatch.

Everything the model names is checked against the catalog we supplied it, and
anything rejected is reported rather than dropped — a hallucinated lab id must
not look identical to "no lab was spoken", since silence and a wrong lab lead
to very different corrective actions.

Also fixed, from review of this commit:
- an empty prosthesis object no longer fabricates an "incomplete, cannot ship"
  warning on a plain restoration
- an override naming a tooth outside the selection now reports
  tooth_not_selected rather than malformed; the clinician was understood, the
  tooth just is not on this detail
- a due object with no `kind` is treated as no deadline rather than a blank
  "heard but lost" row; an unrecognised kind is still flagged, and named

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:05:30 +03:30
a1a999a884 fix(backend): correct "next weekday" and harden resolvers against model output
Four defects found by review of the preceding commits.

"next <weekday>" was occurrence-anchored ("this" plus seven) rather than week-
anchored. Said on a Thursday, "Thursday next week" resolved to +14 instead of
+7: next week runs Sat 10-18 to Fri 10-24, so its Thursday is 10-23, not 10-30.
A lab case a week late. "next" now counts from the start of the following
Saturday-start week, which also lets "this" and "next" correctly coincide —
said on a Thursday, "the coming Saturday" and "Saturday next week" are the same
day. "this" stays occurrence-anchored so it can never resolve into the past.

The other three all come from the same root cause: exported functions that are
reachable from untrusted model output must degrade, not throw or drop.

- a non-object `due` (the model emitting a bare string) was treated as "no
  deadline spoken" and silently discarded; only null/undefined mean absent now,
  anything else is flagged so the clinician sees something was heard and lost
- isJalaliLeapYear / jalaliDaysInMonth threw for years outside the conversion
  table, contradicting the module's own "degrade to null" contract; they now
  return false / 0, which also makes isValidJalaliDate's day check naturally
  false
- civilDateInZone passed a client-supplied zone straight to Intl, which raises
  RangeError before any fallback; it now validates and backstops to UTC, so a
  bad zone costs at most a day rather than a 500

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:05:30 +03:30
70092e932d feat(backend): resolve spoken deadlines to ISO dates
Jalali conversion is arithmetic here, not inference. A model asked to turn
"۲۵ مهر" into ISO answers confidently and is often wrong, and @IsDateString()
accepts the wrong answer — so the model emits a date intent and this decides
what it means.

Deviation from the spec, deliberately: the resolver takes todayIso rather than
an IANA zone. Working in civil dates means nothing here reasons about instants.
The zone is used one level up, where civilDateInZone() derives "today" from the
actor's zone server-side — better than the spec's client-supplied date, which
the client could set arbitrarily.

Conventions pinned by tests:
- "this <weekday>" is 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. "next" adds a further week.
- month offsets clamp to the end of shorter months (31 Jan + 1 = 28/29 Feb)
- a resolved date in the past, or more than five years out, is treated as
  unresolved however it was arrived at — an absolute date the model invented
  can land anywhere
- no due date at all is not an error; an unparseable one is, and echoes what
  was heard so the review sheet can show it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:05:30 +03:30
1076c13472 feat(backend): voice intent contract and tooth-intent resolver
The extraction model emits intents, never resolved values — no FDI codes, no
ISO dates. This adds the contract it must satisfy and the resolver that turns
spoken tooth references into FDI, so quadrant mirroring is a unit test rather
than a hope.

resolveToothIntent never guesses and never clamps: position 9, a deciduous
tooth, or a malformed shape resolve to null and are reported as unresolved with
the transcript span that produced them, so the review sheet can show the
clinician exactly which words were not understood.

Everything here parses untrusted model output, so nothing may throw:

- a non-array where a list was expected degrades like any other malformed shape
- explicit codes are trimmed, for parity with normalizeTeeth
- '51' reports as not_permanent_tooth (a real primary tooth the chart cannot
  show) while '99' reports as malformed — the clinician should not be told a
  deciduous tooth was heard when nothing tooth-shaped was
- unresolved items only dedupe when they carry a spoken span; without one,
  collapsing them would hide a lost tooth behind a single blank review row

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:05:30 +03:30
4b4c197c03 feat(backend): extract shared FDI tooth geometry
Voice extraction needs quadrant mapping and adjacency server-side, and
treatment.utils.ts already held a private copy of the tooth set. Lift it into
common/fdi.ts rather than create a second source of truth; treatment.utils now
imports it, behaviour unchanged (existing suites still pass).

toFdi() is the single place the patient-right convention lives: quadrant 1 is
the patient's upper right, so upper+patient_right -> 1x, upper+patient_left ->
2x, lower+patient_left -> 3x, lower+patient_right -> 4x. Getting this backwards
mirrors every quadrant and yields a valid-looking code for the wrong tooth,
which no schema check can catch — so all four quadrants are pinned by tests,
along with out-of-range positions never being clamped and deciduous teeth being
rejected outright (the chart is permanent dentition only).

Adjacency mirrors the frontend's arch-order rule, so the midline pairs 11-21
and 41-31 count as neighbours exactly as the chart treats them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:05:30 +03:30
80ffa37edd feat(backend): port Jalali calendar arithmetic with tests
Voice extraction resolves spoken Jalali dates into ISO dates server-side, so
the backend needs the conversion the frontend already had. The resolvers live
here rather than in the frontend precisely because this half of the repo has a
test runner.

Ported from frontend/src/lib/i18n/persianCalendar.ts and verified faithful by
differential test: every day from 1900-2100 (73,414 days), zero mismatches on
conversion, leap years and month lengths.

Two deliberate divergences from the original:

- jalaliToIsoDate() returns null instead of throwing. It is fed model-supplied
  values, which may be nonsense, and an invalid date must degrade to
  "unresolved" rather than a 500. The year guard runs before jalaliDaysInMonth
  so the throwing jalCal is unreachable from it.
- toLatinDigits() also handles the Arabic-Indic block (U+0660-U+0669), not just
  Persian (U+06F0-U+06F9). ASR output can carry either, sometimes mixed with
  ASCII in one transcript; the frontend version only parses keystrokes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:05:30 +03:30
648e8ed1f2 docs: spec for voice-driven treatment detail entry
Design spec for filling a TreatmentDetail by voice, settled across three
grilling sessions (30 decisions, logged in the spec).

Key shape:
- two-stage pipeline: OpenRouter whisper-1 -> gemini-3.7-flash
- the LLM emits *intents*, never FDI codes or ISO dates; pure Jest-tested
  backend resolvers own quadrant mapping and Jalali conversion
- provider registry keyed by locale so fa can diverge from en/nl
- review sheet confirms before anything touches the form
- audio and transcripts are never persisted

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:05:30 +03:30
809c72af0f Merge branch 'master' into feature/ci-cd 2026-08-23 19:09:52 +03:30
201300f446 staging deploy md doc. 2026-08-23 19:06:13 +03:30
24c4feb11c Enable Gitea Actions staging deploy on master push 2026-08-23 19:06:13 +03:30
3a16f04c51 package-locks and prisma migration update. 2026-08-23 19:06:13 +03:30
6d49fd0b89 Bind staging nginx to localhost for Windows Docker Desktop
Publish port 8088 on 127.0.0.1 only so netsh portproxy can expose staging
to LAN/public IPs; document the one-time portproxy setup on Windows.

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-23 16:16:14 +03:30
2b2cca8361 fix docker url for making connection with gitea. 2026-08-23 15:20:14 +03:30
0e8e3a4a27 local ip set instead of static public ip server to find it in network. 2026-08-23 14:13:29 +03:30
b4832cd663 Merge pull request 'bugfix/treatment-bugs' (#66) from bugfix/treatment-bugs into master
All checks were successful
Registry — build, push, deploy / temp-success (push) Successful in 2s
Reviewed-on: http://178.131.50.201:3000/admin/dyolink/pulls/66
Reviewed-by: aminmsvi <amn.mousavi@gmail.com>
2026-08-23 12:44:44 +03:30
a43c433d55 lable self-hosted change to windows 2026-08-23 11:34:10 +03:30
8b462ea45a Merge branch 'master' into feature/ci-cd 2026-08-23 11:11:27 +03:30
ebd2de39cf staging deploy md doc. 2026-08-23 11:11:07 +03:30
1258ecf899 Enable Gitea Actions staging deploy on master push 2026-08-23 11:10:28 +03:30
8d34def001 improvement: the last flow improved a little. 2026-08-22 20:33:11 +03:30
7f02a84ebf improvement: a flow added to create treatment plan for searched patients with no treatment history. 2026-08-22 20:25:11 +03:30
b1405b22b1 improvement: patient search moved to the top of treatment feature. 2026-08-22 20:11:43 +03:30
50725b7b3f bugfix: no-detail treatment ui made some confusion. it is improved. at least to a degree. 2026-08-22 19:36:37 +03:30
f52ad6b84a bugfix: Treatment detail auto-fill bug fixed. 2026-08-22 18:51:57 +03:30
de46da216f bugfix: FDI tooth chart is now compatible with RTL direction. 2026-08-22 17:49:38 +03:30
b779a57cba remove ./public from dockerfile. 2026-08-21 14:58:21 +03:30
e6c236f1a0 package-locks and prisma migration update. 2026-08-20 11:30:14 +03:30
171 changed files with 10021 additions and 2216 deletions

View File

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

View File

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

View File

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

View File

@@ -1,14 +1,15 @@
---
description: Lab case QR share link — token, access API, focus page, auth redirect
globs: frontend/src/app/**/lab-case/**,frontend/src/components/ui/lab/CaseTasksFocusView.tsx,frontend/src/components/ui/lab/LabCaseShareQr*.tsx,frontend/src/lib/api/lab-case-access.ts,frontend/src/lib/auth/postAuthRedirect.ts,frontend/src/app/**/login/page.tsx,backend/src/modules/cases/lab-case-access.*,backend/src/common/lab-case-access-token.ts
globs: frontend/src/app/**/lab-case/**,frontend/src/components/ui/lab/CaseTasksFocusView.tsx,frontend/src/components/ui/lab/LabCaseShareQr*.tsx,frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx,frontend/src/lib/api/lab-case-access.ts,frontend/src/lib/auth/postAuthRedirect.ts,frontend/src/app/**/login/page.tsx,backend/src/modules/cases/lab-case-access.*,backend/src/common/lab-case-access-token.ts,backend/src/modules/treatments/treatments.service.ts
alwaysApply: false
---
# Lab case share link
- **Token:** `LabCase.accessToken` on first ship; `shareUrl` on case detail only when sent.
- **Token:** `LabCase.accessToken` on first ship; `shareUrl` on lab case detail **and** clinic treatment lab-case drafts when sent.
- **QR:** client-side `react-qr-code` in frontend — ❌ no backend QR endpoint.
- **Cases panel:** attachment preview left, QR thumb right, one row; dialog for QR + copy link.
- **Treatment dispatch:** same thumb + dialog on sent cases (`LabCasesDispatchPanel`). Dest/jobs share a row with the QR; tracker + comments are full width below.
- **Route:** `/lab-case/[token]` → `CaseTasksFocusView` (dashboard layout, auth required).
- **Access:** lab (`TAB_TASKS_*`) or clinic treatment **provider** (`TAB_TREATMENT_EDIT` + `isActorTreatmentProvider`); else `LAB_CASE_ACCESS_DENIED`.
- **Task status on link page:** same assignee rule as Tasks — `canEditLabTaskStatus`; backend `PATCH /tasks/:id` enforces assignee.

View File

@@ -1,6 +1,6 @@
---
description: Lab Cases tab — prosthesis filter, auto-select, list cards, Case Sheet PDF
globs: frontend/src/components/ui/lab/CasesPage.tsx,frontend/src/components/ui/lab/CaseDetailPanel.tsx,frontend/src/components/ui/lab/LabCaseProsthesisGroupsList.tsx,frontend/src/components/ui/lab/LabCaseShareQr*.tsx,frontend/src/components/ui/lab/CaseSheet*.tsx,frontend/src/components/lab/caseSheetPdf.ts,frontend/src/components/lab/caseDetailUtils.ts,backend/src/modules/cases/**
globs: frontend/src/components/ui/lab/CasesPage.tsx,frontend/src/components/ui/lab/CaseDetailPanel.tsx,frontend/src/components/ui/lab/LabCaseProsthesisGroupsList.tsx,frontend/src/components/ui/lab/ProsthesisStackedTypeLabel.tsx,frontend/src/components/ui/lab/LabCaseShareQr*.tsx,frontend/src/components/ui/lab/CaseSheet*.tsx,frontend/src/components/lab/caseSheetPdf.ts,frontend/src/components/lab/caseDetailUtils.ts,frontend/src/components/treatment/prosthesisJobPath.ts,backend/src/modules/cases/**
alwaysApply: false
---
@@ -8,7 +8,7 @@ alwaysApply: false
- **Filter by prosthesis**, not treatment type — `prosthesisTypeCode`; matches cases with **any task** of that type.
- **Auto-select** newest case on tab open; right panel never empty when list has items; `?caseId=` deep link overrides.
- **List cards:** `LabCaseProsthesisGroupsList` (catalog color + teeth); same component as Treatment shipments rail; no patient mobile on card.
- **List cards:** `LabCaseProsthesisGroupsList` (catalog color + teeth); same component as Treatment shipments rail; no patient mobile on card. Connected same-type teeth share a group; stacked types on one tooth split by `+` in the type key. Job titles are the picker path to the **leaf** (`prosthesisJobPath.ts` / `ProsthesisStackedTypeLabel`) — e.g. Crowns · PFM Crown, never the category alone. Indirect omits Veneer/Inlay/Onlay/Overlay (Inlay · Layered ceramic). Join ancestors/leaf with `PROSTHESIS_JOB_PATH_SEP` (`\u00a0·\u00a0`) so the gap does not collapse between inline spans.
- **Assignment** in detail only (`TAB_CASES_EDIT`); task status changes on Tasks tab.
- **Share link:** QR + URL when case is sent; token API + focus page — see `.cursor/skills/lab-case-share-link/SKILL.md`.
- **Case Sheet PDF:** client A4 via `jspdf` + `html2canvas`; print layout must stay **hex-only** (Tailwind `lab()`/`oklch` breaks capture). Optional `externalCode` replaces PDF order number when set.

View File

@@ -1,14 +1,14 @@
---
description: Lab Tasks tab — sort, grouping, filters, prosthesis colors
globs: frontend/src/components/ui/lab/TasksPage.tsx,frontend/src/components/ui/lab/Task*.tsx,frontend/src/components/ui/lab/Case*.tsx,frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx,frontend/src/components/lab/taskListGrouping.ts,frontend/src/components/lab/labCaseDueDateDisplay.ts,frontend/src/components/shared/formSelectStyles.ts,frontend/src/components/treatment/prosthesisTypeDisplay.ts,frontend/src/components/shared/catalog-type-colors.ts,backend/src/modules/tasks/**,backend/src/common/lab-case-due-date.ts,backend/src/modules/treatments/**
globs: frontend/src/components/ui/lab/TasksPage.tsx,frontend/src/components/ui/lab/Task*.tsx,frontend/src/components/ui/lab/Case*.tsx,frontend/src/components/ui/lab/ProsthesisStackedTypeLabel.tsx,frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx,frontend/src/components/lab/taskListGrouping.ts,frontend/src/components/lab/labCaseDueDateDisplay.ts,frontend/src/components/shared/formSelectStyles.ts,frontend/src/components/treatment/prosthesisTypeDisplay.ts,frontend/src/components/treatment/prosthesisJobPath.ts,frontend/src/components/shared/catalog-type-colors.ts,backend/src/modules/tasks/**,backend/src/common/lab-case-due-date.ts,backend/src/modules/treatments/**
alwaysApply: false
---
# Lab Tasks
- **Default sort:** newest `labCase.sentAt` first; `stepOrder` asc within prosthesis group (backend `buildOrderBy`).
- **Grouping:** only when `sortBy=date`; flat list + hint for other sorts.
- **Prosthesis colors:** `PROSTHESIS_TYPE_COLORS` + `prosthesisTypeBadgeStyleFromCatalog` — never row index.
- **Grouping:** only when `sortBy=date`; flat list + hint for other sorts. Unconnected teeth stay one pipeline; **connected** teeth that share a type share a pipeline; `partial_denture` is one pipeline for all selected teeth.
- **Prosthesis colors:** `PROSTHESIS_TYPE_COLORS` + `prosthesisTypeColorFromCatalog` as text (no type pills) — never row index. Stacked `code+code` labels use `ProsthesisStackedTypeLabel` (one color per type). Titles are the picker path to the leaf (`prosthesisJobPath.ts`), not the category name alone. Indirect omits the Veneer/Inlay/Onlay/Overlay parent. Path joins use `PROSTHESIS_JOB_PATH_SEP` (`\u00a0·\u00a0`).
- **Important first:** `pinImportant=true` (sort pin, not filter).
- **Task assignment:** assign in Cases (`TAB_CASES_EDIT`); status edit on Tasks only for assignee or unassigned tasks; others see “Assigned to {name}”.
- **Grouped comments:** when `sortBy=date`, one comments icon on **case header** (not per row); flat sort keeps per-row comments.

View File

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

View File

@@ -11,8 +11,8 @@ Shipped cases get a stable **access token** and share URL. QR + link open a focu
- **Schema:** `LabCase.accessToken` (`String?`, `@unique`).
- **On first ship:** `treatments.service` sets `accessToken` + `sentAt` in the same update.
- **Backfill:** `LabCaseAccessService.ensureAccessToken()` for older sent cases when building case detail `shareUrl`.
- **URL:** `buildLabCaseShareUrl(token, locale)``{FRONTEND_URL}/{locale}/lab-case/{token}` (`backend/src/common/lab-case-access-token.ts`).
- **Backfill:** `LabCaseAccessService.ensureAccessToken()` for older sent cases when building case detail `shareUrl`; clinic drafts use `TreatmentsService.ensureSentLabCaseAccessTokens()` on getDraft.
- **URL:** `buildLabCaseShareUrl(token, locale)``{FRONTEND_URL}/{locale}/lab-case/{token}` (`backend/src/common/lab-case-access-token.ts`). Clinic lab-case payloads include the same `shareUrl`.
## Backend API (`LabCaseAccessController`)
@@ -41,11 +41,13 @@ Task status updates use **`PATCH /tasks/:id`** (not token routes) — same assig
|-------|------|
| Focus page | `app/[locale]/(dashboard)/lab-case/[token]/page.tsx``CaseTasksFocusView` |
| API client | `lib/api/lab-case-access.ts` |
| QR UI | `LabCaseShareQrCode`, `LabCaseShareQrDialog`, thumb in `CaseDetailPanel` |
| QR UI | `LabCaseShareQrCode`, `LabCaseShareQrDialog`, thumb in `CaseDetailPanel` **and** Treatment `LabCasesDispatchPanel` |
| QR package | `react-qr-code` (frontend only — no backend QR generation) |
**Cases detail header:** attachment preview **left**, QR thumb **right**, same row (`w-24 sm:w-32`). QR opens dialog (large QR + URL + copy); no inline copy on panel. Only when `shareUrl` present (sent case).
**Treatment lab dispatch:** after send, the same thumb + dialog (same `shareUrl` / token) so the dentist can scan to the focus page. Dest/jobs share a row with the QR; tracker + comments span the card **below** it (not beside it).
**Share focus page:** grouped tasks (reuse `TaskRow`, `TaskCaseGroupHeader`); comments via `LabCaseCommentsPanel` + token API (`viewerSide` from `session.accessMode`). Newest-first; sent/received use logical start/end. Access denied → inline message (`asApiError` for `LAB_CASE_ACCESS_DENIED`).
## Auth redirect (logged out → login → back)

View File

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

View File

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

View File

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

2
.gitattributes vendored Normal file
View File

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

View File

@@ -0,0 +1,281 @@
# 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
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 .
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- 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
}
$prefix = "${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_OWNER }}"
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
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Resolve node:20-alpine (Gitea, then mirrors, Hub last)
run: |
$ErrorActionPreference = 'Stop'
$prefix = "${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_OWNER }}".Trim()
$extra = '${{ vars.NODE_IMAGE_SOURCE }}'.Trim()
if ($extra -like '*NODE_IMAGE_SOURCE*') { $extra = '' }
# Do not nest powershell -File: empty -ExtraSources "$extra" is dropped and PS5.1 errors MissingArgument.
$scriptArgs = @{ RegistryPrefix = $prefix }
if (-not [string]::IsNullOrWhiteSpace($extra)) { $scriptArgs['ExtraSources'] = $extra }
& .\infrastructure\scripts\ci-resolve-node-image.ps1 @scriptArgs
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Build and push backend (tag only, not :latest)
run: |
$ErrorActionPreference = 'Stop'
$dispatchTag = '${{ github.event.inputs.tag }}'.Trim()
if (-not [string]::IsNullOrWhiteSpace($dispatchTag)) { $tag = $dispatchTag } else { $tag = "${{ github.ref_name }}" }
$prefix = "${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_OWNER }}".Trim()
$nodeImage = ([System.IO.File]::ReadAllText((Join-Path (Get-Location) '.ci-node-image'))).Trim()
if ([string]::IsNullOrWhiteSpace($nodeImage)) {
Write-Host "Missing .ci-node-image"
exit 1
}
if (Test-Path '.ci-use-legacy-builder') { $env:DOCKER_BUILDKIT = '0' }
Write-Host "Building $prefix/dyolink-backend:$tag (NODE_IMAGE=$nodeImage)"
$ok = $false
for ($i = 1; $i -le 3; $i++) {
Write-Host "docker build attempt $i/3"
docker build --build-arg "NODE_IMAGE=$nodeImage" -t "$prefix/dyolink-backend:$tag" ./backend
if ($LASTEXITCODE -eq 0) { $ok = $true; break }
if ($i -lt 3) { Start-Sleep -Seconds (20 * $i) }
}
if (-not $ok) { exit 1 }
docker push "$prefix/dyolink-backend:$tag"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Build and push frontend (nudentic.ir baked in)
env:
PROD_PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
NEXT_PUBLIC_SENTRY_DSN: ${{ vars.NEXT_PUBLIC_SENTRY_DSN }}
run: |
$ErrorActionPreference = 'Stop'
$dispatchTag = '${{ github.event.inputs.tag }}'.Trim()
if (-not [string]::IsNullOrWhiteSpace($dispatchTag)) { $tag = $dispatchTag } else { $tag = "${{ github.ref_name }}" }
$prefix = "${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_OWNER }}".Trim()
$nodeImage = ([System.IO.File]::ReadAllText((Join-Path (Get-Location) '.ci-node-image'))).Trim()
if ([string]::IsNullOrWhiteSpace($nodeImage)) {
Write-Host "Missing .ci-node-image"
exit 1
}
if (Test-Path '.ci-use-legacy-builder') { $env:DOCKER_BUILDKIT = '0' }
$base = $env:PROD_PUBLIC_BASE_URL.Trim()
if ([string]::IsNullOrWhiteSpace($base)) { $base = 'https://nudentic.ir' }
$base = $base.TrimEnd('/')
Write-Host "Building $prefix/dyolink-frontend:$tag (NODE_IMAGE=$nodeImage)"
$ok = $false
for ($i = 1; $i -le 3; $i++) {
Write-Host "docker build attempt $i/3"
docker build `
--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" `
--build-arg "NODE_IMAGE=$nodeImage" `
-t "$prefix/dyolink-frontend:$tag" `
./frontend
if ($LASTEXITCODE -eq 0) { $ok = $true; break }
if ($i -lt 3) { Start-Sleep -Seconds (20 * $i) }
}
if (-not $ok) { exit 1 }
docker push "$prefix/dyolink-frontend:$tag"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
deploy:
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 .
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- 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"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
scp.exe @ssh -P $port `
infrastructure/docker-compose.prod.yml `
"${user}@${hostName}:${infra}/docker-compose.prod.yml"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
scp.exe @ssh -P $port `
infrastructure/scripts/prod-remote-deploy.sh `
"${user}@${hostName}:${infra}/scripts/prod-remote-deploy.sh"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
scp.exe @ssh -P $port `
infrastructure/scripts/render-nginx-ssl.sh `
"${user}@${hostName}:${infra}/scripts/render-nginx-ssl.sh"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
scp.exe @ssh -P $port `
infrastructure/nginx/nginx.ssl.conf.template `
"${user}@${hostName}:${infra}/nginx/nginx.ssl.conf.template"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
ssh.exe @ssh -p $port "${user}@${hostName}" "chmod +x $infra/scripts/prod-remote-deploy.sh $infra/scripts/render-nginx-ssl.sh"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- 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' }
$dispatchTag = '${{ github.event.inputs.tag }}'.Trim()
if (-not [string]::IsNullOrWhiteSpace($dispatchTag)) { $tag = $dispatchTag } else { $tag = "${{ github.ref_name }}" }
$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
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }

View File

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

View File

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

View File

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

131
README.md
View File

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

View File

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

View File

@@ -1,7 +1,10 @@
# CI can pass a Gitea-hosted mirror when Docker Hub TLS fails (see ci-resolve-node-image.ps1).
ARG NODE_IMAGE=node:20-alpine
# ============================================
# STAGE 1: BUILDER STAGE
# ============================================
FROM node:20-alpine AS builder
FROM ${NODE_IMAGE} AS builder
WORKDIR /app
@@ -30,7 +33,7 @@ RUN npm prune --omit=dev
# ============================================
# STAGE 2: PRODUCTION STAGE
# ============================================
FROM node:20-alpine
FROM ${NODE_IMAGE}
RUN apk add --no-cache dumb-init
@@ -47,7 +50,8 @@ COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Windows git/build context may use CRLF; strip before chmod (fixes dumb-init "No such file or directory").
RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh && chmod +x /usr/local/bin/docker-entrypoint.sh
RUN mkdir -p /app/logs && \
chown -R dyolink:nodejs /app

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

View File

@@ -498,12 +498,17 @@ 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.** Per-detail
conveniences that would top the case up afterwards are suppressed for a voice-created
case; concretely, the dispatch panel's remembered-prosthesis default
(`previewConfirmedCaseIds`). A default that quietly adds a prosthesis type to a tooth the
- **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.
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
@@ -741,10 +746,8 @@ enabling this for real clinics.
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 dispatch panel's remembered "last type for this lab" does **not** overwrite
either. Also check the bulk «اعمال برای همه دندان‌ها» select stays on its placeholder;
- **a hand-made prosthesis detail with an empty map** → still gets the remembered default
pre-filled. The exemption is for previewed cases only, not a removal of the convenience.
both, and the bulk «اعمال برای همه دندان‌ها» select stays on its placeholder. Nothing may
rewrite a per-tooth type the sheet already showed.
---

View File

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

View File

@@ -1,5 +1,8 @@
# CI can pass a Gitea-hosted mirror when Docker Hub TLS fails (see ci-resolve-node-image.ps1).
ARG NODE_IMAGE=node:20-alpine
# Build stage — produces `.next/standalone` (see next.config.ts output: standalone)
FROM node:20-alpine AS builder
FROM ${NODE_IMAGE} AS builder
WORKDIR /app
@@ -18,17 +21,21 @@ 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
# Production — minimal runtime using Next.js standalone bundle
FROM node:20-alpine AS runner
FROM ${NODE_IMAGE} AS runner
RUN apk add --no-cache dumb-init
@@ -41,19 +48,19 @@ ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
COPY --from=builder /app/public ./public
COPY --from=builder --chown=dyolink:nodejs /app/.next/standalone ./
COPY --from=builder --chown=dyolink:nodejs /app/.next/static ./.next/static
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Windows git/build context may use CRLF; strip before chmod (fixes dumb-init "No such file or directory").
RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh && chmod +x /usr/local/bin/docker-entrypoint.sh
USER dyolink
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
CMD node -e "require('http').get('http://127.0.0.1:3000/', (r) => {if(r.statusCode!==200)process.exit(1)})"
CMD node -e "require('http').get('http://127.0.0.1:3000/', (r) => { process.exit(r.statusCode >= 200 && r.statusCode < 400 ? 0 : 1); }).on('error', () => process.exit(1))"
ENTRYPOINT ["dumb-init", "--", "docker-entrypoint.sh"]

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 28 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 23 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 306 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.7 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.8 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.6 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 33 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 41 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 18 KiB

View File

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

After

Width:  |  Height:  |  Size: 1.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 20 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 306 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 31 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

File diff suppressed because one or more lines are too long

View File

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

View File

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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 23 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 306 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.7 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.8 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.6 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 33 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 41 KiB

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