Commit Graph
70 Commits
Author SHA1 Message Date
hamedandClaude Opus 5 369f3ae710 feat(seed): one command that builds three complete, working environments
Manual testing had no environment to test in: the demo seeder builds volume
(500 doctors, 20k appointments via raw INSERT) for the representation module,
which is the wrong shape for walking through a scenario end to end.

app:seed-scenarios builds three environments that each work from login to
booking:

  1. an independent doctor, service mode, with services, schedule, insurance,
     patients and appointments
  2. a doctor who also owns a clinic, with three more doctors inside it, a
     slot/service mix, two laser devices and two rooms, and laser services that
     genuinely require a laser
  3. a clinic whose owner is not a doctor, with all three booking modes live
     (slot, service and resource), five devices, and the same full data set

Everything goes through entities and the real services rather than raw SQL, so
tenant pairs, the unique active-slot key and the insurance rules hold. The
status machine is walked step by step (completed only via confirmed) instead of
writing a status the application could never produce.

--reset drops the schema, re-runs migrations and seeds base data in one go.
Three things it has to handle, each found by it breaking:

- representations must exist before cities, because cities.json references them
  by id and the category importer validates that
- migrations run mid-process invalidate the EntityManager's connection, so the
  manager is taken from the registry and reset afterwards
- a sub-command's --no-interaction in ArrayInput is not enough; without
  setInteractive(false) the migration waits forever for a confirmation

It also writes site_config.altcha_enabled = '0'. On a freshly migrated database
the captcha defaults to on and nobody can log in at all — panel or site.

Verified against the running app, not just the database: booking-locations
reports the right mode per doctor, service slots respect the buffer, the
slot-mode doctor returns a session with 15 slots, the resource-mode doctor
returns 40 options each with a real device assignment, a patient booked a
service appointment through the public endpoint, and the admin panel renders
the seeded day for both clinic owners.

TEST_USERS.md is rewritten: every account it described was gone after the wipe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 18:41:03 +03:30
hamedandClaude Opus 5 4049daf071 feat: close the last four domain events, and the panel paths they describe
Every one of the fourteen named events now has an emit point. The four that
were missing all sat on paths owned by earlier tasks:

- AppointmentCompleted fires from both status-change routes, after the row is
  saved. A rejected transition or a version conflict leaves no event; otherwise
  the completed count runs ahead of the appointments themselves.
- AppointmentRescheduled is a third event, not a replacement. A rebook is a
  confirm plus a cancel, and a consumer that only hears the cancel messages a
  patient who still has an appointment.
- ResourceBlocked / ResourceReleased are a pair. Capacity coming back has to be
  as audible as capacity going away, or the resource reads as permanently taken.

Publishing is now on the scheduler rather than an unregistered command: the
logic moved out of PublishDomainEventsCommand into OutboxPublisher so the
recurring message and the manual command share it, and the existing
worker-scheduler container consumes it. The scheduler message carries no data
on purpose — what to publish is read from the table, so an event recorded
between two ticks is not skipped. DomainEventMessage routes to async, since a
slow consumer was otherwise slowing the drain itself and its failure marked a
row failed that had in fact been delivered.

Panel work that these paths made reachable:

- Cancelling from the appointment page now goes through the policy-aware
  endpoint and shows the penalty preview before the confirm, so the operator
  does not discover the patient's penalty after the fact. The cancellation
  service writes the timeline entry itself and accepts a reason, which that
  path previously dropped on the floor.
- Rescheduling reuses the booking page under ?rebook=<uuid> — the search and
  hold steps are identical and only the final step differs. The doctor picker
  is hidden there: a reschedule is not an invitation to change doctors.
- A new GET /appointment/{uuid}/segments exposes the recorded plan. An empty
  list is not an error, it means the appointment is slot-based, and that is
  exactly what gates the resource-mode reschedule button.

AppointmentInvoiceCard no longer crashes the whole detail page when an older
invoice has no discount breakdown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 21:27:55 +03:30
hamedandClaude Opus 5 3c43955800 feat(events): domain event outbox and the two reports that close the loop
Tasks 07 through 13 each changed something the rest of the system might want
to know about, with no contract for saying so. And task 05 shipped a powerful
segment editor with no feedback on whether a clinic defined its segments right.

Events
- A closed list of names, because a consumer branches on the string and a
  one-letter typo would produce an event nobody hears and no error either
- Payloads carry uuids and scalars only; non-scalars are dropped, not
  serialised, so a consumer always fetches fresh rather than reading a stale
  detached entity
- record() deliberately does not flush: the event row commits with the change
  it describes, so a rolled-back transaction leaves no event behind. A test
  pins exactly that
- app:events:publish drains the outbox; five failed attempts park a row with
  its error rather than deleting it, because a silently dropped event is a
  loss with no trace. app:events:prune only ever removes published rows

Reports
- Resource utilisation separates available, occupied and active minutes.
  The gap between occupied and active is what exposes a bad segment
  definition, and available is multiplied by capacity so a three-chair room
  does not read as permanently over 100%
- A resource with no calendar reports utilization: null, not zero — dividing
  by zero means something different from being idle
- Plan accuracy compares planned against actual duration per service and
  flags both directions: running short wastes capacity that could have been
  sold. Its row links straight to editing that service's segments, because a
  report with no route to a fix does not get read

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 12:27:54 +03:30
hamedandClaude Opus 5 fc504f4415 feat(course): treatment courses with protocol-driven session planning
Laser is six to eight sessions; the previous design only knew single
appointments, which is the exception rather than the rule.

- CourseProtocol per service: session count and three distinct spacings —
  min is the earliest that is clinically allowed, ideal is best, max is where
  the course starts losing its effect
- Starting a course creates every session up front as `planned` and copies the
  protocol's numbers and per-session params, so changing the protocol tomorrow
  leaves a running course alone
- Suggestions anchor on the last *completed* session, not the course start:
  when session 2 slips, session 3 moves with it
- Slots are ranked by distance from ideal, not by earliest available — day 21
  is worse than day 27 when 28 is the target
- book-all is all-or-nothing inside one transaction, with a moving anchor and a
  90-day horizon; sessions past the horizon stay planned and are reported, not
  treated as failures
- The effective minimum is the stricter of the protocol and the task-09 spacing
  policy, so a clinic rule never fights the protocol
- Cancelling one session returns only that session to planned; abandoning a
  course does not cancel its appointments, which stays an explicit decision

One active course per (patient, service) via active_course_key, the same
partial-uniqueness trick as Appointment::activeSlotKey.

Admin: CourseProtocolsPage, TreatmentCoursePage and a courses tab on the
patient record.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 11:33:07 +03:30
hamedandClaude Opus 5 ca9648732d feat(package): session packages backed by a credit ledger
"Six laser sessions" is the common case in an aesthetics clinic: the patient
pays once and books the sessions later.

Credit is a ledger, not a counter. No table has a remaining/used_count column
and a schema test enforces that — the balance is always SUM(delta) over
append-only rows, so every number a patient sees has a full history behind it.
Corrections are new rows, never edits.

- purchase / consume / refund / adjustment / expiry, each with a reason, an
  author and the appointment it belongs to
