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:
@@ -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 <QrGenerator typeId={state.qrType.id} />;
|
||||
// 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 <QrGenerator typeId={state.qrType.id} maySetSlug={maySetSlug} />;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="mx-auto max-w-6xl px-4 py-8 sm:px-6 sm:py-10">
|
||||
<div className="mb-7">
|
||||
@@ -25,7 +33,7 @@ export default async function MyCodesPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CodesList />
|
||||
<CodesList maySetSlug={maySetSlug} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10">
|
||||
<Link
|
||||
href="/verwaltung"
|
||||
className="mb-5 inline-flex items-center gap-1.5 rounded-lg text-sm font-medium text-slate-600 transition-colors hover:text-slate-900"
|
||||
>
|
||||
<Icon name="arrowLeft" className="size-4" />
|
||||
Zur Verwaltung
|
||||
</Link>
|
||||
|
||||
<div className="mb-7">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
|
||||
Einstellungen
|
||||
</h1>
|
||||
<p className="mt-2 text-slate-600">
|
||||
Was Bearbeiter dürfen und was Administratoren vorbehalten bleibt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SettingsAdmin initial={{ editorsMaySetSlug: settings.editorsMaySetSlug }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
<Tile
|
||||
href="/verwaltung/einstellungen"
|
||||
icon="lock"
|
||||
title="Einstellungen"
|
||||
description="Was Bearbeiter dürfen — etwa eigene Kurz-Links vergeben."
|
||||
hint={
|
||||
settings.editorsMaySetSlug
|
||||
? "Kurz-Links: auch Bearbeiter"
|
||||
: "Kurz-Links: nur Administratoren"
|
||||
}
|
||||
/>
|
||||
<Tile
|
||||
href="/verwaltung/erscheinungsbild"
|
||||
icon="image"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Response> {
|
||||
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,
|
||||
@@ -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({
|
||||
Reference in New Issue
Block a user