import type { AuthUser } from './auth.js'; import { getDatabase } from './database.js'; export type CompletionCandidate = { pieceId: string; title: string; usageCount: number; }; export type CompletionSummary = { doneCount: number; skippedCount: number; openCount: number; candidates: CompletionCandidate[]; }; export function isShowFinalized(showId: string): boolean { return Boolean( getDatabase().prepare('SELECT 1 FROM show_finalizations WHERE show_id = ?').get(showId) ); } export function getCompletionSummary(showId: string): CompletionSummary { const database = getDatabase(); if (!database.prepare('SELECT 1 FROM shows WHERE id = ?').get(showId)) throw new Error('NOT_FOUND'); const counts = database .prepare( `SELECT sum(CASE WHEN u.status = 'done' THEN 1 ELSE 0 END) AS done_count, sum(CASE WHEN u.status = 'skipped' THEN 1 ELSE 0 END) AS skipped_count, sum(CASE WHEN u.status IN ('open', 'current') THEN 1 ELSE 0 END) AS open_count FROM piece_usages u JOIN show_sections s ON s.id = u.show_section_id WHERE s.show_id = ? AND s.is_reserve_bank = 0` ) .get(showId) as { done_count: number | null; skipped_count: number | null; open_count: number | null; }; const candidates = database .prepare( `SELECT u.original_piece_id AS piece_id, p.title, count(*) AS usage_count FROM piece_usages u JOIN show_sections s ON s.id = u.show_section_id JOIN pieces p ON p.id = u.original_piece_id WHERE s.show_id = ? AND s.is_reserve_bank = 0 AND u.status = 'done' AND p.status != 'archived' AND p.deleted_at_utc IS NULL GROUP BY u.original_piece_id, p.title ORDER BY p.title COLLATE NOCASE` ) .all(showId) as { piece_id: string; title: string; usage_count: number }[]; return { doneCount: counts.done_count ?? 0, skippedCount: counts.skipped_count ?? 0, openCount: counts.open_count ?? 0, candidates: candidates.map((candidate) => ({ pieceId: candidate.piece_id, title: candidate.title, usageCount: candidate.usage_count })) }; } export function getHistoricalActualDurations(showId: string): Record { return Object.fromEntries( ( getDatabase() .prepare( `SELECT usage_id, actual_duration_seconds FROM piece_usage_history WHERE show_id = ?` ) .all(showId) as { usage_id: string; actual_duration_seconds: number }[] ).map((row) => [row.usage_id, row.actual_duration_seconds]) ); } export function finalizeShow(input: { showId: string; commandId: string; expectedShowRevision: number; expectedLiveRevision: number; archivePieceIds: string[]; actor: AuthUser; now?: Date; }) { if (!input.commandId || input.commandId.length > 200) throw new Error('INVALID_COMMAND'); if (!Number.isInteger(input.expectedShowRevision) || input.expectedShowRevision < 1) { throw new Error('INVALID_REVISION'); } if (!Number.isInteger(input.expectedLiveRevision) || input.expectedLiveRevision < 0) { throw new Error('INVALID_REVISION'); } const archivePieceIds = [...new Set(input.archivePieceIds.filter(Boolean))]; const database = getDatabase(); return database.transaction(() => { const existing = database .prepare('SELECT command_id FROM show_finalizations WHERE show_id = ?') .get(input.showId) as { command_id: string } | undefined; if (existing) { if (existing.command_id !== input.commandId) throw new Error('SHOW_COMPLETED'); return { duplicate: true }; } const state = database .prepare( `SELECT sh.revision AS show_revision, ls.revision AS live_revision, ls.ended_at_utc FROM shows sh JOIN show_live_states ls ON ls.show_id = sh.id WHERE sh.id = ?` ) .get(input.showId) as { show_revision: number; live_revision: number; ended_at_utc: string | null } | undefined; if (!state) throw new Error('NOT_FOUND'); if (!state.ended_at_utc) throw new Error('NOT_ENDED'); if ( state.show_revision !== input.expectedShowRevision || state.live_revision !== input.expectedLiveRevision ) { throw new Error('CONFLICT'); } const candidates = getCompletionSummary(input.showId).candidates.map( (candidate) => candidate.pieceId ); if (archivePieceIds.some((pieceId) => !candidates.includes(pieceId))) { throw new Error('INVALID_ARCHIVE_SELECTION'); } const now = (input.now ?? new Date()).toISOString(); database .prepare( `UPDATE live_usage_runs SET ended_at_utc = ? WHERE show_id = ? AND ended_at_utc IS NULL` ) .run(now, input.showId); database .prepare( `UPDATE live_show_timer_runs SET ended_at_utc = ? WHERE show_id = ? AND ended_at_utc IS NULL` ) .run(now, input.showId); database .prepare( `UPDATE live_section_timer_runs SET ended_at_utc = ? WHERE ended_at_utc IS NULL AND timer_id IN ( SELECT id FROM live_section_timers WHERE show_id = ? )` ) .run(now, input.showId); database .prepare( `UPDATE live_section_timers SET ended_at_utc = ? WHERE show_id = ? AND ended_at_utc IS NULL` ) .run(now, input.showId); database .prepare( `UPDATE show_sections SET category_name_snapshot = COALESCE(category_name_snapshot, (SELECT name FROM section_categories WHERE id = show_sections.category_id)), category_color_snapshot = COALESCE(category_color_snapshot, (SELECT color FROM section_categories WHERE id = show_sections.category_id)) WHERE show_id = ?` ) .run(input.showId); database .prepare( `INSERT INTO piece_usage_history ( usage_id, show_id, original_piece_id, show_title_snapshot, usage_title_snapshot, used_at_utc, actual_duration_seconds ) SELECT u.id, sh.id, u.original_piece_id, sh.title, u.title_snapshot, COALESCE(sh.starts_at_utc, ?), COALESCE(( SELECT sum(max(0, CAST((julianday(COALESCE(r.ended_at_utc, ?)) - julianday(r.started_at_utc)) * 86400 AS INTEGER))) FROM live_usage_runs r WHERE r.usage_id = u.id ), 0) FROM piece_usages u JOIN show_sections s ON s.id = u.show_section_id JOIN shows sh ON sh.id = s.show_id WHERE sh.id = ? AND s.is_reserve_bank = 0 AND u.status = 'done' AND u.original_piece_id IS NOT NULL` ) .run(now, now, input.showId); const archivePiece = database.prepare( `UPDATE pieces SET archived_from_status = status, status = 'archived', revision = revision + 1, updated_at_utc = ? WHERE id = ? AND status != 'archived' AND deleted_at_utc IS NULL` ); for (const pieceId of archivePieceIds) { archivePiece.run(now, pieceId); } const updated = database .prepare( `UPDATE shows SET status = 'completed', completed_at_utc = ?, revision = revision + 1, updated_at_utc = ? WHERE id = ? AND revision = ? AND status != 'completed'` ) .run(now, now, input.showId, input.expectedShowRevision); if (updated.changes !== 1) throw new Error('CONFLICT'); database .prepare( `INSERT INTO show_finalizations (show_id, command_id, source_revision, finalized_by_user_id, finalized_at_utc) VALUES (?, ?, ?, ?, ?)` ) .run(input.showId, input.commandId, input.expectedShowRevision, input.actor.id, now); const recordArchivedPiece = database.prepare( 'INSERT INTO show_finalization_archived_pieces (show_id, piece_id) VALUES (?, ?)' ); for (const pieceId of archivePieceIds) { recordArchivedPiece.run(input.showId, pieceId); } return { duplicate: false }; })(); }