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

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

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

No files matched your search

+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;
+70 -36
View File
@@ -103,33 +103,34 @@ model User {
refreshTokens RefreshToken[]
inspections Inspection[]
varroaMeasurements VarroaMeasurement[]
swarmControlChecks SwarmControlCheck[]
winterChecks WinterCheck[]
treatments Treatment[]
feedings Feeding[]
harvests Harvest[]
extractions Extraction[]
honeyMeasurements HoneyMeasurement[]
stockMovements StockMovement[]
assignedTasks Task[] @relation("TaskAssignee")
createdTasks Task[] @relation("TaskCreator")
decidedSuggestions Suggestion[] @relation("SuggestionDecider")
assignedTaskTemplates TaskTemplate[] @relation("TaskTemplateAssignee")
createdTaskTemplates TaskTemplate[] @relation("TaskTemplateCreator")
colonyMoves ColonyApiaryHistory[]
auditLogs AuditLog[]
bottlingBatches BottlingBatch[]
productionBatches ProductionBatch[]
batchMovements BatchMovement[]
rawMaterialLots RawMaterialLot[]
notifications Notification[]
queenEvaluations QueenEvaluation[]
webhookSubscriptions WebhookSubscription[]
documents Document[]
honeyBatchRelocations HoneyBatchRelocation[]
apiKeys ApiKey[]
inspections Inspection[]
varroaMeasurements VarroaMeasurement[]
swarmControlChecks SwarmControlCheck[]
winterChecks WinterCheck[]
treatments Treatment[]
feedings Feeding[]
harvests Harvest[]
extractions Extraction[]
honeyMeasurements HoneyMeasurement[]
stockMovements StockMovement[]
assignedTasks Task[] @relation("TaskAssignee")
createdTasks Task[] @relation("TaskCreator")
decidedSuggestions Suggestion[] @relation("SuggestionDecider")
assignedTaskTemplates TaskTemplate[] @relation("TaskTemplateAssignee")
createdTaskTemplates TaskTemplate[] @relation("TaskTemplateCreator")
colonyMoves ColonyApiaryHistory[]
auditLogs AuditLog[]
bottlingBatches BottlingBatch[]
productionBatches ProductionBatch[]
batchMovements BatchMovement[]
rawMaterialLots RawMaterialLot[]
notifications Notification[]
notificationPreference NotificationPreference?
queenEvaluations QueenEvaluation[]
webhookSubscriptions WebhookSubscription[]
documents Document[]
honeyBatchRelocations HoneyBatchRelocation[]
apiKeys ApiKey[]
@@index([roleId])
}
@@ -291,6 +292,7 @@ model Colony {
colonyType ColonyType
status ColonyStatus @default(ACTIVE)
strength Int?
occupiedFrames Int?
broodStatus ColonyCondition?
feedStatus ColonyCondition?
healthStatus ColonyCondition?
@@ -1374,21 +1376,52 @@ enum NotificationPriority {
KRITISCH
}
enum NotificationCategory {
TASK_DUE
DIGEST
SUGGESTION
WITHDRAWAL
SEASON
SYSTEM
}
// Jede Zeile gehoert genau einem Empfaenger (T4: Fan-out mit Rechtepruefung
// beim Schreiben). requiredPermissions = Rechte des Empfaengers plus Rechte
// des Inhalts; dedupeKey = "<praefix>:<userId>".
model Notification {
id String @id @default(cuid())
userId String?
user User? @relation(fields: [userId], references: [id])
priority NotificationPriority
message String
relatedType String?
relatedId String?
read Boolean @default(false)
createdAt DateTime @default(now())
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
priority NotificationPriority
category NotificationCategory
message String
relatedType String?
relatedId String?
link String?
requiredPermissions String[] @default([])
dedupeKey String? @unique
read Boolean @default(false)
createdAt DateTime @default(now())
@@index([userId, read])
@@index([priority])
}
// Einstellungen je Nutzer; Standard beim ersten Lesen angelegt.
model NotificationPreference {
userId String @id
user User @relation(fields: [userId], references: [id])
taskDueEnabled Boolean @default(true)
digestEnabled Boolean @default(true)
digestTime String @default("06:30")
quietFrom String? @default("21:00")
quietUntil String? @default("06:30")
telegramEnabled Boolean @default(false)
telegramCategories NotificationCategory[] @default([TASK_DUE, DIGEST])
telegramDetail Boolean @default(true)
updatedAt DateTime @updatedAt
}
// ---------------------------------------------------------------------------
// Webhooks (Abschnitt 27) – v1.x Modul 28
// ---------------------------------------------------------------------------
@@ -1574,6 +1607,7 @@ model CalendarSyncSettings {
syncFeeding Boolean @default(true)
syncHarvest Boolean @default(true)
syncQueenAssignment Boolean @default(true)
syncWithdrawal Boolean @default(true)
updatedAt DateTime @updatedAt
}
@@ -189,7 +189,7 @@ describe('ApiaryFieldService', () => {
relatedId: { in: ['c2', 'c10'] },
read: false,
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) },
read: false,
priority: { in: ['WARNUNG', 'KRITISCH'] },
OR: [{ userId }, { userId: null }],
userId,
},
_count: { _all: true },
});
@@ -45,7 +45,7 @@ describe('AutomationService', () => {
$connect: jest.Mock;
};
let auditLog: { record: jest.Mock };
let notifications: { record: jest.Mock };
let notifications: { recordForPermission: jest.Mock };
let tasks: { createInTx: jest.Mock };
const queenlessRule: Rule = {
@@ -102,7 +102,9 @@ describe('AutomationService', () => {
$connect: jest.fn(),
};
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
notifications = { record: jest.fn().mockResolvedValue({ id: 'n-1' }) };
notifications = {
recordForPermission: jest.fn().mockResolvedValue([{ id: 'n-1' }]),
};
tasks = { createInTx: jest.fn().mockResolvedValue({ id: 'task-1' }) };
service = new AutomationService(
prisma as unknown as PrismaService,
@@ -258,7 +260,9 @@ describe('AutomationService', () => {
staged.push(client === tx ? 'task' : 'task outside tx');
return Promise.resolve({ id: 'task-1' });
});
notifications.record.mockRejectedValue(new Error('notifications down'));
notifications.recordForPermission.mockRejectedValue(
new Error('notifications down'),
);
auditLog.record.mockImplementation((_entry: unknown, client: unknown) => {
staged.push(client === tx ? 'audit' : 'audit outside tx');
return Promise.resolve();
@@ -303,9 +307,11 @@ describe('AutomationService', () => {
},
null,
);
expect(notifications.record).toHaveBeenCalledWith(
expect(notifications.recordForPermission).toHaveBeenCalledWith(
'tasks:read',
{
priority: 'WARNUNG',
category: 'SUGGESTION',
message: 'Kontrolle empfohlen',
relatedType: 'COLONY',
relatedId: 'colony-1',
@@ -332,7 +338,9 @@ describe('AutomationService', () => {
});
it('lets a failed notification write propagate, so the delivery is rolled back and retried', async () => {
notifications.record.mockRejectedValue(new Error('notifications down'));
notifications.recordForPermission.mockRejectedValue(
new Error('notifications down'),
);
await expect(
service.evaluateInspectionIn(asTx(), queenlessInspection as never),
@@ -355,7 +363,7 @@ describe('AutomationService', () => {
select: { id: true },
});
expect(tasks.createInTx).not.toHaveBeenCalled();
expect(notifications.record).not.toHaveBeenCalled();
expect(notifications.recordForPermission).not.toHaveBeenCalled();
expect(auditLog.record).not.toHaveBeenCalled();
});
@@ -403,7 +411,7 @@ describe('AutomationService', () => {
await expect(
service.evaluateInspectionIn(asTx(), queenlessInspection as never),
).rejects.toThrow('tasks down');
expect(notifications.record).not.toHaveBeenCalled();
expect(notifications.recordForPermission).not.toHaveBeenCalled();
expect(auditLog.record).not.toHaveBeenCalled();
});
});
@@ -426,7 +434,7 @@ describe('AutomationService', () => {
await service.evaluateInspectionIn(asTx(), queenlessInspection as never);
expect(tasks.createInTx).toHaveBeenCalledTimes(1);
expect(notifications.record).toHaveBeenCalledTimes(1);
expect(notifications.recordForPermission).toHaveBeenCalledTimes(1);
});
it('does not trigger when the queen was seen', async () => {
@@ -600,8 +600,16 @@ export class AutomationService {
},
null,
);
await this.notifications.record(
{ priority: 'WARNUNG', message: title, relatedType, relatedId },
// Task 7 replaces this path by suggestions.
await this.notifications.recordForPermission(
'tasks:read',
{
priority: 'WARNUNG',
category: 'SUGGESTION',
message: title,
relatedType,
relatedId,
},
tx,
);
await this.auditLog.record(
@@ -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,
'document',
'notification',
'notificationPreference',
'auditLog',
] as const;
@@ -113,6 +114,10 @@ describe('BackupService', () => {
upsert: jest.fn().mockResolvedValue({ id: `${key}-upserted` }),
};
}
tx.notification = {
upsert: jest.fn().mockResolvedValue({ id: 'notification-upserted' }),
findUnique: jest.fn().mockResolvedValue(null),
} as never;
tx.queen.update = jest.fn().mockResolvedValue({});
tx.queen.findMany = jest.fn().mockResolvedValue([]);
tx.document.findUnique = jest.fn().mockResolvedValue(null);
@@ -1304,6 +1309,148 @@ describe('BackupService', () => {
});
});
describe('notifications', () => {
const note = (overrides: Row = {}): Row => ({
id: 'n1',
userId: 'u1',
priority: 'INFO',
category: 'SUGGESTION',
message: 'm',
relatedType: 'COLONY',
relatedId: 'c1',
read: true,
createdAt: '2026-01-01T00:00:00.000Z',
...overrides,
});
const without = (row: Row, ...keys: string[]): Row =>
Object.fromEntries(
Object.entries(row).filter(([key]) => !keys.includes(key)),
);
const importNotes = (rows: Row[]) =>
service.importAll(
{
version: 3,
exportedAt: '2026-01-01T00:00:00.000Z',
data: { notification: rows },
},
'admin-1',
TRUSTED,
);
const written = () =>
(
tx.notification.upsert.mock.calls as [
{ where: { id: string }; create: Row },
][]
).map(([arg]) => arg.create);
beforeEach(() => {
tx.user.findMany.mockResolvedValue([{ id: 'u1' }, { id: 'u2' }]);
});
it('restore keeps the userId of a notification and skips a row whose user does not exist in the target', async () => {
const summary = await importNotes([
note({ id: 'n1', userId: 'u2' }),
note({ id: 'n2', userId: 'gone' }),
]);
expect(written().map((row) => [row.id, row.userId])).toEqual([
['n1', 'u2'],
]);
expect(summary.imported.notification).toBe(1);
expect(summary.imported.notificationSkipped).toBe(1);
});
it('skips and counts a row whose dedupeKey already exists under another id', async () => {
(
tx.notification as unknown as { findUnique: jest.Mock }
).findUnique.mockImplementation(
({ where }: { where: { dedupeKey: string } }) =>
Promise.resolve(
where.dedupeKey === 'k:u1' ? { id: 'other-id' } : null,
),
);
const summary = await importNotes([
note({ id: 'n1', dedupeKey: 'k:u1' }),
note({ id: 'n2', dedupeKey: 'k:u2' }),
// Same id as the stored row: a plain re-restore, written as before.
note({ id: 'other-id', dedupeKey: 'k:u1' }),
]);
expect(written().map((row) => row.id)).toEqual(['n2', 'other-id']);
expect(summary.imported.notification).toBe(2);
expect(summary.imported.notificationSkipped).toBe(1);
});
it('a repeated restore of broadcast copies does not reset their read flags', async () => {
const { userId: _u, category: _c, ...old } = note({ id: 'b' });
void _u;
void _c;
await importNotes([old]);
const calls = tx.notification.upsert.mock.calls as [{ update: Row }][];
expect(calls).toHaveLength(2);
expect(calls.every(([arg]) => Object.keys(arg.update).length === 0)).toBe(
true,
);
});
it('never writes a notification without a userId', async () => {
await importNotes([
note({ userId: null }),
note({ id: 'n3', userId: 'gone' }),
]);
expect(written().every((row) => typeof row.userId === 'string')).toBe(
true,
);
});
it('an old bundle notification without userId is copied per active user: category SYSTEM for relatedType JobRun or none, SUGGESTION otherwise', async () => {
const old = (id: string, relatedType: string | null): Row => {
return without(note({ id, relatedType }), 'userId', 'category');
};
await importNotes([
old('a', 'JobRun'),
old('b', null),
old('c', 'COLONY'),
]);
const rows = written().map((row) => [row.userId, row.category, row.read]);
expect(rows).toEqual([
['u1', 'SYSTEM', true],
['u2', 'SYSTEM', true],
['u1', 'SYSTEM', true],
['u2', 'SYSTEM', true],
['u1', 'SUGGESTION', true],
['u2', 'SUGGESTION', true],
]);
expect(new Set(written().map((row) => row.id)).size).toBe(6);
});
it('fills the category of an old row that has a userId but none', async () => {
await importNotes([without(note({ relatedType: 'JobRun' }), 'category')]);
expect(written()[0]).toMatchObject({ userId: 'u1', category: 'SYSTEM' });
});
it('exports and restores the notification preferences of the users', async () => {
prisma.notificationPreference.findMany.mockResolvedValue([
{ userId: 'u1', digestTime: '07:00' },
{ userId: 'gone', digestTime: '08:00' },
]);
const bundle = await service.exportAll();
expect(bundle.data.notificationPreference).toHaveLength(2);
await service.importAll(bundle, 'admin-1', TRUSTED);
const calls = tx.notificationPreference.upsert.mock.calls as [
{ where: { userId: string } },
][];
expect(calls.map(([arg]) => arg.where.userId)).toEqual(['u1']);
});
});
// Drift guard: a model added to the schema has to be backed up or listed
// in MODELS_EXCLUDED_FROM_BACKUP with its reason - never silently missing.
describe('model coverage', () => {
+99 -13
View File
@@ -71,7 +71,8 @@ export interface BackupImportSummary {
* - siteSettings: keeper name/address printed on the Bestandsbuch and the
* public links.
* - document: writes files to the server's disk.
* - notification: messages shown to users as coming from ApiCore.
* - notification, notificationPreference: messages shown to users as coming
* from ApiCore, and settings of a user.
*/
export const LEGACY_UNSEALED_TABLES = [
'apiary',
@@ -188,6 +189,23 @@ function withCurrentFeedVocabulary(row: Row): Row {
};
}
// Bundles written before the notification categories carry none: job failures
// (relatedType 'JobRun') and rows without relatedType are system messages,
// everything else a suggestion - the rule of migration 11.
function withCurrentNotificationCategory(row: Row): Row {
if (typeof row.category === 'string') {
return row;
}
const relatedType = asString(row, 'relatedType');
return {
...row,
category:
relatedType === null || relatedType === 'JobRun'
? 'SYSTEM'
: 'SUGGESTION',
};
}
function documentsStorageRoot(): string {
return process.env.DOCUMENTS_STORAGE_PATH ?? './uploads';
}
@@ -316,6 +334,8 @@ export class BackupService {
);
data.notification = await this.prisma.notification.findMany();
data.notificationPreference =
await this.prisma.notificationPreference.findMany();
data.auditLog = await this.prisma.auditLog.findMany();
return {
@@ -1125,20 +1145,86 @@ export class BackupService {
}
imported.document = documentCount;
const activeUserIds = (
await tx.user.findMany({
where: { isActive: true },
select: { id: true },
orderBy: { id: 'asc' },
})
).map((user) => user.id);
let notificationsWritten = 0;
let notificationsSkipped = 0;
for (const row of rowsOf('notification')) {
await tx.notification.upsert({
where: { id: row.id as string },
create: {
...row,
userId: resolveOptionalUserId(asString(row, 'userId')),
} as never,
update: {
...row,
userId: resolveOptionalUserId(asString(row, 'userId')),
} as never,
});
const withCategory = withCurrentNotificationCategory(row);
const rawUserId = asString(row, 'userId');
// A bundle from before the per-user notifications carries
// broadcasts (no userId): one copy per active user, as migration 8.
// A row of a user this database does not know is skipped - the
// userId is mandatory and never falls back to somebody else.
const owners =
rawUserId === null
? activeUserIds
: knownUserIds.has(rawUserId)
? [rawUserId]
: [];
if (owners.length === 0) {
notificationsSkipped += 1;
continue;
}
for (const userId of owners) {
const data = {
...withCategory,
id:
rawUserId === null
? `${row.id as string}:${userId}`
: (row.id as string),
userId,
};
// The unique dedupeKey of a row that lives here under another id
// would abort the whole restore: skip that row instead.
const dedupeKey = asString(data, 'dedupeKey');
if (dedupeKey !== null) {
const holder = await tx.notification.findUnique({
where: { dedupeKey },
select: { id: true },
});
if (holder && holder.id !== data.id) {
notificationsSkipped += 1;
continue;
}
}
await tx.notification.upsert({
where: { id: data.id },
create: data as never,
// A copy made from a broadcast is only added: a repeated
// restore must not reset what the user already read.
update: (rawUserId === null ? {} : data) as never,
});
notificationsWritten += 1;
}
}
imported.notification = rowsOf('notification').length;
imported.notification = notificationsWritten;
if (notificationsSkipped > 0) {
imported.notificationSkipped = notificationsSkipped;
this.logger.warn(
`${notificationsSkipped} Meldung(en) übersprungen: Nutzer im Zielsystem unbekannt.`,
);
}
let preferencesWritten = 0;
for (const row of rowsOf('notificationPreference')) {
const userId = asString(row, 'userId');
if (userId === null || !knownUserIds.has(userId)) {
continue;
}
await tx.notificationPreference.upsert({
where: { userId },
create: row as never,
update: row as never,
});
preferencesWritten += 1;
}
imported.notificationPreference = preferencesWritten;
// Append-only: entries already here are never rewritten by a file.
for (const row of rowsOf('auditLog')) {
@@ -23,6 +23,7 @@ describe('CalendarSyncSettingsService', () => {
syncFeeding: true,
syncHarvest: true,
syncQueenAssignment: true,
syncWithdrawal: true,
updatedAt: new Date('2026-01-01'),
};
@@ -87,6 +88,7 @@ describe('CalendarSyncSettingsService', () => {
syncFeeding: undefined,
syncHarvest: undefined,
syncQueenAssignment: undefined,
syncWithdrawal: undefined,
},
});
expect(result.groupId).toBe('group-abc');
@@ -149,8 +151,25 @@ describe('CalendarSyncSettingsService', () => {
syncFeeding: undefined,
syncHarvest: undefined,
syncQueenAssignment: undefined,
syncWithdrawal: undefined,
},
});
});
it('passes the withdrawal switch on', async () => {
prisma.calendarSyncSettings.findUnique.mockResolvedValue(existing);
prisma.calendarSyncSettings.update.mockResolvedValue({
...existing,
syncWithdrawal: false,
});
await service.update({ syncWithdrawal: false }, 'user-1');
expect(prisma.calendarSyncSettings.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ syncWithdrawal: false }) as unknown,
}),
);
});
});
});
@@ -47,6 +47,7 @@ export class CalendarSyncSettingsService {
syncFeeding: dto.syncFeeding,
syncHarvest: dto.syncHarvest,
syncQueenAssignment: dto.syncQueenAssignment,
syncWithdrawal: dto.syncWithdrawal,
},
});
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 () => {
await handlers.task(
envelope('TaskCreated', 'task-1', { dueDate: null }),
@@ -105,6 +123,12 @@ describe('CalendarSyncEventHandlers', () => {
);
expect(registered('feeding')[0].handlerId).toBe('calendar-sync.feeding');
expect(registered('harvest')[0].handlerId).toBe('calendar-sync.harvest');
expect(registered('queenAssignment')).toEqual([
{
types: ['QueenAssigned'],
handlerId: 'calendar-sync.queen-assignment',
},
]);
expect(registered('task')).toEqual([
{
types: ['TaskCreated', 'TaskUpdated', 'TaskCompleted'],
@@ -74,4 +74,17 @@ export class CalendarSyncEventHandlers {
}
await this.calendarSync.enqueueIn(tx, 'TASK', event.aggregateId);
}
// The aggregate is the queen; the calendar entry belongs to the history row.
@OnDomainEvent(['QueenAssigned'], 'calendar-sync.queen-assignment')
async queenAssignment(
event: AnyDomainEventEnvelope<'QueenAssigned'>,
tx: Prisma.TransactionClient,
): Promise<void> {
await this.calendarSync.enqueueIn(
tx,
'QUEEN_ASSIGNMENT',
event.payload.historyId,
);
}
}
@@ -42,6 +42,7 @@ describe('CalendarSyncService', () => {
syncFeeding: true,
syncHarvest: true,
syncQueenAssignment: true,
syncWithdrawal: true,
updatedAt: new Date(),
};
@@ -122,11 +123,22 @@ describe('CalendarSyncService', () => {
expect(prisma.calendarSyncOutboxEntry.upsert).not.toHaveBeenCalled();
});
it('does not enqueue a WITHDRAWAL entry yet (the switch arrives with Task 3)', async () => {
it('enqueues a WITHDRAWAL entry while syncWithdrawal is on', async () => {
settingsService.get.mockResolvedValue(enabledSettings);
await service.enqueue('WITHDRAWAL', 'treatment-1');
expect(prisma.calendarSyncOutboxEntry.upsert).toHaveBeenCalled();
});
it('does not enqueue a WITHDRAWAL entry when syncWithdrawal is off', async () => {
settingsService.get.mockResolvedValue({
...enabledSettings,
syncWithdrawal: false,
});
await service.enqueue('WITHDRAWAL', 'treatment-1');
expect(prisma.calendarSyncOutboxEntry.upsert).not.toHaveBeenCalled();
});
@@ -103,8 +103,7 @@ export class CalendarSyncService {
FEEDING: settings.syncFeeding,
HARVEST: settings.syncHarvest,
QUEEN_ASSIGNMENT: settings.syncQueenAssignment,
// Switch in the settings arrives with Task 3.
WITHDRAWAL: false,
WITHDRAWAL: settings.syncWithdrawal,
};
return flagBySourceType[sourceType];
}
@@ -66,4 +66,9 @@ export class UpdateCalendarSyncSettingsDto {
@IsOptionalNotNull()
@IsBoolean()
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 { WebhooksService } from '../webhooks/webhooks.service';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { berlinToday } from '../common/dates';
describe('ColoniesService', () => {
let service: ColoniesService;
@@ -139,6 +140,96 @@ describe('ColoniesService', () => {
expect(lineageArg.data.toColonyId).toBe('colony-2');
});
it('split records ColonySplit with queenSource and the given date, audit inside the transaction', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
const child = { id: 'colony-2', code: 'V-001-A', apiaryId: 'apiary-1' };
tx.colony.create.mockResolvedValue(child);
tx.colonyLineage.create.mockResolvedValue({ id: 'lineage-1' });
await service.split(
'colony-1',
{
code: 'V-001-A',
colonyType: 'NUCLEUS_COLONY',
date: '2026-05-05',
queenSource: 'QUEEN_CELL',
},
'user-1',
);
expect(tx.$queryRaw).toHaveBeenCalledTimes(1);
const [[lineageArg]] = tx.colonyLineage.create.mock.calls as [
[{ data: { date: Date; queenSource: string } }],
];
expect(lineageArg.data.date).toEqual(new Date('2026-05-05T00:00:00.000Z'));
expect(lineageArg.data.queenSource).toBe('QUEEN_CELL');
expect(events.record).toHaveBeenCalledWith(
tx,
expect.objectContaining({
type: 'ColonySplit',
aggregateType: 'Colony',
aggregateId: 'colony-2',
actorUserId: 'user-1',
payload: {
parentColonyId: 'colony-1',
lineageId: 'lineage-1',
queenSource: 'QUEEN_CELL',
splitOn: '2026-05-05',
},
}),
);
expect(auditLog.record).toHaveBeenCalledWith(expect.anything(), tx);
});
it('split without a date uses today and a null queenSource', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.create.mockResolvedValue({ id: 'colony-2' });
tx.colonyLineage.create.mockResolvedValue({ id: 'lineage-1' });
await service.split(
'colony-1',
{ code: 'V-001-A', colonyType: 'NUCLEUS_COLONY' },
'user-1',
);
const [[, event]] = events.record.mock.calls as [
[unknown, { payload: { queenSource: unknown; splitOn: string } }],
];
expect(event.payload.queenSource).toBeNull();
expect(event.payload.splitOn).toBe(berlinToday());
});
it('split with a future date answers 400', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
await expect(
service.split(
'colony-1',
{ code: 'V-001-A', colonyType: 'NUCLEUS_COLONY', date: '2999-01-01' },
'user-1',
),
).rejects.toMatchObject({
status: 400,
message: 'Das Datum liegt in der Zukunft.',
});
expect(tx.colony.create).not.toHaveBeenCalled();
});
it('split of a colony that ended meanwhile (seen after the lock) answers 409', async () => {
prisma.colony.findUnique
.mockResolvedValueOnce(parentColony)
.mockResolvedValueOnce({ ...parentColony, status: 'DISSOLVED' });
await expect(
service.split(
'colony-1',
{ code: 'V-001-A', colonyType: 'NUCLEUS_COLONY' },
'user-1',
),
).rejects.toBeInstanceOf(ConflictException);
expect(tx.colony.create).not.toHaveBeenCalled();
});
it('merge marks the merged colony as MERGED, releases its queen, and records a MERGE lineage entry', async () => {
prisma.colony.findUnique
.mockResolvedValueOnce(parentColony)
@@ -502,6 +593,7 @@ describe('ColoniesService', () => {
it('writes the audit entry of create, move and split inside the transaction', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.create.mockResolvedValue({ id: 'colony-2' });
tx.colonyLineage.create.mockResolvedValue({ id: 'lineage-1' });
tx.colony.update.mockResolvedValue({
...parentColony,
apiaryId: 'apiary-2',
@@ -17,6 +17,7 @@ import { MergeColonyDto } from './dto/merge-colony.dto';
import { isLivingColonyStatus } from './colony-status';
import { lockRowsForUpdate } from '../prisma/row-lock';
import { colonyTransitionEvents, lockColony } from './colony-events';
import { berlinToday, dayToDate } from '../common/dates';
export interface ColonyFilter {
status?: ColonyStatus;
@@ -194,9 +195,16 @@ export class ColoniesService {
dto: SplitColonyDto,
actingUserId: string,
): Promise<Colony> {
const parent = await this.findById(id);
this.assertActive(parent);
await this.findById(id);
const today = berlinToday();
const splitOn = dto.date ?? today;
if (splitOn > today) {
throw new BadRequestException('Das Datum liegt in der Zukunft.');
}
const queenSource = dto.queenSource ?? null;
const child = await this.prisma.$transaction(async (tx) => {
const parent = await lockColony(tx, id);
this.assertActive(parent);
const created = await tx.colony.create({
data: {
code: dto.code,
@@ -205,12 +213,13 @@ export class ColoniesService {
notes: dto.notes,
},
});
await tx.colonyLineage.create({
const lineage = await tx.colonyLineage.create({
data: {
type: 'SPLIT',
fromColonyId: parent.id,
toColonyId: created.id,
date: new Date(),
date: dayToDate(splitOn),
queenSource,
notes: dto.notes,
},
});
@@ -224,6 +233,18 @@ export class ColoniesService {
},
tx,
);
await this.events.record(tx, {
type: 'ColonySplit',
aggregateType: 'Colony',
aggregateId: created.id,
actorUserId: actingUserId,
payload: {
parentColonyId: parent.id,
lineageId: lineage.id,
queenSource,
splitOn,
},
});
return created;
});
return child;
@@ -1,6 +1,6 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsOptional, IsString } from 'class-validator';
import { ColonyType } from '../../../generated/prisma';
import { IsEnum, IsOptional, IsString, Matches } from 'class-validator';
import { ColonyType, SplitQueenSource } from '../../../generated/prisma';
export class SplitColonyDto {
@ApiProperty()
@@ -15,4 +15,16 @@ export class SplitColonyDto {
@IsOptional()
@IsString()
notes?: string;
@ApiPropertyOptional({
description: 'Day of the split (YYYY-MM-DD), not after today.',
})
@IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/, { message: 'date must be YYYY-MM-DD' })
date?: string;
@ApiPropertyOptional({ enum: SplitQueenSource })
@IsOptional()
@IsEnum(SplitQueenSource)
queenSource?: SplitQueenSource;
}
@@ -43,10 +43,8 @@ describe('legacy after-commit paths', () => {
]);
});
it('only the queen assignment still enqueues a calendar entry directly', () => {
expect(filesCalling('calendarSync.enqueue(')).toEqual([
'src/queens/queens.service.ts',
]);
it('no module enqueues a calendar entry directly any more', () => {
expect(filesCalling('calendarSync.enqueue(')).toEqual([]);
});
it('only the modules without an event still dispatch webhooks directly', () => {
@@ -71,9 +71,13 @@ function orderColumn(column: OutboxOrderColumn): Prisma.Sql {
}
/**
* Claims up to `limit` due PENDING entries, oldest first. SKIP LOCKED lets
* several workers claim side by side without waiting for or overlapping
* each other. Returns the claimed rows (all columns) in claim order.
* Claims up to `limit` due PENDING entries, oldest first. "availableAt" is
* TIMESTAMP(3), so a value written from now() is rounded to the nearest
* millisecond - up to 0.5 ms ahead of the clock. Comparing against now()
* rounded the same way keeps such an entry due as soon as it is written;
* a plain now() would miss it for the first fraction of a millisecond.
* SKIP LOCKED lets several workers claim side by side without waiting for
* or overlapping each other. Returns the claimed rows (all columns) in claim order.
* Uses the root client: a claim must commit on its own.
*/
export async function claimBatch<Row>(
@@ -96,7 +100,7 @@ export async function claimBatch<Row>(
UPDATE ${name} SET "status" = 'PROCESSING', "claimedAt" = now()
WHERE "id" IN (
SELECT "id" FROM ${name}
WHERE "status" = 'PENDING' AND "availableAt" <= now() ${filter}
WHERE "status" = 'PENDING' AND "availableAt" <= CAST(now() AS timestamp(3)) ${filter}
ORDER BY ${orderBy}
LIMIT ${options.limit}
FOR UPDATE SKIP LOCKED
@@ -15,6 +15,7 @@ import { UpdateHoneyBatchDto } from '../../honey-batches/dto/update-honey-batch.
import { UpdateHoneyMeasurementDto } from '../../honey-batches/dto/update-honey-measurement.dto';
import { UpdateInspectionDto } from '../../inspections/dto/update-inspection.dto';
import { UpdateLabelTemplateDto } from '../../label-templates/dto/update-label-template.dto';
import { UpdateNotificationSettingsDto } from '../../notifications/dto/update-notification-settings.dto';
import { UpdateProductionBatchDto } from '../../production-batches/dto/update-production-batch.dto';
import { UpdateProductDto } from '../../products/dto/update-product.dto';
import { UpdateProfileDto } from '../../profile/dto/update-profile.dto';
@@ -78,9 +79,23 @@ const contracts: Record<string, NullContract> = {
'syncFeeding',
'syncHarvest',
'syncQueenAssignment',
'syncWithdrawal',
],
clearable: ['groupId'],
},
// quietFrom/quietUntil: null clears them, but only as a pair (the service
// judges the merged values).
UpdateNotificationSettingsDto: {
dto: UpdateNotificationSettingsDto,
notNull: [
'taskDueEnabled',
'digestEnabled',
'digestTime',
'telegramCategories',
'telegramDetail',
],
clearable: ['quietFrom', 'quietUntil'],
},
UpdateBottlingBatchDto: {
dto: UpdateBottlingBatchDto,
notNull: [
@@ -173,7 +173,7 @@ describe('DomainEventDispatcher', () => {
`UPDATE "DomainEvent" SET "status" = 'PROCESSING', "claimedAt" = now()`,
);
expect(text(claim)).toContain(
`WHERE "status" = 'PENDING' AND "availableAt" <= now()`,
`WHERE "status" = 'PENDING' AND "availableAt" <= CAST(now() AS timestamp(3))`,
);
expect(text(claim)).toContain(
'ORDER BY "occurredAt" LIMIT ? FOR UPDATE SKIP LOCKED',
@@ -186,7 +186,7 @@ describe('DomainEventDispatcher', () => {
const claim = db.claimQuery();
expect(text(claim)).toContain(
`"availableAt" <= now() AND "correlationId" = ANY(?) ORDER BY`,
`"availableAt" <= CAST(now() AS timestamp(3)) AND "correlationId" = ANY(?) ORDER BY`,
);
expect(claim.values).toEqual([['c1', 'c2'], 50]);
});
@@ -3,6 +3,7 @@ import {
ColonyStatus,
Prisma,
RawMaterialKind,
SplitQueenSource,
TaskRelatedType,
TaskStatus,
} from '../../generated/prisma';
@@ -68,6 +69,20 @@ export interface DomainEventPayloads {
lastInspectedOn: string | null;
firstOfYear?: boolean;
};
// Aggregate Colony: the new split colony.
ColonySplit: {
parentColonyId: string;
lineageId: string;
queenSource: SplitQueenSource | null;
splitOn: string;
};
// Aggregate Queen.
QueenAssigned: {
colonyId: string;
historyId: string;
assignedOn: string;
previousColonyId: string | null;
};
}
export type DomainEventType = keyof DomainEventPayloads;
@@ -97,6 +112,8 @@ const CATALOG: Record<DomainEventType, true> = {
QueenAged: true,
VarroaMeasurementDue: true,
InspectionOverdue: true,
ColonySplit: true,
QueenAssigned: true,
};
export const DOMAIN_EVENT_TYPES: readonly DomainEventType[] = Object.freeze(
@@ -183,7 +183,7 @@ describe('DomainEventsService', () => {
describe('domain event catalog', () => {
it('lists every event type exactly once', () => {
expect(DOMAIN_EVENT_TYPES).toHaveLength(23);
expect(DOMAIN_EVENT_TYPES).toHaveLength(25);
expect(new Set(DOMAIN_EVENT_TYPES).size).toBe(DOMAIN_EVENT_TYPES.length);
expect(DOMAIN_EVENT_TYPES).toEqual(
expect.arrayContaining([
@@ -191,6 +191,8 @@ describe('domain event catalog', () => {
'StockChanged',
'TaskDue',
'InspectionOverdue',
'ColonySplit',
'QueenAssigned',
]),
);
});
@@ -174,10 +174,10 @@ describe('daily evaluation rules', () => {
}),
).toBe('2026-10-12'));
it('ends on the treatment day itself without a waiting time and ignores the time of day', () =>
it('ends on the treatment day itself without a waiting time and uses the Berlin day, ignoring the time of day', () =>
expect(
withdrawalEndDay({
date: new Date('2026-09-28T22:15:00.000Z'),
date: new Date('2026-09-28T22:15:00+02:00'),
endDate: null,
withdrawalPeriodDays: 0,
}),
@@ -12,6 +12,7 @@ import { inspectionDueOn } from '../season/inspection-due';
import { isQueenAged } from '../season/queen-age';
import type { SeasonService } from '../season/season.service';
import { varroaMeasurementDue } from '../season/varroa-due';
import { withdrawalUntilDay } from '../treatments/withdrawal-window';
/** How many days back a run still records an occurrence it missed. */
export const CATCH_UP_DAYS = 7;
@@ -82,10 +83,7 @@ function dayAfter(date: Date, days: number): Date {
export function withdrawalEndDay(
t: Pick<Treatment, 'date' | 'endDate' | 'withdrawalPeriodDays'>,
): string | null {
if (t.withdrawalPeriodDays === null) {
return null;
}
return utcDay(dayAfter(t.endDate ?? t.date, t.withdrawalPeriodDays));
return withdrawalUntilDay(t);
}
/** True for a 'YYYY-MM-DD' day from today − CATCH_UP_DAYS up to today. */
@@ -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) {}
@Get()
findAll(
findOwn(
@CurrentUser() actor: AuthenticatedUser,
@Query('unreadOnly') unreadOnly?: string,
) {
return this.notificationsService.findAll({
userId: actor.id,
return this.notificationsService.findOwn(actor.id, {
unreadOnly: unreadOnly === 'true',
});
}
// Declared before ':id/read' so the fixed path is matched first.
@Post('read-all')
markAllRead(@CurrentUser() actor: AuthenticatedUser) {
return this.notificationsService.markAllRead(actor.id);
}
@Post(':id/read')
markRead(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
return this.notificationsService.markRead(id, actor.id);
@@ -1,10 +1,12 @@
import { Module } from '@nestjs/common';
import { NotificationPreferencesService } from './notification-preferences.service';
import { NotificationSettingsController } from './notification-settings.controller';
import { NotificationsController } from './notifications.controller';
import { NotificationsService } from './notifications.service';
@Module({
controllers: [NotificationsController],
providers: [NotificationsService],
exports: [NotificationsService],
controllers: [NotificationsController, NotificationSettingsController],
providers: [NotificationsService, NotificationPreferencesService],
exports: [NotificationsService, NotificationPreferencesService],
})
export class NotificationsModule {}
@@ -1,18 +1,30 @@
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { NotFoundException } from '@nestjs/common';
import { NotificationsService } from './notifications.service';
import { assertAppLink } from './notification-link';
import { PrismaService } from '../prisma/prisma.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
type CreateManyArg = { data: { userId: string; message: string }[] };
type CreateManyArg = {
data: {
userId: string;
message: string;
dedupeKey: string | null;
requiredPermissions: string[];
category: string;
link: string | null;
}[];
skipDuplicates: boolean;
};
describe('NotificationsService', () => {
let service: NotificationsService;
let held: Set<string>;
let prisma: {
notification: {
create: jest.Mock;
createMany: jest.Mock;
createManyAndReturn: jest.Mock;
findMany: jest.Mock;
findUnique: jest.Mock;
update: jest.Mock;
findFirstOrThrow: jest.Mock;
updateMany: jest.Mock;
};
user: { findMany: jest.Mock };
};
@@ -20,177 +32,375 @@ describe('NotificationsService', () => {
beforeEach(() => {
prisma = {
notification: {
create: jest.fn(),
createMany: jest
createManyAndReturn: jest
.fn()
.mockImplementation(({ data }: CreateManyArg) =>
Promise.resolve({ count: data.length }),
Promise.resolve(data.map((row, i) => ({ id: `n${i}`, ...row }))),
),
findMany: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
findMany: jest.fn().mockResolvedValue([]),
findFirstOrThrow: jest.fn(),
updateMany: jest.fn(),
},
user: { findMany: jest.fn().mockResolvedValue([]) },
};
service = new NotificationsService(prisma as unknown as PrismaService);
});
it('records a notification with the given priority and message', async () => {
const created = {
id: 'n1',
priority: 'KRITISCH',
message: 'Keine Königin gesehen',
};
prisma.notification.create.mockResolvedValueOnce(created);
const result = await service.record({
priority: 'KRITISCH',
message: 'Keine Königin gesehen',
});
expect(result).toEqual(created);
expect(prisma.notification.create).toHaveBeenCalledWith({
data: { priority: 'KRITISCH', message: 'Keine Königin gesehen' },
});
});
it('records through the transaction client when one is given', async () => {
const tx = { notification: { create: jest.fn() } };
await service.record(
{ priority: 'HINWEIS', message: 'Aufgabe fällig' },
tx as never,
held = new Set();
service = new NotificationsService(
prisma as unknown as PrismaService,
{
getPermissions: jest
.fn()
.mockImplementation(() => Promise.resolve(held)),
} as unknown as PermissionResolverService,
);
});
expect(tx.notification.create).toHaveBeenCalledTimes(1);
expect(prisma.notification.create).not.toHaveBeenCalled();
const lastCreate = () =>
(
prisma.notification.createManyAndReturn.mock.calls[0] as [CreateManyArg]
)[0];
it('record without userId does not compile', () => {
// @ts-expect-error userId fehlt
void service.record({ priority: 'INFO', category: 'SYSTEM', message: 'm' });
});
describe('record', () => {
it('writes one row for an eligible recipient and returns it', async () => {
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }]);
const result = await service.record({
userId: 'u1',
priority: 'KRITISCH',
category: 'SYSTEM',
message: 'Keine Königin gesehen',
dedupeKey: 'k:u1',
});
expect(result).toMatchObject({ userId: 'u1', dedupeKey: 'k:u1' });
expect(lastCreate().skipDuplicates).toBe(true);
expect(lastCreate().data).toEqual([
{
userId: 'u1',
priority: 'KRITISCH',
category: 'SYSTEM',
message: 'Keine Königin gesehen',
relatedType: null,
relatedId: null,
link: null,
requiredPermissions: [],
dedupeKey: 'k:u1',
},
]);
});
it('returns null for a recipient without the permission', async () => {
await expect(
service.record({
userId: 'u1',
priority: 'INFO',
category: 'SYSTEM',
message: 'm',
requiredPermissions: ['colonies:read'],
}),
).resolves.toBeNull();
expect(prisma.notification.createManyAndReturn).not.toHaveBeenCalled();
});
it('returns null for a repeated dedupe key', async () => {
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }]);
prisma.notification.createManyAndReturn.mockResolvedValue([]);
await expect(
service.record({
userId: 'u1',
priority: 'INFO',
category: 'SYSTEM',
message: 'm',
dedupeKey: 'k:u1',
}),
).resolves.toBeNull();
});
it('writes through the transaction client when one is given', async () => {
const tx = {
user: { findMany: jest.fn().mockResolvedValue([{ id: 'u1' }]) },
notification: {
createManyAndReturn: jest.fn().mockResolvedValue([{ id: 'n' }]),
},
};
await service.record(
{ userId: 'u1', priority: 'INFO', category: 'SYSTEM', message: 'm' },
tx as never,
);
expect(tx.notification.createManyAndReturn).toHaveBeenCalledTimes(1);
expect(prisma.notification.createManyAndReturn).not.toHaveBeenCalled();
});
it('rejects an unsafe link before writing', async () => {
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }]);
await expect(
service.record({
userId: 'u1',
priority: 'INFO',
category: 'SYSTEM',
message: 'm',
link: 'https://evil.example',
}),
).rejects.toThrow();
expect(prisma.notification.createManyAndReturn).not.toHaveBeenCalled();
});
});
describe('recordForPermission', () => {
it('notifies every active user whose active role holds the permission', async () => {
const input = {
priority: 'WARNUNG' as const,
category: 'SYSTEM' as const,
message: 'Job „App-Backup“ fehlgeschlagen',
};
it('writes one row per active user holding every permission, with a key per recipient', async () => {
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }, { id: 'u2' }]);
await expect(
service.recordForPermission('backup:configure', {
priority: 'WARNUNG',
message: 'Job „App-Backup“ fehlgeschlagen',
}),
).resolves.toBe(2);
const [[createArg]] = prisma.notification.createMany.mock.calls as [
[CreateManyArg],
];
expect(createArg.data.map((n) => n.userId)).toEqual(['u1', 'u2']);
expect(createArg.data[0]).toEqual({
userId: 'u1',
priority: 'WARNUNG',
message: 'Job „App-Backup“ fehlgeschlagen',
const rows = await service.recordForPermission('backup:configure', {
...input,
dedupeKeyPrefix: 'p',
});
expect(rows).toHaveLength(2);
expect(lastCreate().data.map((r) => [r.userId, r.dedupeKey])).toEqual([
['u1', 'p:u1'],
['u2', 'p:u2'],
]);
});
it('selects recipients like the permission guard: active user, active role, granted key', async () => {
await service.recordForPermission('backup:configure', {
priority: 'WARNUNG',
message: 'x',
});
it('selects recipients like the permission guard: active user, active role, every key granted', async () => {
await service.recordForPermission(
['backup:configure', 'colonies:read'],
input,
);
expect(prisma.user.findMany).toHaveBeenCalledWith({
where: {
isActive: true,
role: {
status: 'ACTIVE',
permissions: {
some: { permission: { key: 'backup:configure' } },
},
AND: [
{
permissions: {
some: { permission: { key: 'backup:configure' } },
},
},
{
permissions: { some: { permission: { key: 'colonies:read' } } },
},
],
},
},
select: { id: true },
orderBy: { id: 'asc' },
});
});
it('writes nothing and returns 0 when nobody holds the permission', async () => {
it('stores the union of recipient and content permissions', async () => {
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }]);
await service.recordForPermission('colonies:read', {
...input,
requiredPermissions: ['inspections:read', 'colonies:read'],
});
expect(lastCreate().data[0].requiredPermissions).toEqual([
'colonies:read',
'inspections:read',
]);
});
it('throws for an empty permission list instead of broadcasting', async () => {
await expect(service.recordForPermission([], input)).rejects.toThrow();
expect(prisma.user.findMany).not.toHaveBeenCalled();
});
it('writes nothing when nobody holds the permission', async () => {
await expect(
service.recordForPermission('backup:configure', input),
).resolves.toEqual([]);
expect(prisma.notification.createManyAndReturn).not.toHaveBeenCalled();
});
it('a repeated dedupe key writes nothing', async () => {
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }]);
prisma.notification.createManyAndReturn.mockResolvedValue([]);
await expect(
service.recordForPermission('backup:configure', {
priority: 'WARNUNG',
message: 'x',
...input,
dedupeKeyPrefix: 'p',
}),
).resolves.toBe(0);
expect(prisma.notification.createMany).not.toHaveBeenCalled();
).resolves.toEqual([]);
});
it('reads and writes through the transaction client when one is given', async () => {
const tx = {
user: { findMany: jest.fn().mockResolvedValue([{ id: 'u1' }]) },
notification: {
createMany: jest.fn().mockResolvedValue({ count: 1 }),
createManyAndReturn: jest.fn().mockResolvedValue([{ id: 'n1' }]),
},
};
await expect(
service.recordForPermission(
'backup:configure',
{ priority: 'WARNUNG', message: 'x' },
tx as never,
),
).resolves.toBe(1);
service.recordForPermission('backup:configure', input, tx as never),
).resolves.toHaveLength(1);
expect(tx.notification.createMany).toHaveBeenCalledTimes(1);
expect(prisma.user.findMany).not.toHaveBeenCalled();
expect(prisma.notification.createMany).not.toHaveBeenCalled();
expect(prisma.notification.createManyAndReturn).not.toHaveBeenCalled();
});
});
it('lists notifications for a user including broadcast notifications', async () => {
await service.findAll({ userId: 'user-1' });
describe('recordForUsers', () => {
it('skips users missing a required permission', async () => {
prisma.user.findMany.mockResolvedValue([{ id: 'u2' }]);
const [[queryArg]] = prisma.notification.findMany.mock.calls as [
[{ where: { OR?: { userId: string | null }[] } }],
];
expect(queryArg.where.OR).toEqual([{ userId: 'user-1' }, { userId: null }]);
});
const rows = await service.recordForUsers(['u1', 'u2'], {
priority: 'INFO',
category: 'SUGGESTION',
message: 'm',
requiredPermissions: ['colonies:read'],
});
it('throws NotFoundException when marking an unknown notification as read', async () => {
prisma.notification.findUnique.mockResolvedValueOnce(null);
await expect(service.markRead('missing', 'user-1')).rejects.toBeInstanceOf(
NotFoundException,
);
});
expect(prisma.user.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
id: { in: ['u1', 'u2'] },
}) as unknown,
}),
);
expect(rows.map((r) => r.userId)).toEqual(['u2']);
});
it("throws ForbiddenException when marking another user's notification as read", async () => {
prisma.notification.findUnique.mockResolvedValueOnce({
id: 'n1',
userId: 'owner',
read: false,
it('does nothing for an empty user list', async () => {
await expect(
service.recordForUsers([], {
priority: 'INFO',
category: 'SYSTEM',
message: 'm',
}),
).resolves.toEqual([]);
expect(prisma.user.findMany).not.toHaveBeenCalled();
});
await expect(service.markRead('n1', 'someone-else')).rejects.toBeInstanceOf(
ForbiddenException,
);
});
it('marks a broadcast notification (no owner) as read for any user', async () => {
prisma.notification.findUnique.mockResolvedValueOnce({
id: 'n1',
userId: null,
read: false,
describe('reading', () => {
it('lists only the own notifications, newest first, at most 100', async () => {
await service.findOwn('user-1', { unreadOnly: true });
expect(prisma.notification.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: 'user-1', read: false },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
take: 500,
}),
);
});
prisma.notification.update.mockResolvedValueOnce({ id: 'n1', read: true });
const result = await service.markRead('n1', 'any-user');
it('hides a row whose permissions the user no longer holds (revoked after the write)', async () => {
const row = (id: string, requiredPermissions: string[]) => ({
id,
priority: 'INFO',
category: 'SYSTEM',
message: 'm',
relatedType: null,
relatedId: null,
link: null,
read: false,
createdAt: new Date(0),
requiredPermissions,
});
prisma.notification.findMany.mockResolvedValue([
row('open', []),
row('revoked', ['colonies:read', 'varroa:read']),
row('kept', ['colonies:read']),
]);
held.add('colonies:read');
expect(result.read).toBe(true);
});
const views = await service.findOwn('user-1');
it("marks the owner's own notification as read", async () => {
prisma.notification.findUnique.mockResolvedValueOnce({
id: 'n1',
userId: 'user-1',
read: false,
expect(views.map((view) => view.id)).toEqual(['open', 'kept']);
expect(views[0]).not.toHaveProperty('requiredPermissions');
});
prisma.notification.update.mockResolvedValueOnce({ id: 'n1', read: true });
const result = await service.markRead('n1', 'user-1');
it('selects the view fields plus the permission list for filtering, never the dedupe key', async () => {
await service.findOwn('user-1');
expect(result.read).toBe(true);
const [[arg]] = prisma.notification.findMany.mock.calls as [
[{ select: Record<string, true> }],
];
expect(Object.keys(arg.select).sort()).toEqual([
'category',
'createdAt',
'id',
'link',
'message',
'priority',
'read',
'relatedId',
'relatedType',
'requiredPermissions',
]);
});
it('answers 404 when the notification is foreign or unknown', async () => {
prisma.notification.updateMany.mockResolvedValue({ count: 0 });
await expect(service.markRead('n1', 'user-1')).rejects.toBeInstanceOf(
NotFoundException,
);
expect(prisma.notification.updateMany).toHaveBeenCalledWith({
where: { id: 'n1', userId: 'user-1' },
data: { read: true },
});
});
it('marks the own notification as read', async () => {
prisma.notification.updateMany.mockResolvedValue({ count: 1 });
prisma.notification.findFirstOrThrow.mockResolvedValue({
id: 'n1',
read: true,
});
await expect(service.markRead('n1', 'user-1')).resolves.toMatchObject({
read: true,
});
});
it('marks only the own unread notifications as read', async () => {
prisma.notification.updateMany.mockResolvedValue({ count: 3 });
await expect(service.markAllRead('user-1')).resolves.toEqual({
updated: 3,
});
expect(prisma.notification.updateMany).toHaveBeenCalledWith({
where: { userId: 'user-1', read: false },
data: { read: true },
});
});
});
});
describe('assertAppLink', () => {
it('accepts a relative app path', () => {
expect(() => assertAppLink('/tasks?status=OPEN&x=1')).not.toThrow();
});
it.each([
'//evil.example',
'https://evil.example',
'/\\evil',
'tasks',
'/a b',
])('rejects %s', (link) => expect(() => assertAppLink(link)).toThrow());
it('rejects a link over 300 characters', () => {
expect(() => assertAppLink(`/${'a'.repeat(300)}`)).toThrow();
});
});
@@ -1,98 +1,269 @@
import {
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { DbClient } from '../prisma/db-client';
import { PermissionKey } from '../auth/permissions/permission-catalog';
import {
Notification,
NotificationCategory,
NotificationPriority,
Prisma,
} from '../../generated/prisma';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { assertAppLink, notificationDedupeKey } from './notification-link';
export interface CreateNotificationInput {
userId?: string;
userId: string;
priority: NotificationPriority;
category: NotificationCategory;
message: string;
relatedType?: string;
relatedId?: string;
relatedType?: string | null;
relatedId?: string | null;
link?: string | null;
requiredPermissions?: readonly PermissionKey[];
dedupeKey?: string | null;
}
export interface NotificationFilter {
userId?: string;
unreadOnly?: boolean;
export type FanOutNotificationInput = Omit<
CreateNotificationInput,
'userId' | 'dedupeKey'
> & { dedupeKeyPrefix?: string };
export interface NotificationView {
id: string;
priority: NotificationPriority;
category: NotificationCategory;
message: string;
relatedType: string | null;
relatedId: string | null;
link: string | null;
read: boolean;
createdAt: Date;
}
type NotificationContent = Omit<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()
export class NotificationsService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly permissionResolver: PermissionResolverService,
) {}
/** One recipient. null: repeated dedupe key, or the recipient lacks a right. */
async record(
input: CreateNotificationInput,
tx?: Prisma.TransactionClient,
): Promise<Notification> {
): Promise<Notification | null> {
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
* role, permission granted - the same rule the PermissionsGuard applies.
* Returns how many were written.
* The own notifications, newest first, at most 100. A row whose
* requiredPermissions the user no longer holds (role changed after the
* write) stays hidden.
*/
async recordForPermission(
permission: PermissionKey,
input: Omit<CreateNotificationInput, 'userId'>,
tx?: Prisma.TransactionClient,
): Promise<number> {
const db: DbClient = tx ?? this.prisma;
const recipients = await db.user.findMany({
async findOwn(
userId: string,
options: { unreadOnly?: boolean } = {},
): Promise<NotificationView[]> {
const rows = await this.prisma.notification.findMany({
where: { userId, ...(options.unreadOnly ? { read: false } : {}) },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
take: MAX_SCANNED,
select: { ...VIEW_SELECT, requiredPermissions: true },
});
const held = await this.permissionResolver.getPermissions(userId);
return rows
.filter((row) => row.requiredPermissions.every((key) => held.has(key)))
.slice(0, MAX_LISTED)
.map(toView);
}
/** One statement; a foreign or unknown id matches nothing and answers 404. */
async markRead(id: string, userId: string): Promise<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: {
...(userIds ? { id: { in: [...userIds] } } : {}),
isActive: true,
role: {
status: 'ACTIVE',
permissions: { some: { permission: { key: permission } } },
AND: keys.map((key) => ({
permissions: { some: { permission: { key } } },
})),
},
},
select: { id: true },
orderBy: { id: 'asc' },
});
if (recipients.length === 0) {
return 0;
}
const { count } = await db.notification.createMany({
data: recipients.map((recipient) => ({ ...input, userId: recipient.id })),
});
return count;
return users.map((user) => user.id);
}
async findAll(filter: NotificationFilter = {}): Promise<Notification[]> {
return this.prisma.notification.findMany({
where: {
OR: filter.userId
? [{ userId: filter.userId }, { userId: null }]
: undefined,
read: filter.unreadOnly ? false : undefined,
},
orderBy: { createdAt: 'desc' },
take: 100,
});
}
async markRead(id: string, actingUserId: string): Promise<Notification> {
const notification = await this.prisma.notification.findUnique({
where: { id },
});
if (!notification) {
throw new NotFoundException('Notification not found');
private async write(
db: DbClient,
targets: readonly Target[],
content: NotificationContent,
recipientKeys: readonly PermissionKey[],
): Promise<Notification[]> {
if (content.link) {
assertAppLink(content.link);
}
if (notification.userId !== null && notification.userId !== actingUserId) {
throw new ForbiddenException("Cannot modify another user's notification");
if (targets.length === 0) {
return [];
}
return this.prisma.notification.update({
where: { id },
data: { read: true },
const requiredPermissions = uniqueSorted([
...recipientKeys,
...(content.requiredPermissions ?? []),
]);
return db.notification.createManyAndReturn({
data: targets.map(({ userId, dedupeKey }) => ({
userId,
priority: content.priority,
category: content.category,
message: content.message,
relatedType: content.relatedType ?? null,
relatedId: content.relatedId ?? null,
link: content.link ?? null,
requiredPermissions,
dedupeKey,
})),
skipDuplicates: true,
});
}
}
@@ -7,12 +7,17 @@ import { QueensService } from './queens.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { CalendarSyncService } from '../calendar-sync/calendar-sync.service';
import { DomainEventsService } from '../domain-events/domain-events.service';
describe('QueensService', () => {
let service: QueensService;
let tx: {
queen: { update: jest.Mock };
$queryRaw: jest.Mock;
queen: {
update: jest.Mock;
findUnique: jest.Mock;
findUniqueOrThrow: jest.Mock;
};
queenColonyHistory: { updateMany: jest.Mock; create: jest.Mock };
colony: { findUnique: jest.Mock; update: jest.Mock };
};
@@ -29,13 +34,20 @@ describe('QueensService', () => {
$transaction: jest.Mock;
};
let auditLog: { record: jest.Mock };
let webhooks: { dispatch: jest.Mock };
let events: { record: jest.Mock };
const queen = { id: 'queen-1', code: 'Q-001', status: 'ACTIVE' };
const colony = { id: 'colony-1', currentQueenId: null };
beforeEach(() => {
tx = {
queen: { update: jest.fn().mockResolvedValue(queen) },
$queryRaw: jest.fn().mockResolvedValue([]),
queen: {
update: jest.fn().mockResolvedValue(queen),
findUnique: jest.fn(),
findUniqueOrThrow: jest.fn().mockResolvedValue(queen),
},
queenColonyHistory: { updateMany: jest.fn(), create: jest.fn() },
colony: {
findUnique: jest.fn().mockResolvedValue(null),
@@ -57,13 +69,17 @@ describe('QueensService', () => {
),
};
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
const calendarSync = { enqueue: jest.fn(), cancel: jest.fn() };
webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
events = { record: jest.fn().mockResolvedValue(undefined) };
// The locked re-read sees what the pre-read saw unless a test says otherwise.
tx.queen.findUnique.mockImplementation(
(args: unknown) => prisma.queen.findUnique(args) as Promise<unknown>,
);
service = new QueensService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
webhooks as unknown as WebhooksService,
calendarSync as unknown as CalendarSyncService,
events as unknown as DomainEventsService,
);
});
@@ -74,9 +90,18 @@ describe('QueensService', () => {
);
});
// Inside the transaction: lookup by id is the target colony, by
// currentQueenId the colony the queen leaves.
function mockColonies(target: unknown, previous: unknown = null): void {
const lookup = (args: { where: { id?: string } }) =>
Promise.resolve(args.where.id === undefined ? previous : target);
prisma.colony.findUnique.mockImplementation(lookup);
tx.colony.findUnique.mockImplementation(lookup);
}
it('assignColony throws NotFoundException when the colony is missing', async () => {
prisma.queen.findUnique.mockResolvedValue(queen);
prisma.colony.findUnique.mockResolvedValue(null);
mockColonies(null);
await expect(
service.assignColony('queen-1', { colonyId: 'missing' }, 'user-1'),
).rejects.toBeInstanceOf(NotFoundException);
@@ -90,15 +115,13 @@ describe('QueensService', () => {
await expect(
service.assignColony('queen-1', { colonyId: 'colony-1' }, 'user-1'),
).rejects.toBeInstanceOf(ConflictException);
expect(prisma.colony.findUnique).not.toHaveBeenCalled();
expect(tx.colony.update).not.toHaveBeenCalled();
expect(events.record).not.toHaveBeenCalled();
});
it('assignColony closes the previous queen history, sets currentQueenId, and opens a new history row', async () => {
prisma.queen.findUnique.mockResolvedValue(queen);
prisma.colony.findUnique.mockResolvedValue({
...colony,
currentQueenId: 'queen-old',
});
mockColonies({ ...colony, status: 'ACTIVE', currentQueenId: 'queen-old' });
tx.colony.update.mockResolvedValue({});
tx.queenColonyHistory.create.mockResolvedValue({ id: 'history-1' });
@@ -122,6 +145,57 @@ describe('QueensService', () => {
expect(historyArg.data.colonyId).toBe('colony-1');
});
it('assignColony records QueenAssigned with previousColonyId and calls neither webhooks.dispatch nor calendarSync', async () => {
prisma.queen.findUnique.mockResolvedValue(queen);
mockColonies(
{ ...colony, status: 'ACTIVE' },
{ id: 'colony-prev', status: 'ACTIVE' },
);
tx.colony.update.mockResolvedValue({});
tx.queenColonyHistory.create.mockResolvedValue({ id: 'history-1' });
await service.assignColony('queen-1', { colonyId: 'colony-1' }, 'user-1');
// Target and previous colony in one statement, then the queen.
expect(tx.$queryRaw).toHaveBeenCalledTimes(2);
expect(events.record).toHaveBeenCalledTimes(1);
expect(events.record).toHaveBeenCalledWith(
tx,
expect.objectContaining({
type: 'QueenAssigned',
aggregateType: 'Queen',
aggregateId: 'queen-1',
actorUserId: 'user-1',
payload: {
colonyId: 'colony-1',
historyId: 'history-1',
assignedOn: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/) as string,
previousColonyId: 'colony-prev',
},
}),
);
expect(auditLog.record).toHaveBeenCalledWith(expect.anything(), tx);
expect(webhooks.dispatch).not.toHaveBeenCalled();
});
it('assignColony answers 409 when the queen was moved by someone else after the pre-read', async () => {
prisma.queen.findUnique.mockResolvedValue(queen);
prisma.colony.findUnique.mockResolvedValue(null);
tx.colony.findUnique.mockImplementation(
(args: { where: { id?: string } }) =>
Promise.resolve(
args.where.id === undefined
? { id: 'colony-other', status: 'ACTIVE' }
: { ...colony, status: 'ACTIVE' },
),
);
await expect(
service.assignColony('queen-1', { colonyId: 'colony-1' }, 'user-1'),
).rejects.toBeInstanceOf(ConflictException);
expect(events.record).not.toHaveBeenCalled();
});
it('addEvaluation creates a queen evaluation and records an audit log entry', async () => {
prisma.queen.findUnique.mockResolvedValue(queen);
const created = { id: 'eval-1', queenId: 'queen-1', colonyStrength: 4 };
+77 -44
View File
@@ -7,7 +7,9 @@ import {
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { CalendarSyncService } from '../calendar-sync/calendar-sync.service';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { utcDay } from '../domain-events/domain-event-types';
import { lockRowForUpdate, lockRowsForUpdate } from '../prisma/row-lock';
import { Queen, QueenStatus } from '../../generated/prisma';
import { CreateQueenDto } from './dto/create-queen.dto';
import { UpdateQueenDto } from './dto/update-queen.dto';
@@ -64,7 +66,7 @@ export class QueensService {
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly webhooks: WebhooksService,
private readonly calendarSync: CalendarSyncService,
private readonly events: DomainEventsService,
) {}
async findAll(filter: QueenFilter = {}): Promise<Queen[]> {
@@ -158,28 +160,55 @@ export class QueensService {
dto: AssignColonyDto,
actingUserId: string,
): Promise<Queen> {
const queen = await this.findById(id);
if (QUEEN_NO_LONGER_AVAILABLE_STATUSES.includes(queen.status)) {
throw new ConflictException(
'Die Königin ist nicht mehr verfügbar für eine Zuweisung - korrigiere zuerst ihren Status, falls dies ein Versehen war',
);
}
const colony = await this.prisma.colony.findUnique({
where: { id: dto.colonyId },
await this.findById(id);
// Only to know which colony to lock; the transaction re-reads it.
const preRead = await this.prisma.colony.findUnique({
where: { currentQueenId: id },
select: { id: true },
});
if (!colony) {
throw new NotFoundException('Colony not found');
}
if (!isLivingColonyStatus(colony.status)) {
throw new ConflictException(
`Das Volk besteht nicht mehr (Status: ${colony.status}) und kann keine Königin aufnehmen`,
);
}
if (colony.currentQueenId === queen.id) {
throw new ConflictException('Die Königin sitzt bereits in diesem Volk');
}
return this.prisma.$transaction(async (tx) => {
// Colonies first (one statement, ascending ids), then the queen: the
// order every writer of both uses.
await lockRowsForUpdate(tx, 'Colony', [
dto.colonyId,
...(preRead ? [preRead.id] : []),
]);
await lockRowForUpdate(tx, 'Queen', id);
const queen = await tx.queen.findUnique({ where: { id } });
if (!queen) {
throw new NotFoundException('Queen not found');
}
if (QUEEN_NO_LONGER_AVAILABLE_STATUSES.includes(queen.status)) {
throw new ConflictException(
'Die Königin ist nicht mehr verfügbar für eine Zuweisung - korrigiere zuerst ihren Status, falls dies ein Versehen war',
);
}
const colony = await tx.colony.findUnique({
where: { id: dto.colonyId },
});
if (!colony) {
throw new NotFoundException('Colony not found');
}
if (!isLivingColonyStatus(colony.status)) {
throw new ConflictException(
`Das Volk besteht nicht mehr (Status: ${colony.status}) und kann keine Königin aufnehmen`,
);
}
if (colony.currentQueenId === queen.id) {
throw new ConflictException('Die Königin sitzt bereits in diesem Volk');
}
// The colony the queen leaves must let go of her - currentQueenId is
// unique, and a stale pointer would both block this assignment and
// keep showing her in a colony she is no longer in.
const previousColony = await tx.colony.findUnique({
where: { currentQueenId: queen.id },
});
if ((previousColony?.id ?? null) !== (preRead?.id ?? null)) {
throw new ConflictException(
'Die Königin wurde gerade umgesetzt - bitte erneut versuchen',
);
}
const history = await this.prisma.$transaction(async (tx) => {
const now = new Date();
if (colony.currentQueenId) {
await tx.queenColonyHistory.updateMany({
@@ -191,12 +220,6 @@ export class QueensService {
data: { endDate: now },
});
}
// The colony the queen leaves must let go of her - currentQueenId is
// unique, and a stale pointer would both block this assignment and
// keep showing her in a colony she is no longer in.
const previousColony = await tx.colony.findUnique({
where: { currentQueenId: queen.id },
});
if (previousColony) {
await tx.colony.update({
where: { id: previousColony.id },
@@ -221,29 +244,39 @@ export class QueensService {
status: colony.status === 'QUEENLESS' ? 'ACTIVE' : undefined,
},
});
return tx.queenColonyHistory.create({
const history = await tx.queenColonyHistory.create({
data: {
queenId: queen.id,
colonyId: colony.id,
startDate: new Date(),
startDate: now,
notes: dto.notes,
},
});
await this.auditLog.record(
{
entityType: 'Queen',
entityId: queen.id,
action: 'UPDATE',
userId: actingUserId,
beforeData: queen,
afterData: { ...queen, assignedColonyId: colony.id },
},
tx,
);
await this.events.record(tx, {
type: 'QueenAssigned',
aggregateType: 'Queen',
aggregateId: queen.id,
actorUserId: actingUserId,
payload: {
colonyId: colony.id,
historyId: history.id,
assignedOn: utcDay(now),
previousColonyId: previousColony?.id ?? null,
},
});
return tx.queen.findUniqueOrThrow({ where: { id } });
});
await this.auditLog.record({
entityType: 'Queen',
entityId: queen.id,
action: 'UPDATE',
userId: actingUserId,
beforeData: queen,
afterData: { ...queen, assignedColonyId: colony.id },
});
const updated = await this.findById(id);
await this.webhooks.dispatch('queen.replaced', updated);
await this.calendarSync.enqueue('QUEEN_ASSIGNMENT', history.id);
return updated;
}
async getHistory(id: string) {
@@ -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 { AuditModule } from '../audit/audit.module';
import { TasksModule } from '../tasks/tasks.module';
import { SuggestionViewsService } from './suggestion-views.service';
import { SuggestionsController } from './suggestions.controller';
import { SuggestionsService } from './suggestions.service';
/**
@@ -9,7 +11,8 @@ import { SuggestionsService } from './suggestions.service';
*/
@Module({
imports: [TasksModule, AuditModule],
providers: [SuggestionsService],
exports: [SuggestionsService],
controllers: [SuggestionsController],
providers: [SuggestionsService, SuggestionViewsService],
exports: [SuggestionsService, SuggestionViewsService],
})
export class SuggestionsModule {}
@@ -59,7 +59,7 @@ describe('JobRunService', () => {
$executeRaw: jest.fn<Promise<number>, RawCall>().mockResolvedValue(1),
$queryRaw: jest
.fn<Promise<unknown>, RawCall>()
.mockResolvedValue([{ notified: false }]),
.mockResolvedValue([{ notified: false, day: '2026-10-13' }]),
};
prisma = {
jobRun,
@@ -71,7 +71,9 @@ describe('JobRunService', () => {
Promise.resolve([runRow({ status: status as JobRun['status'] })]),
),
};
notifications = { recordForPermission: jest.fn().mockResolvedValue(2) };
notifications = {
recordForPermission: jest.fn().mockResolvedValue([{ id: 'n1' }]),
};
service = new JobRunService(
prisma as unknown as PrismaService,
notifications as unknown as NotificationsService,
@@ -177,9 +179,11 @@ describe('JobRunService', () => {
'automation:update',
{
priority: 'WARNUNG',
category: 'SYSTEM',
message: 'Job „Tägliche Auswertung“ fehlgeschlagen',
relatedType: 'JobRun',
relatedId: 'run-1',
dedupeKeyPrefix: 'job-failed:daily-evaluation:2026-10-13',
},
tx,
);
@@ -188,7 +192,7 @@ describe('JobRunService', () => {
// The hourly app-backup check would otherwise report one outage every
// hour. The check and the write share a transaction behind a lock per
// job, so two failing runs at once cannot both pass the check.
it('notifies at most once per job and UTC day, decided by the database clock', async () => {
it('notifies at most once per job and Berlin day, decided by the database clock', async () => {
tx.$queryRaw.mockResolvedValue([{ notified: true }]);
await service.run('app-backup', () => Promise.reject(new Error('boom')));
@@ -198,7 +202,13 @@ describe('JobRunService', () => {
expect(sqlText(lock)).toContain('pg_advisory_xact_lock');
expect(lock.slice(1)).toEqual(['job-failure-notification:app-backup']);
const check = tx.$queryRaw.mock.calls[0];
expect(sqlText(check)).toContain(`date_trunc('day', now())`);
expect(sqlText(check)).toContain(
`date_trunc('day', now() AT TIME ZONE 'Europe/Berlin')`,
);
// The key carries the same day the check uses.
expect(sqlText(check)).toContain(
`to_char(now() AT TIME ZONE 'Europe/Berlin'`,
);
expect(check.slice(1)).toEqual(['JobRun', 'app-backup']);
expect(tx.$executeRaw.mock.invocationCallOrder[0]).toBeLessThan(
tx.$queryRaw.mock.invocationCallOrder[0],
+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
* hour. The advisory lock per job serialises check and write, so two
* runs failing at once cannot both pass the check. A notification that
* runs failing at once cannot both pass the check; the dedupe key per
* recipient is a second guard. A notification that
* cannot be written is logged; the run stays FAILED either way.
*/
private async notifyFailure(job: JobName, runId: string): Promise<void> {
try {
await this.prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${`job-failure-notification:${job}`}, 0))`;
const [{ notified }] = await tx.$queryRaw<{ notified: boolean }[]>`
const [{ notified, day }] = await tx.$queryRaw<
{ notified: boolean; day: string }[]
>`
SELECT EXISTS (
SELECT 1 FROM "Notification" n
JOIN "JobRun" r ON r."id" = n."relatedId"
WHERE n."relatedType" = ${JOB_RUN_RELATED_TYPE}
AND r."job" = ${job}
AND n."createdAt" >= date_trunc('day', now())
) AS "notified"`;
AND n."createdAt" >= date_trunc('day', now() AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'Europe/Berlin'
) AS "notified",
to_char(now() AT TIME ZONE 'Europe/Berlin', 'YYYY-MM-DD') AS "day"`;
if (notified) {
return;
}
@@ -187,9 +192,11 @@ export class JobRunService {
JOB_FAILURE_PERMISSION[job],
{
priority: 'WARNUNG',
category: 'SYSTEM',
message: `Job „${JOB_LABELS_DE[job]}“ fehlgeschlagen`,
relatedType: JOB_RUN_RELATED_TYPE,
relatedId: runId,
dedupeKeyPrefix: `job-failed:${job}:${day}`,
},
tx,
);
@@ -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 () => {
prisma.treatment.findMany.mockResolvedValue([endedFirstOfJuly]);
@@ -14,6 +14,12 @@ import {
} from '../common/bulk/bulk-create';
import { runBulk } from '../common/bulk/run-bulk';
import { lockRowForUpdate } from '../prisma/row-lock';
import { berlinDayOf, dayToDate } from '../common/dates';
import {
isInWindow,
withdrawalWindowOf,
type WithdrawalWindow,
} from './withdrawal-window';
import { CreateTreatmentDto } from './dto/create-treatment.dto';
import { BulkCreateTreatmentDto } from './dto/bulk-create-treatment.dto';
import { UpdateTreatmentDto } from './dto/update-treatment.dto';
@@ -24,16 +30,6 @@ export interface WithdrawalStatus {
product: string;
}
// When a treatment began: the earlier of its date and the start of a
// multi-day treatment - the earlier one errs on the side of withdrawal.
function treatmentStart(
treatment: Pick<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
// a bulk entry stores exactly what single entries would. Where the colony
// stands today is where it was treated: the apiary is kept on the treatment
@@ -105,29 +101,23 @@ export class TreatmentsService {
withdrawalPeriodDays: { not: null },
},
});
const activeByColony = new Map<string, WithdrawalStatus>();
const harvestDay = berlinDayOf(at);
const activeByColony = new Map<string, WithdrawalWindow>();
for (const treatment of treatments) {
if (treatmentStart(treatment) > at) {
continue;
}
const referenceDate = treatment.endDate ?? treatment.date;
const activeUntil = new Date(referenceDate);
activeUntil.setUTCDate(
activeUntil.getUTCDate() + (treatment.withdrawalPeriodDays ?? 0),
);
if (activeUntil <= at) {
const window = withdrawalWindowOf(treatment);
if (!window || !isInWindow(window, harvestDay)) {
continue;
}
const current = activeByColony.get(treatment.colonyId);
if (!current || activeUntil > new Date(current.activeUntil)) {
activeByColony.set(treatment.colonyId, {
colonyId: treatment.colonyId,
activeUntil: activeUntil.toISOString(),
product: treatment.product,
});
if (!current || window.untilDay > current.untilDay) {
activeByColony.set(treatment.colonyId, window);
}
}
return [...activeByColony.values()];
return [...activeByColony.values()].map((window) => ({
colonyId: window.colonyId,
activeUntil: dayToDate(window.untilDay).toISOString(),
product: window.product,
}));
}
async create(
@@ -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 { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { VarroaBandsService } from './varroa-bands.service';
import { VarroaService } from './varroa.service';
@ApiTags('varroa-measurements')
@ApiBearerAuth()
@Controller('colonies')
export class ColonyVarroaMeasurementsController {
constructor(private readonly varroaService: VarroaService) {}
constructor(
private readonly varroaService: VarroaService,
private readonly varroaBandsService: VarroaBandsService,
) {}
@RequirePermission('varroa:read')
@Get(':id/varroa-measurements')
findByColony(@Param('id') id: string) {
return this.varroaService.findByColony(id);
/** The measurements of the colony, each with its rating (light, band, reason). */
async findByColony(@Param('id') id: string) {
return this.varroaBandsService.rate(
id,
await this.varroaService.findByColony(id),
);
}
}
@@ -6,6 +6,7 @@ import {
IsNumber,
IsOptional,
IsString,
Max,
Min,
} from 'class-validator';
import { VarroaMethod } from '../../../generated/prisma';
@@ -35,6 +36,18 @@ export class CreateVarroaMeasurementDto {
@Min(0)
miteCount?: number;
@ApiPropertyOptional({
description:
'Sample size in bees (powdered sugar, washing); the percentage is mites / sample * 100.',
minimum: 1,
maximum: 2000,
})
@IsOptional()
@IsInt()
@Min(1)
@Max(2000)
sampleBees?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@@ -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 { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
@@ -7,7 +15,8 @@ import { CreateVarroaMeasurementDto } from './dto/create-varroa-measurement.dto'
import { BulkCreateVarroaMeasurementDto } from './dto/bulk-create-varroa-measurement.dto';
import { UpdateVarroaMeasurementDto } from './dto/update-varroa-measurement.dto';
import { VarroaService } from './varroa.service';
import { VarroaThresholdService } from './varroa-threshold.service';
import { VarroaBandsService } from './varroa-bands.service';
import { VarroaThresholdsQuery } from './dto/varroa-thresholds.query';
import { Idempotent } from '../common/idempotency/idempotent.decorator';
@ApiTags('varroa-measurements')
@@ -16,7 +25,7 @@ import { Idempotent } from '../common/idempotency/idempotent.decorator';
export class VarroaController {
constructor(
private readonly varroaService: VarroaService,
private readonly varroaThresholdService: VarroaThresholdService,
private readonly varroaBandsService: VarroaBandsService,
) {}
@RequirePermission('varroa:read')
@@ -25,11 +34,11 @@ export class VarroaController {
return this.varroaService.findAll();
}
/** Effective alarm threshold; declared before :id so it is not shadowed. */
/** Bands in force on a day for a colony; declared before :id so it is not shadowed. */
@RequirePermission('varroa:read', 'varroa:create')
@Get('threshold')
getThreshold() {
return this.varroaThresholdService.current();
@Get('thresholds')
getThresholds(@Query() query: VarroaThresholdsQuery) {
return this.varroaBandsService.thresholds(query);
}
@RequirePermission('varroa:read')
+2 -2
View File
@@ -2,7 +2,7 @@ import { Module } from '@nestjs/common';
import { VarroaController } from './varroa.controller';
import { ColonyVarroaMeasurementsController } from './colony-varroa-measurements.controller';
import { VarroaService } from './varroa.service';
import { VarroaThresholdService } from './varroa-threshold.service';
import { VarroaBandsService } from './varroa-bands.service';
/**
* The threshold rule and the varroa.measured webhook follow from the
@@ -11,7 +11,7 @@ import { VarroaThresholdService } from './varroa-threshold.service';
*/
@Module({
controllers: [VarroaController, ColonyVarroaMeasurementsController],
providers: [VarroaService, VarroaThresholdService],
providers: [VarroaService, VarroaBandsService],
exports: [VarroaService],
})
export class VarroaModule {}
@@ -284,6 +284,57 @@ describe('VarroaService', () => {
expect(createArg.data.calculatedValue).toBeUndefined();
});
it('stores mites per 100 bees for powdered sugar with a sample size', async () => {
tx.varroaMeasurement.create.mockResolvedValue(measurement);
await service.create(
{
colonyId: 'colony-1',
date: '2026-07-01',
method: 'POWDERED_SUGAR',
miteCount: 10,
sampleBees: 500,
},
'user-1',
);
const [[createArg]] = tx.varroaMeasurement.create.mock.calls as [
[{ data: { calculatedValue?: number; sampleBees?: number } }],
];
expect(createArg.data.calculatedValue).toBe(2);
expect(createArg.data.sampleBees).toBe(500);
});
it('does not derive a percentage from the sample for natural mite fall or without a sample', async () => {
tx.varroaMeasurement.create.mockResolvedValue(measurement);
await service.create(
{
colonyId: 'colony-1',
date: '2026-07-01',
method: 'WASHING_METHOD',
miteCount: 10,
},
'user-1',
);
await service.create(
{
colonyId: 'colony-1',
date: '2026-07-01',
method: 'NATURAL_MITE_FALL',
miteCount: 10,
sampleBees: 500,
},
'user-1',
);
const calls = tx.varroaMeasurement.create.mock.calls as [
{ data: { calculatedValue?: number } },
][];
expect(calls[0][0].data.calculatedValue).toBeUndefined();
expect(calls[1][0].data.calculatedValue).toBeUndefined();
});
it('respects an explicitly provided calculatedValue instead of computing one', async () => {
tx.varroaMeasurement.create.mockResolvedValue(measurement);
@@ -373,6 +424,16 @@ describe('VarroaService', () => {
).resolves.toBe(20);
});
it('rounds mites/day to two decimals so the stored value rates as shown', async () => {
// 7203 / 720 days = 10.0042: stored as 10, which is yellow, not red.
await expect(
storedCalculatedValueAfter(naturalMiteFall, {
miteCount: 7203,
durationMinutes: 720 * 24 * 60,
}),
).resolves.toBe(10);
});
it('recomputes mites/day when the duration changes', async () => {
await expect(
storedCalculatedValueAfter(naturalMiteFall, {
@@ -443,6 +504,108 @@ describe('VarroaService', () => {
).resolves.toBe(15);
});
describe('with a sample size', () => {
const washed = {
...measurement,
method: 'WASHING_METHOD',
miteCount: 10,
durationMinutes: null,
sampleBees: 500,
calculatedValue: 2,
};
it('recomputes the percentage when the sample size changes', async () => {
await expect(
storedCalculatedValueAfter(washed, { sampleBees: 250 }),
).resolves.toBe(4);
});
it('update with sampleBees null clears the sample and keeps a manually entered value', async () => {
tx.varroaMeasurement.findUnique.mockResolvedValue({
...washed,
calculatedValue: 3,
});
tx.varroaMeasurement.update.mockResolvedValue(measurement);
await service.update(
'varroa-1',
{ sampleBees: null as unknown as number },
'user-1',
);
const [[updateArg]] = tx.varroaMeasurement.update.mock.calls as [
[
{
data: {
sampleBees?: number | null;
calculatedValue?: number | null;
};
},
],
];
expect(updateArg.data.sampleBees).toBeNull();
expect(updateArg.data.calculatedValue).toBeUndefined();
});
it('clears a derived percentage when the sample is removed', async () => {
await expect(
storedCalculatedValueAfter(washed, {
sampleBees: null as unknown as number,
}),
).resolves.toBeNull();
});
it('rounds the derived percentage to two decimals', async () => {
await expect(
storedCalculatedValueAfter(washed, {
miteCount: 7,
sampleBees: 300,
}),
).resolves.toBe(2.33);
});
it('recomputes the percentage when the mite count changes', async () => {
await expect(
storedCalculatedValueAfter(washed, { miteCount: 15 }),
).resolves.toBe(3);
});
it('leaves a manual percentage alone when only the notes change', async () => {
await expect(
storedCalculatedValueAfter(
{ ...washed, calculatedValue: 3 },
{ notes: 'x' },
),
).resolves.toBeUndefined();
});
it('derives the percentage when the method switches from drone brood', async () => {
await expect(
storedCalculatedValueAfter(
{ ...washed, method: 'DRONE_BROOD_CONTROL', calculatedValue: 9 },
{ method: 'POWDERED_SUGAR' },
),
).resolves.toBe(2);
});
it('clears the percentage when the method switches to drone brood', async () => {
await expect(
storedCalculatedValueAfter(washed, {
method: 'DRONE_BROOD_CONTROL',
}),
).resolves.toBeNull();
});
it('keeps a changed percentage as a manual override', async () => {
await expect(
storedCalculatedValueAfter(washed, {
sampleBees: 250,
calculatedValue: 7,
}),
).resolves.toBe(7);
});
});
// @IsOptional lets an explicit null through the validation pipe.
const clearedValue = { calculatedValue: null as unknown as number };
+74 -26
View File
@@ -19,16 +19,19 @@ import { BulkCreateVarroaMeasurementDto } from './dto/bulk-create-varroa-measure
import { UpdateVarroaMeasurementDto } from './dto/update-varroa-measurement.dto';
const MINUTES_PER_DAY = 24 * 60;
const PERCENT = 100;
const DECIMALS_FACTOR = 100;
// Stored and rated value are the same: rounded once, on write.
function roundToTwoDecimals(value: number): number {
return Math.round(value * DECIMALS_FACTOR) / DECIMALS_FACTOR;
}
// Natural mite fall is counted on a sticky-board insert left in the hive
// for some duration and is conventionally normalized to a daily rate
// ("Milben/Tag") - both the VARROA_THRESHOLD automation config
// (thresholdMitesPerDay) and the annual PDF report already assume
// calculatedValue is in that unit. The other methods (Puderzucker,
// Waschmethode, Drohnenbrutkontrolle) report a different, non-time-based
// metric (mites per fixed bee sample); there is no single verified
// formula for those here, so calculatedValue is left for the user to
// enter manually for those methods instead of guessing one.
// ("Milben/Tag"). Powdered sugar and washing report the infestation as mites
// per 100 bees of a sample of sampleBees (percent). The drone brood check has
// no verified formula, so its calculatedValue is left for the user to enter.
function computeNaturalMiteFallPerDay(
miteCount: number | null | undefined,
durationMinutes: number | null | undefined,
@@ -39,16 +42,44 @@ function computeNaturalMiteFallPerDay(
if (!durationMinutes || durationMinutes <= 0) {
return undefined;
}
return (miteCount / durationMinutes) * MINUTES_PER_DAY;
return roundToTwoDecimals((miteCount / durationMinutes) * MINUTES_PER_DAY);
}
function deriveCalculatedValue(
method: VarroaMethod,
function computeInfestationPercent(
miteCount: number | null | undefined,
durationMinutes: number | null | undefined,
sampleBees: number | null | undefined,
): number | undefined {
return method === 'NATURAL_MITE_FALL'
? computeNaturalMiteFallPerDay(miteCount, durationMinutes)
if (miteCount === null || miteCount === undefined) {
return undefined;
}
if (!sampleBees || sampleBees <= 0) {
return undefined;
}
return roundToTwoDecimals((miteCount / sampleBees) * PERCENT);
}
function isPercentMethod(method: VarroaMethod): boolean {
return method === 'POWDERED_SUGAR' || method === 'WASHING_METHOD';
}
interface Derivation {
method: VarroaMethod;
miteCount?: number | null;
durationMinutes?: number | null;
sampleBees?: number | null;
}
function deriveCalculatedValue({
method,
miteCount,
durationMinutes,
sampleBees,
}: Derivation): number | undefined {
if (method === 'NATURAL_MITE_FALL') {
return computeNaturalMiteFallPerDay(miteCount, durationMinutes);
}
return isPercentMethod(method)
? computeInfestationPercent(miteCount, sampleBees)
: undefined;
}
@@ -69,18 +100,25 @@ function calculatedValueForUpdate(
if (sent !== undefined && sent !== null && sent !== before.calculatedValue) {
return sent;
}
const method = dto.method ?? before.method;
const miteCount =
dto.miteCount === undefined ? before.miteCount : dto.miteCount;
const durationMinutes =
dto.durationMinutes === undefined
? before.durationMinutes
: dto.durationMinutes;
const merged: Derivation = {
method: dto.method ?? before.method,
miteCount: dto.miteCount === undefined ? before.miteCount : dto.miteCount,
durationMinutes:
dto.durationMinutes === undefined
? before.durationMinutes
: dto.durationMinutes,
sampleBees:
dto.sampleBees === undefined ? before.sampleBees : dto.sampleBees,
};
const { method } = merged;
const derivationInputChanged =
method !== before.method ||
(method === 'NATURAL_MITE_FALL' &&
(miteCount !== before.miteCount ||
durationMinutes !== before.durationMinutes));
(merged.miteCount !== before.miteCount ||
merged.durationMinutes !== before.durationMinutes)) ||
(isPercentMethod(method) &&
(merged.miteCount !== before.miteCount ||
merged.sampleBees !== before.sampleBees));
// An explicit null asks for the value to be cleared - like a create
// without a value, that means "derive it where a rule exists".
const mustDerive =
@@ -88,8 +126,20 @@ function calculatedValueForUpdate(
if (!mustDerive) {
return undefined;
}
const derived = deriveCalculatedValue(merged);
// A percentage that no longer derives (the sample was removed) is cleared
// only if it was derived itself; a value entered by hand stays.
if (
derived === undefined &&
sent !== null &&
method === before.method &&
isPercentMethod(method) &&
before.calculatedValue !== deriveCalculatedValue(before)
) {
return undefined;
}
// null clears a value the merged record no longer supports.
return deriveCalculatedValue(method, miteCount, durationMinutes) ?? null;
return derived ?? null;
}
// The row a new measurement is stored as - for create and createBulk alike,
@@ -101,9 +151,7 @@ function measurementRow(
): Prisma.VarroaMeasurementUncheckedCreateInput {
return {
...dto,
calculatedValue:
dto.calculatedValue ??
deriveCalculatedValue(dto.method, dto.miteCount, dto.durationMinutes),
calculatedValue: dto.calculatedValue ?? deriveCalculatedValue(dto),
date: new Date(dto.date),
userId: actingUserId,
};
@@ -188,7 +188,7 @@ describe('WebhookDeliveryWorker', () => {
`UPDATE "WebhookDelivery" SET "status" = 'PROCESSING', "claimedAt" = now()`,
);
expect(text(claim)).toContain(
`WHERE "status" = 'PENDING' AND "availableAt" <= now()`,
`WHERE "status" = 'PENDING' AND "availableAt" <= CAST(now() AS timestamp(3))`,
);
expect(text(claim)).toContain(
'ORDER BY "createdAt" LIMIT ? FOR UPDATE SKIP LOCKED',
@@ -201,7 +201,7 @@ describe('WebhookDeliveryWorker', () => {
const claim = db.claimQuery();
expect(text(claim)).toContain(
`"availableAt" <= now() AND "subscriptionId" = ANY(?) ORDER BY`,
`"availableAt" <= CAST(now() AS timestamp(3)) AND "subscriptionId" = ANY(?) ORDER BY`,
);
expect(claim.values).toEqual([['wh-1', 'wh-2'], 50]);
});
@@ -18,6 +18,7 @@ type RecordModel =
| 'feeding'
| 'harvest'
| 'honeyBatch'
| 'queen'
| 'task';
const RECORD_MODELS: readonly RecordModel[] = [
@@ -27,6 +28,7 @@ const RECORD_MODELS: readonly RecordModel[] = [
'feeding',
'harvest',
'honeyBatch',
'queen',
'task',
];
@@ -79,6 +81,7 @@ describe('WebhookEventHandlers', () => {
StockChanged: 'stock.changed',
TaskCreated: 'task.created',
TaskCompleted: 'task.completed',
QueenAssigned: 'queen.replaced',
});
});
@@ -156,6 +159,27 @@ describe('WebhookEventHandlers', () => {
);
});
it('QueenAssigned forwards queen.replaced with the queen', async () => {
const queen = { id: 'q1', code: 'Q-001' };
tx.queen.findUnique.mockResolvedValueOnce(queen);
await forward(
envelope('QueenAssigned', 'q1', {
colonyId: 'c1',
historyId: 'h1',
assignedOn: '2026-10-14',
previousColonyId: null,
}),
);
expect(tx.queen.findUnique).toHaveBeenCalledWith({ where: { id: 'q1' } });
expect(webhooks.enqueueDeliveries).toHaveBeenCalledWith(
tx,
'queen.replaced',
queen,
);
});
it('enqueues harvest.created with the harvest and its colonies', async () => {
const harvest = {
id: 'h1',
@@ -23,6 +23,7 @@ export const DOMAIN_EVENT_WEBHOOKS: Readonly<
StockChanged: 'stock.changed',
TaskCreated: 'task.created',
TaskCompleted: 'task.completed',
QueenAssigned: 'queen.replaced',
});
/**
@@ -92,6 +93,8 @@ async function webhookData(
case 'TaskCreated':
case 'TaskCompleted':
return tx.task.findUnique(byAggregate);
case 'QueenAssigned':
return tx.queen.findUnique(byAggregate);
default:
return null;
}
+6 -1
View File
@@ -266,7 +266,12 @@ describe('Automation rules (e2e)', () => {
const notifications = await prisma.notification.findMany({
where: { relatedId: colonyId },
});
expect(notifications).toHaveLength(1);
// One copy per user who may read tasks, never a broadcast.
expect(notifications.length).toBeGreaterThan(0);
expect(notifications.every((n) => n.category === 'SUGGESTION')).toBe(true);
expect(new Set(notifications.map((n) => n.userId)).size).toBe(
notifications.length,
);
});
it('does not trigger when the queen was seen', async () => {
@@ -44,6 +44,7 @@ describe('Calendar sync outbox (e2e)', () => {
syncFeeding: true,
syncHarvest: true,
syncQueenAssignment: true,
syncWithdrawal: true,
updatedAt: new Date(),
};
@@ -359,6 +360,37 @@ describe('Calendar sync outbox (e2e)', () => {
expect(JSON.stringify(response.body)).toContain('Gruppen-ID');
});
// GET /settings is mocked in this file (see beforeEach), so the stored
// row is read directly.
it('syncWithdrawal is on by default and can be switched off', async () => {
const fresh = await prisma.calendarSyncSettings.create({
data: { id: `default-check-${suffix}` },
});
await prisma.calendarSyncSettings.delete({ where: { id: fresh.id } });
expect(fresh.syncWithdrawal).toBe(true);
await prisma.calendarSyncSettings.upsert({
where: { id: 'singleton' },
create: { id: 'singleton' },
update: {},
});
const response = await request(app.getHttpServer())
.patch('/api/v1/calendar/sync/settings')
.set(auth())
.send({ syncWithdrawal: false })
.expect(200);
expect(response.body).toMatchObject({ syncWithdrawal: false });
const stored = await prisma.calendarSyncSettings.findUniqueOrThrow({
where: { id: 'singleton' },
});
expect(stored.syncWithdrawal).toBe(false);
await prisma.calendarSyncSettings.update({
where: { id: 'singleton' },
data: { syncWithdrawal: true },
});
});
// PATCH null rule: the switches cannot hold null - 400, not a 500.
it('rejects null on a switch with 400', async () => {
await request(app.getHttpServer())
+51
View File
@@ -35,6 +35,7 @@ describe('Colonies (e2e)', () => {
let apiaryBId: string;
let colonyId: string;
let childColonyId: string;
const splitColonyIds: string[] = [];
let colonyToMergeId: string;
let healthColonyId: string;
let raceColonyId: string;
@@ -83,6 +84,7 @@ describe('Colonies (e2e)', () => {
colonyToMergeId,
healthColonyId,
raceColonyId,
...splitColonyIds,
].filter(Boolean);
// Rule tasks are written without a user - their audit entry and
// TaskCreated are not covered by the userId cleanup below.
@@ -190,6 +192,55 @@ describe('Colonies (e2e)', () => {
expect((history.body as ColonyHistoryBody).lineageAsSource).toHaveLength(1);
});
it('records ColonySplit with the given day and queen source, and refuses a future day', async () => {
await request(app.getHttpServer())
.post(`/api/v1/colonies/${colonyId}/split`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({
code: `V-${Date.now()}-F`,
colonyType: 'NUCLEUS_COLONY',
date: '2999-01-01',
})
.expect(400);
await request(app.getHttpServer())
.post(`/api/v1/colonies/${colonyId}/split`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({
code: `V-${Date.now()}-T`,
colonyType: 'NUCLEUS_COLONY',
date: '2026-05-05T23:30:00+02:00',
})
.expect(400);
const response = await request(app.getHttpServer())
.post(`/api/v1/colonies/${colonyId}/split`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({
code: `V-${Date.now()}-B`,
colonyType: 'NUCLEUS_COLONY',
date: '2026-05-05',
queenSource: 'QUEEN_CELL',
})
.expect(201);
const splitId = (response.body as ColonyBody).id;
splitColonyIds.push(splitId);
const lineage = await prisma.colonyLineage.findFirstOrThrow({
where: { toColonyId: splitId },
});
expect(lineage.queenSource).toBe('QUEEN_CELL');
expect(lineage.date.toISOString().slice(0, 10)).toBe('2026-05-05');
const event = await prisma.domainEvent.findFirstOrThrow({
where: { type: 'ColonySplit', aggregateId: splitId },
});
expect(event.payload).toEqual({
parentColonyId: colonyId,
lineageId: lineage.id,
queenSource: 'QUEEN_CELL',
splitOn: '2026-05-05',
});
});
it('merges another colony into the surviving one', async () => {
const otherColony = await request(app.getHttpServer())
.post('/api/v1/colonies')
+327 -65
View File
@@ -2,6 +2,7 @@ import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
import { NotificationsService } from '../src/notifications/notifications.service';
import { PrismaService } from '../src/prisma/prisma.service';
import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
@@ -9,99 +10,360 @@ import { login } from './utils/api';
interface NotificationBody {
id: string;
priority: string;
category: string;
read: boolean;
}
interface SettingsBody {
taskDueEnabled: boolean;
digestEnabled: boolean;
digestTime: string;
quietFrom: string | null;
quietUntil: string | null;
telegramCategories: string[];
telegramDetail: boolean;
}
describe('Notifications (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const beekeeperEmail = `notifications-e2e-beekeeper-${Date.now()}@example.com`;
let notifications: NotificationsService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
const password = 'SuperSecret123!';
const staffEmail = `notifications-e2e-staff-${Date.now()}@example.com`;
let beekeeperId: string;
let staffId: string;
let beekeeperToken: string;
let staffToken: string;
let notificationId: string;
const emails = {
beekeeper: `notifications-e2e-beekeeper-${suffix}@example.com`,
staff: `notifications-e2e-staff-${suffix}@example.com`,
bare: `notifications-e2e-bare-${suffix}@example.com`,
admin: `notifications-e2e-admin-${suffix}@example.com`,
};
const roleKey = `NOTIF_BARE_${suffix}`;
const ids = {} as Record<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 () => {
app = await createTestApp();
prisma = app.get(PrismaService);
notifications = app.get(NotificationsService);
await prisma.role.create({
data: { key: roleKey, name: 'Notifications bare role' },
});
const passwordHash = await bcrypt.hash(password, 12);
const beekeeper = await prisma.user.create({
data: {
email: beekeeperEmail,
passwordHash,
name: 'Beekeeper',
role: { connect: { key: 'BEEKEEPER' } },
},
});
beekeeperId = beekeeper.id;
const staff = await prisma.user.create({
data: {
email: staffEmail,
passwordHash,
name: 'Staff',
role: { connect: { key: 'STAFF' } },
},
});
staffId = staff.id;
beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken;
staffToken = (await login(app, staffEmail, password)).accessToken;
const notification = await prisma.notification.create({
data: {
userId: beekeeperId,
priority: 'KRITISCH',
message: `Kritischer Testalarm ${Date.now()}`,
},
});
notificationId = notification.id;
const roleOf: Record<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: {
email: emails[who],
passwordHash,
name: who,
role: { connect: { key: roleOf[who] } },
},
});
ids[who] = user.id;
tokens[who] = (await login(app, emails[who], password)).accessToken;
}
});
afterAll(async () => {
await prisma.notification.deleteMany({ where: { id: notificationId } });
const userIds = Object.values(ids);
if (apiKeyId) {
await prisma.apiKey.deleteMany({ where: { id: apiKeyId } });
}
await prisma.notification.deleteMany({
where: {
OR: [
{ id: { in: createdNotificationIds } },
{ userId: { in: userIds } },
],
},
});
await prisma.notificationPreference.deleteMany({
where: { userId: { in: userIds } },
});
await prisma.refreshToken.deleteMany({
where: { userId: { in: [beekeeperId, staffId] } },
});
await prisma.auditLog.deleteMany({
where: { userId: { in: [beekeeperId, staffId] } },
});
await prisma.user.deleteMany({
where: { id: { in: [beekeeperId, staffId] } },
where: { userId: { in: userIds } },
});
await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } });
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
await prisma.role.deleteMany({ where: { key: roleKey } });
await app.close();
});
it('lists notifications for the authenticated user', async () => {
const response = await request(app.getHttpServer())
const own = await seedNotification(ids.beekeeper);
const response = await request(server())
.get('/api/v1/notifications')
.set('Authorization', `Bearer ${beekeeperToken}`)
.set(as('beekeeper'))
.expect(200);
const notifications = response.body as NotificationBody[];
expect(
notifications.some(
(notification) =>
notification.id === notificationId &&
notification.priority === 'KRITISCH',
),
).toBe(true);
const list = response.body as NotificationBody[];
expect(list.some((n) => n.id === own.id && n.priority === 'KRITISCH')).toBe(
true,
);
expect(list[0]).not.toHaveProperty('requiredPermissions');
expect(list[0]).not.toHaveProperty('dedupeKey');
});
it("rejects marking another user's notification as read", () => {
return request(app.getHttpServer())
.post(`/api/v1/notifications/${notificationId}/read`)
.set('Authorization', `Bearer ${staffToken}`)
.expect(403);
it('hides a row whose permissions the user does not hold (any more)', async () => {
const open = await seedNotification(ids.bare);
const hidden = await seedNotification(ids.bare, {
requiredPermissions: ['colonies:read'],
});
const response = await request(server())
.get('/api/v1/notifications?unreadOnly=true')
.set(as('bare'))
.expect(200);
const listed = (response.body as NotificationBody[]).map((n) => n.id);
expect(listed).toContain(open.id);
expect(listed).not.toContain(hidden.id);
});
it('marks a notification as read', async () => {
const response = await request(app.getHttpServer())
.post(`/api/v1/notifications/${notificationId}/read`)
.set('Authorization', `Bearer ${beekeeperToken}`)
it('reading is per user: marking one copy read leaves the other unread', async () => {
const [first, second] = await notifications.recordForUsers(
[ids.beekeeper, ids.staff],
{
priority: 'INFO',
category: 'SYSTEM',
message: `Rundmeldung ${suffix}`,
dedupeKeyPrefix: `e2e-read:${suffix}`,
},
);
createdNotificationIds.push(first.id, second.id);
const mine = [first, second].find((n) => n.userId === ids.beekeeper)!;
const theirs = [first, second].find((n) => n.userId === ids.staff)!;
const response = await request(server())
.post(`/api/v1/notifications/${mine.id}/read`)
.set(as('beekeeper'))
.expect(201);
const notification = response.body as NotificationBody;
expect(notification.read).toBe(true);
expect((response.body as NotificationBody).read).toBe(true);
expect(
(
await prisma.notification.findUniqueOrThrow({
where: { id: theirs.id },
})
).read,
).toBe(false);
});
it('a fan-out for colonies:read never reaches a user without it', async () => {
const rows = await notifications.recordForPermission('colonies:read', {
priority: 'INFO',
category: 'SUGGESTION',
message: `Nur mit Volksrecht ${suffix}`,
dedupeKeyPrefix: `e2e-fanout:${suffix}`,
});
createdNotificationIds.push(...rows.map((row) => row.id));
const recipients = rows.map((row) => row.userId);
expect(recipients).toContain(ids.beekeeper);
expect(recipients).not.toContain(ids.bare);
expect(
rows.every((row) => row.requiredPermissions.includes('colonies:read')),
).toBe(true);
const none = await notifications.recordForUsers([ids.bare], {
priority: 'INFO',
category: 'SUGGESTION',
message: 'x',
requiredPermissions: ['colonies:read'],
});
expect(none).toEqual([]);
});
it('a repeated dedupe key writes nothing the second time', async () => {
const input = {
priority: 'INFO' as const,
category: 'SYSTEM' as const,
message: `Einmalig ${suffix}`,
dedupeKeyPrefix: `e2e-dedupe:${suffix}`,
};
const first = await notifications.recordForUsers([ids.beekeeper], input);
createdNotificationIds.push(...first.map((row) => row.id));
const second = await notifications.recordForUsers([ids.beekeeper], input);
expect(first).toHaveLength(1);
expect(second).toEqual([]);
});
it('read-all marks only own notifications', async () => {
const own = await seedNotification(ids.staff);
const foreign = await seedNotification(ids.beekeeper);
const response = await request(server())
.post('/api/v1/notifications/read-all')
.set(as('staff'))
.expect(201);
expect(
(response.body as { updated: number }).updated,
).toBeGreaterThanOrEqual(1);
expect(
(await prisma.notification.findUniqueOrThrow({ where: { id: own.id } }))
.read,
).toBe(true);
expect(
(
await prisma.notification.findUniqueOrThrow({
where: { id: foreign.id },
})
).read,
).toBe(false);
});
it('reading a foreign notification answers 404', async () => {
const foreign = await seedNotification(ids.beekeeper);
await request(server())
.post(`/api/v1/notifications/${foreign.id}/read`)
.set(as('staff'))
.expect(404);
await request(server())
.post('/api/v1/notifications/does-not-exist/read')
.set(as('staff'))
.expect(404);
expect(
(
await prisma.notification.findUniqueOrThrow({
where: { id: foreign.id },
})
).read,
).toBe(false);
});
describe('notification settings', () => {
it('an API key gets 403 on /profile/notification-settings', async () => {
const created = await request(server())
.post('/api/v1/api-keys')
.set(as('admin'))
.send({ name: `notif-e2e ${suffix}`, scopes: ['shop-sync:read'] })
.expect(201);
const body = created.body as { apiKey: { id: string }; token: string };
apiKeyId = body.apiKey.id;
await request(server())
.get('/api/v1/profile/notification-settings')
.set('Authorization', `Bearer ${body.token}`)
.expect(403);
});
it('returns the defaults on first read', async () => {
const response = await request(server())
.get('/api/v1/profile/notification-settings')
.set(as('bare'))
.expect(200);
expect(response.body).toEqual({
taskDueEnabled: true,
digestEnabled: true,
digestTime: '06:30',
quietFrom: '21:00',
quietUntil: '06:30',
telegramCategories: ['TASK_DUE', 'DIGEST'],
telegramDetail: true,
});
});
it('PATCH notification-settings stores digest time, quiet hours and Telegram categories', async () => {
const response = await request(server())
.patch('/api/v1/profile/notification-settings')
.set(as('beekeeper'))
.send({
digestTime: '07:15',
quietFrom: '22:00',
quietUntil: '05:30',
telegramCategories: ['SYSTEM', 'WITHDRAWAL'],
telegramDetail: false,
})
.expect(200);
const settings = response.body as SettingsBody;
expect(settings).toMatchObject({
digestTime: '07:15',
quietFrom: '22:00',
quietUntil: '05:30',
telegramCategories: ['SYSTEM', 'WITHDRAWAL'],
telegramDetail: false,
taskDueEnabled: true,
});
const stored = await prisma.notificationPreference.findUniqueOrThrow({
where: { userId: ids.beekeeper },
});
expect(stored.telegramEnabled).toBe(false);
expect(stored.digestTime).toBe('07:15');
});
it('rejects a digest time outside 05:00 to 12:00 and a lone quiet-hour value', async () => {
const early = await request(server())
.patch('/api/v1/profile/notification-settings')
.set(as('staff'))
.send({ digestTime: '04:30' })
.expect(400);
expect(JSON.stringify(early.body)).toContain(
'Bitte eine Uhrzeit zwischen 05:00 und 12:00 wählen.',
);
const lone = await request(server())
.patch('/api/v1/profile/notification-settings')
.set(as('staff'))
.send({ quietFrom: null })
.expect(400);
expect(JSON.stringify(lone.body)).toContain(
'Bitte Beginn und Ende der Ruhezeit zusammen leeren.',
);
await request(server())
.patch('/api/v1/profile/notification-settings')
.set(as('staff'))
.send({ quietFrom: null, quietUntil: null })
.expect(200);
await request(server())
.patch('/api/v1/profile/notification-settings')
.set(as('staff'))
.send({ digestEnabled: null })
.expect(400);
});
it('does not accept telegramEnabled', async () => {
await request(server())
.patch('/api/v1/profile/notification-settings')
.set(as('staff'))
.send({ telegramEnabled: true })
.expect(400);
});
});
});
@@ -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);
// Automation: the warning cannot be written.
jest
.spyOn(app.get(NotificationsService), 'record')
.spyOn(app.get(NotificationsService), 'recordForPermission')
.mockRejectedValue(new Error('notifications down'));
// Calendar sync: enqueue cannot read its settings.
jest
@@ -290,7 +290,7 @@ describe('Post-commit side effects (e2e)', () => {
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
const colonyId = (await createColony('PC-RETRY')).id;
jest
.spyOn(app.get(NotificationsService), 'record')
.spyOn(app.get(NotificationsService), 'recordForPermission')
.mockRejectedValueOnce(new Error('notifications down'));
const { body } = (await post('/api/v1/inspections', {
@@ -311,9 +311,13 @@ describe('Post-commit side effects (e2e)', () => {
await prisma.$executeRaw`UPDATE "DomainEvent" SET "availableAt" = now() WHERE "aggregateId" = ${body.id}`;
await drainDomainEvents(app, [body.id]);
expect(await taskCount('QUEENLESS_NO_EGGS', colonyId)).toBe(1);
expect(
await prisma.notification.count({ where: { relatedId: colonyId } }),
).toBe(1);
// One copy per recipient (everybody who may read tasks), written once.
const warnings = await prisma.notification.findMany({
where: { relatedId: colonyId },
select: { userId: true },
});
expect(warnings.length).toBeGreaterThan(0);
expect(new Set(warnings.map((w) => w.userId)).size).toBe(warnings.length);
const deliveries = await prisma.domainEventDelivery.findMany({
where: { event: { aggregateId: body.id } },
+125 -1
View File
@@ -5,6 +5,8 @@ import { App } from 'supertest/types';
import { PrismaService } from '../src/prisma/prisma.service';
import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
import { drainDomainEvents } from './utils/domain-events';
import { CalendarSyncSettingsService } from '../src/calendar-sync/calendar-sync-settings.service';
interface QueenBody {
id: string;
@@ -22,6 +24,8 @@ describe('Queens (e2e)', () => {
let colonyId: string;
let queenId: string;
let daughterQueenId: string;
let secondColonyId: string;
let subscriptionId: string;
beforeAll(async () => {
app = await createTestApp();
@@ -53,7 +57,43 @@ describe('Queens (e2e)', () => {
colonyId = colony.id;
});
afterEach(() => {
jest.restoreAllMocks();
});
afterAll(async () => {
if (subscriptionId) {
// Deliveries go with their subscription (ON DELETE CASCADE).
await prisma.webhookSubscription.deleteMany({
where: { id: subscriptionId },
});
}
if (queenId) {
const historyIds = (
await prisma.queenColonyHistory.findMany({
where: { queenId },
select: { id: true },
})
).map((history) => history.id);
await prisma.calendarSyncOutboxEntry.deleteMany({
where: { sourceType: 'QUEEN_ASSIGNMENT', sourceId: { in: historyIds } },
});
}
if (secondColonyId) {
await prisma.queenColonyHistory.deleteMany({
where: { colonyId: secondColonyId },
});
await prisma.colony.update({
where: { id: secondColonyId },
data: { currentQueenId: null },
});
}
if (queenId) {
// Events go with their deliveries (ON DELETE CASCADE).
await prisma.domainEvent.deleteMany({
where: { aggregateType: 'Queen', aggregateId: queenId },
});
}
if (queenId) {
await prisma.queenEvaluation.deleteMany({ where: { queenId } });
}
@@ -70,7 +110,9 @@ describe('Queens (e2e)', () => {
if (queenId) {
await prisma.queen.delete({ where: { id: queenId } });
}
await prisma.colony.deleteMany({ where: { id: colonyId } });
await prisma.colony.deleteMany({
where: { id: { in: [colonyId, secondColonyId].filter(Boolean) } },
});
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
await prisma.refreshToken.deleteMany({ where: { userId: beekeeperId } });
await prisma.auditLog.deleteMany({ where: { userId: beekeeperId } });
@@ -94,6 +136,8 @@ describe('Queens (e2e)', () => {
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({ colonyId })
.expect(201);
// Settle the first assignment's event, so later tests see only their own.
await drainDomainEvents(app, [queenId]);
const colony = await request(app.getHttpServer())
.get(`/api/v1/colonies/${colonyId}`)
@@ -110,6 +154,86 @@ describe('Queens (e2e)', () => {
expect(history.body).toHaveLength(1);
});
it('assigning a queen leaves a calendar entry and a queen.replaced delivery after draining', async () => {
jest.spyOn(app.get(CalendarSyncSettingsService), 'get').mockResolvedValue({
id: 'singleton',
enabled: true,
groupId: 'group-e2e',
syncTask: true,
syncInspection: true,
syncTreatment: true,
syncFeeding: true,
syncHarvest: true,
syncQueenAssignment: true,
syncWithdrawal: true,
updatedAt: new Date(),
});
subscriptionId = (
await prisma.webhookSubscription.create({
data: {
url: 'https://example.com/hook',
events: ['queen.replaced'],
secret: 'a'.repeat(64),
createdByUserId: beekeeperId,
},
})
).id;
secondColonyId = (
await prisma.colony.create({
data: {
code: `V2-${Date.now()}`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
},
})
).id;
await request(app.getHttpServer())
.post(`/api/v1/queens/${queenId}/assign-colony`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({ colonyId: secondColonyId })
.expect(201);
// Nothing but the event is written in the request.
const history = await prisma.queenColonyHistory.findFirstOrThrow({
where: { queenId, colonyId: secondColonyId },
});
expect(
await prisma.calendarSyncOutboxEntry.count({
where: { sourceType: 'QUEEN_ASSIGNMENT', sourceId: history.id },
}),
).toBe(0);
expect(
await prisma.webhookDelivery.count({
where: { subscriptionId },
}),
).toBe(0);
const event = await prisma.domainEvent.findFirstOrThrow({
where: { type: 'QueenAssigned', aggregateId: queenId },
orderBy: { occurredAt: 'desc' },
});
expect(event.payload).toEqual(
expect.objectContaining({
colonyId: secondColonyId,
historyId: history.id,
previousColonyId: colonyId,
}),
);
await drainDomainEvents(app, [queenId]);
expect(
await prisma.calendarSyncOutboxEntry.count({
where: { sourceType: 'QUEEN_ASSIGNMENT', sourceId: history.id },
}),
).toBe(1);
const deliveries = await prisma.webhookDelivery.findMany({
where: { subscriptionId, event: 'queen.replaced' },
});
expect(deliveries).toHaveLength(1);
expect(deliveries[0]?.body).toContain(queenId);
});
it('adds a queen evaluation and lists it back', async () => {
await request(app.getHttpServer())
.post(`/api/v1/queens/${queenId}/evaluations`)
@@ -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 SPLIT_QUEEN_SOURCE = '20261014100300_t4_split_queen_source';
const VARROA_SAMPLE_SIZE = '20261014100400_t4_varroa_sample_size';
const NOTIFICATION_FANOUT = '20261014100500_t4_notification_fanout';
const OCCUPIED_FRAMES = '20261014100700_t4_occupied_frames';
const NOTIFICATION_SETTINGS = '20261014100800_t4_notification_settings';
const TIMEOUT_MS = 120_000;
const TIMEOUT_MS = 240_000;
interface InspectionSeed {
id: string;
@@ -36,6 +39,48 @@ async function seedColony(db: ScratchDatabase): Promise<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(
db: ScratchDatabase,
seeds: InspectionSeed[],
@@ -215,4 +260,215 @@ describe('T4 schema II migrations (e2e)', () => {
},
TIMEOUT_MS,
);
it(
'broadcasts are copied per active user with their read flag',
async () => {
db = await createScratchDatabase(NOTIFICATION_FANOUT);
await seedUsers(db);
await seedNotification(db, {
id: 'broadcast',
userId: null,
relatedType: 'COLONY',
read: true,
createdAt: '2026-04-01T08:00:00Z',
});
await seedNotification(db, {
id: 'personal',
userId: 'user-c',
relatedType: 'COLONY',
});
const [{ createdAt }] = await db.query<{ createdAt: string }>(
`SELECT "createdAt"::text AS "createdAt" FROM "Notification" WHERE "id" = 'broadcast'`,
);
await db.migrateThrough(NOTIFICATION_FANOUT);
const rows = await db.query<{
userId: string;
read: boolean;
createdAt: string;
}>(
`SELECT "userId", "read", "createdAt"::text AS "createdAt"
FROM "Notification" `,
);
rows.sort((a, b) => (a.userId < b.userId ? -1 : 1));
expect(rows.map(({ userId, read }) => ({ userId, read }))).toEqual([
{ userId: 'user-a', read: true },
{ userId: 'user-b', read: true },
{ userId: 'user-c', read: false },
]);
expect(rows[0].createdAt).toBe(createdAt);
expect(rows[1].createdAt).toBe(createdAt);
expect(
await db.query(
`SELECT is_nullable FROM information_schema.columns
WHERE table_schema = current_schema() AND table_name = 'Notification' AND column_name = 'userId'`,
),
).toEqual([{ is_nullable: 'NO' }]);
},
TIMEOUT_MS,
);
it(
'drops broadcasts when no user is active and keeps the personal rows',
async () => {
db = await createScratchDatabase(NOTIFICATION_FANOUT);
await db.query(
`INSERT INTO "Role" ("id", "key", "name", "updatedAt")
VALUES ('role-1', 'MIG_TEST', 'Migration test', now())`,
);
await db.query(
`INSERT INTO "User" ("id", "email", "passwordHash", "name", "roleId", "isActive", "updatedAt")
VALUES ('user-x', '[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 () => {
await expect(
withdrawalStatusAt('2026-07-14T23:59:59.000Z'),
withdrawalStatusAt('2026-07-14T23:59:59+02:00'),
).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);
});
describe('GET /varroa-measurements/threshold', () => {
const thresholdBody = (token: string) =>
describe('GET /varroa-measurements/thresholds', () => {
const thresholdsBody = (token: string, query = '') =>
request(app.getHttpServer())
.get('/api/v1/varroa-measurements/threshold')
.get(`/api/v1/varroa-measurements/thresholds${query}`)
.set('Authorization', `Bearer ${token}`);
it('returns the threshold shape and is not shadowed by :id', async () => {
const response = await thresholdBody(staffToken).expect(200);
it('returns the bands shape and is not shadowed by :id', async () => {
const response = await thresholdsBody(staffToken).expect(200);
const body = response.body as { thresholdMitesPerDay: number | null };
expect(Object.keys(body)).toEqual(['thresholdMitesPerDay']);
expect(
body.thresholdMitesPerDay === null ||
typeof body.thresholdMitesPerDay === 'number',
).toBe(true);
const body = response.body as { day: string; active: boolean };
expect(Object.keys(body).sort()).toEqual(
['active', 'bands', 'day', 'postTreatmentUntil'].sort(),
);
expect(typeof body.active).toBe('boolean');
});
// The shared rule is never written here (parallel e2e workers); the
// inactive / custom value cases are covered by varroa-threshold.spec.ts.
// inactive / custom value cases are covered by the unit specs.
it('rejects a role without any varroa permission', () =>
thresholdBody(noVarroaToken).expect(403));
thresholdsBody(noVarroaToken).expect(403));
});
describe('POST /varroa-measurements/bulk', () => {