Files
hackerspace.sh/docs/API_REFERENCE.md
T
Moheeb Zara 39c00260c7 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.
2026-05-16 19:51:51 -07:00

29 KiB

Hackerspace.sh - API Reference

Last Updated: 2026-05-15
Complete reference for all server actions, database operations, and API patterns. Actions added since the original baseline are listed in "Server actions added (migrations 016-024)" at the end.


Table of Contents

  1. Server Actions Overview
  2. Authentication Actions
  3. Task Actions
  4. Project Actions
  5. Member Actions
  6. Payment Actions
  7. Knowledge Base Actions
  8. Contact Actions
  9. Settings Actions
  10. Data Fetching Patterns
  11. Real-time Subscriptions
  12. Error Handling

1. Server Actions Overview

All mutations use Next.js Server Actions with 'use server' directive. Actions:

  • Authenticate via supabase.auth.getUser()
  • Validate membership and roles
  • Return { data?, error?, success? } objects
  • Call revalidatePath() for cache invalidation

Import Pattern

// In client components
import { createTask, claimTask } from '@/lib/actions'
import { signIn, signOut } from '@/lib/auth-actions'

// Usage
const result = await createTask({ title: 'Clean laser', type: 'chore' })
if (result.error) {
  setError(result.error)
} else {
  // Success - result.data contains the new task
}

2. Authentication Actions

File: lib/auth-actions.ts

signIn(email, password)

Authenticates user with email/password.

async function signIn(email: string, password: string): Promise<{
  data?: AuthData
  error?: string
}>

Parameters:

  • email (string, required): User email
  • password (string, required): User password

Returns:

  • { data: AuthData } on success
  • { error: string } on failure

Example:

const result = await signIn('[email protected]', 'password123')
if (result.error) {
  toast.error(result.error)
} else {
  router.push('/dashboard')
}

signUp(formData)

Creates new account and optionally creates/joins a space.

async function signUp(formData: {
  email: string
  password: string
  fullName: string
  action: 'create' | 'join'
  spaceName?: string
  spaceSlug?: string
  city?: string
  inviteCode?: string
}): Promise<{ data?: AuthData; error?: string }>

Parameters:

  • email (string, required): User email
  • password (string, required): Minimum 8 characters
  • fullName (string, required): Display name
  • action ('create' | 'join', required): Space action
  • spaceName (string): Required if action='create'
  • spaceSlug (string): Required if action='create'
  • city (string, optional): Space location
  • inviteCode (string): Required if action='join'

Behavior:

  • Creates auth user with metadata
  • DB trigger handle_space_signup creates space/member
  • Returns session if email confirmation disabled
  • Otherwise redirects to confirmation page

signOut()

Signs out current user and redirects to login.

async function signOut(): Promise<never>

Behavior:

  • Calls supabase.auth.signOut()
  • Redirects to /login

getUser()

Gets current authenticated user.

async function getUser(): Promise<User | null>

getCurrentMembership()

Gets current user's space membership with space details.

async function getCurrentMembership(): Promise<SpaceMember & { spaces: Space } | null>

createSpace(formData)

Creates a new space for authenticated user.

async function createSpace(formData: {
  spaceName: string
  spaceSlug: string
  spaceCity?: string
  displayName: string
}): Promise<{ success?: boolean; error?: string }>

Role Required: Authenticated (no existing space membership)

Behavior:

  • Uses admin client (bypasses RLS)
  • Creates space with auto-generated invite code
  • Creates member as admin/plus/current

joinSpace(formData)

Joins existing space via invite code.

async function joinSpace(formData: {
  inviteCode: string
  displayName: string
}): Promise<{ success?: boolean; error?: string }>

Role Required: Authenticated

Behavior:

  • Validates invite code
  • Creates member with status based on require_approval

3. Task Actions

File: lib/actions.ts

createTask(formData)

Creates a new task or chore.

