Compare commits

..

27 Commits

Author SHA1 Message Date
e48eeb18c4 docs: add CLAUDE.md for project guidance and conventions 2026-08-21 00:52:00 +03:30
63bad336b9 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-21 05:05:04 +08:00
78e756b78f 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-21 05:04:30 +08:00
118853ce73 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-21 05:04:30 +08:00
d226a2b294 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-21 05:04:15 +08:00
2f92f2745b 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-21 05:04:15 +08:00
4bf1bf4389 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-21 04:54:31 +08:00
54e4fa8628 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-21 04:54:31 +08:00
1b22dfa36d 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-21 04:44:20 +08:00
215aa1fd87 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-21 04:43:19 +08:00
17d3c5ca25 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-21 04:42:09 +08:00
647ff65b00 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-21 04:02:49 +08:00
6ba1fad56c 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-21 04:02:39 +08:00
2a42f20bff 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-20 22:12:16 +03:30
7b50134d03 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-20 20:24:08 +03:30
56d413944a 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-20 20:22:42 +03:30
8757a8952c 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-20 19:47:10 +03:30
ff2bd09669 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-20 18:43:04 +03:30
01b4ed7633 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-20 18:27:14 +03:30
de6e259932 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-20 18:00:53 +03:30
b4aff39797 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-20 17:41:41 +03:30
f3fb8736ab 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-20 17:25:44 +03:30
5fcb72508e 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-20 17:17:15 +03:30
5878bd62e4 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-20 17:13:26 +03:30
dfd376d97a 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-20 17:05:54 +03:30
4764401766 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-20 17:00:41 +03:30
ca4d28a976 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-20 16:51:57 +03:30
90 changed files with 1074 additions and 4354 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`. Unexpected 500s / client crashes: GlitchTip via Sentry SDKs (`SENTRY_DSN` / `NEXT_PUBLIC_SENTRY_DSN`). Skill: `.cursor/skills/api-errors/SKILL.md`. Appointments/working hours: client IANA `timeZone` (never Node local `getHours()`).
Logical failures: `AppException(ErrorCode.X)` → `errors.X` in en/fa/nl. UI: `getUserFacingError`. Skill: `.cursor/skills/api-errors/SKILL.md`. Appointments/working hours: client IANA `timeZone` (never Node local `getHours()`).
## Notifications (inbox + live tabs)

View File

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

View File

@@ -10,17 +10,16 @@ alwaysApply: false
- **Current draft** preview: omitted in live editing (form is the source). History browse still uses preview + **Load into workspace**.
- **Entry:** Type dropdown + `TreatmentDetailAttachmentsStrip` on one row, bordered chart (Cases chrome), then full-width auto-growing Notes. No stepper unless prosthesis — then `WizardStepper` Treatment → Lab with Back/Next. Chip switches reset to the treatment form unless `pendingEntryStepRef` requests Lab (shipments rail open). Lab-dependent chips use colored sent/unsent text; sent date is on Lab dispatch. Lab dispatch keeps comments.
- **Tooth hits:** unrotated full cell, `pointerdown` only (not `click` too — double-toggles). Glyph `pointer-events-none`; nest scale/hover inside the rotate wrapper. Groups: `toothSelectionGroups.ts` (never 1-tooth connected); prune lab `toothProsthesis` on change.
- **Day strip:** `ScheduleDayPicker` `compact` centered in the header. Timed cards use purpose banners. Unscheduled cards use the same banner from the **first 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`.
- **Day strip:** `ScheduleDayPicker` `compact` centered in the header. Timed cards use purpose banners. Unscheduled cards use the same banner once typed (sync live draft onto the open card); untyped keep chip theming. Trash inherits banner ink. Strip-delete only when `areUnscheduledDetailsStripDeletable` (blank lines or `[]`); persist `[]` then `DELETE /treatments/:id`.
- **Lab dispatch UI:** due date end-aligned beside title (`sm:flex-row` + `justify-between`; stacks on mobile). Prosthesis type: stacked below `md`, 50/50 same-row from `md`. Content clinical field = **Notes** (not case comments).
- **Lab comments:** shared `LabCaseCommentsPanel` (newest-first; sent=`justify-start`, received=`justify-end`; `viewerSide`) across Treatment / Cases / Tasks / share. Use logical `text-start`/`text-end`, not left/right.
- **Detail chrome:** chips (type + teeth) + Add at top; **Remove** = trash on chip (unsent, including last line; disabled when day-locked / no edit / uploading). Last-line confirm: plan will be empty until Add. Empty `[]` shows `noDetails` (not the type-first overlay). New treatment seeds one blank detail. No delete in type/notes fields.
- **Patient search:** `PatientSearchCombobox` in the **page header** (workspace-wide). Opens 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.
- **Detail chrome:** chips (type + teeth) + Add at top; **Remove** = trash on chip (unsent, including last line; disabled when day-locked / no edit / uploading). Empty details persist as `[]`. New treatment seeds one blank detail; load of empty stays `[]`. No delete in type/notes fields.
- **Lab dispatch attention:** `LabDispatchAttentionPanel` — unsent lab-dependent details; quick jump to dispatch.
- **History API:** patient-scoped; non-owners filtered by provider on treatment or appointment; org owners see all.
- **History filters (client-side):** `PastTreatmentsPanel` — “Not shipped to lab” + single date; helpers in `treatmentHistoryFilters.ts`.
- **Lab shipment block:** lab-dependent detail with no teeth saves but cannot ship — same inline amber banner on Treatment and Lab (`labBlockedBannerClass`); toast on add shipment.
- **Lab shipment block:** lab-dependent detail with no teeth saves but cannot ship — `LabShipmentBlockedNotice`, inline banner, toast on add shipment.
- **Edit gating:** `canEditTreatmentForDay` = permission + appointment + not past day + `live` mode. Sent detail locks that line; Add still OK same day. Upload rejects sent (`TREATMENT_DETAIL_SENT`).
- **Lab search:** `LinkedOrganizationSearchCombobox`; invite lab via `/organizations?action=invite-lab` when permitted. New dispatch lines start empty (lab + apply-all / per-tooth prosthesis type). Chips = last 3 **sent** labs (`labDispatchDefaults.ts`) — never auto-select. Clear the search box when switching details.
- **Lab search:** `LinkedOrganizationSearchCombobox`; invite lab via `/organizations?action=invite-lab` when permitted.
- **Scroll:** `scrollWithinMainScrollContainer`; shared `Checkbox` only.
Full map: `.cursor/skills/treatment-workspace/SKILL.md`

View File

