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:
1 parent
d99a697739
commit
f468dc5f75
87 files changed
+5941
-429
No files matched your search
@@ -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
|
||||
|
||||
[0m [90m 8 |[39m [36mimport[39m { [33mUpdateVarroaMeasurementDto[39m } [36mfrom[39m [32m'./dto/update-varroa-measurement.dto'[39m[33m;[39m
|
||||
[90m 9 |[39m [36mimport[39m { [33mVarroaService[39m } [36mfrom[39m [32m'./varroa.service'[39m[33m;[39m
|
||||
[31m[1m>[22m[39m[90m 10 |[39m [36mimport[39m { [33mVarroaThresholdService[39m } [36mfrom[39m [32m'./varroa-threshold.service'[39m[33m;[39m
|
||||
[90m |[39m [31m[1m^[22m[39m
|
||||
[90m 11 |[39m [36mimport[39m { [33mIdempotent[39m } [36mfrom[39m [32m'../common/idempotency/idempotent.decorator'[39m[33m;[39m
|
||||
[90m 12 |[39m
|
||||
[90m 13 |[39m [33m@[39m[33mApiTags[39m([32m'varroa-measurements'[39m)[0m
|
||||
|
||||
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)
|
||||
|
||||
[31m[Nest] 17844 - [39m14/10/2026, 13:53:09 [31m ERROR[39m [38;5;3m[DomainEventDispatcher] [39m[31mDomain events FAILED, an expired claim used up their attempts: cmv81wj4d001xroijcqgc9y09[39m
|
||||
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
|
||||
|
||||
[0m [90m 109 |[39m [36mconst[39m d [33m=[39m [36mawait[39m db[33m.[39m$queryRaw[33m<[39m[33munknown[39m[][33m>[39m([33mPrisma[39m[33m.[39msql[32m`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}`[39m)[33m;[39m
|
||||
[90m 110 |[39m [90m// eslint-disable-next-line[39m
|
||||
[31m[1m>[22m[39m[90m 111 |[39m require([32m'node:fs'[39m)[33m.[39mappendFileSync(process[33m.[39menv[33m.[39m[33mDBG_LOG[39m [36mas[39m string[33m,[39m [36mnew[39m [33mDate[39m()[33m.[39mtoISOString() [33m+[39m [32m' '[39m [33m+[39m table [33m+[39m [32m' empty claim; rows='[39m [33m+[39m [33mJSON[39m[33m.[39mstringify(d) [33m+[39m [32m'\n'[39m)[33m;[39m
|
||||
[90m |[39m [31m[1m^[22m[39m
|
||||
[90m 112 |[39m }
|
||||
[90m 113 |[39m [36mreturn[39m __res[33m;[39m
|
||||
[90m 114 |[39m }[0m
|
||||
|
||||
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
|
||||
|
||||
[0m [90m 109 |[39m [36mconst[39m d [33m=[39m [36mawait[39m db[33m.[39m$queryRaw[33m<[39m[33munknown[39m[][33m>[39m([33mPrisma[39m[33m.[39msql[32m`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}`[39m)[33m;[39m
|
||||
[90m 110 |[39m [90m// eslint-disable-next-line[39m
|
||||
[31m[1m>[22m[39m[90m 111 |[39m require([32m'node:fs'[39m)[33m.[39mappendFileSync(process[33m.[39menv[33m.[39m[33mDBG_LOG[39m [36mas[39m string[33m,[39m [36mnew[39m [33mDate[39m()[33m.[39mtoISOString() [33m+[39m [32m' '[39m [33m+[39m table [33m+[39m [32m' empty claim; rows='[39m [33m+[39m [33mJSON[39m[33m.[39mstringify(d) [33m+[39m [32m'\n'[39m)[33m;[39m
|
||||
[90m |[39m [31m[1m^[22m[39m
|
||||
[90m 112 |[39m }
|
||||
[90m 113 |[39m [36mreturn[39m __res[33m;[39m
|
||||
[90m 114 |[39m }[0m
|
||||
|
||||
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]
|
||||
|
||||
[0m [90m 320 |[39m deliveries[33m:[39m [{ handler[33m:[39m [32m'test.counter'[39m[33m,[39m status[33m:[39m [32m'FAILED'[39m[33m,[39m attempts[33m:[39m [35m7[39m }][33m,[39m
|
||||
[90m 321 |[39m })[33m;[39m
|
||||
[31m[1m>[22m[39m[90m 322 |[39m [36mawait[39m expect(appA[33m.[39m[36mget[39m([33mDomainEventDispatcher[39m)[33m.[39mdrain(filter))[33m.[39mresolves[33m.[39mtoBe(
|
||||
[90m |[39m [31m[1m^[22m[39m
|
||||
[90m 323 |[39m [35m0[39m[33m,[39m
|
||||
[90m 324 |[39m )[33m;[39m
|
||||
[90m 325 |[39m })[33m;[39m[0m
|
||||
|
||||
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
|
||||
|
||||
[0m [90m 109 |[39m [36mconst[39m d [33m=[39m [36mawait[39m db[33m.[39m$queryRaw[33m<[39m[33munknown[39m[][33m>[39m([33mPrisma[39m[33m.[39msql[32m`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}`[39m)[33m;[39m
|
||||
[90m 110 |[39m [90m// eslint-disable-next-line[39m
|
||||
[31m[1m>[22m[39m[90m 111 |[39m require([32m'node:fs'[39m)[33m.[39mappendFileSync(process[33m.[39menv[33m.[39m[33mDBG_LOG[39m [36mas[39m string[33m,[39m [36mnew[39m [33mDate[39m()[33m.[39mtoISOString() [33m+[39m [32m' '[39m [33m+[39m table [33m+[39m [32m' empty claim; rows='[39m [33m+[39m [33mJSON[39m[33m.[39mstringify(d) [33m+[39m [32m'\n'[39m)[33m;[39m
|
||||
[90m |[39m [31m[1m^[22m[39m
|
||||
[90m 112 |[39m }
|
||||
[90m 113 |[39m [36mreturn[39m __res[33m;[39m
|
||||
[90m 114 |[39m }[0m
|
||||
|
||||
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
|
||||
|
||||
[0m [90m 109 |[39m [36mconst[39m d [33m=[39m [36mawait[39m db[33m.[39m$queryRaw[33m<[39m[33munknown[39m[][33m>[39m([33mPrisma[39m[33m.[39msql[32m`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}`[39m)[33m;[39m
|
||||
[90m 110 |[39m [90m// eslint-disable-next-line[39m
|
||||
[31m[1m>[22m[39m[90m 111 |[39m require([32m'node:fs'[39m)[33m.[39mappendFileSync(process[33m.[39menv[33m.[39m[33mDBG_LOG[39m [36mas[39m string[33m,[39m [36mnew[39m [33mDate[39m()[33m.[39mtoISOString() [33m+[39m [32m' '[39m [33m+[39m table [33m+[39m [32m' empty claim; rows='[39m [33m+[39m [33mJSON[39m[33m.[39mstringify(d) [33m+[39m [32m'\n'[39m)[33m;[39m
|
||||
[90m |[39m [31m[1m^[22m[39m
|
||||
[90m 112 |[39m }
|
||||
[90m 113 |[39m [36mreturn[39m __res[33m;[39m
|
||||
[90m 114 |[39m }[0m
|
||||
|
||||
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
|
||||
|
||||
[0m [90m 109 |[39m [36mconst[39m d [33m=[39m [36mawait[39m db[33m.[39m$queryRaw[33m<[39m[33munknown[39m[][33m>[39m([33mPrisma[39m[33m.[39msql[32m`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}`[39m)[33m;[39m
|
||||
[90m 110 |[39m [90m// eslint-disable-next-line[39m
|
||||
[31m[1m>[22m[39m[90m 111 |[39m require([32m'node:fs'[39m)[33m.[39mappendFileSync(process[33m.[39menv[33m.[39m[33mDBG_LOG[39m [36mas[39m string[33m,[39m [36mnew[39m [33mDate[39m()[33m.[39mtoISOString() [33m+[39m [32m' '[39m [33m+[39m table [33m+[39m [32m' empty claim; rows='[39m [33m+[39m [33mJSON[39m[33m.[39mstringify(d) [33m+[39m [32m'\n'[39m)[33m;[39m
|
||||
[90m |[39m [31m[1m^[22m[39m
|
||||
[90m 112 |[39m }
|
||||
[90m 113 |[39m [36mreturn[39m __res[33m;[39m
|
||||
[90m 114 |[39m }[0m
|
||||
|
||||
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]
|
||||
|
||||
[0m [90m 508 |[39m [36mconst[39m filter [33m=[39m [36mawait[39m filterFor([32m'failing'[39m)[33m;[39m
|
||||
[90m 509 |[39m
|
||||
[31m[1m>[22m[39m[90m 510 |[39m [36mawait[39m expect(appA[33m.[39m[36mget[39m([33mDomainEventDispatcher[39m)[33m.[39mdrain(filter))[33m.[39mresolves[33m.[39mtoBe(
|
||||
[90m |[39m [31m[1m^[22m[39m
|
||||
[90m 511 |[39m [35m1[39m[33m,[39m
|
||||
[90m 512 |[39m )[33m;[39m
|
||||
[90m 513 |[39m[0m
|
||||
|
||||
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);
|
||||
+39
@@ -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;
|
||||
@@ -125,6 +125,7 @@ model User {
|
||||
batchMovements BatchMovement[]
|
||||
rawMaterialLots RawMaterialLot[]
|
||||
notifications Notification[]
|
||||
notificationPreference NotificationPreference?
|
||||
queenEvaluations QueenEvaluation[]
|
||||
webhookSubscriptions WebhookSubscription[]
|
||||
documents Document[]
|
||||
@@ -291,6 +292,7 @@ model Colony {
|
||||
colonyType ColonyType
|
||||
status ColonyStatus @default(ACTIVE)
|
||||
strength Int?
|
||||
occupiedFrames Int?
|
||||
broodStatus ColonyCondition?
|
||||
feedStatus ColonyCondition?
|
||||
healthStatus ColonyCondition?
|
||||
@@ -1374,14 +1376,30 @@ 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])
|
||||
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())
|
||||
|
||||
@@ -1389,6 +1407,21 @@ model Notification {
|
||||
@@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', () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
imported.notification = rowsOf('notification').length;
|
||||
for (const userId of owners) {
|
||||
const data = {
|
||||
...withCategory,
|
||||
id:
|
||||
rawUserId === null
|
||||
? `${row.id as string}:${userId}`
|
||||
: (row.id as string),
|
||||
userId,
|
||||
};
|
||||
// The unique dedupeKey of a row that lives here under another id
|
||||
// would abort the whole restore: skip that row instead.
|
||||
const dedupeKey = asString(data, 'dedupeKey');
|
||||
if (dedupeKey !== null) {
|
||||
const holder = await tx.notification.findUnique({
|
||||
where: { dedupeKey },
|
||||
select: { id: true },
|
||||
});
|
||||
if (holder && holder.id !== data.id) {
|
||||
notificationsSkipped += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
await tx.notification.upsert({
|
||||
where: { id: data.id },
|
||||
create: data as never,
|
||||
// A copy made from a broadcast is only added: a repeated
|
||||
// restore must not reset what the user already read.
|
||||
update: (rawUserId === null ? {} : data) as never,
|
||||
});
|
||||
notificationsWritten += 1;
|
||||
}
|
||||
}
|
||||
imported.notification = notificationsWritten;
|
||||
if (notificationsSkipped > 0) {
|
||||
imported.notificationSkipped = notificationsSkipped;
|
||||
this.logger.warn(
|
||||
`${notificationsSkipped} Meldung(en) übersprungen: Nutzer im Zielsystem unbekannt.`,
|
||||
);
|
||||
}
|
||||
|
||||
let preferencesWritten = 0;
|
||||
for (const row of rowsOf('notificationPreference')) {
|
||||
const userId = asString(row, 'userId');
|
||||
if (userId === null || !knownUserIds.has(userId)) {
|
||||
continue;
|
||||
}
|
||||
await tx.notificationPreference.upsert({
|
||||
where: { userId },
|
||||
create: row as never,
|
||||
update: row as never,
|
||||
});
|
||||
preferencesWritten += 1;
|
||||
}
|
||||
imported.notificationPreference = preferencesWritten;
|
||||
|
||||
// Append-only: entries already here are never rewritten by a file.
|
||||
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);
|
||||
held = new Set();
|
||||
service = new NotificationsService(
|
||||
prisma as unknown as PrismaService,
|
||||
{
|
||||
getPermissions: jest
|
||||
.fn()
|
||||
.mockImplementation(() => Promise.resolve(held)),
|
||||
} as unknown as PermissionResolverService,
|
||||
);
|
||||
});
|
||||
|
||||
it('records a notification with the given priority and message', async () => {
|
||||
const created = {
|
||||
id: 'n1',
|
||||
priority: 'KRITISCH',
|
||||
message: 'Keine Königin gesehen',
|
||||
};
|
||||
prisma.notification.create.mockResolvedValueOnce(created);
|
||||
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).toEqual(created);
|
||||
expect(prisma.notification.create).toHaveBeenCalledWith({
|
||||
data: { priority: 'KRITISCH', message: 'Keine Königin gesehen' },
|
||||
});
|
||||
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('records through the transaction client when one is given', async () => {
|
||||
const tx = { notification: { create: jest.fn() } };
|
||||
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(
|
||||
{ priority: 'HINWEIS', message: 'Aufgabe fällig' },
|
||||
{ userId: 'u1', priority: 'INFO', category: 'SYSTEM', message: 'm' },
|
||||
tx as never,
|
||||
);
|
||||
|
||||
expect(tx.notification.create).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.notification.create).not.toHaveBeenCalled();
|
||||
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',
|
||||
});
|
||||
|
||||
it('selects recipients like the permission guard: active user, active role, granted key', async () => {
|
||||
await service.recordForPermission('backup:configure', {
|
||||
priority: 'WARNUNG',
|
||||
message: 'x',
|
||||
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, every key granted', async () => {
|
||||
await service.recordForPermission(
|
||||
['backup:configure', 'colonies:read'],
|
||||
input,
|
||||
);
|
||||
|
||||
expect(prisma.user.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
isActive: true,
|
||||
role: {
|
||||
status: 'ACTIVE',
|
||||
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 }[] } }],
|
||||
const rows = await service.recordForUsers(['u1', 'u2'], {
|
||||
priority: 'INFO',
|
||||
category: 'SUGGESTION',
|
||||
message: 'm',
|
||||
requiredPermissions: ['colonies:read'],
|
||||
});
|
||||
|
||||
expect(prisma.user.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
id: { in: ['u1', 'u2'] },
|
||||
}) as unknown,
|
||||
}),
|
||||
);
|
||||
expect(rows.map((r) => r.userId)).toEqual(['u2']);
|
||||
});
|
||||
|
||||
it('does nothing for an empty user list', async () => {
|
||||
await expect(
|
||||
service.recordForUsers([], {
|
||||
priority: 'INFO',
|
||||
category: 'SYSTEM',
|
||||
message: 'm',
|
||||
}),
|
||||
).resolves.toEqual([]);
|
||||
expect(prisma.user.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reading', () => {
|
||||
it('lists only the own notifications, newest first, at most 100', async () => {
|
||||
await service.findOwn('user-1', { unreadOnly: true });
|
||||
|
||||
expect(prisma.notification.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { userId: 'user-1', read: false },
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
take: 500,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('hides a row whose permissions the user no longer holds (revoked after the write)', async () => {
|
||||
const row = (id: string, requiredPermissions: string[]) => ({
|
||||
id,
|
||||
priority: 'INFO',
|
||||
category: 'SYSTEM',
|
||||
message: 'm',
|
||||
relatedType: null,
|
||||
relatedId: null,
|
||||
link: null,
|
||||
read: false,
|
||||
createdAt: new Date(0),
|
||||
requiredPermissions,
|
||||
});
|
||||
prisma.notification.findMany.mockResolvedValue([
|
||||
row('open', []),
|
||||
row('revoked', ['colonies:read', 'varroa:read']),
|
||||
row('kept', ['colonies:read']),
|
||||
]);
|
||||
held.add('colonies:read');
|
||||
|
||||
const views = await service.findOwn('user-1');
|
||||
|
||||
expect(views.map((view) => view.id)).toEqual(['open', 'kept']);
|
||||
expect(views[0]).not.toHaveProperty('requiredPermissions');
|
||||
});
|
||||
|
||||
it('selects the view fields plus the permission list for filtering, never the dedupe key', async () => {
|
||||
await service.findOwn('user-1');
|
||||
|
||||
const [[arg]] = prisma.notification.findMany.mock.calls as [
|
||||
[{ select: Record<string, true> }],
|
||||
];
|
||||
expect(queryArg.where.OR).toEqual([{ userId: 'user-1' }, { userId: null }]);
|
||||
expect(Object.keys(arg.select).sort()).toEqual([
|
||||
'category',
|
||||
'createdAt',
|
||||
'id',
|
||||
'link',
|
||||
'message',
|
||||
'priority',
|
||||
'read',
|
||||
'relatedId',
|
||||
'relatedType',
|
||||
'requiredPermissions',
|
||||
]);
|
||||
});
|
||||
|
||||
it('throws NotFoundException when marking an unknown notification as read', async () => {
|
||||
prisma.notification.findUnique.mockResolvedValueOnce(null);
|
||||
await expect(service.markRead('missing', 'user-1')).rejects.toBeInstanceOf(
|
||||
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("throws ForbiddenException when marking another user's notification as read", async () => {
|
||||
prisma.notification.findUnique.mockResolvedValueOnce({
|
||||
it('marks the own notification as read', async () => {
|
||||
prisma.notification.updateMany.mockResolvedValue({ count: 1 });
|
||||
prisma.notification.findFirstOrThrow.mockResolvedValue({
|
||||
id: 'n1',
|
||||
userId: 'owner',
|
||||
read: false,
|
||||
});
|
||||
await expect(service.markRead('n1', 'someone-else')).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
read: true,
|
||||
});
|
||||
|
||||
it('marks a broadcast notification (no owner) as read for any user', async () => {
|
||||
prisma.notification.findUnique.mockResolvedValueOnce({
|
||||
id: 'n1',
|
||||
userId: null,
|
||||
read: false,
|
||||
await expect(service.markRead('n1', 'user-1')).resolves.toMatchObject({
|
||||
read: true,
|
||||
});
|
||||
prisma.notification.update.mockResolvedValueOnce({ id: 'n1', read: true });
|
||||
});
|
||||
|
||||
const result = await service.markRead('n1', 'any-user');
|
||||
it('marks only the own unread notifications as read', async () => {
|
||||
prisma.notification.updateMany.mockResolvedValue({ count: 3 });
|
||||
|
||||
expect(result.read).toBe(true);
|
||||
await expect(service.markAllRead('user-1')).resolves.toEqual({
|
||||
updated: 3,
|
||||
});
|
||||
expect(prisma.notification.updateMany).toHaveBeenCalledWith({
|
||||
where: { userId: 'user-1', read: false },
|
||||
data: { read: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("marks the owner's own notification as read", async () => {
|
||||
prisma.notification.findUnique.mockResolvedValueOnce({
|
||||
id: 'n1',
|
||||
userId: 'user-1',
|
||||
read: false,
|
||||
describe('assertAppLink', () => {
|
||||
it('accepts a relative app path', () => {
|
||||
expect(() => assertAppLink('/tasks?status=OPEN&x=1')).not.toThrow();
|
||||
});
|
||||
prisma.notification.update.mockResolvedValueOnce({ id: 'n1', read: true });
|
||||
|
||||
const result = await service.markRead('n1', 'user-1');
|
||||
it.each([
|
||||
'//evil.example',
|
||||
'https://evil.example',
|
||||
'/\\evil',
|
||||
'tasks',
|
||||
'/a b',
|
||||
])('rejects %s', (link) => expect(() => assertAppLink(link)).toThrow());
|
||||
|
||||
expect(result.read).toBe(true);
|
||||
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;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class NotificationsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
type NotificationContent = Omit<FanOutNotificationInput, 'dedupeKeyPrefix'>;
|
||||
|
||||
async record(
|
||||
input: CreateNotificationInput,
|
||||
tx?: Prisma.TransactionClient,
|
||||
): Promise<Notification> {
|
||||
const db: DbClient = tx ?? this.prisma;
|
||||
return db.notification.create({ data: { ...input } });
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
async recordForPermission(
|
||||
permission: PermissionKey,
|
||||
input: Omit<CreateNotificationInput, 'userId'>,
|
||||
@Injectable()
|
||||
export class NotificationsService {
|
||||
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<number> {
|
||||
): Promise<Notification | null> {
|
||||
const db: DbClient = tx ?? this.prisma;
|
||||
const recipients = await db.user.findMany({
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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,
|
||||
});
|
||||
private async write(
|
||||
db: DbClient,
|
||||
targets: readonly Target[],
|
||||
content: NotificationContent,
|
||||
recipientKeys: readonly PermissionKey[],
|
||||
): Promise<Notification[]> {
|
||||
if (content.link) {
|
||||
assertAppLink(content.link);
|
||||
}
|
||||
|
||||
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');
|
||||
if (targets.length === 0) {
|
||||
return [];
|
||||
}
|
||||
if (notification.userId !== null && notification.userId !== actingUserId) {
|
||||
throw new ForbiddenException("Cannot modify another user's notification");
|
||||
}
|
||||
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 };
|
||||
|
||||
@@ -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,13 +160,30 @@ export class QueensService {
|
||||
dto: AssignColonyDto,
|
||||
actingUserId: string,
|
||||
): Promise<Queen> {
|
||||
const queen = await this.findById(id);
|
||||
await this.findById(id);
|
||||
// Only to know which colony to lock; the transaction re-reads it.
|
||||
const preRead = await this.prisma.colony.findUnique({
|
||||
where: { currentQueenId: id },
|
||||
select: { id: true },
|
||||
});
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
// Colonies first (one statement, ascending ids), then the queen: the
|
||||
// order every writer of both uses.
|
||||
await lockRowsForUpdate(tx, 'Colony', [
|
||||
dto.colonyId,
|
||||
...(preRead ? [preRead.id] : []),
|
||||
]);
|
||||
await lockRowForUpdate(tx, 'Queen', id);
|
||||
const queen = await tx.queen.findUnique({ where: { id } });
|
||||
if (!queen) {
|
||||
throw new NotFoundException('Queen not found');
|
||||
}
|
||||
if (QUEEN_NO_LONGER_AVAILABLE_STATUSES.includes(queen.status)) {
|
||||
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({
|
||||
const colony = await tx.colony.findUnique({
|
||||
where: { id: dto.colonyId },
|
||||
});
|
||||
if (!colony) {
|
||||
@@ -178,8 +197,18 @@ export class QueensService {
|
||||
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({
|
||||
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 } });
|
||||
});
|
||||
|
||||
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],
|
||||
|
||||
@@ -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,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 };
|
||||
|
||||
|
||||
@@ -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 =
|
||||
const merged: Derivation = {
|
||||
method: dto.method ?? before.method,
|
||||
miteCount: dto.miteCount === undefined ? before.miteCount : dto.miteCount,
|
||||
durationMinutes:
|
||||
dto.durationMinutes === undefined
|
||||
? before.durationMinutes
|
||||
: dto.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;
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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({
|
||||
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: beekeeperEmail,
|
||||
email: emails[who],
|
||||
passwordHash,
|
||||
name: 'Beekeeper',
|
||||
role: { connect: { key: 'BEEKEEPER' } },
|
||||
name: who,
|
||||
role: { connect: { key: roleOf[who] } },
|
||||
},
|
||||
});
|
||||
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;
|
||||
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}`)
|
||||
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('reading is per user: marking one copy read leaves the other unread', async () => {
|
||||
const [first, second] = await notifications.recordForUsers(
|
||||
[ids.beekeeper, ids.staff],
|
||||
{
|
||||
priority: 'INFO',
|
||||
category: 'SYSTEM',
|
||||
message: `Rundmeldung ${suffix}`,
|
||||
dedupeKeyPrefix: `e2e-read:${suffix}`,
|
||||
},
|
||||
);
|
||||
createdNotificationIds.push(first.id, second.id);
|
||||
const mine = [first, second].find((n) => n.userId === ids.beekeeper)!;
|
||||
const theirs = [first, second].find((n) => n.userId === ids.staff)!;
|
||||
|
||||
const response = await request(server())
|
||||
.post(`/api/v1/notifications/${mine.id}/read`)
|
||||
.set(as('beekeeper'))
|
||||
.expect(201);
|
||||
|
||||
expect((response.body as NotificationBody).read).toBe(true);
|
||||
expect(
|
||||
(
|
||||
await prisma.notification.findUniqueOrThrow({
|
||||
where: { id: theirs.id },
|
||||
})
|
||||
).read,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('a fan-out for colonies:read never reaches a user without it', async () => {
|
||||
const rows = await notifications.recordForPermission('colonies:read', {
|
||||
priority: 'INFO',
|
||||
category: 'SUGGESTION',
|
||||
message: `Nur mit Volksrecht ${suffix}`,
|
||||
dedupeKeyPrefix: `e2e-fanout:${suffix}`,
|
||||
});
|
||||
createdNotificationIds.push(...rows.map((row) => row.id));
|
||||
|
||||
const recipients = rows.map((row) => row.userId);
|
||||
expect(recipients).toContain(ids.beekeeper);
|
||||
expect(recipients).not.toContain(ids.bare);
|
||||
expect(
|
||||
rows.every((row) => row.requiredPermissions.includes('colonies:read')),
|
||||
).toBe(true);
|
||||
|
||||
const none = await notifications.recordForUsers([ids.bare], {
|
||||
priority: 'INFO',
|
||||
category: 'SUGGESTION',
|
||||
message: 'x',
|
||||
requiredPermissions: ['colonies:read'],
|
||||
});
|
||||
expect(none).toEqual([]);
|
||||
});
|
||||
|
||||
it('a repeated dedupe key writes nothing the second time', async () => {
|
||||
const input = {
|
||||
priority: 'INFO' as const,
|
||||
category: 'SYSTEM' as const,
|
||||
message: `Einmalig ${suffix}`,
|
||||
dedupeKeyPrefix: `e2e-dedupe:${suffix}`,
|
||||
};
|
||||
const first = await notifications.recordForUsers([ids.beekeeper], input);
|
||||
createdNotificationIds.push(...first.map((row) => row.id));
|
||||
|
||||
const second = await notifications.recordForUsers([ids.beekeeper], input);
|
||||
|
||||
expect(first).toHaveLength(1);
|
||||
expect(second).toEqual([]);
|
||||
});
|
||||
|
||||
it('read-all marks only own notifications', async () => {
|
||||
const own = await seedNotification(ids.staff);
|
||||
const foreign = await seedNotification(ids.beekeeper);
|
||||
|
||||
const response = await request(server())
|
||||
.post('/api/v1/notifications/read-all')
|
||||
.set(as('staff'))
|
||||
.expect(201);
|
||||
|
||||
expect(
|
||||
(response.body as { updated: number }).updated,
|
||||
).toBeGreaterThanOrEqual(1);
|
||||
expect(
|
||||
(await prisma.notification.findUniqueOrThrow({ where: { id: own.id } }))
|
||||
.read,
|
||||
).toBe(true);
|
||||
expect(
|
||||
(
|
||||
await prisma.notification.findUniqueOrThrow({
|
||||
where: { id: foreign.id },
|
||||
})
|
||||
).read,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('reading a foreign notification answers 404', async () => {
|
||||
const foreign = await seedNotification(ids.beekeeper);
|
||||
|
||||
await request(server())
|
||||
.post(`/api/v1/notifications/${foreign.id}/read`)
|
||||
.set(as('staff'))
|
||||
.expect(404);
|
||||
await request(server())
|
||||
.post('/api/v1/notifications/does-not-exist/read')
|
||||
.set(as('staff'))
|
||||
.expect(404);
|
||||
expect(
|
||||
(
|
||||
await prisma.notification.findUniqueOrThrow({
|
||||
where: { id: foreign.id },
|
||||
})
|
||||
).read,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
describe('notification settings', () => {
|
||||
it('an API key gets 403 on /profile/notification-settings', async () => {
|
||||
const created = await request(server())
|
||||
.post('/api/v1/api-keys')
|
||||
.set(as('admin'))
|
||||
.send({ name: `notif-e2e ${suffix}`, scopes: ['shop-sync:read'] })
|
||||
.expect(201);
|
||||
const body = created.body as { apiKey: { id: string }; token: string };
|
||||
apiKeyId = body.apiKey.id;
|
||||
|
||||
await request(server())
|
||||
.get('/api/v1/profile/notification-settings')
|
||||
.set('Authorization', `Bearer ${body.token}`)
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('marks a notification as read', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/api/v1/notifications/${notificationId}/read`)
|
||||
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||
.expect(201);
|
||||
const notification = response.body as NotificationBody;
|
||||
expect(notification.read).toBe(true);
|
||||
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 } },
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user