// ApiCore MVP-Datenmodell – abgeleitet aus docs/lastenheft.md Abschnitt 13, 4.2-4.22, 26. // Prinzip: Historie statt Löschen (siehe ARCHITECTURE.md §4) – fachliche Haupttabellen // tragen `status` + `deactivatedAt` statt Hard-Delete. Beziehungen sind relationale FKs, // keine Freitextfelder, damit die Rückverfolgbarkeitskette (Abschnitt 16, 29) abfragbar bleibt. generator client { provider = "prisma-client-js" output = "../generated/prisma" } datasource db { provider = "postgresql" } // --------------------------------------------------------------------------- // Benutzer / Auth / Audit (Abschnitt 3, 14, 15, 16) // --------------------------------------------------------------------------- enum RoleStatus { ACTIVE INACTIVE } model Role { id String @id @default(cuid()) key String @unique name String description String? isSystem Boolean @default(false) status RoleStatus @default(ACTIVE) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt deactivatedAt DateTime? permissions RolePermission[] users User[] @@index([status]) } model Permission { id String @id @default(cuid()) key String @unique resource String action String labelDe String groupDe String sortOrder Int roles RolePermission[] @@unique([resource, action]) @@index([resource]) } model RolePermission { roleId String role Role @relation(fields: [roleId], references: [id], onDelete: Cascade) permissionId String permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade) grantedAt DateTime @default(now()) @@id([roleId, permissionId]) @@index([permissionId]) } model User { id String @id @default(cuid()) email String @unique passwordHash String name String roleId String role Role @relation(fields: [roleId], references: [id]) isActive Boolean @default(true) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt deactivatedAt DateTime? // TOTP-Secret AES-256-GCM-verschluesselt gespeichert (siehe // src/auth/mfa/mfa-crypto.util.ts), nie im Klartext in der DB. mfaEnabled Boolean @default(false) mfaSecret String? // Zuletzt angenommener TOTP-Zeitschritt: derselbe Code (oder ein aelterer) // wird danach nie wieder akzeptiert (Replay-Schutz). mfaLastTimeStep Int? // Fehlversuche (Passwort, MFA-Code, Bestaetigung) seit dem letzten Erfolg; // ab 5 ist das Konto bis authLockedUntil gesperrt. failedAuthAttempts Int @default(0) authLockedUntil DateTime? avatarPath String? // Gesetzt bei jedem Passwortwechsel; JwtAuthGuard verwirft Access-Tokens, // die vor diesem Zeitpunkt ausgestellt wurden (siehe // PermissionResolverService.isTokenValid), damit ein gestohlener Token // nach einer Passwortaenderung nicht bis zu 15 Minuten weiter funktioniert. passwordChangedAt DateTime? // Unveraenderliche Identitaet beim Identity Provider ("|"), // gesetzt bei der ersten Microsoft-Anmeldung. Danach entscheidet nur sie, // nicht mehr der (unverifizierte) E-Mail-Claim, welches Konto sich anmeldet. ssoSubject String? @unique refreshTokens RefreshToken[] inspections Inspection[] varroaMeasurements VarroaMeasurement[] swarmControlChecks SwarmControlCheck[] winterChecks WinterCheck[] treatments Treatment[] feedings Feeding[] harvests Harvest[] extractions Extraction[] phenologyReports PhenologyReport[] honeyMeasurements HoneyMeasurement[] stockMovements StockMovement[] assignedTasks Task[] @relation("TaskAssignee") createdTasks Task[] @relation("TaskCreator") decidedSuggestions Suggestion[] @relation("SuggestionDecider") assignedTaskTemplates TaskTemplate[] @relation("TaskTemplateAssignee") createdTaskTemplates TaskTemplate[] @relation("TaskTemplateCreator") colonyMoves ColonyApiaryHistory[] auditLogs AuditLog[] bottlingBatches BottlingBatch[] productionBatches ProductionBatch[] batchMovements BatchMovement[] rawMaterialLots RawMaterialLot[] notifications Notification[] notificationPreference NotificationPreference? telegramLink TelegramLink? telegramLinkTokens TelegramLinkToken[] notificationDeliveries NotificationDelivery[] queenEvaluations QueenEvaluation[] webhookSubscriptions WebhookSubscription[] documents Document[] honeyBatchRelocations HoneyBatchRelocation[] apiKeys ApiKey[] veterinaryMedicineLots VeterinaryMedicineLot[] veterinaryMedicineMovements VeterinaryMedicineMovement[] authorityNotices AuthorityNotice[] apiaryRestrictions ApiaryRestriction[] complianceAcknowledgements ComplianceAcknowledgement[] @@index([roleId]) } // Schlüssel für Maschinen-Zugriffe (z. B. Wabenhain-Shop-Sync) statt eines // Service-Kontos mit Passwort. Gespeichert wird nur der SHA-256-Hash des // Schlüssels; der Klartext wird genau einmal bei der Erstellung angezeigt. // Ein Schlüssel ist kein Benutzer: er darf nur die Routen, deren // Berechtigung in `scopes` steht (siehe api-keys/api-key-scopes.ts). model ApiKey { id String @id @default(cuid()) name String // Anfang des Schlüssels ("ack_ab12cd34") - zum Wiedererkennen in der // Liste, ohne den Schlüssel selbst zu speichern. prefix String keyHash String @unique scopes String[] createdByUserId String? createdByUser User? @relation(fields: [createdByUserId], references: [id]) createdAt DateTime @default(now()) lastUsedAt DateTime? // Absender-IP der letzten Nutzung - hilft beim Einrichten der Allowlist. lastUsedIp String? // Nur von diesen Adressen/Netzen (IP oder CIDR) gueltig; leer = ueberall. allowedIps String[] @default([]) expiresAt DateTime? revokedAt DateTime? @@index([revokedAt]) } // Einmal-Tokens (SSO-Abschluss, MFA-Zwischenschritt), die schon benutzt // wurden. Ein zweiter Versuch mit derselben jti wird abgelehnt. model ConsumedToken { jti String @id expiresAt DateTime @@index([expiresAt]) } model RefreshToken { id String @id @default(cuid()) userId String user User @relation(fields: [userId], references: [id]) tokenHash String @unique expiresAt DateTime createdAt DateTime @default(now()) revokedAt DateTime? // Anmeldezeitpunkt der Sitzung, an jede rotierte Nachfolge-Token vererbt: // spaetestens SESSION_MAX_AGE danach ist neu anzumelden. sessionStartedAt DateTime @default(now()) @@index([userId]) } enum AuditAction { CREATE UPDATE DEACTIVATE DELETE LOGIN LOGIN_FAILED LOGOUT TOKEN_REFRESHED MFA_ENABLED MFA_DISABLED ACCOUNT_LOCKED PRINT } model AuditLog { id String @id @default(cuid()) entityType String entityId String action AuditAction userId String? user User? @relation(fields: [userId], references: [id]) timestamp DateTime @default(now()) beforeData Json? afterData Json? @@index([entityType, entityId]) @@index([timestamp]) } // --------------------------------------------------------------------------- // Bienenstände (Abschnitt 4.2) // --------------------------------------------------------------------------- enum ApiaryStatus { ACTIVE INACTIVE } model Apiary { id String @id @default(cuid()) name String description String? location String? latitude Float? longitude Float? address String? // Coarse, public-safe place name for storefront/shop output (never the exact address). publicLocationLabel String? status ApiaryStatus @default(ACTIVE) notes String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt deactivatedAt DateTime? colonies Colony[] colonyMoves ColonyApiaryHistory[] harvests Harvest[] treatments Treatment[] rawMaterialLots RawMaterialLot[] phenologyReports PhenologyReport[] phenologyStates ApiaryPhenology[] weatherDays ApiaryWeatherDay[] restrictions ApiaryRestriction[] @@index([status]) } // --------------------------------------------------------------------------- // Völkerverwaltung (Abschnitt 4.3, 4.4) // --------------------------------------------------------------------------- enum ColonyType { PRODUCTION_COLONY NUCLEUS_COLONY YOUNG_COLONY CARETAKER_COLONY MATING_NUCLEUS RESERVE_COLONY } enum ColonyStatus { ACTIVE WEAK CONSPICUOUS QUEENLESS DISSOLVED SOLD DECEASED MERGED } // Fuenfstufige Zustandsskala, geteilt von Brut-, Futter- und Gesundheitsstatus. enum ColonyCondition { EXCELLENT GOOD AVERAGE WEAK CRITICAL } model Colony { id String @id @default(cuid()) code String @unique apiaryId String apiary Apiary @relation(fields: [apiaryId], references: [id]) hiveType String? originText String? colonyType ColonyType status ColonyStatus @default(ACTIVE) strength Int? occupiedFrames Int? broodStatus ColonyCondition? feedStatus ColonyCondition? healthStatus ColonyCondition? notes String? currentQueenId String? @unique currentQueen Queen? @relation("ColonyCurrentQueen", fields: [currentQueenId], references: [id]) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt deactivatedAt DateTime? apiaryHistory ColonyApiaryHistory[] queenHistory QueenColonyHistory[] lineageAsSource ColonyLineage[] @relation("LineageFrom") lineageAsResult ColonyLineage[] @relation("LineageTo") inspections Inspection[] varroaMeasurements VarroaMeasurement[] swarmControlChecks SwarmControlCheck[] winterChecks WinterCheck[] treatments Treatment[] feedings Feeding[] harvests HarvestColony[] queenEvaluations QueenEvaluation[] suggestions Suggestion[] @@index([apiaryId]) @@index([status]) } // Verschiebung Volk <-> Bienenstand, historisch nachvollziehbar (Abschnitt 4.2) model ColonyApiaryHistory { id String @id @default(cuid()) colonyId String colony Colony @relation(fields: [colonyId], references: [id]) apiaryId String apiary Apiary @relation(fields: [apiaryId], references: [id]) movedAt DateTime @default(now()) movedByUserId String? movedByUser User? @relation(fields: [movedByUserId], references: [id]) notes String? @@index([colonyId]) @@index([apiaryId]) } enum LineageType { SPLIT MERGE } // Ableger bilden (SPLIT: from=Muttervolk, to=Ableger) und Völker vereinigen // (MERGE: mehrere from-Zeilen auf denselben to=Zielvolk) – Abschnitt 4.4. enum SplitQueenSource { RAISE_OWN QUEEN_CELL QUEEN } model ColonyLineage { id String @id @default(cuid()) type LineageType fromColonyId String fromColony Colony @relation("LineageFrom", fields: [fromColonyId], references: [id]) toColonyId String toColony Colony @relation("LineageTo", fields: [toColonyId], references: [id]) date DateTime queenSource SplitQueenSource? notes String? createdAt DateTime @default(now()) @@index([fromColonyId]) @@index([toColonyId]) } // --------------------------------------------------------------------------- // Königinnenverwaltung (Abschnitt 4.5) // --------------------------------------------------------------------------- enum QueenStatus { ACTIVE RESERVE UNMATED MATED LOST REPLACED SOLD DECEASED } model Queen { id String @id @default(cuid()) code String @unique hatchYear Int? breed String? origin String? breeder String? markingColor String? markingNumber String? matingType String? matingStatus String? inseminationData String? motherLine String? fatherLine String? motherQueenId String? status QueenStatus @default(ACTIVE) notes String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt deactivatedAt DateTime? motherQueen Queen? @relation("QueenMotherline", fields: [motherQueenId], references: [id]) daughterQueens Queen[] @relation("QueenMotherline") colony Colony? @relation("ColonyCurrentQueen") colonyHistory QueenColonyHistory[] evaluations QueenEvaluation[] @@index([status]) @@index([motherQueenId]) } // --------------------------------------------------------------------------- // Königinnenbewertung (Abschnitt 4.7) – v1.x Modul 31 // --------------------------------------------------------------------------- model QueenEvaluation { id String @id @default(cuid()) queenId String queen Queen @relation(fields: [queenId], references: [id]) colonyId String? colony Colony? @relation(fields: [colonyId], references: [id]) date DateTime colonyStrength Int gentleness Int combBuild Int swarmingTendency Int broodPattern Int honeyYield Int notes String? userId String? user User? @relation(fields: [userId], references: [id]) createdAt DateTime @default(now()) @@index([queenId]) @@index([colonyId]) } // Königinnenwechsel historisch nachvollziehbar (Abschnitt 4.5) model QueenColonyHistory { id String @id @default(cuid()) queenId String queen Queen @relation(fields: [queenId], references: [id]) colonyId String colony Colony @relation(fields: [colonyId], references: [id]) startDate DateTime endDate DateTime? notes String? @@index([queenId]) @@index([colonyId]) } // --------------------------------------------------------------------------- // Durchsichten / digitale Stockkarte (Abschnitt 4.6, 4.7) // --------------------------------------------------------------------------- model Inspection { id String @id @default(cuid()) colonyId String colony Colony @relation(fields: [colonyId], references: [id]) userId String user User @relation(fields: [userId], references: [id]) date DateTime weather String? temperature Float? colonyStrength Int? broodFrames Int? broodAmount String? broodPattern Int? openBroodPresent Boolean? cappedBroodPresent Boolean? eggsPresent Boolean? queenSeen Boolean? queenCellsPresent Boolean? swarmMood Int? gentleness Int? combBehavior Int? feedStatus ColonyCondition? honeySuperStatus String? healthIssues String? varroaIssues String? measures String? notes String? createdAt DateTime @default(now()) tasks Task[] @@index([colonyId, date]) } // --------------------------------------------------------------------------- // Aufgabenmanagement (Abschnitt 4.8) // --------------------------------------------------------------------------- enum TaskPriority { LOW NORMAL HIGH URGENT } enum TaskStatus { OPEN IN_PROGRESS DONE CANCELLED } enum TaskRelatedType { COLONY APIARY QUEEN HONEY_BATCH STORAGE GENERAL } model Task { id String @id @default(cuid()) title String description String? priority TaskPriority @default(NORMAL) dueDate DateTime? status TaskStatus @default(OPEN) assignedUserId String? assignedUser User? @relation("TaskAssignee", fields: [assignedUserId], references: [id]) createdByUserId String? createdByUser User? @relation("TaskCreator", fields: [createdByUserId], references: [id]) relatedType TaskRelatedType @default(GENERAL) relatedId String? sourceInspectionId String? sourceInspection Inspection? @relation(fields: [sourceInspectionId], references: [id]) sourceTemplateId String? sourceTemplate TaskTemplate? @relation(fields: [sourceTemplateId], references: [id]) // Set when an automation rule created the task (not a person). Keeps an // automatic suggestion distinguishable from a planned task, and lets the // rule see that it already left an open task for the same object instead // of piling up a duplicate on every further trigger. sourceAutomationTrigger AutomationTriggerType? // Set when the task was created by accepting a suggestion. sourceSuggestionId String? sourceSuggestion Suggestion? @relation(fields: [sourceSuggestionId], references: [id], onDelete: SetNull) createdAt DateTime @default(now()) completedAt DateTime? @@index([status, dueDate]) @@index([relatedType, relatedId]) @@index([sourceAutomationTrigger, relatedType, relatedId, status]) @@index([sourceSuggestionId]) } // --------------------------------------------------------------------------- // Vorschlaege (T4): Regeln, Ketten und der Saison-Autopilot schlagen vor, der // Imker entscheidet. Uebernehmen legt eine Aufgabe an (Task.sourceSuggestionId). // --------------------------------------------------------------------------- enum SuggestionStatus { OPEN ACCEPTED DISMISSED RESOLVED EXPIRED OBSOLETE } enum SuggestionMode { PLAN DO } enum SuggestionSource { RULE CHAIN AUTOPILOT } enum SuggestionIntent { INSPECT SWARM_CHECK QUEEN_CHECK QUEEN_ACCEPTANCE QUEEN_REPLACE SPLIT_PLAN VARROA_MEASURE VARROA_RECHECK VARROA_TREAT SUMMER_TREATMENT FEED HONEY_SUPER WINTER_CHECK HEALTH_CHECK FOLLOW_UP STOCK_REORDER CLEANUP } model Suggestion { id String @id @default(cuid()) ruleKey AutomationTriggerType source SuggestionSource mode SuggestionMode intent SuggestionIntent status SuggestionStatus @default(OPEN) priority TaskPriority @default(NORMAL) relatedType TaskRelatedType relatedId String? colonyId String? colony Colony? @relation(fields: [colonyId], references: [id]) reasonCode String // Only IDs, numbers and YYYY-MM-DD. evidence Json // e.g. 'varroa:read'; without that permission the suggestion is invisible. requiredPermission String? dueDate DateTime? expiresAt DateTime? // PLAN with several dates: [{ step, offsetDays, windowDays? }] plan Json? // '::'; EXPIRED/RESOLVED clear it. dedupeKey String? @unique sourceEventId String? decidedAt DateTime? decidedByUserId String? decidedByUser User? @relation("SuggestionDecider", fields: [decidedByUserId], references: [id]) decisionNote String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt tasks Task[] @@index([status, colonyId]) @@index([status, dueDate]) @@index([ruleKey, relatedType, relatedId, status]) } enum TaskTemplateRecurrenceType { YEARLY MONTHLY INTERVAL_DAYS } // Wiederkehrende Aufgaben-Vorlagen – erzeugen automatisch eine neue Task, // sobald nextDueDate erreicht ist (siehe TasksService.generateFromTemplates). model TaskTemplate { id String @id @default(cuid()) title String description String? priority TaskPriority @default(NORMAL) assignedUserId String? assignedUser User? @relation("TaskTemplateAssignee", fields: [assignedUserId], references: [id]) createdByUserId String? createdByUser User? @relation("TaskTemplateCreator", fields: [createdByUserId], references: [id]) recurrenceType TaskTemplateRecurrenceType recurrenceMonth Int? recurrenceDayOfMonth Int? intervalDays Int? nextDueDate DateTime active Boolean @default(true) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt generatedTasks Task[] @@index([active, nextDueDate]) } // --------------------------------------------------------------------------- // Varroamanagement (Abschnitt 4.10) // --------------------------------------------------------------------------- enum VarroaMethod { NATURAL_MITE_FALL POWDERED_SUGAR WASHING_METHOD DRONE_BROOD_CONTROL } model VarroaMeasurement { id String @id @default(cuid()) colonyId String colony Colony @relation(fields: [colonyId], references: [id]) userId String user User @relation(fields: [userId], references: [id]) date DateTime method VarroaMethod durationMinutes Int? miteCount Int? sampleBees Int? calculatedValue Float? assessment String? notes String? createdAt DateTime @default(now()) @@index([colonyId, date]) } // --------------------------------------------------------------------------- // Schwarmkontrolle (jenseits Lastenheft: strukturiertes Protokoll für // Weiselzellenkontrollen während der Schwarmzeit) // --------------------------------------------------------------------------- enum SwarmMood { NONE LOW HIGH } enum SwarmControlAction { NONE QUEEN_CELLS_REMOVED ARTIFICIAL_SWARM COLONY_DIVIDED OTHER } model SwarmControlCheck { id String @id @default(cuid()) colonyId String colony Colony @relation(fields: [colonyId], references: [id]) userId String user User @relation(fields: [userId], references: [id]) date DateTime queenCellsFound Int? queenCellsCapped Boolean @default(false) swarmMood SwarmMood @default(NONE) actionTaken SwarmControlAction @default(NONE) notes String? nextCheckDate DateTime? createdAt DateTime @default(now()) @@index([colonyId, date]) } // --------------------------------------------------------------------------- // Ein- und Auswinterung, Winterverluste // --------------------------------------------------------------------------- enum WinterCheckSeason { AUTUMN // Einwinterung SPRING // Auswinterung } enum WinterOutcome { SURVIVED WEAK LOST } // Verlustkategorien des COLOSS-Monitorings: totes/leeres Volk, unloesbares // Koeniginnenproblem, Naturereignis. Bewusst keine Diagnose (Varroa, Virus // ...): die gehoert, wenn ueberhaupt bekannt, in die Notiz. enum ColonyLossCause { DEAD_OR_EMPTY QUEEN_PROBLEM NATURAL_DISASTER } // Eine Einwinterung und eine Auswinterung je Volk und Winter. winterYear ist // das Jahr, in dem der Winter beginnt (Winter 2026/27 = 2026). model WinterCheck { id String @id @default(cuid()) colonyId String colony Colony @relation(fields: [colonyId], references: [id]) userId String user User @relation(fields: [userId], references: [id]) winterYear Int season WinterCheckSeason date DateTime occupiedSeams Int? feedStockKg Float? queenPresent Boolean? outcome WinterOutcome? lossCause ColonyLossCause? notes String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@unique([colonyId, winterYear, season]) @@index([winterYear]) } // --------------------------------------------------------------------------- // Behandlung / Tierarzneimittel (Abschnitt 4.11) // --------------------------------------------------------------------------- model Treatment { id String @id @default(cuid()) colonyId String colony Colony @relation(fields: [colonyId], references: [id]) userId String user User @relation(fields: [userId], references: [id]) date DateTime startDate DateTime? endDate DateTime? product String activeIngredient String? amount String? method String? reason String? batchNumber String? notes String? withdrawalPeriodDays Int? // Bestandsbuch (Art. 108 VO (EU) 2019/6): where the colony stood on the // day of the treatment - kept with the treatment because the colony may // be moved later - and the supplier / purchase proof / prescribing vet. apiaryId String? apiary Apiary? @relation(fields: [apiaryId], references: [id]) supplier String? purchaseProof String? prescribingVet String? // T6: catalogue entry, the pack the dose was taken from and the dose per // colony in the medicine's unit. With a pack, supplier / proof / batch // number / vet are read from the pack, never copied. medicineId String? medicine VeterinaryMedicine? @relation(fields: [medicineId], references: [id], onDelete: Restrict) medicineLotId String? medicineLot VeterinaryMedicineLot? @relation(fields: [medicineLotId], references: [id], onDelete: Restrict) doseQuantity Float? createdAt DateTime @default(now()) medicineMovements VeterinaryMedicineMovement[] @@index([colonyId, date]) @@index([medicineId]) @@index([medicineLotId]) } // --------------------------------------------------------------------------- // Fütterung (Abschnitt 4.12) // --------------------------------------------------------------------------- enum FeedType { SUGAR_WATER FEED_SYRUP FEED_PASTE OWN_HONEY OTHER } model Feeding { id String @id @default(cuid()) colonyId String colony Colony @relation(fields: [colonyId], references: [id]) userId String user User @relation(fields: [userId], references: [id]) date DateTime feedType FeedType product String? amount Float unit String batchNumber String? notes String? createdAt DateTime @default(now()) @@index([colonyId, date]) } // --------------------------------------------------------------------------- // Ernte / Schleuderung / Honigcharge / Rückverfolgbarkeit (Abschnitt 4.13-4.17) // --------------------------------------------------------------------------- model Harvest { id String @id @default(cuid()) apiaryId String apiary Apiary @relation(fields: [apiaryId], references: [id]) userId String user User @relation(fields: [userId], references: [id]) date DateTime honeyType String? honeySuperCount Int? grossWeight Float? netWeight Float? notes String? createdAt DateTime @default(now()) colonies HarvestColony[] extractions ExtractionHarvest[] rawMaterialLots RawMaterialLot[] @@index([apiaryId, date]) } model HarvestColony { id String @id @default(cuid()) harvestId String harvest Harvest @relation(fields: [harvestId], references: [id]) colonyId String colony Colony @relation(fields: [colonyId], references: [id]) @@unique([harvestId, colonyId]) @@index([colonyId]) } model Extraction { id String @id @default(cuid()) date DateTime userId String user User @relation(fields: [userId], references: [id]) amount Float? room String? waterContent Float? temperature Float? notes String? createdAt DateTime @default(now()) harvests ExtractionHarvest[] honeyBatches HoneyBatch[] @@index([date]) } model ExtractionHarvest { id String @id @default(cuid()) extractionId String extraction Extraction @relation(fields: [extractionId], references: [id]) harvestId String harvest Harvest @relation(fields: [harvestId], references: [id]) @@unique([extractionId, harvestId]) @@index([harvestId]) } enum HoneyBatchStatus { RAW EXTRACTED CLARIFIED CREAMED STORED BOTTLED SOLD CONSUMED } model HoneyBatch { id String @id @default(cuid()) batchNumber String @unique extractionId String extraction Extraction @relation(fields: [extractionId], references: [id]) honeyType String? amount Float? // Ausgangsmenge in kg; amount = initialAmount + Summe der Buchungen / 1000. // null = Menge nicht erfasst, dann wird nicht gebucht. initialAmount Float? waterContent Float? storageContainerId String? storageContainer StorageContainer? @relation(fields: [storageContainerId], references: [id]) status HoneyBatchStatus @default(RAW) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt measurements HoneyMeasurement[] bottlingBatches BottlingBatch[] relocations HoneyBatchRelocation[] containerAllocations HoneyBatchContainerAllocation[] movements BatchMovement[] @@index([status]) @@index([extractionId]) } // Honigmessungen (Abschnitt 4.17) – als Teil des HoneyBatch-Moduls im MVP, // siehe ARCHITECTURE.md Annahme 1. model HoneyMeasurement { id String @id @default(cuid()) honeyBatchId String honeyBatch HoneyBatch @relation(fields: [honeyBatchId], references: [id]) userId String user User @relation(fields: [userId], references: [id]) waterContent Float measuredAt DateTime device String? temperature Float? notes String? createdAt DateTime @default(now()) @@index([honeyBatchId]) } // --------------------------------------------------------------------------- // Lagerbehälter / grundlegende Lagerverwaltung (Abschnitt 4.18, 4.21) // --------------------------------------------------------------------------- enum StorageContainerStatus { EMPTY FILLED CLEANING OUT_OF_SERVICE } model StorageContainer { id String @id @default(cuid()) code String @unique containerType String? capacity Float? currentAmount Float? location String? status StorageContainerStatus @default(EMPTY) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt honeyBatches HoneyBatch[] relocationsFrom HoneyBatchRelocation[] @relation("RelocationFrom") relocationsTo HoneyBatchRelocation[] @relation("RelocationTo") batchAllocations HoneyBatchContainerAllocation[] @@index([status]) } // Umlagerungshistorie einer Honigcharge zwischen Lagerbehältern (Abschnitt 26, v1.x Modul 32) model HoneyBatchRelocation { id String @id @default(cuid()) honeyBatchId String honeyBatch HoneyBatch @relation(fields: [honeyBatchId], references: [id]) fromStorageContainerId String? fromStorageContainer StorageContainer? @relation("RelocationFrom", fields: [fromStorageContainerId], references: [id]) toStorageContainerId String toStorageContainer StorageContainer @relation("RelocationTo", fields: [toStorageContainerId], references: [id]) userId String? user User? @relation(fields: [userId], references: [id]) notes String? relocatedAt DateTime @default(now()) createdAt DateTime @default(now()) @@index([honeyBatchId, relocatedAt]) } // Aufteilung einer Honigcharge auf mehrere Lagerbehälter (z.B. 125 kg auf 5 // Eimer à 25 kg) - additiv zum obigen "primären" storageContainerId/ // Umlagern-Fluss auf HoneyBatch, der weiterhin den einfachen Ein-Behälter- // Fall abdeckt; diese Tabelle bildet zusätzlich den Fall ab, dass eine // Charge zu groß für einen einzelnen Behälter ist. @@unique macht das // Zuweisen einer Menge zu einem Behälter ein Upsert (erneutes Zuweisen // überschreibt die Menge, statt einen Duplikat-Eintrag anzulegen). model HoneyBatchContainerAllocation { id String @id @default(cuid()) honeyBatchId String honeyBatch HoneyBatch @relation(fields: [honeyBatchId], references: [id]) storageContainerId String storageContainer StorageContainer @relation(fields: [storageContainerId], references: [id]) amount Float createdAt DateTime @default(now()) updatedAt DateTime @updatedAt // The name the migration created (an older Prisma shortened it). Mapped // explicitly so schema and database agree and no rename migration is due. @@unique([honeyBatchId, storageContainerId], map: "HoneyBatchContainerAllocation_honeyBatchId_storageContaine_key") @@index([storageContainerId]) } enum StockCategory { HONEY CONSUMABLE EQUIPMENT_MATERIAL } model StockItem { id String @id @default(cuid()) name String category StockCategory unit String quantity Float @default(0) minThreshold Float? notes String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt movements StockMovement[] @@index([category]) } enum StockMovementType { IN OUT TRANSFER ADJUSTMENT } model StockMovement { id String @id @default(cuid()) stockItemId String stockItem StockItem @relation(fields: [stockItemId], references: [id]) type StockMovementType quantity Float date DateTime @default(now()) userId String? user User? @relation(fields: [userId], references: [id]) relatedType String? relatedId String? notes String? createdAt DateTime @default(now()) @@index([stockItemId, date]) } // --------------------------------------------------------------------------- // Abfüllungen und Produkte (Abschnitt 4.19, 4.20) – v1.x Modul 23 // --------------------------------------------------------------------------- enum ProductStatus { ACTIVE DISCONTINUED } // Wo der Bestand eines Artikels liegt: Honig je Abfuellung (BottlingBatch), // alles andere (Kerzen, Propolis-Produkte, ...) je Herstellungscharge // (ProductionBatch). Nicht umstellbar, sobald Chargen existieren. enum ProductKind { BOTTLED_HONEY MANUFACTURED } // Vorgabe fuer die Kategorie des Produkts im Webshop (Wabenhain). enum ShopCategory { HONIG KERZEN PROPOLIS SONSTIGES } model Product { id String @id @default(cuid()) name String description String? sku String? @unique packagingSize Float? weight Float? salesUnit String? // Haltbarkeit in Monaten, kein Standardwert (MHD-Vorbelegung bei der Abfuellung). shelfLifeMonths Int? // Bezeichnung des Lebensmittels auf dem Etikett, z. B. "Bluetenhonig" (T6). legalName String? status ProductStatus @default(ACTIVE) kind ProductKind @default(BOTTLED_HONEY) shopCategory ShopCategory @default(HONIG) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt bottlingBatches BottlingBatch[] productionBatches ProductionBatch[] @@index([status]) } // Etikettenvorlage mit {{Platzhalter}}-Text, wird beim Drucken mit den // Daten einer Abfuellung befuellt (siehe LabelTemplatesService.renderLabelsPdf). model LabelTemplate { id String @id @default(cuid()) name String // Array of LabelElement objects (text/placeholder/qrCode/image/line), see // label-templates.service.ts. Drives both the in-browser canvas designer // and the pdfkit print renderer from the same source of truth. elements Json @default("[]") widthMm Float @default(90) heightMm Float @default(35) // A4 sheet layout. columns/rows are nullable: null means "auto-fit as many // as fit" (the original behaviour); a real label sheet product (e.g. a // specific Avery SKU) publishes an exact columns/rows/margin/gap grid, so // setting these lets a template's printed positions match the physical, // pre-die-cut sheet instead of just whatever the naive fit computes. columns Int? rows Int? marginTopMm Float @default(5) marginLeftMm Float @default(5) gapXMm Float @default(2) gapYMm Float @default(2) // Kleine Schnittmarken im Blattrand, eine an jedem Ende jeder // Schnittlinie - zum Anlegen am Papierschneider. Aus bei vorgestanzten // Etikettenbögen, die nicht geschnitten werden. cropMarks Boolean @default(true) // Mindest-x-Hoehe der Pflichtangaben in mm (T6), CHECK 0,5 bis 5. minXHeightMm Float @default(1.2) isDefault Boolean @default(false) active Boolean @default(true) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([active]) } model BottlingBatch { id String @id @default(cuid()) honeyBatchId String honeyBatch HoneyBatch @relation(fields: [honeyBatchId], references: [id]) productId String? product Product? @relation(fields: [productId], references: [id]) date DateTime jarSize Float jarCount Int totalAmount Float bestBeforeDate DateTime lotNumber String @unique userId String? user User? @relation(fields: [userId], references: [id]) createdAt DateTime @default(now()) movements BatchMovement[] causedMovements BatchMovement[] @relation("BatchMovementSourceBottling") @@index([honeyBatchId]) @@index([productId]) } // --------------------------------------------------------------------------- // Herstellungschargen und Bestandsbewegungen (Bestandsabgleich mit dem Shop, // docs/superpowers/specs/2026-10-02-bestandsabgleich-design.md) // --------------------------------------------------------------------------- // Gegenstueck zur Abfuellung fuer Artikel der Art MANUFACTURED (Kerzen, // Propolis-Tinkturen, Salben, ...). Spaeter Ziel eines Herstellungslaufs, der // Rohstoff-Chargen verbraucht. model ProductionBatch { id String @id @default(cuid()) productId String product Product @relation(fields: [productId], references: [id]) lotNumber String @unique date DateTime // Hergestellte Stueckzahl - Ausgangsmenge fuer den Rest. quantity Int bestBeforeDate DateTime? notes String? userId String? user User? @relation(fields: [userId], references: [id]) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt movements BatchMovement[] // Woraus die Charge hergestellt wurde (Rueckverfolgung Rohstoff -> Ware). inputs ProductionInput[] @@index([productId]) } // --------------------------------------------------------------------------- // Rohstoffe (Wachs, Propolis) und ihr Verbrauch in der Herstellung // --------------------------------------------------------------------------- enum RawMaterialKind { BEESWAX PROPOLIS } // Eine Rohstoff-Charge, z. B. "Wachs 2026-01: 4200 g" aus dem Entdeckeln // oder "Propolis 2026-02: 180 g" vom Abkratzen der Gitter. Menge in Gramm; // der Rest folgt wie bei allen Chargen aus dem Bewegungsbuch. model RawMaterialLot { id String @id @default(cuid()) kind RawMaterialKind lotNumber String @unique // Gewinnungsdatum date DateTime // Gewonnene Menge in Gramm - Ausgangsmenge fuer den Rest. quantity Int // Herkunft, optional: Stand und/oder Ernte, aus der der Rohstoff stammt. apiaryId String? apiary Apiary? @relation(fields: [apiaryId], references: [id]) harvestId String? harvest Harvest? @relation(fields: [harvestId], references: [id]) notes String? userId String? user User? @relation(fields: [userId], references: [id]) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt movements BatchMovement[] usedIn ProductionInput[] @@index([kind]) } // Verbrauch einer Rohstoff-Charge in einer Herstellungscharge. Die // Abbuchung selbst steht als CONSUMPTION im Bewegungsbuch des Rohstoffs. model ProductionInput { id String @id @default(cuid()) productionBatchId String productionBatch ProductionBatch @relation(fields: [productionBatchId], references: [id], onDelete: Cascade) rawMaterialLotId String rawMaterialLot RawMaterialLot @relation(fields: [rawMaterialLotId], references: [id], onDelete: Restrict) // Verbrauchte Menge in Gramm. quantity Int createdAt DateTime @default(now()) @@index([productionBatchId]) @@index([rawMaterialLotId]) } enum BatchMovementType { SHOP_SALE SHOP_RETURN WITHDRAWAL CORRECTION // Rohstoff in einer Herstellungscharge verbraucht (minus) bzw. beim // Loeschen einer irrtuemlich angelegten Charge zurueckgegeben (plus). CONSUMPTION // Abfuellung entnimmt Honig aus der Charge (Task 23: Bewegungsbuch) BOTTLING } enum WithdrawalReason { HOFLADEN EIGENBEDARF BRUCH PROBE SONSTIGES } // Bewegungsbuch: jede Bestandsaenderung einer Charge, nie geloescht (Fehler // werden gegengebucht). Rest = Ausgangsmenge + Summe aller quantity. // Genau eine der beiden Chargen-Spalten ist gesetzt (CHECK-Constraint in der // Migration). model BatchMovement { id String @id @default(cuid()) bottlingBatchId String? bottlingBatch BottlingBatch? @relation(fields: [bottlingBatchId], references: [id], onDelete: Restrict) productionBatchId String? productionBatch ProductionBatch? @relation(fields: [productionBatchId], references: [id], onDelete: Restrict) rawMaterialLotId String? rawMaterialLot RawMaterialLot? @relation(fields: [rawMaterialLotId], references: [id], onDelete: Restrict) // Honig: Bewegungsbuch der Honigcharge, quantity dann in Gramm. honeyBatchId String? honeyBatch HoneyBatch? @relation(fields: [honeyBatchId], references: [id], onDelete: Restrict) // Rueckverweis auf die verursachende Abfuellung (BOTTLING). sourceBottlingBatchId String? sourceBottlingBatch BottlingBatch? @relation("BatchMovementSourceBottling", fields: [sourceBottlingBatchId], references: [id], onDelete: SetNull) type BatchMovementType // Mit Vorzeichen: minus = raus, plus = rein. quantity Int reason WithdrawalReason? notes String? shopRequestId String? shopRequest ShopStockRequest? @relation(fields: [shopRequestId], references: [id], onDelete: Restrict) userId String? user User? @relation(fields: [userId], references: [id]) apiKeyId String? createdAt DateTime @default(now()) @@index([bottlingBatchId]) @@index([productionBatchId]) @@index([rawMaterialLotId]) @@index([honeyBatchId]) @@index([sourceBottlingBatchId]) @@index([shopRequestId]) // ERP-Export /bewegungen: sortiert und blättert nach (createdAt, id). @@index([createdAt, id]) } enum ShopStockRequestKind { SALE RETURN } // Eine Buchungsanfrage des Shops. externalId (vom Shop vergeben) macht jede // Anfrage idempotent: dieselbe Anfrage zweimal bucht nur einmal. model ShopStockRequest { id String @id @default(cuid()) externalId String @unique kind ShopStockRequestKind // Ziel: eine Abfuellung (Honig) ODER ein Artikel (MANUFACTURED, FIFO). bottlingBatchId String? productId String? quantity Int reversesExternalId String? apiKeyId String? createdAt DateTime @default(now()) movements BatchMovement[] @@index([reversesExternalId]) } // --------------------------------------------------------------------------- // Inventar (Abschnitt 4.22) – v1.x Modul 24 // --------------------------------------------------------------------------- enum EquipmentCondition { NEW GOOD WORN DEFECTIVE } model Equipment { id String @id @default(cuid()) inventoryNumber String @unique name String manufacturer String? purchaseDate DateTime? condition EquipmentCondition @default(GOOD) location String? maintenanceDate DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([condition]) } // --------------------------------------------------------------------------- // Benachrichtigungssystem (Abschnitt 24) – v1.x Modul 26 // --------------------------------------------------------------------------- enum NotificationPriority { INFO HINWEIS WARNUNG KRITISCH } enum NotificationCategory { TASK_DUE DIGEST SUGGESTION WITHDRAWAL SEASON SYSTEM COMPLIANCE } // Jede Zeile gehoert genau einem Empfaenger (T4: Fan-out mit Rechtepruefung // beim Schreiben). requiredPermissions = Rechte des Empfaengers plus Rechte // des Inhalts; dedupeKey = ":". model Notification { id String @id @default(cuid()) userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) priority NotificationPriority category NotificationCategory message String relatedType String? relatedId String? link String? requiredPermissions String[] @default([]) dedupeKey String? @unique read Boolean @default(false) createdAt DateTime @default(now()) deliveries NotificationDelivery[] @@index([userId, read]) @@index([priority]) } // Einstellungen je Nutzer; Standard beim ersten Lesen angelegt. model NotificationPreference { userId String @id user User @relation(fields: [userId], references: [id], onDelete: Cascade) taskDueEnabled Boolean @default(true) digestEnabled Boolean @default(true) digestTime String @default("06:30") quietFrom String? @default("21:00") quietUntil String? @default("06:30") telegramEnabled Boolean @default(false) telegramCategories NotificationCategory[] @default([TASK_DUE, DIGEST, COMPLIANCE]) telegramDetail Boolean @default(true) updatedAt DateTime @updatedAt } enum NotificationChannel { TELEGRAM } enum NotificationDeliveryStatus { PENDING PROCESSING DELIVERED SKIPPED FAILED } // Chat binding of a user to this deployment's Telegram bot (T4c). One private // chat belongs to exactly one user. model TelegramLink { userId String @id user User @relation(fields: [userId], references: [id], onDelete: Cascade) chatId BigInt @unique telegramFirstName String? linkedAt DateTime @default(now()) } // Single-use link token, valid for 10 minutes; only the SHA-256 is stored. model TelegramLinkToken { id String @id @default(cuid()) userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) tokenHash String @unique expiresAt DateTime consumedAt DateTime? createdAt DateTime @default(now()) @@index([userId]) } // Outbox like WebhookDelivery. The text is not stored: the sender renders it // from the Notification or the templateCode. CHECK (migration): exactly one of // (notificationId with userId) or (templateCode with chatId). model NotificationDelivery { id String @id @default(cuid()) channel NotificationChannel userId String? user User? @relation(fields: [userId], references: [id], onDelete: Cascade) chatId BigInt? notificationId String? notification Notification? @relation(fields: [notificationId], references: [id], onDelete: Cascade) templateCode String? status NotificationDeliveryStatus @default(PENDING) attempts Int @default(0) availableAt DateTime @default(now()) claimedAt DateTime? lastStatusCode Int? lastError String? deliveredAt DateTime? createdAt DateTime @default(now()) @@unique([notificationId, channel]) @@index([status, availableAt]) @@index([userId, createdAt]) @@index([notificationId]) } // One row: update offset and poll lease of the Telegram receiver. model TelegramPollState { id String @id @default("singleton") updateOffset BigInt @default(0) leaseOwner String? leaseUntil DateTime? lastPollAt DateTime? lastError String? } // --------------------------------------------------------------------------- // Webhooks (Abschnitt 27) – v1.x Modul 28 // --------------------------------------------------------------------------- model WebhookSubscription { id String @id @default(cuid()) url String events String[] secret String active Boolean @default(true) createdByUserId String? createdByUser User? @relation(fields: [createdByUserId], references: [id]) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt deliveries WebhookDelivery[] @@index([active]) } // --------------------------------------------------------------------------- // Dokumente und Bilder (Abschnitt 4.28) – v1.x Modul 29 // --------------------------------------------------------------------------- enum DocumentEntityType { APIARY COLONY QUEEN INSPECTION HONEY_BATCH MEDICINE_LOT AUTHORITY_NOTICE APIARY_RESTRICTION } model Document { id String @id @default(cuid()) entityType DocumentEntityType entityId String fileName String mimeType String sizeBytes Int storagePath String uploadedByUserId String? uploadedByUser User? @relation(fields: [uploadedByUserId], references: [id]) createdAt DateTime @default(now()) @@index([entityType, entityId]) } // --------------------------------------------------------------------------- // Automatisierungen (Abschnitt 26) – v1.x Modul 30 // --------------------------------------------------------------------------- // Fest hinterlegte Trigger (Code, nicht nutzerdefiniert) – die Tabelle erlaubt // nur, jeden Trigger zu aktivieren/deaktivieren und seine Schwellenwerte // (config) sowie den Aufgabentext (actionTemplate) anzupassen. enum AutomationTriggerType { QUEENLESS_NO_EGGS VARROA_THRESHOLD FEED_STATUS_CRITICAL SWARM_MOOD_HIGH COLONY_HEALTH_CRITICAL STOCK_ITEM_LOW HONEY_BATCH_NUMBER_FORMAT BOTTLING_LOT_NUMBER_FORMAT TREATMENT_FOLLOW_UP FEEDING_FOLLOW_UP RAW_MATERIAL_LOT_NUMBER_FORMAT PRODUCTION_BATCH_NUMBER_FORMAT RAW_MATERIAL_LOW INSPECTION_INTERVAL QUEEN_CELLS_SWARM_CHECK QUEEN_ACCEPTANCE_CHECK SPLIT_SCHEDULE COLONY_ENDED_CLEANUP WITHDRAWAL_ENDED HONEY_WATER_CONTENT SEASON_AUTOPILOT PHENOLOGY } model AutomationRule { id String @id @default(cuid()) triggerType AutomationTriggerType @unique active Boolean @default(true) config Json? actionTemplate String? // Running counter for the number-format rules (*_NUMBER_FORMAT) - unused // by the threshold-style rules. Kept as // a dedicated column (not inside `config`) so it can be incremented with a // single atomic `UPDATE ... SET sequenceValue = sequenceValue + 1`, // race-safe under concurrent batch/bottling creation. sequenceValue Int @default(0) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt sequences AutomationSequence[] } // Separate counters of one number-format rule, one per scope - e.g. per raw // material kind when the format contains {ART}, so wax and propolis each // count WA-2026-0001, WA-2026-0002, ... and PR-2026-0001, ... on their own. // Without {ART} the rule keeps using AutomationRule.sequenceValue: separate // counters behind an identical prefix would produce duplicate numbers. model AutomationSequence { triggerType AutomationTriggerType rule AutomationRule @relation(fields: [triggerType], references: [triggerType], onDelete: Cascade) scope String value Int @default(0) updatedAt DateTime @updatedAt @@id([triggerType, scope]) } // --------------------------------------------------------------------------- // Website-Einstellungen – Social-Media-Links auf der öffentlichen Herkunftsseite // --------------------------------------------------------------------------- // Singleton row (fixed id) rather than a key/value table: exactly one set of // site-wide social links exists, and a fixed-id upsert is simpler than // enforcing "at most one row" any other way. model SiteSettings { id String @id @default("singleton") youtubeUrl String? instagramUrl String? tiktokUrl String? websiteUrl String? // Name and address of the bee keeper, printed on the Bestandsbuch. Never // part of any public page. holderName String? holderAddress String? // Name/firm and postal address of the food business operator printed on // the label (T6) - the brand, not the bee keeper of the Bestandsbuch. foodBusinessName String? foodBusinessAddress String? // Country of origin printed on labels and shop output. originCountry String @default("Deutschland") updatedAt DateTime @updatedAt } // --------------------------------------------------------------------------- // Backup-Einstellungen – GUI-Override für den automatischen Backup-Zeitplan. // Jede der drei Umgebungsvariablen (BACKUP_STORAGE_PATH/_CRON_SCHEDULE/ // _RETENTION_COUNT) hat Vorrang vor dem hier gespeicherten Wert - diese Zeile // greift nur pro Feld, wenn die jeweilige Env-Variable nicht gesetzt ist. // --------------------------------------------------------------------------- model BackupSettings { id String @id @default("singleton") storagePath String? cronSchedule String? retentionCount Int? updatedAt DateTime @updatedAt } // --------------------------------------------------------------------------- // Kalender-Synchronisation – einseitige Übertragung (ApiCore → M365) von // Kalendereinträgen in den Kalender einer Microsoft-365-Gruppe via Graph API. // Zugangsdaten (Tenant/Client-ID/-Secret) sind bewusst NICHT Teil dieses // Modells - wie bei OIDC_CLIENT_SECRET nur über Env-Variablen (Secret), nie // über die GUI editierbar. groupId ist keine Zugangsdaten-Information und // daher hier GUI-konfigurierbar. // --------------------------------------------------------------------------- enum CalendarSyncSourceType { TASK INSPECTION TREATMENT FEEDING HARVEST QUEEN_ASSIGNMENT WITHDRAWAL } enum CalendarSyncStatus { PENDING SYNCED FAILED SKIPPED } model CalendarSyncSettings { id String @id @default("singleton") enabled Boolean @default(false) groupId String? syncTask Boolean @default(true) syncInspection Boolean @default(true) syncTreatment Boolean @default(true) syncFeeding Boolean @default(true) syncHarvest Boolean @default(true) syncQueenAssignment Boolean @default(true) syncWithdrawal Boolean @default(true) updatedAt DateTime @updatedAt } // Outbox statt synchronem Push direkt im Request-Pfad (anders als das // Webhooks-Modul): Graph-API-Aufrufe sind zu langsam/fehleranfällig // (Token-Refresh, Drosselung, transiente 5xx), um eine Durchsicht-/ // Aufgaben-Erstellung darauf warten zu lassen. Ein periodischer Worker // verarbeitet PENDING-Zeilen und wiederholt fehlgeschlagene Versuche. // @@unique auf [sourceType, sourceId] macht enqueue() ein Upsert: ein // erneutes Enqueue (z. B. nach einer Aufgaben-Änderung) aktualisiert das // bestehende Graph-Event über die gespeicherte graphEventId, statt ein // Duplikat anzulegen. // --------------------------------------------------------------------------- // Idempotenz – schützt Anlege-Anfragen vor doppelter Ausführung, wenn ein // Client dieselbe Anfrage wiederholt (Offline-Warteschlange nach verlorener // Antwort, Netzabbruch, erneutes Absenden). Der Client schickt pro fachlichem // Vorgang einen eindeutigen `Idempotency-Key`; eine Wiederholung bekommt die // gespeicherte erste Antwort statt eines zweiten Datensatzes. Siehe // src/common/idempotency/idempotency.interceptor.ts. // --------------------------------------------------------------------------- enum IdempotencyStatus { PENDING COMPLETED } model IdempotencyRecord { id String @id @default(cuid()) userId String key String // "POST /api/v1/inspections" - a key reused for a different endpoint is // rejected instead of replaying an unrelated response. scope String status IdempotencyStatus @default(PENDING) statusCode Int? responseBody Json? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@unique([userId, key]) @@index([createdAt]) } model CalendarSyncOutboxEntry { id String @id @default(cuid()) sourceType CalendarSyncSourceType sourceId String status CalendarSyncStatus @default(PENDING) graphEventId String? attempts Int @default(0) lastError String? // Set while one worker (pod, cron tick, "Jetzt synchronisieren") writes // this entry to M365, so no second worker creates the same event. A claim // older than the stale timeout belongs to a crashed worker and is retaken. claimedAt DateTime? // Counts every enqueue: a worker only marks the entry SYNCED if nothing // was enqueued since it claimed it - an edit made during the sync is not // lost but synced on the next pass. revision Int @default(0) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@unique([sourceType, sourceId]) @@index([status]) } // --------------------------------------------------------------------------- // Plattform-Rückgrat: Domain-Events (Outbox), Webhook-Zustellung, Job-Läufe, // Throttler-Zähler. Alle fünf Modelle sind vom Backup ausgenommen. // --------------------------------------------------------------------------- enum DomainEventStatus { PENDING PROCESSING DONE FAILED } model DomainEvent { id String @id @default(cuid()) type String aggregateType String aggregateId String payload Json actorUserId String? correlationId String // Nullable @unique instead of a partial index: avoids the migration drift // described in the backbone spec (section 3.5). dedupeKey String? @unique occurredAt DateTime @default(now()) status DomainEventStatus @default(PENDING) attempts Int @default(0) availableAt DateTime @default(now()) claimedAt DateTime? processedAt DateTime? lastError String? deliveries DomainEventDelivery[] @@index([status, availableAt]) @@index([correlationId]) @@index([aggregateType, aggregateId]) } enum DomainEventDeliveryStatus { PENDING DONE FAILED } model DomainEventDelivery { id String @id @default(cuid()) eventId String event DomainEvent @relation(fields: [eventId], references: [id], onDelete: Cascade) handler String status DomainEventDeliveryStatus @default(PENDING) attempts Int @default(0) lastError String? completedAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@unique([eventId, handler]) } enum WebhookDeliveryStatus { PENDING PROCESSING DELIVERED FAILED } model WebhookDelivery { id String @id @default(cuid()) subscriptionId String subscription WebhookSubscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade) event String body String status WebhookDeliveryStatus @default(PENDING) attempts Int @default(0) availableAt DateTime @default(now()) claimedAt DateTime? lastStatusCode Int? lastError String? deliveredAt DateTime? createdAt DateTime @default(now()) @@index([status, availableAt]) @@index([subscriptionId, createdAt]) } enum JobRunStatus { RUNNING SUCCEEDED FAILED } model JobRun { id String @id @default(cuid()) job String status JobRunStatus @default(RUNNING) startedAt DateTime @default(now()) finishedAt DateTime? result Json? error String? @@index([job, startedAt]) @@index([status]) } // UNLOGGED in the migration: no WAL, emptied after a crash - only counters get lost. model ThrottlerHit { key String throttlerName String hits Int expiresAt DateTime blockedUntil DateTime? @@id([key, throttlerName]) @@index([expiresAt]) } // --------------------------------------------------------------------------- // Pflanzenentwicklung und Saisonabschnitt (T4b, Spec 4.2) // --------------------------------------------------------------------------- enum PhenologyPhase { SALWEIDE KIRSCHE APFEL HOLUNDER LINDE TRACHTENDE HERBST WINTER } enum SeasonSegment { WINTERRUHE FRUEHJAHR SCHWARMZEIT SOMMER SPAETSOMMER HERBST } enum PhaseSource { REPORT GTS CALENDAR } // Eigene Meldung per Antippen; zurueckgezogen statt geloescht. model PhenologyReport { id String @id @default(cuid()) // null = alle Staende apiaryId String? apiary Apiary? @relation(fields: [apiaryId], references: [id], onDelete: Restrict) phase PhenologyPhase date DateTime notes String? userId String user User @relation(fields: [userId], references: [id]) revokedAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([date]) @@index([apiaryId, phase, date]) } // Abgeleiteter Zustand, schreibt nur der Job/Handler (nicht im Backup). model ApiaryPhenology { apiaryId String apiary Apiary @relation(fields: [apiaryId], references: [id], onDelete: Cascade) year Int gts Float? gtsThroughDay DateTime? missingDays Int @default(0) estimatedPhase PhenologyPhase? reportedPhase PhenologyPhase? effectivePhase PhenologyPhase? phaseSource PhaseSource? segment SeasonSegment segmentSince DateTime updatedAt DateTime @updatedAt @@id([apiaryId, year]) } // Tagesmittel je Stand, schreibt nur die Worker-Schleife (nicht im Backup). model ApiaryWeatherDay { apiaryId String apiary Apiary @relation(fields: [apiaryId], references: [id], onDelete: Cascade) // Kalendertag Europe/Berlin day DateTime meanTempC Float? // 'forecast' | 'archive' source String fetchedAt DateTime @default(now()) @@id([apiaryId, day]) } // --------------------------------------------------------------------------- // T6 Pruefbereitschaft (docs/superpowers/specs/2026-10-13-t6-compliance-design.md) // --------------------------------------------------------------------------- enum MedicineUnit { ML G PIECE } // Katalog des Imkers. Jeder Wert kommt aus Packungsbeilage oder Rezept und // wird vom Imker eingetragen; der Code liefert keine Arzneimitteldaten // (ARCHITECTURE Annahme 10). model VeterinaryMedicine { id String @id @default(cuid()) name String activeIngredient String? authorisationNumber String? prescriptionOnly Boolean @default(false) // Wartezeit Honig in Tagen, auch 0. withdrawalPeriodDays Int? // Nur aenderbar, solange keine Packung existiert. unit MedicineUnit dosePerColony Float? doseNote String? applicationMethod String? minTemperatureC Float? maxTemperatureC Float? defaultSupplier String? notes String? active Boolean @default(true) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt lots VeterinaryMedicineLot[] treatments Treatment[] @@index([active]) } // Eine gekaufte Packung. Ch.-B., Lieferant, Beleg und Tierarzt stehen hier // einmal und gelten fuer jede Behandlung aus dieser Packung. CHECK quantity > 0. model VeterinaryMedicineLot { id String @id @default(cuid()) // Nur aenderbar, solange keine Bewegung existiert. medicineId String medicine VeterinaryMedicine @relation(fields: [medicineId], references: [id], onDelete: Restrict) batchNumber String? purchasedOn DateTime quantity Float expiresOn DateTime? supplier String? purchaseProof String? prescribingVet String? notes String? userId String? user User? @relation(fields: [userId], references: [id]) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt movements VeterinaryMedicineMovement[] treatments Treatment[] @@index([medicineId, expiresOn]) } enum VeterinaryMedicineMovementType { TREATMENT CORRECTION DISPOSAL } // Bewegungsbuch der Packungen: nie geaendert, nie geloescht; Fehler werden // gegengebucht. Rest = quantity der Packung + Summe aller quantity. // CHECKs: quantity <> 0; (type = 'TREATMENT') = (treatmentId IS NOT NULL). model VeterinaryMedicineMovement { id String @id @default(cuid()) lotId String lot VeterinaryMedicineLot @relation(fields: [lotId], references: [id], onDelete: Restrict) type VeterinaryMedicineMovementType quantity Float treatmentId String? treatment Treatment? @relation(fields: [treatmentId], references: [id], onDelete: Restrict) reason String? userId String? user User? @relation(fields: [userId], references: [id]) createdAt DateTime @default(now()) @@index([lotId]) @@index([treatmentId]) } // Singleton wie SiteSettings. Werte mit "Annahme" sind vom Inhaber zu pruefen. model ComplianceSettings { id String @id @default("singleton") veterinaryOfficeName String? veterinaryOfficeAddress String? registrationNumber String? noticeOnColonyCountChange Boolean @default(true) noticeOnApiaryChange Boolean @default(true) // MM-TT. tskReferenceDay String @default("01-01") tskReportDueDay String? tskMemberNumber String? tskExcludedColonyTypes ColonyType[] @default([]) // Vorgabe in der Migration (Spec 5.4), Annahme - vom Inhaber zu pruefen. netQuantityDigitRules Json @default("[{\"maxGrams\":50,\"minMm\":2},{\"maxGrams\":200,\"minMm\":3},{\"maxGrams\":1000,\"minMm\":4},{\"maxGrams\":null,\"minMm\":6}]") updatedAt DateTime @updatedAt } enum AuthorityNoticeKind { REGISTRATION HERD_CHANGE TSK_REPORT } enum AuthorityNoticeStatus { DRAFT SENT CANCELLED } model AuthorityNotice { id String @id @default(cuid()) kind AuthorityNoticeKind status AuthorityNoticeStatus @default(DRAFT) year Int? referenceDate DateTime countedAt DateTime snapshot Json totalColonies Int sentOn DateTime? sentVia String? reference String? notes String? // Nur bei DRAFT gesetzt ("REGISTRATION", "HERD_CHANGE", "TSK_REPORT:2027"), // sonst null: hoechstens ein offener Entwurf je Art und Jahr. Nullbares // @unique statt partiellem Index, wie dedupeKey im Rueckgrat. openKey String? @unique createdByUserId String? createdBy User? @relation(fields: [createdByUserId], references: [id]) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([kind, status]) @@index([year]) } // CHECK "endsOn" IS NULL OR "endsOn" >= "startsOn". model ApiaryRestriction { id String @id @default(cuid()) apiaryId String apiary Apiary @relation(fields: [apiaryId], references: [id], onDelete: Restrict) disease String authority String? reference String? startsOn DateTime endsOn DateTime? notes String? createdByUserId String? createdBy User? @relation(fields: [createdByUserId], references: [id]) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([apiaryId, startsOn]) } // "Geprueft, in Ordnung" fuer einen Pruefen-Punkt. Gilt nur, solange der // Fingerabdruck zum aktuellen Befund passt. model ComplianceAcknowledgement { id String @id @default(cuid()) itemKey String subjectId String fingerprint String note String userId String user User @relation(fields: [userId], references: [id]) createdAt DateTime @default(now()) revokedAt DateTime? revokedByUserId String? @@index([itemKey, subjectId]) }