feat: T4 wave 5 - colony end cleanup, withdrawal chain, next steps, inspection takeover UI
- Colony end releases the queen in the same transaction; open suggestions become obsolete and a CLEANUP suggestion lists open tasks and the queen; apply-cleanup validates evidence and blocks reactivated colonies - Withdrawal chain: plausibility warnings on harvest and bottling, conflict notifications also when a treatment is entered or corrected after the harvest (naming bottled lots), withdrawal spans in the calendar and M365 sync, Berlin-day end events - Next 3 steps per colony and the "Heute" endpoint with ranking and an explicit merge table by intent - Inspection-triggered rule suggestions follow the colony's latest inspection (resolve when the finding is gone, refresh when it stays) - Frontend: colony card takeover in the inspection form, feed status as condition, occupied frames 0-40; legacy offline entries keep syncing - Notifications and preferences are deleted with their user Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
02eb16924e
commit
ef33e5549b
122 files changed
+10608
-405
No files matched your search
+16
@@ -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;
|
||||||
@@ -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[]>;
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
if (event.type === 'InspectionRecorded') {
|
||||||
await this.automation.evaluateInspectionIn(tx, inspection);
|
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(
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A corrected inspection proposes nothing new, but an open suggestion it
|
||||||
|
* supersedes follows the corrected values (resolved or refreshed).
|
||||||
|
*/
|
||||||
|
async evaluateInspectionCorrectionIn(
|
||||||
|
tx: Tx,
|
||||||
|
inspection: Inspection,
|
||||||
|
): Promise<void> {
|
||||||
|
assertTransactionClient(
|
||||||
|
tx,
|
||||||
|
'AutomationService.evaluateInspectionCorrectionIn',
|
||||||
|
);
|
||||||
|
await this.syncInspectionRulesIn(tx, inspection, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Colony lock first, then everything that decides is read under it: the
|
||||||
|
* colony's actual latest inspection (date, createdAt, id) is the only one
|
||||||
|
* that counts. An event of any other inspection - a late or retried
|
||||||
|
* delivery, a backdated entry, the correction of an older one - proposes
|
||||||
|
* nothing and changes nothing; the latest inspection has its own event.
|
||||||
|
* (An inspection moved to another colony raises no event for the colony
|
||||||
|
* it left, so a suggestion there stays until that colony's next
|
||||||
|
* inspection.)
|
||||||
|
*/
|
||||||
|
private async syncInspectionRulesIn(
|
||||||
|
tx: Tx,
|
||||||
|
inspection: Inspection,
|
||||||
|
proposeNew: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!(await lockRowForUpdate(tx, 'Colony', inspection.colonyId))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const latest = await tx.inspection.findFirst({
|
||||||
|
where: { colonyId: inspection.colonyId },
|
||||||
|
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
|
||||||
|
});
|
||||||
|
if (latest === null || latest.id !== inspection.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.syncInspectionRuleIn(
|
||||||
tx,
|
tx,
|
||||||
'QUEENLESS_NO_EGGS',
|
'QUEENLESS_NO_EGGS',
|
||||||
'QUEEN_CHECK',
|
'QUEEN_CHECK',
|
||||||
inspection,
|
latest,
|
||||||
|
latest.queenSeen === false && latest.eggsPresent === false,
|
||||||
|
proposeNew,
|
||||||
);
|
);
|
||||||
}
|
await this.syncInspectionRuleIn(
|
||||||
if (inspection.feedStatus === 'CRITICAL') {
|
|
||||||
await this.proposeOnInspectionIn(
|
|
||||||
tx,
|
tx,
|
||||||
'FEED_STATUS_CRITICAL',
|
'FEED_STATUS_CRITICAL',
|
||||||
'FEED',
|
'FEED',
|
||||||
inspection,
|
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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,8 +217,7 @@ 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
|
||||||
@@ -231,11 +253,12 @@ export class BottlingService {
|
|||||||
payload: { honeyBatchId: created.honeyBatchId },
|
payload: { honeyBatchId: created.honeyBatchId },
|
||||||
actorUserId: actingUserId,
|
actorUserId: actingUserId,
|
||||||
});
|
});
|
||||||
return created;
|
return this.withWithdrawalWarnings(
|
||||||
},
|
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', () => {
|
||||||
|
const withdrawalEntry = () =>
|
||||||
|
pendingEntry({ sourceType: 'WITHDRAWAL', sourceId: 't1' });
|
||||||
|
const treatment = (overrides: Record<string, unknown> = {}) => ({
|
||||||
|
id: 't1',
|
||||||
|
colonyId: 'c1',
|
||||||
|
product: 'Oxuvar',
|
||||||
|
date: new Date('2026-07-31T00:00:00.000Z'),
|
||||||
|
startDate: null,
|
||||||
|
endDate: null,
|
||||||
|
withdrawalPeriodDays: 20,
|
||||||
|
colony: { code: 'B3' },
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes the window as an all-day span from the first covered day to the first harvestable day', async () => {
|
||||||
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
|
||||||
pendingEntry({ sourceType: 'WITHDRAWAL', sourceId: 't1' }),
|
withdrawalEntry(),
|
||||||
]);
|
]);
|
||||||
|
prisma.treatment.findUnique.mockResolvedValue(treatment());
|
||||||
|
graphClient.createEvent.mockResolvedValue('g1');
|
||||||
|
|
||||||
|
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();
|
await service.processOutbox();
|
||||||
|
|
||||||
expect(graphClient.createEvent).not.toHaveBeenCalled();
|
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,10 +473,23 @@ 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({
|
||||||
|
where: { id: sourceId },
|
||||||
|
include: { colony: true },
|
||||||
|
});
|
||||||
|
const span = treatment ? withdrawalSpanOf(treatment) : null;
|
||||||
|
if (!treatment || !span) {
|
||||||
return null;
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getStatus(): Promise<CalendarSyncStatusReport> {
|
async getStatus(): Promise<CalendarSyncStatusReport> {
|
||||||
|
|||||||
@@ -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'),
|
from: new Date('2026-06-01'),
|
||||||
to: new Date('2026-06-30'),
|
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'),
|
from: new Date('2026-06-01'),
|
||||||
to: new Date('2026-06-30'),
|
to: new Date('2026-06-30'),
|
||||||
apiaryId: 'a1',
|
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'),
|
from: new Date('2026-06-01'),
|
||||||
to: new Date('2026-06-30'),
|
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
|
||||||
|
? this.prisma.treatment.findMany({
|
||||||
where: { date: { gte: from, lte: to }, ...colonyFilter },
|
where: { date: { gte: from, lte: to }, ...colonyFilter },
|
||||||
include: { colony: true },
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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');
|
||||||
|
|
||||||
|
|||||||
@@ -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,7 +213,24 @@ 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) =>
|
||||||
|
this.completeIn(tx, id, actingUserId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Erledigt" on the caller's transaction: locks the task, reads it
|
||||||
|
* afterwards, and writes audit entry and TaskCompleted only on the
|
||||||
|
* transition (a task that is DONE already comes back unchanged). The one
|
||||||
|
* path of complete() and of the clean-up of an ended colony.
|
||||||
|
*/
|
||||||
|
async completeIn(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
id: string,
|
||||||
|
actingUserId: string,
|
||||||
|
): Promise<Task> {
|
||||||
|
assertTransactionClient(tx, 'TasksService.completeIn');
|
||||||
|
await lockRowForUpdate(tx, 'Task', id);
|
||||||
const before = await this.findIn(tx, id);
|
const before = await this.findIn(tx, id);
|
||||||
// A second tap on "Erledigt" (or a retried request) must not move the
|
// A second tap on "Erledigt" (or a retried request) must not move the
|
||||||
// completion time or announce the completion a second time.
|
// completion time or announce the completion a second time.
|
||||||
@@ -237,7 +255,47 @@ export class TasksService {
|
|||||||
);
|
);
|
||||||
await this.recordCompleted(tx, task, actingUserId);
|
await this.recordCompleted(tx, task, actingUserId);
|
||||||
return task;
|
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],
|
||||||
|
|||||||
@@ -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`)
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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`;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,7 +196,20 @@ 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>
|
||||||
|
<NumberStepper
|
||||||
|
id="colony-occupied-frames"
|
||||||
|
value={occupiedFrames}
|
||||||
|
onChange={setOccupiedFrames}
|
||||||
|
max={MAX_OCCUPIED_FRAMES}
|
||||||
|
unitLabel="besetzte Waben"
|
||||||
|
decrementLabel="Eine besetzte Wabe weniger"
|
||||||
|
incrementLabel="Eine besetzte Wabe mehr"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{colony.strength !== null ? (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="colony-strength">Volksstärke (alt, Skala 1–10)</Label>
|
||||||
<EnumSelect
|
<EnumSelect
|
||||||
id="colony-strength"
|
id="colony-strength"
|
||||||
labels={COLONY_STRENGTH_LABELS}
|
labels={COLONY_STRENGTH_LABELS}
|
||||||
@@ -196,6 +218,7 @@ export function ColonyEditCard({ colony }: { colony: Colony }) {
|
|||||||
onChange={setStrength}
|
onChange={setStrength}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<ChoiceGroup
|
||||||
id={id}
|
id={id}
|
||||||
labelledBy={labelId}
|
labelledBy={labelId}
|
||||||
options={FEED_STATUS_OPTIONS}
|
options={feedOptions}
|
||||||
value={values.feedStatus}
|
value={values.feedStatus}
|
||||||
onChange={(feedStatus) => set("feedStatus", feedStatus)}
|
onChange={(feedStatus) => set("feedStatus", feedStatus)}
|
||||||
className="grid-cols-2 sm:grid-cols-4"
|
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
Reference in new issue
Block a user