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)
|
||||
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).
|
||||
- `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.
|
||||
- `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`).
|
||||
- `listSessionSignups(input)` — manager or instructor; the attendee list for a session.
|
||||
- `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. 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 (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`).
|
||||
- `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).
|
||||
@@ -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.
|
||||
- `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.
|
||||
- `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).
|
||||
|
||||
### 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.
|
||||
|
||||
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).
|
||||
|
||||
Reference in New Issue
Block a user