- Introduced management mode for appointment slots, allowing doctors, admins, and clinic managers to view and book slots regardless of the online booking status.
- Updated SlotCalculatorService to accept a management context parameter, bypassing online booking restrictions.
- Modified appointment-related endpoints to handle management context and ensure proper authorization checks.
- Added tests to verify that management users can access slots even when online booking is disabled, while public users are still restricted.
- Improved documentation for API endpoints to reflect new management parameters and behaviors.
Three related fixes, all rooted in the same flaw: authorization and scoping
decided by the caller's role instead of by the environment the data belongs to.
1. Single-appointment access (clinic operations were entirely broken)
AppointmentController::canView/canManage only knew the patient, the owning
doctor and admin -- appointment.clinic was never consulted. A clinic user could
create an appointment through /my/appointment but got 403 on detail, edit,
move, reserve transfer/replace and status change, so nearly every appointment
operation failed in clinic mode.
AppointmentAccessChecker now decides from appointment.clinic: clinic owner,
member doctor (via ClinicDoctorPermissionChecker) and assigned secretary (via
active context + DoctorSecretary) are recognised. Actions reuse the existing
permission vocabulary, so active=false remains the single source of truth for
"collaboration ended". Cancellation is gated separately and an inline status on
PATCH /appointment/{uuid} cannot bypass that gate. The patient is narrowed to
view + cancel.
Also fixed alongside: listByDoctor now serves a clinic manager but scoped to
that clinic; todayStats gained an admin branch and no longer passes an array of
doctor ids as the clinic parameter; PatientController::appointments filters on
appointment.clinic instead of current membership, so deactivating a doctor no
longer erases clinic appointment history from the case file.
The doctor-only active_slot_key was reviewed and deliberately left alone -- a
doctor is one physical person, so adding clinic to the key would permit
double-booking, not fix a bug. Reasoning recorded on the entity.
2. Appointment registration and confirmation
Panel-created appointments are born pending ("ثبت شده") instead of confirmed.
Confirming is now an explicit act: POST /appointment/{uuid}/confirm transitions
the status, files the case file for the appointment's environment (reusing an
existing record or creating one) and registers full or partial payments on the
resulting visit -- all in one transaction.
AppointmentExpiryService would have expired those pending appointments the
moment their slot time passed; findExpiredPending is now limited to online
gateway holds, which are the only pendings carrying a TTL. A pending
appointment still occupies its slot, so the time stays reserved.
The admin panel gets a "قطعی کردن نوبت" modal showing the visit fee, each
selected service, the total, and paid/remaining/status. It is wired inside
AppointmentStatusDropdown, so picking "confirmed" anywhere (timeline, detail,
reserve list, info modal) goes through it and confirmation can never silently
skip the case file and payment.
3. Clinic case-file access
PatientRecordScopeResolver replaces the single-destination role mapping: the
active context decides, so a doctor invited into a clinic finally sees their
patients' records there. A clinic record is per-patient and shared by design,
so "their own patients" is derived from appointments with that doctor in that
clinic rather than from a new column. Clinic secretaries are limited to their
assigned doctors. Read and write share one rule, and out-of-scope records
report 404 so other environments are never disclosed.
Tests: 29 new cases across the three areas (clinic appointment access, confirm
flow, clinic record access). Full suite 466 tests, 2 pre-existing failures
unchanged. API docs updated for all three.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Confirming an appointment was supposed to create the patient's record and its
session, and PatientService already knew how. Only two of the five paths that
confirm an appointment ever called it, and the one that mattered most did not:
a booking paid for online was confirmed inside the payment callback, which
never ran the side-effects. Every Nobat724 booking therefore went unfiled — 7
confirmed appointments in dev had no session at all.
The side-effects now run through AppointmentConfirmationService, which every
path calls: the payment callback, both PATCH endpoints, and panel/admin
bookings. Creating the record can no longer roll back a confirmation or a
payment; a failure is logged and can be repaired with the new
app:appointment:backfill-sessions command.
Two related defects fixed along the way:
- A doctor working at a clinic got two records for one appointment, one under
the doctor and one under the clinic, so a single visit's revenue was counted
twice. The booking context now decides, and it decides once.
- That context was inferred from address_id, falling back to "the doctor's only
clinic" — a guess that files an appointment under the wrong practice now that
schedules are per-context. It is stored as appointments.clinic_id instead.
Panel and admin bookings were left pending forever: nothing confirmed them and
no payment was expected. They are created confirmed.
Repeat confirmations no longer duplicate the session; an archived one still
counts as filed, so archiving a mistaken visit does not resurrect it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The public doctor payload built `active`/`free_turn`/`hours_of_work` from the
personal schedule alone, so a doctor bookable only at a clinic was reported as
"نوبتدهی غیرفعال". Aggregate over every schedule instead: any schedule with
online booking on and an active day makes the doctor bookable, and the disabled
label only appears when all of them are off.
Three admin-panel fixes for the same class of bug:
- AppointmentsPage took the selected doctor from `dbUuid`, which is the clinic's
uuid inside a clinic context — the slots request 404'd. Use `doctorUuid`.
- TurnsTimeline rendered any error or unknown empty_reason as "این روز شیفت کاری
ندارد". Errors now surface as errors and unknown reasons get a neutral message;
the day-off wording is reserved for an explicit day_off from the backend.
- Admins have no clinic context, so slots fell back to the personal schedule.
They now pick a location from `appointment-booking-locations` and that choice
drives the slot, service and create-appointment requests.
Adds `app:schedule:normalize-format` for legacy rows stored as a bare JSON list
covering only Saturday, which read as day-off for the rest of the week.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two faults, one root: the per-context booking work updated ScheduleSection but
left the rest of the panel calling slot endpoints without clinic_uuid. Absent
clinic_uuid means the personal practice, so the panel asked about a schedule the
doctor barely uses and got nothing back.
- useClinicContext() resolves the current environment once and is used by the
appointments page, useDoctorBookingServices, ServiceSlotPicker and both
queries in NewAppointmentDrawer (a fifth call site a sweep turned up). It
returns null in a doctor's personal environment so the mirror-image bug — a
doctor seeing the clinic's schedule at their own practice — cannot appear.
clinicUuid is part of every query key; without it the cache leaks across
environments.
- appointment-slots returns empty_reason (no_schedule | holiday | day_off |
outside_window). TurnsTimeline rendered «این روز تعطیل است» for any empty day,
which is what the bug report actually saw; it now says which of the four it is.
- booking-locations lists a location only when the context has an address and an
active shift points at it. The dev data had three "personal" schedules whose
shifts referenced the clinic's address, so the public site advertised a
personal practice that could never be booked.
- ?date= adds available_on_date per location, validated as a real calendar date.
- MyAppointmentsController and AdminApiController resolved the appointment
address with no context and could store the wrong one. Both now go through the
new BookingContextResolver, which also replaces AppointmentController's private
copy of the same membership check.
- app:schedule:audit-locations reports shifts pointing at a missing or foreign
address; --fix deactivates them rather than deleting.
Verified against the reported doctor: same date, no clinic_uuid -> 0 sessions,
with it -> 1 session; a full week matches the configured Sat/Tue/Wed/Thu.
Suite: 417 tests, 2 failures — both pre-existing and unrelated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
next_available_at called getAvailableSlots() once per day for up to 30 days, and
that helper re-read the schedule, holidays and overrides on every call and then
issued an isSlotTaken() query per candidate slot. Cost grew with both the days
scanned and the slots per day, multiplied by the number of locations.
findNextAvailableStart() fetches the schedule, holidays, overrides and blocking
intervals once for the whole window and walks the days in memory.
Measured on the dev data (a doctor with two locations, first opening several
days out): 73 -> 20 queries for one request. The gap widens as locations or the
distance to the first opening grow.
Reserve appointments must keep blocking here: findBusyIntervals() filters
isReserve = false, so reusing it would have reported a reserved slot as free.
Added findBlockingIntervals(), which mirrors isSlotTaken()'s predicate, and
factored both onto a shared builder.
Verified the endpoint returns identical timestamps before and after.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The booking-locations endpoint described where a doctor can be booked but not
when, so the public site had no way to emit openingHoursSpecification and had
to fall back to availableService alone.
Each location now carries opening_hours: its active weekly shifts flattened,
with the English weekday name so the consumer can map it onto schema.org
directly. A day with two shifts appears twice; days with no active shift are
omitted. Hours are per context, so the personal practice and each clinic report
their own.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- 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.
The API and React components were already parameterized by doctor uuid, but 14
copy-pasted identity checks limited every endpoint to "the doctor themselves or
an admin", so a clinic owner could not touch a member doctor's booking setup.
- Replaces those 14 checks with one denyDoctorAccess() that also admits the
owner of a clinic the doctor belongs to, and a member doctor holding the
clinic's appointment_settings permission (view for GET, update for writes).
A doctor's own settings short-circuit before any permission lookup.
- Moves ScheduleSection and its tabs out of DoctorDetailPage into
components/schedule/ScheduleSection.tsx so the doctor panel and the new
clinic page render the same module instead of one page importing another.
Pure relocation — no logic changed.
- Adds ClinicAppointmentSettingsPage: one tab per clinic doctor, each rendering
that same section. The tab wrapper is keyed by doctor uuid so in-progress
schedule edits cannot leak onto the wrong doctor.
- insurance-pricing accepts an optional doctor_uuid (query on GET, body on PUT)
under the same access rule, so the visit-price card works inside the clinic
tabs. Fixes saveInsurancePricing calling getInsurancePricing with the wrong
argument by extracting the shared pricingPayload().
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Added functionality to assign a single secretary to multiple doctors within a clinic, allowing for scoped access to appointments.
- Introduced `SecretaryService` to handle the logic for assigning and syncing doctors for a secretary.
- Updated `SecretaryController` to support multi-doctor assignment via new endpoints and modified existing ones.
- Enhanced `DoctorSecretary` entity to include secretary UUID in its serialized output.
- Implemented repository methods to facilitate the retrieval and management of doctor-secretary relationships.
- Adjusted appointment filtering in `MyAppointmentsController` to ensure secretaries only see appointments for assigned doctors.
- Created tests to validate the new multi-doctor assignment functionality and appointment access restrictions.
- Updated frontend components to support multi-select for doctors in the secretary management UI.
Introduce the first per-appointment event system. On cancel (via the status
or general update endpoints) an AppointmentEvent (type=cancelled, «نوبت لغو
شد») is recorded with the actor, cancel time, and an optional cancel_reason,
plus a warning-level app_log entry. New GET /appointment/{uuid}/events
returns the ordered event list. The admin appointment detail page renders a
Timeline section and the cancel dialog now collects an optional reason.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Introduced a new boolean flag `require_visit_price` in the `EntityInsurancePricing` to enforce visit price for appointments.
- Updated the appointment creation endpoints to validate `visit_price_rials` based on the new flag.
- Added `visit_price_rials` field to the `Appointment` entity to store the visit price.
- Enhanced the `PatientService` to validate visit price during session creation.
- Updated API documentation to reflect changes in appointment and insurance pricing.
- Implemented a new service `VisitPriceRequirementResolver` to determine if a visit price is required for a doctor based on their pricing settings.
- Added migrations to update the database schema for the new fields.
Service-booking mode now selects services by section like slot mode:
appointment-booking-services returns service_section per item; ServiceSlotPicker
groups by section (SearchableSelect), accumulates picks across sections into a
removable 'section -> service' chip list.
Secretaries can override a service's duration for a single appointment without
changing the service default: appointment-service-slots accepts durations[uuid]
and both create endpoints accept service_durations; the override drives total
duration and slot_end. Online (patient) booking is unaffected — it never sends
overrides. Backend + frontend tests and docs updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Appointments could only reference a single service (ManyToOne). Add an
appointment_service_items join table (ManyToMany) so an appointment can
carry several services; the first stays the primary service_item for
backward compatibility, and toArray now also returns service_items[].
Both create endpoints (my/appointment, admin/appointment) accept
service_item_uuids[] and attach all of them. A new duration_from_services
flag gates the slot_end recompute: service-booking mode sends it true
(slot_end = start + Σ durations); slot mode omits it so the manual end
time is preserved. The admin endpoint previously ignored services entirely.
Frontend: in slot mode the single service dropdown becomes a checkbox list
filtered by the selected section (multi-select); service mode sends the
duration flag. Migration + backend/entity tests + docs updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Introduced a new booking mode in WeeklySchedule to support service-based appointments.
- Updated SlotCalculatorService to calculate available start times based on selected service durations and buffer times.
- Enhanced AppointmentController to handle service items during booking, calculating slot_end on the server side.
- Implemented validation to ensure at least one bookable service exists for doctors in service mode.
- Added new API endpoint to retrieve available appointment slots based on selected services.
- Updated MyAppointmentsController to accept service items during appointment creation.
- Modified ServiceItem entity to include a bookable flag, allowing services to be marked for scheduling.
- Created migration to add bookable column to service_items table.
- Added tests for service-based slot calculations and validation logic.
- Implemented a new endpoint `/api/v1/my/appointment/patient-lookup` to search for patients by mobile number before booking an appointment.
- Updated the `NewAppointmentModal` component to utilize the new patient lookup feature, allowing for direct booking if the patient is found with a national code.
- Enhanced the appointment booking form to handle mobile input normalization and display relevant fields based on the search results.
- Added tests for the new patient lookup functionality, ensuring proper behavior for found and not found cases, as well as validation for mobile input.
- Updated sidebar tests to reflect changes in the sidebar component structure and functionality.
Admin-side booking (POST /api/v1/my/appointment and
/api/v1/admin/appointment) resolved the patient User by mobile only, so
one person booked under two mobiles produced two User rows — and two
case-files, since PatientRecord is keyed on user_id. National code is the
real unique identity (User.national_code is already unique); a person may
have several mobiles.
Booking now requires + validates patient_national_code and resolves the
patient national-code-first (then mobile) via a shared PatientResolver, so
the case-file stays unique per national code even across mobiles. Reusing a
mobile already bound to a different national code returns 422
ERR_PROFILE_MOBILE_TAKEN. The admin create form and NewAppointmentDrawer
gain a national-code field and send it; both had a dead patient-picker URL
(/api/v1/patient) fixed to the real /api/v1/patients, whose payload already
carries user_national_code for autofill.
Docs (appointment.md, admin.md) and tests updated; new
AppointmentNationalCodeTest covers success, single-file reuse, missing,
invalid, and identity-conflict cases.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- AppointmentEditPage (edit.pdf): full-page edit of بخش/سرویس/پرسنل, Jalali
date + start/end time, deposit, status and notes; hydrates from
GET /appointment/{uuid} and saves through the general PATCH with the
optimistic-lock version. Routed at /admin/appointments/:uuid/edit (the
actions-menu ویرایش target).
- AppointmentFiltersModal (filter-desktop.pdf): name/national-code search,
بخش/سرویس selects, six status checkboxes (لغو شده covers both cancel
reasons), gender radios, حذف همه reset. Filtering is client-side over the
loaded day via the pure applyAppointmentFilters; toolbar gains the filter
button with an active indicator.
- /my/appointments rows now include patient_national_code and patient_gender
so the filters have data to match on.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extend Appointment for the clinic-facing appointments area:
- New nullable relations service_section/service_item/staff (بخش/سرویس/پرسنل)
plus deposit_required/deposit_amount_rials (بیعانه) and is_reserve.
- New statuses following_up (در حال پیگیری) and salon (سالن) with day-of
transition rules; reserve entries never occupy a slot (several reserves may
share one day), enforced in refreshActiveSlotKey.
- rescheduleTo(slotStart, slotEnd, isReserve) keeps active_slot_key consistent
for جا به جایی and reserve transfers.
- New PATCH /api/v1/appointment/{uuid}: partial update covering edit, slot
move (409 on taken slot, race backstop on the unique key), reserve toggle,
patient swap (جایگزینی) and optional status transition; optimistic lock via
version like the status endpoint.
- POST /my/appointment now accepts the workflow fields and is_reserve
(day-level entry: no past-slot rule, no atomic slot booking); GET
/my/appointments gains reserve=1 and returns the new fields per row.
Migration Version20260713195434 (+ mirrored on db_test). Docs updated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Introduced `PatientAppointment` interface to define appointment structure.
- Implemented `findByUserAndDoctorIds` method in `AppointmentRepository` to retrieve appointments for a user filtered by doctor IDs.
- Added `acceptedDoctorIdsByClinic` method in `ClinicDoctorInvitationRepository` to get accepted doctor IDs for a clinic.
- Created new endpoint in `PatientController` to fetch appointments for a patient, ensuring only relevant doctors' appointments are displayed.
- Created migration to add representation_cities table and domain, is_global fields to representations.
- Implemented SiteContextController to resolve domain to site context (city | representation | unknown).
- Developed DomainContext and DomainContextResolver services for domain mapping.
- Added tests for DomainContextResolver and commission logic based on domain ownership.
~27 ad-hoc error codes (SLOT_TAKEN, USER_NOT_FOUND, VALIDATION, …) were raw
strings, so ErrorCodes::message() returned the "unknown" fallback for them.
Register all 14 distinct codes as constants with their messages and replace the
raw usages across AdminApiController, MyAppointmentsController, CategoryController,
PreRegistrationController and ClinicInvitationController.
Wire values are kept identical (verified no consumer — admin SPA, nobat724_front,
tauri — switches on these strings), so this is backward compatible.
Regression: tests/Shared/ErrorCodesTest (wire values preserved + message resolves).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add unique constraints (one migration, no dup data in either DB):
- users.email, users.national_code (M16) — NULLs still allowed.
- payments.gateway_token (M17).
- date_overrides (doctor_id, date) (M18) — was a non-unique index.
- financial_breakdowns (payment_id, source) (M19) — anti double-accounting.
Regression: tests/Database/UniqueConstraintsTest (4 duplicate-insert cases).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- M2 GET /insurance/{id}: was unguarded; now owner-or-admin (403 otherwise) —
stops reading another doctor's negotiated price by id enumeration.
- M3 GET /clinic-pro/doctor-address/{id}: add the same owner/admin check the
sibling PATCH/DELETE already had.
- M4 POST/PATCH /service-item: staff_uuid must belong to the caller's tenant
(entity_type/entity_id) → 422; stops binding another tenant's staff.
- M5 appointment-settings list endpoints (date-override/holidays/
available-locations): add the per-doctor ownership check the sibling
single-record endpoints already enforce.
Regressions (6 negative cases fail without the fixes):
DoctorInsuranceOwnershipTest, DoctorAddressOwnershipTest,
ServiceItemStaffOwnershipTest, AppointmentSettingsListOwnershipTest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A non-unique index on (doctor_id, slot_start) plus a count-then-insert check
left a TOCTOU race: two concurrent requests could both pass isSlotTaken and
both insert. wrapInTransaction alone doesn't stop the phantom under InnoDB
REPEATABLE-READ.
Add a nullable, unique active_slot_key on Appointment = "doctorId:slotStart"
while the booking occupies the slot (pending/confirmed — in lockstep with
isSlotTaken); NULL once expired/completed/no_show/cancelled (NULLs don't collide
in a MySQL unique index, so released slots rebook freely). bookAtomically now:
catches the unique violation -> SlotTakenException, and expires lapsed pendings
in-transaction so the ~1-min window before the expiry cron doesn't wrongly block
rebooking. All three booking paths (online / my / admin) routed through it.
Migration backfills one row per (doctor, slot) — the latest id — so the index
builds even on dirty historical data without destructively cancelling bookings.
(Backfill surfaced a real pre-existing double-booked slot in dev data.)
Regression: tests/Appointment/SlotUniquenessTest. Adjusted the expiry-service
test fixture to use distinct slots (one live booking per slot is now enforced).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
createAppointment only checked the caller held an allowed role, then booked
onto whatever doctor_uuid the request named — a doctor could book onto any
other doctor's calendar, a clinic onto doctors outside it, a secretary outside
their scope. Add canBookForDoctor(): doctor→own only, clinic→member doctors,
secretary→active scope + appointments.create permission, admin→any.
Regression: tests/Appointment/BookingScopeTest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- appointments(status, expires_at): the per-minute expiry scheduler filtered on
these with no covering index
- patient_sessions(created_at): dashboard revenue range scans
- users(status): admin user-list filter
PatientRecord(entity_type,entity_id) already covered by its unique constraint;
UserActiveContext.user_id is the PK — neither needed a new index.
AppointmentExpiryService ran one findPendingByAppointment query per expiring
booking. Add PaymentRepository::findPendingByAppointments (one IN query keyed
by appointment id) and use it. Test covers expiry + payment cancellation for
several appointments at once.
getOverride leaked any doctor's override to any authenticated user; add the
owner-or-admin check (matching the update/delete endpoints) + regression test.
getSchedule returned any doctor's schedule to any authenticated user — the
mutation endpoints (update/delete) already checked owner-or-admin but this GET
did not. Add the same check + regression test (fails without the fix).
On prod (opcache.preload + prod container), getRepository(Entity::class)
returned Doctrine's default repository instead of the custom one when the
entity's #[ORM\Entity] had no repositoryClass — so custom finders like
DoctorSecretaryRepository::findAllActiveBySecretary threw BadMethodCallException,
making /oauth/userinfo return 500 after login. Declare repositoryClass explicitly
on all 25 affected entities.
Also add app:create-admin command (create/promote a ROLE_ADMIN user by mobile).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Added domain guard in CommissionService to ensure commission is calculated only when the appointment is booked under the same representation as the doctor.
- Updated RepresentationController to filter statistics by representation, ensuring accurate data is shown for each representative.
- Introduced new endpoints for the representation dashboard to provide summary statistics, doctor performance, and financial reports.
- Created new pages for RepresentationFinance and RepresentationSettlement to display financial data and allow for settlement requests.
- Added migration to include booking_representation_id in appointments for tracking the representative under which the appointment was booked.
Appointments now persist address_id resolved from the weekly-schedule
session (location_id) across all booking paths (online, secretary, admin).
On confirm, the patient is added to the clinic owning that address, or to
the doctor's single clinic as fallback. Weekly-schedule create/update now
requires location_id on every active session. PatientSession exposes
doctor_uuid/doctor_name so clinic records show which doctor each visit is for.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The appointment detail view needs the doctor's specialty and the clinic
address/phone/map, which toArray didn't return. Add doctor.specialties[] and
a top-level `address` (the doctor's first address via DoctorAddress::toArray —
address, telephone, map). Additive only; existing keys unchanged. Docs updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Unify and harden the payment flow (same API for the main site and all
consumer sites; per-client difference is only frontend_address).
- Payment gains STATUS_CANCELED. Gateways distinguish user-cancel from
failure (Mellat ResCode=17, SEP CanceledByUser, mock cancel=1) via a new
PaymentVerifyResult::canceled flag; callback sets canceled vs failed and
skips the circuit-breaker on cancel.
- Expiry job now cancels the pending payment when a booking lapses
(AppointmentExpiryService + PaymentRepository::findPendingByAppointment).
- frontend_address allowlist is read from the payment_allowed_frontend_hosts
site setting (manageable via PATCH /api/v1/admin/settings), falling back to
the ALLOWED_FRONTEND_HOSTS env var — so a new consumer site needs no code
change.
- .env: broaden CORS_ALLOW_ORIGIN to city subdomains (*.localhost /
*.clinic-pro.ddev.site) and add yazd-nobat.localhost to ALLOWED_FRONTEND_HOSTS.
- Update docs/api/payment.md and docs/api/admin.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rebuild the doctor rating/review system to power the public site's rich
review UI, and restrict who may submit.
Ratings:
- Rate entity holds five 0–100 dimensions (waiting time, diagnosis
accuracy, behaviour, cleanliness, expertise) instead of a single score.
- GET /rate/{uuid} returns aggregate {point, satisfaction, averages[]}.
- POST /rate upserts all five dimensions and returns the new aggregate.
Comments:
- Comment gains parent/replies (threaded) and a rich toArray with author,
like_status (like/dislike counts + current user's vote) and nested
approved replies. POST /comment accepts {comment, parent}.
- Likes are directional (value 1=like, -1=dislike) with toggle/replace;
POST /like/{uuid} returns like_count/dislike_count/current_user_like.
Eligibility:
- Only a user with a confirmed appointment in the last 30 days may rate or
comment (AppointmentRepository::hasRecentConfirmed); otherwise
403 ERR_RATING_NOT_ELIGIBLE. New GET /rate/{uuid}/eligibility for the UI.
- security.yaml: narrow the public rate pattern so /eligibility stays auth'd.
Also updates admin rates listing to the new dimensions and the rating/admin
API docs. Includes migration for the new columns.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Consolidate onto the symfony/scheduler recipe's App\Schedule (stateful +
processOnlyLastMissedRun, so missed runs after downtime still execute)
instead of a separate provider. Add the every-1-minute
ExpireAppointmentsMessage there, point scheduler_default at
schedule://default, and drop the redundant ExpireAppointmentsSchedule.
debug:scheduler shows the trigger registered.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Run the 15-min payment-expiry every minute through Symfony Scheduler so
unpaid pending bookings flip to expired without a system crontab. Install
symfony/scheduler; extract the expiry logic into AppointmentExpiryService
(reused by the existing command); add ExpireAppointmentsMessage + handler
and an #[AsSchedule] provider (RecurringMessage::every 1 minute); wire a
scheduler_default transport in messenger.yaml. Slots already free
just-in-time via isSlotTaken, so this only syncs the DB status.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>