cadf18d07a0647254f2b36ecb04d1cc82f5cc9a4
77
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
4fe0c4f9bf |
refactor(pricing): make the service the only price source
Price lists, annual tariffs and per-branch price overrides each answered
"what does this service cost?" differently, so a single date could carry
several answers and nobody could say which one was right. Price now lives
only on ServiceItem.price_rials, edited from the services page.
- drop PriceList/PriceListItem, their repositories and the seven
/api/v1/price-list(s) endpoints; PricingController keeps only quote and
the appointment price snapshot
- drop Tariff, TariffRepository, TariffService and the two
/service-items/{uuid}/tariffs endpoints; creating or repricing a service
no longer upserts a current-year tariff
- drop price_rials from ServiceBranchOverride; the entity stays for its
duration columns, which DurationCalculator and ServiceSelectionValidator
still read
- InvoiceService reads the item price directly
- PricingEngine collapses to a single source; breakdown.sources always
reports service_item, keeping the response contract intact
- remove the price-lists admin page, its route and settings-menu entry, the
tariff modal and the service detail tariffs tab; useAppointmentInvoice
moves to its own hook file
Migration drops price_lists, price_list_items, service_tariffs and the
override price column.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
26425bec31 |
Manage a resource's services from the panel
A "سرویسها" action on each resource row opens a modal listing what that resource performs, with its own duration and price. It follows the skills modal exactly — same PUT-replaces-everything contract, same components, no new page and no new route. Leaving a cell empty means inherit, so the effective value is shown as the placeholder along with where it came from: "40 — service default", "1,800,000 toman — branch". Without that the user cannot tell an unset field from a zero, which is the one thing this screen has to communicate. Two backend adjustments came out of wiring it up: - the offering filter in findEligible is now scoped to the requirement's resource type. Registering lasers for a service was making the room in the same plan ineligible and breaking the whole booking — "who performs this" is about the performing role, not about rooms and support resources. The seeder caught this immediately. - the scenario seeder now creates offerings and resource categories, so the demo data exercises this model instead of leaving every resource empty. Three vitest tests: inherited value with its source, saving an override, and clearing back to inheritance. Verified in the browser at 1440 dark, 1440 compact and 390 mobile — the last with no horizontal scroll. Panel suite 648 green across 98 files, tsc clean, encore build succeeds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6d7c54508c |
Let categories contain other categories, and share them with resources
Two gaps against the spec. Resources could not be categorised at all — only services carried a catalog category — so "this device is for hands and feet" was unsayable. And CatalogCategory::$parent is a tree built for menu ordering: one parent per category. Laser areas overlap, so "hand" belongs under both "whole body" and "upper limb" at once, which a tree cannot express. Containment is therefore a separate directed acyclic graph (catalog_category_includes) sitting beside the display hierarchy, and resources join the existing clinic-wide categories through a many-to-many rather than growing a parallel list of their own. CategoryClosureResolver walks it transitively: whole body includes lower body includes foot, so whole body includes foot without anyone writing that pair down. The walk reads every edge of the environment in one query and traverses in memory — a query per level would tie round-trips to graph depth. The visited set doubles as the cycle guard, so even data that already contains a loop cannot hang the traversal, and assertNoCycle refuses to create one. Selection now rejects picking an area together with a category that contains it: "whole body laser" and "hand laser" in one appointment is a 422 with a Persian message naming both. This replaces hand-written incompatible_with pairs for the area case — defined once on the category instead of per item pair — while that relation stays for incompatibilities that have nothing to do with areas. Nine tests, including the two-parents case a tree could not hold, the cycle refusal, the self-edge, and the empty-graph boundary. TenantSchemaCoverageTest caught the new edge entity as unclassified; it is registered as an aggregate child of the parent category, which is what the constructor already enforces. Suite 1286 green, phpstan at its 14-error baseline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
826b940c00 |
Add the resource↔service link that decides who offers what, and for how much
Until now a resource was picked by type and skill alone, so two devices of the same type were indistinguishable even when only one of them performed the service — and there was nowhere to say that this doctor takes 30 minutes for a filler while that one takes 45. ResourceServiceOffering is that link: resource ↔ service item, with an optional duration, an optional price and an active flag. Because a "service option" here is itself a ServiceItem inside an ItemGroup, one table covers both levels the spec asks for — a row against the parent item is "resource + service", a row against a member item is "resource + option". A third table would have meant two sources of truth for one concept and a rewrite of every path that already speaks ServiceItem. It is an aggregate child of ClinicResource, like ResourceSkill: no tenant columns of its own, since the resource already carries the pair and a copy is just something that can drift. The constructor refuses a resource and a service from different environments — TenantFilter does not cover that case, as both uuids arrive from the request body and the filter does not apply to aggregate children. null means inherit, not zero: an explicit zero is a duration that does not exist, while null means this resource has nothing to say and the resolver should look one level up. Zero and negative values are rejected outright. Tests cover the pair being stored, the duplicate pair hitting the unique constraint, the cross-environment guard, null-means-inherit, one service across two devices with different numbers, and deactivating without losing them. Suite 1264 green, phpstan at its 14-error baseline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c4f1f25c80 |
Refactor booking system: Remove unused policies, packages, and related entities
- Removed package consumption flags and related properties from PriceQuote. - Eliminated unused domain event publishing for policies and waitlist in Schedule. - Cleaned up BookingEngineSeeder by removing package and policy related logic. - Updated SeedScenariosCommand to reflect removal of policies from output. - Dropped policy, package, treatment course, cancellation, waitlist, and domain event tables in migration. - Removed domain event assertions from tests related to resource blocking. |
||
|
|
65d5831c64 | Remove ReportTest and WaitlistTest files as part of codebase cleanup | ||
|
|
fc6b865c15 |
feat(seed): make the seeded environments exercise the new booking engine
The first version of the seeder only filled the old skeleton: doctors, clinics, services, appointments written straight to the table. None of the sixteen tasks under docs/new_feture had any data, so nothing they built could be tried. BookingEngineSeeder now seeds, per environment: - catalog v2: a category tree, an item group with a 1..2 selection range, an incompatible pair, and a per-branch price override - resources: a skill with levels, a resource pool with priorities, and a maintenance window next week - a three-segment plan on the flagship service — numbing (room exclusive), wait (room passive, nobody else held), laser (room + device) — which is the whole point of the plan model and cannot be seen with single-segment services - an active price list, and a price snapshot per booked appointment - six policies, one per category, each with a condition and an effect - a package with a consumed session in the ledger, and a treatment protocol with per-session parameters plus an active course carrying its six sessions - a general and a per-service cancellation policy, no-show records, waitlist entries Two appointments per environment are booked through the real path — AppointmentPlanBuilder, AvailabilityEngine, HoldService, BookingService — so segments, resource occupancy and the domain-event outbox are populated by the code that will run in production rather than by INSERTs. Two defects in the seeding surfaced and are fixed here: - SegmentRequirement is the owning side, so persisting one leaves the template's in-memory collection empty. The plan built later in the same process saw segments that needed nothing, and the bookings occupied the device but never the room — while the same service was correct over HTTP, where the entity is read fresh. The collection is now kept in step. - passing the service as its own selected item produced a different plan than the booking flow builds. A course with no sessions also reported "everything is scheduled"; sessions are now created from the protocol steps. Verified over HTTP: segments come back as 5/30/20, utilization reports 110 minutes on the room and 90 on the laser, the course suggests session 1 with three slots, and the six policies list one per category. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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. |
||
|
|
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. |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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. |
||
|
|
f218bc17ef | feat: enhance security audit and CSP configuration for admin interface | ||
|
|
7e847b62c4 | feat: update allowed frontend hosts and add clinic-pro.ir domain | ||
|
|
087683e877 | feat(csp): add worker-src directive for ALTCHA proof-of-work in admin CSP | ||
|
|
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> |
||
|
|
6275b3da1e | feat: implement Content-Security-Policy for admin SPA and enhance session cookie security | ||
|
|
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. |
||
|
|
cb399ac653 |
Merge branch 'dev' into main
# Conflicts: # docs/api/doctor.md |
||
|
|
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. |
||
|
|
d780b5cbb6 | feat(validation): enforce naming rules for doctors and clinics to prevent placeholders | ||
|
|
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> |
||
|
|
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. |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
34d8a400b7 | feat(doctors): enhance doctor listing with bookable sorting and filtering, add JSON_EXTRACT DQL function | ||
|
|
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>
|
||
|
|
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>
|