diff --git a/src/lib/db.ts b/src/lib/db.ts index 5155e55..0736b5d 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -18,6 +18,9 @@ export interface User { 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; @@ -69,10 +72,13 @@ export interface AttendanceSummary { // ── 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 }; 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('foreign_keys = ON'); } @@ -94,7 +100,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 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; } @@ -105,9 +111,11 @@ export function createUser(data: { 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) VALUES (?,?,?,?,?)' - ).run(data.email, data.password_hash, data.name, data.organisation, data.role); + '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); } @@ -129,10 +137,38 @@ 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 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[]; } +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 {