feat(db): pulse/vote/roadmap/event/activity helpers + derivePulseStatus

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) <noreply@anthropic.com>
This commit is contained in:
Jonathan Hvid 2026-05-11 14:41:49 +02:00
parent 56992ed4ca
commit 1735487ab9

View file

@ -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<number, { id: number; name: string; slug: string | null }[]>();
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<number> {
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<number>();
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;
}