Files
qrcode-generator/prisma/schema.prisma
T
2026-08-21 10:55:54 +02:00

344 lines
13 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.
/// Leer bei Konten, die sich ausschließlich über den externen Anbieter
/// anmelden: dort gibt es hier kein Passwort zu prüfen.
passwordHash String?
/// Stabile Kennung beim externen Anbieter (OIDC `sub`). Bleibt leer, solange
/// sich das Konto nur mit Passwort anmeldet.
oidcSubject String? @unique
/// 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)
/// Läuft ohne Aktivität ab; wird beim Arbeiten nachgeführt.
expiresAt DateTime
/// Harte Obergrenze ab dem Anmelden. Ohne sie könnte eine gestohlene
/// Sitzung durch regelmäßige Nutzung unbegrenzt weiterleben.
absoluteExpiresAt DateTime @default(dbgenerated("CURRENT_TIMESTAMP + INTERVAL '90 days'"))
lastSeenAt DateTime @default(now())
createdAt DateTime @default(now())
@@index([userId])
@@index([expiresAt])
@@index([absoluteExpiresAt])
}
/// 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[]
aliases QrCodeAlias[]
}
/// 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())
/// Statisch oder dynamisch. Ein statischer Code traegt seinen Inhalt selbst
/// und wird nur abgelegt, damit man ihn wiederfindet und erneut
/// herunterladen kann — er hat weder Kurz-Link noch Scan-Zaehler.
mode String @default("dynamic")
/// Kurz-Link. 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.
///
/// Leer bei statischen Codes: Die fuehren auf nichts, was dieser Server
/// ausliefert. In PostgreSQL schliessen sich mehrere NULL in einem
/// eindeutigen Index nicht aus, die Bedingung bleibt also gueltig.
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)
/// Logo in der Mitte: "branding" nimmt das Logo der Installation,
/// "upload" eine eigene Datei. Leer heisst: kein Logo.
logoSource String?
/// Die eigene Datei, wenn `logoSource` = "upload". Sie haengt ueber
/// `Upload.qrCodeId` am Code und verschwindet mit ihm.
logoUploadId String?
/// Kantenlaenge des Logos in Prozent der Kantenlaenge des Codes.
logoScale Int @default(20)
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[]
/// Frühere Kurz-Links, die weiterhin hierher führen.
aliases QrCodeAlias[]
@@unique([domainId, slug])
@@index([createdAt])
@@index([groupId])
@@index([domainId])
@@index([mode])
}
/// 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())
/// „code" gehört zu einem QR-Code, „branding" zur Oberfläche. Nur die
/// erste Art wird als herrenlos weggeräumt.
purpose String @default("code")
/// 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)
brandingLogo Branding? @relation("BrandingLogo")
brandingFavicon Branding? @relation("BrandingFavicon")
@@index([qrCodeId])
@@index([createdAt])
@@index([purpose])
}
/// Verhalten der Installation, das sich nicht aus dem Code ergibt.
///
/// Genau eine Zeile mit fester Id — wie `Branding`, nur für Verhalten statt
/// Aussehen. Ein eigenes Modell statt Schlüssel-Wert-Paaren, damit die Felder
/// ihren Typ behalten.
model InstanceSetting {
id String @id @default("instance")
/// Dürfen Bearbeiter den Kurz-Link selbst vergeben und ändern?
///
/// Aus, weil ein Kurz-Link eine öffentliche Adresse ist: Wer ihn ändert,
/// entwertet im Zweifel gedruckte Codes oder besetzt Adressen, die
/// jemand anderes gebraucht hätte. Administratoren können das freigeben.
editorsMaySetSlug Boolean @default(false)
/// Geheimnis fuer den Domain-Check.
///
/// Die Pruefung ruft die Domain von aussen auf und laesst sich einen HMAC
/// ueber eine Zufallszahl zurueckgeben. Nur wer dieses Geheimnis kennt,
/// kann ihn bilden — so steht fest, dass wirklich diese Installation
/// geantwortet hat und nicht irgendein anderer Server. Es verlaesst den
/// Server nie und entsteht beim ersten Check.
checkSecret String?
updatedAt DateTime @updatedAt
}
/// Ein früherer Kurz-Link, der weiterhin auf seinen Code führt.
///
/// Ohne das würde jedes Umbenennen die bereits gedruckten Codes entwerten —
/// und genau deren Unveränderlichkeit ist der Sinn eines dynamischen Codes.
/// Freigeben lässt sich ein solcher Eintrag von Hand, etwa nach einem
/// Tippfehler, den noch niemand gedruckt hat.
model QrCodeAlias {
id String @id @default(cuid())
/// Mitgeführt, damit die Eindeutigkeit je Domain gilt — wie beim Code selbst.
domainId String
domain Domain @relation(fields: [domainId], references: [id], onDelete: Cascade)
slug String
qrCodeId String
qrCode QrCode @relation(fields: [qrCodeId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique([domainId, slug])
@@index([qrCodeId])
}
/// Erscheinungsbild der Installation — Name, Logo, Favicon, Akzentfarbe.
///
/// Genau eine Zeile mit fester Id. Ein eigenes Modell statt einzelner
/// Schlüssel-Wert-Paare, damit die Felder ihren Typ behalten und die
/// Beziehungen zu den hochgeladenen Dateien echte Fremdschlüssel sind.
model Branding {
id String @id @default("branding")
appName String @default("QR-Code-Generator")
/// Zeile in der Fußzeile, hinter dem Namen.
footerText String @default("statische und dynamische QR-Codes")
/// Akzentfarbe als Hex. Übernommen werden daraus Farbton und Sättigung;
/// die Helligkeitsstufen stammen aus der Vorlage — siehe
/// src/lib/branding-colors.ts.
accentColor String @default("#4F46E5")
logoUploadId String? @unique
logoUpload Upload? @relation("BrandingLogo", fields: [logoUploadId], references: [id], onDelete: SetNull)
faviconUploadId String? @unique
faviconUpload Upload? @relation("BrandingFavicon", fields: [faviconUploadId], references: [id], onDelete: SetNull)
updatedAt DateTime @updatedAt
}
/// 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])
}