feat: T6 wave 1 – compliance schema, permissions, lock order, backup
Backend Image / build-and-push (push) Successful in 6m39s
Frontend Image / build-and-push (push) Successful in 11m25s

- Migrations (additive): veterinary medicines with lots and movement
  ledger, treatment medicine/lot/dose links, label particulars, authority
  notices, apiary restrictions, compliance settings and acknowledgements;
  notification category COMPLIANCE, added to non-empty Telegram lists.
- Permissions veterinary-medicines:* and compliance:* with role grants.
- LOCKABLE_TABLES: Apiary locks before Colony; T6 tables appended.
- Backup exports and restores the new tables in FK order; unsealed files
  skip them and report dropped medicine links; an open TSK draft in the
  target wins.
- describeNullContracts shared helper; Telegram backlog e2e no longer
  depends on the time of day.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-15 13:04:13 +02:00
1 parent 7a1480138d
commit 8a31c42578
23 files changed
+2028 -84

No files matched your search

+231 -15
View File
@@ -136,6 +136,12 @@ model User {
honeyBatchRelocations HoneyBatchRelocation[]
apiKeys ApiKey[]
veterinaryMedicineLots VeterinaryMedicineLot[]
veterinaryMedicineMovements VeterinaryMedicineMovement[]
authorityNotices AuthorityNotice[]
apiaryRestrictions ApiaryRestriction[]
complianceAcknowledgements ComplianceAcknowledgement[]
@@index([roleId])
}
@@ -202,6 +208,7 @@ enum AuditAction {
MFA_ENABLED
MFA_DISABLED
ACCOUNT_LOCKED
PRINT
}
model AuditLog {
@@ -252,6 +259,7 @@ model Apiary {
phenologyReports PhenologyReport[]
phenologyStates ApiaryPhenology[]
weatherDays ApiaryWeatherDay[]
restrictions ApiaryRestriction[]
@@index([status])
}
@@ -793,11 +801,11 @@ model WinterCheck {
// ---------------------------------------------------------------------------
model Treatment {
id String @id @default(cuid())
id String @id @default(cuid())
colonyId String
colony Colony @relation(fields: [colonyId], references: [id])
colony Colony @relation(fields: [colonyId], references: [id])
userId String
user User @relation(fields: [userId], references: [id])
user User @relation(fields: [userId], references: [id])
date DateTime
startDate DateTime?
endDate DateTime?
@@ -813,13 +821,25 @@ model Treatment {
// 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])
apiary Apiary? @relation(fields: [apiaryId], references: [id])
supplier String?
purchaseProof String?
prescribingVet String?
createdAt DateTime @default(now())
// 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])
}
// ---------------------------------------------------------------------------
@@ -1125,6 +1145,8 @@ model Product {
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)
@@ -1163,6 +1185,8 @@ model LabelTemplate {
// 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())
@@ -1405,6 +1429,7 @@ enum NotificationCategory {
WITHDRAWAL
SEASON
SYSTEM
COMPLIANCE
}
// Jede Zeile gehoert genau einem Empfaenger (T4: Fan-out mit Rechtepruefung
@@ -1440,7 +1465,7 @@ model NotificationPreference {
quietFrom String? @default("21:00")
quietUntil String? @default("06:30")
telegramEnabled Boolean @default(false)
telegramCategories NotificationCategory[] @default([TASK_DUE, DIGEST])
telegramCategories NotificationCategory[] @default([TASK_DUE, DIGEST, COMPLIANCE])
telegramDetail Boolean @default(true)
updatedAt DateTime @updatedAt
}
@@ -1547,6 +1572,9 @@ enum DocumentEntityType {
QUEEN
INSPECTION
HONEY_BATCH
MEDICINE_LOT
AUTHORITY_NOTICE
APIARY_RESTRICTION
}
model Document {
@@ -1637,18 +1665,22 @@ model AutomationSequence {
// 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?
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?
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
originCountry String @default("Deutschland")
updatedAt DateTime @updatedAt
}
// ---------------------------------------------------------------------------
@@ -1966,3 +1998,187 @@ model ApiaryWeatherDay {
@@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])
}