From 368ce3ac8cb2050b136d194755355ac31584afb1 Mon Sep 17 00:00:00 2001 From: Jonathan Hvid Date: Mon, 11 May 2026 15:55:35 +0200 Subject: [PATCH] feat(db): dispatches + member-number allocation + focus-tags parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit db.ts: - User type gains pull_quote, member_number, focus_tags; all SELECT lists updated. getAllCabMembers (member_number asc) and countCabMembers (used by /pulse denominator) added. - createUser allocates a member_number in-transaction when role=cab. - updateUserRole returns { allocated: number | null } so admin can surface the assignment; allocation is one-way: pilot→cab→pilot→cab keeps the original number. - allocateMemberNumber: MAX(member_number)+1, idempotent, never reuses. - updateUserAdminFields: title / pull_quote / focus_tags (parsed array). - createEvent / updateEvent extended for audience, duration_label, action_label, notes_url. - Dispatch CRUD: create / update / publish (stamps published_at) / archive / delete. getDispatchById, getLatestPublishedDispatches, getAllDispatchesForAdmin, getAdjacentDispatches (prev/next in published order). - getEventAttendees(slug, status) backs the upcoming-event avatar pile. format.ts: - AVATAR_PIGMENTS (terracotta/copper/walnut/indigo/heather) + pigmentForId (id % palette, deterministic). - parseFocusTags: trim, strip ASCII control chars (\x00-\x1F\x7F), collapse internal whitespace, dedupe, cap 3 × 24. - readFocusTags (safe JSON.parse for display). - dispatchSlug / parseDispatchSlug: {id}-{kebab(title)}; renames don't break links because the id leads. - dispatchKindLabel, stripMarkdownLight, dispatchExcerptParas (two-paragraph excerpt with sentence-boundary cut). Tests: member-number allocation (idempotent, never reuses, allocates on role transition) and focus_tags parser (control chars, whitespace collapse, dedupe, cap). 24/24 passing. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/db.ts | 244 +++++++++++++++++++++++++++++++++--- src/lib/format.ts | Bin 3572 -> 8335 bytes tests/focus-tags.test.ts | 35 ++++++ tests/member-number.test.ts | 61 +++++++++ 4 files changed, 325 insertions(+), 15 deletions(-) create mode 100644 tests/focus-tags.test.ts create mode 100644 tests/member-number.test.ts diff --git a/src/lib/db.ts b/src/lib/db.ts index 015cf9e..c8da261 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -21,6 +21,9 @@ export interface User { 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; @@ -100,7 +103,7 @@ export function getUserById(id: number): 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' + '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; } @@ -113,10 +116,32 @@ export function createUser(data: { }): 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); + 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 { @@ -127,8 +152,19 @@ 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); +/** 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 { @@ -137,16 +173,41 @@ export function deactivateUser(id: number): void { 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' + '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 FROM users WHERE slug = ? AND active = 1' + '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 { @@ -705,7 +766,7 @@ export function setRoadmapAttributions(itemId: number, userIds: number[]): void // ── Events ─────────────────────────────────────────────────────── -export type EventKind = 'dinner' | 'office_hours' | 'summit' | 'virtual'; +export type EventKind = 'dinner' | 'office_hours' | 'summit' | 'virtual' | 'working_session'; export interface Event { id: number; @@ -718,6 +779,10 @@ export interface Event { 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; } @@ -732,14 +797,20 @@ export function createEvent(data: { 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, created_by) - VALUES (?,?,?,?,?,?,?,?,?,?) + 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.created_by, + 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); } @@ -753,14 +824,21 @@ export function updateEvent(id: number, data: { 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 = ? + 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, id, + 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, ); } @@ -909,3 +987,139 @@ export function countShippedAttributions(userId: number): number { `).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; +} + +export interface DispatchWithAuthor extends Dispatch { + author_name: string; + author_title: string | null; + author_role: Role; +} + +export function createDispatch(data: { + title: string; + body: string; + excerpt: string | null; + kind: DispatchKind; + author_id: number; + status: DispatchStatus; +}): number { + const published_at = data.status === 'published' + ? new Date().toISOString().slice(0, 19).replace('T', ' ') + : null; + const r = db.prepare(` + INSERT INTO dispatches (title, body, excerpt, kind, author_id, status, published_at) + VALUES (?,?,?,?,?,?,?) + `).run(data.title, data.body, data.excerpt, data.kind, data.author_id, data.status, published_at); + return Number(r.lastInsertRowid); +} + +export function updateDispatch(id: number, data: { + title: string; + body: string; + excerpt: string | null; + kind: DispatchKind; + author_id: number; +}): void { + db.prepare(` + UPDATE dispatches + SET title = ?, body = ?, excerpt = ?, kind = ?, author_id = ?, updated_at = datetime('now') + WHERE id = ? + `).run(data.title, data.body, data.excerpt, data.kind, data.author_id, id); +} + +/** Promote draft → published, stamping published_at = now() on first publish. + * Idempotent: if already published, published_at is preserved. */ +export function publishDispatch(id: number): void { + db.prepare(` + UPDATE dispatches + SET status = 'published', + published_at = COALESCE(published_at, datetime('now')), + updated_at = datetime('now') + WHERE id = ? + `).run(id); +} + +/** Archive a dispatch. Leaves published_at intact for history. */ +export function archiveDispatch(id: number): void { + db.prepare("UPDATE dispatches SET status = 'archived', updated_at = datetime('now') WHERE id = ?").run(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[]; +} diff --git a/src/lib/format.ts b/src/lib/format.ts index 3e2c4b3ebb811593ec831d754957dd65f8a53a15..8d64ccc5507553378fb1310e42cc1728a3a0f73c 100644 GIT binary patch literal 8335 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 { + it('trims, splits on commas, drops empties', () => { + expect(parseFocusTags(' GDPR, Telemetry, , Policy ')).toEqual(['GDPR', 'Telemetry', 'Policy']); + }); + + it('caps at 3 entries', () => { + expect(parseFocusTags('a,b,c,d,e')).toEqual(['a', 'b', 'c']); + }); + + it('caps each entry at 24 chars', () => { + expect(parseFocusTags('a-very-long-tag-that-runs-on-forever')).toEqual(['a-very-long-tag-that-run']); + }); + + it('dedupes after normalisation', () => { + expect(parseFocusTags('GDPR, gdpr, GDPR')).toEqual(['GDPR', 'gdpr']); // case-sensitive + expect(parseFocusTags('Foo, Foo, Foo')).toEqual(['Foo']); + }); + + it('collapses internal whitespace so visually-identical tags merge', () => { + expect(parseFocusTags('Healthcare data, Healthcare data')).toEqual(['Healthcare data']); + }); + + it('strips ASCII control characters', () => { + expect(parseFocusTags('Policy, Telemetry')).toEqual(['Policy', 'Telemetry']); + }); + + it('returns [] on empty input', () => { + expect(parseFocusTags('')).toEqual([]); + expect(parseFocusTags(' ')).toEqual([]); + expect(parseFocusTags(',,,')).toEqual([]); + }); +}); diff --git a/tests/member-number.test.ts b/tests/member-number.test.ts new file mode 100644 index 0000000..9ca0b14 --- /dev/null +++ b/tests/member-number.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import type Database from 'better-sqlite3'; +import { allocateMemberNumber, createUser, updateUserRole, deactivateUser } from '../src/lib/db.js'; + +const db = (globalThis as typeof globalThis & { __bifrost_db?: Database.Database }).__bifrost_db!; + +function makeUser(name: string, role: 'pilot' | 'cab' | 'fenja' = 'pilot'): number { + return createUser({ email: `${name}@x.test`, password_hash: 'x', name, organisation: 'TestOrg', role }); +} + +beforeEach(() => { + db.exec(` + DELETE FROM votes; + DELETE FROM pulses; + DELETE FROM activity; + DELETE FROM dispatches; + DELETE FROM users; + `); +}); + +describe('allocateMemberNumber', () => { + it('allocates 1 for the first cab user and sequential numbers thereafter', () => { + const a = makeUser('alice', 'cab'); + const b = makeUser('bob', 'cab'); + const c = makeUser('cara', 'cab'); + expect(db.prepare('SELECT member_number FROM users WHERE id=?').get(a)).toEqual({ member_number: 1 }); + expect(db.prepare('SELECT member_number FROM users WHERE id=?').get(b)).toEqual({ member_number: 2 }); + expect(db.prepare('SELECT member_number FROM users WHERE id=?').get(c)).toEqual({ member_number: 3 }); + }); + + it('is idempotent — calling allocate on a user who already has a number returns the same one', () => { + const a = makeUser('alice', 'cab'); + const first = (db.prepare('SELECT member_number AS n FROM users WHERE id=?').get(a) as { n: number }).n; + const reAllocated = allocateMemberNumber(a); + expect(reAllocated).toBe(first); + }); + + it('never reuses: deactivating a holder does not free their number', () => { + const a = makeUser('alice', 'cab'); // #1 + const b = makeUser('bob', 'cab'); // #2 + deactivateUser(a); + const c = makeUser('cara', 'cab'); // expects #3, not #1 + expect((db.prepare('SELECT member_number AS n FROM users WHERE id=?').get(c) as { n: number }).n).toBe(3); + }); + + it('does not allocate when a non-cab user is created', () => { + const a = makeUser('alice', 'pilot'); + expect(db.prepare('SELECT member_number FROM users WHERE id=?').get(a)).toEqual({ member_number: null }); + }); + + it('allocates on role transition pilot → cab and only the first time', () => { + const a = makeUser('alice', 'pilot'); + const r1 = updateUserRole(a, 'cab'); + expect(r1.allocated).toBe(1); + // transition back and forth must not change the number + updateUserRole(a, 'pilot'); + const r2 = updateUserRole(a, 'cab'); + expect(r2.allocated).toBeNull(); + expect((db.prepare('SELECT member_number AS n FROM users WHERE id=?').get(a) as { n: number }).n).toBe(1); + }); +});