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>
911 lines
31 KiB
TypeScript
911 lines
31 KiB
TypeScript
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.
|
|
}
|
|
|
|
export type UserPublic = Omit<User, 'password_hash'>;
|
|
|
|
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 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 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);
|
|
return Number(r.lastInsertRowid);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
export function updateUserRole(id: number, role: Role): void {
|
|
db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, id);
|
|
}
|
|
|
|
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 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 FROM users WHERE slug = ? AND active = 1'
|
|
).get(slug) as UserPublic | null;
|
|
}
|
|
|
|
// ── Slugs ────────────────────────────────────────────────────────
|
|
|
|
export function slugifyName(name: string): string {
|
|
return name
|
|
.toLowerCase()
|
|
.normalize('NFKD').replace(/[̀-ͯ]/g, '') // strip 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 })[];
|
|
}
|
|
|
|
// ── 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<number> {
|
|
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[];
|
|
}
|
|
|
|
// ── 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;
|
|
}
|