import { randomUUID } from 'node:crypto'; import { error, fail, redirect } from '@sveltejs/kit'; import type { Actions, PageServerLoad } from './$types'; import { requireBarkeeper, requireUser } from '#lib/server/auth.js'; import { getHistoricalActualDurations } from '#lib/server/completion.js'; import { getTaxonomy, listPieces, listPieceTemplates } from '#lib/server/content.js'; import { createShare, deleteShare, listShares, replaceShareToken, updateShare } from '#lib/server/shares.js'; import { berlinLocalToUtc } from '#lib/server/time.js'; import { addPieceUsage, addShowSection, createAndAddPieceUsage, duplicatePieceUsage, duplicateCompletedShow, getShow, getPieceUsageContexts, movePieceUsage, moveShowSection, refreshPieceUsageFromOriginal, removePieceUsage, removeShowSection, saveShowMetadata, updatePieceUsage, updateShowSection, type EditableShowStatus, type PieceUsageUpdate } from '#lib/server/shows.js'; function text(data: FormData, name: string) { return String(data.get(name) || ''); } function nullableDuration(data: FormData) { const value = text(data, 'explicitDurationSeconds').trim(); return value === '' ? null : Number(value); } function pieceDurationInSeconds(data: FormData) { return Math.round(Number(data.get('durationMinutes')) * 60); } function berlinInput(value: string | null) { if (!value) return ''; const parts = new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Berlin', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).formatToParts(new Date(value)); const read = (type: Intl.DateTimeFormatPartTypes) => parts.find((part) => part.type === type)?.value ?? ''; return `${read('year')}-${read('month')}-${read('day')}T${read('hour')}:${read('minute')}`; } function actionError(cause: unknown) { const code = cause instanceof Error ? cause.message : ''; if (code === 'CONFLICT') return { status: 409, error: 'Die Sendung wurde inzwischen geändert. Bitte neu laden.', conflict: true }; if (code === 'SHOW_COMPLETED') return { status: 409, error: 'Abgeschlossene Sendungen können nicht bearbeitet werden.' }; if (code === 'PIECE_NOT_READY') return { status: 400, error: 'Nur sendefertige Pieces können eingeplant werden.' }; if (code === 'ORIGINAL_NOT_FOUND') return { status: 409, error: 'Das zugehörige Fundus-Piece ist nicht mehr verfügbar.' }; if (code === 'NOT_FOUND') return { status: 404, error: 'Der Eintrag wurde nicht gefunden.' }; if ( ['USAGE_IS_CURRENT', 'USAGE_IS_PRESENTED', 'USAGE_HAS_LIVE_RUNS', 'USAGE_NOT_OPEN'].includes( code ) ) return { status: 409, error: 'Diese Sendungsfassung war bereits live aktiv oder wird gerade verwendet und kann nicht entfernt werden.' }; if (code === 'INVALID_DURATION') return { status: 400, error: 'Piece-Dauern müssen zwischen 0 und 1.440 Minuten liegen.' }; if (code === 'INVALID_PASSWORD') return { status: 400, error: 'Ein Freigabepasswort benötigt mindestens 12 Zeichen.' }; if (code === 'INVALID_EXPIRY') return { status: 400, error: 'Der Ablaufzeitpunkt muss in der Zukunft liegen.' }; if (code === 'INVALID_SCOPE' || code === 'INVALID_PASSWORD_MODE') return { status: 400, error: 'Die Einstellungen der Freigabe sind ungültig.' }; if (code.startsWith('INVALID_')) return { status: 400, error: 'Mindestens ein Feld ist ungültig oder zu lang.' }; return { status: 500, error: 'Die Änderung konnte nicht gespeichert werden.' }; } function attempt(intent: string, operation: () => unknown) { try { return { intent, success: 'Änderung gespeichert.', result: operation() }; } catch (cause) { return fail(actionError(cause).status, { intent, ...actionError(cause) }); } } export const load: PageServerLoad = ({ locals, params, url }) => { const actor = requireUser(locals.user); const show = getShow(params.id); if (!show) error(404, 'Sendung nicht gefunden'); const shares = actor.role === 'barkeeper' ? listShares(actor, show.id) : []; if (show.status === 'completed') { return { show, shares, canManageShares: actor.role === 'barkeeper', historical: true, actualDurations: getHistoricalActualDurations(show.id), duplicateCommandId: randomUUID(), startsAtLocal: berlinInput(show.startsAtUtc), pieces: [], taxonomy: getTaxonomy(), pieceTemplates: [], createCommandId: randomUUID(), filters: { query: '', categoryId: '', includeUsed: false, includePlanned: false } }; } const pieces = listPieces({ statuses: ['ready'], query: url.searchParams.get('q') ?? '', categoryId: url.searchParams.get('category') ?? '' }); const usageContexts = getPieceUsageContexts( pieces.map((piece) => piece.id), show.id ); return { show, shares, canManageShares: actor.role === 'barkeeper', historical: false, actualDurations: {} as Record, duplicateCommandId: randomUUID(), startsAtLocal: berlinInput(show.startsAtUtc), pieces: pieces.map((piece) => ({ ...piece, ...usageContexts[piece.id] })), taxonomy: getTaxonomy(), pieceTemplates: listPieceTemplates(), createCommandId: randomUUID(), filters: { query: url.searchParams.get('q') ?? '', categoryId: url.searchParams.get('category') ?? '', includeUsed: url.searchParams.get('includeUsed') === '1', includePlanned: url.searchParams.get('includePlanned') === '1' } }; }; export const actions = { createShare: async ({ locals, params, request }) => { const actor = requireBarkeeper(locals.user); const data = await request.formData(); try { const share = await createShare(actor, { showId: params.id, scope: text(data, 'scope'), password: text(data, 'password'), expiresAtUtc: berlinLocalToUtc(text(data, 'expiresAt'), 'INVALID_EXPIRY') }); return { intent: 'createShare', success: 'Leselink angelegt. Der Link wird nur jetzt vollständig angezeigt.', shareUrl: `/freigabe/${share.token}` }; } catch (cause) { return fail(actionError(cause).status, { intent: 'createShare', ...actionError(cause) }); } }, updateShare: async ({ locals, params, 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') }, params.id ); return { intent: 'updateShare', success: 'Freigabe aktualisiert.' }; } catch (cause) { return fail(actionError(cause).status, { intent: 'updateShare', ...actionError(cause) }); } }, replaceShare: async ({ locals, params, request }) => { const actor = requireBarkeeper(locals.user); const data = await request.formData(); try { const token = replaceShareToken(actor, text(data, 'shareId'), params.id); return { intent: 'replaceShare', success: 'Ersatzlink angelegt. Der bisherige Link ist ungültig.', shareUrl: `/freigabe/${token}` }; } catch (cause) { return fail(actionError(cause).status, { intent: 'replaceShare', ...actionError(cause) }); } }, deleteShare: async ({ locals, params, request }) => { const actor = requireBarkeeper(locals.user); const data = await request.formData(); try { deleteShare(actor, text(data, 'shareId'), params.id); return { intent: 'deleteShare', success: 'Freigabe gelöscht.' }; } catch (cause) { return fail(actionError(cause).status, { intent: 'deleteShare', ...actionError(cause) }); } }, duplicateShow: async ({ locals, params, request }) => { requireUser(locals.user); const data = await request.formData(); try { const duplicate = duplicateCompletedShow(params.id, text(data, 'commandId')); redirect(303, `/sendungen/${duplicate.id}`); } catch (cause) { if (cause && typeof cause === 'object' && 'status' in cause) throw cause; return fail(actionError(cause).status, { intent: 'duplicateShow', ...actionError(cause) }); } }, saveMetadata: async ({ locals, params, request }) => { requireUser(locals.user); const data = await request.formData(); return attempt('saveMetadata', () => saveShowMetadata(params.id, Number(data.get('revision')), { title: text(data, 'title'), startsAtUtc: berlinLocalToUtc(text(data, 'startsAt')), twitchTitle: text(data, 'twitchTitle'), announcementText: text(data, 'announcementText'), status: text(data, 'status') as EditableShowStatus }) ); }, addSection: async ({ locals, params, request }) => { requireUser(locals.user); const data = await request.formData(); return attempt('addSection', () => addShowSection(params.id, Number(data.get('revision')), { name: text(data, 'name'), categoryId: text(data, 'categoryId') || null, explicitDurationSeconds: nullableDuration(data), twitchTags: text(data, 'twitchTags').split(/[\n,]/) }) ); }, updateSection: async ({ locals, params, request }) => { requireUser(locals.user); const data = await request.formData(); return attempt('updateSection', () => updateShowSection(params.id, text(data, 'sectionId'), Number(data.get('revision')), { name: text(data, 'name'), categoryId: text(data, 'categoryId') || null, explicitDurationSeconds: nullableDuration(data), twitchTags: text(data, 'twitchTags').split(/[\n,]/) }) ); }, removeSection: async ({ locals, params, request }) => { requireUser(locals.user); const data = await request.formData(); return attempt('removeSection', () => removeShowSection(params.id, text(data, 'sectionId'), Number(data.get('revision'))) ); }, moveSection: async ({ locals, params, request }) => { requireUser(locals.user); const data = await request.formData(); return attempt('moveSection', () => moveShowSection( params.id, text(data, 'sectionId'), Number(data.get('position')), Number(data.get('revision')) ) ); }, addUsage: async ({ locals, params, request }) => { requireUser(locals.user); const data = await request.formData(); return attempt('addUsage', () => addPieceUsage(params.id, Number(data.get('revision')), { pieceId: text(data, 'pieceId'), sectionId: text(data, 'sectionId') || null }) ); }, createUsage: async ({ locals, params, request }) => { requireUser(locals.user); const data = await request.formData(); return attempt('createUsage', () => createAndAddPieceUsage(params.id, Number(data.get('revision')), text(data, 'commandId'), { title: text(data, 'title'), templateId: text(data, 'templateId') || 'blank', sectionId: text(data, 'sectionId') || null }) ); }, updateUsage: async ({ locals, params, request }) => { requireUser(locals.user); const data = await request.formData(); const toolIds = data.getAll('usageToolId').map(String); const toolWorkUrls = data.getAll('toolWorkUrl').map(String); const toolNotes = data.getAll('toolNote').map(String); return attempt('updateUsage', () => updatePieceUsage(params.id, text(data, 'usageId'), Number(data.get('revision')), { title: text(data, 'title'), outputTitle: text(data, 'outputTitle'), durationSeconds: pieceDurationInSeconds(data), ownNotes: text(data, 'ownNotes'), intro: text(data, 'intro'), research: text(data, 'research'), moderation: text(data, 'moderation'), interaction: text(data, 'interaction'), transition: text(data, 'transition'), presentation: text(data, 'presentation'), presentationMode: text(data, 'presentationMode') as PieceUsageUpdate['presentationMode'], presentationSourceLabel: text(data, 'presentationSourceLabel'), presentationSourceUrl: text(data, 'presentationSourceUrl'), presentationSourceTimecodeSeconds: text(data, 'presentationSourceTimecodeSeconds') ? Number(data.get('presentationSourceTimecodeSeconds')) : null, presentationTitle: text(data, 'presentationTitle'), presentationDescription: text(data, 'presentationDescription'), presentationImageUrl: text(data, 'presentationImageUrl'), presentationSiteName: text(data, 'presentationSiteName'), presentationMediaId: text(data, 'presentationMediaId') || null, tools: toolIds.map((id, index) => ({ id, workUrl: toolWorkUrls[index] ?? '', note: toolNotes[index] ?? '' })) }) ); }, duplicateUsage: async ({ locals, params, request }) => { requireUser(locals.user); const data = await request.formData(); return attempt('duplicateUsage', () => duplicatePieceUsage(params.id, text(data, 'usageId'), Number(data.get('revision'))) ); }, refreshUsage: async ({ locals, params, request }) => { requireUser(locals.user); const data = await request.formData(); return attempt('refreshUsage', () => refreshPieceUsageFromOriginal(params.id, text(data, 'usageId'), Number(data.get('revision'))) ); }, removeUsage: async ({ locals, params, request }) => { requireUser(locals.user); const data = await request.formData(); return attempt('removeUsage', () => removePieceUsage(params.id, text(data, 'usageId'), Number(data.get('revision'))) ); }, moveUsage: async ({ locals, params, request }) => { requireUser(locals.user); const data = await request.formData(); return attempt('moveUsage', () => movePieceUsage(params.id, text(data, 'usageId'), Number(data.get('revision')), { sectionId: text(data, 'sectionId') || null, position: Number(data.get('position')) }) ); } } satisfies Actions;