Schliesst Issue #1. Der Wunsch betraf den Kurz-Link, nicht das Ziel: Er soll sich von Anfang an bestimmen und spaeter aendern lassen. Beim Anlegen gibt es dafuer ein Feld, im Bearbeiten-Dialog laesst er sich aendern. Erlaubt sind Kleinbuchstaben, Ziffern, Bindestriche und „/" als Trenner — damit sind Adressen wie kunde-a/sommer moeglich. Umlaute bleiben draussen, weil sie in Adressen unleserlich kodiert werden; Gross- und Kleinschreibung ist beim Aufrufen egal. Beim Aendern bleibt die vorherige Adresse gueltig. Das ist der Kern: Auf gedruckten Codes steht die alte, und genau deren Bestand ist der Sinn eines dynamischen Codes. Frueher vergebene Adressen stehen im Dialog und lassen sich einzeln freigeben — fuer den Tippfehler, mit dem noch nichts gedruckt ist. Solange eine Adresse gehalten wird, kann sie kein anderer Code uebernehmen; sonst liessen sich fremde gedruckte Codes umlenken. Vergeben und Aendern ist standardmaessig Administratoren vorbehalten. Ein Kurz-Link ist eine oeffentliche Adresse, und wer ihn aendert, kann Aushaenge entwerten. Unter Verwaltung -> Einstellungen laesst sich das fuer Bearbeiter freigeben; ohne das Recht erscheint das Feld gar nicht, Codes anlegen und deren Ziel aendern geht weiterhin. Eigene Aktion in der Policy statt Teil von codes:update, weil sie mehr wiegt. Technisch: /r und /s sind Sammelrouten, weil ein Kurz-Link „/" enthalten darf. Die Eindeutigkeit gilt zusaetzlich auf der kleingeschriebenen Form; die Migration bricht ab, falls der Bestand das nicht zulaesst, statt still Daten zu verlieren. Der bestehende exakte Index bleibt — er traegt den haeufigen Fall, den gescannten Code.
235 lines
7.7 KiB
TypeScript
235 lines
7.7 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;
|
|
|
|
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();
|
|
const umbenannt =
|
|
neuerSlug !== undefined &&
|
|
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,
|
|
slug: existing.slug,
|
|
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);
|
|
}
|
|
}
|