From 8a77b1d301a7bdee885a0c9729ad6e341cfffa09 Mon Sep 17 00:00:00 2001 From: Moheeb Zara Date: Sat, 16 May 2026 22:14:02 -0700 Subject: [PATCH] feat(presence): check-in/out + present/attendance actions Self-only (member resolved server-side, never from input), validated service-client writes (no client RLS write policy). checkIn enforces host eligibility via spaces.host_requires_card + active-card count, blocks a fresh double check-in, and auto-closes a stale open visit before a new one (handles forgotten check-outs without a cron). checkOut closes the caller's open visit. listPresentNow (any member; staleness applied in pure logic), getMyVisits (/me), listAttendance (all members, last 250). Zod checkIn/checkOut schemas; barrel export. --- lib/actions/index.ts | 3 + lib/actions/presence.ts | 229 ++++++++++++++++++++++++++++++++++++++++ lib/validations.ts | 11 ++ 3 files changed, 243 insertions(+) create mode 100644 lib/actions/presence.ts diff --git a/lib/actions/index.ts b/lib/actions/index.ts index 4934e2b..7d7a277 100644 --- a/lib/actions/index.ts +++ b/lib/actions/index.ts @@ -62,3 +62,6 @@ export * from './member-cards' // Door connections + executor (Door epic) export * from './door' + +// Presence & attendance (check-in / check-out / hosting) +export * from './presence' diff --git a/lib/actions/presence.ts b/lib/actions/presence.ts new file mode 100644 index 0000000..dfc95b3 --- /dev/null +++ b/lib/actions/presence.ts @@ -0,0 +1,229 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { createClient } from '@/lib/supabase/server' +import { createAdminClient } from '@/lib/supabase/admin' +import { requireMember, logActivity, parseInput } from '@/lib/auth-helpers' +import { checkInSchema, checkOutSchema } from '@/lib/validations' +import { presenceStatus, hostEligibility } from '@/lib/presence-logic' + +function isUniqueViolation(message: string): boolean { + return /duplicate key value|already exists|unique constraint/i.test(message) +} + +function revalidatePresence() { + revalidatePath('/dashboard') + revalidatePath('/attendance') + revalidatePath('/me') +} + +// Check the signed-in member in. Self-only: the member is resolved +// server-side, never taken from input. At most one open visit per member is +// enforced by a partial unique index; a stale open visit (forgotten +// check-out) is auto-closed first. Checking in as a host may require an +// active access card depending on the space's host_requires_card setting. +export async function checkIn(input: unknown) { + const supabase = await createClient() + const auth = await requireMember(supabase) + if (!auth.ok) return { error: auth.error } + const { member } = auth + + const v = parseInput(checkInSchema, input) + if (!v.ok) return { error: v.error } + const { asHost, note } = v.data + + const admin = createAdminClient() + + if (asHost) { + const { data: space } = await admin + .from('spaces') + .select('host_requires_card') + .eq('id', member.space_id) + .maybeSingle() + const hostRequiresCard = space?.host_requires_card ?? true + const { count: cardCount } = await admin + .from('member_cards') + .select('id', { count: 'exact', head: true }) + .eq('space_id', member.space_id) + .eq('member_id', member.id) + .eq('is_active', true) + const elig = hostEligibility({ + asHost: true, + hasActiveCard: (cardCount ?? 0) > 0, + hostRequiresCard, + }) + if (!elig.ok) return { error: elig.reason } + } + + // One open visit per member. If a stale open visit exists, auto-close it + // and continue; if a fresh one exists, they are already here. + const { data: open } = await admin + .from('space_visits') + .select('id, checked_in_at') + .eq('space_id', member.space_id) + .eq('member_id', member.id) + .is('checked_out_at', null) + .maybeSingle() + if (open) { + if (presenceStatus(open.checked_in_at as string, null) === 'present') { + return { error: 'You are already checked in. Check out first if you are leaving.' } + } + await admin + .from('space_visits') + .update({ checked_out_at: new Date().toISOString(), check_out_note: 'auto-closed (forgotten check-out)' }) + .eq('id', open.id) + .eq('space_id', member.space_id) + } + + const { data, error } = await admin + .from('space_visits') + .insert({ + space_id: member.space_id, + member_id: member.id, + is_host: asHost, + check_in_note: note ?? null, + }) + .select('id') + .single() + if (error) { + if (isUniqueViolation(error.message)) { + return { error: 'You are already checked in.' } + } + return { error: error.message } + } + + await logActivity(supabase, member, asHost ? 'checked in (host)' : 'checked in', 'space_visit', data.id as string) + revalidatePresence() + return { data: { id: data.id as string, is_host: asHost } } +} + +// Check the signed-in member out of their current open visit. +export async function checkOut(input: unknown) { + const supabase = await createClient() + const auth = await requireMember(supabase) + if (!auth.ok) return { error: auth.error } + const { member } = auth + + const v = parseInput(checkOutSchema, input) + if (!v.ok) return { error: v.error } + + const admin = createAdminClient() + const { data: open } = await admin + .from('space_visits') + .select('id') + .eq('space_id', member.space_id) + .eq('member_id', member.id) + .is('checked_out_at', null) + .maybeSingle() + if (!open) return { error: 'You are not checked in.' } + + const { error } = await admin + .from('space_visits') + .update({ checked_out_at: new Date().toISOString(), check_out_note: v.data.note ?? null }) + .eq('id', open.id) + .eq('space_id', member.space_id) + if (error) return { error: error.message } + + await logActivity(supabase, member, 'checked out', 'space_visit', open.id as string) + revalidatePresence() + return { data: { id: open.id as string } } +} + +type VisitRow = { + id: string + member_id: string + checked_in_at: string + checked_out_at: string | null + is_host: boolean + check_in_note: string | null + check_out_note: string | null + space_members: { display_name: string | null } | { display_name: string | null }[] | null +} + +function memberName(r: VisitRow): string { + const sm = r.space_members + const name = Array.isArray(sm) ? sm[0]?.display_name : sm?.display_name + return name ?? 'Member' +} + +// Who is here right now. Any space member may see this (presence is social). +// Service client scoped by space_id; staleness is applied in pure logic so a +// forgotten check-out does not show as present. +export async function listPresentNow() { + const supabase = await createClient() + const auth = await requireMember(supabase) + if (!auth.ok) return { error: auth.error } + const { member } = auth + + const { data, error } = await createAdminClient() + .from('space_visits') + .select('id, member_id, checked_in_at, checked_out_at, is_host, check_in_note, check_out_note, space_members!space_visits_member_id_fkey(display_name)') + .eq('space_id', member.space_id) + .is('checked_out_at', null) + .order('checked_in_at', { ascending: true }) + if (error) return { error: error.message } + + const rows = (data ?? []) as unknown as VisitRow[] + return { + data: rows + .filter(r => presenceStatus(r.checked_in_at, r.checked_out_at) === 'present') + .map(r => ({ + id: r.id, + memberId: r.member_id, + name: memberName(r), + isHost: r.is_host, + isMe: r.member_id === member.id, + checkedInAt: r.checked_in_at, + note: r.check_in_note, + })), + } +} + +// The signed-in member's own recent visits (for /me). +export async function getMyVisits() { + const supabase = await createClient() + const auth = await requireMember(supabase) + if (!auth.ok) return { error: auth.error } + const { member } = auth + + const { data, error } = await createAdminClient() + .from('space_visits') + .select('id, checked_in_at, checked_out_at, is_host, check_in_note, check_out_note') + .eq('space_id', member.space_id) + .eq('member_id', member.id) + .order('checked_in_at', { ascending: false }) + .limit(50) + if (error) return { error: error.message } + return { data: data ?? [] } +} + +// Org-wide attendance history. Visible to any space member (product +// decision). Service client scoped by space_id; most recent first. +export async function listAttendance() { + const supabase = await createClient() + const auth = await requireMember(supabase) + if (!auth.ok) return { error: auth.error } + const { member } = auth + + const { data, error } = await createAdminClient() + .from('space_visits') + .select('id, member_id, checked_in_at, checked_out_at, is_host, check_in_note, check_out_note, space_members!space_visits_member_id_fkey(display_name)') + .eq('space_id', member.space_id) + .order('checked_in_at', { ascending: false }) + .limit(250) + if (error) return { error: error.message } + + const rows = (data ?? []) as unknown as VisitRow[] + return { + data: rows.map(r => ({ + id: r.id, + name: memberName(r), + isHost: r.is_host, + status: presenceStatus(r.checked_in_at, r.checked_out_at), + checkedInAt: r.checked_in_at, + checkedOutAt: r.checked_out_at, + checkInNote: r.check_in_note, + checkOutNote: r.check_out_note, + })), + } +} diff --git a/lib/validations.ts b/lib/validations.ts index dc9f462..e48c8fc 100644 --- a/lib/validations.ts +++ b/lib/validations.ts @@ -970,6 +970,17 @@ export const doorControlSchema = z.object({ verb: z.enum(['open', 'unlock', 'lock']), }) +// ─── Presence / attendance ─────────────────────────────────────────────────── + +export const checkInSchema = z.object({ + asHost: z.boolean().optional().default(false), + note: z.string().max(500).optional().nullable(), +}) + +export const checkOutSchema = z.object({ + note: z.string().max(500).optional().nullable(), +}) + // ─── Generic ID schemas ────────────────────────────────────────────────────── export const uuidSchema = z.string().uuid('Invalid ID format')