b9d87be360
- Tabelle CustomModule mit Zeilenschutz (tenant_isolation_policy), Migration 20260929120000 - API /custom-modules: Lesen für jeden Angemeldeten, Schreiben nur Administrator, nur https ohne Zugangsdaten - Seitenleiste zeigt eigene Module unter ihrer Kategorie, Rahmen-Seite mit Sandbox und „In neuem Tab öffnen“ - MODULE_CATEGORIES als gemeinsame Liste, Zugriffsklassifikation nachgemessen fortgeschrieben Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
735 lines
30 KiB
Plaintext
735 lines
30 KiB
Plaintext
datasource db {
|
|
provider = "postgresql"
|
|
url = env("DATABASE_URL")
|
|
}
|
|
|
|
generator client {
|
|
provider = "prisma-client-js"
|
|
}
|
|
|
|
model Tenant {
|
|
id String @id @default(uuid())
|
|
name String
|
|
slug String @unique
|
|
isActive Boolean @default(true)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
users User[]
|
|
ldapConfig LdapConfig?
|
|
groups Group[]
|
|
moduleGrants ModuleGrant[]
|
|
}
|
|
|
|
enum Role {
|
|
SUPER_ADMIN
|
|
ADMIN
|
|
USER
|
|
}
|
|
|
|
model User {
|
|
id String @id @default(uuid())
|
|
username String @unique
|
|
email String? @unique
|
|
passwordHash String?
|
|
displayName String?
|
|
role Role @default(USER)
|
|
isActive Boolean @default(true)
|
|
mustChangePassword Boolean @default(false)
|
|
ldapDn String?
|
|
tenantId String
|
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
lastLoginAt DateTime?
|
|
avatarPath String?
|
|
accentColor String?
|
|
// quick-260925-bow: zuletzt gesehene freigegebene Version (X.Y.Z) fuer das
|
|
// "Was ist neu"-Fenster; null = Bestandsbenutzer (sieht nur die laufende Version)
|
|
lastSeenReleaseVersion String?
|
|
// quick-260928-ujj: gewaehlter Dashboard-Hintergrund; null = nie gewaehlt,
|
|
// sonst das durch parseDashboardBackground (@tessera/shared) normalisierte
|
|
// Objekt, auch { kind: 'none' } fuer bewusst "kein Hintergrund"
|
|
dashboardBackground Json?
|
|
passwordResetTokens PasswordResetToken[]
|
|
groupMemberships GroupMembership[]
|
|
moduleGrants ModuleGrant[]
|
|
|
|
@@index([tenantId])
|
|
@@index([username])
|
|
@@index([email])
|
|
}
|
|
|
|
model PasswordResetToken {
|
|
id String @id @default(uuid())
|
|
token String @unique
|
|
userId String
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
expiresAt DateTime
|
|
usedAt DateTime?
|
|
createdAt DateTime @default(now())
|
|
}
|
|
|
|
model LdapConfig {
|
|
id String @id @default(uuid())
|
|
tenantId String @unique
|
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
|
serverUrl String
|
|
baseDn String
|
|
bindDn String?
|
|
encryptedBindPassword String? // AES-256-GCM ciphertext (iv:authTag:ciphertext hex), wie CalendarSource/SmtpConfig
|
|
searchFilter String @default("(objectClass=person)")
|
|
syncIntervalMin Int @default(0)
|
|
isActive Boolean @default(true)
|
|
tlsRejectUnauthorized Boolean @default(true)
|
|
groupFilterDns String[] @default([])
|
|
userExcludeList String[] @default([])
|
|
lastSyncAt DateTime?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
fieldMappings LdapFieldMapping[]
|
|
}
|
|
|
|
model LdapFieldMapping {
|
|
id String @id @default(uuid())
|
|
ldapConfigId String
|
|
ldapConfig LdapConfig @relation(fields: [ldapConfigId], references: [id], onDelete: Cascade)
|
|
ldapField String
|
|
tesseraField String
|
|
isDefault Boolean @default(false)
|
|
createdAt DateTime @default(now())
|
|
|
|
@@unique([ldapConfigId, ldapField])
|
|
}
|
|
|
|
model Module {
|
|
id String @id @default(uuid())
|
|
slug String @unique
|
|
name String
|
|
version String
|
|
category String
|
|
description Json
|
|
icon String?
|
|
isSystem Boolean @default(false)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
activations TenantModuleActivation[]
|
|
grants ModuleGrant[]
|
|
}
|
|
|
|
model TenantModuleActivation {
|
|
id String @id @default(uuid())
|
|
tenantId String
|
|
moduleId String
|
|
isActive Boolean @default(true)
|
|
activatedAt DateTime @default(now())
|
|
module Module @relation(fields: [moduleId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([tenantId, moduleId])
|
|
@@index([tenantId])
|
|
}
|
|
|
|
// Phase 15 (PERM-04/05/06) — zweites Standbein der Zugriffskontrolle neben
|
|
// TenantModuleActivation. D-05: Gruppen sind Tessera-eigene Objekte pro
|
|
// Mandant mit optionaler AD-Bindung (ldapDn) — ein Umbau nach Vergabe
|
|
// echter Freigaben ist eine Datenmigration (one-way). D-13: pro Mandant
|
|
// darf höchstens eine Gruppe die Standard-Markierung tragen, DB-erzwungen
|
|
// über einen partiellen Unique-Index in der Hand-SQL-Ergänzung dieser
|
|
// Migration (Prisma 6.19 kennt keine partiellen Indizes ohne Preview-Flag).
|
|
// D-04: ModuleGrant trägt bewusst KEIN Rechtestufen-Feld — nur Zugriff an/aus.
|
|
enum MembershipSource {
|
|
MANUAL
|
|
LDAP
|
|
}
|
|
|
|
model Group {
|
|
id String @id @default(uuid())
|
|
tenantId String
|
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
|
name String
|
|
ldapDn String? // optionale AD-Bindung (D-05)
|
|
internalName String? // D-04: vom Sync nie berührt, überschreibt die Anzeige wenn gesetzt
|
|
ldapObjectGuid String? // D-04/SC-3: hex-kodierter objectGUID, rename-stabiler Sync-Match-Key (ldapDn bleibt Anzeige-/Debug-Feld)
|
|
isDefault Boolean @default(false) // D-13 — genau eine pro Mandant, DB-erzwungen (Hand-SQL)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
memberships GroupMembership[]
|
|
grants ModuleGrant[]
|
|
|
|
@@unique([tenantId, name]) // Gruppennamen sind pro Mandant eindeutig
|
|
@@unique([tenantId, ldapDn]) // NULL ist in Postgres je Zeile distinct — mehrere ungebundene Gruppen sind erlaubt
|
|
@@unique([tenantId, ldapObjectGuid]) // gleiches NULL-ist-distinct-Muster wie @@unique([tenantId, ldapDn])
|
|
@@index([tenantId])
|
|
}
|
|
|
|
model GroupMembership {
|
|
id String @id @default(uuid())
|
|
groupId String
|
|
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
|
userId String
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
source MembershipSource @default(MANUAL)
|
|
createdAt DateTime @default(now())
|
|
|
|
@@unique([groupId, userId]) // Upsert-Ziel
|
|
@@index([userId])
|
|
@@index([groupId])
|
|
}
|
|
|
|
model ModuleGrant {
|
|
id String @id @default(uuid())
|
|
tenantId String
|
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
|
moduleId String
|
|
module Module @relation(fields: [moduleId], references: [id], onDelete: Cascade)
|
|
groupId String?
|
|
group Group? @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
|
userId String?
|
|
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
createdAt DateTime @default(now())
|
|
|
|
// Entweder-oder (Gruppe XOR Benutzer, D-04) + Duplikat-Schutz je Variante
|
|
// werden per hand-editierter migration.sql ergänzt — Prisma 6.19 hat kein
|
|
// stabiles partial-index-Feature ohne previewFeatures-Flag.
|
|
@@index([tenantId])
|
|
@@index([moduleId])
|
|
}
|
|
|
|
// Dashboard-Reiter (quick-260923-ad9, D-01/D-02/D-09): mehrere Dashboards je
|
|
// Benutzer, ueber `position` (Integer) aufsteigend sortiert. KEIN Unique auf
|
|
// (userId, position) — `reorderDashboards` (Muster FavoritesService.reorder)
|
|
// schreibt beim Umsortieren ALLE Positionen eines Benutzers in EINER
|
|
// Transaktion neu; ein Unique waere dabei nur im Weg (kollidiert waehrend
|
|
// des Umschreibens mit sich selbst). KEIN eigenes Standard-Feld: "als
|
|
// Favorit festlegen" IST das Nach-vorn-Ziehen (D-09) — Position 0 ist der
|
|
// Standard, es gibt keine zweite Wahrheit daneben. Keine Relation zu
|
|
// User/Tenant — Form der Nachbarmodelle WidgetInstance/DashboardImage (eine
|
|
// Relation zu User wuerde an Bestandszeilen verwaister Benutzer scheitern).
|
|
model Dashboard {
|
|
id String @id @default(uuid())
|
|
userId String
|
|
tenantId String
|
|
name String
|
|
position Int
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
widgets WidgetInstance[]
|
|
layout DashboardLayout?
|
|
|
|
@@index([userId])
|
|
@@index([tenantId])
|
|
}
|
|
|
|
model DashboardLayout {
|
|
id String @id @default(uuid())
|
|
userId String
|
|
tenantId String
|
|
// quick-260923-ad9 (D-02): haengt jetzt am Dashboard statt am Benutzer —
|
|
// die Eindeutigkeit wandert von userId auf dashboardId, userId/tenantId
|
|
// bleiben fuer Besitz- und Mandantenpruefung erhalten.
|
|
dashboardId String @unique
|
|
dashboard Dashboard @relation(fields: [dashboardId], references: [id], onDelete: Cascade)
|
|
layouts Json @default("{}")
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@index([userId])
|
|
@@index([tenantId])
|
|
}
|
|
|
|
model WidgetInstance {
|
|
id String @id @default(uuid())
|
|
userId String
|
|
tenantId String
|
|
// quick-260923-ad9 (D-02): Kacheln haengen ab jetzt am Reiter, nicht mehr
|
|
// nur am Benutzer.
|
|
dashboardId String
|
|
dashboard Dashboard @relation(fields: [dashboardId], references: [id], onDelete: Cascade)
|
|
widgetType String
|
|
config Json @default("{}")
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
favoriteLinks FavoriteLink[]
|
|
|
|
@@index([userId])
|
|
@@index([tenantId])
|
|
@@index([dashboardId])
|
|
}
|
|
|
|
// Bilderrahmen-Widget (quick-260921-pi9): hochgeladene Bilder eines Benutzers.
|
|
// Keine Relation — wie WidgetInstance. Grenzen (5 MiB je Datei, 30 je
|
|
// Benutzer) und die Magic-Byte-Erkennung leben in
|
|
// src/dashboard/dashboard-image-rules.ts; Besitz = gleicher Mandant UND
|
|
// gleicher Benutzer (Regel in Migration 20260921120000 mit Benutzerdimension).
|
|
//
|
|
// quick-260922-hk4: die Bytes liegen jetzt im Dateibereich
|
|
// (user-files/dashboard-images/<userId>/<id>.<ext>), die Zeile haelt nur
|
|
// noch den relativen Pfad — Muster User.avatarPath. Die Datenbanksicherung
|
|
// (pg_dump) bleibt dadurch klein.
|
|
model DashboardImage {
|
|
id String @id @default(uuid())
|
|
userId String
|
|
tenantId String
|
|
originalName String
|
|
mimeType String
|
|
size Int
|
|
// Relativ zur Monorepo-Wurzel, z. B.
|
|
// "user-files/dashboard-images/<userId>/<id>.png". Die Bytes liegen seit
|
|
// quick-260922-hk4 im Dateibereich; die alte Spalte `data` ist mit Stufe 2
|
|
// (Migration 20260924120000_dashboard_image_drop_data) entfernt.
|
|
storagePath String
|
|
createdAt DateTime @default(now())
|
|
|
|
@@index([userId])
|
|
@@index([tenantId])
|
|
}
|
|
|
|
model SearchProvider {
|
|
id String @id @default(uuid())
|
|
userId String?
|
|
tenantId String?
|
|
name String
|
|
urlTemplate String
|
|
isDefault Boolean @default(false)
|
|
createdAt DateTime @default(now())
|
|
|
|
@@index([userId])
|
|
}
|
|
|
|
model CalendarSource {
|
|
id String @id @default(uuid())
|
|
userId String
|
|
tenantId String
|
|
name String
|
|
type String // 'caldav' | 'ics' | 'exchange'
|
|
exchangeMode String? // 'ews' | 'graph' — only for exchange type
|
|
domain String? // Exchange EWS only: Windows domain (e.g. COMPANY)
|
|
url String
|
|
username String?
|
|
encryptedPassword String? // AES-256-GCM ciphertext (iv:authTag:ciphertext hex)
|
|
color String? @default("#3B82F6")
|
|
isVisible Boolean @default(true)
|
|
syncIntervalMin Int @default(15)
|
|
lastSyncAt DateTime?
|
|
lastSyncError String?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@index([userId])
|
|
@@index([tenantId])
|
|
}
|
|
|
|
model DkvModuleConfig {
|
|
id String @id @default(uuid())
|
|
tenantId String @unique
|
|
protocol String @default("imap") // 'imap' | 'exchange'
|
|
host String?
|
|
port Int?
|
|
encryption String @default("ssl-tls") // 'none' | 'starttls' | 'ssl-tls'
|
|
folder String @default("INBOX")
|
|
senderFilter String?
|
|
pollIntervalMin Int @default(60)
|
|
isActive Boolean @default(false)
|
|
exportRecipient String?
|
|
vehicleFormatString String @default("{Marke}/{Modell}/{Kennzeichen}")
|
|
domain String? // Exchange only: Windows domain (optional)
|
|
encryptedInboxCreds String? // AES-256-GCM: JSON { username, password } encrypted
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@index([tenantId])
|
|
}
|
|
|
|
// Phase 14, Plan 03 (INGEST-05, CONFIG-02, D-06/D-07) — per-tenant portal-
|
|
// alert mailbox config, mirroring DkvModuleConfig's shape/pattern exactly
|
|
// (own tenantId @unique row, own encrypted creds — D-03: each module keeps
|
|
// its own independent mailbox config, this is a SEPARATE mailbox from the
|
|
// DKV invoice inbox). Credentials are encrypted via CalendarCryptoService
|
|
// (same AES-256-GCM iv:authTag:ciphertext format as DkvModuleConfig/
|
|
// SmtpConfig) and excluded from every API response (Safe-Select, T-07-12).
|
|
// Phase 17 (D-01): ownership lives at the USER, not the tenant — each user
|
|
// connects their own alert mailbox, so two colleagues at the same tenant can
|
|
// each run their own inbox in parallel. `tenantId` stays as a denormalized
|
|
// field (SMTP resolution, ownerTenantId tagging on ingested Tender rows) —
|
|
// same role as `tenantId` on TenderMatch, not the ownership key anymore.
|
|
model TenderEmailConfig {
|
|
id String @id @default(uuid())
|
|
userId String @unique
|
|
tenantId String
|
|
protocol String @default("imap") // 'imap' | 'exchange'
|
|
host String?
|
|
port Int?
|
|
encryption String @default("ssl-tls") // 'none' | 'starttls' | 'ssl-tls'
|
|
folder String @default("INBOX")
|
|
senderFilter String?
|
|
domain String? // Exchange only: Windows domain (optional)
|
|
isActive Boolean @default(false)
|
|
encryptedInboxCreds String? // AES-256-GCM: JSON { username, password } encrypted
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@index([tenantId])
|
|
@@index([userId])
|
|
}
|
|
|
|
model DkvVehicleMaster {
|
|
id String @id @default(uuid())
|
|
tenantId String
|
|
kennzeichen String
|
|
marke String
|
|
modell String
|
|
fahrer String // "Vorname Nachname"
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@unique([tenantId, kennzeichen])
|
|
@@index([tenantId])
|
|
}
|
|
|
|
model DkvInvoiceHistory {
|
|
id String @id @default(uuid())
|
|
tenantId String
|
|
datumZeit DateTime @default(now())
|
|
rechnungsnummer String
|
|
anzahlFahrzeuge Int @default(0)
|
|
anzahlTransaktionen Int @default(0)
|
|
status String // 'Verarbeitet' | 'Fehler' | 'Versand fehlgeschlagen'
|
|
errorMessage String?
|
|
exportFilename String?
|
|
createdAt DateTime @default(now())
|
|
|
|
@@index([tenantId])
|
|
@@index([datumZeit])
|
|
}
|
|
|
|
model SmtpConfig {
|
|
id String @id @default(uuid())
|
|
tenantId String @unique
|
|
host String
|
|
port Int @default(587)
|
|
encryption String @default("starttls") // 'none' | 'starttls' | 'ssl-tls'
|
|
username String?
|
|
encryptedPassword String? // AES-256-GCM via CalendarCryptoService
|
|
fromAddress String
|
|
bugReportRecipient String? // Postfach fuer den Fehler-melden-Knopf (quick-260914-m97); leer = Rueckfall TESSERA_BUGREPORT_TO
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
}
|
|
|
|
model FavoriteLink {
|
|
id String @id @default(uuid())
|
|
userId String
|
|
tenantId String
|
|
widgetId String
|
|
title String
|
|
url String
|
|
iconUrl String?
|
|
// quick-260923-lrr: Typ eines hochgeladenen eigenen Symbols (erkannt an
|
|
// den Bytes, NIE aus der Anfrage uebernommen); null = kein hochgeladenes
|
|
// Symbol. Kein Pfad gespeichert — die Datei liegt ableitbar unter
|
|
// user-files/favorite-icons/<userId>/<id>.<ext>.
|
|
uploadedIconMime String?
|
|
// quick-260923-lrr: Zaehler fuer die ausgelieferte Symbol-Adresse
|
|
// (?v=<iconVersion>), steigt bei jeder Aenderung der Symbolquelle
|
|
// (neue iconUrl, Upload, Entfernen) — macht den 24-h-Browser-Zwischenspeicher
|
|
// nach einer Aenderung sofort ungueltig.
|
|
iconVersion Int @default(0)
|
|
position Int @default(0)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
widgetInstance WidgetInstance @relation(fields: [widgetId], references: [id], onDelete: Cascade)
|
|
|
|
@@index([userId])
|
|
@@index([tenantId])
|
|
@@index([widgetId])
|
|
}
|
|
|
|
// Ausschreibungs-Radar (Phase 10) — platform-global tender reference data.
|
|
// D-03: Tender carries NO tenantId and is NOT wrapped by forTenant()/RLS —
|
|
// per-tenant scoping (saved searches) lives one layer up in Phase 11.
|
|
model Tender {
|
|
id String @id @default(uuid())
|
|
sourcePortal String // e.g. 'doe-opendata'
|
|
sourceNoticeId String
|
|
ocid String? // OCDS contracting id, when present
|
|
dedupKey String @unique // ocid, or fallback sourcePortal:sourceNoticeId — SCHEMA-02 upsert target
|
|
title String
|
|
buyerName String?
|
|
cpvCodes String[] @default([])
|
|
cpvDivisions String[] @default([]) // FILTER-03: normalized 2-digit CPV divisions (hasSome-filterable, Pitfall 2)
|
|
region String?
|
|
plz String?
|
|
bundesland String?
|
|
deadlineAt DateTime? // frequently null (RESEARCH Pattern 4) — nullable is mandatory
|
|
estimatedValue Decimal? @db.Decimal(14, 2)
|
|
procedureType String?
|
|
status String @default("active") // 'active' | 'expired' (D-05 retention marking)
|
|
sourceUrl String?
|
|
contentHash String // SCHEMA-02 change detection
|
|
rawPayload Json? // debugging / re-normalization
|
|
// SCHEMA-03 (D-04) — Fuzzy-Dedup lookup key, additive + nullable. Backfilled
|
|
// for pre-existing rows by backfill-tender-source.ts (Plan 13-01 Task 2).
|
|
// dedupKey above stays the SCHEMA-02 upsert target — NOT replaced here.
|
|
fingerprint String?
|
|
// Phase 14, Plan 03 (INGEST-05, D-13) — per-tenant visibility for PRIVATE
|
|
// sources only. null = global/platform-wide (D-03, unchanged for all
|
|
// public sources: DÖE/NetServer/cosinex/RSS — existing rows stay null,
|
|
// no backfill). Set = visible ONLY to that tenant (email-alert tenders,
|
|
// since an alert mailbox reflects one tenant's private subscription).
|
|
// Read filter: buildTenderWhere OR[{ownerTenantId:null},{ownerTenantId:tenant}].
|
|
// Write: dedup CREATE only sets this — the UPDATE branch never touches it,
|
|
// so a source later also seen globally is never retroactively hidden.
|
|
ownerTenantId String?
|
|
publishedAt DateTime
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@index([status])
|
|
@@index([deadlineAt])
|
|
@@index([publishedAt])
|
|
@@index([bundesland]) // FILTER-02: post-backfill Bundesland-Filter-Performance
|
|
@@index([cpvDivisions], type: Gin) // FILTER-03: post-backfill CPV-Divisions-Filter-Performance (hasSome)
|
|
@@index([fingerprint]) // SCHEMA-03: dedup resolver fingerprint-tier lookup
|
|
@@index([ownerTenantId]) // D-13: read-filter lookup for private (email-alert) tenders
|
|
// Deliberately NO tenant column/index for the platform-wide default (D-03)
|
|
// — ownerTenantId above is the sole, additive, nullable exception for
|
|
// privately-sourced tenders (D-13).
|
|
triage TenderTriage[]
|
|
matches TenderMatch[]
|
|
sources TenderSource[] // SCHEMA-03/D-03 — all source portals this tender was seen on
|
|
}
|
|
|
|
// SCHEMA-03 (D-03) — Quell-Ebene einer Ausschreibung. 1:n zu Tender: ein
|
|
// Tender ist EIN Trefferlisten-Eintrag; mehrere TenderSource-Zeilen sind die
|
|
// Liste der Quell-Portale/Links, über die er gefunden wurde (Cross-Source
|
|
// Dedup, "ein Eintrag + Liste ALLER Quell-Links", nicht "Primärquelle
|
|
// gewinnt"). Backfilled 1:1 for pre-existing DÖE tenders (D-05).
|
|
model TenderSource {
|
|
id String @id @default(uuid())
|
|
tenderId String
|
|
sourcePortal String // 'doe-opendata' | 'tender24' | 'lhs-vpbw' | 'vergabe.landbw' | 'cosinex-dtvp'
|
|
sourceNoticeId String
|
|
ocid String?
|
|
sourceUrl String?
|
|
createdAt DateTime @default(now())
|
|
tender Tender @relation(fields: [tenderId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([sourcePortal, sourceNoticeId]) // eine Quell-Notiz gehört zu genau einem Tender
|
|
@@index([tenderId])
|
|
}
|
|
|
|
// UI-03/04 — per-user Triage-Zustand pro Tender (gelesen/ungelesen, Favorit).
|
|
// Scoping-Muster wie FavoriteLink (T-08-06, Pitfall 4): userId-Scoping im
|
|
// Service, KEIN forTenant()/RLS — RLS existiert nur für Auth-Kerntabellen.
|
|
// tenantId wird zusätzlich mitgeführt (spätere Tenant-Isolation, T-11-12),
|
|
// ist aber NICHT das Scoping-Feld — jede Query filtert auf userId.
|
|
model TenderTriage {
|
|
id String @id @default(uuid())
|
|
userId String
|
|
tenantId String
|
|
tenderId String
|
|
isRead Boolean @default(false)
|
|
isFavorite Boolean @default(false)
|
|
readAt DateTime?
|
|
favoritedAt DateTime?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
tender Tender @relation(fields: [tenderId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([userId, tenderId]) // Upsert-Target (setTriage); ein Triage-Row je (user,tender)
|
|
@@index([userId])
|
|
@@index([tenderId])
|
|
}
|
|
|
|
// FILTER-06 — per-user Suchprofil (D-08/D-11). Scoping-Muster wie
|
|
// FavoriteLink/TenderTriage (T-08-06, Pitfall 4): userId-Scoping im
|
|
// Service, KEIN forTenant()/RLS. tenantId wird zusätzlich mitgeführt
|
|
// (spätere Tenant-Isolation), ist aber NICHT das Scoping-Feld. KEIN
|
|
// Tender-FK (Pitfall 6) — ein Profil speichert nur die Filterkriterien
|
|
// (deckungsgleich mit den URL-searchParams, Plan 11-06), nicht Tender-Ids,
|
|
// daher unkritisch bei der 90-Tage-Retention.
|
|
model TenderSavedSearch {
|
|
id String @id @default(uuid())
|
|
userId String
|
|
tenantId String
|
|
name String
|
|
filters Json // serialisierte Filterkombination (q, plz, bundesland, region, cpv, deadlineFrom/To, openOnly, valueMin/Max, includeNullValue, sort, favOnly)
|
|
instantAlert Boolean @default(false) // NOTIFY-02/D-04 — Sofort-Alert pro Profil, Default AUS
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
matches TenderMatch[] // Gegenrelation (NOTIFY-03)
|
|
|
|
@@unique([userId, name]) // keine zwei Profile gleichen Namens pro Nutzer
|
|
@@index([userId])
|
|
}
|
|
|
|
// NOTIFY-03 Kern-Invariante — der "getroffen"-Datensatz (D-06), ein Row je
|
|
// (tender x savedSearch)-Paar. Ein einziges `notifiedAt` ist das
|
|
// Eligibility-Gate: NULL = noch nicht benachrichtigt, gesetzt (egal ob
|
|
// durch 'instant' oder 'digest') = das Paar wird NIE wieder benachrichtigt
|
|
// (D-06 — kein Doppelversand, weder Digest+Instant noch zweimal im selben
|
|
// Kanal). Match-Erzeugung ist ein idempotenter Upsert auf
|
|
// @@unique([tenderId, savedSearchId]) mit `update: {}` — ein Re-Match
|
|
// bewahrt ein bereits gesetztes notifiedAt strukturell (T-12-04).
|
|
//
|
|
// Scoping-Muster wie TenderTriage/TenderSavedSearch (Pitfall 4): userId
|
|
// wird aus dem Profil denormalisiert mitgeführt und ist das Scoping-Feld
|
|
// für Reads (where:{userId}, IDOR-Schutz), tenantId zusätzlich für die
|
|
// spätere Mandanten-SMTP-Auflösung im Digest/Instant-Versand — KEIN
|
|
// forTenant()/RLS.
|
|
model TenderMatch {
|
|
id String @id @default(uuid())
|
|
tenderId String
|
|
savedSearchId String
|
|
userId String // denormalisiert vom Profil — Scoping-Feld für Reads
|
|
tenantId String // denormalisiert vom Profil — SMTP-Auflösung (D-08)
|
|
matchedAt DateTime @default(now())
|
|
notifiedAt DateTime? // NULL = noch nicht benachrichtigt (das Eligibility-Gate, D-06)
|
|
notifiedChannel String? // 'digest' | 'instant' — nur Audit, NICHT Teil der Invariante
|
|
tender Tender @relation(fields: [tenderId], references: [id], onDelete: Cascade)
|
|
savedSearch TenderSavedSearch @relation(fields: [savedSearchId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([tenderId, savedSearchId]) // Upsert-Target — ein Match je Paar, idempotent
|
|
@@index([userId]) // Digest-/Instant-Query: where userId, notifiedAt null
|
|
@@index([notifiedAt])
|
|
}
|
|
|
|
// NOTIFY-01/D-03 — Digest-Intervall ist eine per-USER Einstellung (nicht
|
|
// pro Profil): ein Digest deckt alle Suchprofile eines Nutzers ab. Default
|
|
// 'daily' (D-01). Scoping-Muster wie TenderTriage (userId, kein
|
|
// forTenant()/RLS); tenantId zusätzlich für SMTP-Auflösung im Digest-Cron.
|
|
model TenderNotificationPref {
|
|
id String @id @default(uuid())
|
|
userId String @unique // ein Pref-Row je Nutzer
|
|
tenantId String
|
|
digestInterval String @default("daily") // 'daily' | 'weekly' | 'off' (D-01)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@index([userId])
|
|
}
|
|
|
|
// Singleton-per-source admin poll config (INGEST-06 foundation).
|
|
model TenderSourcePollConfig {
|
|
id String @id @default(uuid())
|
|
sourceType String @unique // fixed slug 'doe-opendata' — @unique makes singleton intent explicit
|
|
pollIntervalMin Int @default(60) // D-04 default hourly
|
|
isActive Boolean @default(false)
|
|
lastIngestedDay DateTime? // day-cursor, NOT a timestamp (RESEARCH Pattern 1)
|
|
// Phase 14, Plan 02 (D-15/Pitfall 1): 'day' | 'tick'. 'day' sources
|
|
// (doe-opendata/ai-netserver/cosinex-dtvp) keep the lastIngestedDay-gated
|
|
// once-per-calendar-day fetch, unchanged. 'tick' sources (rss) are
|
|
// fetched on every active scheduler tick, ignoring lastIngestedDay
|
|
// entirely — the day-cursor gate was built for a genuine daily
|
|
// batch-export API and would otherwise silently cap RSS to one fetch
|
|
// per day regardless of pollIntervalMin.
|
|
pollGranularity String @default("day")
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
}
|
|
|
|
// Phase 14, Plan 02 (INGEST-04, D-14) — admin-managed RSS feed list.
|
|
// Feed URLs are RUNTIME admin/user input — unlike the hardcoded
|
|
// NETSERVER_PORTALS/COSINEX_BASE_URL constants, the code-level
|
|
// SourceRegistry denylist gate does NOT cover this data (RESEARCH.md
|
|
// Pitfall 3); TenderRssFeedSourceService enforces a SEPARATE save-time
|
|
// hostname/SSRF guard (T-14-02-01) on create/update.
|
|
//
|
|
// Phase 17, Plan 02 (D-02): the list is now two-part. `userId = null` is a
|
|
// PLATFORM-WIDE feed — admin-managed, active for every tenant (mirrors
|
|
// TenderSourcePollConfig's global/RLS-exempt stance, D-08); this is the
|
|
// bucket the service.bund.de default (seeded since Phase 14) lives in, and
|
|
// stays there unmigrated (17-CONTEXT.md, offener Punkt 2). `userId` set is
|
|
// a PERSONAL feed owned by exactly one user. `tenantId` is denormalized
|
|
// from the owner (same role as `TenderEmailConfig.tenantId`, Phase 17 Plan
|
|
// 01) — null for platform-wide feeds, set for personal feeds so
|
|
// `RssAdapter` can tag their ingested `Tender` rows with the existing D-13
|
|
// `ownerTenantId` origin marking (D-06, this plan).
|
|
//
|
|
// `@@unique([userId, url])` replaces the old `url @unique`: two different
|
|
// users may now follow the same address. NULL is distinct per-row in a
|
|
// PostgreSQL unique index, so this does NOT prevent the same URL being
|
|
// registered twice platform-wide (both userId NULL) — deliberately
|
|
// accepted, see 17-02-PLAN.md Objective (T-17-13): cross-source dedup
|
|
// absorbs the duplicate, only costing one extra fetch.
|
|
model TenderRssFeedSource {
|
|
id String @id @default(uuid())
|
|
url String
|
|
label String
|
|
isActive Boolean @default(true)
|
|
userId String? // null = platform-wide (D-02); set = personal feed owner
|
|
tenantId String? // denormalized owner's tenant, null for platform-wide feeds (D-06)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@unique([userId, url])
|
|
@@index([userId])
|
|
}
|
|
|
|
// Quick-Auftrag 260923-dhh — Proxmox-Modul (PVE/PBS/PMG), nur beobachten (D-01).
|
|
//
|
|
// Vorbild ist `CalendarSource` (mehrere verschluesselte Fremdsystem-Zugaenge
|
|
// je Mandant), NICHT `DkvModuleConfig` (Singleton je Mandant): ein Mandant
|
|
// traegt hier beliebig viele Server ein. `authMethod` waehlt zwischen einem
|
|
// API-Token (`tokenId`/`encryptedTokenSecret`) und Benutzer/Passwort
|
|
// (`username`/`encryptedPassword`); PMG kennt laut Recherche nur Letzteres
|
|
// (DTO lehnt Token bei PMG serverseitig ab, D-03). `tlsRejectUnauthorized`
|
|
// ist woertlich der Feldname aus `LdapConfig` — Voreinstellung "pruefen",
|
|
// pro Zeile umschaltbar, nie global (D-04).
|
|
model ProxmoxServer {
|
|
id String @id @default(uuid())
|
|
tenantId String
|
|
name String
|
|
productType String // 'pve' | 'pbs' | 'pmg'
|
|
baseUrl String
|
|
authMethod String // 'token' | 'password'
|
|
tokenId String?
|
|
encryptedTokenSecret String? // AES-256-GCM ciphertext (iv:authTag:ciphertext hex), wie CalendarSource.encryptedPassword
|
|
username String?
|
|
encryptedPassword String? // AES-256-GCM ciphertext (iv:authTag:ciphertext hex)
|
|
tlsRejectUnauthorized Boolean @default(true)
|
|
isActive Boolean @default(true)
|
|
pollIntervalMin Int @default(5)
|
|
position Int @default(0)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
status ProxmoxServerStatus?
|
|
|
|
@@index([tenantId])
|
|
}
|
|
|
|
// Zwischenlager (D-05): der Hintergrunddienst (Aufgabe 4) beschreibt diese
|
|
// Zeile, die Modulseite liest ausschliesslich daraus — nie live bei Proxmox.
|
|
model ProxmoxServerStatus {
|
|
id String @id @default(uuid())
|
|
serverId String @unique
|
|
server ProxmoxServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
|
|
tenantId String
|
|
lastPolledAt DateTime?
|
|
lastOkAt DateTime?
|
|
reachable Boolean @default(false)
|
|
errorKind String?
|
|
errorDetail String?
|
|
metrics Json?
|
|
rawSample Json?
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@index([tenantId])
|
|
}
|
|
|
|
// Eigene Module (quick-260929-9wc): vom Administrator angelegte Seitenleisten-
|
|
// Eintraege, die eine externe https-Seite im Rahmen zeigen. Sichtbar fuer alle
|
|
// Benutzer des Mandanten. Zeilenschutz nach Muster ProxmoxServer (tenantId,
|
|
// keine Relation zu Tenant).
|
|
model CustomModule {
|
|
id String @id @default(uuid())
|
|
tenantId String
|
|
name String
|
|
url String
|
|
category String // eine der MODULE_CATEGORIES aus @tessera/shared
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@index([tenantId])
|
|
}
|