Release 0.4.0
This commit is contained in:
1 parent
7590be66a5
commit
4042fe6a64
24 files changed
+3570
-290
No files matched your search
@@ -47,12 +47,28 @@ describe('database setup', () => {
|
||||
(SELECT count(*) FROM schema_migrations) AS migrations,
|
||||
(SELECT count(*) FROM pieces) AS pieces,
|
||||
(SELECT count(*) FROM shows) AS shows,
|
||||
(SELECT count(*) FROM piece_usages) AS usages`
|
||||
(SELECT count(*) FROM piece_usages) AS usages,
|
||||
(SELECT count(*) FROM show_templates) AS templates,
|
||||
(SELECT count(*) FROM show_sections WHERE is_reserve_bank = 1) AS reserve_banks`
|
||||
)
|
||||
.get() as { migrations: number; pieces: number; shows: number; usages: number };
|
||||
.get() as {
|
||||
migrations: number;
|
||||
pieces: number;
|
||||
shows: number;
|
||||
usages: number;
|
||||
templates: number;
|
||||
reserve_banks: number;
|
||||
};
|
||||
database.close();
|
||||
|
||||
expect(counts).toEqual({ migrations: 3, pieces: 6, shows: 1, usages: 3 });
|
||||
expect(counts).toEqual({
|
||||
migrations: 4,
|
||||
pieces: 6,
|
||||
shows: 2,
|
||||
usages: 5,
|
||||
templates: 2,
|
||||
reserve_banks: 2
|
||||
});
|
||||
expect(databasePath).not.toBe(path.resolve('.data/sendewerk.sqlite'));
|
||||
});
|
||||
|
||||
|
||||
+49
-94
@@ -1,4 +1,5 @@
|
||||
import { getDatabase } from './database.js';
|
||||
import { getShow as getShowById, listShows } from './shows.js';
|
||||
|
||||
export type PreviewPiece = {
|
||||
id: string;
|
||||
@@ -104,109 +105,63 @@ export function getPieces(): PreviewPiece[] {
|
||||
}));
|
||||
}
|
||||
|
||||
type ShowRow = {
|
||||
id: string;
|
||||
title: string;
|
||||
starts_at_utc: string;
|
||||
twitch_title: string;
|
||||
announcement_text: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
type SectionRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string | null;
|
||||
category_color: string | null;
|
||||
explicit_duration_seconds: number | null;
|
||||
};
|
||||
|
||||
type UsageRow = {
|
||||
id: string;
|
||||
show_section_id: string;
|
||||
title_snapshot: string;
|
||||
duration_seconds: number;
|
||||
status: PreviewUsage['status'];
|
||||
intro_snapshot: string;
|
||||
moderation_snapshot: string;
|
||||
transition_snapshot: string;
|
||||
is_reserve: number;
|
||||
};
|
||||
|
||||
export function getShow(): PreviewShow {
|
||||
const database = getDatabase();
|
||||
const show = database
|
||||
.prepare(
|
||||
`SELECT id, title, starts_at_utc, twitch_title, announcement_text, status
|
||||
FROM shows ORDER BY starts_at_utc DESC LIMIT 1`
|
||||
)
|
||||
.get() as ShowRow | undefined;
|
||||
|
||||
export function getShow(showId?: string): PreviewShow {
|
||||
const latest = showId ? null : listShows().find((show) => show.startsAtUtc !== null);
|
||||
const show = getShowById(showId ?? latest?.id ?? '');
|
||||
if (!show) throw new Error('No preview show found. Run npm run db:seed.');
|
||||
|
||||
const sectionRows = database
|
||||
.prepare(
|
||||
`SELECT s.id, s.name, c.name AS category, c.color AS category_color,
|
||||
s.explicit_duration_seconds
|
||||
FROM show_sections s
|
||||
LEFT JOIN section_categories c ON c.id = s.category_id
|
||||
WHERE s.show_id = ? ORDER BY s.position`
|
||||
)
|
||||
.all(show.id) as SectionRow[];
|
||||
const usageRows = database
|
||||
.prepare(
|
||||
`SELECT id, show_section_id, title_snapshot, duration_seconds, status,
|
||||
intro_snapshot, moderation_snapshot, transition_snapshot, is_reserve
|
||||
FROM piece_usages WHERE show_section_id IN (
|
||||
SELECT id FROM show_sections WHERE show_id = ?
|
||||
) ORDER BY position`
|
||||
)
|
||||
.all(show.id) as UsageRow[];
|
||||
|
||||
const sections = sectionRows.map((section) => {
|
||||
const usages = usageRows
|
||||
.filter((usage) => usage.show_section_id === section.id)
|
||||
.map((usage) => ({
|
||||
const sections: PreviewSection[] = show.sections.map((section) => ({
|
||||
id: section.id,
|
||||
name: section.name,
|
||||
category: section.category ?? 'Offen',
|
||||
categoryColor: section.categoryColor ?? '#8b8f98',
|
||||
durationSeconds: section.durationSeconds,
|
||||
usages: section.usages.map((usage) => ({
|
||||
id: usage.id,
|
||||
title: usage.title,
|
||||
durationSeconds: usage.durationSeconds,
|
||||
status: usage.status,
|
||||
intro: usage.intro,
|
||||
moderation: usage.moderation,
|
||||
transition: usage.transition,
|
||||
isReserve: false
|
||||
}))
|
||||
}));
|
||||
const reserveHost = sections[Math.min(1, sections.length - 1)];
|
||||
if (reserveHost) {
|
||||
reserveHost.usages.push(
|
||||
...show.reserve.map((usage) => ({
|
||||
id: usage.id,
|
||||
title: usage.title_snapshot,
|
||||
durationSeconds: usage.duration_seconds,
|
||||
title: usage.title,
|
||||
durationSeconds: usage.durationSeconds,
|
||||
status: usage.status,
|
||||
intro: usage.intro_snapshot,
|
||||
moderation: usage.moderation_snapshot,
|
||||
transition: usage.transition_snapshot,
|
||||
isReserve: usage.is_reserve === 1
|
||||
}));
|
||||
const plannedUsages = usages.filter((usage) => !usage.isReserve);
|
||||
return {
|
||||
id: section.id,
|
||||
name: section.name,
|
||||
category: section.category ?? 'Offen',
|
||||
categoryColor: section.category_color ?? '#8b8f98',
|
||||
durationSeconds:
|
||||
section.explicit_duration_seconds ??
|
||||
plannedUsages.reduce((total, usage) => total + usage.durationSeconds, 0),
|
||||
usages
|
||||
};
|
||||
});
|
||||
intro: usage.intro,
|
||||
moderation: usage.moderation,
|
||||
transition: usage.transition,
|
||||
isReserve: true
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
id: show.id,
|
||||
title: show.title,
|
||||
startsAtUtc: show.starts_at_utc,
|
||||
startsAtBerlin: new Intl.DateTimeFormat('de-DE', {
|
||||
timeZone: 'Europe/Berlin',
|
||||
weekday: 'short',
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(new Date(show.starts_at_utc)),
|
||||
twitchTitle: show.twitch_title,
|
||||
announcement: show.announcement_text,
|
||||
startsAtUtc: show.startsAtUtc ?? '',
|
||||
startsAtBerlin: show.startsAtUtc
|
||||
? new Intl.DateTimeFormat('de-DE', {
|
||||
timeZone: 'Europe/Berlin',
|
||||
weekday: 'short',
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(new Date(show.startsAtUtc))
|
||||
: 'Termin offen',
|
||||
twitchTitle: show.twitchTitle,
|
||||
announcement: show.announcementText,
|
||||
status: show.status,
|
||||
sections,
|
||||
totalDurationSeconds: sections.reduce((total, section) => total + section.durationSeconds, 0)
|
||||
totalDurationSeconds: show.totalDurationSeconds
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { getPiece } from './content.js';
|
||||
import { closeDatabase, getDatabase } from './database.js';
|
||||
import {
|
||||
addPieceUsage,
|
||||
addShowSection,
|
||||
createShow,
|
||||
getShow,
|
||||
listShowTemplates,
|
||||
movePieceUsage,
|
||||
moveShowSection,
|
||||
removePieceUsage,
|
||||
removeShowSection,
|
||||
updatePieceUsage,
|
||||
updateShowSection,
|
||||
type PieceUsage
|
||||
} from './shows.js';
|
||||
|
||||
let directory: string;
|
||||
|
||||
beforeEach(() => {
|
||||
directory = mkdtempSync(path.join(tmpdir(), 'sendewerk-shows-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 });
|
||||
});
|
||||
|
||||
function editedUsage(usage: PieceUsage, overrides: Partial<PieceUsage> = {}) {
|
||||
const edited = { ...usage, ...overrides };
|
||||
return {
|
||||
title: edited.title,
|
||||
intro: edited.intro,
|
||||
research: edited.research,
|
||||
moderation: edited.moderation,
|
||||
transition: edited.transition,
|
||||
presentation: edited.presentation,
|
||||
ownNotes: edited.ownNotes,
|
||||
durationSeconds: edited.durationSeconds,
|
||||
status: edited.status
|
||||
};
|
||||
}
|
||||
|
||||
describe('show builder data layer', () => {
|
||||
it('creates blank and templated shows with independent sections and reserve banks', () => {
|
||||
const templatesBefore = listShowTemplates();
|
||||
expect(
|
||||
templatesBefore.find((template) => template.id === 'template-default')?.sections
|
||||
).toHaveLength(5);
|
||||
expect(templatesBefore.find((template) => template.id === 'template-games')?.name).toBe(
|
||||
'Spieleabend'
|
||||
);
|
||||
|
||||
const blank = createShow({ title: null, startsAtUtc: null, templateId: null });
|
||||
expect(getShow(blank.id)).toMatchObject({
|
||||
title: 'Neue Sendung',
|
||||
startsAtUtc: null,
|
||||
sections: [],
|
||||
reserve: []
|
||||
});
|
||||
const reserveCount = getDatabase()
|
||||
.prepare(
|
||||
'SELECT count(*) AS count FROM show_sections WHERE show_id = ? AND is_reserve_bank = 1'
|
||||
)
|
||||
.get(blank.id) as { count: number };
|
||||
expect(reserveCount.count).toBe(1);
|
||||
|
||||
const templated = createShow({
|
||||
title: 'Vorlagenkopie',
|
||||
startsAtUtc: '2027-01-08T19:00:00Z',
|
||||
templateId: 'template-default'
|
||||
});
|
||||
const copy = getShow(templated.id)!;
|
||||
expect(copy.sections.map((section) => section.name)).toEqual([
|
||||
'Start',
|
||||
'JC / Reaction',
|
||||
'Spiel 1',
|
||||
'JC',
|
||||
'Spiel 2'
|
||||
]);
|
||||
updateShowSection(templated.id, copy.sections[0].id, copy.revision, {
|
||||
name: 'Eigener Start',
|
||||
categoryId: 'cat-jc',
|
||||
explicitDurationSeconds: 420
|
||||
});
|
||||
expect(listShowTemplates()).toEqual(templatesBefore);
|
||||
});
|
||||
|
||||
it('snapshots a ready piece repeatedly and keeps usage edits independent from its original', () => {
|
||||
const created = createShow({
|
||||
title: 'Doppelte Verwendung',
|
||||
startsAtUtc: null,
|
||||
templateId: null
|
||||
});
|
||||
const section = addShowSection(created.id, created.revision, {
|
||||
name: 'Fundstücke',
|
||||
categoryId: 'cat-reaction',
|
||||
explicitDurationSeconds: null
|
||||
});
|
||||
const first = addPieceUsage(created.id, section.revision, {
|
||||
pieceId: 'piece-console',
|
||||
sectionId: section.id
|
||||
});
|
||||
const second = addPieceUsage(created.id, first.revision, {
|
||||
pieceId: 'piece-console',
|
||||
sectionId: section.id
|
||||
});
|
||||
const usages = getShow(created.id)!.sections[0].usages;
|
||||
const firstSaved = updatePieceUsage(
|
||||
created.id,
|
||||
first.id,
|
||||
second.revision,
|
||||
editedUsage(usages[0], {
|
||||
title: 'Konsole als Auftakt',
|
||||
durationSeconds: 300,
|
||||
ownNotes: 'Kurbel zuerst zeigen',
|
||||
research: 'Eigene Recherchekopie'
|
||||
})
|
||||
);
|
||||
updatePieceUsage(
|
||||
created.id,
|
||||
second.id,
|
||||
firstSaved.revision,
|
||||
editedUsage(usages[1], { title: 'Konsole als Rückblick', durationSeconds: 900 })
|
||||
);
|
||||
|
||||
const show = getShow(created.id)!;
|
||||
expect(show.sections[0].usages).toMatchObject([
|
||||
{
|
||||
title: 'Konsole als Auftakt',
|
||||
durationSeconds: 300,
|
||||
ownNotes: 'Kurbel zuerst zeigen',
|
||||
research: 'Eigene Recherchekopie'
|
||||
},
|
||||
{ title: 'Konsole als Rückblick', durationSeconds: 900 }
|
||||
]);
|
||||
expect(getPiece('piece-console')).toMatchObject({
|
||||
title: 'Die kleine Konsole mit dem großen Dreh',
|
||||
durationSeconds: 480,
|
||||
researchText:
|
||||
'## Recherche\n\n- Herstellerangaben gegen unabhängige Tests prüfen\n- Fokus auf Bedienidee statt Leistungsdaten'
|
||||
});
|
||||
});
|
||||
|
||||
it('persists normalized section and usage reordering, moving and removal', () => {
|
||||
const created = createShow({ title: 'Sortierung', startsAtUtc: null, templateId: null });
|
||||
const firstSection = addShowSection(created.id, created.revision, {
|
||||
name: 'Erster Block',
|
||||
categoryId: null,
|
||||
explicitDurationSeconds: null
|
||||
});
|
||||
const secondSection = addShowSection(created.id, firstSection.revision, {
|
||||
name: 'Zweiter Block',
|
||||
categoryId: null,
|
||||
explicitDurationSeconds: null
|
||||
});
|
||||
const firstUsage = addPieceUsage(created.id, secondSection.revision, {
|
||||
pieceId: 'piece-console',
|
||||
sectionId: firstSection.id
|
||||
});
|
||||
const secondUsage = addPieceUsage(created.id, firstUsage.revision, {
|
||||
pieceId: 'piece-effects',
|
||||
sectionId: firstSection.id
|
||||
});
|
||||
const thirdUsage = addPieceUsage(created.id, secondUsage.revision, {
|
||||
pieceId: 'piece-pixels',
|
||||
sectionId: firstSection.id
|
||||
});
|
||||
const reordered = movePieceUsage(created.id, thirdUsage.id, thirdUsage.revision, {
|
||||
sectionId: firstSection.id,
|
||||
position: 0
|
||||
});
|
||||
const reserved = movePieceUsage(created.id, firstUsage.id, reordered.revision, {
|
||||
sectionId: null,
|
||||
position: 0
|
||||
});
|
||||
const plannedAgain = movePieceUsage(created.id, firstUsage.id, reserved.revision, {
|
||||
sectionId: secondSection.id,
|
||||
position: 0
|
||||
});
|
||||
const removedUsage = removePieceUsage(created.id, secondUsage.id, plannedAgain.revision);
|
||||
const movedSection = moveShowSection(created.id, secondSection.id, 0, removedUsage.revision);
|
||||
removeShowSection(created.id, firstSection.id, movedSection.revision);
|
||||
|
||||
const show = getShow(created.id)!;
|
||||
expect(show.sections.map((section) => [section.name, section.position])).toEqual([
|
||||
['Zweiter Block', 0]
|
||||
]);
|
||||
expect(show.sections[0].usages.map((usage) => [usage.id, usage.position])).toEqual([
|
||||
[firstUsage.id, 0]
|
||||
]);
|
||||
expect(show.reserve).toEqual([]);
|
||||
expect(getDatabase().pragma('foreign_key_check')).toEqual([]);
|
||||
});
|
||||
|
||||
it('uses explicit section durations once and excludes the show-wide reserve', () => {
|
||||
const created = createShow({ title: 'Dauer', startsAtUtc: null, templateId: null });
|
||||
const section = addShowSection(created.id, created.revision, {
|
||||
name: 'Fester Block',
|
||||
categoryId: null,
|
||||
explicitDurationSeconds: 600
|
||||
});
|
||||
const planned = addPieceUsage(created.id, section.revision, {
|
||||
pieceId: 'piece-effects',
|
||||
sectionId: section.id
|
||||
});
|
||||
addPieceUsage(created.id, planned.revision, {
|
||||
pieceId: 'piece-pixels',
|
||||
sectionId: null
|
||||
});
|
||||
|
||||
const explicit = getShow(created.id)!;
|
||||
expect(explicit.sections[0].durationSeconds).toBe(600);
|
||||
expect(explicit.totalDurationSeconds).toBe(600);
|
||||
expect(explicit.reserve[0].durationSeconds).toBe(540);
|
||||
|
||||
updateShowSection(created.id, section.id, explicit.revision, {
|
||||
name: 'Summierter Block',
|
||||
categoryId: null,
|
||||
explicitDurationSeconds: null
|
||||
});
|
||||
expect(getShow(created.id)).toMatchObject({
|
||||
totalDurationSeconds: 720,
|
||||
sections: [{ durationSeconds: 720 }]
|
||||
});
|
||||
});
|
||||
|
||||
it('rolls stale and invalid aggregate mutations back and rejects completed shows', () => {
|
||||
const created = createShow({ title: 'Konflikt', startsAtUtc: null, templateId: null });
|
||||
const saved = addShowSection(created.id, created.revision, {
|
||||
name: 'Gültig',
|
||||
categoryId: null,
|
||||
explicitDurationSeconds: null
|
||||
});
|
||||
expect(() =>
|
||||
addShowSection(created.id, created.revision, {
|
||||
name: 'Veraltet',
|
||||
categoryId: null,
|
||||
explicitDurationSeconds: null
|
||||
})
|
||||
).toThrow('CONFLICT');
|
||||
expect(getShow(created.id)).toMatchObject({
|
||||
revision: saved.revision,
|
||||
sections: [{ name: 'Gültig' }]
|
||||
});
|
||||
|
||||
expect(() => removeShowSection(created.id, 'missing', saved.revision)).toThrow('NOT_FOUND');
|
||||
expect(getShow(created.id)?.revision).toBe(saved.revision);
|
||||
getDatabase().prepare("UPDATE shows SET status = 'completed' WHERE id = ?").run(created.id);
|
||||
expect(() =>
|
||||
addPieceUsage(created.id, saved.revision, {
|
||||
pieceId: 'piece-console',
|
||||
sectionId: saved.id
|
||||
})
|
||||
).toThrow('SHOW_COMPLETED');
|
||||
expect(getShow(created.id)).toMatchObject({
|
||||
revision: saved.revision,
|
||||
sections: [{ usages: [] }]
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,796 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type Database from 'better-sqlite3';
|
||||
import { getDatabase } from './database.js';
|
||||
|
||||
export const SHOW_STATUSES = ['draft', 'ready', 'live', 'completed'] as const;
|
||||
export const USAGE_STATUSES = ['open', 'current', 'done', 'skipped'] as const;
|
||||
|
||||
export type ShowStatus = (typeof SHOW_STATUSES)[number];
|
||||
export type EditableShowStatus = Exclude<ShowStatus, 'completed'>;
|
||||
export type UsageStatus = (typeof USAGE_STATUSES)[number];
|
||||
|
||||
export type ShowListItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
startsAtUtc: string | null;
|
||||
status: ShowStatus;
|
||||
revision: number;
|
||||
totalDurationSeconds: number;
|
||||
sectionCount: number;
|
||||
};
|
||||
|
||||
export type ShowTemplateSection = {
|
||||
id: string;
|
||||
name: string;
|
||||
categoryId: string | null;
|
||||
position: number;
|
||||
explicitDurationSeconds: number | null;
|
||||
};
|
||||
|
||||
export type ShowTemplate = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
sections: ShowTemplateSection[];
|
||||
};
|
||||
|
||||
export type PieceUsage = {
|
||||
id: string;
|
||||
originalPieceId: string | null;
|
||||
position: number;
|
||||
status: UsageStatus;
|
||||
durationSeconds: number;
|
||||
title: string;
|
||||
intro: string;
|
||||
research: string;
|
||||
moderation: string;
|
||||
transition: string;
|
||||
presentation: string;
|
||||
ownNotes: string;
|
||||
};
|
||||
|
||||
export type ShowSection = {
|
||||
id: string;
|
||||
name: string;
|
||||
categoryId: string | null;
|
||||
category: string | null;
|
||||
categoryColor: string | null;
|
||||
position: number;
|
||||
explicitDurationSeconds: number | null;
|
||||
durationSeconds: number;
|
||||
usages: PieceUsage[];
|
||||
};
|
||||
|
||||
export type Show = {
|
||||
id: string;
|
||||
title: string;
|
||||
startsAtUtc: string | null;
|
||||
twitchTitle: string;
|
||||
announcementText: string;
|
||||
status: ShowStatus;
|
||||
revision: number;
|
||||
createdAtUtc: string;
|
||||
updatedAtUtc: string;
|
||||
sections: ShowSection[];
|
||||
reserve: PieceUsage[];
|
||||
totalDurationSeconds: number;
|
||||
};
|
||||
|
||||
export type CreateShowInput = {
|
||||
title: string | null;
|
||||
startsAtUtc: string | null;
|
||||
templateId: string | null;
|
||||
};
|
||||
|
||||
export type ShowMetadataInput = {
|
||||
title: string;
|
||||
startsAtUtc: string | null;
|
||||
twitchTitle: string;
|
||||
announcementText: string;
|
||||
status: EditableShowStatus;
|
||||
};
|
||||
|
||||
export type ShowSectionInput = {
|
||||
name: string;
|
||||
categoryId: string | null;
|
||||
explicitDurationSeconds: number | null;
|
||||
position?: number;
|
||||
};
|
||||
|
||||
export type PieceUsageInput = {
|
||||
pieceId: string;
|
||||
sectionId: string | null;
|
||||
position?: number;
|
||||
};
|
||||
|
||||
export type PieceUsageUpdate = {
|
||||
title: string;
|
||||
intro: string;
|
||||
research: string;
|
||||
moderation: string;
|
||||
transition: string;
|
||||
presentation: string;
|
||||
ownNotes: string;
|
||||
durationSeconds: number;
|
||||
status: UsageStatus;
|
||||
};
|
||||
|
||||
export type MovePieceUsageInput = {
|
||||
sectionId: string | null;
|
||||
position: number;
|
||||
};
|
||||
|
||||
export type MutationResult = { revision: number; updatedAtUtc: string };
|
||||
export type CreatedMutationResult = MutationResult & { id: string };
|
||||
|
||||
type ShowRow = {
|
||||
id: string;
|
||||
title: string;
|
||||
starts_at_utc: string | null;
|
||||
twitch_title: string;
|
||||
announcement_text: string;
|
||||
status: ShowStatus;
|
||||
revision: number;
|
||||
created_at_utc: string;
|
||||
updated_at_utc: string;
|
||||
};
|
||||
|
||||
type SectionRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
category_id: string | null;
|
||||
category: string | null;
|
||||
category_color: string | null;
|
||||
position: number;
|
||||
explicit_duration_seconds: number | null;
|
||||
is_reserve_bank: number;
|
||||
};
|
||||
|
||||
type UsageRow = {
|
||||
id: string;
|
||||
show_section_id: string;
|
||||
original_piece_id: string | null;
|
||||
position: number;
|
||||
status: UsageStatus;
|
||||
duration_seconds: number;
|
||||
title_snapshot: string;
|
||||
intro_snapshot: string;
|
||||
research_snapshot: string;
|
||||
moderation_snapshot: string;
|
||||
transition_snapshot: string;
|
||||
presentation_snapshot: string;
|
||||
own_notes: string;
|
||||
};
|
||||
|
||||
function cleanText(value: unknown, maximum: number, field: string, required = false): string {
|
||||
const text = String(value ?? '').trim();
|
||||
if (text.length > maximum || (required && !text)) {
|
||||
throw new Error(`INVALID_${field.toUpperCase()}`);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function validDuration(value: number | null): number | null {
|
||||
if (value === null) return null;
|
||||
if (!Number.isInteger(value) || value < 0 || value > 86_400) {
|
||||
throw new Error('INVALID_DURATION');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function validPosition(value: number, maximum: number): number {
|
||||
if (!Number.isInteger(value) || value < 0) throw new Error('INVALID_POSITION');
|
||||
return Math.min(value, maximum);
|
||||
}
|
||||
|
||||
function validTimestamp(value: string | null): string | null {
|
||||
if (value === null) return null;
|
||||
if (!value.endsWith('Z')) throw new Error('INVALID_TIMESTAMP');
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.valueOf())) throw new Error('INVALID_TIMESTAMP');
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
function requireRevision(expectedRevision: number): void {
|
||||
if (!Number.isInteger(expectedRevision) || expectedRevision < 1) {
|
||||
throw new Error('INVALID_REVISION');
|
||||
}
|
||||
}
|
||||
|
||||
function requireCategory(database: Database.Database, categoryId: string | null): void {
|
||||
if (!categoryId) return;
|
||||
if (!database.prepare('SELECT 1 FROM section_categories WHERE id = ?').get(categoryId)) {
|
||||
throw new Error('INVALID_CATEGORY');
|
||||
}
|
||||
}
|
||||
|
||||
function bumpShow(
|
||||
database: Database.Database,
|
||||
showId: string,
|
||||
expectedRevision: number
|
||||
): MutationResult {
|
||||
requireRevision(expectedRevision);
|
||||
const updatedAtUtc = new Date().toISOString();
|
||||
const result = database
|
||||
.prepare(
|
||||
`UPDATE shows SET revision = revision + 1, updated_at_utc = ?
|
||||
WHERE id = ? AND revision = ? AND status != 'completed'`
|
||||
)
|
||||
.run(updatedAtUtc, showId, expectedRevision);
|
||||
if (result.changes !== 1) {
|
||||
const show = database.prepare('SELECT status FROM shows WHERE id = ?').get(showId) as
|
||||
{ status: ShowStatus } | undefined;
|
||||
if (!show) throw new Error('NOT_FOUND');
|
||||
if (show.status === 'completed') throw new Error('SHOW_COMPLETED');
|
||||
throw new Error('CONFLICT');
|
||||
}
|
||||
return { revision: expectedRevision + 1, updatedAtUtc };
|
||||
}
|
||||
|
||||
function reserveSectionId(database: Database.Database, showId: string): string {
|
||||
const row = database
|
||||
.prepare('SELECT id FROM show_sections WHERE show_id = ? AND is_reserve_bank = 1')
|
||||
.get(showId) as { id: string } | undefined;
|
||||
if (!row) throw new Error('INVALID_RESERVE_BANK');
|
||||
return row.id;
|
||||
}
|
||||
|
||||
function sectionBelongsToShow(
|
||||
database: Database.Database,
|
||||
showId: string,
|
||||
sectionId: string,
|
||||
allowReserve = false
|
||||
): void {
|
||||
const row = database
|
||||
.prepare('SELECT is_reserve_bank FROM show_sections WHERE id = ? AND show_id = ?')
|
||||
.get(sectionId, showId) as { is_reserve_bank: number } | undefined;
|
||||
if (!row || (!allowReserve && row.is_reserve_bank === 1)) throw new Error('NOT_FOUND');
|
||||
}
|
||||
|
||||
function usageSection(
|
||||
database: Database.Database,
|
||||
showId: string,
|
||||
usageId: string
|
||||
): { sectionId: string } {
|
||||
const row = database
|
||||
.prepare(
|
||||
`SELECT u.show_section_id AS sectionId 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 { sectionId: string } | undefined;
|
||||
if (!row) throw new Error('NOT_FOUND');
|
||||
return row;
|
||||
}
|
||||
|
||||
function normalizePositions(
|
||||
database: Database.Database,
|
||||
table: 'show_sections' | 'piece_usages',
|
||||
ids: string[]
|
||||
): void {
|
||||
if (!ids.length) return;
|
||||
const placeholders = ids.map(() => '?').join(', ');
|
||||
const maximum = database
|
||||
.prepare(
|
||||
`SELECT COALESCE(max(position), -1) AS maximum FROM ${table} WHERE id IN (${placeholders})`
|
||||
)
|
||||
.get(...ids) as { maximum: number };
|
||||
const offset = maximum.maximum + ids.length + 1;
|
||||
database
|
||||
.prepare(`UPDATE ${table} SET position = position + ? WHERE id IN (${placeholders})`)
|
||||
.run(offset, ...ids);
|
||||
const update = database.prepare(`UPDATE ${table} SET position = ? WHERE id = ?`);
|
||||
ids.forEach((id, position) => update.run(position, id));
|
||||
}
|
||||
|
||||
function usageFromRow(row: UsageRow): PieceUsage {
|
||||
return {
|
||||
id: row.id,
|
||||
originalPieceId: row.original_piece_id,
|
||||
position: row.position,
|
||||
status: row.status,
|
||||
durationSeconds: row.duration_seconds,
|
||||
title: row.title_snapshot,
|
||||
intro: row.intro_snapshot,
|
||||
research: row.research_snapshot,
|
||||
moderation: row.moderation_snapshot,
|
||||
transition: row.transition_snapshot,
|
||||
presentation: row.presentation_snapshot,
|
||||
ownNotes: row.own_notes
|
||||
};
|
||||
}
|
||||
|
||||
export function listShows(): ShowListItem[] {
|
||||
return (
|
||||
getDatabase()
|
||||
.prepare(
|
||||
`SELECT sh.id, sh.title, sh.starts_at_utc, sh.status, sh.revision,
|
||||
count(DISTINCT CASE WHEN s.is_reserve_bank = 0 THEN s.id END) AS section_count,
|
||||
COALESCE(sum(CASE WHEN s.is_reserve_bank = 0 THEN
|
||||
COALESCE(s.explicit_duration_seconds,
|
||||
(SELECT sum(u.duration_seconds) FROM piece_usages u WHERE u.show_section_id = s.id))
|
||||
ELSE 0 END), 0) AS total_duration_seconds
|
||||
FROM shows sh LEFT JOIN show_sections s ON s.show_id = sh.id
|
||||
GROUP BY sh.id
|
||||
ORDER BY sh.starts_at_utc IS NULL, sh.starts_at_utc DESC, sh.created_at_utc DESC`
|
||||
)
|
||||
.all() as {
|
||||
id: string;
|
||||
title: string;
|
||||
starts_at_utc: string | null;
|
||||
status: ShowStatus;
|
||||
revision: number;
|
||||
section_count: number;
|
||||
total_duration_seconds: number;
|
||||
}[]
|
||||
).map((row) => ({
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
startsAtUtc: row.starts_at_utc,
|
||||
status: row.status,
|
||||
revision: row.revision,
|
||||
totalDurationSeconds: row.total_duration_seconds,
|
||||
sectionCount: row.section_count
|
||||
}));
|
||||
}
|
||||
|
||||
export function listShowTemplates(): ShowTemplate[] {
|
||||
const database = getDatabase();
|
||||
const templates = database
|
||||
.prepare('SELECT id, name, description FROM show_templates ORDER BY name')
|
||||
.all() as Omit<ShowTemplate, 'sections'>[];
|
||||
const sectionStatement = database.prepare(
|
||||
`SELECT id, name, category_id, position, explicit_duration_seconds
|
||||
FROM show_template_sections WHERE template_id = ? ORDER BY position`
|
||||
);
|
||||
return templates.map((template) => ({
|
||||
...template,
|
||||
sections: (
|
||||
sectionStatement.all(template.id) as {
|
||||
id: string;
|
||||
name: string;
|
||||
category_id: string | null;
|
||||
position: number;
|
||||
explicit_duration_seconds: number | null;
|
||||
}[]
|
||||
).map((section) => ({
|
||||
id: section.id,
|
||||
name: section.name,
|
||||
categoryId: section.category_id,
|
||||
position: section.position,
|
||||
explicitDurationSeconds: section.explicit_duration_seconds
|
||||
}))
|
||||
}));
|
||||
}
|
||||
|
||||
export function getShow(id: string): Show | null {
|
||||
const database = getDatabase();
|
||||
const show = database
|
||||
.prepare(
|
||||
`SELECT id, title, starts_at_utc, twitch_title, announcement_text, status, revision,
|
||||
created_at_utc, updated_at_utc FROM shows WHERE id = ?`
|
||||
)
|
||||
.get(id) as ShowRow | undefined;
|
||||
if (!show) return null;
|
||||
const sectionRows = database
|
||||
.prepare(
|
||||
`SELECT s.id, s.name, s.category_id, c.name AS category, c.color AS category_color,
|
||||
s.position, s.explicit_duration_seconds, s.is_reserve_bank
|
||||
FROM show_sections s LEFT JOIN section_categories c ON c.id = s.category_id
|
||||
WHERE s.show_id = ? ORDER BY s.position`
|
||||
)
|
||||
.all(id) as SectionRow[];
|
||||
const usageRows = database
|
||||
.prepare(
|
||||
`SELECT u.id, u.show_section_id, u.original_piece_id, u.position, u.status,
|
||||
u.duration_seconds, u.title_snapshot, u.intro_snapshot, u.research_snapshot,
|
||||
u.moderation_snapshot, u.transition_snapshot, u.presentation_snapshot, u.own_notes
|
||||
FROM piece_usages u JOIN show_sections s ON s.id = u.show_section_id
|
||||
WHERE s.show_id = ? ORDER BY s.position, u.position`
|
||||
)
|
||||
.all(id) as UsageRow[];
|
||||
const usagesBySection = new Map<string, PieceUsage[]>();
|
||||
for (const row of usageRows) {
|
||||
const usages = usagesBySection.get(row.show_section_id) ?? [];
|
||||
usages.push(usageFromRow(row));
|
||||
usagesBySection.set(row.show_section_id, usages);
|
||||
}
|
||||
const sections = sectionRows
|
||||
.filter((row) => row.is_reserve_bank === 0)
|
||||
.map((row) => {
|
||||
const usages = usagesBySection.get(row.id) ?? [];
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
categoryId: row.category_id,
|
||||
category: row.category,
|
||||
categoryColor: row.category_color,
|
||||
position: row.position,
|
||||
explicitDurationSeconds: row.explicit_duration_seconds,
|
||||
durationSeconds:
|
||||
row.explicit_duration_seconds ??
|
||||
usages.reduce((total, usage) => total + usage.durationSeconds, 0),
|
||||
usages
|
||||
};
|
||||
});
|
||||
const reserveRow = sectionRows.find((row) => row.is_reserve_bank === 1);
|
||||
return {
|
||||
id: show.id,
|
||||
title: show.title,
|
||||
startsAtUtc: show.starts_at_utc,
|
||||
twitchTitle: show.twitch_title,
|
||||
announcementText: show.announcement_text,
|
||||
status: show.status,
|
||||
revision: show.revision,
|
||||
createdAtUtc: show.created_at_utc,
|
||||
updatedAtUtc: show.updated_at_utc,
|
||||
sections,
|
||||
reserve: reserveRow ? (usagesBySection.get(reserveRow.id) ?? []) : [],
|
||||
totalDurationSeconds: sections.reduce((total, section) => total + section.durationSeconds, 0)
|
||||
};
|
||||
}
|
||||
|
||||
export function createShow(input: CreateShowInput): CreatedMutationResult {
|
||||
const database = getDatabase();
|
||||
const id = randomUUID();
|
||||
const title = cleanText(input.title ?? 'Neue Sendung', 200, 'title', true);
|
||||
const startsAtUtc = validTimestamp(input.startsAtUtc);
|
||||
const now = new Date().toISOString();
|
||||
const create = database.transaction(() => {
|
||||
if (
|
||||
input.templateId &&
|
||||
!database.prepare('SELECT 1 FROM show_templates WHERE id = ?').get(input.templateId)
|
||||
) {
|
||||
throw new Error('INVALID_TEMPLATE');
|
||||
}
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO shows (id, title, starts_at_utc, twitch_title, announcement_text,
|
||||
status, revision, created_at_utc, updated_at_utc)
|
||||
VALUES (?, ?, ?, '', '', 'draft', 1, ?, ?)`
|
||||
)
|
||||
.run(id, title, startsAtUtc, now, now);
|
||||
const templateSections = input.templateId
|
||||
? (database
|
||||
.prepare(
|
||||
`SELECT name, category_id, position, explicit_duration_seconds
|
||||
FROM show_template_sections WHERE template_id = ? ORDER BY position`
|
||||
)
|
||||
.all(input.templateId) as {
|
||||
name: string;
|
||||
category_id: string | null;
|
||||
position: number;
|
||||
explicit_duration_seconds: number | null;
|
||||
}[])
|
||||
: [];
|
||||
const insertSection = database.prepare(
|
||||
`INSERT INTO show_sections (id, show_id, name, category_id, position,
|
||||
explicit_duration_seconds, is_reserve_bank) VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
for (const section of templateSections) {
|
||||
insertSection.run(
|
||||
randomUUID(),
|
||||
id,
|
||||
section.name,
|
||||
section.category_id,
|
||||
section.position,
|
||||
section.explicit_duration_seconds,
|
||||
0
|
||||
);
|
||||
}
|
||||
insertSection.run(randomUUID(), id, 'Reservebank', null, templateSections.length, null, 1);
|
||||
});
|
||||
create();
|
||||
return { id, revision: 1, updatedAtUtc: now };
|
||||
}
|
||||
|
||||
export function saveShowMetadata(
|
||||
showId: string,
|
||||
expectedRevision: number,
|
||||
input: ShowMetadataInput
|
||||
): MutationResult {
|
||||
const title = cleanText(input.title, 200, 'title', true);
|
||||
const startsAtUtc = validTimestamp(input.startsAtUtc);
|
||||
const twitchTitle = cleanText(input.twitchTitle, 200, 'twitch_title');
|
||||
const announcementText = cleanText(input.announcementText, 20_000, 'announcement');
|
||||
if (!['draft', 'ready', 'live'].includes(input.status)) throw new Error('INVALID_STATUS');
|
||||
const database = getDatabase();
|
||||
return database.transaction(() => {
|
||||
const result = bumpShow(database, showId, expectedRevision);
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE shows SET title = ?, starts_at_utc = ?, twitch_title = ?,
|
||||
announcement_text = ?, status = ? WHERE id = ?`
|
||||
)
|
||||
.run(title, startsAtUtc, twitchTitle, announcementText, input.status, showId);
|
||||
return result;
|
||||
})();
|
||||
}
|
||||
|
||||
export function addShowSection(
|
||||
showId: string,
|
||||
expectedRevision: number,
|
||||
input: ShowSectionInput
|
||||
): CreatedMutationResult {
|
||||
const name = cleanText(input.name, 200, 'section_name', true);
|
||||
const duration = validDuration(input.explicitDurationSeconds);
|
||||
const database = getDatabase();
|
||||
requireCategory(database, input.categoryId);
|
||||
return database.transaction(() => {
|
||||
const result = bumpShow(database, showId, expectedRevision);
|
||||
const visibleIds = (
|
||||
database
|
||||
.prepare(
|
||||
`SELECT id FROM show_sections WHERE show_id = ? AND is_reserve_bank = 0 ORDER BY position`
|
||||
)
|
||||
.all(showId) as { id: string }[]
|
||||
).map((row) => row.id);
|
||||
const position = validPosition(input.position ?? visibleIds.length, visibleIds.length);
|
||||
const id = randomUUID();
|
||||
const allIds = (
|
||||
database
|
||||
.prepare('SELECT id FROM show_sections WHERE show_id = ? ORDER BY position')
|
||||
.all(showId) as {
|
||||
id: string;
|
||||
}[]
|
||||
).map((row) => row.id);
|
||||
const temporaryPosition = allIds.length;
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO show_sections (id, show_id, name, category_id, position,
|
||||
explicit_duration_seconds, is_reserve_bank) VALUES (?, ?, ?, ?, ?, ?, 0)`
|
||||
)
|
||||
.run(id, showId, name, input.categoryId, temporaryPosition, duration);
|
||||
visibleIds.splice(position, 0, id);
|
||||
const reserveId = reserveSectionId(database, showId);
|
||||
normalizePositions(database, 'show_sections', [...visibleIds, reserveId]);
|
||||
return { ...result, id };
|
||||
})();
|
||||
}
|
||||
|
||||
export function updateShowSection(
|
||||
showId: string,
|
||||
sectionId: string,
|
||||
expectedRevision: number,
|
||||
input: Omit<ShowSectionInput, 'position'>
|
||||
): MutationResult {
|
||||
const name = cleanText(input.name, 200, 'section_name', true);
|
||||
const duration = validDuration(input.explicitDurationSeconds);
|
||||
const database = getDatabase();
|
||||
requireCategory(database, input.categoryId);
|
||||
return database.transaction(() => {
|
||||
const result = bumpShow(database, showId, expectedRevision);
|
||||
sectionBelongsToShow(database, showId, sectionId);
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE show_sections SET name = ?, category_id = ?, explicit_duration_seconds = ?
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(name, input.categoryId, duration, sectionId);
|
||||
return result;
|
||||
})();
|
||||
}
|
||||
|
||||
export function removeShowSection(
|
||||
showId: string,
|
||||
sectionId: string,
|
||||
expectedRevision: number
|
||||
): MutationResult {
|
||||
const database = getDatabase();
|
||||
return database.transaction(() => {
|
||||
const result = bumpShow(database, showId, expectedRevision);
|
||||
sectionBelongsToShow(database, showId, sectionId);
|
||||
database.prepare('DELETE FROM show_sections WHERE id = ?').run(sectionId);
|
||||
const ids = (
|
||||
database
|
||||
.prepare('SELECT id FROM show_sections WHERE show_id = ? ORDER BY position')
|
||||
.all(showId) as {
|
||||
id: string;
|
||||
}[]
|
||||
).map((row) => row.id);
|
||||
normalizePositions(database, 'show_sections', ids);
|
||||
return result;
|
||||
})();
|
||||
}
|
||||
|
||||
export function moveShowSection(
|
||||
showId: string,
|
||||
sectionId: string,
|
||||
targetPosition: number,
|
||||
expectedRevision: number
|
||||
): MutationResult {
|
||||
const database = getDatabase();
|
||||
return database.transaction(() => {
|
||||
const result = bumpShow(database, showId, expectedRevision);
|
||||
sectionBelongsToShow(database, showId, sectionId);
|
||||
const ids = (
|
||||
database
|
||||
.prepare(
|
||||
`SELECT id FROM show_sections WHERE show_id = ? AND is_reserve_bank = 0 ORDER BY position`
|
||||
)
|
||||
.all(showId) as { id: string }[]
|
||||
).map((row) => row.id);
|
||||
const current = ids.indexOf(sectionId);
|
||||
ids.splice(current, 1);
|
||||
ids.splice(validPosition(targetPosition, ids.length), 0, sectionId);
|
||||
ids.push(reserveSectionId(database, showId));
|
||||
normalizePositions(database, 'show_sections', ids);
|
||||
return result;
|
||||
})();
|
||||
}
|
||||
|
||||
export function addPieceUsage(
|
||||
showId: string,
|
||||
expectedRevision: number,
|
||||
input: PieceUsageInput
|
||||
): CreatedMutationResult {
|
||||
const database = getDatabase();
|
||||
return database.transaction(() => {
|
||||
const result = bumpShow(database, showId, expectedRevision);
|
||||
const sectionId = input.sectionId ?? reserveSectionId(database, showId);
|
||||
sectionBelongsToShow(database, showId, sectionId, input.sectionId === null);
|
||||
const piece = database
|
||||
.prepare(
|
||||
`SELECT title, duration_seconds, intro, research_text, moderation_notes,
|
||||
transition_text, presentation_text, status FROM pieces WHERE id = ?`
|
||||
)
|
||||
.get(input.pieceId) as
|
||||
| {
|
||||
title: string;
|
||||
duration_seconds: number;
|
||||
intro: string;
|
||||
research_text: string;
|
||||
moderation_notes: string;
|
||||
transition_text: string;
|
||||
presentation_text: string;
|
||||
status: string;
|
||||
}
|
||||
| undefined;
|
||||
if (!piece) throw new Error('NOT_FOUND');
|
||||
if (piece.status !== 'ready') throw new Error('PIECE_NOT_READY');
|
||||
const ids = (
|
||||
database
|
||||
.prepare('SELECT id FROM piece_usages WHERE show_section_id = ? ORDER BY position')
|
||||
.all(sectionId) as { id: string }[]
|
||||
).map((row) => row.id);
|
||||
const position = validPosition(input.position ?? ids.length, ids.length);
|
||||
const id = randomUUID();
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO piece_usages (id, show_section_id, original_piece_id, position, status,
|
||||
duration_seconds, title_snapshot, intro_snapshot, research_snapshot,
|
||||
moderation_snapshot, transition_snapshot, presentation_snapshot, own_notes, is_reserve)
|
||||
VALUES (?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, '', ?)`
|
||||
)
|
||||
.run(
|
||||
id,
|
||||
sectionId,
|
||||
input.pieceId,
|
||||
ids.length,
|
||||
piece.duration_seconds,
|
||||
piece.title,
|
||||
piece.intro,
|
||||
piece.research_text,
|
||||
piece.moderation_notes,
|
||||
piece.transition_text,
|
||||
piece.presentation_text,
|
||||
input.sectionId === null ? 1 : 0
|
||||
);
|
||||
ids.splice(position, 0, id);
|
||||
normalizePositions(database, 'piece_usages', ids);
|
||||
return { ...result, id };
|
||||
})();
|
||||
}
|
||||
|
||||
export function updatePieceUsage(
|
||||
showId: string,
|
||||
usageId: string,
|
||||
expectedRevision: number,
|
||||
input: PieceUsageUpdate
|
||||
): MutationResult {
|
||||
const title = cleanText(input.title, 200, 'title', true);
|
||||
const duration = validDuration(input.durationSeconds)!;
|
||||
if (!USAGE_STATUSES.includes(input.status)) throw new Error('INVALID_STATUS');
|
||||
const values = {
|
||||
intro: cleanText(input.intro, 50_000, 'intro'),
|
||||
research: cleanText(input.research, 50_000, 'research'),
|
||||
moderation: cleanText(input.moderation, 50_000, 'moderation'),
|
||||
transition: cleanText(input.transition, 50_000, 'transition'),
|
||||
presentation: cleanText(input.presentation, 50_000, 'presentation'),
|
||||
ownNotes: cleanText(input.ownNotes, 50_000, 'notes')
|
||||
};
|
||||
const database = getDatabase();
|
||||
return database.transaction(() => {
|
||||
const result = bumpShow(database, showId, expectedRevision);
|
||||
usageSection(database, showId, usageId);
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE piece_usages SET title_snapshot = ?, intro_snapshot = ?, research_snapshot = ?,
|
||||
moderation_snapshot = ?, transition_snapshot = ?, presentation_snapshot = ?,
|
||||
own_notes = ?, duration_seconds = ?, status = ? WHERE id = ?`
|
||||
)
|
||||
.run(
|
||||
title,
|
||||
values.intro,
|
||||
values.research,
|
||||
values.moderation,
|
||||
values.transition,
|
||||
values.presentation,
|
||||
values.ownNotes,
|
||||
duration,
|
||||
input.status,
|
||||
usageId
|
||||
);
|
||||
return result;
|
||||
})();
|
||||
}
|
||||
|
||||
export function removePieceUsage(
|
||||
showId: string,
|
||||
usageId: string,
|
||||
expectedRevision: number
|
||||
): MutationResult {
|
||||
const database = getDatabase();
|
||||
return database.transaction(() => {
|
||||
const result = bumpShow(database, showId, expectedRevision);
|
||||
const source = usageSection(database, showId, usageId);
|
||||
database.prepare('DELETE FROM piece_usages WHERE id = ?').run(usageId);
|
||||
const ids = (
|
||||
database
|
||||
.prepare('SELECT id FROM piece_usages WHERE show_section_id = ? ORDER BY position')
|
||||
.all(source.sectionId) as { id: string }[]
|
||||
).map((row) => row.id);
|
||||
normalizePositions(database, 'piece_usages', ids);
|
||||
return result;
|
||||
})();
|
||||
}
|
||||
|
||||
export function movePieceUsage(
|
||||
showId: string,
|
||||
usageId: string,
|
||||
expectedRevision: number,
|
||||
input: MovePieceUsageInput
|
||||
): MutationResult {
|
||||
const database = getDatabase();
|
||||
return database.transaction(() => {
|
||||
const result = bumpShow(database, showId, expectedRevision);
|
||||
const source = usageSection(database, showId, usageId);
|
||||
const targetSectionId = input.sectionId ?? reserveSectionId(database, showId);
|
||||
sectionBelongsToShow(database, showId, targetSectionId, input.sectionId === null);
|
||||
const sourceIds = (
|
||||
database
|
||||
.prepare('SELECT id FROM piece_usages WHERE show_section_id = ? ORDER BY position')
|
||||
.all(source.sectionId) as { id: string }[]
|
||||
).map((row) => row.id);
|
||||
if (source.sectionId === targetSectionId) {
|
||||
sourceIds.splice(sourceIds.indexOf(usageId), 1);
|
||||
sourceIds.splice(validPosition(input.position, sourceIds.length), 0, usageId);
|
||||
normalizePositions(database, 'piece_usages', sourceIds);
|
||||
} else {
|
||||
const targetIds = (
|
||||
database
|
||||
.prepare('SELECT id FROM piece_usages WHERE show_section_id = ? ORDER BY position')
|
||||
.all(targetSectionId) as { id: string }[]
|
||||
).map((row) => row.id);
|
||||
const remainingSourceIds = sourceIds.filter((id) => id !== usageId);
|
||||
normalizePositions(database, 'piece_usages', sourceIds);
|
||||
normalizePositions(database, 'piece_usages', targetIds);
|
||||
const temporaryPosition = (
|
||||
database
|
||||
.prepare(
|
||||
'SELECT COALESCE(max(position), -1) + 1 AS position FROM piece_usages WHERE show_section_id = ?'
|
||||
)
|
||||
.get(targetSectionId) as { position: number }
|
||||
).position;
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE piece_usages SET show_section_id = ?, position = ?, is_reserve = ? WHERE id = ?`
|
||||
)
|
||||
.run(targetSectionId, temporaryPosition, input.sectionId === null ? 1 : 0, usageId);
|
||||
targetIds.splice(validPosition(input.position, targetIds.length), 0, usageId);
|
||||
normalizePositions(database, 'piece_usages', remainingSourceIds);
|
||||
normalizePositions(database, 'piece_usages', targetIds);
|
||||
}
|
||||
return result;
|
||||
})();
|
||||
}
|
||||
Reference in new issue
Block a user