diff --git a/src/app/api/v1/bibliothek/[entryId]/route.ts b/src/app/api/v1/bibliothek/[entryId]/route.ts new file mode 100644 index 0000000..b00cdd5 --- /dev/null +++ b/src/app/api/v1/bibliothek/[entryId]/route.ts @@ -0,0 +1,85 @@ +import { z } from "zod"; +import { apiError, readJson, withApi } from "@/lib/api"; +import { prisma } from "@/lib/db"; +import { + getNextEpisode, + getProgress, + moveToTrash, + setStatus, + updateEntryDetails, +} from "@/lib/library"; + +const STATUSES = ["WATCHING", "PAUSED", "COMPLETED", "DROPPED", "WATCHLIST"] as const; + +/** GET /api/v1/bibliothek/{id} — ein Eintrag mit Fortschritt und nächster Episode. */ +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 entry = await prisma.libraryEntry.findFirst({ + where: { id: entryId, userId: user.id }, + include: { + title: true, + tags: { include: { tag: { select: { name: true } } } }, + }, + }); + if (!entry) return apiError("Diesen Eintrag gibt es nicht.", 404); + + const [progress, next] = await Promise.all([ + getProgress(user.id, entry.id), + getNextEpisode(user.id, entry.id), + ]); + + return { + id: entry.id, + status: entry.status, + rating: entry.rating, + notes: entry.notes, + whereToWatch: entry.whereToWatch, + favorite: entry.favorite, + currentRun: entry.currentRun, + tags: entry.tags.map((link) => link.tag.name), + title: { + id: entry.title.id, + title: entry.title.title, + type: entry.title.type, + tmdbId: entry.title.tmdbId, + year: entry.title.releaseDate?.getFullYear() ?? null, + genres: entry.title.genres, + }, + progress, + nextEpisode: next, + }; +}); + +const patchSchema = z.object({ + status: z.enum(STATUSES).optional(), + 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(), +}); + +/** PATCH /api/v1/bibliothek/{id} — Status und Angaben ändern. */ +export const PATCH = withApi(async (request, user, context) => { + const { entryId } = await context.params; + if (!entryId) return apiError("Es fehlt die Kennung des Eintrags.", 400); + + const { status, ...details } = await readJson(request, patchSchema); + + if (status) await setStatus(user.id, entryId, status); + if (Object.keys(details).length > 0) { + await updateEntryDetails(user.id, entryId, details); + } + + return { ok: true }; +}); + +/** DELETE /api/v1/bibliothek/{id} — in den Papierkorb legen. */ +export const DELETE = withApi(async (_request, user, context) => { + const { entryId } = await context.params; + if (!entryId) return apiError("Es fehlt die Kennung des Eintrags.", 400); + + await moveToTrash(user.id, entryId); + return { ok: true, message: "In den Papierkorb gelegt." }; +}); diff --git a/src/app/api/v1/bibliothek/[entryId]/sichtungen/route.ts b/src/app/api/v1/bibliothek/[entryId]/sichtungen/route.ts new file mode 100644 index 0000000..b44d86b --- /dev/null +++ b/src/app/api/v1/bibliothek/[entryId]/sichtungen/route.ts @@ -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 }; +}); diff --git a/src/app/api/v1/bibliothek/route.ts b/src/app/api/v1/bibliothek/route.ts new file mode 100644 index 0000000..983d462 --- /dev/null +++ b/src/app/api/v1/bibliothek/route.ts @@ -0,0 +1,63 @@ +import { z } from "zod"; +import { readJson, readPaging, withApi } from "@/lib/api"; +import { getLibrary } from "@/lib/queries"; +import { addToLibrary } from "@/lib/library"; +import { ensureTitle } from "@/lib/catalog"; +import type { EntryStatus, TitleType } from "@/generated/prisma/client"; + +const STATUSES = ["WATCHING", "PAUSED", "COMPLETED", "DROPPED", "WATCHLIST"] as const; + +/** GET /api/v1/bibliothek — Bibliothek lesen, mit denselben Filtern wie im UI. */ +export const GET = withApi(async (request, user) => { + const url = new URL(request.url); + const { limit, offset } = readPaging(request); + + const status = url.searchParams.get("status"); + const type = url.searchParams.get("typ"); + + const items = await getLibrary(user.id, { + query: url.searchParams.get("q") ?? undefined, + status: STATUSES.includes(status as EntryStatus) ? (status as EntryStatus) : undefined, + type: type === "SERIES" || type === "MOVIE" ? (type as TitleType) : undefined, + genre: url.searchParams.get("genre") ?? undefined, + tag: url.searchParams.get("tag") ?? undefined, + provider: url.searchParams.get("dienst") ?? undefined, + }); + + return { + total: items.length, + limit, + offset, + items: items.slice(offset, offset + limit), + }; +}); + +const addSchema = z + .object({ + titleId: z.string().min(1).optional(), + tmdbId: z.number().int().positive().optional(), + type: z.enum(["SERIES", "MOVIE"]).optional(), + status: z.enum(STATUSES).default("WATCHLIST"), + }) + .refine((value) => Boolean(value.titleId) || Boolean(value.tmdbId && value.type), { + message: "Entweder titleId oder tmdbId zusammen mit type angeben.", + }); + +/** POST /api/v1/bibliothek — Titel aufnehmen, per interner ID oder TMDB-ID. */ +export const POST = withApi(async (request, user) => { + const body = await readJson(request, addSchema); + + let titleId = body.titleId; + if (!titleId && body.tmdbId && body.type) { + const title = await ensureTitle(body.type, body.tmdbId, { region: user.region }); + titleId = title.id; + } + if (!titleId) { + throw new z.ZodError([ + { code: "custom", path: ["titleId"], message: "Kein Titel angegeben." }, + ]); + } + + const entry = await addToLibrary(user.id, titleId, body.status); + return { id: entry.id, titleId: entry.titleId, status: entry.status }; +}); diff --git a/src/app/api/v1/export/route.ts b/src/app/api/v1/export/route.ts new file mode 100644 index 0000000..d67ccc5 --- /dev/null +++ b/src/app/api/v1/export/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from "next/server"; +import { withApi } from "@/lib/api"; +import { exportCsv, exportJson } from "@/lib/transfer"; + +/** + * GET /api/v1/export?format=json|csv + * + * Vollständiger Export der eigenen Daten. JSON enthält zusätzlich Sichtungen + * und Universen, CSV ist die schlanke Tabellenform für Tabellenkalkulationen. + */ +export const GET = withApi(async (request, user) => { + const url = new URL(request.url); + const format = url.searchParams.get("format") === "csv" ? "csv" : "json"; + const stamp = new Date().toISOString().slice(0, 10); + + if (format === "csv") { + const csv = await exportCsv(user.id); + return new NextResponse(csv, { + headers: { + "content-type": "text/csv; charset=utf-8", + "content-disposition": `attachment; filename="flimmer-${stamp}.csv"`, + }, + }); + } + + const data = await exportJson(user.id); + return new NextResponse(JSON.stringify(data, null, 2), { + headers: { + "content-type": "application/json; charset=utf-8", + "content-disposition": `attachment; filename="flimmer-${stamp}.json"`, + }, + }); +}); diff --git a/src/app/api/v1/kalender/route.ts b/src/app/api/v1/kalender/route.ts new file mode 100644 index 0000000..cb9e47d --- /dev/null +++ b/src/app/api/v1/kalender/route.ts @@ -0,0 +1,29 @@ +import { withApi } from "@/lib/api"; +import { getUpcomingEpisodes } from "@/lib/stats"; + +/** + * GET /api/v1/kalender?von=2026-09-01&bis=2026-09-30 + * + * Kommende Episoden der getrackten Serien. Ohne Angabe: die nächsten 30 Tage. + */ +export const GET = withApi(async (request, user) => { + const url = new URL(request.url); + + const parse = (value: string | null, fallback: Date) => { + if (!value) return fallback; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? fallback : date; + }; + + const from = parse(url.searchParams.get("von"), new Date()); + const to = parse(url.searchParams.get("bis"), new Date(Date.now() + 30 * 86_400_000)); + + const episodes = await getUpcomingEpisodes(user.id, from, to); + + return { + from: from.toISOString(), + to: to.toISOString(), + total: episodes.length, + items: episodes, + }; +}); diff --git a/src/app/api/v1/me/route.ts b/src/app/api/v1/me/route.ts new file mode 100644 index 0000000..cdedadf --- /dev/null +++ b/src/app/api/v1/me/route.ts @@ -0,0 +1,19 @@ +import { withApi } from "@/lib/api"; +import { prisma } from "@/lib/db"; + +/** GET /api/v1/me — wer bin ich, und wie groß ist meine Bibliothek? */ +export const GET = withApi(async (_request, user) => { + const [entries, watches] = await Promise.all([ + prisma.libraryEntry.count({ where: { userId: user.id, deletedAt: null } }), + prisma.watch.count({ where: { userId: user.id } }), + ]); + + return { + id: user.id, + name: user.name, + email: user.email, + role: user.role, + region: user.region, + library: { entries, watches }, + }; +}); diff --git a/src/app/api/v1/openapi.json/route.ts b/src/app/api/v1/openapi.json/route.ts new file mode 100644 index 0000000..1a2ab4e --- /dev/null +++ b/src/app/api/v1/openapi.json/route.ts @@ -0,0 +1,310 @@ +import { NextResponse } from "next/server"; +import { env } from "@/lib/env"; +import pkg from "../../../../../package.json"; + +/** + * Maschinenlesbare Beschreibung der REST-API. + * + * Handgeschrieben statt aus Zod erzeugt: die Schnittstelle ist überschaubar, + * und ein Generator brächte eine weitere Abhängigkeit samt Versionsrhythmus + * für einen Nutzen, den ein Dutzend Zeilen JSON auch hergeben. + */ +export const dynamic = "force-dynamic"; + +export function GET() { + const document = { + openapi: "3.1.0", + info: { + title: "Flimmer API", + version: pkg.version, + description: + "REST-Schnittstelle einer Flimmer-Instanz. Anmeldung über einen persönlichen " + + "API-Schlüssel (Einstellungen → API-Schlüssel) als Bearer-Token. Alle Antworten " + + "sind JSON; Zeitangaben in ISO 8601, Laufzeiten in Minuten.", + license: { name: "MIT" }, + }, + servers: [{ url: `${env.appUrl}/api/v1` }], + security: [{ bearerAuth: [] }], + components: { + securitySchemes: { + bearerAuth: { type: "http", scheme: "bearer", description: "Persönlicher API-Schlüssel" }, + }, + schemas: { + Fehler: { + type: "object", + properties: { error: { type: "string" }, details: {} }, + required: ["error"], + }, + Sichtungsergebnis: { + type: "object", + properties: { + watchIds: { + type: "array", + items: { type: "string" }, + description: "Kennungen der neu angelegten Sichtungen — für DELETE zum Zurücknehmen.", + }, + status: { type: "string" }, + label: { type: "string" }, + }, + }, + }, + }, + paths: { + "/me": { + get: { + summary: "Angemeldetes Konto", + responses: { "200": { description: "Konto samt Umfang der Bibliothek" } }, + }, + }, + "/bibliothek": { + get: { + summary: "Bibliothek lesen", + parameters: [ + { name: "q", in: "query", schema: { type: "string" }, description: "Volltextsuche" }, + { + name: "status", + in: "query", + schema: { + type: "string", + enum: ["WATCHING", "PAUSED", "COMPLETED", "DROPPED", "WATCHLIST"], + }, + }, + { name: "typ", in: "query", schema: { type: "string", enum: ["SERIES", "MOVIE"] } }, + { name: "genre", in: "query", schema: { type: "string" } }, + { name: "tag", in: "query", schema: { type: "string" } }, + { name: "dienst", in: "query", schema: { type: "string" } }, + { name: "limit", in: "query", schema: { type: "integer", default: 50, maximum: 200 } }, + { name: "offset", in: "query", schema: { type: "integer", default: 0 } }, + ], + responses: { "200": { description: "Einträge mit Fortschritt" } }, + }, + post: { + summary: "Titel aufnehmen", + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + properties: { + titleId: { type: "string", description: "Interne Kennung" }, + tmdbId: { type: "integer", description: "Alternativ: TMDB-Kennung" }, + type: { type: "string", enum: ["SERIES", "MOVIE"] }, + status: { + type: "string", + enum: ["WATCHING", "PAUSED", "COMPLETED", "DROPPED", "WATCHLIST"], + default: "WATCHLIST", + }, + }, + }, + }, + }, + }, + responses: { "200": { description: "Angelegter oder vorhandener Eintrag" } }, + }, + }, + "/bibliothek/{entryId}": { + parameters: [{ name: "entryId", in: "path", required: true, schema: { type: "string" } }], + get: { summary: "Eintrag mit Fortschritt", responses: { "200": { description: "Eintrag" } } }, + patch: { + summary: "Status, Bewertung, Notiz ändern", + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + properties: { + status: { + type: "string", + enum: ["WATCHING", "PAUSED", "COMPLETED", "DROPPED", "WATCHLIST"], + }, + rating: { type: "integer", minimum: 1, maximum: 10, nullable: true }, + notes: { type: "string", nullable: true }, + whereToWatch: { type: "string", nullable: true }, + favorite: { type: "boolean" }, + }, + }, + }, + }, + }, + responses: { "200": { description: "Gespeichert" } }, + }, + delete: { + summary: "In den Papierkorb legen", + responses: { "200": { description: "Verschoben" } }, + }, + }, + "/bibliothek/{entryId}/sichtungen": { + parameters: [{ name: "entryId", in: "path", required: true, schema: { type: "string" } }], + get: { summary: "Sichtungen auflisten", responses: { "200": { description: "Sichtungen" } } }, + post: { + summary: "Als gesehen markieren", + description: + "action=next hakt die nächste offene Episode ab, movie einen Film, " + + "episodes eine Auswahl, season eine ganze Staffel, upTo alles bis zur genannten Episode.", + requestBody: { + required: true, + content: { + "application/json": { + schema: { + oneOf: [ + { + type: "object", + properties: { action: { const: "next" } }, + required: ["action"], + }, + { + type: "object", + properties: { action: { const: "movie" } }, + required: ["action"], + }, + { + type: "object", + properties: { + action: { const: "episodes" }, + episodeIds: { type: "array", items: { type: "string" } }, + }, + required: ["action", "episodeIds"], + }, + { + type: "object", + properties: { action: { const: "season" }, seasonId: { type: "string" } }, + required: ["action", "seasonId"], + }, + { + type: "object", + properties: { action: { const: "upTo" }, episodeId: { type: "string" } }, + required: ["action", "episodeId"], + }, + ], + }, + }, + }, + }, + responses: { + "200": { + description: "Angelegte Sichtungen", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Sichtungsergebnis" }, + }, + }, + }, + }, + }, + delete: { + summary: "Sichtungen zurücknehmen", + requestBody: { + required: true, + content: { + "application/json": { + schema: { + oneOf: [ + { + type: "object", + properties: { watchIds: { type: "array", items: { type: "string" } } }, + required: ["watchIds"], + }, + { + type: "object", + properties: { episodeIds: { type: "array", items: { type: "string" } } }, + required: ["episodeIds"], + }, + ], + }, + }, + }, + }, + responses: { "200": { description: "Zurückgenommen" } }, + }, + }, + "/suche": { + get: { + summary: "TMDB durchsuchen", + parameters: [ + { name: "q", in: "query", required: true, schema: { type: "string", minLength: 2 } }, + { name: "typ", in: "query", schema: { type: "string", enum: ["serie", "film"] } }, + ], + responses: { + "200": { description: "Treffer mit TMDB-Kennung" }, + "503": { description: "Kein TMDB-Schlüssel hinterlegt" }, + }, + }, + }, + "/kalender": { + get: { + summary: "Kommende Episoden", + parameters: [ + { name: "von", in: "query", schema: { type: "string", format: "date" } }, + { name: "bis", in: "query", schema: { type: "string", format: "date" } }, + ], + responses: { "200": { description: "Termine im Zeitraum" } }, + }, + }, + "/statistik": { + get: { + summary: "Kennzahlen und Verteilungen", + parameters: [{ name: "jahr", in: "query", schema: { type: "integer" } }], + responses: { "200": { description: "Statistik" } }, + }, + }, + "/universen": { + get: { summary: "Universen auflisten", responses: { "200": { description: "Universen" } } }, + post: { + summary: "Universum anlegen", + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + properties: { name: { type: "string" }, description: { type: "string" } }, + required: ["name"], + }, + }, + }, + }, + responses: { "200": { description: "Angelegt" } }, + }, + }, + "/universen/{id}": { + parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], + get: { + summary: "Universum mit Reihenfolge und Fortschritt", + parameters: [{ name: "reihenfolge", in: "query", schema: { type: "string" } }], + responses: { "200": { description: "Universum" }, "404": { description: "Unbekannt" } }, + }, + post: { + summary: "Titel ins Universum aufnehmen", + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + properties: { titleId: { type: "string" } }, + required: ["titleId"], + }, + }, + }, + }, + responses: { "200": { description: "Aufgenommen" } }, + }, + }, + "/export": { + get: { + summary: "Eigene Daten exportieren", + parameters: [ + { name: "format", in: "query", schema: { type: "string", enum: ["json", "csv"] } }, + ], + responses: { "200": { description: "Datei" } }, + }, + }, + }, + }; + + return NextResponse.json(document, { + headers: { "cache-control": "public, max-age=300" }, + }); +} diff --git a/src/app/api/v1/statistik/route.ts b/src/app/api/v1/statistik/route.ts new file mode 100644 index 0000000..9ee0dd9 --- /dev/null +++ b/src/app/api/v1/statistik/route.ts @@ -0,0 +1,25 @@ +import { withApi } from "@/lib/api"; +import { + getGenreDistribution, + getMonthlyActivity, + getProviderDistribution, + getTotals, + getYearReview, +} from "@/lib/stats"; + +/** GET /api/v1/statistik?jahr=2026 — alle Kennzahlen auf einmal. */ +export const GET = withApi(async (request, user) => { + const url = new URL(request.url); + const parsed = Number.parseInt(url.searchParams.get("jahr") ?? "", 10); + const year = Number.isFinite(parsed) ? parsed : new Date().getFullYear(); + + const [totals, monthly, providers, genres, review] = await Promise.all([ + getTotals(user.id), + getMonthlyActivity(user.id, 12), + getProviderDistribution(user.id), + getGenreDistribution(user.id), + getYearReview(user.id, year), + ]); + + return { totals, monthly, providers, genres, yearReview: review }; +}); diff --git a/src/app/api/v1/suche/route.ts b/src/app/api/v1/suche/route.ts new file mode 100644 index 0000000..22a8dd3 --- /dev/null +++ b/src/app/api/v1/suche/route.ts @@ -0,0 +1,53 @@ +import { apiError, withApi } from "@/lib/api"; +import { env } from "@/lib/env"; +import { searchMovies, searchMulti, searchSeries, TmdbNotConfiguredError } from "@/lib/tmdb"; + +/** + * GET /api/v1/suche?q=…&typ=serie|film + * + * Durchsucht TMDB. Die Treffer tragen die TMDB-ID — damit lässt sich der + * Titel über POST /api/v1/bibliothek direkt aufnehmen. + */ +export const GET = withApi(async (request) => { + const url = new URL(request.url); + const query = (url.searchParams.get("q") ?? "").trim(); + const type = url.searchParams.get("typ"); + + if (query.length < 2) { + return apiError("Der Suchbegriff braucht mindestens zwei Zeichen.", 400); + } + if (!env.tmdb.isConfigured) { + return apiError("Auf dieser Instanz ist kein TMDB-Schlüssel hinterlegt.", 503); + } + + try { + const data = + type === "serie" + ? await searchSeries(query) + : type === "film" + ? await searchMovies(query) + : await searchMulti(query); + + return { + query, + total: data.total_results, + items: data.results.map((item) => { + const isSeries = + item.media_type === "tv" || (type === "serie" && item.media_type === undefined); + const date = isSeries ? item.first_air_date : item.release_date; + return { + tmdbId: item.id, + type: isSeries ? "SERIES" : "MOVIE", + title: item.name ?? item.title ?? "Ohne Titel", + overview: item.overview ?? null, + posterPath: item.poster_path ?? null, + year: date ? Number.parseInt(date.slice(0, 4), 10) : null, + voteAverage: item.vote_average ?? null, + }; + }), + }; + } catch (error) { + if (error instanceof TmdbNotConfiguredError) return apiError(error.message, 503); + return apiError(error instanceof Error ? error.message : "Die Suche ist fehlgeschlagen.", 502); + } +}); diff --git a/src/app/api/v1/universen/[id]/route.ts b/src/app/api/v1/universen/[id]/route.ts new file mode 100644 index 0000000..f670157 --- /dev/null +++ b/src/app/api/v1/universen/[id]/route.ts @@ -0,0 +1,27 @@ +import { z } from "zod"; +import { apiError, readJson, withApi } from "@/lib/api"; +import { addTitle, getUniverse } from "@/lib/universes"; + +/** GET /api/v1/universen/{id}?reihenfolge=… — Inhalt und Fortschritt. */ +export const GET = withApi(async (request, user, context) => { + const { id } = await context.params; + if (!id) return apiError("Es fehlt die Kennung des Universums.", 400); + + const url = new URL(request.url); + const universe = await getUniverse(user.id, id, url.searchParams.get("reihenfolge") ?? undefined); + if (!universe) return apiError("Dieses Universum gibt es nicht.", 404); + + return universe; +}); + +const addSchema = z.object({ titleId: z.string().min(1) }); + +/** POST /api/v1/universen/{id} — Titel aufnehmen. */ +export const POST = withApi(async (request, user, context) => { + const { id } = await context.params; + if (!id) return apiError("Es fehlt die Kennung des Universums.", 400); + + const body = await readJson(request, addSchema); + await addTitle(user.id, id, body.titleId); + return { ok: true }; +}); diff --git a/src/app/api/v1/universen/route.ts b/src/app/api/v1/universen/route.ts new file mode 100644 index 0000000..879dc65 --- /dev/null +++ b/src/app/api/v1/universen/route.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; +import { readJson, withApi } from "@/lib/api"; +import { createUniverse, listUniverses } from "@/lib/universes"; + +/** GET /api/v1/universen — eigene und geteilte Universen. */ +export const GET = withApi(async (_request, user) => { + const universes = await listUniverses(user.id); + return { total: universes.length, items: universes }; +}); + +const createSchema = z.object({ + name: z.string().trim().min(1).max(120), + description: z.string().max(2000).optional(), +}); + +/** POST /api/v1/universen — neues Universum anlegen. */ +export const POST = withApi(async (request, user) => { + const body = await readJson(request, createSchema); + const universe = await createUniverse(user.id, body.name, body.description); + return { id: universe.id, name: universe.name, slug: universe.slug }; +}); diff --git a/src/lib/api.ts b/src/lib/api.ts new file mode 100644 index 0000000..d3efcfb --- /dev/null +++ b/src/lib/api.ts @@ -0,0 +1,89 @@ +import "server-only"; +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { getApiUser, type SessionUser } from "./auth"; +import { LibraryError } from "./library"; +import { UniverseError } from "./universes"; + +/** + * Gemeinsames Gerüst der REST-API. + * + * Zwei Dinge macht jeder Endpunkt gleich: die Anmeldung prüfen (Bearer-Token + * oder Sitzungscookie — dieselben Endpunkte bedienen Skripte und das eigene + * Frontend) und Fehler in eine einheitliche Form bringen. + */ + +export type ApiHandler = ( + request: Request, + user: SessionUser, + context: { params: Promise> }, +) => Promise; + +export function apiError(message: string, status = 400, details?: unknown) { + return NextResponse.json({ error: message, ...(details ? { details } : {}) }, { status }); +} + +/** + * Umhüllt einen Endpunkt: Anmeldung, Fehlerbehandlung, JSON-Antwort. + * Gibt der Handler bereits eine Response zurück, wird sie durchgereicht. + */ +export function withApi(handler: ApiHandler) { + return async (request: Request, context: { params: Promise> }) => { + const user = await getApiUser(request); + if (!user) { + return NextResponse.json( + { + error: + "Nicht angemeldet. Schicke einen API-Schlüssel im Kopf: Authorization: Bearer ", + }, + { status: 401, headers: { "www-authenticate": "Bearer" } }, + ); + } + + try { + const result = await handler(request, user, context); + if (result instanceof NextResponse) return result; + return NextResponse.json(result ?? { ok: true }); + } catch (error) { + if (error instanceof z.ZodError) { + return apiError("Die Anfrage ist unvollständig oder falsch aufgebaut.", 422, error.issues); + } + if (error instanceof LibraryError || error instanceof UniverseError) { + return apiError(error.message, 400); + } + console.error("API-Fehler:", error); + return apiError("Interner Fehler.", 500); + } + }; +} + +/** Liest und prüft den JSON-Rumpf einer Anfrage. */ +export async function readJson( + request: Request, + schema: S, +): Promise> { + let raw: unknown; + try { + raw = await request.json(); + } catch { + throw new z.ZodError([ + { + code: "custom", + path: [], + message: "Der Rumpf der Anfrage ist kein gültiges JSON.", + }, + ]); + } + return schema.parse(raw); +} + +/** Seitenweise Abfrage: `?limit=50&offset=0` */ +export function readPaging(request: Request, defaultLimit = 50, maxLimit = 200) { + const url = new URL(request.url); + const limit = Math.min( + maxLimit, + Math.max(1, Number.parseInt(url.searchParams.get("limit") ?? "", 10) || defaultLimit), + ); + const offset = Math.max(0, Number.parseInt(url.searchParams.get("offset") ?? "", 10) || 0); + return { limit, offset }; +}