Files
clinicpro/CLAUDE.md
T
hamedandClaude Opus 5 75d5052f72 feat(tenant): enforce environment isolation in the ORM layer
Phase 4 of the tenant-marking series. Until now isolation depended on every
query remembering its own WHERE clause. With 82 entities and 844 tests, that is
not a guarantee — it is a hope. MariaDB has no row-level security, so the
backstop has to live in Doctrine.

TenantFilter appends (entity_type, entity_id) to every DQL query on a
tenant-owning entity. It ships disabled and TenantFilterSubscriber turns it on
per request.

The filter engages only for a **chosen** environment — an explicit clinic_uuid
on the request, or a stored UserActiveContext. EntityContext now records which
of the two produced it. Locking a user to the role fallback instead would hide
data they are entitled to: a clinic-member doctor who never switched context
lost every appointment belonging to that clinic. Five tests caught exactly that
before the gate was added. Admins and unauthenticated marketplace traffic stay
outside the filter by design.

Two findings from running it rather than reasoning about it:

- Dereferencing a lazy proxy whose target the filter excluded raises
  EntityNotFoundException, which surfaced as 500 on four patient endpoints.
  ExceptionSubscriber now maps it to 404: outside your environment means it does
  not exist for you. It is logged at info level so a genuinely broken FK is still
  visible.
- EntityManager::find() by primary key IS filtered in Doctrine ORM 3, contrary
  to the limitation carried over from older versions. The stronger guarantee is
  pinned by a test so a future regression is noticed, and the documented table
  was corrected.

The filter also caught a real leak: a clinic secretary's appointment list
filtered by doctor id alone, so a doctor's personal-practice booking appeared in
the clinic list. The test had been asserting that behaviour.

GlobalTables classifies all 82 entities into four states — carries a tenant,
deliberately global, aggregate child, or recorded debt — and
TenantSchemaCoverageTest fails on anything unclassified. Aggregate children
declare their root explicitly, because several attach through a scalar FK rather
than a Doctrine association and cannot be inferred from metadata; the test walks
each chain to a tenant-owning root. Financial tables stay in DEFERRED with a
ceiling assertion so the list cannot grow quietly.

Deliberately not built: the prePersist assignment listener from the plan. The
tenant columns are NOT NULL without a default, so a missing assignTenant()
already fails loudly at flush — phase 2 surfaced 123 such failures. A listener
would add silent auto-assignment where the current behaviour is an explicit
crash.

EXPLAIN with the filter's conditions still picks idx_appointments_tenant_slot
and uniq_patient_record.

Tests: 844 passing. PHPStan unchanged at its 17 pre-existing errors, none in
files touched here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 12:27:27 +03:30

250 lines
14 KiB
Markdown

