From 39c00260c7cb6aae890825b31a78fe13d5e65c64 Mon Sep 17 00:00:00 2001 From: Moheeb Zara Date: Sat, 16 May 2026 19:51:51 -0700 Subject: [PATCH] feat(equipment): server actions + page guard equipment.ts: registry CRUD (equipment.manage; delete refused once reservations exist, archive instead), listEquipmentForMembers (active items with a member_certified flag computed via the service client), reserveEquipment / cancelReservation as validated service-client writes that enforce status, no-overlap and the required-certification gate with an equipment.manage override (and manager booking on a member's behalf), listEquipmentReservations and getMyReservations. equipment-guard mirrors the other module guards. Barrel + API_REFERENCE updated. --- docs/API_REFERENCE.md | 8 + lib/actions/equipment.ts | 375 +++++++++++++++++++++++++++++++++++++++ lib/actions/index.ts | 3 + lib/equipment-guard.ts | 31 ++++ 4 files changed, 417 insertions(+) create mode 100644 lib/actions/equipment.ts create mode 100644 lib/equipment-guard.ts diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index afa496d..e05656a 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -920,3 +920,11 @@ Class/session management is gated by `classes.manage`; attendance/completion by - `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). + +### Equipment: `equipment.ts` (migration 033) +Registry management is gated by `equipment.manage`; reserving needs only membership (gated by equipment status and the optional required certification). Gates use the `user_has_permission` RPC; the `equipment`/`equipment_reservations` RLS independently enforces the same. +- `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. +- `getMyReservations()` — the signed-in member's own reservations (joined equipment). No params; RLS restricts to own rows (the `/me` view). diff --git a/lib/actions/equipment.ts b/lib/actions/equipment.ts new file mode 100644 index 0000000..8fd2437 --- /dev/null +++ b/lib/actions/equipment.ts @@ -0,0 +1,375 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { createClient } from '@/lib/supabase/server' +import { createAdminClient } from '@/lib/supabase/admin' +import { + requireMember, + logActivity, + parseInput, + type Member, + type ServerSupabase, +} from '@/lib/auth-helpers' +import { + createEquipmentSchema, + updateEquipmentSchema, + equipmentIdSchema, + reserveEquipmentSchema, + cancelReservationSchema, + listEquipmentReservationsSchema, +} from '@/lib/validations' +import { reservationEligibility, hasConflict } from '@/lib/equipment-logic' +import { isCertificationActive } from '@/lib/certifications-logic' + +type Gate = + | { ok: true; supabase: ServerSupabase; member: Member } + | { ok: false; error: string } + +async function requireEquipmentManager(): Promise { + const supabase = await createClient() + const auth = await requireMember(supabase) + if (!auth.ok) return { ok: false, error: auth.error } + const { member } = auth + const { data: allowed, error } = await supabase.rpc('user_has_permission', { + uid: member.user_id as string, + sid: member.space_id, + perm: 'equipment.manage', + }) + if (error) return { ok: false, error: error.message } + if (!allowed) return { ok: false, error: 'You do not have permission to manage equipment' } + return { ok: true, supabase, member } +} + +async function holdsActiveCert( + admin: ReturnType, + spaceId: string, + memberId: string, + certificationId: string, +): Promise { + const { data } = await admin + .from('member_certifications') + .select('revoked_at, expires_at') + .eq('space_id', spaceId) + .eq('member_id', memberId) + .eq('certification_id', certificationId) + return (data ?? []).some(r => + isCertificationActive({ + revoked_at: (r as { revoked_at: string | null }).revoked_at, + expires_at: (r as { expires_at: string | null }).expires_at, + }), + ) +} + +// ─── Registry (equipment.manage) ───────────────────────────────────────────── + +export async function createEquipment(input: unknown) { + const gate = await requireEquipmentManager() + if (!gate.ok) return { error: gate.error } + const { supabase, member } = gate + + const v = parseInput(createEquipmentSchema, input) + if (!v.ok) return { error: v.error } + const e = v.data + + if (e.required_certification_id) { + const { data: cert } = await supabase + .from('certifications') + .select('id') + .eq('id', e.required_certification_id) + .eq('space_id', member.space_id) + .maybeSingle() + if (!cert) return { error: 'The selected certification was not found in this space.' } + } + + const { data, error } = await supabase + .from('equipment') + .insert({ + space_id: member.space_id, + name: e.name, + description: e.description ?? null, + location: e.location ?? null, + status: e.status, + required_certification_id: e.required_certification_id ?? null, + asset_tag: e.asset_tag ?? null, + created_by: member.id, + }) + .select('id') + .single() + if (error) return { error: error.message } + + await logActivity(supabase, member, 'created', 'equipment', data.id as string, e.name) + revalidatePath('/equipment') + revalidatePath('/equipment/manage') + return { data: { id: data.id as string } } +} + +export async function updateEquipment(input: unknown) { + const gate = await requireEquipmentManager() + if (!gate.ok) return { error: gate.error } + const { supabase, member } = gate + + const v = parseInput(updateEquipmentSchema, input) + if (!v.ok) return { error: v.error } + const u = v.data + + if (u.required_certification_id) { + const { data: cert } = await supabase + .from('certifications') + .select('id') + .eq('id', u.required_certification_id) + .eq('space_id', member.space_id) + .maybeSingle() + if (!cert) return { error: 'The selected certification was not found in this space.' } + } + + const patch: Record = {} + if (u.name !== undefined) patch.name = u.name + if (u.description !== undefined) patch.description = u.description ?? null + if (u.location !== undefined) patch.location = u.location ?? null + if (u.status !== undefined) patch.status = u.status + if (u.required_certification_id !== undefined) patch.required_certification_id = u.required_certification_id ?? null + if (u.asset_tag !== undefined) patch.asset_tag = u.asset_tag ?? null + if (u.is_active !== undefined) patch.is_active = u.is_active + if (Object.keys(patch).length === 0) return { data: { id: u.equipmentId } } + + const { error } = await supabase + .from('equipment') + .update(patch) + .eq('id', u.equipmentId) + .eq('space_id', member.space_id) + if (error) return { error: error.message } + + await logActivity(supabase, member, 'updated', 'equipment', u.equipmentId) + revalidatePath('/equipment') + revalidatePath('/equipment/manage') + return { data: { id: u.equipmentId } } +} + +export async function deleteEquipment(input: unknown) { + const gate = await requireEquipmentManager() + if (!gate.ok) return { error: gate.error } + const { supabase, member } = gate + + const v = parseInput(equipmentIdSchema, input) + if (!v.ok) return { error: v.error } + + const { count } = await supabase + .from('equipment_reservations') + .select('id', { count: 'exact', head: true }) + .eq('equipment_id', v.data.equipmentId) + if ((count ?? 0) > 0) { + return { error: 'This equipment has reservations. Archive it instead of deleting.' } + } + + const { error } = await supabase + .from('equipment') + .delete() + .eq('id', v.data.equipmentId) + .eq('space_id', member.space_id) + if (error) return { error: error.message } + + await logActivity(supabase, member, 'deleted', 'equipment', v.data.equipmentId) + revalidatePath('/equipment') + revalidatePath('/equipment/manage') + return { data: { id: v.data.equipmentId } } +} + +export async function listEquipment() { + const gate = await requireEquipmentManager() + if (!gate.ok) return { error: gate.error } + const { supabase, member } = gate + + const { data, error } = await supabase + .from('equipment') + .select('id, name, description, location, status, required_certification_id, asset_tag, is_active, certifications(name)') + .eq('space_id', member.space_id) + .order('is_active', { ascending: false }) + .order('name', { ascending: true }) + if (error) return { error: error.message } + return { data: data ?? [] } +} + +// ─── Member browse + reserve / cancel ──────────────────────────────────────── + +// Active equipment for the catalog. Any member. RLS already restricts to +// is_active for non-managers. Returns the required-cert name and whether the +// caller holds it (so the UI can show the gate before they try). +export async function listEquipmentForMembers() { + const supabase = await createClient() + const auth = await requireMember(supabase) + if (!auth.ok) return { error: auth.error } + const { member } = auth + + const { data, error } = await supabase + .from('equipment') + .select('id, name, description, location, status, required_certification_id, certifications(name)') + .eq('space_id', member.space_id) + .eq('is_active', true) + .order('name', { ascending: true }) + if (error) return { error: error.message } + + const admin = createAdminClient() + const rows = await Promise.all( + (data ?? []).map(async (e: Record) => { + const certId = e.required_certification_id as string | null + const member_certified = certId + ? await holdsActiveCert(admin, member.space_id, member.id, certId) + : true + return { ...e, member_certified } + }), + ) + return { data: rows } +} + +export async function reserveEquipment(input: unknown) { + const supabase = await createClient() + const auth = await requireMember(supabase) + if (!auth.ok) return { error: auth.error } + const { member } = auth + + const v = parseInput(reserveEquipmentSchema, input) + if (!v.ok) return { error: v.error } + const r = v.data + + const { data: isMgr } = await supabase.rpc('user_has_permission', { + uid: member.user_id as string, + sid: member.space_id, + perm: 'equipment.manage', + }) + const managerOverride = !!isMgr + + // Only a manager may book on someone else's behalf. + const targetMemberId = managerOverride && r.memberId ? r.memberId : member.id + + const { data: equip } = await supabase + .from('equipment') + .select('id, space_id, status, is_active, required_certification_id') + .eq('id', r.equipmentId) + .eq('space_id', member.space_id) + .maybeSingle() + if (!equip) return { error: 'Equipment not found' } + + const admin = createAdminClient() + const { data: existing } = await admin + .from('equipment_reservations') + .select('starts_at, ends_at, status') + .eq('equipment_id', r.equipmentId) + const conflict = hasConflict( + r.starts_at, + r.ends_at, + (existing ?? []) as Array<{ starts_at: string; ends_at: string; status: string }>, + ) + + const requiresCert = !!equip.required_certification_id + const memberHasCert = requiresCert + ? await holdsActiveCert(admin, member.space_id, targetMemberId, equip.required_certification_id as string) + : false + + const eligible = reservationEligibility({ + equipmentStatus: equip.status as string, + equipmentActive: equip.is_active as boolean, + startsAt: r.starts_at, + endsAt: r.ends_at, + conflict, + requiresCert, + memberHasCert, + managerOverride, + }) + if (!eligible.ok) return { error: eligible.reason } + + const { data, error } = await admin + .from('equipment_reservations') + .insert({ + equipment_id: r.equipmentId, + space_id: member.space_id, + member_id: targetMemberId, + starts_at: r.starts_at, + ends_at: r.ends_at, + notes: r.notes ?? null, + created_by: member.id, + }) + .select('id') + .single() + if (error) return { error: error.message } + + await logActivity(supabase, member, 'reserved', 'equipment', r.equipmentId) + revalidatePath('/equipment') + revalidatePath('/me') + return { data: { id: data.id as string } } +} + +export async function cancelReservation(input: unknown) { + const supabase = await createClient() + const auth = await requireMember(supabase) + if (!auth.ok) return { error: auth.error } + const { member } = auth + + const v = parseInput(cancelReservationSchema, input) + if (!v.ok) return { error: v.error } + + const admin = createAdminClient() + const { data: res } = await admin + .from('equipment_reservations') + .select('id, space_id, member_id, status, equipment_id') + .eq('id', v.data.reservationId) + .maybeSingle() + if (!res || res.space_id !== member.space_id) return { error: 'Reservation not found' } + if (res.status === 'cancelled') return { error: 'That reservation is already cancelled.' } + + const ownIt = res.member_id === member.id + let permitted = ownIt + if (!permitted) { + const { data: isMgr } = await supabase.rpc('user_has_permission', { + uid: member.user_id as string, + sid: member.space_id, + perm: 'equipment.manage', + }) + permitted = !!isMgr + } + if (!permitted) return { error: 'You can only cancel your own reservations.' } + + const { error } = await admin + .from('equipment_reservations') + .update({ status: 'cancelled' }) + .eq('id', v.data.reservationId) + if (error) return { error: error.message } + + await logActivity(supabase, member, 'cancelled_reservation', 'equipment', res.equipment_id as string) + revalidatePath('/equipment') + revalidatePath('/me') + return { data: { id: v.data.reservationId } } +} + +export async function listEquipmentReservations(input: unknown) { + const gate = await requireEquipmentManager() + if (!gate.ok) return { error: gate.error } + const { supabase, member } = gate + + const v = parseInput(listEquipmentReservationsSchema, input) + if (!v.ok) return { error: v.error } + + const { data, error } = await supabase + .from('equipment_reservations') + .select('id, member_id, starts_at, ends_at, status, notes, space_members(display_name, email)') + .eq('space_id', member.space_id) + .eq('equipment_id', v.data.equipmentId) + .order('starts_at', { ascending: true }) + if (error) return { error: error.message } + return { data: data ?? [] } +} + +export async function getMyReservations() { + const supabase = await createClient() + const auth = await requireMember(supabase) + if (!auth.ok) return { error: auth.error } + const { member } = auth + + const { data, error } = await supabase + .from('equipment_reservations') + .select('id, starts_at, ends_at, status, notes, equipment(name, location)') + .eq('space_id', member.space_id) + .eq('member_id', member.id) + .order('starts_at', { ascending: false }) + if (error) return { error: error.message } + return { data: data ?? [] } +} diff --git a/lib/actions/index.ts b/lib/actions/index.ts index d3aa0ef..5e9fe73 100644 --- a/lib/actions/index.ts +++ b/lib/actions/index.ts @@ -53,3 +53,6 @@ export * from './certifications' // Classes (offerings, sessions, signups) export * from './classes' + +// Equipment registry + reservations +export * from './equipment' diff --git a/lib/equipment-guard.ts b/lib/equipment-guard.ts new file mode 100644 index 0000000..688f8cd --- /dev/null +++ b/lib/equipment-guard.ts @@ -0,0 +1,31 @@ +import { redirect } from 'next/navigation' +import { createClient } from '@/lib/supabase/server' + +// Server-component guard for the /equipment/manage surface. Mirrors the +// forms/certifications/classes guards: resolve the member, check +// equipment.manage via the same RPC the RLS uses, redirect non-managers +// before any data is fetched. The RLS independently enforces this. +export async function requireEquipmentManagerPage() { + const supabase = await createClient() + const { + data: { user }, + } = await supabase.auth.getUser() + if (!user) redirect('/login') + + const { data: member } = await supabase + .from('space_members') + .select('id, space_id, role, user_id, display_name') + .eq('user_id', user.id) + .in('status', ['current', 'unverified', 'late']) + .single() + if (!member) redirect('/login') + + const { data: allowed } = await supabase.rpc('user_has_permission', { + uid: user.id, + sid: member.space_id, + perm: 'equipment.manage', + }) + if (!allowed) redirect('/dashboard') + + return { supabase, member } +}