feat: T6 wave 1 – compliance schema, permissions, lock order, backup
- 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:
1 parent
7a1480138d
commit
8a31c42578
23 files changed
+2014
-70
No files matched your search
+146
@@ -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);
|
||||
+140
@@ -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;
|
||||
+7
@@ -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';
|
||||
+13
@@ -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"));
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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']);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in new issue
Block a user