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

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.siteAdmin: /adminSwagger: /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)

# 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:

@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.phpextends 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

#[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

قواعد غیرقابل‌مذاکره

  1. SOLID در هر کد جدید. کامپوننت/کلاس چندمسئولیتی ننویس.
  2. API جدید فقط وقتی هیچ اندپوینت موجودی — حتی با توسعه — کافی نباشد. همیشه اول بگرد، بعد توسعه بده، در آخر بساز. و دلیلش را بنویس.
  3. مستندات کوتاه و دقیق روی هر چیز عمومی. کامنت بدیهی ننویس.
  4. هیچ تسکی بدون تست (موفق + خطا + مرزی) و بدون اجرای موفق تست‌ها تمام‌شده نیست.
  5. ورودی من فارسی است. اول منظورم را به spec انگلیسی تبدیل کن و برداشتت را به فارسی تأیید بگیر. کد و مستندات و کامیت انگلیسی؛ گفت‌وگو با من فارسی. رشته‌های UI فارسی و از فایل i18n.