Files
qrcode-generator/prisma/schema.prisma
T
Florian Wilde c06f87e6d9 Logo in der Mitte des QR-Codes
Zur Wahl stehen zwei Quellen: das Logo der Installation aus dem
Erscheinungsbild oder ein eigenes je Code. Die Groesse ist zwischen 10 und
25 Prozent der Kantenlaenge einstellbar, hinter dem Bild liegt eine Flaeche
in der Hintergrundfarbe.

Mit Logo gilt die Fehlerkorrektur H, unabhaengig von der Auswahl — was das
Bild verdeckt, muss sie ausgleichen. Die Auswahl steht dann fest auf H und
sagt auch warum; heimlich geschieht das nicht. Ab 22 Prozent kommt eine
Warnung dazu.

Gerendert wird weiterhin serverseitig, damit Vorschau und Download
uebereinstimmen: das SVG bekommt ein eingebettetes Bild, das PNG wird mit
sharp zusammengesetzt. sharp lag ohnehin als Abhaengigkeit von Next im
Baum und steht jetzt ausdruecklich in der package.json.

Ein eigenes Logo gehoert zur Gestaltung, nicht zum Inhalt — es muss
trotzdem an den Code gehaengt werden. Sonst raeumt die Aufraeumroutine es
nach einem Tag weg und der Code verliert sein Bild.

Refs #8
2026-08-21 03:22:36 +02:00

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