feat(portal): consent, deletion, requests that take effect, and the seeder bug...
feat(portal): consent, deletion, requests that take effect, and the seeder bug that would have erased it all
The additions the addendum marked P0 and the programme had not built, plus
two ordering bugs found by testing a from-scratch install rather than only the
incremental one.
The seeder bug (would have broken the portal on the second deploy)
------------------------------------------------------------------
PermissionSeeder deletes every permission_role row for a role and reinserts
its own list, and db:seed runs on EVERY container start when
RUN_SEED_ON_FIRST_DEPLOY is true. So the portal.* grants added by migration
would have worked exactly until the next deploy and then vanished — the
portal 403'ing for every member, with nothing in the logs and no migration to
blame.
And on a brand-new client the migration runs before any academy exists, so it
created no player role and granted nothing at all.
Both are fixed where they belong: the permissions and the `player` role are in
PermissionSeeder and RolesAndPermissionsSeeder now, so a fresh install gets
them and the seeder stops erasing them. The migration stays for existing
tenants. Verified by migrating an empty database from zero, seeding it, then
booting it a second time and re-checking every grant.
attendance.scan reaches trainers, head trainers and reception — the people who
actually stand at a gate. payments.approve_proof reaches accountants.
B2 — consent and deletion (a store-submission blocker)
------------------------------------------------------
Apple 5.1.1(v) and Google both refuse an app that creates accounts and cannot
delete them, so this is what makes a submission possible rather than a
refinement to add later.
There was no consent record anywhere in the schema — not a column — while this
product publishes children's photographs on a public website and sends
marketing over WhatsApp. Consents are versioned and append-only, enforced by a
database trigger: a consent is a statement about a particular text at a
particular moment, so editing one destroys the only thing that makes it
evidence. Withdrawal is a new row. Bumping the document version invalidates
previous answers, because a boolean would silently claim a member agreed to
text they have never seen.
Deletion is redaction, not erasure. This is also an accounting system:
invoices, payments and ledger rows are the academy's books, and a member must
not be able to delete them by tapping a button. The person's identifying data
is destroyed, the login is destroyed, the financial record survives without
their name. Three gates before that: re-authentication, a cooling-off window,
and a blocked state with the reason shown when money is owed or an enrolment
is live — shown up front, because a refusal at the last step is not respectful.
Data export is the other half of the same obligation and is streamed, never
stored: a file of somebody's whole record sitting on disk waiting to be
collected is a second copy of the data they asked to control.
B4 + E7 — requests that actually do something
---------------------------------------------
`grep -rln ServiceRequest` found a model, an event, a listener and a provider,
and no admin screen. Approving a freeze never called ParticipantService::freeze()
— the column changed and the subscription kept running. A member was told
their subscription was frozen when it was not.
Approval is now defined by its effect, and the effect runs in the same
transaction: if it fails the approval fails with it and the request stays
pending. "Approved, and nothing happened" is worse than "still pending".
E7 decided: an excuse is a service_request, never a direct attendance write.
Two contradictory implementations existed and neither worked — ParentExcuseForm
validated, stored its medical attachment to the PUBLIC disk, then discarded the
record behind a `// TODO` while telling the parent it had succeeded; and the
deleted API wrote status='excused' with no marker and no check that the session
belonged to the participant, so a player could excuse himself and corrupt every
attendance figure the product reports. Approval goes through
AttendanceMarkingService with the approving staff member as marker, and a
coach's existing observation is never overwritten. ParentExcuseForm is deleted:
it never worked, so there was nothing to preserve.
B1, B3, B5, B7, B11, B12, B13
------------------------------
- Document upload and renewal. The admin half has been complete for a long
time — DocumentApprovalList, a nightly documents:expire, a
MedicalCertificateAlert — and the member half did not exist, so a certificate
expired at 06:00 and the member had no way inside the product to fix it.
- Payable instalments. reminders:installments and push:installment-due fire
daily and the only payment path ever built charged the whole due_amount: the
push said pay and the app could not. Settled from the wallet, which is the
one payment the portal can complete immediately — money the academy already
holds.
- Waitlist accept/decline. The offer, the expiry and the push all existed with
no accept surface anywhere, so the offer expired and the place went to nobody.
- Renewal surface, for RenewalPolicy::ManualRenew, which explicitly means a
human decides.
- can_authorize_payment gates instalment payment as well as proof submission.
- Every member upload is streamed from the private disk with attachment,
nosniff and no-store. A member upload is never a URL.
- /health asserts the schema this code needs and names what is missing.
Verified against a deliberately half-migrated database: 503, and
["payment_proofs","invoices.branch_id"].
/parent retired
---------------
Permanent redirects to the equivalent portal screen, parameters preserved so a
bookmarked invoice still lands on that invoice. Two member portals must not
coexist: they diverge, and the one nobody updates is the one a member has
bookmarked.
E2 recorded in config/compliance.php: 18, hardcoded rather than per-academy,
because a settings row nobody tunes is a false choice and a twelve year old
must never open the app and see the household's arrears.
Verification
------------
- 144 migrations from zero on an empty database, then db:seed, then a second
boot — every grant intact.
- The same on the restored oc_sport tenant: nothing to migrate, seed clean,
713 invoices / 356 participants / 649 payments untouched.
- 11 portal screens render 200 for a real member; 4 staff screens for an owner;
members refused on both staff screens.
- Suite: 76 pass on SQLite; on the tenant, PortalSmoke 3/3, AdminScreens 2/2,
PaymentProof 11/11, CheckInScan 10/10, ServiceRequestEffect 14/14.
Co-Authored-By:
Claude Opus 5 (1M context) <noreply@anthropic.com>
Showing
This diff is collapsed.
This diff is collapsed.
config/compliance.php
0 → 100644
This diff is collapsed.
Please register or sign in to comment