feat: T4 wave 3 - notifications per user, suggestions API, varroa bands, split/queen events

- Schema III: per-user notifications (broadcast fan-out migration,
  category, link, requiredPermissions filter), notification preferences
  and settings endpoints, calendar withdrawal switch, occupiedFrames
- Suggestions API: list/summary with permission visibility and paging,
  accept/dismiss/reopen incl. bulk, SuggestionView on all responses
- Varroa traffic light by season and method, young colonies, treatment
  effect window, sample-based percentage; shared withdrawal-window helper
  on Berlin calendar days; GET /varroa-measurements/thresholds
- ColonySplit and QueenAssigned domain events with calendar and webhook
  handlers
- Outbox claims compare against now() rounded to timestamp(3); fixes the
  flaky e2e claims of rows written with SQL now()

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 14:25:24 +02:00
1 parent d99a697739
commit f468dc5f75
87 files changed
+6022 -510

No files matched your search

@@ -0,0 +1,33 @@
-- T4 migration 8/n: every notification belongs to exactly one user. A
-- broadcast (userId IS NULL) is copied once per active user with its read
-- flag and creation time, then the original is deleted. After that userId is
-- NOT NULL; dedupeKey ("<prefix>:<userId>") makes repeated writes harmless.
-- Check afterwards: SELECT count(*) FROM "Notification" WHERE "userId" IS NULL;
-- must be 0 (the SET NOT NULL below fails otherwise).
-- DropForeignKey (was ON DELETE SET NULL, impossible for a NOT NULL column)
ALTER TABLE "Notification" DROP CONSTRAINT "Notification_userId_fkey";
-- AlterTable
ALTER TABLE "Notification" ADD COLUMN "dedupeKey" TEXT;
-- Fan-out of the broadcasts to ALL active users (isActive only, no role or
-- permission filter): a broadcast was visible to every user before, so the
-- copies keep exactly that visibility. With no active user, broadcasts are
-- dropped. The copies get no dedupeKey and the default requiredPermissions.
INSERT INTO "Notification" ("id", "userId", "priority", "message", "relatedType", "relatedId", "read", "createdAt")
SELECT gen_random_uuid()::text, u."id", n."priority", n."message", n."relatedType", n."relatedId", n."read", n."createdAt"
FROM "Notification" n
CROSS JOIN "User" u
WHERE n."userId" IS NULL AND u."isActive" = true;
DELETE FROM "Notification" WHERE "userId" IS NULL;
-- AlterTable
ALTER TABLE "Notification" ALTER COLUMN "userId" SET NOT NULL;
-- CreateIndex
CREATE UNIQUE INDEX "Notification_dedupeKey_key" ON "Notification"("dedupeKey");
-- AddForeignKey
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,5 @@
-- T4 migration 9/n: switch for the withdrawal-period entries of the calendar
-- sync (on by default, like the other sources).
-- AlterTable
ALTER TABLE "CalendarSyncSettings" ADD COLUMN "syncWithdrawal" BOOLEAN NOT NULL DEFAULT true;
@@ -0,0 +1,11 @@
-- T4 migration 10/n: Colony.occupiedFrames, the number of occupied frames
-- (0-40, same unit and bounds as Inspection.colonyStrength). Colony.strength
-- (scale 1-10) is left untouched, nothing is converted.
--
-- Check BEFORE the deploy (hits must be clarified with the owner, never cut):
-- SELECT count(*) FROM "Inspection" WHERE "colonyStrength" > 40;
-- AlterTable
ALTER TABLE "Colony" ADD COLUMN "occupiedFrames" INTEGER;
ALTER TABLE "Colony" ADD CONSTRAINT "Colony_occupiedFrames_range" CHECK ("occupiedFrames" BETWEEN 0 AND 40);
@@ -0,0 +1,39 @@
-- T4 migration 11/n (part category, link, permissions, preferences). Runs
-- after migration 8. category of the old rows: job failures carry
-- relatedType 'JobRun' (and rows without relatedType are system messages) ->
-- SYSTEM, everything else -> SUGGESTION. Only then NOT NULL.
-- CreateEnum
CREATE TYPE "NotificationCategory" AS ENUM ('TASK_DUE', 'DIGEST', 'SUGGESTION', 'WITHDRAWAL', 'SEASON', 'SYSTEM');
-- AlterTable
ALTER TABLE "Notification" ADD COLUMN "category" "NotificationCategory",
ADD COLUMN "link" TEXT,
ADD COLUMN "requiredPermissions" TEXT[] DEFAULT ARRAY[]::TEXT[];
UPDATE "Notification"
SET "category" = CASE
WHEN "relatedType" = 'JobRun' OR "relatedType" IS NULL THEN 'SYSTEM'::"NotificationCategory"
ELSE 'SUGGESTION'::"NotificationCategory"
END;
ALTER TABLE "Notification" ALTER COLUMN "category" SET NOT NULL;
-- CreateTable
CREATE TABLE "NotificationPreference" (
"userId" TEXT NOT NULL,
"taskDueEnabled" BOOLEAN NOT NULL DEFAULT true,
"digestEnabled" BOOLEAN NOT NULL DEFAULT true,
"digestTime" TEXT NOT NULL DEFAULT '06:30',
"quietFrom" TEXT DEFAULT '21:00',
"quietUntil" TEXT DEFAULT '06:30',
"telegramEnabled" BOOLEAN NOT NULL DEFAULT false,
"telegramCategories" "NotificationCategory"[] DEFAULT ARRAY['TASK_DUE', 'DIGEST']::"NotificationCategory"[],
"telegramDetail" BOOLEAN NOT NULL DEFAULT true,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "NotificationPreference_pkey" PRIMARY KEY ("userId")
);
-- AddForeignKey
ALTER TABLE "NotificationPreference" ADD CONSTRAINT "NotificationPreference_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;