feat: T4 wave 3 - notifications per user, suggestions API, varroa bands, split/queen events

- Schema III: per-user notifications (broadcast fan-out migration,
  category, link, requiredPermissions filter), notification preferences
  and settings endpoints, calendar withdrawal switch, occupiedFrames
- Suggestions API: list/summary with permission visibility and paging,
  accept/dismiss/reopen incl. bulk, SuggestionView on all responses
- Varroa traffic light by season and method, young colonies, treatment
  effect window, sample-based percentage; shared withdrawal-window helper
  on Berlin calendar days; GET /varroa-measurements/thresholds
- ColonySplit and QueenAssigned domain events with calendar and webhook
  handlers
- Outbox claims compare against now() rounded to timestamp(3); fixes the
  flaky e2e claims of rows written with SQL now()

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 14:25:24 +02:00
1 parent d99a697739
commit f468dc5f75
87 files changed
+6022 -510

No files matched your search

@@ -0,0 +1,143 @@
import type { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import type { PrismaService } from '../prisma/prisma.service';
import { SuggestionViewsService } from './suggestion-views.service';
const NOW = new Date('2026-05-05T08:00:00.000Z');
function row(id: string, colonyId: string) {
return {
id,
ruleKey: 'INSPECTION_INTERVAL',
source: 'RULE',
mode: 'DO',
intent: 'INSPECT',
status: 'OPEN',
priority: 'NORMAL',
relatedType: 'COLONY',
relatedId: colonyId,
colonyId,
colony: { code: 'B3', apiaryId: 'api_1', apiary: { name: 'Hang' } },
reasonCode: 'UNKNOWN_CODE',
evidence: { colonyId },
requiredPermission: 'inspections:read',
dueDate: new Date('2026-05-07T00:00:00.000Z'),
expiresAt: null,
plan: null,
decidedAt: null,
decisionNote: null,
createdAt: NOW,
};
}
function build(rows: ReturnType<typeof row>[]) {
const suggestionFindMany = jest.fn().mockResolvedValue(rows);
const colonyFindMany = jest
.fn()
.mockResolvedValue([{ id: 'col_1', code: 'B3' }]);
const prisma = {
suggestion: { findMany: suggestionFindMany, groupBy: jest.fn() },
colony: { findMany: colonyFindMany },
queen: { findMany: jest.fn().mockResolvedValue([]) },
treatment: { findMany: jest.fn().mockResolvedValue([]) },
apiary: { findMany: jest.fn().mockResolvedValue([]) },
stockItem: { findMany: jest.fn().mockResolvedValue([]) },
automationRule: { findMany: jest.fn().mockResolvedValue([]) },
$queryRaw: jest.fn().mockResolvedValue([{ cutoff: NOW }]),
};
const resolver = {
getPermissions: jest.fn().mockResolvedValue(new Set(['inspections:read'])),
};
const service = new SuggestionViewsService(
prisma as unknown as PrismaService,
resolver as unknown as PermissionResolverService,
);
return { service, prisma, suggestionFindMany, colonyFindMany };
}
describe('SuggestionViewsService', () => {
it('filters by requiredPermission in the query, not after loading', async () => {
const { service, suggestionFindMany } = build([]);
await service.list('user_1', {});
const [args] = suggestionFindMany.mock.calls[0] as [
{ where: { AND: unknown[] } },
];
expect(args.where.AND).toContainEqual({
OR: [
{ requiredPermission: null },
{ requiredPermission: { in: ['inspections:read'] } },
],
});
expect(service.visibleWhere(new Set(['a:read']))).toEqual({
OR: [
{ requiredPermission: null },
{ requiredPermission: { in: ['a:read'] } },
],
});
});
it('renders title and reasons with one lookup for all rows', async () => {
const { service, colonyFindMany } = build([
row('s1', 'col_1'),
row('s2', 'col_1'),
row('s3', 'col_1'),
]);
const views = await service.list('user_1', {});
expect(views.map((view) => view.id)).toEqual(['s1', 's2', 's3']);
expect(views[0]).toMatchObject({
title: 'Durchsicht fällig',
colonyCode: 'B3',
apiaryId: 'api_1',
apiaryName: 'Hang',
dueDate: '2026-05-07',
cleanup: null,
});
expect(colonyFindMany).toHaveBeenCalledTimes(1);
});
it('lists a dismissed suggestion only within 30 days of its decision', async () => {
const { service, suggestionFindMany, prisma } = build([]);
await service.list('user_1', { status: 'DISMISSED' });
expect(prisma.$queryRaw).toHaveBeenCalledTimes(1);
const [dismissedArgs] = suggestionFindMany.mock.calls[0] as [
{ where: unknown },
];
expect(JSON.stringify(dismissedArgs.where)).toContain(
'"decidedAt":{"gte":"2026-05-05T08:00:00.000Z"}',
);
});
it('reads the cleanup tasks from evidence.openTaskIds', async () => {
const cleanup = {
...row('s9', 'col_1'),
intent: 'CLEANUP',
evidence: { colonyId: 'col_1', openTaskIds: ['t1', 't2'], queenId: 'q1' },
};
const { service } = build([cleanup]);
const [view] = await service.list('user_1', {});
expect(view.cleanup).toEqual({ taskIds: ['t1', 't2'], queenId: 'q1' });
});
it('passes limit (default 200) and offset to the query', async () => {
const { service, suggestionFindMany } = build([]);
await service.list('user_1', {});
await service.list('user_1', { limit: 5, offset: 10 });
expect((suggestionFindMany.mock.calls[0] as unknown[])[0]).toMatchObject({
take: 200,
skip: 0,
});
expect((suggestionFindMany.mock.calls[1] as unknown[])[0]).toMatchObject({
take: 5,
skip: 10,
});
});
});