VoiceCapture und VoiceCaptureAudio nach Spec §3 mit CHECKs (audioBytes 1..2 MiB, durationSec 1..240, CONFIRMED genau mit inspectionId, audioMime nur bei AUDIO), Recht voice-captures:use für ADMIN, BEEKEEPER und STAFF, Sperrreihenfolge mit VoiceCapture am Ende, Ausschluss aus dem Backup mit Begründung, Wächter gegen Suche und Export. Refs #27 Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2360 lines
76 KiB
Plaintext
2360 lines
76 KiB
Plaintext
// 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 ("<iss>|<oid>"),
|
||
// 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[]
|
||
voiceCaptures VoiceCapture[]
|
||
hiveChanges HiveChange[]
|
||
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[]
|
||
hiveSetup HiveSetup?
|
||
voiceCaptures VoiceCapture[]
|
||
|
||
@@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[]
|
||
hiveChange HiveChange?
|
||
|
||
@@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?
|
||
// '<ruleKey>:<relatedId|none>:<anchor>'; 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 = "<praefix>:<userId>".
|
||
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])
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Beute in 3D: Beutensysteme, Teile-Arten, Aufbau je Volk (Spec 2026-10-15)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
model HiveSystem {
|
||
id String @id @default(cuid())
|
||
name String @unique
|
||
frameWidthMm Int // Oberträger
|
||
frameHeightMm Int
|
||
footprintLMm Int
|
||
footprintWMm Int
|
||
template Json // Aufbau-JSON ohne Ids, Schema 5.2
|
||
active Boolean @default(true)
|
||
partTypes HivePartType[]
|
||
setups HiveSetup[]
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
}
|
||
|
||
model HivePartType {
|
||
id String @id @default(cuid())
|
||
systemId String
|
||
system HiveSystem @relation(fields: [systemId], references: [id])
|
||
kind HivePartKind
|
||
name String
|
||
articleRef String? // z. B. Holtermann-Art.-Nr.
|
||
lengthMm Int
|
||
widthMm Int
|
||
heightMm Int
|
||
boxDepth HiveBoxDepth?
|
||
frameSlots Int? // nur BOX
|
||
slotWidth Int? // nur FRAME_FEEDER: belegte Rähmchen-Plätze
|
||
stateSchema Json // Abschnitt 4.2
|
||
active Boolean @default(true)
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
@@unique([systemId, name])
|
||
}
|
||
|
||
model HiveSetup {
|
||
colonyId String @id
|
||
colony Colony @relation(fields: [colonyId], references: [id])
|
||
systemId String
|
||
system HiveSystem @relation(fields: [systemId], references: [id])
|
||
version Int @default(1)
|
||
state Json // Abschnitt 5.2, immer vollständig
|
||
updatedAt DateTime @updatedAt
|
||
changes HiveChange[]
|
||
}
|
||
|
||
model HiveChange {
|
||
id String @id @default(cuid())
|
||
colonyId String
|
||
setup HiveSetup @relation(fields: [colonyId], references: [colonyId])
|
||
version Int // Version nach der Änderung
|
||
occurredOn DateTime // fachlicher Tag (Durchsicht oder Eingabe)
|
||
source HiveChangeSource
|
||
inspectionId String? @unique
|
||
inspection Inspection? @relation(fields: [inspectionId], references: [id])
|
||
userId String
|
||
user User @relation(fields: [userId], references: [id])
|
||
ops Json // angewandte Operationen, Abschnitt 6
|
||
stateAfter Json
|
||
note String?
|
||
createdAt DateTime @default(now())
|
||
|
||
@@unique([colonyId, version])
|
||
@@index([colonyId, occurredOn])
|
||
}
|
||
|
||
enum HivePartKind {
|
||
FLOOR
|
||
BOX
|
||
DIVIDER_BOARD
|
||
QUEEN_EXCLUDER
|
||
FEEDER
|
||
COVER_SHEET
|
||
PROPOLIS_SCREEN
|
||
INNER_LID
|
||
INSULATION
|
||
ROOF
|
||
FLOOR_INSERT
|
||
EVAPORATOR
|
||
FRAME_FEEDER
|
||
}
|
||
|
||
enum HiveBoxDepth {
|
||
FULL
|
||
SHALLOW
|
||
}
|
||
|
||
enum HiveChangeSource {
|
||
INIT
|
||
CARD
|
||
EDITOR
|
||
INSPECTION
|
||
AI_SUGGESTION
|
||
}
|
||
|
||
enum VoiceCaptureSource {
|
||
AUDIO
|
||
TEXT
|
||
}
|
||
|
||
enum VoiceCaptureStatus {
|
||
QUEUED // audio uploaded, waits for Whisper
|
||
TRANSCRIBING // claimed by the CronJob
|
||
TRANSCRIBED // text present, waits for Claude (also source TEXT)
|
||
EXTRACTING // claimed by the worker
|
||
READY // suggestions present, waits for the user
|
||
CONFIRMED // inspection saved, content deleted
|
||
DISCARDED // discarded by the user, content deleted
|
||
FAILED // finally failed, code in lastErrorCode
|
||
EXPIRED // deadline passed, content deleted
|
||
}
|
||
|
||
// A voice note. Audio and text are personal data: content lives here only
|
||
// encrypted and only until it is taken over, at most 7 days. The row itself
|
||
// holds no content and stays 90 days for the error and cost overview.
|
||
model VoiceCapture {
|
||
id String @id @default(cuid())
|
||
ownerUserId String
|
||
owner User @relation(fields: [ownerUserId], references: [id])
|
||
colonyId String
|
||
colony Colony @relation(fields: [colonyId], references: [id])
|
||
inspectionDate DateTime @db.Date // date of the inspection per the form
|
||
source VoiceCaptureSource
|
||
status VoiceCaptureStatus @default(QUEUED)
|
||
|
||
audioMime String? // audio/webm, audio/ogg, audio/mp4
|
||
audioBytes Int?
|
||
durationSec Int? // reported by the device; Whisper measures again
|
||
|
||
transcriptEnc String? // key-ring ciphertext
|
||
suggestionEnc String? // key-ring ciphertext of the checked suggestion
|
||
|
||
attempts Int @default(0)
|
||
availableAt DateTime @default(now())
|
||
claimedAt DateTime?
|
||
claimToken String? // guards against completion by a process that lost the claim
|
||
lastErrorCode String? // fixed code, never content
|
||
|
||
model String? // e.g. "claude-sonnet-5-5"
|
||
inputTokens Int?
|
||
outputTokens Int?
|
||
|
||
inspectionId String? // set when CONFIRMED
|
||
createdAt DateTime @default(now())
|
||
readyAt DateTime?
|
||
contentExpiresAt DateTime // createdAt + 7 days
|
||
contentDeletedAt DateTime? // when audio/text/suggestion were deleted
|
||
updatedAt DateTime @updatedAt
|
||
|
||
audio VoiceCaptureAudio?
|
||
|
||
@@index([status, availableAt])
|
||
@@index([ownerUserId, status])
|
||
@@index([contentExpiresAt])
|
||
}
|
||
|
||
// The audio lives in its own table: deleting is a DELETE of the row (not
|
||
// nulling a big column), and the row is never loaded into lists.
|
||
model VoiceCaptureAudio {
|
||
captureId String @id
|
||
capture VoiceCapture @relation(fields: [captureId], references: [id], onDelete: Cascade)
|
||
dataEnc Bytes // key-ring ciphertext of the audio, at most 2 MB plaintext
|
||
createdAt DateTime @default(now())
|
||
}
|