-
Mahmoud Aglan authored
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>
e5ce6f7f