Files
clinicpro/CLAUDE.md
T

8.1 KiB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Project Overview

ClinicPro — a clinic management and appointment booking platform migrated from Drupal to Symfony 7. It consists of a Symfony REST API backend and a React 19 admin SPA bundled inside Symfony via Webpack Encore.

  • Local URL: https://clinic-pro.ddev.site
  • Admin panel: https://clinic-pro.ddev.site/admin
  • Swagger UI: https://clinic-pro.ddev.site/api/doc

Commands

All commands run inside ddev: prefix with ddev exec unless noted.

First-time setup

ddev exec composer install
ddev exec php bin/console lexik:jwt:generate-keypair   # generate JWT keys
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
ddev exec yarn install && ddev exec yarn dev
ddev exec php bin/console app:create-admin             # create first admin user

Backend (PHP/Symfony)

ddev exec php bin/console cache:clear
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
ddev exec php bin/console doctrine:migrations:diff --no-interaction   # generate migration after entity change
ddev exec php bin/console debug:router | grep api
ddev exec php bin/console messenger:consume async                      # start queue worker

# Tests
ddev exec php bin/phpunit
ddev exec php bin/phpunit tests/SomeTest.php                          # single test file

# Static analysis (level 5, with Symfony + Doctrine extensions)
ddev exec php vendor/bin/phpstan analyse

Frontend (React/TypeScript)

ddev exec yarn dev          # one-off dev build (use this to check for errors)
ddev exec yarn watch        # watch mode
ddev exec yarn build        # production build

# Type check only (faster)
ddev exec npx tsc --noEmit --project tsconfig.json

Note: The CSS build has a known lightningcss.linux-arm64-gnu.node native module error inside ddev — this is pre-existing and does not block JS/TS compilation. TypeScript errors only appear in TSC output.


Architecture

Backend — src/

Domain-driven structure; each domain is its own namespace under App\<Domain>\:

src/
  Admin/Controller/AdminApiController.php   # all admin-only list/stats endpoints
  Appointment/     Doctor/     Clinic/
  Auth/            Payment/    Rating/
  Blog/            Representation/  Secretary/
  Category/        Settlement/      Sms/
  Shared/Controller/BaseController.php      # all controllers extend this
  Shared/Constant/ErrorCodes.php

Every controller extends BaseController which provides four response helpers:

Method Shape When to use
$this->success($data) { success, data: $data } Single resource / action
$this->paginated($items, $total, $page, $limit) { success, data: $items[], meta: { totalRecords, totalPages, currentPage } } Admin list endpoints
$this->error($code, $message, $status) { success:false, errors:[{code,message}] } All error responses
$this->validationError($violations) { success:false, errors:[{code,field,message}] } HTTP 422 Input validation failures

Domain exceptions: throw AppException(ErrorCodes::ERR_XXX, null, $httpStatus) anywhere in the domain — ExceptionSubscriber catches it and calls $this->error() automatically. All error codes and their Persian messages live in src/Shared/Constant/ErrorCodes.php.

Critical pitfall — double-nested responses:
$this->success(['data' => $rep->toArray()]) produces { data: { data: {...} } }, so the frontend must extract with data?.data?.data. The paginated() helper does NOT nest — it returns data as a flat array.

Frontend — assets/admin/

Single-page app mounted at /admin/*:

assets/admin/
  App.tsx                    # React Router routes
  pages/                     # one file per page
  components/
    ui/                      # DataTable, Modal, ConfirmDialog, PageHeader, StatusBadge, Pagination,
                             #   SearchableSelect, PersianDateInput, PersianCalendar, AppointmentStatusDropdown
    layout/                  # AdminLayout, Sidebar, Topbar
  hooks/                     # custom React hooks
  lib/api.ts                 # fetch wrapper (reads JWT from localStorage key: clinicpro-auth)
  lib/utils.ts               # formatRial, formatNumber, formatDate, formatDateTime
  types/index.ts             # all TypeScript interfaces
  stores/
    authStore.ts             # Zustand auth store (persisted to localStorage)
    uiStore.ts               # sidebar open/close state

Data fetching pattern: TanStack Query v5 (useQuery / useMutation). Query keys use ['resource-name', page, filters].

API response types in lib/api.ts:

  • ApiResponse<T> — for single-resource responses: extract with data?.data
  • PaginatedResponse<T> — for admin lists: items at data?.data, total at data?.meta?.totalRecords

Forms: React Hook Form + Zod resolver. Schema defined with z.object(), type inferred with z.infer<typeof schema>.

Auth

  • JWT stored in Zustand store → localStorage['clinicpro-auth']state.token
  • api.ts reads it automatically for every request
  • Admin routes require ROLE_ADMIN. #[IsGranted('ROLE_ADMIN')] on controller class or method.
  • Public endpoints listed in config/packages/security.yaml under public_endpoints firewall pattern

Category / Bundle system

Categories are polymorphic via a bundle string field. Used for: state, city, specially_doctor, doctor_services, insurance_type, supplementary_insurance, tag.

City IDs (integer FK to categories.id where bundle='city') are stored on entities like Representation.cityId. To get the city name, LEFT JOIN the categories table in DQL.

Database

  • MariaDB 11.8 via ddev
  • Doctrine ORM with integer Unix timestamps (createdAt, updatedAt) — not DateTime objects
  • All admin list queries in AdminApiController use DQL array hydration (.getArrayResult()) to avoid triggering non-existent getter errors on entities
  • Migrations in migrations/ — always run doctrine:migrations:diff after entity changes

Key Patterns

Adding a new admin list endpoint (backend):

  1. Add method to src/Admin/Controller/AdminApiController.php
  2. Use $this->em->createQueryBuilder() with ->getArrayResult() (never use entity getters in admin list queries)
  3. Return $this->paginated($items, $total, $page, $limit)

Adding a new admin page (frontend):

  1. Create assets/admin/pages/XxxPage.tsx
  2. Use PaginatedResponse<YourType> with useQuery
  3. Extract: data?.data for items, data?.meta?.totalRecords for total
  4. Add route in App.tsx
  5. Add UI components: <DataTable>, <Pagination>, <Modal>, <ConfirmDialog>

Category API endpoint pattern: GET /api/v1/categorys/{bundle} (note: typo categorys is intentional — existing route). Response is double-nested: extract array with data?.data?.data ?? [].


Standing Rule — API Documentation

Whenever any API endpoint is created or modified (controller file, route, request/response structure, error code, permission), the corresponding file in docs/api/ must be updated in the same session.

Changed file Doc to update
src/Auth/* docs/api/auth.md
src/Doctor/* docs/api/doctor.md
src/Clinic/* docs/api/clinic.md + docs/api/clinic-invitation.md
src/Appointment/Controller/AppointmentController.php docs/api/appointment.md
src/Appointment/Controller/AppointmentSettings* docs/api/appointment-settings.md
src/Payment/* docs/api/payment.md
src/Settlement/* docs/api/settlement.md
src/Rating/* docs/api/rating.md
src/Secretary/* docs/api/secretary.md
src/Representation/* docs/api/representation.md
src/Sms/* docs/api/sms.md
src/Blog/* docs/api/blog.md
src/Admin/* docs/api/admin.md
Category/Province/City controllers docs/api/location.md, docs/api/specialty.md, docs/api/insurance.md, docs/api/doctor-service.md, docs/api/tag.md