"use server"; import { revalidatePath } from "next/cache"; import { z } from "zod"; import { getSessionUser } from "@/lib/auth"; import { addToLibrary, LibraryError, markEpisodesWatched, markMovieWatched, markSeasonWatched, markUpTo, moveToTrash, purgeEntry, restoreFromTrash, setStatus, startRewatch, undoWatches, unmarkEpisodes, unmarkSeason, updateEntryDetails, watchNextEpisode, type WatchResult, } from "@/lib/library"; import { prisma } from "@/lib/db"; /** * Server-Aktionen rund um die Bibliothek. * * Jede Aktion holt sich die Sitzung selbst — auf Angaben aus dem Formular ist * beim Zugriffsschutz kein Verlass. Zurück kommt immer dieselbe Hülle, damit * das UI Erfolg, Fehler und Rückgängig-Möglichkeit einheitlich behandeln kann. */ export type ActionResult = { ok: boolean; message?: string; /** Neu angelegte Sichtungen — Grundlage für „Rückgängig". */ watchIds?: string[]; }; async function run( fn: (userId: string) => Promise<{ message?: string; watchIds?: string[] } | void>, revalidate: string[] = [], ): Promise { const user = await getSessionUser(); if (!user) return { ok: false, message: "Bitte melde dich an." }; try { const result = (await fn(user.id)) ?? {}; for (const path of revalidate) revalidatePath(path, "layout"); return { ok: true, message: result.message, watchIds: result.watchIds }; } catch (error) { if (error instanceof LibraryError) return { ok: false, message: error.message }; console.error("Bibliotheksaktion fehlgeschlagen:", error); return { ok: false, message: "Das hat nicht geklappt. Bitte noch einmal versuchen." }; } } const PATHS = ["/", "/bibliothek", "/titel", "/papierkorb", "/kalender", "/statistik"]; const cuid = z.string().min(1).max(64); const statusSchema = z.enum(["WATCHING", "PAUSED", "COMPLETED", "DROPPED", "WATCHLIST"]); // --------------------------------------------------------------------------- // Aufnehmen und entfernen // --------------------------------------------------------------------------- export async function addTitleAction(titleId: string, status: string = "WATCHLIST") { return run(async (userId) => { const parsedStatus = statusSchema.parse(status); await addToLibrary(userId, cuid.parse(titleId), parsedStatus); return { message: parsedStatus === "WATCHLIST" ? "Auf die Watchlist gesetzt" : "Zur Bibliothek hinzugefügt", }; }, PATHS); } export async function trashEntryAction(entryId: string) { return run(async (userId) => { await moveToTrash(userId, cuid.parse(entryId)); return { message: "In den Papierkorb gelegt" }; }, PATHS); } export async function restoreEntryAction(entryId: string) { return run(async (userId) => { await restoreFromTrash(userId, cuid.parse(entryId)); return { message: "Wiederhergestellt" }; }, PATHS); } export async function purgeEntryAction(entryId: string) { return run(async (userId) => { await purgeEntry(userId, cuid.parse(entryId)); return { message: "Endgültig gelöscht" }; }, PATHS); } // --------------------------------------------------------------------------- // Status und Angaben // --------------------------------------------------------------------------- export async function setStatusAction(entryId: string, status: string) { return run(async (userId) => { await setStatus(userId, cuid.parse(entryId), statusSchema.parse(status)); return { message: "Status geändert" }; }, PATHS); } const detailsSchema = z.object({ rating: z.number().int().min(1).max(10).nullable().optional(), notes: z.string().max(5000).nullable().optional(), whereToWatch: z.string().max(120).nullable().optional(), favorite: z.boolean().optional(), owned: z.boolean().optional(), ownedSource: z.string().max(80).nullable().optional(), }); export async function updateEntryAction(entryId: string, data: unknown) { return run(async (userId) => { await updateEntryDetails(userId, cuid.parse(entryId), detailsSchema.parse(data)); return { message: "Gespeichert" }; }, PATHS); } export async function startRewatchAction(entryId: string) { return run(async (userId) => { const entry = await startRewatch(userId, cuid.parse(entryId)); return { message: `Durchlauf ${entry.currentRun + 1} gestartet` }; }, PATHS); } // --------------------------------------------------------------------------- // Sichtungen // --------------------------------------------------------------------------- function toResult(result: WatchResult) { return { message: result.label, watchIds: result.watchIds }; } export async function toggleEpisodeAction(entryId: string, episodeId: string, watched: boolean) { return run(async (userId) => { if (watched) { return toResult(await markEpisodesWatched(userId, cuid.parse(entryId), [cuid.parse(episodeId)])); } await unmarkEpisodes(userId, cuid.parse(entryId), [cuid.parse(episodeId)]); return { message: "Markierung entfernt" }; }, PATHS); } export async function toggleSeasonAction(entryId: string, seasonId: string, watched: boolean) { return run(async (userId) => { if (watched) { return toResult(await markSeasonWatched(userId, cuid.parse(entryId), cuid.parse(seasonId))); } await unmarkSeason(userId, cuid.parse(entryId), cuid.parse(seasonId)); return { message: "Staffel zurückgesetzt" }; }, PATHS); } export async function markUpToAction(entryId: string, episodeId: string) { return run( async (userId) => toResult(await markUpTo(userId, cuid.parse(entryId), cuid.parse(episodeId))), PATHS, ); } export async function watchNextAction(entryId: string) { return run(async (userId) => toResult(await watchNextEpisode(userId, cuid.parse(entryId))), PATHS); } export async function markMovieAction(entryId: string) { return run(async (userId) => toResult(await markMovieWatched(userId, cuid.parse(entryId))), PATHS); } export async function undoAction(entryId: string, watchIds: string[]) { return run(async (userId) => { await undoWatches(userId, cuid.parse(entryId), z.array(cuid).max(500).parse(watchIds)); return { message: "Rückgängig gemacht" }; }, PATHS); } // --------------------------------------------------------------------------- // Tags // --------------------------------------------------------------------------- export async function setTagsAction(entryId: string, tagNames: string[]) { return run(async (userId) => { const id = cuid.parse(entryId); const names = z .array(z.string().trim().min(1).max(40)) .max(20) .parse(tagNames) .map((name) => name.toLocaleLowerCase("de-DE")); const entry = await prisma.libraryEntry.findFirst({ where: { id, userId }, select: { id: true }, }); if (!entry) throw new LibraryError("Dieser Eintrag gehört nicht zu deiner Bibliothek."); const tags = await Promise.all( names.map((name) => prisma.tag.upsert({ where: { userId_name: { userId, name } }, create: { userId, name }, update: {}, select: { id: true }, }), ), ); await prisma.$transaction([ prisma.tagOnEntry.deleteMany({ where: { entryId: id, tagId: { notIn: tags.map((tag) => tag.id) } }, }), ...tags.map((tag) => prisma.tagOnEntry.upsert({ where: { tagId_entryId: { tagId: tag.id, entryId: id } }, create: { tagId: tag.id, entryId: id }, update: {}, }), ), ]); return { message: "Tags gespeichert" }; }, PATHS); } // --------------------------------------------------------------------------- // Manuell angelegte Titel // --------------------------------------------------------------------------- const manualSchema = z.object({ type: z.enum(["SERIES", "MOVIE"]), title: z.string().trim().min(1).max(200), year: z.number().int().min(1880).max(2200).nullable().optional(), runtime: z.number().int().min(1).max(2000).nullable().optional(), overview: z.string().trim().max(4000).optional(), genres: z.array(z.string().trim().min(1).max(40)).max(10).optional(), status: statusSchema.default("WATCHLIST"), }); /** * Legt einen Titel ohne TMDB-Treffer an. * * Für alles, was TMDB nicht kennt: Heimvideos, Vorträge, regionale * Produktionen. Solche Titel haben keine `tmdbId` und werden vom Sync-Lauf * daher nie angefasst. */ export async function createManualTitleAction(data: unknown): Promise { const user = await getSessionUser(); if (!user) return { ok: false, message: "Bitte melde dich an." }; const parsed = manualSchema.safeParse(data); if (!parsed.success) { return { ok: false, message: "Bitte gib mindestens einen Titel an." }; } const { type, title, year, runtime, overview, genres, status } = parsed.data; try { const created = await prisma.title.create({ data: { type, title, overview: overview || null, runtime: runtime ?? null, releaseDate: year ? new Date(Date.UTC(year, 0, 1)) : null, genres: genres ?? [], status: "Manuell angelegt", syncedAt: new Date(), }, select: { id: true }, }); await addToLibrary(user.id, created.id, status); for (const path of PATHS) revalidatePath(path, "layout"); return { ok: true, message: `„${title}" angelegt`, titleId: created.id }; } catch (error) { console.error("Manuellen Titel anlegen fehlgeschlagen:", error); return { ok: false, message: "Das hat nicht geklappt. Bitte noch einmal versuchen." }; } }