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