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) 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]) } 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 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) // Deliberately NO tenant column and NO tenant index — this is global data (D-03) triage TenderTriage[] matches TenderMatch[] } // 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) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }