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:
@@ -8,6 +8,8 @@ import {
|
||||
type CertStatus,
|
||||
} from '@/lib/certifications-logic'
|
||||
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'
|
||||
|
||||
@@ -74,6 +76,26 @@ export default async function MePage() {
|
||||
}
|
||||
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 (
|
||||
<>
|
||||
<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.
|
||||
</p>
|
||||
</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’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>
|
||||
</>
|
||||
)
|
||||
|
||||
+25
-1
@@ -419,7 +419,8 @@ Migrations 012-030 are tracked authoritatively in `docs/DATABASE_SCHEMA.md`
|
||||
(governance kernel, areas, forum/tiers/roles/invites, configurable onboarding,
|
||||
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).
|
||||
`030` certifications + Instructor capability, `031` secrets SELECT honors
|
||||
`ops.secrets.read`, `032` classes + sessions + signups).
|
||||
|
||||
### 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
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
Branch `main`. Start state clean, deploys green (incident-tracking pass-20 live).
|
||||
|
||||
Reference in New Issue
Block a user