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

+231 -7
View File
@@ -1,9 +1,14 @@
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 { SuggestionsService } from '../src/suggestions/suggestions.service';
import type { ProposeInput } from '../src/suggestions/suggestion.types';
import { berlinToday } from '../src/common/dates';
import { login } from './utils/api';
import { createTestApp } from './utils/create-test-app';
import { drainDomainEvents } from './utils/domain-events';
/**
* The suggestion core against a real PostgreSQL: the dedupe key, the SQL
@@ -17,6 +22,9 @@ describe('Suggestions (e2e)', () => {
let apiaryId: string;
let colonyId: string;
let userId: string;
const roleIds: string[] = [];
const extraUserIds: string[] = [];
const password = 'SuperSecret123!';
const proposal = (anchor: string): ProposeInput => ({
ruleKey: 'SPLIT_SCHEDULE',
source: 'CHAIN',
@@ -69,23 +77,64 @@ describe('Suggestions (e2e)', () => {
});
afterAll(async () => {
await prisma.task.deleteMany({
where: { sourceSuggestion: { colonyId } },
await clearSuggestions();
const inspectionIds = (
await prisma.inspection.findMany({
where: { colonyId },
select: { id: true },
})
).map((inspection) => inspection.id);
await prisma.domainEvent.deleteMany({
where: { aggregateId: { in: inspectionIds } },
});
await prisma.suggestion.deleteMany({ where: { colonyId } });
await prisma.calendarSyncOutboxEntry.deleteMany({
where: { sourceId: { in: inspectionIds } },
});
await prisma.inspection.deleteMany({ where: { colonyId } });
await prisma.colony.delete({ where: { id: colonyId } });
await prisma.apiary.delete({ where: { id: apiaryId } });
await prisma.auditLog.deleteMany({ where: { userId } });
await prisma.user.delete({ where: { id: userId } });
await prisma.refreshToken.deleteMany({
where: { userId: { in: extraUserIds } },
});
await prisma.auditLog.deleteMany({
where: { userId: { in: [userId, ...extraUserIds] } },
});
await prisma.user.deleteMany({
where: { id: { in: [userId, ...extraUserIds] } },
});
await prisma.role.deleteMany({ where: { id: { in: roleIds } } });
await app.close();
});
afterEach(async () => {
/** The colony's suggestions with what hangs on them: tasks, notifications, audit. */
async function clearSuggestions(): Promise<void> {
const ids = (
await prisma.suggestion.findMany({
where: { colonyId },
select: { id: true },
})
).map((suggestion) => suggestion.id);
await prisma.task.deleteMany({
where: { sourceSuggestion: { colonyId } },
});
await prisma.notification.deleteMany({
where: { relatedType: 'Suggestion', relatedId: { in: ids } },
});
await prisma.auditLog.deleteMany({
where: {
OR: [
{ entityType: 'Suggestion', entityId: { in: ids } },
{
entityType: 'AutomationRule',
afterData: { path: ['relatedId'], equals: colonyId },
},
],
},
});
await prisma.suggestion.deleteMany({ where: { colonyId } });
});
}
afterEach(clearSuggestions);
it('proposes once per anchor and writes an audit entry', async () => {
const created = await propose(proposal('a1'));
@@ -412,4 +461,179 @@ describe('Suggestions (e2e)', () => {
expect(obsolete.status).toBe('OBSOLETE');
expect(obsolete.dedupeKey).not.toBeNull();
});
describe('rule suggestions', () => {
let writer: { id: string; token: string };
let withoutInspections: { id: string };
let withoutTasks: { id: string };
async function createUser(
label: string,
permissions: string[],
): Promise<{ id: string; token: string }> {
const role = await prisma.role.create({
data: {
key: `SUG_RULES_${label.toUpperCase()}_${suffix}`,
name: `Suggestions rules ${label}`,
permissions: {
create: permissions.map((key) => ({
permission: { connect: { key } },
})),
},
},
});
roleIds.push(role.id);
const email = `suggestions-rules-${label}-${suffix}@example.com`;
const user = await prisma.user.create({
data: {
email,
passwordHash: await bcrypt.hash(password, 12),
name: `Suggestions rules ${label}`,
role: { connect: { id: role.id } },
},
});
extraUserIds.push(user.id);
return {
id: user.id,
token: (await login(app, email, password)).accessToken,
};
}
beforeAll(async () => {
writer = await createUser('writer', [
'inspections:create',
'inspections:read',
'tasks:read',
]);
withoutInspections = await createUser('noinsp', ['tasks:read']);
withoutTasks = await createUser('notasks', ['inspections:read']);
});
/** Records an inspection and runs the handlers of its event. */
async function inspect(body: object): Promise<string> {
const response = await request(app.getHttpServer())
.post('/api/v1/inspections')
.set('Authorization', `Bearer ${writer.token}`)
.send({ colonyId, date: new Date().toISOString(), ...body })
.expect(201);
const id = (response.body as { id: string }).id;
await drainDomainEvents(app, [id]);
return id;
}
const queenless = { queenSeen: false, eggsPresent: false };
const ruleSuggestions = () =>
prisma.suggestion.findMany({
where: { colonyId, ruleKey: 'QUEENLESS_NO_EGGS' },
});
it('an inspection without queen and eggs leaves one OPEN suggestion and no task', async () => {
const inspectionId = await inspect(queenless);
const suggestions = await ruleSuggestions();
expect(suggestions).toHaveLength(1);
expect(suggestions[0]).toMatchObject({
status: 'OPEN',
source: 'RULE',
mode: 'PLAN',
intent: 'QUEEN_CHECK',
dedupeKey: `QUEENLESS_NO_EGGS:${colonyId}:${inspectionId}`,
});
expect(await prisma.task.count({ where: { relatedId: colonyId } })).toBe(
0,
);
});
it('a dismissed suggestion is not proposed again for the same inspection', async () => {
const inspectionId = await inspect(queenless);
const [first] = await ruleSuggestions();
await service.dismiss(first.id, null, userId);
// The delivery runs again, as after a retry or a catch-up.
await prisma.domainEvent.updateMany({
where: { aggregateId: inspectionId },
data: { status: 'PENDING', processedAt: null, claimedAt: null },
});
await prisma.domainEventDelivery.updateMany({
where: {
handler: 'automation.inspection-rules',
event: { aggregateId: inspectionId },
},
data: { status: 'PENDING', attempts: 0, completedAt: null },
});
await drainDomainEvents(app, [inspectionId]);
const suggestions = await ruleSuggestions();
expect(suggestions).toHaveLength(1);
expect(suggestions[0].status).toBe('DISMISSED');
});
it('a later inspection with the same finding proposes again', async () => {
await inspect(queenless);
const [first] = await ruleSuggestions();
await service.dismiss(first.id, null, userId);
await inspect(queenless);
const suggestions = await ruleSuggestions();
expect(suggestions.map((suggestion) => suggestion.status).sort()).toEqual(
['DISMISSED', 'OPEN'],
);
});
it('accepting a rule suggestion without dueInDays creates a task due today', async () => {
await inspect(queenless);
const [open] = await ruleSuggestions();
expect(open.dueDate).toBeNull();
const result = await service.accept(open.id, {}, userId);
expect(result.tasks).toHaveLength(1);
expect(result.tasks[0].dueDate?.toISOString().slice(0, 10)).toBe(
berlinToday(),
);
expect(result.tasks[0]).toMatchObject({
sourceSuggestionId: open.id,
sourceAutomationTrigger: 'QUEENLESS_NO_EGGS',
priority: 'HIGH',
});
});
it('a chain suggestion proposed with notify true notifies like a rule suggestion', async () => {
const created = (await prisma.$transaction((tx) =>
service.proposeIn(tx, {
...proposal('notify-chain'),
notify: true,
requiredPermission: 'colonies:read',
}),
))!;
const rows = await prisma.notification.findMany({
where: { relatedType: 'Suggestion', relatedId: created.id },
});
expect(rows.length).toBeGreaterThan(0);
expect(rows.every((row) => row.category === 'SUGGESTION')).toBe(true);
expect(rows.every((row) => row.priority === 'WARNUNG')).toBe(true);
expect(rows.every((row) => row.link === `/colonies/${colonyId}`)).toBe(
true,
);
});
it('notify writes a WARNUNG only to holders of tasks:read and inspections:read', async () => {
await inspect(queenless);
const [open] = await ruleSuggestions();
const rows = await prisma.notification.findMany({
where: { relatedType: 'Suggestion', relatedId: open.id },
});
const recipients = new Set(rows.map((row) => row.userId));
expect(recipients.has(writer.id)).toBe(true);
expect(recipients.has(withoutInspections.id)).toBe(false);
expect(recipients.has(withoutTasks.id)).toBe(false);
expect(rows.every((row) => row.priority === 'WARNUNG')).toBe(true);
expect(
rows.every((row) => row.message.startsWith(`SUG-${suffix}: `)),
).toBe(true);
});
});
});