Release 0.5.0
This commit is contained in:
1 parent
4042fe6a64
commit
5a9d7735d4
26 files changed
+1855
-113
No files matched your search
@@ -62,7 +62,7 @@ describe('database setup', () => {
|
||||
database.close();
|
||||
|
||||
expect(counts).toEqual({
|
||||
migrations: 4,
|
||||
migrations: 5,
|
||||
pieces: 6,
|
||||
shows: 2,
|
||||
usages: 5,
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { closeDatabase, getDatabase } from './database.js';
|
||||
import {
|
||||
completeCurrent,
|
||||
getLiveView,
|
||||
getPresentationMedia,
|
||||
getPresentationView,
|
||||
goBack,
|
||||
issuePresentationAccess,
|
||||
moveOpenUsage,
|
||||
presentUsage,
|
||||
skipCurrent,
|
||||
startOrResumeLive
|
||||
} from './live.js';
|
||||
|
||||
let directory: string;
|
||||
|
||||
beforeEach(() => {
|
||||
directory = mkdtempSync(path.join(tmpdir(), 'sendewerk-live-test-'));
|
||||
process.env.SENDEWERK_DB_PATH = path.join(directory, 'test.sqlite');
|
||||
process.env.SENDEWERK_UPLOAD_DIR = path.join(directory, 'uploads');
|
||||
execFileSync(process.execPath, ['scripts/migrate.mjs'], { cwd: process.cwd(), env: process.env });
|
||||
execFileSync(process.execPath, ['scripts/seed.mjs'], { cwd: process.cwd(), env: process.env });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDatabase();
|
||||
delete process.env.SENDEWERK_DB_PATH;
|
||||
delete process.env.SENDEWERK_UPLOAD_DIR;
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('live mode', () => {
|
||||
it('restores live state, records actual intervals and applies commands only once', () => {
|
||||
const started = new Date('2026-10-22T18:00:00.000Z');
|
||||
const initial = getLiveView('show-october', started);
|
||||
expect(initial.current).toBeNull();
|
||||
|
||||
expect(startOrResumeLive('show-october', 'start-command', 0, started)).toEqual({
|
||||
revision: 1,
|
||||
duplicate: false
|
||||
});
|
||||
expect(startOrResumeLive('show-october', 'start-command', 0, started)).toEqual({
|
||||
revision: 1,
|
||||
duplicate: true
|
||||
});
|
||||
let live = getLiveView('show-october', new Date('2026-10-22T18:05:00.000Z'));
|
||||
expect(live.current?.id).toBe('usage-console');
|
||||
expect(live.current?.actualDurationSeconds).toBe(300);
|
||||
expect(() =>
|
||||
moveOpenUsage('show-october', 'usage-effects', live.show.revision, {
|
||||
sectionId: 'section-stories',
|
||||
position: 0
|
||||
})
|
||||
).toThrow('LIVE_ORDER_LOCKED');
|
||||
|
||||
completeCurrent('show-october', 'complete-console', 1, new Date('2026-10-22T18:05:00.000Z'));
|
||||
completeCurrent('show-october', 'complete-console', 1, new Date('2026-10-22T18:05:00.000Z'));
|
||||
live = getLiveView('show-october', new Date('2026-10-22T18:06:00.000Z'));
|
||||
expect(live.current?.id).toBe('usage-effects');
|
||||
expect(
|
||||
live.sections
|
||||
.flatMap((section) => section.usages)
|
||||
.find((usage) => usage.id === 'usage-console')
|
||||
).toMatchObject({ status: 'done', actualDurationSeconds: 300 });
|
||||
expect(
|
||||
getDatabase()
|
||||
.prepare('SELECT count(*) AS count FROM live_usage_runs WHERE usage_id = ?')
|
||||
.get('usage-console')
|
||||
).toEqual({ count: 1 });
|
||||
|
||||
expect(() => skipCurrent('show-october', 'stale-command', 1)).toThrow('CONFLICT');
|
||||
skipCurrent('show-october', 'skip-effects', 2, new Date('2026-10-22T18:06:00.000Z'));
|
||||
live = getLiveView('show-october', new Date('2026-10-22T18:07:00.000Z'));
|
||||
expect(live.current).toBeNull();
|
||||
expect(live.completedCount).toBe(2);
|
||||
|
||||
goBack('show-october', 'back-effects', 3, new Date('2026-10-22T18:07:00.000Z'));
|
||||
live = getLiveView('show-october', new Date('2026-10-22T18:08:00.000Z'));
|
||||
expect(live.current).toMatchObject({ id: 'usage-effects', status: 'current' });
|
||||
expect(live.current?.actualDurationSeconds).toBe(120);
|
||||
});
|
||||
|
||||
it('changes presentation only explicitly and exposes no private fields', async () => {
|
||||
const uploads = process.env.SENDEWERK_UPLOAD_DIR!;
|
||||
mkdirSync(uploads, { recursive: true });
|
||||
writeFileSync(path.join(uploads, 'presentation.png'), Buffer.from('test-image'));
|
||||
getDatabase()
|
||||
.prepare(
|
||||
`INSERT INTO piece_media (id, piece_id, storage_name, original_name, mime_type,
|
||||
byte_size, alt_text, position, created_at_utc) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
'presentation-image',
|
||||
'piece-console',
|
||||
'presentation.png',
|
||||
'private-original-name.png',
|
||||
'image/png',
|
||||
10,
|
||||
'Konsole auf dem Tisch',
|
||||
0,
|
||||
'2026-10-22T18:00:00.000Z'
|
||||
);
|
||||
|
||||
const token = issuePresentationAccess('show-october');
|
||||
expect(getPresentationView(token)).toEqual({ revision: 0, content: null });
|
||||
startOrResumeLive('show-october', 'start-output', 0);
|
||||
expect(getPresentationView(token)).toEqual({ revision: 0, content: null });
|
||||
|
||||
presentUsage('show-october', 'usage-console', 'present-console', 1);
|
||||
const output = getPresentationView(token)!;
|
||||
expect(output.content).toMatchObject({
|
||||
title: 'Die kleine Konsole mit dem großen Dreh',
|
||||
images: [{ id: 'presentation-image', altText: 'Konsole auf dem Tisch' }]
|
||||
});
|
||||
const serialized = JSON.stringify(output);
|
||||
expect(serialized).toContain('Weniger Leistung, mehr Idee.');
|
||||
expect(serialized).not.toContain('Herstellerangaben');
|
||||
expect(serialized).not.toContain('Moderationsstichpunkte');
|
||||
expect(serialized).not.toContain('private-original-name');
|
||||
expect(serialized).not.toContain('presentation.png');
|
||||
|
||||
const media = await getPresentationMedia(token, 'presentation-image');
|
||||
expect(media?.data.toString()).toBe('test-image');
|
||||
expect(await getPresentationMedia(token, 'unknown-image')).toBeNull();
|
||||
|
||||
const replacementToken = issuePresentationAccess('show-october');
|
||||
expect(getPresentationView(token)).toBeNull();
|
||||
expect(getPresentationView(replacementToken)?.content?.title).toBe(
|
||||
'Die kleine Konsole mit dem großen Dreh'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,563 @@
|
||||
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
||||
import type Database from 'better-sqlite3';
|
||||
import { getMediaFile } from './content.js';
|
||||
import { getDatabase } from './database.js';
|
||||
import { renderMarkdown } from './markdown.js';
|
||||
import {
|
||||
getShow,
|
||||
movePieceUsage,
|
||||
type MovePieceUsageInput,
|
||||
type PieceUsage,
|
||||
type ShowSection
|
||||
} from './shows.js';
|
||||
|
||||
type LiveStateRow = {
|
||||
show_id: string;
|
||||
revision: number;
|
||||
current_usage_id: string | null;
|
||||
started_at_utc: string | null;
|
||||
ended_at_utc: string | null;
|
||||
presentation_usage_id: string | null;
|
||||
presentation_revision: number;
|
||||
updated_at_utc: string;
|
||||
};
|
||||
|
||||
type OrderedUsageRow = {
|
||||
id: string;
|
||||
section_id: string;
|
||||
section_name: string;
|
||||
section_position: number;
|
||||
position: number;
|
||||
status: PieceUsage['status'];
|
||||
};
|
||||
|
||||
export type LiveUsage = PieceUsage & {
|
||||
sectionId: string;
|
||||
sectionName: string;
|
||||
actualDurationSeconds: number;
|
||||
introHtml: string;
|
||||
researchHtml: string;
|
||||
moderationHtml: string;
|
||||
transitionHtml: string;
|
||||
};
|
||||
|
||||
export type LiveSection = Omit<ShowSection, 'usages'> & { usages: LiveUsage[] };
|
||||
|
||||
export type LiveView = {
|
||||
show: {
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
revision: number;
|
||||
totalDurationSeconds: number;
|
||||
};
|
||||
liveRevision: number;
|
||||
startedAtUtc: string | null;
|
||||
updatedAtUtc: string;
|
||||
presentationUsageId: string | null;
|
||||
presentationRevision: number;
|
||||
sections: LiveSection[];
|
||||
reserve: LiveUsage[];
|
||||
current: LiveUsage | null;
|
||||
next: LiveUsage | null;
|
||||
completedCount: number;
|
||||
totalCount: number;
|
||||
};
|
||||
|
||||
export type PresentationView = {
|
||||
revision: number;
|
||||
content: null | {
|
||||
title: string;
|
||||
html: string;
|
||||
sources: { label: string; url: string; timecodeSeconds: number | null }[];
|
||||
images: { id: string; altText: string }[];
|
||||
};
|
||||
};
|
||||
|
||||
function hashToken(token: string) {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
function nowIso(now?: Date) {
|
||||
return (now ?? new Date()).toISOString();
|
||||
}
|
||||
|
||||
function requireCommandId(commandId: string) {
|
||||
if (!commandId || commandId.length > 200) throw new Error('INVALID_COMMAND');
|
||||
}
|
||||
|
||||
function ensureLiveState(database: Database.Database, showId: string, now: string): LiveStateRow {
|
||||
const show = database.prepare('SELECT 1 FROM shows WHERE id = ?').get(showId);
|
||||
if (!show) throw new Error('NOT_FOUND');
|
||||
database
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO show_live_states (show_id, updated_at_utc)
|
||||
VALUES (?, ?)`
|
||||
)
|
||||
.run(showId, now);
|
||||
return database
|
||||
.prepare('SELECT * FROM show_live_states WHERE show_id = ?')
|
||||
.get(showId) as LiveStateRow;
|
||||
}
|
||||
|
||||
function orderedUsages(database: Database.Database, showId: string): OrderedUsageRow[] {
|
||||
return database
|
||||
.prepare(
|
||||
`SELECT u.id, s.id AS section_id, s.name AS section_name,
|
||||
s.position AS section_position, u.position, u.status
|
||||
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
|
||||
ORDER BY s.position, u.position`
|
||||
)
|
||||
.all(showId) as OrderedUsageRow[];
|
||||
}
|
||||
|
||||
function usageBelongsToShow(database: Database.Database, showId: string, usageId: string) {
|
||||
const row = database
|
||||
.prepare(
|
||||
`SELECT s.is_reserve_bank FROM piece_usages u
|
||||
JOIN show_sections s ON s.id = u.show_section_id
|
||||
WHERE u.id = ? AND s.show_id = ?`
|
||||
)
|
||||
.get(usageId, showId) as { is_reserve_bank: number } | undefined;
|
||||
if (!row || row.is_reserve_bank) throw new Error('NOT_FOUND');
|
||||
}
|
||||
|
||||
function closeActiveRun(database: Database.Database, showId: string, now: string) {
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE live_usage_runs SET ended_at_utc = ?
|
||||
WHERE show_id = ? AND ended_at_utc IS NULL`
|
||||
)
|
||||
.run(now, showId);
|
||||
}
|
||||
|
||||
function activateUsage(database: Database.Database, showId: string, usageId: string, now: string) {
|
||||
usageBelongsToShow(database, showId, usageId);
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE piece_usages SET status = 'open'
|
||||
WHERE status = 'current' AND id != ? AND id IN (
|
||||
SELECT u.id FROM piece_usages u JOIN show_sections s ON s.id = u.show_section_id
|
||||
WHERE s.show_id = ?
|
||||
)`
|
||||
)
|
||||
.run(usageId, showId);
|
||||
database.prepare(`UPDATE piece_usages SET status = 'current' WHERE id = ?`).run(usageId);
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO live_usage_runs (id, show_id, usage_id, started_at_utc)
|
||||
VALUES (?, ?, ?, ?)`
|
||||
)
|
||||
.run(randomUUID(), showId, usageId, now);
|
||||
database
|
||||
.prepare(
|
||||
'UPDATE show_live_states SET current_usage_id = ?, ended_at_utc = NULL WHERE show_id = ?'
|
||||
)
|
||||
.run(usageId, showId);
|
||||
}
|
||||
|
||||
function executeCommand(
|
||||
showId: string,
|
||||
commandId: string,
|
||||
expectedRevision: number,
|
||||
action: string,
|
||||
operation: (database: Database.Database, state: LiveStateRow, now: string) => void,
|
||||
nowValue?: Date
|
||||
) {
|
||||
requireCommandId(commandId);
|
||||
const database = getDatabase();
|
||||
return database.transaction(() => {
|
||||
const duplicate = database
|
||||
.prepare('SELECT result_revision FROM live_commands WHERE show_id = ? AND command_id = ?')
|
||||
.get(showId, commandId) as { result_revision: number } | undefined;
|
||||
if (duplicate) return { revision: duplicate.result_revision, duplicate: true };
|
||||
const now = nowIso(nowValue);
|
||||
const state = ensureLiveState(database, showId, now);
|
||||
const show = database.prepare('SELECT status FROM shows WHERE id = ?').get(showId) as {
|
||||
status: string;
|
||||
};
|
||||
if (show.status === 'completed') throw new Error('SHOW_COMPLETED');
|
||||
if (state.revision !== expectedRevision) throw new Error('CONFLICT');
|
||||
operation(database, state, now);
|
||||
const updated = database
|
||||
.prepare(
|
||||
`UPDATE show_live_states SET revision = revision + 1, updated_at_utc = ?
|
||||
WHERE show_id = ? AND revision = ?`
|
||||
)
|
||||
.run(now, showId, expectedRevision);
|
||||
if (updated.changes !== 1) throw new Error('CONFLICT');
|
||||
const revision = expectedRevision + 1;
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO live_commands (show_id, command_id, action, result_revision, created_at_utc)
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(showId, commandId, action, revision, now);
|
||||
return { revision, duplicate: false };
|
||||
})();
|
||||
}
|
||||
|
||||
function actualDurations(database: Database.Database, showId: string, now: string) {
|
||||
const rows = database
|
||||
.prepare(
|
||||
`SELECT usage_id, started_at_utc, ended_at_utc
|
||||
FROM live_usage_runs WHERE show_id = ?`
|
||||
)
|
||||
.all(showId) as { usage_id: string; started_at_utc: string; ended_at_utc: string | null }[];
|
||||
const durations = new Map<string, number>();
|
||||
for (const row of rows) {
|
||||
const seconds = Math.max(
|
||||
0,
|
||||
Math.floor(
|
||||
(new Date(row.ended_at_utc ?? now).getTime() - new Date(row.started_at_utc).getTime()) /
|
||||
1000
|
||||
)
|
||||
);
|
||||
durations.set(row.usage_id, (durations.get(row.usage_id) ?? 0) + seconds);
|
||||
}
|
||||
return durations;
|
||||
}
|
||||
|
||||
function liveUsage(
|
||||
usage: PieceUsage,
|
||||
sectionId: string,
|
||||
sectionName: string,
|
||||
actualSeconds: number
|
||||
) {
|
||||
return {
|
||||
...usage,
|
||||
sectionId,
|
||||
sectionName,
|
||||
actualDurationSeconds: actualSeconds,
|
||||
introHtml: renderMarkdown(usage.intro),
|
||||
researchHtml: renderMarkdown(usage.research),
|
||||
moderationHtml: renderMarkdown(usage.moderation),
|
||||
transitionHtml: renderMarkdown(usage.transition)
|
||||
};
|
||||
}
|
||||
|
||||
export function getLiveView(showId: string, nowValue?: Date): LiveView {
|
||||
const database = getDatabase();
|
||||
const now = nowIso(nowValue);
|
||||
const state = ensureLiveState(database, showId, now);
|
||||
const show = getShow(showId);
|
||||
if (!show) throw new Error('NOT_FOUND');
|
||||
const durations = actualDurations(database, showId, now);
|
||||
const sections = show.sections.map((section) => ({
|
||||
...section,
|
||||
usages: section.usages.map((usage) =>
|
||||
liveUsage(usage, section.id, section.name, durations.get(usage.id) ?? 0)
|
||||
)
|
||||
}));
|
||||
const reserve = show.reserve.map((usage) =>
|
||||
liveUsage(usage, '', 'Reservebank', durations.get(usage.id) ?? 0)
|
||||
);
|
||||
const usages = sections.flatMap((section) => section.usages);
|
||||
const current = usages.find((usage) => usage.id === state.current_usage_id) ?? null;
|
||||
const currentIndex = current ? usages.findIndex((usage) => usage.id === current.id) : -1;
|
||||
const next =
|
||||
usages.slice(currentIndex + 1).find((usage) => usage.status === 'open') ??
|
||||
(current ? null : (usages.find((usage) => usage.status === 'open') ?? null));
|
||||
return {
|
||||
show: {
|
||||
id: show.id,
|
||||
title: show.title,
|
||||
status: show.status,
|
||||
revision: show.revision,
|
||||
totalDurationSeconds: show.totalDurationSeconds
|
||||
},
|
||||
liveRevision: state.revision,
|
||||
startedAtUtc: state.started_at_utc,
|
||||
updatedAtUtc: state.updated_at_utc,
|
||||
presentationUsageId: state.presentation_usage_id,
|
||||
presentationRevision: state.presentation_revision,
|
||||
sections,
|
||||
reserve,
|
||||
current,
|
||||
next,
|
||||
completedCount: usages.filter((usage) => usage.status === 'done' || usage.status === 'skipped')
|
||||
.length,
|
||||
totalCount: usages.length
|
||||
};
|
||||
}
|
||||
|
||||
export function startOrResumeLive(
|
||||
showId: string,
|
||||
commandId: string,
|
||||
expectedRevision: number,
|
||||
nowValue?: Date
|
||||
) {
|
||||
return executeCommand(
|
||||
showId,
|
||||
commandId,
|
||||
expectedRevision,
|
||||
'start',
|
||||
(database, state, now) => {
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE shows SET status = 'live', updated_at_utc = ?
|
||||
WHERE id = ? AND status != 'completed'`
|
||||
)
|
||||
.run(now, showId);
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE show_live_states SET started_at_utc = COALESCE(started_at_utc, ?), ended_at_utc = NULL
|
||||
WHERE show_id = ?`
|
||||
)
|
||||
.run(now, showId);
|
||||
const activeRun = database
|
||||
.prepare('SELECT 1 FROM live_usage_runs WHERE show_id = ? AND ended_at_utc IS NULL')
|
||||
.get(showId);
|
||||
if (state.current_usage_id && activeRun) return;
|
||||
const current = state.current_usage_id
|
||||
? orderedUsages(database, showId).find((usage) => usage.id === state.current_usage_id)
|
||||
: undefined;
|
||||
const target =
|
||||
current ?? orderedUsages(database, showId).find((usage) => usage.status === 'open');
|
||||
if (target) activateUsage(database, showId, target.id, now);
|
||||
},
|
||||
nowValue
|
||||
);
|
||||
}
|
||||
|
||||
function advanceCurrent(
|
||||
showId: string,
|
||||
commandId: string,
|
||||
expectedRevision: number,
|
||||
status: 'done' | 'skipped',
|
||||
nowValue?: Date
|
||||
) {
|
||||
return executeCommand(
|
||||
showId,
|
||||
commandId,
|
||||
expectedRevision,
|
||||
status,
|
||||
(database, state, now) => {
|
||||
if (!state.current_usage_id) throw new Error('NO_CURRENT');
|
||||
const ordered = orderedUsages(database, showId);
|
||||
const currentIndex = ordered.findIndex((usage) => usage.id === state.current_usage_id);
|
||||
if (currentIndex < 0) throw new Error('NOT_FOUND');
|
||||
closeActiveRun(database, showId, now);
|
||||
database
|
||||
.prepare('UPDATE piece_usages SET status = ? WHERE id = ?')
|
||||
.run(status, state.current_usage_id);
|
||||
const next = ordered.slice(currentIndex + 1).find((usage) => usage.status === 'open');
|
||||
if (next) activateUsage(database, showId, next.id, now);
|
||||
else
|
||||
database
|
||||
.prepare('UPDATE show_live_states SET current_usage_id = NULL WHERE show_id = ?')
|
||||
.run(showId);
|
||||
},
|
||||
nowValue
|
||||
);
|
||||
}
|
||||
|
||||
export function completeCurrent(
|
||||
showId: string,
|
||||
commandId: string,
|
||||
expectedRevision: number,
|
||||
nowValue?: Date
|
||||
) {
|
||||
return advanceCurrent(showId, commandId, expectedRevision, 'done', nowValue);
|
||||
}
|
||||
|
||||
export function skipCurrent(
|
||||
showId: string,
|
||||
commandId: string,
|
||||
expectedRevision: number,
|
||||
nowValue?: Date
|
||||
) {
|
||||
return advanceCurrent(showId, commandId, expectedRevision, 'skipped', nowValue);
|
||||
}
|
||||
|
||||
export function goBack(
|
||||
showId: string,
|
||||
commandId: string,
|
||||
expectedRevision: number,
|
||||
nowValue?: Date
|
||||
) {
|
||||
return executeCommand(
|
||||
showId,
|
||||
commandId,
|
||||
expectedRevision,
|
||||
'back',
|
||||
(database, state, now) => {
|
||||
const ordered = orderedUsages(database, showId);
|
||||
const currentIndex = state.current_usage_id
|
||||
? ordered.findIndex((usage) => usage.id === state.current_usage_id)
|
||||
: ordered.length;
|
||||
const previous = ordered
|
||||
.slice(0, Math.max(0, currentIndex))
|
||||
.reverse()
|
||||
.find((usage) => usage.status === 'done' || usage.status === 'skipped');
|
||||
if (!previous) throw new Error('NO_PREVIOUS');
|
||||
if (state.current_usage_id) {
|
||||
closeActiveRun(database, showId, now);
|
||||
database
|
||||
.prepare(`UPDATE piece_usages SET status = 'open' WHERE id = ?`)
|
||||
.run(state.current_usage_id);
|
||||
}
|
||||
activateUsage(database, showId, previous.id, now);
|
||||
},
|
||||
nowValue
|
||||
);
|
||||
}
|
||||
|
||||
export function presentUsage(
|
||||
showId: string,
|
||||
usageId: string | null,
|
||||
commandId: string,
|
||||
expectedRevision: number,
|
||||
nowValue?: Date
|
||||
) {
|
||||
return executeCommand(
|
||||
showId,
|
||||
commandId,
|
||||
expectedRevision,
|
||||
usageId ? 'present' : 'clear-presentation',
|
||||
(database, _state, now) => {
|
||||
if (usageId) usageBelongsToShow(database, showId, usageId);
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE show_live_states SET presentation_usage_id = ?,
|
||||
presentation_revision = presentation_revision + 1, updated_at_utc = ?
|
||||
WHERE show_id = ?`
|
||||
)
|
||||
.run(usageId, now, showId);
|
||||
},
|
||||
nowValue
|
||||
);
|
||||
}
|
||||
|
||||
export function moveOpenUsage(
|
||||
showId: string,
|
||||
usageId: string,
|
||||
expectedShowRevision: number,
|
||||
input: MovePieceUsageInput
|
||||
) {
|
||||
const database = getDatabase();
|
||||
const usage = database
|
||||
.prepare(
|
||||
`SELECT u.status FROM piece_usages u JOIN show_sections s ON s.id = u.show_section_id
|
||||
WHERE u.id = ? AND s.show_id = ?`
|
||||
)
|
||||
.get(usageId, showId) as { status: PieceUsage['status'] } | undefined;
|
||||
if (!usage) throw new Error('NOT_FOUND');
|
||||
if (usage.status !== 'open') throw new Error('LIVE_ORDER_LOCKED');
|
||||
if (input.sectionId) {
|
||||
const target = database
|
||||
.prepare(
|
||||
`SELECT u.id, u.status FROM piece_usages u JOIN show_sections s ON s.id = u.show_section_id
|
||||
WHERE s.id = ? AND s.show_id = ? AND s.is_reserve_bank = 0 ORDER BY u.position`
|
||||
)
|
||||
.all(input.sectionId, showId) as { id: string; status: PieceUsage['status'] }[];
|
||||
if (!target.length) {
|
||||
const section = database
|
||||
.prepare('SELECT 1 FROM show_sections WHERE id = ? AND show_id = ? AND is_reserve_bank = 0')
|
||||
.get(input.sectionId, showId);
|
||||
if (!section) throw new Error('NOT_FOUND');
|
||||
}
|
||||
const withoutMoved = target.filter((item) => item.id !== usageId);
|
||||
const firstOpenPosition = withoutMoved.findIndex((item) => item.status === 'open');
|
||||
const minimum = firstOpenPosition < 0 ? withoutMoved.length : firstOpenPosition;
|
||||
if (input.position < minimum) throw new Error('LIVE_ORDER_LOCKED');
|
||||
}
|
||||
return movePieceUsage(showId, usageId, expectedShowRevision, input);
|
||||
}
|
||||
|
||||
export function issuePresentationAccess(showId: string) {
|
||||
const database = getDatabase();
|
||||
const now = nowIso();
|
||||
ensureLiveState(database, showId, now);
|
||||
const token = randomBytes(32).toString('base64url');
|
||||
database.transaction(() => {
|
||||
database
|
||||
.prepare(
|
||||
'UPDATE presentation_access_tokens SET revoked_at_utc = ? WHERE show_id = ? AND revoked_at_utc IS NULL'
|
||||
)
|
||||
.run(now, showId);
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO presentation_access_tokens (id, show_id, token_hash, created_at_utc)
|
||||
VALUES (?, ?, ?, ?)`
|
||||
)
|
||||
.run(randomUUID(), showId, hashToken(token), now);
|
||||
})();
|
||||
return token;
|
||||
}
|
||||
|
||||
function presentationShowId(database: Database.Database, token: string) {
|
||||
if (!token || token.length > 200) return null;
|
||||
const row = database
|
||||
.prepare(
|
||||
`SELECT show_id FROM presentation_access_tokens
|
||||
WHERE token_hash = ? AND revoked_at_utc IS NULL`
|
||||
)
|
||||
.get(hashToken(token)) as { show_id: string } | undefined;
|
||||
return row?.show_id ?? null;
|
||||
}
|
||||
|
||||
export function getPresentationView(token: string): PresentationView | null {
|
||||
const database = getDatabase();
|
||||
const showId = presentationShowId(database, token);
|
||||
if (!showId) return null;
|
||||
const state = ensureLiveState(database, showId, nowIso());
|
||||
if (!state.presentation_usage_id) return { revision: state.presentation_revision, content: null };
|
||||
const usage = database
|
||||
.prepare(
|
||||
`SELECT u.title_snapshot, u.presentation_snapshot, u.original_piece_id
|
||||
FROM piece_usages u JOIN show_sections s ON s.id = u.show_section_id
|
||||
WHERE u.id = ? AND s.show_id = ? AND s.is_reserve_bank = 0`
|
||||
)
|
||||
.get(state.presentation_usage_id, showId) as
|
||||
| { title_snapshot: string; presentation_snapshot: string; original_piece_id: string | null }
|
||||
| undefined;
|
||||
if (!usage) return { revision: state.presentation_revision, content: null };
|
||||
const sources = usage.original_piece_id
|
||||
? (database
|
||||
.prepare(
|
||||
`SELECT label, url, timecode_seconds AS timecodeSeconds
|
||||
FROM piece_sources WHERE piece_id = ? ORDER BY position`
|
||||
)
|
||||
.all(usage.original_piece_id) as {
|
||||
label: string;
|
||||
url: string;
|
||||
timecodeSeconds: number | null;
|
||||
}[])
|
||||
: [];
|
||||
const images = usage.original_piece_id
|
||||
? (database
|
||||
.prepare(
|
||||
`SELECT id, alt_text AS altText FROM piece_media
|
||||
WHERE piece_id = ? ORDER BY position`
|
||||
)
|
||||
.all(usage.original_piece_id) as { id: string; altText: string }[])
|
||||
: [];
|
||||
return {
|
||||
revision: state.presentation_revision,
|
||||
content: {
|
||||
title: usage.title_snapshot,
|
||||
html: renderMarkdown(usage.presentation_snapshot),
|
||||
sources,
|
||||
images
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPresentationMedia(token: string, mediaId: string) {
|
||||
const database = getDatabase();
|
||||
const showId = presentationShowId(database, token);
|
||||
if (!showId) return null;
|
||||
const allowed = database
|
||||
.prepare(
|
||||
`SELECT 1 FROM show_live_states l
|
||||
JOIN piece_usages u ON u.id = l.presentation_usage_id
|
||||
JOIN show_sections s ON s.id = u.show_section_id
|
||||
JOIN piece_media m ON m.piece_id = u.original_piece_id
|
||||
WHERE l.show_id = ? AND s.show_id = ? AND m.id = ?`
|
||||
)
|
||||
.get(showId, showId, mediaId);
|
||||
if (!allowed) return null;
|
||||
return getMediaFile(mediaId);
|
||||
}
|
||||
Reference in new issue
Block a user