Files
qrcode-generator/src/app/api/codes/[id]/route.ts
T
Florian Wilde eeaf3820b7 Statische Codes benennen und auf Wunsch ablegen
Das Feld „Name (optional)" stand nur im Dynamisch-Block. Bei einem statischen
Code gab es damit keine Moeglichkeit, ihm einen Namen zum Wiederfinden zu
geben.

Jetzt gilt es in beiden Betriebsarten und tut zweierlei: Es bestimmt den
Dateinamen beim Herunterladen — aus „Plakat Eingang" wird
qr-plakat-eingang.png statt qr-example-com-plakat.png — und es laesst sich
damit ein statischer Code in „Meine Codes" ablegen.

Ein abgelegter statischer Code ist ein Schnappschuss, kein steuerbares Ziel.
Aenderbar ist nur der Name; Inhalt und Gestaltung bleiben, wie sie beim
Ablegen waren, denn genau das steckt im gedruckten Bild. Er bekommt keinen
Kurz-Link, ist unter /r nicht erreichbar und zaehlt keine Scans — in der
Liste steht dort „statisch" und ein Strich. Wer nach dem Ablegen den Inhalt
aendert, bekommt das Ablegen erneut angeboten; der vorherige Eintrag bleibt
als eigener Schnappschuss.

Dafuer traegt QrCode jetzt `mode`, und `slug` darf leer sein. In PostgreSQL
schliessen sich mehrere NULL in einem eindeutigen Index nicht aus, die
Bedingung „je Domain eindeutig" gilt fuer dynamische Codes also unveraendert.

Refs #2
2026-08-20 19:33:49 +02:00

253 lines
8.5 KiB
TypeScript

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 { attachUploads } 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<Response> {
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<Response> {
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);
}
/* ------------------------------------------------------- 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, neueDateien);
}
return Response.json({ code: toQrCodeDto(updated) });
} catch (error) {
return errorResponse(error);
}
}
export async function DELETE(
_request: NextRequest,
context: Context,
): Promise<Response> {
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);
}
}