@@ -22,7 +22,7 @@ Thin route: `app/[locale]/(dashboard)/treatment/page.tsx` (supports `?appointmen
1. **Day strip**`AppointmentsStrip.tsx` renders `DayStripItem[]` (`appointment` | `unscheduled`) via `DayStripCard`. Header uses **`ScheduleDayPicker` `compact`**: date is centered in a 3-col grid; no “Schedule date” label; **Today** sits on the navigator (`CalendarDaySelect` when the label row is hidden). Timed appointments keep treatment-type pastel banners. Unscheduled cards use the same banner from the **first 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.
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.
2. **Treatment preview**`TreatmentPreviewCard.tsx` (history browse only; omitted for the live draft)
@@ -41,11 +41,11 @@ Right-column entry is **not** a three-step wizard. Type dropdown + `TreatmentDet
| Stage | UI | When |
|-------|-----|------|
| **Treatment** | Type dropdown + `TreatmentDetailAttachmentsStrip`, `FdiToothChart` (Cases scale), full-width Notes | Always |
| **Lab** | `LabCasesDispatchPanel` | Only when active detail type is lab-dependent. Entering Lab auto-ensures a shipment draft. **No default lab or prosthesis type** on a new detail (including siblings in the same plan). Last **3 sent** labs appear as chips under search — pick is explicit. Comments stay on the dispatch panel. |
| **Lab** | `LabCasesDispatchPanel` | Only when active detail type is lab-dependent. Entering Lab auto-ensures a shipment draft. Last-used lab and prosthesis type are remembered. Comments stay on the dispatch panel. |
- Prosthesis uses `WizardStepper` (Treatment → Lab) with Back/Next. Lab dispatch keeps comments.
- Detail chips show **type + teeth**, not “Detail N”. Lab-dependent chips use colored sent/unsent text (same size as the label); sent date stays on Lab dispatch.
- Detail type may differ from appointment purpose. Purpose seeds the first line of an empty **appointment** draft (first open, and **Add detail** when the plan is `[]`). Later **Add detail** starts with an empty type. Unscheduled / New treatment still seeds a blank first line.
- Detail type may differ from appointment purpose (purpose only defaults new details).
- Switching `activeDetailId` resets to the treatment form, unless `pendingEntryStepRef` is set to `lab` first (lab shipments rail / “Go to dispatch” / load-with-focus).
- Live draft is **not** duplicated in the left rail preview; preview is for history browse only.
@@ -120,7 +120,7 @@ Helpers: `frontend/src/components/treatment/treatmentHistoryFilters.ts`.
Saved lab-dependent detail with **no teeth** can autosave but **cannot** create a lab shipment.
- Inline amber banner in `TreatmentDetailsEditor` on **both** Treatment and Lab steps when the active detail qualifies (`isLabDependentDetailMissingTeeth`). Do not use a separate notice card.
- Inline banner in `TreatmentDetailsEditor` + `LabShipmentBlockedNotice` above dispatch when active detail qualifies (`isLabDependentDetailMissingTeeth`).
- `handleAddLabCase` shows toast with `labShipmentBlockedBody`.
- Dispatch panel only appears when a detail passes `isDetailReadyForLabDispatch` (persisted + lab-dependent + teeth).
@@ -155,7 +155,7 @@ On today: in-progress slot first, else nearest start time to `now`. Other days:
`LinkedOrganizationSearchCombobox` in `LabCasesDispatchPanel` — search-only results (no dropdown). Chips under the search are the last **3 labs this clinic sent a case to** (`rememberRecentLab` after successful send). They are shortcuts, not defaults: a new details lab and prosthesis type (apply-all and per-tooth) stay empty until the user chooses. Switching details clears the search box. No match + org tab access → **Invite a lab** navigates to `/organizations?action=invite-lab`. No org access → show permission message; dispatch stops.
`LinkedOrganizationSearchCombobox` in `LabCasesDispatchPanel` — search-only results (no dropdown). No match + org tab access → **Invite a lab** navigates to `/organizations?action=invite-lab`. No org access → show permission message; dispatch stops.
@@ -201,7 +201,7 @@ Use shared `Checkbox` (not native `<input type="checkbox">`) to avoid focus-driv
Walk-in uses one sentinel `Patient` per clinic (`isWalkIn`, hidden from Patients/search/booking). Display via i18n, never the stored name. Patient search: same workspace patient with a live visit → no-op; else open todays strip visit if any; else load latest history into the editor; **no history and no strip visit → do not auto-create**. Detach the previous visit, keep the searched patient, and show an inline editor empty state (`noTreatmentFoundTitle` / `noTreatmentFoundBody`) that points to **New treatment** in the rail (Walk-in, current named patient card, or search).
Walk-in uses one sentinel `Patient` per clinic (`isWalkIn`, hidden from Patients/search/booking). Display via i18n, never the stored name. Patient search: same workspace patient → no-op; else load latest history into the editor; **no history → do not auto-create** (history rail empties; dentist uses **New treatment** and picks a patient, including Walk-in).
Draft writes for appointments require provider match (`ensureAppointmentProvider`). Standalone requires `treatment.providerUserId === actor`.

2
.gitattributes vendored
View File

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

View File

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

View File

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

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 — staging `https://wixur.ir` (Windows, `STAGING-DEPLOY.md`); prod `https://nudentic.ir` (Linux, `DEPLOY.md`) |
| `infrastructure/` | Docker, nginx, deploy scripts |
**Organization types:** `CLINIC` (patients, appointments, treatment) and `LAB` (cases, tasks). Many features are org-type-specific. Permissions use `TAB_*_READ` / `TAB_*_EDIT` codes — see `backend/src/common/permissions.ts`.
@@ -44,8 +44,8 @@ frontend/src/
**Treatment tab:** Preview and editable form are **separate** until the user clicks **Load into workspace** on a history item. See `.cursor/skills/treatment-workspace/SKILL.md` before changing that flow.
**Treatment edit / details (quick ref):**
- Day/mode gate: editable only for live draft on today/future (`canEditTreatmentForDay`). Past day / historical load → read-only form. Patient search sits in the **page header** (workspace-wide). If the patient has a visit on the day strip, that visit opens; else the latest history plan loads. If neither exists, the editor shows an inline empty state (no dialog) pointing to **New treatment** in the rail — do not auto-create. **New treatment** is at the top of the left rail (selected-patient card below it) and opens a picker: Walk-in first, then a matching card for the current named patient (name + mobile/email), then search. It does not auto-copy the appointment 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`).
- 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`).
- 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.
@@ -53,9 +53,8 @@ frontend/src/
- **Schedule date:** Treatment strip and Appointments page headers use `ScheduleDayPicker` `compact` (date centered in a 3-col header; no “Schedule date” label; Today on the navigator).
**Treatment lab rules (quick ref):**
- Lab-dependent details (e.g. prosthesis) **without teeth** can save but **cannot ship** — same inline amber banner (`labShipmentBlockedBody`) on Treatment **and** Lab steps; toast on dispatch add.
- New prosthesis dispatch lines start **empty** (no default lab, no apply-all / per-tooth prosthesis type), even for siblings in the same plan. Recent-lab chips are the last **3 sent** destinations — pick is explicit, never auto-selected.
- Detail treatment type need **not** match appointment purpose — purpose only seeds the **first** line of an empty **appointment** draft (first open, and **Add detail** when the plan is `[]`). Further **Add detail** starts with an empty type. Unscheduled / New treatment still seeds a blank first line.
- Lab-dependent details (e.g. prosthesis) **without teeth** can save but **cannot ship** — show `LabShipmentBlockedNotice` + inline banner; toast on dispatch add.
- Detail treatment type need **not** match appointment purpose — purpose only pre-fills new details.
- **History filters** are client-side only (`treatmentHistoryFilters.ts`): “Not shipped to lab” + single date on already-fetched patient history; includes live current draft when filtering.
- **Lab shipments rail**: unified list with scope toggle **This patient** vs **All updates** (unread across org for **this clinician's cases only**, includes patient name). Opening a case from the rail jumps to the **Lab** send sheet.
- **Unread semantics**: Treatment tab badge = count of unread cases **for the user's own treatment plans** (per-case read cursor) and clears when a case is opened/marked read (not on tab visit).
@@ -85,7 +84,7 @@ backend/src/
prisma/ → schema, migrations, seed
```
Errors: `AppException` + `ErrorCode` → frontend `getUserFacingError()`. Unexpected 500s: GlitchTip (`SENTRY_DSN`). Never throw raw strings for user-facing failures.
Errors: `AppException` + `ErrorCode` → frontend `getUserFacingError()`. Never throw raw strings for user-facing failures.
## Git & commits

131
README.md
View File

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

View File

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

View File

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

View File

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

View File

@@ -46,7 +46,6 @@
"@nestjs/throttler": "^6.5.0",
"@nestjs/websockets": "^11.1.28",
"@prisma/client": "^6.19.2",
"@sentry/nestjs": "^10.72.0",
"adminjs": "^7.8.17",
"axios": "^1.13.5",
"bcrypt": "^6.0.0",

View File

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

View File

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

View File

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

View File

@@ -93,9 +93,6 @@ describe('createJsonBodyParser', () => {
'/api/voice/extract/extra',
'/api/voice',
'/voice/extract',
// Express ignores one trailing slash, not two — this one never routes, so it must
// not get the large parser either.
'/api/voice/extract//',
]) {
const res = await request(buildApp()).post(path).send(bodyOfKb(300));
expect(res.status).toBe(413);

View File

@@ -17,19 +17,21 @@ export const VOICE_BODY_LIMIT = '10mb';
* of audio, so that one route needs a larger limit while every other endpoint keeps the
* default — a large body should not become acceptable everywhere.
*
* Deliberately one middleware that *chooses* a parser, not a path-mounted parser stacked in
* front of a default one: that arrangement depended on Express's mount-path stripping and on
* body-parser skipping an already-parsed request, and silently stopped applying whenever the
* middleware order shifted. One explicit branch has no such coupling.
* Deliberately a single middleware that *chooses* a parser, rather than a path-mounted
* parser stacked in front of a default one. That arrangement relied on Express's
* mount-path stripping plus body-parser skipping an already-parsed request, and it
* silently stopped applying when the surrounding middleware order shifted — at which point
* the endpoint rejected every real recording with a 500. One explicit branch has no such
* coupling, and is covered by body-parsers.spec.ts.
*/
/**
* Express routes case-insensitively and ignores exactly one trailing slash, so
* `/API/Voice/Extract/` reaches the same controller and must get the same limit — otherwise
* it 413s every real recording, which reads as a broken microphone rather than a route.
* Two slashes never route, so they must not buy a 10 MB buffer either.
* Express routes case-insensitively and ignores a trailing slash unless configured
* otherwise, so `/API/Voice/Extract/` reaches the same controller. Matching only the
* canonical spelling would hand those requests the 100 kb parser and 413 every real
* recording — a failure that looks like a broken microphone, not a routing detail.
*/
function isVoiceExtractPath(path: string): boolean {
return path.toLowerCase().replace(/\/$/, '') === VOICE_EXTRACT_PATH;
return path.toLowerCase().replace(/\/+$/, '') === VOICE_EXTRACT_PATH;
}
export function createJsonBodyParser(): RequestHandler {

View File

@@ -1,11 +1,19 @@
/**
* Persian (Extended Arabic-Indic, U+06F0U+06F9) zero, and Arabic-Indic (U+0660U+0669)
* zero. ASR output can carry either block, sometimes mixed with ASCII in one transcript.
*/
const PERSIAN_ZERO = 0x06f0;
const ARABIC_INDIC_ZERO = 0x0660;
/**
* Normalise Persian and Arabic-Indic digits to ASCII. Non-digits pass through.
*
* Both blocks, not just the Persian one the frontend handles: ASR output can carry either,
* sometimes mixed with ASCII in a single transcript.
* Deliberately wider than the frontend original, which only handles the Persian block:
* this parses model/ASR output rather than keystrokes, so both blocks must be accepted
* or a spoken date or tooth number silently degrades to "unresolved".
*
* Lives on its own rather than inside jalali.ts because tooth codes need it too, and a
* tooth module reaching into the calendar module would read as an accident.
*/
export function toLatinDigits(value: string): string {
return value.replace(/[۰-۹٠-٩]/g, (ch) => {

View File

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

View File

@@ -14,9 +14,10 @@ export type Arch = 'upper' | 'lower';
export type PatientSide = 'patient_right' | 'patient_left';
/**
* Upper arch in chart order: patient's RIGHT (18) → midline → patient's LEFT (28) — the drawn
* layout, which mirrors the patient's own sides. Never read a position off this array by
* index; use `toFdi()`, which owns the side convention.
* Upper arch in chart order: patient's RIGHT (18) → midline → patient's LEFT (28).
* That is the drawn left-to-right layout, which is the mirror of the patient's own sides.
* Do not read a tooth position off this array by index use `toFdi()`, which owns the
* side convention.
*/
export const FDI_UPPER_ARCH_ORDER = [
'18',
@@ -67,9 +68,12 @@ export function isFdiTooth(value: unknown): value is string {
}
/**
* Clean up a tooth code the model echoed back. It is reading Persian speech, so it can hand
* back "۲۶" or "2 6" from digit-by-digit dictation; neither matches literally, and the
* near-miss does not fail loudly — the tooth just turns into "not understood".
* Clean up a tooth code the extraction model echoed back, before it is matched.
*
* The model is transcribing Persian speech, so it can hand back "۲۶" in Persian digits or
* "2 6" from a digit-by-digit dictation. Neither matches an FDI code literally, and a
* near-miss here does not fail loudly — the tooth quietly turns into "not understood".
* Returns '' for anything that is not a string.
*/
export function normalizeFdiCode(value: unknown): string {
if (typeof value !== 'string') return '';
@@ -110,8 +114,9 @@ export function teethBetweenInclusive(a: string, b: string): string[] | null {
/**
* Arch + patient side + position (1 = central incisor … 8 = third molar) → FDI code.
*
* The single place the patient-right convention lives. Getting it backwards mirrors every
* quadrant into a valid-looking code for the wrong tooth, which no schema check can catch.
* This function is the single place the patient-right convention lives. Getting it
* backwards mirrors every quadrant and produces a valid-looking code for the wrong tooth,
* which no schema check can catch — hence the exhaustive test coverage.
*/
export function toFdi(
arch: Arch,
@@ -130,8 +135,8 @@ export function toFdi(
}
/**
* Along the arch, not lexically — a bridge reads 16-15-14, and 11 sits beside 21 across the
* midline. Teeth from another arch sort to the end, stably.
* Sort teeth along the arch, not lexically — a bridge reads 16-15-14, and 11 sits beside
* 21 across the midline. Teeth from another arch (or unknown) sort to the end, stably.
*/
export function sortInArchOrder(teeth: readonly string[]): string[] {
if (teeth.length === 0) return [];

View File

@@ -1,8 +1,10 @@
/**
* Jalali (Persian) calendar arithmetic, ported from
* `frontend/src/lib/i18n/persianCalendar.ts` (itself jalaali-js, MIT). The backend needs it
* because voice resolves spoken Jalali dates server-side, where the resolvers are tested.
* Keep the two copies in step; the underlying calendar does not change.
* Jalali (Persian) calendar arithmetic.
*
* Ported from `frontend/src/lib/i18n/persianCalendar.ts` (itself from jalaali-js, MIT).
* The backend needs this because voice extraction resolves spoken Jalali dates into ISO
* dates server-side, where the resolvers are unit-tested — the frontend has no test
* runner. Keep the two copies in step; the underlying calendar does not change.
*/
const BREAKS = [
@@ -142,8 +144,11 @@ export function isJalaliLeapYear(jy: number): boolean {
}
/**
* Days in a Jalali month, or 0 when the year or month is not real. Zero rather than a throw:
* every export here is reachable from model-supplied values, so the module degrades.
* Days in a Jalali month, or 0 when the year or month is not real.
*
* Zero rather than a throw: every export here is reachable from model-supplied values, so
* the whole module degrades instead of raising. Zero also makes `isValidJalaliDate`'s
* `jd <= jalaliDaysInMonth(...)` naturally false.
*/
export function jalaliDaysInMonth(jy: number, jm: number): number {
if (!isSupportedJalaliYear(jy)) return 0;
@@ -165,8 +170,10 @@ export function isValidJalaliDate(jy: number, jm: number, jd: number): boolean {
}
/**
* Jalali triple → `YYYY-MM-DD`, or null when the date is not real. Null rather than a throw,
* for the same reason: callers resolve model-supplied values, which may be nonsense.
* Jalali triple → `YYYY-MM-DD`, or null when the date is not real.
*
* Returns null rather than throwing: callers resolve model-supplied values, which may be
* nonsense, and an invalid date must degrade to "unresolved" rather than a 500.
*/
export function jalaliToIsoDate(
jy: number,

View File

@@ -55,12 +55,15 @@ export function civilDateJsWeekday(isoDate: string): number {
}
/**
* Today's civil date (`YYYY-MM-DD`) in an IANA zone, so the server derives "today" from a
* client-supplied *zone* rather than trusting a client-supplied date.
* Today's civil date (`YYYY-MM-DD`) in an IANA zone.
*
* Lets the server derive "today" from a client-supplied time zone instead of trusting a
* client-supplied date, which matters for relative deadlines like "by Thursday".
*/
export function civilDateInZone(date: Date, timeZone: string): string {
// Intl throws RangeError on an unknown zone and this takes a client-supplied string;
// callers validate first, this is the backstop.
// Intl throws RangeError on an unknown zone, before any fallback below could help, and
// this receives a client-supplied string. Callers validate first; this is the backstop
// so a bad zone degrades to a date that is at most a day out rather than a 500.
const zone = isValidIanaTimeZone(timeZone) ? timeZone : 'UTC';
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: zone,

View File

@@ -186,9 +186,10 @@ function parseProviderId(
}
/**
* Every enabled locale gets its own ASR and LLM provider+model, each independently
* overridable. They all point at the same OpenRouter models today; the per-locale
* indirection stays so a locale can diverge by configuration rather than by code.
* Every enabled locale gets its own ASR and LLM provider+model, each overridable
* independently. They all point at the same OpenRouter models today; the per-locale
* indirection is kept because Persian ASR is the weakest link and swapping only `fa` must
* not be a code change.
*/
function buildVoiceConfig(
getEnvVarWithDefault: (key: string, defaultValue: string) => string,

View File

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

View File

@@ -1,5 +1,4 @@
// backend/src/main.ts
import './instrument';
import { NestFactory } from '@nestjs/core';
import { urlencoded } from 'express';
import { AppModule } from './app.module';
@@ -26,13 +25,11 @@ console.log = (...args) => {
};
async function bootstrap() {
// bodyParser is disabled so the JSON parsers can be registered in an explicit order below;
// Nest's built-in one would otherwise reject a voice recording at 100 kb.
// bodyParser is disabled here so the JSON parsers can be registered in an explicit
// order below; Nest's built-in one is installed during create() and would otherwise
// reject a voice recording at its 100 kb default before any later middleware ran.
const app = await NestFactory.create(AppModule, { bodyParser: false });
// Nginx terminates TLS; AdminJS sessions and Secure cookies need the real proto/host.
app.getHttpAdapter().getInstance().set('trust proxy', 1);
// Voice needs a larger JSON limit than everything else; see body-parsers.ts.
app.use(createJsonBodyParser());
app.use(urlencoded({ extended: true }));

View File

@@ -70,7 +70,7 @@ type SentLabCaseRow = Prisma.LabCaseGetPayload<{ include: typeof sentLabCaseIncl
const treatmentInclude = {
patient: {
select: { id: true, firstName: true, lastName: true, isWalkIn: true, mobile: true, email: true },
select: { id: true, firstName: true, lastName: true, isWalkIn: true },
},
details: {
orderBy: [{ sortOrder: 'asc' as const }],
@@ -1366,8 +1366,6 @@ export class TreatmentsService {
firstName: string;
lastName: string;
isWalkIn: boolean;
mobile?: string | null;
email?: string | null;
};
details: Array<{
id: string;
@@ -1428,8 +1426,6 @@ export class TreatmentsService {
firstName: treatment.patient.firstName,
lastName: treatment.patient.lastName,
isWalkIn: treatment.patient.isWalkIn,
mobile: treatment.patient.mobile ?? null,
email: treatment.patient.email ?? null,
}
: null,
details: treatment.details.map((d) => this.mapDetail(d)),

View File

@@ -26,32 +26,48 @@ export type VoiceAudioFormat = (typeof VOICE_AUDIO_FORMATS)[number];
export const VOICE_LOCALES = ['en', 'fa', 'nl'] as const;
export class ExtractVoiceDto {
/** Base64 audio, no data: prefix. Well above a 2-minute opus clip (~400 KB). */
/**
* Base64 audio, no data: prefix. Capped well above a 2-minute opus clip (~400 KB) but
* far below OpenRouter's 25 MB ceiling, so an oversized upload is rejected before it
* costs a vendor call.
*/
@IsString()
@IsBase64()
// Both name their own code: the shared map sends `maxLength` to
// VALIDATION_FIELD_REQUIRED and `isIn` to VALIDATION_LANGUAGE_INVALID, neither of which
// is true here.
// Both constraints name their own code. Left to the default mapping, `maxLength` falls
// through to VALIDATION_FIELD_REQUIRED and `isIn` resolves to
// VALIDATION_LANGUAGE_INVALID — so an oversized recording told the clinician a field
// was missing, and an unsupported container told them their language was invalid.
@MaxLength(8_000_000, { message: ErrorCode.VOICE_CLIP_TOO_LONG })
audio: string;
@IsIn(VOICE_AUDIO_FORMATS, { message: ErrorCode.VOICE_UNSUPPORTED_FORMAT })
format: VoiceAudioFormat;
/** The clinician's IANA zone; "today" is derived from it, never sent by the client. */
/**
* The clinician's IANA zone. The server derives "today" from it rather than trusting a
* client-supplied date, which is what relative deadlines resolve against.
*/
@IsString()
@MaxLength(64)
timeZone: string;
/** Required, not optional — omitting it would bypass VOICE_MAX_RECORDING_MS entirely. */
/**
* Recording length as measured by the client.
*
* Required, not optional: an optional value means omitting it bypasses
* VOICE_MAX_RECORDING_MS entirely, which would make the cap advisory.
*/
@IsInt()
@Min(0)
durationMs: number;
/**
* The locale the UI offered the microphone in. Sent explicitly because `user.language` can
* diverge from the URL locale, and a mismatch transcribes Persian with an English hint and
* anchors "next Thursday" to the wrong week start.
* The locale the clinician is actually speaking, as the UI offered the microphone.
*
* Sent explicitly rather than read from `user.language`: the two can diverge (a
* bookmarked /fa/ URL, a language toggle whose save failed), and a mismatch would
* transcribe Persian with an English hint and anchor "next Thursday" to the wrong
* week start. Gating the button and resolving the request must agree by construction.
*/
@IsIn(VOICE_LOCALES)
locale: string;

View File

@@ -15,6 +15,7 @@ import type { DueIntent, UnresolvedItem, Weekday } from './voice.types';
* to reason about instants.
*/
/** JS `getUTCDay()` numbering: Sunday = 0. */
const WEEKDAY_TO_JS: Record<Weekday, number> = {
saturday: 6,
sunday: 0,
@@ -25,6 +26,7 @@ const WEEKDAY_TO_JS: Record<Weekday, number> = {
friday: 5,
};
/** Refuse absurd deadlines however they were arrived at. */
const MAX_DAYS_AHEAD = 365 * 5;
export type DueResolution = {
@@ -76,9 +78,12 @@ function unresolved(spoken: string): DueResolution {
}
/**
* What to quote back when a deadline could not be resolved. Every field is nullable on the
* wire and `toVoiceIntent` casts rather than checks, and the sheet renders this verbatim —
* so a half-classified deadline must fall back to '', not to `"null null"`.
* What to quote back when a deadline could not be resolved.
*
* Every field here is nullable on the wire and `toVoiceIntent` casts rather than checks,
* so a half-classified deadline arrives with nulls in it. The review sheet renders this
* verbatim — `"null null" — not a usable date` in front of a clinician is worse than the
* reason on its own, which the sheet already handles for a blank string.
*/
function describe(intent: DueIntent): string {
const usable = (value: unknown): value is number =>
@@ -103,7 +108,8 @@ function describe(intent: DueIntent): string {
? `${intent.y}-${intent.m}-${intent.d}`
: '';
default: {
// An unrecognised `kind`, already established as a string — echo what was heard.
// Reaching here means an unrecognised `kind`, which resolveDueDate has already
// established is a string — echo it so the review row names what was heard.
const kind = (intent as { kind?: unknown })?.kind;
return typeof kind === 'string' ? kind : '';
}
@@ -111,9 +117,11 @@ function describe(intent: DueIntent): string {
}
/**
* Which weekday starts the week, per locale.
*
* "Next Thursday" is week-relative, so this changes the answer: the Iranian week starts
* Saturday, the Dutch and English week Monday. Hardcoding either puts the other locale's
* deadline a week out.
* Saturday, the Dutch and (European) English week starts Monday. Hardcoding Saturday
* would put an en/nl clinician's deadline a week out.
*/
const WEEK_START_BY_LOCALE: Record<string, number> = {
fa: WEEKDAY_TO_JS.saturday,
@@ -127,18 +135,22 @@ export function weekStartForLocale(locale: string): number {
return WEEK_START_BY_LOCALE[locale] ?? DEFAULT_WEEK_START;
}
/** Most recent week-start day, counting today if today is that day. */
function startOfWeek(iso: string, weekStartJs: number): string {
const back = (civilDateJsWeekday(iso) - weekStartJs + 7) % 7;
return addDays(iso, -back);
}
/**
* `'this'` is occurrence-anchored: the soonest occurrence strictly after today, so it can
* never resolve into the past.
* `'this'` is occurrence-anchored: the soonest occurrence strictly after today, so "by
* Thursday" said on a Thursday means the next one — a deadline of today is almost never
* what was meant, and this can never resolve into the past.
*
* `'next'` is *week*-anchored, not "this plus seven" — adding a week to `'this'` overshoots
* by seven days whenever `'this'` has already rolled into next week. The two legitimately
* coincide: said on a Thursday, "the coming Saturday" and "Saturday next week" are one day.
* `'next'` is *week*-anchored, not "this plus seven". "Thursday next week" means the
* Thursday of the Saturday-start week after this one; adding a week to `'this'` would
* overshoot by seven days whenever `'this'` had already rolled into next week. The two
* can legitimately coincide — said on a Thursday, "the coming Saturday" and "Saturday
* next week" are the same day.
*/
function resolveWeekday(
intent: Extract<DueIntent, { kind: 'weekday' }>,
@@ -187,15 +199,18 @@ export function resolveDueDate(
todayIso: string,
weekStartJs: number = DEFAULT_WEEK_START,
): DueResolution {
// Absent is not an error — most utterances carry no deadline.
// Absent is not an error — most utterances carry no deadline. Anything else that is not
// an intent object is a deadline we failed to understand, and must be flagged rather
// than silently dropped.
if (intent === null || intent === undefined) {
return { dueDate: null, unresolved: null };
}
if (typeof intent !== 'object') {
return unresolved(String(intent).slice(0, 120));
}
// No `kind` at all says nothing about a deadline, so it is not "heard but lost". An
// *unrecognised* kind did try to say something, and is flagged below.
// An object carrying no `kind` at all says nothing about a deadline; flagging it would
// put a blank "heard but lost" row in front of a clinician who never mentioned one. An
// object with an *unrecognised* kind did try to say something, and is flagged below.
if (typeof (intent as { kind?: unknown }).kind !== 'string') {
return { dueDate: null, unresolved: null };
}
@@ -225,7 +240,8 @@ export function resolveDueDate(
if (!resolved) return unresolved(describe(intent));
// A date the model invented can land anywhere; past or decades away is not a deadline.
// An absolute date the model invented can land anywhere; a deadline in the past or
// decades away is not a deadline.
const daysAhead = (utcMsOf(resolved) - utcMsOf(todayIso)) / 86_400_000;
if (daysAhead < 0 || daysAhead > MAX_DAYS_AHEAD)
return unresolved(describe(intent));

View File

@@ -91,9 +91,12 @@ function mergeOverlapping(sets: string[][]): string[][] {
}
/**
* Span teeth join the selection: "a bridge from 14 to 16" selects 15 though it was never
* named. A cross-arch span is reported rather than guessed at, and a span collapsing to one
* tooth degrades to a single — there is no one-tooth bridge.
* Turn spoken bridge spans plus loose teeth into selection groups.
*
* Span teeth are added to the selection: saying "a bridge from 14 to 16" selects 15 even
* though it was never named. A span whose endpoints are in different arches is impossible
* and is reported rather than guessed at. A span that collapses to one tooth degrades to a
* single — there is no such thing as a one-tooth bridge.
*/
export function resolveConnectedSpans(
spans: readonly ConnectedSpanIntent[],
@@ -166,8 +169,10 @@ export function resolveConnectedSpans(
}
/**
* A default across the selection, then per-tooth overrides — "همه زیرکونیا، ۲۶ پی‌اف‌ام" is
* how clinicians actually speak.
* Expand a default prosthesis type across the selection, then apply per-tooth overrides.
*
* "همه زیرکونیا، ۲۶ پی‌اف‌ام" is how clinicians actually speak, so the model names the type
* once and overrides the exceptions.
*/
export function resolveProsthesis(
intent: ProsthesisIntent | null | undefined,
@@ -277,12 +282,14 @@ export function resolveVoiceIntent(
? intent.comment.trim()
: null;
// An invented lab id would ship a case to a lab the clinic never named. A rejected one is
// reported, so it cannot look identical to "no lab was spoken".
// A lab id the model invented is worse than none — it would ship a case to a lab the
// clinic never named. Only ids from the list we supplied survive, and a rejected one is
// reported: a hallucinated lab must not look identical to "no lab was spoken".
const labId = resolveCatalogCode(intent?.labId, ctx.linkedLabIds);
if (intent?.labId != null && !labId) {
// `spoken` is what the clinician said — quoting an invented id back would put a raw
// UUID in front of the user.
// `spoken` means "what the clinician said". A rejected lab id is an opaque
// identifier the model invented, so quoting it back would put a raw UUID in front
// of the user; the reason alone carries the meaning.
unresolved.push({ spoken: '', reason: 'unknown_catalog_code' });
}

View File

@@ -10,10 +10,13 @@ import type {
import { WEEKDAYS } from './voice.types';
/**
* The shape the model actually emits, and its JSON schema.
*
* Deliberately flat: strict `json_schema` mode has poor support for discriminated unions,
* so every variant field is present and nullable. `toVoiceIntent` narrows it into the
* internal union and is total — anything it cannot classify becomes a shape the resolvers
* report as unresolved rather than something that throws here.
* so every variant field is present and nullable on the wire. `toVoiceIntent` narrows the
* flat shape into the internal union the resolvers consume, and is total — anything it
* cannot classify becomes a shape the resolvers will report as unresolved rather than
* something that throws here.
*/
export type WireToothIntent = {
@@ -180,8 +183,9 @@ const FDI_SHAPE = /^[1-8][1-8]$/;
function toToothIntent(wire: WireToothIntent | undefined | null): ToothIntent {
const spoken = typeof wire?.spoken === 'string' ? wire.spoken : '';
// "۲۶" and "2 6" are FDI codes that do not match literally; unnormalised they fall
// through to the positional branch with no quadrant and read as unresolved.
// Persian digits and digit-by-digit dictation ("۲۶", "2 6") are FDI codes that do not
// match literally; without normalising first they fall through to the positional branch
// with no quadrant and are reported as unresolved.
const fdi = normalizeFdiCode(wire?.fdi);
// Only take the explicit branch for something actually FDI-shaped. A model that emits
// fdi:"6" alongside correct arch/side/position would otherwise lose the tooth entirely.

View File

@@ -124,8 +124,9 @@ export class OpenRouterExtractionProvider implements ExtractionProvider {
body: JSON.stringify({
model: this.config.model,
temperature: 0,
// Only route to endpoints that actually honour the JSON schema — otherwise OpenRouter may
// pick a provider that treats it as a hint and returns prose, failing intermittently.
// Only route to endpoints that actually honour the JSON schema. Without this,
// OpenRouter may pick a provider that treats it as a hint and returns prose,
// which fails parsing intermittently and unreproducibly.
provider: { require_parameters: true },
messages: buildExtractionPrompt(transcript, catalog, localeHint),
response_format: {

View File

@@ -18,14 +18,18 @@ export type ToothResolution = {
/** Everything here parses untrusted model output, so nothing may throw. */
function normalizedFdi(intent: ToothIntent): string {
// The same normalisation the wire layer used to pick this branch, so the two agree.
// Same normalisation the wire layer used to pick this branch, so the two cannot
// disagree: '14 ' is tooth 14 through the treatment API and '۲۶' is tooth 26, and
// neither may be reported as malformed here.
return normalizeFdiCode((intent as { fdi?: unknown }).fdi);
}
/**
* Never guesses and never clamps: position 9, a deciduous tooth or a malformed intent all
* resolve to null, so the caller surfaces "not understood" rather than silently selecting a
* neighbouring tooth.
* Resolve one spoken tooth reference to an FDI code, or null.
*
* Never guesses and never clamps: a position of 9, a deciduous tooth, or a malformed
* intent resolves to null so the caller can surface it as "not understood" rather than
* silently selecting a neighbouring tooth.
*/
export function resolveToothIntent(intent: ToothIntent): string | null {
if (!intent || typeof intent !== 'object') return null;
@@ -61,8 +65,9 @@ function unresolvedReason(intent: ToothIntent): UnresolvedItem['reason'] {
intent.position < 1 ||
intent.position > 8;
if (positionBad) return 'position_out_of_range';
// The position was understood; the quadrant was never said. "دندون دو" names four
// teeth, so "could not be read" would send the clinician after the wrong fault.
// The position was understood, so the words were not the problem: the speaker never
// said which quadrant. "دندون دو" names four teeth at once, and telling the
// clinician it "could not be read" would send them looking for the wrong fault.
const archMissing = intent.arch !== 'upper' && intent.arch !== 'lower';
const sideMissing =
intent.side !== 'patient_right' && intent.side !== 'patient_left';
@@ -73,8 +78,11 @@ function unresolvedReason(intent: ToothIntent): UnresolvedItem['reason'] {
}
/**
* The teeth still consistent with what *was* heard — "دو" leaves four, "دو بالا" two. Not
* a guess: the full set of readings, for the clinician to choose from.
* The teeth still consistent with what *was* heard.
*
* Narrowed by whatever the clinician did say, so "دو" offers four and "دو بالا" offers
* two. This is not a guess — it is the full set of readings, handed to the clinician to
* choose from rather than picked on their behalf.
*/
function quadrantCandidates(intent: ToothIntent): string[] {
if (intent.kind !== 'positional') return [];
@@ -101,8 +109,11 @@ function spokenOf(intent: ToothIntent): string {
}
/**
* Duplicates collapse; anything unresolvable is reported rather than dropped, so the sheet
* can show which words were not understood.
* Resolve a list of spoken tooth references.
*
* Duplicates collapse — a clinician may name the same tooth twice in one sentence — and
* anything unresolvable is reported rather than dropped, so the review sheet can show the
* user exactly which words were not understood.
*/
export function resolveToothIntents(
intents: readonly ToothIntent[],
@@ -127,8 +138,10 @@ export function resolveToothIntents(
const spoken = spokenOf(intent);
const candidates =
reason === 'tooth_missing_quadrant' ? quadrantCandidates(intent) : [];
// Only dedupe what we can tell apart: without `spoken`, two lost references collapse
// into one blank row and a tooth vanishes. Candidates are part of the identity.
// Only dedupe items we can actually tell apart. Without `spoken`, two distinct lost
// references would collapse into one blank review row and a tooth would vanish. The
// candidates are part of the identity: the same word with a different arch heard
// offers a different choice.
if (spoken) {
const key = `${spoken}::${reason}::${candidates.join(',')}`;
if (seenUnresolved.has(key)) continue;

View File

@@ -3,9 +3,15 @@ import { ThrottlerGuard } from '@nestjs/throttler';
import { AppException, ErrorCode } from '../../common/errors';
/**
* Rate limits voice extraction per user, not per IP: the default tracker keys on `req.ip`,
* which behind nginx means the whole deployment shares one bucket unless `trust proxy` is
* set, and one clinic could then lock out every other.
* Rate limits voice extraction per user rather than per IP.
*
* The default tracker keys on `req.ip`, which behind nginx means the whole deployment
* shares one bucket unless `trust proxy` is set — and an abuser rotating IPs would bypass
* it entirely. Since v1 ships with no plan gate, this is the only control on metered
* vendor spend, so it has to key on something the client cannot change.
*
* Guard order matters: the controller's JwtAuthGuard runs before this method-level guard,
* so `req.user` is populated by the time `getTracker` is called.
*/
@Injectable()
export class VoiceThrottlerGuard extends ThrottlerGuard {

View File

@@ -48,8 +48,9 @@ export class VoiceController {
@Res({ passthrough: true }) res: Response,
@Body() dto: ExtractVoiceDto,
) {
// Cancelling in the browser closes the connection; propagate it as an abort so the vendor
// call stops rather than settling unseen. It is metered per minute.
// Cancelling in the browser closes the connection; propagate that as an abort so the
// in-flight vendor call stops rather than settling and being discarded. It is metered
// per minute, so letting it run costs real money for a result nobody will see.
const aborter = new AbortController();
res.on('close', () => {
if (!res.writableFinished) aborter.abort();

View File

@@ -1,8 +1,10 @@
import type { VoiceIntent } from './voice.types';
/**
* ASR and extraction are separate, independently swappable roles — they will not come from
* the same vendor for every locale. Both resolve per locale from `config.voice.profiles`.
* ASR and extraction are separate, independently swappable roles — they will not come
* from the same vendor for every locale. Both are resolved per locale from
* `config.voice.profiles`, so pointing `fa` at a Persian-specialist vendor while `en`
* and `nl` keep OpenRouter is configuration, not code.
*/
export type AudioInput = {

View File

@@ -55,8 +55,10 @@ export class VoiceService {
}
/**
* What the frontend needs to decide whether to render the microphone. v1 is ungated beyond
* a configured locale profile; the Plan.features design is deferred, not dropped.
* What the frontend needs to decide whether to render the microphone at all.
*
* v1 ships ungated beyond a configured locale profile — no plan check. The
* Plan.features design is deferred, not dropped.
*/
getAvailability(): VoiceAvailability {
const voice = this.voiceConfig;
@@ -105,9 +107,10 @@ export class VoiceService {
throw this.toAppException(error, 'asr');
}
// durationMs is client-reported, so not enforcement. usage.seconds is the vendor's own
// measurement — a client under-reporting to slip past the cap is caught here, after the
// ASR spend but before the more expensive extraction call.
// durationMs is client-reported and therefore not enforcement. usage.seconds is the
// vendor's own measurement of the audio it decoded, so a client under-reporting length
// to slip past the cap is caught here — after the ASR spend, but before the extraction
// call, and visibly in telemetry.
if (asrSeconds != null) {
this.assertWithinCap(asrSeconds * 1000);
}
@@ -208,9 +211,13 @@ export class VoiceService {
}
/**
* The client auto-stops at maxMs and only then measures, so a capped recording always
* reports slightly over. Without this tolerance every auto-stopped recording — the exact
* case the cap exists for — would be rejected as too long.
* Grace above the configured cap.
*
* The client auto-stops when elapsed >= maxMs, then measures the final length after the
* recorder has actually stopped — so a recording that runs to the cap always reports
* slightly over it. Without this tolerance the auto-stop would guarantee a rejection,
* discarding exactly the recording it was meant to save. The client still reports the
* true length, so telemetry stays honest.
*/
private static readonly CAP_TOLERANCE_MS = 2_000;
@@ -312,7 +319,10 @@ export class VoiceService {
);
}
/** Structured and patient-free: never the transcript, never audio, never a patient id. */
/**
* Structured, patient-free. Never the transcript, never audio, never a patient id.
* Log lines are the interim sink until this repo has metrics infrastructure.
*/
private logTelemetry(input: {
locale: string;
durationMs: number;

View File

@@ -78,8 +78,10 @@ export type UnresolvedItem = {
spoken: string;
reason: UnresolvedReason;
/**
* FDI codes still consistent with what was heard — "دو" leaves four, "دو بالا" two. Only
* `tooth_missing_quadrant` carries them; the sheet offers them as chips.
* FDI codes still consistent with what was heard, when a choice would settle it.
* Only `tooth_missing_quadrant` carries these: "دو" leaves four teeth on the table,
* "دو بالا" leaves two. The review sheet offers them so an under-specified tooth is one
* tap from resolved rather than a dead end.
*/
candidates?: string[];
};

View File

@@ -145,25 +145,9 @@ path**. `onAddDetail` is not called and not changed.
`setEntryStep('treatment')` matters: `showChrome` is always on, so the control is visible
during the **Lab** wizard step too. Confirming there returns to the treatment step.
**Confirm also saves.** The new detail is persisted immediately (`persistDraft({force:true})`),
and when the result carries a lab, a due date or a prosthesis map the lab case is saved with it
(`persistLabCases`). Not politeness — the autosave effect watches `details` only, so a lab draft
left in component state alone loses the destination lab, the due date and the whole prosthesis
map on the next reload. The detail survives, which is what makes that loss look like a
successful save.
One guard on it: `persistDraft` returns a **preview** treatment instead of saving when any
detail still lacks a treatment type — the blank chip the workspace opens with is enough — and a
preview's detail id falls back to the client id. Confirm therefore checks *what came back*, not
the precondition, and skips the lab-case save when it did not get a real id; posting a lab case
against an id the server has never seen fails the whole save. Checking the result rather than
the condition keeps this true for every early return `persistDraft` has.
> Accepted consequences:
> - Tapping Add and then 🎤 leaves behind the blank chip that Add created. It carries the
> usual trash affordance.
> - That same blank chip blocks confirm's immediate lab-case save until it is given a type
> or removed; the lab rows stay in local state until the ordinary Lab-step save.
> - Dictating into an existing detail is not supported in v1 — voice always makes a new
> one.
@@ -498,17 +482,6 @@ that justified this whole design.
- any row carrying an unresolved item or an incomplete prosthesis map.
- Unresolved items are shown with what was heard ("دندان شیری — بازشناسی نشد"), so the
clinician can see what the system did not understand.
- **The sheet is a contract: confirm fills exactly what it previewed — no more.** Any
per-detail convenience that would top the case up afterwards has to be suppressed for a
voice-created case, because a default that quietly adds a prosthesis type to a tooth the
sheet never mentioned turns the confirmation step into a lie about what it was going to
do — which is the whole reason the step exists.
> This branch carried an exemption for one such default, the dispatch panel's
> remembered-prosthesis auto-fill. `origin/master` deleted that feature outright
> (`f52ad6b`), so the exemption went with it in the rebase and nothing enforces this rule
> in code today. It is a constraint on whatever gets added next, not a description of
> something that exists.
- An item that carries `candidates` renders them as **tappable chips** — the one place the
sheet is interactive. Picking one folds the tooth into the result (`withChosenTeeth`) and
ticks the teeth row, so an under-specified tooth is one tap from resolved instead of a
@@ -543,8 +516,8 @@ who can edit treatments, in every configured locale. `Plan.features.voiceTreatme
and the availability API stay documented here as the intended gate, deferred rather than
dropped, so turning them on later is additive.
Consequence to accept deliberately: with no plan gate, the per-user throttle and the 2-minute
recording cap are the **only** controls on metered vendor spend. See open item 14.
Consequence to accept deliberately: with no plan gate and no duration cap (§2), the
per-user throttle is the **only** control on metered vendor spend. See open item 14.
---
@@ -556,31 +529,15 @@ English Nest exception for a user-facing failure.
| Code | When |
|---|---|
| `VOICE_MIC_DENIED` | microphone permission actually refused, or no input device**client-side only**: needs the `errors.X` key in all three message files, but no `ErrorCode` entry and no throw site. Reserved for a real permission failure: see the note below |
| `VOICE_CLIP_TOO_LONG` | over `maxMs` (server-side re-check), over vendor limits, or a request body past the DTO's size cap |
| `VOICE_UNSUPPORTED_FORMAT` | **the browser cannot record at all** — no `MediaRecorder`, no container both it and the API accept, or a recorder that throws after permission was granted; and server-side, a `format` outside `VOICE_AUDIO_FORMATS` |
| `VOICE_MIC_DENIED` | browser permission refused**client-side only**: needs the `errors.X` key in all three message files, but no `ErrorCode` entry and no throw site |
| `VOICE_CLIP_TOO_LONG` | over `maxMs` (server-side re-check), or over vendor limits |
| `VOICE_UNSUPPORTED_FORMAT` | recorder produced a container the profile rejects |
| `VOICE_ASR_FAILED` | transcription stage failed |
| `VOICE_EXTRACT_FAILED` | transcript obtained, structuring failed |
| `VOICE_NOTHING_RECOGNIZED` | empty or unusable transcript |
| `VOICE_NOT_AVAILABLE` | no profile for locale (v1); plan flag off, once enforced |
| `VOICE_RATE_LIMITED` | throttle |
**Two of these are raised by DTO validation, not by a throw site.**
`validationExceptionFactory` returns a constraint's `message` verbatim when the message is
itself a known `ErrorCode`, so the voice DTO names its own failures:
`@MaxLength(…, { message: ErrorCode.VOICE_CLIP_TOO_LONG })` and
`@IsIn(…, { message: ErrorCode.VOICE_UNSUPPORTED_FORMAT })`. Left to the shared constraint map
they fall through to `VALIDATION_FIELD_REQUIRED` and `VALIDATION_LANGUAGE_INVALID` — an
oversized recording telling the clinician a field is missing, and an unsupported container
telling them their language is invalid. Any new voice constraint should name its code the same
way.
**`VOICE_MIC_DENIED` is only for a real permission failure.** Three client paths used to report
it for something else entirely — no `MediaRecorder`, no acceptable container, and a recorder
that throws after permission was already granted. All three are "this browser cannot record"
and now report `VOICE_UNSUPPORTED_FORMAT`; blaming the microphone sends the clinician hunting
in site settings for a permission nothing ever asked for.
**Transcript salvage — specified, NOT built.** The backend half exists: `VOICE_EXTRACT_FAILED`
carries `details.transcript` and `HttpExceptionFilter` forwards it. The client half was
never written — `onError` only resolves a message through `getUserFacingError`, which never
@@ -738,16 +695,7 @@ enabling this for real clinics.
- no layout shift in the header row on record start, stop, or the 2:00 auto-stop;
- **hold past 2:00** → auto-stops and proceeds to processing, not an error;
- **cancel during processing** → the vendor request is actually aborted;
- **review sheet on mobile** → full-screen overlay; closing it leaves the draft intact;
- **confirm with a lab, a due date or a prosthesis map, then reload** → all three are still
there. They live on the lab case, which the autosave effect does not watch, so this is
the check that catches a lab draft left unsaved in component state;
- **record straight after opening a visit**, while the blank chip is still untyped, and
confirm with a lab ticked → no error toast: confirm detects the preview treatment and
skips the lab-case save rather than posting an id the server has never seen;
- **dictate two different prosthesis types** ("۱۲ روکش PFM، ۱۳ روکش PFZ") → the form shows
both, and the bulk «اعمال برای همه دندان‌ها» select stays on its placeholder. Nothing may
rewrite a per-tooth type the sheet already showed.
- **review sheet on mobile** → full-screen overlay; closing it leaves the draft intact.
---
@@ -779,14 +727,6 @@ Settled in a grilling session on 2026-08-20.
| 28 | Review sheet | Modal on desktop, full-screen overlay (not a route) on mobile; candidate chips are its only interactive part |
| 29 | Cancel | Aborts the in-flight vendor call |
| 30 | v1 gating | Open to everyone; `Plan.features` gate deferred, not dropped |
Added while getting the first live recordings working (2026-08-21):
| # | Question | Decision |
|---|---|---|
| 31 | Tooth numbering | A spoken number **is** its FDI code, in all three locales. A lone digit stays unresolved and offers its candidate teeth as chips (§6, §7) |
| 32 | What confirm writes | Confirm persists the detail *and* its lab case, because autosave watches `details` only — but skips the lab-case save when it got a preview treatment back (§2) |
| 33 | Preview as contract | Applying a voice result fills exactly what the sheet showed. Per-detail conveniences that would add more are suppressed for that case (§7) |
| 15 | Gating | `Plan.features` flag — its first consumer |
UI placement settled in a second grilling session on 2026-08-20.

View File

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

View File

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

View File

@@ -18,9 +18,6 @@
"redirecting": "Redirecting…",
"readOnlyAccess": "Read-only access for this organization.",
"errorGeneric": "Something went wrong",
"pageErrorTitle": "Something went wrong",
"pageErrorBody": "This page failed to load. You can try again.",
"tryAgain": "Try again",
"loadingEllipsis": "Loading...",
"search": "Search",
"action": "Action",
@@ -686,9 +683,7 @@
"selectedPatient": "Selected patient",
"purposeLabel": "Purpose:",
"loadingAppointments": "Loading appointments…",
"selectDayWithAppointment": "Search for a patient or select a visit from the day strip.",
"noTreatmentFoundTitle": "No treatment found",
"noTreatmentFoundBody": "There is no treatment or visit for {name} yet. You can add one with the {action} button in the rail.",
"selectDayWithAppointment": "Select a day with at least one appointment.",
"confirmDiscard": "You have unsaved changes. Discard them and continue?",
"errorChooseOrg": "Choose at least one active organization to send this case.",
"successCaseSent": "Case sent to selected organizations.",
@@ -712,8 +707,6 @@
"walkIn": "Walk-in",
"newTreatment": "New treatment",
"newTreatmentPatientPrompt": "Who is this visit for?",
"newTreatmentUseCurrent": "Use {name}",
"newTreatmentUseCurrentHint": "Start a new visit for the patient already open on this page.",
"walkInPickerHint": "No named patient — always available, no search needed.",
"errorCreateTreatment": "Could not create treatment.",
"deleteEmptyTreatment": "Delete empty treatment",
@@ -724,7 +717,6 @@
"detailsTitle": "Treatment details",
"addDetail": "Add detail",
"confirmRemoveDetail": "Remove this treatment detail?",
"confirmRemoveLastDetail": "This is the last detail. Removing it leaves the plan empty until you add another. Continue?",
"removeDetailAria": "Remove detail {n}",
"detailLabel": "Detail {n}",
"detailSentBadge": "sent",

View File

@@ -18,9 +18,6 @@
"redirecting": "در حال انتقال...",
"readOnlyAccess": "دسترسی فقط خواندنی برای این سازمان.",
"errorGeneric": "خطایی رخ داده است",
"pageErrorTitle": "خطایی رخ داده است",
"pageErrorBody": "این صفحه بارگذاری نشد. می‌توانید دوباره تلاش کنید.",
"tryAgain": "تلاش دوباره",
"loadingEllipsis": "در حال بارگذاری...",
"search": "جستجو",
"action": "عملیات",
@@ -687,9 +684,7 @@
"selectedPatient": "بیمار انتخاب شده",
"purposeLabel": "هدف:",
"loadingAppointments": "در حال بارگذاری نوبت‌ها...",
"selectDayWithAppointment": "برای بیمار جستجو کنید یا یک ویزیت را از نوار روز انتخاب کنید.",
"noTreatmentFoundTitle": "درمانی یافت نشد",
"noTreatmentFoundBody": "هنوز درمان یا ویزیتی برای {name} ثبت نشده است. می‌توانید با دکمه {action} در نوار کناری یکی اضافه کنید.",
"selectDayWithAppointment": "روزی را انتخاب کنید که حداقل یک نوبت داشته باشد.",
"confirmDiscard": "تغییرات ذخیره‌نشده دارید. آنها را کنار بگذارید و ادامه دهید؟",
"errorChooseOrg": "حداقل یک سازمان فعال را برای ارسال این پرونده انتخاب کنید.",
"successCaseSent": "پرونده به سازمان‌های انتخاب شده ارسال شد.",
@@ -713,8 +708,6 @@
"walkIn": "بدون نوبت (مراجع)",
"newTreatment": "درمان جدید",
"newTreatmentPatientPrompt": "این ویزیت برای چه کسی است؟",
"newTreatmentUseCurrent": "استفاده از {name}",
"newTreatmentUseCurrentHint": "ویزیت جدیدی برای بیماری که همین حالا در این صفحه باز است شروع کنید.",
"walkInPickerHint": "بیمار نام‌دار نیست — همیشه در دسترس است و نیازی به جستجو ندارد.",
"errorCreateTreatment": "ایجاد درمان ممکن نشد.",
"deleteEmptyTreatment": "حذف درمان خالی",
@@ -725,7 +718,6 @@
"detailsTitle": "جزئیات درمان",
"addDetail": "افزودن جزئیات",
"confirmRemoveDetail": "این جزئیات درمان حذف شود؟",
"confirmRemoveLastDetail": "این آخرین جزئیات است. با حذف آن برنامه خالی می‌ماند تا جزئیات جدیدی اضافه کنید. ادامه می‌دهید؟",
"removeDetailAria": "حذف جزئیات {n}",
"detailLabel": "جزئیات {n}",
"detailSentBadge": "ارسال‌شده",

View File

@@ -18,9 +18,6 @@
"redirecting": "Bezig met doorsturen...",
"readOnlyAccess": "Alleen-lezen toegang voor deze organisatie.",
"errorGeneric": "Er is iets misgegaan",
"pageErrorTitle": "Er is iets misgegaan",
"pageErrorBody": "Deze pagina kon niet worden geladen. U kunt het opnieuw proberen.",
"tryAgain": "Opnieuw proberen",
"loadingEllipsis": "Laden...",
"search": "Zoeken",
"action": "Actie",
@@ -686,9 +683,7 @@
"selectedPatient": "Geselecteerde patiënt",
"purposeLabel": "Doel:",
"loadingAppointments": "Afspraken laden...",
"selectDayWithAppointment": "Zoek een patiënt of kies een bezoek uit de dagstrook.",
"noTreatmentFoundTitle": "Geen behandeling gevonden",
"noTreatmentFoundBody": "Er is nog geen behandeling of bezoek voor {name}. U kunt er een toevoegen met de knop {action} in de zijbalk.",
"selectDayWithAppointment": "Selecteer een dag met ten minste één afspraak.",
"confirmDiscard": "U heeft niet-opgeslagen wijzigingen. Wilt u deze negeren en doorgaan?",
"errorChooseOrg": "Kies ten minste één actieve organisatie om deze case te verzenden.",
"successCaseSent": "Case verzonden naar geselecteerde organisaties.",
@@ -712,8 +707,6 @@
"walkIn": "Inloop",
"newTreatment": "Nieuwe behandeling",
"newTreatmentPatientPrompt": "Voor wie is dit bezoek?",
"newTreatmentUseCurrent": "{name} gebruiken",
"newTreatmentUseCurrentHint": "Start een nieuw bezoek voor de patiënt die al op deze pagina openstaat.",
"walkInPickerHint": "Geen benoemde patiënt — altijd beschikbaar, zonder zoeken.",
"errorCreateTreatment": "Behandeling aanmaken is mislukt.",
"deleteEmptyTreatment": "Lege behandeling verwijderen",
@@ -724,7 +717,6 @@
"detailsTitle": "Behandeldetails",
"addDetail": "Detail toevoegen",
"confirmRemoveDetail": "Dit behandelingsdetail verwijderen?",
"confirmRemoveLastDetail": "Dit is het laatste detail. Als u het verwijdert, blijft het plan leeg tot u een nieuw detail toevoegt. Doorgaan?",
"removeDetailAria": "Detail {n} verwijderen",
"detailLabel": "Detail {n}",
"detailSentBadge": "verzonden",

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -12,10 +12,3 @@ export type DayStripItem = {
subtitle: string;
canDelete?: boolean;
};
/** Unscheduled card banner: first lines type only. Empty / missing type → no color. */
export function unscheduledStripColorCode(
details: readonly { treatmentType?: string | null }[],
): string {
return details[0]?.treatmentType?.trim() ?? '';
}

View File

@@ -1,60 +1,73 @@
const STORAGE_PREFIX = 'dyolink.labDispatchDefaults.';
/** Chips under lab search — last destinations this clinic actually sent a case to. */
export const MAX_RECENT_LABS = 3;
export type LabDispatchDefaults = {
lastLabId: string | null;
lastProsthesisByLab: Record<string, string>;
};
const EMPTY: LabDispatchDefaults = {
lastLabId: null,
lastProsthesisByLab: {},
};
function storageKey(clinicOrganizationId: string): string {
return `${STORAGE_PREFIX}${clinicOrganizationId}`;
}
function uniqueIds(ids: string[]): string[] {
const out: string[] = [];
for (const id of ids) {
if (id && !out.includes(id)) out.push(id);
}
return out;
}
export function loadRecentLabIds(clinicOrganizationId: string | null | undefined): string[] {
if (!clinicOrganizationId || typeof window === 'undefined') return [];
export function loadLabDispatchDefaults(clinicOrganizationId: string | null | undefined): LabDispatchDefaults {
if (!clinicOrganizationId || typeof window === 'undefined') return EMPTY;
try {
const raw = window.localStorage.getItem(storageKey(clinicOrganizationId));
if (!raw) return [];
const parsed = JSON.parse(raw) as {
recentLabIds?: unknown;
lastLabId?: unknown;
if (!raw) return EMPTY;
const parsed = JSON.parse(raw) as Partial<LabDispatchDefaults>;
return {
lastLabId: typeof parsed.lastLabId === 'string' ? parsed.lastLabId : null,
lastProsthesisByLab:
parsed.lastProsthesisByLab && typeof parsed.lastProsthesisByLab === 'object'
? parsed.lastProsthesisByLab
: {},
};
const fromList = Array.isArray(parsed.recentLabIds)
? parsed.recentLabIds.filter((id): id is string => typeof id === 'string')
: [];
// Older builds stored a single lastLabId used as an auto-selected default.
const fromLegacy = typeof parsed.lastLabId === 'string' ? [parsed.lastLabId] : [];
return uniqueIds([...fromList, ...fromLegacy]).slice(0, MAX_RECENT_LABS);
} catch {
return [];
return EMPTY;
}
}
function writeRecentLabIds(clinicOrganizationId: string, recentLabIds: string[]): void {
function writeDefaults(clinicOrganizationId: string, next: LabDispatchDefaults): void {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(
storageKey(clinicOrganizationId),
JSON.stringify({ recentLabIds }),
);
window.localStorage.setItem(storageKey(clinicOrganizationId), JSON.stringify(next));
} catch {
// Ignore quota / private-mode failures.
}
}
/** Record a lab after a successful send — suggestion chips only, never a form default. */
export function rememberRecentLab(
export function rememberLastLab(clinicOrganizationId: string | null | undefined, labId: string): void {
if (!clinicOrganizationId || !labId) return;
const current = loadLabDispatchDefaults(clinicOrganizationId);
writeDefaults(clinicOrganizationId, { ...current, lastLabId: labId });
}
export function rememberLastProsthesisType(
clinicOrganizationId: string | null | undefined,
labId: string,
prosthesisTypeCode: string,
): void {
if (!clinicOrganizationId || !labId) return;
writeRecentLabIds(
clinicOrganizationId,
uniqueIds([labId, ...loadRecentLabIds(clinicOrganizationId)]).slice(0, MAX_RECENT_LABS),
);
if (!clinicOrganizationId || !labId || !prosthesisTypeCode) return;
const current = loadLabDispatchDefaults(clinicOrganizationId);
writeDefaults(clinicOrganizationId, {
...current,
lastLabId: labId,
lastProsthesisByLab: {
...current.lastProsthesisByLab,
[labId]: prosthesisTypeCode,
},
});
}
export function lastProsthesisTypeForLab(
clinicOrganizationId: string | null | undefined,
labId: string | null | undefined,
): string | null {
if (!clinicOrganizationId || !labId) return null;
return loadLabDispatchDefaults(clinicOrganizationId).lastProsthesisByLab[labId] ?? null;
}

View File

@@ -19,9 +19,14 @@ export function voiceRowAvailability(result: VoiceExtractionResult) {
}
/**
* Everything available ticks itself, with two exceptions: an inexactly-matched lab, because
* it is the one extracted value whose error leaves the building; and an incomplete
* prosthesis map, which cannot ship at all and would just move the failure to dispatch.
* Which rows start ticked.
*
* Everything available ticks itself, with two deliberate exceptions:
*
* - **lab, when the name only approximately matched.** Shipping a case to a lab is the one
* extracted value whose error leaves the building, so it always requires a deliberate tick.
* - **prosthesis, when the map is incomplete.** A prosthesis detail with an untyped tooth
* cannot ship at all, so applying it would just move the failure to dispatch.
*/
export function initialVoiceSelection(result: VoiceExtractionResult): VoiceApplySelection {
const available = voiceRowAvailability(result);
@@ -36,8 +41,11 @@ export function initialVoiceSelection(result: VoiceExtractionResult): VoiceApply
}
/**
* Intersected with availability rather than counting ticks: a row can be ticked and then lose
* its content, and "Apply 1 item" that applies nothing is worse than a wrong number.
* How many rows will actually be applied — drives the confirm button's label.
*
* Intersected with availability rather than counting ticks: a row can be ticked and then
* lose its content (the last candidate tooth un-picked), and "Apply 1 item" that applies
* nothing is worse than a wrong number.
*/
export function countSelected(
selection: VoiceApplySelection,
@@ -58,10 +66,16 @@ function recheckProsthesis(
}
/**
* Fold the candidate picks into the result, so nothing downstream has to know chips exist.
* Fold the clinician's candidate picks into the extracted result.
*
* Union rather than toggle: a candidate can coincidentally be a tooth the recording already
* produced ("۱۲ و دو"), and tapping it must not deselect that one.
* Everything downstream reads a `VoiceExtractionResult` — row availability, the mini
* chart, the prosthesis warning, `applyVoiceResult` — so resolving the picks into one here
* means none of them has to know the chips exist.
*
* Union rather than toggle, for two reasons: a candidate can coincidentally be a tooth the
* recording already produced ("۱۲ و دو"), where tapping it must not deselect that tooth;
* and `groupsFromFlatTeeth` keeps the bridges intact while giving every remaining tooth a
* single group, so no tooth can be lost on the way through.
*/
export function withChosenTeeth(
result: VoiceExtractionResult,
@@ -89,7 +103,12 @@ export function connectedTeethFromResult(result: VoiceExtractionResult): Set<Fdi
return connected;
}
/** A recording that produced nothing should say so, not show an empty form of checkboxes. */
/**
* Whether the sheet has anything worth showing.
*
* A recording that produced nothing usable should say so plainly rather than present an
* empty form of checkboxes.
*/
export function hasAnythingToApply(result: VoiceExtractionResult): boolean {
return Object.values(voiceRowAvailability(result)).some(Boolean);
}

View File

@@ -1,6 +1,5 @@
'use client';
import * as Sentry from '@sentry/nextjs';
import { Component, type ErrorInfo, type ReactNode } from 'react';
interface TodayWidgetErrorBoundaryProps {
@@ -24,7 +23,6 @@ export class TodayWidgetErrorBoundary extends Component<
componentDidCatch(error: Error, info: ErrorInfo) {
console.error('Today widget render error:', error, info);
Sentry.captureException(error, { extra: { componentStack: info.componentStack } });
}
render() {

View File

@@ -24,12 +24,6 @@ const TOOTH_NUMBER_GAP = 'mt-1';
const REALISTIC_NUMBER_GAP = '2mm';
/** Tight interproximal gap between tooth columns. */
const TOOTH_GAP = 'gap-x-px';
/**
* Centers a mark on the inline-end edge of a tooth column (between this tooth
* and the next in flex order). Logical `end` + 0-width flex stays correct in LTR and RTL.
*/
const EDGE_MARK_ANCHOR =
'absolute inset-y-0 end-0 z-10 w-0 flex items-center justify-center';
function quadrantMirrored(fdi: FdiToothId): boolean {
const q = fdi[0];
@@ -291,42 +285,40 @@ export function FdiToothChart({
/>
) : null}
{renderEdge ? (
<div className={EDGE_MARK_ANCHOR}>
{linkInteractive ? (
<button
type="button"
disabled={isDisabled}
title={linked ? t('toothUnlinkHint') : t('toothLinkHint')}
aria-label={
linkInteractive ? (
<button
type="button"
disabled={isDisabled}
title={linked ? t('toothUnlinkHint') : t('toothLinkHint')}
aria-label={
linked
? t('toothUnlinkAria', { a: fdi, b: next! })
: t('toothLinkAria', { a: fdi, b: next! })
}
aria-pressed={linked}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onToggleLink?.(fdi, next!);
}}
className={`
absolute right-0 z-10 translate-x-1/2 h-3.5 w-3.5 rounded-full border-2 transition-colors
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50
${
linked
? t('toothUnlinkAria', { a: fdi, b: next! })
: t('toothLinkAria', { a: fdi, b: next! })
? 'border-primary bg-primary shadow-sm'
: 'border-primary bg-background-secondary hover:bg-primary/15'
}
aria-pressed={linked}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onToggleLink?.(fdi, next!);
}}
className={`
h-3.5 w-3.5 shrink-0 rounded-full border-2 transition-colors
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50
${
linked
? 'border-primary bg-primary shadow-sm'
: 'border-primary bg-background-secondary hover:bg-primary/15'
}
${isDisabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
`}
/>
) : (
<span
className="h-2.5 w-2.5 shrink-0 rounded-full bg-primary shadow-sm"
title={t('toothConnectedHint')}
aria-hidden
/>
)}
</div>
${isDisabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
`}
/>
) : (
<span
className="absolute right-0 z-10 translate-x-1/2 h-2.5 w-2.5 rounded-full bg-primary shadow-sm"
title={t('toothConnectedHint')}
aria-hidden
/>
)
) : null}
</div>
);

View File

@@ -1,6 +1,6 @@
'use client';
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/shared/Button';
import { Checkbox } from '@/components/ui/shared/Checkbox';
@@ -16,6 +16,12 @@ import { LabCaseTrackerCard } from '@/components/ui/treatment/LabCaseTrackerCard
import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay';
import { treatmentsApi } from '@/lib/api/treatments';
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
import {
lastProsthesisTypeForLab,
loadLabDispatchDefaults,
rememberLastLab,
rememberLastProsthesisType,
} from '@/components/treatment/labDispatchDefaults';
import type { ProsthesisCatalogEntry, TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type { LabCaseDraft, LinkedOrganizationOption, TreatmentDetailDraft } from '@/types/treatment';
import type { PatientLabCaseSummary } from '@/types/lab-case-activity';
@@ -28,6 +34,7 @@ interface LabCasesDispatchPanelProps {
labCases: LabCaseDraft[];
labDependentCodes: Set<string>;
treatmentCatalog: TreatmentCatalogEntry[];
clinicOrganizationId?: string | null;
labCaseSummary?: PatientLabCaseSummary | null;
locale: string;
onLabCaseSummaryChange?: (summary: PatientLabCaseSummary) => void;
@@ -41,6 +48,7 @@ interface LabCasesDispatchPanelProps {
organizationSearch: string;
onOrganizationSearchChange: (value: string) => void;
recentOrganizationIds: string[];
onRecentOrganizationPick: (orgId: string) => void;
canInviteLab?: boolean;
onInviteLab?: () => void;
sendBusyId: string | null;
@@ -116,6 +124,7 @@ export function LabCasesDispatchPanel({
labCases,
labDependentCodes,
treatmentCatalog,
clinicOrganizationId,
labCaseSummary,
locale,
onLabCaseSummaryChange,
@@ -129,6 +138,7 @@ export function LabCasesDispatchPanel({
organizationSearch,
onOrganizationSearchChange,
recentOrganizationIds,
onRecentOrganizationPick,
canInviteLab = false,
onInviteLab,
sendBusyId,
@@ -140,6 +150,7 @@ export function LabCasesDispatchPanel({
const [prosthesisOptions, setProsthesisOptions] = useState<ProsthesisCatalogEntry[]>([]);
const [applyAllProsthesis, setApplyAllProsthesis] = useState('');
const [pendingComment, setPendingComment] = useState('');
const autoFilledCaseRef = useRef<string | null>(null);
const hasTrackerSummary = Boolean(labCaseSummary && labCaseSummary.labCaseId);
const activeLinkedOrganizations = orgs.filter((o) => o.active);
@@ -157,14 +168,7 @@ export function LabCasesDispatchPanel({
const activeLabCase =
labCaseForActiveDetail ??
(activeLabCaseId
? labCases.find(
(lc) =>
lc.clientId === activeLabCaseId &&
(lc.detailClientId == null || lc.detailClientId === activeDetailId),
)
: null) ??
null;
(activeLabCaseId ? labCases.find((lc) => lc.clientId === activeLabCaseId) : null);
const sent = Boolean(activeLabCase?.sentAt);
const activeDetailNumber = details.findIndex((d) => d.clientId === activeDetailId) + 1;
@@ -204,7 +208,6 @@ export function LabCasesDispatchPanel({
useEffect(() => {
setPendingComment('');
setApplyAllProsthesis('');
}, [activeLabCase?.clientId]);
function updateActiveLabCase(patch: Partial<LabCaseDraft>) {
@@ -223,6 +226,45 @@ export function LabCasesDispatchPanel({
// eslint-disable-next-line react-hooks/exhaustive-deps -- only sync newly uploaded files
}, [activeDetail?.attachmentMetas, activeLabCase?.clientId, sent]);
useEffect(() => {
if (!activeLabCase || sent || activeLabCase.destinationOrganizationId) return;
const lastLabId = loadLabDispatchDefaults(clinicOrganizationId).lastLabId;
const lastLab = lastLabId
? activeLinkedOrganizations.find((o) => o.id === lastLabId)
: undefined;
if (!lastLab) return;
updateActiveLabCase({ destinationOrganizationId: lastLab.id, toothProsthesis: [] });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeLabCase?.clientId, clinicOrganizationId, sent]);
useEffect(() => {
if (!activeLabCase || sent) return;
if (!activeLabCase.destinationOrganizationId) return;
if (prosthesisOptions.length === 0 || prosthesisRows.length === 0) return;
const fillKey = `${activeLabCase.clientId}:${prosthesisRows.length}`;
if (autoFilledCaseRef.current === fillKey) return;
if (isProsthesisMapComplete(activeLabCase, prosthesisRows)) {
autoFilledCaseRef.current = fillKey;
return;
}
const lastCode = lastProsthesisTypeForLab(
clinicOrganizationId,
activeLabCase.destinationOrganizationId,
);
if (!lastCode || !prosthesisOptions.some((opt) => opt.code === lastCode)) return;
autoFilledCaseRef.current = fillKey;
setApplyAllProsthesis(lastCode);
updateActiveLabCase({ toothProsthesis: toothProsthesisForRows(prosthesisRows, lastCode) });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
activeLabCase?.clientId,
activeLabCase?.destinationOrganizationId,
clinicOrganizationId,
prosthesisOptions,
prosthesisRows.length,
sent,
]);
if (!activeDetail || !isLabDependentDetail) {
return null;
}
@@ -245,11 +287,25 @@ export function LabCasesDispatchPanel({
]
: rest;
updateActiveLabCase({ toothProsthesis: next });
if (prosthesisTypeCode && activeLabCase.destinationOrganizationId) {
rememberLastProsthesisType(
clinicOrganizationId,
activeLabCase.destinationOrganizationId,
prosthesisTypeCode,
);
}
}
function applyProsthesisToAll(code: string) {
if (!activeLabCase || !code) return;
updateActiveLabCase({ toothProsthesis: toothProsthesisForRows(prosthesisRows, code) });
if (activeLabCase.destinationOrganizationId) {
rememberLastProsthesisType(
clinicOrganizationId,
activeLabCase.destinationOrganizationId,
code,
);
}
}
function toggleAttachmentInActiveLabCase(attachmentId: string, checked: boolean) {
@@ -261,11 +317,13 @@ export function LabCasesDispatchPanel({
}
function handleSelectOrganization(org: LinkedOrganizationOption) {
autoFilledCaseRef.current = null;
updateActiveLabCase({
destinationOrganizationId: org.id,
toothProsthesis: [],
});
setApplyAllProsthesis('');
rememberLastLab(clinicOrganizationId, org.id);
}
const caseFullyComplete = isLabCaseCompleted(activeLabCase?.taskProgress);
@@ -405,7 +463,10 @@ export function LabCasesDispatchPanel({
key={o.id}
type="button"
disabled={disabled}
onClick={() => handleSelectOrganization(o)}
onClick={() => {
handleSelectOrganization(o);
onRecentOrganizationPick(o.id);
}}
className="text-xs rounded-[var(--radius-sm)] border border-border/70 px-2 py-1 text-text-secondary hover:text-text-primary hover:border-border focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 disabled:opacity-50"
>
{o.name}
@@ -554,7 +615,26 @@ export function LabCasesDispatchPanel({
!prosthesisComplete
}
isLoading={sendBusyId === activeLabCase.clientId}
onClick={() => onSendLabCase(activeLabCase, pendingComment.trim())}
onClick={() => {
if (activeLabCase.destinationOrganizationId) {
rememberLastLab(clinicOrganizationId, activeLabCase.destinationOrganizationId);
const codes = [
...new Set(
activeLabCase.toothProsthesis
.map((tp) => tp.prosthesisTypeCode)
.filter(Boolean),
),
];
if (codes.length === 1) {
rememberLastProsthesisType(
clinicOrganizationId,
activeLabCase.destinationOrganizationId,
codes[0],
);
}
}
return onSendLabCase(activeLabCase, pendingComment.trim());
}}
>
{t('sendToLab')}
</Button>

View File

@@ -0,0 +1,20 @@
'use client';
import { AlertCircle } from 'lucide-react';
import { useTranslations } from 'next-intl';
export function LabShipmentBlockedNotice() {
const t = useTranslations('treatment');
return (
<div className="surface-card p-4 border border-amber-500/35 bg-amber-500/5">
<div className="flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-amber-600 dark:text-amber-400 shrink-0 mt-0.5 icon-flat" />
<div className="min-w-0 space-y-1">
<h3 className="text-sm font-semibold text-text-primary">{t('labShipmentBlockedTitle')}</h3>
<p className="text-xs text-text-muted">{t('labShipmentBlockedBody')}</p>
</div>
</div>
</div>
);
}

View File

@@ -4,45 +4,19 @@ import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/shared/Button';
import { PatientSearchCombobox } from '@/components/ui/patient/PatientSearchCombobox';
import { usePatientSearchQuery } from '@/lib/hooks/usePatientSearchQuery';
import { formatMobileForDisplay } from '@/lib/phone';
import type { Patient } from '@/types/patient';
const pickerChoiceClass =
'w-full rounded-[var(--radius-md)] border border-primary/40 bg-primary-soft px-3 py-2 text-start transition-colors hover:border-primary disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45';
interface NewTreatmentPatientPickerProps {
creating?: boolean;
currentPatient?: {
id: string;
displayName: string;
mobile?: string | null;
email?: string | null;
} | null;
onSelectWalkIn: () => void | Promise<void>;
onSelectPatient: (patient: Patient) => void | Promise<void>;
onSelectCurrentPatient?: () => void | Promise<void>;
onCancel: () => void;
}
function contactLine(
patient: { mobile?: string | null; email?: string | null },
fallback: string,
): string {
const mobile = patient.mobile?.trim()
? formatMobileForDisplay(patient.mobile.trim())
: '';
if (mobile) return mobile;
const email = patient.email?.trim();
if (email) return email;
return fallback;
}
export function NewTreatmentPatientPicker({
creating = false,
currentPatient = null,
onSelectWalkIn,
onSelectPatient,
onSelectCurrentPatient,
onCancel,
}: NewTreatmentPatientPickerProps) {
const t = useTranslations('treatment');
@@ -57,25 +31,11 @@ export function NewTreatmentPatientPicker({
type="button"
disabled={creating}
onClick={() => void onSelectWalkIn()}
className={pickerChoiceClass}
className="w-full rounded-[var(--radius-md)] border border-primary/40 bg-primary-soft px-3 py-2 text-start transition-colors hover:border-primary disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45"
>
<p className="text-sm font-medium text-text-primary">{t('walkIn')}</p>
<p className="text-xs text-text-muted mt-0.5">{t('walkInPickerHint')}</p>
</button>
{currentPatient && onSelectCurrentPatient ? (
<button
type="button"
disabled={creating}
onClick={() => void onSelectCurrentPatient()}
aria-label={t('newTreatmentUseCurrent', { name: currentPatient.displayName })}
className={pickerChoiceClass}
>
<p className="text-sm font-medium text-text-primary break-words">{currentPatient.displayName}</p>
<p className="text-xs text-text-muted mt-0.5 break-words">
{contactLine(currentPatient, t('newTreatmentUseCurrentHint'))}
</p>
</button>
) : null}
<PatientSearchCombobox
search={search}
onSearchChange={setSearch}

View File

@@ -225,12 +225,12 @@ export function TreatmentDetailsEditor({
{stepper && activeDetail ? <div className="pt-1">{stepper}</div> : null}
{activeDetail && showMissingTeethLabBlock ? (
<p className={labBlockedBannerClass}>{t('labShipmentBlockedBody')}</p>
) : null}
{showFields && activeDetail ? (
<div className="space-y-3">
{showMissingTeethLabBlock && (
<p className={labBlockedBannerClass}>{t('labShipmentBlockedBody')}</p>
)}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:items-end">
<Dropdown
label={t('treatmentType')}
@@ -290,8 +290,6 @@ export function TreatmentDetailsEditor({
{footer ? <div className="pt-1">{footer}</div> : null}
</div>
) : showFields && !activeDetail ? (
<p className="text-sm text-text-muted">{t('noDetails')}</p>
) : null}
</div>
);
@@ -338,12 +336,17 @@ function NotesField({
/**
* "Add detail", split into two segments with the microphone at the logical end.
*
* Built like the detail chip's trash affordance in this same file — a wrapper holding two
* raw `<button>`s divided by `border-s` — rather than two shared `Button`s, which hardcode
* their own rounding and would fight a segmented control. `border-s` puts the microphone
* visually right in en/nl and left in fa, on the same side as the chip's trash in both.
* Built like the detail chip's trash affordance in this same file — an
* `inline-flex items-stretch overflow-hidden rounded` wrapper holding two raw `<button>`s
* divided by `border-s` — rather than two shared `Button`s, which each hardcode their own
* rounding and would fight a segmented control.
*
* Add keeps its exact existing behaviour; the microphone is an independent action.
* `border-s` puts the microphone at the *logical* end: visually right in en/nl, visually
* left in fa, on the same side as the chip's trash in both directions.
*
* The two halves share a wrapper and nothing else. Add keeps its exact existing
* behaviour; the microphone is an independent action that creates nothing until the
* clinician confirms.
*/
function AddDetailWithVoice({
addLabel,

View File

@@ -15,6 +15,7 @@ import { AppointmentsStrip } from '@/components/ui/treatment/AppointmentsStrip';
import { NewTreatmentPatientPicker } from '@/components/ui/treatment/NewTreatmentPatientPicker';
import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart';
import { LabCasesDispatchPanel } from '@/components/ui/treatment/LabCasesDispatchPanel';
import { LabShipmentBlockedNotice } from '@/components/ui/treatment/LabShipmentBlockedNotice';
import { LabDispatchAttentionPanel } from '@/components/ui/treatment/LabDispatchAttentionPanel';
import { PastTreatmentsPanel } from '@/components/ui/treatment/PastTreatmentsPanel';
import { TreatmentDetailsEditor } from '@/components/ui/treatment/TreatmentDetailsEditor';
@@ -40,7 +41,7 @@ import type {
import { treatmentsApi } from '@/lib/api/treatments';
import { notificationsApi } from '@/lib/api/notifications';
import { pickAutoAppointment } from '@/components/shared/treatmentSelection';
import { unscheduledStripColorCode, type DayStripItem } from '@/components/treatment/dayStrip';
import type { DayStripItem } from '@/components/treatment/dayStrip';
import {
areDetailsPersistable,
defaultTreatmentTypeForAppointment,
@@ -64,11 +65,7 @@ import {
} from '@/components/treatment/toothSelectionGroups';
import type { LabDispatchAttentionItem } from '@/components/treatment/labDispatchAttention';
import { collectLabDispatchAttention } from '@/components/treatment/labDispatchAttention';
import {
loadRecentLabIds,
MAX_RECENT_LABS,
rememberRecentLab,
} from '@/components/treatment/labDispatchDefaults';
import { loadLabDispatchDefaults, rememberLastLab } from '@/components/treatment/labDispatchDefaults';
import { canEditTreatment, canViewTreatment, canAccessDashboardRoute } from '@/components/shared/permissions';
import { scrollWithinMainScrollContainer } from '@/components/shared/scrollWithinMain';
import { useMarkTabReadOnVisit, useTabBadgeCounts } from '@/lib/hooks/useTabBadgeCounts';
@@ -214,7 +211,6 @@ function mapAppointment(record: AppointmentRecord): TreatmentAppointment {
patientId: record.patientId,
patientFirstName: record.patient.firstName,
patientLastName: record.patient.lastName,
patientMobile: record.patient.mobile,
providerUserId: record.providerUserId,
startAt: record.startAt,
endAt: record.endAt,
@@ -395,12 +391,10 @@ export function TreatmentWorkspace({
const [unreadLabCasesLoading, setUnreadLabCasesLoading] = useState(false);
const [labCasesScope, setLabCasesScope] = useState<TreatmentLabCasesScope>('patient');
const [selectedRailLabCaseId, setSelectedRailLabCaseId] = useState<string | null>(null);
const [searchedPatient, setSearchedPatient] = useState<
(Pick<Patient, 'id' | 'firstName' | 'lastName'> & {
mobile?: string | null;
email?: string | null;
}) | null
>(null);
const [searchedPatient, setSearchedPatient] = useState<Pick<
Patient,
'id' | 'firstName' | 'lastName'
> | null>(null);
const [patientSearchBusy, setPatientSearchBusy] = useState(false);
const [newTreatmentPickerOpen, setNewTreatmentPickerOpen] = useState(false);
const [creatingStandalone, setCreatingStandalone] = useState(false);
@@ -533,8 +527,6 @@ export function TreatmentWorkspace({
id: selectedAppointment.patientId,
firstName: selectedAppointment.patientFirstName,
lastName: selectedAppointment.patientLastName,
mobile: selectedAppointment.patientMobile ?? null,
email: null,
purpose: selectedAppointment.purpose,
isWalkIn: false,
};
@@ -545,8 +537,6 @@ export function TreatmentWorkspace({
id: selectedStandalone.patientId,
firstName: isWalkIn ? walkInLabel : (selectedStandalone.patient?.firstName ?? ''),
lastName: isWalkIn ? '' : (selectedStandalone.patient?.lastName ?? ''),
mobile: isWalkIn ? null : (selectedStandalone.patient?.mobile ?? null),
email: isWalkIn ? null : (selectedStandalone.patient?.email ?? null),
purpose: selectedStandalone.details[0]?.treatmentType,
isWalkIn,
};
@@ -556,9 +546,7 @@ export function TreatmentWorkspace({
id: searchedPatient.id,
firstName: searchedPatient.firstName,
lastName: searchedPatient.lastName,
mobile: searchedPatient.mobile ?? null,
email: searchedPatient.email ?? null,
purpose: undefined,
purpose: undefined as string | undefined,
isWalkIn: false,
};
}
@@ -569,23 +557,6 @@ export function TreatmentWorkspace({
const activePatientName = activePatient
? `${activePatient.firstName} ${activePatient.lastName}`.trim()
: null;
const namedActivePatient =
activePatient && !activePatient.isWalkIn && activePatient.id
? {
id: activePatient.id,
displayName: activePatientName ?? '',
mobile: activePatient.mobile ?? null,
email: activePatient.email ?? null,
}
: null;
const searchedWithoutLiveVisit = Boolean(searchedPatient) && !hasLiveContext;
const showSearchedPatientLoading =
searchedWithoutLiveVisit && (patientSearchBusy || historyLoading);
const showNoTreatmentFound =
searchedWithoutLiveVisit &&
!patientSearchBusy &&
!historyLoading &&
history.length === 0;
const unreadUpdatesCount = unreadLabCases.length;
const otherPatientsUnreadCount = useMemo(
@@ -657,8 +628,8 @@ export function TreatmentWorkspace({
setStandaloneTreatments((prev) => {
const current = prev.find((row) => row.id === selectedStandaloneId);
if (!current) return prev;
const prevColor = unscheduledStripColorCode(current.details);
const nextColor = unscheduledStripColorCode(nextDetails);
const prevColor = current.details.find((d) => d.treatmentType?.trim())?.treatmentType?.trim() ?? '';
const nextColor = nextDetails.find((d) => d.treatmentType?.trim())?.treatmentType?.trim() ?? '';
if (
prevColor === nextColor &&
areUnscheduledDetailsStripDeletable(current.details) ===
@@ -689,7 +660,8 @@ export function TreatmentWorkspace({
const sourceDetails = tr.id === selectedStandaloneId && !draftHydratingRef.current
? details
: tr.details;
const colorCode = unscheduledStripColorCode(sourceDetails);
const typedDetail = sourceDetails.find((d) => Boolean(d.treatmentType?.trim()));
const colorCode = typedDetail?.treatmentType?.trim() ?? '';
return {
kind: 'unscheduled' as const,
id: tr.id,
@@ -791,7 +763,7 @@ export function TreatmentWorkspace({
mapped.length > 0
? mapped
: options?.seedBlankIfEmpty
? [newDetail()]
? [newDetail(defaultTreatmentTypeForAppointment(undefined, treatmentCatalog))]
: [];
setDetails(nextDetails);
setActiveDetailId((prev) => {
@@ -806,7 +778,7 @@ export function TreatmentWorkspace({
setActiveLabCaseId(mappedLabCases[0]?.clientId ?? null);
setOrganizationSearch('');
setSaveStatus('idle');
}, []);
}, [treatmentCatalog]);
const selectedTeethSet = useMemo(() => new Set(activeDetail?.teeth ?? []), [activeDetail?.teeth]);
const connectedSelectedTeeth = useMemo(
@@ -859,7 +831,6 @@ export function TreatmentWorkspace({
useEffect(() => {
setShowWholeTreatmentPlan(false);
rangeAnchorRef.current = null;
setOrganizationSearch('');
const pending = pendingEntryStepRef.current;
pendingEntryStepRef.current = null;
setEntryStep(pending ?? 'treatment');
@@ -973,11 +944,11 @@ export function TreatmentWorkspace({
setLabDependentCodes(
new Set(catalogResponse.data.filter((entry) => entry.labDependent).map((entry) => entry.code)),
);
const recentIds = loadRecentLabIds(currentOrganization?.id).filter((id) =>
orgsResponse.data.some((o) => o.id === id && o.active),
);
if (recentIds.length > 0) {
setRecentOrganizationIds(recentIds);
const lastLabId = loadLabDispatchDefaults(currentOrganization?.id).lastLabId;
if (lastLabId && orgsResponse.data.some((o) => o.id === lastLabId && o.active)) {
setRecentOrganizationIds((prev) =>
prev.includes(lastLabId) ? prev : [lastLabId, ...prev].slice(0, 10),
);
}
} catch (error: unknown) {
if (!cancelled) {
@@ -1612,51 +1583,24 @@ export function TreatmentWorkspace({
return;
}
const ok = await flushDraftSave();
if (!ok) return;
setPatientSearchBusy(true);
setSearchedPatient({
id: patient.id,
firstName: patient.firstName,
lastName: patient.lastName,
});
try {
const stripAppointment = appointments.find((row) => row.patientId === patient.id);
const stripStandalone = standaloneTreatments.find(
(row) => row.patientId === patient.id && !row.patient?.isWalkIn,
);
if (stripAppointment || stripStandalone) {
draftHydratingRef.current = true;
resetToLiveContext();
setSearchedPatient(null);
setNewTreatmentPickerOpen(false);
setSelectionLocked(true);
if (stripAppointment) {
setSelectedAppointmentId(stripAppointment.id);
setSelectedStandaloneId(null);
} else if (stripStandalone) {
setSelectedAppointmentId(null);
setSelectedStandaloneId(stripStandalone.id);
const response = await treatmentsApi.listPatientHistory(patient.id, 1);
const latest = response.data[0];
if (latest) {
const ok = await loadTreatmentIntoWorkspace(latest);
if (!ok) {
setSearchedPatient(null);
}
return;
}
setSearchedPatient({
id: patient.id,
firstName: patient.firstName,
lastName: patient.lastName,
mobile: patient.mobile,
email: patient.email,
});
resetToLiveContext();
setNewTreatmentPickerOpen(false);
setSelectedAppointmentId(null);
setSelectedStandaloneId(null);
setSelectionLocked(true);
setHistory([]);
setHistoryLoading(true);
const response = await treatmentsApi.listPatientHistory(patient.id, 1);
const latest = response.data[0];
if (latest) {
await loadTreatmentIntoWorkspace(latest);
}
await refreshHistory(patient.id);
} catch (error: unknown) {
showError(getUserFacingError(error, tErrors, t('errorLoadHistory')));
setSearchedPatient(null);
@@ -1670,11 +1614,8 @@ export function TreatmentWorkspace({
selectedStandalone?.patientId,
searchedPatient?.id,
hasLiveContext,
flushDraftSave,
appointments,
standaloneTreatments,
resetToLiveContext,
loadTreatmentIntoWorkspace,
refreshHistory,
showError,
t,
tErrors,
@@ -2000,7 +1941,8 @@ export function TreatmentWorkspace({
const nextDetails = [...detailsRef.current, detail];
setDetails(nextDetails);
// persistDraft reads detailsRef, and setDetails has not rendered yet.
// persistDraft reads detailsRef, and setDetails has not rendered yet. The codebase
// already writes this ref imperatively after a save for the same reason.
detailsRef.current = nextDetails;
setActiveDetailId(detail.clientId);
setEntryStep('treatment');
@@ -2044,18 +1986,13 @@ export function TreatmentWorkspace({
const updatedLabCases = [...labCaseDrafts, draft];
setLabCaseDrafts(updatedLabCases);
// Autosave only watches `details`, so a lab draft left in state alone loses the
// lab, the due date and the prosthesis map on reload — silently, because the
// detail itself survives.
// Every other path that creates a lab draft persists it immediately, and the
// autosave effect only watches `details`. Left in state alone, the destination
// lab, the due date and the whole prosthesis map vanish on the next reload —
// silently, because the detail itself does survive.
void (async () => {
try {
const saved = await persistDraft({ force: true });
// persistDraft returns a *preview* when the details are not persistable — one
// blank detail is enough — and a preview's detail id falls back to the client
// id. Check what came back, not the precondition, so this holds for every early
// return persistDraft has.
const savedDetail = saved.details.find((d) => d.clientId === detail.clientId);
if (!savedDetail?.id || savedDetail.id === detail.clientId) return;
await persistLabCases(saved, updatedLabCases);
} catch (error: unknown) {
showError(getUserFacingError(error, tErrors, t('errorSaveLabShipments')));
@@ -2084,9 +2021,7 @@ export function TreatmentWorkspace({
if (idx < 0) return;
const target = details[idx];
if (!target || isDetailLocked(target)) return;
if (!window.confirm(
details.length <= 1 ? t('confirmRemoveLastDetail') : t('confirmRemoveDetail'),
)) return;
if (!window.confirm(t('confirmRemoveDetail'))) return;
const nextDetails = details.filter((d) => d.clientId !== detailClientId);
const nextActive =
@@ -2162,9 +2097,14 @@ export function TreatmentWorkspace({
return;
}
const lastLabId = loadLabDispatchDefaults(currentOrganization?.id).lastLabId;
const lastLabStillActive = lastLabId
? orgs.some((o) => o.id === lastLabId && o.active)
: false;
const next: LabCaseDraft = {
...newLabCaseDraft(),
detailClientId: shouldIncludeActive ? activeDetailId : null,
destinationOrganizationId: lastLabStillActive ? lastLabId! : null,
attachmentIds: activeDetail?.attachmentMetas.map((a) => a.id) ?? [],
};
const updatedLabCases = [...cleaned, next];
@@ -2180,9 +2120,11 @@ export function TreatmentWorkspace({
}, [
activeDetailId,
canEditTreatmentForDay,
currentOrganization?.id,
details,
labCaseDrafts,
labDependentCodes,
orgs,
persistDraft,
persistLabCases,
selectedAppointment,
@@ -2302,8 +2244,8 @@ export function TreatmentWorkspace({
setRecentOrganizationIds((prev) => {
const orgId = labCase.destinationOrganizationId!;
rememberRecentLab(currentOrganization?.id, orgId);
return [orgId, ...prev.filter((id) => id !== orgId)].slice(0, MAX_RECENT_LABS);
rememberLastLab(currentOrganization?.id, orgId);
return [orgId, ...prev.filter((id) => id !== orgId)].slice(0, 10);
});
showSuccess(t('successCaseSent'));
notifyTabBadgesChanged();
@@ -2344,24 +2286,11 @@ export function TreatmentWorkspace({
return (
<div className="space-y-4">
<header className="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
<div className="min-w-0 shrink-0">
<h1 className="text-xl sm:text-2xl font-semibold text-text-primary">{t('title')}</h1>
{!canEdit ? (
<p className="text-sm text-text-secondary mt-1">{t('subtitleReadOnly')}</p>
) : null}
</div>
<div className="min-w-0 flex-1">
<PatientSearchCombobox
search={patientSearch}
onSearchChange={setPatientSearch}
patients={patientSearchResults}
loading={patientSearchLoading || patientSearchBusy}
onSelectPatient={handleSelectSearchedPatient}
placeholder={tPatients('searchPlaceholder')}
emptyResultsMessage={tPatients('noResults')}
/>
</div>
<header className="space-y-1">
<h1 className="text-xl sm:text-2xl font-semibold text-text-primary">{t('title')}</h1>
{!canEdit ? (
<p className="text-sm text-text-secondary">{t('subtitleReadOnly')}</p>
) : null}
</header>
<AppointmentsStrip
@@ -2392,6 +2321,15 @@ export function TreatmentWorkspace({
<div className="treatment-layout-grid grid grid-cols-1 xl:grid-cols-[minmax(300px,380px)_minmax(0,1fr)] gap-4 items-start">
<div className="space-y-3 min-w-0 xl:max-w-[380px]">
<div className="surface-card p-3 space-y-3">
<PatientSearchCombobox
search={patientSearch}
onSearchChange={setPatientSearch}
patients={patientSearchResults}
loading={patientSearchLoading || patientSearchBusy}
onSelectPatient={handleSelectSearchedPatient}
placeholder={tPatients('searchPlaceholder')}
emptyResultsMessage={tPatients('noResults')}
/>
{canEdit && !isViewingPastDay ? (
<div className="space-y-2">
<Button
@@ -2406,16 +2344,10 @@ export function TreatmentWorkspace({
{newTreatmentPickerOpen ? (
<NewTreatmentPatientPicker
creating={creatingStandalone}
currentPatient={namedActivePatient}
onSelectWalkIn={() => createStandaloneTreatment({ walkIn: true })}
onSelectPatient={(patient) =>
createStandaloneTreatment({ patientId: patient.id })
}
onSelectCurrentPatient={
namedActivePatient
? () => createStandaloneTreatment({ patientId: namedActivePatient.id })
: undefined
}
onCancel={() => setNewTreatmentPickerOpen(false)}
/>
) : null}
@@ -2423,14 +2355,8 @@ export function TreatmentWorkspace({
) : null}
{activePatient ? (
<div
className={`space-y-0.5 ${
canEdit && !isViewingPastDay ? 'border-t border-border/60 pt-3' : ''
}`}
>
<p className="text-[10px] uppercase tracking-wide text-text-muted">
{t('selectedPatient')}
</p>
<div className="space-y-0.5 border-t border-border/60 pt-3">
<p className="text-[10px] uppercase tracking-wide text-text-muted">{t('selectedPatient')}</p>
<p className="text-base font-semibold text-text-primary">{activePatientName}</p>
{activePatient.purpose ? (
<p className="text-[11px] text-text-secondary">
@@ -2442,11 +2368,7 @@ export function TreatmentWorkspace({
) : null}
</div>
) : (
<p
className={`text-sm text-text-muted ${
canEdit && !isViewingPastDay ? 'border-t border-border/60 pt-3' : ''
}`}
>
<p className="text-sm text-text-muted border-t border-border/60 pt-3">
{apptsLoading ? t('loadingAppointments') : t('selectDayWithAppointment')}
</p>
)}
@@ -2549,24 +2471,6 @@ export function TreatmentWorkspace({
</div>
<div className="space-y-3 min-w-0 w-full">
{showSearchedPatientLoading ? (
<div className="surface-card p-6">
<p className="text-sm text-text-muted">{t('loading')}</p>
</div>
) : showNoTreatmentFound ? (
<div className="surface-card w-full p-6 space-y-3">
<h2 className="text-lg font-semibold text-text-primary">
{t('noTreatmentFoundTitle')}
</h2>
<p className="text-sm text-text-secondary">
{t('noTreatmentFoundBody', {
name: activePatientName ?? '',
action: t('newTreatment'),
})}
</p>
</div>
) : (
<>
<TreatmentDetailsEditor
details={details}
activeDetailId={activeDetailId}
@@ -2580,14 +2484,9 @@ export function TreatmentWorkspace({
saveStatus={saveStatus}
uploadBusy={uploadBusyDetailId === activeDetailId}
onAddDetail={() => {
const seedFromAppointment =
details.length === 0 && selectedAppointment
? defaultTreatmentTypeForAppointment(
selectedAppointment.purpose,
treatmentCatalog,
)
: undefined;
const next = newDetail(seedFromAppointment);
const next = newDetail(
defaultTreatmentTypeForAppointment(selectedAppointment?.purpose, treatmentCatalog),
);
setDetails((prev) => [...prev, next]);
setActiveDetailId(next.clientId);
setEntryStep('treatment');
@@ -2827,6 +2726,7 @@ export function TreatmentWorkspace({
{entryStep === 'lab' ? (
<div ref={labPanelRef} className="space-y-3">
{showLabShipmentBlocked ? <LabShipmentBlockedNotice /> : null}
{showLabDispatchPanel ? (
<LabCasesDispatchPanel
details={details}
@@ -2834,6 +2734,7 @@ export function TreatmentWorkspace({
labCases={labCaseDrafts}
labDependentCodes={labDependentCodes}
treatmentCatalog={treatmentCatalog}
clinicOrganizationId={currentOrganization?.id}
labCaseSummary={activeLabCaseSummary}
locale={locale}
onLabCaseSummaryChange={handleLabCaseSummaryChange}
@@ -2851,21 +2752,33 @@ export function TreatmentWorkspace({
organizationSearch={organizationSearch}
onOrganizationSearchChange={setOrganizationSearch}
recentOrganizationIds={recentOrganizationIds}
onRecentOrganizationPick={(orgId) => {
setLabCaseDrafts((prev) => {
const targetId =
activeLabCaseId ??
prev.find((lc) => !lc.sentAt && lc.detailClientId === activeDetailId)
?.clientId;
if (!targetId) return prev;
return prev.map((lc) =>
lc.clientId === targetId && !lc.sentAt
? { ...lc, destinationOrganizationId: orgId }
: lc,
);
});
}}
sendBusyId={sendBusyId}
onSendLabCase={(lc, comment) => handleSendLabCase(lc, comment)}
onCommentError={showError}
canInviteLab={canAccessOrganizations}
onInviteLab={() => router.push('/organizations?action=invite-lab')}
/>
) : showLabShipmentBlocked ? null : (
) : (
<p className="text-sm text-text-muted surface-card p-4">
{t('entryStepLabUnavailable')}
</p>
)}
</div>
) : null}
</>
)}
</div>
</div>
{voiceResult ? (

View File

@@ -16,9 +16,9 @@ function formatElapsed(ms: number): string {
/**
* Live recording / processing strip.
*
* Sits between the header row and the chip strip rather than inside the segmented control:
* the header is `sm:justify-between`, so growing the button mid-recording would shift the
* whole row.
* Sits between the header row and the chip strip rather than inside the segmented
* control: the header is `sm:justify-between`, so growing the button mid-recording would
* shove the row on every start and every stop.
*/
export function VoiceRecordingBar({ voice }: { voice: VoiceCaptureState }) {
const t = useTranslations('treatment');

View File

@@ -38,8 +38,9 @@ interface VoiceReviewSheetProps {
/**
* Confirmation step between the model's output and the form.
*
* Modal on desktop, bottom sheet on mobile — an overlay and not a route, because navigating
* would unmount TreatmentWorkspace and destroy the in-progress draft.
* Modal on desktop, bottom sheet on mobile via ResponsiveDialog — deliberately an overlay
* and not a route, because navigating would unmount TreatmentWorkspace and destroy the
* in-progress draft.
*/
export function VoiceReviewSheet({
result,
@@ -77,9 +78,10 @@ export function VoiceReviewSheet({
// The teeth row starts unticked whenever the recording produced no teeth of its own,
// and a picked tooth that is not ticked applies nothing.
teeth: true,
// A picked tooth has no prosthesis type, so the map is no longer shippable — leaving the
// row ticked would apply a map dispatch rejects. Only ever unticks; re-ticking is the
// clinician's call.
// The picked tooth has no prosthesis type, which makes the map unshippable. Leaving
// the row ticked would apply a map that `assertCompleteToothProsthesisMap` rejects
// at dispatch — the exact failure the never-auto-tick-incomplete rule exists to
// prevent. Only ever unticks: re-ticking is the clinician's call.
prosthesis:
prev.prosthesis &&
withChosenTeeth(result, nextChosen).prosthesis?.complete !== false,
@@ -288,8 +290,9 @@ function Row({
}
/**
* `new Date('2025-10-17')` parses a civil date as UTC midnight, which renders as the 16th
* west of Greenwich. Build it from its parts so it means the same day everywhere.
* A bare `YYYY-MM-DD` is a *civil* date, but `new Date('2025-10-17')` parses it as UTC
* midnight — which renders as the 16th for any viewer west of Greenwich. Build the date
* from its parts so it means the same day everywhere.
*/
function civilDateToLocalDate(iso: string): Date {
const [year, month, day] = iso.split('-').map(Number);

View File

@@ -1,15 +0,0 @@
import * as Sentry from '@sentry/nextjs';
import { sentrySharedOptions } from '@/lib/error-tracking/sentrySharedOptions';
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN?.trim();
if (dsn) {
Sentry.init({
dsn,
environment:
process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT?.trim() ||
process.env.NODE_ENV ||
'development',
...sentrySharedOptions(),
});
}

View File

@@ -1,9 +0,0 @@
import * as Sentry from '@sentry/nextjs';
export async function register(): Promise<void> {
if (process.env.NEXT_RUNTIME === 'nodejs') {
await import('./lib/error-tracking/sentry.server.config');
}
}
export const onRequestError = Sentry.captureRequestError;

View File

@@ -2,7 +2,6 @@
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
import type { ApiError } from '@/types/api';
import { notifyAccessTokenRefreshed } from '@/lib/auth/accessTokenEvents';
import { reportUnexpectedApiFailure } from '@/lib/error-tracking/reportUnexpectedApiFailure';
interface CustomAxiosRequestConfig extends InternalAxiosRequestConfig {
_retry?: boolean;
@@ -47,7 +46,6 @@ function shouldSkipRefreshRetry(url: string | undefined): boolean {
apiClient.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
reportUnexpectedApiFailure(error);
const originalRequest = error.config as CustomAxiosRequestConfig;
if (

View File

@@ -22,11 +22,9 @@ export const voiceApi = {
payload: ExtractVoicePayload,
signal?: AbortSignal,
): Promise<{ success: boolean; data: VoiceExtractionResult }> => {
// ASR + LLM routinely exceeds the default 10s axios timeout; match slow upload endpoints.
const response = await apiClient.post('/voice/extract', payload, {
signal,
timeout: 120_000,
});
// The signal is forwarded so cancelling closes the connection, which aborts the
// metered vendor call server-side rather than letting it settle unseen.
const response = await apiClient.post('/voice/extract', payload, { signal });
return response.data;
},
};

View File

@@ -1,20 +0,0 @@
import * as Sentry from '@sentry/nextjs';
import type { AxiosError } from 'axios';
/** Report network failures and HTTP 5xx only — not coded 4xx AppExceptions. */
export function reportUnexpectedApiFailure(error: AxiosError): void {
const status = error.response?.status;
if (status !== undefined && status < 500) {
return;
}
Sentry.captureException(error, {
tags: {
api_status: status ? String(status) : 'network',
},
extra: {
url: error.config?.url,
method: error.config?.method,
},
});
}

View File

@@ -1,15 +0,0 @@
import * as Sentry from '@sentry/nextjs';
import { sentrySharedOptions } from '@/lib/error-tracking/sentrySharedOptions';
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN?.trim();
if (dsn) {
Sentry.init({
dsn,
environment:
process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT?.trim() ||
process.env.NODE_ENV ||
'development',
...sentrySharedOptions(),
});
}

View File

@@ -1,28 +0,0 @@
import type { ErrorEvent } from '@sentry/core';
/** Shared Sentry/GlitchTip options — no session replay, no PII in payloads. */
export function sentrySharedOptions() {
return {
sendDefaultPii: false as const,
tracesSampleRate: 0,
replaysSessionSampleRate: 0,
replaysOnErrorSampleRate: 0,
beforeSend(event: ErrorEvent): ErrorEvent {
if (event.request) {
delete event.request.cookies;
delete event.request.data;
if (event.request.headers) {
delete event.request.headers.cookie;
delete event.request.headers.authorization;
delete event.request.headers.Authorization;
}
}
if (event.user) {
delete event.user.email;
delete event.user.ip_address;
delete event.user.username;
}
return event;
},
};
}

View File

@@ -9,9 +9,11 @@ const PREFERRED_MIME_TYPES = [
] as const;
/**
* Pick a container this browser can record AND the backend accepts. Chrome and Android give
* webm/opus, Safari and iPad mp4/aac; both go to the vendor unmodified, so there is no
* transcode step and the list is an intersection, not a preference.
* Pick a container this browser can record AND the backend accepts.
*
* Chrome and Android produce webm/opus; Safari and iPad produce mp4/aac. Both go to the
* vendor unmodified, so there is no transcode step — but the choice still has to be made
* at record time, and `isTypeSupported` is missing entirely on older Safari.
*/
export function pickRecordingMimeType(): string | null {
if (typeof MediaRecorder === 'undefined') return null;

View File

@@ -69,8 +69,9 @@ export function useVoiceCapture({
/** getUserMedia is async; without this a permission granted after unmount leaks the mic. */
const mountedRef = useRef(true);
/**
* Set synchronously on click: `phase` only becomes 'recording' once getUserMedia resolves,
* so a second click during the permission prompt would orphan the first stream.
* Set synchronously on click. `phase` does not become 'recording' until getUserMedia
* resolves, so without this a second click during the permission prompt would start a
* second stream and orphan the first — mic indicator lit, interval leaked.
*/
const startingRef = useRef(false);
@@ -138,14 +139,8 @@ export function useVoiceCapture({
);
const stop = useCallback(() => {
// No recorder means nothing will fire `onstop`, so nothing else moves the phase.
if (!recorderRef.current) {
teardown();
setPhase('idle');
return;
}
try {
recorderRef.current.stop();
recorderRef.current?.stop();
} catch {
teardown();
setPhase('idle');
@@ -155,10 +150,7 @@ export function useVoiceCapture({
const onStart = useCallback(() => {
if (phase !== 'idle' || startingRef.current) return;
if (!isMediaRecorderSupported()) {
// VOICE_UNSUPPORTED_FORMAT, not MIC_DENIED: nothing asked for a permission yet, and
// blaming the microphone sends the clinician into site settings for no reason. Same
// for the two paths below.
onError(clientError('VOICE_UNSUPPORTED_FORMAT'));
onError(clientError('VOICE_MIC_DENIED'));
return;
}
@@ -186,7 +178,7 @@ export function useVoiceCapture({
const mimeType = pickRecordingMimeType();
if (mimeType === null) {
stream.getTracks().forEach((track) => track.stop());
onError(clientError('VOICE_UNSUPPORTED_FORMAT'));
onError(clientError('VOICE_MIC_DENIED'));
return;
}
@@ -228,11 +220,13 @@ export function useVoiceCapture({
if (maxMs != null && elapsed >= maxMs) stop();
}, LEVEL_POLL_MS);
} catch {
// `new MediaRecorder()` and `recorder.start()` both throw on some browsers, and by
// then the stream is live — without this the mic indicator stays lit until unmount.
// `new MediaRecorder(...)` and `recorder.start()` both throw on some browsers,
// and by then the stream is already live. Without this the promise rejects
// unhandled, the UI sits at 'idle' with nothing shown, and the browser's
// recording indicator stays lit until the workspace unmounts.
teardown();
setPhase('idle');
onError(clientError('VOICE_UNSUPPORTED_FORMAT'));
onError(clientError('VOICE_MIC_DENIED'));
} finally {
startingRef.current = false;
}
@@ -241,7 +235,8 @@ export function useVoiceCapture({
const onCancel = useCallback(() => {
cancelledRef.current = true;
// Aborting closes the connection, which aborts the vendor call server-side.
// Aborting closes the connection, which aborts the vendor call server-side. It is
// metered per minute, so letting it settle costs money for a result nobody sees.
abortRef.current?.abort();
try {
recorderRef.current?.stop();
@@ -275,9 +270,10 @@ function attachLevelMeter(
source.connect(analyser);
const data = new Uint8Array(analyser.frequencyBinCount);
// Sample every frame so a transient is not missed, publish at LEVEL_POLL_MS. The hook
// lives in TreatmentWorkspace, so an unthrottled setLevel is ~7,200 whole-tree renders
// across a two-minute recording.
// Sample every frame so a transient is not missed, but publish at LEVEL_POLL_MS.
// This hook lives in TreatmentWorkspace, so an unthrottled setLevel re-renders the
// details editor, the FDI chart and the lab panel on every animation frame — about
// 7,200 whole-tree renders across a two-minute recording.
let peakSinceEmit = 0;
let lastEmit = 0;
const tick = (now: number) => {

View File

@@ -38,7 +38,6 @@ export interface TreatmentAppointment {
patientId: string;
patientFirstName: string;
patientLastName: string;
patientMobile?: string | null;
providerUserId: string;
startAt: string;
endAt: string;
@@ -140,8 +139,6 @@ export interface PastTreatment {
firstName: string;
lastName: string;
isWalkIn: boolean;
mobile?: string | null;
email?: string | null;
} | null;
details: PastTreatmentDetail[];
labCases: PastLabCase[];

View File

@@ -1,97 +1,25 @@
# Dyolink — Production Server Deploy Guide
Linux VPS serves **`https://nudentic.ir` only**. Images come from the **Gitea registry** (`wixur.ir:3000`) on git tags. Staging is Windows (`https://wixur.ir`) — see [`STAGING-DEPLOY.md`](STAGING-DEPLOY.md).
Deploy the full stack (Postgres, NestJS API, Next.js, Nginx, Let's Encrypt) on a fresh Linux server using **Docker Hub** images.
**Automated path:** tag `v1.0.1` → [`.gitea/workflows/prod-tag-deploy.yml`](../.gitea/workflows/prod-tag-deploy.yml) (Windows runner builds, SSH to this server).
**Manual / first SSL:** Docker Hub + [`scripts/build-and-push-prod.sh`](scripts/build-and-push-prod.sh) is still documented below as a fallback.
**Example host:** `https://nudentic.ir` (do not point `wixur.ir` at this VPS).
---
## Automated deploy (Gitea tags)
```
git tag v1.0.1 && git push origin v1.0.1
Windows act_runner builds frontend with https://nudentic.ir
Push wixur.ir:3000/<owner>/dyolink-*:v1.0.1 (never :latest)
SSH → Linux docker login wixur.ir:3000 → compose pull + up
https://nudentic.ir
```
`:latest` is **staging only** (wixur.ir baked in). Production compose must pin `TAG=v1.0.1`.
### One-time on the Linux server
1. **HTTP registry** — Gitea is `http://wixur.ir:3000`. In `/etc/docker/daemon.json`:
```json
{
"insecure-registries": ["wixur.ir:3000"]
}
```
Then `sudo systemctl restart docker` (containers restart).
2. **`.env`** in `/opt/dyolink/infrastructure/` — from [`deploy.prod.env.example`](deploy.prod.env.example):
- `DOMAIN=nudentic.ir`
- `REGISTRY_PREFIX=wixur.ir:3000/<gitea-owner>` (same owner as Gitea `REGISTRY_OWNER`)
- `TAG=v1.0.1` (CI overrides per release)
3. **`secrets/backend.env`:** `FRONTEND_URL=https://nudentic.ir`, `COOKIE_SECURE=true`, plus `ADMINJS_EMAIL` / `ADMINJS_PASSWORD` for `https://nudentic.ir/admin`
4. **SSH** — user that can run `docker` (e.g. `dyolink` in the `docker` group). Put the matching **public** key in `~/.ssh/authorized_keys`.
5. **Test pull** (after a staging or prod image exists):
```bash
docker login wixur.ir:3000 -u <gitea-user>
docker pull wixur.ir:3000/<owner>/dyolink-backend:<sha-or-tag>
```
### Gitea (same repo as staging)
**Variables:** `PROD_PUBLIC_BASE_URL=https://nudentic.ir`, `PROD_REGISTRY_HOST=wixur.ir:3000`, `PROD_INFRA_DIR=/opt/dyolink/infrastructure`, plus existing `REGISTRY_HOST` / `REGISTRY_OWNER` / `CLONE_HOST`.
**Secrets:** `PROD_SSH_HOST`, `PROD_SSH_USER`, `PROD_SSH_KEY` (private key). Reuse `REGISTRY_USERNAME` / `REGISTRY_PASSWORD`. Optional `PROD_SSH_PORT` (default 22).
OpenSSH must be on the Windows runner (`ssh.exe` / `scp.exe`).
### Release
```bash
git tag v1.0.1
git push origin v1.0.1
```
Or Gitea → Actions → **Production — tag build, push, deploy** → Run → tag `v1.0.1`.
Check `https://nudentic.ir/api/health`. AdminJS is `https://nudentic.ir/admin`.
**Example used in production:** `https://wixur.ir` on server `185.243.48.140`.
---
## Architecture
```
Internet → Nginx (:80 / :443)
├── / → frontend:3000 (Next.js)
── /api → backend:3000 (NestJS API)
└── /admin → backend:3000 (AdminJS)
├── / → frontend:3000 (Next.js)
── /api → backend:3000 (NestJS)
└── postgres:5432
```
| Service | Image | Notes |
|-----------|------------------------------------|--------------------------------|
| postgres | `postgres:15-alpine` | Data in Docker volume |
| backend | `REGISTRY_PREFIX/dyolink-backend:TAG` | Gitea `v*` or Hub `dyolink/…` |
| frontend | `REGISTRY_PREFIX/dyolink-frontend:TAG` | URLs baked in at **build time** |
| backend | `dyolink/dyolink-backend:latest` | Runs migrations + seed on start |
| frontend | `dyolink/dyolink-frontend:latest` | URLs baked in at **build time** |
| nginx | `nginx:alpine` | SSL termination + reverse proxy |
| certbot | `certbot/certbot` | Auto-renews certificates |
@@ -361,19 +289,16 @@ Open `https://YOUR_DOMAIN` in a browser.
## Updating the app (new release)
**Preferred:** `git tag vX.Y.Z && git push origin vX.Y.Z` (Gitea production workflow).
**Fallback (Docker Hub from a Mac):**
**On Mac** — build & push:
```bash
./infrastructure/scripts/build-and-push-prod.sh nudentic.ir v1.0.1
./infrastructure/scripts/build-and-push-prod.sh wixur.ir latest
```
**On server** (if not using CI):
**On server:**
```bash
cd /opt/dyolink/infrastructure
# TAG in .env or: TAG=v1.0.1 docker compose -f docker-compose.prod.yml --env-file .env pull backend frontend
docker compose -f docker-compose.prod.yml --env-file .env pull backend frontend
docker compose -f docker-compose.prod.yml --env-file .env up -d
```
@@ -436,23 +361,6 @@ docker volume rm dyolink_postgres_data_prod
./scripts/deploy-prod.sh
```
### Docker login to `wixur.ir:3000`: `http response to HTTPS client`
Add `"insecure-registries": ["wixur.ir:3000"]` to `/etc/docker/daemon.json` and restart Docker.
### `connection reset by peer` while pulling `:v*` from Gitea
`docker login` succeeded, then `compose pull` failed on a frontend/backend manifest or layer from `wixur.ir:3000`. Auth is fine — the Windows Gitea registry dropped the TCP connection (common right after a large push, or when both images pull in parallel).
Images are already in the registry. Re-run only the **deploy** job, or on the VPS:
```bash
cd /opt/dyolink/infrastructure
./scripts/prod-remote-deploy.sh v1.0.2
```
`prod-remote-deploy.sh` pulls backend then frontend with retries. If every attempt RSTs, check Gitea is up and `insecure-registries` includes `wixur.ir:3000`.
### View logs
```bash
@@ -477,13 +385,12 @@ docker compose -f docker-compose.prod.yml --env-file .env exec frontend \
| Path on server | Purpose |
|----------------|---------|
| `/opt/dyolink/infrastructure/.env` | `DOMAIN`, `REGISTRY_PREFIX`, `TAG`, Let's Encrypt email |
| `/opt/dyolink/infrastructure/.env` | Domain, Docker Hub user, Let's Encrypt email |
| `/opt/dyolink/secrets/database.env` | Postgres credentials |
| `/opt/dyolink/secrets/backend.env` | API secrets, DATABASE_URL, JWT, SMS, `FRONTEND_URL` |
| `/opt/dyolink/secrets/backend.env` | API secrets, DATABASE_URL, JWT, SMS |
| `/opt/dyolink/infrastructure/nginx/generated/default.conf` | Auto-generated nginx SSL config |
| `/opt/dyolink/infrastructure/scripts/prod-remote-deploy.sh` | Tag deploy (CI SSH) |
| `/opt/dyolink/infrastructure/scripts/deploy-prod.sh` | First-time SSL + stack up |
| `.gitea/workflows/prod-tag-deploy.yml` | CI: build, push `:v*`, SSH deploy |
| `/opt/dyolink/infrastructure/scripts/deploy-prod.sh` | Main deploy entry point |
| `/opt/dyolink/infrastructure/scripts/build-and-push-prod.sh` | Build & push (run on Mac) |
---
@@ -497,7 +404,6 @@ docker compose -f docker-compose.prod.yml --env-file .env exec frontend \
- [ ] `.env`, `database.env`, `backend.env` configured (real passwords + JWT)
- [ ] `./scripts/deploy-prod.sh` completed
- [ ] `curl https://DOMAIN/api/health` returns `{"status":"ok",...}`
- [ ] `https://DOMAIN/admin` shows the AdminJS login (not the Next.js app)
- [ ] App loads in browser
---

View File

@@ -1,350 +0,0 @@
# Dyolink — Staging deploy (Gitea + Windows)
Automatic staging on a **self-hosted Gitea** machine: merge (or push) to **`master`** → build Docker images → push to Gitea Container Registry → deploy on the same host.
| Public URL | What |
|------------|------|
| `https://wixur.ir` | Staging app (port **443** → Windows nginx → Docker). Mobinnet: public **80** is the modem. |
| `http://wixur.ir:8088` | HTTP fallback |
| `http://wixur.ir:3000` | Gitea + container registry |
DNS `wixur.ir` must point at the **Windows** host. Production (`https://nudentic.ir` on Linux) is separate — see [`DEPLOY.md`](DEPLOY.md). Do **not** point `wixur.ir` at the Linux VPS.
---
## Architecture
```
Push / merge to master
Gitea Actions (self-hosted act_runner on Windows)
Build backend + frontend → push to Gitea registry
docker compose pull + up -d (docker-compose.registry.yml)
https://wixur.ir → Windows nginx :443 → 127.0.0.1:18088 → Docker nginx → app
http://wixur.ir:8088 → portproxy :8088 → 127.0.0.1:18088 (fallback)
http://wixur.ir:3000 → Gitea (native, no Docker)
```
| Service | Image source |
|----------|---------------------------------------------------|
| postgres | `postgres:15-alpine` (pulled from Docker Hub) |
| backend | `<REGISTRY_HOST>/<owner>/dyolink-backend:<sha>` |
| frontend | `<REGISTRY_HOST>/<owner>/dyolink-frontend:<sha>` |
| nginx | `nginx:alpine` |
Frontend public URLs are **baked in at build time** via `PUBLIC_BASE_URL`. After changing the public URL, re-run the Gitea workflow (or push to `master`) and set `FRONTEND_URL` in `C:\dyolink\secrets\backend.staging.env` to the same origin.
---
## Cut over from `:8088` to `http://wixur.ir`
DNS A record for `wixur.ir` → Windows IP (already done if `http://wixur.ir:3000` and `:8088` work).
On Windows: host nginx on **80** proxies to Docker **18088** ([`nginx/windows-edge-http.conf`](nginx/windows-edge-http.conf)). Remove portproxy on **80** (keep **8088** as fallback). Router must forward **80**. See §7.
In Gitea → repo → **Settings → Actions → Variables**:
- `PUBLIC_BASE_URL` = `https://wixur.ir`
- `STAGING_HTTP_PORT` = `80` (optional; workflow default is 80)
On disk: `FRONTEND_URL=https://wixur.ir` and `COOKIE_SECURE=true` in `C:\dyolink\secrets\backend.staging.env`.
Then run the **Registry — build, push, deploy** workflow so the frontend image is rebuilt without `:8088`.
---
---
## Triggers
| Event | Staging deploy? |
|-------|-----------------|
| Push to `master` | Yes |
| PR merged into `master` | Yes (merge = push to `master`) |
| Push to other branches only | No |
| Git tag (e.g. `v1.0.0`) | No — reserved for production later |
| Manual | Gitea → Actions → **Registry — build, push, deploy** → Run workflow |
Workflow file: [`.gitea/workflows/registry-build-deploy.yml`](../.gitea/workflows/registry-build-deploy.yml)
---
## One-time setup (Windows host)
### 1. Docker Desktop
- Install and keep **Docker Desktop running** during builds/deploys.
- **Settings → Docker Engine** — allow HTTP registry:
```json
{
"insecure-registries": [
"host.docker.internal:3000",
"127.0.0.1:3000",
"wixur.ir:3000"
]
}
```
Apply & restart Docker Desktop.
**Why `host.docker.internal`?** Docker Desktop runs in a Linux VM. `docker login` runs from that VM — `127.0.0.1:3000` is the VM, not Gitea on Windows.
### 2b. Gitea `app.ini` — match registry URL
Edit `C:\gitea\custom\conf\app.ini`:
```ini
[server]
ROOT_URL = http://host.docker.internal:3000/
```
Restart Gitea. Gitea uses `ROOT_URL` for Docker registry auth redirects.
### 2c. Gitea Actions runner
Download [act_runner](https://gitea.com/gitea/act_runner/releases) → e.g. `C:\gitea-runner\act_runner.exe`.
**Important — runner level:** the registration token decides which repos can use the runner.
| Token from | Runner level | Works for `admin/dyolink`? |
|------------|--------------|----------------------------|
| **Site Administration → Actions → Runners** | Instance (global) | Yes (recommended) |
| **Repo → Settings → Actions → Runners** | Repository | Yes |
| **User profile → Settings → Actions → Runners** | Individual | Often **no** — jobs stay queued with “no matching online runner” |
If your runner shows **Type: Individual** in Gitea but jobs never start, delete it and re-register with an **instance** or **repository** token (table above).
Register (use **localhost** when Gitea runs on the same PC — public IP often fails locally):
```powershell
cd C:\gitea-runner
# Stop daemon first (Ctrl+C) if running
.\act_runner.exe register `
--instance "http://127.0.0.1:3000" `
--token "<token-from-Site-Admin-OR-repo-Runners-page>" `
--name "windows-staging" `
--labels "windows:host"
```
Start (leave running, or install as a Windows service later):
```powershell
.\act_runner.exe daemon
```
**Verify:** open **your repo****Settings → Actions → Runners**`windows-staging` must appear here as **Idle/Online** (not only under user settings).
**Requires:** `git` on PATH (for workflow checkout step).
### 3. Secret env files (not in git)
```powershell
New-Item -ItemType Directory -Force -Path "C:\dyolink\secrets"
```
Copy examples and edit:
- `infrastructure/database.staging.env.example``C:\dyolink\secrets\database.staging.env`
- `infrastructure/backend.staging.env.example``C:\dyolink\secrets\backend.staging.env`
Rules:
- `DATABASE_URL` password must match `POSTGRES_PASSWORD`.
- `FRONTEND_URL` must match `PUBLIC_BASE_URL` (`http://wixur.ir`).
- Replace JWT secrets with long random values (not `CHANGE_ME`).
### 4. Gitea repository Variables
**Settings → Actions → Variables**
| Name | Example | Notes |
|------|---------|--------|
| `REGISTRY_HOST` | `host.docker.internal:3000` | **Windows + Docker Desktop:** Docker runs in a Linux VM — `127.0.0.1` is the VM, not Gitea. Use `host.docker.internal:3000`. Gitea `ROOT_URL` should match this so registry login from CI works. Browsers can use `http://wixur.ir:3000` or `https://gitea.wixur.ir`. **Do not** set this to `gitea.wixur.ir` unless that HTTPS proxy allows unlimited body size (see 413 below). |
| `REGISTRY_OWNER` | `admin` | Gitea user/org owning packages |
| `PUBLIC_BASE_URL` | `https://wixur.ir` | How **users** open staging (HTTPS on 443). No trailing slash. |
| `DEPLOY_SECRETS_DIR` | `C:/dyolink/secrets` | Forward slashes OK on Windows |
| `CLONE_HOST` | `127.0.0.1:3000` | Git clone (runs on Windows host, not inside Docker VM) |
| `STAGING_HTTP_PORT` | `80` | Public HTTP port (portproxy). Optional; workflow default is 80. |
| `STAGING_LOCAL_PORT` | `18088` | Docker bind on `127.0.0.1`. Must not be `80` if portproxy already uses 80. |
**Naming note:** Gitea rejects variable names starting with `GITEA_` or `GITHUB_`. Use `CLONE_HOST`, not `GITEA_CLONE_URL`.
### 5. Gitea repository Secrets
**Settings → Actions → Secrets**
| Name | Value |
|------|--------|
| `REGISTRY_USERNAME` | Gitea username |
| `REGISTRY_PASSWORD` | Gitea access token with **package read/write** |
Create token: profile → **Settings → Applications → Generate New Token**.
### 6. Firewall (once)
```powershell
New-NetFirewallRule -DisplayName "Dyolink Staging HTTP 80" -Direction Inbound -Protocol TCP -LocalPort 80 -Action Allow
# Optional fallback while cutting over from :8088
New-NetFirewallRule -DisplayName "Dyolink Staging 8088" -Direction Inbound -Protocol TCP -LocalPort 8088 -Action Allow
```
### 7. Port 80 = host nginx (not portproxy)
Gitea stays on **:3000**. Docker staging nginx binds **127.0.0.1:18088**. A **Windows nginx** (the 1.29.x you already have) listens on **80** and proxies to 18088. Config: [`nginx/windows-edge-http.conf`](nginx/windows-edge-http.conf).
**Do not** run portproxy on port 80 at the same time — it will lose to nginx (404 / hang). Keep portproxy **8088 → 18088** as fallback.
```powershell
# 1) Free port 80 from portproxy (nginx will bind 80)
netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=80
# 2) Confirm Docker staging is up
curl.exe http://127.0.0.1:18088/health
# 3) Install windows-edge-http.conf into host nginx, then:
# nginx -t
# nginx -s reload
# (paths depend on where nginx is installed)
# 4) Must print "healthy" (not 404)
curl.exe http://127.0.0.1/health
```
Replace any **default_server** / leftover `server { listen 80; }` in the host nginx that returns 404, or this file will never win.
From another machine (after **router forward TCP 80** → this PC):
```bash
curl http://wixur.ir/health
```
**Mobinnet:** public **80** is the modem. Use **443** instead: router forward **TCP 443 → 192.168.1.100**, then `https://wixur.ir`. Let's Encrypt after mobile-data `/health` works (self-signed is enough for that test).
---
## Test the pipeline
### Before first run
- [ ] Docker Desktop running
- [ ] `act_runner.exe daemon` running
- [ ] All Variables + Secrets set (including `CLONE_HOST`)
- [ ] Secret env files exist under `DEPLOY_SECRETS_DIR`
- [ ] Workflow enabled on `master` (see repo)
### Option A — Manual workflow (safest first test)
1. Gitea → repo → **Actions**
2. **Registry — build, push, deploy****Run workflow** → branch `master`
3. Watch jobs: **build-and-push****deploy** (first run ~1530 min)
### Option B — Push to master
```bash
git push origin master
```
Or merge a PR into `master` — same result.
### Verify success
**On Windows (PowerShell):**
```powershell
docker ps
```
Expect: `dyolink_nginx_staging`, `dyolink_backend_staging`, `dyolink_frontend_staging`, `dyolink_postgres_staging`.
**From browser or another machine:**
```text
http://wixur.ir
```
**Health check:**
```powershell
curl http://wixur.ir/api/health
```
Expected: `{"status":"ok",...}`
**Gitea packages:** profile/org → **Packages** — should list `dyolink-backend` and `dyolink-frontend` after first build.
---
## Manual deploy (without CI)
Useful when debugging registry/compose without re-running the full workflow.
On the Windows host, from repo `infrastructure/`:
1. Create `deploy.registry.env` from [`deploy.registry.env.example`](deploy.registry.env.example)
2. Set `REGISTRY_PREFIX`, `IMAGE_TAG`, `STAGING_HTTP_PORT`, `DEPLOY_SECRETS_DIR`
3. `docker login host.docker.internal:3000 -u <user>` (on Windows Docker Desktop; Linux prod will use `wixur.ir:3000`)
4. `docker compose -f docker-compose.registry.yml --env-file deploy.registry.env pull backend frontend`
5. `docker compose -f docker-compose.registry.yml --env-file deploy.registry.env up -d`
---
## Troubleshooting
| Symptom | Fix |
|---------|-----|
| `no matching online runner with label` | Runner **offline** → start `act_runner.exe daemon`. Or wrong **runner level** → re-register with token from **Site Administration → Actions → Runners** or **repo → Settings → Actions → Runners** (not user profile). Confirm runner appears on **repo** Runners page as Online. |
| Runner can't register on public IP | Use `http://127.0.0.1:3000` for `--instance` |
| Variable name rejected in Gitea | No `GITEA_*` / `GITHUB_*` prefixes; use `CLONE_HOST` |
| `413 Request Entity Too Large` on `docker push` to `https://gitea.wixur.ir/v2/…/blobs/uploads` | Nginx (or Cloudflare) in front of Gitea is rejecting the image layer. **Fix the proxy** (then `nginx -s reload`): in the `server { server_name gitea.wixur.ir; }` block set `client_max_body_size 0;` and `proxy_request_buffering off;` — snippet: [`nginx/windows-gitea.wixur.snippet.conf`](nginx/windows-gitea.wixur.snippet.conf). **Or skip the proxy:** set `REGISTRY_HOST=host.docker.internal:3000` (and Gitea `ROOT_URL`) so CI pushes to `:3000`. If the hostname is orange-clouded on Cloudflare, grey-cloud it (free plan caps uploads at 100MB). |
| `docker login` connection refused on `127.0.0.1:3000` | **Docker Desktop on Windows:** set `REGISTRY_HOST=host.docker.internal:3000`, add it to insecure-registries, set Gitea `ROOT_URL=http://host.docker.internal:3000/`. Keep `CLONE_HOST=127.0.0.1:3000` for git. |
| `docker login` / push denied, redirect to public IP | Set Gitea `ROOT_URL` to a host Docker can reach (`host.docker.internal:3000` on Windows Docker Desktop). |
| `server gave HTTP response to HTTPS client` | Add registry host to Docker **insecure-registries**, restart Docker |
| `Missing database.staging.env` | Check `DEPLOY_SECRETS_DIR` path and file names |
| `docker login` denied | Token needs package permissions; check username/secret |
| Git clone fails in workflow | Set `CLONE_HOST=127.0.0.1:3000` |
| Port 80 bind forbidden / access permissions | Portproxy already owns 80. Bind Docker to `18088` and point portproxy **80 → 127.0.0.1:18088**. Free IIS if it holds 80. |
| `http://wixur.ir` unreachable / empty reply | Docker Desktop: run **portproxy** (§7). `127.0.0.1:18088/health` must work on Windows first. Router must forward **80**. |
| Port 8088 bind forbidden / access permissions | Portproxy already owns 8088. Bind Docker to `18088` and point portproxy **8088 → 127.0.0.1:18088**. |
| Port 8088 unreachable from Mac / empty reply | Legacy URL. Prefer `http://wixur.ir`. Same 18088 backend; add portproxy 8088 only as fallback. |
| Backend restart loop | JWT secrets still placeholder; fix `backend.staging.env` |
| Backend DB auth error | `DATABASE_URL` password ≠ `POSTGRES_PASSWORD` |
| `dumb-init docker-entrypoint.sh: No such file or directory` | Windows CRLF in shell scripts — fixed in Dockerfiles (rebuild images). |
| `frontend is unhealthy` / deploy waits on frontend | Next.js `/` redirects to `/en` (3xx). Rebuild after healthcheck fix (accepts 2xx/3xx). |
**Logs:**
```powershell
docker logs dyolink_backend_staging --tail 50
docker logs dyolink_nginx_staging --tail 50
docker logs dyolink_frontend_staging --tail 50
```
---
## File reference
| Path | Role |
|------|------|
| `.gitea/workflows/registry-build-deploy.yml` | CI: build, push, deploy |
| `infrastructure/docker-compose.registry.yml` | Staging stack (pull-only images) |
| `infrastructure/deploy.registry.env.example` | Manual deploy env template |
| `infrastructure/database.staging.env.example` | Postgres secrets template |
| `infrastructure/backend.staging.env.example` | API secrets template |
| `infrastructure/nginx/http-only.conf` | HTTP reverse proxy **inside Docker** staging |
| `infrastructure/nginx/windows-edge-http.conf` | Windows **host** nginx on port 80/443 → 18088 |
| `infrastructure/nginx/windows-gitea.wixur.snippet.conf` | Windows nginx vhost for `https://gitea.wixur.ir` (unlimited body — Docker push) |
---
## Production (Linux + tags)
| Environment | Trigger | Host |
|-------------|---------|------|
| Staging | Push/merge to `master` | Windows + `https://wixur.ir` |
| Production | Git tag `v*.*.*` | Linux + `https://nudentic.ir` |
Workflow: [`.gitea/workflows/prod-tag-deploy.yml`](../.gitea/workflows/prod-tag-deploy.yml). Do **not** push production images as `:latest`. Setup: [`DEPLOY.md`](DEPLOY.md).

View File

@@ -12,12 +12,7 @@ JWT_REFRESH_SECRET=replace_with_a_different_openssl_rand_hex_32_output
JWT_REFRESH_EXPIRES_IN=30d
# Must match DOMAIN in .env — used for CORS, invite links, cookies
FRONTEND_URL=https://nudentic.ir
# AdminJS at https://nudentic.ir/admin (nginx proxies /admin to the API).
# Change these — the code defaults are only for local development.
ADMINJS_EMAIL=admin@nudentic.ir
ADMINJS_PASSWORD=CHANGE_ME_STRONG_ADMINJS_PASSWORD
FRONTEND_URL=https://wixur.ir
# Required for HTTPS — browsers reject Secure cookies over plain HTTP
COOKIE_SECURE=true
@@ -25,7 +20,3 @@ COOKIE_SECURE=true
# SMS (sms.ir)
SMS_IR_API_KEY=CHANGE_ME_SMS_IR_API_KEY
SMS_IR_TEMPLATE_ID=123456
# GlitchTip (Sentry SDK). Empty = disabled. Use the dyolink-backend project DSN.
# SENTRY_DSN=https://PUBLIC_KEY@errors.wixur.ir/1
# SENTRY_ENVIRONMENT=production

View File

@@ -9,20 +9,12 @@ JWT_EXPIRES_IN=15m
JWT_REFRESH_SECRET=another_long_random_secret_different_from_JWT_SECRET
JWT_REFRESH_EXPIRES_IN=30d
# CORS, cookies, and invite links — must match how users open the app
FRONTEND_URL=https://wixur.ir
# CORS, cookies, and invite links — must match how users open the app (nginx host port)
FRONTEND_URL=http://178.131.50.201:8088
# AdminJS at https://wixur.ir/admin (nginx proxies /admin to the API).
ADMINJS_EMAIL=admin@wixur.ir
ADMINJS_PASSWORD=CHANGE_ME_STRONG_ADMINJS_PASSWORD
# TLS is terminated on Windows nginx :443 — cookies must be Secure
COOKIE_SECURE=true
# HTTP staging — keep false unless you terminate TLS in front of the app
COOKIE_SECURE=false
# SMS (sms.ir)
SMS_IR_API_KEY=CHANGE_ME_SMS_IR_API_KEY
SMS_IR_TEMPLATE_ID=123456
# GlitchTip (Sentry SDK). Empty = disabled. Use the dyolink-backend project DSN.
# SENTRY_DSN=https://PUBLIC_KEY@errors.wixur.ir/1
# SENTRY_ENVIRONMENT=staging

View File

@@ -1,27 +1,15 @@
# Copy to infrastructure/.env on the Linux server (not committed).
# Copy to infrastructure/.env on the server (not committed).
# docker compose -f docker-compose.prod.yml --env-file .env ...
DOMAIN=nudentic.ir
# Gitea Container Registry (tag auto-deploy). Same packages Windows CI pushes
# via host.docker.internal:3000 — Linux pulls via this hostname.
# Format: <host>:<port>/<owner> — no http://
REGISTRY_PREFIX=wixur.ir:3000/admin
# Image tag — CI sets this per release (v1.0.1). Do not use latest for Gitea prod
# (staging already owns :latest with the wixur.ir frontend bake).
TAG=v1.0.1
# Hub-only fallback: comment out REGISTRY_PREFIX and use:
# REGISTRY_PREFIX=dyolink
# TAG=latest
DOMAIN=wixur.ir
DOCKER_USERNAME=dyolink
TAG=latest
# Let's Encrypt — certificate issuance and renewal notices
LETSENCRYPT_EMAIL=rameen.naghdi@gmail.com
# Optional: extra hostnames on the same cert (space-separated)
# CERTBOT_EXTRA_DOMAINS=www.nudentic.ir
# Optional: extra hostnames on the same cert (space-separated), e.g. www.wixur.ir
# CERTBOT_EXTRA_DOMAINS=www.wixur.ir
# Optional: use Let's Encrypt staging while testing (avoids rate limits)
# LETSENCRYPT_STAGING=1

View File

@@ -6,20 +6,14 @@
# --- Registry boundary (swap when moving Gitea → Docker Hub) ---
# Gitea: REGISTRY_PREFIX = <host>:<port>/<owner>
# Hub: REGISTRY_PREFIX = docker.io/<user> (or your username for implicit hub)
# Windows CI pushes via host.docker.internal:3000; browsers/Linux use wixur.ir:3000 (same Gitea).
REGISTRY_PREFIX=wixur.ir:3000/yourgiteauser
REGISTRY_PREFIX=178.131.50.201:3000/yourgiteauser
# Short git SHA from CI, or "latest" after a manual pull of :latest
IMAGE_TAG=latest
# Public HTTP port (router + Windows portproxy listen here) — 80 so users open http://wixur.ir
STAGING_HTTP_PORT=80
# Docker nginx bind on localhost only (must differ from 80 if portproxy owns 80)
STAGING_LOCAL_PORT=18088
# Host port published for nginx (URL = http://<your-ip>:<this-port>)
STAGING_HTTP_PORT=8088
# Absolute path on the server where database.staging.env and backend.staging.env live.
# Use forward slashes on Windows. Same path as Gitea Actions variable DEPLOY_SECRETS_DIR.
# DEPLOY_SECRETS_DIR=C:/dyolink/secrets
#
# Gitea Actions also needs CLONE_HOST=127.0.0.1:3000 when runner and Gitea share one Windows host.
# See STAGING-DEPLOY.md (do not use GITEA_* variable names — Gitea rejects them).
# Use forward slashes on Windows. Same variable as Gitea Actions DEPLOY_SECRETS_DIR.
# DEPLOY_SECRETS_DIR=D:/dyolink/secrets

View File

@@ -1,11 +1,13 @@
# Production stack — pull backend/frontend from Gitea (or Docker Hub fallback).
# HTTPS via Let's Encrypt (certbot) on this host only (nudentic.ir).
# Production stack — pull images from Docker Hub, HTTPS via Let's Encrypt (certbot).
#
# Gitea tag deploy: set REGISTRY_PREFIX=wixur.ir:3000/<owner> and TAG=v1.0.1 in .env
# Hub fallback: omit REGISTRY_PREFIX, set DOCKER_USERNAME=dyolink (image dyolink/dyolink-*).
# Server setup (minimal):
# 1. Copy deploy.prod.env.example → .env (DOMAIN, DOCKER_USERNAME, LETSENCRYPT_EMAIL)
# 2. Copy secrets/*.example → ../secrets/ (database.env, backend.env) — outside git
# 3. docker login (private Docker Hub images)
# 4. ./scripts/init-letsencrypt.sh (first time only)
# 5. docker compose -f docker-compose.prod.yml --env-file .env up -d
#
# First SSL: ./scripts/init-letsencrypt.sh then up -d
# Tag updates: CI runs scripts/prod-remote-deploy.sh (or pull + up locally)
# Updates: docker compose pull && docker compose up -d
name: dyolink-prod
@@ -37,7 +39,7 @@ services:
start_period: 40s
backend:
image: ${REGISTRY_PREFIX:-dyolink}/dyolink-backend:${TAG:-latest}
image: ${DOCKER_USERNAME}/dyolink-backend:${TAG:-latest}
container_name: dyolink_backend_prod
depends_on:
postgres:
@@ -48,8 +50,6 @@ services:
NODE_ENV: production
TZ: UTC
PORT: "3000"
SENTRY_ENVIRONMENT: production
SENTRY_RELEASE: ${TAG:-latest}
expose:
- "3000"
networks:
@@ -68,7 +68,7 @@ services:
start_period: 60s
frontend:
image: ${REGISTRY_PREFIX:-dyolink}/dyolink-frontend:${TAG:-latest}
image: ${DOCKER_USERNAME}/dyolink-frontend:${TAG:-latest}
container_name: dyolink_frontend_prod
depends_on:
- backend
@@ -88,8 +88,7 @@ services:
max-size: "10m"
max-file: "3"
healthcheck:
# Next.js `/` redirects to `/en` — accept 2xx/3xx.
test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:3000/', (r) => { process.exit(r.statusCode >= 200 && r.statusCode < 400 ? 0 : 1); }).on('error', () => process.exit(1))"]
test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:3000/', (r) => {if(r.statusCode!==200)process.exit(1)})"]
interval: 30s
timeout: 10s
retries: 3

View File

@@ -2,7 +2,7 @@
# No backend/frontend source on the deployment host except this compose file + config + secrets.
#
# Required env (see deploy.registry.env.example):
# REGISTRY_PREFIX e.g. host.docker.internal:3000/admin (Windows CI) or wixur.ir:3000/admin (no protocol)
# REGISTRY_PREFIX e.g. 178.131.50.201:3000/yourgiteauser (no protocol, no trailing slash)
# IMAGE_TAG short sha or "latest" (CI sets this per deploy)
# Optional:
# DEPLOY_SECRETS_DIR absolute path on the server to database/backend *.env files (see below)
@@ -47,8 +47,6 @@ services:
NODE_ENV: production
TZ: UTC
PORT: "3000"
SENTRY_ENVIRONMENT: staging
SENTRY_RELEASE: ${IMAGE_TAG:-latest}
expose:
- "3000"
networks:
@@ -77,8 +75,7 @@ services:
- dyolink_staging
restart: unless-stopped
healthcheck:
# Root redirects to /en (next-intl) — accept 2xx/3xx as healthy.
test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:3000/', (r) => { process.exit(r.statusCode >= 200 && r.statusCode < 400 ? 0 : 1); }).on('error', () => process.exit(1))"]
test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:3000/', (r) => {if(r.statusCode!==200)process.exit(1)})"]
interval: 30s
timeout: 10s
retries: 3
@@ -88,14 +85,10 @@ services:
image: nginx:alpine
container_name: dyolink_nginx_staging
depends_on:
backend:
condition: service_healthy
frontend:
condition: service_healthy
- backend
- frontend
ports:
# Bind a private localhost port — Windows portproxy owns public 80 (http://wixur.ir).
# See STAGING-DEPLOY.md §7: portproxy 0.0.0.0:80 → 127.0.0.1:18088
- "127.0.0.1:${STAGING_LOCAL_PORT:-18088}:80"
- "${STAGING_HTTP_PORT:-8088}:80"
volumes:
- ./nginx/http-only.conf:/etc/nginx/conf.d/default.conf:ro
- ./logs/nginx-staging:/var/log/nginx

View File

@@ -43,7 +43,6 @@ services:
NODE_ENV: production
TZ: UTC
PORT: "3000"
SENTRY_ENVIRONMENT: staging
expose:
- "3000"
networks:
@@ -61,11 +60,9 @@ services:
context: ../frontend
dockerfile: Dockerfile
args:
NEXT_PUBLIC_API_URL: ${STAGING_NEXT_PUBLIC_API_URL:-http://wixur.ir/api}
NEXT_PUBLIC_APP_URL: ${STAGING_NEXT_PUBLIC_APP_URL:-http://wixur.ir}
NEXT_PUBLIC_API_URL: ${STAGING_NEXT_PUBLIC_API_URL:-http://178.131.50.201:8088/api}
NEXT_PUBLIC_APP_URL: ${STAGING_NEXT_PUBLIC_APP_URL:-http://178.131.50.201:8088}
NEXT_PUBLIC_APP_NAME: ${STAGING_NEXT_PUBLIC_APP_NAME:-Dyolink}
NEXT_PUBLIC_SENTRY_DSN: ${NEXT_PUBLIC_SENTRY_DSN:-}
NEXT_PUBLIC_SENTRY_ENVIRONMENT: staging
container_name: dyolink_frontend_staging
depends_on:
- backend
@@ -80,8 +77,7 @@ services:
- dyolink_staging
restart: unless-stopped
healthcheck:
# Root redirects to /en (next-intl) — accept 2xx/3xx as healthy.
test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:3000/', (r) => { process.exit(r.statusCode >= 200 && r.statusCode < 400 ? 0 : 1); }).on('error', () => process.exit(1))"]
test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:3000/', (r) => {if(r.statusCode!==200)process.exit(1)})"]
interval: 30s
timeout: 10s
retries: 3
@@ -91,12 +87,10 @@ services:
image: nginx:alpine
container_name: dyolink_nginx_staging
depends_on:
backend:
condition: service_healthy
frontend:
condition: service_healthy
- backend
- frontend
ports:
- "127.0.0.1:${STAGING_LOCAL_PORT:-18088}:80"
- "${STAGING_HTTP_PORT:-8088}:80"
volumes:
- ./nginx/http-only.conf:/etc/nginx/conf.d/default.conf:ro
- ./logs/nginx-staging:/var/log/nginx

View File

@@ -1,13 +1,13 @@
# Copy to .env.staging next to docker-compose.staging.yml (optional).
# Used only for compose variable substitution (build args, host port).
STAGING_HTTP_PORT=80
STAGING_HTTP_PORT=8088
# Public URLs baked into the frontend image at build time — must match how users open the app.
STAGING_NEXT_PUBLIC_API_URL=http://wixur.ir/api
STAGING_NEXT_PUBLIC_APP_URL=http://wixur.ir
STAGING_NEXT_PUBLIC_API_URL=http://178.131.50.201:8088/api
STAGING_NEXT_PUBLIC_APP_URL=http://178.131.50.201:8088
STAGING_NEXT_PUBLIC_APP_NAME=Dyolink
# Must match how users open staging (http://wixur.ir).
# Change the IP/port if your server address differs.
# Registry / pull-only deploy (see deploy.registry.env.example + docker-compose.registry.yml).

View File

@@ -1,10 +1,15 @@
# HTTP only — local dev and IP-based staging (no TLS).
# Use with: docker compose and map host port e.g. 8080:80 or 8088:80
#
# Dynamic proxy_pass via Docker DNS (127.0.0.11) so nginx starts even if
# backend/frontend containers are not up yet (static upstream blocks fail at boot).
resolver 127.0.0.11 valid=10s ipv6=off;
upstream dyolink_backend {
server backend:3000;
keepalive 32;
}
upstream dyolink_frontend {
server frontend:3000;
keepalive 32;
}
server {
listen 80;
@@ -14,8 +19,7 @@ server {
client_max_body_size 50M;
location / {
set $frontend_upstream http://frontend:3000;
proxy_pass $frontend_upstream;
proxy_pass http://dyolink_frontend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
@@ -29,24 +33,7 @@ server {
}
location /api {
set $backend_upstream http://backend:3000;
proxy_pass $backend_upstream;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300;
proxy_connect_timeout 300;
}
# AdminJS (Nest, not under /api)
location /admin {
set $backend_upstream http://backend:3000;
proxy_pass $backend_upstream;
proxy_pass http://dyolink_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';

View File

@@ -46,21 +46,6 @@ server {
proxy_connect_timeout 300;
}
# AdminJS (Nest, not under /api)
location /admin {
proxy_pass http://dyolink_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300;
proxy_connect_timeout 300;
}
location /health {
access_log off;
return 200 "healthy\n";

View File

@@ -86,21 +86,6 @@ server {
proxy_read_timeout 300;
proxy_connect_timeout 300;
}
# AdminJS (Nest, not under /api)
location /admin {
proxy_pass http://dyolink_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300;
proxy_connect_timeout 300;
}
# Health check endpoint (no logging)
location /health {

View File

@@ -83,21 +83,6 @@ server {
proxy_connect_timeout 300;
}
# AdminJS (Nest, not under /api) — https://nudentic.ir/admin
location /admin {
proxy_pass http://dyolink_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300;
proxy_connect_timeout 300;
}
location /health {
access_log off;
return 200 "healthy\n";

View File

@@ -1,88 +0,0 @@
# Windows host nginx.conf — paste over
# C:\tools\nginx-1.29.5\conf\nginx.conf
#
# :80 — LAN / 127.0.0.1 only (Mobinnet keeps public :80 for the modem)
# :443 — public https://wixur.ir (router must forward 443 → this PC)
#
# Put Let's Encrypt PEMs in conf\ssl\ (win-acme --pemfilesname wixur):
# ssl\wixur-chain.pem
# ssl\wixur-key.pem
#
# After save: nginx -t then restart nginx
# curl.exe https://wixur.ir/health → healthy
# (curl to 127.0.0.1:443 fails name-check; cert is for wixur.ir)
#user nobody;
worker_processes 1;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
# Unlimited at http{} so a gitea.wixur.ir vhost can inherit it (Docker layers
# 413 with 50M). App server blocks below cap uploads at 50M.
client_max_body_size 0;
# Shared proxy to Docker staging
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name wixur.ir www.wixur.ir localhost 127.0.0.1;
client_max_body_size 50M;
location / {
proxy_pass http://127.0.0.1:18088;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300;
proxy_connect_timeout 300;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name wixur.ir www.wixur.ir localhost 127.0.0.1;
ssl_certificate ssl/wixur-chain.pem;
ssl_certificate_key ssl/wixur-key.pem;
ssl_protocols TLSv1.2 TLSv1.3;
client_max_body_size 50M;
location / {
proxy_pass http://127.0.0.1:18088;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300;
proxy_connect_timeout 300;
}
}
# Gitea + container registry (https://gitea.wixur.ir). Optional include —
# copy windows-gitea.wixur.snippet.conf next to this file and uncomment:
# include windows-gitea.wixur.snippet.conf;
}

View File

@@ -1,58 +0,0 @@
# Fastest fix if you already have a gitea.wixur.ir server { } block: add only
# client_max_body_size 0;
# proxy_request_buffering off;
# inside that server (or its location /), then nginx -t && nginx -s reload.
# Do not add a second server_name gitea.wixur.ir — duplicate listen/ssl will fail.
#
# Full vhost (only if that host is not already in nginx.conf): paste inside http { }
# of C:\tools\nginx-1.29.5\conf\nginx.conf. Needs the $connection_upgrade map from
# windows-edge-http.conf.
#
# Certs: keep the ssl_certificate paths you already use for gitea.wixur.ir
# (the names below are placeholders).
#
# After save: nginx -t then nginx -s reload
server {
listen 80;
listen [::]:80;
server_name gitea.wixur.ir;
location /.well-known/acme-challenge/ {
root html;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name gitea.wixur.ir;
ssl_certificate ssl/gitea-chain.pem;
ssl_certificate_key ssl/gitea-key.pem;
ssl_protocols TLSv1.2 TLSv1.3;
# 0 = unlimited (Docker registry blob PUT)
client_max_body_size 0;
client_body_timeout 600s;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_request_buffering off;
proxy_buffering off;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 600;
proxy_connect_timeout 60;
proxy_send_timeout 600;
}
}

View File

@@ -1,4 +0,0 @@
# After win-acme writes Let's Encrypt PEMs into conf/ssl/:
# ssl_certificate ssl/wixur-chain.pem;
# ssl_certificate_key ssl/wixur-key.pem;
# (exact filenames: dir C:\tools\nginx-1.29.5\conf\ssl )

View File

@@ -1,60 +0,0 @@
#!/usr/bin/env bash
# Run on the Linux production host (CI SSHs here after pushing :v* images).
# Usage: ./prod-remote-deploy.sh v1.0.1
set -euo pipefail
TAG="${1:?usage: prod-remote-deploy.sh v1.0.1}"
INFRA="${PROD_INFRA_DIR:-/opt/dyolink/infrastructure}"
cd "$INFRA"
if [ ! -f .env ]; then
echo "Missing $INFRA/.env — copy deploy.prod.env.example and set DOMAIN=nudentic.ir REGISTRY_PREFIX=wixur.ir:3000/<owner>"
exit 1
fi
if ! grep -q '^REGISTRY_PREFIX=.\+' .env; then
echo "Set REGISTRY_PREFIX in .env (Gitea registry, e.g. wixur.ir:3000/admin). Do not use Docker Hub :latest for tag deploys."
exit 1
fi
export TAG
# Windows Gitea often RSTs concurrent or long pulls (connection reset by peer).
# Pull one image at a time with backoff so a flake does not fail the whole tag.
pull_one() {
local service="$1"
local attempt=1
local max=5
local delay=8
while [ "$attempt" -le "$max" ]; do
echo "Pulling $service :$TAG (attempt $attempt/$max)"
if docker compose -f docker-compose.prod.yml --env-file .env pull "$service"; then
return 0
fi
if [ "$attempt" -eq "$max" ]; then
echo "Failed to pull $service after $max attempts"
return 1
fi
echo "Pull of $service failed; retrying in ${delay}s..."
sleep "$delay"
delay=$((delay * 2))
attempt=$((attempt + 1))
done
}
echo "Pulling backend/frontend :$TAG from Gitea (REGISTRY_PREFIX in .env)"
pull_one backend
pull_one frontend
if [ -f nginx/nginx.ssl.conf.template ]; then
echo "Rendering nginx SSL config (includes /admin → backend)"
chmod +x scripts/render-nginx-ssl.sh
./scripts/render-nginx-ssl.sh
fi
docker compose -f docker-compose.prod.yml --env-file .env up -d --force-recreate nginx
docker compose -f docker-compose.prod.yml --env-file .env up -d
echo "=== Status ==="
docker compose -f docker-compose.prod.yml --env-file .env ps
echo "Health: https://nudentic.ir/api/health"
echo "AdminJS: https://nudentic.ir/admin"

View File

@@ -1,11 +0,0 @@
@echo off
REM Run as Administrator. Stops nginx so win-acme can bind 443 (TLS-ALPN),
REM renews, then starts nginx again. Point the win-acme scheduled task here
REM instead of calling wacs.exe directly.
cd /d C:\tools\nginx-1.29.5
taskkill /F /IM nginx.exe >nul 2>&1
timeout /t 2 /nobreak >nul
cd /d C:\tools\win-acme
wacs.exe --renew --closeonfinish
cd /d C:\tools\nginx-1.29.5
start "" nginx.exe