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

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

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

No files matched your search

+166
View File
@@ -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.<anonymous> (../src/varroa/varroa.controller.ts:10:1)
at Object.<anonymous> (../src/varroa/varroa.module.ts:2:1)
at Object.<anonymous> (../src/app.module.ts:20:1)
at Object.<anonymous> (utils/create-test-app.ts:6:1)
at Object.<anonymous> (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.<anonymous> (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.<anonymous> (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.<anonymous> (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.<anonymous> (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.<anonymous> (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.<anonymous> (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.<anonymous> (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.
@@ -0,0 +1,33 @@
-- T4 migration 8/n: every notification belongs to exactly one user. A
-- broadcast (userId IS NULL) is copied once per active user with its read
-- flag and creation time, then the original is deleted. After that userId is
-- NOT NULL; dedupeKey ("<prefix>:<userId>") makes repeated writes harmless.
-- Check afterwards: SELECT count(*) FROM "Notification" WHERE "userId" IS NULL;
-- must be 0 (the SET NOT NULL below fails otherwise).
-- DropForeignKey (was ON DELETE SET NULL, impossible for a NOT NULL column)
ALTER TABLE "Notification" DROP CONSTRAINT "Notification_userId_fkey";
-- AlterTable
ALTER TABLE "Notification" ADD COLUMN "dedupeKey" TEXT;
-- Fan-out of the broadcasts to ALL active users (isActive only, no role or
-- permission filter): a broadcast was visible to every user before, so the
-- copies keep exactly that visibility. With no active user, broadcasts are
-- dropped. The copies get no dedupeKey and the default requiredPermissions.
INSERT INTO "Notification" ("id", "userId", "priority", "message", "relatedType", "relatedId", "read", "createdAt")
SELECT gen_random_uuid()::text, u."id", n."priority", n."message", n."relatedType", n."relatedId", n."read", n."createdAt"
FROM "Notification" n
CROSS JOIN "User" u
WHERE n."userId" IS NULL AND u."isActive" = true;
DELETE FROM "Notification" WHERE "userId" IS NULL;
-- AlterTable
ALTER TABLE "Notification" ALTER COLUMN "userId" SET NOT NULL;
-- CreateIndex
CREATE UNIQUE INDEX "Notification_dedupeKey_key" ON "Notification"("dedupeKey");
-- AddForeignKey
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,5 @@
-- T4 migration 9/n: switch for the withdrawal-period entries of the calendar
-- sync (on by default, like the other sources).
-- AlterTable
ALTER TABLE "CalendarSyncSettings" ADD COLUMN "syncWithdrawal" BOOLEAN NOT NULL DEFAULT true;
@@ -0,0 +1,11 @@
-- T4 migration 10/n: Colony.occupiedFrames, the number of occupied frames
-- (0-40, same unit and bounds as Inspection.colonyStrength). Colony.strength
-- (scale 1-10) is left untouched, nothing is converted.
--
-- Check BEFORE the deploy (hits must be clarified with the owner, never cut):
-- SELECT count(*) FROM "Inspection" WHERE "colonyStrength" > 40;
-- AlterTable
ALTER TABLE "Colony" ADD COLUMN "occupiedFrames" INTEGER;
ALTER TABLE "Colony" ADD CONSTRAINT "Colony_occupiedFrames_range" CHECK ("occupiedFrames" BETWEEN 0 AND 40);
@@ -0,0 +1,39 @@
-- T4 migration 11/n (part category, link, permissions, preferences). Runs
-- after migration 8. category of the old rows: job failures carry
-- relatedType 'JobRun' (and rows without relatedType are system messages) ->
-- SYSTEM, everything else -> SUGGESTION. Only then NOT NULL.
-- CreateEnum
CREATE TYPE "NotificationCategory" AS ENUM ('TASK_DUE', 'DIGEST', 'SUGGESTION', 'WITHDRAWAL', 'SEASON', 'SYSTEM');
-- AlterTable
ALTER TABLE "Notification" ADD COLUMN "category" "NotificationCategory",
ADD COLUMN "link" TEXT,
ADD COLUMN "requiredPermissions" TEXT[] DEFAULT ARRAY[]::TEXT[];
UPDATE "Notification"
SET "category" = CASE
WHEN "relatedType" = 'JobRun' OR "relatedType" IS NULL THEN 'SYSTEM'::"NotificationCategory"
ELSE 'SUGGESTION'::"NotificationCategory"
END;
ALTER TABLE "Notification" ALTER COLUMN "category" SET NOT NULL;
-- CreateTable
CREATE TABLE "NotificationPreference" (
"userId" TEXT NOT NULL,
"taskDueEnabled" BOOLEAN NOT NULL DEFAULT true,
"digestEnabled" BOOLEAN NOT NULL DEFAULT true,
"digestTime" TEXT NOT NULL DEFAULT '06:30',
"quietFrom" TEXT DEFAULT '21:00',
"quietUntil" TEXT DEFAULT '06:30',
"telegramEnabled" BOOLEAN NOT NULL DEFAULT false,
"telegramCategories" "NotificationCategory"[] DEFAULT ARRAY['TASK_DUE', 'DIGEST']::"NotificationCategory"[],
"telegramDetail" BOOLEAN NOT NULL DEFAULT true,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "NotificationPreference_pkey" PRIMARY KEY ("userId")
);
-- AddForeignKey
ALTER TABLE "NotificationPreference" ADD CONSTRAINT "NotificationPreference_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+36 -2
View File
@@ -125,6 +125,7 @@ model User {
batchMovements BatchMovement[] batchMovements BatchMovement[]
rawMaterialLots RawMaterialLot[] rawMaterialLots RawMaterialLot[]
notifications Notification[] notifications Notification[]
notificationPreference NotificationPreference?
queenEvaluations QueenEvaluation[] queenEvaluations QueenEvaluation[]
webhookSubscriptions WebhookSubscription[] webhookSubscriptions WebhookSubscription[]
documents Document[] documents Document[]
@@ -291,6 +292,7 @@ model Colony {
colonyType ColonyType colonyType ColonyType
status ColonyStatus @default(ACTIVE) status ColonyStatus @default(ACTIVE)
strength Int? strength Int?
occupiedFrames Int?
broodStatus ColonyCondition? broodStatus ColonyCondition?
feedStatus ColonyCondition? feedStatus ColonyCondition?
healthStatus ColonyCondition? healthStatus ColonyCondition?
@@ -1374,14 +1376,30 @@ enum NotificationPriority {
KRITISCH 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 = "<praefix>:<userId>".
model Notification { model Notification {
id String @id @default(cuid()) id String @id @default(cuid())
userId String? userId String
user User? @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
priority NotificationPriority priority NotificationPriority
category NotificationCategory
message String message String
relatedType String? relatedType String?
relatedId String? relatedId String?
link String?
requiredPermissions String[] @default([])
dedupeKey String? @unique
read Boolean @default(false) read Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -1389,6 +1407,21 @@ model Notification {
@@index([priority]) @@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 // Webhooks (Abschnitt 27) – v1.x Modul 28
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1574,6 +1607,7 @@ model CalendarSyncSettings {
syncFeeding Boolean @default(true) syncFeeding Boolean @default(true)
syncHarvest Boolean @default(true) syncHarvest Boolean @default(true)
syncQueenAssignment Boolean @default(true) syncQueenAssignment Boolean @default(true)
syncWithdrawal Boolean @default(true)
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
@@ -189,7 +189,7 @@ describe('ApiaryFieldService', () => {
relatedId: { in: ['c2', 'c10'] }, relatedId: { in: ['c2', 'c10'] },
read: false, read: false,
priority: { in: ['WARNUNG', 'KRITISCH'] }, priority: { in: ['WARNUNG', 'KRITISCH'] },
OR: [{ userId: 'u1' }, { userId: null }], userId: 'u1',
}, },
}), }),
); );
@@ -146,7 +146,7 @@ export class ApiaryFieldService {
relatedId: { in: colonies.map((colony) => colony.id) }, relatedId: { in: colonies.map((colony) => colony.id) },
read: false, read: false,
priority: { in: ['WARNUNG', 'KRITISCH'] }, priority: { in: ['WARNUNG', 'KRITISCH'] },
OR: [{ userId }, { userId: null }], userId,
}, },
_count: { _all: true }, _count: { _all: true },
}); });
@@ -45,7 +45,7 @@ describe('AutomationService', () => {
$connect: jest.Mock; $connect: jest.Mock;
}; };
let auditLog: { record: jest.Mock }; let auditLog: { record: jest.Mock };
let notifications: { record: jest.Mock }; let notifications: { recordForPermission: jest.Mock };
let tasks: { createInTx: jest.Mock }; let tasks: { createInTx: jest.Mock };
const queenlessRule: Rule = { const queenlessRule: Rule = {
@@ -102,7 +102,9 @@ describe('AutomationService', () => {
$connect: jest.fn(), $connect: jest.fn(),
}; };
auditLog = { record: jest.fn().mockResolvedValue(undefined) }; 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' }) }; tasks = { createInTx: jest.fn().mockResolvedValue({ id: 'task-1' }) };
service = new AutomationService( service = new AutomationService(
prisma as unknown as PrismaService, prisma as unknown as PrismaService,
@@ -258,7 +260,9 @@ describe('AutomationService', () => {
staged.push(client === tx ? 'task' : 'task outside tx'); staged.push(client === tx ? 'task' : 'task outside tx');
return Promise.resolve({ id: 'task-1' }); 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) => { auditLog.record.mockImplementation((_entry: unknown, client: unknown) => {
staged.push(client === tx ? 'audit' : 'audit outside tx'); staged.push(client === tx ? 'audit' : 'audit outside tx');
return Promise.resolve(); return Promise.resolve();
@@ -303,9 +307,11 @@ describe('AutomationService', () => {
}, },
null, null,
); );
expect(notifications.record).toHaveBeenCalledWith( expect(notifications.recordForPermission).toHaveBeenCalledWith(
'tasks:read',
{ {
priority: 'WARNUNG', priority: 'WARNUNG',
category: 'SUGGESTION',
message: 'Kontrolle empfohlen', message: 'Kontrolle empfohlen',
relatedType: 'COLONY', relatedType: 'COLONY',
relatedId: 'colony-1', 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 () => { 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( await expect(
service.evaluateInspectionIn(asTx(), queenlessInspection as never), service.evaluateInspectionIn(asTx(), queenlessInspection as never),
@@ -355,7 +363,7 @@ describe('AutomationService', () => {
select: { id: true }, select: { id: true },
}); });
expect(tasks.createInTx).not.toHaveBeenCalled(); expect(tasks.createInTx).not.toHaveBeenCalled();
expect(notifications.record).not.toHaveBeenCalled(); expect(notifications.recordForPermission).not.toHaveBeenCalled();
expect(auditLog.record).not.toHaveBeenCalled(); expect(auditLog.record).not.toHaveBeenCalled();
}); });
@@ -403,7 +411,7 @@ describe('AutomationService', () => {
await expect( await expect(
service.evaluateInspectionIn(asTx(), queenlessInspection as never), service.evaluateInspectionIn(asTx(), queenlessInspection as never),
).rejects.toThrow('tasks down'); ).rejects.toThrow('tasks down');
expect(notifications.record).not.toHaveBeenCalled(); expect(notifications.recordForPermission).not.toHaveBeenCalled();
expect(auditLog.record).not.toHaveBeenCalled(); expect(auditLog.record).not.toHaveBeenCalled();
}); });
}); });
@@ -426,7 +434,7 @@ describe('AutomationService', () => {
await service.evaluateInspectionIn(asTx(), queenlessInspection as never); await service.evaluateInspectionIn(asTx(), queenlessInspection as never);
expect(tasks.createInTx).toHaveBeenCalledTimes(1); 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 () => { it('does not trigger when the queen was seen', async () => {
@@ -600,8 +600,16 @@ export class AutomationService {
}, },
null, null,
); );
await this.notifications.record( // Task 7 replaces this path by suggestions.
{ priority: 'WARNUNG', message: title, relatedType, relatedId }, await this.notifications.recordForPermission(
'tasks:read',
{
priority: 'WARNUNG',
category: 'SUGGESTION',
message: title,
relatedType,
relatedId,
},
tx, tx,
); );
await this.auditLog.record( await this.auditLog.record(
@@ -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();
});
});
});
@@ -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<Record<VarroaMethod, VarroaBand>>;
/** 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<VarroaMeasurement, 'date' | 'method' | 'calculatedValue'>,
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,
};
}
@@ -72,6 +72,7 @@ const ALL_ENTITY_KEYS = [
...BUSINESS_ENTITY_KEYS, ...BUSINESS_ENTITY_KEYS,
'document', 'document',
'notification', 'notification',
'notificationPreference',
'auditLog', 'auditLog',
] as const; ] as const;
@@ -113,6 +114,10 @@ describe('BackupService', () => {
upsert: jest.fn().mockResolvedValue({ id: `${key}-upserted` }), 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.update = jest.fn().mockResolvedValue({});
tx.queen.findMany = jest.fn().mockResolvedValue([]); tx.queen.findMany = jest.fn().mockResolvedValue([]);
tx.document.findUnique = jest.fn().mockResolvedValue(null); 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 // 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. // in MODELS_EXCLUDED_FROM_BACKUP with its reason - never silently missing.
describe('model coverage', () => { describe('model coverage', () => {
+99 -13
View File
@@ -71,7 +71,8 @@ export interface BackupImportSummary {
* - siteSettings: keeper name/address printed on the Bestandsbuch and the * - siteSettings: keeper name/address printed on the Bestandsbuch and the
* public links. * public links.
* - document: writes files to the server's disk. * - 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 = [ export const LEGACY_UNSEALED_TABLES = [
'apiary', '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 { function documentsStorageRoot(): string {
return process.env.DOCUMENTS_STORAGE_PATH ?? './uploads'; return process.env.DOCUMENTS_STORAGE_PATH ?? './uploads';
} }
@@ -316,6 +334,8 @@ export class BackupService {
); );
data.notification = await this.prisma.notification.findMany(); data.notification = await this.prisma.notification.findMany();
data.notificationPreference =
await this.prisma.notificationPreference.findMany();
data.auditLog = await this.prisma.auditLog.findMany(); data.auditLog = await this.prisma.auditLog.findMany();
return { return {
@@ -1125,20 +1145,86 @@ export class BackupService {
} }
imported.document = documentCount; 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')) { for (const row of rowsOf('notification')) {
await tx.notification.upsert({ const withCategory = withCurrentNotificationCategory(row);
where: { id: row.id as string }, const rawUserId = asString(row, 'userId');
create: { // A bundle from before the per-user notifications carries
...row, // broadcasts (no userId): one copy per active user, as migration 8.
userId: resolveOptionalUserId(asString(row, 'userId')), // A row of a user this database does not know is skipped - the
} as never, // userId is mandatory and never falls back to somebody else.
update: { const owners =
...row, rawUserId === null
userId: resolveOptionalUserId(asString(row, 'userId')), ? activeUserIds
} as never, : knownUserIds.has(rawUserId)
}); ? [rawUserId]
: [];
if (owners.length === 0) {
notificationsSkipped += 1;
continue;
} }
imported.notification = rowsOf('notification').length; 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 = 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. // Append-only: entries already here are never rewritten by a file.
for (const row of rowsOf('auditLog')) { for (const row of rowsOf('auditLog')) {
@@ -23,6 +23,7 @@ describe('CalendarSyncSettingsService', () => {
syncFeeding: true, syncFeeding: true,
syncHarvest: true, syncHarvest: true,
syncQueenAssignment: true, syncQueenAssignment: true,
syncWithdrawal: true,
updatedAt: new Date('2026-01-01'), updatedAt: new Date('2026-01-01'),
}; };
@@ -87,6 +88,7 @@ describe('CalendarSyncSettingsService', () => {
syncFeeding: undefined, syncFeeding: undefined,
syncHarvest: undefined, syncHarvest: undefined,
syncQueenAssignment: undefined, syncQueenAssignment: undefined,
syncWithdrawal: undefined,
}, },
}); });
expect(result.groupId).toBe('group-abc'); expect(result.groupId).toBe('group-abc');
@@ -149,8 +151,25 @@ describe('CalendarSyncSettingsService', () => {
syncFeeding: undefined, syncFeeding: undefined,
syncHarvest: undefined, syncHarvest: undefined,
syncQueenAssignment: 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,
}),
);
});
}); });
}); });
@@ -47,6 +47,7 @@ export class CalendarSyncSettingsService {
syncFeeding: dto.syncFeeding, syncFeeding: dto.syncFeeding,
syncHarvest: dto.syncHarvest, syncHarvest: dto.syncHarvest,
syncQueenAssignment: dto.syncQueenAssignment, syncQueenAssignment: dto.syncQueenAssignment,
syncWithdrawal: dto.syncWithdrawal,
}, },
}); });
await this.auditLog.record({ await this.auditLog.record({
@@ -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 () => { it('TaskCreated without a due date enqueues nothing', async () => {
await handlers.task( await handlers.task(
envelope('TaskCreated', 'task-1', { dueDate: null }), envelope('TaskCreated', 'task-1', { dueDate: null }),
@@ -105,6 +123,12 @@ describe('CalendarSyncEventHandlers', () => {
); );
expect(registered('feeding')[0].handlerId).toBe('calendar-sync.feeding'); expect(registered('feeding')[0].handlerId).toBe('calendar-sync.feeding');
expect(registered('harvest')[0].handlerId).toBe('calendar-sync.harvest'); expect(registered('harvest')[0].handlerId).toBe('calendar-sync.harvest');
expect(registered('queenAssignment')).toEqual([
{
types: ['QueenAssigned'],
handlerId: 'calendar-sync.queen-assignment',
},
]);
expect(registered('task')).toEqual([ expect(registered('task')).toEqual([
{ {
types: ['TaskCreated', 'TaskUpdated', 'TaskCompleted'], types: ['TaskCreated', 'TaskUpdated', 'TaskCompleted'],
@@ -74,4 +74,17 @@ export class CalendarSyncEventHandlers {
} }
await this.calendarSync.enqueueIn(tx, 'TASK', event.aggregateId); 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<void> {
await this.calendarSync.enqueueIn(
tx,
'QUEEN_ASSIGNMENT',
event.payload.historyId,
);
}
} }
@@ -42,6 +42,7 @@ describe('CalendarSyncService', () => {
syncFeeding: true, syncFeeding: true,
syncHarvest: true, syncHarvest: true,
syncQueenAssignment: true, syncQueenAssignment: true,
syncWithdrawal: true,
updatedAt: new Date(), updatedAt: new Date(),
}; };
@@ -122,11 +123,22 @@ describe('CalendarSyncService', () => {
expect(prisma.calendarSyncOutboxEntry.upsert).not.toHaveBeenCalled(); 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); settingsService.get.mockResolvedValue(enabledSettings);
await service.enqueue('WITHDRAWAL', 'treatment-1'); 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(); expect(prisma.calendarSyncOutboxEntry.upsert).not.toHaveBeenCalled();
}); });
@@ -103,8 +103,7 @@ export class CalendarSyncService {
FEEDING: settings.syncFeeding, FEEDING: settings.syncFeeding,
HARVEST: settings.syncHarvest, HARVEST: settings.syncHarvest,
QUEEN_ASSIGNMENT: settings.syncQueenAssignment, QUEEN_ASSIGNMENT: settings.syncQueenAssignment,
// Switch in the settings arrives with Task 3. WITHDRAWAL: settings.syncWithdrawal,
WITHDRAWAL: false,
}; };
return flagBySourceType[sourceType]; return flagBySourceType[sourceType];
} }
@@ -66,4 +66,9 @@ export class UpdateCalendarSyncSettingsDto {
@IsOptionalNotNull() @IsOptionalNotNull()
@IsBoolean() @IsBoolean()
syncQueenAssignment?: boolean; syncQueenAssignment?: boolean;
@ApiPropertyOptional()
@IsOptionalNotNull()
@IsBoolean()
syncWithdrawal?: boolean;
} }
@@ -4,6 +4,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service'; import { WebhooksService } from '../webhooks/webhooks.service';
import { DomainEventsService } from '../domain-events/domain-events.service'; import { DomainEventsService } from '../domain-events/domain-events.service';
import { berlinToday } from '../common/dates';
describe('ColoniesService', () => { describe('ColoniesService', () => {
let service: ColoniesService; let service: ColoniesService;
@@ -139,6 +140,96 @@ describe('ColoniesService', () => {
expect(lineageArg.data.toColonyId).toBe('colony-2'); 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 () => { it('merge marks the merged colony as MERGED, releases its queen, and records a MERGE lineage entry', async () => {
prisma.colony.findUnique prisma.colony.findUnique
.mockResolvedValueOnce(parentColony) .mockResolvedValueOnce(parentColony)
@@ -502,6 +593,7 @@ describe('ColoniesService', () => {
it('writes the audit entry of create, move and split inside the transaction', async () => { it('writes the audit entry of create, move and split inside the transaction', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony); prisma.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.create.mockResolvedValue({ id: 'colony-2' }); tx.colony.create.mockResolvedValue({ id: 'colony-2' });
tx.colonyLineage.create.mockResolvedValue({ id: 'lineage-1' });
tx.colony.update.mockResolvedValue({ tx.colony.update.mockResolvedValue({
...parentColony, ...parentColony,
apiaryId: 'apiary-2', apiaryId: 'apiary-2',
@@ -17,6 +17,7 @@ import { MergeColonyDto } from './dto/merge-colony.dto';
import { isLivingColonyStatus } from './colony-status'; import { isLivingColonyStatus } from './colony-status';
import { lockRowsForUpdate } from '../prisma/row-lock'; import { lockRowsForUpdate } from '../prisma/row-lock';
import { colonyTransitionEvents, lockColony } from './colony-events'; import { colonyTransitionEvents, lockColony } from './colony-events';
import { berlinToday, dayToDate } from '../common/dates';
export interface ColonyFilter { export interface ColonyFilter {
status?: ColonyStatus; status?: ColonyStatus;
@@ -194,9 +195,16 @@ export class ColoniesService {
dto: SplitColonyDto, dto: SplitColonyDto,
actingUserId: string, actingUserId: string,
): Promise<Colony> { ): Promise<Colony> {
const parent = await this.findById(id); await this.findById(id);
this.assertActive(parent); 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 child = await this.prisma.$transaction(async (tx) => {
const parent = await lockColony(tx, id);
this.assertActive(parent);
const created = await tx.colony.create({ const created = await tx.colony.create({
data: { data: {
code: dto.code, code: dto.code,
@@ -205,12 +213,13 @@ export class ColoniesService {
notes: dto.notes, notes: dto.notes,
}, },
}); });
await tx.colonyLineage.create({ const lineage = await tx.colonyLineage.create({
data: { data: {
type: 'SPLIT', type: 'SPLIT',
fromColonyId: parent.id, fromColonyId: parent.id,
toColonyId: created.id, toColonyId: created.id,
date: new Date(), date: dayToDate(splitOn),
queenSource,
notes: dto.notes, notes: dto.notes,
}, },
}); });
@@ -224,6 +233,18 @@ export class ColoniesService {
}, },
tx, 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 created;
}); });
return child; return child;
@@ -1,6 +1,6 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsOptional, IsString } from 'class-validator'; import { IsEnum, IsOptional, IsString, Matches } from 'class-validator';
import { ColonyType } from '../../../generated/prisma'; import { ColonyType, SplitQueenSource } from '../../../generated/prisma';
export class SplitColonyDto { export class SplitColonyDto {
@ApiProperty() @ApiProperty()
@@ -15,4 +15,16 @@ export class SplitColonyDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
notes?: string; 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;
} }
@@ -43,10 +43,8 @@ describe('legacy after-commit paths', () => {
]); ]);
}); });
it('only the queen assignment still enqueues a calendar entry directly', () => { it('no module enqueues a calendar entry directly any more', () => {
expect(filesCalling('calendarSync.enqueue(')).toEqual([ expect(filesCalling('calendarSync.enqueue(')).toEqual([]);
'src/queens/queens.service.ts',
]);
}); });
it('only the modules without an event still dispatch webhooks directly', () => { it('only the modules without an event still dispatch webhooks directly', () => {
@@ -71,9 +71,13 @@ function orderColumn(column: OutboxOrderColumn): Prisma.Sql {
} }
/** /**
* Claims up to `limit` due PENDING entries, oldest first. SKIP LOCKED lets * Claims up to `limit` due PENDING entries, oldest first. "availableAt" is
* several workers claim side by side without waiting for or overlapping * TIMESTAMP(3), so a value written from now() is rounded to the nearest
* each other. Returns the claimed rows (all columns) in claim order. * 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. * Uses the root client: a claim must commit on its own.
*/ */
export async function claimBatch<Row>( export async function claimBatch<Row>(
@@ -96,7 +100,7 @@ export async function claimBatch<Row>(
UPDATE ${name} SET "status" = 'PROCESSING', "claimedAt" = now() UPDATE ${name} SET "status" = 'PROCESSING', "claimedAt" = now()
WHERE "id" IN ( WHERE "id" IN (
SELECT "id" FROM ${name} 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} ORDER BY ${orderBy}
LIMIT ${options.limit} LIMIT ${options.limit}
FOR UPDATE SKIP LOCKED FOR UPDATE SKIP LOCKED
@@ -15,6 +15,7 @@ import { UpdateHoneyBatchDto } from '../../honey-batches/dto/update-honey-batch.
import { UpdateHoneyMeasurementDto } from '../../honey-batches/dto/update-honey-measurement.dto'; import { UpdateHoneyMeasurementDto } from '../../honey-batches/dto/update-honey-measurement.dto';
import { UpdateInspectionDto } from '../../inspections/dto/update-inspection.dto'; import { UpdateInspectionDto } from '../../inspections/dto/update-inspection.dto';
import { UpdateLabelTemplateDto } from '../../label-templates/dto/update-label-template.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 { UpdateProductionBatchDto } from '../../production-batches/dto/update-production-batch.dto';
import { UpdateProductDto } from '../../products/dto/update-product.dto'; import { UpdateProductDto } from '../../products/dto/update-product.dto';
import { UpdateProfileDto } from '../../profile/dto/update-profile.dto'; import { UpdateProfileDto } from '../../profile/dto/update-profile.dto';
@@ -78,9 +79,23 @@ const contracts: Record<string, NullContract> = {
'syncFeeding', 'syncFeeding',
'syncHarvest', 'syncHarvest',
'syncQueenAssignment', 'syncQueenAssignment',
'syncWithdrawal',
], ],
clearable: ['groupId'], 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: { UpdateBottlingBatchDto: {
dto: UpdateBottlingBatchDto, dto: UpdateBottlingBatchDto,
notNull: [ notNull: [
@@ -173,7 +173,7 @@ describe('DomainEventDispatcher', () => {
`UPDATE "DomainEvent" SET "status" = 'PROCESSING', "claimedAt" = now()`, `UPDATE "DomainEvent" SET "status" = 'PROCESSING', "claimedAt" = now()`,
); );
expect(text(claim)).toContain( expect(text(claim)).toContain(
`WHERE "status" = 'PENDING' AND "availableAt" <= now()`, `WHERE "status" = 'PENDING' AND "availableAt" <= CAST(now() AS timestamp(3))`,
); );
expect(text(claim)).toContain( expect(text(claim)).toContain(
'ORDER BY "occurredAt" LIMIT ? FOR UPDATE SKIP LOCKED', 'ORDER BY "occurredAt" LIMIT ? FOR UPDATE SKIP LOCKED',
@@ -186,7 +186,7 @@ describe('DomainEventDispatcher', () => {
const claim = db.claimQuery(); const claim = db.claimQuery();
expect(text(claim)).toContain( 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]); expect(claim.values).toEqual([['c1', 'c2'], 50]);
}); });
@@ -3,6 +3,7 @@ import {
ColonyStatus, ColonyStatus,
Prisma, Prisma,
RawMaterialKind, RawMaterialKind,
SplitQueenSource,
TaskRelatedType, TaskRelatedType,
TaskStatus, TaskStatus,
} from '../../generated/prisma'; } from '../../generated/prisma';
@@ -68,6 +69,20 @@ export interface DomainEventPayloads {
lastInspectedOn: string | null; lastInspectedOn: string | null;
firstOfYear?: boolean; 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; export type DomainEventType = keyof DomainEventPayloads;
@@ -97,6 +112,8 @@ const CATALOG: Record<DomainEventType, true> = {
QueenAged: true, QueenAged: true,
VarroaMeasurementDue: true, VarroaMeasurementDue: true,
InspectionOverdue: true, InspectionOverdue: true,
ColonySplit: true,
QueenAssigned: true,
}; };
export const DOMAIN_EVENT_TYPES: readonly DomainEventType[] = Object.freeze( export const DOMAIN_EVENT_TYPES: readonly DomainEventType[] = Object.freeze(
@@ -183,7 +183,7 @@ describe('DomainEventsService', () => {
describe('domain event catalog', () => { describe('domain event catalog', () => {
it('lists every event type exactly once', () => { 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(new Set(DOMAIN_EVENT_TYPES).size).toBe(DOMAIN_EVENT_TYPES.length);
expect(DOMAIN_EVENT_TYPES).toEqual( expect(DOMAIN_EVENT_TYPES).toEqual(
expect.arrayContaining([ expect.arrayContaining([
@@ -191,6 +191,8 @@ describe('domain event catalog', () => {
'StockChanged', 'StockChanged',
'TaskDue', 'TaskDue',
'InspectionOverdue', 'InspectionOverdue',
'ColonySplit',
'QueenAssigned',
]), ]),
); );
}); });
@@ -174,10 +174,10 @@ describe('daily evaluation rules', () => {
}), }),
).toBe('2026-10-12')); ).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( expect(
withdrawalEndDay({ withdrawalEndDay({
date: new Date('2026-09-28T22:15:00.000Z'), date: new Date('2026-09-28T22:15:00+02:00'),
endDate: null, endDate: null,
withdrawalPeriodDays: 0, withdrawalPeriodDays: 0,
}), }),
@@ -12,6 +12,7 @@ import { inspectionDueOn } from '../season/inspection-due';
import { isQueenAged } from '../season/queen-age'; import { isQueenAged } from '../season/queen-age';
import type { SeasonService } from '../season/season.service'; import type { SeasonService } from '../season/season.service';
import { varroaMeasurementDue } from '../season/varroa-due'; import { varroaMeasurementDue } from '../season/varroa-due';
import { withdrawalUntilDay } from '../treatments/withdrawal-window';
/** How many days back a run still records an occurrence it missed. */ /** How many days back a run still records an occurrence it missed. */
export const CATCH_UP_DAYS = 7; export const CATCH_UP_DAYS = 7;
@@ -82,10 +83,7 @@ function dayAfter(date: Date, days: number): Date {
export function withdrawalEndDay( export function withdrawalEndDay(
t: Pick<Treatment, 'date' | 'endDate' | 'withdrawalPeriodDays'>, t: Pick<Treatment, 'date' | 'endDate' | 'withdrawalPeriodDays'>,
): string | null { ): string | null {
if (t.withdrawalPeriodDays === null) { return withdrawalUntilDay(t);
return null;
}
return utcDay(dayAfter(t.endDate ?? t.date, t.withdrawalPeriodDays));
} }
/** True for a 'YYYY-MM-DD' day from today − CATCH_UP_DAYS up to today. */ /** True for a 'YYYY-MM-DD' day from today − CATCH_UP_DAYS up to today. */
@@ -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;
}
@@ -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/);
}
}
});
});
@@ -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}`;
}
@@ -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<unknown>) =>
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<string, unknown> }],
];
expect(arg.data).not.toHaveProperty('telegramEnabled');
expect(arg.data.telegramCategories).toEqual(['SYSTEM']);
});
});
@@ -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<NotificationPreference> {
return db.notificationPreference.upsert({
where: { userId },
create: { userId },
update: {},
});
}
async update(
userId: string,
dto: UpdateNotificationSettingsDto,
): Promise<NotificationPreference> {
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,
},
});
});
}
}
@@ -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<NotificationSettingsView> {
return toNotificationSettingsView(
await this.preferences.getOrCreate(actor.id),
);
}
@Patch()
async update(
@Body() dto: UpdateNotificationSettingsDto,
@CurrentUser() actor: AuthenticatedUser,
): Promise<NotificationSettingsView> {
return toNotificationSettingsView(
await this.preferences.update(actor.id, dto),
);
}
}
@@ -13,16 +13,21 @@ export class NotificationsController {
constructor(private readonly notificationsService: NotificationsService) {} constructor(private readonly notificationsService: NotificationsService) {}
@Get() @Get()
findAll( findOwn(
@CurrentUser() actor: AuthenticatedUser, @CurrentUser() actor: AuthenticatedUser,
@Query('unreadOnly') unreadOnly?: string, @Query('unreadOnly') unreadOnly?: string,
) { ) {
return this.notificationsService.findAll({ return this.notificationsService.findOwn(actor.id, {
userId: actor.id,
unreadOnly: unreadOnly === 'true', 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') @Post(':id/read')
markRead(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) { markRead(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
return this.notificationsService.markRead(id, actor.id); return this.notificationsService.markRead(id, actor.id);
@@ -1,10 +1,12 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { NotificationPreferencesService } from './notification-preferences.service';
import { NotificationSettingsController } from './notification-settings.controller';
import { NotificationsController } from './notifications.controller'; import { NotificationsController } from './notifications.controller';
import { NotificationsService } from './notifications.service'; import { NotificationsService } from './notifications.service';
@Module({ @Module({
controllers: [NotificationsController], controllers: [NotificationsController, NotificationSettingsController],
providers: [NotificationsService], providers: [NotificationsService, NotificationPreferencesService],
exports: [NotificationsService], exports: [NotificationsService, NotificationPreferencesService],
}) })
export class NotificationsModule {} export class NotificationsModule {}
@@ -1,18 +1,30 @@
import { ForbiddenException, NotFoundException } from '@nestjs/common'; import { NotFoundException } from '@nestjs/common';
import { NotificationsService } from './notifications.service'; import { NotificationsService } from './notifications.service';
import { assertAppLink } from './notification-link';
import { PrismaService } from '../prisma/prisma.service'; 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', () => { describe('NotificationsService', () => {
let service: NotificationsService; let service: NotificationsService;
let held: Set<string>;
let prisma: { let prisma: {
notification: { notification: {
create: jest.Mock; createManyAndReturn: jest.Mock;
createMany: jest.Mock;
findMany: jest.Mock; findMany: jest.Mock;
findUnique: jest.Mock; findFirstOrThrow: jest.Mock;
update: jest.Mock; updateMany: jest.Mock;
}; };
user: { findMany: jest.Mock }; user: { findMany: jest.Mock };
}; };
@@ -20,177 +32,375 @@ describe('NotificationsService', () => {
beforeEach(() => { beforeEach(() => {
prisma = { prisma = {
notification: { notification: {
create: jest.fn(), createManyAndReturn: jest
createMany: jest
.fn() .fn()
.mockImplementation(({ data }: CreateManyArg) => .mockImplementation(({ data }: CreateManyArg) =>
Promise.resolve({ count: data.length }), Promise.resolve(data.map((row, i) => ({ id: `n${i}`, ...row }))),
), ),
findMany: jest.fn(), findMany: jest.fn().mockResolvedValue([]),
findUnique: jest.fn(), findFirstOrThrow: jest.fn(),
update: jest.fn(), updateMany: jest.fn(),
}, },
user: { findMany: jest.fn().mockResolvedValue([]) }, user: { findMany: jest.fn().mockResolvedValue([]) },
}; };
service = new NotificationsService(prisma as unknown as PrismaService); held = new Set();
service = new NotificationsService(
prisma as unknown as PrismaService,
{
getPermissions: jest
.fn()
.mockImplementation(() => Promise.resolve(held)),
} as unknown as PermissionResolverService,
);
}); });
it('records a notification with the given priority and message', async () => { const lastCreate = () =>
const created = { (
id: 'n1', prisma.notification.createManyAndReturn.mock.calls[0] as [CreateManyArg]
priority: 'KRITISCH', )[0];
message: 'Keine Königin gesehen',
}; it('record without userId does not compile', () => {
prisma.notification.create.mockResolvedValueOnce(created); // @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({ const result = await service.record({
userId: 'u1',
priority: 'KRITISCH', priority: 'KRITISCH',
category: 'SYSTEM',
message: 'Keine Königin gesehen', message: 'Keine Königin gesehen',
dedupeKey: 'k:u1',
}); });
expect(result).toEqual(created); expect(result).toMatchObject({ userId: 'u1', dedupeKey: 'k:u1' });
expect(prisma.notification.create).toHaveBeenCalledWith({ expect(lastCreate().skipDuplicates).toBe(true);
data: { priority: 'KRITISCH', message: 'Keine Königin gesehen' }, 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('records through the transaction client when one is given', async () => { it('returns null for a recipient without the permission', async () => {
const tx = { notification: { create: jest.fn() } }; 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( await service.record(
{ priority: 'HINWEIS', message: 'Aufgabe fällig' }, { userId: 'u1', priority: 'INFO', category: 'SYSTEM', message: 'm' },
tx as never, tx as never,
); );
expect(tx.notification.create).toHaveBeenCalledTimes(1); expect(tx.notification.createManyAndReturn).toHaveBeenCalledTimes(1);
expect(prisma.notification.create).not.toHaveBeenCalled(); 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', () => { 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' }]); prisma.user.findMany.mockResolvedValue([{ id: 'u1' }, { id: 'u2' }]);
await expect( const rows = await service.recordForPermission('backup:configure', {
service.recordForPermission('backup:configure', { ...input,
priority: 'WARNUNG', dedupeKeyPrefix: 'p',
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',
});
}); });
it('selects recipients like the permission guard: active user, active role, granted key', async () => { expect(rows).toHaveLength(2);
await service.recordForPermission('backup:configure', { expect(lastCreate().data.map((r) => [r.userId, r.dedupeKey])).toEqual([
priority: 'WARNUNG', ['u1', 'p:u1'],
message: 'x', ['u2', 'p:u2'],
]);
}); });
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({ expect(prisma.user.findMany).toHaveBeenCalledWith({
where: { where: {
isActive: true, isActive: true,
role: { role: {
status: 'ACTIVE', status: 'ACTIVE',
AND: [
{
permissions: { permissions: {
some: { permission: { key: 'backup:configure' } }, some: { permission: { key: 'backup:configure' } },
}, },
}, },
{
permissions: { some: { permission: { key: 'colonies:read' } } },
},
],
},
}, },
select: { id: true }, 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( await expect(
service.recordForPermission('backup:configure', { service.recordForPermission('backup:configure', {
priority: 'WARNUNG', ...input,
message: 'x', dedupeKeyPrefix: 'p',
}), }),
).resolves.toBe(0); ).resolves.toEqual([]);
expect(prisma.notification.createMany).not.toHaveBeenCalled();
}); });
it('reads and writes through the transaction client when one is given', async () => { it('reads and writes through the transaction client when one is given', async () => {
const tx = { const tx = {
user: { findMany: jest.fn().mockResolvedValue([{ id: 'u1' }]) }, user: { findMany: jest.fn().mockResolvedValue([{ id: 'u1' }]) },
notification: { notification: {
createMany: jest.fn().mockResolvedValue({ count: 1 }), createManyAndReturn: jest.fn().mockResolvedValue([{ id: 'n1' }]),
}, },
}; };
await expect( await expect(
service.recordForPermission( service.recordForPermission('backup:configure', input, tx as never),
'backup:configure', ).resolves.toHaveLength(1);
{ priority: 'WARNUNG', message: 'x' },
tx as never,
),
).resolves.toBe(1);
expect(tx.notification.createMany).toHaveBeenCalledTimes(1);
expect(prisma.user.findMany).not.toHaveBeenCalled(); 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 () => { describe('recordForUsers', () => {
await service.findAll({ userId: 'user-1' }); it('skips users missing a required permission', async () => {
prisma.user.findMany.mockResolvedValue([{ id: 'u2' }]);
const [[queryArg]] = prisma.notification.findMany.mock.calls as [ const rows = await service.recordForUsers(['u1', 'u2'], {
[{ where: { OR?: { userId: string | null }[] } }], priority: 'INFO',
category: 'SUGGESTION',
message: 'm',
requiredPermissions: ['colonies:read'],
});
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('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();
});
});
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,
}),
);
});
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');
const views = await service.findOwn('user-1');
expect(views.map((view) => view.id)).toEqual(['open', 'kept']);
expect(views[0]).not.toHaveProperty('requiredPermissions');
});
it('selects the view fields plus the permission list for filtering, never the dedupe key', async () => {
await service.findOwn('user-1');
const [[arg]] = prisma.notification.findMany.mock.calls as [
[{ select: Record<string, true> }],
]; ];
expect(queryArg.where.OR).toEqual([{ userId: 'user-1' }, { userId: null }]); expect(Object.keys(arg.select).sort()).toEqual([
'category',
'createdAt',
'id',
'link',
'message',
'priority',
'read',
'relatedId',
'relatedType',
'requiredPermissions',
]);
}); });
it('throws NotFoundException when marking an unknown notification as read', async () => { it('answers 404 when the notification is foreign or unknown', async () => {
prisma.notification.findUnique.mockResolvedValueOnce(null); prisma.notification.updateMany.mockResolvedValue({ count: 0 });
await expect(service.markRead('missing', 'user-1')).rejects.toBeInstanceOf( await expect(service.markRead('n1', 'user-1')).rejects.toBeInstanceOf(
NotFoundException, NotFoundException,
); );
expect(prisma.notification.updateMany).toHaveBeenCalledWith({
where: { id: 'n1', userId: 'user-1' },
data: { read: true },
});
}); });
it("throws ForbiddenException when marking another user's notification as read", async () => { it('marks the own notification as read', async () => {
prisma.notification.findUnique.mockResolvedValueOnce({ prisma.notification.updateMany.mockResolvedValue({ count: 1 });
prisma.notification.findFirstOrThrow.mockResolvedValue({
id: 'n1', id: 'n1',
userId: 'owner', read: true,
read: false,
});
await expect(service.markRead('n1', 'someone-else')).rejects.toBeInstanceOf(
ForbiddenException,
);
}); });
it('marks a broadcast notification (no owner) as read for any user', async () => { await expect(service.markRead('n1', 'user-1')).resolves.toMatchObject({
prisma.notification.findUnique.mockResolvedValueOnce({ read: true,
id: 'n1',
userId: null,
read: false,
}); });
prisma.notification.update.mockResolvedValueOnce({ id: 'n1', read: true });
const result = await service.markRead('n1', 'any-user');
expect(result.read).toBe(true);
}); });
it("marks the owner's own notification as read", async () => { it('marks only the own unread notifications as read', async () => {
prisma.notification.findUnique.mockResolvedValueOnce({ prisma.notification.updateMany.mockResolvedValue({ count: 3 });
id: 'n1',
userId: 'user-1', await expect(service.markAllRead('user-1')).resolves.toEqual({
read: false, updated: 3,
});
expect(prisma.notification.updateMany).toHaveBeenCalledWith({
where: { userId: 'user-1', read: false },
data: { read: true },
});
}); });
prisma.notification.update.mockResolvedValueOnce({ id: 'n1', read: true }); });
});
const result = await service.markRead('n1', 'user-1'); describe('assertAppLink', () => {
it('accepts a relative app path', () => {
expect(() => assertAppLink('/tasks?status=OPEN&x=1')).not.toThrow();
});
expect(result.read).toBe(true); 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();
}); });
}); });
@@ -1,98 +1,269 @@
import { import { Injectable, NotFoundException } from '@nestjs/common';
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { DbClient } from '../prisma/db-client'; import { DbClient } from '../prisma/db-client';
import { PermissionKey } from '../auth/permissions/permission-catalog'; import { PermissionKey } from '../auth/permissions/permission-catalog';
import { import {
Notification, Notification,
NotificationCategory,
NotificationPriority, NotificationPriority,
Prisma, Prisma,
} from '../../generated/prisma'; } from '../../generated/prisma';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { assertAppLink, notificationDedupeKey } from './notification-link';
export interface CreateNotificationInput { export interface CreateNotificationInput {
userId?: string; userId: string;
priority: NotificationPriority; priority: NotificationPriority;
category: NotificationCategory;
message: string; message: string;
relatedType?: string; relatedType?: string | null;
relatedId?: string; relatedId?: string | null;
link?: string | null;
requiredPermissions?: readonly PermissionKey[];
dedupeKey?: string | null;
} }
export interface NotificationFilter { export type FanOutNotificationInput = Omit<
userId?: string; CreateNotificationInput,
unreadOnly?: boolean; '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<FanOutNotificationInput, 'dedupeKeyPrefix'>;
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() @Injectable()
export class NotificationsService { 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( async record(
input: CreateNotificationInput, input: CreateNotificationInput,
tx?: Prisma.TransactionClient, tx?: Prisma.TransactionClient,
): Promise<Notification> { ): Promise<Notification | null> {
const db: DbClient = tx ?? this.prisma; 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<Notification[]> {
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<Notification[]> {
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 * The own notifications, newest first, at most 100. A row whose
* role, permission granted - the same rule the PermissionsGuard applies. * requiredPermissions the user no longer holds (role changed after the
* Returns how many were written. * write) stays hidden.
*/ */
async recordForPermission( async findOwn(
permission: PermissionKey, userId: string,
input: Omit<CreateNotificationInput, 'userId'>, options: { unreadOnly?: boolean } = {},
tx?: Prisma.TransactionClient, ): Promise<NotificationView[]> {
): Promise<number> { const rows = await this.prisma.notification.findMany({
const db: DbClient = tx ?? this.prisma; where: { userId, ...(options.unreadOnly ? { read: false } : {}) },
const recipients = await db.user.findMany({ 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<NotificationView> {
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<Notification[]> {
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<string[]> {
const users = await db.user.findMany({
where: { where: {
...(userIds ? { id: { in: [...userIds] } } : {}),
isActive: true, isActive: true,
role: { role: {
status: 'ACTIVE', status: 'ACTIVE',
permissions: { some: { permission: { key: permission } } }, AND: keys.map((key) => ({
permissions: { some: { permission: { key } } },
})),
}, },
}, },
select: { id: true }, select: { id: true },
orderBy: { id: 'asc' },
}); });
if (recipients.length === 0) { return users.map((user) => user.id);
return 0;
}
const { count } = await db.notification.createMany({
data: recipients.map((recipient) => ({ ...input, userId: recipient.id })),
});
return count;
} }
async findAll(filter: NotificationFilter = {}): Promise<Notification[]> { private async write(
return this.prisma.notification.findMany({ db: DbClient,
where: { targets: readonly Target[],
OR: filter.userId content: NotificationContent,
? [{ userId: filter.userId }, { userId: null }] recipientKeys: readonly PermissionKey[],
: undefined, ): Promise<Notification[]> {
read: filter.unreadOnly ? false : undefined, if (content.link) {
}, assertAppLink(content.link);
orderBy: { createdAt: 'desc' },
take: 100,
});
} }
if (targets.length === 0) {
async markRead(id: string, actingUserId: string): Promise<Notification> { return [];
const notification = await this.prisma.notification.findUnique({
where: { id },
});
if (!notification) {
throw new NotFoundException('Notification not found');
} }
if (notification.userId !== null && notification.userId !== actingUserId) { const requiredPermissions = uniqueSorted([
throw new ForbiddenException("Cannot modify another user's notification"); ...recipientKeys,
} ...(content.requiredPermissions ?? []),
return this.prisma.notification.update({ ]);
where: { id }, return db.notification.createManyAndReturn({
data: { read: true }, 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,
}); });
} }
} }
@@ -7,12 +7,17 @@ import { QueensService } from './queens.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.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', () => { describe('QueensService', () => {
let service: QueensService; let service: QueensService;
let tx: { 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 }; queenColonyHistory: { updateMany: jest.Mock; create: jest.Mock };
colony: { findUnique: jest.Mock; update: jest.Mock }; colony: { findUnique: jest.Mock; update: jest.Mock };
}; };
@@ -29,13 +34,20 @@ describe('QueensService', () => {
$transaction: jest.Mock; $transaction: jest.Mock;
}; };
let auditLog: { record: 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 queen = { id: 'queen-1', code: 'Q-001', status: 'ACTIVE' };
const colony = { id: 'colony-1', currentQueenId: null }; const colony = { id: 'colony-1', currentQueenId: null };
beforeEach(() => { beforeEach(() => {
tx = { 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() }, queenColonyHistory: { updateMany: jest.fn(), create: jest.fn() },
colony: { colony: {
findUnique: jest.fn().mockResolvedValue(null), findUnique: jest.fn().mockResolvedValue(null),
@@ -57,13 +69,17 @@ describe('QueensService', () => {
), ),
}; };
auditLog = { record: jest.fn().mockResolvedValue(undefined) }; auditLog = { record: jest.fn().mockResolvedValue(undefined) };
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) }; webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
const calendarSync = { enqueue: jest.fn(), cancel: jest.fn() }; 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<unknown>,
);
service = new QueensService( service = new QueensService(
prisma as unknown as PrismaService, prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService, auditLog as unknown as AuditLogService,
webhooks as unknown as WebhooksService, 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 () => { it('assignColony throws NotFoundException when the colony is missing', async () => {
prisma.queen.findUnique.mockResolvedValue(queen); prisma.queen.findUnique.mockResolvedValue(queen);
prisma.colony.findUnique.mockResolvedValue(null); mockColonies(null);
await expect( await expect(
service.assignColony('queen-1', { colonyId: 'missing' }, 'user-1'), service.assignColony('queen-1', { colonyId: 'missing' }, 'user-1'),
).rejects.toBeInstanceOf(NotFoundException); ).rejects.toBeInstanceOf(NotFoundException);
@@ -90,15 +115,13 @@ describe('QueensService', () => {
await expect( await expect(
service.assignColony('queen-1', { colonyId: 'colony-1' }, 'user-1'), service.assignColony('queen-1', { colonyId: 'colony-1' }, 'user-1'),
).rejects.toBeInstanceOf(ConflictException); ).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 () => { it('assignColony closes the previous queen history, sets currentQueenId, and opens a new history row', async () => {
prisma.queen.findUnique.mockResolvedValue(queen); prisma.queen.findUnique.mockResolvedValue(queen);
prisma.colony.findUnique.mockResolvedValue({ mockColonies({ ...colony, status: 'ACTIVE', currentQueenId: 'queen-old' });
...colony,
currentQueenId: 'queen-old',
});
tx.colony.update.mockResolvedValue({}); tx.colony.update.mockResolvedValue({});
tx.queenColonyHistory.create.mockResolvedValue({ id: 'history-1' }); tx.queenColonyHistory.create.mockResolvedValue({ id: 'history-1' });
@@ -122,6 +145,57 @@ describe('QueensService', () => {
expect(historyArg.data.colonyId).toBe('colony-1'); 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 () => { it('addEvaluation creates a queen evaluation and records an audit log entry', async () => {
prisma.queen.findUnique.mockResolvedValue(queen); prisma.queen.findUnique.mockResolvedValue(queen);
const created = { id: 'eval-1', queenId: 'queen-1', colonyStrength: 4 }; const created = { id: 'eval-1', queenId: 'queen-1', colonyStrength: 4 };
+54 -21
View File
@@ -7,7 +7,9 @@ import {
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.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 { Queen, QueenStatus } from '../../generated/prisma';
import { CreateQueenDto } from './dto/create-queen.dto'; import { CreateQueenDto } from './dto/create-queen.dto';
import { UpdateQueenDto } from './dto/update-queen.dto'; import { UpdateQueenDto } from './dto/update-queen.dto';
@@ -64,7 +66,7 @@ export class QueensService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService, private readonly auditLog: AuditLogService,
private readonly webhooks: WebhooksService, private readonly webhooks: WebhooksService,
private readonly calendarSync: CalendarSyncService, private readonly events: DomainEventsService,
) {} ) {}
async findAll(filter: QueenFilter = {}): Promise<Queen[]> { async findAll(filter: QueenFilter = {}): Promise<Queen[]> {
@@ -158,13 +160,30 @@ export class QueensService {
dto: AssignColonyDto, dto: AssignColonyDto,
actingUserId: string, actingUserId: string,
): Promise<Queen> { ): Promise<Queen> {
const queen = await this.findById(id); 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 },
});
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)) { if (QUEEN_NO_LONGER_AVAILABLE_STATUSES.includes(queen.status)) {
throw new ConflictException( throw new ConflictException(
'Die Königin ist nicht mehr verfügbar für eine Zuweisung - korrigiere zuerst ihren Status, falls dies ein Versehen war', '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({ const colony = await tx.colony.findUnique({
where: { id: dto.colonyId }, where: { id: dto.colonyId },
}); });
if (!colony) { if (!colony) {
@@ -178,8 +197,18 @@ export class QueensService {
if (colony.currentQueenId === queen.id) { if (colony.currentQueenId === queen.id) {
throw new ConflictException('Die Königin sitzt bereits in diesem Volk'); 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(); const now = new Date();
if (colony.currentQueenId) { if (colony.currentQueenId) {
await tx.queenColonyHistory.updateMany({ await tx.queenColonyHistory.updateMany({
@@ -191,12 +220,6 @@ export class QueensService {
data: { endDate: now }, 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) { if (previousColony) {
await tx.colony.update({ await tx.colony.update({
where: { id: previousColony.id }, where: { id: previousColony.id },
@@ -221,29 +244,39 @@ export class QueensService {
status: colony.status === 'QUEENLESS' ? 'ACTIVE' : undefined, status: colony.status === 'QUEENLESS' ? 'ACTIVE' : undefined,
}, },
}); });
return tx.queenColonyHistory.create({ const history = await tx.queenColonyHistory.create({
data: { data: {
queenId: queen.id, queenId: queen.id,
colonyId: colony.id, colonyId: colony.id,
startDate: new Date(), startDate: now,
notes: dto.notes, notes: dto.notes,
}, },
}); });
}); await this.auditLog.record(
{
await this.auditLog.record({
entityType: 'Queen', entityType: 'Queen',
entityId: queen.id, entityId: queen.id,
action: 'UPDATE', action: 'UPDATE',
userId: actingUserId, userId: actingUserId,
beforeData: queen, beforeData: queen,
afterData: { ...queen, assignedColonyId: colony.id }, 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 } });
}); });
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) { async getHistory(id: string) {
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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[];
}
@@ -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<TaskPriority, number>;
}
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;
}
@@ -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<typeof row>[]) {
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,
});
});
});
@@ -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<TaskPriority, number>;
}
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<Suggestion, 'intent' | 'evidence'>,
): 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<string>): Prisma.SuggestionWhereInput {
return {
OR: [
{ requiredPermission: null },
{ requiredPermission: { in: [...permissions] } },
],
};
}
async list(
userId: string,
query: ListSuggestionsQuery,
): Promise<SuggestionView[]> {
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<SuggestionView> {
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<SuggestionView[]> {
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<SuggestionSummary> {
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<TaskPriority, number>;
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<void> {
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<Prisma.SuggestionWhereInput> {
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<SuggestionView[]> {
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),
};
});
}
}
@@ -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,
}));
}
}
@@ -1,6 +1,8 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AuditModule } from '../audit/audit.module'; import { AuditModule } from '../audit/audit.module';
import { TasksModule } from '../tasks/tasks.module'; import { TasksModule } from '../tasks/tasks.module';
import { SuggestionViewsService } from './suggestion-views.service';
import { SuggestionsController } from './suggestions.controller';
import { SuggestionsService } from './suggestions.service'; import { SuggestionsService } from './suggestions.service';
/** /**
@@ -9,7 +11,8 @@ import { SuggestionsService } from './suggestions.service';
*/ */
@Module({ @Module({
imports: [TasksModule, AuditModule], imports: [TasksModule, AuditModule],
providers: [SuggestionsService], controllers: [SuggestionsController],
exports: [SuggestionsService], providers: [SuggestionsService, SuggestionViewsService],
exports: [SuggestionsService, SuggestionViewsService],
}) })
export class SuggestionsModule {} export class SuggestionsModule {}
@@ -59,7 +59,7 @@ describe('JobRunService', () => {
$executeRaw: jest.fn<Promise<number>, RawCall>().mockResolvedValue(1), $executeRaw: jest.fn<Promise<number>, RawCall>().mockResolvedValue(1),
$queryRaw: jest $queryRaw: jest
.fn<Promise<unknown>, RawCall>() .fn<Promise<unknown>, RawCall>()
.mockResolvedValue([{ notified: false }]), .mockResolvedValue([{ notified: false, day: '2026-10-13' }]),
}; };
prisma = { prisma = {
jobRun, jobRun,
@@ -71,7 +71,9 @@ describe('JobRunService', () => {
Promise.resolve([runRow({ status: status as JobRun['status'] })]), Promise.resolve([runRow({ status: status as JobRun['status'] })]),
), ),
}; };
notifications = { recordForPermission: jest.fn().mockResolvedValue(2) }; notifications = {
recordForPermission: jest.fn().mockResolvedValue([{ id: 'n1' }]),
};
service = new JobRunService( service = new JobRunService(
prisma as unknown as PrismaService, prisma as unknown as PrismaService,
notifications as unknown as NotificationsService, notifications as unknown as NotificationsService,
@@ -177,9 +179,11 @@ describe('JobRunService', () => {
'automation:update', 'automation:update',
{ {
priority: 'WARNUNG', priority: 'WARNUNG',
category: 'SYSTEM',
message: 'Job „Tägliche Auswertung“ fehlgeschlagen', message: 'Job „Tägliche Auswertung“ fehlgeschlagen',
relatedType: 'JobRun', relatedType: 'JobRun',
relatedId: 'run-1', relatedId: 'run-1',
dedupeKeyPrefix: 'job-failed:daily-evaluation:2026-10-13',
}, },
tx, tx,
); );
@@ -188,7 +192,7 @@ describe('JobRunService', () => {
// The hourly app-backup check would otherwise report one outage every // The hourly app-backup check would otherwise report one outage every
// hour. The check and the write share a transaction behind a lock per // 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. // 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 }]); tx.$queryRaw.mockResolvedValue([{ notified: true }]);
await service.run('app-backup', () => Promise.reject(new Error('boom'))); 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(sqlText(lock)).toContain('pg_advisory_xact_lock');
expect(lock.slice(1)).toEqual(['job-failure-notification:app-backup']); expect(lock.slice(1)).toEqual(['job-failure-notification:app-backup']);
const check = tx.$queryRaw.mock.calls[0]; 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(check.slice(1)).toEqual(['JobRun', 'app-backup']);
expect(tx.$executeRaw.mock.invocationCallOrder[0]).toBeLessThan( expect(tx.$executeRaw.mock.invocationCallOrder[0]).toBeLessThan(
tx.$queryRaw.mock.invocationCallOrder[0], tx.$queryRaw.mock.invocationCallOrder[0],
+12 -5
View File
@@ -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 * hourly app-backup check would otherwise report the same outage every
* hour. The advisory lock per job serialises check and write, so two * 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. * cannot be written is logged; the run stays FAILED either way.
*/ */
private async notifyFailure(job: JobName, runId: string): Promise<void> { private async notifyFailure(job: JobName, runId: string): Promise<void> {
try { try {
await this.prisma.$transaction(async (tx) => { await this.prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${`job-failure-notification:${job}`}, 0))`; 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 EXISTS (
SELECT 1 FROM "Notification" n SELECT 1 FROM "Notification" n
JOIN "JobRun" r ON r."id" = n."relatedId" JOIN "JobRun" r ON r."id" = n."relatedId"
WHERE n."relatedType" = ${JOB_RUN_RELATED_TYPE} WHERE n."relatedType" = ${JOB_RUN_RELATED_TYPE}
AND r."job" = ${job} AND r."job" = ${job}
AND n."createdAt" >= date_trunc('day', now()) AND n."createdAt" >= date_trunc('day', now() AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'Europe/Berlin'
) AS "notified"`; ) AS "notified",
to_char(now() AT TIME ZONE 'Europe/Berlin', 'YYYY-MM-DD') AS "day"`;
if (notified) { if (notified) {
return; return;
} }
@@ -187,9 +192,11 @@ export class JobRunService {
JOB_FAILURE_PERMISSION[job], JOB_FAILURE_PERMISSION[job],
{ {
priority: 'WARNUNG', priority: 'WARNUNG',
category: 'SYSTEM',
message: `Job „${JOB_LABELS_DE[job]}“ fehlgeschlagen`, message: `Job „${JOB_LABELS_DE[job]}“ fehlgeschlagen`,
relatedType: JOB_RUN_RELATED_TYPE, relatedType: JOB_RUN_RELATED_TYPE,
relatedId: runId, relatedId: runId,
dedupeKeyPrefix: `job-failed:${job}:${day}`,
}, },
tx, tx,
); );
@@ -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' } }),
);
});
});
@@ -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<TreatmentWindowLite[]> {
const treatments = await db.treatment.findMany({
where: { colonyId },
select: { date: true, startDate: true, endDate: true },
orderBy: { date: 'asc' },
});
return treatments.map(treatmentWindowOf);
}
@@ -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 () => { it('omits a window that has ended by that date', async () => {
prisma.treatment.findMany.mockResolvedValue([endedFirstOfJuly]); prisma.treatment.findMany.mockResolvedValue([endedFirstOfJuly]);
@@ -14,6 +14,12 @@ import {
} from '../common/bulk/bulk-create'; } from '../common/bulk/bulk-create';
import { runBulk } from '../common/bulk/run-bulk'; import { runBulk } from '../common/bulk/run-bulk';
import { lockRowForUpdate } from '../prisma/row-lock'; 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 { CreateTreatmentDto } from './dto/create-treatment.dto';
import { BulkCreateTreatmentDto } from './dto/bulk-create-treatment.dto'; import { BulkCreateTreatmentDto } from './dto/bulk-create-treatment.dto';
import { UpdateTreatmentDto } from './dto/update-treatment.dto'; import { UpdateTreatmentDto } from './dto/update-treatment.dto';
@@ -24,16 +30,6 @@ export interface WithdrawalStatus {
product: string; 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<Treatment, 'date' | 'startDate'>,
): 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 // 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 // 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 // stands today is where it was treated: the apiary is kept on the treatment
@@ -105,29 +101,23 @@ export class TreatmentsService {
withdrawalPeriodDays: { not: null }, withdrawalPeriodDays: { not: null },
}, },
}); });
const activeByColony = new Map<string, WithdrawalStatus>(); const harvestDay = berlinDayOf(at);
const activeByColony = new Map<string, WithdrawalWindow>();
for (const treatment of treatments) { for (const treatment of treatments) {
if (treatmentStart(treatment) > at) { const window = withdrawalWindowOf(treatment);
continue; if (!window || !isInWindow(window, harvestDay)) {
}
const referenceDate = treatment.endDate ?? treatment.date;
const activeUntil = new Date(referenceDate);
activeUntil.setUTCDate(
activeUntil.getUTCDate() + (treatment.withdrawalPeriodDays ?? 0),
);
if (activeUntil <= at) {
continue; continue;
} }
const current = activeByColony.get(treatment.colonyId); const current = activeByColony.get(treatment.colonyId);
if (!current || activeUntil > new Date(current.activeUntil)) { if (!current || window.untilDay > current.untilDay) {
activeByColony.set(treatment.colonyId, { activeByColony.set(treatment.colonyId, window);
colonyId: treatment.colonyId,
activeUntil: activeUntil.toISOString(),
product: treatment.product,
});
} }
} }
return [...activeByColony.values()]; return [...activeByColony.values()].map((window) => ({
colonyId: window.colonyId,
activeUntil: dayToDate(window.untilDay).toISOString(),
product: window.product,
}));
} }
async create( async create(
@@ -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' });
});
});
});
@@ -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<Treatment, 'date' | 'startDate'>): 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<Treatment, 'date' | 'endDate'>): string {
return dayOf(t.endDate ?? t.date);
}
export interface TreatmentWindowLite {
startDay: string;
endDay: string;
}
export function treatmentWindowOf(
t: Pick<Treatment, 'date' | 'startDate' | 'endDate'>,
): 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<Treatment, 'date' | 'endDate' | 'withdrawalPeriodDays'>,
): 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;
}
@@ -1,17 +1,25 @@
import { Controller, Get, Param } from '@nestjs/common'; import { Controller, Get, Param } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { RequirePermission } from '../auth/decorators/require-permission.decorator'; import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { VarroaBandsService } from './varroa-bands.service';
import { VarroaService } from './varroa.service'; import { VarroaService } from './varroa.service';
@ApiTags('varroa-measurements') @ApiTags('varroa-measurements')
@ApiBearerAuth() @ApiBearerAuth()
@Controller('colonies') @Controller('colonies')
export class ColonyVarroaMeasurementsController { export class ColonyVarroaMeasurementsController {
constructor(private readonly varroaService: VarroaService) {} constructor(
private readonly varroaService: VarroaService,
private readonly varroaBandsService: VarroaBandsService,
) {}
@RequirePermission('varroa:read') @RequirePermission('varroa:read')
@Get(':id/varroa-measurements') @Get(':id/varroa-measurements')
findByColony(@Param('id') id: string) { /** The measurements of the colony, each with its rating (light, band, reason). */
return this.varroaService.findByColony(id); async findByColony(@Param('id') id: string) {
return this.varroaBandsService.rate(
id,
await this.varroaService.findByColony(id),
);
} }
} }
@@ -6,6 +6,7 @@ import {
IsNumber, IsNumber,
IsOptional, IsOptional,
IsString, IsString,
Max,
Min, Min,
} from 'class-validator'; } from 'class-validator';
import { VarroaMethod } from '../../../generated/prisma'; import { VarroaMethod } from '../../../generated/prisma';
@@ -35,6 +36,18 @@ export class CreateVarroaMeasurementDto {
@Min(0) @Min(0)
miteCount?: number; 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() @ApiPropertyOptional()
@IsOptional() @IsOptional()
@IsNumber() @IsNumber()
@@ -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;
}
@@ -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();
});
});
});
@@ -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<VarroaThresholds> {
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<M extends RatableMeasurement>(
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<ColonyType> {
const colony = await this.prisma.colony.findUnique({
where: { id: colonyId },
select: { colonyType: true },
});
if (!colony) {
throw new NotFoundException('Colony not found');
}
return colony.colonyType;
}
}
@@ -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<VarroaThreshold> {
const rule = await this.prisma.automationRule.findUnique({
where: { triggerType: 'VARROA_THRESHOLD' },
select: { active: true, config: true },
});
return { thresholdMitesPerDay: varroaThresholdOf(rule) };
}
}
@@ -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 { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { RequirePermission } from '../auth/decorators/require-permission.decorator'; import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { CurrentUser } from '../auth/decorators/current-user.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 { BulkCreateVarroaMeasurementDto } from './dto/bulk-create-varroa-measurement.dto';
import { UpdateVarroaMeasurementDto } from './dto/update-varroa-measurement.dto'; import { UpdateVarroaMeasurementDto } from './dto/update-varroa-measurement.dto';
import { VarroaService } from './varroa.service'; 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'; import { Idempotent } from '../common/idempotency/idempotent.decorator';
@ApiTags('varroa-measurements') @ApiTags('varroa-measurements')
@@ -16,7 +25,7 @@ import { Idempotent } from '../common/idempotency/idempotent.decorator';
export class VarroaController { export class VarroaController {
constructor( constructor(
private readonly varroaService: VarroaService, private readonly varroaService: VarroaService,
private readonly varroaThresholdService: VarroaThresholdService, private readonly varroaBandsService: VarroaBandsService,
) {} ) {}
@RequirePermission('varroa:read') @RequirePermission('varroa:read')
@@ -25,11 +34,11 @@ export class VarroaController {
return this.varroaService.findAll(); 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') @RequirePermission('varroa:read', 'varroa:create')
@Get('threshold') @Get('thresholds')
getThreshold() { getThresholds(@Query() query: VarroaThresholdsQuery) {
return this.varroaThresholdService.current(); return this.varroaBandsService.thresholds(query);
} }
@RequirePermission('varroa:read') @RequirePermission('varroa:read')
+2 -2
View File
@@ -2,7 +2,7 @@ import { Module } from '@nestjs/common';
import { VarroaController } from './varroa.controller'; import { VarroaController } from './varroa.controller';
import { ColonyVarroaMeasurementsController } from './colony-varroa-measurements.controller'; import { ColonyVarroaMeasurementsController } from './colony-varroa-measurements.controller';
import { VarroaService } from './varroa.service'; 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 * The threshold rule and the varroa.measured webhook follow from the
@@ -11,7 +11,7 @@ import { VarroaThresholdService } from './varroa-threshold.service';
*/ */
@Module({ @Module({
controllers: [VarroaController, ColonyVarroaMeasurementsController], controllers: [VarroaController, ColonyVarroaMeasurementsController],
providers: [VarroaService, VarroaThresholdService], providers: [VarroaService, VarroaBandsService],
exports: [VarroaService], exports: [VarroaService],
}) })
export class VarroaModule {} export class VarroaModule {}
@@ -284,6 +284,57 @@ describe('VarroaService', () => {
expect(createArg.data.calculatedValue).toBeUndefined(); 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 () => { it('respects an explicitly provided calculatedValue instead of computing one', async () => {
tx.varroaMeasurement.create.mockResolvedValue(measurement); tx.varroaMeasurement.create.mockResolvedValue(measurement);
@@ -373,6 +424,16 @@ describe('VarroaService', () => {
).resolves.toBe(20); ).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 () => { it('recomputes mites/day when the duration changes', async () => {
await expect( await expect(
storedCalculatedValueAfter(naturalMiteFall, { storedCalculatedValueAfter(naturalMiteFall, {
@@ -443,6 +504,108 @@ describe('VarroaService', () => {
).resolves.toBe(15); ).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. // @IsOptional lets an explicit null through the validation pipe.
const clearedValue = { calculatedValue: null as unknown as number }; const clearedValue = { calculatedValue: null as unknown as number };
+72 -24
View File
@@ -19,16 +19,19 @@ import { BulkCreateVarroaMeasurementDto } from './dto/bulk-create-varroa-measure
import { UpdateVarroaMeasurementDto } from './dto/update-varroa-measurement.dto'; import { UpdateVarroaMeasurementDto } from './dto/update-varroa-measurement.dto';
const MINUTES_PER_DAY = 24 * 60; 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 // 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 // for some duration and is conventionally normalized to a daily rate
// ("Milben/Tag") - both the VARROA_THRESHOLD automation config // ("Milben/Tag"). Powdered sugar and washing report the infestation as mites
// (thresholdMitesPerDay) and the annual PDF report already assume // per 100 bees of a sample of sampleBees (percent). The drone brood check has
// calculatedValue is in that unit. The other methods (Puderzucker, // no verified formula, so its calculatedValue is left for the user to enter.
// 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.
function computeNaturalMiteFallPerDay( function computeNaturalMiteFallPerDay(
miteCount: number | null | undefined, miteCount: number | null | undefined,
durationMinutes: number | null | undefined, durationMinutes: number | null | undefined,
@@ -39,16 +42,44 @@ function computeNaturalMiteFallPerDay(
if (!durationMinutes || durationMinutes <= 0) { if (!durationMinutes || durationMinutes <= 0) {
return undefined; return undefined;
} }
return (miteCount / durationMinutes) * MINUTES_PER_DAY; return roundToTwoDecimals((miteCount / durationMinutes) * MINUTES_PER_DAY);
} }
function deriveCalculatedValue( function computeInfestationPercent(
method: VarroaMethod,
miteCount: number | null | undefined, miteCount: number | null | undefined,
durationMinutes: number | null | undefined, sampleBees: number | null | undefined,
): number | undefined { ): number | undefined {
return method === 'NATURAL_MITE_FALL' if (miteCount === null || miteCount === undefined) {
? computeNaturalMiteFallPerDay(miteCount, durationMinutes) 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; : undefined;
} }
@@ -69,18 +100,25 @@ function calculatedValueForUpdate(
if (sent !== undefined && sent !== null && sent !== before.calculatedValue) { if (sent !== undefined && sent !== null && sent !== before.calculatedValue) {
return sent; return sent;
} }
const method = dto.method ?? before.method; const merged: Derivation = {
const miteCount = method: dto.method ?? before.method,
dto.miteCount === undefined ? before.miteCount : dto.miteCount; miteCount: dto.miteCount === undefined ? before.miteCount : dto.miteCount,
const durationMinutes = durationMinutes:
dto.durationMinutes === undefined dto.durationMinutes === undefined
? before.durationMinutes ? before.durationMinutes
: dto.durationMinutes; : dto.durationMinutes,
sampleBees:
dto.sampleBees === undefined ? before.sampleBees : dto.sampleBees,
};
const { method } = merged;
const derivationInputChanged = const derivationInputChanged =
method !== before.method || method !== before.method ||
(method === 'NATURAL_MITE_FALL' && (method === 'NATURAL_MITE_FALL' &&
(miteCount !== before.miteCount || (merged.miteCount !== before.miteCount ||
durationMinutes !== before.durationMinutes)); 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 // An explicit null asks for the value to be cleared - like a create
// without a value, that means "derive it where a rule exists". // without a value, that means "derive it where a rule exists".
const mustDerive = const mustDerive =
@@ -88,8 +126,20 @@ function calculatedValueForUpdate(
if (!mustDerive) { if (!mustDerive) {
return undefined; 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. // 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, // The row a new measurement is stored as - for create and createBulk alike,
@@ -101,9 +151,7 @@ function measurementRow(
): Prisma.VarroaMeasurementUncheckedCreateInput { ): Prisma.VarroaMeasurementUncheckedCreateInput {
return { return {
...dto, ...dto,
calculatedValue: calculatedValue: dto.calculatedValue ?? deriveCalculatedValue(dto),
dto.calculatedValue ??
deriveCalculatedValue(dto.method, dto.miteCount, dto.durationMinutes),
date: new Date(dto.date), date: new Date(dto.date),
userId: actingUserId, userId: actingUserId,
}; };
@@ -188,7 +188,7 @@ describe('WebhookDeliveryWorker', () => {
`UPDATE "WebhookDelivery" SET "status" = 'PROCESSING', "claimedAt" = now()`, `UPDATE "WebhookDelivery" SET "status" = 'PROCESSING', "claimedAt" = now()`,
); );
expect(text(claim)).toContain( expect(text(claim)).toContain(
`WHERE "status" = 'PENDING' AND "availableAt" <= now()`, `WHERE "status" = 'PENDING' AND "availableAt" <= CAST(now() AS timestamp(3))`,
); );
expect(text(claim)).toContain( expect(text(claim)).toContain(
'ORDER BY "createdAt" LIMIT ? FOR UPDATE SKIP LOCKED', 'ORDER BY "createdAt" LIMIT ? FOR UPDATE SKIP LOCKED',
@@ -201,7 +201,7 @@ describe('WebhookDeliveryWorker', () => {
const claim = db.claimQuery(); const claim = db.claimQuery();
expect(text(claim)).toContain( 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]); expect(claim.values).toEqual([['wh-1', 'wh-2'], 50]);
}); });
@@ -18,6 +18,7 @@ type RecordModel =
| 'feeding' | 'feeding'
| 'harvest' | 'harvest'
| 'honeyBatch' | 'honeyBatch'
| 'queen'
| 'task'; | 'task';
const RECORD_MODELS: readonly RecordModel[] = [ const RECORD_MODELS: readonly RecordModel[] = [
@@ -27,6 +28,7 @@ const RECORD_MODELS: readonly RecordModel[] = [
'feeding', 'feeding',
'harvest', 'harvest',
'honeyBatch', 'honeyBatch',
'queen',
'task', 'task',
]; ];
@@ -79,6 +81,7 @@ describe('WebhookEventHandlers', () => {
StockChanged: 'stock.changed', StockChanged: 'stock.changed',
TaskCreated: 'task.created', TaskCreated: 'task.created',
TaskCompleted: 'task.completed', 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 () => { it('enqueues harvest.created with the harvest and its colonies', async () => {
const harvest = { const harvest = {
id: 'h1', id: 'h1',
@@ -23,6 +23,7 @@ export const DOMAIN_EVENT_WEBHOOKS: Readonly<
StockChanged: 'stock.changed', StockChanged: 'stock.changed',
TaskCreated: 'task.created', TaskCreated: 'task.created',
TaskCompleted: 'task.completed', TaskCompleted: 'task.completed',
QueenAssigned: 'queen.replaced',
}); });
/** /**
@@ -92,6 +93,8 @@ async function webhookData(
case 'TaskCreated': case 'TaskCreated':
case 'TaskCompleted': case 'TaskCompleted':
return tx.task.findUnique(byAggregate); return tx.task.findUnique(byAggregate);
case 'QueenAssigned':
return tx.queen.findUnique(byAggregate);
default: default:
return null; return null;
} }
+6 -1
View File
@@ -266,7 +266,12 @@ describe('Automation rules (e2e)', () => {
const notifications = await prisma.notification.findMany({ const notifications = await prisma.notification.findMany({
where: { relatedId: colonyId }, 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 () => { it('does not trigger when the queen was seen', async () => {
@@ -44,6 +44,7 @@ describe('Calendar sync outbox (e2e)', () => {
syncFeeding: true, syncFeeding: true,
syncHarvest: true, syncHarvest: true,
syncQueenAssignment: true, syncQueenAssignment: true,
syncWithdrawal: true,
updatedAt: new Date(), updatedAt: new Date(),
}; };
@@ -359,6 +360,37 @@ describe('Calendar sync outbox (e2e)', () => {
expect(JSON.stringify(response.body)).toContain('Gruppen-ID'); 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. // PATCH null rule: the switches cannot hold null - 400, not a 500.
it('rejects null on a switch with 400', async () => { it('rejects null on a switch with 400', async () => {
await request(app.getHttpServer()) await request(app.getHttpServer())
+51
View File
@@ -35,6 +35,7 @@ describe('Colonies (e2e)', () => {
let apiaryBId: string; let apiaryBId: string;
let colonyId: string; let colonyId: string;
let childColonyId: string; let childColonyId: string;
const splitColonyIds: string[] = [];
let colonyToMergeId: string; let colonyToMergeId: string;
let healthColonyId: string; let healthColonyId: string;
let raceColonyId: string; let raceColonyId: string;
@@ -83,6 +84,7 @@ describe('Colonies (e2e)', () => {
colonyToMergeId, colonyToMergeId,
healthColonyId, healthColonyId,
raceColonyId, raceColonyId,
...splitColonyIds,
].filter(Boolean); ].filter(Boolean);
// Rule tasks are written without a user - their audit entry and // Rule tasks are written without a user - their audit entry and
// TaskCreated are not covered by the userId cleanup below. // TaskCreated are not covered by the userId cleanup below.
@@ -190,6 +192,55 @@ describe('Colonies (e2e)', () => {
expect((history.body as ColonyHistoryBody).lineageAsSource).toHaveLength(1); 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 () => { it('merges another colony into the surviving one', async () => {
const otherColony = await request(app.getHttpServer()) const otherColony = await request(app.getHttpServer())
.post('/api/v1/colonies') .post('/api/v1/colonies')
+323 -61
View File
@@ -2,6 +2,7 @@ import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs'; import * as bcrypt from 'bcryptjs';
import request from 'supertest'; import request from 'supertest';
import { App } from 'supertest/types'; import { App } from 'supertest/types';
import { NotificationsService } from '../src/notifications/notifications.service';
import { PrismaService } from '../src/prisma/prisma.service'; import { PrismaService } from '../src/prisma/prisma.service';
import { createTestApp } from './utils/create-test-app'; import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api'; import { login } from './utils/api';
@@ -9,99 +10,360 @@ import { login } from './utils/api';
interface NotificationBody { interface NotificationBody {
id: string; id: string;
priority: string; priority: string;
category: string;
read: boolean; read: boolean;
} }
interface SettingsBody {
taskDueEnabled: boolean;
digestEnabled: boolean;
digestTime: string;
quietFrom: string | null;
quietUntil: string | null;
telegramCategories: string[];
telegramDetail: boolean;
}
describe('Notifications (e2e)', () => { describe('Notifications (e2e)', () => {
let app: INestApplication<App>; let app: INestApplication<App>;
let prisma: PrismaService; 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 password = 'SuperSecret123!';
const staffEmail = `notifications-e2e-staff-${Date.now()}@example.com`; const emails = {
let beekeeperId: string; beekeeper: `notifications-e2e-beekeeper-${suffix}@example.com`,
let staffId: string; staff: `notifications-e2e-staff-${suffix}@example.com`,
let beekeeperToken: string; bare: `notifications-e2e-bare-${suffix}@example.com`,
let staffToken: string; admin: `notifications-e2e-admin-${suffix}@example.com`,
let notificationId: string; };
const roleKey = `NOTIF_BARE_${suffix}`;
const ids = {} as Record<keyof typeof emails, string>;
const tokens = {} as Record<keyof typeof emails, string>;
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<string, unknown> = {},
) {
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 () => { beforeAll(async () => {
app = await createTestApp(); app = await createTestApp();
prisma = app.get(PrismaService); 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 passwordHash = await bcrypt.hash(password, 12);
const beekeeper = await prisma.user.create({ const roleOf: Record<keyof typeof emails, string> = {
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: { data: {
email: beekeeperEmail, email: emails[who],
passwordHash, passwordHash,
name: 'Beekeeper', name: who,
role: { connect: { key: 'BEEKEEPER' } }, role: { connect: { key: roleOf[who] } },
}, },
}); });
beekeeperId = beekeeper.id; ids[who] = user.id;
const staff = await prisma.user.create({ tokens[who] = (await login(app, emails[who], password)).accessToken;
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;
}); });
afterAll(async () => { 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({ await prisma.refreshToken.deleteMany({
where: { userId: { in: [beekeeperId, staffId] } }, where: { userId: { in: userIds } },
});
await prisma.auditLog.deleteMany({
where: { userId: { in: [beekeeperId, staffId] } },
});
await prisma.user.deleteMany({
where: { id: { in: [beekeeperId, staffId] } },
}); });
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(); await app.close();
}); });
it('lists notifications for the authenticated user', async () => { 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') .get('/api/v1/notifications')
.set('Authorization', `Bearer ${beekeeperToken}`) .set(as('beekeeper'))
.expect(200); .expect(200);
const notifications = response.body as NotificationBody[]; const list = response.body as NotificationBody[];
expect(
notifications.some( expect(list.some((n) => n.id === own.id && n.priority === 'KRITISCH')).toBe(
(notification) => true,
notification.id === notificationId && );
notification.priority === 'KRITISCH', expect(list[0]).not.toHaveProperty('requiredPermissions');
), expect(list[0]).not.toHaveProperty('dedupeKey');
).toBe(true);
}); });
it("rejects marking another user's notification as read", () => { it('hides a row whose permissions the user does not hold (any more)', async () => {
return request(app.getHttpServer()) const open = await seedNotification(ids.bare);
.post(`/api/v1/notifications/${notificationId}/read`) const hidden = await seedNotification(ids.bare, {
.set('Authorization', `Bearer ${staffToken}`) 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('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);
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); .expect(403);
}); });
it('marks a notification as read', async () => { it('returns the defaults on first read', async () => {
const response = await request(app.getHttpServer()) const response = await request(server())
.post(`/api/v1/notifications/${notificationId}/read`) .get('/api/v1/profile/notification-settings')
.set('Authorization', `Bearer ${beekeeperToken}`) .set(as('bare'))
.expect(201); .expect(200);
const notification = response.body as NotificationBody;
expect(notification.read).toBe(true); 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);
});
}); });
}); });
@@ -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);
});
});
@@ -213,7 +213,7 @@ describe('Post-commit side effects (e2e)', () => {
.mockImplementation(() => undefined); .mockImplementation(() => undefined);
// Automation: the warning cannot be written. // Automation: the warning cannot be written.
jest jest
.spyOn(app.get(NotificationsService), 'record') .spyOn(app.get(NotificationsService), 'recordForPermission')
.mockRejectedValue(new Error('notifications down')); .mockRejectedValue(new Error('notifications down'));
// Calendar sync: enqueue cannot read its settings. // Calendar sync: enqueue cannot read its settings.
jest jest
@@ -290,7 +290,7 @@ describe('Post-commit side effects (e2e)', () => {
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
const colonyId = (await createColony('PC-RETRY')).id; const colonyId = (await createColony('PC-RETRY')).id;
jest jest
.spyOn(app.get(NotificationsService), 'record') .spyOn(app.get(NotificationsService), 'recordForPermission')
.mockRejectedValueOnce(new Error('notifications down')); .mockRejectedValueOnce(new Error('notifications down'));
const { body } = (await post('/api/v1/inspections', { 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 prisma.$executeRaw`UPDATE "DomainEvent" SET "availableAt" = now() WHERE "aggregateId" = ${body.id}`;
await drainDomainEvents(app, [body.id]); await drainDomainEvents(app, [body.id]);
expect(await taskCount('QUEENLESS_NO_EGGS', colonyId)).toBe(1); expect(await taskCount('QUEENLESS_NO_EGGS', colonyId)).toBe(1);
expect( // One copy per recipient (everybody who may read tasks), written once.
await prisma.notification.count({ where: { relatedId: colonyId } }), const warnings = await prisma.notification.findMany({
).toBe(1); 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({ const deliveries = await prisma.domainEventDelivery.findMany({
where: { event: { aggregateId: body.id } }, where: { event: { aggregateId: body.id } },
+125 -1
View File
@@ -5,6 +5,8 @@ import { App } from 'supertest/types';
import { PrismaService } from '../src/prisma/prisma.service'; import { PrismaService } from '../src/prisma/prisma.service';
import { createTestApp } from './utils/create-test-app'; import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api'; import { login } from './utils/api';
import { drainDomainEvents } from './utils/domain-events';
import { CalendarSyncSettingsService } from '../src/calendar-sync/calendar-sync-settings.service';
interface QueenBody { interface QueenBody {
id: string; id: string;
@@ -22,6 +24,8 @@ describe('Queens (e2e)', () => {
let colonyId: string; let colonyId: string;
let queenId: string; let queenId: string;
let daughterQueenId: string; let daughterQueenId: string;
let secondColonyId: string;
let subscriptionId: string;
beforeAll(async () => { beforeAll(async () => {
app = await createTestApp(); app = await createTestApp();
@@ -53,7 +57,43 @@ describe('Queens (e2e)', () => {
colonyId = colony.id; colonyId = colony.id;
}); });
afterEach(() => {
jest.restoreAllMocks();
});
afterAll(async () => { 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) { if (queenId) {
await prisma.queenEvaluation.deleteMany({ where: { queenId } }); await prisma.queenEvaluation.deleteMany({ where: { queenId } });
} }
@@ -70,7 +110,9 @@ describe('Queens (e2e)', () => {
if (queenId) { if (queenId) {
await prisma.queen.delete({ where: { id: 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.apiary.deleteMany({ where: { id: apiaryId } });
await prisma.refreshToken.deleteMany({ where: { userId: beekeeperId } }); await prisma.refreshToken.deleteMany({ where: { userId: beekeeperId } });
await prisma.auditLog.deleteMany({ where: { userId: beekeeperId } }); await prisma.auditLog.deleteMany({ where: { userId: beekeeperId } });
@@ -94,6 +136,8 @@ describe('Queens (e2e)', () => {
.set('Authorization', `Bearer ${beekeeperToken}`) .set('Authorization', `Bearer ${beekeeperToken}`)
.send({ colonyId }) .send({ colonyId })
.expect(201); .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()) const colony = await request(app.getHttpServer())
.get(`/api/v1/colonies/${colonyId}`) .get(`/api/v1/colonies/${colonyId}`)
@@ -110,6 +154,86 @@ describe('Queens (e2e)', () => {
expect(history.body).toHaveLength(1); 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 () => { it('adds a queen evaluation and lists it back', async () => {
await request(app.getHttpServer()) await request(app.getHttpServer())
.post(`/api/v1/queens/${queenId}/evaluations`) .post(`/api/v1/queens/${queenId}/evaluations`)
@@ -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<App>;
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<string, number> }).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 });
});
});
+257 -1
View File
@@ -6,8 +6,11 @@ import {
const FEED_VOCABULARY = '20261014100200_t4_feed_vocabulary'; const FEED_VOCABULARY = '20261014100200_t4_feed_vocabulary';
const SPLIT_QUEEN_SOURCE = '20261014100300_t4_split_queen_source'; const SPLIT_QUEEN_SOURCE = '20261014100300_t4_split_queen_source';
const VARROA_SAMPLE_SIZE = '20261014100400_t4_varroa_sample_size'; 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 { interface InspectionSeed {
id: string; id: string;
@@ -36,6 +39,48 @@ async function seedColony(db: ScratchDatabase): Promise<void> {
); );
} }
/** Two active users and one inactive one, plus the role they need. */
async function seedUsers(db: ScratchDatabase): Promise<void> {
await db.query(
`INSERT INTO "Role" ("id", "key", "name", "updatedAt")
VALUES ('role-1', 'MIG_TEST', 'Migration test', now())`,
);
for (const [id, 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<void> {
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( async function seedInspections(
db: ScratchDatabase, db: ScratchDatabase,
seeds: InspectionSeed[], seeds: InspectionSeed[],
@@ -215,4 +260,215 @@ describe('T4 schema II migrations (e2e)', () => {
}, },
TIMEOUT_MS, 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', '[email protected]', '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,
);
}); });
+1 -1
View File
@@ -260,7 +260,7 @@ describe('Treatments (e2e)', () => {
it('accepts a full ISO timestamp', async () => { it('accepts a full ISO timestamp', async () => {
await expect( await expect(
withdrawalStatusAt('2026-07-14T23:59:59.000Z'), withdrawalStatusAt('2026-07-14T23:59:59+02:00'),
).resolves.toHaveLength(1); ).resolves.toHaveLength(1);
}); });
+25
View File
@@ -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<Suggestion> {
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;
}
@@ -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<string, BandBody | undefined>;
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<App>;
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',
});
});
});
+12 -13
View File
@@ -232,28 +232,27 @@ describe('Varroa measurements (e2e)', () => {
expect(measurements.some((entry) => entry.id === measurementId)).toBe(true); expect(measurements.some((entry) => entry.id === measurementId)).toBe(true);
}); });
describe('GET /varroa-measurements/threshold', () => { describe('GET /varroa-measurements/thresholds', () => {
const thresholdBody = (token: string) => const thresholdsBody = (token: string, query = '') =>
request(app.getHttpServer()) request(app.getHttpServer())
.get('/api/v1/varroa-measurements/threshold') .get(`/api/v1/varroa-measurements/thresholds${query}`)
.set('Authorization', `Bearer ${token}`); .set('Authorization', `Bearer ${token}`);
it('returns the threshold shape and is not shadowed by :id', async () => { it('returns the bands shape and is not shadowed by :id', async () => {
const response = await thresholdBody(staffToken).expect(200); const response = await thresholdsBody(staffToken).expect(200);
const body = response.body as { thresholdMitesPerDay: number | null }; const body = response.body as { day: string; active: boolean };
expect(Object.keys(body)).toEqual(['thresholdMitesPerDay']); expect(Object.keys(body).sort()).toEqual(
expect( ['active', 'bands', 'day', 'postTreatmentUntil'].sort(),
body.thresholdMitesPerDay === null || );
typeof body.thresholdMitesPerDay === 'number', expect(typeof body.active).toBe('boolean');
).toBe(true);
}); });
// The shared rule is never written here (parallel e2e workers); the // 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', () => it('rejects a role without any varroa permission', () =>
thresholdBody(noVarroaToken).expect(403)); thresholdsBody(noVarroaToken).expect(403));
}); });
describe('POST /varroa-measurements/bulk', () => { describe('POST /varroa-measurements/bulk', () => {