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>
14 KiB
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, notdocs/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/, testsApp\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)
# 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.nodenative-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:
@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 withdata?.dataPaginatedResponse<T>(admin lists): items atdata?.data, total atdata?.meta?.totalRecords
State management:
- Server state → TanStack Query (cache,
staleTime: 30s,retry: 1). - Client state → Zustand.
authStore(JWT + user, persisted tolocalStorage['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;ExceptionSubscribercatches it and formats via$this->error(). Codes + Persian messages insrc/Shared/Constant/ErrorCodes.php. - Pitfall — double nesting:
$this->success(['data' => $x])yields{ data: { data: x } }; the frontend must then readdata?.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)
src/<Domain>/Entity/Foo.php— Doctrine entity (attributes; see model pattern).src/<Domain>/Repository/FooRepository.php— queries.src/<Domain>/Service/FooService.php— logic (optional but preferred).src/<Domain>/Controller/FooController.php—extends BaseController, route/api/v1/..., guard with#[IsGranted], return a response helper.ddev exec php bin/console doctrine:migrations:diffthenmigrate.- Update the matching
docs/api/*.md(see Standing Rule).
Model (entity) pattern
#[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
قواعد غیرقابلمذاکره
- SOLID در هر کد جدید. کامپوننت/کلاس چندمسئولیتی ننویس.
- API جدید فقط وقتی هیچ اندپوینت موجودی — حتی با توسعه — کافی نباشد. همیشه اول بگرد، بعد توسعه بده، در آخر بساز. و دلیلش را بنویس.
- مستندات کوتاه و دقیق روی هر چیز عمومی. کامنت بدیهی ننویس.
- هیچ تسکی بدون تست (موفق + خطا + مرزی) و بدون اجرای موفق تستها تمامشده نیست.
- ورودی من فارسی است. اول منظورم را به spec انگلیسی تبدیل کن و برداشتت را به فارسی تأیید بگیر. کد و مستندات و کامیت انگلیسی؛ گفتوگو با من فارسی. رشتههای UI فارسی و از فایل i18n.