• Mahmoud Aglan's avatar
    feat(portal): consent, deletion, requests that take effect, and the seeder bug... · e5ce6f7f
    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: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
    e5ce6f7f