applyVoiceResult decided what to write and wrote it, in one callback inside a
3200-line client component. Nothing could reach it — not exported, and rendering
its component means mocking next-intl, the i18n router and six axios modules.
Three of the five defects found in live use sat in that callback, while 209
green tests covered the helpers around it.
buildVoiceApplyPlan(result, selection, ctx) -> { detail, labCaseDraft } now
holds the decision and writes nothing. The callback keeps only what a component
must do: setDetails, the ref writes the in-flight save reads, and the order that
lets lab rows carry a real treatmentDetailId. newDetail and newLabCaseDraft move
to treatmentDetailRules.ts so the pure module can build a draft without
importing a component.
21 tests. Verified they bite by reverting each bug in place: reading
labDependent from result.treatmentType fails 1, merging the plain teeth list
onto a prosthesis detail fails 2.
One group asserts an invariant that belongs to the backend — every
toothProsthesis row naming a real tooth must be in detail.teeth, which is
TREATMENT_TOOTH_NOT_ON_DETAIL at treatments.service.ts:806. It spans two
processes, so neither side could state it alone before.
Not covered, still manual: the labCaseDraftsRef timing needs a real render.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
112 lines
9.8 KiB
Markdown
112 lines
9.8 KiB
Markdown
# CLAUDE.md
|
|
|
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
|
|
## Read first
|
|
|
|
Project conventions already live in **`AGENTS.md`** (project map + per-feature quick-reference), **`.cursor/rules/*.mdc`** (short always-on / file-scoped rules), and **`.cursor/skills/*/SKILL.md`** (multi-step workflow playbooks). They are plain markdown — read the ones covering the area you touch **before** editing. This file covers only what those do not: commands and cross-cutting architecture.
|
|
|
|
Per `.cursor/rules/maintain-agent-docs.mdc`: when the user establishes a durable convention, update the matching `.mdc` rule or `SKILL.md` — not this file.
|
|
|
|
## Commands
|
|
|
|
There is **no root `package.json`**. Every npm command runs inside `backend/` or `frontend/`.
|
|
|
|
### Backend (`cd backend`)
|
|
|
|
| Command | Purpose |
|
|
|---|---|
|
|
| `npm run start:dev` | API on `http://localhost:3000/api`; Swagger `/api/docs`; AdminJS `/admin` |
|
|
| `npm run build` | **Verification gate for cross-cutting backend changes** |
|
|
| `npm test` | Jest (`src/**/*.spec.ts`) |
|
|
| `npm test -- lab-case-task.generator` | Single suite by path fragment |
|
|
| `npm test -- -t "merges teeth"` | Single test by name |
|
|
| `npm run test:e2e` | Jest with `test/jest-e2e.json` |
|
|
| `npm run lint` | ESLint with `--fix` |
|
|
| `docker compose -f docker-compose.postgres.yml up -d` | Dev Postgres (host port from `POSTGRES_PORT` in `.env`) |
|
|
| `npm run prisma:generate` / `prisma:migrate` / `prisma:seed` | Client, dev migration, reference-data upsert (seed never wipes) |
|
|
| `npm run prisma:export-prosthesis-catalog` | Rewrite `docs/prosthesis-catalog.xlsx` from `catalog-seed-data.ts` |
|
|
| `npx prisma migrate reset` | Dev clean slate — drop, re-migrate, re-seed. Never against staging/prod |
|
|
| `npm run prisma:wipe-app-data` / `prisma:reset-treatment` / `prisma:regenerate-tasks` | Targeted dev data scripts |
|
|
|
|
`DATABASE_URL` must use `localhost` when Nest runs on the host and Postgres in Docker.
|
|
|
|
### Frontend (`cd frontend`)
|
|
|
|
| Command | Purpose |
|
|
|---|---|
|
|
| `npm run dev` | Dev server on **3001** (3000 is the API) |
|
|
| `npx tsc --noEmit` | **Verification gate for any type or cross-cutting frontend change** |
|
|
| `npm run build` | Production build (`output: 'standalone'`) |
|
|
| `npm run lint` | ESLint via Next |
|
|
| `npx vitest run` | Vitest — pure helpers only (`prosthesisTree.ts`, `voiceReviewRows.ts`, `toothSelectionGroups.ts`, `voiceApply.ts`) |
|
|
|
|
`NEXT_PUBLIC_*` values are baked in at build time — restart `npm run dev` after changing `.env.local`.
|
|
|
|
### Git
|
|
|
|
Do not commit, push, amend, force-push, or skip hooks unless the user explicitly asks.
|
|
|
|
## Architecture
|
|
|
|
Dental **clinic ↔ lab** platform. Every user acts inside one `Organization` whose `type` is `CLINIC` (patients, appointments, treatment) or `LAB` (cases, tasks). Most features exist only for one side.
|
|
|
|
### Request identity: cookie JWT carrying the selected org
|
|
|
|
There is no `Authorization` header. `JwtStrategy` reads the httpOnly **`accessToken` cookie**, and the JWT payload carries `organizationId` — the org the user currently acts as. `POST /auth/select-organization` re-issues the token with a different org, so **switching orgs means a new token**, and every service scopes queries by `req.user.organizationId`.
|
|
|
|
On 401 the axios interceptor (`frontend/src/lib/api/client.ts`) refreshes, **re-selects** the org from `localStorage.currentOrganizationId`, then retries the original request — skipping that dance for auth endpoints and public invitation routes. `frontend/src/proxy.ts` (the Next middleware, exported as `proxy`) is a separate, cookie-only route gate that redirects unauthenticated users to `/{locale}/login?from=…`.
|
|
|
|
### Permissions
|
|
|
|
`TAB_*_READ` / `TAB_*_EDIT` codes in `backend/src/common/permissions.ts`; **EDIT implies READ**. Owners get org-type defaults merged with stored grants — always resolve via `hasEffectivePermission` / `getEffectivePermissionNames` in `common/membership-permissions.ts`, never by reading `membership.permissions` directly. Controllers stack `JwtAuthGuard` + `ClinicOrgGuard`/`LabOrgGuard`; feature-specific checks belong in the **service**.
|
|
|
|
### Error contract (spans 3 layers — change all of them)
|
|
|
|
`AppException(ErrorCode.X)` → `HttpExceptionFilter` → `{ success: false, error: { code } }` → axios normalizes to `ApiError` → `getUserFacingError(err, tErrors, fallback)` resolves `errors.X` from the message files. Adding a user-facing failure means: a code in `common/errors/error-codes.ts`, the throw site, and an `errors.X` key in **all three** of `frontend/messages/{en,fa,nl}.json`. Never throw raw English Nest exceptions for user-facing failures.
|
|
|
|
### The core domain pipeline
|
|
|
|
```
|
|
Appointment ─┐
|
|
├→ Treatment (patient + day) → TreatmentDetail (treatment type + selected teeth)
|
|
Walk-in ─────┘ │
|
|
│ "send to lab" (clinic side)
|
|
▼
|
|
LabCase + LabCaseToothProsthesis (per tooth, grouped by sourceKey)
|
|
│ generateLabCaseTasks()
|
|
▼
|
|
ProsthesisType → ProsthesisTypeStep → LabWorkflowStep ⇒ LabCaseTask rows
|
|
│
|
|
▼
|
|
LAB org: Cases tab + Tasks tab
|
|
```
|
|
|
|
`backend/src/modules/cases/lab-case-task.generator.ts` is the expansion point: it is **idempotent** (returns early if tasks exist) and drives the entire lab-side task list from catalog data. Teeth carry `selectionGroupId` so bridges/connected units survive into task grouping. A `LabCase` can also be lab-origin (`LabCaseOrigin`), created without any clinic treatment.
|
|
|
|
Clinics may only dispatch to labs they are linked to: `OrganizationLink` (A↔B, `LinkStatus`), plus `OrganizationInvitation` for counterparts not yet on the platform — the invite flow writes both rows in one transaction and stores only the token hash.
|
|
|
|
### Catalog is code-based and DB-translated
|
|
|
|
`TreatmentType`, `ProsthesisType`, and `LabWorkflowStep` store a stable `code` and **no label**. Labels come from `CatalogTranslation(entityKind, entityCode, locale)` resolved by `CatalogLabelService` (falls back locale → `en` → humanized code). So: never hardcode a catalog label in backend code, and pass the actor's locale into anything that materializes labels (task generation does). Frontend colors/labels for these codes live in `components/shared/treatmentTypeDisplay.ts` and `components/treatment/prosthesisTypeDisplay.ts`.
|
|
|
|
### Realtime and unread state
|
|
|
|
`modules/notifications/user-notification.service.ts` writes `UserNotification` rows and pushes them through the Socket.IO transport in `backend/src/realtime/` (`emitToUserOrg` → `notification.created`). On the frontend a single `notification.created` event drives three things: the header bell inbox, sidebar **tab badges**, and a *soft* refresh of whatever list is currently open — soft meaning it must not remount components or clear an in-progress treatment draft. Unread is per-user cursor state (`LabCaseUserReadState`, `LabCaseUserTabReadState`) plus the `LabCaseActivity` log — badges clear on opening a case, not on visiting a tab.
|
|
|
|
### Layout conventions worth knowing before you create a file
|
|
|
|
- **Prisma lives outside `src/`**: `backend/prisma/` holds `schema.prisma`, migrations, seeds *and* `prisma.module.ts` / `prisma.service.ts` — hence imports like `../../../prisma/prisma.service`. Register new Nest modules in `app.module.ts`.
|
|
- **AdminJS (`/admin`)** resources are a manual allowlist in `backend/src/admin/resources.ts` — update them whenever `schema.prisma` changes (see `.cursor/rules/adminjs.mdc`).
|
|
- **Frontend layering** (`.cursor/rules/frontend-components.mdc`): `app/**/page.tsx` is a thin wrapper only → route logic in `components/ui/{feature}/{Feature}Page.tsx` → JSX in `components/ui/**` → pure helpers in `components/{feature}/` or `components/shared/`. No JSX outside `ui/`, no pure helpers inside it.
|
|
- **i18n is mandatory, not a follow-up**: every user-visible string goes into `en.json`, `fa.json`, **and** `nl.json`. `fa` is RTL, so use logical `text-start`/`text-end`, never `text-left`/`text-right`. Dates/times/numbers go through `lib/i18n/format.ts`; form dates use `AppDateInput`, never a native date input.
|
|
- Treatment attachments are written to disk at `backend/uploads/treatments` relative to `process.cwd()`.
|
|
|
|
### Tests
|
|
|
|
Jest covers pure logic only — permission normalization, phone/timezone helpers, task generation, lab-send validation, voice extraction contract (`backend/src/**`). Frontend has Vitest for its own pure helpers only — no React, no DOM: `prosthesisTree.ts`, `voiceReviewRows.ts`, `toothSelectionGroups.ts` and `voiceApply.ts` (`frontend/src/components/treatment/*.spec.ts`), run via `npx vitest run`. When a component callback holds a decision worth testing, extract the decision into `components/{feature}/` and leave the commit — state, refs, save order — in the component; `voiceApply.ts` is the worked example. `npx tsc --noEmit` remains the frontend's cross-cutting gate.
|
|
|
|
## Deployment
|
|
|
|
Images are built on a dev machine and pulled by the server; Compose files and scripts are in `infrastructure/` (`docker-compose.{prod,staging,registry}.yml`). Full guide: `infrastructure/DEPLOY.md`. Root `README.md` covers the Docker Hub + Let's Encrypt path and the Gitea registry path. Frontend `NEXT_PUBLIC_*` are **build args** — changing the public domain requires rebuilding the frontend image. Next `output: 'standalone'` does **not** include `public/`; `frontend/Dockerfile` copies `/app/public` next to `server.js` (catalog icons at `/prosthesis-catalog/*.svg`). Production tags are immutable — CI clones `--branch $tag`; cut a new `v*` instead of moving an existing tag.
|