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

@@ -55,20 +55,31 @@ describe('Post-commit side effects (e2e)', () => {
return colony;
}
/** Open or not: a rule task that exists at all. */
const taskCount = (trigger: AutomationTriggerType, relatedId: string) =>
prisma.task.count({
where: { sourceAutomationTrigger: trigger, relatedId },
/** Open or not: a rule suggestion that exists at all. */
const suggestionCount = (ruleKey: AutomationTriggerType, relatedId: string) =>
prisma.suggestion.count({ where: { ruleKey, relatedId } });
const openSuggestionCount = (
ruleKey: AutomationTriggerType,
relatedId: string,
) =>
prisma.suggestion.count({
where: { ruleKey, relatedId, status: 'OPEN' },
});
const openTaskCount = (trigger: AutomationTriggerType, relatedId: string) =>
prisma.task.count({
where: {
sourceAutomationTrigger: trigger,
relatedId,
status: { in: ['OPEN', 'IN_PROGRESS'] },
},
/** The notifications written for the suggestions of the given objects. */
async function suggestionNotifications(relatedIds: string[]) {
const ids = (
await prisma.suggestion.findMany({
where: { relatedId: { in: relatedIds } },
select: { id: true },
})
).map((suggestion) => suggestion.id);
return prisma.notification.findMany({
where: { relatedType: 'Suggestion', relatedId: { in: ids } },
select: { userId: true },
});
}
/** Everything this file wrote, scoped by its own ids. */
async function cleanUp(): Promise<void> {
@@ -86,13 +97,13 @@ describe('Post-commit side effects (e2e)', () => {
.flat()
.map((record) => record.id);
const relatedIds = [...colonyIds, ...stockItemIds];
const taskIds = (
await prisma.task.findMany({
const suggestionIds = (
await prisma.suggestion.findMany({
where: { relatedId: { in: relatedIds } },
select: { id: true },
})
).map((task) => task.id);
const aggregateIds = [...recordIds, ...taskIds];
).map((suggestion) => suggestion.id);
const aggregateIds = recordIds;
// Deliveries go with their event (ON DELETE CASCADE).
await prisma.domainEvent.deleteMany({
where: { aggregateId: { in: aggregateIds } },
@@ -100,12 +111,12 @@ describe('Post-commit side effects (e2e)', () => {
await prisma.calendarSyncOutboxEntry.deleteMany({
where: { sourceId: { in: aggregateIds } },
});
// Rule tasks and the rules' own entries are written without a user.
// Rule suggestions and the rules' own entries are written without a user.
await prisma.auditLog.deleteMany({
where: {
OR: [
{ userId },
{ entityType: 'Task', entityId: { in: taskIds } },
{ entityType: 'Suggestion', entityId: { in: suggestionIds } },
...relatedIds.map((relatedId) => ({
entityType: 'AutomationRule',
afterData: { path: ['relatedId'], equals: relatedId },
@@ -114,9 +125,11 @@ describe('Post-commit side effects (e2e)', () => {
},
});
await prisma.notification.deleteMany({
where: { relatedId: { in: relatedIds } },
where: { relatedType: 'Suggestion', relatedId: { in: suggestionIds } },
});
await prisma.suggestion.deleteMany({
where: { id: { in: suggestionIds } },
});
await prisma.task.deleteMany({ where: { id: { in: taskIds } } });
await prisma.inspection.deleteMany({ where: byColony.where });
await prisma.treatment.deleteMany({ where: byColony.where });
await prisma.varroaMeasurement.deleteMany({ where: byColony.where });
@@ -254,13 +267,11 @@ describe('Post-commit side effects (e2e)', () => {
attempts: 0,
}),
]);
// No rule ran in the request, not even one that failed: the task
// No rule ran in the request, not even one that failed: the suggestion
// follows when the handler runs.
expect(automationFailuresFor('Inspection', inspectionId)).toEqual([]);
expect(await taskCount('QUEENLESS_NO_EGGS', colony.id)).toBe(0);
expect(
await prisma.notification.count({ where: { relatedId: colony.id } }),
).toBe(0);
expect(await suggestionCount('QUEENLESS_NO_EGGS', colony.id)).toBe(0);
expect(await suggestionNotifications([colony.id])).toEqual([]);
});
it('still answers 201 for a treatment when the rule evaluation would fail, and replays it', async () => {
@@ -286,7 +297,7 @@ describe('Post-commit side effects (e2e)', () => {
});
describe('when an automation handler fails', () => {
it('creates exactly one automation task when the handler fails once and is retried', async () => {
it('creates exactly one automation suggestion when the handler fails once and is retried', async () => {
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
const colonyId = (await createColony('PC-RETRY')).id;
jest
@@ -302,20 +313,16 @@ describe('Post-commit side effects (e2e)', () => {
await drainDomainEvents(app, [body.id]);
// The warning failed, so the whole evaluation rolled back.
expect(await taskCount('QUEENLESS_NO_EGGS', colonyId)).toBe(0);
expect(
await prisma.notification.count({ where: { relatedId: colonyId } }),
).toBe(0);
expect(await suggestionCount('QUEENLESS_NO_EGGS', colonyId)).toBe(0);
expect(await suggestionNotifications([colonyId])).toEqual([]);
// Due again now instead of after the 30 s backoff.
await prisma.$executeRaw`UPDATE "DomainEvent" SET "availableAt" = now() WHERE "aggregateId" = ${body.id}`;
await drainDomainEvents(app, [body.id]);
expect(await taskCount('QUEENLESS_NO_EGGS', colonyId)).toBe(1);
// One copy per recipient (everybody who may read tasks), written once.
const warnings = await prisma.notification.findMany({
where: { relatedId: colonyId },
select: { userId: true },
});
expect(await suggestionCount('QUEENLESS_NO_EGGS', colonyId)).toBe(1);
// One copy per recipient (everybody who may read tasks and
// inspections), written once.
const warnings = await suggestionNotifications([colonyId]);
expect(warnings.length).toBeGreaterThan(0);
expect(new Set(warnings.map((w) => w.userId)).size).toBe(warnings.length);
@@ -337,11 +344,11 @@ describe('Post-commit side effects (e2e)', () => {
});
});
describe('automation tasks', () => {
it('leaves one open reorder task when several triggers for the same stock item arrive at once', async () => {
describe('automation suggestions', () => {
it('leaves one open reorder suggestion when several triggers for the same stock item arrive at once', async () => {
const automation = app.get(AutomationService);
// The same object crossing the threshold on several pods/requests at
// once: every evaluation sees "no open task yet" unless checking and
// once: every evaluation sees "nothing open yet" unless checking and
// creating are serialised.
const item = await prisma.stockItem.create({
data: {
@@ -358,10 +365,10 @@ describe('Post-commit side effects (e2e)', () => {
Array.from({ length: 8 }, () => automation.evaluateStockItem(item)),
);
expect(await openTaskCount('STOCK_ITEM_LOW', item.id)).toBe(1);
expect(await openSuggestionCount('STOCK_ITEM_LOW', item.id)).toBe(1);
});
it('leaves one open task when several triggers for the same colony arrive at once', async () => {
it('leaves one open suggestion when several triggers for the same colony arrive at once', async () => {
const colony = await createColony('PC-RACE');
const measurement = {
colonyId: colony.id,
@@ -384,7 +391,7 @@ describe('Post-commit side effects (e2e)', () => {
),
);
expect(await openTaskCount('VARROA_THRESHOLD', colony.id)).toBe(1);
expect(await openSuggestionCount('VARROA_THRESHOLD', colony.id)).toBe(1);
});
});
});