docs: API_REFERENCE + ARCHITECTURE + HANDOFF pass-29 (class form gate, rosters, /doors)
This commit is contained in:
@@ -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
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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`).
|
||||||
|
|||||||
Reference in New Issue
Block a user