370 lines
14 KiB
Plaintext
370 lines
14 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[]
|
|
typeSettings QrTypeGroupSetting[]
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
|
|
/// Abweichung von der globalen Freigabe fuer eine einzelne Gruppe.
|
|
///
|
|
/// Bewusst eine eigene Tabelle statt eines zusammengesetzten Schluessels in
|
|
/// `QrTypeSetting`: Der globale Fall braeuchte dort eine leere Gruppe, und
|
|
/// PostgreSQL laesst in einem eindeutigen Index beliebig viele NULL
|
|
/// nebeneinander zu — die Eindeutigkeit der globalen Zeile waere also gerade
|
|
/// nicht gesichert.
|
|
///
|
|
/// Fehlt eine Zeile, folgt die Gruppe der globalen Einstellung. Eine Zeile
|
|
/// mit `enabled = false` schaltet den Typ **nur** in dieser Gruppe ab. Eine
|
|
/// Zeile mit `enabled = true` kann eine globale Abschaltung **nicht**
|
|
/// aufheben — global aus gewinnt immer.
|
|
model QrTypeGroupSetting {
|
|
/// Kennung aus der Typ-Registry, z. B. "website".
|
|
type String
|
|
groupId String
|
|
enabled Boolean
|
|
updatedAt DateTime @updatedAt
|
|
|
|
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
|
|
|
@@id([type, groupId])
|
|
@@index([groupId])
|
|
}
|
|
|
|
/// 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])
|
|
}
|