From 8a31c42578204a5f6cf540da0b838ca614143e61 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Thu, 15 Oct 2026 13:04:13 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20T6=20wave=201=20=E2=80=93=20compliance?= =?UTF-8?q?=20schema,=20permissions,=20lock=20order,=20backup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../migration.sql | 146 +++++++++ .../migration.sql | 21 ++ .../migration.sql | 140 ++++++++ .../migration.sql | 7 + .../migration.sql | 13 + ApiCore-Backend/prisma/schema.prisma | 246 +++++++++++++- .../auth/permissions/permission-catalog.ts | 21 ++ .../src/backup/backup.service.spec.ts | 310 ++++++++++++++++++ ApiCore-Backend/src/backup/backup.service.ts | 205 +++++++++++- .../validation/null-contract.spec-helper.ts | 61 ++++ .../update-dto-null-handling.spec.ts | 50 +-- .../src/notifications/delivery-time.spec.ts | 12 + .../update-notification-settings.dto.spec.ts | 36 ++ .../notification-preferences.service.spec.ts | 4 +- ApiCore-Backend/src/prisma/row-lock.spec.ts | 55 +++- ApiCore-Backend/src/prisma/row-lock.ts | 22 +- ApiCore-Backend/test/backup.e2e-spec.ts | 214 ++++++++++++ .../test/compliance-permissions.e2e-spec.ts | 116 +++++++ .../test/notifications.e2e-spec.ts | 20 +- .../test/t6-migrations.e2e-spec.ts | 93 ++++++ ApiCore-Backend/test/t6-schema.e2e-spec.ts | 297 +++++++++++++++++ ApiCore-Backend/test/telegram.e2e-spec.ts | 21 +- ApiCore-Backend/tsconfig.build.json | 2 +- 23 files changed, 2028 insertions(+), 84 deletions(-) create mode 100644 ApiCore-Backend/prisma/migrations/20261016100000_t6_veterinary_medicines/migration.sql create mode 100644 ApiCore-Backend/prisma/migrations/20261016100100_t6_label_particulars/migration.sql create mode 100644 ApiCore-Backend/prisma/migrations/20261016100200_t6_authority_compliance/migration.sql create mode 100644 ApiCore-Backend/prisma/migrations/20261016100300_t6_notification_category_compliance/migration.sql create mode 100644 ApiCore-Backend/prisma/migrations/20261016100400_t6_notification_compliance_default/migration.sql create mode 100644 ApiCore-Backend/src/common/validation/null-contract.spec-helper.ts create mode 100644 ApiCore-Backend/src/notifications/dto/update-notification-settings.dto.spec.ts create mode 100644 ApiCore-Backend/test/compliance-permissions.e2e-spec.ts create mode 100644 ApiCore-Backend/test/t6-migrations.e2e-spec.ts create mode 100644 ApiCore-Backend/test/t6-schema.e2e-spec.ts diff --git a/ApiCore-Backend/prisma/migrations/20261016100000_t6_veterinary_medicines/migration.sql b/ApiCore-Backend/prisma/migrations/20261016100000_t6_veterinary_medicines/migration.sql new file mode 100644 index 0000000..e7eed94 --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20261016100000_t6_veterinary_medicines/migration.sql @@ -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; diff --git a/ApiCore-Backend/prisma/migrations/20261016100100_t6_label_particulars/migration.sql b/ApiCore-Backend/prisma/migrations/20261016100100_t6_label_particulars/migration.sql new file mode 100644 index 0000000..4e23b35 --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20261016100100_t6_label_particulars/migration.sql @@ -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); diff --git a/ApiCore-Backend/prisma/migrations/20261016100200_t6_authority_compliance/migration.sql b/ApiCore-Backend/prisma/migrations/20261016100200_t6_authority_compliance/migration.sql new file mode 100644 index 0000000..ff1d59d --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20261016100200_t6_authority_compliance/migration.sql @@ -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; diff --git a/ApiCore-Backend/prisma/migrations/20261016100300_t6_notification_category_compliance/migration.sql b/ApiCore-Backend/prisma/migrations/20261016100300_t6_notification_category_compliance/migration.sql new file mode 100644 index 0000000..522a66d --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20261016100300_t6_notification_category_compliance/migration.sql @@ -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'; diff --git a/ApiCore-Backend/prisma/migrations/20261016100400_t6_notification_compliance_default/migration.sql b/ApiCore-Backend/prisma/migrations/20261016100400_t6_notification_compliance_default/migration.sql new file mode 100644 index 0000000..c87192b --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20261016100400_t6_notification_compliance_default/migration.sql @@ -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")); diff --git a/ApiCore-Backend/prisma/schema.prisma b/ApiCore-Backend/prisma/schema.prisma index 20bad16..d211dc5 100644 --- a/ApiCore-Backend/prisma/schema.prisma +++ b/ApiCore-Backend/prisma/schema.prisma @@ -136,6 +136,12 @@ model User { honeyBatchRelocations HoneyBatchRelocation[] apiKeys ApiKey[] + veterinaryMedicineLots VeterinaryMedicineLot[] + veterinaryMedicineMovements VeterinaryMedicineMovement[] + authorityNotices AuthorityNotice[] + apiaryRestrictions ApiaryRestriction[] + complianceAcknowledgements ComplianceAcknowledgement[] + @@index([roleId]) } @@ -202,6 +208,7 @@ enum AuditAction { MFA_ENABLED MFA_DISABLED ACCOUNT_LOCKED + PRINT } model AuditLog { @@ -252,6 +259,7 @@ model Apiary { phenologyReports PhenologyReport[] phenologyStates ApiaryPhenology[] weatherDays ApiaryWeatherDay[] + restrictions ApiaryRestriction[] @@index([status]) } @@ -793,11 +801,11 @@ model WinterCheck { // --------------------------------------------------------------------------- model Treatment { - id String @id @default(cuid()) + id String @id @default(cuid()) colonyId String - colony Colony @relation(fields: [colonyId], references: [id]) + colony Colony @relation(fields: [colonyId], references: [id]) userId String - user User @relation(fields: [userId], references: [id]) + user User @relation(fields: [userId], references: [id]) date DateTime startDate DateTime? endDate DateTime? @@ -813,13 +821,25 @@ model Treatment { // day of the treatment - kept with the treatment because the colony may // be moved later - and the supplier / purchase proof / prescribing vet. apiaryId String? - apiary Apiary? @relation(fields: [apiaryId], references: [id]) + apiary Apiary? @relation(fields: [apiaryId], references: [id]) supplier String? purchaseProof String? prescribingVet String? - createdAt DateTime @default(now()) + // T6: catalogue entry, the pack the dose was taken from and the dose per + // colony in the medicine's unit. With a pack, supplier / proof / batch + // number / vet are read from the pack, never copied. + medicineId String? + medicine VeterinaryMedicine? @relation(fields: [medicineId], references: [id], onDelete: Restrict) + medicineLotId String? + medicineLot VeterinaryMedicineLot? @relation(fields: [medicineLotId], references: [id], onDelete: Restrict) + doseQuantity Float? + createdAt DateTime @default(now()) + + medicineMovements VeterinaryMedicineMovement[] @@index([colonyId, date]) + @@index([medicineId]) + @@index([medicineLotId]) } // --------------------------------------------------------------------------- @@ -1125,6 +1145,8 @@ model Product { salesUnit String? // Haltbarkeit in Monaten, kein Standardwert (MHD-Vorbelegung bei der Abfuellung). shelfLifeMonths Int? + // Bezeichnung des Lebensmittels auf dem Etikett, z. B. "Bluetenhonig" (T6). + legalName String? status ProductStatus @default(ACTIVE) kind ProductKind @default(BOTTLED_HONEY) shopCategory ShopCategory @default(HONIG) @@ -1163,6 +1185,8 @@ model LabelTemplate { // Schnittlinie - zum Anlegen am Papierschneider. Aus bei vorgestanzten // Etikettenbögen, die nicht geschnitten werden. cropMarks Boolean @default(true) + // Mindest-x-Hoehe der Pflichtangaben in mm (T6), CHECK 0,5 bis 5. + minXHeightMm Float @default(1.2) isDefault Boolean @default(false) active Boolean @default(true) createdAt DateTime @default(now()) @@ -1405,6 +1429,7 @@ enum NotificationCategory { WITHDRAWAL SEASON SYSTEM + COMPLIANCE } // Jede Zeile gehoert genau einem Empfaenger (T4: Fan-out mit Rechtepruefung @@ -1440,7 +1465,7 @@ model NotificationPreference { quietFrom String? @default("21:00") quietUntil String? @default("06:30") telegramEnabled Boolean @default(false) - telegramCategories NotificationCategory[] @default([TASK_DUE, DIGEST]) + telegramCategories NotificationCategory[] @default([TASK_DUE, DIGEST, COMPLIANCE]) telegramDetail Boolean @default(true) updatedAt DateTime @updatedAt } @@ -1547,6 +1572,9 @@ enum DocumentEntityType { QUEEN INSPECTION HONEY_BATCH + MEDICINE_LOT + AUTHORITY_NOTICE + APIARY_RESTRICTION } model Document { @@ -1637,18 +1665,22 @@ model AutomationSequence { // site-wide social links exists, and a fixed-id upsert is simpler than // enforcing "at most one row" any other way. model SiteSettings { - id String @id @default("singleton") - youtubeUrl String? - instagramUrl String? - tiktokUrl String? - websiteUrl String? + id String @id @default("singleton") + youtubeUrl String? + instagramUrl String? + tiktokUrl String? + websiteUrl String? // Name and address of the bee keeper, printed on the Bestandsbuch. Never // part of any public page. - holderName String? - holderAddress String? + holderName String? + holderAddress String? + // Name/firm and postal address of the food business operator printed on + // the label (T6) - the brand, not the bee keeper of the Bestandsbuch. + foodBusinessName String? + foodBusinessAddress String? // Country of origin printed on labels and shop output. - originCountry String @default("Deutschland") - updatedAt DateTime @updatedAt + originCountry String @default("Deutschland") + updatedAt DateTime @updatedAt } // --------------------------------------------------------------------------- @@ -1966,3 +1998,187 @@ model ApiaryWeatherDay { @@id([apiaryId, day]) } + +// --------------------------------------------------------------------------- +// T6 Pruefbereitschaft (docs/superpowers/specs/2026-10-13-t6-compliance-design.md) +// --------------------------------------------------------------------------- + +enum MedicineUnit { + ML + G + PIECE +} + +// Katalog des Imkers. Jeder Wert kommt aus Packungsbeilage oder Rezept und +// wird vom Imker eingetragen; der Code liefert keine Arzneimitteldaten +// (ARCHITECTURE Annahme 10). +model VeterinaryMedicine { + id String @id @default(cuid()) + name String + activeIngredient String? + authorisationNumber String? + prescriptionOnly Boolean @default(false) + // Wartezeit Honig in Tagen, auch 0. + withdrawalPeriodDays Int? + // Nur aenderbar, solange keine Packung existiert. + unit MedicineUnit + dosePerColony Float? + doseNote String? + applicationMethod String? + minTemperatureC Float? + maxTemperatureC Float? + defaultSupplier String? + notes String? + active Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + lots VeterinaryMedicineLot[] + treatments Treatment[] + + @@index([active]) +} + +// Eine gekaufte Packung. Ch.-B., Lieferant, Beleg und Tierarzt stehen hier +// einmal und gelten fuer jede Behandlung aus dieser Packung. CHECK quantity > 0. +model VeterinaryMedicineLot { + id String @id @default(cuid()) + // Nur aenderbar, solange keine Bewegung existiert. + medicineId String + medicine VeterinaryMedicine @relation(fields: [medicineId], references: [id], onDelete: Restrict) + batchNumber String? + purchasedOn DateTime + quantity Float + expiresOn DateTime? + supplier String? + purchaseProof String? + prescribingVet String? + notes String? + userId String? + user User? @relation(fields: [userId], references: [id]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + movements VeterinaryMedicineMovement[] + treatments Treatment[] + + @@index([medicineId, expiresOn]) +} + +enum VeterinaryMedicineMovementType { + TREATMENT + CORRECTION + DISPOSAL +} + +// Bewegungsbuch der Packungen: nie geaendert, nie geloescht; Fehler werden +// gegengebucht. Rest = quantity der Packung + Summe aller quantity. +// CHECKs: quantity <> 0; (type = 'TREATMENT') = (treatmentId IS NOT NULL). +model VeterinaryMedicineMovement { + id String @id @default(cuid()) + lotId String + lot VeterinaryMedicineLot @relation(fields: [lotId], references: [id], onDelete: Restrict) + type VeterinaryMedicineMovementType + quantity Float + treatmentId String? + treatment Treatment? @relation(fields: [treatmentId], references: [id], onDelete: Restrict) + reason String? + userId String? + user User? @relation(fields: [userId], references: [id]) + createdAt DateTime @default(now()) + + @@index([lotId]) + @@index([treatmentId]) +} + +// Singleton wie SiteSettings. Werte mit "Annahme" sind vom Inhaber zu pruefen. +model ComplianceSettings { + id String @id @default("singleton") + veterinaryOfficeName String? + veterinaryOfficeAddress String? + registrationNumber String? + noticeOnColonyCountChange Boolean @default(true) + noticeOnApiaryChange Boolean @default(true) + // MM-TT. + tskReferenceDay String @default("01-01") + tskReportDueDay String? + tskMemberNumber String? + tskExcludedColonyTypes ColonyType[] @default([]) + // Vorgabe in der Migration (Spec 5.4), Annahme - vom Inhaber zu pruefen. + netQuantityDigitRules Json @default("[{\"maxGrams\":50,\"minMm\":2},{\"maxGrams\":200,\"minMm\":3},{\"maxGrams\":1000,\"minMm\":4},{\"maxGrams\":null,\"minMm\":6}]") + updatedAt DateTime @updatedAt +} + +enum AuthorityNoticeKind { + REGISTRATION + HERD_CHANGE + TSK_REPORT +} + +enum AuthorityNoticeStatus { + DRAFT + SENT + CANCELLED +} + +model AuthorityNotice { + id String @id @default(cuid()) + kind AuthorityNoticeKind + status AuthorityNoticeStatus @default(DRAFT) + year Int? + referenceDate DateTime + countedAt DateTime + snapshot Json + totalColonies Int + sentOn DateTime? + sentVia String? + reference String? + notes String? + // Nur bei DRAFT gesetzt ("REGISTRATION", "HERD_CHANGE", "TSK_REPORT:2027"), + // sonst null: hoechstens ein offener Entwurf je Art und Jahr. Nullbares + // @unique statt partiellem Index, wie dedupeKey im Rueckgrat. + openKey String? @unique + createdByUserId String? + createdBy User? @relation(fields: [createdByUserId], references: [id]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([kind, status]) + @@index([year]) +} + +// CHECK "endsOn" IS NULL OR "endsOn" >= "startsOn". +model ApiaryRestriction { + id String @id @default(cuid()) + apiaryId String + apiary Apiary @relation(fields: [apiaryId], references: [id], onDelete: Restrict) + disease String + authority String? + reference String? + startsOn DateTime + endsOn DateTime? + notes String? + createdByUserId String? + createdBy User? @relation(fields: [createdByUserId], references: [id]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([apiaryId, startsOn]) +} + +// "Geprueft, in Ordnung" fuer einen Pruefen-Punkt. Gilt nur, solange der +// Fingerabdruck zum aktuellen Befund passt. +model ComplianceAcknowledgement { + id String @id @default(cuid()) + itemKey String + subjectId String + fingerprint String + note String + userId String + user User @relation(fields: [userId], references: [id]) + createdAt DateTime @default(now()) + revokedAt DateTime? + revokedByUserId String? + + @@index([itemKey, subjectId]) +} diff --git a/ApiCore-Backend/src/auth/permissions/permission-catalog.ts b/ApiCore-Backend/src/auth/permissions/permission-catalog.ts index ac83e4c..6c3c125 100644 --- a/ApiCore-Backend/src/auth/permissions/permission-catalog.ts +++ b/ApiCore-Backend/src/auth/permissions/permission-catalog.ts @@ -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']; diff --git a/ApiCore-Backend/src/backup/backup.service.spec.ts b/ApiCore-Backend/src/backup/backup.service.spec.ts index 78cabb3..f76d63c 100644 --- a/ApiCore-Backend/src/backup/backup.service.spec.ts +++ b/ApiCore-Backend/src/backup/backup.service.spec.ts @@ -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', () => { diff --git a/ApiCore-Backend/src/backup/backup.service.ts b/ApiCore-Backend/src/backup/backup.service.ts index 741a969..7bf1329 100644 --- a/ApiCore-Backend/src/backup/backup.service.ts +++ b/ApiCore-Backend/src/backup/backup.service.ts @@ -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(); const imported: Record = {}; @@ -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( @@ -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] } + : {}), + }; } /** diff --git a/ApiCore-Backend/src/common/validation/null-contract.spec-helper.ts b/ApiCore-Backend/src/common/validation/null-contract.spec-helper.ts new file mode 100644 index 0000000..83b9249 --- /dev/null +++ b/ApiCore-Backend/src/common/validation/null-contract.spec-helper.ts @@ -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, +): Promise { + 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, +): 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([]); + }); + } + }); + }); +} diff --git a/ApiCore-Backend/src/common/validation/update-dto-null-handling.spec.ts b/ApiCore-Backend/src/common/validation/update-dto-null-handling.spec.ts index 2a0a0e6..a238646 100644 --- a/ApiCore-Backend/src/common/validation/update-dto-null-handling.spec.ts +++ b/ApiCore-Backend/src/common/validation/update-dto-null-handling.spec.ts @@ -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 = { UpdateApiaryDto: { @@ -374,37 +367,4 @@ const contracts: Record = { }, }; -async function errorPropertiesFor( - dto: new () => object, - body: Record, -): Promise { - 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); diff --git a/ApiCore-Backend/src/notifications/delivery-time.spec.ts b/ApiCore-Backend/src/notifications/delivery-time.spec.ts index 893baaa..47d1c19 100644 --- a/ApiCore-Backend/src/notifications/delivery-time.spec.ts +++ b/ApiCore-Backend/src/notifications/delivery-time.spec.ts @@ -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'); diff --git a/ApiCore-Backend/src/notifications/dto/update-notification-settings.dto.spec.ts b/ApiCore-Backend/src/notifications/dto/update-notification-settings.dto.spec.ts new file mode 100644 index 0000000..0243817 --- /dev/null +++ b/ApiCore-Backend/src/notifications/dto/update-notification-settings.dto.spec.ts @@ -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): Promise { + 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']); + }); +}); diff --git a/ApiCore-Backend/src/notifications/notification-preferences.service.spec.ts b/ApiCore-Backend/src/notifications/notification-preferences.service.spec.ts index 520aaf3..a3e0567 100644 --- a/ApiCore-Backend/src/notifications/notification-preferences.service.spec.ts +++ b/ApiCore-Backend/src/notifications/notification-preferences.service.spec.ts @@ -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, }, }); diff --git a/ApiCore-Backend/src/prisma/row-lock.spec.ts b/ApiCore-Backend/src/prisma/row-lock.spec.ts index a1a9a85..956f3eb 100644 --- a/ApiCore-Backend/src/prisma/row-lock.spec.ts +++ b/ApiCore-Backend/src/prisma/row-lock.spec.ts @@ -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']); + }, + ); }); diff --git a/ApiCore-Backend/src/prisma/row-lock.ts b/ApiCore-Backend/src/prisma/row-lock.ts index 3f3ffd2..ad8251f 100644 --- a/ApiCore-Backend/src/prisma/row-lock.ts +++ b/ApiCore-Backend/src/prisma/row-lock.ts @@ -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; diff --git a/ApiCore-Backend/test/backup.e2e-spec.ts b/ApiCore-Backend/test/backup.e2e-spec.ts index ca702e1..e56b70f 100644 --- a/ApiCore-Backend/test/backup.e2e-spec.ts +++ b/ApiCore-Backend/test/backup.e2e-spec.ts @@ -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; + }; + 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'), diff --git a/ApiCore-Backend/test/compliance-permissions.e2e-spec.ts b/ApiCore-Backend/test/compliance-permissions.e2e-spec.ts new file mode 100644 index 0000000..07e53e1 --- /dev/null +++ b/ApiCore-Backend/test/compliance-permissions.e2e-spec.ts @@ -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 { + 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'); + }); +}); diff --git a/ApiCore-Backend/test/notifications.e2e-spec.ts b/ApiCore-Backend/test/notifications.e2e-spec.ts index 0f03abb..ae9c999 100644 --- a/ApiCore-Backend/test/notifications.e2e-spec.ts +++ b/ApiCore-Backend/test/notifications.e2e-spec.ts @@ -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') diff --git a/ApiCore-Backend/test/t6-migrations.e2e-spec.ts b/ApiCore-Backend/test/t6-migrations.e2e-spec.ts new file mode 100644 index 0000000..2150fd7 --- /dev/null +++ b/ApiCore-Backend/test/t6-migrations.e2e-spec.ts @@ -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 { + 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, + ); +}); diff --git a/ApiCore-Backend/test/t6-schema.e2e-spec.ts b/ApiCore-Backend/test/t6-schema.e2e-spec.ts new file mode 100644 index 0000000..2c681b6 --- /dev/null +++ b/ApiCore-Backend/test/t6-schema.e2e-spec.ts @@ -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 { + 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 { + 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); + }); + }); +}); diff --git a/ApiCore-Backend/test/telegram.e2e-spec.ts b/ApiCore-Backend/test/telegram.e2e-spec.ts index 7de4007..18f3316 100644 --- a/ApiCore-Backend/test/telegram.e2e-spec.ts +++ b/ApiCore-Backend/test/telegram.e2e-spec.ts @@ -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. diff --git a/ApiCore-Backend/tsconfig.build.json b/ApiCore-Backend/tsconfig.build.json index ababca0..6b878ed 100644 --- a/ApiCore-Backend/tsconfig.build.json +++ b/ApiCore-Backend/tsconfig.build.json @@ -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"] }