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? } 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[] @@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(60) 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[] } 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]) } 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 }