Files
opencrm/backend/prisma/schema.prisma
T
duffyduckandClaude Opus 5 e81a83ae8f Siegelwechsel ist ein Alarm, kein Hinweis (Pentest R185-01/-02)
R185-01 (MEDIUM): Die Flanke, die wir selbst gemeldet hatten, hat der
Tester live bestaetigt. seal-backlog war beim ZWEITEN Aufruf genauso
gegatet wie beim ersten ({"confirm":"SEAL"} -> 200), und das Ereignis
landete nur im Audit-Log, nicht im Alarmkanal. Sein Punkt: der
automatische Rueckhalt des Gegenbuchs haengt an `valid` - und `valid`
ueberlebt ein ersetzendes Siegel per Konstruktion. Angriff: Altzeile per
DB-Zugriff loeschen, neu siegeln, Luecke ist beglaubigt, valid wieder
true. Live reproduziert.

Zwei Schichten, in seiner Reihenfolge:

1. Alarmkanal. Neuer SecurityEventType AUDIT_SEAL_CHANGED (Migration
   20260826120000). Erstes Siegeln HIGH, Ersetzen CRITICAL - geht damit
   ueber sendPendingCriticalAlerts sofort per Mail raus. Die Details
   halten Wurzel vorher/nachher und den vollstaendigen Vorbefund fest.
2. Gate. Steht bereits ein Siegel, verlangt der Endpunkt
   {"confirm":"RESEAL"} statt SEAL, mit einem Text, der sagt, was dabei
   verloren geht. Ein Austausch der Beweisgrundlage soll nicht dasselbe
   Wort haben wie das Einrichten.

Und im Gegenbuch selbst: dort stand fuer den Wurzelwechsel ein
console.warn, waehrend der Rueckgabecode auf 0 blieb - also exakt das
Muster, das wir dem CRM zweimal angekreidet haben (R179, R183-02), im
Werkzeug, das dagegen gebaut wurde. Jetzt exit 2, mit alter und neuer
Wurzel samt Blattzahl; "10 Blaetter -> 9 Blaetter" zeigt die Loeschung
sofort. Auch die Erstsiegelung meldet sich, statt stillschweigend
uebernommen zu werden.

Aufloesbar gemacht: der Alarm bricht ab, BEVOR angehaengt wird - ohne
Bestaetigungsweg haette auch ein legitimes Siegeln fuer immer alarmiert
(R183-03-Falle). Neu ist NOTARY_SEAL_ACK, bewusst nicht "true", sondern
die Wurzel selbst (mind. 16 Zeichen): ein stehen gelassener Wert passt
beim naechsten Wechsel nicht mehr und kann keinen weiteren Austausch
durchwinken.

R185-02 (LOW): GET /api/audit-logs?action=<x> gab ungueltige Enum-Werte
roh an die Spalte -> 500. Zweifach schlecht: fehlende Validierung und
Fehler-Orakel (200 vs 500 verraet die Enum-Mitglieder). Jetzt 400 mit
der erlaubten Menge im Klartext. Mitgenommen: sensitivity, Datumsfelder,
Zahlenfelder, Textlaengen und ein Deckel auf limit (200), ueber den sich
sonst die ganze Tabelle an der Seitenlogik vorbei ziehen liess. Beide
Endpunkte.

Getestet ueber HTTP gegen eine Wegwerf-DB, inkl. echtem Gegenbuch-Lauf
mit SSH-signiertem lokalem Repo. Zusaetzlich nachgeholt, was der Tester
nicht herstellen konnte: vollstaendig unsigniertes Protokoll ->
kein_siegel statt der frueheren falschen Entwarnung nicht_noetig, und
seal-backlog nennt den fehlenden Schluessel als naechsten Schritt.
Gegenrichtung geprueft, R183-03 bleibt behoben.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 09:41:11 +02:00