- consume happens in confirm(), never in quote(): if the preview consumed, a
  page refresh would cost the patient a session
- cancelling adds a refund row; the consume row stays
- FIFO across a patient's packages — the oldest is closest to expiring
- an empty package is not an error, it just does not apply and the patient pays
- adjust/expire need a doctor or clinic role, and adjust always needs a reason
- app:package:expire writes the closing row so "where did my 3 sessions go?"
  always has an answer

Consume takes a pessimistic lock on the one package row. That is the opposite
of task 07's slot buckets, and docs/api/package.md carries the table explaining
why, so nobody unifies them later.

Idempotency checks for an existing consume row before inserting rather than
catching the unique violation: in Doctrine that exception closes the
EntityManager and burns the rest of the request. The unique key stays as the
last line of defence.

Admin: PackagesPage, a packages tab on the patient record, and a ledger page
whose running-balance column shows where the final number came from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 11:11:03 +03:30
hamedandClaude Opus 5 584ea4067f feat(policy): six-category policy engine wired into the booking flow
Rules become data instead of code: a clinic can say "laser under 18 requires
parental consent" without a deploy.

Engine
- Policy / PolicyVersionLog entities, closed field/operator/effect lists per
  category (PolicySchema), condition validation at write time
- PolicyResolver: priority -> specificity -> age, combining effects by
  veto / max / sum / union
- A missing fact fails its clause instead of silently passing it
- Policies are drafts until activated, and are versioned rather than edited

Wiring
- selection -> ServiceSelectionValidator
- eligibility + spacing -> BookingPolicyGuard, at hold time not confirm time
- resource + timing -> AppointmentPlanBuilder, including template-less services
- pricing -> PricingEngine, alongside (not replacing) the manual discount

The condition column is named condition_json: `condition` is a MariaDB keyword
and broke every INSERT.

Tests: 17 in tests/Policy including NoPolicyRegressionTest, which pins that a
clinic with no policies sees byte-identical output to task 08.
Docs: docs/api/policy.md (real captured JSON) + docs/architecture/policy-engine.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 10:19:19 +03:30
hamedandClaude Opus 5 34b07421bd feat(pricing): date-ranged price lists and immutable appointment invoices
Section 12 and the fifth closing rule: changing a price never changes an
already-booked appointment.

The pricing chain already existed and worked. Two things were missing. Tariff only
carries a year, so a rate change starting in Mehr could not be expressed — PriceList
now takes an explicit date range and Tariff remains the layer beneath it. And an
appointment stored a single number, so after a price change or a discount nobody
could say what those 2,400,000 rials were made of.

Price resolution walks four layers per service and takes the first hit: branch
override, then the covering price list, then the yearly tariff, then the service's own
price. The last one is the guarantee that a date no list covers still returns a price
rather than zero or an exception. breakdown.sources reports which layer answered, so a
surprising number can be traced instead of guessed at.

Two calculation decisions worth stating. Tax is computed on the patient's share, not
the gross — a patient does not pay tax on the portion the insurer covers. And a
discount larger than the amount floors the total at zero rather than going negative,
because a negative balance would mean the clinic owes the patient money, which nothing
downstream is built to mean.

A branch-specific list deliberately does not count as overlapping a general one; it
takes precedence instead. Treating them as a conflict would have made per-branch
exceptions impossible to express. Lists have no effect until activated, so drafting
next quarter's prices cannot disturb today's.

PriceSnapshot has no setters and a unique key on appointment_id: a snapshot that can
be edited is not a snapshot, and two invoices for one appointment would be two truths.
Corrections are a new row plus voiding the old one. Invoices are written during
confirm with the prices of that moment — computing later would let a rate change
between booking and invoicing produce a different number, which is exactly what rule
five forbids.

12 tests. The one that matters is
testBookedAppointmentKeepsItsOriginalInvoiceAfterAPriceChange: book, double the
service price, watch quote return the new number while the appointment's invoice
returns the old one. Without it rule five is only a claim.

1220 tests / 3551 assertions. phpstan back at its 14-error baseline. Frozen slot
contract green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 09:42:21 +03:30
hamedandClaude Opus 5 4395eea56e feat(booking): multi-resource holds and confirmation with a database-level guarantee
Section 11 and the third closing rule of the design document: preventing a double
booking is the database's job, not the code's. Any "is it free?" check in PHP has a
race window between the read and the write — two concurrent requests both see free
and both write.

MariaDB has no range EXCLUDE constraint, so every occupied interval is broken into
fixed five-minute buckets under UNIQUE(resource_id, bucket_at, seat). The code only
INSERTs; a rejection from the database *is* the answer. `seat` carries capacity: a
three-bed room has seats 0..2, allocation walks upward on each collision, and the
fourth concurrent hold finds nowhere to sit. Counting capacity in PHP would have
rebuilt the very race this removes.

Buckets are written through DBAL rather than the ORM on purpose: a unique violation
raised inside flush() closes the EntityManager, and the next seat attempt would then
fail with "EntityManager is closed", hiding the real outcome.

Occupancy is one row per (segment × resource). The reference test asserts the payoff
directly: for a 55-minute appointment of numbing / waiting / laser, the room gets
three rows and the operator only two — the operator holds nothing during the wait and
stays bookable for someone else.

A partial hold never survives. If the second resource has no room, the first is
released and the hold itself removed; otherwise a resource stays locked for an
appointment that will never exist.

Confirming does not re-reserve anything — the seats were taken at hold time and only
the label changes. Re-reserving on confirm would reopen the race the hold closed.
Cancelling marks rows `released` instead of deleting them, because the history of
which resource was busy when is the input to the utilisation reports; the uniqueness
buckets *are* deleted, or that interval would stay locked forever.

Expired holds are released by the existing scheduler rather than a new one. That
exposed a bug in my own change: the flush guard used $count, which now includes
released holds, so reset([]) could pass false to save(). It is guarded on $expired.

The appointment itself is still built with the existing constructor, so
active_slot_key, events and the payment path behave exactly as before — the
multi-resource occupancy sits beside them, not instead of them.

12 tests. Two matter most: the second hold on the same resource and interval getting
409, and a test that writes a duplicate bucket row over a *separate connection* and
expects the unique-key violation — if that one ever passes silently, the guarantee
had moved back into the code.

1208 tests / 3495 assertions. phpstan at its 14-error baseline. Frozen slot contract
green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 09:28:55 +03:30
hamedandClaude Opus 5 24534ec483 feat(availability): multi-resource availability engine
Section 10 of the design document, and the payoff for tasks 01–05. The engine slides
a multi-segment plan across resource calendars and answers which times are actually
possible, with a suggested resource for each role. Until now the only conflict the
system checked was the doctor's; rooms, devices and operators did not exist.

Allocation is per *role*, not per segment, and that is what returns the wasted
capacity. An operator with no requirement during "waiting for the cream" is simply
not examined for those minutes, so another patient can use them. The reference test
encodes exactly that: patient A holds 10:00–11:00 while the operator is only busy
10:00–10:05 and 10:35–11:00, and patient B is offered a slot inside the gap with the
second room assigned. The spec says the task is not verified without that scenario.

