Files
CapaKraken/packages/db/prisma/schema.prisma
Hartmut fe79810a85
CI / Architecture Guardrails (push) Successful in 6m1s
CI / Assistant Split Regression (push) Successful in 6m52s
CI / Lint (push) Successful in 8m40s
CI / Typecheck (push) Successful in 9m45s
CI / Unit Tests (push) Successful in 7m28s
CI / Build (push) Failing after 10m16s
CI / E2E Tests (push) Has been cancelled
CI / Fresh-Linux Docker Deploy (push) Has been cancelled
CI / Release Images (push) Has been cancelled
security: MFA backup codes — issue on enable, redeem at login, regenerate on demand (#43)
Adds a one-time-use backup code set so users with a lost authenticator are not
locked out. Codes are Crockford base32 (XXXXX-XXXXX), hashed with argon2id, and
redeemed under a WHERE-guarded delete so a concurrent replay race fails closed.

- New MfaBackupCode model + migration
- Issue 10 codes inside the enable transaction; show plaintext exactly once
- Sign-in page accepts TOTP or backup code, reporting remaining count
- regenerateBackupCodes tRPC mutation wipes + reissues atomically
- Unit coverage for generator, normalizer, verify, redeem, and race path

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 18:47:18 +02:00

1765 lines
59 KiB
Plaintext

// CapaKraken — Prisma Schema
// All monetary values stored as integer cents to avoid float precision issues.
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ─── Enums ───────────────────────────────────────────────────────────────────
enum SystemRole {
ADMIN
MANAGER
CONTROLLER
USER
VIEWER
}
enum OrderType {
BD
CHARGEABLE
INTERNAL
OVERHEAD
}
enum AllocationType {
INT
EXT
}
enum AllocationStatus {
PROPOSED
CONFIRMED
ACTIVE
COMPLETED
CANCELLED
}
enum BlueprintTarget {
RESOURCE
PROJECT
}
enum ProjectStatus {
DRAFT
ACTIVE
ON_HOLD
COMPLETED
CANCELLED
}
enum EstimateStatus {
DRAFT
IN_REVIEW
APPROVED
ARCHIVED
}
enum EstimateVersionStatus {
WORKING
BASELINE
SUBMITTED
APPROVED
SUPERSEDED
}
enum EstimateExportFormat {
XLSX
CSV
JSON
SAP
MMP
}
enum ResourceType {
EMPLOYEE
FREELANCER
APPRENTICE
INTERN
STUDENT
}
enum AuditAction {
CREATE
UPDATE
DELETE
SHIFT
IMPORT
}
enum VacationType {
ANNUAL
SICK
PUBLIC_HOLIDAY
OTHER
}
enum VacationStatus {
PENDING
APPROVED
REJECTED
CANCELLED
}
enum HolidayCalendarScope {
COUNTRY
STATE
CITY
}
enum ImportBatchStatus {
DRAFT
STAGING
STAGED
REVIEW_READY
APPROVED
COMMITTING
COMMITTED
FAILED
CANCELLED
}
enum StagedRecordStatus {
PARSED
NORMALIZED
UNRESOLVED
APPROVED
REJECTED
COMMITTED
FAILED
}
enum DispoImportSourceKind {
REFERENCE
PLANNING
CHARGEABILITY
ROSTER
}
enum AbsenceTrigger {
SICK
VACATION
PUBLIC_HOLIDAY
CUSTOM
}
enum CostEffect {
CHARGE
ZERO
REDUCE
}
enum ChargeabilityEffect {
COUNT
SKIP
}
enum DispoStagedRecordType {
RESOURCE
CLIENT
PROJECT
ASSIGNMENT
VACATION
AVAILABILITY_RULE
UNRESOLVED
}
// ─── Auth.js Compatible Models ───────────────────────────────────────────────
model User {
id String @id @default(cuid())
name String?
email String @unique
emailVerified DateTime?
image String?
passwordHash String?
systemRole SystemRole @default(USER)
permissionOverrides Json? @db.JsonB
dashboardLayout Json? @db.JsonB
columnPreferences Json? @db.JsonB
favoriteProjectIds Json? @db.JsonB // string[] of project IDs
lastLoginAt DateTime?
lastActiveAt DateTime?
totpSecret String? // Base32 TOTP secret
totpEnabled Boolean @default(false)
lastTotpAt DateTime? // tracks last successful TOTP use to prevent replay
isActive Boolean @default(true)
deletedAt DateTime?
accounts Account[]
sessions Session[]
auditLogs AuditLog[]
vacationsRequested Vacation[] @relation("vacation_requested")
vacationsApproved Vacation[] @relation("vacation_approved")
resource Resource?
notifications Notification[]
tasksAssigned Notification[] @relation("taskAssignee")
notificationsSent Notification[] @relation("notificationSender")
broadcasts NotificationBroadcast[] @relation("broadcastSender")
comments Comment[]
activeSessions ActiveSession[]
reportTemplates ReportTemplate[]
assistantApprovals AssistantApproval[]
mfaBackupCodes MfaBackupCode[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("users")
}
// One row per still-redeemable backup code. We store argon2id(code) — never
// the plaintext — and delete the row on redemption so replay is physically
// impossible. Generation wipes and recreates the whole set (kick-oldest
// strategy not used here: recovery codes are all-or-nothing, a partial
// set is worse than none).
model MfaBackupCode {
id String @id @default(cuid())
userId String
codeHash String
usedAt DateTime?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("mfa_backup_codes")
}
enum AssistantApprovalStatus {
PENDING
APPROVED
CANCELLED
EXPIRED
}
enum ReportTemplateEntity {
RESOURCE
PROJECT
ASSIGNMENT
RESOURCE_MONTH
}
model ReportTemplate {
id String @id @default(cuid())
ownerId String
name String
description String?
entity ReportTemplateEntity
config Json @db.JsonB
isShared Boolean @default(false)
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([ownerId, updatedAt])
@@unique([ownerId, name])
@@map("report_templates")
}
model AssistantApproval {
id String @id @default(cuid())
userId String
conversationId String
toolName String
toolArguments String @db.Text
summary String
status AssistantApprovalStatus @default(PENDING)
approvedAt DateTime?
cancelledAt DateTime?
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, conversationId, status, expiresAt])
@@index([status, expiresAt])
@@map("assistant_approvals")
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@unique([provider, providerAccountId])
@@map("accounts")
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("sessions")
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
@@map("verification_tokens")
}
// ─── Import Staging ───────────────────────────────────────────────────────────
model ImportBatch {
id String @id @default(cuid())
sourceSystem String @default("DISPO_V2")
status ImportBatchStatus @default(DRAFT)
referenceSourceFile String?
planningSourceFile String?
chargeabilitySourceFile String?
notes String?
summary Json @db.JsonB @default("{}")
startedAt DateTime?
stagedAt DateTime?
approvedAt DateTime?
committedAt DateTime?
failedAt DateTime?
stagedResources StagedResource[]
stagedClients StagedClient[]
stagedProjects StagedProject[]
stagedAssignments StagedAssignment[]
stagedVacations StagedVacation[]
stagedAvailabilityRules StagedAvailabilityRule[]
stagedUnresolvedRecords StagedUnresolvedRecord[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status])
@@map("import_batches")
}
model StagedResource {
id String @id @default(cuid())
importBatchId String
status StagedRecordStatus @default(PARSED)
sourceKind DispoImportSourceKind
sourceWorkbook String
sourceSheet String
sourceRow Int
sourceColumn String?
canonicalExternalId String
enterpriseId String?
eid String?
displayName String?
email String?
chapter String?
chapterCode String?
managementLevelGroupName String?
managementLevelName String?
countryCode String?
metroCityName String?
clientUnitName String?
resourceType ResourceType?
chargeabilityTarget Float?
fte Float?
lcrCents Int?
ucrCents Int?
availability Json? @db.JsonB
roleTokens String[] @default([])
warnings String[] @default([])
errorMessage String?
rawPayload Json @db.JsonB
normalizedData Json @db.JsonB @default("{}")
importBatch ImportBatch @relation(fields: [importBatchId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([importBatchId, status])
@@index([canonicalExternalId])
@@map("staged_resources")
}
model StagedClient {
id String @id @default(cuid())
importBatchId String
status StagedRecordStatus @default(PARSED)
sourceKind DispoImportSourceKind
sourceWorkbook String
sourceSheet String
sourceRow Int
sourceColumn String?
clientCode String?
parentClientCode String?
name String
sortOrder Int?
isActive Boolean @default(true)
warnings String[] @default([])
errorMessage String?
rawPayload Json @db.JsonB
normalizedData Json @db.JsonB @default("{}")
importBatch ImportBatch @relation(fields: [importBatchId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([importBatchId, status])
@@index([clientCode])
@@map("staged_clients")
}
model StagedProject {
id String @id @default(cuid())
importBatchId String
status StagedRecordStatus @default(PARSED)
sourceKind DispoImportSourceKind
sourceWorkbook String
sourceSheet String
sourceRow Int
sourceColumn String?
projectKey String
shortCode String?
name String?
clientCode String?
utilizationCategoryCode String?
orderType OrderType?
allocationType AllocationType?
winProbability Int?
isInternal Boolean @default(false)
isTbd Boolean @default(false)
startDate DateTime? @db.Date
endDate DateTime? @db.Date
warnings String[] @default([])
errorMessage String?
rawPayload Json @db.JsonB
normalizedData Json @db.JsonB @default("{}")
importBatch ImportBatch @relation(fields: [importBatchId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([importBatchId, status])
@@index([projectKey])
@@map("staged_projects")
}
model StagedAssignment {
id String @id @default(cuid())
importBatchId String
status StagedRecordStatus @default(PARSED)
sourceKind DispoImportSourceKind
sourceWorkbook String
sourceSheet String
sourceRow Int
sourceColumn String?
resourceExternalId String
projectKey String?
assignmentDate DateTime? @db.Date
startDate DateTime? @db.Date
endDate DateTime? @db.Date
hoursPerDay Float?
percentage Float?
slotFraction Float?
roleToken String?
roleName String?
chapterToken String?
utilizationCategoryCode String?
winProbability Int?
isInternal Boolean @default(false)
isUnassigned Boolean @default(false)
isTbd Boolean @default(false)
warnings String[] @default([])
errorMessage String?
rawPayload Json @db.JsonB
normalizedData Json @db.JsonB @default("{}")
importBatch ImportBatch @relation(fields: [importBatchId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([importBatchId, status])
@@index([resourceExternalId])
@@index([projectKey])
@@index([assignmentDate])
@@map("staged_assignments")
}
model StagedVacation {
id String @id @default(cuid())
importBatchId String
status StagedRecordStatus @default(PARSED)
sourceKind DispoImportSourceKind
sourceWorkbook String
sourceSheet String
sourceRow Int
sourceColumn String?
resourceExternalId String
vacationType VacationType
startDate DateTime @db.Date
endDate DateTime @db.Date
note String?
holidayName String?
isHalfDay Boolean @default(false)
halfDayPart String?
isPublicHoliday Boolean @default(false)
warnings String[] @default([])
errorMessage String?
rawPayload Json @db.JsonB
normalizedData Json @db.JsonB @default("{}")
importBatch ImportBatch @relation(fields: [importBatchId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([importBatchId, status])
@@index([resourceExternalId])
@@index([startDate, endDate])
@@map("staged_vacations")
}
model StagedAvailabilityRule {
id String @id @default(cuid())
importBatchId String
status StagedRecordStatus @default(PARSED)
sourceKind DispoImportSourceKind
sourceWorkbook String
sourceSheet String
sourceRow Int
sourceColumn String?
resourceExternalId String
ruleType String
weekday Int?
effectiveStartDate DateTime? @db.Date
effectiveEndDate DateTime? @db.Date
availableHours Float?
percentage Float?
isResolved Boolean @default(false)
warnings String[] @default([])
errorMessage String?
rawPayload Json @db.JsonB
normalizedData Json @db.JsonB @default("{}")
importBatch ImportBatch @relation(fields: [importBatchId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([importBatchId, status])
@@index([resourceExternalId])
@@map("staged_availability_rules")
}
model StagedUnresolvedRecord {
id String @id @default(cuid())
importBatchId String
status StagedRecordStatus @default(UNRESOLVED)
sourceKind DispoImportSourceKind
sourceWorkbook String
sourceSheet String
sourceRow Int
sourceColumn String?
recordType DispoStagedRecordType
resourceExternalId String?
projectKey String?
message String
resolutionHint String?
warnings String[] @default([])
rawPayload Json @db.JsonB
normalizedData Json @db.JsonB @default("{}")
importBatch ImportBatch @relation(fields: [importBatchId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([importBatchId, status])
@@index([recordType])
@@index([resourceExternalId])
@@index([projectKey])
@@map("staged_unresolved_records")
}
// ─── Country / Metro City ────────────────────────────────────────────────────
model Country {
id String @id @default(cuid())
code String @unique // ISO 3166-1 alpha-2 (DE, IN, ES, ...)
name String
dailyWorkingHours Float @default(8.0)
scheduleRules Json? @db.JsonB // e.g. Spain variable schedule
isActive Boolean @default(true)
metroCities MetroCity[]
holidayCalendars HolidayCalendar[]
resources Resource[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("countries")
}
model MetroCity {
id String @id @default(cuid())
name String
countryId String
country Country @relation(fields: [countryId], references: [id])
resources Resource[]
holidayCalendars HolidayCalendar[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([countryId, name])
@@map("metro_cities")
}
model HolidayCalendar {
id String @id @default(cuid())
name String
scopeType HolidayCalendarScope
countryId String
stateCode String?
metroCityId String?
isActive Boolean @default(true)
priority Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
country Country @relation(fields: [countryId], references: [id], onDelete: Cascade)
metroCity MetroCity? @relation(fields: [metroCityId], references: [id], onDelete: Cascade)
entries HolidayCalendarEntry[]
@@index([countryId, scopeType])
@@index([countryId, stateCode])
@@index([metroCityId])
// Scope uniqueness is enforced via partial unique indexes in SQL migrations.
@@map("holiday_calendars")
}
model HolidayCalendarEntry {
id String @id @default(cuid())
holidayCalendarId String
date DateTime @db.Date
name String
isRecurringAnnual Boolean @default(false)
source String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
holidayCalendar HolidayCalendar @relation(fields: [holidayCalendarId], references: [id], onDelete: Cascade)
@@unique([holidayCalendarId, date])
@@index([date])
@@map("holiday_calendar_entries")
}
// ─── Org Unit Hierarchy ─────────────────────────────────────────────────────
model OrgUnit {
id String @id @default(cuid())
name String
shortName String?
level Int // 5, 6, or 7
parentId String?
parent OrgUnit? @relation("OrgUnitTree", fields: [parentId], references: [id])
children OrgUnit[] @relation("OrgUnitTree")
sortOrder Int @default(0)
isActive Boolean @default(true)
resources Resource[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([parentId, name])
@@index([level])
@@map("org_units")
}
// ─── Utilization Category ───────────────────────────────────────────────────
model UtilizationCategory {
id String @id @default(cuid())
code String @unique // "Chg", "BD", "MD&I", etc.
name String
description String?
sortOrder Int @default(0)
isActive Boolean @default(true)
isDefault Boolean @default(false)
projects Project[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("utilization_categories")
}
// ─── Client / WBS ───────────────────────────────────────────────────────────
model Client {
id String @id @default(cuid())
name String
code String? @unique // optional short code e.g. "BMW"
parentId String?
parent Client? @relation("ClientTree", fields: [parentId], references: [id])
children Client[] @relation("ClientTree")
isActive Boolean @default(true)
deletedAt DateTime?
sortOrder Int @default(0)
tags String[] @default([])
projects Project[]
resourceClientUnits Resource[] @relation("resource_client_unit")
rateCards RateCard[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([parentId, name])
@@map("clients")
}
// ─── Management Level ───────────────────────────────────────────────────────
model ManagementLevelGroup {
id String @id @default(cuid())
name String @unique
targetPercentage Float // e.g. 0.805 for 80.5%
sortOrder Int @default(0)
levels ManagementLevel[]
resources Resource[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("management_level_groups")
}
model ManagementLevel {
id String @id @default(cuid())
name String @unique // e.g. "10-Senior Analyst"
groupId String
group ManagementLevelGroup @relation(fields: [groupId], references: [id])
resources Resource[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([groupId])
@@map("management_levels")
}
// ─── Blueprint System ─────────────────────────────────────────────────────────
model Blueprint {
id String @id @default(cuid())
name String
target BlueprintTarget
description String?
// fieldDefs: BlueprintFieldDefinition[] (see shared/types)
fieldDefs Json @db.JsonB
// defaults: Record<string, unknown>
defaults Json @db.JsonB @default("{}")
// validationRules: BlueprintValidationRule[]
validationRules Json @db.JsonB @default("[]")
// rolePresets: StaffingRequirement[] — default roles for project creation wizard
rolePresets Json @db.JsonB @default("[]")
isActive Boolean @default(true)
deletedAt DateTime?
isGlobal Boolean @default(false)
resources Resource[]
projects Project[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([isActive])
@@index([target])
@@map("blueprints")
}
// ─── Role ─────────────────────────────────────────────────────────────────────
model Role {
id String @id @default(cuid())
name String @unique
description String?
color String? // hex color e.g. "#6366f1"
isActive Boolean @default(true)
deletedAt DateTime?
resourceRoles ResourceRole[]
demandRequirements DemandRequirement[]
assignments Assignment[]
areaResources Resource[] @relation("resource_area_role")
estimateDemandLines EstimateDemandLine[] @relation("estimate_demand_line_role")
rateCardLines RateCardLine[] @relation("rate_card_line_role")
resourceCostSnapshots ResourceCostSnapshot[] @relation("resource_cost_snapshot_role")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("roles")
}
// ─── ResourceRole ─────────────────────────────────────────────────────────────
model ResourceRole {
id String @id @default(cuid())
resourceId String
roleId String
isPrimary Boolean @default(false)
resource Resource @relation(fields: [resourceId], references: [id], onDelete: Cascade)
role Role @relation(fields: [roleId], references: [id], onDelete: Restrict)
@@unique([resourceId, roleId])
@@map("resource_roles")
}
// ─── Resource ─────────────────────────────────────────────────────────────────
model Resource {
id String @id @default(cuid())
eid String @unique // Employee ID, e.g. "EMP-001"
displayName String
email String @unique
chapter String?
// Monetary values in integer cents (e.g. 8500 = 85.00 EUR/h)
lcrCents Int // Labor Cost Rate (internal)
ucrCents Int // Utilization Cost Rate (charge-out)
currency String @default("EUR")
chargeabilityTarget Float @default(80) // Percent 0-100
// availability: WeekdayAvailability
availability Json @db.JsonB
// skills: SkillEntry[]
skills Json @db.JsonB @default("[]")
// dynamicFields: Record<string, unknown>
dynamicFields Json @db.JsonB @default("{}")
blueprintId String?
blueprint Blueprint? @relation(fields: [blueprintId], references: [id])
isActive Boolean @default(true)
deletedAt DateTime?
userId String? @unique
user User? @relation(fields: [userId], references: [id])
// Skill Matrix fields
portfolioUrl String?
roleId String?
aiSummary String?
aiSummaryUpdatedAt DateTime?
skillMatrixUpdatedAt DateTime?
areaRole Role? @relation("resource_area_role", fields: [roleId], references: [id], onDelete: SetNull)
// Value Score fields
valueScore Int?
valueScoreBreakdown Json? @db.JsonB
valueScoreUpdatedAt DateTime?
// Vacation-related
postalCode String?
federalState String? // e.g. "BY" (Bavaria), derived from postalCode or set manually
// Dispo v2: Country / Metro City / Org Unit
countryId String?
country Country? @relation(fields: [countryId], references: [id])
metroCityId String?
metroCity MetroCity? @relation(fields: [metroCityId], references: [id])
orgUnitId String?
orgUnit OrgUnit? @relation(fields: [orgUnitId], references: [id])
// Dispo v2: Management Level
managementLevelGroupId String?
managementLevelGroup ManagementLevelGroup? @relation(fields: [managementLevelGroupId], references: [id])
managementLevelId String?
managementLevel ManagementLevel? @relation(fields: [managementLevelId], references: [id])
// Dispo v2: Resource classification
resourceType ResourceType @default(EMPLOYEE)
chgResponsibility Boolean @default(true) // "Chg Responsibility" status
rolledOff Boolean @default(false)
departed Boolean @default(false)
enterpriseId String? @unique // Enterprise ID e.g. "a.kasperovich" (username, not email)
clientUnitId String?
clientUnit Client? @relation("resource_client_unit", fields: [clientUnitId], references: [id])
fte Float @default(1.0) // at least 2 decimal places
assignments Assignment[]
vacations Vacation[]
resourceRoles ResourceRole[]
vacationEntitlements VacationEntitlement[]
estimateDemandLines EstimateDemandLine[] @relation("estimate_demand_line_resource")
resourceCostSnapshots ResourceCostSnapshot[] @relation("resource_cost_snapshot_resource")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([chapter])
@@index([isActive])
@@index([countryId])
@@index([orgUnitId])
@@index([resourceType])
@@index([managementLevelGroupId])
@@index([blueprintId])
@@index([roleId])
@@map("resources")
}
// ─── Project ──────────────────────────────────────────────────────────────────
model Project {
id String @id @default(cuid())
shortCode String @unique // e.g. "PROJ-001"
name String
orderType OrderType
allocationType AllocationType
winProbability Int @default(100) // 0-100
budgetCents Int // Total budget in cents
startDate DateTime @db.Date
endDate DateTime @db.Date
status ProjectStatus @default(DRAFT)
responsiblePerson String?
shoringThreshold Int? @default(55) // Max offshore % before alert (0-100)
onshoreCountryCode String? @default("DE") // Country code considered "onshore"
color String? // Hex color for timeline display, e.g. "#3b82f6"
coverImageUrl String? @db.Text // Base64 data-URL for project cover art
coverFocusY Int @default(50) // Vertical focus point 0-100 (% from top)
coverAiGenerated Boolean @default(false) // EGAI 4.3.1.3 — true when cover was AI-generated
// staffingReqs: StaffingRequirement[]
staffingReqs Json @db.JsonB @default("[]")
// dynamicFields: Record<string, unknown>
dynamicFields Json @db.JsonB @default("{}")
blueprintId String?
blueprint Blueprint? @relation(fields: [blueprintId], references: [id])
// Dispo v2: Utilization Category + Client
utilizationCategoryId String?
utilizationCategory UtilizationCategory? @relation(fields: [utilizationCategoryId], references: [id])
clientId String?
client Client? @relation(fields: [clientId], references: [id])
demandRequirements DemandRequirement[]
assignments Assignment[]
estimates Estimate[]
calculationRules CalculationRule[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status])
@@index([startDate, endDate])
@@index([orderType])
@@index([clientId])
@@map("projects")
}
// ─── Estimating ──────────────────────────────────────────────────────────────
model Estimate {
id String @id @default(cuid())
projectId String?
name String
opportunityId String?
baseCurrency String @default("EUR")
status EstimateStatus @default(DRAFT)
latestVersionNumber Int @default(1)
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
versions EstimateVersion[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([projectId])
@@index([status])
@@index([opportunityId])
@@map("estimates")
}
model EstimateVersion {
id String @id @default(cuid())
estimateId String
versionNumber Int
label String?
status EstimateVersionStatus @default(WORKING)
notes String?
lockedAt DateTime?
projectSnapshot Json @db.JsonB @default("{}")
commercialTerms Json? @db.JsonB
estimate Estimate @relation(fields: [estimateId], references: [id], onDelete: Cascade)
assumptions EstimateAssumption[]
scopeItems ScopeItem[]
demandLines EstimateDemandLine[]
resourceSnapshots ResourceCostSnapshot[]
metrics EstimateMetric[]
exports EstimateExport[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([estimateId, versionNumber])
@@index([status])
@@map("estimate_versions")
}
model EstimateAssumption {
id String @id @default(cuid())
estimateVersionId String
category String
key String
label String
valueType String @default("json")
value Json @db.JsonB
sortOrder Int @default(0)
notes String?
estimateVersion EstimateVersion @relation(fields: [estimateVersionId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([estimateVersionId, category, key])
@@index([estimateVersionId, sortOrder])
@@map("estimate_assumptions")
}
model ScopeItem {
id String @id @default(cuid())
estimateVersionId String
sequenceNo Int
scopeType String
packageCode String?
name String
description String?
scene String?
page String?
location String?
assumptionCategory String?
technicalSpec Json @db.JsonB @default("{}")
frameCount Int?
itemCount Float?
unitMode String?
internalComments String?
externalComments String?
sortOrder Int @default(0)
metadata Json @db.JsonB @default("{}")
estimateVersion EstimateVersion @relation(fields: [estimateVersionId], references: [id], onDelete: Cascade)
demandLines EstimateDemandLine[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([estimateVersionId, sortOrder])
@@index([estimateVersionId, sequenceNo])
@@map("scope_items")
}
model EstimateDemandLine {
id String @id @default(cuid())
estimateVersionId String
scopeItemId String?
roleId String?
resourceId String?
lineType String @default("LABOR")
name String
chapter String?
hours Float
days Float?
fte Float?
rateSource String?
costRateCents Int @default(0)
billRateCents Int @default(0)
currency String @default("EUR")
costTotalCents Int @default(0)
priceTotalCents Int @default(0)
monthlySpread Json @db.JsonB @default("{}")
staffingAttributes Json @db.JsonB @default("{}")
metadata Json @db.JsonB @default("{}")
estimateVersion EstimateVersion @relation(fields: [estimateVersionId], references: [id], onDelete: Cascade)
scopeItem ScopeItem? @relation(fields: [scopeItemId], references: [id], onDelete: SetNull)
role Role? @relation("estimate_demand_line_role", fields: [roleId], references: [id], onDelete: SetNull)
resource Resource? @relation("estimate_demand_line_resource", fields: [resourceId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([estimateVersionId])
@@index([scopeItemId])
@@index([roleId])
@@index([resourceId])
@@map("estimate_demand_lines")
}
model RateCard {
id String @id @default(cuid())
name String
currency String @default("EUR")
effectiveFrom DateTime? @db.Date
effectiveTo DateTime? @db.Date
source String?
isActive Boolean @default(true)
clientId String?
client Client? @relation(fields: [clientId], references: [id], onDelete: SetNull)
lines RateCardLine[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([isActive])
@@index([clientId])
@@index([effectiveFrom, effectiveTo])
@@map("rate_cards")
}
model RateCardLine {
id String @id @default(cuid())
rateCardId String
roleId String?
chapter String?
location String?
seniority String?
workType String?
serviceGroup String?
costRateCents Int
billRateCents Int?
machineRateCents Int?
attributes Json @db.JsonB @default("{}")
rateCard RateCard @relation(fields: [rateCardId], references: [id], onDelete: Cascade)
role Role? @relation("rate_card_line_role", fields: [roleId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([rateCardId])
@@index([roleId])
@@map("rate_card_lines")
}
model ResourceCostSnapshot {
id String @id @default(cuid())
estimateVersionId String
resourceId String?
sourceEid String?
displayName String
chapter String?
roleId String?
currency String @default("EUR")
lcrCents Int
ucrCents Int
fte Float?
location String?
country String?
level String?
workType String?
attributes Json @db.JsonB @default("{}")
estimateVersion EstimateVersion @relation(fields: [estimateVersionId], references: [id], onDelete: Cascade)
resource Resource? @relation("resource_cost_snapshot_resource", fields: [resourceId], references: [id], onDelete: SetNull)
role Role? @relation("resource_cost_snapshot_role", fields: [roleId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([estimateVersionId])
@@index([resourceId])
@@map("resource_cost_snapshots")
}
model EstimateMetric {
id String @id @default(cuid())
estimateVersionId String
key String
label String
metricGroup String?
valueDecimal Float
valueCents Int?
currency String?
metadata Json @db.JsonB @default("{}")
estimateVersion EstimateVersion @relation(fields: [estimateVersionId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([estimateVersionId, key])
@@index([estimateVersionId, metricGroup])
@@map("estimate_metrics")
}
model EstimateExport {
id String @id @default(cuid())
estimateVersionId String
format EstimateExportFormat
fileName String
storageKey String?
payload Json? @db.JsonB
estimateVersion EstimateVersion @relation(fields: [estimateVersionId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([estimateVersionId, format])
@@map("estimate_exports")
}
model EffortRuleSet {
id String @id @default(cuid())
name String
description String?
isDefault Boolean @default(false)
rules EffortRule[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([isDefault])
@@map("effort_rule_sets")
}
model EffortRule {
id String @id @default(cuid())
ruleSetId String
scopeType String
discipline String
chapter String?
unitMode String @default("flat")
hoursPerUnit Float
description String?
sortOrder Int @default(0)
ruleSet EffortRuleSet @relation(fields: [ruleSetId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([ruleSetId])
@@index([scopeType])
@@map("effort_rules")
}
// ─── Experience Multipliers ──────────────────────────────────────────────────
model ExperienceMultiplierSet {
id String @id @default(cuid())
name String
description String?
isDefault Boolean @default(false)
rules ExperienceMultiplierRule[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([isDefault])
@@map("experience_multiplier_sets")
}
model ExperienceMultiplierRule {
id String @id @default(cuid())
multiplierSetId String
chapter String?
location String?
level String?
costMultiplier Float @default(1.0)
billMultiplier Float @default(1.0)
shoringRatio Float?
additionalEffortRatio Float?
description String?
sortOrder Int @default(0)
multiplierSet ExperienceMultiplierSet @relation(fields: [multiplierSetId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([multiplierSetId])
@@map("experience_multiplier_rules")
}
// ─── DemandRequirement / Assignment (additive migration target) ─────────────
model DemandRequirement {
id String @id @default(cuid())
projectId String
startDate DateTime @db.Date
endDate DateTime @db.Date
hoursPerDay Float
percentage Float
role String?
roleId String?
headcount Int @default(1)
budgetCents Int @default(0)
status AllocationStatus @default(PROPOSED)
metadata Json @db.JsonB @default("{}")
project Project @relation(fields: [projectId], references: [id])
roleEntity Role? @relation(fields: [roleId], references: [id])
assignments Assignment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([projectId])
@@index([startDate, endDate])
@@index([status])
@@index([projectId, status, startDate])
@@index([roleId])
@@map("demand_requirements")
}
model Assignment {
id String @id @default(cuid())
demandRequirementId String?
resourceId String
projectId String
startDate DateTime @db.Date
endDate DateTime @db.Date
hoursPerDay Float
percentage Float
role String?
roleId String?
dailyCostCents Int
status AllocationStatus @default(PROPOSED)
metadata Json @db.JsonB @default("{}")
overbookingAcknowledged Boolean @default(false)
demandRequirement DemandRequirement? @relation(fields: [demandRequirementId], references: [id], onDelete: SetNull)
resource Resource @relation(fields: [resourceId], references: [id])
project Project @relation(fields: [projectId], references: [id])
roleEntity Role? @relation(fields: [roleId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([resourceId, projectId, startDate, endDate], name: "unique_assignment")
@@index([demandRequirementId])
@@index([resourceId])
@@index([projectId])
@@index([startDate, endDate])
@@index([status])
@@index([resourceId, status, startDate])
@@index([projectId, startDate, endDate])
@@index([status, startDate, endDate])
@@index([projectId, status, startDate, endDate])
@@index([resourceId, status, endDate])
@@index([projectId, status, endDate])
@@index([roleId])
@@map("assignments")
}
// ─── Vacation ─────────────────────────────────────────────────────────────────
model Vacation {
id String @id @default(cuid())
resourceId String
type VacationType
status VacationStatus @default(PENDING)
startDate DateTime @db.Date
endDate DateTime @db.Date
note String?
rejectionReason String?
isHalfDay Boolean @default(false)
halfDayPart String? // "MORNING" | "AFTERNOON"
deductedDays Float?
holidayCountryCode String?
holidayCountryName String?
holidayFederalState String?
holidayMetroCityName String?
holidayCalendarDates Json? @db.JsonB
holidayLegacyPublicHolidayDates Json? @db.JsonB
requestedById String
approvedById String?
approvedAt DateTime?
resource Resource @relation(fields: [resourceId], references: [id])
requestedBy User @relation("vacation_requested", fields: [requestedById], references: [id])
approvedBy User? @relation("vacation_approved", fields: [approvedById], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([resourceId])
@@index([startDate, endDate])
@@index([status])
@@index([resourceId, status, startDate, endDate])
@@index([requestedById])
@@map("vacations")
}
// ─── VacationEntitlement ──────────────────────────────────────────────────────
model VacationEntitlement {
id String @id @default(cuid())
resourceId String
year Int
entitledDays Float @default(28) // annual quota (including carryover)
carryoverDays Float @default(0) // days carried over from previous year
usedDays Float @default(0) // APPROVED ANNUAL+OTHER days consumed (cached)
pendingDays Float @default(0) // PENDING ANNUAL+OTHER days (cached)
resource Resource @relation(fields: [resourceId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([resourceId, year])
@@index([year])
@@map("vacation_entitlements")
}
// ─── Notification Enums ──────────────────────────────────────────────────────
enum NotificationCategory {
NOTIFICATION
REMINDER
TASK
APPROVAL
}
enum NotificationPriority {
LOW
NORMAL
HIGH
URGENT
}
enum TaskStatus {
OPEN
IN_PROGRESS
DONE
DISMISSED
}
// ─── Notification ─────────────────────────────────────────────────────────────
model Notification {
id String @id @default(cuid())
userId String
type String // e.g. "VACATION_REQUESTED", "OVERALLOCATION", "PROJECT_DEADLINE"
title String
body String?
entityId String? // related entity (vacation ID, allocation ID, project ID)
entityType String? // "vacation", "allocation", "project"
readAt DateTime?
createdAt DateTime @default(now())
// Extended fields
category NotificationCategory @default(NOTIFICATION)
priority NotificationPriority @default(NORMAL)
link String?
// Task fields
taskStatus TaskStatus?
taskAction String?
assigneeId String?
dueDate DateTime?
completedAt DateTime?
completedBy String?
// Reminder fields
remindAt DateTime?
recurrence String? // "daily" | "weekly" | "monthly" | null
nextRemindAt DateTime?
// Targeting
sourceId String?
senderId String?
channel String @default("in_app")
updatedAt DateTime @updatedAt
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
assignee User? @relation("taskAssignee", fields: [assigneeId], references: [id])
sender User? @relation("notificationSender", fields: [senderId], references: [id])
@@index([userId, readAt])
@@index([userId, category, taskStatus])
@@index([nextRemindAt])
@@index([assigneeId, taskStatus])
@@index([category, nextRemindAt])
@@index([userId, dueDate])
@@map("notifications")
}
// ─── Notification Broadcast ─────────────────────────────────────────────────
model NotificationBroadcast {
id String @id @default(cuid())
senderId String
title String
body String?
link String?
category NotificationCategory @default(NOTIFICATION)
priority NotificationPriority @default(NORMAL)
channel String @default("in_app")
targetType String // "user" | "role" | "project" | "orgUnit" | "all"
targetValue String?
scheduledAt DateTime?
sentAt DateTime?
recipientCount Int @default(0)
createdAt DateTime @default(now())
sender User @relation("broadcastSender", fields: [senderId], references: [id])
@@index([senderId])
@@index([scheduledAt, sentAt])
@@map("notification_broadcasts")
}
// ─── System Role Configuration ────────────────────────────────────────────────
model SystemRoleConfig {
role SystemRole @id
label String // Display label, e.g. "Manager"
description String? // Optional description of the role
defaultPermissions Json @db.JsonB // PermissionKey[] — default permissions for this role
color String? // Badge color, e.g. "purple", "blue"
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("system_role_configs")
}
// ─── System Settings ──────────────────────────────────────────────────────────
model SystemSettings {
id String @id @default("singleton")
aiProvider String? @default("openai") // "openai" | "azure"
azureOpenAiEndpoint String? // Azure only: base URL e.g. "https://myinstance.cognitiveservices.azure.com"
azureOpenAiDeployment String? // deployment/model name e.g. "gpt-5"
azureOpenAiApiKey String? // API key (plaintext, internal only)
azureApiVersion String? @default("2025-01-01-preview") // Azure API version
aiMaxCompletionTokens Int? @default(2000)
aiTemperature Float? @default(1)
aiSummaryPrompt String? // prompt template; use {role} {chapter} {mainSkills} {topSkills}
scoreWeights Json? @db.JsonB // ValueScoreWeights (5 floats summing to 1.0)
scoreVisibleRoles Json? @db.JsonB // SystemRole[] e.g. ["ADMIN","MANAGER"]
// SMTP / email settings
smtpHost String?
smtpPort Int? @default(587)
smtpUser String?
smtpPassword String?
smtpFrom String?
smtpTls Boolean? @default(true)
// Global viewer-side anonymization
anonymizationEnabled Boolean? @default(false)
anonymizationDomain String? @default("superhartmut.de")
anonymizationSeed String?
anonymizationMode String? @default("global")
anonymizationAliases Json? @db.JsonB
// Vacation defaults
vacationDefaultDays Int? @default(28) // default annual entitlement
// Timeline undo
timelineUndoMaxSteps Int? @default(50) // max undo history depth
// DALL-E image generation (Azure requires separate deployment)
azureDalleDeployment String? // e.g. "dall-e-3" — Azure DALL-E deployment name
azureDalleEndpoint String? // Optional: separate endpoint for DALL-E (if different from chat)
azureDalleApiKey String? // Optional: separate API key for DALL-E
// Gemini image generation
geminiApiKey String?
geminiModel String? @default("gemini-2.5-flash-image")
imageProvider String? @default("dalle") // "dalle" | "gemini"
// Session timeout settings
sessionMaxAge Int? @default(28800) // Absolute timeout in seconds (8h)
sessionIdleTimeout Int? @default(1800) // Idle timeout in seconds (30min)
// Concurrent session limit (kick-oldest strategy)
maxConcurrentSessions Int? @default(3)
// MFA enforcement: roles that are required to have TOTP enabled (JSON array of SystemRole strings)
// e.g. ["ADMIN"] or ["ADMIN","MANAGER"]. Null = no enforcement.
requireMfaForRoles Json? @db.JsonB
updatedAt DateTime @updatedAt
@@map("system_settings")
}
// ─── Active Session Registry (JWT session tracking) ──────────────────────────
model ActiveSession {
id String @id @default(cuid())
userId String
jti String @unique // JWT ID — unique per token
createdAt DateTime @default(now())
lastSeenAt DateTime @default(now())
userAgent String?
ipAddress String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, createdAt])
@@map("active_sessions")
}
// ─── Calculation Rules ────────────────────────────────────────────────────────
model CalculationRule {
id String @id @default(cuid())
name String
description String?
// ── Matching ──
triggerType AbsenceTrigger
projectId String?
orderType OrderType?
// ── Effects ──
costEffect CostEffect
costReductionPercent Int? // only for REDUCE (0-100)
chargeabilityEffect ChargeabilityEffect
// ── Ordering ──
priority Int @default(0)
isActive Boolean @default(true)
project Project? @relation(fields: [projectId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([triggerType, isActive])
@@index([projectId])
@@map("calculation_rules")
}
// ─── Comment ─────────────────────────────────────────────────────────────────
model Comment {
id String @id @default(cuid())
entityType String // "estimate", "estimate_version", "scope_item", "demand_line"
entityId String
parentId String? // for replies
authorId String
body String @db.Text
mentions String[] // user IDs mentioned
resolved Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
author User @relation(fields: [authorId], references: [id])
parent Comment? @relation("CommentReplies", fields: [parentId], references: [id])
replies Comment[] @relation("CommentReplies")
@@index([entityType, entityId])
@@index([authorId])
@@index([parentId])
@@map("comments")
}
// ─── Audit Log ────────────────────────────────────────────────────────────────
model AuditLog {
id String @id @default(cuid())
entityType String // "Resource" | "Project" | "Allocation" | "Blueprint"
entityId String
action AuditAction
userId String?
user User? @relation(fields: [userId], references: [id])
// changes: { before: Record<string, unknown>, after: Record<string, unknown> }
changes Json @db.JsonB
createdAt DateTime @default(now())
source String? // "ui" | "api" | "ai" | "import" | "cron"
entityName String? // Human-readable name e.g. "Porsche Taycan Project"
summary String? // One-liner: "Changed status from DRAFT to ACTIVE"
@@index([entityType, entityId])
@@index([userId])
@@index([createdAt])
@@index([entityType, createdAt])
@@map("audit_logs")
}
// ─── Webhook ──────────────────────────────────────────────────────────────────
model Webhook {
id String @id @default(cuid())
name String
url String
secret String? // HMAC signing secret
events String[] // ["allocation.created", "project.status_changed", etc.]
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("webhooks")
}
// ─── Invite Token ─────────────────────────────────────────────────────────────
model InviteToken {
id String @id @default(cuid())
email String
role SystemRole @default(USER)
token String @unique
expiresAt DateTime
usedAt DateTime?
createdById String // userId of the inviting admin
createdAt DateTime @default(now())
@@index([token])
@@index([email])
@@map("invite_tokens")
}
model PasswordResetToken {
id String @id @default(cuid())
email String
token String @unique
expiresAt DateTime
usedAt DateTime?
createdAt DateTime @default(now())
@@index([token])
@@index([email])
@@map("password_reset_tokens")
}