From 53cb9a7e491db1f790dcb7a11846d52145175749 Mon Sep 17 00:00:00 2001 From: Jonathan Hvid Date: Mon, 11 May 2026 14:39:07 +0200 Subject: [PATCH] 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) --- KNOWN_ISSUES.md | 3 + migrations/0003_council_portal.sql | 125 +++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 KNOWN_ISSUES.md create mode 100644 migrations/0003_council_portal.sql diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md new file mode 100644 index 0000000..c0d38b8 --- /dev/null +++ b/KNOWN_ISSUES.md @@ -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. diff --git a/migrations/0003_council_portal.sql b/migrations/0003_council_portal.sql new file mode 100644 index 0000000..0ac95a0 --- /dev/null +++ b/migrations/0003_council_portal.sql @@ -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 2–4) + 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);