feat(portal): identity, the member portal shell, and the check-in pass
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:
Claude Opus 5 (1M context) <noreply@anthropic.com>
Showing
This diff is collapsed.
config/attendance.php
0 → 100644
resources/css/portal.css
0 → 100644
resources/js/portal.js
0 → 100644
This diff is collapsed.
tests/Fixtures/qr_golden.php
0 → 100644
This diff is collapsed.
tests/Unit/QrCodeTest.php
0 → 100644
This diff is collapsed.
Please register or sign in to comment