import type { NextRequest } from "next/server"; import { z } from "zod"; import { getActor } from "@/lib/auth/actor"; import { resolveTargetGroup } from "@/lib/auth/groups"; import { requirePermission, visibleGroupIds } from "@/lib/auth/policy"; import { toJsonInput, toQrCodeDto } from "@/lib/codes"; import { prisma } from "@/lib/db"; import { resolveTargetDomain } from "@/lib/domains"; import { errorResponse, firstZodMessage, jsonError } from "@/lib/http"; import { requireAvailableQrType } from "@/lib/qr/availability"; import { designSchema } from "@/lib/qr/design"; import { assertDynamicPossible } from "@/lib/qr/request"; import { QR_TYPE_IDS, QrRequestError } from "@/lib/qr/type-module"; import { describeSlugProblem, generateSlug, normalizeSlug } from "@/lib/slug"; import { assertSlugAvailable } from "@/lib/slug-resolve"; import { getSettings } from "@/lib/settings"; import { attachUploads } from "@/lib/uploads"; export const dynamic = "force-dynamic"; const createSchema = z.object({ type: z.enum(QR_TYPE_IDS, { error: "Unbekannter QR-Typ." }), data: z.record(z.string(), z.unknown()), design: designSchema, label: z .string() .trim() .max(120, { error: "Der Name darf höchstens 120 Zeichen lang sein." }) .optional(), /** * Statisch oder dynamisch. Ein statischer Code wird nur abgelegt, damit man * ihn wiederfindet — er bekommt keinen Kurz-Link und zählt keine Scans. */ mode: z.enum(["static", "dynamic"], { error: "Ungültiger Modus." }) .default("dynamic"), /** * Selbst gewählter Kurz-Link. Ohne Angabe wird einer erzeugt. * Geprüft wird er in `describeSlugProblem` — hier nur die grobe Länge. */ slug: z.string().trim().max(120).optional(), /** In welcher Gruppe der Code angelegt wird. Ohne Anmeldung bedeutungslos. */ groupId: z.string().trim().min(1).optional(), /** Unter welcher Domain die Kurz-URL entsteht. */ domainId: z.string().trim().min(1).optional(), }); function isUniqueViolation(error: unknown): boolean { return ( typeof error === "object" && error !== null && "code" in error && (error as { code?: unknown }).code === "P2002" ); } /** Die dynamischen Codes, die der Anfragende sehen darf — neueste zuerst. */ export async function GET(): Promise { try { const actor = await getActor(); if (actor.kind === "anonymous") { throw new QrRequestError("Bitte melde dich an.", 401); } const visible = visibleGroupIds(actor); const codes = await prisma.qrCode.findMany({ where: visible === null ? undefined : { groupId: { in: visible } }, orderBy: { createdAt: "desc" }, include: { group: { select: { name: true } }, domain: { select: { hostname: true } }, createdBy: { select: { name: true, email: true } }, aliases: { select: { slug: true }, orderBy: { createdAt: "desc" } }, }, }); return Response.json({ codes: codes.map(toQrCodeDto) }); } catch (error) { return errorResponse(error); } } /** Legt einen dynamischen Code an und vergibt dafür eine freie Kurz-URL. */ export async function POST(request: NextRequest): Promise { try { const actor = await getActor(); const parsed = createSchema.safeParse(await request.json()); if (!parsed.success) { return jsonError(firstZodMessage(parsed.error)); } const { type, design, label } = parsed.data; // Zielgruppe zuerst: davon hängt ab, ob überhaupt angelegt werden darf. const groupId = await resolveTargetGroup(actor, parsed.data.groupId); requirePermission(actor, "codes:create", { groupId }); // Die Domain bestimmt, unter welchem Hostnamen die Kurz-URL erreichbar // ist — und der Slug ist nur je Domain eindeutig. const domainId = await resolveTargetDomain(actor, groupId, parsed.data.domainId); const qrType = await requireAvailableQrType(type); const validated = qrType.validate(parsed.data.data); if (!validated.ok) { return jsonError(validated.message); } const istStatisch = parsed.data.mode === "static"; if (istStatisch) { // Ein statischer Code trägt seinen Inhalt selbst — der Typ muss das // können, sonst gäbe es nichts zu kodieren. if (!qrType.supportsStatic) { return jsonError( `Der QR-Typ „${qrType.title}“ funktioniert nur als dynamischer Code.`, ); } if (parsed.data.slug) { return jsonError( "Ein statischer Code hat keinen Kurz-Link. Sein Inhalt steckt im Code selbst.", ); } } else { // Erst hier, nach der Validierung: die Probe braucht die geprüften Daten. assertDynamicPossible(qrType, validated.data); } // Ein selbst gewählter Kurz-Link wird geprüft, bevor irgendetwas entsteht — // sonst stünde am Ende ein Code mit einer Adresse, die niemand wollte. const gewuenscht = parsed.data.slug?.trim(); if (gewuenscht) { // Einen Kurz-Link selbst zu vergeben wiegt mehr als einen Code anzulegen. const { editorsMaySetSlug } = await getSettings(); requirePermission(actor, "codes:slug", { groupId, editorsMaySetSlug }); const problem = describeSlugProblem(gewuenscht); if (problem) return jsonError(problem); await assertSlugAvailable(domainId, gewuenscht); } if (istStatisch) { const created = await prisma.qrCode.create({ data: { mode: "static", slug: null, type: qrType.id, label: label && label.length > 0 ? label : null, data: toJsonInput(validated.data), groupId, domainId, createdById: actor.kind === "user" ? actor.user.id : null, ...design, }, include: { group: { select: { name: true } }, domain: { select: { hostname: true } }, createdBy: { select: { name: true, email: true } }, aliases: { select: { slug: true }, orderBy: { createdAt: "desc" } }, }, }); await attachUploads(created.id, qrType.uploadIds(validated.data)); return Response.json({ code: toQrCodeDto(created) }, { status: 201 }); } // Slug-Kollisionen sind extrem unwahrscheinlich, aber nicht unmöglich. for (let attempt = 0; attempt < 5; attempt += 1) { try { const created = await prisma.qrCode.create({ data: { slug: gewuenscht ? normalizeSlug(gewuenscht) : generateSlug(), type: qrType.id, label: label && label.length > 0 ? label : null, data: toJsonInput(validated.data), groupId, domainId, createdById: actor.kind === "user" ? actor.user.id : null, ...design, }, include: { group: { select: { name: true } }, domain: { select: { hostname: true } }, createdBy: { select: { name: true, email: true } }, aliases: { select: { slug: true }, orderBy: { createdAt: "desc" } }, }, }); // Erst jetzt gehören die hochgeladenen Dateien zu diesem Code — // vorher lagen sie ohne Bezug herum. await attachUploads(created.id, qrType.uploadIds(validated.data)); return Response.json({ code: toQrCodeDto(created) }, { status: 201 }); } catch (error) { // Bei einem selbst gewählten Kurz-Link hilft kein zweiter Versuch: Er // käme immer wieder auf dieselbe Adresse. if (gewuenscht && isUniqueViolation(error)) { return jsonError( `Der Kurz-Link „${normalizeSlug(gewuenscht)}" ist auf dieser Domain schon vergeben.`, 409, ); } if (!isUniqueViolation(error)) throw error; } } throw new QrRequestError( "Es konnte keine freie Kurz-URL erzeugt werden. Bitte versuche es erneut.", 500, ); } catch (error) { return errorResponse(error); } }