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

+99 -13
View File
@@ -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')) {