Files
hackerspace.sh/docs/API_REFERENCE.md
T
Moheeb Zara 897fa30ec1 feat(forms): server actions, Zod schemas, and dynamic answer validation (Phase 2)
- lib/validations.ts: form field/array schemas (8 field types,
  duplicate-key and select-options refinements) and create/update/
  status/submit/link envelope schemas; slug immutable on update
- lib/forms-schema.ts: pure parseFormSchema + validateAnswers; unknown
  answer keys are discarded so a caller cannot inject arbitrary jsonb
- lib/actions/forms.ts: create/update/setStatus/delete/list/results/
  CSV export (forms.manage via user_has_permission + RLS), submitForm
  (service-client read+write, visibility + waiver consent enforced,
  schema/legal/version snapshot, IP/UA capture), and
  linkSubmissionsForMember (manual admin link; auto path is Phase 5)
- updateForm bumps a published waiver's version on legal/schema change
  (non-blocking; existing submissions stay valid against their snapshot)
- deleteForm refuses when submissions exist (immutable records)
- types/database.ts: type user_has_permission/user_effective_roles
  (stale since migration 023)
- __tests__/forms.test.ts (14); API_REFERENCE + HANDOFF updated

285/285 tests, build clean. No deploy.
2026-05-16 14:51:20 -07:00

898 lines
21 KiB
Markdown

# 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](#1-server-actions-overview)
2. [Authentication Actions](#2-authentication-actions)
3. [Task Actions](#3-task-actions)
4. [Project Actions](#4-project-actions)
5. [Member Actions](#5-member-actions)
6. [Payment Actions](#6-payment-actions)
7. [Knowledge Base Actions](#7-knowledge-base-actions)
8. [Contact Actions](#8-contact-actions)
9. [Settings Actions](#9-settings-actions)
10. [Data Fetching Patterns](#10-data-fetching-patterns)
11. [Real-time Subscriptions](#11-real-time-subscriptions)
12. [Error Handling](#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
```typescript
// 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.
```typescript
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**:
```typescript
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.
```typescript
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.
```typescript
async function signOut(): Promise<never>
```
**Behavior**:
- Calls `supabase.auth.signOut()`
- Redirects to `/login`
---
### `getUser()`
Gets current authenticated user.
```typescript
async function getUser(): Promise<User | null>
```
---
### `getCurrentMembership()`
Gets current user's space membership with space details.
```typescript
async function getCurrentMembership(): Promise<SpaceMember & { spaces: Space } | null>
```
---
### `createSpace(formData)`
Creates a new space for authenticated user.
```typescript
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.
```typescript
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.
```typescript
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.
```typescript
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.
```typescript
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.
```typescript
async function deleteTask(taskId: string): Promise<{ success?: boolean; error?: string }>
```
**Role Required**: Member
---
## 4. Project Actions
### `createProject(formData)`
Creates a new project.
```typescript
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.
```typescript
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.
```typescript
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.
```typescript
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.
```typescript
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.
```typescript
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.
```typescript
async function removeMember(memberId: string): Promise<{ success?: boolean; error?: string }>
```
**Role Required**: Admin only
---
### `importMembers(rows)`
Bulk import members from CSV data.
```typescript
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.
```typescript
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.
```typescript
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.
```typescript
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.
```typescript
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.
```typescript
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.
```typescript
async function deleteKbEntry(entryId: string): Promise<{ success?: boolean; error?: string }>
```
**Role Required**: Board or Admin (via RLS)
---
### `createSecret(formData)`
Creates encrypted secret.
```typescript
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.
```typescript
async function deleteSecret(secretId: string): Promise<{ success?: boolean; error?: string }>
```
**Role Required**: Admin only
---
### `upsertAreaLead(formData)`
Creates or updates area lead assignment.
```typescript
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.
```typescript
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.
```typescript
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.
```typescript
async function deleteContact(contactId: string): Promise<{ success?: boolean; error?: string }>
```
**Role Required**: Member
---
## 9. Settings Actions
### `updateSpaceSettings(updates)`
Updates space configuration.
```typescript
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.
```typescript
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.
```typescript
async function disconnectIntegration(platform: string): Promise<{ success?: boolean; error?: string }>
```
**Role Required**: Admin only
---
### `rotateWebhookSecret()`
Generates new webhook secret.
```typescript
async function rotateWebhookSecret(): Promise<{ secret?: string; error?: string }>
```
**Role Required**: Admin only
**Returns**: New `whsec_` prefixed secret
---
## 10. Data Fetching Patterns
### Server Component Fetch
```typescript
// 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
```typescript
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
```typescript
'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:
```typescript
// Success with data
{ data: T }
// Success without data
{ success: true }
// Error
{ error: string }
```
### Client-side Handling
```typescript
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`.
### 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`.
### 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` (migration 026)
Management actions are gated by `forms.manage` (checked via the `user_has_permission` RPC; the `forms` RLS independently enforces it):
- `createForm(input)` — friendly error on slug collision (slug is globally unique). `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.
Not implemented yet (planned): builder UI, public `/f/[slug]` page, onboarding form-step integration, automatic retro-link on email verification.