feat: T4 wave 5 - colony end cleanup, withdrawal chain, next steps, inspection takeover UI

- Colony end releases the queen in the same transaction; open
  suggestions become obsolete and a CLEANUP suggestion lists open
  tasks and the queen; apply-cleanup validates evidence and blocks
  reactivated colonies
- Withdrawal chain: plausibility warnings on harvest and bottling,
  conflict notifications also when a treatment is entered or corrected
  after the harvest (naming bottled lots), withdrawal spans in the
  calendar and M365 sync, Berlin-day end events
- Next 3 steps per colony and the "Heute" endpoint with ranking and an
  explicit merge table by intent
- Inspection-triggered rule suggestions follow the colony's latest
  inspection (resolve when the finding is gone, refresh when it stays)
- Frontend: colony card takeover in the inspection form, feed status as
  condition, occupied frames 0-40; legacy offline entries keep syncing
- Notifications and preferences are deleted with their user

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 16:28:07 +02:00
1 parent 02eb16924e
commit ef33e5549b
122 files changed
+10707 -504

No files matched your search

@@ -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],
@@ -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> = {},
): 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);
});
});
@@ -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<string>;
overview: ColonyOverview;
nextSteps: ReadonlyMap<string, ColonyNextSteps>;
}
/**
@@ -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<OfflinePackV1> {
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<string>,
): Promise<Map<string, { WARNUNG?: number; KRITISCH?: number }>> {
const counts = new Map<string, { WARNUNG?: number; KRITISCH?: number }>();
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<OfflinePackV1['queens']> {
const queenIds = colonies.flatMap((colony) =>
colony.currentQueenId ? [colony.currentQueenId] : [],
@@ -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<string, NextStep[]>;
}
+4
View File
@@ -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,
@@ -42,6 +42,7 @@ describe('AutomationEventHandlers', () => {
let tx: Record<RecordModel, { findUnique: jest.Mock }>;
let automation: Record<
| 'evaluateInspectionIn'
| 'evaluateInspectionCorrectionIn'
| 'evaluateVarroaMeasurementIn'
| 'evaluateTreatmentIn'
| 'evaluateFeedingIn'
@@ -63,6 +64,7 @@ describe('AutomationEventHandlers', () => {
) as Record<RecordModel, { findUnique: jest.Mock }>;
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',
@@ -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<void> {
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
@@ -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<string, unknown> = {}) =>
({
...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 = (
@@ -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<void> {
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<void> {
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<void> {
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<void> {
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),
@@ -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,
@@ -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<AutopilotToday> {
return this.nextSteps.today(actor.id);
}
}
@@ -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 {}
@@ -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<ColonyNextSteps> {
return this.nextSteps.forColony(id, actor.id);
}
}
@@ -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;
}
@@ -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> = {}): 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']);
});
});
@@ -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:<id>' | 'suggestion:<id>' */
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<Record<SuggestionIntent, NextStepForm>>
> = {
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<Record<NextStepForm, string>> = {
inspection: 'Durchsicht',
varroa: 'Varroa messen',
feeding: 'Füttern',
treatment: 'Behandlung',
'winter-check': 'Winterkontrolle',
};
const PRIORITY_RANK: Readonly<Record<TaskPriority, number>> = {
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<Partial<Record<SuggestionIntent, NextStepForm>>> =
{
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<string, StepSource[]>();
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<string>();
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 }));
}
@@ -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<string, unknown> = {}) => ({
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();
});
});
});
@@ -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<string, ColonyComputation>;
seasons: Map<string, SeasonState>;
intervalDays: Readonly<
Record<Exclude<SeasonSegmentKey, 'WINTERRUHE'>, 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<ColonyNextSteps> {
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<string>,
): Promise<Map<string, ColonyNextSteps>> {
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<string>,
): Promise<Map<string, WarningCount>> {
const held =
permissions ?? (await this.permissionResolver.getPermissions(userId));
const counts = new Map<string, WarningCount>();
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<AutopilotToday> {
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<string, ColonyComputation>,
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<string>,
today: string,
): Promise<Computation> {
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<string, ColonyComputation>();
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<string>,
): Promise<Map<string, string>> {
const days = new Map<string, string>();
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<string>,
today: string,
): Promise<Map<string, string>> {
const until = new Map<string, string>();
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<string>,
today: string,
): Promise<Map<string, StepSource[]>> {
const sources = new Map<string, StepSource[]>();
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<Map<AutomationTriggerType, string | null>> {
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),
);
}
}
+1 -1
View File
@@ -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.
@@ -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],
})
@@ -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',
);
});
});
});
@@ -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<T extends object>(
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);
});
}
@@ -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([
@@ -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<void> {
await this.calendarSync.enqueueIn(tx, 'WITHDRAWAL', event.aggregateId);
}
@OnDomainEvent(
['FeedingRecorded', 'FeedingCorrected'],
'calendar-sync.feeding',
@@ -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],
@@ -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<string, unknown> = {}) => ({
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 () => {
@@ -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<void>,
): Promise<void> {
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<void> {
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<void> {
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),
};
}
}
}
@@ -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(
@@ -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,
@@ -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);
}
}
@@ -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<string, unknown> = {}) => ({
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<string, unknown> }).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();
});
});
});
@@ -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<CalendarEvent[]> {
/**
* 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<CalendarEvent[]> {
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<CalendarEvent[]> {
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<TreatmentWithColony[]>([]),
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());
@@ -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<string, unknown> },
][];
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<string, unknown> },
][];
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();
});
});
});
@@ -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(
{
@@ -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);
});
});
@@ -13,7 +13,7 @@ function colony(overrides: Partial<Colony> = {}): 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');
});
});
+14 -2
View File
@@ -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<Colony, 'status' | 'healthStatus'>;
@@ -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<Colony, 'id' | 'status' | 'healthStatus'>,
after: Pick<Colony, 'id' | 'status' | 'healthStatus'>,
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;
@@ -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();
});
});
@@ -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<string | null> {
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;
}
@@ -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',
]);
});
@@ -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([]);
});
});
@@ -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<PlausibilityWarningCode, WarningSeverity>
> = 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> = T & { warnings: PlausibilityWarning[] };
/** The record as the response body, plus the warnings found for it. */
export function withWarnings<T extends object>(
record: T,
warnings: readonly PlausibilityWarning[],
): WithWarnings<T> {
return { ...record, warnings: [...warnings] };
}
@@ -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: {
@@ -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],
@@ -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',
]);
});
});
});
@@ -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<T extends { id: string }>(
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);
});
}
}
@@ -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(
@@ -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',
}),
]);
});
});
@@ -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<TreatmentRow[]>`
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) {
@@ -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();
});
});
});
+56 -1
View File
@@ -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<Queen> {
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,
@@ -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<CleanupResultDto> {
await this.views.assertVisible(actor.id, [id]);
return this.cleanup.apply(id, dto, {
id: actor.id,
permissions: await this.permissions.getPermissions(actor.id),
});
}
}
@@ -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 {}
@@ -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> = {}): 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<string> = 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<unknown>) =>
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();
});
});
@@ -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<string>;
}
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<CleanupResult> {
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<string>,
): 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<void> {
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<Omit<CleanupResult, 'queenStatus'>> {
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<QueenStatus | null> {
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<void> {
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,
);
}
}
@@ -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;
@@ -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<string, unknown>][]
)[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,
});
});
});
@@ -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<void> {
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<string | null> {
if (!queenId) {
return null;
}
const queen = await tx.queen.findUnique({
where: { id: queenId },
select: { status: true },
});
return queen?.status === 'ACTIVE' ? queenId : null;
}
}
@@ -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 {}
@@ -1,4 +1,68 @@
import type { SuggestionTextRegister } from '../suggestion-texts';
import { formatDayDe } from './format-day';
const ENDED_VERBS: Readonly<Record<string, string>> = {
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 };
},
},
};
@@ -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<string, unknown> }) =>
@@ -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');
+82 -24
View File
@@ -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<Task> {
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<Task> {
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<Task> {
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;
}
/**
@@ -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');
@@ -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(
{
@@ -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>): 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> = {},
): 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');
});
});
});
@@ -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<WithdrawalConflict[]> {
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<WithdrawalConflict[]> {
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<WithdrawalConflict[]> {
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<PlausibilityWarning[]> {
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<number> {
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<Map<string, string[]>> {
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<string, string[]>();
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<Map<string, WithdrawalWindow[]>> {
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<string, WithdrawalWindow[]>();
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<Map<string, string>> {
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]));
}
}
@@ -0,0 +1,43 @@
import { withdrawalSpanOf } from './withdrawal-span';
const treatment = (overrides: Record<string, unknown> = {}) => ({
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',
}),
);
});
});
@@ -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<typeof withdrawalWindowOf>[0],
): WithdrawalWindow | null {
const window = withdrawalWindowOf(treatment);
return window !== null && window.fromDay < window.untilDay ? window : null;
}
@@ -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 = <T extends DomainEventType>(
type: T,
aggregateId: string,
payload: Record<string, unknown>,
) => ({ type, aggregateId, payload }) as unknown as AnyDomainEventEnvelope<T>;
const treatmentRow = (overrides: Record<string, unknown> = {}) => ({
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();
});
});
});
@@ -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<void> {
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<void> {
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<void> {
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<void> {
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<boolean> {
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);
});
}
}
@@ -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 {}
@@ -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],