• Mahmoud Aglan's avatar
    feat(portal): identity, the member portal shell, and the check-in pass · acca60b0
    Mahmoud Aglan authored
    S3, S4 and the core of S8.
    
    Identity (S3)
    -------------
    GuardianResolver replaces ten hand-copied
    `Guardian::where('person_id', …)->first()` lookups, every one wrong in the
    same two ways: `->first()` on a column with no unique constraint, so a
    guardian holding two rows saw one set of children and was 403'd on the rest,
    silently; and no answer at all for an adult member, because all eleven
    app/Livewire/Parent/* components end in ->firstOrFail() and a player has no
    guardian row. That is why a player given the `parent` role saw empty lists —
    the domain had no path from a user to his own participant.
    
    PermissionService::getChildParticipantIds() also carried
    `->where('person_id', …)->orWhere('user_id', …)`, which with the tenant
    global scope appended compiles to `person_id = ? OR (user_id = ? AND
    academy_id = ?)` — the first branch escaping the tenant filter entirely. The
    closure is what keeps both branches inside it.
    
    A real `player` role and the portal.* permissions ship as a guarded
    migration, not a seeder: db:seed only runs when RUN_SEED_ON_FIRST_DEPLOY is
    true, so a client deployed outside the one-click template would never receive
    them. Same pattern as 2026_09_01_000001.
    
    portal_invitations stores only the SHA-256 of its token — a raw token in a
    row is a password in a row — and consumption is one conditional UPDATE whose
    WHERE clause carries every condition, so two taps on the same link on a phone
    cannot both create an account. Activation lives in a plain controller, never
    Livewire: a single-use token in a public property is serialised into the page
    on every round-trip.
    
    users.email stays NOT NULL UNIQUE, deliberately. 2024_01_01_000002 declares
    it inside Schema::create, so Postgres emits a UNIQUE CONSTRAINT that cannot
    be made partial without a DROP CONSTRAINT in up(); CREATE INDEX CONCURRENTLY
    cannot run in a migration transaction; and password_reset_tokens.email is the
    primary key the broker keys on. Portal accounts get p{uuid}@portal.invalid
    (RFC 2606, never routable) plus an email_is_synthetic flag every mail path
    checks. No unique index on users.phone either: 2026_08_30_000004 logged that
    it left duplicates in place, so one would hard-fail on at least one live
    client and then block that client's migrations forever.
    
    Phone login now refuses when one number matches several different people —
    signing someone into a stranger's account — while still resolving a genuine
    duplicate pair for the same person.
    
    config/branch_lock.php gains portal.* and parent.*: RequireBranchSelection
    runs on the whole web group, so without it any user holding branches.view_all
    in all-branches mode is bounced out of the portal by middleware.
    
    The portal (S4)
    ---------------
    Five tabs at /app — الرئيسية, التدريب, المدفوعات, الأكاديمية, حسابي — with
    the pass as a header affordance because it is per active profile: a guardian
    with three children needs three.
    
    PortalContext is the scope rule the IA turns on, decided once instead of
    eleven times: training is member-scoped, money is family-scoped. The old
    components each re-read session('active_child_id') independently while
    ParentFinances ignored it and aggregated everyone — the domain saying out
    loud that a household has one balance. The active id is re-validated against
    GuardianResolver on every read, so a value put into the session, or left
    there after a withdrawal, cannot widen what an account sees.
    
    No participant id is held in a public property anywhere in the namespace.
    This is Livewire v4, where a plain public property is settable from the
    browser, so a check in mount() that is not repeated in render() is
    decoration, not a check.
    
    portal.css is the only entrypoint built with `source(none)`. app.css and
    website.css are each a bare `@import 'tailwindcss'`, so v4 auto-detects from
    the project root and both emit the identical complete utility set — a third
    file written the same way would have been a third identical copy. Measured:
    portal.css 17.11 kB / 4.54 kB gzipped against app.css at 208 kB / 31 kB.
    
    Screens surface what was always one join away and never loaded: the coach
    taking each session and the reason for a substitution, cancelled_reason so an
    empty week does not read the same as Eid, and per-event registration for the
    right child — answerable only since event_registrations gained participant_id
    in S1.
    
    The check-in pass (S8 core)
    ---------------------------
    qr_check_in_enabled has been a toggle in system settings with zero functional
    readers since 2026_07_27: the product advertised a feature that did not exist.
    
    The pass asserts identity and never authorizes. Enrolment, participant
    status, session existence and branch are fresh reads at every scan, which is
    what makes a suspension take effect at the next scan rather than the next
    token rotation. The secret is derived by HKDF from a pepper that is
    deliberately not APP_KEY, revocation is one integer column, and a scanned
    code is consumed by INSERT … ON CONFLICT DO NOTHING inside the same
    transaction as the attendance write — a Cache::has/put pair would be a
    time-of-check race, and two scanners at one gate is exactly when it loses.
    Relay is not solvable; it is made worthless instead.
    
    SelfCheckInService writes through AttendanceMarkingService with the scanning
    staff as the marker rather than adding a second attendance write path. The
    deleted API had one of those: POST /v1/absences/report wrote status='excused'
    with no marker, no transition check, no audit and no check that the session
    belonged to the participant.
    
    QrCode is written rather than pulled in — there is no Composer step here that
    can add to the committed lock file, and the alternative was the existing
    pattern of an <img> pointing at api.qrserver.com, which sends the member's
    token to a third party and fails when the venue's wifi does.
    
    It was verified module-for-module against an independent implementation
    across versions 1-10 and all eight masks, given identical codewords. That
    found two bugs neither visible nor throwing: a Reed-Solomon generator
    polynomial built with its terms reversed, and missing version-information
    blocks for versions 7 and up, whose 36 modules were being filled with payload
    and shifting the whole stream. Both produced a plausible square of black and
    white that no scanner accepts. tests/Fixtures/qr_golden.php freezes that
    verification.
    
    Verified against a restored copy of backups/oc_sport-20260831-081053.dump:
    all seven portal screens render 200 for a real member account, the manifest
    is tenant-branded and no-store, and a member opening another family's invoice
    gets 403.
    
    Suite: 76 passed, 3 skipped (the tenant smoke test skips off Postgres rather
    than pretending SQLite is production).
    Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
    acca60b0
Name
Last commit
Last update
..
Portal Loading commit data...
CertificateController.php Loading commit data...
ContactFormController.php Loading commit data...
Controller.php Loading commit data...
DocumentController.php Loading commit data...
ExpenseAttachmentController.php Loading commit data...
ExportController.php Loading commit data...
GroupSchedulePrintController.php Loading commit data...
HealthController.php Loading commit data...
InvoicePrintController.php Loading commit data...
ParticipantCardController.php Loading commit data...
PublicDocumentController.php Loading commit data...
PublicEventController.php Loading commit data...
PublicWebsiteController.php Loading commit data...
ReceiptController.php Loading commit data...
ReportPrintController.php Loading commit data...
SchedulePrintController.php Loading commit data...
WebsitePageController.php Loading commit data...