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.
239 lines
8.2 KiB
Plaintext
239 lines
8.2 KiB
Plaintext
// QR-Code-Generator — Datenmodell
|
|
// Doku: https://pris.ly/d/prisma-schema
|
|
|
|
generator client {
|
|
provider = "prisma-client"
|
|
output = "../src/generated/prisma"
|
|
}
|
|
|
|
datasource db {
|
|
provider = "postgresql"
|
|
}
|
|
|
|
/// Rolle eines Benutzers innerhalb einer Gruppe.
|
|
///
|
|
/// Darüber liegt noch eine Ebene: `User.isInstanceAdmin`. Sie darf Gruppen
|
|
/// und Benutzer überhaupt erst anlegen — ohne sie gäbe es niemanden, der die
|
|
/// erste Gruppe erstellt.
|
|
enum Role {
|
|
/// Darf zusätzlich Mitglieder und Rollen der Gruppe verwalten.
|
|
ADMIN
|
|
/// Darf Codes anlegen, ändern und löschen.
|
|
EDITOR
|
|
/// Darf nur lesen und herunterladen.
|
|
VIEWER
|
|
}
|
|
|
|
model User {
|
|
id String @id @default(cuid())
|
|
email String @unique
|
|
name String?
|
|
/// scrypt-Hash, siehe src/lib/auth/password.ts — nie das Klartextpasswort.
|
|
passwordHash String
|
|
/// Darf Gruppen, Benutzer und Domains verwalten.
|
|
isInstanceAdmin Boolean @default(false)
|
|
/// Deaktivierte Benutzer können sich nicht anmelden; laufende Sitzungen
|
|
/// werden beim nächsten Zugriff verworfen.
|
|
isActive Boolean @default(true)
|
|
failedLogins Int @default(0)
|
|
lockedUntil DateTime?
|
|
lastLoginAt DateTime?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
sessions Session[]
|
|
memberships Membership[]
|
|
createdCodes QrCode[] @relation("QrCodeCreator")
|
|
|
|
@@index([email])
|
|
}
|
|
|
|
/// Angemeldete Sitzung. In der Datenbank statt in einem signierten Cookie,
|
|
/// damit sie sich serverseitig beenden lässt — etwa wenn ein Benutzer
|
|
/// deaktiviert wird.
|
|
model Session {
|
|
id String @id @default(cuid())
|
|
/// SHA-256 des Cookie-Werts. Der Wert selbst steht nirgends in der Datenbank:
|
|
/// wer sie liest, kann sich damit trotzdem nicht anmelden.
|
|
tokenHash String @unique
|
|
userId String
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
expiresAt DateTime
|
|
lastSeenAt DateTime @default(now())
|
|
createdAt DateTime @default(now())
|
|
|
|
@@index([userId])
|
|
@@index([expiresAt])
|
|
}
|
|
|
|
/// Eine Gruppe bündelt Benutzer und die Codes, die ihnen gehören.
|
|
model Group {
|
|
id String @id @default(cuid())
|
|
name String @unique
|
|
description String?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
memberships Membership[]
|
|
codes QrCode[]
|
|
domainGrants DomainGrant[]
|
|
}
|
|
|
|
/// Mitgliedschaft eines Benutzers in einer Gruppe, mit Rolle. Ein Benutzer
|
|
/// kann in mehreren Gruppen sein — mit unterschiedlicher Rolle je Gruppe.
|
|
model Membership {
|
|
id String @id @default(cuid())
|
|
userId String
|
|
groupId String
|
|
role Role
|
|
createdAt DateTime @default(now())
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([userId, groupId])
|
|
@@index([groupId])
|
|
}
|
|
|
|
/// Eine Domain, unter der Kurz-URLs ausgeliefert werden.
|
|
///
|
|
/// Die Weiterleitung erkennt am `Host`-Header, welche Domain gemeint ist, und
|
|
/// prüft ihn gegen diese Tabelle. Ein unbekannter Hostname führt zu 404 —
|
|
/// damit lässt sich über einen gefälschten Header keine fremde Domain
|
|
/// ansprechen.
|
|
model Domain {
|
|
id String @id @default(cuid())
|
|
/// Kleingeschrieben, ohne Schema, ggf. mit Port — z. B. "qr.example.com".
|
|
hostname String @unique
|
|
label String?
|
|
isActive Boolean @default(true)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
grants DomainGrant[]
|
|
codes QrCode[]
|
|
}
|
|
|
|
/// Zuteilung einer Domain an eine Gruppe. Eine Domain kann mehreren Gruppen
|
|
/// dienen; welche davon die Voreinstellung der Gruppe ist, steht in
|
|
/// `isDefault`.
|
|
model DomainGrant {
|
|
id String @id @default(cuid())
|
|
domainId String
|
|
groupId String
|
|
isDefault Boolean @default(false)
|
|
createdAt DateTime @default(now())
|
|
|
|
domain Domain @relation(fields: [domainId], references: [id], onDelete: Cascade)
|
|
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([domainId, groupId])
|
|
@@index([groupId])
|
|
}
|
|
|
|
/// Freigabe eines QR-Typs durch die Verwaltung.
|
|
///
|
|
/// Es gibt nur Zeilen für Typen, die bewusst umgeschaltet wurden — fehlt ein
|
|
/// Eintrag, gilt der Typ als freigegeben. So bleibt eine frische Installation
|
|
/// ohne Vorbelegung nutzbar, und neue Typen aus späteren Versionen sind
|
|
/// automatisch sichtbar.
|
|
model QrTypeSetting {
|
|
/// Kennung aus der Typ-Registry, z. B. "website".
|
|
type String @id
|
|
enabled Boolean
|
|
updatedAt DateTime @updatedAt
|
|
}
|
|
|
|
/// Ein dynamischer QR-Code. Der QR-Code kodiert `{BASE_URL}/r/{slug}` und
|
|
/// leitet beim Aufruf auf das Ziel weiter, das aus `data` aufgelöst wird.
|
|
///
|
|
/// `type` + `data` sind bewusst generisch gehalten: die Typ-Module legen ihr
|
|
/// eigenes Payload-Format in `data` ab, ohne Schemaänderung.
|
|
model QrCode {
|
|
id String @id @default(cuid())
|
|
/// Nur je Domain eindeutig — derselbe sprechende Slug darf auf zwei
|
|
/// Domains liegen. Weil eine Domain mehreren Gruppen dienen kann, gilt
|
|
/// diese Eindeutigkeit über Gruppengrenzen hinweg.
|
|
slug String
|
|
type String
|
|
label String?
|
|
data Json
|
|
fgColor String @default("#000000")
|
|
bgColor String @default("#FFFFFF")
|
|
size Int @default(512)
|
|
ecLevel String @default("M")
|
|
margin Int @default(4)
|
|
scans Int @default(0)
|
|
lastScanAt DateTime?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
/// Domain, unter der die Kurz-URL dieses Codes erreichbar ist.
|
|
domainId String
|
|
domain Domain @relation(fields: [domainId], references: [id])
|
|
/// Jeder Code gehört einer Gruppe — auch bei ausgeschalteter Anmeldung.
|
|
/// Dann ist es die beim ersten Start angelegte Standardgruppe.
|
|
groupId String
|
|
group Group @relation(fields: [groupId], references: [id])
|
|
/// Wer den Code angelegt hat. Leer für Codes aus der Zeit vor der
|
|
/// Anmeldung und für Codes, deren Ersteller gelöscht wurde.
|
|
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])
|
|
}
|