Feat #6: Dublettenprüfung #14
@@ -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
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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
@@ -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") ?? "";
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user