The agent's own dashboard (dashboard/summary and doctors/performance) counted
every appointment belonging to their doctors, so the bookings a secretary types
into the panel — which earn the agent nothing — sat next to a commission column
that ignored them. Both now count only bookings that came from the agent's own
site, matching the monthly/yearly report. The per-doctor income column is also
scoped to this agent, since a doctor may have been under another one before.
Adds a backfill for the bookings paid before city domains resolved to an agent:
they carry neither booking_representation_id nor a FinancialBreakdown, and
neither can be recovered by replaying the request. Both are derived from
payments.frontend_address, the address the payment was started from. The
recovered breakdown is dated to the payment, not to the run, or a year of
commission would land in "today". Dry-run by default; re-running is a no-op,
and a payment whose domain does not match the doctor's owner is reported and
skipped rather than retried forever.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three things kept a city-site booking from ever reaching its representative.
The domain never resolved. City sites carry their own domain on cities.domain
while a representative's coverage is a set of cities, and representations.domain
is normally only filled for a global agent. The resolver looked at that column
alone, so bookingRepId was always null and the commission guard rejected every
booking made through a city site. It now falls back to the active representative
covering that city, and stays null when two of them cover it — an ambiguous
money assignment has to be resolved in the data, not guessed.
Commission waited for confirmation. The money has already arrived when the
gateway callback succeeds; confirming the appointment is the doctor's or
secretary's job and may happen days later or never. It is now recorded on
payment, with the appointment still pending. Recording is idempotent, so the
confirmation path stays and creates nothing twice.
The dashboard counted every appointment of the representative's doctors,
including the ones a secretary typed into the panel. It now counts only
bookings that came from the representative's own site.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Several storefronts share one gateway — the city booking sites and the
ClinicPro panel itself — so the payments report could not say where a
transaction originated. The payment already stores the return address it was
started with; the domain is derived from it.
Lowercased and stripped of "www." so one domain is one value in the report,
null when a payment has no return address. Exposed on both the list and the
detail (which also carries the full address), and the list search now matches
it, so filtering to a single site needs no new parameter.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A doctor who also owns a clinic runs two environments, and a subscription
belongs to an environment, not to the user. The panel treated it as the
user's: the subscription query was keyed ['subscription-my'] with no
context, and switching environments never touched the react-query cache. So
upgrading the clinic left the personal practice showing the clinic's plan
with its feature-gated menu items unlocked, and vice versa.
The query key now carries the active dbUuid, and the context switch clears
the whole cache — every cached response belongs to the environment it was
fetched in, not just this one.
The API was already correct: DualEnvironmentSubscriptionTest pins that
granting one environment leaves the other on free.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A user who is both a doctor and a clinic owner always resolved to the
doctor: SubscriptionController had its own role-first resolveEntity, and
ownedEntity() returned the doctor whenever one existed. So a subscription
granted to that user's clinic was stored correctly but never surfaced —
/subscription/my kept reporting the free plan and the panel kept the
feature-gated menu items locked.
ownedEntity() now disambiguates with the active context when the user owns
both environments, and the controller delegates to it instead of re-deriving
the pair from roles. Payment already used ownedEntity(), so display, purchase
and admin grant now agree on one environment.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Granting stacks a new row and extends the existing expiry, so a mistaken
grant had no way back: the report tab was read-only and no endpoint deleted
a subscription (only DELETE .../subscription/period/{uuid}, a different
resource).
Adds DELETE /api/v1/admin/subscription/{uuid}. Payment-backed subscriptions
are refused with 409 — deleting one would leave the sales report with a
payment that owns nothing; refunds are the correct path there.
The report tab gets a per-row delete with a confirm dialog, plus a button
that jumps to the grant tab so add and remove live in the same place.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The About page addresses the reader formally and speaks as "we"; the
landing pages used informal second-person singular ("تعریف میکنی") and
the home hero was a slogan. Both now match the About tone.
Landing copy: all informal verb forms and possessives converted to the
formal plural. Home: hero headline and lead rewritten as a plain
statement of what we built, plus small wording passes on the devices,
specialties and stats blocks.
The home-page smoke assertion follows the new headline.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds a public "درباره ما" page at /درباره-ما reusing the landing theme
(same webpack entry, header, footer, registration modal). Section ids
match the header anchors so the nav works on this page too.
The explicit route takes precedence over LandingController's catch-all
/{slug}, which is registered with priority -10.
Also links it from the header nav and footer quick links, and lists it
in sitemap.xml.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every landing page was listed in the footer, so adding keyword clusters made
that one column taller than the rest of the footer put together. It now lists
six — the head terms and the two biggest specialties — and the rest stay
reachable through the "other solutions" block each page already renders.
That trade only holds if nothing falls out of the link graph, so the old test
asserting "the footer links to every page" is replaced by one that walks the
related-page graph from the footer links and fails when any page is not
reachable. Physiotherapy was the one page that would have been orphaned; the
polyclinic page now links to it.
The related-solutions heading said "for your trade", which reads oddly for the
clinics it is addressing, and its cards were left-aligned in a centered
section.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Keyword research put the head demand on four phrases — clinic management
software, doctor's office software, online booking, and the price/comparison
questions that precede a purchase — and we only had a page for one of them.
The new pages cover the head terms (clinic management, online booking), the
buying intent (price, how to choose, download), the positioning that separates
a hosted product from the installed ones that dominate these results (web
based, no install), and two long-tails where competition is thin (clinic
accounting, psychology practice).
The download page deliberately answers the intent rather than the wording:
someone searching for an installer wants to try something without paying, and
the page gives them that while explaining why there is nothing to install.
Copy is written per page rather than templated — eight pages saying the same
thing in different words is duplicate content, and every claim on them maps to
something the product actually does. Internal links from the older pages were
repointed at the new head-term pages, and the registry tests no longer hardcode
the page count: they now assert that every declared slug has a page, and that
titles, headings and descriptions stay unique.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The encounter edit form asked for contracts in the panel's own environment, so
inside a clinic it got an empty list: no insurance to choose, and the one
already recorded on the encounter had no matching option to display. It now
scopes the request by the encounter's doctor, the same way the confirm modal
does, which is why the session payload's existing doctor_uuid is threaded
through.
Saving the form had the matching defect on the server. Recalculating the totals
looked up the coverage percentage against the record's tenant rather than the
contract's, so a single save on a correctly-billed encounter zeroed the
insurance share and moved the whole amount back onto the patient.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The invoice and the encounter belong to the clinic, but the contract sits on
the doctor, so every coverage lookup asked the clinic, got nothing, and
resolved to zero percent. Three failures followed from that one wrong tenant:
the encounter charged the patient the full amount, the payment page showed a
balance the insurer owed, and — because no line carried an insurance share —
no claim was ever built, leaving the claims page empty.
Coverage now resolves through the same doctor-first-then-clinic rule the rest
of the insurance settings use, both when the encounter is created and when its
invoice lines are built.
Changing the insurance on an appointment also re-runs the encounter's shares.
Reception routinely confirms first and corrects the insurance afterwards, and
until now those shares stayed frozen at whatever the first calculation said.
Recorded payments are untouched; only the payable amount moves, so an
overpayment simply clamps the remainder at zero. The PATCH response also
returns the appointment's real venue now, matching GET.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A clinic owner configures insurance on the doctor (`doctor_uuid`), but an
appointment booked at the clinic belongs to the clinic — so at confirm time
the engine looked for contracts under the clinic, found none, and the operator
had no insurance to pick and no way to save one ("this insurance has no active
contract"). The two sides were writing and reading different tenants.
Contracts, service kinds and the visit price now resolve doctor-first with the
appointment's clinic as fallback, each judged separately: a doctor who holds
their own contracts but leaves the visit price to the clinic gets each from the
right place. The confirm modal asks the same question the engine answers, via
`inherit=1` on the two read endpoints; the settings pages deliberately do not
send it, since editing must target the doctor's own row.
Two further things came out of the same sweep. The service-kind settings
repository had the tenant-filter blindness already fixed for contracts and
pricing — reads pinned to the caller's environment while the target is another
tenant — so it is now exempted the same way. And a coverage percentage of zero
is accepted as a real choice meaning "this contract does not cover that service
kind"; what is still rejected is leaving an enabled kind with no percentage at
all, inheriting a central default of zero included.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pricing rows are keyed by the *target* tenant, but the tenant filter pins
every read to the environment of whoever is asking. A clinic owner setting the
free-visit price for one of their doctors was therefore blind to the row that
already existed: each save inserted another one — the unique key does not stop
it, because insurance_id is NULL for the free-visit row and MySQL does not treat
NULLs as equal — and the following read was blind in the same way, so the panel
kept showing the old value. From the outside it simply looked like the field
would not save.
Reads now run outside the filter, the same exception the tenant-insurance
repository already makes for the same reason, with authorization still coming
from resolveTargetEntity(). findOneForInsurance() takes the newest row so a
tenant that already accumulated duplicates converges on the last value the user
entered, and a migration collapses those leftovers.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A doctor's office number sat next to the address on the public profile and in
the anonymous API payload, so harvesting the phone number of every practice in
the country was one unauthenticated request away. Street address and map
coordinates stay public — a patient needs those to find the place — but the
phone is now opt-in per caller: DoctorAddress::toArray() and the clinic
serializers only emit it when told to, and the public doctor/clinic endpoints
tell them to only when the caller may edit that profile (the same can_edit they
already compute). Owner-facing address CRUD keeps returning it unchanged.
The patient still gets the number where it is actually useful — their own
appointment. That payload also stops guessing: it used to serialise the
doctor's *first* address, so a booking made at the clinic or at a second office
showed the wrong street entirely. It now resolves the address recorded on the
appointment itself, which works the same for a personal office and a clinic
branch, and falls back to the clinic's own number when the address has none.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
createUser() drew a random mobile and recovered from a collision by catching
the unique-constraint violation and calling resetManager(). That hands back a
brand-new EntityManager, which detaches every entity the running test had
built so far; its next flush died with "Multiple non-persisted new entities
were found", always in a different test and never reproducible in isolation.
The number is now checked before the insert, so the collision never reaches
the database and the manager stays open.
testParentIdAddsNoQueryPerSpecialty counted queries on the first request of
each size, so one-shot per-process caches — site config, subscription plan,
Doctrine metadata — landed inside the count or not depending on which tests
had run before it. Both requests are now warmed first; the assertion measures
steady-state growth, which is what it was always about.
The 23 PHPUnit notices were all one complaint: doubles created with
createMock() that never had an expectation. The ones that only stub return
values became createStub(); in SmsServiceLookupOnlyTest the provider and the
bus got the expectations they were missing, since "dispatch does not touch
the provider" and "sendNow does not enqueue" are exactly what that suite is
there to prove.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
created_at is a second-resolution integer, so dozens of posts routinely share
one value and MySQL is free to return tied rows in any order. Two consecutive
pages of the same list could hand back one post twice and never show another,
and the admin-list tests failed at random on whichever post got shuffled past
the page boundary.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
TenantInsurance reads ran through the tenant filter, which pins every query
to the *requesting* user's environment. A clinic owner managing a doctor's
contracts therefore read an empty set, recomputed version 1, and hit
`uniq_tenant_insurance_version` on insert. The reads now bypass the filter —
authorization is already established by resolveTargetEntity(), and the
uuid-based paths re-assert ownership after loading.
UserActiveContext::upsert() raced with itself: the panel fires several
/oauth/userinfo requests at once, all saw no row, all inserted, and the
losers died on a duplicate PRIMARY (closing the EntityManager with them).
Replaced with INSERT ... ON DUPLICATE KEY UPDATE.
A service that carries a treatment protocol but no catalog category is bad
catalog data, not a system failure; it was logged at error level on every
confirm and buried the real errors. Now a warning carrying the service id.
Kavenegar's HTTP 431 says only "malformed request". The provider's own
message and the token slot names are now logged so the template can actually
be fixed in the panel; token values stay out of the log.
Redis DSNs gained timeout/retry_interval/tcp_keepalive so a brief connection
loss reconnects quietly instead of logging a warning each time.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Implemented header and hero section in _header.html.twig and _hero_art.html.twig.
- Created a pre-registration modal in _reg_modal.html.twig with form fields and validation.
- Added page scripts for dynamic behavior and interaction in _page_scripts.html.twig.
- Developed landing page structure in landing.html.twig, integrating header, footer, and modal.
- Introduced tests for landing page rendering and registry validation in LandingPageTest.php and LandingRegistryTest.php.
- Create README.md for user guides covering roles: independent doctor, clinic owner, clinic manager, secretary, and staff.
- Add detailed step-by-step guides for setting up and managing clinics for clinic managers and clinic owners.
- Include instructions for independent doctors on setting up their practice.
- Provide a comprehensive guide for secretaries on daily operations and patient management.
- Introduce staff guide for nurses and technicians on session management.
- Integrated TourButton component into SettingsMenuPage, SkillsPage, SmsWalletPage, StaffPage, StaffSessionDetailPage, StaffTreatmentSessionsPage, SubscriptionPage, TagsSettingsPage, TreatmentCasesPage to enhance user onboarding experience.
- Created new tour definitions for appointments, clinics, staff management, financial management, and patient management, ensuring comprehensive guidance for users navigating the admin panel.
- Updated documentation to reflect the addition of tours and their implementation details.
- Implemented TourProgressController to handle API endpoints for tracking guided tours seen by users.
- Created UserTourProgress entity to store the highest version of tours seen by each user.
- Developed UserTourProgressRepository for database interactions related to user tour progress.
- Introduced TourProgressService to manage business logic for marking tours as seen and retrieving seen maps.
- Added comprehensive tests for API endpoints and entity behavior to ensure functionality and data integrity.
- Updated SubscriptionPeriod interface to include tax-related fields: tax_percent, tax_rials, and payable_rials.
- Modified payment API documentation to reflect changes in tax handling for subscriptions and SMS wallet charges.
- Adjusted PaymentController to calculate payment amounts based on subscription period details instead of client input.
- Enhanced PaymentManager to handle net amounts for SMS wallet charges, ensuring tax is not credited to the wallet.
- Created PaymentTaxCalculator and SubscriptionTaxCalculator services to manage tax calculations consistently across payment types.
- Added tests for tax calculations in both subscription and SMS wallet contexts, ensuring correct behavior with and without tax enabled.
- Updated frontend components to display tax information appropriately during payment processes.
- Implemented the ability for admins to grant subscriptions to doctors and clinics without payment.
- Added new API endpoint `/api/v1/admin/subscription/grant` for granting subscriptions.
- Updated the subscription model to track the admin who granted the subscription.
- Enhanced the subscription report to include details about granted subscriptions.
- Introduced a new `is_granted` field to indicate if a subscription was granted by an admin.
- Updated the database schema to support the new functionality with a migration.
- Added tests to ensure the correct behavior of the subscription granting process.
- Added OTP login functionality in driver.mjs to handle user authentication with a fixed code in dev environment.
- Enhanced RoleRoute component in App.tsx to support clinic-scoped doctor roles and permissions.
- Updated ClinicDoctorsManager component to include pagination and search functionality for better user experience.
- Refactored tests for ClinicDoctorsManager to cover new features and ensure proper API mocking.
- Adjusted permissions in settingsMenu.ts and PracticeDomainSettingsPage.tsx to align with updated backend requirements.
- Created RoleRoute.test.tsx to validate role-based access logic for different user scenarios.
- Implemented PublicResourceBookingController to handle public resource booking requests.
- Added methods for retrieving bookable resources, available slots, and month availability.
- Created PublicResourceBookingService to manage public resource offerings and service visibility.
- Developed tests for public resource booking to ensure correct functionality and error handling.
- Replaced raw input fields for pricing with PriceInput component across various forms and modals to ensure consistent formatting and accessibility.
- Updated tests to reflect changes in pricing input handling, ensuring values are displayed in toman with proper formatting.
- Enhanced accessibility by adding aria-labels and aria-invalid attributes to PriceInput components.
- Adjusted UI elements to improve layout and user experience, particularly in forms related to service items and scheduling.
- Changed labels from "نمایش در نوبتدهی" to "نمایش در نوبتدهی آنلاین" for clarity.
`GET /api/v1/doctors` could not answer either question the public search box
asks. Typing a specialty name returned nothing, because `name` only matched
`d.name`. And `specialty_id` matched one id exactly, so a parent group only
found doctors who happened to carry the parent — which they usually do, but
only as a side effect of `expandWithAncestors` running on save. A doctor
imported through any other path has no denormalised parent, and a search
guarantee resting on a save-time side effect is not a guarantee.
`expandWithDescendants` mirrors the existing ancestor walk over the same cached
parentMap, so no extra query. It deliberately keeps unknown ids instead of
dropping them like its mirror does: the result feeds an `IN (...)`, and an empty
array turns the filter into a no-op that returns every doctor — an unknown id
must mean "nothing", never "everything".
Both specialty filters use their own EXISTS alias rather than the shared `s`
join. Two conditions on one alias force a single join row to satisfy both, so a
doctor filtered by specialty A while searching the name of specialty B was
silently dropped. Verified by reverting to the shared alias and watching
testFilterOnOneSpecialtyWhileSearchingTheNameOfAnother fail.
toListArray now carries specialties[].parent_id so a client can tell the main
specialty from a sub-specialty instead of printing all of them. It is a string,
matching toDetailArray and the sibling `id` key — one concept should not have
two types across two endpoints. Reading the id off the parent proxy costs no
query; measured 6→11 queries with four more doctors both with and without the
field. That growth is a pre-existing N+1 (findWithFilters does not fetch-join
specialties, unlike findByClinic) and is left untouched here.
Also drops the phantom `search` parameter from the OpenAPI annotation — it was
advertised but never read, so a client sending it got an unfiltered list — and
documents the six live parameters that were missing.
Note for deploy: DoctorRepository gained a constructor argument, so a stale
container fails with ArgumentCountError until cache:clear runs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A representative could create a doctor or clinic but not finish its profile:
PATCH /api/v1/doctor/{uuid} accepted only the doctor or an admin, and the
clinic gate ran through ClinicDoctorPermissionChecker, which asks about clinic
membership — a representative is not a member. Onboarding stopped at an empty
public record.
Grant is permanent while representation_id points at the rep, and limited to
content: RepresentationEditPolicy holds ownership plus the field whitelist.
Sending a key outside it aborts the whole request with 403 and names the field,
rather than filtering the payload silently, so a rep never believes a change
saved when it did not. medical_system_code, `active` and clinic `doctors` stay
out — credential, and membership, belong to the record's owner. `active` already
has a dedicated rep endpoint.
ClinicDoctorPermissionChecker is untouched on purpose; folding a second concept
into it would give it two reasons to change.
Doctor/clinic detail responses now carry can_edit, computed by the same policy
the PATCH gate uses, so the panel reads authorization instead of re-deriving it
and drifting. Both endpoints stay public: no token means can_edit false and an
otherwise unchanged payload, which is what nobat724_front consumes.
Address endpoints follow the same policy. createAddress now resolves its target
from an explicit doctor_uuid instead of findByUser first — a representative who
also has a doctor profile was silently writing the address onto their own.
Every rep edit writes one app_log row (channel representation_edit) recording
who, what, and which field names — never values. Owner and admin edits write
nothing, keeping /admin/logs readable.
Docs corrected where they already disagreed with the code: 403/404 error codes
on both PATCH routes, a non-existent "cannot delete the last clinic address"
409, and the missing gallery-size 422.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat: create SanitizeBlogBodiesCommand to clean existing blog bodies according to current HTML sanitization policies
test: add AppointmentTreatmentSessionLinkTest to ensure appointment booking functionality works correctly with treatment session links
- Added PermissionGateTrait to manage access control for AppointmentPlanController and BillingController.
- Introduced denyUnlessGrantedForPlanning method in AppointmentPlanController to handle specific permission checks for planning appointments.
- Updated existing methods in both controllers to utilize the new permission checks.
- Refactored ResourcePermissionTrait to use PermissionGateTrait for cleaner permission management.
- Added tests to ensure proper permission enforcement across different scenarios, including cross-tenant access restrictions for staff.
- Implemented BlogBodySanitizer to clean HTML content before saving articles, ensuring security against XSS attacks.
- Added tests for BlogBodySanitizer to verify that unsafe tags and attributes are stripped from the content.
- Introduced ApiLeastPrivilegeTest to ensure that unauthorized users cannot access sensitive API routes, maintaining strict access control.
Adding a secretary meant deciding all 17 permission resources in the same
dialog. The full-height capture showed the form running past 1300px with the
save button below 16 accordions, and the dynamic registry makes that worse: every
page added in future lengthens this one modal.
The add/edit modal now carries only doctors, profile and address, and fits on
screen with its footer visible. Permissions move to SecretaryPermissionsModal,
reachable from a row action and opened automatically right after a successful
add, since a new secretary starts on the role defaults and the owner usually
wants to set them.
Neither create nor update sends permissions any more — the backend seeds the role
defaults on create, and the permissions modal owns the writes, fanning out over
every link row so a secretary shared across doctors stays consistent.
PermissionAccordions moves to components/ui as a shared component. Sections now
start collapsed with a granted/total badge on each header, so the panel opens at
a fixed height and still says which sections are on.
Two design-system slips caught by re-screenshotting rather than by the audit:
- a text button as a third row action pushed the name column out of the table, so
the desktop row uses an icon with a title and the mobile card keeps the label
- .btn.secondary is not defined in styles.css (variants are primary/ghost/soft/
danger/accent), so it renders as a bare .btn. Used ghost here. 25 other files
have the same dead class; left alone as a separate sweep.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PatientController::resolveScope and TenantTagController::guardTagView only ever
checked the secretary, while every write in both controllers already ran through
both checkers. So an invited clinic doctor with patients.view off got 200 with an
empty list where a secretary got 403 — one permission, two behaviours. No data
was exposed either way; tenant scoping emptied the result.
The fix is not canOrNonMember. That collapses two different situations: a
membership row switched to active=false means the collaboration ended, and
ClinicDoctorPermission::can() returns false for everything in that case too.
Routing it through the permission gate turned the existing 404 on a single record
into a 403, which confirms the record exists to someone who just lost access.
ClinicRecordAccessTest caught it.
isActiveMemberDenied() answers the narrower question — active member, permission
off — and leaves a deactivated row to the data scope, which closes it with a 404
and discloses nothing. A test now pins that distinction so it cannot be collapsed
again.
Tags keep the tags.view OR patients.view rule, now for both roles.
Verified live in three states: active with both off 403/403, deactivated not 403,
active with patients.view on 200/200.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A full role-by-role sweep (9 roles x 18 endpoints against the running app) showed
the addresses toggles in the owner's permission form controlled nothing. Grep
confirms it: no gate anywhere referenced 'addresses'. The panel's address list was
gated on appointment_settings.view instead — the same borrowed-permission pattern
already fixed for resources and treatment.
GET /api/v1/addresses now gates on addresses.view.
The resource drops to view-only. Creating, updating and deleting an address in
ClinicController is explicitly owner-or-admin
($clinic->getUser()->getId() !== $user->getId()), so those three actions could
never be delegated to a secretary or an invited doctor no matter what the form
said. Both role defaults narrow to ['view' => true] to match, and stored JSON
keeps its old keys harmlessly since merge only reads registry keys.
This widens secretary access: addresses.view defaults to true while
appointment_settings.view defaults to false, so secretaries who could not list
addresses now can. That is deliberate and costs no confidentiality — the same
addresses are already served anonymously from
GET /api/v1/clinic/{uuid}/addresses, which is whitelisted in security.yaml.
Verified live in three states: default 200, addresses.view off 403, and
addresses off with appointment_settings on still 403, proving the borrow is gone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The backend gate is now per-action, so a single canUpdate driving add, edit and
delete would show buttons the server answers 403 to. Each button now checks its
own action, and the includes modal takes canCreate/canDelete so its add select
and per-edge remove button follow the same split.
ServiceCategoryTab is deliberately left alone: its save patches
/api/v1/service-item/{uuid}, which is ClinicServiceController and already gated on
services.update, so canEdit was already the right permission. Only its read of the
category tree moved behind services.view, and the page it lives on already
requires that.
The page test now drives a configurable can(), covering view-only, create-only,
update-only and delete-only.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The controller carried only IS_AUTHENTICATED_FULLY on the class and none of its
15 routes checked a permission. A secretary whose owner had turned `services`
fully off could still create, rename and delete service categories, build item
groups, replace group members, and rewrite service relations and per-branch
overrides.
Scope is intra-tenant privilege escalation, not IDOR: owned() and requireItem()
already resolve every uuid against the caller's active environment, so no data
crossed tenants.
Gating is per-action (view/create/update/delete) and reuses denyServices() from
ClinicServiceController in the same domain, so a secretary with `update` cannot
create or delete. The call is the first statement in every action, before
requireCategory/requireItem — placed after, an unknown uuid would answer 404 and
leak whether the record exists.
An earlier note claimed these endpoints were consumed by the booking flow and so
could not be closed. That was wrong. service-selection/validate, the group routes
and the relation routes have no consumer in any of the three API clients, and the
sibling controller already puts every service read behind services.view — the
booking modal reads service-items through it — so any flow needing services
already needed the permission.
The docs claimed appointment_settings.* for the includes routes, which was never
enforced either; corrected to services.*.
The test loops the whole route list rather than sampling, and a guard asserts the
count of #[Route( equals the count of denyServices( so a future ungated route
fails here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous note said GET /api/v1/subscription/my has no gate because it
returns 200 with every permission off. That was wrong. It gates deliberately
with a degraded payload instead of a 403: without subscription.view the
response drops the active subscription and used_trial, and effective_plan
keeps only features, max_secretaries and max_resources — no plan identity,
no billing. Verified against the running app both ways.
The 403 it does not throw is the point: FeatureGate and useSubscription need
capability flags on every page, so a 403 would break the whole panel.
Also adds .claude/prompt/service-catalog-permission-gate.md for the one real
gap, with the per-route analysis that was previously deferred.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
clinic.md's default envelope is regenerated from the running app, so it now shows
all 17 resources instead of 13, including services with its full create/delete
actions. Both role docs point at permission.md for the shared registry and spell
out the merge rule that makes new resources work on existing rows: deleting a key
means "take the default", not "deny" — denying requires an explicit false.
A systematic sweep over every gated route with all permissions off found two
places where the docs claimed enforcement that does not exist:
- GET /api/v1/subscription/my returns 200 with every permission off. Only trial
is gated.
- ServiceCatalogController has no gate at all.
Both are pre-existing and both are left as-is rather than half-fixed: their
endpoints are also consumed by the booking and subscription flows, where a hard
gate would break secretaries who legitimately need them. The docs now say so.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The three hardcoded resource lists in the admin panel are gone. MySecretariesPage,
SecretariesPage and DoctorPermissionsModal now render from
GET /api/v1/permission-catalog, so a resource added to the backend registry shows
up in all of them with no frontend change. Each has a test that proves exactly
that by adding a resource to the mock and asserting it renders.
SecretaryPermissions was an interface with a field per resource, which made
"dynamic" impossible in TypeScript — every new resource would have been a compile
error. It is now an open map. Only two files consumed it.
The borrowed gates are corrected:
- five resource pages moved off appointment_settings onto their own 'resources'
- treatment-cases moved off appointments onto 'treatment'
- service-categories moved onto 'services', which is what ServiceCatalogController
actually manages (categories, item groups, service relations) — not resources
TreatmentCaseController had no permission gate at all, only IS_AUTHENTICATED_FULLY,
so any secretary could read and edit treatment cases. All seven of its actions are
now gated on treatment view/update.
ResourcePermissionTrait takes the resource from an overridable method instead of
hardcoding appointment_settings. HolidayController overrides it back, since the
holidays page really is appointment settings. The booking gate keeps its
appointments.view fallback so a secretary who may book is not blocked by a
resource-config permission.
Defaults were picked to preserve today's effective access, so no role gains or
loses a page from this move.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both permission forms in the admin panel can now render from the backend
registry instead of their own hardcoded lists. Resources come back as an array
so display order is part of the contract, each carrying its Persian label, its
actions, and the clinic_only flag that used to live in the frontend.
contextPermissions() normalizes the no-row branch through the registry too, so
a doctor whose permission row was never provisioned sees the same shape as one
who has it.
Two existing assertions compared the API response against DEFAULT_PERMISSIONS
by identity. The values are unchanged; only key order moved to the registry's,
so both now compare through PermissionCatalog::merge.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The list of permissionable resources was duplicated in six places that had
already diverged: both permission entities, three admin UI files and the
SecretaryPermissions TypeScript interface. Adding a resource meant editing all
of them, so new pages borrowed an unrelated resource instead — five resource
pages sat on appointment_settings.view and treatment-cases on appointments.view.
PermissionCatalog is now the only place that says which resources and actions
exist. Each entity keeps its own DEFAULT_PERMISSIONS, but as role policy only;
a test asserts those defaults never name a resource the registry doesn't have.
getPermissions() merges the stored JSON over the role defaults, so a resource
added to the registry later resolves to the role default instead of silently
false for every existing row. Explicitly stored values are never overwritten,
and no data migration is needed.
Two asymmetries fixed along the way:
- ClinicDoctorPermission validated writes against its own DEFAULT_PERMISSIONS,
so services.create/delete could never be stored for an invited doctor.
- DoctorSecretary had no validation at all and would store any key, and it only
read $patch['resources'] — the admin SecretariesPage sends a flat map, so its
permission edit silently did nothing. Both entities now accept either shape
and filter through the registry.
New resources 'resources' and 'treatment' are registered with defaults chosen to
preserve today's effective access, since both pages are currently gated on a
borrowed resource.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A finished session prints three areas with their device readings, so a course
with any history pushed the sessions that still need work off the screen.
Each session is a collapsible card now, with the scannable facts kept in the
head — number, status, date, staff, and an area count so opening is a decision
rather than a guess. History starts collapsed; a session that still needs
booking starts open, because its button is the reason it is on the page and
should not sit behind an extra click. A session with neither areas nor an
action has no body and renders as a plain row rather than an empty toggle.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The tab stacked every course's full session list on one page. A protocol allows
sixty steps, so one open course was enough to bury the others. Courses are cards
now — service, progress, supervising doctor, staff — and opening one replaces
the list with its detail: a back button, two tabs (the whole course, or only
what is still to come), search and paging inside each. The choice lives in the
URL so browser-back returns to the same course.
Booking stays where the work is: the button sits on the session card inside the
upcoming tab, not on a separate page.
The patient banner's 'نوبت بعدی' read '—' for anyone mid-course, because it only
looked at booked appointments and a course's later sessions have none yet. It
now falls back to the next session of the active course and relabels itself
'جلسهٔ بعدی' when it does — a planned session is not a booking, and the banner
should not call it one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The modal took its date from the caller and had no way to change it. Opened from
a session card, that meant the projected date or nothing: if the device had no
free time that day the user had to close a form they had already filled with a
patient and a service, go elsewhere, and start again.
The date is now a field in step one, seeded from the prop and reset when the
prop changes. ServiceSlotPicker already clears the picked slot when its date
changes, so the times reload for the new day on their own.
The header chip drops the date whenever that field is on screen; showing the
same value twice, one editable and one not, invites the reader to trust the
wrong one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The tab was called 'نوبتهای بعدی' but shows the whole course — finished sessions
with their recorded readings as much as upcoming ones. It is 'دورههای درمان' now.
Booking from a session still made the user search for a patient the page already
had open. The plan response carries the patient's national code (from the
profile, falling back to the user — the same COALESCE PatientController uses,
because users.national_code is routinely empty), and the modal takes a patient
prop that seeds the lookup and hides the search step. The old 'بیمار یافت شد'
card is suppressed in that mode; saying it twice is noise.
Sessions are now searchable and paged. A protocol allows up to 60 steps and a
patient can hold several courses, so an unbounded list was only ever going to
work for the small cases. Search filters on what the card actually shows —
service, staff, status, session number, area names — and runs in the page,
since /plan already returns the whole course and a round trip would add latency
and nothing else.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The staff field started empty even when the service's treatment protocol already
named who may perform it — a decision made once in the service settings and then
asked again on every booking. The modal now reads that protocol and preselects
its first staff member, but only until the user touches the field; otherwise a
manual choice would be wiped on the next service change. A protocol with no
staff leaves it empty and does not block submission.
Renames the four user-facing 'اپراتور' strings to 'پرسنل', matching the record
in /admin/staff that they all refer to. ResourcesPage keeps the word: there it
names a kind of bookable resource (doctor, operator, room, device), not a
ClinicStaff row.
Drops a test whose premise the default invalidated; the two new ones cover both
sides — protocol with staff sends staff_uuid, protocol without staff sends none.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A patient's file had no view of their multi-session courses: which ones they
have, when the remaining sessions fall, or what was recorded in the sessions
already done. All of it lived on a tenant-wide page.
The tab lists the patient's courses and, per course, a card for every session
with its date, its status, and — for finished ones — the areas treated with the
device readings a staff member logged. Estimated dates are labelled as such, so
a projection is never read as a booking.
Each unbooked session carries a button that opens the same NewAppointmentModal
used elsewhere, seeded with that session's date, and now binds the resulting
appointment to that exact session via a new treatmentSessionUuid prop — a
patient can have several open courses, and without it the attachment falls back
to guessing from the service.
The modal opens in resource mode, not doctor mode: a course's service is booked
against the device's calendar, so useDoctorBookingServices returns nothing for
the supervising doctor and the picker would render 'no bookable services'. The
plan response now carries the course's device for exactly this. A course with no
device yet says so instead of offering a button that cannot work.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
GET /api/v1/treatment-case/{uuid}/plan returns every session with a date and an
is_estimate flag, plus each session's area records — the device readings a staff
member actually logged. Until now nothing exposed either: due_at existed only
for the next session, and TreatmentCase::toArray() serialised sessions without
their areas, so 'what was done' was unreachable outside the staff panel.
Kept separate from GET /treatment-case/{uuid}; that response feeds the edit
modal, which needs neither the calendar nor the areas.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TreatmentScheduler deliberately writes only the next session's due_at, because a
date not yet anchored to anything real is a false claim about the future and has
to be rewritten every time a patient runs late. But the panel still needs to
show the whole course.
TreatmentPlanProjector builds that chain at display time and writes nothing.
Each date carries is_estimate so a projection is never mistaken for a fact. A
session's anchor is, in order: when it was finished, when its appointment is, or
its written due_at; a case with none falls back to when it was opened, so a
course that has not been booked yet still shows dates instead of blanks.
Read and write stay in separate classes — mixing them risks storing an estimate.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The list could be narrowed by status, search and open-date, but not by patient
— so a patient's own file had no way to ask which courses belong to them.
`?record=` adds that bound.
patientRecord is joined once and shared with the search branch; joining it twice
under the same alias is a DQL error, and search already needed it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>