Sprechende Kurz-Links: selbst vergeben, aendern, frueher gueltig halten
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.
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getActor } from "@/lib/auth/actor";
|
||||
import { requirePermission } from "@/lib/auth/policy";
|
||||
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||
import { getSettings, saveSettings } from "@/lib/settings";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const updateSchema = z.object({
|
||||
editorsMaySetSlug: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export async function GET(): Promise<Response> {
|
||||
try {
|
||||
requirePermission(await getActor(), "instance:manage");
|
||||
return Response.json({ settings: await getSettings() });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest): Promise<Response> {
|
||||
try {
|
||||
requirePermission(await getActor(), "instance:manage");
|
||||
|
||||
const parsed = updateSchema.safeParse(await request.json());
|
||||
if (!parsed.success) {
|
||||
return jsonError(firstZodMessage(parsed.error));
|
||||
}
|
||||
if (Object.keys(parsed.data).length === 0) {
|
||||
return jsonError("Es wurden keine Änderungen übermittelt.");
|
||||
}
|
||||
|
||||
return Response.json({ settings: await saveSettings(parsed.data) });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,9 @@ 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";
|
||||
|
||||
@@ -21,6 +24,7 @@ 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({
|
||||
@@ -32,6 +36,17 @@ const updateSchema = z.object({
|
||||
.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) {
|
||||
@@ -109,14 +124,84 @@ export async function PATCH(
|
||||
Object.assign(update, parsed.data.design);
|
||||
}
|
||||
|
||||
if (Object.keys(update).length === 0) {
|
||||
/* ------------------------------------------------------- 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.");
|
||||
}
|
||||
|
||||
const updated = await prisma.qrCode.update({
|
||||
where: { id },
|
||||
data: update,
|
||||
include: WITH_RELATIONS,
|
||||
// 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
|
||||
|
||||
@@ -12,7 +12,9 @@ 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 { generateSlug } from "@/lib/slug";
|
||||
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";
|
||||
@@ -26,6 +28,11 @@ const createSchema = z.object({
|
||||
.trim()
|
||||
.max(120, { error: "Der Name darf höchstens 120 Zeichen lang sein." })
|
||||
.optional(),
|
||||
/**
|
||||
* 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. */
|
||||
@@ -57,6 +64,7 @@ export async function GET(): Promise<Response> {
|
||||
group: { select: { name: true } },
|
||||
domain: { select: { hostname: true } },
|
||||
createdBy: { select: { name: true, email: true } },
|
||||
aliases: { select: { slug: true }, orderBy: { createdAt: "desc" } },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -96,12 +104,25 @@ export async function POST(request: NextRequest): Promise<Response> {
|
||||
// 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);
|
||||
}
|
||||
|
||||
// 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: generateSlug(),
|
||||
slug: gewuenscht ? normalizeSlug(gewuenscht) : generateSlug(),
|
||||
type: qrType.id,
|
||||
label: label && label.length > 0 ? label : null,
|
||||
data: toJsonInput(validated.data),
|
||||
@@ -114,6 +135,7 @@ export async function POST(request: NextRequest): Promise<Response> {
|
||||
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 —
|
||||
@@ -122,6 +144,14 @@ export async function POST(request: NextRequest): Promise<Response> {
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user