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
+6022
-510
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;
|
||||||
@@ -103,33 +103,34 @@ model User {
|
|||||||
|
|
||||||
refreshTokens RefreshToken[]
|
refreshTokens RefreshToken[]
|
||||||
|
|
||||||
inspections Inspection[]
|
inspections Inspection[]
|
||||||
varroaMeasurements VarroaMeasurement[]
|
varroaMeasurements VarroaMeasurement[]
|
||||||
swarmControlChecks SwarmControlCheck[]
|
swarmControlChecks SwarmControlCheck[]
|
||||||
winterChecks WinterCheck[]
|
winterChecks WinterCheck[]
|
||||||
treatments Treatment[]
|
treatments Treatment[]
|
||||||
feedings Feeding[]
|
feedings Feeding[]
|
||||||
harvests Harvest[]
|
harvests Harvest[]
|
||||||
extractions Extraction[]
|
extractions Extraction[]
|
||||||
honeyMeasurements HoneyMeasurement[]
|
honeyMeasurements HoneyMeasurement[]
|
||||||
stockMovements StockMovement[]
|
stockMovements StockMovement[]
|
||||||
assignedTasks Task[] @relation("TaskAssignee")
|
assignedTasks Task[] @relation("TaskAssignee")
|
||||||
createdTasks Task[] @relation("TaskCreator")
|
createdTasks Task[] @relation("TaskCreator")
|
||||||
decidedSuggestions Suggestion[] @relation("SuggestionDecider")
|
decidedSuggestions Suggestion[] @relation("SuggestionDecider")
|
||||||
assignedTaskTemplates TaskTemplate[] @relation("TaskTemplateAssignee")
|
assignedTaskTemplates TaskTemplate[] @relation("TaskTemplateAssignee")
|
||||||
createdTaskTemplates TaskTemplate[] @relation("TaskTemplateCreator")
|
createdTaskTemplates TaskTemplate[] @relation("TaskTemplateCreator")
|
||||||
colonyMoves ColonyApiaryHistory[]
|
colonyMoves ColonyApiaryHistory[]
|
||||||
auditLogs AuditLog[]
|
auditLogs AuditLog[]
|
||||||
bottlingBatches BottlingBatch[]
|
bottlingBatches BottlingBatch[]
|
||||||
productionBatches ProductionBatch[]
|
productionBatches ProductionBatch[]
|
||||||
batchMovements BatchMovement[]
|
batchMovements BatchMovement[]
|
||||||
rawMaterialLots RawMaterialLot[]
|
rawMaterialLots RawMaterialLot[]
|
||||||
notifications Notification[]
|
notifications Notification[]
|
||||||
queenEvaluations QueenEvaluation[]
|
notificationPreference NotificationPreference?
|
||||||
webhookSubscriptions WebhookSubscription[]
|
queenEvaluations QueenEvaluation[]
|
||||||
documents Document[]
|
webhookSubscriptions WebhookSubscription[]
|
||||||
honeyBatchRelocations HoneyBatchRelocation[]
|
documents Document[]
|
||||||
apiKeys ApiKey[]
|
honeyBatchRelocations HoneyBatchRelocation[]
|
||||||
|
apiKeys ApiKey[]
|
||||||
|
|
||||||
@@index([roleId])
|
@@index([roleId])
|
||||||
}
|
}
|
||||||
@@ -291,6 +292,7 @@ model Colony {
|
|||||||
colonyType ColonyType
|
colonyType ColonyType
|
||||||
status ColonyStatus @default(ACTIVE)
|
status ColonyStatus @default(ACTIVE)
|
||||||
strength Int?
|
strength Int?
|
||||||
|
occupiedFrames Int?
|
||||||
broodStatus ColonyCondition?
|
broodStatus ColonyCondition?
|
||||||
feedStatus ColonyCondition?
|
feedStatus ColonyCondition?
|
||||||
healthStatus ColonyCondition?
|
healthStatus ColonyCondition?
|
||||||
@@ -1374,21 +1376,52 @@ enum NotificationPriority {
|
|||||||
KRITISCH
|
KRITISCH
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum NotificationCategory {
|
||||||
|
TASK_DUE
|
||||||
|
DIGEST
|
||||||
|
SUGGESTION
|
||||||
|
WITHDRAWAL
|
||||||
|
SEASON
|
||||||
|
SYSTEM
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jede Zeile gehoert genau einem Empfaenger (T4: Fan-out mit Rechtepruefung
|
||||||
|
// beim Schreiben). requiredPermissions = Rechte des Empfaengers plus Rechte
|
||||||
|
// des Inhalts; dedupeKey = "<praefix>:<userId>".
|
||||||
model Notification {
|
model Notification {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String?
|
userId String
|
||||||
user User? @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
priority NotificationPriority
|
priority NotificationPriority
|
||||||
message String
|
category NotificationCategory
|
||||||
relatedType String?
|
message String
|
||||||
relatedId String?
|
relatedType String?
|
||||||
read Boolean @default(false)
|
relatedId String?
|
||||||
createdAt DateTime @default(now())
|
link String?
|
||||||
|
requiredPermissions String[] @default([])
|
||||||
|
dedupeKey String? @unique
|
||||||
|
read Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@index([userId, read])
|
@@index([userId, read])
|
||||||
@@index([priority])
|
@@index([priority])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Einstellungen je Nutzer; Standard beim ersten Lesen angelegt.
|
||||||
|
model NotificationPreference {
|
||||||
|
userId String @id
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
taskDueEnabled Boolean @default(true)
|
||||||
|
digestEnabled Boolean @default(true)
|
||||||
|
digestTime String @default("06:30")
|
||||||
|
quietFrom String? @default("21:00")
|
||||||
|
quietUntil String? @default("06:30")
|
||||||
|
telegramEnabled Boolean @default(false)
|
||||||
|
telegramCategories NotificationCategory[] @default([TASK_DUE, DIGEST])
|
||||||
|
telegramDetail Boolean @default(true)
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Webhooks (Abschnitt 27) – v1.x Modul 28
|
// Webhooks (Abschnitt 27) – v1.x Modul 28
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1574,6 +1607,7 @@ model CalendarSyncSettings {
|
|||||||
syncFeeding Boolean @default(true)
|
syncFeeding Boolean @default(true)
|
||||||
syncHarvest Boolean @default(true)
|
syncHarvest Boolean @default(true)
|
||||||
syncQueenAssignment Boolean @default(true)
|
syncQueenAssignment Boolean @default(true)
|
||||||
|
syncWithdrawal Boolean @default(true)
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ describe('ApiaryFieldService', () => {
|
|||||||
relatedId: { in: ['c2', 'c10'] },
|
relatedId: { in: ['c2', 'c10'] },
|
||||||
read: false,
|
read: false,
|
||||||
priority: { in: ['WARNUNG', 'KRITISCH'] },
|
priority: { in: ['WARNUNG', 'KRITISCH'] },
|
||||||
OR: [{ userId: 'u1' }, { userId: null }],
|
userId: 'u1',
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ export class ApiaryFieldService {
|
|||||||
relatedId: { in: colonies.map((colony) => colony.id) },
|
relatedId: { in: colonies.map((colony) => colony.id) },
|
||||||
read: false,
|
read: false,
|
||||||
priority: { in: ['WARNUNG', 'KRITISCH'] },
|
priority: { in: ['WARNUNG', 'KRITISCH'] },
|
||||||
OR: [{ userId }, { userId: null }],
|
userId,
|
||||||
},
|
},
|
||||||
_count: { _all: true },
|
_count: { _all: true },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ describe('AutomationService', () => {
|
|||||||
$connect: jest.Mock;
|
$connect: jest.Mock;
|
||||||
};
|
};
|
||||||
let auditLog: { record: jest.Mock };
|
let auditLog: { record: jest.Mock };
|
||||||
let notifications: { record: jest.Mock };
|
let notifications: { recordForPermission: jest.Mock };
|
||||||
let tasks: { createInTx: jest.Mock };
|
let tasks: { createInTx: jest.Mock };
|
||||||
|
|
||||||
const queenlessRule: Rule = {
|
const queenlessRule: Rule = {
|
||||||
@@ -102,7 +102,9 @@ describe('AutomationService', () => {
|
|||||||
$connect: jest.fn(),
|
$connect: jest.fn(),
|
||||||
};
|
};
|
||||||
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||||
notifications = { record: jest.fn().mockResolvedValue({ id: 'n-1' }) };
|
notifications = {
|
||||||
|
recordForPermission: jest.fn().mockResolvedValue([{ id: 'n-1' }]),
|
||||||
|
};
|
||||||
tasks = { createInTx: jest.fn().mockResolvedValue({ id: 'task-1' }) };
|
tasks = { createInTx: jest.fn().mockResolvedValue({ id: 'task-1' }) };
|
||||||
service = new AutomationService(
|
service = new AutomationService(
|
||||||
prisma as unknown as PrismaService,
|
prisma as unknown as PrismaService,
|
||||||
@@ -258,7 +260,9 @@ describe('AutomationService', () => {
|
|||||||
staged.push(client === tx ? 'task' : 'task outside tx');
|
staged.push(client === tx ? 'task' : 'task outside tx');
|
||||||
return Promise.resolve({ id: 'task-1' });
|
return Promise.resolve({ id: 'task-1' });
|
||||||
});
|
});
|
||||||
notifications.record.mockRejectedValue(new Error('notifications down'));
|
notifications.recordForPermission.mockRejectedValue(
|
||||||
|
new Error('notifications down'),
|
||||||
|
);
|
||||||
auditLog.record.mockImplementation((_entry: unknown, client: unknown) => {
|
auditLog.record.mockImplementation((_entry: unknown, client: unknown) => {
|
||||||
staged.push(client === tx ? 'audit' : 'audit outside tx');
|
staged.push(client === tx ? 'audit' : 'audit outside tx');
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
@@ -303,9 +307,11 @@ describe('AutomationService', () => {
|
|||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
expect(notifications.record).toHaveBeenCalledWith(
|
expect(notifications.recordForPermission).toHaveBeenCalledWith(
|
||||||
|
'tasks:read',
|
||||||
{
|
{
|
||||||
priority: 'WARNUNG',
|
priority: 'WARNUNG',
|
||||||
|
category: 'SUGGESTION',
|
||||||
message: 'Kontrolle empfohlen',
|
message: 'Kontrolle empfohlen',
|
||||||
relatedType: 'COLONY',
|
relatedType: 'COLONY',
|
||||||
relatedId: 'colony-1',
|
relatedId: 'colony-1',
|
||||||
@@ -332,7 +338,9 @@ describe('AutomationService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('lets a failed notification write propagate, so the delivery is rolled back and retried', async () => {
|
it('lets a failed notification write propagate, so the delivery is rolled back and retried', async () => {
|
||||||
notifications.record.mockRejectedValue(new Error('notifications down'));
|
notifications.recordForPermission.mockRejectedValue(
|
||||||
|
new Error('notifications down'),
|
||||||
|
);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.evaluateInspectionIn(asTx(), queenlessInspection as never),
|
service.evaluateInspectionIn(asTx(), queenlessInspection as never),
|
||||||
@@ -355,7 +363,7 @@ describe('AutomationService', () => {
|
|||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
expect(tasks.createInTx).not.toHaveBeenCalled();
|
expect(tasks.createInTx).not.toHaveBeenCalled();
|
||||||
expect(notifications.record).not.toHaveBeenCalled();
|
expect(notifications.recordForPermission).not.toHaveBeenCalled();
|
||||||
expect(auditLog.record).not.toHaveBeenCalled();
|
expect(auditLog.record).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -403,7 +411,7 @@ describe('AutomationService', () => {
|
|||||||
await expect(
|
await expect(
|
||||||
service.evaluateInspectionIn(asTx(), queenlessInspection as never),
|
service.evaluateInspectionIn(asTx(), queenlessInspection as never),
|
||||||
).rejects.toThrow('tasks down');
|
).rejects.toThrow('tasks down');
|
||||||
expect(notifications.record).not.toHaveBeenCalled();
|
expect(notifications.recordForPermission).not.toHaveBeenCalled();
|
||||||
expect(auditLog.record).not.toHaveBeenCalled();
|
expect(auditLog.record).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -426,7 +434,7 @@ describe('AutomationService', () => {
|
|||||||
await service.evaluateInspectionIn(asTx(), queenlessInspection as never);
|
await service.evaluateInspectionIn(asTx(), queenlessInspection as never);
|
||||||
|
|
||||||
expect(tasks.createInTx).toHaveBeenCalledTimes(1);
|
expect(tasks.createInTx).toHaveBeenCalledTimes(1);
|
||||||
expect(notifications.record).toHaveBeenCalledTimes(1);
|
expect(notifications.recordForPermission).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not trigger when the queen was seen', async () => {
|
it('does not trigger when the queen was seen', async () => {
|
||||||
|
|||||||
@@ -600,8 +600,16 @@ export class AutomationService {
|
|||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
await this.notifications.record(
|
// Task 7 replaces this path by suggestions.
|
||||||
{ priority: 'WARNUNG', message: title, relatedType, relatedId },
|
await this.notifications.recordForPermission(
|
||||||
|
'tasks:read',
|
||||||
|
{
|
||||||
|
priority: 'WARNUNG',
|
||||||
|
category: 'SUGGESTION',
|
||||||
|
message: title,
|
||||||
|
relatedType,
|
||||||
|
relatedId,
|
||||||
|
},
|
||||||
tx,
|
tx,
|
||||||
);
|
);
|
||||||
await this.auditLog.record(
|
await this.auditLog.record(
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
import type { ColonyType, VarroaMethod } from '../../generated/prisma';
|
||||||
|
import { bandsFor, classify, rateMeasurement } from './varroa-bands';
|
||||||
|
import { DEFAULT_VARROA_THRESHOLD_CONFIG } from './rule-config/varroa-threshold.config';
|
||||||
|
|
||||||
|
const DEFAULTS = DEFAULT_VARROA_THRESHOLD_CONFIG;
|
||||||
|
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
|
||||||
|
|
||||||
|
describe('varroa bands', () => {
|
||||||
|
it.each<
|
||||||
|
[
|
||||||
|
string,
|
||||||
|
ColonyType,
|
||||||
|
VarroaMethod,
|
||||||
|
number,
|
||||||
|
'green' | 'yellow' | 'red' | null,
|
||||||
|
]
|
||||||
|
>([
|
||||||
|
['2026-08-10', 'PRODUCTION_COLONY', 'NATURAL_MITE_FALL', 10.0, 'yellow'],
|
||||||
|
['2026-08-10', 'PRODUCTION_COLONY', 'NATURAL_MITE_FALL', 10.1, 'red'],
|
||||||
|
['2026-08-10', 'PRODUCTION_COLONY', 'NATURAL_MITE_FALL', 4.9, 'green'],
|
||||||
|
['2026-08-10', 'PRODUCTION_COLONY', 'NATURAL_MITE_FALL', 5, 'yellow'],
|
||||||
|
['2026-11-20', 'PRODUCTION_COLONY', 'NATURAL_MITE_FALL', 0.6, 'red'],
|
||||||
|
['2026-11-20', 'PRODUCTION_COLONY', 'NATURAL_MITE_FALL', 0.5, 'green'],
|
||||||
|
['2026-06-10', 'YOUNG_COLONY', 'NATURAL_MITE_FALL', 5.1, 'red'],
|
||||||
|
['2026-06-10', 'YOUNG_COLONY', 'NATURAL_MITE_FALL', 2.5, 'yellow'],
|
||||||
|
['2026-11-20', 'NUCLEUS_COLONY', 'NATURAL_MITE_FALL', 0.3, 'red'],
|
||||||
|
['2026-11-20', 'NUCLEUS_COLONY', 'NATURAL_MITE_FALL', 0.25, 'green'],
|
||||||
|
['2026-07-10', 'PRODUCTION_COLONY', 'POWDERED_SUGAR', 5.1, 'red'],
|
||||||
|
['2026-07-10', 'PRODUCTION_COLONY', 'POWDERED_SUGAR', 1, 'yellow'],
|
||||||
|
['2026-07-10', 'YOUNG_COLONY', 'POWDERED_SUGAR', 4, 'yellow'],
|
||||||
|
['2026-09-10', 'PRODUCTION_COLONY', 'WASHING_METHOD', 1.5, 'green'],
|
||||||
|
['2026-09-10', 'PRODUCTION_COLONY', 'WASHING_METHOD', 2, 'yellow'],
|
||||||
|
['2026-04-10', 'PRODUCTION_COLONY', 'POWDERED_SUGAR', 9, null],
|
||||||
|
['2026-11-10', 'PRODUCTION_COLONY', 'WASHING_METHOD', 9, null],
|
||||||
|
['2026-07-10', 'PRODUCTION_COLONY', 'DRONE_BROOD_CONTROL', 50, null],
|
||||||
|
])('%s %s %s %p -> %p', (day, type, method, value, light) =>
|
||||||
|
expect(
|
||||||
|
classify(value, bandsFor(day, type, DEFAULTS, []).bands[method]),
|
||||||
|
).toBe(light),
|
||||||
|
);
|
||||||
|
|
||||||
|
it('classifies a missing value or band as no light', () => {
|
||||||
|
const band = bandsFor('2026-08-10', null, DEFAULTS, []).bands
|
||||||
|
.NATURAL_MITE_FALL;
|
||||||
|
expect(classify(null, band)).toBeNull();
|
||||||
|
expect(classify(3, undefined)).toBeNull();
|
||||||
|
expect(classify(3, null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('describes the band with its reason, unit and colony kind', () => {
|
||||||
|
const autumn = bandsFor('2026-08-10', 'YOUNG_COLONY', DEFAULTS, []);
|
||||||
|
expect(autumn.day).toBe('2026-08-10');
|
||||||
|
expect(autumn.bands.NATURAL_MITE_FALL).toEqual({
|
||||||
|
method: 'NATURAL_MITE_FALL',
|
||||||
|
unit: 'MITES_PER_DAY',
|
||||||
|
yellowFrom: 2.5,
|
||||||
|
redAbove: 5,
|
||||||
|
reason: 'NATURAL_FALL_SEASON',
|
||||||
|
youngColony: true,
|
||||||
|
});
|
||||||
|
expect(autumn.bands.POWDERED_SUGAR).toMatchObject({
|
||||||
|
unit: 'PERCENT',
|
||||||
|
yellowFrom: 2,
|
||||||
|
redAbove: 5,
|
||||||
|
reason: 'INFESTATION_AUTUMN',
|
||||||
|
});
|
||||||
|
const winter = bandsFor('2026-01-10', 'PRODUCTION_COLONY', DEFAULTS, []);
|
||||||
|
expect(winter.bands.NATURAL_MITE_FALL).toMatchObject({
|
||||||
|
yellowFrom: null,
|
||||||
|
redAbove: 0.5,
|
||||||
|
reason: 'NATURAL_FALL_WINTER',
|
||||||
|
youngColony: false,
|
||||||
|
});
|
||||||
|
expect(winter.bands.POWDERED_SUGAR).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a colony without a type as a full colony', () =>
|
||||||
|
expect(
|
||||||
|
bandsFor('2026-08-10', null, DEFAULTS, []).bands.NATURAL_MITE_FALL,
|
||||||
|
).toMatchObject({ redAbove: 10, youngColony: false }));
|
||||||
|
|
||||||
|
it('names the recheck day while a treatment effect is in force', () => {
|
||||||
|
const treatments = [{ startDay: '2026-07-20', endDay: '2026-08-02' }];
|
||||||
|
const until = (day: string) =>
|
||||||
|
bandsFor(day, null, DEFAULTS, treatments).postTreatmentUntil;
|
||||||
|
expect(until('2026-07-25')).toBe('2026-08-16');
|
||||||
|
expect(until('2026-08-15')).toBe('2026-08-16');
|
||||||
|
expect(until('2026-08-16')).toBeNull();
|
||||||
|
expect(until('2026-07-19')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the latest recheck day of overlapping treatments', () =>
|
||||||
|
expect(
|
||||||
|
bandsFor('2026-08-01', null, DEFAULTS, [
|
||||||
|
{ startDay: '2026-07-01', endDay: '2026-07-20' },
|
||||||
|
{ startDay: '2026-07-25', endDay: '2026-08-10' },
|
||||||
|
]).postTreatmentUntil,
|
||||||
|
).toBe('2026-08-24'));
|
||||||
|
|
||||||
|
describe('month edges', () => {
|
||||||
|
const lightOn = (
|
||||||
|
day: string,
|
||||||
|
method: VarroaMethod,
|
||||||
|
value: number,
|
||||||
|
): string | null =>
|
||||||
|
classify(
|
||||||
|
value,
|
||||||
|
bandsFor(day, 'PRODUCTION_COLONY', DEFAULTS, []).bands[method],
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each<[string, VarroaMethod, number, string | null]>([
|
||||||
|
['2026-02-28', 'NATURAL_MITE_FALL', 3, 'red'],
|
||||||
|
['2026-03-01', 'NATURAL_MITE_FALL', 3, 'green'],
|
||||||
|
['2026-10-31', 'NATURAL_MITE_FALL', 3, 'green'],
|
||||||
|
['2026-11-01', 'NATURAL_MITE_FALL', 3, 'red'],
|
||||||
|
['2026-04-30', 'POWDERED_SUGAR', 3, null],
|
||||||
|
['2026-05-01', 'POWDERED_SUGAR', 3, 'yellow'],
|
||||||
|
['2026-07-31', 'POWDERED_SUGAR', 1.5, 'yellow'],
|
||||||
|
['2026-08-01', 'POWDERED_SUGAR', 1.5, 'green'],
|
||||||
|
['2026-10-31', 'POWDERED_SUGAR', 3, 'yellow'],
|
||||||
|
['2026-11-01', 'POWDERED_SUGAR', 3, null],
|
||||||
|
['2026-04-30', 'WASHING_METHOD', 3, null],
|
||||||
|
['2026-05-01', 'WASHING_METHOD', 3, 'yellow'],
|
||||||
|
['2026-07-31', 'WASHING_METHOD', 1.5, 'yellow'],
|
||||||
|
['2026-08-01', 'WASHING_METHOD', 1.5, 'green'],
|
||||||
|
['2026-10-31', 'WASHING_METHOD', 3, 'yellow'],
|
||||||
|
['2026-11-01', 'WASHING_METHOD', 3, null],
|
||||||
|
])('%s %s %p -> %p', (day, method, value, light) =>
|
||||||
|
expect(lightOn(day, method, value)).toBe(light),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the young factor off the percent bands', () => {
|
||||||
|
const { bands } = bandsFor('2026-07-10', 'YOUNG_COLONY', DEFAULTS, []);
|
||||||
|
expect(bands.POWDERED_SUGAR).toMatchObject({
|
||||||
|
youngColony: false,
|
||||||
|
yellowFrom: 1,
|
||||||
|
redAbove: 5,
|
||||||
|
});
|
||||||
|
expect(bands.NATURAL_MITE_FALL?.youngColony).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('treatment effect edges', () => {
|
||||||
|
const treatments = [{ startDay: '2026-07-20', endDay: '2026-08-02' }];
|
||||||
|
const ratingOn = (day: string, method: VarroaMethod) =>
|
||||||
|
rateMeasurement(
|
||||||
|
{ date: d(day), method, calculatedValue: 12 },
|
||||||
|
null,
|
||||||
|
DEFAULTS,
|
||||||
|
treatments,
|
||||||
|
);
|
||||||
|
|
||||||
|
it('starts on the start day and ends the day before the recheck day', () => {
|
||||||
|
expect(ratingOn('2026-07-19', 'NATURAL_MITE_FALL').light).toBe('red');
|
||||||
|
expect(ratingOn('2026-07-20', 'NATURAL_MITE_FALL').light).toBe(
|
||||||
|
'POST_TREATMENT',
|
||||||
|
);
|
||||||
|
expect(ratingOn('2026-08-15', 'NATURAL_MITE_FALL').light).toBe(
|
||||||
|
'POST_TREATMENT',
|
||||||
|
);
|
||||||
|
expect(ratingOn('2026-08-16', 'NATURAL_MITE_FALL').light).toBe('red');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('covers a single-day treatment', () => {
|
||||||
|
const single = [{ startDay: '2026-08-01', endDay: '2026-08-01' }];
|
||||||
|
const until = (day: string) =>
|
||||||
|
bandsFor(day, null, DEFAULTS, single).postTreatmentUntil;
|
||||||
|
expect(until('2026-08-01')).toBe('2026-08-15');
|
||||||
|
expect(until('2026-08-14')).toBe('2026-08-15');
|
||||||
|
expect(until('2026-08-15')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies to the percent methods and never to the drone brood check', () => {
|
||||||
|
expect(ratingOn('2026-07-25', 'POWDERED_SUGAR').light).toBe(
|
||||||
|
'POST_TREATMENT',
|
||||||
|
);
|
||||||
|
expect(ratingOn('2026-08-05', 'WASHING_METHOD').light).toBe(
|
||||||
|
'POST_TREATMENT',
|
||||||
|
);
|
||||||
|
expect(ratingOn('2026-07-25', 'DRONE_BROOD_CONTROL').light).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rates by the Berlin calendar day of the measurement', () => {
|
||||||
|
// 2026-10-31T23:30Z is 2026-11-01 00:30 in Berlin: winter band.
|
||||||
|
const rating = rateMeasurement(
|
||||||
|
{
|
||||||
|
date: new Date('2026-11-01T00:30:00+01:00'),
|
||||||
|
method: 'NATURAL_MITE_FALL',
|
||||||
|
calculatedValue: 3,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
DEFAULTS,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
expect(rating.reasonCode).toBe('NATURAL_FALL_WINTER');
|
||||||
|
expect(rating.light).toBe('red');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rateMeasurement', () => {
|
||||||
|
it('a measurement 5 days after treatment end has no light and names the recheck day', () =>
|
||||||
|
expect(
|
||||||
|
rateMeasurement(
|
||||||
|
{
|
||||||
|
date: d('2026-08-07'),
|
||||||
|
method: 'NATURAL_MITE_FALL',
|
||||||
|
calculatedValue: 12,
|
||||||
|
},
|
||||||
|
'PRODUCTION_COLONY',
|
||||||
|
DEFAULTS,
|
||||||
|
[{ startDay: '2026-07-20', endDay: '2026-08-02' }],
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
light: 'POST_TREATMENT',
|
||||||
|
reasonCode: 'POST_TREATMENT',
|
||||||
|
band: null,
|
||||||
|
postTreatmentUntil: '2026-08-16',
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('rates outside a treatment effect with the band and reason', () => {
|
||||||
|
const rating = rateMeasurement(
|
||||||
|
{
|
||||||
|
date: d('2026-08-20'),
|
||||||
|
method: 'NATURAL_MITE_FALL',
|
||||||
|
calculatedValue: 12,
|
||||||
|
},
|
||||||
|
'PRODUCTION_COLONY',
|
||||||
|
DEFAULTS,
|
||||||
|
[{ startDay: '2026-07-20', endDay: '2026-08-02' }],
|
||||||
|
);
|
||||||
|
expect(rating).toMatchObject({
|
||||||
|
light: 'red',
|
||||||
|
reasonCode: 'NATURAL_FALL_SEASON',
|
||||||
|
postTreatmentUntil: null,
|
||||||
|
});
|
||||||
|
expect(rating.band).toMatchObject({ redAbove: 10 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has no light for a method without a band or a missing value', () => {
|
||||||
|
expect(
|
||||||
|
rateMeasurement(
|
||||||
|
{
|
||||||
|
date: d('2026-04-10'),
|
||||||
|
method: 'POWDERED_SUGAR',
|
||||||
|
calculatedValue: 9,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
DEFAULTS,
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
light: null,
|
||||||
|
reasonCode: null,
|
||||||
|
band: null,
|
||||||
|
postTreatmentUntil: null,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
rateMeasurement(
|
||||||
|
{
|
||||||
|
date: d('2026-08-10'),
|
||||||
|
method: 'NATURAL_MITE_FALL',
|
||||||
|
calculatedValue: null,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
DEFAULTS,
|
||||||
|
[],
|
||||||
|
).light,
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import type {
|
||||||
|
ColonyType,
|
||||||
|
VarroaMeasurement,
|
||||||
|
VarroaMethod,
|
||||||
|
} from '../../generated/prisma';
|
||||||
|
import { addDays, berlinDayOf } from '../common/dates';
|
||||||
|
import type { TreatmentWindowLite } from '../treatments/withdrawal-window';
|
||||||
|
import type { VarroaThresholdConfig } from './rule-config/rule-config.types';
|
||||||
|
|
||||||
|
export type VarroaLightKey = 'green' | 'yellow' | 'red';
|
||||||
|
|
||||||
|
export type VarroaBandReason =
|
||||||
|
| 'NATURAL_FALL_SEASON'
|
||||||
|
| 'NATURAL_FALL_WINTER'
|
||||||
|
| 'INFESTATION_SUMMER'
|
||||||
|
| 'INFESTATION_AUTUMN';
|
||||||
|
|
||||||
|
export interface VarroaBand {
|
||||||
|
method: VarroaMethod;
|
||||||
|
unit: 'MITES_PER_DAY' | 'PERCENT';
|
||||||
|
/** null: no yellow stage (winter). */
|
||||||
|
yellowFrom: number | null;
|
||||||
|
redAbove: number;
|
||||||
|
reason: VarroaBandReason;
|
||||||
|
youngColony: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VarroaBands {
|
||||||
|
day: string;
|
||||||
|
bands: Partial<Record<VarroaMethod, VarroaBand>>;
|
||||||
|
/** Recheck day while a treatment effect is in force, otherwise null. */
|
||||||
|
postTreatmentUntil: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VarroaRating {
|
||||||
|
light: VarroaLightKey | 'POST_TREATMENT' | null;
|
||||||
|
reasonCode: VarroaBandReason | 'POST_TREATMENT' | null;
|
||||||
|
band: VarroaBand | null;
|
||||||
|
postTreatmentUntil: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PERCENT_METHODS: readonly VarroaMethod[] = [
|
||||||
|
'POWDERED_SUGAR',
|
||||||
|
'WASHING_METHOD',
|
||||||
|
];
|
||||||
|
|
||||||
|
function naturalFallBand(
|
||||||
|
month: number,
|
||||||
|
factor: number,
|
||||||
|
young: boolean,
|
||||||
|
config: VarroaThresholdConfig['naturalFall'],
|
||||||
|
): VarroaBand {
|
||||||
|
const inSeason = config.seasonMonths.includes(month);
|
||||||
|
return {
|
||||||
|
method: 'NATURAL_MITE_FALL',
|
||||||
|
unit: 'MITES_PER_DAY',
|
||||||
|
yellowFrom: inSeason ? config.yellowFrom * factor : null,
|
||||||
|
redAbove: (inSeason ? config.redAbove : config.winterRedAbove) * factor,
|
||||||
|
reason: inSeason ? 'NATURAL_FALL_SEASON' : 'NATURAL_FALL_WINTER',
|
||||||
|
youngColony: young,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The percentage band of the month, absent outside summer and autumn. */
|
||||||
|
function percentBand(
|
||||||
|
method: VarroaMethod,
|
||||||
|
month: number,
|
||||||
|
config: VarroaThresholdConfig['infestationPercent'],
|
||||||
|
): VarroaBand | undefined {
|
||||||
|
if (config.summerMonths.includes(month)) {
|
||||||
|
return {
|
||||||
|
method,
|
||||||
|
unit: 'PERCENT',
|
||||||
|
...config.summer,
|
||||||
|
reason: 'INFESTATION_SUMMER',
|
||||||
|
youngColony: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (config.autumnMonths.includes(month)) {
|
||||||
|
return {
|
||||||
|
method,
|
||||||
|
unit: 'PERCENT',
|
||||||
|
...config.autumn,
|
||||||
|
reason: 'INFESTATION_AUTUMN',
|
||||||
|
youngColony: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Latest recheck day of the treatments whose effect covers `day`. */
|
||||||
|
function postTreatmentUntil(
|
||||||
|
day: string,
|
||||||
|
recheckAfterDays: number,
|
||||||
|
treatments: readonly TreatmentWindowLite[],
|
||||||
|
): string | null {
|
||||||
|
let until: string | null = null;
|
||||||
|
for (const { startDay, endDay } of treatments) {
|
||||||
|
const recheckDay = addDays(endDay, recheckAfterDays);
|
||||||
|
const covers = startDay <= day && day < recheckDay;
|
||||||
|
if (covers && (until === null || recheckDay > until)) {
|
||||||
|
until = recheckDay;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return until;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bands in force on `day` for a colony of `colonyType`: natural mite fall
|
||||||
|
* by season (young colonies at the reduced factor), the percentage methods in
|
||||||
|
* summer and autumn only, no band for the drone brood check.
|
||||||
|
*/
|
||||||
|
export function bandsFor(
|
||||||
|
day: string,
|
||||||
|
colonyType: ColonyType | null,
|
||||||
|
config: VarroaThresholdConfig,
|
||||||
|
treatments: readonly TreatmentWindowLite[],
|
||||||
|
): VarroaBands {
|
||||||
|
const month = Number(day.slice(5, 7));
|
||||||
|
const young =
|
||||||
|
colonyType !== null && config.youngColonyTypes.includes(colonyType);
|
||||||
|
const factor = young ? config.youngColonyFactor : 1;
|
||||||
|
const bands: VarroaBands['bands'] = {
|
||||||
|
NATURAL_MITE_FALL: naturalFallBand(
|
||||||
|
month,
|
||||||
|
factor,
|
||||||
|
young,
|
||||||
|
config.naturalFall,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
for (const method of PERCENT_METHODS) {
|
||||||
|
const band = percentBand(method, month, config.infestationPercent);
|
||||||
|
if (band) {
|
||||||
|
bands[method] = band;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
day,
|
||||||
|
bands,
|
||||||
|
postTreatmentUntil: postTreatmentUntil(
|
||||||
|
day,
|
||||||
|
config.recheckAfterDays,
|
||||||
|
treatments,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Red above redAbove, yellow from yellowFrom, otherwise green. */
|
||||||
|
export function classify(
|
||||||
|
value: number | null,
|
||||||
|
band: VarroaBand | null | undefined,
|
||||||
|
): VarroaLightKey | null {
|
||||||
|
if (value === null || !band) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (value > band.redAbove) {
|
||||||
|
return 'red';
|
||||||
|
}
|
||||||
|
if (band.yellowFrom !== null && value >= band.yellowFrom) {
|
||||||
|
return 'yellow';
|
||||||
|
}
|
||||||
|
return 'green';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The light of one measurement. A measurement taken from the start of a
|
||||||
|
* treatment until the recheck day has no light (treatment effect), except the
|
||||||
|
* drone brood check, which never has one.
|
||||||
|
*/
|
||||||
|
export function rateMeasurement(
|
||||||
|
m: Pick<VarroaMeasurement, 'date' | 'method' | 'calculatedValue'>,
|
||||||
|
colonyType: ColonyType | null,
|
||||||
|
config: VarroaThresholdConfig,
|
||||||
|
treatments: readonly TreatmentWindowLite[],
|
||||||
|
): VarroaRating {
|
||||||
|
const day = berlinDayOf(m.date);
|
||||||
|
const { bands, postTreatmentUntil: recheckDay } = bandsFor(
|
||||||
|
day,
|
||||||
|
colonyType,
|
||||||
|
config,
|
||||||
|
treatments,
|
||||||
|
);
|
||||||
|
const band = bands[m.method] ?? null;
|
||||||
|
if (recheckDay !== null && m.method !== 'DRONE_BROOD_CONTROL') {
|
||||||
|
return {
|
||||||
|
light: 'POST_TREATMENT',
|
||||||
|
reasonCode: 'POST_TREATMENT',
|
||||||
|
band: null,
|
||||||
|
postTreatmentUntil: recheckDay,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
light: classify(m.calculatedValue, band),
|
||||||
|
reasonCode: band?.reason ?? null,
|
||||||
|
band,
|
||||||
|
postTreatmentUntil: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -72,6 +72,7 @@ const ALL_ENTITY_KEYS = [
|
|||||||
...BUSINESS_ENTITY_KEYS,
|
...BUSINESS_ENTITY_KEYS,
|
||||||
'document',
|
'document',
|
||||||
'notification',
|
'notification',
|
||||||
|
'notificationPreference',
|
||||||
'auditLog',
|
'auditLog',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -113,6 +114,10 @@ describe('BackupService', () => {
|
|||||||
upsert: jest.fn().mockResolvedValue({ id: `${key}-upserted` }),
|
upsert: jest.fn().mockResolvedValue({ id: `${key}-upserted` }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
tx.notification = {
|
||||||
|
upsert: jest.fn().mockResolvedValue({ id: 'notification-upserted' }),
|
||||||
|
findUnique: jest.fn().mockResolvedValue(null),
|
||||||
|
} as never;
|
||||||
tx.queen.update = jest.fn().mockResolvedValue({});
|
tx.queen.update = jest.fn().mockResolvedValue({});
|
||||||
tx.queen.findMany = jest.fn().mockResolvedValue([]);
|
tx.queen.findMany = jest.fn().mockResolvedValue([]);
|
||||||
tx.document.findUnique = jest.fn().mockResolvedValue(null);
|
tx.document.findUnique = jest.fn().mockResolvedValue(null);
|
||||||
@@ -1304,6 +1309,148 @@ describe('BackupService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('notifications', () => {
|
||||||
|
const note = (overrides: Row = {}): Row => ({
|
||||||
|
id: 'n1',
|
||||||
|
userId: 'u1',
|
||||||
|
priority: 'INFO',
|
||||||
|
category: 'SUGGESTION',
|
||||||
|
message: 'm',
|
||||||
|
relatedType: 'COLONY',
|
||||||
|
relatedId: 'c1',
|
||||||
|
read: true,
|
||||||
|
createdAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
const without = (row: Row, ...keys: string[]): Row =>
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(row).filter(([key]) => !keys.includes(key)),
|
||||||
|
);
|
||||||
|
const importNotes = (rows: Row[]) =>
|
||||||
|
service.importAll(
|
||||||
|
{
|
||||||
|
version: 3,
|
||||||
|
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
data: { notification: rows },
|
||||||
|
},
|
||||||
|
'admin-1',
|
||||||
|
TRUSTED,
|
||||||
|
);
|
||||||
|
const written = () =>
|
||||||
|
(
|
||||||
|
tx.notification.upsert.mock.calls as [
|
||||||
|
{ where: { id: string }; create: Row },
|
||||||
|
][]
|
||||||
|
).map(([arg]) => arg.create);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tx.user.findMany.mockResolvedValue([{ id: 'u1' }, { id: 'u2' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restore keeps the userId of a notification and skips a row whose user does not exist in the target', async () => {
|
||||||
|
const summary = await importNotes([
|
||||||
|
note({ id: 'n1', userId: 'u2' }),
|
||||||
|
note({ id: 'n2', userId: 'gone' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(written().map((row) => [row.id, row.userId])).toEqual([
|
||||||
|
['n1', 'u2'],
|
||||||
|
]);
|
||||||
|
expect(summary.imported.notification).toBe(1);
|
||||||
|
expect(summary.imported.notificationSkipped).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips and counts a row whose dedupeKey already exists under another id', async () => {
|
||||||
|
(
|
||||||
|
tx.notification as unknown as { findUnique: jest.Mock }
|
||||||
|
).findUnique.mockImplementation(
|
||||||
|
({ where }: { where: { dedupeKey: string } }) =>
|
||||||
|
Promise.resolve(
|
||||||
|
where.dedupeKey === 'k:u1' ? { id: 'other-id' } : null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const summary = await importNotes([
|
||||||
|
note({ id: 'n1', dedupeKey: 'k:u1' }),
|
||||||
|
note({ id: 'n2', dedupeKey: 'k:u2' }),
|
||||||
|
// Same id as the stored row: a plain re-restore, written as before.
|
||||||
|
note({ id: 'other-id', dedupeKey: 'k:u1' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(written().map((row) => row.id)).toEqual(['n2', 'other-id']);
|
||||||
|
expect(summary.imported.notification).toBe(2);
|
||||||
|
expect(summary.imported.notificationSkipped).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a repeated restore of broadcast copies does not reset their read flags', async () => {
|
||||||
|
const { userId: _u, category: _c, ...old } = note({ id: 'b' });
|
||||||
|
void _u;
|
||||||
|
void _c;
|
||||||
|
await importNotes([old]);
|
||||||
|
|
||||||
|
const calls = tx.notification.upsert.mock.calls as [{ update: Row }][];
|
||||||
|
expect(calls).toHaveLength(2);
|
||||||
|
expect(calls.every(([arg]) => Object.keys(arg.update).length === 0)).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never writes a notification without a userId', async () => {
|
||||||
|
await importNotes([
|
||||||
|
note({ userId: null }),
|
||||||
|
note({ id: 'n3', userId: 'gone' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(written().every((row) => typeof row.userId === 'string')).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an old bundle notification without userId is copied per active user: category SYSTEM for relatedType JobRun or none, SUGGESTION otherwise', async () => {
|
||||||
|
const old = (id: string, relatedType: string | null): Row => {
|
||||||
|
return without(note({ id, relatedType }), 'userId', 'category');
|
||||||
|
};
|
||||||
|
|
||||||
|
await importNotes([
|
||||||
|
old('a', 'JobRun'),
|
||||||
|
old('b', null),
|
||||||
|
old('c', 'COLONY'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const rows = written().map((row) => [row.userId, row.category, row.read]);
|
||||||
|
expect(rows).toEqual([
|
||||||
|
['u1', 'SYSTEM', true],
|
||||||
|
['u2', 'SYSTEM', true],
|
||||||
|
['u1', 'SYSTEM', true],
|
||||||
|
['u2', 'SYSTEM', true],
|
||||||
|
['u1', 'SUGGESTION', true],
|
||||||
|
['u2', 'SUGGESTION', true],
|
||||||
|
]);
|
||||||
|
expect(new Set(written().map((row) => row.id)).size).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fills the category of an old row that has a userId but none', async () => {
|
||||||
|
await importNotes([without(note({ relatedType: 'JobRun' }), 'category')]);
|
||||||
|
expect(written()[0]).toMatchObject({ userId: 'u1', category: 'SYSTEM' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exports and restores the notification preferences of the users', async () => {
|
||||||
|
prisma.notificationPreference.findMany.mockResolvedValue([
|
||||||
|
{ userId: 'u1', digestTime: '07:00' },
|
||||||
|
{ userId: 'gone', digestTime: '08:00' },
|
||||||
|
]);
|
||||||
|
const bundle = await service.exportAll();
|
||||||
|
expect(bundle.data.notificationPreference).toHaveLength(2);
|
||||||
|
|
||||||
|
await service.importAll(bundle, 'admin-1', TRUSTED);
|
||||||
|
|
||||||
|
const calls = tx.notificationPreference.upsert.mock.calls as [
|
||||||
|
{ where: { userId: string } },
|
||||||
|
][];
|
||||||
|
expect(calls.map(([arg]) => arg.where.userId)).toEqual(['u1']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Drift guard: a model added to the schema has to be backed up or listed
|
// Drift guard: a model added to the schema has to be backed up or listed
|
||||||
// in MODELS_EXCLUDED_FROM_BACKUP with its reason - never silently missing.
|
// in MODELS_EXCLUDED_FROM_BACKUP with its reason - never silently missing.
|
||||||
describe('model coverage', () => {
|
describe('model coverage', () => {
|
||||||
|
|||||||
@@ -71,7 +71,8 @@ export interface BackupImportSummary {
|
|||||||
* - siteSettings: keeper name/address printed on the Bestandsbuch and the
|
* - siteSettings: keeper name/address printed on the Bestandsbuch and the
|
||||||
* public links.
|
* public links.
|
||||||
* - document: writes files to the server's disk.
|
* - document: writes files to the server's disk.
|
||||||
* - notification: messages shown to users as coming from ApiCore.
|
* - notification, notificationPreference: messages shown to users as coming
|
||||||
|
* from ApiCore, and settings of a user.
|
||||||
*/
|
*/
|
||||||
export const LEGACY_UNSEALED_TABLES = [
|
export const LEGACY_UNSEALED_TABLES = [
|
||||||
'apiary',
|
'apiary',
|
||||||
@@ -188,6 +189,23 @@ function withCurrentFeedVocabulary(row: Row): Row {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bundles written before the notification categories carry none: job failures
|
||||||
|
// (relatedType 'JobRun') and rows without relatedType are system messages,
|
||||||
|
// everything else a suggestion - the rule of migration 11.
|
||||||
|
function withCurrentNotificationCategory(row: Row): Row {
|
||||||
|
if (typeof row.category === 'string') {
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
const relatedType = asString(row, 'relatedType');
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
category:
|
||||||
|
relatedType === null || relatedType === 'JobRun'
|
||||||
|
? 'SYSTEM'
|
||||||
|
: 'SUGGESTION',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function documentsStorageRoot(): string {
|
function documentsStorageRoot(): string {
|
||||||
return process.env.DOCUMENTS_STORAGE_PATH ?? './uploads';
|
return process.env.DOCUMENTS_STORAGE_PATH ?? './uploads';
|
||||||
}
|
}
|
||||||
@@ -316,6 +334,8 @@ export class BackupService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
data.notification = await this.prisma.notification.findMany();
|
data.notification = await this.prisma.notification.findMany();
|
||||||
|
data.notificationPreference =
|
||||||
|
await this.prisma.notificationPreference.findMany();
|
||||||
data.auditLog = await this.prisma.auditLog.findMany();
|
data.auditLog = await this.prisma.auditLog.findMany();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1125,20 +1145,86 @@ export class BackupService {
|
|||||||
}
|
}
|
||||||
imported.document = documentCount;
|
imported.document = documentCount;
|
||||||
|
|
||||||
|
const activeUserIds = (
|
||||||
|
await tx.user.findMany({
|
||||||
|
where: { isActive: true },
|
||||||
|
select: { id: true },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
})
|
||||||
|
).map((user) => user.id);
|
||||||
|
let notificationsWritten = 0;
|
||||||
|
let notificationsSkipped = 0;
|
||||||
for (const row of rowsOf('notification')) {
|
for (const row of rowsOf('notification')) {
|
||||||
await tx.notification.upsert({
|
const withCategory = withCurrentNotificationCategory(row);
|
||||||
where: { id: row.id as string },
|
const rawUserId = asString(row, 'userId');
|
||||||
create: {
|
// A bundle from before the per-user notifications carries
|
||||||
...row,
|
// broadcasts (no userId): one copy per active user, as migration 8.
|
||||||
userId: resolveOptionalUserId(asString(row, 'userId')),
|
// A row of a user this database does not know is skipped - the
|
||||||
} as never,
|
// userId is mandatory and never falls back to somebody else.
|
||||||
update: {
|
const owners =
|
||||||
...row,
|
rawUserId === null
|
||||||
userId: resolveOptionalUserId(asString(row, 'userId')),
|
? activeUserIds
|
||||||
} as never,
|
: knownUserIds.has(rawUserId)
|
||||||
});
|
? [rawUserId]
|
||||||
|
: [];
|
||||||
|
if (owners.length === 0) {
|
||||||
|
notificationsSkipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const userId of owners) {
|
||||||
|
const data = {
|
||||||
|
...withCategory,
|
||||||
|
id:
|
||||||
|
rawUserId === null
|
||||||
|
? `${row.id as string}:${userId}`
|
||||||
|
: (row.id as string),
|
||||||
|
userId,
|
||||||
|
};
|
||||||
|
// The unique dedupeKey of a row that lives here under another id
|
||||||
|
// would abort the whole restore: skip that row instead.
|
||||||
|
const dedupeKey = asString(data, 'dedupeKey');
|
||||||
|
if (dedupeKey !== null) {
|
||||||
|
const holder = await tx.notification.findUnique({
|
||||||
|
where: { dedupeKey },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (holder && holder.id !== data.id) {
|
||||||
|
notificationsSkipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await tx.notification.upsert({
|
||||||
|
where: { id: data.id },
|
||||||
|
create: data as never,
|
||||||
|
// A copy made from a broadcast is only added: a repeated
|
||||||
|
// restore must not reset what the user already read.
|
||||||
|
update: (rawUserId === null ? {} : data) as never,
|
||||||
|
});
|
||||||
|
notificationsWritten += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
imported.notification = rowsOf('notification').length;
|
imported.notification = notificationsWritten;
|
||||||
|
if (notificationsSkipped > 0) {
|
||||||
|
imported.notificationSkipped = notificationsSkipped;
|
||||||
|
this.logger.warn(
|
||||||
|
`${notificationsSkipped} Meldung(en) übersprungen: Nutzer im Zielsystem unbekannt.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let preferencesWritten = 0;
|
||||||
|
for (const row of rowsOf('notificationPreference')) {
|
||||||
|
const userId = asString(row, 'userId');
|
||||||
|
if (userId === null || !knownUserIds.has(userId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await tx.notificationPreference.upsert({
|
||||||
|
where: { userId },
|
||||||
|
create: row as never,
|
||||||
|
update: row as never,
|
||||||
|
});
|
||||||
|
preferencesWritten += 1;
|
||||||
|
}
|
||||||
|
imported.notificationPreference = preferencesWritten;
|
||||||
|
|
||||||
// Append-only: entries already here are never rewritten by a file.
|
// Append-only: entries already here are never rewritten by a file.
|
||||||
for (const row of rowsOf('auditLog')) {
|
for (const row of rowsOf('auditLog')) {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ describe('CalendarSyncSettingsService', () => {
|
|||||||
syncFeeding: true,
|
syncFeeding: true,
|
||||||
syncHarvest: true,
|
syncHarvest: true,
|
||||||
syncQueenAssignment: true,
|
syncQueenAssignment: true,
|
||||||
|
syncWithdrawal: true,
|
||||||
updatedAt: new Date('2026-01-01'),
|
updatedAt: new Date('2026-01-01'),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -87,6 +88,7 @@ describe('CalendarSyncSettingsService', () => {
|
|||||||
syncFeeding: undefined,
|
syncFeeding: undefined,
|
||||||
syncHarvest: undefined,
|
syncHarvest: undefined,
|
||||||
syncQueenAssignment: undefined,
|
syncQueenAssignment: undefined,
|
||||||
|
syncWithdrawal: undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(result.groupId).toBe('group-abc');
|
expect(result.groupId).toBe('group-abc');
|
||||||
@@ -149,8 +151,25 @@ describe('CalendarSyncSettingsService', () => {
|
|||||||
syncFeeding: undefined,
|
syncFeeding: undefined,
|
||||||
syncHarvest: undefined,
|
syncHarvest: undefined,
|
||||||
syncQueenAssignment: undefined,
|
syncQueenAssignment: undefined,
|
||||||
|
syncWithdrawal: undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('passes the withdrawal switch on', async () => {
|
||||||
|
prisma.calendarSyncSettings.findUnique.mockResolvedValue(existing);
|
||||||
|
prisma.calendarSyncSettings.update.mockResolvedValue({
|
||||||
|
...existing,
|
||||||
|
syncWithdrawal: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.update({ syncWithdrawal: false }, 'user-1');
|
||||||
|
|
||||||
|
expect(prisma.calendarSyncSettings.update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({ syncWithdrawal: false }) as unknown,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -47,6 +47,7 @@ export class CalendarSyncSettingsService {
|
|||||||
syncFeeding: dto.syncFeeding,
|
syncFeeding: dto.syncFeeding,
|
||||||
syncHarvest: dto.syncHarvest,
|
syncHarvest: dto.syncHarvest,
|
||||||
syncQueenAssignment: dto.syncQueenAssignment,
|
syncQueenAssignment: dto.syncQueenAssignment,
|
||||||
|
syncWithdrawal: dto.syncWithdrawal,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await this.auditLog.record({
|
await this.auditLog.record({
|
||||||
|
|||||||
@@ -59,6 +59,24 @@ describe('CalendarSyncEventHandlers', () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
it('QueenAssigned enqueues QUEEN_ASSIGNMENT with the history id', async () => {
|
||||||
|
await handlers.queenAssignment(
|
||||||
|
envelope('QueenAssigned', 'queen-1', {
|
||||||
|
colonyId: 'colony-1',
|
||||||
|
historyId: 'history-1',
|
||||||
|
assignedOn: '2026-10-14',
|
||||||
|
previousColonyId: null,
|
||||||
|
}),
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(calendarSync.enqueueIn).toHaveBeenCalledWith(
|
||||||
|
tx,
|
||||||
|
'QUEEN_ASSIGNMENT',
|
||||||
|
'history-1',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('TaskCreated without a due date enqueues nothing', async () => {
|
it('TaskCreated without a due date enqueues nothing', async () => {
|
||||||
await handlers.task(
|
await handlers.task(
|
||||||
envelope('TaskCreated', 'task-1', { dueDate: null }),
|
envelope('TaskCreated', 'task-1', { dueDate: null }),
|
||||||
@@ -105,6 +123,12 @@ describe('CalendarSyncEventHandlers', () => {
|
|||||||
);
|
);
|
||||||
expect(registered('feeding')[0].handlerId).toBe('calendar-sync.feeding');
|
expect(registered('feeding')[0].handlerId).toBe('calendar-sync.feeding');
|
||||||
expect(registered('harvest')[0].handlerId).toBe('calendar-sync.harvest');
|
expect(registered('harvest')[0].handlerId).toBe('calendar-sync.harvest');
|
||||||
|
expect(registered('queenAssignment')).toEqual([
|
||||||
|
{
|
||||||
|
types: ['QueenAssigned'],
|
||||||
|
handlerId: 'calendar-sync.queen-assignment',
|
||||||
|
},
|
||||||
|
]);
|
||||||
expect(registered('task')).toEqual([
|
expect(registered('task')).toEqual([
|
||||||
{
|
{
|
||||||
types: ['TaskCreated', 'TaskUpdated', 'TaskCompleted'],
|
types: ['TaskCreated', 'TaskUpdated', 'TaskCompleted'],
|
||||||
|
|||||||
@@ -74,4 +74,17 @@ export class CalendarSyncEventHandlers {
|
|||||||
}
|
}
|
||||||
await this.calendarSync.enqueueIn(tx, 'TASK', event.aggregateId);
|
await this.calendarSync.enqueueIn(tx, 'TASK', event.aggregateId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The aggregate is the queen; the calendar entry belongs to the history row.
|
||||||
|
@OnDomainEvent(['QueenAssigned'], 'calendar-sync.queen-assignment')
|
||||||
|
async queenAssignment(
|
||||||
|
event: AnyDomainEventEnvelope<'QueenAssigned'>,
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.calendarSync.enqueueIn(
|
||||||
|
tx,
|
||||||
|
'QUEEN_ASSIGNMENT',
|
||||||
|
event.payload.historyId,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -42,6 +42,7 @@ describe('CalendarSyncService', () => {
|
|||||||
syncFeeding: true,
|
syncFeeding: true,
|
||||||
syncHarvest: true,
|
syncHarvest: true,
|
||||||
syncQueenAssignment: true,
|
syncQueenAssignment: true,
|
||||||
|
syncWithdrawal: true,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -122,11 +123,22 @@ describe('CalendarSyncService', () => {
|
|||||||
expect(prisma.calendarSyncOutboxEntry.upsert).not.toHaveBeenCalled();
|
expect(prisma.calendarSyncOutboxEntry.upsert).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not enqueue a WITHDRAWAL entry yet (the switch arrives with Task 3)', async () => {
|
it('enqueues a WITHDRAWAL entry while syncWithdrawal is on', async () => {
|
||||||
settingsService.get.mockResolvedValue(enabledSettings);
|
settingsService.get.mockResolvedValue(enabledSettings);
|
||||||
|
|
||||||
await service.enqueue('WITHDRAWAL', 'treatment-1');
|
await service.enqueue('WITHDRAWAL', 'treatment-1');
|
||||||
|
|
||||||
|
expect(prisma.calendarSyncOutboxEntry.upsert).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not enqueue a WITHDRAWAL entry when syncWithdrawal is off', async () => {
|
||||||
|
settingsService.get.mockResolvedValue({
|
||||||
|
...enabledSettings,
|
||||||
|
syncWithdrawal: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.enqueue('WITHDRAWAL', 'treatment-1');
|
||||||
|
|
||||||
expect(prisma.calendarSyncOutboxEntry.upsert).not.toHaveBeenCalled();
|
expect(prisma.calendarSyncOutboxEntry.upsert).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -103,8 +103,7 @@ export class CalendarSyncService {
|
|||||||
FEEDING: settings.syncFeeding,
|
FEEDING: settings.syncFeeding,
|
||||||
HARVEST: settings.syncHarvest,
|
HARVEST: settings.syncHarvest,
|
||||||
QUEEN_ASSIGNMENT: settings.syncQueenAssignment,
|
QUEEN_ASSIGNMENT: settings.syncQueenAssignment,
|
||||||
// Switch in the settings arrives with Task 3.
|
WITHDRAWAL: settings.syncWithdrawal,
|
||||||
WITHDRAWAL: false,
|
|
||||||
};
|
};
|
||||||
return flagBySourceType[sourceType];
|
return flagBySourceType[sourceType];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,4 +66,9 @@ export class UpdateCalendarSyncSettingsDto {
|
|||||||
@IsOptionalNotNull()
|
@IsOptionalNotNull()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
syncQueenAssignment?: boolean;
|
syncQueenAssignment?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptionalNotNull()
|
||||||
|
@IsBoolean()
|
||||||
|
syncWithdrawal?: boolean;
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
|||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||||
import { DomainEventsService } from '../domain-events/domain-events.service';
|
import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||||
|
import { berlinToday } from '../common/dates';
|
||||||
|
|
||||||
describe('ColoniesService', () => {
|
describe('ColoniesService', () => {
|
||||||
let service: ColoniesService;
|
let service: ColoniesService;
|
||||||
@@ -139,6 +140,96 @@ describe('ColoniesService', () => {
|
|||||||
expect(lineageArg.data.toColonyId).toBe('colony-2');
|
expect(lineageArg.data.toColonyId).toBe('colony-2');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('split records ColonySplit with queenSource and the given date, audit inside the transaction', async () => {
|
||||||
|
prisma.colony.findUnique.mockResolvedValue(parentColony);
|
||||||
|
const child = { id: 'colony-2', code: 'V-001-A', apiaryId: 'apiary-1' };
|
||||||
|
tx.colony.create.mockResolvedValue(child);
|
||||||
|
tx.colonyLineage.create.mockResolvedValue({ id: 'lineage-1' });
|
||||||
|
|
||||||
|
await service.split(
|
||||||
|
'colony-1',
|
||||||
|
{
|
||||||
|
code: 'V-001-A',
|
||||||
|
colonyType: 'NUCLEUS_COLONY',
|
||||||
|
date: '2026-05-05',
|
||||||
|
queenSource: 'QUEEN_CELL',
|
||||||
|
},
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(tx.$queryRaw).toHaveBeenCalledTimes(1);
|
||||||
|
const [[lineageArg]] = tx.colonyLineage.create.mock.calls as [
|
||||||
|
[{ data: { date: Date; queenSource: string } }],
|
||||||
|
];
|
||||||
|
expect(lineageArg.data.date).toEqual(new Date('2026-05-05T00:00:00.000Z'));
|
||||||
|
expect(lineageArg.data.queenSource).toBe('QUEEN_CELL');
|
||||||
|
expect(events.record).toHaveBeenCalledWith(
|
||||||
|
tx,
|
||||||
|
expect.objectContaining({
|
||||||
|
type: 'ColonySplit',
|
||||||
|
aggregateType: 'Colony',
|
||||||
|
aggregateId: 'colony-2',
|
||||||
|
actorUserId: 'user-1',
|
||||||
|
payload: {
|
||||||
|
parentColonyId: 'colony-1',
|
||||||
|
lineageId: 'lineage-1',
|
||||||
|
queenSource: 'QUEEN_CELL',
|
||||||
|
splitOn: '2026-05-05',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(auditLog.record).toHaveBeenCalledWith(expect.anything(), tx);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('split without a date uses today and a null queenSource', async () => {
|
||||||
|
prisma.colony.findUnique.mockResolvedValue(parentColony);
|
||||||
|
tx.colony.create.mockResolvedValue({ id: 'colony-2' });
|
||||||
|
tx.colonyLineage.create.mockResolvedValue({ id: 'lineage-1' });
|
||||||
|
|
||||||
|
await service.split(
|
||||||
|
'colony-1',
|
||||||
|
{ code: 'V-001-A', colonyType: 'NUCLEUS_COLONY' },
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
const [[, event]] = events.record.mock.calls as [
|
||||||
|
[unknown, { payload: { queenSource: unknown; splitOn: string } }],
|
||||||
|
];
|
||||||
|
expect(event.payload.queenSource).toBeNull();
|
||||||
|
expect(event.payload.splitOn).toBe(berlinToday());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('split with a future date answers 400', async () => {
|
||||||
|
prisma.colony.findUnique.mockResolvedValue(parentColony);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.split(
|
||||||
|
'colony-1',
|
||||||
|
{ code: 'V-001-A', colonyType: 'NUCLEUS_COLONY', date: '2999-01-01' },
|
||||||
|
'user-1',
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
status: 400,
|
||||||
|
message: 'Das Datum liegt in der Zukunft.',
|
||||||
|
});
|
||||||
|
expect(tx.colony.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('split of a colony that ended meanwhile (seen after the lock) answers 409', async () => {
|
||||||
|
prisma.colony.findUnique
|
||||||
|
.mockResolvedValueOnce(parentColony)
|
||||||
|
.mockResolvedValueOnce({ ...parentColony, status: 'DISSOLVED' });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.split(
|
||||||
|
'colony-1',
|
||||||
|
{ code: 'V-001-A', colonyType: 'NUCLEUS_COLONY' },
|
||||||
|
'user-1',
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
expect(tx.colony.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('merge marks the merged colony as MERGED, releases its queen, and records a MERGE lineage entry', async () => {
|
it('merge marks the merged colony as MERGED, releases its queen, and records a MERGE lineage entry', async () => {
|
||||||
prisma.colony.findUnique
|
prisma.colony.findUnique
|
||||||
.mockResolvedValueOnce(parentColony)
|
.mockResolvedValueOnce(parentColony)
|
||||||
@@ -502,6 +593,7 @@ describe('ColoniesService', () => {
|
|||||||
it('writes the audit entry of create, move and split inside the transaction', async () => {
|
it('writes the audit entry of create, move and split inside the transaction', async () => {
|
||||||
prisma.colony.findUnique.mockResolvedValue(parentColony);
|
prisma.colony.findUnique.mockResolvedValue(parentColony);
|
||||||
tx.colony.create.mockResolvedValue({ id: 'colony-2' });
|
tx.colony.create.mockResolvedValue({ id: 'colony-2' });
|
||||||
|
tx.colonyLineage.create.mockResolvedValue({ id: 'lineage-1' });
|
||||||
tx.colony.update.mockResolvedValue({
|
tx.colony.update.mockResolvedValue({
|
||||||
...parentColony,
|
...parentColony,
|
||||||
apiaryId: 'apiary-2',
|
apiaryId: 'apiary-2',
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { MergeColonyDto } from './dto/merge-colony.dto';
|
|||||||
import { isLivingColonyStatus } from './colony-status';
|
import { isLivingColonyStatus } from './colony-status';
|
||||||
import { lockRowsForUpdate } from '../prisma/row-lock';
|
import { lockRowsForUpdate } from '../prisma/row-lock';
|
||||||
import { colonyTransitionEvents, lockColony } from './colony-events';
|
import { colonyTransitionEvents, lockColony } from './colony-events';
|
||||||
|
import { berlinToday, dayToDate } from '../common/dates';
|
||||||
|
|
||||||
export interface ColonyFilter {
|
export interface ColonyFilter {
|
||||||
status?: ColonyStatus;
|
status?: ColonyStatus;
|
||||||
@@ -194,9 +195,16 @@ export class ColoniesService {
|
|||||||
dto: SplitColonyDto,
|
dto: SplitColonyDto,
|
||||||
actingUserId: string,
|
actingUserId: string,
|
||||||
): Promise<Colony> {
|
): Promise<Colony> {
|
||||||
const parent = await this.findById(id);
|
await this.findById(id);
|
||||||
this.assertActive(parent);
|
const today = berlinToday();
|
||||||
|
const splitOn = dto.date ?? today;
|
||||||
|
if (splitOn > today) {
|
||||||
|
throw new BadRequestException('Das Datum liegt in der Zukunft.');
|
||||||
|
}
|
||||||
|
const queenSource = dto.queenSource ?? null;
|
||||||
const child = await this.prisma.$transaction(async (tx) => {
|
const child = await this.prisma.$transaction(async (tx) => {
|
||||||
|
const parent = await lockColony(tx, id);
|
||||||
|
this.assertActive(parent);
|
||||||
const created = await tx.colony.create({
|
const created = await tx.colony.create({
|
||||||
data: {
|
data: {
|
||||||
code: dto.code,
|
code: dto.code,
|
||||||
@@ -205,12 +213,13 @@ export class ColoniesService {
|
|||||||
notes: dto.notes,
|
notes: dto.notes,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await tx.colonyLineage.create({
|
const lineage = await tx.colonyLineage.create({
|
||||||
data: {
|
data: {
|
||||||
type: 'SPLIT',
|
type: 'SPLIT',
|
||||||
fromColonyId: parent.id,
|
fromColonyId: parent.id,
|
||||||
toColonyId: created.id,
|
toColonyId: created.id,
|
||||||
date: new Date(),
|
date: dayToDate(splitOn),
|
||||||
|
queenSource,
|
||||||
notes: dto.notes,
|
notes: dto.notes,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -224,6 +233,18 @@ export class ColoniesService {
|
|||||||
},
|
},
|
||||||
tx,
|
tx,
|
||||||
);
|
);
|
||||||
|
await this.events.record(tx, {
|
||||||
|
type: 'ColonySplit',
|
||||||
|
aggregateType: 'Colony',
|
||||||
|
aggregateId: created.id,
|
||||||
|
actorUserId: actingUserId,
|
||||||
|
payload: {
|
||||||
|
parentColonyId: parent.id,
|
||||||
|
lineageId: lineage.id,
|
||||||
|
queenSource,
|
||||||
|
splitOn,
|
||||||
|
},
|
||||||
|
});
|
||||||
return created;
|
return created;
|
||||||
});
|
});
|
||||||
return child;
|
return child;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsEnum, IsOptional, IsString } from 'class-validator';
|
import { IsEnum, IsOptional, IsString, Matches } from 'class-validator';
|
||||||
import { ColonyType } from '../../../generated/prisma';
|
import { ColonyType, SplitQueenSource } from '../../../generated/prisma';
|
||||||
|
|
||||||
export class SplitColonyDto {
|
export class SplitColonyDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@@ -15,4 +15,16 @@ export class SplitColonyDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Day of the split (YYYY-MM-DD), not after today.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Matches(/^\d{4}-\d{2}-\d{2}$/, { message: 'date must be YYYY-MM-DD' })
|
||||||
|
date?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: SplitQueenSource })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(SplitQueenSource)
|
||||||
|
queenSource?: SplitQueenSource;
|
||||||
}
|
}
|
||||||
@@ -43,10 +43,8 @@ describe('legacy after-commit paths', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('only the queen assignment still enqueues a calendar entry directly', () => {
|
it('no module enqueues a calendar entry directly any more', () => {
|
||||||
expect(filesCalling('calendarSync.enqueue(')).toEqual([
|
expect(filesCalling('calendarSync.enqueue(')).toEqual([]);
|
||||||
'src/queens/queens.service.ts',
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('only the modules without an event still dispatch webhooks directly', () => {
|
it('only the modules without an event still dispatch webhooks directly', () => {
|
||||||
|
|||||||
@@ -71,9 +71,13 @@ function orderColumn(column: OutboxOrderColumn): Prisma.Sql {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Claims up to `limit` due PENDING entries, oldest first. SKIP LOCKED lets
|
* Claims up to `limit` due PENDING entries, oldest first. "availableAt" is
|
||||||
* several workers claim side by side without waiting for or overlapping
|
* TIMESTAMP(3), so a value written from now() is rounded to the nearest
|
||||||
* each other. Returns the claimed rows (all columns) in claim order.
|
* millisecond - up to 0.5 ms ahead of the clock. Comparing against now()
|
||||||
|
* rounded the same way keeps such an entry due as soon as it is written;
|
||||||
|
* a plain now() would miss it for the first fraction of a millisecond.
|
||||||
|
* SKIP LOCKED lets several workers claim side by side without waiting for
|
||||||
|
* or overlapping each other. Returns the claimed rows (all columns) in claim order.
|
||||||
* Uses the root client: a claim must commit on its own.
|
* Uses the root client: a claim must commit on its own.
|
||||||
*/
|
*/
|
||||||
export async function claimBatch<Row>(
|
export async function claimBatch<Row>(
|
||||||
@@ -96,7 +100,7 @@ export async function claimBatch<Row>(
|
|||||||
UPDATE ${name} SET "status" = 'PROCESSING', "claimedAt" = now()
|
UPDATE ${name} SET "status" = 'PROCESSING', "claimedAt" = now()
|
||||||
WHERE "id" IN (
|
WHERE "id" IN (
|
||||||
SELECT "id" FROM ${name}
|
SELECT "id" FROM ${name}
|
||||||
WHERE "status" = 'PENDING' AND "availableAt" <= now() ${filter}
|
WHERE "status" = 'PENDING' AND "availableAt" <= CAST(now() AS timestamp(3)) ${filter}
|
||||||
ORDER BY ${orderBy}
|
ORDER BY ${orderBy}
|
||||||
LIMIT ${options.limit}
|
LIMIT ${options.limit}
|
||||||
FOR UPDATE SKIP LOCKED
|
FOR UPDATE SKIP LOCKED
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { UpdateHoneyBatchDto } from '../../honey-batches/dto/update-honey-batch.
|
|||||||
import { UpdateHoneyMeasurementDto } from '../../honey-batches/dto/update-honey-measurement.dto';
|
import { UpdateHoneyMeasurementDto } from '../../honey-batches/dto/update-honey-measurement.dto';
|
||||||
import { UpdateInspectionDto } from '../../inspections/dto/update-inspection.dto';
|
import { UpdateInspectionDto } from '../../inspections/dto/update-inspection.dto';
|
||||||
import { UpdateLabelTemplateDto } from '../../label-templates/dto/update-label-template.dto';
|
import { UpdateLabelTemplateDto } from '../../label-templates/dto/update-label-template.dto';
|
||||||
|
import { UpdateNotificationSettingsDto } from '../../notifications/dto/update-notification-settings.dto';
|
||||||
import { UpdateProductionBatchDto } from '../../production-batches/dto/update-production-batch.dto';
|
import { UpdateProductionBatchDto } from '../../production-batches/dto/update-production-batch.dto';
|
||||||
import { UpdateProductDto } from '../../products/dto/update-product.dto';
|
import { UpdateProductDto } from '../../products/dto/update-product.dto';
|
||||||
import { UpdateProfileDto } from '../../profile/dto/update-profile.dto';
|
import { UpdateProfileDto } from '../../profile/dto/update-profile.dto';
|
||||||
@@ -78,9 +79,23 @@ const contracts: Record<string, NullContract> = {
|
|||||||
'syncFeeding',
|
'syncFeeding',
|
||||||
'syncHarvest',
|
'syncHarvest',
|
||||||
'syncQueenAssignment',
|
'syncQueenAssignment',
|
||||||
|
'syncWithdrawal',
|
||||||
],
|
],
|
||||||
clearable: ['groupId'],
|
clearable: ['groupId'],
|
||||||
},
|
},
|
||||||
|
// quietFrom/quietUntil: null clears them, but only as a pair (the service
|
||||||
|
// judges the merged values).
|
||||||
|
UpdateNotificationSettingsDto: {
|
||||||
|
dto: UpdateNotificationSettingsDto,
|
||||||
|
notNull: [
|
||||||
|
'taskDueEnabled',
|
||||||
|
'digestEnabled',
|
||||||
|
'digestTime',
|
||||||
|
'telegramCategories',
|
||||||
|
'telegramDetail',
|
||||||
|
],
|
||||||
|
clearable: ['quietFrom', 'quietUntil'],
|
||||||
|
},
|
||||||
UpdateBottlingBatchDto: {
|
UpdateBottlingBatchDto: {
|
||||||
dto: UpdateBottlingBatchDto,
|
dto: UpdateBottlingBatchDto,
|
||||||
notNull: [
|
notNull: [
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ describe('DomainEventDispatcher', () => {
|
|||||||
`UPDATE "DomainEvent" SET "status" = 'PROCESSING', "claimedAt" = now()`,
|
`UPDATE "DomainEvent" SET "status" = 'PROCESSING', "claimedAt" = now()`,
|
||||||
);
|
);
|
||||||
expect(text(claim)).toContain(
|
expect(text(claim)).toContain(
|
||||||
`WHERE "status" = 'PENDING' AND "availableAt" <= now()`,
|
`WHERE "status" = 'PENDING' AND "availableAt" <= CAST(now() AS timestamp(3))`,
|
||||||
);
|
);
|
||||||
expect(text(claim)).toContain(
|
expect(text(claim)).toContain(
|
||||||
'ORDER BY "occurredAt" LIMIT ? FOR UPDATE SKIP LOCKED',
|
'ORDER BY "occurredAt" LIMIT ? FOR UPDATE SKIP LOCKED',
|
||||||
@@ -186,7 +186,7 @@ describe('DomainEventDispatcher', () => {
|
|||||||
|
|
||||||
const claim = db.claimQuery();
|
const claim = db.claimQuery();
|
||||||
expect(text(claim)).toContain(
|
expect(text(claim)).toContain(
|
||||||
`"availableAt" <= now() AND "correlationId" = ANY(?) ORDER BY`,
|
`"availableAt" <= CAST(now() AS timestamp(3)) AND "correlationId" = ANY(?) ORDER BY`,
|
||||||
);
|
);
|
||||||
expect(claim.values).toEqual([['c1', 'c2'], 50]);
|
expect(claim.values).toEqual([['c1', 'c2'], 50]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
ColonyStatus,
|
ColonyStatus,
|
||||||
Prisma,
|
Prisma,
|
||||||
RawMaterialKind,
|
RawMaterialKind,
|
||||||
|
SplitQueenSource,
|
||||||
TaskRelatedType,
|
TaskRelatedType,
|
||||||
TaskStatus,
|
TaskStatus,
|
||||||
} from '../../generated/prisma';
|
} from '../../generated/prisma';
|
||||||
@@ -68,6 +69,20 @@ export interface DomainEventPayloads {
|
|||||||
lastInspectedOn: string | null;
|
lastInspectedOn: string | null;
|
||||||
firstOfYear?: boolean;
|
firstOfYear?: boolean;
|
||||||
};
|
};
|
||||||
|
// Aggregate Colony: the new split colony.
|
||||||
|
ColonySplit: {
|
||||||
|
parentColonyId: string;
|
||||||
|
lineageId: string;
|
||||||
|
queenSource: SplitQueenSource | null;
|
||||||
|
splitOn: string;
|
||||||
|
};
|
||||||
|
// Aggregate Queen.
|
||||||
|
QueenAssigned: {
|
||||||
|
colonyId: string;
|
||||||
|
historyId: string;
|
||||||
|
assignedOn: string;
|
||||||
|
previousColonyId: string | null;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DomainEventType = keyof DomainEventPayloads;
|
export type DomainEventType = keyof DomainEventPayloads;
|
||||||
@@ -97,6 +112,8 @@ const CATALOG: Record<DomainEventType, true> = {
|
|||||||
QueenAged: true,
|
QueenAged: true,
|
||||||
VarroaMeasurementDue: true,
|
VarroaMeasurementDue: true,
|
||||||
InspectionOverdue: true,
|
InspectionOverdue: true,
|
||||||
|
ColonySplit: true,
|
||||||
|
QueenAssigned: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DOMAIN_EVENT_TYPES: readonly DomainEventType[] = Object.freeze(
|
export const DOMAIN_EVENT_TYPES: readonly DomainEventType[] = Object.freeze(
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ describe('DomainEventsService', () => {
|
|||||||
|
|
||||||
describe('domain event catalog', () => {
|
describe('domain event catalog', () => {
|
||||||
it('lists every event type exactly once', () => {
|
it('lists every event type exactly once', () => {
|
||||||
expect(DOMAIN_EVENT_TYPES).toHaveLength(23);
|
expect(DOMAIN_EVENT_TYPES).toHaveLength(25);
|
||||||
expect(new Set(DOMAIN_EVENT_TYPES).size).toBe(DOMAIN_EVENT_TYPES.length);
|
expect(new Set(DOMAIN_EVENT_TYPES).size).toBe(DOMAIN_EVENT_TYPES.length);
|
||||||
expect(DOMAIN_EVENT_TYPES).toEqual(
|
expect(DOMAIN_EVENT_TYPES).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
@@ -191,6 +191,8 @@ describe('domain event catalog', () => {
|
|||||||
'StockChanged',
|
'StockChanged',
|
||||||
'TaskDue',
|
'TaskDue',
|
||||||
'InspectionOverdue',
|
'InspectionOverdue',
|
||||||
|
'ColonySplit',
|
||||||
|
'QueenAssigned',
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -174,10 +174,10 @@ describe('daily evaluation rules', () => {
|
|||||||
}),
|
}),
|
||||||
).toBe('2026-10-12'));
|
).toBe('2026-10-12'));
|
||||||
|
|
||||||
it('ends on the treatment day itself without a waiting time and ignores the time of day', () =>
|
it('ends on the treatment day itself without a waiting time and uses the Berlin day, ignoring the time of day', () =>
|
||||||
expect(
|
expect(
|
||||||
withdrawalEndDay({
|
withdrawalEndDay({
|
||||||
date: new Date('2026-09-28T22:15:00.000Z'),
|
date: new Date('2026-09-28T22:15:00+02:00'),
|
||||||
endDate: null,
|
endDate: null,
|
||||||
withdrawalPeriodDays: 0,
|
withdrawalPeriodDays: 0,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { inspectionDueOn } from '../season/inspection-due';
|
|||||||
import { isQueenAged } from '../season/queen-age';
|
import { isQueenAged } from '../season/queen-age';
|
||||||
import type { SeasonService } from '../season/season.service';
|
import type { SeasonService } from '../season/season.service';
|
||||||
import { varroaMeasurementDue } from '../season/varroa-due';
|
import { varroaMeasurementDue } from '../season/varroa-due';
|
||||||
|
import { withdrawalUntilDay } from '../treatments/withdrawal-window';
|
||||||
|
|
||||||
/** How many days back a run still records an occurrence it missed. */
|
/** How many days back a run still records an occurrence it missed. */
|
||||||
export const CATCH_UP_DAYS = 7;
|
export const CATCH_UP_DAYS = 7;
|
||||||
@@ -82,10 +83,7 @@ function dayAfter(date: Date, days: number): Date {
|
|||||||
export function withdrawalEndDay(
|
export function withdrawalEndDay(
|
||||||
t: Pick<Treatment, 'date' | 'endDate' | 'withdrawalPeriodDays'>,
|
t: Pick<Treatment, 'date' | 'endDate' | 'withdrawalPeriodDays'>,
|
||||||
): string | null {
|
): string | null {
|
||||||
if (t.withdrawalPeriodDays === null) {
|
return withdrawalUntilDay(t);
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return utcDay(dayAfter(t.endDate ?? t.date, t.withdrawalPeriodDays));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True for a 'YYYY-MM-DD' day from today − CATCH_UP_DAYS up to today. */
|
/** True for a 'YYYY-MM-DD' day from today − CATCH_UP_DAYS up to today. */
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
ArrayMaxSize,
|
||||||
|
ArrayUnique,
|
||||||
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
|
IsEnum,
|
||||||
|
Matches,
|
||||||
|
ValidateIf,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { NotificationCategory } from '../../../generated/prisma';
|
||||||
|
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
|
||||||
|
|
||||||
|
const TIME_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||||
|
const TIME_MESSAGE = 'Bitte eine Uhrzeit im Format HH:MM angeben.';
|
||||||
|
const MAX_TELEGRAM_CATEGORIES = Object.keys(NotificationCategory).length;
|
||||||
|
|
||||||
|
// PATCH null rule: an omitted field stays unchanged; the switches, the digest
|
||||||
|
// time and the category list are NOT NULL columns, so null on them is a 400.
|
||||||
|
// quietFrom/quietUntil are nullable: null clears them - only as a pair (the
|
||||||
|
// service judges the merged values). telegramEnabled is not changeable here:
|
||||||
|
// connecting and disconnecting set it.
|
||||||
|
export class UpdateNotificationSettingsDto {
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptionalNotNull()
|
||||||
|
@IsBoolean()
|
||||||
|
taskDueEnabled?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptionalNotNull()
|
||||||
|
@IsBoolean()
|
||||||
|
digestEnabled?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'HH:MM, 05:00 bis 12:00 (Europe/Berlin)',
|
||||||
|
})
|
||||||
|
@IsOptionalNotNull()
|
||||||
|
@Matches(TIME_PATTERN, { message: TIME_MESSAGE })
|
||||||
|
digestTime?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: String, nullable: true })
|
||||||
|
@ValidateIf((_object, value) => value !== undefined && value !== null)
|
||||||
|
@Matches(TIME_PATTERN, { message: TIME_MESSAGE })
|
||||||
|
quietFrom?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: String, nullable: true })
|
||||||
|
@ValidateIf((_object, value) => value !== undefined && value !== null)
|
||||||
|
@Matches(TIME_PATTERN, { message: TIME_MESSAGE })
|
||||||
|
quietUntil?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: NotificationCategory, isArray: true })
|
||||||
|
@IsOptionalNotNull()
|
||||||
|
@IsArray()
|
||||||
|
@ArrayUnique()
|
||||||
|
@ArrayMaxSize(MAX_TELEGRAM_CATEGORIES)
|
||||||
|
@IsEnum(NotificationCategory, { each: true })
|
||||||
|
telegramCategories?: NotificationCategory[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptionalNotNull()
|
||||||
|
@IsBoolean()
|
||||||
|
telegramDetail?: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { readdirSync, readFileSync } from 'node:fs';
|
||||||
|
import { join, relative, sep } from 'node:path';
|
||||||
|
|
||||||
|
// Spec 14.3: a Notification row always has a recipient. Rows are written only
|
||||||
|
// by NotificationsService (fan-out, permission check, dedupe key) and by the
|
||||||
|
// backup restore (which resolves the userId itself).
|
||||||
|
const SRC = join(__dirname, '..');
|
||||||
|
const WRITE_SOURCE =
|
||||||
|
'\\bnotification\\.(create|createMany|createManyAndReturn|upsert)\\(';
|
||||||
|
const RAW_INSERT_SOURCE = 'INSERT\\s+INTO\\s+"?Notification"?(?![A-Za-z])';
|
||||||
|
const ALLOWED = [
|
||||||
|
'backup/backup.service.ts',
|
||||||
|
'notifications/notifications.service.ts',
|
||||||
|
];
|
||||||
|
|
||||||
|
function sources(dir: string): string[] {
|
||||||
|
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
||||||
|
const path = join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
return sources(path);
|
||||||
|
}
|
||||||
|
return entry.name.endsWith('.ts') && !entry.name.endsWith('.spec.ts')
|
||||||
|
? [path]
|
||||||
|
: [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const rel = (file: string) => relative(SRC, file).split(sep).join('/');
|
||||||
|
|
||||||
|
function filesMatching(source: string): string[] {
|
||||||
|
return sources(SRC)
|
||||||
|
.filter((file) => new RegExp(source, 'i').test(readFileSync(file, 'utf8')))
|
||||||
|
.map(rel)
|
||||||
|
.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Index of the parenthesis that closes the one at `open`. */
|
||||||
|
function closingParen(text: string, open: number): number {
|
||||||
|
let depth = 0;
|
||||||
|
for (let i = open; i < text.length; i += 1) {
|
||||||
|
if (text[i] === '(') depth += 1;
|
||||||
|
if (text[i] === ')') {
|
||||||
|
depth -= 1;
|
||||||
|
if (depth === 0) return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('unbalanced parentheses');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('no broadcast notifications', () => {
|
||||||
|
it('Notification rows are written only by NotificationsService and the backup restore', () =>
|
||||||
|
expect(filesMatching(WRITE_SOURCE)).toEqual(ALLOWED));
|
||||||
|
|
||||||
|
it('no raw INSERT INTO "Notification" in src', () =>
|
||||||
|
expect(filesMatching(RAW_INSERT_SOURCE)).toEqual([]));
|
||||||
|
|
||||||
|
it('every Notification write in those two files carries a userId (spec 14.3)', () => {
|
||||||
|
for (const file of ALLOWED) {
|
||||||
|
const text = readFileSync(join(SRC, file), 'utf8');
|
||||||
|
const hits = [...text.matchAll(new RegExp(WRITE_SOURCE, 'g'))];
|
||||||
|
expect(hits.length).toBeGreaterThan(0);
|
||||||
|
for (const hit of hits) {
|
||||||
|
const open = hit.index + hit[0].length - 1;
|
||||||
|
const call = text.slice(open, closingParen(text, open) + 1);
|
||||||
|
if (call.includes('userId')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// The data object is built above the call: its definition must set userId.
|
||||||
|
const before = text.slice(Math.max(0, hit.index - 3000), hit.index);
|
||||||
|
const definition = [...before.matchAll(/const data = \{[\s\S]*?\};/g)];
|
||||||
|
expect(call).toMatch(/create: data\b/);
|
||||||
|
expect(definition.at(-1)?.[0]).toMatch(/\buserId\b/);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* A notification link is a relative app path: it starts with "/", not with
|
||||||
|
* "//" or "/\", carries no scheme and is at most 300 characters long. Anything
|
||||||
|
* else is a programming error, never user input (the code that records the
|
||||||
|
* notification builds the link).
|
||||||
|
*/
|
||||||
|
const APP_LINK_PATTERN = /^\/(?![/\\])[A-Za-z0-9\-._~/?=&%]{0,299}$/;
|
||||||
|
|
||||||
|
export function assertAppLink(link: string): void {
|
||||||
|
if (!APP_LINK_PATTERN.test(link)) {
|
||||||
|
throw new Error(`Notification link is not a relative app path: ${link}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One key per recipient, so a fan-out repeated for the same event writes nothing new. */
|
||||||
|
export function notificationDedupeKey(prefix: string, userId: string): string {
|
||||||
|
return `${prefix}:${userId}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { NotificationPreferencesService } from './notification-preferences.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||||
|
|
||||||
|
jest.mock('../prisma/row-lock', () => ({
|
||||||
|
lockRowForUpdate: jest.fn().mockResolvedValue(true),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const stored = {
|
||||||
|
userId: 'u1',
|
||||||
|
taskDueEnabled: true,
|
||||||
|
digestEnabled: true,
|
||||||
|
digestTime: '06:30',
|
||||||
|
quietFrom: '21:00' as string | null,
|
||||||
|
quietUntil: '06:30' as string | null,
|
||||||
|
telegramEnabled: false,
|
||||||
|
telegramCategories: ['TASK_DUE', 'DIGEST'],
|
||||||
|
telegramDetail: true,
|
||||||
|
updatedAt: new Date(0),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('NotificationPreferencesService', () => {
|
||||||
|
let service: NotificationPreferencesService;
|
||||||
|
let tx: {
|
||||||
|
notificationPreference: {
|
||||||
|
upsert: jest.Mock;
|
||||||
|
findUniqueOrThrow: jest.Mock;
|
||||||
|
update: jest.Mock;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
let prisma: {
|
||||||
|
notificationPreference: { upsert: jest.Mock };
|
||||||
|
$transaction: jest.Mock;
|
||||||
|
};
|
||||||
|
const order: string[] = [];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
order.length = 0;
|
||||||
|
(lockRowForUpdate as jest.Mock).mockImplementation(() => {
|
||||||
|
order.push('lock');
|
||||||
|
return Promise.resolve(true);
|
||||||
|
});
|
||||||
|
tx = {
|
||||||
|
notificationPreference: {
|
||||||
|
upsert: jest.fn().mockImplementation(() => {
|
||||||
|
order.push('upsert');
|
||||||
|
return Promise.resolve(stored);
|
||||||
|
}),
|
||||||
|
findUniqueOrThrow: jest.fn().mockImplementation(() => {
|
||||||
|
order.push('read');
|
||||||
|
return Promise.resolve(stored);
|
||||||
|
}),
|
||||||
|
update: jest
|
||||||
|
.fn()
|
||||||
|
.mockImplementation(({ data }: { data: object }) =>
|
||||||
|
Promise.resolve({ ...stored, ...data }),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
prisma = {
|
||||||
|
notificationPreference: {
|
||||||
|
upsert: jest.fn().mockResolvedValue(stored),
|
||||||
|
},
|
||||||
|
$transaction: jest
|
||||||
|
.fn()
|
||||||
|
.mockImplementation((work: (t: unknown) => Promise<unknown>) =>
|
||||||
|
work(tx),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
service = new NotificationPreferencesService(
|
||||||
|
prisma as unknown as PrismaService,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates the defaults on first read', async () => {
|
||||||
|
await expect(service.getOrCreate('u1')).resolves.toBe(stored);
|
||||||
|
expect(prisma.notificationPreference.upsert).toHaveBeenCalledWith({
|
||||||
|
where: { userId: 'u1' },
|
||||||
|
create: { userId: 'u1' },
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the given client for the first read', async () => {
|
||||||
|
await service.getOrCreate('u1', tx as never);
|
||||||
|
expect(tx.notificationPreference.upsert).toHaveBeenCalledTimes(1);
|
||||||
|
expect(prisma.notificationPreference.upsert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['04:30', '12:01', '00:00', '23:59'])(
|
||||||
|
'rejects %s as digest time with the German message',
|
||||||
|
async (digestTime) => {
|
||||||
|
const failure = service.update('u1', { digestTime });
|
||||||
|
await expect(failure).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
await expect(failure).rejects.toThrow(
|
||||||
|
'Bitte eine Uhrzeit zwischen 05:00 und 12:00 wählen.',
|
||||||
|
);
|
||||||
|
expect(tx.notificationPreference.update).not.toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(['05:00', '12:00', '09:15'])(
|
||||||
|
'accepts %s as digest time',
|
||||||
|
async (digestTime) => {
|
||||||
|
await service.update('u1', { digestTime });
|
||||||
|
expect(tx.notificationPreference.update).toHaveBeenCalledWith({
|
||||||
|
where: { userId: 'u1' },
|
||||||
|
data: expect.objectContaining({ digestTime }) as unknown,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('clears quiet hours only as a pair', async () => {
|
||||||
|
const failure = service.update('u1', { quietFrom: null });
|
||||||
|
await expect(failure).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
await expect(failure).rejects.toThrow(
|
||||||
|
'Bitte Beginn und Ende der Ruhezeit zusammen leeren.',
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.update('u1', { quietFrom: null, quietUntil: null });
|
||||||
|
expect(tx.notificationPreference.update).toHaveBeenCalledWith({
|
||||||
|
where: { userId: 'u1' },
|
||||||
|
data: expect.objectContaining({
|
||||||
|
quietFrom: null,
|
||||||
|
quietUntil: null,
|
||||||
|
}) as unknown,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('judges the pair rule on the merged values, not on the dto alone', async () => {
|
||||||
|
tx.notificationPreference.findUniqueOrThrow.mockResolvedValue({
|
||||||
|
...stored,
|
||||||
|
quietFrom: null,
|
||||||
|
quietUntil: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.update('u1', { quietFrom: '22:00' }),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
await expect(
|
||||||
|
service.update('u1', { quietFrom: '22:00', quietUntil: '05:30' }),
|
||||||
|
).resolves.toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update locks the preference row by userId and merges with the stored values read inside the transaction', async () => {
|
||||||
|
tx.notificationPreference.findUniqueOrThrow.mockImplementation(() => {
|
||||||
|
order.push('read');
|
||||||
|
return Promise.resolve({ ...stored, digestTime: '07:00' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.update('u1', { telegramDetail: false });
|
||||||
|
|
||||||
|
expect(order).toEqual(['upsert', 'lock', 'read']);
|
||||||
|
expect(lockRowForUpdate).toHaveBeenCalledWith(
|
||||||
|
tx,
|
||||||
|
'NotificationPreference',
|
||||||
|
'u1',
|
||||||
|
);
|
||||||
|
expect(tx.notificationPreference.update).toHaveBeenCalledWith({
|
||||||
|
where: { userId: 'u1' },
|
||||||
|
data: {
|
||||||
|
taskDueEnabled: true,
|
||||||
|
digestEnabled: true,
|
||||||
|
digestTime: '07:00',
|
||||||
|
quietFrom: '21:00',
|
||||||
|
quietUntil: '06:30',
|
||||||
|
telegramCategories: ['TASK_DUE', 'DIGEST'],
|
||||||
|
telegramDetail: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result.telegramDetail).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never writes telegramEnabled', async () => {
|
||||||
|
await service.update('u1', { telegramCategories: ['SYSTEM'] });
|
||||||
|
const [[arg]] = tx.notificationPreference.update.mock.calls as [
|
||||||
|
[{ data: Record<string, unknown> }],
|
||||||
|
];
|
||||||
|
expect(arg.data).not.toHaveProperty('telegramEnabled');
|
||||||
|
expect(arg.data.telegramCategories).toEqual(['SYSTEM']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
NotificationCategory,
|
||||||
|
NotificationPreference,
|
||||||
|
} from '../../generated/prisma';
|
||||||
|
import { DbClient } from '../prisma/db-client';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||||
|
import { UpdateNotificationSettingsDto } from './dto/update-notification-settings.dto';
|
||||||
|
|
||||||
|
/** The digest time the owner may choose, Europe/Berlin (inclusive). */
|
||||||
|
const DIGEST_TIME_MIN = '05:00';
|
||||||
|
const DIGEST_TIME_MAX = '12:00';
|
||||||
|
const DIGEST_TIME_MESSAGE =
|
||||||
|
'Bitte eine Uhrzeit zwischen 05:00 und 12:00 wählen.';
|
||||||
|
const QUIET_PAIR_MESSAGE =
|
||||||
|
'Bitte Beginn und Ende der Ruhezeit zusammen leeren.';
|
||||||
|
|
||||||
|
export interface NotificationSettingsView {
|
||||||
|
taskDueEnabled: boolean;
|
||||||
|
digestEnabled: boolean;
|
||||||
|
digestTime: string;
|
||||||
|
quietFrom: string | null;
|
||||||
|
quietUntil: string | null;
|
||||||
|
telegramCategories: NotificationCategory[];
|
||||||
|
telegramDetail: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toNotificationSettingsView(
|
||||||
|
preference: NotificationPreference,
|
||||||
|
): NotificationSettingsView {
|
||||||
|
return {
|
||||||
|
taskDueEnabled: preference.taskDueEnabled,
|
||||||
|
digestEnabled: preference.digestEnabled,
|
||||||
|
digestTime: preference.digestTime,
|
||||||
|
quietFrom: preference.quietFrom,
|
||||||
|
quietUntil: preference.quietUntil,
|
||||||
|
telegramCategories: preference.telegramCategories,
|
||||||
|
telegramDetail: preference.telegramDetail,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HH:MM strings of one day compare correctly as text. */
|
||||||
|
function assertDigestTime(digestTime: string): void {
|
||||||
|
if (digestTime < DIGEST_TIME_MIN || digestTime > DIGEST_TIME_MAX) {
|
||||||
|
throw new BadRequestException(DIGEST_TIME_MESSAGE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-user notification settings; a missing row means the defaults. */
|
||||||
|
@Injectable()
|
||||||
|
export class NotificationPreferencesService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
getOrCreate(
|
||||||
|
userId: string,
|
||||||
|
db: DbClient = this.prisma,
|
||||||
|
): Promise<NotificationPreference> {
|
||||||
|
return db.notificationPreference.upsert({
|
||||||
|
where: { userId },
|
||||||
|
create: { userId },
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
userId: string,
|
||||||
|
dto: UpdateNotificationSettingsDto,
|
||||||
|
): Promise<NotificationPreference> {
|
||||||
|
if (dto.digestTime !== undefined) {
|
||||||
|
assertDigestTime(dto.digestTime);
|
||||||
|
}
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
await this.getOrCreate(userId, tx);
|
||||||
|
await lockRowForUpdate(tx, 'NotificationPreference', userId);
|
||||||
|
const stored = await tx.notificationPreference.findUniqueOrThrow({
|
||||||
|
where: { userId },
|
||||||
|
});
|
||||||
|
const quietFrom =
|
||||||
|
dto.quietFrom !== undefined ? dto.quietFrom : stored.quietFrom;
|
||||||
|
const quietUntil =
|
||||||
|
dto.quietUntil !== undefined ? dto.quietUntil : stored.quietUntil;
|
||||||
|
if ((quietFrom === null) !== (quietUntil === null)) {
|
||||||
|
throw new BadRequestException(QUIET_PAIR_MESSAGE);
|
||||||
|
}
|
||||||
|
return tx.notificationPreference.update({
|
||||||
|
where: { userId },
|
||||||
|
data: {
|
||||||
|
taskDueEnabled: dto.taskDueEnabled ?? stored.taskDueEnabled,
|
||||||
|
digestEnabled: dto.digestEnabled ?? stored.digestEnabled,
|
||||||
|
digestTime: dto.digestTime ?? stored.digestTime,
|
||||||
|
quietFrom,
|
||||||
|
quietUntil,
|
||||||
|
telegramCategories:
|
||||||
|
dto.telegramCategories ?? stored.telegramCategories,
|
||||||
|
telegramDetail: dto.telegramDetail ?? stored.telegramDetail,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Body, Controller, Get, Patch } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { AnyAuthenticatedUser } from '../auth/decorators/any-authenticated-user.decorator';
|
||||||
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
|
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||||
|
import { UpdateNotificationSettingsDto } from './dto/update-notification-settings.dto';
|
||||||
|
import {
|
||||||
|
NotificationPreferencesService,
|
||||||
|
NotificationSettingsView,
|
||||||
|
toNotificationSettingsView,
|
||||||
|
} from './notification-preferences.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The caller's own notification settings: self-scoped like ProfileController
|
||||||
|
* (no permission applies), @AnyAuthenticatedUser keeps API keys out.
|
||||||
|
*/
|
||||||
|
@ApiTags('profile')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@AnyAuthenticatedUser()
|
||||||
|
@Controller('profile/notification-settings')
|
||||||
|
export class NotificationSettingsController {
|
||||||
|
constructor(private readonly preferences: NotificationPreferencesService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async get(
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
|
): Promise<NotificationSettingsView> {
|
||||||
|
return toNotificationSettingsView(
|
||||||
|
await this.preferences.getOrCreate(actor.id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch()
|
||||||
|
async update(
|
||||||
|
@Body() dto: UpdateNotificationSettingsDto,
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
|
): Promise<NotificationSettingsView> {
|
||||||
|
return toNotificationSettingsView(
|
||||||
|
await this.preferences.update(actor.id, dto),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,16 +13,21 @@ export class NotificationsController {
|
|||||||
constructor(private readonly notificationsService: NotificationsService) {}
|
constructor(private readonly notificationsService: NotificationsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
findAll(
|
findOwn(
|
||||||
@CurrentUser() actor: AuthenticatedUser,
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
@Query('unreadOnly') unreadOnly?: string,
|
@Query('unreadOnly') unreadOnly?: string,
|
||||||
) {
|
) {
|
||||||
return this.notificationsService.findAll({
|
return this.notificationsService.findOwn(actor.id, {
|
||||||
userId: actor.id,
|
|
||||||
unreadOnly: unreadOnly === 'true',
|
unreadOnly: unreadOnly === 'true',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Declared before ':id/read' so the fixed path is matched first.
|
||||||
|
@Post('read-all')
|
||||||
|
markAllRead(@CurrentUser() actor: AuthenticatedUser) {
|
||||||
|
return this.notificationsService.markAllRead(actor.id);
|
||||||
|
}
|
||||||
|
|
||||||
@Post(':id/read')
|
@Post(':id/read')
|
||||||
markRead(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
|
markRead(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
|
||||||
return this.notificationsService.markRead(id, actor.id);
|
return this.notificationsService.markRead(id, actor.id);
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { NotificationPreferencesService } from './notification-preferences.service';
|
||||||
|
import { NotificationSettingsController } from './notification-settings.controller';
|
||||||
import { NotificationsController } from './notifications.controller';
|
import { NotificationsController } from './notifications.controller';
|
||||||
import { NotificationsService } from './notifications.service';
|
import { NotificationsService } from './notifications.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [NotificationsController],
|
controllers: [NotificationsController, NotificationSettingsController],
|
||||||
providers: [NotificationsService],
|
providers: [NotificationsService, NotificationPreferencesService],
|
||||||
exports: [NotificationsService],
|
exports: [NotificationsService, NotificationPreferencesService],
|
||||||
})
|
})
|
||||||
export class NotificationsModule {}
|
export class NotificationsModule {}
|
||||||
@@ -1,18 +1,30 @@
|
|||||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
import { NotFoundException } from '@nestjs/common';
|
||||||
import { NotificationsService } from './notifications.service';
|
import { NotificationsService } from './notifications.service';
|
||||||
|
import { assertAppLink } from './notification-link';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||||
|
|
||||||
type CreateManyArg = { data: { userId: string; message: string }[] };
|
type CreateManyArg = {
|
||||||
|
data: {
|
||||||
|
userId: string;
|
||||||
|
message: string;
|
||||||
|
dedupeKey: string | null;
|
||||||
|
requiredPermissions: string[];
|
||||||
|
category: string;
|
||||||
|
link: string | null;
|
||||||
|
}[];
|
||||||
|
skipDuplicates: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
describe('NotificationsService', () => {
|
describe('NotificationsService', () => {
|
||||||
let service: NotificationsService;
|
let service: NotificationsService;
|
||||||
|
let held: Set<string>;
|
||||||
let prisma: {
|
let prisma: {
|
||||||
notification: {
|
notification: {
|
||||||
create: jest.Mock;
|
createManyAndReturn: jest.Mock;
|
||||||
createMany: jest.Mock;
|
|
||||||
findMany: jest.Mock;
|
findMany: jest.Mock;
|
||||||
findUnique: jest.Mock;
|
findFirstOrThrow: jest.Mock;
|
||||||
update: jest.Mock;
|
updateMany: jest.Mock;
|
||||||
};
|
};
|
||||||
user: { findMany: jest.Mock };
|
user: { findMany: jest.Mock };
|
||||||
};
|
};
|
||||||
@@ -20,177 +32,375 @@ describe('NotificationsService', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
prisma = {
|
prisma = {
|
||||||
notification: {
|
notification: {
|
||||||
create: jest.fn(),
|
createManyAndReturn: jest
|
||||||
createMany: jest
|
|
||||||
.fn()
|
.fn()
|
||||||
.mockImplementation(({ data }: CreateManyArg) =>
|
.mockImplementation(({ data }: CreateManyArg) =>
|
||||||
Promise.resolve({ count: data.length }),
|
Promise.resolve(data.map((row, i) => ({ id: `n${i}`, ...row }))),
|
||||||
),
|
),
|
||||||
findMany: jest.fn(),
|
findMany: jest.fn().mockResolvedValue([]),
|
||||||
findUnique: jest.fn(),
|
findFirstOrThrow: jest.fn(),
|
||||||
update: jest.fn(),
|
updateMany: jest.fn(),
|
||||||
},
|
},
|
||||||
user: { findMany: jest.fn().mockResolvedValue([]) },
|
user: { findMany: jest.fn().mockResolvedValue([]) },
|
||||||
};
|
};
|
||||||
service = new NotificationsService(prisma as unknown as PrismaService);
|
held = new Set();
|
||||||
});
|
service = new NotificationsService(
|
||||||
|
prisma as unknown as PrismaService,
|
||||||
it('records a notification with the given priority and message', async () => {
|
{
|
||||||
const created = {
|
getPermissions: jest
|
||||||
id: 'n1',
|
.fn()
|
||||||
priority: 'KRITISCH',
|
.mockImplementation(() => Promise.resolve(held)),
|
||||||
message: 'Keine Königin gesehen',
|
} as unknown as PermissionResolverService,
|
||||||
};
|
|
||||||
prisma.notification.create.mockResolvedValueOnce(created);
|
|
||||||
|
|
||||||
const result = await service.record({
|
|
||||||
priority: 'KRITISCH',
|
|
||||||
message: 'Keine Königin gesehen',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual(created);
|
|
||||||
expect(prisma.notification.create).toHaveBeenCalledWith({
|
|
||||||
data: { priority: 'KRITISCH', message: 'Keine Königin gesehen' },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('records through the transaction client when one is given', async () => {
|
|
||||||
const tx = { notification: { create: jest.fn() } };
|
|
||||||
|
|
||||||
await service.record(
|
|
||||||
{ priority: 'HINWEIS', message: 'Aufgabe fällig' },
|
|
||||||
tx as never,
|
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
expect(tx.notification.create).toHaveBeenCalledTimes(1);
|
const lastCreate = () =>
|
||||||
expect(prisma.notification.create).not.toHaveBeenCalled();
|
(
|
||||||
|
prisma.notification.createManyAndReturn.mock.calls[0] as [CreateManyArg]
|
||||||
|
)[0];
|
||||||
|
|
||||||
|
it('record without userId does not compile', () => {
|
||||||
|
// @ts-expect-error userId fehlt
|
||||||
|
void service.record({ priority: 'INFO', category: 'SYSTEM', message: 'm' });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('record', () => {
|
||||||
|
it('writes one row for an eligible recipient and returns it', async () => {
|
||||||
|
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }]);
|
||||||
|
|
||||||
|
const result = await service.record({
|
||||||
|
userId: 'u1',
|
||||||
|
priority: 'KRITISCH',
|
||||||
|
category: 'SYSTEM',
|
||||||
|
message: 'Keine Königin gesehen',
|
||||||
|
dedupeKey: 'k:u1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ userId: 'u1', dedupeKey: 'k:u1' });
|
||||||
|
expect(lastCreate().skipDuplicates).toBe(true);
|
||||||
|
expect(lastCreate().data).toEqual([
|
||||||
|
{
|
||||||
|
userId: 'u1',
|
||||||
|
priority: 'KRITISCH',
|
||||||
|
category: 'SYSTEM',
|
||||||
|
message: 'Keine Königin gesehen',
|
||||||
|
relatedType: null,
|
||||||
|
relatedId: null,
|
||||||
|
link: null,
|
||||||
|
requiredPermissions: [],
|
||||||
|
dedupeKey: 'k:u1',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for a recipient without the permission', async () => {
|
||||||
|
await expect(
|
||||||
|
service.record({
|
||||||
|
userId: 'u1',
|
||||||
|
priority: 'INFO',
|
||||||
|
category: 'SYSTEM',
|
||||||
|
message: 'm',
|
||||||
|
requiredPermissions: ['colonies:read'],
|
||||||
|
}),
|
||||||
|
).resolves.toBeNull();
|
||||||
|
expect(prisma.notification.createManyAndReturn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for a repeated dedupe key', async () => {
|
||||||
|
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }]);
|
||||||
|
prisma.notification.createManyAndReturn.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.record({
|
||||||
|
userId: 'u1',
|
||||||
|
priority: 'INFO',
|
||||||
|
category: 'SYSTEM',
|
||||||
|
message: 'm',
|
||||||
|
dedupeKey: 'k:u1',
|
||||||
|
}),
|
||||||
|
).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes through the transaction client when one is given', async () => {
|
||||||
|
const tx = {
|
||||||
|
user: { findMany: jest.fn().mockResolvedValue([{ id: 'u1' }]) },
|
||||||
|
notification: {
|
||||||
|
createManyAndReturn: jest.fn().mockResolvedValue([{ id: 'n' }]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await service.record(
|
||||||
|
{ userId: 'u1', priority: 'INFO', category: 'SYSTEM', message: 'm' },
|
||||||
|
tx as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(tx.notification.createManyAndReturn).toHaveBeenCalledTimes(1);
|
||||||
|
expect(prisma.notification.createManyAndReturn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an unsafe link before writing', async () => {
|
||||||
|
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }]);
|
||||||
|
await expect(
|
||||||
|
service.record({
|
||||||
|
userId: 'u1',
|
||||||
|
priority: 'INFO',
|
||||||
|
category: 'SYSTEM',
|
||||||
|
message: 'm',
|
||||||
|
link: 'https://evil.example',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow();
|
||||||
|
expect(prisma.notification.createManyAndReturn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('recordForPermission', () => {
|
describe('recordForPermission', () => {
|
||||||
it('notifies every active user whose active role holds the permission', async () => {
|
const input = {
|
||||||
|
priority: 'WARNUNG' as const,
|
||||||
|
category: 'SYSTEM' as const,
|
||||||
|
message: 'Job „App-Backup“ fehlgeschlagen',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('writes one row per active user holding every permission, with a key per recipient', async () => {
|
||||||
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }, { id: 'u2' }]);
|
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }, { id: 'u2' }]);
|
||||||
|
|
||||||
await expect(
|
const rows = await service.recordForPermission('backup:configure', {
|
||||||
service.recordForPermission('backup:configure', {
|
...input,
|
||||||
priority: 'WARNUNG',
|
dedupeKeyPrefix: 'p',
|
||||||
message: 'Job „App-Backup“ fehlgeschlagen',
|
|
||||||
}),
|
|
||||||
).resolves.toBe(2);
|
|
||||||
|
|
||||||
const [[createArg]] = prisma.notification.createMany.mock.calls as [
|
|
||||||
[CreateManyArg],
|
|
||||||
];
|
|
||||||
expect(createArg.data.map((n) => n.userId)).toEqual(['u1', 'u2']);
|
|
||||||
expect(createArg.data[0]).toEqual({
|
|
||||||
userId: 'u1',
|
|
||||||
priority: 'WARNUNG',
|
|
||||||
message: 'Job „App-Backup“ fehlgeschlagen',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(rows).toHaveLength(2);
|
||||||
|
expect(lastCreate().data.map((r) => [r.userId, r.dedupeKey])).toEqual([
|
||||||
|
['u1', 'p:u1'],
|
||||||
|
['u2', 'p:u2'],
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('selects recipients like the permission guard: active user, active role, granted key', async () => {
|
it('selects recipients like the permission guard: active user, active role, every key granted', async () => {
|
||||||
await service.recordForPermission('backup:configure', {
|
await service.recordForPermission(
|
||||||
priority: 'WARNUNG',
|
['backup:configure', 'colonies:read'],
|
||||||
message: 'x',
|
input,
|
||||||
});
|
);
|
||||||
|
|
||||||
expect(prisma.user.findMany).toHaveBeenCalledWith({
|
expect(prisma.user.findMany).toHaveBeenCalledWith({
|
||||||
where: {
|
where: {
|
||||||
isActive: true,
|
isActive: true,
|
||||||
role: {
|
role: {
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
permissions: {
|
AND: [
|
||||||
some: { permission: { key: 'backup:configure' } },
|
{
|
||||||
},
|
permissions: {
|
||||||
|
some: { permission: { key: 'backup:configure' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
permissions: { some: { permission: { key: 'colonies:read' } } },
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('writes nothing and returns 0 when nobody holds the permission', async () => {
|
it('stores the union of recipient and content permissions', async () => {
|
||||||
|
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }]);
|
||||||
|
|
||||||
|
await service.recordForPermission('colonies:read', {
|
||||||
|
...input,
|
||||||
|
requiredPermissions: ['inspections:read', 'colonies:read'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(lastCreate().data[0].requiredPermissions).toEqual([
|
||||||
|
'colonies:read',
|
||||||
|
'inspections:read',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws for an empty permission list instead of broadcasting', async () => {
|
||||||
|
await expect(service.recordForPermission([], input)).rejects.toThrow();
|
||||||
|
expect(prisma.user.findMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes nothing when nobody holds the permission', async () => {
|
||||||
|
await expect(
|
||||||
|
service.recordForPermission('backup:configure', input),
|
||||||
|
).resolves.toEqual([]);
|
||||||
|
expect(prisma.notification.createManyAndReturn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a repeated dedupe key writes nothing', async () => {
|
||||||
|
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }]);
|
||||||
|
prisma.notification.createManyAndReturn.mockResolvedValue([]);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.recordForPermission('backup:configure', {
|
service.recordForPermission('backup:configure', {
|
||||||
priority: 'WARNUNG',
|
...input,
|
||||||
message: 'x',
|
dedupeKeyPrefix: 'p',
|
||||||
}),
|
}),
|
||||||
).resolves.toBe(0);
|
).resolves.toEqual([]);
|
||||||
|
|
||||||
expect(prisma.notification.createMany).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reads and writes through the transaction client when one is given', async () => {
|
it('reads and writes through the transaction client when one is given', async () => {
|
||||||
const tx = {
|
const tx = {
|
||||||
user: { findMany: jest.fn().mockResolvedValue([{ id: 'u1' }]) },
|
user: { findMany: jest.fn().mockResolvedValue([{ id: 'u1' }]) },
|
||||||
notification: {
|
notification: {
|
||||||
createMany: jest.fn().mockResolvedValue({ count: 1 }),
|
createManyAndReturn: jest.fn().mockResolvedValue([{ id: 'n1' }]),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.recordForPermission(
|
service.recordForPermission('backup:configure', input, tx as never),
|
||||||
'backup:configure',
|
).resolves.toHaveLength(1);
|
||||||
{ priority: 'WARNUNG', message: 'x' },
|
|
||||||
tx as never,
|
|
||||||
),
|
|
||||||
).resolves.toBe(1);
|
|
||||||
|
|
||||||
expect(tx.notification.createMany).toHaveBeenCalledTimes(1);
|
|
||||||
expect(prisma.user.findMany).not.toHaveBeenCalled();
|
expect(prisma.user.findMany).not.toHaveBeenCalled();
|
||||||
expect(prisma.notification.createMany).not.toHaveBeenCalled();
|
expect(prisma.notification.createManyAndReturn).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('lists notifications for a user including broadcast notifications', async () => {
|
describe('recordForUsers', () => {
|
||||||
await service.findAll({ userId: 'user-1' });
|
it('skips users missing a required permission', async () => {
|
||||||
|
prisma.user.findMany.mockResolvedValue([{ id: 'u2' }]);
|
||||||
|
|
||||||
const [[queryArg]] = prisma.notification.findMany.mock.calls as [
|
const rows = await service.recordForUsers(['u1', 'u2'], {
|
||||||
[{ where: { OR?: { userId: string | null }[] } }],
|
priority: 'INFO',
|
||||||
];
|
category: 'SUGGESTION',
|
||||||
expect(queryArg.where.OR).toEqual([{ userId: 'user-1' }, { userId: null }]);
|
message: 'm',
|
||||||
});
|
requiredPermissions: ['colonies:read'],
|
||||||
|
});
|
||||||
|
|
||||||
it('throws NotFoundException when marking an unknown notification as read', async () => {
|
expect(prisma.user.findMany).toHaveBeenCalledWith(
|
||||||
prisma.notification.findUnique.mockResolvedValueOnce(null);
|
expect.objectContaining({
|
||||||
await expect(service.markRead('missing', 'user-1')).rejects.toBeInstanceOf(
|
where: expect.objectContaining({
|
||||||
NotFoundException,
|
id: { in: ['u1', 'u2'] },
|
||||||
);
|
}) as unknown,
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
|
expect(rows.map((r) => r.userId)).toEqual(['u2']);
|
||||||
|
});
|
||||||
|
|
||||||
it("throws ForbiddenException when marking another user's notification as read", async () => {
|
it('does nothing for an empty user list', async () => {
|
||||||
prisma.notification.findUnique.mockResolvedValueOnce({
|
await expect(
|
||||||
id: 'n1',
|
service.recordForUsers([], {
|
||||||
userId: 'owner',
|
priority: 'INFO',
|
||||||
read: false,
|
category: 'SYSTEM',
|
||||||
|
message: 'm',
|
||||||
|
}),
|
||||||
|
).resolves.toEqual([]);
|
||||||
|
expect(prisma.user.findMany).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
await expect(service.markRead('n1', 'someone-else')).rejects.toBeInstanceOf(
|
|
||||||
ForbiddenException,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('marks a broadcast notification (no owner) as read for any user', async () => {
|
describe('reading', () => {
|
||||||
prisma.notification.findUnique.mockResolvedValueOnce({
|
it('lists only the own notifications, newest first, at most 100', async () => {
|
||||||
id: 'n1',
|
await service.findOwn('user-1', { unreadOnly: true });
|
||||||
userId: null,
|
|
||||||
read: false,
|
expect(prisma.notification.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: { userId: 'user-1', read: false },
|
||||||
|
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||||
|
take: 500,
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
prisma.notification.update.mockResolvedValueOnce({ id: 'n1', read: true });
|
|
||||||
|
|
||||||
const result = await service.markRead('n1', 'any-user');
|
it('hides a row whose permissions the user no longer holds (revoked after the write)', async () => {
|
||||||
|
const row = (id: string, requiredPermissions: string[]) => ({
|
||||||
|
id,
|
||||||
|
priority: 'INFO',
|
||||||
|
category: 'SYSTEM',
|
||||||
|
message: 'm',
|
||||||
|
relatedType: null,
|
||||||
|
relatedId: null,
|
||||||
|
link: null,
|
||||||
|
read: false,
|
||||||
|
createdAt: new Date(0),
|
||||||
|
requiredPermissions,
|
||||||
|
});
|
||||||
|
prisma.notification.findMany.mockResolvedValue([
|
||||||
|
row('open', []),
|
||||||
|
row('revoked', ['colonies:read', 'varroa:read']),
|
||||||
|
row('kept', ['colonies:read']),
|
||||||
|
]);
|
||||||
|
held.add('colonies:read');
|
||||||
|
|
||||||
expect(result.read).toBe(true);
|
const views = await service.findOwn('user-1');
|
||||||
});
|
|
||||||
|
|
||||||
it("marks the owner's own notification as read", async () => {
|
expect(views.map((view) => view.id)).toEqual(['open', 'kept']);
|
||||||
prisma.notification.findUnique.mockResolvedValueOnce({
|
expect(views[0]).not.toHaveProperty('requiredPermissions');
|
||||||
id: 'n1',
|
|
||||||
userId: 'user-1',
|
|
||||||
read: false,
|
|
||||||
});
|
});
|
||||||
prisma.notification.update.mockResolvedValueOnce({ id: 'n1', read: true });
|
|
||||||
|
|
||||||
const result = await service.markRead('n1', 'user-1');
|
it('selects the view fields plus the permission list for filtering, never the dedupe key', async () => {
|
||||||
|
await service.findOwn('user-1');
|
||||||
|
|
||||||
expect(result.read).toBe(true);
|
const [[arg]] = prisma.notification.findMany.mock.calls as [
|
||||||
|
[{ select: Record<string, true> }],
|
||||||
|
];
|
||||||
|
expect(Object.keys(arg.select).sort()).toEqual([
|
||||||
|
'category',
|
||||||
|
'createdAt',
|
||||||
|
'id',
|
||||||
|
'link',
|
||||||
|
'message',
|
||||||
|
'priority',
|
||||||
|
'read',
|
||||||
|
'relatedId',
|
||||||
|
'relatedType',
|
||||||
|
'requiredPermissions',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers 404 when the notification is foreign or unknown', async () => {
|
||||||
|
prisma.notification.updateMany.mockResolvedValue({ count: 0 });
|
||||||
|
await expect(service.markRead('n1', 'user-1')).rejects.toBeInstanceOf(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
|
expect(prisma.notification.updateMany).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'n1', userId: 'user-1' },
|
||||||
|
data: { read: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks the own notification as read', async () => {
|
||||||
|
prisma.notification.updateMany.mockResolvedValue({ count: 1 });
|
||||||
|
prisma.notification.findFirstOrThrow.mockResolvedValue({
|
||||||
|
id: 'n1',
|
||||||
|
read: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.markRead('n1', 'user-1')).resolves.toMatchObject({
|
||||||
|
read: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks only the own unread notifications as read', async () => {
|
||||||
|
prisma.notification.updateMany.mockResolvedValue({ count: 3 });
|
||||||
|
|
||||||
|
await expect(service.markAllRead('user-1')).resolves.toEqual({
|
||||||
|
updated: 3,
|
||||||
|
});
|
||||||
|
expect(prisma.notification.updateMany).toHaveBeenCalledWith({
|
||||||
|
where: { userId: 'user-1', read: false },
|
||||||
|
data: { read: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('assertAppLink', () => {
|
||||||
|
it('accepts a relative app path', () => {
|
||||||
|
expect(() => assertAppLink('/tasks?status=OPEN&x=1')).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'//evil.example',
|
||||||
|
'https://evil.example',
|
||||||
|
'/\\evil',
|
||||||
|
'tasks',
|
||||||
|
'/a b',
|
||||||
|
])('rejects %s', (link) => expect(() => assertAppLink(link)).toThrow());
|
||||||
|
|
||||||
|
it('rejects a link over 300 characters', () => {
|
||||||
|
expect(() => assertAppLink(`/${'a'.repeat(300)}`)).toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,98 +1,269 @@
|
|||||||
import {
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
ForbiddenException,
|
|
||||||
Injectable,
|
|
||||||
NotFoundException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { DbClient } from '../prisma/db-client';
|
import { DbClient } from '../prisma/db-client';
|
||||||
import { PermissionKey } from '../auth/permissions/permission-catalog';
|
import { PermissionKey } from '../auth/permissions/permission-catalog';
|
||||||
import {
|
import {
|
||||||
Notification,
|
Notification,
|
||||||
|
NotificationCategory,
|
||||||
NotificationPriority,
|
NotificationPriority,
|
||||||
Prisma,
|
Prisma,
|
||||||
} from '../../generated/prisma';
|
} from '../../generated/prisma';
|
||||||
|
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||||
|
import { assertAppLink, notificationDedupeKey } from './notification-link';
|
||||||
|
|
||||||
export interface CreateNotificationInput {
|
export interface CreateNotificationInput {
|
||||||
userId?: string;
|
userId: string;
|
||||||
priority: NotificationPriority;
|
priority: NotificationPriority;
|
||||||
|
category: NotificationCategory;
|
||||||
message: string;
|
message: string;
|
||||||
relatedType?: string;
|
relatedType?: string | null;
|
||||||
relatedId?: string;
|
relatedId?: string | null;
|
||||||
|
link?: string | null;
|
||||||
|
requiredPermissions?: readonly PermissionKey[];
|
||||||
|
dedupeKey?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NotificationFilter {
|
export type FanOutNotificationInput = Omit<
|
||||||
userId?: string;
|
CreateNotificationInput,
|
||||||
unreadOnly?: boolean;
|
'userId' | 'dedupeKey'
|
||||||
|
> & { dedupeKeyPrefix?: string };
|
||||||
|
|
||||||
|
export interface NotificationView {
|
||||||
|
id: string;
|
||||||
|
priority: NotificationPriority;
|
||||||
|
category: NotificationCategory;
|
||||||
|
message: string;
|
||||||
|
relatedType: string | null;
|
||||||
|
relatedId: string | null;
|
||||||
|
link: string | null;
|
||||||
|
read: boolean;
|
||||||
|
createdAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type NotificationContent = Omit<FanOutNotificationInput, 'dedupeKeyPrefix'>;
|
||||||
|
|
||||||
|
interface Target {
|
||||||
|
userId: string;
|
||||||
|
dedupeKey: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VIEW_SELECT = {
|
||||||
|
id: true,
|
||||||
|
priority: true,
|
||||||
|
category: true,
|
||||||
|
message: true,
|
||||||
|
relatedType: true,
|
||||||
|
relatedId: true,
|
||||||
|
link: true,
|
||||||
|
read: true,
|
||||||
|
createdAt: true,
|
||||||
|
} satisfies Prisma.NotificationSelect;
|
||||||
|
|
||||||
|
function toView(row: NotificationView): NotificationView {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
priority: row.priority,
|
||||||
|
category: row.category,
|
||||||
|
message: row.message,
|
||||||
|
relatedType: row.relatedType,
|
||||||
|
relatedId: row.relatedId,
|
||||||
|
link: row.link,
|
||||||
|
read: row.read,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_LISTED = 100;
|
||||||
|
/** Rows read before the permission filter, so hidden rows do not empty the list. */
|
||||||
|
const MAX_SCANNED = 500;
|
||||||
|
|
||||||
|
function uniqueSorted(keys: readonly PermissionKey[]): PermissionKey[] {
|
||||||
|
return [...new Set(keys)].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every notification belongs to exactly one user. Rows are written only to
|
||||||
|
* active users with an active role who hold every permission the content
|
||||||
|
* needs (the rule the PermissionsGuard applies). The stored
|
||||||
|
* requiredPermissions are the recipient's and the content's permissions
|
||||||
|
* together, so a later consumer (e.g. the Telegram sender) can check them
|
||||||
|
* again.
|
||||||
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class NotificationsService {
|
export class NotificationsService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly permissionResolver: PermissionResolverService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** One recipient. null: repeated dedupe key, or the recipient lacks a right. */
|
||||||
async record(
|
async record(
|
||||||
input: CreateNotificationInput,
|
input: CreateNotificationInput,
|
||||||
tx?: Prisma.TransactionClient,
|
tx?: Prisma.TransactionClient,
|
||||||
): Promise<Notification> {
|
): Promise<Notification | null> {
|
||||||
const db: DbClient = tx ?? this.prisma;
|
const db: DbClient = tx ?? this.prisma;
|
||||||
return db.notification.create({ data: { ...input } });
|
const { userId, dedupeKey, ...content } = input;
|
||||||
|
const keys = uniqueSorted(content.requiredPermissions ?? []);
|
||||||
|
const recipients = await this.recipients(db, keys, [userId]);
|
||||||
|
const [row] = await this.write(
|
||||||
|
db,
|
||||||
|
recipients.map((id) => ({ userId: id, dedupeKey: dedupeKey ?? null })),
|
||||||
|
content,
|
||||||
|
keys,
|
||||||
|
);
|
||||||
|
return row ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The given users, minus those missing a permission the content needs. */
|
||||||
|
async recordForUsers(
|
||||||
|
userIds: readonly string[],
|
||||||
|
input: FanOutNotificationInput,
|
||||||
|
tx?: Prisma.TransactionClient,
|
||||||
|
): Promise<Notification[]> {
|
||||||
|
if (userIds.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const keys = uniqueSorted(input.requiredPermissions ?? []);
|
||||||
|
return this.fanOut(tx ?? this.prisma, keys, input, userIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every user holding all of `permission` (and the content's permissions). */
|
||||||
|
async recordForPermission(
|
||||||
|
permission: PermissionKey | readonly PermissionKey[],
|
||||||
|
input: FanOutNotificationInput,
|
||||||
|
tx?: Prisma.TransactionClient,
|
||||||
|
): Promise<Notification[]> {
|
||||||
|
const recipientKeys =
|
||||||
|
typeof permission === 'string' ? [permission] : permission;
|
||||||
|
if (recipientKeys.length === 0) {
|
||||||
|
// An empty list would match every user: a silent broadcast.
|
||||||
|
throw new Error('recordForPermission needs at least one permission');
|
||||||
|
}
|
||||||
|
const keys = uniqueSorted([
|
||||||
|
...recipientKeys,
|
||||||
|
...(input.requiredPermissions ?? []),
|
||||||
|
]);
|
||||||
|
return this.fanOut(tx ?? this.prisma, keys, input, undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One notification per user who may act on it: active account, active
|
* The own notifications, newest first, at most 100. A row whose
|
||||||
* role, permission granted - the same rule the PermissionsGuard applies.
|
* requiredPermissions the user no longer holds (role changed after the
|
||||||
* Returns how many were written.
|
* write) stays hidden.
|
||||||
*/
|
*/
|
||||||
async recordForPermission(
|
async findOwn(
|
||||||
permission: PermissionKey,
|
userId: string,
|
||||||
input: Omit<CreateNotificationInput, 'userId'>,
|
options: { unreadOnly?: boolean } = {},
|
||||||
tx?: Prisma.TransactionClient,
|
): Promise<NotificationView[]> {
|
||||||
): Promise<number> {
|
const rows = await this.prisma.notification.findMany({
|
||||||
const db: DbClient = tx ?? this.prisma;
|
where: { userId, ...(options.unreadOnly ? { read: false } : {}) },
|
||||||
const recipients = await db.user.findMany({
|
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||||
|
take: MAX_SCANNED,
|
||||||
|
select: { ...VIEW_SELECT, requiredPermissions: true },
|
||||||
|
});
|
||||||
|
const held = await this.permissionResolver.getPermissions(userId);
|
||||||
|
return rows
|
||||||
|
.filter((row) => row.requiredPermissions.every((key) => held.has(key)))
|
||||||
|
.slice(0, MAX_LISTED)
|
||||||
|
.map(toView);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One statement; a foreign or unknown id matches nothing and answers 404. */
|
||||||
|
async markRead(id: string, userId: string): Promise<NotificationView> {
|
||||||
|
const { count } = await this.prisma.notification.updateMany({
|
||||||
|
where: { id, userId },
|
||||||
|
data: { read: true },
|
||||||
|
});
|
||||||
|
if (count === 0) {
|
||||||
|
throw new NotFoundException('Notification not found');
|
||||||
|
}
|
||||||
|
return this.prisma.notification.findFirstOrThrow({
|
||||||
|
where: { id, userId },
|
||||||
|
select: VIEW_SELECT,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async markAllRead(userId: string): Promise<{ updated: number }> {
|
||||||
|
const { count } = await this.prisma.notification.updateMany({
|
||||||
|
where: { userId, read: false },
|
||||||
|
data: { read: true },
|
||||||
|
});
|
||||||
|
return { updated: count };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fanOut(
|
||||||
|
db: DbClient,
|
||||||
|
keys: readonly PermissionKey[],
|
||||||
|
input: FanOutNotificationInput,
|
||||||
|
userIds: readonly string[] | undefined,
|
||||||
|
): Promise<Notification[]> {
|
||||||
|
const { dedupeKeyPrefix, ...content } = input;
|
||||||
|
const recipients = await this.recipients(db, keys, userIds);
|
||||||
|
return this.write(
|
||||||
|
db,
|
||||||
|
recipients.map((userId) => ({
|
||||||
|
userId,
|
||||||
|
dedupeKey: dedupeKeyPrefix
|
||||||
|
? notificationDedupeKey(dedupeKeyPrefix, userId)
|
||||||
|
: null,
|
||||||
|
})),
|
||||||
|
content,
|
||||||
|
keys,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Active user, active role, every key granted - in one query. */
|
||||||
|
private async recipients(
|
||||||
|
db: DbClient,
|
||||||
|
keys: readonly PermissionKey[],
|
||||||
|
userIds: readonly string[] | undefined,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const users = await db.user.findMany({
|
||||||
where: {
|
where: {
|
||||||
|
...(userIds ? { id: { in: [...userIds] } } : {}),
|
||||||
isActive: true,
|
isActive: true,
|
||||||
role: {
|
role: {
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
permissions: { some: { permission: { key: permission } } },
|
AND: keys.map((key) => ({
|
||||||
|
permissions: { some: { permission: { key } } },
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
});
|
});
|
||||||
if (recipients.length === 0) {
|
return users.map((user) => user.id);
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
const { count } = await db.notification.createMany({
|
|
||||||
data: recipients.map((recipient) => ({ ...input, userId: recipient.id })),
|
|
||||||
});
|
|
||||||
return count;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async findAll(filter: NotificationFilter = {}): Promise<Notification[]> {
|
private async write(
|
||||||
return this.prisma.notification.findMany({
|
db: DbClient,
|
||||||
where: {
|
targets: readonly Target[],
|
||||||
OR: filter.userId
|
content: NotificationContent,
|
||||||
? [{ userId: filter.userId }, { userId: null }]
|
recipientKeys: readonly PermissionKey[],
|
||||||
: undefined,
|
): Promise<Notification[]> {
|
||||||
read: filter.unreadOnly ? false : undefined,
|
if (content.link) {
|
||||||
},
|
assertAppLink(content.link);
|
||||||
orderBy: { createdAt: 'desc' },
|
|
||||||
take: 100,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (notification.userId !== null && notification.userId !== actingUserId) {
|
if (targets.length === 0) {
|
||||||
throw new ForbiddenException("Cannot modify another user's notification");
|
return [];
|
||||||
}
|
}
|
||||||
return this.prisma.notification.update({
|
const requiredPermissions = uniqueSorted([
|
||||||
where: { id },
|
...recipientKeys,
|
||||||
data: { read: true },
|
...(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 { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||||
import { CalendarSyncService } from '../calendar-sync/calendar-sync.service';
|
import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||||
|
|
||||||
describe('QueensService', () => {
|
describe('QueensService', () => {
|
||||||
let service: QueensService;
|
let service: QueensService;
|
||||||
let tx: {
|
let tx: {
|
||||||
queen: { update: jest.Mock };
|
$queryRaw: jest.Mock;
|
||||||
|
queen: {
|
||||||
|
update: jest.Mock;
|
||||||
|
findUnique: jest.Mock;
|
||||||
|
findUniqueOrThrow: jest.Mock;
|
||||||
|
};
|
||||||
queenColonyHistory: { updateMany: jest.Mock; create: jest.Mock };
|
queenColonyHistory: { updateMany: jest.Mock; create: jest.Mock };
|
||||||
colony: { findUnique: jest.Mock; update: jest.Mock };
|
colony: { findUnique: jest.Mock; update: jest.Mock };
|
||||||
};
|
};
|
||||||
@@ -29,13 +34,20 @@ describe('QueensService', () => {
|
|||||||
$transaction: jest.Mock;
|
$transaction: jest.Mock;
|
||||||
};
|
};
|
||||||
let auditLog: { record: jest.Mock };
|
let auditLog: { record: jest.Mock };
|
||||||
|
let webhooks: { dispatch: jest.Mock };
|
||||||
|
let events: { record: jest.Mock };
|
||||||
|
|
||||||
const queen = { id: 'queen-1', code: 'Q-001', status: 'ACTIVE' };
|
const queen = { id: 'queen-1', code: 'Q-001', status: 'ACTIVE' };
|
||||||
const colony = { id: 'colony-1', currentQueenId: null };
|
const colony = { id: 'colony-1', currentQueenId: null };
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
tx = {
|
tx = {
|
||||||
queen: { update: jest.fn().mockResolvedValue(queen) },
|
$queryRaw: jest.fn().mockResolvedValue([]),
|
||||||
|
queen: {
|
||||||
|
update: jest.fn().mockResolvedValue(queen),
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
findUniqueOrThrow: jest.fn().mockResolvedValue(queen),
|
||||||
|
},
|
||||||
queenColonyHistory: { updateMany: jest.fn(), create: jest.fn() },
|
queenColonyHistory: { updateMany: jest.fn(), create: jest.fn() },
|
||||||
colony: {
|
colony: {
|
||||||
findUnique: jest.fn().mockResolvedValue(null),
|
findUnique: jest.fn().mockResolvedValue(null),
|
||||||
@@ -57,13 +69,17 @@ describe('QueensService', () => {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||||
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
|
webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
|
||||||
const calendarSync = { enqueue: jest.fn(), cancel: jest.fn() };
|
events = { record: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
// The locked re-read sees what the pre-read saw unless a test says otherwise.
|
||||||
|
tx.queen.findUnique.mockImplementation(
|
||||||
|
(args: unknown) => prisma.queen.findUnique(args) as Promise<unknown>,
|
||||||
|
);
|
||||||
service = new QueensService(
|
service = new QueensService(
|
||||||
prisma as unknown as PrismaService,
|
prisma as unknown as PrismaService,
|
||||||
auditLog as unknown as AuditLogService,
|
auditLog as unknown as AuditLogService,
|
||||||
webhooks as unknown as WebhooksService,
|
webhooks as unknown as WebhooksService,
|
||||||
calendarSync as unknown as CalendarSyncService,
|
events as unknown as DomainEventsService,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -74,9 +90,18 @@ describe('QueensService', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Inside the transaction: lookup by id is the target colony, by
|
||||||
|
// currentQueenId the colony the queen leaves.
|
||||||
|
function mockColonies(target: unknown, previous: unknown = null): void {
|
||||||
|
const lookup = (args: { where: { id?: string } }) =>
|
||||||
|
Promise.resolve(args.where.id === undefined ? previous : target);
|
||||||
|
prisma.colony.findUnique.mockImplementation(lookup);
|
||||||
|
tx.colony.findUnique.mockImplementation(lookup);
|
||||||
|
}
|
||||||
|
|
||||||
it('assignColony throws NotFoundException when the colony is missing', async () => {
|
it('assignColony throws NotFoundException when the colony is missing', async () => {
|
||||||
prisma.queen.findUnique.mockResolvedValue(queen);
|
prisma.queen.findUnique.mockResolvedValue(queen);
|
||||||
prisma.colony.findUnique.mockResolvedValue(null);
|
mockColonies(null);
|
||||||
await expect(
|
await expect(
|
||||||
service.assignColony('queen-1', { colonyId: 'missing' }, 'user-1'),
|
service.assignColony('queen-1', { colonyId: 'missing' }, 'user-1'),
|
||||||
).rejects.toBeInstanceOf(NotFoundException);
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
@@ -90,15 +115,13 @@ describe('QueensService', () => {
|
|||||||
await expect(
|
await expect(
|
||||||
service.assignColony('queen-1', { colonyId: 'colony-1' }, 'user-1'),
|
service.assignColony('queen-1', { colonyId: 'colony-1' }, 'user-1'),
|
||||||
).rejects.toBeInstanceOf(ConflictException);
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
expect(prisma.colony.findUnique).not.toHaveBeenCalled();
|
expect(tx.colony.update).not.toHaveBeenCalled();
|
||||||
|
expect(events.record).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('assignColony closes the previous queen history, sets currentQueenId, and opens a new history row', async () => {
|
it('assignColony closes the previous queen history, sets currentQueenId, and opens a new history row', async () => {
|
||||||
prisma.queen.findUnique.mockResolvedValue(queen);
|
prisma.queen.findUnique.mockResolvedValue(queen);
|
||||||
prisma.colony.findUnique.mockResolvedValue({
|
mockColonies({ ...colony, status: 'ACTIVE', currentQueenId: 'queen-old' });
|
||||||
...colony,
|
|
||||||
currentQueenId: 'queen-old',
|
|
||||||
});
|
|
||||||
tx.colony.update.mockResolvedValue({});
|
tx.colony.update.mockResolvedValue({});
|
||||||
tx.queenColonyHistory.create.mockResolvedValue({ id: 'history-1' });
|
tx.queenColonyHistory.create.mockResolvedValue({ id: 'history-1' });
|
||||||
|
|
||||||
@@ -122,6 +145,57 @@ describe('QueensService', () => {
|
|||||||
expect(historyArg.data.colonyId).toBe('colony-1');
|
expect(historyArg.data.colonyId).toBe('colony-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('assignColony records QueenAssigned with previousColonyId and calls neither webhooks.dispatch nor calendarSync', async () => {
|
||||||
|
prisma.queen.findUnique.mockResolvedValue(queen);
|
||||||
|
mockColonies(
|
||||||
|
{ ...colony, status: 'ACTIVE' },
|
||||||
|
{ id: 'colony-prev', status: 'ACTIVE' },
|
||||||
|
);
|
||||||
|
tx.colony.update.mockResolvedValue({});
|
||||||
|
tx.queenColonyHistory.create.mockResolvedValue({ id: 'history-1' });
|
||||||
|
|
||||||
|
await service.assignColony('queen-1', { colonyId: 'colony-1' }, 'user-1');
|
||||||
|
|
||||||
|
// Target and previous colony in one statement, then the queen.
|
||||||
|
expect(tx.$queryRaw).toHaveBeenCalledTimes(2);
|
||||||
|
expect(events.record).toHaveBeenCalledTimes(1);
|
||||||
|
expect(events.record).toHaveBeenCalledWith(
|
||||||
|
tx,
|
||||||
|
expect.objectContaining({
|
||||||
|
type: 'QueenAssigned',
|
||||||
|
aggregateType: 'Queen',
|
||||||
|
aggregateId: 'queen-1',
|
||||||
|
actorUserId: 'user-1',
|
||||||
|
payload: {
|
||||||
|
colonyId: 'colony-1',
|
||||||
|
historyId: 'history-1',
|
||||||
|
assignedOn: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/) as string,
|
||||||
|
previousColonyId: 'colony-prev',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(auditLog.record).toHaveBeenCalledWith(expect.anything(), tx);
|
||||||
|
expect(webhooks.dispatch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('assignColony answers 409 when the queen was moved by someone else after the pre-read', async () => {
|
||||||
|
prisma.queen.findUnique.mockResolvedValue(queen);
|
||||||
|
prisma.colony.findUnique.mockResolvedValue(null);
|
||||||
|
tx.colony.findUnique.mockImplementation(
|
||||||
|
(args: { where: { id?: string } }) =>
|
||||||
|
Promise.resolve(
|
||||||
|
args.where.id === undefined
|
||||||
|
? { id: 'colony-other', status: 'ACTIVE' }
|
||||||
|
: { ...colony, status: 'ACTIVE' },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.assignColony('queen-1', { colonyId: 'colony-1' }, 'user-1'),
|
||||||
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
expect(events.record).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('addEvaluation creates a queen evaluation and records an audit log entry', async () => {
|
it('addEvaluation creates a queen evaluation and records an audit log entry', async () => {
|
||||||
prisma.queen.findUnique.mockResolvedValue(queen);
|
prisma.queen.findUnique.mockResolvedValue(queen);
|
||||||
const created = { id: 'eval-1', queenId: 'queen-1', colonyStrength: 4 };
|
const created = { id: 'eval-1', queenId: 'queen-1', colonyStrength: 4 };
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import {
|
|||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
import { WebhooksService } from '../webhooks/webhooks.service';
|
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||||
import { CalendarSyncService } from '../calendar-sync/calendar-sync.service';
|
import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||||
|
import { utcDay } from '../domain-events/domain-event-types';
|
||||||
|
import { lockRowForUpdate, lockRowsForUpdate } from '../prisma/row-lock';
|
||||||
import { Queen, QueenStatus } from '../../generated/prisma';
|
import { Queen, QueenStatus } from '../../generated/prisma';
|
||||||
import { CreateQueenDto } from './dto/create-queen.dto';
|
import { CreateQueenDto } from './dto/create-queen.dto';
|
||||||
import { UpdateQueenDto } from './dto/update-queen.dto';
|
import { UpdateQueenDto } from './dto/update-queen.dto';
|
||||||
@@ -64,7 +66,7 @@ export class QueensService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly auditLog: AuditLogService,
|
private readonly auditLog: AuditLogService,
|
||||||
private readonly webhooks: WebhooksService,
|
private readonly webhooks: WebhooksService,
|
||||||
private readonly calendarSync: CalendarSyncService,
|
private readonly events: DomainEventsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findAll(filter: QueenFilter = {}): Promise<Queen[]> {
|
async findAll(filter: QueenFilter = {}): Promise<Queen[]> {
|
||||||
@@ -158,28 +160,55 @@ export class QueensService {
|
|||||||
dto: AssignColonyDto,
|
dto: AssignColonyDto,
|
||||||
actingUserId: string,
|
actingUserId: string,
|
||||||
): Promise<Queen> {
|
): Promise<Queen> {
|
||||||
const queen = await this.findById(id);
|
await this.findById(id);
|
||||||
if (QUEEN_NO_LONGER_AVAILABLE_STATUSES.includes(queen.status)) {
|
// Only to know which colony to lock; the transaction re-reads it.
|
||||||
throw new ConflictException(
|
const preRead = await this.prisma.colony.findUnique({
|
||||||
'Die Königin ist nicht mehr verfügbar für eine Zuweisung - korrigiere zuerst ihren Status, falls dies ein Versehen war',
|
where: { currentQueenId: id },
|
||||||
);
|
select: { id: true },
|
||||||
}
|
|
||||||
const colony = await this.prisma.colony.findUnique({
|
|
||||||
where: { id: dto.colonyId },
|
|
||||||
});
|
});
|
||||||
if (!colony) {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
throw new NotFoundException('Colony not found');
|
// Colonies first (one statement, ascending ids), then the queen: the
|
||||||
}
|
// order every writer of both uses.
|
||||||
if (!isLivingColonyStatus(colony.status)) {
|
await lockRowsForUpdate(tx, 'Colony', [
|
||||||
throw new ConflictException(
|
dto.colonyId,
|
||||||
`Das Volk besteht nicht mehr (Status: ${colony.status}) und kann keine Königin aufnehmen`,
|
...(preRead ? [preRead.id] : []),
|
||||||
);
|
]);
|
||||||
}
|
await lockRowForUpdate(tx, 'Queen', id);
|
||||||
if (colony.currentQueenId === queen.id) {
|
const queen = await tx.queen.findUnique({ where: { id } });
|
||||||
throw new ConflictException('Die Königin sitzt bereits in diesem Volk');
|
if (!queen) {
|
||||||
}
|
throw new NotFoundException('Queen not found');
|
||||||
|
}
|
||||||
|
if (QUEEN_NO_LONGER_AVAILABLE_STATUSES.includes(queen.status)) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'Die Königin ist nicht mehr verfügbar für eine Zuweisung - korrigiere zuerst ihren Status, falls dies ein Versehen war',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const colony = await tx.colony.findUnique({
|
||||||
|
where: { id: dto.colonyId },
|
||||||
|
});
|
||||||
|
if (!colony) {
|
||||||
|
throw new NotFoundException('Colony not found');
|
||||||
|
}
|
||||||
|
if (!isLivingColonyStatus(colony.status)) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Das Volk besteht nicht mehr (Status: ${colony.status}) und kann keine Königin aufnehmen`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (colony.currentQueenId === queen.id) {
|
||||||
|
throw new ConflictException('Die Königin sitzt bereits in diesem Volk');
|
||||||
|
}
|
||||||
|
// The colony the queen leaves must let go of her - currentQueenId is
|
||||||
|
// unique, and a stale pointer would both block this assignment and
|
||||||
|
// keep showing her in a colony she is no longer in.
|
||||||
|
const previousColony = await tx.colony.findUnique({
|
||||||
|
where: { currentQueenId: queen.id },
|
||||||
|
});
|
||||||
|
if ((previousColony?.id ?? null) !== (preRead?.id ?? null)) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'Die Königin wurde gerade umgesetzt - bitte erneut versuchen',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const history = await this.prisma.$transaction(async (tx) => {
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
if (colony.currentQueenId) {
|
if (colony.currentQueenId) {
|
||||||
await tx.queenColonyHistory.updateMany({
|
await tx.queenColonyHistory.updateMany({
|
||||||
@@ -191,12 +220,6 @@ export class QueensService {
|
|||||||
data: { endDate: now },
|
data: { endDate: now },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// The colony the queen leaves must let go of her - currentQueenId is
|
|
||||||
// unique, and a stale pointer would both block this assignment and
|
|
||||||
// keep showing her in a colony she is no longer in.
|
|
||||||
const previousColony = await tx.colony.findUnique({
|
|
||||||
where: { currentQueenId: queen.id },
|
|
||||||
});
|
|
||||||
if (previousColony) {
|
if (previousColony) {
|
||||||
await tx.colony.update({
|
await tx.colony.update({
|
||||||
where: { id: previousColony.id },
|
where: { id: previousColony.id },
|
||||||
@@ -221,29 +244,39 @@ export class QueensService {
|
|||||||
status: colony.status === 'QUEENLESS' ? 'ACTIVE' : undefined,
|
status: colony.status === 'QUEENLESS' ? 'ACTIVE' : undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return tx.queenColonyHistory.create({
|
const history = await tx.queenColonyHistory.create({
|
||||||
data: {
|
data: {
|
||||||
queenId: queen.id,
|
queenId: queen.id,
|
||||||
colonyId: colony.id,
|
colonyId: colony.id,
|
||||||
startDate: new Date(),
|
startDate: now,
|
||||||
notes: dto.notes,
|
notes: dto.notes,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
await this.auditLog.record(
|
||||||
|
{
|
||||||
|
entityType: 'Queen',
|
||||||
|
entityId: queen.id,
|
||||||
|
action: 'UPDATE',
|
||||||
|
userId: actingUserId,
|
||||||
|
beforeData: queen,
|
||||||
|
afterData: { ...queen, assignedColonyId: colony.id },
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
await this.events.record(tx, {
|
||||||
|
type: 'QueenAssigned',
|
||||||
|
aggregateType: 'Queen',
|
||||||
|
aggregateId: queen.id,
|
||||||
|
actorUserId: actingUserId,
|
||||||
|
payload: {
|
||||||
|
colonyId: colony.id,
|
||||||
|
historyId: history.id,
|
||||||
|
assignedOn: utcDay(now),
|
||||||
|
previousColonyId: previousColony?.id ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return tx.queen.findUniqueOrThrow({ where: { id } });
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.auditLog.record({
|
|
||||||
entityType: 'Queen',
|
|
||||||
entityId: queen.id,
|
|
||||||
action: 'UPDATE',
|
|
||||||
userId: actingUserId,
|
|
||||||
beforeData: queen,
|
|
||||||
afterData: { ...queen, assignedColonyId: colony.id },
|
|
||||||
});
|
|
||||||
|
|
||||||
const updated = await this.findById(id);
|
|
||||||
await this.webhooks.dispatch('queen.replaced', updated);
|
|
||||||
await this.calendarSync.enqueue('QUEEN_ASSIGNMENT', history.id);
|
|
||||||
return updated;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getHistory(id: string) {
|
async getHistory(id: string) {
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Matches,
|
||||||
|
MaxLength,
|
||||||
|
MinLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class AcceptSuggestionDto {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
nullable: true,
|
||||||
|
description: 'JJJJ-MM-TT; bei einem Plan der Tag des ersten Schritts.',
|
||||||
|
example: '2026-05-12',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Matches(/^\d{4}-\d{2}-\d{2}$/, {
|
||||||
|
message: 'Bitte ein Datum im Format JJJJ-MM-TT angeben.',
|
||||||
|
})
|
||||||
|
dueDate?: string | null;
|
||||||
|
|
||||||
|
// User ids are cuids, but no other DTO in the codebase checks their format
|
||||||
|
// (the existence and activity check in the service is the real validation),
|
||||||
|
// so only the length is bounded here.
|
||||||
|
@ApiPropertyOptional({ nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(64)
|
||||||
|
assignedUserId?: string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsOptional, IsString, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class DismissSuggestionDto {
|
||||||
|
@ApiPropertyOptional({ maxLength: 500 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500, { message: 'Die Notiz darf höchstens 500 Zeichen haben.' })
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsEnum,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Max,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { SuggestionStatus } from '../../../generated/prisma';
|
||||||
|
|
||||||
|
export const DEFAULT_LIST_LIMIT = 200;
|
||||||
|
export const MAX_LIST_LIMIT = 500;
|
||||||
|
|
||||||
|
export class ListSuggestionsQuery {
|
||||||
|
@ApiPropertyOptional({ enum: SuggestionStatus, default: 'OPEN' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(SuggestionStatus, { message: 'Bitte einen gültigen Status wählen.' })
|
||||||
|
status?: SuggestionStatus;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(64)
|
||||||
|
colonyId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(64)
|
||||||
|
apiaryId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: `Einträge je Abruf, 1 bis ${MAX_LIST_LIMIT}`,
|
||||||
|
default: DEFAULT_LIST_LIMIT,
|
||||||
|
minimum: 1,
|
||||||
|
maximum: MAX_LIST_LIMIT,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt({ message: 'Das Limit muss eine ganze Zahl sein.' })
|
||||||
|
@Min(1, { message: 'Das Limit muss mindestens 1 sein.' })
|
||||||
|
@Max(MAX_LIST_LIMIT, {
|
||||||
|
message: `Das Limit darf höchstens ${MAX_LIST_LIMIT} sein.`,
|
||||||
|
})
|
||||||
|
limit?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'So viele Einträge der sortierten Liste überspringen',
|
||||||
|
default: 0,
|
||||||
|
minimum: 0,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt({ message: 'Der Versatz muss eine ganze Zahl sein.' })
|
||||||
|
@Min(0, { message: 'Der Versatz darf nicht negativ sein.' })
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
ArrayMaxSize,
|
||||||
|
ArrayMinSize,
|
||||||
|
ArrayUnique,
|
||||||
|
IsArray,
|
||||||
|
IsString,
|
||||||
|
MaxLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class SuggestionIdsDto {
|
||||||
|
@ApiProperty({ type: [String], minItems: 1, maxItems: 50 })
|
||||||
|
@IsArray({ message: 'Bitte eine Liste von Vorschlägen angeben.' })
|
||||||
|
@ArrayMinSize(1, { message: 'Bitte mindestens einen Vorschlag wählen.' })
|
||||||
|
@ArrayMaxSize(50, { message: 'Bitte höchstens 50 Vorschläge wählen.' })
|
||||||
|
@ArrayUnique({ message: 'Jeder Vorschlag darf nur einmal vorkommen.' })
|
||||||
|
@IsString({ each: true })
|
||||||
|
@MaxLength(64, { each: true })
|
||||||
|
ids!: string[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
AutomationTriggerType,
|
||||||
|
SuggestionIntent,
|
||||||
|
SuggestionMode,
|
||||||
|
SuggestionSource,
|
||||||
|
SuggestionStatus,
|
||||||
|
TaskPriority,
|
||||||
|
TaskRelatedType,
|
||||||
|
} from '../../../generated/prisma';
|
||||||
|
import type {
|
||||||
|
SuggestionSummary,
|
||||||
|
SuggestionView,
|
||||||
|
} from '../suggestion-views.service';
|
||||||
|
|
||||||
|
class PlanStepDto {
|
||||||
|
@ApiProperty() step!: string;
|
||||||
|
@ApiProperty() offsetDays!: number;
|
||||||
|
@ApiProperty({ required: false }) windowDays?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CleanupDto {
|
||||||
|
@ApiProperty({ type: [String] }) taskIds!: string[];
|
||||||
|
@ApiProperty({ nullable: true, type: String }) queenId!: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A suggestion as the app reads it; no evidence, keys or permissions. */
|
||||||
|
export class SuggestionViewDto implements SuggestionView {
|
||||||
|
@ApiProperty() id!: string;
|
||||||
|
@ApiProperty({ enum: AutomationTriggerType }) ruleKey!: AutomationTriggerType;
|
||||||
|
@ApiProperty({ enum: SuggestionSource }) source!: SuggestionSource;
|
||||||
|
@ApiProperty({ enum: SuggestionMode }) mode!: SuggestionMode;
|
||||||
|
@ApiProperty({ enum: SuggestionIntent }) intent!: SuggestionIntent;
|
||||||
|
@ApiProperty({ enum: SuggestionStatus }) status!: SuggestionStatus;
|
||||||
|
@ApiProperty({ enum: TaskPriority }) priority!: TaskPriority;
|
||||||
|
@ApiProperty({ enum: TaskRelatedType }) relatedType!: TaskRelatedType;
|
||||||
|
@ApiProperty({ nullable: true, type: String }) relatedId!: string | null;
|
||||||
|
@ApiProperty({ nullable: true, type: String }) colonyId!: string | null;
|
||||||
|
@ApiProperty({ nullable: true, type: String }) colonyCode!: string | null;
|
||||||
|
@ApiProperty({ nullable: true, type: String }) apiaryId!: string | null;
|
||||||
|
@ApiProperty({ nullable: true, type: String }) apiaryName!: string | null;
|
||||||
|
@ApiProperty() title!: string;
|
||||||
|
@ApiProperty({ type: [String] }) reasons!: string[];
|
||||||
|
@ApiProperty({ nullable: true, type: String, description: 'JJJJ-MM-TT' })
|
||||||
|
dueDate!: string | null;
|
||||||
|
@ApiProperty({ nullable: true, type: String, description: 'JJJJ-MM-TT' })
|
||||||
|
expiresAt!: string | null;
|
||||||
|
@ApiProperty({ nullable: true, type: [PlanStepDto] })
|
||||||
|
plan!: PlanStepDto[] | null;
|
||||||
|
@ApiProperty({ nullable: true, type: String }) decidedAt!: string | null;
|
||||||
|
@ApiProperty({ nullable: true, type: String }) decisionNote!: string | null;
|
||||||
|
@ApiProperty() createdAt!: string;
|
||||||
|
@ApiProperty({ nullable: true, type: CleanupDto, description: 'Nur CLEANUP' })
|
||||||
|
cleanup!: CleanupDto | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SuggestionSummaryDto implements SuggestionSummary {
|
||||||
|
@ApiProperty() open!: number;
|
||||||
|
@ApiProperty({
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: { type: 'number' },
|
||||||
|
example: { LOW: 0, NORMAL: 2, HIGH: 1, URGENT: 0 },
|
||||||
|
})
|
||||||
|
byPriority!: Record<TaskPriority, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AcceptSuggestionResponseDto {
|
||||||
|
@ApiProperty({ type: SuggestionViewDto }) suggestion!: SuggestionViewDto;
|
||||||
|
@ApiProperty({
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'object' },
|
||||||
|
description: 'Die neuen Aufgaben, in der Form von GET /tasks.',
|
||||||
|
})
|
||||||
|
tasks!: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BulkDismissResponseDto {
|
||||||
|
@ApiProperty() dismissed!: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import type { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||||
|
import type { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { SuggestionViewsService } from './suggestion-views.service';
|
||||||
|
|
||||||
|
const NOW = new Date('2026-05-05T08:00:00.000Z');
|
||||||
|
|
||||||
|
function row(id: string, colonyId: string) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
ruleKey: 'INSPECTION_INTERVAL',
|
||||||
|
source: 'RULE',
|
||||||
|
mode: 'DO',
|
||||||
|
intent: 'INSPECT',
|
||||||
|
status: 'OPEN',
|
||||||
|
priority: 'NORMAL',
|
||||||
|
relatedType: 'COLONY',
|
||||||
|
relatedId: colonyId,
|
||||||
|
colonyId,
|
||||||
|
colony: { code: 'B3', apiaryId: 'api_1', apiary: { name: 'Hang' } },
|
||||||
|
reasonCode: 'UNKNOWN_CODE',
|
||||||
|
evidence: { colonyId },
|
||||||
|
requiredPermission: 'inspections:read',
|
||||||
|
dueDate: new Date('2026-05-07T00:00:00.000Z'),
|
||||||
|
expiresAt: null,
|
||||||
|
plan: null,
|
||||||
|
decidedAt: null,
|
||||||
|
decisionNote: null,
|
||||||
|
createdAt: NOW,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function build(rows: ReturnType<typeof row>[]) {
|
||||||
|
const suggestionFindMany = jest.fn().mockResolvedValue(rows);
|
||||||
|
const colonyFindMany = jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([{ id: 'col_1', code: 'B3' }]);
|
||||||
|
const prisma = {
|
||||||
|
suggestion: { findMany: suggestionFindMany, groupBy: jest.fn() },
|
||||||
|
colony: { findMany: colonyFindMany },
|
||||||
|
queen: { findMany: jest.fn().mockResolvedValue([]) },
|
||||||
|
treatment: { findMany: jest.fn().mockResolvedValue([]) },
|
||||||
|
apiary: { findMany: jest.fn().mockResolvedValue([]) },
|
||||||
|
stockItem: { findMany: jest.fn().mockResolvedValue([]) },
|
||||||
|
automationRule: { findMany: jest.fn().mockResolvedValue([]) },
|
||||||
|
$queryRaw: jest.fn().mockResolvedValue([{ cutoff: NOW }]),
|
||||||
|
};
|
||||||
|
const resolver = {
|
||||||
|
getPermissions: jest.fn().mockResolvedValue(new Set(['inspections:read'])),
|
||||||
|
};
|
||||||
|
const service = new SuggestionViewsService(
|
||||||
|
prisma as unknown as PrismaService,
|
||||||
|
resolver as unknown as PermissionResolverService,
|
||||||
|
);
|
||||||
|
return { service, prisma, suggestionFindMany, colonyFindMany };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SuggestionViewsService', () => {
|
||||||
|
it('filters by requiredPermission in the query, not after loading', async () => {
|
||||||
|
const { service, suggestionFindMany } = build([]);
|
||||||
|
|
||||||
|
await service.list('user_1', {});
|
||||||
|
|
||||||
|
const [args] = suggestionFindMany.mock.calls[0] as [
|
||||||
|
{ where: { AND: unknown[] } },
|
||||||
|
];
|
||||||
|
expect(args.where.AND).toContainEqual({
|
||||||
|
OR: [
|
||||||
|
{ requiredPermission: null },
|
||||||
|
{ requiredPermission: { in: ['inspections:read'] } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(service.visibleWhere(new Set(['a:read']))).toEqual({
|
||||||
|
OR: [
|
||||||
|
{ requiredPermission: null },
|
||||||
|
{ requiredPermission: { in: ['a:read'] } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders title and reasons with one lookup for all rows', async () => {
|
||||||
|
const { service, colonyFindMany } = build([
|
||||||
|
row('s1', 'col_1'),
|
||||||
|
row('s2', 'col_1'),
|
||||||
|
row('s3', 'col_1'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const views = await service.list('user_1', {});
|
||||||
|
|
||||||
|
expect(views.map((view) => view.id)).toEqual(['s1', 's2', 's3']);
|
||||||
|
expect(views[0]).toMatchObject({
|
||||||
|
title: 'Durchsicht fällig',
|
||||||
|
colonyCode: 'B3',
|
||||||
|
apiaryId: 'api_1',
|
||||||
|
apiaryName: 'Hang',
|
||||||
|
dueDate: '2026-05-07',
|
||||||
|
cleanup: null,
|
||||||
|
});
|
||||||
|
expect(colonyFindMany).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists a dismissed suggestion only within 30 days of its decision', async () => {
|
||||||
|
const { service, suggestionFindMany, prisma } = build([]);
|
||||||
|
|
||||||
|
await service.list('user_1', { status: 'DISMISSED' });
|
||||||
|
|
||||||
|
expect(prisma.$queryRaw).toHaveBeenCalledTimes(1);
|
||||||
|
const [dismissedArgs] = suggestionFindMany.mock.calls[0] as [
|
||||||
|
{ where: unknown },
|
||||||
|
];
|
||||||
|
expect(JSON.stringify(dismissedArgs.where)).toContain(
|
||||||
|
'"decidedAt":{"gte":"2026-05-05T08:00:00.000Z"}',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads the cleanup tasks from evidence.openTaskIds', async () => {
|
||||||
|
const cleanup = {
|
||||||
|
...row('s9', 'col_1'),
|
||||||
|
intent: 'CLEANUP',
|
||||||
|
evidence: { colonyId: 'col_1', openTaskIds: ['t1', 't2'], queenId: 'q1' },
|
||||||
|
};
|
||||||
|
const { service } = build([cleanup]);
|
||||||
|
|
||||||
|
const [view] = await service.list('user_1', {});
|
||||||
|
|
||||||
|
expect(view.cleanup).toEqual({ taskIds: ['t1', 't2'], queenId: 'q1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes limit (default 200) and offset to the query', async () => {
|
||||||
|
const { service, suggestionFindMany } = build([]);
|
||||||
|
|
||||||
|
await service.list('user_1', {});
|
||||||
|
await service.list('user_1', { limit: 5, offset: 10 });
|
||||||
|
|
||||||
|
expect((suggestionFindMany.mock.calls[0] as unknown[])[0]).toMatchObject({
|
||||||
|
take: 200,
|
||||||
|
skip: 0,
|
||||||
|
});
|
||||||
|
expect((suggestionFindMany.mock.calls[1] as unknown[])[0]).toMatchObject({
|
||||||
|
take: 5,
|
||||||
|
skip: 10,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
Prisma,
|
||||||
|
type AutomationTriggerType,
|
||||||
|
type Suggestion,
|
||||||
|
type SuggestionIntent,
|
||||||
|
type SuggestionMode,
|
||||||
|
type SuggestionSource,
|
||||||
|
type SuggestionStatus,
|
||||||
|
type TaskPriority,
|
||||||
|
type TaskRelatedType,
|
||||||
|
} from '../../generated/prisma';
|
||||||
|
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||||
|
import type { PlanStepConfig } from '../automation/rule-config';
|
||||||
|
import { berlinToday } from '../common/dates';
|
||||||
|
import { utcDay } from '../domain-events/domain-event-types';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import {
|
||||||
|
DEFAULT_LIST_LIMIT,
|
||||||
|
type ListSuggestionsQuery,
|
||||||
|
} from './dto/list-suggestions.query';
|
||||||
|
import { buildEvidenceLookup } from './evidence-lookup';
|
||||||
|
import { planOf } from './suggestion-plan';
|
||||||
|
import { renderSuggestion } from './suggestion-texts';
|
||||||
|
import type { SuggestionEvidence } from './suggestion.types';
|
||||||
|
|
||||||
|
export interface SuggestionView {
|
||||||
|
id: string;
|
||||||
|
ruleKey: AutomationTriggerType;
|
||||||
|
source: SuggestionSource;
|
||||||
|
mode: SuggestionMode;
|
||||||
|
intent: SuggestionIntent;
|
||||||
|
status: SuggestionStatus;
|
||||||
|
priority: TaskPriority;
|
||||||
|
relatedType: TaskRelatedType;
|
||||||
|
relatedId: string | null;
|
||||||
|
colonyId: string | null;
|
||||||
|
colonyCode: string | null;
|
||||||
|
apiaryId: string | null;
|
||||||
|
apiaryName: string | null;
|
||||||
|
title: string;
|
||||||
|
reasons: string[];
|
||||||
|
dueDate: string | null;
|
||||||
|
expiresAt: string | null;
|
||||||
|
plan: PlanStepConfig[] | null;
|
||||||
|
decidedAt: string | null;
|
||||||
|
decisionNote: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
/** Only for CLEANUP suggestions. */
|
||||||
|
cleanup: { taskIds: string[]; queenId: string | null } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SuggestionSummary {
|
||||||
|
open: number;
|
||||||
|
byPriority: Record<TaskPriority, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NOT_FOUND_MESSAGE = 'Der Vorschlag wurde nicht gefunden.';
|
||||||
|
/** A dismissed suggestion stays listed (and can be reopened) this long. */
|
||||||
|
const DISMISSED_LISTED_DAYS = 30;
|
||||||
|
const PRIORITIES: readonly TaskPriority[] = ['LOW', 'NORMAL', 'HIGH', 'URGENT'];
|
||||||
|
|
||||||
|
const LIST_INCLUDE = {
|
||||||
|
colony: {
|
||||||
|
select: {
|
||||||
|
code: true,
|
||||||
|
apiaryId: true,
|
||||||
|
apiary: { select: { name: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Prisma.SuggestionInclude;
|
||||||
|
|
||||||
|
type SuggestionRow = Prisma.SuggestionGetPayload<{
|
||||||
|
include: typeof LIST_INCLUDE;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
function cleanupOf(
|
||||||
|
row: Pick<Suggestion, 'intent' | 'evidence'>,
|
||||||
|
): SuggestionView['cleanup'] {
|
||||||
|
if (row.intent !== 'CLEANUP') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const { openTaskIds, queenId } = row.evidence as SuggestionEvidence;
|
||||||
|
return {
|
||||||
|
taskIds: Array.isArray(openTaskIds)
|
||||||
|
? openTaskIds.filter((id): id is string => typeof id === 'string')
|
||||||
|
: [],
|
||||||
|
queenId: typeof queenId === 'string' ? queenId : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The read side of the suggestions: lists and counts only what the user may
|
||||||
|
* see (the filter by `requiredPermission` is part of the query), with title
|
||||||
|
* and reasons rendered from the stored code and evidence.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class SuggestionViewsService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly permissions: PermissionResolverService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Suggestions without a required permission, or one the user holds. */
|
||||||
|
visibleWhere(permissions: ReadonlySet<string>): Prisma.SuggestionWhereInput {
|
||||||
|
return {
|
||||||
|
OR: [
|
||||||
|
{ requiredPermission: null },
|
||||||
|
{ requiredPermission: { in: [...permissions] } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(
|
||||||
|
userId: string,
|
||||||
|
query: ListSuggestionsQuery,
|
||||||
|
): Promise<SuggestionView[]> {
|
||||||
|
const permissions = await this.permissions.getPermissions(userId);
|
||||||
|
const status = query.status ?? 'OPEN';
|
||||||
|
const rows = await this.prisma.suggestion.findMany({
|
||||||
|
where: {
|
||||||
|
AND: [
|
||||||
|
this.visibleWhere(permissions),
|
||||||
|
await this.statusWhere(status),
|
||||||
|
...(query.colonyId ? [{ colonyId: query.colonyId }] : []),
|
||||||
|
...(query.apiaryId ? [{ colony: { apiaryId: query.apiaryId } }] : []),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
include: LIST_INCLUDE,
|
||||||
|
orderBy: [
|
||||||
|
{ dueDate: { sort: 'asc', nulls: 'last' } },
|
||||||
|
{ priority: 'desc' },
|
||||||
|
{ createdAt: 'asc' },
|
||||||
|
{ id: 'asc' },
|
||||||
|
],
|
||||||
|
take: query.limit ?? DEFAULT_LIST_LIMIT,
|
||||||
|
skip: query.offset ?? 0,
|
||||||
|
});
|
||||||
|
return this.toViews(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One visible suggestion, 404 if it is unknown or hidden. */
|
||||||
|
async getOne(userId: string, id: string): Promise<SuggestionView> {
|
||||||
|
const [view] = await this.getMany(userId, [id]);
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The visible suggestions with these ids, in the order of `ids`. */
|
||||||
|
async getMany(
|
||||||
|
userId: string,
|
||||||
|
ids: readonly string[],
|
||||||
|
): Promise<SuggestionView[]> {
|
||||||
|
const permissions = await this.permissions.getPermissions(userId);
|
||||||
|
const rows = await this.prisma.suggestion.findMany({
|
||||||
|
where: {
|
||||||
|
AND: [this.visibleWhere(permissions), { id: { in: [...ids] } }],
|
||||||
|
},
|
||||||
|
include: LIST_INCLUDE,
|
||||||
|
});
|
||||||
|
const views = await this.toViews(rows);
|
||||||
|
const byId = new Map(views.map((view) => [view.id, view]));
|
||||||
|
return ids.map((id) => {
|
||||||
|
const view = byId.get(id);
|
||||||
|
if (!view) {
|
||||||
|
throw new NotFoundException(NOT_FOUND_MESSAGE);
|
||||||
|
}
|
||||||
|
return view;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async summary(userId: string): Promise<SuggestionSummary> {
|
||||||
|
const permissions = await this.permissions.getPermissions(userId);
|
||||||
|
const groups = await this.prisma.suggestion.groupBy({
|
||||||
|
by: ['priority'],
|
||||||
|
where: { AND: [this.visibleWhere(permissions), { status: 'OPEN' }] },
|
||||||
|
_count: { _all: true },
|
||||||
|
});
|
||||||
|
const byPriority = Object.fromEntries(
|
||||||
|
PRIORITIES.map((priority) => [priority, 0]),
|
||||||
|
) as Record<TaskPriority, number>;
|
||||||
|
for (const group of groups) {
|
||||||
|
byPriority[group.priority] = group._count._all;
|
||||||
|
}
|
||||||
|
const open = PRIORITIES.reduce((sum, key) => sum + byPriority[key], 0);
|
||||||
|
return { open, byPriority };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 404 for an unknown suggestion and for one the user may not see, so the
|
||||||
|
* existence of a suggestion stays hidden together with its content.
|
||||||
|
*/
|
||||||
|
async assertVisible(userId: string, ids: readonly string[]): Promise<void> {
|
||||||
|
const permissions = await this.permissions.getPermissions(userId);
|
||||||
|
const unique = [...new Set(ids)];
|
||||||
|
const visible = await this.prisma.suggestion.count({
|
||||||
|
where: { AND: [this.visibleWhere(permissions), { id: { in: unique } }] },
|
||||||
|
});
|
||||||
|
if (visible !== unique.length) {
|
||||||
|
throw new NotFoundException(NOT_FOUND_MESSAGE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The database clock decides what "within 30 days" means. */
|
||||||
|
private async statusWhere(
|
||||||
|
status: SuggestionStatus,
|
||||||
|
): Promise<Prisma.SuggestionWhereInput> {
|
||||||
|
if (status !== 'DISMISSED') {
|
||||||
|
return { status };
|
||||||
|
}
|
||||||
|
const [{ cutoff }] = await this.prisma.$queryRaw<{ cutoff: Date }[]>`
|
||||||
|
SELECT now() - make_interval(days => ${DISMISSED_LISTED_DAYS}) AS cutoff`;
|
||||||
|
return { status, decidedAt: { gte: cutoff } };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async toViews(rows: SuggestionRow[]): Promise<SuggestionView[]> {
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const [lookup, rules] = await Promise.all([
|
||||||
|
buildEvidenceLookup(
|
||||||
|
this.prisma,
|
||||||
|
rows.map((row) => row.evidence as SuggestionEvidence),
|
||||||
|
),
|
||||||
|
this.prisma.automationRule.findMany({
|
||||||
|
where: {
|
||||||
|
triggerType: { in: [...new Set(rows.map((row) => row.ruleKey))] },
|
||||||
|
},
|
||||||
|
select: { triggerType: true, actionTemplate: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const templates = new Map(
|
||||||
|
rules.map((rule) => [rule.triggerType, rule.actionTemplate]),
|
||||||
|
);
|
||||||
|
const today = berlinToday();
|
||||||
|
return rows.map((row) => {
|
||||||
|
const text = renderSuggestion(
|
||||||
|
row,
|
||||||
|
lookup,
|
||||||
|
today,
|
||||||
|
templates.get(row.ruleKey),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
ruleKey: row.ruleKey,
|
||||||
|
source: row.source,
|
||||||
|
mode: row.mode,
|
||||||
|
intent: row.intent,
|
||||||
|
status: row.status,
|
||||||
|
priority: row.priority,
|
||||||
|
relatedType: row.relatedType,
|
||||||
|
relatedId: row.relatedId,
|
||||||
|
colonyId: row.colonyId,
|
||||||
|
colonyCode: row.colony?.code ?? null,
|
||||||
|
apiaryId: row.colony?.apiaryId ?? null,
|
||||||
|
apiaryName: row.colony?.apiary.name ?? null,
|
||||||
|
title: text.title,
|
||||||
|
reasons: text.reasons,
|
||||||
|
dueDate: row.dueDate === null ? null : utcDay(row.dueDate),
|
||||||
|
expiresAt: row.expiresAt === null ? null : utcDay(row.expiresAt),
|
||||||
|
plan: planOf(row.plan),
|
||||||
|
decidedAt: row.decidedAt?.toISOString() ?? null,
|
||||||
|
decisionNote: row.decisionNote,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
cleanup: cleanupOf(row),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiCreatedResponse,
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiTags,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
|
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
|
||||||
|
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||||
|
import { Idempotent } from '../common/idempotency/idempotent.decorator';
|
||||||
|
import { AcceptSuggestionDto } from './dto/accept-suggestion.dto';
|
||||||
|
import { DismissSuggestionDto } from './dto/dismiss-suggestion.dto';
|
||||||
|
import { ListSuggestionsQuery } from './dto/list-suggestions.query';
|
||||||
|
import { SuggestionIdsDto } from './dto/suggestion-ids.dto';
|
||||||
|
import {
|
||||||
|
AcceptSuggestionResponseDto,
|
||||||
|
BulkDismissResponseDto,
|
||||||
|
SuggestionSummaryDto,
|
||||||
|
SuggestionViewDto,
|
||||||
|
} from './dto/suggestion-response.dto';
|
||||||
|
import type { AcceptSuggestionResult } from './suggestion.types';
|
||||||
|
import { SuggestionViewsService } from './suggestion-views.service';
|
||||||
|
import { SuggestionsService } from './suggestions.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every decision first checks that the user may see the suggestion (404
|
||||||
|
* otherwise); the state checks, 409 and 400 answers come from the service.
|
||||||
|
* Static routes come before `:id`.
|
||||||
|
*/
|
||||||
|
@ApiTags('suggestions')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('suggestions')
|
||||||
|
export class SuggestionsController {
|
||||||
|
constructor(
|
||||||
|
private readonly views: SuggestionViewsService,
|
||||||
|
private readonly suggestions: SuggestionsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@ApiOkResponse({ type: [SuggestionViewDto] })
|
||||||
|
@RequirePermission('tasks:read')
|
||||||
|
@Get()
|
||||||
|
list(
|
||||||
|
@Query() query: ListSuggestionsQuery,
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.views.list(actor.id, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiOkResponse({ type: SuggestionSummaryDto })
|
||||||
|
@RequirePermission('tasks:read')
|
||||||
|
@Get('summary')
|
||||||
|
summary(@CurrentUser() actor: AuthenticatedUser) {
|
||||||
|
return this.views.summary(actor.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiCreatedResponse({ type: [AcceptSuggestionResponseDto] })
|
||||||
|
@RequirePermission('tasks:create')
|
||||||
|
@Idempotent()
|
||||||
|
@Post('bulk-accept')
|
||||||
|
async bulkAccept(
|
||||||
|
@Body() dto: SuggestionIdsDto,
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
await this.views.assertVisible(actor.id, dto.ids);
|
||||||
|
const results = await this.suggestions.bulkAccept(dto.ids, actor.id);
|
||||||
|
return this.withViews(actor.id, results);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiCreatedResponse({ type: BulkDismissResponseDto })
|
||||||
|
@RequirePermission('tasks:update')
|
||||||
|
@Idempotent()
|
||||||
|
@Post('bulk-dismiss')
|
||||||
|
async bulkDismiss(
|
||||||
|
@Body() dto: SuggestionIdsDto,
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
await this.views.assertVisible(actor.id, dto.ids);
|
||||||
|
return {
|
||||||
|
dismissed: await this.suggestions.bulkDismiss(dto.ids, actor.id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiCreatedResponse({ type: AcceptSuggestionResponseDto })
|
||||||
|
@RequirePermission('tasks:create')
|
||||||
|
@Idempotent()
|
||||||
|
@Post(':id/accept')
|
||||||
|
async accept(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: AcceptSuggestionDto,
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
await this.views.assertVisible(actor.id, [id]);
|
||||||
|
const result = await this.suggestions.accept(id, dto, actor.id);
|
||||||
|
const [accepted] = await this.withViews(actor.id, [result]);
|
||||||
|
return accepted;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiCreatedResponse({ type: SuggestionViewDto })
|
||||||
|
@RequirePermission('tasks:update')
|
||||||
|
@Idempotent()
|
||||||
|
@Post(':id/dismiss')
|
||||||
|
async dismiss(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: DismissSuggestionDto,
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
await this.views.assertVisible(actor.id, [id]);
|
||||||
|
await this.suggestions.dismiss(id, dto.note ?? null, actor.id);
|
||||||
|
return this.views.getOne(actor.id, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiCreatedResponse({ type: SuggestionViewDto })
|
||||||
|
@RequirePermission('tasks:update')
|
||||||
|
@Idempotent()
|
||||||
|
@Post(':id/reopen')
|
||||||
|
async reopen(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
await this.views.assertVisible(actor.id, [id]);
|
||||||
|
await this.suggestions.reopen(id, actor.id);
|
||||||
|
return this.views.getOne(actor.id, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The accept results in the app's shape: rendered suggestion + tasks. */
|
||||||
|
private async withViews(
|
||||||
|
userId: string,
|
||||||
|
results: readonly AcceptSuggestionResult[],
|
||||||
|
) {
|
||||||
|
const views = await this.views.getMany(
|
||||||
|
userId,
|
||||||
|
results.map((result) => result.suggestionId),
|
||||||
|
);
|
||||||
|
return results.map((result, index) => ({
|
||||||
|
suggestion: views[index],
|
||||||
|
tasks: result.tasks,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AuditModule } from '../audit/audit.module';
|
import { AuditModule } from '../audit/audit.module';
|
||||||
import { TasksModule } from '../tasks/tasks.module';
|
import { TasksModule } from '../tasks/tasks.module';
|
||||||
|
import { SuggestionViewsService } from './suggestion-views.service';
|
||||||
|
import { SuggestionsController } from './suggestions.controller';
|
||||||
import { SuggestionsService } from './suggestions.service';
|
import { SuggestionsService } from './suggestions.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -9,7 +11,8 @@ import { SuggestionsService } from './suggestions.service';
|
|||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TasksModule, AuditModule],
|
imports: [TasksModule, AuditModule],
|
||||||
providers: [SuggestionsService],
|
controllers: [SuggestionsController],
|
||||||
exports: [SuggestionsService],
|
providers: [SuggestionsService, SuggestionViewsService],
|
||||||
|
exports: [SuggestionsService, SuggestionViewsService],
|
||||||
})
|
})
|
||||||
export class SuggestionsModule {}
|
export class SuggestionsModule {}
|
||||||
@@ -59,7 +59,7 @@ describe('JobRunService', () => {
|
|||||||
$executeRaw: jest.fn<Promise<number>, RawCall>().mockResolvedValue(1),
|
$executeRaw: jest.fn<Promise<number>, RawCall>().mockResolvedValue(1),
|
||||||
$queryRaw: jest
|
$queryRaw: jest
|
||||||
.fn<Promise<unknown>, RawCall>()
|
.fn<Promise<unknown>, RawCall>()
|
||||||
.mockResolvedValue([{ notified: false }]),
|
.mockResolvedValue([{ notified: false, day: '2026-10-13' }]),
|
||||||
};
|
};
|
||||||
prisma = {
|
prisma = {
|
||||||
jobRun,
|
jobRun,
|
||||||
@@ -71,7 +71,9 @@ describe('JobRunService', () => {
|
|||||||
Promise.resolve([runRow({ status: status as JobRun['status'] })]),
|
Promise.resolve([runRow({ status: status as JobRun['status'] })]),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
notifications = { recordForPermission: jest.fn().mockResolvedValue(2) };
|
notifications = {
|
||||||
|
recordForPermission: jest.fn().mockResolvedValue([{ id: 'n1' }]),
|
||||||
|
};
|
||||||
service = new JobRunService(
|
service = new JobRunService(
|
||||||
prisma as unknown as PrismaService,
|
prisma as unknown as PrismaService,
|
||||||
notifications as unknown as NotificationsService,
|
notifications as unknown as NotificationsService,
|
||||||
@@ -177,9 +179,11 @@ describe('JobRunService', () => {
|
|||||||
'automation:update',
|
'automation:update',
|
||||||
{
|
{
|
||||||
priority: 'WARNUNG',
|
priority: 'WARNUNG',
|
||||||
|
category: 'SYSTEM',
|
||||||
message: 'Job „Tägliche Auswertung“ fehlgeschlagen',
|
message: 'Job „Tägliche Auswertung“ fehlgeschlagen',
|
||||||
relatedType: 'JobRun',
|
relatedType: 'JobRun',
|
||||||
relatedId: 'run-1',
|
relatedId: 'run-1',
|
||||||
|
dedupeKeyPrefix: 'job-failed:daily-evaluation:2026-10-13',
|
||||||
},
|
},
|
||||||
tx,
|
tx,
|
||||||
);
|
);
|
||||||
@@ -188,7 +192,7 @@ describe('JobRunService', () => {
|
|||||||
// The hourly app-backup check would otherwise report one outage every
|
// The hourly app-backup check would otherwise report one outage every
|
||||||
// hour. The check and the write share a transaction behind a lock per
|
// hour. The check and the write share a transaction behind a lock per
|
||||||
// job, so two failing runs at once cannot both pass the check.
|
// job, so two failing runs at once cannot both pass the check.
|
||||||
it('notifies at most once per job and UTC day, decided by the database clock', async () => {
|
it('notifies at most once per job and Berlin day, decided by the database clock', async () => {
|
||||||
tx.$queryRaw.mockResolvedValue([{ notified: true }]);
|
tx.$queryRaw.mockResolvedValue([{ notified: true }]);
|
||||||
|
|
||||||
await service.run('app-backup', () => Promise.reject(new Error('boom')));
|
await service.run('app-backup', () => Promise.reject(new Error('boom')));
|
||||||
@@ -198,7 +202,13 @@ describe('JobRunService', () => {
|
|||||||
expect(sqlText(lock)).toContain('pg_advisory_xact_lock');
|
expect(sqlText(lock)).toContain('pg_advisory_xact_lock');
|
||||||
expect(lock.slice(1)).toEqual(['job-failure-notification:app-backup']);
|
expect(lock.slice(1)).toEqual(['job-failure-notification:app-backup']);
|
||||||
const check = tx.$queryRaw.mock.calls[0];
|
const check = tx.$queryRaw.mock.calls[0];
|
||||||
expect(sqlText(check)).toContain(`date_trunc('day', now())`);
|
expect(sqlText(check)).toContain(
|
||||||
|
`date_trunc('day', now() AT TIME ZONE 'Europe/Berlin')`,
|
||||||
|
);
|
||||||
|
// The key carries the same day the check uses.
|
||||||
|
expect(sqlText(check)).toContain(
|
||||||
|
`to_char(now() AT TIME ZONE 'Europe/Berlin'`,
|
||||||
|
);
|
||||||
expect(check.slice(1)).toEqual(['JobRun', 'app-backup']);
|
expect(check.slice(1)).toEqual(['JobRun', 'app-backup']);
|
||||||
expect(tx.$executeRaw.mock.invocationCallOrder[0]).toBeLessThan(
|
expect(tx.$executeRaw.mock.invocationCallOrder[0]).toBeLessThan(
|
||||||
tx.$queryRaw.mock.invocationCallOrder[0],
|
tx.$queryRaw.mock.invocationCallOrder[0],
|
||||||
|
|||||||
@@ -162,24 +162,29 @@ export class JobRunService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* At most one notification per job and UTC day (database clock): the
|
* At most one notification per job and Berlin calendar day (database clock,
|
||||||
|
* the day logic of the rest of the app; the same day is in the dedupe key): the
|
||||||
* hourly app-backup check would otherwise report the same outage every
|
* hourly app-backup check would otherwise report the same outage every
|
||||||
* hour. The advisory lock per job serialises check and write, so two
|
* hour. The advisory lock per job serialises check and write, so two
|
||||||
* runs failing at once cannot both pass the check. A notification that
|
* runs failing at once cannot both pass the check; the dedupe key per
|
||||||
|
* recipient is a second guard. A notification that
|
||||||
* cannot be written is logged; the run stays FAILED either way.
|
* cannot be written is logged; the run stays FAILED either way.
|
||||||
*/
|
*/
|
||||||
private async notifyFailure(job: JobName, runId: string): Promise<void> {
|
private async notifyFailure(job: JobName, runId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.prisma.$transaction(async (tx) => {
|
await this.prisma.$transaction(async (tx) => {
|
||||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${`job-failure-notification:${job}`}, 0))`;
|
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${`job-failure-notification:${job}`}, 0))`;
|
||||||
const [{ notified }] = await tx.$queryRaw<{ notified: boolean }[]>`
|
const [{ notified, day }] = await tx.$queryRaw<
|
||||||
|
{ notified: boolean; day: string }[]
|
||||||
|
>`
|
||||||
SELECT EXISTS (
|
SELECT EXISTS (
|
||||||
SELECT 1 FROM "Notification" n
|
SELECT 1 FROM "Notification" n
|
||||||
JOIN "JobRun" r ON r."id" = n."relatedId"
|
JOIN "JobRun" r ON r."id" = n."relatedId"
|
||||||
WHERE n."relatedType" = ${JOB_RUN_RELATED_TYPE}
|
WHERE n."relatedType" = ${JOB_RUN_RELATED_TYPE}
|
||||||
AND r."job" = ${job}
|
AND r."job" = ${job}
|
||||||
AND n."createdAt" >= date_trunc('day', now())
|
AND n."createdAt" >= date_trunc('day', now() AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'Europe/Berlin'
|
||||||
) AS "notified"`;
|
) AS "notified",
|
||||||
|
to_char(now() AT TIME ZONE 'Europe/Berlin', 'YYYY-MM-DD') AS "day"`;
|
||||||
if (notified) {
|
if (notified) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -187,9 +192,11 @@ export class JobRunService {
|
|||||||
JOB_FAILURE_PERMISSION[job],
|
JOB_FAILURE_PERMISSION[job],
|
||||||
{
|
{
|
||||||
priority: 'WARNUNG',
|
priority: 'WARNUNG',
|
||||||
|
category: 'SYSTEM',
|
||||||
message: `Job „${JOB_LABELS_DE[job]}“ fehlgeschlagen`,
|
message: `Job „${JOB_LABELS_DE[job]}“ fehlgeschlagen`,
|
||||||
relatedType: JOB_RUN_RELATED_TYPE,
|
relatedType: JOB_RUN_RELATED_TYPE,
|
||||||
relatedId: runId,
|
relatedId: runId,
|
||||||
|
dedupeKeyPrefix: `job-failed:${job}:${day}`,
|
||||||
},
|
},
|
||||||
tx,
|
tx,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { DbClient } from '../prisma/db-client';
|
||||||
|
import { treatmentWindowsIn } from './treatment-windows';
|
||||||
|
|
||||||
|
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
|
||||||
|
|
||||||
|
describe('treatmentWindowsIn', () => {
|
||||||
|
it('maps every treatment of the colony to a window and ignores other colonies', async () => {
|
||||||
|
const rows = [
|
||||||
|
{
|
||||||
|
colonyId: 'a',
|
||||||
|
date: d('2026-07-20'),
|
||||||
|
startDate: null,
|
||||||
|
endDate: d('2026-08-02'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
colonyId: 'a',
|
||||||
|
date: d('2026-09-01'),
|
||||||
|
startDate: d('2026-08-28'),
|
||||||
|
endDate: null,
|
||||||
|
},
|
||||||
|
{ colonyId: 'b', date: d('2026-06-01'), startDate: null, endDate: null },
|
||||||
|
];
|
||||||
|
const findMany = jest.fn(({ where }: { where: { colonyId: string } }) =>
|
||||||
|
Promise.resolve(rows.filter((row) => row.colonyId === where.colonyId)),
|
||||||
|
);
|
||||||
|
const db = { treatment: { findMany } } as unknown as DbClient;
|
||||||
|
|
||||||
|
await expect(treatmentWindowsIn(db, 'a')).resolves.toEqual([
|
||||||
|
{ startDay: '2026-07-20', endDay: '2026-08-02' },
|
||||||
|
{ startDay: '2026-08-28', endDay: '2026-09-01' },
|
||||||
|
]);
|
||||||
|
expect(findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ where: { colonyId: 'a' } }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { DbClient } from '../prisma/db-client';
|
||||||
|
import {
|
||||||
|
treatmentWindowOf,
|
||||||
|
type TreatmentWindowLite,
|
||||||
|
} from './withdrawal-window';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The treatment windows of one colony (all treatments, oldest first). A
|
||||||
|
* function rather than a service so that callers need no module import.
|
||||||
|
*/
|
||||||
|
export async function treatmentWindowsIn(
|
||||||
|
db: DbClient,
|
||||||
|
colonyId: string,
|
||||||
|
): Promise<TreatmentWindowLite[]> {
|
||||||
|
const treatments = await db.treatment.findMany({
|
||||||
|
where: { colonyId },
|
||||||
|
select: { date: true, startDate: true, endDate: true },
|
||||||
|
orderBy: { date: 'asc' },
|
||||||
|
});
|
||||||
|
return treatments.map(treatmentWindowOf);
|
||||||
|
}
|
||||||
@@ -504,6 +504,28 @@ describe('TreatmentsService', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('counts the harvest day in Berlin time: 00:30 on the end day is outside', async () => {
|
||||||
|
prisma.treatment.findMany.mockResolvedValue([endedFirstOfJuly]);
|
||||||
|
|
||||||
|
const result = await service.getWithdrawalStatus(
|
||||||
|
['colony-1'],
|
||||||
|
new Date('2026-07-15T00:30:00+02:00'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is still active at 23:30 Berlin time the day before the end', async () => {
|
||||||
|
prisma.treatment.findMany.mockResolvedValue([endedFirstOfJuly]);
|
||||||
|
|
||||||
|
const result = await service.getWithdrawalStatus(
|
||||||
|
['colony-1'],
|
||||||
|
new Date('2026-07-14T23:30:00+02:00'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('omits a window that has ended by that date', async () => {
|
it('omits a window that has ended by that date', async () => {
|
||||||
prisma.treatment.findMany.mockResolvedValue([endedFirstOfJuly]);
|
prisma.treatment.findMany.mockResolvedValue([endedFirstOfJuly]);
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ import {
|
|||||||
} from '../common/bulk/bulk-create';
|
} from '../common/bulk/bulk-create';
|
||||||
import { runBulk } from '../common/bulk/run-bulk';
|
import { runBulk } from '../common/bulk/run-bulk';
|
||||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||||
|
import { berlinDayOf, dayToDate } from '../common/dates';
|
||||||
|
import {
|
||||||
|
isInWindow,
|
||||||
|
withdrawalWindowOf,
|
||||||
|
type WithdrawalWindow,
|
||||||
|
} from './withdrawal-window';
|
||||||
import { CreateTreatmentDto } from './dto/create-treatment.dto';
|
import { CreateTreatmentDto } from './dto/create-treatment.dto';
|
||||||
import { BulkCreateTreatmentDto } from './dto/bulk-create-treatment.dto';
|
import { BulkCreateTreatmentDto } from './dto/bulk-create-treatment.dto';
|
||||||
import { UpdateTreatmentDto } from './dto/update-treatment.dto';
|
import { UpdateTreatmentDto } from './dto/update-treatment.dto';
|
||||||
@@ -24,16 +30,6 @@ export interface WithdrawalStatus {
|
|||||||
product: string;
|
product: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// When a treatment began: the earlier of its date and the start of a
|
|
||||||
// multi-day treatment - the earlier one errs on the side of withdrawal.
|
|
||||||
function treatmentStart(
|
|
||||||
treatment: Pick<Treatment, 'date' | 'startDate'>,
|
|
||||||
): Date {
|
|
||||||
return treatment.startDate && treatment.startDate < treatment.date
|
|
||||||
? treatment.startDate
|
|
||||||
: treatment.date;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The row a new treatment is stored as - for create and createBulk alike, so
|
// The row a new treatment is stored as - for create and createBulk alike, so
|
||||||
// a bulk entry stores exactly what single entries would. Where the colony
|
// a bulk entry stores exactly what single entries would. Where the colony
|
||||||
// stands today is where it was treated: the apiary is kept on the treatment
|
// stands today is where it was treated: the apiary is kept on the treatment
|
||||||
@@ -105,29 +101,23 @@ export class TreatmentsService {
|
|||||||
withdrawalPeriodDays: { not: null },
|
withdrawalPeriodDays: { not: null },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const activeByColony = new Map<string, WithdrawalStatus>();
|
const harvestDay = berlinDayOf(at);
|
||||||
|
const activeByColony = new Map<string, WithdrawalWindow>();
|
||||||
for (const treatment of treatments) {
|
for (const treatment of treatments) {
|
||||||
if (treatmentStart(treatment) > at) {
|
const window = withdrawalWindowOf(treatment);
|
||||||
continue;
|
if (!window || !isInWindow(window, harvestDay)) {
|
||||||
}
|
|
||||||
const referenceDate = treatment.endDate ?? treatment.date;
|
|
||||||
const activeUntil = new Date(referenceDate);
|
|
||||||
activeUntil.setUTCDate(
|
|
||||||
activeUntil.getUTCDate() + (treatment.withdrawalPeriodDays ?? 0),
|
|
||||||
);
|
|
||||||
if (activeUntil <= at) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const current = activeByColony.get(treatment.colonyId);
|
const current = activeByColony.get(treatment.colonyId);
|
||||||
if (!current || activeUntil > new Date(current.activeUntil)) {
|
if (!current || window.untilDay > current.untilDay) {
|
||||||
activeByColony.set(treatment.colonyId, {
|
activeByColony.set(treatment.colonyId, window);
|
||||||
colonyId: treatment.colonyId,
|
|
||||||
activeUntil: activeUntil.toISOString(),
|
|
||||||
product: treatment.product,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return [...activeByColony.values()];
|
return [...activeByColony.values()].map((window) => ({
|
||||||
|
colonyId: window.colonyId,
|
||||||
|
activeUntil: dayToDate(window.untilDay).toISOString(),
|
||||||
|
product: window.product,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(
|
async create(
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { withdrawalEndDay } from '../jobs/daily-evaluation.rules';
|
||||||
|
import {
|
||||||
|
isInWindow,
|
||||||
|
treatmentWindowOf,
|
||||||
|
withdrawalWindowOf,
|
||||||
|
} from './withdrawal-window';
|
||||||
|
|
||||||
|
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
|
||||||
|
|
||||||
|
describe('withdrawal-window', () => {
|
||||||
|
describe('withdrawalWindowOf', () => {
|
||||||
|
it('runs from the treatment start to (endDate ?? date) + days, exclusive', () =>
|
||||||
|
expect(
|
||||||
|
withdrawalWindowOf({
|
||||||
|
id: 't',
|
||||||
|
colonyId: 'c',
|
||||||
|
product: 'Oxuvar',
|
||||||
|
date: d('2026-09-28'),
|
||||||
|
startDate: null,
|
||||||
|
endDate: d('2026-10-01'),
|
||||||
|
withdrawalPeriodDays: 14,
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
treatmentId: 't',
|
||||||
|
colonyId: 'c',
|
||||||
|
product: 'Oxuvar',
|
||||||
|
fromDay: '2026-09-28',
|
||||||
|
untilDay: '2026-10-15',
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('starts at startDate when it lies before date', () =>
|
||||||
|
expect(
|
||||||
|
withdrawalWindowOf({
|
||||||
|
id: 't',
|
||||||
|
colonyId: 'c',
|
||||||
|
product: 'Oxuvar',
|
||||||
|
date: d('2026-07-20'),
|
||||||
|
startDate: d('2026-07-10'),
|
||||||
|
endDate: d('2026-07-20'),
|
||||||
|
withdrawalPeriodDays: 7,
|
||||||
|
}),
|
||||||
|
).toMatchObject({ fromDay: '2026-07-10', untilDay: '2026-07-27' }));
|
||||||
|
|
||||||
|
it('ignores a startDate after date', () =>
|
||||||
|
expect(
|
||||||
|
withdrawalWindowOf({
|
||||||
|
id: 't',
|
||||||
|
colonyId: 'c',
|
||||||
|
product: 'p',
|
||||||
|
date: d('2026-07-20'),
|
||||||
|
startDate: d('2026-07-25'),
|
||||||
|
endDate: null,
|
||||||
|
withdrawalPeriodDays: 1,
|
||||||
|
}),
|
||||||
|
).toMatchObject({ fromDay: '2026-07-20', untilDay: '2026-07-21' }));
|
||||||
|
|
||||||
|
it('has no window without withdrawalPeriodDays', () =>
|
||||||
|
expect(
|
||||||
|
withdrawalWindowOf({
|
||||||
|
id: 't',
|
||||||
|
colonyId: 'c',
|
||||||
|
product: 'p',
|
||||||
|
date: d('2026-07-20'),
|
||||||
|
startDate: null,
|
||||||
|
endDate: null,
|
||||||
|
withdrawalPeriodDays: null,
|
||||||
|
}),
|
||||||
|
).toBeNull());
|
||||||
|
|
||||||
|
it('is inactive on untilDay and active on fromDay', () => {
|
||||||
|
const window = withdrawalWindowOf({
|
||||||
|
id: 't',
|
||||||
|
colonyId: 'c',
|
||||||
|
product: 'p',
|
||||||
|
date: d('2026-07-20'),
|
||||||
|
startDate: d('2026-07-10'),
|
||||||
|
endDate: d('2026-07-20'),
|
||||||
|
withdrawalPeriodDays: 7,
|
||||||
|
});
|
||||||
|
expect(window).toMatchObject({
|
||||||
|
fromDay: '2026-07-10',
|
||||||
|
untilDay: '2026-07-27',
|
||||||
|
});
|
||||||
|
expect(window && isInWindow(window, '2026-07-10')).toBe(true);
|
||||||
|
expect(window && isInWindow(window, '2026-07-26')).toBe(true);
|
||||||
|
expect(window && isInWindow(window, '2026-07-27')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('withdrawalEndDay delivers the window end', () =>
|
||||||
|
expect(
|
||||||
|
withdrawalEndDay({
|
||||||
|
date: d('2026-09-28'),
|
||||||
|
endDate: d('2026-10-01'),
|
||||||
|
withdrawalPeriodDays: 14,
|
||||||
|
}),
|
||||||
|
).toBe('2026-10-15'));
|
||||||
|
|
||||||
|
it('counts days in Berlin time', () =>
|
||||||
|
expect(
|
||||||
|
withdrawalWindowOf({
|
||||||
|
id: 't',
|
||||||
|
colonyId: 'c',
|
||||||
|
product: 'p',
|
||||||
|
date: new Date('2026-07-01T00:30:00+02:00'),
|
||||||
|
startDate: null,
|
||||||
|
endDate: null,
|
||||||
|
withdrawalPeriodDays: 1,
|
||||||
|
}),
|
||||||
|
).toMatchObject({ fromDay: '2026-07-01', untilDay: '2026-07-02' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isInWindow', () => {
|
||||||
|
const window = {
|
||||||
|
treatmentId: 't',
|
||||||
|
colonyId: 'c',
|
||||||
|
product: 'p',
|
||||||
|
fromDay: '2026-07-10',
|
||||||
|
untilDay: '2026-07-15',
|
||||||
|
};
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['2026-07-09', false],
|
||||||
|
['2026-07-10', true],
|
||||||
|
['2026-07-14', true],
|
||||||
|
['2026-07-15', false],
|
||||||
|
])('%s -> %p', (day, expected) =>
|
||||||
|
expect(isInWindow(window, day)).toBe(expected),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('treatmentWindowOf', () => {
|
||||||
|
it('starts at startDate when it is before date and ends at endDate ?? date', () => {
|
||||||
|
expect(
|
||||||
|
treatmentWindowOf({
|
||||||
|
date: d('2026-07-20'),
|
||||||
|
startDate: d('2026-07-10'),
|
||||||
|
endDate: null,
|
||||||
|
}),
|
||||||
|
).toEqual({ startDay: '2026-07-10', endDay: '2026-07-20' });
|
||||||
|
expect(
|
||||||
|
treatmentWindowOf({
|
||||||
|
date: d('2026-07-20'),
|
||||||
|
startDate: d('2026-07-25'),
|
||||||
|
endDate: d('2026-08-02'),
|
||||||
|
}),
|
||||||
|
).toEqual({ startDay: '2026-07-20', endDay: '2026-08-02' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import type { Treatment } from '../../generated/prisma';
|
||||||
|
import { addDays, berlinDayOf } from '../common/dates';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one place that turns a treatment into days: the withdrawal period
|
||||||
|
* (Wartezeit) and the varroa treatment effect both read it, so there is no
|
||||||
|
* second window calculation. Pure, no database. All days are 'YYYY-MM-DD'
|
||||||
|
* (Berlin calendar day of the stored DateTime).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const dayOf = berlinDayOf;
|
||||||
|
|
||||||
|
/** When a treatment began: startDate if it lies before date, otherwise date. */
|
||||||
|
function startDayOf(t: Pick<Treatment, 'date' | 'startDate'>): string {
|
||||||
|
const date = dayOf(t.date);
|
||||||
|
const start = t.startDate ? dayOf(t.startDate) : null;
|
||||||
|
return start !== null && start < date ? start : date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** When a treatment ended: endDate, or date for a single-day treatment. */
|
||||||
|
function endDayOf(t: Pick<Treatment, 'date' | 'endDate'>): string {
|
||||||
|
return dayOf(t.endDate ?? t.date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TreatmentWindowLite {
|
||||||
|
startDay: string;
|
||||||
|
endDay: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function treatmentWindowOf(
|
||||||
|
t: Pick<Treatment, 'date' | 'startDate' | 'endDate'>,
|
||||||
|
): TreatmentWindowLite {
|
||||||
|
return { startDay: startDayOf(t), endDay: endDayOf(t) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WithdrawalWindow {
|
||||||
|
treatmentId: string;
|
||||||
|
colonyId: string;
|
||||||
|
product: string;
|
||||||
|
fromDay: string;
|
||||||
|
/** Exclusive: the first day honey may be harvested again. */
|
||||||
|
untilDay: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First harvestable day, null without a withdrawal period. */
|
||||||
|
export function withdrawalUntilDay(
|
||||||
|
t: Pick<Treatment, 'date' | 'endDate' | 'withdrawalPeriodDays'>,
|
||||||
|
): string | null {
|
||||||
|
return t.withdrawalPeriodDays === null
|
||||||
|
? null
|
||||||
|
: addDays(endDayOf(t), t.withdrawalPeriodDays);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withdrawalWindowOf(
|
||||||
|
t: Pick<
|
||||||
|
Treatment,
|
||||||
|
| 'id'
|
||||||
|
| 'colonyId'
|
||||||
|
| 'product'
|
||||||
|
| 'date'
|
||||||
|
| 'startDate'
|
||||||
|
| 'endDate'
|
||||||
|
| 'withdrawalPeriodDays'
|
||||||
|
>,
|
||||||
|
): WithdrawalWindow | null {
|
||||||
|
const untilDay = withdrawalUntilDay(t);
|
||||||
|
if (untilDay === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
treatmentId: t.id,
|
||||||
|
colonyId: t.colonyId,
|
||||||
|
product: t.product,
|
||||||
|
fromDay: startDayOf(t),
|
||||||
|
untilDay,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** fromDay <= day < untilDay */
|
||||||
|
export function isInWindow(window: WithdrawalWindow, day: string): boolean {
|
||||||
|
return window.fromDay <= day && day < window.untilDay;
|
||||||
|
}
|
||||||
@@ -1,17 +1,25 @@
|
|||||||
import { Controller, Get, Param } from '@nestjs/common';
|
import { Controller, Get, Param } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
|
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
|
||||||
|
import { VarroaBandsService } from './varroa-bands.service';
|
||||||
import { VarroaService } from './varroa.service';
|
import { VarroaService } from './varroa.service';
|
||||||
|
|
||||||
@ApiTags('varroa-measurements')
|
@ApiTags('varroa-measurements')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Controller('colonies')
|
@Controller('colonies')
|
||||||
export class ColonyVarroaMeasurementsController {
|
export class ColonyVarroaMeasurementsController {
|
||||||
constructor(private readonly varroaService: VarroaService) {}
|
constructor(
|
||||||
|
private readonly varroaService: VarroaService,
|
||||||
|
private readonly varroaBandsService: VarroaBandsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@RequirePermission('varroa:read')
|
@RequirePermission('varroa:read')
|
||||||
@Get(':id/varroa-measurements')
|
@Get(':id/varroa-measurements')
|
||||||
findByColony(@Param('id') id: string) {
|
/** The measurements of the colony, each with its rating (light, band, reason). */
|
||||||
return this.varroaService.findByColony(id);
|
async findByColony(@Param('id') id: string) {
|
||||||
|
return this.varroaBandsService.rate(
|
||||||
|
id,
|
||||||
|
await this.varroaService.findByColony(id),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
|
Max,
|
||||||
Min,
|
Min,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { VarroaMethod } from '../../../generated/prisma';
|
import { VarroaMethod } from '../../../generated/prisma';
|
||||||
@@ -35,6 +36,18 @@ export class CreateVarroaMeasurementDto {
|
|||||||
@Min(0)
|
@Min(0)
|
||||||
miteCount?: number;
|
miteCount?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'Sample size in bees (powdered sugar, washing); the percentage is mites / sample * 100.',
|
||||||
|
minimum: 1,
|
||||||
|
maximum: 2000,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(2000)
|
||||||
|
sampleBees?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsOptional, IsString, Matches, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
|
const MAX_ID_LENGTH = 64;
|
||||||
|
const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /varroa-measurements/thresholds. The date is only shape-checked here;
|
||||||
|
* an impossible day (2026-02-30) is rejected by the service.
|
||||||
|
*/
|
||||||
|
export class VarroaThresholdsQuery {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Volk, für dessen Typ die Bänder gelten',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(MAX_ID_LENGTH)
|
||||||
|
@Matches(/^[A-Za-z0-9_-]+$/, { message: 'colonyId ist ungültig' })
|
||||||
|
colonyId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Messtag (JJJJ-MM-TT), ohne Angabe heute (Berlin)',
|
||||||
|
example: '2026-08-10',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Matches(DAY_PATTERN, { message: 'date muss ein Tag (JJJJ-MM-TT) sein' })
|
||||||
|
date?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { VarroaBandsService } from './varroa-bands.service';
|
||||||
|
|
||||||
|
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
|
||||||
|
|
||||||
|
describe('VarroaBandsService', () => {
|
||||||
|
let service: VarroaBandsService;
|
||||||
|
let prisma: {
|
||||||
|
automationRule: { findUnique: jest.Mock };
|
||||||
|
colony: { findUnique: jest.Mock };
|
||||||
|
treatment: { findMany: jest.Mock };
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
prisma = {
|
||||||
|
// No row: the built-in (active) rule applies.
|
||||||
|
automationRule: { findUnique: jest.fn().mockResolvedValue(null) },
|
||||||
|
colony: { findUnique: jest.fn() },
|
||||||
|
treatment: { findMany: jest.fn().mockResolvedValue([]) },
|
||||||
|
};
|
||||||
|
service = new VarroaBandsService(prisma as unknown as PrismaService);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('thresholds', () => {
|
||||||
|
it('halves the natural mite fall bands for a young colony', async () => {
|
||||||
|
prisma.colony.findUnique.mockResolvedValue({
|
||||||
|
colonyType: 'YOUNG_COLONY',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.thresholds({
|
||||||
|
colonyId: 'c1',
|
||||||
|
date: '2026-06-10',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.active).toBe(true);
|
||||||
|
expect(result.bands.NATURAL_MITE_FALL).toMatchObject({
|
||||||
|
yellowFrom: 2.5,
|
||||||
|
redAbove: 5,
|
||||||
|
youngColony: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns no powdered sugar band in April', async () => {
|
||||||
|
const result = await service.thresholds({ date: '2026-04-10' });
|
||||||
|
|
||||||
|
expect(result.bands.POWDERED_SUGAR).toBeUndefined();
|
||||||
|
expect(result.bands.NATURAL_MITE_FALL).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the recheck day while a treatment of the colony is in effect', async () => {
|
||||||
|
prisma.colony.findUnique.mockResolvedValue({
|
||||||
|
colonyType: 'PRODUCTION_COLONY',
|
||||||
|
});
|
||||||
|
prisma.treatment.findMany.mockResolvedValue([
|
||||||
|
{ date: d('2026-08-02'), startDate: d('2026-07-20'), endDate: null },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await service.thresholds({
|
||||||
|
colonyId: 'c1',
|
||||||
|
date: '2026-08-07',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.postTreatmentUntil).toBe('2026-08-16');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns no bands while the rule is switched off', async () => {
|
||||||
|
prisma.automationRule.findUnique.mockResolvedValue({
|
||||||
|
id: 'r',
|
||||||
|
active: false,
|
||||||
|
config: {},
|
||||||
|
actionTemplate: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.thresholds({ date: '2026-08-10' })).resolves.toEqual(
|
||||||
|
{
|
||||||
|
day: '2026-08-10',
|
||||||
|
bands: {},
|
||||||
|
postTreatmentUntil: null,
|
||||||
|
active: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an unknown colony with 404', async () => {
|
||||||
|
prisma.colony.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.thresholds({ colonyId: 'missing' }),
|
||||||
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['2026-02-30', '2026-13-01', '10.08.2026', 'x'])(
|
||||||
|
'rejects the invalid date %s with 400',
|
||||||
|
async (date) => {
|
||||||
|
await expect(service.thresholds({ date })).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rate', () => {
|
||||||
|
const measurement = {
|
||||||
|
id: 'm1',
|
||||||
|
colonyId: 'c1',
|
||||||
|
date: d('2026-08-20'),
|
||||||
|
method: 'NATURAL_MITE_FALL' as const,
|
||||||
|
calculatedValue: 12,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('adds the rating of every measurement', async () => {
|
||||||
|
prisma.colony.findUnique.mockResolvedValue({
|
||||||
|
colonyType: 'PRODUCTION_COLONY',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [rated] = await service.rate('c1', [measurement]);
|
||||||
|
|
||||||
|
expect(rated).toMatchObject({ id: 'm1' });
|
||||||
|
expect(rated.rating).toMatchObject({
|
||||||
|
light: 'red',
|
||||||
|
reasonCode: 'NATURAL_FALL_SEASON',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rates nothing while the rule is switched off', async () => {
|
||||||
|
prisma.automationRule.findUnique.mockResolvedValue({
|
||||||
|
id: 'r',
|
||||||
|
active: false,
|
||||||
|
config: {},
|
||||||
|
actionTemplate: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [rated] = await service.rate('c1', [measurement]);
|
||||||
|
|
||||||
|
expect(rated.rating).toEqual({
|
||||||
|
light: null,
|
||||||
|
reasonCode: null,
|
||||||
|
band: null,
|
||||||
|
postTreatmentUntil: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not read the colony for an empty list', async () => {
|
||||||
|
await expect(service.rate('c1', [])).resolves.toEqual([]);
|
||||||
|
expect(prisma.colony.findUnique).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import type { ColonyType, VarroaMeasurement } from '../../generated/prisma';
|
||||||
|
import { readRule } from '../automation/rule-config';
|
||||||
|
import {
|
||||||
|
bandsFor,
|
||||||
|
rateMeasurement,
|
||||||
|
type VarroaBands,
|
||||||
|
type VarroaRating,
|
||||||
|
} from '../automation/varroa-bands';
|
||||||
|
import { berlinToday, dayToDate } from '../common/dates';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { treatmentWindowsIn } from '../treatments/treatment-windows';
|
||||||
|
|
||||||
|
export interface VarroaThresholds extends VarroaBands {
|
||||||
|
/** false while the VARROA_THRESHOLD rule is switched off: no bands. */
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RatableMeasurement = Pick<
|
||||||
|
VarroaMeasurement,
|
||||||
|
'date' | 'method' | 'calculatedValue'
|
||||||
|
>;
|
||||||
|
|
||||||
|
const NO_RATING: VarroaRating = {
|
||||||
|
light: null,
|
||||||
|
reasonCode: null,
|
||||||
|
band: null,
|
||||||
|
postTreatmentUntil: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function validDay(day: string): string {
|
||||||
|
try {
|
||||||
|
dayToDate(day);
|
||||||
|
} catch {
|
||||||
|
throw new BadRequestException('Bitte einen gültigen Tag angeben.');
|
||||||
|
}
|
||||||
|
return day;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The varroa bands (green/yellow/red limits) of the VARROA_THRESHOLD rule for
|
||||||
|
* a day and colony, and the rating of measurements against them.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class VarroaBandsService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async thresholds(query: {
|
||||||
|
colonyId?: string;
|
||||||
|
date?: string;
|
||||||
|
}): Promise<VarroaThresholds> {
|
||||||
|
const day = validDay(query.date ?? berlinToday());
|
||||||
|
const rule = await readRule(this.prisma, 'VARROA_THRESHOLD');
|
||||||
|
const colonyType =
|
||||||
|
query.colonyId === undefined
|
||||||
|
? null
|
||||||
|
: await this.colonyTypeOf(query.colonyId);
|
||||||
|
if (!rule.active) {
|
||||||
|
return { day, bands: {}, postTreatmentUntil: null, active: false };
|
||||||
|
}
|
||||||
|
const treatments =
|
||||||
|
query.colonyId === undefined
|
||||||
|
? []
|
||||||
|
: await treatmentWindowsIn(this.prisma, query.colonyId);
|
||||||
|
return {
|
||||||
|
...bandsFor(day, colonyType, rule.config, treatments),
|
||||||
|
active: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Each measurement of one colony with its rating. */
|
||||||
|
async rate<M extends RatableMeasurement>(
|
||||||
|
colonyId: string,
|
||||||
|
measurements: readonly M[],
|
||||||
|
): Promise<(M & { rating: VarroaRating })[]> {
|
||||||
|
if (measurements.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const rule = await readRule(this.prisma, 'VARROA_THRESHOLD');
|
||||||
|
if (!rule.active) {
|
||||||
|
return measurements.map((m) => ({ ...m, rating: NO_RATING }));
|
||||||
|
}
|
||||||
|
const colony = await this.prisma.colony.findUnique({
|
||||||
|
where: { id: colonyId },
|
||||||
|
select: { colonyType: true },
|
||||||
|
});
|
||||||
|
const treatments = await treatmentWindowsIn(this.prisma, colonyId);
|
||||||
|
return measurements.map((m) => ({
|
||||||
|
...m,
|
||||||
|
rating: rateMeasurement(
|
||||||
|
m,
|
||||||
|
colony?.colonyType ?? null,
|
||||||
|
rule.config,
|
||||||
|
treatments,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async colonyTypeOf(colonyId: string): Promise<ColonyType> {
|
||||||
|
const colony = await this.prisma.colony.findUnique({
|
||||||
|
where: { id: colonyId },
|
||||||
|
select: { colonyType: true },
|
||||||
|
});
|
||||||
|
if (!colony) {
|
||||||
|
throw new NotFoundException('Colony not found');
|
||||||
|
}
|
||||||
|
return colony.colonyType;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { varroaThresholdOf } from '../automation/varroa-threshold';
|
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
|
||||||
|
|
||||||
export interface VarroaThreshold {
|
|
||||||
// null when the VARROA_THRESHOLD rule is switched off.
|
|
||||||
thresholdMitesPerDay: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class VarroaThresholdService {
|
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
|
||||||
|
|
||||||
async current(): Promise<VarroaThreshold> {
|
|
||||||
const rule = await this.prisma.automationRule.findUnique({
|
|
||||||
where: { triggerType: 'VARROA_THRESHOLD' },
|
|
||||||
select: { active: true, config: true },
|
|
||||||
});
|
|
||||||
return { thresholdMitesPerDay: varroaThresholdOf(rule) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,12 @@
|
|||||||
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
|
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
|
||||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
@@ -7,7 +15,8 @@ import { CreateVarroaMeasurementDto } from './dto/create-varroa-measurement.dto'
|
|||||||
import { BulkCreateVarroaMeasurementDto } from './dto/bulk-create-varroa-measurement.dto';
|
import { BulkCreateVarroaMeasurementDto } from './dto/bulk-create-varroa-measurement.dto';
|
||||||
import { UpdateVarroaMeasurementDto } from './dto/update-varroa-measurement.dto';
|
import { UpdateVarroaMeasurementDto } from './dto/update-varroa-measurement.dto';
|
||||||
import { VarroaService } from './varroa.service';
|
import { VarroaService } from './varroa.service';
|
||||||
import { VarroaThresholdService } from './varroa-threshold.service';
|
import { VarroaBandsService } from './varroa-bands.service';
|
||||||
|
import { VarroaThresholdsQuery } from './dto/varroa-thresholds.query';
|
||||||
import { Idempotent } from '../common/idempotency/idempotent.decorator';
|
import { Idempotent } from '../common/idempotency/idempotent.decorator';
|
||||||
|
|
||||||
@ApiTags('varroa-measurements')
|
@ApiTags('varroa-measurements')
|
||||||
@@ -16,7 +25,7 @@ import { Idempotent } from '../common/idempotency/idempotent.decorator';
|
|||||||
export class VarroaController {
|
export class VarroaController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly varroaService: VarroaService,
|
private readonly varroaService: VarroaService,
|
||||||
private readonly varroaThresholdService: VarroaThresholdService,
|
private readonly varroaBandsService: VarroaBandsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@RequirePermission('varroa:read')
|
@RequirePermission('varroa:read')
|
||||||
@@ -25,11 +34,11 @@ export class VarroaController {
|
|||||||
return this.varroaService.findAll();
|
return this.varroaService.findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Effective alarm threshold; declared before :id so it is not shadowed. */
|
/** Bands in force on a day for a colony; declared before :id so it is not shadowed. */
|
||||||
@RequirePermission('varroa:read', 'varroa:create')
|
@RequirePermission('varroa:read', 'varroa:create')
|
||||||
@Get('threshold')
|
@Get('thresholds')
|
||||||
getThreshold() {
|
getThresholds(@Query() query: VarroaThresholdsQuery) {
|
||||||
return this.varroaThresholdService.current();
|
return this.varroaBandsService.thresholds(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@RequirePermission('varroa:read')
|
@RequirePermission('varroa:read')
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Module } from '@nestjs/common';
|
|||||||
import { VarroaController } from './varroa.controller';
|
import { VarroaController } from './varroa.controller';
|
||||||
import { ColonyVarroaMeasurementsController } from './colony-varroa-measurements.controller';
|
import { ColonyVarroaMeasurementsController } from './colony-varroa-measurements.controller';
|
||||||
import { VarroaService } from './varroa.service';
|
import { VarroaService } from './varroa.service';
|
||||||
import { VarroaThresholdService } from './varroa-threshold.service';
|
import { VarroaBandsService } from './varroa-bands.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The threshold rule and the varroa.measured webhook follow from the
|
* The threshold rule and the varroa.measured webhook follow from the
|
||||||
@@ -11,7 +11,7 @@ import { VarroaThresholdService } from './varroa-threshold.service';
|
|||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [VarroaController, ColonyVarroaMeasurementsController],
|
controllers: [VarroaController, ColonyVarroaMeasurementsController],
|
||||||
providers: [VarroaService, VarroaThresholdService],
|
providers: [VarroaService, VarroaBandsService],
|
||||||
exports: [VarroaService],
|
exports: [VarroaService],
|
||||||
})
|
})
|
||||||
export class VarroaModule {}
|
export class VarroaModule {}
|
||||||
@@ -284,6 +284,57 @@ describe('VarroaService', () => {
|
|||||||
expect(createArg.data.calculatedValue).toBeUndefined();
|
expect(createArg.data.calculatedValue).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('stores mites per 100 bees for powdered sugar with a sample size', async () => {
|
||||||
|
tx.varroaMeasurement.create.mockResolvedValue(measurement);
|
||||||
|
|
||||||
|
await service.create(
|
||||||
|
{
|
||||||
|
colonyId: 'colony-1',
|
||||||
|
date: '2026-07-01',
|
||||||
|
method: 'POWDERED_SUGAR',
|
||||||
|
miteCount: 10,
|
||||||
|
sampleBees: 500,
|
||||||
|
},
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
const [[createArg]] = tx.varroaMeasurement.create.mock.calls as [
|
||||||
|
[{ data: { calculatedValue?: number; sampleBees?: number } }],
|
||||||
|
];
|
||||||
|
expect(createArg.data.calculatedValue).toBe(2);
|
||||||
|
expect(createArg.data.sampleBees).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not derive a percentage from the sample for natural mite fall or without a sample', async () => {
|
||||||
|
tx.varroaMeasurement.create.mockResolvedValue(measurement);
|
||||||
|
|
||||||
|
await service.create(
|
||||||
|
{
|
||||||
|
colonyId: 'colony-1',
|
||||||
|
date: '2026-07-01',
|
||||||
|
method: 'WASHING_METHOD',
|
||||||
|
miteCount: 10,
|
||||||
|
},
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
await service.create(
|
||||||
|
{
|
||||||
|
colonyId: 'colony-1',
|
||||||
|
date: '2026-07-01',
|
||||||
|
method: 'NATURAL_MITE_FALL',
|
||||||
|
miteCount: 10,
|
||||||
|
sampleBees: 500,
|
||||||
|
},
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
const calls = tx.varroaMeasurement.create.mock.calls as [
|
||||||
|
{ data: { calculatedValue?: number } },
|
||||||
|
][];
|
||||||
|
expect(calls[0][0].data.calculatedValue).toBeUndefined();
|
||||||
|
expect(calls[1][0].data.calculatedValue).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it('respects an explicitly provided calculatedValue instead of computing one', async () => {
|
it('respects an explicitly provided calculatedValue instead of computing one', async () => {
|
||||||
tx.varroaMeasurement.create.mockResolvedValue(measurement);
|
tx.varroaMeasurement.create.mockResolvedValue(measurement);
|
||||||
|
|
||||||
@@ -373,6 +424,16 @@ describe('VarroaService', () => {
|
|||||||
).resolves.toBe(20);
|
).resolves.toBe(20);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rounds mites/day to two decimals so the stored value rates as shown', async () => {
|
||||||
|
// 7203 / 720 days = 10.0042: stored as 10, which is yellow, not red.
|
||||||
|
await expect(
|
||||||
|
storedCalculatedValueAfter(naturalMiteFall, {
|
||||||
|
miteCount: 7203,
|
||||||
|
durationMinutes: 720 * 24 * 60,
|
||||||
|
}),
|
||||||
|
).resolves.toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
it('recomputes mites/day when the duration changes', async () => {
|
it('recomputes mites/day when the duration changes', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
storedCalculatedValueAfter(naturalMiteFall, {
|
storedCalculatedValueAfter(naturalMiteFall, {
|
||||||
@@ -443,6 +504,108 @@ describe('VarroaService', () => {
|
|||||||
).resolves.toBe(15);
|
).resolves.toBe(15);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('with a sample size', () => {
|
||||||
|
const washed = {
|
||||||
|
...measurement,
|
||||||
|
method: 'WASHING_METHOD',
|
||||||
|
miteCount: 10,
|
||||||
|
durationMinutes: null,
|
||||||
|
sampleBees: 500,
|
||||||
|
calculatedValue: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('recomputes the percentage when the sample size changes', async () => {
|
||||||
|
await expect(
|
||||||
|
storedCalculatedValueAfter(washed, { sampleBees: 250 }),
|
||||||
|
).resolves.toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update with sampleBees null clears the sample and keeps a manually entered value', async () => {
|
||||||
|
tx.varroaMeasurement.findUnique.mockResolvedValue({
|
||||||
|
...washed,
|
||||||
|
calculatedValue: 3,
|
||||||
|
});
|
||||||
|
tx.varroaMeasurement.update.mockResolvedValue(measurement);
|
||||||
|
|
||||||
|
await service.update(
|
||||||
|
'varroa-1',
|
||||||
|
{ sampleBees: null as unknown as number },
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
const [[updateArg]] = tx.varroaMeasurement.update.mock.calls as [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
sampleBees?: number | null;
|
||||||
|
calculatedValue?: number | null;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
],
|
||||||
|
];
|
||||||
|
expect(updateArg.data.sampleBees).toBeNull();
|
||||||
|
expect(updateArg.data.calculatedValue).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears a derived percentage when the sample is removed', async () => {
|
||||||
|
await expect(
|
||||||
|
storedCalculatedValueAfter(washed, {
|
||||||
|
sampleBees: null as unknown as number,
|
||||||
|
}),
|
||||||
|
).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rounds the derived percentage to two decimals', async () => {
|
||||||
|
await expect(
|
||||||
|
storedCalculatedValueAfter(washed, {
|
||||||
|
miteCount: 7,
|
||||||
|
sampleBees: 300,
|
||||||
|
}),
|
||||||
|
).resolves.toBe(2.33);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recomputes the percentage when the mite count changes', async () => {
|
||||||
|
await expect(
|
||||||
|
storedCalculatedValueAfter(washed, { miteCount: 15 }),
|
||||||
|
).resolves.toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a manual percentage alone when only the notes change', async () => {
|
||||||
|
await expect(
|
||||||
|
storedCalculatedValueAfter(
|
||||||
|
{ ...washed, calculatedValue: 3 },
|
||||||
|
{ notes: 'x' },
|
||||||
|
),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives the percentage when the method switches from drone brood', async () => {
|
||||||
|
await expect(
|
||||||
|
storedCalculatedValueAfter(
|
||||||
|
{ ...washed, method: 'DRONE_BROOD_CONTROL', calculatedValue: 9 },
|
||||||
|
{ method: 'POWDERED_SUGAR' },
|
||||||
|
),
|
||||||
|
).resolves.toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears the percentage when the method switches to drone brood', async () => {
|
||||||
|
await expect(
|
||||||
|
storedCalculatedValueAfter(washed, {
|
||||||
|
method: 'DRONE_BROOD_CONTROL',
|
||||||
|
}),
|
||||||
|
).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a changed percentage as a manual override', async () => {
|
||||||
|
await expect(
|
||||||
|
storedCalculatedValueAfter(washed, {
|
||||||
|
sampleBees: 250,
|
||||||
|
calculatedValue: 7,
|
||||||
|
}),
|
||||||
|
).resolves.toBe(7);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// @IsOptional lets an explicit null through the validation pipe.
|
// @IsOptional lets an explicit null through the validation pipe.
|
||||||
const clearedValue = { calculatedValue: null as unknown as number };
|
const clearedValue = { calculatedValue: null as unknown as number };
|
||||||
|
|
||||||
|
|||||||
@@ -19,16 +19,19 @@ import { BulkCreateVarroaMeasurementDto } from './dto/bulk-create-varroa-measure
|
|||||||
import { UpdateVarroaMeasurementDto } from './dto/update-varroa-measurement.dto';
|
import { UpdateVarroaMeasurementDto } from './dto/update-varroa-measurement.dto';
|
||||||
|
|
||||||
const MINUTES_PER_DAY = 24 * 60;
|
const MINUTES_PER_DAY = 24 * 60;
|
||||||
|
const PERCENT = 100;
|
||||||
|
const DECIMALS_FACTOR = 100;
|
||||||
|
|
||||||
|
// Stored and rated value are the same: rounded once, on write.
|
||||||
|
function roundToTwoDecimals(value: number): number {
|
||||||
|
return Math.round(value * DECIMALS_FACTOR) / DECIMALS_FACTOR;
|
||||||
|
}
|
||||||
|
|
||||||
// Natural mite fall is counted on a sticky-board insert left in the hive
|
// Natural mite fall is counted on a sticky-board insert left in the hive
|
||||||
// for some duration and is conventionally normalized to a daily rate
|
// for some duration and is conventionally normalized to a daily rate
|
||||||
// ("Milben/Tag") - both the VARROA_THRESHOLD automation config
|
// ("Milben/Tag"). Powdered sugar and washing report the infestation as mites
|
||||||
// (thresholdMitesPerDay) and the annual PDF report already assume
|
// per 100 bees of a sample of sampleBees (percent). The drone brood check has
|
||||||
// calculatedValue is in that unit. The other methods (Puderzucker,
|
// no verified formula, so its calculatedValue is left for the user to enter.
|
||||||
// Waschmethode, Drohnenbrutkontrolle) report a different, non-time-based
|
|
||||||
// metric (mites per fixed bee sample); there is no single verified
|
|
||||||
// formula for those here, so calculatedValue is left for the user to
|
|
||||||
// enter manually for those methods instead of guessing one.
|
|
||||||
function computeNaturalMiteFallPerDay(
|
function computeNaturalMiteFallPerDay(
|
||||||
miteCount: number | null | undefined,
|
miteCount: number | null | undefined,
|
||||||
durationMinutes: number | null | undefined,
|
durationMinutes: number | null | undefined,
|
||||||
@@ -39,16 +42,44 @@ function computeNaturalMiteFallPerDay(
|
|||||||
if (!durationMinutes || durationMinutes <= 0) {
|
if (!durationMinutes || durationMinutes <= 0) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
return (miteCount / durationMinutes) * MINUTES_PER_DAY;
|
return roundToTwoDecimals((miteCount / durationMinutes) * MINUTES_PER_DAY);
|
||||||
}
|
}
|
||||||
|
|
||||||
function deriveCalculatedValue(
|
function computeInfestationPercent(
|
||||||
method: VarroaMethod,
|
|
||||||
miteCount: number | null | undefined,
|
miteCount: number | null | undefined,
|
||||||
durationMinutes: number | null | undefined,
|
sampleBees: number | null | undefined,
|
||||||
): number | undefined {
|
): number | undefined {
|
||||||
return method === 'NATURAL_MITE_FALL'
|
if (miteCount === null || miteCount === undefined) {
|
||||||
? computeNaturalMiteFallPerDay(miteCount, durationMinutes)
|
return undefined;
|
||||||
|
}
|
||||||
|
if (!sampleBees || sampleBees <= 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return roundToTwoDecimals((miteCount / sampleBees) * PERCENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPercentMethod(method: VarroaMethod): boolean {
|
||||||
|
return method === 'POWDERED_SUGAR' || method === 'WASHING_METHOD';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Derivation {
|
||||||
|
method: VarroaMethod;
|
||||||
|
miteCount?: number | null;
|
||||||
|
durationMinutes?: number | null;
|
||||||
|
sampleBees?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveCalculatedValue({
|
||||||
|
method,
|
||||||
|
miteCount,
|
||||||
|
durationMinutes,
|
||||||
|
sampleBees,
|
||||||
|
}: Derivation): number | undefined {
|
||||||
|
if (method === 'NATURAL_MITE_FALL') {
|
||||||
|
return computeNaturalMiteFallPerDay(miteCount, durationMinutes);
|
||||||
|
}
|
||||||
|
return isPercentMethod(method)
|
||||||
|
? computeInfestationPercent(miteCount, sampleBees)
|
||||||
: undefined;
|
: undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,18 +100,25 @@ function calculatedValueForUpdate(
|
|||||||
if (sent !== undefined && sent !== null && sent !== before.calculatedValue) {
|
if (sent !== undefined && sent !== null && sent !== before.calculatedValue) {
|
||||||
return sent;
|
return sent;
|
||||||
}
|
}
|
||||||
const method = dto.method ?? before.method;
|
const merged: Derivation = {
|
||||||
const miteCount =
|
method: dto.method ?? before.method,
|
||||||
dto.miteCount === undefined ? before.miteCount : dto.miteCount;
|
miteCount: dto.miteCount === undefined ? before.miteCount : dto.miteCount,
|
||||||
const durationMinutes =
|
durationMinutes:
|
||||||
dto.durationMinutes === undefined
|
dto.durationMinutes === undefined
|
||||||
? before.durationMinutes
|
? before.durationMinutes
|
||||||
: dto.durationMinutes;
|
: dto.durationMinutes,
|
||||||
|
sampleBees:
|
||||||
|
dto.sampleBees === undefined ? before.sampleBees : dto.sampleBees,
|
||||||
|
};
|
||||||
|
const { method } = merged;
|
||||||
const derivationInputChanged =
|
const derivationInputChanged =
|
||||||
method !== before.method ||
|
method !== before.method ||
|
||||||
(method === 'NATURAL_MITE_FALL' &&
|
(method === 'NATURAL_MITE_FALL' &&
|
||||||
(miteCount !== before.miteCount ||
|
(merged.miteCount !== before.miteCount ||
|
||||||
durationMinutes !== before.durationMinutes));
|
merged.durationMinutes !== before.durationMinutes)) ||
|
||||||
|
(isPercentMethod(method) &&
|
||||||
|
(merged.miteCount !== before.miteCount ||
|
||||||
|
merged.sampleBees !== before.sampleBees));
|
||||||
// An explicit null asks for the value to be cleared - like a create
|
// An explicit null asks for the value to be cleared - like a create
|
||||||
// without a value, that means "derive it where a rule exists".
|
// without a value, that means "derive it where a rule exists".
|
||||||
const mustDerive =
|
const mustDerive =
|
||||||
@@ -88,8 +126,20 @@ function calculatedValueForUpdate(
|
|||||||
if (!mustDerive) {
|
if (!mustDerive) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
const derived = deriveCalculatedValue(merged);
|
||||||
|
// A percentage that no longer derives (the sample was removed) is cleared
|
||||||
|
// only if it was derived itself; a value entered by hand stays.
|
||||||
|
if (
|
||||||
|
derived === undefined &&
|
||||||
|
sent !== null &&
|
||||||
|
method === before.method &&
|
||||||
|
isPercentMethod(method) &&
|
||||||
|
before.calculatedValue !== deriveCalculatedValue(before)
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
// null clears a value the merged record no longer supports.
|
// null clears a value the merged record no longer supports.
|
||||||
return deriveCalculatedValue(method, miteCount, durationMinutes) ?? null;
|
return derived ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The row a new measurement is stored as - for create and createBulk alike,
|
// The row a new measurement is stored as - for create and createBulk alike,
|
||||||
@@ -101,9 +151,7 @@ function measurementRow(
|
|||||||
): Prisma.VarroaMeasurementUncheckedCreateInput {
|
): Prisma.VarroaMeasurementUncheckedCreateInput {
|
||||||
return {
|
return {
|
||||||
...dto,
|
...dto,
|
||||||
calculatedValue:
|
calculatedValue: dto.calculatedValue ?? deriveCalculatedValue(dto),
|
||||||
dto.calculatedValue ??
|
|
||||||
deriveCalculatedValue(dto.method, dto.miteCount, dto.durationMinutes),
|
|
||||||
date: new Date(dto.date),
|
date: new Date(dto.date),
|
||||||
userId: actingUserId,
|
userId: actingUserId,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ describe('WebhookDeliveryWorker', () => {
|
|||||||
`UPDATE "WebhookDelivery" SET "status" = 'PROCESSING', "claimedAt" = now()`,
|
`UPDATE "WebhookDelivery" SET "status" = 'PROCESSING', "claimedAt" = now()`,
|
||||||
);
|
);
|
||||||
expect(text(claim)).toContain(
|
expect(text(claim)).toContain(
|
||||||
`WHERE "status" = 'PENDING' AND "availableAt" <= now()`,
|
`WHERE "status" = 'PENDING' AND "availableAt" <= CAST(now() AS timestamp(3))`,
|
||||||
);
|
);
|
||||||
expect(text(claim)).toContain(
|
expect(text(claim)).toContain(
|
||||||
'ORDER BY "createdAt" LIMIT ? FOR UPDATE SKIP LOCKED',
|
'ORDER BY "createdAt" LIMIT ? FOR UPDATE SKIP LOCKED',
|
||||||
@@ -201,7 +201,7 @@ describe('WebhookDeliveryWorker', () => {
|
|||||||
|
|
||||||
const claim = db.claimQuery();
|
const claim = db.claimQuery();
|
||||||
expect(text(claim)).toContain(
|
expect(text(claim)).toContain(
|
||||||
`"availableAt" <= now() AND "subscriptionId" = ANY(?) ORDER BY`,
|
`"availableAt" <= CAST(now() AS timestamp(3)) AND "subscriptionId" = ANY(?) ORDER BY`,
|
||||||
);
|
);
|
||||||
expect(claim.values).toEqual([['wh-1', 'wh-2'], 50]);
|
expect(claim.values).toEqual([['wh-1', 'wh-2'], 50]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ type RecordModel =
|
|||||||
| 'feeding'
|
| 'feeding'
|
||||||
| 'harvest'
|
| 'harvest'
|
||||||
| 'honeyBatch'
|
| 'honeyBatch'
|
||||||
|
| 'queen'
|
||||||
| 'task';
|
| 'task';
|
||||||
|
|
||||||
const RECORD_MODELS: readonly RecordModel[] = [
|
const RECORD_MODELS: readonly RecordModel[] = [
|
||||||
@@ -27,6 +28,7 @@ const RECORD_MODELS: readonly RecordModel[] = [
|
|||||||
'feeding',
|
'feeding',
|
||||||
'harvest',
|
'harvest',
|
||||||
'honeyBatch',
|
'honeyBatch',
|
||||||
|
'queen',
|
||||||
'task',
|
'task',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -79,6 +81,7 @@ describe('WebhookEventHandlers', () => {
|
|||||||
StockChanged: 'stock.changed',
|
StockChanged: 'stock.changed',
|
||||||
TaskCreated: 'task.created',
|
TaskCreated: 'task.created',
|
||||||
TaskCompleted: 'task.completed',
|
TaskCompleted: 'task.completed',
|
||||||
|
QueenAssigned: 'queen.replaced',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -156,6 +159,27 @@ describe('WebhookEventHandlers', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('QueenAssigned forwards queen.replaced with the queen', async () => {
|
||||||
|
const queen = { id: 'q1', code: 'Q-001' };
|
||||||
|
tx.queen.findUnique.mockResolvedValueOnce(queen);
|
||||||
|
|
||||||
|
await forward(
|
||||||
|
envelope('QueenAssigned', 'q1', {
|
||||||
|
colonyId: 'c1',
|
||||||
|
historyId: 'h1',
|
||||||
|
assignedOn: '2026-10-14',
|
||||||
|
previousColonyId: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(tx.queen.findUnique).toHaveBeenCalledWith({ where: { id: 'q1' } });
|
||||||
|
expect(webhooks.enqueueDeliveries).toHaveBeenCalledWith(
|
||||||
|
tx,
|
||||||
|
'queen.replaced',
|
||||||
|
queen,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('enqueues harvest.created with the harvest and its colonies', async () => {
|
it('enqueues harvest.created with the harvest and its colonies', async () => {
|
||||||
const harvest = {
|
const harvest = {
|
||||||
id: 'h1',
|
id: 'h1',
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export const DOMAIN_EVENT_WEBHOOKS: Readonly<
|
|||||||
StockChanged: 'stock.changed',
|
StockChanged: 'stock.changed',
|
||||||
TaskCreated: 'task.created',
|
TaskCreated: 'task.created',
|
||||||
TaskCompleted: 'task.completed',
|
TaskCompleted: 'task.completed',
|
||||||
|
QueenAssigned: 'queen.replaced',
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -92,6 +93,8 @@ async function webhookData(
|
|||||||
case 'TaskCreated':
|
case 'TaskCreated':
|
||||||
case 'TaskCompleted':
|
case 'TaskCompleted':
|
||||||
return tx.task.findUnique(byAggregate);
|
return tx.task.findUnique(byAggregate);
|
||||||
|
case 'QueenAssigned':
|
||||||
|
return tx.queen.findUnique(byAggregate);
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -266,7 +266,12 @@ describe('Automation rules (e2e)', () => {
|
|||||||
const notifications = await prisma.notification.findMany({
|
const notifications = await prisma.notification.findMany({
|
||||||
where: { relatedId: colonyId },
|
where: { relatedId: colonyId },
|
||||||
});
|
});
|
||||||
expect(notifications).toHaveLength(1);
|
// One copy per user who may read tasks, never a broadcast.
|
||||||
|
expect(notifications.length).toBeGreaterThan(0);
|
||||||
|
expect(notifications.every((n) => n.category === 'SUGGESTION')).toBe(true);
|
||||||
|
expect(new Set(notifications.map((n) => n.userId)).size).toBe(
|
||||||
|
notifications.length,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not trigger when the queen was seen', async () => {
|
it('does not trigger when the queen was seen', async () => {
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ describe('Calendar sync outbox (e2e)', () => {
|
|||||||
syncFeeding: true,
|
syncFeeding: true,
|
||||||
syncHarvest: true,
|
syncHarvest: true,
|
||||||
syncQueenAssignment: true,
|
syncQueenAssignment: true,
|
||||||
|
syncWithdrawal: true,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -359,6 +360,37 @@ describe('Calendar sync outbox (e2e)', () => {
|
|||||||
expect(JSON.stringify(response.body)).toContain('Gruppen-ID');
|
expect(JSON.stringify(response.body)).toContain('Gruppen-ID');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /settings is mocked in this file (see beforeEach), so the stored
|
||||||
|
// row is read directly.
|
||||||
|
it('syncWithdrawal is on by default and can be switched off', async () => {
|
||||||
|
const fresh = await prisma.calendarSyncSettings.create({
|
||||||
|
data: { id: `default-check-${suffix}` },
|
||||||
|
});
|
||||||
|
await prisma.calendarSyncSettings.delete({ where: { id: fresh.id } });
|
||||||
|
expect(fresh.syncWithdrawal).toBe(true);
|
||||||
|
await prisma.calendarSyncSettings.upsert({
|
||||||
|
where: { id: 'singleton' },
|
||||||
|
create: { id: 'singleton' },
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.patch('/api/v1/calendar/sync/settings')
|
||||||
|
.set(auth())
|
||||||
|
.send({ syncWithdrawal: false })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.body).toMatchObject({ syncWithdrawal: false });
|
||||||
|
const stored = await prisma.calendarSyncSettings.findUniqueOrThrow({
|
||||||
|
where: { id: 'singleton' },
|
||||||
|
});
|
||||||
|
expect(stored.syncWithdrawal).toBe(false);
|
||||||
|
await prisma.calendarSyncSettings.update({
|
||||||
|
where: { id: 'singleton' },
|
||||||
|
data: { syncWithdrawal: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// PATCH null rule: the switches cannot hold null - 400, not a 500.
|
// PATCH null rule: the switches cannot hold null - 400, not a 500.
|
||||||
it('rejects null on a switch with 400', async () => {
|
it('rejects null on a switch with 400', async () => {
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ describe('Colonies (e2e)', () => {
|
|||||||
let apiaryBId: string;
|
let apiaryBId: string;
|
||||||
let colonyId: string;
|
let colonyId: string;
|
||||||
let childColonyId: string;
|
let childColonyId: string;
|
||||||
|
const splitColonyIds: string[] = [];
|
||||||
let colonyToMergeId: string;
|
let colonyToMergeId: string;
|
||||||
let healthColonyId: string;
|
let healthColonyId: string;
|
||||||
let raceColonyId: string;
|
let raceColonyId: string;
|
||||||
@@ -83,6 +84,7 @@ describe('Colonies (e2e)', () => {
|
|||||||
colonyToMergeId,
|
colonyToMergeId,
|
||||||
healthColonyId,
|
healthColonyId,
|
||||||
raceColonyId,
|
raceColonyId,
|
||||||
|
...splitColonyIds,
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
// Rule tasks are written without a user - their audit entry and
|
// Rule tasks are written without a user - their audit entry and
|
||||||
// TaskCreated are not covered by the userId cleanup below.
|
// TaskCreated are not covered by the userId cleanup below.
|
||||||
@@ -190,6 +192,55 @@ describe('Colonies (e2e)', () => {
|
|||||||
expect((history.body as ColonyHistoryBody).lineageAsSource).toHaveLength(1);
|
expect((history.body as ColonyHistoryBody).lineageAsSource).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('records ColonySplit with the given day and queen source, and refuses a future day', async () => {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post(`/api/v1/colonies/${colonyId}/split`)
|
||||||
|
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||||
|
.send({
|
||||||
|
code: `V-${Date.now()}-F`,
|
||||||
|
colonyType: 'NUCLEUS_COLONY',
|
||||||
|
date: '2999-01-01',
|
||||||
|
})
|
||||||
|
.expect(400);
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post(`/api/v1/colonies/${colonyId}/split`)
|
||||||
|
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||||
|
.send({
|
||||||
|
code: `V-${Date.now()}-T`,
|
||||||
|
colonyType: 'NUCLEUS_COLONY',
|
||||||
|
date: '2026-05-05T23:30:00+02:00',
|
||||||
|
})
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/api/v1/colonies/${colonyId}/split`)
|
||||||
|
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||||
|
.send({
|
||||||
|
code: `V-${Date.now()}-B`,
|
||||||
|
colonyType: 'NUCLEUS_COLONY',
|
||||||
|
date: '2026-05-05',
|
||||||
|
queenSource: 'QUEEN_CELL',
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
const splitId = (response.body as ColonyBody).id;
|
||||||
|
splitColonyIds.push(splitId);
|
||||||
|
|
||||||
|
const lineage = await prisma.colonyLineage.findFirstOrThrow({
|
||||||
|
where: { toColonyId: splitId },
|
||||||
|
});
|
||||||
|
expect(lineage.queenSource).toBe('QUEEN_CELL');
|
||||||
|
expect(lineage.date.toISOString().slice(0, 10)).toBe('2026-05-05');
|
||||||
|
const event = await prisma.domainEvent.findFirstOrThrow({
|
||||||
|
where: { type: 'ColonySplit', aggregateId: splitId },
|
||||||
|
});
|
||||||
|
expect(event.payload).toEqual({
|
||||||
|
parentColonyId: colonyId,
|
||||||
|
lineageId: lineage.id,
|
||||||
|
queenSource: 'QUEEN_CELL',
|
||||||
|
splitOn: '2026-05-05',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('merges another colony into the surviving one', async () => {
|
it('merges another colony into the surviving one', async () => {
|
||||||
const otherColony = await request(app.getHttpServer())
|
const otherColony = await request(app.getHttpServer())
|
||||||
.post('/api/v1/colonies')
|
.post('/api/v1/colonies')
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { INestApplication } from '@nestjs/common';
|
|||||||
import * as bcrypt from 'bcryptjs';
|
import * as bcrypt from 'bcryptjs';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import { App } from 'supertest/types';
|
import { App } from 'supertest/types';
|
||||||
|
import { NotificationsService } from '../src/notifications/notifications.service';
|
||||||
import { PrismaService } from '../src/prisma/prisma.service';
|
import { PrismaService } from '../src/prisma/prisma.service';
|
||||||
import { createTestApp } from './utils/create-test-app';
|
import { createTestApp } from './utils/create-test-app';
|
||||||
import { login } from './utils/api';
|
import { login } from './utils/api';
|
||||||
@@ -9,99 +10,360 @@ import { login } from './utils/api';
|
|||||||
interface NotificationBody {
|
interface NotificationBody {
|
||||||
id: string;
|
id: string;
|
||||||
priority: string;
|
priority: string;
|
||||||
|
category: string;
|
||||||
read: boolean;
|
read: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SettingsBody {
|
||||||
|
taskDueEnabled: boolean;
|
||||||
|
digestEnabled: boolean;
|
||||||
|
digestTime: string;
|
||||||
|
quietFrom: string | null;
|
||||||
|
quietUntil: string | null;
|
||||||
|
telegramCategories: string[];
|
||||||
|
telegramDetail: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
describe('Notifications (e2e)', () => {
|
describe('Notifications (e2e)', () => {
|
||||||
let app: INestApplication<App>;
|
let app: INestApplication<App>;
|
||||||
let prisma: PrismaService;
|
let prisma: PrismaService;
|
||||||
const beekeeperEmail = `notifications-e2e-beekeeper-${Date.now()}@example.com`;
|
let notifications: NotificationsService;
|
||||||
|
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
|
||||||
const password = 'SuperSecret123!';
|
const password = 'SuperSecret123!';
|
||||||
const staffEmail = `notifications-e2e-staff-${Date.now()}@example.com`;
|
const emails = {
|
||||||
let beekeeperId: string;
|
beekeeper: `notifications-e2e-beekeeper-${suffix}@example.com`,
|
||||||
let staffId: string;
|
staff: `notifications-e2e-staff-${suffix}@example.com`,
|
||||||
let beekeeperToken: string;
|
bare: `notifications-e2e-bare-${suffix}@example.com`,
|
||||||
let staffToken: string;
|
admin: `notifications-e2e-admin-${suffix}@example.com`,
|
||||||
let notificationId: string;
|
};
|
||||||
|
const roleKey = `NOTIF_BARE_${suffix}`;
|
||||||
|
const ids = {} as Record<keyof typeof emails, string>;
|
||||||
|
const tokens = {} as Record<keyof typeof emails, string>;
|
||||||
|
const createdNotificationIds: string[] = [];
|
||||||
|
let apiKeyId: string | undefined;
|
||||||
|
|
||||||
|
const server = () => app.getHttpServer();
|
||||||
|
const as = (who: keyof typeof emails) => ({
|
||||||
|
Authorization: `Bearer ${tokens[who]}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function seedNotification(
|
||||||
|
userId: string,
|
||||||
|
overrides: Record<string, unknown> = {},
|
||||||
|
) {
|
||||||
|
const row = await prisma.notification.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
priority: 'KRITISCH',
|
||||||
|
category: 'SYSTEM',
|
||||||
|
message: `Kritischer Testalarm ${suffix}`,
|
||||||
|
...overrides,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
createdNotificationIds.push(row.id);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
app = await createTestApp();
|
app = await createTestApp();
|
||||||
prisma = app.get(PrismaService);
|
prisma = app.get(PrismaService);
|
||||||
|
notifications = app.get(NotificationsService);
|
||||||
|
|
||||||
|
await prisma.role.create({
|
||||||
|
data: { key: roleKey, name: 'Notifications bare role' },
|
||||||
|
});
|
||||||
const passwordHash = await bcrypt.hash(password, 12);
|
const passwordHash = await bcrypt.hash(password, 12);
|
||||||
const beekeeper = await prisma.user.create({
|
const roleOf: Record<keyof typeof emails, string> = {
|
||||||
data: {
|
beekeeper: 'BEEKEEPER',
|
||||||
email: beekeeperEmail,
|
staff: 'STAFF',
|
||||||
passwordHash,
|
bare: roleKey,
|
||||||
name: 'Beekeeper',
|
admin: 'ADMIN',
|
||||||
role: { connect: { key: 'BEEKEEPER' } },
|
};
|
||||||
},
|
for (const who of Object.keys(emails) as (keyof typeof emails)[]) {
|
||||||
});
|
const user = await prisma.user.create({
|
||||||
beekeeperId = beekeeper.id;
|
data: {
|
||||||
const staff = await prisma.user.create({
|
email: emails[who],
|
||||||
data: {
|
passwordHash,
|
||||||
email: staffEmail,
|
name: who,
|
||||||
passwordHash,
|
role: { connect: { key: roleOf[who] } },
|
||||||
name: 'Staff',
|
},
|
||||||
role: { connect: { key: 'STAFF' } },
|
});
|
||||||
},
|
ids[who] = user.id;
|
||||||
});
|
tokens[who] = (await login(app, emails[who], password)).accessToken;
|
||||||
staffId = staff.id;
|
}
|
||||||
beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken;
|
|
||||||
staffToken = (await login(app, staffEmail, password)).accessToken;
|
|
||||||
|
|
||||||
const notification = await prisma.notification.create({
|
|
||||||
data: {
|
|
||||||
userId: beekeeperId,
|
|
||||||
priority: 'KRITISCH',
|
|
||||||
message: `Kritischer Testalarm ${Date.now()}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
notificationId = notification.id;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await prisma.notification.deleteMany({ where: { id: notificationId } });
|
const userIds = Object.values(ids);
|
||||||
|
if (apiKeyId) {
|
||||||
|
await prisma.apiKey.deleteMany({ where: { id: apiKeyId } });
|
||||||
|
}
|
||||||
|
await prisma.notification.deleteMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ id: { in: createdNotificationIds } },
|
||||||
|
{ userId: { in: userIds } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.notificationPreference.deleteMany({
|
||||||
|
where: { userId: { in: userIds } },
|
||||||
|
});
|
||||||
await prisma.refreshToken.deleteMany({
|
await prisma.refreshToken.deleteMany({
|
||||||
where: { userId: { in: [beekeeperId, staffId] } },
|
where: { userId: { in: userIds } },
|
||||||
});
|
|
||||||
await prisma.auditLog.deleteMany({
|
|
||||||
where: { userId: { in: [beekeeperId, staffId] } },
|
|
||||||
});
|
|
||||||
await prisma.user.deleteMany({
|
|
||||||
where: { id: { in: [beekeeperId, staffId] } },
|
|
||||||
});
|
});
|
||||||
|
await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } });
|
||||||
|
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
|
||||||
|
await prisma.role.deleteMany({ where: { key: roleKey } });
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('lists notifications for the authenticated user', async () => {
|
it('lists notifications for the authenticated user', async () => {
|
||||||
const response = await request(app.getHttpServer())
|
const own = await seedNotification(ids.beekeeper);
|
||||||
|
|
||||||
|
const response = await request(server())
|
||||||
.get('/api/v1/notifications')
|
.get('/api/v1/notifications')
|
||||||
.set('Authorization', `Bearer ${beekeeperToken}`)
|
.set(as('beekeeper'))
|
||||||
.expect(200);
|
.expect(200);
|
||||||
const notifications = response.body as NotificationBody[];
|
const list = response.body as NotificationBody[];
|
||||||
expect(
|
|
||||||
notifications.some(
|
expect(list.some((n) => n.id === own.id && n.priority === 'KRITISCH')).toBe(
|
||||||
(notification) =>
|
true,
|
||||||
notification.id === notificationId &&
|
);
|
||||||
notification.priority === 'KRITISCH',
|
expect(list[0]).not.toHaveProperty('requiredPermissions');
|
||||||
),
|
expect(list[0]).not.toHaveProperty('dedupeKey');
|
||||||
).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects marking another user's notification as read", () => {
|
it('hides a row whose permissions the user does not hold (any more)', async () => {
|
||||||
return request(app.getHttpServer())
|
const open = await seedNotification(ids.bare);
|
||||||
.post(`/api/v1/notifications/${notificationId}/read`)
|
const hidden = await seedNotification(ids.bare, {
|
||||||
.set('Authorization', `Bearer ${staffToken}`)
|
requiredPermissions: ['colonies:read'],
|
||||||
.expect(403);
|
});
|
||||||
|
|
||||||
|
const response = await request(server())
|
||||||
|
.get('/api/v1/notifications?unreadOnly=true')
|
||||||
|
.set(as('bare'))
|
||||||
|
.expect(200);
|
||||||
|
const listed = (response.body as NotificationBody[]).map((n) => n.id);
|
||||||
|
|
||||||
|
expect(listed).toContain(open.id);
|
||||||
|
expect(listed).not.toContain(hidden.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('marks a notification as read', async () => {
|
it('reading is per user: marking one copy read leaves the other unread', async () => {
|
||||||
const response = await request(app.getHttpServer())
|
const [first, second] = await notifications.recordForUsers(
|
||||||
.post(`/api/v1/notifications/${notificationId}/read`)
|
[ids.beekeeper, ids.staff],
|
||||||
.set('Authorization', `Bearer ${beekeeperToken}`)
|
{
|
||||||
|
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(201);
|
||||||
const notification = response.body as NotificationBody;
|
|
||||||
expect(notification.read).toBe(true);
|
expect((response.body as NotificationBody).read).toBe(true);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await prisma.notification.findUniqueOrThrow({
|
||||||
|
where: { id: theirs.id },
|
||||||
|
})
|
||||||
|
).read,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a fan-out for colonies:read never reaches a user without it', async () => {
|
||||||
|
const rows = await notifications.recordForPermission('colonies:read', {
|
||||||
|
priority: 'INFO',
|
||||||
|
category: 'SUGGESTION',
|
||||||
|
message: `Nur mit Volksrecht ${suffix}`,
|
||||||
|
dedupeKeyPrefix: `e2e-fanout:${suffix}`,
|
||||||
|
});
|
||||||
|
createdNotificationIds.push(...rows.map((row) => row.id));
|
||||||
|
|
||||||
|
const recipients = rows.map((row) => row.userId);
|
||||||
|
expect(recipients).toContain(ids.beekeeper);
|
||||||
|
expect(recipients).not.toContain(ids.bare);
|
||||||
|
expect(
|
||||||
|
rows.every((row) => row.requiredPermissions.includes('colonies:read')),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
const none = await notifications.recordForUsers([ids.bare], {
|
||||||
|
priority: 'INFO',
|
||||||
|
category: 'SUGGESTION',
|
||||||
|
message: 'x',
|
||||||
|
requiredPermissions: ['colonies:read'],
|
||||||
|
});
|
||||||
|
expect(none).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a repeated dedupe key writes nothing the second time', async () => {
|
||||||
|
const input = {
|
||||||
|
priority: 'INFO' as const,
|
||||||
|
category: 'SYSTEM' as const,
|
||||||
|
message: `Einmalig ${suffix}`,
|
||||||
|
dedupeKeyPrefix: `e2e-dedupe:${suffix}`,
|
||||||
|
};
|
||||||
|
const first = await notifications.recordForUsers([ids.beekeeper], input);
|
||||||
|
createdNotificationIds.push(...first.map((row) => row.id));
|
||||||
|
|
||||||
|
const second = await notifications.recordForUsers([ids.beekeeper], input);
|
||||||
|
|
||||||
|
expect(first).toHaveLength(1);
|
||||||
|
expect(second).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('read-all marks only own notifications', async () => {
|
||||||
|
const own = await seedNotification(ids.staff);
|
||||||
|
const foreign = await seedNotification(ids.beekeeper);
|
||||||
|
|
||||||
|
const response = await request(server())
|
||||||
|
.post('/api/v1/notifications/read-all')
|
||||||
|
.set(as('staff'))
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(response.body as { updated: number }).updated,
|
||||||
|
).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(
|
||||||
|
(await prisma.notification.findUniqueOrThrow({ where: { id: own.id } }))
|
||||||
|
.read,
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await prisma.notification.findUniqueOrThrow({
|
||||||
|
where: { id: foreign.id },
|
||||||
|
})
|
||||||
|
).read,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reading a foreign notification answers 404', async () => {
|
||||||
|
const foreign = await seedNotification(ids.beekeeper);
|
||||||
|
|
||||||
|
await request(server())
|
||||||
|
.post(`/api/v1/notifications/${foreign.id}/read`)
|
||||||
|
.set(as('staff'))
|
||||||
|
.expect(404);
|
||||||
|
await request(server())
|
||||||
|
.post('/api/v1/notifications/does-not-exist/read')
|
||||||
|
.set(as('staff'))
|
||||||
|
.expect(404);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await prisma.notification.findUniqueOrThrow({
|
||||||
|
where: { id: foreign.id },
|
||||||
|
})
|
||||||
|
).read,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('notification settings', () => {
|
||||||
|
it('an API key gets 403 on /profile/notification-settings', async () => {
|
||||||
|
const created = await request(server())
|
||||||
|
.post('/api/v1/api-keys')
|
||||||
|
.set(as('admin'))
|
||||||
|
.send({ name: `notif-e2e ${suffix}`, scopes: ['shop-sync:read'] })
|
||||||
|
.expect(201);
|
||||||
|
const body = created.body as { apiKey: { id: string }; token: string };
|
||||||
|
apiKeyId = body.apiKey.id;
|
||||||
|
|
||||||
|
await request(server())
|
||||||
|
.get('/api/v1/profile/notification-settings')
|
||||||
|
.set('Authorization', `Bearer ${body.token}`)
|
||||||
|
.expect(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the defaults on first read', async () => {
|
||||||
|
const response = await request(server())
|
||||||
|
.get('/api/v1/profile/notification-settings')
|
||||||
|
.set(as('bare'))
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.body).toEqual({
|
||||||
|
taskDueEnabled: true,
|
||||||
|
digestEnabled: true,
|
||||||
|
digestTime: '06:30',
|
||||||
|
quietFrom: '21:00',
|
||||||
|
quietUntil: '06:30',
|
||||||
|
telegramCategories: ['TASK_DUE', 'DIGEST'],
|
||||||
|
telegramDetail: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PATCH notification-settings stores digest time, quiet hours and Telegram categories', async () => {
|
||||||
|
const response = await request(server())
|
||||||
|
.patch('/api/v1/profile/notification-settings')
|
||||||
|
.set(as('beekeeper'))
|
||||||
|
.send({
|
||||||
|
digestTime: '07:15',
|
||||||
|
quietFrom: '22:00',
|
||||||
|
quietUntil: '05:30',
|
||||||
|
telegramCategories: ['SYSTEM', 'WITHDRAWAL'],
|
||||||
|
telegramDetail: false,
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
const settings = response.body as SettingsBody;
|
||||||
|
|
||||||
|
expect(settings).toMatchObject({
|
||||||
|
digestTime: '07:15',
|
||||||
|
quietFrom: '22:00',
|
||||||
|
quietUntil: '05:30',
|
||||||
|
telegramCategories: ['SYSTEM', 'WITHDRAWAL'],
|
||||||
|
telegramDetail: false,
|
||||||
|
taskDueEnabled: true,
|
||||||
|
});
|
||||||
|
const stored = await prisma.notificationPreference.findUniqueOrThrow({
|
||||||
|
where: { userId: ids.beekeeper },
|
||||||
|
});
|
||||||
|
expect(stored.telegramEnabled).toBe(false);
|
||||||
|
expect(stored.digestTime).toBe('07:15');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a digest time outside 05:00 to 12:00 and a lone quiet-hour value', async () => {
|
||||||
|
const early = await request(server())
|
||||||
|
.patch('/api/v1/profile/notification-settings')
|
||||||
|
.set(as('staff'))
|
||||||
|
.send({ digestTime: '04:30' })
|
||||||
|
.expect(400);
|
||||||
|
expect(JSON.stringify(early.body)).toContain(
|
||||||
|
'Bitte eine Uhrzeit zwischen 05:00 und 12:00 wählen.',
|
||||||
|
);
|
||||||
|
|
||||||
|
const lone = await request(server())
|
||||||
|
.patch('/api/v1/profile/notification-settings')
|
||||||
|
.set(as('staff'))
|
||||||
|
.send({ quietFrom: null })
|
||||||
|
.expect(400);
|
||||||
|
expect(JSON.stringify(lone.body)).toContain(
|
||||||
|
'Bitte Beginn und Ende der Ruhezeit zusammen leeren.',
|
||||||
|
);
|
||||||
|
|
||||||
|
await request(server())
|
||||||
|
.patch('/api/v1/profile/notification-settings')
|
||||||
|
.set(as('staff'))
|
||||||
|
.send({ quietFrom: null, quietUntil: null })
|
||||||
|
.expect(200);
|
||||||
|
await request(server())
|
||||||
|
.patch('/api/v1/profile/notification-settings')
|
||||||
|
.set(as('staff'))
|
||||||
|
.send({ digestEnabled: null })
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not accept telegramEnabled', async () => {
|
||||||
|
await request(server())
|
||||||
|
.patch('/api/v1/profile/notification-settings')
|
||||||
|
.set(as('staff'))
|
||||||
|
.send({ telegramEnabled: true })
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { Prisma } from '../generated/prisma';
|
||||||
|
import { claimBatch } from '../src/common/outbox/outbox-claims';
|
||||||
|
import { PrismaModule } from '../src/prisma/prisma.module';
|
||||||
|
import { PrismaService } from '../src/prisma/prisma.service';
|
||||||
|
|
||||||
|
const ROUNDS = 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "availableAt" is TIMESTAMP(3): a value written from SQL now() (stale
|
||||||
|
* release, retry backoff, requeue) is ROUNDED to milliseconds, so it can lie
|
||||||
|
* up to half a millisecond after the next statement's now(). A claim right
|
||||||
|
* behind such a write must still find the row.
|
||||||
|
*/
|
||||||
|
describe('claimBatch and millisecond rounding (e2e)', () => {
|
||||||
|
const correlationId = `claim-rounding-${Date.now()}-${Math.round(Math.random() * 1e6)}`;
|
||||||
|
let moduleRef: TestingModule;
|
||||||
|
let prisma: PrismaService;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
moduleRef = await Test.createTestingModule({
|
||||||
|
imports: [PrismaModule],
|
||||||
|
}).compile();
|
||||||
|
await moduleRef.init();
|
||||||
|
prisma = moduleRef.get(PrismaService);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.domainEvent.deleteMany({ where: { correlationId } });
|
||||||
|
await moduleRef.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('claims an entry whose availableAt was just set to now()', async () => {
|
||||||
|
const event = await prisma.domainEvent.create({
|
||||||
|
data: {
|
||||||
|
type: 'TaskDue',
|
||||||
|
aggregateType: 'Task',
|
||||||
|
aggregateId: correlationId,
|
||||||
|
payload: {},
|
||||||
|
correlationId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let missed = 0;
|
||||||
|
for (let round = 0; round < ROUNDS; round++) {
|
||||||
|
await prisma.$executeRaw`UPDATE "DomainEvent" SET "status" = 'PENDING', "claimedAt" = NULL, "availableAt" = now() WHERE "id" = ${event.id}`;
|
||||||
|
const claimed = await claimBatch(prisma, 'DomainEvent', {
|
||||||
|
limit: 1,
|
||||||
|
filter: Prisma.sql`"correlationId" = ${correlationId}`,
|
||||||
|
orderBy: 'occurredAt',
|
||||||
|
});
|
||||||
|
if (claimed.length !== 1) {
|
||||||
|
missed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(missed).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -213,7 +213,7 @@ describe('Post-commit side effects (e2e)', () => {
|
|||||||
.mockImplementation(() => undefined);
|
.mockImplementation(() => undefined);
|
||||||
// Automation: the warning cannot be written.
|
// Automation: the warning cannot be written.
|
||||||
jest
|
jest
|
||||||
.spyOn(app.get(NotificationsService), 'record')
|
.spyOn(app.get(NotificationsService), 'recordForPermission')
|
||||||
.mockRejectedValue(new Error('notifications down'));
|
.mockRejectedValue(new Error('notifications down'));
|
||||||
// Calendar sync: enqueue cannot read its settings.
|
// Calendar sync: enqueue cannot read its settings.
|
||||||
jest
|
jest
|
||||||
@@ -290,7 +290,7 @@ describe('Post-commit side effects (e2e)', () => {
|
|||||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||||
const colonyId = (await createColony('PC-RETRY')).id;
|
const colonyId = (await createColony('PC-RETRY')).id;
|
||||||
jest
|
jest
|
||||||
.spyOn(app.get(NotificationsService), 'record')
|
.spyOn(app.get(NotificationsService), 'recordForPermission')
|
||||||
.mockRejectedValueOnce(new Error('notifications down'));
|
.mockRejectedValueOnce(new Error('notifications down'));
|
||||||
|
|
||||||
const { body } = (await post('/api/v1/inspections', {
|
const { body } = (await post('/api/v1/inspections', {
|
||||||
@@ -311,9 +311,13 @@ describe('Post-commit side effects (e2e)', () => {
|
|||||||
await prisma.$executeRaw`UPDATE "DomainEvent" SET "availableAt" = now() WHERE "aggregateId" = ${body.id}`;
|
await prisma.$executeRaw`UPDATE "DomainEvent" SET "availableAt" = now() WHERE "aggregateId" = ${body.id}`;
|
||||||
await drainDomainEvents(app, [body.id]);
|
await drainDomainEvents(app, [body.id]);
|
||||||
expect(await taskCount('QUEENLESS_NO_EGGS', colonyId)).toBe(1);
|
expect(await taskCount('QUEENLESS_NO_EGGS', colonyId)).toBe(1);
|
||||||
expect(
|
// One copy per recipient (everybody who may read tasks), written once.
|
||||||
await prisma.notification.count({ where: { relatedId: colonyId } }),
|
const warnings = await prisma.notification.findMany({
|
||||||
).toBe(1);
|
where: { relatedId: colonyId },
|
||||||
|
select: { userId: true },
|
||||||
|
});
|
||||||
|
expect(warnings.length).toBeGreaterThan(0);
|
||||||
|
expect(new Set(warnings.map((w) => w.userId)).size).toBe(warnings.length);
|
||||||
|
|
||||||
const deliveries = await prisma.domainEventDelivery.findMany({
|
const deliveries = await prisma.domainEventDelivery.findMany({
|
||||||
where: { event: { aggregateId: body.id } },
|
where: { event: { aggregateId: body.id } },
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { App } from 'supertest/types';
|
|||||||
import { PrismaService } from '../src/prisma/prisma.service';
|
import { PrismaService } from '../src/prisma/prisma.service';
|
||||||
import { createTestApp } from './utils/create-test-app';
|
import { createTestApp } from './utils/create-test-app';
|
||||||
import { login } from './utils/api';
|
import { login } from './utils/api';
|
||||||
|
import { drainDomainEvents } from './utils/domain-events';
|
||||||
|
import { CalendarSyncSettingsService } from '../src/calendar-sync/calendar-sync-settings.service';
|
||||||
|
|
||||||
interface QueenBody {
|
interface QueenBody {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -22,6 +24,8 @@ describe('Queens (e2e)', () => {
|
|||||||
let colonyId: string;
|
let colonyId: string;
|
||||||
let queenId: string;
|
let queenId: string;
|
||||||
let daughterQueenId: string;
|
let daughterQueenId: string;
|
||||||
|
let secondColonyId: string;
|
||||||
|
let subscriptionId: string;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
app = await createTestApp();
|
app = await createTestApp();
|
||||||
@@ -53,7 +57,43 @@ describe('Queens (e2e)', () => {
|
|||||||
colonyId = colony.id;
|
colonyId = colony.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
if (subscriptionId) {
|
||||||
|
// Deliveries go with their subscription (ON DELETE CASCADE).
|
||||||
|
await prisma.webhookSubscription.deleteMany({
|
||||||
|
where: { id: subscriptionId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (queenId) {
|
||||||
|
const historyIds = (
|
||||||
|
await prisma.queenColonyHistory.findMany({
|
||||||
|
where: { queenId },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
).map((history) => history.id);
|
||||||
|
await prisma.calendarSyncOutboxEntry.deleteMany({
|
||||||
|
where: { sourceType: 'QUEEN_ASSIGNMENT', sourceId: { in: historyIds } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (secondColonyId) {
|
||||||
|
await prisma.queenColonyHistory.deleteMany({
|
||||||
|
where: { colonyId: secondColonyId },
|
||||||
|
});
|
||||||
|
await prisma.colony.update({
|
||||||
|
where: { id: secondColonyId },
|
||||||
|
data: { currentQueenId: null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (queenId) {
|
||||||
|
// Events go with their deliveries (ON DELETE CASCADE).
|
||||||
|
await prisma.domainEvent.deleteMany({
|
||||||
|
where: { aggregateType: 'Queen', aggregateId: queenId },
|
||||||
|
});
|
||||||
|
}
|
||||||
if (queenId) {
|
if (queenId) {
|
||||||
await prisma.queenEvaluation.deleteMany({ where: { queenId } });
|
await prisma.queenEvaluation.deleteMany({ where: { queenId } });
|
||||||
}
|
}
|
||||||
@@ -70,7 +110,9 @@ describe('Queens (e2e)', () => {
|
|||||||
if (queenId) {
|
if (queenId) {
|
||||||
await prisma.queen.delete({ where: { id: queenId } });
|
await prisma.queen.delete({ where: { id: queenId } });
|
||||||
}
|
}
|
||||||
await prisma.colony.deleteMany({ where: { id: colonyId } });
|
await prisma.colony.deleteMany({
|
||||||
|
where: { id: { in: [colonyId, secondColonyId].filter(Boolean) } },
|
||||||
|
});
|
||||||
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
|
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
|
||||||
await prisma.refreshToken.deleteMany({ where: { userId: beekeeperId } });
|
await prisma.refreshToken.deleteMany({ where: { userId: beekeeperId } });
|
||||||
await prisma.auditLog.deleteMany({ where: { userId: beekeeperId } });
|
await prisma.auditLog.deleteMany({ where: { userId: beekeeperId } });
|
||||||
@@ -94,6 +136,8 @@ describe('Queens (e2e)', () => {
|
|||||||
.set('Authorization', `Bearer ${beekeeperToken}`)
|
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||||
.send({ colonyId })
|
.send({ colonyId })
|
||||||
.expect(201);
|
.expect(201);
|
||||||
|
// Settle the first assignment's event, so later tests see only their own.
|
||||||
|
await drainDomainEvents(app, [queenId]);
|
||||||
|
|
||||||
const colony = await request(app.getHttpServer())
|
const colony = await request(app.getHttpServer())
|
||||||
.get(`/api/v1/colonies/${colonyId}`)
|
.get(`/api/v1/colonies/${colonyId}`)
|
||||||
@@ -110,6 +154,86 @@ describe('Queens (e2e)', () => {
|
|||||||
expect(history.body).toHaveLength(1);
|
expect(history.body).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('assigning a queen leaves a calendar entry and a queen.replaced delivery after draining', async () => {
|
||||||
|
jest.spyOn(app.get(CalendarSyncSettingsService), 'get').mockResolvedValue({
|
||||||
|
id: 'singleton',
|
||||||
|
enabled: true,
|
||||||
|
groupId: 'group-e2e',
|
||||||
|
syncTask: true,
|
||||||
|
syncInspection: true,
|
||||||
|
syncTreatment: true,
|
||||||
|
syncFeeding: true,
|
||||||
|
syncHarvest: true,
|
||||||
|
syncQueenAssignment: true,
|
||||||
|
syncWithdrawal: true,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
subscriptionId = (
|
||||||
|
await prisma.webhookSubscription.create({
|
||||||
|
data: {
|
||||||
|
url: 'https://example.com/hook',
|
||||||
|
events: ['queen.replaced'],
|
||||||
|
secret: 'a'.repeat(64),
|
||||||
|
createdByUserId: beekeeperId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).id;
|
||||||
|
secondColonyId = (
|
||||||
|
await prisma.colony.create({
|
||||||
|
data: {
|
||||||
|
code: `V2-${Date.now()}`,
|
||||||
|
apiaryId,
|
||||||
|
colonyType: 'PRODUCTION_COLONY',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).id;
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post(`/api/v1/queens/${queenId}/assign-colony`)
|
||||||
|
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||||
|
.send({ colonyId: secondColonyId })
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
// Nothing but the event is written in the request.
|
||||||
|
const history = await prisma.queenColonyHistory.findFirstOrThrow({
|
||||||
|
where: { queenId, colonyId: secondColonyId },
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
await prisma.calendarSyncOutboxEntry.count({
|
||||||
|
where: { sourceType: 'QUEEN_ASSIGNMENT', sourceId: history.id },
|
||||||
|
}),
|
||||||
|
).toBe(0);
|
||||||
|
expect(
|
||||||
|
await prisma.webhookDelivery.count({
|
||||||
|
where: { subscriptionId },
|
||||||
|
}),
|
||||||
|
).toBe(0);
|
||||||
|
const event = await prisma.domainEvent.findFirstOrThrow({
|
||||||
|
where: { type: 'QueenAssigned', aggregateId: queenId },
|
||||||
|
orderBy: { occurredAt: 'desc' },
|
||||||
|
});
|
||||||
|
expect(event.payload).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
colonyId: secondColonyId,
|
||||||
|
historyId: history.id,
|
||||||
|
previousColonyId: colonyId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await drainDomainEvents(app, [queenId]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await prisma.calendarSyncOutboxEntry.count({
|
||||||
|
where: { sourceType: 'QUEEN_ASSIGNMENT', sourceId: history.id },
|
||||||
|
}),
|
||||||
|
).toBe(1);
|
||||||
|
const deliveries = await prisma.webhookDelivery.findMany({
|
||||||
|
where: { subscriptionId, event: 'queen.replaced' },
|
||||||
|
});
|
||||||
|
expect(deliveries).toHaveLength(1);
|
||||||
|
expect(deliveries[0]?.body).toContain(queenId);
|
||||||
|
});
|
||||||
|
|
||||||
it('adds a queen evaluation and lists it back', async () => {
|
it('adds a queen evaluation and lists it back', async () => {
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.post(`/api/v1/queens/${queenId}/evaluations`)
|
.post(`/api/v1/queens/${queenId}/evaluations`)
|
||||||
|
|||||||
@@ -0,0 +1,648 @@
|
|||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import * as bcrypt from 'bcryptjs';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { App } from 'supertest/types';
|
||||||
|
import { PrismaService } from '../src/prisma/prisma.service';
|
||||||
|
import type { ProposeInput } from '../src/suggestions/suggestion.types';
|
||||||
|
import { login } from './utils/api';
|
||||||
|
import { createTestApp } from './utils/create-test-app';
|
||||||
|
import { drainDomainEvents } from './utils/domain-events';
|
||||||
|
import { proposeForTest } from './utils/suggestions';
|
||||||
|
|
||||||
|
interface ViewBody {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
reasons: string[];
|
||||||
|
colonyCode: string | null;
|
||||||
|
apiaryName: string | null;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VIEW_KEYS = [
|
||||||
|
'apiaryId',
|
||||||
|
'apiaryName',
|
||||||
|
'cleanup',
|
||||||
|
'colonyCode',
|
||||||
|
'colonyId',
|
||||||
|
'createdAt',
|
||||||
|
'decidedAt',
|
||||||
|
'decisionNote',
|
||||||
|
'dueDate',
|
||||||
|
'expiresAt',
|
||||||
|
'id',
|
||||||
|
'intent',
|
||||||
|
'mode',
|
||||||
|
'plan',
|
||||||
|
'priority',
|
||||||
|
'reasons',
|
||||||
|
'relatedId',
|
||||||
|
'relatedType',
|
||||||
|
'ruleKey',
|
||||||
|
'source',
|
||||||
|
'status',
|
||||||
|
'title',
|
||||||
|
];
|
||||||
|
const INTERNAL_KEYS = [
|
||||||
|
'evidence',
|
||||||
|
'dedupeKey',
|
||||||
|
'requiredPermission',
|
||||||
|
'decidedByUserId',
|
||||||
|
'reasonCode',
|
||||||
|
'sourceEventId',
|
||||||
|
];
|
||||||
|
|
||||||
|
function expectViewShape(view: object): void {
|
||||||
|
expect(Object.keys(view).sort()).toEqual(VIEW_KEYS);
|
||||||
|
for (const key of INTERNAL_KEYS) {
|
||||||
|
expect(view).not.toHaveProperty(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The suggestion routes against a real PostgreSQL, as real roles. */
|
||||||
|
describe('Suggestions API (e2e)', () => {
|
||||||
|
let app: INestApplication<App>;
|
||||||
|
let prisma: PrismaService;
|
||||||
|
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
|
||||||
|
const password = 'SuperSecret123!';
|
||||||
|
const base = '/api/v1/suggestions';
|
||||||
|
const ADMIN_PERMISSIONS = [
|
||||||
|
'tasks:read',
|
||||||
|
'tasks:create',
|
||||||
|
'tasks:update',
|
||||||
|
'inspections:read',
|
||||||
|
'varroa:read',
|
||||||
|
'colonies:read',
|
||||||
|
];
|
||||||
|
let apiaryId: string;
|
||||||
|
let colonyId: string;
|
||||||
|
let inactiveUserId: string;
|
||||||
|
const roleIds: string[] = [];
|
||||||
|
const userIds: string[] = [];
|
||||||
|
const tokens: Record<
|
||||||
|
'full' | 'noVarroa' | 'readOnly' | 'noUpdate' | 'noTasks',
|
||||||
|
string
|
||||||
|
> = { full: '', noVarroa: '', readOnly: '', noUpdate: '', noTasks: '' };
|
||||||
|
let keyCounter = 0;
|
||||||
|
const nextKey = () => `suggestions-api-${suffix}-${(keyCounter += 1)}-key`;
|
||||||
|
|
||||||
|
const proposal = (anchor: string): ProposeInput => ({
|
||||||
|
ruleKey: 'INSPECTION_INTERVAL',
|
||||||
|
source: 'RULE',
|
||||||
|
mode: 'DO',
|
||||||
|
intent: 'INSPECT',
|
||||||
|
relatedType: 'COLONY',
|
||||||
|
relatedId: colonyId,
|
||||||
|
colonyId,
|
||||||
|
reasonCode: `SUGGESTIONS_API_${suffix}`,
|
||||||
|
evidence: { colonyId },
|
||||||
|
requiredPermission: 'inspections:read',
|
||||||
|
dueDate: '2026-05-12',
|
||||||
|
anchor,
|
||||||
|
});
|
||||||
|
|
||||||
|
const get = (token: string, path: string) =>
|
||||||
|
request(app.getHttpServer())
|
||||||
|
.get(`${base}${path}`)
|
||||||
|
.set('Authorization', `Bearer ${token}`);
|
||||||
|
|
||||||
|
const post = (token: string, path: string, body: object = {}, key?: string) =>
|
||||||
|
request(app.getHttpServer())
|
||||||
|
.post(`${base}${path}`)
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.set('Idempotency-Key', key ?? nextKey())
|
||||||
|
.send(body);
|
||||||
|
|
||||||
|
const tasksOf = (suggestionId: string) =>
|
||||||
|
prisma.task.findMany({ where: { sourceSuggestionId: suggestionId } });
|
||||||
|
|
||||||
|
async function createUser(
|
||||||
|
label: string,
|
||||||
|
permissions: string[],
|
||||||
|
isActive = true,
|
||||||
|
): Promise<{ id: string; token: string }> {
|
||||||
|
const role = await prisma.role.create({
|
||||||
|
data: {
|
||||||
|
key: `SUGGESTIONS_API_${label.toUpperCase()}_${suffix}`,
|
||||||
|
name: `Suggestions API ${label}`,
|
||||||
|
permissions: {
|
||||||
|
create: permissions.map((key) => ({
|
||||||
|
permission: { connect: { key } },
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
roleIds.push(role.id);
|
||||||
|
const email = `suggestions-api-${label}-${suffix}@example.com`;
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email,
|
||||||
|
passwordHash: await bcrypt.hash(password, 12),
|
||||||
|
name: `Suggestions API ${label}`,
|
||||||
|
isActive,
|
||||||
|
role: { connect: { id: role.id } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
userIds.push(user.id);
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
token: isActive ? (await login(app, email, password)).accessToken : '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
app = await createTestApp();
|
||||||
|
prisma = app.get(PrismaService);
|
||||||
|
apiaryId = (
|
||||||
|
await prisma.apiary.create({ data: { name: `Sug API ${suffix}` } })
|
||||||
|
).id;
|
||||||
|
colonyId = (
|
||||||
|
await prisma.colony.create({
|
||||||
|
data: {
|
||||||
|
code: `SA-${suffix}`,
|
||||||
|
apiaryId,
|
||||||
|
colonyType: 'PRODUCTION_COLONY',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).id;
|
||||||
|
tokens.full = (await createUser('full', ADMIN_PERMISSIONS)).token;
|
||||||
|
tokens.noVarroa = (
|
||||||
|
await createUser(
|
||||||
|
'novarroa',
|
||||||
|
ADMIN_PERMISSIONS.filter((key) => key !== 'varroa:read'),
|
||||||
|
)
|
||||||
|
).token;
|
||||||
|
tokens.readOnly = (await createUser('readonly', ['tasks:read'])).token;
|
||||||
|
tokens.noUpdate = (
|
||||||
|
await createUser('noupdate', ['tasks:read', 'tasks:create'])
|
||||||
|
).token;
|
||||||
|
tokens.noTasks = (await createUser('notasks', ['colonies:read'])).token;
|
||||||
|
inactiveUserId = (await createUser('inactive', ['tasks:read'], false)).id;
|
||||||
|
});
|
||||||
|
|
||||||
|
const cleanDb = async () => {
|
||||||
|
const ids = (
|
||||||
|
await prisma.suggestion.findMany({
|
||||||
|
where: { colonyId },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
).map((row) => row.id);
|
||||||
|
const taskIds = (
|
||||||
|
await prisma.task.findMany({
|
||||||
|
where: { sourceSuggestionId: { in: ids } },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
).map((row) => row.id);
|
||||||
|
await prisma.domainEvent.deleteMany({
|
||||||
|
where: { aggregateId: { in: taskIds } },
|
||||||
|
});
|
||||||
|
await prisma.calendarSyncOutboxEntry.deleteMany({
|
||||||
|
where: { sourceType: 'TASK', sourceId: { in: taskIds } },
|
||||||
|
});
|
||||||
|
await prisma.task.deleteMany({ where: { id: { in: taskIds } } });
|
||||||
|
await prisma.suggestion.deleteMany({ where: { colonyId } });
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(cleanDb);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await cleanDb();
|
||||||
|
await prisma.colony.delete({ where: { id: colonyId } });
|
||||||
|
await prisma.apiary.delete({ where: { id: apiaryId } });
|
||||||
|
await prisma.idempotencyRecord.deleteMany({
|
||||||
|
where: { userId: { in: userIds } },
|
||||||
|
});
|
||||||
|
await prisma.refreshToken.deleteMany({
|
||||||
|
where: { userId: { in: userIds } },
|
||||||
|
});
|
||||||
|
await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } });
|
||||||
|
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
|
||||||
|
await prisma.role.deleteMany({ where: { id: { in: roleIds } } });
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists open suggestions with rendered title and reasons', async () => {
|
||||||
|
const created = await proposeForTest(app, proposal('a1'));
|
||||||
|
|
||||||
|
const response = await get(tokens.full, `?colonyId=${colonyId}`).expect(
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [view] = response.body as ViewBody[];
|
||||||
|
expect(view).toMatchObject({
|
||||||
|
id: created.id,
|
||||||
|
title: 'Durchsicht fällig',
|
||||||
|
reasons: [],
|
||||||
|
colonyCode: `SA-${suffix}`,
|
||||||
|
apiaryName: `Sug API ${suffix}`,
|
||||||
|
status: 'OPEN',
|
||||||
|
dueDate: '2026-05-12',
|
||||||
|
});
|
||||||
|
const byApiary = await get(tokens.full, `?apiaryId=${apiaryId}`).expect(
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
expect(byApiary.body).toHaveLength(1);
|
||||||
|
const other = await get(tokens.full, '?apiaryId=none').expect(200);
|
||||||
|
expect(other.body).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a user without varroa:read sees neither the varroa suggestion nor its count', async () => {
|
||||||
|
await proposeForTest(app, {
|
||||||
|
...proposal('v1'),
|
||||||
|
ruleKey: 'VARROA_THRESHOLD',
|
||||||
|
intent: 'VARROA_TREAT',
|
||||||
|
requiredPermission: 'varroa:read',
|
||||||
|
});
|
||||||
|
|
||||||
|
const hidden = await get(tokens.noVarroa, `?colonyId=${colonyId}`);
|
||||||
|
const hiddenSummary = await get(tokens.noVarroa, '/summary');
|
||||||
|
const seen = await get(tokens.full, `?colonyId=${colonyId}`);
|
||||||
|
const seenSummary = await get(tokens.full, '/summary');
|
||||||
|
|
||||||
|
expect(hidden.body).toEqual([]);
|
||||||
|
expect((hiddenSummary.body as { open: number }).open).toBe(0);
|
||||||
|
expect(seen.body).toHaveLength(1);
|
||||||
|
expect((seenSummary.body as { open: number }).open).toBeGreaterThanOrEqual(
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(seenSummary.body as { byPriority: Record<string, number> }).byPriority,
|
||||||
|
).toEqual(
|
||||||
|
expect.objectContaining({ LOW: 0, NORMAL: expect.any(Number) as number }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers 404 to a decision on a suggestion the user may not see', async () => {
|
||||||
|
const created = await proposeForTest(app, {
|
||||||
|
...proposal('v2'),
|
||||||
|
ruleKey: 'VARROA_THRESHOLD',
|
||||||
|
intent: 'VARROA_TREAT',
|
||||||
|
requiredPermission: 'varroa:read',
|
||||||
|
});
|
||||||
|
|
||||||
|
await post(tokens.noVarroa, `/${created.id}/accept`).expect(404);
|
||||||
|
await post(tokens.noVarroa, `/${created.id}/dismiss`).expect(404);
|
||||||
|
await post(tokens.noVarroa, '/bulk-accept', { ids: [created.id] }).expect(
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
await post(tokens.noVarroa, '/bulk-dismiss', { ids: [created.id] }).expect(
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
await post(tokens.full, `/${created.id}/dismiss`).expect(201);
|
||||||
|
await post(tokens.noVarroa, `/${created.id}/reopen`).expect(404);
|
||||||
|
expect(await tasksOf(created.id)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accept creates exactly one task with sourceSuggestionId and a calendar delivery', async () => {
|
||||||
|
const created = await proposeForTest(app, proposal('c1'));
|
||||||
|
|
||||||
|
const response = await post(tokens.full, `/${created.id}/accept`).expect(
|
||||||
|
201,
|
||||||
|
);
|
||||||
|
|
||||||
|
const tasks = await tasksOf(created.id);
|
||||||
|
expect(tasks).toHaveLength(1);
|
||||||
|
expect(tasks[0]).toMatchObject({
|
||||||
|
sourceSuggestionId: created.id,
|
||||||
|
title: 'Durchsicht fällig',
|
||||||
|
});
|
||||||
|
const body = response.body as {
|
||||||
|
suggestion: object & { status: string };
|
||||||
|
tasks: { id: string }[];
|
||||||
|
};
|
||||||
|
expect(Object.keys(body).sort()).toEqual(['suggestion', 'tasks']);
|
||||||
|
expectViewShape(body.suggestion);
|
||||||
|
expect(body.suggestion.status).toBe('ACCEPTED');
|
||||||
|
expect(body.tasks.map((task) => task.id)).toEqual([tasks[0].id]);
|
||||||
|
expect(body.tasks[0]).toMatchObject({
|
||||||
|
sourceSuggestionId: created.id,
|
||||||
|
title: 'Durchsicht fällig',
|
||||||
|
});
|
||||||
|
await drainDomainEvents(app, [tasks[0].id]);
|
||||||
|
const events = await prisma.domainEvent.findMany({
|
||||||
|
where: { aggregateType: 'Task', aggregateId: tasks[0].id },
|
||||||
|
include: { deliveries: true },
|
||||||
|
});
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].deliveries).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
handler: 'calendar-sync.task',
|
||||||
|
status: 'DONE',
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a second accept with another key answers 409 and leaves exactly one task', async () => {
|
||||||
|
const created = await proposeForTest(app, proposal('c2'));
|
||||||
|
await post(tokens.full, `/${created.id}/accept`).expect(201);
|
||||||
|
|
||||||
|
const second = await post(tokens.full, `/${created.id}/accept`).expect(409);
|
||||||
|
|
||||||
|
expect(second.body).toMatchObject({
|
||||||
|
message: 'Der Vorschlag ist nicht mehr offen.',
|
||||||
|
status: 'ACCEPTED',
|
||||||
|
});
|
||||||
|
expect(await tasksOf(created.id)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the same Idempotency-Key replays the first accept', async () => {
|
||||||
|
const created = await proposeForTest(app, proposal('c3'));
|
||||||
|
const key = nextKey();
|
||||||
|
|
||||||
|
const first = await post(
|
||||||
|
tokens.full,
|
||||||
|
`/${created.id}/accept`,
|
||||||
|
{},
|
||||||
|
key,
|
||||||
|
).expect(201);
|
||||||
|
const replay = await post(
|
||||||
|
tokens.full,
|
||||||
|
`/${created.id}/accept`,
|
||||||
|
{},
|
||||||
|
key,
|
||||||
|
).expect(201);
|
||||||
|
|
||||||
|
expect(replay.body).toEqual(first.body);
|
||||||
|
expect(await tasksOf(created.id)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bulk-accept with one non-OPEN id answers 409 and creates no task', async () => {
|
||||||
|
const open = await proposeForTest(app, proposal('b1'));
|
||||||
|
const dismissed = await proposeForTest(app, {
|
||||||
|
...proposal('b2'),
|
||||||
|
ruleKey: 'QUEEN_ACCEPTANCE_CHECK',
|
||||||
|
intent: 'QUEEN_ACCEPTANCE',
|
||||||
|
});
|
||||||
|
await post(tokens.full, `/${dismissed.id}/dismiss`).expect(201);
|
||||||
|
|
||||||
|
await post(tokens.full, '/bulk-accept', {
|
||||||
|
ids: [open.id, dismissed.id],
|
||||||
|
}).expect(409);
|
||||||
|
|
||||||
|
expect(await tasksOf(open.id)).toHaveLength(0);
|
||||||
|
expect(
|
||||||
|
(await prisma.suggestion.findUniqueOrThrow({ where: { id: open.id } }))
|
||||||
|
.status,
|
||||||
|
).toBe('OPEN');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bulk endpoints reject 0 or 51 ids', async () => {
|
||||||
|
const ids51 = Array.from({ length: 51 }, (_, index) => `id-${index}`);
|
||||||
|
|
||||||
|
for (const path of ['/bulk-accept', '/bulk-dismiss']) {
|
||||||
|
await post(tokens.full, path, { ids: [] }).expect(400);
|
||||||
|
await post(tokens.full, path, { ids: ids51 }).expect(400);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bulk-dismiss dismisses all given suggestions', async () => {
|
||||||
|
const first = await proposeForTest(app, proposal('d1'));
|
||||||
|
const second = await proposeForTest(app, {
|
||||||
|
...proposal('d2'),
|
||||||
|
ruleKey: 'QUEEN_ACCEPTANCE_CHECK',
|
||||||
|
intent: 'QUEEN_ACCEPTANCE',
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await post(tokens.full, '/bulk-dismiss', {
|
||||||
|
ids: [first.id, second.id],
|
||||||
|
}).expect(201);
|
||||||
|
|
||||||
|
expect(response.body).toEqual({ dismissed: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accept with an unknown or inactive assignedUserId answers 400 and creates no task', async () => {
|
||||||
|
const created = await proposeForTest(app, proposal('u1'));
|
||||||
|
|
||||||
|
await post(tokens.full, `/${created.id}/accept`, {
|
||||||
|
assignedUserId: 'does-not-exist',
|
||||||
|
}).expect(400);
|
||||||
|
const inactive = await post(tokens.full, `/${created.id}/accept`, {
|
||||||
|
assignedUserId: inactiveUserId,
|
||||||
|
}).expect(400);
|
||||||
|
|
||||||
|
expect(inactive.body).toMatchObject({
|
||||||
|
issues: [{ field: 'assignedUserId' }],
|
||||||
|
});
|
||||||
|
expect(await tasksOf(created.id)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dismiss then reopen restores OPEN; the dismissed one is listed for 30 days', async () => {
|
||||||
|
const created = await proposeForTest(app, proposal('r1'));
|
||||||
|
|
||||||
|
const dismissResponse = await post(tokens.full, `/${created.id}/dismiss`, {
|
||||||
|
note: 'später',
|
||||||
|
}).expect(201);
|
||||||
|
expectViewShape(dismissResponse.body as object);
|
||||||
|
expect(dismissResponse.body).toMatchObject({
|
||||||
|
status: 'DISMISSED',
|
||||||
|
decisionNote: 'später',
|
||||||
|
});
|
||||||
|
const dismissed = await get(
|
||||||
|
tokens.full,
|
||||||
|
`?status=DISMISSED&colonyId=${colonyId}`,
|
||||||
|
).expect(200);
|
||||||
|
expect(await get(tokens.full, `?colonyId=${colonyId}`)).toMatchObject({
|
||||||
|
body: [],
|
||||||
|
});
|
||||||
|
await prisma.$executeRaw`UPDATE "Suggestion" SET "decidedAt" = now() - interval '31 days' WHERE id = ${created.id}`;
|
||||||
|
const stale = await get(
|
||||||
|
tokens.full,
|
||||||
|
`?status=DISMISSED&colonyId=${colonyId}`,
|
||||||
|
).expect(200);
|
||||||
|
await prisma.$executeRaw`UPDATE "Suggestion" SET "decidedAt" = now() WHERE id = ${created.id}`;
|
||||||
|
const reopened = await post(tokens.full, `/${created.id}/reopen`).expect(
|
||||||
|
201,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(dismissed.body).toHaveLength(1);
|
||||||
|
expect((dismissed.body as { decisionNote: string }[])[0]).toMatchObject({
|
||||||
|
decisionNote: 'später',
|
||||||
|
});
|
||||||
|
expect(stale.body).toEqual([]);
|
||||||
|
expectViewShape(reopened.body as object);
|
||||||
|
expect(reopened.body).toMatchObject({ status: 'OPEN' });
|
||||||
|
expect(await get(tokens.full, `?colonyId=${colonyId}`)).toMatchObject({
|
||||||
|
body: [expect.objectContaining({ id: created.id })],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a dismiss note over 500 characters', async () => {
|
||||||
|
const created = await proposeForTest(app, proposal('n1'));
|
||||||
|
|
||||||
|
await post(tokens.full, `/${created.id}/dismiss`, {
|
||||||
|
note: 'x'.repeat(501),
|
||||||
|
}).expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('403 without tasks:create on accept and without tasks:update on dismiss', async () => {
|
||||||
|
const created = await proposeForTest(app, proposal('p1'));
|
||||||
|
|
||||||
|
await post(tokens.readOnly, `/${created.id}/accept`).expect(403);
|
||||||
|
await post(tokens.readOnly, '/bulk-accept', { ids: [created.id] }).expect(
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
await post(tokens.noUpdate, `/${created.id}/dismiss`).expect(403);
|
||||||
|
await post(tokens.noUpdate, `/${created.id}/reopen`).expect(403);
|
||||||
|
await post(tokens.noUpdate, '/bulk-dismiss', { ids: [created.id] }).expect(
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
await request(app.getHttpServer()).get(base).expect(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bulk-accept answers one result per suggestion in the view shape', async () => {
|
||||||
|
const first = await proposeForTest(app, proposal('ba1'));
|
||||||
|
const second = await proposeForTest(app, {
|
||||||
|
...proposal('ba2'),
|
||||||
|
ruleKey: 'QUEEN_ACCEPTANCE_CHECK',
|
||||||
|
intent: 'QUEEN_ACCEPTANCE',
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await post(tokens.full, '/bulk-accept', {
|
||||||
|
ids: [first.id, second.id],
|
||||||
|
}).expect(201);
|
||||||
|
|
||||||
|
const results = response.body as {
|
||||||
|
suggestion: { id: string };
|
||||||
|
tasks: object[];
|
||||||
|
}[];
|
||||||
|
expect(results.map((result) => result.suggestion.id)).toEqual([
|
||||||
|
first.id,
|
||||||
|
second.id,
|
||||||
|
]);
|
||||||
|
for (const result of results) {
|
||||||
|
expectViewShape(result.suggestion);
|
||||||
|
expect(result.tasks).toHaveLength(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('403 on the list and the summary without tasks:read', async () => {
|
||||||
|
await get(tokens.noTasks, '').expect(403);
|
||||||
|
await get(tokens.noTasks, '/summary').expect(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an invalid status, a datetime dueDate and a bad limit with 400', async () => {
|
||||||
|
const created = await proposeForTest(app, proposal('v400'));
|
||||||
|
|
||||||
|
await get(tokens.full, '?status=NOPE').expect(400);
|
||||||
|
await get(tokens.full, '?limit=501').expect(400);
|
||||||
|
await get(tokens.full, '?limit=0').expect(400);
|
||||||
|
await get(tokens.full, '?offset=-1').expect(400);
|
||||||
|
const response = await post(tokens.full, `/${created.id}/accept`, {
|
||||||
|
dueDate: '2026-05-12T10:00:00Z',
|
||||||
|
}).expect(400);
|
||||||
|
|
||||||
|
expect(JSON.stringify(response.body)).toContain('JJJJ-MM-TT');
|
||||||
|
expect(await tasksOf(created.id)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('limit and offset page through the list', async () => {
|
||||||
|
await proposeForTest(app, proposal('l1'));
|
||||||
|
await proposeForTest(app, {
|
||||||
|
...proposal('l2'),
|
||||||
|
ruleKey: 'QUEEN_ACCEPTANCE_CHECK',
|
||||||
|
intent: 'QUEEN_ACCEPTANCE',
|
||||||
|
});
|
||||||
|
const all = (await get(tokens.full, `?colonyId=${colonyId}`).expect(200))
|
||||||
|
.body as ViewBody[];
|
||||||
|
|
||||||
|
const first = (
|
||||||
|
await get(tokens.full, `?colonyId=${colonyId}&limit=1`).expect(200)
|
||||||
|
).body as ViewBody[];
|
||||||
|
const second = (
|
||||||
|
await get(tokens.full, `?colonyId=${colonyId}&limit=1&offset=1`).expect(
|
||||||
|
200,
|
||||||
|
)
|
||||||
|
).body as ViewBody[];
|
||||||
|
|
||||||
|
expect(all).toHaveLength(2);
|
||||||
|
expect(first.map((view) => view.id)).toEqual([all[0].id]);
|
||||||
|
expect(second.map((view) => view.id)).toEqual([all[1].id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('combines the apiaryId filter with status and colonyId', async () => {
|
||||||
|
const created = await proposeForTest(app, proposal('f1'));
|
||||||
|
await post(tokens.full, `/${created.id}/dismiss`).expect(201);
|
||||||
|
|
||||||
|
const hit = await get(
|
||||||
|
tokens.full,
|
||||||
|
`?apiaryId=${apiaryId}&colonyId=${colonyId}&status=DISMISSED`,
|
||||||
|
).expect(200);
|
||||||
|
const wrongStatus = await get(
|
||||||
|
tokens.full,
|
||||||
|
`?apiaryId=${apiaryId}&colonyId=${colonyId}&status=OPEN`,
|
||||||
|
).expect(200);
|
||||||
|
const wrongApiary = await get(
|
||||||
|
tokens.full,
|
||||||
|
`?apiaryId=none&colonyId=${colonyId}&status=DISMISSED`,
|
||||||
|
).expect(200);
|
||||||
|
|
||||||
|
expect((hit.body as ViewBody[]).map((view) => view.id)).toEqual([
|
||||||
|
created.id,
|
||||||
|
]);
|
||||||
|
expect(wrongStatus.body).toEqual([]);
|
||||||
|
expect(wrongApiary.body).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sorts by due date (none last), then priority (highest first)', async () => {
|
||||||
|
const mk = (
|
||||||
|
anchor: string,
|
||||||
|
intent: ProposeInput['intent'],
|
||||||
|
priority: ProposeInput['priority'],
|
||||||
|
dueDate: string | null,
|
||||||
|
) =>
|
||||||
|
proposeForTest(app, { ...proposal(anchor), intent, priority, dueDate });
|
||||||
|
const undated = await mk('s1', 'QUEEN_CHECK', 'URGENT', null);
|
||||||
|
const normal = await mk('s2', 'INSPECT', 'NORMAL', '2026-05-12');
|
||||||
|
const urgent = await mk('s3', 'SWARM_CHECK', 'URGENT', '2026-05-12');
|
||||||
|
const early = await mk('s4', 'HEALTH_CHECK', 'LOW', '2026-05-10');
|
||||||
|
|
||||||
|
const response = await get(tokens.full, `?colonyId=${colonyId}`).expect(
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect((response.body as ViewBody[]).map((view) => view.id)).toEqual([
|
||||||
|
early.id,
|
||||||
|
urgent.id,
|
||||||
|
normal.id,
|
||||||
|
undated.id,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the cleanup payload from the openTaskIds evidence', async () => {
|
||||||
|
await proposeForTest(app, {
|
||||||
|
...proposal('cl1'),
|
||||||
|
ruleKey: 'COLONY_ENDED_CLEANUP',
|
||||||
|
intent: 'CLEANUP',
|
||||||
|
requiredPermission: 'colonies:read',
|
||||||
|
evidence: {
|
||||||
|
colonyId,
|
||||||
|
status: 'DECEASED',
|
||||||
|
endedOn: '2026-03-12',
|
||||||
|
via: 'winter',
|
||||||
|
openTaskIds: ['t_a', 't_b'],
|
||||||
|
queenId: 'q_1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await proposeForTest(app, { ...proposal('cl2'), intent: 'QUEEN_CHECK' });
|
||||||
|
|
||||||
|
const views = (await get(tokens.full, `?colonyId=${colonyId}`).expect(200))
|
||||||
|
.body as { intent: string; cleanup: unknown }[];
|
||||||
|
|
||||||
|
expect(views.find((v) => v.intent === 'CLEANUP')?.cleanup).toEqual({
|
||||||
|
taskIds: ['t_a', 't_b'],
|
||||||
|
queenId: 'q_1',
|
||||||
|
});
|
||||||
|
expect(views.find((v) => v.intent === 'QUEEN_CHECK')?.cleanup).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bulk-dismiss answers { dismissed } only', async () => {
|
||||||
|
const created = await proposeForTest(app, proposal('bd1'));
|
||||||
|
|
||||||
|
const response = await post(tokens.full, '/bulk-dismiss', {
|
||||||
|
ids: [created.id],
|
||||||
|
}).expect(201);
|
||||||
|
|
||||||
|
expect(response.body).toEqual({ dismissed: 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,8 +6,11 @@ import {
|
|||||||
const FEED_VOCABULARY = '20261014100200_t4_feed_vocabulary';
|
const FEED_VOCABULARY = '20261014100200_t4_feed_vocabulary';
|
||||||
const SPLIT_QUEEN_SOURCE = '20261014100300_t4_split_queen_source';
|
const SPLIT_QUEEN_SOURCE = '20261014100300_t4_split_queen_source';
|
||||||
const VARROA_SAMPLE_SIZE = '20261014100400_t4_varroa_sample_size';
|
const VARROA_SAMPLE_SIZE = '20261014100400_t4_varroa_sample_size';
|
||||||
|
const NOTIFICATION_FANOUT = '20261014100500_t4_notification_fanout';
|
||||||
|
const OCCUPIED_FRAMES = '20261014100700_t4_occupied_frames';
|
||||||
|
const NOTIFICATION_SETTINGS = '20261014100800_t4_notification_settings';
|
||||||
|
|
||||||
const TIMEOUT_MS = 120_000;
|
const TIMEOUT_MS = 240_000;
|
||||||
|
|
||||||
interface InspectionSeed {
|
interface InspectionSeed {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -36,6 +39,48 @@ async function seedColony(db: ScratchDatabase): Promise<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Two active users and one inactive one, plus the role they need. */
|
||||||
|
async function seedUsers(db: ScratchDatabase): Promise<void> {
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO "Role" ("id", "key", "name", "updatedAt")
|
||||||
|
VALUES ('role-1', 'MIG_TEST', 'Migration test', now())`,
|
||||||
|
);
|
||||||
|
for (const [id, active] of [
|
||||||
|
['user-a', true],
|
||||||
|
['user-b', true],
|
||||||
|
['user-c', false],
|
||||||
|
] as const) {
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO "User" ("id", "email", "passwordHash", "name", "roleId", "isActive", "updatedAt")
|
||||||
|
VALUES ($1, $2, 'x', $1, 'role-1', $3, now())`,
|
||||||
|
[id, `${id}@example.com`, active],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedNotification(
|
||||||
|
db: ScratchDatabase,
|
||||||
|
row: {
|
||||||
|
id: string;
|
||||||
|
userId: string | null;
|
||||||
|
relatedType: string | null;
|
||||||
|
read?: boolean;
|
||||||
|
createdAt?: string;
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO "Notification" ("id", "userId", "priority", "message", "relatedType", "read", "createdAt")
|
||||||
|
VALUES ($1, $2, 'WARNUNG', 'm', $3, $4, $5)`,
|
||||||
|
[
|
||||||
|
row.id,
|
||||||
|
row.userId,
|
||||||
|
row.relatedType,
|
||||||
|
row.read ?? false,
|
||||||
|
row.createdAt ?? '2026-05-01T10:00:00Z',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function seedInspections(
|
async function seedInspections(
|
||||||
db: ScratchDatabase,
|
db: ScratchDatabase,
|
||||||
seeds: InspectionSeed[],
|
seeds: InspectionSeed[],
|
||||||
@@ -215,4 +260,215 @@ describe('T4 schema II migrations (e2e)', () => {
|
|||||||
},
|
},
|
||||||
TIMEOUT_MS,
|
TIMEOUT_MS,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'broadcasts are copied per active user with their read flag',
|
||||||
|
async () => {
|
||||||
|
db = await createScratchDatabase(NOTIFICATION_FANOUT);
|
||||||
|
await seedUsers(db);
|
||||||
|
await seedNotification(db, {
|
||||||
|
id: 'broadcast',
|
||||||
|
userId: null,
|
||||||
|
relatedType: 'COLONY',
|
||||||
|
read: true,
|
||||||
|
createdAt: '2026-04-01T08:00:00Z',
|
||||||
|
});
|
||||||
|
await seedNotification(db, {
|
||||||
|
id: 'personal',
|
||||||
|
userId: 'user-c',
|
||||||
|
relatedType: 'COLONY',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [{ createdAt }] = await db.query<{ createdAt: string }>(
|
||||||
|
`SELECT "createdAt"::text AS "createdAt" FROM "Notification" WHERE "id" = 'broadcast'`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await db.migrateThrough(NOTIFICATION_FANOUT);
|
||||||
|
|
||||||
|
const rows = await db.query<{
|
||||||
|
userId: string;
|
||||||
|
read: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}>(
|
||||||
|
`SELECT "userId", "read", "createdAt"::text AS "createdAt"
|
||||||
|
FROM "Notification" `,
|
||||||
|
);
|
||||||
|
rows.sort((a, b) => (a.userId < b.userId ? -1 : 1));
|
||||||
|
expect(rows.map(({ userId, read }) => ({ userId, read }))).toEqual([
|
||||||
|
{ userId: 'user-a', read: true },
|
||||||
|
{ userId: 'user-b', read: true },
|
||||||
|
{ userId: 'user-c', read: false },
|
||||||
|
]);
|
||||||
|
expect(rows[0].createdAt).toBe(createdAt);
|
||||||
|
expect(rows[1].createdAt).toBe(createdAt);
|
||||||
|
expect(
|
||||||
|
await db.query(
|
||||||
|
`SELECT is_nullable FROM information_schema.columns
|
||||||
|
WHERE table_schema = current_schema() AND table_name = 'Notification' AND column_name = 'userId'`,
|
||||||
|
),
|
||||||
|
).toEqual([{ is_nullable: 'NO' }]);
|
||||||
|
},
|
||||||
|
TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'drops broadcasts when no user is active and keeps the personal rows',
|
||||||
|
async () => {
|
||||||
|
db = await createScratchDatabase(NOTIFICATION_FANOUT);
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO "Role" ("id", "key", "name", "updatedAt")
|
||||||
|
VALUES ('role-1', 'MIG_TEST', 'Migration test', now())`,
|
||||||
|
);
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO "User" ("id", "email", "passwordHash", "name", "roleId", "isActive", "updatedAt")
|
||||||
|
VALUES ('user-x', '[email protected]', 'x', 'x', 'role-1', false, now())`,
|
||||||
|
);
|
||||||
|
await seedNotification(db, {
|
||||||
|
id: 'broadcast',
|
||||||
|
userId: null,
|
||||||
|
relatedType: null,
|
||||||
|
});
|
||||||
|
await seedNotification(db, {
|
||||||
|
id: 'personal',
|
||||||
|
userId: 'user-x',
|
||||||
|
relatedType: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.migrateThrough(NOTIFICATION_FANOUT);
|
||||||
|
|
||||||
|
expect(await db.query('SELECT "id" FROM "Notification"')).toEqual([
|
||||||
|
{ id: 'personal' },
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'copies carry no dedupeKey, old rows get empty requiredPermissions, and the preference defaults hold',
|
||||||
|
async () => {
|
||||||
|
db = await createScratchDatabase(NOTIFICATION_FANOUT);
|
||||||
|
await seedUsers(db);
|
||||||
|
await seedNotification(db, {
|
||||||
|
id: 'broadcast',
|
||||||
|
userId: null,
|
||||||
|
relatedType: 'COLONY',
|
||||||
|
});
|
||||||
|
await seedNotification(db, {
|
||||||
|
id: 'personal',
|
||||||
|
userId: 'user-a',
|
||||||
|
relatedType: 'COLONY',
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.migrateThrough(NOTIFICATION_SETTINGS);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await db.query(
|
||||||
|
`SELECT count(*)::int AS "rows",
|
||||||
|
count("dedupeKey")::int AS "keys",
|
||||||
|
count(*) FILTER (WHERE "requiredPermissions" = '{}')::int AS "emptyPermissions",
|
||||||
|
count("link")::int AS "links"
|
||||||
|
FROM "Notification"`,
|
||||||
|
),
|
||||||
|
).toEqual([{ rows: 3, keys: 0, emptyPermissions: 3, links: 0 }]);
|
||||||
|
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO "NotificationPreference" ("userId", "updatedAt") VALUES ('user-a', now())`,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
await db.query(
|
||||||
|
`SELECT "taskDueEnabled", "digestEnabled", "digestTime", "quietFrom", "quietUntil",
|
||||||
|
"telegramEnabled", "telegramCategories"::text AS "telegramCategories", "telegramDetail"
|
||||||
|
FROM "NotificationPreference"`,
|
||||||
|
),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
taskDueEnabled: true,
|
||||||
|
digestEnabled: true,
|
||||||
|
digestTime: '06:30',
|
||||||
|
quietFrom: '21:00',
|
||||||
|
quietUntil: '06:30',
|
||||||
|
telegramEnabled: false,
|
||||||
|
telegramCategories: '{TASK_DUE,DIGEST}',
|
||||||
|
telegramDetail: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'legacy job-failure rows (relatedType JobRun) and rows without relatedType get SYSTEM, all others SUGGESTION, before NOT NULL',
|
||||||
|
async () => {
|
||||||
|
db = await createScratchDatabase(NOTIFICATION_FANOUT);
|
||||||
|
await seedUsers(db);
|
||||||
|
await seedNotification(db, {
|
||||||
|
id: 'n-job',
|
||||||
|
userId: 'user-a',
|
||||||
|
relatedType: 'JobRun',
|
||||||
|
});
|
||||||
|
await seedNotification(db, {
|
||||||
|
id: 'n-none',
|
||||||
|
userId: 'user-a',
|
||||||
|
relatedType: null,
|
||||||
|
});
|
||||||
|
await seedNotification(db, {
|
||||||
|
id: 'n-colony',
|
||||||
|
userId: 'user-a',
|
||||||
|
relatedType: 'COLONY',
|
||||||
|
});
|
||||||
|
await seedNotification(db, {
|
||||||
|
id: 'n-broadcast',
|
||||||
|
userId: null,
|
||||||
|
relatedType: 'JobRun',
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.migrateThrough(NOTIFICATION_SETTINGS);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await db.query(
|
||||||
|
`SELECT "id", "category" FROM "Notification" WHERE "id" LIKE 'n-%' ORDER BY "id"`,
|
||||||
|
),
|
||||||
|
).toEqual([
|
||||||
|
{ id: 'n-colony', category: 'SUGGESTION' },
|
||||||
|
{ id: 'n-job', category: 'SYSTEM' },
|
||||||
|
{ id: 'n-none', category: 'SYSTEM' },
|
||||||
|
]);
|
||||||
|
// The broadcast copies carry relatedType JobRun too.
|
||||||
|
expect(
|
||||||
|
await db.query(
|
||||||
|
`SELECT DISTINCT "category" FROM "Notification" WHERE "relatedType" = 'JobRun' AND "id" NOT LIKE 'n-%'`,
|
||||||
|
),
|
||||||
|
).toEqual([{ category: 'SYSTEM' }]);
|
||||||
|
expect(
|
||||||
|
await db.query(
|
||||||
|
`SELECT is_nullable FROM information_schema.columns
|
||||||
|
WHERE table_schema = current_schema() AND table_name = 'Notification' AND column_name = 'category'`,
|
||||||
|
),
|
||||||
|
).toEqual([{ is_nullable: 'NO' }]);
|
||||||
|
},
|
||||||
|
TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'occupied frames migration leaves strength untouched',
|
||||||
|
async () => {
|
||||||
|
db = await createScratchDatabase(OCCUPIED_FRAMES);
|
||||||
|
await seedColony(db);
|
||||||
|
await db.query(`UPDATE "Colony" SET "strength" = 7`);
|
||||||
|
|
||||||
|
await db.migrateThrough(OCCUPIED_FRAMES);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await db.query('SELECT "strength", "occupiedFrames" FROM "Colony"'),
|
||||||
|
).toEqual([{ strength: 7, occupiedFrames: null }]);
|
||||||
|
await db.query(`UPDATE "Colony" SET "occupiedFrames" = 40`);
|
||||||
|
await expect(
|
||||||
|
db.query(`UPDATE "Colony" SET "occupiedFrames" = 41`),
|
||||||
|
).rejects.toThrow();
|
||||||
|
await expect(
|
||||||
|
db.query(`UPDATE "Colony" SET "occupiedFrames" = -1`),
|
||||||
|
).rejects.toThrow();
|
||||||
|
},
|
||||||
|
TIMEOUT_MS,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
@@ -260,7 +260,7 @@ describe('Treatments (e2e)', () => {
|
|||||||
|
|
||||||
it('accepts a full ISO timestamp', async () => {
|
it('accepts a full ISO timestamp', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
withdrawalStatusAt('2026-07-14T23:59:59.000Z'),
|
withdrawalStatusAt('2026-07-14T23:59:59+02:00'),
|
||||||
).resolves.toHaveLength(1);
|
).resolves.toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
expect(measurements.some((entry) => entry.id === measurementId)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('GET /varroa-measurements/threshold', () => {
|
describe('GET /varroa-measurements/thresholds', () => {
|
||||||
const thresholdBody = (token: string) =>
|
const thresholdsBody = (token: string, query = '') =>
|
||||||
request(app.getHttpServer())
|
request(app.getHttpServer())
|
||||||
.get('/api/v1/varroa-measurements/threshold')
|
.get(`/api/v1/varroa-measurements/thresholds${query}`)
|
||||||
.set('Authorization', `Bearer ${token}`);
|
.set('Authorization', `Bearer ${token}`);
|
||||||
|
|
||||||
it('returns the threshold shape and is not shadowed by :id', async () => {
|
it('returns the bands shape and is not shadowed by :id', async () => {
|
||||||
const response = await thresholdBody(staffToken).expect(200);
|
const response = await thresholdsBody(staffToken).expect(200);
|
||||||
|
|
||||||
const body = response.body as { thresholdMitesPerDay: number | null };
|
const body = response.body as { day: string; active: boolean };
|
||||||
expect(Object.keys(body)).toEqual(['thresholdMitesPerDay']);
|
expect(Object.keys(body).sort()).toEqual(
|
||||||
expect(
|
['active', 'bands', 'day', 'postTreatmentUntil'].sort(),
|
||||||
body.thresholdMitesPerDay === null ||
|
);
|
||||||
typeof body.thresholdMitesPerDay === 'number',
|
expect(typeof body.active).toBe('boolean');
|
||||||
).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// The shared rule is never written here (parallel e2e workers); the
|
// The shared rule is never written here (parallel e2e workers); the
|
||||||
// inactive / custom value cases are covered by varroa-threshold.spec.ts.
|
// inactive / custom value cases are covered by the unit specs.
|
||||||
|
|
||||||
it('rejects a role without any varroa permission', () =>
|
it('rejects a role without any varroa permission', () =>
|
||||||
thresholdBody(noVarroaToken).expect(403));
|
thresholdsBody(noVarroaToken).expect(403));
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /varroa-measurements/bulk', () => {
|
describe('POST /varroa-measurements/bulk', () => {
|
||||||
|
|||||||
Reference in new issue
Block a user