One resource is chosen for every segment that needs its role, not independently per
segment — otherwise the operator in segment 1 and segment 3 could be two different
people and the patient would change hands mid-treatment.

Occupancy is stored one row per (segment × resource) rather than one per appointment.
The granularity is the whole point; a row per appointment would re-create the
single-interval model the design rejects. Reserved intervals are widened by each
resource's setup/cleanup, because the resource genuinely is not available then.

booking_mode gains a third value, resource, alongside slot and service. It is purely
additive: the default stays slot, no environment moves on its own, and a location
that has not opted in keeps the untouched legacy path. The frozen slot-mode contract
stays green.

Performance is a test, not a hope: 30 days, 20 resources and 500 existing bookings
complete well inside the 500ms budget. Every input is read once and the rest is in
memory — no query inside the day or candidate loop — and candidates are generated
only from the free windows of the scarcest role, which turns tens of thousands of
candidates into a few hundred.

An empty result is not an error and not a 404: it carries
reason: "no_capacity_in_range" so the caller does not have to infer meaning from
emptiness.

Also fixed a genuinely intermittent test defect: NumericFieldNormalizerTest padded a
random number with the three-byte Persian "۰" using byte-based str_pad, producing
broken UTF-8 whenever the number was short. It failed roughly at random. The improved
assertion message added earlier is what identified it immediately.

1196 tests / 3414 assertions. phpstan at its 14-error baseline.

Resource-picking strategies, the availability cache and the settings UI are recorded
as outstanding in the checklist with reasons — the cache in particular would be
premature while the performance test passes comfortably without it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 21:21:33 +03:30
hamedandClaude Opus 5 22c89fbae4 feat(plan): multi-segment appointments with per-segment resource requirements
Section 7 of the design document, and the reason the whole resource layer exists.
A laser session is not one block: numbing cream (5 min, room + operator), waiting for
it to work (30 min, room only), the laser itself (20 min, room + operator + device),
aftercare (5 min, room + operator). Under the single-interval model the operator is
locked for all 60 minutes while actually working 30 — half the capacity thrown away.

AppointmentPlanBuilder turns (service, selected items, branch, patient) into a plan:
segments with offsets, durations and resource requirements. It deliberately assigns
no absolute time and no specific resource — that is the next task. This only produces
the *shape* of the appointment.

Segment duration comes from one of two sources. A fixed segment carries its own
number; an item-driven one gets its duration from task 04's DurationCalculator, so
"the laser itself" grows with two treated areas while "waiting for the cream" does
not. One number could not have expressed that.

Three contracts worth stating:

- A service with no segment templates falls back to a single continuous segment
  requiring the doctor resource — exactly today's behaviour. Without it every
  existing service would have become unplannable overnight.
- A segment with no requirements is valid: "waiting at home" consumes time but
  occupies nothing.
- same_gender_as_patient with an unknown patient gender is a 422, not a silently
  dropped requirement. Dropping it quietly would route the patient to a resource the
  clinic said must not serve them.

When no resource qualifies, the error names the role, the skill and the branch —
"no female operator with the skill «Alexandrite laser» is available at «Central»" —
rather than an empty result the caller has to interpret (section 10).

occupancy_offset carries each requirement's setup/cleanup minutes for the availability
engine. It is taken as the maximum across candidates, because the builder does not yet
know which resource will be picked and under-reserving means the next appointment
lands on top of the cleanup.

11 tests covering the document's reference example (offsets 0/5/35/55, total 60),
item-driven scaling, the no-template fallback, all three gender-constraint outcomes,
merging and both caps. 1186 tests overall. phpstan back at its 14-error baseline;
slot-mode frozen contract green.

The admin segments page is not built; the checklist records it with a target. The
backend and preview endpoint are complete and consumable without it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 21:07:30 +03:30
hamedandClaude Opus 5 b1b06c1b36 feat(catalog): dual durations, item groups, relations and branch overrides
Section 5 of the design document rejects summing service durations. "Face + bikini"
is not 15+12=27 minutes but 15+8=23 — preparation and settling the patient do not
happen twice. Seven wasted minutes times twenty appointments a day is an hour of
capacity lost daily, and AppointmentController was doing exactly that plain sum.

Each item now carries a solo duration and an additional duration. One item counts at
its solo duration and the rest at their additional; the anchor is the item with the
*largest* solo duration rather than the first one selected. Anchoring on selection
order would have let the same basket cost different amounts depending on click order,
so a patient could buy a shorter appointment by reordering. Largest-first is also
conservative: no combination is ever under-estimated, and under-estimating pushes the
next appointment on top of this one.

additional_duration_minutes stays NULL by default and the entity reads NULL as "same
as solo", so every existing service keeps behaving exactly as before — the 236
appointment-domain tests pass unchanged. The old duration_minutes column is kept and
written in step rather than renamed, because other consumers still read it.

ServiceBookingCalculator now delegates to DurationCalculator, which is the one-line
change task 00 predicted when it deliberately preserved the naive sum.

Selection rules are data, not policy: min/max per group is a number, and "bikini does
not combine with full body" is a relation. Putting either in a rules engine means
several rules per service and nobody able to explain a rejection. Validation returns
*all* errors at once rather than the first, since a user with three problems should
not make three round trips. Prerequisite cycles are rejected at write time — storing
both "A requires B" and "B requires A" would make every selection permanently invalid.

Named CatalogCategory, not ServiceCategory: that name is already an insurance enum
(outpatient/inpatient) living on ServiceItem itself, so the two would have collided in
the same file's imports.

Also fixed a defect the tests caught: breakdown() used $overrides[$id]?->… on a key
that may not exist, which warns instead of yielding null.

1175 tests / 3289 assertions. phpstan measured at 14 errors both with and without
this change (verified by stashing). Slot-mode frozen contract green.

The admin UI tab for groups and relations is not built; the checklist records it as
outstanding with a target. The backend is complete and
POST /service-selection/validate is consumable without it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 18:44:02 +03:30
hamedandClaude Opus 5 4d722830e3 feat(resource): calendar UI, holiday admin, backfill and interval algebra
Completes task 03. The resource calendar page edits weekly shifts, records leave and
maintenance, and previews two weeks of availability with a Persian reason for every
empty day — showing the raw server key ("outside_branch_hours") to a user would have
been a meaningless message. The preview is labelled raw on the page itself, because
booked appointments are not subtracted yet and mistaking it for bookable time leads
to overbooking.

The interval algebra moved to src/Shared/Time/TimeInterval.php with twelve unit
tests: tasks 05 and 06 need the same union/intersect/subtract, and a second
implementation is how two subtly different definitions of "overlap" get born. The
half-open [start, end) contract is what makes a shift ending at 13:00 and one
starting at 13:00 not overlap.

AvailabilityQueryCountTest locks the query count flat: one day and ninety days cost
exactly the same number of queries. Without it the first refactor can put a query
inside the day loop and a 90-day response quietly becomes hundreds of queries —
something only production would reveal.

app:resource:calendar:backfill derives shifts from existing WeeklySchedule sessions,
so the resources created in task 02 are not left with empty calendars. It skips any
resource a user has already configured, which is also what makes it idempotent. The
weekly schedule itself is untouched: this is a copy, not a migration.

