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>
This commit is contained in:
Jonathan Hvid 2026-05-11 14:39:07 +02:00
parent d054b56bf7
commit 53cb9a7e49
2 changed files with 128 additions and 0 deletions

3
KNOWN_ISSUES.md Normal file
View file

@ -0,0 +1,3 @@
# Known issues
- `src/pages/api/contributions/[id]/edit.ts` — both `POST` and `GET` handlers return `302 → /contribute` unconditionally. The route accepts the request but never edits anything; the linked "Edit" affordance on `/contribute` therefore does nothing within the 10-minute window. Out of scope for the council portal restructure; leave untouched.

View file

@ -0,0 +1,125 @@
-- 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);