1222951af6
- User.email auf optional gestellt (Migration geschrieben, NICHT ausgefuehrt); Eindeutigkeitsindex unangetastet, NULL bleibt in Postgres je verschieden - Neuer Kollisionsentscheider (resolveEmailForWrite) in ldap.service.ts: eine bereits vergebene Adresse wird nie umgehaengt (T-Q3-01) — das zuerst angelegte Konto behaelt sie, jedes weitere Konto entsteht ohne Adresse (gesperrte Nutzerentscheidung 2026-09-09, WINDOWS #15) - Entscheider in upsertMappedUser (Sync) UND importUsersByDn (Handimport) verdrahtet, damit der zweite Anlageweg nicht als Luecke bestehen bleibt - LdapSyncResult um emailConflicts/skippedNoLogin/entryFailures erweitert; rohe ORM-Ausnahmetexte gehen nur noch an logger.error, nie in den Bericht (T-Q3-02) - UserService.create nimmt die Adresse optional entgegen; Tender-Digest und Instant-Alert ueberspringen Empfaenger ohne Adresse (continue) - Fuenf neue Testfaelle vorab gegen den unveraenderten Bestand rot gelaufen (erwartete Ursachen bestaetigt); 651/651 API-Tests gruen, prisma validate und type-check sauber Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU
586 lines
24 KiB
Plaintext
586 lines
24 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?
|
|
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])
|
|
}
|
|
|
|
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).
|
|
// 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
|
|
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 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])
|
|
}
|