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:
@@ -21,6 +21,11 @@ APP_PORT=3000
|
||||
# Wer den Zugang verliert, setzt den Wert zurück auf false und startet neu.
|
||||
AUTH_ENABLED=false
|
||||
|
||||
# Groesste erlaubte Datei fuer die Typen "PDF" und "Bild", in Megabyte.
|
||||
# Die Dateien liegen in der Datenbank — wer die sichert, hat alles. Sehr
|
||||
# grosse Werte machen die Sicherung entsprechend schwer.
|
||||
MAX_UPLOAD_MB=10
|
||||
|
||||
# --- PostgreSQL ------------------------------------------------------------
|
||||
POSTGRES_USER=qrcode
|
||||
POSTGRES_PASSWORD=bitte-aendern
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
Eine Webanwendung zum Erzeugen von QR-Codes — als Docker-Compose-Stack aus
|
||||
zwei Containern: der Next.js-Anwendung und einer PostgreSQL-Datenbank.
|
||||
|
||||
Umgesetzt sind **9 der 15 QR-Typen**. Die übrigen sechs sind in der Oberfläche
|
||||
sichtbar, als Platzhalter markiert und lassen sich über die Verwaltung
|
||||
ausblenden.
|
||||
Umgesetzt sind **alle 15 QR-Typen**. Welche davon in der Oberfläche erscheinen,
|
||||
entscheidet die Verwaltung — nicht gebrauchte lassen sich ausblenden.
|
||||
|
||||
---
|
||||
|
||||
@@ -152,9 +151,10 @@ Für einen Code, dessen Ziel du später ändern kannst:
|
||||
|
||||
### Schritt 8 — Aufräumen, was du nicht brauchst
|
||||
|
||||
Unter **Verwaltung → QR-Typen** kannst du einzelne Typen ausblenden — etwa
|
||||
die sechs, die noch nicht umgesetzt sind, damit sie gar nicht erst
|
||||
auftauchen.
|
||||
Unter **Verwaltung → QR-Typen** kannst du einzelne Typen ausblenden — 15
|
||||
Kacheln sind viel, wenn ihr ohnehin nur drei davon braucht. Ausgeblendete
|
||||
Typen verschwinden aus der Auswahl; bereits erstellte Codes dieses Typs
|
||||
funktionieren unverändert weiter.
|
||||
|
||||
### Die wichtigsten Befehle im Betrieb
|
||||
|
||||
@@ -358,8 +358,7 @@ werden — der Hostname darin bleibt `db` (der Servicename aus
|
||||
### 1. Typ wählen
|
||||
|
||||
Die Startseite zeigt unter „Was möchtest du erstellen?“ ein Kachel-Grid der
|
||||
freigegebenen QR-Typen. Umgesetzte Typen sind anklickbar, die übrigen tragen
|
||||
das Badge „Bald verfügbar“.
|
||||
freigegebenen QR-Typen. Alle 15 Typen sind umgesetzt.
|
||||
|
||||
| Typ | Kodiert als | Statisch | Dynamisch |
|
||||
| --- | --- | :-: | :-: |
|
||||
@@ -368,10 +367,16 @@ das Badge „Bald verfügbar“.
|
||||
| SMS | `SMSTO:` | ✓ | ✓ |
|
||||
| WhatsApp | `https://wa.me/…` | ✓ | ✓ |
|
||||
| Standort | `geo:` — dynamisch über OpenStreetMap | ✓ | ✓ |
|
||||
| Video | die Videoadresse — dynamisch mit Player-Seite | ✓ | ✓ |
|
||||
| vCard | vCard 3.0 | ✓ | — |
|
||||
| WLAN | `WIFI:` | ✓ | — |
|
||||
| App-Link | Weiterleitung je nach Gerät | — | ✓ |
|
||||
| Zahlung | GiroCode (EPC069-12) oder Zahlungslink | ✓ | nur Link |
|
||||
| Mehrere Links | eigene Seite mit Linkliste | — | ✓ |
|
||||
| Social Media | eigene Seite mit den Profilen | — | ✓ |
|
||||
| PDF | hinterlegte Datei auf eigener Seite | — | ✓ |
|
||||
| Bild | hinterlegte Bilder auf eigener Seite | — | ✓ |
|
||||
| Feedback | Bewertungsformular auf eigener Seite | — | ✓ |
|
||||
|
||||
Warum manche Typen nur eine Betriebsart können:
|
||||
|
||||
@@ -386,6 +391,12 @@ Warum manche Typen nur eine Betriebsart können:
|
||||
dynamischer Code funktioniert daher nur die Variante „Zahlungslink“ — die
|
||||
Anwendung weist das beim Speichern ausdrücklich ab, statt einen Code
|
||||
entstehen zu lassen, der beim Scannen ins Leere führt.
|
||||
- **Mehrere Links, Social Media, PDF, Bild und Feedback** brauchen eine Seite,
|
||||
die die Anwendung selbst ausliefert — eine Linkliste oder ein Formular passt
|
||||
nicht in einen einzelnen Code. Diese Seiten liegen unter `/s/{slug}`; der
|
||||
gedruckte Code zeigt weiterhin auf `/r/{slug}` und wird dorthin
|
||||
weitergeleitet. Der Scan-Zähler zählt dabei den Aufruf des Codes, nicht das
|
||||
Neuladen der Seite.
|
||||
|
||||
> **Zum WLAN-Code:** Das Passwort steht im Klartext im Code — wer ihn scannt
|
||||
> oder abfotografiert, kennt es. Für Gästenetze ist das gewollt. Besteht ein
|
||||
@@ -482,9 +493,70 @@ Kartenansicht.
|
||||
|
||||
Statische Codes erscheinen hier nicht, weil sie nicht gespeichert werden.
|
||||
|
||||
> Version 1 ist für den Einzelplatz-/LAN-Betrieb gedacht und hat **keine
|
||||
> Benutzerverwaltung**. Wer die Anwendung erreicht, sieht und ändert alle
|
||||
> Codes. Die App gehört daher nicht ungeschützt ins offene Internet.
|
||||
Bei einem **Feedback-Code** führt aus der Zeile ein Stern-Symbol zur
|
||||
Auswertung: Durchschnitt, Verteilung der Noten und die abgegebenen Kommentare.
|
||||
|
||||
---
|
||||
|
||||
### 7. Eigene Seiten: Links, Profile, Dateien, Feedback
|
||||
|
||||
Fünf Typen haben kein fremdes Ziel, auf das sich weiterleiten ließe — sie
|
||||
brauchen eine Seite. Die liefert die Anwendung selbst aus, unter
|
||||
`{BASE_URL}/s/{slug}`:
|
||||
|
||||
| Typ | Was die Seite zeigt |
|
||||
| --- | --- |
|
||||
| Mehrere Links | Überschrift, kurzer Text, bis zu 20 beschriftete Links |
|
||||
| Social Media | dieselbe Form, aber mit Netzwerk-Auswahl aus einem Katalog |
|
||||
| PDF | das hinterlegte Dokument mit „Öffnen“ und „Herunterladen“ |
|
||||
| Bild | ein Bild oder eine Galerie aus bis zu 10 Bildern mit Bildunterschriften |
|
||||
| Feedback | ein Formular mit Sternen oder Gesichtern und optionalem Kommentar |
|
||||
|
||||
Diese Seiten sind **öffentlich** und zeigen bewusst nichts von der Anwendung:
|
||||
keine Navigation, kein „Verwaltung“, kein Hinweis auf den Generator. Wer einen
|
||||
Aushang scannt, soll nur den Inhalt sehen.
|
||||
|
||||
Der gedruckte Code zeigt weiterhin auf `/r/{slug}`; von dort geht es auf die
|
||||
Seite. Der Scan-Zähler zählt dabei den Aufruf des Codes — ein Neuladen der
|
||||
Seite zählt nicht mit.
|
||||
|
||||
**Bei Social Media** genügt als Eingabe das Kürzel: aus `@firma` wird beim
|
||||
Speichern `https://instagram.com/firma`. Eine vollständige Adresse bleibt
|
||||
unverändert, damit auch abweichende Profil-Adressen möglich sind.
|
||||
|
||||
**Beim Video-Typ** wird der Player von YouTube oder Vimeo erst nach einem Klick
|
||||
geladen, und davor steht, dass dabei Daten an den Anbieter gehen. Ungefragt
|
||||
einen Drittanbieter einzubinden wäre auf einer Seite, die Fremde nach einem
|
||||
Scan zu sehen bekommen, nicht in Ordnung.
|
||||
|
||||
#### Hochgeladene Dateien
|
||||
|
||||
PDFs und Bilder liegen **in der Datenbank**, nicht auf der Platte. Damit gibt
|
||||
es weiterhin genau ein Volumen zu sichern: Wer die Datenbank sichert, hat
|
||||
alles.
|
||||
|
||||
- Die Grenze steht in der `.env` unter `MAX_UPLOAD_MB` (Voreinstellung 10).
|
||||
- Erlaubt sind PDF sowie PNG, JPEG, WebP, GIF und AVIF. **SVG bewusst nicht** —
|
||||
eine SVG-Datei darf Skripte enthalten und würde von derselben Adresse wie die
|
||||
Anwendung ausgeliefert.
|
||||
- Geprüft wird der **Inhalt**, nicht die Dateiendung. Eine Textdatei mit dem
|
||||
Namen `harmlos.pdf` wird abgewiesen.
|
||||
- Wird ein Code gelöscht, verschwindet seine Datei mit ihm. Eine ausgetauschte
|
||||
Datei und eine hochgeladene, aber nie gespeicherte werden nach 24 Stunden
|
||||
aufgeräumt.
|
||||
|
||||
#### Rückmeldungen
|
||||
|
||||
Ein Feedback-Eintrag besteht aus einer Note von 1 bis 5 und einem optionalen
|
||||
Text. Gespeichert wird **kein Name, keine IP-Adresse, keine Gerätekennung** —
|
||||
wer einen Aushang scannt und eine Note vergibt, bleibt anonym. Gegen Fluten
|
||||
gibt es eine Bremse von 10 Rückmeldungen pro Minute und Absender; die Adresse
|
||||
dient dabei nur als Schlüssel im Arbeitsspeicher und wird nirgends gespeichert.
|
||||
|
||||
> Ohne eingeschaltete Anmeldung sieht und ändert jeder, der die Anwendung
|
||||
> erreicht, alle Codes. Für den Einzelplatz- oder LAN-Betrieb ist das gewollt;
|
||||
> ins offene Internet gehört die Anwendung dann nicht. Mit `AUTH_ENABLED=true`
|
||||
> gibt es Benutzer, Gruppen und Rollen — siehe unten.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ services:
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
# Anmeldung mit Benutzern, Gruppen und Rollen.
|
||||
AUTH_ENABLED: ${AUTH_ENABLED:-false}
|
||||
MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-10}
|
||||
ports:
|
||||
# Direktes Port-Mapping, bewusst ohne Reverse Proxy.
|
||||
- "${APP_PORT:-3000}:3000"
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Hochgeladene Dateien für die Typen „PDF" und „Bild".
|
||||
--
|
||||
-- Der Inhalt liegt als bytea in der Datenbank. Damit bleibt es bei einem
|
||||
-- Volumen, das gesichert werden muss.
|
||||
CREATE TABLE "Upload" (
|
||||
"id" TEXT NOT NULL,
|
||||
"filename" TEXT NOT NULL,
|
||||
"mimeType" TEXT NOT NULL,
|
||||
"size" INTEGER NOT NULL,
|
||||
"data" BYTEA NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"qrCodeId" TEXT,
|
||||
|
||||
CONSTRAINT "Upload_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "Upload_qrCodeId_idx" ON "Upload"("qrCodeId");
|
||||
CREATE INDEX "Upload_createdAt_idx" ON "Upload"("createdAt");
|
||||
|
||||
-- Wird ein Code gelöscht, verschwindet seine Datei mit ihm.
|
||||
ALTER TABLE "Upload"
|
||||
ADD CONSTRAINT "Upload_qrCodeId_fkey"
|
||||
FOREIGN KEY ("qrCodeId") REFERENCES "QrCode"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,20 @@
|
||||
-- Rückmeldungen zu einem Feedback-Code.
|
||||
--
|
||||
-- Ohne IP-Adresse und ohne Gerätekennung: Wer einen Aushang scannt und eine
|
||||
-- Note vergibt, soll nicht wiedererkennbar sein.
|
||||
CREATE TABLE "Feedback" (
|
||||
"id" TEXT NOT NULL,
|
||||
"qrCodeId" TEXT NOT NULL,
|
||||
"rating" INTEGER NOT NULL,
|
||||
"comment" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Feedback_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "Feedback_qrCodeId_createdAt_idx" ON "Feedback"("qrCodeId", "createdAt");
|
||||
|
||||
ALTER TABLE "Feedback"
|
||||
ADD CONSTRAINT "Feedback_qrCodeId_fkey"
|
||||
FOREIGN KEY ("qrCodeId") REFERENCES "QrCode"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -180,8 +180,59 @@ model QrCode {
|
||||
createdById String?
|
||||
createdBy User? @relation("QrCodeCreator", fields: [createdById], references: [id], onDelete: SetNull)
|
||||
|
||||
/// Dateien, die zu diesem Code gehören — beim Löschen gehen sie mit.
|
||||
uploads Upload[]
|
||||
/// Rückmeldungen, die über die Landingpage eingegangen sind.
|
||||
feedback Feedback[]
|
||||
|
||||
@@unique([domainId, slug])
|
||||
@@index([createdAt])
|
||||
@@index([groupId])
|
||||
@@index([domainId])
|
||||
}
|
||||
|
||||
/// Eine hochgeladene Datei — das PDF hinter einem PDF-Code, das Bild hinter
|
||||
/// einem Bild-Code.
|
||||
///
|
||||
/// Der Inhalt liegt in der Datenbank, nicht auf der Platte. Damit gibt es
|
||||
/// weiterhin genau ein Volumen zu sichern: Wer die Datenbank sichert, hat
|
||||
/// alles. Bei Dateigrößen im einstelligen Megabyte-Bereich und einer
|
||||
/// Installation dieser Größenordnung ist das der bessere Tausch als ein
|
||||
/// zweites Volumen, das beim Sichern vergessen werden kann.
|
||||
model Upload {
|
||||
id String @id @default(cuid())
|
||||
/// Ursprünglicher Dateiname, für den Download.
|
||||
filename String
|
||||
mimeType String
|
||||
/// Größe in Bytes — als Angabe für die Oberfläche, ohne die Datei zu laden.
|
||||
size Int
|
||||
data Bytes
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
/// Der Code, zu dem die Datei gehört. Leer, solange sie nur hochgeladen,
|
||||
/// der Code aber noch nicht gespeichert wurde; solche Reste räumt
|
||||
/// `src/lib/uploads.ts` nach einer Weile weg.
|
||||
qrCodeId String?
|
||||
qrCode QrCode? @relation(fields: [qrCodeId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([qrCodeId])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
/// Eine Rückmeldung, die jemand über die Landingpage eines Feedback-Codes
|
||||
/// abgegeben hat.
|
||||
///
|
||||
/// Bewusst ohne jeden Bezug zur Person: keine IP-Adresse, kein Kennzeichen
|
||||
/// des Geräts. Wer einen Zettel an der Wand scannt und eine Note vergibt,
|
||||
/// soll nicht wiedererkennbar sein.
|
||||
model Feedback {
|
||||
id String @id @default(cuid())
|
||||
qrCodeId String
|
||||
qrCode QrCode @relation(fields: [qrCodeId], references: [id], onDelete: Cascade)
|
||||
/// 1 bis 5.
|
||||
rating Int
|
||||
comment String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([qrCodeId, createdAt])
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ export default async function AppLayout({ children }: LayoutProps<"/">) {
|
||||
const showAdmin = can(actor, "instance:manage");
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-col">
|
||||
<div className="flex flex-1 flex-col">
|
||||
<header className="border-b border-slate-200 bg-white">
|
||||
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-x-4 gap-y-2 px-4 py-3.5 sm:px-6">
|
||||
<Link
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { Icon } from "@/components/icons";
|
||||
import { Card } from "@/components/ui";
|
||||
import { requirePageActor } from "@/lib/auth/guard";
|
||||
import { can } from "@/lib/auth/policy";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { FEEDBACK_SCALES, feedbackDataSchema } from "@/lib/qr/types/feedback";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata: Metadata = { title: "Rückmeldungen" };
|
||||
|
||||
type PageProps = { params: Promise<{ id: string }> };
|
||||
|
||||
function formatDate(value: Date): string {
|
||||
return new Intl.DateTimeFormat("de-DE", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
export default async function FeedbackResultsPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
const actor = await requirePageActor();
|
||||
|
||||
const code = await prisma.qrCode.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
feedback: { orderBy: { createdAt: "desc" }, take: 200 },
|
||||
},
|
||||
});
|
||||
|
||||
if (!code || code.type !== "feedback") notFound();
|
||||
if (!can(actor, "codes:read", { groupId: code.groupId })) notFound();
|
||||
|
||||
const daten = feedbackDataSchema.safeParse(code.data);
|
||||
const skala = daten.success ? daten.data.scale : "stars";
|
||||
const titel = daten.success ? daten.data.title : (code.label ?? "Feedback");
|
||||
|
||||
const eintraege = code.feedback;
|
||||
const anzahl = eintraege.length;
|
||||
const schnitt =
|
||||
anzahl > 0
|
||||
? eintraege.reduce((summe, eintrag) => summe + eintrag.rating, 0) / anzahl
|
||||
: 0;
|
||||
|
||||
// Verteilung von 5 nach 1 — so liest man eine Bewertungsübersicht.
|
||||
const verteilung = [5, 4, 3, 2, 1].map((note) => ({
|
||||
note,
|
||||
anzahl: eintraege.filter((eintrag) => eintrag.rating === note).length,
|
||||
}));
|
||||
const groesste = Math.max(1, ...verteilung.map((zeile) => zeile.anzahl));
|
||||
|
||||
const kommentare = eintraege.filter((eintrag) => eintrag.comment);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10">
|
||||
<Link
|
||||
href="/meine-codes"
|
||||
className="mb-5 inline-flex items-center gap-1.5 text-sm font-medium text-slate-600 transition-colors hover:text-slate-900"
|
||||
>
|
||||
<Icon name="arrowLeft" className="size-4" />
|
||||
Zurück zu Meine Codes
|
||||
</Link>
|
||||
|
||||
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
|
||||
{titel}
|
||||
</h1>
|
||||
<p className="mt-2 text-slate-600">
|
||||
{anzahl === 0
|
||||
? "Es ist noch keine Rückmeldung eingegangen."
|
||||
: `${anzahl} ${anzahl === 1 ? "Rückmeldung" : "Rückmeldungen"} · ${code.scans} ${code.scans === 1 ? "Scan" : "Scans"}`}
|
||||
</p>
|
||||
|
||||
{anzahl > 0 ? (
|
||||
<>
|
||||
<Card className="mt-7">
|
||||
<div className="flex flex-wrap items-center gap-x-8 gap-y-4">
|
||||
<div>
|
||||
<p className="text-4xl font-bold text-slate-900 tabular-nums">
|
||||
{schnitt.toFixed(1).replace(".", ",")}
|
||||
</p>
|
||||
<p className="text-sm text-slate-500">von 5 im Schnitt</p>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
{verteilung.map((zeile) => (
|
||||
<div key={zeile.note} className="flex items-center gap-2.5">
|
||||
<span className="w-10 shrink-0 text-right text-sm text-slate-500 tabular-nums">
|
||||
{zeile.note}{" "}
|
||||
<span aria-hidden="true">
|
||||
{FEEDBACK_SCALES[skala].symbols[zeile.note - 1]}
|
||||
</span>
|
||||
</span>
|
||||
<span className="h-2.5 min-w-0 flex-1 overflow-hidden rounded-full bg-slate-100">
|
||||
<span
|
||||
className="block h-full rounded-full bg-indigo-500"
|
||||
style={{
|
||||
width: `${Math.round((zeile.anzahl / groesste) * 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
<span className="w-8 shrink-0 text-sm text-slate-600 tabular-nums">
|
||||
{zeile.anzahl}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{kommentare.length > 0 ? (
|
||||
<div className="mt-7">
|
||||
<h2 className="mb-3 text-lg font-semibold text-slate-900">
|
||||
Kommentare ({kommentare.length})
|
||||
</h2>
|
||||
<ul className="space-y-3">
|
||||
{kommentare.map((eintrag) => (
|
||||
<li
|
||||
key={eintrag.id}
|
||||
className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm"
|
||||
>
|
||||
<div className="mb-1.5 flex items-center gap-2 text-sm text-slate-500">
|
||||
<span className="font-medium text-slate-900 tabular-nums">
|
||||
{eintrag.rating}/5
|
||||
</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<time dateTime={eintrag.createdAt.toISOString()}>
|
||||
{formatDate(eintrag.createdAt)}
|
||||
</time>
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap text-slate-800">
|
||||
{eintrag.comment}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{anzahl >= 200 ? (
|
||||
<p className="mt-3 text-sm text-slate-500">
|
||||
Angezeigt werden die 200 jüngsten Rückmeldungen.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-7 text-slate-600">
|
||||
Bisher hat niemand etwas dazugeschrieben.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Card className="mt-7">
|
||||
<p className="text-slate-600">
|
||||
Sobald jemand den Code scannt und eine Bewertung abgibt, steht sie
|
||||
hier. Die Rückmeldungen enthalten keinen Namen und keine
|
||||
IP-Adresse.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||
import { requireImplementedQrType } from "@/lib/qr/availability";
|
||||
import { designSchema } from "@/lib/qr/design";
|
||||
import { assertDynamicPossible } from "@/lib/qr/request";
|
||||
import { attachUploads } from "@/lib/uploads";
|
||||
import { QrRequestError } from "@/lib/qr/type-module";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -77,6 +78,8 @@ export async function PATCH(
|
||||
requirePermission(actor, "codes:update", { groupId: existing.groupId });
|
||||
|
||||
const update: Prisma.QrCodeUpdateInput = {};
|
||||
/** Dateien, die nach der Änderung zu diesem Code gehören. */
|
||||
let neueDateien: string[] | null = null;
|
||||
|
||||
if (parsed.data.data !== undefined) {
|
||||
// Bewusst nur „ausprogrammiert“, nicht „freigegeben“: ein in der
|
||||
@@ -92,6 +95,7 @@ export async function PATCH(
|
||||
// stillschweigend unbrauchbar machen.
|
||||
assertDynamicPossible(qrType, validated.data);
|
||||
update.data = toJsonInput(validated.data);
|
||||
neueDateien = qrType.uploadIds(validated.data);
|
||||
}
|
||||
|
||||
if (parsed.data.label !== undefined) {
|
||||
@@ -114,6 +118,13 @@ export async function PATCH(
|
||||
data: update,
|
||||
include: WITH_RELATIONS,
|
||||
});
|
||||
|
||||
// Eine ausgetauschte Datei verliert dabei ihren Bezug zum Code und wird
|
||||
// später als verwaist weggeräumt.
|
||||
if (neueDateien !== null) {
|
||||
await attachUploads(updated.id, neueDateien);
|
||||
}
|
||||
|
||||
return Response.json({ code: toQrCodeDto(updated) });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
|
||||
@@ -13,6 +13,7 @@ import { designSchema } from "@/lib/qr/design";
|
||||
import { assertDynamicPossible } from "@/lib/qr/request";
|
||||
import { QR_TYPE_IDS, QrRequestError } from "@/lib/qr/type-module";
|
||||
import { generateSlug } from "@/lib/slug";
|
||||
import { attachUploads } from "@/lib/uploads";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -115,6 +116,10 @@ export async function POST(request: NextRequest): Promise<Response> {
|
||||
createdBy: { select: { name: true, email: true } },
|
||||
},
|
||||
});
|
||||
// Erst jetzt gehören die hochgeladenen Dateien zu diesem Code —
|
||||
// vorher lagen sie ohne Bezug herum.
|
||||
await attachUploads(created.id, qrType.uploadIds(validated.data));
|
||||
|
||||
return Response.json({ code: toQrCodeDto(created) }, { status: 201 });
|
||||
} catch (error) {
|
||||
if (!isUniqueViolation(error)) throw error;
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const feedbackSchema = z.object({
|
||||
codeId: z.string().trim().min(1),
|
||||
rating: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1, { error: "Bitte wähle eine Bewertung." })
|
||||
.max(5, { error: "Bitte wähle eine Bewertung." }),
|
||||
comment: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(1000, { error: "Der Text ist zu lang (höchstens 1000 Zeichen)." })
|
||||
.default(""),
|
||||
});
|
||||
|
||||
/**
|
||||
* Grobe Bremse gegen Fluten.
|
||||
*
|
||||
* Im Arbeitsspeicher und damit pro Container — bei einem Dienst dieser Größe
|
||||
* genügt das. Die Adresse wird nur als Schlüssel benutzt und nirgends
|
||||
* gespeichert; nach dem Zeitfenster ist sie wieder weg.
|
||||
*/
|
||||
const FENSTER_MS = 60_000;
|
||||
const MAX_PRO_FENSTER = 10;
|
||||
const zaehler = new Map<string, { anzahl: number; bis: number }>();
|
||||
|
||||
function zuSchnell(schluessel: string): boolean {
|
||||
const jetzt = Date.now();
|
||||
const eintrag = zaehler.get(schluessel);
|
||||
|
||||
if (!eintrag || eintrag.bis < jetzt) {
|
||||
zaehler.set(schluessel, { anzahl: 1, bis: jetzt + FENSTER_MS });
|
||||
// Gelegentlich ausmisten, damit die Tabelle nicht wächst.
|
||||
if (zaehler.size > 5000) {
|
||||
for (const [key, wert] of zaehler) {
|
||||
if (wert.bis < jetzt) zaehler.delete(key);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
eintrag.anzahl += 1;
|
||||
return eintrag.anzahl > MAX_PRO_FENSTER;
|
||||
}
|
||||
|
||||
/** Nimmt eine Rückmeldung von einer öffentlichen Landingpage entgegen. */
|
||||
export async function POST(request: NextRequest): Promise<Response> {
|
||||
try {
|
||||
const parsed = feedbackSchema.safeParse(await request.json());
|
||||
if (!parsed.success) {
|
||||
return jsonError(firstZodMessage(parsed.error));
|
||||
}
|
||||
|
||||
const herkunft =
|
||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||
request.headers.get("x-real-ip") ||
|
||||
"unbekannt";
|
||||
|
||||
if (zuSchnell(herkunft)) {
|
||||
return jsonError(
|
||||
"Zu viele Rückmeldungen in kurzer Zeit. Bitte versuche es gleich noch einmal.",
|
||||
429,
|
||||
);
|
||||
}
|
||||
|
||||
// Nur Codes, die tatsächlich Feedback einsammeln — sonst ließen sich
|
||||
// beliebige Codes mit Einträgen zumüllen.
|
||||
const code = await prisma.qrCode.findUnique({
|
||||
where: { id: parsed.data.codeId },
|
||||
select: { id: true, type: true },
|
||||
});
|
||||
if (!code || code.type !== "feedback") {
|
||||
return jsonError("Zu diesem Code lässt sich keine Rückmeldung abgeben.", 404);
|
||||
}
|
||||
|
||||
await prisma.feedback.create({
|
||||
data: {
|
||||
qrCodeId: code.id,
|
||||
rating: parsed.data.rating,
|
||||
comment: parsed.data.comment.length > 0 ? parsed.data.comment : null,
|
||||
},
|
||||
});
|
||||
|
||||
return Response.json({ ok: true }, { status: 201 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Context = { params: Promise<{ id: string }> };
|
||||
|
||||
/**
|
||||
* Liefert eine hochgeladene Datei aus.
|
||||
*
|
||||
* Bewusst ohne Anmeldung: Die Landingpages sind öffentlich, und wer einen
|
||||
* Code scannt, soll das hinterlegte PDF sehen können. Der Schutz liegt in der
|
||||
* nicht erratbaren Id — dieselbe Überlegung wie beim Slug einer Kurz-URL.
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
context: Context,
|
||||
): Promise<Response> {
|
||||
const { id } = await context.params;
|
||||
|
||||
const upload = await prisma.upload.findUnique({ where: { id } });
|
||||
if (!upload) {
|
||||
return new Response("Datei nicht gefunden", {
|
||||
status: 404,
|
||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
|
||||
// `?download` erzwingt das Speichern statt der Anzeige.
|
||||
const alsDownload = request.nextUrl.searchParams.has("download");
|
||||
|
||||
return new Response(new Uint8Array(upload.data), {
|
||||
headers: {
|
||||
"Content-Type": upload.mimeType,
|
||||
"Content-Length": String(upload.size),
|
||||
// Der Typ steht fest — der Browser soll nicht raten und daraus
|
||||
// womöglich etwas Ausführbares machen.
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
// Ein PDF darf Skripte enthalten. Ausgeliefert von derselben Adresse
|
||||
// wie die Anwendung könnte es damit auf angemeldete Sitzungen
|
||||
// zugreifen; die Sandbox nimmt ihm diese Möglichkeit.
|
||||
"Content-Security-Policy": "sandbox; default-src 'none'",
|
||||
"Content-Disposition": `${alsDownload ? "attachment" : "inline"}; filename*=UTF-8''${encodeURIComponent(upload.filename)}`,
|
||||
// Der Inhalt einer Id ändert sich nie — die Id wechselt mit der Datei.
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { getActor } from "@/lib/auth/actor";
|
||||
import { resolveTargetGroup } from "@/lib/auth/groups";
|
||||
import { requirePermission } from "@/lib/auth/policy";
|
||||
import { errorResponse, jsonError } from "@/lib/http";
|
||||
import {
|
||||
cleanupOrphanedUploads,
|
||||
isUploadKind,
|
||||
maxUploadBytes,
|
||||
storeUpload,
|
||||
} from "@/lib/uploads";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Nimmt eine Datei entgegen und gibt ihre Id zurück.
|
||||
*
|
||||
* Der Upload passiert vor dem Speichern des Codes — anders geht es nicht, das
|
||||
* Formular braucht die Id, um sie im Code zu hinterlegen. Dateien ohne Code
|
||||
* bleiben deshalb erst einmal liegen und werden später weggeräumt.
|
||||
*/
|
||||
export async function POST(request: NextRequest): Promise<Response> {
|
||||
try {
|
||||
const actor = await getActor();
|
||||
|
||||
// Wer keinen Code anlegen darf, soll auch nichts ablegen können.
|
||||
const groupId = await resolveTargetGroup(actor, undefined);
|
||||
requirePermission(actor, "codes:create", { groupId });
|
||||
|
||||
let form: FormData;
|
||||
try {
|
||||
form = await request.formData();
|
||||
} catch {
|
||||
return jsonError("Die Datei konnte nicht gelesen werden.");
|
||||
}
|
||||
|
||||
const kind = form.get("kind");
|
||||
if (!isUploadKind(kind)) {
|
||||
return jsonError("Unbekannte Dateiart.");
|
||||
}
|
||||
|
||||
const file = form.get("file");
|
||||
if (!(file instanceof File)) {
|
||||
return jsonError("Es wurde keine Datei mitgeschickt.");
|
||||
}
|
||||
|
||||
const upload = await storeUpload(file, kind);
|
||||
|
||||
// Beiläufig aufräumen. Scheitert das, ist der Upload trotzdem gültig.
|
||||
try {
|
||||
await cleanupOrphanedUploads();
|
||||
} catch (error) {
|
||||
console.error("Verwaiste Uploads konnten nicht entfernt werden:", error);
|
||||
}
|
||||
|
||||
return Response.json({ upload }, { status: 201 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
/** Damit die Oberfläche die Grenze nennen kann, bevor jemand wählt. */
|
||||
export async function GET(): Promise<Response> {
|
||||
return Response.json({ maxBytes: maxUploadBytes() });
|
||||
}
|
||||
+1
-1
@@ -22,7 +22,7 @@ export const metadata: Metadata = {
|
||||
export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||
return (
|
||||
<html lang="de" className="h-full">
|
||||
<body className="min-h-full">{children}</body>
|
||||
<body className="flex min-h-full flex-col">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -524,6 +524,18 @@ export function CodesList() {
|
||||
|
||||
<td className="py-2.5 pr-4 pl-3">
|
||||
<div className="flex items-center justify-end gap-0.5">
|
||||
{/* Ein Feedback-Code ohne Weg zu den Antworten waere
|
||||
nur die halbe Sache. */}
|
||||
{code.type === "feedback" ? (
|
||||
<Link
|
||||
href={`/meine-codes/${code.id}/feedback`}
|
||||
title="Rückmeldungen ansehen"
|
||||
aria-label={`Rückmeldungen zu ${displayName(code)} ansehen`}
|
||||
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900"
|
||||
>
|
||||
<Icon name="star" className="size-4" />
|
||||
</Link>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEdit(code)}
|
||||
@@ -655,6 +667,15 @@ export function CodesList() {
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{code.type === "feedback" ? (
|
||||
<Link
|
||||
href={`/meine-codes/${code.id}/feedback`}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
|
||||
>
|
||||
<Icon name="star" className="size-3.5" />
|
||||
Rückmeldungen
|
||||
</Link>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { LandingShell } from "@/components/landing/shell";
|
||||
import type { LandingProps } from "@/components/landing/types";
|
||||
import { FEEDBACK_SCALES, feedbackDataSchema } from "@/lib/qr/types/feedback";
|
||||
|
||||
export function FeedbackLanding({ data, codeId }: LandingProps) {
|
||||
const { title, question, scale, allowComment, commentLabel, thankYou } =
|
||||
feedbackDataSchema.parse(data);
|
||||
|
||||
const [note, setNote] = useState<number | null>(null);
|
||||
const [kommentar, setKommentar] = useState("");
|
||||
const [laeuft, setLaeuft] = useState(false);
|
||||
const [fertig, setFertig] = useState(false);
|
||||
const [fehler, setFehler] = useState<string | null>(null);
|
||||
|
||||
const symbole = FEEDBACK_SCALES[scale].symbols;
|
||||
|
||||
async function absenden() {
|
||||
if (note === null) return;
|
||||
setLaeuft(true);
|
||||
setFehler(null);
|
||||
try {
|
||||
const response = await fetch("/api/feedback", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ codeId, rating: note, comment: kommentar }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => null);
|
||||
setFehler(body?.error ?? "Die Rückmeldung konnte nicht gesendet werden.");
|
||||
return;
|
||||
}
|
||||
setFertig(true);
|
||||
} catch {
|
||||
setFehler(
|
||||
"Die Rückmeldung konnte nicht gesendet werden. Besteht die Verbindung noch?",
|
||||
);
|
||||
} finally {
|
||||
setLaeuft(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (fertig) {
|
||||
return (
|
||||
<LandingShell title={title}>
|
||||
<div className="rounded-2xl border border-emerald-200 bg-emerald-50 px-6 py-10 text-center">
|
||||
<p className="text-4xl" aria-hidden="true">
|
||||
✓
|
||||
</p>
|
||||
<p className="mt-3 text-lg font-medium text-emerald-900">
|
||||
{thankYou}
|
||||
</p>
|
||||
</div>
|
||||
</LandingShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LandingShell title={title}>
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm sm:p-6">
|
||||
<fieldset>
|
||||
<legend className="text-center text-base font-medium text-slate-900">
|
||||
{question}
|
||||
</legend>
|
||||
|
||||
<div className="mt-5 flex justify-center gap-1.5 sm:gap-2">
|
||||
{symbole.map((symbol, index) => {
|
||||
const wert = index + 1;
|
||||
const gewaehlt = note !== null && note >= wert;
|
||||
return (
|
||||
<button
|
||||
key={wert}
|
||||
type="button"
|
||||
aria-label={`${wert} von 5`}
|
||||
aria-pressed={note === wert}
|
||||
disabled={laeuft}
|
||||
onClick={() => setNote(wert)}
|
||||
className={
|
||||
scale === "stars"
|
||||
? `rounded-lg px-1 text-4xl transition-transform hover:scale-110 sm:text-5xl ${
|
||||
gewaehlt ? "text-amber-400" : "text-slate-200"
|
||||
}`
|
||||
: `rounded-lg px-1 text-3xl transition-transform hover:scale-110 sm:text-4xl ${
|
||||
note === wert
|
||||
? "scale-110"
|
||||
: "opacity-40 grayscale"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{symbol}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{note !== null ? (
|
||||
<p className="mt-2 text-center text-sm text-slate-500">
|
||||
{note} von 5
|
||||
</p>
|
||||
) : null}
|
||||
</fieldset>
|
||||
|
||||
{allowComment ? (
|
||||
<div className="mt-6">
|
||||
<label
|
||||
htmlFor="feedback-comment"
|
||||
className="mb-1.5 block text-sm font-medium text-slate-700"
|
||||
>
|
||||
{commentLabel}
|
||||
</label>
|
||||
<textarea
|
||||
id="feedback-comment"
|
||||
rows={3}
|
||||
maxLength={1000}
|
||||
value={kommentar}
|
||||
disabled={laeuft}
|
||||
onChange={(event) => setKommentar(event.target.value)}
|
||||
className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2.5 text-sm text-slate-900 shadow-sm placeholder:text-slate-400"
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{fehler ? (
|
||||
<p role="alert" className="mt-4 text-sm text-red-600">
|
||||
{fehler}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={note === null || laeuft}
|
||||
onClick={() => void absenden()}
|
||||
className="mt-6 w-full rounded-lg bg-indigo-600 px-4 py-3 font-medium text-white transition-colors hover:bg-indigo-700 disabled:cursor-not-allowed disabled:bg-slate-300"
|
||||
>
|
||||
{laeuft ? "Wird gesendet …" : "Absenden"}
|
||||
</button>
|
||||
|
||||
<p className="mt-3 text-center text-xs text-slate-400">
|
||||
Deine Rückmeldung wird ohne Namen und ohne IP-Adresse gespeichert.
|
||||
</p>
|
||||
</div>
|
||||
</LandingShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { LandingShell } from "@/components/landing/shell";
|
||||
import type { LandingProps } from "@/components/landing/types";
|
||||
import { imageDataSchema } from "@/lib/qr/types/image";
|
||||
|
||||
export function ImageLanding({ data }: LandingProps) {
|
||||
const { title, description, images } = imageDataSchema.parse(data);
|
||||
|
||||
return (
|
||||
<LandingShell title={title || "Bilder"} subtitle={description || null}>
|
||||
<div className="space-y-4">
|
||||
{images.map((image, index) => (
|
||||
<figure
|
||||
key={image.fileId}
|
||||
className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- eigene
|
||||
Auslieferung ohne bekannte Maße; der Optimierer von Next
|
||||
brächte hier nichts. */}
|
||||
<img
|
||||
src={`/api/uploads/${image.fileId}`}
|
||||
alt={image.caption || `Bild ${index + 1}`}
|
||||
// Das erste Bild sieht man sofort, die übrigen erst beim Scrollen.
|
||||
loading={index === 0 ? "eager" : "lazy"}
|
||||
className="block w-full"
|
||||
/>
|
||||
{image.caption ? (
|
||||
<figcaption className="border-t border-slate-100 px-4 py-3 text-sm text-slate-600">
|
||||
{image.caption}
|
||||
</figcaption>
|
||||
) : null}
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
</LandingShell>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
import { FeedbackLanding } from "@/components/landing/feedback-landing";
|
||||
import { ImageLanding } from "@/components/landing/image-landing";
|
||||
import { LinksLanding } from "@/components/landing/links-landing";
|
||||
import { PdfLanding } from "@/components/landing/pdf-landing";
|
||||
import { SocialLanding } from "@/components/landing/social-landing";
|
||||
import type { LandingProps } from "@/components/landing/types";
|
||||
import { VideoLanding } from "@/components/landing/video-landing";
|
||||
@@ -17,6 +20,9 @@ export const qrLandings: Partial<Record<QrTypeId, ComponentType<LandingProps>>>
|
||||
{
|
||||
links: LinksLanding,
|
||||
social: SocialLanding,
|
||||
pdf: PdfLanding,
|
||||
image: ImageLanding,
|
||||
feedback: FeedbackLanding,
|
||||
video: VideoLanding,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Icon } from "@/components/icons";
|
||||
import { LandingShell } from "@/components/landing/shell";
|
||||
import type { LandingProps } from "@/components/landing/types";
|
||||
import { pdfDataSchema } from "@/lib/qr/types/pdf";
|
||||
import { formatBytes } from "@/lib/uploads";
|
||||
|
||||
export function PdfLanding({ data }: LandingProps) {
|
||||
const { fileId, filename, size, title, description } =
|
||||
pdfDataSchema.parse(data);
|
||||
const href = `/api/uploads/${fileId}`;
|
||||
|
||||
return (
|
||||
<LandingShell
|
||||
title={title || filename || "PDF-Dokument"}
|
||||
subtitle={description || null}
|
||||
>
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-red-50 text-red-600">
|
||||
<Icon name="document" className="size-6" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium text-slate-900">{filename}</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
PDF{size > 0 ? ` · ${formatBytes(size)}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-2 sm:grid-cols-2">
|
||||
<a
|
||||
href={href}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-indigo-700"
|
||||
>
|
||||
<Icon name="document" className="size-4" />
|
||||
Dokument öffnen
|
||||
</a>
|
||||
<a
|
||||
href={`${href}?download`}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-white px-4 py-2.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
|
||||
>
|
||||
<Icon name="download" className="size-4" />
|
||||
Herunterladen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</LandingShell>
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,7 @@ export function LandingShell({
|
||||
footer?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-full flex-col bg-slate-50">
|
||||
<div className="flex flex-1 flex-col bg-slate-50">
|
||||
<main className="mx-auto w-full max-w-lg flex-1 px-4 py-10 sm:px-6 sm:py-14">
|
||||
<header className="mb-7 text-center">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-balance text-slate-900 sm:text-3xl">
|
||||
@@ -52,7 +52,7 @@ export function LandingNotice({
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid min-h-full place-items-center bg-slate-50 px-4 py-16">
|
||||
<div className="grid flex-1 place-items-center bg-slate-50 px-4 py-16">
|
||||
<div className="max-w-md text-center">
|
||||
<h1 className="text-xl font-bold text-slate-900 sm:text-2xl">
|
||||
{title}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import type { QrFormProps } from "@/components/qr-forms/types";
|
||||
import { Checkbox, Field, Select, TextArea, TextInput } from "@/components/ui";
|
||||
import { FEEDBACK_SCALES } from "@/lib/qr/types/feedback";
|
||||
|
||||
export function FeedbackForm({ value, onChange, error, disabled }: QrFormProps) {
|
||||
const title = typeof value.title === "string" ? value.title : "";
|
||||
const question = typeof value.question === "string" ? value.question : "";
|
||||
const scale = value.scale === "faces" ? "faces" : "stars";
|
||||
const allowComment = value.allowComment !== false;
|
||||
const commentLabel =
|
||||
typeof value.commentLabel === "string" ? value.commentLabel : "";
|
||||
const thankYou = typeof value.thankYou === "string" ? value.thankYou : "";
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<Field
|
||||
label="Überschrift der Seite"
|
||||
htmlFor="feedback-title"
|
||||
error={error}
|
||||
hint="Wo hängt der Code? Zum Beispiel „Gastraum“ oder „Werkstatt“."
|
||||
>
|
||||
<TextInput
|
||||
id="feedback-title"
|
||||
value={title}
|
||||
invalid={Boolean(error)}
|
||||
disabled={disabled}
|
||||
maxLength={80}
|
||||
placeholder="Wie war dein Besuch?"
|
||||
onChange={(event) => onChange({ ...value, title: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Frage"
|
||||
htmlFor="feedback-question"
|
||||
hint="Steht über den Bewertungssymbolen."
|
||||
>
|
||||
<TextInput
|
||||
id="feedback-question"
|
||||
value={question}
|
||||
disabled={disabled}
|
||||
maxLength={160}
|
||||
placeholder="Wie zufrieden bist du?"
|
||||
onChange={(event) =>
|
||||
onChange({ ...value, question: event.target.value })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Bewertungsskala"
|
||||
htmlFor="feedback-scale"
|
||||
hint="In beiden Fällen wird 1 bis 5 gespeichert."
|
||||
>
|
||||
<Select
|
||||
id="feedback-scale"
|
||||
value={scale}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange({ ...value, scale: event.target.value })}
|
||||
>
|
||||
{Object.entries(FEEDBACK_SCALES).map(([id, eintrag]) => (
|
||||
<option key={id} value={id}>
|
||||
{eintrag.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Checkbox
|
||||
id="feedback-allow-comment"
|
||||
checked={allowComment}
|
||||
disabled={disabled}
|
||||
onChange={(event) =>
|
||||
onChange({ ...value, allowComment: event.target.checked })
|
||||
}
|
||||
label="Zusätzlich einen freien Text zulassen"
|
||||
hint="Sonst gibt es nur die Note — das senkt die Hemmschwelle."
|
||||
/>
|
||||
|
||||
{allowComment ? (
|
||||
<Field label="Beschriftung des Textfelds" htmlFor="feedback-comment-label">
|
||||
<TextInput
|
||||
id="feedback-comment-label"
|
||||
value={commentLabel}
|
||||
disabled={disabled}
|
||||
maxLength={120}
|
||||
placeholder="Möchtest du uns noch etwas sagen?"
|
||||
onChange={(event) =>
|
||||
onChange({ ...value, commentLabel: event.target.value })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
) : null}
|
||||
|
||||
<Field
|
||||
label="Text nach dem Absenden"
|
||||
htmlFor="feedback-thanks"
|
||||
hint="Das Letzte, was jemand von dieser Seite sieht."
|
||||
>
|
||||
<TextArea
|
||||
id="feedback-thanks"
|
||||
rows={2}
|
||||
value={thankYou}
|
||||
disabled={disabled}
|
||||
maxLength={200}
|
||||
placeholder="Danke für deine Rückmeldung!"
|
||||
onChange={(event) =>
|
||||
onChange({ ...value, thankYou: event.target.value })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useRef, useState } from "react";
|
||||
|
||||
import { Icon } from "@/components/icons";
|
||||
import { Button } from "@/components/ui";
|
||||
import { UPLOAD_KINDS, type UploadKind } from "@/lib/uploads.shared";
|
||||
|
||||
export type UploadedFile = {
|
||||
fileId: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Datei auswählen und sofort hochladen.
|
||||
*
|
||||
* Der Upload passiert vor dem Speichern des Codes, weil das Formular die Id
|
||||
* der Datei braucht. Bleibt der Code ungespeichert, räumt der Server die
|
||||
* Datei später von selbst weg.
|
||||
*/
|
||||
export function FileField({
|
||||
kind,
|
||||
onUploaded,
|
||||
disabled,
|
||||
label,
|
||||
}: {
|
||||
kind: UploadKind;
|
||||
onUploaded: (file: UploadedFile) => void;
|
||||
disabled?: boolean;
|
||||
label: string;
|
||||
}) {
|
||||
const inputId = useId();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [laeuft, setLaeuft] = useState(false);
|
||||
const [fehler, setFehler] = useState<string | null>(null);
|
||||
|
||||
async function hochladen(file: File) {
|
||||
setFehler(null);
|
||||
setLaeuft(true);
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.set("kind", kind);
|
||||
form.set("file", file);
|
||||
|
||||
const response = await fetch("/api/uploads", {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
const body = await response.json().catch(() => null);
|
||||
|
||||
if (!response.ok) {
|
||||
setFehler(body?.error ?? "Die Datei konnte nicht hochgeladen werden.");
|
||||
return;
|
||||
}
|
||||
|
||||
onUploaded({
|
||||
fileId: body.upload.id,
|
||||
filename: body.upload.filename,
|
||||
size: body.upload.size,
|
||||
});
|
||||
} catch {
|
||||
setFehler(
|
||||
"Die Datei konnte nicht hochgeladen werden. Besteht die Verbindung noch?",
|
||||
);
|
||||
} finally {
|
||||
setLaeuft(false);
|
||||
// Damit dieselbe Datei erneut gewählt werden kann.
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
id={inputId}
|
||||
type="file"
|
||||
accept={UPLOAD_KINDS[kind].accept}
|
||||
disabled={disabled || laeuft}
|
||||
className="sr-only"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) void hochladen(file);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={disabled || laeuft}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
{laeuft ? (
|
||||
"Wird hochgeladen …"
|
||||
) : (
|
||||
<>
|
||||
<Icon name="download" className="size-4 rotate-180" />
|
||||
{label}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{fehler ? (
|
||||
<p role="alert" className="mt-2 text-sm text-red-600">
|
||||
{fehler}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
"use client";
|
||||
|
||||
import { Icon } from "@/components/icons";
|
||||
import { FileField } from "@/components/qr-forms/file-field";
|
||||
import type { QrFormProps } from "@/components/qr-forms/types";
|
||||
import { Field, TextArea, TextInput } from "@/components/ui";
|
||||
import { MAX_IMAGES, type ImageEntry } from "@/lib/qr/types/image";
|
||||
import { formatBytes } from "@/lib/uploads.shared";
|
||||
|
||||
function readImages(value: unknown): ImageEntry[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.flatMap((entry) => {
|
||||
const record = (entry ?? {}) as Record<string, unknown>;
|
||||
if (typeof record.fileId !== "string" || record.fileId.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
fileId: record.fileId,
|
||||
filename: typeof record.filename === "string" ? record.filename : "bild",
|
||||
size: typeof record.size === "number" ? record.size : 0,
|
||||
caption: typeof record.caption === "string" ? record.caption : "",
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
export function ImageForm({ value, onChange, error, disabled }: QrFormProps) {
|
||||
const title = typeof value.title === "string" ? value.title : "";
|
||||
const description =
|
||||
typeof value.description === "string" ? value.description : "";
|
||||
const images = readImages(value.images);
|
||||
|
||||
const setImages = (next: ImageEntry[]) => onChange({ ...value, images: next });
|
||||
|
||||
const move = (index: number, direction: -1 | 1) => {
|
||||
const target = index + direction;
|
||||
if (target < 0 || target >= images.length) return;
|
||||
const next = [...images];
|
||||
[next[index], next[target]] = [next[target], next[index]];
|
||||
setImages(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<div className="mb-2 flex items-baseline justify-between gap-3">
|
||||
<span className="text-sm font-medium text-slate-700">Bilder</span>
|
||||
<span className="text-xs text-slate-500">
|
||||
{images.length} von {MAX_IMAGES}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{images.length > 0 ? (
|
||||
<ul className="mb-3 space-y-3">
|
||||
{images.map((image, index) => (
|
||||
<li
|
||||
key={image.fileId}
|
||||
className="rounded-xl border border-slate-200 bg-slate-50/70 p-3"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- eigene
|
||||
Auslieferung, unbekannte Maße */}
|
||||
<img
|
||||
src={`/api/uploads/${image.fileId}`}
|
||||
alt=""
|
||||
className="size-14 shrink-0 rounded-lg border border-slate-200 bg-white object-cover"
|
||||
/>
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<p className="truncate text-sm text-slate-600">
|
||||
{image.filename}
|
||||
{image.size > 0 ? ` · ${formatBytes(image.size)}` : ""}
|
||||
</p>
|
||||
<TextInput
|
||||
aria-label={`Bildunterschrift von Bild ${index + 1}`}
|
||||
value={image.caption}
|
||||
disabled={disabled}
|
||||
maxLength={120}
|
||||
placeholder="Bildunterschrift (optional)"
|
||||
onChange={(event) =>
|
||||
setImages(
|
||||
images.map((entry, i) =>
|
||||
i === index
|
||||
? { ...entry, caption: event.target.value }
|
||||
: entry,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || index === 0}
|
||||
onClick={() => move(index, -1)}
|
||||
aria-label={`Bild ${index + 1} nach oben`}
|
||||
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
|
||||
>
|
||||
<Icon name="arrowLeft" className="size-4 rotate-90" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || index === images.length - 1}
|
||||
onClick={() => move(index, 1)}
|
||||
aria-label={`Bild ${index + 1} nach unten`}
|
||||
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
|
||||
>
|
||||
<Icon name="arrowLeft" className="size-4 -rotate-90" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() =>
|
||||
setImages(images.filter((_, i) => i !== index))
|
||||
}
|
||||
aria-label={`Bild ${index + 1} entfernen`}
|
||||
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-red-600 disabled:opacity-40"
|
||||
>
|
||||
<Icon name="trash" className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{images.length < MAX_IMAGES ? (
|
||||
<FileField
|
||||
kind="image"
|
||||
label={images.length === 0 ? "Bild auswählen" : "Weiteres Bild"}
|
||||
disabled={disabled}
|
||||
onUploaded={(file) =>
|
||||
setImages([
|
||||
...images,
|
||||
{
|
||||
fileId: file.fileId,
|
||||
filename: file.filename,
|
||||
size: file.size,
|
||||
caption: "",
|
||||
},
|
||||
])
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<p role="alert" className="mt-2 text-sm text-red-600">
|
||||
{error}
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-2 text-sm text-slate-500">
|
||||
PNG, JPEG, WebP, GIF oder AVIF. Mehrere Bilder erscheinen
|
||||
untereinander als Galerie.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Field
|
||||
label="Titel (optional)"
|
||||
htmlFor="image-title"
|
||||
hint="Steht über den Bildern."
|
||||
>
|
||||
<TextInput
|
||||
id="image-title"
|
||||
value={title}
|
||||
disabled={disabled}
|
||||
maxLength={80}
|
||||
placeholder="Impressionen vom Hoffest"
|
||||
onChange={(event) => onChange({ ...value, title: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Kurzer Text (optional)"
|
||||
htmlFor="image-description"
|
||||
hint="Eine Zeile zur Einordnung."
|
||||
>
|
||||
<TextArea
|
||||
id="image-description"
|
||||
rows={2}
|
||||
value={description}
|
||||
disabled={disabled}
|
||||
maxLength={300}
|
||||
onChange={(event) =>
|
||||
onChange({ ...value, description: event.target.value })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
import { FeedbackForm } from "@/components/qr-forms/feedback-form";
|
||||
import { ImageForm } from "@/components/qr-forms/image-form";
|
||||
import { LinksForm } from "@/components/qr-forms/links-form";
|
||||
import { PaymentForm } from "@/components/qr-forms/payment-form";
|
||||
import { PdfForm } from "@/components/qr-forms/pdf-form";
|
||||
import {
|
||||
AppForm,
|
||||
EmailForm,
|
||||
@@ -30,6 +33,9 @@ export const qrForms: Partial<Record<QrTypeId, ComponentType<QrFormProps>>> = {
|
||||
app: AppForm,
|
||||
links: LinksForm,
|
||||
social: SocialForm,
|
||||
pdf: PdfForm,
|
||||
image: ImageForm,
|
||||
feedback: FeedbackForm,
|
||||
video: VideoForm,
|
||||
whatsapp: WhatsappForm,
|
||||
email: EmailForm,
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { Icon } from "@/components/icons";
|
||||
import { FileField } from "@/components/qr-forms/file-field";
|
||||
import type { QrFormProps } from "@/components/qr-forms/types";
|
||||
import { Field, TextArea, TextInput } from "@/components/ui";
|
||||
import { formatBytes } from "@/lib/uploads.shared";
|
||||
|
||||
export function PdfForm({ value, onChange, error, disabled }: QrFormProps) {
|
||||
const fileId = typeof value.fileId === "string" ? value.fileId : "";
|
||||
const filename = typeof value.filename === "string" ? value.filename : "";
|
||||
const size = typeof value.size === "number" ? value.size : 0;
|
||||
const title = typeof value.title === "string" ? value.title : "";
|
||||
const description =
|
||||
typeof value.description === "string" ? value.description : "";
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<span className="mb-1.5 block text-sm font-medium text-slate-700">
|
||||
PDF-Datei
|
||||
</span>
|
||||
|
||||
{fileId ? (
|
||||
<div className="flex items-center gap-3 rounded-xl border border-slate-200 bg-slate-50/70 p-3">
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-red-50 text-red-600">
|
||||
<Icon name="document" className="size-5" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-slate-900">
|
||||
{filename || "Dokument"}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{size > 0 ? formatBytes(size) : "PDF"}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() =>
|
||||
onChange({ ...value, fileId: "", filename: "", size: 0 })
|
||||
}
|
||||
aria-label="Datei entfernen"
|
||||
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-red-600 disabled:opacity-40"
|
||||
>
|
||||
<Icon name="trash" className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<FileField
|
||||
kind="pdf"
|
||||
label="PDF auswählen"
|
||||
disabled={disabled}
|
||||
onUploaded={(file) =>
|
||||
onChange({
|
||||
...value,
|
||||
fileId: file.fileId,
|
||||
filename: file.filename,
|
||||
size: file.size,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error ? (
|
||||
<p role="alert" className="mt-2 text-sm text-red-600">
|
||||
{error}
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-2 text-sm text-slate-500">
|
||||
Die Datei liegt anschließend hier und ist über den Code erreichbar.
|
||||
Sie lässt sich jederzeit austauschen, ohne den Code neu zu drucken.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Field
|
||||
label="Titel (optional)"
|
||||
htmlFor="pdf-title"
|
||||
hint="Steht über dem Dokument. Ohne Angabe gilt der Dateiname."
|
||||
>
|
||||
<TextInput
|
||||
id="pdf-title"
|
||||
value={title}
|
||||
disabled={disabled}
|
||||
maxLength={80}
|
||||
placeholder="Preisliste 2026"
|
||||
onChange={(event) => onChange({ ...value, title: event.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Kurzer Text (optional)"
|
||||
htmlFor="pdf-description"
|
||||
hint="Eine Zeile zur Einordnung."
|
||||
>
|
||||
<TextArea
|
||||
id="pdf-description"
|
||||
rows={2}
|
||||
value={description}
|
||||
disabled={disabled}
|
||||
maxLength={300}
|
||||
onChange={(event) =>
|
||||
onChange({ ...value, description: event.target.value })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+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