211 lines
7.2 KiB
TypeScript
211 lines
7.2 KiB
TypeScript
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<string, number> {
|
|
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 };
|
|
})();
|
|
}
|