Merge #6: Dublettenprüfung

This commit was merged in pull request #14.
This commit is contained in:
2026-08-21 08:46:49 +00:00
5 changed files with 218 additions and 30 deletions
+11
View File
@@ -122,6 +122,17 @@ Liste steht dort „statisch“ und ein Strich.
Wer nach dem Ablegen den Inhalt ändert, bekommt das Ablegen erneut angeboten — Wer nach dem Ablegen den Inhalt ändert, bekommt das Ablegen erneut angeboten —
der vorherige Eintrag bleibt als eigener Schnappschuss bestehen. 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 ### Der Kurz-Link
Ohne eigene Angabe entsteht er aus acht Zeichen eines Alphabets ohne Ohne eigene Angabe entsteht er aus acht Zeichen eines Alphabets ohne
+25
View File
@@ -7,6 +7,7 @@ import { requirePermission, visibleGroupIds } from "@/lib/auth/policy";
import { toJsonInput, toQrCodeDto } from "@/lib/codes"; import { toJsonInput, toQrCodeDto } from "@/lib/codes";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { resolveTargetDomain } from "@/lib/domains"; import { resolveTargetDomain } from "@/lib/domains";
import { findStaticDuplicate } from "@/lib/duplicates";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http"; import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { requireAvailableQrType } from "@/lib/qr/availability"; import { requireAvailableQrType } from "@/lib/qr/availability";
import { designSchema } from "@/lib/qr/design"; import { designSchema } from "@/lib/qr/design";
@@ -43,6 +44,12 @@ const createSchema = z.object({
groupId: z.string().trim().min(1).optional(), groupId: z.string().trim().min(1).optional(),
/** Unter welcher Domain die Kurz-URL entsteht. */ /** Unter welcher Domain die Kurz-URL entsteht. */
domainId: z.string().trim().min(1).optional(), 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 { function isUniqueViolation(error: unknown): boolean {
@@ -140,6 +147,24 @@ export async function POST(request: NextRequest): Promise<Response> {
await assertSlugAvailable(domainId, gewuenscht); 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) { if (istStatisch) {
const created = await prisma.qrCode.create({ const created = await prisma.qrCode.create({
data: { data: {
+88 -16
View File
@@ -43,6 +43,21 @@ function sameData(a: unknown, b: unknown): boolean {
return stableStringify(a) === stableStringify(b); 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<string, unknown>;
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({ export function QrGenerator({
typeId, typeId,
/** Darf der Kurz-Link selbst vergeben werden? Sonst erscheint das Feld nicht. */ /** Darf der Kurz-Link selbst vergeben werden? Sonst erscheint das Feld nicht. */
@@ -65,6 +80,14 @@ export function QrGenerator({
const [saved, setSaved] = useState<QrCodeDto | null>(null); const [saved, setSaved] = useState<QrCodeDto | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null); const [saveError, setSaveError] = useState<string | null>(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 [copied, setCopied] = useState(false);
const [preview, setPreview] = useState<PreviewState | null>(null); const [preview, setPreview] = useState<PreviewState | null>(null);
@@ -187,10 +210,18 @@ export function QrGenerator({
!sameData(saved.design, design) || !sameData(saved.design, design) ||
(saved.label ?? "") !== label.trim()); (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; if (!validation.ok || !designValid || slugProblem) return;
setSaving(true); setSaving(true);
setSaveError(null); setSaveError(null);
setDuplicate(null);
try { try {
const body = { const body = {
type: typeId, type: typeId,
@@ -200,6 +231,8 @@ export function QrGenerator({
label: label.trim() || undefined, label: label.trim() || undefined,
// Ein statischer Code hat keinen Kurz-Link. // Ein statischer Code hat keinen Kurz-Link.
slug: isDynamic ? wunschSlug.trim() || undefined : undefined, slug: isDynamic ? wunschSlug.trim() || undefined : undefined,
// Nur beim zweiten Anlauf, nachdem die Warnung dastand.
allowDuplicate: trotzDublette,
}; };
const result = saved const result = saved
? await apiJson<{ code: QrCodeDto }>(`/api/codes/${saved.id}`, { ? await apiJson<{ code: QrCodeDto }>(`/api/codes/${saved.id}`, {
@@ -212,11 +245,20 @@ export function QrGenerator({
}); });
setSaved(result.code); setSaved(result.code);
} catch (error) { } catch (error) {
setSaveError( // Eine gemeldete Dublette ist kein Fehler, sondern eine Rückfrage.
error instanceof ApiError const gemeldet =
? error.message error instanceof ApiError && error.status === 409
: "Der Code konnte nicht gespeichert werden.", ? 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 { } finally {
setSaving(false); setSaving(false);
} }
@@ -232,6 +274,7 @@ export function QrGenerator({
setIsDynamic(dynamisch); setIsDynamic(dynamisch);
setSaved(null); setSaved(null);
setSaveError(null); setSaveError(null);
setDuplicate(null);
}, []); }, []);
const handleCopy = useCallback(async () => { const handleCopy = useCallback(async () => {
@@ -423,14 +466,43 @@ export function QrGenerator({
</Link> </Link>
</div> </div>
) : ( ) : (
<Button <div className="space-y-3">
type="button" {dublette ? (
variant="secondary" <Notice tone="warning">
onClick={handleSave} <span className="font-medium">
disabled={saving || !ready} Diesen Inhalt hast du schon abgelegt
> </span>{" "}
{saving ? "Legt ab …" : "In „Meine Codes“ ablegen"} — {dublette.label ? `„${dublette.label}“` : "ohne Namen"}{" "}
</Button> vom {dublettenDatum.format(new Date(dublette.createdAt))}.
Zweimal derselbe Inhalt ergibt zweimal denselben Code.
<span className="mt-1 block text-xs">
Verglichen wird nur der Inhalt, nicht die Gestaltung —
derselbe Code in anderen Farben gilt hier als Dublette.
</span>
<span className="mt-2 flex flex-wrap items-center gap-3">
<Link
href="/meine-codes"
className="inline-flex items-center gap-1.5 rounded-lg text-sm font-medium underline"
>
Den vorhandenen ansehen
<Icon name="external" className="size-3.5" />
</Link>
</span>
</Notice>
) : null}
<Button
type="button"
variant="secondary"
onClick={() => void handleSave(dublette !== null)}
disabled={saving || !ready}
>
{saving
? "Legt ab …"
: dublette
? "Trotzdem ablegen"
: "In „Meine Codes“ ablegen"}
</Button>
</div>
) )
) : saved ? ( ) : saved ? (
<div className="space-y-3"> <div className="space-y-3">
@@ -460,7 +532,7 @@ export function QrGenerator({
{hasUnsavedChanges ? ( {hasUnsavedChanges ? (
<Button <Button
type="button" type="button"
onClick={handleSave} onClick={() => void handleSave()}
disabled={saving || !ready} disabled={saving || !ready}
> >
{saving ? "Speichert …" : "Änderungen speichern"} {saving ? "Speichert …" : "Änderungen speichern"}
@@ -492,7 +564,7 @@ export function QrGenerator({
</Notice> </Notice>
<Button <Button
type="button" type="button"
onClick={handleSave} onClick={() => void handleSave()}
disabled={saving || !ready || Boolean(slugProblem)} disabled={saving || !ready || Boolean(slugProblem)}
> >
{saving ? "Speichert …" : "Dynamischen Code speichern"} {saving ? "Speichert …" : "Dynamischen Code speichern"}
+33 -14
View File
@@ -1,23 +1,42 @@
/** Kleine Hilfen für Aufrufe der eigenen API aus Client-Komponenten. */ /** Kleine Hilfen für Aufrufe der eigenen API aus Client-Komponenten. */
async function readError(response: Response): Promise<string> { async function readError(response: Response): Promise<ApiError> {
let body: unknown = null;
try { try {
const body: unknown = await response.json(); body = await response.json();
if (
body !== null &&
typeof body === "object" &&
"error" in body &&
typeof (body as { error: unknown }).error === "string"
) {
return (body as { error: string }).error;
}
} catch { } catch {
// Antwort ohne JSON-Körper — unten kommt die allgemeine Meldung. // Antwort ohne JSON-Körper — unten kommt die allgemeine Meldung.
} }
return `Die Anfrage ist fehlgeschlagen (HTTP ${response.status}).`;
const message =
body !== null &&
typeof body === "object" &&
"error" in body &&
typeof (body as { error: unknown }).error === "string"
? (body as { error: string }).error
: `Die Anfrage ist fehlgeschlagen (HTTP ${response.status}).`;
return new ApiError(message, response.status, body);
} }
export class ApiError extends Error {} /**
* Fehler einer eigenen API-Antwort.
*
* Trägt neben der Meldung auch den Status und den Antwortkörper: Manche
* Fehler sind keine Sackgasse, sondern eine Rückfrage — etwa die gemeldete
* Dublette beim Ablegen, zu der die Oberfläche den vorhandenen Eintrag
* anzeigen will.
*/
export class ApiError extends Error {
readonly status: number;
readonly details: unknown;
constructor(message: string, status = 0, details: unknown = null) {
super(message);
this.status = status;
this.details = details;
}
}
export async function apiJson<T>( export async function apiJson<T>(
url: string, url: string,
@@ -34,7 +53,7 @@ export async function apiJson<T>(
}); });
if (!response.ok) { if (!response.ok) {
throw new ApiError(await readError(response)); throw await readError(response);
} }
if (response.status === 204) { if (response.status === 204) {
return undefined as T; return undefined as T;
@@ -55,7 +74,7 @@ export async function downloadFile(
}); });
if (!response.ok) { if (!response.ok) {
throw new ApiError(await readError(response)); throw await readError(response);
} }
const disposition = response.headers.get("Content-Disposition") ?? ""; const disposition = response.headers.get("Content-Disposition") ?? "";
+61
View File
@@ -0,0 +1,61 @@
import "server-only";
import { toJsonInput } from "@/lib/codes";
import { prisma } from "@/lib/db";
/**
* Dublettenprüfung für statische Codes.
*
* Ein statischer Code trägt seinen Inhalt selbst — zweimal derselbe Inhalt
* ergibt zweimal exakt dasselbe Bild. Solche Einträge sind nicht falsch, aber
* sie fluten die Liste, und beim Wiederfinden weiß niemand mehr, welcher der
* beiden gemeint war.
*
* Bewusst **nur** für statische Codes: Zwei dynamische Codes mit demselben
* Ziel sind ein üblicher Fall — zwei Kampagnen, zwei Kurz-Links, getrennte
* Zähler. Da wäre eine Warnung nur lästig.
*
* Bewusst **ohne** die Gestaltung: Verglichen wird der Inhalt. Derselbe Inhalt
* einmal in Schwarz-Weiß für den Druck und einmal in Hausfarben ist zwar
* bewusst zweimal angelegt — aber eben auch zweimal derselbe Code. Die
* Warnung sagt das und lässt sich übergehen.
*/
export type StaticDuplicate = {
id: string;
label: string | null;
createdAt: string;
};
/**
* Sucht einen bereits abgelegten statischen Code mit gleichem Typ und
* gleichem Inhalt in derselben Gruppe — den zuletzt angelegten.
*
* Die Gruppe grenzt bewusst ein: Ein Treffer aus einer fremden Gruppe wäre
* nicht bloß nutzlos, er verriete auch deren Bestand.
*/
export async function findStaticDuplicate(
groupId: string,
type: string,
data: Record<string, unknown>,
): Promise<StaticDuplicate | null> {
const treffer = await prisma.qrCode.findFirst({
where: {
mode: "static",
type,
groupId,
// Postgres vergleicht jsonb nach Struktur, nicht nach Schreibweise —
// Schlüsselreihenfolge und Leerzeichen spielen keine Rolle.
data: { equals: toJsonInput(data) },
},
orderBy: { createdAt: "desc" },
select: { id: true, label: true, createdAt: true },
});
if (!treffer) return null;
return {
id: treffer.id,
label: treffer.label,
createdAt: treffer.createdAt.toISOString(),
};
}