Files
tessera-ctl/apps/api/prisma/schema.prisma
T
schalli f9591dc491 feat(11-03): cpvDivisions column — normalizer + backfill migration
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>
2026-07-21 16:11:40 +02:00

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
}