project-bifrost-platform/migrations/0003_council_portal.sql
Jonathan Hvid 53cb9a7e49 feat(db): add migration 0003 for council portal schema
Adds title/cab_joined_date/slug to users, extends attendance with kind enum
and 'interested' status, and creates pulses, votes, roadmap_items,
roadmap_attributions, events, and activity tables.

Slug backfill covers the three seed users; new-user slug generation will
live in db.ts. roadmap_items has shipped_at to drive the council mark
(simpler than an audit table). roadmap_attributions is admin-curated only.

Also logs the pre-existing /api/contributions/[id]/edit 302-only bug in
KNOWN_ISSUES.md so it isn't lost; out of scope for this work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:39:07 +02:00

125 lines
6.6 KiB
SQL
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

-- Council portal restructure — Phase 1
-- Adds: title/cab_joined_date/slug to users; kind+interested status on attendance;
-- pulses, votes, roadmap_items, roadmap_attributions, events, activity tables.
-- ── users: new columns ─────────────────────────────────────────────
ALTER TABLE users ADD COLUMN title TEXT;
ALTER TABLE users ADD COLUMN cab_joined_date TEXT;
ALTER TABLE users ADD COLUMN slug TEXT;
-- Partial unique index (allows NULL while enforcing uniqueness on populated slugs)
CREATE UNIQUE INDEX idx_users_slug_unique ON users(slug) WHERE slug IS NOT NULL;
-- Backfill slugs from existing names (seed users have simple ASCII names).
-- New users get slugs generated in db.ts (kebab-case + dedupe).
UPDATE users
SET slug = LOWER(REPLACE(REPLACE(name, ' ', '-'), '.', ''))
WHERE slug IS NULL;
-- ── attendance: rebuild for kind + widened status ──────────────────
-- SQLite can't ALTER a CHECK constraint; rebuild is required.
CREATE TABLE attendance_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
meeting_slug TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'meeting' CHECK(kind IN ('meeting','event')),
status TEXT NOT NULL CHECK(status IN ('yes','no','interested')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(user_id, meeting_slug)
);
INSERT INTO attendance_new (id, user_id, meeting_slug, kind, status, updated_at)
SELECT id, user_id, meeting_slug, 'meeting', status, updated_at FROM attendance;
DROP TABLE attendance;
ALTER TABLE attendance_new RENAME TO attendance;
CREATE INDEX idx_attendance_meeting ON attendance(meeting_slug);
CREATE INDEX idx_attendance_kind ON attendance(kind);
-- ── pulses ─────────────────────────────────────────────────────────
CREATE TABLE pulses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
question TEXT NOT NULL,
context TEXT,
options TEXT NOT NULL, -- JSON array of strings (length 24)
opens_at TEXT NOT NULL,
closes_at TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft' CHECK(status IN ('draft','open','closed')),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
created_by INTEGER NOT NULL REFERENCES users(id)
);
CREATE INDEX idx_pulses_status ON pulses(status);
CREATE INDEX idx_pulses_dates ON pulses(opens_at, closes_at);
-- ── votes ──────────────────────────────────────────────────────────
CREATE TABLE votes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pulse_id INTEGER NOT NULL REFERENCES pulses(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
option_index INTEGER NOT NULL CHECK(option_index >= 0),
voted_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(pulse_id, user_id)
);
CREATE INDEX idx_votes_pulse ON votes(pulse_id);
CREATE INDEX idx_votes_user ON votes(user_id);
-- ── roadmap_items ──────────────────────────────────────────────────
CREATE TABLE roadmap_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'exploring' CHECK(status IN ('shipping','beta','exploring')),
target TEXT,
display_order INTEGER NOT NULL DEFAULT 0,
shipped_at TEXT, -- set when status first transitions to 'shipping'; never reset
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_roadmap_status ON roadmap_items(status, display_order);
CREATE INDEX idx_roadmap_shipped ON roadmap_items(shipped_at);
-- ── roadmap_attributions ───────────────────────────────────────────
-- Members who shaped this roadmap item. Set explicitly by admin when
-- creating/editing. NOT derived from votes, contributions, or any other signal.
-- Drives the council mark dot-lighting (a quarter lights iff this user has
-- an attribution to an item whose shipped_at falls in that quarter) and the
-- "you shaped this" line on /pulse and /roadmap.
CREATE TABLE roadmap_attributions (
roadmap_item_id INTEGER NOT NULL REFERENCES roadmap_items(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (roadmap_item_id, user_id)
);
CREATE INDEX idx_roadmap_attr_user ON roadmap_attributions(user_id);
-- ── events ─────────────────────────────────────────────────────────
CREATE TABLE events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'dinner' CHECK(kind IN ('dinner','office_hours','summit','virtual')),
description TEXT NOT NULL DEFAULT '',
location TEXT NOT NULL DEFAULT '',
starts_at TEXT NOT NULL,
ends_at TEXT,
capacity INTEGER,
photo_url TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
created_by INTEGER REFERENCES users(id)
);
CREATE INDEX idx_events_starts_at ON events(starts_at);
-- ── activity ───────────────────────────────────────────────────────
-- Drives the live ticker on /pulse. Written automatically by the action
-- it represents (vote cast, RSVP set, roadmap shipped, pulse opened) —
-- never by admin UI. Ticker query: kind != null AND created_at > now - 7d, LIMIT 12.
CREATE TABLE activity (
id INTEGER PRIMARY KEY AUTOINCREMENT,
actor_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK(kind IN ('voted','rsvped','booked_office_hours','roadmap_shipped','pulse_opened')),
subject_type TEXT NOT NULL,
subject_id INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_activity_recent ON activity(created_at);