feat: T4 wave 4 - rules suggest, takeover, chains, autopilot A1-A3, suggestions page, varroa bands UI

- Automation rules propose suggestions instead of creating tasks;
  notify per rule config; corrected varroa measurements refresh or
  resolve their suggestion
- Inspection values can be taken over into the colony card
  (occupiedFrames 0-40, strength deprecated); warning counts use the
  same visibility rule as the notification list
- Biotech chains: swarm check after queen cells, queen acceptance
  check, split schedules (raise own, queen cell, queen)
- Season autopilot core with A1 inspection due, A2 varroa measurement,
  A3 recheck after treatment; colony sync and daily step
- Frontend: suggestions page with groups, bulk accept, undo, paging,
  focus management and permission gating
- Frontend: varroa traffic light from seasonal bands, sample size,
  one range request for the colony chart
- Notifications: mark-read answers 404 for rows the user may no
  longer see

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 15:22:36 +02:00
1 parent 64a2b7dad9
commit 02eb16924e
142 files changed
+11847 -1251

No files matched your search

+365
View File
@@ -0,0 +1,365 @@
import { INestApplication } from '@nestjs/common';
import { App } from 'supertest/types';
import { PrismaService } from '../src/prisma/prisma.service';
import { DomainEventsService } from '../src/domain-events/domain-events.service';
import { ChainEventHandlers } from '../src/suggestions/chains/chains.handlers';
import { SuggestionsService } from '../src/suggestions/suggestions.service';
import { createTestApp } from './utils/create-test-app';
import { drainDomainEvents } from './utils/domain-events';
/**
* The biotechnical chains against a real PostgreSQL: an inspection, a queen
* assignment or a split leaves one PLAN suggestion; later inspections clean
* up the checks they answer. The events are recorded directly (the services
* that record them have their own e2e tests) and drained like the worker.
*/
describe('Chains (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
let events: DomainEventsService;
let suggestions: SuggestionsService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
let apiaryId: string;
let colonyId: string;
let userId: string;
let aggregateIds: string[] = [];
// Only the chains: the season autopilot writes its own suggestions for the
// same colony when the inspection event is delivered.
const chainSuggestions = () =>
prisma.suggestion.findMany({
where: { colonyId, source: 'CHAIN' },
orderBy: { createdAt: 'asc' },
});
const openOf = (intent: 'SWARM_CHECK' | 'QUEEN_ACCEPTANCE' | 'SPLIT_PLAN') =>
prisma.suggestion.findMany({
where: { colonyId, source: 'CHAIN', status: 'OPEN', intent },
});
const dayOf = (date: Date | null) => date?.toISOString().slice(0, 10);
/** Records an inspection and runs the handlers of its event. */
async function inspect(
day: string,
findings: {
queenCellsPresent?: boolean | null;
queenSeen?: boolean | null;
eggsPresent?: boolean | null;
} = {},
): Promise<string> {
const inspection = await prisma.inspection.create({
data: {
colonyId,
userId,
date: new Date(`${day}T10:00:00.000Z`),
queenCellsPresent: findings.queenCellsPresent ?? null,
queenSeen: findings.queenSeen ?? null,
eggsPresent: findings.eggsPresent ?? null,
},
});
await recordAndDrain(inspection.id, () =>
prisma.$transaction((tx) =>
events.record(tx, {
type: 'InspectionRecorded',
aggregateType: 'Inspection',
aggregateId: inspection.id,
payload: { colonyId },
}),
),
);
return inspection.id;
}
async function assignQueen(assignedOn: string): Promise<string> {
const queenId = `queen-${suffix}-${aggregateIds.length}`;
const historyId = `hist-${suffix}-${aggregateIds.length}`;
await recordAndDrain(queenId, () =>
prisma.$transaction((tx) =>
events.record(tx, {
type: 'QueenAssigned',
aggregateType: 'Queen',
aggregateId: queenId,
payload: { colonyId, historyId, assignedOn, previousColonyId: null },
}),
),
);
return historyId;
}
async function split(
queenSource: 'RAISE_OWN' | 'QUEEN_CELL' | 'QUEEN' | null,
): Promise<void> {
await recordAndDrain(colonyId, () =>
prisma.$transaction((tx) =>
events.record(tx, {
type: 'ColonySplit',
aggregateType: 'Colony',
aggregateId: colonyId,
payload: {
parentColonyId: `parent-${suffix}`,
lineageId: `lineage-${suffix}-${aggregateIds.length}`,
queenSource,
splitOn: '2026-05-01',
},
}),
),
);
}
async function recordAndDrain(
aggregateId: string,
record: () => Promise<void>,
): Promise<void> {
aggregateIds = [...aggregateIds, aggregateId];
await record();
await drainDomainEvents(app, [aggregateId]);
}
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
events = app.get(DomainEventsService);
suggestions = app.get(SuggestionsService);
const apiary = await prisma.apiary.create({
data: { name: `Chains e2e ${suffix}` },
});
apiaryId = apiary.id;
const colony = await prisma.colony.create({
data: {
code: `CHN-${suffix}`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
},
});
colonyId = colony.id;
const user = await prisma.user.create({
data: {
email: `chains-e2e-${suffix}@example.com`,
passwordHash: 'x',
name: 'Chains e2e',
role: { connect: { key: 'STAFF' } },
},
});
userId = user.id;
});
async function cleanUp(): Promise<void> {
await prisma.task.deleteMany({
where: {
OR: [{ relatedId: colonyId }, { sourceSuggestion: { colonyId } }],
},
});
await prisma.suggestion.deleteMany({ where: { colonyId } });
await prisma.domainEvent.deleteMany({
where: { aggregateId: { in: aggregateIds } },
});
await prisma.inspection.deleteMany({ where: { colonyId } });
aggregateIds = [];
}
afterEach(async () => {
await cleanUp();
await prisma.colony.update({
where: { id: colonyId },
data: { status: 'ACTIVE' },
});
});
afterAll(async () => {
await cleanUp();
await prisma.auditLog.deleteMany({
where: {
OR: [
{ userId },
{
entityType: 'Suggestion',
afterData: { path: ['colonyId'], equals: colonyId },
},
],
},
});
await prisma.colony.delete({ where: { id: colonyId } });
await prisma.apiary.delete({ where: { id: apiaryId } });
await prisma.user.delete({ where: { id: userId } });
await app.close();
});
it('queen cells -> one PLAN suggestion due +7', async () => {
const inspectionId = await inspect('2026-05-05', {
queenCellsPresent: true,
});
const open = await openOf('SWARM_CHECK');
expect(open).toHaveLength(1);
expect(open[0]).toMatchObject({
ruleKey: 'QUEEN_CELLS_SWARM_CHECK',
source: 'CHAIN',
mode: 'PLAN',
relatedType: 'COLONY',
relatedId: colonyId,
requiredPermission: 'inspections:read',
dedupeKey: `QUEEN_CELLS_SWARM_CHECK:${colonyId}:${inspectionId}`,
});
expect(dayOf(open[0].dueDate)).toBe('2026-05-12');
expect(await prisma.task.count({ where: { relatedId: colonyId } })).toBe(0);
});
it('an inspection without queen cells proposes nothing', async () => {
await inspect('2026-05-05', { queenCellsPresent: false });
await inspect('2026-05-06');
expect(await chainSuggestions()).toHaveLength(0);
});
it('RAISE_OWN -> accept creates 3 tasks at +9, +21 and +28', async () => {
await split('RAISE_OWN');
const [suggestion] = await openOf('SPLIT_PLAN');
expect(suggestion).toMatchObject({
ruleKey: 'SPLIT_SCHEDULE',
mode: 'PLAN',
requiredPermission: 'colonies:read',
});
expect(dayOf(suggestion.dueDate)).toBe('2026-05-10');
const result = await suggestions.accept(suggestion.id, {}, userId);
expect(
result.tasks.map((task) => [task.title, dayOf(task.dueDate)]),
).toEqual([
['Nachschaffungszellen prüfen', '2026-05-10'],
['Begattung prüfen', '2026-05-22'],
['Stifte prüfen', '2026-05-29'],
]);
});
it('a split without queenSource leaves nothing', async () => {
await split(null);
expect(await chainSuggestions()).toHaveLength(0);
});
it('a queen assignment -> acceptance check +7', async () => {
const historyId = await assignQueen('2026-05-05');
const open = await openOf('QUEEN_ACCEPTANCE');
expect(open).toHaveLength(1);
expect(open[0]).toMatchObject({
ruleKey: 'QUEEN_ACCEPTANCE_CHECK',
source: 'CHAIN',
mode: 'PLAN',
requiredPermission: 'queens:read',
dedupeKey: `QUEEN_ACCEPTANCE_CHECK:${colonyId}:${historyId}`,
});
expect(dayOf(open[0].dueDate)).toBe('2026-05-12');
});
it('a split with QUEEN plus a later assignment yields one acceptance suggestion', async () => {
await split('QUEEN');
await assignQueen('2026-05-01');
const open = await openOf('QUEEN_ACCEPTANCE');
expect(open).toHaveLength(1);
expect(open[0].ruleKey).toBe('SPLIT_SCHEDULE');
expect(dayOf(open[0].dueDate)).toBe('2026-05-08');
});
it('an inspection on the due day resolves the open swarm check', async () => {
await inspect('2026-05-05', { queenCellsPresent: true });
await inspect('2026-05-12', { queenCellsPresent: false });
const [resolved] = await chainSuggestions();
expect(resolved).toMatchObject({
status: 'RESOLVED',
dedupeKey: null,
decisionNote: 'Mit Durchsicht erledigt',
});
});
it('an inspection before the due day leaves the swarm check open', async () => {
await inspect('2026-05-05', { queenCellsPresent: true });
await inspect('2026-05-08', { queenCellsPresent: false });
expect(await openOf('SWARM_CHECK')).toHaveLength(1);
});
it('a second inspection with queen cells replaces the open swarm check by a new one due +7', async () => {
await inspect('2026-05-05', { queenCellsPresent: true });
const secondId = await inspect('2026-05-08', { queenCellsPresent: true });
const all = await chainSuggestions();
expect(all.map((row) => row.status)).toEqual(['RESOLVED', 'OPEN']);
expect(all[1].dedupeKey).toBe(
`QUEEN_CELLS_SWARM_CHECK:${colonyId}:${secondId}`,
);
expect(dayOf(all[1].dueDate)).toBe('2026-05-15');
});
it('an inspection with neither queen seen nor eggs leaves the open acceptance check open; with eggs it resolves it', async () => {
await assignQueen('2026-05-05');
await inspect('2026-05-12');
expect(await openOf('QUEEN_ACCEPTANCE')).toHaveLength(1);
await inspect('2026-05-13', { eggsPresent: true });
expect(await openOf('QUEEN_ACCEPTANCE')).toHaveLength(0);
});
it('delivering the same InspectionRecorded and ColonySplit twice leaves exactly one suggestion each', async () => {
const inspectionId = await inspect('2026-05-05', {
queenCellsPresent: true,
});
await split('RAISE_OWN');
const handlers = app.get(ChainEventHandlers);
const events = await prisma.domainEvent.findMany({
where: { aggregateId: { in: aggregateIds } },
});
expect(events).toHaveLength(2);
// Redelivery after a crash: the handlers run again with the stored events.
for (const event of events) {
await prisma.$transaction(async (tx) => {
const envelope = { ...event, attempts: 1 } as never;
if (event.type === 'InspectionRecorded') {
await handlers.queenCells(envelope, tx);
} else {
await handlers.splitSchedule(envelope, tx);
}
});
}
const all = await chainSuggestions();
expect(all.map((row) => [row.intent, row.status])).toEqual([
['SWARM_CHECK', 'OPEN'],
['SPLIT_PLAN', 'OPEN'],
]);
expect(all[0].dedupeKey).toContain(inspectionId);
});
it('a backdated inspection with queen cells does not replace the newer open check', async () => {
await inspect('2026-05-08', { queenCellsPresent: true });
await inspect('2026-05-02', { queenCellsPresent: true });
const all = await chainSuggestions();
expect(all.map((row) => row.status)).toEqual(['OPEN']);
expect(dayOf(all[0].dueDate)).toBe('2026-05-15');
});
it('nothing for an ended colony', async () => {
await prisma.colony.update({
where: { id: colonyId },
data: { status: 'DISSOLVED' },
});
await inspect('2026-05-05', { queenCellsPresent: true });
await assignQueen('2026-05-05');
await split('QUEEN');
expect(await chainSuggestions()).toHaveLength(0);
});
});