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//.), 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//.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//.. uploadedIconMime String? // quick-260923-lrr: Zaehler fuer die ausgelieferte Symbol-Adresse // (?v=), 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]) }