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

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

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

No files matched your search

@@ -0,0 +1,16 @@
-- T4 follow-up: notifications and their settings go with their user.
-- Users are deactivated, never hard-deleted, in production, so RESTRICT
-- protected nothing; but fan-out writes rows to users of other test files,
-- and deleting such a user must not fail on a foreign notification.
-- DropForeignKey
ALTER TABLE "Notification" DROP CONSTRAINT "Notification_userId_fkey";
-- DropForeignKey
ALTER TABLE "NotificationPreference" DROP CONSTRAINT "NotificationPreference_userId_fkey";
-- AddForeignKey
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "NotificationPreference" ADD CONSTRAINT "NotificationPreference_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+2 -2
View File
@@ -1391,7 +1391,7 @@ enum NotificationCategory {
model Notification { model Notification {
id String @id @default(cuid()) id String @id @default(cuid())
userId String userId String
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
priority NotificationPriority priority NotificationPriority
category NotificationCategory category NotificationCategory
message String message String
@@ -1410,7 +1410,7 @@ model Notification {
// Einstellungen je Nutzer; Standard beim ersten Lesen angelegt. // Einstellungen je Nutzer; Standard beim ersten Lesen angelegt.
model NotificationPreference { model NotificationPreference {
userId String @id userId String @id
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
taskDueEnabled Boolean @default(true) taskDueEnabled Boolean @default(true)
digestEnabled Boolean @default(true) digestEnabled Boolean @default(true)
digestTime String @default("06:30") digestTime String @default("06:30")
@@ -1,9 +1,11 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AutopilotModule } from '../autopilot/autopilot.module';
import { ApiariesController } from './apiaries.controller'; import { ApiariesController } from './apiaries.controller';
import { ApiariesService } from './apiaries.service'; import { ApiariesService } from './apiaries.service';
import { ApiaryFieldService } from './apiary-field.service'; import { ApiaryFieldService } from './apiary-field.service';
@Module({ @Module({
imports: [AutopilotModule],
controllers: [ApiariesController], controllers: [ApiariesController],
providers: [ApiariesService, ApiaryFieldService], providers: [ApiariesService, ApiaryFieldService],
exports: [ApiariesService], exports: [ApiariesService],
@@ -2,6 +2,8 @@ import { NotFoundException } from '@nestjs/common';
import { ApiaryFieldService } from './apiary-field.service'; import { ApiaryFieldService } from './apiary-field.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.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', () => { describe('ApiaryFieldService', () => {
let service: ApiaryFieldService; let service: ApiaryFieldService;
@@ -14,6 +16,21 @@ describe('ApiaryFieldService', () => {
task: { findMany: jest.Mock }; task: { findMany: jest.Mock };
}; };
let permissionResolver: { getPermissions: 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) => ({ const colonyRow = (id: string, code: string, queenId: string | null) => ({
id, id,
@@ -61,9 +78,19 @@ describe('ApiaryFieldService', () => {
task: { findMany: jest.fn().mockResolvedValue([]) }, task: { findMany: jest.fn().mockResolvedValue([]) },
}; };
permissionResolver = { getPermissions: jest.fn() }; 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( service = new ApiaryFieldService(
prisma as unknown as PrismaService, prisma as unknown as PrismaService,
permissionResolver as unknown as PermissionResolverService, permissionResolver as unknown as PermissionResolverService,
nextSteps as unknown as NextStepsService,
); );
}); });
@@ -173,23 +200,19 @@ describe('ApiaryFieldService', () => {
expect(inspectionArgs.omit).toEqual({ userId: true }); expect(inspectionArgs.omit).toEqual({ userId: true });
}); });
const lastQuery = () => it('takes the warning counts from the open suggestions the caller may see', async () => {
(prisma.$queryRaw.mock.calls[0] as [{ sql: string; values: unknown[] }])[0]; grant('colonies:read', 'tasks:read');
nextSteps.warningCounts.mockResolvedValue(
it('counts only unread WARNUNG and KRITISCH colony notifications visible to the caller', async () => { new Map([['c2', { warnings: 2, critical: 1 }]]),
grant('colonies:read'); );
prisma.$queryRaw.mockResolvedValue([
{ relatedId: 'c2', priority: 'WARNUNG', count: 2 },
{ relatedId: 'c2', priority: 'KRITISCH', count: 1 },
]);
const overview = await service.colonyOverview('apiary-1', 'u1'); const overview = await service.colonyOverview('apiary-1', 'u1');
const query = lastQuery(); expect(nextSteps.warningCounts).toHaveBeenCalledWith(
expect(query.sql).toContain('"userId" ='); ['c2', 'c10'],
expect(query.sql).toContain('"read" = false'); 'u1',
expect(query.values).toContain('u1'); new Set(['colonies:read', 'tasks:read']),
expect(query.values).toEqual(expect.arrayContaining(['c2', 'c10'])); );
expect(overview.rows[0]).toMatchObject({ expect(overview.rows[0]).toMatchObject({
code: 'V-2', code: 'V-2',
openWarnings: 2, openWarnings: 2,
@@ -199,17 +222,69 @@ describe('ApiaryFieldService', () => {
openWarnings: 0, openWarnings: 0,
openCritical: 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 () => { it('carries inspectionDueOn, segment and the title of the first next step', async () => {
grant('colonies:read', 'varroa:read'); 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(overview.rows[0]).toMatchObject({
expect(query.sql).toContain('"requiredPermissions" <@'); inspectionDueOn: '2026-05-10',
// The held set is passed as is: a key outside the catalog is never segment: 'SOMMER',
// contained in it, so such a row stays hidden (parity with findOwn). nextStepTitle: 'Durchsicht mit Schwarmkontrolle',
expect(query.values).toContainEqual(['colonies:read', 'varroa:read']); });
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 { Injectable, NotFoundException } from '@nestjs/common';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; 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 { LIVING_COLONY_WHERE } from '../colonies/colony-status';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { Prisma } from '../../generated/prisma';
import type { import type {
ColonyOverview, ColonyOverview,
ColonyOverviewRow, ColonyOverviewRow,
@@ -18,6 +19,7 @@ interface FieldData {
colonies: PackColony[]; colonies: PackColony[];
permissions: ReadonlySet<string>; permissions: ReadonlySet<string>;
overview: ColonyOverview; overview: ColonyOverview;
nextSteps: ReadonlyMap<string, ColonyNextSteps>;
} }
/** /**
@@ -30,6 +32,7 @@ export class ApiaryFieldService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly permissionResolver: PermissionResolverService, private readonly permissionResolver: PermissionResolverService,
private readonly nextSteps: NextStepsService,
) {} ) {}
async colonyOverview( async colonyOverview(
@@ -40,10 +43,8 @@ export class ApiaryFieldService {
} }
async offlinePack(apiaryId: string, userId: string): Promise<OfflinePackV1> { async offlinePack(apiaryId: string, userId: string): Promise<OfflinePackV1> {
const { apiary, colonies, permissions, overview } = await this.load( const { apiary, colonies, permissions, overview, nextSteps } =
apiaryId, await this.load(apiaryId, userId);
userId,
);
const [queens, inspections, tasks] = await Promise.all([ const [queens, inspections, tasks] = await Promise.all([
permissions.has('queens:read') ? this.loadQueens(colonies) : null, permissions.has('queens:read') ? this.loadQueens(colonies) : null,
permissions.has('inspections:read') permissions.has('inspections:read')
@@ -60,6 +61,12 @@ export class ApiaryFieldService {
queens, queens,
inspections, inspections,
tasks, 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 }), a.code.localeCompare(b.code, 'de', { numeric: true }),
); );
const inspectionsVisible = permissions.has('inspections:read'); 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, 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 => ({ const rows = colonies.map((colony): ColonyOverviewRow => ({
id: colony.id, id: colony.id,
@@ -110,14 +119,18 @@ export class ApiaryFieldService {
feedStatus: colony.feedStatus, feedStatus: colony.feedStatus,
healthStatus: colony.healthStatus, healthStatus: colony.healthStatus,
lastInspectionDate: lastInspections?.get(colony.id) ?? null, lastInspectionDate: lastInspections?.get(colony.id) ?? null,
openWarnings: warnings.get(colony.id)?.WARNUNG ?? 0, inspectionDueOn: nextSteps.get(colony.id)?.inspectionDueOn ?? null,
openCritical: warnings.get(colony.id)?.KRITISCH ?? 0, 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 { return {
apiary, apiary,
colonies, colonies,
permissions, permissions,
overview: { apiaryId, inspectionsVisible, rows }, overview: { apiaryId, inspectionsVisible, rows },
nextSteps,
}; };
} }
@@ -138,42 +151,6 @@ export class ApiaryFieldService {
return dates; 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']> { private loadQueens(colonies: PackColony[]): Promise<OfflinePackV1['queens']> {
const queenIds = colonies.flatMap((colony) => const queenIds = colonies.flatMap((colony) =>
colony.currentQueenId ? [colony.currentQueenId] : [], colony.currentQueenId ? [colony.currentQueenId] : [],
@@ -7,6 +7,8 @@ import type {
Queen, Queen,
Task, Task,
} from '../../generated/prisma'; } 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. */ /** One living colony on the apiary page, reduced to what the traffic light needs. */
export interface ColonyOverviewRow { export interface ColonyOverviewRow {
@@ -20,9 +22,18 @@ export interface ColonyOverviewRow {
healthStatus: ColonyCondition | null; healthStatus: ColonyCondition | null;
/** ISO timestamp; always null when the caller may not read inspections. */ /** ISO timestamp; always null when the caller may not read inspections. */
lastInspectionDate: string | null; 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; openWarnings: number;
/** Unread KRITISCH notifications visible to the caller. */ /** Open suggestions of priority URGENT the caller may see. */
openCritical: number; openCritical: number;
} }
@@ -90,4 +101,9 @@ export interface OfflinePackV1 {
| 'relatedId' | 'relatedId'
>[] >[]
| null; | null;
/**
* The next steps per colony id (what `GET /colonies/:id/next-steps`
* returns as `steps`). Absent in packs cached before T4.
*/
nextSteps?: Record<string, NextStep[]>;
} }
+4
View File
@@ -53,9 +53,11 @@ import { BackupModule } from './backup/backup.module';
import { ShopSyncModule } from './shop-sync/shop-sync.module'; import { ShopSyncModule } from './shop-sync/shop-sync.module';
import { ErpExportModule } from './erp-export/erp-export.module'; import { ErpExportModule } from './erp-export/erp-export.module';
import { ApiKeysModule } from './api-keys/api-keys.module'; import { ApiKeysModule } from './api-keys/api-keys.module';
import { CleanupModule } from './suggestions/cleanup.module';
import { SuggestionsModule } from './suggestions/suggestions.module'; import { SuggestionsModule } from './suggestions/suggestions.module';
import { AutopilotModule } from './autopilot/autopilot.module'; import { AutopilotModule } from './autopilot/autopilot.module';
import { SystemModule } from './system/system.module'; import { SystemModule } from './system/system.module';
import { WithdrawalModule } from './withdrawal/withdrawal.module';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
import { PermissionsGuard } from './auth/guards/permissions.guard'; import { PermissionsGuard } from './auth/guards/permissions.guard';
import { PermissionsModule } from './auth/permissions/permissions.module'; import { PermissionsModule } from './auth/permissions/permissions.module';
@@ -109,7 +111,9 @@ import { IdempotencyInterceptor } from './common/idempotency/idempotency.interce
DocumentsModule, DocumentsModule,
AutomationModule, AutomationModule,
SuggestionsModule, SuggestionsModule,
CleanupModule,
AutopilotModule, AutopilotModule,
WithdrawalModule,
LabelTemplatesModule, LabelTemplatesModule,
ImportModule, ImportModule,
PublicModule, PublicModule,
@@ -42,6 +42,7 @@ describe('AutomationEventHandlers', () => {
let tx: Record<RecordModel, { findUnique: jest.Mock }>; let tx: Record<RecordModel, { findUnique: jest.Mock }>;
let automation: Record< let automation: Record<
| 'evaluateInspectionIn' | 'evaluateInspectionIn'
| 'evaluateInspectionCorrectionIn'
| 'evaluateVarroaMeasurementIn' | 'evaluateVarroaMeasurementIn'
| 'evaluateTreatmentIn' | 'evaluateTreatmentIn'
| 'evaluateFeedingIn' | 'evaluateFeedingIn'
@@ -63,6 +64,7 @@ describe('AutomationEventHandlers', () => {
) as Record<RecordModel, { findUnique: jest.Mock }>; ) as Record<RecordModel, { findUnique: jest.Mock }>;
automation = { automation = {
evaluateInspectionIn: jest.fn().mockResolvedValue(undefined), evaluateInspectionIn: jest.fn().mockResolvedValue(undefined),
evaluateInspectionCorrectionIn: jest.fn().mockResolvedValue(undefined),
evaluateVarroaMeasurementIn: jest.fn().mockResolvedValue(undefined), evaluateVarroaMeasurementIn: jest.fn().mockResolvedValue(undefined),
evaluateTreatmentIn: jest.fn().mockResolvedValue(undefined), evaluateTreatmentIn: jest.fn().mockResolvedValue(undefined),
evaluateFeedingIn: jest.fn().mockResolvedValue(undefined), evaluateFeedingIn: jest.fn().mockResolvedValue(undefined),
@@ -84,7 +86,7 @@ describe('AutomationEventHandlers', () => {
expect(registered('inspectionRules')).toEqual([ expect(registered('inspectionRules')).toEqual([
{ {
types: ['InspectionRecorded'], types: ['InspectionRecorded', 'InspectionCorrected'],
handlerId: 'automation.inspection-rules', 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 () => { it('lets an evaluation failure propagate, so the delivery is retried', async () => {
tx.inspection.findUnique.mockResolvedValueOnce({ tx.inspection.findUnique.mockResolvedValueOnce({
id: 'record-1', id: 'record-1',
@@ -35,9 +35,14 @@ export class AutomationEventHandlers {
return colony !== null && isLivingColonyStatus(colony.status); 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( async inspectionRules(
event: AnyDomainEventEnvelope<'InspectionRecorded'>, event: AnyDomainEventEnvelope<'InspectionRecorded' | 'InspectionCorrected'>,
tx: Tx, tx: Tx,
): Promise<void> { ): Promise<void> {
const inspection = await tx.inspection.findUnique({ const inspection = await tx.inspection.findUnique({
@@ -49,7 +54,11 @@ export class AutomationEventHandlers {
) { ) {
return; 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 // A correction can lift a count over the threshold, so it is evaluated
@@ -32,6 +32,7 @@ describe('AutomationService', () => {
}; };
colony: { findUnique: jest.Mock }; colony: { findUnique: jest.Mock };
suggestion: { findFirst: jest.Mock }; suggestion: { findFirst: jest.Mock };
inspection: { findFirst: jest.Mock };
treatment: { findMany: jest.Mock }; treatment: { findMany: jest.Mock };
stockMovement: { findFirst: jest.Mock }; stockMovement: { findFirst: jest.Mock };
rawMaterialLot: { findFirst: jest.Mock }; rawMaterialLot: { findFirst: jest.Mock };
@@ -90,6 +91,16 @@ describe('AutomationService', () => {
}; };
const asTx = () => tx as unknown as Prisma.TransactionClient; 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[]) => { const withRules = (...list: Rule[]) => {
for (const rule of list) { for (const rule of list) {
rules.set(rule.triggerType, rule); rules.set(rule.triggerType, rule);
@@ -125,6 +136,7 @@ describe('AutomationService', () => {
}), }),
}, },
suggestion: { findFirst: jest.fn().mockResolvedValue(null) }, suggestion: { findFirst: jest.fn().mockResolvedValue(null) },
inspection: { findFirst: jest.fn().mockResolvedValue(null) },
treatment: { findMany: jest.fn().mockResolvedValue([]) }, treatment: { findMany: jest.fn().mockResolvedValue([]) },
stockMovement: { findFirst: jest.fn().mockResolvedValue(null) }, stockMovement: { findFirst: jest.fn().mockResolvedValue(null) },
rawMaterialLot: { 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 () => { 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(tx.automationRule.findUnique).toHaveBeenCalled();
expect(prisma.automationRule.createMany).not.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 () => { it('proposes a queen check anchored on the inspection when the queen was not seen and no eggs are present', async () => {
withRules(queenlessRule); withRules(queenlessRule);
await service.evaluateInspectionIn(asTx(), queenlessInspection as never); await evaluateInspection(queenlessInspection as never);
expect(propose).toHaveBeenCalledTimes(1); expect(propose).toHaveBeenCalledTimes(1);
expect(propose).toHaveBeenCalledWith(tx, 'QUEENLESS_NO_EGGS', { 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 () => { it('dates the suggestion from the inspection day when the rule has dueInDays', async () => {
withRules({ ...queenlessRule, config: { dueInDays: 3 } }); withRules({ ...queenlessRule, config: { dueInDays: 3 } });
await service.evaluateInspectionIn(asTx(), queenlessInspection as never); await evaluateInspection(queenlessInspection as never);
expect(propose).toHaveBeenCalledWith( expect(propose).toHaveBeenCalledWith(
tx, tx,
@@ -555,7 +567,7 @@ describe('AutomationService', () => {
it('does not trigger when the queen was seen', async () => { it('does not trigger when the queen was seen', async () => {
withRules(queenlessRule); withRules(queenlessRule);
await service.evaluateInspectionIn(asTx(), { await evaluateInspection({
...queenlessInspection, ...queenlessInspection,
queenSeen: true, queenSeen: true,
} as never); } as never);
@@ -566,7 +578,7 @@ describe('AutomationService', () => {
it('proposes feeding when the feed status is CRITICAL', async () => { it('proposes feeding when the feed status is CRITICAL', async () => {
withRules(feedRule); withRules(feedRule);
await service.evaluateInspectionIn(asTx(), { await evaluateInspection({
...queenlessInspection, ...queenlessInspection,
id: 'inspection-3', id: 'inspection-3',
queenSeen: true, queenSeen: true,
@@ -590,7 +602,7 @@ describe('AutomationService', () => {
it('proposes both findings of one inspection', async () => { it('proposes both findings of one inspection', async () => {
withRules(queenlessRule, feedRule); withRules(queenlessRule, feedRule);
await service.evaluateInspectionIn(asTx(), { await evaluateInspection({
...queenlessInspection, ...queenlessInspection,
feedStatus: 'CRITICAL', feedStatus: 'CRITICAL',
} as never); } as never);
@@ -604,7 +616,7 @@ describe('AutomationService', () => {
{ ...feedRule, active: false }, { ...feedRule, active: false },
); );
await service.evaluateInspectionIn(asTx(), { await evaluateInspection({
...queenlessInspection, ...queenlessInspection,
feedStatus: 'CRITICAL', feedStatus: 'CRITICAL',
} as never); } as never);
@@ -613,7 +625,7 @@ describe('AutomationService', () => {
}); });
it('reads no rule when there is no finding', async () => { it('reads no rule when there is no finding', async () => {
await service.evaluateInspectionIn(asTx(), { await evaluateInspection({
...queenlessInspection, ...queenlessInspection,
queenSeen: true, queenSeen: true,
} as never); } 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', () => { describe('evaluateVarroaMeasurementIn', () => {
let propose: jest.SpyInstance; let propose: jest.SpyInstance;
const measurement = ( const measurement = (
@@ -112,6 +112,17 @@ function dueDayOf(config: object, baseDay: string): string | null {
return typeof dueInDays === 'number' ? addDays(baseDay, dueInDays) : 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() @Injectable()
export class AutomationService { export class AutomationService {
private readonly logger = new Logger(AutomationService.name); 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. // 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> { async evaluateInspectionIn(tx: Tx, inspection: Inspection): Promise<void> {
assertTransactionClient(tx, 'AutomationService.evaluateInspectionIn'); assertTransactionClient(tx, 'AutomationService.evaluateInspectionIn');
if (inspection.queenSeen === false && inspection.eggsPresent === false) { await this.syncInspectionRulesIn(tx, inspection, true);
await this.proposeOnInspectionIn( }
tx,
'QUEENLESS_NO_EGGS', /**
'QUEEN_CHECK', * A corrected inspection proposes nothing new, but an open suggestion it
inspection, * 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') { const latest = await tx.inspection.findFirst({
await this.proposeOnInspectionIn( where: { colonyId: inspection.colonyId },
tx, orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
'FEED_STATUS_CRITICAL', });
'FEED', if (latest === null || latest.id !== inspection.id) {
inspection, 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. */ /** The reason code of an inspection rule is the rule's own name. */
@@ -267,11 +373,7 @@ export class AutomationService {
colonyId: inspection.colonyId, colonyId: inspection.colonyId,
intent, intent,
reasonCode: trigger, reasonCode: trigger,
evidence: { evidence: inspectionEvidence(inspection, inspectedOn),
colonyId: inspection.colonyId,
inspectionId: inspection.id,
inspectedOn,
},
anchor: inspection.id, anchor: inspection.id,
requiredPermission: 'inspections:read', requiredPermission: 'inspections:read',
dueDate: dueDayOf(rule.config, inspectedOn), dueDate: dueDayOf(rule.config, inspectedOn),
@@ -1,7 +1,6 @@
import type { Prisma } from '../../generated/prisma'; import type { Prisma } from '../../generated/prisma';
import { berlinDayOf, dayToDate } from '../common/dates'; import { berlinDayOf, dayToDate } from '../common/dates';
import type { SeasonState } from '../season/season-types'; import type { SeasonState } from '../season/season-types';
import { utcDay } from '../domain-events/domain-event-types';
import type { AutopilotInput } from './autopilot.types'; import type { AutopilotInput } from './autopilot.types';
/** /**
@@ -72,8 +71,8 @@ export async function loadAutopilotInputIn(
const lastTreatment = treatments const lastTreatment = treatments
.map((treatment) => ({ .map((treatment) => ({
id: treatment.id, id: treatment.id,
startDay: utcDay(treatment.startDate ?? treatment.date), startDay: berlinDayOf(treatment.startDate ?? treatment.date),
endDay: utcDay(treatment.endDate ?? treatment.date), endDay: berlinDayOf(treatment.endDate ?? treatment.date),
})) }))
.reduce<{ id: string; startDay: string; endDay: string } | null>( .reduce<{ id: string; startDay: string; endDay: string } | null>(
(latest, candidate) => (latest, candidate) =>
@@ -124,9 +123,11 @@ export async function loadAutopilotInputIn(
lastInspection: lastInspection:
inspection === null inspection === null
? null ? null
: { ...inspection, date: utcDay(inspection.date) }, : { ...inspection, date: berlinDayOf(inspection.date) },
lastVarroa: lastVarroa:
varroa === null ? null : { id: varroa.id, date: utcDay(varroa.date) }, varroa === null
? null
: { id: varroa.id, date: berlinDayOf(varroa.date) },
lastTreatment, lastTreatment,
varroaSinceLastTreatmentEnd, varroaSinceLastTreatmentEnd,
feedingsSinceInspection, 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 { SeasonModule } from '../season/season.module';
import { SuggestionsModule } from '../suggestions/suggestions.module'; import { SuggestionsModule } from '../suggestions/suggestions.module';
import { AutopilotEventHandlers } from './autopilot.handlers'; import { AutopilotEventHandlers } from './autopilot.handlers';
import { AutopilotTodayController } from './autopilot-today.controller';
import { AutopilotService } from './autopilot.service'; import { AutopilotService } from './autopilot.service';
import { AutopilotStep } from './autopilot.step'; import { AutopilotStep } from './autopilot.step';
import { ColonyNextStepsController } from './colony-next-steps.controller';
import { NextStepsService } from './next-steps.service';
import { SuggestionsExpiryStep } from './suggestions-expiry.step'; import { SuggestionsExpiryStep } from './suggestions-expiry.step';
/** /**
* The season autopilot: the event handler and the daily steps that keep the * 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 * autopilot suggestions of each colony in line with the rules, and the read
* AppModule and WorkerModule (the handler) and by JobsModule (the steps). * side "next steps" / "today". Imported by AppModule and WorkerModule (the
* handler), JobsModule (the steps) and ApiariesModule (the colony overview).
*/ */
@Module({ @Module({
imports: [SeasonModule, SuggestionsModule], imports: [SeasonModule, SuggestionsModule],
controllers: [ColonyNextStepsController, AutopilotTodayController],
providers: [ providers: [
AutopilotService, AutopilotService,
NextStepsService,
AutopilotEventHandlers, AutopilotEventHandlers,
AutopilotStep, AutopilotStep,
SuggestionsExpiryStep, SuggestionsExpiryStep,
], ],
exports: [AutopilotService, AutopilotStep, SuggestionsExpiryStep], exports: [
AutopilotService,
NextStepsService,
AutopilotStep,
SuggestionsExpiryStep,
],
}) })
export class AutopilotModule {} export class AutopilotModule {}
@@ -0,0 +1,26 @@
import { Controller, Get, Param } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { ColonyNextStepsDto } from './dto/next-steps-response.dto';
import type { ColonyNextSteps } from './next-step-ranking';
import { NextStepsService } from './next-steps.service';
@ApiTags('colonies')
@ApiBearerAuth()
@Controller('colonies')
export class ColonyNextStepsController {
constructor(private readonly nextSteps: NextStepsService) {}
/** The next steps of one colony, only what the caller may see. */
@ApiOkResponse({ type: ColonyNextStepsDto })
@RequirePermission('colonies:read')
@Get(':id/next-steps')
nextStepsOf(
@Param('id') id: string,
@CurrentUser() actor: AuthenticatedUser,
): Promise<ColonyNextSteps> {
return this.nextSteps.forColony(id, actor.id);
}
}
@@ -0,0 +1,100 @@
import { ApiProperty } from '@nestjs/swagger';
import { SuggestionIntent } from '../../../generated/prisma';
import {
PHASE_SOURCES,
PHENOLOGY_PHASES,
SEASON_SEGMENTS,
} from '../../season/season-types';
import type { ColonyNextSteps, NextStep } from '../next-step-ranking';
import type { AutopilotToday } from '../next-steps.service';
const FORMS = [
'inspection',
'varroa',
'feeding',
'treatment',
'winter-check',
] as const;
class NextStepActionDto {
@ApiProperty({ enum: ['CAPTURE', 'ACCEPT', 'CLEANUP', 'OPEN_TASK'] })
type!: NextStep['action']['type'];
@ApiProperty({ enum: FORMS, required: false }) form?: (typeof FORMS)[number];
@ApiProperty({ required: false }) href?: string;
@ApiProperty({ required: false }) suggestionId?: string;
@ApiProperty({ required: false }) taskId?: string;
}
export class NextStepDto {
@ApiProperty() key!: string;
@ApiProperty({ enum: ['TASK', 'SUGGESTION'] }) kind!: NextStep['kind'];
@ApiProperty({ enum: SuggestionIntent, nullable: true })
intent!: SuggestionIntent | null;
@ApiProperty() title!: string;
@ApiProperty({ type: [String] }) reasons!: string[];
@ApiProperty({ nullable: true, type: String }) dueOn!: string | null;
@ApiProperty({ enum: ['OVERDUE', 'TODAY', 'SOON', 'LATER', 'NONE'] })
urgency!: NextStep['urgency'];
@ApiProperty({ type: [String] }) suggestionIds!: string[];
@ApiProperty({ type: NextStepActionDto }) action!: NextStepActionDto;
@ApiProperty() dismissible!: boolean;
}
export class ColonyNextStepsDto {
@ApiProperty() colonyId!: string;
@ApiProperty({ enum: SEASON_SEGMENTS }) segment!: ColonyNextSteps['segment'];
@ApiProperty({ enum: PHENOLOGY_PHASES, nullable: true })
phase!: ColonyNextSteps['phase'];
@ApiProperty({ enum: PHASE_SOURCES, nullable: true })
phaseSource!: ColonyNextSteps['phaseSource'];
@ApiProperty({ nullable: true, type: String }) inspectionDueOn!:
string | null;
@ApiProperty({ nullable: true, type: String }) withdrawalUntil!:
string | null;
@ApiProperty({ type: [NextStepDto] }) steps!: NextStepDto[];
}
class TodayNextStepDto extends NextStepDto {
@ApiProperty() colonyId!: string;
@ApiProperty() colonyCode!: string;
}
class DueColonyDto {
@ApiProperty() id!: string;
@ApiProperty() code!: string;
@ApiProperty() dueOn!: string;
@ApiProperty() overdueDays!: number;
}
class InspectionDueDto {
@ApiProperty() apiaryId!: string;
@ApiProperty() apiaryName!: string;
@ApiProperty({ enum: SEASON_SEGMENTS })
segment!: AutopilotToday['seasons'][number]['segment'];
@ApiProperty({ nullable: true, type: Number }) intervalDays!: number | null;
@ApiProperty({ type: [DueColonyDto] }) colonies!: DueColonyDto[];
}
class SeasonOfApiaryDto {
@ApiProperty() apiaryId!: string;
@ApiProperty() apiaryName!: string;
@ApiProperty({ enum: SEASON_SEGMENTS })
segment!: AutopilotToday['seasons'][number]['segment'];
@ApiProperty({ enum: PHENOLOGY_PHASES, nullable: true })
phase!: AutopilotToday['seasons'][number]['phase'];
@ApiProperty({ enum: PHASE_SOURCES, nullable: true })
phaseSource!: AutopilotToday['seasons'][number]['phaseSource'];
}
export class AutopilotTodayDto {
@ApiProperty({ type: TodayNextStepDto, nullable: true })
nextStep!: TodayNextStepDto | null;
@ApiProperty({
type: [InspectionDueDto],
description: 'Colonies whose inspection is due today or overdue.',
})
inspectionDue!: InspectionDueDto[];
@ApiProperty() suggestionsOpen!: number;
@ApiProperty({ type: [SeasonOfApiaryDto] }) seasons!: SeasonOfApiaryDto[];
@ApiProperty() tasksVisible!: boolean;
}
@@ -0,0 +1,460 @@
import type { SuggestionIntent } from '../../generated/prisma';
import {
FORM_OF_INTENT,
INTENT_ORDER,
mergeSameForm,
rankSteps,
topSteps,
toNextStep,
type StepSource,
} from './next-step-ranking';
const TODAY = '2026-05-12';
let counter = 0;
function source(overrides: Partial<StepSource> = {}): StepSource {
counter += 1;
const id = overrides.id ?? `s${String(counter).padStart(3, '0')}`;
return {
kind: 'SUGGESTION',
id,
intent: 'INSPECT',
mode: 'DO',
status: 'OPEN',
priority: 'NORMAL',
dueOn: TODAY,
createdAt: new Date('2026-05-01T08:00:00Z'),
title: 'Durchsicht',
reasons: ['Grund.'],
colonyId: 'col-1',
...overrides,
};
}
const ids = (items: readonly StepSource[]): string[] => items.map((i) => i.id);
describe('rankSteps', () => {
it('IN_PROGRESS comes first, then overdue, then by priority and the intent table', () => {
const inProgress = source({
id: 'a-progress',
kind: 'TASK',
mode: null,
intent: null,
status: 'IN_PROGRESS',
priority: 'URGENT',
dueOn: '2026-06-30',
});
const overdueLow = source({
id: 'b-overdue-low',
priority: 'LOW',
dueOn: '2026-05-01',
});
const todayHigh = source({
id: 'c-today-high',
priority: 'HIGH',
intent: 'FEED',
});
const todayHighVarroa = source({
id: 'd-today-high-varroa',
priority: 'HIGH',
intent: 'VARROA_TREAT',
});
const soon = source({ id: 'e-soon', dueOn: '2026-05-14' });
const later = source({ id: 'f-later', dueOn: '2026-05-30' });
const dateless = source({ id: 'g-none', dueOn: null });
const ranked = rankSteps(
[
dateless,
later,
soon,
todayHigh,
overdueLow,
todayHighVarroa,
inProgress,
],
TODAY,
);
// Urgency 0 holds the in-progress task, today and overdue steps; inside
// it priority decides, then the intent table (varroa before feed).
expect(ids(ranked)).toEqual([
'a-progress',
'd-today-high-varroa',
'c-today-high',
'b-overdue-low',
'e-soon',
'f-later',
'g-none',
]);
});
it('a dateless URGENT step counts as urgent', () => {
const urgentNoDate = source({
id: 'urgent',
dueOn: null,
priority: 'URGENT',
});
const soon = source({ id: 'soon', dueOn: '2026-05-14', priority: 'HIGH' });
const normalDateless = source({ id: 'plain', dueOn: null });
expect(ids(rankSteps([normalDateless, soon, urgentNoDate], TODAY))).toEqual(
['urgent', 'soon', 'plain'],
);
});
it('treats a task without an intent like FOLLOW_UP', () => {
const task = source({ id: 'task', kind: 'TASK', mode: null, intent: null });
const cleanup = source({ id: 'cleanup', intent: 'CLEANUP', mode: 'DO' });
const varroa = source({ id: 'varroa', intent: 'VARROA_TREAT' });
expect(ids(rankSteps([cleanup, task, varroa], TODAY))).toEqual([
'varroa',
'task',
'cleanup',
]);
});
it('orders ties by due day, creation time and id', () => {
const early = source({ id: 'z', dueOn: '2026-05-10' });
const sameDayOlder = source({
id: 'y',
dueOn: '2026-05-11',
createdAt: new Date('2026-05-01T00:00:00Z'),
});
const sameDayNewer = source({
id: 'x',
dueOn: '2026-05-11',
createdAt: new Date('2026-05-02T00:00:00Z'),
});
const tieB = source({ id: 'b', dueOn: '2026-05-11' });
const tieA = source({ id: 'a', dueOn: '2026-05-11' });
const ranked = rankSteps(
[sameDayNewer, tieB, early, tieA, sameDayOlder],
TODAY,
);
expect(ids(ranked)).toEqual(['z', 'y', 'a', 'b', 'x']);
});
it('does not change its input', () => {
const input = [source({ id: 'b' }), source({ id: 'a' })];
const copy = [...input];
rankSteps(input, TODAY);
expect(input).toEqual(copy);
});
});
describe('INTENT_ORDER and FORM_OF_INTENT', () => {
it('lists every intent exactly once', () => {
expect(new Set(INTENT_ORDER).size).toBe(INTENT_ORDER.length);
expect(INTENT_ORDER).toHaveLength(17);
expect(INTENT_ORDER[0]).toBe('VARROA_TREAT');
});
it('maps the intents to the capture forms of the spec', () => {
const forms: [SuggestionIntent, string][] = [
['INSPECT', 'inspection'],
['SWARM_CHECK', 'inspection'],
['QUEEN_CHECK', 'inspection'],
['QUEEN_ACCEPTANCE', 'inspection'],
['HONEY_SUPER', 'inspection'],
['HEALTH_CHECK', 'inspection'],
['VARROA_MEASURE', 'varroa'],
['VARROA_RECHECK', 'varroa'],
['FEED', 'feeding'],
['VARROA_TREAT', 'treatment'],
['SUMMER_TREATMENT', 'treatment'],
['WINTER_CHECK', 'winter-check'],
];
for (const [intent, form] of forms) {
expect(FORM_OF_INTENT[intent]).toBe(form);
}
expect(FORM_OF_INTENT.SPLIT_PLAN).toBeUndefined();
expect(FORM_OF_INTENT.CLEANUP).toBeUndefined();
});
});
describe('mergeSameForm', () => {
it('INSPECT (DO) and SWARM_CHECK merge into one step with two reasons and CAPTURE', () => {
const swarm = source({
id: 'swarm',
intent: 'SWARM_CHECK',
mode: 'PLAN',
title: 'Schwarmkontrolle',
reasons: ['Weiselzellen am 05.05. gesehen.'],
dueOn: '2026-05-11',
});
const inspect = source({
id: 'inspect',
intent: 'INSPECT',
mode: 'DO',
title: 'Durchsicht',
reasons: [
'Letzte Durchsicht vor 8 Tagen.',
'In der Schwarmzeit alle 7 Tage.',
],
dueOn: '2026-05-11',
});
const groups = mergeSameForm(rankSteps([inspect, swarm], TODAY));
expect(groups).toHaveLength(1);
const step = toNextStep(groups[0], TODAY);
expect(step.title).toBe('Durchsicht mit Schwarmkontrolle');
expect(step.reasons).toEqual([
'Weiselzellen am 05.05. gesehen.',
'Letzte Durchsicht vor 8 Tagen.',
]);
expect(step.action).toEqual({
type: 'CAPTURE',
form: 'inspection',
href: '/inspections/new?colonyId=col-1',
});
expect(step.urgency).toBe('OVERDUE');
expect(step.dismissible).toBe(true);
});
it('merges the inspection family, but never HONEY_SUPER (spec example B3)', () => {
const inspect = source({ id: 'inspect', intent: 'INSPECT', mode: 'DO' });
const swarm = source({ id: 'swarm', intent: 'SWARM_CHECK', mode: 'PLAN' });
const queen = source({ id: 'queen', intent: 'QUEEN_CHECK', mode: 'PLAN' });
const honeyPlan = source({
id: 'honey-plan',
intent: 'HONEY_SUPER',
mode: 'PLAN',
});
const honeyDo = source({
id: 'honey-do',
intent: 'HONEY_SUPER',
mode: 'DO',
});
const groups = mergeSameForm([queen, swarm, inspect, honeyPlan, honeyDo]);
expect(groups.map(ids)).toEqual([
['queen', 'swarm', 'inspect'],
['honey-plan'],
['honey-do'],
]);
});
it('merges VARROA_MEASURE and VARROA_RECHECK, not VARROA_TREAT', () => {
const measure = source({ id: 'm', intent: 'VARROA_MEASURE', mode: 'DO' });
const recheck = source({ id: 'r', intent: 'VARROA_RECHECK', mode: 'DO' });
const treat = source({ id: 't', intent: 'VARROA_TREAT', mode: 'DO' });
expect(mergeSameForm([treat, recheck, measure]).map(ids)).toEqual([
['t'],
['r', 'm'],
]);
});
it('a merged step takes the earliest due day, all suggestion ids and is dismissible', () => {
const inspect = source({
id: 'inspect',
intent: 'INSPECT',
dueOn: '2026-05-14',
title: 'Durchsicht',
reasons: ['Letzte Durchsicht vor 8 Tagen.'],
});
const queen = source({
id: 'queen',
intent: 'QUEEN_ACCEPTANCE',
mode: 'PLAN',
dueOn: '2026-05-10',
title: 'Annahme der Königin prüfen',
reasons: ['Königin am 05.05. zugesetzt.'],
});
const step = toNextStep([inspect, queen], TODAY);
expect(step.title).toBe('Durchsicht mit Annahme der Königin prüfen');
expect(step.dueOn).toBe('2026-05-10');
expect(step.urgency).toBe('OVERDUE');
expect(step.suggestionIds).toEqual(['inspect', 'queen']);
expect(step.dismissible).toBe(true);
expect(step.reasons[0]).toContain('zugesetzt');
});
it('an IN_PROGRESS task counts as TODAY', () => {
const step = toNextStep(
[
source({
kind: 'TASK',
mode: null,
intent: null,
status: 'IN_PROGRESS',
dueOn: '2026-07-01',
}),
],
TODAY,
);
expect(step.urgency).toBe('TODAY');
expect(step.suggestionIds).toEqual([]);
});
it('two PLAN steps of the same form do not merge', () => {
const a = source({ id: 'a', intent: 'SWARM_CHECK', mode: 'PLAN' });
const b = source({ id: 'b', intent: 'QUEEN_ACCEPTANCE', mode: 'PLAN' });
expect(mergeSameForm([a, b]).map(ids)).toEqual([['a'], ['b']]);
});
it('never merges tasks or steps of other colonies', () => {
const doStep = source({ id: 'do', intent: 'INSPECT', mode: 'DO' });
const task = source({
id: 'task',
kind: 'TASK',
mode: null,
intent: 'SWARM_CHECK',
});
const otherColony = source({
id: 'other',
intent: 'SWARM_CHECK',
mode: 'PLAN',
colonyId: 'col-2',
});
expect(mergeSameForm([doStep, task, otherColony]).map(ids)).toEqual([
['do'],
['task'],
['other'],
]);
});
it('keeps the rank order of the groups (a group sits where its best member sits)', () => {
const varroa = source({
id: 'varroa',
intent: 'VARROA_TREAT',
mode: 'PLAN',
});
const swarm = source({ id: 'swarm', intent: 'SWARM_CHECK', mode: 'PLAN' });
const feed = source({ id: 'feed', intent: 'FEED', mode: 'PLAN' });
const inspect = source({ id: 'inspect', intent: 'INSPECT', mode: 'DO' });
const groups = mergeSameForm([varroa, swarm, feed, inspect]);
expect(groups.map(ids)).toEqual([
['varroa'],
['swarm', 'inspect'],
['feed'],
]);
});
});
describe('toNextStep', () => {
it('opens a task with OPEN_TASK and never lets it be dismissed', () => {
const step = toNextStep(
[
source({
id: 't1',
kind: 'TASK',
mode: null,
intent: null,
title: 'Wachs',
}),
],
TODAY,
);
expect(step).toMatchObject({
key: 'task:t1',
kind: 'TASK',
intent: null,
action: { type: 'OPEN_TASK', taskId: 't1', href: '/tasks' },
dismissible: false,
});
});
it('accepts a PLAN suggestion and lets it be dismissed', () => {
const step = toNextStep(
[source({ id: 's1', intent: 'VARROA_TREAT', mode: 'PLAN' })],
TODAY,
);
expect(step).toMatchObject({
key: 'suggestion:s1',
action: { type: 'ACCEPT', suggestionId: 's1' },
dismissible: true,
});
});
it('falls back to the capture link of a PLAN suggestion when accepting is not allowed', () => {
const step = toNextStep(
[source({ id: 's1', intent: 'VARROA_TREAT', mode: 'PLAN' })],
TODAY,
{ canAccept: false },
);
expect(step.action).toEqual({
type: 'CAPTURE',
form: 'treatment',
href: '/treatments/new?colonyId=col-1',
});
});
it('points a CLEANUP suggestion at the cleanup', () => {
const step = toNextStep(
[source({ id: 'c1', intent: 'CLEANUP', mode: 'DO' })],
TODAY,
);
expect(step.action).toEqual({ type: 'CLEANUP', suggestionId: 'c1' });
});
it('builds the capture links of every form', () => {
const href = (intent: SuggestionIntent, today = TODAY): string => {
const { action } = toNextStep([source({ intent, mode: 'DO' })], today);
return action.type === 'CAPTURE' ? action.href : 'no-capture';
};
expect(href('VARROA_MEASURE')).toBe('/varroa/new?colonyId=col-1');
expect(href('FEED')).toBe('/feedings/new?colonyId=col-1');
expect(href('SUMMER_TREATMENT')).toBe('/treatments/new?colonyId=col-1');
expect(href('WINTER_CHECK', '2026-11-20')).toBe(
'/winter?colonyId=col-1&season=AUTUMN',
);
expect(href('WINTER_CHECK', '2026-03-20')).toBe(
'/winter?colonyId=col-1&season=SPRING',
);
});
it('derives the urgency from the due day', () => {
const urgency = (dueOn: string | null) =>
toNextStep([source({ dueOn })], TODAY).urgency;
expect(urgency('2026-05-11')).toBe('OVERDUE');
expect(urgency('2026-05-12')).toBe('TODAY');
expect(urgency('2026-05-15')).toBe('SOON');
expect(urgency('2026-05-16')).toBe('LATER');
expect(urgency(null)).toBe('NONE');
});
});
describe('topSteps', () => {
it('never returns more than 3 and is stable by id', () => {
const items = ['e', 'c', 'a', 'd', 'b'].map((id) =>
source({ id, intent: 'FEED', mode: 'PLAN' }),
);
const first = topSteps(items, TODAY, { limit: 3 });
const second = topSteps([...items].reverse(), TODAY, { limit: 3 });
expect(first.map((step) => step.key)).toEqual([
'suggestion:a',
'suggestion:b',
'suggestion:c',
]);
expect(second.map((step) => step.key)).toEqual(
first.map((step) => step.key),
);
});
it('drops a step the user can neither accept nor capture', () => {
const split = source({ id: 'split', intent: 'SPLIT_PLAN', mode: 'PLAN' });
const feed = source({ id: 'feed', intent: 'FEED', mode: 'PLAN' });
const steps = topSteps([split, feed], TODAY, {
limit: 3,
canAccept: false,
});
expect(steps.map((step) => step.key)).toEqual(['suggestion:feed']);
});
});
@@ -0,0 +1,438 @@
import type {
SuggestionIntent,
SuggestionMode,
TaskPriority,
} from '../../generated/prisma';
import { daysBetween } from '../common/dates';
import type {
PhaseSourceKey,
PhenologyPhaseKey,
SeasonSegmentKey,
} from '../season/season-types';
export type NextStepForm =
'inspection' | 'varroa' | 'feeding' | 'treatment' | 'winter-check';
export interface NextStep {
/** 'task:<id>' | 'suggestion:<id>' */
key: string;
kind: 'TASK' | 'SUGGESTION';
intent: SuggestionIntent | null;
title: string;
/** At most 2 short sentences. */
reasons: string[];
/** YYYY-MM-DD */
dueOn: string | null;
urgency: 'OVERDUE' | 'TODAY' | 'SOON' | 'LATER' | 'NONE';
/** The suggestions behind the step (all members of a merged step); [] for a task. */
suggestionIds: string[];
action:
| { type: 'CAPTURE'; form: NextStepForm; href: string }
| { type: 'ACCEPT'; suggestionId: string }
| { type: 'CLEANUP'; suggestionId: string }
| { type: 'OPEN_TASK'; taskId: string; href: string };
dismissible: boolean;
}
export interface ColonyNextSteps {
colonyId: string;
segment: SeasonSegmentKey;
phase: PhenologyPhaseKey | null;
phaseSource: PhaseSourceKey | null;
inspectionDueOn: string | null;
withdrawalUntil: string | null;
/** At most `maxStepsPerColony` (3). */
steps: NextStep[];
}
/** An open task or an open suggestion of one colony, ready to be ranked. */
export interface StepSource {
kind: 'TASK' | 'SUGGESTION';
id: string;
intent: SuggestionIntent | null;
mode: SuggestionMode | null;
status: string;
priority: TaskPriority;
/** YYYY-MM-DD */
dueOn: string | null;
createdAt: Date;
title: string;
reasons: string[];
colonyId: string;
}
/** Spec 8.4 point 3. A task without a suggestion counts as FOLLOW_UP. */
export const INTENT_ORDER: readonly SuggestionIntent[] = [
'VARROA_TREAT',
'QUEEN_CHECK',
'QUEEN_ACCEPTANCE',
'SWARM_CHECK',
'SPLIT_PLAN',
'FEED',
'INSPECT',
'VARROA_RECHECK',
'HEALTH_CHECK',
'SUMMER_TREATMENT',
'VARROA_MEASURE',
'WINTER_CHECK',
'HONEY_SUPER',
'QUEEN_REPLACE',
'FOLLOW_UP',
'CLEANUP',
'STOCK_REORDER',
];
export const FORM_OF_INTENT: Readonly<
Partial<Record<SuggestionIntent, NextStepForm>>
> = {
INSPECT: 'inspection',
SWARM_CHECK: 'inspection',
QUEEN_CHECK: 'inspection',
QUEEN_ACCEPTANCE: 'inspection',
HONEY_SUPER: 'inspection',
HEALTH_CHECK: 'inspection',
VARROA_MEASURE: 'varroa',
VARROA_RECHECK: 'varroa',
FEED: 'feeding',
VARROA_TREAT: 'treatment',
SUMMER_TREATMENT: 'treatment',
WINTER_CHECK: 'winter-check',
};
/** Title of the merged step; the second title is appended with "mit". */
const FORM_TITLES: Readonly<Record<NextStepForm, string>> = {
inspection: 'Durchsicht',
varroa: 'Varroa messen',
feeding: 'Füttern',
treatment: 'Behandlung',
'winter-check': 'Winterkontrolle',
};
const PRIORITY_RANK: Readonly<Record<TaskPriority, number>> = {
URGENT: 0,
HIGH: 1,
NORMAL: 2,
LOW: 3,
};
const INTENT_RANK = new Map(
INTENT_ORDER.map((intent, index) => [intent, index] as const),
);
const SOON_DAYS = 3;
const LATER_TIER_DAYS = 14;
const MAX_REASONS = 2;
const FIRST_AUTUMN_MONTH = 7;
const TASKS_HREF = '/tasks';
const DATELESS_TIER = 4;
export interface StepOptions {
/** The user may accept suggestions (tasks:create). Default true. */
canAccept?: boolean;
}
export interface TopStepsOptions extends StepOptions {
limit: number;
}
function intentRank(source: StepSource): number {
return INTENT_RANK.get(source.intent ?? 'FOLLOW_UP') ?? INTENT_ORDER.length;
}
/** Spec 8.4 point 1: 0 = now, 1 <= 3 days, 2 <= 14 days, 3 later, 4 no date. */
function urgencyTier(source: StepSource, today: string): number {
if (source.status === 'IN_PROGRESS') {
return 0;
}
if (source.dueOn === null) {
return source.priority === 'URGENT' ? 0 : DATELESS_TIER;
}
const days = daysBetween(today, source.dueOn);
if (days <= 0) {
return 0;
}
if (days <= SOON_DAYS) {
return 1;
}
return days <= LATER_TIER_DAYS ? 2 : 3;
}
function compareText(a: string, b: string): number {
if (a === b) {
return 0;
}
return a < b ? -1 : 1;
}
/** The total order of the spec: urgency, priority, intent, due day, createdAt, id. */
export function compareSources(
a: StepSource,
b: StepSource,
today: string,
): number {
return (
urgencyTier(a, today) - urgencyTier(b, today) ||
PRIORITY_RANK[a.priority] - PRIORITY_RANK[b.priority] ||
intentRank(a) - intentRank(b) ||
compareText(a.dueOn ?? '9999-12-31', b.dueOn ?? '9999-12-31') ||
a.createdAt.getTime() - b.createdAt.getTime() ||
compareText(a.id, b.id)
);
}
export function rankSteps(
items: readonly StepSource[],
today: string,
): StepSource[] {
return [...items].sort((a, b) => compareSources(a, b, today));
}
function formOf(source: StepSource): NextStepForm | undefined {
return source.kind === 'SUGGESTION' && source.intent !== null
? FORM_OF_INTENT[source.intent]
: undefined;
}
/**
* Intents that merge into one step with the "plain" member of their family
* (spec 8.4 says: same form = one step; the B3 example of spec 8.5 shows
* "Durchsicht mit Schwarmkontrolle" next to a separate "Honigraum prüfen",
* although both open the inspection form - the example wins).
* inspection family: INSPECT, SWARM_CHECK, QUEEN_CHECK, QUEEN_ACCEPTANCE
* varroa family: VARROA_MEASURE, VARROA_RECHECK
* Everything else (HONEY_SUPER, HEALTH_CHECK, FEED, VARROA_TREAT,
* SUMMER_TREATMENT, WINTER_CHECK ...) never merges.
*/
const MERGE_FAMILY: Readonly<Partial<Record<SuggestionIntent, NextStepForm>>> =
{
INSPECT: 'inspection',
SWARM_CHECK: 'inspection',
QUEEN_CHECK: 'inspection',
QUEEN_ACCEPTANCE: 'inspection',
VARROA_MEASURE: 'varroa',
VARROA_RECHECK: 'varroa',
};
/** The member whose title is the form's own title, by intent. */
const PLAIN_INTENT: Readonly<
Record<'inspection' | 'varroa', SuggestionIntent>
> = { inspection: 'INSPECT', varroa: 'VARROA_MEASURE' };
function familyOf(source: StepSource): NextStepForm | undefined {
return source.kind === 'SUGGESTION' && source.intent !== null
? MERGE_FAMILY[source.intent]
: undefined;
}
/**
* Suggestions of one merge family (table above) of one colony become one
* step, when at least one of them is a DO suggestion (PLAN suggestions alone
* are decisions, not a form to fill in). The group keeps the position of its
* best-ranked member; `ranked` must already be ranked.
*/
export function mergeSameForm(ranked: readonly StepSource[]): StepSource[][] {
const members = new Map<string, StepSource[]>();
for (const item of ranked) {
const family = familyOf(item);
if (family !== undefined) {
const key = `${item.colonyId}|${family}`;
members.set(key, [...(members.get(key) ?? []), item]);
}
}
const groups: StepSource[][] = [];
const placed = new Set<string>();
for (const item of ranked) {
const family = familyOf(item);
const key = family === undefined ? null : `${item.colonyId}|${family}`;
const group = key === null ? undefined : members.get(key);
const mergeable =
group !== undefined &&
group.length > 1 &&
group.some((member) => member.mode === 'DO');
if (key === null || !mergeable) {
groups.push([item]);
} else if (!placed.has(key)) {
placed.add(key);
groups.push(group);
}
}
return groups;
}
function urgencyOf(dueOn: string | null, today: string): NextStep['urgency'] {
if (dueOn === null) {
return 'NONE';
}
const days = daysBetween(today, dueOn);
if (days < 0) {
return 'OVERDUE';
}
if (days === 0) {
return 'TODAY';
}
return days <= SOON_DAYS ? 'SOON' : 'LATER';
}
function captureHref(
form: NextStepForm,
colonyId: string,
today: string,
): string {
const query = `colonyId=${encodeURIComponent(colonyId)}`;
switch (form) {
case 'inspection':
return `/inspections/new?${query}`;
case 'varroa':
return `/varroa/new?${query}`;
case 'feeding':
return `/feedings/new?${query}`;
case 'treatment':
return `/treatments/new?${query}`;
case 'winter-check': {
const month = Number(today.slice(5, 7));
const season = month >= FIRST_AUTUMN_MONTH ? 'AUTUMN' : 'SPRING';
return `/winter?${query}&season=${season}`;
}
}
}
function capture(
form: NextStepForm,
colonyId: string,
today: string,
): NextStep['action'] {
return {
type: 'CAPTURE',
form,
href: captureHref(form, colonyId, today),
};
}
function actionOf(
source: StepSource,
today: string,
canAccept: boolean,
): NextStep['action'] | null {
if (source.kind === 'TASK') {
return { type: 'OPEN_TASK', taskId: source.id, href: TASKS_HREF };
}
if (source.intent === 'CLEANUP') {
return { type: 'CLEANUP', suggestionId: source.id };
}
if (source.mode === 'PLAN' && canAccept) {
return { type: 'ACCEPT', suggestionId: source.id };
}
const form = formOf(source);
return form === undefined ? null : capture(form, source.colonyId, today);
}
/** Whether the user can do anything with this step (else it is not shown). */
function isActionable(
source: StepSource,
today: string,
canAccept: boolean,
): boolean {
return actionOf(source, today, canAccept) !== null;
}
/**
* The specific cause first (the step that is not the plain form, "Weiselzellen
* gesehen"), then the plain one ("Letzte Durchsicht vor 8 Tagen").
*/
function isPlain(form: NextStepForm, item: StepSource): boolean {
return (
(form === 'inspection' || form === 'varroa') &&
item.intent === PLAIN_INTENT[form]
);
}
/**
* The specific cause first ("Weiselzellen gesehen"), then the plain member
* ("Letzte Durchsicht vor 8 Tagen"), at most two.
*/
function mergedReasons(
form: NextStepForm,
group: readonly StepSource[],
): string[] {
const ordered = [
...group.filter((item) => !isPlain(form, item)),
...group.filter((item) => isPlain(form, item)),
];
const firsts = ordered.flatMap((item) => item.reasons.slice(0, 1));
const rest = ordered.flatMap((item) => item.reasons.slice(1));
return [...new Set([...firsts, ...rest])].slice(0, MAX_REASONS);
}
function mergedTitle(form: NextStepForm, group: readonly StepSource[]): string {
const second = group.find((item) => !isPlain(form, item));
const formTitle = FORM_TITLES[form];
return second === undefined ? formTitle : `${formTitle} mit ${second.title}`;
}
/** The earliest due day of the members (null when none has one). */
function earliestDue(group: readonly StepSource[]): string | null {
const days = group.flatMap((item) =>
item.dueOn === null ? [] : [item.dueOn],
);
return days.length === 0 ? null : days.reduce((a, b) => (a <= b ? a : b));
}
/** One step from a ranked group (a single item, or a merged group). */
export function toNextStep(
group: readonly StepSource[],
today: string,
options: StepOptions = {},
): NextStep {
const [head] = group;
const canAccept = options.canAccept ?? true;
const form = formOf(head);
if (group.length > 1 && form !== undefined) {
return {
key: `suggestion:${head.id}`,
kind: 'SUGGESTION',
intent: head.intent,
title: mergedTitle(form, group),
reasons: mergedReasons(form, group),
dueOn: earliestDue(group),
urgency: urgencyOf(earliestDue(group), today),
suggestionIds: group.map((item) => item.id),
action: capture(form, head.colonyId, today),
dismissible: true,
};
}
const action = actionOf(head, today, canAccept);
if (action === null) {
throw new Error(`Step ${head.id} has no action`);
}
return {
key: `${head.kind === 'TASK' ? 'task' : 'suggestion'}:${head.id}`,
kind: head.kind,
intent: head.intent,
title: head.title,
reasons: head.reasons.slice(0, MAX_REASONS),
dueOn: head.dueOn,
urgency:
head.status === 'IN_PROGRESS' ? 'TODAY' : urgencyOf(head.dueOn, today),
suggestionIds: head.kind === 'SUGGESTION' ? [head.id] : [],
action,
dismissible: head.kind === 'SUGGESTION',
};
}
/**
* The next steps of one colony: what the user can act on, ranked, merged by
* form and cut to `limit`.
*/
export function topSteps(
items: readonly StepSource[],
today: string,
options: TopStepsOptions,
): NextStep[] {
const canAccept = options.canAccept ?? true;
const actionable = items.filter((item) =>
isActionable(item, today, canAccept),
);
return mergeSameForm(rankSteps(actionable, today))
.slice(0, options.limit)
.map((group) => toNextStep(group, today, { canAccept }));
}
@@ -0,0 +1,413 @@
import { NotFoundException } from '@nestjs/common';
import type { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import type { PrismaService } from '../prisma/prisma.service';
import { SeasonService } from '../season/season.service';
import { SuggestionViewsService } from '../suggestions/suggestion-views.service';
import { NextStepsService } from './next-steps.service';
const TODAY = '2026-05-12';
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
describe('NextStepsService', () => {
let service: NextStepsService;
let prisma: {
colony: { findMany: jest.Mock };
suggestion: { findMany: jest.Mock; groupBy: jest.Mock; count: jest.Mock };
task: { findMany: jest.Mock };
inspection: { groupBy: jest.Mock };
treatment: { findMany: jest.Mock };
automationRule: { findUnique: jest.Mock; findMany: jest.Mock };
apiary: { findMany: jest.Mock };
};
let permissionResolver: { getPermissions: jest.Mock };
const grant = (...keys: string[]) =>
permissionResolver.getPermissions.mockResolvedValue(new Set(keys));
const suggestionRow = (overrides: Record<string, unknown> = {}) => ({
id: 's1',
ruleKey: 'VARROA_THRESHOLD',
reasonCode: 'UNKNOWN_CODE',
evidence: {},
intent: 'VARROA_TREAT',
mode: 'PLAN',
status: 'OPEN',
priority: 'HIGH',
dueDate: d('2026-05-12'),
createdAt: new Date('2026-05-10T08:00:00Z'),
colonyId: 'c1',
...overrides,
});
beforeEach(() => {
prisma = {
colony: {
findMany: jest.fn().mockResolvedValue([
{
id: 'c1',
code: 'B3',
apiaryId: 'a1',
createdAt: new Date('2026-04-01T08:00:00Z'),
},
]),
},
suggestion: {
findMany: jest.fn().mockResolvedValue([]),
groupBy: jest.fn().mockResolvedValue([]),
count: jest.fn().mockResolvedValue(0),
},
task: { findMany: jest.fn().mockResolvedValue([]) },
inspection: {
groupBy: jest
.fn()
.mockResolvedValue([
{ colonyId: 'c1', _max: { date: d('2026-05-03') } },
]),
},
treatment: { findMany: jest.fn().mockResolvedValue([]) },
automationRule: {
findUnique: jest.fn().mockResolvedValue(null),
findMany: jest.fn().mockResolvedValue([]),
},
apiary: { findMany: jest.fn().mockResolvedValue([]) },
};
permissionResolver = { getPermissions: jest.fn() };
service = new NextStepsService(
prisma as unknown as PrismaService,
permissionResolver as unknown as PermissionResolverService,
new SeasonService(),
new SuggestionViewsService(
prisma as unknown as PrismaService,
permissionResolver as unknown as PermissionResolverService,
),
);
});
it('answers 404 for an unknown colony', async () => {
grant('colonies:read');
prisma.colony.findMany.mockResolvedValue([]);
await expect(service.forColony('nope', 'u1', TODAY)).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('reads no suggestions and no tasks without tasks:read', async () => {
grant('colonies:read', 'varroa:read');
const steps = await service.forColony('c1', 'u1', TODAY);
expect(steps.steps).toEqual([]);
expect(prisma.suggestion.findMany).not.toHaveBeenCalled();
expect(prisma.task.findMany).not.toHaveBeenCalled();
});
it('filters the suggestions by the permissions the user holds', async () => {
grant('colonies:read', 'tasks:read', 'inspections:read');
await service.forColony('c1', 'u1', TODAY);
expect(prisma.suggestion.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
AND: [
{
OR: [
{ requiredPermission: null },
{
requiredPermission: {
in: ['colonies:read', 'tasks:read', 'inspections:read'],
},
},
],
},
{ status: 'OPEN', colonyId: { in: ['c1'] } },
],
},
}),
);
});
it('offers ACCEPT only with tasks:create, else the capture link', async () => {
prisma.suggestion.findMany.mockResolvedValue([suggestionRow()]);
grant('colonies:read', 'tasks:read', 'tasks:create');
const withCreate = await service.forColony('c1', 'u1', TODAY);
grant('colonies:read', 'tasks:read');
const withoutCreate = await service.forColony('c1', 'u1', TODAY);
expect(withCreate.steps[0].action).toEqual({
type: 'ACCEPT',
suggestionId: 's1',
});
expect(withoutCreate.steps[0].action).toEqual({
type: 'CAPTURE',
form: 'treatment',
href: '/treatments/new?colonyId=c1',
});
});
it('cuts the steps to maxStepsPerColony', async () => {
grant('colonies:read', 'tasks:read');
prisma.suggestion.findMany.mockResolvedValue(
[
['a', 'WINTER_CHECK'],
['b', 'VARROA_MEASURE'],
['c', 'INSPECT'],
['d', 'FEED'],
['e', 'VARROA_TREAT'],
].map(([id, intent]) => suggestionRow({ id, intent, mode: 'DO' })),
);
const steps = await service.forColony('c1', 'u1', TODAY);
expect(steps.steps.map((step) => step.key)).toEqual([
'suggestion:e',
'suggestion:d',
'suggestion:c',
]);
});
it('shows inspectionDueOn only with inspections:read', async () => {
grant('colonies:read');
const hidden = await service.forColony('c1', 'u1', TODAY);
grant('colonies:read', 'inspections:read');
const shown = await service.forColony('c1', 'u1', TODAY);
expect(hidden.inspectionDueOn).toBeNull();
// Swarm season by calendar: 7 days after 2026-05-03.
expect(shown.inspectionDueOn).toBe('2026-05-10');
expect(shown.segment).toBe('SCHWARMZEIT');
expect(prisma.inspection.groupBy).toHaveBeenCalledTimes(1);
});
it('reports the latest running withdrawal period as a hint with treatments:read', async () => {
prisma.treatment.findMany.mockResolvedValue([
{
id: 't1',
colonyId: 'c1',
product: 'A',
date: d('2026-05-01'),
startDate: null,
endDate: d('2026-05-05'),
withdrawalPeriodDays: 10,
},
{
id: 't2',
colonyId: 'c1',
product: 'B',
date: d('2026-05-02'),
startDate: null,
endDate: d('2026-05-08'),
withdrawalPeriodDays: 30,
},
{
id: 't3',
colonyId: 'c1',
product: 'C',
date: d('2026-01-02'),
startDate: null,
endDate: null,
withdrawalPeriodDays: 10,
},
]);
grant('colonies:read', 'treatments:read');
const shown = await service.forColony('c1', 'u1', TODAY);
grant('colonies:read');
const hidden = await service.forColony('c1', 'u1', TODAY);
expect(shown.withdrawalUntil).toBe('2026-06-07');
expect(shown.steps).toEqual([]);
expect(hidden.withdrawalUntil).toBeNull();
expect(prisma.treatment.findMany).toHaveBeenCalledTimes(1);
});
it('ranks a task like its intent and shows it with OPEN_TASK', async () => {
grant('colonies:read', 'tasks:read');
prisma.task.findMany.mockResolvedValue([
{
id: 't1',
title: 'Wachs einschmelzen',
status: 'IN_PROGRESS',
priority: 'NORMAL',
dueDate: null,
createdAt: new Date('2026-05-01T00:00:00Z'),
relatedId: 'c1',
sourceAutomationTrigger: null,
sourceSuggestion: null,
},
]);
const steps = await service.forColony('c1', 'u1', TODAY);
expect(steps.steps).toHaveLength(1);
expect(steps.steps[0]).toMatchObject({
key: 'task:t1',
title: 'Wachs einschmelzen',
action: { type: 'OPEN_TASK', taskId: 't1', href: '/tasks' },
});
});
it('hides an open suggestion when an open task of the colony has the same intent', async () => {
grant('colonies:read', 'tasks:read');
prisma.suggestion.findMany.mockResolvedValue([
suggestionRow({ id: 'varroa', intent: 'VARROA_TREAT' }),
suggestionRow({ id: 'feed', intent: 'FEED', mode: 'DO' }),
suggestionRow({ id: 'swarm', intent: 'SWARM_CHECK', colonyId: 'c9' }),
]);
prisma.task.findMany.mockResolvedValue([
{
id: 'task-accepted',
title: 'Behandeln',
status: 'OPEN',
priority: 'NORMAL',
dueDate: null,
createdAt: new Date('2026-05-01T00:00:00Z'),
relatedId: 'c1',
sourceAutomationTrigger: null,
sourceSuggestion: { intent: 'VARROA_TREAT' },
},
{
id: 'task-legacy',
title: 'Füttern',
status: 'OPEN',
priority: 'NORMAL',
dueDate: null,
createdAt: new Date('2026-05-01T00:00:00Z'),
relatedId: 'c1',
sourceAutomationTrigger: 'FEED_STATUS_CRITICAL',
sourceSuggestion: null,
},
]);
const steps = await service.forColony('c1', 'u1', TODAY);
expect(steps.steps.map((step) => step.key).sort()).toEqual([
'task:task-accepted',
'task:task-legacy',
]);
});
it('reads a fixed number of queries for many colonies', async () => {
grant('colonies:read', 'tasks:read', 'inspections:read', 'treatments:read');
prisma.colony.findMany.mockResolvedValue(
['c1', 'c2', 'c3'].map((id) => ({
id,
code: id,
apiaryId: 'a1',
createdAt: new Date('2026-04-01T08:00:00Z'),
})),
);
const result = await service.forColonies(['c1', 'c2', 'c3'], 'u1', TODAY);
expect([...result.keys()]).toEqual(['c1', 'c2', 'c3']);
expect(prisma.colony.findMany).toHaveBeenCalledTimes(1);
expect(prisma.suggestion.findMany).toHaveBeenCalledTimes(1);
expect(prisma.task.findMany).toHaveBeenCalledTimes(1);
expect(prisma.inspection.groupBy).toHaveBeenCalledTimes(1);
expect(prisma.treatment.findMany).toHaveBeenCalledTimes(1);
});
describe('today', () => {
it('picks the most urgent first step over all colonies', async () => {
grant('colonies:read', 'tasks:read');
prisma.colony.findMany.mockImplementation((args: { select: object }) =>
Promise.resolve(
'code' in args.select
? ['c1', 'c2'].map((id) => ({
id,
code: id.toUpperCase(),
apiaryId: 'a1',
createdAt: new Date('2026-04-01T08:00:00Z'),
}))
: [{ id: 'c1' }, { id: 'c2' }],
),
);
prisma.apiary.findMany.mockResolvedValue([
{ id: 'a1', name: 'Heimstand' },
]);
prisma.suggestion.count.mockResolvedValue(2);
prisma.suggestion.findMany.mockResolvedValue([
suggestionRow({ id: 'late', colonyId: 'c1', priority: 'LOW' }),
suggestionRow({ id: 'urgent', colonyId: 'c2', priority: 'URGENT' }),
]);
const today = await service.today('u1', TODAY);
expect(today.nextStep).toMatchObject({
key: 'suggestion:urgent',
colonyId: 'c2',
colonyCode: 'C2',
});
expect(today.suggestionsOpen).toBe(2);
expect(today.tasksVisible).toBe(true);
expect(today.seasons).toEqual([
{
apiaryId: 'a1',
apiaryName: 'Heimstand',
segment: 'SCHWARMZEIT',
phase: 'APFEL',
phaseSource: 'CALENDAR',
},
]);
});
it('has no next step and counts no suggestions without tasks:read', async () => {
grant('colonies:read');
const today = await service.today('u1', TODAY);
expect(today.nextStep).toBeNull();
expect(today.suggestionsOpen).toBe(0);
expect(today.tasksVisible).toBe(false);
expect(prisma.suggestion.count).not.toHaveBeenCalled();
});
it('lists only colonies whose inspection is due, with the overdue days', async () => {
grant('colonies:read', 'inspections:read');
prisma.apiary.findMany.mockResolvedValue([
{ id: 'a1', name: 'Heimstand' },
]);
const today = await service.today('u1', TODAY);
expect(today.inspectionDue).toEqual([
{
apiaryId: 'a1',
apiaryName: 'Heimstand',
segment: 'SCHWARMZEIT',
intervalDays: 7,
colonies: [
{ id: 'c1', code: 'B3', dueOn: '2026-05-10', overdueDays: 2 },
],
},
]);
});
});
describe('warningCounts', () => {
it('counts open HIGH and URGENT suggestions per colony', async () => {
grant('colonies:read', 'tasks:read');
prisma.suggestion.groupBy.mockResolvedValue([
{ colonyId: 'c1', priority: 'HIGH', _count: { _all: 2 } },
{ colonyId: 'c1', priority: 'URGENT', _count: { _all: 1 } },
{ colonyId: null, priority: 'HIGH', _count: { _all: 5 } },
]);
const counts = await service.warningCounts(['c1'], 'u1');
expect(counts).toEqual(new Map([['c1', { warnings: 2, critical: 1 }]]));
});
it('counts nothing without tasks:read', async () => {
grant('colonies:read');
const counts = await service.warningCounts(['c1'], 'u1');
expect(counts.size).toBe(0);
expect(prisma.suggestion.groupBy).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,585 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import type {
AutomationTriggerType,
SuggestionIntent,
TaskPriority,
} from '../../generated/prisma';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { readRule } from '../automation/rule-config';
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
import {
addDays,
berlinDayOf,
berlinToday,
daysBetween,
dayToDate,
} from '../common/dates';
import { PrismaService } from '../prisma/prisma.service';
import { SeasonService } from '../season/season.service';
import { inspectionDueOn } from '../season/inspection-due';
import type { SeasonSegmentKey, SeasonState } from '../season/season-types';
import { buildEvidenceLookup } from '../suggestions/evidence-lookup';
import { renderSuggestion } from '../suggestions/suggestion-texts';
import { SuggestionViewsService } from '../suggestions/suggestion-views.service';
import {
INTENT_OF_LEGACY_TRIGGER,
type SuggestionEvidence,
} from '../suggestions/suggestion.types';
import {
isInWindow,
withdrawalWindowOf,
} from '../treatments/withdrawal-window';
import {
compareSources,
topSteps,
type ColonyNextSteps,
type NextStep,
type StepSource,
} from './next-step-ranking';
export interface AutopilotToday {
nextStep: (NextStep & { colonyId: string; colonyCode: string }) | null;
/** Colonies whose inspection is due today or overdue (`dueOn <= today`), not those coming up. */
inspectionDue: {
apiaryId: string;
apiaryName: string;
segment: SeasonSegmentKey;
intervalDays: number | null;
colonies: {
id: string;
code: string;
dueOn: string;
overdueDays: number;
}[];
}[];
suggestionsOpen: number;
seasons: {
apiaryId: string;
apiaryName: string;
segment: SeasonSegmentKey;
phase: ColonyNextSteps['phase'];
phaseSource: ColonyNextSteps['phaseSource'];
}[];
tasksVisible: boolean;
}
/** Open suggestions of HIGH / URGENT priority that the caller may see. */
export interface WarningCount {
warnings: number;
critical: number;
}
interface ColonyComputation {
code: string;
apiaryId: string;
/** Leading source of the first step, for the comparison across colonies. */
head: StepSource | null;
view: ColonyNextSteps;
}
interface Computation {
colonies: Map<string, ColonyComputation>;
seasons: Map<string, SeasonState>;
intervalDays: Readonly<
Record<Exclude<SeasonSegmentKey, 'WINTERRUHE'>, number>
>;
}
/** A withdrawal period longer than this is not looked for. */
const WITHDRAWAL_LOOKBACK_DAYS = 400;
const OPEN_TASK_STATUSES = ['OPEN', 'IN_PROGRESS'] as const;
const WARNING_PRIORITIES: readonly TaskPriority[] = ['HIGH', 'URGENT'];
/** The intent an open task stands for; null for a plain task. */
function taskIntentOf(task: {
sourceSuggestion: { intent: SuggestionIntent } | null;
sourceAutomationTrigger: AutomationTriggerType | null;
}): SuggestionIntent | null {
return (
task.sourceSuggestion?.intent ??
(task.sourceAutomationTrigger === null
? null
: (INTENT_OF_LEGACY_TRIGGER[task.sourceAutomationTrigger] ?? null))
);
}
function byCode(a: { code: string }, b: { code: string }): number {
return a.code.localeCompare(b.code, 'de', { numeric: true });
}
/**
* "The next 3 steps" of a colony (Spec 8.4, 8.5) and the day overview: what
* the user may see of the open tasks and suggestions, ranked, merged by form
* and cut to `maxStepsPerColony`. Reads only; a fixed number of queries for
* any number of colonies.
*/
@Injectable()
export class NextStepsService {
constructor(
private readonly prisma: PrismaService,
private readonly permissionResolver: PermissionResolverService,
private readonly seasons: SeasonService,
private readonly suggestionViews: SuggestionViewsService,
) {}
async forColony(
colonyId: string,
userId: string,
today: string = berlinToday(),
): Promise<ColonyNextSteps> {
const view = (await this.forColonies([colonyId], userId, today)).get(
colonyId,
);
if (view === undefined) {
throw new NotFoundException('Colony not found');
}
return view;
}
/** Unknown colonies are left out of the map. */
async forColonies(
colonyIds: readonly string[],
userId: string,
today: string = berlinToday(),
permissions?: ReadonlySet<string>,
): Promise<Map<string, ColonyNextSteps>> {
const held =
permissions ?? (await this.permissionResolver.getPermissions(userId));
const { colonies } = await this.compute(colonyIds, held, today);
return new Map(
[...colonies].map(([id, colony]) => [id, colony.view] as const),
);
}
/**
* Open HIGH / URGENT suggestions per colony: the "warnings" and
* "critical" of the colony traffic light. Only what the user may see
* (`tasks:read` and the permission of each suggestion).
*/
async warningCounts(
colonyIds: readonly string[],
userId: string,
permissions?: ReadonlySet<string>,
): Promise<Map<string, WarningCount>> {
const held =
permissions ?? (await this.permissionResolver.getPermissions(userId));
const counts = new Map<string, WarningCount>();
if (!held.has('tasks:read') || colonyIds.length === 0) {
return counts;
}
const groups = await this.prisma.suggestion.groupBy({
by: ['colonyId', 'priority'],
where: {
AND: [
this.suggestionViews.visibleWhere(held),
{
status: 'OPEN',
colonyId: { in: [...colonyIds] },
priority: { in: [...WARNING_PRIORITIES] },
},
],
},
_count: { _all: true },
});
for (const group of groups) {
if (group.colonyId === null) {
continue;
}
const current = counts.get(group.colonyId) ?? {
warnings: 0,
critical: 0,
};
counts.set(
group.colonyId,
group.priority === 'URGENT'
? { ...current, critical: group._count._all }
: { ...current, warnings: group._count._all },
);
}
return counts;
}
async today(
userId: string,
today: string = berlinToday(),
): Promise<AutopilotToday> {
const held = await this.permissionResolver.getPermissions(userId);
const living = await this.prisma.colony.findMany({
where: LIVING_COLONY_WHERE,
select: { id: true },
});
const { colonies, seasons, intervalDays } = await this.compute(
living.map((colony) => colony.id),
held,
today,
);
const apiaries = await this.prisma.apiary.findMany({
where: { id: { in: [...seasons.keys()] } },
select: { id: true, name: true },
});
const apiaryName = new Map(apiaries.map((a) => [a.id, a.name] as const));
const nameOf = (apiaryId: string): string => apiaryName.get(apiaryId) ?? '';
const suggestionsOpen = held.has('tasks:read')
? await this.prisma.suggestion.count({
where: {
AND: [this.suggestionViews.visibleWhere(held), { status: 'OPEN' }],
},
})
: 0;
const sortedApiaries = [...seasons.values()].sort((a, b) =>
nameOf(a.apiaryId).localeCompare(nameOf(b.apiaryId), 'de', {
numeric: true,
}),
);
return {
nextStep: this.mostUrgent(colonies, today),
inspectionDue: sortedApiaries.flatMap((season) => {
const due = [...colonies]
.filter(
([, c]) =>
c.apiaryId === season.apiaryId &&
c.view.inspectionDueOn !== null &&
c.view.inspectionDueOn <= today,
)
.map(([id, c]) => ({
id,
code: c.code,
dueOn: c.view.inspectionDueOn ?? today,
overdueDays: daysBetween(c.view.inspectionDueOn ?? today, today),
}))
.sort((a, b) => a.dueOn.localeCompare(b.dueOn) || byCode(a, b));
return due.length === 0
? []
: [
{
apiaryId: season.apiaryId,
apiaryName: nameOf(season.apiaryId),
segment: season.segment,
intervalDays:
season.segment === 'WINTERRUHE'
? null
: intervalDays[season.segment],
colonies: due,
},
];
}),
suggestionsOpen,
seasons: sortedApiaries.map((season) => ({
apiaryId: season.apiaryId,
apiaryName: nameOf(season.apiaryId),
segment: season.segment,
phase: season.phase,
phaseSource: season.phaseSource,
})),
tasksVisible: held.has('tasks:read'),
};
}
private mostUrgent(
colonies: ReadonlyMap<string, ColonyComputation>,
today: string,
): AutopilotToday['nextStep'] {
const candidates = [...colonies].flatMap(([colonyId, colony]) =>
colony.head === null || colony.view.steps.length === 0
? []
: [{ colonyId, colony, head: colony.head }],
);
candidates.sort((a, b) => compareSources(a.head, b.head, today));
const best = candidates.at(0);
return best === undefined
? null
: {
...best.colony.view.steps[0],
colonyId: best.colonyId,
colonyCode: best.colony.code,
};
}
private async compute(
colonyIds: readonly string[],
held: ReadonlySet<string>,
today: string,
): Promise<Computation> {
const ids = [...new Set(colonyIds)];
const rows =
ids.length === 0
? []
: await this.prisma.colony.findMany({
where: { id: { in: ids } },
select: { id: true, code: true, apiaryId: true, createdAt: true },
});
const [states, interval, autopilot] = await Promise.all([
this.seasons.statesFor(
this.prisma,
rows.map((row) => row.apiaryId),
today,
),
readRule(this.prisma, 'INSPECTION_INTERVAL'),
readRule(this.prisma, 'SEASON_AUTOPILOT'),
]);
const foundIds = rows.map((row) => row.id);
const [lastInspections, sources, withdrawals] = await Promise.all([
this.lastInspectionDays(foundIds, held),
this.stepSources(foundIds, held, today),
this.withdrawalUntil(foundIds, held, today),
]);
const colonies = new Map<string, ColonyComputation>();
for (const row of rows) {
const season = states.get(row.apiaryId);
if (season === undefined) {
continue;
}
const due = interval.active
? inspectionDueOn(
{
createdOn: berlinDayOf(row.createdAt),
lastInspectedOn: lastInspections.get(row.id) ?? null,
},
season,
interval.config,
)
: null;
const own = sources.get(row.id) ?? [];
const steps = topSteps(own, today, {
limit: autopilot.config.maxStepsPerColony,
canAccept: held.has('tasks:create'),
});
colonies.set(row.id, {
code: row.code,
apiaryId: row.apiaryId,
head: this.headOf(own, steps),
view: {
colonyId: row.id,
segment: season.segment,
phase: season.phase,
phaseSource: season.phaseSource,
inspectionDueOn:
due !== null && held.has('inspections:read') ? due.dueOn : null,
withdrawalUntil: withdrawals.get(row.id) ?? null,
steps,
},
});
}
return {
colonies,
seasons: states,
intervalDays: interval.config.intervalDays,
};
}
/** The source that stands for the first step (for the cross-colony order). */
private headOf(
own: readonly StepSource[],
steps: readonly NextStep[],
): StepSource | null {
const first = steps.at(0);
if (first === undefined) {
return null;
}
const byId = new Map(own.map((source) => [source.id, source] as const));
const id =
first.suggestionIds.at(0) ??
(first.action.type === 'OPEN_TASK' ? first.action.taskId : null);
return id === null ? null : (byId.get(id) ?? null);
}
/** Last inspection day per colony; nothing without `inspections:read`. */
private async lastInspectionDays(
colonyIds: readonly string[],
held: ReadonlySet<string>,
): Promise<Map<string, string>> {
const days = new Map<string, string>();
if (!held.has('inspections:read') || colonyIds.length === 0) {
return days;
}
const groups = await this.prisma.inspection.groupBy({
by: ['colonyId'],
where: { colonyId: { in: [...colonyIds] } },
_max: { date: true },
});
for (const group of groups) {
if (group._max.date) {
days.set(group.colonyId, berlinDayOf(group._max.date));
}
}
return days;
}
/** The youngest running withdrawal period per colony (treatments:read). */
private async withdrawalUntil(
colonyIds: readonly string[],
held: ReadonlySet<string>,
today: string,
): Promise<Map<string, string>> {
const until = new Map<string, string>();
if (!held.has('treatments:read') || colonyIds.length === 0) {
return until;
}
const cutoff = dayToDate(addDays(today, -WITHDRAWAL_LOOKBACK_DAYS));
const treatments = await this.prisma.treatment.findMany({
where: {
colonyId: { in: [...colonyIds] },
withdrawalPeriodDays: { not: null },
OR: [{ endDate: { gte: cutoff } }, { date: { gte: cutoff } }],
},
select: {
id: true,
colonyId: true,
product: true,
date: true,
startDate: true,
endDate: true,
withdrawalPeriodDays: true,
},
});
for (const treatment of treatments) {
const window = withdrawalWindowOf(treatment);
if (window === null || !isInWindow(window, today)) {
continue;
}
const current = until.get(treatment.colonyId);
if (current === undefined || window.untilDay > current) {
until.set(treatment.colonyId, window.untilDay);
}
}
return until;
}
/**
* Open suggestions (visible ones, and only with `tasks:read`) and open
* tasks (`tasks:read`) of the colonies, as rankable sources.
*/
private async stepSources(
colonyIds: readonly string[],
held: ReadonlySet<string>,
today: string,
): Promise<Map<string, StepSource[]>> {
const sources = new Map<string, StepSource[]>();
if (!held.has('tasks:read') || colonyIds.length === 0) {
return sources;
}
const [suggestions, tasks] = await Promise.all([
this.prisma.suggestion.findMany({
where: {
AND: [
this.suggestionViews.visibleWhere(held),
{ status: 'OPEN', colonyId: { in: [...colonyIds] } },
],
},
select: {
id: true,
ruleKey: true,
reasonCode: true,
evidence: true,
intent: true,
mode: true,
status: true,
priority: true,
dueDate: true,
createdAt: true,
colonyId: true,
},
}),
this.prisma.task.findMany({
where: {
status: { in: [...OPEN_TASK_STATUSES] },
relatedType: 'COLONY',
relatedId: { in: [...colonyIds] },
},
select: {
id: true,
title: true,
status: true,
priority: true,
dueDate: true,
createdAt: true,
relatedId: true,
sourceAutomationTrigger: true,
sourceSuggestion: { select: { intent: true } },
},
}),
]);
const [lookup, templates] = await Promise.all([
buildEvidenceLookup(
this.prisma,
suggestions.map((row) => row.evidence as SuggestionEvidence),
),
this.titleTemplates(suggestions.map((row) => row.ruleKey)),
]);
const add = (colonyId: string, source: StepSource): void => {
sources.set(colonyId, [...(sources.get(colonyId) ?? []), source]);
};
// One open thing per intent and colony: an open task of the same intent
// (accepted suggestion or legacy rule task) hides an open suggestion.
const openTaskIntents = new Set(
tasks.flatMap((row) => {
const intent = taskIntentOf(row);
return row.relatedId === null || intent === null
? []
: [`${row.relatedId}|${intent}`];
}),
);
for (const row of suggestions) {
if (
row.colonyId === null ||
openTaskIntents.has(`${row.colonyId}|${row.intent}`)
) {
continue;
}
const text = renderSuggestion(
row,
lookup,
today,
templates.get(row.ruleKey),
);
add(row.colonyId, {
kind: 'SUGGESTION',
id: row.id,
intent: row.intent,
mode: row.mode,
status: row.status,
priority: row.priority,
dueOn: row.dueDate === null ? null : berlinDayOf(row.dueDate),
createdAt: row.createdAt,
title: text.title,
reasons: text.reasons,
colonyId: row.colonyId,
});
}
for (const row of tasks) {
if (row.relatedId === null) {
continue;
}
add(row.relatedId, {
kind: 'TASK',
id: row.id,
intent: taskIntentOf(row),
mode: null,
status: row.status,
priority: row.priority,
dueOn: row.dueDate === null ? null : berlinDayOf(row.dueDate),
createdAt: row.createdAt,
title: row.title,
reasons: [],
colonyId: row.relatedId,
});
}
return sources;
}
/** The `actionTemplate` of each rule replaces the title of its suggestions. */
private async titleTemplates(
ruleKeys: readonly AutomationTriggerType[],
): Promise<Map<AutomationTriggerType, string | null>> {
if (ruleKeys.length === 0) {
return new Map();
}
const rules = await this.prisma.automationRule.findMany({
where: { triggerType: { in: [...new Set(ruleKeys)] } },
select: { triggerType: true, actionTemplate: true },
});
return new Map(
rules.map((rule) => [rule.triggerType, rule.actionTemplate] as const),
);
}
}
+1 -1
View File
@@ -1057,7 +1057,7 @@ export class BackupService {
} }
imported.calendarSyncSettings = rowsOf('calendarSyncSettings').length; 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 // the target database may already track the same source under
// another id. graphEventId comes along, so a restored entry keeps // another id. graphEventId comes along, so a restored entry keeps
// updating its existing M365 event instead of creating a duplicate. // updating its existing M365 event instead of creating a duplicate.
@@ -1,11 +1,12 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AutomationModule } from '../automation/automation.module'; import { AutomationModule } from '../automation/automation.module';
import { StockModule } from '../stock/stock.module'; import { StockModule } from '../stock/stock.module';
import { WithdrawalModule } from '../withdrawal/withdrawal.module';
import { BottlingController } from './bottling.controller'; import { BottlingController } from './bottling.controller';
import { BottlingService } from './bottling.service'; import { BottlingService } from './bottling.service';
@Module({ @Module({
imports: [AutomationModule, StockModule], imports: [AutomationModule, StockModule, WithdrawalModule],
controllers: [BottlingController], controllers: [BottlingController],
providers: [BottlingService], providers: [BottlingService],
}) })
@@ -9,6 +9,8 @@ import { AuditLogService } from '../audit/audit-log.service';
import { DomainEventsService } from '../domain-events/domain-events.service'; import { DomainEventsService } from '../domain-events/domain-events.service';
import { AutomationService } from '../automation/automation.service'; import { AutomationService } from '../automation/automation.service';
import { StockService } from '../stock/stock.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 }; type AmountChange = number | { increment?: number; decrement?: number };
@@ -63,6 +65,10 @@ describe('BottlingService', () => {
}; };
let auditLog: { record: jest.Mock }; let auditLog: { record: jest.Mock };
let events: { recordAll: jest.Mock }; let events: { recordAll: jest.Mock };
let withdrawalCheck: {
conflictsForHoneyBatch: jest.Mock;
warningsFor: jest.Mock;
};
let tx: { let tx: {
$queryRaw: jest.Mock; $queryRaw: jest.Mock;
product: { findUnique: jest.Mock }; product: { findUnique: jest.Mock };
@@ -185,12 +191,17 @@ describe('BottlingService', () => {
), ),
hasMovements: jest.fn().mockResolvedValue(false), hasMovements: jest.fn().mockResolvedValue(false),
}; };
withdrawalCheck = {
conflictsForHoneyBatch: jest.fn().mockResolvedValue([]),
warningsFor: jest.fn().mockResolvedValue([]),
};
service = new BottlingService( service = new BottlingService(
prisma as unknown as PrismaService, prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService, auditLog as unknown as AuditLogService,
events as unknown as DomainEventsService, events as unknown as DomainEventsService,
automation as unknown as AutomationService, automation as unknown as AutomationService,
stock as unknown as StockService, stock as unknown as StockService,
withdrawalCheck as unknown as WithdrawalCheckService,
); );
}); });
@@ -253,7 +264,7 @@ describe('BottlingService', () => {
honeyBatchId: 'h1', honeyBatchId: 'h1',
totalAmount: 25, totalAmount: 25,
}); });
prisma.bottlingBatch.findUnique.mockResolvedValueOnce({ tx.bottlingBatch.findUnique.mockResolvedValueOnce({
id: 'b1', id: 'b1',
totalAmount: 25, totalAmount: 25,
}); });
@@ -270,7 +281,7 @@ describe('BottlingService', () => {
'user-1', '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(honeyBatches.get('h1')).toEqual({ amount: 0, status: 'BOTTLED' });
expect(auditLog.record).toHaveBeenCalledWith( expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
@@ -292,7 +303,7 @@ describe('BottlingService', () => {
honeyBatchId: 'h1', honeyBatchId: 'h1',
totalAmount: 5, totalAmount: 5,
}); });
prisma.bottlingBatch.findUnique.mockResolvedValueOnce({ id: 'b1' }); tx.bottlingBatch.findUnique.mockResolvedValueOnce({ id: 'b1' });
await service.create( await service.create(
{ {
@@ -359,7 +370,7 @@ describe('BottlingService', () => {
const result = await service.update('b1', { lotNumber: 'L-2' }, 'user-1'); const result = await service.update('b1', { lotNumber: 'L-2' }, 'user-1');
// Without movements the remaining stock equals the jar count. // 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(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
entityType: 'BottlingBatch', entityType: 'BottlingBatch',
@@ -675,4 +686,80 @@ describe('BottlingService', () => {
NotFoundException, 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 type { AnyNewDomainEvent } from '../domain-events/domain-event-types';
import { AutomationService } from '../automation/automation.service'; import { AutomationService } from '../automation/automation.service';
import { StockService } from '../stock/stock.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 { CreateBottlingBatchDto } from './dto/create-bottling-batch.dto';
import { UpdateBottlingBatchDto } from './dto/update-bottling-batch.dto'; import { UpdateBottlingBatchDto } from './dto/update-bottling-batch.dto';
@@ -116,8 +118,29 @@ export class BottlingService {
private readonly events: DomainEventsService, private readonly events: DomainEventsService,
private readonly automation: AutomationService, private readonly automation: AutomationService,
private readonly stock: StockService, 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 = {}) { async findAll(filter: BottlingBatchFilter = {}) {
const batches = await this.prisma.bottlingBatch.findMany({ const batches = await this.prisma.bottlingBatch.findMany({
where: { honeyBatchId: filter.honeyBatchId }, where: { honeyBatchId: filter.honeyBatchId },
@@ -194,48 +217,48 @@ export class BottlingService {
); );
} }
const bottlingBatch: BottlingBatch = await this.prisma.$transaction( return this.prisma.$transaction(async (tx) => {
async (tx) => { // Booked against the stored amount inside the transaction, never
// Booked against the stored amount inside the transaction, never // against the value read above: two bottlings submitted at the same
// against the value read above: two bottlings submitted at the same // time (double tap, two devices) must not both pass the check and
// time (double tap, two devices) must not both pass the check and // overwrite each other's deduction.
// overwrite each other's deduction. await takeFromHoneyBatch(tx, dto.honeyBatchId, totalAmount);
await takeFromHoneyBatch(tx, dto.honeyBatchId, totalAmount); const created = await tx.bottlingBatch.create({
const created = await tx.bottlingBatch.create({ data: {
data: { honeyBatchId: dto.honeyBatchId,
honeyBatchId: dto.honeyBatchId, productId: dto.productId,
productId: dto.productId, date: new Date(dto.date),
date: new Date(dto.date), jarSize: dto.jarSize,
jarSize: dto.jarSize, jarCount: dto.jarCount,
jarCount: dto.jarCount, totalAmount,
totalAmount, bestBeforeDate: new Date(dto.bestBeforeDate),
bestBeforeDate: new Date(dto.bestBeforeDate), lotNumber,
lotNumber, userId: actingUserId,
userId: actingUserId, },
}, });
}); await this.auditLog.record(
await this.auditLog.record( {
{ entityType: 'BottlingBatch',
entityType: 'BottlingBatch', entityId: created.id,
entityId: created.id, action: 'CREATE',
action: 'CREATE', userId: actingUserId,
userId: actingUserId, afterData: created,
afterData: created, },
}, tx,
tx, );
); await this.recordEvents(tx, {
await this.recordEvents(tx, { type: 'BatchBottled',
type: 'BatchBottled', aggregateType: 'BottlingBatch',
aggregateType: 'BottlingBatch', aggregateId: created.id,
aggregateId: created.id, payload: { honeyBatchId: created.honeyBatchId },
payload: { honeyBatchId: created.honeyBatchId }, actorUserId: actingUserId,
actorUserId: actingUserId, });
}); return this.withWithdrawalWarnings(
return created; tx,
}, created.honeyBatchId,
); await this.findById(created.id, tx),
);
return this.findById(bottlingBatch.id); });
} }
/** /**
@@ -337,7 +360,7 @@ export class BottlingService {
payload: { honeyBatchId, removed: false }, payload: { honeyBatchId, removed: false },
actorUserId: actingUserId, 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)( it.each(['TaskCreated', 'TaskUpdated', 'TaskCompleted'] as const)(
'task handler enqueues the task on %s', 'task handler enqueues the task on %s',
async (type) => { async (type) => {
@@ -121,6 +137,12 @@ describe('CalendarSyncEventHandlers', () => {
expect(registered('treatment')[0].handlerId).toBe( expect(registered('treatment')[0].handlerId).toBe(
'calendar-sync.treatment', '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('feeding')[0].handlerId).toBe('calendar-sync.feeding');
expect(registered('harvest')[0].handlerId).toBe('calendar-sync.harvest'); expect(registered('harvest')[0].handlerId).toBe('calendar-sync.harvest');
expect(registered('queenAssignment')).toEqual([ expect(registered('queenAssignment')).toEqual([
@@ -35,6 +35,20 @@ export class CalendarSyncEventHandlers {
await this.calendarSync.enqueueIn(tx, 'TREATMENT', event.aggregateId); 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( @OnDomainEvent(
['FeedingRecorded', 'FeedingCorrected'], ['FeedingRecorded', 'FeedingCorrected'],
'calendar-sync.feeding', 'calendar-sync.feeding',
@@ -5,10 +5,10 @@ import { CalendarSyncService } from './calendar-sync.service';
import { CalendarSyncSettingsService } from './calendar-sync-settings.service'; import { CalendarSyncSettingsService } from './calendar-sync-settings.service';
import { GraphClientService } from './graph-client.service'; import { GraphClientService } from './graph-client.service';
// @Global(): CalendarSyncService.enqueue() is called from otherwise-unrelated // @Global(): WorkerRuntimeModule (the calendar-outbox loop) injects
// domain modules (Inspections, Treatments, Feedings, Harvests, Queens) until // CalendarSyncService without importing this module, in the worker process
// they record domain events - matches WebhooksModule's own @Global() // and in role "all". Domain modules no longer call it: they record events and
// rationale rather than having each of those modules import this one. // CalendarSyncEventHandlers enqueues.
@Global() @Global()
@Module({ @Module({
controllers: [CalendarSyncController], controllers: [CalendarSyncController],
@@ -1,4 +1,3 @@
import { Logger } from '@nestjs/common';
import { CalendarSyncService } from './calendar-sync.service'; import { CalendarSyncService } from './calendar-sync.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { CalendarSyncSettingsService } from './calendar-sync-settings.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 () => { it('does nothing when sync is disabled overall', async () => {
settingsService.get.mockResolvedValue({ settingsService.get.mockResolvedValue({
...enabledSettings, ...enabledSettings,
enabled: false, enabled: false,
}); });
await service.enqueue('TASK', 'task-1'); await service.enqueueIn(root(), 'TASK', 'task-1');
expect(prisma.calendarSyncOutboxEntry.upsert).not.toHaveBeenCalled(); expect(prisma.calendarSyncOutboxEntry.upsert).not.toHaveBeenCalled();
}); });
@@ -126,7 +127,7 @@ describe('CalendarSyncService', () => {
it('enqueues a WITHDRAWAL entry while syncWithdrawal is on', async () => { it('enqueues a WITHDRAWAL entry while syncWithdrawal is on', async () => {
settingsService.get.mockResolvedValue(enabledSettings); settingsService.get.mockResolvedValue(enabledSettings);
await service.enqueue('WITHDRAWAL', 'treatment-1'); await service.enqueueIn(root(), 'WITHDRAWAL', 'treatment-1');
expect(prisma.calendarSyncOutboxEntry.upsert).toHaveBeenCalled(); expect(prisma.calendarSyncOutboxEntry.upsert).toHaveBeenCalled();
}); });
@@ -137,7 +138,7 @@ describe('CalendarSyncService', () => {
syncWithdrawal: false, syncWithdrawal: false,
}); });
await service.enqueue('WITHDRAWAL', 'treatment-1'); await service.enqueueIn(root(), 'WITHDRAWAL', 'treatment-1');
expect(prisma.calendarSyncOutboxEntry.upsert).not.toHaveBeenCalled(); expect(prisma.calendarSyncOutboxEntry.upsert).not.toHaveBeenCalled();
}); });
@@ -148,13 +149,13 @@ describe('CalendarSyncService', () => {
syncTask: false, syncTask: false,
}); });
await service.enqueue('TASK', 'task-1'); await service.enqueueIn(root(), 'TASK', 'task-1');
expect(prisma.calendarSyncOutboxEntry.upsert).not.toHaveBeenCalled(); expect(prisma.calendarSyncOutboxEntry.upsert).not.toHaveBeenCalled();
}); });
it('upserts a PENDING row, resetting attempts and lastError and counting the revision up', async () => { 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({ expect(prisma.calendarSyncOutboxEntry.upsert).toHaveBeenCalledWith({
where: { where: {
@@ -169,34 +170,9 @@ describe('CalendarSyncService', () => {
}, },
}); });
}); });
});
// enqueue() runs after the source record is committed: a failure must not it('no longer has a post-commit enqueue', () => {
// fail that request (the client would retry and store it twice). expect('enqueue' in service).toBe(false);
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');
}); });
}); });
@@ -660,14 +636,98 @@ describe('CalendarSyncService', () => {
expectSettled({ status: 'FAILED', attempts: 5, lastError: 'db error' }); expectSettled({ status: 'FAILED', attempts: 5, lastError: 'db error' });
}); });
it('writes no Graph event for a WITHDRAWAL entry before Task 12', async () => { describe('WITHDRAWAL entries', () => {
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([ const withdrawalEntry = () =>
pendingEntry({ sourceType: 'WITHDRAWAL', sourceId: 't1' }), 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 () => { 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 { Injectable, Logger } from '@nestjs/common';
import { DbClient } from '../prisma/db-client'; import { DbClient } from '../prisma/db-client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { runAfterCommit } from '../common/after-commit'; import { dayToDate } from '../common/dates';
import { loggableError } from '../common/loggable-error'; import { loggableError } from '../common/loggable-error';
import { notifyWorker, WAKE_CHANNELS } from '../common/worker-wakeup'; import { notifyWorker, WAKE_CHANNELS } from '../common/worker-wakeup';
import { withdrawalSpanOf } from '../withdrawal/withdrawal-span';
import { CalendarSyncSettingsService } from './calendar-sync-settings.service'; import { CalendarSyncSettingsService } from './calendar-sync-settings.service';
import { import {
GraphClientService, GraphClientService,
@@ -108,21 +109,6 @@ export class CalendarSyncService {
return flagBySourceType[sourceType]; 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 // 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) // 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 // 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. // Best-effort removal from M365: logged, never thrown.
private async deleteGraphEvent(graphEventId: string): Promise<void> { private async deleteGraphEvent(graphEventId: string): Promise<void> {
const settings = await this.settingsService.get(); const settings = await this.settingsService.get();
@@ -500,9 +473,22 @@ export class CalendarSyncService {
date: assignment.startDate, date: assignment.startDate,
}; };
} }
case 'WITHDRAWAL': case 'WITHDRAWAL': {
// Switch Task 3, content Task 12: nothing to write before that. const treatment = await this.prisma.treatment.findUnique({
return null; 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'); 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 () => { it('caches the token and does not fetch it again for a second call', async () => {
fetchMock fetchMock
.mockResolvedValueOnce( .mockResolvedValueOnce(
@@ -3,6 +3,11 @@ import { Injectable } from '@nestjs/common';
export interface GraphCalendarEventInput { export interface GraphCalendarEventInput {
title: string; title: string;
date: Date; 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 { interface CachedToken {
@@ -145,7 +150,13 @@ export class GraphClientService {
const day = input.date.toISOString().slice(0, 10); const day = input.date.toISOString().slice(0, 10);
const nextDay = new Date(input.date); const nextDay = new Date(input.date);
nextDay.setUTCDate(nextDay.getUTCDate() + 1); 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 { return {
subject: input.title, subject: input.title,
isAllDay: true, isAllDay: true,
@@ -1,6 +1,8 @@
import { BadRequestException, Controller, Get, Query } from '@nestjs/common'; import { BadRequestException, Controller, Get, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { RequirePermission } from '../auth/decorators/require-permission.decorator'; import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CalendarService } from './calendar.service'; import { CalendarService } from './calendar.service';
@ApiTags('calendar') @ApiTags('calendar')
@@ -12,6 +14,7 @@ export class CalendarController {
@RequirePermission('calendar:read') @RequirePermission('calendar:read')
@Get() @Get()
getEvents( getEvents(
@CurrentUser() actor: AuthenticatedUser,
@Query('from') from: string, @Query('from') from: string,
@Query('to') to: string, @Query('to') to: string,
@Query('apiaryId') apiaryId?: string, @Query('apiaryId') apiaryId?: string,
@@ -21,10 +24,13 @@ export class CalendarController {
'Query parameters from and to are required', 'Query parameters from and to are required',
); );
} }
return this.calendarService.getEvents({ const range = { from: new Date(from), to: new Date(to) };
from: new Date(from), if (
to: new Date(to), Number.isNaN(range.from.getTime()) ||
apiaryId, 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 { CalendarService } from './calendar.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
describe('CalendarService', () => { describe('CalendarService', () => {
let service: CalendarService; let service: CalendarService;
@@ -12,7 +13,10 @@ describe('CalendarService', () => {
queenColonyHistory: { findMany: jest.Mock }; queenColonyHistory: { findMany: jest.Mock };
}; };
let resolver: { getPermissions: jest.Mock };
beforeEach(() => { beforeEach(() => {
resolver = { getPermissions: jest.fn().mockResolvedValue(new Set()) };
prisma = { prisma = {
task: { findMany: jest.fn().mockResolvedValue([]) }, task: { findMany: jest.fn().mockResolvedValue([]) },
inspection: { findMany: jest.fn().mockResolvedValue([]) }, inspection: { findMany: jest.fn().mockResolvedValue([]) },
@@ -21,7 +25,10 @@ describe('CalendarService', () => {
harvest: { findMany: jest.fn().mockResolvedValue([]) }, harvest: { findMany: jest.fn().mockResolvedValue([]) },
queenColonyHistory: { 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 () => { 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' }, { id: 'h1', date: new Date('2026-06-10'), apiaryId: 'a1' },
]); ]);
const events = await service.getEvents({ const events = await service.getEvents(
from: new Date('2026-06-01'), {
to: new Date('2026-06-30'), from: new Date('2026-06-01'),
}); to: new Date('2026-06-30'),
},
'user-1',
);
expect(events.map((event) => event.type)).toEqual([ expect(events.map((event) => event.type)).toEqual([
'INSPECTION', 'INSPECTION',
@@ -53,11 +63,14 @@ describe('CalendarService', () => {
}); });
it('filters colony-linked events by apiary when apiaryId is given', async () => { it('filters colony-linked events by apiary when apiaryId is given', async () => {
await service.getEvents({ await service.getEvents(
from: new Date('2026-06-01'), {
to: new Date('2026-06-30'), from: new Date('2026-06-01'),
apiaryId: 'a1', to: new Date('2026-06-30'),
}); apiaryId: 'a1',
},
'user-1',
);
const [[inspectionArg]] = prisma.inspection.findMany.mock.calls as [ const [[inspectionArg]] = prisma.inspection.findMany.mock.calls as [
[{ where: { colony?: { apiaryId?: string } } }], [{ where: { colony?: { apiaryId?: string } } }],
@@ -75,11 +88,155 @@ describe('CalendarService', () => {
{ id: 't1', title: 'Ohne Datum', dueDate: null }, { id: 't1', title: 'Ohne Datum', dueDate: null },
]); ]);
const events = await service.getEvents({ const events = await service.getEvents(
from: new Date('2026-06-01'), {
to: new Date('2026-06-30'), from: new Date('2026-06-01'),
}); to: new Date('2026-06-30'),
},
'user-1',
);
expect(events).toHaveLength(0); 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 { Injectable } from '@nestjs/common';
import type { Prisma } from '../../generated/prisma';
import { PrismaService } from '../prisma/prisma.service'; 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 = export type CalendarEventType =
| 'TASK' | 'TASK'
@@ -7,12 +15,15 @@ export type CalendarEventType =
| 'TREATMENT' | 'TREATMENT'
| 'FEEDING' | 'FEEDING'
| 'HARVEST' | 'HARVEST'
| 'QUEEN_ASSIGNMENT'; | 'QUEEN_ASSIGNMENT'
| 'WITHDRAWAL';
export interface CalendarEvent { export interface CalendarEvent {
id: string; id: string;
type: CalendarEventType; type: CalendarEventType;
date: Date; date: Date;
/** WITHDRAWAL only: the last day the span covers (inclusive). */
endDate?: Date;
title: string; title: string;
relatedType: string; relatedType: string;
relatedId: string; relatedId: string;
@@ -26,10 +37,68 @@ export interface CalendarQuery {
@Injectable() @Injectable()
export class CalendarService { 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; 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 colonyFilter = apiaryId ? { colony: { apiaryId } } : {};
const [ const [
@@ -39,6 +108,7 @@ export class CalendarService {
feedings, feedings,
harvests, harvests,
queenAssignments, queenAssignments,
withdrawals,
] = await Promise.all([ ] = await Promise.all([
this.prisma.task.findMany({ this.prisma.task.findMany({
where: { dueDate: { gte: from, lte: to } }, where: { dueDate: { gte: from, lte: to } },
@@ -47,10 +117,12 @@ export class CalendarService {
where: { date: { gte: from, lte: to }, ...colonyFilter }, where: { date: { gte: from, lte: to }, ...colonyFilter },
include: { colony: true }, include: { colony: true },
}), }),
this.prisma.treatment.findMany({ canReadTreatments
where: { date: { gte: from, lte: to }, ...colonyFilter }, ? this.prisma.treatment.findMany({
include: { colony: true }, where: { date: { gte: from, lte: to }, ...colonyFilter },
}), include: { colony: true },
})
: Promise.resolve<TreatmentWithColony[]>([]),
this.prisma.feeding.findMany({ this.prisma.feeding.findMany({
where: { date: { gte: from, lte: to }, ...colonyFilter }, where: { date: { gte: from, lte: to }, ...colonyFilter },
include: { colony: true }, include: { colony: true },
@@ -65,6 +137,7 @@ export class CalendarService {
where: { startDate: { gte: from, lte: to }, ...colonyFilter }, where: { startDate: { gte: from, lte: to }, ...colonyFilter },
include: { colony: true, queen: true }, include: { colony: true, queen: true },
}), }),
this.withdrawalEvents(query, canReadTreatments),
]); ]);
const events: CalendarEvent[] = [ const events: CalendarEvent[] = [
@@ -118,6 +191,7 @@ export class CalendarService {
relatedType: 'Queen', relatedType: 'Queen',
relatedId: assignment.queenId, relatedId: assignment.queenId,
})), })),
...withdrawals,
]; ];
return events.sort((a, b) => a.date.getTime() - b.date.getTime()); return events.sort((a, b) => a.date.getTime() - b.date.getTime());
@@ -14,6 +14,7 @@ describe('ColoniesService', () => {
colonyApiaryHistory: { create: jest.Mock }; colonyApiaryHistory: { create: jest.Mock };
colonyLineage: { create: jest.Mock }; colonyLineage: { create: jest.Mock };
queenColonyHistory: { updateMany: jest.Mock }; queenColonyHistory: { updateMany: jest.Mock };
suggestion: { updateMany: jest.Mock };
}; };
let prisma: { let prisma: {
colony: { colony: {
@@ -46,6 +47,7 @@ describe('ColoniesService', () => {
colonyApiaryHistory: { create: jest.fn() }, colonyApiaryHistory: { create: jest.fn() },
colonyLineage: { create: jest.fn() }, colonyLineage: { create: jest.fn() },
queenColonyHistory: { updateMany: jest.fn() }, queenColonyHistory: { updateMany: jest.fn() },
suggestion: { updateMany: jest.fn() },
}; };
prisma = { prisma = {
colony: { colony: {
@@ -255,17 +257,15 @@ describe('ColoniesService', () => {
'user-1', 'user-1',
); );
const [[updateArg]] = tx.colony.update.mock.calls as [ const updates = tx.colony.update.mock.calls as [
[ { where: { id: string }; data: Record<string, unknown> },
{ ][];
where: { id: string }; expect(updates[0][0]).toEqual({
data: { status: string; currentQueenId: string | null }; where: { id: 'colony-3' },
}, data: { currentQueenId: null },
], });
]; expect(updates[1][0].where.id).toBe('colony-3');
expect(updateArg.where.id).toBe('colony-3'); expect(updates[1][0].data.status).toBe('MERGED');
expect(updateArg.data.status).toBe('MERGED');
expect(updateArg.data.currentQueenId).toBeNull();
const [[historyCloseArg]] = tx.queenColonyHistory.updateMany.mock.calls as [ const [[historyCloseArg]] = tx.queenColonyHistory.updateMany.mock.calls as [
[ [
@@ -368,22 +368,16 @@ describe('ColoniesService', () => {
await service.dissolve('colony-1', 'user-1'); await service.dissolve('colony-1', 'user-1');
const [[dissolveUpdateArg]] = tx.colony.update.mock.calls as [ const updates = tx.colony.update.mock.calls as [
[ { where: { id: string }; data: Record<string, unknown> },
{ ][];
where: { id: string }; expect(updates[0][0]).toEqual({
data: { where: { id: 'colony-1' },
status: string; data: { currentQueenId: null },
deactivatedAt: Date; });
currentQueenId: string | 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);
],
];
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 [[historyCloseArg]] = tx.queenColonyHistory.updateMany.mock.calls as [ const [[historyCloseArg]] = tx.queenColonyHistory.updateMany.mock.calls as [
[ [
@@ -460,6 +454,38 @@ describe('ColoniesService', () => {
expect(transitionTypes()).toEqual(['ColonyStatusChanged']); 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 () => { it('writes an apiary-history entry when an edit corrects the apiary', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony); prisma.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.update.mockResolvedValue({ tx.colony.update.mockResolvedValue({
@@ -579,7 +605,12 @@ describe('ColoniesService', () => {
expect.objectContaining({ expect.objectContaining({
type: 'ColonyEnded', type: 'ColonyEnded',
aggregateId: 'colony-1', aggregateId: 'colony-1',
payload: { status: 'DISSOLVED' }, payload: {
status: 'DISSOLVED',
endedOn: berlinToday(),
releasedQueenId: null,
via: 'DISSOLVE',
},
}), }),
]); ]);
}); });
@@ -622,6 +653,10 @@ describe('ColoniesService', () => {
'MERGED', 'MERGED',
'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(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ entityId: 'colony-1', action: 'UPDATE' }), expect.objectContaining({ entityId: 'colony-1', action: 'UPDATE' }),
tx, tx,
@@ -737,4 +772,56 @@ describe('ColoniesService', () => {
const [[query]] = tx.$queryRaw.mock.calls as [[{ sql: string }]]; const [[query]] = tx.$queryRaw.mock.calls as [[{ sql: string }]];
expect(query.sql).toContain('ANY(?) ORDER BY id FOR UPDATE'); 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 { MoveColonyDto } from './dto/move-colony.dto';
import { SplitColonyDto } from './dto/split-colony.dto'; import { SplitColonyDto } from './dto/split-colony.dto';
import { MergeColonyDto } from './dto/merge-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 { lockRowsForUpdate } from '../prisma/row-lock';
import { colonyTransitionEvents, lockColony } from './colony-events'; import { colonyTransitionEvents, lockColony } from './colony-events';
import { berlinToday, dayToDate } from '../common/dates'; import { berlinToday, dayToDate } from '../common/dates';
@@ -129,6 +133,16 @@ export class ColoniesService {
if (movesApiary) { if (movesApiary) {
await this.assertApiaryActive(dto.apiaryId!); 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({ const updated = await tx.colony.update({
where: { id }, where: { id },
data: { ...dto }, data: { ...dto },
@@ -148,7 +162,11 @@ export class ColoniesService {
} }
await this.events.recordAll( await this.events.recordAll(
tx, tx,
colonyTransitionEvents(before, updated, actingUserId), colonyTransitionEvents(before, updated, actingUserId, {
endedOn: today,
releasedQueenId,
via: 'EDIT',
}),
); );
await this.auditLog.record( await this.auditLog.record(
{ {
@@ -279,6 +297,7 @@ export class ColoniesService {
'Jedes Volk darf nur einmal zur Vereinigung ausgewählt werden', 'Jedes Volk darf nur einmal zur Vereinigung ausgewählt werden',
); );
} }
const today = berlinToday();
await this.prisma.$transaction(async (tx) => { await this.prisma.$transaction(async (tx) => {
// One statement in id order, the same order bulk lockRowsForShare // One statement in id order, the same order bulk lockRowsForShare
// uses: no deadlock with a concurrent bulk write. // 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 // The survivor keeps its own queen; merged-away queens are simply
// released, not auto-transferred (which colony's queen "wins" a // released, not auto-transferred (which colony's queen "wins" a
// merge is a beekeeper decision, not one to guess here). // merge is a beekeeper decision, not one to guess here).
const releasedQueenId = await releaseQueenIn(
tx,
mergedColonyId,
dayToDate(today),
);
const mergedAway = await tx.colony.update({ const mergedAway = await tx.colony.update({
where: { id: mergedColonyId }, where: { id: mergedColonyId },
data: { data: { status: 'MERGED', deactivatedAt: new Date() },
status: 'MERGED',
deactivatedAt: new Date(),
currentQueenId: null,
},
}); });
await this.events.recordAll( await this.events.recordAll(
tx, 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({ await tx.colonyLineage.create({
data: { data: {
type: 'MERGE', type: 'MERGE',
@@ -353,27 +367,19 @@ export class ColoniesService {
// Re-dissolving would overwrite deactivatedAt and the terminal status // Re-dissolving would overwrite deactivatedAt and the terminal status
// (e.g. MERGED -> DISSOLVED), losing when and how the colony really ended. // (e.g. MERGED -> DISSOLVED), losing when and how the colony really ended.
this.assertActive(before); this.assertActive(before);
const today = berlinToday();
const releasedQueenId = await releaseQueenIn(tx, id, dayToDate(today));
const updated = await tx.colony.update({ const updated = await tx.colony.update({
where: { id }, where: { id },
data: { data: { status: 'DISSOLVED', deactivatedAt: new Date() },
status: 'DISSOLVED',
deactivatedAt: new Date(),
currentQueenId: null,
},
}); });
if (before.currentQueenId) {
await tx.queenColonyHistory.updateMany({
where: {
queenId: before.currentQueenId,
colonyId: id,
endDate: null,
},
data: { endDate: new Date() },
});
}
await this.events.recordAll( await this.events.recordAll(
tx, tx,
colonyTransitionEvents(before, updated, actingUserId), colonyTransitionEvents(before, updated, actingUserId, {
endedOn: today,
releasedQueenId,
via: 'DISSOLVE',
}),
); );
await this.auditLog.record( await this.auditLog.record(
{ {
@@ -404,9 +410,15 @@ export class ColoniesService {
where: { id }, where: { id },
data: { status: 'ACTIVE', deactivatedAt: null }, 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( await this.events.recordAll(
tx, tx,
colonyTransitionEvents(before, updated, actingUserId), colonyTransitionEvents(before, updated, actingUserId, null),
); );
await this.auditLog.record( 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', () => { describe('colonyTransitionEvents', () => {
it('records nothing when neither status nor healthStatus changed', () => { it('records nothing when neither status nor healthStatus changed', () => {
expect( expect(
colonyTransitionEvents(colony(), colony({ notes: 'neu' }), 'u1'), colonyTransitionEvents(colony(), colony({ notes: 'neu' }), 'u1', null),
).toEqual([]); ).toEqual([]);
}); });
@@ -22,6 +22,7 @@ describe('colonyTransitionEvents', () => {
colony(), colony(),
colony({ healthStatus: 'CRITICAL' }), colony({ healthStatus: 'CRITICAL' }),
'u1', 'u1',
null,
); );
expect(events).toEqual([ expect(events).toEqual([
{ {
@@ -38,18 +39,24 @@ describe('colonyTransitionEvents', () => {
}); });
it.each(['DISSOLVED', 'SOLD', 'DECEASED', 'MERGED'] as const)( 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) => { (status) => {
const events = colonyTransitionEvents( const events = colonyTransitionEvents(
colony({ status: 'WEAK' }), colony({ status: 'WEAK' }),
colony({ status }), colony({ status }),
null, null,
{ endedOn: '2027-03-12', releasedQueenId: 'q1', via: 'WINTER_CHECK' },
); );
expect(events.map((event) => event.type)).toEqual([ expect(events.map((event) => event.type)).toEqual([
'ColonyStatusChanged', 'ColonyStatusChanged',
'ColonyEnded', '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: 'DISSOLVED' }),
colony({ status: 'DECEASED' }), colony({ status: 'DECEASED' }),
'u1', 'u1',
null,
).map((event) => event.type), ).map((event) => event.type),
).toEqual(['ColonyStatusChanged']); ).toEqual(['ColonyStatusChanged']);
expect( expect(
@@ -66,7 +74,19 @@ describe('colonyTransitionEvents', () => {
colony({ status: 'DISSOLVED' }), colony({ status: 'DISSOLVED' }),
colony({ status: 'ACTIVE' }), colony({ status: 'ACTIVE' }),
'u1', 'u1',
null,
).map((event) => event.type), ).map((event) => event.type),
).toEqual(['ColonyStatusChanged']); ).toEqual(['ColonyStatusChanged']);
}); });
it('refuses to build ColonyEnded without the context of the end', () => {
expect(() =>
colonyTransitionEvents(
colony({ status: 'ACTIVE' }),
colony({ status: 'SOLD' }),
'u1',
null,
),
).toThrow('ColonyEnded needs');
});
}); });
+14 -2
View File
@@ -5,6 +5,7 @@ import {
AnyNewDomainEvent, AnyNewDomainEvent,
ColonyStateSnapshot, ColonyStateSnapshot,
} from '../domain-events/domain-event-types'; } from '../domain-events/domain-event-types';
import type { ColonyEnd } from './colony-lifecycle';
import { isLivingColonyStatus } from './colony-status'; import { isLivingColonyStatus } from './colony-status';
type ColonyState = Pick<Colony, 'status' | 'healthStatus'>; type ColonyState = Pick<Colony, 'status' | 'healthStatus'>;
@@ -18,12 +19,15 @@ function snapshotOf(colony: ColonyState): ColonyStateSnapshot {
* ColonyStatusChanged as soon as status or healthStatus differs, plus * ColonyStatusChanged as soon as status or healthStatus differs, plus
* ColonyEnded when a living colony became an ended one. Empty when nothing * ColonyEnded when a living colony became an ended one. Empty when nothing
* the events care about changed. The caller records them in the same * 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( export function colonyTransitionEvents(
before: Pick<Colony, 'id' | 'status' | 'healthStatus'>, before: Pick<Colony, 'id' | 'status' | 'healthStatus'>,
after: Pick<Colony, 'id' | 'status' | 'healthStatus'>, after: Pick<Colony, 'id' | 'status' | 'healthStatus'>,
actorUserId: string | null, actorUserId: string | null,
ended: ColonyEnd | null,
): AnyNewDomainEvent<'ColonyStatusChanged' | 'ColonyEnded'>[] { ): AnyNewDomainEvent<'ColonyStatusChanged' | 'ColonyEnded'>[] {
if ( if (
before.status === after.status && before.status === after.status &&
@@ -44,12 +48,20 @@ export function colonyTransitionEvents(
isLivingColonyStatus(before.status) && isLivingColonyStatus(before.status) &&
!isLivingColonyStatus(after.status) !isLivingColonyStatus(after.status)
) { ) {
if (ended === null) {
throw new Error('ColonyEnded needs the context of the end');
}
events.push({ events.push({
type: 'ColonyEnded', type: 'ColonyEnded',
aggregateType: 'Colony', aggregateType: 'Colony',
aggregateId: after.id, aggregateId: after.id,
actorUserId, actorUserId,
payload: { status: after.status }, payload: {
status: after.status,
endedOn: ended.endedOn,
releasedQueenId: ended.releasedQueenId,
via: ended.via,
},
}); });
} }
return events; 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', () => { it('runAfterCommit is only used by the remaining legacy paths', () => {
expect(filesCalling('runAfterCommit(')).toEqual([ expect(filesCalling('runAfterCommit(')).toEqual([
'src/automation/automation.service.ts', 'src/automation/automation.service.ts',
'src/calendar-sync/calendar-sync.service.ts',
'src/webhooks/webhooks.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[] }; HarvestRecorded: { apiaryId: string; colonyIds: string[] };
HarvestCorrected: { apiaryId: string; colonyIds: string[] }; HarvestCorrected: { apiaryId: string; colonyIds: string[] };
ColonyStatusChanged: { from: ColonyStateSnapshot; to: ColonyStateSnapshot }; 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 }; BatchBottled: { honeyBatchId: string };
BottlingCorrected: { honeyBatchId: string; removed: boolean }; BottlingCorrected: { honeyBatchId: string; removed: boolean };
StockChanged: { StockChanged: {
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { WithdrawalModule } from '../withdrawal/withdrawal.module';
import { HarvestsController } from './harvests.controller'; import { HarvestsController } from './harvests.controller';
import { HarvestsService } from './harvests.service'; import { HarvestsService } from './harvests.service';
@Module({ @Module({
imports: [WithdrawalModule],
controllers: [HarvestsController], controllers: [HarvestsController],
providers: [HarvestsService], providers: [HarvestsService],
exports: [HarvestsService], exports: [HarvestsService],
@@ -3,6 +3,8 @@ import { HarvestsService } from './harvests.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
import { DomainEventsService } from '../domain-events/domain-events.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', () => { describe('HarvestsService', () => {
let service: HarvestsService; let service: HarvestsService;
@@ -13,6 +15,10 @@ describe('HarvestsService', () => {
}; };
let auditLog: { record: jest.Mock }; let auditLog: { record: jest.Mock };
let events: { record: jest.Mock }; let events: { record: jest.Mock };
let withdrawalCheck: {
conflictsForHarvests: jest.Mock;
warningsFor: jest.Mock;
};
let tx: { let tx: {
harvest: { harvest: {
create: jest.Mock; create: jest.Mock;
@@ -55,10 +61,15 @@ describe('HarvestsService', () => {
}; };
auditLog = { record: jest.fn() }; auditLog = { record: jest.fn() };
events = { record: jest.fn() }; events = { record: jest.fn() };
withdrawalCheck = {
conflictsForHarvests: jest.fn().mockResolvedValue([]),
warningsFor: jest.fn().mockResolvedValue([]),
};
service = new HarvestsService( service = new HarvestsService(
prisma as unknown as PrismaService, prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService, auditLog as unknown as AuditLogService,
events as unknown as DomainEventsService, events as unknown as DomainEventsService,
withdrawalCheck as unknown as WithdrawalCheckService,
); );
}); });
@@ -117,7 +128,7 @@ describe('HarvestsService', () => {
'user-1', 'user-1',
); );
expect(result).toEqual(updated); expect(result).toEqual({ ...updated, warnings: [] });
expect(tx.harvestColony.deleteMany).not.toHaveBeenCalled(); expect(tx.harvestColony.deleteMany).not.toHaveBeenCalled();
expect(auditLog.record).toHaveBeenCalledWith( expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
@@ -236,4 +247,72 @@ describe('HarvestsService', () => {
).rejects.toBeInstanceOf(BadRequestException); ).rejects.toBeInstanceOf(BadRequestException);
expect(prisma.$transaction).not.toHaveBeenCalled(); 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 { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
import { DomainEventsService } from '../domain-events/domain-events.service'; import { DomainEventsService } from '../domain-events/domain-events.service';
import { DbClient } from '../prisma/db-client';
import { lockRowForUpdate } from '../prisma/row-lock'; 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 { CreateHarvestDto } from './dto/create-harvest.dto';
import { UpdateHarvestDto } from './dto/update-harvest.dto'; import { UpdateHarvestDto } from './dto/update-harvest.dto';
@@ -20,8 +23,27 @@ export class HarvestsService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService, private readonly auditLog: AuditLogService,
private readonly events: DomainEventsService, 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() { async findAll() {
return this.prisma.harvest.findMany({ return this.prisma.harvest.findMany({
include: harvestInclude, include: harvestInclude,
@@ -107,10 +129,11 @@ export class HarvestsService {
payload: { apiaryId: harvest.apiaryId, colonyIds: dto.colonyIds }, payload: { apiaryId: harvest.apiaryId, colonyIds: dto.colonyIds },
actorUserId: actingUserId, actorUserId: actingUserId,
}); });
return tx.harvest.findUniqueOrThrow({ const created = await tx.harvest.findUniqueOrThrow({
where: { id: harvest.id }, where: { id: harvest.id },
include: harvestInclude, include: harvestInclude,
}); });
return this.withWithdrawalWarnings(tx, created);
}); });
} }
@@ -177,7 +200,7 @@ export class HarvestsService {
}, },
actorUserId: actingUserId, 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 * Inspections are written together with their audit entry and domain event
* in one transaction: InspectionRecorded, InspectionCorrected. Automation * 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 * entry (calendar-sync.inspection) and the inspection.created webhook
* (webhooks.forward) follow from the event, never from a direct call - so * (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. * 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 * A correction proposes no new rule suggestion (an open one follows the
* backbone. InspectionCorrected moves the calendar entry when the date or * corrected values via automation.inspection-rules). InspectionCorrected moves the calendar entry when the date or
* colony changed instead of leaving the old one behind. * colony changed instead of leaving the old one behind.
*/ */
async update( async update(
@@ -644,6 +644,54 @@ describe('daily evaluation rules', () => {
...unknown[], ...unknown[],
]; ];
expect(strings.join('?')).toContain('"withdrawalPeriodDays" IS NOT NULL'); 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. */ /** Treatment: the day after which honey may be harvested again. */
const withdrawalEnded: DailyRule<'WithdrawalEnded'> = { const withdrawalEnded: DailyRule<'WithdrawalEnded'> = {
type: 'WithdrawalEnded', type: 'WithdrawalEnded',
async occurrences(db, today) { async occurrences(db, _today, { day: runDay }) {
// Prisma cannot compare column arithmetic; the days are compared as // 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[]>` const treatments = await db.$queryRaw<TreatmentRow[]>`
SELECT "id", "colonyId", "date", "endDate", "withdrawalPeriodDays" SELECT "id", "colonyId", "date", "endDate", "withdrawalPeriodDays"
FROM "Treatment" FROM "Treatment"
WHERE "withdrawalPeriodDays" IS NOT NULL WHERE "withdrawalPeriodDays" IS NOT NULL
AND (COALESCE("endDate", "date") + "withdrawalPeriodDays" * interval '1 day')::date 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) => { return treatments.flatMap((treatment) => {
const day = withdrawalEndDay(treatment); const day = withdrawalEndDay(treatment);
if (day === null) { if (day === null) {
@@ -373,4 +373,73 @@ describe('QueensService', () => {
expect(lineage.descendants[0].code).toBe('Q-D'); expect(lineage.descendants[0].code).toBe('Q-D');
expect(lineage.descendants[0].daughters).toEqual([]); expect(lineage.descendants[0].daughters).toEqual([]);
}); });
describe('updateStatusIn', () => {
it('locks the queen, reads it afterwards, sets the status and audits in the same transaction', async () => {
tx.queen.findUnique.mockResolvedValue(queen);
tx.queen.update.mockResolvedValue({ ...queen, status: 'DECEASED' });
const result = await service.updateStatusIn(
tx as never,
'queen-1',
'DECEASED',
'user-1',
);
expect(result.status).toBe('DECEASED');
expect(tx.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan(
tx.queen.findUnique.mock.invocationCallOrder[0],
);
expect(tx.queen.update).toHaveBeenCalledWith({
where: { id: 'queen-1' },
data: { status: 'DECEASED' },
});
expect(auditLog.record).toHaveBeenCalledWith(
{
entityType: 'Queen',
entityId: 'queen-1',
action: 'UPDATE',
userId: 'user-1',
beforeData: queen,
afterData: { ...queen, status: 'DECEASED' },
},
tx,
);
expect(prisma.$transaction).not.toHaveBeenCalled();
});
it('answers 404 for an unknown queen', async () => {
tx.queen.findUnique.mockResolvedValue(null);
await expect(
service.updateStatusIn(tx as never, 'gone', 'SOLD', 'user-1'),
).rejects.toBeInstanceOf(NotFoundException);
expect(tx.queen.update).not.toHaveBeenCalled();
});
it('answers 409 when the queen still sits in a colony and leaves the status unavailable', async () => {
tx.queen.findUnique.mockResolvedValue(queen);
tx.colony.findUnique.mockResolvedValue({ id: 'colony-9' });
await expect(
service.updateStatusIn(tx as never, 'queen-1', 'DECEASED', 'user-1'),
).rejects.toBeInstanceOf(ConflictException);
expect(tx.queen.update).not.toHaveBeenCalled();
});
it('changes nothing and audits nothing when the status is the same', async () => {
tx.queen.findUnique.mockResolvedValue(queen);
const result = await service.updateStatusIn(
tx as never,
'queen-1',
'ACTIVE',
'user-1',
);
expect(result).toBe(queen);
expect(tx.queen.update).not.toHaveBeenCalled();
expect(auditLog.record).not.toHaveBeenCalled();
});
});
}); });
+56 -1
View File
@@ -9,8 +9,9 @@ import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service'; import { WebhooksService } from '../webhooks/webhooks.service';
import { DomainEventsService } from '../domain-events/domain-events.service'; import { DomainEventsService } from '../domain-events/domain-events.service';
import { utcDay } from '../domain-events/domain-event-types'; import { utcDay } from '../domain-events/domain-event-types';
import { assertTransactionClient } from '../prisma/db-client';
import { lockRowForUpdate, lockRowsForUpdate } from '../prisma/row-lock'; 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 { CreateQueenDto } from './dto/create-queen.dto';
import { UpdateQueenDto } from './dto/update-queen.dto'; import { UpdateQueenDto } from './dto/update-queen.dto';
import { AssignColonyDto } from './dto/assign-colony.dto'; import { AssignColonyDto } from './dto/assign-colony.dto';
@@ -155,6 +156,60 @@ export class QueensService {
return queen; 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( async assignColony(
id: string, id: string,
dto: AssignColonyDto, 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 { ChainEventHandlers } from './chains/chains.handlers';
import { SuggestionViewsService } from './suggestion-views.service'; import { SuggestionViewsService } from './suggestion-views.service';
import { SuggestionsController } from './suggestions.controller'; import { SuggestionsController } from './suggestions.controller';
import { SuggestionEventHandlers } from './suggestions.handlers';
import { SuggestionsService } from './suggestions.service'; import { SuggestionsService } from './suggestions.service';
/** /**
@@ -14,7 +15,12 @@ import { SuggestionsService } from './suggestions.service';
@Module({ @Module({
imports: [TasksModule, AuditModule, NotificationsModule], imports: [TasksModule, AuditModule, NotificationsModule],
controllers: [SuggestionsController], controllers: [SuggestionsController],
providers: [SuggestionsService, SuggestionViewsService, ChainEventHandlers], providers: [
SuggestionsService,
SuggestionViewsService,
ChainEventHandlers,
SuggestionEventHandlers,
],
exports: [SuggestionsService, SuggestionViewsService], exports: [SuggestionsService, SuggestionViewsService],
}) })
export class SuggestionsModule {} export class SuggestionsModule {}
@@ -1,4 +1,68 @@
import type { SuggestionTextRegister } from '../suggestion-texts'; 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. */ /** 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', () => { describe('TasksService', () => {
let service: TasksService; let service: TasksService;
let tx: { let tx: {
$queryRaw: jest.Mock;
task: { task: {
create: jest.Mock; create: jest.Mock;
update: jest.Mock; update: jest.Mock;
@@ -51,6 +52,7 @@ describe('TasksService', () => {
beforeEach(() => { beforeEach(() => {
tx = { tx = {
$queryRaw: jest.fn().mockResolvedValue([{ id: 'task-1' }]),
task: { task: {
// The stored row: defaults filled in like the database does. // The stored row: defaults filled in like the database does.
create: jest.fn(({ data }: { data: Record<string, unknown> }) => 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', () => { describe('generateFromTemplates', () => {
const now = new Date('2026-05-20T08:00:00.000Z'); const now = new Date('2026-05-20T08:00:00.000Z');
+82 -24
View File
@@ -1,6 +1,7 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { assertTransactionClient, DbClient } from '../prisma/db-client'; import { assertTransactionClient, DbClient } from '../prisma/db-client';
import { lockRowForUpdate } from '../prisma/row-lock';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
import { DomainEventsService } from '../domain-events/domain-events.service'; import { DomainEventsService } from '../domain-events/domain-events.service';
import { utcDay } from '../domain-events/domain-event-types'; import { utcDay } from '../domain-events/domain-event-types';
@@ -212,32 +213,89 @@ export class TasksService {
} }
async complete(id: string, actingUserId: string): Promise<Task> { async complete(id: string, actingUserId: string): Promise<Task> {
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction((tx) =>
const before = await this.findIn(tx, id); this.completeIn(tx, id, actingUserId),
// 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; /**
} * "Erledigt" on the caller's transaction: locks the task, reads it
const completed = await this.markDoneIn(tx, id); * afterwards, and writes audit entry and TaskCompleted only on the
const task = await tx.task.findUniqueOrThrow({ where: { id } }); * transition (a task that is DONE already comes back unchanged). The one
if (!completed) { * path of complete() and of the clean-up of an ended colony.
return task; */
} async completeIn(
await this.auditLog.record( tx: Prisma.TransactionClient,
{ id: string,
entityType: 'Task', actingUserId: string,
entityId: id, ): Promise<Task> {
action: 'UPDATE', assertTransactionClient(tx, 'TasksService.completeIn');
userId: actingUserId, await lockRowForUpdate(tx, 'Task', id);
beforeData: before, const before = await this.findIn(tx, id);
afterData: task, // A second tap on "Erledigt" (or a retried request) must not move the
}, // completion time or announce the completion a second time.
tx, if (before.status === 'DONE') {
); return before;
await this.recordCompleted(tx, task, actingUserId); }
const completed = await this.markDoneIn(tx, id);
const task = await tx.task.findUniqueOrThrow({ where: { id } });
if (!completed) {
return task; 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; $queryRaw: jest.Mock;
winterCheck: { create: jest.Mock }; winterCheck: { create: jest.Mock };
colony: { update: jest.Mock; findUnique: jest.Mock }; colony: { update: jest.Mock; findUnique: jest.Mock };
queenColonyHistory: { updateMany: jest.Mock };
auditLog: { create: jest.Mock }; auditLog: { create: jest.Mock };
}; };
let prisma: { let prisma: {
@@ -50,6 +51,7 @@ describe('WinterChecksService', () => {
.fn() .fn()
.mockResolvedValue({ ...livingColony, status: 'DECEASED' }), .mockResolvedValue({ ...livingColony, status: 'DECEASED' }),
}, },
queenColonyHistory: { updateMany: jest.fn() },
auditLog: { create: jest.fn() }, auditLog: { create: jest.fn() },
}; };
prisma = { prisma = {
@@ -112,11 +114,51 @@ describe('WinterChecksService', () => {
expect.objectContaining({ expect.objectContaining({
type: 'ColonyEnded', type: 'ColonyEnded',
aggregateId: 'c1', 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 () => { it('records no colony event when the colony is not to be marked deceased', async () => {
await service.create({ ...lostCheck, markColonyDeceased: false }, 'u1'); 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 { Colony, Prisma, WinterCheck } from '../../generated/prisma';
import { isLivingColonyStatus } from '../colonies/colony-status'; import { isLivingColonyStatus } from '../colonies/colony-status';
import { colonyTransitionEvents, lockColony } from '../colonies/colony-events'; 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 { CreateWinterCheckDto } from './dto/create-winter-check.dto';
import { UpdateWinterCheckDto } from './dto/update-winter-check.dto'; import { UpdateWinterCheckDto } from './dto/update-winter-check.dto';
import { WinterCheckQueryDto } from './dto/winter-check-query.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. // must come from the row as it is now, not from an earlier read.
const colony = await lockColony(tx, colonyId); const colony = await lockColony(tx, colonyId);
if (!isLivingColonyStatus(colony.status)) return; 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({ const after = await tx.colony.update({
where: { id: colony.id }, where: { id: colony.id },
data: { status: 'DECEASED', deactivatedAt: check.date }, data: { status: 'DECEASED', deactivatedAt: check.date },
}); });
await this.events.recordAll( await this.events.recordAll(
tx, tx,
colonyTransitionEvents(colony, after, actingUserId), colonyTransitionEvents(colony, after, actingUserId, {
endedOn: utcDay(check.date),
releasedQueenId,
via: 'WINTER_CHECK',
}),
); );
await this.auditLog.record( 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 { SuggestionsModule } from '../suggestions/suggestions.module';
import { TasksModule } from '../tasks/tasks.module'; import { TasksModule } from '../tasks/tasks.module';
import { WebhooksModule } from '../webhooks/webhooks.module'; import { WebhooksModule } from '../webhooks/webhooks.module';
import { WithdrawalModule } from '../withdrawal/withdrawal.module';
import { WorkerHealthServer } from './worker-health-server.service'; import { WorkerHealthServer } from './worker-health-server.service';
import { WorkerRuntimeModule } from './worker-runtime.module'; import { WorkerRuntimeModule } from './worker-runtime.module';
@@ -35,6 +36,7 @@ import { WorkerRuntimeModule } from './worker-runtime.module';
AutomationModule, AutomationModule,
SuggestionsModule, SuggestionsModule,
AutopilotModule, AutopilotModule,
WithdrawalModule,
WorkerRuntimeModule, WorkerRuntimeModule,
], ],
providers: [WorkerHealthServer], providers: [WorkerHealthServer],
+114 -1
View File
@@ -89,6 +89,9 @@ describe('Apiaries (e2e)', () => {
await prisma.task.deleteMany({ await prisma.task.deleteMany({
where: { title: { startsWith: 'field-e2e-' } }, where: { title: { startsWith: 'field-e2e-' } },
}); });
await prisma.suggestion.deleteMany({
where: { colony: { apiaryId: fieldApiaryId } },
});
await prisma.colony.deleteMany({ where: { apiaryId: fieldApiaryId } }); await prisma.colony.deleteMany({ where: { apiaryId: fieldApiaryId } });
await prisma.apiary.delete({ where: { id: fieldApiaryId } }); await prisma.apiary.delete({ where: { id: fieldApiaryId } });
} }
@@ -210,7 +213,14 @@ describe('Apiaries (e2e)', () => {
describe('apiary field endpoints', () => { describe('apiary field endpoints', () => {
interface OverviewBody { interface OverviewBody {
apiaryId: string; apiaryId: string;
rows: { code: string }[]; rows: {
code: string;
inspectionDueOn: string | null;
segment: string;
nextStepTitle: string | null;
openWarnings: number;
openCritical: number;
}[];
} }
interface PackBody { interface PackBody {
version: number; version: number;
@@ -285,6 +295,109 @@ describe('Apiaries (e2e)', () => {
]); ]);
}); });
it('colony-overview carries inspectionDueOn, segment and nextStepTitle; warnings come from suggestions', async () => {
const permissions = [
'colonies:read',
'tasks:read',
'inspections:read',
'varroa:read',
];
const role = await prisma.role.create({
data: {
key: `FIELD_OVERVIEW_${Date.now()}`,
name: 'Feld-Übersicht',
permissions: {
create: permissions.map((key) => ({
permission: { connect: { key } },
})),
},
},
});
const email = `apiaries-e2e-overview-${Date.now()}@example.com`;
const user = await prisma.user.create({
data: {
email,
passwordHash: await bcrypt.hash(password, 12),
name: 'Overview',
roleId: role.id,
},
});
const colony = await prisma.colony.findFirstOrThrow({
where: { apiaryId: fieldApiaryId, code: { startsWith: 'V-10-' } },
});
const base = {
ruleKey: 'VARROA_THRESHOLD' as const,
source: 'RULE' as const,
mode: 'PLAN' as const,
intent: 'VARROA_TREAT' as const,
relatedType: 'COLONY' as const,
relatedId: colony.id,
colonyId: colony.id,
reasonCode: 'FIELD_E2E',
evidence: { colonyId: colony.id },
};
try {
await prisma.suggestion.createMany({
data: [
{ ...base, priority: 'HIGH', requiredPermission: 'varroa:read' },
{
...base,
intent: 'HONEY_SUPER',
priority: 'URGENT',
requiredPermission: 'inspections:read',
},
{
...base,
intent: 'FEED',
priority: 'URGENT',
requiredPermission: 'feedings:read',
},
{ ...base, intent: 'INSPECT', priority: 'NORMAL' },
],
});
const token = (await login(app, email, password)).accessToken;
const response = await request(app.getHttpServer())
.get(`/api/v1/apiaries/${fieldApiaryId}/colony-overview`)
.set('Authorization', `Bearer ${token}`)
.expect(200);
const rows = (response.body as OverviewBody).rows;
const row = rows.find((item) => item.code.startsWith('V-10-'));
// HIGH (varroa:read) and URGENT (inspections:read) are visible, the
// URGENT one that needs feedings:read is not.
expect(row).toMatchObject({ openWarnings: 1, openCritical: 1 });
expect(row?.nextStepTitle).toEqual(expect.any(String));
expect(row?.segment).toEqual(expect.any(String));
expect(row).toHaveProperty('inspectionDueOn');
if (row?.segment === 'WINTERRUHE') {
expect(row.inspectionDueOn).toBeNull();
} else {
expect(row?.inspectionDueOn).toMatch(/^\d{4}-\d{2}-\d{2}$/);
}
const quiet = rows.find((item) => item.code.startsWith('V-2-'));
expect(quiet).toMatchObject({
openWarnings: 0,
openCritical: 0,
nextStepTitle: null,
});
const pack = await request(app.getHttpServer())
.get(`/api/v1/apiaries/${fieldApiaryId}/offline-pack`)
.set('Authorization', `Bearer ${token}`)
.expect(200);
const steps = (pack.body as { nextSteps?: Record<string, unknown[]> })
.nextSteps;
expect(steps?.[colony.id]?.length).toBeGreaterThan(0);
expect(steps?.[colony.id]?.length).toBeLessThanOrEqual(3);
} finally {
await prisma.suggestion.deleteMany({ where: { colonyId: colony.id } });
await prisma.refreshToken.deleteMany({ where: { userId: user.id } });
await prisma.user.delete({ where: { id: user.id } });
await prisma.role.delete({ where: { id: role.id } });
}
});
it('offline-pack contains the open tasks of the apiary and its colonies', async () => { it('offline-pack contains the open tasks of the apiary and its colonies', async () => {
const response = await request(app.getHttpServer()) const response = await request(app.getHttpServer())
.get(`/api/v1/apiaries/${fieldApiaryId}/offline-pack`) .get(`/api/v1/apiaries/${fieldApiaryId}/offline-pack`)
+210 -9
View File
@@ -29,6 +29,7 @@ describe('Automation rules (e2e)', () => {
let staffToken: string; let staffToken: string;
let apiaryId: string; let apiaryId: string;
let colonyId: string; let colonyId: string;
const followColonyIds: string[] = [];
let varroaRuleId: string; let varroaRuleId: string;
let swarmRuleId: string; let swarmRuleId: string;
@@ -112,13 +113,17 @@ describe('Automation rules (e2e)', () => {
// Rule suggestions and the rules' own entries are written without a // Rule suggestions and the rules' own entries are written without a
// user - they are not covered by the userId cleanup below. Deliveries go // user - they are not covered by the userId cleanup below. Deliveries go
// with their event (ON DELETE CASCADE). // with their event (ON DELETE CASCADE).
const colonyIds = [colonyId, ...followColonyIds];
const suggestionIds = ( const suggestionIds = (
await prisma.suggestion.findMany({ await prisma.suggestion.findMany({
where: { colonyId }, where: { colonyId },
select: { id: true }, select: { id: true },
}) })
).map((suggestion) => suggestion.id); ).map((suggestion) => suggestion.id);
const byColony = { where: { colonyId }, select: { id: true } }; const byColony = {
where: { colonyId: { in: colonyIds } },
select: { id: true },
};
const recordIds = [ const recordIds = [
...(await prisma.inspection.findMany(byColony)), ...(await prisma.inspection.findMany(byColony)),
...(await prisma.varroaMeasurement.findMany(byColony)), ...(await prisma.varroaMeasurement.findMany(byColony)),
@@ -127,10 +132,10 @@ describe('Automation rules (e2e)', () => {
where: { where: {
OR: [ OR: [
{ entityType: 'Suggestion', entityId: { in: suggestionIds } }, { entityType: 'Suggestion', entityId: { in: suggestionIds } },
{ ...colonyIds.map((id) => ({
entityType: 'AutomationRule', entityType: 'AutomationRule',
afterData: { path: ['relatedId'], equals: colonyId }, afterData: { path: ['relatedId'], equals: id },
}, })),
], ],
}, },
}); });
@@ -143,11 +148,19 @@ describe('Automation rules (e2e)', () => {
await prisma.notification.deleteMany({ await prisma.notification.deleteMany({
where: { relatedType: 'Suggestion', relatedId: { in: suggestionIds } }, where: { relatedType: 'Suggestion', relatedId: { in: suggestionIds } },
}); });
await prisma.suggestion.deleteMany({ where: { colonyId } }); await prisma.suggestion.deleteMany({
await prisma.varroaMeasurement.deleteMany({ where: { colonyId } }); where: { colonyId: { in: colonyIds } },
await prisma.swarmControlCheck.deleteMany({ where: { colonyId } }); });
await prisma.inspection.deleteMany({ where: { colonyId } }); await prisma.varroaMeasurement.deleteMany({
await prisma.colony.deleteMany({ where: { id: colonyId } }); where: { colonyId: { in: colonyIds } },
});
await prisma.swarmControlCheck.deleteMany({
where: { colonyId: { in: colonyIds } },
});
await prisma.inspection.deleteMany({
where: { colonyId: { in: colonyIds } },
});
await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } });
await prisma.apiary.deleteMany({ where: { id: apiaryId } }); await prisma.apiary.deleteMany({ where: { id: apiaryId } });
await prisma.refreshToken.deleteMany({ await prisma.refreshToken.deleteMany({
where: { userId: { in: [adminId, staffId] } }, where: { userId: { in: [adminId, staffId] } },
@@ -448,6 +461,194 @@ describe('Automation rules (e2e)', () => {
expect(resolved).toMatchObject({ status: 'RESOLVED', dedupeKey: null }); expect(resolved).toMatchObject({ status: 'RESOLVED', dedupeKey: null });
}); });
describe('an open inspection suggestion follows newer inspections', () => {
const ANCHOR_DAY = '2026-05-05T00:00:00.000Z';
async function freshColony(): Promise<string> {
const colony = await prisma.colony.create({
data: {
code: `AUTO-F-${Date.now()}-${followColonyIds.length}`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
},
});
followColonyIds.push(colony.id);
return colony.id;
}
async function inspectOn(
forColony: string,
date: string,
body: object,
): Promise<string> {
const response = await request(app.getHttpServer())
.post('/api/v1/inspections')
.set('Authorization', `Bearer ${staffToken}`)
.send({ colonyId: forColony, date, ...body })
.expect(201);
const id = (response.body as { id: string }).id;
await drainDomainEvents(app, [id]);
return id;
}
const queenlessOf = (forColony: string) =>
prisma.suggestion.findMany({
where: { colonyId: forColony, ruleKey: 'QUEENLESS_NO_EGGS' },
});
const queenless = { queenSeen: false, eggsPresent: false };
const clean = { queenSeen: true, eggsPresent: true };
it('a newer clean inspection resolves it', async () => {
const forColony = await freshColony();
await inspectOn(forColony, ANCHOR_DAY, queenless);
expect(await queenlessOf(forColony)).toMatchObject([{ status: 'OPEN' }]);
await inspectOn(forColony, '2026-05-12T00:00:00.000Z', clean);
expect(await queenlessOf(forColony)).toMatchObject([
{ status: 'RESOLVED', dedupeKey: null },
]);
});
it('a newer inspection with the finding keeps one OPEN suggestion with updated evidence', async () => {
const forColony = await freshColony();
await inspectOn(forColony, ANCHOR_DAY, queenless);
const newerId = await inspectOn(
forColony,
'2026-05-12T00:00:00.000Z',
queenless,
);
const suggestions = await queenlessOf(forColony);
expect(suggestions).toHaveLength(1);
expect(suggestions[0]).toMatchObject({
status: 'OPEN',
evidence: { inspectionId: newerId, inspectedOn: '2026-05-12' },
});
});
it('a backdated inspection changes nothing', async () => {
const forColony = await freshColony();
await inspectOn(forColony, ANCHOR_DAY, queenless);
const [before] = await queenlessOf(forColony);
await inspectOn(forColony, '2026-04-20T00:00:00.000Z', clean);
const [after] = await queenlessOf(forColony);
expect(after).toMatchObject({
status: 'OPEN',
evidence: before.evidence,
});
});
it('correcting the inspection of the finding keeps it OPEN while the finding remains', async () => {
const forColony = await freshColony();
const id = await inspectOn(forColony, ANCHOR_DAY, queenless);
await request(app.getHttpServer())
.patch(`/api/v1/inspections/${id}`)
.set('Authorization', `Bearer ${staffToken}`)
.send({ queenSeen: false, eggsPresent: false, notes: 'korrigiert' })
.expect(200);
await drainDomainEvents(app, [id]);
expect(await queenlessOf(forColony)).toMatchObject([
{ status: 'OPEN', evidence: { inspectionId: id } },
]);
});
it('correcting an older inspection than the latest changes nothing', async () => {
const forColony = await freshColony();
const olderId = await inspectOn(forColony, ANCHOR_DAY, queenless);
await inspectOn(forColony, '2026-05-12T00:00:00.000Z', queenless);
const [before] = await queenlessOf(forColony);
await request(app.getHttpServer())
.patch(`/api/v1/inspections/${olderId}`)
.set('Authorization', `Bearer ${staffToken}`)
.send({ queenSeen: true, eggsPresent: true })
.expect(200);
await drainDomainEvents(app, [olderId]);
const [after] = await queenlessOf(forColony);
expect(after).toMatchObject({
status: 'OPEN',
evidence: before.evidence,
});
});
it('a late retry of an older inspection after a newer clean one proposes nothing', async () => {
const forColony = await freshColony();
const olderId = await inspectOn(forColony, ANCHOR_DAY, queenless);
await inspectOn(forColony, '2026-05-12T00:00:00.000Z', clean);
expect(await queenlessOf(forColony)).toMatchObject([
{ status: 'RESOLVED' },
]);
// The delivery of the older inspection runs again.
await prisma.domainEvent.updateMany({
where: { aggregateId: olderId },
data: { status: 'PENDING', processedAt: null, claimedAt: null },
});
await prisma.domainEventDelivery.updateMany({
where: {
handler: 'automation.inspection-rules',
event: { aggregateId: olderId },
},
data: { status: 'PENDING', attempts: 0, completedAt: null },
});
await drainDomainEvents(app, [olderId]);
expect(await queenlessOf(forColony)).toMatchObject([
{ status: 'RESOLVED' },
]);
});
it('the feed rule follows newer inspections the same way', async () => {
const forColony = await freshColony();
await inspectOn(forColony, ANCHOR_DAY, {
...clean,
feedStatus: 'CRITICAL',
});
const feedOf = () =>
prisma.suggestion.findMany({
where: { colonyId: forColony, ruleKey: 'FEED_STATUS_CRITICAL' },
});
expect(await feedOf()).toMatchObject([{ status: 'OPEN' }]);
const stillId = await inspectOn(forColony, '2026-05-12T00:00:00.000Z', {
...clean,
feedStatus: 'CRITICAL',
});
expect(await feedOf()).toMatchObject([
{ status: 'OPEN', evidence: { inspectionId: stillId } },
]);
await inspectOn(forColony, '2026-05-19T00:00:00.000Z', {
...clean,
feedStatus: 'GOOD',
});
expect(await feedOf()).toMatchObject([{ status: 'RESOLVED' }]);
});
it('correcting the inspection of the finding resolves it', async () => {
const forColony = await freshColony();
const id = await inspectOn(forColony, ANCHOR_DAY, queenless);
await request(app.getHttpServer())
.patch(`/api/v1/inspections/${id}`)
.set('Authorization', `Bearer ${staffToken}`)
.send({ queenSeen: true, eggsPresent: true })
.expect(200);
await drainDomainEvents(app, [id]);
expect(await queenlessOf(forColony)).toMatchObject([
{ status: 'RESOLVED' },
]);
});
});
it('lets an admin clear a rule config, which falls back to its defaults', async () => { it('lets an admin clear a rule config, which falls back to its defaults', async () => {
const before = await prisma.automationRule.findUniqueOrThrow({ const before = await prisma.automationRule.findUniqueOrThrow({
where: { id: varroaRuleId }, where: { id: varroaRuleId },
+5 -1
View File
@@ -15,6 +15,7 @@ interface BottlingBatchBody {
id: string; id: string;
totalAmount: number; totalAmount: number;
lotNumber: string; lotNumber: string;
warnings: unknown[];
} }
interface RecordedEvent { interface RecordedEvent {
@@ -187,6 +188,8 @@ describe('Bottling (e2e)', () => {
const bottlingBatch = response.body as BottlingBatchBody; const bottlingBatch = response.body as BottlingBatchBody;
bottlingBatchId = bottlingBatch.id; bottlingBatchId = bottlingBatch.id;
expect(bottlingBatch.totalAmount).toBe(25); expect(bottlingBatch.totalAmount).toBe(25);
// The harvest behind the honey batch has no withdrawal window.
expect(bottlingBatch.warnings).toEqual([]);
const honeyBatchResponse = await request(app.getHttpServer()) const honeyBatchResponse = await request(app.getHttpServer())
.get(`/api/v1/honey-batches/${honeyBatchId}`) .get(`/api/v1/honey-batches/${honeyBatchId}`)
@@ -217,11 +220,12 @@ describe('Bottling (e2e)', () => {
}); });
it('a correction records BottlingCorrected and StockChanged together', async () => { it('a correction records BottlingCorrected and StockChanged together', async () => {
await request(app.getHttpServer()) const corrected = await request(app.getHttpServer())
.patch(`/api/v1/bottling-batches/${bottlingBatchId}`) .patch(`/api/v1/bottling-batches/${bottlingBatchId}`)
.set('Authorization', `Bearer ${beekeeperToken}`) .set('Authorization', `Bearer ${beekeeperToken}`)
.send({ jarCount: 40 }) .send({ jarCount: 40 })
.expect(200); .expect(200);
expect((corrected.body as BottlingBatchBody).warnings).toEqual([]);
// 10 jars of 500 g fewer: 5 kg go back to the honey batch. // 10 jars of 500 g fewer: 5 kg go back to the honey batch.
expect(await honeyBatchAmount()).toBe(5); expect(await honeyBatchAmount()).toBe(5);
@@ -168,7 +168,7 @@ describe('Calendar sync outbox (e2e)', () => {
.mockImplementationOnce(async (_g, input) => { .mockImplementationOnce(async (_g, input) => {
createdEvents.push(input.title); createdEvents.push(input.title);
// The task's due date is changed right now, on another request. // The task's due date is changed right now, on another request.
await calendarSync.enqueue('TASK', task.id); await calendarSync.enqueueIn(prisma, 'TASK', task.id);
return `event-${suffix}-edited`; return `event-${suffix}-edited`;
}); });
+131 -3
View File
@@ -7,8 +7,13 @@ import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api'; import { login } from './utils/api';
interface CalendarEventBody { interface CalendarEventBody {
id: string;
type: string; type: string;
title: string; title: string;
date: string;
endDate?: string;
relatedType: string;
relatedId: string;
} }
describe('Calendar (e2e)', () => { describe('Calendar (e2e)', () => {
@@ -21,6 +26,11 @@ describe('Calendar (e2e)', () => {
let apiaryId: string; let apiaryId: string;
let colonyId: string; let colonyId: string;
let taskId: string; let taskId: string;
let treatmentId: string;
let colonyCode: string;
let calendarOnlyRoleId: string;
let calendarOnlyId: string;
let calendarOnlyToken: string;
beforeAll(async () => { beforeAll(async () => {
app = await createTestApp(); app = await createTestApp();
@@ -50,6 +60,39 @@ describe('Calendar (e2e)', () => {
}, },
}); });
colonyId = colony.id; colonyId = colony.id;
colonyCode = colony.code;
// 2027-06-10 + 10 days: harvestable again from 2027-06-20.
const treatment = await prisma.treatment.create({
data: {
colonyId,
userId: beekeeperId,
date: new Date('2027-06-10T00:00:00.000Z'),
product: 'Oxuvar',
withdrawalPeriodDays: 10,
},
});
treatmentId = treatment.id;
const calendarOnlyRole = await prisma.role.create({
data: {
key: `CAL_ONLY_${Date.now()}`,
name: 'Nur Kalender',
permissions: {
create: [{ permission: { connect: { key: 'calendar:read' } } }],
},
},
});
calendarOnlyRoleId = calendarOnlyRole.id;
const calendarOnly = await prisma.user.create({
data: {
email: `calendar-e2e-only-${Date.now()}@example.com`,
passwordHash,
name: 'Calendar only',
roleId: calendarOnlyRoleId,
},
});
calendarOnlyId = calendarOnly.id;
calendarOnlyToken = (await login(app, calendarOnly.email, password))
.accessToken;
const task = await prisma.task.create({ const task = await prisma.task.create({
data: { data: {
title: `Kalendertest ${Date.now()}`, title: `Kalendertest ${Date.now()}`,
@@ -62,11 +105,16 @@ describe('Calendar (e2e)', () => {
afterAll(async () => { afterAll(async () => {
await prisma.task.deleteMany({ where: { id: taskId } }); await prisma.task.deleteMany({ where: { id: taskId } });
await prisma.treatment.deleteMany({ where: { id: treatmentId } });
await prisma.colony.deleteMany({ where: { id: colonyId } }); await prisma.colony.deleteMany({ where: { id: colonyId } });
await prisma.apiary.deleteMany({ where: { id: apiaryId } }); await prisma.apiary.deleteMany({ where: { id: apiaryId } });
await prisma.refreshToken.deleteMany({ where: { userId: beekeeperId } }); const userIds = [beekeeperId, calendarOnlyId];
await prisma.auditLog.deleteMany({ where: { userId: beekeeperId } }); await prisma.refreshToken.deleteMany({
await prisma.user.deleteMany({ where: { id: beekeeperId } }); where: { userId: { in: userIds } },
});
await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } });
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
await prisma.role.deleteMany({ where: { id: calendarOnlyRoleId } });
await app.close(); await app.close();
}); });
@@ -90,4 +138,84 @@ describe('Calendar (e2e)', () => {
), ),
).toBe(true); ).toBe(true);
}); });
it('lists WITHDRAWAL as a span with endDate for users with treatments:read', async () => {
const response = await request(app.getHttpServer())
.get('/api/v1/calendar?from=2027-06-01&to=2027-06-30')
.set('Authorization', `Bearer ${beekeeperToken}`)
.expect(200);
const events = response.body as CalendarEventBody[];
const span = events.find(
(event) =>
event.type === 'WITHDRAWAL' && event.id === `withdrawal:${treatmentId}`,
);
expect(span).toMatchObject({
title: `Wartezeit ${colonyCode} · Oxuvar`,
relatedType: 'Colony',
relatedId: colonyId,
});
expect(span?.date.slice(0, 10)).toBe('2027-06-10');
// The last covered day, inclusive; 2027-06-20 is harvestable again.
expect(span?.endDate?.slice(0, 10)).toBe('2027-06-19');
});
it('shows the span in a range that only touches the end of the window', async () => {
const response = await request(app.getHttpServer())
.get('/api/v1/calendar?from=2027-06-19&to=2027-06-25')
.set('Authorization', `Bearer ${beekeeperToken}`)
.expect(200);
const events = response.body as CalendarEventBody[];
expect(
events.some((event) => event.id === `withdrawal:${treatmentId}`),
).toBe(true);
const later = await request(app.getHttpServer())
.get('/api/v1/calendar?from=2027-06-20&to=2027-06-25')
.set('Authorization', `Bearer ${beekeeperToken}`)
.expect(200);
expect(
(later.body as CalendarEventBody[]).some(
(event) => event.id === `withdrawal:${treatmentId}`,
),
).toBe(false);
});
it('hides WITHDRAWAL from users without treatments:read', async () => {
const response = await request(app.getHttpServer())
.get('/api/v1/calendar?from=2027-06-01&to=2027-06-30')
.set('Authorization', `Bearer ${calendarOnlyToken}`)
.expect(200);
expect(
(response.body as CalendarEventBody[]).filter(
(event) => event.type === 'WITHDRAWAL',
),
).toEqual([]);
});
it('lists TREATMENT events only for users with treatments:read', async () => {
const asBeekeeper = await request(app.getHttpServer())
.get('/api/v1/calendar?from=2027-06-01&to=2027-06-30')
.set('Authorization', `Bearer ${beekeeperToken}`)
.expect(200);
const asCalendarOnly = await request(app.getHttpServer())
.get('/api/v1/calendar?from=2027-06-01&to=2027-06-30')
.set('Authorization', `Bearer ${calendarOnlyToken}`)
.expect(200);
const treatmentEvent = (body: unknown) =>
(body as CalendarEventBody[]).filter(
(event) => event.type === 'TREATMENT' && event.id === treatmentId,
);
expect(treatmentEvent(asBeekeeper.body)).toHaveLength(1);
expect(treatmentEvent(asCalendarOnly.body)).toEqual([]);
});
it('rejects an invalid date range', () => {
return request(app.getHttpServer())
.get('/api/v1/calendar?from=nonsense&to=2027-06-30')
.set('Authorization', `Bearer ${beekeeperToken}`)
.expect(400);
});
}); });
+15 -2
View File
@@ -2,6 +2,7 @@ import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs'; import * as bcrypt from 'bcryptjs';
import request from 'supertest'; import request from 'supertest';
import { App } from 'supertest/types'; import { App } from 'supertest/types';
import { berlinToday } from '../src/common/dates';
import { PrismaService } from '../src/prisma/prisma.service'; import { PrismaService } from '../src/prisma/prisma.service';
import { createTestApp } from './utils/create-test-app'; import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api'; import { login } from './utils/api';
@@ -270,7 +271,14 @@ describe('Colonies (e2e)', () => {
const ended = await prisma.domainEvent.findMany({ const ended = await prisma.domainEvent.findMany({
where: { type: 'ColonyEnded', aggregateId: colonyToMergeId }, where: { type: 'ColonyEnded', aggregateId: colonyToMergeId },
}); });
expect(ended.map((event) => event.payload)).toEqual([{ status: 'MERGED' }]); expect(ended.map((event) => event.payload)).toEqual([
{
status: 'MERGED',
endedOn: berlinToday(),
releasedQueenId: null,
via: 'MERGE',
},
]);
}); });
it('dissolves the colony without deleting it', async () => { it('dissolves the colony without deleting it', async () => {
@@ -288,7 +296,12 @@ describe('Colonies (e2e)', () => {
'ColonyStatusChanged', 'ColonyStatusChanged',
'ColonyEnded', 'ColonyEnded',
]); ]);
expect(events[1].payload).toEqual({ status: 'DISSOLVED' }); expect(events[1].payload).toEqual({
status: 'DISSOLVED',
endedOn: berlinToday(),
releasedQueenId: null,
via: 'DISSOLVE',
});
await request(app.getHttpServer()) await request(app.getHttpServer())
.get(`/api/v1/colonies/${colonyId}`) .get(`/api/v1/colonies/${colonyId}`)
@@ -0,0 +1,586 @@
import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
import { PrismaService } from '../src/prisma/prisma.service';
import { login } from './utils/api';
import { createTestApp } from './utils/create-test-app';
import { drainDomainEvents } from './utils/domain-events';
import { proposeForTest } from './utils/suggestions';
interface CleanupBody {
cancelled: number;
completed: number;
kept: number;
queenStatus: string | null;
}
interface EndedColony {
colonyId: string;
queenId: string;
taskIds: string[];
suggestionId: string;
}
/**
* A colony that ends (winter loss, edit to SOLD): the queen assignment ends
* in the same transaction, open suggestions turn OBSOLETE, one clean-up
* suggestion is left, and "Aufräumen" settles tasks and queen. Every
* assertion is on this file's own colonies.
*/
describe('Colony ended (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
const password = 'SuperSecret123!';
const FULL_PERMISSIONS = [
'tasks:read',
'tasks:create',
'tasks:update',
'tasks:complete',
'queens:update',
'colonies:read',
'colonies:update',
'colonies:deactivate',
'winter-checks:read',
'winter-checks:create',
];
let apiaryId: string;
const colonyIds: string[] = [];
const queenIds: string[] = [];
const roleIds: string[] = [];
const userIds: string[] = [];
const tokens = { full: '', noComplete: '', noQueens: '' };
let counter = 0;
const nextKey = () => `colony-ended-${suffix}-${(counter += 1)}-key`;
const api = () => request(app.getHttpServer());
const auth = (token: string) => ({ Authorization: `Bearer ${token}` });
async function createUser(
label: string,
permissions: string[],
): Promise<string> {
const role = await prisma.role.create({
data: {
key: `COLONY_ENDED_${label.toUpperCase()}_${suffix}`,
name: `Colony ended ${label}`,
permissions: {
create: permissions.map((key) => ({
permission: { connect: { key } },
})),
},
},
});
roleIds.push(role.id);
const email = `colony-ended-${label}-${suffix}@example.com`;
const user = await prisma.user.create({
data: {
email,
passwordHash: await bcrypt.hash(password, 12),
name: `Colony ended ${label}`,
role: { connect: { id: role.id } },
},
});
userIds.push(user.id);
return (await login(app, email, password)).accessToken;
}
/** A living colony with an active queen assigned today and some open tasks. */
async function newColony(
label: string,
taskCount = 3,
): Promise<{ colonyId: string; queenId: string; taskIds: string[] }> {
const queen = await prisma.queen.create({
data: { code: `CE-Q-${label}-${suffix}`, status: 'ACTIVE' },
});
queenIds.push(queen.id);
const colony = await prisma.colony.create({
data: {
code: `CE-${label}-${suffix}`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
currentQueenId: queen.id,
},
});
colonyIds.push(colony.id);
await prisma.queenColonyHistory.create({
data: {
queenId: queen.id,
colonyId: colony.id,
startDate: new Date('2002-04-01T00:00:00.000Z'),
},
});
const taskIds: string[] = [];
for (let index = 0; index < taskCount; index += 1) {
const task = await prisma.task.create({
data: {
title: `Aufgabe ${index}`,
relatedType: 'COLONY',
relatedId: colony.id,
},
});
taskIds.push(task.id);
}
return { colonyId: colony.id, queenId: queen.id, taskIds };
}
/** Ends a colony through the edit route and returns its clean-up suggestion. */
async function endColony(label: string): Promise<EndedColony> {
const colony = await newColony(label);
await api()
.patch(`/api/v1/colonies/${colony.colonyId}`)
.set(auth(tokens.full))
.send({ status: 'SOLD' })
.expect(200);
await drainDomainEvents(app, [colony.colonyId]);
const suggestion = await prisma.suggestion.findFirstOrThrow({
where: { colonyId: colony.colonyId, intent: 'CLEANUP', status: 'OPEN' },
});
return { ...colony, suggestionId: suggestion.id };
}
const applyCleanup = (token: string, suggestionId: string, body: object) =>
api()
.post(`/api/v1/suggestions/${suggestionId}/apply-cleanup`)
.set(auth(token))
.set('Idempotency-Key', nextKey())
.send(body);
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
apiaryId = (
await prisma.apiary.create({ data: { name: `Colony ended ${suffix}` } })
).id;
tokens.full = await createUser('full', FULL_PERMISSIONS);
tokens.noQueens = await createUser(
'noqueens',
FULL_PERMISSIONS.filter((key) => key !== 'queens:update'),
);
tokens.noComplete = await createUser(
'nocomplete',
FULL_PERMISSIONS.filter((key) => key !== 'tasks:complete'),
);
});
afterAll(async () => {
const taskIds = (
await prisma.task.findMany({
where: { relatedId: { in: colonyIds } },
select: { id: true },
})
).map((row) => row.id);
await prisma.domainEvent.deleteMany({
where: { aggregateId: { in: [...taskIds, ...colonyIds, ...queenIds] } },
});
await prisma.calendarSyncOutboxEntry.deleteMany({
where: { sourceType: 'TASK', sourceId: { in: taskIds } },
});
await prisma.task.deleteMany({ where: { id: { in: taskIds } } });
await prisma.suggestion.deleteMany({
where: { colonyId: { in: colonyIds } },
});
await prisma.winterCheck.deleteMany({
where: { colonyId: { in: colonyIds } },
});
await prisma.queenColonyHistory.deleteMany({
where: { colonyId: { in: colonyIds } },
});
await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } });
await prisma.queen.deleteMany({ where: { id: { in: queenIds } } });
await prisma.apiary.delete({ where: { id: apiaryId } });
await prisma.idempotencyRecord.deleteMany({
where: { userId: { in: userIds } },
});
await prisma.refreshToken.deleteMany({
where: { userId: { in: userIds } },
});
await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } });
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
await prisma.role.deleteMany({ where: { id: { in: roleIds } } });
await app.close();
});
it('a winter loss with markColonyDeceased ends the queen assignment in the same transaction', async () => {
const { colonyId, queenId } = await newColony('winter', 0);
await api()
.post('/api/v1/winter-checks')
.set(auth(tokens.full))
.send({
colonyId,
season: 'SPRING',
date: '2002-03-20',
outcome: 'LOST',
lossCause: 'DEAD_OR_EMPTY',
markColonyDeceased: true,
})
.expect(201);
const colony = await prisma.colony.findUniqueOrThrow({
where: { id: colonyId },
});
expect(colony).toMatchObject({ status: 'DECEASED', currentQueenId: null });
const history = await prisma.queenColonyHistory.findMany({
where: { colonyId, queenId },
});
expect(history).toHaveLength(1);
expect(history[0].endDate?.toISOString()).toBe('2002-03-20T00:00:00.000Z');
// The queen herself keeps her status: the clean-up decides.
await expect(
prisma.queen.findUniqueOrThrow({ where: { id: queenId } }),
).resolves.toMatchObject({ status: 'ACTIVE' });
const ended = await prisma.domainEvent.findMany({
where: { aggregateId: colonyId, type: 'ColonyEnded' },
});
expect(ended.map((event) => event.payload)).toEqual([
{
status: 'DECEASED',
endedOn: '2002-03-20',
releasedQueenId: queenId,
via: 'WINTER_CHECK',
},
]);
});
it('open suggestions become OBSOLETE after draining and one CLEANUP suggestion is left', async () => {
const { colonyId, taskIds, queenId } = await newColony('obsolete', 2);
const open = await proposeForTest(app, {
ruleKey: 'INSPECTION_INTERVAL',
source: 'RULE',
mode: 'DO',
intent: 'INSPECT',
relatedType: 'COLONY',
relatedId: colonyId,
colonyId,
reasonCode: `COLONY_ENDED_E2E_${suffix}`,
evidence: { colonyId },
requiredPermission: 'inspections:read',
dueDate: '2026-05-12',
anchor: `ce-${suffix}`,
});
await api()
.patch(`/api/v1/colonies/${colonyId}`)
.set(auth(tokens.full))
.send({ status: 'DECEASED' })
.expect(200);
await drainDomainEvents(app, [colonyId]);
await expect(
prisma.suggestion.findUniqueOrThrow({ where: { id: open.id } }),
).resolves.toMatchObject({ status: 'OBSOLETE' });
const cleanups = await prisma.suggestion.findMany({
where: { colonyId, intent: 'CLEANUP' },
});
expect(cleanups).toHaveLength(1);
expect(cleanups[0]).toMatchObject({
status: 'OPEN',
mode: 'DO',
ruleKey: 'COLONY_ENDED_CLEANUP',
requiredPermission: 'colonies:read',
});
const evidence = cleanups[0].evidence as Record<string, unknown>;
expect(evidence).toMatchObject({
colonyId,
status: 'DECEASED',
via: 'EDIT',
queenId,
});
expect(evidence.openTaskIds).toEqual([...taskIds].sort());
const colony = await prisma.colony.findUniqueOrThrow({
where: { id: colonyId },
});
expect(colony.currentQueenId).toBeNull();
});
it('apply-cleanup cancels 2 tasks and completes 1, then the suggestion is RESOLVED', async () => {
const ended = await endColony('apply');
const [first, second, third] = [...ended.taskIds].sort();
const response = await applyCleanup(tokens.full, ended.suggestionId, {
tasks: [
{ id: first, outcome: 'CANCEL' },
{ id: second, outcome: 'CANCEL' },
{ id: third, outcome: 'DONE' },
],
queen: { id: ended.queenId, status: 'SOLD' },
}).expect(201);
expect(response.body as CleanupBody).toEqual({
cancelled: 2,
completed: 1,
kept: 0,
queenStatus: 'SOLD',
});
const tasks = await prisma.task.findMany({
where: { id: { in: ended.taskIds } },
orderBy: { id: 'asc' },
});
expect(tasks.map((task) => task.status)).toEqual([
'CANCELLED',
'CANCELLED',
'DONE',
]);
expect(tasks[2].completedAt).not.toBeNull();
await expect(
prisma.queen.findUniqueOrThrow({ where: { id: ended.queenId } }),
).resolves.toMatchObject({ status: 'SOLD' });
await drainDomainEvents(app, [first, second]);
for (const id of [first, second]) {
const updates = await prisma.domainEvent.findMany({
where: { aggregateId: id, type: 'TaskUpdated' },
include: { deliveries: true },
});
expect(updates).toHaveLength(1);
expect(updates[0].payload).toMatchObject({ status: 'CANCELLED' });
expect(updates[0].deliveries).toEqual(
expect.arrayContaining([
expect.objectContaining({
handler: 'calendar-sync.task',
status: 'DONE',
}),
]),
);
}
const suggestion = await prisma.suggestion.findUniqueOrThrow({
where: { id: ended.suggestionId },
});
expect(suggestion).toMatchObject({
status: 'RESOLVED',
decisionNote: 'Aufgeräumt',
dedupeKey: null,
});
await expect(
prisma.auditLog.count({
where: {
entityType: 'Suggestion',
entityId: ended.suggestionId,
action: 'UPDATE',
},
}),
).resolves.toBe(1);
});
it('apply-cleanup DONE writes the audit entry and TaskCompleted like a normal completion (delivered after draining)', async () => {
const ended = await endColony('done');
const [first, ...rest] = [...ended.taskIds].sort();
await applyCleanup(tokens.full, ended.suggestionId, {
tasks: [
{ id: first, outcome: 'DONE' },
...rest.map((id) => ({ id, outcome: 'KEEP' })),
],
}).expect(201);
await drainDomainEvents(app, [first]);
const audit = await prisma.auditLog.findMany({
where: { entityType: 'Task', entityId: first, action: 'UPDATE' },
});
expect(audit).toHaveLength(1);
expect(audit[0].afterData).toMatchObject({ status: 'DONE' });
const completed = await prisma.domainEvent.findMany({
where: { aggregateId: first, type: 'TaskCompleted' },
include: { deliveries: true },
});
expect(completed).toHaveLength(1);
expect(completed[0].deliveries).toEqual(
expect.arrayContaining([
expect.objectContaining({
handler: 'calendar-sync.task',
status: 'DONE',
}),
]),
);
const kept = await prisma.task.findMany({
where: { id: { in: rest } },
});
expect(kept.map((task) => task.status)).toEqual(['OPEN', 'OPEN']);
});
it('403 without tasks:complete when one task is DONE, and nothing changes', async () => {
const ended = await endColony('forbidden');
const [first] = [...ended.taskIds].sort();
await applyCleanup(tokens.noComplete, ended.suggestionId, {
tasks: [{ id: first, outcome: 'DONE' }],
}).expect(403);
await expect(
prisma.task.findUniqueOrThrow({ where: { id: first } }),
).resolves.toMatchObject({ status: 'OPEN' });
await expect(
prisma.suggestion.findUniqueOrThrow({
where: { id: ended.suggestionId },
}),
).resolves.toMatchObject({ status: 'OPEN' });
});
it('400 for a task id that is not in the evidence', async () => {
const ended = await endColony('foreign');
const other = await newColony('foreign-other', 1);
const response = await applyCleanup(tokens.full, ended.suggestionId, {
tasks: [{ id: other.taskIds[0], outcome: 'CANCEL' }],
}).expect(400);
expect((response.body as { message: string }).message).toBe(
'Nur Aufgaben und Königin aus dem Vorschlag.',
);
await expect(
prisma.task.findUniqueOrThrow({ where: { id: other.taskIds[0] } }),
).resolves.toMatchObject({ status: 'OPEN' });
});
it('409 when the suggestion was cleaned up already', async () => {
const ended = await endColony('twice');
await applyCleanup(tokens.full, ended.suggestionId, {
tasks: [],
}).expect(201);
const second = await applyCleanup(tokens.full, ended.suggestionId, {
tasks: [],
}).expect(409);
expect(second.body).toMatchObject({
message: 'Der Vorschlag ist nicht mehr offen.',
status: 'RESOLVED',
});
});
it('403 without queens:update when the queen is given, and nothing changes', async () => {
const ended = await endColony('noqueens');
await applyCleanup(tokens.noQueens, ended.suggestionId, {
tasks: [],
queen: { id: ended.queenId, status: 'SOLD' },
}).expect(403);
await expect(
prisma.queen.findUniqueOrThrow({ where: { id: ended.queenId } }),
).resolves.toMatchObject({ status: 'ACTIVE' });
});
it('400 for a queen id that is not in the evidence', async () => {
const ended = await endColony('foreignqueen');
const other = await newColony('foreignqueen-other', 0);
await applyCleanup(tokens.full, ended.suggestionId, {
tasks: [],
queen: { id: other.queenId, status: 'SOLD' },
}).expect(400);
await expect(
prisma.queen.findUniqueOrThrow({ where: { id: other.queenId } }),
).resolves.toMatchObject({ status: 'ACTIVE' });
});
it('400 when the evidence has no queen but the request names one', async () => {
const colony = await newColony('noqueenevidence');
await prisma.queen.update({
where: { id: colony.queenId },
data: { status: 'LOST' },
});
await api()
.patch(`/api/v1/colonies/${colony.colonyId}`)
.set(auth(tokens.full))
.send({ status: 'SOLD' })
.expect(200);
await drainDomainEvents(app, [colony.colonyId]);
const suggestion = await prisma.suggestion.findFirstOrThrow({
where: { colonyId: colony.colonyId, intent: 'CLEANUP', status: 'OPEN' },
});
expect((suggestion.evidence as { queenId: unknown }).queenId).toBeNull();
await applyCleanup(tokens.full, suggestion.id, {
tasks: [],
queen: { id: colony.queenId, status: 'SOLD' },
}).expect(400);
});
it('409 when a colony holds the queen again, and the whole clean-up rolls back', async () => {
const ended = await endColony('held');
const holder = await newColony('held-holder', 0);
await prisma.colony.update({
where: { id: holder.colonyId },
data: { currentQueenId: ended.queenId },
});
const [first] = [...ended.taskIds].sort();
await applyCleanup(tokens.full, ended.suggestionId, {
tasks: [{ id: first, outcome: 'CANCEL' }],
queen: { id: ended.queenId, status: 'DECEASED' },
}).expect(409);
await expect(
prisma.task.findUniqueOrThrow({ where: { id: first } }),
).resolves.toMatchObject({ status: 'OPEN' });
await expect(
prisma.suggestion.findUniqueOrThrow({
where: { id: ended.suggestionId },
}),
).resolves.toMatchObject({ status: 'OPEN' });
await expect(
prisma.queen.findUniqueOrThrow({ where: { id: ended.queenId } }),
).resolves.toMatchObject({ status: 'ACTIVE' });
});
it('reactivating the colony turns its open CLEANUP suggestion OBSOLETE', async () => {
const ended = await endColony('reactivate');
await api()
.post(`/api/v1/colonies/${ended.colonyId}/reactivate`)
.set(auth(tokens.full))
.expect(201);
await expect(
prisma.suggestion.findUniqueOrThrow({
where: { id: ended.suggestionId },
}),
).resolves.toMatchObject({ status: 'OBSOLETE' });
});
it('409 "Das Volk ist wieder aktiv." when the colony lives again but the suggestion is still open', async () => {
const ended = await endColony('living');
await prisma.colony.update({
where: { id: ended.colonyId },
data: { status: 'ACTIVE', deactivatedAt: null },
});
const [first] = [...ended.taskIds].sort();
const response = await applyCleanup(tokens.full, ended.suggestionId, {
tasks: [{ id: first, outcome: 'CANCEL' }],
}).expect(409);
expect((response.body as { message: string }).message).toBe(
'Das Volk ist wieder aktiv.',
);
await expect(
prisma.task.findUniqueOrThrow({ where: { id: first } }),
).resolves.toMatchObject({ status: 'OPEN' });
});
it('end, reactivate, end again the same day leaves one OBSOLETE and one OPEN CLEANUP suggestion', async () => {
const ended = await endColony('reend');
await api()
.post(`/api/v1/colonies/${ended.colonyId}/reactivate`)
.set(auth(tokens.full))
.expect(201);
await api()
.patch(`/api/v1/colonies/${ended.colonyId}`)
.set(auth(tokens.full))
.send({ status: 'DECEASED' })
.expect(200);
await drainDomainEvents(app, [ended.colonyId]);
const cleanups = await prisma.suggestion.findMany({
where: { colonyId: ended.colonyId, intent: 'CLEANUP' },
});
expect(cleanups.map((row) => row.status).sort()).toEqual([
'OBSOLETE',
'OPEN',
]);
});
});
+5 -1
View File
@@ -11,6 +11,7 @@ interface HarvestBody {
id: string; id: string;
apiaryId: string; apiaryId: string;
colonies: { colonyId: string }[]; colonies: { colonyId: string }[];
warnings: unknown[];
} }
describe('Harvests (e2e)', () => { describe('Harvests (e2e)', () => {
@@ -133,6 +134,8 @@ describe('Harvests (e2e)', () => {
const harvest = response.body as HarvestBody; const harvest = response.body as HarvestBody;
harvestId = harvest.id; harvestId = harvest.id;
expect(harvest.colonies).toHaveLength(2); expect(harvest.colonies).toHaveLength(2);
// No treatment with a withdrawal period: the list is there, and empty.
expect(harvest.warnings).toEqual([]);
}); });
it('wrote the audit entry and HarvestRecorded with the colony ids together with the harvest', async () => { it('wrote the audit entry and HarvestRecorded with the colony ids together with the harvest', async () => {
@@ -202,11 +205,12 @@ describe('Harvests (e2e)', () => {
}); });
it('records HarvestCorrected with the new colony set on an edit', async () => { it('records HarvestCorrected with the new colony set on an edit', async () => {
await request(app.getHttpServer()) const edited = await request(app.getHttpServer())
.patch(`/api/v1/harvests/${harvestId}`) .patch(`/api/v1/harvests/${harvestId}`)
.set('Authorization', `Bearer ${staffToken}`) .set('Authorization', `Bearer ${staffToken}`)
.send({ colonyIds: [colonyOneId] }) .send({ colonyIds: [colonyOneId] })
.expect(200); .expect(200);
expect((edited.body as HarvestBody).warnings).toEqual([]);
const corrections = await prisma.domainEvent.findMany({ const corrections = await prisma.domainEvent.findMany({
where: { where: {
+8 -3
View File
@@ -160,7 +160,7 @@ describe('Inspections (e2e)', () => {
]); ]);
}); });
it('records InspectionCorrected for an edit, whose follow-ups are the calendar and the autopilot', async () => { it('records InspectionCorrected for an edit, whose follow-ups are the autopilot, the rules and the calendar', async () => {
await drainDomainEvents(app, [inspectionId]); await drainDomainEvents(app, [inspectionId]);
await request(app.getHttpServer()) await request(app.getHttpServer())
@@ -189,13 +189,18 @@ describe('Inspections (e2e)', () => {
}); });
await drainDomainEvents(app, [inspectionId]); await drainDomainEvents(app, [inspectionId]);
// A correction evaluates no automation rule and sends no webhook; the // A correction proposes nothing and sends no webhook; the autopilot, the
// calendar and the autopilot of the colony follow. // automation rules (they only follow open suggestions) and the calendar
// follow.
const deliveries = await prisma.domainEventDelivery.findMany({ const deliveries = await prisma.domainEventDelivery.findMany({
where: { eventId: correction.id }, where: { eventId: correction.id },
orderBy: { handler: 'asc' }, orderBy: { handler: 'asc' },
}); });
expect(deliveries).toEqual([ expect(deliveries).toEqual([
expect.objectContaining({
handler: 'automation.inspection-rules',
status: 'DONE',
}),
expect.objectContaining({ expect.objectContaining({
handler: 'autopilot.colony-sync', handler: 'autopilot.colony-sync',
status: 'DONE', status: 'DONE',
+457
View File
@@ -0,0 +1,457 @@
import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
import type { Prisma } from '../generated/prisma';
import { NextStepsService } from '../src/autopilot/next-steps.service';
import { addDays, berlinToday } from '../src/common/dates';
import { PrismaService } from '../src/prisma/prisma.service';
import { login } from './utils/api';
import { createTestApp } from './utils/create-test-app';
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
/** The example of Spec 8.5: colony B3 on 12 May, in the swarm season by calendar. */
const EXAMPLE_DAY = '2026-05-12';
interface StepBody {
key: string;
kind: string;
intent: string | null;
title: string;
reasons: string[];
dueOn: string | null;
urgency: string;
action: { type: string; form?: string; href?: string; suggestionId?: string };
dismissible: boolean;
}
interface NextStepsBody {
colonyId: string;
segment: string;
inspectionDueOn: string | null;
withdrawalUntil: string | null;
steps: StepBody[];
}
interface TodayBody {
nextStep: { colonyId: string } | null;
inspectionDue: { apiaryId: string }[];
suggestionsOpen: number;
seasons: { apiaryId: string; segment: string }[];
tasksVisible: boolean;
}
/**
* The next steps and the day overview against a real PostgreSQL. The
* assertions about order use the service with the fixed example day; the
* routes are checked for what the user may see, which does not depend on the
* day. Everything is scoped to this file's own apiary and colonies.
*/
describe('Next steps (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
let nextSteps: NextStepsService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
const password = 'SuperSecret123!';
const FULL = [
'colonies:read',
'tasks:read',
'tasks:create',
'inspections:read',
'varroa:read',
'treatments:read',
];
let apiaryId: string;
let colonyId: string;
let recorderId: string;
const roleIds: string[] = [];
const userIds: string[] = [];
const users = {} as Record<
'full' | 'noVarroa' | 'noCreate' | 'noTasks',
{ id: string; token: string }
>;
async function createUser(
label: string,
permissions: string[],
): Promise<{ id: string; token: string }> {
const role = await prisma.role.create({
data: {
key: `NEXT_STEPS_${label.toUpperCase()}_${suffix}`,
name: `Next steps ${label}`,
permissions: {
create: permissions.map((key) => ({
permission: { connect: { key } },
})),
},
},
});
roleIds.push(role.id);
const email = `next-steps-${label}-${suffix}@example.com`;
const user = await prisma.user.create({
data: {
email,
passwordHash: await bcrypt.hash(password, 12),
name: `Next steps ${label}`,
role: { connect: { id: role.id } },
},
});
userIds.push(user.id);
return {
id: user.id,
token: (await login(app, email, password)).accessToken,
};
}
function suggestion(
overrides: Partial<Prisma.SuggestionUncheckedCreateInput>,
): Prisma.SuggestionUncheckedCreateInput {
return {
ruleKey: 'INSPECTION_INTERVAL',
source: 'RULE',
mode: 'DO',
intent: 'INSPECT',
relatedType: 'COLONY',
relatedId: colonyId,
colonyId,
reasonCode: 'NEXT_STEPS_E2E',
evidence: { colonyId },
requiredPermission: 'inspections:read',
dueDate: d(EXAMPLE_DAY),
...overrides,
};
}
/** The three suggestions of the spec example. */
const exampleSuggestions = (): Prisma.SuggestionUncheckedCreateInput[] => [
suggestion({
source: 'AUTOPILOT',
mode: 'DO',
intent: 'INSPECT',
priority: 'HIGH',
reasonCode: 'INSPECTION_DUE',
evidence: {
colonyId,
inspectionId: 'none',
lastInspectedOn: '2026-05-04',
dueOn: '2026-05-11',
intervalDays: 7,
segment: 'SCHWARMZEIT',
},
dueDate: d('2026-05-11'),
}),
suggestion({
ruleKey: 'QUEEN_CELLS_SWARM_CHECK',
source: 'CHAIN',
mode: 'PLAN',
intent: 'SWARM_CHECK',
reasonCode: 'QUEEN_CELLS_SWARM_CHECK',
evidence: {
colonyId,
inspectionId: 'insp-1',
inspectedOn: '2026-05-05',
baseDate: '2026-05-05',
},
}),
suggestion({
ruleKey: 'VARROA_THRESHOLD',
mode: 'PLAN',
intent: 'VARROA_TREAT',
reasonCode: 'VARROA_RED',
evidence: {
colonyId,
measurementId: 'm-1',
measuredOn: '2026-05-10',
value: 12.4,
redAbove: 10,
unit: 'MITES_PER_DAY',
},
requiredPermission: 'varroa:read',
}),
suggestion({
ruleKey: 'SEASON_AUTOPILOT',
source: 'AUTOPILOT',
intent: 'HONEY_SUPER',
reasonCode: 'NEXT_STEPS_E2E_HONEY_SUPER',
dueDate: d('2026-05-14'),
}),
];
const get = (token: string, path: string) =>
request(app.getHttpServer())
.get(`/api/v1${path}`)
.set('Authorization', `Bearer ${token}`);
const cleanDb = async () => {
await prisma.suggestion.deleteMany({ where: { colonyId } });
await prisma.task.deleteMany({
where: { relatedType: 'COLONY', relatedId: colonyId },
});
await prisma.treatment.deleteMany({ where: { colonyId } });
};
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
nextSteps = app.get(NextStepsService);
apiaryId = (
await prisma.apiary.create({ data: { name: `Next steps ${suffix}` } })
).id;
colonyId = (
await prisma.colony.create({
data: {
code: `B3-${suffix}`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
createdAt: d('2026-04-01'),
},
})
).id;
users.full = await createUser('full', FULL);
users.noVarroa = await createUser(
'novarroa',
FULL.filter((key) => key !== 'varroa:read'),
);
users.noCreate = await createUser(
'nocreate',
FULL.filter((key) => key !== 'tasks:create'),
);
users.noTasks = await createUser(
'notasks',
FULL.filter((key) => !key.startsWith('tasks:')),
);
recorderId = users.full.id;
await prisma.inspection.create({
data: { colonyId, userId: recorderId, date: d('2026-05-04') },
});
});
afterEach(cleanDb);
afterAll(async () => {
await cleanDb();
await prisma.inspection.deleteMany({ where: { colonyId } });
await prisma.colony.delete({ where: { id: colonyId } });
await prisma.apiary.delete({ where: { id: apiaryId } });
await prisma.refreshToken.deleteMany({
where: { userId: { in: userIds } },
});
await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } });
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
await prisma.role.deleteMany({ where: { id: { in: roleIds } } });
await app.close();
});
it('returns the three steps of the spec example in order', async () => {
await prisma.suggestion.createMany({ data: exampleSuggestions() });
const view = await nextSteps.forColony(
colonyId,
users.full.id,
EXAMPLE_DAY,
);
expect(view).toMatchObject({
colonyId,
segment: 'SCHWARMZEIT',
phase: 'APFEL',
phaseSource: 'CALENDAR',
inspectionDueOn: '2026-05-11',
withdrawalUntil: null,
});
expect(view.steps).toHaveLength(3);
const [inspection, treatment, honey] = view.steps;
expect(inspection).toMatchObject({
title: 'Durchsicht mit Schwarmkontrolle',
urgency: 'OVERDUE',
dueOn: '2026-05-11',
action: {
type: 'CAPTURE',
form: 'inspection',
href: `/inspections/new?colonyId=${colonyId}`,
},
dismissible: true,
});
expect(inspection.reasons).toHaveLength(2);
expect(inspection.reasons[0]).toContain('Weiselzellen');
expect(inspection.reasons[1]).toContain('Letzte Durchsicht vor 8 Tagen');
expect(treatment).toMatchObject({
intent: 'VARROA_TREAT',
action: { type: 'ACCEPT' },
dismissible: true,
});
expect(treatment.reasons.join(' ')).toContain('12,4');
expect(honey).toMatchObject({
intent: 'HONEY_SUPER',
title: 'Honigraum aufsetzen',
urgency: 'SOON',
action: { type: 'CAPTURE', form: 'inspection' },
});
});
it('a HONEY_SUPER PLAN suggestion stays a step of its own next to the merged inspection', async () => {
await prisma.suggestion.createMany({
data: [
...exampleSuggestions().filter((row) => row.intent !== 'HONEY_SUPER'),
suggestion({
ruleKey: 'SEASON_AUTOPILOT',
source: 'AUTOPILOT',
mode: 'PLAN',
intent: 'HONEY_SUPER',
reasonCode: 'NEXT_STEPS_E2E_HONEY_SUPER',
dueDate: d('2026-05-14'),
}),
],
});
const view = await nextSteps.forColony(
colonyId,
users.full.id,
EXAMPLE_DAY,
);
const titles = view.steps.map((step) => step.title);
expect(titles).toContain('Durchsicht mit Schwarmkontrolle');
expect(view.steps.map((step) => step.intent)).toContain('HONEY_SUPER');
const merged = view.steps.find((step) =>
step.title.startsWith('Durchsicht'),
);
expect(merged?.suggestionIds).toHaveLength(2);
expect(merged?.dismissible).toBe(true);
});
it('next steps never carry a varroa reason without varroa:read', async () => {
await prisma.suggestion.createMany({ data: exampleSuggestions() });
const full = await get(
users.full.token,
`/colonies/${colonyId}/next-steps`,
).expect(200);
const restricted = await get(
users.noVarroa.token,
`/colonies/${colonyId}/next-steps`,
).expect(200);
const fullBody = full.body as NextStepsBody;
const restrictedBody = restricted.body as NextStepsBody;
expect(fullBody.steps.map((step) => step.intent)).toContain('VARROA_TREAT');
expect(restrictedBody.steps.map((step) => step.intent)).not.toContain(
'VARROA_TREAT',
);
expect(JSON.stringify(restrictedBody)).not.toMatch(/Milben|Behandlung/);
});
it('a user without tasks:read gets no suggestion steps and no ACCEPT action', async () => {
await prisma.suggestion.createMany({ data: exampleSuggestions() });
await prisma.task.create({
data: {
title: 'Wachs einschmelzen',
relatedType: 'COLONY',
relatedId: colonyId,
},
});
const noTasks = await get(
users.noTasks.token,
`/colonies/${colonyId}/next-steps`,
).expect(200);
const noCreate = await get(
users.noCreate.token,
`/colonies/${colonyId}/next-steps`,
).expect(200);
const hidden = noTasks.body as NextStepsBody;
expect(hidden.steps).toEqual([]);
// The inspection date is not a task thing: it stays.
expect(hidden.segment).toEqual(expect.any(String));
const limited = noCreate.body as NextStepsBody;
expect(limited.steps.length).toBeGreaterThan(0);
expect(limited.steps.map((step) => step.action.type)).not.toContain(
'ACCEPT',
);
expect(
limited.steps.find((step) => step.intent === 'VARROA_TREAT')?.action,
).toMatchObject({ type: 'CAPTURE', form: 'treatment' });
});
it('withdrawalUntil appears as a hint, not as a step', async () => {
const today = berlinToday();
await prisma.treatment.create({
data: {
colonyId,
userId: recorderId,
date: d(addDays(today, -3)),
product: 'Oxalsäure',
withdrawalPeriodDays: 30,
},
});
const full = await get(
users.full.token,
`/colonies/${colonyId}/next-steps`,
).expect(200);
const readerWithoutTasks = await get(
users.noTasks.token,
`/colonies/${colonyId}/next-steps`,
).expect(200);
const body = full.body as NextStepsBody;
expect(body.withdrawalUntil).toBe(addDays(today, 27));
expect(body.steps).toEqual([]);
expect((readerWithoutTasks.body as NextStepsBody).withdrawalUntil).toBe(
addDays(today, 27),
);
});
it('answers 404 for an unknown colony and 403 without colonies:read', async () => {
await get(users.full.token, '/colonies/does-not-exist/next-steps').expect(
404,
);
const noColonies = await createUser('nocolonies', ['tasks:read']);
await get(noColonies.token, `/colonies/${colonyId}/next-steps`).expect(403);
await get(noColonies.token, '/autopilot/today').expect(403);
});
it('GET /autopilot/today lists due inspections per apiary and hides tasks without tasks:read', async () => {
await prisma.suggestion.createMany({ data: exampleSuggestions() });
const full = await nextSteps.today(users.full.id, EXAMPLE_DAY);
const entry = full.inspectionDue.find((item) => item.apiaryId === apiaryId);
expect(entry).toMatchObject({
apiaryName: `Next steps ${suffix}`,
segment: 'SCHWARMZEIT',
intervalDays: 7,
colonies: [
{
id: colonyId,
code: `B3-${suffix}`,
dueOn: '2026-05-11',
overdueDays: 1,
},
],
});
expect(full.seasons).toContainEqual(
expect.objectContaining({ apiaryId, segment: 'SCHWARMZEIT' }),
);
expect(full.suggestionsOpen).toBeGreaterThanOrEqual(4);
expect(full.tasksVisible).toBe(true);
const noTasks = await get(users.noTasks.token, '/autopilot/today').expect(
200,
);
const hidden = noTasks.body as TodayBody;
expect(hidden).toMatchObject({
nextStep: null,
suggestionsOpen: 0,
tasksVisible: false,
});
expect(hidden.seasons.map((season) => season.apiaryId)).toContain(apiaryId);
const allowed = await get(users.full.token, '/autopilot/today').expect(200);
const body = allowed.body as TodayBody;
expect(body.tasksVisible).toBe(true);
expect(body.suggestionsOpen).toBeGreaterThanOrEqual(4);
expect(body.nextStep).not.toBeNull();
});
});
@@ -182,6 +182,35 @@ describe('Notifications (e2e)', () => {
).toBe(false); ).toBe(false);
}); });
it('deleting a user removes their notifications and settings', async () => {
const user = await prisma.user.create({
data: {
email: `notifications-e2e-cascade-${suffix}@example.com`,
passwordHash: 'not-used',
name: 'Cascade',
role: { connect: { key: roleKey } },
},
});
await prisma.notification.create({
data: {
userId: user.id,
priority: 'INFO',
category: 'SYSTEM',
message: 'weg',
},
});
await prisma.notificationPreference.create({ data: { userId: user.id } });
await prisma.user.delete({ where: { id: user.id } });
expect(
await prisma.notification.count({ where: { userId: user.id } }),
).toBe(0);
expect(
await prisma.notificationPreference.count({ where: { userId: user.id } }),
).toBe(0);
});
it('reading is per user: marking one copy read leaves the other unread', async () => { it('reading is per user: marking one copy read leaves the other unread', async () => {
const [first, second] = await notifications.recordForUsers( const [first, second] = await notifications.recordForUsers(
[ids.beekeeper, ids.staff], [ids.beekeeper, ids.staff],
@@ -203,7 +203,12 @@ describe('Winter checks (e2e)', () => {
'ColonyStatusChanged', 'ColonyStatusChanged',
'ColonyEnded', 'ColonyEnded',
]); ]);
expect(events[1].payload).toEqual({ status: 'DECEASED' }); expect(events[1].payload).toEqual({
status: 'DECEASED',
endedOn: '2002-03-20',
releasedQueenId: null,
via: 'WINTER_CHECK',
});
expect( expect(
await prisma.domainEvent.count({ where: { aggregateId: survivorId } }), await prisma.domainEvent.count({ where: { aggregateId: survivorId } }),
).toBe(0); ).toBe(0);
@@ -0,0 +1,644 @@
import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
import { CalendarSyncService } from '../src/calendar-sync/calendar-sync.service';
import { CalendarSyncSettingsService } from '../src/calendar-sync/calendar-sync-settings.service';
import { DomainEventsService } from '../src/domain-events/domain-events.service';
import { PrismaService } from '../src/prisma/prisma.service';
import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
import { drainDomainEvents } from './utils/domain-events';
interface Warning {
code: string;
severity: string;
message: string;
refs: { type: string; id: string }[];
}
interface WithWarningsBody {
id: string;
warnings: Warning[];
}
/**
* The withdrawal (Wartezeit) chain against a real PostgreSQL: a harvest or
* a bottling inside a treatment's window is written and answered with a
* warning, leaves one notification per harvest and colony, the end of a
* window tells that the colony may be harvested again, and the window
* reaches the M365 calendar as a span. The notifications fan out to every
* user holding the permissions, so the assertions only look at the reader
* created here and every block removes the rows it caused.
*/
describe('Withdrawal chain (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
let events: DomainEventsService;
let calendarSync: CalendarSyncService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
const password = 'SuperSecret123!';
let apiaryId: string;
let adminId: string;
let readerId: string;
let roleId: string;
const colonyIds: string[] = [];
const harvestIds: string[] = [];
const treatmentIds: string[] = [];
const bottlingIds: string[] = [];
const extractionIds: string[] = [];
const honeyBatchIds: string[] = [];
const rootIds: string[] = [];
const server = () => app.getHttpServer();
const asAdmin = { Authorization: '' };
async function colonyWith(
label: string,
): Promise<{ id: string; code: string }> {
const colony = await prisma.colony.create({
data: {
code: `WD-${label}-${suffix}`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
},
});
colonyIds.push(colony.id);
return { id: colony.id, code: colony.code };
}
async function treat(
colonyId: string,
day: string,
withdrawalPeriodDays: number | null,
): Promise<string> {
const treatment = await prisma.treatment.create({
data: {
colonyId,
userId: adminId,
date: new Date(day.includes('T') ? day : `${day}T00:00:00.000Z`),
product: 'Oxuvar',
withdrawalPeriodDays,
},
});
treatmentIds.push(treatment.id);
return treatment.id;
}
async function postHarvest(colonyId: string | string[], day: string) {
const response = await request(server())
.post('/api/v1/harvests')
.set(asAdmin)
.send({
apiaryId,
date: `${day}T00:00:00.000Z`,
colonyIds: Array.isArray(colonyId) ? colonyId : [colonyId],
})
.expect(201);
const body = response.body as WithWarningsBody;
harvestIds.push(body.id);
rootIds.push(body.id);
return body;
}
const conflictRowsOf = (harvestId: string, colonyId: string) =>
prisma.notification.findMany({
where: {
userId: readerId,
dedupeKey: {
startsWith: `withdrawal-conflict:${harvestId}:${colonyId}:`,
},
},
});
/** Removes what this file's chain wrote to anybody, so other files' users stay clean. */
async function purgeNotifications(): Promise<void> {
await prisma.notification.deleteMany({
where: {
category: 'WITHDRAWAL',
OR: [
{ relatedId: { in: [...harvestIds, ...colonyIds] } },
...treatmentIds.map((id) => ({
dedupeKey: { startsWith: `withdrawal-ended:${id}:` },
})),
],
},
});
}
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
events = app.get(DomainEventsService);
calendarSync = app.get(CalendarSyncService);
const passwordHash = await bcrypt.hash(password, 12);
const admin = await prisma.user.create({
data: {
email: `withdrawal-e2e-admin-${suffix}@example.com`,
passwordHash,
name: 'Withdrawal admin',
role: { connect: { key: 'ADMIN' } },
},
});
adminId = admin.id;
asAdmin.Authorization = `Bearer ${(await login(app, admin.email, password)).accessToken}`;
const role = await prisma.role.create({
data: {
key: `WD_READER_${suffix}`,
name: 'Wartezeit Leser',
permissions: {
create: ['harvests:read', 'harvests:create', 'treatments:read'].map(
(key) => ({ permission: { connect: { key } } }),
),
},
},
});
roleId = role.id;
const reader = await prisma.user.create({
data: {
email: `withdrawal-e2e-reader-${suffix}@example.com`,
passwordHash,
name: 'Withdrawal reader',
roleId,
},
});
readerId = reader.id;
apiaryId = (
await prisma.apiary.create({ data: { name: `Wartezeit ${suffix}` } })
).id;
});
afterAll(async () => {
await purgeNotifications();
const roots = await prisma.domainEvent.findMany({
where: { aggregateId: { in: rootIds } },
select: { correlationId: true },
});
await prisma.domainEvent.deleteMany({
where: {
OR: [
{ aggregateId: { in: rootIds } },
{ correlationId: { in: roots.map((root) => root.correlationId) } },
],
},
});
await prisma.calendarSyncOutboxEntry.deleteMany({
where: {
sourceId: { in: [...treatmentIds, ...harvestIds] },
},
});
await prisma.task.deleteMany({
where: {
OR: [
{ relatedId: { in: colonyIds } },
{ sourceSuggestion: { colonyId: { in: colonyIds } } },
],
},
});
await prisma.suggestion.deleteMany({
where: { colonyId: { in: colonyIds } },
});
await prisma.auditLog.deleteMany({
where: {
OR: [
{ userId: { in: [adminId, readerId] } },
{
entityId: {
in: [...harvestIds, ...bottlingIds, ...treatmentIds],
},
},
...colonyIds.map((colonyId) => ({
entityType: 'Suggestion',
afterData: { path: ['colonyId'], equals: colonyId },
})),
],
},
});
await prisma.bottlingBatch.deleteMany({
where: { honeyBatchId: { in: honeyBatchIds } },
});
await prisma.honeyBatch.deleteMany({
where: { id: { in: honeyBatchIds } },
});
await prisma.extractionHarvest.deleteMany({
where: { extractionId: { in: extractionIds } },
});
await prisma.extraction.deleteMany({
where: { id: { in: extractionIds } },
});
await prisma.harvestColony.deleteMany({
where: { harvestId: { in: harvestIds } },
});
await prisma.harvest.deleteMany({ where: { id: { in: harvestIds } } });
await prisma.treatment.deleteMany({ where: { id: { in: treatmentIds } } });
await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } });
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
await prisma.refreshToken.deleteMany({
where: { userId: { in: [adminId, readerId] } },
});
await prisma.user.deleteMany({
where: { id: { in: [adminId, readerId] } },
});
await prisma.role.deleteMany({ where: { id: roleId } });
await app.close();
});
describe('harvest and bottling inside the window', () => {
let colony: { id: string; code: string };
let harvestId: string;
afterAll(purgeNotifications);
beforeAll(async () => {
colony = await colonyWith('A');
// 2026-07-10 + 20 days: harvestable again from 2026-07-30.
await treat(colony.id, '2026-07-10', 20);
});
it('a harvest inside the window answers 201 with a warning and leaves one notification', async () => {
const harvest = await postHarvest(colony.id, '2026-07-20');
harvestId = harvest.id;
expect(harvest.warnings).toEqual([
{
code: 'WITHDRAWAL_ACTIVE',
severity: 'WARNUNG',
message: `${colony.code}: Wartezeit nach Oxuvar endet am 30.07.2026.`,
refs: [
{ type: 'Colony', id: colony.id },
{ type: 'Treatment', id: treatmentIds[0] },
{ type: 'Harvest', id: harvestId },
],
},
]);
// The request wrote the harvest all the same.
expect(await prisma.harvest.count({ where: { id: harvestId } })).toBe(1);
await drainDomainEvents(app, [harvestId]);
await drainDomainEvents(app, [harvestId]);
const rows = await conflictRowsOf(harvestId, colony.id);
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
priority: 'WARNUNG',
category: 'WITHDRAWAL',
message: `Wartezeit: ${colony.code} am 20.07. geerntet, Wartezeit nach Oxuvar endet am 30.07.2026.`,
relatedType: 'Harvest',
relatedId: harvestId,
link: `/harvests/${harvestId}`,
read: false,
});
expect(rows[0].requiredPermissions).toEqual(
expect.arrayContaining(['harvests:read', 'treatments:read']),
);
});
it('a bottling of that harvest warns again but notifies no second time', async () => {
const extraction = await prisma.extraction.create({
data: { date: new Date('2026-07-21T00:00:00.000Z'), userId: adminId },
});
extractionIds.push(extraction.id);
await prisma.extractionHarvest.create({
data: { extractionId: extraction.id, harvestId },
});
const honeyBatch = await prisma.honeyBatch.create({
data: {
batchNumber: `WD-HC-${suffix}`,
extractionId: extraction.id,
amount: 20,
},
});
honeyBatchIds.push(honeyBatch.id);
await drainDomainEvents(app, [harvestId]);
expect(await conflictRowsOf(harvestId, colony.id)).toHaveLength(1);
const response = await request(server())
.post('/api/v1/bottling-batches')
.set(asAdmin)
.send({
honeyBatchId: honeyBatch.id,
date: '2026-07-22T00:00:00.000Z',
jarSize: 500,
jarCount: 4,
bestBeforeDate: '2028-07-22T00:00:00.000Z',
lotNumber: `WD-LOT-${suffix}`,
})
.expect(201);
const bottling = response.body as WithWarningsBody;
bottlingIds.push(bottling.id);
rootIds.push(bottling.id);
expect(bottling.warnings).toHaveLength(1);
expect(bottling.warnings[0]).toMatchObject({
code: 'WITHDRAWAL_ACTIVE',
message: `${colony.code}: Wartezeit nach Oxuvar endet am 30.07.2026.`,
});
await drainDomainEvents(app, [bottling.id]);
expect(await conflictRowsOf(harvestId, colony.id)).toHaveLength(1);
});
it('a harvest on the first harvestable day answers an empty warning list', async () => {
const harvest = await postHarvest(colony.id, '2026-07-30');
expect(harvest.warnings).toEqual([]);
await drainDomainEvents(app, [harvest.id]);
expect(await conflictRowsOf(harvest.id, colony.id)).toHaveLength(0);
});
it('a corrected harvest that moves out of the window answers an empty list; into it, a warning', async () => {
const harvest = await postHarvest(colony.id, '2026-09-01');
expect(harvest.warnings).toEqual([]);
const moved = await request(server())
.patch(`/api/v1/harvests/${harvest.id}`)
.set(asAdmin)
.send({ date: '2026-07-12T00:00:00.000Z' })
.expect(200);
expect((moved.body as WithWarningsBody).warnings).toHaveLength(1);
const back = await request(server())
.patch(`/api/v1/harvests/${harvest.id}`)
.set(asAdmin)
.send({ date: '2026-09-02T00:00:00.000Z' })
.expect(200);
expect((back.body as WithWarningsBody).warnings).toEqual([]);
});
});
describe('harvest of several colonies', () => {
afterAll(purgeNotifications);
it('warns and notifies only for the colony that is inside a window', async () => {
const inside = await colonyWith('D1');
const outside = await colonyWith('D2');
await treat(inside.id, '2026-07-10', 20);
const harvest = await postHarvest([inside.id, outside.id], '2026-07-20');
expect(harvest.warnings).toHaveLength(1);
expect(harvest.warnings[0].refs).toContainEqual({
type: 'Colony',
id: inside.id,
});
expect(harvest.warnings[0].message.startsWith(`${inside.code}:`)).toBe(
true,
);
await drainDomainEvents(app, [harvest.id]);
expect(await conflictRowsOf(harvest.id, inside.id)).toHaveLength(1);
expect(await conflictRowsOf(harvest.id, outside.id)).toHaveLength(0);
});
});
describe('treatment entered after the harvest', () => {
afterAll(purgeNotifications);
it('a backdated treatment notifies the harvests it covers, once; a correction that extends the window adds the newly covered ones', async () => {
const colony = await colonyWith('E');
const early = await postHarvest(colony.id, '2026-07-20');
const late = await postHarvest(colony.id, '2026-08-05');
expect(early.warnings).toEqual([]);
await drainDomainEvents(app, [early.id, late.id]);
// The early harvest was bottled before the treatment was entered.
const extraction = await prisma.extraction.create({
data: { date: new Date('2026-07-21T00:00:00.000Z'), userId: adminId },
});
extractionIds.push(extraction.id);
await prisma.extractionHarvest.create({
data: { extractionId: extraction.id, harvestId: early.id },
});
const honeyBatch = await prisma.honeyBatch.create({
data: {
batchNumber: `WD-HC2-${suffix}`,
extractionId: extraction.id,
amount: 5,
},
});
honeyBatchIds.push(honeyBatch.id);
await prisma.bottlingBatch.create({
data: {
honeyBatchId: honeyBatch.id,
userId: adminId,
date: new Date('2026-07-22T00:00:00.000Z'),
jarSize: 500,
jarCount: 2,
totalAmount: 1,
bestBeforeDate: new Date('2028-07-22T00:00:00.000Z'),
lotNumber: `WD-LOT2-${suffix}`,
},
});
const created = await request(server())
.post('/api/v1/treatments')
.set(asAdmin)
.send({
colonyId: colony.id,
date: '2026-07-10T00:00:00.000Z',
product: 'Oxuvar',
withdrawalPeriodDays: 20,
})
.expect(201);
const treatmentId = (created.body as { id: string }).id;
treatmentIds.push(treatmentId);
rootIds.push(treatmentId);
await drainDomainEvents(app, [treatmentId]);
// 2026-07-10 + 20 = harvestable from 2026-07-30: only the early harvest.
const earlyRows = await conflictRowsOf(early.id, colony.id);
expect(earlyRows).toHaveLength(1);
expect(earlyRows[0].message).toContain(
`Bereits abgefüllt: Los WD-LOT2-${suffix}.`,
);
expect(await conflictRowsOf(late.id, colony.id)).toHaveLength(0);
await request(server())
.patch(`/api/v1/treatments/${treatmentId}`)
.set(asAdmin)
.send({ withdrawalPeriodDays: 40 })
.expect(200);
await drainDomainEvents(app, [treatmentId]);
// Until 2026-08-19: the late harvest is covered now; the early one
// is not notified a second time.
expect(await conflictRowsOf(early.id, colony.id)).toHaveLength(1);
expect(await conflictRowsOf(late.id, colony.id)).toHaveLength(1);
});
});
describe('end of a window', () => {
afterAll(purgeNotifications);
const endedEvent = (
treatmentId: string,
colonyId: string,
endedOn: string,
) =>
prisma.$transaction((tx) =>
events.record(tx, {
type: 'WithdrawalEnded',
aggregateType: 'Treatment',
aggregateId: treatmentId,
payload: { colonyId, endedOn },
}),
);
const readyRows = (colonyId: string) =>
prisma.notification.findMany({
where: {
userId: readerId,
relatedId: colonyId,
category: 'WITHDRAWAL',
dedupeKey: { startsWith: 'withdrawal-ended:' },
},
});
it('WithdrawalEnded yields one INFO only when no other window is running', async () => {
const colony = await colonyWith('B');
// First window: 2026-06-01 + 10 = 2026-06-11. Second: 2026-06-05 + 20 = 2026-06-25.
const first = await treat(colony.id, '2026-06-01', 10);
const second = await treat(colony.id, '2026-06-05', 20);
rootIds.push(first, second);
await endedEvent(first, colony.id, '2026-06-11');
await drainDomainEvents(app, [first]);
expect(await readyRows(colony.id)).toHaveLength(0);
// Recorded twice: the job could deliver the same end again.
await endedEvent(second, colony.id, '2026-06-25');
await endedEvent(second, colony.id, '2026-06-25');
await drainDomainEvents(app, [second]);
const rows = await readyRows(colony.id);
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
priority: 'INFO',
category: 'WITHDRAWAL',
message: `${colony.code} ist wieder erntefähig. Wartezeit nach Oxuvar endete am 25.06.2026.`,
relatedType: 'Colony',
link: `/colonies/${colony.id}`,
});
expect(rows[0].requiredPermissions).toEqual(
expect.arrayContaining(['harvests:create', 'treatments:read']),
);
});
});
describe('end of a window across the Berlin day boundary', () => {
afterAll(purgeNotifications);
it('takes the end day from the Berlin day of a treatment stored with a time part', async () => {
const colony = await colonyWith('F');
// 2026-06-01T22:30Z is 00:30 on 2026-06-02 in Berlin; one day later
// the period ends on 2026-06-03 (the plain UTC day would say 06-02).
const id = await treat(colony.id, '2026-06-01T22:30:00.000Z', 1);
rootIds.push(id);
const record = (endedOn: string) =>
prisma.$transaction((tx) =>
events.record(tx, {
type: 'WithdrawalEnded',
aggregateType: 'Treatment',
aggregateId: id,
payload: { colonyId: colony.id, endedOn },
}),
);
await record('2026-06-02');
await drainDomainEvents(app, [id]);
expect(
await prisma.notification.count({
where: { userId: readerId, relatedId: colony.id },
}),
).toBe(0);
await record('2026-06-03');
await drainDomainEvents(app, [id]);
const rows = await prisma.notification.findMany({
where: { userId: readerId, relatedId: colony.id },
});
expect(rows).toHaveLength(1);
expect(rows[0].message).toContain('endete am 03.06.2026');
expect(rows[0].message).toContain(colony.code);
});
});
describe('calendar entry', () => {
// The settings spy is this file's only mock; the shared singleton row is
// never touched.
afterEach(() => {
jest.restoreAllMocks();
});
it('a treatment with withdrawal days leaves a WITHDRAWAL calendar entry; removing the days unregisters it', async () => {
const colony = await colonyWith('C');
jest
.spyOn(app.get(CalendarSyncSettingsService), 'get')
.mockResolvedValue({
id: 'singleton',
enabled: true,
groupId: 'group-e2e',
syncTask: true,
syncInspection: true,
syncTreatment: true,
syncFeeding: true,
syncHarvest: true,
syncQueenAssignment: true,
syncWithdrawal: true,
updatedAt: new Date(),
});
const created = await request(server())
.post('/api/v1/treatments')
.set(asAdmin)
.send({
colonyId: colony.id,
date: '2026-08-03T00:00:00.000Z',
product: 'Oxuvar',
withdrawalPeriodDays: 20,
})
.expect(201);
const treatmentId = (created.body as { id: string }).id;
treatmentIds.push(treatmentId);
rootIds.push(treatmentId);
await drainDomainEvents(app, [treatmentId]);
const entry = await prisma.calendarSyncOutboxEntry.findUniqueOrThrow({
where: {
sourceType_sourceId: {
sourceType: 'WITHDRAWAL',
sourceId: treatmentId,
},
},
});
expect(entry.status).toBe('PENDING');
const buildEventInput = (id: string) =>
(
calendarSync as unknown as {
buildEventInput(type: string, id: string): Promise<unknown>;
}
).buildEventInput('WITHDRAWAL', id);
// Graph's end day is exclusive: the first harvestable day.
expect(await buildEventInput(treatmentId)).toEqual({
title: `Wartezeit ${colony.code} · Oxuvar`,
date: new Date('2026-08-03T00:00:00.000Z'),
endDate: new Date('2026-08-23T00:00:00.000Z'),
});
await request(server())
.patch(`/api/v1/treatments/${treatmentId}`)
.set(asAdmin)
.send({ withdrawalPeriodDays: null })
.expect(200);
await drainDomainEvents(app, [treatmentId]);
const after = await prisma.calendarSyncOutboxEntry.findUniqueOrThrow({
where: { id: entry.id },
});
// Enqueued again: the worker finds no span and removes the event.
expect(after.revision).toBeGreaterThan(entry.revision);
expect(after.status).toBe('PENDING');
expect(await buildEventInput(treatmentId)).toBeNull();
});
});
});
@@ -1,9 +1,11 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, within } from "@testing-library/react"; import { fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ColonyActions } from "./colony-actions"; import { ColonyActions } from "./colony-actions";
import { fetchColonies, mergeColony } from "@/lib/colonies/api"; import { fetchColonies, mergeColony, splitColony } from "@/lib/colonies/api";
import { ApiError } from "@/lib/auth/api-client";
import { todayLocalIsoDate } from "@/lib/dates";
import type { Colony, ColonyStatus } from "@/lib/colonies/types"; import type { Colony, ColonyStatus } from "@/lib/colonies/types";
vi.mock("@/lib/colonies/api", () => ({ vi.mock("@/lib/colonies/api", () => ({
@@ -31,6 +33,7 @@ function colony(id: string, code: string, status: ColonyStatus = "ACTIVE"): Colo
colonyType: "PRODUCTION_COLONY", colonyType: "PRODUCTION_COLONY",
status, status,
strength: null, strength: null,
occupiedFrames: null,
broodStatus: null, broodStatus: null,
feedStatus: null, feedStatus: null,
healthStatus: null, healthStatus: null,
@@ -140,3 +143,106 @@ describe("ColonyActions – colonies that need attention", () => {
expect(options.map((option) => option.textContent)).toEqual(["V-02", "V-03 (Weisellos)"]); expect(options.map((option) => option.textContent)).toEqual(["V-02", "V-03 (Weisellos)"]);
}); });
}); });
describe("ColonyActions – split", () => {
beforeEach(() => {
vi.mocked(fetchColonies).mockResolvedValue([target]);
vi.mocked(splitColony).mockReset();
vi.mocked(splitColony).mockResolvedValue(other);
});
async function openSplit() {
renderActions();
await userEvent.click(screen.getByRole("button", { name: "Ableger bilden" }));
return screen.findByRole("dialog");
}
const originGroup = (dialog: HTMLElement) =>
within(dialog).getByRole("radiogroup", { name: /Woher kommt die Königin\?/ });
it("the split dialog sends date and queenSource with nothing preselected", async () => {
const dialog = await openSplit();
const group = originGroup(dialog);
const radios = within(group).getAllByRole("radio");
expect(radios.map((radio) => radio.textContent)).toEqual([
"Eigene Zellen ziehen",
"Weiselzelle",
"Königin",
"Weiß ich noch nicht",
]);
expect(radios.every((radio) => radio.getAttribute("aria-checked") === "false")).toBe(true);
const date = within(dialog).getByLabelText(/Datum des Ablegers/);
expect(date).toHaveValue(todayLocalIsoDate());
expect(date).toHaveAttribute("max", todayLocalIsoDate());
await userEvent.type(within(dialog).getByLabelText(/Code des Ablegers/), "N-01");
fireEvent.change(date, { target: { value: "2026-10-10" } });
await userEvent.click(within(group).getByRole("radio", { name: "Eigene Zellen ziehen" }));
expect(
within(dialog).getByText("Es entsteht ein Plan: Zellen prüfen, Begattung, Stifte."),
).toBeInTheDocument();
await userEvent.click(within(dialog).getByRole("button", { name: "Ableger anlegen" }));
expect(splitColony).toHaveBeenCalledWith("colony-1", {
code: "N-01",
colonyType: "NUCLEUS_COLONY",
date: "2026-10-10",
queenSource: "RAISE_OWN",
});
});
it("states the consequence of each origin", async () => {
const dialog = await openSplit();
const group = originGroup(dialog);
await userEvent.click(within(group).getByRole("radio", { name: "Weiselzelle" }));
expect(within(dialog).getByText("Es entsteht ein Plan: Stifte prüfen.")).toBeInTheDocument();
await userEvent.click(within(group).getByRole("radio", { name: "Königin" }));
expect(
within(dialog).getByText("Es entsteht ein Plan: Annahme der Königin prüfen."),
).toBeInTheDocument();
await userEvent.click(within(group).getByRole("radio", { name: "Weiß ich noch nicht" }));
expect(within(dialog).getByText("Ohne Angabe entsteht kein Plan.")).toBeInTheDocument();
});
it("„Weiß ich noch nicht“ sends no queenSource", async () => {
const dialog = await openSplit();
await userEvent.type(within(dialog).getByLabelText(/Code des Ablegers/), "N-02");
await userEvent.click(
within(originGroup(dialog)).getByRole("radio", { name: "Weiß ich noch nicht" }),
);
await userEvent.click(within(dialog).getByRole("button", { name: "Ableger anlegen" }));
const [, input] = vi.mocked(splitColony).mock.calls[0];
expect(input).toMatchObject({ code: "N-02", date: todayLocalIsoDate() });
expect(input).not.toHaveProperty("queenSource");
});
it("a future date shows the server message at the date field", async () => {
vi.mocked(splitColony).mockRejectedValue(
new ApiError(400, "Das Datum liegt in der Zukunft."),
);
const dialog = await openSplit();
await userEvent.type(within(dialog).getByLabelText(/Code des Ablegers/), "N-03");
await userEvent.click(within(dialog).getByRole("button", { name: "Ableger anlegen" }));
const message = await within(dialog).findByText("Das Datum liegt in der Zukunft.");
const date = within(dialog).getByLabelText(/Datum des Ablegers/);
expect(date).toHaveAttribute("aria-invalid", "true");
expect(date.getAttribute("aria-describedby")).toContain(message.id);
});
it("asks for the date when it was cleared", async () => {
const dialog = await openSplit();
await userEvent.type(within(dialog).getByLabelText(/Code des Ablegers/), "N-04");
fireEvent.change(within(dialog).getByLabelText(/Datum des Ablegers/), { target: { value: "" } });
await userEvent.click(within(dialog).getByRole("button", { name: "Ableger anlegen" }));
expect(within(dialog).getByText("Bitte ein Datum angeben.")).toBeInTheDocument();
expect(splitColony).not.toHaveBeenCalled();
});
});
@@ -10,8 +10,10 @@ import {
reactivateColony, reactivateColony,
splitColony, splitColony,
} from "@/lib/colonies/api"; } from "@/lib/colonies/api";
import { Colony, ColonyType } from "@/lib/colonies/types"; import { Colony, ColonyType, SplitQueenSource } from "@/lib/colonies/types";
import { COLONY_TYPES, COLONY_TYPE_LABELS } from "@/lib/colonies/labels"; import { COLONY_TYPES, COLONY_TYPE_LABELS, SPLIT_QUEEN_SOURCE_LABELS } from "@/lib/colonies/labels";
import { ApiError } from "@/lib/auth/api-client";
import { todayLocalIsoDate } from "@/lib/dates";
import { isLivingColony } from "@/lib/colonies/status"; import { isLivingColony } from "@/lib/colonies/status";
import { fetchApiaries } from "@/lib/apiaries/api"; import { fetchApiaries } from "@/lib/apiaries/api";
import { fetchColonies } from "@/lib/colonies/api"; import { fetchColonies } from "@/lib/colonies/api";
@@ -21,6 +23,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { ConfirmDialog } from "@/components/confirm-dialog"; import { ConfirmDialog } from "@/components/confirm-dialog";
import { Field } from "@/components/form/field"; import { Field } from "@/components/form/field";
import { ChoiceGroup, choiceOptions, type ChoiceOption } from "@/components/form/choice-group";
import { LivingColonySelectField } from "@/components/colony-select-field"; import { LivingColonySelectField } from "@/components/colony-select-field";
import { import {
Dialog, Dialog,
@@ -136,24 +139,63 @@ function MoveDialog({ colony }: { colony: Colony }) {
); );
} }
/** "" = not answered yet; "UNKNOWN" = the beekeeper said they do not know. */
type QueenOriginChoice = SplitQueenSource | "UNKNOWN" | "";
const QUEEN_ORIGIN_OPTIONS: readonly ChoiceOption<QueenOriginChoice>[] = [
...choiceOptions<SplitQueenSource>(SPLIT_QUEEN_SOURCE_LABELS),
{ value: "UNKNOWN", label: "Weiß ich noch nicht" },
];
/** What follows from the choice: the chain of checks the server plans. */
const QUEEN_ORIGIN_CONSEQUENCE: Record<QueenOriginChoice, string> = {
RAISE_OWN: "Es entsteht ein Plan: Zellen prüfen, Begattung, Stifte.",
QUEEN_CELL: "Es entsteht ein Plan: Stifte prüfen.",
QUEEN: "Es entsteht ein Plan: Annahme der Königin prüfen.",
UNKNOWN: "Ohne Angabe entsteht kein Plan.",
"": "Ohne Angabe entsteht kein Plan.",
};
function queenSourceOf(choice: QueenOriginChoice): SplitQueenSource | undefined {
return choice === "" || choice === "UNKNOWN" ? undefined : choice;
}
function SplitDialog({ colony }: { colony: Colony }) { function SplitDialog({ colony }: { colony: Colony }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [code, setCode] = useState(""); const [code, setCode] = useState("");
const [colonyType, setColonyType] = useState<ColonyType>("NUCLEUS_COLONY"); const [colonyType, setColonyType] = useState<ColonyType>("NUCLEUS_COLONY");
const [date, setDate] = useState(todayLocalIsoDate);
// The origin of the queen is an observation: nothing is preselected.
const [origin, setOrigin] = useState<QueenOriginChoice>("");
const [codeError, setCodeError] = useState<string | null>(null); const [codeError, setCodeError] = useState<string | null>(null);
const [dateError, setDateError] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const mutation = useMutation({ const mutation = useMutation({
mutationFn: () => splitColony(colony.id, { code, colonyType }), mutationFn: () => {
const queenSource = queenSourceOf(origin);
return splitColony(colony.id, {
code,
colonyType,
date,
...(queenSource ? { queenSource } : {}),
});
},
onSuccess: () => { onSuccess: () => {
invalidateColony(queryClient, colony.id); invalidateColony(queryClient, colony.id);
void queryClient.invalidateQueries({ queryKey: ["colonies"] }); void queryClient.invalidateQueries({ queryKey: ["colonies"] });
toast.success(`Ableger ${code} angelegt`); toast.success(`Ableger ${code} angelegt`);
setOpen(false); setOpen(false);
}, },
onError: (mutationError) => onError: (mutationError) => {
setError(errorMessage(mutationError, "Ableger konnte nicht angelegt werden.")), // The date's own complaint belongs at the date field, not in the footer.
if (mutationError instanceof ApiError && mutationError.status === 400 && /Datum/.test(mutationError.message)) {
setDateError(mutationError.message);
return;
}
setError(errorMessage(mutationError, "Ableger konnte nicht angelegt werden."));
},
}); });
function handleSubmit(event: FormEvent) { function handleSubmit(event: FormEvent) {
@@ -163,6 +205,10 @@ function SplitDialog({ colony }: { colony: Colony }) {
setCodeError("Bitte einen Code für den Ableger angeben."); setCodeError("Bitte einen Code für den Ableger angeben.");
return; return;
} }
if (!date) {
setDateError("Bitte ein Datum angeben.");
return;
}
mutation.mutate(); mutation.mutate();
} }
@@ -210,6 +256,41 @@ function SplitDialog({ colony }: { colony: Colony }) {
/> />
)} )}
</Field> </Field>
<Field label="Datum des Ablegers" required error={dateError}>
{({ id, describedBy, invalid }) => (
<Input
id={id}
type="date"
required
max={todayLocalIsoDate()}
className="h-11"
aria-describedby={describedBy}
aria-invalid={invalid}
value={date}
onChange={(event) => {
setDate(event.target.value);
setDateError(null);
}}
/>
)}
</Field>
<Field label="Woher kommt die Königin?" group>
{({ id, labelId }) => (
<>
<ChoiceGroup
id={id}
labelledBy={labelId}
options={QUEEN_ORIGIN_OPTIONS}
value={origin}
onChange={setOrigin}
className="grid-cols-1 sm:grid-cols-2"
/>
<p role="status" aria-live="polite" className="text-sm text-muted-foreground">
{QUEEN_ORIGIN_CONSEQUENCE[origin]}
</p>
</>
)}
</Field>
<ActionFooter error={error}> <ActionFooter error={error}>
<Button type="submit" size="touch" disabled={mutation.isPending}> <Button type="submit" size="touch" disabled={mutation.isPending}>
{mutation.isPending ? "Wird angelegt…" : "Ableger anlegen"} {mutation.isPending ? "Wird angelegt…" : "Ableger anlegen"}
@@ -21,6 +21,7 @@ const colony: Colony = {
colonyType: "PRODUCTION_COLONY", colonyType: "PRODUCTION_COLONY",
status: "ACTIVE", status: "ACTIVE",
strength: 7, strength: 7,
occupiedFrames: 8,
broodStatus: "GOOD", broodStatus: "GOOD",
feedStatus: "AVERAGE", feedStatus: "AVERAGE",
healthStatus: "CRITICAL", healthStatus: "CRITICAL",
@@ -31,11 +32,11 @@ const colony: Colony = {
deactivatedAt: null, deactivatedAt: null,
}; };
function renderCard() { function renderCard(subject: Colony = colony) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render( return render(
<QueryClientProvider client={client}> <QueryClientProvider client={client}>
<ColonyEditCard colony={colony} /> <ColonyEditCard colony={subject} />
</QueryClientProvider>, </QueryClientProvider>,
); );
} }
@@ -85,7 +86,7 @@ describe("ColonyEditCard", () => {
renderCard(); renderCard();
expect(screen.getByRole("combobox", { name: "Brutstatus" })).toHaveTextContent("Gut"); expect(screen.getByRole("combobox", { name: "Brutstatus" })).toHaveTextContent("Gut");
expect(screen.getByRole("combobox", { name: "Volksstärke" })).toHaveTextContent("7"); expect(screen.getByRole("combobox", { name: "Volksstärke (alt, Skala 1–10)" })).toHaveTextContent("7");
await choose(/^Status/, "Weisellos"); await choose(/^Status/, "Weisellos");
await choose(/^Volktyp/, "Ableger"); await choose(/^Volktyp/, "Ableger");
await choose(/Brutstatus/, "Kritisch"); await choose(/Brutstatus/, "Kritisch");
@@ -146,4 +147,85 @@ describe("ColonyEditCard", () => {
notes: "sanft", notes: "sanft",
}); });
}); });
it("„Besetzte Waben“ accepts 0–40 and sends the number", async () => {
renderCard();
const field = screen.getByLabelText(/^Besetzte Waben/);
expect(field).toHaveValue("8");
await userEvent.clear(field);
await userEvent.type(field, "0");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(vi.mocked(updateColony).mock.calls[0][1]).toMatchObject({ occupiedFrames: 0 });
});
it("caps „Besetzte Waben“ at 40", async () => {
renderCard();
const field = screen.getByLabelText(/^Besetzte Waben/);
await userEvent.clear(field);
await userEvent.type(field, "99");
expect(field).toHaveValue("40");
});
it("clearing „Besetzte Waben“ sends null", async () => {
renderCard();
await userEvent.clear(screen.getByLabelText(/^Besetzte Waben/));
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(vi.mocked(updateColony).mock.calls[0][1]).toMatchObject({ occupiedFrames: null });
});
it("shows the legacy strength field only when a value is stored", () => {
renderCard({ ...colony, strength: null });
expect(
screen.queryByRole("combobox", { name: "Volksstärke (alt, Skala 1–10)" }),
).not.toBeInTheDocument();
});
it("does not touch an unset legacy strength when saving", async () => {
renderCard({ ...colony, strength: null });
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(vi.mocked(updateColony).mock.calls[0][1]).not.toHaveProperty("strength");
});
it("words the feed status like the inspection form", async () => {
renderCard();
await userEvent.click(screen.getByRole("combobox", { name: "Futterstatus" }));
for (const name of ["Nicht erfasst", "Reichlich", "Ausreichend", "Mittel", "Niedrig", "Kritisch"]) {
expect(await screen.findByRole("option", { name })).toBeInTheDocument();
}
expect(screen.queryByRole("option", { name: "Sehr gut" })).not.toBeInTheDocument();
expect(screen.queryByRole("option", { name: "Gut" })).not.toBeInTheDocument();
});
});
describe("ColonyEditCard – legacy strength after clearing", () => {
it("does not resend strength once it was cleared and the card shows the saved colony", async () => {
vi.mocked(updateColony).mockReset().mockResolvedValue(colony);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const ui = (subject: Colony) => (
<QueryClientProvider client={client}>
<ColonyEditCard colony={subject} />
</QueryClientProvider>
);
const { rerender } = render(ui(colony));
await userEvent.click(screen.getByRole("combobox", { name: /Volksstärke/ }));
await userEvent.click(await screen.findByRole("option", { name: "Nicht erfasst" }));
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(vi.mocked(updateColony).mock.calls[0][1]).toMatchObject({ strength: null });
rerender(ui({ ...colony, strength: null }));
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(vi.mocked(updateColony).mock.calls[1][1]).not.toHaveProperty("strength");
});
}); });
@@ -6,6 +6,7 @@ import { toast } from "sonner";
import { updateColony } from "@/lib/colonies/api"; import { updateColony } from "@/lib/colonies/api";
import { fetchApiaries } from "@/lib/apiaries/api"; import { fetchApiaries } from "@/lib/apiaries/api";
import { Colony, ColonyCondition, ColonyStatus, ColonyType } from "@/lib/colonies/types"; import { Colony, ColonyCondition, ColonyStatus, ColonyType } from "@/lib/colonies/types";
import { FEED_CONDITION_LABELS } from "@/lib/colonies/condition";
import { import {
COLONY_CONDITIONS, COLONY_CONDITIONS,
COLONY_CONDITION_LABELS, COLONY_CONDITION_LABELS,
@@ -15,6 +16,7 @@ import {
COLONY_STRENGTH_LABELS, COLONY_STRENGTH_LABELS,
COLONY_TYPES, COLONY_TYPES,
COLONY_TYPE_LABELS, COLONY_TYPE_LABELS,
MAX_OCCUPIED_FRAMES,
} from "@/lib/colonies/labels"; } from "@/lib/colonies/labels";
import { errorMessage } from "@/lib/forms/error-message"; import { errorMessage } from "@/lib/forms/error-message";
import { emptyToNull } from "@/lib/forms/empty-to-null"; import { emptyToNull } from "@/lib/forms/empty-to-null";
@@ -25,6 +27,7 @@ import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { RecordSelect } from "@/components/form/record-select"; import { RecordSelect } from "@/components/form/record-select";
import { EnumSelect } from "@/components/form/enum-select"; import { EnumSelect } from "@/components/form/enum-select";
import { NumberStepper } from "@/components/form/number-stepper";
import { import {
Card, Card,
CardContent, CardContent,
@@ -40,25 +43,28 @@ function ConditionSelect({
label, label,
value, value,
onChange, onChange,
labels = COLONY_CONDITION_LABELS,
}: { }: {
id: string; id: string;
label: string; label: string;
value: ColonyCondition | null; value: ColonyCondition | null;
onChange: (value: ColonyCondition | null) => void; onChange: (value: ColonyCondition | null) => void;
/** How the five levels are worded; the feed scale has its own words. */
labels?: Record<ColonyCondition, string>;
}) { }) {
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor={id}>{label}</Label> <Label htmlFor={id}>{label}</Label>
<EnumSelect <EnumSelect
id={id} id={id}
labels={COLONY_CONDITION_LABELS} labels={labels}
values={COLONY_CONDITIONS} values={COLONY_CONDITIONS}
noneLabel={NOT_RECORDED_LABEL} noneLabel={NOT_RECORDED_LABEL}
value={value ?? ""} value={value ?? ""}
onChange={(next) => onChange(next || null)} onChange={(next) => onChange(next || null)}
renderOption={(condition) => ( renderOption={(condition) => (
<Badge className={COLONY_CONDITION_TONE_CLASS[condition]}> <Badge className={COLONY_CONDITION_TONE_CLASS[condition]}>
{COLONY_CONDITION_LABELS[condition]} {labels[condition]}
</Badge> </Badge>
)} )}
/> />
@@ -74,6 +80,7 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
const [colonyType, setColonyType] = useState<ColonyType>(colony.colonyType); const [colonyType, setColonyType] = useState<ColonyType>(colony.colonyType);
const [hiveType, setHiveType] = useState(colony.hiveType ?? ""); const [hiveType, setHiveType] = useState(colony.hiveType ?? "");
const [originText, setOriginText] = useState(colony.originText ?? ""); const [originText, setOriginText] = useState(colony.originText ?? "");
const [occupiedFrames, setOccupiedFrames] = useState(colony.occupiedFrames?.toString() ?? "");
const [strength, setStrength] = useState(colony.strength?.toString() ?? ""); const [strength, setStrength] = useState(colony.strength?.toString() ?? "");
const [broodStatus, setBroodStatus] = useState<ColonyCondition | null>(colony.broodStatus); const [broodStatus, setBroodStatus] = useState<ColonyCondition | null>(colony.broodStatus);
const [feedStatus, setFeedStatus] = useState<ColonyCondition | null>(colony.feedStatus); const [feedStatus, setFeedStatus] = useState<ColonyCondition | null>(colony.feedStatus);
@@ -97,7 +104,9 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
// Emptied fields go out as null - omitted, the old value would stay. // Emptied fields go out as null - omitted, the old value would stay.
hiveType: emptyToNull(hiveType), hiveType: emptyToNull(hiveType),
originText: emptyToNull(originText), originText: emptyToNull(originText),
strength: emptyToNull(strength, Number), occupiedFrames: emptyToNull(occupiedFrames, Number),
// The old 1-10 scale is only sent while a value is stored (to clear it).
...(colony.strength !== null ? { strength: emptyToNull(strength, Number) } : {}),
broodStatus, broodStatus,
feedStatus, feedStatus,
healthStatus, healthStatus,
@@ -187,15 +196,29 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
/> />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="colony-strength">Volksstärke</Label> <Label htmlFor="colony-occupied-frames">Besetzte Waben</Label>
<EnumSelect <NumberStepper
id="colony-strength" id="colony-occupied-frames"
labels={COLONY_STRENGTH_LABELS} value={occupiedFrames}
noneLabel={NOT_RECORDED_LABEL} onChange={setOccupiedFrames}
value={strength} max={MAX_OCCUPIED_FRAMES}
onChange={setStrength} unitLabel="besetzte Waben"
decrementLabel="Eine besetzte Wabe weniger"
incrementLabel="Eine besetzte Wabe mehr"
/> />
</div> </div>
{colony.strength !== null ? (
<div className="flex flex-col gap-2">
<Label htmlFor="colony-strength">Volksstärke (alt, Skala 1–10)</Label>
<EnumSelect
id="colony-strength"
labels={COLONY_STRENGTH_LABELS}
noneLabel={NOT_RECORDED_LABEL}
value={strength}
onChange={setStrength}
/>
</div>
) : null}
<ConditionSelect <ConditionSelect
id="colony-brood" id="colony-brood"
label="Brutstatus" label="Brutstatus"
@@ -207,6 +230,7 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
label="Futterstatus" label="Futterstatus"
value={feedStatus} value={feedStatus}
onChange={setFeedStatus} onChange={setFeedStatus}
labels={FEED_CONDITION_LABELS}
/> />
<ConditionSelect <ConditionSelect
id="colony-health" id="colony-health"
@@ -38,7 +38,7 @@ const inspection: Inspection = {
swarmMood: null, swarmMood: null,
gentleness: 2, gentleness: 2,
combBehavior: null, combBehavior: null,
feedStatus: "ausreichend", feedStatus: "GOOD",
honeySuperStatus: null, honeySuperStatus: null,
healthIssues: null, healthIssues: null,
varroaIssues: null, varroaIssues: null,
@@ -244,4 +244,69 @@ describe("InspectionEditDialog", () => {
expect(screen.queryByLabelText(/Brutmenge/)).not.toBeInTheDocument(); expect(screen.queryByLabelText(/Brutmenge/)).not.toBeInTheDocument();
}); });
it("the edit dialog shows a stored AVERAGE feed value", async () => {
vi.mocked(updateInspection).mockResolvedValue(inspection);
await openDialog({ ...inspection, feedStatus: "AVERAGE" });
const group = screen.getByRole("radiogroup", { name: /^Futter/ });
expect(
within(group)
.getAllByRole("radio")
.find((radio) => radio.getAttribute("aria-checked") === "true")?.textContent,
).toBe("Mittel");
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
const [, input] = vi.mocked(updateInspection).mock.calls[0];
expect(input).toMatchObject({ feedStatus: "AVERAGE" });
});
it("sends the feed status as the enum and never sends applyToColony on a correction", async () => {
vi.mocked(updateInspection).mockResolvedValue(inspection);
await openDialog();
await userEvent.click(
within(screen.getByRole("radiogroup", { name: /^Futter/ })).getByRole("radio", {
name: "Niedrig",
}),
);
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
const [, input] = vi.mocked(updateInspection).mock.calls[0];
expect(input).toMatchObject({ feedStatus: "WEAK" });
expect(input).not.toHaveProperty("applyToColony");
});
it("shows a hint for an unmappable stored feed text and does not overwrite it unless changed", async () => {
vi.mocked(updateInspection).mockResolvedValue(inspection);
await openDialog({ ...inspection, feedStatus: "halb voll" as never });
expect(
screen.getByText("Alter Futterwert „halb voll“ nicht übernommen – bitte neu wählen."),
).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(vi.mocked(updateInspection).mock.calls[0][1]).not.toHaveProperty("feedStatus");
});
it("sends the new choice and drops the hint once the unmappable feed text is replaced", async () => {
vi.mocked(updateInspection).mockResolvedValue(inspection);
await openDialog({ ...inspection, feedStatus: "halb voll" as never });
await userEvent.click(
within(screen.getByRole("radiogroup", { name: /^Futter/ })).getByRole("radio", { name: "Niedrig" }),
);
expect(screen.queryByText(/Alter Futterwert/)).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(vi.mocked(updateInspection).mock.calls[0][1]).toMatchObject({ feedStatus: "WEAK" });
});
it("caps „Volksstärke“ at 40 besetzte Waben", async () => {
await openDialog();
const field = screen.getByLabelText(/^Volksstärke/);
await userEvent.clear(field);
await userEvent.type(field, "77");
expect(field).toHaveValue("40");
});
}); });
@@ -4,6 +4,8 @@ import { FormEvent, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { updateInspection } from "@/lib/inspections/api"; import { updateInspection } from "@/lib/inspections/api";
import { Inspection } from "@/lib/inspections/types"; import { Inspection } from "@/lib/inspections/types";
import { feedConditionFromInput } from "@/lib/colonies/condition";
import { MAX_OCCUPIED_FRAMES } from "@/lib/colonies/labels";
import type { RatingDirection } from "@/lib/forms/rating"; import type { RatingDirection } from "@/lib/forms/rating";
import { errorMessage } from "@/lib/forms/error-message"; import { errorMessage } from "@/lib/forms/error-message";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -24,9 +26,11 @@ import {
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { import {
FEED_STATUS_OPTIONS,
INSPECTION_OBSERVATIONS, INSPECTION_OBSERVATIONS,
InspectionFormValues, InspectionFormValues,
feedFieldFrom,
feedLegacyHint,
feedStatusOptions,
toInspectionInput, toInspectionInput,
} from "../../inspections/new/inspection-form-values"; } from "../../inspections/new/inspection-form-values";
import { import {
@@ -55,14 +59,17 @@ function valuesFromInspection(inspection: Inspection): InspectionFormValues {
swarmMood: inspection.swarmMood, swarmMood: inspection.swarmMood,
gentleness: inspection.gentleness, gentleness: inspection.gentleness,
combBehavior: inspection.combBehavior, combBehavior: inspection.combBehavior,
// Kept verbatim even if it is not one of the three known values, so // Any stored level is kept (the colony scale has five), so saving
// saving without touching it never changes it. // without touching it never changes it.
feedStatus: inspection.feedStatus, // An unmappable stored text stays untouched (and is hinted at) until changed.
...feedFieldFrom(inspection.feedStatus),
honeySuperStatus: inspection.honeySuperStatus ?? "", honeySuperStatus: inspection.honeySuperStatus ?? "",
healthIssues: inspection.healthIssues ?? "", healthIssues: inspection.healthIssues ?? "",
varroaIssues: inspection.varroaIssues ?? "", varroaIssues: inspection.varroaIssues ?? "",
measures: inspection.measures ?? "", measures: inspection.measures ?? "",
notes: inspection.notes ?? "", notes: inspection.notes ?? "",
// A correction never takes anything over onto the colony card.
applyToColony: [],
weatherAuto: false, weatherAuto: false,
// A saved inspection takes its photos straight to the server (Fotos below). // A saved inspection takes its photos straight to the server (Fotos below).
photoSessionId: "", photoSessionId: "",
@@ -96,12 +103,18 @@ export function InspectionEditDialog({ inspection }: { inspection: Inspection })
); );
const [dateError, setDateError] = useState<string | null>(null); const [dateError, setDateError] = useState<string | null>(null);
const [submitError, setSubmitError] = useState<string | null>(null); const [submitError, setSubmitError] = useState<string | null>(null);
// Offered from the stored value, so a level outside the three stays visible.
const [feedOptions] = useState(() => feedStatusOptions(feedConditionFromInput(inspection.feedStatus)));
function set<K extends keyof InspectionFormValues>( function set<K extends keyof InspectionFormValues>(
key: K, key: K,
value: InspectionFormValues[K], value: InspectionFormValues[K],
) { ) {
setValues((current) => ({ ...current, [key]: value })); setValues((current) => ({
...current,
[key]: value,
...(key === "feedStatus" ? { feedLegacyText: null } : {}),
}));
if (key === "date") setDateError(null); if (key === "date") setDateError(null);
} }
@@ -240,6 +253,7 @@ export function InspectionEditDialog({ inspection }: { inspection: Inspection })
id={id} id={id}
value={values.colonyStrength} value={values.colonyStrength}
onChange={(count) => set("colonyStrength", count)} onChange={(count) => set("colonyStrength", count)}
max={MAX_OCCUPIED_FRAMES}
unitLabel="besetzte Waben" unitLabel="besetzte Waben"
decrementLabel="Eine besetzte Wabe weniger" decrementLabel="Eine besetzte Wabe weniger"
incrementLabel="Eine besetzte Wabe mehr" incrementLabel="Eine besetzte Wabe mehr"
@@ -267,14 +281,21 @@ export function InspectionEditDialog({ inspection }: { inspection: Inspection })
<FormSection step={4} title="Futter"> <FormSection step={4} title="Futter">
<Field label="Futterstatus" group> <Field label="Futterstatus" group>
{({ id, labelId }) => ( {({ id, labelId }) => (
<ChoiceGroup <>
id={id} <ChoiceGroup
labelledBy={labelId} id={id}
options={FEED_STATUS_OPTIONS} labelledBy={labelId}
value={values.feedStatus} options={feedOptions}
onChange={(feedStatus) => set("feedStatus", feedStatus)} value={values.feedStatus}
className="grid-cols-2 sm:grid-cols-4" onChange={(feedStatus) => set("feedStatus", feedStatus)}
/> className="grid-cols-2 sm:grid-cols-4"
/>
{values.feedLegacyText !== null ? (
<p role="status" className="text-sm">
{feedLegacyHint(values.feedLegacyText)}
</p>
) : null}
</>
)} )}
</Field> </Field>
</FormSection> </FormSection>
@@ -4,6 +4,7 @@ import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import ColonyDetailPage from "./page"; import ColonyDetailPage from "./page";
import { fetchColony } from "@/lib/colonies/api"; import { fetchColony } from "@/lib/colonies/api";
import type { Colony } from "@/lib/colonies/types";
vi.mock("next/navigation", () => ({ vi.mock("next/navigation", () => ({
useParams: () => ({ id: "colony-1" }), useParams: () => ({ id: "colony-1" }),
@@ -14,6 +15,16 @@ vi.mock("@/lib/export/api", () => ({
exportColonyRecordCardPdf: vi.fn(), exportColonyRecordCardPdf: vi.fn(),
})); }));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock("./colony-edit-card", () => ({ ColonyEditCard: () => null }));
vi.mock("./colony-actions", () => ({ ColonyActions: () => null }));
vi.mock("./colony-history", () => ({ ColonyHistoryCard: () => null }));
vi.mock("./colony-inspections", () => ({ ColonyInspectionsCard: () => null }));
vi.mock("./colony-varroa", () => ({ ColonyVarroaCard: () => null }));
vi.mock("./colony-swarm-control", () => ({ ColonySwarmControlCard: () => null }));
vi.mock("./colony-treatments", () => ({ ColonyTreatmentsCard: () => null }));
vi.mock("./colony-feedings", () => ({ ColonyFeedingsCard: () => null }));
vi.mock("@/components/document-upload-card", () => ({ DocumentUploadCard: () => null }));
vi.mock("@/components/qr-code-dialog", () => ({ QrCodeDialog: () => null }));
function renderPage() { function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
@@ -48,3 +59,41 @@ describe("ColonyDetailPage – failed load", () => {
expect(fetchColony).toHaveBeenCalledTimes(2); expect(fetchColony).toHaveBeenCalledTimes(2);
}); });
}); });
describe("ColonyDetailPage – strength", () => {
const colony = {
id: "colony-1",
code: "V-01",
colonyType: "PRODUCTION_COLONY",
status: "ACTIVE",
strength: null,
occupiedFrames: null,
} as Colony;
beforeEach(() => {
vi.mocked(fetchColony).mockReset();
});
it("shows „{n} besetzte Waben“", async () => {
vi.mocked(fetchColony).mockResolvedValue({ ...colony, occupiedFrames: 8, strength: 7 });
renderPage();
expect(await screen.findByText("8 besetzte Waben")).toBeInTheDocument();
expect(screen.queryByText(/Volksstärke \(alt\)/)).not.toBeInTheDocument();
});
it("the colony page shows „Volksstärke (alt): 7 von 10“ without occupiedFrames", async () => {
vi.mocked(fetchColony).mockResolvedValue({ ...colony, strength: 7 });
renderPage();
expect(await screen.findByText("Volksstärke (alt): 7 von 10")).toBeInTheDocument();
});
it("shows no strength line when none is stored", async () => {
vi.mocked(fetchColony).mockResolvedValue(colony);
renderPage();
expect(await screen.findByRole("heading", { name: "V-01" })).toBeInTheDocument();
expect(screen.queryByText(/besetzte Waben|Volksstärke/)).not.toBeInTheDocument();
});
});
@@ -6,7 +6,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import { ClipboardCheck, FileText, Layers } from "lucide-react"; import { ClipboardCheck, FileText, Layers } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { fetchColony } from "@/lib/colonies/api"; import { fetchColony } from "@/lib/colonies/api";
import { COLONY_STATUS_LABELS, COLONY_TYPE_LABELS } from "@/lib/colonies/labels"; import { COLONY_STATUS_LABELS, COLONY_TYPE_LABELS, colonyStrengthText } from "@/lib/colonies/labels";
import { exportColonyAnnualReportPdf, exportColonyRecordCardPdf } from "@/lib/export/api"; import { exportColonyAnnualReportPdf, exportColonyRecordCardPdf } from "@/lib/export/api";
import { QrCodeDialog } from "@/components/qr-code-dialog"; import { QrCodeDialog } from "@/components/qr-code-dialog";
import { DocumentUploadCard } from "@/components/document-upload-card"; import { DocumentUploadCard } from "@/components/document-upload-card";
@@ -46,6 +46,8 @@ export default function ColonyDetailPage() {
return <DetailQueryFallback query={colonyQuery} what="Volksdaten" {...BACK} />; return <DetailQueryFallback query={colonyQuery} what="Volksdaten" {...BACK} />;
} }
const strengthText = colonyStrengthText(colony);
return ( return (
<main className="flex flex-col gap-4 p-4 sm:p-6"> <main className="flex flex-col gap-4 p-4 sm:p-6">
<DetailHeader <DetailHeader
@@ -81,6 +83,7 @@ export default function ColonyDetailPage() {
]} ]}
{...BACK} {...BACK}
/> />
{strengthText ? <p className="text-sm text-muted-foreground">{strengthText}</p> : null}
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-3"> <div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-3">
<div className="flex flex-col gap-4 lg:col-span-1"> <div className="flex flex-col gap-4 lg:col-span-1">
<ColonyEditCard key={colony.id} colony={colony} /> <ColonyEditCard key={colony.id} colony={colony} />
@@ -8,6 +8,8 @@ export interface ColonyFormValues {
colonyType: ColonyType; colonyType: ColonyType;
hiveType: string; hiveType: string;
originText: string; originText: string;
/** Occupied frames as typed; "" = not counted. */
occupiedFrames: string;
} }
export type ColonyFormErrors = Partial<Record<keyof ColonyFormValues, string>>; export type ColonyFormErrors = Partial<Record<keyof ColonyFormValues, string>>;
@@ -19,6 +21,7 @@ export function initialColonyValues(): ColonyFormValues {
colonyType: "PRODUCTION_COLONY", colonyType: "PRODUCTION_COLONY",
hiveType: "", hiveType: "",
originText: "", originText: "",
occupiedFrames: "",
}; };
} }
@@ -38,5 +41,6 @@ export function toColonyInput(values: ColonyFormValues): CreateColonyInput {
// Not filled in = null, the same convention as every edit form. // Not filled in = null, the same convention as every edit form.
hiveType: emptyToNull(values.hiveType), hiveType: emptyToNull(values.hiveType),
originText: emptyToNull(values.originText), originText: emptyToNull(values.originText),
occupiedFrames: emptyToNull(values.occupiedFrames, Number),
}; };
} }
@@ -60,6 +60,7 @@ describe("NewColonyPage", () => {
// Not filled in = null, the same convention as every edit form. // Not filled in = null, the same convention as every edit form.
hiveType: null, hiveType: null,
originText: null, originText: null,
occupiedFrames: null,
}); });
expect(push).toHaveBeenCalledWith("/colonies/colony-9"); expect(push).toHaveBeenCalledWith("/colonies/colony-9");
}); });
@@ -76,3 +77,39 @@ describe("NewColonyPage", () => {
expect(createColony).not.toHaveBeenCalled(); expect(createColony).not.toHaveBeenCalled();
}); });
}); });
describe("NewColonyPage – besetzte Waben", () => {
beforeEach(() => {
window.localStorage.clear();
push.mockReset();
vi.mocked(fetchApiaries).mockResolvedValue([
{ id: "apiary-1", name: "Hausgarten", status: "ACTIVE" },
] as never);
vi.mocked(createColony).mockReset();
vi.mocked(createColony).mockResolvedValue({ id: "colony-9" } as never);
});
it("sends only occupiedFrames, no old strength", async () => {
renderPage();
await userEvent.type(screen.getByLabelText(/Code/), "V-08");
await userEvent.click(screen.getByRole("combobox", { name: /Bienenstand/ }));
await userEvent.click(await screen.findByRole("option", { name: "Hausgarten" }));
await userEvent.click(screen.getByRole("button", { name: /Weitere Angaben/ }));
await userEvent.type(screen.getByLabelText(/^Besetzte Waben/), "6");
await userEvent.click(screen.getByRole("button", { name: "Volk anlegen" }));
const [input] = vi.mocked(createColony).mock.calls[0];
expect(input).toMatchObject({ occupiedFrames: 6 });
expect(input).not.toHaveProperty("strength");
});
it("caps the count at 40", async () => {
renderPage();
await userEvent.click(screen.getByRole("button", { name: /Weitere Angaben/ }));
const field = screen.getByLabelText(/^Besetzte Waben/);
await userEvent.type(field, "63");
expect(field).toHaveValue("40");
});
});
@@ -6,7 +6,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { Layers } from "lucide-react"; import { Layers } from "lucide-react";
import { createColony } from "@/lib/colonies/api"; import { createColony } from "@/lib/colonies/api";
import { COLONY_TYPES, COLONY_TYPE_LABELS } from "@/lib/colonies/labels"; import { COLONY_TYPES, COLONY_TYPE_LABELS, MAX_OCCUPIED_FRAMES } from "@/lib/colonies/labels";
import { fetchApiaries } from "@/lib/apiaries/api"; import { fetchApiaries } from "@/lib/apiaries/api";
import { useFormDraft } from "@/lib/forms/use-form-draft"; import { useFormDraft } from "@/lib/forms/use-form-draft";
import { errorMessage } from "@/lib/forms/error-message"; import { errorMessage } from "@/lib/forms/error-message";
@@ -14,6 +14,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { EnumSelect } from "@/components/form/enum-select"; import { EnumSelect } from "@/components/form/enum-select";
import { RecordSelect } from "@/components/form/record-select"; import { RecordSelect } from "@/components/form/record-select";
import { NumberStepper } from "@/components/form/number-stepper";
import { FormBackLink } from "@/components/form/form-back-link"; import { FormBackLink } from "@/components/form/form-back-link";
import { PageHeader } from "@/components/page-header"; import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
@@ -85,7 +86,8 @@ export default function NewColonyPage() {
function restoreDraft() { function restoreDraft() {
const restored = draft.restore(); const restored = draft.restore();
if (restored) { if (restored) {
setValues(restored); // A draft from before „Besetzte Waben“ existed lacks it.
setValues({ ...initialColonyValues(), ...restored });
setDirty(true); setDirty(true);
} }
} }
@@ -183,7 +185,7 @@ export default function NewColonyPage() {
title="Weitere Angaben" title="Weitere Angaben"
collapsible collapsible
defaultOpen={false} defaultOpen={false}
fieldCount={2} fieldCount={3}
> >
<Field label="Beutentyp" hint="z. B. Zander, Dadant"> <Field label="Beutentyp" hint="z. B. Zander, Dadant">
{({ id, describedBy }) => ( {({ id, describedBy }) => (
@@ -195,6 +197,20 @@ export default function NewColonyPage() {
/> />
)} )}
</Field> </Field>
<Field label="Besetzte Waben" hint="Wie viele Waben das Volk besetzt">
{({ id, describedBy }) => (
<NumberStepper
id={id}
describedBy={describedBy}
value={values.occupiedFrames}
onChange={(count) => set("occupiedFrames", count)}
max={MAX_OCCUPIED_FRAMES}
unitLabel="besetzte Waben"
decrementLabel="Eine besetzte Wabe weniger"
incrementLabel="Eine besetzte Wabe mehr"
/>
)}
</Field>
<Field label="Herkunft" hint="z. B. Ableger von V-03, Schwarm"> <Field label="Herkunft" hint="z. B. Ableger von V-03, Schwarm">
{({ id, describedBy }) => ( {({ id, describedBy }) => (
<Input <Input
@@ -30,6 +30,7 @@ describe("ColoniesPage", () => {
colonyType: "PRODUCTION_COLONY", colonyType: "PRODUCTION_COLONY",
status: "ACTIVE", status: "ACTIVE",
strength: null, strength: null,
occupiedFrames: null,
broodStatus: null, broodStatus: null,
feedStatus: null, feedStatus: null,
healthStatus: null, healthStatus: null,
@@ -7,7 +7,12 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { fetchColonies } from "@/lib/colonies/api"; import { fetchColonies } from "@/lib/colonies/api";
import { Colony, ColonyStatus } from "@/lib/colonies/types"; import { Colony, ColonyStatus } from "@/lib/colonies/types";
import { COLONY_CONDITION_LABELS, COLONY_STATUS_LABELS, COLONY_TYPE_LABELS } from "@/lib/colonies/labels"; import {
COLONY_CONDITION_LABELS,
COLONY_STATUS_LABELS,
COLONY_TYPE_LABELS,
colonyStrengthText,
} from "@/lib/colonies/labels";
import { ENDED_COLONY_STATUSES, LIVING_COLONY_STATUSES } from "@/lib/colonies/status"; import { ENDED_COLONY_STATUSES, LIVING_COLONY_STATUSES } from "@/lib/colonies/status";
import { exportColoniesCsv } from "@/lib/export/api"; import { exportColoniesCsv } from "@/lib/export/api";
import { statusTone } from "@/lib/status-tone"; import { statusTone } from "@/lib/status-tone";
@@ -175,7 +180,7 @@ function ColonyGrid({ colonies }: { colonies: Colony[] }) {
<Badge variant={statusTone(colony.status)}>{COLONY_STATUS_LABELS[colony.status]}</Badge> <Badge variant={statusTone(colony.status)}>{COLONY_STATUS_LABELS[colony.status]}</Badge>
} }
fields={[ fields={[
{ label: "Stärke", value: colony.strength ?? "—" }, { label: "Stärke", value: colonyStrengthText(colony) ?? "—" },
{ {
label: "Gesundheit", label: "Gesundheit",
value: colony.healthStatus ? COLONY_CONDITION_LABELS[colony.healthStatus] : "—", value: colony.healthStatus ? COLONY_CONDITION_LABELS[colony.healthStatus] : "—",
Loaded 100 of 122 files, more files were not shown because too many files have changed in this diff. Show more