A bridge is a contiguous span, but every operation that filtered teeth out of a group kept the `connected` kind as long as two teeth remained. Reduce a 12-13-14 span to 12 and 14 and you get a "bridge" with no pontic — and worse, both teeth keep one selectionGroupId, so the lab receives them as a single unit and task generation builds work that cannot be made. linkedEdgesFromGroups already guarded the drawn marks with areArchNeighbors, so the chart looked right while the data was wrong. Adds splitDisconnectedRuns, which breaks what remains into contiguous runs: a run of two or more stays connected, a run of one becomes a single. The first run keeps the original groupId so lab rows pointing at it stay valid, and pruneToothProsthesisForGroups re-maps the rest. Applied at all three sites that filter a group's teeth, which each carried their own copy of the length check: - groupsFromFlatTeeth, when teeth are no longer selected - applyShiftRange, when a shift-range takes teeth from an existing group - removeTeethFromGroups, the path pruneDetailTeethToJobs uses toggleToothInGroups already split into runs by hand for the single-removal case; this is the same rule, shared. Tests: a new toothSelectionGroups.spec.ts (13) plus two for removeTeethFromGroups. The middle-tooth cases fail without the fix; the end-tooth and contiguous cases pass either way and exist to prove it does not over-reach. CLAUDE.md updated for the third Vitest file. Not fixed: a pre-existing unused `leftIdx` warning in unlinkAdjacentTeeth, unrelated to this change. Gates: 52 Vitest tests, tsc --noEmit clean, next build clean, ESLint unchanged at 1 pre-existing warning. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
9.1 KiB
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) |
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/holdsschema.prisma, migrations, seeds andprisma.module.ts/prisma.service.ts— hence imports like../../../prisma/prisma.service. Register new Nest modules inapp.module.ts. - Frontend layering (
.cursor/rules/frontend-components.mdc):app/**/page.tsxis a thin wrapper only → route logic incomponents/ui/{feature}/{Feature}Page.tsx→ JSX incomponents/ui/**→ pure helpers incomponents/{feature}/orcomponents/shared/. No JSX outsideui/, no pure helpers inside it. - i18n is mandatory, not a follow-up: every user-visible string goes into
en.json,fa.json, andnl.json.fais RTL, so use logicaltext-start/text-end, nevertext-left/text-right. Dates/times/numbers go throughlib/i18n/format.ts; form dates useAppDateInput, never a native date input. - Treatment attachments are written to disk at
backend/uploads/treatmentsrelative toprocess.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 and toothSelectionGroups.ts (frontend/src/components/treatment/*.spec.ts), run via npx vitest run. 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.