async function createTask(formData: {
  title: string
  description?: string
  type: string          // 'task' | 'chore'
  area?: string
  recurrence?: string   // 'none' | 'daily' | 'weekly' | 'biweekly' | 'monthly'
  due_date?: string     // ISO date string
}): Promise<{ data?: Task; error?: string }>

Role Required: Member

Behavior:

  • Sets requested_by to current user
  • Sets status to 'open'
  • Logs activity
  • Revalidates /tasks and /dashboard

claimTask(taskId)

Claims an open task.

async function claimTask(taskId: string): Promise<{ success?: boolean; error?: string }>

Role Required: Member

Behavior:

  • Sets claimed_by to current user
  • Sets status to 'claimed'
  • Logs activity

completeTask(taskId)

Marks task as completed.

async function completeTask(taskId: string): Promise<{ success?: boolean; error?: string }>

Role Required: Member (ideally claimant)

Behavior:

  • Sets status to 'completed'
  • Sets completed_at and last_done_at
  • Logs activity

deleteTask(taskId)

Deletes a task.

async function deleteTask(taskId: string): Promise<{ success?: boolean; error?: string }>

Role Required: Member


4. Project Actions

createProject(formData)

Creates a new project.

async function createProject(formData: {
  title: string
  description?: string
  area?: string
  tags?: string[]
  due_date?: string
}): Promise<{ data?: Project; error?: string }>

Role Required: Member

Behavior:

  • Sets status to 'backlog'
  • Logs activity

updateProjectStatus(projectId, status)

Updates project kanban status.

async function updateProjectStatus(
  projectId: string, 
  status: string  // 'backlog' | 'in_progress' | 'review' | 'done' | 'blocked'
): Promise<{ success?: boolean; error?: string }>

Role Required: Member


deleteProject(projectId)

Deletes a project.

async function deleteProject(projectId: string): Promise<{ success?: boolean; error?: string }>

Role Required: Member


5. Member Actions

addMember(formData)

Manually adds a member to the space.

async function addMember(formData: {
  display_name: string
  email: string
  phone?: string
  handle?: string
  tier: string          // 'plus' | 'basic' | 'associate'
  role: string          // 'admin' | 'board' | 'treasurer' | 'member' | 'associate'
  joined_at?: string
  has_card_access?: boolean
}): Promise<{ data?: SpaceMember; error?: string }>

Role Required: Admin or Board

Note: Creates member without user_id (offline member). User can claim via signup later.


updateMember(memberId, updates)

Updates member details.

async function updateMember(memberId: string, updates: {
  display_name?: string
  email?: string
  phone?: string
  handle?: string
  tier?: string
  role?: string
  status?: string
  has_card_access?: boolean
  payment_status?: string
  payment_note?: string
}): Promise<{ success?: boolean; error?: string }>

Role Required: Admin or Board


approveMember(memberId)

Approves a pending member.

async function approveMember(memberId: string): Promise<{ success?: boolean; error?: string }>

Role Required: Admin or Board

Behavior:

  • Sets status to 'current'
  • Sets approved to true
  • Logs activity

removeMember(memberId)

Removes member from space.

async function removeMember(memberId: string): Promise<{ success?: boolean; error?: string }>

Role Required: Admin only


importMembers(rows)

Bulk import members from CSV data.

async function importMembers(rows: Array<{
  display_name: string
  email: string
  phone?: string
  tier?: string
  joined_at?: string
  last_paid_at?: string
  has_card_access?: boolean
}>): Promise<{ data?: SpaceMember[]; count?: number; error?: string }>

Role Required: Admin or Board

Behavior:

  • Upserts on (space_id, email) to avoid duplicates

6. Payment Actions

logCashPayment(formData)

Records a cash payment.

async function logCashPayment(formData: {
  amount: number
  from_note: string
  member_id?: string
  transaction_date?: string
}): Promise<{ data?: Payment; error?: string }>

