Release 0.6.0
This commit is contained in:
1 parent
fd47910d09
commit
b80dded55d
40 files changed
+4080
-856
No files matched your search
@@ -0,0 +1,210 @@
|
||||
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 };
|
||||
})();
|
||||
}
|
||||
Reference in new issue
Block a user