Commit Graph
55 Commits
Author SHA1 Message Date
hamedandClaude Opus 4.8 15c6f5dce7 feat(patients): phase A — records list + create/edit form (Figma)
Rebuild the patient records (پرونده‌ها) area, phase A of the Figma redesign:

- BE: add a clinic-scoped `record_number` and a TenantTag `tags` M2M to
  PatientRecord (migration + EAGER-hydrated collection). POST /patient and
  PATCH /patient/{uuid} now accept `record_number` and tenant-scoped `tags`
  (foreign tag → 422); demographic fields (gender, date_of_birth,
  referral_source, description) continue to live on UserProfile via PATCH.
- FE: new PatientsListPage (table + card views, search, pagination, tags
  column, "تشکیل پرونده") at /admin/patients, and PatientRecordFormPage
  (create/edit) that POSTs the record then PATCHes the demographics. Point
  the sidebar "پرونده" entry to the new list.

Phases B–E (tabbed patient file, service stepper, invoice, payments/wallet,
call-center) follow. Backend covered by PHPUnit, FE by Vitest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 14:54:38 +03:30
hamedandClaude Opus 4.8 76f9fbe88f feat(settings): complete remaining settings tabs (account, tags, turns)
Fill the three previously-placeholder settings sections so every menu item
is now a real page inside the settings shell:

- حساب کاربری: new authenticated POST /api/v1/user/change-password
  (verifies current password, ≥8 chars, must differ) + account page with a
  profile summary and change-password form.
- برچسب‌ها: new per-tenant TenantTag domain (entity/repo/controller +
  migration) with tenant-scoped CRUD at /api/v1/tenant-tag(s), plus a tags
  management page (list + color + add/edit/delete).
