import Database from 'better-sqlite3'; import { join } from 'path'; // ── Types ──────────────────────────────────────────────────────── export type Role = 'pilot' | 'cab' | 'fenja'; export type ContributionType = 'idea' | 'inspiration' | 'question'; export type AttendanceStatus = 'yes' | 'no'; export interface User { id: number; email: string; password_hash: string; name: string; organisation: string; role: Role; bio: string; created_at: string; last_seen_at: string | null; active: number; title: string | null; // job title, e.g. 'VP Engineering' cab_joined_date: string | null; // ISO date; only set for role='cab'. Falls back to created_at when null. slug: string | null; // URL handle, kebab-case, unique. Auto-generated on createUser. pull_quote: string | null; // one sentence in the member's voice, rendered on /members member_number: number | null; // sequential, never reused. Allocated on role→cab transition. focus_tags: string | null; // JSON array of up to 3 strings (24 chars max each) } export type UserPublic = Omit; export interface Invite { id: number; token_hash: string; email: string; name: string; organisation: string; role: Role; expires_at: string; used_at: string | null; created_at: string; created_by_user_id: number | null; } export interface Contribution { id: number; user_id: number; type: ContributionType; body_md: string; created_at: string; edited_at: string | null; hidden_at: string | null; } export interface ContributionRow extends Contribution { author_name: string; author_organisation: string; author_role: Role; reaction_count: number; } export interface Reply { id: number; contribution_id: number; user_id: number; body_md: string; created_at: string; author_name: string; author_role: Role; } export interface AttendanceSummary { yes: number; no: number; } // ── Connection ─────────────────────────────────────────────────── // Persist across Vite HMR reloads in dev. Override BIFROST_DB_PATH for tests // (use ':memory:'); defaults to bifrost.db in the project root. const DB_PATH = process.env.BIFROST_DB_PATH ?? join(process.cwd(), 'bifrost.db'); const g = globalThis as typeof globalThis & { __bifrost_db?: Database.Database }; if (!g.__bifrost_db) { g.__bifrost_db = new Database(DB_PATH); g.__bifrost_db.pragma('journal_mode = WAL'); g.__bifrost_db.pragma('foreign_keys = ON'); } const db = g.__bifrost_db; // ── Users ──────────────────────────────────────────────────────── export function getUserByEmail(email: string): User | null { return db.prepare( 'SELECT * FROM users WHERE email = ? AND active = 1' ).get(email) as User | null; } export function getUserById(id: number): User | null { return db.prepare( 'SELECT * FROM users WHERE id = ? AND active = 1' ).get(id) as User | null; } export function getUserPublicById(id: number): UserPublic | null { return db.prepare( 'SELECT id,email,name,organisation,role,bio,created_at,last_seen_at,active,title,cab_joined_date,slug,pull_quote,member_number,focus_tags FROM users WHERE id = ? AND active = 1' ).get(id) as UserPublic | null; } export function createUser(data: { email: string; password_hash: string; name: string; organisation: string; role: Role; }): number { const slug = generateUniqueSlug(data.name); const cabJoined = data.role === 'cab' ? new Date().toISOString().slice(0, 10) : null; const tx = db.transaction(() => { const r = db.prepare( 'INSERT INTO users (email,password_hash,name,organisation,role,slug,cab_joined_date) VALUES (?,?,?,?,?,?,?)' ).run(data.email, data.password_hash, data.name, data.organisation, data.role, slug, cabJoined); const id = Number(r.lastInsertRowid); if (data.role === 'cab') allocateMemberNumber(id); return id; }); return tx(); } /** * Reserve the next free member_number for a user. Strictly sequential, * never reused — even if a number-holder is deactivated and later replaced, * the new member gets MAX(member_number) + 1, not the retired number. * Idempotent: returns the existing number if already allocated. */ export function allocateMemberNumber(userId: number): number { return db.transaction(() => { const existing = db.prepare('SELECT member_number FROM users WHERE id = ?').get(userId) as { member_number: number | null } | undefined; if (existing?.member_number != null) return existing.member_number; const max = db.prepare('SELECT MAX(member_number) AS m FROM users').get() as { m: number | null }; const next = (max.m ?? 0) + 1; db.prepare('UPDATE users SET member_number = ? WHERE id = ?').run(next, userId); return next; })(); } export function updateUserLastSeen(id: number): void { db.prepare("UPDATE users SET last_seen_at = datetime('now') WHERE id = ?").run(id); } export function updateUserProfile(id: number, name: string, bio: string): void { db.prepare('UPDATE users SET name = ?, bio = ? WHERE id = ?').run(name, bio, id); } /** Update a user's email — their login identity. Throws if the address is * already used by another account (the column is UNIQUE). */ export function updateUserEmail(id: number, email: string): void { const clash = db.prepare('SELECT id FROM users WHERE email = ? AND id != ?') .get(email, id) as { id: number } | undefined; if (clash) throw new Error('That email is already in use by another account.'); db.prepare('UPDATE users SET email = ? WHERE id = ?').run(email, id); } /** Set a user's password hash (self-service change or admin reset). */ export function updateUserPassword(id: number, passwordHash: string): void { db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(passwordHash, id); } /** Returns the newly-allocated member_number when the transition lands on * cab and the user had none; null otherwise. Callers may ignore. */ export function updateUserRole(id: number, role: Role): { allocated: number | null } { return db.transaction(() => { const current = db.prepare('SELECT role, member_number FROM users WHERE id = ?') .get(id) as { role: Role; member_number: number | null } | undefined; if (!current) return { allocated: null }; db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, id); if (role === 'cab' && current.member_number === null) { return { allocated: allocateMemberNumber(id) }; } return { allocated: null }; })(); } export function deactivateUser(id: number): void { db.prepare('UPDATE users SET active = 0 WHERE id = ?').run(id); } export function getAllUsersPublic(): UserPublic[] { return db.prepare( 'SELECT id,email,name,organisation,role,bio,created_at,last_seen_at,active,title,cab_joined_date,slug,pull_quote,member_number,focus_tags FROM users ORDER BY organisation,name' ).all() as UserPublic[]; } export function getUserBySlug(slug: string): UserPublic | null { return db.prepare( 'SELECT id,email,name,organisation,role,bio,created_at,last_seen_at,active,title,cab_joined_date,slug,pull_quote,member_number,focus_tags FROM users WHERE slug = ? AND active = 1' ).get(slug) as UserPublic | null; } /** All cab users, ordered by member_number asc (with NULLs at the end as a defensive fallback). */ export function getAllCabMembers(): UserPublic[] { return db.prepare(` SELECT id,email,name,organisation,role,bio,created_at,last_seen_at,active,title,cab_joined_date,slug,pull_quote,member_number,focus_tags FROM users WHERE role = 'cab' AND active = 1 ORDER BY (member_number IS NULL), member_number ASC, id ASC `).all() as UserPublic[]; } export function countCabMembers(): number { const r = db.prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'cab' AND active = 1").get() as { n: number }; return r.n; } /** Update admin-editable profile fields (title, pull_quote, focus_tags). focus_tags is a parsed array. */ export function updateUserAdminFields(id: number, data: { title: string | null; pull_quote: string | null; focus_tags: string[]; }): void { db.prepare('UPDATE users SET title = ?, pull_quote = ?, focus_tags = ? WHERE id = ?') .run(data.title, data.pull_quote, JSON.stringify(data.focus_tags), id); } // ── Slugs ──────────────────────────────────────────────────────── export function slugifyName(name: string): string { return name .toLowerCase() .replace(/ø/g, 'o').replace(/æ/g, 'ae').replace(/å/g, 'a') // Danish letters NFKD leaves intact .normalize('NFKD').replace(/[̀-ͯ]/g, '') // strip remaining diacritics .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, ''); } /** Returns a slug guaranteed not to collide with any existing user. */ export function generateUniqueSlug(name: string): string { const base = slugifyName(name) || 'member'; let candidate = base; let n = 1; while (db.prepare('SELECT 1 FROM users WHERE slug = ?').get(candidate)) { n += 1; candidate = `${base}-${n}`; } return candidate; } // ── Sessions ───────────────────────────────────────────────────── export function createDbSession(id: string, userId: number, expiresAt: string): void { db.prepare( 'INSERT INTO sessions (id,user_id,expires_at) VALUES (?,?,?)' ).run(id, userId, expiresAt); } export function getDbSession(id: string): { user_id: number; expires_at: string } | null { return db.prepare( "SELECT user_id,expires_at FROM sessions WHERE id = ? AND expires_at > datetime('now')" ).get(id) as { user_id: number; expires_at: string } | null; } export function deleteDbSession(id: string): void { db.prepare('DELETE FROM sessions WHERE id = ?').run(id); } // ── Invites ────────────────────────────────────────────────────── export function createInvite(data: { token_hash: string; email: string; name: string; organisation: string; role: Role; expires_at: string; created_by_user_id: number; }): number { const r = db.prepare(` INSERT INTO invites (token_hash,email,name,organisation,role,expires_at,created_by_user_id) VALUES (?,?,?,?,?,?,?) `).run(data.token_hash, data.email, data.name, data.organisation, data.role, data.expires_at, data.created_by_user_id); return Number(r.lastInsertRowid); } export function getInviteByTokenHash(tokenHash: string): Invite | null { return db.prepare( 'SELECT * FROM invites WHERE token_hash = ?' ).get(tokenHash) as Invite | null; } export function redeemInvite(inviteId: number): void { db.prepare("UPDATE invites SET used_at = datetime('now') WHERE id = ?").run(inviteId); } export function createUserFromInvite(invite: Invite, passwordHash: string): UserPublic { const id = createUser({ email: invite.email, password_hash: passwordHash, name: invite.name, organisation: invite.organisation, role: invite.role, }); return getUserPublicById(id) as UserPublic; } export function markInviteUsed(id: number): void { db.prepare("UPDATE invites SET used_at = datetime('now') WHERE id = ?").run(id); } export function revokeInvite(id: number): void { db.prepare("UPDATE invites SET expires_at = datetime('now') WHERE id = ? AND used_at IS NULL").run(id); } export function getAllInvites(): (Invite & { creator_name: string | null })[] { return db.prepare(` SELECT i.*, u.name AS creator_name FROM invites i LEFT JOIN users u ON u.id = i.created_by_user_id ORDER BY i.created_at DESC `).all() as (Invite & { creator_name: string | null })[]; } export function getInviteById(id: number): (Invite & { creator_name: string | null }) | null { return db.prepare(` SELECT i.*, u.name AS creator_name FROM invites i LEFT JOIN users u ON u.id = i.created_by_user_id WHERE i.id = ? `).get(id) as (Invite & { creator_name: string | null }) | null; } // ── Contributions ──────────────────────────────────────────────── export function createContribution(data: { user_id: number; type: ContributionType; body_md: string; }): number { const r = db.prepare( 'INSERT INTO contributions (user_id,type,body_md) VALUES (?,?,?)' ).run(data.user_id, data.type, data.body_md); return Number(r.lastInsertRowid); } export function getContributions(opts: { type?: ContributionType; sort?: 'newest' | 'top'; } = {}): ContributionRow[] { const typeClause = opts.type ? `AND c.type = '${opts.type}'` : ''; const orderClause = opts.sort === 'top' ? 'ORDER BY reaction_count DESC, c.created_at DESC' : 'ORDER BY c.created_at DESC'; return db.prepare(` SELECT c.*, u.name AS author_name, u.organisation AS author_organisation, u.role AS author_role, (SELECT COUNT(*) FROM reactions r WHERE r.contribution_id = c.id) AS reaction_count FROM contributions c JOIN users u ON u.id = c.user_id WHERE c.hidden_at IS NULL ${typeClause} ${orderClause} `).all() as ContributionRow[]; } export function getContributionById(id: number): Contribution | null { return db.prepare('SELECT * FROM contributions WHERE id = ?').get(id) as Contribution | null; } export function updateContribution(id: number, body_md: string): void { db.prepare("UPDATE contributions SET body_md = ?, edited_at = datetime('now') WHERE id = ?") .run(body_md, id); } export function hideContribution(id: number): void { db.prepare("UPDATE contributions SET hidden_at = datetime('now') WHERE id = ?").run(id); } // ── Reactions ──────────────────────────────────────────────────── export function addReaction(userId: number, contributionId: number): void { db.prepare('INSERT OR IGNORE INTO reactions (user_id,contribution_id) VALUES (?,?)').run(userId, contributionId); } export function removeReaction(userId: number, contributionId: number): void { db.prepare('DELETE FROM reactions WHERE user_id = ? AND contribution_id = ?').run(userId, contributionId); } export function hasReaction(userId: number, contributionId: number): boolean { const r = db.prepare('SELECT id FROM reactions WHERE user_id = ? AND contribution_id = ?').get(userId, contributionId); return r !== undefined; } export function getReactedIds(userId: number): Set { const rows = db.prepare('SELECT contribution_id FROM reactions WHERE user_id = ?').all(userId) as { contribution_id: number }[]; return new Set(rows.map(r => r.contribution_id)); } // ── Replies ────────────────────────────────────────────────────── export function createReply(data: { contribution_id: number; user_id: number; body_md: string; }): void { db.prepare('INSERT INTO replies (contribution_id,user_id,body_md) VALUES (?,?,?)') .run(data.contribution_id, data.user_id, data.body_md); } export function getReplies(contributionId: number): Reply[] { return db.prepare(` SELECT re.*,u.name AS author_name,u.role AS author_role FROM replies re JOIN users u ON u.id = re.user_id WHERE re.contribution_id = ? ORDER BY re.created_at ASC `).all(contributionId) as Reply[]; } // ── Attendance ─────────────────────────────────────────────────── export function setAttendance(userId: number, meetingSlug: string, status: AttendanceStatus): void { db.prepare(` INSERT INTO attendance (user_id,meeting_slug,status,updated_at) VALUES (?,?,?,datetime('now')) ON CONFLICT(user_id,meeting_slug) DO UPDATE SET status=excluded.status, updated_at=excluded.updated_at `).run(userId, meetingSlug, status); } export function getAttendanceSummary(meetingSlug: string): AttendanceSummary { const rows = db.prepare( "SELECT status, COUNT(*) AS n FROM attendance WHERE meeting_slug = ? GROUP BY status" ).all(meetingSlug) as { status: string; n: number }[]; const yes = rows.find(r => r.status === 'yes')?.n ?? 0; const no = rows.find(r => r.status === 'no')?.n ?? 0; return { yes, no }; } export function getUserAttendance(userId: number, meetingSlug: string): AttendanceStatus | null { const r = db.prepare( 'SELECT status FROM attendance WHERE user_id = ? AND meeting_slug = ?' ).get(userId, meetingSlug) as { status: AttendanceStatus } | undefined; return r?.status ?? null; } export function getAllAttendance(meetingSlug: string): { user_id: number; status: AttendanceStatus; name: string }[] { return db.prepare(` SELECT a.user_id, a.status, u.name FROM attendance a JOIN users u ON u.id = a.user_id WHERE a.meeting_slug = ? `).all(meetingSlug) as { user_id: number; status: AttendanceStatus; name: string }[]; } // ── Join requests ──────────────────────────────────────────────── export function createJoinRequest(userId: number): void { db.prepare('INSERT OR IGNORE INTO join_requests (user_id) VALUES (?)').run(userId); } export function hasJoinRequest(userId: number): boolean { const r = db.prepare('SELECT id FROM join_requests WHERE user_id = ?').get(userId); return r !== undefined; } export interface JoinRequest { id: number; user_id: number; created_at: string; user_name: string; user_email: string; user_organisation: string; } export function getAllJoinRequests(): JoinRequest[] { return db.prepare(` SELECT jr.id, jr.user_id, jr.created_at, u.name AS user_name, u.email AS user_email, u.organisation AS user_organisation FROM join_requests jr JOIN users u ON u.id = jr.user_id ORDER BY jr.created_at DESC `).all() as JoinRequest[]; } export function getJoinRequestById(id: number): JoinRequest | null { return db.prepare(` SELECT jr.id, jr.user_id, jr.created_at, u.name AS user_name, u.email AS user_email, u.organisation AS user_organisation FROM join_requests jr JOIN users u ON u.id = jr.user_id WHERE jr.id = ? `).get(id) as JoinRequest | null; } export function deleteJoinRequest(id: number): void { db.prepare('DELETE FROM join_requests WHERE id = ?').run(id); } // ── 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); } /** UPSERT — first vote inserts, subsequent ones update option_index + voted_at. * Use this when the UI allows members to change their pick while the pulse is * still open. Returns true if this was a brand-new vote (so callers can * record activity once), false if it changed an existing vote. */ export function castOrChangeVote(pulseId: number, userId: number, optionIndex: number): boolean { const existing = getUserVote(pulseId, userId); db.prepare(` INSERT INTO votes (pulse_id, user_id, option_index, voted_at) VALUES (?, ?, ?, datetime('now')) ON CONFLICT(pulse_id, user_id) DO UPDATE SET option_index = excluded.option_index, voted_at = excluded.voted_at `).run(pulseId, userId, optionIndex); return existing === null; } 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' | 'in_beta' | 'planned' | 'exploring' | 'considering'; export interface RoadmapItem { id: number; title: string; description: string; status: RoadmapStatus; target: string | null; display_order: number; shipped_at: string | null; metadata_text: string | null; // short narrative cue shown on hover in /roadmap features: string[]; // bullet-point capabilities, rendered as a plus-icon list created_at: string; updated_at: string; } export interface RoadmapItemWithAttribution extends RoadmapItem { attributed: { id: number; name: string; slug: string | null }[]; } /** The `features` column is stored as a JSON array of strings (or NULL). */ type RoadmapItemRow = Omit & { features: string | null }; function parseFeatures(raw: string | null): string[] { if (!raw || raw.trim() === '') return []; try { const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === 'string') : []; } catch { return []; } } function rowToRoadmapItem(row: RoadmapItemRow): RoadmapItem { return { ...row, features: parseFeatures(row.features) }; } export function createRoadmapItem(data: { title: string; description: string; status: RoadmapStatus; target?: string | null; display_order?: number; metadata_text?: string | null; features?: string[]; }): number { const shipped_at = data.status === 'shipping' ? new Date().toISOString().slice(0, 19).replace('T', ' ') : null; const requestedOrder = data.display_order ?? 0; const features = JSON.stringify((data.features ?? []).filter((f) => f.trim() !== '')); return db.transaction(() => { // Cascade: insert at position N shifts every existing item at or after N // down by one, keeping the order dense. db.prepare( 'UPDATE roadmap_items SET display_order = display_order + 1 WHERE display_order >= ?' ).run(requestedOrder); const r = db.prepare(` INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at, metadata_text, features) VALUES (?,?,?,?,?,?,?,?) `).run( data.title, data.description, data.status, data.target ?? null, requestedOrder, shipped_at, data.metadata_text ?? null, features, ); 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; metadata_text?: string | null; features?: string[]; }): { shippedNow: boolean } { const current = db.prepare('SELECT status, shipped_at, display_order FROM roadmap_items WHERE id = ?') .get(id) as { status: RoadmapStatus; shipped_at: string | null; display_order: number } | 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; return db.transaction(() => { // Cascade neighbours when display_order changes. // Moving forward (A → B, B > A): rows in (A, B] shift down by 1. // Moving back (A → B, B < A): rows in [B, A) shift up by 1. const from = current.display_order; const to = data.display_order; if (to > from) { db.prepare( 'UPDATE roadmap_items SET display_order = display_order - 1 WHERE id != ? AND display_order > ? AND display_order <= ?' ).run(id, from, to); } else if (to < from) { db.prepare( 'UPDATE roadmap_items SET display_order = display_order + 1 WHERE id != ? AND display_order >= ? AND display_order < ?' ).run(id, to, from); } const features = JSON.stringify((data.features ?? []).filter((f) => f.trim() !== '')); db.prepare(` UPDATE roadmap_items SET title = ?, description = ?, status = ?, target = ?, display_order = ?, shipped_at = ?, metadata_text = ?, features = ?, updated_at = datetime('now') WHERE id = ? `).run(data.title, data.description, data.status, data.target, data.display_order, shipped_at, data.metadata_text ?? null, features, id); return { shippedNow }; })(); } export function deleteRoadmapItem(id: number): void { db.transaction(() => { const row = db.prepare('SELECT display_order FROM roadmap_items WHERE id = ?') .get(id) as { display_order: number } | undefined; db.prepare('DELETE FROM roadmap_items WHERE id = ?').run(id); if (row) { // Cascade: every row after the deleted slot shifts up by 1. db.prepare( 'UPDATE roadmap_items SET display_order = display_order - 1 WHERE display_order > ?' ).run(row.display_order); } })(); } export function getRoadmapItem(id: number): RoadmapItemWithAttribution | null { const row = db.prepare('SELECT * FROM roadmap_items WHERE id = ?').get(id) as RoadmapItemRow | undefined; if (!row) 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 { ...rowToRoadmapItem(row), attributed }; } export function getAllRoadmapItems(): RoadmapItemWithAttribution[] { const rows = db.prepare( 'SELECT * FROM roadmap_items ORDER BY status, display_order, created_at' ).all() as RoadmapItemRow[]; const items = rows.map(rowToRoadmapItem); 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' | 'working_session'; 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; audience: string | null; duration_label: string | null; action_label: string | null; notes_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; audience?: string | null; duration_label?: string | null; action_label?: string | null; notes_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, audience, duration_label, action_label, notes_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.audience ?? null, data.duration_label ?? null, data.action_label ?? null, data.notes_url ?? null, 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; audience?: string | null; duration_label?: string | null; action_label?: string | null; notes_url?: string | null; }): void { db.prepare(` UPDATE events SET title = ?, kind = ?, description = ?, location = ?, starts_at = ?, ends_at = ?, capacity = ?, photo_url = ?, audience = ?, duration_label = ?, action_label = ?, notes_url = ? WHERE id = ? `).run( data.title, data.kind, data.description, data.location, data.starts_at, data.ends_at, data.capacity, data.photo_url, data.audience ?? null, data.duration_label ?? null, data.action_label ?? null, data.notes_url ?? null, 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; } // ── Dispatches ─────────────────────────────────────────────────── export type DispatchKind = 'decision' | 'update' | 'behind_the_scenes' | 'note'; export type DispatchStatus = 'draft' | 'published' | 'archived'; export interface Dispatch { id: number; title: string; body: string; excerpt: string | null; kind: DispatchKind; author_id: number; status: DispatchStatus; published_at: string | null; created_at: string; updated_at: string; pulse_id: number | null; // attached poll, if any } export interface DispatchWithAuthor extends Dispatch { author_name: string; author_title: string | null; author_role: Role; } export interface DispatchWithPoll extends DispatchWithAuthor { poll: PulseWithCounts | null; } /** Optional poll attachment used when creating/updating a dispatch. */ export interface DispatchPollInput { question: string; options: string[]; opens_at: string; closes_at: string; } export function createDispatch(data: { title: string; body: string; excerpt: string | null; kind: DispatchKind; author_id: number; status: DispatchStatus; poll?: DispatchPollInput | null; }): number { const published_at = data.status === 'published' ? new Date().toISOString().slice(0, 19).replace('T', ' ') : null; return db.transaction(() => { let pulseId: number | null = null; if (data.poll && data.poll.options.length >= 2) { pulseId = createPulse({ question: data.poll.question, context: null, options: data.poll.options, opens_at: data.poll.opens_at, closes_at: data.poll.closes_at, status: data.status === 'published' ? 'open' : 'draft', created_by: data.author_id, }); } const r = db.prepare(` INSERT INTO dispatches (title, body, excerpt, kind, author_id, status, published_at, pulse_id) VALUES (?,?,?,?,?,?,?,?) `).run(data.title, data.body, data.excerpt, data.kind, data.author_id, data.status, published_at, pulseId); return Number(r.lastInsertRowid); })(); } /** Update a dispatch and, optionally, manage its attached poll. */ export function updateDispatch(id: number, data: { title: string; body: string; excerpt: string | null; kind: DispatchKind; author_id: number; poll?: DispatchPollInput | null; // present + has options ⇒ attach/update; explicit null ⇒ detach pollExplicit?: boolean; // distinguishes "leave poll alone" (undefined) from "detach" (null + flag) }): void { db.transaction(() => { const cur = db.prepare('SELECT pulse_id, status FROM dispatches WHERE id = ?') .get(id) as { pulse_id: number | null; status: DispatchStatus } | undefined; if (!cur) return; let pulseId: number | null = cur.pulse_id; if (data.pollExplicit) { if (data.poll && data.poll.options.length >= 2) { if (cur.pulse_id) { // update the existing pulse in place updatePulse(cur.pulse_id, { question: data.poll.question, context: null, options: data.poll.options, opens_at: data.poll.opens_at, closes_at: data.poll.closes_at, }); } else { pulseId = createPulse({ question: data.poll.question, context: null, options: data.poll.options, opens_at: data.poll.opens_at, closes_at: data.poll.closes_at, status: cur.status === 'published' ? 'open' : 'draft', created_by: data.author_id, }); } } else { // explicit detach pulseId = null; } } db.prepare(` UPDATE dispatches SET title = ?, body = ?, excerpt = ?, kind = ?, author_id = ?, pulse_id = ?, updated_at = datetime('now') WHERE id = ? `).run(data.title, data.body, data.excerpt, data.kind, data.author_id, pulseId, id); })(); } /** Dispatch + its attached poll (with counts + this viewer's vote). */ export function getDispatchWithPoll(dispatchId: number, viewerId: number): DispatchWithPoll | null { const d = getDispatchById(dispatchId); if (!d) return null; const poll = d.pulse_id ? getPulseWithCounts(d.pulse_id, viewerId) : null; return { ...d, poll }; } /** Promote draft → published, stamping published_at = now() on first publish. * Idempotent: if already published, published_at is preserved. Also opens * any attached draft poll so members can start voting. */ export function publishDispatch(id: number): void { db.transaction(() => { db.prepare(` UPDATE dispatches SET status = 'published', published_at = COALESCE(published_at, datetime('now')), updated_at = datetime('now') WHERE id = ? `).run(id); const row = db.prepare('SELECT pulse_id FROM dispatches WHERE id = ?').get(id) as { pulse_id: number | null } | undefined; if (row?.pulse_id) publishPulse(row.pulse_id); })(); } /** Archive a dispatch. Leaves published_at intact for history. Closes any * attached open poll so the bar charts read final. */ export function archiveDispatch(id: number): void { db.transaction(() => { db.prepare("UPDATE dispatches SET status = 'archived', updated_at = datetime('now') WHERE id = ?").run(id); const row = db.prepare('SELECT pulse_id FROM dispatches WHERE id = ?').get(id) as { pulse_id: number | null } | undefined; if (row?.pulse_id) closePulse(row.pulse_id); })(); } export function deleteDispatch(id: number): void { db.prepare('DELETE FROM dispatches WHERE id = ?').run(id); } export function getDispatchById(id: number): DispatchWithAuthor | null { return db.prepare(` SELECT d.*, u.name AS author_name, u.title AS author_title, u.role AS author_role FROM dispatches d JOIN users u ON u.id = d.author_id WHERE d.id = ? `).get(id) as DispatchWithAuthor | null; } export function getLatestPublishedDispatches(limit: number): DispatchWithAuthor[] { return db.prepare(` SELECT d.*, u.name AS author_name, u.title AS author_title, u.role AS author_role FROM dispatches d JOIN users u ON u.id = d.author_id WHERE d.status = 'published' ORDER BY d.published_at DESC, d.id DESC LIMIT ? `).all(limit) as DispatchWithAuthor[]; } export function getAllDispatchesForAdmin(opts: { status?: DispatchStatus } = {}): DispatchWithAuthor[] { const clause = opts.status ? `WHERE d.status = '${opts.status}'` : ''; return db.prepare(` SELECT d.*, u.name AS author_name, u.title AS author_title, u.role AS author_role FROM dispatches d JOIN users u ON u.id = d.author_id ${clause} ORDER BY d.created_at DESC, d.id DESC `).all() as DispatchWithAuthor[]; } /** Adjacent dispatches in published order. Returns the immediately newer and older. */ export function getAdjacentDispatches(id: number): { prev: DispatchWithAuthor | null; next: DispatchWithAuthor | null } { const cur = getDispatchById(id); if (!cur || cur.status !== 'published' || !cur.published_at) return { prev: null, next: null }; const prev = db.prepare(` SELECT d.*, u.name AS author_name, u.title AS author_title, u.role AS author_role FROM dispatches d JOIN users u ON u.id = d.author_id WHERE d.status = 'published' AND d.published_at < ? ORDER BY d.published_at DESC LIMIT 1 `).get(cur.published_at) as DispatchWithAuthor | null; const next = db.prepare(` SELECT d.*, u.name AS author_name, u.title AS author_title, u.role AS author_role FROM dispatches d JOIN users u ON u.id = d.author_id WHERE d.status = 'published' AND d.published_at > ? ORDER BY d.published_at ASC LIMIT 1 `).get(cur.published_at) as DispatchWithAuthor | null; return { prev, next }; } // ── Event attendees (for the avatar pile on /events) ───────────── export function getEventAttendees(slug: string, status: 'yes' | 'interested' | 'no' = 'yes'): UserPublic[] { return db.prepare(` SELECT u.id, u.email, u.name, u.organisation, u.role, u.bio, u.created_at, u.last_seen_at, u.active, u.title, u.cab_joined_date, u.slug, u.pull_quote, u.member_number, u.focus_tags FROM attendance a JOIN users u ON u.id = a.user_id WHERE a.meeting_slug = ? AND a.kind = 'event' AND a.status = ? ORDER BY a.updated_at DESC `).all(slug, status) as UserPublic[]; }