1545 lines
63 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
// ==================== PDF TEMPLATES (Auftragsvorlagen) ====================
model PdfTemplate {
id Int @id @default(autoincrement())
name String @unique // z.B. "EWE Auftragsformular"
description String? // Beschreibung
providerName String? // Zugehöriger Anbieter (z.B. "EWE")
templatePath String // Pfad zur PDF-Vorlage
originalName String // Originaler Dateiname
// Feld-Mapping: JSON-Objekt { pdfFieldName: crmFieldPath }
// z.B. { "Vorname": "customer.firstName", "PLZ": "customer.addresses[0].postalCode" }
fieldMapping String @db.LongText // JSON
// Rufnummern-Konfiguration
phoneFieldPrefix String? // Prefix für Rufnummern-Felder (z.B. "Rufnummer")
maxPhoneFields Int? @default(8) // Max. Rufnummern-Felder im PDF
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// ==================== EMAIL LOG ====================
model EmailLog {
id Int @id @default(autoincrement())
// Absender & Empfänger
fromAddress String // Absender-E-Mail
toAddress String // Empfänger-E-Mail
subject String // Betreff
// Versand-Kontext
context String // z.B. "consent-link", "authorization-request", "customer-email"
customerId Int? // Zugehöriger Kunde (falls vorhanden)
triggeredBy String? // Wer hat den Versand ausgelöst (User-Email)
// SMTP-Details
smtpServer String // SMTP-Server
smtpPort Int // SMTP-Port
smtpEncryption String // SSL, STARTTLS, NONE
smtpUser String // SMTP-Benutzername
// Ergebnis
success Boolean // Erfolgreich?
messageId String? // Message-ID aus SMTP-Antwort
errorMessage String? @db.Text // Fehlermeldung bei Fehler
smtpResponse String? @db.Text // SMTP-Server-Antwort
// Zeitstempel
sentAt DateTime @default(now())
@@index([sentAt])
@@index([customerId])
@@index([success])
}
// ==================== APP SETTINGS ====================
model AppSetting {
id Int @id @default(autoincrement())
key String @unique
value String @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// ==================== USERS & AUTH ====================
model User {
id Int @id @default(autoincrement())
email String @unique
password String
firstName String
lastName String
isActive Boolean @default(true)
/// Dienstkonto (z. B. das Gegenbuch). Meldet sich planmaessig und haeufig an.
/// Solche Anmeldungen werden im Audit-Log als Routine gefuehrt statt als
/// CRITICAL - sonst trainiert das vorhersagbarste Ereignis im System den
/// Betreiber darauf, die hoechste Stufe wegzuklicken (Pentest R182).
isServiceAccount Boolean @default(false)
tokenInvalidatedAt DateTime? // Zeitpunkt ab dem alle Tokens ungültig sind (für Zwangslogout bei Rechteänderung)
// Passwort-Reset
passwordResetToken String? @unique
passwordResetExpiresAt DateTime?
// Messaging-Kanäle (für Datenschutz-Link-Versand)
whatsappNumber String?
telegramUsername String?
signalNumber String?
customerId Int? @unique
customer Customer? @relation(fields: [customerId], references: [id])
roles UserRole[]
// Persönliche Anrede-Präferenz pro Kunde ("Du"/"Sie") User-Wunsch
// 2026-07-04. Fehlender Eintrag = Kunden-Default (Customer.useInformalAddress).
customerSalutations UserCustomerSalutation[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Role {
id Int @id @default(autoincrement())
name String @unique
description String?
permissions RolePermission[]
users UserRole[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Permission {
id Int @id @default(autoincrement())
resource String
action String
roles RolePermission[]
@@unique([resource, action])
}
model RolePermission {
roleId Int
permissionId Int
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade)
@@id([roleId, permissionId])
}
model UserRole {
userId Int
roleId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
@@id([userId, roleId])
}
// ==================== CUSTOMERS ====================
enum CustomerType {
PRIVATE
BUSINESS
}
model Customer {
id Int @id @default(autoincrement())
customerNumber String @unique
type CustomerType @default(PRIVATE)
salutation String?
firstName String
lastName String
companyName String?
foundingDate DateTime? // Gründungsdatum (für Firmen)
birthDate DateTime?
birthPlace String?
email String?
phone String?
mobile String?
taxNumber String?
// Kleinunternehmer nach §19 UStG bzw. sonst USt-befreit: dann wird der
// (Firmen-)Kunde bei Gutschriften wie ein Privatkunde behandelt (keine USt),
// bis er ggf. USt-pflichtig wird. Steuert nur die VORBELEGUNG neuer
// Gutschriften; bereits erstellte Gutschriften speichern ihren eigenen
// Snapshot und bleiben von einem späteren Wechsel unberührt.
vatExempt Boolean @default(false)
businessRegistrationPath String? // PDF-Pfad zur Gewerbeanmeldung
commercialRegisterPath String? // PDF-Pfad zum Handelsregisterauszug
commercialRegisterNumber String? // Handelsregisternummer (Text)
privacyPolicyPath String? // PDF-Pfad zur Datenschutzerklärung (für alle Kunden)
consentHash String? @unique // Hash für öffentlichen Einwilligungslink /datenschutz/<hash>
consentHashExpiresAt DateTime? // Pentest 57.7: TTL für Public-Consent-Link (30 Tage Default); nach Ablauf ist getCustomerByConsentHash null und der Link muss neu generiert werden.
notes String? @db.Text
// ===== Portal-Zugangsdaten =====
portalEnabled Boolean @default(false) // Portal aktiviert?
portalEmail String? @unique // Portal-Login E-Mail
portalPasswordHash String? // Gehashtes Passwort (für Login)
portalPasswordEncrypted String? // Verschlüsseltes Passwort (für Anzeige)
portalLastLogin DateTime? // Letzte Anmeldung
// Portal Passwort-Reset
portalPasswordResetToken String? @unique
portalPasswordResetExpiresAt DateTime?
// Portal Session-Invalidation (nach Passwort-Reset / Rechte-Änderung)
portalTokenInvalidatedAt DateTime?
// Einmalpasswort: gesetzt durch "Zugangsdaten versenden"-Button. Beim ersten
// erfolgreichen Login wird der Hash sofort gelöscht (OTP verbraucht) und
// Frontend in Force-Change-Password-Flow geleitet.
portalPasswordMustChange Boolean @default(false)
// Geburtstagsmodal: Jahr in dem dem Kunden der Geburtstagsgruß gezeigt wurde (vermeidet mehrfaches Anzeigen)
lastBirthdayGreetingYear Int?
// Anrede-Verhältnis: true = Du (informell), false = Sie (formell, Default)
useInformalAddress Boolean @default(false)
// Automatischer Geburtstagsgruß-Versand
autoBirthdayGreeting Boolean @default(false)
autoBirthdayChannel String? // "email", "whatsapp", "telegram", "signal"
user User?
addresses Address[]
bankCards BankCard[]
identityDocuments IdentityDocument[]
meters Meter[]
stressfreiEmails StressfreiEmail[]
contracts Contract[]
// Vertreter-Beziehungen (Kunde kann für andere Kunden handeln)
representingFor CustomerRepresentative[] @relation("RepresentativeCustomer")
representedBy CustomerRepresentative[] @relation("RepresentedCustomer")
// Vollmachten
authorizationsGiven RepresentativeAuthorization[] @relation("AuthorizationCustomer")
authorizationsReceived RepresentativeAuthorization[] @relation("AuthorizationRepresentative")
// DSGVO: Einwilligungen
consents CustomerConsent[]
// Persönliche Anrede-Präferenzen aller Mitarbeiter zu diesem Kunden.
userSalutations UserCustomerSalutation[]
// Werbung: wer hat wen an Board geholt.
// referralAsRecruited = dieser Kunde WURDE geworben (max. 1 Werber).
// referralsAsRecruiter = Kunden, die DIESER Kunde geworben hat (beliebig viele).
referralAsRecruited CustomerReferral? @relation("ReferralRecruited")
referralsAsRecruiter CustomerReferral[] @relation("ReferralRecruiter")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// Werbe-Beziehung zwischen zwei Kunden ("Geworben / angeworben").
// Ein Datensatz = ein Werber (recruiter) hat einen Kunden (recruited) an
// Board geholt, plus die Beziehungsart. `recruitedId` ist @unique, damit ein
// Kunde nur von EINER Person geworben sein kann (Abschnitt 1 in der UI).
// Ein Werber kann beliebig viele Kunden werben (Abschnitt 2).
model CustomerReferral {
id Int @id @default(autoincrement())
recruiterId Int
recruiter Customer @relation("ReferralRecruiter", fields: [recruiterId], references: [id], onDelete: Cascade)
recruitedId Int @unique
recruited Customer @relation("ReferralRecruited", fields: [recruitedId], references: [id], onDelete: Cascade)
relationship String // Ehepartner/in, Kind, Freund/Kumpel, …
createdAt DateTime @default(now())
createdBy String?
@@index([recruiterId])
}
// ==================== USER-CUSTOMER SALUTATION ====================
// Per-User-Präferenz, ob der eingeloggte Mitarbeiter mit dem jeweiligen
// Kunden per Du oder Sie ist. Fehlender Eintrag → fällt auf den
// Kunden-Default (Customer.useInformalAddress) zurück.
// User-Wunsch 2026-07-04: Stefan kann per Du mit einem Kunden sein,
// während andere Mitarbeiter denselben Kunden weiter siezen.
model UserCustomerSalutation {
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
customerId Int
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
// 'DU' oder 'SIE'. String statt Enum, damit man ohne Migration einen
// dritten Wert nachschieben könnte (z.B. 'MIXED' o.ä.).
preference String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@id([userId, customerId])
@@index([customerId])
}
// ==================== CUSTOMER REPRESENTATIVES ====================
// Vertretungsbeziehung: Ein Kunde kann die Verträge eines anderen Kunden einsehen
// z.B. Sohn (representativeId) kann Verträge der Mutter (customerId) sehen
model CustomerRepresentative {
id Int @id @default(autoincrement())
customerId Int // Der Kunde, dessen Verträge eingesehen werden (z.B. Mutter)
customer Customer @relation("RepresentedCustomer", fields: [customerId], references: [id], onDelete: Cascade)
representativeId Int // Der Kunde, der einsehen darf (z.B. Sohn)
representative Customer @relation("RepresentativeCustomer", fields: [representativeId], references: [id], onDelete: Cascade)
notes String? // Notizen zur Vertretung
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([customerId, representativeId]) // Keine doppelten Einträge
}
// ==================== VOLLMACHTEN ====================
// Vollmacht: Kunde B erteilt Kunde A die Vollmacht, seine Daten einzusehen
// Ohne Vollmacht kann der Vertreter die Verträge des Kunden NICHT sehen
model RepresentativeAuthorization {
id Int @id @default(autoincrement())
customerId Int // Der Kunde, der die Vollmacht erteilt (z.B. Mutter)
customer Customer @relation("AuthorizationCustomer", fields: [customerId], references: [id], onDelete: Cascade)
representativeId Int // Der Vertreter, der Zugriff bekommt (z.B. Sohn)
representative Customer @relation("AuthorizationRepresentative", fields: [representativeId], references: [id], onDelete: Cascade)
isGranted Boolean @default(false) // Vollmacht erteilt?
grantedAt DateTime? // Wann erteilt
withdrawnAt DateTime? // Wann widerrufen
source String? // Quelle: 'portal', 'papier', 'crm-backend'
documentPath String? // PDF-Upload der unterschriebenen Vollmacht
notes String? @db.Text // Notizen
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([customerId, representativeId]) // Eine Vollmacht pro Paar
}
// ==================== ADDRESSES ====================
enum AddressType {
DELIVERY_RESIDENCE
BILLING
}
model Address {
id Int @id @default(autoincrement())
customerId Int
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
type AddressType @default(DELIVERY_RESIDENCE)
street String
houseNumber String
postalCode String
city String
country String @default("Deutschland")
isDefault Boolean @default(false)
// Marktlokations-IDs (MaLo) je Sparte die MaLo gehört zur (Liefer-)Adresse,
// nicht zum Vertrag. Strom- und Gas-Marktlokation sind getrennt.
maloIdElectricity String? // MaLo-ID Strom
maloIdGas String? // MaLo-ID Gas
// Eigentümer (leer = Kunde ist selbst Eigentümer)
ownerCompany String?
ownerFirstName String?
ownerLastName String?
ownerStreet String?
ownerHouseNumber String?
ownerPostalCode String?
ownerCity String?
ownerPhone String?
ownerMobile String?
ownerEmail String?
contractsAsDelivery Contract[] @relation("DeliveryAddress")
contractsAsBilling Contract[] @relation("BillingAddress")
meters Meter[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// ==================== BANK CARDS ====================
model BankCard {
id Int @id @default(autoincrement())
customerId Int
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
accountHolder String
iban String
cardNumber String? // Karten-/Kontokartennummer (optional, z.B. Debitkarte)
bic String?
bankName String?
expiryDate DateTime?
documentPath String? // Pfad zur hochgeladenen PDF
description String? @db.Text // Freitext-Notiz (z.B. "Geschäftskonto", "gemeinsames Konto mit Partner")
isActive Boolean @default(true)
contracts Contract[]
creditNotePayouts CreditNote[] @relation("CreditNotePayoutBankCard")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// ==================== IDENTITY DOCUMENTS ====================
enum DocumentType {
ID_CARD
PASSPORT
DRIVERS_LICENSE
OTHER
}
model IdentityDocument {
id Int @id @default(autoincrement())
customerId Int
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
type DocumentType @default(ID_CARD)
documentNumber String
issuingAuthority String?
issueDate DateTime?
expiryDate DateTime?
documentPath String? // Pfad zur hochgeladenen PDF
isActive Boolean @default(true)
// Führerschein-spezifische Felder
licenseClasses String? // z.B. "B, BE, AM, L" - kommasepariert
licenseIssueDate DateTime? // Datum des Führerscheinerwerbs (Klasse B)
contracts Contract[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// ==================== EMAIL PROVIDER CONFIG (Plesk, cPanel etc.) ====================
enum EmailProviderType {
PLESK
CPANEL
DIRECTADMIN
}
// Verschlüsselungstyp für E-Mail-Verbindungen
enum MailEncryption {
SSL // Implicit SSL/TLS (Ports 465/993) - Verschlüsselung von Anfang an
STARTTLS // STARTTLS (Ports 587/143) - Startet unverschlüsselt, dann Upgrade
NONE // Keine Verschlüsselung (Ports 25/143)
}
model EmailProviderConfig {
id Int @id @default(autoincrement())
name String @unique // z.B. "Plesk Hauptserver"
type EmailProviderType
apiUrl String // API-URL (z.B. https://server.de:8443)
apiKey String? // API-Key (verschlüsselt)
username String? // Benutzername für API
passwordEncrypted String? // Passwort (verschlüsselt)
domain String // Domain für E-Mails (z.B. stressfrei-wechseln.de)
defaultForwardEmail String? // Standard-Weiterleitungsadresse (unsere eigene)
// IMAP/SMTP-Server für E-Mail-Client (optional, default: mail.{domain})
imapServer String? // z.B. "mail.stressfrei-wechseln.de"
imapPort Int? @default(993)
smtpServer String?
smtpPort Int? @default(465)
// Verschlüsselungs-Einstellungen
imapEncryption MailEncryption @default(SSL) // SSL, STARTTLS oder NONE
smtpEncryption MailEncryption @default(SSL) // SSL, STARTTLS oder NONE
allowSelfSignedCerts Boolean @default(false) // Selbstsignierte Zertifikate erlauben
// System-E-Mail für automatisierte Nachrichten (z.B. DSGVO Consent-Links)
systemEmailAddress String? // z.B. "info@stressfrei-wechseln.de"
systemEmailPasswordEncrypted String? // Passwort (verschlüsselt)
// Label für Kunden-E-Mail-Adressen in der UI (z.B. "Stressfrei-Wechseln")
// Wenn leer, wird automatisch aus der Domain abgeleitet (z.B. "stressfrei-wechseln.de" → "Stressfrei-Wechseln")
customerEmailLabel String?
isActive Boolean @default(true)
isDefault Boolean @default(false) // Standard-Provider
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// ==================== STRESSFREI-WECHSELN EMAIL ADDRESSES ====================
model StressfreiEmail {
id Int @id @default(autoincrement())
customerId Int
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
email String // Die Weiterleitungs-E-Mail-Adresse
platform String? // Für welche Plattform (z.B. "Freenet", "Klarmobil")
notes String? @db.Text // Optionale Notizen
isActive Boolean @default(true)
isProvisioned Boolean @default(false) // Wurde bei Provider angelegt?
provisionedAt DateTime? // Wann wurde provisioniert?
provisionError String? @db.Text // Fehlermeldung falls Provisionierung fehlschlug
// Mailbox-Zugangsdaten (für IMAP/SMTP-Zugang)
hasMailbox Boolean @default(false) // Hat echte Mailbox (nicht nur Weiterleitung)?
emailPasswordEncrypted String? // Verschlüsseltes Mailbox-Passwort (AES-256-GCM)
// Zusätzliche Weiterleitungsziele (über die Stamm-E-Mail des Kunden
// hinaus). Wird beim Sync zusammen mit customer.email +
// config.defaultForwardEmail an den Provider geschickt. JSON-Array
// von Strings, z.B. `["info@partner.de","cc@kanzlei.de"]`.
additionalForwardingEmails String? @db.Text
contracts Contract[] // Verträge die diese E-Mail als Benutzername verwenden
cachedEmails CachedEmail[] // Gecachte E-Mails aus dieser Mailbox
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// ==================== CACHED EMAILS (E-Mail-Client) ====================
enum EmailFolder {
INBOX
SENT
SPAM
}
model CachedEmail {
id Int @id @default(autoincrement())
stressfreiEmailId Int
stressfreiEmail StressfreiEmail @relation(fields: [stressfreiEmailId], references: [id], onDelete: Cascade)
// Ordner (Posteingang oder Gesendet)
folder EmailFolder @default(INBOX)
// IMAP-Identifikation
messageId String // RFC 5322 Message-ID
uid Int // IMAP UID (für Synchronisierung, bei SENT = 0)
// E-Mail-Metadaten
subject String?
fromAddress String
fromName String?
toAddresses String @db.Text // JSON Array
ccAddresses String? @db.Text // JSON Array
receivedAt DateTime
// Inhalt
textBody String? @db.LongText
htmlBody String? @db.LongText
hasAttachments Boolean @default(false)
attachmentNames String? @db.Text // JSON Array
// Vertragszuordnung
contractId Int?
contract Contract? @relation(fields: [contractId], references: [id], onDelete: SetNull)
assignedAt DateTime?
assignedBy Int? // User ID der die Zuordnung gemacht hat
isAutoAssigned Boolean @default(false) // true = automatisch beim Senden aus Vertrag
// Flags
isRead Boolean @default(false)
isStarred Boolean @default(false)
// Papierkorb
isDeleted Boolean @default(false) // Im Papierkorb?
deletedAt DateTime? // Wann gelöscht?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([stressfreiEmailId, messageId, folder]) // Folder hinzugefügt: gleiche MessageID kann in INBOX und SENT existieren
@@index([contractId])
@@index([stressfreiEmailId, folder, receivedAt])
@@index([stressfreiEmailId, isDeleted]) // Für Papierkorb-Abfragen
}
// ==================== METERS (Energy) ====================
enum MeterType {
ELECTRICITY
GAS
}
enum MeterTariffModel {
SINGLE // Eintarifzähler (Standard)
DUAL // Zweitarifzähler (HT/NT)
}
model Meter {
id Int @id @default(autoincrement())
customerId Int
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
// Lieferadresse, an der der Zähler hängt. Optional, weil Bestandszähler
// vor dem Feature noch keine Adresse haben (werden manuell nachgepflegt).
addressId Int?
address Address? @relation(fields: [addressId], references: [id], onDelete: SetNull)
meterNumber String
type MeterType
tariffModel MeterTariffModel @default(SINGLE) // Eintarif oder Zweitarif (HT/NT)
location String?
isActive Boolean @default(true)
// Folgezähler-Kette: zeigt auf den Vorgänger, den dieser Zähler abgelöst hat.
// Wird beim Anlegen als Folgezähler gesetzt; informational + zum Anzeigen
// der Kette. Auto-Propagation auf Verträge passiert beim Create.
predecessorMeterId Int?
predecessor Meter? @relation("MeterSuccessor", fields: [predecessorMeterId], references: [id], onDelete: SetNull)
successors Meter[] @relation("MeterSuccessor")
readings MeterReading[]
energyDetails EnergyContractDetails[]
contractMeters ContractMeter[] @relation("ContractMeters")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model MeterReading {
id Int @id @default(autoincrement())
meterId Int
meter Meter @relation(fields: [meterId], references: [id], onDelete: Cascade)
readingDate DateTime
value Float // Bei Eintarif: Gesamtwert. Bei Zweitarif: HT-Wert
valueNt Float? // Nur bei Zweitarif: NT-Wert (Niedertarif)
unit String @default("kWh")
notes String?
// Meldung & Übertragung
reportedBy String? // Wer hat gemeldet? (E-Mail des Portal-Kunden oder Mitarbeiter)
status MeterReadingStatus @default(RECORDED)
transferredAt DateTime? // Wann wurde der Stand an den Anbieter übertragen?
transferredBy String? // Wer hat übertragen?
createdAt DateTime @default(now())
}
enum MeterReadingStatus {
RECORDED // Erfasst (vom Mitarbeiter)
REPORTED // Vom Kunden gemeldet (Portal)
TRANSFERRED // An Anbieter übertragen
}
// ==================== SALES PLATFORMS ====================
model SalesPlatform {
id Int @id @default(autoincrement())
name String @unique
contactInfo String? @db.Text
isActive Boolean @default(true)
contracts Contract[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// ==================== CANCELLATION PERIODS ====================
model CancellationPeriod {
id Int @id @default(autoincrement())
code String @unique // z.B. "14T", "1M", "3M", "12M", "1J"
description String // z.B. "14 Tage", "1 Monat", "3 Monate"
isActive Boolean @default(true)
contracts Contract[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// ==================== CONTRACT DURATIONS ====================
model ContractDuration {
id Int @id @default(autoincrement())
code String @unique // z.B. "12M", "24M", "1J", "2J"
description String // z.B. "12 Monate", "24 Monate", "1 Jahr"
isActive Boolean @default(true)
contracts Contract[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// ==================== PROVIDERS (Anbieter) ====================
model Provider {
id Int @id @default(autoincrement())
name String @unique // Anbietername
portalUrl String? // Kundenkontourl (Login-Seite)
usernameFieldName String? // Benutzernamefeld (z.B. "email", "username")
passwordFieldName String? // Kennwortfeld (z.B. "password", "pwd")
// Kontaktdaten beim Anbieter (für CRM-Mitarbeiter zum Nachschlagen)
contactEmail String? // Allgemeine Kontakt-Emailadresse
contactPhone String? // Kontakt-Telefonnummer
contactFax String? // Kontakt-Faxnummer
contactAddress String? @db.Text // Kontakt-Postadresse (mehrzeilig)
// Dedizierte Kündigungs-Endpunkte (wenn separat vom allgemeinen Kontakt)
cancellationEmail String? // Kündigungs-Emailadresse
cancellationFax String? // Kündigungs-Faxnummer
cancellationAddress String? @db.Text // Kündigungs-Postadresse (mehrzeilig)
isActive Boolean @default(true)
tariffs Tariff[]
contracts Contract[]
previousContracts Contract[] @relation("PreviousProvider") // Verträge wo dieser Provider Altanbieter ist
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// ==================== TARIFFS (Tarife) ====================
model Tariff {
id Int @id @default(autoincrement())
providerId Int
provider Provider @relation(fields: [providerId], references: [id], onDelete: Cascade)
name String // Tarifname
isActive Boolean @default(true)
contracts Contract[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([providerId, name]) // Eindeutiger Tarif pro Anbieter
}
// ==================== CONTRACT CATEGORIES ====================
model ContractCategory {
id Int @id @default(autoincrement())
code String @unique // Technischer Code (z.B. ELECTRICITY, GAS)
name String // Anzeigename (z.B. Strom, Gas)
icon String? // Icon-Name für UI (z.B. "Zap", "Flame")
color String? // Farbe für UI (z.B. "#FFC107")
sortOrder Int @default(0)
isActive Boolean @default(true)
contracts Contract[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// ==================== CONTRACTS ====================
// Legacy Enum - wird durch ContractCategory ersetzt
enum ContractType {
ELECTRICITY
GAS
DSL
CABLE
FIBER
MOBILE
TV
CAR_INSURANCE
}
enum ContractStatus {
DRAFT
PENDING
ACTIVE
// Kündigungsschreiben raus, Bestätigung des Anbieters steht noch aus.
CANCELLED
// Kündigung vom Anbieter bestätigt (Bestätigungsdatum/-dokument liegt vor).
CANCELLED_CONFIRMED
EXPIRED
DEACTIVATED
// "Läuft weiter auf unbestimmte Zeit" Vertrag ist unbefristet aktiv.
// Cockpit-Fristen (Kündigungsvorlauf, Verlängerungs-Reminder etc.)
// ignorieren diesen Status, weil es kein Ende gibt, das getriggered
// werden könnte.
ONGOING
}
model Contract {
id Int @id @default(autoincrement())
contractNumber String @unique
customerId Int
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
type ContractType
status ContractStatus @default(DRAFT)
// Neue konfigurierbare Kategorie (ersetzt langfristig das type-Enum)
contractCategoryId Int?
contractCategory ContractCategory? @relation(fields: [contractCategoryId], references: [id])
// Lieferadresse
addressId Int?
address Address? @relation("DeliveryAddress", fields: [addressId], references: [id])
// Rechnungsadresse (falls leer, wird Lieferadresse verwendet)
billingAddressId Int?
billingAddress Address? @relation("BillingAddress", fields: [billingAddressId], references: [id])
bankCardId Int?
bankCard BankCard? @relation(fields: [bankCardId], references: [id])
identityDocumentId Int?
identityDocument IdentityDocument? @relation(fields: [identityDocumentId], references: [id])
salesPlatformId Int?
salesPlatform SalesPlatform? @relation(fields: [salesPlatformId], references: [id])
cancellationPeriodId Int?
cancellationPeriod CancellationPeriod? @relation(fields: [cancellationPeriodId], references: [id])
contractDurationId Int?
contractDuration ContractDuration? @relation(fields: [contractDurationId], references: [id])
previousContractId Int? @unique
previousContract Contract? @relation("ContractHistory", fields: [previousContractId], references: [id])
followUpContract Contract? @relation("ContractHistory")
// Altanbieter-Daten (nur wenn kein Vorgängervertrag existiert)
previousProviderId Int?
previousProvider Provider? @relation("PreviousProvider", fields: [previousProviderId], references: [id])
previousCustomerNumber String? // Kundennummer beim Altanbieter
previousContractNumber String? // Vertragsnummer beim Altanbieter
// Anbieter & Tarif (neue Verknüpfung)
providerId Int?
provider Provider? @relation(fields: [providerId], references: [id])
tariffId Int?
tariff Tariff? @relation(fields: [tariffId], references: [id])
// Legacy-Felder (für Abwärtskompatibilität)
providerName String?
tariffName String?
customerNumberAtProvider String?
contractNumberAtProvider String? // Vertragsnummer beim Anbieter
orderNumberAtSalesPlatform String? // Auftragsnummer bei der Vertriebsplattform
customerNumberAtSalesPlatform String? // Kundennummer bei der Vertriebsplattform
contractNumberAtSalesPlatform String? // Vertragsnummer bei der Vertriebsplattform
priceFirst12Months String? // Preis erste 12 Monate
priceFrom13Months String? // Preis ab 13. Monat
priceAfter24Months String? // Preis nach 24 Monaten
startDate DateTime?
endDate DateTime? // Wird aus startDate + contractDuration berechnet
commission Float?
// Kündigungsdokumente
cancellationLetterPath String? // Kündigungsschreiben PDF
cancellationConfirmationPath String? // Kündigungsbestätigung PDF
cancellationLetterOptionsPath String? // Kündigungsschreiben Optionen PDF
cancellationConfirmationOptionsPath String? // Kündigungsbestätigung Optionen PDF
// Kündigungsdaten
cancellationConfirmationDate DateTime? // Kündigungsbestätigungsdatum
cancellationConfirmationOptionsDate DateTime? // Kündigungsbestätigungsoptionendatum
wasSpecialCancellation Boolean @default(false) // Wurde sondergekündigt?
portalUsername String?
portalPasswordEncrypted String?
// Stressfrei-Wechseln E-Mail als Benutzername (Alternative zu portalUsername)
stressfreiEmailId Int?
stressfreiEmail StressfreiEmail? @relation(fields: [stressfreiEmailId], references: [id])
// Portaldaten bewusst nicht hinterlegt (Anbieter hat kein Portal oder
// Kunde bekommt keine Zugangsdaten). Wenn true, unterdrückt das Cockpit
// die „Portal-Zugangsdaten fehlen"-Warnung für diesen Vertrag.
portalCredentialsNotRequired Boolean @default(false)
// Snooze: Vertrag zurückstellen bis Datum (für Cockpit)
nextReviewDate DateTime? // Erneute Prüfung am
notes String? @db.Text
energyDetails EnergyContractDetails?
internetDetails InternetContractDetails?
mobileDetails MobileContractDetails?
tvDetails TvContractDetails?
carInsuranceDetails CarInsuranceDetails?
tasks ContractTask[]
assignedEmails CachedEmail[] // Zugeordnete E-Mails aus dem E-Mail-Client
historyEntries ContractHistoryEntry[]
documents ContractDocument[]
invoices Invoice[]
creditNotes CreditNote[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// ==================== CONTRACT DOCUMENTS ====================
model ContractDocument {
id Int @id @default(autoincrement())
contractId Int
contract Contract @relation(fields: [contractId], references: [id], onDelete: Cascade)
documentType String // Auftragsformular, Lieferbestätigung, etc.
documentPath String // Dateipfad
originalName String // Originaler Dateiname
notes String? @db.Text
uploadedBy String? // Wer hat hochgeladen
createdAt DateTime @default(now())
@@index([contractId])
}
// ==================== CONTRACT HISTORY ====================
model ContractHistoryEntry {
id Int @id @default(autoincrement())
contractId Int
contract Contract @relation(fields: [contractId], references: [id], onDelete: Cascade)
title String // Kurzbeschreibung (z.B. "Folgevertrag erstellt", "kWh auf 18000 erhöht")
description String? @db.Text // Längere Beschreibung (optional)
isAutomatic Boolean @default(false) // true = automatisch erstellt, false = manuell
createdBy String // E-Mail des Erstellers
createdAt DateTime @default(now())
}
// ==================== CONTRACT TASKS ====================
enum ContractTaskStatus {
OPEN
COMPLETED
}
model ContractTask {
id Int @id @default(autoincrement())
// Nullable: Aufgaben können auch OHNE Vertrag/Kunde angelegt werden
// (allgemeine interne Aufgabe). Ohne Vertrag → kein Kunde → nie im Portal.
contractId Int?
contract Contract? @relation(fields: [contractId], references: [id], onDelete: Cascade)
title String
description String? @db.Text
status ContractTaskStatus @default(OPEN)
visibleInPortal Boolean @default(false)
createdBy String? // Name des Erstellers
completedAt DateTime?
subtasks ContractTaskSubtask[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model ContractTaskSubtask {
id Int @id @default(autoincrement())
taskId Int
task ContractTask @relation(fields: [taskId], references: [id], onDelete: Cascade)
title String
status ContractTaskStatus @default(OPEN)
createdBy String?
completedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// ==================== ENERGY CONTRACT DETAILS ====================
enum InvoiceType {
INTERIM // Zwischenrechnung
FINAL // Schlussrechnung
NOT_AVAILABLE // Rechnung nicht mehr zu bekommen
}
model EnergyContractDetails {
id Int @id @default(autoincrement())
contractId Int @unique
contract Contract @relation(fields: [contractId], references: [id], onDelete: Cascade)
meterId Int?
meter Meter? @relation(fields: [meterId], references: [id])
maloId String? // Marktlokations-ID
annualConsumption Float? // kWh für Strom, m³ für Gas
annualConsumptionKwh Float? // kWh für Gas (zusätzlich zu m³)
basePrice Float? // €/Monat
unitPrice Float? // €/kWh (Arbeitspreis) - bei HT/NT: HT-Preis
unitPriceNt Float? // €/kWh NT-Preis (nur bei Zweitarifzähler)
// Bonus wurde 2026-05-24 in zwei Felder aufgeteilt: Sofort-Bonus
// (Auszahlung kurz nach Wechsel) + Neukunden-Bonus (Auszahlung am
// Vertragsende / nach 12 Monaten). Beide werden im Detail als
// Gesamtbonus aufsummiert und in der Kostenvorschau einzeln
// dargestellt. Migration kopiert das alte `bonus` nach
// `instantBonus` (Annahme: meistgenutzte Variante).
instantBonus Float? // Sofort-Bonus
newCustomerBonus Float? // Neukunden-Bonus
noBonusDesired Boolean @default(false) // Kunde wünscht keine Bonis
previousProviderName String?
previousCustomerNumber String?
invoices Invoice[] // Rechnungen
contractMeters ContractMeter[] // Zähler-Zuordnungen (inkl. Folgezähler)
}
model ContractMeter {
id Int @id @default(autoincrement())
energyContractDetailsId Int
energyContractDetails EnergyContractDetails @relation(fields: [energyContractDetailsId], references: [id], onDelete: Cascade)
meterId Int
meter Meter @relation("ContractMeters", fields: [meterId], references: [id])
position Int @default(0) // 0 = Original, 1 = erster Folgezähler, etc.
installedAt DateTime? // Ab wann wird dieser Zähler am Vertrag genutzt?
removedAt DateTime? // Wann wurde der Zähler gewechselt? (null = aktuell)
finalReading Float? // Letzter Stand vor dem Wechsel
createdAt DateTime @default(now())
@@unique([energyContractDetailsId, meterId])
@@index([energyContractDetailsId])
}
model Invoice {
id Int @id @default(autoincrement())
energyContractDetailsId Int?
energyContractDetails EnergyContractDetails? @relation(fields: [energyContractDetailsId], references: [id], onDelete: Cascade)
contractId Int?
contract Contract? @relation(fields: [contractId], references: [id], onDelete: Cascade)
invoiceDate DateTime
invoiceType InvoiceType
documentPath String? // Pflicht, außer bei NOT_AVAILABLE
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([energyContractDetailsId])
@@index([contractId])
}
// ==================== GUTSCHRIFTEN (CREDIT NOTES / SUBVENTIONEN) ====================
// Art der Gutschrift: Geldbetrag (per Überweisung, in EUR) oder Sachwert
// (Smartphone, Elektrogerät …, näher im Textfeld beschrieben).
enum CreditNoteType {
GELD
SACHWERT
}
// Kundentyp zum Zeitpunkt der Gutschrift bestimmt die USt-Sicht
// (Privat rechnet brutto, Firma zieht Vorsteuer). Wird aus dem Kunden
// vorbelegt, ist aber pro Gutschrift überschreibbar.
enum CreditNoteCustomerType {
PRIVAT
FIRMA
}
// Basis, auf die sich der eingegebene Betrag bezieht (nur relevant, wenn
// vatRelevant = true). Bei NETTO wird die USt aufgeschlagen, bei BRUTTO
// herausgerechnet.
enum CreditNoteAmountBasis {
NETTO
BRUTTO
}
model CreditNote {
id Int @id @default(autoincrement())
contractId Int
contract Contract @relation(fields: [contractId], references: [id], onDelete: Cascade)
// Gutschrift-Nummer aus dem Gutschrift-Nummernkreis (z.B. GS-2026-0001).
// NULL bei betragslosen Sachwert-Übergaben (= Lieferschein, s.u.).
number String? @unique
// Lieferschein-/Subventionsnummer aus dem separaten Lieferschein-
// Nummernkreis (z.B. LS-2026-0001) nur bei betragslosen Sachwerten.
// Beide Nummern werden lazy pro Serie vergeben und NIE wieder freigegeben
// (keine Lücken); angezeigt wird je nach aktuellem Typ die passende.
deliveryNoteNumber String? @unique
type CreditNoteType
// Pflicht bei SACHWERT: was wird gewährt (z.B. "Smartphone Samsung A55").
sachwertDescription String?
customerType CreditNoteCustomerType
// USt-relevant ja/nein (Steuerberater-Auslegung: Vermittlungsleistung vs.
// reiner Abschlussbonus/Entgeltminderung). Bei false: net = brutto, USt = 0.
vatRelevant Boolean @default(false)
amountBasis CreditNoteAmountBasis @default(BRUTTO)
vatRate Float @default(19) // Prozent
amountNet Float @default(0)
amountVat Float @default(0)
amountGross Float @default(0)
currency String @default("EUR")
// Datum der Gutschrift (frei änderbar).
creditDate DateTime
// Ort der Unterschrift + Unterschrift-/Bestätigungszeitpunkt.
place String?
signedAt DateTime?
// Sachwert: Bestätigung "Ware erhalten".
goodsReceived Boolean @default(false)
// Auszahlungskonto (nur GELD): Bankkarte des Kunden, auf die überwiesen
// wird. Kann ein anderes Konto sein als das Abbuchkonto des Vertrags.
// ON DELETE SET NULL, damit das Löschen einer Bankkarte die Gutschrift
// (historischer Beleg) nicht mitreißt.
payoutBankCardId Int?
payoutBankCard BankCard? @relation("CreditNotePayoutBankCard", fields: [payoutBankCardId], references: [id], onDelete: SetNull)
// Überweisungsbeleg (nur GELD) hochgeladene Datei.
receiptPath String?
// Generiertes Gutschrift-Dokument (PDF bzw. ZUGFeRD-Hybrid) Phase 3.
pdfPath String?
notes String?
createdBy String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([contractId])
}
// Absender-/Firmenstammdaten (Einzel-Zeile). Fließen in Gutschrift-PDF und
// ZUGFeRD-XML (Verkäufer/Seller-Party) sowie in die Bankangaben ein.
model CompanyProfile {
id Int @id @default(autoincrement())
name String @default("")
street String @default("")
houseNumber String @default("")
postalCode String @default("")
city String @default("")
country String @default("DE") // ISO-2, für ZUGFeRD countryID
vatId String @default("") // USt-IdNr (DE...)
taxNumber String @default("") // Steuernummer
commercialRegister String @default("") // z.B. HRB 12345, Amtsgericht
managingDirector String @default("") // Geschäftsführer/Inhaber
email String @default("")
phone String @default("")
website String @default("")
iban String @default("")
bic String @default("")
bankName String @default("")
logoPath String?
updatedAt DateTime @updatedAt
}
// Nummernkreis für Gutschriften (Einzel-Zeile, in den Einstellungen
// verwaltbar). Nummer wird transaktional vergeben, damit keine Lücken/
// Doppelvergaben entstehen.
model CreditNoteNumberRange {
id Int @id @default(autoincrement())
prefix String @default("GS-")
includeYear Boolean @default(true)
separator String @default("-")
padding Int @default(4)
nextNumber Int @default(1)
resetYearly Boolean @default(true)
currentYear Int?
updatedAt DateTime @updatedAt
}
// Separater Nummernkreis für Lieferscheine (betragslose Sachwert-Übergaben).
// Eigene Serie, damit die Gutschrift-Nummerierung sauber/lückenlos bleibt.
model DeliveryNoteNumberRange {
id Int @id @default(autoincrement())
prefix String @default("LS-")
includeYear Boolean @default(true)
separator String @default("-")
padding Int @default(4)
nextNumber Int @default(1)
resetYearly Boolean @default(true)
currentYear Int?
updatedAt DateTime @updatedAt
}
// ==================== INTERNET CONTRACT DETAILS ====================
model InternetContractDetails {
id Int @id @default(autoincrement())
contractId Int @unique
contract Contract @relation(fields: [contractId], references: [id], onDelete: Cascade)
downloadSpeed Int?
uploadSpeed Int?
routerModel String?
routerSerialNumber String?
installationDate DateTime?
// Internet-Zugangsdaten
internetUsername String?
internetPasswordEncrypted String? // Verschlüsselt gespeichert
// Objekt & Lage
propertyType String? // Objekttyp (Mehrparteienhaus, Freistehendes Haus, etc.)
propertyLocation String? // Lage (Erdgeschoss, OG1, etc.)
connectionLocation String? // Lage des Anschlusses (Flur, HWR, etc.)
// Glasfaser-spezifisch
homeId String?
// Vodafone DSL/Kabel spezifisch
activationCode String?
phoneNumbers PhoneNumber[]
}
model PhoneNumber {
id Int @id @default(autoincrement())
internetContractDetailsId Int
internetDetails InternetContractDetails @relation(fields: [internetContractDetailsId], references: [id], onDelete: Cascade)
// phoneNumber bleibt die komplette Nummer (Vorwahl + Anschluss) für
// bestehende Reports/Listen. Vorwahl wird zusätzlich getrennt erfasst,
// damit Auftragsformulare (Wechselauftrag PDF) sie verlässlich
// befüllen können ohne heuristischen String-Split.
phoneNumber String
areaCode String?
isMain Boolean @default(false)
// SIP-Zugangsdaten
sipUsername String?
sipPasswordEncrypted String? // Verschlüsselt gespeichert
sipServer String?
}
// ==================== MOBILE CONTRACT DETAILS ====================
model MobileContractDetails {
id Int @id @default(autoincrement())
contractId Int @unique
contract Contract @relation(fields: [contractId], references: [id], onDelete: Cascade)
requiresMultisim Boolean @default(false) // Multisim erforderlich?
// Physisches Mobilfunknetz, auf dem der Tarif läuft: TELEKOM | VODAFONE
// | TELEFONICA (null = nicht gewählt). Als String statt Enum gehalten,
// damit weitere Netze ohne Migration ergänzbar sind.
mobileNetwork String?
dataVolume Float?
includedMinutes Int?
includedSMS Int?
deviceModel String?
deviceImei String?
simCards SimCard[]
// Legacy-Felder (für Abwärtskompatibilität, werden durch simCards ersetzt)
phoneNumber String?
simCardNumber String?
}
model SimCard {
id Int @id @default(autoincrement())
mobileDetailsId Int
mobileDetails MobileContractDetails @relation(fields: [mobileDetailsId], references: [id], onDelete: Cascade)
phoneNumber String? // Rufnummer
simCardNumber String? // SIM-Kartennummer
pin String? // PIN (verschlüsselt gespeichert)
puk String? // PUK (verschlüsselt gespeichert)
isMultisim Boolean @default(false) // Ist dies eine Multisim-Karte?
isMain Boolean @default(false) // Ist dies die Hauptkarte?
isEsim Boolean @default(false) // Ist dies eine eSIM?
// Tatsächlicher Nutzer der SIM-Karte (z.B. Mitarbeiter bei Firmenverträgen,
// Kind bei Eltern-Vertrag) kann vom Vertragsinhaber abweichen.
cardUser String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// ==================== TV CONTRACT DETAILS ====================
model TvContractDetails {
id Int @id @default(autoincrement())
contractId Int @unique
contract Contract @relation(fields: [contractId], references: [id], onDelete: Cascade)
receiverModel String?
smartcardNumber String?
package String?
}
// ==================== CAR INSURANCE DETAILS ====================
enum InsuranceType {
LIABILITY
PARTIAL
FULL
}
model CarInsuranceDetails {
id Int @id @default(autoincrement())
contractId Int @unique
contract Contract @relation(fields: [contractId], references: [id], onDelete: Cascade)
licensePlate String?
hsn String?
tsn String?
vin String?
vehicleType String?
firstRegistration DateTime?
noClaimsClass String?
insuranceType InsuranceType @default(LIABILITY)
deductiblePartial Float?
deductibleFull Float?
policyNumber String?
previousInsurer String?
}
// ==================== AUDIT LOGGING (DSGVO) ====================
/// Einzeiliger Mutex fuer die Audit-Hash-Kette (genau eine Zeile, id = 1).
///
/// Warum eine eigene Tabelle statt GET_LOCK oder `SELECT … FOR UPDATE` auf
/// AuditLog selbst:
/// - GET_LOCK muss auf derselben Verbindung freigegeben werden. Innerhalb des
/// Prisma-Transaktions-Callbacks faellt das Release damit VOR den COMMIT
/// der naechste Schreiber liest das Kettenende, bevor die Vorgaengerzeile
/// sichtbar ist, und haengt sich an denselben Vorgaenger (Fork, R166-01).
/// - `FOR UPDATE` auf das Kettenende von AuditLog nimmt Gap-/Next-Key-Locks,
/// die mit den gleichzeitigen INSERTs kollidieren (Deadlocks, dabei gingen
/// 38 von 40 Eintraegen verloren).
/// InnoDB-Zeilensperren werden erst beim COMMIT freigegeben genau das
/// schliesst das Fenster.
model AuditChainLock {
id Int @id
updatedAt DateTime @updatedAt
}
/// Ausgegebene Refresh-Tokens (Pentest R164-02).
///
/// Vorher war die Rotation wirkungslos: Der alte Token blieb bis `exp`
/// gueltig, ein gestohlener Token also bis zu 7 Tage parallel nutzbar.
/// Jetzt traegt jeder Refresh-Token eine `jti` und gehoert zu einer
/// Sitzungs-`familyId`. Beim Einloesen wird die `jti` verbraucht; taucht sie
/// danach erneut auf, gilt das als Replay und die GESAMTE Familie wird
/// widerrufen (Angreifer und legitimer Nutzer fliegen raus, der Vorfall wird
/// gemeldet) - das uebliche Vorgehen aus der OAuth-Sicherheits-BCP.
///
/// Der Token selbst wird NICHT gespeichert - die Signatur authentifiziert ihn
/// bereits, und ein DB-Leck soll keine nutzbaren Sitzungen preisgeben.
model RefreshTokenRecord {
id Int @id @default(autoincrement())
jti String @unique
familyId String
userId Int?
customerId Int?
isCustomerPortal Boolean @default(false)
issuedAt DateTime @default(now())
expiresAt DateTime
/// Gesetzt, sobald der Token eingeloest wurde (Einmalverwendung).
usedAt DateTime?
replacedByJti String?
/// Wiederverwendungen innerhalb des Kulanzfensters (parallele Tabs).
reuseCount Int @default(0)
revokedAt DateTime?
revokedReason String?
@@index([familyId])
@@index([expiresAt])
@@index([userId])
@@index([customerId])
}
/// Blattwerte des Bestandssiegels (Pentest R171-02 / R172).
///
/// Der Altbestand (Hash-Version 1/2) laesst sich nicht rueckwirkend signieren
/// ein Siegel vergibt man beim Anlegen. Bei V1 sind nur 7 von 24 Spalten
/// gehasht, ein DB-Schreiber kann dort z. B. `success` von false auf true
/// setzen, ohne den Hash zu beruehren: die Pruefung meldet weiterhin "gueltig".
///
/// Das Bestandssiegel schliesst das, ohne irgendetwas umzuschreiben: Zum
/// Zeitpunkt X wird je Altzeile ein Blattwert ueber den VOLLEN Inhalt
/// (alle Spalten inkl. id und hashVersion) gebildet; die Wurzel darueber
/// steht in einem HMAC-signierten Marker. Jede spaetere Aenderung an einer
/// Altzeile bricht Blatt und Wurzel.
model AuditBacklogSeal {
auditLogId Int @id
leafHash String
}
enum AuditAction {
CREATE
READ
UPDATE
DELETE
EXPORT // DSGVO-Datenexport
ANONYMIZE // Recht auf Vergessenwerden
LOGIN
LOGOUT
LOGIN_FAILED
TOKEN_REFRESH // stiller Access-Token-Refresh (Cookie), kein interaktiver Login
}
enum AuditSensitivity {
LOW // Einstellungen, Plattformen
MEDIUM // Verträge, Tarife
HIGH // Kundendaten, Bankdaten
CRITICAL // Authentifizierung, Ausweisdokumente
}
model AuditLog {
id Int @id @default(autoincrement())
// Wer
userId Int? // Staff User (null bei Kundenportal/System)
userEmail String
userRole String? @db.Text // Rolle zum Zeitpunkt der Aktion
customerId Int? // Bei Kundenportal-Zugriff
isCustomerPortal Boolean @default(false)
// Was
action AuditAction
sensitivity AuditSensitivity @default(MEDIUM)
// Welche Ressource
resourceType String // Prisma Model Name
resourceId String? // ID des Datensatzes
resourceLabel String? // Lesbare Bezeichnung
// Kontext
endpoint String // API-Pfad
httpMethod String // GET, POST, PUT, DELETE
ipAddress String
userAgent String? @db.Text
// Änderungen (JSON, bei sensiblen Daten verschlüsselt)
changesBefore String? @db.LongText
changesAfter String? @db.LongText
changesEncrypted Boolean @default(false)
// DSGVO
dataSubjectId Int? // Betroffene Person (für Reports)
legalBasis String? // Rechtsgrundlage
// Status
success Boolean @default(true)
errorMessage String? @db.Text
durationMs Int?
// Unveränderlichkeit (Hash-Kette)
createdAt DateTime @default(now())
hash String? // SHA-256 Hash des Eintrags
previousHash String? // Hash des vorherigen Eintrags
/// 1 = Alt-Hash ueber 7 Felder, 2 = Hash ueber alle Inhaltsspalten
/// (Pentest R166-02). Bestandszeilen bleiben mit Version 1 gueltig.
hashVersion Int @default(1)
@@index([userId])
@@index([customerId])
@@index([resourceType, resourceId])
@@index([dataSubjectId])
@@index([action])
@@index([createdAt])
@@index([sensitivity])
}
// ==================== CONSENT MANAGEMENT (DSGVO) ====================
enum ConsentType {
DATA_PROCESSING // Grundlegende Datenverarbeitung
MARKETING_EMAIL // E-Mail-Marketing
MARKETING_PHONE // Telefon-Marketing
DATA_SHARING_PARTNER // Weitergabe an Partner
}
enum ConsentStatus {
GRANTED
WITHDRAWN
PENDING
}
model CustomerConsent {
id Int @id @default(autoincrement())
customerId Int
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
consentType ConsentType
status ConsentStatus @default(PENDING)
grantedAt DateTime?
withdrawnAt DateTime?
source String? // "portal", "telefon", "papier", "email"
documentPath String? // Unterschriebenes Dokument
version String? // Version der Datenschutzerklärung
ipAddress String?
createdBy String // User der die Einwilligung erfasst hat
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([customerId, consentType])
@@index([customerId])
@@index([consentType])
@@index([status])
}
// ==================== DATA DELETION REQUESTS (DSGVO) ====================
enum DeletionRequestStatus {
PENDING // Anfrage eingegangen
IN_PROGRESS // Wird bearbeitet
COMPLETED // Abgeschlossen
PARTIALLY_COMPLETED // Teildaten behalten (rechtliche Gründe)
REJECTED // Abgelehnt
}
model DataDeletionRequest {
id Int @id @default(autoincrement())
customerId Int
status DeletionRequestStatus @default(PENDING)
requestedAt DateTime @default(now())
requestSource String // "email", "portal", "brief"
requestedBy String // Wer hat angefragt
processedAt DateTime?
processedBy String? // Mitarbeiter der bearbeitet hat
deletedData String? @db.LongText // JSON: Was wurde gelöscht
retainedData String? @db.LongText // JSON: Was wurde behalten + Grund
retentionReason String? @db.Text // Begründung für Aufbewahrung
proofDocument String? // Pfad zum Löschnachweis-PDF
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([customerId])
@@index([status])
@@index([requestedAt])
}
// ==================== AUDIT RETENTION POLICIES ====================
model AuditRetentionPolicy {
id Int @id @default(autoincrement())
resourceType String // "*" für Standard, oder spezifischer Model-Name
sensitivity AuditSensitivity?
retentionDays Int // Aufbewahrungsfrist in Tagen (z.B. 3650 = 10 Jahre)
description String?
legalBasis String? // Gesetzliche Grundlage (z.B. "AO §147", "HGB §257")
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([resourceType, sensitivity])
}
// ==================== SECURITY MONITORING ====================
// Sicherheitsrelevante Events für Realtime-Alerting + Forensik.
// Im Gegensatz zum AuditLog (forensisch, hash-gekettet) ist das hier
// optimiert für schnelles Filtern + Alerting (nicht-tamper-evident, dafür
// effizient querybar). Threshold-Detection läuft per Cron.
enum SecurityEventType {
LOGIN_FAILED // falsches Passwort / unbekannter User
LOGIN_SUCCESS // erfolgreicher Login (informativ)
RATE_LIMIT_HIT // express-rate-limit hat zugeschlagen
ACCESS_DENIED // 403 von canAccess* (versuchter IDOR)
SSRF_BLOCKED // ssrfGuard hat geblockte Adresse abgefangen
PASSWORD_RESET_REQUEST // Reset-Mail angefordert
PASSWORD_RESET_CONFIRM // Reset abgeschlossen
LOGOUT // expliziter Logout
TOKEN_REJECTED // ungültiger / abgelaufener / manipulierter JWT
PERMISSION_CHANGED // Admin hat Rolle/Permission geändert
AUDIT_SEAL_CHANGED // Bestandssiegel gesetzt oder ersetzt (Beweis-Grundlage)
SUSPICIOUS // generischer Catch-All
}
enum SecuritySeverity {
INFO // Login-Success, Logout
LOW // Einzelner failed Login, einzelner 403
MEDIUM // Rate-Limit-Hit, mehrere 403er
HIGH // SSRF-Block, JWT-Manipulation
CRITICAL // Threshold überschritten (>10 failed login/h, >5 403/min)
}
enum BackupOperation {
CREATE
RESTORE
}
// Persistiertes Log für Backup-Vorgänge.
// `summary` ist die einzeilige Anzeige in der Liste (z.B. "4859 Datensätze
// wiederhergestellt"), `fullLog` der detaillierte Output inkl. Stack-Trace
// für das Modal. Wird beim Build/Restore in `backup.controller.ts`
// geschrieben.
model BackupLog {
id Int @id @default(autoincrement())
operation BackupOperation
backupName String?
success Boolean
durationMs Int @default(0)
summary String @db.Text
fullLog String @db.LongText
userId Int?
userEmail String?
ipAddress String?
createdAt DateTime @default(now())
@@index([operation, createdAt])
@@index([createdAt])
}
model SecurityEvent {
id Int @id @default(autoincrement())
type SecurityEventType
severity SecuritySeverity
message String @db.Text
ipAddress String?
userId Int? // Mitarbeiter (falls eingeloggt)
customerId Int? // Portal-Kunde (falls eingeloggt)
userEmail String? // beste Schätzung auch bei nicht eingeloggt
endpoint String? // betroffener Endpoint
details Json? // strukturierte Zusatzinfo
alerted Boolean @default(false) // schon per Email versendet?
alertedAt DateTime?
createdAt DateTime @default(now())
@@index([type, createdAt])
@@index([severity, createdAt])
@@index([ipAddress, createdAt])
@@index([alerted, severity])
}