Merge branch 'main' into feat/11-typen-je-gruppe

This commit is contained in:
Florian Wilde
2026-08-21 10:58:08 +02:00
36 changed files with 1896 additions and 85 deletions
+45 -2
View File
@@ -7,6 +7,7 @@ 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";
@@ -43,8 +44,32 @@ const createSchema = z.object({
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" &&
@@ -141,6 +166,24 @@ export async function POST(request: NextRequest): Promise<Response> {
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: {
@@ -161,7 +204,7 @@ export async function POST(request: NextRequest): Promise<Response> {
aliases: { select: { slug: true }, orderBy: { createdAt: "desc" } },
},
});
await attachUploads(created.id, qrType.uploadIds(validated.data));
await attachUploads(created.id, dateienDesCodes(qrType, validated.data, design));
return Response.json({ code: toQrCodeDto(created) }, { status: 201 });
}
@@ -188,7 +231,7 @@ export async function POST(request: NextRequest): Promise<Response> {
});
// Erst jetzt gehören die hochgeladenen Dateien zu diesem Code —
// vorher lagen sie ohne Bezug herum.
await attachUploads(created.id, qrType.uploadIds(validated.data));
await attachUploads(created.id, dateienDesCodes(qrType, validated.data, design));
return Response.json({ code: toQrCodeDto(created) }, { status: 201 });
} catch (error) {