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