diff --git a/README.md b/README.md
index d068ed1..849b10a 100644
--- a/README.md
+++ b/README.md
@@ -31,6 +31,8 @@ entscheidet die Verwaltung — nicht gebrauchte lassen sich ausblenden.
Admin-Bereich einstellbar.
- **Hell und dunkel:** folgt dem Betriebssystem, mit Umschalter für eine
eigene Wahl.
+- **Sprechende Kurz-Links:** `qr.firma.de/r/sommeraktion` statt Zufallszeichen,
+ nachträglich änderbar — die vorherige Adresse bleibt gültig.
- **Zwei Container, kein Reverse Proxy nötig.** Migrationen laufen beim Start
von selbst; alles Nötige steht in einer `.env`.
diff --git a/docs/funktionen.md b/docs/funktionen.md
index 043810a..910e64d 100644
--- a/docs/funktionen.md
+++ b/docs/funktionen.md
@@ -104,10 +104,41 @@ Browser landet.
| Braucht diesen Server | nie | dauerhaft |
| In der Datenbank | nichts | Slug, Typ, Daten, Gestaltung, Zähler |
-Der **Slug** ist acht Zeichen lang aus einem Alphabet ohne verwechselbare
-Zeichen (kein `0`, `O`, `1`, `l`, `I`). Er ist **je Domain** eindeutig, nicht
-global — derselbe Slug darf auf zwei Domains liegen und dort auf
-Verschiedenes zeigen.
+### Der Kurz-Link
+
+Ohne eigene Angabe entsteht er aus acht Zeichen eines Alphabets ohne
+verwechselbare Zeichen (kein `0`, `O`, `1`, `l`, `I`) — Kurz-URLs werden
+gelegentlich abgetippt.
+
+**Selbst bestimmen** lässt er sich beim Anlegen und später ändern:
+
+| | |
+| --- | --- |
+| Erlaubt | Kleinbuchstaben, Ziffern, Bindestriche, `/` als Trenner |
+| Beispiele | `sommeraktion`, `tisch-14`, `kunde-a/flyer` |
+| Nicht erlaubt | Umlaute, Leerzeichen, Unterstriche, Bindestrich am Rand eines Abschnitts |
+| Länge | bis 100 Zeichen, höchstens 3 Abschnitte à 48 Zeichen |
+
+Groß- und Kleinschreibung spielt beim Aufrufen keine Rolle: `SommerAktion`
+findet `sommeraktion`. Gespeichert wird kleingeschrieben, und zwei Kurz-Links
+dürfen sich nicht nur in der Schreibweise unterscheiden.
+
+**Beim Ändern bleibt die vorherige Adresse gültig.** Das ist der Sinn der
+Sache: Auf gedruckten Codes steht die alte, und die soll weiter funktionieren.
+Frühere Adressen stehen im Bearbeiten-Dialog und lassen sich einzeln
+**freigeben** — für den Fall eines Tippfehlers, mit dem noch nichts gedruckt
+ist. Eine freigegebene Adresse ist sofort tot und danach wieder vergebbar.
+Solange sie gehalten wird, kann sie kein anderer Code übernehmen; sonst
+ließen sich fremde gedruckte Codes umlenken.
+
+**Wer darf das?** Standardmäßig nur Administratoren — ein Kurz-Link ist eine
+öffentliche Adresse, und wer ihn ändert, kann Aushänge entwerten. Unter
+Verwaltung → Einstellungen lässt sich das für Bearbeiter freigeben. Ohne das
+Recht erscheint das Feld gar nicht; Codes anlegen und deren Ziel ändern geht
+weiterhin.
+
+Der Kurz-Link ist **je Domain** eindeutig, nicht global — derselbe darf auf
+zwei Domains liegen und dort auf Verschiedenes zeigen.
Die Weiterleitung antwortet mit **302** und `Cache-Control: no-store`. Ein
permanenter Redirect würde in Browsern und Zwischenspeichern hängen bleiben —
@@ -415,6 +446,7 @@ auch hier ausgeschlossen, aus demselben Grund wie bei den übrigen Uploads.
| Bereich | Was dort geht |
| --- | --- |
+| **Einstellungen** | was Bearbeiter dürfen — etwa eigene Kurz-Links |
| **Erscheinungsbild** | Name, Logo, Favicon, Akzentfarbe |
| **QR-Typen** | einzelne Typen ausblenden |
| **Benutzer** | anlegen, deaktivieren, Passwort setzen, Instanz-Admin vergeben |
@@ -466,7 +498,7 @@ Alles, was die Oberfläche tut, geht auch über HTTP:
| `POST /api/feedback` | Rückmeldung abgeben |
| `GET/POST /api/auth` | Zustand, Anmelden, Abmelden, Passwort ändern |
| `GET /api/auth/oidc/start` · `/callback` | Anmeldung über den Anbieter |
-| `/api/admin/types` · `users` · `groups` · `domains` · `branding` | Verwaltung |
+| `/api/admin/types` · `users` · `groups` · `domains` · `branding` · `settings` | Verwaltung |
| `GET /r/{slug}` | Weiterleitung eines dynamischen Codes |
| `GET /s/{slug}` | öffentliche Landingpage |
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 8fbb738..1bf8113 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -74,6 +74,15 @@ Folgt dem Betriebssystem, mit Umschalter für Hell, Dunkel und System.
Umgesetzt über `light-dark()` statt über `dark:`-Klassen — deshalb ohne
Änderung an den rund 440 Farbklassen und ohne JavaScript für die Systemwahl.
+### Sprechende Kurz-Links (17.08.2026)
+
+Kurz-Links selbst vergeben und nachträglich ändern, mit `/` als Trenner. Die
+vorherige Adresse bleibt gültig und lässt sich einzeln freigeben — sonst würde
+jedes Umbenennen gedruckte Codes entwerten. Standardmäßig Administratoren
+vorbehalten, per Einstellung für Bearbeiter freigebbar.
+
+Schließt [Issue #1](https://git.wilde-server.de/florian.wilde/qrcode-generator/issues/1).
+
### Dokumentation (15.08.2026)
Bedienungsanleitung mit Bildern, Authentik-Anleitung, Funktionsübersicht.
diff --git a/prisma/migrations/20260817090000_eigene_kurzlinks/migration.sql b/prisma/migrations/20260817090000_eigene_kurzlinks/migration.sql
new file mode 100644
index 0000000..62bc043
--- /dev/null
+++ b/prisma/migrations/20260817090000_eigene_kurzlinks/migration.sql
@@ -0,0 +1,56 @@
+-- Selbst gewählte Kurz-Links, umbenennbar, mit weiterhin gültigen Vorgängern.
+
+-- Frühere Kurz-Links eines Codes. Ohne sie würde jedes Umbenennen die bereits
+-- gedruckten Codes entwerten.
+CREATE TABLE "QrCodeAlias" (
+ "id" TEXT NOT NULL,
+ "domainId" TEXT NOT NULL,
+ "slug" TEXT NOT NULL,
+ "qrCodeId" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "QrCodeAlias_pkey" PRIMARY KEY ("id")
+);
+
+CREATE UNIQUE INDEX "QrCodeAlias_domainId_slug_key" ON "QrCodeAlias"("domainId", "slug");
+CREATE INDEX "QrCodeAlias_qrCodeId_idx" ON "QrCodeAlias"("qrCodeId");
+
+ALTER TABLE "QrCodeAlias" ADD CONSTRAINT "QrCodeAlias_domainId_fkey"
+ FOREIGN KEY ("domainId") REFERENCES "Domain"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+ALTER TABLE "QrCodeAlias" ADD CONSTRAINT "QrCodeAlias_qrCodeId_fkey"
+ FOREIGN KEY ("qrCodeId") REFERENCES "QrCode"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- Gross- und Kleinschreibung soll beim Aufrufen keine Rolle spielen. Damit
+-- „Sommer" und „sommer" nicht als zwei verschiedene Kurz-Links vergeben werden
+-- koennen, gilt die Eindeutigkeit ab jetzt auf der kleingeschriebenen Form.
+--
+-- Zuerst pruefen, ob der bestehende Bestand das ueberhaupt zulaesst. Bei
+-- erzeugten Kurz-Links aus acht Zeichen ist eine solche Kollision praktisch
+-- ausgeschlossen; ein stiller Datenverlust waere aber unentschuldbar, deshalb
+-- bricht die Migration in dem Fall lieber ab.
+DO $$
+DECLARE kollisionen INT;
+BEGIN
+ SELECT COUNT(*) INTO kollisionen FROM (
+ SELECT "domainId", LOWER("slug")
+ FROM "QrCode"
+ GROUP BY "domainId", LOWER("slug")
+ HAVING COUNT(*) > 1
+ ) AS doppelt;
+
+ IF kollisionen > 0 THEN
+ RAISE EXCEPTION
+ 'Es gibt % Kurz-Links, die sich nur in der Gross-/Kleinschreibung unterscheiden. Bitte einen davon umbenennen und die Migration erneut starten.',
+ kollisionen;
+ END IF;
+END $$;
+
+-- Der bestehende Index bleibt: Er traegt die exakten Nachschlagevorgaenge, und
+-- das ist der haeufige Fall — ein gescannter Code liefert die Adresse genau so,
+-- wie sie gespeichert ist. Der zweite Index kommt dazu und sorgt dafuer, dass
+-- sich zwei Kurz-Links nicht nur in der Gross-/Kleinschreibung unterscheiden
+-- koennen.
+CREATE UNIQUE INDEX "QrCode_domainId_lower_slug_key"
+ ON "QrCode"("domainId", LOWER("slug"));
+CREATE UNIQUE INDEX "QrCodeAlias_domainId_lower_slug_key"
+ ON "QrCodeAlias"("domainId", LOWER("slug"));
diff --git a/prisma/migrations/20260817120000_einstellungen/migration.sql b/prisma/migrations/20260817120000_einstellungen/migration.sql
new file mode 100644
index 0000000..9216332
--- /dev/null
+++ b/prisma/migrations/20260817120000_einstellungen/migration.sql
@@ -0,0 +1,12 @@
+-- Verhalten der Installation, das sich nicht aus dem Code ergibt.
+
+CREATE TABLE "InstanceSetting" (
+ "id" TEXT NOT NULL DEFAULT 'instance',
+ "editorsMaySetSlug" BOOLEAN NOT NULL DEFAULT false,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "InstanceSetting_pkey" PRIMARY KEY ("id")
+);
+
+-- Die eine Zeile gleich anlegen, damit die Anwendung sie nur lesen muss.
+INSERT INTO "InstanceSetting" ("id", "updatedAt") VALUES ('instance', CURRENT_TIMESTAMP);
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 58ae0f5..1bda016 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -122,6 +122,7 @@ model Domain {
grants DomainGrant[]
codes QrCode[]
+ aliases QrCodeAlias[]
}
/// Zuteilung einer Domain an eine Gruppe. Eine Domain kann mehreren Gruppen
@@ -194,6 +195,8 @@ model QrCode {
uploads Upload[]
/// Rückmeldungen, die über die Landingpage eingegangen sind.
feedback Feedback[]
+ /// Frühere Kurz-Links, die weiterhin hierher führen.
+ aliases QrCodeAlias[]
@@unique([domainId, slug])
@@index([createdAt])
@@ -237,6 +240,42 @@ model Upload {
@@index([purpose])
}
+/// Verhalten der Installation, das sich nicht aus dem Code ergibt.
+///
+/// Genau eine Zeile mit fester Id — wie `Branding`, nur für Verhalten statt
+/// Aussehen. Ein eigenes Modell statt Schlüssel-Wert-Paaren, damit die Felder
+/// ihren Typ behalten.
+model InstanceSetting {
+ id String @id @default("instance")
+ /// Dürfen Bearbeiter den Kurz-Link selbst vergeben und ändern?
+ ///
+ /// Aus, weil ein Kurz-Link eine öffentliche Adresse ist: Wer ihn ändert,
+ /// entwertet im Zweifel gedruckte Codes oder besetzt Adressen, die
+ /// jemand anderes gebraucht hätte. Administratoren können das freigeben.
+ editorsMaySetSlug Boolean @default(false)
+ updatedAt DateTime @updatedAt
+}
+
+/// Ein früherer Kurz-Link, der weiterhin auf seinen Code führt.
+///
+/// Ohne das würde jedes Umbenennen die bereits gedruckten Codes entwerten —
+/// und genau deren Unveränderlichkeit ist der Sinn eines dynamischen Codes.
+/// Freigeben lässt sich ein solcher Eintrag von Hand, etwa nach einem
+/// Tippfehler, den noch niemand gedruckt hat.
+model QrCodeAlias {
+ id String @id @default(cuid())
+ /// Mitgeführt, damit die Eindeutigkeit je Domain gilt — wie beim Code selbst.
+ domainId String
+ domain Domain @relation(fields: [domainId], references: [id], onDelete: Cascade)
+ slug String
+ qrCodeId String
+ qrCode QrCode @relation(fields: [qrCodeId], references: [id], onDelete: Cascade)
+ createdAt DateTime @default(now())
+
+ @@unique([domainId, slug])
+ @@index([qrCodeId])
+}
+
/// Erscheinungsbild der Installation — Name, Logo, Favicon, Akzentfarbe.
///
/// Genau eine Zeile mit fester Id. Ein eigenes Modell statt einzelner
diff --git a/src/app/(app)/erstellen/[type]/page.tsx b/src/app/(app)/erstellen/[type]/page.tsx
index ef614a8..41df18a 100644
--- a/src/app/(app)/erstellen/[type]/page.tsx
+++ b/src/app/(app)/erstellen/[type]/page.tsx
@@ -4,6 +4,9 @@ import { notFound } from "next/navigation";
import { Icon } from "@/components/icons";
import { QrGenerator } from "@/components/qr-generator";
+import { resolveTargetGroup } from "@/lib/auth/groups";
+import { can } from "@/lib/auth/policy";
+import { getSettings } from "@/lib/settings";
import { requirePageActor } from "@/lib/auth/guard";
import { getQrTypeState } from "@/lib/qr/availability";
import { getQrType } from "@/lib/qr/registry";
@@ -47,7 +50,7 @@ function Hinweis({
}
export default async function CreatePage({ params }: Props) {
- await requirePageActor();
+ const actor = await requirePageActor();
const { type } = await params;
const state = await getQrTypeState(type);
@@ -75,5 +78,12 @@ export default async function CreatePage({ params }: Props) {
);
}
- return ;
+ // Ob der Kurz-Link selbst vergeben werden darf, haengt an Rolle und
+ // Einstellung. Das Feld erscheint nur dann — die Route prueft es ohnehin
+ // noch einmal.
+ const { editorsMaySetSlug } = await getSettings();
+ const groupId = await resolveTargetGroup(actor, undefined).catch(() => undefined);
+ const maySetSlug = can(actor, "codes:slug", { groupId, editorsMaySetSlug });
+
+ return ;
}
diff --git a/src/app/(app)/meine-codes/page.tsx b/src/app/(app)/meine-codes/page.tsx
index 9880e3c..26bcf7d 100644
--- a/src/app/(app)/meine-codes/page.tsx
+++ b/src/app/(app)/meine-codes/page.tsx
@@ -1,6 +1,8 @@
import type { Metadata } from "next";
import { CodesList } from "@/components/codes-list";
+import { can } from "@/lib/auth/policy";
+import { getSettings } from "@/lib/settings";
import { requirePageActor } from "@/lib/auth/guard";
export const metadata: Metadata = { title: "Meine Codes" };
@@ -8,10 +10,16 @@ export const metadata: Metadata = { title: "Meine Codes" };
export const dynamic = "force-dynamic";
export default async function MyCodesPage() {
- await requirePageActor();
+ const actor = await requirePageActor();
// Breiter als die übrigen Seiten: Die Liste soll viele Projekte
// nebeneinander zeigen können.
+ // Der Kurz-Link darf je Gruppe verschieden bearbeitbar sein; hier zaehlt,
+ // ob es irgendwo erlaubt ist. Die Route prueft es beim Speichern erneut,
+ // dann mit der Gruppe des betroffenen Codes.
+ const { editorsMaySetSlug } = await getSettings();
+ const maySetSlug = can(actor, "codes:slug", { editorsMaySetSlug });
+
return (
@@ -25,7 +33,7 @@ export default async function MyCodesPage() {
-
+
);
}
diff --git a/src/app/(app)/verwaltung/einstellungen/page.tsx b/src/app/(app)/verwaltung/einstellungen/page.tsx
new file mode 100644
index 0000000..a3446f0
--- /dev/null
+++ b/src/app/(app)/verwaltung/einstellungen/page.tsx
@@ -0,0 +1,39 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+
+import { Icon } from "@/components/icons";
+import { SettingsAdmin } from "@/components/settings-admin";
+import { requirePagePermission } from "@/lib/auth/guard";
+import { getSettings } from "@/lib/settings";
+
+export const metadata: Metadata = { title: "Einstellungen" };
+
+export const dynamic = "force-dynamic";
+
+export default async function SettingsPage() {
+ await requirePagePermission("instance:manage");
+ const settings = await getSettings();
+
+ return (
+
+
+
+ Zur Verwaltung
+
+
+
+
+ Einstellungen
+
+
+ Was Bearbeiter dürfen und was Administratoren vorbehalten bleibt.
+
+
+
+
+
+ );
+}
diff --git a/src/app/(app)/verwaltung/page.tsx b/src/app/(app)/verwaltung/page.tsx
index 5c0dc70..f34a0eb 100644
--- a/src/app/(app)/verwaltung/page.tsx
+++ b/src/app/(app)/verwaltung/page.tsx
@@ -6,6 +6,7 @@ import { Notice } from "@/components/ui";
import { isAuthEnabled } from "@/lib/auth/actor";
import { requirePagePermission } from "@/lib/auth/guard";
import { getBranding } from "@/lib/branding";
+import { getSettings } from "@/lib/settings";
import { prisma } from "@/lib/db";
export const metadata: Metadata = { title: "Verwaltung" };
@@ -46,13 +47,14 @@ function Tile({
export default async function AdminPage() {
await requirePagePermission("instance:manage");
- const [userCount, groupCount, codeCount, domainCount, branding] =
+ const [userCount, groupCount, codeCount, domainCount, branding, settings] =
await Promise.all([
prisma.user.count(),
prisma.group.count(),
prisma.qrCode.count(),
prisma.domain.count(),
getBranding(),
+ getSettings(),
]);
const eigenes = [
@@ -120,6 +122,17 @@ export default async function AdminPage() {
description="Festlegen, welche Typen auf der Startseite erscheinen."
hint="15 Typen insgesamt"
/>
+ {
+ try {
+ requirePermission(await getActor(), "instance:manage");
+ return Response.json({ settings: await getSettings() });
+ } catch (error) {
+ return errorResponse(error);
+ }
+}
+
+export async function PATCH(request: NextRequest): Promise {
+ 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);
+ }
+}
diff --git a/src/app/api/codes/[id]/route.ts b/src/app/api/codes/[id]/route.ts
index 3b1653d..a8d04a2 100644
--- a/src/app/api/codes/[id]/route.ts
+++ b/src/app/api/codes/[id]/route.ts
@@ -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
diff --git a/src/app/api/codes/route.ts b/src/app/api/codes/route.ts
index 5feb0e6..8aef2e4 100644
--- a/src/app/api/codes/route.ts
+++ b/src/app/api/codes/route.ts
@@ -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 {
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 {
// 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 {
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 {
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;
}
}
diff --git a/src/app/r/[slug]/route.ts b/src/app/r/[...slug]/route.ts
similarity index 89%
rename from src/app/r/[slug]/route.ts
rename to src/app/r/[...slug]/route.ts
index b5283bc..dc66aa2 100644
--- a/src/app/r/[slug]/route.ts
+++ b/src/app/r/[...slug]/route.ts
@@ -4,10 +4,13 @@ import { prisma } from "@/lib/db";
import { ensureDefaultDomain, resolveRequestDomain } from "@/lib/domains";
import { getQrType } from "@/lib/qr/registry";
import { isValidSlug } from "@/lib/slug";
+import { findCodeBySlug } from "@/lib/slug-resolve";
export const dynamic = "force-dynamic";
-type Context = { params: Promise<{ slug: string }> };
+// Ein Kurz-Link darf „/" enthalten (z. B. kunde-a/sommer), deshalb eine
+// Sammelroute: Next liefert die Abschnitte dann als Liste.
+type Context = { params: Promise<{ slug: string[] }> };
function notFound(message: string): Response {
return new Response(
@@ -44,7 +47,8 @@ export async function GET(
request: NextRequest,
context: Context,
): Promise {
- const { slug } = await context.params;
+ const abschnitte = (await context.params).slug;
+ const slug = (abschnitte ?? []).join("/");
if (!isValidSlug(slug)) {
return notFound("Diese Kurz-Adresse ist ungültig.");
@@ -65,9 +69,8 @@ export async function GET(
);
}
- const code = await prisma.qrCode.findUnique({
- where: { domainId_slug: { domainId: domain.id, slug } },
- });
+ const gefunden = await findCodeBySlug(domain.id, slug);
+ const code = gefunden?.code;
if (!code) {
return notFound(
@@ -85,7 +88,7 @@ export async function GET(
// Kein fremdes Ziel — die Seite liefern wir selbst aus. Bewusst relativ,
// damit der Aufruf auf der Adresse bleibt, über die er hereinkam (auch
// wenn das eine nicht registrierte ist, etwa eine IP im LAN).
- target = `/s/${slug}`;
+ target = `/s/${code.slug}`;
} else {
try {
// Der User-Agent geht mit: Typen wie der App-Link entscheiden daran,
diff --git a/src/app/s/[slug]/page.tsx b/src/app/s/[...slug]/page.tsx
similarity index 89%
rename from src/app/s/[slug]/page.tsx
rename to src/app/s/[...slug]/page.tsx
index 69fabaa..a6d731b 100644
--- a/src/app/s/[slug]/page.tsx
+++ b/src/app/s/[...slug]/page.tsx
@@ -3,14 +3,15 @@ import { headers } from "next/headers";
import { qrLandings } from "@/components/landing";
import { LandingNotice } from "@/components/landing/shell";
-import { prisma } from "@/lib/db";
import { ensureDefaultDomain, resolveRequestDomain } from "@/lib/domains";
import { getQrType } from "@/lib/qr/registry";
import { isValidSlug } from "@/lib/slug";
+import { findCodeBySlug } from "@/lib/slug-resolve";
export const dynamic = "force-dynamic";
-type PageProps = { params: Promise<{ slug: string }> };
+// Sammelroute, weil ein Kurz-Link „/" enthalten darf.
+type PageProps = { params: Promise<{ slug: string[] }> };
/**
* Öffentliche Landingpage eines dynamischen Codes.
@@ -21,16 +22,15 @@ type PageProps = { params: Promise<{ slug: string }> };
* Der Scan-Zähler wird bewusst **nicht** hier erhöht, sondern in `/r/[slug]`.
* Sonst zählte jedes Neuladen dieser Seite als weiterer Scan.
*/
-async function loadCode(slug: string) {
+async function loadCode(abschnitte: string[] | undefined) {
+ const slug = (abschnitte ?? []).join("/");
if (!isValidSlug(slug)) return null;
const matched = await resolveRequestDomain(await headers());
const domain = matched ?? { ...(await ensureDefaultDomain()), isActive: true };
if (!domain.isActive) return null;
- return prisma.qrCode.findUnique({
- where: { domainId_slug: { domainId: domain.id, slug } },
- });
+ return (await findCodeBySlug(domain.id, slug))?.code ?? null;
}
export async function generateMetadata({
diff --git a/src/components/codes-list.tsx b/src/components/codes-list.tsx
index aa195cf..a9aced1 100644
--- a/src/components/codes-list.tsx
+++ b/src/components/codes-list.tsx
@@ -19,6 +19,7 @@ import {
import { ApiError, apiJson } from "@/lib/api-client";
import type { QrCodeDto } from "@/lib/dto";
import { qrTypes } from "@/lib/qr/registry";
+import { describeSlugProblem } from "@/lib/slug";
import type { QrFormData } from "@/lib/qr/type-module";
const dateFormat = new Intl.DateTimeFormat("de-DE", {
@@ -65,11 +66,19 @@ function displayName(code: QrCodeDto): string {
return code.label?.trim() || code.summary;
}
-export function CodesList() {
+export function CodesList({
+ /** Darf der Kurz-Link geändert werden? Sonst erscheinen die Felder nicht. */
+ maySetSlug = true,
+}: {
+ maySetSlug?: boolean;
+}) {
const [codes, setCodes] = useState(null);
const [loadError, setLoadError] = useState(null);
const [editing, setEditing] = useState(null);
+ const [editSlug, setEditSlug] = useState("");
+ /** Welche früheren Adressen gelten sollen — was hier fehlt, wird freigegeben. */
+ const [editAliases, setEditAliases] = useState([]);
const [editData, setEditData] = useState({});
const [editLabel, setEditLabel] = useState("");
const [editError, setEditError] = useState(null);
@@ -124,9 +133,19 @@ export function CodesList() {
setEditing(code);
setEditData(code.data);
setEditLabel(code.label ?? "");
+ setEditSlug(code.slug);
+ setEditAliases(code.aliases);
setEditError(null);
};
+ const slugProblem =
+ editSlug.trim().length > 0 ? describeSlugProblem(editSlug) : null;
+ const slugGeaendert =
+ editing !== null && editSlug.trim().toLowerCase() !== editing.slug.toLowerCase();
+ const freigegeben = editing
+ ? editing.aliases.filter((alias) => !editAliases.includes(alias))
+ : [];
+
const EditForm = editing ? qrForms[editing.type] : undefined;
const editValidation = useMemo(
() => (editing ? qrTypes[editing.type].validate(editData) : null),
@@ -139,12 +158,23 @@ export function CodesList() {
setEditError(editValidation.message);
return;
}
+ if (slugProblem) {
+ setEditError(slugProblem);
+ return;
+ }
setSaving(true);
setEditError(null);
try {
await apiJson<{ code: QrCodeDto }>(`/api/codes/${editing.id}`, {
method: "PATCH",
- json: { data: editValidation.data, label: editLabel.trim() || null },
+ json: {
+ data: editValidation.data,
+ label: editLabel.trim() || null,
+ // Nur mitschicken, wenn es überhaupt bearbeitet werden durfte.
+ ...(maySetSlug
+ ? { slug: editSlug.trim(), aliases: editAliases }
+ : {}),
+ },
});
setEditing(null);
await load();
@@ -539,8 +569,8 @@ export function CodesList() {
setEditing(null)}
footer={
<>
@@ -742,12 +772,8 @@ export function CodesList() {
{editing ? (