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>
250 lines
14 KiB
Markdown
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.
|