feat(classes): My classes on /me + docs

/me gains a "My classes" section (the member's own signups with status
and attendance). ARCHITECTURE documents the classes module; HANDOFF
pass-22 records the code-complete-but-local module and the reaffirmed
roadmap.
This commit is contained in:
Moheeb Zara
2026-05-16 19:38:37 -07:00
parent 15176b652a
commit 209622867f
3 changed files with 108 additions and 1 deletions
+61
View File
@@ -8,6 +8,8 @@ import {
type CertStatus, type CertStatus,
} from '@/lib/certifications-logic' } from '@/lib/certifications-logic'
import { PERMISSIONS, PERMISSION_CODES } from '@/lib/permissions-catalog' import { PERMISSIONS, PERMISSION_CODES } from '@/lib/permissions-catalog'
import { getMyClassSignups } from '@/lib/actions'
import { SIGNUP_STATUS_LABEL, SESSION_STATUS_LABEL } from '@/lib/classes-logic'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@@ -74,6 +76,26 @@ export default async function MePage() {
} }
const held = PERMISSIONS.filter(p => heldCodes.includes(p.code)) const held = PERMISSIONS.filter(p => heldCodes.includes(p.code))
const signupRes = await getMyClassSignups()
type ClassSignup = {
id: string
status: string
attended: boolean
signed_up_at: string
class_sessions:
| { starts_at: string; ends_at: string | null; location: string | null; status: string; classes: { title: string } | { title: string }[] | null }
| { starts_at: string; ends_at: string | null; location: string | null; status: string; classes: { title: string } | { title: string }[] | null }[]
| null
}
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 titleOf = (cs: ClassSignup) => {
const sess = sessionOf(cs)
const c = sess ? (Array.isArray(sess.classes) ? sess.classes[0] : sess.classes) : null
return c?.title ?? 'Class'
}
return ( return (
<> <>
<PageHeader> <PageHeader>
@@ -152,6 +174,45 @@ export default async function MePage() {
Permissions are set per role by a space admin. This is a read-only view. Permissions are set per role by a space admin. This is a read-only view.
</p> </p>
</section> </section>
<section>
<h2 className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground mb-3">
My classes
</h2>
{classSignups.length === 0 ? (
<p className="font-sans text-sm text-muted-foreground">
You have not signed up for any classes. Browse what&rsquo;s on at{' '}
<a href="/classes" className="text-primary underline">Classes</a>.
</p>
) : (
<ul className="divide-y rounded-lg border border-border">
{classSignups.map(cs => {
const sess = sessionOf(cs)
return (
<li key={cs.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">{titleOf(cs)}</span>
<Badge variant={cs.status === 'registered' ? 'default' : 'outline'}>
{SIGNUP_STATUS_LABEL[cs.status] ?? cs.status}
</Badge>
{cs.attended && <Badge variant="outline">Attended</Badge>}
</div>
{sess && (
<p className="font-mono text-[10px] text-muted-foreground mt-1">
{new Date(sess.starts_at).toLocaleString()}
{sess.location ? ` · ${sess.location}` : ''}
{' · '}
{SESSION_STATUS_LABEL[sess.status] ?? sess.status}
</p>
)}
</div>
</li>
)
})}
</ul>
)}
</section>
</div> </div>
</> </>
) )
+25 -1
View File
@@ -419,7 +419,8 @@ Migrations 012-030 are tracked authoritatively in `docs/DATABASE_SCHEMA.md`
(governance kernel, areas, forum/tiers/roles/invites, configurable onboarding, (governance kernel, areas, forum/tiers/roles/invites, configurable onboarding,
customizable permissions + Ops ACLs, self-change hardening, `026`-`029` custom customizable permissions + Ops ACLs, self-change hardening, `026`-`029` custom
forms + waivers + onboarding form step + per-space form slug + invite roles, forms + waivers + onboarding form step + per-space form slug + invite roles,
`030` certifications + Instructor capability). `030` certifications + Instructor capability, `031` secrets SELECT honors
`ops.secrets.read`, `032` classes + sessions + signups).
### Forms & waivers (migrations 026-029; complete Phases 1-5) ### Forms & waivers (migrations 026-029; complete Phases 1-5)
@@ -465,6 +466,29 @@ 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` (offering), `class_sessions` (scheduled occurrence), and
`class_signups` (member signup) back a class scheduler. Pure logic in
`lib/classes-logic.ts` (`effectiveCapacity`, `computeSignupStatus`,
`canSignUp`, `pickPromotion`), unit-tested. Server actions in
`lib/actions/classes.ts`. Two additive permissions: `classes.manage`
(class/session CRUD, gates `/classes/manage` via `lib/classes-guard.ts`)
and `classes.instruct` (attendance, completion, attendee list). Member
signup needs only membership. `/classes` is the member calendar (sign up /
waitlist / cancel); the member's own signups also appear on `/me`. RLS is
additive and default-deny: classes readable by managers (all) or members
(active only), sessions by any space member, signups by managers/instructors
(all) or the member (own) with instructor-only UPDATE and **no INSERT/DELETE
policy** so signup/cancel funnels through one validated service-client
action that enforces capacity/waitlist/dedupe and promotes the earliest
waitlisted member when a seat frees. 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
`certifications.grant`; otherwise completion succeeds and the result
reports the certificates were skipped (no service-role bypass of the
guarded certifications surface). No anonymous path.
--- ---
## 13. Known Limitations ## 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 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).
### Classes + sessions + signups (locked module 2) — CODE-COMPLETE, LOCAL, 6 commits, NOT deployed
Phase-by-phase, each gated (vitest + `pnpm build` read in-run) + its own commit:
- A: migration `032_classes.sql` (`classes`, `class_sessions`, `class_signups`) mirrored in `schema.sql` (in-place seed fn + appended Section 19); `classes.manage`/`classes.instruct` perms (group Classes) seeded+backfilled; types; DATABASE_SCHEMA/DB_SCHEMA_MAP. Additive default-deny RLS; `class_signups` has SELECT (manage/instruct or own) + instructor UPDATE, NO INSERT/DELETE (signup/cancel via validated service-client action); partial unique = one non-cancelled signup per member+session.
- B: pure `lib/classes-logic.ts` (effectiveCapacity, computeSignupStatus, sessionTiming, canSignUp, pickPromotion) + 10 tests (suite 382) + Zod.
- C: `lib/actions/classes.ts` (class/session CRUD; service-client signUp/cancel w/ capacity+waitlist+dedupe+promotion; listUpcomingSessions w/ service-client counts so members see spots-left but not who; markAttendance; completeSession) + `lib/classes-guard.ts` + barrel + API_REFERENCE.
- D: `/classes/manage` admin pages + sidebar Admin "Manage classes".
- E: `/classes` member calendar (signup/waitlist/cancel) + instructor attendance/completion component + sidebar member "Classes".
- F: "My classes" on `/me` + ARCHITECTURE + this entry.
Cert-on-completion (user-approved design): `completeSession` awards `classes.grants_certification_id` to attended members via the normal `grantCertification` path, so it only issues if the acting instructor also holds `certifications.grant`; else completion still succeeds and the UI says certificates were skipped. No service-role bypass of the guarded certifications surface.
### Open / not done
- Classes is LOCAL (6 commits). Awaiting deploy approval. After push: confirm `032` applied, board has both perms, exercise create class → schedule session → member signup/waitlist/cancel → attendance → complete (cert-on-completion) → `/me`. NOT browser-verified (no live browser).
- Pure logic unit-tested; action orchestration (capacity/waitlist/promotion/RLS) NOT (same systemic no-mock-harness gap as forms/certs).
- Roadmap remaining: module 3 Equipment, module 4 Door epic (member_cards + configurable door integration + native HeatSync adapter + the universal API-call UI builder; design-first, do last). Noted cert refinement still open: `/certifications` (and `/classes/manage`) sidebar links are in the admin-only block, so a non-admin permission-holder must reach them by URL.
---
## 2026-05-17 (pass 21) — Two UX fixes DEPLOYED; Certifications module (1) code-complete LOCAL ## 2026-05-17 (pass 21) — Two UX fixes DEPLOYED; Certifications module (1) code-complete LOCAL
Branch `main`. Start state clean, deploys green (incident-tracking pass-20 live). Branch `main`. Start state clean, deploys green (incident-tracking pass-20 live).