main
10
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
2459625c41 | feat(tenant): implement tenant filter scope to manage cross-tenant data visibility | ||
|
|
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> |
||
|
|
04d3222559 |
feat(resource): admin UI for resources, types, skills and pools, plus real API docs
Four pages on the existing design system: a resources list whose branch/type/skill/
status filters live in the URL and go straight to the server, and three supporting
pages for types, skills and pools. Filtering client-side over a list the server had
already filtered would have been a second source of truth, so the page does neither.
The pool members dialog only offers resources from the pool's own branch and type —
the same rule the server enforces with 422, applied early so the user never reaches
the error. Skill assignment and pool membership are both full replacements, and both
say so in the dialog, because a partial-looking save that silently drops rows is
worse than an explicit one.
Wiring that was missing: deactivating a staff member through
PATCH /api/v1/staff/{uuid}/toggle now closes their resource too. Without it an
inactive operator would still have shown up in availability search. It is an explicit
call rather than a Doctrine lifecycle callback, since callbacks do not fire for
getArrayResult() — which is how every admin list is built — and that asymmetry is
its own bug. The reverse does not hold: closing a resource does not deactivate the
person, who may be purely administrative.
docs/api/resource.md documents all sixteen endpoints with responses captured from
real curl runs against ddev, including the 422 bodies for person-capacity and
non-scalar attributes. staff.md gains a "relationship to resources" section stating
that job_title is not a skill. tenancy.md contrasts these aggregate children —
whose roots do carry a tenant pair — with the branch_working_hours case from task 01,
where the root was global and the classification was wrong.
Also fixed a pre-existing flaky test: NumericFieldNormalizerTest guarded its random
mobile against collision on the never-reset db_test but not its random national code,
so a full-suite run could fail with 422 and close the EntityManager, taking an
unrelated test down with it. Both are now guarded, and the assertion prints the
server's response instead of a bare "422 is not 201".
Verified: phpunit 1119 tests / 3113 assertions green; slot-mode frozen contract green;
phpstan 14 errors before and after, none in touched files; tsc clean; vitest 88 files
/ 617 tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
d813843fcd |
feat(branch): admin UI for branch working hours and rooms, plus real API docs
Three pages, all on the existing design system: BranchesPage lists the current environment's booking locations with their working-hours and active-room counts, and two subpages edit the week and the rooms. The list page deliberately does not create or rename a branch — clinic and doctor detail pages already do that, and duplicating it would give one physical place two edit surfaces. Route permission reuses `appointment_settings` rather than inventing a new one. Two real bugs fell out of exercising this end to end: `days` was serialising as a JSON *array*, not an object keyed "0".."6" — keys 0..6 are sequential so json_encode collapses them to a list. The client reads days["0"] either way, so nothing looked broken, but the response shape was unstable: one missing day would flip the same field to an object. The controller now casts to stdClass and WorkingHoursTest::testDaysIsAJsonObjectNotAnArray pins it. Found by curling the endpoint for the docs, not by any test. `<input type="time">` caps at 23:59, so it can neither display nor produce the legal end value 1440. An all-day range would have vanished from the form and been corrupted by the first save. Ranges now carry an explicit end-of-day flag, with a round-trip test proving 1440 survives. docs/api/branch.md documents all eight endpoints with responses captured from real curl runs against ddev, including the 422 and 404 bodies. doctor.md records that active/timezone now appear on all nine existing address endpoints (additive), and tenancy.md gains the two lessons this task taught: an aggregate child whose root is itself declared global inherits no environment and needs a real pair, and TenantFilter is not a substitute for an explicit ownership check because hard isolation only applies to a *chosen* context. Verified: phpunit 1067 tests / 2974 assertions green; slot-mode frozen contract green; phpstan 14 errors before and after, none in touched files; tsc clean; vitest 87 files / 612 tests green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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>
|
||
|
|
6ab1eb6483 |
fix(tenant): scope the patient wallet ledger to the environment reading it
ownsRecord guards the patient record, not the rows underneath it, so
GET /api/v1/patient/{uuid}/wallet/transactions — and the recent_transactions
in the balance summary — returned the patient's entire history. Clinic A
could read what the patient paid at clinic B, down to the name of the staff
member who entered it.
The wallet stays the person's: the balance is still the sum of that user's
credits minus debits across every environment. Scoping it would show a
patient part of their own money and would make the running balance_after
meaningless. So this is attribution per row, not ownership per wallet.
The columns are deliberately named recorded_entity_type / recorded_entity_id
rather than entity_type / entity_id. TenantFilter keys on the latter and
would then scope the balance query too — the exact bug this avoids. The
naming is load-bearing, and both the entity and the architecture doc say so.
Rows that cannot be attributed — entered before this split, or outside any
environment such as a representation's commission — stay NULL and remain
visible everywhere; hiding them would make an existing patient's history
look deleted. The migration reports how many there are (0 in dev, all
attributable from payments and session references).
Consequence, documented in both docs/api/patient.md and the wallet tab: the
listed rows no longer sum to the displayed balance.
Removing the fix turns 3 of the 6 new tests red.
Tests: 902 backend (+6), 570 frontend. 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> |