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
@@ -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],
|
||||
|
||||
@@ -162,24 +162,29 @@ export class JobRunService {
|
||||
}
|
||||
|
||||
/**
|
||||
* At most one notification per job and UTC day (database clock): the
|
||||
* At most one notification per job and Berlin calendar day (database clock,
|
||||
* the day logic of the rest of the app; the same day is in the dedupe key): the
|
||||
* hourly app-backup check would otherwise report the same outage every
|
||||
* hour. The advisory lock per job serialises check and write, so two
|
||||
* runs failing at once cannot both pass the check. A notification that
|
||||
* runs failing at once cannot both pass the check; the dedupe key per
|
||||
* recipient is a second guard. A notification that
|
||||
* cannot be written is logged; the run stays FAILED either way.
|
||||
*/
|
||||
private async notifyFailure(job: JobName, runId: string): Promise<void> {
|
||||
try {
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${`job-failure-notification:${job}`}, 0))`;
|
||||
const [{ notified }] = await tx.$queryRaw<{ notified: boolean }[]>`
|
||||
const [{ notified, day }] = await tx.$queryRaw<
|
||||
{ notified: boolean; day: string }[]
|
||||
>`
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM "Notification" n
|
||||
JOIN "JobRun" r ON r."id" = n."relatedId"
|
||||
WHERE n."relatedType" = ${JOB_RUN_RELATED_TYPE}
|
||||
AND r."job" = ${job}
|
||||
AND n."createdAt" >= date_trunc('day', now())
|
||||
) AS "notified"`;
|
||||
AND n."createdAt" >= date_trunc('day', now() AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'Europe/Berlin'
|
||||
) AS "notified",
|
||||
to_char(now() AT TIME ZONE 'Europe/Berlin', 'YYYY-MM-DD') AS "day"`;
|
||||
if (notified) {
|
||||
return;
|
||||
}
|
||||
@@ -187,9 +192,11 @@ export class JobRunService {
|
||||
JOB_FAILURE_PERMISSION[job],
|
||||
{
|
||||
priority: 'WARNUNG',
|
||||
category: 'SYSTEM',
|
||||
message: `Job „${JOB_LABELS_DE[job]}“ fehlgeschlagen`,
|
||||
relatedType: JOB_RUN_RELATED_TYPE,
|
||||
relatedId: runId,
|
||||
dedupeKeyPrefix: `job-failed:${job}:${day}`,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user