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.
This commit is contained in:
@@ -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`).
|
- `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`.
|
- `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).
|
- `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).
|
||||||
|
|||||||
@@ -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<Gate> {
|
||||||
|
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<typeof createAdminClient>,
|
||||||
|
spaceId: string,
|
||||||
|
memberId: string,
|
||||||
|
certificationId: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
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<string, unknown> = {}
|
||||||
|
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<string, unknown>) => {
|
||||||
|
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 ?? [] }
|
||||||
|
}
|
||||||
@@ -53,3 +53,6 @@ export * from './certifications'
|
|||||||
|
|
||||||
// Classes (offerings, sessions, signups)
|
// Classes (offerings, sessions, signups)
|
||||||
export * from './classes'
|
export * from './classes'
|
||||||
|
|
||||||
|
// Equipment registry + reservations
|
||||||
|
export * from './equipment'
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user