# CLAUDE.md
Guidance for Claude Code when working in **ClinicPro** — a clinic management & appointment platform. A Symfony 7.4 REST API backend plus a React 19 admin SPA bundled inside Symfony via Webpack Encore.
- **Local:** `https://clinic-pro.ddev.site`**Admin:** `/admin`**Swagger:** `/api/doc`
- Runs inside **ddev**: prefix commands with `ddev exec`.
- Entire product is Persian/Farsi, **RTL**, Jalali (Shamsi) dates. Keep new strings Persian, dates Jalali.
> **Source of truth for design tokens is `assets/admin/styles.css`**, not `docs/admin-ui/ui-design-spec.md`. That spec doc is an older aspirational draft (purple palette, tiptap, date-io) that does **not** match the shipped code (indigo palette, CKEditor, jalaali-js). Trust the code.
---
## Stack & versions
### Backend (`composer.json`)
- **PHP ≥ 8.2**, **Symfony 7.4**, **Doctrine ORM 3.6** + migrations, **MariaDB 11.8** (via ddev)
- Auth: **JWT** (`lexik/jwt-authentication-bundle`)
- Async/scheduled: `symfony/messenger` + `symfony/scheduler` + `symfony/redis-messenger`
- API docs: `nelmio/api-doc-bundle` + `zircote/swagger-php`; CORS: `nelmio/cors-bundle`
- Also: `symfony/uid`, `symfony/rate-limiter`, `altcha-org/altcha`, Twig, `symfony/ux-react`
- PSR-4: `App\``src/`, tests `App\Tests\``tests/`
### Frontend admin SPA (`package.json`)
- **React 19** + **TypeScript 5**, bundled by **Webpack Encore 6** (not Vite)
- **Tailwind CSS v4** (`@tailwindcss/postcss`, CSS-first config)
- Server state: **TanStack Query v5** · Tables: **TanStack Table v8**
- Client state: **Zustand 5** · Forms: **React Hook Form 7 + Zod 3** (`@hookform/resolvers`)
- Routing: **React Router v7** · Icons: **Heroicons v2** · Charts: **Recharts 3**
- Select: `react-select` · Rich text: **CKEditor 5** · Dates: `jalaali-js` · Maps: `leaflet`/`react-leaflet`
- Toasts: `sonner` (+ `react-hot-toast`) · Font: **Vazirmatn** via `@fontsource/vazirmatn`
- Tests: **Vitest** + Testing Library (jsdom)
### Build entry points (`webpack.config.js`)
Three Encore entries → `public/build/`:
| Entry | Source | Purpose |
|---|---|---|
| `admin` | `assets/admin/index.tsx` | React admin SPA, mounted at `/admin/*` |
| `app` | `assets/app.js` | Stimulus/UX React controllers |
| `home` | `assets/home/index.js` | Public-facing pages |
---
## Commands (prefix with `ddev exec`)
```bash
# Backend
php bin/console cache:clear
php bin/console doctrine:migrations:diff --no-interaction # after any entity change
php bin/console doctrine:migrations:migrate --no-interaction
php bin/console debug:router | grep api
php bin/console messenger:consume async # SMS / async jobs
php bin/console messenger:consume scheduler_default # scheduled tasks
php bin/phpunit # tests
php vendor/bin/phpstan analyse # static analysis (level 5)
# Frontend
yarn dev # one-off build (use to check for errors)
yarn watch # watch mode
yarn build # production
npx tsc --noEmit --project tsconfig.json # type check only (faster)
yarn test # vitest
```
> Known: CSS build has a pre-existing `lightningcss.linux-arm64-gnu.node` native-module error inside ddev; it does not block JS/TS compilation. TypeScript errors still surface in TSC output.
---
## Folder structure & where things go
### Backend — `src/<Domain>/`
Domain-driven; each domain is its own namespace `App\<Domain>\` holding its own layers:
```
src/<Domain>/
Controller/ # HTTP endpoints — extend BaseController
Entity/ # Doctrine entities
Repository/ # Doctrine repositories (DQL / query builders)
Service/ # domain logic
Command/ # console commands (optional)
```
Domains include: `Doctor`, `Patient`, `Appointment`, `Payment`, `Clinic`, `ClinicInvitation`,
`ClinicService`, `DoctorService`, `Secretary`, `Staff`, `Settlement`, `Billing`, `Subscription`,
`Rating`, `Blog`, `Sms`, `Category`, `Location`, `Specialty`, `Insurance`, `Tag`, `Representation`,
`Auth`, `Admin`, `Dashboard`, `UserProfile`, `Config`.
Shared infra:
```
src/Shared/Controller/BaseController.php # every controller extends this
src/Shared/Constant/ErrorCodes.php # all error codes + Persian messages
```
Cross-cutting: `migrations/`, `config/`, `templates/`, `docs/api/` (endpoint docs).
### Frontend — `assets/admin/`
```
index.tsx # entry: mounts <App/>, QueryClientProvider, <Toaster/> (sonner)
App.tsx # React Router v7 routes
pages/ # one file per page — XxxPage.tsx (~50 pages)
components/
ui/ # shared design-system components (see below)
*.tsx # feature-specific composites (ServiceTariffModal, ImageCropModal, …)
hooks/ # useSubscription, usePaymentConfig, usePwaInstall, …
lib/api.ts # fetch wrapper; reads JWT from localStorage['clinicpro-auth']
lib/utils.ts # formatRial, formatNumber, formatDate, formatDateTime
types/index.ts # all shared TypeScript interfaces
stores/ # authStore.ts (persisted), uiStore.ts (sidebar/ui)
styles.css # Tailwind entry + all design tokens
```
---
## Styling & design tokens
Tailwind **v4**, imported in `assets/admin/styles.css`:
```css
@import "tailwindcss";
@import "@fontsource/vazirmatn/{300..800}.css";
@custom-variant dark (&:where(.dark, .dark *));
@theme { --font-sans: "Vazirmatn", ui-sans-serif, system-ui, sans-serif; }
```
**All design tokens are CSS custom properties in the `:root` block of `styles.css`** — reference them via Tailwind arbitrary values (`bg-[var(--surface)]`) or plain CSS, do not hardcode hex:
| Group | Tokens |
|---|---|
| Brand | `--primary:#5559CE` (indigo), `--primary-600/700`, `--primary-soft/soft2`, `--on-primary` |
| Accent | `--accent:#f0682a` (orange, matches clinic-pro-tauri), `--accent-600`, `--accent-bg` |
| Surfaces | `--bg`, `--bg-2`, `--surface`, `--surface-2/3`, `--border`, `--border-2` |
| Text | `--text`, `--text-2`, `--text-3` |
| Status | `--success/-bg`, `--warning/-bg`, `--danger/-bg`, `--info/-bg`, `--violet/-bg` |
| Stat cards | `--stat-{amber,violet,green,pink}-{bg,fg}` |
| Radius | `--r-xs:7 · --r-sm:8 · --r:14 · --r-lg:18 · --r-xl:24 · --r-pill:999` (px) |
| Shadow | `--shadow-sm`, `--shadow`, `--shadow-lg` |
| Layout | `--sidebar-w:243`, `--collapsed-w:90`, `--topbar-h:64`, `--gap:20`, `--card-pad:22`, `--row-h:56` |
| Motion | `--ease: cubic-bezier(.22,.61,.36,1)` |
Theming: **dark mode** overrides via `[data-theme="dark"]`; **compact density** via `[data-density="compact"]`. oklch color versions applied under `@supports` with sRGB fallbacks for old WebKit.
---
## Design system components — `assets/admin/components/ui/`
Reuse these before building new ones:
`DataTable` (sortable, search, skeleton loading, empty state, bulk) · `Modal` · `ConfirmDialog` ·
`PageHeader` (title + breadcrumb + action) · `StatCard` · `StatusBadge` · `Pagination` ·
`SearchableSelect` · `AppointmentStatusDropdown` · `PersianDateInput` / `PersianDatePicker` /
`PersianCalendar` · `MobileInput` · `PriceInput` · `Portal` · `FeatureGate` · `Altcha` ·
`InviteDoctorModal` · `PwaInstallBanner` / `PwaLoginCard` / `NotificationMobileCard`.
Feature composites (not generic) live one level up in `components/*.tsx`.
---
## Conventions
**Naming:** pages `XxxPage.tsx`, components PascalCase, hooks `useXxx.ts`. Backend PSR-4 `App\<Domain>\<Layer>`; entities singular (`Doctor`), tables snake_case plural (`patient_records`).
**Data fetching:** TanStack Query only — `useQuery` / `useMutation`. Query keys `['resource-name', page, filters]`. All HTTP goes through `lib/api.ts`, which injects the JWT automatically.
- `ApiResponse<T>` (single resource): extract with `data?.data`
- `PaginatedResponse<T>` (admin lists): items at `data?.data`, total at `data?.meta?.totalRecords`
**State management:**
- **Server state → TanStack Query** (cache, `staleTime: 30s`, `retry: 1`).
- **Client state → Zustand.** `authStore` (JWT + user, persisted to `localStorage['clinicpro-auth']`), `uiStore` (sidebar/ui flags).
**Forms:** React Hook Form + Zod resolver — `z.object({...})`, type via `z.infer<typeof schema>`.
**Routing:** React Router v7, `<BrowserRouter>` in `index.tsx`, route table in `App.tsx`, SPA served at `/admin/*`.
**Auth:** JWT issued by Symfony. Admin routes guard with `#[IsGranted('ROLE_ADMIN')]` on the controller class/method. Public endpoints are whitelisted in `config/packages/security.yaml`.
---
## Backend endpoint & model patterns
### Response envelope — `BaseController` helpers
Every controller `extends BaseController`:
| Method | Shape |
|---|---|
| `$this->success($data)` | `{ success, data }` |
| `$this->paginated($items, $total, $page, $limit)` | `{ success, data:[], meta:{ totalRecords, totalPages, currentPage } }` (flat — no nesting) |
| `$this->error($code, $message, $status)` | `{ success:false, errors:[{code,message}] }` |
| `$this->validationError($violations)` | `{ success:false, errors:[{code,field,message}] }` HTTP 422 |
- **Errors:** throw `AppException(ErrorCodes::ERR_XXX, null, $httpStatus)` anywhere in a domain; `ExceptionSubscriber` catches it and formats via `$this->error()`. Codes + Persian messages in `src/Shared/Constant/ErrorCodes.php`.
- **Pitfall — double nesting:** `$this->success(['data' => $x])` yields `{ data: { data: x } }`; the frontend must then read `data?.data?.data`. Prefer passing the payload directly.
- **Admin list queries** use `->getArrayResult()` (array hydration), never entity getters, to avoid missing-getter errors.
### Adding an endpoint (one domain)
1. `src/<Domain>/Entity/Foo.php` — Doctrine entity (attributes; see model pattern).
2. `src/<Domain>/Repository/FooRepository.php` — queries.
3. `src/<Domain>/Service/FooService.php` — logic (optional but preferred).
4. `src/<Domain>/Controller/FooController.php``extends BaseController`, route `/api/v1/...`, guard with `#[IsGranted]`, return a response helper.
5. `ddev exec php bin/console doctrine:migrations:diff` then `migrate`.
6. Update the matching `docs/api/*.md` (see Standing Rule).
### Model (entity) pattern
```php
#[ORM\Entity(repositoryClass: FooRepository::class)]
#[ORM\Table(name: 'foos')]
class Foo
{
#[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid; // Uuid::v4()->toRfc4122() in constructor
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt; // Unix timestamp (time()), NOT DateTime
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'RESTRICT')]
private User $user;
}
```
Conventions: integer surrogate `id`; string `uuid` (v4) for external references; **timestamps are `int` Unix**, not DateTime; column names snake_case via `name:`; relations `ManyToOne` / `OneToMany`.
### Category / bundle system
Categories are polymorphic via a `bundle` string: `state`, `city`, `specially_doctor`, `doctor_services`, `insurance_type`, `supplementary_insurance`, `tag`. City is an integer FK to `categories.id` where `bundle='city'` (e.g. `Representation.cityId`) — LEFT JOIN `categories` in DQL to get the name. Endpoint: `GET /api/v1/categorys/{bundle}` (typo `categorys` is the real route); its response is double-nested → extract with `data?.data?.data ?? []`.
---
## Standing Rule — API docs
**Whenever any endpoint is created or modified** (route, request/response shape, error code, permission), update the matching file in `docs/api/` **in the same session**. Mapping mirrors `src/<Domain>``docs/api/<domain>.md` (e.g. `src/Doctor/*``docs/api/doctor.md`, `src/Admin/*``docs/api/admin.md`, Category/Location controllers → `docs/api/location.md` + `specialty.md` + `insurance.md` + `tag.md`).
## Project skills
`.claude/skills/`: `add-admin-endpoint`, `add-admin-page`, `sync-db`, `prompt-writer`, `run-prompt`. Prefer them over hand-rolling. Test users: `TEST_USERS.md` (rebuild: `ddev exec php create_test_users.php`).
---
## جداسازی محیط (tenant)
هر دادهٔ عملیاتی به یک محیط تعلق دارد: مطب شخصی یک پزشک، یا یک کلینیک — جفت `(entity_type, entity_id)`.
- **entity جدید** یا `App\Shared\Tenant\TenantOwnedTrait` می‌گیرد، یا با دلیل در `App\Shared\Tenant\GlobalTables` ثبت می‌شود. `TenantSchemaCoverageTest` هر دو حالت را اجبار می‌کند و entity طبقه‌بندی‌نشده را قرمز می‌کند.
- **محیط جاری** همیشه از `EntityContextResolver` گرفته می‌شود، نه از نقش کاربر.
- `TenantFilter` تور ایمنی است، نه جایگزین authorization — روی SQL خام، `getReference()` و فرزندان aggregate اعمال نمی‌شود.
- ایندکس‌های لیست باید `entity_type, entity_id` را **ستون اول** داشته باشند.
جزئیات و جدول کاملِ «چه تضمین می‌دهد و چه نمی‌دهد»: [docs/architecture/tenancy.md](docs/architecture/tenancy.md)
## قواعد غیرقابل‌مذاکره
1. SOLID در هر کد جدید. کامپوننت/کلاس چندمسئولیتی ننویس.
2. API جدید فقط وقتی هیچ اندپوینت موجودی — حتی با توسعه — کافی نباشد.
همیشه اول بگرد، بعد توسعه بده، در آخر بساز. و دلیلش را بنویس.
3. مستندات کوتاه و دقیق روی هر چیز عمومی. کامنت بدیهی ننویس.
4. هیچ تسکی بدون تست (موفق + خطا + مرزی) و بدون اجرای موفق تست‌ها تمام‌شده نیست.
5. ورودی من فارسی است. اول منظورم را به spec انگلیسی تبدیل کن و برداشتت را
به فارسی تأیید بگیر. کد و مستندات و کامیت انگلیسی؛ گفت‌وگو با من فارسی.
رشته‌های UI فارسی و از فایل i18n.