diff --git a/docs/funktionen.md b/docs/funktionen.md index 1662174..6305123 100644 --- a/docs/funktionen.md +++ b/docs/funktionen.md @@ -122,6 +122,17 @@ Liste steht dort „statisch“ und ein Strich. Wer nach dem Ablegen den Inhalt ändert, bekommt das Ablegen erneut angeboten — der vorherige Eintrag bleibt als eigener Schnappschuss bestehen. +**Dublettenprüfung.** Liegt in derselben Gruppe schon ein statischer Code mit +identischem Inhalt, hält das Ablegen an und zeigt, welcher Eintrag das ist und +wann er entstand. Der Knopf heißt dann **Trotzdem ablegen** — eine Warnung, +keine Sperre: Zwei Fassungen desselben Codes können gewollt sein. + +Verglichen wird nur der Inhalt, nicht die Gestaltung. Derselbe Code in +Schwarz-Weiß und in Hausfarben gilt also als Dublette — was er streng +genommen auch ist, denn gescannt wird beides Mal dasselbe. Dynamische Codes +bleiben außen vor: Zwei Kurz-Links auf dasselbe Ziel sind der Normalfall, +sobald zwei Kampagnen getrennt gezählt werden sollen. + ### Der Kurz-Link Ohne eigene Angabe entsteht er aus acht Zeichen eines Alphabets ohne diff --git a/src/app/api/codes/route.ts b/src/app/api/codes/route.ts index 239279e..d87cbea 100644 --- a/src/app/api/codes/route.ts +++ b/src/app/api/codes/route.ts @@ -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,6 +44,12 @@ 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), }); function isUniqueViolation(error: unknown): boolean { @@ -140,6 +147,24 @@ export async function POST(request: NextRequest): Promise { 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: { diff --git a/src/components/qr-generator.tsx b/src/components/qr-generator.tsx index 5f1e9f3..d3af378 100644 --- a/src/components/qr-generator.tsx +++ b/src/components/qr-generator.tsx @@ -43,6 +43,21 @@ function sameData(a: unknown, b: unknown): boolean { return stableStringify(a) === stableStringify(b); } +/** Der bereits abgelegte Code, den die Dublettenprüfung gemeldet hat. */ +type Dublette = { id: string; label: string | null; createdAt: string }; + +/** Liest die Meldung aus einer 409-Antwort — oder gibt auf. */ +function leseDublette(details: unknown): Dublette | null { + if (details === null || typeof details !== "object") return null; + const feld = (details as { duplicate?: unknown }).duplicate; + if (feld === null || typeof feld !== "object") return null; + const { id, label, createdAt } = feld as Record; + if (typeof id !== "string" || typeof createdAt !== "string") return null; + return { id, label: typeof label === "string" ? label : null, createdAt }; +} + +const dublettenDatum = new Intl.DateTimeFormat("de-DE", { dateStyle: "medium" }); + export function QrGenerator({ typeId, /** Darf der Kurz-Link selbst vergeben werden? Sonst erscheint das Feld nicht. */ @@ -65,6 +80,14 @@ export function QrGenerator({ const [saved, setSaved] = useState(null); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(null); + /** + * Gemeldete Dublette samt dem Inhalt, für den sie gilt. Ändert sich der + * Inhalt, ist die Warnung überholt und verschwindet von selbst — ohne + * einen Effekt, der sie hinterherräumt. + */ + const [duplicate, setDuplicate] = useState< + { treffer: Dublette; inhalt: string } | null + >(null); const [copied, setCopied] = useState(false); const [preview, setPreview] = useState(null); @@ -187,10 +210,18 @@ export function QrGenerator({ !sameData(saved.design, design) || (saved.label ?? "") !== label.trim()); - const handleSave = useCallback(async () => { + // Nur zeigen, solange der Inhalt derselbe ist, für den die Meldung kam. + const dublette = + duplicate !== null && + duplicate.inhalt === stableStringify(validation.ok ? validation.data : data) + ? duplicate.treffer + : null; + + const handleSave = useCallback(async (trotzDublette = false) => { if (!validation.ok || !designValid || slugProblem) return; setSaving(true); setSaveError(null); + setDuplicate(null); try { const body = { type: typeId, @@ -200,6 +231,8 @@ export function QrGenerator({ label: label.trim() || undefined, // Ein statischer Code hat keinen Kurz-Link. slug: isDynamic ? wunschSlug.trim() || undefined : undefined, + // Nur beim zweiten Anlauf, nachdem die Warnung dastand. + allowDuplicate: trotzDublette, }; const result = saved ? await apiJson<{ code: QrCodeDto }>(`/api/codes/${saved.id}`, { @@ -212,11 +245,20 @@ export function QrGenerator({ }); setSaved(result.code); } catch (error) { - setSaveError( - error instanceof ApiError - ? error.message - : "Der Code konnte nicht gespeichert werden.", - ); + // Eine gemeldete Dublette ist kein Fehler, sondern eine Rückfrage. + const gemeldet = + error instanceof ApiError && error.status === 409 + ? leseDublette(error.details) + : null; + if (gemeldet) { + setDuplicate({ treffer: gemeldet, inhalt: stableStringify(validation.data) }); + } else { + setSaveError( + error instanceof ApiError + ? error.message + : "Der Code konnte nicht gespeichert werden.", + ); + } } finally { setSaving(false); } @@ -232,6 +274,7 @@ export function QrGenerator({ setIsDynamic(dynamisch); setSaved(null); setSaveError(null); + setDuplicate(null); }, []); const handleCopy = useCallback(async () => { @@ -423,14 +466,43 @@ export function QrGenerator({ ) : ( - +
+ {dublette ? ( + + + Diesen Inhalt hast du schon abgelegt + {" "} + — {dublette.label ? `„${dublette.label}“` : "ohne Namen"}{" "} + vom {dublettenDatum.format(new Date(dublette.createdAt))}. + Zweimal derselbe Inhalt ergibt zweimal denselben Code. + + Verglichen wird nur der Inhalt, nicht die Gestaltung — + derselbe Code in anderen Farben gilt hier als Dublette. + + + + Den vorhandenen ansehen + + + + + ) : null} + +
) ) : saved ? (
@@ -460,7 +532,7 @@ export function QrGenerator({ {hasUnsavedChanges ? (