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

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

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

No files matched your search

@@ -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);
});
});