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:
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[]>;
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
Reference in new issue
Block a user