feat(db): configurable path, user field extensions, slug generation

- BIFROST_DB_PATH env var overrides the default bifrost.db path; lets
  vitest open ':memory:' per suite without touching prod data.
- Extend User/UserPublic with title, cab_joined_date, slug.
- Update SELECT lists for getUserPublicById and getAllUsersPublic.
- Add getUserBySlug for /members/:slug routes.
- Add slugifyName + generateUniqueSlug; createUser now auto-slugs from name
  and stamps cab_joined_date for cab-role users.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Hvid 2026-05-11 14:40:24 +02:00
parent 53cb9a7e49
commit 56992ed4ca

View file

@ -18,6 +18,9 @@ export interface User {
created_at: string; created_at: string;
last_seen_at: string | null; last_seen_at: string | null;
active: number; 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 type UserPublic = Omit<User, 'password_hash'>;
@ -69,10 +72,13 @@ export interface AttendanceSummary {
// ── Connection ─────────────────────────────────────────────────── // ── Connection ───────────────────────────────────────────────────
// Persist across Vite HMR reloads in dev // 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 }; const g = globalThis as typeof globalThis & { __bifrost_db?: Database.Database };
if (!g.__bifrost_db) { if (!g.__bifrost_db) {
g.__bifrost_db = new Database(join(process.cwd(), 'bifrost.db')); g.__bifrost_db = new Database(DB_PATH);
g.__bifrost_db.pragma('journal_mode = WAL'); g.__bifrost_db.pragma('journal_mode = WAL');
g.__bifrost_db.pragma('foreign_keys = ON'); g.__bifrost_db.pragma('foreign_keys = ON');
} }
@ -94,7 +100,7 @@ export function getUserById(id: number): User | null {
export function getUserPublicById(id: number): UserPublic | null { export function getUserPublicById(id: number): UserPublic | null {
return db.prepare( return db.prepare(
'SELECT id,email,name,organisation,role,bio,created_at,last_seen_at,active 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 FROM users WHERE id = ? AND active = 1'
).get(id) as UserPublic | null; ).get(id) as UserPublic | null;
} }
@ -105,9 +111,11 @@ export function createUser(data: {
organisation: string; organisation: string;
role: Role; role: Role;
}): number { }): number {
const slug = generateUniqueSlug(data.name);
const cabJoined = data.role === 'cab' ? new Date().toISOString().slice(0, 10) : null;
const r = db.prepare( const r = db.prepare(
'INSERT INTO users (email,password_hash,name,organisation,role) VALUES (?,?,?,?,?)' '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); ).run(data.email, data.password_hash, data.name, data.organisation, data.role, slug, cabJoined);
return Number(r.lastInsertRowid); return Number(r.lastInsertRowid);
} }
@ -129,10 +137,38 @@ export function deactivateUser(id: number): void {
export function getAllUsersPublic(): UserPublic[] { export function getAllUsersPublic(): UserPublic[] {
return db.prepare( return db.prepare(
'SELECT id,email,name,organisation,role,bio,created_at,last_seen_at,active FROM users ORDER BY organisation,name' '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[]; ).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 ───────────────────────────────────────────────────── // ── Sessions ─────────────────────────────────────────────────────
export function createDbSession(id: string, userId: number, expiresAt: string): void { export function createDbSession(id: string, userId: number, expiresAt: string): void {