Also added --replace to the holiday import. upsert keys on the date, so a row written
with a *wrong* date can never correct itself — re-running just creates the right row
beside the wrong one. That is exactly what happened after fixing the Jalali
conversion bug, and it was caught while capturing real responses for the docs.

Deferred with reasons recorded in the checklist: seasonal shift validity (two
nullable columns can be added later without backfill, so "needed from day one" does
not hold), and a Jalali date picker in the exception form.

1154 tests / 3229 assertions. phpstan at its 14-error baseline, none in touched
files. tsc clean, vitest 622 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 18:28:48 +03:30
hamedandClaude Opus 5 1fdfdf9e48 feat(resource): resource calendars, exceptions and national holidays
Section 9 of the design document builds free time by subtracting seven layers.
Four existed and all of them hung off the doctor. This adds the missing ones and
puts them on the resource:

  branch hours ∩ resource shifts − national holidays − resource exceptions

Booked appointments and holds are deliberately NOT subtracted here — those are
tasks 06/07, as is intersecting several resources. The method is called
rawAvailability() so nobody mistakes the output for bookable time. Nothing in this
change calls SlotCalculatorService; the existing slot path stays frozen.

Four types of exception (leave, absence, maintenance, ad-hoc closure) share one
table because all four are "an interval subtracted from a resource's calendar";
splitting them would mean four queries per availability lookup instead of one.
Holiday overrides work in both directions: a clinic that opens on a public holiday,
and a clinic that closes on an ordinary day.

Every empty day carries a reason (national_holiday, no_shift, branch_closed,
outside_branch_hours, exception, …). Without it an empty response is
indistinguishable from a bug and the first person debugging has to read four tables
by hand.

Three real defects found on the way:

JalaliDateService.gregorianToJalali() was wrong — it returned [3006, 7, 3] for
2026-07-30 instead of [1405, 5, 8], roughly 1601 years off. jalaliYear(),
jalaliMonth(), jalaliMonthRange() and jalaliYearRange() all inherit that, so the
representation reports built on them have been filtering by nonsense ranges. The
class's own formatDateTime() was already correct because it used IntlDateFormatter,
so both conversions now go through the same mechanism, and JalaliDateServiceTest
pins Nowruz and the 6/31→7/1 boundary. There were no tests before, which is why
nobody noticed.

TimeInterval added a seconds-based midnight to a minutes-based interval, turning an
eight-hour shift into eight seconds. The conversion is now an explicitly named
minutesToAbsolute() so the unit change cannot happen silently again.

HolidayService.upsertNational() persisted but left flushing to the caller. Every
HTTP request reboots the kernel, so the caller often held a different
EntityManager: persist landed on one, flush on the other, and nothing was written
with no error at all. The write is now self-contained.

119 tests across tests/Resource, tests/Branch and tests/Representation. phpstan
clean on both touched domains.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 18:14:23 +03:30
hamedandClaude Opus 5 964c09cc00 feat(resource): resource types, resources, skills and pools
The document's first golden rule is "the calendar belongs to the resource, not to
the doctor". Today the only thing that can be occupied is a doctor, and ClinicStaff
is a label on services and appointments with no calendar, capacity or skills. This
adds the layer underneath: anything that can be busy — doctor, operator, assistant,
device, room, bed, chair.

Two corrections to the planned schema:

- `address_id` → doctor_addresses, not `branch_id` → a new branches table. The
  branch already exists and is the address (task 01).
- UNIQUE is (doctor_id, address_id), not (doctor_id). A WeeklySchedule is per
  (doctor, clinic) but every session inside it carries its own location_id, so one
  doctor already works at several addresses within one environment. Keying on the
  doctor alone would have made that unrepresentable — and task 03 gives each
  resource its own calendar, which is exactly per-location.

Design points worth keeping:

- Resources bridge to Doctor/ClinicStaff/Room rather than absorbing them; those
  three have live consumers (appointments.doctor_id, service_item_staff, the public
  site) and subclassing would mean migrating all of them at once. At most one bridge
  column is non-null, enforced in the entity because MariaDB will not reliably
  enforce a multi-column CHECK.
- Capacity is concurrency: a three-bed injection room is one resource with capacity
  3, not three resources, so occupancy in task 06 stays a COUNT against a limit
  instead of a merge of three calendars. A person resource is refused capacity > 1.
- Skills are a table, not rules. With 50 operators and 200 services, expressing
  "who may operate what" as policy would mean 10,000 rules.
- findEligible() uses HAVING COUNT(DISTINCT …) because "skills A and B" means both;
  a plain IN would have matched a resource holding only one.
- setup/cleanup minutes occupy the resource without being part of the patient's
  appointment, and are per-resource — distinct from the existing per-doctor
  WeeklySchedule.meta.buffer_minutes, which stays untouched.

Two real bugs found by running the backfill against real data rather than fixtures:

ResourceLinker::systemType() persisted a type without flushing, so the next lookup
missed it and created a second — the run died on "Duplicate entry 'doctor-1-staff'
for key uniq_rt_tenant_code". It now keeps an identity map for the unit of work.

The command looped over every WeeklySchedule once per environment, which is
quadratic and never finished on real data. Doctors are now a single pass keyed by
the schedule's own environment. It also flushes per environment and accepts
--pair=clinic:12, so one bad row cannot close the EntityManager and abort a
fleet-wide run, and operators can re-run for a single clinic.

Staff are the one case that cannot be derived: nothing records which branch they
work at. Rather than guessing the first one and seating them in the wrong building,
multi-branch environments are skipped and reported.

88 tests, 230 assertions across tests/Resource and tests/Branch. phpstan clean on
src/Resource.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 17:34:36 +03:30
hamedandClaude Opus 5 b589a851d0 feat(booking): make PATCH derive appointment duration from its services
In service mode PATCH accepted any duration and only updated the single
service_item column while the service_items collection stayed untouched, so an
edit could leave an appointment with old services and a new length. A 45-minute
service could be shortened to 20 and the next patient would sit on top of it.

Services are now resolved before the time block (duration depends on them) and
the stored end must equal start + total minutes. Reserve entries are exempt:
they carry slot_start == slot_end and occupy no interval, but they do store the
computed duration so a later conversion does not lose it.

No convert-reserve endpoint was added: PATCH already converts a reserve to a
timed appointment via rescheduleTo($start, $end, $isReserve), which refreshes
active_slot_key itself. Project rule 8 — a new endpoint needs an existing one to
be insufficient even after extension.

Slot mode is untouched: with booking_mode = slot the duration stays null and not
one of the new branches runs. Covered by an explicit test.

New error codes are ERR_APPOINTMENT_003/004 (the file only had 001/002).

