258 lines
9.5 KiB
TypeScript
258 lines
9.5 KiB
TypeScript
import type { NextRequest } from "next/server";
|
|
import { z } from "zod";
|
|
|
|
import { getActor } from "@/lib/auth/actor";
|
|
import { resolveTargetGroup } from "@/lib/auth/groups";
|
|
import { requirePermission, visibleGroupIds } from "@/lib/auth/policy";
|
|
import { toJsonInput, toQrCodeDto } from "@/lib/codes";
|
|
import { prisma } from "@/lib/db";
|
|
import { resolveTargetDomain } from "@/lib/domains";
|
|
import { findStaticDuplicate } from "@/lib/duplicates";
|
|
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
|
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 { 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";
|
|
|
|
const createSchema = z.object({
|
|
type: z.enum(QR_TYPE_IDS, { error: "Unbekannter QR-Typ." }),
|
|
data: z.record(z.string(), z.unknown()),
|
|
design: designSchema,
|
|
label: z
|
|
.string()
|
|
.trim()
|
|
.max(120, { error: "Der Name darf höchstens 120 Zeichen lang sein." })
|
|
.optional(),
|
|
/**
|
|
* Statisch oder dynamisch. Ein statischer Code wird nur abgelegt, damit man
|
|
* ihn wiederfindet — er bekommt keinen Kurz-Link und zählt keine Scans.
|
|
*/
|
|
mode: z.enum(["static", "dynamic"], { error: "Ungültiger Modus." })
|
|
.default("dynamic"),
|
|
/**
|
|
* 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. */
|
|
domainId: z.string().trim().min(1).optional(),
|
|
/**
|
|
* Eine gemeldete Dublette bewusst in Kauf nehmen. Ohne dieses Häkchen
|
|
* antwortet das Anlegen mit 409, sobald derselbe Inhalt schon als
|
|
* statischer Code in der Gruppe liegt.
|
|
*/
|
|
allowDuplicate: z.boolean().default(false),
|
|
});
|
|
|
|
/**
|
|
* Alle Dateien, die zu einem Code gehören.
|
|
*
|
|
* Das sind die des Typ-Moduls — ein PDF, ein Bild — **und** ein eigenes Logo
|
|
* aus der Gestaltung. Wird das vergessen, löst `attachUploads` das Logo beim
|
|
* nächsten Speichern wieder vom Code und räumt es später als herrenlos weg.
|
|
*/
|
|
function dateienDesCodes(
|
|
qrType: { uploadIds: (data: never) => string[] },
|
|
data: unknown,
|
|
design: { logoSource: string | null; logoUploadId: string | null },
|
|
): string[] {
|
|
const ids = qrType.uploadIds(data as never);
|
|
return design.logoSource === "upload" && design.logoUploadId
|
|
? [...ids, design.logoUploadId]
|
|
: ids;
|
|
}
|
|
|
|
function isUniqueViolation(error: unknown): boolean {
|
|
return (
|
|
typeof error === "object" &&
|
|
error !== null &&
|
|
"code" in error &&
|
|
(error as { code?: unknown }).code === "P2002"
|
|
);
|
|
}
|
|
|
|
/** Die dynamischen Codes, die der Anfragende sehen darf — neueste zuerst. */
|
|
export async function GET(): Promise<Response> {
|
|
try {
|
|
const actor = await getActor();
|
|
if (actor.kind === "anonymous") {
|
|
throw new QrRequestError("Bitte melde dich an.", 401);
|
|
}
|
|
|
|
const visible = visibleGroupIds(actor);
|
|
const codes = await prisma.qrCode.findMany({
|
|
where: visible === null ? undefined : { groupId: { in: visible } },
|
|
orderBy: { createdAt: "desc" },
|
|
include: {
|
|
group: { select: { name: true } },
|
|
domain: { select: { hostname: true } },
|
|
createdBy: { select: { name: true, email: true } },
|
|
aliases: { select: { slug: true }, orderBy: { createdAt: "desc" } },
|
|
},
|
|
});
|
|
|
|
return Response.json({ codes: codes.map(toQrCodeDto) });
|
|
} catch (error) {
|
|
return errorResponse(error);
|
|
}
|
|
}
|
|
|
|
/** Legt einen dynamischen Code an und vergibt dafür eine freie Kurz-URL. */
|
|
export async function POST(request: NextRequest): Promise<Response> {
|
|
try {
|
|
const actor = await getActor();
|
|
|
|
const parsed = createSchema.safeParse(await request.json());
|
|
if (!parsed.success) {
|
|
return jsonError(firstZodMessage(parsed.error));
|
|
}
|
|
|
|
const { type, design, label } = parsed.data;
|
|
|
|
// Zielgruppe zuerst: davon hängt ab, ob überhaupt angelegt werden darf.
|
|
const groupId = await resolveTargetGroup(actor, parsed.data.groupId);
|
|
requirePermission(actor, "codes:create", { groupId });
|
|
|
|
// Die Domain bestimmt, unter welchem Hostnamen die Kurz-URL erreichbar
|
|
// ist — und der Slug ist nur je Domain eindeutig.
|
|
const domainId = await resolveTargetDomain(actor, groupId, parsed.data.domainId);
|
|
|
|
// Die Freigabe haengt an der Zielgruppe — global abgeschaltet gewinnt.
|
|
const qrType = await requireAvailableQrType(type, groupId);
|
|
|
|
const validated = qrType.validate(parsed.data.data);
|
|
if (!validated.ok) {
|
|
return jsonError(validated.message);
|
|
}
|
|
|
|
const istStatisch = parsed.data.mode === "static";
|
|
|
|
if (istStatisch) {
|
|
// Ein statischer Code trägt seinen Inhalt selbst — der Typ muss das
|
|
// können, sonst gäbe es nichts zu kodieren.
|
|
if (!qrType.supportsStatic) {
|
|
return jsonError(
|
|
`Der QR-Typ „${qrType.title}“ funktioniert nur als dynamischer Code.`,
|
|
);
|
|
}
|
|
if (parsed.data.slug) {
|
|
return jsonError(
|
|
"Ein statischer Code hat keinen Kurz-Link. Sein Inhalt steckt im Code selbst.",
|
|
);
|
|
}
|
|
} else {
|
|
// 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);
|
|
}
|
|
|
|
// Zweimal derselbe statische Inhalt ergibt zweimal dasselbe Bild. Das ist
|
|
// erlaubt, aber selten gewollt — deshalb ein Halt mit Rückfrage statt
|
|
// eines stillen zweiten Eintrags.
|
|
if (istStatisch && !parsed.data.allowDuplicate) {
|
|
const dublette = await findStaticDuplicate(groupId, qrType.id, validated.data);
|
|
if (dublette) {
|
|
return Response.json(
|
|
{
|
|
error: dublette.label
|
|
? `Diesen Inhalt hast du schon abgelegt — als „${dublette.label}“.`
|
|
: "Diesen Inhalt hast du schon abgelegt.",
|
|
duplicate: dublette,
|
|
},
|
|
{ status: 409 },
|
|
);
|
|
}
|
|
}
|
|
|
|
if (istStatisch) {
|
|
const created = await prisma.qrCode.create({
|
|
data: {
|
|
mode: "static",
|
|
slug: null,
|
|
type: qrType.id,
|
|
label: label && label.length > 0 ? label : null,
|
|
data: toJsonInput(validated.data),
|
|
groupId,
|
|
domainId,
|
|
createdById: actor.kind === "user" ? actor.user.id : null,
|
|
...design,
|
|
},
|
|
include: {
|
|
group: { select: { name: true } },
|
|
domain: { select: { hostname: true } },
|
|
createdBy: { select: { name: true, email: true } },
|
|
aliases: { select: { slug: true }, orderBy: { createdAt: "desc" } },
|
|
},
|
|
});
|
|
await attachUploads(created.id, dateienDesCodes(qrType, validated.data, design));
|
|
return Response.json({ code: toQrCodeDto(created) }, { status: 201 });
|
|
}
|
|
|
|
// 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: gewuenscht ? normalizeSlug(gewuenscht) : generateSlug(),
|
|
type: qrType.id,
|
|
label: label && label.length > 0 ? label : null,
|
|
data: toJsonInput(validated.data),
|
|
groupId,
|
|
domainId,
|
|
createdById: actor.kind === "user" ? actor.user.id : null,
|
|
...design,
|
|
},
|
|
include: {
|
|
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 —
|
|
// vorher lagen sie ohne Bezug herum.
|
|
await attachUploads(created.id, dateienDesCodes(qrType, validated.data, design));
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
throw new QrRequestError(
|
|
"Es konnte keine freie Kurz-URL erzeugt werden. Bitte versuche es erneut.",
|
|
500,
|
|
);
|
|
} catch (error) {
|
|
return errorResponse(error);
|
|
}
|
|
}
|