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:
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user