Task: docs/new_feture/taskes/task-00-service-mode-completion/
Slot-mode contract: unchanged (--group=slot-mode-frozen green)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 12:57:48 +03:30
hamed 57aeb40934 feat: add staff role functionality with dashboard access and service management
- Implemented SidebarStaff component tests to ensure staff users see only their dashboard and services.
- Created StaffMyServicesPage to display assigned services for staff users.
- Added migration to link clinic staff rows to user accounts for ROLE_STAFF access.
- Defined StaffPermissions class for static permissions related to staff role.
- Introduced StaffRouteGuardSubscriber to restrict API access for staff users.
- Developed StaffAccountService for managing staff user accounts and linking them to clinic staff.
- Added comprehensive tests for StaffAccountService to validate user creation, mobile number handling, and account attachment.
- Implemented tests for staff dashboard access to ensure proper permissions and access control.
- Created tests for staff login context to verify correct environment visibility based on user roles.
2026-07-30 10:18:41 +03:30
hamed 4f4bce9fe2 feat(migrations): update franchise to percentage in tenant_insurances and tenant_service_coverage
- Changed franchise_rials to franchise_percent in tenant_insurances and tenant_service_coverage tables.
- Reset old rial values to 0/NULL as they are not convertible to percentage.

feat(command): add SeedInsuranceScenarioCommand for seeding insurance data

- Implemented a command to seed supplementary insurance contracts, patients, and claims for a specified doctor.
- Includes functionality for purging existing scenario data and generating new entries with predefined contracts and patient scenarios.
2026-07-29 13:28:59 +03:30
hamedandClaude Opus 5 a95ee9a618 feat(tenant): give a tenant pair to the children reachable by a request uuid
Phase 7 concluded that aggregate children needed no column of their own,
because every repository query anchors to its root. That was true of the
repositories, and it missed the case where the anchor never happens:

    $item = $this->serviceItemRepo->findByUuid($data['service_item_uuid']);

A lookup by uuid is itself an unanchored query, and TenantFilter cannot help
when the table has no column to filter on. All three leaks phase 7 found had
exactly this shape, including the one that put another environment's service
price on a patient's invoice.

Measuring which children are actually loaded that way gives eight of the
twenty-five — service_items (15 call sites), patient_sessions (7),
session_payments, patient_notes, patient_calls, patient_messages,
patient_attachments, patient_medical_records. They now carry their own pair
and leave AGGREGATE_CHILDREN; the other seventeen are only ever traversed
from their root and stay as they were.

The pair is derived from the root inside the constructor rather than passed
in, so no creation site can forget it and the value has one source. A root
never changes environment, so the copy is written once and cannot drift.

This is defence at the data layer rather than at the entry point: a forgotten
guard now returns nothing instead of another environment's row. The existing
TenantOwnershipChecker guards stay as the outer layer.

Verified against an imported production database: 8 tables backfilled, zero
rows unmatched, zero rows inconsistent with their root. Dropping the column
again turns the leak test red.

Tests: 911 backend (+5). PHPStan unchanged at 17.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 16:50:51 +03:30
hamedandClaude Opus 5 c9d4348c46 feat(tenant): mark the financial tables with their owning environment
Phase 6 of the tenant series. GlobalTables::DEFERRED is now empty and the
coverage test asserts it stays that way.

payments carries the (entity_type, entity_id) pair and belongs to the
receiving side, never the payer: an appointment payment takes the
appointment's environment, a subscription takes the environment its buyer
owns, and an SMS wallet top-up takes the wallet's. The patient never chose
an environment, so TenantFilter stays off for them and they still see their
own payment.

Three corrections to the analysis the phase was planned on, each backed by
the code or the data rather than the plan:

- A third payment type exists. Payment::TYPE_SMS_WALLET is created in
  SmsWalletController and already carries its environment in the metadata;
  without assigning it the write would fail at flush.
- clinic_subscriptions has no user_id, and its trial rows carry no payment,
  so it cannot drive the subscription backfill. The environment is derived
  the way handleSubscriptionActivation derives it — and that method now
  reads the pair off the payment instead of re-deriving it, so a payment and
  the subscription it buys can no longer land on different environments.
- WalletTransaction is not a child of Payment. payment_id is nullable and
  none of the four creation sites set it; the wallet is a person's, with a
  running balance per user. It and Settlement, which withdraws from that same
  wallet, are global with a recorded reason instead.

bank_accounts and pos_devices move from the registering user to the
environment. Their pair is deliberately nullable: nothing in the existing
data says which of a multi-environment owner's cards belongs where, and
guessing would point real money at the wrong account. Ambiguous rows stay
unassigned and the migration reports how many. The cost is that such a row
is invisible in every environment, so the owner reaches it through a
user-scoped lookup that runs outside the filter, and assigns it with
PATCH .../{uuid}/environment. The admin panel marks those rows and offers
the assignment.

Tests: 896 backend (+11), 570 frontend (+4). PHPStan unchanged at its 17
pre-existing errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 15:06:28 +03:30
hamedandClaude Opus 5 d2f4b5c428 fix(tenant): check the environment wherever a uuid comes from the request
Phase 7 was scoped to guard aggregate children, which the Doctrine filter cannot
reach. Measuring first — as the plan required — moved the target: all 22 children
and their 20 repositories were already sound. Every list query anchors on its
root, and ServiceItemRepository even joins service_sections and filters on the
pair by hand. A repository-level guard would have found nothing.

The real exposure was one layer up. Where a uuid arrives from a request body or
query string, the entity it names is loaded by uuid alone, and the filter is no
help: aggregate children have no tenant column, and a panel user who never chose
an environment is not filtered at all. Three leaks, each proven by removing the
fix and watching the new tests go red:

- GET /api/v1/appointment-service-slots accepted service_item_uuids from any
  environment. Existence, bookable state and duration leaked through the error
  messages and the returned slots. The booking path in the same controller had
  guarded this since it was written; the slot path never did.
- POST /api/v1/my/appointment attached service_section_uuid, service_item_uuid,
  staff_uuid and the service list without any check, and persisted them onto the
  appointment. A write, not just a read.
- PatientService did the same in all three of its loops — pricing, session
  create, session update — so another environment's service price entered the
  invoice and its SessionService row was stored, staff included.

TenantOwnershipChecker is the single place that answers "does this belong to the
current environment?". It reads getEntityType()/getEntityId(), so ServiceItem now
delegates that pair to its section: an aggregate child exposing the tenant it
inherits. An entity that exposes no pair throws rather than returning false —
silence here builds an always-closed guard, which is its own bug.

TenantLookupInventoryTest keeps a per-file count of these lookups. It earned its
place immediately: the first run found more sites than the manual grep had, and
reviewing them turned up the third PatientService loop. StaffController looked
unguarded until read properly — ownsStaff sits two lines below the null check.

One assertion was wrong before it was right: the create-path test read
`$session['services'] ?? []`, which passes vacuously. It now counts the stored
rows through the repository, and fails without the fix.

Tests: 879 passing. PHPStan unchanged at its 17 pre-existing errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 13:39:59 +03:30
hamedandClaude Opus 5 6d2fd564e2 chore(tenant): audit the paths the filter cannot reach
Phase 5, the last of the tenant-marking series. The Doctrine filter added in
phase 4 does not see raw DBAL, so every place that writes SQL by hand was read
and classified rather than assumed safe.

The audit found no code to fix. ClaimRepository was the only tenant-owning table
reached by raw SQL, and all three of its queries already close on
c.entity_type/:entity_id. That protection had no test, so it now has one: the
claims dashboard is the only tenant surface whose isolation depends entirely on
a hand-written WHERE, and nothing would have reported its removal.

