// Craftvia — Prisma-Schema (Fundament)
//
// Enthält ausschließlich die Plattform-Grundlage: Mandanten, Identitäten/Mitgliedschaften,
// RBAC, Audit-Log, Mail, Auth-Tokens, Backup/DSGVO. Die Craftvia-Fachmodelle (Kunden,
// Objekte, Aufträge, …) liefert die Architektur separat (docs/craftvia/ARCHITEKTUR.md).
//
// Multi-Tenant-Regel: Jede fachliche Tabelle trägt tenantId. Zugriff nur über den
// zentralen Tenant-Guard (src/server/db.ts); zusätzlich Postgres RLS.
// Neue Tenant-Tabelle ⇒ in der Migration `SELECT enable_tenant_rls('
');` aufrufen
// UND das Modell in beide TENANT_MODELS-Listen (src/server/db.ts, src/server/backup/topology.ts)
// eintragen — siehe docs/craftvia/MIGRATIONS.md.
generator client {
provider = "prisma-client-js"
previewFeatures = ["postgresqlExtensions"]
}
datasource db {
provider = "postgresql"
extensions = [pgvector(map: "vector")]
}
enum TenantStatus {
ACTIVE
SUSPENDED
ARCHIVED
}
enum UserStatus {
ACTIVE
INVITED
LOCKED
DEACTIVATED
}
model Tenant {
id String @id @default(cuid())
name String
slug String @unique
short String?
sector String?
status TenantStatus @default(ACTIVE)
config Json @default("{}")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
users User[]
roles Role[]
auditLogs AuditLog[]
settings TenantSettings?
modules TenantModule[]
@@map("tenants")
}
// Mandanten-Einstellungen: Unternehmensdaten, Branding, Sicherheits-Policy.
model TenantSettings {
id String @id @default(cuid())
tenantId String @unique @map("tenant_id")
orgName String @map("org_name")
orgShort String? @map("org_short")
address String?
phone String?
email String?
sector String?
// Platzhalter für das Mandanten-Logo im Objektspeicher (Upload folgt).
logoKey String? @map("logo_key")
accent String?
locale String @default("de")
timezone String @default("Europe/Berlin")
securityPolicy Json @default("{}") @map("security_policy") // pw/mfa/session
smtp Json @default("{}")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@index([tenantId])
@@map("tenant_settings")
}
// Modul-Freischaltung je Mandant (Feature-Toggle) — gated Navigation, API und Daten
model TenantModule {
id String @id @default(cuid())
tenantId String @map("tenant_id")
moduleKey String @map("module_key")
enabled Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@unique([tenantId, moduleKey])
@@index([tenantId])
@@map("tenant_modules")
}
// Zentrale, GLOBALE Anmelde-Identität einer Person. E-Mail/Passwort/MFA leben hier;
// die per-Mandant-Zeile `User` ist die "Mitgliedschaft" und verweist per identityId hierher.
// - Identity ist GLOBAL: KEIN tenant_id, NICHT in TENANT_MODELS, KEINE RLS-Policy.
// Lookup läuft über den Owner-`prisma`-Client (Login).
model Identity {
id String @id @default(cuid())
email String @unique
passwordHash String @map("password_hash")
// Erzwungener Passwortwechsel beim nächsten Login (Initial-/Reset-Passwort).
mustChangePassword Boolean @default(false) @map("must_change_password")
// Optionale TOTP-MFA der Identity. recoveryCodes = Argon2id-Hashes (F-17).
mfaSecret String? @map("mfa_secret")
mfaEnrolledAt DateTime? @map("mfa_enrolled_at")
recoveryCodes Json @default("[]") @map("recovery_codes")
// F-17: zuletzt akzeptierter TOTP-Zeitschritt (RFC 6238) — Replay-Schutz.
lastTotpStep BigInt? @map("last_totp_step")
// Brute-Force-Schutz Login (F-05): Fehlversuchszähler + Sperre bis.
failedLogins Int @default(0) @map("failed_logins")
lockedUntil DateTime? @map("locked_until")
// SEC2: globaler Session-Kill-Switch. Ein Token, dessen `iat` davor liegt, gilt als ungültig.
sessionsValidAfter DateTime? @map("sessions_valid_after")
status String @default("ACTIVE") // ACTIVE | LOCKED | DISABLED
// Persönliche UI-Sprache der Person (folgt der Identity über alle Mandanten).
uiLocale String @default("de") @map("ui_locale") // de | en
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
memberships User[]
// Passkeys sind identitäts-, nicht mandantengebunden.
webauthnCredentials WebAuthnCredential[]
@@map("identities")
}
// Mitgliedschaft einer Identity in einem Mandanten.
model User {
id String @id @default(cuid())
tenantId String @map("tenant_id")
identityId String @map("identity_id")
email String // Denormalisierung (Anzeige/Filter, Upsert-Key tenantId_email)
name String
status UserStatus @default(ACTIVE)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
tenant Tenant @relation(fields: [tenantId], references: [id])
identity Identity @relation(fields: [identityId], references: [id])
userRoles UserRole[]
teamsLed Team[] @relation("TeamLeader")
teamMemberships TeamMember[] @relation("TeamMemberships")
workOrdersLed WorkOrder[] @relation("WorkOrderTeamLead")
workOrderAssignments WorkOrderAssignee[] @relation("WorkOrderAssignments")
workSessions WorkSession[] @relation("WorkSessionUser")
notifications Notification[] @relation("UserNotifications")
@@unique([tenantId, email])
// Eine Person (Identity) hat je Mandant höchstens EINE Mitgliedschaft.
@@unique([tenantId, identityId])
@@index([tenantId])
@@index([identityId])
@@map("users")
}
// WebAuthn/Passkey-Credential einer GLOBALEN Identity (2. Faktor, Alternative zu TOTP).
// KEIN tenant_id, NICHT in TENANT_MODELS, keine RLS. credentialId/publicKey base64url.
model WebAuthnCredential {
id String @id @default(cuid())
identityId String @map("identity_id")
credentialId String @unique @map("credential_id")
publicKey String @map("public_key")
counter BigInt @default(0)
transports String[] @default([])
deviceName String? @map("device_name")
createdAt DateTime @default(now()) @map("created_at")
lastUsedAt DateTime? @map("last_used_at")
identity Identity @relation(fields: [identityId], references: [id], onDelete: Cascade)
@@index([identityId])
@@map("webauthn_credentials")
}
model Role {
id String @id @default(cuid())
tenantId String @map("tenant_id")
key String // z. B. tenant-admin, backoffice, team-lead, technician
name String
tenant Tenant @relation(fields: [tenantId], references: [id])
userRoles UserRole[]
rolePermissions RolePermission[]
@@unique([tenantId, key])
@@index([tenantId])
@@map("roles")
}
model Permission {
id String @id @default(cuid())
key String @unique // z. B. customer:read, work_order:write
rolePermissions RolePermission[]
@@map("permissions")
}
model UserRole {
userId String @map("user_id")
roleId String @map("role_id")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
@@id([userId, roleId])
@@map("user_roles")
}
model RolePermission {
roleId String @map("role_id")
permissionId String @map("permission_id")
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade)
@@id([roleId, permissionId])
@@map("role_permissions")
}
// Plattform-Administratoren (Betreiber). Getrennter Store OHNE tenant_id, eigener
// Login (/platform/login), TOTP-MFA + Recovery-Codes. Kein Zugriff auf Mandanten-Fachdaten.
model PlatformAdmin {
id String @id @default(cuid())
email String @unique
passwordHash String @map("password_hash")
name String
status String @default("ACTIVE") // ACTIVE | LOCKED | DISABLED
// SEC4: Plattform-Rolle. "full" = Voll-Admin (darf verwalten), "readonly" = nur lesen.
role String @default("full") @map("role") // full | readonly
mfaSecret String? @map("mfa_secret")
mfaEnrolledAt DateTime? @map("mfa_enrolled_at")
recoveryCodes Json @default("[]") @map("recovery_codes")
failedLogins Int @default(0) @map("failed_logins")
lockedUntil DateTime? @map("locked_until")
lastTotpStep BigInt? @map("last_totp_step")
lastLoginAt DateTime? @map("last_login_at")
sessionsValidAfter DateTime? @map("sessions_valid_after")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("platform_admins")
}
// Plattformweite Betriebseinstellungen (Singleton).
model PlatformSetting {
id String @id @default("singleton")
mfaRequired Boolean @default(false) @map("mfa_required")
// Backup-Zielspeicher. Präzedenz zur Laufzeit: DB-Config → Env (S3_*/BACKUP_LOCAL_DIR)
// → lokaler Default `.backups`. Das S3-Secret liegt NUR verschlüsselt (secret-crypto).
backupTarget String @default("local") @map("backup_target") // local | s3
backupLocalDir String? @map("backup_local_dir")
backupS3Endpoint String? @map("backup_s3_endpoint")
backupS3Bucket String? @map("backup_s3_bucket")
backupS3Region String? @map("backup_s3_region")
backupS3AccessKey String? @map("backup_s3_access_key")
backupS3SecretKeyEnc String? @map("backup_s3_secret_key_enc")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("platform_settings")
}
model AuditLog {
id String @id @default(cuid())
// Nullable: Plattform-Ereignisse (scope=platform) haben keinen Mandantenbezug.
tenantId String? @map("tenant_id")
scope String @default("tenant") // tenant | platform
actorId String? @map("actor_id")
action String // create | update | delete | login | denied | export | …
entity String // z. B. user, role, tenant
entityId String? @map("entity_id")
before Json?
after Json?
createdAt DateTime @default(now()) @map("created_at")
tenant Tenant? @relation(fields: [tenantId], references: [id])
@@index([tenantId, entity, entityId])
@@index([tenantId, createdAt])
@@map("audit_logs")
}
/// Versandprotokoll jeder Mail (Auditierbarkeit + Idempotenz).
///
/// `tenantId` ist nullable: Plattform-Mails haben keinen Mandantenbezug (scope=platform).
/// `dedupeKey` ist plattformweit unique und verhindert Doppelversand.
/// Bewusst NICHT gespeichert: Mail-Inhalt und alle Token/Secrets.
model MailLog {
id String @id @default(cuid())
tenantId String? @map("tenant_id")
scope String @default("tenant") // tenant | platform
to String
template String
locale String @default("de")
status String @default("pending") // pending | sent | failed | bounced | suppressed
providerMessageId String? @map("provider_message_id")
error String?
attempts Int @default(0)
dedupeKey String? @unique @map("dedupe_key")
createdAt DateTime @default(now()) @map("created_at")
sentAt DateTime? @map("sent_at")
@@index([tenantId, createdAt])
@@index([status])
@@map("mail_logs")
}
/// Benachrichtigungs-Einstellung je Nutzer und Ereignistyp.
///
/// Default ist opt-in: fehlt eine Zeile, gilt `email = true`. Transaktionsmails
/// (Reset, Passwortwechsel) unterliegen dieser Steuerung NICHT.
model NotificationPreference {
id String @id @default(cuid())
tenantId String @map("tenant_id")
userId String @map("user_id")
eventType String @map("event_type") // Ereignistyp der Fachmodule, z. B. work_order_assigned
email Boolean @default(true)
locale String?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@unique([userId, eventType])
@@index([tenantId])
@@map("notification_preferences")
}
/// Einmal-Token für Passwort-Reset, Einladung und E-Mail-Änderung.
///
/// Das Rohtoken steht ausschließlich im Link; in der DB liegt nur sein SHA-256-Hash.
/// `principalType` unterscheidet Mandanten-Nutzer und Plattform-Admins. `tenantId` ist
/// nur bei `tenant_user` gesetzt; Plattform-Zeilen haben `tenantId = null`.
model AuthToken {
id String @id @default(cuid())
principalType String @map("principal_type") // tenant_user | platform_admin
principalId String @map("principal_id")
tenantId String? @map("tenant_id")
type String // password_reset | email_change | invitation
tokenHash String @unique @map("token_hash")
/// Nur bei type=email_change: die zu bestätigende neue Adresse.
newEmail String? @map("new_email")
expiresAt DateTime @map("expires_at")
usedAt DateTime? @map("used_at")
requestIp String? @map("request_ip")
createdAt DateTime @default(now()) @map("created_at")
@@index([principalType, principalId, type])
@@index([expiresAt])
@@map("auth_tokens")
}
// =============================================================================
// Backup/DSGVO — GLOBALE Modelle (kein tenant_id-RLS)
// =============================================================================
/// Löschnachweis (Art. 17 DSGVO). GLOBAL — überdauert eine Mandanten-Löschung.
/// Enthält nur Metadaten, KEINE gelöschten Personendaten selbst.
model DeletionCertificate {
id String @id @default(cuid())
/// tenant | person
scope String
tenantId String? @map("tenant_id")
tenantSlug String? @map("tenant_slug")
subjectIdentityId String? @map("subject_identity_id")
subjectEmail String? @map("subject_email")
actorId String? @map("actor_id")
/// Zeilenzahlen je Modell, die HART gelöscht wurden (JSON: {model: count}).
deletedCounts Json @default("{}") @map("deleted_counts")
/// Zeilenzahlen je Modell, die ANONYMISIERT wurden.
anonymizedCounts Json @default("{}") @map("anonymized_counts")
snapshotId String? @map("snapshot_id")
reason String?
createdAt DateTime @default(now()) @map("created_at")
@@index([tenantId])
@@index([subjectIdentityId])
@@map("deletion_certificates")
}
/// Tombstone/Löschliste. GLOBAL — überlebt einen Tenant-Restore bewusst (nicht in
/// TENANT_MODELS), damit ein alter Snapshot gelöschte PII NICHT zurückbringt.
model TombstoneEntry {
id String @id @default(cuid())
tenantId String @map("tenant_id")
/// Prisma-Modellname der betroffenen Tabelle (z. B. "User").
model String
targetField String @map("target_field")
targetValue String @map("target_value")
/// delete | anonymize
action String
anonymizedFields Json @default("{}") @map("anonymized_fields")
reason String?
createdAt DateTime @default(now()) @map("created_at")
@@unique([tenantId, model, targetValue])
@@index([tenantId])
@@map("tombstone_entries")
}
/// Betreiber-Portal: Status eines Backup-/DSGVO-Jobs. GLOBAL (Plattform-Fähigkeit).
/// Enthält KEINE PII und KEINE Secrets — nur Ausführungs-Metadaten.
model BackupJob {
id String @id @default(cuid())
/// tenant_restore | tenant_export | dsgvo_export
kind String
/// queued | running | done | failed
status String @default("queued")
tenantId String @map("tenant_id")
tenantSlug String? @map("tenant_slug")
subjectIdentityId String? @map("subject_identity_id")
actorId String? @map("actor_id")
snapshotId String? @map("snapshot_id")
params Json @default("{}")
result Json?
error String?
downloadToken String? @unique @map("download_token")
downloadExpiresAt DateTime? @map("download_expires_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([tenantId])
@@index([status])
@@map("backup_jobs")
}
// ============================================================================
// Craftvia domain model (MVP) — appended to prisma/schema.prisma
// Conventions: camelCase fields @map snake_case, tables @@map plural snake_case.
// Every tenant table carries tenantId (plain column, guarded by dbForTenant + RLS)
// and is listed in BOTH TENANT_MODELS lists (src/server/db.ts, backup/topology.ts).
// Soft delete: deletedAt on business records (spec §27.5).
// ============================================================================
// ---------- Numbering / configuration ----------
model NumberSequence {
id String @id @default(cuid())
tenantId String @map("tenant_id")
key String // "customer" | "work_order" | "report" | "emergency"
prefix String @default("")
nextValue Int @default(1) @map("next_value")
padding Int @default(5)
updatedAt DateTime @updatedAt @map("updated_at")
@@unique([tenantId, key])
@@map("number_sequences")
}
model OrderType {
id String @id @default(cuid())
tenantId String @map("tenant_id")
key String // montage | reparatur | wartung | stoerung | notdienst | besichtigung | abnahme | nacharbeit | custom
name String
active Boolean @default(true)
signatureRequired Boolean @default(true) @map("signature_required")
sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
checklistTemplates ChecklistTemplate[]
workOrders WorkOrder[]
@@unique([tenantId, key])
@@map("order_types")
}
model ChecklistTemplate {
id String @id @default(cuid())
tenantId String @map("tenant_id")
orderTypeId String? @map("order_type_id")
name String
active Boolean @default(true)
// items: [{ key, label, required, requiresPhoto }]
items Json @default("[]")
// requiredPhotos: [{ key, label }]
requiredPhotos Json @default("[]") @map("required_photos")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
orderType OrderType? @relation(fields: [orderTypeId], references: [id], onDelete: SetNull)
@@index([tenantId])
@@map("checklist_templates")
}
// ---------- Customers / contacts / sites ----------
enum CustomerStatus {
active
inactive
provisional // "Vorläufig – Prüfung durch Backoffice erforderlich"
merged
}
model Customer {
id String @id @default(cuid())
tenantId String @map("tenant_id")
customerNumber String? @map("customer_number")
companyName String? @map("company_name")
salutation String?
firstName String? @map("first_name")
lastName String? @map("last_name")
street String?
houseNumber String? @map("house_number")
postalCode String? @map("postal_code")
city String?
country String @default("DE")
phone String?
mobile String?
email String?
notes String?
billingNotes String? @map("billing_notes")
status CustomerStatus @default(active)
isProvisional Boolean @default(false) @map("is_provisional")
mergedIntoId String? @map("merged_into_id")
createdById String? @map("created_by_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
contacts Contact[]
sites Site[]
workOrders WorkOrder[]
documents Document[]
@@unique([tenantId, customerNumber])
@@index([tenantId, status])
@@index([tenantId, companyName])
@@index([tenantId, lastName])
@@map("customers")
}
model Contact {
id String @id @default(cuid())
tenantId String @map("tenant_id")
customerId String @map("customer_id")
name String
role String? // Funktion
phone String?
mobile String?
email String?
preferredChannel String? @map("preferred_channel") // phone | mobile | email
notes String?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
sites Site[]
workOrders WorkOrder[]
@@index([tenantId, customerId])
@@map("contacts")
}
enum SiteStatus {
active
inactive
provisional
}
model Site {
id String @id @default(cuid())
tenantId String @map("tenant_id")
customerId String @map("customer_id")
name String
street String?
houseNumber String? @map("house_number")
postalCode String? @map("postal_code")
city String?
country String @default("DE")
contactId String? @map("contact_id")
onSiteContact String? @map("on_site_contact") // free text if no Contact
phone String?
accessNotes String? @map("access_notes")
parkingNotes String? @map("parking_notes")
safetyNotes String? @map("safety_notes")
technicalNotes String? @map("technical_notes")
status SiteStatus @default(active)
latitude Float?
longitude Float?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
customer Customer @relation(fields: [customerId], references: [id], onDelete: Restrict)
contact Contact? @relation(fields: [contactId], references: [id], onDelete: SetNull)
workOrders WorkOrder[]
documents Document[]
@@index([tenantId, customerId])
@@map("sites")
}
// ---------- Teams ----------
enum TeamStatus {
active
inactive
}
model Team {
id String @id @default(cuid())
tenantId String @map("tenant_id")
name String
leaderUserId String? @map("leader_user_id")
status TeamStatus @default(active)
phone String?
vehicle String?
area String?
notes String?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
leader User? @relation("TeamLeader", fields: [leaderUserId], references: [id], onDelete: SetNull)
members TeamMember[]
workOrders WorkOrder[]
@@unique([tenantId, name])
@@map("teams")
}
model TeamMember {
id String @id @default(cuid())
tenantId String @map("tenant_id")
teamId String @map("team_id")
userId String @map("user_id")
validFrom DateTime @default(now()) @map("valid_from")
validTo DateTime? @map("valid_to")
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
user User @relation("TeamMemberships", fields: [userId], references: [id], onDelete: Cascade)
@@index([tenantId, userId])
@@index([teamId])
@@map("team_members")
}
// ---------- Work orders ----------
enum WorkOrderStatus {
draft
review_required
planned
assigned
accepted
en_route
in_progress
paused
waiting_material
daily_report_created
technically_completed
signature_pending
in_review
released_for_billing
billed
cancelled
}
enum WorkOrderPriority {
low
normal
high
urgent
}
model WorkOrder {
id String @id @default(cuid())
tenantId String @map("tenant_id")
number String // internal, from NumberSequence "work_order"
externalOrderNumber String? @map("external_order_number")
offerNumber String? @map("offer_number")
customerId String @map("customer_id")
siteId String? @map("site_id")
contactId String? @map("contact_id")
orderTypeId String? @map("order_type_id")
priority WorkOrderPriority @default(normal)
status WorkOrderStatus @default(draft)
title String
description String?
scope String? // Leistungsumfang
plannedStart DateTime? @map("planned_start")
plannedEnd DateTime? @map("planned_end")
assignedTeamId String? @map("assigned_team_id")
teamLeadUserId String? @map("team_lead_user_id")
signatureRequired Boolean @default(true) @map("signature_required")
billingType String? @map("billing_type") // fixed | time_material | maintenance_contract | warranty
internalNotes String? @map("internal_notes")
technicianNotes String? @map("technician_notes")
isEmergency Boolean @default(false) @map("is_emergency")
emergencyReason String? @map("emergency_reason")
sourceImportId String? @unique @map("source_import_id")
followUpWork String? @map("follow_up_work") // offene Folgearbeiten (aggregiert)
// incremented on every mutation — used by offline sync conflict detection
version Int @default(1)
createdById String? @map("created_by_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
customer Customer @relation(fields: [customerId], references: [id], onDelete: Restrict)
site Site? @relation(fields: [siteId], references: [id], onDelete: SetNull)
contact Contact? @relation(fields: [contactId], references: [id], onDelete: SetNull)
orderType OrderType? @relation(fields: [orderTypeId], references: [id], onDelete: SetNull)
team Team? @relation(fields: [assignedTeamId], references: [id], onDelete: SetNull)
teamLead User? @relation("WorkOrderTeamLead", fields: [teamLeadUserId], references: [id], onDelete: SetNull)
sourceImport ImportJob? @relation("ImportCreatedOrder", fields: [sourceImportId], references: [id], onDelete: SetNull)
assignees WorkOrderAssignee[]
statusHistory WorkOrderStatusChange[]
checklistItems ChecklistItem[]
photoRequirements PhotoRequirement[]
materialPlans MaterialPlan[]
materialUsages MaterialUsage[]
workSessions WorkSession[]
notes ActivityNote[]
photos Photo[]
voiceNotes VoiceNote[]
reports Report[]
documents Document[]
@@unique([tenantId, number])
@@index([tenantId, status])
@@index([tenantId, assignedTeamId, status])
@@index([tenantId, plannedStart])
@@index([tenantId, customerId])
@@index([tenantId, siteId])
@@map("work_orders")
}
model WorkOrderAssignee {
id String @id @default(cuid())
tenantId String @map("tenant_id")
workOrderId String @map("work_order_id")
userId String @map("user_id")
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
user User @relation("WorkOrderAssignments", fields: [userId], references: [id], onDelete: Cascade)
@@unique([workOrderId, userId])
@@index([tenantId, userId])
@@map("work_order_assignees")
}
model WorkOrderStatusChange {
id String @id @default(cuid())
tenantId String @map("tenant_id")
workOrderId String @map("work_order_id")
fromStatus WorkOrderStatus? @map("from_status")
toStatus WorkOrderStatus @map("to_status")
actorId String? @map("actor_id")
reason String?
createdAt DateTime @default(now()) @map("created_at")
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
@@index([tenantId, workOrderId])
@@map("work_order_status_changes")
}
model ChecklistItem {
id String @id @default(cuid())
tenantId String @map("tenant_id")
workOrderId String @map("work_order_id")
key String
label String
required Boolean @default(false)
requiresPhoto Boolean @default(false) @map("requires_photo")
sortOrder Int @default(0) @map("sort_order")
checked Boolean @default(false)
checkedById String? @map("checked_by_id")
checkedAt DateTime? @map("checked_at")
comment String?
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
photos Photo[]
@@index([tenantId, workOrderId])
@@map("checklist_items")
}
model PhotoRequirement {
id String @id @default(cuid())
tenantId String @map("tenant_id")
workOrderId String @map("work_order_id")
key String // ausgangszustand | typenschild | leitungsverlauf | zwischenschritt | fertige_montage | funktionspruefung | arbeitsbereich_abschluss | custom
label String
sortOrder Int @default(0) @map("sort_order")
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
photos Photo[]
@@unique([workOrderId, key])
@@index([tenantId])
@@map("photo_requirements")
}
// ---------- Materials ----------
model MaterialPlan {
id String @id @default(cuid())
tenantId String @map("tenant_id")
workOrderId String @map("work_order_id")
name String
articleNumber String? @map("article_number")
plannedQuantity Decimal @map("planned_quantity") @db.Decimal(12, 3)
unit String
notes String?
sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
usages MaterialUsage[]
@@index([tenantId, workOrderId])
@@map("material_plans")
}
enum MaterialUsageStatus {
fully_used
partially_used
not_used
additional
}
model MaterialUsage {
id String @id @default(cuid())
tenantId String @map("tenant_id")
workOrderId String @map("work_order_id")
materialPlanId String? @map("material_plan_id")
workSessionId String? @map("work_session_id")
name String
articleNumber String? @map("article_number")
actualQuantity Decimal @map("actual_quantity") @db.Decimal(12, 3)
unit String
usageStatus MaterialUsageStatus @map("usage_status")
deviationReason String? @map("deviation_reason")
notes String?
photoId String? @map("photo_id")
recordedById String? @map("recorded_by_id")
clientId String? @unique @map("client_id") // offline local id
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
materialPlan MaterialPlan? @relation(fields: [materialPlanId], references: [id], onDelete: SetNull)
workSession WorkSession? @relation(fields: [workSessionId], references: [id], onDelete: SetNull)
@@index([tenantId, workOrderId])
@@map("material_usages")
}
// ---------- Field execution ----------
enum WorkSessionStatus {
en_route
running
paused
ended
}
model WorkSession {
id String @id @default(cuid())
tenantId String @map("tenant_id")
workOrderId String @map("work_order_id")
userId String @map("user_id")
teamId String? @map("team_id")
status WorkSessionStatus @default(running)
startedAt DateTime @map("started_at")
endedAt DateTime? @map("ended_at")
startLat Float? @map("start_lat")
startLng Float? @map("start_lng")
startedOffline Boolean @default(false) @map("started_offline")
deviceInfo String? @map("device_info")
clientId String? @unique @map("client_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
user User @relation("WorkSessionUser", fields: [userId], references: [id], onDelete: Restrict)
entries TimeEntry[]
materials MaterialUsage[]
@@index([tenantId, workOrderId])
@@index([tenantId, userId, status])
@@map("work_sessions")
}
enum TimeEntryType {
travel
work
break
material_procurement
return_travel
interruption
}
model TimeEntry {
id String @id @default(cuid())
tenantId String @map("tenant_id")
workSessionId String @map("work_session_id")
userId String @map("user_id")
type TimeEntryType @default(work)
startedAt DateTime @map("started_at")
endedAt DateTime? @map("ended_at")
// manual corrections must be audited (spec §12.2)
corrected Boolean @default(false)
correctionReason String? @map("correction_reason")
correctedById String? @map("corrected_by_id")
clientId String? @unique @map("client_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
workSession WorkSession @relation(fields: [workSessionId], references: [id], onDelete: Cascade)
@@index([tenantId, workSessionId])
@@index([tenantId, userId, startedAt])
@@map("time_entries")
}
enum ActivityNoteKind {
work_done
deviation
problem
additional_work
not_executable
follow_up
recommendation
customer_note
general
}
model ActivityNote {
id String @id @default(cuid())
tenantId String @map("tenant_id")
workOrderId String @map("work_order_id")
authorId String? @map("author_id")
kind ActivityNoteKind @default(general)
text String
voiceNoteId String? @unique @map("voice_note_id")
clientId String? @unique @map("client_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
voiceNote VoiceNote? @relation(fields: [voiceNoteId], references: [id], onDelete: SetNull)
@@index([tenantId, workOrderId])
@@map("activity_notes")
}
// ---------- Files ----------
enum DocumentCategory {
order_confirmation
technical_drawing
floor_plan
wiring_diagram
assembly_instructions
safety_document
product_document
customer_note
work_record
daily_report
completion_report
customer_approval
photo
voice_note
signature
other
}
enum DocumentVisibility {
backoffice_only
team_lead
team
customer_report
}
enum UploadStatus {
pending
uploaded
failed
quarantined
}
model Document {
id String @id @default(cuid())
tenantId String @map("tenant_id")
customerId String? @map("customer_id")
siteId String? @map("site_id")
workOrderId String? @map("work_order_id")
category DocumentCategory
title String?
fileName String @map("file_name")
storageKey String @map("storage_key") // /...
previewKey String? @map("preview_key") // thumbnail
mimeType String @map("mime_type")
fileSize Int @map("file_size")
checksum String // sha256 hex
version Int @default(1)
// groups versions of the same logical document
lineageId String @map("lineage_id")
visibility DocumentVisibility @default(team)
approvalStatus String? @map("approval_status") // draft | approved
uploadStatus UploadStatus @default(uploaded) @map("upload_status")
uploadedById String? @map("uploaded_by_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
site Site? @relation(fields: [siteId], references: [id], onDelete: SetNull)
workOrder WorkOrder? @relation(fields: [workOrderId], references: [id], onDelete: SetNull)
@@unique([lineageId, version])
@@index([tenantId, workOrderId])
@@index([tenantId, siteId])
@@index([tenantId, customerId])
@@map("documents")
}
enum PhotoPhase {
before
during
after
}
model Photo {
id String @id @default(cuid())
tenantId String @map("tenant_id")
workOrderId String @map("work_order_id")
workSessionId String? @map("work_session_id")
documentId String @unique @map("document_id")
checklistItemId String? @map("checklist_item_id")
photoRequirementId String? @map("photo_requirement_id")
phase PhotoPhase?
comment String?
takenAt DateTime @map("taken_at")
latitude Float?
longitude Float?
takenById String? @map("taken_by_id")
includeInReport Boolean @default(true) @map("include_in_report")
clientId String? @unique @map("client_id")
createdAt DateTime @default(now()) @map("created_at")
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
checklistItem ChecklistItem? @relation(fields: [checklistItemId], references: [id], onDelete: SetNull)
photoRequirement PhotoRequirement? @relation(fields: [photoRequirementId], references: [id], onDelete: SetNull)
@@index([tenantId, workOrderId])
@@map("photos")
}
enum TranscriptionStatus {
pending
done
failed
disabled
}
model VoiceNote {
id String @id @default(cuid())
tenantId String @map("tenant_id")
workOrderId String @map("work_order_id")
workSessionId String? @map("work_session_id")
documentId String @unique @map("document_id")
durationSeconds Int? @map("duration_seconds")
transcript String?
transcriptionStatus TranscriptionStatus @default(pending) @map("transcription_status")
transcriptionModel String? @map("transcription_model")
recordedById String? @map("recorded_by_id")
recordedAt DateTime @map("recorded_at")
clientId String? @unique @map("client_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
activityNote ActivityNote?
@@index([tenantId, workOrderId])
@@map("voice_notes")
}
// ---------- Reports & signatures ----------
enum ReportType {
daily
completion
}
enum ReportStatus {
draft // incl. Lotse-Entwurf
submitted // by technician, "Zur Prüfung"
team_approved
approved // final → immutable PDF
rejected // correction requested
superseded
}
model Report {
id String @id @default(cuid())
tenantId String @map("tenant_id")
workOrderId String @map("work_order_id")
type ReportType
reportDate DateTime @map("report_date") @db.Date
version Int @default(1)
// groups versions of the same report
lineageId String @map("lineage_id")
status ReportStatus @default(draft)
// structured snapshot (see src/lib/reports/content.ts ReportContent)
content Json
aiDrafted Boolean @default(false) @map("ai_drafted")
aiGenerationId String? @map("ai_generation_id")
pdfDocumentId String? @unique @map("pdf_document_id")
pdfChecksum String? @map("pdf_checksum")
createdById String? @map("created_by_id")
submittedAt DateTime? @map("submitted_at")
teamApprovedById String? @map("team_approved_by_id")
teamApprovedAt DateTime? @map("team_approved_at")
approvedById String? @map("approved_by_id")
approvedAt DateTime? @map("approved_at")
rejectionReason String? @map("rejection_reason")
clientId String? @unique @map("client_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Restrict)
signature Signature?
@@unique([lineageId, version])
@@index([tenantId, workOrderId])
@@index([tenantId, status])
@@map("reports")
}
enum SignatureOutcome {
signed
customer_absent
refused
later
not_required
}
model Signature {
id String @id @default(cuid())
tenantId String @map("tenant_id")
reportId String @unique @map("report_id")
outcome SignatureOutcome
signerName String? @map("signer_name")
signerRole String? @map("signer_role")
imageDocumentId String? @unique @map("image_document_id") // PNG of the drawn signature
confirmationText String? @map("confirmation_text")
reason String? // required for absent/refused/later
signedAt DateTime @map("signed_at")
capturedById String? @map("captured_by_id")
clientId String? @unique @map("client_id")
createdAt DateTime @default(now()) @map("created_at")
report Report @relation(fields: [reportId], references: [id], onDelete: Cascade)
@@index([tenantId])
@@map("signatures")
}
// ---------- PDF import ----------
enum ImportStatus {
uploaded
processing
review_required // "Entwurf – Prüfung erforderlich"
confirmed
failed
discarded
}
model ImportJob {
id String @id @default(cuid())
tenantId String @map("tenant_id")
documentId String @unique @map("document_id") // original file (kept forever)
status ImportStatus @default(uploaded)
errorMessage String? @map("error_message")
extractedText String? @map("extracted_text")
// [{ field, value, confidence }] + positions — see src/lib/imports/extraction.ts
extraction Json?
extractionVersion String? @map("extraction_version")
extractionModel String? @map("extraction_model")
provider String?
// user corrections: { field: { from, to } }
corrections Json?
// duplicate candidates: [{ customerId, score, reasons[] }]
duplicateCandidates Json? @map("duplicate_candidates")
importedById String? @map("imported_by_id")
confirmedById String? @map("confirmed_by_id")
confirmedAt DateTime? @map("confirmed_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
createdWorkOrder WorkOrder? @relation("ImportCreatedOrder")
@@index([tenantId, status])
@@map("import_jobs")
}
// ---------- Notifications ----------
model Notification {
id String @id @default(cuid())
tenantId String @map("tenant_id")
userId String @map("user_id")
type String // see src/lib/events.ts EVENT_TYPES
title String
message String
entityType String? @map("entity_type")
entityId String? @map("entity_id")
link String?
readAt DateTime? @map("read_at")
emailedAt DateTime? @map("emailed_at")
createdAt DateTime @default(now()) @map("created_at")
user User @relation("UserNotifications", fields: [userId], references: [id], onDelete: Cascade)
@@index([tenantId, userId, readAt])
@@map("notifications")
}
// ---------- Offline sync ----------
enum SyncOpStatus {
applied
conflict
rejected
}
model SyncOperation {
id String @id @default(cuid())
tenantId String @map("tenant_id")
userId String @map("user_id")
clientOpId String @map("client_op_id") // idempotency key from device
opType String @map("op_type")
entityType String? @map("entity_type")
entityId String? @map("entity_id")
baseVersion Int? @map("base_version")
payload Json
status SyncOpStatus
result Json?
errorCode String? @map("error_code")
clientCreatedAt DateTime @map("client_created_at")
receivedAt DateTime @default(now()) @map("received_at")
resolvedById String? @map("resolved_by_id")
resolvedAt DateTime? @map("resolved_at")
@@unique([tenantId, clientOpId])
@@index([tenantId, status])
@@map("sync_operations")
}
// ---------- Lotse (AI) ----------
model AiGeneration {
id String @id @default(cuid())
tenantId String @map("tenant_id")
kind String // report_draft | voice_summary | completeness_check | import_extraction
provider String
model String
entityType String? @map("entity_type")
entityId String? @map("entity_id")
input Json?
output Json?
inputTokens Int? @map("input_tokens")
outputTokens Int? @map("output_tokens")
createdById String? @map("created_by_id")
createdAt DateTime @default(now()) @map("created_at")
@@index([tenantId, entityType, entityId])
@@map("ai_generations")
}