// 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") }