docs: API_REFERENCE + ARCHITECTURE + HANDOFF pass-29 (class form gate, rosters, /doors)

This commit is contained in:
Moheeb Zara
2026-05-16 21:48:46 -07:00
parent 2fdc27597e
commit 46b294286a
3 changed files with 44 additions and 12 deletions
+6 -6
View File
@@ -912,11 +912,11 @@ Cert-type actions are gated by `certifications.manage`; grant actions by `certif
### Classes: `classes.ts` (migration 032) ### Classes: `classes.ts` (migration 032)
Class/session management is gated by `classes.manage`; attendance/completion by `classes.instruct`. Member signup needs no permission (just membership). Gates use the `user_has_permission` RPC; the `classes`/`class_sessions`/`class_signups` RLS independently enforces the same. Class/session management is gated by `classes.manage`; attendance/completion by `classes.instruct`. Member signup needs no permission (just membership). Gates use the `user_has_permission` RPC; the `classes`/`class_sessions`/`class_signups` RLS independently enforces the same.
- `createClass` / `updateClass` / `deleteClass` / `listClasses` — `classes.manage`. `deleteClass` is refused once the class has sessions (archive via `updateClass {is_active:false}`). `payment_link` is a generic http(s) link only (no payment integration); `grants_certification_id` is validated to be a cert in the space. - `createClass` / `updateClass` / `deleteClass` / `listClasses` — `classes.manage`. `deleteClass` is refused once the class has sessions (archive via `updateClass {is_active:false}`). `payment_link` is a generic http(s) link only (no payment integration); `grants_certification_id` is validated to be a cert in the space. `required_form_id` (migration 037) is validated via the service client to be a form in the space that is `published`; setting it hard-gates signup on a form submission being on file.
- `createSession` / `updateSession` / `deleteSession` — `classes.manage`. `updateSession` refuses `status:'completed'` (use `completeSession`); `deleteSession` refused if non-cancelled signups exist (cancel instead). - `createSession` / `updateSession` / `deleteSession` — `classes.manage`. `updateSession` refuses `status:'completed'` (use `completeSession`); `deleteSession` refused if non-cancelled signups exist (cancel instead).
- `listUpcomingSessions()` — any member; upcoming non-cancelled sessions of active classes, joined with the class. `registered_count` is computed with the service client so a member sees "spots left" without being able to read who else signed up (`class_signups` SELECT hides other members); `my_status` is the caller's own signup status. - `listUpcomingSessions()` — any member; upcoming non-cancelled sessions of active classes, joined with the class. `registered_count` is computed with the service client so a member sees "spots left" without being able to read who else signed up (`class_signups` SELECT hides other members); `my_status` is the caller's own signup status. When the class has a `required_form_id` it also returns `required_form { title, url, satisfied }` (the public `/f/{space}/{slug}` URL and whether THIS member has a submission on file) so the member surface can show/link the requirement.
- `signUpForClass(input)` / `cancelMySignup(input)` — any member. Validated service-client writes (`class_signups` has no INSERT/DELETE policy): `signUpForClass` checks eligibility (`canSignUp`), de-dupes, and registers vs waitlists by effective capacity; `cancelMySignup` cancels (soft) and promotes the earliest waitlisted member when a registered seat frees (`pickPromotion`). - `signUpForClass(input)` / `cancelMySignup(input)` — any member. Validated service-client writes (`class_signups` has no INSERT/DELETE policy): `signUpForClass` checks eligibility (`canSignUp`), de-dupes, and registers vs waitlists by effective capacity. A `classes.manage` holder gets the override and may pass `memberId` to sign someone up on their behalf. If the class has a `required_form_id`, `signupFormEligibility` hard-blocks signup unless the target member has a `form_submissions` row for that form (checked via the service client, since a class manager need not hold `forms.manage`); the manager override bypasses the gate. `cancelMySignup` cancels (soft) and promotes the earliest waitlisted member when a registered seat frees (`pickPromotion`).
- `listSessionSignups(input)` — manager or instructor; the attendee list for a session. - `listSessionSignups(input)` — manager or instructor; the attendee list (name, status, attended) for a session. Surfaced both on the member page for instructors and behind a per-session "Signups" toggle on `/classes/manage`; members never see it.
- `markAttendance(input)` — `classes.instruct`; sets `attended` on a signup (RLS UPDATE = `classes.instruct`). - `markAttendance(input)` — `classes.instruct`; sets `attended` on a signup (RLS UPDATE = `classes.instruct`).
- `completeSession(input)` — `classes.instruct`; marks the session completed (validated service-client transition, since the `class_sessions` UPDATE policy is `classes.manage`). If the class has `grants_certification_id`, it awards the cert to each attended member **through the normal `grantCertification` path**, so it only happens when the acting instructor also holds `certifications.grant`; otherwise completion still succeeds and the result reports `certificatesSkipped: true`. - `completeSession(input)` — `classes.instruct`; marks the session completed (validated service-client transition, since the `class_sessions` UPDATE policy is `classes.manage`). If the class has `grants_certification_id`, it awards the cert to each attended member **through the normal `grantCertification` path**, so it only happens when the acting instructor also holds `certifications.grant`; otherwise completion still succeeds and the result reports `certificatesSkipped: true`.
- `getMyClassSignups()` — the signed-in member's own signups (joined session + class). No params; RLS restricts to own rows (the `/me` view). - `getMyClassSignups()` — the signed-in member's own signups (joined session + class). No params; RLS restricts to own rows (the `/me` view).
@@ -926,7 +926,7 @@ Registry management is gated by `equipment.manage`; reserving needs only members
- `createEquipment` / `updateEquipment` / `deleteEquipment` / `listEquipment` — `equipment.manage`. `deleteEquipment` is refused once the item has reservations (archive via `updateEquipment {is_active:false}`). `required_certification_id` is validated to be a cert in the space. - `createEquipment` / `updateEquipment` / `deleteEquipment` / `listEquipment` — `equipment.manage`. `deleteEquipment` is refused once the item has reservations (archive via `updateEquipment {is_active:false}`). `required_certification_id` is validated to be a cert in the space.
- `listEquipmentForMembers()` — any member; active equipment with the required-cert name and a `member_certified` flag (computed with the service client so the gate can be shown up front without exposing other members' certifications). - `listEquipmentForMembers()` — any member; active equipment with the required-cert name and a `member_certified` flag (computed with the service client so the gate can be shown up front without exposing other members' certifications).
- `reserveEquipment(input)` / `cancelReservation(input)` — any member. Validated service-client writes (`equipment_reservations` has no INSERT/DELETE policy): `reserveEquipment` runs `reservationEligibility` (status/active, future window, `hasConflict` no-overlap, required-cert) — a holder of `equipment.manage` gets the override and may pass `memberId` to book on someone's behalf; `cancelReservation` allows the owner or an `equipment.manage` holder. - `reserveEquipment(input)` / `cancelReservation(input)` — any member. Validated service-client writes (`equipment_reservations` has no INSERT/DELETE policy): `reserveEquipment` runs `reservationEligibility` (status/active, future window, `hasConflict` no-overlap, required-cert) — a holder of `equipment.manage` gets the override and may pass `memberId` to book on someone's behalf; `cancelReservation` allows the owner or an `equipment.manage` holder.
- `listEquipmentReservations(input)` — `equipment.manage`; reservations for one item (joined member) for the schedule view. - `listEquipmentReservations(input)` — `equipment.manage`; reservations for one item (joined member name) for the schedule view. Surfaced behind a per-item "Reservations" toggle on `/equipment/manage` (who reserved + window + status, with a manager Cancel).
- `getMyReservations()` — the signed-in member's own reservations (joined equipment). No params; RLS restricts to own rows (the `/me` view). - `getMyReservations()` — the signed-in member's own reservations (joined equipment). No params; RLS restricts to own rows (the `/me` view).
### Door epic: `member-cards.ts` (migration 034) + `door.ts` (migrations 035-036) ### Door epic: `member-cards.ts` (migration 034) + `door.ts` (migrations 035-036)
@@ -935,4 +935,4 @@ Card UID is a credential. `member-cards.ts`: `addMemberCard`/`updateMemberCard`/
Phase 3 live actions (all `door.operate`, rate-limited, every attempt writes one redacted `door_access_log` row, service client after the permission check since operators have no RLS read on `door_connections`/`member_cards`): `grantCard({connectionId, cardId, permissionMask?})` allocates the card's per-connection integer slot via the pure lowest-free allocator (`door_card_slots`; idempotent re-grant, `slot_exhausted` when full), reserves it in the DB first, calls the controller, and rolls the reservation back if the call fails. `revokeCard({connectionId, cardId})` is idempotent (no slot assigned means already revoked) and frees the slot only on confirmed controller success so the app's map never diverges from the device. `doorControl({connectionId, verb})` with `verb` of `open`/`unlock`/`lock` (HeatSync `open` is a momentary `o1`; generic controllers need the matching verb template) touches no slot. Phase 3 live actions (all `door.operate`, rate-limited, every attempt writes one redacted `door_access_log` row, service client after the permission check since operators have no RLS read on `door_connections`/`member_cards`): `grantCard({connectionId, cardId, permissionMask?})` allocates the card's per-connection integer slot via the pure lowest-free allocator (`door_card_slots`; idempotent re-grant, `slot_exhausted` when full), reserves it in the DB first, calls the controller, and rolls the reservation back if the call fails. `revokeCard({connectionId, cardId})` is idempotent (no slot assigned means already revoked) and frees the slot only on confirmed controller success so the app's map never diverges from the device. `doorControl({connectionId, verb})` with `verb` of `open`/`unlock`/`lock` (HeatSync `open` is a momentary `o1`; generic controllers need the matching verb template) touches no slot.
Member self-entry (no permission code; any active member): `selfEntry({connectionId})` triggers a momentary OPEN only, allowed only when the connection is `is_enabled` AND `allow_member_self_entry` AND the caller has at least one active `member_card` on file (the locked eligibility rule: a `door_card_slots` row is NOT required). Membership and cards are resolved server-side, so a member can only ever open a door for themselves; never unlock/lock/grant/revoke, never anonymous. Strict per-member rate limit (5/min), one redacted `door_access_log` row `action='self_entry'` (`target_member_id` = self), through the same hardened executor. `listSelfEntryDoors()` returns the enabled self-entry connections for the caller's space, but only when the caller has an active card (otherwise empty), so the surface is hidden for ineligible members. Surfaced as a "Door access" panel on the dashboard. Member self-entry (no permission code; any active member): `selfEntry({connectionId})` triggers a momentary OPEN only, allowed only when the connection is `is_enabled` AND `allow_member_self_entry` AND the caller has at least one active `member_card` on file (the locked eligibility rule: a `door_card_slots` row is NOT required). Membership and cards are resolved server-side, so a member can only ever open a door for themselves; never unlock/lock/grant/revoke, never anonymous. Strict per-member rate limit (5/min), one redacted `door_access_log` row `action='self_entry'` (`target_member_id` = self), through the same hardened executor. `listSelfEntryDoors()` returns the enabled self-entry connections for the caller's space, but only when the caller has an active card (otherwise empty), so the surface is hidden for ineligible members. `listMyDoorActivity()` returns the caller's own `door_access_log` rows (actor or target; service client after `requireMember`; `detail` is already secret-redacted at write time). Surfaced as a "Door access" panel on the dashboard and on the member `/doors` page (self-entry + masked own cards via `getMyCards` + recent personal activity).
+18 -6
View File
@@ -468,12 +468,12 @@ history is immutable (revoke is a soft `revoked_at` UPDATE). Expiry is
snapshotted at grant time so later edits to the cert type never retroactively snapshotted at grant time so later edits to the cert type never retroactively
change an existing grant. There is no anonymous path. change an existing grant. There is no anonymous path.
### Classes (migration 032) ### Classes (migrations 032, 037)
`classes` (offering), `class_sessions` (scheduled occurrence), and `classes` (offering), `class_sessions` (scheduled occurrence), and
`class_signups` (member signup) back a class scheduler. Pure logic in `class_signups` (member signup) back a class scheduler. Pure logic in
`lib/classes-logic.ts` (`effectiveCapacity`, `computeSignupStatus`, `lib/classes-logic.ts` (`effectiveCapacity`, `computeSignupStatus`,
`canSignUp`, `pickPromotion`), unit-tested. Server actions in `canSignUp`, `signupFormEligibility`, `pickPromotion`), unit-tested. Server actions in
`lib/actions/classes.ts`. Two additive permissions: `classes.manage` `lib/actions/classes.ts`. Two additive permissions: `classes.manage`
(class/session CRUD, gates `/classes/manage` via `lib/classes-guard.ts`) (class/session CRUD, gates `/classes/manage` via `lib/classes-guard.ts`)
and `classes.instruct` (attendance, completion, attendee list). Member and `classes.instruct` (attendance, completion, attendee list). Member
@@ -484,8 +484,17 @@ additive and default-deny: classes readable by managers (all) or members
(all) or the member (own) with instructor-only UPDATE and **no INSERT/DELETE (all) or the member (own) with instructor-only UPDATE and **no INSERT/DELETE
policy** so signup/cancel funnels through one validated service-client policy** so signup/cancel funnels through one validated service-client
action that enforces capacity/waitlist/dedupe and promotes the earliest action that enforces capacity/waitlist/dedupe and promotes the earliest
waitlisted member when a seat frees. Completing a session can award the waitlisted member when a seat frees. A class may optionally set
class's certification, but only through the normal `grantCertification` `required_form_id` (migration 037): signup is then hard-gated on the member
having a `form_submissions` row for that form on file (waiver model),
checked via the service client since a class manager need not hold
`forms.manage`; a `classes.manage` holder gets the override and may also
sign a member up on their behalf (mirrors the equipment required-cert
gate). The member calendar shows the requirement and links to the public
form; the per-session signup roster is reused on `/classes/manage` behind a
toggle for staff (members never see who else signed up). Completing a
session can award the class's certification, but only through the normal
`grantCertification`
path, so it still requires the acting instructor to hold path, so it still requires the acting instructor to hold
`certifications.grant`; otherwise completion succeeds and the result `certifications.grant`; otherwise completion succeeds and the result
reports the certificates were skipped (no service-role bypass of the reports the certificates were skipped (no service-role bypass of the
@@ -548,8 +557,11 @@ OPEN on a connection that is enabled and has `allow_member_self_entry` on
(opt-in, off by default; the locked eligibility rule is "any active card", (opt-in, off by default; the locked eligibility rule is "any active card",
no `door_card_slots` row required). Membership/cards are resolved no `door_card_slots` row required). Membership/cards are resolved
server-side, strict per-member rate limit, one redacted `self_entry` audit server-side, strict per-member rate limit, one redacted `self_entry` audit
row; surfaced as a "Door access" panel on the dashboard, hidden entirely row; surfaced as a "Door access" panel on the dashboard and on the
for ineligible members. Phases 4-5 add inbound log ingest and the member `/doors` page (self-entry + masked own cards via `getMyCards` +
the member's own recent activity via `listMyDoorActivity`, a service-client
read after `requireMember` filtered to that member), hidden entirely for
ineligible members. Phases 4-5 add inbound log ingest and the
universal API-call UI builder. No anonymous path. universal API-call UI builder. No anonymous path.
--- ---
+20
View File
@@ -4,6 +4,26 @@ Append-only. Newest entries on top. Keep each entry to one screen.
--- ---
## 2026-05-17 (pass 29) — Classes form-gate + rosters + /doors page; LOCAL, awaiting deploy
Branch `main`. Pass-28 (self-entry) deployed. User: "continue, also polish, also add a doors page, also polish the classes and equipment reservation stuff, should be able to see who signed up and also should be able to require a form optionally for classes." Four design forks asked + LOCKED: roster = staff-only (members stay blind); form gate = hard gate + classes.manage override; "completed" = any submission on file (waiver model), form must be published; doors page = full at `/doors`.
### Built (LOCAL, not deployed) — 6 commits, each gated + own commit
- **P1 `92c5737`**: migration `037_class_required_form.sql` (`classes.required_form_id` nullable FK -> forms ON DELETE SET NULL) + schema.sql + types + DATABASE_SCHEMA/DB_SCHEMA_MAP. ADD COLUMN IF NOT EXISTS; no RLS change (additive nullable col on already-policied `classes`).
- **P2 `f3b05af`**: pure `signupFormEligibility` + 4 tests (suite 417->421). `signUpForClass` now has classes.manage override + sign-up-on-behalf (`memberId`) and the hard form gate (`hasFormSubmission` via service client since a class mgr need not hold forms.manage; manager override bypasses). create/updateClass validate the form is in-space + published. `listUpcomingSessions` returns `required_form {title,url,satisfied}`. Manage UI: published-form picker; member UI: shows requirement, links the form, swaps Sign up for "Complete required form" until on file.
- **P3 `69fa64c`**: reused `SessionAttendance` behind a per-session "Signups" toggle on `/classes/manage` (staff roster + attendance + complete). Members still see only spots-left.
- **P4 `c93ee9b`**: new `EquipmentReservations` component behind a per-item "Reservations" toggle on `/equipment/manage` (who reserved + window + status + manager Cancel; `listEquipmentReservations` already existed).
- **P5 `2fdc275`**: new `listMyDoorActivity` (service client after requireMember; rows where caller is actor/target; detail already redacted). New `/doors` member page = self-entry (reuses DoorSelfEntry) + masked own cards (getMyCards) + recent personal activity; empty-state. Sidebar "Doors" under Learn.
- **P6 (this)**: API_REFERENCE + ARCHITECTURE + this entry.
### Open / next
- LOCAL & undeployed: pass-29 (6 commits) + the still-unpushed pass-28 deploy-state docs `268d78d`. **Awaiting deploy approval** (migration 037 applies on deploy). After push: confirm 037 applied; exercise create class w/ required form -> member blocked until form submitted -> manager override/on-behalf -> /classes/manage Signups roster -> /equipment/manage Reservations -> /doors page. NOT browser-verified here.
- Gate green at build: suite 421, `pnpm build` clean.
- Door epic remaining: **P4** inbound access-log ingest; **P5** universal API-call UI builder (`api_buttons`). (Door P-numbers are separate from this pass's P1-P6.)
- Residual/test gap unchanged: no Supabase mock harness so action orchestration (form gate, override, on-behalf) is not unit-tested; pure logic is.
---
## 2026-05-17 (pass 28) — Member self-entry DEPLOYED (dashboard surface) ## 2026-05-17 (pass 28) — Member self-entry DEPLOYED (dashboard surface)
Branch `main`. Pass-27 (Door P3) is deployed. User then directed: "add door access controls to dashboard if user has card access enabled" = build the locked-design member self-entry, surfaced on the dashboard. No new migration (reuses 035 `allow_member_self_entry` + `member_cards`). Branch `main`. Pass-27 (Door P3) is deployed. User then directed: "add door access controls to dashboard if user has card access enabled" = build the locked-design member self-entry, surfaced on the dashboard. No new migration (reuses 035 `allow_member_self_entry` + `member_cards`).