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

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

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

No files matched your search

@@ -0,0 +1,146 @@
-- T6 migration 1/5: veterinary medicine catalogue, packs and the pack ledger.
-- A treatment may point at a catalogue entry and at the pack it was taken
-- from; the pack ledger is booked, never edited. Rows of the catalogue and
-- the packs are never deleted while something references them (RESTRICT).
-- CHECKs: a pack holds more than nothing; a booking moves something; only a
-- TREATMENT booking belongs to a treatment.
-- CreateEnum
CREATE TYPE "MedicineUnit" AS ENUM ('ML', 'G', 'PIECE');
-- CreateEnum
CREATE TYPE "VeterinaryMedicineMovementType" AS ENUM ('TREATMENT', 'CORRECTION', 'DISPOSAL');
-- AlterEnum
ALTER TYPE "DocumentEntityType" ADD VALUE 'MEDICINE_LOT';
-- AlterTable
ALTER TABLE "Treatment" ADD COLUMN "doseQuantity" DOUBLE PRECISION,
ADD COLUMN "medicineId" TEXT,
ADD COLUMN "medicineLotId" TEXT;
-- CreateTable
CREATE TABLE "VeterinaryMedicine" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"activeIngredient" TEXT,
"authorisationNumber" TEXT,
"prescriptionOnly" BOOLEAN NOT NULL DEFAULT false,
"withdrawalPeriodDays" INTEGER,
"unit" "MedicineUnit" NOT NULL,
"dosePerColony" DOUBLE PRECISION,
"doseNote" TEXT,
"applicationMethod" TEXT,
"minTemperatureC" DOUBLE PRECISION,
"maxTemperatureC" DOUBLE PRECISION,
"defaultSupplier" TEXT,
"notes" TEXT,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "VeterinaryMedicine_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "VeterinaryMedicineLot" (
"id" TEXT NOT NULL,
"medicineId" TEXT NOT NULL,
"batchNumber" TEXT,
"purchasedOn" TIMESTAMP(3) NOT NULL,
"quantity" DOUBLE PRECISION NOT NULL,
"expiresOn" TIMESTAMP(3),
"supplier" TEXT,
"purchaseProof" TEXT,
"prescribingVet" TEXT,
"notes" TEXT,
"userId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "VeterinaryMedicineLot_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "VeterinaryMedicineMovement" (
"id" TEXT NOT NULL,
"lotId" TEXT NOT NULL,
"type" "VeterinaryMedicineMovementType" NOT NULL,
"quantity" DOUBLE PRECISION NOT NULL,
"treatmentId" TEXT,
"reason" TEXT,
"userId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "VeterinaryMedicineMovement_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "VeterinaryMedicine_active_idx" ON "VeterinaryMedicine"("active");
-- CreateIndex
CREATE INDEX "VeterinaryMedicineLot_medicineId_expiresOn_idx" ON "VeterinaryMedicineLot"("medicineId", "expiresOn");
-- CreateIndex
CREATE INDEX "VeterinaryMedicineMovement_lotId_idx" ON "VeterinaryMedicineMovement"("lotId");
-- CreateIndex
CREATE INDEX "VeterinaryMedicineMovement_treatmentId_idx" ON "VeterinaryMedicineMovement"("treatmentId");
-- CreateIndex
CREATE INDEX "Treatment_medicineId_idx" ON "Treatment"("medicineId");
-- CreateIndex
CREATE INDEX "Treatment_medicineLotId_idx" ON "Treatment"("medicineLotId");
-- AddForeignKey
ALTER TABLE "Treatment" ADD CONSTRAINT "Treatment_medicineId_fkey" FOREIGN KEY ("medicineId") REFERENCES "VeterinaryMedicine"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Treatment" ADD CONSTRAINT "Treatment_medicineLotId_fkey" FOREIGN KEY ("medicineLotId") REFERENCES "VeterinaryMedicineLot"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "VeterinaryMedicineLot" ADD CONSTRAINT "VeterinaryMedicineLot_medicineId_fkey" FOREIGN KEY ("medicineId") REFERENCES "VeterinaryMedicine"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "VeterinaryMedicineLot" ADD CONSTRAINT "VeterinaryMedicineLot_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "VeterinaryMedicineMovement" ADD CONSTRAINT "VeterinaryMedicineMovement_lotId_fkey" FOREIGN KEY ("lotId") REFERENCES "VeterinaryMedicineLot"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "VeterinaryMedicineMovement" ADD CONSTRAINT "VeterinaryMedicineMovement_treatmentId_fkey" FOREIGN KEY ("treatmentId") REFERENCES "Treatment"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "VeterinaryMedicineMovement" ADD CONSTRAINT "VeterinaryMedicineMovement_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- Constraints Prisma cannot express.
ALTER TABLE "VeterinaryMedicineLot"
ADD CONSTRAINT "VeterinaryMedicineLot_quantity_positive" CHECK ("quantity" > 0);
ALTER TABLE "VeterinaryMedicineMovement"
ADD CONSTRAINT "VeterinaryMedicineMovement_quantity_nonzero" CHECK ("quantity" <> 0);
ALTER TABLE "VeterinaryMedicineMovement"
ADD CONSTRAINT "VeterinaryMedicineMovement_treatment_matches_type"
CHECK (("type" = 'TREATMENT') = ("treatmentId" IS NOT NULL));
-- Permissions: the boot-time catalog sync only grants new permissions to
-- system roles (ADMIN), so the grants are made here (same as winter_checks).
-- API_CLIENT gets nothing: receipts and invoice numbers are not for the shop.
INSERT INTO "Permission" (id, key, resource, action, "labelDe", "groupDe", "sortOrder")
VALUES
(gen_random_uuid()::text, 'veterinary-medicines:read', 'veterinary-medicines', 'read', 'Ansehen', 'Tierarzneimittel', 3700),
(gen_random_uuid()::text, 'veterinary-medicines:create', 'veterinary-medicines', 'create', 'Anlegen', 'Tierarzneimittel', 3701),
(gen_random_uuid()::text, 'veterinary-medicines:update', 'veterinary-medicines', 'update', 'Bearbeiten', 'Tierarzneimittel', 3702)
ON CONFLICT (key) DO NOTHING;
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
SELECT r.id, p.id, now()
FROM "Role" r, "Permission" p
WHERE p.resource = 'veterinary-medicines'
AND (
r.key IN ('ADMIN', 'BEEKEEPER', 'STAFF')
OR (r.key = 'READONLY' AND p.action = 'read')
)
ON CONFLICT DO NOTHING;
@@ -0,0 +1,21 @@
-- T6 migration 2/5: mandatory label particulars. The food business operator
-- (name and address) is separate from the bee keeper of the Bestandsbuch; a
-- product carries the legal name of the food; a label template carries the
-- minimum x-height (CHECK 0.5 to 5 mm) the print check warns about. Printing
-- a label is audited as PRINT.
-- AlterEnum
ALTER TYPE "AuditAction" ADD VALUE 'PRINT';
-- AlterTable
ALTER TABLE "SiteSettings" ADD COLUMN "foodBusinessAddress" TEXT,
ADD COLUMN "foodBusinessName" TEXT;
-- AlterTable
ALTER TABLE "Product" ADD COLUMN "legalName" TEXT;
-- AlterTable
ALTER TABLE "LabelTemplate" ADD COLUMN "minXHeightMm" DOUBLE PRECISION NOT NULL DEFAULT 1.2;
ALTER TABLE "LabelTemplate"
ADD CONSTRAINT "LabelTemplate_minXHeightMm_range" CHECK ("minXHeightMm" BETWEEN 0.5 AND 5);
@@ -0,0 +1,140 @@
-- T6 migration 3/5: authority package and readiness. ComplianceSettings is a
-- singleton like SiteSettings; the default net-quantity digit rules are an
-- assumption the owner has to check (ARCHITECTURE). AuthorityNotice.openKey
-- is a nullable unique key: at most one open draft per kind and year, any
-- number of rows without a key. ApiaryRestriction ends on or after its start.
-- CreateEnum
CREATE TYPE "AuthorityNoticeKind" AS ENUM ('REGISTRATION', 'HERD_CHANGE', 'TSK_REPORT');
-- CreateEnum
CREATE TYPE "AuthorityNoticeStatus" AS ENUM ('DRAFT', 'SENT', 'CANCELLED');
-- AlterEnum
-- This migration adds more than one value to an enum. They are not used in
-- this migration (PostgreSQL forbids it inside the same transaction).
ALTER TYPE "DocumentEntityType" ADD VALUE 'AUTHORITY_NOTICE';
ALTER TYPE "DocumentEntityType" ADD VALUE 'APIARY_RESTRICTION';
-- CreateTable
CREATE TABLE "ComplianceSettings" (
"id" TEXT NOT NULL DEFAULT 'singleton',
"veterinaryOfficeName" TEXT,
"veterinaryOfficeAddress" TEXT,
"registrationNumber" TEXT,
"noticeOnColonyCountChange" BOOLEAN NOT NULL DEFAULT true,
"noticeOnApiaryChange" BOOLEAN NOT NULL DEFAULT true,
"tskReferenceDay" TEXT NOT NULL DEFAULT '01-01',
"tskReportDueDay" TEXT,
"tskMemberNumber" TEXT,
"tskExcludedColonyTypes" "ColonyType"[] DEFAULT ARRAY[]::"ColonyType"[],
"netQuantityDigitRules" JSONB NOT NULL DEFAULT '[{"maxGrams":50,"minMm":2},{"maxGrams":200,"minMm":3},{"maxGrams":1000,"minMm":4},{"maxGrams":null,"minMm":6}]',
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ComplianceSettings_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AuthorityNotice" (
"id" TEXT NOT NULL,
"kind" "AuthorityNoticeKind" NOT NULL,
"status" "AuthorityNoticeStatus" NOT NULL DEFAULT 'DRAFT',
"year" INTEGER,
"referenceDate" TIMESTAMP(3) NOT NULL,
"countedAt" TIMESTAMP(3) NOT NULL,
"snapshot" JSONB NOT NULL,
"totalColonies" INTEGER NOT NULL,
"sentOn" TIMESTAMP(3),
"sentVia" TEXT,
"reference" TEXT,
"notes" TEXT,
"openKey" TEXT,
"createdByUserId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AuthorityNotice_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ApiaryRestriction" (
"id" TEXT NOT NULL,
"apiaryId" TEXT NOT NULL,
"disease" TEXT NOT NULL,
"authority" TEXT,
"reference" TEXT,
"startsOn" TIMESTAMP(3) NOT NULL,
"endsOn" TIMESTAMP(3),
"notes" TEXT,
"createdByUserId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ApiaryRestriction_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ComplianceAcknowledgement" (
"id" TEXT NOT NULL,
"itemKey" TEXT NOT NULL,
"subjectId" TEXT NOT NULL,
"fingerprint" TEXT NOT NULL,
"note" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"revokedAt" TIMESTAMP(3),
"revokedByUserId" TEXT,
CONSTRAINT "ComplianceAcknowledgement_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "AuthorityNotice_openKey_key" ON "AuthorityNotice"("openKey");
-- CreateIndex
CREATE INDEX "AuthorityNotice_kind_status_idx" ON "AuthorityNotice"("kind", "status");
-- CreateIndex
CREATE INDEX "AuthorityNotice_year_idx" ON "AuthorityNotice"("year");
-- CreateIndex
CREATE INDEX "ApiaryRestriction_apiaryId_startsOn_idx" ON "ApiaryRestriction"("apiaryId", "startsOn");
-- CreateIndex
CREATE INDEX "ComplianceAcknowledgement_itemKey_subjectId_idx" ON "ComplianceAcknowledgement"("itemKey", "subjectId");
-- AddForeignKey
ALTER TABLE "AuthorityNotice" ADD CONSTRAINT "AuthorityNotice_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ApiaryRestriction" ADD CONSTRAINT "ApiaryRestriction_apiaryId_fkey" FOREIGN KEY ("apiaryId") REFERENCES "Apiary"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ApiaryRestriction" ADD CONSTRAINT "ApiaryRestriction_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ComplianceAcknowledgement" ADD CONSTRAINT "ComplianceAcknowledgement_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- Constraints Prisma cannot express.
ALTER TABLE "ApiaryRestriction"
ADD CONSTRAINT "ApiaryRestriction_endsOn_after_startsOn"
CHECK ("endsOn" IS NULL OR "endsOn" >= "startsOn");
-- Permissions (see the winter_checks migration): read for everyone who
-- reads the records, manage (settings, notices, restrictions, readiness
-- sign-off) only for ADMIN and BEEKEEPER. API_CLIENT gets nothing.
INSERT INTO "Permission" (id, key, resource, action, "labelDe", "groupDe", "sortOrder")
VALUES
(gen_random_uuid()::text, 'compliance:read', 'compliance', 'read', 'Ansehen', 'Prüfbereitschaft & Behörden', 3800),
(gen_random_uuid()::text, 'compliance:manage', 'compliance', 'manage', 'Verwalten', 'Prüfbereitschaft & Behörden', 3801)
ON CONFLICT (key) DO NOTHING;
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
SELECT r.id, p.id, now()
FROM "Role" r, "Permission" p
WHERE p.resource = 'compliance'
AND (
r.key IN ('ADMIN', 'BEEKEEPER')
OR (r.key IN ('STAFF', 'READONLY') AND p.action = 'read')
)
ON CONFLICT DO NOTHING;
@@ -0,0 +1,7 @@
-- T6 migration 4/5: notification category for the authority hints ("Bestand
-- geaendert", "TSK-Stichtag"). The value is only added here; migration 5
-- uses it (PostgreSQL forbids using a new enum value in the same
-- transaction that adds it).
-- AlterEnum
ALTER TYPE "NotificationCategory" ADD VALUE 'COMPLIANCE';
@@ -0,0 +1,13 @@
-- T6 migration 5/5: COMPLIANCE is a Telegram category chosen by default. New
-- preferences start with it; every existing preference gets it appended
-- (the app shows all categories, the list only steers Telegram). A user can
-- still remove it in the profile: an empty list is a deliberate opt-out and
-- stays empty (only lists with at least one entry get the new category).
-- AlterTable
ALTER TABLE "NotificationPreference" ALTER COLUMN "telegramCategories" SET DEFAULT ARRAY['TASK_DUE', 'DIGEST', 'COMPLIANCE']::"NotificationCategory"[];
UPDATE "NotificationPreference"
SET "telegramCategories" = array_append("telegramCategories", 'COMPLIANCE'::"NotificationCategory")
WHERE cardinality("telegramCategories") > 0
AND NOT ('COMPLIANCE'::"NotificationCategory" = ANY("telegramCategories"));