- مدیریت نوبت دهی: export the existing WeeklyScheduleTab from
  DoctorDetailPage and reuse it in a standalone AppointmentSettingsPage
  (current doctor's uuid + addresses).

Wire all three menu entries to their routes. Backend covered by PHPUnit
(change-password, tenant-tag CRUD + ownership); FE covered by Vitest.
API docs updated (auth.md, tag.md).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 14:16:21 +03:30
hamedandClaude Opus 4.8 9e4ee11831 feat(services): match Figma خدمات page in settings shell + multi-staff
Render the clinic services page (sections → services) inside the settings
sub-navigation shell (SettingsLayout, "خدمات" active) to match the Figma
settings design. Restyle section cards to show the service count and a
status toggle with edit/delete actions, and service cards with labelled
price/duration and personnel chips.

A service can now have multiple personnel: add an additive many-to-many
ServiceItem↔ClinicStaff (staffMembers, EAGER) while keeping the legacy
single `staff` column mirrored for backward compatibility. Endpoints accept
`staff_uuids[]` (falling back to the legacy single `staff_uuid`) and return
`staff_members[]`; the section list now reports `items_count`.

Backfill-safe: pre-migration rows fall back to the single staff in toArray.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 13:28:51 +03:30
hamedandClaude Opus 4.8 580bc983b3 feat(patient): complete "اطلاعات پرونده" demographic form
Backend:
- Add profile columns field_of_study, province_id, city_id, postal_code,
  referral_source (UserProfile + migration).
- Extend PATCH /api/v1/patient/{uuid} to persist all demographic fields
  and return them in the patient profile payload.
- Support editable mobile (login identifier): validation, uniqueness,
  User.setMobileNumber, new ERR_PROFILE_002.
- Update docs/api/patient.md.

Frontend:
- New reusable Input, Field, and PatientRecordInfoForm (RHF + Zod).
- usePatient/useUpdatePatient hooks and patientForm mapping helpers.
- Extend the existing "info" tab in MyPatientsPage to the full field set
  via the shared form (province/city/insurance options, Jalali date).

Tests: Patient entity + PATCH integration (PHPUnit); form, hooks, and
mapping helpers (Vitest).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 11:51:18 +03:30
hamed 23590370b2 feat(doctor): prevent duplicate doctor imports based on medical system code 2026-07-11 19:56:58 +03:30
hamed 6c67d522ba feat(doctor): ensure default office address creation during doctor import 2026-07-11 19:21:24 +03:30
hamedandClaude Opus 4.8 2f0131171d feat(doctor): claim captcha+mobile, owner profile delete, admin map zoom fix
- DoctorClaimController: ALTCHA CaptchaGuard on /claim (dev no-op via
  ALTCHA_ENABLED=false); optional `mobile` field must match the logged-in
  user's number (422 ERR_CONFLICT_001 on mismatch)
- DoctorController::delete: now IS_AUTHENTICATED_FULLY — admin (any) or the
  owner of a claimed profile (IDOR-guarded); FK appointment guard kept
- DoctorDetailPage address map: MapController calls map.invalidateSize()
  before flyTo (fixes needing to pick a city twice on a freshly-mounted map);
  geocode retries once (nominatim empty/429 on first hit)
- tests: mobile mismatch, owner-delete allowed + others 403, unclaimed not
  deletable by random user
- docs: doctor-claim.md (mobile+captcha), doctor.md (delete permission)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 14:57:41 +03:30
hamedandClaude Opus 4.8 3dcd4f3b26 feat(doctor): imported IRIMC doctors are active by default
Per request, doctors created via import now set active_doctor_appointment
= true (was false). DoctorImportTest updated to assert active.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 14:20:42 +03:30
hamedandClaude Opus 4.8 51432c7bb9 fix(doctor): strip «دکتر» prefix on IRIMC import + name-fix & purge commands
Root cause of "دکتر دکتر …" (and ellipsis-truncated "…نی") in admin: IRIMC
names already contain the «دکتر» title, while the panel renders «دکتر {name}».
Convention is to store the bare name.

- DoctorImportService: normalize name via PersianText::stripDoctorTitle
  (also fixes ي/ی, ك/ک, half-space)
- PersianText::stripDoctorTitle now strips consecutive «دکتر دکتر …» prefixes
- app:doctors:fix-irimc-names: one-off backfill for existing source='irimc'
  rows (dry-run supported) — fixed 340 rows
- app:doctors:purge: FK-safe full wipe of doctors + all dependent tables +
  orphan surrogate users, for a clean test DB (dry-run default, --force to
  apply, prod-guarded)
- tests: PersianTextTest cases for the title stripping; DoctorImportTest
  asserts stored name has no «دکتر» prefix
- docs/api/doctor-import.md: name convention + the two new commands

Verified: import "دکتر صفورا حجازی نیا" → stored "صفورا حجازی نیا" → panel
shows single «دکتر صفورا حجازی نیا».

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 14:07:18 +03:30
hamedandClaude Opus 4.8 af125572c9 feat(doctor): complete IRIMC import feature — claim flow, least-privilege importer, unique import key
- Extract import logic from AdminApiController into DoctorImportService
  (thin DoctorImportController keeps the same route/contract)
- Surrogate users get marker role ROLE_UNCLAIMED_DOCTOR (+ backfill command
  app:doctors:backfill-surrogate-role) enabling safe deletion after claim
- DB-level UNIQUE (source, medical_system_code) + concurrent-import retry
- Doctor profile claim flow (climed.md): shahkar + PersonInfo identity checks
  via existing ApiIrService, Persian name normalization (PersianText),
  pessimistic-lock race protection, DoctorClaimRequest audit table
  (national code hashed, mobile masked), doctor_claim rate limiter,
  public claim-info endpoint, welcome SMS
- Admin support tools: manual transfer endpoint + paginated doctor-claims
  audit list + owner_status filter/fields in admin doctors list
- Least privilege: system owner now gets ROLE_IMPORTER (ROLE_ADMIN stripped),
  import endpoint accepts ADMIN|IMPORTER, isStaff includes IMPORTER
- Headless crawler login: X-Service-Token header bypasses captcha only
  (rate limit + password checks intact; empty env = no bypass)
- docs: doctor-claim.md (new), doctor-import.md, admin.md, doctor.md
- tests: DoctorImportTest (6), DoctorClaimTest (11), PersianTextTest (5)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 11:39:15 +03:30
hamed ec184dfcc8 Add AST cache files for AltchaService, API documentation, and AltchaService tests
- Created JSON representation of AltchaService class and its methods, including imports and relationships.
- Added documentation for the Captcha API, detailing endpoints and responses.
- Introduced test cases for AltchaService, covering various functionalities and edge cases.
2026-07-10 11:42:23 +03:30
hamed 10b0743d9a Implement ALTCHA captcha service with challenge generation and solution verification
- Added AltchaService class for managing ALTCHA captcha challenges and solutions.
- Created CaptchaController to handle API requests for generating challenges.
- Introduced CaptchaGuard for validating captcha solutions on public endpoints.
- Developed unit tests for AltchaService to ensure challenge creation and solution verification functionality.
- Implemented integration tests for the Captcha API endpoint and captcha bypass behavior when disabled.
- Added documentation for the Captcha API in the corresponding markdown file.
2026-07-10 10:31:59 +03:30
hamed 11efed4100 Add AST cache for AdminLogsTest.php with extracted nodes and edges 2026-07-10 09:48:45 +03:30
hamed 0ad8487aac feat: add SeedDemoDataCommand for seeding production-like demo data for testing representations, doctors, clinics, users, appointments, and commissions 2026-07-09 08:07:41 +03:30
hamed 0750bc9812 feat: add multi-city representation support and domain context resolution
- Created migration to add representation_cities table and domain, is_global fields to representations.
- Implemented SiteContextController to resolve domain to site context (city | representation | unknown).
- Developed DomainContext and DomainContextResolver services for domain mapping.
- Added tests for DomainContextResolver and commission logic based on domain ownership.
2026-07-09 07:26:58 +03:30
hamed ccbb1d0b1f feat: Add City entity methods and migration for title field
- Created a new JSON file for the City entity's AST representation, detailing its methods and properties.
- Added a migration to alter the cities table by adding a nullable title column.
2026-07-08 12:19:06 +03:30
hamed 8f4f7fc951 fix: update KavehNegarProvider to use GET requests with query parameters to resolve 431 and idle timeout errors 2026-07-08 09:51:30 +03:30
hamed a2c809b7de Add SmsServiceLookupOnlyTest AST and migration for template_code in sms_logs
- Created a new AST JSON file for SmsServiceLookupOnlyTest.php, detailing nodes, edges, and raw calls for better code analysis.
- Added a new migration (Version20260707202553) to alter the sms_logs table by adding a nullable template_code column.
2026-07-08 00:03:33 +03:30
hamed 87f4d1695f Add CorsRegexEnvProcessor and corresponding tests
- Implemented CorsRegexEnvProcessor to build CORS origin regex from a comma-separated host list (ALLOWED_FRONTEND_HOSTS).
- Added tests for CorsRegexEnvProcessor to validate regex generation and matching behavior.
- Created JSON files for AST representation of the new classes and tests.
2026-07-07 14:58:41 +03:30
hamed 4ee1524f31 Add AST JSON files for NotificationMobileController and manifest.json; create SmsServiceLookupOnlyTest for SMS service functionality 2026-07-07 14:41:22 +03:30
hamed ca71c49451 feat(payment): add payment detail endpoint and update payment model with order_id and patient_name
feat(appointment): enhance appointment detail page with time formatting and additional info
fix(payment): update payment query to fetch from the correct endpoint and adjust response structure
docs(api): add search parameter to payments API documentation and detail response structure
test(payment): add unit test for MellatGateway to verify null credentials handling
2026-07-02 15:10:15 +03:30
hamed 22937dfa56 feat: add CategoryImportController for bulk JSON import and export of categories
- Implemented export functionality to retrieve all rows from specified category tables.
- Developed import functionality with strict validation and referential integrity checks.
- Added error handling for various import scenarios including invalid formats and duplicate entries.
- Introduced tests for import functionality to ensure correct behavior and validation.
2026-06-30 21:51:06 +03:30
hamed 803196108c feat(logging): Implement database logging with app_log table
- Created migration to set up app_log table for storing application logs.
- Added AppLog entity and repository for ORM handling of logs.
- Developed DbLogger service to persist logs of level WARNING and above to the database while maintaining existing logging behavior.
- Implemented tests for admin log retrieval and DbLogger functionality to ensure proper logging behavior.
- Enhanced logging context sanitization for better error tracking.
2026-06-29 20:01:03 +03:30
hamed 8705b88270 fix(auth): update refresh token behavior to be reusable within TTL and add tests for token functionality 2026-06-28 21:57:42 +03:30
hamed c2b47d6396 delete(tests): remove RefreshTokenRotationTest as it is no longer needed 2026-06-28 21:52:52 +03:30
hamedandClaude Opus 4.8 66ab597efd fix(audit): low-tier — session patient-gate + 201 statuses (L1, L11) + triage
L1: PATCH /session now enforces the patient_records subscription gate like its
sibling endpoints (ownership was already checked; the feature gate was missing).
L11: POST /pre-registration and POST /representation/iban return 201 on create.

Remaining low-tier findings triaged and accepted without change (documented in
docs/audit-backlog.md): L8 is a false positive (FK auto-indexed), L6/L7/L9 are
marginal indexes, L4/L5 are small bounded N+1, L2/L3/L10/L12 are minor — none
with security/integrity impact.

Regression: tests/Audit/LowTierFixesTest (both fail without the fix).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 21:21:03 +03:30
hamedandClaude Opus 4.8 b1aaaf3d55 fix(db): purge service-item config rows on delete (M20)
Tariff and TenantServiceCoverage reference a service item by a raw int (no FK),
so deleting an item orphaned its tariffs and tenant-coverage config. Delete them
in deleteItem() before removing the item. (The in-use FK guard for invoice/claim
usage is preserved.)

Remaining M20 refs (ClinicStaff/SmsWallet/DoctorAddress.clinicId/Claim.insurance_id
on rare owner deletions) are accepted as harmless unreferenced rows; SmsWallet is
intentionally retained as a financial record. Documented in docs/audit-backlog.md.

Regression: tests/ClinicService/ServiceItemDeleteCleanupTest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 21:17:30 +03:30
hamedandClaude Opus 4.8 4b80616a17 refactor(errors): centralise legacy string error codes in ErrorCodes (M21)
~27 ad-hoc error codes (SLOT_TAKEN, USER_NOT_FOUND, VALIDATION, …) were raw
strings, so ErrorCodes::message() returned the "unknown" fallback for them.
Register all 14 distinct codes as constants with their messages and replace the
raw usages across AdminApiController, MyAppointmentsController, CategoryController,
PreRegistrationController and ClinicInvitationController.

Wire values are kept identical (verified no consumer — admin SPA, nobat724_front,
tauri — switches on these strings), so this is backward compatible.

Regression: tests/Shared/ErrorCodesTest (wire values preserved + message resolves).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 21:12:23 +03:30
hamedandClaude Opus 4.8 96a86dbfed fix(db): business-key unique constraints (M16-M19)
Add unique constraints (one migration, no dup data in either DB):
- users.email, users.national_code (M16) — NULLs still allowed.
- payments.gateway_token (M17).
- date_overrides (doctor_id, date) (M18) — was a non-unique index.
- financial_breakdowns (payment_id, source) (M19) — anti double-accounting.

Regression: tests/Database/UniqueConstraintsTest (4 duplicate-insert cases).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 21:03:54 +03:30
hamedandClaude Opus 4.8 ccb71e4371 perf(secretary,billing): kill N+1 in secretary + claims lists (M8, M9)
M8: DoctorSecretary::toArray() lazy-loaded secretary/doctor/clinic per row;
fetch-join them in findByDoctorScope/findByClinic (shared listWithRelations()).

M9: enrichClaims() lazy-loaded each claim's items collection and called
insuranceRepo->find() per claim. Fetch-join items in findByTenant (Paginator,
fetchJoinCollection) and batch-fetch insurance names once.

Regressions (query count constant vs row count): SecretaryListNPlusOneTest,
ClaimsListNPlusOneTest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 20:43:35 +03:30
hamedandClaude Opus 4.8 61ac775175 perf(settlement,rating): paginate 3 unbounded list endpoints (M10-M12)
- M10 GET /settlement: was unbounded; add page/limit + countByUser + data.meta.
- M11 GET /admin/comments/pending: paginate findPending + countPending.
- M12 GET /comments/{doctor}: paginate the fetch-joined roots query via
  Paginator(fetchJoinCollection) + countApprovedRootsByDoctor.

All keep the existing { data: { data: [...] } } envelope and add data.meta
(backward compatible). Default limit 50 / max 100.

Regressions: SettlementListPaginationTest, CommentPaginationTest (both fail
without the limits). Also de-flaked SendCodeMobileRateLimitTest (randomised the
IP block so the persistent per-IP limiter buckets don't accumulate across runs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 20:36:41 +03:30
hamedandClaude Opus 4.8 447482c2ea perf(db): composite index on wallet_transactions(user_id, type) (M14)
Helps the per-type SUM balance query. M13 (service_items.section_id) and M15
(clinic_doctor_invitations.doctor_id) were false positives — both columns carry
a FK and are therefore auto-indexed by InnoDB; verified against the live schema.

Structural regression: InfraSmokeTest::testWalletUserTypeIndexExists.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 20:28:24 +03:30
hamedandClaude Opus 4.8 670cef24f4 fix(security): per-mobile OTP cap + refresh-token rotation (M6, M7)
M6: send-code rate-limited only per IP, so a victim's number could be
SMS-flooded from rotating IPs. Add a per-mobile bucket (same 5/hour policy)
keyed by the validated mobile.

M7: /oauth/token/refresh reused the presented refresh token verbatim (no
rotation) and never re-checked the user. The rotation infra already existed
(issueTokens mints a fresh refresh token) — the controller just discarded it.
Now revoke the presented token (single-use), issue a fresh pair, and reject a
suspended user (status != 1).

Regressions: tests/Auth/SendCodeMobileRateLimitTest,
tests/Auth/RefreshTokenRotationTest (both fail without the fix).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 20:25:22 +03:30
hamedandClaude Opus 4.8 fe6383314e fix(security): enforce ownership on 4 IDOR read/bind endpoints (M2-M5)
- M2 GET /insurance/{id}: was unguarded; now owner-or-admin (403 otherwise) —
  stops reading another doctor's negotiated price by id enumeration.
- M3 GET /clinic-pro/doctor-address/{id}: add the same owner/admin check the
  sibling PATCH/DELETE already had.
- M4 POST/PATCH /service-item: staff_uuid must belong to the caller's tenant
  (entity_type/entity_id) → 422; stops binding another tenant's staff.
- M5 appointment-settings list endpoints (date-override/holidays/
  available-locations): add the per-doctor ownership check the sibling
  single-record endpoints already enforce.

Regressions (6 negative cases fail without the fixes):
DoctorInsuranceOwnershipTest, DoctorAddressOwnershipTest,
ServiceItemStaffOwnershipTest, AppointmentSettingsListOwnershipTest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 20:17:52 +03:30
hamedandClaude Opus 4.8 23ca56b293 fix(billing): bound approved/paid amounts on claim transition (M1)
approve/pay accepted any approved_rials/paid_rials with no bounds, so the
claiming tenant could write arbitrary figures into the insurer-debt ledger
(negative, or far above the claimed total). Validate: approved ∈ [0, claimed],
paid ∈ [0, approved] → 422 otherwise. (The "force arbitrary status" half of the
finding was already prevented by Claim::canTransitionTo.)

Regression: tests/Billing/ClaimAmountBoundsTest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 20:07:19 +03:30
hamedandClaude Opus 4.8 131a78343b test(audit): strengthen H5/H7/H8 into true fail-without-fix regressions
Verification pass found three tests only guarded correctness, not the fix's
behavior:
- H7: add repository white-box test asserting likes/replies come back as
  initialised PersistentCollections (lazy without the fetch-join).
- H8: add a query-count test (constant vs coverage-row count) — without the
  batch fetch the count grows ~1 per row.
- H5: add an end-to-end test hitting DELETE /api/v1/doctor and asserting the
  insurance config is purged (the service unit test didn't cover the wiring).

All three now fail when their fix is reverted. Suite: 39 tests / 92 assertions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 20:01:12 +03:30
hamedandClaude Opus 4.8 d8f7db0ada perf(billing,settlement): paginate claims and wallet transactions (H9, H10)
GET /billing/claims loaded every tenant claim with no limit. Add
findByTenant(page, limit) + countByTenant (shared query builder), default
limit 50 / max 100, and expose totals as data.meta — kept inside the existing
{ data: { data: [...] } } envelope so current clients are unaffected.

GET /wallet/transactions was already bounded (findByUser defaulted to limit 50)
but page-less; add page/offset + countByUser + the same additive meta.

Regression: tests/Billing/ClaimsListPaginationTest,
tests/Settlement/WalletTransactionsPaginationTest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 19:42:04 +03:30
hamedandClaude Opus 4.8 42d934abc2 perf(rating): fetch-join comment tree to kill N+1 in public list (H7)
findApprovedRootsByDoctor used a plain findBy, so Comment::toArray() lazy-loaded
likes, replies and the author per comment (and recursively per reply). Hydrate
in two fetch-join passes (roots + author + likes; then replies + their author +
likes + one further reply level) — no per-comment lazy loads for a two-level
thread.

Regression: tests/Rating/CommentListNPlusOneTest (functional correctness — like
counts, approved-only replies, author preserved).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 19:35:34 +03:30
hamedandClaude Opus 4.8 eb1997066e perf(insurance): batch-fetch service items in coverage list (H8)
listServiceCoverage called serviceItemRepo->find() once per coverage row (N+1).
Collect the ids and fetch them in one findBy(['id' => $ids]), then map by id.

Regression: tests/Insurance/ServiceCoverageNPlusOneTest (functional correctness —
every row resolves the right service_item_uuid).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 19:29:06 +03:30
hamedandClaude Opus 4.8 6b12f3ddb9 perf(doctor): fetch-join specialties in clinic doctor list (H6)
findByClinicWithFilters left-joined specialties only for filtering, so
toListArray() lazy-loaded them per doctor (N+1). addSelect them and switch the
result fetch to Paginator(fetchJoinCollection: true) so LIMIT still paginates by
doctor.

Test infra: ApiTestCase::countQueries() (via doctrine.debug_data_holder).
Regression: tests/Doctor/ClinicDoctorListNPlusOneTest asserts the query count
does not grow with doctor count (4→10 without the fix).

Also relaxed AppointmentExpiryServiceTest's exact-count assertion (it counts all
stale pendings in the shared db_test, which accumulates) — logged test-isolation
debt as E6.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 19:24:49 +03:30
hamedandClaude Opus 4.8 4cf6873900 fix(db): purge insurance config when a doctor/clinic is deleted (H5)
tenant_insurances, entity_insurance_pricing and tenant_service_coverages
reference their owner through a polymorphic (entity_type, entity_id) pair, so no
database FK can cascade their cleanup. Hard-deleting a doctor (DoctorController)
or clinic (AdminApiController) left these rows orphaned.

Add TenantInsuranceCleanupService::purgeForEntity() and call it from both delete
paths — removes coverage (via owning tenant_insurance ids), then tenant
insurances, then pricing.

Residual (separate, lower-freq paths): deleting an insurance category or a
service_item still orphans rows that reference them by id — tracked under the
medium-tier soft-ref findings.

Regression: tests/Insurance/TenantInsuranceCleanupTest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 19:16:10 +03:30
hamedandClaude Opus 4.8 d14ac38da5 fix(db): RESTRICT delete of a Payment that has a FinancialBreakdown (H4)
The ledger FK used ON DELETE CASCADE on a non-nullable column, so deleting a
Payment silently destroyed its immutable financial breakdown rows. Switch to
RESTRICT — a settled payment can no longer be deleted out from under its ledger.

Regression: tests/Settlement/FinancialBreakdownIntegrityTest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 19:10:53 +03:30
hamedandClaude Opus 4.8 7fa4b55d3f fix(security): unique payment reference_id, reject replayed callbacks (H3)
reference_id (the gateway's settled-transaction ref) was not unique, so the
same successful callback — or a RefNum replayed onto another order — could
credit twice. Add a unique index (NULL until success, so pending/failed rows
don't collide) and an application-level pre-check in the callback that fails the
payment if the reference already belongs to another order. The unique index is
the hard backstop behind the check.

Regression: PaymentCallbackAmountTest::testReplayedGatewayReferenceIsRejected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 19:08:26 +03:30
hamedandClaude Opus 4.8 aa87b4a9cb fix(db): prevent double-booking a slot via unique active_slot_key (H2)
A non-unique index on (doctor_id, slot_start) plus a count-then-insert check
left a TOCTOU race: two concurrent requests could both pass isSlotTaken and
both insert. wrapInTransaction alone doesn't stop the phantom under InnoDB
REPEATABLE-READ.

Add a nullable, unique active_slot_key on Appointment = "doctorId:slotStart"
while the booking occupies the slot (pending/confirmed — in lockstep with
isSlotTaken); NULL once expired/completed/no_show/cancelled (NULLs don't collide
in a MySQL unique index, so released slots rebook freely). bookAtomically now:
catches the unique violation -> SlotTakenException, and expires lapsed pendings
in-transaction so the ~1-min window before the expiry cron doesn't wrongly block
rebooking. All three booking paths (online / my / admin) routed through it.

Migration backfills one row per (doctor, slot) — the latest id — so the index
builds even on dirty historical data without destructively cancelling bookings.
(Backfill surfaced a real pre-existing double-booked slot in dev data.)

Regression: tests/Appointment/SlotUniquenessTest. Adjusted the expiry-service
test fixture to use distinct slots (one live booking per slot is now enforced).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 19:04:54 +03:30
hamedandClaude Opus 4.8 c084571bf0 fix(security): enforce doctor scope on POST my/appointment (H1)
createAppointment only checked the caller held an allowed role, then booked
onto whatever doctor_uuid the request named — a doctor could book onto any
other doctor's calendar, a clinic onto doctors outside it, a secretary outside
their scope. Add canBookForDoctor(): doctor→own only, clinic→member doctors,
secretary→active scope + appointments.create permission, admin→any.

Regression: tests/Appointment/BookingScopeTest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 18:52:23 +03:30
hamedandClaude Opus 4.8 ae06498a96 fix(security): verify gateway-confirmed amount in payment callback (C1)
The callback marked an order success on any verify-ok result without comparing
the gateway-settled amount to the amount charged. SEP returns AffectiveAmount;
an underpayment or a replayed RefNum from a cheaper order would confirm the
expensive order. Now reject (status=failed, no activation) when the gateway
reports an amount that mismatches the stored amount_rials. Gateways that don't
report a settled amount (Mellat binds it server-side) skip the check.

MockGateway now echoes mock_amount so the guard is exercisable in tests.
Regression: tests/Payment/PaymentCallbackAmountTest (underpayment rejected,
matching amount succeeds).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 18:49:33 +03:30
hamedandClaude Opus 4.8 6bd49c2d3e fix(security): make commission_percent & active admin-only on PATCH representation
A representation editing its own record could raise its own commission or
self-activate (privilege escalation). Restrict both fields to ROLE_ADMIN and
range-check commission (0–100). Owner can still edit name/city/bank.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 17:36:48 +03:30
hamed e456809b55 fix(devops): repair phpstan config so static analysis runs again
- container_xml_path -> containerXmlPath (phpstan-symfony v2 rename); the old
  key made phpstan abort with an invalid-configuration error, so analysis
  silently never ran
- add the missing tests/doctrine_object_manager.php loader
- drop a stale ignoreErrors pattern

phpstan now runs and surfaces 42 pre-existing level-5 errors (tracked separately).
2026-06-28 17:17:46 +03:30
hamed e1740462b5 perf(appointment): batch-fetch pending payments in expiry loop (fix N+1)
AppointmentExpiryService ran one findPendingByAppointment query per expiring
booking. Add PaymentRepository::findPendingByAppointments (one IN query keyed
by appointment id) and use it. Test covers expiry + payment cancellation for
several appointments at once.
2026-06-28 17:10:32 +03:30
hamed 372bea4849 test(orm): regression — every entity with a custom repo declares repositoryClass
Auto-discovers each mapped entity that has a sibling <Entity>Repository class
and asserts getRepository() returns it (not Doctrine's default). Catches the
prod-only opcache.preload bug class that broke /oauth/userinfo.
2026-06-28 16:53:40 +03:30