// 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) 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]) }