feat: T4 wave 5 - colony end cleanup, withdrawal chain, next steps, inspection takeover UI
- Colony end releases the queen in the same transaction; open suggestions become obsolete and a CLEANUP suggestion lists open tasks and the queen; apply-cleanup validates evidence and blocks reactivated colonies - Withdrawal chain: plausibility warnings on harvest and bottling, conflict notifications also when a treatment is entered or corrected after the harvest (naming bottled lots), withdrawal spans in the calendar and M365 sync, Berlin-day end events - Next 3 steps per colony and the "Heute" endpoint with ranking and an explicit merge table by intent - Inspection-triggered rule suggestions follow the colony's latest inspection (resolve when the finding is gone, refresh when it stays) - Frontend: colony card takeover in the inspection form, feed status as condition, occupied frames 0-40; legacy offline entries keep syncing - Notifications and preferences are deleted with their user Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
02eb16924e
commit
ef33e5549b
122 files changed
+10707
-504
No files matched your search
@@ -2,6 +2,8 @@ import { NotFoundException } from '@nestjs/common';
|
||||
import { ApiaryFieldService } from './apiary-field.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||
import type { ColonyNextSteps } from '../autopilot/next-step-ranking';
|
||||
import { NextStepsService } from '../autopilot/next-steps.service';
|
||||
|
||||
describe('ApiaryFieldService', () => {
|
||||
let service: ApiaryFieldService;
|
||||
@@ -14,6 +16,21 @@ describe('ApiaryFieldService', () => {
|
||||
task: { findMany: jest.Mock };
|
||||
};
|
||||
let permissionResolver: { getPermissions: jest.Mock };
|
||||
let nextSteps: { forColonies: jest.Mock; warningCounts: jest.Mock };
|
||||
|
||||
const stepsOf = (
|
||||
colonyId: string,
|
||||
overrides: Partial<ColonyNextSteps> = {},
|
||||
): ColonyNextSteps => ({
|
||||
colonyId,
|
||||
segment: 'SCHWARMZEIT',
|
||||
phase: null,
|
||||
phaseSource: null,
|
||||
inspectionDueOn: null,
|
||||
withdrawalUntil: null,
|
||||
steps: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const colonyRow = (id: string, code: string, queenId: string | null) => ({
|
||||
id,
|
||||
@@ -61,9 +78,19 @@ describe('ApiaryFieldService', () => {
|
||||
task: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
};
|
||||
permissionResolver = { getPermissions: jest.fn() };
|
||||
nextSteps = {
|
||||
forColonies: jest.fn().mockResolvedValue(
|
||||
new Map([
|
||||
['c10', stepsOf('c10')],
|
||||
['c2', stepsOf('c2')],
|
||||
]),
|
||||
),
|
||||
warningCounts: jest.fn().mockResolvedValue(new Map()),
|
||||
};
|
||||
service = new ApiaryFieldService(
|
||||
prisma as unknown as PrismaService,
|
||||
permissionResolver as unknown as PermissionResolverService,
|
||||
nextSteps as unknown as NextStepsService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -173,23 +200,19 @@ describe('ApiaryFieldService', () => {
|
||||
expect(inspectionArgs.omit).toEqual({ userId: true });
|
||||
});
|
||||
|
||||
const lastQuery = () =>
|
||||
(prisma.$queryRaw.mock.calls[0] as [{ sql: string; values: unknown[] }])[0];
|
||||
|
||||
it('counts only unread WARNUNG and KRITISCH colony notifications visible to the caller', async () => {
|
||||
grant('colonies:read');
|
||||
prisma.$queryRaw.mockResolvedValue([
|
||||
{ relatedId: 'c2', priority: 'WARNUNG', count: 2 },
|
||||
{ relatedId: 'c2', priority: 'KRITISCH', count: 1 },
|
||||
]);
|
||||
it('takes the warning counts from the open suggestions the caller may see', async () => {
|
||||
grant('colonies:read', 'tasks:read');
|
||||
nextSteps.warningCounts.mockResolvedValue(
|
||||
new Map([['c2', { warnings: 2, critical: 1 }]]),
|
||||
);
|
||||
|
||||
const overview = await service.colonyOverview('apiary-1', 'u1');
|
||||
|
||||
const query = lastQuery();
|
||||
expect(query.sql).toContain('"userId" =');
|
||||
expect(query.sql).toContain('"read" = false');
|
||||
expect(query.values).toContain('u1');
|
||||
expect(query.values).toEqual(expect.arrayContaining(['c2', 'c10']));
|
||||
expect(nextSteps.warningCounts).toHaveBeenCalledWith(
|
||||
['c2', 'c10'],
|
||||
'u1',
|
||||
new Set(['colonies:read', 'tasks:read']),
|
||||
);
|
||||
expect(overview.rows[0]).toMatchObject({
|
||||
code: 'V-2',
|
||||
openWarnings: 2,
|
||||
@@ -199,17 +222,69 @@ describe('ApiaryFieldService', () => {
|
||||
openWarnings: 0,
|
||||
openCritical: 0,
|
||||
});
|
||||
expect(prisma.$queryRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requires the notification permissions to be a subset of the held ones, stale keys included', async () => {
|
||||
grant('colonies:read', 'varroa:read');
|
||||
it('carries inspectionDueOn, segment and the title of the first next step', async () => {
|
||||
grant('colonies:read', 'tasks:read');
|
||||
nextSteps.forColonies.mockResolvedValue(
|
||||
new Map([
|
||||
[
|
||||
'c2',
|
||||
stepsOf('c2', {
|
||||
segment: 'SOMMER',
|
||||
inspectionDueOn: '2026-05-10',
|
||||
steps: [
|
||||
{
|
||||
key: 'suggestion:s1',
|
||||
kind: 'SUGGESTION',
|
||||
intent: 'INSPECT',
|
||||
title: 'Durchsicht mit Schwarmkontrolle',
|
||||
reasons: [],
|
||||
suggestionIds: ['s1'],
|
||||
dueOn: '2026-05-10',
|
||||
urgency: 'OVERDUE',
|
||||
action: { type: 'ACCEPT', suggestionId: 's1' },
|
||||
dismissible: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
['c10', stepsOf('c10', { segment: 'WINTERRUHE' })],
|
||||
]),
|
||||
);
|
||||
|
||||
await service.colonyOverview('apiary-1', 'u1');
|
||||
const overview = await service.colonyOverview('apiary-1', 'u1');
|
||||
|
||||
const query = lastQuery();
|
||||
expect(query.sql).toContain('"requiredPermissions" <@');
|
||||
// The held set is passed as is: a key outside the catalog is never
|
||||
// contained in it, so such a row stays hidden (parity with findOwn).
|
||||
expect(query.values).toContainEqual(['colonies:read', 'varroa:read']);
|
||||
expect(overview.rows[0]).toMatchObject({
|
||||
inspectionDueOn: '2026-05-10',
|
||||
segment: 'SOMMER',
|
||||
nextStepTitle: 'Durchsicht mit Schwarmkontrolle',
|
||||
});
|
||||
expect(overview.rows[1]).toMatchObject({
|
||||
inspectionDueOn: null,
|
||||
segment: 'WINTERRUHE',
|
||||
nextStepTitle: null,
|
||||
});
|
||||
expect(nextSteps.forColonies).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('leaves the segment null when the colony has no season state', async () => {
|
||||
grant('colonies:read');
|
||||
nextSteps.forColonies.mockResolvedValue(new Map());
|
||||
|
||||
const overview = await service.colonyOverview('apiary-1', 'u1');
|
||||
|
||||
expect(overview.rows.every((row) => row.segment === null)).toBe(true);
|
||||
});
|
||||
|
||||
it('adds the next steps per colony to the offline pack', async () => {
|
||||
grant('colonies:read');
|
||||
|
||||
const pack = await service.offlinePack('apiary-1', 'u1');
|
||||
|
||||
expect(pack.version).toBe(1);
|
||||
expect(pack.nextSteps).toEqual({ c2: [], c10: [] });
|
||||
expect(nextSteps.forColonies).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user