Files
tessera-ctl/apps/api/prisma/schema.prisma
T
schalli e812738c3a feat(14-02): add TenderRssFeedSource CRUD, tick poll gate, and wire RssAdapter
Global admin-managed RSS feed list (TenderRssFeedSource, D-08/D-14) with
a save-time hostname/SSRF guard (TenderRssFeedSourceService) — RSS feed
URLs are runtime admin input, so the code-level SourceRegistry denylist
gate does not cover them; a separate check rejects DENYLISTED_PORTALS
hostnames, non-http(s) schemes, and private/loopback hosts.

Adds TenderSourcePollConfig.pollGranularity ('day' | 'tick', D-15):
pollDueSources() branches per source — 'day' sources keep the existing
lastIngestedDay gate byte-unchanged, 'tick' sources (rss) fetch on every
active scheduler tick regardless of lastIngestedDay, since the day-cursor
gate was built for a genuine daily batch-export API and would otherwise
silently cap RSS to one fetch per calendar day.

Wires RssAdapter.fetchTenders() to fan out over active feed rows (native
fetch + AbortController 15s + response-size ceiling, catch-per-feed),
registers it in tenders.module.ts, and seeds the 'rss' poll config
active with pollGranularity='tick' plus a default-active service.bund.de
feed row (subreport-elvis has no single canonical URL — zero rows seeded,
admin adds relevant municipality feeds).

Migration applied locally per project convention (host -> container IP).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 13:24:36 +02:00

450 lines
17 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?
}
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])
}
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?
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
// Deliberately NO tenant column and NO tenant index — this is global data (D-03)
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
}