385 lines
13 KiB
TypeScript
385 lines
13 KiB
TypeScript
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: ['preparing', '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<string, number>,
|
|
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'),
|
|
preparationStatus: text(data, 'preparationStatus') as PieceUsageUpdate['preparationStatus'],
|
|
preparationEffort: (text(data, 'preparationEffort') ||
|
|
null) as PieceUsageUpdate['preparationEffort'],
|
|
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;
|