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

@@ -59,7 +59,7 @@ describe('JobRunService', () => {
$executeRaw: jest.fn<Promise<number>, RawCall>().mockResolvedValue(1),
$queryRaw: jest
.fn<Promise<unknown>, RawCall>()
.mockResolvedValue([{ notified: false }]),
.mockResolvedValue([{ notified: false, day: '2026-10-13' }]),
};
prisma = {
jobRun,
@@ -71,7 +71,9 @@ describe('JobRunService', () => {
Promise.resolve([runRow({ status: status as JobRun['status'] })]),
),
};
notifications = { recordForPermission: jest.fn().mockResolvedValue(2) };
notifications = {
recordForPermission: jest.fn().mockResolvedValue([{ id: 'n1' }]),
};
service = new JobRunService(
prisma as unknown as PrismaService,
notifications as unknown as NotificationsService,
@@ -177,9 +179,11 @@ describe('JobRunService', () => {
'automation:update',
{
priority: 'WARNUNG',
category: 'SYSTEM',
message: 'Job „Tägliche Auswertung“ fehlgeschlagen',
relatedType: 'JobRun',
relatedId: 'run-1',
dedupeKeyPrefix: 'job-failed:daily-evaluation:2026-10-13',
},
tx,
);
@@ -188,7 +192,7 @@ describe('JobRunService', () => {
// The hourly app-backup check would otherwise report one outage every
// hour. The check and the write share a transaction behind a lock per
// job, so two failing runs at once cannot both pass the check.
it('notifies at most once per job and UTC day, decided by the database clock', async () => {
it('notifies at most once per job and Berlin day, decided by the database clock', async () => {
tx.$queryRaw.mockResolvedValue([{ notified: true }]);
await service.run('app-backup', () => Promise.reject(new Error('boom')));
@@ -198,7 +202,13 @@ describe('JobRunService', () => {
expect(sqlText(lock)).toContain('pg_advisory_xact_lock');
expect(lock.slice(1)).toEqual(['job-failure-notification:app-backup']);
const check = tx.$queryRaw.mock.calls[0];
expect(sqlText(check)).toContain(`date_trunc('day', now())`);
expect(sqlText(check)).toContain(
`date_trunc('day', now() AT TIME ZONE 'Europe/Berlin')`,
);
// The key carries the same day the check uses.
expect(sqlText(check)).toContain(
`to_char(now() AT TIME ZONE 'Europe/Berlin'`,
);
expect(check.slice(1)).toEqual(['JobRun', 'app-backup']);
expect(tx.$executeRaw.mock.invocationCallOrder[0]).toBeLessThan(
tx.$queryRaw.mock.invocationCallOrder[0],