Everything else falls outside the question. AdminApiController is cross-tenant
on purpose behind a class-level ROLE_ADMIN. RepresentationActionController only
counts doctors, scoped by representation_id. CategoryImporter interpolates a
table name, but it comes from a hardcoded const map behind isValidBundle() and
ROLE_ADMIN, so it cannot be steered by input. The purge and seed commands are
console-only, dry-run by default, and blocked from prod at the kernel. The
health check is SELECT 1 and the logger writes to a global table. getReference()
appears once in src, on User, which is global.

app:tenant:dump gives one environment's rows as SQL — the practical benefit of
database-per-tenant without its cost. It reads the table list from metadata using
the same test the filter applies, so a table that gains a tenant pair later is
included automatically instead of being silently missed. The --tenant value ends
up inside a --where clause and an argv entry, so it is validated by a closed
regex rather than escaped; seven malformed inputs are covered, including SQL and
shell injection attempts.

Verified by running it against the dev database: a real clinic produced 20 tables
with only that clinic's rows and no doctor-owned row, an unknown id exited
non-zero with a Persian message, "clinic:1 OR 1=1" was refused, and a tenant with
no data still produced a valid file.

Not verified: browser-level checks of the admin panel and the public site. The
OTP login is behind an Altcha proof-of-work, so no interactive token was
obtained. What was checked instead: the admin SPA type-checks clean, the public
doctor and specialty endpoints answer 200 with cross-tenant results, and neither
nobat724_front nor clinic-pro-tauri references owner_type, owner_id, clinic_key
or db_type anywhere. The functional suite already exercises the same HTTP path
with real JWTs and the subscriber active.

Tests: 856 passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 12:46:30 +03:30
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
hamedandClaude Opus 5 2e0888e0ef refactor(tenant): give every table one spelling of the tenant pair
Phase 3 of the tenant-marking series. The same concept was written four ways,
and the Doctrine filter arriving in phase 4 keys on the field name — so the
tables using a different spelling would have been skipped silently, which is
exactly the leak this work exists to prevent.

- discount_rules: owner_type/owner_id renamed to entity_type/entity_id. Pure
  rename, no data moves.
- doctor_secretaries: owner_type plus a nullable clinic_id replaced by the
  shared pair. The environment now comes from the clinic argument alone, so the
  inconsistent combination (owner_type='clinic', clinic_id=NULL) can no longer
  be constructed, and the redundant constructor parameter is gone.
- user_active_context: added db_type, so resolving an environment is one lookup
  instead of "try clinics, then try doctors". Filled from the type already
  present in available_contexts.
- entity_type is VARCHAR(10) in all twenty tenant tables; four of them were 20.

Behaviour change, the only one in this series: the doctor_secretaries unique key
went from (doctor_id, secretary_id, owner_type) to (doctor_id, secretary_id,
entity_type, entity_id). With clinic_id outside the key, one secretary could not
be assigned to the same doctor in two clinics — the second row collided on
owner_type='clinic'. The duplicate check in SecretaryController had the same
blind spot and would have rejected the request before the database saw it; both
are fixed together.

Correcting an assumption from the phase-3 plan: mobile_verification_otp.entity_type
really is a tenant pair. NotificationMobileController validates the target against
['doctor','clinic'] and stores that entity's id, so the column was normalised with
the rest rather than treated as unrelated.

TenantOwnedTrait gained assignTenantPair() for callers that resolved the pair as
scalars and hold no entity — building an EntityContext from scalars would produce
one where isClinic() is true but ->clinic is null, breaking consumers silently.

tests/ApiTestCase::createUser now retries on a duplicate mobile. db_test is never
reset and already holds ~38k users, so the 9-digit random draw collided often
enough to fail unrelated tests a few percent of runs.

Tests: 830 passing. PHPStan reports no new errors on the changed files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 11:56:57 +03:30
hamedandClaude Opus 5 d53874ff50 feat(tenant): mark the booking tables with their owning environment
Phase 2 of the tenant-marking series. appointments, weekly_schedules and
date_overrides kept their environment implicit in a nullable clinic_id, so
every query that wanted "this environment's rows" had to rebuild
clinic_id IS NULL ? doctor : clinic itself. The two calendar tables also
depended on a MariaDB-only generated column, clinic_key = IFNULL(clinic_id, 0),
purely to make a unique key work across NULLs.

All three now carry the (entity_type, entity_id) pair that service_sections,
patient_records and clinic_staff already use, via a shared TenantOwnedTrait.
The pair is a deliberate denormalisation of clinic_id/doctor_id: the automatic
tenant filter and the tenant-leading indexes both need a real column, and
neither can be built on an IF() expression.

- Unique keys keep doctor_id alongside the pair. A clinic has several doctors
  and each has their own schedule, so (entity_type, entity_id) alone would
  reject the second doctor.
- clinic_key is gone from both calendar tables.
- appointments gained tenant-leading indexes; EXPLAIN on the panel's list query
  now picks idx_appointments_tenant_slot.

Deliberately unchanged, both with the reason already recorded in the code:
active_slot_key stays keyed on doctor + slot, since adding the environment
would let one doctor be booked in their own practice and a clinic at the same
moment. holidays keeps its nullable clinic_id, where NULL means "every
environment" rather than "personal practice" — a meaning the pair cannot carry.

The migration adds the columns nullable, backfills, aborts if any row is left
without an owner, and only then tightens to NOT NULL. It creates each
replacement unique index before dropping the old one, so the tables are never
left unprotected — MariaDB commits implicitly on DDL, so ordering is the only
safety net. It runs its statements through the connection rather than addSql()
because the guard has to sit between the backfill and the NOT NULL change.

Columns are NOT NULL with no default on purpose: a construction site that
forgets assignTenant() fails at flush instead of silently writing entity_id 0,
which the phase 4 filter would then hide from everyone.

Verified on the dev database: 0 rows without a tenant, 0 personal bookings
mismatched against their doctor, 0 clinic bookings mismatched against their
clinic.

Tests: 819 passing (813 + 6 new in BookingTenantTest). PHPStan clean on every
changed file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 11:22:37 +03:30
hamedandClaude Opus 5 1a7bf53577 refactor(tenant): make EntityContextResolver the single context resolver
Phase 1 of the tenant-marking series. The "which environment is this user
working in?" decision was reimplemented in six places, each reading
UserActiveContext.db_uuid and then guessing whether the uuid belongs to a
clinic or a doctor. Every copy was a place the roles could silently diverge.

EntityContextResolver already encoded the right precedence (explicit
clinic_uuid > stored active context > role) but only five files used it, and
it did not recognise secretaries at all: canActInClinic accepted admins,
clinic owners and member doctors, so a secretary's active clinic context
always collapsed to unknown. That gap is why SecretaryAccessChecker carried
its own copy of the logic.

- canActInClinic now also accepts an active DoctorSecretary relation, and a
  matching canActForDoctor covers the personal-practice branch.
- AppointmentAccessChecker, ClinicDoctorAccessChecker, SecretaryAccessChecker,
  PatientRecordScopeResolver, MyAppointmentsController and the secretary
  dashboard all resolve through it now.
- PatientRecordScopeResolver keeps only its real responsibility: which
  doctors' patients are visible inside the resolved environment.
