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

@@ -24,6 +24,7 @@ describe('NotificationsService', () => {
createManyAndReturn: jest.Mock;
findMany: jest.Mock;
findFirstOrThrow: jest.Mock;
findFirst: jest.Mock;
updateMany: jest.Mock;
};
user: { findMany: jest.Mock };
@@ -39,6 +40,7 @@ describe('NotificationsService', () => {
),
findMany: jest.fn().mockResolvedValue([]),
findFirstOrThrow: jest.fn(),
findFirst: jest.fn(),
updateMany: jest.fn(),
},
user: { findMany: jest.fn().mockResolvedValue([]) },
@@ -351,17 +353,33 @@ describe('NotificationsService', () => {
});
it('answers 404 when the notification is foreign or unknown', async () => {
prisma.notification.updateMany.mockResolvedValue({ count: 0 });
prisma.notification.findFirst.mockResolvedValue(null);
await expect(service.markRead('n1', 'user-1')).rejects.toBeInstanceOf(
NotFoundException,
);
expect(prisma.notification.updateMany).toHaveBeenCalledWith({
expect(prisma.notification.findFirst).toHaveBeenCalledWith({
where: { id: 'n1', userId: 'user-1' },
data: { read: true },
select: { requiredPermissions: true },
});
expect(prisma.notification.updateMany).not.toHaveBeenCalled();
});
it('marks the own notification as read', async () => {
it('answers 404 and changes nothing for a row whose permissions are no longer held', async () => {
prisma.notification.findFirst.mockResolvedValue({
requiredPermissions: ['colonies:read'],
});
await expect(service.markRead('n1', 'user-1')).rejects.toBeInstanceOf(
NotFoundException,
);
expect(prisma.notification.updateMany).not.toHaveBeenCalled();
expect(prisma.notification.findFirstOrThrow).not.toHaveBeenCalled();
});
it('marks the own visible notification as read', async () => {
held.add('colonies:read');
prisma.notification.findFirst.mockResolvedValue({
requiredPermissions: ['colonies:read'],
});
prisma.notification.updateMany.mockResolvedValue({ count: 1 });
prisma.notification.findFirstOrThrow.mockResolvedValue({
id: 'n1',
@@ -371,16 +389,30 @@ describe('NotificationsService', () => {
await expect(service.markRead('n1', 'user-1')).resolves.toMatchObject({
read: true,
});
expect(prisma.notification.updateMany).toHaveBeenCalledWith({
where: { id: 'n1', userId: 'user-1' },
data: { read: true },
});
});
it('marks only the own unread notifications as read', async () => {
prisma.notification.updateMany.mockResolvedValue({ count: 3 });
it('marks only the own unread notifications the user may see', async () => {
held.add('colonies:read');
prisma.notification.findMany.mockResolvedValue([
{ id: 'a', requiredPermissions: [] },
{ id: 'b', requiredPermissions: ['colonies:read'] },
{ id: 'c', requiredPermissions: ['varroa:read'] },
]);
prisma.notification.updateMany.mockResolvedValue({ count: 2 });
await expect(service.markAllRead('user-1')).resolves.toEqual({
updated: 3,
updated: 2,
});
expect(prisma.notification.findMany).toHaveBeenCalledWith({
where: { userId: 'user-1', read: false },
select: { id: true, requiredPermissions: true },
});
expect(prisma.notification.updateMany).toHaveBeenCalledWith({
where: { userId: 'user-1', read: false },
where: { id: { in: ['a', 'b'] }, userId: 'user-1', read: false },
data: { read: true },
});
});