939 lines
33 KiB
Markdown
939 lines
33 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`.
|
|
|
|
`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. `required_form_id` (migration 037) is validated via the service client to be a form in the space that is `published`; setting it hard-gates signup on a form submission being on file.
|
|
- `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. When the class has a `required_form_id` it also returns `required_form { title, url, satisfied }` (the public `/f/{space}/{slug}` URL and whether THIS member has a submission on file) so the member surface can show/link the requirement.
|
|
- `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. A `classes.manage` holder gets the override and may pass `memberId` to sign someone up on their behalf. If the class has a `required_form_id`, `signupFormEligibility` hard-blocks signup unless the target member has a `form_submissions` row for that form (checked via the service client, since a class manager need not hold `forms.manage`); the manager override bypasses the gate. `cancelMySignup` cancels (soft) and promotes the earliest waitlisted member when a registered seat frees (`pickPromotion`).
|
|
- `listSessionSignups(input)` — manager or instructor; the attendee list (name, status, attended) for a session. Surfaced both on the member page for instructors and behind a per-session "Signups" toggle on `/classes/manage`; members never see it.
|
|
- `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 name) for the schedule view. Surfaced behind a per-item "Reservations" toggle on `/equipment/manage` (who reserved + window + status, with a manager Cancel).
|
|
- `getMyReservations()` — the signed-in member's own reservations (joined equipment). No params; RLS restricts to own rows (the `/me` view).
|
|
|
|
### Door epic: `member-cards.ts` (migration 034) + `door.ts` (migrations 035-036)
|
|
Card UID is a credential. `member-cards.ts`: `addMemberCard`/`updateMemberCard`/`deleteMemberCard`/`listMemberCards` are `door.manage` (full UID); `getMyCards()` is the member's own MASKED view (service client, returns only count + last4, never the raw UID — `member_cards` has no member RLS SELECT).
|
|
`door.ts` (all `door.manage` except the read log): `createDoorConnection`/`updateDoorConnection`/`deleteDoorConnection`/`listDoorConnections`; `listSecretTitles()` (titles only, to pick a vault secret for the password). `testDoorConnection(input)` runs only the `status` verb through the hardened executor and writes one redacted audit row. `listDoorAccessLog()` is `door.manage` OR `door.operate`. Security model: the shared password is read from the AES-256-GCM secrets vault server-side and embedded in the request per the verified firmware (auth is a query param); `lib/door/executor.ts` is the single egress — it enforces `validateDoorTarget` (request host must equal the connection's pinned host; metadata/link-local always blocked; http(s) only), refuses redirects, caps time/body, and `redactDoorSecrets` scrubs the password before anything is logged. Native HeatSync verbs are encoded with the verified fixed-width zero-padding (slot 3 / perm 3 / tag 8 / `&e=` password); generic connections use admin `{slot}/{tag}/{pw}` templates.
|
|
|
|
Phase 3 live actions (all `door.operate`, rate-limited, every attempt writes one redacted `door_access_log` row, service client after the permission check since operators have no RLS read on `door_connections`/`member_cards`): `grantCard({connectionId, cardId, permissionMask?})` allocates the card's per-connection integer slot via the pure lowest-free allocator (`door_card_slots`; idempotent re-grant, `slot_exhausted` when full), reserves it in the DB first, calls the controller, and rolls the reservation back if the call fails. `revokeCard({connectionId, cardId})` is idempotent (no slot assigned means already revoked) and frees the slot only on confirmed controller success so the app's map never diverges from the device. `doorControl({connectionId, verb})` with `verb` of `open`/`unlock`/`lock` (HeatSync `open` is a momentary `o1`; generic controllers need the matching verb template) touches no slot.
|
|
|
|
Member self-entry (no permission code; any active member): `selfEntry({connectionId})` triggers a momentary OPEN only, allowed only when the connection is `is_enabled` AND `allow_member_self_entry` AND the caller has at least one active `member_card` on file (the locked eligibility rule: a `door_card_slots` row is NOT required). Membership and cards are resolved server-side, so a member can only ever open a door for themselves; never unlock/lock/grant/revoke, never anonymous. Strict per-member rate limit (5/min), one redacted `door_access_log` row `action='self_entry'` (`target_member_id` = self), through the same hardened executor. `listSelfEntryDoors()` returns the enabled self-entry connections for the caller's space, but only when the caller has an active card (otherwise empty), so the surface is hidden for ineligible members. `listMyDoorActivity()` returns the caller's own `door_access_log` rows (actor or target; service client after `requireMember`; `detail` is already secret-redacted at write time). Surfaced as a "Door access" panel on the dashboard and on the member `/doors` page (self-entry + masked own cards via `getMyCards` + recent personal activity).
|