Files
tessera-ctl/apps/api/prisma/schema.prisma
T
schalli c5c704bae9 feat(15-01): Group/GroupMembership/ModuleGrant schema + D-06 backfill migration
- Group/GroupMembership/ModuleGrant models plus MembershipSource enum
  (D-05), placed under TenantModuleActivation with German block comment
- Hand-SQL appended to the generated migration: partial unique index for
  one default group per tenant (D-13), CHECK num_nonnulls xor-constraint
  plus two partial unique indexes for ModuleGrant (D-04), and the D-06
  backfill (Group -> GroupMembership -> ModuleGrant, each INSERT guarded
  by WHERE NOT EXISTS for idempotent re-runs on `prisma migrate deploy`)
- apps/api/src/groups/migration-sql.spec.ts verifies the hand-SQL by
  reading migration.sql directly, no DB required
- Verified against the local DB: default-group count matches tenant
  count, membership/grant counts match existing users/active
  activations, and the XOR constraint rejects a group+user-less insert
2026-08-04 15:03:48 +02:00

555 lines
22 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?
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?
bindPassword String?
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)
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
@@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])
}
model DashboardLayout {
id String @id @default(uuid())
userId String @unique
tenantId String
layouts Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([tenantId])
}
model WidgetInstance {
id String @id @default(uuid())
userId String
tenantId String
widgetType String
config Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
favoriteLinks FavoriteLink[]
@@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).
model TenderEmailConfig {
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?
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])
}
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
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?
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 GLOBAL RSS feed list.
// Deliberately NO tenantId (mirrors TenderSourcePollConfig's global/
// RLS-exempt stance, D-08: RSS feeds are public and identical for every
// tenant). Feed URLs are RUNTIME admin 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.
model TenderRssFeedSource {
id String @id @default(uuid())
url String @unique
label String
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}