diff --git a/ApiCore-Backend/.out b/ApiCore-Backend/.out new file mode 100644 index 0000000..925fb39 --- /dev/null +++ b/ApiCore-Backend/.out @@ -0,0 +1,166 @@ +FAIL test/webhooks.e2e-spec.ts + ● Test suite failed to run + + Cannot find module './varroa-threshold.service' from '../src/varroa/varroa.controller.ts' + + Require stack: + C:/Users/Nick.Szaunig/OneDrive - EnCry/Documents/VS Code/ApiCore/ApiCore-Backend/src/varroa/varroa.controller.ts + C:/Users/Nick.Szaunig/OneDrive - EnCry/Documents/VS Code/ApiCore/ApiCore-Backend/src/varroa/varroa.module.ts + C:/Users/Nick.Szaunig/OneDrive - EnCry/Documents/VS Code/ApiCore/ApiCore-Backend/src/app.module.ts + utils/create-test-app.ts + webhooks.e2e-spec.ts + +   8 | import { UpdateVarroaMeasurementDto } from './dto/update-varroa-measurement.dto'; +  9 | import { VarroaService } from './varroa.service'; + > 10 | import { VarroaThresholdService } from './varroa-threshold.service'; +  | ^ +  11 | import { Idempotent } from '../common/idempotency/idempotent.decorator'; +  12 | +  13 | @ApiTags('varroa-measurements') + + at Resolver._throwModNotFoundError (../node_modules/jest-resolve/build/index.js:895:11) + at Object. (../src/varroa/varroa.controller.ts:10:1) + at Object. (../src/varroa/varroa.module.ts:2:1) + at Object. (../src/app.module.ts:20:1) + at Object. (utils/create-test-app.ts:6:1) + at Object. (webhooks.e2e-spec.ts:9:1) + +[Nest] 17844 - 14/10/2026, 13:53:09  ERROR [DomainEventDispatcher] Domain events FAILED, an expired claim used up their attempts: cmv81wj4d001xroijcqgc9y09 +FAIL test/domain-event-dispatcher.e2e-spec.ts + ● Console + + console.log + ◇ injected env (8) from .env.test // tip: ⌁ auth for agents [www.vestauth.com] + + at _log (../node_modules/dotenv/lib/main.js:131:11) + + ● DomainEventDispatcher (e2e) › two dispatchers in parallel produce exactly one effect per event and handler + + TypeError: The "path" argument must be of type string or an instance of Buffer or URL. Received undefined + +   109 | const d = await db.$queryRaw<unknown[]>(Prisma.sql`SELECT "id","status","availableAt", now() AS n, clock_timestamp() AS c, ("availableAt" <= now()) AS due, pg_backend_pid() AS pid FROM ${name} WHERE ${options.filter}`); +  110 | // eslint-disable-next-line + > 111 | require('node:fs').appendFileSync(process.env.DBG_LOG as string, new Date().toISOString() + ' ' + table + ' empty claim; rows=' + JSON.stringify(d) + '\n'); +  | ^ +  112 | } +  113 | return __res; +  114 | } + + at claimBatch (../src/common/outbox/outbox-claims.ts:111:24) + at DomainEventDispatcher.runOnce (../src/domain-events/domain-event-dispatcher.ts:69:20) + at DomainEventDispatcher.drain (../src/domain-events/domain-event-dispatcher.ts:101:23) + at async Promise.all (index 1) + at Object. (domain-event-dispatcher.e2e-spec.ts:211:20) + + ● DomainEventDispatcher (e2e) › hands the handler the stored event and its correlation id + + TypeError: The "path" argument must be of type string or an instance of Buffer or URL. Received undefined + +   109 | const d = await db.$queryRaw<unknown[]>(Prisma.sql`SELECT "id","status","availableAt", now() AS n, clock_timestamp() AS c, ("availableAt" <= now()) AS due, pg_backend_pid() AS pid FROM ${name} WHERE ${options.filter}`); +  110 | // eslint-disable-next-line + > 111 | require('node:fs').appendFileSync(process.env.DBG_LOG as string, new Date().toISOString() + ' ' + table + ' empty claim; rows=' + JSON.stringify(d) + '\n'); +  | ^ +  112 | } +  113 | return __res; +  114 | } + + at claimBatch (../src/common/outbox/outbox-claims.ts:111:24) + at DomainEventDispatcher.runOnce (../src/domain-events/domain-event-dispatcher.ts:69:20) + at DomainEventDispatcher.drain (../src/domain-events/domain-event-dispatcher.ts:101:23) + at Object. (domain-event-dispatcher.e2e-spec.ts:245:5) + + ● DomainEventDispatcher (e2e) › a stale release that ends in FAILED also fails the open deliveries + + expect(received).resolves.toBe() + + Received promise rejected instead of resolved + Rejected to value: [TypeError: The "path" argument must be of type string or an instance of Buffer or URL. Received undefined] + +   320 | deliveries: [{ handler: 'test.counter', status: 'FAILED', attempts: 7 }], +  321 | }); + > 322 | await expect(appA.get(DomainEventDispatcher).drain(filter)).resolves.toBe( +  | ^ +  323 | 0, +  324 | ); +  325 | }); + + at expect (../node_modules/expect/build/index.js:2116:15) + at Object. (domain-event-dispatcher.e2e-spec.ts:322:11) + + ● DomainEventDispatcher (e2e) › drains a backlog of 120 events in batches of at most 50 + + TypeError: The "path" argument must be of type string or an instance of Buffer or URL. Received undefined + +   109 | const d = await db.$queryRaw<unknown[]>(Prisma.sql`SELECT "id","status","availableAt", now() AS n, clock_timestamp() AS c, ("availableAt" <= now()) AS due, pg_backend_pid() AS pid FROM ${name} WHERE ${options.filter}`); +  110 | // eslint-disable-next-line + > 111 | require('node:fs').appendFileSync(process.env.DBG_LOG as string, new Date().toISOString() + ' ' + table + ' empty claim; rows=' + JSON.stringify(d) + '\n'); +  | ^ +  112 | } +  113 | return __res; +  114 | } + + at claimBatch (../src/common/outbox/outbox-claims.ts:111:24) + at DomainEventDispatcher.runOnce (../src/domain-events/domain-event-dispatcher.ts:69:20) + at Object. (domain-event-dispatcher.e2e-spec.ts:333:19) + + ● DomainEventDispatcher (e2e) › drainDomainEvents processes only the events of the given root aggregates + + TypeError: The "path" argument must be of type string or an instance of Buffer or URL. Received undefined + +   109 | const d = await db.$queryRaw<unknown[]>(Prisma.sql`SELECT "id","status","availableAt", now() AS n, clock_timestamp() AS c, ("availableAt" <= now()) AS due, pg_backend_pid() AS pid FROM ${name} WHERE ${options.filter}`); +  110 | // eslint-disable-next-line + > 111 | require('node:fs').appendFileSync(process.env.DBG_LOG as string, new Date().toISOString() + ' ' + table + ' empty claim; rows=' + JSON.stringify(d) + '\n'); +  | ^ +  112 | } +  113 | return __res; +  114 | } + + at claimBatch (../src/common/outbox/outbox-claims.ts:111:24) + at DomainEventDispatcher.runOnce (../src/domain-events/domain-event-dispatcher.ts:69:20) + at DomainEventDispatcher.drain (../src/domain-events/domain-event-dispatcher.ts:101:23) + at drainDomainEvents (utils/domain-events.ts:24:3) + at Object. (domain-event-dispatcher.e2e-spec.ts:344:5) + + ● DomainEventDispatcher (e2e) › a stale-released event does not repeat a handler that already committed + + TypeError: The "path" argument must be of type string or an instance of Buffer or URL. Received undefined + +   109 | const d = await db.$queryRaw<unknown[]>(Prisma.sql`SELECT "id","status","availableAt", now() AS n, clock_timestamp() AS c, ("availableAt" <= now()) AS due, pg_backend_pid() AS pid FROM ${name} WHERE ${options.filter}`); +  110 | // eslint-disable-next-line + > 111 | require('node:fs').appendFileSync(process.env.DBG_LOG as string, new Date().toISOString() + ' ' + table + ' empty claim; rows=' + JSON.stringify(d) + '\n'); +  | ^ +  112 | } +  113 | return __res; +  114 | } + + at claimBatch (../src/common/outbox/outbox-claims.ts:111:24) + at DomainEventDispatcher.runOnce (../src/domain-events/domain-event-dispatcher.ts:69:20) + at DomainEventDispatcher.drain (../src/domain-events/domain-event-dispatcher.ts:101:23) + at Object. (domain-event-dispatcher.e2e-spec.ts:391:5) + + ● DomainEventDispatcher (e2e) › schedules a failed event about 30 s ahead by the database clock + + expect(received).resolves.toBe() + + Received promise rejected instead of resolved + Rejected to value: [TypeError: The "path" argument must be of type string or an instance of Buffer or URL. Received undefined] + +   508 | const filter = await filterFor('failing'); +  509 | + > 510 | await expect(appA.get(DomainEventDispatcher).drain(filter)).resolves.toBe( +  | ^ +  511 | 1, +  512 | ); +  513 | + + at expect (../node_modules/expect/build/index.js:2116:15) + at Object. (domain-event-dispatcher.e2e-spec.ts:510:11) + +Test Suites: 2 failed, 2 total +Tests: 7 failed, 2 passed, 9 total +Snapshots: 0 total +Time: 3.973 s, estimated 7 s +Ran all test suites matching test/webhooks.e2e-spec.ts|test/domain-event-dispatcher.e2e-spec.ts. +Jest did not exit one second after the test run has completed. + +'This usually means that there are asynchronous operations that weren't stopped in your tests. Consider running Jest with `--detectOpenHandles` to troubleshoot this issue. diff --git a/ApiCore-Backend/prisma/migrations/20261014100500_t4_notification_fanout/migration.sql b/ApiCore-Backend/prisma/migrations/20261014100500_t4_notification_fanout/migration.sql new file mode 100644 index 0000000..8e4a7aa --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20261014100500_t4_notification_fanout/migration.sql @@ -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 (":") 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; diff --git a/ApiCore-Backend/prisma/migrations/20261014100600_t4_calendar_withdrawal/migration.sql b/ApiCore-Backend/prisma/migrations/20261014100600_t4_calendar_withdrawal/migration.sql new file mode 100644 index 0000000..d24751a --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20261014100600_t4_calendar_withdrawal/migration.sql @@ -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; diff --git a/ApiCore-Backend/prisma/migrations/20261014100700_t4_occupied_frames/migration.sql b/ApiCore-Backend/prisma/migrations/20261014100700_t4_occupied_frames/migration.sql new file mode 100644 index 0000000..dd5a3b7 --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20261014100700_t4_occupied_frames/migration.sql @@ -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); diff --git a/ApiCore-Backend/prisma/migrations/20261014100800_t4_notification_settings/migration.sql b/ApiCore-Backend/prisma/migrations/20261014100800_t4_notification_settings/migration.sql new file mode 100644 index 0000000..5141e09 --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20261014100800_t4_notification_settings/migration.sql @@ -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; diff --git a/ApiCore-Backend/prisma/schema.prisma b/ApiCore-Backend/prisma/schema.prisma index d987542..687d0ec 100644 --- a/ApiCore-Backend/prisma/schema.prisma +++ b/ApiCore-Backend/prisma/schema.prisma @@ -103,33 +103,34 @@ model User { refreshTokens RefreshToken[] - inspections Inspection[] - varroaMeasurements VarroaMeasurement[] - swarmControlChecks SwarmControlCheck[] - winterChecks WinterCheck[] - treatments Treatment[] - feedings Feeding[] - harvests Harvest[] - extractions Extraction[] - honeyMeasurements HoneyMeasurement[] - stockMovements StockMovement[] - assignedTasks Task[] @relation("TaskAssignee") - createdTasks Task[] @relation("TaskCreator") - decidedSuggestions Suggestion[] @relation("SuggestionDecider") - assignedTaskTemplates TaskTemplate[] @relation("TaskTemplateAssignee") - createdTaskTemplates TaskTemplate[] @relation("TaskTemplateCreator") - colonyMoves ColonyApiaryHistory[] - auditLogs AuditLog[] - bottlingBatches BottlingBatch[] - productionBatches ProductionBatch[] - batchMovements BatchMovement[] - rawMaterialLots RawMaterialLot[] - notifications Notification[] - queenEvaluations QueenEvaluation[] - webhookSubscriptions WebhookSubscription[] - documents Document[] - honeyBatchRelocations HoneyBatchRelocation[] - apiKeys ApiKey[] + inspections Inspection[] + varroaMeasurements VarroaMeasurement[] + swarmControlChecks SwarmControlCheck[] + winterChecks WinterCheck[] + treatments Treatment[] + feedings Feeding[] + harvests Harvest[] + extractions Extraction[] + honeyMeasurements HoneyMeasurement[] + stockMovements StockMovement[] + assignedTasks Task[] @relation("TaskAssignee") + createdTasks Task[] @relation("TaskCreator") + decidedSuggestions Suggestion[] @relation("SuggestionDecider") + assignedTaskTemplates TaskTemplate[] @relation("TaskTemplateAssignee") + createdTaskTemplates TaskTemplate[] @relation("TaskTemplateCreator") + colonyMoves ColonyApiaryHistory[] + auditLogs AuditLog[] + bottlingBatches BottlingBatch[] + productionBatches ProductionBatch[] + batchMovements BatchMovement[] + rawMaterialLots RawMaterialLot[] + notifications Notification[] + notificationPreference NotificationPreference? + queenEvaluations QueenEvaluation[] + webhookSubscriptions WebhookSubscription[] + documents Document[] + honeyBatchRelocations HoneyBatchRelocation[] + apiKeys ApiKey[] @@index([roleId]) } @@ -291,6 +292,7 @@ model Colony { colonyType ColonyType status ColonyStatus @default(ACTIVE) strength Int? + occupiedFrames Int? broodStatus ColonyCondition? feedStatus ColonyCondition? healthStatus ColonyCondition? @@ -1374,21 +1376,52 @@ enum NotificationPriority { KRITISCH } +enum NotificationCategory { + TASK_DUE + DIGEST + SUGGESTION + WITHDRAWAL + SEASON + SYSTEM +} + +// Jede Zeile gehoert genau einem Empfaenger (T4: Fan-out mit Rechtepruefung +// beim Schreiben). requiredPermissions = Rechte des Empfaengers plus Rechte +// des Inhalts; dedupeKey = ":". model Notification { - id String @id @default(cuid()) - userId String? - user User? @relation(fields: [userId], references: [id]) - priority NotificationPriority - message String - relatedType String? - relatedId String? - read Boolean @default(false) - createdAt DateTime @default(now()) + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id]) + priority NotificationPriority + category NotificationCategory + message String + relatedType String? + relatedId String? + link String? + requiredPermissions String[] @default([]) + dedupeKey String? @unique + read Boolean @default(false) + createdAt DateTime @default(now()) @@index([userId, read]) @@index([priority]) } +// Einstellungen je Nutzer; Standard beim ersten Lesen angelegt. +model NotificationPreference { + userId String @id + user User @relation(fields: [userId], references: [id]) + taskDueEnabled Boolean @default(true) + digestEnabled Boolean @default(true) + digestTime String @default("06:30") + quietFrom String? @default("21:00") + quietUntil String? @default("06:30") + telegramEnabled Boolean @default(false) + telegramCategories NotificationCategory[] @default([TASK_DUE, DIGEST]) + telegramDetail Boolean @default(true) + updatedAt DateTime @updatedAt +} + // --------------------------------------------------------------------------- // Webhooks (Abschnitt 27) – v1.x Modul 28 // --------------------------------------------------------------------------- @@ -1574,6 +1607,7 @@ model CalendarSyncSettings { syncFeeding Boolean @default(true) syncHarvest Boolean @default(true) syncQueenAssignment Boolean @default(true) + syncWithdrawal Boolean @default(true) updatedAt DateTime @updatedAt } diff --git a/ApiCore-Backend/src/apiaries/apiary-field.service.spec.ts b/ApiCore-Backend/src/apiaries/apiary-field.service.spec.ts index 3d1ce66..98edceb 100644 --- a/ApiCore-Backend/src/apiaries/apiary-field.service.spec.ts +++ b/ApiCore-Backend/src/apiaries/apiary-field.service.spec.ts @@ -189,7 +189,7 @@ describe('ApiaryFieldService', () => { relatedId: { in: ['c2', 'c10'] }, read: false, priority: { in: ['WARNUNG', 'KRITISCH'] }, - OR: [{ userId: 'u1' }, { userId: null }], + userId: 'u1', }, }), ); diff --git a/ApiCore-Backend/src/apiaries/apiary-field.service.ts b/ApiCore-Backend/src/apiaries/apiary-field.service.ts index 0318a29..9e1e4e6 100644 --- a/ApiCore-Backend/src/apiaries/apiary-field.service.ts +++ b/ApiCore-Backend/src/apiaries/apiary-field.service.ts @@ -146,7 +146,7 @@ export class ApiaryFieldService { relatedId: { in: colonies.map((colony) => colony.id) }, read: false, priority: { in: ['WARNUNG', 'KRITISCH'] }, - OR: [{ userId }, { userId: null }], + userId, }, _count: { _all: true }, }); diff --git a/ApiCore-Backend/src/automation/automation.service.spec.ts b/ApiCore-Backend/src/automation/automation.service.spec.ts index ce10968..a4cdb95 100644 --- a/ApiCore-Backend/src/automation/automation.service.spec.ts +++ b/ApiCore-Backend/src/automation/automation.service.spec.ts @@ -45,7 +45,7 @@ describe('AutomationService', () => { $connect: jest.Mock; }; let auditLog: { record: jest.Mock }; - let notifications: { record: jest.Mock }; + let notifications: { recordForPermission: jest.Mock }; let tasks: { createInTx: jest.Mock }; const queenlessRule: Rule = { @@ -102,7 +102,9 @@ describe('AutomationService', () => { $connect: jest.fn(), }; auditLog = { record: jest.fn().mockResolvedValue(undefined) }; - notifications = { record: jest.fn().mockResolvedValue({ id: 'n-1' }) }; + notifications = { + recordForPermission: jest.fn().mockResolvedValue([{ id: 'n-1' }]), + }; tasks = { createInTx: jest.fn().mockResolvedValue({ id: 'task-1' }) }; service = new AutomationService( prisma as unknown as PrismaService, @@ -258,7 +260,9 @@ describe('AutomationService', () => { staged.push(client === tx ? 'task' : 'task outside tx'); return Promise.resolve({ id: 'task-1' }); }); - notifications.record.mockRejectedValue(new Error('notifications down')); + notifications.recordForPermission.mockRejectedValue( + new Error('notifications down'), + ); auditLog.record.mockImplementation((_entry: unknown, client: unknown) => { staged.push(client === tx ? 'audit' : 'audit outside tx'); return Promise.resolve(); @@ -303,9 +307,11 @@ describe('AutomationService', () => { }, null, ); - expect(notifications.record).toHaveBeenCalledWith( + expect(notifications.recordForPermission).toHaveBeenCalledWith( + 'tasks:read', { priority: 'WARNUNG', + category: 'SUGGESTION', message: 'Kontrolle empfohlen', relatedType: 'COLONY', relatedId: 'colony-1', @@ -332,7 +338,9 @@ describe('AutomationService', () => { }); it('lets a failed notification write propagate, so the delivery is rolled back and retried', async () => { - notifications.record.mockRejectedValue(new Error('notifications down')); + notifications.recordForPermission.mockRejectedValue( + new Error('notifications down'), + ); await expect( service.evaluateInspectionIn(asTx(), queenlessInspection as never), @@ -355,7 +363,7 @@ describe('AutomationService', () => { select: { id: true }, }); expect(tasks.createInTx).not.toHaveBeenCalled(); - expect(notifications.record).not.toHaveBeenCalled(); + expect(notifications.recordForPermission).not.toHaveBeenCalled(); expect(auditLog.record).not.toHaveBeenCalled(); }); @@ -403,7 +411,7 @@ describe('AutomationService', () => { await expect( service.evaluateInspectionIn(asTx(), queenlessInspection as never), ).rejects.toThrow('tasks down'); - expect(notifications.record).not.toHaveBeenCalled(); + expect(notifications.recordForPermission).not.toHaveBeenCalled(); expect(auditLog.record).not.toHaveBeenCalled(); }); }); @@ -426,7 +434,7 @@ describe('AutomationService', () => { await service.evaluateInspectionIn(asTx(), queenlessInspection as never); expect(tasks.createInTx).toHaveBeenCalledTimes(1); - expect(notifications.record).toHaveBeenCalledTimes(1); + expect(notifications.recordForPermission).toHaveBeenCalledTimes(1); }); it('does not trigger when the queen was seen', async () => { diff --git a/ApiCore-Backend/src/automation/automation.service.ts b/ApiCore-Backend/src/automation/automation.service.ts index 06f1f76..6ac6f55 100644 --- a/ApiCore-Backend/src/automation/automation.service.ts +++ b/ApiCore-Backend/src/automation/automation.service.ts @@ -600,8 +600,16 @@ export class AutomationService { }, null, ); - await this.notifications.record( - { priority: 'WARNUNG', message: title, relatedType, relatedId }, + // Task 7 replaces this path by suggestions. + await this.notifications.recordForPermission( + 'tasks:read', + { + priority: 'WARNUNG', + category: 'SUGGESTION', + message: title, + relatedType, + relatedId, + }, tx, ); await this.auditLog.record( diff --git a/ApiCore-Backend/src/automation/varroa-bands.spec.ts b/ApiCore-Backend/src/automation/varroa-bands.spec.ts new file mode 100644 index 0000000..f636d15 --- /dev/null +++ b/ApiCore-Backend/src/automation/varroa-bands.spec.ts @@ -0,0 +1,271 @@ +import type { ColonyType, VarroaMethod } from '../../generated/prisma'; +import { bandsFor, classify, rateMeasurement } from './varroa-bands'; +import { DEFAULT_VARROA_THRESHOLD_CONFIG } from './rule-config/varroa-threshold.config'; + +const DEFAULTS = DEFAULT_VARROA_THRESHOLD_CONFIG; +const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`); + +describe('varroa bands', () => { + it.each< + [ + string, + ColonyType, + VarroaMethod, + number, + 'green' | 'yellow' | 'red' | null, + ] + >([ + ['2026-08-10', 'PRODUCTION_COLONY', 'NATURAL_MITE_FALL', 10.0, 'yellow'], + ['2026-08-10', 'PRODUCTION_COLONY', 'NATURAL_MITE_FALL', 10.1, 'red'], + ['2026-08-10', 'PRODUCTION_COLONY', 'NATURAL_MITE_FALL', 4.9, 'green'], + ['2026-08-10', 'PRODUCTION_COLONY', 'NATURAL_MITE_FALL', 5, 'yellow'], + ['2026-11-20', 'PRODUCTION_COLONY', 'NATURAL_MITE_FALL', 0.6, 'red'], + ['2026-11-20', 'PRODUCTION_COLONY', 'NATURAL_MITE_FALL', 0.5, 'green'], + ['2026-06-10', 'YOUNG_COLONY', 'NATURAL_MITE_FALL', 5.1, 'red'], + ['2026-06-10', 'YOUNG_COLONY', 'NATURAL_MITE_FALL', 2.5, 'yellow'], + ['2026-11-20', 'NUCLEUS_COLONY', 'NATURAL_MITE_FALL', 0.3, 'red'], + ['2026-11-20', 'NUCLEUS_COLONY', 'NATURAL_MITE_FALL', 0.25, 'green'], + ['2026-07-10', 'PRODUCTION_COLONY', 'POWDERED_SUGAR', 5.1, 'red'], + ['2026-07-10', 'PRODUCTION_COLONY', 'POWDERED_SUGAR', 1, 'yellow'], + ['2026-07-10', 'YOUNG_COLONY', 'POWDERED_SUGAR', 4, 'yellow'], + ['2026-09-10', 'PRODUCTION_COLONY', 'WASHING_METHOD', 1.5, 'green'], + ['2026-09-10', 'PRODUCTION_COLONY', 'WASHING_METHOD', 2, 'yellow'], + ['2026-04-10', 'PRODUCTION_COLONY', 'POWDERED_SUGAR', 9, null], + ['2026-11-10', 'PRODUCTION_COLONY', 'WASHING_METHOD', 9, null], + ['2026-07-10', 'PRODUCTION_COLONY', 'DRONE_BROOD_CONTROL', 50, null], + ])('%s %s %s %p -> %p', (day, type, method, value, light) => + expect( + classify(value, bandsFor(day, type, DEFAULTS, []).bands[method]), + ).toBe(light), + ); + + it('classifies a missing value or band as no light', () => { + const band = bandsFor('2026-08-10', null, DEFAULTS, []).bands + .NATURAL_MITE_FALL; + expect(classify(null, band)).toBeNull(); + expect(classify(3, undefined)).toBeNull(); + expect(classify(3, null)).toBeNull(); + }); + + it('describes the band with its reason, unit and colony kind', () => { + const autumn = bandsFor('2026-08-10', 'YOUNG_COLONY', DEFAULTS, []); + expect(autumn.day).toBe('2026-08-10'); + expect(autumn.bands.NATURAL_MITE_FALL).toEqual({ + method: 'NATURAL_MITE_FALL', + unit: 'MITES_PER_DAY', + yellowFrom: 2.5, + redAbove: 5, + reason: 'NATURAL_FALL_SEASON', + youngColony: true, + }); + expect(autumn.bands.POWDERED_SUGAR).toMatchObject({ + unit: 'PERCENT', + yellowFrom: 2, + redAbove: 5, + reason: 'INFESTATION_AUTUMN', + }); + const winter = bandsFor('2026-01-10', 'PRODUCTION_COLONY', DEFAULTS, []); + expect(winter.bands.NATURAL_MITE_FALL).toMatchObject({ + yellowFrom: null, + redAbove: 0.5, + reason: 'NATURAL_FALL_WINTER', + youngColony: false, + }); + expect(winter.bands.POWDERED_SUGAR).toBeUndefined(); + }); + + it('treats a colony without a type as a full colony', () => + expect( + bandsFor('2026-08-10', null, DEFAULTS, []).bands.NATURAL_MITE_FALL, + ).toMatchObject({ redAbove: 10, youngColony: false })); + + it('names the recheck day while a treatment effect is in force', () => { + const treatments = [{ startDay: '2026-07-20', endDay: '2026-08-02' }]; + const until = (day: string) => + bandsFor(day, null, DEFAULTS, treatments).postTreatmentUntil; + expect(until('2026-07-25')).toBe('2026-08-16'); + expect(until('2026-08-15')).toBe('2026-08-16'); + expect(until('2026-08-16')).toBeNull(); + expect(until('2026-07-19')).toBeNull(); + }); + + it('uses the latest recheck day of overlapping treatments', () => + expect( + bandsFor('2026-08-01', null, DEFAULTS, [ + { startDay: '2026-07-01', endDay: '2026-07-20' }, + { startDay: '2026-07-25', endDay: '2026-08-10' }, + ]).postTreatmentUntil, + ).toBe('2026-08-24')); + + describe('month edges', () => { + const lightOn = ( + day: string, + method: VarroaMethod, + value: number, + ): string | null => + classify( + value, + bandsFor(day, 'PRODUCTION_COLONY', DEFAULTS, []).bands[method], + ); + + it.each<[string, VarroaMethod, number, string | null]>([ + ['2026-02-28', 'NATURAL_MITE_FALL', 3, 'red'], + ['2026-03-01', 'NATURAL_MITE_FALL', 3, 'green'], + ['2026-10-31', 'NATURAL_MITE_FALL', 3, 'green'], + ['2026-11-01', 'NATURAL_MITE_FALL', 3, 'red'], + ['2026-04-30', 'POWDERED_SUGAR', 3, null], + ['2026-05-01', 'POWDERED_SUGAR', 3, 'yellow'], + ['2026-07-31', 'POWDERED_SUGAR', 1.5, 'yellow'], + ['2026-08-01', 'POWDERED_SUGAR', 1.5, 'green'], + ['2026-10-31', 'POWDERED_SUGAR', 3, 'yellow'], + ['2026-11-01', 'POWDERED_SUGAR', 3, null], + ['2026-04-30', 'WASHING_METHOD', 3, null], + ['2026-05-01', 'WASHING_METHOD', 3, 'yellow'], + ['2026-07-31', 'WASHING_METHOD', 1.5, 'yellow'], + ['2026-08-01', 'WASHING_METHOD', 1.5, 'green'], + ['2026-10-31', 'WASHING_METHOD', 3, 'yellow'], + ['2026-11-01', 'WASHING_METHOD', 3, null], + ])('%s %s %p -> %p', (day, method, value, light) => + expect(lightOn(day, method, value)).toBe(light), + ); + }); + + it('keeps the young factor off the percent bands', () => { + const { bands } = bandsFor('2026-07-10', 'YOUNG_COLONY', DEFAULTS, []); + expect(bands.POWDERED_SUGAR).toMatchObject({ + youngColony: false, + yellowFrom: 1, + redAbove: 5, + }); + expect(bands.NATURAL_MITE_FALL?.youngColony).toBe(true); + }); + + describe('treatment effect edges', () => { + const treatments = [{ startDay: '2026-07-20', endDay: '2026-08-02' }]; + const ratingOn = (day: string, method: VarroaMethod) => + rateMeasurement( + { date: d(day), method, calculatedValue: 12 }, + null, + DEFAULTS, + treatments, + ); + + it('starts on the start day and ends the day before the recheck day', () => { + expect(ratingOn('2026-07-19', 'NATURAL_MITE_FALL').light).toBe('red'); + expect(ratingOn('2026-07-20', 'NATURAL_MITE_FALL').light).toBe( + 'POST_TREATMENT', + ); + expect(ratingOn('2026-08-15', 'NATURAL_MITE_FALL').light).toBe( + 'POST_TREATMENT', + ); + expect(ratingOn('2026-08-16', 'NATURAL_MITE_FALL').light).toBe('red'); + }); + + it('covers a single-day treatment', () => { + const single = [{ startDay: '2026-08-01', endDay: '2026-08-01' }]; + const until = (day: string) => + bandsFor(day, null, DEFAULTS, single).postTreatmentUntil; + expect(until('2026-08-01')).toBe('2026-08-15'); + expect(until('2026-08-14')).toBe('2026-08-15'); + expect(until('2026-08-15')).toBeNull(); + }); + + it('applies to the percent methods and never to the drone brood check', () => { + expect(ratingOn('2026-07-25', 'POWDERED_SUGAR').light).toBe( + 'POST_TREATMENT', + ); + expect(ratingOn('2026-08-05', 'WASHING_METHOD').light).toBe( + 'POST_TREATMENT', + ); + expect(ratingOn('2026-07-25', 'DRONE_BROOD_CONTROL').light).toBeNull(); + }); + }); + + it('rates by the Berlin calendar day of the measurement', () => { + // 2026-10-31T23:30Z is 2026-11-01 00:30 in Berlin: winter band. + const rating = rateMeasurement( + { + date: new Date('2026-11-01T00:30:00+01:00'), + method: 'NATURAL_MITE_FALL', + calculatedValue: 3, + }, + null, + DEFAULTS, + [], + ); + expect(rating.reasonCode).toBe('NATURAL_FALL_WINTER'); + expect(rating.light).toBe('red'); + }); + + describe('rateMeasurement', () => { + it('a measurement 5 days after treatment end has no light and names the recheck day', () => + expect( + rateMeasurement( + { + date: d('2026-08-07'), + method: 'NATURAL_MITE_FALL', + calculatedValue: 12, + }, + 'PRODUCTION_COLONY', + DEFAULTS, + [{ startDay: '2026-07-20', endDay: '2026-08-02' }], + ), + ).toEqual({ + light: 'POST_TREATMENT', + reasonCode: 'POST_TREATMENT', + band: null, + postTreatmentUntil: '2026-08-16', + })); + + it('rates outside a treatment effect with the band and reason', () => { + const rating = rateMeasurement( + { + date: d('2026-08-20'), + method: 'NATURAL_MITE_FALL', + calculatedValue: 12, + }, + 'PRODUCTION_COLONY', + DEFAULTS, + [{ startDay: '2026-07-20', endDay: '2026-08-02' }], + ); + expect(rating).toMatchObject({ + light: 'red', + reasonCode: 'NATURAL_FALL_SEASON', + postTreatmentUntil: null, + }); + expect(rating.band).toMatchObject({ redAbove: 10 }); + }); + + it('has no light for a method without a band or a missing value', () => { + expect( + rateMeasurement( + { + date: d('2026-04-10'), + method: 'POWDERED_SUGAR', + calculatedValue: 9, + }, + null, + DEFAULTS, + [], + ), + ).toEqual({ + light: null, + reasonCode: null, + band: null, + postTreatmentUntil: null, + }); + expect( + rateMeasurement( + { + date: d('2026-08-10'), + method: 'NATURAL_MITE_FALL', + calculatedValue: null, + }, + null, + DEFAULTS, + [], + ).light, + ).toBeNull(); + }); + }); +}); diff --git a/ApiCore-Backend/src/automation/varroa-bands.ts b/ApiCore-Backend/src/automation/varroa-bands.ts new file mode 100644 index 0000000..4ef2f69 --- /dev/null +++ b/ApiCore-Backend/src/automation/varroa-bands.ts @@ -0,0 +1,198 @@ +import type { + ColonyType, + VarroaMeasurement, + VarroaMethod, +} from '../../generated/prisma'; +import { addDays, berlinDayOf } from '../common/dates'; +import type { TreatmentWindowLite } from '../treatments/withdrawal-window'; +import type { VarroaThresholdConfig } from './rule-config/rule-config.types'; + +export type VarroaLightKey = 'green' | 'yellow' | 'red'; + +export type VarroaBandReason = + | 'NATURAL_FALL_SEASON' + | 'NATURAL_FALL_WINTER' + | 'INFESTATION_SUMMER' + | 'INFESTATION_AUTUMN'; + +export interface VarroaBand { + method: VarroaMethod; + unit: 'MITES_PER_DAY' | 'PERCENT'; + /** null: no yellow stage (winter). */ + yellowFrom: number | null; + redAbove: number; + reason: VarroaBandReason; + youngColony: boolean; +} + +export interface VarroaBands { + day: string; + bands: Partial>; + /** Recheck day while a treatment effect is in force, otherwise null. */ + postTreatmentUntil: string | null; +} + +export interface VarroaRating { + light: VarroaLightKey | 'POST_TREATMENT' | null; + reasonCode: VarroaBandReason | 'POST_TREATMENT' | null; + band: VarroaBand | null; + postTreatmentUntil: string | null; +} + +const PERCENT_METHODS: readonly VarroaMethod[] = [ + 'POWDERED_SUGAR', + 'WASHING_METHOD', +]; + +function naturalFallBand( + month: number, + factor: number, + young: boolean, + config: VarroaThresholdConfig['naturalFall'], +): VarroaBand { + const inSeason = config.seasonMonths.includes(month); + return { + method: 'NATURAL_MITE_FALL', + unit: 'MITES_PER_DAY', + yellowFrom: inSeason ? config.yellowFrom * factor : null, + redAbove: (inSeason ? config.redAbove : config.winterRedAbove) * factor, + reason: inSeason ? 'NATURAL_FALL_SEASON' : 'NATURAL_FALL_WINTER', + youngColony: young, + }; +} + +/** The percentage band of the month, absent outside summer and autumn. */ +function percentBand( + method: VarroaMethod, + month: number, + config: VarroaThresholdConfig['infestationPercent'], +): VarroaBand | undefined { + if (config.summerMonths.includes(month)) { + return { + method, + unit: 'PERCENT', + ...config.summer, + reason: 'INFESTATION_SUMMER', + youngColony: false, + }; + } + if (config.autumnMonths.includes(month)) { + return { + method, + unit: 'PERCENT', + ...config.autumn, + reason: 'INFESTATION_AUTUMN', + youngColony: false, + }; + } + return undefined; +} + +/** Latest recheck day of the treatments whose effect covers `day`. */ +function postTreatmentUntil( + day: string, + recheckAfterDays: number, + treatments: readonly TreatmentWindowLite[], +): string | null { + let until: string | null = null; + for (const { startDay, endDay } of treatments) { + const recheckDay = addDays(endDay, recheckAfterDays); + const covers = startDay <= day && day < recheckDay; + if (covers && (until === null || recheckDay > until)) { + until = recheckDay; + } + } + return until; +} + +/** + * The bands in force on `day` for a colony of `colonyType`: natural mite fall + * by season (young colonies at the reduced factor), the percentage methods in + * summer and autumn only, no band for the drone brood check. + */ +export function bandsFor( + day: string, + colonyType: ColonyType | null, + config: VarroaThresholdConfig, + treatments: readonly TreatmentWindowLite[], +): VarroaBands { + const month = Number(day.slice(5, 7)); + const young = + colonyType !== null && config.youngColonyTypes.includes(colonyType); + const factor = young ? config.youngColonyFactor : 1; + const bands: VarroaBands['bands'] = { + NATURAL_MITE_FALL: naturalFallBand( + month, + factor, + young, + config.naturalFall, + ), + }; + for (const method of PERCENT_METHODS) { + const band = percentBand(method, month, config.infestationPercent); + if (band) { + bands[method] = band; + } + } + return { + day, + bands, + postTreatmentUntil: postTreatmentUntil( + day, + config.recheckAfterDays, + treatments, + ), + }; +} + +/** Red above redAbove, yellow from yellowFrom, otherwise green. */ +export function classify( + value: number | null, + band: VarroaBand | null | undefined, +): VarroaLightKey | null { + if (value === null || !band) { + return null; + } + if (value > band.redAbove) { + return 'red'; + } + if (band.yellowFrom !== null && value >= band.yellowFrom) { + return 'yellow'; + } + return 'green'; +} + +/** + * The light of one measurement. A measurement taken from the start of a + * treatment until the recheck day has no light (treatment effect), except the + * drone brood check, which never has one. + */ +export function rateMeasurement( + m: Pick, + colonyType: ColonyType | null, + config: VarroaThresholdConfig, + treatments: readonly TreatmentWindowLite[], +): VarroaRating { + const day = berlinDayOf(m.date); + const { bands, postTreatmentUntil: recheckDay } = bandsFor( + day, + colonyType, + config, + treatments, + ); + const band = bands[m.method] ?? null; + if (recheckDay !== null && m.method !== 'DRONE_BROOD_CONTROL') { + return { + light: 'POST_TREATMENT', + reasonCode: 'POST_TREATMENT', + band: null, + postTreatmentUntil: recheckDay, + }; + } + return { + light: classify(m.calculatedValue, band), + reasonCode: band?.reason ?? null, + band, + postTreatmentUntil: null, + }; +} diff --git a/ApiCore-Backend/src/backup/backup.service.spec.ts b/ApiCore-Backend/src/backup/backup.service.spec.ts index 9b5d68a..dccc092 100644 --- a/ApiCore-Backend/src/backup/backup.service.spec.ts +++ b/ApiCore-Backend/src/backup/backup.service.spec.ts @@ -72,6 +72,7 @@ const ALL_ENTITY_KEYS = [ ...BUSINESS_ENTITY_KEYS, 'document', 'notification', + 'notificationPreference', 'auditLog', ] as const; @@ -113,6 +114,10 @@ describe('BackupService', () => { upsert: jest.fn().mockResolvedValue({ id: `${key}-upserted` }), }; } + tx.notification = { + upsert: jest.fn().mockResolvedValue({ id: 'notification-upserted' }), + findUnique: jest.fn().mockResolvedValue(null), + } as never; tx.queen.update = jest.fn().mockResolvedValue({}); tx.queen.findMany = jest.fn().mockResolvedValue([]); tx.document.findUnique = jest.fn().mockResolvedValue(null); @@ -1304,6 +1309,148 @@ describe('BackupService', () => { }); }); + describe('notifications', () => { + const note = (overrides: Row = {}): Row => ({ + id: 'n1', + userId: 'u1', + priority: 'INFO', + category: 'SUGGESTION', + message: 'm', + relatedType: 'COLONY', + relatedId: 'c1', + read: true, + createdAt: '2026-01-01T00:00:00.000Z', + ...overrides, + }); + const without = (row: Row, ...keys: string[]): Row => + Object.fromEntries( + Object.entries(row).filter(([key]) => !keys.includes(key)), + ); + const importNotes = (rows: Row[]) => + service.importAll( + { + version: 3, + exportedAt: '2026-01-01T00:00:00.000Z', + data: { notification: rows }, + }, + 'admin-1', + TRUSTED, + ); + const written = () => + ( + tx.notification.upsert.mock.calls as [ + { where: { id: string }; create: Row }, + ][] + ).map(([arg]) => arg.create); + + beforeEach(() => { + tx.user.findMany.mockResolvedValue([{ id: 'u1' }, { id: 'u2' }]); + }); + + it('restore keeps the userId of a notification and skips a row whose user does not exist in the target', async () => { + const summary = await importNotes([ + note({ id: 'n1', userId: 'u2' }), + note({ id: 'n2', userId: 'gone' }), + ]); + + expect(written().map((row) => [row.id, row.userId])).toEqual([ + ['n1', 'u2'], + ]); + expect(summary.imported.notification).toBe(1); + expect(summary.imported.notificationSkipped).toBe(1); + }); + + it('skips and counts a row whose dedupeKey already exists under another id', async () => { + ( + tx.notification as unknown as { findUnique: jest.Mock } + ).findUnique.mockImplementation( + ({ where }: { where: { dedupeKey: string } }) => + Promise.resolve( + where.dedupeKey === 'k:u1' ? { id: 'other-id' } : null, + ), + ); + + const summary = await importNotes([ + note({ id: 'n1', dedupeKey: 'k:u1' }), + note({ id: 'n2', dedupeKey: 'k:u2' }), + // Same id as the stored row: a plain re-restore, written as before. + note({ id: 'other-id', dedupeKey: 'k:u1' }), + ]); + + expect(written().map((row) => row.id)).toEqual(['n2', 'other-id']); + expect(summary.imported.notification).toBe(2); + expect(summary.imported.notificationSkipped).toBe(1); + }); + + it('a repeated restore of broadcast copies does not reset their read flags', async () => { + const { userId: _u, category: _c, ...old } = note({ id: 'b' }); + void _u; + void _c; + await importNotes([old]); + + const calls = tx.notification.upsert.mock.calls as [{ update: Row }][]; + expect(calls).toHaveLength(2); + expect(calls.every(([arg]) => Object.keys(arg.update).length === 0)).toBe( + true, + ); + }); + + it('never writes a notification without a userId', async () => { + await importNotes([ + note({ userId: null }), + note({ id: 'n3', userId: 'gone' }), + ]); + + expect(written().every((row) => typeof row.userId === 'string')).toBe( + true, + ); + }); + + it('an old bundle notification without userId is copied per active user: category SYSTEM for relatedType JobRun or none, SUGGESTION otherwise', async () => { + const old = (id: string, relatedType: string | null): Row => { + return without(note({ id, relatedType }), 'userId', 'category'); + }; + + await importNotes([ + old('a', 'JobRun'), + old('b', null), + old('c', 'COLONY'), + ]); + + const rows = written().map((row) => [row.userId, row.category, row.read]); + expect(rows).toEqual([ + ['u1', 'SYSTEM', true], + ['u2', 'SYSTEM', true], + ['u1', 'SYSTEM', true], + ['u2', 'SYSTEM', true], + ['u1', 'SUGGESTION', true], + ['u2', 'SUGGESTION', true], + ]); + expect(new Set(written().map((row) => row.id)).size).toBe(6); + }); + + it('fills the category of an old row that has a userId but none', async () => { + await importNotes([without(note({ relatedType: 'JobRun' }), 'category')]); + expect(written()[0]).toMatchObject({ userId: 'u1', category: 'SYSTEM' }); + }); + + it('exports and restores the notification preferences of the users', async () => { + prisma.notificationPreference.findMany.mockResolvedValue([ + { userId: 'u1', digestTime: '07:00' }, + { userId: 'gone', digestTime: '08:00' }, + ]); + const bundle = await service.exportAll(); + expect(bundle.data.notificationPreference).toHaveLength(2); + + await service.importAll(bundle, 'admin-1', TRUSTED); + + const calls = tx.notificationPreference.upsert.mock.calls as [ + { where: { userId: string } }, + ][]; + expect(calls.map(([arg]) => arg.where.userId)).toEqual(['u1']); + }); + }); + // 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 10d1fa5..3b2648e 100644 --- a/ApiCore-Backend/src/backup/backup.service.ts +++ b/ApiCore-Backend/src/backup/backup.service.ts @@ -71,7 +71,8 @@ export interface BackupImportSummary { * - siteSettings: keeper name/address printed on the Bestandsbuch and the * public links. * - document: writes files to the server's disk. - * - notification: messages shown to users as coming from ApiCore. + * - notification, notificationPreference: messages shown to users as coming + * from ApiCore, and settings of a user. */ export const LEGACY_UNSEALED_TABLES = [ 'apiary', @@ -188,6 +189,23 @@ function withCurrentFeedVocabulary(row: Row): Row { }; } +// Bundles written before the notification categories carry none: job failures +// (relatedType 'JobRun') and rows without relatedType are system messages, +// everything else a suggestion - the rule of migration 11. +function withCurrentNotificationCategory(row: Row): Row { + if (typeof row.category === 'string') { + return row; + } + const relatedType = asString(row, 'relatedType'); + return { + ...row, + category: + relatedType === null || relatedType === 'JobRun' + ? 'SYSTEM' + : 'SUGGESTION', + }; +} + function documentsStorageRoot(): string { return process.env.DOCUMENTS_STORAGE_PATH ?? './uploads'; } @@ -316,6 +334,8 @@ export class BackupService { ); data.notification = await this.prisma.notification.findMany(); + data.notificationPreference = + await this.prisma.notificationPreference.findMany(); data.auditLog = await this.prisma.auditLog.findMany(); return { @@ -1125,20 +1145,86 @@ export class BackupService { } imported.document = documentCount; + const activeUserIds = ( + await tx.user.findMany({ + where: { isActive: true }, + select: { id: true }, + orderBy: { id: 'asc' }, + }) + ).map((user) => user.id); + let notificationsWritten = 0; + let notificationsSkipped = 0; for (const row of rowsOf('notification')) { - await tx.notification.upsert({ - where: { id: row.id as string }, - create: { - ...row, - userId: resolveOptionalUserId(asString(row, 'userId')), - } as never, - update: { - ...row, - userId: resolveOptionalUserId(asString(row, 'userId')), - } as never, - }); + const withCategory = withCurrentNotificationCategory(row); + const rawUserId = asString(row, 'userId'); + // A bundle from before the per-user notifications carries + // broadcasts (no userId): one copy per active user, as migration 8. + // A row of a user this database does not know is skipped - the + // userId is mandatory and never falls back to somebody else. + const owners = + rawUserId === null + ? activeUserIds + : knownUserIds.has(rawUserId) + ? [rawUserId] + : []; + if (owners.length === 0) { + notificationsSkipped += 1; + continue; + } + for (const userId of owners) { + const data = { + ...withCategory, + id: + rawUserId === null + ? `${row.id as string}:${userId}` + : (row.id as string), + userId, + }; + // The unique dedupeKey of a row that lives here under another id + // would abort the whole restore: skip that row instead. + const dedupeKey = asString(data, 'dedupeKey'); + if (dedupeKey !== null) { + const holder = await tx.notification.findUnique({ + where: { dedupeKey }, + select: { id: true }, + }); + if (holder && holder.id !== data.id) { + notificationsSkipped += 1; + continue; + } + } + await tx.notification.upsert({ + where: { id: data.id }, + create: data as never, + // A copy made from a broadcast is only added: a repeated + // restore must not reset what the user already read. + update: (rawUserId === null ? {} : data) as never, + }); + notificationsWritten += 1; + } } - imported.notification = rowsOf('notification').length; + imported.notification = notificationsWritten; + if (notificationsSkipped > 0) { + imported.notificationSkipped = notificationsSkipped; + this.logger.warn( + `${notificationsSkipped} Meldung(en) übersprungen: Nutzer im Zielsystem unbekannt.`, + ); + } + + let preferencesWritten = 0; + for (const row of rowsOf('notificationPreference')) { + const userId = asString(row, 'userId'); + if (userId === null || !knownUserIds.has(userId)) { + continue; + } + await tx.notificationPreference.upsert({ + where: { userId }, + create: row as never, + update: row as never, + }); + preferencesWritten += 1; + } + imported.notificationPreference = preferencesWritten; // Append-only: entries already here are never rewritten by a file. for (const row of rowsOf('auditLog')) { diff --git a/ApiCore-Backend/src/calendar-sync/calendar-sync-settings.service.spec.ts b/ApiCore-Backend/src/calendar-sync/calendar-sync-settings.service.spec.ts index ee2b490..c7778ad 100644 --- a/ApiCore-Backend/src/calendar-sync/calendar-sync-settings.service.spec.ts +++ b/ApiCore-Backend/src/calendar-sync/calendar-sync-settings.service.spec.ts @@ -23,6 +23,7 @@ describe('CalendarSyncSettingsService', () => { syncFeeding: true, syncHarvest: true, syncQueenAssignment: true, + syncWithdrawal: true, updatedAt: new Date('2026-01-01'), }; @@ -87,6 +88,7 @@ describe('CalendarSyncSettingsService', () => { syncFeeding: undefined, syncHarvest: undefined, syncQueenAssignment: undefined, + syncWithdrawal: undefined, }, }); expect(result.groupId).toBe('group-abc'); @@ -149,8 +151,25 @@ describe('CalendarSyncSettingsService', () => { syncFeeding: undefined, syncHarvest: undefined, syncQueenAssignment: undefined, + syncWithdrawal: undefined, }, }); }); + + it('passes the withdrawal switch on', async () => { + prisma.calendarSyncSettings.findUnique.mockResolvedValue(existing); + prisma.calendarSyncSettings.update.mockResolvedValue({ + ...existing, + syncWithdrawal: false, + }); + + await service.update({ syncWithdrawal: false }, 'user-1'); + + expect(prisma.calendarSyncSettings.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ syncWithdrawal: false }) as unknown, + }), + ); + }); }); }); diff --git a/ApiCore-Backend/src/calendar-sync/calendar-sync-settings.service.ts b/ApiCore-Backend/src/calendar-sync/calendar-sync-settings.service.ts index 040a8d0..02910e4 100644 --- a/ApiCore-Backend/src/calendar-sync/calendar-sync-settings.service.ts +++ b/ApiCore-Backend/src/calendar-sync/calendar-sync-settings.service.ts @@ -47,6 +47,7 @@ export class CalendarSyncSettingsService { syncFeeding: dto.syncFeeding, syncHarvest: dto.syncHarvest, syncQueenAssignment: dto.syncQueenAssignment, + syncWithdrawal: dto.syncWithdrawal, }, }); await this.auditLog.record({ diff --git a/ApiCore-Backend/src/calendar-sync/calendar-sync.handlers.spec.ts b/ApiCore-Backend/src/calendar-sync/calendar-sync.handlers.spec.ts index 15b35d4..3c59799 100644 --- a/ApiCore-Backend/src/calendar-sync/calendar-sync.handlers.spec.ts +++ b/ApiCore-Backend/src/calendar-sync/calendar-sync.handlers.spec.ts @@ -59,6 +59,24 @@ describe('CalendarSyncEventHandlers', () => { }, ); + it('QueenAssigned enqueues QUEEN_ASSIGNMENT with the history id', async () => { + await handlers.queenAssignment( + envelope('QueenAssigned', 'queen-1', { + colonyId: 'colony-1', + historyId: 'history-1', + assignedOn: '2026-10-14', + previousColonyId: null, + }), + tx, + ); + + expect(calendarSync.enqueueIn).toHaveBeenCalledWith( + tx, + 'QUEEN_ASSIGNMENT', + 'history-1', + ); + }); + it('TaskCreated without a due date enqueues nothing', async () => { await handlers.task( envelope('TaskCreated', 'task-1', { dueDate: null }), @@ -105,6 +123,12 @@ describe('CalendarSyncEventHandlers', () => { ); expect(registered('feeding')[0].handlerId).toBe('calendar-sync.feeding'); expect(registered('harvest')[0].handlerId).toBe('calendar-sync.harvest'); + expect(registered('queenAssignment')).toEqual([ + { + types: ['QueenAssigned'], + handlerId: 'calendar-sync.queen-assignment', + }, + ]); expect(registered('task')).toEqual([ { types: ['TaskCreated', 'TaskUpdated', 'TaskCompleted'], diff --git a/ApiCore-Backend/src/calendar-sync/calendar-sync.handlers.ts b/ApiCore-Backend/src/calendar-sync/calendar-sync.handlers.ts index 8aacc51..99a7742 100644 --- a/ApiCore-Backend/src/calendar-sync/calendar-sync.handlers.ts +++ b/ApiCore-Backend/src/calendar-sync/calendar-sync.handlers.ts @@ -74,4 +74,17 @@ export class CalendarSyncEventHandlers { } await this.calendarSync.enqueueIn(tx, 'TASK', event.aggregateId); } + + // The aggregate is the queen; the calendar entry belongs to the history row. + @OnDomainEvent(['QueenAssigned'], 'calendar-sync.queen-assignment') + async queenAssignment( + event: AnyDomainEventEnvelope<'QueenAssigned'>, + tx: Prisma.TransactionClient, + ): Promise { + await this.calendarSync.enqueueIn( + tx, + 'QUEEN_ASSIGNMENT', + event.payload.historyId, + ); + } } diff --git a/ApiCore-Backend/src/calendar-sync/calendar-sync.service.spec.ts b/ApiCore-Backend/src/calendar-sync/calendar-sync.service.spec.ts index 6562ec3..521603f 100644 --- a/ApiCore-Backend/src/calendar-sync/calendar-sync.service.spec.ts +++ b/ApiCore-Backend/src/calendar-sync/calendar-sync.service.spec.ts @@ -42,6 +42,7 @@ describe('CalendarSyncService', () => { syncFeeding: true, syncHarvest: true, syncQueenAssignment: true, + syncWithdrawal: true, updatedAt: new Date(), }; @@ -122,11 +123,22 @@ describe('CalendarSyncService', () => { expect(prisma.calendarSyncOutboxEntry.upsert).not.toHaveBeenCalled(); }); - it('does not enqueue a WITHDRAWAL entry yet (the switch arrives with Task 3)', async () => { + it('enqueues a WITHDRAWAL entry while syncWithdrawal is on', async () => { settingsService.get.mockResolvedValue(enabledSettings); await service.enqueue('WITHDRAWAL', 'treatment-1'); + expect(prisma.calendarSyncOutboxEntry.upsert).toHaveBeenCalled(); + }); + + it('does not enqueue a WITHDRAWAL entry when syncWithdrawal is off', async () => { + settingsService.get.mockResolvedValue({ + ...enabledSettings, + syncWithdrawal: false, + }); + + await service.enqueue('WITHDRAWAL', 'treatment-1'); + expect(prisma.calendarSyncOutboxEntry.upsert).not.toHaveBeenCalled(); }); diff --git a/ApiCore-Backend/src/calendar-sync/calendar-sync.service.ts b/ApiCore-Backend/src/calendar-sync/calendar-sync.service.ts index fce9a6f..c4a43b1 100644 --- a/ApiCore-Backend/src/calendar-sync/calendar-sync.service.ts +++ b/ApiCore-Backend/src/calendar-sync/calendar-sync.service.ts @@ -103,8 +103,7 @@ export class CalendarSyncService { FEEDING: settings.syncFeeding, HARVEST: settings.syncHarvest, QUEEN_ASSIGNMENT: settings.syncQueenAssignment, - // Switch in the settings arrives with Task 3. - WITHDRAWAL: false, + WITHDRAWAL: settings.syncWithdrawal, }; return flagBySourceType[sourceType]; } diff --git a/ApiCore-Backend/src/calendar-sync/dto/update-calendar-sync-settings.dto.ts b/ApiCore-Backend/src/calendar-sync/dto/update-calendar-sync-settings.dto.ts index e11e08a..6b3b0be 100644 --- a/ApiCore-Backend/src/calendar-sync/dto/update-calendar-sync-settings.dto.ts +++ b/ApiCore-Backend/src/calendar-sync/dto/update-calendar-sync-settings.dto.ts @@ -66,4 +66,9 @@ export class UpdateCalendarSyncSettingsDto { @IsOptionalNotNull() @IsBoolean() syncQueenAssignment?: boolean; + + @ApiPropertyOptional() + @IsOptionalNotNull() + @IsBoolean() + syncWithdrawal?: boolean; } diff --git a/ApiCore-Backend/src/colonies/colonies.service.spec.ts b/ApiCore-Backend/src/colonies/colonies.service.spec.ts index 880a59f..8719872 100644 --- a/ApiCore-Backend/src/colonies/colonies.service.spec.ts +++ b/ApiCore-Backend/src/colonies/colonies.service.spec.ts @@ -4,6 +4,7 @@ import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; import { WebhooksService } from '../webhooks/webhooks.service'; import { DomainEventsService } from '../domain-events/domain-events.service'; +import { berlinToday } from '../common/dates'; describe('ColoniesService', () => { let service: ColoniesService; @@ -139,6 +140,96 @@ describe('ColoniesService', () => { expect(lineageArg.data.toColonyId).toBe('colony-2'); }); + it('split records ColonySplit with queenSource and the given date, audit inside the transaction', async () => { + prisma.colony.findUnique.mockResolvedValue(parentColony); + const child = { id: 'colony-2', code: 'V-001-A', apiaryId: 'apiary-1' }; + tx.colony.create.mockResolvedValue(child); + tx.colonyLineage.create.mockResolvedValue({ id: 'lineage-1' }); + + await service.split( + 'colony-1', + { + code: 'V-001-A', + colonyType: 'NUCLEUS_COLONY', + date: '2026-05-05', + queenSource: 'QUEEN_CELL', + }, + 'user-1', + ); + + expect(tx.$queryRaw).toHaveBeenCalledTimes(1); + const [[lineageArg]] = tx.colonyLineage.create.mock.calls as [ + [{ data: { date: Date; queenSource: string } }], + ]; + expect(lineageArg.data.date).toEqual(new Date('2026-05-05T00:00:00.000Z')); + expect(lineageArg.data.queenSource).toBe('QUEEN_CELL'); + expect(events.record).toHaveBeenCalledWith( + tx, + expect.objectContaining({ + type: 'ColonySplit', + aggregateType: 'Colony', + aggregateId: 'colony-2', + actorUserId: 'user-1', + payload: { + parentColonyId: 'colony-1', + lineageId: 'lineage-1', + queenSource: 'QUEEN_CELL', + splitOn: '2026-05-05', + }, + }), + ); + expect(auditLog.record).toHaveBeenCalledWith(expect.anything(), tx); + }); + + it('split without a date uses today and a null queenSource', async () => { + prisma.colony.findUnique.mockResolvedValue(parentColony); + tx.colony.create.mockResolvedValue({ id: 'colony-2' }); + tx.colonyLineage.create.mockResolvedValue({ id: 'lineage-1' }); + + await service.split( + 'colony-1', + { code: 'V-001-A', colonyType: 'NUCLEUS_COLONY' }, + 'user-1', + ); + + const [[, event]] = events.record.mock.calls as [ + [unknown, { payload: { queenSource: unknown; splitOn: string } }], + ]; + expect(event.payload.queenSource).toBeNull(); + expect(event.payload.splitOn).toBe(berlinToday()); + }); + + it('split with a future date answers 400', async () => { + prisma.colony.findUnique.mockResolvedValue(parentColony); + + await expect( + service.split( + 'colony-1', + { code: 'V-001-A', colonyType: 'NUCLEUS_COLONY', date: '2999-01-01' }, + 'user-1', + ), + ).rejects.toMatchObject({ + status: 400, + message: 'Das Datum liegt in der Zukunft.', + }); + expect(tx.colony.create).not.toHaveBeenCalled(); + }); + + it('split of a colony that ended meanwhile (seen after the lock) answers 409', async () => { + prisma.colony.findUnique + .mockResolvedValueOnce(parentColony) + .mockResolvedValueOnce({ ...parentColony, status: 'DISSOLVED' }); + + await expect( + service.split( + 'colony-1', + { code: 'V-001-A', colonyType: 'NUCLEUS_COLONY' }, + 'user-1', + ), + ).rejects.toBeInstanceOf(ConflictException); + expect(tx.colony.create).not.toHaveBeenCalled(); + }); + it('merge marks the merged colony as MERGED, releases its queen, and records a MERGE lineage entry', async () => { prisma.colony.findUnique .mockResolvedValueOnce(parentColony) @@ -502,6 +593,7 @@ describe('ColoniesService', () => { it('writes the audit entry of create, move and split inside the transaction', async () => { prisma.colony.findUnique.mockResolvedValue(parentColony); tx.colony.create.mockResolvedValue({ id: 'colony-2' }); + tx.colonyLineage.create.mockResolvedValue({ id: 'lineage-1' }); tx.colony.update.mockResolvedValue({ ...parentColony, apiaryId: 'apiary-2', diff --git a/ApiCore-Backend/src/colonies/colonies.service.ts b/ApiCore-Backend/src/colonies/colonies.service.ts index 30e690a..dbdee6b 100644 --- a/ApiCore-Backend/src/colonies/colonies.service.ts +++ b/ApiCore-Backend/src/colonies/colonies.service.ts @@ -17,6 +17,7 @@ import { MergeColonyDto } from './dto/merge-colony.dto'; import { isLivingColonyStatus } from './colony-status'; import { lockRowsForUpdate } from '../prisma/row-lock'; import { colonyTransitionEvents, lockColony } from './colony-events'; +import { berlinToday, dayToDate } from '../common/dates'; export interface ColonyFilter { status?: ColonyStatus; @@ -194,9 +195,16 @@ export class ColoniesService { dto: SplitColonyDto, actingUserId: string, ): Promise { - const parent = await this.findById(id); - this.assertActive(parent); + await this.findById(id); + const today = berlinToday(); + const splitOn = dto.date ?? today; + if (splitOn > today) { + throw new BadRequestException('Das Datum liegt in der Zukunft.'); + } + const queenSource = dto.queenSource ?? null; const child = await this.prisma.$transaction(async (tx) => { + const parent = await lockColony(tx, id); + this.assertActive(parent); const created = await tx.colony.create({ data: { code: dto.code, @@ -205,12 +213,13 @@ export class ColoniesService { notes: dto.notes, }, }); - await tx.colonyLineage.create({ + const lineage = await tx.colonyLineage.create({ data: { type: 'SPLIT', fromColonyId: parent.id, toColonyId: created.id, - date: new Date(), + date: dayToDate(splitOn), + queenSource, notes: dto.notes, }, }); @@ -224,6 +233,18 @@ export class ColoniesService { }, tx, ); + await this.events.record(tx, { + type: 'ColonySplit', + aggregateType: 'Colony', + aggregateId: created.id, + actorUserId: actingUserId, + payload: { + parentColonyId: parent.id, + lineageId: lineage.id, + queenSource, + splitOn, + }, + }); return created; }); return child; diff --git a/ApiCore-Backend/src/colonies/dto/split-colony.dto.ts b/ApiCore-Backend/src/colonies/dto/split-colony.dto.ts index ee5100b..5021702 100644 --- a/ApiCore-Backend/src/colonies/dto/split-colony.dto.ts +++ b/ApiCore-Backend/src/colonies/dto/split-colony.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsEnum, IsOptional, IsString } from 'class-validator'; -import { ColonyType } from '../../../generated/prisma'; +import { IsEnum, IsOptional, IsString, Matches } from 'class-validator'; +import { ColonyType, SplitQueenSource } from '../../../generated/prisma'; export class SplitColonyDto { @ApiProperty() @@ -15,4 +15,16 @@ export class SplitColonyDto { @IsOptional() @IsString() notes?: string; + + @ApiPropertyOptional({ + description: 'Day of the split (YYYY-MM-DD), not after today.', + }) + @IsOptional() + @Matches(/^\d{4}-\d{2}-\d{2}$/, { message: 'date must be YYYY-MM-DD' }) + date?: string; + + @ApiPropertyOptional({ enum: SplitQueenSource }) + @IsOptional() + @IsEnum(SplitQueenSource) + queenSource?: SplitQueenSource; } diff --git a/ApiCore-Backend/src/common/after-commit-usage.spec.ts b/ApiCore-Backend/src/common/after-commit-usage.spec.ts index 9b50d13..57e6e3c 100644 --- a/ApiCore-Backend/src/common/after-commit-usage.spec.ts +++ b/ApiCore-Backend/src/common/after-commit-usage.spec.ts @@ -43,10 +43,8 @@ describe('legacy after-commit paths', () => { ]); }); - it('only the queen assignment still enqueues a calendar entry directly', () => { - expect(filesCalling('calendarSync.enqueue(')).toEqual([ - 'src/queens/queens.service.ts', - ]); + it('no module enqueues a calendar entry directly any more', () => { + expect(filesCalling('calendarSync.enqueue(')).toEqual([]); }); it('only the modules without an event still dispatch webhooks directly', () => { diff --git a/ApiCore-Backend/src/common/outbox/outbox-claims.ts b/ApiCore-Backend/src/common/outbox/outbox-claims.ts index 2153c85..755437a 100644 --- a/ApiCore-Backend/src/common/outbox/outbox-claims.ts +++ b/ApiCore-Backend/src/common/outbox/outbox-claims.ts @@ -71,9 +71,13 @@ function orderColumn(column: OutboxOrderColumn): Prisma.Sql { } /** - * Claims up to `limit` due PENDING entries, oldest first. SKIP LOCKED lets - * several workers claim side by side without waiting for or overlapping - * each other. Returns the claimed rows (all columns) in claim order. + * Claims up to `limit` due PENDING entries, oldest first. "availableAt" is + * TIMESTAMP(3), so a value written from now() is rounded to the nearest + * millisecond - up to 0.5 ms ahead of the clock. Comparing against now() + * rounded the same way keeps such an entry due as soon as it is written; + * a plain now() would miss it for the first fraction of a millisecond. + * SKIP LOCKED lets several workers claim side by side without waiting for + * or overlapping each other. Returns the claimed rows (all columns) in claim order. * Uses the root client: a claim must commit on its own. */ export async function claimBatch( @@ -96,7 +100,7 @@ export async function claimBatch( UPDATE ${name} SET "status" = 'PROCESSING', "claimedAt" = now() WHERE "id" IN ( SELECT "id" FROM ${name} - WHERE "status" = 'PENDING' AND "availableAt" <= now() ${filter} + WHERE "status" = 'PENDING' AND "availableAt" <= CAST(now() AS timestamp(3)) ${filter} ORDER BY ${orderBy} LIMIT ${options.limit} FOR UPDATE SKIP LOCKED 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 06680f5..f7deef2 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 @@ -15,6 +15,7 @@ import { UpdateHoneyBatchDto } from '../../honey-batches/dto/update-honey-batch. import { UpdateHoneyMeasurementDto } from '../../honey-batches/dto/update-honey-measurement.dto'; import { UpdateInspectionDto } from '../../inspections/dto/update-inspection.dto'; import { UpdateLabelTemplateDto } from '../../label-templates/dto/update-label-template.dto'; +import { UpdateNotificationSettingsDto } from '../../notifications/dto/update-notification-settings.dto'; import { UpdateProductionBatchDto } from '../../production-batches/dto/update-production-batch.dto'; import { UpdateProductDto } from '../../products/dto/update-product.dto'; import { UpdateProfileDto } from '../../profile/dto/update-profile.dto'; @@ -78,9 +79,23 @@ const contracts: Record = { 'syncFeeding', 'syncHarvest', 'syncQueenAssignment', + 'syncWithdrawal', ], clearable: ['groupId'], }, + // quietFrom/quietUntil: null clears them, but only as a pair (the service + // judges the merged values). + UpdateNotificationSettingsDto: { + dto: UpdateNotificationSettingsDto, + notNull: [ + 'taskDueEnabled', + 'digestEnabled', + 'digestTime', + 'telegramCategories', + 'telegramDetail', + ], + clearable: ['quietFrom', 'quietUntil'], + }, UpdateBottlingBatchDto: { dto: UpdateBottlingBatchDto, notNull: [ diff --git a/ApiCore-Backend/src/domain-events/domain-event-dispatcher.spec.ts b/ApiCore-Backend/src/domain-events/domain-event-dispatcher.spec.ts index b6d9f30..7248671 100644 --- a/ApiCore-Backend/src/domain-events/domain-event-dispatcher.spec.ts +++ b/ApiCore-Backend/src/domain-events/domain-event-dispatcher.spec.ts @@ -173,7 +173,7 @@ describe('DomainEventDispatcher', () => { `UPDATE "DomainEvent" SET "status" = 'PROCESSING', "claimedAt" = now()`, ); expect(text(claim)).toContain( - `WHERE "status" = 'PENDING' AND "availableAt" <= now()`, + `WHERE "status" = 'PENDING' AND "availableAt" <= CAST(now() AS timestamp(3))`, ); expect(text(claim)).toContain( 'ORDER BY "occurredAt" LIMIT ? FOR UPDATE SKIP LOCKED', @@ -186,7 +186,7 @@ describe('DomainEventDispatcher', () => { const claim = db.claimQuery(); expect(text(claim)).toContain( - `"availableAt" <= now() AND "correlationId" = ANY(?) ORDER BY`, + `"availableAt" <= CAST(now() AS timestamp(3)) AND "correlationId" = ANY(?) ORDER BY`, ); expect(claim.values).toEqual([['c1', 'c2'], 50]); }); diff --git a/ApiCore-Backend/src/domain-events/domain-event-types.ts b/ApiCore-Backend/src/domain-events/domain-event-types.ts index 443cf25..f400640 100644 --- a/ApiCore-Backend/src/domain-events/domain-event-types.ts +++ b/ApiCore-Backend/src/domain-events/domain-event-types.ts @@ -3,6 +3,7 @@ import { ColonyStatus, Prisma, RawMaterialKind, + SplitQueenSource, TaskRelatedType, TaskStatus, } from '../../generated/prisma'; @@ -68,6 +69,20 @@ export interface DomainEventPayloads { lastInspectedOn: string | null; firstOfYear?: boolean; }; + // Aggregate Colony: the new split colony. + ColonySplit: { + parentColonyId: string; + lineageId: string; + queenSource: SplitQueenSource | null; + splitOn: string; + }; + // Aggregate Queen. + QueenAssigned: { + colonyId: string; + historyId: string; + assignedOn: string; + previousColonyId: string | null; + }; } export type DomainEventType = keyof DomainEventPayloads; @@ -97,6 +112,8 @@ const CATALOG: Record = { QueenAged: true, VarroaMeasurementDue: true, InspectionOverdue: true, + ColonySplit: true, + QueenAssigned: true, }; export const DOMAIN_EVENT_TYPES: readonly DomainEventType[] = Object.freeze( diff --git a/ApiCore-Backend/src/domain-events/domain-events.service.spec.ts b/ApiCore-Backend/src/domain-events/domain-events.service.spec.ts index 5217aa8..8d19f9e 100644 --- a/ApiCore-Backend/src/domain-events/domain-events.service.spec.ts +++ b/ApiCore-Backend/src/domain-events/domain-events.service.spec.ts @@ -183,7 +183,7 @@ describe('DomainEventsService', () => { describe('domain event catalog', () => { it('lists every event type exactly once', () => { - expect(DOMAIN_EVENT_TYPES).toHaveLength(23); + expect(DOMAIN_EVENT_TYPES).toHaveLength(25); expect(new Set(DOMAIN_EVENT_TYPES).size).toBe(DOMAIN_EVENT_TYPES.length); expect(DOMAIN_EVENT_TYPES).toEqual( expect.arrayContaining([ @@ -191,6 +191,8 @@ describe('domain event catalog', () => { 'StockChanged', 'TaskDue', 'InspectionOverdue', + 'ColonySplit', + 'QueenAssigned', ]), ); }); diff --git a/ApiCore-Backend/src/jobs/daily-evaluation.rules.spec.ts b/ApiCore-Backend/src/jobs/daily-evaluation.rules.spec.ts index a0b846a..d5ab876 100644 --- a/ApiCore-Backend/src/jobs/daily-evaluation.rules.spec.ts +++ b/ApiCore-Backend/src/jobs/daily-evaluation.rules.spec.ts @@ -174,10 +174,10 @@ describe('daily evaluation rules', () => { }), ).toBe('2026-10-12')); - it('ends on the treatment day itself without a waiting time and ignores the time of day', () => + it('ends on the treatment day itself without a waiting time and uses the Berlin day, ignoring the time of day', () => expect( withdrawalEndDay({ - date: new Date('2026-09-28T22:15:00.000Z'), + date: new Date('2026-09-28T22:15:00+02:00'), endDate: null, withdrawalPeriodDays: 0, }), diff --git a/ApiCore-Backend/src/jobs/daily-evaluation.rules.ts b/ApiCore-Backend/src/jobs/daily-evaluation.rules.ts index 0573042..218a109 100644 --- a/ApiCore-Backend/src/jobs/daily-evaluation.rules.ts +++ b/ApiCore-Backend/src/jobs/daily-evaluation.rules.ts @@ -12,6 +12,7 @@ import { inspectionDueOn } from '../season/inspection-due'; import { isQueenAged } from '../season/queen-age'; import type { SeasonService } from '../season/season.service'; import { varroaMeasurementDue } from '../season/varroa-due'; +import { withdrawalUntilDay } from '../treatments/withdrawal-window'; /** How many days back a run still records an occurrence it missed. */ export const CATCH_UP_DAYS = 7; @@ -82,10 +83,7 @@ function dayAfter(date: Date, days: number): Date { export function withdrawalEndDay( t: Pick, ): string | null { - if (t.withdrawalPeriodDays === null) { - return null; - } - return utcDay(dayAfter(t.endDate ?? t.date, t.withdrawalPeriodDays)); + return withdrawalUntilDay(t); } /** True for a 'YYYY-MM-DD' day from today − CATCH_UP_DAYS up to today. */ diff --git a/ApiCore-Backend/src/notifications/dto/update-notification-settings.dto.ts b/ApiCore-Backend/src/notifications/dto/update-notification-settings.dto.ts new file mode 100644 index 0000000..cfc7e0a --- /dev/null +++ b/ApiCore-Backend/src/notifications/dto/update-notification-settings.dto.ts @@ -0,0 +1,63 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { + ArrayMaxSize, + ArrayUnique, + IsArray, + IsBoolean, + IsEnum, + Matches, + ValidateIf, +} from 'class-validator'; +import { NotificationCategory } from '../../../generated/prisma'; +import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null'; + +const TIME_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/; +const TIME_MESSAGE = 'Bitte eine Uhrzeit im Format HH:MM angeben.'; +const MAX_TELEGRAM_CATEGORIES = Object.keys(NotificationCategory).length; + +// PATCH null rule: an omitted field stays unchanged; the switches, the digest +// time and the category list are NOT NULL columns, so null on them is a 400. +// quietFrom/quietUntil are nullable: null clears them - only as a pair (the +// service judges the merged values). telegramEnabled is not changeable here: +// connecting and disconnecting set it. +export class UpdateNotificationSettingsDto { + @ApiPropertyOptional() + @IsOptionalNotNull() + @IsBoolean() + taskDueEnabled?: boolean; + + @ApiPropertyOptional() + @IsOptionalNotNull() + @IsBoolean() + digestEnabled?: boolean; + + @ApiPropertyOptional({ + description: 'HH:MM, 05:00 bis 12:00 (Europe/Berlin)', + }) + @IsOptionalNotNull() + @Matches(TIME_PATTERN, { message: TIME_MESSAGE }) + digestTime?: string; + + @ApiPropertyOptional({ type: String, nullable: true }) + @ValidateIf((_object, value) => value !== undefined && value !== null) + @Matches(TIME_PATTERN, { message: TIME_MESSAGE }) + quietFrom?: string | null; + + @ApiPropertyOptional({ type: String, nullable: true }) + @ValidateIf((_object, value) => value !== undefined && value !== null) + @Matches(TIME_PATTERN, { message: TIME_MESSAGE }) + quietUntil?: string | null; + + @ApiPropertyOptional({ enum: NotificationCategory, isArray: true }) + @IsOptionalNotNull() + @IsArray() + @ArrayUnique() + @ArrayMaxSize(MAX_TELEGRAM_CATEGORIES) + @IsEnum(NotificationCategory, { each: true }) + telegramCategories?: NotificationCategory[]; + + @ApiPropertyOptional() + @IsOptionalNotNull() + @IsBoolean() + telegramDetail?: boolean; +} diff --git a/ApiCore-Backend/src/notifications/no-broadcast-notifications.spec.ts b/ApiCore-Backend/src/notifications/no-broadcast-notifications.spec.ts new file mode 100644 index 0000000..e7508c9 --- /dev/null +++ b/ApiCore-Backend/src/notifications/no-broadcast-notifications.spec.ts @@ -0,0 +1,76 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { join, relative, sep } from 'node:path'; + +// Spec 14.3: a Notification row always has a recipient. Rows are written only +// by NotificationsService (fan-out, permission check, dedupe key) and by the +// backup restore (which resolves the userId itself). +const SRC = join(__dirname, '..'); +const WRITE_SOURCE = + '\\bnotification\\.(create|createMany|createManyAndReturn|upsert)\\('; +const RAW_INSERT_SOURCE = 'INSERT\\s+INTO\\s+"?Notification"?(?![A-Za-z])'; +const ALLOWED = [ + 'backup/backup.service.ts', + 'notifications/notifications.service.ts', +]; + +function sources(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + return sources(path); + } + return entry.name.endsWith('.ts') && !entry.name.endsWith('.spec.ts') + ? [path] + : []; + }); +} + +const rel = (file: string) => relative(SRC, file).split(sep).join('/'); + +function filesMatching(source: string): string[] { + return sources(SRC) + .filter((file) => new RegExp(source, 'i').test(readFileSync(file, 'utf8'))) + .map(rel) + .sort(); +} + +/** Index of the parenthesis that closes the one at `open`. */ +function closingParen(text: string, open: number): number { + let depth = 0; + for (let i = open; i < text.length; i += 1) { + if (text[i] === '(') depth += 1; + if (text[i] === ')') { + depth -= 1; + if (depth === 0) return i; + } + } + throw new Error('unbalanced parentheses'); +} + +describe('no broadcast notifications', () => { + it('Notification rows are written only by NotificationsService and the backup restore', () => + expect(filesMatching(WRITE_SOURCE)).toEqual(ALLOWED)); + + it('no raw INSERT INTO "Notification" in src', () => + expect(filesMatching(RAW_INSERT_SOURCE)).toEqual([])); + + it('every Notification write in those two files carries a userId (spec 14.3)', () => { + for (const file of ALLOWED) { + const text = readFileSync(join(SRC, file), 'utf8'); + const hits = [...text.matchAll(new RegExp(WRITE_SOURCE, 'g'))]; + expect(hits.length).toBeGreaterThan(0); + for (const hit of hits) { + const open = hit.index + hit[0].length - 1; + const call = text.slice(open, closingParen(text, open) + 1); + if (call.includes('userId')) { + continue; + } + // The data object is built above the call: its definition must set userId. + const before = text.slice(Math.max(0, hit.index - 3000), hit.index); + const definition = [...before.matchAll(/const data = \{[\s\S]*?\};/g)]; + expect(call).toMatch(/create: data\b/); + expect(definition.at(-1)?.[0]).toMatch(/\buserId\b/); + } + } + }); +}); diff --git a/ApiCore-Backend/src/notifications/notification-link.ts b/ApiCore-Backend/src/notifications/notification-link.ts new file mode 100644 index 0000000..9d11616 --- /dev/null +++ b/ApiCore-Backend/src/notifications/notification-link.ts @@ -0,0 +1,18 @@ +/** + * A notification link is a relative app path: it starts with "/", not with + * "//" or "/\", carries no scheme and is at most 300 characters long. Anything + * else is a programming error, never user input (the code that records the + * notification builds the link). + */ +const APP_LINK_PATTERN = /^\/(?![/\\])[A-Za-z0-9\-._~/?=&%]{0,299}$/; + +export function assertAppLink(link: string): void { + if (!APP_LINK_PATTERN.test(link)) { + throw new Error(`Notification link is not a relative app path: ${link}`); + } +} + +/** One key per recipient, so a fan-out repeated for the same event writes nothing new. */ +export function notificationDedupeKey(prefix: string, userId: string): string { + return `${prefix}:${userId}`; +} diff --git a/ApiCore-Backend/src/notifications/notification-preferences.service.spec.ts b/ApiCore-Backend/src/notifications/notification-preferences.service.spec.ts new file mode 100644 index 0000000..f5a1647 --- /dev/null +++ b/ApiCore-Backend/src/notifications/notification-preferences.service.spec.ts @@ -0,0 +1,183 @@ +import { BadRequestException } from '@nestjs/common'; +import { NotificationPreferencesService } from './notification-preferences.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { lockRowForUpdate } from '../prisma/row-lock'; + +jest.mock('../prisma/row-lock', () => ({ + lockRowForUpdate: jest.fn().mockResolvedValue(true), +})); + +const stored = { + userId: 'u1', + taskDueEnabled: true, + digestEnabled: true, + digestTime: '06:30', + quietFrom: '21:00' as string | null, + quietUntil: '06:30' as string | null, + telegramEnabled: false, + telegramCategories: ['TASK_DUE', 'DIGEST'], + telegramDetail: true, + updatedAt: new Date(0), +}; + +describe('NotificationPreferencesService', () => { + let service: NotificationPreferencesService; + let tx: { + notificationPreference: { + upsert: jest.Mock; + findUniqueOrThrow: jest.Mock; + update: jest.Mock; + }; + }; + let prisma: { + notificationPreference: { upsert: jest.Mock }; + $transaction: jest.Mock; + }; + const order: string[] = []; + + beforeEach(() => { + order.length = 0; + (lockRowForUpdate as jest.Mock).mockImplementation(() => { + order.push('lock'); + return Promise.resolve(true); + }); + tx = { + notificationPreference: { + upsert: jest.fn().mockImplementation(() => { + order.push('upsert'); + return Promise.resolve(stored); + }), + findUniqueOrThrow: jest.fn().mockImplementation(() => { + order.push('read'); + return Promise.resolve(stored); + }), + update: jest + .fn() + .mockImplementation(({ data }: { data: object }) => + Promise.resolve({ ...stored, ...data }), + ), + }, + }; + prisma = { + notificationPreference: { + upsert: jest.fn().mockResolvedValue(stored), + }, + $transaction: jest + .fn() + .mockImplementation((work: (t: unknown) => Promise) => + work(tx), + ), + }; + service = new NotificationPreferencesService( + prisma as unknown as PrismaService, + ); + }); + + it('creates the defaults on first read', async () => { + await expect(service.getOrCreate('u1')).resolves.toBe(stored); + expect(prisma.notificationPreference.upsert).toHaveBeenCalledWith({ + where: { userId: 'u1' }, + create: { userId: 'u1' }, + update: {}, + }); + }); + + it('uses the given client for the first read', async () => { + await service.getOrCreate('u1', tx as never); + expect(tx.notificationPreference.upsert).toHaveBeenCalledTimes(1); + expect(prisma.notificationPreference.upsert).not.toHaveBeenCalled(); + }); + + it.each(['04:30', '12:01', '00:00', '23:59'])( + 'rejects %s as digest time with the German message', + async (digestTime) => { + const failure = service.update('u1', { digestTime }); + await expect(failure).rejects.toBeInstanceOf(BadRequestException); + await expect(failure).rejects.toThrow( + 'Bitte eine Uhrzeit zwischen 05:00 und 12:00 wählen.', + ); + expect(tx.notificationPreference.update).not.toHaveBeenCalled(); + }, + ); + + it.each(['05:00', '12:00', '09:15'])( + 'accepts %s as digest time', + async (digestTime) => { + await service.update('u1', { digestTime }); + expect(tx.notificationPreference.update).toHaveBeenCalledWith({ + where: { userId: 'u1' }, + data: expect.objectContaining({ digestTime }) as unknown, + }); + }, + ); + + it('clears quiet hours only as a pair', async () => { + const failure = service.update('u1', { quietFrom: null }); + await expect(failure).rejects.toBeInstanceOf(BadRequestException); + await expect(failure).rejects.toThrow( + 'Bitte Beginn und Ende der Ruhezeit zusammen leeren.', + ); + + await service.update('u1', { quietFrom: null, quietUntil: null }); + expect(tx.notificationPreference.update).toHaveBeenCalledWith({ + where: { userId: 'u1' }, + data: expect.objectContaining({ + quietFrom: null, + quietUntil: null, + }) as unknown, + }); + }); + + it('judges the pair rule on the merged values, not on the dto alone', async () => { + tx.notificationPreference.findUniqueOrThrow.mockResolvedValue({ + ...stored, + quietFrom: null, + quietUntil: null, + }); + + await expect( + service.update('u1', { quietFrom: '22:00' }), + ).rejects.toBeInstanceOf(BadRequestException); + await expect( + service.update('u1', { quietFrom: '22:00', quietUntil: '05:30' }), + ).resolves.toBeDefined(); + }); + + it('update locks the preference row by userId and merges with the stored values read inside the transaction', async () => { + tx.notificationPreference.findUniqueOrThrow.mockImplementation(() => { + order.push('read'); + return Promise.resolve({ ...stored, digestTime: '07:00' }); + }); + + const result = await service.update('u1', { telegramDetail: false }); + + expect(order).toEqual(['upsert', 'lock', 'read']); + expect(lockRowForUpdate).toHaveBeenCalledWith( + tx, + 'NotificationPreference', + 'u1', + ); + expect(tx.notificationPreference.update).toHaveBeenCalledWith({ + where: { userId: 'u1' }, + data: { + taskDueEnabled: true, + digestEnabled: true, + digestTime: '07:00', + quietFrom: '21:00', + quietUntil: '06:30', + telegramCategories: ['TASK_DUE', 'DIGEST'], + telegramDetail: false, + }, + }); + expect(result.telegramDetail).toBe(false); + }); + + it('never writes telegramEnabled', async () => { + await service.update('u1', { telegramCategories: ['SYSTEM'] }); + const [[arg]] = tx.notificationPreference.update.mock.calls as [ + [{ data: Record }], + ]; + expect(arg.data).not.toHaveProperty('telegramEnabled'); + expect(arg.data.telegramCategories).toEqual(['SYSTEM']); + }); +}); diff --git a/ApiCore-Backend/src/notifications/notification-preferences.service.ts b/ApiCore-Backend/src/notifications/notification-preferences.service.ts new file mode 100644 index 0000000..d3e1860 --- /dev/null +++ b/ApiCore-Backend/src/notifications/notification-preferences.service.ts @@ -0,0 +1,101 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { + NotificationCategory, + NotificationPreference, +} from '../../generated/prisma'; +import { DbClient } from '../prisma/db-client'; +import { PrismaService } from '../prisma/prisma.service'; +import { lockRowForUpdate } from '../prisma/row-lock'; +import { UpdateNotificationSettingsDto } from './dto/update-notification-settings.dto'; + +/** The digest time the owner may choose, Europe/Berlin (inclusive). */ +const DIGEST_TIME_MIN = '05:00'; +const DIGEST_TIME_MAX = '12:00'; +const DIGEST_TIME_MESSAGE = + 'Bitte eine Uhrzeit zwischen 05:00 und 12:00 wählen.'; +const QUIET_PAIR_MESSAGE = + 'Bitte Beginn und Ende der Ruhezeit zusammen leeren.'; + +export interface NotificationSettingsView { + taskDueEnabled: boolean; + digestEnabled: boolean; + digestTime: string; + quietFrom: string | null; + quietUntil: string | null; + telegramCategories: NotificationCategory[]; + telegramDetail: boolean; +} + +export function toNotificationSettingsView( + preference: NotificationPreference, +): NotificationSettingsView { + return { + taskDueEnabled: preference.taskDueEnabled, + digestEnabled: preference.digestEnabled, + digestTime: preference.digestTime, + quietFrom: preference.quietFrom, + quietUntil: preference.quietUntil, + telegramCategories: preference.telegramCategories, + telegramDetail: preference.telegramDetail, + }; +} + +/** HH:MM strings of one day compare correctly as text. */ +function assertDigestTime(digestTime: string): void { + if (digestTime < DIGEST_TIME_MIN || digestTime > DIGEST_TIME_MAX) { + throw new BadRequestException(DIGEST_TIME_MESSAGE); + } +} + +/** Per-user notification settings; a missing row means the defaults. */ +@Injectable() +export class NotificationPreferencesService { + constructor(private readonly prisma: PrismaService) {} + + getOrCreate( + userId: string, + db: DbClient = this.prisma, + ): Promise { + return db.notificationPreference.upsert({ + where: { userId }, + create: { userId }, + update: {}, + }); + } + + async update( + userId: string, + dto: UpdateNotificationSettingsDto, + ): Promise { + if (dto.digestTime !== undefined) { + assertDigestTime(dto.digestTime); + } + return this.prisma.$transaction(async (tx) => { + await this.getOrCreate(userId, tx); + await lockRowForUpdate(tx, 'NotificationPreference', userId); + const stored = await tx.notificationPreference.findUniqueOrThrow({ + where: { userId }, + }); + const quietFrom = + dto.quietFrom !== undefined ? dto.quietFrom : stored.quietFrom; + const quietUntil = + dto.quietUntil !== undefined ? dto.quietUntil : stored.quietUntil; + if ((quietFrom === null) !== (quietUntil === null)) { + throw new BadRequestException(QUIET_PAIR_MESSAGE); + } + return tx.notificationPreference.update({ + where: { userId }, + data: { + taskDueEnabled: dto.taskDueEnabled ?? stored.taskDueEnabled, + digestEnabled: dto.digestEnabled ?? stored.digestEnabled, + digestTime: dto.digestTime ?? stored.digestTime, + quietFrom, + quietUntil, + telegramCategories: + dto.telegramCategories ?? stored.telegramCategories, + telegramDetail: dto.telegramDetail ?? stored.telegramDetail, + }, + }); + }); + } +} diff --git a/ApiCore-Backend/src/notifications/notification-settings.controller.ts b/ApiCore-Backend/src/notifications/notification-settings.controller.ts new file mode 100644 index 0000000..069de62 --- /dev/null +++ b/ApiCore-Backend/src/notifications/notification-settings.controller.ts @@ -0,0 +1,42 @@ +import { Body, Controller, Get, Patch } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { AnyAuthenticatedUser } from '../auth/decorators/any-authenticated-user.decorator'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import type { AuthenticatedUser } from '../auth/types/authenticated-user'; +import { UpdateNotificationSettingsDto } from './dto/update-notification-settings.dto'; +import { + NotificationPreferencesService, + NotificationSettingsView, + toNotificationSettingsView, +} from './notification-preferences.service'; + +/** + * The caller's own notification settings: self-scoped like ProfileController + * (no permission applies), @AnyAuthenticatedUser keeps API keys out. + */ +@ApiTags('profile') +@ApiBearerAuth() +@AnyAuthenticatedUser() +@Controller('profile/notification-settings') +export class NotificationSettingsController { + constructor(private readonly preferences: NotificationPreferencesService) {} + + @Get() + async get( + @CurrentUser() actor: AuthenticatedUser, + ): Promise { + return toNotificationSettingsView( + await this.preferences.getOrCreate(actor.id), + ); + } + + @Patch() + async update( + @Body() dto: UpdateNotificationSettingsDto, + @CurrentUser() actor: AuthenticatedUser, + ): Promise { + return toNotificationSettingsView( + await this.preferences.update(actor.id, dto), + ); + } +} diff --git a/ApiCore-Backend/src/notifications/notifications.controller.ts b/ApiCore-Backend/src/notifications/notifications.controller.ts index 9fcd8ee..5062c9e 100644 --- a/ApiCore-Backend/src/notifications/notifications.controller.ts +++ b/ApiCore-Backend/src/notifications/notifications.controller.ts @@ -13,16 +13,21 @@ export class NotificationsController { constructor(private readonly notificationsService: NotificationsService) {} @Get() - findAll( + findOwn( @CurrentUser() actor: AuthenticatedUser, @Query('unreadOnly') unreadOnly?: string, ) { - return this.notificationsService.findAll({ - userId: actor.id, + return this.notificationsService.findOwn(actor.id, { unreadOnly: unreadOnly === 'true', }); } + // Declared before ':id/read' so the fixed path is matched first. + @Post('read-all') + markAllRead(@CurrentUser() actor: AuthenticatedUser) { + return this.notificationsService.markAllRead(actor.id); + } + @Post(':id/read') markRead(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) { return this.notificationsService.markRead(id, actor.id); diff --git a/ApiCore-Backend/src/notifications/notifications.module.ts b/ApiCore-Backend/src/notifications/notifications.module.ts index e311df4..a1974cb 100644 --- a/ApiCore-Backend/src/notifications/notifications.module.ts +++ b/ApiCore-Backend/src/notifications/notifications.module.ts @@ -1,10 +1,12 @@ import { Module } from '@nestjs/common'; +import { NotificationPreferencesService } from './notification-preferences.service'; +import { NotificationSettingsController } from './notification-settings.controller'; import { NotificationsController } from './notifications.controller'; import { NotificationsService } from './notifications.service'; @Module({ - controllers: [NotificationsController], - providers: [NotificationsService], - exports: [NotificationsService], + controllers: [NotificationsController, NotificationSettingsController], + providers: [NotificationsService, NotificationPreferencesService], + exports: [NotificationsService, NotificationPreferencesService], }) export class NotificationsModule {} diff --git a/ApiCore-Backend/src/notifications/notifications.service.spec.ts b/ApiCore-Backend/src/notifications/notifications.service.spec.ts index 4a5449a..b3a1c75 100644 --- a/ApiCore-Backend/src/notifications/notifications.service.spec.ts +++ b/ApiCore-Backend/src/notifications/notifications.service.spec.ts @@ -1,18 +1,30 @@ -import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { NotFoundException } from '@nestjs/common'; import { NotificationsService } from './notifications.service'; +import { assertAppLink } from './notification-link'; import { PrismaService } from '../prisma/prisma.service'; +import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; -type CreateManyArg = { data: { userId: string; message: string }[] }; +type CreateManyArg = { + data: { + userId: string; + message: string; + dedupeKey: string | null; + requiredPermissions: string[]; + category: string; + link: string | null; + }[]; + skipDuplicates: boolean; +}; describe('NotificationsService', () => { let service: NotificationsService; + let held: Set; let prisma: { notification: { - create: jest.Mock; - createMany: jest.Mock; + createManyAndReturn: jest.Mock; findMany: jest.Mock; - findUnique: jest.Mock; - update: jest.Mock; + findFirstOrThrow: jest.Mock; + updateMany: jest.Mock; }; user: { findMany: jest.Mock }; }; @@ -20,177 +32,375 @@ describe('NotificationsService', () => { beforeEach(() => { prisma = { notification: { - create: jest.fn(), - createMany: jest + createManyAndReturn: jest .fn() .mockImplementation(({ data }: CreateManyArg) => - Promise.resolve({ count: data.length }), + Promise.resolve(data.map((row, i) => ({ id: `n${i}`, ...row }))), ), - findMany: jest.fn(), - findUnique: jest.fn(), - update: jest.fn(), + findMany: jest.fn().mockResolvedValue([]), + findFirstOrThrow: jest.fn(), + updateMany: jest.fn(), }, user: { findMany: jest.fn().mockResolvedValue([]) }, }; - service = new NotificationsService(prisma as unknown as PrismaService); - }); - - it('records a notification with the given priority and message', async () => { - const created = { - id: 'n1', - priority: 'KRITISCH', - message: 'Keine Königin gesehen', - }; - prisma.notification.create.mockResolvedValueOnce(created); - - const result = await service.record({ - priority: 'KRITISCH', - message: 'Keine Königin gesehen', - }); - - expect(result).toEqual(created); - expect(prisma.notification.create).toHaveBeenCalledWith({ - data: { priority: 'KRITISCH', message: 'Keine Königin gesehen' }, - }); - }); - - it('records through the transaction client when one is given', async () => { - const tx = { notification: { create: jest.fn() } }; - - await service.record( - { priority: 'HINWEIS', message: 'Aufgabe fällig' }, - tx as never, + held = new Set(); + service = new NotificationsService( + prisma as unknown as PrismaService, + { + getPermissions: jest + .fn() + .mockImplementation(() => Promise.resolve(held)), + } as unknown as PermissionResolverService, ); + }); - expect(tx.notification.create).toHaveBeenCalledTimes(1); - expect(prisma.notification.create).not.toHaveBeenCalled(); + const lastCreate = () => + ( + prisma.notification.createManyAndReturn.mock.calls[0] as [CreateManyArg] + )[0]; + + it('record without userId does not compile', () => { + // @ts-expect-error userId fehlt + void service.record({ priority: 'INFO', category: 'SYSTEM', message: 'm' }); + }); + + describe('record', () => { + it('writes one row for an eligible recipient and returns it', async () => { + prisma.user.findMany.mockResolvedValue([{ id: 'u1' }]); + + const result = await service.record({ + userId: 'u1', + priority: 'KRITISCH', + category: 'SYSTEM', + message: 'Keine Königin gesehen', + dedupeKey: 'k:u1', + }); + + expect(result).toMatchObject({ userId: 'u1', dedupeKey: 'k:u1' }); + expect(lastCreate().skipDuplicates).toBe(true); + expect(lastCreate().data).toEqual([ + { + userId: 'u1', + priority: 'KRITISCH', + category: 'SYSTEM', + message: 'Keine Königin gesehen', + relatedType: null, + relatedId: null, + link: null, + requiredPermissions: [], + dedupeKey: 'k:u1', + }, + ]); + }); + + it('returns null for a recipient without the permission', async () => { + await expect( + service.record({ + userId: 'u1', + priority: 'INFO', + category: 'SYSTEM', + message: 'm', + requiredPermissions: ['colonies:read'], + }), + ).resolves.toBeNull(); + expect(prisma.notification.createManyAndReturn).not.toHaveBeenCalled(); + }); + + it('returns null for a repeated dedupe key', async () => { + prisma.user.findMany.mockResolvedValue([{ id: 'u1' }]); + prisma.notification.createManyAndReturn.mockResolvedValue([]); + + await expect( + service.record({ + userId: 'u1', + priority: 'INFO', + category: 'SYSTEM', + message: 'm', + dedupeKey: 'k:u1', + }), + ).resolves.toBeNull(); + }); + + it('writes through the transaction client when one is given', async () => { + const tx = { + user: { findMany: jest.fn().mockResolvedValue([{ id: 'u1' }]) }, + notification: { + createManyAndReturn: jest.fn().mockResolvedValue([{ id: 'n' }]), + }, + }; + + await service.record( + { userId: 'u1', priority: 'INFO', category: 'SYSTEM', message: 'm' }, + tx as never, + ); + + expect(tx.notification.createManyAndReturn).toHaveBeenCalledTimes(1); + expect(prisma.notification.createManyAndReturn).not.toHaveBeenCalled(); + }); + + it('rejects an unsafe link before writing', async () => { + prisma.user.findMany.mockResolvedValue([{ id: 'u1' }]); + await expect( + service.record({ + userId: 'u1', + priority: 'INFO', + category: 'SYSTEM', + message: 'm', + link: 'https://evil.example', + }), + ).rejects.toThrow(); + expect(prisma.notification.createManyAndReturn).not.toHaveBeenCalled(); + }); }); describe('recordForPermission', () => { - it('notifies every active user whose active role holds the permission', async () => { + const input = { + priority: 'WARNUNG' as const, + category: 'SYSTEM' as const, + message: 'Job „App-Backup“ fehlgeschlagen', + }; + + it('writes one row per active user holding every permission, with a key per recipient', async () => { prisma.user.findMany.mockResolvedValue([{ id: 'u1' }, { id: 'u2' }]); - await expect( - service.recordForPermission('backup:configure', { - priority: 'WARNUNG', - message: 'Job „App-Backup“ fehlgeschlagen', - }), - ).resolves.toBe(2); - - const [[createArg]] = prisma.notification.createMany.mock.calls as [ - [CreateManyArg], - ]; - expect(createArg.data.map((n) => n.userId)).toEqual(['u1', 'u2']); - expect(createArg.data[0]).toEqual({ - userId: 'u1', - priority: 'WARNUNG', - message: 'Job „App-Backup“ fehlgeschlagen', + const rows = await service.recordForPermission('backup:configure', { + ...input, + dedupeKeyPrefix: 'p', }); + + expect(rows).toHaveLength(2); + expect(lastCreate().data.map((r) => [r.userId, r.dedupeKey])).toEqual([ + ['u1', 'p:u1'], + ['u2', 'p:u2'], + ]); }); - it('selects recipients like the permission guard: active user, active role, granted key', async () => { - await service.recordForPermission('backup:configure', { - priority: 'WARNUNG', - message: 'x', - }); + it('selects recipients like the permission guard: active user, active role, every key granted', async () => { + await service.recordForPermission( + ['backup:configure', 'colonies:read'], + input, + ); expect(prisma.user.findMany).toHaveBeenCalledWith({ where: { isActive: true, role: { status: 'ACTIVE', - permissions: { - some: { permission: { key: 'backup:configure' } }, - }, + AND: [ + { + permissions: { + some: { permission: { key: 'backup:configure' } }, + }, + }, + { + permissions: { some: { permission: { key: 'colonies:read' } } }, + }, + ], }, }, select: { id: true }, + orderBy: { id: 'asc' }, }); }); - it('writes nothing and returns 0 when nobody holds the permission', async () => { + it('stores the union of recipient and content permissions', async () => { + prisma.user.findMany.mockResolvedValue([{ id: 'u1' }]); + + await service.recordForPermission('colonies:read', { + ...input, + requiredPermissions: ['inspections:read', 'colonies:read'], + }); + + expect(lastCreate().data[0].requiredPermissions).toEqual([ + 'colonies:read', + 'inspections:read', + ]); + }); + + it('throws for an empty permission list instead of broadcasting', async () => { + await expect(service.recordForPermission([], input)).rejects.toThrow(); + expect(prisma.user.findMany).not.toHaveBeenCalled(); + }); + + it('writes nothing when nobody holds the permission', async () => { + await expect( + service.recordForPermission('backup:configure', input), + ).resolves.toEqual([]); + expect(prisma.notification.createManyAndReturn).not.toHaveBeenCalled(); + }); + + it('a repeated dedupe key writes nothing', async () => { + prisma.user.findMany.mockResolvedValue([{ id: 'u1' }]); + prisma.notification.createManyAndReturn.mockResolvedValue([]); + await expect( service.recordForPermission('backup:configure', { - priority: 'WARNUNG', - message: 'x', + ...input, + dedupeKeyPrefix: 'p', }), - ).resolves.toBe(0); - - expect(prisma.notification.createMany).not.toHaveBeenCalled(); + ).resolves.toEqual([]); }); it('reads and writes through the transaction client when one is given', async () => { const tx = { user: { findMany: jest.fn().mockResolvedValue([{ id: 'u1' }]) }, notification: { - createMany: jest.fn().mockResolvedValue({ count: 1 }), + createManyAndReturn: jest.fn().mockResolvedValue([{ id: 'n1' }]), }, }; await expect( - service.recordForPermission( - 'backup:configure', - { priority: 'WARNUNG', message: 'x' }, - tx as never, - ), - ).resolves.toBe(1); + service.recordForPermission('backup:configure', input, tx as never), + ).resolves.toHaveLength(1); - expect(tx.notification.createMany).toHaveBeenCalledTimes(1); expect(prisma.user.findMany).not.toHaveBeenCalled(); - expect(prisma.notification.createMany).not.toHaveBeenCalled(); + expect(prisma.notification.createManyAndReturn).not.toHaveBeenCalled(); }); }); - it('lists notifications for a user including broadcast notifications', async () => { - await service.findAll({ userId: 'user-1' }); + describe('recordForUsers', () => { + it('skips users missing a required permission', async () => { + prisma.user.findMany.mockResolvedValue([{ id: 'u2' }]); - const [[queryArg]] = prisma.notification.findMany.mock.calls as [ - [{ where: { OR?: { userId: string | null }[] } }], - ]; - expect(queryArg.where.OR).toEqual([{ userId: 'user-1' }, { userId: null }]); - }); + const rows = await service.recordForUsers(['u1', 'u2'], { + priority: 'INFO', + category: 'SUGGESTION', + message: 'm', + requiredPermissions: ['colonies:read'], + }); - it('throws NotFoundException when marking an unknown notification as read', async () => { - prisma.notification.findUnique.mockResolvedValueOnce(null); - await expect(service.markRead('missing', 'user-1')).rejects.toBeInstanceOf( - NotFoundException, - ); - }); + expect(prisma.user.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + id: { in: ['u1', 'u2'] }, + }) as unknown, + }), + ); + expect(rows.map((r) => r.userId)).toEqual(['u2']); + }); - it("throws ForbiddenException when marking another user's notification as read", async () => { - prisma.notification.findUnique.mockResolvedValueOnce({ - id: 'n1', - userId: 'owner', - read: false, + it('does nothing for an empty user list', async () => { + await expect( + service.recordForUsers([], { + priority: 'INFO', + category: 'SYSTEM', + message: 'm', + }), + ).resolves.toEqual([]); + expect(prisma.user.findMany).not.toHaveBeenCalled(); }); - await expect(service.markRead('n1', 'someone-else')).rejects.toBeInstanceOf( - ForbiddenException, - ); }); - it('marks a broadcast notification (no owner) as read for any user', async () => { - prisma.notification.findUnique.mockResolvedValueOnce({ - id: 'n1', - userId: null, - read: false, + describe('reading', () => { + it('lists only the own notifications, newest first, at most 100', async () => { + await service.findOwn('user-1', { unreadOnly: true }); + + expect(prisma.notification.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId: 'user-1', read: false }, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + take: 500, + }), + ); }); - prisma.notification.update.mockResolvedValueOnce({ id: 'n1', read: true }); - const result = await service.markRead('n1', 'any-user'); + it('hides a row whose permissions the user no longer holds (revoked after the write)', async () => { + const row = (id: string, requiredPermissions: string[]) => ({ + id, + priority: 'INFO', + category: 'SYSTEM', + message: 'm', + relatedType: null, + relatedId: null, + link: null, + read: false, + createdAt: new Date(0), + requiredPermissions, + }); + prisma.notification.findMany.mockResolvedValue([ + row('open', []), + row('revoked', ['colonies:read', 'varroa:read']), + row('kept', ['colonies:read']), + ]); + held.add('colonies:read'); - expect(result.read).toBe(true); - }); + const views = await service.findOwn('user-1'); - it("marks the owner's own notification as read", async () => { - prisma.notification.findUnique.mockResolvedValueOnce({ - id: 'n1', - userId: 'user-1', - read: false, + expect(views.map((view) => view.id)).toEqual(['open', 'kept']); + expect(views[0]).not.toHaveProperty('requiredPermissions'); }); - prisma.notification.update.mockResolvedValueOnce({ id: 'n1', read: true }); - const result = await service.markRead('n1', 'user-1'); + it('selects the view fields plus the permission list for filtering, never the dedupe key', async () => { + await service.findOwn('user-1'); - expect(result.read).toBe(true); + const [[arg]] = prisma.notification.findMany.mock.calls as [ + [{ select: Record }], + ]; + expect(Object.keys(arg.select).sort()).toEqual([ + 'category', + 'createdAt', + 'id', + 'link', + 'message', + 'priority', + 'read', + 'relatedId', + 'relatedType', + 'requiredPermissions', + ]); + }); + + it('answers 404 when the notification is foreign or unknown', async () => { + prisma.notification.updateMany.mockResolvedValue({ count: 0 }); + await expect(service.markRead('n1', 'user-1')).rejects.toBeInstanceOf( + NotFoundException, + ); + expect(prisma.notification.updateMany).toHaveBeenCalledWith({ + where: { id: 'n1', userId: 'user-1' }, + data: { read: true }, + }); + }); + + it('marks the own notification as read', async () => { + prisma.notification.updateMany.mockResolvedValue({ count: 1 }); + prisma.notification.findFirstOrThrow.mockResolvedValue({ + id: 'n1', + read: true, + }); + + await expect(service.markRead('n1', 'user-1')).resolves.toMatchObject({ + read: true, + }); + }); + + it('marks only the own unread notifications as read', async () => { + prisma.notification.updateMany.mockResolvedValue({ count: 3 }); + + await expect(service.markAllRead('user-1')).resolves.toEqual({ + updated: 3, + }); + expect(prisma.notification.updateMany).toHaveBeenCalledWith({ + where: { userId: 'user-1', read: false }, + data: { read: true }, + }); + }); + }); +}); + +describe('assertAppLink', () => { + it('accepts a relative app path', () => { + expect(() => assertAppLink('/tasks?status=OPEN&x=1')).not.toThrow(); + }); + + it.each([ + '//evil.example', + 'https://evil.example', + '/\\evil', + 'tasks', + '/a b', + ])('rejects %s', (link) => expect(() => assertAppLink(link)).toThrow()); + + it('rejects a link over 300 characters', () => { + expect(() => assertAppLink(`/${'a'.repeat(300)}`)).toThrow(); }); }); diff --git a/ApiCore-Backend/src/notifications/notifications.service.ts b/ApiCore-Backend/src/notifications/notifications.service.ts index 2a44ed3..ccf6de3 100644 --- a/ApiCore-Backend/src/notifications/notifications.service.ts +++ b/ApiCore-Backend/src/notifications/notifications.service.ts @@ -1,98 +1,269 @@ -import { - ForbiddenException, - Injectable, - NotFoundException, -} from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { DbClient } from '../prisma/db-client'; import { PermissionKey } from '../auth/permissions/permission-catalog'; import { Notification, + NotificationCategory, NotificationPriority, Prisma, } from '../../generated/prisma'; +import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; +import { assertAppLink, notificationDedupeKey } from './notification-link'; export interface CreateNotificationInput { - userId?: string; + userId: string; priority: NotificationPriority; + category: NotificationCategory; message: string; - relatedType?: string; - relatedId?: string; + relatedType?: string | null; + relatedId?: string | null; + link?: string | null; + requiredPermissions?: readonly PermissionKey[]; + dedupeKey?: string | null; } -export interface NotificationFilter { - userId?: string; - unreadOnly?: boolean; +export type FanOutNotificationInput = Omit< + CreateNotificationInput, + 'userId' | 'dedupeKey' +> & { dedupeKeyPrefix?: string }; + +export interface NotificationView { + id: string; + priority: NotificationPriority; + category: NotificationCategory; + message: string; + relatedType: string | null; + relatedId: string | null; + link: string | null; + read: boolean; + createdAt: Date; } +type NotificationContent = Omit; + +interface Target { + userId: string; + dedupeKey: string | null; +} + +const VIEW_SELECT = { + id: true, + priority: true, + category: true, + message: true, + relatedType: true, + relatedId: true, + link: true, + read: true, + createdAt: true, +} satisfies Prisma.NotificationSelect; + +function toView(row: NotificationView): NotificationView { + return { + id: row.id, + priority: row.priority, + category: row.category, + message: row.message, + relatedType: row.relatedType, + relatedId: row.relatedId, + link: row.link, + read: row.read, + createdAt: row.createdAt, + }; +} + +const MAX_LISTED = 100; +/** Rows read before the permission filter, so hidden rows do not empty the list. */ +const MAX_SCANNED = 500; + +function uniqueSorted(keys: readonly PermissionKey[]): PermissionKey[] { + return [...new Set(keys)].sort(); +} + +/** + * Every notification belongs to exactly one user. Rows are written only to + * active users with an active role who hold every permission the content + * needs (the rule the PermissionsGuard applies). The stored + * requiredPermissions are the recipient's and the content's permissions + * together, so a later consumer (e.g. the Telegram sender) can check them + * again. + */ @Injectable() export class NotificationsService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly permissionResolver: PermissionResolverService, + ) {} + /** One recipient. null: repeated dedupe key, or the recipient lacks a right. */ async record( input: CreateNotificationInput, tx?: Prisma.TransactionClient, - ): Promise { + ): Promise { const db: DbClient = tx ?? this.prisma; - return db.notification.create({ data: { ...input } }); + const { userId, dedupeKey, ...content } = input; + const keys = uniqueSorted(content.requiredPermissions ?? []); + const recipients = await this.recipients(db, keys, [userId]); + const [row] = await this.write( + db, + recipients.map((id) => ({ userId: id, dedupeKey: dedupeKey ?? null })), + content, + keys, + ); + return row ?? null; + } + + /** The given users, minus those missing a permission the content needs. */ + async recordForUsers( + userIds: readonly string[], + input: FanOutNotificationInput, + tx?: Prisma.TransactionClient, + ): Promise { + if (userIds.length === 0) { + return []; + } + const keys = uniqueSorted(input.requiredPermissions ?? []); + return this.fanOut(tx ?? this.prisma, keys, input, userIds); + } + + /** Every user holding all of `permission` (and the content's permissions). */ + async recordForPermission( + permission: PermissionKey | readonly PermissionKey[], + input: FanOutNotificationInput, + tx?: Prisma.TransactionClient, + ): Promise { + const recipientKeys = + typeof permission === 'string' ? [permission] : permission; + if (recipientKeys.length === 0) { + // An empty list would match every user: a silent broadcast. + throw new Error('recordForPermission needs at least one permission'); + } + const keys = uniqueSorted([ + ...recipientKeys, + ...(input.requiredPermissions ?? []), + ]); + return this.fanOut(tx ?? this.prisma, keys, input, undefined); } /** - * One notification per user who may act on it: active account, active - * role, permission granted - the same rule the PermissionsGuard applies. - * Returns how many were written. + * The own notifications, newest first, at most 100. A row whose + * requiredPermissions the user no longer holds (role changed after the + * write) stays hidden. */ - async recordForPermission( - permission: PermissionKey, - input: Omit, - tx?: Prisma.TransactionClient, - ): Promise { - const db: DbClient = tx ?? this.prisma; - const recipients = await db.user.findMany({ + async findOwn( + userId: string, + options: { unreadOnly?: boolean } = {}, + ): Promise { + const rows = await this.prisma.notification.findMany({ + where: { userId, ...(options.unreadOnly ? { read: false } : {}) }, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + take: MAX_SCANNED, + select: { ...VIEW_SELECT, requiredPermissions: true }, + }); + const held = await this.permissionResolver.getPermissions(userId); + return rows + .filter((row) => row.requiredPermissions.every((key) => held.has(key))) + .slice(0, MAX_LISTED) + .map(toView); + } + + /** One statement; a foreign or unknown id matches nothing and answers 404. */ + async markRead(id: string, userId: string): Promise { + const { count } = await this.prisma.notification.updateMany({ + where: { id, userId }, + data: { read: true }, + }); + if (count === 0) { + throw new NotFoundException('Notification not found'); + } + return this.prisma.notification.findFirstOrThrow({ + where: { id, userId }, + select: VIEW_SELECT, + }); + } + + async markAllRead(userId: string): Promise<{ updated: number }> { + const { count } = await this.prisma.notification.updateMany({ + where: { userId, read: false }, + data: { read: true }, + }); + return { updated: count }; + } + + private async fanOut( + db: DbClient, + keys: readonly PermissionKey[], + input: FanOutNotificationInput, + userIds: readonly string[] | undefined, + ): Promise { + const { dedupeKeyPrefix, ...content } = input; + const recipients = await this.recipients(db, keys, userIds); + return this.write( + db, + recipients.map((userId) => ({ + userId, + dedupeKey: dedupeKeyPrefix + ? notificationDedupeKey(dedupeKeyPrefix, userId) + : null, + })), + content, + keys, + ); + } + + /** Active user, active role, every key granted - in one query. */ + private async recipients( + db: DbClient, + keys: readonly PermissionKey[], + userIds: readonly string[] | undefined, + ): Promise { + const users = await db.user.findMany({ where: { + ...(userIds ? { id: { in: [...userIds] } } : {}), isActive: true, role: { status: 'ACTIVE', - permissions: { some: { permission: { key: permission } } }, + AND: keys.map((key) => ({ + permissions: { some: { permission: { key } } }, + })), }, }, select: { id: true }, + orderBy: { id: 'asc' }, }); - if (recipients.length === 0) { - return 0; - } - const { count } = await db.notification.createMany({ - data: recipients.map((recipient) => ({ ...input, userId: recipient.id })), - }); - return count; + return users.map((user) => user.id); } - async findAll(filter: NotificationFilter = {}): Promise { - return this.prisma.notification.findMany({ - where: { - OR: filter.userId - ? [{ userId: filter.userId }, { userId: null }] - : undefined, - read: filter.unreadOnly ? false : undefined, - }, - orderBy: { createdAt: 'desc' }, - take: 100, - }); - } - - async markRead(id: string, actingUserId: string): Promise { - const notification = await this.prisma.notification.findUnique({ - where: { id }, - }); - if (!notification) { - throw new NotFoundException('Notification not found'); + private async write( + db: DbClient, + targets: readonly Target[], + content: NotificationContent, + recipientKeys: readonly PermissionKey[], + ): Promise { + if (content.link) { + assertAppLink(content.link); } - if (notification.userId !== null && notification.userId !== actingUserId) { - throw new ForbiddenException("Cannot modify another user's notification"); + if (targets.length === 0) { + return []; } - return this.prisma.notification.update({ - where: { id }, - data: { read: true }, + const requiredPermissions = uniqueSorted([ + ...recipientKeys, + ...(content.requiredPermissions ?? []), + ]); + return db.notification.createManyAndReturn({ + data: targets.map(({ userId, dedupeKey }) => ({ + userId, + priority: content.priority, + category: content.category, + message: content.message, + relatedType: content.relatedType ?? null, + relatedId: content.relatedId ?? null, + link: content.link ?? null, + requiredPermissions, + dedupeKey, + })), + skipDuplicates: true, }); } } diff --git a/ApiCore-Backend/src/queens/queens.service.spec.ts b/ApiCore-Backend/src/queens/queens.service.spec.ts index 62d84a5..015b394 100644 --- a/ApiCore-Backend/src/queens/queens.service.spec.ts +++ b/ApiCore-Backend/src/queens/queens.service.spec.ts @@ -7,12 +7,17 @@ import { QueensService } from './queens.service'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; import { WebhooksService } from '../webhooks/webhooks.service'; -import { CalendarSyncService } from '../calendar-sync/calendar-sync.service'; +import { DomainEventsService } from '../domain-events/domain-events.service'; describe('QueensService', () => { let service: QueensService; let tx: { - queen: { update: jest.Mock }; + $queryRaw: jest.Mock; + queen: { + update: jest.Mock; + findUnique: jest.Mock; + findUniqueOrThrow: jest.Mock; + }; queenColonyHistory: { updateMany: jest.Mock; create: jest.Mock }; colony: { findUnique: jest.Mock; update: jest.Mock }; }; @@ -29,13 +34,20 @@ describe('QueensService', () => { $transaction: jest.Mock; }; let auditLog: { record: jest.Mock }; + let webhooks: { dispatch: jest.Mock }; + let events: { record: jest.Mock }; const queen = { id: 'queen-1', code: 'Q-001', status: 'ACTIVE' }; const colony = { id: 'colony-1', currentQueenId: null }; beforeEach(() => { tx = { - queen: { update: jest.fn().mockResolvedValue(queen) }, + $queryRaw: jest.fn().mockResolvedValue([]), + queen: { + update: jest.fn().mockResolvedValue(queen), + findUnique: jest.fn(), + findUniqueOrThrow: jest.fn().mockResolvedValue(queen), + }, queenColonyHistory: { updateMany: jest.fn(), create: jest.fn() }, colony: { findUnique: jest.fn().mockResolvedValue(null), @@ -57,13 +69,17 @@ describe('QueensService', () => { ), }; auditLog = { record: jest.fn().mockResolvedValue(undefined) }; - const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) }; - const calendarSync = { enqueue: jest.fn(), cancel: jest.fn() }; + webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) }; + events = { record: jest.fn().mockResolvedValue(undefined) }; + // The locked re-read sees what the pre-read saw unless a test says otherwise. + tx.queen.findUnique.mockImplementation( + (args: unknown) => prisma.queen.findUnique(args) as Promise, + ); service = new QueensService( prisma as unknown as PrismaService, auditLog as unknown as AuditLogService, webhooks as unknown as WebhooksService, - calendarSync as unknown as CalendarSyncService, + events as unknown as DomainEventsService, ); }); @@ -74,9 +90,18 @@ describe('QueensService', () => { ); }); + // Inside the transaction: lookup by id is the target colony, by + // currentQueenId the colony the queen leaves. + function mockColonies(target: unknown, previous: unknown = null): void { + const lookup = (args: { where: { id?: string } }) => + Promise.resolve(args.where.id === undefined ? previous : target); + prisma.colony.findUnique.mockImplementation(lookup); + tx.colony.findUnique.mockImplementation(lookup); + } + it('assignColony throws NotFoundException when the colony is missing', async () => { prisma.queen.findUnique.mockResolvedValue(queen); - prisma.colony.findUnique.mockResolvedValue(null); + mockColonies(null); await expect( service.assignColony('queen-1', { colonyId: 'missing' }, 'user-1'), ).rejects.toBeInstanceOf(NotFoundException); @@ -90,15 +115,13 @@ describe('QueensService', () => { await expect( service.assignColony('queen-1', { colonyId: 'colony-1' }, 'user-1'), ).rejects.toBeInstanceOf(ConflictException); - expect(prisma.colony.findUnique).not.toHaveBeenCalled(); + expect(tx.colony.update).not.toHaveBeenCalled(); + expect(events.record).not.toHaveBeenCalled(); }); it('assignColony closes the previous queen history, sets currentQueenId, and opens a new history row', async () => { prisma.queen.findUnique.mockResolvedValue(queen); - prisma.colony.findUnique.mockResolvedValue({ - ...colony, - currentQueenId: 'queen-old', - }); + mockColonies({ ...colony, status: 'ACTIVE', currentQueenId: 'queen-old' }); tx.colony.update.mockResolvedValue({}); tx.queenColonyHistory.create.mockResolvedValue({ id: 'history-1' }); @@ -122,6 +145,57 @@ describe('QueensService', () => { expect(historyArg.data.colonyId).toBe('colony-1'); }); + it('assignColony records QueenAssigned with previousColonyId and calls neither webhooks.dispatch nor calendarSync', async () => { + prisma.queen.findUnique.mockResolvedValue(queen); + mockColonies( + { ...colony, status: 'ACTIVE' }, + { id: 'colony-prev', status: 'ACTIVE' }, + ); + tx.colony.update.mockResolvedValue({}); + tx.queenColonyHistory.create.mockResolvedValue({ id: 'history-1' }); + + await service.assignColony('queen-1', { colonyId: 'colony-1' }, 'user-1'); + + // Target and previous colony in one statement, then the queen. + expect(tx.$queryRaw).toHaveBeenCalledTimes(2); + expect(events.record).toHaveBeenCalledTimes(1); + expect(events.record).toHaveBeenCalledWith( + tx, + expect.objectContaining({ + type: 'QueenAssigned', + aggregateType: 'Queen', + aggregateId: 'queen-1', + actorUserId: 'user-1', + payload: { + colonyId: 'colony-1', + historyId: 'history-1', + assignedOn: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/) as string, + previousColonyId: 'colony-prev', + }, + }), + ); + expect(auditLog.record).toHaveBeenCalledWith(expect.anything(), tx); + expect(webhooks.dispatch).not.toHaveBeenCalled(); + }); + + it('assignColony answers 409 when the queen was moved by someone else after the pre-read', async () => { + prisma.queen.findUnique.mockResolvedValue(queen); + prisma.colony.findUnique.mockResolvedValue(null); + tx.colony.findUnique.mockImplementation( + (args: { where: { id?: string } }) => + Promise.resolve( + args.where.id === undefined + ? { id: 'colony-other', status: 'ACTIVE' } + : { ...colony, status: 'ACTIVE' }, + ), + ); + + await expect( + service.assignColony('queen-1', { colonyId: 'colony-1' }, 'user-1'), + ).rejects.toBeInstanceOf(ConflictException); + expect(events.record).not.toHaveBeenCalled(); + }); + it('addEvaluation creates a queen evaluation and records an audit log entry', async () => { prisma.queen.findUnique.mockResolvedValue(queen); const created = { id: 'eval-1', queenId: 'queen-1', colonyStrength: 4 }; diff --git a/ApiCore-Backend/src/queens/queens.service.ts b/ApiCore-Backend/src/queens/queens.service.ts index 663aa26..148c0e0 100644 --- a/ApiCore-Backend/src/queens/queens.service.ts +++ b/ApiCore-Backend/src/queens/queens.service.ts @@ -7,7 +7,9 @@ import { import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; import { WebhooksService } from '../webhooks/webhooks.service'; -import { CalendarSyncService } from '../calendar-sync/calendar-sync.service'; +import { DomainEventsService } from '../domain-events/domain-events.service'; +import { utcDay } from '../domain-events/domain-event-types'; +import { lockRowForUpdate, lockRowsForUpdate } from '../prisma/row-lock'; import { Queen, QueenStatus } from '../../generated/prisma'; import { CreateQueenDto } from './dto/create-queen.dto'; import { UpdateQueenDto } from './dto/update-queen.dto'; @@ -64,7 +66,7 @@ export class QueensService { private readonly prisma: PrismaService, private readonly auditLog: AuditLogService, private readonly webhooks: WebhooksService, - private readonly calendarSync: CalendarSyncService, + private readonly events: DomainEventsService, ) {} async findAll(filter: QueenFilter = {}): Promise { @@ -158,28 +160,55 @@ export class QueensService { dto: AssignColonyDto, actingUserId: string, ): Promise { - const queen = await this.findById(id); - if (QUEEN_NO_LONGER_AVAILABLE_STATUSES.includes(queen.status)) { - throw new ConflictException( - 'Die Königin ist nicht mehr verfügbar für eine Zuweisung - korrigiere zuerst ihren Status, falls dies ein Versehen war', - ); - } - const colony = await this.prisma.colony.findUnique({ - where: { id: dto.colonyId }, + await this.findById(id); + // Only to know which colony to lock; the transaction re-reads it. + const preRead = await this.prisma.colony.findUnique({ + where: { currentQueenId: id }, + select: { id: true }, }); - if (!colony) { - throw new NotFoundException('Colony not found'); - } - if (!isLivingColonyStatus(colony.status)) { - throw new ConflictException( - `Das Volk besteht nicht mehr (Status: ${colony.status}) und kann keine Königin aufnehmen`, - ); - } - if (colony.currentQueenId === queen.id) { - throw new ConflictException('Die Königin sitzt bereits in diesem Volk'); - } + return this.prisma.$transaction(async (tx) => { + // Colonies first (one statement, ascending ids), then the queen: the + // order every writer of both uses. + await lockRowsForUpdate(tx, 'Colony', [ + dto.colonyId, + ...(preRead ? [preRead.id] : []), + ]); + await lockRowForUpdate(tx, 'Queen', id); + const queen = await tx.queen.findUnique({ where: { id } }); + if (!queen) { + throw new NotFoundException('Queen not found'); + } + if (QUEEN_NO_LONGER_AVAILABLE_STATUSES.includes(queen.status)) { + throw new ConflictException( + 'Die Königin ist nicht mehr verfügbar für eine Zuweisung - korrigiere zuerst ihren Status, falls dies ein Versehen war', + ); + } + const colony = await tx.colony.findUnique({ + where: { id: dto.colonyId }, + }); + if (!colony) { + throw new NotFoundException('Colony not found'); + } + if (!isLivingColonyStatus(colony.status)) { + throw new ConflictException( + `Das Volk besteht nicht mehr (Status: ${colony.status}) und kann keine Königin aufnehmen`, + ); + } + if (colony.currentQueenId === queen.id) { + throw new ConflictException('Die Königin sitzt bereits in diesem Volk'); + } + // The colony the queen leaves must let go of her - currentQueenId is + // unique, and a stale pointer would both block this assignment and + // keep showing her in a colony she is no longer in. + const previousColony = await tx.colony.findUnique({ + where: { currentQueenId: queen.id }, + }); + if ((previousColony?.id ?? null) !== (preRead?.id ?? null)) { + throw new ConflictException( + 'Die Königin wurde gerade umgesetzt - bitte erneut versuchen', + ); + } - const history = await this.prisma.$transaction(async (tx) => { const now = new Date(); if (colony.currentQueenId) { await tx.queenColonyHistory.updateMany({ @@ -191,12 +220,6 @@ export class QueensService { data: { endDate: now }, }); } - // The colony the queen leaves must let go of her - currentQueenId is - // unique, and a stale pointer would both block this assignment and - // keep showing her in a colony she is no longer in. - const previousColony = await tx.colony.findUnique({ - where: { currentQueenId: queen.id }, - }); if (previousColony) { await tx.colony.update({ where: { id: previousColony.id }, @@ -221,29 +244,39 @@ export class QueensService { status: colony.status === 'QUEENLESS' ? 'ACTIVE' : undefined, }, }); - return tx.queenColonyHistory.create({ + const history = await tx.queenColonyHistory.create({ data: { queenId: queen.id, colonyId: colony.id, - startDate: new Date(), + startDate: now, notes: dto.notes, }, }); + await this.auditLog.record( + { + entityType: 'Queen', + entityId: queen.id, + action: 'UPDATE', + userId: actingUserId, + beforeData: queen, + afterData: { ...queen, assignedColonyId: colony.id }, + }, + tx, + ); + await this.events.record(tx, { + type: 'QueenAssigned', + aggregateType: 'Queen', + aggregateId: queen.id, + actorUserId: actingUserId, + payload: { + colonyId: colony.id, + historyId: history.id, + assignedOn: utcDay(now), + previousColonyId: previousColony?.id ?? null, + }, + }); + return tx.queen.findUniqueOrThrow({ where: { id } }); }); - - await this.auditLog.record({ - entityType: 'Queen', - entityId: queen.id, - action: 'UPDATE', - userId: actingUserId, - beforeData: queen, - afterData: { ...queen, assignedColonyId: colony.id }, - }); - - const updated = await this.findById(id); - await this.webhooks.dispatch('queen.replaced', updated); - await this.calendarSync.enqueue('QUEEN_ASSIGNMENT', history.id); - return updated; } async getHistory(id: string) { diff --git a/ApiCore-Backend/src/suggestions/dto/accept-suggestion.dto.ts b/ApiCore-Backend/src/suggestions/dto/accept-suggestion.dto.ts new file mode 100644 index 0000000..6e17c78 --- /dev/null +++ b/ApiCore-Backend/src/suggestions/dto/accept-suggestion.dto.ts @@ -0,0 +1,31 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsOptional, + IsString, + Matches, + MaxLength, + MinLength, +} from 'class-validator'; + +export class AcceptSuggestionDto { + @ApiPropertyOptional({ + nullable: true, + description: 'JJJJ-MM-TT; bei einem Plan der Tag des ersten Schritts.', + example: '2026-05-12', + }) + @IsOptional() + @Matches(/^\d{4}-\d{2}-\d{2}$/, { + message: 'Bitte ein Datum im Format JJJJ-MM-TT angeben.', + }) + dueDate?: string | null; + + // User ids are cuids, but no other DTO in the codebase checks their format + // (the existence and activity check in the service is the real validation), + // so only the length is bounded here. + @ApiPropertyOptional({ nullable: true }) + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(64) + assignedUserId?: string | null; +} diff --git a/ApiCore-Backend/src/suggestions/dto/dismiss-suggestion.dto.ts b/ApiCore-Backend/src/suggestions/dto/dismiss-suggestion.dto.ts new file mode 100644 index 0000000..4263140 --- /dev/null +++ b/ApiCore-Backend/src/suggestions/dto/dismiss-suggestion.dto.ts @@ -0,0 +1,10 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, MaxLength } from 'class-validator'; + +export class DismissSuggestionDto { + @ApiPropertyOptional({ maxLength: 500 }) + @IsOptional() + @IsString() + @MaxLength(500, { message: 'Die Notiz darf höchstens 500 Zeichen haben.' }) + note?: string; +} diff --git a/ApiCore-Backend/src/suggestions/dto/list-suggestions.query.ts b/ApiCore-Backend/src/suggestions/dto/list-suggestions.query.ts new file mode 100644 index 0000000..a588bae --- /dev/null +++ b/ApiCore-Backend/src/suggestions/dto/list-suggestions.query.ts @@ -0,0 +1,60 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + IsEnum, + IsInt, + IsOptional, + IsString, + Max, + MaxLength, + Min, +} from 'class-validator'; +import { SuggestionStatus } from '../../../generated/prisma'; + +export const DEFAULT_LIST_LIMIT = 200; +export const MAX_LIST_LIMIT = 500; + +export class ListSuggestionsQuery { + @ApiPropertyOptional({ enum: SuggestionStatus, default: 'OPEN' }) + @IsOptional() + @IsEnum(SuggestionStatus, { message: 'Bitte einen gültigen Status wählen.' }) + status?: SuggestionStatus; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(64) + colonyId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(64) + apiaryId?: string; + + @ApiPropertyOptional({ + description: `Einträge je Abruf, 1 bis ${MAX_LIST_LIMIT}`, + default: DEFAULT_LIST_LIMIT, + minimum: 1, + maximum: MAX_LIST_LIMIT, + }) + @IsOptional() + @Type(() => Number) + @IsInt({ message: 'Das Limit muss eine ganze Zahl sein.' }) + @Min(1, { message: 'Das Limit muss mindestens 1 sein.' }) + @Max(MAX_LIST_LIMIT, { + message: `Das Limit darf höchstens ${MAX_LIST_LIMIT} sein.`, + }) + limit?: number; + + @ApiPropertyOptional({ + description: 'So viele Einträge der sortierten Liste überspringen', + default: 0, + minimum: 0, + }) + @IsOptional() + @Type(() => Number) + @IsInt({ message: 'Der Versatz muss eine ganze Zahl sein.' }) + @Min(0, { message: 'Der Versatz darf nicht negativ sein.' }) + offset?: number; +} diff --git a/ApiCore-Backend/src/suggestions/dto/suggestion-ids.dto.ts b/ApiCore-Backend/src/suggestions/dto/suggestion-ids.dto.ts new file mode 100644 index 0000000..18b9393 --- /dev/null +++ b/ApiCore-Backend/src/suggestions/dto/suggestion-ids.dto.ts @@ -0,0 +1,20 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + ArrayMaxSize, + ArrayMinSize, + ArrayUnique, + IsArray, + IsString, + MaxLength, +} from 'class-validator'; + +export class SuggestionIdsDto { + @ApiProperty({ type: [String], minItems: 1, maxItems: 50 }) + @IsArray({ message: 'Bitte eine Liste von Vorschlägen angeben.' }) + @ArrayMinSize(1, { message: 'Bitte mindestens einen Vorschlag wählen.' }) + @ArrayMaxSize(50, { message: 'Bitte höchstens 50 Vorschläge wählen.' }) + @ArrayUnique({ message: 'Jeder Vorschlag darf nur einmal vorkommen.' }) + @IsString({ each: true }) + @MaxLength(64, { each: true }) + ids!: string[]; +} diff --git a/ApiCore-Backend/src/suggestions/dto/suggestion-response.dto.ts b/ApiCore-Backend/src/suggestions/dto/suggestion-response.dto.ts new file mode 100644 index 0000000..2e5d4e9 --- /dev/null +++ b/ApiCore-Backend/src/suggestions/dto/suggestion-response.dto.ts @@ -0,0 +1,79 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + AutomationTriggerType, + SuggestionIntent, + SuggestionMode, + SuggestionSource, + SuggestionStatus, + TaskPriority, + TaskRelatedType, +} from '../../../generated/prisma'; +import type { + SuggestionSummary, + SuggestionView, +} from '../suggestion-views.service'; + +class PlanStepDto { + @ApiProperty() step!: string; + @ApiProperty() offsetDays!: number; + @ApiProperty({ required: false }) windowDays?: number; +} + +class CleanupDto { + @ApiProperty({ type: [String] }) taskIds!: string[]; + @ApiProperty({ nullable: true, type: String }) queenId!: string | null; +} + +/** A suggestion as the app reads it; no evidence, keys or permissions. */ +export class SuggestionViewDto implements SuggestionView { + @ApiProperty() id!: string; + @ApiProperty({ enum: AutomationTriggerType }) ruleKey!: AutomationTriggerType; + @ApiProperty({ enum: SuggestionSource }) source!: SuggestionSource; + @ApiProperty({ enum: SuggestionMode }) mode!: SuggestionMode; + @ApiProperty({ enum: SuggestionIntent }) intent!: SuggestionIntent; + @ApiProperty({ enum: SuggestionStatus }) status!: SuggestionStatus; + @ApiProperty({ enum: TaskPriority }) priority!: TaskPriority; + @ApiProperty({ enum: TaskRelatedType }) relatedType!: TaskRelatedType; + @ApiProperty({ nullable: true, type: String }) relatedId!: string | null; + @ApiProperty({ nullable: true, type: String }) colonyId!: string | null; + @ApiProperty({ nullable: true, type: String }) colonyCode!: string | null; + @ApiProperty({ nullable: true, type: String }) apiaryId!: string | null; + @ApiProperty({ nullable: true, type: String }) apiaryName!: string | null; + @ApiProperty() title!: string; + @ApiProperty({ type: [String] }) reasons!: string[]; + @ApiProperty({ nullable: true, type: String, description: 'JJJJ-MM-TT' }) + dueDate!: string | null; + @ApiProperty({ nullable: true, type: String, description: 'JJJJ-MM-TT' }) + expiresAt!: string | null; + @ApiProperty({ nullable: true, type: [PlanStepDto] }) + plan!: PlanStepDto[] | null; + @ApiProperty({ nullable: true, type: String }) decidedAt!: string | null; + @ApiProperty({ nullable: true, type: String }) decisionNote!: string | null; + @ApiProperty() createdAt!: string; + @ApiProperty({ nullable: true, type: CleanupDto, description: 'Nur CLEANUP' }) + cleanup!: CleanupDto | null; +} + +export class SuggestionSummaryDto implements SuggestionSummary { + @ApiProperty() open!: number; + @ApiProperty({ + type: 'object', + additionalProperties: { type: 'number' }, + example: { LOW: 0, NORMAL: 2, HIGH: 1, URGENT: 0 }, + }) + byPriority!: Record; +} + +export class AcceptSuggestionResponseDto { + @ApiProperty({ type: SuggestionViewDto }) suggestion!: SuggestionViewDto; + @ApiProperty({ + type: 'array', + items: { type: 'object' }, + description: 'Die neuen Aufgaben, in der Form von GET /tasks.', + }) + tasks!: unknown[]; +} + +export class BulkDismissResponseDto { + @ApiProperty() dismissed!: number; +} diff --git a/ApiCore-Backend/src/suggestions/suggestion-views.service.spec.ts b/ApiCore-Backend/src/suggestions/suggestion-views.service.spec.ts new file mode 100644 index 0000000..c0dfea7 --- /dev/null +++ b/ApiCore-Backend/src/suggestions/suggestion-views.service.spec.ts @@ -0,0 +1,143 @@ +import type { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; +import type { PrismaService } from '../prisma/prisma.service'; +import { SuggestionViewsService } from './suggestion-views.service'; + +const NOW = new Date('2026-05-05T08:00:00.000Z'); + +function row(id: string, colonyId: string) { + return { + id, + ruleKey: 'INSPECTION_INTERVAL', + source: 'RULE', + mode: 'DO', + intent: 'INSPECT', + status: 'OPEN', + priority: 'NORMAL', + relatedType: 'COLONY', + relatedId: colonyId, + colonyId, + colony: { code: 'B3', apiaryId: 'api_1', apiary: { name: 'Hang' } }, + reasonCode: 'UNKNOWN_CODE', + evidence: { colonyId }, + requiredPermission: 'inspections:read', + dueDate: new Date('2026-05-07T00:00:00.000Z'), + expiresAt: null, + plan: null, + decidedAt: null, + decisionNote: null, + createdAt: NOW, + }; +} + +function build(rows: ReturnType[]) { + const suggestionFindMany = jest.fn().mockResolvedValue(rows); + const colonyFindMany = jest + .fn() + .mockResolvedValue([{ id: 'col_1', code: 'B3' }]); + const prisma = { + suggestion: { findMany: suggestionFindMany, groupBy: jest.fn() }, + colony: { findMany: colonyFindMany }, + queen: { findMany: jest.fn().mockResolvedValue([]) }, + treatment: { findMany: jest.fn().mockResolvedValue([]) }, + apiary: { findMany: jest.fn().mockResolvedValue([]) }, + stockItem: { findMany: jest.fn().mockResolvedValue([]) }, + automationRule: { findMany: jest.fn().mockResolvedValue([]) }, + $queryRaw: jest.fn().mockResolvedValue([{ cutoff: NOW }]), + }; + const resolver = { + getPermissions: jest.fn().mockResolvedValue(new Set(['inspections:read'])), + }; + const service = new SuggestionViewsService( + prisma as unknown as PrismaService, + resolver as unknown as PermissionResolverService, + ); + return { service, prisma, suggestionFindMany, colonyFindMany }; +} + +describe('SuggestionViewsService', () => { + it('filters by requiredPermission in the query, not after loading', async () => { + const { service, suggestionFindMany } = build([]); + + await service.list('user_1', {}); + + const [args] = suggestionFindMany.mock.calls[0] as [ + { where: { AND: unknown[] } }, + ]; + expect(args.where.AND).toContainEqual({ + OR: [ + { requiredPermission: null }, + { requiredPermission: { in: ['inspections:read'] } }, + ], + }); + expect(service.visibleWhere(new Set(['a:read']))).toEqual({ + OR: [ + { requiredPermission: null }, + { requiredPermission: { in: ['a:read'] } }, + ], + }); + }); + + it('renders title and reasons with one lookup for all rows', async () => { + const { service, colonyFindMany } = build([ + row('s1', 'col_1'), + row('s2', 'col_1'), + row('s3', 'col_1'), + ]); + + const views = await service.list('user_1', {}); + + expect(views.map((view) => view.id)).toEqual(['s1', 's2', 's3']); + expect(views[0]).toMatchObject({ + title: 'Durchsicht fällig', + colonyCode: 'B3', + apiaryId: 'api_1', + apiaryName: 'Hang', + dueDate: '2026-05-07', + cleanup: null, + }); + expect(colonyFindMany).toHaveBeenCalledTimes(1); + }); + + it('lists a dismissed suggestion only within 30 days of its decision', async () => { + const { service, suggestionFindMany, prisma } = build([]); + + await service.list('user_1', { status: 'DISMISSED' }); + + expect(prisma.$queryRaw).toHaveBeenCalledTimes(1); + const [dismissedArgs] = suggestionFindMany.mock.calls[0] as [ + { where: unknown }, + ]; + expect(JSON.stringify(dismissedArgs.where)).toContain( + '"decidedAt":{"gte":"2026-05-05T08:00:00.000Z"}', + ); + }); + + it('reads the cleanup tasks from evidence.openTaskIds', async () => { + const cleanup = { + ...row('s9', 'col_1'), + intent: 'CLEANUP', + evidence: { colonyId: 'col_1', openTaskIds: ['t1', 't2'], queenId: 'q1' }, + }; + const { service } = build([cleanup]); + + const [view] = await service.list('user_1', {}); + + expect(view.cleanup).toEqual({ taskIds: ['t1', 't2'], queenId: 'q1' }); + }); + + it('passes limit (default 200) and offset to the query', async () => { + const { service, suggestionFindMany } = build([]); + + await service.list('user_1', {}); + await service.list('user_1', { limit: 5, offset: 10 }); + + expect((suggestionFindMany.mock.calls[0] as unknown[])[0]).toMatchObject({ + take: 200, + skip: 0, + }); + expect((suggestionFindMany.mock.calls[1] as unknown[])[0]).toMatchObject({ + take: 5, + skip: 10, + }); + }); +}); diff --git a/ApiCore-Backend/src/suggestions/suggestion-views.service.ts b/ApiCore-Backend/src/suggestions/suggestion-views.service.ts new file mode 100644 index 0000000..31f27f0 --- /dev/null +++ b/ApiCore-Backend/src/suggestions/suggestion-views.service.ts @@ -0,0 +1,268 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { + Prisma, + type AutomationTriggerType, + type Suggestion, + type SuggestionIntent, + type SuggestionMode, + type SuggestionSource, + type SuggestionStatus, + type TaskPriority, + type TaskRelatedType, +} from '../../generated/prisma'; +import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; +import type { PlanStepConfig } from '../automation/rule-config'; +import { berlinToday } from '../common/dates'; +import { utcDay } from '../domain-events/domain-event-types'; +import { PrismaService } from '../prisma/prisma.service'; +import { + DEFAULT_LIST_LIMIT, + type ListSuggestionsQuery, +} from './dto/list-suggestions.query'; +import { buildEvidenceLookup } from './evidence-lookup'; +import { planOf } from './suggestion-plan'; +import { renderSuggestion } from './suggestion-texts'; +import type { SuggestionEvidence } from './suggestion.types'; + +export interface SuggestionView { + id: string; + ruleKey: AutomationTriggerType; + source: SuggestionSource; + mode: SuggestionMode; + intent: SuggestionIntent; + status: SuggestionStatus; + priority: TaskPriority; + relatedType: TaskRelatedType; + relatedId: string | null; + colonyId: string | null; + colonyCode: string | null; + apiaryId: string | null; + apiaryName: string | null; + title: string; + reasons: string[]; + dueDate: string | null; + expiresAt: string | null; + plan: PlanStepConfig[] | null; + decidedAt: string | null; + decisionNote: string | null; + createdAt: string; + /** Only for CLEANUP suggestions. */ + cleanup: { taskIds: string[]; queenId: string | null } | null; +} + +export interface SuggestionSummary { + open: number; + byPriority: Record; +} + +const NOT_FOUND_MESSAGE = 'Der Vorschlag wurde nicht gefunden.'; +/** A dismissed suggestion stays listed (and can be reopened) this long. */ +const DISMISSED_LISTED_DAYS = 30; +const PRIORITIES: readonly TaskPriority[] = ['LOW', 'NORMAL', 'HIGH', 'URGENT']; + +const LIST_INCLUDE = { + colony: { + select: { + code: true, + apiaryId: true, + apiary: { select: { name: true } }, + }, + }, +} satisfies Prisma.SuggestionInclude; + +type SuggestionRow = Prisma.SuggestionGetPayload<{ + include: typeof LIST_INCLUDE; +}>; + +function cleanupOf( + row: Pick, +): SuggestionView['cleanup'] { + if (row.intent !== 'CLEANUP') { + return null; + } + const { openTaskIds, queenId } = row.evidence as SuggestionEvidence; + return { + taskIds: Array.isArray(openTaskIds) + ? openTaskIds.filter((id): id is string => typeof id === 'string') + : [], + queenId: typeof queenId === 'string' ? queenId : null, + }; +} + +/** + * The read side of the suggestions: lists and counts only what the user may + * see (the filter by `requiredPermission` is part of the query), with title + * and reasons rendered from the stored code and evidence. + */ +@Injectable() +export class SuggestionViewsService { + constructor( + private readonly prisma: PrismaService, + private readonly permissions: PermissionResolverService, + ) {} + + /** Suggestions without a required permission, or one the user holds. */ + visibleWhere(permissions: ReadonlySet): Prisma.SuggestionWhereInput { + return { + OR: [ + { requiredPermission: null }, + { requiredPermission: { in: [...permissions] } }, + ], + }; + } + + async list( + userId: string, + query: ListSuggestionsQuery, + ): Promise { + const permissions = await this.permissions.getPermissions(userId); + const status = query.status ?? 'OPEN'; + const rows = await this.prisma.suggestion.findMany({ + where: { + AND: [ + this.visibleWhere(permissions), + await this.statusWhere(status), + ...(query.colonyId ? [{ colonyId: query.colonyId }] : []), + ...(query.apiaryId ? [{ colony: { apiaryId: query.apiaryId } }] : []), + ], + }, + include: LIST_INCLUDE, + orderBy: [ + { dueDate: { sort: 'asc', nulls: 'last' } }, + { priority: 'desc' }, + { createdAt: 'asc' }, + { id: 'asc' }, + ], + take: query.limit ?? DEFAULT_LIST_LIMIT, + skip: query.offset ?? 0, + }); + return this.toViews(rows); + } + + /** One visible suggestion, 404 if it is unknown or hidden. */ + async getOne(userId: string, id: string): Promise { + const [view] = await this.getMany(userId, [id]); + return view; + } + + /** The visible suggestions with these ids, in the order of `ids`. */ + async getMany( + userId: string, + ids: readonly string[], + ): Promise { + const permissions = await this.permissions.getPermissions(userId); + const rows = await this.prisma.suggestion.findMany({ + where: { + AND: [this.visibleWhere(permissions), { id: { in: [...ids] } }], + }, + include: LIST_INCLUDE, + }); + const views = await this.toViews(rows); + const byId = new Map(views.map((view) => [view.id, view])); + return ids.map((id) => { + const view = byId.get(id); + if (!view) { + throw new NotFoundException(NOT_FOUND_MESSAGE); + } + return view; + }); + } + + async summary(userId: string): Promise { + const permissions = await this.permissions.getPermissions(userId); + const groups = await this.prisma.suggestion.groupBy({ + by: ['priority'], + where: { AND: [this.visibleWhere(permissions), { status: 'OPEN' }] }, + _count: { _all: true }, + }); + const byPriority = Object.fromEntries( + PRIORITIES.map((priority) => [priority, 0]), + ) as Record; + for (const group of groups) { + byPriority[group.priority] = group._count._all; + } + const open = PRIORITIES.reduce((sum, key) => sum + byPriority[key], 0); + return { open, byPriority }; + } + + /** + * 404 for an unknown suggestion and for one the user may not see, so the + * existence of a suggestion stays hidden together with its content. + */ + async assertVisible(userId: string, ids: readonly string[]): Promise { + const permissions = await this.permissions.getPermissions(userId); + const unique = [...new Set(ids)]; + const visible = await this.prisma.suggestion.count({ + where: { AND: [this.visibleWhere(permissions), { id: { in: unique } }] }, + }); + if (visible !== unique.length) { + throw new NotFoundException(NOT_FOUND_MESSAGE); + } + } + + /** The database clock decides what "within 30 days" means. */ + private async statusWhere( + status: SuggestionStatus, + ): Promise { + if (status !== 'DISMISSED') { + return { status }; + } + const [{ cutoff }] = await this.prisma.$queryRaw<{ cutoff: Date }[]>` + SELECT now() - make_interval(days => ${DISMISSED_LISTED_DAYS}) AS cutoff`; + return { status, decidedAt: { gte: cutoff } }; + } + + private async toViews(rows: SuggestionRow[]): Promise { + if (rows.length === 0) { + return []; + } + const [lookup, rules] = await Promise.all([ + buildEvidenceLookup( + this.prisma, + rows.map((row) => row.evidence as SuggestionEvidence), + ), + this.prisma.automationRule.findMany({ + where: { + triggerType: { in: [...new Set(rows.map((row) => row.ruleKey))] }, + }, + select: { triggerType: true, actionTemplate: true }, + }), + ]); + const templates = new Map( + rules.map((rule) => [rule.triggerType, rule.actionTemplate]), + ); + const today = berlinToday(); + return rows.map((row) => { + const text = renderSuggestion( + row, + lookup, + today, + templates.get(row.ruleKey), + ); + return { + id: row.id, + ruleKey: row.ruleKey, + source: row.source, + mode: row.mode, + intent: row.intent, + status: row.status, + priority: row.priority, + relatedType: row.relatedType, + relatedId: row.relatedId, + colonyId: row.colonyId, + colonyCode: row.colony?.code ?? null, + apiaryId: row.colony?.apiaryId ?? null, + apiaryName: row.colony?.apiary.name ?? null, + title: text.title, + reasons: text.reasons, + dueDate: row.dueDate === null ? null : utcDay(row.dueDate), + expiresAt: row.expiresAt === null ? null : utcDay(row.expiresAt), + plan: planOf(row.plan), + decidedAt: row.decidedAt?.toISOString() ?? null, + decisionNote: row.decisionNote, + createdAt: row.createdAt.toISOString(), + cleanup: cleanupOf(row), + }; + }); + } +} diff --git a/ApiCore-Backend/src/suggestions/suggestions.controller.ts b/ApiCore-Backend/src/suggestions/suggestions.controller.ts new file mode 100644 index 0000000..765cf5c --- /dev/null +++ b/ApiCore-Backend/src/suggestions/suggestions.controller.ts @@ -0,0 +1,140 @@ +import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; +import { + ApiBearerAuth, + ApiCreatedResponse, + ApiOkResponse, + ApiTags, +} from '@nestjs/swagger'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { RequirePermission } from '../auth/decorators/require-permission.decorator'; +import type { AuthenticatedUser } from '../auth/types/authenticated-user'; +import { Idempotent } from '../common/idempotency/idempotent.decorator'; +import { AcceptSuggestionDto } from './dto/accept-suggestion.dto'; +import { DismissSuggestionDto } from './dto/dismiss-suggestion.dto'; +import { ListSuggestionsQuery } from './dto/list-suggestions.query'; +import { SuggestionIdsDto } from './dto/suggestion-ids.dto'; +import { + AcceptSuggestionResponseDto, + BulkDismissResponseDto, + SuggestionSummaryDto, + SuggestionViewDto, +} from './dto/suggestion-response.dto'; +import type { AcceptSuggestionResult } from './suggestion.types'; +import { SuggestionViewsService } from './suggestion-views.service'; +import { SuggestionsService } from './suggestions.service'; + +/** + * Every decision first checks that the user may see the suggestion (404 + * otherwise); the state checks, 409 and 400 answers come from the service. + * Static routes come before `:id`. + */ +@ApiTags('suggestions') +@ApiBearerAuth() +@Controller('suggestions') +export class SuggestionsController { + constructor( + private readonly views: SuggestionViewsService, + private readonly suggestions: SuggestionsService, + ) {} + + @ApiOkResponse({ type: [SuggestionViewDto] }) + @RequirePermission('tasks:read') + @Get() + list( + @Query() query: ListSuggestionsQuery, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.views.list(actor.id, query); + } + + @ApiOkResponse({ type: SuggestionSummaryDto }) + @RequirePermission('tasks:read') + @Get('summary') + summary(@CurrentUser() actor: AuthenticatedUser) { + return this.views.summary(actor.id); + } + + @ApiCreatedResponse({ type: [AcceptSuggestionResponseDto] }) + @RequirePermission('tasks:create') + @Idempotent() + @Post('bulk-accept') + async bulkAccept( + @Body() dto: SuggestionIdsDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + await this.views.assertVisible(actor.id, dto.ids); + const results = await this.suggestions.bulkAccept(dto.ids, actor.id); + return this.withViews(actor.id, results); + } + + @ApiCreatedResponse({ type: BulkDismissResponseDto }) + @RequirePermission('tasks:update') + @Idempotent() + @Post('bulk-dismiss') + async bulkDismiss( + @Body() dto: SuggestionIdsDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + await this.views.assertVisible(actor.id, dto.ids); + return { + dismissed: await this.suggestions.bulkDismiss(dto.ids, actor.id), + }; + } + + @ApiCreatedResponse({ type: AcceptSuggestionResponseDto }) + @RequirePermission('tasks:create') + @Idempotent() + @Post(':id/accept') + async accept( + @Param('id') id: string, + @Body() dto: AcceptSuggestionDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + await this.views.assertVisible(actor.id, [id]); + const result = await this.suggestions.accept(id, dto, actor.id); + const [accepted] = await this.withViews(actor.id, [result]); + return accepted; + } + + @ApiCreatedResponse({ type: SuggestionViewDto }) + @RequirePermission('tasks:update') + @Idempotent() + @Post(':id/dismiss') + async dismiss( + @Param('id') id: string, + @Body() dto: DismissSuggestionDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + await this.views.assertVisible(actor.id, [id]); + await this.suggestions.dismiss(id, dto.note ?? null, actor.id); + return this.views.getOne(actor.id, id); + } + + @ApiCreatedResponse({ type: SuggestionViewDto }) + @RequirePermission('tasks:update') + @Idempotent() + @Post(':id/reopen') + async reopen( + @Param('id') id: string, + @CurrentUser() actor: AuthenticatedUser, + ) { + await this.views.assertVisible(actor.id, [id]); + await this.suggestions.reopen(id, actor.id); + return this.views.getOne(actor.id, id); + } + + /** The accept results in the app's shape: rendered suggestion + tasks. */ + private async withViews( + userId: string, + results: readonly AcceptSuggestionResult[], + ) { + const views = await this.views.getMany( + userId, + results.map((result) => result.suggestionId), + ); + return results.map((result, index) => ({ + suggestion: views[index], + tasks: result.tasks, + })); + } +} diff --git a/ApiCore-Backend/src/suggestions/suggestions.module.ts b/ApiCore-Backend/src/suggestions/suggestions.module.ts index 345c2a8..2e2b363 100644 --- a/ApiCore-Backend/src/suggestions/suggestions.module.ts +++ b/ApiCore-Backend/src/suggestions/suggestions.module.ts @@ -1,6 +1,8 @@ import { Module } from '@nestjs/common'; import { AuditModule } from '../audit/audit.module'; import { TasksModule } from '../tasks/tasks.module'; +import { SuggestionViewsService } from './suggestion-views.service'; +import { SuggestionsController } from './suggestions.controller'; import { SuggestionsService } from './suggestions.service'; /** @@ -9,7 +11,8 @@ import { SuggestionsService } from './suggestions.service'; */ @Module({ imports: [TasksModule, AuditModule], - providers: [SuggestionsService], - exports: [SuggestionsService], + controllers: [SuggestionsController], + providers: [SuggestionsService, SuggestionViewsService], + exports: [SuggestionsService, SuggestionViewsService], }) export class SuggestionsModule {} diff --git a/ApiCore-Backend/src/system/job-run.service.spec.ts b/ApiCore-Backend/src/system/job-run.service.spec.ts index a8eaaf6..efdcaf9 100644 --- a/ApiCore-Backend/src/system/job-run.service.spec.ts +++ b/ApiCore-Backend/src/system/job-run.service.spec.ts @@ -59,7 +59,7 @@ describe('JobRunService', () => { $executeRaw: jest.fn, RawCall>().mockResolvedValue(1), $queryRaw: jest .fn, RawCall>() - .mockResolvedValue([{ notified: false }]), + .mockResolvedValue([{ notified: false, day: '2026-10-13' }]), }; prisma = { jobRun, @@ -71,7 +71,9 @@ describe('JobRunService', () => { Promise.resolve([runRow({ status: status as JobRun['status'] })]), ), }; - notifications = { recordForPermission: jest.fn().mockResolvedValue(2) }; + notifications = { + recordForPermission: jest.fn().mockResolvedValue([{ id: 'n1' }]), + }; service = new JobRunService( prisma as unknown as PrismaService, notifications as unknown as NotificationsService, @@ -177,9 +179,11 @@ describe('JobRunService', () => { 'automation:update', { priority: 'WARNUNG', + category: 'SYSTEM', message: 'Job „Tägliche Auswertung“ fehlgeschlagen', relatedType: 'JobRun', relatedId: 'run-1', + dedupeKeyPrefix: 'job-failed:daily-evaluation:2026-10-13', }, tx, ); @@ -188,7 +192,7 @@ describe('JobRunService', () => { // The hourly app-backup check would otherwise report one outage every // hour. The check and the write share a transaction behind a lock per // job, so two failing runs at once cannot both pass the check. - it('notifies at most once per job and UTC day, decided by the database clock', async () => { + it('notifies at most once per job and Berlin day, decided by the database clock', async () => { tx.$queryRaw.mockResolvedValue([{ notified: true }]); await service.run('app-backup', () => Promise.reject(new Error('boom'))); @@ -198,7 +202,13 @@ describe('JobRunService', () => { expect(sqlText(lock)).toContain('pg_advisory_xact_lock'); expect(lock.slice(1)).toEqual(['job-failure-notification:app-backup']); const check = tx.$queryRaw.mock.calls[0]; - expect(sqlText(check)).toContain(`date_trunc('day', now())`); + expect(sqlText(check)).toContain( + `date_trunc('day', now() AT TIME ZONE 'Europe/Berlin')`, + ); + // The key carries the same day the check uses. + expect(sqlText(check)).toContain( + `to_char(now() AT TIME ZONE 'Europe/Berlin'`, + ); expect(check.slice(1)).toEqual(['JobRun', 'app-backup']); expect(tx.$executeRaw.mock.invocationCallOrder[0]).toBeLessThan( tx.$queryRaw.mock.invocationCallOrder[0], diff --git a/ApiCore-Backend/src/system/job-run.service.ts b/ApiCore-Backend/src/system/job-run.service.ts index f3fb23d..0f32315 100644 --- a/ApiCore-Backend/src/system/job-run.service.ts +++ b/ApiCore-Backend/src/system/job-run.service.ts @@ -162,24 +162,29 @@ export class JobRunService { } /** - * At most one notification per job and UTC day (database clock): the + * At most one notification per job and Berlin calendar day (database clock, + * the day logic of the rest of the app; the same day is in the dedupe key): the * hourly app-backup check would otherwise report the same outage every * hour. The advisory lock per job serialises check and write, so two - * runs failing at once cannot both pass the check. A notification that + * runs failing at once cannot both pass the check; the dedupe key per + * recipient is a second guard. A notification that * cannot be written is logged; the run stays FAILED either way. */ private async notifyFailure(job: JobName, runId: string): Promise { try { await this.prisma.$transaction(async (tx) => { await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${`job-failure-notification:${job}`}, 0))`; - const [{ notified }] = await tx.$queryRaw<{ notified: boolean }[]>` + const [{ notified, day }] = await tx.$queryRaw< + { notified: boolean; day: string }[] + >` SELECT EXISTS ( SELECT 1 FROM "Notification" n JOIN "JobRun" r ON r."id" = n."relatedId" WHERE n."relatedType" = ${JOB_RUN_RELATED_TYPE} AND r."job" = ${job} - AND n."createdAt" >= date_trunc('day', now()) - ) AS "notified"`; + AND n."createdAt" >= date_trunc('day', now() AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'Europe/Berlin' + ) AS "notified", + to_char(now() AT TIME ZONE 'Europe/Berlin', 'YYYY-MM-DD') AS "day"`; if (notified) { return; } @@ -187,9 +192,11 @@ export class JobRunService { JOB_FAILURE_PERMISSION[job], { priority: 'WARNUNG', + category: 'SYSTEM', message: `Job „${JOB_LABELS_DE[job]}“ fehlgeschlagen`, relatedType: JOB_RUN_RELATED_TYPE, relatedId: runId, + dedupeKeyPrefix: `job-failed:${job}:${day}`, }, tx, ); diff --git a/ApiCore-Backend/src/treatments/treatment-windows.spec.ts b/ApiCore-Backend/src/treatments/treatment-windows.spec.ts new file mode 100644 index 0000000..90a6c11 --- /dev/null +++ b/ApiCore-Backend/src/treatments/treatment-windows.spec.ts @@ -0,0 +1,36 @@ +import type { DbClient } from '../prisma/db-client'; +import { treatmentWindowsIn } from './treatment-windows'; + +const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`); + +describe('treatmentWindowsIn', () => { + it('maps every treatment of the colony to a window and ignores other colonies', async () => { + const rows = [ + { + colonyId: 'a', + date: d('2026-07-20'), + startDate: null, + endDate: d('2026-08-02'), + }, + { + colonyId: 'a', + date: d('2026-09-01'), + startDate: d('2026-08-28'), + endDate: null, + }, + { colonyId: 'b', date: d('2026-06-01'), startDate: null, endDate: null }, + ]; + const findMany = jest.fn(({ where }: { where: { colonyId: string } }) => + Promise.resolve(rows.filter((row) => row.colonyId === where.colonyId)), + ); + const db = { treatment: { findMany } } as unknown as DbClient; + + await expect(treatmentWindowsIn(db, 'a')).resolves.toEqual([ + { startDay: '2026-07-20', endDay: '2026-08-02' }, + { startDay: '2026-08-28', endDay: '2026-09-01' }, + ]); + expect(findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { colonyId: 'a' } }), + ); + }); +}); diff --git a/ApiCore-Backend/src/treatments/treatment-windows.ts b/ApiCore-Backend/src/treatments/treatment-windows.ts new file mode 100644 index 0000000..14bd471 --- /dev/null +++ b/ApiCore-Backend/src/treatments/treatment-windows.ts @@ -0,0 +1,21 @@ +import type { DbClient } from '../prisma/db-client'; +import { + treatmentWindowOf, + type TreatmentWindowLite, +} from './withdrawal-window'; + +/** + * The treatment windows of one colony (all treatments, oldest first). A + * function rather than a service so that callers need no module import. + */ +export async function treatmentWindowsIn( + db: DbClient, + colonyId: string, +): Promise { + const treatments = await db.treatment.findMany({ + where: { colonyId }, + select: { date: true, startDate: true, endDate: true }, + orderBy: { date: 'asc' }, + }); + return treatments.map(treatmentWindowOf); +} diff --git a/ApiCore-Backend/src/treatments/treatments.service.spec.ts b/ApiCore-Backend/src/treatments/treatments.service.spec.ts index f5ed36e..64c86de 100644 --- a/ApiCore-Backend/src/treatments/treatments.service.spec.ts +++ b/ApiCore-Backend/src/treatments/treatments.service.spec.ts @@ -504,6 +504,28 @@ describe('TreatmentsService', () => { ]); }); + it('counts the harvest day in Berlin time: 00:30 on the end day is outside', async () => { + prisma.treatment.findMany.mockResolvedValue([endedFirstOfJuly]); + + const result = await service.getWithdrawalStatus( + ['colony-1'], + new Date('2026-07-15T00:30:00+02:00'), + ); + + expect(result).toEqual([]); + }); + + it('is still active at 23:30 Berlin time the day before the end', async () => { + prisma.treatment.findMany.mockResolvedValue([endedFirstOfJuly]); + + const result = await service.getWithdrawalStatus( + ['colony-1'], + new Date('2026-07-14T23:30:00+02:00'), + ); + + expect(result).toHaveLength(1); + }); + it('omits a window that has ended by that date', async () => { prisma.treatment.findMany.mockResolvedValue([endedFirstOfJuly]); diff --git a/ApiCore-Backend/src/treatments/treatments.service.ts b/ApiCore-Backend/src/treatments/treatments.service.ts index 3ef269f..146e458 100644 --- a/ApiCore-Backend/src/treatments/treatments.service.ts +++ b/ApiCore-Backend/src/treatments/treatments.service.ts @@ -14,6 +14,12 @@ import { } from '../common/bulk/bulk-create'; import { runBulk } from '../common/bulk/run-bulk'; import { lockRowForUpdate } from '../prisma/row-lock'; +import { berlinDayOf, dayToDate } from '../common/dates'; +import { + isInWindow, + withdrawalWindowOf, + type WithdrawalWindow, +} from './withdrawal-window'; import { CreateTreatmentDto } from './dto/create-treatment.dto'; import { BulkCreateTreatmentDto } from './dto/bulk-create-treatment.dto'; import { UpdateTreatmentDto } from './dto/update-treatment.dto'; @@ -24,16 +30,6 @@ export interface WithdrawalStatus { product: string; } -// When a treatment began: the earlier of its date and the start of a -// multi-day treatment - the earlier one errs on the side of withdrawal. -function treatmentStart( - treatment: Pick, -): Date { - return treatment.startDate && treatment.startDate < treatment.date - ? treatment.startDate - : treatment.date; -} - // The row a new treatment is stored as - for create and createBulk alike, so // a bulk entry stores exactly what single entries would. Where the colony // stands today is where it was treated: the apiary is kept on the treatment @@ -105,29 +101,23 @@ export class TreatmentsService { withdrawalPeriodDays: { not: null }, }, }); - const activeByColony = new Map(); + const harvestDay = berlinDayOf(at); + const activeByColony = new Map(); for (const treatment of treatments) { - if (treatmentStart(treatment) > at) { - continue; - } - const referenceDate = treatment.endDate ?? treatment.date; - const activeUntil = new Date(referenceDate); - activeUntil.setUTCDate( - activeUntil.getUTCDate() + (treatment.withdrawalPeriodDays ?? 0), - ); - if (activeUntil <= at) { + const window = withdrawalWindowOf(treatment); + if (!window || !isInWindow(window, harvestDay)) { continue; } const current = activeByColony.get(treatment.colonyId); - if (!current || activeUntil > new Date(current.activeUntil)) { - activeByColony.set(treatment.colonyId, { - colonyId: treatment.colonyId, - activeUntil: activeUntil.toISOString(), - product: treatment.product, - }); + if (!current || window.untilDay > current.untilDay) { + activeByColony.set(treatment.colonyId, window); } } - return [...activeByColony.values()]; + return [...activeByColony.values()].map((window) => ({ + colonyId: window.colonyId, + activeUntil: dayToDate(window.untilDay).toISOString(), + product: window.product, + })); } async create( diff --git a/ApiCore-Backend/src/treatments/withdrawal-window.spec.ts b/ApiCore-Backend/src/treatments/withdrawal-window.spec.ts new file mode 100644 index 0000000..3a5ddfc --- /dev/null +++ b/ApiCore-Backend/src/treatments/withdrawal-window.spec.ts @@ -0,0 +1,149 @@ +import { withdrawalEndDay } from '../jobs/daily-evaluation.rules'; +import { + isInWindow, + treatmentWindowOf, + withdrawalWindowOf, +} from './withdrawal-window'; + +const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`); + +describe('withdrawal-window', () => { + describe('withdrawalWindowOf', () => { + it('runs from the treatment start to (endDate ?? date) + days, exclusive', () => + expect( + withdrawalWindowOf({ + id: 't', + colonyId: 'c', + product: 'Oxuvar', + date: d('2026-09-28'), + startDate: null, + endDate: d('2026-10-01'), + withdrawalPeriodDays: 14, + }), + ).toEqual({ + treatmentId: 't', + colonyId: 'c', + product: 'Oxuvar', + fromDay: '2026-09-28', + untilDay: '2026-10-15', + })); + + it('starts at startDate when it lies before date', () => + expect( + withdrawalWindowOf({ + id: 't', + colonyId: 'c', + product: 'Oxuvar', + date: d('2026-07-20'), + startDate: d('2026-07-10'), + endDate: d('2026-07-20'), + withdrawalPeriodDays: 7, + }), + ).toMatchObject({ fromDay: '2026-07-10', untilDay: '2026-07-27' })); + + it('ignores a startDate after date', () => + expect( + withdrawalWindowOf({ + id: 't', + colonyId: 'c', + product: 'p', + date: d('2026-07-20'), + startDate: d('2026-07-25'), + endDate: null, + withdrawalPeriodDays: 1, + }), + ).toMatchObject({ fromDay: '2026-07-20', untilDay: '2026-07-21' })); + + it('has no window without withdrawalPeriodDays', () => + expect( + withdrawalWindowOf({ + id: 't', + colonyId: 'c', + product: 'p', + date: d('2026-07-20'), + startDate: null, + endDate: null, + withdrawalPeriodDays: null, + }), + ).toBeNull()); + + it('is inactive on untilDay and active on fromDay', () => { + const window = withdrawalWindowOf({ + id: 't', + colonyId: 'c', + product: 'p', + date: d('2026-07-20'), + startDate: d('2026-07-10'), + endDate: d('2026-07-20'), + withdrawalPeriodDays: 7, + }); + expect(window).toMatchObject({ + fromDay: '2026-07-10', + untilDay: '2026-07-27', + }); + expect(window && isInWindow(window, '2026-07-10')).toBe(true); + expect(window && isInWindow(window, '2026-07-26')).toBe(true); + expect(window && isInWindow(window, '2026-07-27')).toBe(false); + }); + + it('withdrawalEndDay delivers the window end', () => + expect( + withdrawalEndDay({ + date: d('2026-09-28'), + endDate: d('2026-10-01'), + withdrawalPeriodDays: 14, + }), + ).toBe('2026-10-15')); + + it('counts days in Berlin time', () => + expect( + withdrawalWindowOf({ + id: 't', + colonyId: 'c', + product: 'p', + date: new Date('2026-07-01T00:30:00+02:00'), + startDate: null, + endDate: null, + withdrawalPeriodDays: 1, + }), + ).toMatchObject({ fromDay: '2026-07-01', untilDay: '2026-07-02' })); + }); + + describe('isInWindow', () => { + const window = { + treatmentId: 't', + colonyId: 'c', + product: 'p', + fromDay: '2026-07-10', + untilDay: '2026-07-15', + }; + + it.each([ + ['2026-07-09', false], + ['2026-07-10', true], + ['2026-07-14', true], + ['2026-07-15', false], + ])('%s -> %p', (day, expected) => + expect(isInWindow(window, day)).toBe(expected), + ); + }); + + describe('treatmentWindowOf', () => { + it('starts at startDate when it is before date and ends at endDate ?? date', () => { + expect( + treatmentWindowOf({ + date: d('2026-07-20'), + startDate: d('2026-07-10'), + endDate: null, + }), + ).toEqual({ startDay: '2026-07-10', endDay: '2026-07-20' }); + expect( + treatmentWindowOf({ + date: d('2026-07-20'), + startDate: d('2026-07-25'), + endDate: d('2026-08-02'), + }), + ).toEqual({ startDay: '2026-07-20', endDay: '2026-08-02' }); + }); + }); +}); diff --git a/ApiCore-Backend/src/treatments/withdrawal-window.ts b/ApiCore-Backend/src/treatments/withdrawal-window.ts new file mode 100644 index 0000000..10ff078 --- /dev/null +++ b/ApiCore-Backend/src/treatments/withdrawal-window.ts @@ -0,0 +1,82 @@ +import type { Treatment } from '../../generated/prisma'; +import { addDays, berlinDayOf } from '../common/dates'; + +/** + * The one place that turns a treatment into days: the withdrawal period + * (Wartezeit) and the varroa treatment effect both read it, so there is no + * second window calculation. Pure, no database. All days are 'YYYY-MM-DD' + * (Berlin calendar day of the stored DateTime). + */ + +const dayOf = berlinDayOf; + +/** When a treatment began: startDate if it lies before date, otherwise date. */ +function startDayOf(t: Pick): string { + const date = dayOf(t.date); + const start = t.startDate ? dayOf(t.startDate) : null; + return start !== null && start < date ? start : date; +} + +/** When a treatment ended: endDate, or date for a single-day treatment. */ +function endDayOf(t: Pick): string { + return dayOf(t.endDate ?? t.date); +} + +export interface TreatmentWindowLite { + startDay: string; + endDay: string; +} + +export function treatmentWindowOf( + t: Pick, +): TreatmentWindowLite { + return { startDay: startDayOf(t), endDay: endDayOf(t) }; +} + +export interface WithdrawalWindow { + treatmentId: string; + colonyId: string; + product: string; + fromDay: string; + /** Exclusive: the first day honey may be harvested again. */ + untilDay: string; +} + +/** First harvestable day, null without a withdrawal period. */ +export function withdrawalUntilDay( + t: Pick, +): string | null { + return t.withdrawalPeriodDays === null + ? null + : addDays(endDayOf(t), t.withdrawalPeriodDays); +} + +export function withdrawalWindowOf( + t: Pick< + Treatment, + | 'id' + | 'colonyId' + | 'product' + | 'date' + | 'startDate' + | 'endDate' + | 'withdrawalPeriodDays' + >, +): WithdrawalWindow | null { + const untilDay = withdrawalUntilDay(t); + if (untilDay === null) { + return null; + } + return { + treatmentId: t.id, + colonyId: t.colonyId, + product: t.product, + fromDay: startDayOf(t), + untilDay, + }; +} + +/** fromDay <= day < untilDay */ +export function isInWindow(window: WithdrawalWindow, day: string): boolean { + return window.fromDay <= day && day < window.untilDay; +} diff --git a/ApiCore-Backend/src/varroa/colony-varroa-measurements.controller.ts b/ApiCore-Backend/src/varroa/colony-varroa-measurements.controller.ts index 1ee052e..73e55c3 100644 --- a/ApiCore-Backend/src/varroa/colony-varroa-measurements.controller.ts +++ b/ApiCore-Backend/src/varroa/colony-varroa-measurements.controller.ts @@ -1,17 +1,25 @@ import { Controller, Get, Param } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { RequirePermission } from '../auth/decorators/require-permission.decorator'; +import { VarroaBandsService } from './varroa-bands.service'; import { VarroaService } from './varroa.service'; @ApiTags('varroa-measurements') @ApiBearerAuth() @Controller('colonies') export class ColonyVarroaMeasurementsController { - constructor(private readonly varroaService: VarroaService) {} + constructor( + private readonly varroaService: VarroaService, + private readonly varroaBandsService: VarroaBandsService, + ) {} @RequirePermission('varroa:read') @Get(':id/varroa-measurements') - findByColony(@Param('id') id: string) { - return this.varroaService.findByColony(id); + /** The measurements of the colony, each with its rating (light, band, reason). */ + async findByColony(@Param('id') id: string) { + return this.varroaBandsService.rate( + id, + await this.varroaService.findByColony(id), + ); } } diff --git a/ApiCore-Backend/src/varroa/dto/create-varroa-measurement.dto.ts b/ApiCore-Backend/src/varroa/dto/create-varroa-measurement.dto.ts index c83fb3a..bdae801 100644 --- a/ApiCore-Backend/src/varroa/dto/create-varroa-measurement.dto.ts +++ b/ApiCore-Backend/src/varroa/dto/create-varroa-measurement.dto.ts @@ -6,6 +6,7 @@ import { IsNumber, IsOptional, IsString, + Max, Min, } from 'class-validator'; import { VarroaMethod } from '../../../generated/prisma'; @@ -35,6 +36,18 @@ export class CreateVarroaMeasurementDto { @Min(0) miteCount?: number; + @ApiPropertyOptional({ + description: + 'Sample size in bees (powdered sugar, washing); the percentage is mites / sample * 100.', + minimum: 1, + maximum: 2000, + }) + @IsOptional() + @IsInt() + @Min(1) + @Max(2000) + sampleBees?: number; + @ApiPropertyOptional() @IsOptional() @IsNumber() diff --git a/ApiCore-Backend/src/varroa/dto/varroa-thresholds.query.ts b/ApiCore-Backend/src/varroa/dto/varroa-thresholds.query.ts new file mode 100644 index 0000000..84330e0 --- /dev/null +++ b/ApiCore-Backend/src/varroa/dto/varroa-thresholds.query.ts @@ -0,0 +1,29 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, Matches, MaxLength } from 'class-validator'; + +const MAX_ID_LENGTH = 64; +const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +/** + * GET /varroa-measurements/thresholds. The date is only shape-checked here; + * an impossible day (2026-02-30) is rejected by the service. + */ +export class VarroaThresholdsQuery { + @ApiPropertyOptional({ + description: 'Volk, für dessen Typ die Bänder gelten', + }) + @IsOptional() + @IsString() + @MaxLength(MAX_ID_LENGTH) + @Matches(/^[A-Za-z0-9_-]+$/, { message: 'colonyId ist ungültig' }) + colonyId?: string; + + @ApiPropertyOptional({ + description: 'Messtag (JJJJ-MM-TT), ohne Angabe heute (Berlin)', + example: '2026-08-10', + }) + @IsOptional() + @IsString() + @Matches(DAY_PATTERN, { message: 'date muss ein Tag (JJJJ-MM-TT) sein' }) + date?: string; +} diff --git a/ApiCore-Backend/src/varroa/varroa-bands.service.spec.ts b/ApiCore-Backend/src/varroa/varroa-bands.service.spec.ts new file mode 100644 index 0000000..7164c04 --- /dev/null +++ b/ApiCore-Backend/src/varroa/varroa-bands.service.spec.ts @@ -0,0 +1,149 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { VarroaBandsService } from './varroa-bands.service'; + +const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`); + +describe('VarroaBandsService', () => { + let service: VarroaBandsService; + let prisma: { + automationRule: { findUnique: jest.Mock }; + colony: { findUnique: jest.Mock }; + treatment: { findMany: jest.Mock }; + }; + + beforeEach(() => { + prisma = { + // No row: the built-in (active) rule applies. + automationRule: { findUnique: jest.fn().mockResolvedValue(null) }, + colony: { findUnique: jest.fn() }, + treatment: { findMany: jest.fn().mockResolvedValue([]) }, + }; + service = new VarroaBandsService(prisma as unknown as PrismaService); + }); + + describe('thresholds', () => { + it('halves the natural mite fall bands for a young colony', async () => { + prisma.colony.findUnique.mockResolvedValue({ + colonyType: 'YOUNG_COLONY', + }); + + const result = await service.thresholds({ + colonyId: 'c1', + date: '2026-06-10', + }); + + expect(result.active).toBe(true); + expect(result.bands.NATURAL_MITE_FALL).toMatchObject({ + yellowFrom: 2.5, + redAbove: 5, + youngColony: true, + }); + }); + + it('returns no powdered sugar band in April', async () => { + const result = await service.thresholds({ date: '2026-04-10' }); + + expect(result.bands.POWDERED_SUGAR).toBeUndefined(); + expect(result.bands.NATURAL_MITE_FALL).toBeDefined(); + }); + + it('names the recheck day while a treatment of the colony is in effect', async () => { + prisma.colony.findUnique.mockResolvedValue({ + colonyType: 'PRODUCTION_COLONY', + }); + prisma.treatment.findMany.mockResolvedValue([ + { date: d('2026-08-02'), startDate: d('2026-07-20'), endDate: null }, + ]); + + const result = await service.thresholds({ + colonyId: 'c1', + date: '2026-08-07', + }); + + expect(result.postTreatmentUntil).toBe('2026-08-16'); + }); + + it('returns no bands while the rule is switched off', async () => { + prisma.automationRule.findUnique.mockResolvedValue({ + id: 'r', + active: false, + config: {}, + actionTemplate: null, + }); + + await expect(service.thresholds({ date: '2026-08-10' })).resolves.toEqual( + { + day: '2026-08-10', + bands: {}, + postTreatmentUntil: null, + active: false, + }, + ); + }); + + it('rejects an unknown colony with 404', async () => { + prisma.colony.findUnique.mockResolvedValue(null); + + await expect( + service.thresholds({ colonyId: 'missing' }), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it.each(['2026-02-30', '2026-13-01', '10.08.2026', 'x'])( + 'rejects the invalid date %s with 400', + async (date) => { + await expect(service.thresholds({ date })).rejects.toBeInstanceOf( + BadRequestException, + ); + }, + ); + }); + + describe('rate', () => { + const measurement = { + id: 'm1', + colonyId: 'c1', + date: d('2026-08-20'), + method: 'NATURAL_MITE_FALL' as const, + calculatedValue: 12, + }; + + it('adds the rating of every measurement', async () => { + prisma.colony.findUnique.mockResolvedValue({ + colonyType: 'PRODUCTION_COLONY', + }); + + const [rated] = await service.rate('c1', [measurement]); + + expect(rated).toMatchObject({ id: 'm1' }); + expect(rated.rating).toMatchObject({ + light: 'red', + reasonCode: 'NATURAL_FALL_SEASON', + }); + }); + + it('rates nothing while the rule is switched off', async () => { + prisma.automationRule.findUnique.mockResolvedValue({ + id: 'r', + active: false, + config: {}, + actionTemplate: null, + }); + + const [rated] = await service.rate('c1', [measurement]); + + expect(rated.rating).toEqual({ + light: null, + reasonCode: null, + band: null, + postTreatmentUntil: null, + }); + }); + + it('does not read the colony for an empty list', async () => { + await expect(service.rate('c1', [])).resolves.toEqual([]); + expect(prisma.colony.findUnique).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/ApiCore-Backend/src/varroa/varroa-bands.service.ts b/ApiCore-Backend/src/varroa/varroa-bands.service.ts new file mode 100644 index 0000000..52c3e49 --- /dev/null +++ b/ApiCore-Backend/src/varroa/varroa-bands.service.ts @@ -0,0 +1,113 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import type { ColonyType, VarroaMeasurement } from '../../generated/prisma'; +import { readRule } from '../automation/rule-config'; +import { + bandsFor, + rateMeasurement, + type VarroaBands, + type VarroaRating, +} from '../automation/varroa-bands'; +import { berlinToday, dayToDate } from '../common/dates'; +import { PrismaService } from '../prisma/prisma.service'; +import { treatmentWindowsIn } from '../treatments/treatment-windows'; + +export interface VarroaThresholds extends VarroaBands { + /** false while the VARROA_THRESHOLD rule is switched off: no bands. */ + active: boolean; +} + +type RatableMeasurement = Pick< + VarroaMeasurement, + 'date' | 'method' | 'calculatedValue' +>; + +const NO_RATING: VarroaRating = { + light: null, + reasonCode: null, + band: null, + postTreatmentUntil: null, +}; + +function validDay(day: string): string { + try { + dayToDate(day); + } catch { + throw new BadRequestException('Bitte einen gültigen Tag angeben.'); + } + return day; +} + +/** + * The varroa bands (green/yellow/red limits) of the VARROA_THRESHOLD rule for + * a day and colony, and the rating of measurements against them. + */ +@Injectable() +export class VarroaBandsService { + constructor(private readonly prisma: PrismaService) {} + + async thresholds(query: { + colonyId?: string; + date?: string; + }): Promise { + const day = validDay(query.date ?? berlinToday()); + const rule = await readRule(this.prisma, 'VARROA_THRESHOLD'); + const colonyType = + query.colonyId === undefined + ? null + : await this.colonyTypeOf(query.colonyId); + if (!rule.active) { + return { day, bands: {}, postTreatmentUntil: null, active: false }; + } + const treatments = + query.colonyId === undefined + ? [] + : await treatmentWindowsIn(this.prisma, query.colonyId); + return { + ...bandsFor(day, colonyType, rule.config, treatments), + active: true, + }; + } + + /** Each measurement of one colony with its rating. */ + async rate( + colonyId: string, + measurements: readonly M[], + ): Promise<(M & { rating: VarroaRating })[]> { + if (measurements.length === 0) { + return []; + } + const rule = await readRule(this.prisma, 'VARROA_THRESHOLD'); + if (!rule.active) { + return measurements.map((m) => ({ ...m, rating: NO_RATING })); + } + const colony = await this.prisma.colony.findUnique({ + where: { id: colonyId }, + select: { colonyType: true }, + }); + const treatments = await treatmentWindowsIn(this.prisma, colonyId); + return measurements.map((m) => ({ + ...m, + rating: rateMeasurement( + m, + colony?.colonyType ?? null, + rule.config, + treatments, + ), + })); + } + + private async colonyTypeOf(colonyId: string): Promise { + const colony = await this.prisma.colony.findUnique({ + where: { id: colonyId }, + select: { colonyType: true }, + }); + if (!colony) { + throw new NotFoundException('Colony not found'); + } + return colony.colonyType; + } +} diff --git a/ApiCore-Backend/src/varroa/varroa-threshold.service.ts b/ApiCore-Backend/src/varroa/varroa-threshold.service.ts deleted file mode 100644 index d52710a..0000000 --- a/ApiCore-Backend/src/varroa/varroa-threshold.service.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { varroaThresholdOf } from '../automation/varroa-threshold'; -import { PrismaService } from '../prisma/prisma.service'; - -export interface VarroaThreshold { - // null when the VARROA_THRESHOLD rule is switched off. - thresholdMitesPerDay: number | null; -} - -@Injectable() -export class VarroaThresholdService { - constructor(private readonly prisma: PrismaService) {} - - async current(): Promise { - const rule = await this.prisma.automationRule.findUnique({ - where: { triggerType: 'VARROA_THRESHOLD' }, - select: { active: true, config: true }, - }); - return { thresholdMitesPerDay: varroaThresholdOf(rule) }; - } -} diff --git a/ApiCore-Backend/src/varroa/varroa.controller.ts b/ApiCore-Backend/src/varroa/varroa.controller.ts index a0c1f89..306b489 100644 --- a/ApiCore-Backend/src/varroa/varroa.controller.ts +++ b/ApiCore-Backend/src/varroa/varroa.controller.ts @@ -1,4 +1,12 @@ -import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common'; +import { + Body, + Controller, + Get, + Param, + Patch, + Post, + Query, +} from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { RequirePermission } from '../auth/decorators/require-permission.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; @@ -7,7 +15,8 @@ import { CreateVarroaMeasurementDto } from './dto/create-varroa-measurement.dto' import { BulkCreateVarroaMeasurementDto } from './dto/bulk-create-varroa-measurement.dto'; import { UpdateVarroaMeasurementDto } from './dto/update-varroa-measurement.dto'; import { VarroaService } from './varroa.service'; -import { VarroaThresholdService } from './varroa-threshold.service'; +import { VarroaBandsService } from './varroa-bands.service'; +import { VarroaThresholdsQuery } from './dto/varroa-thresholds.query'; import { Idempotent } from '../common/idempotency/idempotent.decorator'; @ApiTags('varroa-measurements') @@ -16,7 +25,7 @@ import { Idempotent } from '../common/idempotency/idempotent.decorator'; export class VarroaController { constructor( private readonly varroaService: VarroaService, - private readonly varroaThresholdService: VarroaThresholdService, + private readonly varroaBandsService: VarroaBandsService, ) {} @RequirePermission('varroa:read') @@ -25,11 +34,11 @@ export class VarroaController { return this.varroaService.findAll(); } - /** Effective alarm threshold; declared before :id so it is not shadowed. */ + /** Bands in force on a day for a colony; declared before :id so it is not shadowed. */ @RequirePermission('varroa:read', 'varroa:create') - @Get('threshold') - getThreshold() { - return this.varroaThresholdService.current(); + @Get('thresholds') + getThresholds(@Query() query: VarroaThresholdsQuery) { + return this.varroaBandsService.thresholds(query); } @RequirePermission('varroa:read') diff --git a/ApiCore-Backend/src/varroa/varroa.module.ts b/ApiCore-Backend/src/varroa/varroa.module.ts index a99442f..ed6e29a 100644 --- a/ApiCore-Backend/src/varroa/varroa.module.ts +++ b/ApiCore-Backend/src/varroa/varroa.module.ts @@ -2,7 +2,7 @@ import { Module } from '@nestjs/common'; import { VarroaController } from './varroa.controller'; import { ColonyVarroaMeasurementsController } from './colony-varroa-measurements.controller'; import { VarroaService } from './varroa.service'; -import { VarroaThresholdService } from './varroa-threshold.service'; +import { VarroaBandsService } from './varroa-bands.service'; /** * The threshold rule and the varroa.measured webhook follow from the @@ -11,7 +11,7 @@ import { VarroaThresholdService } from './varroa-threshold.service'; */ @Module({ controllers: [VarroaController, ColonyVarroaMeasurementsController], - providers: [VarroaService, VarroaThresholdService], + providers: [VarroaService, VarroaBandsService], exports: [VarroaService], }) export class VarroaModule {} diff --git a/ApiCore-Backend/src/varroa/varroa.service.spec.ts b/ApiCore-Backend/src/varroa/varroa.service.spec.ts index 6118c00..2ff648d 100644 --- a/ApiCore-Backend/src/varroa/varroa.service.spec.ts +++ b/ApiCore-Backend/src/varroa/varroa.service.spec.ts @@ -284,6 +284,57 @@ describe('VarroaService', () => { expect(createArg.data.calculatedValue).toBeUndefined(); }); + it('stores mites per 100 bees for powdered sugar with a sample size', async () => { + tx.varroaMeasurement.create.mockResolvedValue(measurement); + + await service.create( + { + colonyId: 'colony-1', + date: '2026-07-01', + method: 'POWDERED_SUGAR', + miteCount: 10, + sampleBees: 500, + }, + 'user-1', + ); + + const [[createArg]] = tx.varroaMeasurement.create.mock.calls as [ + [{ data: { calculatedValue?: number; sampleBees?: number } }], + ]; + expect(createArg.data.calculatedValue).toBe(2); + expect(createArg.data.sampleBees).toBe(500); + }); + + it('does not derive a percentage from the sample for natural mite fall or without a sample', async () => { + tx.varroaMeasurement.create.mockResolvedValue(measurement); + + await service.create( + { + colonyId: 'colony-1', + date: '2026-07-01', + method: 'WASHING_METHOD', + miteCount: 10, + }, + 'user-1', + ); + await service.create( + { + colonyId: 'colony-1', + date: '2026-07-01', + method: 'NATURAL_MITE_FALL', + miteCount: 10, + sampleBees: 500, + }, + 'user-1', + ); + + const calls = tx.varroaMeasurement.create.mock.calls as [ + { data: { calculatedValue?: number } }, + ][]; + expect(calls[0][0].data.calculatedValue).toBeUndefined(); + expect(calls[1][0].data.calculatedValue).toBeUndefined(); + }); + it('respects an explicitly provided calculatedValue instead of computing one', async () => { tx.varroaMeasurement.create.mockResolvedValue(measurement); @@ -373,6 +424,16 @@ describe('VarroaService', () => { ).resolves.toBe(20); }); + it('rounds mites/day to two decimals so the stored value rates as shown', async () => { + // 7203 / 720 days = 10.0042: stored as 10, which is yellow, not red. + await expect( + storedCalculatedValueAfter(naturalMiteFall, { + miteCount: 7203, + durationMinutes: 720 * 24 * 60, + }), + ).resolves.toBe(10); + }); + it('recomputes mites/day when the duration changes', async () => { await expect( storedCalculatedValueAfter(naturalMiteFall, { @@ -443,6 +504,108 @@ describe('VarroaService', () => { ).resolves.toBe(15); }); + describe('with a sample size', () => { + const washed = { + ...measurement, + method: 'WASHING_METHOD', + miteCount: 10, + durationMinutes: null, + sampleBees: 500, + calculatedValue: 2, + }; + + it('recomputes the percentage when the sample size changes', async () => { + await expect( + storedCalculatedValueAfter(washed, { sampleBees: 250 }), + ).resolves.toBe(4); + }); + + it('update with sampleBees null clears the sample and keeps a manually entered value', async () => { + tx.varroaMeasurement.findUnique.mockResolvedValue({ + ...washed, + calculatedValue: 3, + }); + tx.varroaMeasurement.update.mockResolvedValue(measurement); + + await service.update( + 'varroa-1', + { sampleBees: null as unknown as number }, + 'user-1', + ); + + const [[updateArg]] = tx.varroaMeasurement.update.mock.calls as [ + [ + { + data: { + sampleBees?: number | null; + calculatedValue?: number | null; + }; + }, + ], + ]; + expect(updateArg.data.sampleBees).toBeNull(); + expect(updateArg.data.calculatedValue).toBeUndefined(); + }); + + it('clears a derived percentage when the sample is removed', async () => { + await expect( + storedCalculatedValueAfter(washed, { + sampleBees: null as unknown as number, + }), + ).resolves.toBeNull(); + }); + + it('rounds the derived percentage to two decimals', async () => { + await expect( + storedCalculatedValueAfter(washed, { + miteCount: 7, + sampleBees: 300, + }), + ).resolves.toBe(2.33); + }); + + it('recomputes the percentage when the mite count changes', async () => { + await expect( + storedCalculatedValueAfter(washed, { miteCount: 15 }), + ).resolves.toBe(3); + }); + + it('leaves a manual percentage alone when only the notes change', async () => { + await expect( + storedCalculatedValueAfter( + { ...washed, calculatedValue: 3 }, + { notes: 'x' }, + ), + ).resolves.toBeUndefined(); + }); + + it('derives the percentage when the method switches from drone brood', async () => { + await expect( + storedCalculatedValueAfter( + { ...washed, method: 'DRONE_BROOD_CONTROL', calculatedValue: 9 }, + { method: 'POWDERED_SUGAR' }, + ), + ).resolves.toBe(2); + }); + + it('clears the percentage when the method switches to drone brood', async () => { + await expect( + storedCalculatedValueAfter(washed, { + method: 'DRONE_BROOD_CONTROL', + }), + ).resolves.toBeNull(); + }); + + it('keeps a changed percentage as a manual override', async () => { + await expect( + storedCalculatedValueAfter(washed, { + sampleBees: 250, + calculatedValue: 7, + }), + ).resolves.toBe(7); + }); + }); + // @IsOptional lets an explicit null through the validation pipe. const clearedValue = { calculatedValue: null as unknown as number }; diff --git a/ApiCore-Backend/src/varroa/varroa.service.ts b/ApiCore-Backend/src/varroa/varroa.service.ts index f266311..5b5d0f9 100644 --- a/ApiCore-Backend/src/varroa/varroa.service.ts +++ b/ApiCore-Backend/src/varroa/varroa.service.ts @@ -19,16 +19,19 @@ import { BulkCreateVarroaMeasurementDto } from './dto/bulk-create-varroa-measure import { UpdateVarroaMeasurementDto } from './dto/update-varroa-measurement.dto'; const MINUTES_PER_DAY = 24 * 60; +const PERCENT = 100; +const DECIMALS_FACTOR = 100; + +// Stored and rated value are the same: rounded once, on write. +function roundToTwoDecimals(value: number): number { + return Math.round(value * DECIMALS_FACTOR) / DECIMALS_FACTOR; +} // Natural mite fall is counted on a sticky-board insert left in the hive // for some duration and is conventionally normalized to a daily rate -// ("Milben/Tag") - both the VARROA_THRESHOLD automation config -// (thresholdMitesPerDay) and the annual PDF report already assume -// calculatedValue is in that unit. The other methods (Puderzucker, -// Waschmethode, Drohnenbrutkontrolle) report a different, non-time-based -// metric (mites per fixed bee sample); there is no single verified -// formula for those here, so calculatedValue is left for the user to -// enter manually for those methods instead of guessing one. +// ("Milben/Tag"). Powdered sugar and washing report the infestation as mites +// per 100 bees of a sample of sampleBees (percent). The drone brood check has +// no verified formula, so its calculatedValue is left for the user to enter. function computeNaturalMiteFallPerDay( miteCount: number | null | undefined, durationMinutes: number | null | undefined, @@ -39,16 +42,44 @@ function computeNaturalMiteFallPerDay( if (!durationMinutes || durationMinutes <= 0) { return undefined; } - return (miteCount / durationMinutes) * MINUTES_PER_DAY; + return roundToTwoDecimals((miteCount / durationMinutes) * MINUTES_PER_DAY); } -function deriveCalculatedValue( - method: VarroaMethod, +function computeInfestationPercent( miteCount: number | null | undefined, - durationMinutes: number | null | undefined, + sampleBees: number | null | undefined, ): number | undefined { - return method === 'NATURAL_MITE_FALL' - ? computeNaturalMiteFallPerDay(miteCount, durationMinutes) + if (miteCount === null || miteCount === undefined) { + return undefined; + } + if (!sampleBees || sampleBees <= 0) { + return undefined; + } + return roundToTwoDecimals((miteCount / sampleBees) * PERCENT); +} + +function isPercentMethod(method: VarroaMethod): boolean { + return method === 'POWDERED_SUGAR' || method === 'WASHING_METHOD'; +} + +interface Derivation { + method: VarroaMethod; + miteCount?: number | null; + durationMinutes?: number | null; + sampleBees?: number | null; +} + +function deriveCalculatedValue({ + method, + miteCount, + durationMinutes, + sampleBees, +}: Derivation): number | undefined { + if (method === 'NATURAL_MITE_FALL') { + return computeNaturalMiteFallPerDay(miteCount, durationMinutes); + } + return isPercentMethod(method) + ? computeInfestationPercent(miteCount, sampleBees) : undefined; } @@ -69,18 +100,25 @@ function calculatedValueForUpdate( if (sent !== undefined && sent !== null && sent !== before.calculatedValue) { return sent; } - const method = dto.method ?? before.method; - const miteCount = - dto.miteCount === undefined ? before.miteCount : dto.miteCount; - const durationMinutes = - dto.durationMinutes === undefined - ? before.durationMinutes - : dto.durationMinutes; + const merged: Derivation = { + method: dto.method ?? before.method, + miteCount: dto.miteCount === undefined ? before.miteCount : dto.miteCount, + durationMinutes: + dto.durationMinutes === undefined + ? before.durationMinutes + : dto.durationMinutes, + sampleBees: + dto.sampleBees === undefined ? before.sampleBees : dto.sampleBees, + }; + const { method } = merged; const derivationInputChanged = method !== before.method || (method === 'NATURAL_MITE_FALL' && - (miteCount !== before.miteCount || - durationMinutes !== before.durationMinutes)); + (merged.miteCount !== before.miteCount || + merged.durationMinutes !== before.durationMinutes)) || + (isPercentMethod(method) && + (merged.miteCount !== before.miteCount || + merged.sampleBees !== before.sampleBees)); // An explicit null asks for the value to be cleared - like a create // without a value, that means "derive it where a rule exists". const mustDerive = @@ -88,8 +126,20 @@ function calculatedValueForUpdate( if (!mustDerive) { return undefined; } + const derived = deriveCalculatedValue(merged); + // A percentage that no longer derives (the sample was removed) is cleared + // only if it was derived itself; a value entered by hand stays. + if ( + derived === undefined && + sent !== null && + method === before.method && + isPercentMethod(method) && + before.calculatedValue !== deriveCalculatedValue(before) + ) { + return undefined; + } // null clears a value the merged record no longer supports. - return deriveCalculatedValue(method, miteCount, durationMinutes) ?? null; + return derived ?? null; } // The row a new measurement is stored as - for create and createBulk alike, @@ -101,9 +151,7 @@ function measurementRow( ): Prisma.VarroaMeasurementUncheckedCreateInput { return { ...dto, - calculatedValue: - dto.calculatedValue ?? - deriveCalculatedValue(dto.method, dto.miteCount, dto.durationMinutes), + calculatedValue: dto.calculatedValue ?? deriveCalculatedValue(dto), date: new Date(dto.date), userId: actingUserId, }; diff --git a/ApiCore-Backend/src/webhooks/webhook-delivery.worker.spec.ts b/ApiCore-Backend/src/webhooks/webhook-delivery.worker.spec.ts index 4abc137..4a3ad31 100644 --- a/ApiCore-Backend/src/webhooks/webhook-delivery.worker.spec.ts +++ b/ApiCore-Backend/src/webhooks/webhook-delivery.worker.spec.ts @@ -188,7 +188,7 @@ describe('WebhookDeliveryWorker', () => { `UPDATE "WebhookDelivery" SET "status" = 'PROCESSING', "claimedAt" = now()`, ); expect(text(claim)).toContain( - `WHERE "status" = 'PENDING' AND "availableAt" <= now()`, + `WHERE "status" = 'PENDING' AND "availableAt" <= CAST(now() AS timestamp(3))`, ); expect(text(claim)).toContain( 'ORDER BY "createdAt" LIMIT ? FOR UPDATE SKIP LOCKED', @@ -201,7 +201,7 @@ describe('WebhookDeliveryWorker', () => { const claim = db.claimQuery(); expect(text(claim)).toContain( - `"availableAt" <= now() AND "subscriptionId" = ANY(?) ORDER BY`, + `"availableAt" <= CAST(now() AS timestamp(3)) AND "subscriptionId" = ANY(?) ORDER BY`, ); expect(claim.values).toEqual([['wh-1', 'wh-2'], 50]); }); diff --git a/ApiCore-Backend/src/webhooks/webhook.handlers.spec.ts b/ApiCore-Backend/src/webhooks/webhook.handlers.spec.ts index b73ab68..caa067b 100644 --- a/ApiCore-Backend/src/webhooks/webhook.handlers.spec.ts +++ b/ApiCore-Backend/src/webhooks/webhook.handlers.spec.ts @@ -18,6 +18,7 @@ type RecordModel = | 'feeding' | 'harvest' | 'honeyBatch' + | 'queen' | 'task'; const RECORD_MODELS: readonly RecordModel[] = [ @@ -27,6 +28,7 @@ const RECORD_MODELS: readonly RecordModel[] = [ 'feeding', 'harvest', 'honeyBatch', + 'queen', 'task', ]; @@ -79,6 +81,7 @@ describe('WebhookEventHandlers', () => { StockChanged: 'stock.changed', TaskCreated: 'task.created', TaskCompleted: 'task.completed', + QueenAssigned: 'queen.replaced', }); }); @@ -156,6 +159,27 @@ describe('WebhookEventHandlers', () => { ); }); + it('QueenAssigned forwards queen.replaced with the queen', async () => { + const queen = { id: 'q1', code: 'Q-001' }; + tx.queen.findUnique.mockResolvedValueOnce(queen); + + await forward( + envelope('QueenAssigned', 'q1', { + colonyId: 'c1', + historyId: 'h1', + assignedOn: '2026-10-14', + previousColonyId: null, + }), + ); + + expect(tx.queen.findUnique).toHaveBeenCalledWith({ where: { id: 'q1' } }); + expect(webhooks.enqueueDeliveries).toHaveBeenCalledWith( + tx, + 'queen.replaced', + queen, + ); + }); + it('enqueues harvest.created with the harvest and its colonies', async () => { const harvest = { id: 'h1', diff --git a/ApiCore-Backend/src/webhooks/webhook.handlers.ts b/ApiCore-Backend/src/webhooks/webhook.handlers.ts index d074025..5c7d3d5 100644 --- a/ApiCore-Backend/src/webhooks/webhook.handlers.ts +++ b/ApiCore-Backend/src/webhooks/webhook.handlers.ts @@ -23,6 +23,7 @@ export const DOMAIN_EVENT_WEBHOOKS: Readonly< StockChanged: 'stock.changed', TaskCreated: 'task.created', TaskCompleted: 'task.completed', + QueenAssigned: 'queen.replaced', }); /** @@ -92,6 +93,8 @@ async function webhookData( case 'TaskCreated': case 'TaskCompleted': return tx.task.findUnique(byAggregate); + case 'QueenAssigned': + return tx.queen.findUnique(byAggregate); default: return null; } diff --git a/ApiCore-Backend/test/automation.e2e-spec.ts b/ApiCore-Backend/test/automation.e2e-spec.ts index d1c951a..a040910 100644 --- a/ApiCore-Backend/test/automation.e2e-spec.ts +++ b/ApiCore-Backend/test/automation.e2e-spec.ts @@ -266,7 +266,12 @@ describe('Automation rules (e2e)', () => { const notifications = await prisma.notification.findMany({ where: { relatedId: colonyId }, }); - expect(notifications).toHaveLength(1); + // One copy per user who may read tasks, never a broadcast. + expect(notifications.length).toBeGreaterThan(0); + expect(notifications.every((n) => n.category === 'SUGGESTION')).toBe(true); + expect(new Set(notifications.map((n) => n.userId)).size).toBe( + notifications.length, + ); }); it('does not trigger when the queen was seen', async () => { diff --git a/ApiCore-Backend/test/calendar-sync.e2e-spec.ts b/ApiCore-Backend/test/calendar-sync.e2e-spec.ts index 1ca0c5f..17ba8ac 100644 --- a/ApiCore-Backend/test/calendar-sync.e2e-spec.ts +++ b/ApiCore-Backend/test/calendar-sync.e2e-spec.ts @@ -44,6 +44,7 @@ describe('Calendar sync outbox (e2e)', () => { syncFeeding: true, syncHarvest: true, syncQueenAssignment: true, + syncWithdrawal: true, updatedAt: new Date(), }; @@ -359,6 +360,37 @@ describe('Calendar sync outbox (e2e)', () => { expect(JSON.stringify(response.body)).toContain('Gruppen-ID'); }); + // GET /settings is mocked in this file (see beforeEach), so the stored + // row is read directly. + it('syncWithdrawal is on by default and can be switched off', async () => { + const fresh = await prisma.calendarSyncSettings.create({ + data: { id: `default-check-${suffix}` }, + }); + await prisma.calendarSyncSettings.delete({ where: { id: fresh.id } }); + expect(fresh.syncWithdrawal).toBe(true); + await prisma.calendarSyncSettings.upsert({ + where: { id: 'singleton' }, + create: { id: 'singleton' }, + update: {}, + }); + + const response = await request(app.getHttpServer()) + .patch('/api/v1/calendar/sync/settings') + .set(auth()) + .send({ syncWithdrawal: false }) + .expect(200); + + expect(response.body).toMatchObject({ syncWithdrawal: false }); + const stored = await prisma.calendarSyncSettings.findUniqueOrThrow({ + where: { id: 'singleton' }, + }); + expect(stored.syncWithdrawal).toBe(false); + await prisma.calendarSyncSettings.update({ + where: { id: 'singleton' }, + data: { syncWithdrawal: true }, + }); + }); + // PATCH null rule: the switches cannot hold null - 400, not a 500. it('rejects null on a switch with 400', async () => { await request(app.getHttpServer()) diff --git a/ApiCore-Backend/test/colonies.e2e-spec.ts b/ApiCore-Backend/test/colonies.e2e-spec.ts index 5854e33..6de5206 100644 --- a/ApiCore-Backend/test/colonies.e2e-spec.ts +++ b/ApiCore-Backend/test/colonies.e2e-spec.ts @@ -35,6 +35,7 @@ describe('Colonies (e2e)', () => { let apiaryBId: string; let colonyId: string; let childColonyId: string; + const splitColonyIds: string[] = []; let colonyToMergeId: string; let healthColonyId: string; let raceColonyId: string; @@ -83,6 +84,7 @@ describe('Colonies (e2e)', () => { colonyToMergeId, healthColonyId, raceColonyId, + ...splitColonyIds, ].filter(Boolean); // Rule tasks are written without a user - their audit entry and // TaskCreated are not covered by the userId cleanup below. @@ -190,6 +192,55 @@ describe('Colonies (e2e)', () => { expect((history.body as ColonyHistoryBody).lineageAsSource).toHaveLength(1); }); + it('records ColonySplit with the given day and queen source, and refuses a future day', async () => { + await request(app.getHttpServer()) + .post(`/api/v1/colonies/${colonyId}/split`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .send({ + code: `V-${Date.now()}-F`, + colonyType: 'NUCLEUS_COLONY', + date: '2999-01-01', + }) + .expect(400); + await request(app.getHttpServer()) + .post(`/api/v1/colonies/${colonyId}/split`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .send({ + code: `V-${Date.now()}-T`, + colonyType: 'NUCLEUS_COLONY', + date: '2026-05-05T23:30:00+02:00', + }) + .expect(400); + + const response = await request(app.getHttpServer()) + .post(`/api/v1/colonies/${colonyId}/split`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .send({ + code: `V-${Date.now()}-B`, + colonyType: 'NUCLEUS_COLONY', + date: '2026-05-05', + queenSource: 'QUEEN_CELL', + }) + .expect(201); + const splitId = (response.body as ColonyBody).id; + splitColonyIds.push(splitId); + + const lineage = await prisma.colonyLineage.findFirstOrThrow({ + where: { toColonyId: splitId }, + }); + expect(lineage.queenSource).toBe('QUEEN_CELL'); + expect(lineage.date.toISOString().slice(0, 10)).toBe('2026-05-05'); + const event = await prisma.domainEvent.findFirstOrThrow({ + where: { type: 'ColonySplit', aggregateId: splitId }, + }); + expect(event.payload).toEqual({ + parentColonyId: colonyId, + lineageId: lineage.id, + queenSource: 'QUEEN_CELL', + splitOn: '2026-05-05', + }); + }); + it('merges another colony into the surviving one', async () => { const otherColony = await request(app.getHttpServer()) .post('/api/v1/colonies') diff --git a/ApiCore-Backend/test/notifications.e2e-spec.ts b/ApiCore-Backend/test/notifications.e2e-spec.ts index 86269d1..730e18a 100644 --- a/ApiCore-Backend/test/notifications.e2e-spec.ts +++ b/ApiCore-Backend/test/notifications.e2e-spec.ts @@ -2,6 +2,7 @@ import { INestApplication } from '@nestjs/common'; import * as bcrypt from 'bcryptjs'; import request from 'supertest'; import { App } from 'supertest/types'; +import { NotificationsService } from '../src/notifications/notifications.service'; import { PrismaService } from '../src/prisma/prisma.service'; import { createTestApp } from './utils/create-test-app'; import { login } from './utils/api'; @@ -9,99 +10,360 @@ import { login } from './utils/api'; interface NotificationBody { id: string; priority: string; + category: string; read: boolean; } +interface SettingsBody { + taskDueEnabled: boolean; + digestEnabled: boolean; + digestTime: string; + quietFrom: string | null; + quietUntil: string | null; + telegramCategories: string[]; + telegramDetail: boolean; +} + describe('Notifications (e2e)', () => { let app: INestApplication; let prisma: PrismaService; - const beekeeperEmail = `notifications-e2e-beekeeper-${Date.now()}@example.com`; + let notifications: NotificationsService; + const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`; const password = 'SuperSecret123!'; - const staffEmail = `notifications-e2e-staff-${Date.now()}@example.com`; - let beekeeperId: string; - let staffId: string; - let beekeeperToken: string; - let staffToken: string; - let notificationId: string; + const emails = { + beekeeper: `notifications-e2e-beekeeper-${suffix}@example.com`, + staff: `notifications-e2e-staff-${suffix}@example.com`, + bare: `notifications-e2e-bare-${suffix}@example.com`, + admin: `notifications-e2e-admin-${suffix}@example.com`, + }; + const roleKey = `NOTIF_BARE_${suffix}`; + const ids = {} as Record; + const tokens = {} as Record; + const createdNotificationIds: string[] = []; + let apiKeyId: string | undefined; + + const server = () => app.getHttpServer(); + const as = (who: keyof typeof emails) => ({ + Authorization: `Bearer ${tokens[who]}`, + }); + + async function seedNotification( + userId: string, + overrides: Record = {}, + ) { + const row = await prisma.notification.create({ + data: { + userId, + priority: 'KRITISCH', + category: 'SYSTEM', + message: `Kritischer Testalarm ${suffix}`, + ...overrides, + }, + }); + createdNotificationIds.push(row.id); + return row; + } beforeAll(async () => { app = await createTestApp(); prisma = app.get(PrismaService); + notifications = app.get(NotificationsService); + await prisma.role.create({ + data: { key: roleKey, name: 'Notifications bare role' }, + }); const passwordHash = await bcrypt.hash(password, 12); - const beekeeper = await prisma.user.create({ - data: { - email: beekeeperEmail, - passwordHash, - name: 'Beekeeper', - role: { connect: { key: 'BEEKEEPER' } }, - }, - }); - beekeeperId = beekeeper.id; - const staff = await prisma.user.create({ - data: { - email: staffEmail, - passwordHash, - name: 'Staff', - role: { connect: { key: 'STAFF' } }, - }, - }); - staffId = staff.id; - beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken; - staffToken = (await login(app, staffEmail, password)).accessToken; - - const notification = await prisma.notification.create({ - data: { - userId: beekeeperId, - priority: 'KRITISCH', - message: `Kritischer Testalarm ${Date.now()}`, - }, - }); - notificationId = notification.id; + const roleOf: Record = { + beekeeper: 'BEEKEEPER', + staff: 'STAFF', + bare: roleKey, + admin: 'ADMIN', + }; + for (const who of Object.keys(emails) as (keyof typeof emails)[]) { + const user = await prisma.user.create({ + data: { + email: emails[who], + passwordHash, + name: who, + role: { connect: { key: roleOf[who] } }, + }, + }); + ids[who] = user.id; + tokens[who] = (await login(app, emails[who], password)).accessToken; + } }); afterAll(async () => { - await prisma.notification.deleteMany({ where: { id: notificationId } }); + const userIds = Object.values(ids); + if (apiKeyId) { + await prisma.apiKey.deleteMany({ where: { id: apiKeyId } }); + } + await prisma.notification.deleteMany({ + where: { + OR: [ + { id: { in: createdNotificationIds } }, + { userId: { in: userIds } }, + ], + }, + }); + await prisma.notificationPreference.deleteMany({ + where: { userId: { in: userIds } }, + }); await prisma.refreshToken.deleteMany({ - where: { userId: { in: [beekeeperId, staffId] } }, - }); - await prisma.auditLog.deleteMany({ - where: { userId: { in: [beekeeperId, staffId] } }, - }); - await prisma.user.deleteMany({ - where: { id: { in: [beekeeperId, staffId] } }, + where: { userId: { in: userIds } }, }); + await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } }); + await prisma.user.deleteMany({ where: { id: { in: userIds } } }); + await prisma.role.deleteMany({ where: { key: roleKey } }); await app.close(); }); it('lists notifications for the authenticated user', async () => { - const response = await request(app.getHttpServer()) + const own = await seedNotification(ids.beekeeper); + + const response = await request(server()) .get('/api/v1/notifications') - .set('Authorization', `Bearer ${beekeeperToken}`) + .set(as('beekeeper')) .expect(200); - const notifications = response.body as NotificationBody[]; - expect( - notifications.some( - (notification) => - notification.id === notificationId && - notification.priority === 'KRITISCH', - ), - ).toBe(true); + const list = response.body as NotificationBody[]; + + expect(list.some((n) => n.id === own.id && n.priority === 'KRITISCH')).toBe( + true, + ); + expect(list[0]).not.toHaveProperty('requiredPermissions'); + expect(list[0]).not.toHaveProperty('dedupeKey'); }); - it("rejects marking another user's notification as read", () => { - return request(app.getHttpServer()) - .post(`/api/v1/notifications/${notificationId}/read`) - .set('Authorization', `Bearer ${staffToken}`) - .expect(403); + it('hides a row whose permissions the user does not hold (any more)', async () => { + const open = await seedNotification(ids.bare); + const hidden = await seedNotification(ids.bare, { + requiredPermissions: ['colonies:read'], + }); + + const response = await request(server()) + .get('/api/v1/notifications?unreadOnly=true') + .set(as('bare')) + .expect(200); + const listed = (response.body as NotificationBody[]).map((n) => n.id); + + expect(listed).toContain(open.id); + expect(listed).not.toContain(hidden.id); }); - it('marks a notification as read', async () => { - const response = await request(app.getHttpServer()) - .post(`/api/v1/notifications/${notificationId}/read`) - .set('Authorization', `Bearer ${beekeeperToken}`) + it('reading is per user: marking one copy read leaves the other unread', async () => { + const [first, second] = await notifications.recordForUsers( + [ids.beekeeper, ids.staff], + { + priority: 'INFO', + category: 'SYSTEM', + message: `Rundmeldung ${suffix}`, + dedupeKeyPrefix: `e2e-read:${suffix}`, + }, + ); + createdNotificationIds.push(first.id, second.id); + const mine = [first, second].find((n) => n.userId === ids.beekeeper)!; + const theirs = [first, second].find((n) => n.userId === ids.staff)!; + + const response = await request(server()) + .post(`/api/v1/notifications/${mine.id}/read`) + .set(as('beekeeper')) .expect(201); - const notification = response.body as NotificationBody; - expect(notification.read).toBe(true); + + expect((response.body as NotificationBody).read).toBe(true); + expect( + ( + await prisma.notification.findUniqueOrThrow({ + where: { id: theirs.id }, + }) + ).read, + ).toBe(false); + }); + + it('a fan-out for colonies:read never reaches a user without it', async () => { + const rows = await notifications.recordForPermission('colonies:read', { + priority: 'INFO', + category: 'SUGGESTION', + message: `Nur mit Volksrecht ${suffix}`, + dedupeKeyPrefix: `e2e-fanout:${suffix}`, + }); + createdNotificationIds.push(...rows.map((row) => row.id)); + + const recipients = rows.map((row) => row.userId); + expect(recipients).toContain(ids.beekeeper); + expect(recipients).not.toContain(ids.bare); + expect( + rows.every((row) => row.requiredPermissions.includes('colonies:read')), + ).toBe(true); + + const none = await notifications.recordForUsers([ids.bare], { + priority: 'INFO', + category: 'SUGGESTION', + message: 'x', + requiredPermissions: ['colonies:read'], + }); + expect(none).toEqual([]); + }); + + it('a repeated dedupe key writes nothing the second time', async () => { + const input = { + priority: 'INFO' as const, + category: 'SYSTEM' as const, + message: `Einmalig ${suffix}`, + dedupeKeyPrefix: `e2e-dedupe:${suffix}`, + }; + const first = await notifications.recordForUsers([ids.beekeeper], input); + createdNotificationIds.push(...first.map((row) => row.id)); + + const second = await notifications.recordForUsers([ids.beekeeper], input); + + expect(first).toHaveLength(1); + expect(second).toEqual([]); + }); + + it('read-all marks only own notifications', async () => { + const own = await seedNotification(ids.staff); + const foreign = await seedNotification(ids.beekeeper); + + const response = await request(server()) + .post('/api/v1/notifications/read-all') + .set(as('staff')) + .expect(201); + + expect( + (response.body as { updated: number }).updated, + ).toBeGreaterThanOrEqual(1); + expect( + (await prisma.notification.findUniqueOrThrow({ where: { id: own.id } })) + .read, + ).toBe(true); + expect( + ( + await prisma.notification.findUniqueOrThrow({ + where: { id: foreign.id }, + }) + ).read, + ).toBe(false); + }); + + it('reading a foreign notification answers 404', async () => { + const foreign = await seedNotification(ids.beekeeper); + + await request(server()) + .post(`/api/v1/notifications/${foreign.id}/read`) + .set(as('staff')) + .expect(404); + await request(server()) + .post('/api/v1/notifications/does-not-exist/read') + .set(as('staff')) + .expect(404); + expect( + ( + await prisma.notification.findUniqueOrThrow({ + where: { id: foreign.id }, + }) + ).read, + ).toBe(false); + }); + + describe('notification settings', () => { + it('an API key gets 403 on /profile/notification-settings', async () => { + const created = await request(server()) + .post('/api/v1/api-keys') + .set(as('admin')) + .send({ name: `notif-e2e ${suffix}`, scopes: ['shop-sync:read'] }) + .expect(201); + const body = created.body as { apiKey: { id: string }; token: string }; + apiKeyId = body.apiKey.id; + + await request(server()) + .get('/api/v1/profile/notification-settings') + .set('Authorization', `Bearer ${body.token}`) + .expect(403); + }); + + it('returns the defaults on first read', async () => { + const response = await request(server()) + .get('/api/v1/profile/notification-settings') + .set(as('bare')) + .expect(200); + + expect(response.body).toEqual({ + taskDueEnabled: true, + digestEnabled: true, + digestTime: '06:30', + quietFrom: '21:00', + quietUntil: '06:30', + telegramCategories: ['TASK_DUE', 'DIGEST'], + telegramDetail: true, + }); + }); + + it('PATCH notification-settings stores digest time, quiet hours and Telegram categories', async () => { + const response = await request(server()) + .patch('/api/v1/profile/notification-settings') + .set(as('beekeeper')) + .send({ + digestTime: '07:15', + quietFrom: '22:00', + quietUntil: '05:30', + telegramCategories: ['SYSTEM', 'WITHDRAWAL'], + telegramDetail: false, + }) + .expect(200); + const settings = response.body as SettingsBody; + + expect(settings).toMatchObject({ + digestTime: '07:15', + quietFrom: '22:00', + quietUntil: '05:30', + telegramCategories: ['SYSTEM', 'WITHDRAWAL'], + telegramDetail: false, + taskDueEnabled: true, + }); + const stored = await prisma.notificationPreference.findUniqueOrThrow({ + where: { userId: ids.beekeeper }, + }); + expect(stored.telegramEnabled).toBe(false); + expect(stored.digestTime).toBe('07:15'); + }); + + 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') + .set(as('staff')) + .send({ digestTime: '04:30' }) + .expect(400); + expect(JSON.stringify(early.body)).toContain( + 'Bitte eine Uhrzeit zwischen 05:00 und 12:00 wählen.', + ); + + const lone = await request(server()) + .patch('/api/v1/profile/notification-settings') + .set(as('staff')) + .send({ quietFrom: null }) + .expect(400); + expect(JSON.stringify(lone.body)).toContain( + 'Bitte Beginn und Ende der Ruhezeit zusammen leeren.', + ); + + await request(server()) + .patch('/api/v1/profile/notification-settings') + .set(as('staff')) + .send({ quietFrom: null, quietUntil: null }) + .expect(200); + await request(server()) + .patch('/api/v1/profile/notification-settings') + .set(as('staff')) + .send({ digestEnabled: null }) + .expect(400); + }); + + it('does not accept telegramEnabled', async () => { + await request(server()) + .patch('/api/v1/profile/notification-settings') + .set(as('staff')) + .send({ telegramEnabled: true }) + .expect(400); + }); }); }); diff --git a/ApiCore-Backend/test/outbox-claims.e2e-spec.ts b/ApiCore-Backend/test/outbox-claims.e2e-spec.ts new file mode 100644 index 0000000..f131bbd --- /dev/null +++ b/ApiCore-Backend/test/outbox-claims.e2e-spec.ts @@ -0,0 +1,57 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { Prisma } from '../generated/prisma'; +import { claimBatch } from '../src/common/outbox/outbox-claims'; +import { PrismaModule } from '../src/prisma/prisma.module'; +import { PrismaService } from '../src/prisma/prisma.service'; + +const ROUNDS = 1000; + +/** + * "availableAt" is TIMESTAMP(3): a value written from SQL now() (stale + * release, retry backoff, requeue) is ROUNDED to milliseconds, so it can lie + * up to half a millisecond after the next statement's now(). A claim right + * behind such a write must still find the row. + */ +describe('claimBatch and millisecond rounding (e2e)', () => { + const correlationId = `claim-rounding-${Date.now()}-${Math.round(Math.random() * 1e6)}`; + let moduleRef: TestingModule; + let prisma: PrismaService; + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [PrismaModule], + }).compile(); + await moduleRef.init(); + prisma = moduleRef.get(PrismaService); + }); + + afterAll(async () => { + await prisma.domainEvent.deleteMany({ where: { correlationId } }); + await moduleRef.close(); + }); + + it('claims an entry whose availableAt was just set to now()', async () => { + const event = await prisma.domainEvent.create({ + data: { + type: 'TaskDue', + aggregateType: 'Task', + aggregateId: correlationId, + payload: {}, + correlationId, + }, + }); + let missed = 0; + for (let round = 0; round < ROUNDS; round++) { + await prisma.$executeRaw`UPDATE "DomainEvent" SET "status" = 'PENDING', "claimedAt" = NULL, "availableAt" = now() WHERE "id" = ${event.id}`; + const claimed = await claimBatch(prisma, 'DomainEvent', { + limit: 1, + filter: Prisma.sql`"correlationId" = ${correlationId}`, + orderBy: 'occurredAt', + }); + if (claimed.length !== 1) { + missed++; + } + } + expect(missed).toBe(0); + }); +}); diff --git a/ApiCore-Backend/test/post-commit-side-effects.e2e-spec.ts b/ApiCore-Backend/test/post-commit-side-effects.e2e-spec.ts index c7d2867..0c753ae 100644 --- a/ApiCore-Backend/test/post-commit-side-effects.e2e-spec.ts +++ b/ApiCore-Backend/test/post-commit-side-effects.e2e-spec.ts @@ -213,7 +213,7 @@ describe('Post-commit side effects (e2e)', () => { .mockImplementation(() => undefined); // Automation: the warning cannot be written. jest - .spyOn(app.get(NotificationsService), 'record') + .spyOn(app.get(NotificationsService), 'recordForPermission') .mockRejectedValue(new Error('notifications down')); // Calendar sync: enqueue cannot read its settings. jest @@ -290,7 +290,7 @@ describe('Post-commit side effects (e2e)', () => { jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); const colonyId = (await createColony('PC-RETRY')).id; jest - .spyOn(app.get(NotificationsService), 'record') + .spyOn(app.get(NotificationsService), 'recordForPermission') .mockRejectedValueOnce(new Error('notifications down')); const { body } = (await post('/api/v1/inspections', { @@ -311,9 +311,13 @@ describe('Post-commit side effects (e2e)', () => { await prisma.$executeRaw`UPDATE "DomainEvent" SET "availableAt" = now() WHERE "aggregateId" = ${body.id}`; await drainDomainEvents(app, [body.id]); expect(await taskCount('QUEENLESS_NO_EGGS', colonyId)).toBe(1); - expect( - await prisma.notification.count({ where: { relatedId: colonyId } }), - ).toBe(1); + // One copy per recipient (everybody who may read tasks), written once. + const warnings = await prisma.notification.findMany({ + where: { relatedId: colonyId }, + select: { userId: true }, + }); + expect(warnings.length).toBeGreaterThan(0); + expect(new Set(warnings.map((w) => w.userId)).size).toBe(warnings.length); const deliveries = await prisma.domainEventDelivery.findMany({ where: { event: { aggregateId: body.id } }, diff --git a/ApiCore-Backend/test/queens.e2e-spec.ts b/ApiCore-Backend/test/queens.e2e-spec.ts index 1cda2b9..51a3120 100644 --- a/ApiCore-Backend/test/queens.e2e-spec.ts +++ b/ApiCore-Backend/test/queens.e2e-spec.ts @@ -5,6 +5,8 @@ import { App } from 'supertest/types'; import { PrismaService } from '../src/prisma/prisma.service'; import { createTestApp } from './utils/create-test-app'; import { login } from './utils/api'; +import { drainDomainEvents } from './utils/domain-events'; +import { CalendarSyncSettingsService } from '../src/calendar-sync/calendar-sync-settings.service'; interface QueenBody { id: string; @@ -22,6 +24,8 @@ describe('Queens (e2e)', () => { let colonyId: string; let queenId: string; let daughterQueenId: string; + let secondColonyId: string; + let subscriptionId: string; beforeAll(async () => { app = await createTestApp(); @@ -53,7 +57,43 @@ describe('Queens (e2e)', () => { colonyId = colony.id; }); + afterEach(() => { + jest.restoreAllMocks(); + }); + afterAll(async () => { + if (subscriptionId) { + // Deliveries go with their subscription (ON DELETE CASCADE). + await prisma.webhookSubscription.deleteMany({ + where: { id: subscriptionId }, + }); + } + if (queenId) { + const historyIds = ( + await prisma.queenColonyHistory.findMany({ + where: { queenId }, + select: { id: true }, + }) + ).map((history) => history.id); + await prisma.calendarSyncOutboxEntry.deleteMany({ + where: { sourceType: 'QUEEN_ASSIGNMENT', sourceId: { in: historyIds } }, + }); + } + if (secondColonyId) { + await prisma.queenColonyHistory.deleteMany({ + where: { colonyId: secondColonyId }, + }); + await prisma.colony.update({ + where: { id: secondColonyId }, + data: { currentQueenId: null }, + }); + } + if (queenId) { + // Events go with their deliveries (ON DELETE CASCADE). + await prisma.domainEvent.deleteMany({ + where: { aggregateType: 'Queen', aggregateId: queenId }, + }); + } if (queenId) { await prisma.queenEvaluation.deleteMany({ where: { queenId } }); } @@ -70,7 +110,9 @@ describe('Queens (e2e)', () => { if (queenId) { await prisma.queen.delete({ where: { id: queenId } }); } - await prisma.colony.deleteMany({ where: { id: colonyId } }); + await prisma.colony.deleteMany({ + where: { id: { in: [colonyId, secondColonyId].filter(Boolean) } }, + }); await prisma.apiary.deleteMany({ where: { id: apiaryId } }); await prisma.refreshToken.deleteMany({ where: { userId: beekeeperId } }); await prisma.auditLog.deleteMany({ where: { userId: beekeeperId } }); @@ -94,6 +136,8 @@ describe('Queens (e2e)', () => { .set('Authorization', `Bearer ${beekeeperToken}`) .send({ colonyId }) .expect(201); + // Settle the first assignment's event, so later tests see only their own. + await drainDomainEvents(app, [queenId]); const colony = await request(app.getHttpServer()) .get(`/api/v1/colonies/${colonyId}`) @@ -110,6 +154,86 @@ describe('Queens (e2e)', () => { expect(history.body).toHaveLength(1); }); + it('assigning a queen leaves a calendar entry and a queen.replaced delivery after draining', async () => { + jest.spyOn(app.get(CalendarSyncSettingsService), 'get').mockResolvedValue({ + id: 'singleton', + enabled: true, + groupId: 'group-e2e', + syncTask: true, + syncInspection: true, + syncTreatment: true, + syncFeeding: true, + syncHarvest: true, + syncQueenAssignment: true, + syncWithdrawal: true, + updatedAt: new Date(), + }); + subscriptionId = ( + await prisma.webhookSubscription.create({ + data: { + url: 'https://example.com/hook', + events: ['queen.replaced'], + secret: 'a'.repeat(64), + createdByUserId: beekeeperId, + }, + }) + ).id; + secondColonyId = ( + await prisma.colony.create({ + data: { + code: `V2-${Date.now()}`, + apiaryId, + colonyType: 'PRODUCTION_COLONY', + }, + }) + ).id; + + await request(app.getHttpServer()) + .post(`/api/v1/queens/${queenId}/assign-colony`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .send({ colonyId: secondColonyId }) + .expect(201); + + // Nothing but the event is written in the request. + const history = await prisma.queenColonyHistory.findFirstOrThrow({ + where: { queenId, colonyId: secondColonyId }, + }); + expect( + await prisma.calendarSyncOutboxEntry.count({ + where: { sourceType: 'QUEEN_ASSIGNMENT', sourceId: history.id }, + }), + ).toBe(0); + expect( + await prisma.webhookDelivery.count({ + where: { subscriptionId }, + }), + ).toBe(0); + const event = await prisma.domainEvent.findFirstOrThrow({ + where: { type: 'QueenAssigned', aggregateId: queenId }, + orderBy: { occurredAt: 'desc' }, + }); + expect(event.payload).toEqual( + expect.objectContaining({ + colonyId: secondColonyId, + historyId: history.id, + previousColonyId: colonyId, + }), + ); + + await drainDomainEvents(app, [queenId]); + + expect( + await prisma.calendarSyncOutboxEntry.count({ + where: { sourceType: 'QUEEN_ASSIGNMENT', sourceId: history.id }, + }), + ).toBe(1); + const deliveries = await prisma.webhookDelivery.findMany({ + where: { subscriptionId, event: 'queen.replaced' }, + }); + expect(deliveries).toHaveLength(1); + expect(deliveries[0]?.body).toContain(queenId); + }); + it('adds a queen evaluation and lists it back', async () => { await request(app.getHttpServer()) .post(`/api/v1/queens/${queenId}/evaluations`) diff --git a/ApiCore-Backend/test/suggestions-api.e2e-spec.ts b/ApiCore-Backend/test/suggestions-api.e2e-spec.ts new file mode 100644 index 0000000..19afa20 --- /dev/null +++ b/ApiCore-Backend/test/suggestions-api.e2e-spec.ts @@ -0,0 +1,648 @@ +import { INestApplication } from '@nestjs/common'; +import * as bcrypt from 'bcryptjs'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import { PrismaService } from '../src/prisma/prisma.service'; +import type { ProposeInput } from '../src/suggestions/suggestion.types'; +import { login } from './utils/api'; +import { createTestApp } from './utils/create-test-app'; +import { drainDomainEvents } from './utils/domain-events'; +import { proposeForTest } from './utils/suggestions'; + +interface ViewBody { + id: string; + title: string; + reasons: string[]; + colonyCode: string | null; + apiaryName: string | null; + status: string; +} + +const VIEW_KEYS = [ + 'apiaryId', + 'apiaryName', + 'cleanup', + 'colonyCode', + 'colonyId', + 'createdAt', + 'decidedAt', + 'decisionNote', + 'dueDate', + 'expiresAt', + 'id', + 'intent', + 'mode', + 'plan', + 'priority', + 'reasons', + 'relatedId', + 'relatedType', + 'ruleKey', + 'source', + 'status', + 'title', +]; +const INTERNAL_KEYS = [ + 'evidence', + 'dedupeKey', + 'requiredPermission', + 'decidedByUserId', + 'reasonCode', + 'sourceEventId', +]; + +function expectViewShape(view: object): void { + expect(Object.keys(view).sort()).toEqual(VIEW_KEYS); + for (const key of INTERNAL_KEYS) { + expect(view).not.toHaveProperty(key); + } +} + +/** The suggestion routes against a real PostgreSQL, as real roles. */ +describe('Suggestions API (e2e)', () => { + let app: INestApplication; + let prisma: PrismaService; + const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`; + const password = 'SuperSecret123!'; + const base = '/api/v1/suggestions'; + const ADMIN_PERMISSIONS = [ + 'tasks:read', + 'tasks:create', + 'tasks:update', + 'inspections:read', + 'varroa:read', + 'colonies:read', + ]; + let apiaryId: string; + let colonyId: string; + let inactiveUserId: string; + const roleIds: string[] = []; + const userIds: string[] = []; + const tokens: Record< + 'full' | 'noVarroa' | 'readOnly' | 'noUpdate' | 'noTasks', + string + > = { full: '', noVarroa: '', readOnly: '', noUpdate: '', noTasks: '' }; + let keyCounter = 0; + const nextKey = () => `suggestions-api-${suffix}-${(keyCounter += 1)}-key`; + + const proposal = (anchor: string): ProposeInput => ({ + ruleKey: 'INSPECTION_INTERVAL', + source: 'RULE', + mode: 'DO', + intent: 'INSPECT', + relatedType: 'COLONY', + relatedId: colonyId, + colonyId, + reasonCode: `SUGGESTIONS_API_${suffix}`, + evidence: { colonyId }, + requiredPermission: 'inspections:read', + dueDate: '2026-05-12', + anchor, + }); + + const get = (token: string, path: string) => + request(app.getHttpServer()) + .get(`${base}${path}`) + .set('Authorization', `Bearer ${token}`); + + const post = (token: string, path: string, body: object = {}, key?: string) => + request(app.getHttpServer()) + .post(`${base}${path}`) + .set('Authorization', `Bearer ${token}`) + .set('Idempotency-Key', key ?? nextKey()) + .send(body); + + const tasksOf = (suggestionId: string) => + prisma.task.findMany({ where: { sourceSuggestionId: suggestionId } }); + + async function createUser( + label: string, + permissions: string[], + isActive = true, + ): Promise<{ id: string; token: string }> { + const role = await prisma.role.create({ + data: { + key: `SUGGESTIONS_API_${label.toUpperCase()}_${suffix}`, + name: `Suggestions API ${label}`, + permissions: { + create: permissions.map((key) => ({ + permission: { connect: { key } }, + })), + }, + }, + }); + roleIds.push(role.id); + const email = `suggestions-api-${label}-${suffix}@example.com`; + const user = await prisma.user.create({ + data: { + email, + passwordHash: await bcrypt.hash(password, 12), + name: `Suggestions API ${label}`, + isActive, + role: { connect: { id: role.id } }, + }, + }); + userIds.push(user.id); + return { + id: user.id, + token: isActive ? (await login(app, email, password)).accessToken : '', + }; + } + + beforeAll(async () => { + app = await createTestApp(); + prisma = app.get(PrismaService); + apiaryId = ( + await prisma.apiary.create({ data: { name: `Sug API ${suffix}` } }) + ).id; + colonyId = ( + await prisma.colony.create({ + data: { + code: `SA-${suffix}`, + apiaryId, + colonyType: 'PRODUCTION_COLONY', + }, + }) + ).id; + tokens.full = (await createUser('full', ADMIN_PERMISSIONS)).token; + tokens.noVarroa = ( + await createUser( + 'novarroa', + ADMIN_PERMISSIONS.filter((key) => key !== 'varroa:read'), + ) + ).token; + tokens.readOnly = (await createUser('readonly', ['tasks:read'])).token; + tokens.noUpdate = ( + await createUser('noupdate', ['tasks:read', 'tasks:create']) + ).token; + tokens.noTasks = (await createUser('notasks', ['colonies:read'])).token; + inactiveUserId = (await createUser('inactive', ['tasks:read'], false)).id; + }); + + const cleanDb = async () => { + const ids = ( + await prisma.suggestion.findMany({ + where: { colonyId }, + select: { id: true }, + }) + ).map((row) => row.id); + const taskIds = ( + await prisma.task.findMany({ + where: { sourceSuggestionId: { in: ids } }, + select: { id: true }, + }) + ).map((row) => row.id); + await prisma.domainEvent.deleteMany({ + where: { aggregateId: { in: taskIds } }, + }); + await prisma.calendarSyncOutboxEntry.deleteMany({ + where: { sourceType: 'TASK', sourceId: { in: taskIds } }, + }); + await prisma.task.deleteMany({ where: { id: { in: taskIds } } }); + await prisma.suggestion.deleteMany({ where: { colonyId } }); + }; + + afterEach(cleanDb); + + afterAll(async () => { + await cleanDb(); + await prisma.colony.delete({ where: { id: colonyId } }); + await prisma.apiary.delete({ where: { id: apiaryId } }); + await prisma.idempotencyRecord.deleteMany({ + where: { userId: { in: userIds } }, + }); + await prisma.refreshToken.deleteMany({ + where: { userId: { in: userIds } }, + }); + await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } }); + await prisma.user.deleteMany({ where: { id: { in: userIds } } }); + await prisma.role.deleteMany({ where: { id: { in: roleIds } } }); + await app.close(); + }); + + it('lists open suggestions with rendered title and reasons', async () => { + const created = await proposeForTest(app, proposal('a1')); + + const response = await get(tokens.full, `?colonyId=${colonyId}`).expect( + 200, + ); + + const [view] = response.body as ViewBody[]; + expect(view).toMatchObject({ + id: created.id, + title: 'Durchsicht fällig', + reasons: [], + colonyCode: `SA-${suffix}`, + apiaryName: `Sug API ${suffix}`, + status: 'OPEN', + dueDate: '2026-05-12', + }); + const byApiary = await get(tokens.full, `?apiaryId=${apiaryId}`).expect( + 200, + ); + expect(byApiary.body).toHaveLength(1); + const other = await get(tokens.full, '?apiaryId=none').expect(200); + expect(other.body).toEqual([]); + }); + + it('a user without varroa:read sees neither the varroa suggestion nor its count', async () => { + await proposeForTest(app, { + ...proposal('v1'), + ruleKey: 'VARROA_THRESHOLD', + intent: 'VARROA_TREAT', + requiredPermission: 'varroa:read', + }); + + const hidden = await get(tokens.noVarroa, `?colonyId=${colonyId}`); + const hiddenSummary = await get(tokens.noVarroa, '/summary'); + const seen = await get(tokens.full, `?colonyId=${colonyId}`); + const seenSummary = await get(tokens.full, '/summary'); + + expect(hidden.body).toEqual([]); + expect((hiddenSummary.body as { open: number }).open).toBe(0); + expect(seen.body).toHaveLength(1); + expect((seenSummary.body as { open: number }).open).toBeGreaterThanOrEqual( + 1, + ); + expect( + (seenSummary.body as { byPriority: Record }).byPriority, + ).toEqual( + expect.objectContaining({ LOW: 0, NORMAL: expect.any(Number) as number }), + ); + }); + + it('answers 404 to a decision on a suggestion the user may not see', async () => { + const created = await proposeForTest(app, { + ...proposal('v2'), + ruleKey: 'VARROA_THRESHOLD', + intent: 'VARROA_TREAT', + requiredPermission: 'varroa:read', + }); + + await post(tokens.noVarroa, `/${created.id}/accept`).expect(404); + await post(tokens.noVarroa, `/${created.id}/dismiss`).expect(404); + await post(tokens.noVarroa, '/bulk-accept', { ids: [created.id] }).expect( + 404, + ); + await post(tokens.noVarroa, '/bulk-dismiss', { ids: [created.id] }).expect( + 404, + ); + await post(tokens.full, `/${created.id}/dismiss`).expect(201); + await post(tokens.noVarroa, `/${created.id}/reopen`).expect(404); + expect(await tasksOf(created.id)).toHaveLength(0); + }); + + it('accept creates exactly one task with sourceSuggestionId and a calendar delivery', async () => { + const created = await proposeForTest(app, proposal('c1')); + + const response = await post(tokens.full, `/${created.id}/accept`).expect( + 201, + ); + + const tasks = await tasksOf(created.id); + expect(tasks).toHaveLength(1); + expect(tasks[0]).toMatchObject({ + sourceSuggestionId: created.id, + title: 'Durchsicht fällig', + }); + const body = response.body as { + suggestion: object & { status: string }; + tasks: { id: string }[]; + }; + expect(Object.keys(body).sort()).toEqual(['suggestion', 'tasks']); + expectViewShape(body.suggestion); + expect(body.suggestion.status).toBe('ACCEPTED'); + expect(body.tasks.map((task) => task.id)).toEqual([tasks[0].id]); + expect(body.tasks[0]).toMatchObject({ + sourceSuggestionId: created.id, + title: 'Durchsicht fällig', + }); + await drainDomainEvents(app, [tasks[0].id]); + const events = await prisma.domainEvent.findMany({ + where: { aggregateType: 'Task', aggregateId: tasks[0].id }, + include: { deliveries: true }, + }); + expect(events).toHaveLength(1); + expect(events[0].deliveries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + handler: 'calendar-sync.task', + status: 'DONE', + }), + ]), + ); + }); + + it('a second accept with another key answers 409 and leaves exactly one task', async () => { + const created = await proposeForTest(app, proposal('c2')); + await post(tokens.full, `/${created.id}/accept`).expect(201); + + const second = await post(tokens.full, `/${created.id}/accept`).expect(409); + + expect(second.body).toMatchObject({ + message: 'Der Vorschlag ist nicht mehr offen.', + status: 'ACCEPTED', + }); + expect(await tasksOf(created.id)).toHaveLength(1); + }); + + it('the same Idempotency-Key replays the first accept', async () => { + const created = await proposeForTest(app, proposal('c3')); + const key = nextKey(); + + const first = await post( + tokens.full, + `/${created.id}/accept`, + {}, + key, + ).expect(201); + const replay = await post( + tokens.full, + `/${created.id}/accept`, + {}, + key, + ).expect(201); + + expect(replay.body).toEqual(first.body); + expect(await tasksOf(created.id)).toHaveLength(1); + }); + + it('bulk-accept with one non-OPEN id answers 409 and creates no task', async () => { + const open = await proposeForTest(app, proposal('b1')); + const dismissed = await proposeForTest(app, { + ...proposal('b2'), + ruleKey: 'QUEEN_ACCEPTANCE_CHECK', + intent: 'QUEEN_ACCEPTANCE', + }); + await post(tokens.full, `/${dismissed.id}/dismiss`).expect(201); + + await post(tokens.full, '/bulk-accept', { + ids: [open.id, dismissed.id], + }).expect(409); + + expect(await tasksOf(open.id)).toHaveLength(0); + expect( + (await prisma.suggestion.findUniqueOrThrow({ where: { id: open.id } })) + .status, + ).toBe('OPEN'); + }); + + it('bulk endpoints reject 0 or 51 ids', async () => { + const ids51 = Array.from({ length: 51 }, (_, index) => `id-${index}`); + + for (const path of ['/bulk-accept', '/bulk-dismiss']) { + await post(tokens.full, path, { ids: [] }).expect(400); + await post(tokens.full, path, { ids: ids51 }).expect(400); + } + }); + + it('bulk-dismiss dismisses all given suggestions', async () => { + const first = await proposeForTest(app, proposal('d1')); + const second = await proposeForTest(app, { + ...proposal('d2'), + ruleKey: 'QUEEN_ACCEPTANCE_CHECK', + intent: 'QUEEN_ACCEPTANCE', + }); + + const response = await post(tokens.full, '/bulk-dismiss', { + ids: [first.id, second.id], + }).expect(201); + + expect(response.body).toEqual({ dismissed: 2 }); + }); + + it('accept with an unknown or inactive assignedUserId answers 400 and creates no task', async () => { + const created = await proposeForTest(app, proposal('u1')); + + await post(tokens.full, `/${created.id}/accept`, { + assignedUserId: 'does-not-exist', + }).expect(400); + const inactive = await post(tokens.full, `/${created.id}/accept`, { + assignedUserId: inactiveUserId, + }).expect(400); + + expect(inactive.body).toMatchObject({ + issues: [{ field: 'assignedUserId' }], + }); + expect(await tasksOf(created.id)).toHaveLength(0); + }); + + it('dismiss then reopen restores OPEN; the dismissed one is listed for 30 days', async () => { + const created = await proposeForTest(app, proposal('r1')); + + const dismissResponse = await post(tokens.full, `/${created.id}/dismiss`, { + note: 'später', + }).expect(201); + expectViewShape(dismissResponse.body as object); + expect(dismissResponse.body).toMatchObject({ + status: 'DISMISSED', + decisionNote: 'später', + }); + const dismissed = await get( + tokens.full, + `?status=DISMISSED&colonyId=${colonyId}`, + ).expect(200); + expect(await get(tokens.full, `?colonyId=${colonyId}`)).toMatchObject({ + body: [], + }); + await prisma.$executeRaw`UPDATE "Suggestion" SET "decidedAt" = now() - interval '31 days' WHERE id = ${created.id}`; + const stale = await get( + tokens.full, + `?status=DISMISSED&colonyId=${colonyId}`, + ).expect(200); + await prisma.$executeRaw`UPDATE "Suggestion" SET "decidedAt" = now() WHERE id = ${created.id}`; + const reopened = await post(tokens.full, `/${created.id}/reopen`).expect( + 201, + ); + + expect(dismissed.body).toHaveLength(1); + expect((dismissed.body as { decisionNote: string }[])[0]).toMatchObject({ + decisionNote: 'später', + }); + expect(stale.body).toEqual([]); + expectViewShape(reopened.body as object); + expect(reopened.body).toMatchObject({ status: 'OPEN' }); + expect(await get(tokens.full, `?colonyId=${colonyId}`)).toMatchObject({ + body: [expect.objectContaining({ id: created.id })], + }); + }); + + it('rejects a dismiss note over 500 characters', async () => { + const created = await proposeForTest(app, proposal('n1')); + + await post(tokens.full, `/${created.id}/dismiss`, { + note: 'x'.repeat(501), + }).expect(400); + }); + + it('403 without tasks:create on accept and without tasks:update on dismiss', async () => { + const created = await proposeForTest(app, proposal('p1')); + + await post(tokens.readOnly, `/${created.id}/accept`).expect(403); + await post(tokens.readOnly, '/bulk-accept', { ids: [created.id] }).expect( + 403, + ); + await post(tokens.noUpdate, `/${created.id}/dismiss`).expect(403); + await post(tokens.noUpdate, `/${created.id}/reopen`).expect(403); + await post(tokens.noUpdate, '/bulk-dismiss', { ids: [created.id] }).expect( + 403, + ); + await request(app.getHttpServer()).get(base).expect(401); + }); + + it('bulk-accept answers one result per suggestion in the view shape', async () => { + const first = await proposeForTest(app, proposal('ba1')); + const second = await proposeForTest(app, { + ...proposal('ba2'), + ruleKey: 'QUEEN_ACCEPTANCE_CHECK', + intent: 'QUEEN_ACCEPTANCE', + }); + + const response = await post(tokens.full, '/bulk-accept', { + ids: [first.id, second.id], + }).expect(201); + + const results = response.body as { + suggestion: { id: string }; + tasks: object[]; + }[]; + expect(results.map((result) => result.suggestion.id)).toEqual([ + first.id, + second.id, + ]); + for (const result of results) { + expectViewShape(result.suggestion); + expect(result.tasks).toHaveLength(1); + } + }); + + it('403 on the list and the summary without tasks:read', async () => { + await get(tokens.noTasks, '').expect(403); + await get(tokens.noTasks, '/summary').expect(403); + }); + + it('rejects an invalid status, a datetime dueDate and a bad limit with 400', async () => { + const created = await proposeForTest(app, proposal('v400')); + + await get(tokens.full, '?status=NOPE').expect(400); + await get(tokens.full, '?limit=501').expect(400); + await get(tokens.full, '?limit=0').expect(400); + await get(tokens.full, '?offset=-1').expect(400); + const response = await post(tokens.full, `/${created.id}/accept`, { + dueDate: '2026-05-12T10:00:00Z', + }).expect(400); + + expect(JSON.stringify(response.body)).toContain('JJJJ-MM-TT'); + expect(await tasksOf(created.id)).toHaveLength(0); + }); + + it('limit and offset page through the list', async () => { + await proposeForTest(app, proposal('l1')); + await proposeForTest(app, { + ...proposal('l2'), + ruleKey: 'QUEEN_ACCEPTANCE_CHECK', + intent: 'QUEEN_ACCEPTANCE', + }); + const all = (await get(tokens.full, `?colonyId=${colonyId}`).expect(200)) + .body as ViewBody[]; + + const first = ( + await get(tokens.full, `?colonyId=${colonyId}&limit=1`).expect(200) + ).body as ViewBody[]; + const second = ( + await get(tokens.full, `?colonyId=${colonyId}&limit=1&offset=1`).expect( + 200, + ) + ).body as ViewBody[]; + + expect(all).toHaveLength(2); + expect(first.map((view) => view.id)).toEqual([all[0].id]); + expect(second.map((view) => view.id)).toEqual([all[1].id]); + }); + + it('combines the apiaryId filter with status and colonyId', async () => { + const created = await proposeForTest(app, proposal('f1')); + await post(tokens.full, `/${created.id}/dismiss`).expect(201); + + const hit = await get( + tokens.full, + `?apiaryId=${apiaryId}&colonyId=${colonyId}&status=DISMISSED`, + ).expect(200); + const wrongStatus = await get( + tokens.full, + `?apiaryId=${apiaryId}&colonyId=${colonyId}&status=OPEN`, + ).expect(200); + const wrongApiary = await get( + tokens.full, + `?apiaryId=none&colonyId=${colonyId}&status=DISMISSED`, + ).expect(200); + + expect((hit.body as ViewBody[]).map((view) => view.id)).toEqual([ + created.id, + ]); + expect(wrongStatus.body).toEqual([]); + expect(wrongApiary.body).toEqual([]); + }); + + it('sorts by due date (none last), then priority (highest first)', async () => { + const mk = ( + anchor: string, + intent: ProposeInput['intent'], + priority: ProposeInput['priority'], + dueDate: string | null, + ) => + proposeForTest(app, { ...proposal(anchor), intent, priority, dueDate }); + const undated = await mk('s1', 'QUEEN_CHECK', 'URGENT', null); + const normal = await mk('s2', 'INSPECT', 'NORMAL', '2026-05-12'); + const urgent = await mk('s3', 'SWARM_CHECK', 'URGENT', '2026-05-12'); + const early = await mk('s4', 'HEALTH_CHECK', 'LOW', '2026-05-10'); + + const response = await get(tokens.full, `?colonyId=${colonyId}`).expect( + 200, + ); + + expect((response.body as ViewBody[]).map((view) => view.id)).toEqual([ + early.id, + urgent.id, + normal.id, + undated.id, + ]); + }); + + it('shows the cleanup payload from the openTaskIds evidence', async () => { + await proposeForTest(app, { + ...proposal('cl1'), + ruleKey: 'COLONY_ENDED_CLEANUP', + intent: 'CLEANUP', + requiredPermission: 'colonies:read', + evidence: { + colonyId, + status: 'DECEASED', + endedOn: '2026-03-12', + via: 'winter', + openTaskIds: ['t_a', 't_b'], + queenId: 'q_1', + }, + }); + await proposeForTest(app, { ...proposal('cl2'), intent: 'QUEEN_CHECK' }); + + const views = (await get(tokens.full, `?colonyId=${colonyId}`).expect(200)) + .body as { intent: string; cleanup: unknown }[]; + + expect(views.find((v) => v.intent === 'CLEANUP')?.cleanup).toEqual({ + taskIds: ['t_a', 't_b'], + queenId: 'q_1', + }); + expect(views.find((v) => v.intent === 'QUEEN_CHECK')?.cleanup).toBeNull(); + }); + + it('bulk-dismiss answers { dismissed } only', async () => { + const created = await proposeForTest(app, proposal('bd1')); + + const response = await post(tokens.full, '/bulk-dismiss', { + ids: [created.id], + }).expect(201); + + expect(response.body).toEqual({ dismissed: 1 }); + }); +}); diff --git a/ApiCore-Backend/test/t4-migrations.e2e-spec.ts b/ApiCore-Backend/test/t4-migrations.e2e-spec.ts index 6ccec46..52d929d 100644 --- a/ApiCore-Backend/test/t4-migrations.e2e-spec.ts +++ b/ApiCore-Backend/test/t4-migrations.e2e-spec.ts @@ -6,8 +6,11 @@ import { const FEED_VOCABULARY = '20261014100200_t4_feed_vocabulary'; const SPLIT_QUEEN_SOURCE = '20261014100300_t4_split_queen_source'; const VARROA_SAMPLE_SIZE = '20261014100400_t4_varroa_sample_size'; +const NOTIFICATION_FANOUT = '20261014100500_t4_notification_fanout'; +const OCCUPIED_FRAMES = '20261014100700_t4_occupied_frames'; +const NOTIFICATION_SETTINGS = '20261014100800_t4_notification_settings'; -const TIMEOUT_MS = 120_000; +const TIMEOUT_MS = 240_000; interface InspectionSeed { id: string; @@ -36,6 +39,48 @@ async function seedColony(db: ScratchDatabase): Promise { ); } +/** Two active users and one inactive one, plus the role they need. */ +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, active] of [ + ['user-a', true], + ['user-b', true], + ['user-c', false], + ] as const) { + await db.query( + `INSERT INTO "User" ("id", "email", "passwordHash", "name", "roleId", "isActive", "updatedAt") + VALUES ($1, $2, 'x', $1, 'role-1', $3, now())`, + [id, `${id}@example.com`, active], + ); + } +} + +async function seedNotification( + db: ScratchDatabase, + row: { + id: string; + userId: string | null; + relatedType: string | null; + read?: boolean; + createdAt?: string; + }, +): Promise { + await db.query( + `INSERT INTO "Notification" ("id", "userId", "priority", "message", "relatedType", "read", "createdAt") + VALUES ($1, $2, 'WARNUNG', 'm', $3, $4, $5)`, + [ + row.id, + row.userId, + row.relatedType, + row.read ?? false, + row.createdAt ?? '2026-05-01T10:00:00Z', + ], + ); +} + async function seedInspections( db: ScratchDatabase, seeds: InspectionSeed[], @@ -215,4 +260,215 @@ describe('T4 schema II migrations (e2e)', () => { }, TIMEOUT_MS, ); + + it( + 'broadcasts are copied per active user with their read flag', + async () => { + db = await createScratchDatabase(NOTIFICATION_FANOUT); + await seedUsers(db); + await seedNotification(db, { + id: 'broadcast', + userId: null, + relatedType: 'COLONY', + read: true, + createdAt: '2026-04-01T08:00:00Z', + }); + await seedNotification(db, { + id: 'personal', + userId: 'user-c', + relatedType: 'COLONY', + }); + + const [{ createdAt }] = await db.query<{ createdAt: string }>( + `SELECT "createdAt"::text AS "createdAt" FROM "Notification" WHERE "id" = 'broadcast'`, + ); + + await db.migrateThrough(NOTIFICATION_FANOUT); + + const rows = await db.query<{ + userId: string; + read: boolean; + createdAt: string; + }>( + `SELECT "userId", "read", "createdAt"::text AS "createdAt" + FROM "Notification" `, + ); + rows.sort((a, b) => (a.userId < b.userId ? -1 : 1)); + expect(rows.map(({ userId, read }) => ({ userId, read }))).toEqual([ + { userId: 'user-a', read: true }, + { userId: 'user-b', read: true }, + { userId: 'user-c', read: false }, + ]); + expect(rows[0].createdAt).toBe(createdAt); + expect(rows[1].createdAt).toBe(createdAt); + expect( + await db.query( + `SELECT is_nullable FROM information_schema.columns + WHERE table_schema = current_schema() AND table_name = 'Notification' AND column_name = 'userId'`, + ), + ).toEqual([{ is_nullable: 'NO' }]); + }, + TIMEOUT_MS, + ); + + it( + 'drops broadcasts when no user is active and keeps the personal rows', + async () => { + db = await createScratchDatabase(NOTIFICATION_FANOUT); + await db.query( + `INSERT INTO "Role" ("id", "key", "name", "updatedAt") + VALUES ('role-1', 'MIG_TEST', 'Migration test', now())`, + ); + await db.query( + `INSERT INTO "User" ("id", "email", "passwordHash", "name", "roleId", "isActive", "updatedAt") + VALUES ('user-x', 'x@example.com', 'x', 'x', 'role-1', false, now())`, + ); + await seedNotification(db, { + id: 'broadcast', + userId: null, + relatedType: null, + }); + await seedNotification(db, { + id: 'personal', + userId: 'user-x', + relatedType: null, + }); + + await db.migrateThrough(NOTIFICATION_FANOUT); + + expect(await db.query('SELECT "id" FROM "Notification"')).toEqual([ + { id: 'personal' }, + ]); + }, + TIMEOUT_MS, + ); + + it( + 'copies carry no dedupeKey, old rows get empty requiredPermissions, and the preference defaults hold', + async () => { + db = await createScratchDatabase(NOTIFICATION_FANOUT); + await seedUsers(db); + await seedNotification(db, { + id: 'broadcast', + userId: null, + relatedType: 'COLONY', + }); + await seedNotification(db, { + id: 'personal', + userId: 'user-a', + relatedType: 'COLONY', + }); + + await db.migrateThrough(NOTIFICATION_SETTINGS); + + expect( + await db.query( + `SELECT count(*)::int AS "rows", + count("dedupeKey")::int AS "keys", + count(*) FILTER (WHERE "requiredPermissions" = '{}')::int AS "emptyPermissions", + count("link")::int AS "links" + FROM "Notification"`, + ), + ).toEqual([{ rows: 3, keys: 0, emptyPermissions: 3, links: 0 }]); + + await db.query( + `INSERT INTO "NotificationPreference" ("userId", "updatedAt") VALUES ('user-a', now())`, + ); + expect( + await db.query( + `SELECT "taskDueEnabled", "digestEnabled", "digestTime", "quietFrom", "quietUntil", + "telegramEnabled", "telegramCategories"::text AS "telegramCategories", "telegramDetail" + FROM "NotificationPreference"`, + ), + ).toEqual([ + { + taskDueEnabled: true, + digestEnabled: true, + digestTime: '06:30', + quietFrom: '21:00', + quietUntil: '06:30', + telegramEnabled: false, + telegramCategories: '{TASK_DUE,DIGEST}', + telegramDetail: true, + }, + ]); + }, + TIMEOUT_MS, + ); + + it( + 'legacy job-failure rows (relatedType JobRun) and rows without relatedType get SYSTEM, all others SUGGESTION, before NOT NULL', + async () => { + db = await createScratchDatabase(NOTIFICATION_FANOUT); + await seedUsers(db); + await seedNotification(db, { + id: 'n-job', + userId: 'user-a', + relatedType: 'JobRun', + }); + await seedNotification(db, { + id: 'n-none', + userId: 'user-a', + relatedType: null, + }); + await seedNotification(db, { + id: 'n-colony', + userId: 'user-a', + relatedType: 'COLONY', + }); + await seedNotification(db, { + id: 'n-broadcast', + userId: null, + relatedType: 'JobRun', + }); + + await db.migrateThrough(NOTIFICATION_SETTINGS); + + expect( + await db.query( + `SELECT "id", "category" FROM "Notification" WHERE "id" LIKE 'n-%' ORDER BY "id"`, + ), + ).toEqual([ + { id: 'n-colony', category: 'SUGGESTION' }, + { id: 'n-job', category: 'SYSTEM' }, + { id: 'n-none', category: 'SYSTEM' }, + ]); + // The broadcast copies carry relatedType JobRun too. + expect( + await db.query( + `SELECT DISTINCT "category" FROM "Notification" WHERE "relatedType" = 'JobRun' AND "id" NOT LIKE 'n-%'`, + ), + ).toEqual([{ category: 'SYSTEM' }]); + expect( + await db.query( + `SELECT is_nullable FROM information_schema.columns + WHERE table_schema = current_schema() AND table_name = 'Notification' AND column_name = 'category'`, + ), + ).toEqual([{ is_nullable: 'NO' }]); + }, + TIMEOUT_MS, + ); + + it( + 'occupied frames migration leaves strength untouched', + async () => { + db = await createScratchDatabase(OCCUPIED_FRAMES); + await seedColony(db); + await db.query(`UPDATE "Colony" SET "strength" = 7`); + + await db.migrateThrough(OCCUPIED_FRAMES); + + expect( + await db.query('SELECT "strength", "occupiedFrames" FROM "Colony"'), + ).toEqual([{ strength: 7, occupiedFrames: null }]); + await db.query(`UPDATE "Colony" SET "occupiedFrames" = 40`); + await expect( + db.query(`UPDATE "Colony" SET "occupiedFrames" = 41`), + ).rejects.toThrow(); + await expect( + db.query(`UPDATE "Colony" SET "occupiedFrames" = -1`), + ).rejects.toThrow(); + }, + TIMEOUT_MS, + ); }); diff --git a/ApiCore-Backend/test/treatments.e2e-spec.ts b/ApiCore-Backend/test/treatments.e2e-spec.ts index 3b63e83..60b0262 100644 --- a/ApiCore-Backend/test/treatments.e2e-spec.ts +++ b/ApiCore-Backend/test/treatments.e2e-spec.ts @@ -260,7 +260,7 @@ describe('Treatments (e2e)', () => { it('accepts a full ISO timestamp', async () => { await expect( - withdrawalStatusAt('2026-07-14T23:59:59.000Z'), + withdrawalStatusAt('2026-07-14T23:59:59+02:00'), ).resolves.toHaveLength(1); }); diff --git a/ApiCore-Backend/test/utils/suggestions.ts b/ApiCore-Backend/test/utils/suggestions.ts new file mode 100644 index 0000000..9382149 --- /dev/null +++ b/ApiCore-Backend/test/utils/suggestions.ts @@ -0,0 +1,25 @@ +import { INestApplicationContext } from '@nestjs/common'; +import type { Suggestion } from '../../generated/prisma'; +import { PrismaService } from '../../src/prisma/prisma.service'; +import { SuggestionsService } from '../../src/suggestions/suggestions.service'; +import type { ProposeInput } from '../../src/suggestions/suggestion.types'; + +/** + * Proposes a suggestion in its own transaction, as a rule handler would. + * Throws when nothing was proposed (anchor or intent already taken), so a + * test never works with a suggestion that does not exist. + */ +export async function proposeForTest( + app: INestApplicationContext, + input: ProposeInput, +): Promise { + const prisma = app.get(PrismaService); + const service = app.get(SuggestionsService); + const created = await prisma.$transaction((tx) => + service.proposeIn(tx, input), + ); + if (created === null) { + throw new Error(`proposeForTest: nothing proposed for ${input.anchor}`); + } + return created; +} diff --git a/ApiCore-Backend/test/varroa-seasonal.e2e-spec.ts b/ApiCore-Backend/test/varroa-seasonal.e2e-spec.ts new file mode 100644 index 0000000..ce17404 --- /dev/null +++ b/ApiCore-Backend/test/varroa-seasonal.e2e-spec.ts @@ -0,0 +1,224 @@ +import { INestApplication } from '@nestjs/common'; +import * as bcrypt from 'bcryptjs'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import { PrismaService } from '../src/prisma/prisma.service'; +import { createTestApp } from './utils/create-test-app'; +import { login } from './utils/api'; + +interface BandBody { + yellowFrom: number | null; + redAbove: number; + reason: string; + youngColony: boolean; +} + +interface ThresholdsBody { + day: string; + active: boolean; + bands: Record; + postTreatmentUntil: string | null; +} + +interface RatedMeasurementBody { + id: string; + rating: { + light: string | null; + reasonCode: string | null; + band: BandBody | null; + postTreatmentUntil: string | null; + }; +} + +describe('Varroa seasonal bands (e2e)', () => { + let app: INestApplication; + let prisma: PrismaService; + const suffix = Date.now(); + const email = `varroa-seasonal-${suffix}@example.com`; + const password = 'SuperSecret123!'; + let userId: string; + let token: string; + let apiaryId: string; + let productionColonyId: string; + let youngColonyId: string; + + const get = (path: string) => + request(app.getHttpServer()) + .get(`/api/v1${path}`) + .set('Authorization', `Bearer ${token}`); + + beforeAll(async () => { + app = await createTestApp(); + prisma = app.get(PrismaService); + const user = await prisma.user.create({ + data: { + email, + passwordHash: await bcrypt.hash(password, 12), + name: 'Varroa seasonal', + role: { connect: { key: 'BEEKEEPER' } }, + }, + }); + userId = user.id; + token = (await login(app, email, password)).accessToken; + apiaryId = ( + await prisma.apiary.create({ data: { name: `Stand ${suffix}` } }) + ).id; + productionColonyId = ( + await prisma.colony.create({ + data: { + code: `VS-P-${suffix}`, + apiaryId, + colonyType: 'PRODUCTION_COLONY', + }, + }) + ).id; + youngColonyId = ( + await prisma.colony.create({ + data: { code: `VS-Y-${suffix}`, apiaryId, colonyType: 'YOUNG_COLONY' }, + }) + ).id; + }); + + afterAll(async () => { + const colonyIds = [productionColonyId, youngColonyId]; + await prisma.treatment.deleteMany({ + where: { colonyId: { in: colonyIds } }, + }); + await prisma.varroaMeasurement.deleteMany({ + where: { colonyId: { in: colonyIds } }, + }); + await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } }); + await prisma.apiary.deleteMany({ where: { id: apiaryId } }); + await prisma.refreshToken.deleteMany({ where: { userId } }); + await prisma.auditLog.deleteMany({ where: { userId } }); + await prisma.user.deleteMany({ where: { id: userId } }); + await app.close(); + }); + + it('thresholds for a young colony are halved', async () => { + const full = ( + await get( + `/varroa-measurements/thresholds?colonyId=${productionColonyId}&date=2026-06-10`, + ).expect(200) + ).body as ThresholdsBody; + const young = ( + await get( + `/varroa-measurements/thresholds?colonyId=${youngColonyId}&date=2026-06-10`, + ).expect(200) + ).body as ThresholdsBody; + + expect(full.bands.NATURAL_MITE_FALL).toMatchObject({ + yellowFrom: 5, + redAbove: 10, + reason: 'NATURAL_FALL_SEASON', + youngColony: false, + }); + expect(young.bands.NATURAL_MITE_FALL).toMatchObject({ + yellowFrom: 2.5, + redAbove: 5, + youngColony: true, + }); + }); + + it('thresholds return no band for powdered sugar in April', async () => { + const body = ( + await get( + `/varroa-measurements/thresholds?colonyId=${productionColonyId}&date=2026-04-10`, + ).expect(200) + ).body as ThresholdsBody; + + expect(body.bands.POWDERED_SUGAR).toBeUndefined(); + expect(body.bands.NATURAL_MITE_FALL).toBeDefined(); + }); + + it('thresholds reject an unknown colony and an invalid date', async () => { + await get('/varroa-measurements/thresholds?colonyId=unknown').expect(404); + await get('/varroa-measurements/thresholds?date=2026-02-30').expect(400); + await get('/varroa-measurements/thresholds?date=10.08.2026').expect(400); + }); + + it('the old /threshold route is gone (404)', async () => { + await get('/varroa-measurements/threshold').expect(404); + }); + + it('colony measurements carry a rating with band and light', async () => { + const create = (body: object) => + request(app.getHttpServer()) + .post('/api/v1/varroa-measurements') + .set('Authorization', `Bearer ${token}`) + .send(body) + .expect(201); + const red = ( + await create({ + colonyId: productionColonyId, + date: '2026-08-10T00:00:00.000Z', + method: 'NATURAL_MITE_FALL', + miteCount: 22, + durationMinutes: 1440, + }) + ).body as { id: string; calculatedValue: number }; + const percent = ( + await create({ + colonyId: productionColonyId, + date: '2026-07-10T00:00:00.000Z', + method: 'POWDERED_SUGAR', + miteCount: 10, + sampleBees: 500, + }) + ).body as { id: string; calculatedValue: number }; + expect(percent.calculatedValue).toBe(2); + + const rated = ( + await get(`/colonies/${productionColonyId}/varroa-measurements`).expect( + 200, + ) + ).body as RatedMeasurementBody[]; + + const byId = new Map(rated.map((entry) => [entry.id, entry.rating])); + expect(byId.get(red.id)).toMatchObject({ + light: 'red', + reasonCode: 'NATURAL_FALL_SEASON', + band: { yellowFrom: 5, redAbove: 10 }, + postTreatmentUntil: null, + }); + expect(byId.get(percent.id)).toMatchObject({ + light: 'yellow', + reasonCode: 'INFESTATION_SUMMER', + }); + }); + + it('a measurement after a treatment has no light and names the recheck day', async () => { + await prisma.treatment.create({ + data: { + colonyId: youngColonyId, + userId, + date: new Date('2026-08-02T00:00:00.000Z'), + startDate: new Date('2026-07-20T00:00:00.000Z'), + product: 'Oxuvar', + }, + }); + const created = ( + await request(app.getHttpServer()) + .post('/api/v1/varroa-measurements') + .set('Authorization', `Bearer ${token}`) + .send({ + colonyId: youngColonyId, + date: '2026-08-07T00:00:00.000Z', + method: 'NATURAL_MITE_FALL', + calculatedValue: 12, + }) + .expect(201) + ).body as { id: string }; + + const rated = ( + await get(`/colonies/${youngColonyId}/varroa-measurements`).expect(200) + ).body as RatedMeasurementBody[]; + + expect(rated.find((entry) => entry.id === created.id)?.rating).toEqual({ + light: 'POST_TREATMENT', + reasonCode: 'POST_TREATMENT', + band: null, + postTreatmentUntil: '2026-08-16', + }); + }); +}); diff --git a/ApiCore-Backend/test/varroa.e2e-spec.ts b/ApiCore-Backend/test/varroa.e2e-spec.ts index af0bc8b..dd70abb 100644 --- a/ApiCore-Backend/test/varroa.e2e-spec.ts +++ b/ApiCore-Backend/test/varroa.e2e-spec.ts @@ -232,28 +232,27 @@ describe('Varroa measurements (e2e)', () => { expect(measurements.some((entry) => entry.id === measurementId)).toBe(true); }); - describe('GET /varroa-measurements/threshold', () => { - const thresholdBody = (token: string) => + describe('GET /varroa-measurements/thresholds', () => { + const thresholdsBody = (token: string, query = '') => request(app.getHttpServer()) - .get('/api/v1/varroa-measurements/threshold') + .get(`/api/v1/varroa-measurements/thresholds${query}`) .set('Authorization', `Bearer ${token}`); - it('returns the threshold shape and is not shadowed by :id', async () => { - const response = await thresholdBody(staffToken).expect(200); + it('returns the bands shape and is not shadowed by :id', async () => { + const response = await thresholdsBody(staffToken).expect(200); - const body = response.body as { thresholdMitesPerDay: number | null }; - expect(Object.keys(body)).toEqual(['thresholdMitesPerDay']); - expect( - body.thresholdMitesPerDay === null || - typeof body.thresholdMitesPerDay === 'number', - ).toBe(true); + const body = response.body as { day: string; active: boolean }; + expect(Object.keys(body).sort()).toEqual( + ['active', 'bands', 'day', 'postTreatmentUntil'].sort(), + ); + expect(typeof body.active).toBe('boolean'); }); // The shared rule is never written here (parallel e2e workers); the - // inactive / custom value cases are covered by varroa-threshold.spec.ts. + // inactive / custom value cases are covered by the unit specs. it('rejects a role without any varroa permission', () => - thresholdBody(noVarroaToken).expect(403)); + thresholdsBody(noVarroaToken).expect(403)); }); describe('POST /varroa-measurements/bulk', () => {