// Room model: named multi-client listening sessions. // // Each user has an auto-provisioned "personal" room (slug = `u-`) so the // kiosk Just Works on first login. Shared rooms are explicit creations and // have a member list. import { getDb } from './db/index.js'; import { slugify } from './stations.js'; function rowToRoom(row) { if (!row) return null; return { id: row.id, slug: row.slug, name: row.name, created_by: row.created_by, created_at: row.created_at }; } export function getRoomBySlug(slug) { return rowToRoom(getDb().prepare('SELECT * FROM rooms WHERE slug = ?').get(slug)); } export function getRoomById(id) { return rowToRoom(getDb().prepare('SELECT * FROM rooms WHERE id = ?').get(id)); } export function isMember(roomId, userId) { if (!roomId || !userId) return false; const row = getDb().prepare( 'SELECT 1 FROM room_members WHERE room_id = ? AND user_id = ?' ).get(roomId, userId); return !!row; } export function listRoomsForUser(userId) { return getDb().prepare(` SELECT r.*, rm.role FROM rooms r JOIN room_members rm ON rm.room_id = r.id WHERE rm.user_id = ? ORDER BY r.name COLLATE NOCASE `).all(userId).map((row) => ({ ...rowToRoom(row), role: row.role })); } export function listMembers(roomId) { return getDb().prepare(` SELECT u.id, u.username, rm.role, rm.created_at FROM room_members rm JOIN users u ON u.id = rm.user_id WHERE rm.room_id = ? ORDER BY u.username COLLATE NOCASE `).all(roomId); } function uniqueRoomSlug(base) { const db = getDb(); let slug = base, n = 1; while (db.prepare('SELECT 1 FROM rooms WHERE slug = ?').get(slug)) { n += 1; slug = `${base}-${n}`; } return slug; } export function createRoom({ name, slug, ownerId }) { if (!name) throw new Error('name required'); const db = getDb(); const baseSlug = slug || slugify(name); const finalSlug = uniqueRoomSlug(baseSlug); const info = db.prepare( 'INSERT INTO rooms (slug, name, created_by) VALUES (?, ?, ?)' ).run(finalSlug, name, ownerId ?? null); const id = info.lastInsertRowid; if (ownerId) { db.prepare( "INSERT OR IGNORE INTO room_members (room_id, user_id, role) VALUES (?, ?, 'owner')" ).run(id, ownerId); } db.prepare( 'INSERT OR IGNORE INTO room_state (room_id, playing, volume) VALUES (?, 0, 0.7)' ).run(id); return getRoomById(id); } export function addMember(roomId, userId, role = 'member') { getDb().prepare( 'INSERT OR IGNORE INTO room_members (room_id, user_id, role) VALUES (?, ?, ?)' ).run(roomId, userId, role); } export function removeMember(roomId, userId) { return getDb().prepare( 'DELETE FROM room_members WHERE room_id = ? AND user_id = ?' ).run(roomId, userId).changes > 0; } /** Idempotently ensure a personal room exists for the user, returning it. */ export function ensurePersonalRoom(user) { if (!user) return null; const slug = `u-${user.id}`; const existing = getRoomBySlug(slug); if (existing) return existing; const db = getDb(); db.prepare( 'INSERT INTO rooms (slug, name, created_by) VALUES (?, ?, ?)' ).run(slug, `${user.username}'s room`, user.id); const room = getRoomBySlug(slug); db.prepare( "INSERT OR IGNORE INTO room_members (room_id, user_id, role) VALUES (?, ?, 'owner')" ).run(room.id, user.id); db.prepare( 'INSERT OR IGNORE INTO room_state (room_id, playing, volume) VALUES (?, 0, 0.7)' ).run(room.id); return room; } export function getRoomState(roomId) { const row = getDb().prepare('SELECT * FROM room_state WHERE room_id = ?').get(roomId); if (!row) return { station_id: null, playing: false, volume: 0.7, updated_at: null }; return { station_id: row.station_id, playing: !!row.playing, volume: row.volume, updated_at: row.updated_at }; } export function setRoomState(roomId, patch) { const cur = getRoomState(roomId); const next = { ...cur, ...patch }; getDb().prepare(` INSERT INTO room_state (room_id, station_id, playing, volume, updated_at) VALUES (?, ?, ?, ?, datetime('now')) ON CONFLICT(room_id) DO UPDATE SET station_id = excluded.station_id, playing = excluded.playing, volume = excluded.volume, updated_at = excluded.updated_at `).run(roomId, next.station_id ?? null, next.playing ? 1 : 0, next.volume ?? 0.7); return getRoomState(roomId); }