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

@@ -72,6 +72,7 @@ const ALL_ENTITY_KEYS = [
...BUSINESS_ENTITY_KEYS,
'document',
'notification',
'notificationPreference',
'auditLog',
] as const;
@@ -113,6 +114,10 @@ describe('BackupService', () => {
upsert: jest.fn().mockResolvedValue({ id: `${key}-upserted` }),
};
}
tx.notification = {
upsert: jest.fn().mockResolvedValue({ id: 'notification-upserted' }),
findUnique: jest.fn().mockResolvedValue(null),
} as never;
tx.queen.update = jest.fn().mockResolvedValue({});
tx.queen.findMany = jest.fn().mockResolvedValue([]);
tx.document.findUnique = jest.fn().mockResolvedValue(null);
@@ -1304,6 +1309,148 @@ describe('BackupService', () => {
});
});
describe('notifications', () => {
const note = (overrides: Row = {}): Row => ({
id: 'n1',
userId: 'u1',
priority: 'INFO',
category: 'SUGGESTION',
message: 'm',
relatedType: 'COLONY',
relatedId: 'c1',
read: true,
createdAt: '2026-01-01T00:00:00.000Z',
...overrides,
});
const without = (row: Row, ...keys: string[]): Row =>
Object.fromEntries(
Object.entries(row).filter(([key]) => !keys.includes(key)),
);
const importNotes = (rows: Row[]) =>
service.importAll(
{
version: 3,
exportedAt: '2026-01-01T00:00:00.000Z',
data: { notification: rows },
},
'admin-1',
TRUSTED,
);
const written = () =>
(
tx.notification.upsert.mock.calls as [
{ where: { id: string }; create: Row },
][]
).map(([arg]) => arg.create);
beforeEach(() => {
tx.user.findMany.mockResolvedValue([{ id: 'u1' }, { id: 'u2' }]);
});
it('restore keeps the userId of a notification and skips a row whose user does not exist in the target', async () => {
const summary = await importNotes([
note({ id: 'n1', userId: 'u2' }),
note({ id: 'n2', userId: 'gone' }),
]);
expect(written().map((row) => [row.id, row.userId])).toEqual([
['n1', 'u2'],
]);
expect(summary.imported.notification).toBe(1);
expect(summary.imported.notificationSkipped).toBe(1);
});
it('skips and counts a row whose dedupeKey already exists under another id', async () => {
(
tx.notification as unknown as { findUnique: jest.Mock }
).findUnique.mockImplementation(
({ where }: { where: { dedupeKey: string } }) =>
Promise.resolve(
where.dedupeKey === 'k:u1' ? { id: 'other-id' } : null,
),
);
const summary = await importNotes([
note({ id: 'n1', dedupeKey: 'k:u1' }),
note({ id: 'n2', dedupeKey: 'k:u2' }),
// Same id as the stored row: a plain re-restore, written as before.
note({ id: 'other-id', dedupeKey: 'k:u1' }),
]);
expect(written().map((row) => row.id)).toEqual(['n2', 'other-id']);
expect(summary.imported.notification).toBe(2);
expect(summary.imported.notificationSkipped).toBe(1);
});
it('a repeated restore of broadcast copies does not reset their read flags', async () => {
const { userId: _u, category: _c, ...old } = note({ id: 'b' });
void _u;
void _c;
await importNotes([old]);
const calls = tx.notification.upsert.mock.calls as [{ update: Row }][];
expect(calls).toHaveLength(2);
expect(calls.every(([arg]) => Object.keys(arg.update).length === 0)).toBe(
true,
);
});
it('never writes a notification without a userId', async () => {
await importNotes([
note({ userId: null }),
note({ id: 'n3', userId: 'gone' }),
]);
expect(written().every((row) => typeof row.userId === 'string')).toBe(
true,
);
});
it('an old bundle notification without userId is copied per active user: category SYSTEM for relatedType JobRun or none, SUGGESTION otherwise', async () => {
const old = (id: string, relatedType: string | null): Row => {
return without(note({ id, relatedType }), 'userId', 'category');
};
await importNotes([
old('a', 'JobRun'),
old('b', null),
old('c', 'COLONY'),
]);
const rows = written().map((row) => [row.userId, row.category, row.read]);
expect(rows).toEqual([
['u1', 'SYSTEM', true],
['u2', 'SYSTEM', true],
['u1', 'SYSTEM', true],
['u2', 'SYSTEM', true],
['u1', 'SUGGESTION', true],
['u2', 'SUGGESTION', true],
]);
expect(new Set(written().map((row) => row.id)).size).toBe(6);
});
it('fills the category of an old row that has a userId but none', async () => {
await importNotes([without(note({ relatedType: 'JobRun' }), 'category')]);
expect(written()[0]).toMatchObject({ userId: 'u1', category: 'SYSTEM' });
});
it('exports and restores the notification preferences of the users', async () => {
prisma.notificationPreference.findMany.mockResolvedValue([
{ userId: 'u1', digestTime: '07:00' },
{ userId: 'gone', digestTime: '08:00' },
]);
const bundle = await service.exportAll();
expect(bundle.data.notificationPreference).toHaveLength(2);
await service.importAll(bundle, 'admin-1', TRUSTED);
const calls = tx.notificationPreference.upsert.mock.calls as [
{ where: { userId: string } },
][];
expect(calls.map(([arg]) => arg.where.userId)).toEqual(['u1']);
});
});
// Drift guard: a model added to the schema has to be backed up or listed
// in MODELS_EXCLUDED_FROM_BACKUP with its reason - never silently missing.
describe('model coverage', () => {