Files
craftvia/prisma/schema.prisma
T
msolarczekandClaude Opus 5 5f08df324f L10b Betrieb & Aufräumen: Migrationen clientId je Mandant und KI-Token-Kontingent
- 20260915090000_betrieb_client_id_per_tenant (Aufräumpunkt f, L8 offener Punkt 6): globale
  Unique-Indizes auf client_id (material_usages, work_sessions, time_entries, activity_notes,
  photos, voice_notes, reports, signatures) → @@unique([tenantId, clientId]). Offline-IDs sind nur
  je Mandant eindeutig; ein Replay derselben ID in einem anderen Mandanten scheiterte bisher mit
  internem Fehler. Keine neuen Tabellen.
- 20260915091000_betrieb_ai_token_limit (Aufräumpunkt k): tenant_settings.ai_monthly_token_limit
  (NULL = Plattform-Vorgabe AI_MONTHLY_TOKEN_LIMIT, 0 = unbegrenzt). Keine neue Tabelle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 18:19:19 +02:00

1307 lines
48 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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('<table>');` 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("{}")
// Craftvia §33.2 — tenant mail settings (sender address stays the platform domain for SPF/DKIM).
mailFromName String? @map("mail_from_name")
mailReplyTo String? @map("mail_reply_to")
emergencyRecipients String[] @default([]) @map("emergency_recipients")
billingRecipients String[] @default([]) @map("billing_recipients")
// Lotse (L9): "sie" | "du"; null = neutral without pronouns (Brandbook §9.2)
lotseAddressForm String? @map("lotse_address_form")
// L10b (Spec §31): monthly AI token budget (input + output) per tenant; null = env AI_MONTHLY_TOKEN_LIMIT, 0 = unlimited
aiMonthlyTokenLimit Int? @map("ai_monthly_token_limit")
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?
ipAddress String? @map("ip_address")
userAgent String? @map("user_agent")
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? @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])
@@unique([tenantId, clientId])
@@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? @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])
@@unique([tenantId, clientId])
@@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? @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])
@@unique([tenantId, clientId])
@@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? @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])
@@unique([tenantId, clientId])
@@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") // <tenantId>/...
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? @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])
@@unique([tenantId, clientId])
@@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? @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])
@@unique([tenantId, clientId])
@@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? @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])
@@unique([tenantId, clientId])
@@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? @map("client_id")
createdAt DateTime @default(now()) @map("created_at")
report Report @relation(fields: [reportId], references: [id], onDelete: Cascade)
@@index([tenantId])
@@unique([tenantId, clientId])
@@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")
}