- The resolver answers "where"; ClinicDoctorPermissionChecker and
  SecretaryPermissionChecker still answer "what may you do".

Left deliberately untouched, with the reason recorded at each site:
SubscriptionController, InventoryController and TenantTagController check
ROLE_DOCTOR unconditionally and ignore the active context, so a member doctor
sees personal inventory/tags/subscription even inside a clinic. Switching them
changes what users see, which is a product decision, not a refactor.
AuthController keeps its repository because it writes the active context.

tests/ApiTestCase now seeds the "free" subscription plan. db_test had no such
row, so getEffectivePlan returned null, every hasFeature() was false and 83
tests across Patient, ClinicService, Insurance and Appointment failed with 403.

No schema, route, request, response or error code changed.

Tests: 813 passing (was 730 passing / 83 failing). PHPStan clean on all
changed files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 10:59:22 +03:30
hamed 8d2b0d908a feat: Add online share functionality for secretaries
- Introduced `online_share_enabled` and `online_share_percent` fields in the `doctor_secretaries` table to manage secretary shares from online appointments.
- Added `bank_account` field in the `profiles` table to store user-level IBANs for settlements.
- Created `secretary_earnings` table to track earnings per secretary from online appointments, including a foreign key relationship with `financial_breakdowns`.
- Implemented `SecretaryEarning` entity and repository for managing secretary earnings.
- Developed `SecretaryShareResolver` service to determine which secretaries earn from online payments.
- Added `UserIbanResolver` service to handle user IBAN retrieval and management.
- Created `HasIbansTrait` for entities to manage IBANs in a JSON format.
- Implemented tests for secretary earnings and API endpoints for managing secretary shares and IBANs.
2026-07-25 18:34:18 +03:30
hamed f218bc17ef feat: enhance security audit and CSP configuration for admin interface 2026-07-23 14:12:05 +03:30
hamed 7e847b62c4 feat: update allowed frontend hosts and add clinic-pro.ir domain 2026-07-21 16:51:42 +03:30
hamed 087683e877 feat(csp): add worker-src directive for ALTCHA proof-of-work in admin CSP 2026-07-20 14:34:46 +03:30
hamedandClaude Fable 5 7ac8ddbd25 feat(config): add central maintenance mode
Adds a platform-wide maintenance switch controlled from the admin panel.
A single kernel.request subscriber (priority 6, after the firewall listener)
short-circuits every request with 503, so no controller has to check it and
all API clients — the admin SPA, nobat724_front and clinic-pro-tauri — are
covered at once.

- SiteConfig gains five maintenance_* keys; no entity change, no migration
- MaintenanceService caches the state in Redis for 30s and is fail-open:
  a Redis or database failure never takes the site down by itself
- API responses reuse the BaseController::error() envelope with code
  MAINTENANCE_MODE plus a Retry-After header; browsers get a self-contained
  Twig page (inline CSS, noindex) that renders even mid-deploy
