import type { NextRequest } from "next/server"; import { z } from "zod"; import type { Prisma } from "@/generated/prisma/client"; import { getActor } from "@/lib/auth/actor"; import { requirePermission } from "@/lib/auth/policy"; import { toJsonInput, toQrCodeDto } from "@/lib/codes"; import { prisma } from "@/lib/db"; import { errorResponse, firstZodMessage, jsonError } from "@/lib/http"; import { requireImplementedQrType } from "@/lib/qr/availability"; import { designSchema } from "@/lib/qr/design"; import { assertDynamicPossible } from "@/lib/qr/request"; import { describeSlugProblem, normalizeSlug } from "@/lib/slug"; import { assertSlugAvailable } from "@/lib/slug-resolve"; import { getSettings } from "@/lib/settings"; import { attachUpload, attachUploads, detachUpload } from "@/lib/uploads"; import { QrRequestError } from "@/lib/qr/type-module"; export const dynamic = "force-dynamic"; type Context = { params: Promise<{ id: string }> }; const WITH_RELATIONS = { group: { select: { name: true } }, domain: { select: { hostname: true } }, createdBy: { select: { name: true, email: true } }, aliases: { select: { slug: true }, orderBy: { createdAt: "desc" } }, } as const; const updateSchema = z.object({ data: z.record(z.string(), z.unknown()).optional(), design: designSchema.optional(), label: z .string() .trim() .max(120, { error: "Der Name darf höchstens 120 Zeichen lang sein." }) .nullable() .optional(), /** Neuer Kurz-Link. Der bisherige bleibt als frühere Adresse gültig. */ slug: z.string().trim().max(120).optional(), /** * Welche früheren Adressen weiterhin gelten sollen. * * Bewusst als vollständige Liste und nicht als „lösche diese eine": So * beschreibt die Anfrage den gewünschten Zustand, und die Oberfläche muss * sich nicht merken, was sie schon geschickt hat. Was fehlt, wird * freigegeben — und ist damit sofort tot. */ aliases: z.array(z.string().trim().max(120)).max(50).optional(), }); async function loadCode(id: string) { const code = await prisma.qrCode.findUnique({ where: { id }, include: WITH_RELATIONS, }); if (!code) { throw new QrRequestError("Dieser QR-Code existiert nicht (mehr).", 404); } return code; } export async function GET( _request: NextRequest, context: Context, ): Promise { try { const { id } = await context.params; const actor = await getActor(); const code = await loadCode(id); requirePermission(actor, "codes:read", { groupId: code.groupId }); return Response.json({ code: toQrCodeDto(code) }); } catch (error) { return errorResponse(error); } } /** Ändert Ziel, Name oder Gestaltung — die Kurz-URL bleibt bestehen. */ export async function PATCH( request: NextRequest, context: Context, ): Promise { try { const { id } = await context.params; const actor = await getActor(); const parsed = updateSchema.safeParse(await request.json()); if (!parsed.success) { return jsonError(firstZodMessage(parsed.error)); } const existing = await loadCode(id); requirePermission(actor, "codes:update", { groupId: existing.groupId }); const update: Prisma.QrCodeUpdateInput = {}; /** Dateien, die nach der Änderung zu diesem Code gehören. */ let neueDateien: string[] | null = null; // Der Inhalt eines statischen Codes steckt im gedruckten Bild. Ihn hier zu // ändern würde die Ablage von dem entfernen, was tatsächlich im Umlauf // ist — der Eintrag ist ein Schnappschuss, kein steuerbares Ziel. if (parsed.data.data !== undefined && existing.mode === "static") { return jsonError( "Der Inhalt eines statischen Codes lässt sich nicht ändern — er steckt im gedruckten Code selbst. Änderbar ist nur der Name.", ); } if (parsed.data.data !== undefined) { // Bewusst nur „ausprogrammiert“, nicht „freigegeben“: ein in der // Verwaltung abgeschalteter Typ soll bestehende Codes nicht // unbearbeitbar machen — sie sind längst gedruckt. const qrType = requireImplementedQrType(existing.type); const validated = qrType.validate(parsed.data.data); if (!validated.ok) { return jsonError(validated.message); } // Auch beim Bearbeiten: die neuen Daten müssen ein Weiterleitungsziel // hergeben. Sonst könnte man einen laufenden Code durch eine Änderung // stillschweigend unbrauchbar machen. assertDynamicPossible(qrType, validated.data); update.data = toJsonInput(validated.data); neueDateien = qrType.uploadIds(validated.data); } if (parsed.data.label !== undefined) { update.label = parsed.data.label && parsed.data.label.length > 0 ? parsed.data.label : null; } if (parsed.data.design !== undefined) { Object.assign(update, parsed.data.design); } // Ein eigenes Logo gehört zur Gestaltung, nicht zum Inhalt. Es muss // trotzdem an den Code gehängt werden — sonst liegt die Datei herrenlos // herum und wird nach einem Tag weggeräumt, mitsamt dem Logo im Code. const logoVorher = existing.logoSource === "upload" ? existing.logoUploadId : null; const logoNachher = parsed.data.design === undefined ? logoVorher : parsed.data.design.logoSource === "upload" ? parsed.data.design.logoUploadId : null; /* ------------------------------------------------------- Kurz-Link */ const neuerSlug = parsed.data.slug?.trim(); // Ein statischer Code hat keinen Kurz-Link — sein Inhalt steckt in ihm // selbst. Ein Umbenennen waere gegenstandslos. if (existing.mode === "static" && neuerSlug) { return jsonError( "Ein statischer Code hat keinen Kurz-Link. Sein Inhalt steckt im Code selbst.", ); } const umbenannt = neuerSlug !== undefined && existing.slug !== null && normalizeSlug(neuerSlug) !== normalizeSlug(existing.slug); /** * Welche früheren Adressen gelten sollen. Fehlt das Feld, bleibt alles wie * es ist; steht es da, beschreibt es den gewünschten Zustand. */ const behalten = parsed.data.aliases === undefined ? null : new Set(parsed.data.aliases.map(normalizeSlug)); const vorhandeneAliase = await prisma.qrCodeAlias.findMany({ where: { qrCodeId: existing.id }, select: { id: true, slug: true }, }); const freigeben = behalten ? vorhandeneAliase.filter((a) => !behalten.has(normalizeSlug(a.slug))) : []; // Erst prüfen, wenn wirklich etwas am Kurz-Link geändert wird. Die // Oberfläche schickt die Liste der früheren Adressen bei jeder Änderung // mit — unverändert soll das kein zusätzliches Recht verlangen. if (umbenannt || freigeben.length > 0) { const { editorsMaySetSlug } = await getSettings(); requirePermission(actor, "codes:slug", { groupId: existing.groupId, editorsMaySetSlug, }); } if (umbenannt) { const problem = describeSlugProblem(neuerSlug); if (problem) return jsonError(problem); await assertSlugAvailable(existing.domainId, neuerSlug, existing.id); update.slug = normalizeSlug(neuerSlug); } if ( Object.keys(update).length === 0 && !umbenannt && freigeben.length === 0 ) { return jsonError("Es wurden keine Änderungen übermittelt."); } // Umbenennen, alte Adresse sichern und Freigaben in einem Zug — sonst // gäbe es einen Moment, in dem der Code unter keiner Adresse erreichbar // ist, oder eine freigegebene Adresse bliebe hängen. const updated = await prisma.$transaction(async (tx) => { if (freigeben.length > 0) { await tx.qrCodeAlias.deleteMany({ where: { id: { in: freigeben.map((a) => a.id) } }, }); } if (umbenannt) { // Der bisherige Kurz-Link bleibt gültig — daran hängen gedruckte // Codes. Freigeben lässt er sich danach einzeln. await tx.qrCodeAlias.create({ data: { domainId: existing.domainId, // `umbenannt` gilt nur bei vorhandenem Kurz-Link. slug: existing.slug as string, qrCodeId: existing.id, }, }); } return tx.qrCode.update({ where: { id }, data: update, include: WITH_RELATIONS, }); }); // Eine ausgetauschte Datei verliert dabei ihren Bezug zum Code und wird // später als verwaist weggeräumt. if (neueDateien !== null) { await attachUploads( updated.id, logoNachher ? [...neueDateien, logoNachher] : neueDateien, ); } else if (logoNachher && logoNachher !== logoVorher) { await attachUpload(updated.id, logoNachher); } // Ein abgelegtes oder ausgetauschtes Logo verliert seinen Bezug und wird // später weggeräumt. if (logoVorher && logoVorher !== logoNachher) { await detachUpload(updated.id, logoVorher); } return Response.json({ code: toQrCodeDto(updated) }); } catch (error) { return errorResponse(error); } } export async function DELETE( _request: NextRequest, context: Context, ): Promise { try { const { id } = await context.params; const actor = await getActor(); const existing = await loadCode(id); requirePermission(actor, "codes:delete", { groupId: existing.groupId }); await prisma.qrCode.delete({ where: { id } }); return new Response(null, { status: 204 }); } catch (error) { return errorResponse(error); } }