From 1735487ab93e9069c1705160cda469ffadd94bd1 Mon Sep 17 00:00:00 2001 From: Jonathan Hvid Date: Mon, 11 May 2026 14:41:49 +0200 Subject: [PATCH] feat(db): pulse/vote/roadmap/event/activity helpers + derivePulseStatus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds typed query functions for the council-portal entities. Pulse status is stored AND derived: draft and closed are sticky, open auto-decays to closed once now ≥ closes_at. Draft → open is an explicit admin Publish action, not date-driven, so admins can stage a pulse without surprise auto-publishing. Roadmap updateRoadmapItem stamps shipped_at the first time status transitions to 'shipping' and never resets it; returns { shippedNow } so callers can fire the roadmap_shipped activity row exactly once. Event RSVPs reuse the existing attendance table with kind='event'; no parallel Rsvp table. setEventRsvp upserts on UNIQUE(user_id, meeting_slug). getLitQuarters drives the CouncilMark dot pattern from roadmap_attributions × shipped_at — admin-curated, not derived from votes. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/db.ts | 514 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 514 insertions(+) diff --git a/src/lib/db.ts b/src/lib/db.ts index 0736b5d..015cf9e 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -395,3 +395,517 @@ export function getAllJoinRequests(): JoinRequest[] { ORDER BY jr.created_at DESC `).all() as JoinRequest[]; } + +// ── Date helpers ───────────────────────────────────────────────── + +/** SQLite stores 'YYYY-MM-DD HH:MM:SS' in UTC by project convention. */ +function parseSqlDate(s: string): Date { + if (/T.*[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s)) return new Date(s); + return new Date(s.replace(' ', 'T') + 'Z'); +} + +// ── Pulses ─────────────────────────────────────────────────────── + +export type PulseStatus = 'draft' | 'open' | 'closed'; + +export interface Pulse { + id: number; + question: string; + context: string | null; + options: string; // raw JSON + opens_at: string; + closes_at: string; + status: PulseStatus; // stored status + created_at: string; + created_by: number; +} + +export interface PulseRow { + id: number; + question: string; + context: string | null; + options: string[]; // parsed + opens_at: string; + closes_at: string; + stored_status: PulseStatus; + status: PulseStatus; // derived from stored + dates + created_at: string; + created_by: number; +} + +export interface PulseWithCounts extends PulseRow { + votes_total: number; + votes_by_option: number[]; + my_vote: number | null; +} + +/** + * derive the live status of a pulse from its stored status and dates. + * - draft → stays draft until admin publishes (independent of dates) + * - closed → stays closed + * - open → becomes closed once now ≥ closes_at + * Promoting draft → open is an explicit admin action (publishPulse), not date-driven. + */ +export function derivePulseStatus( + stored: PulseStatus, + closesAt: string, + now: Date = new Date() +): PulseStatus { + if (stored === 'draft' || stored === 'closed') return stored; + return now < parseSqlDate(closesAt) ? 'open' : 'closed'; +} + +function rowToPulse(row: Pulse): PulseRow { + const status = derivePulseStatus(row.status, row.closes_at); + return { + id: row.id, + question: row.question, + context: row.context, + options: JSON.parse(row.options) as string[], + opens_at: row.opens_at, + closes_at: row.closes_at, + stored_status: row.status, + status, + created_at: row.created_at, + created_by: row.created_by, + }; +} + +export function createPulse(data: { + question: string; + context: string | null; + options: string[]; + opens_at: string; + closes_at: string; + status?: PulseStatus; + created_by: number; +}): number { + if (data.options.length < 2 || data.options.length > 4) { + throw new Error('Pulse must have between 2 and 4 options'); + } + const r = db.prepare(` + INSERT INTO pulses (question, context, options, opens_at, closes_at, status, created_by) + VALUES (?,?,?,?,?,?,?) + `).run( + data.question, + data.context, + JSON.stringify(data.options), + data.opens_at, + data.closes_at, + data.status ?? 'draft', + data.created_by, + ); + return Number(r.lastInsertRowid); +} + +export function updatePulse(id: number, data: { + question: string; + context: string | null; + options: string[]; + opens_at: string; + closes_at: string; +}): void { + db.prepare(` + UPDATE pulses SET question = ?, context = ?, options = ?, opens_at = ?, closes_at = ? + WHERE id = ? + `).run(data.question, data.context, JSON.stringify(data.options), data.opens_at, data.closes_at, id); +} + +export function publishPulse(id: number): void { + db.prepare("UPDATE pulses SET status = 'open' WHERE id = ? AND status = 'draft'").run(id); +} + +export function closePulse(id: number): void { + db.prepare("UPDATE pulses SET status = 'closed' WHERE id = ?").run(id); +} + +export function deletePulse(id: number): void { + db.prepare('DELETE FROM pulses WHERE id = ?').run(id); +} + +export function getPulseById(id: number): PulseRow | null { + const row = db.prepare('SELECT * FROM pulses WHERE id = ?').get(id) as Pulse | undefined; + return row ? rowToPulse(row) : null; +} + +export function getAllPulses(): PulseRow[] { + const rows = db.prepare('SELECT * FROM pulses ORDER BY created_at DESC').all() as Pulse[]; + return rows.map(rowToPulse); +} + +/** The current open pulse, if any. Returns null if none is open right now. */ +export function getOpenPulse(): PulseRow | null { + const rows = db.prepare( + "SELECT * FROM pulses WHERE status = 'open' ORDER BY closes_at ASC LIMIT 1" + ).all() as Pulse[]; + for (const row of rows) { + const live = rowToPulse(row); + if (live.status === 'open') return live; + } + return null; +} + +export function getPulseWithCounts(id: number, userId: number): PulseWithCounts | null { + const pulse = getPulseById(id); + if (!pulse) return null; + const counts = db.prepare( + 'SELECT option_index, COUNT(*) AS n FROM votes WHERE pulse_id = ? GROUP BY option_index' + ).all(id) as { option_index: number; n: number }[]; + const byOption = pulse.options.map((_, i) => counts.find(c => c.option_index === i)?.n ?? 0); + const myVoteRow = db.prepare( + 'SELECT option_index FROM votes WHERE pulse_id = ? AND user_id = ?' + ).get(id, userId) as { option_index: number } | undefined; + return { + ...pulse, + votes_total: byOption.reduce((a, b) => a + b, 0), + votes_by_option: byOption, + my_vote: myVoteRow?.option_index ?? null, + }; +} + +// ── Votes ──────────────────────────────────────────────────────── + +export function castVote(pulseId: number, userId: number, optionIndex: number): void { + // UNIQUE(pulse_id, user_id) enforces one-per-member. INSERT OR IGNORE keeps + // the first vote and silently no-ops on re-attempts; callers should read back. + db.prepare( + 'INSERT OR IGNORE INTO votes (pulse_id, user_id, option_index) VALUES (?,?,?)' + ).run(pulseId, userId, optionIndex); +} + +export function getUserVote(pulseId: number, userId: number): number | null { + const r = db.prepare( + 'SELECT option_index FROM votes WHERE pulse_id = ? AND user_id = ?' + ).get(pulseId, userId) as { option_index: number } | undefined; + return r?.option_index ?? null; +} + +export function countPulseParticipants(pulseId: number): number { + const r = db.prepare( + 'SELECT COUNT(*) AS n FROM votes WHERE pulse_id = ?' + ).get(pulseId) as { n: number }; + return r.n; +} + +// ── Roadmap items ──────────────────────────────────────────────── + +export type RoadmapStatus = 'shipping' | 'beta' | 'exploring'; + +export interface RoadmapItem { + id: number; + title: string; + description: string; + status: RoadmapStatus; + target: string | null; + display_order: number; + shipped_at: string | null; + created_at: string; + updated_at: string; +} + +export interface RoadmapItemWithAttribution extends RoadmapItem { + attributed: { id: number; name: string; slug: string | null }[]; +} + +export function createRoadmapItem(data: { + title: string; + description: string; + status: RoadmapStatus; + target?: string | null; + display_order?: number; +}): number { + const shipped_at = data.status === 'shipping' ? new Date().toISOString().slice(0, 19).replace('T', ' ') : null; + const r = db.prepare(` + INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at) + VALUES (?,?,?,?,?,?) + `).run( + data.title, + data.description, + data.status, + data.target ?? null, + data.display_order ?? 0, + shipped_at, + ); + return Number(r.lastInsertRowid); +} + +/** + * Update a roadmap item. If status transitions to 'shipping' for the first + * time (shipped_at IS NULL), stamps shipped_at = now. Never resets shipped_at. + */ +export function updateRoadmapItem(id: number, data: { + title: string; + description: string; + status: RoadmapStatus; + target: string | null; + display_order: number; +}): { shippedNow: boolean } { + const current = db.prepare('SELECT status, shipped_at FROM roadmap_items WHERE id = ?') + .get(id) as { status: RoadmapStatus; shipped_at: string | null } | undefined; + if (!current) throw new Error(`Roadmap item ${id} not found`); + + const shippedNow = data.status === 'shipping' && current.shipped_at === null; + const shipped_at = shippedNow + ? new Date().toISOString().slice(0, 19).replace('T', ' ') + : current.shipped_at; + + db.prepare(` + UPDATE roadmap_items + SET title = ?, description = ?, status = ?, target = ?, display_order = ?, + shipped_at = ?, updated_at = datetime('now') + WHERE id = ? + `).run(data.title, data.description, data.status, data.target, data.display_order, shipped_at, id); + + return { shippedNow }; +} + +export function deleteRoadmapItem(id: number): void { + db.prepare('DELETE FROM roadmap_items WHERE id = ?').run(id); +} + +export function getRoadmapItem(id: number): RoadmapItemWithAttribution | null { + const item = db.prepare('SELECT * FROM roadmap_items WHERE id = ?').get(id) as RoadmapItem | undefined; + if (!item) return null; + const attributed = db.prepare(` + SELECT u.id, u.name, u.slug FROM roadmap_attributions ra + JOIN users u ON u.id = ra.user_id + WHERE ra.roadmap_item_id = ? + ORDER BY u.name + `).all(id) as { id: number; name: string; slug: string | null }[]; + return { ...item, attributed }; +} + +export function getAllRoadmapItems(): RoadmapItemWithAttribution[] { + const items = db.prepare( + 'SELECT * FROM roadmap_items ORDER BY status, display_order, created_at' + ).all() as RoadmapItem[]; + const attribs = db.prepare(` + SELECT ra.roadmap_item_id, u.id, u.name, u.slug + FROM roadmap_attributions ra + JOIN users u ON u.id = ra.user_id + ORDER BY u.name + `).all() as { roadmap_item_id: number; id: number; name: string; slug: string | null }[]; + const byItem = new Map(); + for (const a of attribs) { + const list = byItem.get(a.roadmap_item_id) ?? []; + list.push({ id: a.id, name: a.name, slug: a.slug }); + byItem.set(a.roadmap_item_id, list); + } + return items.map(i => ({ ...i, attributed: byItem.get(i.id) ?? [] })); +} + +export function setRoadmapAttributions(itemId: number, userIds: number[]): void { + const tx = db.transaction(() => { + db.prepare('DELETE FROM roadmap_attributions WHERE roadmap_item_id = ?').run(itemId); + const ins = db.prepare('INSERT INTO roadmap_attributions (roadmap_item_id, user_id) VALUES (?,?)'); + for (const uid of userIds) ins.run(itemId, uid); + }); + tx(); +} + +// ── Events ─────────────────────────────────────────────────────── + +export type EventKind = 'dinner' | 'office_hours' | 'summit' | 'virtual'; + +export interface Event { + id: number; + slug: string; + title: string; + kind: EventKind; + description: string; + location: string; + starts_at: string; + ends_at: string | null; + capacity: number | null; + photo_url: string | null; + created_at: string; + created_by: number | null; +} + +export function createEvent(data: { + slug: string; + title: string; + kind: EventKind; + description: string; + location: string; + starts_at: string; + ends_at: string | null; + capacity: number | null; + photo_url: string | null; + created_by: number; +}): number { + const r = db.prepare(` + INSERT INTO events (slug, title, kind, description, location, starts_at, ends_at, capacity, photo_url, created_by) + VALUES (?,?,?,?,?,?,?,?,?,?) + `).run( + data.slug, data.title, data.kind, data.description, data.location, + data.starts_at, data.ends_at, data.capacity, data.photo_url, data.created_by, + ); + return Number(r.lastInsertRowid); +} + +export function updateEvent(id: number, data: { + title: string; + kind: EventKind; + description: string; + location: string; + starts_at: string; + ends_at: string | null; + capacity: number | null; + photo_url: string | null; +}): void { + db.prepare(` + UPDATE events SET title = ?, kind = ?, description = ?, location = ?, + starts_at = ?, ends_at = ?, capacity = ?, photo_url = ? + WHERE id = ? + `).run( + data.title, data.kind, data.description, data.location, + data.starts_at, data.ends_at, data.capacity, data.photo_url, id, + ); +} + +export function deleteEvent(id: number): void { + db.prepare('DELETE FROM events WHERE id = ?').run(id); +} + +export function getEventById(id: number): Event | null { + return db.prepare('SELECT * FROM events WHERE id = ?').get(id) as Event | null; +} + +export function getEventBySlug(slug: string): Event | null { + return db.prepare('SELECT * FROM events WHERE slug = ?').get(slug) as Event | null; +} + +export function getAllEvents(): Event[] { + return db.prepare('SELECT * FROM events ORDER BY starts_at DESC').all() as Event[]; +} + +export function getUpcomingEvents(limit = 10): Event[] { + return db.prepare( + "SELECT * FROM events WHERE starts_at >= datetime('now') ORDER BY starts_at ASC LIMIT ?" + ).all(limit) as Event[]; +} + +export function getPastEvents(limit = 50): Event[] { + return db.prepare( + "SELECT * FROM events WHERE starts_at < datetime('now') ORDER BY starts_at DESC LIMIT ?" + ).all(limit) as Event[]; +} + +export function getEventRsvpCount(slug: string): { going: number; interested: number; declined: number } { + const rows = db.prepare( + "SELECT status, COUNT(*) AS n FROM attendance WHERE meeting_slug = ? AND kind = 'event' GROUP BY status" + ).all(slug) as { status: string; n: number }[]; + return { + going: rows.find(r => r.status === 'yes')?.n ?? 0, + interested: rows.find(r => r.status === 'interested')?.n ?? 0, + declined: rows.find(r => r.status === 'no')?.n ?? 0, + }; +} + +export function setEventRsvp(userId: number, eventSlug: string, status: 'yes' | 'no' | 'interested'): void { + db.prepare(` + INSERT INTO attendance (user_id, meeting_slug, kind, status, updated_at) + VALUES (?, ?, 'event', ?, datetime('now')) + ON CONFLICT(user_id, meeting_slug) DO UPDATE SET status = excluded.status, updated_at = excluded.updated_at + `).run(userId, eventSlug, status); +} + +export function getUserRsvp(userId: number, eventSlug: string): 'yes' | 'no' | 'interested' | null { + const r = db.prepare( + "SELECT status FROM attendance WHERE user_id = ? AND meeting_slug = ? AND kind = 'event'" + ).get(userId, eventSlug) as { status: 'yes' | 'no' | 'interested' } | undefined; + return r?.status ?? null; +} + +// ── Activity (ticker feed) ─────────────────────────────────────── + +export type ActivityKind = 'voted' | 'rsvped' | 'booked_office_hours' | 'roadmap_shipped' | 'pulse_opened'; + +export interface Activity { + id: number; + actor_id: number; + kind: ActivityKind; + subject_type: string; + subject_id: number; + created_at: string; +} + +export interface ActivityRow extends Activity { + actor_name: string; + actor_role: Role; +} + +export function recordActivity( + actorId: number, + kind: ActivityKind, + subjectType: string, + subjectId: number, +): void { + db.prepare( + 'INSERT INTO activity (actor_id, kind, subject_type, subject_id) VALUES (?,?,?,?)' + ).run(actorId, kind, subjectType, subjectId); +} + +export function getRecentActivity(opts: { limit?: number; sinceDays?: number } = {}): ActivityRow[] { + const limit = opts.limit ?? 12; + const sinceDays = opts.sinceDays ?? 7; + return db.prepare(` + SELECT a.*, u.name AS actor_name, u.role AS actor_role + FROM activity a JOIN users u ON u.id = a.actor_id + WHERE a.created_at >= datetime('now', ?) + ORDER BY a.created_at DESC + LIMIT ? + `).all(`-${sinceDays} days`, limit) as ActivityRow[]; +} + +export function getAllActivityForAdmin(limit = 200): ActivityRow[] { + return db.prepare(` + SELECT a.*, u.name AS actor_name, u.role AS actor_role + FROM activity a JOIN users u ON u.id = a.actor_id + ORDER BY a.created_at DESC + LIMIT ? + `).all(limit) as ActivityRow[]; +} + +// ── Council mark data ──────────────────────────────────────────── + +/** + * Returns a set of quarter indices (0 = current quarter, 1 = previous, …, up to 11) + * in which the user has at least one roadmap_attribution to an item whose + * shipped_at falls in that quarter. Drives CouncilMark dot-lighting. + * + * Note: pulse votes do NOT light a quarter — the sigil represents consequential + * contribution, not participation. See council-mark spec. + */ +export function getLitQuarters(userId: number, anchor: Date = new Date()): Set { + const rows = db.prepare(` + SELECT ri.shipped_at + FROM roadmap_attributions ra + JOIN roadmap_items ri ON ri.id = ra.roadmap_item_id + WHERE ra.user_id = ? AND ri.shipped_at IS NOT NULL + `).all(userId) as { shipped_at: string }[]; + + const out = new Set(); + const anchorQ = Math.floor(anchor.getUTCMonth() / 3); + const anchorYear = anchor.getUTCFullYear(); + + for (const row of rows) { + const shipped = parseSqlDate(row.shipped_at); + const sQ = Math.floor(shipped.getUTCMonth() / 3); + const sYear = shipped.getUTCFullYear(); + const back = (anchorYear - sYear) * 4 + (anchorQ - sQ); + if (back >= 0 && back < 12) out.add(back); + } + return out; +} + +export function countShippedAttributions(userId: number): number { + const r = db.prepare(` + SELECT COUNT(*) AS n + FROM roadmap_attributions ra + JOIN roadmap_items ri ON ri.id = ra.roadmap_item_id + WHERE ra.user_id = ? AND ri.shipped_at IS NOT NULL + `).get(userId) as { n: number }; + return r.n; +}