- Whitelist keeps /oauth/*, the login endpoints and /api/v1/admin/settings
  reachable, otherwise an admin could neither sign in nor switch it back off
- Admin bypass falls back to decoding the Authorization JWT, because several
  admin-panel endpoints sit in the public_endpoints firewall (security: false)
  where no token is ever resolved and isGranted always returns false
- A kernel.exception handler at priority 20 covers routing 404/405 and
  firewall 401, which are thrown before the request listener runs
- app:maintenance on|off|status is the escape hatch when the panel is down

Also removes a stray `APP_SECRET = ...` line from .env.dev: the spaces around
`=` are rejected by Symfony Dotenv, which made every console command and the
whole app fatal. The secret already lives in .env.local, as the comment above
that line instructs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 22:01:34 +03:30
hamed 6275b3da1e feat: implement Content-Security-Policy for admin SPA and enhance session cookie security 2026-07-19 21:00:32 +03:30
hamed 74577c2ff6 feat: unify doctor title handling and enhance specialty selection
- Implemented a helper function `displayDoctorName` to prepend "دکتر" to doctor names for consistent display across the application.
- Updated various components (InviteDoctorModal, DashboardPage, DoctorDetailPage, DoctorsPage, etc.) to utilize the new helper for rendering doctor names.
- Modified the DoctorFormPage to automatically add the "دکتر" title in the UI without requiring user input.
- Fixed the EditSpecialtyPicker component to allow multiple specialty selections, resolving a UI bug where only one specialty could be selected at a time.
- Ensured that the backend strips the "دکتر" title from the name during pre-registration and doctor creation processes.
- Added tests for the new functionality, including checks for title handling and specialty selection logic.
- Updated API documentation to reflect changes in name handling and display logic.
2026-07-19 19:57:03 +03:30
hamed cb399ac653 Merge branch 'dev' into main
# Conflicts:
#	docs/api/doctor.md
2026-07-19 16:15:30 +03:30
hamed b05aeaf58b Refactor doctor name handling across the application
- Removed the "دکتر" prefix from doctor names in various components and API responses to ensure consistency and clarity.
- Updated the AppointmentDetailPage, CommentsPage, DashboardPage, RatingsPage, SecretariesPage, and other relevant files to reflect the changes in doctor name formatting.
- Adjusted API documentation to align with the new naming conventions.
- Implemented validation to prevent the creation of clinics without a name and restricted users to a single clinic.
- Added tests to verify that doctor names are stored without titles and that clinic creation adheres to the new validation rules.
2026-07-19 16:09:55 +03:30
hamed d780b5cbb6 feat(validation): enforce naming rules for doctors and clinics to prevent placeholders 2026-07-19 08:38:11 +03:30
hamedandClaude Opus 4.8 3363dfbf22 feat(doctor): expose city/state in public doctor list
The public doctor list had no location field, so multi-domain consumers
could not tell which city domain owns a doctor. nobat724_front's sitemap
worked around this by fetching the list once per city (35 sweeps) and
subtracting, costing ~13s to build the root sitemap.

Location is resolved in bulk by DoctorRepository::findLocationsByDoctors
using the same rule the city_id/state_id filter applies: the doctor's own
address first, falling back to the address of a clinic they belong to.
Without the clinic fallback a doctor could match city_id=X yet report no
city, which would break the sitemap's per-domain partitioning.

city/state are arrays with at most one entry, matching the shape already
used by the doctor detail response and the clinic list. A doctor with no
address reports [] rather than null. Multi-location doctors get a single
primary city, mirroring the canonical rule on the public site.

Also surface the applied page size as meta.limit. Repositories silently
clamp limit to 50, which previously made clients believe pagination had
ended early — this is what truncated the sitemap to 50 doctors.

The clinic doctor-list endpoint gets the same location data so both
endpoints agree.

Location resolution costs at most 2 queries regardless of page size,
asserted directly against the repository rather than through the
endpoint, since the endpoint carries a pre-existing specialties N+1 in
findWithFilters that is unrelated to this change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 08:08:48 +03:30
hamed f1258d206d feat(migrations): add clinic_id context to weekly_schedules, date_overrides, and holidays
- Introduced clinic_id to weekly_schedules, date_overrides, and holidays to differentiate between personal and clinic schedules.
- Updated unique constraints and indexes to accommodate the new clinic context.

feat(command): create AssignScheduleClinicCommand to move schedules

- Added a command to move a doctor's personal weekly schedule into a clinic context.
- Implemented checks to ensure sessions align with the target clinic.

feat(context): implement EntityContext and EntityContextResolver

- Created EntityContext to represent the effective working environment of a request (doctor or clinic).
- Developed EntityContextResolver to determine the execution context based on user roles and active contexts.

test: add ServiceModeContextTest for appointment scheduling

- Implemented tests to ensure service booking respects clinic and personal contexts.
- Verified that financial data is omitted in clinic contexts in InvitedDoctorDashboardScopeTest.
2026-07-18 13:32:56 +03:30
hamedandClaude Opus 4.8 00cb9aaa1a feat(admin): normalize Persian/Arabic digits in every numeric field
Users typing on a Persian keyboard produced two distinct failures. Fields with
type="number" silently returned an empty string — the browser rejects Persian
digits, so the value was lost and saved as empty or zero. Text fields passed the
Persian characters straight through to the database, where a mobile stored as
۰۹۱۲… never matches 09… again. The secretary form hit the second case with no
validation at all.

Frontend:
- Adds digitsOnly() and the national-code schemas to lib/utils, plus lib/forms
  with numericField()/latinDigitsField() wrappers for React Hook Form fields.
- Converts every type="number" input to type="text" inputMode="numeric" with
  digit normalization; none remain. Fields that legitimately carry non-digits
  (sheba, landline) only get the digits translated, keeping IR and separators.
- Points the patient national-code and mobile schemas at the shared normalizing
  schemas, which accept Persian input instead of rejecting it.
- Drops two duplicate local digit converters in favour of the shared helper.

Backend:
- Adds NumericFieldNormalizerSubscriber, translating digits in whitelisted
  numeric keys of JSON request bodies under /api/v1/ before controllers run, so
  nobat724_front and clinic-pro-tauri are covered too. Translation only — no
  characters are stripped, non-string values and other keys are untouched.

Three component tests asserted on role="spinbutton" and numeric input values;
both are properties of type="number", so they were updated to match the new
text inputs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 10:38:56 +03:30
hamedandClaude Opus 4.8 27d088c6dd feat: port tauri create-service payment flow to admin session settlement
Port the tauri /files/create-service page (payment mode) to the admin SPA
and back it with real multi-part session settlement:

Backend:
- New SessionPayment entity (session_payments table): partial payments
  per session with method (wallet/pos/cash/card), amount, paid_at, actor
- PatientSession: settlement discount (percent/fixed), discount_rials,
  paid_at, payments relation; remaining debt derived from
  final - discount - paid total
- POST /api/v1/session/{uuid}/payments: register a partial payment;
  wallet method debits the patient wallet; zero remaining marks paid
- PATCH /api/v1/session/{uuid}: accepts discount_type/discount_value
  (null removes) and paid_at, backward compatible
- New error codes: ERR_SESSION_PAYMENT_INVALID/_EXCEEDS,
  ERR_SESSION_DISCOUNT_INVALID
- Migration + 14 functional tests (partial/full/wallet/exceed/discount)

Frontend (admin):
- SessionPaymentPage: two-step stepper (پرداخت ← جزییات) ported from
  tauri AddService payment mode — service cost, settlement discount
  input, Jalali payment date, wallet balance, 4-method payment accordion,
  paid-list box, details summary
- SessionStepper + stepper/payment icons ported verbatim from tauri SVGs
- «تکمیل پرداخت» on SessionServiceCard now navigates to the payment page
  (replaces the small settle modal on PatientDetailPage)
- Routes for patients/ and my-patients/ variants; vitest coverage
- docs/api/patient.md updated

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 13:32:06 +03:30
hamedandClaude Opus 4.8 f028d6841a feat: port wallet charge/withdraw modal from tauri to patient admin page
Add manual wallet withdrawal (debit) endpoint mirroring the offline app's
balance guard, and rebuild the patient کیف پول tab around a single
charge/withdraw toggle modal (quick amounts, تومان→ریال conversion,
transaction filters).

Backend:
- POST /api/v1/patient/{uuid}/wallet/withdraw — creates a debit
  WalletTransaction; 422 ERR_WALLET_INSUFFICIENT when amount exceeds balance.
- ErrorCodes: ERR_WALLET_INSUFFICIENT ('موجودی کیف پول کافی نیست').
- docs/api/patient.md updated.

Frontend:
- usePatientWallet hook (balance + charge/withdraw mutations).
- WalletTransactionModal (toggle, quick amounts, UI-only payment fields).
- WalletTab: charge button, همه/واریزی/برداشت filters.

Tests: backend withdraw success/insufficient/non-positive/ownership;
frontend modal + wallet tab interactions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 11:56:39 +03:30
hamed 34d8a400b7 feat(doctors): enhance doctor listing with bookable sorting and filtering, add JSON_EXTRACT DQL function 2026-07-14 11:25:39 +03:30
hamedandClaude Opus 4.8 537bb8c7b3 feat(patients): phase B2 — attachments (ضمیمه)
Add patient file attachments: a new PatientAttachment entity (record-scoped,
CASCADE) + repository, and endpoints GET /patient/{uuid}/attachments,
POST /patient/{uuid}/attachment (raw-body upload) and DELETE
/patient/attachment/{uuid} (owner-scoped). Factor the shared raw-body upload
logic into FileUploadService. Wire the "ضمیمه" tab in PatientDetailPage
(upload + list + delete). PHPUnit covers list/delete/ownership; Vitest covers
the tab. API docs updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 15:20:00 +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
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 9f56f4aa08 feat(migrations): add ownership fields to doctors table for IRIMC import
- Introduced new columns: owner_status, source, source_ref, managed_by, and claimed_at to the doctors table.
- Created indexes for owner_status and source to optimize queries related to unclaimed doctors.

feat(auth): implement SystemOwnerCommand for managing system-owner user

- Added command to create, activate, and deactivate a system-owner user for IRIMC crawler.
- Ensured the user has ROLE_ADMIN to access import endpoints.
- Handled password setting and user status management within the command.
2026-07-11 08:59:36 +03:30
hamed 3eee6c3886 Update community IDs, enhance CaptchaController caching, and modify home template for Altcha integration
- Changed community IDs for various components in graph.json to reflect updated associations.
- Added Cache-Control headers to the challenge and config methods in CaptchaController to prevent caching by CDNs and proxies.
- Updated the Altcha widget in home.html.twig to include a language attribute for better localization.
- Added a new AST cache file for CaptchaController to improve performance.
- Updated manifest.json with new modification times and AST hashes for several files.
2026-07-10 11:57:16 +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 c3c520801d feat(captcha): add ALTCHA configuration endpoint and integrate into HomeController
- Introduced a new endpoint `/api/v1/altcha/config` in CaptchaController to return the status of the ALTCHA captcha.
- Updated HomeController to inject AltchaService and pass the captcha status to the home page template.
- Modified the home.html.twig template to conditionally render the ALTCHA widget based on the captcha status.
- Updated manifest.json and cache files to reflect changes in the codebase.
2026-07-10 11:18:40 +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