Files
ApiCore/ApiCore-Backend/prisma/schema.prisma
T
Nick SzaunigandClaude Opus 5.5 c01a38835a feat: platform backbone and field capture, first part (checkpoint)
Platform backbone (plan 2026-10-13-plattform-rueckgrat), reviewed tasks:
- run roles api/worker/job/all with shared startup checks (Task 1)
- schema and migration for DomainEvent, deliveries, JobRun, ThrottlerHit (Task 2)
- audit, notifications and domain events written in the same transaction (Task 3)
- dispatcher with SKIP LOCKED claims, retries and stale release (Task 4)
- worker building blocks: tick, LISTEN/NOTIFY wake-up, health (Task 5)
- worker process and entry points (Task 6)
- throttler counters in Postgres (Task 19)
- separate keys per purpose with key ring (Task 20)
- public origin page without exact location (Task 22)
- shop keys split into read and write (Task 23)
- Kustomize base, components, overlay encry, CI validation (Task 26)

Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), reviewed tasks:
- brood frames, read endpoints, apiary overview and offline pack
- network basics, offline queue v3, navigation sheets, form building blocks
- new inspection with last-time hints and weather, varroa per day,
  treatment prefill, apiary page with round
- inspection photo (Task 10) is implemented, review pending

Not rolled out. Remaining tasks follow in later commits.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-13 17:57:54 +02:00

1647 lines
53 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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[]
varroaMeasurements VarroaMeasurement[]
swarmControlChecks SwarmControlCheck[]
winterChecks WinterCheck[]
treatments Treatment[]
feedings Feeding[]
harvests Harvest[]
extractions Extraction[]
honeyMeasurements HoneyMeasurement[]
stockMovements StockMovement[]
assignedTasks Task[] @relation("TaskAssignee")
createdTasks Task[] @relation("TaskCreator")
assignedTaskTemplates TaskTemplate[] @relation("TaskTemplateAssignee")
createdTaskTemplates TaskTemplate[] @relation("TaskTemplateCreator")
colonyMoves ColonyApiaryHistory[]
auditLogs AuditLog[]
bottlingBatches BottlingBatch[]
productionBatches ProductionBatch[]
batchMovements BatchMovement[]
rawMaterialLots RawMaterialLot[]
notifications Notification[]
queenEvaluations QueenEvaluation[]
webhookSubscriptions WebhookSubscription[]
documents Document[]
honeyBatchRelocations HoneyBatchRelocation[]
apiKeys ApiKey[]
@@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
}
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[]
@@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?
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[]
@@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.
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
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 String?
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?
createdAt DateTime @default(now())
completedAt DateTime?
@@index([status, dueDate])
@@index([relatedType, relatedId])
@@index([sourceAutomationTrigger, relatedType, relatedId, status])
}
enum TaskTemplateRecurrenceType {
YEARLY
MONTHLY
INTERVAL_DAYS
}
// Wiederkehrende Aufgaben-Vorlagen – erzeugen automatisch eine neue Task,
// sobald nextDueDate erreicht ist (siehe TasksService.generateDueTasks).
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?
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?
createdAt DateTime @default(now())
@@index([colonyId, date])
}
// ---------------------------------------------------------------------------
// 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?
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[]
@@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?
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)
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[]
@@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
}
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)
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([shopRequestId])
}
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
}
model Notification {
id String @id @default(cuid())
userId String?
user User? @relation(fields: [userId], references: [id])
priority NotificationPriority
message String
relatedType String?
relatedId String?
read Boolean @default(false)
createdAt DateTime @default(now())
@@index([userId, read])
@@index([priority])
}
// ---------------------------------------------------------------------------
// 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
}
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
}
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?
// 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
}
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)
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])
}