Role Required: Treasurer, Board, or Admin

Behavior:

  • Sets platform to 'cash'
  • If member_id provided, sets link_status to 'linked'
  • Updates member's last_paid_at if linked
  • Logs activity

linkPaymentToMember(paymentId, memberId)

Links an unlinked payment to a member.

async function linkPaymentToMember(
  paymentId: string, 
  memberId: string
): Promise<{ success?: boolean; error?: string }>

Role Required: Treasurer, Board, or Admin

Behavior:

  • Updates payment member_id and link_status
  • Updates member last_paid_at and payment_status

importPaymentsCsv(rows)

Bulk import payments.

async function importPaymentsCsv(rows: Array<{
  platform: string
  amount: number
  from_identifier: string
  from_note?: string
  transaction_date: string
}>): Promise<{ data?: Payment[]; count?: number; error?: string }>

Role Required: Treasurer, Board, or Admin


7. Knowledge Base Actions

createKbEntry(formData)

Creates knowledge base entry.

async function createKbEntry(formData: {
  title: string
  content: string
  description?: string
  area?: string
  visibility?: string   // 'all_members' | 'board' | 'admin_only'
  is_pinned?: boolean
  tags?: string[]
  icon?: string
}): Promise<{ data?: KnowledgeBase; error?: string }>

Role Required: Member


updateKbEntry(entryId, updates)

Updates knowledge base entry.

async function updateKbEntry(entryId: string, updates: {
  title?: string
  content?: string
  area?: string
  visibility?: string
  is_pinned?: boolean
  tags?: string[]
}): Promise<{ success?: boolean; error?: string }>

Role Required: Member


deleteKbEntry(entryId)

Deletes knowledge base entry.

async function deleteKbEntry(entryId: string): Promise<{ success?: boolean; error?: string }>

Role Required: Board or Admin (via RLS)


createSecret(formData)

Creates encrypted secret.

async function createSecret(formData: {
  title: string
  value: string
  description?: string
  area?: string
  icon?: string
}): Promise<{ data?: Secret; error?: string }>

Role Required: Admin or Board


deleteSecret(secretId)

Deletes secret.

async function deleteSecret(secretId: string): Promise<{ success?: boolean; error?: string }>

Role Required: Admin only


upsertAreaLead(formData)

Creates or updates area lead assignment.

async function upsertAreaLead(formData: {
  area_code: string
  area_name: string
  lead_id?: string
  lead_handle?: string
  status?: string       // 'active' | 'vacant' | 'handoff'
}): Promise<{ data?: AreaLead; error?: string }>

Role Required: Admin or Board

Behavior:

  • Upserts on (space_id, area_code)

8. Contact Actions

createContact(formData)

Creates a contact entry.

async function createContact(formData: {
  name: string
  contact_type: string  // 'vendor' | 'supplier' | 'partner' | 'landlord' | 'city'
  email?: string
  phone?: string
  details?: string
  note?: string
  group_label?: string
  tags?: string[]
}): Promise<{ data?: Contact; error?: string }>

Role Required: Member

Behavior:

  • Auto-generates 3-letter code from name

updateContact(contactId, updates)

Updates contact.

async function updateContact(contactId: string, updates: {
  name?: string
  contact_type?: string
  email?: string
  phone?: string
  details?: string
  note?: string
  group_label?: string
  tags?: string[]
}): Promise<{ success?: boolean; error?: string }>

Role Required: Member


deleteContact(contactId)

Deletes contact.

async function deleteContact(contactId: string): Promise<{ success?: boolean; error?: string }>

Role Required: Member


9. Settings Actions

updateSpaceSettings(updates)

Updates space configuration.

async function updateSpaceSettings(updates: {
  name?: string
  slug?: string
  city?: string
  require_approval?: boolean
  public_member_directory?: boolean
}): Promise<{ success?: boolean; error?: string }>

Role Required: Admin only


saveIntegration(platform, config)

