f9591dc491
Adds Tender.cpvDivisions String[] (@@index Gin) derived at ingestion time via cpv-catalog.ts's divisionOf() — replaces exact-match cpvCodes comparison with a typesafe, GIN-indexable hasSome target (FILTER-03, Pitfall 2). Backfill migration 20260721150000_tender_cpv_divisions_backfill applied locally: 1612/1671 rows populated across all observed divisions (741 rows carry division '45' — Bauarbeiten); idempotent (second run: UPDATE 0, ADD COLUMN IF NOT EXISTS / CREATE INDEX IF NOT EXISTS both skip cleanly). Applied via docker exec psql + `prisma migrate resolve --applied` + `prisma generate` against the local dev DB only — no Docker deploy on the test server. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
305 lines
9.8 KiB
Plaintext
305 lines
9.8 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)
|
|
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)
|
|
}
|
|
|
|
// 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
|
|
}
|