import { randomUUID } from 'node:crypto'; import { error, fail } from '@sveltejs/kit'; import type { Actions, PageServerLoad } from './$types'; import { requireBarkeeper, requireUser } from '#lib/server/auth.js'; import { finalizeShow, getCompletionSummary } from '#lib/server/completion.js'; import { completeCurrent, clearPresentationChannel, endLive, getLiveView, goBack, issuePresentationAccess, moveOpenUsage, pauseLive, presentUsage, rotatePresentationChannelKey, resetSectionTimer, resetTotalTimer, resumeEndedLive, resumeLive, selectUsage, setShowStatus, skipCurrent, startNewLiveRun, startOrResumeLive } from '#lib/server/live.js'; import { listShows } from '#lib/server/shows.js'; import { createShare, deleteShare, listShares, updateShare } from '#lib/server/shares.js'; import { berlinLocalToUtc } from '#lib/server/time.js'; function text(data: FormData, name: string) { return String(data.get(name) || ''); } function actionError(cause: unknown) { const code = cause instanceof Error ? cause.message : ''; if (code === 'CONFLICT') return { status: 409, error: 'Der Live-Stand wurde bereits geändert. Die Ansicht wurde erneuert.' }; if (code === 'NO_CURRENT') return { status: 409, error: 'Es ist kein aktuelles Piece aktiv.' }; if (code === 'NOT_STARTED') return { status: 409, error: 'Die Sendung muss vor der Auswahl gestartet werden.' }; if (code === 'NO_PREVIOUS') return { status: 409, error: 'Es gibt kein vorheriges Piece.' }; if (code === 'LIVE_ORDER_LOCKED') return { status: 409, error: 'Nur offene Pieces hinter dem aktuellen Stand können sortiert werden.' }; if (code === 'SHOW_COMPLETED') return { status: 409, error: 'Abgeschlossene Sendungen können nicht live gesteuert werden.' }; if (code === 'NOT_FOUND') return { status: 404, error: 'Sendung oder Piece wurde nicht gefunden.' }; if (code === 'TIMER_PAUSED') return { status: 409, error: 'Die Sendung ist pausiert.' }; if (code === 'ALREADY_PAUSED' || code === 'ALREADY_RUNNING') return { status: 409, error: 'Der Timerzustand wurde bereits geändert.' }; if (code === 'NO_SECTION_TIMER') return { status: 409, error: 'Es ist kein Abschnittstimer aktiv.' }; if (code === 'NOT_LIVE' || code === 'NOT_ENDED' || code === 'ALREADY_STARTED') return { status: 409, error: 'Die Sendung befindet sich nicht im passenden Zustand.' }; if (code.startsWith('INVALID_')) return { status: 400, error: 'Die Live-Aktion ist ungültig.' }; return { status: 500, error: 'Die Live-Aktion konnte nicht gespeichert werden.' }; } function attempt(intent: string, operation: () => unknown) { try { return { intent, success: true, result: operation() }; } catch (cause) { const problem = actionError(cause); return fail(problem.status, { intent, ...problem }); } } export const load: PageServerLoad = ({ locals, url }) => { const actor = requireUser(locals.user); const showId = url.searchParams.get('show') || listShows()[0]?.id; if (!showId) error(404, 'Keine Sendung vorhanden'); try { const live = getLiveView(showId); return { live, canManageShares: actor.role === 'barkeeper', shares: actor.role === 'barkeeper' ? listShares(actor, showId) : [], commandId: randomUUID(), completion: live.endedAtUtc && live.show.status !== 'completed' ? getCompletionSummary(showId) : null }; } catch (cause) { if (cause instanceof Error && cause.message === 'NOT_FOUND') error(404, 'Sendung nicht gefunden'); throw cause; } }; export const actions = { createShare: async ({ locals, request }) => { const actor = requireBarkeeper(locals.user); const data = await request.formData(); try { const share = await createShare(actor, { showId: text(data, 'showId'), scope: text(data, 'scope'), password: text(data, 'password'), expiresAtUtc: berlinLocalToUtc(text(data, 'expiresAt'), 'INVALID_EXPIRY') }); return { intent: 'createShare', success: 'Leselink angelegt.', shareUrl: `/freigabe/${share.token}` }; } catch (cause) { const problem = actionError(cause); return fail(problem.status, { intent: 'createShare', ...problem }); } }, updateShare: async ({ locals, request }) => { const actor = requireBarkeeper(locals.user); const data = await request.formData(); try { await updateShare(actor, text(data, 'shareId'), { scope: text(data, 'scope'), passwordMode: text(data, 'passwordMode'), password: text(data, 'password'), expiresAtUtc: berlinLocalToUtc(text(data, 'expiresAt'), 'INVALID_EXPIRY') }, text(data, 'showId')); return { intent: 'updateShare', success: 'Freigabe aktualisiert.' }; } catch (cause) { const problem = actionError(cause); return fail(problem.status, { intent: 'updateShare', ...problem }); } }, deleteShare: async ({ locals, request }) => { const actor = requireBarkeeper(locals.user); const data = await request.formData(); try { deleteShare(actor, text(data, 'shareId'), text(data, 'showId')); return { intent: 'deleteShare', success: 'Freigabe gelöscht.' }; } catch (cause) { const problem = actionError(cause); return fail(problem.status, { intent: 'deleteShare', ...problem }); } }, start: async ({ locals, request }) => { requireUser(locals.user); const data = await request.formData(); return attempt('start', () => startOrResumeLive(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision'))) ); }, pause: async ({ locals, request }) => { requireUser(locals.user); const data = await request.formData(); return attempt('pause', () => pauseLive(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision'))) ); }, resume: async ({ locals, request }) => { requireUser(locals.user); const data = await request.formData(); return attempt('resume', () => resumeLive(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision'))) ); }, resetSectionTimer: async ({ locals, request }) => { requireUser(locals.user); const data = await request.formData(); return attempt('resetSectionTimer', () => resetSectionTimer(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision'))) ); }, resetTotalTimer: async ({ locals, request }) => { requireUser(locals.user); const data = await request.formData(); return attempt('resetTotalTimer', () => resetTotalTimer(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision'))) ); }, setStatus: async ({ locals, request }) => { requireUser(locals.user); const data = await request.formData(); const status = text(data, 'status'); if (status !== 'draft' && status !== 'ready') { return fail(400, { intent: 'setStatus', error: 'Der Sendungsstatus ist ungültig.' }); } return attempt('setStatus', () => setShowStatus( text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision')), Number(data.get('showRevision')), status ) ); }, end: async ({ locals, request }) => { requireUser(locals.user); const data = await request.formData(); return attempt('end', () => endLive(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision'))) ); }, finalize: async ({ locals, request }) => { const actor = requireUser(locals.user); const data = await request.formData(); return attempt('finalize', () => finalizeShow({ showId: text(data, 'showId'), commandId: text(data, 'commandId'), expectedShowRevision: Number(data.get('showRevision')), expectedLiveRevision: Number(data.get('revision')), archivePieceIds: data.getAll('archivePieceIds').map(String), actor }) ); }, resumeEnded: async ({ locals, request }) => { requireUser(locals.user); const data = await request.formData(); return attempt('resumeEnded', () => resumeEndedLive(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision'))) ); }, startNewRun: async ({ locals, request }) => { requireUser(locals.user); const data = await request.formData(); return attempt('startNewRun', () => startNewLiveRun(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision'))) ); }, complete: async ({ locals, request }) => { requireUser(locals.user); const data = await request.formData(); return attempt('complete', () => completeCurrent(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision'))) ); }, skip: async ({ locals, request }) => { requireUser(locals.user); const data = await request.formData(); return attempt('skip', () => skipCurrent(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision'))) ); }, back: async ({ locals, request }) => { requireUser(locals.user); const data = await request.formData(); return attempt('back', () => goBack(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision'))) ); }, select: async ({ locals, request }) => { requireUser(locals.user); const data = await request.formData(); return attempt('select', () => selectUsage( text(data, 'showId'), text(data, 'usageId'), text(data, 'commandId'), Number(data.get('revision')) ) ); }, present: async ({ locals, request }) => { requireUser(locals.user); const data = await request.formData(); return attempt('present', () => presentUsage( text(data, 'showId'), text(data, 'usageId') || null, text(data, 'commandId'), Number(data.get('revision')) ) ); }, moveUsage: async ({ locals, request }) => { requireUser(locals.user); const data = await request.formData(); return attempt('moveUsage', () => moveOpenUsage(text(data, 'showId'), text(data, 'usageId'), Number(data.get('showRevision')), { sectionId: text(data, 'sectionId') || null, position: Number(data.get('position')) }) ); }, createOutput: async ({ locals, request }) => { requireUser(locals.user); const data = await request.formData(); try { const token = issuePresentationAccess(text(data, 'showId')); return { intent: 'createOutput', success: true, outputUrl: `/heinzlive/default#${token}` }; } catch (cause) { const problem = actionError(cause); return fail(problem.status, { intent: 'createOutput', ...problem }); } }, rotateOutput: async ({ locals, request }) => { requireUser(locals.user); const data = await request.formData(); try { const token = rotatePresentationChannelKey(text(data, 'showId')); return { intent: 'rotateOutput', success: true, outputUrl: `/heinzlive/default#${token}` }; } catch (cause) { const problem = actionError(cause); return fail(problem.status, { intent: 'rotateOutput', ...problem }); } }, clearOutput: async ({ locals }) => { requireUser(locals.user); return attempt('clearOutput', () => clearPresentationChannel()); } } satisfies Actions;