Saves integration configuration.

async function saveIntegration(
  platform: string, 
  config: Record<string, string>
): Promise<{ success?: boolean; error?: string }>

Role Required: Admin only

Behavior:

  • Upserts on (space_id, platform)
  • Stores config with _set indicators for each field

disconnectIntegration(platform)

Disconnects/clears integration.

async function disconnectIntegration(platform: string): Promise<{ success?: boolean; error?: string }>

Role Required: Admin only


rotateWebhookSecret()

Generates new webhook secret.

async function rotateWebhookSecret(): Promise<{ secret?: string; error?: string }>

Role Required: Admin only

Returns: New whsec_ prefixed secret


10. Data Fetching Patterns

Server Component Fetch

// In page.tsx (Server Component)
import { createClient } from '@/lib/supabase/server'

export default async function TasksPage() {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()
  
  const { data: member } = await supabase
    .from('space_members')
    .select('space_id, display_name, role')
    .eq('user_id', user!.id)
    .eq('status', 'current')
    .single()

  const { data: tasks } = await supabase
    .from('tasks')
    .select('*')
    .eq('space_id', member.space_id)
    .order('created_at', { ascending: false })

  return <TasksClient tasks={tasks ?? []} />
}

Parallel Fetching

const [
  { data: tasks },
  { data: projects },
  { count: memberCount }
] = await Promise.all([
  supabase.from('tasks').select('*').eq('space_id', spaceId),
  supabase.from('projects').select('*').eq('space_id', spaceId),
  supabase.from('space_members').select('*', { count: 'exact', head: true }).eq('space_id', spaceId)
])

11. Real-time Subscriptions

Comms Messages

'use client'
import { createClient } from '@/lib/supabase/client'
import { useEffect, useState } from 'react'

function useChannelMessages(channelId: string) {
  const [messages, setMessages] = useState([])

  useEffect(() => {
    const supabase = createClient()
    
    // Initial fetch
    supabase
      .from('comms_messages')
      .select('*')
      .eq('channel_id', channelId)
      .order('created_at')
      .then(({ data }) => setMessages(data ?? []))

    // Subscribe to new messages
    const subscription = supabase
      .channel(`messages:${channelId}`)
      .on('postgres_changes', {
        event: 'INSERT',
        schema: 'public',
        table: 'comms_messages',
        filter: `channel_id=eq.${channelId}`
      }, payload => {
        setMessages(prev => [...prev, payload.new])
      })
      .subscribe()

    return () => {
      subscription.unsubscribe()
    }
  }, [channelId])

  return messages
}

12. Error Handling

Action Return Pattern

All actions return a consistent shape:

// Success with data
{ data: T }

// Success without data
{ success: true }

// Error
{ error: string }

Client-side Handling

const [error, setError] = useState('')
const [loading, setLoading] = useState(false)

async function handleSubmit() {
  setLoading(true)
  setError('')
  
  const result = await createTask(formData)
  
  if (result.error) {
    setError(result.error)
  } else {
    // Success - close modal, refresh data, etc.
  }
  
  setLoading(false)
}

Common Error Messages

Error Cause Solution
"Not authenticated" No session Redirect to login
"No active membership" User has no space Redirect to signup
"Admin access required" Role check failed Show permission error
"Treasurer access required" Payment role check Show permission error
"Invalid invite code" Code not found Ask user to verify
"A space with this URL slug already exists" Duplicate slug Suggest different slug

Server actions added (migrations 016-026)

