diff --git a/ApiCore-Backend/prisma/migrations/20261014100900_t4_notification_user_cascade/migration.sql b/ApiCore-Backend/prisma/migrations/20261014100900_t4_notification_user_cascade/migration.sql new file mode 100644 index 0000000..9358ab2 --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20261014100900_t4_notification_user_cascade/migration.sql @@ -0,0 +1,16 @@ +-- T4 follow-up: notifications and their settings go with their user. +-- Users are deactivated, never hard-deleted, in production, so RESTRICT +-- protected nothing; but fan-out writes rows to users of other test files, +-- and deleting such a user must not fail on a foreign notification. + +-- DropForeignKey +ALTER TABLE "Notification" DROP CONSTRAINT "Notification_userId_fkey"; + +-- DropForeignKey +ALTER TABLE "NotificationPreference" DROP CONSTRAINT "NotificationPreference_userId_fkey"; + +-- AddForeignKey +ALTER TABLE "Notification" ADD CONSTRAINT "Notification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "NotificationPreference" ADD CONSTRAINT "NotificationPreference_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/ApiCore-Backend/prisma/schema.prisma b/ApiCore-Backend/prisma/schema.prisma index 687d0ec..e5eb789 100644 --- a/ApiCore-Backend/prisma/schema.prisma +++ b/ApiCore-Backend/prisma/schema.prisma @@ -1391,7 +1391,7 @@ enum NotificationCategory { model Notification { id String @id @default(cuid()) userId String - user User @relation(fields: [userId], references: [id]) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) priority NotificationPriority category NotificationCategory message String @@ -1410,7 +1410,7 @@ model Notification { // Einstellungen je Nutzer; Standard beim ersten Lesen angelegt. model NotificationPreference { userId String @id - user User @relation(fields: [userId], references: [id]) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) taskDueEnabled Boolean @default(true) digestEnabled Boolean @default(true) digestTime String @default("06:30") diff --git a/ApiCore-Backend/src/apiaries/apiaries.module.ts b/ApiCore-Backend/src/apiaries/apiaries.module.ts index 094107e..d6464b6 100644 --- a/ApiCore-Backend/src/apiaries/apiaries.module.ts +++ b/ApiCore-Backend/src/apiaries/apiaries.module.ts @@ -1,9 +1,11 @@ import { Module } from '@nestjs/common'; +import { AutopilotModule } from '../autopilot/autopilot.module'; import { ApiariesController } from './apiaries.controller'; import { ApiariesService } from './apiaries.service'; import { ApiaryFieldService } from './apiary-field.service'; @Module({ + imports: [AutopilotModule], controllers: [ApiariesController], providers: [ApiariesService, ApiaryFieldService], exports: [ApiariesService], diff --git a/ApiCore-Backend/src/apiaries/apiary-field.service.spec.ts b/ApiCore-Backend/src/apiaries/apiary-field.service.spec.ts index 5835b31..dce2ced 100644 --- a/ApiCore-Backend/src/apiaries/apiary-field.service.spec.ts +++ b/ApiCore-Backend/src/apiaries/apiary-field.service.spec.ts @@ -2,6 +2,8 @@ import { NotFoundException } from '@nestjs/common'; import { ApiaryFieldService } from './apiary-field.service'; import { PrismaService } from '../prisma/prisma.service'; import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; +import type { ColonyNextSteps } from '../autopilot/next-step-ranking'; +import { NextStepsService } from '../autopilot/next-steps.service'; describe('ApiaryFieldService', () => { let service: ApiaryFieldService; @@ -14,6 +16,21 @@ describe('ApiaryFieldService', () => { task: { findMany: jest.Mock }; }; let permissionResolver: { getPermissions: jest.Mock }; + let nextSteps: { forColonies: jest.Mock; warningCounts: jest.Mock }; + + const stepsOf = ( + colonyId: string, + overrides: Partial = {}, + ): ColonyNextSteps => ({ + colonyId, + segment: 'SCHWARMZEIT', + phase: null, + phaseSource: null, + inspectionDueOn: null, + withdrawalUntil: null, + steps: [], + ...overrides, + }); const colonyRow = (id: string, code: string, queenId: string | null) => ({ id, @@ -61,9 +78,19 @@ describe('ApiaryFieldService', () => { task: { findMany: jest.fn().mockResolvedValue([]) }, }; permissionResolver = { getPermissions: jest.fn() }; + nextSteps = { + forColonies: jest.fn().mockResolvedValue( + new Map([ + ['c10', stepsOf('c10')], + ['c2', stepsOf('c2')], + ]), + ), + warningCounts: jest.fn().mockResolvedValue(new Map()), + }; service = new ApiaryFieldService( prisma as unknown as PrismaService, permissionResolver as unknown as PermissionResolverService, + nextSteps as unknown as NextStepsService, ); }); @@ -173,23 +200,19 @@ describe('ApiaryFieldService', () => { expect(inspectionArgs.omit).toEqual({ userId: true }); }); - const lastQuery = () => - (prisma.$queryRaw.mock.calls[0] as [{ sql: string; values: unknown[] }])[0]; - - it('counts only unread WARNUNG and KRITISCH colony notifications visible to the caller', async () => { - grant('colonies:read'); - prisma.$queryRaw.mockResolvedValue([ - { relatedId: 'c2', priority: 'WARNUNG', count: 2 }, - { relatedId: 'c2', priority: 'KRITISCH', count: 1 }, - ]); + it('takes the warning counts from the open suggestions the caller may see', async () => { + grant('colonies:read', 'tasks:read'); + nextSteps.warningCounts.mockResolvedValue( + new Map([['c2', { warnings: 2, critical: 1 }]]), + ); const overview = await service.colonyOverview('apiary-1', 'u1'); - const query = lastQuery(); - expect(query.sql).toContain('"userId" ='); - expect(query.sql).toContain('"read" = false'); - expect(query.values).toContain('u1'); - expect(query.values).toEqual(expect.arrayContaining(['c2', 'c10'])); + expect(nextSteps.warningCounts).toHaveBeenCalledWith( + ['c2', 'c10'], + 'u1', + new Set(['colonies:read', 'tasks:read']), + ); expect(overview.rows[0]).toMatchObject({ code: 'V-2', openWarnings: 2, @@ -199,17 +222,69 @@ describe('ApiaryFieldService', () => { openWarnings: 0, openCritical: 0, }); + expect(prisma.$queryRaw).not.toHaveBeenCalled(); }); - it('requires the notification permissions to be a subset of the held ones, stale keys included', async () => { - grant('colonies:read', 'varroa:read'); + it('carries inspectionDueOn, segment and the title of the first next step', async () => { + grant('colonies:read', 'tasks:read'); + nextSteps.forColonies.mockResolvedValue( + new Map([ + [ + 'c2', + stepsOf('c2', { + segment: 'SOMMER', + inspectionDueOn: '2026-05-10', + steps: [ + { + key: 'suggestion:s1', + kind: 'SUGGESTION', + intent: 'INSPECT', + title: 'Durchsicht mit Schwarmkontrolle', + reasons: [], + suggestionIds: ['s1'], + dueOn: '2026-05-10', + urgency: 'OVERDUE', + action: { type: 'ACCEPT', suggestionId: 's1' }, + dismissible: false, + }, + ], + }), + ], + ['c10', stepsOf('c10', { segment: 'WINTERRUHE' })], + ]), + ); - await service.colonyOverview('apiary-1', 'u1'); + const overview = await service.colonyOverview('apiary-1', 'u1'); - const query = lastQuery(); - expect(query.sql).toContain('"requiredPermissions" <@'); - // The held set is passed as is: a key outside the catalog is never - // contained in it, so such a row stays hidden (parity with findOwn). - expect(query.values).toContainEqual(['colonies:read', 'varroa:read']); + expect(overview.rows[0]).toMatchObject({ + inspectionDueOn: '2026-05-10', + segment: 'SOMMER', + nextStepTitle: 'Durchsicht mit Schwarmkontrolle', + }); + expect(overview.rows[1]).toMatchObject({ + inspectionDueOn: null, + segment: 'WINTERRUHE', + nextStepTitle: null, + }); + expect(nextSteps.forColonies).toHaveBeenCalledTimes(1); + }); + + it('leaves the segment null when the colony has no season state', async () => { + grant('colonies:read'); + nextSteps.forColonies.mockResolvedValue(new Map()); + + const overview = await service.colonyOverview('apiary-1', 'u1'); + + expect(overview.rows.every((row) => row.segment === null)).toBe(true); + }); + + it('adds the next steps per colony to the offline pack', async () => { + grant('colonies:read'); + + const pack = await service.offlinePack('apiary-1', 'u1'); + + expect(pack.version).toBe(1); + expect(pack.nextSteps).toEqual({ c2: [], c10: [] }); + expect(nextSteps.forColonies).toHaveBeenCalledTimes(1); }); }); diff --git a/ApiCore-Backend/src/apiaries/apiary-field.service.ts b/ApiCore-Backend/src/apiaries/apiary-field.service.ts index 26ad35f..2446996 100644 --- a/ApiCore-Backend/src/apiaries/apiary-field.service.ts +++ b/ApiCore-Backend/src/apiaries/apiary-field.service.ts @@ -1,8 +1,9 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; +import type { ColonyNextSteps } from '../autopilot/next-step-ranking'; +import { NextStepsService } from '../autopilot/next-steps.service'; import { LIVING_COLONY_WHERE } from '../colonies/colony-status'; import { PrismaService } from '../prisma/prisma.service'; -import { Prisma } from '../../generated/prisma'; import type { ColonyOverview, ColonyOverviewRow, @@ -18,6 +19,7 @@ interface FieldData { colonies: PackColony[]; permissions: ReadonlySet; overview: ColonyOverview; + nextSteps: ReadonlyMap; } /** @@ -30,6 +32,7 @@ export class ApiaryFieldService { constructor( private readonly prisma: PrismaService, private readonly permissionResolver: PermissionResolverService, + private readonly nextSteps: NextStepsService, ) {} async colonyOverview( @@ -40,10 +43,8 @@ export class ApiaryFieldService { } async offlinePack(apiaryId: string, userId: string): Promise { - const { apiary, colonies, permissions, overview } = await this.load( - apiaryId, - userId, - ); + const { apiary, colonies, permissions, overview, nextSteps } = + await this.load(apiaryId, userId); const [queens, inspections, tasks] = await Promise.all([ permissions.has('queens:read') ? this.loadQueens(colonies) : null, permissions.has('inspections:read') @@ -60,6 +61,12 @@ export class ApiaryFieldService { queens, inspections, tasks, + nextSteps: Object.fromEntries( + colonies.map((colony) => [ + colony.id, + nextSteps.get(colony.id)?.steps ?? [], + ]), + ), }; } @@ -96,9 +103,11 @@ export class ApiaryFieldService { a.code.localeCompare(b.code, 'de', { numeric: true }), ); const inspectionsVisible = permissions.has('inspections:read'); - const [lastInspections, warnings] = await Promise.all([ + const colonyIds = colonies.map((colony) => colony.id); + const [lastInspections, warnings, nextSteps] = await Promise.all([ inspectionsVisible ? this.lastInspectionDates(colonies) : null, - this.warningCounts(colonies, userId, permissions), + this.nextSteps.warningCounts(colonyIds, userId, permissions), + this.nextSteps.forColonies(colonyIds, userId, undefined, permissions), ]); const rows = colonies.map((colony): ColonyOverviewRow => ({ id: colony.id, @@ -110,14 +119,18 @@ export class ApiaryFieldService { feedStatus: colony.feedStatus, healthStatus: colony.healthStatus, lastInspectionDate: lastInspections?.get(colony.id) ?? null, - openWarnings: warnings.get(colony.id)?.WARNUNG ?? 0, - openCritical: warnings.get(colony.id)?.KRITISCH ?? 0, + inspectionDueOn: nextSteps.get(colony.id)?.inspectionDueOn ?? null, + segment: nextSteps.get(colony.id)?.segment ?? null, + nextStepTitle: nextSteps.get(colony.id)?.steps.at(0)?.title ?? null, + openWarnings: warnings.get(colony.id)?.warnings ?? 0, + openCritical: warnings.get(colony.id)?.critical ?? 0, })); return { apiary, colonies, permissions, overview: { apiaryId, inspectionsVisible, rows }, + nextSteps, }; } @@ -138,42 +151,6 @@ export class ApiaryFieldService { return dates; } - private async warningCounts( - colonies: PackColony[], - userId: string, - held: ReadonlySet, - ): Promise> { - const counts = new Map(); - if (colonies.length === 0) { - return counts; - } - // Exactly the rule of NotificationsService.findOwn, decided in SQL: a - // row counts only when every key in requiredPermissions is held (subset - // test, so a key outside the catalog hides the row too). - const groups = await this.prisma.$queryRaw< - { relatedId: string; priority: string; count: number }[] - >(Prisma.sql` - SELECT "relatedId", "priority"::text AS "priority", count(*)::int AS "count" - FROM "Notification" - WHERE "relatedType" = 'COLONY' - AND "relatedId" IN (${Prisma.join(colonies.map((colony) => colony.id))}) - AND "read" = false - AND "priority"::text IN ('WARNUNG', 'KRITISCH') - AND "userId" = ${userId} - AND "requiredPermissions" <@ ${[...held]}::text[] - GROUP BY "relatedId", "priority" - `); - for (const group of groups) { - if (group.priority === 'WARNUNG' || group.priority === 'KRITISCH') { - counts.set(group.relatedId, { - ...counts.get(group.relatedId), - [group.priority]: group.count, - }); - } - } - return counts; - } - private loadQueens(colonies: PackColony[]): Promise { const queenIds = colonies.flatMap((colony) => colony.currentQueenId ? [colony.currentQueenId] : [], diff --git a/ApiCore-Backend/src/apiaries/apiary-field.types.ts b/ApiCore-Backend/src/apiaries/apiary-field.types.ts index b1c450c..1894da6 100644 --- a/ApiCore-Backend/src/apiaries/apiary-field.types.ts +++ b/ApiCore-Backend/src/apiaries/apiary-field.types.ts @@ -7,6 +7,8 @@ import type { Queen, Task, } from '../../generated/prisma'; +import type { NextStep } from '../autopilot/next-step-ranking'; +import type { SeasonSegmentKey } from '../season/season-types'; /** One living colony on the apiary page, reduced to what the traffic light needs. */ export interface ColonyOverviewRow { @@ -20,9 +22,18 @@ export interface ColonyOverviewRow { healthStatus: ColonyCondition | null; /** ISO timestamp; always null when the caller may not read inspections. */ lastInspectionDate: string | null; - /** Unread WARNUNG notifications visible to the caller. */ + /** + * The day the next inspection is due (YYYY-MM-DD); null in the winter + * rest, when the interval rule is off and without `inspections:read`. + */ + inspectionDueOn: string | null; + /** The season segment of the apiary today; null when it is unknown. */ + segment: SeasonSegmentKey | null; + /** Title of the first next step; null without one or without `tasks:read`. */ + nextStepTitle: string | null; + /** Open suggestions of priority HIGH the caller may see. */ openWarnings: number; - /** Unread KRITISCH notifications visible to the caller. */ + /** Open suggestions of priority URGENT the caller may see. */ openCritical: number; } @@ -90,4 +101,9 @@ export interface OfflinePackV1 { | 'relatedId' >[] | null; + /** + * The next steps per colony id (what `GET /colonies/:id/next-steps` + * returns as `steps`). Absent in packs cached before T4. + */ + nextSteps?: Record; } diff --git a/ApiCore-Backend/src/app.module.ts b/ApiCore-Backend/src/app.module.ts index 932a384..ed5cdc5 100644 --- a/ApiCore-Backend/src/app.module.ts +++ b/ApiCore-Backend/src/app.module.ts @@ -53,9 +53,11 @@ import { BackupModule } from './backup/backup.module'; import { ShopSyncModule } from './shop-sync/shop-sync.module'; import { ErpExportModule } from './erp-export/erp-export.module'; import { ApiKeysModule } from './api-keys/api-keys.module'; +import { CleanupModule } from './suggestions/cleanup.module'; import { SuggestionsModule } from './suggestions/suggestions.module'; import { AutopilotModule } from './autopilot/autopilot.module'; import { SystemModule } from './system/system.module'; +import { WithdrawalModule } from './withdrawal/withdrawal.module'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { PermissionsGuard } from './auth/guards/permissions.guard'; import { PermissionsModule } from './auth/permissions/permissions.module'; @@ -109,7 +111,9 @@ import { IdempotencyInterceptor } from './common/idempotency/idempotency.interce DocumentsModule, AutomationModule, SuggestionsModule, + CleanupModule, AutopilotModule, + WithdrawalModule, LabelTemplatesModule, ImportModule, PublicModule, diff --git a/ApiCore-Backend/src/automation/automation.handlers.spec.ts b/ApiCore-Backend/src/automation/automation.handlers.spec.ts index 7ef9d32..0d84736 100644 --- a/ApiCore-Backend/src/automation/automation.handlers.spec.ts +++ b/ApiCore-Backend/src/automation/automation.handlers.spec.ts @@ -42,6 +42,7 @@ describe('AutomationEventHandlers', () => { let tx: Record; let automation: Record< | 'evaluateInspectionIn' + | 'evaluateInspectionCorrectionIn' | 'evaluateVarroaMeasurementIn' | 'evaluateTreatmentIn' | 'evaluateFeedingIn' @@ -63,6 +64,7 @@ describe('AutomationEventHandlers', () => { ) as Record; automation = { evaluateInspectionIn: jest.fn().mockResolvedValue(undefined), + evaluateInspectionCorrectionIn: jest.fn().mockResolvedValue(undefined), evaluateVarroaMeasurementIn: jest.fn().mockResolvedValue(undefined), evaluateTreatmentIn: jest.fn().mockResolvedValue(undefined), evaluateFeedingIn: jest.fn().mockResolvedValue(undefined), @@ -84,7 +86,7 @@ describe('AutomationEventHandlers', () => { expect(registered('inspectionRules')).toEqual([ { - types: ['InspectionRecorded'], + types: ['InspectionRecorded', 'InspectionCorrected'], handlerId: 'automation.inspection-rules', }, ]); @@ -208,6 +210,21 @@ describe('AutomationEventHandlers', () => { }, ); + it('a corrected inspection only follows open suggestions', async () => { + tx.inspection.findUnique.mockResolvedValueOnce({ + id: 'record-1', + colonyId: 'colony-1', + }); + tx.colony.findUnique.mockResolvedValueOnce({ status: 'ACTIVE' }); + + await run('inspectionRules', 'InspectionCorrected' as never); + + expect(automation.evaluateInspectionCorrectionIn).toHaveBeenCalledTimes( + 1, + ); + expect(automation.evaluateInspectionIn).not.toHaveBeenCalled(); + }); + it('lets an evaluation failure propagate, so the delivery is retried', async () => { tx.inspection.findUnique.mockResolvedValueOnce({ id: 'record-1', diff --git a/ApiCore-Backend/src/automation/automation.handlers.ts b/ApiCore-Backend/src/automation/automation.handlers.ts index 3e06ae5..d35f3bd 100644 --- a/ApiCore-Backend/src/automation/automation.handlers.ts +++ b/ApiCore-Backend/src/automation/automation.handlers.ts @@ -35,9 +35,14 @@ export class AutomationEventHandlers { return colony !== null && isLivingColonyStatus(colony.status); } - @OnDomainEvent('InspectionRecorded', 'automation.inspection-rules') + // A correction proposes nothing new; it only lets an open suggestion + // follow the corrected values. + @OnDomainEvent( + ['InspectionRecorded', 'InspectionCorrected'], + 'automation.inspection-rules', + ) async inspectionRules( - event: AnyDomainEventEnvelope<'InspectionRecorded'>, + event: AnyDomainEventEnvelope<'InspectionRecorded' | 'InspectionCorrected'>, tx: Tx, ): Promise { const inspection = await tx.inspection.findUnique({ @@ -49,7 +54,11 @@ export class AutomationEventHandlers { ) { return; } - await this.automation.evaluateInspectionIn(tx, inspection); + if (event.type === 'InspectionRecorded') { + await this.automation.evaluateInspectionIn(tx, inspection); + } else { + await this.automation.evaluateInspectionCorrectionIn(tx, inspection); + } } // A correction can lift a count over the threshold, so it is evaluated diff --git a/ApiCore-Backend/src/automation/automation.service.spec.ts b/ApiCore-Backend/src/automation/automation.service.spec.ts index 5ee5baf..d2ecea2 100644 --- a/ApiCore-Backend/src/automation/automation.service.spec.ts +++ b/ApiCore-Backend/src/automation/automation.service.spec.ts @@ -32,6 +32,7 @@ describe('AutomationService', () => { }; colony: { findUnique: jest.Mock }; suggestion: { findFirst: jest.Mock }; + inspection: { findFirst: jest.Mock }; treatment: { findMany: jest.Mock }; stockMovement: { findFirst: jest.Mock }; rawMaterialLot: { findFirst: jest.Mock }; @@ -90,6 +91,16 @@ describe('AutomationService', () => { }; const asTx = () => tx as unknown as Prisma.TransactionClient; + // The inspection rules decide on the colony's latest inspection: the one + // under test is it. + const evaluateInspection = (inspection: never) => { + tx.inspection.findFirst.mockResolvedValue(inspection); + return service.evaluateInspectionIn(asTx(), inspection); + }; + const evaluateInspectionCorrection = (inspection: never) => { + tx.inspection.findFirst.mockResolvedValue(inspection); + return service.evaluateInspectionCorrectionIn(asTx(), inspection); + }; const withRules = (...list: Rule[]) => { for (const rule of list) { rules.set(rule.triggerType, rule); @@ -125,6 +136,7 @@ describe('AutomationService', () => { }), }, suggestion: { findFirst: jest.fn().mockResolvedValue(null) }, + inspection: { findFirst: jest.fn().mockResolvedValue(null) }, treatment: { findMany: jest.fn().mockResolvedValue([]) }, stockMovement: { findFirst: jest.fn().mockResolvedValue(null) }, rawMaterialLot: { findFirst: jest.fn().mockResolvedValue(null) }, @@ -502,7 +514,7 @@ describe('AutomationService', () => { }); it('read the rules on the given transaction client and write no seed', async () => { - await service.evaluateInspectionIn(asTx(), queenlessInspection as never); + await evaluateInspection(queenlessInspection as never); expect(tx.automationRule.findUnique).toHaveBeenCalled(); expect(prisma.automationRule.createMany).not.toHaveBeenCalled(); @@ -520,7 +532,7 @@ describe('AutomationService', () => { it('proposes a queen check anchored on the inspection when the queen was not seen and no eggs are present', async () => { withRules(queenlessRule); - await service.evaluateInspectionIn(asTx(), queenlessInspection as never); + await evaluateInspection(queenlessInspection as never); expect(propose).toHaveBeenCalledTimes(1); expect(propose).toHaveBeenCalledWith(tx, 'QUEENLESS_NO_EGGS', { @@ -543,7 +555,7 @@ describe('AutomationService', () => { it('dates the suggestion from the inspection day when the rule has dueInDays', async () => { withRules({ ...queenlessRule, config: { dueInDays: 3 } }); - await service.evaluateInspectionIn(asTx(), queenlessInspection as never); + await evaluateInspection(queenlessInspection as never); expect(propose).toHaveBeenCalledWith( tx, @@ -555,7 +567,7 @@ describe('AutomationService', () => { it('does not trigger when the queen was seen', async () => { withRules(queenlessRule); - await service.evaluateInspectionIn(asTx(), { + await evaluateInspection({ ...queenlessInspection, queenSeen: true, } as never); @@ -566,7 +578,7 @@ describe('AutomationService', () => { it('proposes feeding when the feed status is CRITICAL', async () => { withRules(feedRule); - await service.evaluateInspectionIn(asTx(), { + await evaluateInspection({ ...queenlessInspection, id: 'inspection-3', queenSeen: true, @@ -590,7 +602,7 @@ describe('AutomationService', () => { it('proposes both findings of one inspection', async () => { withRules(queenlessRule, feedRule); - await service.evaluateInspectionIn(asTx(), { + await evaluateInspection({ ...queenlessInspection, feedStatus: 'CRITICAL', } as never); @@ -604,7 +616,7 @@ describe('AutomationService', () => { { ...feedRule, active: false }, ); - await service.evaluateInspectionIn(asTx(), { + await evaluateInspection({ ...queenlessInspection, feedStatus: 'CRITICAL', } as never); @@ -613,7 +625,7 @@ describe('AutomationService', () => { }); it('reads no rule when there is no finding', async () => { - await service.evaluateInspectionIn(asTx(), { + await evaluateInspection({ ...queenlessInspection, queenSeen: true, } as never); @@ -622,6 +634,212 @@ describe('AutomationService', () => { }); }); + describe('open inspection suggestions follow the latest inspection', () => { + let propose: jest.SpyInstance; + const latestOf = (overrides: Record = {}) => + ({ + ...queenlessInspection, + id: 'inspection-2', + date: new Date('2026-05-12T00:00:00.000Z'), + createdAt: new Date('2026-05-12T08:00:00.000Z'), + queenSeen: true, + eggsPresent: true, + ...overrides, + }) as never; + const lockedTables = () => + (tx.$queryRaw.mock.calls as [Prisma.Sql][]).map( + ([query]) => /FROM "(\w+)"/.exec(query.sql)?.[1], + ); + + beforeEach(() => { + propose = jest.spyOn(service, 'proposeForRuleIn').mockResolvedValue(null); + withRules(queenlessRule, feedRule); + tx.suggestion.findFirst.mockImplementation( + ({ where }: { where: { ruleKey: string } }) => + Promise.resolve( + where.ruleKey === 'QUEENLESS_NO_EGGS' ? { id: 'open-1' } : null, + ), + ); + }); + + it('locks the colony first, reads the latest inspection and the open suggestion under it, then locks the suggestion', async () => { + await evaluateInspection(latestOf()); + + expect(lockedTables()).toEqual(['Colony', 'Suggestion']); + const [colonyLock, suggestionLock] = + tx.$queryRaw.mock.invocationCallOrder; + const [latestRead] = tx.inspection.findFirst.mock.invocationCallOrder; + const [openRead] = tx.suggestion.findFirst.mock.invocationCallOrder; + expect(colonyLock).toBeLessThan(latestRead); + expect(latestRead).toBeLessThan(openRead); + expect(openRead).toBeLessThan(suggestionLock); + }); + + it('takes the latest inspection by date, then creation time, then id', async () => { + await evaluateInspection(latestOf()); + + expect(tx.inspection.findFirst).toHaveBeenCalledWith({ + where: { colonyId: 'colony-1' }, + orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }], + }); + }); + + it('looks for the open suggestion of each rule for the colony', async () => { + await evaluateInspection(latestOf()); + + expect(tx.suggestion.findFirst).toHaveBeenCalledWith({ + where: { + ruleKey: 'QUEENLESS_NO_EGGS', + colonyId: 'colony-1', + status: 'OPEN', + }, + select: { id: true }, + }); + }); + + it('resolves it when the latest inspection no longer shows the finding', async () => { + await evaluateInspection(latestOf()); + + expect(suggestions.resolveIn).toHaveBeenCalledWith( + tx, + ['open-1'], + 'Bedingung entfällt', + ); + expect(propose).not.toHaveBeenCalled(); + }); + + it('refreshes it with the latest inspection when the finding persists', async () => { + await evaluateInspection( + latestOf({ queenSeen: false, eggsPresent: false }), + ); + + expect(suggestions.refreshIn).toHaveBeenCalledWith(tx, 'open-1', { + evidence: { + colonyId: 'colony-1', + inspectionId: 'inspection-2', + inspectedOn: '2026-05-12', + }, + dueDate: null, + priority: 'HIGH', + expiresAt: null, + }); + expect(suggestions.resolveIn).not.toHaveBeenCalled(); + expect(propose).not.toHaveBeenCalled(); + }); + + it('recomputes the due date from dueInDays when it refreshes', async () => { + withRules({ ...queenlessRule, config: { dueInDays: 4 } }); + + await evaluateInspection( + latestOf({ queenSeen: false, eggsPresent: false }), + ); + + expect(suggestions.refreshIn).toHaveBeenCalledWith( + tx, + 'open-1', + expect.objectContaining({ dueDate: '2026-05-16' }), + ); + }); + + it('is idempotent: a re-delivery refreshes the same evidence again', async () => { + const inspection = latestOf({ queenSeen: false, eggsPresent: false }); + + await evaluateInspection(inspection); + await evaluateInspection(inspection); + + const [first, second] = suggestions.refreshIn.mock.calls as unknown[][]; + expect(second).toEqual(first); + expect(suggestions.resolveIn).not.toHaveBeenCalled(); + }); + + it('proposes nothing and changes nothing when the event inspection is not the latest (late, retried or backdated)', async () => { + tx.inspection.findFirst.mockResolvedValue(latestOf({ id: 'newer' })); + + await service.evaluateInspectionIn( + asTx(), + latestOf({ + id: 'older', + queenSeen: false, + eggsPresent: false, + date: new Date('2026-05-01T00:00:00.000Z'), + }), + ); + await service.evaluateInspectionCorrectionIn(asTx(), latestOf()); + + expect(propose).not.toHaveBeenCalled(); + expect(suggestions.resolveIn).not.toHaveBeenCalled(); + expect(suggestions.refreshIn).not.toHaveBeenCalled(); + expect(tx.suggestion.findFirst).not.toHaveBeenCalled(); + }); + + it('does nothing when the colony is gone or has no inspection any more', async () => { + tx.$queryRaw.mockResolvedValueOnce([]); + await evaluateInspection(latestOf()); + + tx.inspection.findFirst.mockResolvedValue(null); + await service.evaluateInspectionIn(asTx(), latestOf()); + + expect(suggestions.resolveIn).not.toHaveBeenCalled(); + expect(suggestions.refreshIn).not.toHaveBeenCalled(); + }); + + it('the correction of the latest inspection resolves it when the finding is gone', async () => { + await evaluateInspectionCorrection(latestOf()); + + expect(suggestions.resolveIn).toHaveBeenCalledTimes(1); + }); + + it('keeps it OPEN when the corrected latest inspection still shows the finding', async () => { + await evaluateInspectionCorrection( + latestOf({ queenSeen: false, eggsPresent: false }), + ); + + expect(suggestions.resolveIn).not.toHaveBeenCalled(); + expect(suggestions.refreshIn).toHaveBeenCalledTimes(1); + }); + + it('a correction never proposes', async () => { + tx.suggestion.findFirst.mockResolvedValue(null); + + await evaluateInspectionCorrection( + latestOf({ queenSeen: false, eggsPresent: false }), + ); + + expect(propose).not.toHaveBeenCalled(); + }); + + it('leaves open suggestions of an inactive rule untouched', async () => { + withRules({ ...queenlessRule, active: false }); + + await evaluateInspection(latestOf()); + await evaluateInspection( + latestOf({ queenSeen: false, eggsPresent: false }), + ); + + expect(suggestions.resolveIn).not.toHaveBeenCalled(); + expect(suggestions.refreshIn).not.toHaveBeenCalled(); + }); + + it('follows the feed rule the same way', async () => { + tx.suggestion.findFirst.mockImplementation( + ({ where }: { where: { ruleKey: string } }) => + Promise.resolve( + where.ruleKey === 'FEED_STATUS_CRITICAL' + ? { id: 'open-feed' } + : null, + ), + ); + + await evaluateInspection(latestOf()); + + expect(suggestions.resolveIn).toHaveBeenCalledWith( + tx, + ['open-feed'], + 'Bedingung entfällt', + ); + }); + }); + describe('evaluateVarroaMeasurementIn', () => { let propose: jest.SpyInstance; const measurement = ( diff --git a/ApiCore-Backend/src/automation/automation.service.ts b/ApiCore-Backend/src/automation/automation.service.ts index 75bbaaf..f6479c3 100644 --- a/ApiCore-Backend/src/automation/automation.service.ts +++ b/ApiCore-Backend/src/automation/automation.service.ts @@ -112,6 +112,17 @@ function dueDayOf(config: object, baseDay: string): string | null { return typeof dueInDays === 'number' ? addDays(baseDay, dueInDays) : null; } +function inspectionEvidence( + inspection: Inspection, + inspectedOn: string, +): SuggestionEvidence { + return { + colonyId: inspection.colonyId, + inspectionId: inspection.id, + inspectedOn, + }; +} + @Injectable() export class AutomationService { private readonly logger = new Logger(AutomationService.name); @@ -229,24 +240,119 @@ export class AutomationService { // transaction, the legacy wrappers below with a transaction of their own. // ------------------------------------------------------------------------- + /** A new inspection: proposes new findings and follows the open ones. */ async evaluateInspectionIn(tx: Tx, inspection: Inspection): Promise { assertTransactionClient(tx, 'AutomationService.evaluateInspectionIn'); - if (inspection.queenSeen === false && inspection.eggsPresent === false) { - await this.proposeOnInspectionIn( - tx, - 'QUEENLESS_NO_EGGS', - 'QUEEN_CHECK', - inspection, - ); + await this.syncInspectionRulesIn(tx, inspection, true); + } + + /** + * A corrected inspection proposes nothing new, but an open suggestion it + * supersedes follows the corrected values (resolved or refreshed). + */ + async evaluateInspectionCorrectionIn( + tx: Tx, + inspection: Inspection, + ): Promise { + assertTransactionClient( + tx, + 'AutomationService.evaluateInspectionCorrectionIn', + ); + await this.syncInspectionRulesIn(tx, inspection, false); + } + + /** + * Colony lock first, then everything that decides is read under it: the + * colony's actual latest inspection (date, createdAt, id) is the only one + * that counts. An event of any other inspection - a late or retried + * delivery, a backdated entry, the correction of an older one - proposes + * nothing and changes nothing; the latest inspection has its own event. + * (An inspection moved to another colony raises no event for the colony + * it left, so a suggestion there stays until that colony's next + * inspection.) + */ + private async syncInspectionRulesIn( + tx: Tx, + inspection: Inspection, + proposeNew: boolean, + ): Promise { + if (!(await lockRowForUpdate(tx, 'Colony', inspection.colonyId))) { + return; } - if (inspection.feedStatus === 'CRITICAL') { - await this.proposeOnInspectionIn( - tx, - 'FEED_STATUS_CRITICAL', - 'FEED', - inspection, - ); + const latest = await tx.inspection.findFirst({ + where: { colonyId: inspection.colonyId }, + orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }], + }); + if (latest === null || latest.id !== inspection.id) { + return; } + await this.syncInspectionRuleIn( + tx, + 'QUEENLESS_NO_EGGS', + 'QUEEN_CHECK', + latest, + latest.queenSeen === false && latest.eggsPresent === false, + proposeNew, + ); + await this.syncInspectionRuleIn( + tx, + 'FEED_STATUS_CRITICAL', + 'FEED', + latest, + latest.feedStatus === 'CRITICAL', + proposeNew, + ); + } + + /** + * One inspection rule against the colony's latest inspection (colony + * locked). No open suggestion: a met condition proposes - new inspections + * only, a correction never proposes (a corrected value is not a new + * finding; the beekeeper was told about the original). An open one follows + * the latest inspection: condition gone, it is resolved; still met, its + * evidence is refreshed. The refresh keeps the original dedupeKey/anchor, + * so evidence.inspectionId may differ from the anchor. An inactive rule + * leaves open suggestions untouched (as the varroa rule does). + */ + private async syncInspectionRuleIn( + tx: Tx, + trigger: 'QUEENLESS_NO_EGGS' | 'FEED_STATUS_CRITICAL', + intent: SuggestionIntent, + latest: Inspection, + met: boolean, + proposeNew: boolean, + ): Promise { + const open = await tx.suggestion.findFirst({ + where: { + ruleKey: trigger, + colonyId: latest.colonyId, + status: 'OPEN', + }, + select: { id: true }, + }); + if (open === null) { + if (met && proposeNew) { + await this.proposeOnInspectionIn(tx, trigger, intent, latest); + } + return; + } + const rule = await readRule(tx, trigger); + if (!rule.active) { + return; + } + // Colony (held), then suggestion. + await lockRowForUpdate(tx, 'Suggestion', open.id); + if (!met) { + await this.suggestions.resolveIn(tx, [open.id], 'Bedingung entfällt'); + return; + } + const inspectedOn = berlinDayOf(latest.date); + await this.suggestions.refreshIn(tx, open.id, { + evidence: inspectionEvidence(latest, inspectedOn), + dueDate: dueDayOf(rule.config, inspectedOn), + priority: 'HIGH', + expiresAt: null, + }); } /** The reason code of an inspection rule is the rule's own name. */ @@ -267,11 +373,7 @@ export class AutomationService { colonyId: inspection.colonyId, intent, reasonCode: trigger, - evidence: { - colonyId: inspection.colonyId, - inspectionId: inspection.id, - inspectedOn, - }, + evidence: inspectionEvidence(inspection, inspectedOn), anchor: inspection.id, requiredPermission: 'inspections:read', dueDate: dueDayOf(rule.config, inspectedOn), diff --git a/ApiCore-Backend/src/autopilot/autopilot-input.ts b/ApiCore-Backend/src/autopilot/autopilot-input.ts index a27c9ec..c94d929 100644 --- a/ApiCore-Backend/src/autopilot/autopilot-input.ts +++ b/ApiCore-Backend/src/autopilot/autopilot-input.ts @@ -1,7 +1,6 @@ import type { Prisma } from '../../generated/prisma'; import { berlinDayOf, dayToDate } from '../common/dates'; import type { SeasonState } from '../season/season-types'; -import { utcDay } from '../domain-events/domain-event-types'; import type { AutopilotInput } from './autopilot.types'; /** @@ -72,8 +71,8 @@ export async function loadAutopilotInputIn( const lastTreatment = treatments .map((treatment) => ({ id: treatment.id, - startDay: utcDay(treatment.startDate ?? treatment.date), - endDay: utcDay(treatment.endDate ?? treatment.date), + startDay: berlinDayOf(treatment.startDate ?? treatment.date), + endDay: berlinDayOf(treatment.endDate ?? treatment.date), })) .reduce<{ id: string; startDay: string; endDay: string } | null>( (latest, candidate) => @@ -124,9 +123,11 @@ export async function loadAutopilotInputIn( lastInspection: inspection === null ? null - : { ...inspection, date: utcDay(inspection.date) }, + : { ...inspection, date: berlinDayOf(inspection.date) }, lastVarroa: - varroa === null ? null : { id: varroa.id, date: utcDay(varroa.date) }, + varroa === null + ? null + : { id: varroa.id, date: berlinDayOf(varroa.date) }, lastTreatment, varroaSinceLastTreatmentEnd, feedingsSinceInspection, diff --git a/ApiCore-Backend/src/autopilot/autopilot-today.controller.ts b/ApiCore-Backend/src/autopilot/autopilot-today.controller.ts new file mode 100644 index 0000000..84b4262 --- /dev/null +++ b/ApiCore-Backend/src/autopilot/autopilot-today.controller.ts @@ -0,0 +1,26 @@ +import { Controller, Get } from '@nestjs/common'; +import { ApiBearerAuth, 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 { AutopilotTodayDto } from './dto/next-steps-response.dto'; +import { NextStepsService, type AutopilotToday } from './next-steps.service'; + +@ApiTags('autopilot') +@ApiBearerAuth() +@Controller('autopilot') +export class AutopilotTodayController { + constructor(private readonly nextSteps: NextStepsService) {} + + /** + * "Today": the most urgent step over all colonies, the due inspections + * per apiary, the number of open suggestions and the season per apiary. + * Tasks and suggestions only with `tasks:read`. + */ + @ApiOkResponse({ type: AutopilotTodayDto }) + @RequirePermission('colonies:read') + @Get('today') + today(@CurrentUser() actor: AuthenticatedUser): Promise { + return this.nextSteps.today(actor.id); + } +} diff --git a/ApiCore-Backend/src/autopilot/autopilot.module.ts b/ApiCore-Backend/src/autopilot/autopilot.module.ts index 5501127..2df3392 100644 --- a/ApiCore-Backend/src/autopilot/autopilot.module.ts +++ b/ApiCore-Backend/src/autopilot/autopilot.module.ts @@ -2,23 +2,34 @@ import { Module } from '@nestjs/common'; import { SeasonModule } from '../season/season.module'; import { SuggestionsModule } from '../suggestions/suggestions.module'; import { AutopilotEventHandlers } from './autopilot.handlers'; +import { AutopilotTodayController } from './autopilot-today.controller'; import { AutopilotService } from './autopilot.service'; import { AutopilotStep } from './autopilot.step'; +import { ColonyNextStepsController } from './colony-next-steps.controller'; +import { NextStepsService } from './next-steps.service'; import { SuggestionsExpiryStep } from './suggestions-expiry.step'; /** * The season autopilot: the event handler and the daily steps that keep the - * autopilot suggestions of each colony in line with the rules. Imported by - * AppModule and WorkerModule (the handler) and by JobsModule (the steps). + * autopilot suggestions of each colony in line with the rules, and the read + * side "next steps" / "today". Imported by AppModule and WorkerModule (the + * handler), JobsModule (the steps) and ApiariesModule (the colony overview). */ @Module({ imports: [SeasonModule, SuggestionsModule], + controllers: [ColonyNextStepsController, AutopilotTodayController], providers: [ AutopilotService, + NextStepsService, AutopilotEventHandlers, AutopilotStep, SuggestionsExpiryStep, ], - exports: [AutopilotService, AutopilotStep, SuggestionsExpiryStep], + exports: [ + AutopilotService, + NextStepsService, + AutopilotStep, + SuggestionsExpiryStep, + ], }) export class AutopilotModule {} diff --git a/ApiCore-Backend/src/autopilot/colony-next-steps.controller.ts b/ApiCore-Backend/src/autopilot/colony-next-steps.controller.ts new file mode 100644 index 0000000..89f3eda --- /dev/null +++ b/ApiCore-Backend/src/autopilot/colony-next-steps.controller.ts @@ -0,0 +1,26 @@ +import { Controller, Get, Param } from '@nestjs/common'; +import { ApiBearerAuth, 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 { ColonyNextStepsDto } from './dto/next-steps-response.dto'; +import type { ColonyNextSteps } from './next-step-ranking'; +import { NextStepsService } from './next-steps.service'; + +@ApiTags('colonies') +@ApiBearerAuth() +@Controller('colonies') +export class ColonyNextStepsController { + constructor(private readonly nextSteps: NextStepsService) {} + + /** The next steps of one colony, only what the caller may see. */ + @ApiOkResponse({ type: ColonyNextStepsDto }) + @RequirePermission('colonies:read') + @Get(':id/next-steps') + nextStepsOf( + @Param('id') id: string, + @CurrentUser() actor: AuthenticatedUser, + ): Promise { + return this.nextSteps.forColony(id, actor.id); + } +} diff --git a/ApiCore-Backend/src/autopilot/dto/next-steps-response.dto.ts b/ApiCore-Backend/src/autopilot/dto/next-steps-response.dto.ts new file mode 100644 index 0000000..56606c2 --- /dev/null +++ b/ApiCore-Backend/src/autopilot/dto/next-steps-response.dto.ts @@ -0,0 +1,100 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { SuggestionIntent } from '../../../generated/prisma'; +import { + PHASE_SOURCES, + PHENOLOGY_PHASES, + SEASON_SEGMENTS, +} from '../../season/season-types'; +import type { ColonyNextSteps, NextStep } from '../next-step-ranking'; +import type { AutopilotToday } from '../next-steps.service'; + +const FORMS = [ + 'inspection', + 'varroa', + 'feeding', + 'treatment', + 'winter-check', +] as const; + +class NextStepActionDto { + @ApiProperty({ enum: ['CAPTURE', 'ACCEPT', 'CLEANUP', 'OPEN_TASK'] }) + type!: NextStep['action']['type']; + @ApiProperty({ enum: FORMS, required: false }) form?: (typeof FORMS)[number]; + @ApiProperty({ required: false }) href?: string; + @ApiProperty({ required: false }) suggestionId?: string; + @ApiProperty({ required: false }) taskId?: string; +} + +export class NextStepDto { + @ApiProperty() key!: string; + @ApiProperty({ enum: ['TASK', 'SUGGESTION'] }) kind!: NextStep['kind']; + @ApiProperty({ enum: SuggestionIntent, nullable: true }) + intent!: SuggestionIntent | null; + @ApiProperty() title!: string; + @ApiProperty({ type: [String] }) reasons!: string[]; + @ApiProperty({ nullable: true, type: String }) dueOn!: string | null; + @ApiProperty({ enum: ['OVERDUE', 'TODAY', 'SOON', 'LATER', 'NONE'] }) + urgency!: NextStep['urgency']; + @ApiProperty({ type: [String] }) suggestionIds!: string[]; + @ApiProperty({ type: NextStepActionDto }) action!: NextStepActionDto; + @ApiProperty() dismissible!: boolean; +} + +export class ColonyNextStepsDto { + @ApiProperty() colonyId!: string; + @ApiProperty({ enum: SEASON_SEGMENTS }) segment!: ColonyNextSteps['segment']; + @ApiProperty({ enum: PHENOLOGY_PHASES, nullable: true }) + phase!: ColonyNextSteps['phase']; + @ApiProperty({ enum: PHASE_SOURCES, nullable: true }) + phaseSource!: ColonyNextSteps['phaseSource']; + @ApiProperty({ nullable: true, type: String }) inspectionDueOn!: + string | null; + @ApiProperty({ nullable: true, type: String }) withdrawalUntil!: + string | null; + @ApiProperty({ type: [NextStepDto] }) steps!: NextStepDto[]; +} + +class TodayNextStepDto extends NextStepDto { + @ApiProperty() colonyId!: string; + @ApiProperty() colonyCode!: string; +} + +class DueColonyDto { + @ApiProperty() id!: string; + @ApiProperty() code!: string; + @ApiProperty() dueOn!: string; + @ApiProperty() overdueDays!: number; +} + +class InspectionDueDto { + @ApiProperty() apiaryId!: string; + @ApiProperty() apiaryName!: string; + @ApiProperty({ enum: SEASON_SEGMENTS }) + segment!: AutopilotToday['seasons'][number]['segment']; + @ApiProperty({ nullable: true, type: Number }) intervalDays!: number | null; + @ApiProperty({ type: [DueColonyDto] }) colonies!: DueColonyDto[]; +} + +class SeasonOfApiaryDto { + @ApiProperty() apiaryId!: string; + @ApiProperty() apiaryName!: string; + @ApiProperty({ enum: SEASON_SEGMENTS }) + segment!: AutopilotToday['seasons'][number]['segment']; + @ApiProperty({ enum: PHENOLOGY_PHASES, nullable: true }) + phase!: AutopilotToday['seasons'][number]['phase']; + @ApiProperty({ enum: PHASE_SOURCES, nullable: true }) + phaseSource!: AutopilotToday['seasons'][number]['phaseSource']; +} + +export class AutopilotTodayDto { + @ApiProperty({ type: TodayNextStepDto, nullable: true }) + nextStep!: TodayNextStepDto | null; + @ApiProperty({ + type: [InspectionDueDto], + description: 'Colonies whose inspection is due today or overdue.', + }) + inspectionDue!: InspectionDueDto[]; + @ApiProperty() suggestionsOpen!: number; + @ApiProperty({ type: [SeasonOfApiaryDto] }) seasons!: SeasonOfApiaryDto[]; + @ApiProperty() tasksVisible!: boolean; +} diff --git a/ApiCore-Backend/src/autopilot/next-step-ranking.spec.ts b/ApiCore-Backend/src/autopilot/next-step-ranking.spec.ts new file mode 100644 index 0000000..f392063 --- /dev/null +++ b/ApiCore-Backend/src/autopilot/next-step-ranking.spec.ts @@ -0,0 +1,460 @@ +import type { SuggestionIntent } from '../../generated/prisma'; +import { + FORM_OF_INTENT, + INTENT_ORDER, + mergeSameForm, + rankSteps, + topSteps, + toNextStep, + type StepSource, +} from './next-step-ranking'; + +const TODAY = '2026-05-12'; + +let counter = 0; +function source(overrides: Partial = {}): StepSource { + counter += 1; + const id = overrides.id ?? `s${String(counter).padStart(3, '0')}`; + return { + kind: 'SUGGESTION', + id, + intent: 'INSPECT', + mode: 'DO', + status: 'OPEN', + priority: 'NORMAL', + dueOn: TODAY, + createdAt: new Date('2026-05-01T08:00:00Z'), + title: 'Durchsicht', + reasons: ['Grund.'], + colonyId: 'col-1', + ...overrides, + }; +} + +const ids = (items: readonly StepSource[]): string[] => items.map((i) => i.id); + +describe('rankSteps', () => { + it('IN_PROGRESS comes first, then overdue, then by priority and the intent table', () => { + const inProgress = source({ + id: 'a-progress', + kind: 'TASK', + mode: null, + intent: null, + status: 'IN_PROGRESS', + priority: 'URGENT', + dueOn: '2026-06-30', + }); + const overdueLow = source({ + id: 'b-overdue-low', + priority: 'LOW', + dueOn: '2026-05-01', + }); + const todayHigh = source({ + id: 'c-today-high', + priority: 'HIGH', + intent: 'FEED', + }); + const todayHighVarroa = source({ + id: 'd-today-high-varroa', + priority: 'HIGH', + intent: 'VARROA_TREAT', + }); + const soon = source({ id: 'e-soon', dueOn: '2026-05-14' }); + const later = source({ id: 'f-later', dueOn: '2026-05-30' }); + const dateless = source({ id: 'g-none', dueOn: null }); + + const ranked = rankSteps( + [ + dateless, + later, + soon, + todayHigh, + overdueLow, + todayHighVarroa, + inProgress, + ], + TODAY, + ); + + // Urgency 0 holds the in-progress task, today and overdue steps; inside + // it priority decides, then the intent table (varroa before feed). + expect(ids(ranked)).toEqual([ + 'a-progress', + 'd-today-high-varroa', + 'c-today-high', + 'b-overdue-low', + 'e-soon', + 'f-later', + 'g-none', + ]); + }); + + it('a dateless URGENT step counts as urgent', () => { + const urgentNoDate = source({ + id: 'urgent', + dueOn: null, + priority: 'URGENT', + }); + const soon = source({ id: 'soon', dueOn: '2026-05-14', priority: 'HIGH' }); + const normalDateless = source({ id: 'plain', dueOn: null }); + + expect(ids(rankSteps([normalDateless, soon, urgentNoDate], TODAY))).toEqual( + ['urgent', 'soon', 'plain'], + ); + }); + + it('treats a task without an intent like FOLLOW_UP', () => { + const task = source({ id: 'task', kind: 'TASK', mode: null, intent: null }); + const cleanup = source({ id: 'cleanup', intent: 'CLEANUP', mode: 'DO' }); + const varroa = source({ id: 'varroa', intent: 'VARROA_TREAT' }); + + expect(ids(rankSteps([cleanup, task, varroa], TODAY))).toEqual([ + 'varroa', + 'task', + 'cleanup', + ]); + }); + + it('orders ties by due day, creation time and id', () => { + const early = source({ id: 'z', dueOn: '2026-05-10' }); + const sameDayOlder = source({ + id: 'y', + dueOn: '2026-05-11', + createdAt: new Date('2026-05-01T00:00:00Z'), + }); + const sameDayNewer = source({ + id: 'x', + dueOn: '2026-05-11', + createdAt: new Date('2026-05-02T00:00:00Z'), + }); + const tieB = source({ id: 'b', dueOn: '2026-05-11' }); + const tieA = source({ id: 'a', dueOn: '2026-05-11' }); + + const ranked = rankSteps( + [sameDayNewer, tieB, early, tieA, sameDayOlder], + TODAY, + ); + + expect(ids(ranked)).toEqual(['z', 'y', 'a', 'b', 'x']); + }); + + it('does not change its input', () => { + const input = [source({ id: 'b' }), source({ id: 'a' })]; + const copy = [...input]; + rankSteps(input, TODAY); + expect(input).toEqual(copy); + }); +}); + +describe('INTENT_ORDER and FORM_OF_INTENT', () => { + it('lists every intent exactly once', () => { + expect(new Set(INTENT_ORDER).size).toBe(INTENT_ORDER.length); + expect(INTENT_ORDER).toHaveLength(17); + expect(INTENT_ORDER[0]).toBe('VARROA_TREAT'); + }); + + it('maps the intents to the capture forms of the spec', () => { + const forms: [SuggestionIntent, string][] = [ + ['INSPECT', 'inspection'], + ['SWARM_CHECK', 'inspection'], + ['QUEEN_CHECK', 'inspection'], + ['QUEEN_ACCEPTANCE', 'inspection'], + ['HONEY_SUPER', 'inspection'], + ['HEALTH_CHECK', 'inspection'], + ['VARROA_MEASURE', 'varroa'], + ['VARROA_RECHECK', 'varroa'], + ['FEED', 'feeding'], + ['VARROA_TREAT', 'treatment'], + ['SUMMER_TREATMENT', 'treatment'], + ['WINTER_CHECK', 'winter-check'], + ]; + for (const [intent, form] of forms) { + expect(FORM_OF_INTENT[intent]).toBe(form); + } + expect(FORM_OF_INTENT.SPLIT_PLAN).toBeUndefined(); + expect(FORM_OF_INTENT.CLEANUP).toBeUndefined(); + }); +}); + +describe('mergeSameForm', () => { + it('INSPECT (DO) and SWARM_CHECK merge into one step with two reasons and CAPTURE', () => { + const swarm = source({ + id: 'swarm', + intent: 'SWARM_CHECK', + mode: 'PLAN', + title: 'Schwarmkontrolle', + reasons: ['Weiselzellen am 05.05. gesehen.'], + dueOn: '2026-05-11', + }); + const inspect = source({ + id: 'inspect', + intent: 'INSPECT', + mode: 'DO', + title: 'Durchsicht', + reasons: [ + 'Letzte Durchsicht vor 8 Tagen.', + 'In der Schwarmzeit alle 7 Tage.', + ], + dueOn: '2026-05-11', + }); + + const groups = mergeSameForm(rankSteps([inspect, swarm], TODAY)); + expect(groups).toHaveLength(1); + + const step = toNextStep(groups[0], TODAY); + expect(step.title).toBe('Durchsicht mit Schwarmkontrolle'); + expect(step.reasons).toEqual([ + 'Weiselzellen am 05.05. gesehen.', + 'Letzte Durchsicht vor 8 Tagen.', + ]); + expect(step.action).toEqual({ + type: 'CAPTURE', + form: 'inspection', + href: '/inspections/new?colonyId=col-1', + }); + expect(step.urgency).toBe('OVERDUE'); + expect(step.dismissible).toBe(true); + }); + + it('merges the inspection family, but never HONEY_SUPER (spec example B3)', () => { + const inspect = source({ id: 'inspect', intent: 'INSPECT', mode: 'DO' }); + const swarm = source({ id: 'swarm', intent: 'SWARM_CHECK', mode: 'PLAN' }); + const queen = source({ id: 'queen', intent: 'QUEEN_CHECK', mode: 'PLAN' }); + const honeyPlan = source({ + id: 'honey-plan', + intent: 'HONEY_SUPER', + mode: 'PLAN', + }); + const honeyDo = source({ + id: 'honey-do', + intent: 'HONEY_SUPER', + mode: 'DO', + }); + + const groups = mergeSameForm([queen, swarm, inspect, honeyPlan, honeyDo]); + + expect(groups.map(ids)).toEqual([ + ['queen', 'swarm', 'inspect'], + ['honey-plan'], + ['honey-do'], + ]); + }); + + it('merges VARROA_MEASURE and VARROA_RECHECK, not VARROA_TREAT', () => { + const measure = source({ id: 'm', intent: 'VARROA_MEASURE', mode: 'DO' }); + const recheck = source({ id: 'r', intent: 'VARROA_RECHECK', mode: 'DO' }); + const treat = source({ id: 't', intent: 'VARROA_TREAT', mode: 'DO' }); + + expect(mergeSameForm([treat, recheck, measure]).map(ids)).toEqual([ + ['t'], + ['r', 'm'], + ]); + }); + + it('a merged step takes the earliest due day, all suggestion ids and is dismissible', () => { + const inspect = source({ + id: 'inspect', + intent: 'INSPECT', + dueOn: '2026-05-14', + title: 'Durchsicht', + reasons: ['Letzte Durchsicht vor 8 Tagen.'], + }); + const queen = source({ + id: 'queen', + intent: 'QUEEN_ACCEPTANCE', + mode: 'PLAN', + dueOn: '2026-05-10', + title: 'Annahme der Königin prüfen', + reasons: ['Königin am 05.05. zugesetzt.'], + }); + + const step = toNextStep([inspect, queen], TODAY); + + expect(step.title).toBe('Durchsicht mit Annahme der Königin prüfen'); + expect(step.dueOn).toBe('2026-05-10'); + expect(step.urgency).toBe('OVERDUE'); + expect(step.suggestionIds).toEqual(['inspect', 'queen']); + expect(step.dismissible).toBe(true); + expect(step.reasons[0]).toContain('zugesetzt'); + }); + + it('an IN_PROGRESS task counts as TODAY', () => { + const step = toNextStep( + [ + source({ + kind: 'TASK', + mode: null, + intent: null, + status: 'IN_PROGRESS', + dueOn: '2026-07-01', + }), + ], + TODAY, + ); + expect(step.urgency).toBe('TODAY'); + expect(step.suggestionIds).toEqual([]); + }); + + it('two PLAN steps of the same form do not merge', () => { + const a = source({ id: 'a', intent: 'SWARM_CHECK', mode: 'PLAN' }); + const b = source({ id: 'b', intent: 'QUEEN_ACCEPTANCE', mode: 'PLAN' }); + + expect(mergeSameForm([a, b]).map(ids)).toEqual([['a'], ['b']]); + }); + + it('never merges tasks or steps of other colonies', () => { + const doStep = source({ id: 'do', intent: 'INSPECT', mode: 'DO' }); + const task = source({ + id: 'task', + kind: 'TASK', + mode: null, + intent: 'SWARM_CHECK', + }); + const otherColony = source({ + id: 'other', + intent: 'SWARM_CHECK', + mode: 'PLAN', + colonyId: 'col-2', + }); + + expect(mergeSameForm([doStep, task, otherColony]).map(ids)).toEqual([ + ['do'], + ['task'], + ['other'], + ]); + }); + + it('keeps the rank order of the groups (a group sits where its best member sits)', () => { + const varroa = source({ + id: 'varroa', + intent: 'VARROA_TREAT', + mode: 'PLAN', + }); + const swarm = source({ id: 'swarm', intent: 'SWARM_CHECK', mode: 'PLAN' }); + const feed = source({ id: 'feed', intent: 'FEED', mode: 'PLAN' }); + const inspect = source({ id: 'inspect', intent: 'INSPECT', mode: 'DO' }); + + const groups = mergeSameForm([varroa, swarm, feed, inspect]); + + expect(groups.map(ids)).toEqual([ + ['varroa'], + ['swarm', 'inspect'], + ['feed'], + ]); + }); +}); + +describe('toNextStep', () => { + it('opens a task with OPEN_TASK and never lets it be dismissed', () => { + const step = toNextStep( + [ + source({ + id: 't1', + kind: 'TASK', + mode: null, + intent: null, + title: 'Wachs', + }), + ], + TODAY, + ); + expect(step).toMatchObject({ + key: 'task:t1', + kind: 'TASK', + intent: null, + action: { type: 'OPEN_TASK', taskId: 't1', href: '/tasks' }, + dismissible: false, + }); + }); + + it('accepts a PLAN suggestion and lets it be dismissed', () => { + const step = toNextStep( + [source({ id: 's1', intent: 'VARROA_TREAT', mode: 'PLAN' })], + TODAY, + ); + expect(step).toMatchObject({ + key: 'suggestion:s1', + action: { type: 'ACCEPT', suggestionId: 's1' }, + dismissible: true, + }); + }); + + it('falls back to the capture link of a PLAN suggestion when accepting is not allowed', () => { + const step = toNextStep( + [source({ id: 's1', intent: 'VARROA_TREAT', mode: 'PLAN' })], + TODAY, + { canAccept: false }, + ); + expect(step.action).toEqual({ + type: 'CAPTURE', + form: 'treatment', + href: '/treatments/new?colonyId=col-1', + }); + }); + + it('points a CLEANUP suggestion at the cleanup', () => { + const step = toNextStep( + [source({ id: 'c1', intent: 'CLEANUP', mode: 'DO' })], + TODAY, + ); + expect(step.action).toEqual({ type: 'CLEANUP', suggestionId: 'c1' }); + }); + + it('builds the capture links of every form', () => { + const href = (intent: SuggestionIntent, today = TODAY): string => { + const { action } = toNextStep([source({ intent, mode: 'DO' })], today); + return action.type === 'CAPTURE' ? action.href : 'no-capture'; + }; + expect(href('VARROA_MEASURE')).toBe('/varroa/new?colonyId=col-1'); + expect(href('FEED')).toBe('/feedings/new?colonyId=col-1'); + expect(href('SUMMER_TREATMENT')).toBe('/treatments/new?colonyId=col-1'); + expect(href('WINTER_CHECK', '2026-11-20')).toBe( + '/winter?colonyId=col-1&season=AUTUMN', + ); + expect(href('WINTER_CHECK', '2026-03-20')).toBe( + '/winter?colonyId=col-1&season=SPRING', + ); + }); + + it('derives the urgency from the due day', () => { + const urgency = (dueOn: string | null) => + toNextStep([source({ dueOn })], TODAY).urgency; + expect(urgency('2026-05-11')).toBe('OVERDUE'); + expect(urgency('2026-05-12')).toBe('TODAY'); + expect(urgency('2026-05-15')).toBe('SOON'); + expect(urgency('2026-05-16')).toBe('LATER'); + expect(urgency(null)).toBe('NONE'); + }); +}); + +describe('topSteps', () => { + it('never returns more than 3 and is stable by id', () => { + const items = ['e', 'c', 'a', 'd', 'b'].map((id) => + source({ id, intent: 'FEED', mode: 'PLAN' }), + ); + + const first = topSteps(items, TODAY, { limit: 3 }); + const second = topSteps([...items].reverse(), TODAY, { limit: 3 }); + + expect(first.map((step) => step.key)).toEqual([ + 'suggestion:a', + 'suggestion:b', + 'suggestion:c', + ]); + expect(second.map((step) => step.key)).toEqual( + first.map((step) => step.key), + ); + }); + + it('drops a step the user can neither accept nor capture', () => { + const split = source({ id: 'split', intent: 'SPLIT_PLAN', mode: 'PLAN' }); + const feed = source({ id: 'feed', intent: 'FEED', mode: 'PLAN' }); + + const steps = topSteps([split, feed], TODAY, { + limit: 3, + canAccept: false, + }); + + expect(steps.map((step) => step.key)).toEqual(['suggestion:feed']); + }); +}); diff --git a/ApiCore-Backend/src/autopilot/next-step-ranking.ts b/ApiCore-Backend/src/autopilot/next-step-ranking.ts new file mode 100644 index 0000000..85394c6 --- /dev/null +++ b/ApiCore-Backend/src/autopilot/next-step-ranking.ts @@ -0,0 +1,438 @@ +import type { + SuggestionIntent, + SuggestionMode, + TaskPriority, +} from '../../generated/prisma'; +import { daysBetween } from '../common/dates'; +import type { + PhaseSourceKey, + PhenologyPhaseKey, + SeasonSegmentKey, +} from '../season/season-types'; + +export type NextStepForm = + 'inspection' | 'varroa' | 'feeding' | 'treatment' | 'winter-check'; + +export interface NextStep { + /** 'task:' | 'suggestion:' */ + key: string; + kind: 'TASK' | 'SUGGESTION'; + intent: SuggestionIntent | null; + title: string; + /** At most 2 short sentences. */ + reasons: string[]; + /** YYYY-MM-DD */ + dueOn: string | null; + urgency: 'OVERDUE' | 'TODAY' | 'SOON' | 'LATER' | 'NONE'; + /** The suggestions behind the step (all members of a merged step); [] for a task. */ + suggestionIds: string[]; + action: + | { type: 'CAPTURE'; form: NextStepForm; href: string } + | { type: 'ACCEPT'; suggestionId: string } + | { type: 'CLEANUP'; suggestionId: string } + | { type: 'OPEN_TASK'; taskId: string; href: string }; + dismissible: boolean; +} + +export interface ColonyNextSteps { + colonyId: string; + segment: SeasonSegmentKey; + phase: PhenologyPhaseKey | null; + phaseSource: PhaseSourceKey | null; + inspectionDueOn: string | null; + withdrawalUntil: string | null; + /** At most `maxStepsPerColony` (3). */ + steps: NextStep[]; +} + +/** An open task or an open suggestion of one colony, ready to be ranked. */ +export interface StepSource { + kind: 'TASK' | 'SUGGESTION'; + id: string; + intent: SuggestionIntent | null; + mode: SuggestionMode | null; + status: string; + priority: TaskPriority; + /** YYYY-MM-DD */ + dueOn: string | null; + createdAt: Date; + title: string; + reasons: string[]; + colonyId: string; +} + +/** Spec 8.4 point 3. A task without a suggestion counts as FOLLOW_UP. */ +export const INTENT_ORDER: readonly SuggestionIntent[] = [ + 'VARROA_TREAT', + 'QUEEN_CHECK', + 'QUEEN_ACCEPTANCE', + 'SWARM_CHECK', + 'SPLIT_PLAN', + 'FEED', + 'INSPECT', + 'VARROA_RECHECK', + 'HEALTH_CHECK', + 'SUMMER_TREATMENT', + 'VARROA_MEASURE', + 'WINTER_CHECK', + 'HONEY_SUPER', + 'QUEEN_REPLACE', + 'FOLLOW_UP', + 'CLEANUP', + 'STOCK_REORDER', +]; + +export const FORM_OF_INTENT: Readonly< + Partial> +> = { + INSPECT: 'inspection', + SWARM_CHECK: 'inspection', + QUEEN_CHECK: 'inspection', + QUEEN_ACCEPTANCE: 'inspection', + HONEY_SUPER: 'inspection', + HEALTH_CHECK: 'inspection', + VARROA_MEASURE: 'varroa', + VARROA_RECHECK: 'varroa', + FEED: 'feeding', + VARROA_TREAT: 'treatment', + SUMMER_TREATMENT: 'treatment', + WINTER_CHECK: 'winter-check', +}; + +/** Title of the merged step; the second title is appended with "mit". */ +const FORM_TITLES: Readonly> = { + inspection: 'Durchsicht', + varroa: 'Varroa messen', + feeding: 'Füttern', + treatment: 'Behandlung', + 'winter-check': 'Winterkontrolle', +}; + +const PRIORITY_RANK: Readonly> = { + URGENT: 0, + HIGH: 1, + NORMAL: 2, + LOW: 3, +}; + +const INTENT_RANK = new Map( + INTENT_ORDER.map((intent, index) => [intent, index] as const), +); + +const SOON_DAYS = 3; +const LATER_TIER_DAYS = 14; +const MAX_REASONS = 2; +const FIRST_AUTUMN_MONTH = 7; +const TASKS_HREF = '/tasks'; +const DATELESS_TIER = 4; + +export interface StepOptions { + /** The user may accept suggestions (tasks:create). Default true. */ + canAccept?: boolean; +} + +export interface TopStepsOptions extends StepOptions { + limit: number; +} + +function intentRank(source: StepSource): number { + return INTENT_RANK.get(source.intent ?? 'FOLLOW_UP') ?? INTENT_ORDER.length; +} + +/** Spec 8.4 point 1: 0 = now, 1 <= 3 days, 2 <= 14 days, 3 later, 4 no date. */ +function urgencyTier(source: StepSource, today: string): number { + if (source.status === 'IN_PROGRESS') { + return 0; + } + if (source.dueOn === null) { + return source.priority === 'URGENT' ? 0 : DATELESS_TIER; + } + const days = daysBetween(today, source.dueOn); + if (days <= 0) { + return 0; + } + if (days <= SOON_DAYS) { + return 1; + } + return days <= LATER_TIER_DAYS ? 2 : 3; +} + +function compareText(a: string, b: string): number { + if (a === b) { + return 0; + } + return a < b ? -1 : 1; +} + +/** The total order of the spec: urgency, priority, intent, due day, createdAt, id. */ +export function compareSources( + a: StepSource, + b: StepSource, + today: string, +): number { + return ( + urgencyTier(a, today) - urgencyTier(b, today) || + PRIORITY_RANK[a.priority] - PRIORITY_RANK[b.priority] || + intentRank(a) - intentRank(b) || + compareText(a.dueOn ?? '9999-12-31', b.dueOn ?? '9999-12-31') || + a.createdAt.getTime() - b.createdAt.getTime() || + compareText(a.id, b.id) + ); +} + +export function rankSteps( + items: readonly StepSource[], + today: string, +): StepSource[] { + return [...items].sort((a, b) => compareSources(a, b, today)); +} + +function formOf(source: StepSource): NextStepForm | undefined { + return source.kind === 'SUGGESTION' && source.intent !== null + ? FORM_OF_INTENT[source.intent] + : undefined; +} + +/** + * Intents that merge into one step with the "plain" member of their family + * (spec 8.4 says: same form = one step; the B3 example of spec 8.5 shows + * "Durchsicht mit Schwarmkontrolle" next to a separate "Honigraum prüfen", + * although both open the inspection form - the example wins). + * inspection family: INSPECT, SWARM_CHECK, QUEEN_CHECK, QUEEN_ACCEPTANCE + * varroa family: VARROA_MEASURE, VARROA_RECHECK + * Everything else (HONEY_SUPER, HEALTH_CHECK, FEED, VARROA_TREAT, + * SUMMER_TREATMENT, WINTER_CHECK ...) never merges. + */ +const MERGE_FAMILY: Readonly>> = + { + INSPECT: 'inspection', + SWARM_CHECK: 'inspection', + QUEEN_CHECK: 'inspection', + QUEEN_ACCEPTANCE: 'inspection', + VARROA_MEASURE: 'varroa', + VARROA_RECHECK: 'varroa', + }; + +/** The member whose title is the form's own title, by intent. */ +const PLAIN_INTENT: Readonly< + Record<'inspection' | 'varroa', SuggestionIntent> +> = { inspection: 'INSPECT', varroa: 'VARROA_MEASURE' }; + +function familyOf(source: StepSource): NextStepForm | undefined { + return source.kind === 'SUGGESTION' && source.intent !== null + ? MERGE_FAMILY[source.intent] + : undefined; +} + +/** + * Suggestions of one merge family (table above) of one colony become one + * step, when at least one of them is a DO suggestion (PLAN suggestions alone + * are decisions, not a form to fill in). The group keeps the position of its + * best-ranked member; `ranked` must already be ranked. + */ +export function mergeSameForm(ranked: readonly StepSource[]): StepSource[][] { + const members = new Map(); + for (const item of ranked) { + const family = familyOf(item); + if (family !== undefined) { + const key = `${item.colonyId}|${family}`; + members.set(key, [...(members.get(key) ?? []), item]); + } + } + const groups: StepSource[][] = []; + const placed = new Set(); + for (const item of ranked) { + const family = familyOf(item); + const key = family === undefined ? null : `${item.colonyId}|${family}`; + const group = key === null ? undefined : members.get(key); + const mergeable = + group !== undefined && + group.length > 1 && + group.some((member) => member.mode === 'DO'); + if (key === null || !mergeable) { + groups.push([item]); + } else if (!placed.has(key)) { + placed.add(key); + groups.push(group); + } + } + return groups; +} + +function urgencyOf(dueOn: string | null, today: string): NextStep['urgency'] { + if (dueOn === null) { + return 'NONE'; + } + const days = daysBetween(today, dueOn); + if (days < 0) { + return 'OVERDUE'; + } + if (days === 0) { + return 'TODAY'; + } + return days <= SOON_DAYS ? 'SOON' : 'LATER'; +} + +function captureHref( + form: NextStepForm, + colonyId: string, + today: string, +): string { + const query = `colonyId=${encodeURIComponent(colonyId)}`; + switch (form) { + case 'inspection': + return `/inspections/new?${query}`; + case 'varroa': + return `/varroa/new?${query}`; + case 'feeding': + return `/feedings/new?${query}`; + case 'treatment': + return `/treatments/new?${query}`; + case 'winter-check': { + const month = Number(today.slice(5, 7)); + const season = month >= FIRST_AUTUMN_MONTH ? 'AUTUMN' : 'SPRING'; + return `/winter?${query}&season=${season}`; + } + } +} + +function capture( + form: NextStepForm, + colonyId: string, + today: string, +): NextStep['action'] { + return { + type: 'CAPTURE', + form, + href: captureHref(form, colonyId, today), + }; +} + +function actionOf( + source: StepSource, + today: string, + canAccept: boolean, +): NextStep['action'] | null { + if (source.kind === 'TASK') { + return { type: 'OPEN_TASK', taskId: source.id, href: TASKS_HREF }; + } + if (source.intent === 'CLEANUP') { + return { type: 'CLEANUP', suggestionId: source.id }; + } + if (source.mode === 'PLAN' && canAccept) { + return { type: 'ACCEPT', suggestionId: source.id }; + } + const form = formOf(source); + return form === undefined ? null : capture(form, source.colonyId, today); +} + +/** Whether the user can do anything with this step (else it is not shown). */ +function isActionable( + source: StepSource, + today: string, + canAccept: boolean, +): boolean { + return actionOf(source, today, canAccept) !== null; +} + +/** + * The specific cause first (the step that is not the plain form, "Weiselzellen + * gesehen"), then the plain one ("Letzte Durchsicht vor 8 Tagen"). + */ +function isPlain(form: NextStepForm, item: StepSource): boolean { + return ( + (form === 'inspection' || form === 'varroa') && + item.intent === PLAIN_INTENT[form] + ); +} + +/** + * The specific cause first ("Weiselzellen gesehen"), then the plain member + * ("Letzte Durchsicht vor 8 Tagen"), at most two. + */ +function mergedReasons( + form: NextStepForm, + group: readonly StepSource[], +): string[] { + const ordered = [ + ...group.filter((item) => !isPlain(form, item)), + ...group.filter((item) => isPlain(form, item)), + ]; + const firsts = ordered.flatMap((item) => item.reasons.slice(0, 1)); + const rest = ordered.flatMap((item) => item.reasons.slice(1)); + return [...new Set([...firsts, ...rest])].slice(0, MAX_REASONS); +} + +function mergedTitle(form: NextStepForm, group: readonly StepSource[]): string { + const second = group.find((item) => !isPlain(form, item)); + const formTitle = FORM_TITLES[form]; + return second === undefined ? formTitle : `${formTitle} mit ${second.title}`; +} + +/** The earliest due day of the members (null when none has one). */ +function earliestDue(group: readonly StepSource[]): string | null { + const days = group.flatMap((item) => + item.dueOn === null ? [] : [item.dueOn], + ); + return days.length === 0 ? null : days.reduce((a, b) => (a <= b ? a : b)); +} + +/** One step from a ranked group (a single item, or a merged group). */ +export function toNextStep( + group: readonly StepSource[], + today: string, + options: StepOptions = {}, +): NextStep { + const [head] = group; + const canAccept = options.canAccept ?? true; + const form = formOf(head); + if (group.length > 1 && form !== undefined) { + return { + key: `suggestion:${head.id}`, + kind: 'SUGGESTION', + intent: head.intent, + title: mergedTitle(form, group), + reasons: mergedReasons(form, group), + dueOn: earliestDue(group), + urgency: urgencyOf(earliestDue(group), today), + suggestionIds: group.map((item) => item.id), + action: capture(form, head.colonyId, today), + dismissible: true, + }; + } + const action = actionOf(head, today, canAccept); + if (action === null) { + throw new Error(`Step ${head.id} has no action`); + } + return { + key: `${head.kind === 'TASK' ? 'task' : 'suggestion'}:${head.id}`, + kind: head.kind, + intent: head.intent, + title: head.title, + reasons: head.reasons.slice(0, MAX_REASONS), + dueOn: head.dueOn, + urgency: + head.status === 'IN_PROGRESS' ? 'TODAY' : urgencyOf(head.dueOn, today), + suggestionIds: head.kind === 'SUGGESTION' ? [head.id] : [], + action, + dismissible: head.kind === 'SUGGESTION', + }; +} + +/** + * The next steps of one colony: what the user can act on, ranked, merged by + * form and cut to `limit`. + */ +export function topSteps( + items: readonly StepSource[], + today: string, + options: TopStepsOptions, +): NextStep[] { + const canAccept = options.canAccept ?? true; + const actionable = items.filter((item) => + isActionable(item, today, canAccept), + ); + return mergeSameForm(rankSteps(actionable, today)) + .slice(0, options.limit) + .map((group) => toNextStep(group, today, { canAccept })); +} diff --git a/ApiCore-Backend/src/autopilot/next-steps.service.spec.ts b/ApiCore-Backend/src/autopilot/next-steps.service.spec.ts new file mode 100644 index 0000000..58206e9 --- /dev/null +++ b/ApiCore-Backend/src/autopilot/next-steps.service.spec.ts @@ -0,0 +1,413 @@ +import { NotFoundException } from '@nestjs/common'; +import type { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; +import type { PrismaService } from '../prisma/prisma.service'; +import { SeasonService } from '../season/season.service'; +import { SuggestionViewsService } from '../suggestions/suggestion-views.service'; +import { NextStepsService } from './next-steps.service'; + +const TODAY = '2026-05-12'; +const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`); + +describe('NextStepsService', () => { + let service: NextStepsService; + let prisma: { + colony: { findMany: jest.Mock }; + suggestion: { findMany: jest.Mock; groupBy: jest.Mock; count: jest.Mock }; + task: { findMany: jest.Mock }; + inspection: { groupBy: jest.Mock }; + treatment: { findMany: jest.Mock }; + automationRule: { findUnique: jest.Mock; findMany: jest.Mock }; + apiary: { findMany: jest.Mock }; + }; + let permissionResolver: { getPermissions: jest.Mock }; + + const grant = (...keys: string[]) => + permissionResolver.getPermissions.mockResolvedValue(new Set(keys)); + + const suggestionRow = (overrides: Record = {}) => ({ + id: 's1', + ruleKey: 'VARROA_THRESHOLD', + reasonCode: 'UNKNOWN_CODE', + evidence: {}, + intent: 'VARROA_TREAT', + mode: 'PLAN', + status: 'OPEN', + priority: 'HIGH', + dueDate: d('2026-05-12'), + createdAt: new Date('2026-05-10T08:00:00Z'), + colonyId: 'c1', + ...overrides, + }); + + beforeEach(() => { + prisma = { + colony: { + findMany: jest.fn().mockResolvedValue([ + { + id: 'c1', + code: 'B3', + apiaryId: 'a1', + createdAt: new Date('2026-04-01T08:00:00Z'), + }, + ]), + }, + suggestion: { + findMany: jest.fn().mockResolvedValue([]), + groupBy: jest.fn().mockResolvedValue([]), + count: jest.fn().mockResolvedValue(0), + }, + task: { findMany: jest.fn().mockResolvedValue([]) }, + inspection: { + groupBy: jest + .fn() + .mockResolvedValue([ + { colonyId: 'c1', _max: { date: d('2026-05-03') } }, + ]), + }, + treatment: { findMany: jest.fn().mockResolvedValue([]) }, + automationRule: { + findUnique: jest.fn().mockResolvedValue(null), + findMany: jest.fn().mockResolvedValue([]), + }, + apiary: { findMany: jest.fn().mockResolvedValue([]) }, + }; + permissionResolver = { getPermissions: jest.fn() }; + service = new NextStepsService( + prisma as unknown as PrismaService, + permissionResolver as unknown as PermissionResolverService, + new SeasonService(), + new SuggestionViewsService( + prisma as unknown as PrismaService, + permissionResolver as unknown as PermissionResolverService, + ), + ); + }); + + it('answers 404 for an unknown colony', async () => { + grant('colonies:read'); + prisma.colony.findMany.mockResolvedValue([]); + + await expect(service.forColony('nope', 'u1', TODAY)).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + + it('reads no suggestions and no tasks without tasks:read', async () => { + grant('colonies:read', 'varroa:read'); + + const steps = await service.forColony('c1', 'u1', TODAY); + + expect(steps.steps).toEqual([]); + expect(prisma.suggestion.findMany).not.toHaveBeenCalled(); + expect(prisma.task.findMany).not.toHaveBeenCalled(); + }); + + it('filters the suggestions by the permissions the user holds', async () => { + grant('colonies:read', 'tasks:read', 'inspections:read'); + + await service.forColony('c1', 'u1', TODAY); + + expect(prisma.suggestion.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + AND: [ + { + OR: [ + { requiredPermission: null }, + { + requiredPermission: { + in: ['colonies:read', 'tasks:read', 'inspections:read'], + }, + }, + ], + }, + { status: 'OPEN', colonyId: { in: ['c1'] } }, + ], + }, + }), + ); + }); + + it('offers ACCEPT only with tasks:create, else the capture link', async () => { + prisma.suggestion.findMany.mockResolvedValue([suggestionRow()]); + + grant('colonies:read', 'tasks:read', 'tasks:create'); + const withCreate = await service.forColony('c1', 'u1', TODAY); + grant('colonies:read', 'tasks:read'); + const withoutCreate = await service.forColony('c1', 'u1', TODAY); + + expect(withCreate.steps[0].action).toEqual({ + type: 'ACCEPT', + suggestionId: 's1', + }); + expect(withoutCreate.steps[0].action).toEqual({ + type: 'CAPTURE', + form: 'treatment', + href: '/treatments/new?colonyId=c1', + }); + }); + + it('cuts the steps to maxStepsPerColony', async () => { + grant('colonies:read', 'tasks:read'); + prisma.suggestion.findMany.mockResolvedValue( + [ + ['a', 'WINTER_CHECK'], + ['b', 'VARROA_MEASURE'], + ['c', 'INSPECT'], + ['d', 'FEED'], + ['e', 'VARROA_TREAT'], + ].map(([id, intent]) => suggestionRow({ id, intent, mode: 'DO' })), + ); + + const steps = await service.forColony('c1', 'u1', TODAY); + + expect(steps.steps.map((step) => step.key)).toEqual([ + 'suggestion:e', + 'suggestion:d', + 'suggestion:c', + ]); + }); + + it('shows inspectionDueOn only with inspections:read', async () => { + grant('colonies:read'); + const hidden = await service.forColony('c1', 'u1', TODAY); + grant('colonies:read', 'inspections:read'); + const shown = await service.forColony('c1', 'u1', TODAY); + + expect(hidden.inspectionDueOn).toBeNull(); + // Swarm season by calendar: 7 days after 2026-05-03. + expect(shown.inspectionDueOn).toBe('2026-05-10'); + expect(shown.segment).toBe('SCHWARMZEIT'); + expect(prisma.inspection.groupBy).toHaveBeenCalledTimes(1); + }); + + it('reports the latest running withdrawal period as a hint with treatments:read', async () => { + prisma.treatment.findMany.mockResolvedValue([ + { + id: 't1', + colonyId: 'c1', + product: 'A', + date: d('2026-05-01'), + startDate: null, + endDate: d('2026-05-05'), + withdrawalPeriodDays: 10, + }, + { + id: 't2', + colonyId: 'c1', + product: 'B', + date: d('2026-05-02'), + startDate: null, + endDate: d('2026-05-08'), + withdrawalPeriodDays: 30, + }, + { + id: 't3', + colonyId: 'c1', + product: 'C', + date: d('2026-01-02'), + startDate: null, + endDate: null, + withdrawalPeriodDays: 10, + }, + ]); + + grant('colonies:read', 'treatments:read'); + const shown = await service.forColony('c1', 'u1', TODAY); + grant('colonies:read'); + const hidden = await service.forColony('c1', 'u1', TODAY); + + expect(shown.withdrawalUntil).toBe('2026-06-07'); + expect(shown.steps).toEqual([]); + expect(hidden.withdrawalUntil).toBeNull(); + expect(prisma.treatment.findMany).toHaveBeenCalledTimes(1); + }); + + it('ranks a task like its intent and shows it with OPEN_TASK', async () => { + grant('colonies:read', 'tasks:read'); + prisma.task.findMany.mockResolvedValue([ + { + id: 't1', + title: 'Wachs einschmelzen', + status: 'IN_PROGRESS', + priority: 'NORMAL', + dueDate: null, + createdAt: new Date('2026-05-01T00:00:00Z'), + relatedId: 'c1', + sourceAutomationTrigger: null, + sourceSuggestion: null, + }, + ]); + + const steps = await service.forColony('c1', 'u1', TODAY); + + expect(steps.steps).toHaveLength(1); + expect(steps.steps[0]).toMatchObject({ + key: 'task:t1', + title: 'Wachs einschmelzen', + action: { type: 'OPEN_TASK', taskId: 't1', href: '/tasks' }, + }); + }); + + it('hides an open suggestion when an open task of the colony has the same intent', async () => { + grant('colonies:read', 'tasks:read'); + prisma.suggestion.findMany.mockResolvedValue([ + suggestionRow({ id: 'varroa', intent: 'VARROA_TREAT' }), + suggestionRow({ id: 'feed', intent: 'FEED', mode: 'DO' }), + suggestionRow({ id: 'swarm', intent: 'SWARM_CHECK', colonyId: 'c9' }), + ]); + prisma.task.findMany.mockResolvedValue([ + { + id: 'task-accepted', + title: 'Behandeln', + status: 'OPEN', + priority: 'NORMAL', + dueDate: null, + createdAt: new Date('2026-05-01T00:00:00Z'), + relatedId: 'c1', + sourceAutomationTrigger: null, + sourceSuggestion: { intent: 'VARROA_TREAT' }, + }, + { + id: 'task-legacy', + title: 'Füttern', + status: 'OPEN', + priority: 'NORMAL', + dueDate: null, + createdAt: new Date('2026-05-01T00:00:00Z'), + relatedId: 'c1', + sourceAutomationTrigger: 'FEED_STATUS_CRITICAL', + sourceSuggestion: null, + }, + ]); + + const steps = await service.forColony('c1', 'u1', TODAY); + + expect(steps.steps.map((step) => step.key).sort()).toEqual([ + 'task:task-accepted', + 'task:task-legacy', + ]); + }); + + it('reads a fixed number of queries for many colonies', async () => { + grant('colonies:read', 'tasks:read', 'inspections:read', 'treatments:read'); + prisma.colony.findMany.mockResolvedValue( + ['c1', 'c2', 'c3'].map((id) => ({ + id, + code: id, + apiaryId: 'a1', + createdAt: new Date('2026-04-01T08:00:00Z'), + })), + ); + + const result = await service.forColonies(['c1', 'c2', 'c3'], 'u1', TODAY); + + expect([...result.keys()]).toEqual(['c1', 'c2', 'c3']); + expect(prisma.colony.findMany).toHaveBeenCalledTimes(1); + expect(prisma.suggestion.findMany).toHaveBeenCalledTimes(1); + expect(prisma.task.findMany).toHaveBeenCalledTimes(1); + expect(prisma.inspection.groupBy).toHaveBeenCalledTimes(1); + expect(prisma.treatment.findMany).toHaveBeenCalledTimes(1); + }); + + describe('today', () => { + it('picks the most urgent first step over all colonies', async () => { + grant('colonies:read', 'tasks:read'); + prisma.colony.findMany.mockImplementation((args: { select: object }) => + Promise.resolve( + 'code' in args.select + ? ['c1', 'c2'].map((id) => ({ + id, + code: id.toUpperCase(), + apiaryId: 'a1', + createdAt: new Date('2026-04-01T08:00:00Z'), + })) + : [{ id: 'c1' }, { id: 'c2' }], + ), + ); + prisma.apiary.findMany.mockResolvedValue([ + { id: 'a1', name: 'Heimstand' }, + ]); + prisma.suggestion.count.mockResolvedValue(2); + prisma.suggestion.findMany.mockResolvedValue([ + suggestionRow({ id: 'late', colonyId: 'c1', priority: 'LOW' }), + suggestionRow({ id: 'urgent', colonyId: 'c2', priority: 'URGENT' }), + ]); + + const today = await service.today('u1', TODAY); + + expect(today.nextStep).toMatchObject({ + key: 'suggestion:urgent', + colonyId: 'c2', + colonyCode: 'C2', + }); + expect(today.suggestionsOpen).toBe(2); + expect(today.tasksVisible).toBe(true); + expect(today.seasons).toEqual([ + { + apiaryId: 'a1', + apiaryName: 'Heimstand', + segment: 'SCHWARMZEIT', + phase: 'APFEL', + phaseSource: 'CALENDAR', + }, + ]); + }); + + it('has no next step and counts no suggestions without tasks:read', async () => { + grant('colonies:read'); + + const today = await service.today('u1', TODAY); + + expect(today.nextStep).toBeNull(); + expect(today.suggestionsOpen).toBe(0); + expect(today.tasksVisible).toBe(false); + expect(prisma.suggestion.count).not.toHaveBeenCalled(); + }); + + it('lists only colonies whose inspection is due, with the overdue days', async () => { + grant('colonies:read', 'inspections:read'); + prisma.apiary.findMany.mockResolvedValue([ + { id: 'a1', name: 'Heimstand' }, + ]); + + const today = await service.today('u1', TODAY); + + expect(today.inspectionDue).toEqual([ + { + apiaryId: 'a1', + apiaryName: 'Heimstand', + segment: 'SCHWARMZEIT', + intervalDays: 7, + colonies: [ + { id: 'c1', code: 'B3', dueOn: '2026-05-10', overdueDays: 2 }, + ], + }, + ]); + }); + }); + + describe('warningCounts', () => { + it('counts open HIGH and URGENT suggestions per colony', async () => { + grant('colonies:read', 'tasks:read'); + prisma.suggestion.groupBy.mockResolvedValue([ + { colonyId: 'c1', priority: 'HIGH', _count: { _all: 2 } }, + { colonyId: 'c1', priority: 'URGENT', _count: { _all: 1 } }, + { colonyId: null, priority: 'HIGH', _count: { _all: 5 } }, + ]); + + const counts = await service.warningCounts(['c1'], 'u1'); + + expect(counts).toEqual(new Map([['c1', { warnings: 2, critical: 1 }]])); + }); + + it('counts nothing without tasks:read', async () => { + grant('colonies:read'); + + const counts = await service.warningCounts(['c1'], 'u1'); + + expect(counts.size).toBe(0); + expect(prisma.suggestion.groupBy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/ApiCore-Backend/src/autopilot/next-steps.service.ts b/ApiCore-Backend/src/autopilot/next-steps.service.ts new file mode 100644 index 0000000..2da9b23 --- /dev/null +++ b/ApiCore-Backend/src/autopilot/next-steps.service.ts @@ -0,0 +1,585 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import type { + AutomationTriggerType, + SuggestionIntent, + TaskPriority, +} from '../../generated/prisma'; +import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; +import { readRule } from '../automation/rule-config'; +import { LIVING_COLONY_WHERE } from '../colonies/colony-status'; +import { + addDays, + berlinDayOf, + berlinToday, + daysBetween, + dayToDate, +} from '../common/dates'; +import { PrismaService } from '../prisma/prisma.service'; +import { SeasonService } from '../season/season.service'; +import { inspectionDueOn } from '../season/inspection-due'; +import type { SeasonSegmentKey, SeasonState } from '../season/season-types'; +import { buildEvidenceLookup } from '../suggestions/evidence-lookup'; +import { renderSuggestion } from '../suggestions/suggestion-texts'; +import { SuggestionViewsService } from '../suggestions/suggestion-views.service'; +import { + INTENT_OF_LEGACY_TRIGGER, + type SuggestionEvidence, +} from '../suggestions/suggestion.types'; +import { + isInWindow, + withdrawalWindowOf, +} from '../treatments/withdrawal-window'; +import { + compareSources, + topSteps, + type ColonyNextSteps, + type NextStep, + type StepSource, +} from './next-step-ranking'; + +export interface AutopilotToday { + nextStep: (NextStep & { colonyId: string; colonyCode: string }) | null; + /** Colonies whose inspection is due today or overdue (`dueOn <= today`), not those coming up. */ + inspectionDue: { + apiaryId: string; + apiaryName: string; + segment: SeasonSegmentKey; + intervalDays: number | null; + colonies: { + id: string; + code: string; + dueOn: string; + overdueDays: number; + }[]; + }[]; + suggestionsOpen: number; + seasons: { + apiaryId: string; + apiaryName: string; + segment: SeasonSegmentKey; + phase: ColonyNextSteps['phase']; + phaseSource: ColonyNextSteps['phaseSource']; + }[]; + tasksVisible: boolean; +} + +/** Open suggestions of HIGH / URGENT priority that the caller may see. */ +export interface WarningCount { + warnings: number; + critical: number; +} + +interface ColonyComputation { + code: string; + apiaryId: string; + /** Leading source of the first step, for the comparison across colonies. */ + head: StepSource | null; + view: ColonyNextSteps; +} + +interface Computation { + colonies: Map; + seasons: Map; + intervalDays: Readonly< + Record, number> + >; +} + +/** A withdrawal period longer than this is not looked for. */ +const WITHDRAWAL_LOOKBACK_DAYS = 400; +const OPEN_TASK_STATUSES = ['OPEN', 'IN_PROGRESS'] as const; +const WARNING_PRIORITIES: readonly TaskPriority[] = ['HIGH', 'URGENT']; + +/** The intent an open task stands for; null for a plain task. */ +function taskIntentOf(task: { + sourceSuggestion: { intent: SuggestionIntent } | null; + sourceAutomationTrigger: AutomationTriggerType | null; +}): SuggestionIntent | null { + return ( + task.sourceSuggestion?.intent ?? + (task.sourceAutomationTrigger === null + ? null + : (INTENT_OF_LEGACY_TRIGGER[task.sourceAutomationTrigger] ?? null)) + ); +} + +function byCode(a: { code: string }, b: { code: string }): number { + return a.code.localeCompare(b.code, 'de', { numeric: true }); +} + +/** + * "The next 3 steps" of a colony (Spec 8.4, 8.5) and the day overview: what + * the user may see of the open tasks and suggestions, ranked, merged by form + * and cut to `maxStepsPerColony`. Reads only; a fixed number of queries for + * any number of colonies. + */ +@Injectable() +export class NextStepsService { + constructor( + private readonly prisma: PrismaService, + private readonly permissionResolver: PermissionResolverService, + private readonly seasons: SeasonService, + private readonly suggestionViews: SuggestionViewsService, + ) {} + + async forColony( + colonyId: string, + userId: string, + today: string = berlinToday(), + ): Promise { + const view = (await this.forColonies([colonyId], userId, today)).get( + colonyId, + ); + if (view === undefined) { + throw new NotFoundException('Colony not found'); + } + return view; + } + + /** Unknown colonies are left out of the map. */ + async forColonies( + colonyIds: readonly string[], + userId: string, + today: string = berlinToday(), + permissions?: ReadonlySet, + ): Promise> { + const held = + permissions ?? (await this.permissionResolver.getPermissions(userId)); + const { colonies } = await this.compute(colonyIds, held, today); + return new Map( + [...colonies].map(([id, colony]) => [id, colony.view] as const), + ); + } + + /** + * Open HIGH / URGENT suggestions per colony: the "warnings" and + * "critical" of the colony traffic light. Only what the user may see + * (`tasks:read` and the permission of each suggestion). + */ + async warningCounts( + colonyIds: readonly string[], + userId: string, + permissions?: ReadonlySet, + ): Promise> { + const held = + permissions ?? (await this.permissionResolver.getPermissions(userId)); + const counts = new Map(); + if (!held.has('tasks:read') || colonyIds.length === 0) { + return counts; + } + const groups = await this.prisma.suggestion.groupBy({ + by: ['colonyId', 'priority'], + where: { + AND: [ + this.suggestionViews.visibleWhere(held), + { + status: 'OPEN', + colonyId: { in: [...colonyIds] }, + priority: { in: [...WARNING_PRIORITIES] }, + }, + ], + }, + _count: { _all: true }, + }); + for (const group of groups) { + if (group.colonyId === null) { + continue; + } + const current = counts.get(group.colonyId) ?? { + warnings: 0, + critical: 0, + }; + counts.set( + group.colonyId, + group.priority === 'URGENT' + ? { ...current, critical: group._count._all } + : { ...current, warnings: group._count._all }, + ); + } + return counts; + } + + async today( + userId: string, + today: string = berlinToday(), + ): Promise { + const held = await this.permissionResolver.getPermissions(userId); + const living = await this.prisma.colony.findMany({ + where: LIVING_COLONY_WHERE, + select: { id: true }, + }); + const { colonies, seasons, intervalDays } = await this.compute( + living.map((colony) => colony.id), + held, + today, + ); + const apiaries = await this.prisma.apiary.findMany({ + where: { id: { in: [...seasons.keys()] } }, + select: { id: true, name: true }, + }); + const apiaryName = new Map(apiaries.map((a) => [a.id, a.name] as const)); + const nameOf = (apiaryId: string): string => apiaryName.get(apiaryId) ?? ''; + const suggestionsOpen = held.has('tasks:read') + ? await this.prisma.suggestion.count({ + where: { + AND: [this.suggestionViews.visibleWhere(held), { status: 'OPEN' }], + }, + }) + : 0; + const sortedApiaries = [...seasons.values()].sort((a, b) => + nameOf(a.apiaryId).localeCompare(nameOf(b.apiaryId), 'de', { + numeric: true, + }), + ); + return { + nextStep: this.mostUrgent(colonies, today), + inspectionDue: sortedApiaries.flatMap((season) => { + const due = [...colonies] + .filter( + ([, c]) => + c.apiaryId === season.apiaryId && + c.view.inspectionDueOn !== null && + c.view.inspectionDueOn <= today, + ) + .map(([id, c]) => ({ + id, + code: c.code, + dueOn: c.view.inspectionDueOn ?? today, + overdueDays: daysBetween(c.view.inspectionDueOn ?? today, today), + })) + .sort((a, b) => a.dueOn.localeCompare(b.dueOn) || byCode(a, b)); + return due.length === 0 + ? [] + : [ + { + apiaryId: season.apiaryId, + apiaryName: nameOf(season.apiaryId), + segment: season.segment, + intervalDays: + season.segment === 'WINTERRUHE' + ? null + : intervalDays[season.segment], + colonies: due, + }, + ]; + }), + suggestionsOpen, + seasons: sortedApiaries.map((season) => ({ + apiaryId: season.apiaryId, + apiaryName: nameOf(season.apiaryId), + segment: season.segment, + phase: season.phase, + phaseSource: season.phaseSource, + })), + tasksVisible: held.has('tasks:read'), + }; + } + + private mostUrgent( + colonies: ReadonlyMap, + today: string, + ): AutopilotToday['nextStep'] { + const candidates = [...colonies].flatMap(([colonyId, colony]) => + colony.head === null || colony.view.steps.length === 0 + ? [] + : [{ colonyId, colony, head: colony.head }], + ); + candidates.sort((a, b) => compareSources(a.head, b.head, today)); + const best = candidates.at(0); + return best === undefined + ? null + : { + ...best.colony.view.steps[0], + colonyId: best.colonyId, + colonyCode: best.colony.code, + }; + } + + private async compute( + colonyIds: readonly string[], + held: ReadonlySet, + today: string, + ): Promise { + const ids = [...new Set(colonyIds)]; + const rows = + ids.length === 0 + ? [] + : await this.prisma.colony.findMany({ + where: { id: { in: ids } }, + select: { id: true, code: true, apiaryId: true, createdAt: true }, + }); + const [states, interval, autopilot] = await Promise.all([ + this.seasons.statesFor( + this.prisma, + rows.map((row) => row.apiaryId), + today, + ), + readRule(this.prisma, 'INSPECTION_INTERVAL'), + readRule(this.prisma, 'SEASON_AUTOPILOT'), + ]); + const foundIds = rows.map((row) => row.id); + const [lastInspections, sources, withdrawals] = await Promise.all([ + this.lastInspectionDays(foundIds, held), + this.stepSources(foundIds, held, today), + this.withdrawalUntil(foundIds, held, today), + ]); + const colonies = new Map(); + for (const row of rows) { + const season = states.get(row.apiaryId); + if (season === undefined) { + continue; + } + const due = interval.active + ? inspectionDueOn( + { + createdOn: berlinDayOf(row.createdAt), + lastInspectedOn: lastInspections.get(row.id) ?? null, + }, + season, + interval.config, + ) + : null; + const own = sources.get(row.id) ?? []; + const steps = topSteps(own, today, { + limit: autopilot.config.maxStepsPerColony, + canAccept: held.has('tasks:create'), + }); + colonies.set(row.id, { + code: row.code, + apiaryId: row.apiaryId, + head: this.headOf(own, steps), + view: { + colonyId: row.id, + segment: season.segment, + phase: season.phase, + phaseSource: season.phaseSource, + inspectionDueOn: + due !== null && held.has('inspections:read') ? due.dueOn : null, + withdrawalUntil: withdrawals.get(row.id) ?? null, + steps, + }, + }); + } + return { + colonies, + seasons: states, + intervalDays: interval.config.intervalDays, + }; + } + + /** The source that stands for the first step (for the cross-colony order). */ + private headOf( + own: readonly StepSource[], + steps: readonly NextStep[], + ): StepSource | null { + const first = steps.at(0); + if (first === undefined) { + return null; + } + const byId = new Map(own.map((source) => [source.id, source] as const)); + const id = + first.suggestionIds.at(0) ?? + (first.action.type === 'OPEN_TASK' ? first.action.taskId : null); + return id === null ? null : (byId.get(id) ?? null); + } + + /** Last inspection day per colony; nothing without `inspections:read`. */ + private async lastInspectionDays( + colonyIds: readonly string[], + held: ReadonlySet, + ): Promise> { + const days = new Map(); + if (!held.has('inspections:read') || colonyIds.length === 0) { + return days; + } + const groups = await this.prisma.inspection.groupBy({ + by: ['colonyId'], + where: { colonyId: { in: [...colonyIds] } }, + _max: { date: true }, + }); + for (const group of groups) { + if (group._max.date) { + days.set(group.colonyId, berlinDayOf(group._max.date)); + } + } + return days; + } + + /** The youngest running withdrawal period per colony (treatments:read). */ + private async withdrawalUntil( + colonyIds: readonly string[], + held: ReadonlySet, + today: string, + ): Promise> { + const until = new Map(); + if (!held.has('treatments:read') || colonyIds.length === 0) { + return until; + } + const cutoff = dayToDate(addDays(today, -WITHDRAWAL_LOOKBACK_DAYS)); + const treatments = await this.prisma.treatment.findMany({ + where: { + colonyId: { in: [...colonyIds] }, + withdrawalPeriodDays: { not: null }, + OR: [{ endDate: { gte: cutoff } }, { date: { gte: cutoff } }], + }, + select: { + id: true, + colonyId: true, + product: true, + date: true, + startDate: true, + endDate: true, + withdrawalPeriodDays: true, + }, + }); + for (const treatment of treatments) { + const window = withdrawalWindowOf(treatment); + if (window === null || !isInWindow(window, today)) { + continue; + } + const current = until.get(treatment.colonyId); + if (current === undefined || window.untilDay > current) { + until.set(treatment.colonyId, window.untilDay); + } + } + return until; + } + + /** + * Open suggestions (visible ones, and only with `tasks:read`) and open + * tasks (`tasks:read`) of the colonies, as rankable sources. + */ + private async stepSources( + colonyIds: readonly string[], + held: ReadonlySet, + today: string, + ): Promise> { + const sources = new Map(); + if (!held.has('tasks:read') || colonyIds.length === 0) { + return sources; + } + const [suggestions, tasks] = await Promise.all([ + this.prisma.suggestion.findMany({ + where: { + AND: [ + this.suggestionViews.visibleWhere(held), + { status: 'OPEN', colonyId: { in: [...colonyIds] } }, + ], + }, + select: { + id: true, + ruleKey: true, + reasonCode: true, + evidence: true, + intent: true, + mode: true, + status: true, + priority: true, + dueDate: true, + createdAt: true, + colonyId: true, + }, + }), + this.prisma.task.findMany({ + where: { + status: { in: [...OPEN_TASK_STATUSES] }, + relatedType: 'COLONY', + relatedId: { in: [...colonyIds] }, + }, + select: { + id: true, + title: true, + status: true, + priority: true, + dueDate: true, + createdAt: true, + relatedId: true, + sourceAutomationTrigger: true, + sourceSuggestion: { select: { intent: true } }, + }, + }), + ]); + const [lookup, templates] = await Promise.all([ + buildEvidenceLookup( + this.prisma, + suggestions.map((row) => row.evidence as SuggestionEvidence), + ), + this.titleTemplates(suggestions.map((row) => row.ruleKey)), + ]); + const add = (colonyId: string, source: StepSource): void => { + sources.set(colonyId, [...(sources.get(colonyId) ?? []), source]); + }; + // One open thing per intent and colony: an open task of the same intent + // (accepted suggestion or legacy rule task) hides an open suggestion. + const openTaskIntents = new Set( + tasks.flatMap((row) => { + const intent = taskIntentOf(row); + return row.relatedId === null || intent === null + ? [] + : [`${row.relatedId}|${intent}`]; + }), + ); + for (const row of suggestions) { + if ( + row.colonyId === null || + openTaskIntents.has(`${row.colonyId}|${row.intent}`) + ) { + continue; + } + const text = renderSuggestion( + row, + lookup, + today, + templates.get(row.ruleKey), + ); + add(row.colonyId, { + kind: 'SUGGESTION', + id: row.id, + intent: row.intent, + mode: row.mode, + status: row.status, + priority: row.priority, + dueOn: row.dueDate === null ? null : berlinDayOf(row.dueDate), + createdAt: row.createdAt, + title: text.title, + reasons: text.reasons, + colonyId: row.colonyId, + }); + } + for (const row of tasks) { + if (row.relatedId === null) { + continue; + } + add(row.relatedId, { + kind: 'TASK', + id: row.id, + intent: taskIntentOf(row), + mode: null, + status: row.status, + priority: row.priority, + dueOn: row.dueDate === null ? null : berlinDayOf(row.dueDate), + createdAt: row.createdAt, + title: row.title, + reasons: [], + colonyId: row.relatedId, + }); + } + return sources; + } + + /** The `actionTemplate` of each rule replaces the title of its suggestions. */ + private async titleTemplates( + ruleKeys: readonly AutomationTriggerType[], + ): Promise> { + if (ruleKeys.length === 0) { + return new Map(); + } + const rules = await this.prisma.automationRule.findMany({ + where: { triggerType: { in: [...new Set(ruleKeys)] } }, + select: { triggerType: true, actionTemplate: true }, + }); + return new Map( + rules.map((rule) => [rule.triggerType, rule.actionTemplate] as const), + ); + } +} diff --git a/ApiCore-Backend/src/backup/backup.service.ts b/ApiCore-Backend/src/backup/backup.service.ts index 3b2648e..3ff2eb9 100644 --- a/ApiCore-Backend/src/backup/backup.service.ts +++ b/ApiCore-Backend/src/backup/backup.service.ts @@ -1057,7 +1057,7 @@ export class BackupService { } imported.calendarSyncSettings = rowsOf('calendarSyncSettings').length; - // Keyed by sourceType+sourceId, like CalendarSyncService.enqueue - + // Keyed by sourceType+sourceId, like CalendarSyncService.enqueueIn - // the target database may already track the same source under // another id. graphEventId comes along, so a restored entry keeps // updating its existing M365 event instead of creating a duplicate. diff --git a/ApiCore-Backend/src/bottling/bottling.module.ts b/ApiCore-Backend/src/bottling/bottling.module.ts index 83ba5a3..5fa2c1c 100644 --- a/ApiCore-Backend/src/bottling/bottling.module.ts +++ b/ApiCore-Backend/src/bottling/bottling.module.ts @@ -1,11 +1,12 @@ import { Module } from '@nestjs/common'; import { AutomationModule } from '../automation/automation.module'; import { StockModule } from '../stock/stock.module'; +import { WithdrawalModule } from '../withdrawal/withdrawal.module'; import { BottlingController } from './bottling.controller'; import { BottlingService } from './bottling.service'; @Module({ - imports: [AutomationModule, StockModule], + imports: [AutomationModule, StockModule, WithdrawalModule], controllers: [BottlingController], providers: [BottlingService], }) diff --git a/ApiCore-Backend/src/bottling/bottling.service.spec.ts b/ApiCore-Backend/src/bottling/bottling.service.spec.ts index 061eab2..ab8b619 100644 --- a/ApiCore-Backend/src/bottling/bottling.service.spec.ts +++ b/ApiCore-Backend/src/bottling/bottling.service.spec.ts @@ -9,6 +9,8 @@ import { AuditLogService } from '../audit/audit-log.service'; import { DomainEventsService } from '../domain-events/domain-events.service'; import { AutomationService } from '../automation/automation.service'; import { StockService } from '../stock/stock.service'; +import type { PlausibilityWarning } from '../common/warnings/plausibility-warning'; +import { WithdrawalCheckService } from '../withdrawal/withdrawal-check.service'; type AmountChange = number | { increment?: number; decrement?: number }; @@ -63,6 +65,10 @@ describe('BottlingService', () => { }; let auditLog: { record: jest.Mock }; let events: { recordAll: jest.Mock }; + let withdrawalCheck: { + conflictsForHoneyBatch: jest.Mock; + warningsFor: jest.Mock; + }; let tx: { $queryRaw: jest.Mock; product: { findUnique: jest.Mock }; @@ -185,12 +191,17 @@ describe('BottlingService', () => { ), hasMovements: jest.fn().mockResolvedValue(false), }; + withdrawalCheck = { + conflictsForHoneyBatch: jest.fn().mockResolvedValue([]), + warningsFor: jest.fn().mockResolvedValue([]), + }; service = new BottlingService( prisma as unknown as PrismaService, auditLog as unknown as AuditLogService, events as unknown as DomainEventsService, automation as unknown as AutomationService, stock as unknown as StockService, + withdrawalCheck as unknown as WithdrawalCheckService, ); }); @@ -253,7 +264,7 @@ describe('BottlingService', () => { honeyBatchId: 'h1', totalAmount: 25, }); - prisma.bottlingBatch.findUnique.mockResolvedValueOnce({ + tx.bottlingBatch.findUnique.mockResolvedValueOnce({ id: 'b1', totalAmount: 25, }); @@ -270,7 +281,7 @@ describe('BottlingService', () => { 'user-1', ); - expect(result).toEqual({ id: 'b1', totalAmount: 25 }); + expect(result).toEqual({ id: 'b1', totalAmount: 25, warnings: [] }); expect(honeyBatches.get('h1')).toEqual({ amount: 0, status: 'BOTTLED' }); expect(auditLog.record).toHaveBeenCalledWith( expect.objectContaining({ @@ -292,7 +303,7 @@ describe('BottlingService', () => { honeyBatchId: 'h1', totalAmount: 5, }); - prisma.bottlingBatch.findUnique.mockResolvedValueOnce({ id: 'b1' }); + tx.bottlingBatch.findUnique.mockResolvedValueOnce({ id: 'b1' }); await service.create( { @@ -359,7 +370,7 @@ describe('BottlingService', () => { const result = await service.update('b1', { lotNumber: 'L-2' }, 'user-1'); // Without movements the remaining stock equals the jar count. - expect(result).toEqual({ ...updated, remainingCount: 10 }); + expect(result).toEqual({ ...updated, remainingCount: 10, warnings: [] }); expect(auditLog.record).toHaveBeenCalledWith( expect.objectContaining({ entityType: 'BottlingBatch', @@ -675,4 +686,80 @@ describe('BottlingService', () => { NotFoundException, ); }); + + describe('withdrawal warnings', () => { + const conflict = { + harvestId: 'harvest-1', + harvestedOn: '2026-07-01', + colonyId: 'c1', + treatmentId: 't1', + product: 'Oxuvar', + untilDay: '2026-07-20', + }; + const warning: PlausibilityWarning = { + code: 'WITHDRAWAL_ACTIVE', + severity: 'WARNUNG', + message: 'B3: Wartezeit nach Oxuvar endet am 20.07.2026.', + refs: [{ type: 'Colony', id: 'c1' }], + }; + + beforeEach(() => { + withdrawalCheck.conflictsForHoneyBatch.mockResolvedValue([conflict]); + withdrawalCheck.warningsFor.mockResolvedValue([warning]); + }); + + it('create answers with the warning of the harvests behind the honey batch and still bottles', async () => { + prisma.honeyBatch.findUnique.mockResolvedValueOnce({ + id: 'h1', + amount: 25, + }); + honeyBatches.set('h1', { amount: 25 }); + tx.bottlingBatch.create.mockResolvedValueOnce({ + id: 'b1', + honeyBatchId: 'h1', + totalAmount: 5, + }); + tx.bottlingBatch.findUnique.mockResolvedValueOnce({ id: 'b1' }); + + const result = await service.create( + { + honeyBatchId: 'h1', + date: '2026-01-01', + jarSize: 500, + jarCount: 10, + bestBeforeDate: '2027-01-01', + lotNumber: 'L-1', + }, + 'user-1', + ); + + expect(result).toEqual({ id: 'b1', warnings: [warning] }); + expect(withdrawalCheck.conflictsForHoneyBatch).toHaveBeenCalledWith( + tx, + 'h1', + ); + expect(tx.bottlingBatch.create).toHaveBeenCalledTimes(1); + }); + + it('update checks the honey batch the bottling draws on now', async () => { + const before = { + id: 'b1', + lotNumber: 'L-1', + honeyBatchId: 'h1', + honeyBatch: { id: 'h1', amount: 10 }, + jarSize: 500, + jarCount: 10, + totalAmount: 5, + }; + lockedRows(before, { ...before, lotNumber: 'L-2' }); + + const result = await service.update('b1', { lotNumber: 'L-2' }, 'user-1'); + + expect(result.warnings).toEqual([warning]); + expect(withdrawalCheck.conflictsForHoneyBatch).toHaveBeenCalledWith( + tx, + 'h1', + ); + }); + }); }); diff --git a/ApiCore-Backend/src/bottling/bottling.service.ts b/ApiCore-Backend/src/bottling/bottling.service.ts index 5e4dff7..537ef98 100644 --- a/ApiCore-Backend/src/bottling/bottling.service.ts +++ b/ApiCore-Backend/src/bottling/bottling.service.ts @@ -12,7 +12,9 @@ import { DomainEventsService } from '../domain-events/domain-events.service'; import type { AnyNewDomainEvent } from '../domain-events/domain-event-types'; import { AutomationService } from '../automation/automation.service'; import { StockService } from '../stock/stock.service'; -import { BottlingBatch, Prisma } from '../../generated/prisma'; +import { withWarnings } from '../common/warnings/plausibility-warning'; +import { WithdrawalCheckService } from '../withdrawal/withdrawal-check.service'; +import { Prisma } from '../../generated/prisma'; import { CreateBottlingBatchDto } from './dto/create-bottling-batch.dto'; import { UpdateBottlingBatchDto } from './dto/update-bottling-batch.dto'; @@ -116,8 +118,29 @@ export class BottlingService { private readonly events: DomainEventsService, private readonly automation: AutomationService, private readonly stock: StockService, + private readonly withdrawalCheck: WithdrawalCheckService, ) {} + /** + * The bottling as the response body plus the withdrawal warnings of the + * harvests behind its honey batch. Bottling inside a window is written all + * the same (Annahme 10): the warning is information, never a 4xx. + */ + private async withWithdrawalWarnings( + db: DbClient, + honeyBatchId: string, + bottling: T, + ) { + const conflicts = await this.withdrawalCheck.conflictsForHoneyBatch( + db, + honeyBatchId, + ); + return withWarnings( + bottling, + await this.withdrawalCheck.warningsFor(db, conflicts), + ); + } + async findAll(filter: BottlingBatchFilter = {}) { const batches = await this.prisma.bottlingBatch.findMany({ where: { honeyBatchId: filter.honeyBatchId }, @@ -194,48 +217,48 @@ export class BottlingService { ); } - const bottlingBatch: BottlingBatch = await this.prisma.$transaction( - async (tx) => { - // Booked against the stored amount inside the transaction, never - // against the value read above: two bottlings submitted at the same - // time (double tap, two devices) must not both pass the check and - // overwrite each other's deduction. - await takeFromHoneyBatch(tx, dto.honeyBatchId, totalAmount); - const created = await tx.bottlingBatch.create({ - data: { - honeyBatchId: dto.honeyBatchId, - productId: dto.productId, - date: new Date(dto.date), - jarSize: dto.jarSize, - jarCount: dto.jarCount, - totalAmount, - bestBeforeDate: new Date(dto.bestBeforeDate), - lotNumber, - userId: actingUserId, - }, - }); - await this.auditLog.record( - { - entityType: 'BottlingBatch', - entityId: created.id, - action: 'CREATE', - userId: actingUserId, - afterData: created, - }, - tx, - ); - await this.recordEvents(tx, { - type: 'BatchBottled', - aggregateType: 'BottlingBatch', - aggregateId: created.id, - payload: { honeyBatchId: created.honeyBatchId }, - actorUserId: actingUserId, - }); - return created; - }, - ); - - return this.findById(bottlingBatch.id); + return this.prisma.$transaction(async (tx) => { + // Booked against the stored amount inside the transaction, never + // against the value read above: two bottlings submitted at the same + // time (double tap, two devices) must not both pass the check and + // overwrite each other's deduction. + await takeFromHoneyBatch(tx, dto.honeyBatchId, totalAmount); + const created = await tx.bottlingBatch.create({ + data: { + honeyBatchId: dto.honeyBatchId, + productId: dto.productId, + date: new Date(dto.date), + jarSize: dto.jarSize, + jarCount: dto.jarCount, + totalAmount, + bestBeforeDate: new Date(dto.bestBeforeDate), + lotNumber, + userId: actingUserId, + }, + }); + await this.auditLog.record( + { + entityType: 'BottlingBatch', + entityId: created.id, + action: 'CREATE', + userId: actingUserId, + afterData: created, + }, + tx, + ); + await this.recordEvents(tx, { + type: 'BatchBottled', + aggregateType: 'BottlingBatch', + aggregateId: created.id, + payload: { honeyBatchId: created.honeyBatchId }, + actorUserId: actingUserId, + }); + return this.withWithdrawalWarnings( + tx, + created.honeyBatchId, + await this.findById(created.id, tx), + ); + }); } /** @@ -337,7 +360,7 @@ export class BottlingService { payload: { honeyBatchId, removed: false }, actorUserId: actingUserId, }); - return bottlingBatch; + return this.withWithdrawalWarnings(tx, honeyBatchId, bottlingBatch); }); } diff --git a/ApiCore-Backend/src/calendar-sync/calendar-sync.handlers.spec.ts b/ApiCore-Backend/src/calendar-sync/calendar-sync.handlers.spec.ts index 3c59799..74ed177 100644 --- a/ApiCore-Backend/src/calendar-sync/calendar-sync.handlers.spec.ts +++ b/ApiCore-Backend/src/calendar-sync/calendar-sync.handlers.spec.ts @@ -47,6 +47,22 @@ describe('CalendarSyncEventHandlers', () => { }, ); + it.each(['TreatmentRecorded', 'TreatmentCorrected'] as const)( + 'withdrawal handler enqueues the treatment as WITHDRAWAL on %s, so the span follows its days', + async (type) => { + await handlers.withdrawal( + envelope(type, 'treatment-1', { colonyId: 'c1' }), + tx, + ); + + expect(calendarSync.enqueueIn).toHaveBeenCalledWith( + tx, + 'WITHDRAWAL', + 'treatment-1', + ); + }, + ); + it.each(['TaskCreated', 'TaskUpdated', 'TaskCompleted'] as const)( 'task handler enqueues the task on %s', async (type) => { @@ -121,6 +137,12 @@ describe('CalendarSyncEventHandlers', () => { expect(registered('treatment')[0].handlerId).toBe( 'calendar-sync.treatment', ); + expect(registered('withdrawal')).toEqual([ + { + types: ['TreatmentRecorded', 'TreatmentCorrected'], + handlerId: 'calendar-sync.withdrawal', + }, + ]); expect(registered('feeding')[0].handlerId).toBe('calendar-sync.feeding'); expect(registered('harvest')[0].handlerId).toBe('calendar-sync.harvest'); expect(registered('queenAssignment')).toEqual([ diff --git a/ApiCore-Backend/src/calendar-sync/calendar-sync.handlers.ts b/ApiCore-Backend/src/calendar-sync/calendar-sync.handlers.ts index 99a7742..2586821 100644 --- a/ApiCore-Backend/src/calendar-sync/calendar-sync.handlers.ts +++ b/ApiCore-Backend/src/calendar-sync/calendar-sync.handlers.ts @@ -35,6 +35,20 @@ export class CalendarSyncEventHandlers { await this.calendarSync.enqueueIn(tx, 'TREATMENT', event.aggregateId); } + // The same events, a second entry: the withdrawal span of the treatment. + // Enqueued also when the treatment has no withdrawal days (any more): the + // worker then removes an event created earlier. + @OnDomainEvent( + ['TreatmentRecorded', 'TreatmentCorrected'], + 'calendar-sync.withdrawal', + ) + async withdrawal( + event: AnyDomainEventEnvelope<'TreatmentRecorded' | 'TreatmentCorrected'>, + tx: Prisma.TransactionClient, + ): Promise { + await this.calendarSync.enqueueIn(tx, 'WITHDRAWAL', event.aggregateId); + } + @OnDomainEvent( ['FeedingRecorded', 'FeedingCorrected'], 'calendar-sync.feeding', diff --git a/ApiCore-Backend/src/calendar-sync/calendar-sync.module.ts b/ApiCore-Backend/src/calendar-sync/calendar-sync.module.ts index bbc0272..60bfe79 100644 --- a/ApiCore-Backend/src/calendar-sync/calendar-sync.module.ts +++ b/ApiCore-Backend/src/calendar-sync/calendar-sync.module.ts @@ -5,10 +5,10 @@ import { CalendarSyncService } from './calendar-sync.service'; import { CalendarSyncSettingsService } from './calendar-sync-settings.service'; import { GraphClientService } from './graph-client.service'; -// @Global(): CalendarSyncService.enqueue() is called from otherwise-unrelated -// domain modules (Inspections, Treatments, Feedings, Harvests, Queens) until -// they record domain events - matches WebhooksModule's own @Global() -// rationale rather than having each of those modules import this one. +// @Global(): WorkerRuntimeModule (the calendar-outbox loop) injects +// CalendarSyncService without importing this module, in the worker process +// and in role "all". Domain modules no longer call it: they record events and +// CalendarSyncEventHandlers enqueues. @Global() @Module({ controllers: [CalendarSyncController], diff --git a/ApiCore-Backend/src/calendar-sync/calendar-sync.service.spec.ts b/ApiCore-Backend/src/calendar-sync/calendar-sync.service.spec.ts index 521603f..753f1bd 100644 --- a/ApiCore-Backend/src/calendar-sync/calendar-sync.service.spec.ts +++ b/ApiCore-Backend/src/calendar-sync/calendar-sync.service.spec.ts @@ -1,4 +1,3 @@ -import { Logger } from '@nestjs/common'; import { CalendarSyncService } from './calendar-sync.service'; import { PrismaService } from '../prisma/prisma.service'; import { CalendarSyncSettingsService } from './calendar-sync-settings.service'; @@ -111,14 +110,16 @@ describe('CalendarSyncService', () => { ); }); - describe('enqueue', () => { + describe('enqueueIn on the root client', () => { + const root = () => prisma as unknown as PrismaService; + it('does nothing when sync is disabled overall', async () => { settingsService.get.mockResolvedValue({ ...enabledSettings, enabled: false, }); - await service.enqueue('TASK', 'task-1'); + await service.enqueueIn(root(), 'TASK', 'task-1'); expect(prisma.calendarSyncOutboxEntry.upsert).not.toHaveBeenCalled(); }); @@ -126,7 +127,7 @@ describe('CalendarSyncService', () => { it('enqueues a WITHDRAWAL entry while syncWithdrawal is on', async () => { settingsService.get.mockResolvedValue(enabledSettings); - await service.enqueue('WITHDRAWAL', 'treatment-1'); + await service.enqueueIn(root(), 'WITHDRAWAL', 'treatment-1'); expect(prisma.calendarSyncOutboxEntry.upsert).toHaveBeenCalled(); }); @@ -137,7 +138,7 @@ describe('CalendarSyncService', () => { syncWithdrawal: false, }); - await service.enqueue('WITHDRAWAL', 'treatment-1'); + await service.enqueueIn(root(), 'WITHDRAWAL', 'treatment-1'); expect(prisma.calendarSyncOutboxEntry.upsert).not.toHaveBeenCalled(); }); @@ -148,13 +149,13 @@ describe('CalendarSyncService', () => { syncTask: false, }); - await service.enqueue('TASK', 'task-1'); + await service.enqueueIn(root(), 'TASK', 'task-1'); expect(prisma.calendarSyncOutboxEntry.upsert).not.toHaveBeenCalled(); }); it('upserts a PENDING row, resetting attempts and lastError and counting the revision up', async () => { - await service.enqueue('TASK', 'task-1'); + await service.enqueueIn(root(), 'TASK', 'task-1'); expect(prisma.calendarSyncOutboxEntry.upsert).toHaveBeenCalledWith({ where: { @@ -169,34 +170,9 @@ describe('CalendarSyncService', () => { }, }); }); - }); - // enqueue() runs after the source record is committed: a failure must not - // fail that request (the client would retry and store it twice). - describe('post-commit calls', () => { - let errorSpy: jest.SpyInstance; - - beforeEach(() => { - errorSpy = jest - .spyOn(Logger.prototype, 'error') - .mockImplementation(() => undefined); - }); - - afterEach(() => { - errorSpy.mockRestore(); - }); - - it('enqueue never throws and logs the failure with the record', async () => { - settingsService.get.mockRejectedValue(new Error('db down')); - - await expect( - service.enqueue('INSPECTION', 'inspection-1'), - ).resolves.toBeUndefined(); - - expect(errorSpy).toHaveBeenCalledTimes(1); - const [message] = errorSpy.mock.calls[0] as [string]; - expect(message).toContain('INSPECTION inspection-1'); - expect(message).toContain('db down'); + it('no longer has a post-commit enqueue', () => { + expect('enqueue' in service).toBe(false); }); }); @@ -660,14 +636,98 @@ describe('CalendarSyncService', () => { expectSettled({ status: 'FAILED', attempts: 5, lastError: 'db error' }); }); - it('writes no Graph event for a WITHDRAWAL entry before Task 12', async () => { - prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([ - pendingEntry({ sourceType: 'WITHDRAWAL', sourceId: 't1' }), - ]); + describe('WITHDRAWAL entries', () => { + const withdrawalEntry = () => + pendingEntry({ sourceType: 'WITHDRAWAL', sourceId: 't1' }); + const treatment = (overrides: Record = {}) => ({ + id: 't1', + colonyId: 'c1', + product: 'Oxuvar', + date: new Date('2026-07-31T00:00:00.000Z'), + startDate: null, + endDate: null, + withdrawalPeriodDays: 20, + colony: { code: 'B3' }, + ...overrides, + }); - await service.processOutbox(); + it('writes the window as an all-day span from the first covered day to the first harvestable day', async () => { + prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([ + withdrawalEntry(), + ]); + prisma.treatment.findUnique.mockResolvedValue(treatment()); + graphClient.createEvent.mockResolvedValue('g1'); - expect(graphClient.createEvent).not.toHaveBeenCalled(); + await service.processOutbox(); + + expect(graphClient.createEvent).toHaveBeenCalledWith( + 'group-1', + { + title: 'Wartezeit B3 · Oxuvar', + date: new Date('2026-07-31T00:00:00.000Z'), + endDate: new Date('2026-08-20T00:00:00.000Z'), + }, + TRANSACTION_ID, + ); + }); + + it('starts at startDate when it lies before the treatment date', async () => { + prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([ + withdrawalEntry(), + ]); + prisma.treatment.findUnique.mockResolvedValue( + treatment({ + startDate: new Date('2026-07-28T00:00:00.000Z'), + endDate: new Date('2026-08-04T00:00:00.000Z'), + }), + ); + graphClient.createEvent.mockResolvedValue('g1'); + + await service.processOutbox(); + + expect(graphClient.createEvent).toHaveBeenCalledWith( + 'group-1', + expect.objectContaining({ + date: new Date('2026-07-28T00:00:00.000Z'), + endDate: new Date('2026-08-24T00:00:00.000Z'), + }), + TRANSACTION_ID, + ); + }); + + it.each([ + ['has no withdrawal days any more', { withdrawalPeriodDays: null }], + ['has a window of zero days', { withdrawalPeriodDays: 0 }], + ])( + 'removes the event when the treatment %s', + async (_case, overrides) => { + prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([ + { ...withdrawalEntry(), graphEventId: 'graph-event-1' }, + ]); + prisma.treatment.findUnique.mockResolvedValue(treatment(overrides)); + + await service.processOutbox(); + + expect(graphClient.createEvent).not.toHaveBeenCalled(); + expect(graphClient.deleteEvent).toHaveBeenCalledWith( + 'group-1', + 'graph-event-1', + ); + expectSettled({ status: 'SKIPPED', graphEventId: null }); + }, + ); + + it('writes nothing for a treatment that is gone', async () => { + prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([ + withdrawalEntry(), + ]); + prisma.treatment.findUnique.mockResolvedValue(null); + + await service.processOutbox(); + + expect(graphClient.createEvent).not.toHaveBeenCalled(); + expectSettled({ status: 'SKIPPED', graphEventId: null }); + }); }); it('builds titles per source type matching the read-only calendar view', async () => { diff --git a/ApiCore-Backend/src/calendar-sync/calendar-sync.service.ts b/ApiCore-Backend/src/calendar-sync/calendar-sync.service.ts index c4a43b1..abcdd0b 100644 --- a/ApiCore-Backend/src/calendar-sync/calendar-sync.service.ts +++ b/ApiCore-Backend/src/calendar-sync/calendar-sync.service.ts @@ -2,9 +2,10 @@ import { createHash } from 'node:crypto'; import { Injectable, Logger } from '@nestjs/common'; import { DbClient } from '../prisma/db-client'; import { PrismaService } from '../prisma/prisma.service'; -import { runAfterCommit } from '../common/after-commit'; +import { dayToDate } from '../common/dates'; import { loggableError } from '../common/loggable-error'; import { notifyWorker, WAKE_CHANNELS } from '../common/worker-wakeup'; +import { withdrawalSpanOf } from '../withdrawal/withdrawal-span'; import { CalendarSyncSettingsService } from './calendar-sync-settings.service'; import { GraphClientService, @@ -108,21 +109,6 @@ export class CalendarSyncService { return flagBySourceType[sourceType]; } - // enqueue() follows a committed write in the domain services and is - // called without a guard - so it never throws (see runAfterCommit). - private afterCommit( - action: string, - sourceType: CalendarSyncSourceType, - sourceId: string, - work: () => Promise, - ): Promise { - return runAfterCommit( - this.logger, - { action, entityType: sourceType, entityId: sourceId }, - work, - ); - } - // The core of every enqueue: upserts rather than always inserting, so // re-enqueuing an already-synced row (e.g. a Task's due date changed) // resets it to PENDING while keeping its graphEventId - the worker then @@ -155,19 +141,6 @@ export class CalendarSyncService { }); } - // Legacy path for sources that do not record a domain event yet - // (inspections, treatments, feedings and harvests until their producer - // tasks; queen assignments), called right after a committed write: never - // throws. - async enqueue( - sourceType: CalendarSyncSourceType, - sourceId: string, - ): Promise { - await this.afterCommit('Calendar sync enqueue', sourceType, sourceId, () => - this.enqueueIn(this.prisma, sourceType, sourceId), - ); - } - // Best-effort removal from M365: logged, never thrown. private async deleteGraphEvent(graphEventId: string): Promise { const settings = await this.settingsService.get(); @@ -500,9 +473,22 @@ export class CalendarSyncService { date: assignment.startDate, }; } - case 'WITHDRAWAL': - // Switch Task 3, content Task 12: nothing to write before that. - return null; + case 'WITHDRAWAL': { + const treatment = await this.prisma.treatment.findUnique({ + where: { id: sourceId }, + include: { colony: true }, + }); + const span = treatment ? withdrawalSpanOf(treatment) : null; + if (!treatment || !span) { + return null; + } + // Graph's end day is exclusive, like untilDay (first harvestable day). + return { + title: `Wartezeit ${treatment.colony.code} · ${treatment.product}`, + date: dayToDate(span.fromDay), + endDate: dayToDate(span.untilDay), + }; + } } } diff --git a/ApiCore-Backend/src/calendar-sync/graph-client.service.spec.ts b/ApiCore-Backend/src/calendar-sync/graph-client.service.spec.ts index 39db2fc..ce10bdb 100644 --- a/ApiCore-Backend/src/calendar-sync/graph-client.service.spec.ts +++ b/ApiCore-Backend/src/calendar-sync/graph-client.service.spec.ts @@ -93,6 +93,64 @@ describe('GraphClientService', () => { expect(body.end.dateTime).toBe('2026-03-08T00:00:00.0000000'); }); + it.each([ + ['create', 'POST'], + ['update', 'PATCH'], + ] as const)( + 'sends a multi-day all-day event on %s up to the exclusive end day', + async (operation, method) => { + fetchMock + .mockResolvedValueOnce( + jsonResponse(200, { access_token: 'tok-1', expires_in: 3600 }), + ) + .mockResolvedValueOnce(jsonResponse(201, { id: 'graph-event-1' })); + const input = { + title: 'Wartezeit B3 · Oxuvar', + date: new Date(Date.UTC(2026, 6, 31)), + endDate: new Date(Date.UTC(2026, 7, 20)), + }; + + if (operation === 'create') { + await service.createEvent('group-1', input, 'tx-1'); + } else { + await service.updateEvent('group-1', 'event-1', input); + } + + const [, init] = fetchMock.mock.calls[1] as [ + string, + { method: string; body: string }, + ]; + const body = JSON.parse(init.body) as { + isAllDay: boolean; + start: { dateTime: string }; + end: { dateTime: string }; + }; + expect(init.method).toBe(method); + expect(body.isAllDay).toBe(true); + expect(body.start.dateTime).toBe('2026-07-31T00:00:00.0000000'); + expect(body.end.dateTime).toBe('2026-08-20T00:00:00.0000000'); + }, + ); + + it('keeps a one-day event when the end day is not after the start day', async () => { + fetchMock + .mockResolvedValueOnce( + jsonResponse(200, { access_token: 'tok-1', expires_in: 3600 }), + ) + .mockResolvedValueOnce(jsonResponse(201, { id: 'graph-event-1' })); + const day = new Date(Date.UTC(2026, 6, 31)); + + await service.createEvent( + 'group-1', + { title: 'x', date: day, endDate: day }, + 'tx-1', + ); + + const [, init] = fetchMock.mock.calls[1] as [string, { body: string }]; + const body = JSON.parse(init.body) as { end: { dateTime: string } }; + expect(body.end.dateTime).toBe('2026-08-01T00:00:00.0000000'); + }); + it('caches the token and does not fetch it again for a second call', async () => { fetchMock .mockResolvedValueOnce( diff --git a/ApiCore-Backend/src/calendar-sync/graph-client.service.ts b/ApiCore-Backend/src/calendar-sync/graph-client.service.ts index 77676cf..e8040d0 100644 --- a/ApiCore-Backend/src/calendar-sync/graph-client.service.ts +++ b/ApiCore-Backend/src/calendar-sync/graph-client.service.ts @@ -3,6 +3,11 @@ import { Injectable } from '@nestjs/common'; export interface GraphCalendarEventInput { title: string; date: Date; + /** + * All-day events are exclusive at the end, like Graph itself: the day + * after the last covered day. Without it the event covers `date` only. + */ + endDate?: Date; } interface CachedToken { @@ -145,7 +150,13 @@ export class GraphClientService { const day = input.date.toISOString().slice(0, 10); const nextDay = new Date(input.date); nextDay.setUTCDate(nextDay.getUTCDate() + 1); - const nextDayString = nextDay.toISOString().slice(0, 10); + const nextDayString = ( + input.endDate !== undefined && input.endDate > input.date + ? input.endDate + : nextDay + ) + .toISOString() + .slice(0, 10); return { subject: input.title, isAllDay: true, diff --git a/ApiCore-Backend/src/calendar/calendar.controller.ts b/ApiCore-Backend/src/calendar/calendar.controller.ts index 7146e3b..043c72e 100644 --- a/ApiCore-Backend/src/calendar/calendar.controller.ts +++ b/ApiCore-Backend/src/calendar/calendar.controller.ts @@ -1,6 +1,8 @@ import { BadRequestException, Controller, Get, Query } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { RequirePermission } from '../auth/decorators/require-permission.decorator'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import { CalendarService } from './calendar.service'; @ApiTags('calendar') @@ -12,6 +14,7 @@ export class CalendarController { @RequirePermission('calendar:read') @Get() getEvents( + @CurrentUser() actor: AuthenticatedUser, @Query('from') from: string, @Query('to') to: string, @Query('apiaryId') apiaryId?: string, @@ -21,10 +24,13 @@ export class CalendarController { 'Query parameters from and to are required', ); } - return this.calendarService.getEvents({ - from: new Date(from), - to: new Date(to), - apiaryId, - }); + const range = { from: new Date(from), to: new Date(to) }; + if ( + Number.isNaN(range.from.getTime()) || + Number.isNaN(range.to.getTime()) + ) { + throw new BadRequestException('from and to must be valid dates'); + } + return this.calendarService.getEvents({ ...range, apiaryId }, actor.id); } } diff --git a/ApiCore-Backend/src/calendar/calendar.service.spec.ts b/ApiCore-Backend/src/calendar/calendar.service.spec.ts index 8b507bf..84e792b 100644 --- a/ApiCore-Backend/src/calendar/calendar.service.spec.ts +++ b/ApiCore-Backend/src/calendar/calendar.service.spec.ts @@ -1,5 +1,6 @@ import { CalendarService } from './calendar.service'; import { PrismaService } from '../prisma/prisma.service'; +import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; describe('CalendarService', () => { let service: CalendarService; @@ -12,7 +13,10 @@ describe('CalendarService', () => { queenColonyHistory: { findMany: jest.Mock }; }; + let resolver: { getPermissions: jest.Mock }; + beforeEach(() => { + resolver = { getPermissions: jest.fn().mockResolvedValue(new Set()) }; prisma = { task: { findMany: jest.fn().mockResolvedValue([]) }, inspection: { findMany: jest.fn().mockResolvedValue([]) }, @@ -21,7 +25,10 @@ describe('CalendarService', () => { harvest: { findMany: jest.fn().mockResolvedValue([]) }, queenColonyHistory: { findMany: jest.fn().mockResolvedValue([]) }, }; - service = new CalendarService(prisma as unknown as PrismaService); + service = new CalendarService( + prisma as unknown as PrismaService, + resolver as unknown as PermissionResolverService, + ); }); it('merges tasks, inspections, and harvests into one sorted event list', async () => { @@ -40,10 +47,13 @@ describe('CalendarService', () => { { id: 'h1', date: new Date('2026-06-10'), apiaryId: 'a1' }, ]); - const events = await service.getEvents({ - from: new Date('2026-06-01'), - to: new Date('2026-06-30'), - }); + const events = await service.getEvents( + { + from: new Date('2026-06-01'), + to: new Date('2026-06-30'), + }, + 'user-1', + ); expect(events.map((event) => event.type)).toEqual([ 'INSPECTION', @@ -53,11 +63,14 @@ describe('CalendarService', () => { }); it('filters colony-linked events by apiary when apiaryId is given', async () => { - await service.getEvents({ - from: new Date('2026-06-01'), - to: new Date('2026-06-30'), - apiaryId: 'a1', - }); + await service.getEvents( + { + from: new Date('2026-06-01'), + to: new Date('2026-06-30'), + apiaryId: 'a1', + }, + 'user-1', + ); const [[inspectionArg]] = prisma.inspection.findMany.mock.calls as [ [{ where: { colony?: { apiaryId?: string } } }], @@ -75,11 +88,155 @@ describe('CalendarService', () => { { id: 't1', title: 'Ohne Datum', dueDate: null }, ]); - const events = await service.getEvents({ - from: new Date('2026-06-01'), - to: new Date('2026-06-30'), - }); + const events = await service.getEvents( + { + from: new Date('2026-06-01'), + to: new Date('2026-06-30'), + }, + 'user-1', + ); expect(events).toHaveLength(0); }); + + describe('withdrawal spans', () => { + const treatment = (overrides: Record = {}) => ({ + id: 'tr1', + colonyId: 'c1', + product: 'Oxuvar', + date: new Date('2026-06-10T00:00:00.000Z'), + startDate: null, + endDate: null, + withdrawalPeriodDays: 20, + colony: { code: 'V-001' }, + ...overrides, + }); + const query = { + from: new Date('2026-06-01T00:00:00.000Z'), + to: new Date('2026-06-30T00:00:00.000Z'), + }; + + beforeEach(() => { + resolver.getPermissions.mockResolvedValue(new Set(['treatments:read'])); + }); + + it('lists the window as one WITHDRAWAL event with its last covered day as endDate', async () => { + prisma.treatment.findMany.mockResolvedValue([treatment()]); + + const events = await service.getEvents(query, 'user-1'); + + const withdrawal = events.filter((event) => event.type === 'WITHDRAWAL'); + expect(withdrawal).toEqual([ + { + id: 'withdrawal:tr1', + type: 'WITHDRAWAL', + date: new Date('2026-06-10T00:00:00.000Z'), + endDate: new Date('2026-06-29T00:00:00.000Z'), + title: 'Wartezeit V-001 · Oxuvar', + relatedType: 'Colony', + relatedId: 'c1', + }, + ]); + }); + + it('shows a window that began before the range but reaches into it', async () => { + prisma.treatment.findMany.mockResolvedValue([ + treatment({ date: new Date('2026-05-20T00:00:00.000Z') }), + ]); + + const events = await service.getEvents(query, 'user-1'); + + expect(events.filter((e) => e.type === 'WITHDRAWAL')).toHaveLength(1); + }); + + it('leaves out a window that ended before the range (the first harvestable day is not covered)', async () => { + prisma.treatment.findMany.mockResolvedValue([ + treatment({ date: new Date('2026-05-12T00:00:00.000Z') }), + ]); + + const events = await service.getEvents(query, 'user-1'); + + expect(events.filter((e) => e.type === 'WITHDRAWAL')).toHaveLength(0); + }); + + it('leaves out a window that begins after the range', async () => { + prisma.treatment.findMany.mockResolvedValue([ + treatment({ date: new Date('2026-07-01T00:00:00.000Z') }), + ]); + + const events = await service.getEvents(query, 'user-1'); + + expect(events.filter((e) => e.type === 'WITHDRAWAL')).toHaveLength(0); + }); + + it('leaves out treatments without withdrawal days', async () => { + prisma.treatment.findMany.mockResolvedValue([ + treatment({ withdrawalPeriodDays: null }), + treatment({ id: 'tr2', withdrawalPeriodDays: 0 }), + ]); + + const events = await service.getEvents(query, 'user-1'); + + expect(events.filter((e) => e.type === 'WITHDRAWAL')).toHaveLength(0); + }); + + it('does not show them without treatments:read, and does not read them either', async () => { + resolver.getPermissions.mockResolvedValue(new Set(['calendar:read'])); + prisma.treatment.findMany.mockResolvedValue([treatment()]); + + const events = await service.getEvents(query, 'user-1'); + + expect(events.filter((e) => e.type === 'WITHDRAWAL')).toHaveLength(0); + expect(prisma.treatment.findMany).not.toHaveBeenCalled(); + }); + + it('filters by apiary through the colony like the other colony events', async () => { + prisma.treatment.findMany.mockResolvedValue([]); + + await service.getEvents({ ...query, apiaryId: 'a1' }, 'user-1'); + + const withdrawalCall = prisma.treatment.findMany.mock.calls.find( + ([arg]) => + (arg as { where: Record }).where + .withdrawalPeriodDays !== undefined, + ) as [{ where: { colony?: { apiaryId?: string } } }] | undefined; + expect(withdrawalCall?.[0].where.colony).toEqual({ apiaryId: 'a1' }); + }); + }); + + describe('treatment events', () => { + const query = { + from: new Date('2026-06-01T00:00:00.000Z'), + to: new Date('2026-06-30T00:00:00.000Z'), + }; + const row = { + id: 'tr1', + colonyId: 'c1', + product: 'Oxuvar', + date: new Date('2026-06-10T00:00:00.000Z'), + startDate: null, + endDate: null, + withdrawalPeriodDays: null, + colony: { code: 'V-001' }, + }; + + it('lists TREATMENT events with treatments:read', async () => { + resolver.getPermissions.mockResolvedValue(new Set(['treatments:read'])); + prisma.treatment.findMany.mockResolvedValue([row]); + + const events = await service.getEvents(query, 'user-1'); + + expect(events.map((e) => e.type)).toEqual(['TREATMENT']); + }); + + it('leaves them out, and does not read them, without treatments:read', async () => { + resolver.getPermissions.mockResolvedValue(new Set(['calendar:read'])); + prisma.treatment.findMany.mockResolvedValue([row]); + + const events = await service.getEvents(query, 'user-1'); + + expect(events.filter((e) => e.type === 'TREATMENT')).toEqual([]); + expect(prisma.treatment.findMany).not.toHaveBeenCalled(); + }); + }); }); diff --git a/ApiCore-Backend/src/calendar/calendar.service.ts b/ApiCore-Backend/src/calendar/calendar.service.ts index 3aa5ec4..47988ed 100644 --- a/ApiCore-Backend/src/calendar/calendar.service.ts +++ b/ApiCore-Backend/src/calendar/calendar.service.ts @@ -1,5 +1,13 @@ import { Injectable } from '@nestjs/common'; +import type { Prisma } from '../../generated/prisma'; import { PrismaService } from '../prisma/prisma.service'; +import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; +import { addDays, berlinDayOf, dayToDate } from '../common/dates'; +import { withdrawalSpanOf } from '../withdrawal/withdrawal-span'; + +type TreatmentWithColony = Prisma.TreatmentGetPayload<{ + include: { colony: true }; +}>; export type CalendarEventType = | 'TASK' @@ -7,12 +15,15 @@ export type CalendarEventType = | 'TREATMENT' | 'FEEDING' | 'HARVEST' - | 'QUEEN_ASSIGNMENT'; + | 'QUEEN_ASSIGNMENT' + | 'WITHDRAWAL'; export interface CalendarEvent { id: string; type: CalendarEventType; date: Date; + /** WITHDRAWAL only: the last day the span covers (inclusive). */ + endDate?: Date; title: string; relatedType: string; relatedId: string; @@ -26,10 +37,68 @@ export interface CalendarQuery { @Injectable() export class CalendarService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly permissionResolver: PermissionResolverService, + ) {} - async getEvents(query: CalendarQuery): Promise { + /** + * The withdrawal windows (Wartezeit) that touch the range, one span each. + * They come from treatments, so they need the right to read treatments. + */ + private async withdrawalEvents( + query: CalendarQuery, + canReadTreatments: boolean, + ): Promise { + if (!canReadTreatments) { + return []; + } + const fromDay = berlinDayOf(query.from); + const toDay = berlinDayOf(query.to); + // A window never begins after its treatment's dates, so a treatment + // dated after the range cannot reach into it. + const beforeEnd = { lt: dayToDate(addDays(toDay, 1)) }; + const treatments = await this.prisma.treatment.findMany({ + where: { + withdrawalPeriodDays: { not: null }, + OR: [{ date: beforeEnd }, { startDate: beforeEnd }], + ...(query.apiaryId ? { colony: { apiaryId: query.apiaryId } } : {}), + }, + include: { colony: true }, + }); + return treatments.flatMap((treatment) => { + const span = withdrawalSpanOf(treatment); + if (span === null) { + return []; + } + const lastDay = addDays(span.untilDay, -1); + if (span.fromDay > toDay || lastDay < fromDay) { + return []; + } + return [ + { + id: `withdrawal:${treatment.id}`, + type: 'WITHDRAWAL' as const, + date: dayToDate(span.fromDay), + endDate: dayToDate(lastDay), + title: `Wartezeit ${treatment.colony.code} · ${treatment.product}`, + relatedType: 'Colony', + relatedId: treatment.colonyId, + }, + ]; + }); + } + + async getEvents( + query: CalendarQuery, + userId: string, + ): Promise { const { from, to, apiaryId } = query; + // Treatments (and the withdrawal spans derived from them) are shown only + // to users who may read treatments, as on the treatment routes. + const canReadTreatments = ( + await this.permissionResolver.getPermissions(userId) + ).has('treatments:read'); const colonyFilter = apiaryId ? { colony: { apiaryId } } : {}; const [ @@ -39,6 +108,7 @@ export class CalendarService { feedings, harvests, queenAssignments, + withdrawals, ] = await Promise.all([ this.prisma.task.findMany({ where: { dueDate: { gte: from, lte: to } }, @@ -47,10 +117,12 @@ export class CalendarService { where: { date: { gte: from, lte: to }, ...colonyFilter }, include: { colony: true }, }), - this.prisma.treatment.findMany({ - where: { date: { gte: from, lte: to }, ...colonyFilter }, - include: { colony: true }, - }), + canReadTreatments + ? this.prisma.treatment.findMany({ + where: { date: { gte: from, lte: to }, ...colonyFilter }, + include: { colony: true }, + }) + : Promise.resolve([]), this.prisma.feeding.findMany({ where: { date: { gte: from, lte: to }, ...colonyFilter }, include: { colony: true }, @@ -65,6 +137,7 @@ export class CalendarService { where: { startDate: { gte: from, lte: to }, ...colonyFilter }, include: { colony: true, queen: true }, }), + this.withdrawalEvents(query, canReadTreatments), ]); const events: CalendarEvent[] = [ @@ -118,6 +191,7 @@ export class CalendarService { relatedType: 'Queen', relatedId: assignment.queenId, })), + ...withdrawals, ]; return events.sort((a, b) => a.date.getTime() - b.date.getTime()); diff --git a/ApiCore-Backend/src/colonies/colonies.service.spec.ts b/ApiCore-Backend/src/colonies/colonies.service.spec.ts index 99240d0..a284445 100644 --- a/ApiCore-Backend/src/colonies/colonies.service.spec.ts +++ b/ApiCore-Backend/src/colonies/colonies.service.spec.ts @@ -14,6 +14,7 @@ describe('ColoniesService', () => { colonyApiaryHistory: { create: jest.Mock }; colonyLineage: { create: jest.Mock }; queenColonyHistory: { updateMany: jest.Mock }; + suggestion: { updateMany: jest.Mock }; }; let prisma: { colony: { @@ -46,6 +47,7 @@ describe('ColoniesService', () => { colonyApiaryHistory: { create: jest.fn() }, colonyLineage: { create: jest.fn() }, queenColonyHistory: { updateMany: jest.fn() }, + suggestion: { updateMany: jest.fn() }, }; prisma = { colony: { @@ -255,17 +257,15 @@ describe('ColoniesService', () => { 'user-1', ); - const [[updateArg]] = tx.colony.update.mock.calls as [ - [ - { - where: { id: string }; - data: { status: string; currentQueenId: string | null }; - }, - ], - ]; - expect(updateArg.where.id).toBe('colony-3'); - expect(updateArg.data.status).toBe('MERGED'); - expect(updateArg.data.currentQueenId).toBeNull(); + const updates = tx.colony.update.mock.calls as [ + { where: { id: string }; data: Record }, + ][]; + expect(updates[0][0]).toEqual({ + where: { id: 'colony-3' }, + data: { currentQueenId: null }, + }); + expect(updates[1][0].where.id).toBe('colony-3'); + expect(updates[1][0].data.status).toBe('MERGED'); const [[historyCloseArg]] = tx.queenColonyHistory.updateMany.mock.calls as [ [ @@ -368,22 +368,16 @@ describe('ColoniesService', () => { await service.dissolve('colony-1', 'user-1'); - const [[dissolveUpdateArg]] = tx.colony.update.mock.calls as [ - [ - { - where: { id: string }; - data: { - status: string; - deactivatedAt: Date; - currentQueenId: string | null; - }; - }, - ], - ]; - expect(dissolveUpdateArg.where).toEqual({ id: 'colony-1' }); - expect(dissolveUpdateArg.data.status).toBe('DISSOLVED'); - expect(dissolveUpdateArg.data.currentQueenId).toBeNull(); - expect(dissolveUpdateArg.data.deactivatedAt).toBeInstanceOf(Date); + const updates = tx.colony.update.mock.calls as [ + { where: { id: string }; data: Record }, + ][]; + expect(updates[0][0]).toEqual({ + where: { id: 'colony-1' }, + data: { currentQueenId: null }, + }); + expect(updates[1][0].where).toEqual({ id: 'colony-1' }); + expect(updates[1][0].data.status).toBe('DISSOLVED'); + expect(updates[1][0].data.deactivatedAt).toBeInstanceOf(Date); const [[historyCloseArg]] = tx.queenColonyHistory.updateMany.mock.calls as [ [ @@ -460,6 +454,38 @@ describe('ColoniesService', () => { expect(transitionTypes()).toEqual(['ColonyStatusChanged']); }); + it('reactivate turns the open CLEANUP suggestions of the colony OBSOLETE after locking it', async () => { + prisma.colony.findUnique.mockResolvedValue({ + ...parentColony, + status: 'SOLD', + }); + tx.colony.update.mockResolvedValue({ ...parentColony, status: 'ACTIVE' }); + + await service.reactivate('colony-1', 'user-1'); + + expect(tx.suggestion.updateMany).toHaveBeenCalledWith({ + where: { colonyId: 'colony-1', intent: 'CLEANUP', status: 'OPEN' }, + data: { status: 'OBSOLETE', decidedAt: expect.any(Date) as Date }, + }); + expect(tx.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan( + tx.suggestion.updateMany.mock.invocationCallOrder[0], + ); + }); + + it('an edit between two ended statuses releases no queen (nothing living ended)', async () => { + prisma.colony.findUnique.mockResolvedValue({ + ...parentColony, + status: 'DISSOLVED', + healthStatus: null, + currentQueenId: 'q1', + }); + tx.colony.update.mockResolvedValue({ ...parentColony, status: 'SOLD' }); + + await service.update('colony-1', { status: 'SOLD' }, 'user-1'); + + expect(tx.queenColonyHistory.updateMany).not.toHaveBeenCalled(); + }); + it('writes an apiary-history entry when an edit corrects the apiary', async () => { prisma.colony.findUnique.mockResolvedValue(parentColony); tx.colony.update.mockResolvedValue({ @@ -579,7 +605,12 @@ describe('ColoniesService', () => { expect.objectContaining({ type: 'ColonyEnded', aggregateId: 'colony-1', - payload: { status: 'DISSOLVED' }, + payload: { + status: 'DISSOLVED', + endedOn: berlinToday(), + releasedQueenId: null, + via: 'DISSOLVE', + }, }), ]); }); @@ -622,6 +653,10 @@ describe('ColoniesService', () => { 'MERGED', 'MERGED', ]); + expect(ended.map((event) => event.payload)).toEqual([ + expect.objectContaining({ via: 'MERGE', releasedQueenId: null }), + expect.objectContaining({ via: 'MERGE', releasedQueenId: null }), + ]); expect(auditLog.record).toHaveBeenCalledWith( expect.objectContaining({ entityId: 'colony-1', action: 'UPDATE' }), tx, @@ -737,4 +772,56 @@ describe('ColoniesService', () => { const [[query]] = tx.$queryRaw.mock.calls as [[{ sql: string }]]; expect(query.sql).toContain('ANY(?) ORDER BY id FOR UPDATE'); }); + + describe('a colony that ends through an edit', () => { + const living = { + ...parentColony, + healthStatus: null, + currentQueenId: 'q1', + }; + + it.each(['SOLD', 'DECEASED'] as const)( + 'update to %s releases the queen with the day of the change and records ColonyEnded via EDIT', + async (status) => { + prisma.colony.findUnique.mockResolvedValue(living); + tx.colony.update.mockResolvedValue({ + ...living, + status, + currentQueenId: null, + }); + + await service.update('colony-1', { status }, 'user-1'); + + const today = berlinToday(); + expect(tx.queenColonyHistory.updateMany).toHaveBeenCalledWith({ + where: { queenId: 'q1', colonyId: 'colony-1', endDate: null }, + data: { endDate: new Date(`${today}T00:00:00.000Z`) }, + }); + expect(tx.colony.update.mock.calls[0]).toEqual([ + { where: { id: 'colony-1' }, data: { currentQueenId: null } }, + ]); + expect(events.recordAll).toHaveBeenCalledWith(tx, [ + expect.objectContaining({ type: 'ColonyStatusChanged' }), + expect.objectContaining({ + type: 'ColonyEnded', + payload: { + status, + endedOn: today, + releasedQueenId: 'q1', + via: 'EDIT', + }, + }), + ]); + }, + ); + + it('releases no queen for an edit that keeps the colony alive', async () => { + prisma.colony.findUnique.mockResolvedValue(living); + tx.colony.update.mockResolvedValue({ ...living, status: 'WEAK' }); + + await service.update('colony-1', { status: 'WEAK' }, 'user-1'); + + expect(tx.queenColonyHistory.updateMany).not.toHaveBeenCalled(); + }); + }); }); diff --git a/ApiCore-Backend/src/colonies/colonies.service.ts b/ApiCore-Backend/src/colonies/colonies.service.ts index dd6c435..4b53b3f 100644 --- a/ApiCore-Backend/src/colonies/colonies.service.ts +++ b/ApiCore-Backend/src/colonies/colonies.service.ts @@ -14,7 +14,11 @@ import { UpdateColonyDto } from './dto/update-colony.dto'; import { MoveColonyDto } from './dto/move-colony.dto'; import { SplitColonyDto } from './dto/split-colony.dto'; import { MergeColonyDto } from './dto/merge-colony.dto'; -import { isLivingColonyStatus } from './colony-status'; +import { releaseQueenIn } from './colony-lifecycle'; +import { + isLivingColonyStatus, + TERMINAL_COLONY_STATUSES, +} from './colony-status'; import { lockRowsForUpdate } from '../prisma/row-lock'; import { colonyTransitionEvents, lockColony } from './colony-events'; import { berlinToday, dayToDate } from '../common/dates'; @@ -129,6 +133,16 @@ export class ColoniesService { if (movesApiary) { await this.assertApiaryActive(dto.apiaryId!); } + // An edit into an ended status ends the queen assignment like dissolve + // does, before the status is written (the pointer is read under the lock). + const today = berlinToday(); + const endsColony = + dto.status !== undefined && + TERMINAL_COLONY_STATUSES.includes(dto.status) && + isLivingColonyStatus(before.status); + const releasedQueenId = endsColony + ? await releaseQueenIn(tx, id, dayToDate(today)) + : null; const updated = await tx.colony.update({ where: { id }, data: { ...dto }, @@ -148,7 +162,11 @@ export class ColoniesService { } await this.events.recordAll( tx, - colonyTransitionEvents(before, updated, actingUserId), + colonyTransitionEvents(before, updated, actingUserId, { + endedOn: today, + releasedQueenId, + via: 'EDIT', + }), ); await this.auditLog.record( { @@ -279,6 +297,7 @@ export class ColoniesService { 'Jedes Volk darf nur einmal zur Vereinigung ausgewählt werden', ); } + const today = berlinToday(); await this.prisma.$transaction(async (tx) => { // One statement in id order, the same order bulk lockRowsForShare // uses: no deadlock with a concurrent bulk write. @@ -299,28 +318,23 @@ export class ColoniesService { // The survivor keeps its own queen; merged-away queens are simply // released, not auto-transferred (which colony's queen "wins" a // merge is a beekeeper decision, not one to guess here). + const releasedQueenId = await releaseQueenIn( + tx, + mergedColonyId, + dayToDate(today), + ); const mergedAway = await tx.colony.update({ where: { id: mergedColonyId }, - data: { - status: 'MERGED', - deactivatedAt: new Date(), - currentQueenId: null, - }, + data: { status: 'MERGED', deactivatedAt: new Date() }, }); await this.events.recordAll( tx, - colonyTransitionEvents(merged, mergedAway, actingUserId), + colonyTransitionEvents(merged, mergedAway, actingUserId, { + endedOn: today, + releasedQueenId, + via: 'MERGE', + }), ); - if (merged.currentQueenId) { - await tx.queenColonyHistory.updateMany({ - where: { - queenId: merged.currentQueenId, - colonyId: mergedColonyId, - endDate: null, - }, - data: { endDate: new Date() }, - }); - } await tx.colonyLineage.create({ data: { type: 'MERGE', @@ -353,27 +367,19 @@ export class ColoniesService { // Re-dissolving would overwrite deactivatedAt and the terminal status // (e.g. MERGED -> DISSOLVED), losing when and how the colony really ended. this.assertActive(before); + const today = berlinToday(); + const releasedQueenId = await releaseQueenIn(tx, id, dayToDate(today)); const updated = await tx.colony.update({ where: { id }, - data: { - status: 'DISSOLVED', - deactivatedAt: new Date(), - currentQueenId: null, - }, + data: { status: 'DISSOLVED', deactivatedAt: new Date() }, }); - if (before.currentQueenId) { - await tx.queenColonyHistory.updateMany({ - where: { - queenId: before.currentQueenId, - colonyId: id, - endDate: null, - }, - data: { endDate: new Date() }, - }); - } await this.events.recordAll( tx, - colonyTransitionEvents(before, updated, actingUserId), + colonyTransitionEvents(before, updated, actingUserId, { + endedOn: today, + releasedQueenId, + via: 'DISSOLVE', + }), ); await this.auditLog.record( { @@ -404,9 +410,15 @@ export class ColoniesService { where: { id }, data: { status: 'ACTIVE', deactivatedAt: null }, }); + // The clean-up of the end is moot now (colony row locked first, then + // the suggestions: the order every writer uses). + await tx.suggestion.updateMany({ + where: { colonyId: id, intent: 'CLEANUP', status: 'OPEN' }, + data: { status: 'OBSOLETE', decidedAt: new Date() }, + }); await this.events.recordAll( tx, - colonyTransitionEvents(before, updated, actingUserId), + colonyTransitionEvents(before, updated, actingUserId, null), ); await this.auditLog.record( { diff --git a/ApiCore-Backend/src/colonies/colony-end-releases-queen.spec.ts b/ApiCore-Backend/src/colonies/colony-end-releases-queen.spec.ts new file mode 100644 index 0000000..7baf64e --- /dev/null +++ b/ApiCore-Backend/src/colonies/colony-end-releases-queen.spec.ts @@ -0,0 +1,108 @@ +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import ts from 'typescript'; + +const SRC = join(__dirname, '..'); +const TERMINAL = new Set(['DISSOLVED', 'SOLD', 'DECEASED', 'MERGED']); + +function sourceFiles(dir: string): string[] { + return readdirSync(dir).flatMap((name) => { + const path = join(dir, name); + if (statSync(path).isDirectory()) { + return sourceFiles(path); + } + return path.endsWith('.ts') && !path.endsWith('.spec.ts') ? [path] : []; + }); +} + +interface Writer { + where: string; + releaseBefore: boolean; +} + +/** + * Every function-like node that writes an ended colony status into a `data` + * object (`data: { status: 'SOLD' }`) or passes the DTO whole (`data: { + * ...dto }` in the colonies service, whose status may be ended), with + * whether a `releaseQueenIn(` call stands earlier in the same method. + */ +function writersIn(file: string): Writer[] { + const text = readFileSync(file, 'utf8'); + const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true); + const isColoniesService = file.endsWith('colonies.service.ts'); + const writers: Writer[] = []; + + const writesEndedStatus = ( + data: ts.ObjectLiteralExpression, + method: ts.Node | undefined, + ): boolean => + data.properties.some( + (property) => + (ts.isPropertyAssignment(property) && + property.name.getText() === 'status' && + ts.isStringLiteral(property.initializer) && + TERMINAL.has(property.initializer.text)) || + (isColoniesService && + method !== undefined && + ts.isMethodDeclaration(method) && + method.name.getText() === 'updateIn' && + ts.isSpreadAssignment(property) && + property.expression.getText() === 'dto'), + ); + + const enclosingMethod = (node: ts.Node): ts.Node | undefined => { + for (let up = node.parent; up; up = up.parent) { + if (ts.isMethodDeclaration(up) || ts.isFunctionDeclaration(up)) { + return up; + } + } + return undefined; + }; + + const visit = (node: ts.Node): void => { + if ( + ts.isPropertyAssignment(node) && + node.name.getText() === 'data' && + ts.isObjectLiteralExpression(node.initializer) && + writesEndedStatus(node.initializer, enclosingMethod(node)) + ) { + const method = enclosingMethod(node); + const body = method?.getText() ?? ''; + const release = body.indexOf('releaseQueenIn('); + writers.push({ + where: `${file}:${source.getLineAndCharacterOfPosition(node.getStart()).line + 1}`, + releaseBefore: + method !== undefined && + release !== -1 && + method.getStart() + release < node.getStart(), + }); + } + ts.forEachChild(node, visit); + }; + visit(source); + return writers; +} + +/** + * Guard: whoever lets a colony end must end the queen assignment in the + * same transaction. A new way to set DISSOLVED/SOLD/DECEASED/MERGED without + * releaseQueenIn leaves a dead colony holding the queen's unique pointer. + * Checked per method: the release comes before the write in the same method. + */ +describe('a colony that ends releases its queen', () => { + const writers = sourceFiles(SRC).flatMap(writersIn); + + it('every write of an ended colony status is preceded by releaseQueenIn in the same method', () => { + expect(writers.filter((writer) => !writer.releaseBefore)).toEqual([]); + }); + + it('finds the four known ways (dissolve, merge, edit, winter check)', () => { + const places = writers.map((writer) => writer.where); + expect( + places.filter((p) => p.includes('colonies.service.ts')), + ).toHaveLength(3); + expect( + places.filter((p) => p.includes('winter-checks.service.ts')), + ).toHaveLength(1); + }); +}); diff --git a/ApiCore-Backend/src/colonies/colony-events.spec.ts b/ApiCore-Backend/src/colonies/colony-events.spec.ts index 940be46..3cac80d 100644 --- a/ApiCore-Backend/src/colonies/colony-events.spec.ts +++ b/ApiCore-Backend/src/colonies/colony-events.spec.ts @@ -13,7 +13,7 @@ function colony(overrides: Partial = {}): Colony { describe('colonyTransitionEvents', () => { it('records nothing when neither status nor healthStatus changed', () => { expect( - colonyTransitionEvents(colony(), colony({ notes: 'neu' }), 'u1'), + colonyTransitionEvents(colony(), colony({ notes: 'neu' }), 'u1', null), ).toEqual([]); }); @@ -22,6 +22,7 @@ describe('colonyTransitionEvents', () => { colony(), colony({ healthStatus: 'CRITICAL' }), 'u1', + null, ); expect(events).toEqual([ { @@ -38,18 +39,24 @@ describe('colonyTransitionEvents', () => { }); it.each(['DISSOLVED', 'SOLD', 'DECEASED', 'MERGED'] as const)( - 'adds ColonyEnded { status: %s } on the transition from living to ended', + 'ColonyEnded carries status %s, endedOn, releasedQueenId and via', (status) => { const events = colonyTransitionEvents( colony({ status: 'WEAK' }), colony({ status }), null, + { endedOn: '2027-03-12', releasedQueenId: 'q1', via: 'WINTER_CHECK' }, ); expect(events.map((event) => event.type)).toEqual([ 'ColonyStatusChanged', 'ColonyEnded', ]); - expect(events[1].payload).toEqual({ status }); + expect(events[1].payload).toEqual({ + status, + endedOn: '2027-03-12', + releasedQueenId: 'q1', + via: 'WINTER_CHECK', + }); }, ); @@ -59,6 +66,7 @@ describe('colonyTransitionEvents', () => { colony({ status: 'DISSOLVED' }), colony({ status: 'DECEASED' }), 'u1', + null, ).map((event) => event.type), ).toEqual(['ColonyStatusChanged']); expect( @@ -66,7 +74,19 @@ describe('colonyTransitionEvents', () => { colony({ status: 'DISSOLVED' }), colony({ status: 'ACTIVE' }), 'u1', + null, ).map((event) => event.type), ).toEqual(['ColonyStatusChanged']); }); + + it('refuses to build ColonyEnded without the context of the end', () => { + expect(() => + colonyTransitionEvents( + colony({ status: 'ACTIVE' }), + colony({ status: 'SOLD' }), + 'u1', + null, + ), + ).toThrow('ColonyEnded needs'); + }); }); diff --git a/ApiCore-Backend/src/colonies/colony-events.ts b/ApiCore-Backend/src/colonies/colony-events.ts index 6ff277e..1d2628d 100644 --- a/ApiCore-Backend/src/colonies/colony-events.ts +++ b/ApiCore-Backend/src/colonies/colony-events.ts @@ -5,6 +5,7 @@ import { AnyNewDomainEvent, ColonyStateSnapshot, } from '../domain-events/domain-event-types'; +import type { ColonyEnd } from './colony-lifecycle'; import { isLivingColonyStatus } from './colony-status'; type ColonyState = Pick; @@ -18,12 +19,15 @@ function snapshotOf(colony: ColonyState): ColonyStateSnapshot { * ColonyStatusChanged as soon as status or healthStatus differs, plus * ColonyEnded when a living colony became an ended one. Empty when nothing * the events care about changed. The caller records them in the same - * transaction as the colony update. + * transaction as the colony update. `ended` is the context of the end + * (day, released queen, way) and is required exactly when a living colony + * became an ended one - the caller calls releaseQueenIn first. */ export function colonyTransitionEvents( before: Pick, after: Pick, actorUserId: string | null, + ended: ColonyEnd | null, ): AnyNewDomainEvent<'ColonyStatusChanged' | 'ColonyEnded'>[] { if ( before.status === after.status && @@ -44,12 +48,20 @@ export function colonyTransitionEvents( isLivingColonyStatus(before.status) && !isLivingColonyStatus(after.status) ) { + if (ended === null) { + throw new Error('ColonyEnded needs the context of the end'); + } events.push({ type: 'ColonyEnded', aggregateType: 'Colony', aggregateId: after.id, actorUserId, - payload: { status: after.status }, + payload: { + status: after.status, + endedOn: ended.endedOn, + releasedQueenId: ended.releasedQueenId, + via: ended.via, + }, }); } return events; diff --git a/ApiCore-Backend/src/colonies/colony-lifecycle.spec.ts b/ApiCore-Backend/src/colonies/colony-lifecycle.spec.ts new file mode 100644 index 0000000..504fa64 --- /dev/null +++ b/ApiCore-Backend/src/colonies/colony-lifecycle.spec.ts @@ -0,0 +1,49 @@ +import type { Prisma } from '../../generated/prisma'; +import { releaseQueenIn } from './colony-lifecycle'; + +describe('releaseQueenIn', () => { + let tx: { + colony: { findUnique: jest.Mock; update: jest.Mock }; + queenColonyHistory: { updateMany: jest.Mock }; + }; + const asTx = () => tx as unknown as Prisma.TransactionClient; + const endDate = new Date('2027-03-12T00:00:00.000Z'); + + beforeEach(() => { + tx = { + colony: { findUnique: jest.fn(), update: jest.fn() }, + queenColonyHistory: { updateMany: jest.fn() }, + }; + }); + + it('ends the open history with the end date and clears currentQueenId', async () => { + tx.colony.findUnique.mockResolvedValue({ currentQueenId: 'queen-1' }); + + const released = await releaseQueenIn(asTx(), 'col-1', endDate); + + expect(released).toBe('queen-1'); + expect(tx.queenColonyHistory.updateMany).toHaveBeenCalledWith({ + where: { queenId: 'queen-1', colonyId: 'col-1', endDate: null }, + data: { endDate }, + }); + expect(tx.colony.update).toHaveBeenCalledWith({ + where: { id: 'col-1' }, + data: { currentQueenId: null }, + }); + }); + + it('returns null for a colony without a queen and writes nothing', async () => { + tx.colony.findUnique.mockResolvedValue({ currentQueenId: null }); + + await expect(releaseQueenIn(asTx(), 'col-1', endDate)).resolves.toBeNull(); + + expect(tx.queenColonyHistory.updateMany).not.toHaveBeenCalled(); + expect(tx.colony.update).not.toHaveBeenCalled(); + }); + + it('returns null for an unknown colony', async () => { + tx.colony.findUnique.mockResolvedValue(null); + + await expect(releaseQueenIn(asTx(), 'gone', endDate)).resolves.toBeNull(); + }); +}); diff --git a/ApiCore-Backend/src/colonies/colony-lifecycle.ts b/ApiCore-Backend/src/colonies/colony-lifecycle.ts new file mode 100644 index 0000000..e4f2355 --- /dev/null +++ b/ApiCore-Backend/src/colonies/colony-lifecycle.ts @@ -0,0 +1,48 @@ +import type { Prisma } from '../../generated/prisma'; +import { assertTransactionClient } from '../prisma/db-client'; + +/** The ways a colony can end; carried by ColonyEnded. */ +export type ColonyEndVia = 'DISSOLVE' | 'MERGE' | 'EDIT' | 'WINTER_CHECK'; + +/** What ColonyEnded and its handlers need to know about the end. */ +export interface ColonyEnd { + /** YYYY-MM-DD, the business day of the end - never read from the clock later. */ + endedOn: string; + releasedQueenId: string | null; + via: ColonyEndVia; +} + +/** + * Ends the queen assignment of a colony that ends: closes the open + * QueenColonyHistory with `endDate` and clears currentQueenId (a unique + * pointer, so a dead colony must not keep it). The queen's own status does + * not change. Resolves to the queen's id, or null when the colony had none. + * + * Every way a colony ends calls this in the transaction of the end, with + * the colony row locked: currentQueenId is read here, under that lock, and + * the callers build ColonyEnded from the result. + */ +export async function releaseQueenIn( + tx: Prisma.TransactionClient, + colonyId: string, + endDate: Date, +): Promise { + assertTransactionClient(tx, 'releaseQueenIn'); + const colony = await tx.colony.findUnique({ + where: { id: colonyId }, + select: { currentQueenId: true }, + }); + const queenId = colony?.currentQueenId ?? null; + if (queenId === null) { + return null; + } + await tx.queenColonyHistory.updateMany({ + where: { queenId, colonyId, endDate: null }, + data: { endDate }, + }); + await tx.colony.update({ + where: { id: colonyId }, + data: { currentQueenId: null }, + }); + return queenId; +} diff --git a/ApiCore-Backend/src/common/after-commit-usage.spec.ts b/ApiCore-Backend/src/common/after-commit-usage.spec.ts index 57e6e3c..88c8b2d 100644 --- a/ApiCore-Backend/src/common/after-commit-usage.spec.ts +++ b/ApiCore-Backend/src/common/after-commit-usage.spec.ts @@ -38,7 +38,6 @@ describe('legacy after-commit paths', () => { it('runAfterCommit is only used by the remaining legacy paths', () => { expect(filesCalling('runAfterCommit(')).toEqual([ 'src/automation/automation.service.ts', - 'src/calendar-sync/calendar-sync.service.ts', 'src/webhooks/webhooks.service.ts', ]); }); diff --git a/ApiCore-Backend/src/common/warnings/plausibility-warning.spec.ts b/ApiCore-Backend/src/common/warnings/plausibility-warning.spec.ts new file mode 100644 index 0000000..8a0b966 --- /dev/null +++ b/ApiCore-Backend/src/common/warnings/plausibility-warning.spec.ts @@ -0,0 +1,51 @@ +import { + WARNING_SEVERITY, + withWarnings, + type PlausibilityWarning, +} from './plausibility-warning'; + +describe('plausibility warnings', () => { + it('gives each code the severity of the honey rules', () => { + expect(WARNING_SEVERITY).toEqual({ + WITHDRAWAL_ACTIVE: 'WARNUNG', + EXTRACTION_EXCEEDS_HARVEST: 'WARNUNG', + HARVEST_NET_MISSING: 'HINWEIS', + BATCHES_EXCEED_EXTRACTION: 'WARNUNG', + WATER_CONTENT_HIGH: 'WARNUNG', + WATER_CONTENT_ABOVE_LEGAL: 'BESTAETIGEN', + WATER_CONTENT_MISSING: 'HINWEIS', + JAR_SIZE_DIFFERS: 'HINWEIS', + }); + }); + + it('adds the warnings next to the record without changing the record', () => { + const record = { id: 'h1', date: '2026-08-01' }; + const warning: PlausibilityWarning = { + code: 'WITHDRAWAL_ACTIVE', + severity: 'WARNUNG', + message: 'B3: Wartezeit nach Oxuvar endet am 20.08.2026.', + refs: [{ type: 'Colony', id: 'c1' }], + }; + + const result = withWarnings(record, [warning]); + + expect(result).toEqual({ ...record, warnings: [warning] }); + expect(record).toEqual({ id: 'h1', date: '2026-08-01' }); + }); + + it('answers an empty list when there is nothing to warn about', () => { + expect(withWarnings({ id: 'h1' }, [])).toEqual({ id: 'h1', warnings: [] }); + }); + + it('does not share the warnings array with the caller', () => { + const list: PlausibilityWarning[] = []; + const result = withWarnings({ id: 'h1' }, list); + list.push({ + code: 'WITHDRAWAL_ACTIVE', + severity: 'WARNUNG', + message: 'x', + refs: [], + }); + expect(result.warnings).toEqual([]); + }); +}); diff --git a/ApiCore-Backend/src/common/warnings/plausibility-warning.ts b/ApiCore-Backend/src/common/warnings/plausibility-warning.ts new file mode 100644 index 0000000..7a9f132 --- /dev/null +++ b/ApiCore-Backend/src/common/warnings/plausibility-warning.ts @@ -0,0 +1,46 @@ +export type PlausibilityWarningCode = + | 'WITHDRAWAL_ACTIVE' + | 'EXTRACTION_EXCEEDS_HARVEST' + | 'HARVEST_NET_MISSING' + | 'BATCHES_EXCEED_EXTRACTION' + | 'WATER_CONTENT_HIGH' + | 'WATER_CONTENT_ABOVE_LEGAL' + | 'WATER_CONTENT_MISSING' + | 'JAR_SIZE_DIFFERS'; + +export type WarningSeverity = 'HINWEIS' | 'WARNUNG' | 'BESTAETIGEN'; + +/** + * One format for every plausibility finding on honey records (spec 10.3). + * A warning never blocks a write; only BESTAETIGEN asks for a confirmation, + * and only where the endpoint says so. + */ +export interface PlausibilityWarning { + code: PlausibilityWarningCode; + severity: WarningSeverity; + message: string; + refs: { type: string; id: string }[]; +} + +export const WARNING_SEVERITY: Readonly< + Record +> = Object.freeze({ + WITHDRAWAL_ACTIVE: 'WARNUNG', + EXTRACTION_EXCEEDS_HARVEST: 'WARNUNG', + HARVEST_NET_MISSING: 'HINWEIS', + BATCHES_EXCEED_EXTRACTION: 'WARNUNG', + WATER_CONTENT_HIGH: 'WARNUNG', + WATER_CONTENT_ABOVE_LEGAL: 'BESTAETIGEN', + WATER_CONTENT_MISSING: 'HINWEIS', + JAR_SIZE_DIFFERS: 'HINWEIS', +}); + +export type WithWarnings = T & { warnings: PlausibilityWarning[] }; + +/** The record as the response body, plus the warnings found for it. */ +export function withWarnings( + record: T, + warnings: readonly PlausibilityWarning[], +): WithWarnings { + return { ...record, warnings: [...warnings] }; +} diff --git a/ApiCore-Backend/src/domain-events/domain-event-types.ts b/ApiCore-Backend/src/domain-events/domain-event-types.ts index f400640..2c32d99 100644 --- a/ApiCore-Backend/src/domain-events/domain-event-types.ts +++ b/ApiCore-Backend/src/domain-events/domain-event-types.ts @@ -39,7 +39,13 @@ export interface DomainEventPayloads { HarvestRecorded: { apiaryId: string; colonyIds: string[] }; HarvestCorrected: { apiaryId: string; colonyIds: string[] }; ColonyStatusChanged: { from: ColonyStateSnapshot; to: ColonyStateSnapshot }; - ColonyEnded: { status: ColonyStatus }; + ColonyEnded: { + status: ColonyStatus; + // endedOn/releasedQueenId/via came with T4a: absent on older events. + endedOn?: string; + releasedQueenId?: string | null; + via?: 'DISSOLVE' | 'MERGE' | 'EDIT' | 'WINTER_CHECK'; + }; BatchBottled: { honeyBatchId: string }; BottlingCorrected: { honeyBatchId: string; removed: boolean }; StockChanged: { diff --git a/ApiCore-Backend/src/harvests/harvests.module.ts b/ApiCore-Backend/src/harvests/harvests.module.ts index f4e9e5d..2ab3495 100644 --- a/ApiCore-Backend/src/harvests/harvests.module.ts +++ b/ApiCore-Backend/src/harvests/harvests.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; +import { WithdrawalModule } from '../withdrawal/withdrawal.module'; import { HarvestsController } from './harvests.controller'; import { HarvestsService } from './harvests.service'; @Module({ + imports: [WithdrawalModule], controllers: [HarvestsController], providers: [HarvestsService], exports: [HarvestsService], diff --git a/ApiCore-Backend/src/harvests/harvests.service.spec.ts b/ApiCore-Backend/src/harvests/harvests.service.spec.ts index d2fa9ff..12f908d 100644 --- a/ApiCore-Backend/src/harvests/harvests.service.spec.ts +++ b/ApiCore-Backend/src/harvests/harvests.service.spec.ts @@ -3,6 +3,8 @@ import { HarvestsService } from './harvests.service'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; import { DomainEventsService } from '../domain-events/domain-events.service'; +import type { PlausibilityWarning } from '../common/warnings/plausibility-warning'; +import { WithdrawalCheckService } from '../withdrawal/withdrawal-check.service'; describe('HarvestsService', () => { let service: HarvestsService; @@ -13,6 +15,10 @@ describe('HarvestsService', () => { }; let auditLog: { record: jest.Mock }; let events: { record: jest.Mock }; + let withdrawalCheck: { + conflictsForHarvests: jest.Mock; + warningsFor: jest.Mock; + }; let tx: { harvest: { create: jest.Mock; @@ -55,10 +61,15 @@ describe('HarvestsService', () => { }; auditLog = { record: jest.fn() }; events = { record: jest.fn() }; + withdrawalCheck = { + conflictsForHarvests: jest.fn().mockResolvedValue([]), + warningsFor: jest.fn().mockResolvedValue([]), + }; service = new HarvestsService( prisma as unknown as PrismaService, auditLog as unknown as AuditLogService, events as unknown as DomainEventsService, + withdrawalCheck as unknown as WithdrawalCheckService, ); }); @@ -117,7 +128,7 @@ describe('HarvestsService', () => { 'user-1', ); - expect(result).toEqual(updated); + expect(result).toEqual({ ...updated, warnings: [] }); expect(tx.harvestColony.deleteMany).not.toHaveBeenCalled(); expect(auditLog.record).toHaveBeenCalledWith( expect.objectContaining({ @@ -236,4 +247,72 @@ describe('HarvestsService', () => { ).rejects.toBeInstanceOf(BadRequestException); expect(prisma.$transaction).not.toHaveBeenCalled(); }); + + describe('withdrawal warnings', () => { + const conflict = { + harvestId: 'harvest-1', + harvestedOn: '2026-07-01', + colonyId: 'colony-1', + treatmentId: 't1', + product: 'Oxuvar', + untilDay: '2026-07-20', + }; + const warning: PlausibilityWarning = { + code: 'WITHDRAWAL_ACTIVE', + severity: 'WARNUNG', + message: 'V-1: Wartezeit nach Oxuvar endet am 20.07.2026.', + refs: [{ type: 'Colony', id: 'colony-1' }], + }; + + beforeEach(() => { + withdrawalCheck.conflictsForHarvests.mockResolvedValue([conflict]); + withdrawalCheck.warningsFor.mockResolvedValue([warning]); + }); + + it('create answers the harvest with the warning and still writes it', async () => { + const result = await service.create( + { + apiaryId: 'apiary-1', + date: '2026-07-01', + colonyIds: ['colony-1', 'colony-2'], + }, + 'user-1', + ); + + expect(result).toEqual({ ...harvest, warnings: [warning] }); + expect(tx.harvest.create).toHaveBeenCalledTimes(1); + expect(events.record).toHaveBeenCalledTimes(1); + }); + + it('create checks inside the transaction, after the colonies were linked', async () => { + await service.create( + { + apiaryId: 'apiary-1', + date: '2026-07-01', + colonyIds: ['colony-1', 'colony-2'], + }, + 'user-1', + ); + + expect(withdrawalCheck.conflictsForHarvests).toHaveBeenCalledWith(tx, [ + 'harvest-1', + ]); + expect(withdrawalCheck.warningsFor).toHaveBeenCalledWith(tx, [conflict]); + }); + + it('update answers the corrected harvest with the warning', async () => { + tx.harvest.findUnique.mockResolvedValue(harvest); + + const result = await service.update( + 'harvest-1', + { date: '2026-07-02' }, + 'user-1', + ); + + expect(result).toEqual({ ...harvest, warnings: [warning] }); + expect(withdrawalCheck.conflictsForHarvests).toHaveBeenCalledWith(tx, [ + 'harvest-1', + ]); + }); + }); }); diff --git a/ApiCore-Backend/src/harvests/harvests.service.ts b/ApiCore-Backend/src/harvests/harvests.service.ts index 32b18b1..a61f786 100644 --- a/ApiCore-Backend/src/harvests/harvests.service.ts +++ b/ApiCore-Backend/src/harvests/harvests.service.ts @@ -6,7 +6,10 @@ import { import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; import { DomainEventsService } from '../domain-events/domain-events.service'; +import { DbClient } from '../prisma/db-client'; import { lockRowForUpdate } from '../prisma/row-lock'; +import { withWarnings } from '../common/warnings/plausibility-warning'; +import { WithdrawalCheckService } from '../withdrawal/withdrawal-check.service'; import { CreateHarvestDto } from './dto/create-harvest.dto'; import { UpdateHarvestDto } from './dto/update-harvest.dto'; @@ -20,8 +23,27 @@ export class HarvestsService { private readonly prisma: PrismaService, private readonly auditLog: AuditLogService, private readonly events: DomainEventsService, + private readonly withdrawalCheck: WithdrawalCheckService, ) {} + /** + * The harvest as the response body plus its withdrawal warnings. A harvest + * inside a withdrawal window is written all the same (Annahme 10): the + * warning is information, never a 4xx. + */ + private async withWithdrawalWarnings( + db: DbClient, + harvest: T, + ) { + const conflicts = await this.withdrawalCheck.conflictsForHarvests(db, [ + harvest.id, + ]); + return withWarnings( + harvest, + await this.withdrawalCheck.warningsFor(db, conflicts), + ); + } + async findAll() { return this.prisma.harvest.findMany({ include: harvestInclude, @@ -107,10 +129,11 @@ export class HarvestsService { payload: { apiaryId: harvest.apiaryId, colonyIds: dto.colonyIds }, actorUserId: actingUserId, }); - return tx.harvest.findUniqueOrThrow({ + const created = await tx.harvest.findUniqueOrThrow({ where: { id: harvest.id }, include: harvestInclude, }); + return this.withWithdrawalWarnings(tx, created); }); } @@ -177,7 +200,7 @@ export class HarvestsService { }, actorUserId: actingUserId, }); - return harvest; + return this.withWithdrawalWarnings(tx, harvest); }); } } diff --git a/ApiCore-Backend/src/inspections/inspections.service.ts b/ApiCore-Backend/src/inspections/inspections.service.ts index 44e183e..6243f5f 100644 --- a/ApiCore-Backend/src/inspections/inspections.service.ts +++ b/ApiCore-Backend/src/inspections/inspections.service.ts @@ -26,7 +26,8 @@ export type InspectionWithTakeover = Inspection & { /** * Inspections are written together with their audit entry and domain event * in one transaction: InspectionRecorded, InspectionCorrected. Automation - * rules (automation.inspection-rules, on InspectionRecorded only), calendar + * rules (automation.inspection-rules: new findings on InspectionRecorded, + * open suggestions follow on InspectionCorrected too), calendar * entry (calendar-sync.inspection) and the inspection.created webhook * (webhooks.forward) follow from the event, never from a direct call - so * none of them can fail the request or run twice on a retried one. @@ -160,8 +161,8 @@ export class InspectionsService { } /** - * A correction evaluates no automation rule, as before the event - * backbone. InspectionCorrected moves the calendar entry when the date or + * A correction proposes no new rule suggestion (an open one follows the + * corrected values via automation.inspection-rules). InspectionCorrected moves the calendar entry when the date or * colony changed instead of leaving the old one behind. */ async update( diff --git a/ApiCore-Backend/src/jobs/daily-evaluation.rules.spec.ts b/ApiCore-Backend/src/jobs/daily-evaluation.rules.spec.ts index d5ab876..2726d37 100644 --- a/ApiCore-Backend/src/jobs/daily-evaluation.rules.spec.ts +++ b/ApiCore-Backend/src/jobs/daily-evaluation.rules.spec.ts @@ -644,6 +644,54 @@ describe('daily evaluation rules', () => { ...unknown[], ]; expect(strings.join('?')).toContain('"withdrawalPeriodDays" IS NOT NULL'); - expect(values).toEqual(['2026-06-03', '2026-06-10']); + // One day of margin on both sides: the SQL end day is a plain UTC date, + // the event day is the Berlin day of the withdrawal-window helper. + expect(values).toEqual(['2026-06-02', '2026-06-11']); + }); + + it('bounds the withdrawal query by the Berlin day of the run, not by the instant it is given', async () => { + const { db, queryRaw } = fakeDb({}); + const rule = DAILY_EVALUATION_RULES.find( + (r) => r.type === 'WithdrawalEnded', + )!; + + // 2026-06-10T23:30Z is already 2026-06-11 in Berlin. + await rule.occurrences(db, new Date('2026-06-10T23:30:00.000Z'), { + seasons, + day: '2026-06-11', + }); + + const [, ...values] = queryRaw.mock.calls[0] as [ + readonly string[], + ...unknown[], + ]; + expect(values).toEqual(['2026-06-03', '2026-06-12']); + }); + + it('takes the end day of a treatment stored with a time part from the Berlin day', async () => { + // 2026-06-01T22:30Z is 00:30 on 2026-06-02 in Berlin: one withdrawal day + // later the period ends on 2026-06-03, the first day of the window. The + // plain UTC date of the SQL would say 2026-06-02. + const { db } = fakeDb({ + treatments: [ + { + id: 'treatment-1', + colonyId: 'colony-1', + date: new Date('2026-06-01T22:30:00.000Z'), + endDate: null, + withdrawalPeriodDays: 1, + }, + ], + }); + + const events = await eventsOn(db, '2026-06-10'); + + expect(events).toEqual([ + expect.objectContaining({ + type: 'WithdrawalEnded', + payload: { colonyId: 'colony-1', endedOn: '2026-06-03' }, + dedupeKey: 'WithdrawalEnded:treatment-1:2026-06-03', + }), + ]); }); }); diff --git a/ApiCore-Backend/src/jobs/daily-evaluation.rules.ts b/ApiCore-Backend/src/jobs/daily-evaluation.rules.ts index 218a109..c7ccc3e 100644 --- a/ApiCore-Backend/src/jobs/daily-evaluation.rules.ts +++ b/ApiCore-Backend/src/jobs/daily-evaluation.rules.ts @@ -123,15 +123,19 @@ interface TreatmentRow { /** Treatment: the day after which honey may be harvested again. */ const withdrawalEnded: DailyRule<'WithdrawalEnded'> = { type: 'WithdrawalEnded', - async occurrences(db, today) { + async occurrences(db, _today, { day: runDay }) { // Prisma cannot compare column arithmetic; the days are compared as - // dates, so the session time zone plays no part. + // dates, so the session time zone plays no part. The SQL day is a plain + // UTC date, the event day is the Berlin day of the withdrawal-window + // helper: they can differ by a day for a treatment stored with a time + // part, so the query takes one day more on each side and the helper's + // day decides (catchUpEvents keeps the window). const treatments = await db.$queryRaw` SELECT "id", "colonyId", "date", "endDate", "withdrawalPeriodDays" FROM "Treatment" WHERE "withdrawalPeriodDays" IS NOT NULL AND (COALESCE("endDate", "date") + "withdrawalPeriodDays" * interval '1 day')::date - BETWEEN ${utcDay(dayAfter(today, -CATCH_UP_DAYS))}::date AND ${utcDay(today)}::date`; + BETWEEN ${addDays(runDay, -CATCH_UP_DAYS - 1)}::date AND ${addDays(runDay, 1)}::date`; return treatments.flatMap((treatment) => { const day = withdrawalEndDay(treatment); if (day === null) { diff --git a/ApiCore-Backend/src/queens/queens.service.spec.ts b/ApiCore-Backend/src/queens/queens.service.spec.ts index 015b394..0ee146d 100644 --- a/ApiCore-Backend/src/queens/queens.service.spec.ts +++ b/ApiCore-Backend/src/queens/queens.service.spec.ts @@ -373,4 +373,73 @@ describe('QueensService', () => { expect(lineage.descendants[0].code).toBe('Q-D'); expect(lineage.descendants[0].daughters).toEqual([]); }); + + describe('updateStatusIn', () => { + it('locks the queen, reads it afterwards, sets the status and audits in the same transaction', async () => { + tx.queen.findUnique.mockResolvedValue(queen); + tx.queen.update.mockResolvedValue({ ...queen, status: 'DECEASED' }); + + const result = await service.updateStatusIn( + tx as never, + 'queen-1', + 'DECEASED', + 'user-1', + ); + + expect(result.status).toBe('DECEASED'); + expect(tx.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan( + tx.queen.findUnique.mock.invocationCallOrder[0], + ); + expect(tx.queen.update).toHaveBeenCalledWith({ + where: { id: 'queen-1' }, + data: { status: 'DECEASED' }, + }); + expect(auditLog.record).toHaveBeenCalledWith( + { + entityType: 'Queen', + entityId: 'queen-1', + action: 'UPDATE', + userId: 'user-1', + beforeData: queen, + afterData: { ...queen, status: 'DECEASED' }, + }, + tx, + ); + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); + + it('answers 404 for an unknown queen', async () => { + tx.queen.findUnique.mockResolvedValue(null); + + await expect( + service.updateStatusIn(tx as never, 'gone', 'SOLD', 'user-1'), + ).rejects.toBeInstanceOf(NotFoundException); + expect(tx.queen.update).not.toHaveBeenCalled(); + }); + + it('answers 409 when the queen still sits in a colony and leaves the status unavailable', async () => { + tx.queen.findUnique.mockResolvedValue(queen); + tx.colony.findUnique.mockResolvedValue({ id: 'colony-9' }); + + await expect( + service.updateStatusIn(tx as never, 'queen-1', 'DECEASED', 'user-1'), + ).rejects.toBeInstanceOf(ConflictException); + expect(tx.queen.update).not.toHaveBeenCalled(); + }); + + it('changes nothing and audits nothing when the status is the same', async () => { + tx.queen.findUnique.mockResolvedValue(queen); + + const result = await service.updateStatusIn( + tx as never, + 'queen-1', + 'ACTIVE', + 'user-1', + ); + + expect(result).toBe(queen); + expect(tx.queen.update).not.toHaveBeenCalled(); + expect(auditLog.record).not.toHaveBeenCalled(); + }); + }); }); diff --git a/ApiCore-Backend/src/queens/queens.service.ts b/ApiCore-Backend/src/queens/queens.service.ts index 148c0e0..2da8fd7 100644 --- a/ApiCore-Backend/src/queens/queens.service.ts +++ b/ApiCore-Backend/src/queens/queens.service.ts @@ -9,8 +9,9 @@ import { AuditLogService } from '../audit/audit-log.service'; import { WebhooksService } from '../webhooks/webhooks.service'; import { DomainEventsService } from '../domain-events/domain-events.service'; import { utcDay } from '../domain-events/domain-event-types'; +import { assertTransactionClient } from '../prisma/db-client'; import { lockRowForUpdate, lockRowsForUpdate } from '../prisma/row-lock'; -import { Queen, QueenStatus } from '../../generated/prisma'; +import { Prisma, Queen, QueenStatus } from '../../generated/prisma'; import { CreateQueenDto } from './dto/create-queen.dto'; import { UpdateQueenDto } from './dto/update-queen.dto'; import { AssignColonyDto } from './dto/assign-colony.dto'; @@ -155,6 +156,60 @@ export class QueensService { return queen; } + /** + * Differs from update(): this is the status alone, on the caller's + * transaction, with the audit entry inside it. update() also lets go of + * the colony that holds a queen who becomes unavailable (and audits after + * its transaction); here that case is a 409, because the clean-up of an + * ended colony must never unlink a colony she was re-assigned to. + * + * Sets the status of a queen on the caller's transaction: locks the queen, + * reads it afterwards, audits the change. For a status that takes her out + * of use it refuses while a colony still holds her (409) - the colony + * side has to let go of her first (releaseQueenIn, assignColony); this + * method never touches a colony. The same status again changes nothing. + */ + async updateStatusIn( + tx: Prisma.TransactionClient, + id: string, + status: QueenStatus, + actingUserId: string, + ): Promise { + assertTransactionClient(tx, 'QueensService.updateStatusIn'); + await lockRowForUpdate(tx, 'Queen', id); + const before = await tx.queen.findUnique({ where: { id } }); + if (!before) { + throw new NotFoundException('Queen not found'); + } + if (before.status === status) { + return before; + } + if (QUEEN_NO_LONGER_AVAILABLE_STATUSES.includes(status)) { + const holder = await tx.colony.findUnique({ + where: { currentQueenId: id }, + select: { id: true }, + }); + if (holder) { + throw new ConflictException( + 'Die Königin sitzt noch in einem Volk - bitte zuerst dort lösen', + ); + } + } + const updated = await tx.queen.update({ where: { id }, data: { status } }); + await this.auditLog.record( + { + entityType: 'Queen', + entityId: id, + action: 'UPDATE', + userId: actingUserId, + beforeData: before, + afterData: updated, + }, + tx, + ); + return updated; + } + async assignColony( id: string, dto: AssignColonyDto, diff --git a/ApiCore-Backend/src/suggestions/cleanup.controller.ts b/ApiCore-Backend/src/suggestions/cleanup.controller.ts new file mode 100644 index 0000000..f6a3da1 --- /dev/null +++ b/ApiCore-Backend/src/suggestions/cleanup.controller.ts @@ -0,0 +1,43 @@ +import { Body, Controller, Param, Post } from '@nestjs/common'; +import { ApiBearerAuth, ApiCreatedResponse, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { RequirePermission } from '../auth/decorators/require-permission.decorator'; +import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; +import type { AuthenticatedUser } from '../auth/types/authenticated-user'; +import { Idempotent } from '../common/idempotency/idempotent.decorator'; +import { CleanupService } from './cleanup.service'; +import { ApplyCleanupDto, CleanupResultDto } from './dto/apply-cleanup.dto'; +import { SuggestionViewsService } from './suggestion-views.service'; + +/** + * "Aufräumen" of an ended colony. The route needs tasks:update; DONE + * additionally needs tasks:complete and the queen queens:update (checked by + * the service). Visibility comes first: 404 for a suggestion the user may + * not see. + */ +@ApiTags('suggestions') +@ApiBearerAuth() +@Controller('suggestions') +export class CleanupController { + constructor( + private readonly views: SuggestionViewsService, + private readonly cleanup: CleanupService, + private readonly permissions: PermissionResolverService, + ) {} + + @ApiCreatedResponse({ type: CleanupResultDto }) + @RequirePermission('tasks:update') + @Idempotent() + @Post(':id/apply-cleanup') + async applyCleanup( + @Param('id') id: string, + @Body() dto: ApplyCleanupDto, + @CurrentUser() actor: AuthenticatedUser, + ): Promise { + await this.views.assertVisible(actor.id, [id]); + return this.cleanup.apply(id, dto, { + id: actor.id, + permissions: await this.permissions.getPermissions(actor.id), + }); + } +} diff --git a/ApiCore-Backend/src/suggestions/cleanup.module.ts b/ApiCore-Backend/src/suggestions/cleanup.module.ts new file mode 100644 index 0000000..831101c --- /dev/null +++ b/ApiCore-Backend/src/suggestions/cleanup.module.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common'; +import { AuditModule } from '../audit/audit.module'; +import { QueensModule } from '../queens/queens.module'; +import { TasksModule } from '../tasks/tasks.module'; +import { CleanupController } from './cleanup.controller'; +import { CleanupService } from './cleanup.service'; +import { SuggestionsModule } from './suggestions.module'; + +/** + * The clean-up of an ended colony, API only: it settles tasks and the + * queen, which pulls QueensService (and with it the webhooks) in. Kept apart + * from SuggestionsModule so the CLI jobs, which import that one, do not + * need them. The permission resolver comes from the global + * PermissionsModule. + */ +@Module({ + imports: [SuggestionsModule, TasksModule, QueensModule, AuditModule], + controllers: [CleanupController], + providers: [CleanupService], +}) +export class CleanupModule {} diff --git a/ApiCore-Backend/src/suggestions/cleanup.service.spec.ts b/ApiCore-Backend/src/suggestions/cleanup.service.spec.ts new file mode 100644 index 0000000..fe14c5b --- /dev/null +++ b/ApiCore-Backend/src/suggestions/cleanup.service.spec.ts @@ -0,0 +1,337 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + NotFoundException, +} from '@nestjs/common'; +import type { AuditLogService } from '../audit/audit-log.service'; +import type { PrismaService } from '../prisma/prisma.service'; +import { lockRowForUpdate, lockRowsForUpdate } from '../prisma/row-lock'; +import type { QueensService } from '../queens/queens.service'; +import type { TasksService } from '../tasks/tasks.service'; +import { CleanupService } from './cleanup.service'; +import type { ApplyCleanupDto } from './dto/apply-cleanup.dto'; +import type { SuggestionsService } from './suggestions.service'; + +jest.mock('../prisma/row-lock'); + +const ALL = new Set(['tasks:update', 'tasks:complete', 'queens:update']); +const NO_COMPLETE = new Set(['tasks:update', 'queens:update']); +const NO_QUEENS = new Set(['tasks:update', 'tasks:complete']); + +describe('CleanupService', () => { + let tx: { + suggestion: { + findUnique: jest.Mock; + findUniqueOrThrow: jest.Mock; + updateMany: jest.Mock; + }; + task: { findMany: jest.Mock }; + colony: { findUnique: jest.Mock }; + }; + let prisma: { $transaction: jest.Mock }; + let auditLog: { record: jest.Mock }; + let tasks: { cancelIn: jest.Mock; completeIn: jest.Mock }; + let queens: { updateStatusIn: jest.Mock }; + let suggestions: { resolveIn: jest.Mock }; + let service: CleanupService; + + const open = { + id: 'sug-1', + status: 'OPEN', + intent: 'CLEANUP', + evidence: { + colonyId: 'col-1', + status: 'DECEASED', + endedOn: '2027-03-12', + via: 'WINTER_CHECK', + openTaskIds: ['t1', 't2', 't3'], + queenId: 'queen-1', + }, + }; + const onColony = { relatedType: 'COLONY', relatedId: 'col-1' }; + const resolved = { ...open, status: 'RESOLVED' }; + const dto = (overrides: Partial = {}): ApplyCleanupDto => ({ + tasks: [ + { id: 't1', outcome: 'CANCEL' }, + { id: 't2', outcome: 'CANCEL' }, + { id: 't3', outcome: 'DONE' }, + ], + queen: { id: 'queen-1', status: 'DECEASED' }, + ...overrides, + }); + const actor = (permissions: ReadonlySet = ALL) => ({ + id: 'user-1', + permissions, + }); + + beforeEach(() => { + (lockRowForUpdate as jest.Mock).mockReset().mockResolvedValue(true); + (lockRowsForUpdate as jest.Mock).mockReset().mockResolvedValue(undefined); + tx = { + suggestion: { + findUnique: jest.fn().mockResolvedValue(open), + findUniqueOrThrow: jest.fn().mockResolvedValue(resolved), + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + task: { + findMany: jest.fn().mockResolvedValue([ + { id: 't1', status: 'OPEN', ...onColony }, + { id: 't2', status: 'IN_PROGRESS', ...onColony }, + { id: 't3', status: 'OPEN', ...onColony }, + ]), + }, + colony: { + findUnique: jest.fn().mockResolvedValue({ status: 'SOLD' }), + }, + }; + prisma = { + $transaction: jest.fn((work: (client: typeof tx) => Promise) => + work(tx), + ), + }; + auditLog = { record: jest.fn().mockResolvedValue(undefined) }; + tasks = { + cancelIn: jest.fn().mockResolvedValue({}), + completeIn: jest.fn().mockResolvedValue({}), + }; + queens = { + updateStatusIn: jest + .fn() + .mockResolvedValue({ id: 'queen-1', status: 'DECEASED' }), + }; + suggestions = { resolveIn: jest.fn().mockResolvedValue(1) }; + service = new CleanupService( + prisma as unknown as PrismaService, + auditLog as unknown as AuditLogService, + tasks as unknown as TasksService, + queens as unknown as QueensService, + suggestions as unknown as SuggestionsService, + ); + }); + + it('cancels 2 tasks, completes 1, sets the queen and resolves the suggestion', async () => { + const result = await service.apply('sug-1', dto(), actor()); + + expect(result).toEqual({ + cancelled: 2, + completed: 1, + kept: 0, + queenStatus: 'DECEASED', + }); + expect(tasks.cancelIn).toHaveBeenCalledTimes(2); + expect(tasks.cancelIn).toHaveBeenCalledWith(tx, 't1', 'user-1'); + expect(tasks.completeIn).toHaveBeenCalledWith(tx, 't3', 'user-1'); + expect(queens.updateStatusIn).toHaveBeenCalledWith( + tx, + 'queen-1', + 'DECEASED', + 'user-1', + ); + expect(suggestions.resolveIn).toHaveBeenCalledWith( + tx, + ['sug-1'], + 'Aufgeräumt', + ); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ + entityType: 'Suggestion', + entityId: 'sug-1', + action: 'UPDATE', + userId: 'user-1', + beforeData: open, + afterData: resolved, + }), + tx, + ); + }); + + it('locks Suggestion, then Task, then (via updateStatusIn) the queen', async () => { + await service.apply('sug-1', dto(), actor()); + + expect(lockRowForUpdate).toHaveBeenCalledWith(tx, 'Suggestion', 'sug-1'); + expect(lockRowsForUpdate).toHaveBeenCalledWith(tx, 'Task', [ + 't1', + 't2', + 't3', + ]); + const suggestionLock = (lockRowForUpdate as jest.Mock).mock + .invocationCallOrder[0]; + const taskLock = (lockRowsForUpdate as jest.Mock).mock + .invocationCallOrder[0]; + expect(suggestionLock).toBeLessThan(taskLock); + expect(taskLock).toBeLessThan( + queens.updateStatusIn.mock.invocationCallOrder[0], + ); + // The suggestion is read only after its lock. + expect(suggestionLock).toBeLessThan( + tx.suggestion.findUnique.mock.invocationCallOrder[0], + ); + }); + + it('KEEP touches nothing and counts as kept; a task that is no longer open is kept as well', async () => { + tx.task.findMany.mockResolvedValue([ + { id: 't1', status: 'OPEN', ...onColony }, + { id: 't2', status: 'DONE', ...onColony }, + { id: 't3', status: 'CANCELLED', ...onColony }, + ]); + + const result = await service.apply( + 'sug-1', + dto({ + tasks: [ + { id: 't1', outcome: 'KEEP' }, + { id: 't2', outcome: 'CANCEL' }, + { id: 't3', outcome: 'DONE' }, + ], + queen: null, + }), + actor(), + ); + + expect(result).toEqual({ + cancelled: 0, + completed: 0, + kept: 3, + queenStatus: null, + }); + expect(tasks.cancelIn).not.toHaveBeenCalled(); + expect(tasks.completeIn).not.toHaveBeenCalled(); + expect(queens.updateStatusIn).not.toHaveBeenCalled(); + }); + + it('403 without tasks:complete when one task is DONE', async () => { + await expect( + service.apply('sug-1', dto(), actor(NO_COMPLETE)), + ).rejects.toBeInstanceOf(ForbiddenException); + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); + + it('does not need tasks:complete when no task is DONE', async () => { + await expect( + service.apply( + 'sug-1', + dto({ tasks: [{ id: 't1', outcome: 'CANCEL' }], queen: null }), + actor(NO_COMPLETE), + ), + ).resolves.toMatchObject({ cancelled: 1 }); + }); + + it('403 without queens:update when the queen is given', async () => { + await expect( + service.apply('sug-1', dto(), actor(NO_QUEENS)), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('400 for a task id that is not in the evidence', async () => { + await expect( + service.apply( + 'sug-1', + dto({ tasks: [{ id: 'foreign', outcome: 'CANCEL' }], queen: null }), + actor(), + ), + ).rejects.toMatchObject({ + response: expect.objectContaining({ + message: 'Nur Aufgaben und Königin aus dem Vorschlag.', + }) as object, + }); + expect(tasks.cancelIn).not.toHaveBeenCalled(); + }); + + it('400 for a queen that is not in the evidence', async () => { + await expect( + service.apply( + 'sug-1', + dto({ tasks: [], queen: { id: 'other', status: 'SOLD' } }), + actor(), + ), + ).rejects.toBeInstanceOf(BadRequestException); + expect(queens.updateStatusIn).not.toHaveBeenCalled(); + }); + + it('400 when the same task is named twice', async () => { + await expect( + service.apply( + 'sug-1', + dto({ + tasks: [ + { id: 't1', outcome: 'CANCEL' }, + { id: 't1', outcome: 'DONE' }, + ], + queen: null, + }), + actor(), + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('409 with the status when the suggestion is not open any more', async () => { + tx.suggestion.findUnique.mockResolvedValue({ ...open, status: 'RESOLVED' }); + + await expect(service.apply('sug-1', dto(), actor())).rejects.toMatchObject({ + response: { + message: 'Der Vorschlag ist nicht mehr offen.', + status: 'RESOLVED', + }, + }); + expect(tasks.cancelIn).not.toHaveBeenCalled(); + }); + + it('409 when the compare-and-set loses against a parallel decision', async () => { + suggestions.resolveIn.mockResolvedValue(0); + + await expect(service.apply('sug-1', dto(), actor())).rejects.toBeInstanceOf( + ConflictException, + ); + }); + + it('400 for a suggestion that is not a clean-up', async () => { + tx.suggestion.findUnique.mockResolvedValue({ ...open, intent: 'INSPECT' }); + + await expect(service.apply('sug-1', dto(), actor())).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it('404 for an unknown suggestion', async () => { + tx.suggestion.findUnique.mockResolvedValue(null); + + await expect(service.apply('gone', dto(), actor())).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + + it('409 "Das Volk ist wieder aktiv." when the colony was reactivated meanwhile', async () => { + tx.colony.findUnique.mockResolvedValue({ status: 'ACTIVE' }); + + await expect(service.apply('sug-1', dto(), actor())).rejects.toMatchObject({ + response: { message: 'Das Volk ist wieder aktiv.' }, + }); + expect(tasks.cancelIn).not.toHaveBeenCalled(); + }); + + it('leaves a task alone that is not a task of the evidence colony any more', async () => { + tx.task.findMany.mockResolvedValue([ + { id: 't1', status: 'OPEN', relatedType: 'COLONY', relatedId: 'other' }, + { id: 't2', status: 'OPEN', relatedType: 'GENERAL', relatedId: null }, + { id: 't3', status: 'OPEN', ...onColony }, + ]); + + const result = await service.apply('sug-1', dto({ queen: null }), actor()); + + expect(result).toMatchObject({ cancelled: 0, completed: 1, kept: 2 }); + expect(tasks.cancelIn).not.toHaveBeenCalled(); + }); + + it('400 when the evidence has no queen but the request names one', async () => { + tx.suggestion.findUnique.mockResolvedValue({ + ...open, + evidence: { ...open.evidence, queenId: null }, + }); + + await expect(service.apply('sug-1', dto(), actor())).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(queens.updateStatusIn).not.toHaveBeenCalled(); + }); +}); diff --git a/ApiCore-Backend/src/suggestions/cleanup.service.ts b/ApiCore-Backend/src/suggestions/cleanup.service.ts new file mode 100644 index 0000000..c529c05 --- /dev/null +++ b/ApiCore-Backend/src/suggestions/cleanup.service.ts @@ -0,0 +1,252 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import type { Prisma, QueenStatus, Suggestion } from '../../generated/prisma'; +import { isLivingColonyStatus } from '../colonies/colony-status'; +import { AuditLogService } from '../audit/audit-log.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { lockRowForUpdate, lockRowsForUpdate } from '../prisma/row-lock'; +import { QueensService } from '../queens/queens.service'; +import { TasksService } from '../tasks/tasks.service'; +import type { ApplyCleanupDto, CleanupResult } from './dto/apply-cleanup.dto'; +import type { SuggestionEvidence } from './suggestion.types'; +import { SuggestionsService } from './suggestions.service'; + +const OPEN_TASK_STATUSES: readonly string[] = ['OPEN', 'IN_PROGRESS']; +const NOT_OPEN_MESSAGE = 'Der Vorschlag ist nicht mehr offen.'; +const FOREIGN_ID_MESSAGE = 'Nur Aufgaben und Königin aus dem Vorschlag.'; + +export interface CleanupActor { + id: string; + permissions: ReadonlySet; +} + +function stringsOf(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((entry): entry is string => typeof entry === 'string') + : []; +} + +/** + * Executes the "Aufräumen" of an ended colony: tasks are cancelled, + * completed (like a normal completion) or kept, the queen may get a new + * status, and the clean-up suggestion is resolved - all in one transaction, + * all or nothing. Only the ids the suggestion names are accepted. Locks: + * Suggestion, then Tasks, then the Queen; never the Colony. + */ +@Injectable() +export class CleanupService { + constructor( + private readonly prisma: PrismaService, + private readonly auditLog: AuditLogService, + private readonly tasks: TasksService, + private readonly queens: QueensService, + private readonly suggestions: SuggestionsService, + ) {} + + async apply( + suggestionId: string, + dto: ApplyCleanupDto, + actor: CleanupActor, + ): Promise { + this.assertMayDo(dto, actor.permissions); + return this.prisma.$transaction(async (tx) => { + await lockRowForUpdate(tx, 'Suggestion', suggestionId); + const before = await tx.suggestion.findUnique({ + where: { id: suggestionId }, + }); + if (!before) { + throw new NotFoundException('Der Vorschlag wurde nicht gefunden.'); + } + if (before.status !== 'OPEN') { + throw new ConflictException({ + message: NOT_OPEN_MESSAGE, + status: before.status, + }); + } + if (before.intent !== 'CLEANUP') { + throw new BadRequestException('Das ist kein Aufräum-Vorschlag.'); + } + const evidence = before.evidence as SuggestionEvidence; + this.assertFromEvidence(dto, evidence); + await this.assertColonyStillEndedIn(tx, evidence); + + const counts = await this.settleTasksIn( + tx, + dto, + evidence.colonyId, + actor.id, + ); + const queenStatus = await this.settleQueenIn(tx, dto, actor.id); + + const resolved = await this.suggestions.resolveIn( + tx, + [suggestionId], + 'Aufgeräumt', + ); + if (resolved === 0) { + throw new ConflictException({ + message: NOT_OPEN_MESSAGE, + status: before.status, + }); + } + await tx.suggestion.updateMany({ + where: { id: suggestionId }, + data: { decidedByUserId: actor.id }, + }); + await this.auditSuggestionIn(tx, before, actor.id); + return { ...counts, queenStatus }; + }); + } + + /** The route needs tasks:update; the parts need their own right. */ + private assertMayDo( + dto: ApplyCleanupDto, + permissions: ReadonlySet, + ): void { + if ( + dto.tasks.some((task) => task.outcome === 'DONE') && + !permissions.has('tasks:complete') + ) { + throw new ForbiddenException( + 'Zum Erledigen von Aufgaben fehlt die Berechtigung.', + ); + } + if (dto.queen && !permissions.has('queens:update')) { + throw new ForbiddenException( + 'Zum Ändern der Königin fehlt die Berechtigung.', + ); + } + } + + private assertFromEvidence( + dto: ApplyCleanupDto, + evidence: SuggestionEvidence, + ): void { + const taskIds = new Set(stringsOf(evidence.openTaskIds)); + const named = dto.tasks.map((task) => task.id); + const foreign = + named.some((id) => !taskIds.has(id)) || + (!!dto.queen && dto.queen.id !== evidence.queenId); + if (foreign) { + throw new BadRequestException(FOREIGN_ID_MESSAGE); + } + if (new Set(named).size !== named.length) { + throw new BadRequestException('Jede Aufgabe nur einmal angeben.'); + } + } + + /** A colony that was reactivated meanwhile has nothing to clean up. */ + private async assertColonyStillEndedIn( + tx: Prisma.TransactionClient, + evidence: SuggestionEvidence, + ): Promise { + if (typeof evidence.colonyId !== 'string') { + return; + } + const colony = await tx.colony.findUnique({ + where: { id: evidence.colonyId }, + select: { status: true }, + }); + if (colony && isLivingColonyStatus(colony.status)) { + throw new ConflictException({ + message: 'Das Volk ist wieder aktiv.', + status: 'OPEN', + }); + } + } + + /** + * A task that is no longer open (changed meanwhile) or no longer belongs + * to the evidence colony stays as it is. + */ + private async settleTasksIn( + tx: Prisma.TransactionClient, + dto: ApplyCleanupDto, + colonyId: SuggestionEvidence[string], + actingUserId: string, + ): Promise> { + const ids = dto.tasks.map((task) => task.id); + await lockRowsForUpdate(tx, 'Task', ids); + const current = new Map( + ( + await tx.task.findMany({ + where: { id: { in: ids } }, + select: { + id: true, + status: true, + relatedType: true, + relatedId: true, + }, + }) + ) + .filter( + (task) => + task.relatedType === 'COLONY' && task.relatedId === colonyId, + ) + .map((task) => [task.id, task.status]), + ); + let cancelled = 0; + let completed = 0; + let kept = 0; + for (const { id, outcome } of dto.tasks) { + const status = current.get(id); + if ( + outcome === 'KEEP' || + status === undefined || + !OPEN_TASK_STATUSES.includes(status) + ) { + kept += 1; + } else if (outcome === 'CANCEL') { + await this.tasks.cancelIn(tx, id, actingUserId); + cancelled += 1; + } else { + await this.tasks.completeIn(tx, id, actingUserId); + completed += 1; + } + } + return { cancelled, completed, kept }; + } + + private async settleQueenIn( + tx: Prisma.TransactionClient, + dto: ApplyCleanupDto, + actingUserId: string, + ): Promise { + if (!dto.queen) { + return null; + } + const queen = await this.queens.updateStatusIn( + tx, + dto.queen.id, + dto.queen.status, + actingUserId, + ); + return queen.status; + } + + private async auditSuggestionIn( + tx: Prisma.TransactionClient, + before: Suggestion, + actingUserId: string, + ): Promise { + const after = await tx.suggestion.findUniqueOrThrow({ + where: { id: before.id }, + }); + await this.auditLog.record( + { + entityType: 'Suggestion', + entityId: before.id, + action: 'UPDATE', + userId: actingUserId, + beforeData: before, + afterData: after, + }, + tx, + ); + } +} diff --git a/ApiCore-Backend/src/suggestions/dto/apply-cleanup.dto.ts b/ApiCore-Backend/src/suggestions/dto/apply-cleanup.dto.ts new file mode 100644 index 0000000..a4d424e --- /dev/null +++ b/ApiCore-Backend/src/suggestions/dto/apply-cleanup.dto.ts @@ -0,0 +1,76 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + ArrayMaxSize, + IsArray, + IsEnum, + IsIn, + IsOptional, + IsString, + MaxLength, + MinLength, + ValidateNested, +} from 'class-validator'; +import { QueenStatus } from '../../../generated/prisma'; + +const MAX_CLEANUP_TASKS = 200; +export const CLEANUP_OUTCOMES = ['CANCEL', 'DONE', 'KEEP'] as const; +export type CleanupOutcome = (typeof CLEANUP_OUTCOMES)[number]; + +export class CleanupTaskDto { + @ApiProperty() + @IsString() + @MinLength(1) + @MaxLength(64) + id!: string; + + @ApiProperty({ enum: CLEANUP_OUTCOMES }) + @IsIn(CLEANUP_OUTCOMES, { + message: 'Bitte Abbrechen, Erledigt oder Behalten wählen.', + }) + outcome!: CleanupOutcome; +} + +export class CleanupQueenDto { + @ApiProperty() + @IsString() + @MinLength(1) + @MaxLength(64) + id!: string; + + @ApiProperty({ enum: QueenStatus }) + @IsEnum(QueenStatus) + status!: QueenStatus; +} + +/** What to do with the leftovers of an ended colony; only ids from the suggestion. */ +export class ApplyCleanupDto { + @ApiProperty({ type: [CleanupTaskDto], maxItems: MAX_CLEANUP_TASKS }) + @IsArray({ message: 'Bitte eine Liste von Aufgaben angeben.' }) + @ArrayMaxSize(MAX_CLEANUP_TASKS) + @ValidateNested({ each: true }) + @Type(() => CleanupTaskDto) + tasks!: CleanupTaskDto[]; + + @ApiPropertyOptional({ type: CleanupQueenDto, nullable: true }) + @IsOptional() + @ValidateNested() + @Type(() => CleanupQueenDto) + queen?: CleanupQueenDto | null; +} + +export class CleanupResultDto { + @ApiProperty() + cancelled!: number; + + @ApiProperty() + completed!: number; + + @ApiProperty() + kept!: number; + + @ApiProperty({ enum: QueenStatus, nullable: true }) + queenStatus!: QueenStatus | null; +} + +export type CleanupResult = CleanupResultDto; diff --git a/ApiCore-Backend/src/suggestions/suggestions.handlers.spec.ts b/ApiCore-Backend/src/suggestions/suggestions.handlers.spec.ts new file mode 100644 index 0000000..119b3b6 --- /dev/null +++ b/ApiCore-Backend/src/suggestions/suggestions.handlers.spec.ts @@ -0,0 +1,197 @@ +import type { Prisma } from '../../generated/prisma'; +import { lockRowForUpdate } from '../prisma/row-lock'; +import type { AnyDomainEventEnvelope } from '../domain-events/domain-event-types'; +import { domainEventHandlerMetadata } from '../domain-events/on-domain-event.decorator'; +import type { SuggestionsService } from './suggestions.service'; +import { SuggestionEventHandlers } from './suggestions.handlers'; + +jest.mock('../prisma/row-lock'); + +type Payload = Partial<{ + status: string; + endedOn: string; + releasedQueenId: string | null; + via: string; +}>; + +function endedEvent( + payload: Payload = {}, +): AnyDomainEventEnvelope<'ColonyEnded'> { + return { + id: 'ev-1', + type: 'ColonyEnded', + aggregateType: 'Colony', + aggregateId: 'col-1', + payload: { + status: 'DECEASED', + endedOn: '2027-03-12', + releasedQueenId: null, + via: 'WINTER_CHECK', + ...payload, + }, + actorUserId: null, + correlationId: 'corr-1', + occurredAt: new Date('2027-03-13T08:00:00.000Z'), + attempts: 0, + } as unknown as AnyDomainEventEnvelope<'ColonyEnded'>; +} + +describe('SuggestionEventHandlers', () => { + let tx: { + colony: { findUnique: jest.Mock }; + task: { findMany: jest.Mock }; + queen: { findUnique: jest.Mock }; + automationRule: { findUnique: jest.Mock }; + }; + let suggestions: { obsoleteForColonyIn: jest.Mock; proposeIn: jest.Mock }; + let handlers: SuggestionEventHandlers; + const asTx = () => tx as unknown as Prisma.TransactionClient; + const proposed = () => + ( + suggestions.proposeIn.mock.calls as [unknown, Record][] + )[0][1]; + + beforeEach(() => { + (lockRowForUpdate as jest.Mock).mockReset().mockResolvedValue(true); + tx = { + colony: { + findUnique: jest + .fn() + .mockResolvedValue({ code: 'B3', status: 'DECEASED' }), + }, + task: { findMany: jest.fn().mockResolvedValue([]) }, + queen: { findUnique: jest.fn().mockResolvedValue({ status: 'ACTIVE' }) }, + automationRule: { findUnique: jest.fn().mockResolvedValue(null) }, + }; + suggestions = { + obsoleteForColonyIn: jest.fn().mockResolvedValue(0), + proposeIn: jest.fn().mockResolvedValue({ id: 'sug-1' }), + }; + handlers = new SuggestionEventHandlers( + suggestions as unknown as SuggestionsService, + ); + }); + + it('registers the handler id suggestions.colony-ended for ColonyEnded', () => { + const entries = domainEventHandlerMetadata( + // eslint-disable-next-line @typescript-eslint/unbound-method -- only the decorator metadata is read + SuggestionEventHandlers.prototype.colonyEnded, + ); + expect(entries).toEqual([ + { types: ['ColonyEnded'], handlerId: 'suggestions.colony-ended' }, + ]); + }); + + it('colony-ended turns open suggestions OBSOLETE and proposes CLEANUP when tasks are open', async () => { + tx.task.findMany.mockResolvedValue([{ id: 'task-1' }, { id: 'task-2' }]); + + await handlers.colonyEnded(endedEvent(), asTx()); + + expect(lockRowForUpdate).toHaveBeenCalledWith(tx, 'Colony', 'col-1'); + expect( + (lockRowForUpdate as jest.Mock).mock.invocationCallOrder[0], + ).toBeLessThan(suggestions.obsoleteForColonyIn.mock.invocationCallOrder[0]); + expect(suggestions.obsoleteForColonyIn).toHaveBeenCalledWith(tx, 'col-1'); + expect(tx.task.findMany).toHaveBeenCalledWith({ + where: { + relatedType: 'COLONY', + relatedId: 'col-1', + status: { in: ['OPEN', 'IN_PROGRESS'] }, + }, + select: { id: true }, + orderBy: { id: 'asc' }, + }); + expect(proposed()).toEqual({ + ruleKey: 'COLONY_ENDED_CLEANUP', + source: 'CHAIN', + mode: 'DO', + intent: 'CLEANUP', + priority: 'NORMAL', + relatedType: 'COLONY', + relatedId: 'col-1', + colonyId: 'col-1', + reasonCode: 'COLONY_ENDED', + evidence: { + colonyId: 'col-1', + status: 'DECEASED', + endedOn: '2027-03-12', + via: 'WINTER_CHECK', + openTaskIds: ['task-1', 'task-2'], + queenId: null, + }, + requiredPermission: 'colonies:read', + anchor: 'ended-2027-03-12-ev-1', + sourceEventId: 'ev-1', + notify: false, + }); + }); + + it('proposes CLEANUP when an ACTIVE queen was released, even without tasks', async () => { + await handlers.colonyEnded( + endedEvent({ releasedQueenId: 'queen-1' }), + asTx(), + ); + + expect(tx.queen.findUnique).toHaveBeenCalledWith({ + where: { id: 'queen-1' }, + select: { status: true }, + }); + expect(proposed().evidence).toMatchObject({ + openTaskIds: [], + queenId: 'queen-1', + }); + }); + + it('proposes nothing when nothing is left to tidy up', async () => { + tx.queen.findUnique.mockResolvedValue({ status: 'DECEASED' }); + + await handlers.colonyEnded( + endedEvent({ releasedQueenId: 'queen-1' }), + asTx(), + ); + + expect(suggestions.obsoleteForColonyIn).toHaveBeenCalledTimes(1); + expect(suggestions.proposeIn).not.toHaveBeenCalled(); + }); + + it('only turns suggestions OBSOLETE when the rule is off', async () => { + tx.automationRule.findUnique.mockResolvedValue({ + id: 'rule-1', + active: false, + config: {}, + actionTemplate: null, + }); + tx.task.findMany.mockResolvedValue([{ id: 'task-1' }]); + + await handlers.colonyEnded(endedEvent(), asTx()); + + expect(suggestions.obsoleteForColonyIn).toHaveBeenCalledTimes(1); + expect(suggestions.proposeIn).not.toHaveBeenCalled(); + }); + + it('does nothing for a colony that is gone', async () => { + (lockRowForUpdate as jest.Mock).mockResolvedValue(false); + + await handlers.colonyEnded(endedEvent(), asTx()); + + expect(suggestions.obsoleteForColonyIn).not.toHaveBeenCalled(); + expect(suggestions.proposeIn).not.toHaveBeenCalled(); + }); + + it('reads the end day from the payload; an event queued before the change falls back to its own day', async () => { + tx.task.findMany.mockResolvedValue([{ id: 'task-1' }]); + + await handlers.colonyEnded( + endedEvent({ endedOn: undefined, via: undefined }), + asTx(), + ); + + expect(proposed()).toMatchObject({ + anchor: 'ended-2027-03-13-ev-1', + evidence: expect.objectContaining({ + endedOn: '2027-03-13', + via: 'EDIT', + }) as object, + }); + }); +}); diff --git a/ApiCore-Backend/src/suggestions/suggestions.handlers.ts b/ApiCore-Backend/src/suggestions/suggestions.handlers.ts new file mode 100644 index 0000000..3d79c2e --- /dev/null +++ b/ApiCore-Backend/src/suggestions/suggestions.handlers.ts @@ -0,0 +1,99 @@ +import { Injectable } from '@nestjs/common'; +import type { Prisma } from '../../generated/prisma'; +import { readRule } from '../automation/rule-config'; +import { berlinDayOf } from '../common/dates'; +import type { AnyDomainEventEnvelope } from '../domain-events/domain-event-types'; +import { OnDomainEvent } from '../domain-events/on-domain-event.decorator'; +import { lockRowForUpdate } from '../prisma/row-lock'; +import { SuggestionsService } from './suggestions.service'; + +type Tx = Prisma.TransactionClient; + +const OPEN_TASK_STATUSES = ['OPEN', 'IN_PROGRESS'] as const; + +/** + * Reactions of the suggestions to what happens to a colony. A colony that + * ends takes no more suggestions (they turn OBSOLETE) and may leave work + * behind - open tasks, a queen still recorded as active - for which one + * clean-up suggestion is proposed. Writes only through `tx`. + */ +@Injectable() +export class SuggestionEventHandlers { + constructor(private readonly suggestions: SuggestionsService) {} + + @OnDomainEvent('ColonyEnded', 'suggestions.colony-ended') + async colonyEnded( + event: AnyDomainEventEnvelope<'ColonyEnded'>, + tx: Tx, + ): Promise { + const colonyId = event.aggregateId; + // Colony first, then the suggestions (the order of every proposer). + if (!(await lockRowForUpdate(tx, 'Colony', colonyId))) { + return; + } + await this.suggestions.obsoleteForColonyIn(tx, colonyId); + const rule = await readRule(tx, 'COLONY_ENDED_CLEANUP'); + if (!rule.active) { + return; + } + // Events recorded before endedOn/via/releasedQueenId existed carry none + // of them: the end day falls back to the day the event was recorded, the + // way to 'EDIT' (unknown; only the wording of the text depends on it). + const { status, releasedQueenId } = event.payload; + const endedOn = event.payload.endedOn ?? berlinDayOf(event.occurredAt); + const via = event.payload.via ?? 'EDIT'; + const openTasks = await tx.task.findMany({ + where: { + relatedType: 'COLONY', + relatedId: colonyId, + status: { in: [...OPEN_TASK_STATUSES] }, + }, + select: { id: true }, + orderBy: { id: 'asc' }, + }); + const queenId = await this.activeReleasedQueenIn(tx, releasedQueenId); + if (openTasks.length === 0 && queenId === null) { + return; + } + await this.suggestions.proposeIn(tx, { + ruleKey: 'COLONY_ENDED_CLEANUP', + source: 'CHAIN', + mode: 'DO', + intent: 'CLEANUP', + priority: 'NORMAL', + relatedType: 'COLONY', + relatedId: colonyId, + colonyId, + reasonCode: 'COLONY_ENDED', + evidence: { + colonyId, + status, + endedOn, + via, + openTaskIds: openTasks.map((task) => task.id), + queenId, + }, + requiredPermission: 'colonies:read', + // One anchor per end event: a second end after a reactivation (even on + // the same day) is a new end and gets its own clean-up. + anchor: `ended-${endedOn}-${event.id}`, + sourceEventId: event.id, + notify: false, + }); + } + + /** The released queen's id while she is still recorded as ACTIVE, else null. */ + private async activeReleasedQueenIn( + tx: Tx, + queenId: string | null | undefined, + ): Promise { + if (!queenId) { + return null; + } + const queen = await tx.queen.findUnique({ + where: { id: queenId }, + select: { status: true }, + }); + return queen?.status === 'ACTIVE' ? queenId : null; + } +} diff --git a/ApiCore-Backend/src/suggestions/suggestions.module.ts b/ApiCore-Backend/src/suggestions/suggestions.module.ts index af35e97..370f071 100644 --- a/ApiCore-Backend/src/suggestions/suggestions.module.ts +++ b/ApiCore-Backend/src/suggestions/suggestions.module.ts @@ -5,6 +5,7 @@ import { TasksModule } from '../tasks/tasks.module'; import { ChainEventHandlers } from './chains/chains.handlers'; import { SuggestionViewsService } from './suggestion-views.service'; import { SuggestionsController } from './suggestions.controller'; +import { SuggestionEventHandlers } from './suggestions.handlers'; import { SuggestionsService } from './suggestions.service'; /** @@ -14,7 +15,12 @@ import { SuggestionsService } from './suggestions.service'; @Module({ imports: [TasksModule, AuditModule, NotificationsModule], controllers: [SuggestionsController], - providers: [SuggestionsService, SuggestionViewsService, ChainEventHandlers], + providers: [ + SuggestionsService, + SuggestionViewsService, + ChainEventHandlers, + SuggestionEventHandlers, + ], exports: [SuggestionsService, SuggestionViewsService], }) export class SuggestionsModule {} diff --git a/ApiCore-Backend/src/suggestions/texts/cleanup-texts.ts b/ApiCore-Backend/src/suggestions/texts/cleanup-texts.ts index df27da0..853abde 100644 --- a/ApiCore-Backend/src/suggestions/texts/cleanup-texts.ts +++ b/ApiCore-Backend/src/suggestions/texts/cleanup-texts.ts @@ -1,4 +1,68 @@ import type { SuggestionTextRegister } from '../suggestion-texts'; +import { formatDayDe } from './format-day'; + +const ENDED_VERBS: Readonly> = { + DECEASED: 'ist eingegangen', + SOLD: 'wurde verkauft', + DISSOLVED: 'wurde aufgelöst', + MERGED: 'wurde vereinigt', +}; + +function openTasksSentence(count: number): string { + if (count === 0) { + return ''; + } + return count === 1 + ? ' Eine Aufgabe ist noch offen.' + : ` ${count} Aufgaben sind noch offen.`; +} /** Texts of the clean-up suggestions, by reason code. */ -export const CLEANUP_TEXTS: SuggestionTextRegister = {}; +export const CLEANUP_TEXTS: SuggestionTextRegister = { + COLONY_ENDED: { + ruleKey: 'COLONY_ENDED_CLEANUP', + example: { + colonyId: 'col-1', + status: 'DECEASED', + endedOn: '2027-03-12', + via: 'WINTER_CHECK', + openTaskIds: ['task-1', 'task-2'], + queenId: 'queen-1', + }, + render: (evidence, lookup) => { + const colonyId = evidence.colonyId; + const code = + (typeof colonyId === 'string' ? lookup.colonyCode(colonyId) : null) ?? + 'Volk'; + const verb = + typeof evidence.status === 'string' && + Object.hasOwn(ENDED_VERBS, evidence.status) + ? ENDED_VERBS[evidence.status] + : 'ist beendet'; + const day = + typeof evidence.endedOn === 'string' + ? formatDayDe(evidence.endedOn) + : ''; + const origin = + day === '' + ? '' + : evidence.via === 'WINTER_CHECK' + ? ` (Auswinterung ${day})` + : ` (${day})`; + const taskCount = Array.isArray(evidence.openTaskIds) + ? evidence.openTaskIds.length + : 0; + const queenId = evidence.queenId; + const reasons = [ + `${code} ${verb}${origin}.${openTasksSentence(taskCount)}`, + ]; + if (typeof queenId === 'string') { + const queenCode = lookup.queenCode(queenId); + reasons.push( + `${queenCode === null ? 'Die Königin' : `Königin ${queenCode}`} ist noch als aktiv erfasst.`, + ); + } + return { title: 'Aufräumen', reasons }; + }, + }, +}; diff --git a/ApiCore-Backend/src/tasks/tasks.service.spec.ts b/ApiCore-Backend/src/tasks/tasks.service.spec.ts index 5511ac1..5e1277a 100644 --- a/ApiCore-Backend/src/tasks/tasks.service.spec.ts +++ b/ApiCore-Backend/src/tasks/tasks.service.spec.ts @@ -7,6 +7,7 @@ import { DomainEventsService } from '../domain-events/domain-events.service'; describe('TasksService', () => { let service: TasksService; let tx: { + $queryRaw: jest.Mock; task: { create: jest.Mock; update: jest.Mock; @@ -51,6 +52,7 @@ describe('TasksService', () => { beforeEach(() => { tx = { + $queryRaw: jest.fn().mockResolvedValue([{ id: 'task-1' }]), task: { // The stored row: defaults filled in like the database does. create: jest.fn(({ data }: { data: Record }) => @@ -440,6 +442,123 @@ describe('TasksService', () => { }); }); + describe('completeIn', () => { + const open = { ...task, relatedType: 'COLONY', relatedId: 'colony-1' }; + const done = { ...open, status: 'DONE', completedAt: new Date() }; + + it('completeIn locks the task, writes the audit entry and TaskCompleted once, and complete() uses it', async () => { + tx.task.findUnique.mockResolvedValue(open); + tx.task.updateMany.mockResolvedValue({ count: 1 }); + tx.task.findUniqueOrThrow.mockResolvedValue(done); + + await expect( + service.completeIn(tx as never, 'task-1', 'user-1'), + ).resolves.toBe(done); + + expect(tx.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan( + tx.task.findUnique.mock.invocationCallOrder[0], + ); + expect(auditLog.record).toHaveBeenCalledTimes(1); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ + entityType: 'Task', + action: 'UPDATE', + beforeData: open, + afterData: done, + }), + tx, + ); + expect(events.record).toHaveBeenCalledTimes(1); + expect(events.record).toHaveBeenCalledWith( + tx, + expect.objectContaining({ type: 'TaskCompleted' }), + ); + expect(prisma.$transaction).not.toHaveBeenCalled(); + + // complete() is the same path: lock first, then the same writes. + tx.$queryRaw.mockClear(); + tx.task.findUnique.mockResolvedValue(open); + await service.complete('task-1', 'user-1'); + expect(tx.$queryRaw).toHaveBeenCalledTimes(1); + expect(events.record).toHaveBeenCalledTimes(2); + }); + + it('returns an already finished task unchanged, without audit entry or event', async () => { + tx.task.findUnique.mockResolvedValue(done); + + await expect( + service.completeIn(tx as never, 'task-1', 'user-1'), + ).resolves.toBe(done); + + expect(tx.task.updateMany).not.toHaveBeenCalled(); + expect(auditLog.record).not.toHaveBeenCalled(); + expect(events.record).not.toHaveBeenCalled(); + }); + + it('refuses the root client', async () => { + await expect( + service.completeIn(prisma as never, 'task-1', 'user-1'), + ).rejects.toThrow(); + }); + }); + + describe('cancelIn', () => { + const open = { ...task, dueDate: new Date('2027-03-20T00:00:00.000Z') }; + const cancelled = { ...open, status: 'CANCELLED', completedAt: null }; + + it('locks the task, cancels it and records audit entry and TaskUpdated', async () => { + tx.task.findUnique.mockResolvedValue(open); + tx.task.update.mockResolvedValue(cancelled); + + await expect( + service.cancelIn(tx as never, 'task-1', 'user-1'), + ).resolves.toBe(cancelled); + + expect(tx.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan( + tx.task.findUnique.mock.invocationCallOrder[0], + ); + expect(tx.task.update).toHaveBeenCalledWith({ + where: { id: 'task-1' }, + data: { status: 'CANCELLED', completedAt: null }, + }); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ + entityType: 'Task', + action: 'UPDATE', + beforeData: open, + afterData: cancelled, + }), + tx, + ); + expect(events.record).toHaveBeenCalledWith(tx, { + type: 'TaskUpdated', + aggregateType: 'Task', + aggregateId: 'task-1', + actorUserId: 'user-1', + payload: { status: 'CANCELLED', dueDate: '2027-03-20' }, + }); + }); + + it('leaves a task that is cancelled already untouched', async () => { + tx.task.findUnique.mockResolvedValue(cancelled); + + await expect( + service.cancelIn(tx as never, 'task-1', 'user-1'), + ).resolves.toBe(cancelled); + + expect(tx.task.update).not.toHaveBeenCalled(); + expect(events.record).not.toHaveBeenCalled(); + }); + + it('answers 404 for an unknown task', async () => { + tx.task.findUnique.mockResolvedValue(null); + + await expect( + service.cancelIn(tx as never, 'gone', 'user-1'), + ).rejects.toBeInstanceOf(NotFoundException); + }); + }); + describe('generateFromTemplates', () => { const now = new Date('2026-05-20T08:00:00.000Z'); diff --git a/ApiCore-Backend/src/tasks/tasks.service.ts b/ApiCore-Backend/src/tasks/tasks.service.ts index f8381c9..a8e234e 100644 --- a/ApiCore-Backend/src/tasks/tasks.service.ts +++ b/ApiCore-Backend/src/tasks/tasks.service.ts @@ -1,6 +1,7 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { assertTransactionClient, DbClient } from '../prisma/db-client'; +import { lockRowForUpdate } from '../prisma/row-lock'; import { AuditLogService } from '../audit/audit-log.service'; import { DomainEventsService } from '../domain-events/domain-events.service'; import { utcDay } from '../domain-events/domain-event-types'; @@ -212,32 +213,89 @@ export class TasksService { } async complete(id: string, actingUserId: string): Promise { - return this.prisma.$transaction(async (tx) => { - const before = await this.findIn(tx, id); - // A second tap on "Erledigt" (or a retried request) must not move the - // completion time or announce the completion a second time. - if (before.status === 'DONE') { - return before; - } - const completed = await this.markDoneIn(tx, id); - const task = await tx.task.findUniqueOrThrow({ where: { id } }); - if (!completed) { - return task; - } - await this.auditLog.record( - { - entityType: 'Task', - entityId: id, - action: 'UPDATE', - userId: actingUserId, - beforeData: before, - afterData: task, - }, - tx, - ); - await this.recordCompleted(tx, task, actingUserId); + return this.prisma.$transaction((tx) => + this.completeIn(tx, id, actingUserId), + ); + } + + /** + * "Erledigt" on the caller's transaction: locks the task, reads it + * afterwards, and writes audit entry and TaskCompleted only on the + * transition (a task that is DONE already comes back unchanged). The one + * path of complete() and of the clean-up of an ended colony. + */ + async completeIn( + tx: Prisma.TransactionClient, + id: string, + actingUserId: string, + ): Promise { + assertTransactionClient(tx, 'TasksService.completeIn'); + await lockRowForUpdate(tx, 'Task', id); + const before = await this.findIn(tx, id); + // A second tap on "Erledigt" (or a retried request) must not move the + // completion time or announce the completion a second time. + if (before.status === 'DONE') { + return before; + } + const completed = await this.markDoneIn(tx, id); + const task = await tx.task.findUniqueOrThrow({ where: { id } }); + if (!completed) { return task; + } + await this.auditLog.record( + { + entityType: 'Task', + entityId: id, + action: 'UPDATE', + userId: actingUserId, + beforeData: before, + afterData: task, + }, + tx, + ); + await this.recordCompleted(tx, task, actingUserId); + return task; + } + + /** + * CANCELLED on the caller's transaction: locks the task, reads it + * afterwards, writes the audit entry and TaskUpdated (the calendar entry + * leaves). A task that is cancelled already comes back unchanged. + */ + async cancelIn( + tx: Prisma.TransactionClient, + id: string, + actingUserId: string, + ): Promise { + assertTransactionClient(tx, 'TasksService.cancelIn'); + await lockRowForUpdate(tx, 'Task', id); + const before = await this.findIn(tx, id); + if (before.status === 'CANCELLED') { + return before; + } + const task = await tx.task.update({ + where: { id }, + data: { status: 'CANCELLED', completedAt: null }, }); + await this.auditLog.record( + { + entityType: 'Task', + entityId: id, + action: 'UPDATE', + userId: actingUserId, + beforeData: before, + afterData: task, + }, + tx, + ); + await this.events.record(tx, { + type: 'TaskUpdated', + aggregateType: 'Task', + aggregateId: id, + payload: { status: task.status, dueDate: dueDay(task.dueDate) }, + actorUserId: actingUserId, + }); + return task; } /** diff --git a/ApiCore-Backend/src/winter-checks/winter-checks.service.spec.ts b/ApiCore-Backend/src/winter-checks/winter-checks.service.spec.ts index 4648c82..8100424 100644 --- a/ApiCore-Backend/src/winter-checks/winter-checks.service.spec.ts +++ b/ApiCore-Backend/src/winter-checks/winter-checks.service.spec.ts @@ -25,6 +25,7 @@ describe('WinterChecksService', () => { $queryRaw: jest.Mock; winterCheck: { create: jest.Mock }; colony: { update: jest.Mock; findUnique: jest.Mock }; + queenColonyHistory: { updateMany: jest.Mock }; auditLog: { create: jest.Mock }; }; let prisma: { @@ -50,6 +51,7 @@ describe('WinterChecksService', () => { .fn() .mockResolvedValue({ ...livingColony, status: 'DECEASED' }), }, + queenColonyHistory: { updateMany: jest.fn() }, auditLog: { create: jest.fn() }, }; prisma = { @@ -112,11 +114,51 @@ describe('WinterChecksService', () => { expect.objectContaining({ type: 'ColonyEnded', aggregateId: 'c1', - payload: { status: 'DECEASED' }, + payload: { + status: 'DECEASED', + endedOn: '2027-03-20', + releasedQueenId: null, + via: 'WINTER_CHECK', + }, }), ]); }); + it('a winter loss with markColonyDeceased releases the queen with the check date', async () => { + tx.colony.findUnique.mockResolvedValue({ + ...livingColony, + currentQueenId: 'q1', + }); + + await service.create(lostCheck, 'u1'); + + const endDate = new Date('2027-03-20T00:00:00.000Z'); + expect(tx.queenColonyHistory.updateMany).toHaveBeenCalledWith({ + where: { queenId: 'q1', colonyId: 'c1', endDate: null }, + data: { endDate }, + }); + expect(tx.colony.update).toHaveBeenCalledWith({ + where: { id: 'c1' }, + data: { currentQueenId: null }, + }); + expect(events.recordAll).toHaveBeenCalledWith(tx, [ + expect.anything(), + expect.objectContaining({ + type: 'ColonyEnded', + payload: expect.objectContaining({ + releasedQueenId: 'q1', + endedOn: '2027-03-20', + }) as object, + }), + ]); + }); + + it('releases no queen when the colony is not marked deceased', async () => { + await service.create({ ...lostCheck, markColonyDeceased: false }, 'u1'); + + expect(tx.queenColonyHistory.updateMany).not.toHaveBeenCalled(); + }); + it('records no colony event when the colony is not to be marked deceased', async () => { await service.create({ ...lostCheck, markColonyDeceased: false }, 'u1'); diff --git a/ApiCore-Backend/src/winter-checks/winter-checks.service.ts b/ApiCore-Backend/src/winter-checks/winter-checks.service.ts index 8f96d5f..b66eefe 100644 --- a/ApiCore-Backend/src/winter-checks/winter-checks.service.ts +++ b/ApiCore-Backend/src/winter-checks/winter-checks.service.ts @@ -10,6 +10,8 @@ import { DomainEventsService } from '../domain-events/domain-events.service'; import { Colony, Prisma, WinterCheck } from '../../generated/prisma'; import { isLivingColonyStatus } from '../colonies/colony-status'; import { colonyTransitionEvents, lockColony } from '../colonies/colony-events'; +import { releaseQueenIn } from '../colonies/colony-lifecycle'; +import { utcDay } from '../domain-events/domain-event-types'; import { CreateWinterCheckDto } from './dto/create-winter-check.dto'; import { UpdateWinterCheckDto } from './dto/update-winter-check.dto'; import { WinterCheckQueryDto } from './dto/winter-check-query.dto'; @@ -234,13 +236,19 @@ export class WinterChecksService { // must come from the row as it is now, not from an earlier read. const colony = await lockColony(tx, colonyId); if (!isLivingColonyStatus(colony.status)) return; + // The queen leaves with the colony, on the day of the Auswinterung. + const releasedQueenId = await releaseQueenIn(tx, colony.id, check.date); const after = await tx.colony.update({ where: { id: colony.id }, data: { status: 'DECEASED', deactivatedAt: check.date }, }); await this.events.recordAll( tx, - colonyTransitionEvents(colony, after, actingUserId), + colonyTransitionEvents(colony, after, actingUserId, { + endedOn: utcDay(check.date), + releasedQueenId, + via: 'WINTER_CHECK', + }), ); await this.auditLog.record( { diff --git a/ApiCore-Backend/src/withdrawal/withdrawal-check.service.spec.ts b/ApiCore-Backend/src/withdrawal/withdrawal-check.service.spec.ts new file mode 100644 index 0000000..ae5a8a1 --- /dev/null +++ b/ApiCore-Backend/src/withdrawal/withdrawal-check.service.spec.ts @@ -0,0 +1,374 @@ +import type { DbClient } from '../prisma/db-client'; +import type { Prisma } from '../../generated/prisma'; +import type { NotificationsService } from '../notifications/notifications.service'; +import { + WithdrawalCheckService, + type WithdrawalConflict, +} from './withdrawal-check.service'; + +interface TreatmentRow { + id: string; + colonyId: string; + product: string; + date: Date; + startDate: Date | null; + endDate: Date | null; + withdrawalPeriodDays: number | null; +} + +const treatment = (overrides: Partial): TreatmentRow => ({ + id: 't1', + colonyId: 'c1', + product: 'Oxuvar', + date: new Date('2026-07-31T00:00:00.000Z'), + startDate: null, + endDate: null, + withdrawalPeriodDays: 20, + ...overrides, +}); + +const conflict = ( + overrides: Partial = {}, +): WithdrawalConflict => ({ + harvestId: 'h1', + harvestedOn: '2026-08-10', + colonyId: 'c1', + treatmentId: 't1', + product: 'Oxuvar', + untilDay: '2026-08-20', + ...overrides, +}); + +describe('WithdrawalCheckService', () => { + let db: { + harvest: { findMany: jest.Mock }; + treatment: { findMany: jest.Mock; findUnique: jest.Mock }; + colony: { findMany: jest.Mock }; + honeyBatch: { findUnique: jest.Mock }; + bottlingBatch: { findMany: jest.Mock }; + }; + let notifications: { recordForPermission: jest.Mock }; + let service: WithdrawalCheckService; + + beforeEach(() => { + db = { + harvest: { findMany: jest.fn().mockResolvedValue([]) }, + treatment: { + findMany: jest.fn().mockResolvedValue([]), + findUnique: jest.fn().mockResolvedValue(null), + }, + colony: { + findMany: jest.fn().mockResolvedValue([{ id: 'c1', code: 'B3' }]), + }, + honeyBatch: { findUnique: jest.fn() }, + bottlingBatch: { findMany: jest.fn().mockResolvedValue([]) }, + }; + notifications = { recordForPermission: jest.fn().mockResolvedValue([]) }; + service = new WithdrawalCheckService( + notifications as unknown as NotificationsService, + ); + }); + + const asDb = () => db as unknown as DbClient; + const harvestOn = (id: string, day: string, colonyIds: string[]) => ({ + id, + date: new Date(`${day}T00:00:00.000Z`), + colonies: colonyIds.map((colonyId) => ({ colonyId })), + }); + + describe('conflictsForHarvests', () => { + it('finds the treatment whose window includes the harvest date per colony', async () => { + db.harvest.findMany.mockResolvedValue([ + harvestOn('h1', '2026-08-10', ['c1', 'c2']), + ]); + db.treatment.findMany.mockResolvedValue([ + treatment({ id: 't1', colonyId: 'c1' }), + // Window ended before the harvest: no conflict for c2. + treatment({ + id: 't2', + colonyId: 'c2', + date: new Date('2026-06-01T00:00:00.000Z'), + withdrawalPeriodDays: 10, + }), + ]); + + const conflicts = await service.conflictsForHarvests(asDb(), ['h1']); + + expect(conflicts).toEqual([conflict()]); + }); + + it('treats the first harvestable day as free and the treatment day as covered', async () => { + // t1: 2026-07-31 + 20 days = first harvestable day 2026-08-20. + db.harvest.findMany.mockResolvedValue([ + harvestOn('h-start', '2026-07-31', ['c1']), + harvestOn('h-last', '2026-08-19', ['c1']), + harvestOn('h-free', '2026-08-20', ['c1']), + harvestOn('h-before', '2026-07-30', ['c1']), + ]); + db.treatment.findMany.mockResolvedValue([treatment({})]); + + const conflicts = await service.conflictsForHarvests(asDb(), [ + 'h-start', + 'h-last', + 'h-free', + 'h-before', + ]); + + expect(conflicts.map((c) => c.harvestId)).toEqual(['h-start', 'h-last']); + }); + + it('starts the window at startDate when it lies before the treatment date', async () => { + db.harvest.findMany.mockResolvedValue([ + harvestOn('h1', '2026-07-25', ['c1']), + ]); + db.treatment.findMany.mockResolvedValue([ + treatment({ startDate: new Date('2026-07-24T00:00:00.000Z') }), + ]); + + const conflicts = await service.conflictsForHarvests(asDb(), ['h1']); + + expect(conflicts).toHaveLength(1); + }); + + it('reports one conflict per harvest and colony, the one that lasts longest', async () => { + db.harvest.findMany.mockResolvedValue([ + harvestOn('h1', '2026-08-10', ['c1']), + ]); + db.treatment.findMany.mockResolvedValue([ + treatment({ id: 't-short', withdrawalPeriodDays: 12 }), + treatment({ + id: 't-long', + withdrawalPeriodDays: 30, + product: 'Apivar', + }), + ]); + + const conflicts = await service.conflictsForHarvests(asDb(), ['h1']); + + expect(conflicts).toEqual([ + conflict({ + treatmentId: 't-long', + product: 'Apivar', + untilDay: '2026-08-30', + }), + ]); + }); + + it('ignores treatments without a withdrawal period and colonies without any', async () => { + db.harvest.findMany.mockResolvedValue([ + harvestOn('h1', '2026-08-10', ['c1']), + harvestOn('h2', '2026-08-10', []), + ]); + db.treatment.findMany.mockResolvedValue([ + treatment({ withdrawalPeriodDays: null }), + ]); + + expect(await service.conflictsForHarvests(asDb(), ['h1', 'h2'])).toEqual( + [], + ); + }); + + it('answers at once without ids and does not read anything', async () => { + expect(await service.conflictsForHarvests(asDb(), [])).toEqual([]); + expect(db.harvest.findMany).not.toHaveBeenCalled(); + }); + + it('compares the Berlin calendar day of the harvest', async () => { + // 2026-08-19T22:30Z is 00:30 on 2026-08-20 in Berlin: already free. + db.harvest.findMany.mockResolvedValue([ + { + id: 'h1', + date: new Date('2026-08-19T22:30:00.000Z'), + colonies: [{ colonyId: 'c1' }], + }, + ]); + db.treatment.findMany.mockResolvedValue([treatment({})]); + + expect(await service.conflictsForHarvests(asDb(), ['h1'])).toEqual([]); + }); + }); + + describe('conflictsForTreatment', () => { + it('finds the harvests of the colony inside the window, entered before the treatment', async () => { + db.treatment.findUnique.mockResolvedValue(treatment({})); + // Candidates (date range) first, then the harvests re-read for the check. + db.harvest.findMany + .mockResolvedValueOnce([ + { id: 'h-in', date: new Date('2026-08-10T00:00:00.000Z') }, + { id: 'h-free', date: new Date('2026-08-20T00:00:00.000Z') }, + { id: 'h-before', date: new Date('2026-07-30T00:00:00.000Z') }, + ]) + .mockResolvedValueOnce([harvestOn('h-in', '2026-08-10', ['c1', 'c2'])]); + db.treatment.findMany.mockResolvedValue([treatment({})]); + + const conflicts = await service.conflictsForTreatment(asDb(), 't1'); + + expect(db.harvest.findMany).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + where: { + colonies: { some: { colonyId: 'c1' } }, + date: { + gte: new Date('2026-07-30T00:00:00.000Z'), + lt: new Date('2026-08-21T00:00:00.000Z'), + }, + }, + }), + ); + expect(db.harvest.findMany).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ where: { id: { in: ['h-in'] } } }), + ); + // Only the colony of the treatment. + expect(conflicts).toEqual([conflict({ harvestId: 'h-in' })]); + }); + + it('answers nothing without a withdrawal span or for an unknown treatment', async () => { + db.treatment.findUnique.mockResolvedValueOnce(null); + expect(await service.conflictsForTreatment(asDb(), 'x')).toEqual([]); + db.treatment.findUnique.mockResolvedValueOnce( + treatment({ withdrawalPeriodDays: null }), + ); + expect(await service.conflictsForTreatment(asDb(), 't1')).toEqual([]); + expect(db.harvest.findMany).not.toHaveBeenCalled(); + }); + }); + + describe('conflictsForHoneyBatch', () => { + it('follows batch, extraction and harvests', async () => { + db.honeyBatch.findUnique.mockResolvedValue({ + extraction: { harvests: [{ harvestId: 'h1' }, { harvestId: 'h2' }] }, + }); + db.harvest.findMany.mockResolvedValue([ + harvestOn('h1', '2026-08-10', ['c1']), + ]); + db.treatment.findMany.mockResolvedValue([treatment({})]); + + const conflicts = await service.conflictsForHoneyBatch(asDb(), 'b1'); + + expect(db.harvest.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: { in: ['h1', 'h2'] } }, + }), + ); + expect(conflicts).toEqual([conflict()]); + }); + + it('answers nothing for an unknown batch', async () => { + db.honeyBatch.findUnique.mockResolvedValue(null); + + expect(await service.conflictsForHoneyBatch(asDb(), 'nope')).toEqual([]); + }); + }); + + describe('warningsFor', () => { + it('words the warning with colony code, product and the end day', async () => { + const warnings = await service.warningsFor(asDb(), [conflict()]); + + expect(warnings).toEqual([ + { + code: 'WITHDRAWAL_ACTIVE', + severity: 'WARNUNG', + message: 'B3: Wartezeit nach Oxuvar endet am 20.08.2026.', + refs: [ + { type: 'Colony', id: 'c1' }, + { type: 'Treatment', id: 't1' }, + { type: 'Harvest', id: 'h1' }, + ], + }, + ]); + }); + + it('answers nothing without reading when there is no conflict', async () => { + expect(await service.warningsFor(asDb(), [])).toEqual([]); + expect(db.colony.findMany).not.toHaveBeenCalled(); + }); + }); + + describe('notifyConflictsIn', () => { + const tx = () => db as unknown as Prisma.TransactionClient; + + it('notifies readers of harvests and treatments once per harvest and colony', async () => { + notifications.recordForPermission.mockResolvedValue([{ id: 'n1' }]); + + const count = await service.notifyConflictsIn(tx(), [conflict()]); + + expect(count).toBe(1); + expect(notifications.recordForPermission).toHaveBeenCalledWith( + ['harvests:read', 'treatments:read'], + { + priority: 'WARNUNG', + category: 'WITHDRAWAL', + message: + 'Wartezeit: B3 am 10.08. geerntet, Wartezeit nach Oxuvar endet am 20.08.2026.', + relatedType: 'Harvest', + relatedId: 'h1', + link: '/harvests/h1', + dedupeKeyPrefix: 'withdrawal-conflict:h1:c1', + }, + db, + ); + }); + + it('names the bottling lots when the harvest was already bottled', async () => { + db.bottlingBatch.findMany.mockResolvedValue([ + { + lotNumber: 'L-2', + honeyBatch: { extraction: { harvests: [{ harvestId: 'h1' }] } }, + }, + { + lotNumber: 'L-1', + honeyBatch: { + extraction: { + harvests: [{ harvestId: 'h1' }, { harvestId: 'h9' }], + }, + }, + }, + { + lotNumber: 'L-other', + honeyBatch: { extraction: { harvests: [{ harvestId: 'h9' }] } }, + }, + ]); + + await service.notifyConflictsIn(tx(), [conflict()]); + + const [, input] = notifications.recordForPermission.mock.calls[0] as [ + unknown, + { message: string; dedupeKeyPrefix: string }, + ]; + expect(input.message).toBe( + 'Wartezeit: B3 am 10.08. geerntet, Wartezeit nach Oxuvar endet am 20.08.2026. Bereits abgefüllt: Los L-1, L-2.', + ); + // Same key as without the lots: one notification per harvest and colony. + expect(input.dedupeKeyPrefix).toBe('withdrawal-conflict:h1:c1'); + }); + + it('counts only the rows that were really written', async () => { + notifications.recordForPermission + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ id: 'n1' }, { id: 'n2' }]); + + const count = await service.notifyConflictsIn(tx(), [ + conflict(), + conflict({ harvestId: 'h2' }), + ]); + + expect(count).toBe(2); + }); + + it('does nothing without conflicts', async () => { + expect(await service.notifyConflictsIn(tx(), [])).toBe(0); + expect(notifications.recordForPermission).not.toHaveBeenCalled(); + }); + + it('refuses the root client', async () => { + const root = { + $connect: jest.fn(), + } as unknown as Prisma.TransactionClient; + + await expect( + service.notifyConflictsIn(root, [conflict()]), + ).rejects.toThrow('needs the transaction client'); + }); + }); +}); diff --git a/ApiCore-Backend/src/withdrawal/withdrawal-check.service.ts b/ApiCore-Backend/src/withdrawal/withdrawal-check.service.ts new file mode 100644 index 0000000..de954e3 --- /dev/null +++ b/ApiCore-Backend/src/withdrawal/withdrawal-check.service.ts @@ -0,0 +1,333 @@ +import { Injectable } from '@nestjs/common'; +import type { Prisma } from '../../generated/prisma'; +import { addDays, berlinDayOf, dayToDate } from '../common/dates'; +import { + WARNING_SEVERITY, + type PlausibilityWarning, +} from '../common/warnings/plausibility-warning'; +import { NotificationsService } from '../notifications/notifications.service'; +import { assertTransactionClient, type DbClient } from '../prisma/db-client'; +import { formatDayDe } from '../suggestions/texts/format-day'; +import { + isInWindow, + withdrawalWindowOf, + type WithdrawalWindow, +} from '../treatments/withdrawal-window'; +import { withdrawalSpanOf } from './withdrawal-span'; + +/** A harvest taken from a colony while a treatment's withdrawal period ran. */ +export interface WithdrawalConflict { + harvestId: string; + /** Berlin calendar day of the harvest, 'YYYY-MM-DD'. */ + harvestedOn: string; + colonyId: string; + treatmentId: string; + product: string; + /** First harvestable day again, 'YYYY-MM-DD'. */ + untilDay: string; +} + +const CONFLICT_RECIPIENT_PERMISSIONS = [ + 'harvests:read', + 'treatments:read', +] as const; + +/** The window of a colony that covers `day` for the longest. */ +function longestCovering( + windows: readonly WithdrawalWindow[], + day: string, +): WithdrawalWindow | null { + const covering = windows.filter((window) => isInWindow(window, day)); + if (covering.length === 0) { + return null; + } + return covering.reduce((best, window) => + window.untilDay > best.untilDay || + (window.untilDay === best.untilDay && window.treatmentId < best.treatmentId) + ? window + : best, + ); +} + +/** + * Checks harvests against the withdrawal windows (Wartezeit) of their + * colonies. Only the window helper decides what a window is + * (treatments/withdrawal-window.ts); this service never answers 4xx, it + * reports conflicts, words them as warnings and notifies once per harvest + * and colony. + */ +@Injectable() +export class WithdrawalCheckService { + constructor(private readonly notifications: NotificationsService) {} + + /** One conflict per harvest and colony, ordered by harvest, then colony. */ + async conflictsForHarvests( + db: DbClient, + harvestIds: readonly string[], + ): Promise { + if (harvestIds.length === 0) { + return []; + } + const harvests = await db.harvest.findMany({ + where: { id: { in: [...harvestIds] } }, + select: { + id: true, + date: true, + colonies: { select: { colonyId: true } }, + }, + orderBy: { id: 'asc' }, + }); + const colonyIds = [ + ...new Set( + harvests.flatMap((harvest) => harvest.colonies.map((c) => c.colonyId)), + ), + ].sort(); + if (colonyIds.length === 0) { + return []; + } + const windowsByColony = await this.windowsOf(db, colonyIds); + + return harvests.flatMap((harvest) => { + const harvestedOn = berlinDayOf(harvest.date); + return harvest.colonies + .map((entry) => entry.colonyId) + .sort() + .flatMap((colonyId) => { + const window = longestCovering( + windowsByColony.get(colonyId) ?? [], + harvestedOn, + ); + return window === null + ? [] + : [ + { + harvestId: harvest.id, + harvestedOn, + colonyId, + treatmentId: window.treatmentId, + product: window.product, + untilDay: window.untilDay, + }, + ]; + }); + }); + } + + /** + * The treatment seen from the other side: harvests of its colony that were + * entered before the treatment (or before its days were extended) and lie + * in its window. Bottlings of batches made from those harvests carry the + * same (harvest, colony) keys, so they need no extra lookup. Only the + * treatment's own colony; other colonies of the harvest have their own + * treatments. + */ + async conflictsForTreatment( + db: DbClient, + treatmentId: string, + ): Promise { + const treatment = await db.treatment.findUnique({ + where: { id: treatmentId }, + select: { + id: true, + colonyId: true, + product: true, + date: true, + startDate: true, + endDate: true, + withdrawalPeriodDays: true, + }, + }); + const window = treatment ? withdrawalSpanOf(treatment) : null; + if (!treatment || window === null) { + return []; + } + // A day of margin on both sides; the Berlin day decides below. + const candidates = await db.harvest.findMany({ + where: { + colonies: { some: { colonyId: treatment.colonyId } }, + date: { + gte: dayToDate(addDays(window.fromDay, -1)), + lt: dayToDate(addDays(window.untilDay, 1)), + }, + }, + select: { id: true, date: true }, + orderBy: { id: 'asc' }, + }); + const inWindow = candidates + .filter((harvest) => isInWindow(window, berlinDayOf(harvest.date))) + .map((harvest) => harvest.id); + const conflicts = await this.conflictsForHarvests(db, inWindow); + return conflicts.filter((c) => c.colonyId === treatment.colonyId); + } + + /** Honey batch -> extraction -> harvests. */ + async conflictsForHoneyBatch( + db: DbClient, + honeyBatchId: string, + ): Promise { + const batch = await db.honeyBatch.findUnique({ + where: { id: honeyBatchId }, + select: { + extraction: { + select: { harvests: { select: { harvestId: true } } }, + }, + }, + }); + if (!batch) { + return []; + } + return this.conflictsForHarvests( + db, + batch.extraction.harvests.map((entry) => entry.harvestId), + ); + } + + async warningsFor( + db: DbClient, + conflicts: readonly WithdrawalConflict[], + ): Promise { + if (conflicts.length === 0) { + return []; + } + const codes = await this.colonyCodes(db, conflicts); + return conflicts.map((conflict) => ({ + code: 'WITHDRAWAL_ACTIVE', + severity: WARNING_SEVERITY.WITHDRAWAL_ACTIVE, + message: `${codes.get(conflict.colonyId) ?? conflict.colonyId}: Wartezeit nach ${conflict.product} endet am ${formatDayDe(conflict.untilDay, true)}.`, + refs: [ + { type: 'Colony', id: conflict.colonyId }, + { type: 'Treatment', id: conflict.treatmentId }, + { type: 'Harvest', id: conflict.harvestId }, + ], + })); + } + + /** + * One WARNUNG per harvest and colony for every user who may read harvests + * and treatments. The dedupe key carries harvest and colony, so a later + * stage (bottling) or a corrected harvest writes nothing new. Returns the + * number of rows written. + */ + async notifyConflictsIn( + tx: Prisma.TransactionClient, + conflicts: readonly WithdrawalConflict[], + ): Promise { + assertTransactionClient(tx, 'WithdrawalCheckService.notifyConflictsIn'); + if (conflicts.length === 0) { + return 0; + } + const codes = await this.colonyCodes(tx, conflicts); + const lots = await this.bottlingLotsByHarvest( + tx, + conflicts.map((c) => c.harvestId), + ); + let written = 0; + for (const conflict of conflicts) { + const colony = codes.get(conflict.colonyId) ?? conflict.colonyId; + const bottled = lots.get(conflict.harvestId) ?? []; + const bottledText = + bottled.length > 0 + ? ` Bereits abgefüllt: Los ${bottled.join(', ')}.` + : ''; + const rows = await this.notifications.recordForPermission( + [...CONFLICT_RECIPIENT_PERMISSIONS], + { + priority: 'WARNUNG', + category: 'WITHDRAWAL', + message: `Wartezeit: ${colony} am ${formatDayDe(conflict.harvestedOn)} geerntet, Wartezeit nach ${conflict.product} endet am ${formatDayDe(conflict.untilDay, true)}.${bottledText}`, + relatedType: 'Harvest', + relatedId: conflict.harvestId, + link: `/harvests/${conflict.harvestId}`, + dedupeKeyPrefix: `withdrawal-conflict:${conflict.harvestId}:${conflict.colonyId}`, + }, + tx, + ); + written += rows.length; + } + return written; + } + + /** Lot numbers of the bottlings made from honey of each harvest, sorted. */ + private async bottlingLotsByHarvest( + db: DbClient, + harvestIds: readonly string[], + ): Promise> { + const ids = [...new Set(harvestIds)]; + const bottlings = await db.bottlingBatch.findMany({ + where: { + honeyBatch: { + extraction: { harvests: { some: { harvestId: { in: ids } } } }, + }, + }, + select: { + lotNumber: true, + honeyBatch: { + select: { + extraction: { + select: { harvests: { select: { harvestId: true } } }, + }, + }, + }, + }, + }); + const byHarvest = new Map(); + for (const bottling of bottlings) { + for (const { harvestId } of bottling.honeyBatch.extraction.harvests) { + if (ids.includes(harvestId)) { + byHarvest.set(harvestId, [ + ...(byHarvest.get(harvestId) ?? []), + bottling.lotNumber, + ]); + } + } + } + for (const [id, list] of byHarvest) { + byHarvest.set(id, [...new Set(list)].sort()); + } + return byHarvest; + } + + private async windowsOf( + db: DbClient, + colonyIds: readonly string[], + ): Promise> { + const treatments = await db.treatment.findMany({ + where: { + colonyId: { in: [...colonyIds] }, + withdrawalPeriodDays: { not: null }, + }, + select: { + id: true, + colonyId: true, + product: true, + date: true, + startDate: true, + endDate: true, + withdrawalPeriodDays: true, + }, + }); + const byColony = new Map(); + for (const treatment of treatments) { + const window = withdrawalWindowOf(treatment); + if (window === null) { + continue; + } + byColony.set(treatment.colonyId, [ + ...(byColony.get(treatment.colonyId) ?? []), + window, + ]); + } + return byColony; + } + + private async colonyCodes( + db: DbClient, + conflicts: readonly WithdrawalConflict[], + ): Promise> { + const colonies = await db.colony.findMany({ + where: { id: { in: [...new Set(conflicts.map((c) => c.colonyId))] } }, + select: { id: true, code: true }, + }); + return new Map(colonies.map((colony) => [colony.id, colony.code])); + } +} diff --git a/ApiCore-Backend/src/withdrawal/withdrawal-span.spec.ts b/ApiCore-Backend/src/withdrawal/withdrawal-span.spec.ts new file mode 100644 index 0000000..915a91e --- /dev/null +++ b/ApiCore-Backend/src/withdrawal/withdrawal-span.spec.ts @@ -0,0 +1,43 @@ +import { withdrawalSpanOf } from './withdrawal-span'; + +const treatment = (overrides: Record = {}) => ({ + id: 't1', + colonyId: 'c1', + product: 'Oxuvar', + date: new Date('2026-07-31T00:00:00.000Z'), + startDate: null, + endDate: null, + withdrawalPeriodDays: 20, + ...overrides, +}); + +describe('withdrawalSpanOf', () => { + it('is the withdrawal window of a treatment with withdrawal days', () => { + expect(withdrawalSpanOf(treatment())).toEqual({ + treatmentId: 't1', + colonyId: 'c1', + product: 'Oxuvar', + fromDay: '2026-07-31', + untilDay: '2026-08-20', + }); + }); + + it('is null without withdrawal days', () => { + expect( + withdrawalSpanOf(treatment({ withdrawalPeriodDays: null })), + ).toBeNull(); + }); + + it('is null for a window of zero days, which covers no day to show', () => { + expect(withdrawalSpanOf(treatment({ withdrawalPeriodDays: 0 }))).toBeNull(); + }); + + it('is a span of one day for one withdrawal day', () => { + expect(withdrawalSpanOf(treatment({ withdrawalPeriodDays: 1 }))).toEqual( + expect.objectContaining({ + fromDay: '2026-07-31', + untilDay: '2026-08-01', + }), + ); + }); +}); diff --git a/ApiCore-Backend/src/withdrawal/withdrawal-span.ts b/ApiCore-Backend/src/withdrawal/withdrawal-span.ts new file mode 100644 index 0000000..65d6bf4 --- /dev/null +++ b/ApiCore-Backend/src/withdrawal/withdrawal-span.ts @@ -0,0 +1,16 @@ +import { + withdrawalWindowOf, + type WithdrawalWindow, +} from '../treatments/withdrawal-window'; + +/** + * The withdrawal window as something to show (calendar view, M365 entry): + * null without withdrawal days and for a window that covers no day. The + * window itself still comes from the one helper in treatments/. + */ +export function withdrawalSpanOf( + treatment: Parameters[0], +): WithdrawalWindow | null { + const window = withdrawalWindowOf(treatment); + return window !== null && window.fromDay < window.untilDay ? window : null; +} diff --git a/ApiCore-Backend/src/withdrawal/withdrawal.handlers.spec.ts b/ApiCore-Backend/src/withdrawal/withdrawal.handlers.spec.ts new file mode 100644 index 0000000..0dde4cd --- /dev/null +++ b/ApiCore-Backend/src/withdrawal/withdrawal.handlers.spec.ts @@ -0,0 +1,303 @@ +import type { Prisma } from '../../generated/prisma'; +import type { + AnyDomainEventEnvelope, + DomainEventType, +} from '../domain-events/domain-event-types'; +import { domainEventHandlerMetadata } from '../domain-events/on-domain-event.decorator'; +import type { NotificationsService } from '../notifications/notifications.service'; +import type { + WithdrawalCheckService, + WithdrawalConflict, +} from './withdrawal-check.service'; +import { WithdrawalEventHandlers } from './withdrawal.handlers'; + +const conflict: WithdrawalConflict = { + harvestId: 'h1', + harvestedOn: '2026-08-10', + colonyId: 'c1', + treatmentId: 't1', + product: 'Oxuvar', + untilDay: '2026-08-20', +}; + +const envelope = ( + type: T, + aggregateId: string, + payload: Record, +) => ({ type, aggregateId, payload }) as unknown as AnyDomainEventEnvelope; + +const treatmentRow = (overrides: Record = {}) => ({ + id: 't1', + colonyId: 'c1', + product: 'Oxuvar', + date: new Date('2026-07-31T00:00:00.000Z'), + startDate: null, + endDate: null, + withdrawalPeriodDays: 20, + colony: { code: 'B3', status: 'ACTIVE' }, + ...overrides, +}); + +describe('WithdrawalEventHandlers', () => { + let check: { + conflictsForHarvests: jest.Mock; + conflictsForHoneyBatch: jest.Mock; + conflictsForTreatment: jest.Mock; + notifyConflictsIn: jest.Mock; + }; + let notifications: { recordForPermission: jest.Mock }; + let tx: { + treatment: { findUnique: jest.Mock; findMany: jest.Mock }; + automationRule: { findUnique: jest.Mock }; + }; + let handlers: WithdrawalEventHandlers; + + beforeEach(() => { + check = { + conflictsForHarvests: jest.fn().mockResolvedValue([conflict]), + conflictsForHoneyBatch: jest.fn().mockResolvedValue([conflict]), + conflictsForTreatment: jest.fn().mockResolvedValue([conflict]), + notifyConflictsIn: jest.fn().mockResolvedValue(1), + }; + notifications = { recordForPermission: jest.fn().mockResolvedValue([]) }; + tx = { + treatment: { + findUnique: jest.fn().mockResolvedValue(treatmentRow()), + findMany: jest.fn().mockResolvedValue([]), + }, + automationRule: { findUnique: jest.fn().mockResolvedValue(null) }, + }; + handlers = new WithdrawalEventHandlers( + check as unknown as WithdrawalCheckService, + notifications as unknown as NotificationsService, + ); + }); + + const asTx = () => tx as unknown as Prisma.TransactionClient; + + it('registers the three handlers under their ids', () => { + const entries = (method: keyof WithdrawalEventHandlers) => + domainEventHandlerMetadata( + Object.getOwnPropertyDescriptor( + WithdrawalEventHandlers.prototype, + method, + )?.value as object, + ); + + expect(entries('harvestCheck')).toEqual([ + { + types: ['HarvestRecorded', 'HarvestCorrected'], + handlerId: 'withdrawal.harvest-check', + }, + ]); + expect(entries('bottlingCheck')).toEqual([ + { + types: ['BatchBottled', 'BottlingCorrected'], + handlerId: 'withdrawal.bottling-check', + }, + ]); + expect(entries('treatmentCheck')).toEqual([ + { + types: ['TreatmentRecorded', 'TreatmentCorrected'], + handlerId: 'withdrawal.treatment-check', + }, + ]); + expect(entries('withdrawalEnded')).toEqual([ + { + types: ['WithdrawalEnded'], + handlerId: 'notifications.withdrawal-ended', + }, + ]); + }); + + describe('harvest check', () => { + it.each(['HarvestRecorded', 'HarvestCorrected'] as const)( + 'notifies the conflicts of the harvest on %s', + async (type) => { + await handlers.harvestCheck( + envelope(type, 'h1', { apiaryId: 'a1', colonyIds: ['c1'] }), + asTx(), + ); + + expect(check.conflictsForHarvests).toHaveBeenCalledWith(tx, ['h1']); + expect(check.notifyConflictsIn).toHaveBeenCalledWith(tx, [conflict]); + }, + ); + }); + + describe('treatment check', () => { + it.each(['TreatmentRecorded', 'TreatmentCorrected'] as const)( + 'notifies harvests that already lie in the window of the treatment on %s', + async (type) => { + await handlers.treatmentCheck( + envelope(type, 't1', { colonyId: 'c1' }), + asTx(), + ); + + expect(check.conflictsForTreatment).toHaveBeenCalledWith(tx, 't1'); + expect(check.notifyConflictsIn).toHaveBeenCalledWith(tx, [conflict]); + }, + ); + }); + + describe('bottling check', () => { + it('checks the harvests behind the honey batch on BatchBottled', async () => { + await handlers.bottlingCheck( + envelope('BatchBottled', 'bb1', { honeyBatchId: 'hb1' }), + asTx(), + ); + + expect(check.conflictsForHoneyBatch).toHaveBeenCalledWith(tx, 'hb1'); + expect(check.notifyConflictsIn).toHaveBeenCalledWith(tx, [conflict]); + }); + + it('checks on a corrected bottling too', async () => { + await handlers.bottlingCheck( + envelope('BottlingCorrected', 'bb1', { + honeyBatchId: 'hb1', + removed: false, + }), + asTx(), + ); + + expect(check.notifyConflictsIn).toHaveBeenCalledTimes(1); + }); + + it('does nothing for a removed bottling', async () => { + await handlers.bottlingCheck( + envelope('BottlingCorrected', 'bb1', { + honeyBatchId: 'hb1', + removed: true, + }), + asTx(), + ); + + expect(check.conflictsForHoneyBatch).not.toHaveBeenCalled(); + expect(check.notifyConflictsIn).not.toHaveBeenCalled(); + }); + }); + + describe('withdrawal ended', () => { + const ended = () => + envelope('WithdrawalEnded', 't1', { + colonyId: 'c1', + endedOn: '2026-08-20', + }); + + it('tells readiness to harvest once, with colony, product and end day', async () => { + await handlers.withdrawalEnded(ended(), asTx()); + + expect(notifications.recordForPermission).toHaveBeenCalledWith( + ['harvests:create', 'treatments:read'], + { + priority: 'INFO', + category: 'WITHDRAWAL', + message: + 'B3 ist wieder erntefähig. Wartezeit nach Oxuvar endete am 20.08.2026.', + relatedType: 'Colony', + relatedId: 'c1', + link: '/colonies/c1', + dedupeKeyPrefix: 'withdrawal-ended:t1:2026-08-20', + }, + tx, + ); + }); + + it('stays quiet while another window of the colony still runs', async () => { + tx.treatment.findMany.mockResolvedValue([ + treatmentRow({ + id: 't2', + date: new Date('2026-08-10T00:00:00.000Z'), + withdrawalPeriodDays: 14, + }), + ]); + + await handlers.withdrawalEnded(ended(), asTx()); + + expect(notifications.recordForPermission).not.toHaveBeenCalled(); + }); + + it('ignores a window of another treatment that has ended or not begun', async () => { + tx.treatment.findMany.mockResolvedValue([ + // Ended on 2026-08-20 itself: the day is free again. + treatmentRow({ + id: 't2', + date: new Date('2026-07-01T00:00:00.000Z'), + withdrawalPeriodDays: 50, + }), + // Starts after the end day. + treatmentRow({ + id: 't3', + date: new Date('2026-08-21T00:00:00.000Z'), + withdrawalPeriodDays: 10, + }), + ]); + + await handlers.withdrawalEnded(ended(), asTx()); + + expect(notifications.recordForPermission).toHaveBeenCalledTimes(1); + }); + + it('reads the other treatments of the colony without the ended one', async () => { + await handlers.withdrawalEnded(ended(), asTx()); + + expect(tx.treatment.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + colonyId: 'c1', + id: { not: 't1' }, + withdrawalPeriodDays: { not: null }, + }, + }), + ); + }); + + it('is silent when the rule is switched off', async () => { + tx.automationRule.findUnique.mockResolvedValue({ + id: 'r1', + active: false, + config: { notify: true }, + actionTemplate: null, + }); + + await handlers.withdrawalEnded(ended(), asTx()); + + expect(notifications.recordForPermission).not.toHaveBeenCalled(); + }); + + it('is silent when notify is off', async () => { + tx.automationRule.findUnique.mockResolvedValue({ + id: 'r1', + active: true, + config: { notify: false }, + actionTemplate: null, + }); + + await handlers.withdrawalEnded(ended(), asTx()); + + expect(notifications.recordForPermission).not.toHaveBeenCalled(); + }); + + it('is silent for a colony that no longer exists as a living unit', async () => { + tx.treatment.findUnique.mockResolvedValue( + treatmentRow({ colony: { code: 'B3', status: 'DISSOLVED' } }), + ); + + await handlers.withdrawalEnded(ended(), asTx()); + + expect(notifications.recordForPermission).not.toHaveBeenCalled(); + }); + + it('is silent when the treatment is gone or its window moved since the event', async () => { + tx.treatment.findUnique.mockResolvedValueOnce(null); + await handlers.withdrawalEnded(ended(), asTx()); + + tx.treatment.findUnique.mockResolvedValueOnce( + treatmentRow({ withdrawalPeriodDays: 25 }), + ); + await handlers.withdrawalEnded(ended(), asTx()); + + expect(notifications.recordForPermission).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/ApiCore-Backend/src/withdrawal/withdrawal.handlers.ts b/ApiCore-Backend/src/withdrawal/withdrawal.handlers.ts new file mode 100644 index 0000000..9aab809 --- /dev/null +++ b/ApiCore-Backend/src/withdrawal/withdrawal.handlers.ts @@ -0,0 +1,162 @@ +import { Injectable } from '@nestjs/common'; +import type { Prisma } from '../../generated/prisma'; +import { readRule } from '../automation/rule-config'; +import { isLivingColonyStatus } from '../colonies/colony-status'; +import type { AnyDomainEventEnvelope } from '../domain-events/domain-event-types'; +import { OnDomainEvent } from '../domain-events/on-domain-event.decorator'; +import { NotificationsService } from '../notifications/notifications.service'; +import { formatDayDe } from '../suggestions/texts/format-day'; +import { + isInWindow, + withdrawalWindowOf, +} from '../treatments/withdrawal-window'; +import { WithdrawalCheckService } from './withdrawal-check.service'; + +const READY_RECIPIENT_PERMISSIONS = [ + 'harvests:create', + 'treatments:read', +] as const; + +/** + * The withdrawal chain: a harvest or a bottling inside a withdrawal window + * leaves one notification (the key is the same at both stages), and the end + * of a window tells that the colony may be harvested again. Pure DB writers + * through the delivery transaction; errors propagate so a delivery is retried. + */ +@Injectable() +export class WithdrawalEventHandlers { + constructor( + private readonly check: WithdrawalCheckService, + private readonly notifications: NotificationsService, + ) {} + + @OnDomainEvent( + ['HarvestRecorded', 'HarvestCorrected'], + 'withdrawal.harvest-check', + ) + async harvestCheck( + event: AnyDomainEventEnvelope<'HarvestRecorded' | 'HarvestCorrected'>, + tx: Prisma.TransactionClient, + ): Promise { + const conflicts = await this.check.conflictsForHarvests(tx, [ + event.aggregateId, + ]); + await this.check.notifyConflictsIn(tx, conflicts); + } + + // The other direction: a treatment entered after the harvest (backdated), + // or corrected to longer days, covers harvests that already exist. The key + // is the same as on the harvest path, so nothing is notified twice. + @OnDomainEvent( + ['TreatmentRecorded', 'TreatmentCorrected'], + 'withdrawal.treatment-check', + ) + async treatmentCheck( + event: AnyDomainEventEnvelope<'TreatmentRecorded' | 'TreatmentCorrected'>, + tx: Prisma.TransactionClient, + ): Promise { + const conflicts = await this.check.conflictsForTreatment( + tx, + event.aggregateId, + ); + await this.check.notifyConflictsIn(tx, conflicts); + } + + // The extraction (Schleuderung) joins the check with T4b; until then the + // bottling is the second stage. A removed bottling has nothing to check. + @OnDomainEvent( + ['BatchBottled', 'BottlingCorrected'], + 'withdrawal.bottling-check', + ) + async bottlingCheck( + event: AnyDomainEventEnvelope<'BatchBottled' | 'BottlingCorrected'>, + tx: Prisma.TransactionClient, + ): Promise { + if (event.type === 'BottlingCorrected' && event.payload.removed) { + return; + } + const conflicts = await this.check.conflictsForHoneyBatch( + tx, + event.payload.honeyBatchId, + ); + await this.check.notifyConflictsIn(tx, conflicts); + } + + // The aggregate is the treatment. Quiet when the rule does not notify, when + // the colony is gone, when the treatment changed since the event (the job + // records a new event for its new end day) and while another window of the + // colony still runs: only then is the colony harvestable again. + @OnDomainEvent('WithdrawalEnded', 'notifications.withdrawal-ended') + async withdrawalEnded( + event: AnyDomainEventEnvelope<'WithdrawalEnded'>, + tx: Prisma.TransactionClient, + ): Promise { + const rule = await readRule(tx, 'WITHDRAWAL_ENDED'); + if (!rule.active || !rule.config.notify) { + return; + } + const treatment = await tx.treatment.findUnique({ + where: { id: event.aggregateId }, + select: { + id: true, + colonyId: true, + product: true, + date: true, + startDate: true, + endDate: true, + withdrawalPeriodDays: true, + colony: { select: { code: true, status: true } }, + }, + }); + if (treatment === null || !isLivingColonyStatus(treatment.colony.status)) { + return; + } + const endedOn = event.payload.endedOn; + if (withdrawalWindowOf(treatment)?.untilDay !== endedOn) { + return; + } + if (await this.hasOtherRunningWindow(tx, treatment, endedOn)) { + return; + } + await this.notifications.recordForPermission( + [...READY_RECIPIENT_PERMISSIONS], + { + priority: 'INFO', + category: 'WITHDRAWAL', + message: `${treatment.colony.code} ist wieder erntefähig. Wartezeit nach ${treatment.product} endete am ${formatDayDe(endedOn, true)}.`, + relatedType: 'Colony', + relatedId: treatment.colonyId, + link: `/colonies/${treatment.colonyId}`, + dedupeKeyPrefix: `withdrawal-ended:${treatment.id}:${endedOn}`, + }, + tx, + ); + } + + private async hasOtherRunningWindow( + tx: Prisma.TransactionClient, + treatment: { id: string; colonyId: string }, + day: string, + ): Promise { + const others = await tx.treatment.findMany({ + where: { + colonyId: treatment.colonyId, + id: { not: treatment.id }, + withdrawalPeriodDays: { not: null }, + }, + select: { + id: true, + colonyId: true, + product: true, + date: true, + startDate: true, + endDate: true, + withdrawalPeriodDays: true, + }, + }); + return others.some((other) => { + const window = withdrawalWindowOf(other); + return window !== null && isInWindow(window, day); + }); + } +} diff --git a/ApiCore-Backend/src/withdrawal/withdrawal.module.ts b/ApiCore-Backend/src/withdrawal/withdrawal.module.ts new file mode 100644 index 0000000..1bc3aa8 --- /dev/null +++ b/ApiCore-Backend/src/withdrawal/withdrawal.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { NotificationsModule } from '../notifications/notifications.module'; +import { WithdrawalCheckService } from './withdrawal-check.service'; +import { WithdrawalEventHandlers } from './withdrawal.handlers'; + +/** + * The withdrawal (Wartezeit) chain. Imported by AppModule and WorkerModule + * (the event handlers) and by the harvest and bottling modules, whose + * responses carry the warnings. No PermissionsModule: the permission + * resolver is global in the API and in the worker. + */ +@Module({ + imports: [NotificationsModule], + providers: [WithdrawalCheckService, WithdrawalEventHandlers], + exports: [WithdrawalCheckService], +}) +export class WithdrawalModule {} diff --git a/ApiCore-Backend/src/worker/worker.module.ts b/ApiCore-Backend/src/worker/worker.module.ts index 409b4ab..5d28c89 100644 --- a/ApiCore-Backend/src/worker/worker.module.ts +++ b/ApiCore-Backend/src/worker/worker.module.ts @@ -11,6 +11,7 @@ import { PrismaModule } from '../prisma/prisma.module'; import { SuggestionsModule } from '../suggestions/suggestions.module'; import { TasksModule } from '../tasks/tasks.module'; import { WebhooksModule } from '../webhooks/webhooks.module'; +import { WithdrawalModule } from '../withdrawal/withdrawal.module'; import { WorkerHealthServer } from './worker-health-server.service'; import { WorkerRuntimeModule } from './worker-runtime.module'; @@ -35,6 +36,7 @@ import { WorkerRuntimeModule } from './worker-runtime.module'; AutomationModule, SuggestionsModule, AutopilotModule, + WithdrawalModule, WorkerRuntimeModule, ], providers: [WorkerHealthServer], diff --git a/ApiCore-Backend/test/apiaries.e2e-spec.ts b/ApiCore-Backend/test/apiaries.e2e-spec.ts index dcf405c..272e375 100644 --- a/ApiCore-Backend/test/apiaries.e2e-spec.ts +++ b/ApiCore-Backend/test/apiaries.e2e-spec.ts @@ -89,6 +89,9 @@ describe('Apiaries (e2e)', () => { await prisma.task.deleteMany({ where: { title: { startsWith: 'field-e2e-' } }, }); + await prisma.suggestion.deleteMany({ + where: { colony: { apiaryId: fieldApiaryId } }, + }); await prisma.colony.deleteMany({ where: { apiaryId: fieldApiaryId } }); await prisma.apiary.delete({ where: { id: fieldApiaryId } }); } @@ -210,7 +213,14 @@ describe('Apiaries (e2e)', () => { describe('apiary field endpoints', () => { interface OverviewBody { apiaryId: string; - rows: { code: string }[]; + rows: { + code: string; + inspectionDueOn: string | null; + segment: string; + nextStepTitle: string | null; + openWarnings: number; + openCritical: number; + }[]; } interface PackBody { version: number; @@ -285,6 +295,109 @@ describe('Apiaries (e2e)', () => { ]); }); + it('colony-overview carries inspectionDueOn, segment and nextStepTitle; warnings come from suggestions', async () => { + const permissions = [ + 'colonies:read', + 'tasks:read', + 'inspections:read', + 'varroa:read', + ]; + const role = await prisma.role.create({ + data: { + key: `FIELD_OVERVIEW_${Date.now()}`, + name: 'Feld-Übersicht', + permissions: { + create: permissions.map((key) => ({ + permission: { connect: { key } }, + })), + }, + }, + }); + const email = `apiaries-e2e-overview-${Date.now()}@example.com`; + const user = await prisma.user.create({ + data: { + email, + passwordHash: await bcrypt.hash(password, 12), + name: 'Overview', + roleId: role.id, + }, + }); + const colony = await prisma.colony.findFirstOrThrow({ + where: { apiaryId: fieldApiaryId, code: { startsWith: 'V-10-' } }, + }); + const base = { + ruleKey: 'VARROA_THRESHOLD' as const, + source: 'RULE' as const, + mode: 'PLAN' as const, + intent: 'VARROA_TREAT' as const, + relatedType: 'COLONY' as const, + relatedId: colony.id, + colonyId: colony.id, + reasonCode: 'FIELD_E2E', + evidence: { colonyId: colony.id }, + }; + try { + await prisma.suggestion.createMany({ + data: [ + { ...base, priority: 'HIGH', requiredPermission: 'varroa:read' }, + { + ...base, + intent: 'HONEY_SUPER', + priority: 'URGENT', + requiredPermission: 'inspections:read', + }, + { + ...base, + intent: 'FEED', + priority: 'URGENT', + requiredPermission: 'feedings:read', + }, + { ...base, intent: 'INSPECT', priority: 'NORMAL' }, + ], + }); + const token = (await login(app, email, password)).accessToken; + + const response = await request(app.getHttpServer()) + .get(`/api/v1/apiaries/${fieldApiaryId}/colony-overview`) + .set('Authorization', `Bearer ${token}`) + .expect(200); + + const rows = (response.body as OverviewBody).rows; + const row = rows.find((item) => item.code.startsWith('V-10-')); + // HIGH (varroa:read) and URGENT (inspections:read) are visible, the + // URGENT one that needs feedings:read is not. + expect(row).toMatchObject({ openWarnings: 1, openCritical: 1 }); + expect(row?.nextStepTitle).toEqual(expect.any(String)); + expect(row?.segment).toEqual(expect.any(String)); + expect(row).toHaveProperty('inspectionDueOn'); + if (row?.segment === 'WINTERRUHE') { + expect(row.inspectionDueOn).toBeNull(); + } else { + expect(row?.inspectionDueOn).toMatch(/^\d{4}-\d{2}-\d{2}$/); + } + const quiet = rows.find((item) => item.code.startsWith('V-2-')); + expect(quiet).toMatchObject({ + openWarnings: 0, + openCritical: 0, + nextStepTitle: null, + }); + + const pack = await request(app.getHttpServer()) + .get(`/api/v1/apiaries/${fieldApiaryId}/offline-pack`) + .set('Authorization', `Bearer ${token}`) + .expect(200); + const steps = (pack.body as { nextSteps?: Record }) + .nextSteps; + expect(steps?.[colony.id]?.length).toBeGreaterThan(0); + expect(steps?.[colony.id]?.length).toBeLessThanOrEqual(3); + } finally { + await prisma.suggestion.deleteMany({ where: { colonyId: colony.id } }); + await prisma.refreshToken.deleteMany({ where: { userId: user.id } }); + await prisma.user.delete({ where: { id: user.id } }); + await prisma.role.delete({ where: { id: role.id } }); + } + }); + it('offline-pack contains the open tasks of the apiary and its colonies', async () => { const response = await request(app.getHttpServer()) .get(`/api/v1/apiaries/${fieldApiaryId}/offline-pack`) diff --git a/ApiCore-Backend/test/automation.e2e-spec.ts b/ApiCore-Backend/test/automation.e2e-spec.ts index d49b536..2135ccd 100644 --- a/ApiCore-Backend/test/automation.e2e-spec.ts +++ b/ApiCore-Backend/test/automation.e2e-spec.ts @@ -29,6 +29,7 @@ describe('Automation rules (e2e)', () => { let staffToken: string; let apiaryId: string; let colonyId: string; + const followColonyIds: string[] = []; let varroaRuleId: string; let swarmRuleId: string; @@ -112,13 +113,17 @@ describe('Automation rules (e2e)', () => { // Rule suggestions and the rules' own entries are written without a // user - they are not covered by the userId cleanup below. Deliveries go // with their event (ON DELETE CASCADE). + const colonyIds = [colonyId, ...followColonyIds]; const suggestionIds = ( await prisma.suggestion.findMany({ where: { colonyId }, select: { id: true }, }) ).map((suggestion) => suggestion.id); - const byColony = { where: { colonyId }, select: { id: true } }; + const byColony = { + where: { colonyId: { in: colonyIds } }, + select: { id: true }, + }; const recordIds = [ ...(await prisma.inspection.findMany(byColony)), ...(await prisma.varroaMeasurement.findMany(byColony)), @@ -127,10 +132,10 @@ describe('Automation rules (e2e)', () => { where: { OR: [ { entityType: 'Suggestion', entityId: { in: suggestionIds } }, - { + ...colonyIds.map((id) => ({ entityType: 'AutomationRule', - afterData: { path: ['relatedId'], equals: colonyId }, - }, + afterData: { path: ['relatedId'], equals: id }, + })), ], }, }); @@ -143,11 +148,19 @@ describe('Automation rules (e2e)', () => { await prisma.notification.deleteMany({ where: { relatedType: 'Suggestion', relatedId: { in: suggestionIds } }, }); - await prisma.suggestion.deleteMany({ where: { colonyId } }); - await prisma.varroaMeasurement.deleteMany({ where: { colonyId } }); - await prisma.swarmControlCheck.deleteMany({ where: { colonyId } }); - await prisma.inspection.deleteMany({ where: { colonyId } }); - await prisma.colony.deleteMany({ where: { id: colonyId } }); + await prisma.suggestion.deleteMany({ + where: { colonyId: { in: colonyIds } }, + }); + await prisma.varroaMeasurement.deleteMany({ + where: { colonyId: { in: colonyIds } }, + }); + await prisma.swarmControlCheck.deleteMany({ + where: { colonyId: { in: colonyIds } }, + }); + await prisma.inspection.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: { in: [adminId, staffId] } }, @@ -448,6 +461,194 @@ describe('Automation rules (e2e)', () => { expect(resolved).toMatchObject({ status: 'RESOLVED', dedupeKey: null }); }); + describe('an open inspection suggestion follows newer inspections', () => { + const ANCHOR_DAY = '2026-05-05T00:00:00.000Z'; + + async function freshColony(): Promise { + const colony = await prisma.colony.create({ + data: { + code: `AUTO-F-${Date.now()}-${followColonyIds.length}`, + apiaryId, + colonyType: 'PRODUCTION_COLONY', + }, + }); + followColonyIds.push(colony.id); + return colony.id; + } + + async function inspectOn( + forColony: string, + date: string, + body: object, + ): Promise { + const response = await request(app.getHttpServer()) + .post('/api/v1/inspections') + .set('Authorization', `Bearer ${staffToken}`) + .send({ colonyId: forColony, date, ...body }) + .expect(201); + const id = (response.body as { id: string }).id; + await drainDomainEvents(app, [id]); + return id; + } + + const queenlessOf = (forColony: string) => + prisma.suggestion.findMany({ + where: { colonyId: forColony, ruleKey: 'QUEENLESS_NO_EGGS' }, + }); + const queenless = { queenSeen: false, eggsPresent: false }; + const clean = { queenSeen: true, eggsPresent: true }; + + it('a newer clean inspection resolves it', async () => { + const forColony = await freshColony(); + await inspectOn(forColony, ANCHOR_DAY, queenless); + expect(await queenlessOf(forColony)).toMatchObject([{ status: 'OPEN' }]); + + await inspectOn(forColony, '2026-05-12T00:00:00.000Z', clean); + + expect(await queenlessOf(forColony)).toMatchObject([ + { status: 'RESOLVED', dedupeKey: null }, + ]); + }); + + it('a newer inspection with the finding keeps one OPEN suggestion with updated evidence', async () => { + const forColony = await freshColony(); + await inspectOn(forColony, ANCHOR_DAY, queenless); + + const newerId = await inspectOn( + forColony, + '2026-05-12T00:00:00.000Z', + queenless, + ); + + const suggestions = await queenlessOf(forColony); + expect(suggestions).toHaveLength(1); + expect(suggestions[0]).toMatchObject({ + status: 'OPEN', + evidence: { inspectionId: newerId, inspectedOn: '2026-05-12' }, + }); + }); + + it('a backdated inspection changes nothing', async () => { + const forColony = await freshColony(); + await inspectOn(forColony, ANCHOR_DAY, queenless); + const [before] = await queenlessOf(forColony); + + await inspectOn(forColony, '2026-04-20T00:00:00.000Z', clean); + + const [after] = await queenlessOf(forColony); + expect(after).toMatchObject({ + status: 'OPEN', + evidence: before.evidence, + }); + }); + + it('correcting the inspection of the finding keeps it OPEN while the finding remains', async () => { + const forColony = await freshColony(); + const id = await inspectOn(forColony, ANCHOR_DAY, queenless); + + await request(app.getHttpServer()) + .patch(`/api/v1/inspections/${id}`) + .set('Authorization', `Bearer ${staffToken}`) + .send({ queenSeen: false, eggsPresent: false, notes: 'korrigiert' }) + .expect(200); + await drainDomainEvents(app, [id]); + + expect(await queenlessOf(forColony)).toMatchObject([ + { status: 'OPEN', evidence: { inspectionId: id } }, + ]); + }); + + it('correcting an older inspection than the latest changes nothing', async () => { + const forColony = await freshColony(); + const olderId = await inspectOn(forColony, ANCHOR_DAY, queenless); + await inspectOn(forColony, '2026-05-12T00:00:00.000Z', queenless); + const [before] = await queenlessOf(forColony); + + await request(app.getHttpServer()) + .patch(`/api/v1/inspections/${olderId}`) + .set('Authorization', `Bearer ${staffToken}`) + .send({ queenSeen: true, eggsPresent: true }) + .expect(200); + await drainDomainEvents(app, [olderId]); + + const [after] = await queenlessOf(forColony); + expect(after).toMatchObject({ + status: 'OPEN', + evidence: before.evidence, + }); + }); + + it('a late retry of an older inspection after a newer clean one proposes nothing', async () => { + const forColony = await freshColony(); + const olderId = await inspectOn(forColony, ANCHOR_DAY, queenless); + await inspectOn(forColony, '2026-05-12T00:00:00.000Z', clean); + expect(await queenlessOf(forColony)).toMatchObject([ + { status: 'RESOLVED' }, + ]); + + // The delivery of the older inspection runs again. + await prisma.domainEvent.updateMany({ + where: { aggregateId: olderId }, + data: { status: 'PENDING', processedAt: null, claimedAt: null }, + }); + await prisma.domainEventDelivery.updateMany({ + where: { + handler: 'automation.inspection-rules', + event: { aggregateId: olderId }, + }, + data: { status: 'PENDING', attempts: 0, completedAt: null }, + }); + await drainDomainEvents(app, [olderId]); + + expect(await queenlessOf(forColony)).toMatchObject([ + { status: 'RESOLVED' }, + ]); + }); + + it('the feed rule follows newer inspections the same way', async () => { + const forColony = await freshColony(); + await inspectOn(forColony, ANCHOR_DAY, { + ...clean, + feedStatus: 'CRITICAL', + }); + const feedOf = () => + prisma.suggestion.findMany({ + where: { colonyId: forColony, ruleKey: 'FEED_STATUS_CRITICAL' }, + }); + expect(await feedOf()).toMatchObject([{ status: 'OPEN' }]); + + const stillId = await inspectOn(forColony, '2026-05-12T00:00:00.000Z', { + ...clean, + feedStatus: 'CRITICAL', + }); + expect(await feedOf()).toMatchObject([ + { status: 'OPEN', evidence: { inspectionId: stillId } }, + ]); + + await inspectOn(forColony, '2026-05-19T00:00:00.000Z', { + ...clean, + feedStatus: 'GOOD', + }); + expect(await feedOf()).toMatchObject([{ status: 'RESOLVED' }]); + }); + + it('correcting the inspection of the finding resolves it', async () => { + const forColony = await freshColony(); + const id = await inspectOn(forColony, ANCHOR_DAY, queenless); + + await request(app.getHttpServer()) + .patch(`/api/v1/inspections/${id}`) + .set('Authorization', `Bearer ${staffToken}`) + .send({ queenSeen: true, eggsPresent: true }) + .expect(200); + await drainDomainEvents(app, [id]); + + expect(await queenlessOf(forColony)).toMatchObject([ + { status: 'RESOLVED' }, + ]); + }); + }); + it('lets an admin clear a rule config, which falls back to its defaults', async () => { const before = await prisma.automationRule.findUniqueOrThrow({ where: { id: varroaRuleId }, diff --git a/ApiCore-Backend/test/bottling.e2e-spec.ts b/ApiCore-Backend/test/bottling.e2e-spec.ts index 008b288..ff28b81 100644 --- a/ApiCore-Backend/test/bottling.e2e-spec.ts +++ b/ApiCore-Backend/test/bottling.e2e-spec.ts @@ -15,6 +15,7 @@ interface BottlingBatchBody { id: string; totalAmount: number; lotNumber: string; + warnings: unknown[]; } interface RecordedEvent { @@ -187,6 +188,8 @@ describe('Bottling (e2e)', () => { const bottlingBatch = response.body as BottlingBatchBody; bottlingBatchId = bottlingBatch.id; expect(bottlingBatch.totalAmount).toBe(25); + // The harvest behind the honey batch has no withdrawal window. + expect(bottlingBatch.warnings).toEqual([]); const honeyBatchResponse = await request(app.getHttpServer()) .get(`/api/v1/honey-batches/${honeyBatchId}`) @@ -217,11 +220,12 @@ describe('Bottling (e2e)', () => { }); it('a correction records BottlingCorrected and StockChanged together', async () => { - await request(app.getHttpServer()) + const corrected = await request(app.getHttpServer()) .patch(`/api/v1/bottling-batches/${bottlingBatchId}`) .set('Authorization', `Bearer ${beekeeperToken}`) .send({ jarCount: 40 }) .expect(200); + expect((corrected.body as BottlingBatchBody).warnings).toEqual([]); // 10 jars of 500 g fewer: 5 kg go back to the honey batch. expect(await honeyBatchAmount()).toBe(5); diff --git a/ApiCore-Backend/test/calendar-sync.e2e-spec.ts b/ApiCore-Backend/test/calendar-sync.e2e-spec.ts index 17ba8ac..c4bdbcd 100644 --- a/ApiCore-Backend/test/calendar-sync.e2e-spec.ts +++ b/ApiCore-Backend/test/calendar-sync.e2e-spec.ts @@ -168,7 +168,7 @@ describe('Calendar sync outbox (e2e)', () => { .mockImplementationOnce(async (_g, input) => { createdEvents.push(input.title); // The task's due date is changed right now, on another request. - await calendarSync.enqueue('TASK', task.id); + await calendarSync.enqueueIn(prisma, 'TASK', task.id); return `event-${suffix}-edited`; }); diff --git a/ApiCore-Backend/test/calendar.e2e-spec.ts b/ApiCore-Backend/test/calendar.e2e-spec.ts index b950fad..4560eb3 100644 --- a/ApiCore-Backend/test/calendar.e2e-spec.ts +++ b/ApiCore-Backend/test/calendar.e2e-spec.ts @@ -7,8 +7,13 @@ import { createTestApp } from './utils/create-test-app'; import { login } from './utils/api'; interface CalendarEventBody { + id: string; type: string; title: string; + date: string; + endDate?: string; + relatedType: string; + relatedId: string; } describe('Calendar (e2e)', () => { @@ -21,6 +26,11 @@ describe('Calendar (e2e)', () => { let apiaryId: string; let colonyId: string; let taskId: string; + let treatmentId: string; + let colonyCode: string; + let calendarOnlyRoleId: string; + let calendarOnlyId: string; + let calendarOnlyToken: string; beforeAll(async () => { app = await createTestApp(); @@ -50,6 +60,39 @@ describe('Calendar (e2e)', () => { }, }); colonyId = colony.id; + colonyCode = colony.code; + // 2027-06-10 + 10 days: harvestable again from 2027-06-20. + const treatment = await prisma.treatment.create({ + data: { + colonyId, + userId: beekeeperId, + date: new Date('2027-06-10T00:00:00.000Z'), + product: 'Oxuvar', + withdrawalPeriodDays: 10, + }, + }); + treatmentId = treatment.id; + const calendarOnlyRole = await prisma.role.create({ + data: { + key: `CAL_ONLY_${Date.now()}`, + name: 'Nur Kalender', + permissions: { + create: [{ permission: { connect: { key: 'calendar:read' } } }], + }, + }, + }); + calendarOnlyRoleId = calendarOnlyRole.id; + const calendarOnly = await prisma.user.create({ + data: { + email: `calendar-e2e-only-${Date.now()}@example.com`, + passwordHash, + name: 'Calendar only', + roleId: calendarOnlyRoleId, + }, + }); + calendarOnlyId = calendarOnly.id; + calendarOnlyToken = (await login(app, calendarOnly.email, password)) + .accessToken; const task = await prisma.task.create({ data: { title: `Kalendertest ${Date.now()}`, @@ -62,11 +105,16 @@ describe('Calendar (e2e)', () => { afterAll(async () => { await prisma.task.deleteMany({ where: { id: taskId } }); + await prisma.treatment.deleteMany({ where: { id: treatmentId } }); await prisma.colony.deleteMany({ where: { id: colonyId } }); await prisma.apiary.deleteMany({ where: { id: apiaryId } }); - await prisma.refreshToken.deleteMany({ where: { userId: beekeeperId } }); - await prisma.auditLog.deleteMany({ where: { userId: beekeeperId } }); - await prisma.user.deleteMany({ where: { id: beekeeperId } }); + const userIds = [beekeeperId, calendarOnlyId]; + 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: calendarOnlyRoleId } }); await app.close(); }); @@ -90,4 +138,84 @@ describe('Calendar (e2e)', () => { ), ).toBe(true); }); + + it('lists WITHDRAWAL as a span with endDate for users with treatments:read', async () => { + const response = await request(app.getHttpServer()) + .get('/api/v1/calendar?from=2027-06-01&to=2027-06-30') + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(200); + const events = response.body as CalendarEventBody[]; + + const span = events.find( + (event) => + event.type === 'WITHDRAWAL' && event.id === `withdrawal:${treatmentId}`, + ); + expect(span).toMatchObject({ + title: `Wartezeit ${colonyCode} · Oxuvar`, + relatedType: 'Colony', + relatedId: colonyId, + }); + expect(span?.date.slice(0, 10)).toBe('2027-06-10'); + // The last covered day, inclusive; 2027-06-20 is harvestable again. + expect(span?.endDate?.slice(0, 10)).toBe('2027-06-19'); + }); + + it('shows the span in a range that only touches the end of the window', async () => { + const response = await request(app.getHttpServer()) + .get('/api/v1/calendar?from=2027-06-19&to=2027-06-25') + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(200); + const events = response.body as CalendarEventBody[]; + + expect( + events.some((event) => event.id === `withdrawal:${treatmentId}`), + ).toBe(true); + const later = await request(app.getHttpServer()) + .get('/api/v1/calendar?from=2027-06-20&to=2027-06-25') + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(200); + expect( + (later.body as CalendarEventBody[]).some( + (event) => event.id === `withdrawal:${treatmentId}`, + ), + ).toBe(false); + }); + + it('hides WITHDRAWAL from users without treatments:read', async () => { + const response = await request(app.getHttpServer()) + .get('/api/v1/calendar?from=2027-06-01&to=2027-06-30') + .set('Authorization', `Bearer ${calendarOnlyToken}`) + .expect(200); + + expect( + (response.body as CalendarEventBody[]).filter( + (event) => event.type === 'WITHDRAWAL', + ), + ).toEqual([]); + }); + + it('lists TREATMENT events only for users with treatments:read', async () => { + const asBeekeeper = await request(app.getHttpServer()) + .get('/api/v1/calendar?from=2027-06-01&to=2027-06-30') + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(200); + const asCalendarOnly = await request(app.getHttpServer()) + .get('/api/v1/calendar?from=2027-06-01&to=2027-06-30') + .set('Authorization', `Bearer ${calendarOnlyToken}`) + .expect(200); + + const treatmentEvent = (body: unknown) => + (body as CalendarEventBody[]).filter( + (event) => event.type === 'TREATMENT' && event.id === treatmentId, + ); + expect(treatmentEvent(asBeekeeper.body)).toHaveLength(1); + expect(treatmentEvent(asCalendarOnly.body)).toEqual([]); + }); + + it('rejects an invalid date range', () => { + return request(app.getHttpServer()) + .get('/api/v1/calendar?from=nonsense&to=2027-06-30') + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(400); + }); }); diff --git a/ApiCore-Backend/test/colonies.e2e-spec.ts b/ApiCore-Backend/test/colonies.e2e-spec.ts index 7d409f9..20c992a 100644 --- a/ApiCore-Backend/test/colonies.e2e-spec.ts +++ b/ApiCore-Backend/test/colonies.e2e-spec.ts @@ -2,6 +2,7 @@ import { INestApplication } from '@nestjs/common'; import * as bcrypt from 'bcryptjs'; import request from 'supertest'; import { App } from 'supertest/types'; +import { berlinToday } from '../src/common/dates'; import { PrismaService } from '../src/prisma/prisma.service'; import { createTestApp } from './utils/create-test-app'; import { login } from './utils/api'; @@ -270,7 +271,14 @@ describe('Colonies (e2e)', () => { const ended = await prisma.domainEvent.findMany({ where: { type: 'ColonyEnded', aggregateId: colonyToMergeId }, }); - expect(ended.map((event) => event.payload)).toEqual([{ status: 'MERGED' }]); + expect(ended.map((event) => event.payload)).toEqual([ + { + status: 'MERGED', + endedOn: berlinToday(), + releasedQueenId: null, + via: 'MERGE', + }, + ]); }); it('dissolves the colony without deleting it', async () => { @@ -288,7 +296,12 @@ describe('Colonies (e2e)', () => { 'ColonyStatusChanged', 'ColonyEnded', ]); - expect(events[1].payload).toEqual({ status: 'DISSOLVED' }); + expect(events[1].payload).toEqual({ + status: 'DISSOLVED', + endedOn: berlinToday(), + releasedQueenId: null, + via: 'DISSOLVE', + }); await request(app.getHttpServer()) .get(`/api/v1/colonies/${colonyId}`) diff --git a/ApiCore-Backend/test/colony-ended.e2e-spec.ts b/ApiCore-Backend/test/colony-ended.e2e-spec.ts new file mode 100644 index 0000000..d736474 --- /dev/null +++ b/ApiCore-Backend/test/colony-ended.e2e-spec.ts @@ -0,0 +1,586 @@ +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 { login } from './utils/api'; +import { createTestApp } from './utils/create-test-app'; +import { drainDomainEvents } from './utils/domain-events'; +import { proposeForTest } from './utils/suggestions'; + +interface CleanupBody { + cancelled: number; + completed: number; + kept: number; + queenStatus: string | null; +} + +interface EndedColony { + colonyId: string; + queenId: string; + taskIds: string[]; + suggestionId: string; +} + +/** + * A colony that ends (winter loss, edit to SOLD): the queen assignment ends + * in the same transaction, open suggestions turn OBSOLETE, one clean-up + * suggestion is left, and "Aufräumen" settles tasks and queen. Every + * assertion is on this file's own colonies. + */ +describe('Colony ended (e2e)', () => { + let app: INestApplication; + let prisma: PrismaService; + const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`; + const password = 'SuperSecret123!'; + const FULL_PERMISSIONS = [ + 'tasks:read', + 'tasks:create', + 'tasks:update', + 'tasks:complete', + 'queens:update', + 'colonies:read', + 'colonies:update', + 'colonies:deactivate', + 'winter-checks:read', + 'winter-checks:create', + ]; + let apiaryId: string; + const colonyIds: string[] = []; + const queenIds: string[] = []; + const roleIds: string[] = []; + const userIds: string[] = []; + const tokens = { full: '', noComplete: '', noQueens: '' }; + let counter = 0; + const nextKey = () => `colony-ended-${suffix}-${(counter += 1)}-key`; + + const api = () => request(app.getHttpServer()); + const auth = (token: string) => ({ Authorization: `Bearer ${token}` }); + + async function createUser( + label: string, + permissions: string[], + ): Promise { + const role = await prisma.role.create({ + data: { + key: `COLONY_ENDED_${label.toUpperCase()}_${suffix}`, + name: `Colony ended ${label}`, + permissions: { + create: permissions.map((key) => ({ + permission: { connect: { key } }, + })), + }, + }, + }); + roleIds.push(role.id); + const email = `colony-ended-${label}-${suffix}@example.com`; + const user = await prisma.user.create({ + data: { + email, + passwordHash: await bcrypt.hash(password, 12), + name: `Colony ended ${label}`, + role: { connect: { id: role.id } }, + }, + }); + userIds.push(user.id); + return (await login(app, email, password)).accessToken; + } + + /** A living colony with an active queen assigned today and some open tasks. */ + async function newColony( + label: string, + taskCount = 3, + ): Promise<{ colonyId: string; queenId: string; taskIds: string[] }> { + const queen = await prisma.queen.create({ + data: { code: `CE-Q-${label}-${suffix}`, status: 'ACTIVE' }, + }); + queenIds.push(queen.id); + const colony = await prisma.colony.create({ + data: { + code: `CE-${label}-${suffix}`, + apiaryId, + colonyType: 'PRODUCTION_COLONY', + currentQueenId: queen.id, + }, + }); + colonyIds.push(colony.id); + await prisma.queenColonyHistory.create({ + data: { + queenId: queen.id, + colonyId: colony.id, + startDate: new Date('2002-04-01T00:00:00.000Z'), + }, + }); + const taskIds: string[] = []; + for (let index = 0; index < taskCount; index += 1) { + const task = await prisma.task.create({ + data: { + title: `Aufgabe ${index}`, + relatedType: 'COLONY', + relatedId: colony.id, + }, + }); + taskIds.push(task.id); + } + return { colonyId: colony.id, queenId: queen.id, taskIds }; + } + + /** Ends a colony through the edit route and returns its clean-up suggestion. */ + async function endColony(label: string): Promise { + const colony = await newColony(label); + await api() + .patch(`/api/v1/colonies/${colony.colonyId}`) + .set(auth(tokens.full)) + .send({ status: 'SOLD' }) + .expect(200); + await drainDomainEvents(app, [colony.colonyId]); + const suggestion = await prisma.suggestion.findFirstOrThrow({ + where: { colonyId: colony.colonyId, intent: 'CLEANUP', status: 'OPEN' }, + }); + return { ...colony, suggestionId: suggestion.id }; + } + + const applyCleanup = (token: string, suggestionId: string, body: object) => + api() + .post(`/api/v1/suggestions/${suggestionId}/apply-cleanup`) + .set(auth(token)) + .set('Idempotency-Key', nextKey()) + .send(body); + + beforeAll(async () => { + app = await createTestApp(); + prisma = app.get(PrismaService); + apiaryId = ( + await prisma.apiary.create({ data: { name: `Colony ended ${suffix}` } }) + ).id; + tokens.full = await createUser('full', FULL_PERMISSIONS); + tokens.noQueens = await createUser( + 'noqueens', + FULL_PERMISSIONS.filter((key) => key !== 'queens:update'), + ); + tokens.noComplete = await createUser( + 'nocomplete', + FULL_PERMISSIONS.filter((key) => key !== 'tasks:complete'), + ); + }); + + afterAll(async () => { + const taskIds = ( + await prisma.task.findMany({ + where: { relatedId: { in: colonyIds } }, + select: { id: true }, + }) + ).map((row) => row.id); + await prisma.domainEvent.deleteMany({ + where: { aggregateId: { in: [...taskIds, ...colonyIds, ...queenIds] } }, + }); + await prisma.calendarSyncOutboxEntry.deleteMany({ + where: { sourceType: 'TASK', sourceId: { in: taskIds } }, + }); + await prisma.task.deleteMany({ where: { id: { in: taskIds } } }); + await prisma.suggestion.deleteMany({ + where: { colonyId: { in: colonyIds } }, + }); + await prisma.winterCheck.deleteMany({ + where: { colonyId: { in: colonyIds } }, + }); + await prisma.queenColonyHistory.deleteMany({ + where: { colonyId: { in: colonyIds } }, + }); + await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } }); + await prisma.queen.deleteMany({ where: { id: { in: queenIds } } }); + 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('a winter loss with markColonyDeceased ends the queen assignment in the same transaction', async () => { + const { colonyId, queenId } = await newColony('winter', 0); + + await api() + .post('/api/v1/winter-checks') + .set(auth(tokens.full)) + .send({ + colonyId, + season: 'SPRING', + date: '2002-03-20', + outcome: 'LOST', + lossCause: 'DEAD_OR_EMPTY', + markColonyDeceased: true, + }) + .expect(201); + + const colony = await prisma.colony.findUniqueOrThrow({ + where: { id: colonyId }, + }); + expect(colony).toMatchObject({ status: 'DECEASED', currentQueenId: null }); + const history = await prisma.queenColonyHistory.findMany({ + where: { colonyId, queenId }, + }); + expect(history).toHaveLength(1); + expect(history[0].endDate?.toISOString()).toBe('2002-03-20T00:00:00.000Z'); + // The queen herself keeps her status: the clean-up decides. + await expect( + prisma.queen.findUniqueOrThrow({ where: { id: queenId } }), + ).resolves.toMatchObject({ status: 'ACTIVE' }); + const ended = await prisma.domainEvent.findMany({ + where: { aggregateId: colonyId, type: 'ColonyEnded' }, + }); + expect(ended.map((event) => event.payload)).toEqual([ + { + status: 'DECEASED', + endedOn: '2002-03-20', + releasedQueenId: queenId, + via: 'WINTER_CHECK', + }, + ]); + }); + + it('open suggestions become OBSOLETE after draining and one CLEANUP suggestion is left', async () => { + const { colonyId, taskIds, queenId } = await newColony('obsolete', 2); + const open = await proposeForTest(app, { + ruleKey: 'INSPECTION_INTERVAL', + source: 'RULE', + mode: 'DO', + intent: 'INSPECT', + relatedType: 'COLONY', + relatedId: colonyId, + colonyId, + reasonCode: `COLONY_ENDED_E2E_${suffix}`, + evidence: { colonyId }, + requiredPermission: 'inspections:read', + dueDate: '2026-05-12', + anchor: `ce-${suffix}`, + }); + + await api() + .patch(`/api/v1/colonies/${colonyId}`) + .set(auth(tokens.full)) + .send({ status: 'DECEASED' }) + .expect(200); + await drainDomainEvents(app, [colonyId]); + + await expect( + prisma.suggestion.findUniqueOrThrow({ where: { id: open.id } }), + ).resolves.toMatchObject({ status: 'OBSOLETE' }); + const cleanups = await prisma.suggestion.findMany({ + where: { colonyId, intent: 'CLEANUP' }, + }); + expect(cleanups).toHaveLength(1); + expect(cleanups[0]).toMatchObject({ + status: 'OPEN', + mode: 'DO', + ruleKey: 'COLONY_ENDED_CLEANUP', + requiredPermission: 'colonies:read', + }); + const evidence = cleanups[0].evidence as Record; + expect(evidence).toMatchObject({ + colonyId, + status: 'DECEASED', + via: 'EDIT', + queenId, + }); + expect(evidence.openTaskIds).toEqual([...taskIds].sort()); + const colony = await prisma.colony.findUniqueOrThrow({ + where: { id: colonyId }, + }); + expect(colony.currentQueenId).toBeNull(); + }); + + it('apply-cleanup cancels 2 tasks and completes 1, then the suggestion is RESOLVED', async () => { + const ended = await endColony('apply'); + const [first, second, third] = [...ended.taskIds].sort(); + + const response = await applyCleanup(tokens.full, ended.suggestionId, { + tasks: [ + { id: first, outcome: 'CANCEL' }, + { id: second, outcome: 'CANCEL' }, + { id: third, outcome: 'DONE' }, + ], + queen: { id: ended.queenId, status: 'SOLD' }, + }).expect(201); + + expect(response.body as CleanupBody).toEqual({ + cancelled: 2, + completed: 1, + kept: 0, + queenStatus: 'SOLD', + }); + const tasks = await prisma.task.findMany({ + where: { id: { in: ended.taskIds } }, + orderBy: { id: 'asc' }, + }); + expect(tasks.map((task) => task.status)).toEqual([ + 'CANCELLED', + 'CANCELLED', + 'DONE', + ]); + expect(tasks[2].completedAt).not.toBeNull(); + await expect( + prisma.queen.findUniqueOrThrow({ where: { id: ended.queenId } }), + ).resolves.toMatchObject({ status: 'SOLD' }); + await drainDomainEvents(app, [first, second]); + for (const id of [first, second]) { + const updates = await prisma.domainEvent.findMany({ + where: { aggregateId: id, type: 'TaskUpdated' }, + include: { deliveries: true }, + }); + expect(updates).toHaveLength(1); + expect(updates[0].payload).toMatchObject({ status: 'CANCELLED' }); + expect(updates[0].deliveries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + handler: 'calendar-sync.task', + status: 'DONE', + }), + ]), + ); + } + const suggestion = await prisma.suggestion.findUniqueOrThrow({ + where: { id: ended.suggestionId }, + }); + expect(suggestion).toMatchObject({ + status: 'RESOLVED', + decisionNote: 'Aufgeräumt', + dedupeKey: null, + }); + await expect( + prisma.auditLog.count({ + where: { + entityType: 'Suggestion', + entityId: ended.suggestionId, + action: 'UPDATE', + }, + }), + ).resolves.toBe(1); + }); + + it('apply-cleanup DONE writes the audit entry and TaskCompleted like a normal completion (delivered after draining)', async () => { + const ended = await endColony('done'); + const [first, ...rest] = [...ended.taskIds].sort(); + + await applyCleanup(tokens.full, ended.suggestionId, { + tasks: [ + { id: first, outcome: 'DONE' }, + ...rest.map((id) => ({ id, outcome: 'KEEP' })), + ], + }).expect(201); + await drainDomainEvents(app, [first]); + + const audit = await prisma.auditLog.findMany({ + where: { entityType: 'Task', entityId: first, action: 'UPDATE' }, + }); + expect(audit).toHaveLength(1); + expect(audit[0].afterData).toMatchObject({ status: 'DONE' }); + const completed = await prisma.domainEvent.findMany({ + where: { aggregateId: first, type: 'TaskCompleted' }, + include: { deliveries: true }, + }); + expect(completed).toHaveLength(1); + expect(completed[0].deliveries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + handler: 'calendar-sync.task', + status: 'DONE', + }), + ]), + ); + const kept = await prisma.task.findMany({ + where: { id: { in: rest } }, + }); + expect(kept.map((task) => task.status)).toEqual(['OPEN', 'OPEN']); + }); + + it('403 without tasks:complete when one task is DONE, and nothing changes', async () => { + const ended = await endColony('forbidden'); + const [first] = [...ended.taskIds].sort(); + + await applyCleanup(tokens.noComplete, ended.suggestionId, { + tasks: [{ id: first, outcome: 'DONE' }], + }).expect(403); + + await expect( + prisma.task.findUniqueOrThrow({ where: { id: first } }), + ).resolves.toMatchObject({ status: 'OPEN' }); + await expect( + prisma.suggestion.findUniqueOrThrow({ + where: { id: ended.suggestionId }, + }), + ).resolves.toMatchObject({ status: 'OPEN' }); + }); + + it('400 for a task id that is not in the evidence', async () => { + const ended = await endColony('foreign'); + const other = await newColony('foreign-other', 1); + + const response = await applyCleanup(tokens.full, ended.suggestionId, { + tasks: [{ id: other.taskIds[0], outcome: 'CANCEL' }], + }).expect(400); + + expect((response.body as { message: string }).message).toBe( + 'Nur Aufgaben und Königin aus dem Vorschlag.', + ); + await expect( + prisma.task.findUniqueOrThrow({ where: { id: other.taskIds[0] } }), + ).resolves.toMatchObject({ status: 'OPEN' }); + }); + + it('409 when the suggestion was cleaned up already', async () => { + const ended = await endColony('twice'); + + await applyCleanup(tokens.full, ended.suggestionId, { + tasks: [], + }).expect(201); + const second = await applyCleanup(tokens.full, ended.suggestionId, { + tasks: [], + }).expect(409); + + expect(second.body).toMatchObject({ + message: 'Der Vorschlag ist nicht mehr offen.', + status: 'RESOLVED', + }); + }); + + it('403 without queens:update when the queen is given, and nothing changes', async () => { + const ended = await endColony('noqueens'); + + await applyCleanup(tokens.noQueens, ended.suggestionId, { + tasks: [], + queen: { id: ended.queenId, status: 'SOLD' }, + }).expect(403); + + await expect( + prisma.queen.findUniqueOrThrow({ where: { id: ended.queenId } }), + ).resolves.toMatchObject({ status: 'ACTIVE' }); + }); + + it('400 for a queen id that is not in the evidence', async () => { + const ended = await endColony('foreignqueen'); + const other = await newColony('foreignqueen-other', 0); + + await applyCleanup(tokens.full, ended.suggestionId, { + tasks: [], + queen: { id: other.queenId, status: 'SOLD' }, + }).expect(400); + + await expect( + prisma.queen.findUniqueOrThrow({ where: { id: other.queenId } }), + ).resolves.toMatchObject({ status: 'ACTIVE' }); + }); + + it('400 when the evidence has no queen but the request names one', async () => { + const colony = await newColony('noqueenevidence'); + await prisma.queen.update({ + where: { id: colony.queenId }, + data: { status: 'LOST' }, + }); + await api() + .patch(`/api/v1/colonies/${colony.colonyId}`) + .set(auth(tokens.full)) + .send({ status: 'SOLD' }) + .expect(200); + await drainDomainEvents(app, [colony.colonyId]); + const suggestion = await prisma.suggestion.findFirstOrThrow({ + where: { colonyId: colony.colonyId, intent: 'CLEANUP', status: 'OPEN' }, + }); + expect((suggestion.evidence as { queenId: unknown }).queenId).toBeNull(); + + await applyCleanup(tokens.full, suggestion.id, { + tasks: [], + queen: { id: colony.queenId, status: 'SOLD' }, + }).expect(400); + }); + + it('409 when a colony holds the queen again, and the whole clean-up rolls back', async () => { + const ended = await endColony('held'); + const holder = await newColony('held-holder', 0); + await prisma.colony.update({ + where: { id: holder.colonyId }, + data: { currentQueenId: ended.queenId }, + }); + const [first] = [...ended.taskIds].sort(); + + await applyCleanup(tokens.full, ended.suggestionId, { + tasks: [{ id: first, outcome: 'CANCEL' }], + queen: { id: ended.queenId, status: 'DECEASED' }, + }).expect(409); + + await expect( + prisma.task.findUniqueOrThrow({ where: { id: first } }), + ).resolves.toMatchObject({ status: 'OPEN' }); + await expect( + prisma.suggestion.findUniqueOrThrow({ + where: { id: ended.suggestionId }, + }), + ).resolves.toMatchObject({ status: 'OPEN' }); + await expect( + prisma.queen.findUniqueOrThrow({ where: { id: ended.queenId } }), + ).resolves.toMatchObject({ status: 'ACTIVE' }); + }); + + it('reactivating the colony turns its open CLEANUP suggestion OBSOLETE', async () => { + const ended = await endColony('reactivate'); + + await api() + .post(`/api/v1/colonies/${ended.colonyId}/reactivate`) + .set(auth(tokens.full)) + .expect(201); + + await expect( + prisma.suggestion.findUniqueOrThrow({ + where: { id: ended.suggestionId }, + }), + ).resolves.toMatchObject({ status: 'OBSOLETE' }); + }); + + it('409 "Das Volk ist wieder aktiv." when the colony lives again but the suggestion is still open', async () => { + const ended = await endColony('living'); + await prisma.colony.update({ + where: { id: ended.colonyId }, + data: { status: 'ACTIVE', deactivatedAt: null }, + }); + const [first] = [...ended.taskIds].sort(); + + const response = await applyCleanup(tokens.full, ended.suggestionId, { + tasks: [{ id: first, outcome: 'CANCEL' }], + }).expect(409); + + expect((response.body as { message: string }).message).toBe( + 'Das Volk ist wieder aktiv.', + ); + await expect( + prisma.task.findUniqueOrThrow({ where: { id: first } }), + ).resolves.toMatchObject({ status: 'OPEN' }); + }); + + it('end, reactivate, end again the same day leaves one OBSOLETE and one OPEN CLEANUP suggestion', async () => { + const ended = await endColony('reend'); + + await api() + .post(`/api/v1/colonies/${ended.colonyId}/reactivate`) + .set(auth(tokens.full)) + .expect(201); + await api() + .patch(`/api/v1/colonies/${ended.colonyId}`) + .set(auth(tokens.full)) + .send({ status: 'DECEASED' }) + .expect(200); + await drainDomainEvents(app, [ended.colonyId]); + + const cleanups = await prisma.suggestion.findMany({ + where: { colonyId: ended.colonyId, intent: 'CLEANUP' }, + }); + expect(cleanups.map((row) => row.status).sort()).toEqual([ + 'OBSOLETE', + 'OPEN', + ]); + }); +}); diff --git a/ApiCore-Backend/test/harvests.e2e-spec.ts b/ApiCore-Backend/test/harvests.e2e-spec.ts index 8ee43f8..ef68383 100644 --- a/ApiCore-Backend/test/harvests.e2e-spec.ts +++ b/ApiCore-Backend/test/harvests.e2e-spec.ts @@ -11,6 +11,7 @@ interface HarvestBody { id: string; apiaryId: string; colonies: { colonyId: string }[]; + warnings: unknown[]; } describe('Harvests (e2e)', () => { @@ -133,6 +134,8 @@ describe('Harvests (e2e)', () => { const harvest = response.body as HarvestBody; harvestId = harvest.id; expect(harvest.colonies).toHaveLength(2); + // No treatment with a withdrawal period: the list is there, and empty. + expect(harvest.warnings).toEqual([]); }); it('wrote the audit entry and HarvestRecorded with the colony ids together with the harvest', async () => { @@ -202,11 +205,12 @@ describe('Harvests (e2e)', () => { }); it('records HarvestCorrected with the new colony set on an edit', async () => { - await request(app.getHttpServer()) + const edited = await request(app.getHttpServer()) .patch(`/api/v1/harvests/${harvestId}`) .set('Authorization', `Bearer ${staffToken}`) .send({ colonyIds: [colonyOneId] }) .expect(200); + expect((edited.body as HarvestBody).warnings).toEqual([]); const corrections = await prisma.domainEvent.findMany({ where: { diff --git a/ApiCore-Backend/test/inspections.e2e-spec.ts b/ApiCore-Backend/test/inspections.e2e-spec.ts index cc4262f..5290e9c 100644 --- a/ApiCore-Backend/test/inspections.e2e-spec.ts +++ b/ApiCore-Backend/test/inspections.e2e-spec.ts @@ -160,7 +160,7 @@ describe('Inspections (e2e)', () => { ]); }); - it('records InspectionCorrected for an edit, whose follow-ups are the calendar and the autopilot', async () => { + it('records InspectionCorrected for an edit, whose follow-ups are the autopilot, the rules and the calendar', async () => { await drainDomainEvents(app, [inspectionId]); await request(app.getHttpServer()) @@ -189,13 +189,18 @@ describe('Inspections (e2e)', () => { }); await drainDomainEvents(app, [inspectionId]); - // A correction evaluates no automation rule and sends no webhook; the - // calendar and the autopilot of the colony follow. + // A correction proposes nothing and sends no webhook; the autopilot, the + // automation rules (they only follow open suggestions) and the calendar + // follow. const deliveries = await prisma.domainEventDelivery.findMany({ where: { eventId: correction.id }, orderBy: { handler: 'asc' }, }); expect(deliveries).toEqual([ + expect.objectContaining({ + handler: 'automation.inspection-rules', + status: 'DONE', + }), expect.objectContaining({ handler: 'autopilot.colony-sync', status: 'DONE', diff --git a/ApiCore-Backend/test/next-steps.e2e-spec.ts b/ApiCore-Backend/test/next-steps.e2e-spec.ts new file mode 100644 index 0000000..aec717c --- /dev/null +++ b/ApiCore-Backend/test/next-steps.e2e-spec.ts @@ -0,0 +1,457 @@ +import { INestApplication } from '@nestjs/common'; +import * as bcrypt from 'bcryptjs'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import type { Prisma } from '../generated/prisma'; +import { NextStepsService } from '../src/autopilot/next-steps.service'; +import { addDays, berlinToday } from '../src/common/dates'; +import { PrismaService } from '../src/prisma/prisma.service'; +import { login } from './utils/api'; +import { createTestApp } from './utils/create-test-app'; + +const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`); + +/** The example of Spec 8.5: colony B3 on 12 May, in the swarm season by calendar. */ +const EXAMPLE_DAY = '2026-05-12'; + +interface StepBody { + key: string; + kind: string; + intent: string | null; + title: string; + reasons: string[]; + dueOn: string | null; + urgency: string; + action: { type: string; form?: string; href?: string; suggestionId?: string }; + dismissible: boolean; +} + +interface NextStepsBody { + colonyId: string; + segment: string; + inspectionDueOn: string | null; + withdrawalUntil: string | null; + steps: StepBody[]; +} + +interface TodayBody { + nextStep: { colonyId: string } | null; + inspectionDue: { apiaryId: string }[]; + suggestionsOpen: number; + seasons: { apiaryId: string; segment: string }[]; + tasksVisible: boolean; +} + +/** + * The next steps and the day overview against a real PostgreSQL. The + * assertions about order use the service with the fixed example day; the + * routes are checked for what the user may see, which does not depend on the + * day. Everything is scoped to this file's own apiary and colonies. + */ +describe('Next steps (e2e)', () => { + let app: INestApplication; + let prisma: PrismaService; + let nextSteps: NextStepsService; + const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`; + const password = 'SuperSecret123!'; + const FULL = [ + 'colonies:read', + 'tasks:read', + 'tasks:create', + 'inspections:read', + 'varroa:read', + 'treatments:read', + ]; + let apiaryId: string; + let colonyId: string; + let recorderId: string; + const roleIds: string[] = []; + const userIds: string[] = []; + const users = {} as Record< + 'full' | 'noVarroa' | 'noCreate' | 'noTasks', + { id: string; token: string } + >; + + async function createUser( + label: string, + permissions: string[], + ): Promise<{ id: string; token: string }> { + const role = await prisma.role.create({ + data: { + key: `NEXT_STEPS_${label.toUpperCase()}_${suffix}`, + name: `Next steps ${label}`, + permissions: { + create: permissions.map((key) => ({ + permission: { connect: { key } }, + })), + }, + }, + }); + roleIds.push(role.id); + const email = `next-steps-${label}-${suffix}@example.com`; + const user = await prisma.user.create({ + data: { + email, + passwordHash: await bcrypt.hash(password, 12), + name: `Next steps ${label}`, + role: { connect: { id: role.id } }, + }, + }); + userIds.push(user.id); + return { + id: user.id, + token: (await login(app, email, password)).accessToken, + }; + } + + function suggestion( + overrides: Partial, + ): Prisma.SuggestionUncheckedCreateInput { + return { + ruleKey: 'INSPECTION_INTERVAL', + source: 'RULE', + mode: 'DO', + intent: 'INSPECT', + relatedType: 'COLONY', + relatedId: colonyId, + colonyId, + reasonCode: 'NEXT_STEPS_E2E', + evidence: { colonyId }, + requiredPermission: 'inspections:read', + dueDate: d(EXAMPLE_DAY), + ...overrides, + }; + } + + /** The three suggestions of the spec example. */ + const exampleSuggestions = (): Prisma.SuggestionUncheckedCreateInput[] => [ + suggestion({ + source: 'AUTOPILOT', + mode: 'DO', + intent: 'INSPECT', + priority: 'HIGH', + reasonCode: 'INSPECTION_DUE', + evidence: { + colonyId, + inspectionId: 'none', + lastInspectedOn: '2026-05-04', + dueOn: '2026-05-11', + intervalDays: 7, + segment: 'SCHWARMZEIT', + }, + dueDate: d('2026-05-11'), + }), + suggestion({ + ruleKey: 'QUEEN_CELLS_SWARM_CHECK', + source: 'CHAIN', + mode: 'PLAN', + intent: 'SWARM_CHECK', + reasonCode: 'QUEEN_CELLS_SWARM_CHECK', + evidence: { + colonyId, + inspectionId: 'insp-1', + inspectedOn: '2026-05-05', + baseDate: '2026-05-05', + }, + }), + suggestion({ + ruleKey: 'VARROA_THRESHOLD', + mode: 'PLAN', + intent: 'VARROA_TREAT', + reasonCode: 'VARROA_RED', + evidence: { + colonyId, + measurementId: 'm-1', + measuredOn: '2026-05-10', + value: 12.4, + redAbove: 10, + unit: 'MITES_PER_DAY', + }, + requiredPermission: 'varroa:read', + }), + suggestion({ + ruleKey: 'SEASON_AUTOPILOT', + source: 'AUTOPILOT', + intent: 'HONEY_SUPER', + reasonCode: 'NEXT_STEPS_E2E_HONEY_SUPER', + dueDate: d('2026-05-14'), + }), + ]; + + const get = (token: string, path: string) => + request(app.getHttpServer()) + .get(`/api/v1${path}`) + .set('Authorization', `Bearer ${token}`); + + const cleanDb = async () => { + await prisma.suggestion.deleteMany({ where: { colonyId } }); + await prisma.task.deleteMany({ + where: { relatedType: 'COLONY', relatedId: colonyId }, + }); + await prisma.treatment.deleteMany({ where: { colonyId } }); + }; + + beforeAll(async () => { + app = await createTestApp(); + prisma = app.get(PrismaService); + nextSteps = app.get(NextStepsService); + apiaryId = ( + await prisma.apiary.create({ data: { name: `Next steps ${suffix}` } }) + ).id; + colonyId = ( + await prisma.colony.create({ + data: { + code: `B3-${suffix}`, + apiaryId, + colonyType: 'PRODUCTION_COLONY', + createdAt: d('2026-04-01'), + }, + }) + ).id; + users.full = await createUser('full', FULL); + users.noVarroa = await createUser( + 'novarroa', + FULL.filter((key) => key !== 'varroa:read'), + ); + users.noCreate = await createUser( + 'nocreate', + FULL.filter((key) => key !== 'tasks:create'), + ); + users.noTasks = await createUser( + 'notasks', + FULL.filter((key) => !key.startsWith('tasks:')), + ); + recorderId = users.full.id; + await prisma.inspection.create({ + data: { colonyId, userId: recorderId, date: d('2026-05-04') }, + }); + }); + + afterEach(cleanDb); + + afterAll(async () => { + await cleanDb(); + await prisma.inspection.deleteMany({ where: { colonyId } }); + await prisma.colony.delete({ where: { id: colonyId } }); + await prisma.apiary.delete({ where: { id: apiaryId } }); + 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('returns the three steps of the spec example in order', async () => { + await prisma.suggestion.createMany({ data: exampleSuggestions() }); + + const view = await nextSteps.forColony( + colonyId, + users.full.id, + EXAMPLE_DAY, + ); + + expect(view).toMatchObject({ + colonyId, + segment: 'SCHWARMZEIT', + phase: 'APFEL', + phaseSource: 'CALENDAR', + inspectionDueOn: '2026-05-11', + withdrawalUntil: null, + }); + expect(view.steps).toHaveLength(3); + const [inspection, treatment, honey] = view.steps; + expect(inspection).toMatchObject({ + title: 'Durchsicht mit Schwarmkontrolle', + urgency: 'OVERDUE', + dueOn: '2026-05-11', + action: { + type: 'CAPTURE', + form: 'inspection', + href: `/inspections/new?colonyId=${colonyId}`, + }, + dismissible: true, + }); + expect(inspection.reasons).toHaveLength(2); + expect(inspection.reasons[0]).toContain('Weiselzellen'); + expect(inspection.reasons[1]).toContain('Letzte Durchsicht vor 8 Tagen'); + expect(treatment).toMatchObject({ + intent: 'VARROA_TREAT', + action: { type: 'ACCEPT' }, + dismissible: true, + }); + expect(treatment.reasons.join(' ')).toContain('12,4'); + expect(honey).toMatchObject({ + intent: 'HONEY_SUPER', + title: 'Honigraum aufsetzen', + urgency: 'SOON', + action: { type: 'CAPTURE', form: 'inspection' }, + }); + }); + + it('a HONEY_SUPER PLAN suggestion stays a step of its own next to the merged inspection', async () => { + await prisma.suggestion.createMany({ + data: [ + ...exampleSuggestions().filter((row) => row.intent !== 'HONEY_SUPER'), + suggestion({ + ruleKey: 'SEASON_AUTOPILOT', + source: 'AUTOPILOT', + mode: 'PLAN', + intent: 'HONEY_SUPER', + reasonCode: 'NEXT_STEPS_E2E_HONEY_SUPER', + dueDate: d('2026-05-14'), + }), + ], + }); + + const view = await nextSteps.forColony( + colonyId, + users.full.id, + EXAMPLE_DAY, + ); + + const titles = view.steps.map((step) => step.title); + expect(titles).toContain('Durchsicht mit Schwarmkontrolle'); + expect(view.steps.map((step) => step.intent)).toContain('HONEY_SUPER'); + const merged = view.steps.find((step) => + step.title.startsWith('Durchsicht'), + ); + expect(merged?.suggestionIds).toHaveLength(2); + expect(merged?.dismissible).toBe(true); + }); + + it('next steps never carry a varroa reason without varroa:read', async () => { + await prisma.suggestion.createMany({ data: exampleSuggestions() }); + + const full = await get( + users.full.token, + `/colonies/${colonyId}/next-steps`, + ).expect(200); + const restricted = await get( + users.noVarroa.token, + `/colonies/${colonyId}/next-steps`, + ).expect(200); + + const fullBody = full.body as NextStepsBody; + const restrictedBody = restricted.body as NextStepsBody; + expect(fullBody.steps.map((step) => step.intent)).toContain('VARROA_TREAT'); + expect(restrictedBody.steps.map((step) => step.intent)).not.toContain( + 'VARROA_TREAT', + ); + expect(JSON.stringify(restrictedBody)).not.toMatch(/Milben|Behandlung/); + }); + + it('a user without tasks:read gets no suggestion steps and no ACCEPT action', async () => { + await prisma.suggestion.createMany({ data: exampleSuggestions() }); + await prisma.task.create({ + data: { + title: 'Wachs einschmelzen', + relatedType: 'COLONY', + relatedId: colonyId, + }, + }); + + const noTasks = await get( + users.noTasks.token, + `/colonies/${colonyId}/next-steps`, + ).expect(200); + const noCreate = await get( + users.noCreate.token, + `/colonies/${colonyId}/next-steps`, + ).expect(200); + + const hidden = noTasks.body as NextStepsBody; + expect(hidden.steps).toEqual([]); + // The inspection date is not a task thing: it stays. + expect(hidden.segment).toEqual(expect.any(String)); + const limited = noCreate.body as NextStepsBody; + expect(limited.steps.length).toBeGreaterThan(0); + expect(limited.steps.map((step) => step.action.type)).not.toContain( + 'ACCEPT', + ); + expect( + limited.steps.find((step) => step.intent === 'VARROA_TREAT')?.action, + ).toMatchObject({ type: 'CAPTURE', form: 'treatment' }); + }); + + it('withdrawalUntil appears as a hint, not as a step', async () => { + const today = berlinToday(); + await prisma.treatment.create({ + data: { + colonyId, + userId: recorderId, + date: d(addDays(today, -3)), + product: 'Oxalsäure', + withdrawalPeriodDays: 30, + }, + }); + + const full = await get( + users.full.token, + `/colonies/${colonyId}/next-steps`, + ).expect(200); + const readerWithoutTasks = await get( + users.noTasks.token, + `/colonies/${colonyId}/next-steps`, + ).expect(200); + + const body = full.body as NextStepsBody; + expect(body.withdrawalUntil).toBe(addDays(today, 27)); + expect(body.steps).toEqual([]); + expect((readerWithoutTasks.body as NextStepsBody).withdrawalUntil).toBe( + addDays(today, 27), + ); + }); + + it('answers 404 for an unknown colony and 403 without colonies:read', async () => { + await get(users.full.token, '/colonies/does-not-exist/next-steps').expect( + 404, + ); + const noColonies = await createUser('nocolonies', ['tasks:read']); + await get(noColonies.token, `/colonies/${colonyId}/next-steps`).expect(403); + await get(noColonies.token, '/autopilot/today').expect(403); + }); + + it('GET /autopilot/today lists due inspections per apiary and hides tasks without tasks:read', async () => { + await prisma.suggestion.createMany({ data: exampleSuggestions() }); + + const full = await nextSteps.today(users.full.id, EXAMPLE_DAY); + const entry = full.inspectionDue.find((item) => item.apiaryId === apiaryId); + expect(entry).toMatchObject({ + apiaryName: `Next steps ${suffix}`, + segment: 'SCHWARMZEIT', + intervalDays: 7, + colonies: [ + { + id: colonyId, + code: `B3-${suffix}`, + dueOn: '2026-05-11', + overdueDays: 1, + }, + ], + }); + expect(full.seasons).toContainEqual( + expect.objectContaining({ apiaryId, segment: 'SCHWARMZEIT' }), + ); + expect(full.suggestionsOpen).toBeGreaterThanOrEqual(4); + expect(full.tasksVisible).toBe(true); + + const noTasks = await get(users.noTasks.token, '/autopilot/today').expect( + 200, + ); + const hidden = noTasks.body as TodayBody; + expect(hidden).toMatchObject({ + nextStep: null, + suggestionsOpen: 0, + tasksVisible: false, + }); + expect(hidden.seasons.map((season) => season.apiaryId)).toContain(apiaryId); + + const allowed = await get(users.full.token, '/autopilot/today').expect(200); + const body = allowed.body as TodayBody; + expect(body.tasksVisible).toBe(true); + expect(body.suggestionsOpen).toBeGreaterThanOrEqual(4); + expect(body.nextStep).not.toBeNull(); + }); +}); diff --git a/ApiCore-Backend/test/notifications.e2e-spec.ts b/ApiCore-Backend/test/notifications.e2e-spec.ts index 12cd277..06a5c92 100644 --- a/ApiCore-Backend/test/notifications.e2e-spec.ts +++ b/ApiCore-Backend/test/notifications.e2e-spec.ts @@ -182,6 +182,35 @@ describe('Notifications (e2e)', () => { ).toBe(false); }); + it('deleting a user removes their notifications and settings', async () => { + const user = await prisma.user.create({ + data: { + email: `notifications-e2e-cascade-${suffix}@example.com`, + passwordHash: 'not-used', + name: 'Cascade', + role: { connect: { key: roleKey } }, + }, + }); + await prisma.notification.create({ + data: { + userId: user.id, + priority: 'INFO', + category: 'SYSTEM', + message: 'weg', + }, + }); + await prisma.notificationPreference.create({ data: { userId: user.id } }); + + await prisma.user.delete({ where: { id: user.id } }); + + expect( + await prisma.notification.count({ where: { userId: user.id } }), + ).toBe(0); + expect( + await prisma.notificationPreference.count({ where: { userId: user.id } }), + ).toBe(0); + }); + it('reading is per user: marking one copy read leaves the other unread', async () => { const [first, second] = await notifications.recordForUsers( [ids.beekeeper, ids.staff], diff --git a/ApiCore-Backend/test/winter-checks.e2e-spec.ts b/ApiCore-Backend/test/winter-checks.e2e-spec.ts index b8995b8..0a7db5e 100644 --- a/ApiCore-Backend/test/winter-checks.e2e-spec.ts +++ b/ApiCore-Backend/test/winter-checks.e2e-spec.ts @@ -203,7 +203,12 @@ describe('Winter checks (e2e)', () => { 'ColonyStatusChanged', 'ColonyEnded', ]); - expect(events[1].payload).toEqual({ status: 'DECEASED' }); + expect(events[1].payload).toEqual({ + status: 'DECEASED', + endedOn: '2002-03-20', + releasedQueenId: null, + via: 'WINTER_CHECK', + }); expect( await prisma.domainEvent.count({ where: { aggregateId: survivorId } }), ).toBe(0); diff --git a/ApiCore-Backend/test/withdrawal-chain.e2e-spec.ts b/ApiCore-Backend/test/withdrawal-chain.e2e-spec.ts new file mode 100644 index 0000000..45d6090 --- /dev/null +++ b/ApiCore-Backend/test/withdrawal-chain.e2e-spec.ts @@ -0,0 +1,644 @@ +import { INestApplication } from '@nestjs/common'; +import * as bcrypt from 'bcryptjs'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import { CalendarSyncService } from '../src/calendar-sync/calendar-sync.service'; +import { CalendarSyncSettingsService } from '../src/calendar-sync/calendar-sync-settings.service'; +import { DomainEventsService } from '../src/domain-events/domain-events.service'; +import { PrismaService } from '../src/prisma/prisma.service'; +import { createTestApp } from './utils/create-test-app'; +import { login } from './utils/api'; +import { drainDomainEvents } from './utils/domain-events'; + +interface Warning { + code: string; + severity: string; + message: string; + refs: { type: string; id: string }[]; +} + +interface WithWarningsBody { + id: string; + warnings: Warning[]; +} + +/** + * The withdrawal (Wartezeit) chain against a real PostgreSQL: a harvest or + * a bottling inside a treatment's window is written and answered with a + * warning, leaves one notification per harvest and colony, the end of a + * window tells that the colony may be harvested again, and the window + * reaches the M365 calendar as a span. The notifications fan out to every + * user holding the permissions, so the assertions only look at the reader + * created here and every block removes the rows it caused. + */ +describe('Withdrawal chain (e2e)', () => { + let app: INestApplication; + let prisma: PrismaService; + let events: DomainEventsService; + let calendarSync: CalendarSyncService; + const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`; + const password = 'SuperSecret123!'; + let apiaryId: string; + let adminId: string; + let readerId: string; + let roleId: string; + + const colonyIds: string[] = []; + const harvestIds: string[] = []; + const treatmentIds: string[] = []; + const bottlingIds: string[] = []; + const extractionIds: string[] = []; + const honeyBatchIds: string[] = []; + const rootIds: string[] = []; + + const server = () => app.getHttpServer(); + const asAdmin = { Authorization: '' }; + + async function colonyWith( + label: string, + ): Promise<{ id: string; code: string }> { + const colony = await prisma.colony.create({ + data: { + code: `WD-${label}-${suffix}`, + apiaryId, + colonyType: 'PRODUCTION_COLONY', + }, + }); + colonyIds.push(colony.id); + return { id: colony.id, code: colony.code }; + } + + async function treat( + colonyId: string, + day: string, + withdrawalPeriodDays: number | null, + ): Promise { + const treatment = await prisma.treatment.create({ + data: { + colonyId, + userId: adminId, + date: new Date(day.includes('T') ? day : `${day}T00:00:00.000Z`), + product: 'Oxuvar', + withdrawalPeriodDays, + }, + }); + treatmentIds.push(treatment.id); + return treatment.id; + } + + async function postHarvest(colonyId: string | string[], day: string) { + const response = await request(server()) + .post('/api/v1/harvests') + .set(asAdmin) + .send({ + apiaryId, + date: `${day}T00:00:00.000Z`, + colonyIds: Array.isArray(colonyId) ? colonyId : [colonyId], + }) + .expect(201); + const body = response.body as WithWarningsBody; + harvestIds.push(body.id); + rootIds.push(body.id); + return body; + } + + const conflictRowsOf = (harvestId: string, colonyId: string) => + prisma.notification.findMany({ + where: { + userId: readerId, + dedupeKey: { + startsWith: `withdrawal-conflict:${harvestId}:${colonyId}:`, + }, + }, + }); + + /** Removes what this file's chain wrote to anybody, so other files' users stay clean. */ + async function purgeNotifications(): Promise { + await prisma.notification.deleteMany({ + where: { + category: 'WITHDRAWAL', + OR: [ + { relatedId: { in: [...harvestIds, ...colonyIds] } }, + ...treatmentIds.map((id) => ({ + dedupeKey: { startsWith: `withdrawal-ended:${id}:` }, + })), + ], + }, + }); + } + + beforeAll(async () => { + app = await createTestApp(); + prisma = app.get(PrismaService); + events = app.get(DomainEventsService); + calendarSync = app.get(CalendarSyncService); + const passwordHash = await bcrypt.hash(password, 12); + const admin = await prisma.user.create({ + data: { + email: `withdrawal-e2e-admin-${suffix}@example.com`, + passwordHash, + name: 'Withdrawal admin', + role: { connect: { key: 'ADMIN' } }, + }, + }); + adminId = admin.id; + asAdmin.Authorization = `Bearer ${(await login(app, admin.email, password)).accessToken}`; + const role = await prisma.role.create({ + data: { + key: `WD_READER_${suffix}`, + name: 'Wartezeit Leser', + permissions: { + create: ['harvests:read', 'harvests:create', 'treatments:read'].map( + (key) => ({ permission: { connect: { key } } }), + ), + }, + }, + }); + roleId = role.id; + const reader = await prisma.user.create({ + data: { + email: `withdrawal-e2e-reader-${suffix}@example.com`, + passwordHash, + name: 'Withdrawal reader', + roleId, + }, + }); + readerId = reader.id; + apiaryId = ( + await prisma.apiary.create({ data: { name: `Wartezeit ${suffix}` } }) + ).id; + }); + + afterAll(async () => { + await purgeNotifications(); + const roots = await prisma.domainEvent.findMany({ + where: { aggregateId: { in: rootIds } }, + select: { correlationId: true }, + }); + await prisma.domainEvent.deleteMany({ + where: { + OR: [ + { aggregateId: { in: rootIds } }, + { correlationId: { in: roots.map((root) => root.correlationId) } }, + ], + }, + }); + await prisma.calendarSyncOutboxEntry.deleteMany({ + where: { + sourceId: { in: [...treatmentIds, ...harvestIds] }, + }, + }); + await prisma.task.deleteMany({ + where: { + OR: [ + { relatedId: { in: colonyIds } }, + { sourceSuggestion: { colonyId: { in: colonyIds } } }, + ], + }, + }); + await prisma.suggestion.deleteMany({ + where: { colonyId: { in: colonyIds } }, + }); + await prisma.auditLog.deleteMany({ + where: { + OR: [ + { userId: { in: [adminId, readerId] } }, + { + entityId: { + in: [...harvestIds, ...bottlingIds, ...treatmentIds], + }, + }, + ...colonyIds.map((colonyId) => ({ + entityType: 'Suggestion', + afterData: { path: ['colonyId'], equals: colonyId }, + })), + ], + }, + }); + await prisma.bottlingBatch.deleteMany({ + where: { honeyBatchId: { in: honeyBatchIds } }, + }); + await prisma.honeyBatch.deleteMany({ + where: { id: { in: honeyBatchIds } }, + }); + await prisma.extractionHarvest.deleteMany({ + where: { extractionId: { in: extractionIds } }, + }); + await prisma.extraction.deleteMany({ + where: { id: { in: extractionIds } }, + }); + await prisma.harvestColony.deleteMany({ + where: { harvestId: { in: harvestIds } }, + }); + await prisma.harvest.deleteMany({ where: { id: { in: harvestIds } } }); + await prisma.treatment.deleteMany({ where: { id: { in: treatmentIds } } }); + await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } }); + await prisma.apiary.deleteMany({ where: { id: apiaryId } }); + await prisma.refreshToken.deleteMany({ + where: { userId: { in: [adminId, readerId] } }, + }); + await prisma.user.deleteMany({ + where: { id: { in: [adminId, readerId] } }, + }); + await prisma.role.deleteMany({ where: { id: roleId } }); + await app.close(); + }); + + describe('harvest and bottling inside the window', () => { + let colony: { id: string; code: string }; + let harvestId: string; + + afterAll(purgeNotifications); + + beforeAll(async () => { + colony = await colonyWith('A'); + // 2026-07-10 + 20 days: harvestable again from 2026-07-30. + await treat(colony.id, '2026-07-10', 20); + }); + + it('a harvest inside the window answers 201 with a warning and leaves one notification', async () => { + const harvest = await postHarvest(colony.id, '2026-07-20'); + harvestId = harvest.id; + + expect(harvest.warnings).toEqual([ + { + code: 'WITHDRAWAL_ACTIVE', + severity: 'WARNUNG', + message: `${colony.code}: Wartezeit nach Oxuvar endet am 30.07.2026.`, + refs: [ + { type: 'Colony', id: colony.id }, + { type: 'Treatment', id: treatmentIds[0] }, + { type: 'Harvest', id: harvestId }, + ], + }, + ]); + // The request wrote the harvest all the same. + expect(await prisma.harvest.count({ where: { id: harvestId } })).toBe(1); + + await drainDomainEvents(app, [harvestId]); + await drainDomainEvents(app, [harvestId]); + + const rows = await conflictRowsOf(harvestId, colony.id); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + priority: 'WARNUNG', + category: 'WITHDRAWAL', + message: `Wartezeit: ${colony.code} am 20.07. geerntet, Wartezeit nach Oxuvar endet am 30.07.2026.`, + relatedType: 'Harvest', + relatedId: harvestId, + link: `/harvests/${harvestId}`, + read: false, + }); + expect(rows[0].requiredPermissions).toEqual( + expect.arrayContaining(['harvests:read', 'treatments:read']), + ); + }); + + it('a bottling of that harvest warns again but notifies no second time', async () => { + const extraction = await prisma.extraction.create({ + data: { date: new Date('2026-07-21T00:00:00.000Z'), userId: adminId }, + }); + extractionIds.push(extraction.id); + await prisma.extractionHarvest.create({ + data: { extractionId: extraction.id, harvestId }, + }); + const honeyBatch = await prisma.honeyBatch.create({ + data: { + batchNumber: `WD-HC-${suffix}`, + extractionId: extraction.id, + amount: 20, + }, + }); + honeyBatchIds.push(honeyBatch.id); + await drainDomainEvents(app, [harvestId]); + expect(await conflictRowsOf(harvestId, colony.id)).toHaveLength(1); + + const response = await request(server()) + .post('/api/v1/bottling-batches') + .set(asAdmin) + .send({ + honeyBatchId: honeyBatch.id, + date: '2026-07-22T00:00:00.000Z', + jarSize: 500, + jarCount: 4, + bestBeforeDate: '2028-07-22T00:00:00.000Z', + lotNumber: `WD-LOT-${suffix}`, + }) + .expect(201); + const bottling = response.body as WithWarningsBody; + bottlingIds.push(bottling.id); + rootIds.push(bottling.id); + + expect(bottling.warnings).toHaveLength(1); + expect(bottling.warnings[0]).toMatchObject({ + code: 'WITHDRAWAL_ACTIVE', + message: `${colony.code}: Wartezeit nach Oxuvar endet am 30.07.2026.`, + }); + + await drainDomainEvents(app, [bottling.id]); + + expect(await conflictRowsOf(harvestId, colony.id)).toHaveLength(1); + }); + + it('a harvest on the first harvestable day answers an empty warning list', async () => { + const harvest = await postHarvest(colony.id, '2026-07-30'); + + expect(harvest.warnings).toEqual([]); + await drainDomainEvents(app, [harvest.id]); + expect(await conflictRowsOf(harvest.id, colony.id)).toHaveLength(0); + }); + + it('a corrected harvest that moves out of the window answers an empty list; into it, a warning', async () => { + const harvest = await postHarvest(colony.id, '2026-09-01'); + expect(harvest.warnings).toEqual([]); + + const moved = await request(server()) + .patch(`/api/v1/harvests/${harvest.id}`) + .set(asAdmin) + .send({ date: '2026-07-12T00:00:00.000Z' }) + .expect(200); + expect((moved.body as WithWarningsBody).warnings).toHaveLength(1); + + const back = await request(server()) + .patch(`/api/v1/harvests/${harvest.id}`) + .set(asAdmin) + .send({ date: '2026-09-02T00:00:00.000Z' }) + .expect(200); + expect((back.body as WithWarningsBody).warnings).toEqual([]); + }); + }); + + describe('harvest of several colonies', () => { + afterAll(purgeNotifications); + + it('warns and notifies only for the colony that is inside a window', async () => { + const inside = await colonyWith('D1'); + const outside = await colonyWith('D2'); + await treat(inside.id, '2026-07-10', 20); + + const harvest = await postHarvest([inside.id, outside.id], '2026-07-20'); + + expect(harvest.warnings).toHaveLength(1); + expect(harvest.warnings[0].refs).toContainEqual({ + type: 'Colony', + id: inside.id, + }); + expect(harvest.warnings[0].message.startsWith(`${inside.code}:`)).toBe( + true, + ); + await drainDomainEvents(app, [harvest.id]); + expect(await conflictRowsOf(harvest.id, inside.id)).toHaveLength(1); + expect(await conflictRowsOf(harvest.id, outside.id)).toHaveLength(0); + }); + }); + + describe('treatment entered after the harvest', () => { + afterAll(purgeNotifications); + + it('a backdated treatment notifies the harvests it covers, once; a correction that extends the window adds the newly covered ones', async () => { + const colony = await colonyWith('E'); + const early = await postHarvest(colony.id, '2026-07-20'); + const late = await postHarvest(colony.id, '2026-08-05'); + expect(early.warnings).toEqual([]); + await drainDomainEvents(app, [early.id, late.id]); + // The early harvest was bottled before the treatment was entered. + const extraction = await prisma.extraction.create({ + data: { date: new Date('2026-07-21T00:00:00.000Z'), userId: adminId }, + }); + extractionIds.push(extraction.id); + await prisma.extractionHarvest.create({ + data: { extractionId: extraction.id, harvestId: early.id }, + }); + const honeyBatch = await prisma.honeyBatch.create({ + data: { + batchNumber: `WD-HC2-${suffix}`, + extractionId: extraction.id, + amount: 5, + }, + }); + honeyBatchIds.push(honeyBatch.id); + await prisma.bottlingBatch.create({ + data: { + honeyBatchId: honeyBatch.id, + userId: adminId, + date: new Date('2026-07-22T00:00:00.000Z'), + jarSize: 500, + jarCount: 2, + totalAmount: 1, + bestBeforeDate: new Date('2028-07-22T00:00:00.000Z'), + lotNumber: `WD-LOT2-${suffix}`, + }, + }); + + const created = await request(server()) + .post('/api/v1/treatments') + .set(asAdmin) + .send({ + colonyId: colony.id, + date: '2026-07-10T00:00:00.000Z', + product: 'Oxuvar', + withdrawalPeriodDays: 20, + }) + .expect(201); + const treatmentId = (created.body as { id: string }).id; + treatmentIds.push(treatmentId); + rootIds.push(treatmentId); + await drainDomainEvents(app, [treatmentId]); + + // 2026-07-10 + 20 = harvestable from 2026-07-30: only the early harvest. + const earlyRows = await conflictRowsOf(early.id, colony.id); + expect(earlyRows).toHaveLength(1); + expect(earlyRows[0].message).toContain( + `Bereits abgefüllt: Los WD-LOT2-${suffix}.`, + ); + expect(await conflictRowsOf(late.id, colony.id)).toHaveLength(0); + + await request(server()) + .patch(`/api/v1/treatments/${treatmentId}`) + .set(asAdmin) + .send({ withdrawalPeriodDays: 40 }) + .expect(200); + await drainDomainEvents(app, [treatmentId]); + + // Until 2026-08-19: the late harvest is covered now; the early one + // is not notified a second time. + expect(await conflictRowsOf(early.id, colony.id)).toHaveLength(1); + expect(await conflictRowsOf(late.id, colony.id)).toHaveLength(1); + }); + }); + + describe('end of a window', () => { + afterAll(purgeNotifications); + + const endedEvent = ( + treatmentId: string, + colonyId: string, + endedOn: string, + ) => + prisma.$transaction((tx) => + events.record(tx, { + type: 'WithdrawalEnded', + aggregateType: 'Treatment', + aggregateId: treatmentId, + payload: { colonyId, endedOn }, + }), + ); + + const readyRows = (colonyId: string) => + prisma.notification.findMany({ + where: { + userId: readerId, + relatedId: colonyId, + category: 'WITHDRAWAL', + dedupeKey: { startsWith: 'withdrawal-ended:' }, + }, + }); + + it('WithdrawalEnded yields one INFO only when no other window is running', async () => { + const colony = await colonyWith('B'); + // First window: 2026-06-01 + 10 = 2026-06-11. Second: 2026-06-05 + 20 = 2026-06-25. + const first = await treat(colony.id, '2026-06-01', 10); + const second = await treat(colony.id, '2026-06-05', 20); + rootIds.push(first, second); + + await endedEvent(first, colony.id, '2026-06-11'); + await drainDomainEvents(app, [first]); + expect(await readyRows(colony.id)).toHaveLength(0); + + // Recorded twice: the job could deliver the same end again. + await endedEvent(second, colony.id, '2026-06-25'); + await endedEvent(second, colony.id, '2026-06-25'); + await drainDomainEvents(app, [second]); + + const rows = await readyRows(colony.id); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + priority: 'INFO', + category: 'WITHDRAWAL', + message: `${colony.code} ist wieder erntefähig. Wartezeit nach Oxuvar endete am 25.06.2026.`, + relatedType: 'Colony', + link: `/colonies/${colony.id}`, + }); + expect(rows[0].requiredPermissions).toEqual( + expect.arrayContaining(['harvests:create', 'treatments:read']), + ); + }); + }); + + describe('end of a window across the Berlin day boundary', () => { + afterAll(purgeNotifications); + + it('takes the end day from the Berlin day of a treatment stored with a time part', async () => { + const colony = await colonyWith('F'); + // 2026-06-01T22:30Z is 00:30 on 2026-06-02 in Berlin; one day later + // the period ends on 2026-06-03 (the plain UTC day would say 06-02). + const id = await treat(colony.id, '2026-06-01T22:30:00.000Z', 1); + rootIds.push(id); + const record = (endedOn: string) => + prisma.$transaction((tx) => + events.record(tx, { + type: 'WithdrawalEnded', + aggregateType: 'Treatment', + aggregateId: id, + payload: { colonyId: colony.id, endedOn }, + }), + ); + + await record('2026-06-02'); + await drainDomainEvents(app, [id]); + expect( + await prisma.notification.count({ + where: { userId: readerId, relatedId: colony.id }, + }), + ).toBe(0); + + await record('2026-06-03'); + await drainDomainEvents(app, [id]); + const rows = await prisma.notification.findMany({ + where: { userId: readerId, relatedId: colony.id }, + }); + expect(rows).toHaveLength(1); + expect(rows[0].message).toContain('endete am 03.06.2026'); + expect(rows[0].message).toContain(colony.code); + }); + }); + + describe('calendar entry', () => { + // The settings spy is this file's only mock; the shared singleton row is + // never touched. + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('a treatment with withdrawal days leaves a WITHDRAWAL calendar entry; removing the days unregisters it', async () => { + const colony = await colonyWith('C'); + 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(), + }); + + const created = await request(server()) + .post('/api/v1/treatments') + .set(asAdmin) + .send({ + colonyId: colony.id, + date: '2026-08-03T00:00:00.000Z', + product: 'Oxuvar', + withdrawalPeriodDays: 20, + }) + .expect(201); + const treatmentId = (created.body as { id: string }).id; + treatmentIds.push(treatmentId); + rootIds.push(treatmentId); + await drainDomainEvents(app, [treatmentId]); + + const entry = await prisma.calendarSyncOutboxEntry.findUniqueOrThrow({ + where: { + sourceType_sourceId: { + sourceType: 'WITHDRAWAL', + sourceId: treatmentId, + }, + }, + }); + expect(entry.status).toBe('PENDING'); + const buildEventInput = (id: string) => + ( + calendarSync as unknown as { + buildEventInput(type: string, id: string): Promise; + } + ).buildEventInput('WITHDRAWAL', id); + // Graph's end day is exclusive: the first harvestable day. + expect(await buildEventInput(treatmentId)).toEqual({ + title: `Wartezeit ${colony.code} · Oxuvar`, + date: new Date('2026-08-03T00:00:00.000Z'), + endDate: new Date('2026-08-23T00:00:00.000Z'), + }); + + await request(server()) + .patch(`/api/v1/treatments/${treatmentId}`) + .set(asAdmin) + .send({ withdrawalPeriodDays: null }) + .expect(200); + await drainDomainEvents(app, [treatmentId]); + + const after = await prisma.calendarSyncOutboxEntry.findUniqueOrThrow({ + where: { id: entry.id }, + }); + // Enqueued again: the worker finds no span and removes the event. + expect(after.revision).toBeGreaterThan(entry.revision); + expect(after.status).toBe('PENDING'); + expect(await buildEventInput(treatmentId)).toBeNull(); + }); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.spec.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.spec.tsx index 38a43ac..b7682af 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.spec.tsx @@ -1,9 +1,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { render, screen, within } from "@testing-library/react"; +import { fireEvent, render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ColonyActions } from "./colony-actions"; -import { fetchColonies, mergeColony } from "@/lib/colonies/api"; +import { fetchColonies, mergeColony, splitColony } from "@/lib/colonies/api"; +import { ApiError } from "@/lib/auth/api-client"; +import { todayLocalIsoDate } from "@/lib/dates"; import type { Colony, ColonyStatus } from "@/lib/colonies/types"; vi.mock("@/lib/colonies/api", () => ({ @@ -31,6 +33,7 @@ function colony(id: string, code: string, status: ColonyStatus = "ACTIVE"): Colo colonyType: "PRODUCTION_COLONY", status, strength: null, + occupiedFrames: null, broodStatus: null, feedStatus: null, healthStatus: null, @@ -140,3 +143,106 @@ describe("ColonyActions – colonies that need attention", () => { expect(options.map((option) => option.textContent)).toEqual(["V-02", "V-03 (Weisellos)"]); }); }); + +describe("ColonyActions – split", () => { + beforeEach(() => { + vi.mocked(fetchColonies).mockResolvedValue([target]); + vi.mocked(splitColony).mockReset(); + vi.mocked(splitColony).mockResolvedValue(other); + }); + + async function openSplit() { + renderActions(); + await userEvent.click(screen.getByRole("button", { name: "Ableger bilden" })); + return screen.findByRole("dialog"); + } + + const originGroup = (dialog: HTMLElement) => + within(dialog).getByRole("radiogroup", { name: /Woher kommt die Königin\?/ }); + + it("the split dialog sends date and queenSource with nothing preselected", async () => { + const dialog = await openSplit(); + + const group = originGroup(dialog); + const radios = within(group).getAllByRole("radio"); + expect(radios.map((radio) => radio.textContent)).toEqual([ + "Eigene Zellen ziehen", + "Weiselzelle", + "Königin", + "Weiß ich noch nicht", + ]); + expect(radios.every((radio) => radio.getAttribute("aria-checked") === "false")).toBe(true); + const date = within(dialog).getByLabelText(/Datum des Ablegers/); + expect(date).toHaveValue(todayLocalIsoDate()); + expect(date).toHaveAttribute("max", todayLocalIsoDate()); + + await userEvent.type(within(dialog).getByLabelText(/Code des Ablegers/), "N-01"); + fireEvent.change(date, { target: { value: "2026-10-10" } }); + await userEvent.click(within(group).getByRole("radio", { name: "Eigene Zellen ziehen" })); + expect( + within(dialog).getByText("Es entsteht ein Plan: Zellen prüfen, Begattung, Stifte."), + ).toBeInTheDocument(); + await userEvent.click(within(dialog).getByRole("button", { name: "Ableger anlegen" })); + + expect(splitColony).toHaveBeenCalledWith("colony-1", { + code: "N-01", + colonyType: "NUCLEUS_COLONY", + date: "2026-10-10", + queenSource: "RAISE_OWN", + }); + }); + + it("states the consequence of each origin", async () => { + const dialog = await openSplit(); + const group = originGroup(dialog); + + await userEvent.click(within(group).getByRole("radio", { name: "Weiselzelle" })); + expect(within(dialog).getByText("Es entsteht ein Plan: Stifte prüfen.")).toBeInTheDocument(); + await userEvent.click(within(group).getByRole("radio", { name: "Königin" })); + expect( + within(dialog).getByText("Es entsteht ein Plan: Annahme der Königin prüfen."), + ).toBeInTheDocument(); + await userEvent.click(within(group).getByRole("radio", { name: "Weiß ich noch nicht" })); + expect(within(dialog).getByText("Ohne Angabe entsteht kein Plan.")).toBeInTheDocument(); + }); + + it("„Weiß ich noch nicht“ sends no queenSource", async () => { + const dialog = await openSplit(); + + await userEvent.type(within(dialog).getByLabelText(/Code des Ablegers/), "N-02"); + await userEvent.click( + within(originGroup(dialog)).getByRole("radio", { name: "Weiß ich noch nicht" }), + ); + await userEvent.click(within(dialog).getByRole("button", { name: "Ableger anlegen" })); + + const [, input] = vi.mocked(splitColony).mock.calls[0]; + expect(input).toMatchObject({ code: "N-02", date: todayLocalIsoDate() }); + expect(input).not.toHaveProperty("queenSource"); + }); + + it("a future date shows the server message at the date field", async () => { + vi.mocked(splitColony).mockRejectedValue( + new ApiError(400, "Das Datum liegt in der Zukunft."), + ); + const dialog = await openSplit(); + + await userEvent.type(within(dialog).getByLabelText(/Code des Ablegers/), "N-03"); + await userEvent.click(within(dialog).getByRole("button", { name: "Ableger anlegen" })); + + const message = await within(dialog).findByText("Das Datum liegt in der Zukunft."); + const date = within(dialog).getByLabelText(/Datum des Ablegers/); + expect(date).toHaveAttribute("aria-invalid", "true"); + expect(date.getAttribute("aria-describedby")).toContain(message.id); + }); + + it("asks for the date when it was cleared", async () => { + const dialog = await openSplit(); + + await userEvent.type(within(dialog).getByLabelText(/Code des Ablegers/), "N-04"); + fireEvent.change(within(dialog).getByLabelText(/Datum des Ablegers/), { target: { value: "" } }); + await userEvent.click(within(dialog).getByRole("button", { name: "Ableger anlegen" })); + + expect(within(dialog).getByText("Bitte ein Datum angeben.")).toBeInTheDocument(); + expect(splitColony).not.toHaveBeenCalled(); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.tsx index 8877842..1c21fed 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.tsx @@ -10,8 +10,10 @@ import { reactivateColony, splitColony, } from "@/lib/colonies/api"; -import { Colony, ColonyType } from "@/lib/colonies/types"; -import { COLONY_TYPES, COLONY_TYPE_LABELS } from "@/lib/colonies/labels"; +import { Colony, ColonyType, SplitQueenSource } from "@/lib/colonies/types"; +import { COLONY_TYPES, COLONY_TYPE_LABELS, SPLIT_QUEEN_SOURCE_LABELS } from "@/lib/colonies/labels"; +import { ApiError } from "@/lib/auth/api-client"; +import { todayLocalIsoDate } from "@/lib/dates"; import { isLivingColony } from "@/lib/colonies/status"; import { fetchApiaries } from "@/lib/apiaries/api"; import { fetchColonies } from "@/lib/colonies/api"; @@ -21,6 +23,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { ConfirmDialog } from "@/components/confirm-dialog"; import { Field } from "@/components/form/field"; +import { ChoiceGroup, choiceOptions, type ChoiceOption } from "@/components/form/choice-group"; import { LivingColonySelectField } from "@/components/colony-select-field"; import { Dialog, @@ -136,24 +139,63 @@ function MoveDialog({ colony }: { colony: Colony }) { ); } +/** "" = not answered yet; "UNKNOWN" = the beekeeper said they do not know. */ +type QueenOriginChoice = SplitQueenSource | "UNKNOWN" | ""; + +const QUEEN_ORIGIN_OPTIONS: readonly ChoiceOption[] = [ + ...choiceOptions(SPLIT_QUEEN_SOURCE_LABELS), + { value: "UNKNOWN", label: "Weiß ich noch nicht" }, +]; + +/** What follows from the choice: the chain of checks the server plans. */ +const QUEEN_ORIGIN_CONSEQUENCE: Record = { + RAISE_OWN: "Es entsteht ein Plan: Zellen prüfen, Begattung, Stifte.", + QUEEN_CELL: "Es entsteht ein Plan: Stifte prüfen.", + QUEEN: "Es entsteht ein Plan: Annahme der Königin prüfen.", + UNKNOWN: "Ohne Angabe entsteht kein Plan.", + "": "Ohne Angabe entsteht kein Plan.", +}; + +function queenSourceOf(choice: QueenOriginChoice): SplitQueenSource | undefined { + return choice === "" || choice === "UNKNOWN" ? undefined : choice; +} + function SplitDialog({ colony }: { colony: Colony }) { const queryClient = useQueryClient(); const [open, setOpen] = useState(false); const [code, setCode] = useState(""); const [colonyType, setColonyType] = useState("NUCLEUS_COLONY"); + const [date, setDate] = useState(todayLocalIsoDate); + // The origin of the queen is an observation: nothing is preselected. + const [origin, setOrigin] = useState(""); const [codeError, setCodeError] = useState(null); + const [dateError, setDateError] = useState(null); const [error, setError] = useState(null); const mutation = useMutation({ - mutationFn: () => splitColony(colony.id, { code, colonyType }), + mutationFn: () => { + const queenSource = queenSourceOf(origin); + return splitColony(colony.id, { + code, + colonyType, + date, + ...(queenSource ? { queenSource } : {}), + }); + }, onSuccess: () => { invalidateColony(queryClient, colony.id); void queryClient.invalidateQueries({ queryKey: ["colonies"] }); toast.success(`Ableger ${code} angelegt`); setOpen(false); }, - onError: (mutationError) => - setError(errorMessage(mutationError, "Ableger konnte nicht angelegt werden.")), + onError: (mutationError) => { + // The date's own complaint belongs at the date field, not in the footer. + if (mutationError instanceof ApiError && mutationError.status === 400 && /Datum/.test(mutationError.message)) { + setDateError(mutationError.message); + return; + } + setError(errorMessage(mutationError, "Ableger konnte nicht angelegt werden.")); + }, }); function handleSubmit(event: FormEvent) { @@ -163,6 +205,10 @@ function SplitDialog({ colony }: { colony: Colony }) { setCodeError("Bitte einen Code für den Ableger angeben."); return; } + if (!date) { + setDateError("Bitte ein Datum angeben."); + return; + } mutation.mutate(); } @@ -210,6 +256,41 @@ function SplitDialog({ colony }: { colony: Colony }) { /> )} + + {({ id, describedBy, invalid }) => ( + { + setDate(event.target.value); + setDateError(null); + }} + /> + )} + + + {({ id, labelId }) => ( + <> + +

+ {QUEEN_ORIGIN_CONSEQUENCE[origin]} +

+ + )} +