Phase C: die letzten sechs Typen — PDF, Bild, Feedback und Datei-Upload
Damit sind alle 15 QR-Typen gebaut. PDF und Bild brauchen eine hochgeladene Datei. Die liegt in der Datenbank, nicht auf der Platte: damit bleibt es bei einem Volumen, das gesichert werden muss — ein zweites wird beim Sichern zu leicht vergessen. Geprueft wird der Dateiinhalt, nicht die Endung; eine Textdatei namens harmlos.pdf kommt nicht durch. SVG ist bewusst nicht erlaubt, weil es Skripte enthalten darf und von derselben Adresse wie die Anwendung ausgeliefert wuerde. Ausgeliefert wird mit nosniff und einer CSP-Sandbox, damit auch ein PDF mit Skripten nichts anrichtet. Dateien haengen am Code: wird er geloescht, geht sie mit. Eine ausgetauschte oder nie gespeicherte Datei wird nach 24 Stunden weggeraeumt. Eine fremde Datei laesst sich nicht an den eigenen Code haengen — sonst koennte man sie einem anderen Code entziehen. Feedback sammelt Note und Kommentar, ohne Name, IP oder Geraetekennung. Die Auswertung mit Durchschnitt, Verteilung und Kommentaren ist aus der Codeliste erreichbar — ein Feedback-Code ohne Auswertung waere nur die halbe Sache.
This commit is contained in:
+13
-22
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
defineComingSoonType,
|
||||
isQrTypeId,
|
||||
QR_TYPE_IDS,
|
||||
type QrTypeId,
|
||||
@@ -7,9 +6,12 @@ import {
|
||||
} from "@/lib/qr/type-module";
|
||||
import { appType } from "@/lib/qr/types/app";
|
||||
import { emailType } from "@/lib/qr/types/email";
|
||||
import { feedbackType } from "@/lib/qr/types/feedback";
|
||||
import { imageType } from "@/lib/qr/types/image";
|
||||
import { linksType } from "@/lib/qr/types/links";
|
||||
import { locationType } from "@/lib/qr/types/location";
|
||||
import { paymentType } from "@/lib/qr/types/payment";
|
||||
import { pdfType } from "@/lib/qr/types/pdf";
|
||||
import { smsType } from "@/lib/qr/types/sms";
|
||||
import { socialType } from "@/lib/qr/types/social";
|
||||
import { vcardType } from "@/lib/qr/types/vcard";
|
||||
@@ -22,10 +24,14 @@ import { wifiType } from "@/lib/qr/types/wifi";
|
||||
* Zentrale Registry aller QR-Typen. Die Reihenfolge bestimmt die Reihenfolge
|
||||
* der Kacheln auf der Startseite.
|
||||
*
|
||||
* Ein neuer Typ in V2: den `defineComingSoonType`-Eintrag durch ein echtes
|
||||
* Modul (siehe `types/website.ts`) ersetzen und die zugehörige Formular-
|
||||
* Komponente in `src/components/qr-forms/index.tsx` registrieren. Am
|
||||
* Ein neuer Typ besteht aus einem Modul (siehe `types/website.ts`), einem
|
||||
* Eintrag hier, einer Formular-Komponente in
|
||||
* `src/components/qr-forms/index.ts` und — falls er eine eigene Seite
|
||||
* mitbringt — einer Landingpage in `src/components/landing/index.ts`. Am
|
||||
* restlichen Code ändert sich nichts.
|
||||
*
|
||||
* Für einen angekündigten, aber noch nicht gebauten Typ gibt es weiterhin
|
||||
* `defineComingSoonType` in `type-module.ts`.
|
||||
*/
|
||||
const MODULES: readonly QrTypeModule[] = [
|
||||
websiteType,
|
||||
@@ -34,30 +40,15 @@ const MODULES: readonly QrTypeModule[] = [
|
||||
linksType,
|
||||
socialType,
|
||||
whatsappType,
|
||||
defineComingSoonType({
|
||||
id: "pdf",
|
||||
title: "PDF",
|
||||
description: "Stelle ein PDF-Dokument zum Abruf bereit.",
|
||||
icon: "document",
|
||||
}),
|
||||
defineComingSoonType({
|
||||
id: "image",
|
||||
title: "Bild",
|
||||
description: "Zeige ein Bild oder eine Galerie.",
|
||||
icon: "image",
|
||||
}),
|
||||
pdfType,
|
||||
imageType,
|
||||
videoType,
|
||||
emailType,
|
||||
smsType,
|
||||
locationType,
|
||||
paymentType,
|
||||
wifiType,
|
||||
defineComingSoonType({
|
||||
id: "feedback",
|
||||
title: "Feedback",
|
||||
description: "Sammle Bewertungen und Rückmeldungen.",
|
||||
icon: "star",
|
||||
}),
|
||||
feedbackType,
|
||||
];
|
||||
|
||||
export const qrTypes: Record<QrTypeId, QrTypeModule> = Object.fromEntries(
|
||||
|
||||
@@ -129,6 +129,13 @@ export interface QrTypeModule {
|
||||
redirectTarget(data: unknown, context?: RedirectContext): string;
|
||||
/** Einzeilige Zusammenfassung für Listen und Übersichten. */
|
||||
summary(data: unknown): string;
|
||||
/**
|
||||
* Ids hochgeladener Dateien, auf die diese Daten verweisen.
|
||||
*
|
||||
* Daran hängt, welche Datei zu welchem Code gehört — und damit, was beim
|
||||
* Löschen mitgeht und was als verwaist weggeräumt wird.
|
||||
*/
|
||||
uploadIds(data: unknown): string[];
|
||||
}
|
||||
|
||||
type QrTypeSpec<TSchema extends z.ZodType> = {
|
||||
@@ -159,6 +166,8 @@ type QrTypeSpec<TSchema extends z.ZodType> = {
|
||||
context: RedirectContext,
|
||||
) => string;
|
||||
summary: (data: z.output<TSchema>) => string;
|
||||
/** Voreinstellung: keine. Siehe `QrTypeModule.uploadIds`. */
|
||||
uploadIds?: (data: z.output<TSchema>) => string[];
|
||||
};
|
||||
|
||||
function firstIssueMessage(error: z.ZodError): string {
|
||||
@@ -206,6 +215,7 @@ export function defineQrType<TSchema extends z.ZodType>(
|
||||
? spec.redirectTarget(parse(data), context)
|
||||
: spec.encode(parse(data)),
|
||||
summary: (data) => spec.summary(parse(data)),
|
||||
uploadIds: (data) => (spec.uploadIds ? spec.uploadIds(parse(data)) : []),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -243,5 +253,6 @@ export function defineComingSoonType(spec: {
|
||||
encode: notAvailable,
|
||||
redirectTarget: notAvailable,
|
||||
summary: () => "Noch nicht verfügbar",
|
||||
uploadIds: () => [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { defineQrType, QrTypeError } from "@/lib/qr/type-module";
|
||||
|
||||
/** Wie die Bewertung dargestellt wird. */
|
||||
export const FEEDBACK_SCALES = {
|
||||
stars: { label: "Sterne (1–5)", symbols: ["★", "★", "★", "★", "★"] },
|
||||
faces: { label: "Gesichter", symbols: ["😞", "🙁", "😐", "🙂", "😀"] },
|
||||
} as const;
|
||||
|
||||
export type FeedbackScale = keyof typeof FEEDBACK_SCALES;
|
||||
|
||||
export const feedbackDataSchema = z.object({
|
||||
title: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, { error: "Bitte gib der Seite eine Überschrift." })
|
||||
.max(80, { error: "Die Überschrift ist zu lang (höchstens 80 Zeichen)." }),
|
||||
question: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, { error: "Bitte formuliere eine Frage." })
|
||||
.max(160, { error: "Die Frage ist zu lang (höchstens 160 Zeichen)." })
|
||||
.default("Wie zufrieden bist du?"),
|
||||
scale: z.enum(["stars", "faces"]).default("stars"),
|
||||
/** Darf zusätzlich zur Note etwas geschrieben werden? */
|
||||
allowComment: z.boolean().default(true),
|
||||
commentLabel: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(120, { error: "Der Text ist zu lang (höchstens 120 Zeichen)." })
|
||||
.default("Möchtest du uns noch etwas sagen?"),
|
||||
thankYou: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(200, { error: "Der Text ist zu lang (höchstens 200 Zeichen)." })
|
||||
.default("Danke für deine Rückmeldung!"),
|
||||
});
|
||||
|
||||
export type FeedbackData = z.infer<typeof feedbackDataSchema>;
|
||||
|
||||
export const feedbackType = defineQrType({
|
||||
id: "feedback",
|
||||
title: "Feedback",
|
||||
description: "Sammle Bewertungen und Rückmeldungen.",
|
||||
icon: "star",
|
||||
implemented: true,
|
||||
// Ein Formular braucht eine Seite — und eine Adresse, an die es die
|
||||
// Antworten schickt. Beides entsteht erst beim Speichern.
|
||||
supportsStatic: false,
|
||||
supportsDynamic: true,
|
||||
hasLandingPage: true,
|
||||
schema: feedbackDataSchema,
|
||||
defaultData: {
|
||||
title: "",
|
||||
question: "Wie zufrieden bist du?",
|
||||
scale: "stars",
|
||||
allowComment: true,
|
||||
commentLabel: "Möchtest du uns noch etwas sagen?",
|
||||
thankYou: "Danke für deine Rückmeldung!",
|
||||
},
|
||||
encode: () => {
|
||||
throw new QrTypeError(
|
||||
"Ein Feedback-Bogen funktioniert nur als dynamischer Code — die Antworten brauchen eine Adresse, an die sie gehen.",
|
||||
);
|
||||
},
|
||||
summary: (data) => data.title,
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { defineQrType, QrTypeError } from "@/lib/qr/type-module";
|
||||
|
||||
export const MAX_IMAGES = 10;
|
||||
|
||||
export const imageEntrySchema = z.object({
|
||||
fileId: z.string().trim().min(1, { error: "Bitte lade ein Bild hoch." }),
|
||||
filename: z.string().trim().max(200).default("bild"),
|
||||
size: z.number().int().nonnegative().default(0),
|
||||
/** Bildunterschrift — auch als Alternativtext für Screenreader. */
|
||||
caption: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(120, { error: "Die Bildunterschrift ist zu lang (höchstens 120 Zeichen)." })
|
||||
.default(""),
|
||||
});
|
||||
|
||||
export const imageDataSchema = z.object({
|
||||
title: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(80, { error: "Der Titel ist zu lang (höchstens 80 Zeichen)." })
|
||||
.default(""),
|
||||
description: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(300, { error: "Der Text ist zu lang (höchstens 300 Zeichen)." })
|
||||
.default(""),
|
||||
images: z
|
||||
.array(imageEntrySchema)
|
||||
.min(1, { error: "Bitte lade mindestens ein Bild hoch." })
|
||||
.max(MAX_IMAGES, {
|
||||
error: `Mehr als ${MAX_IMAGES} Bilder sind nicht vorgesehen.`,
|
||||
}),
|
||||
});
|
||||
|
||||
export type ImageData = z.infer<typeof imageDataSchema>;
|
||||
export type ImageEntry = z.infer<typeof imageEntrySchema>;
|
||||
|
||||
export const imageType = defineQrType({
|
||||
id: "image",
|
||||
title: "Bild",
|
||||
description: "Zeige ein Bild oder eine Galerie.",
|
||||
icon: "image",
|
||||
implemented: true,
|
||||
supportsStatic: false,
|
||||
supportsDynamic: true,
|
||||
hasLandingPage: true,
|
||||
schema: imageDataSchema,
|
||||
defaultData: { title: "", description: "", images: [] },
|
||||
encode: () => {
|
||||
throw new QrTypeError(
|
||||
"Hinterlegte Bilder funktionieren nur als dynamischer Code — sie brauchen eine Seite, die sie zeigt.",
|
||||
);
|
||||
},
|
||||
summary: (data) => {
|
||||
if (data.title) return data.title;
|
||||
return data.images.length === 1
|
||||
? data.images[0].filename || "Ein Bild"
|
||||
: `${data.images.length} Bilder`;
|
||||
},
|
||||
uploadIds: (data) => data.images.map((image) => image.fileId),
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { defineQrType, QrTypeError } from "@/lib/qr/type-module";
|
||||
|
||||
/**
|
||||
* Dateiname und Größe stehen doppelt: einmal am Upload-Datensatz, einmal
|
||||
* hier. Das ist Absicht — `summary()` bekommt nur die Daten und darf keine
|
||||
* Datenbankabfrage auslösen, wird aber für jede Zeile der Liste aufgerufen.
|
||||
* Weichen die Werte einmal ab, ist das rein kosmetisch.
|
||||
*/
|
||||
export const pdfDataSchema = z.object({
|
||||
fileId: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, { error: "Bitte lade ein PDF hoch." }),
|
||||
filename: z.string().trim().max(200).default("dokument.pdf"),
|
||||
size: z.number().int().nonnegative().default(0),
|
||||
title: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(80, { error: "Der Titel ist zu lang (höchstens 80 Zeichen)." })
|
||||
.default(""),
|
||||
description: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(300, { error: "Der Text ist zu lang (höchstens 300 Zeichen)." })
|
||||
.default(""),
|
||||
});
|
||||
|
||||
export type PdfData = z.infer<typeof pdfDataSchema>;
|
||||
|
||||
export const pdfType = defineQrType({
|
||||
id: "pdf",
|
||||
title: "PDF",
|
||||
description: "Stelle ein PDF-Dokument zum Abruf bereit.",
|
||||
icon: "document",
|
||||
implemented: true,
|
||||
// Die Datei liegt hier — es gibt keine feste Adresse, die man in einen
|
||||
// statischen Code schreiben könnte.
|
||||
supportsStatic: false,
|
||||
supportsDynamic: true,
|
||||
hasLandingPage: true,
|
||||
schema: pdfDataSchema,
|
||||
defaultData: {
|
||||
fileId: "",
|
||||
filename: "",
|
||||
size: 0,
|
||||
title: "",
|
||||
description: "",
|
||||
},
|
||||
encode: () => {
|
||||
throw new QrTypeError(
|
||||
"Ein hinterlegtes PDF funktioniert nur als dynamischer Code — die Datei braucht eine Adresse, die erst beim Speichern entsteht.",
|
||||
);
|
||||
},
|
||||
summary: (data) => data.title || data.filename || "PDF-Dokument",
|
||||
uploadIds: (data) => (data.fileId ? [data.fileId] : []),
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Was Server und Browser über Uploads gleichermaßen wissen müssen.
|
||||
*
|
||||
* Getrennt von `uploads.ts`, weil das dort liegende Ablegen und Aufräumen
|
||||
* `server-only` ist — die Formulare brauchen aber die Liste der erlaubten
|
||||
* Dateiarten und die Größenformatierung.
|
||||
*/
|
||||
|
||||
export const UPLOAD_KINDS = {
|
||||
pdf: {
|
||||
label: "PDF-Dokument",
|
||||
mimeTypes: ["application/pdf"],
|
||||
accept: "application/pdf,.pdf",
|
||||
},
|
||||
image: {
|
||||
label: "Bild",
|
||||
mimeTypes: [
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
"image/avif",
|
||||
],
|
||||
accept: "image/png,image/jpeg,image/webp,image/gif,image/avif",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type UploadKind = keyof typeof UPLOAD_KINDS;
|
||||
|
||||
export function isUploadKind(value: unknown): value is UploadKind {
|
||||
return value === "pdf" || value === "image";
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1).replace(".", ",")} MB`;
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import "server-only";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { QrRequestError } from "@/lib/qr/type-module";
|
||||
import {
|
||||
formatBytes,
|
||||
UPLOAD_KINDS,
|
||||
type UploadKind,
|
||||
} from "@/lib/uploads.shared";
|
||||
|
||||
// Weiterreichen, damit Aufrufer auf dem Server nur ein Modul kennen müssen.
|
||||
export {
|
||||
UPLOAD_KINDS,
|
||||
formatBytes,
|
||||
isUploadKind,
|
||||
} from "@/lib/uploads.shared";
|
||||
export type { UploadKind } from "@/lib/uploads.shared";
|
||||
|
||||
/** Voreinstellung, überschreibbar per `MAX_UPLOAD_MB`. */
|
||||
const DEFAULT_MAX_MB = 10;
|
||||
|
||||
export function maxUploadBytes(): number {
|
||||
const raw = Number.parseInt(process.env.MAX_UPLOAD_MB ?? "", 10);
|
||||
const mb = Number.isFinite(raw) && raw > 0 && raw <= 200 ? raw : DEFAULT_MAX_MB;
|
||||
return mb * 1024 * 1024;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dateiart anhand der ersten Bytes bestimmen.
|
||||
*
|
||||
* Der vom Browser gemeldete Typ kommt aus der Dateiendung und lässt sich
|
||||
* beliebig setzen. Was wirklich in der Datei steht, verrät nur ihr Anfang —
|
||||
* und danach richten wir uns, damit unter dem Namen „bild.png“ nichts
|
||||
* anderes ins Haus kommt.
|
||||
*/
|
||||
export function sniffMimeType(bytes: Uint8Array): string | null {
|
||||
const starts = (...signature: number[]) =>
|
||||
signature.every((byte, index) => bytes[index] === byte);
|
||||
|
||||
if (starts(0x25, 0x50, 0x44, 0x46)) return "application/pdf"; // %PDF
|
||||
if (starts(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)) return "image/png";
|
||||
if (starts(0xff, 0xd8, 0xff)) return "image/jpeg";
|
||||
if (starts(0x47, 0x49, 0x46, 0x38)) return "image/gif"; // GIF8
|
||||
|
||||
// RIFF....WEBP
|
||||
if (
|
||||
starts(0x52, 0x49, 0x46, 0x46) &&
|
||||
bytes[8] === 0x57 &&
|
||||
bytes[9] === 0x45 &&
|
||||
bytes[10] === 0x42 &&
|
||||
bytes[11] === 0x50
|
||||
) {
|
||||
return "image/webp";
|
||||
}
|
||||
|
||||
// ....ftypavif — die Marke steht ab Byte 4.
|
||||
const brand = String.fromCharCode(...bytes.slice(4, 12));
|
||||
if (brand.startsWith("ftyp") && brand.slice(4, 8) === "avif") {
|
||||
return "image/avif";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Zeichen, die in Dateinamen oder in Kopfzeilen Ärger machen. */
|
||||
const HEIKLE_ZEICHEN = new Set(['"', "*", ":", "<", ">", "?", "|"]);
|
||||
|
||||
/**
|
||||
* Ein harmloser Dateiname: ohne Pfad, ohne Steuerzeichen, nicht zu lang.
|
||||
*
|
||||
* Der Name landet in der `Content-Disposition`-Kopfzeile beim Herunterladen.
|
||||
* Ein Zeilenumbruch darin könnte weitere Kopfzeilen einschleusen, deshalb
|
||||
* fliegt alles unterhalb des Leerzeichens raus.
|
||||
*/
|
||||
export function sanitizeFilename(name: string, fallback: string): string {
|
||||
const base = name.split(/[\\/]/).pop() ?? "";
|
||||
const clean = [...base]
|
||||
.filter((zeichen) => {
|
||||
const code = zeichen.codePointAt(0) ?? 0;
|
||||
if (code < 0x20 || code === 0x7f) return false;
|
||||
return !HEIKLE_ZEICHEN.has(zeichen);
|
||||
})
|
||||
.join("")
|
||||
.trim();
|
||||
|
||||
if (clean.length === 0 || clean === "." || clean === "..") return fallback;
|
||||
return clean.slice(0, 120);
|
||||
}
|
||||
|
||||
export type StoredUpload = {
|
||||
id: string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Nimmt eine Datei entgegen und legt sie ab — noch ohne Bezug zu einem Code.
|
||||
* Verknüpft wird sie erst, wenn der Code gespeichert wird.
|
||||
*/
|
||||
export async function storeUpload(
|
||||
file: File,
|
||||
kind: UploadKind,
|
||||
): Promise<StoredUpload> {
|
||||
const limit = maxUploadBytes();
|
||||
if (file.size === 0) {
|
||||
throw new QrRequestError("Die Datei ist leer.");
|
||||
}
|
||||
if (file.size > limit) {
|
||||
throw new QrRequestError(
|
||||
`Die Datei ist zu groß (${formatBytes(file.size)}). Erlaubt sind bis zu ${formatBytes(limit)}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
// Zweite Prüfung: `file.size` ist nur eine Angabe des Browsers.
|
||||
if (bytes.byteLength > limit) {
|
||||
throw new QrRequestError(
|
||||
`Die Datei ist zu groß. Erlaubt sind bis zu ${formatBytes(limit)}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const erkannt = sniffMimeType(bytes);
|
||||
const erlaubt: readonly string[] = UPLOAD_KINDS[kind].mimeTypes;
|
||||
if (!erkannt || !erlaubt.includes(erkannt)) {
|
||||
throw new QrRequestError(
|
||||
kind === "pdf"
|
||||
? "Das ist keine PDF-Datei. Bitte lade ein PDF hoch."
|
||||
: "Das ist kein unterstütztes Bild. Erlaubt sind PNG, JPEG, WebP, GIF und AVIF.",
|
||||
);
|
||||
}
|
||||
|
||||
return prisma.upload.create({
|
||||
data: {
|
||||
filename: sanitizeFilename(
|
||||
file.name,
|
||||
kind === "pdf" ? "dokument.pdf" : "bild",
|
||||
),
|
||||
mimeType: erkannt,
|
||||
size: bytes.byteLength,
|
||||
data: Buffer.from(bytes),
|
||||
},
|
||||
select: { id: true, filename: true, mimeType: true, size: true },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verknüpft die Dateien eines Codes mit ihm und löst die, die nicht mehr
|
||||
* dazugehören.
|
||||
*
|
||||
* Wird beim Anlegen und bei jeder Änderung aufgerufen. Eine ausgetauschte
|
||||
* Datei verliert dadurch ihren Bezug und fällt später dem Aufräumen zum
|
||||
* Opfer — sofort löschen wäre riskanter, falls das Speichern doch scheitert.
|
||||
*/
|
||||
export async function attachUploads(
|
||||
qrCodeId: string,
|
||||
uploadIds: readonly string[],
|
||||
): Promise<void> {
|
||||
await prisma.upload.updateMany({
|
||||
where: { qrCodeId, id: { notIn: [...uploadIds] } },
|
||||
data: { qrCodeId: null },
|
||||
});
|
||||
|
||||
if (uploadIds.length === 0) return;
|
||||
|
||||
await prisma.upload.updateMany({
|
||||
// Nur freie Dateien oder solche, die schon zu diesem Code gehören: sonst
|
||||
// ließe sich eine fremde Datei durch Angabe ihrer Id an den eigenen Code
|
||||
// hängen — und damit aus dem fremden Code entfernen.
|
||||
where: {
|
||||
id: { in: [...uploadIds] },
|
||||
OR: [{ qrCodeId: null }, { qrCodeId }],
|
||||
},
|
||||
data: { qrCodeId },
|
||||
});
|
||||
}
|
||||
|
||||
/** Wie lange eine hochgeladene, aber nie gespeicherte Datei liegen bleibt. */
|
||||
const ORPHAN_HOURS = 24;
|
||||
|
||||
/**
|
||||
* Räumt Dateien weg, die zu keinem Code gehören.
|
||||
*
|
||||
* Das passiert, wenn jemand eine Datei auswählt und den Code dann doch nicht
|
||||
* speichert. Aufgerufen wird das beim nächsten Upload — ein eigener Dienst
|
||||
* dafür wäre bei diesen Mengen übertrieben.
|
||||
*/
|
||||
export async function cleanupOrphanedUploads(): Promise<number> {
|
||||
const grenze = new Date(Date.now() - ORPHAN_HOURS * 60 * 60 * 1000);
|
||||
const { count } = await prisma.upload.deleteMany({
|
||||
where: { qrCodeId: null, createdAt: { lt: grenze } },
|
||||
});
|
||||
return count;
|
||||
}
|
||||
Reference in New Issue
Block a user