All are 'use server' in lib/actions/*, re-exported from lib/actions/index.ts, validate input with Zod via parseInput, authorize via requireMember / requireMemberWithRole, scope writes by member.space_id, and revalidatePath.

Governance — proposals.ts, incidents.ts, policies.ts

createProposal, openProposal, castVote, decideProposal, withdrawProposal, deleteProposal; fileIncident, updateIncidentStatus, addIncidentUpdate, appealIncident; createPolicy, supersedePolicy, updatePolicyStatus.

trackIncident({ token }) — public, unauthenticated. An anonymous reporter's fileIncident returns a 192-bit reporter_token (UNIQUE column); this looks the case up by that token using the service client (anon has no RLS path) and returns a redacted projection via lib/incident-logic.ts publicIncidentView (board-only updates, subjects, decision-maker ids dropped; disposition only after decided/closed). Public page /track (in middleware PUBLIC_ROUTES); the filing UI deep-links /track?token=.

Areas — areas.ts

createArea, updateArea, deleteArea (admin/board; delete admin).

Forum + comments — forum.ts

createForumThread, updateForumThread (pin/lock = admin/board), deleteForumThread, addComment, editComment, deleteComment. All space-scoped in code.

Chat channels — comms.ts

createChannel (any member), renameChannel, deleteChannel (creator or admin/board; default channels protected).

Tiers / roles / invites — tiers.ts, roles.ts, invites.ts

createTier/updateTier/deleteTier; upsertRoleLabel, createCustomRole/updateCustomRole/deleteCustomRole, assignCustomRole/unassignCustomRole; createInvite/updateInvite/deleteInvite. Invites (migration 029) carry a granted role and usage caps (max_uses, single-use); joinSpace applies the invite's role. Who may grant which role is enforced by lib/invite-logic.ts canAssignInviteRole (admin → any; board → anything except admin). Space-scoped link /join/[space]?code= hands off to /signup?invite=.

Onboarding — onboarding.ts

createOnboardingStep/updateOnboardingStep/deleteOnboardingStep (admin/board); markOnboardingStepDone, finishOnboarding, skipOnboarding (member; completion writes via the service client after the server-side required-steps check, so the self-change trigger cannot be bypassed).

Permissions / Ops ACLs / area-lead — permissions.ts

setRolePermissions(subject, permissions[]), setOpsAcl(entity_type, entity_id, roles[]) (admin/board), createAreaLeadRole, assignAreaLead, unassignAreaLead, deleteAreaLeadRole. ACLs are additive: empty list falls back to the item's default visibility.

Secrets — secrets.ts

createSecret, updateSecret, revealSecret (server-only plaintext path, audit-logged, errors on corrupt ciphertext), deleteSecret. The list query never selects value/encrypted_value.

Bulk import — imports.ts, payments.ts

importMembers, importPaymentsCsv: per-row Zod validation, lowercased emails, flexibleDateTime date normalization, enum-checked platform/tier, positive finite amounts; invalid rows skipped and returned as a skipped count.

Forms & waivers: forms.ts (migrations 026-029)

Management actions are gated by forms.manage (checked via the user_has_permission RPC; the forms RLS independently enforces it):

  • getPublicForm({ space, slug }) — service-client read for the public /f/[space]/[slug] page; returns only a published, non-members form. createForm(input) — friendly error on slug collision (slug is unique per space, migration 028). updateForm(input) — slug is immutable; for a published waiver, a legal-text or schema change bumps version (existing submissions stay valid against their own snapshot, non-blocking re-sign). setFormStatus(input) — draft/published/closed. deleteForm(input) — refused if the form has any submissions (close instead; submissions are immutable records). listForms(), getFormResults(input), exportFormResultsCsv(input) (audited via activity_log).
  • submitForm(input) — public entry point (anonymous, signed-in non-member, or member by form visibility). Reads the form with the service client (anon has no forms grant), enforces visibility + waiver consent, validates answers against the stored field schema (lib/forms-schema.ts, unknown keys discarded), snapshots schema/legal-text/version, captures IP + user-agent, and writes the row with the service client (form_submissions has no write policy, so this is the only path; rows are immutable).
  • linkSubmissionsForMember(input) — forms.manage admin manual-link of prior anonymous submissions by email. The automatic verified-email retro-link is Phase 5.

Onboarding integration (Phase 4, migration 027): a space_onboarding_steps step can have step_type = 'form' with config.form_id. The onboarding flow renders the form/waiver and submits via submitForm; finishOnboarding treats a required form step as satisfied if a submission for that form by the member already exists (any version — re-sign is non-blocking), and fails open if the configured form is missing/unpublished so a misconfiguration cannot trap members.

Retro-link (Phase 5): claimMyAnonymousSubmissions() is a safe, self-only, verified-email-gated action — it resolves the member/email from the caller's session (no trusted params), so a client can only ever link its own anonymous submissions, and only with a confirmed email (the locked decision). It is invoked best-effort from joinSpace (new membership, verified email) and finishOnboarding. addMember/importMembers are deliberately NOT auto-linked: those emails are admin-asserted, not verified, so auto-linking would violate the verified-email rule and risk mis-attributing a waiver; the admin uses the forms.manage linkSubmissionsForMember for those (the "admin manual-link" path).

Certifications + Instructor: certifications.ts (migration 030)

Cert-type actions are gated by certifications.manage; grant actions by certifications.grant (the Instructor capability). Both checked via the user_has_permission RPC; the certifications / member_certifications RLS independently enforces the same permission.

  • createCertification(input) / updateCertification(input) / deleteCertification(input) — manage. Name is unique per space (case-insensitive); friendly error on collision. validity_months null = never expires. deleteCertification is refused once the cert has been granted to anyone (archive via updateCertification {is_active:false} instead — grant history is immutable).
  • listCertifications() — readable by a manager OR a granter (the admin list and the per-member award panel both need the catalog); returns all cert types for the caller's space (RLS-scoped), active first.
  • grantCertification(input) — granter. Verifies the cert is in-space and active and the target member is in-space; computes expires_at from the cert's validity_months at grant time unless an explicit expires_at override is supplied; the partial unique index rejects a second active grant of the same cert to the same member with a friendly message.
  • revokeCertification(input) — granter. Soft revoke (revoked_at/revoked_by/revoked_reason); refused if already revoked. No row is ever deleted (no DELETE policy; history immutable).
  • renewCertification(input) — granter. Resets granted_at to now and recomputes expiry from the cert's current validity; a revoked grant is terminal (issue a fresh grant instead).
  • listMemberCertifications({ memberId }) — manager or granter; all grants for one member (joined with the cert) for the per-member award panel.
  • getMyCertifications() — the signed-in member's own grants. No params: the member is resolved from the session and RLS independently restricts a member to their own rows (the /me view).

Classes: classes.ts (migration 032)

Class/session management is gated by classes.manage; attendance/completion by classes.instruct. Member signup needs no permission (just membership). Gates use the user_has_permission RPC; the classes/class_sessions/class_signups RLS independently enforces the same.

  • createClass / updateClass / deleteClass / listClasses — classes.manage. deleteClass is refused once the class has sessions (archive via updateClass {is_active:false}). payment_link is a generic http(s) link only (no payment integration); grants_certification_id is validated to be a cert in the space.
  • createSession / updateSession / deleteSession — classes.manage. updateSession refuses status:'completed' (use completeSession); deleteSession refused if non-cancelled signups exist (cancel instead).
  • listUpcomingSessions() — any member; upcoming non-cancelled sessions of active classes, joined with the class. registered_count is computed with the service client so a member sees "spots left" without being able to read who else signed up (class_signups SELECT hides other members); my_status is the caller's own signup status.
  • signUpForClass(input) / cancelMySignup(input) — any member. Validated service-client writes (class_signups has no INSERT/DELETE policy): signUpForClass checks eligibility (canSignUp), de-dupes, and registers vs waitlists by effective capacity; cancelMySignup cancels (soft) and promotes the earliest waitlisted member when a registered seat frees (pickPromotion).
  • listSessionSignups(input) — manager or instructor; the attendee list for a session.
  • 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).