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:
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', () => {
|
||||
|
||||
@@ -71,7 +71,8 @@ export interface BackupImportSummary {
|
||||
* - siteSettings: keeper name/address printed on the Bestandsbuch and the
|
||||
* public links.
|
||||
* - document: writes files to the server's disk.
|
||||
* - notification: messages shown to users as coming from ApiCore.
|
||||
* - notification, notificationPreference: messages shown to users as coming
|
||||
* from ApiCore, and settings of a user.
|
||||
*/
|
||||
export const LEGACY_UNSEALED_TABLES = [
|
||||
'apiary',
|
||||
@@ -188,6 +189,23 @@ function withCurrentFeedVocabulary(row: Row): Row {
|
||||
};
|
||||
}
|
||||
|
||||
// Bundles written before the notification categories carry none: job failures
|
||||
// (relatedType 'JobRun') and rows without relatedType are system messages,
|
||||
// everything else a suggestion - the rule of migration 11.
|
||||
function withCurrentNotificationCategory(row: Row): Row {
|
||||
if (typeof row.category === 'string') {
|
||||
return row;
|
||||
}
|
||||
const relatedType = asString(row, 'relatedType');
|
||||
return {
|
||||
...row,
|
||||
category:
|
||||
relatedType === null || relatedType === 'JobRun'
|
||||
? 'SYSTEM'
|
||||
: 'SUGGESTION',
|
||||
};
|
||||
}
|
||||
|
||||
function documentsStorageRoot(): string {
|
||||
return process.env.DOCUMENTS_STORAGE_PATH ?? './uploads';
|
||||
}
|
||||
@@ -316,6 +334,8 @@ export class BackupService {
|
||||
);
|
||||
|
||||
data.notification = await this.prisma.notification.findMany();
|
||||
data.notificationPreference =
|
||||
await this.prisma.notificationPreference.findMany();
|
||||
data.auditLog = await this.prisma.auditLog.findMany();
|
||||
|
||||
return {
|
||||
@@ -1125,20 +1145,86 @@ export class BackupService {
|
||||
}
|
||||
imported.document = documentCount;
|
||||
|
||||
const activeUserIds = (
|
||||
await tx.user.findMany({
|
||||
where: { isActive: true },
|
||||
select: { id: true },
|
||||
orderBy: { id: 'asc' },
|
||||
})
|
||||
).map((user) => user.id);
|
||||
let notificationsWritten = 0;
|
||||
let notificationsSkipped = 0;
|
||||
for (const row of rowsOf('notification')) {
|
||||
await tx.notification.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: {
|
||||
...row,
|
||||
userId: resolveOptionalUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
update: {
|
||||
...row,
|
||||
userId: resolveOptionalUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
});
|
||||
const withCategory = withCurrentNotificationCategory(row);
|
||||
const rawUserId = asString(row, 'userId');
|
||||
// A bundle from before the per-user notifications carries
|
||||
// broadcasts (no userId): one copy per active user, as migration 8.
|
||||
// A row of a user this database does not know is skipped - the
|
||||
// userId is mandatory and never falls back to somebody else.
|
||||
const owners =
|
||||
rawUserId === null
|
||||
? activeUserIds
|
||||
: knownUserIds.has(rawUserId)
|
||||
? [rawUserId]
|
||||
: [];
|
||||
if (owners.length === 0) {
|
||||
notificationsSkipped += 1;
|
||||
continue;
|
||||
}
|
||||
for (const userId of owners) {
|
||||
const data = {
|
||||
...withCategory,
|
||||
id:
|
||||
rawUserId === null
|
||||
? `${row.id as string}:${userId}`
|
||||
: (row.id as string),
|
||||
userId,
|
||||
};
|
||||
// The unique dedupeKey of a row that lives here under another id
|
||||
// would abort the whole restore: skip that row instead.
|
||||
const dedupeKey = asString(data, 'dedupeKey');
|
||||
if (dedupeKey !== null) {
|
||||
const holder = await tx.notification.findUnique({
|
||||
where: { dedupeKey },
|
||||
select: { id: true },
|
||||
});
|
||||
if (holder && holder.id !== data.id) {
|
||||
notificationsSkipped += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
await tx.notification.upsert({
|
||||
where: { id: data.id },
|
||||
create: data as never,
|
||||
// A copy made from a broadcast is only added: a repeated
|
||||
// restore must not reset what the user already read.
|
||||
update: (rawUserId === null ? {} : data) as never,
|
||||
});
|
||||
notificationsWritten += 1;
|
||||
}
|
||||
}
|
||||
imported.notification = rowsOf('notification').length;
|
||||
imported.notification = notificationsWritten;
|
||||
if (notificationsSkipped > 0) {
|
||||
imported.notificationSkipped = notificationsSkipped;
|
||||
this.logger.warn(
|
||||
`${notificationsSkipped} Meldung(en) übersprungen: Nutzer im Zielsystem unbekannt.`,
|
||||
);
|
||||
}
|
||||
|
||||
let preferencesWritten = 0;
|
||||
for (const row of rowsOf('notificationPreference')) {
|
||||
const userId = asString(row, 'userId');
|
||||
if (userId === null || !knownUserIds.has(userId)) {
|
||||
continue;
|
||||
}
|
||||
await tx.notificationPreference.upsert({
|
||||
where: { userId },
|
||||
create: row as never,
|
||||
update: row as never,
|
||||
});
|
||||
preferencesWritten += 1;
|
||||
}
|
||||
imported.notificationPreference = preferencesWritten;
|
||||
|
||||
// Append-only: entries already here are never rewritten by a file.
|
||||
for (const row of rowsOf('auditLog')) {
|
||||
|
||||
Reference in new issue
Block a user