feat: REST-API unter /api/v1 mit OpenAPI-Beschreibung

Dieselben Endpunkte bedienen Skripte und das eigene Frontend: erst wird ein
Bearer-Token geprüft, sonst das Sitzungscookie.

Sichtungsaktionen geben immer watchIds zurück — damit lässt sich genau diese
Aktion über DELETE zurücknehmen, ohne raten zu müssen, was dazugehörte.

Die OpenAPI-Beschreibung ist handgeschrieben statt aus Zod erzeugt: die
Schnittstelle ist überschaubar, und ein Generator brächte eine weitere
Abhängigkeit für einen Nutzen, den ein Dutzend Zeilen JSON auch hergeben.
This commit is contained in:
Florian Wilde
2026-09-01 20:24:48 +02:00
parent b25f0473fd
commit bb63827bf7
12 changed files with 847 additions and 0 deletions
@@ -0,0 +1,93 @@
import { z } from "zod";
import { apiError, readJson, withApi } from "@/lib/api";
import { prisma } from "@/lib/db";
import {
markEpisodesWatched,
markMovieWatched,
markSeasonWatched,
markUpTo,
undoWatches,
unmarkEpisodes,
watchNextEpisode,
} from "@/lib/library";
/** GET — die Sichtungen eines Eintrags, jüngste zuerst. */
export const GET = withApi(async (_request, user, context) => {
const { entryId } = await context.params;
if (!entryId) return apiError("Es fehlt die Kennung des Eintrags.", 400);
const watches = await prisma.watch.findMany({
where: { userId: user.id, entryId },
orderBy: { watchedAt: "desc" },
take: 500,
include: { episode: { select: { seasonNumber: true, episodeNumber: true, name: true } } },
});
return {
total: watches.length,
items: watches.map((watch) => ({
id: watch.id,
run: watch.run,
watchedAt: watch.watchedAt,
minutes: watch.minutes,
episode: watch.episode,
})),
};
});
const postSchema = z.union([
z.object({ action: z.literal("next") }),
z.object({ action: z.literal("movie") }),
z.object({
action: z.literal("episodes"),
episodeIds: z.array(z.string().min(1)).min(1).max(500),
}),
z.object({ action: z.literal("season"), seasonId: z.string().min(1) }),
z.object({ action: z.literal("upTo"), episodeId: z.string().min(1) }),
]);
/**
* POST — etwas als gesehen markieren.
*
* Die Antwort enthält immer `watchIds`. Damit lässt sich die Aktion über
* DELETE gezielt zurücknehmen, ohne raten zu müssen, was dazugehörte.
*/
export const POST = withApi(async (request, user, context) => {
const { entryId } = await context.params;
if (!entryId) return apiError("Es fehlt die Kennung des Eintrags.", 400);
const body = await readJson(request, postSchema);
switch (body.action) {
case "next":
return watchNextEpisode(user.id, entryId);
case "movie":
return markMovieWatched(user.id, entryId);
case "episodes":
return markEpisodesWatched(user.id, entryId, body.episodeIds);
case "season":
return markSeasonWatched(user.id, entryId, body.seasonId);
case "upTo":
return markUpTo(user.id, entryId, body.episodeId);
}
});
const deleteSchema = z.union([
z.object({ watchIds: z.array(z.string().min(1)).min(1).max(500) }),
z.object({ episodeIds: z.array(z.string().min(1)).min(1).max(500) }),
]);
/** DELETE — Sichtungen zurücknehmen, über watchIds oder episodeIds. */
export const DELETE = withApi(async (request, user, context) => {
const { entryId } = await context.params;
if (!entryId) return apiError("Es fehlt die Kennung des Eintrags.", 400);
const body = await readJson(request, deleteSchema);
if ("watchIds" in body) {
await undoWatches(user.id, entryId, body.watchIds);
} else {
await unmarkEpisodes(user.id, entryId, body.episodeIds);
}
return { ok: true };
});