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
+2014 -70

No files matched your search

@@ -0,0 +1,146 @@
-- T6 migration 1/5: veterinary medicine catalogue, packs and the pack ledger.
-- A treatment may point at a catalogue entry and at the pack it was taken
-- from; the pack ledger is booked, never edited. Rows of the catalogue and
-- the packs are never deleted while something references them (RESTRICT).
-- CHECKs: a pack holds more than nothing; a booking moves something; only a
-- TREATMENT booking belongs to a treatment.
-- CreateEnum
CREATE TYPE "MedicineUnit" AS ENUM ('ML', 'G', 'PIECE');
-- CreateEnum
CREATE TYPE "VeterinaryMedicineMovementType" AS ENUM ('TREATMENT', 'CORRECTION', 'DISPOSAL');
-- AlterEnum
ALTER TYPE "DocumentEntityType" ADD VALUE 'MEDICINE_LOT';
-- AlterTable
ALTER TABLE "Treatment" ADD COLUMN "doseQuantity" DOUBLE PRECISION,
ADD COLUMN "medicineId" TEXT,
ADD COLUMN "medicineLotId" TEXT;
-- CreateTable
CREATE TABLE "VeterinaryMedicine" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"activeIngredient" TEXT,
"authorisationNumber" TEXT,
"prescriptionOnly" BOOLEAN NOT NULL DEFAULT false,
"withdrawalPeriodDays" INTEGER,
"unit" "MedicineUnit" NOT NULL,
"dosePerColony" DOUBLE PRECISION,
"doseNote" TEXT,
"applicationMethod" TEXT,
"minTemperatureC" DOUBLE PRECISION,
"maxTemperatureC" DOUBLE PRECISION,
"defaultSupplier" TEXT,
"notes" TEXT,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "VeterinaryMedicine_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "VeterinaryMedicineLot" (
"id" TEXT NOT NULL,
"medicineId" TEXT NOT NULL,
"batchNumber" TEXT,
"purchasedOn" TIMESTAMP(3) NOT NULL,
"quantity" DOUBLE PRECISION NOT NULL,
"expiresOn" TIMESTAMP(3),
"supplier" TEXT,
"purchaseProof" TEXT,
"prescribingVet" TEXT,
"notes" TEXT,
"userId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "VeterinaryMedicineLot_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "VeterinaryMedicineMovement" (
"id" TEXT NOT NULL,
"lotId" TEXT NOT NULL,
"type" "VeterinaryMedicineMovementType" NOT NULL,
"quantity" DOUBLE PRECISION NOT NULL,
"treatmentId" TEXT,
"reason" TEXT,
"userId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "VeterinaryMedicineMovement_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "VeterinaryMedicine_active_idx" ON "VeterinaryMedicine"("active");
-- CreateIndex
CREATE INDEX "VeterinaryMedicineLot_medicineId_expiresOn_idx" ON "VeterinaryMedicineLot"("medicineId", "expiresOn");
-- CreateIndex
CREATE INDEX "VeterinaryMedicineMovement_lotId_idx" ON "VeterinaryMedicineMovement"("lotId");
-- CreateIndex
CREATE INDEX "VeterinaryMedicineMovement_treatmentId_idx" ON "VeterinaryMedicineMovement"("treatmentId");
-- CreateIndex
CREATE INDEX "Treatment_medicineId_idx" ON "Treatment"("medicineId");
-- CreateIndex
CREATE INDEX "Treatment_medicineLotId_idx" ON "Treatment"("medicineLotId");
-- AddForeignKey
ALTER TABLE "Treatment" ADD CONSTRAINT "Treatment_medicineId_fkey" FOREIGN KEY ("medicineId") REFERENCES "VeterinaryMedicine"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Treatment" ADD CONSTRAINT "Treatment_medicineLotId_fkey" FOREIGN KEY ("medicineLotId") REFERENCES "VeterinaryMedicineLot"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "VeterinaryMedicineLot" ADD CONSTRAINT "VeterinaryMedicineLot_medicineId_fkey" FOREIGN KEY ("medicineId") REFERENCES "VeterinaryMedicine"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "VeterinaryMedicineLot" ADD CONSTRAINT "VeterinaryMedicineLot_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "VeterinaryMedicineMovement" ADD CONSTRAINT "VeterinaryMedicineMovement_lotId_fkey" FOREIGN KEY ("lotId") REFERENCES "VeterinaryMedicineLot"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "VeterinaryMedicineMovement" ADD CONSTRAINT "VeterinaryMedicineMovement_treatmentId_fkey" FOREIGN KEY ("treatmentId") REFERENCES "Treatment"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "VeterinaryMedicineMovement" ADD CONSTRAINT "VeterinaryMedicineMovement_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- Constraints Prisma cannot express.
ALTER TABLE "VeterinaryMedicineLot"
ADD CONSTRAINT "VeterinaryMedicineLot_quantity_positive" CHECK ("quantity" > 0);
ALTER TABLE "VeterinaryMedicineMovement"
ADD CONSTRAINT "VeterinaryMedicineMovement_quantity_nonzero" CHECK ("quantity" <> 0);
ALTER TABLE "VeterinaryMedicineMovement"
ADD CONSTRAINT "VeterinaryMedicineMovement_treatment_matches_type"
CHECK (("type" = 'TREATMENT') = ("treatmentId" IS NOT NULL));
-- Permissions: the boot-time catalog sync only grants new permissions to
-- system roles (ADMIN), so the grants are made here (same as winter_checks).
-- API_CLIENT gets nothing: receipts and invoice numbers are not for the shop.
INSERT INTO "Permission" (id, key, resource, action, "labelDe", "groupDe", "sortOrder")
VALUES
(gen_random_uuid()::text, 'veterinary-medicines:read', 'veterinary-medicines', 'read', 'Ansehen', 'Tierarzneimittel', 3700),
(gen_random_uuid()::text, 'veterinary-medicines:create', 'veterinary-medicines', 'create', 'Anlegen', 'Tierarzneimittel', 3701),
(gen_random_uuid()::text, 'veterinary-medicines:update', 'veterinary-medicines', 'update', 'Bearbeiten', 'Tierarzneimittel', 3702)
ON CONFLICT (key) DO NOTHING;
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
SELECT r.id, p.id, now()
FROM "Role" r, "Permission" p
WHERE p.resource = 'veterinary-medicines'
AND (
r.key IN ('ADMIN', 'BEEKEEPER', 'STAFF')
OR (r.key = 'READONLY' AND p.action = 'read')
)
ON CONFLICT DO NOTHING;
@@ -0,0 +1,21 @@
-- T6 migration 2/5: mandatory label particulars. The food business operator
-- (name and address) is separate from the bee keeper of the Bestandsbuch; a
-- product carries the legal name of the food; a label template carries the
-- minimum x-height (CHECK 0.5 to 5 mm) the print check warns about. Printing
-- a label is audited as PRINT.
-- AlterEnum
ALTER TYPE "AuditAction" ADD VALUE 'PRINT';
-- AlterTable
ALTER TABLE "SiteSettings" ADD COLUMN "foodBusinessAddress" TEXT,
ADD COLUMN "foodBusinessName" TEXT;
-- AlterTable
ALTER TABLE "Product" ADD COLUMN "legalName" TEXT;
-- AlterTable
ALTER TABLE "LabelTemplate" ADD COLUMN "minXHeightMm" DOUBLE PRECISION NOT NULL DEFAULT 1.2;
ALTER TABLE "LabelTemplate"
ADD CONSTRAINT "LabelTemplate_minXHeightMm_range" CHECK ("minXHeightMm" BETWEEN 0.5 AND 5);
@@ -0,0 +1,140 @@
-- T6 migration 3/5: authority package and readiness. ComplianceSettings is a
-- singleton like SiteSettings; the default net-quantity digit rules are an
-- assumption the owner has to check (ARCHITECTURE). AuthorityNotice.openKey
-- is a nullable unique key: at most one open draft per kind and year, any
-- number of rows without a key. ApiaryRestriction ends on or after its start.
-- CreateEnum
CREATE TYPE "AuthorityNoticeKind" AS ENUM ('REGISTRATION', 'HERD_CHANGE', 'TSK_REPORT');
-- CreateEnum
CREATE TYPE "AuthorityNoticeStatus" AS ENUM ('DRAFT', 'SENT', 'CANCELLED');
-- AlterEnum
-- This migration adds more than one value to an enum. They are not used in
-- this migration (PostgreSQL forbids it inside the same transaction).
ALTER TYPE "DocumentEntityType" ADD VALUE 'AUTHORITY_NOTICE';
ALTER TYPE "DocumentEntityType" ADD VALUE 'APIARY_RESTRICTION';
-- CreateTable
CREATE TABLE "ComplianceSettings" (
"id" TEXT NOT NULL DEFAULT 'singleton',
"veterinaryOfficeName" TEXT,
"veterinaryOfficeAddress" TEXT,
"registrationNumber" TEXT,
"noticeOnColonyCountChange" BOOLEAN NOT NULL DEFAULT true,
"noticeOnApiaryChange" BOOLEAN NOT NULL DEFAULT true,
"tskReferenceDay" TEXT NOT NULL DEFAULT '01-01',
"tskReportDueDay" TEXT,
"tskMemberNumber" TEXT,
"tskExcludedColonyTypes" "ColonyType"[] DEFAULT ARRAY[]::"ColonyType"[],
"netQuantityDigitRules" JSONB NOT NULL DEFAULT '[{"maxGrams":50,"minMm":2},{"maxGrams":200,"minMm":3},{"maxGrams":1000,"minMm":4},{"maxGrams":null,"minMm":6}]',
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ComplianceSettings_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AuthorityNotice" (
"id" TEXT NOT NULL,
"kind" "AuthorityNoticeKind" NOT NULL,
"status" "AuthorityNoticeStatus" NOT NULL DEFAULT 'DRAFT',
"year" INTEGER,
"referenceDate" TIMESTAMP(3) NOT NULL,
"countedAt" TIMESTAMP(3) NOT NULL,
"snapshot" JSONB NOT NULL,
"totalColonies" INTEGER NOT NULL,
"sentOn" TIMESTAMP(3),
"sentVia" TEXT,
"reference" TEXT,
"notes" TEXT,
"openKey" TEXT,
"createdByUserId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AuthorityNotice_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ApiaryRestriction" (
"id" TEXT NOT NULL,
"apiaryId" TEXT NOT NULL,
"disease" TEXT NOT NULL,
"authority" TEXT,
"reference" TEXT,
"startsOn" TIMESTAMP(3) NOT NULL,
"endsOn" TIMESTAMP(3),
"notes" TEXT,
"createdByUserId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ApiaryRestriction_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ComplianceAcknowledgement" (
"id" TEXT NOT NULL,
"itemKey" TEXT NOT NULL,
"subjectId" TEXT NOT NULL,
"fingerprint" TEXT NOT NULL,
"note" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"revokedAt" TIMESTAMP(3),
"revokedByUserId" TEXT,
CONSTRAINT "ComplianceAcknowledgement_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "AuthorityNotice_openKey_key" ON "AuthorityNotice"("openKey");
-- CreateIndex
CREATE INDEX "AuthorityNotice_kind_status_idx" ON "AuthorityNotice"("kind", "status");
-- CreateIndex
CREATE INDEX "AuthorityNotice_year_idx" ON "AuthorityNotice"("year");
-- CreateIndex
CREATE INDEX "ApiaryRestriction_apiaryId_startsOn_idx" ON "ApiaryRestriction"("apiaryId", "startsOn");
-- CreateIndex
CREATE INDEX "ComplianceAcknowledgement_itemKey_subjectId_idx" ON "ComplianceAcknowledgement"("itemKey", "subjectId");
-- AddForeignKey
ALTER TABLE "AuthorityNotice" ADD CONSTRAINT "AuthorityNotice_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ApiaryRestriction" ADD CONSTRAINT "ApiaryRestriction_apiaryId_fkey" FOREIGN KEY ("apiaryId") REFERENCES "Apiary"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ApiaryRestriction" ADD CONSTRAINT "ApiaryRestriction_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ComplianceAcknowledgement" ADD CONSTRAINT "ComplianceAcknowledgement_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- Constraints Prisma cannot express.
ALTER TABLE "ApiaryRestriction"
ADD CONSTRAINT "ApiaryRestriction_endsOn_after_startsOn"
CHECK ("endsOn" IS NULL OR "endsOn" >= "startsOn");
-- Permissions (see the winter_checks migration): read for everyone who
-- reads the records, manage (settings, notices, restrictions, readiness
-- sign-off) only for ADMIN and BEEKEEPER. API_CLIENT gets nothing.
INSERT INTO "Permission" (id, key, resource, action, "labelDe", "groupDe", "sortOrder")
VALUES
(gen_random_uuid()::text, 'compliance:read', 'compliance', 'read', 'Ansehen', 'Prüfbereitschaft & Behörden', 3800),
(gen_random_uuid()::text, 'compliance:manage', 'compliance', 'manage', 'Verwalten', 'Prüfbereitschaft & Behörden', 3801)
ON CONFLICT (key) DO NOTHING;
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
SELECT r.id, p.id, now()
FROM "Role" r, "Permission" p
WHERE p.resource = 'compliance'
AND (
r.key IN ('ADMIN', 'BEEKEEPER')
OR (r.key IN ('STAFF', 'READONLY') AND p.action = 'read')
)
ON CONFLICT DO NOTHING;
@@ -0,0 +1,7 @@
-- T6 migration 4/5: notification category for the authority hints ("Bestand
-- geaendert", "TSK-Stichtag"). The value is only added here; migration 5
-- uses it (PostgreSQL forbids using a new enum value in the same
-- transaction that adds it).
-- AlterEnum
ALTER TYPE "NotificationCategory" ADD VALUE 'COMPLIANCE';
@@ -0,0 +1,13 @@
-- T6 migration 5/5: COMPLIANCE is a Telegram category chosen by default. New
-- preferences start with it; every existing preference gets it appended
-- (the app shows all categories, the list only steers Telegram). A user can
-- still remove it in the profile: an empty list is a deliberate opt-out and
-- stays empty (only lists with at least one entry get the new category).
-- AlterTable
ALTER TABLE "NotificationPreference" ALTER COLUMN "telegramCategories" SET DEFAULT ARRAY['TASK_DUE', 'DIGEST', 'COMPLIANCE']::"NotificationCategory"[];
UPDATE "NotificationPreference"
SET "telegramCategories" = array_append("telegramCategories", 'COMPLIANCE'::"NotificationCategory")
WHERE cardinality("telegramCategories") > 0
AND NOT ('COMPLIANCE'::"NotificationCategory" = ANY("telegramCategories"));
+217 -1
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])
}
@@ -817,9 +825,21 @@ model Treatment {
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])
}
// ---------------------------------------------------------------------------
@@ -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 {
@@ -1646,6 +1674,10 @@ model SiteSettings {
// 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
@@ -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])
}
@@ -391,6 +391,27 @@ export const PERMISSION_CATALOG = [
[['read', 'Inventar-Spiegel für ERPNext lesen']],
3600,
),
// T6: the veterinary medicine catalogue and its packs. Receipts, invoice
// numbers and supplier addresses live here, so the shop account gets none.
...group(
'Tierarzneimittel',
'veterinary-medicines',
[
['read', 'Ansehen'],
['create', 'Anlegen'],
['update', 'Bearbeiten'],
],
3700,
),
...group(
'Prüfbereitschaft & Behörden',
'compliance',
[
['read', 'Ansehen'],
['manage', 'Verwalten'],
],
3800,
),
] as const satisfies readonly PermissionCatalogEntry[];
export type PermissionKey = (typeof PERMISSION_CATALOG)[number]['key'];
@@ -64,6 +64,23 @@ const BUSINESS_ENTITY_KEYS = [
'backupSettings',
'calendarSyncSettings',
'calendarSyncOutboxEntry',
'veterinaryMedicine',
'veterinaryMedicineLot',
'veterinaryMedicineMovement',
'complianceSettings',
'authorityNotice',
'apiaryRestriction',
'complianceAcknowledgement',
] as const;
const T6_MODELS = [
'VeterinaryMedicine',
'VeterinaryMedicineLot',
'VeterinaryMedicineMovement',
'ComplianceSettings',
'AuthorityNotice',
'ApiaryRestriction',
'ComplianceAcknowledgement',
] as const;
const ALL_ENTITY_KEYS = [
@@ -1638,6 +1655,299 @@ describe('BackupService', () => {
});
});
describe('T6 compliance tables', () => {
const T6_KEYS = T6_MODELS.map(modelKey);
it('no T6 model is excluded from the backup or listed as legacy-unsealed', async () => {
const bundle = await service.exportAll();
for (const model of T6_MODELS) {
expect(MODELS_EXCLUDED_FROM_BACKUP).not.toHaveProperty(model);
expect(bundle.data).toHaveProperty(modelKey(model));
}
for (const key of T6_KEYS) {
expect(LEGACY_UNSEALED_TABLES as readonly string[]).not.toContain(key);
}
});
it('restores medicine, lot, treatment and movement in foreign-key order', async () => {
const one = (id: string) => [{ id }];
await service.importAll(
{
version: 3,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
apiary: one('apiary-1'),
apiaryRestriction: one('restriction-1'),
veterinaryMedicineMovement: one('movement-1'),
treatment: one('treatment-1'),
veterinaryMedicineLot: one('lot-1'),
veterinaryMedicine: one('medicine-1'),
},
},
'admin-1',
TRUSTED,
);
const order = (key: string) => tx[key].upsert.mock.invocationCallOrder[0];
const dependencies: [parent: string, child: string][] = [
['veterinaryMedicine', 'veterinaryMedicineLot'],
['veterinaryMedicineLot', 'treatment'],
['veterinaryMedicine', 'treatment'],
['treatment', 'veterinaryMedicineMovement'],
['veterinaryMedicineLot', 'veterinaryMedicineMovement'],
['apiary', 'apiaryRestriction'],
];
for (const [parent, child] of dependencies) {
expect({ parent, child, before: order(parent) < order(child) }).toEqual(
{ parent, child, before: true },
);
}
});
it('remaps the user columns of the T6 rows like every other table', async () => {
await service.importAll(
{
version: 3,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
veterinaryMedicineLot: [
{ id: 'lot-1', userId: 'gone-user' },
{ id: 'lot-2', userId: 'known-user' },
{ id: 'lot-3', userId: null },
],
veterinaryMedicineMovement: [{ id: 'movement-1', userId: 'gone' }],
authorityNotice: [{ id: 'notice-1', createdByUserId: 'gone' }],
apiaryRestriction: [{ id: 'restriction-1', createdByUserId: null }],
complianceAcknowledgement: [
{ id: 'ack-1', userId: 'gone-user', revokedByUserId: 'gone' },
],
},
},
'admin-1',
TRUSTED,
);
const created = (key: string, call = 0) =>
(tx[key].upsert.mock.calls[call] as [{ create: Row }])[0].create;
expect(created('veterinaryMedicineLot', 0).userId).toBe('admin-1');
expect(created('veterinaryMedicineLot', 1).userId).toBe('known-user');
expect(created('veterinaryMedicineLot', 2).userId).toBeNull();
expect(created('veterinaryMedicineMovement').userId).toBe('admin-1');
expect(created('authorityNotice').createdByUserId).toBe('admin-1');
expect(created('apiaryRestriction').createdByUserId).toBeNull();
expect(created('complianceAcknowledgement').userId).toBe('admin-1');
});
it('upserts the compliance settings singleton by id', async () => {
await service.importAll(
{
version: 3,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
complianceSettings: [
{ id: 'singleton', registrationNumber: 'X-1' },
],
},
},
'admin-1',
TRUSTED,
);
const [[call]] = tx.complianceSettings.upsert.mock.calls as [
[{ where: Row; update: Row }],
];
expect(call.where).toEqual({ id: 'singleton' });
expect(call.update).toEqual({ registrationNumber: 'X-1' });
});
it('skips a notice whose open draft key is held by another row here and counts it', async () => {
(
tx as unknown as { authorityNotice: { findUnique: jest.Mock } }
).authorityNotice.findUnique = jest
.fn()
.mockResolvedValueOnce({ id: 'other-draft' })
.mockResolvedValueOnce({ id: 'notice-2' });
const summary = await service.importAll(
{
version: 3,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
authorityNotice: [
{ id: 'notice-1', openKey: 'HERD_CHANGE' },
{ id: 'notice-2', openKey: 'TSK_REPORT:2027' },
{ id: 'notice-3', openKey: null },
],
},
},
'admin-1',
TRUSTED,
);
expect(tx.authorityNotice.upsert).toHaveBeenCalledTimes(2);
expect(summary.imported.authorityNotice).toBe(2);
expect(summary.imported.authorityNoticeSkipped).toBe(1);
});
it('an unsealed legacy file skips every T6 table and reports it', async () => {
const data = Object.fromEntries(
T6_KEYS.map((key) => [key, [{ id: `${key}-1` }]]),
);
const summary = await service.importAll(
{ version: 2, exportedAt: '2026-01-01T00:00:00.000Z', data },
'admin-1',
{ trusted: false },
);
for (const key of T6_KEYS) {
expect({ key, calls: tx[key].upsert.mock.calls.length }).toEqual({
key,
calls: 0,
});
}
expect([...summary.skipped].sort()).toEqual([...T6_KEYS].sort());
});
it('an unsealed file keeps the pack link off its treatments', async () => {
await service.importAll(
{
version: 2,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
treatment: [
{
id: 'treatment-1',
product: 'Mittel',
medicineId: 'medicine-1',
medicineLotId: 'lot-1',
doseQuantity: 5,
},
],
},
},
'admin-1',
{ trusted: false },
);
const [[call]] = tx.treatment.upsert.mock.calls as [[{ create: Row }]];
expect(call.create).not.toHaveProperty('medicineId');
expect(call.create).not.toHaveProperty('medicineLotId');
expect(call.create).not.toHaveProperty('doseQuantity');
expect(call.create.product).toBe('Mittel');
});
it('counts and logs the treatments whose pack link an unsealed file lost', async () => {
const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation();
const summary = await service.importAll(
{
version: 2,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
treatment: [
{ id: 't1', medicineLotId: 'lot-1' },
{ id: 't2', doseQuantity: 3 },
{ id: 't3', product: 'ohne Verknüpfung', medicineId: null },
],
},
},
'admin-1',
{ trusted: false },
);
expect(summary.imported.treatmentMedicineLinksDropped).toBe(2);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('2 Behandlung'),
);
warn.mockRestore();
});
it('adds no link counter when a sealed file keeps the links', async () => {
const summary = await service.importAll(
{
version: 3,
exportedAt: '2026-01-01T00:00:00.000Z',
data: { treatment: [{ id: 't1', medicineLotId: 'lot-1' }] },
},
'admin-1',
TRUSTED,
);
expect(summary.imported).not.toHaveProperty(
'treatmentMedicineLinksDropped',
);
});
it('leaves out the documents of a skipped notice and lists its id', async () => {
(
tx as unknown as { authorityNotice: { findUnique: jest.Mock } }
).authorityNotice.findUnique = jest
.fn()
.mockResolvedValue({ id: 'other-draft' });
const pdf = Buffer.from('%PDF-1.4').toString('base64');
const doc = (id: string, entityType: string, entityId: string) => ({
id,
entityType,
entityId,
mimeType: 'application/pdf',
contentBase64: pdf,
});
const summary = await service.importAll(
{
version: 3,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
authorityNotice: [
{ id: 'notice-1', openKey: 'HERD_CHANGE' },
{ id: 'notice-2', openKey: null },
],
document: [
doc('d1', 'AUTHORITY_NOTICE', 'notice-1'),
doc('d2', 'AUTHORITY_NOTICE', 'notice-2'),
doc('d3', 'COLONY', 'notice-1'),
],
},
},
'admin-1',
TRUSTED,
);
expect(summary.skippedNoticeIds).toEqual(['notice-1']);
expect(summary.imported.authorityNoticeDocumentsSkipped).toBe(1);
expect(summary.imported.document).toBe(2);
const calls = tx.document.upsert.mock.calls as [
{ where: { id: string } },
][];
const ids = calls.map(([arg]) => arg.where.id);
expect(ids).toEqual(['d2', 'd3']);
});
it('a sealed file keeps the pack link of its treatments', async () => {
await service.importAll(
{
version: 3,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
treatment: [
{ id: 'treatment-1', medicineLotId: 'lot-1', doseQuantity: 5 },
],
},
},
'admin-1',
TRUSTED,
);
const [[call]] = tx.treatment.upsert.mock.calls as [[{ create: Row }]];
expect(call.create).toMatchObject({
medicineLotId: 'lot-1',
doseQuantity: 5,
});
});
});
// Drift guard: a model added to the schema has to be backed up or listed
// in MODELS_EXCLUDED_FROM_BACKUP with its reason - never silently missing.
describe('model coverage', () => {
+195 -10
View File
@@ -42,6 +42,12 @@ export interface BackupImportSummary {
* table outside LEGACY_UNSEALED_TABLES.
*/
skipped: string[];
/**
* Authority notices of the file left out because the target already holds
* the open draft for their kind and year (the target wins). Their documents
* are left out too.
*/
skippedNoticeIds?: string[];
/** False for an old, unsealed file: restricted and add-only. */
sealed: boolean;
}
@@ -179,6 +185,17 @@ function asString(row: Row, key: string): string | null {
return typeof value === 'string' ? value : null;
}
// An unsealed file cannot book the pack ledger, so it cannot link a treatment
// to a catalogue entry or a pack either (that would be a dose without a
// booking).
function withoutMedicineLinks(row: Row): Row {
const rest: Row = { ...row };
delete rest.medicineId;
delete rest.medicineLotId;
delete rest.doseQuantity;
return rest;
}
// Bundles written before the feed vocabulary change carry free text in
// Inspection.feedStatus: known words map to the enum, the rest goes to notes.
function withCurrentFeedVocabulary(row: Row): Row {
@@ -307,6 +324,7 @@ export class BackupService {
});
data.apiary = await this.prisma.apiary.findMany();
data.apiaryRestriction = await this.prisma.apiaryRestriction.findMany();
data.queen = await this.prisma.queen.findMany();
data.colony = await this.prisma.colony.findMany();
data.colonyApiaryHistory = await this.prisma.colonyApiaryHistory.findMany();
@@ -321,7 +339,12 @@ export class BackupService {
data.swarmControlCheck = await this.prisma.swarmControlCheck.findMany();
data.winterCheck = await this.prisma.winterCheck.findMany();
data.phenologyReport = await this.prisma.phenologyReport.findMany();
data.veterinaryMedicine = await this.prisma.veterinaryMedicine.findMany();
data.veterinaryMedicineLot =
await this.prisma.veterinaryMedicineLot.findMany();
data.treatment = await this.prisma.treatment.findMany();
data.veterinaryMedicineMovement =
await this.prisma.veterinaryMedicineMovement.findMany();
data.feeding = await this.prisma.feeding.findMany();
data.harvest = await this.prisma.harvest.findMany();
data.harvestColony = await this.prisma.harvestColony.findMany();
@@ -349,6 +372,10 @@ export class BackupService {
data.automationRule = await this.prisma.automationRule.findMany();
data.automationSequence = await this.prisma.automationSequence.findMany();
data.siteSettings = await this.prisma.siteSettings.findMany();
data.complianceSettings = await this.prisma.complianceSettings.findMany();
data.authorityNotice = await this.prisma.authorityNotice.findMany();
data.complianceAcknowledgement =
await this.prisma.complianceAcknowledgement.findMany();
data.backupSettings = await this.prisma.backupSettings.findMany();
data.calendarSyncSettings =
await this.prisma.calendarSyncSettings.findMany();
@@ -424,6 +451,8 @@ export class BackupService {
// Document files the restored rows no longer point at - deleted only
// once the transaction has committed.
const replacedDocumentFiles: string[] = [];
// Notices of the file the target's open draft replaced (see below).
const skippedNoticeIds = new Set<string>();
const imported: Record<string, number> = {};
@@ -536,6 +565,21 @@ export class BackupService {
}
imported.apiary = rowsOf('apiary').length;
for (const row of rowsOf('apiaryRestriction')) {
const restriction = {
...row,
createdByUserId: resolveOptionalUserId(
asString(row, 'createdByUserId'),
),
};
await tx.apiaryRestriction.upsert({
where: { id: row.id as string },
create: restriction as never,
update: updateWith(restriction) as never,
});
}
imported.apiaryRestriction = rowsOf('apiaryRestriction').length;
// Queens already here before this import keep their mother line when
// the file is unsealed (add-only, see updateWith).
const preexistingQueenIds = new Set<string>(
@@ -772,20 +816,72 @@ export class BackupService {
}
imported.phenologyReport = rowsOf('phenologyReport').length;
// Catalogue and packs before the treatments that point at them; the
// pack ledger after the treatments its bookings belong to.
for (const row of rowsOf('veterinaryMedicine')) {
await tx.veterinaryMedicine.upsert({
where: { id: row.id as string },
create: row as never,
update: updateWith(row) as never,
});
}
imported.veterinaryMedicine = rowsOf('veterinaryMedicine').length;
for (const row of rowsOf('veterinaryMedicineLot')) {
const lot = {
...row,
userId: resolveOptionalUserId(asString(row, 'userId')),
};
await tx.veterinaryMedicineLot.upsert({
where: { id: row.id as string },
create: lot as never,
update: updateWith(lot) as never,
});
}
imported.veterinaryMedicineLot = rowsOf('veterinaryMedicineLot').length;
let medicineLinksDropped = 0;
for (const row of rowsOf('treatment')) {
if (
!trusted &&
(row.medicineId != null ||
row.medicineLotId != null ||
row.doseQuantity != null)
) {
medicineLinksDropped += 1;
}
const treatment = {
...(trusted ? row : withoutMedicineLinks(row)),
userId: resolveUserId(asString(row, 'userId')),
};
await tx.treatment.upsert({
where: { id: row.id as string },
create: {
...row,
userId: resolveUserId(asString(row, 'userId')),
} as never,
update: updateWith({
...row,
userId: resolveUserId(asString(row, 'userId')),
}) as never,
create: treatment as never,
update: updateWith(treatment) as never,
});
}
imported.treatment = rowsOf('treatment').length;
if (medicineLinksDropped > 0) {
imported.treatmentMedicineLinksDropped = medicineLinksDropped;
this.logger.warn(
`${medicineLinksDropped} Behandlung(en) ohne Mittel-/Packungsverknüpfung übernommen: Eine unversiegelte Datei darf das Packungsbuch nicht schreiben.`,
);
}
for (const row of rowsOf('veterinaryMedicineMovement')) {
const movement = {
...row,
userId: resolveOptionalUserId(asString(row, 'userId')),
};
await tx.veterinaryMedicineMovement.upsert({
where: { id: row.id as string },
create: movement as never,
update: movement as never,
});
}
imported.veterinaryMedicineMovement = rowsOf(
'veterinaryMedicineMovement',
).length;
for (const row of rowsOf('feeding')) {
await tx.feeding.upsert({
@@ -1127,6 +1223,70 @@ export class BackupService {
}
imported.siteSettings = rowsOf('siteSettings').length;
for (const row of rowsOf('complianceSettings')) {
const { id, ...withoutId } = row;
await tx.complianceSettings.upsert({
where: { id: (id as string) ?? 'singleton' },
create: row as never,
update: withoutId as never,
});
}
imported.complianceSettings = rowsOf('complianceSettings').length;
// The open draft of a kind and year is unique (openKey): a draft of
// this database that holds the key under another id is kept and the
// file's row skipped, instead of aborting the whole restore.
let noticesWritten = 0;
let noticesSkipped = 0;
for (const row of rowsOf('authorityNotice')) {
const notice = {
...row,
createdByUserId: resolveOptionalUserId(
asString(row, 'createdByUserId'),
),
};
const openKey = asString(row, 'openKey');
if (openKey !== null) {
const holder = await tx.authorityNotice.findUnique({
where: { openKey },
select: { id: true },
});
if (holder && holder.id !== row.id) {
noticesSkipped += 1;
skippedNoticeIds.add(row.id as string);
continue;
}
}
await tx.authorityNotice.upsert({
where: { id: row.id as string },
create: notice as never,
update: notice as never,
});
noticesWritten += 1;
}
imported.authorityNotice = noticesWritten;
if (noticesSkipped > 0) {
imported.authorityNoticeSkipped = noticesSkipped;
this.logger.warn(
`${noticesSkipped} Behördenanzeige(n) übersprungen: Im Zielsystem gibt es schon einen offenen Entwurf.`,
);
}
for (const row of rowsOf('complianceAcknowledgement')) {
const acknowledgement = {
...row,
userId: resolveUserId(asString(row, 'userId')),
};
await tx.complianceAcknowledgement.upsert({
where: { id: row.id as string },
create: acknowledgement as never,
update: acknowledgement as never,
});
}
imported.complianceAcknowledgement = rowsOf(
'complianceAcknowledgement',
).length;
for (const row of rowsOf('backupSettings')) {
const { id, ...withoutId } = row;
await tx.backupSettings.upsert({
@@ -1175,7 +1335,17 @@ export class BackupService {
// written here is simply orphaned on disk (wasted space), not a
// correctness problem, since no DB row ends up pointing at it.
let documentCount = 0;
let noticeDocumentsSkipped = 0;
for (const row of rowsOf('document')) {
// The open draft of the target wins: no document of a skipped
// notice is restored, so none is left without its notice.
if (
asString(row, 'entityType') === 'AUTHORITY_NOTICE' &&
skippedNoticeIds.has(asString(row, 'entityId') ?? '')
) {
noticeDocumentsSkipped += 1;
continue;
}
const contentBase64 = row.contentBase64 as string | null | undefined;
if (!contentBase64) {
continue;
@@ -1234,6 +1404,9 @@ export class BackupService {
documentCount += 1;
}
imported.document = documentCount;
if (noticeDocumentsSkipped > 0) {
imported.authorityNoticeDocumentsSkipped = noticeDocumentsSkipped;
}
const activeUserIds = (
await tx.user.findMany({
@@ -1340,12 +1513,24 @@ export class BackupService {
entityId: 'import',
action: 'CREATE',
userId: actingUserId,
afterData: { imported, skipped, sealed: trusted },
afterData: {
imported,
skipped,
sealed: trusted,
skippedNoticeIds: [...skippedNoticeIds],
},
});
await this.removeReplacedDocumentFiles(replacedDocumentFiles);
return { imported, skipped, sealed: trusted };
return {
imported,
skipped,
sealed: trusted,
...(skippedNoticeIds.size > 0
? { skippedNoticeIds: [...skippedNoticeIds] }
: {}),
};
}
/**
@@ -0,0 +1,61 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
/**
* PATCH contract of an update DTO: an omitted field stays unchanged, null
* clears a field whose column is nullable, and null on a column that cannot
* hold it is a 400 - never silently ignored, never a 500 from the write.
*/
export interface NullContract {
dto: new () => object;
/** Fields whose column cannot hold null: null is a 400 naming the field. */
notNull: string[];
/** Fields whose column is nullable: null is accepted. */
clearable: string[];
}
async function errorPropertiesFor(
dto: new () => object,
body: Record<string, unknown>,
): Promise<string[]> {
const errors = await validate(plainToInstance(dto, body), {
whitelist: true,
forbidNonWhitelisted: true,
});
return errors.map((error) => error.property);
}
/**
* Registers the three checks for every contract (jest `describe`/`it`): an
* empty patch passes, null on a `notNull` field is rejected with exactly that
* field, null on a `clearable` field passes. Call it at the top level of a
* spec file; each spec file keeps its own contracts.
*/
export function describeNullContracts(
title: string,
contracts: Record<string, NullContract>,
): void {
describe(title, () => {
describe.each(Object.entries(contracts))('%s', (_name, contract) => {
it('accepts an empty patch', async () => {
await expect(errorPropertiesFor(contract.dto, {})).resolves.toEqual([]);
});
if (contract.notNull.length > 0) {
it.each(contract.notNull)('rejects null for %s', async (field) => {
await expect(
errorPropertiesFor(contract.dto, { [field]: null }),
).resolves.toEqual([field]);
});
}
if (contract.clearable.length > 0) {
it.each(contract.clearable)('accepts null for %s', async (field) => {
await expect(
errorPropertiesFor(contract.dto, { [field]: null }),
).resolves.toEqual([]);
});
}
});
});
}
@@ -1,5 +1,3 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { UpdateApiaryDto } from '../../apiaries/dto/update-apiary.dto';
import { UpdateAutomationRuleDto } from '../../automation/dto/update-automation-rule.dto';
import { UpdateBackupSettingsDto } from '../../backup/dto/update-backup-settings.dto';
@@ -35,15 +33,10 @@ import { UpdateUserDto } from '../../users/dto/update-user.dto';
import { UpdateVarroaMeasurementDto } from '../../varroa/dto/update-varroa-measurement.dto';
import { UpdateWebhookDto } from '../../webhooks/dto/update-webhook.dto';
import { UpdateWinterCheckDto } from '../../winter-checks/dto/update-winter-check.dto';
// PATCH contract of every update DTO: an omitted field stays unchanged, null
// clears a field whose column is nullable, and null on a column that cannot
// hold it is a 400 - never silently ignored, never a 500 from the write.
interface NullContract {
dto: new () => object;
notNull: string[];
clearable: string[];
}
import {
describeNullContracts,
NullContract,
} from './null-contract.spec-helper';
const contracts: Record<string, NullContract> = {
UpdateApiaryDto: {
@@ -374,37 +367,4 @@ const contracts: Record<string, NullContract> = {
},
};
async function errorPropertiesFor(
dto: new () => object,
body: Record<string, unknown>,
): Promise<string[]> {
const errors = await validate(plainToInstance(dto, body), {
whitelist: true,
forbidNonWhitelisted: true,
});
return errors.map((error) => error.property);
}
describe('update DTO null handling', () => {
describe.each(Object.entries(contracts))('%s', (_name, contract) => {
it('accepts an empty patch', async () => {
await expect(errorPropertiesFor(contract.dto, {})).resolves.toEqual([]);
});
if (contract.notNull.length > 0) {
it.each(contract.notNull)('rejects null for %s', async (field) => {
await expect(
errorPropertiesFor(contract.dto, { [field]: null }),
).resolves.toEqual([field]);
});
}
if (contract.clearable.length > 0) {
it.each(contract.clearable)('accepts null for %s', async (field) => {
await expect(
errorPropertiesFor(contract.dto, { [field]: null }),
).resolves.toEqual([]);
});
}
});
});
describeNullContracts('update DTO null handling', contracts);
@@ -113,6 +113,18 @@ describe('deliveryAvailableAt', () => {
expect(deliveryAvailableAt(now, 'SUGGESTION', NO_QUIET)).toEqual(now);
});
it('COMPLIANCE has no time of day: available now, inside quiet hours at their end', () => {
const afternoon = at('2026-10-13', '15:00');
const night = at('2026-10-13', '23:00');
expect(deliveryAvailableAt(afternoon, 'COMPLIANCE', DEFAULTS)).toEqual(
afternoon,
);
expect(deliveryAvailableAt(night, 'COMPLIANCE', DEFAULTS)).toEqual(
at('2026-10-14', '06:30'),
);
});
it('ends quiet hours at 06:30 Berlin time across the clock change', () => {
const now = at('2026-10-24', '22:00');
@@ -0,0 +1,36 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { NotificationCategory } from '../../../generated/prisma';
import { UpdateNotificationSettingsDto } from './update-notification-settings.dto';
async function errorsOf(body: Record<string, unknown>): Promise<string[]> {
const errors = await validate(
plainToInstance(UpdateNotificationSettingsDto, body),
{ whitelist: true, forbidNonWhitelisted: true },
);
return errors.map((error) => error.property);
}
describe('UpdateNotificationSettingsDto telegramCategories', () => {
it('accepts COMPLIANCE like every other category', async () => {
await expect(
errorsOf({ telegramCategories: ['COMPLIANCE'] }),
).resolves.toEqual([]);
});
it('accepts the complete list of categories, COMPLIANCE included', async () => {
await expect(
errorsOf({ telegramCategories: Object.values(NotificationCategory) }),
).resolves.toEqual([]);
expect(Object.values(NotificationCategory)).toContain('COMPLIANCE');
});
it('rejects an unknown category and a duplicate', async () => {
await expect(
errorsOf({ telegramCategories: ['AUTHORITY'] }),
).resolves.toEqual(['telegramCategories']);
await expect(
errorsOf({ telegramCategories: ['COMPLIANCE', 'COMPLIANCE'] }),
).resolves.toEqual(['telegramCategories']);
});
});
@@ -15,7 +15,7 @@ const stored = {
quietFrom: '21:00' as string | null,
quietUntil: '06:30' as string | null,
telegramEnabled: false,
telegramCategories: ['TASK_DUE', 'DIGEST'],
telegramCategories: ['TASK_DUE', 'DIGEST', 'COMPLIANCE'],
telegramDetail: true,
updatedAt: new Date(0),
};
@@ -167,7 +167,7 @@ describe('NotificationPreferencesService', () => {
digestTime: '07:00',
quietFrom: '21:00',
quietUntil: '06:30',
telegramCategories: ['TASK_DUE', 'DIGEST'],
telegramCategories: ['TASK_DUE', 'DIGEST', 'COMPLIANCE'],
telegramDetail: false,
},
});
+53 -2
View File
@@ -199,7 +199,14 @@ describe('lock keys per table', () => {
'StockItem',
'Treatment',
'VarroaMeasurement',
'Apiary',
'NotificationDelivery',
'LabelTemplate',
'VeterinaryMedicine',
'VeterinaryMedicineLot',
'AuthorityNotice',
'ApiaryRestriction',
'ComplianceAcknowledgement',
] as const)('locks %s by "id"', async (table) => {
const tx = { $queryRaw: jest.fn().mockResolvedValue([]) };
@@ -219,8 +226,9 @@ describe('lock keys per table', () => {
expect(tx.$queryRaw).not.toHaveBeenCalled();
});
it('keeps the map in lock order: Colony, Suggestion, Task, Queen, AutomationRule, then the rest', () => {
it('keeps the map in lock order: Apiary before Colony, the T6 tables last', () => {
expect(LOCK_ORDER).toEqual([
'Apiary',
'Colony',
'Suggestion',
'Task',
@@ -237,11 +245,54 @@ describe('lock keys per table', () => {
'Treatment',
'VarroaMeasurement',
'NotificationPreference',
'Apiary',
'PhenologyReport',
'Extraction',
'HoneyBatch',
'NotificationDelivery',
'LabelTemplate',
'SiteSettings',
'VeterinaryMedicine',
'VeterinaryMedicineLot',
'ComplianceSettings',
'AuthorityNotice',
'ApiaryRestriction',
'ComplianceAcknowledgement',
]);
});
it('every table of the map is lockable by id and each appears once', async () => {
expect(new Set(LOCK_ORDER).size).toBe(LOCK_ORDER.length);
const keyed = new Map([['NotificationPreference', 'userId']]);
for (const table of LOCK_ORDER) {
const tx = { $queryRaw: jest.fn().mockResolvedValue([]) };
await lockRowForUpdate(tx as never, table, 'x1');
const key = keyed.get(table) ?? 'id';
const column = key === 'id' ? 'id' : `"${key}"`;
const [[query]] = tx.$queryRaw.mock.calls as [[Prisma.Sql]];
expect(query.sql).toBe(
`SELECT ${column} FROM "${table}" WHERE ${column} = ? FOR UPDATE`,
);
}
});
it.each(['SiteSettings', 'ComplianceSettings'] as const)(
'locks the %s singleton row by "id"',
async (table) => {
const tx = {
$queryRaw: jest.fn().mockResolvedValue([{ id: 'singleton' }]),
};
await expect(
lockRowForUpdate(tx as never, table, 'singleton'),
).resolves.toBe(true);
const [[query]] = tx.$queryRaw.mock.calls as [[Prisma.Sql]];
expect(query.sql).toBe(
`SELECT id FROM "${table}" WHERE id = ? FOR UPDATE`,
);
expect(query.values).toEqual(['singleton']);
},
);
});
+18 -4
View File
@@ -13,8 +13,11 @@ function lockable(table: string, key = 'id') {
// Fixed identifier map: a table or key column name is never taken from input.
// The order is the lock order: a transaction that locks rows of several
// tables takes them in this order (and ascending key within a table), so two
// of them cannot wait for each other.
// of them cannot wait for each other. Apiary comes first: the paths that lock
// an apiary and a colony together (syncApiaryIn, moving, merging, creating a
// colony) take the apiary rows before the colony rows.
const LOCKABLE_TABLES = {
Apiary: lockable('Apiary'),
Colony: lockable('Colony'),
Suggestion: lockable('Suggestion'),
Task: lockable('Task'),
@@ -32,14 +35,25 @@ const LOCKABLE_TABLES = {
VarroaMeasurement: lockable('VarroaMeasurement'),
// One row per user, keyed by the user (the table arrives with Task 3).
NotificationPreference: lockable('NotificationPreference', 'userId'),
// T4b tables (Task 23): the apiary season state, phenology reports and the
// honey chain (extraction, honey batch ledger).
Apiary: lockable('Apiary'),
// T4b tables (Task 23): phenology reports and the honey chain (extraction,
// honey batch ledger).
PhenologyReport: lockable('PhenologyReport'),
Extraction: lockable('Extraction'),
HoneyBatch: lockable('HoneyBatch'),
// T4c (Task 34): the Telegram delivery outbox.
NotificationDelivery: lockable('NotificationDelivery'),
// T6 (Task 1): label templates, the settings singleton (row "singleton"),
// the veterinary medicine catalogue and its packs, the compliance settings
// singleton (row "singleton", the mutex of the authority notices), the
// notices, the apiary restrictions and the readiness acknowledgements.
LabelTemplate: lockable('LabelTemplate'),
SiteSettings: lockable('SiteSettings'),
VeterinaryMedicine: lockable('VeterinaryMedicine'),
VeterinaryMedicineLot: lockable('VeterinaryMedicineLot'),
ComplianceSettings: lockable('ComplianceSettings'),
AuthorityNotice: lockable('AuthorityNotice'),
ApiaryRestriction: lockable('ApiaryRestriction'),
ComplianceAcknowledgement: lockable('ComplianceAcknowledgement'),
} as const;
export type LockableTable = keyof typeof LOCKABLE_TABLES;
+214
View File
@@ -26,6 +26,15 @@ const SECTIONS_CHILD_FIRST = [
'storageContainer',
'extraction',
'harvest',
'veterinaryMedicineMovement',
'treatment',
'veterinaryMedicineLot',
'veterinaryMedicine',
'complianceAcknowledgement',
'authorityNotice',
'complianceSettings',
'colony',
'apiaryRestriction',
'apiary',
'product',
'automationSequence',
@@ -228,6 +237,118 @@ describe('Backup round trip (e2e)', () => {
]) {
remember('batchMovement', await prisma.batchMovement.create({ data }));
}
// T6: catalogue, pack, a treatment booked from it, the ledger rows, the
// authority notice, the restriction and the acknowledgement.
const colony = remember(
'colony',
await prisma.colony.create({
data: {
code: `B-${suffix}`,
apiaryId: apiary.id,
colonyType: 'PRODUCTION_COLONY',
},
}),
);
const medicine = remember(
'veterinaryMedicine',
await prisma.veterinaryMedicine.create({
data: { name: `Mittel ${suffix}`, unit: 'ML', withdrawalPeriodDays: 0 },
}),
);
const medicineLot = remember(
'veterinaryMedicineLot',
await prisma.veterinaryMedicineLot.create({
data: {
medicineId: medicine.id,
purchasedOn: day,
quantity: 100,
supplier: 'Lieferant',
userId,
},
}),
);
const treatment = remember(
'treatment',
await prisma.treatment.create({
data: {
colonyId: colony.id,
userId,
date: day,
product: `Mittel ${suffix}`,
medicineId: medicine.id,
medicineLotId: medicineLot.id,
doseQuantity: 5,
},
}),
);
remember(
'veterinaryMedicineMovement',
await prisma.veterinaryMedicineMovement.create({
data: {
lotId: medicineLot.id,
type: 'TREATMENT',
quantity: -5,
treatmentId: treatment.id,
userId,
},
}),
);
remember(
'veterinaryMedicineMovement',
await prisma.veterinaryMedicineMovement.create({
data: {
lotId: medicineLot.id,
type: 'DISPOSAL',
quantity: -2,
reason: 'Abgelaufen',
userId,
},
}),
);
remember(
'apiaryRestriction',
await prisma.apiaryRestriction.create({
data: {
apiaryId: apiary.id,
disease: 'Test',
startsOn: day,
createdByUserId: userId,
},
}),
);
remember(
'authorityNotice',
await prisma.authorityNotice.create({
data: {
kind: 'TSK_REPORT',
year: 2098,
referenceDate: day,
countedAt: day,
snapshot: { version: 1, totalLiving: 1 },
totalColonies: 1,
openKey: `TSK_REPORT:2098:${suffix}`,
createdByUserId: userId,
},
}),
);
remember(
'complianceAcknowledgement',
await prisma.complianceAcknowledgement.create({
data: {
itemKey: 'backup-test',
subjectId: suffix,
fingerprint: 'abc',
note: 'Geprüft und in Ordnung',
userId,
},
}),
);
remember(
'complianceSettings',
await prisma.complianceSettings.create({
data: { id: `backup-${suffix}`, registrationNumber: 'R-1' },
}),
);
const sequence = await prisma.automationSequence.create({
data: {
triggerType: 'RAW_MATERIAL_LOT_NUMBER_FORMAT',
@@ -250,6 +371,23 @@ describe('Backup round trip (e2e)', () => {
}, ROUND_TRIP_TIMEOUT_MS);
afterAll(async () => {
// The T6 rows reference users and colonies of this spec: remove them,
// children first, so the shared database does not collect compliance data.
for (const section of [
'veterinaryMedicineMovement',
'treatment',
'veterinaryMedicineLot',
'veterinaryMedicine',
'complianceAcknowledgement',
'authorityNotice',
'complianceSettings',
'apiaryRestriction',
] as const) {
const delegate = prisma[section] as unknown as {
deleteMany(args: { where: { id: { in: string[] } } }): Promise<unknown>;
};
await delegate.deleteMany({ where: { id: { in: [...own[section]] } } });
}
await app.close();
});
@@ -328,6 +466,35 @@ describe('Backup round trip (e2e)', () => {
calendarSyncOutboxEntry: await prisma.calendarSyncOutboxEntry.count(
where('calendarSyncOutboxEntry'),
),
veterinaryMedicine: await prisma.veterinaryMedicine.count(
where('veterinaryMedicine'),
),
veterinaryMedicineLot: await prisma.veterinaryMedicineLot.count(
where('veterinaryMedicineLot'),
),
treatmentWithPack: await prisma.treatment.count({
...where('treatment'),
where: {
id: { in: [...own.treatment] },
medicineLotId: { not: null },
},
}),
veterinaryMedicineMovement:
await prisma.veterinaryMedicineMovement.count(
where('veterinaryMedicineMovement'),
),
apiaryRestriction: await prisma.apiaryRestriction.count(
where('apiaryRestriction'),
),
authorityNotice: await prisma.authorityNotice.count(
where('authorityNotice'),
),
complianceAcknowledgement: await prisma.complianceAcknowledgement.count(
where('complianceAcknowledgement'),
),
complianceSettings: await prisma.complianceSettings.count(
where('complianceSettings'),
),
}).toEqual({
productionBatch: 1,
rawMaterialLot: 1,
@@ -337,6 +504,14 @@ describe('Backup round trip (e2e)', () => {
honeyBatchContainerAllocation: 1,
automationSequence: 1,
calendarSyncOutboxEntry: 1,
veterinaryMedicine: 1,
veterinaryMedicineLot: 1,
treatmentWithPack: 1,
veterinaryMedicineMovement: 2,
apiaryRestriction: 1,
authorityNotice: 1,
complianceAcknowledgement: 1,
complianceSettings: 1,
});
expect(ownRows(await backup.exportAll())).toEqual(exported);
},
@@ -387,6 +562,45 @@ describe('Backup round trip (e2e)', () => {
).toBeNull();
});
it('takes no T6 compliance table from an unsealed file and reports them', async () => {
const medicineId = `medicine-forged-${suffix}`;
const noticeId = `notice-forged-${suffix}`;
const summary = await backup.importFile(
{
version: 2,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
veterinaryMedicine: [
{ id: medicineId, name: 'Gefälscht', unit: 'ML' },
],
authorityNotice: [
{
id: noticeId,
kind: 'REGISTRATION',
referenceDate: '2026-01-01T00:00:00.000Z',
countedAt: '2026-01-01T00:00:00.000Z',
snapshot: {},
totalColonies: 1,
},
],
},
},
userId,
);
expect([...(summary.skipped ?? [])].sort()).toEqual([
'authorityNotice',
'veterinaryMedicine',
]);
expect(
await prisma.veterinaryMedicine.findUnique({ where: { id: medicineId } }),
).toBeNull();
expect(
await prisma.authorityNotice.findUnique({ where: { id: noticeId } }),
).toBeNull();
});
it('removes the file a restored document row replaced', async () => {
const directory = path.join(
path.resolve(process.env.DOCUMENTS_STORAGE_PATH ?? './uploads'),
@@ -0,0 +1,116 @@
import { PERMISSION_CATALOG } from '../src/auth/permissions/permission-catalog';
import { PrismaService } from '../src/prisma/prisma.service';
const NEW_KEYS = [
'veterinary-medicines:read',
'veterinary-medicines:create',
'veterinary-medicines:update',
'compliance:read',
'compliance:manage',
] as const;
/**
* The migrations grant the T6 permissions to the system roles (the boot-time
* catalog sync only grants new permissions to ADMIN). Checked against the
* migrated test database.
*/
describe('T6 permissions (e2e)', () => {
let prisma: PrismaService;
beforeAll(() => {
prisma = new PrismaService();
});
afterAll(async () => {
await prisma.$disconnect();
});
async function newKeysOf(roleKey: string): Promise<string[]> {
const grants = await prisma.rolePermission.findMany({
where: {
role: { key: roleKey },
permission: { key: { in: [...NEW_KEYS] } },
},
include: { permission: { select: { key: true } } },
});
return grants.map((grant) => grant.permission.key).sort();
}
it('BEEKEEPER holds compliance:manage and veterinary-medicines:update', async () => {
const keys = await newKeysOf('BEEKEEPER');
expect(keys).toEqual([...NEW_KEYS].sort());
expect(keys).toContain('compliance:manage');
expect(keys).toContain('veterinary-medicines:update');
});
it('ADMIN holds all five', async () => {
expect(await newKeysOf('ADMIN')).toEqual([...NEW_KEYS].sort());
});
it('STAFF holds compliance:read and veterinary-medicines:create but not compliance:manage', async () => {
const keys = await newKeysOf('STAFF');
expect(keys).toContain('compliance:read');
expect(keys).toContain('veterinary-medicines:create');
expect(keys).not.toContain('compliance:manage');
expect(keys).toEqual(
[
'veterinary-medicines:read',
'veterinary-medicines:create',
'veterinary-medicines:update',
'compliance:read',
].sort(),
);
});
it('READONLY holds the two read permissions only', async () => {
expect(await newKeysOf('READONLY')).toEqual(
['compliance:read', 'veterinary-medicines:read'].sort(),
);
});
it('API_CLIENT holds none of the five new permissions', async () => {
expect(await newKeysOf('API_CLIENT')).toEqual([]);
});
it('the database rows carry the catalog labels, groups and sort order', async () => {
const rows = await prisma.permission.findMany({
where: { key: { in: [...NEW_KEYS] } },
});
expect(rows).toHaveLength(NEW_KEYS.length);
for (const row of rows) {
const entry = PERMISSION_CATALOG.find((e) => e.key === row.key);
expect(entry).toBeDefined();
expect({
resource: row.resource,
action: row.action,
labelDe: row.labelDe,
groupDe: row.groupDe,
sortOrder: row.sortOrder,
}).toEqual({
resource: entry?.resource,
action: entry?.action,
labelDe: entry?.labelDe,
groupDe: entry?.groupDe,
sortOrder: entry?.sortOrder,
});
}
});
it('the catalog groups the new permissions at 3700 and 3800', () => {
const order = (key: string) =>
PERMISSION_CATALOG.find((e) => e.key === key)?.sortOrder;
const group = (key: string) =>
PERMISSION_CATALOG.find((e) => e.key === key)?.groupDe;
expect(order('veterinary-medicines:read')).toBe(3700);
expect(order('veterinary-medicines:create')).toBe(3701);
expect(order('veterinary-medicines:update')).toBe(3702);
expect(order('compliance:read')).toBe(3800);
expect(order('compliance:manage')).toBe(3801);
expect(group('veterinary-medicines:read')).toBe('Tierarzneimittel');
expect(group('compliance:manage')).toBe('Prüfbereitschaft & Behörden');
});
});
+19 -1
View File
@@ -357,7 +357,7 @@ describe('Notifications (e2e)', () => {
digestTime: '06:30',
quietFrom: '21:00',
quietUntil: '06:30',
telegramCategories: ['TASK_DUE', 'DIGEST'],
telegramCategories: ['TASK_DUE', 'DIGEST', 'COMPLIANCE'],
telegramDetail: true,
});
// configured / botUsername follow the environment of the process.
@@ -399,6 +399,24 @@ describe('Notifications (e2e)', () => {
expect(stored.digestTime).toBe('07:15');
});
it('accepts COMPLIANCE as a Telegram category and rejects an unknown one', async () => {
const response = await request(server())
.patch('/api/v1/profile/notification-settings')
.set(as('staff'))
.send({ telegramCategories: ['COMPLIANCE', 'DIGEST'] })
.expect(200);
expect((response.body as SettingsBody).telegramCategories).toEqual([
'COMPLIANCE',
'DIGEST',
]);
await request(server())
.patch('/api/v1/profile/notification-settings')
.set(as('staff'))
.send({ telegramCategories: ['AUTHORITY'] })
.expect(400);
});
it('rejects a digest time outside 05:00 to 12:00 and a lone quiet-hour value', async () => {
const early = await request(server())
.patch('/api/v1/profile/notification-settings')
@@ -0,0 +1,93 @@
import {
ScratchDatabase,
createScratchDatabase,
} from './utils/migration-harness';
const BEFORE_COMPLIANCE_CATEGORY =
'20261016100300_t6_notification_category_compliance';
const COMPLIANCE_DEFAULT = '20261016100400_t6_notification_compliance_default';
const TIMEOUT_MS = 240_000;
async function seedUsers(db: ScratchDatabase): Promise<void> {
await db.query(
`INSERT INTO "Role" ("id", "key", "name", "updatedAt")
VALUES ('role-1', 'MIG_TEST', 'Migration test', now())`,
);
for (const id of ['user-a', 'user-b', 'user-c', 'user-d']) {
await db.query(
`INSERT INTO "User" ("id", "email", "passwordHash", "name", "roleId", "updatedAt")
VALUES ($1, $2, 'x', $1, 'role-1', now())`,
[id, `${id}@example.com`],
);
}
}
describe('T6 migrations (e2e)', () => {
let db: ScratchDatabase | undefined;
afterEach(async () => {
await db?.drop();
db = undefined;
});
it(
'adds COMPLIANCE to the Telegram categories of every existing preference and keeps the rest, an empty list (opt-out) stays empty',
async () => {
db = await createScratchDatabase(BEFORE_COMPLIANCE_CATEGORY);
await seedUsers(db);
await db.query(
`INSERT INTO "NotificationPreference" ("userId", "telegramCategories", "updatedAt") VALUES
('user-a', DEFAULT, now()),
('user-b', '{SYSTEM}', now()),
('user-c', '{}', now()),
('user-d', '{DIGEST,TASK_DUE,SEASON}', now())`,
);
await db.migrateThrough(COMPLIANCE_DEFAULT);
expect(
await db.query(
`SELECT "userId", "telegramCategories"::text AS "categories"
FROM "NotificationPreference" ORDER BY "userId"`,
),
).toEqual([
{ userId: 'user-a', categories: '{TASK_DUE,DIGEST,COMPLIANCE}' },
{ userId: 'user-b', categories: '{SYSTEM,COMPLIANCE}' },
{ userId: 'user-c', categories: '{}' },
{ userId: 'user-d', categories: '{DIGEST,TASK_DUE,SEASON,COMPLIANCE}' },
]);
},
TIMEOUT_MS,
);
it(
'gives a preference created afterwards the new default and lists COMPLIANCE once',
async () => {
db = await createScratchDatabase(BEFORE_COMPLIANCE_CATEGORY);
await seedUsers(db);
await db.migrateThrough(COMPLIANCE_DEFAULT);
await db.query(
`INSERT INTO "NotificationPreference" ("userId", "updatedAt") VALUES ('user-a', now())`,
);
expect(
await db.query(
`SELECT "telegramCategories"::text AS "categories" FROM "NotificationPreference"`,
),
).toEqual([{ categories: '{TASK_DUE,DIGEST,COMPLIANCE}' }]);
expect(
await db.query(
`SELECT enum_range(NULL::"NotificationCategory")::text AS "values"`,
),
).toEqual([
{
values:
'{TASK_DUE,DIGEST,SUGGESTION,WITHDRAWAL,SEASON,SYSTEM,COMPLIANCE}',
},
]);
},
TIMEOUT_MS,
);
});
+297
View File
@@ -0,0 +1,297 @@
import { PrismaService } from '../src/prisma/prisma.service';
/**
* Schema-level guarantees of the T6 migrations that the Prisma schema alone
* cannot express (CHECK constraints, the JSON default, the nullable unique
* key, RESTRICT) against a real PostgreSQL. The test database is shared and
* not empty: every row here is created by this file and removed again, and
* the singleton tables are exercised through rows with their own id.
*/
describe('T6 schema (e2e)', () => {
let prisma: PrismaService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
const CHECK_VIOLATION = /check constraint|violates/i;
let userId: string;
let apiaryId: string;
let colonyId: string;
let treatmentId: string;
const medicineIds: string[] = [];
const lotIds: string[] = [];
const noticeIds: string[] = [];
const restrictionIds: string[] = [];
const templateIds: string[] = [];
const settingsIds: string[] = [];
async function createMedicine(label: string): Promise<string> {
const medicine = await prisma.veterinaryMedicine.create({
data: { name: `T6 ${label} ${suffix}`, unit: 'ML' },
});
medicineIds.push(medicine.id);
return medicine.id;
}
async function createLot(
medicineId: string,
quantity = 100,
): Promise<string> {
const lot = await prisma.veterinaryMedicineLot.create({
data: { medicineId, purchasedOn: new Date('2026-08-01'), quantity },
});
lotIds.push(lot.id);
return lot.id;
}
beforeAll(async () => {
prisma = new PrismaService();
const role = await prisma.role.findUniqueOrThrow({
where: { key: 'READONLY' },
});
const user = await prisma.user.create({
data: {
email: `t6-schema-${suffix}@example.com`,
passwordHash: 'x',
name: 'T6 schema',
roleId: role.id,
},
});
userId = user.id;
const apiary = await prisma.apiary.create({
data: { name: `T6 Stand ${suffix}` },
});
apiaryId = apiary.id;
const colony = await prisma.colony.create({
data: { code: `T6-${suffix}`, apiaryId, colonyType: 'PRODUCTION_COLONY' },
});
colonyId = colony.id;
const treatment = await prisma.treatment.create({
data: { colonyId, userId, date: new Date('2026-08-02'), product: 'T6' },
});
treatmentId = treatment.id;
});
afterAll(async () => {
await prisma.veterinaryMedicineMovement.deleteMany({
where: { lotId: { in: lotIds } },
});
await prisma.treatment.deleteMany({ where: { id: treatmentId } });
await prisma.veterinaryMedicineLot.deleteMany({
where: { id: { in: lotIds } },
});
await prisma.veterinaryMedicine.deleteMany({
where: { id: { in: medicineIds } },
});
await prisma.apiaryRestriction.deleteMany({
where: { id: { in: restrictionIds } },
});
await prisma.authorityNotice.deleteMany({
where: { id: { in: noticeIds } },
});
await prisma.labelTemplate.deleteMany({
where: { id: { in: templateIds } },
});
await prisma.complianceSettings.deleteMany({
where: { id: { in: settingsIds } },
});
await prisma.colony.deleteMany({ where: { id: colonyId } });
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
await prisma.user.deleteMany({ where: { id: userId } });
await prisma.$disconnect();
});
describe('veterinary medicines', () => {
it('the CHECK rejects a lot with quantity 0', async () => {
const medicineId = await createMedicine('lot0');
await expect(createLot(medicineId, 0)).rejects.toThrow(CHECK_VIOLATION);
await expect(createLot(medicineId, -1)).rejects.toThrow(CHECK_VIOLATION);
});
it('the CHECK rejects a movement with quantity 0', async () => {
const lotId = await createLot(await createMedicine('mov0'));
await expect(
prisma.veterinaryMedicineMovement.create({
data: { lotId, type: 'CORRECTION', quantity: 0 },
}),
).rejects.toThrow(CHECK_VIOLATION);
});
it('the CHECK rejects a TREATMENT movement without treatmentId and a CORRECTION movement with one', async () => {
const lotId = await createLot(await createMedicine('type'));
await expect(
prisma.veterinaryMedicineMovement.create({
data: { lotId, type: 'TREATMENT', quantity: -5 },
}),
).rejects.toThrow(CHECK_VIOLATION);
await expect(
prisma.veterinaryMedicineMovement.create({
data: { lotId, type: 'CORRECTION', quantity: -5, treatmentId },
}),
).rejects.toThrow(CHECK_VIOLATION);
await expect(
prisma.veterinaryMedicineMovement.create({
data: { lotId, type: 'DISPOSAL', quantity: -5, treatmentId },
}),
).rejects.toThrow(CHECK_VIOLATION);
});
it('accepts the valid combinations', async () => {
const lotId = await createLot(await createMedicine('valid'));
const booked = await prisma.veterinaryMedicineMovement.create({
data: { lotId, type: 'TREATMENT', quantity: -5, treatmentId },
});
const corrected = await prisma.veterinaryMedicineMovement.create({
data: { lotId, type: 'CORRECTION', quantity: 2.5, reason: 'Inventur' },
});
expect(booked.treatmentId).toBe(treatmentId);
expect(corrected.treatmentId).toBeNull();
});
it('a medicine with lots cannot be deleted (RESTRICT)', async () => {
const medicineId = await createMedicine('restrict');
await createLot(medicineId);
await expect(
prisma.veterinaryMedicine.delete({ where: { id: medicineId } }),
).rejects.toThrow();
expect(
await prisma.veterinaryMedicine.count({ where: { id: medicineId } }),
).toBe(1);
});
it('a lot with movements cannot be deleted (RESTRICT)', async () => {
const lotId = await createLot(await createMedicine('restrict2'));
await prisma.veterinaryMedicineMovement.create({
data: { lotId, type: 'CORRECTION', quantity: 1 },
});
await expect(
prisma.veterinaryMedicineLot.delete({ where: { id: lotId } }),
).rejects.toThrow();
});
});
describe('label particulars', () => {
it('rejects minXHeightMm below 0.5 and above 5', async () => {
const create = async (minXHeightMm: number) => {
const template = await prisma.labelTemplate.create({
data: { name: `T6 ${suffix}`, minXHeightMm },
});
templateIds.push(template.id);
return template;
};
await expect(create(0.49)).rejects.toThrow(CHECK_VIOLATION);
await expect(create(5.01)).rejects.toThrow(CHECK_VIOLATION);
await expect(create(0.5)).resolves.toMatchObject({ minXHeightMm: 0.5 });
await expect(create(5)).resolves.toMatchObject({ minXHeightMm: 5 });
});
it('defaults minXHeightMm to 1.2', async () => {
const template = await prisma.labelTemplate.create({
data: { name: `T6 default ${suffix}` },
});
templateIds.push(template.id);
expect(template.minXHeightMm).toBe(1.2);
});
});
describe('authority compliance', () => {
it('rejects an ApiaryRestriction that ends before it starts', async () => {
const create = async (endsOn: Date | null) => {
const restriction = await prisma.apiaryRestriction.create({
data: {
apiaryId,
disease: 'T6',
startsOn: new Date('2026-09-10'),
endsOn,
},
});
restrictionIds.push(restriction.id);
return restriction;
};
await expect(create(new Date('2026-09-09'))).rejects.toThrow(
CHECK_VIOLATION,
);
await expect(create(new Date('2026-09-10'))).resolves.toBeDefined();
await expect(create(null)).resolves.toBeDefined();
});
it('an apiary with restrictions cannot be deleted (RESTRICT)', async () => {
const lonely = await prisma.apiary.create({
data: { name: `T6 Sperrstand ${suffix}` },
});
const restriction = await prisma.apiaryRestriction.create({
data: {
apiaryId: lonely.id,
disease: 'T6',
startsOn: new Date('2026-09-10'),
},
});
restrictionIds.push(restriction.id);
await expect(
prisma.apiary.delete({ where: { id: lonely.id } }),
).rejects.toThrow();
await prisma.apiaryRestriction.delete({ where: { id: restriction.id } });
await prisma.apiary.delete({ where: { id: lonely.id } });
});
it('ComplianceSettings default netQuantityDigitRules equals the four rules of the spec', async () => {
const settings = await prisma.complianceSettings.create({
data: { id: `t6-${suffix}` },
});
settingsIds.push(settings.id);
expect(settings.netQuantityDigitRules).toEqual([
{ maxGrams: 50, minMm: 2 },
{ maxGrams: 200, minMm: 3 },
{ maxGrams: 1000, minMm: 4 },
{ maxGrams: null, minMm: 6 },
]);
expect(settings).toMatchObject({
noticeOnColonyCountChange: true,
noticeOnApiaryChange: true,
tskReferenceDay: '01-01',
tskReportDueDay: null,
tskExcludedColonyTypes: [],
});
});
it('openKey is unique, several rows with openKey NULL are allowed', async () => {
const notice = (openKey: string | null) =>
prisma.authorityNotice.create({
data: {
kind: 'TSK_REPORT',
year: 2099,
referenceDate: new Date('2099-01-01'),
countedAt: new Date('2099-01-01'),
snapshot: { version: 1 },
totalColonies: 3,
openKey,
},
});
const key = `TSK_REPORT:2099:${suffix}`;
const first = await notice(key);
noticeIds.push(first.id);
await expect(notice(key)).rejects.toThrow();
const sentA = await notice(null);
const sentB = await notice(null);
noticeIds.push(sentA.id, sentB.id);
expect(
await prisma.authorityNotice.count({
where: { id: { in: [first.id, sentA.id, sentB.id] } },
}),
).toBe(3);
});
});
});
+17 -4
View File
@@ -998,13 +998,20 @@ describe('Telegram delivery (e2e)', () => {
it('an old backlog never reaches a chat linked later', async () => {
const user = await createUnlinkedUser('backlog');
const waiting = await queue(user.id, { message: 'alt' });
const backlogText = `Alter Rückstand ${suffix}`;
const waiting = await queue(user.id, { message: backlogText });
await prisma.telegramLink.create({
data: { userId: user.id, chatId: user.chatId, telegramFirstName: 'x' },
});
// Quiet hours all day keep the "Telegram verbunden" notice (SYSTEM)
// back whatever the time of day; the check below does not depend on it.
await prisma.notificationPreference.update({
where: { userId: user.id },
data: { telegramEnabled: true },
data: {
telegramEnabled: true,
quietFrom: '00:00',
quietUntil: '23:59',
},
});
const token = await newLinkToken(user.id);
const newChat = chatBase + 9_400n;
@@ -1014,8 +1021,14 @@ describe('Telegram delivery (e2e)', () => {
expect((await deliveryOf(waiting.deliveryId)).status).toBe('SKIPPED');
await drainFor(user.id);
expect(telegram.sentTo(newChat)).toHaveLength(0);
expect(telegram.sentTo(user.chatId)).toHaveLength(0);
// The backlog text reaches neither chat; the link notice may.
for (const chatId of [newChat, user.chatId]) {
expect(
telegram
.sentTo(chatId)
.filter((sent) => sent.text.includes(backlogText)),
).toEqual([]);
}
});
// Last on purpose: it uses up the 10-per-minute budget of the route.
+1 -1
View File
@@ -6,5 +6,5 @@
// outside it would make the next build skip "unchanged" files.
"incremental": false
},
"exclude": ["node_modules", "test", "dist", "**/*spec.ts", "prisma.config.ts", "prisma/seed.ts"]
"exclude": ["node_modules", "test", "dist", "**/*spec.ts", "**/*.spec-helper.ts", "prisma.config.ts", "prisma/seed.ts"]
}