Migration 030 adds certifications (per-space cert types, optional validity_months, is_active archive) and member_certifications (per-member grants; expires_at snapshotted at grant time; soft revoke; partial unique on one active grant per member+cert). RLS is additive and default-deny: cert types readable by any space member, write-gated by certifications.manage; grants readable by managers/granters (all) or the member (own), written by certifications.grant, with no DELETE policy so the history is immutable. No anonymous path. Two new permission codes in the catalog (group Certifications): certifications.manage and certifications.grant (the Instructor capability, assignable to any role via the existing additive model). Seeded to board and backfilled. Mirrored idempotently in schema.sql; types and schema docs updated.
19 KiB
DB Schema Map — Quick Reference
Last Updated: 2026-05-15
Full Documentation: See 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
/signupform →signUp()→ Supabase auth.signUp- After email confirm / immediate:
createSpace({ spaceName, spaceSlug, displayName })(admin client, bypasses RLS) - Inserts
spaces→ trigger fires → inserts 4 defaultcomms_channels - Inserts
space_membersas role=admin, tier=plus, status=active, approved=true - Redirect →
/dashboard
Sign Up → Join Space
/signupform →signUp()joinSpace({ inviteCode, displayName })(admin client)- Looks up
spaces.invite_code→ insertsspace_memberswith role=member, status=active(orpendingifrequire_approval=true), approved=true - Redirect →
/dashboard
Login
/login→signIn(email, password)→ Supabase auth- Middleware checks session cookie
- App layout RSC calls
getCurrentMembership()→ queriesspace_membersWHEREuser_id = auth.uid()ANDstatus IN ('active', 'pending') - 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:
tasksWHEREspace_id = ?ORDER BYcreated_at DESC - Create:
createTask()→ status=open, type=task|chore - Claim:
claimTask()→ status=claimed - Complete:
completeTask()→ status=completed(NOTdone) - 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_membersfor 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:
paymentsJOINspace_membersfor display_name - Log cash:
logCashPayment()→ platform=cash - Link:
linkPaymentToMember()→ updatesmember_id,link_status=linked, memberlast_paid_at - CSV:
importPaymentsCsv()
Ops / Knowledge Base
- List:
knowledge_baseordered byis_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_channelsWHEREspace_id = ? - Messages: client fetches
comms_messagesWHEREchannel_id = ?ORDER BYcreated_at - Realtime: Supabase channel subscription on
comms_messagesforchannel_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 |
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.