feat(equipment): My reservations on /me + docs

/me gains a "My equipment reservations" section (the member's own
reservations with status and window). ARCHITECTURE documents the
equipment module; HANDOFF pass-23 records Classes deployed and the
code-complete-but-local equipment module.
This commit is contained in:
Moheeb Zara
2026-05-16 19:55:49 -07:00
parent 746c5969a2
commit f32743d8bd
3 changed files with 93 additions and 2 deletions
+49 -1
View File
@@ -8,8 +8,9 @@ import {
type CertStatus,
} from '@/lib/certifications-logic'
import { PERMISSIONS, PERMISSION_CODES } from '@/lib/permissions-catalog'
import { getMyClassSignups } from '@/lib/actions'
import { getMyClassSignups, getMyReservations } from '@/lib/actions'
import { SIGNUP_STATUS_LABEL, SESSION_STATUS_LABEL } from '@/lib/classes-logic'
import { RESERVATION_STATUS_LABEL } from '@/lib/equipment-logic'
export const dynamic = 'force-dynamic'
@@ -90,6 +91,18 @@ export default async function MePage() {
const classSignups: ClassSignup[] = 'data' in signupRes ? (signupRes.data as ClassSignup[]) : []
const sessionOf = (cs: ClassSignup) =>
Array.isArray(cs.class_sessions) ? cs.class_sessions[0] : cs.class_sessions
const reservationRes = await getMyReservations()
type Reservation = {
id: string
starts_at: string
ends_at: string
status: string
notes: string | null
equipment: { name: string; location: string | null } | { name: string; location: string | null }[] | null
}
const reservations: Reservation[] = 'data' in reservationRes ? (reservationRes.data as Reservation[]) : []
const equipOf = (r: Reservation) => (Array.isArray(r.equipment) ? r.equipment[0] : r.equipment)
const titleOf = (cs: ClassSignup) => {
const sess = sessionOf(cs)
const c = sess ? (Array.isArray(sess.classes) ? sess.classes[0] : sess.classes) : null
@@ -213,6 +226,41 @@ export default async function MePage() {
</ul>
)}
</section>
<section>
<h2 className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground mb-3">
My equipment reservations
</h2>
{reservations.length === 0 ? (
<p className="font-sans text-sm text-muted-foreground">
You have no reservations. Browse what you can reserve at{' '}
<a href="/equipment" className="text-primary underline">Equipment</a>.
</p>
) : (
<ul className="divide-y rounded-lg border border-border">
{reservations.map(r => {
const eq = equipOf(r)
return (
<li key={r.id} className="p-4 flex items-start justify-between gap-4 flex-wrap">
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-sans text-sm font-semibold text-foreground">{eq?.name ?? 'Equipment'}</span>
<Badge variant={r.status === 'reserved' ? 'default' : 'outline'}>
{RESERVATION_STATUS_LABEL[r.status] ?? r.status}
</Badge>
</div>
<p className="font-mono text-[10px] text-muted-foreground mt-1">
{new Date(r.starts_at).toLocaleString()} – {new Date(r.ends_at).toLocaleString()}
{eq?.location ? ` · ${eq.location}` : ''}
</p>
{r.notes && <p className="font-sans text-xs text-muted-foreground mt-0.5">{r.notes}</p>}
</div>
</li>
)
})}
</ul>
)}
</section>
</div>
</>
)
+22 -1
View File
@@ -420,7 +420,8 @@ Migrations 012-030 are tracked authoritatively in `docs/DATABASE_SCHEMA.md`
customizable permissions + Ops ACLs, self-change hardening, `026`-`029` custom
forms + waivers + onboarding form step + per-space form slug + invite roles,
`030` certifications + Instructor capability, `031` secrets SELECT honors
`ops.secrets.read`, `032` classes + sessions + signups).
`ops.secrets.read`, `032` classes + sessions + signups, `033` equipment +
reservations).
### Forms & waivers (migrations 026-029; complete Phases 1-5)
@@ -489,6 +490,26 @@ path, so it still requires the acting instructor to hold
reports the certificates were skipped (no service-role bypass of the
guarded certifications surface). No anonymous path.
### Equipment (migration 033)
`equipment` (registry; status available/maintenance/retired; optional
`required_certification_id`; `is_active` archive) and
`equipment_reservations` (member time-window reservation) back a tool
reservation system. Pure logic in `lib/equipment-logic.ts`
(`intervalsOverlap`, `hasConflict`, `reservationEligibility`), unit-tested.
Server actions in `lib/actions/equipment.ts`. One additive permission,
`equipment.manage` (registry CRUD + adjust/cancel any reservation, gates
`/equipment/manage` via `lib/equipment-guard.ts`). Members reserve with
only membership. `/equipment` is the member catalog (reserve), `/me` lists
the member's own reservations. RLS is additive and default-deny: equipment
readable by managers (all) or members (active only), reservations by
managers (all) or the member (own) with manager-only UPDATE and **no
INSERT/DELETE policy** so reserve/cancel funnels through one validated
service-client action that enforces the equipment status, the no-overlap
rule, and the required-certification gate (checked against the normal
`member_certifications` data; an `equipment.manage` holder gets the
override and may book on a member's behalf). No anonymous path.
---
## 13. Known Limitations
+22
View File
@@ -4,6 +4,28 @@ Append-only. Newest entries on top. Keep each entry to one screen.
---
## 2026-05-17 (pass 23) — Classes DEPLOYED; Equipment module (3) code-complete LOCAL
Branch `main`. Classes module (pass-22) was deployed this pass (push `2096228`, Actions run 25979364584 success 1m? ; migration `032` applied; `/classes`, `/classes/manage`, `/me` smoke 307→login, not browser-verified). User then chose to continue the locked order → Equipment (module 3).
### Equipment registry + reservations (locked module 3) — CODE-COMPLETE, LOCAL, 6 commits, NOT deployed
Phase-by-phase, each gated (vitest + `pnpm build` read in-run) + own commit:
- A: migration `033_equipment.sql` (`equipment`, `equipment_reservations`) mirrored in `schema.sql` (in-place seed fn + appended Section 20); `equipment.manage` perm (group Equipment) seeded+backfilled; types; DATABASE_SCHEMA/DB_SCHEMA_MAP. Additive default-deny RLS; reservations have SELECT (manage or own) + manager UPDATE, NO INSERT/DELETE (reserve/cancel via validated service-client action). No DB overlap constraint (no btree_gist dep) — overlap enforced in the action via pure logic.
- B: pure `lib/equipment-logic.ts` (intervalsOverlap, hasConflict, reservationEligibility) + 9 tests (suite 391) + Zod.
- C: `lib/actions/equipment.ts` (registry CRUD; listEquipmentForMembers w/ member_certified flag via service client; reserve/cancel validated service-client w/ status+overlap+required-cert, equipment.manage override + book-for-member; listEquipmentReservations; getMyReservations) + `lib/equipment-guard.ts` + barrel + API_REFERENCE.
- D: `/equipment/manage` admin pages + sidebar Admin "Manage equipment".
- E: `/equipment` member browse + reserve (cert gate shown up front) + sidebar member "Equipment".
- F: "My equipment reservations" on `/me` + ARCHITECTURE + this entry.
Cert-gate (user-approved): reserving cert-gated equipment requires an active `member_certifications` for the member; an `equipment.manage` holder overrides and may book on a member's behalf. Cert check reads `member_certifications` via the service client (boolean only) since a non-cert-permissioned equipment manager can't read it under RLS — no write/PII bypass of the guarded surface.
### Open / not done
- Equipment is LOCAL (6 commits). Awaiting deploy approval. After push: confirm `033` applied, board has `equipment.manage`, exercise add equipment (+ required cert) → member reserve (cert gate / overlap) → manager override → `/me`. NOT browser-verified.
- Pure logic unit-tested; action orchestration (overlap/cert/override/RLS) NOT (same systemic no-mock-harness gap).
- Roadmap remaining: module 4 Door epic (member_cards + configurable door integration + native HeatSync adapter + the universal API-call UI builder; largest/riskiest, design-first, do last). Cert/feature nav links for non-admin permission-holders still admin-block-only (known minor gap).
---
## 2026-05-17 (pass 22) — Classes module (2) code-complete LOCAL; roadmap reaffirmed
Branch `main`. Incident-INSERT RLS bug confirmed RESOLVED by the user (closed; see pass-21 note). User asked to continue the locked roadmap; chose Classes (module 2) next; reaffirmed the locked order (Certs done → Classes → Equipment → Door epic, which is where the "custom API-call UI builder" lives, module 4, design-first/last).