Files
hackerspace.sh/DB_SCHEMA_MAP.md
T
Moheeb Zara 2687c1578d fix(secrets): honor ops.secrets.read permission for reveal/list
A regular member granted reveal access either via the ops.secrets.read
role permission or a per-secret ops_acl entry could not actually see or
reveal secrets: revealSecret hard-gated on built-in role admin/board
(ignoring both the permission and the ACL), and secrets_select RLS never
consulted the permission, and the Ops page short-circuited the secrets
fetch by built-in role.

Migration 031 additively adds an OR user_has_permission(...,
'ops.secrets.read') branch to secrets_select; the admin/board and
per-secret ops_acl branches are unchanged so deployment is access-neutral
unless an admin has granted the permission. revealSecret now resolves
the member and lets RLS be the boundary (a denied row simply does not
return). The Ops page always queries secrets (RLS filters the set) and
shows the section to admin/board, a permission holder, or anyone with an
ACL-granted secret. Write paths (create/update/delete) are unchanged.
Mirrored in schema.sql; migration docs and HANDOFF updated.
2026-05-16 19:07:06 -07:00

428 lines
19 KiB
Markdown

# DB Schema Map — Quick Reference
> **Last Updated**: 2026-05-15
> **Full Documentation**: See [docs/DATABASE_SCHEMA.md](./docs/DATABASE_SCHEMA.md) for complete reference
> **Source of Truth**: `scripts/schema.sql` (canonical, idempotent)
---
## ENUMS — exact live values (queried 2026-03-10)
| Enum | Live Values |
|------|-------------|
| `member_role` | `admin`, `board`, `treasurer`, `member`, `associate` |
| `member_tier` | `plus`, `basic`, `associate` |
| `member_status` | `current`, `late`, `inactive`, `unverified` — **NOT** `active` or `pending` |
| `task_type` | `chore`, `task` — DB column is **`task_type`** (enum name and column name match) |
| `task_status` | `open`, `claimed`, `in_progress`, `overdue`, `due_today`, `completed`, `done`, `blocked` |
| `recurrence_type` | `daily`, `weekly`, `biweekly`, `monthly`, `none` — NO `quarterly` |
| `project_status` | `backlog`, `in_progress`, `review`, `done`, `blocked` |
| `kb_visibility` | `all_members`, `board`, `admin_only` — **NOT** `admins_only` |
| `contact_type` | `vendor`, `supplier`, `partner`, `landlord`, `city` — NO `contractor` |
| `area_lead_status` | `active`, `vacant`, `handoff` |
| `payment_platform` | `paypal`, `zeffy`, `venmo`, `cash` |
| `payment_link_status` | `linked`, `unlinked` |
| `channel_type` | `general`, `area`, `ops`, `project` — NO `board` |
| `contact_type` | `vendor`, `supplier`, `partner`, `landlord`, `city` |
| `payment_platform` | `paypal`, `zeffy`, `venmo`, `cash` |
| `payment_link_status` | `linked`, `unlinked` |
| `channel_type` | `general`, `area`, `ops`, `project` |
---
## TABLES — Every column, exact names
### `spaces`
| Column | Type | Notes |
|--------|------|-------|
| `id` | uuid PK | |
| `name` | text NOT NULL | |
| `slug` | text UNIQUE NOT NULL | |
| `city` | text | nullable |
| `require_approval` | boolean | default true |
| `public_member_directory` | boolean | default false |
| `invite_code` | text UNIQUE NOT NULL | |
| `webhook_secret` | text | nullable |
| `created_at` | timestamptz | |
| `updated_at` | timestamptz | |
**Does NOT have:** `description`, `logo`, `timezone`
---
### `space_members`
| Column | Type | Notes |
|--------|------|-------|
| `id` | uuid PK | |
| `space_id` | uuid FK → spaces | NOT NULL |
| `user_id` | uuid FK → auth.users | **NULLABLE** — offline members have no user_id |
| `role` | text | default `'member'` |
| `tier` | text | default `'basic'` |
| `display_name` | text NOT NULL | |
| `email` | text | nullable |
| `handle` | text | nullable |
| `phone` | text | nullable |
| `status` | text | default `'active'` |
| `payment_status` | text | nullable |
| `payment_note` | text | nullable |
| `joined_at` | timestamptz | |
| `last_payment_at` | timestamptz | nullable — legacy, **also has `last_paid_at`** |
| `last_paid_at` | timestamptz | nullable — added script 013; used by importMembers / linkPaymentToMember |
| `has_card_access` | boolean | default false |
| `approved` | boolean | default **true** (fixed script 008) |
| UNIQUE | `(space_id, user_id)` | |
| UNIQUE | `(space_id, email)` | added script 009 |
**Does NOT have:** `last_payment_at` (has both `last_payment_at` and `last_paid_at`), `approved_at`, `bio`, `avatar_url`
---
### `tasks`
| Column | Type | Notes |
|--------|------|-------|
| `id` | uuid PK | |
| `space_id` | uuid FK → spaces | NOT NULL |
| `title` | text NOT NULL | |
| `description` | text | nullable |
| `task_type` | task_type | default `'task'` — column is **`task_type`**, matching the enum name |
| `status` | task_status | default `'open'` — use `'completed'` not `'done'` |
| `area` | text | nullable |
| `recurrence` | recurrence_type | default `'none'` |
| `due_date` | timestamptz | nullable |
| `assigned_to` | uuid FK → auth.users | nullable |
| `assigned_to_name` | text | nullable |
| `claimed_by` | uuid FK → auth.users | nullable |
| `claimed_by_name` | text | nullable |
| `requested_by` | uuid FK → auth.users | nullable |
| `requested_by_name` | text | nullable |
| `subtask_total` | integer | default 0 |
| `subtask_completed` | integer | default 0 |
| `last_done_at` | timestamptz | nullable |
| `completed_at` | timestamptz | nullable |
| `created_at` | timestamptz | |
| `updated_at` | timestamptz | |
**Does NOT have:** `type` (use `task_type`), `progress`, `blocked`, `priority`
---
### `projects`
| Column | Type | Notes |
|--------|------|-------|
| `id` | uuid PK | |
| `space_id` | uuid FK → spaces | NOT NULL |
| `title` | text NOT NULL | |
| `description` | text | nullable |
| `status` | project_status | default `'backlog'` |
| `area` | text | nullable |
| `tags` | text[] | nullable |
| `assignee_names` | text[] | nullable |
| `task_count` | integer | default 0 |
| `tasks_completed` | integer | default 0 |
| `due_date` | timestamptz | nullable |
| `created_at` | timestamptz | |
| `updated_at` | timestamptz | |
**Does NOT have:** `progress`, `blocked`, `priority`, `owner_id`
---
### `knowledge_base`
| Column | Type | Notes |
|--------|------|-------|
| `id` | uuid PK | |
| `space_id` | uuid FK → spaces | NOT NULL |
| `title` | text NOT NULL | |
| `content` | text | nullable |
| `icon` | text | nullable |
| `visibility` | kb_visibility | default `'all_members'` |
| `area` | text | nullable |
| `tags` | text[] | nullable |
| `is_pinned` | boolean | default false — column is **`is_pinned`**, NOT `pinned` |
| `updated_by_id` | uuid FK → auth.users | nullable |
| `updated_by_name` | text | nullable — **NOT** `updated_by` |
| `created_at` | timestamptz | |
| `updated_at` | timestamptz | |
**Does NOT have:** `description`, `pinned`, `updated_by`, `summary`
---
### `secrets`
| Column | Type | Notes |
|--------|------|-------|
| `id` | uuid PK | |
| `space_id` | uuid FK → spaces | NOT NULL |
| `title` | text NOT NULL | |
| `icon` | text | nullable |
| `description` | text | nullable |
| `value` | text NOT NULL | |
| `area` | text | nullable |
| `created_at` | timestamptz | |
| `updated_at` | timestamptz | |
---
### `area_leads`
| Column | Type | Notes |
|--------|------|-------|
| `id` | uuid PK | |
| `space_id` | uuid FK → spaces | NOT NULL |
| `area_name` | text NOT NULL | |
| `area_code` | text NOT NULL | |
| `lead_id` | uuid FK → auth.users | nullable |
| `lead_handle` | text | nullable |
| `status` | area_lead_status | default `'active'` |
| `created_at` | timestamptz | |
| `updated_at` | timestamptz | |
| UNIQUE | `(space_id, area_code)` | added script 007 |
---
### `contacts`
| Column | Type | Notes |
|--------|------|-------|
| `id` | uuid PK | |
| `space_id` | uuid FK → spaces | NOT NULL |
| `name` | text NOT NULL | |
| `code` | text NOT NULL | auto-generated 3-char prefix + random 3 digits |
| `contact_type` | contact_type NOT NULL | |
| `email` | text | nullable |
| `phone` | text | nullable |
| `details` | text | nullable |
| `note` | text | nullable — added script 007 |
| `group_label` | text | nullable — added script 007 |
| `tags` | text[] | nullable |
| `created_at` | timestamptz | |
| `updated_at` | timestamptz | |
---
### `payments`
| Column | Type | Notes |
|--------|------|-------|
| `id` | uuid PK | |
| `space_id` | uuid FK → spaces | NOT NULL |
| `platform` | payment_platform NOT NULL | |
| `amount` | numeric(10,2) NOT NULL | |
| `from_identifier` | text | nullable |
| `from_note` | text | nullable |
| `member_id` | uuid FK → space_members | nullable |
| `member_name` | text | nullable |
| `link_status` | payment_link_status | default `'unlinked'` |
| `transaction_date` | timestamptz NOT NULL | |
| `created_at` | timestamptz | |
**Does NOT have:** `description`, `reference`, `currency`
---
### `comms_channels`
| Column | Type | Notes |
|--------|------|-------|
| `id` | uuid PK | |
| `space_id` | uuid FK → spaces | NOT NULL |
| `name` | text NOT NULL | |
| `icon` | text | nullable |
| `channel_type` | channel_type | default `'general'` |
| `area_reference` | text | nullable |
| `project_id` | uuid FK → projects | nullable, cascade delete |
| `member_count` | integer | default 0 |
| `created_at` | timestamptz | |
**Does NOT have:** `description`, `topic`, `unread_count`, `is_archived`
Default channels created by trigger on space INSERT: `general`, `announcements`, `random`, `facilities`
---
### `comms_messages`
| Column | Type | Notes |
|--------|------|-------|
| `id` | uuid PK | |
| `channel_id` | uuid FK → comms_channels | NOT NULL, cascade delete |
| `space_id` | uuid FK → spaces | NOT NULL |
| `user_id` | uuid FK → auth.users | nullable — **NOT** `sender_id` |
| `display_name` | text NOT NULL | |
| `handle` | text | nullable |
| `content` | text NOT NULL | **NOT** `message`, **NOT** `body` |
| `created_at` | timestamptz | |
**Does NOT have:** `message`, `body`, `sender_id`, `edited_at`, `reactions`
---
### `integrations`
| Column | Type | Notes |
|--------|------|-------|
| `id` | uuid PK | |
| `space_id` | uuid FK → spaces | NOT NULL |
| `name` | text NOT NULL | |
| `platform` | text NOT NULL | |
| `description` | text | nullable |
| `is_connected` | boolean | default false |
| `config` | jsonb | nullable |
| `created_at` | timestamptz | |
| `updated_at` | timestamptz | |
| UNIQUE | `(space_id, platform)` | added script 007 |
---
### `activity_log`
| Column | Type | Notes |
|--------|------|-------|
| `id` | uuid PK | |
| `space_id` | uuid FK → spaces | NOT NULL |
| `user_id` | uuid FK → auth.users | nullable |
| `display_name` | text | nullable |
| `action` | text NOT NULL | |
| `entity_type` | text | nullable |
| `entity_id` | uuid | nullable |
| `details` | text | nullable |
| `created_at` | timestamptz | |
---
## USER FLOWS
### Sign Up → Create Space
1. `/signup` form → `signUp()` → Supabase auth.signUp
2. After email confirm / immediate: `createSpace({ spaceName, spaceSlug, displayName })` (admin client, bypasses RLS)
3. Inserts `spaces` → trigger fires → inserts 4 default `comms_channels`
4. Inserts `space_members` as role=`admin`, tier=`plus`, status=`active`, approved=`true`
5. Redirect → `/dashboard`
### Sign Up → Join Space
1. `/signup` form → `signUp()`
2. `joinSpace({ inviteCode, displayName })` (admin client)
3. Looks up `spaces.invite_code` → inserts `space_members` with role=`member`, status=`active` (or `pending` if `require_approval=true`), approved=`true`
4. Redirect → `/dashboard`
### Login
1. `/login` → `signIn(email, password)` → Supabase auth
2. Middleware checks session cookie
3. App layout RSC calls `getCurrentMembership()` → queries `space_members` WHERE `user_id = auth.uid()` AND `status IN ('active', 'pending')`
4. If no membership found → redirect `/signup`
### Dashboard
- RSC queries: tasks (open/in_progress), projects (in_progress), recent payments, activity_log, all scoped to `space_id`
### Tasks
- List: `tasks` WHERE `space_id = ?` ORDER BY `created_at DESC`
- Create: `createTask()` → status=`open`, type=`task|chore`
- Claim: `claimTask()` → status=`claimed`
- Complete: `completeTask()` → status=`completed` (NOT `done`)
- Delete: `deleteTask()`
### Projects
- Kanban columns: `backlog` | `in_progress` | `review` | `done`
- Create: `createProject()` → status=`backlog`
- Move: `updateProjectStatus(id, status)` where status ∈ project_status enum
### Members (admin/board write)
- List: all `space_members` for space
- Add: `addMember()` (RLS: user_has_role_in_space admin/board)
- Edit: `updateMember()`
- Approve: `approveMember()` → status=`active`, approved=`true`
- Remove: `removeMember()` (admin only)
- CSV: `importMembers()` → upsert on `(space_id, email)`
### Payments (treasurer/admin/board)
- List: `payments` JOIN `space_members` for display_name
- Log cash: `logCashPayment()` → platform=`cash`
- Link: `linkPaymentToMember()` → updates `member_id`, `link_status=linked`, member `last_paid_at`
- CSV: `importPaymentsCsv()`
### Ops / Knowledge Base
- List: `knowledge_base` ordered by `is_pinned DESC`, `created_at DESC`
- Create: `createKbEntry()` → columns: title, content, area, visibility, is_pinned, tags, icon, updated_by_id, updated_by_name
- Update: `updateKbEntry()` → same columns
- Secrets: `createSecret()`, `deleteSecret()` — admin/board only via RLS
- Area leads: `upsertAreaLead()` on conflict `(space_id, area_code)`
### Comms
- Channels: RSC fetches `comms_channels` WHERE `space_id = ?`
- Messages: client fetches `comms_messages` WHERE `channel_id = ?` ORDER BY `created_at`
- Realtime: Supabase channel subscription on `comms_messages` for `channel_id=eq.{id}`
- Send: INSERT `comms_messages` (channel_id, space_id, **user_id**, display_name, handle, **content**)
### Settings (admin)
- Space: `updateSpaceSettings()`
- Integrations: `saveIntegration()` upsert on `(space_id, platform)`, `disconnectIntegration()`
- Webhook: `rotateWebhookSecret()`
---
## RLS SUMMARY
| Table | SELECT | INSERT | UPDATE | DELETE |
|-------|--------|--------|--------|--------|
| `spaces` | own space members | admin client only (bootstrap) | admin | — |
| `space_members` | own space | self OR admin/board | self OR admin | admin |
| `tasks` | members | members | members | members |
| `projects` | members | members | members | members |
| `knowledge_base` | members (visibility-scoped) | members | members | board/admin |
| `secrets` | admin/board/treasurer | admin | admin | admin |
| `area_leads` | members | board/admin | board/admin | board/admin |
| `contacts` | members | members | members | members |
| `payments` | treasurer/board/admin | treasurer/admin | treasurer/admin | treasurer/admin |
| `comms_channels` | members | board/admin | board/admin | board/admin |
| `comms_messages` | members | members (user_id=auth.uid()) | — | — |
| `integrations` | admin | admin | admin | admin |
| `activity_log` | members | members | — | — |
---
## MIGRATIONS APPLIED
| Script | Change |
|--------|--------|
| 001 | Full schema creation |
| 002 | Schema fixes + create_default_channels trigger (original — had bug) |
| 003 | Signup trigger |
| 004 | Admin helper functions |
| 005 | Additional fixes |
| 006 | Comprehensive RLS rewrite |
| 007 | Added task_status `done`; contacts `note`/`group_label`; unique on integrations(space_id,platform) and area_leads(space_id,area_code) |
| 008 | space_members.approved default=true; backfilled NULLs — fixed login loop |
| 009 | space_members INSERT RLS: allow admin/board inserts; added UNIQUE(space_id,email) |
| 010 | Fixed create_default_channels trigger — removed non-existent `description` column |
| 011 | Renamed tasks `type` column to `task_type` (canonical enum column name) |
| 012 | Canonical sync — task_type enum, cleaned duplicate columns |
| 013 | Added `space_members.last_paid_at` timestamptz — required by importMembers and linkPaymentToMember |
| 014-025 | See `docs/DATABASE_SCHEMA.md` migrations table (governance kernel, areas, forum/tiers/roles/invites, onboarding, permissions/ACLs, self-change hardening, incidents_insert re-assert) |
| 026 | `forms`, `form_submissions` + `forms.manage` permission (additive, default-deny RLS; submissions immutable + service-client-only) |
| 027 | `space_onboarding_steps.step_type` CHECK adds `'form'` (onboarding can embed a form/waiver; form id in step `config.form_id`) |
| 028 | Form slug unique PER SPACE (`UNIQUE(space_id, slug)`, drops global `UNIQUE(slug)`); public URL `/f/[space]/[slug]` |
| 029 | `space_invites.role` (member_role enum, default `member`); invites can grant a role; admin-granting invites are admin-only (app-enforced) |
| 030 | `certifications`, `member_certifications` + `certifications.manage` / `certifications.grant` permissions (the latter = Instructor). Additive default-deny RLS; grants soft-revoked + immutable (no DELETE policy); expiry snapshotted at grant; no anonymous path |
| 031 | `secrets_select` additively also honors `ops.secrets.read` (previously admin/board OR per-secret `ops_acl` only). Reveal/list gates let RLS decide; writes unchanged. Access-neutral unless the permission is granted |
---
## Tables added by migrations 016-030 (quick map)
| Table | Key columns | Purpose |
|-------|-------------|---------|
| `proposals` / `proposal_votes` | space_id, status, quorum_*, voting_*; member_id, position | Async governance voting |
| `incidents` / `incident_updates` | space_id, status, sla_*; visibility | Incident reports + log |
| `policies` | space_id, slug, version, status, body_* | Versioned policy library |
| `space_areas` | space_id, code, name, sort_order, is_archived | Per-space configurable areas |
| `forum_threads` | space_id, author_id, title, category, pinned, locked, comment_count | Forum |
| `comments` | space_id, entity_type, entity_id, author_id, parent_id, body | Polymorphic comments (forum/proposal/incident/policy) |
| `space_tiers` | space_id, slug, name, monthly_price_cents, billing_cadence, is_system | Custom membership tiers (`space_members.tier_id` FK) |
| `space_role_labels` | space_id, role, display_name, color | Rename/recolor built-in roles |
| `space_custom_roles` / `space_member_custom_roles` | space_id, slug, name, color / member_id, custom_role_id | Custom non-privileged roles + assignment |
| `space_invites` | space_id, code, label, expires_at, max_uses, uses_count, is_enabled, role | Multi-code invites; `role` (member_role, default member) is the role the invite grants on join |
| `space_onboarding_steps` | space_id, step_key, step_type, title, body, config, is_enabled, is_required | Configurable member onboarding |
| `space_role_permissions` | space_id, subject, permission | Role/custom-role permission grants |
| `ops_acl` | space_id, entity_type, entity_id, role | Per-item Ops access list (secret/kb/process/area_lead) |
| `forms` | space_id, slug (UNIQUE per space_id), kind (form/waiver), visibility (public_anon/public_auth/members), status (draft/published/closed), schema jsonb, legal_text, version | Custom forms + waivers. Write-gated by `forms.manage`; public page `/f/[space]/[slug]` served via service-client server action (no anon grant) |
| `form_submissions` | form_id, space_id, member_id, submitter_email, answers jsonb, form_snapshot jsonb, legal_text_snapshot, form_version, ip, user_agent | Append-only submissions. Per-row snapshot = immutable waiver record. SELECT = `forms.manage`; NO write policy (service-client only; immutable) |
| `certifications` | space_id, name (UNIQUE per space, case-insensitive), description, validity_months (null = never expires), is_active | Per-space certification types. SELECT = any space member; write-gated by `certifications.manage` |
| `member_certifications` | space_id, member_id, certification_id, granted_by, granted_at, expires_at (snapshotted at grant), revoked_at, revoked_by, revoked_reason, note | Per-member grants. Partial UNIQUE = one active grant per member+cert. SELECT = `certifications.manage`/`certifications.grant` (all) or member (own); INSERT/UPDATE = `certifications.grant`; NO DELETE policy (immutable history) |
Column additions: `space_members.tier_id`, `onboarding_completed_at`, `onboarding_progress`; `secrets.encrypted_value`, `encryption_version`; `knowledge_base.render_markdown`, `is_meeting_minutes`, `meeting_date`. New enum `comment_entity_type`.
Helper functions: `user_effective_roles(uid,sid)`, `user_has_permission(uid,sid,perm)` (both SECURITY DEFINER, search_path=public), plus governance quorum/tally/SLA functions.