feat: T4 wave 6 - reminders and morning digest, next steps UI, warnings and notifications UI

- In-app reminders: task due (skips rescheduled tasks, catch-up wording)
  and a per-user morning digest step in the daily job
- Frontend: "Als Nächstes" on the colony page, "Heute" on the dashboard,
  inspection-due card, season chip, apiary overview fields, cleanup
  sheet for ended colonies
- Frontend: plausibility warnings that stay until dismissed, withdrawal
  spans in the calendar, notification bell with categories and links,
  notification settings in the profile
- ARCHITECTURE.md: T4a sections 11.1-11.10, assumptions 31-41, handler
  table and daily job step order

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 17:05:32 +02:00
1 parent ef33e5549b
commit 32c61c8e14
77 files changed
+5309 -142

No files matched your search

+2
View File
@@ -28,6 +28,7 @@ import { DashboardModule } from './dashboard/dashboard.module';
import { SearchModule } from './search/search.module';
import { ExportModule } from './export/export.module';
import { QrModule } from './qr/qr.module';
import { RemindersModule } from './reminders/reminders.module';
import { ProductsModule } from './products/products.module';
import { BottlingModule } from './bottling/bottling.module';
import { ProductionBatchesModule } from './production-batches/production-batches.module';
@@ -114,6 +115,7 @@ import { IdempotencyInterceptor } from './common/idempotency/idempotency.interce
CleanupModule,
AutopilotModule,
WithdrawalModule,
RemindersModule,
LabelTemplatesModule,
ImportModule,
PublicModule,
@@ -233,36 +233,13 @@ export class NextStepsService {
);
return {
nextStep: this.mostUrgent(colonies, today),
inspectionDue: sortedApiaries.flatMap((season) => {
const due = [...colonies]
.filter(
([, c]) =>
c.apiaryId === season.apiaryId &&
c.view.inspectionDueOn !== null &&
c.view.inspectionDueOn <= today,
)
.map(([id, c]) => ({
id,
code: c.code,
dueOn: c.view.inspectionDueOn ?? today,
overdueDays: daysBetween(c.view.inspectionDueOn ?? today, today),
}))
.sort((a, b) => a.dueOn.localeCompare(b.dueOn) || byCode(a, b));
return due.length === 0
? []
: [
{
apiaryId: season.apiaryId,
apiaryName: nameOf(season.apiaryId),
segment: season.segment,
intervalDays:
season.segment === 'WINTERRUHE'
? null
: intervalDays[season.segment],
colonies: due,
},
];
}),
inspectionDue: this.dueInspections(
colonies,
sortedApiaries,
intervalDays,
nameOf,
today,
),
suggestionsOpen,
seasons: sortedApiaries.map((season) => ({
apiaryId: season.apiaryId,
@@ -275,6 +252,75 @@ export class NextStepsService {
};
}
/**
* Only the due inspections of all living colonies, whatever the reader
* may see (the caller filters by right): the digest's input.
*/
async inspectionDueAll(
today: string = berlinToday(),
): Promise<AutopilotToday['inspectionDue']> {
const living = await this.prisma.colony.findMany({
where: LIVING_COLONY_WHERE,
select: { id: true },
});
const { colonies, seasons, intervalDays } = await this.compute(
living.map((colony) => colony.id),
new Set(['inspections:read']),
today,
);
const apiaries = await this.prisma.apiary.findMany({
where: { id: { in: [...seasons.keys()] } },
select: { id: true, name: true },
});
const names = new Map(apiaries.map((a) => [a.id, a.name] as const));
const nameOf = (apiaryId: string): string => names.get(apiaryId) ?? '';
const sorted = [...seasons.values()].sort((a, b) =>
nameOf(a.apiaryId).localeCompare(nameOf(b.apiaryId), 'de', {
numeric: true,
}),
);
return this.dueInspections(colonies, sorted, intervalDays, nameOf, today);
}
private dueInspections(
colonies: ReadonlyMap<string, ColonyComputation>,
sortedSeasons: readonly SeasonState[],
intervalDays: Computation['intervalDays'],
nameOf: (apiaryId: string) => string,
today: string,
): AutopilotToday['inspectionDue'] {
return sortedSeasons.flatMap((season) => {
const due = [...colonies]
.filter(
([, c]) =>
c.apiaryId === season.apiaryId &&
c.view.inspectionDueOn !== null &&
c.view.inspectionDueOn <= today,
)
.map(([id, c]) => ({
id,
code: c.code,
dueOn: c.view.inspectionDueOn ?? today,
overdueDays: daysBetween(c.view.inspectionDueOn ?? today, today),
}))
.sort((a, b) => a.dueOn.localeCompare(b.dueOn) || byCode(a, b));
return due.length === 0
? []
: [
{
apiaryId: season.apiaryId,
apiaryName: nameOf(season.apiaryId),
segment: season.segment,
intervalDays:
season.segment === 'WINTERRUHE'
? null
: intervalDays[season.segment],
colonies: due,
},
];
});
}
private mostUrgent(
colonies: ReadonlyMap<string, ColonyComputation>,
today: string,
@@ -37,7 +37,7 @@ describe('runDailyEvaluationSteps', () => {
const order: string[] = [];
const digest = step('digest', () => {
order.push('digest');
return Promise.resolve({ digests: 3 });
return Promise.resolve({ digestsWritten: 3 });
});
const timeEvents = step('time-events', () => {
order.push('time-events');
@@ -52,7 +52,7 @@ describe('runDailyEvaluationSteps', () => {
expect(order).toEqual(['time-events', 'digest']);
expect(timeEvents.run).toHaveBeenCalledWith('2026-06-10');
expect(result).toEqual({ TaskDue: 1, digests: 3 });
expect(result).toEqual({ TaskDue: 1, digestsWritten: 3 });
expect(failed).toEqual([]);
});
@@ -68,7 +68,7 @@ describe('runDailyEvaluationSteps', () => {
const autopilot = step('autopilot', () =>
Promise.reject(new Error('connection reset')),
);
const digest = step('digest', () => Promise.resolve({ digests: 4 }));
const digest = step('digest', () => Promise.resolve({ digestsWritten: 4 }));
const { result, failed, failures } = await runDailyEvaluationSteps(
[phenology, timeEvents, autopilot, digest],
@@ -83,7 +83,7 @@ describe('runDailyEvaluationSteps', () => {
phenologyApiaries: 2,
WithdrawalEnded: 1,
TaskDue: 0,
digests: 4,
digestsWritten: 4,
});
expect(errorSpy).toHaveBeenCalledWith(
'Step "autopilot" failed: connection reset',
@@ -3,6 +3,7 @@ import { JobPartialFailure } from '../system/job-run.service';
import { DailyEvaluationJob } from './daily-evaluation.job';
import type { AutopilotStep } from '../autopilot/autopilot.step';
import type { SuggestionsExpiryStep } from '../autopilot/suggestions-expiry.step';
import type { DigestStep } from '../reminders/digest.step';
import type { TimeEventsStep } from './steps/time-events.step';
describe('DailyEvaluationJob', () => {
@@ -21,6 +22,7 @@ describe('DailyEvaluationJob', () => {
let timeEvents: { name: 'time-events'; run: jest.Mock };
let autopilot: { name: 'autopilot'; run: jest.Mock };
let expiry: { name: 'suggestions-expiry'; run: jest.Mock };
let digest: { name: 'digest'; run: jest.Mock };
let job: DailyEvaluationJob;
beforeEach(() => {
@@ -31,10 +33,13 @@ describe('DailyEvaluationJob', () => {
autopilot.run.mockResolvedValue(AUTOPILOT_COUNTS);
expiry = { name: 'suggestions-expiry', run: jest.fn() };
expiry.run.mockResolvedValue({ suggestionsExpired: 3 });
digest = { name: 'digest', run: jest.fn() };
digest.run.mockResolvedValue({ digestsWritten: 5 });
job = new DailyEvaluationJob(
timeEvents as unknown as TimeEventsStep,
autopilot as unknown as AutopilotStep,
expiry as unknown as SuggestionsExpiryStep,
digest as unknown as DigestStep,
);
});
@@ -52,13 +57,15 @@ describe('DailyEvaluationJob', () => {
...COUNTS,
...AUTOPILOT_COUNTS,
suggestionsExpired: 3,
digestsWritten: 5,
});
expect(timeEvents.run).toHaveBeenCalledWith('2026-06-10');
expect(autopilot.run).toHaveBeenCalledWith('2026-06-10');
expect(expiry.run).toHaveBeenCalledWith('2026-06-10');
expect(digest.run).toHaveBeenCalledWith('2026-06-10');
});
it('runs time-events, autopilot and suggestions-expiry in that order', async () => {
it('runs time-events, autopilot, suggestions-expiry and digest in that order', async () => {
await job.run();
expect(timeEvents.run.mock.invocationCallOrder[0]).toBeLessThan(
@@ -67,6 +74,25 @@ describe('DailyEvaluationJob', () => {
expect(autopilot.run.mock.invocationCallOrder[0]).toBeLessThan(
expiry.run.mock.invocationCallOrder[0],
);
expect(expiry.run.mock.invocationCallOrder[0]).toBeLessThan(
digest.run.mock.invocationCallOrder[0],
);
});
it('a failing time-events step does not stop the digest', async () => {
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
timeEvents.run.mockRejectedValue(new Error('boom'));
const failure = await job.run().then(
() => null,
(error: unknown) => error,
);
expect(digest.run).toHaveBeenCalled();
expect(failure).toMatchObject({
failedSteps: ['time-events'],
result: { digestsWritten: 5 },
});
});
it('a failing autopilot step does not stop the expiry step', async () => {
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { AutopilotStep } from '../autopilot/autopilot.step';
import { SuggestionsExpiryStep } from '../autopilot/suggestions-expiry.step';
import { berlinToday } from '../common/dates';
import { DigestStep } from '../reminders/digest.step';
import { JobPartialFailure } from '../system/job-run.service';
import {
DailyEvaluationStep,
@@ -27,6 +28,7 @@ export class DailyEvaluationJob implements Job {
private readonly timeEvents: TimeEventsStep,
private readonly autopilot: AutopilotStep,
private readonly suggestionsExpiry: SuggestionsExpiryStep,
private readonly digest: DigestStep,
) {}
/** Evaluates the current Berlin day. */
@@ -35,6 +37,7 @@ export class DailyEvaluationJob implements Job {
this.timeEvents,
this.autopilot,
this.suggestionsExpiry,
this.digest,
];
const { result, failed, failures } = await runDailyEvaluationSteps(
steps,
+2
View File
@@ -8,6 +8,7 @@ import { IdempotencyCleanupService } from '../common/idempotency/idempotency-cle
import { DomainEventsModule } from '../domain-events/domain-events.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { PrismaModule } from '../prisma/prisma.module';
import { RemindersModule } from '../reminders/reminders.module';
import { SeasonModule } from '../season/season.module';
import { SystemModule } from '../system/system.module';
import { TasksModule } from '../tasks/tasks.module';
@@ -49,6 +50,7 @@ const JOB_CLASSES = [
SystemModule,
SeasonModule,
AutopilotModule,
RemindersModule,
],
providers: [
...JOB_CLASSES,
@@ -0,0 +1,299 @@
import { Logger } from '@nestjs/common';
import type { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import type { NextStepsService } from '../autopilot/next-steps.service';
import type { NotificationsService } from '../notifications/notifications.service';
import type { PrismaService } from '../prisma/prisma.service';
import { JobPartialFailure } from '../system/job-run.service';
import { buildDigest, DigestService, type DigestData } from './digest.service';
const EMPTY: DigestData = {
today: '2026-06-10',
tasks: [],
inspectionDue: [],
suggestions: [],
seasonChanges: [],
};
const DATA: DigestData = {
today: '2026-06-10',
tasks: [
{ assignedUserId: 'u1', overdue: false },
{ assignedUserId: 'u1', overdue: true },
{ assignedUserId: null, overdue: true },
{ assignedUserId: 'u2', overdue: true },
],
inspectionDue: [
{
apiaryId: 'a1',
apiaryName: 'Wald',
segment: 'SCHWARMZEIT',
intervalDays: 7,
colonies: [
{ id: 'c1', code: 'V1', dueOn: '2026-06-08', overdueDays: 2 },
{ id: 'c2', code: 'V2', dueOn: '2026-06-10', overdueDays: 0 },
],
},
],
suggestions: [
{ requiredPermission: null },
{ requiredPermission: 'varroa:read' },
{ requiredPermission: 'inspections:read' },
],
seasonChanges: [
{ apiaryName: 'Wald', segment: 'SCHWARMZEIT', intervalDays: 7 },
],
};
const ALL = new Set([
'tasks:read',
'tasks:complete',
'colonies:read',
'inspections:read',
'apiaries:read',
'varroa:read',
]);
function lines(message: string | undefined): string[] {
return (message ?? '').split('\n');
}
describe('buildDigest', () => {
it('includes a line only with its permission and lists every permission used', () => {
const full = buildDigest('u1', ALL, DATA);
expect(lines(full?.message)).toEqual([
'Guten Morgen. Heute für dich:',
'3 Aufgaben fällig, 2 davon überfällig.',
'Durchsicht fällig: 2 Völker am Stand Wald.',
'3 Vorschläge warten.',
'Stand Wald: Schwarmzeit beginnt. Durchsicht alle 7 Tage.',
]);
expect(full?.requiredPermissions).toEqual([
'apiaries:read',
'colonies:read',
'inspections:read',
'tasks:read',
]);
const tasksOnly = buildDigest('u1', new Set(['tasks:read']), DATA);
expect(lines(tasksOnly?.message)).toEqual([
'Guten Morgen. Heute für dich:',
'2 Aufgaben fällig, 1 davon überfällig.',
'1 Vorschlag wartet.',
]);
expect(tasksOnly?.requiredPermissions).toEqual(['tasks:read']);
});
it('counts suggestions by their requiredPermission', () => {
const digest = buildDigest(
'u1',
new Set(['tasks:read', 'varroa:read']),
DATA,
);
expect(lines(digest?.message)).toContain('2 Vorschläge warten.');
});
it('needs colonies:read and inspections:read for the inspection line', () => {
const noInspections = new Set(ALL);
noInspections.delete('inspections:read');
const noColonies = new Set(ALL);
noColonies.delete('colonies:read');
expect(buildDigest('u1', noInspections, DATA)?.message).not.toContain(
'Durchsicht fällig',
);
expect(buildDigest('u1', noColonies, DATA)?.message).not.toContain(
'Durchsicht fällig',
);
});
it('counts unassigned tasks only with tasks:complete', () => {
const withoutComplete = new Set(ALL);
withoutComplete.delete('tasks:complete');
expect(lines(buildDigest('u1', withoutComplete, DATA)?.message)[1]).toBe(
'2 Aufgaben fällig, 1 davon überfällig.',
);
expect(lines(buildDigest('u1', ALL, DATA)?.message)[1]).toBe(
'3 Aufgaben fällig, 2 davon überfällig.',
);
});
it('uses the singular and leaves out "davon überfällig" when nothing is overdue', () => {
const digest = buildDigest('u1', new Set(['tasks:read']), {
...EMPTY,
tasks: [{ assignedUserId: 'u1', overdue: false }],
});
expect(lines(digest?.message)[1]).toBe('1 Aufgabe fällig.');
});
it('names one colony in the singular', () => {
const digest = buildDigest('u1', ALL, {
...EMPTY,
inspectionDue: [
{
apiaryId: 'a1',
apiaryName: 'Wald',
segment: 'SOMMER',
intervalDays: 14,
colonies: [
{ id: 'c1', code: 'V1', dueOn: '2026-06-10', overdueDays: 0 },
],
},
],
});
expect(lines(digest?.message)[1]).toBe(
'Durchsicht fällig: 1 Volk am Stand Wald.',
);
});
it('leaves out the interval of the winter rest', () => {
const digest = buildDigest('u1', new Set(['apiaries:read']), {
...EMPTY,
seasonChanges: [
{ apiaryName: 'Wald', segment: 'WINTERRUHE', intervalDays: null },
],
});
expect(lines(digest?.message)[1]).toBe('Stand Wald: Winterruhe beginnt.');
});
it('builds no digest without content', () => {
expect(buildDigest('u1', ALL, EMPTY)).toBeNull();
expect(buildDigest('u1', new Set(), DATA)).toBeNull();
expect(
buildDigest('u3', new Set(['tasks:read']), {
...EMPTY,
tasks: [{ assignedUserId: 'u1', overdue: true }],
}),
).toBeNull();
});
});
describe('DigestService', () => {
let users: { id: string }[];
let transaction: jest.Mock;
let recordForUsers: jest.Mock;
let getPermissions: jest.Mock;
let userFindMany: jest.Mock;
let service: DigestService;
const tx = { tx: true };
beforeEach(() => {
users = [{ id: 'u1' }, { id: 'u2' }];
userFindMany = jest.fn(() => Promise.resolve(users));
transaction = jest.fn((work: (client: unknown) => unknown) => work(tx));
recordForUsers = jest.fn().mockResolvedValue([{ id: 'n' }]);
getPermissions = jest.fn().mockResolvedValue(ALL);
const prisma = {
user: { findMany: userFindMany },
task: {
findMany: jest.fn().mockResolvedValue([
{ assignedUserId: null, dueDate: new Date('2026-06-10T00:00:00Z') },
{ assignedUserId: null, dueDate: new Date('2026-06-09T00:00:00Z') },
]),
},
suggestion: {
findMany: jest
.fn()
.mockResolvedValue([{ requiredPermission: 'tasks:read' }]),
},
colony: {
findMany: jest.fn().mockResolvedValue([{ apiaryId: 'a1' }]),
},
apiary: {
findMany: jest.fn().mockResolvedValue([{ id: 'a1', name: 'Wald' }]),
},
$transaction: transaction,
} as unknown as PrismaService;
service = new DigestService(
prisma,
{ recordForUsers } as unknown as NotificationsService,
{ getPermissions } as unknown as PermissionResolverService,
{
inspectionDueAll: jest.fn().mockResolvedValue([]),
} as unknown as NextStepsService,
{
statesFor: jest.fn().mockResolvedValue(new Map()),
},
);
});
afterEach(() => jest.restoreAllMocks());
it('writes at most one digest per user and day', async () => {
const written = await service.runFor('2026-06-10');
expect(written).toBe(2);
expect(recordForUsers).toHaveBeenCalledTimes(2);
const [userIds, input, client] = recordForUsers.mock.calls[0] as [
string[],
Record<string, unknown>,
unknown,
];
expect(userIds).toEqual(['u1']);
expect(client).toBe(tx);
expect(input).toMatchObject({
priority: 'INFO',
category: 'DIGEST',
link: '/dashboard',
dedupeKeyPrefix: 'digest:2026-06-10',
});
});
it('counts only digests that were written', async () => {
recordForUsers.mockResolvedValueOnce([]);
expect(await service.runFor('2026-06-10')).toBe(1);
});
it('skips users who have no content', async () => {
getPermissions.mockResolvedValueOnce(new Set(['colonies:read']));
expect(await service.runFor('2026-06-10')).toBe(1);
expect(recordForUsers).toHaveBeenCalledTimes(1);
});
it('selects active users with an active role who did not switch the digest off', async () => {
await service.runFor('2026-06-10');
expect(userFindMany).toHaveBeenCalledWith({
where: {
isActive: true,
role: { status: 'ACTIVE' },
NOT: { notificationPreference: { digestEnabled: false } },
},
select: { id: true },
orderBy: { id: 'asc' },
});
});
it('limits the run to the given users', async () => {
await service.runFor('2026-06-10', ['u2']);
expect(userFindMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ id: { in: ['u2'] } }) as unknown,
}),
);
});
it('a failing user does not stop the others and fails the step with the count', async () => {
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
recordForUsers.mockRejectedValueOnce(new Error('boom'));
const failure = await service.runFor('2026-06-10').then(
() => null,
(error: unknown) => error,
);
expect(recordForUsers).toHaveBeenCalledTimes(2);
expect(failure).toBeInstanceOf(JobPartialFailure);
expect(failure).toMatchObject({
result: { digestsWritten: 1 },
failedSteps: ['1 user'],
});
});
});
@@ -0,0 +1,318 @@
import { Injectable, Logger } from '@nestjs/common';
import { PermissionKey } from '../auth/permissions/permission-catalog';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import {
AutopilotToday,
NextStepsService,
} from '../autopilot/next-steps.service';
import { readRule } from '../automation/rule-config';
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
import { addDays, dayToDate } from '../common/dates';
import { loggableError } from '../common/loggable-error';
import { utcDay } from '../domain-events/domain-event-types';
import { NotificationsService } from '../notifications/notifications.service';
import { PrismaService } from '../prisma/prisma.service';
import { SeasonSegmentKey } from '../season/season-types';
import { SeasonService } from '../season/season.service';
import { JobPartialFailure } from '../system/job-run.service';
/** One line of the digest and the permissions that entitled the reader to it. */
export interface DigestLine {
text: string;
permissions: PermissionKey[];
}
/** Everything the digest of all users is built from, read once per run. */
export interface DigestData {
/** The Berlin day of the run, YYYY-MM-DD. */
today: string;
/** Open tasks due today or earlier. */
tasks: { assignedUserId: string | null; overdue: boolean }[];
inspectionDue: AutopilotToday['inspectionDue'];
/** Open suggestions. */
suggestions: { requiredPermission: string | null }[];
/** Apiaries whose season segment begins today. */
seasonChanges: {
apiaryName: string;
segment: SeasonSegmentKey;
intervalDays: number | null;
}[];
}
export interface Digest {
message: string;
requiredPermissions: PermissionKey[];
}
const GREETING = 'Guten Morgen. Heute für dich:';
const SEGMENT_NAMES: Readonly<Record<SeasonSegmentKey, string>> = {
WINTERRUHE: 'Winterruhe',
FRUEHJAHR: 'Frühjahr',
SCHWARMZEIT: 'Schwarmzeit',
SOMMER: 'Sommer',
SPAETSOMMER: 'Spätsommer',
HERBST: 'Herbst',
};
const OPEN_TASK_STATUSES = ['OPEN', 'IN_PROGRESS'] as const;
function plural(count: number, one: string, many: string): string {
return `${count} ${count === 1 ? one : many}`;
}
function taskLine(
userId: string,
permissions: ReadonlySet<string>,
data: DigestData,
): DigestLine | null {
if (!permissions.has('tasks:read')) {
return null;
}
const canComplete = permissions.has('tasks:complete');
const mine = data.tasks.filter(
(task) =>
task.assignedUserId === userId ||
(task.assignedUserId === null && canComplete),
);
if (mine.length === 0) {
return null;
}
const overdue = mine.filter((task) => task.overdue).length;
const due = `${plural(mine.length, 'Aufgabe', 'Aufgaben')} fällig`;
return {
text: overdue > 0 ? `${due}, ${overdue} davon überfällig.` : `${due}.`,
permissions: ['tasks:read'],
};
}
function inspectionLines(
permissions: ReadonlySet<string>,
data: DigestData,
): DigestLine[] {
if (
!permissions.has('colonies:read') ||
!permissions.has('inspections:read')
) {
return [];
}
return data.inspectionDue
.filter((apiary) => apiary.colonies.length > 0)
.map((apiary) => ({
text: `Durchsicht fällig: ${plural(apiary.colonies.length, 'Volk', 'Völker')} am Stand ${apiary.apiaryName}.`,
permissions: ['colonies:read', 'inspections:read'],
}));
}
function suggestionLine(
permissions: ReadonlySet<string>,
data: DigestData,
): DigestLine | null {
if (!permissions.has('tasks:read')) {
return null;
}
const visible = data.suggestions.filter(
(suggestion) =>
suggestion.requiredPermission === null ||
permissions.has(suggestion.requiredPermission),
);
if (visible.length === 0) {
return null;
}
return {
text: `${plural(visible.length, 'Vorschlag wartet', 'Vorschläge warten')}.`,
permissions: ['tasks:read'],
};
}
function seasonLines(
permissions: ReadonlySet<string>,
data: DigestData,
): DigestLine[] {
if (!permissions.has('apiaries:read')) {
return [];
}
return data.seasonChanges.map((change) => ({
text:
`Stand ${change.apiaryName}: ${SEGMENT_NAMES[change.segment]} beginnt.` +
(change.intervalDays === null
? ''
: ` Durchsicht alle ${change.intervalDays} Tage.`),
permissions: ['apiaries:read'],
}));
}
/**
* The morning digest of one user (Spec 9a.3): each line only if the user
* holds the permission for it. The stored permissions are those of the
* lines used. null if no line is left - no empty digest.
*/
export function buildDigest(
userId: string,
permissions: ReadonlySet<string>,
data: DigestData,
): Digest | null {
const lines: DigestLine[] = [
taskLine(userId, permissions, data),
...inspectionLines(permissions, data),
suggestionLine(permissions, data),
...seasonLines(permissions, data),
].filter((line): line is DigestLine => line !== null);
if (lines.length === 0) {
return null;
}
const used = new Set(lines.flatMap((line) => line.permissions));
return {
message: [GREETING, ...lines.map((line) => line.text)].join('\n'),
requiredPermissions: [...used].sort(),
};
}
/**
* Writes the in-app morning digest. The data is read once per run, at the
* time of the daily job (04:30): tasks created later appear in the next
* digest. One transaction and one row (key digest:<day>:<userId>) per user,
* so a rerun for the same day writes nothing new.
*/
@Injectable()
export class DigestService {
private readonly logger = new Logger(DigestService.name);
constructor(
private readonly prisma: PrismaService,
private readonly notifications: NotificationsService,
private readonly permissionResolver: PermissionResolverService,
private readonly nextSteps: NextStepsService,
private readonly seasons: SeasonService,
) {}
/**
* The digests of the Berlin day `today` for every active user who did not
* switch them off (`userIds` narrows that, for tests). Returns how many
* were written; a rerun for the same day writes 0 (the keys exist). A failing user is logged and does not stop the others;
* the run then fails with the count of the digests that went through.
*/
async runFor(today: string, userIds?: readonly string[]): Promise<number> {
const users = await this.prisma.user.findMany({
where: {
isActive: true,
role: { status: 'ACTIVE' },
NOT: { notificationPreference: { digestEnabled: false } },
...(userIds ? { id: { in: [...userIds] } } : {}),
},
select: { id: true },
orderBy: { id: 'asc' },
});
if (users.length === 0) {
return 0;
}
const data = await this.loadData(today);
let written = 0;
let failed = 0;
for (const { id } of users) {
try {
written += await this.writeFor(id, data);
} catch (error: unknown) {
const { message, stack } = loggableError(error);
this.logger.error(`Digest of user ${id} failed: ${message}`, stack);
failed += 1;
}
}
if (failed > 0) {
throw new JobPartialFailure({ digestsWritten: written }, [
`${failed} ${failed === 1 ? 'user' : 'users'}`,
]);
}
return written;
}
private async writeFor(userId: string, data: DigestData): Promise<number> {
const permissions = await this.permissionResolver.getPermissions(userId);
const digest = buildDigest(userId, permissions, data);
if (digest === null) {
return 0;
}
const rows = await this.prisma.$transaction((tx) =>
this.notifications.recordForUsers(
[userId],
{
priority: 'INFO',
category: 'DIGEST',
message: digest.message,
link: '/dashboard',
requiredPermissions: digest.requiredPermissions,
dedupeKeyPrefix: `digest:${data.today}`,
},
tx,
),
);
return rows.length;
}
private async loadData(today: string): Promise<DigestData> {
const [tasks, suggestions, inspectionDue, seasonChanges] =
await Promise.all([
this.loadTasks(today),
this.prisma.suggestion.findMany({
where: { status: 'OPEN' },
select: { requiredPermission: true },
}),
// Every colony, whatever the reader may see: the lines are filtered per user.
this.nextSteps.inspectionDueAll(today),
this.loadSeasonChanges(today),
]);
return { today, tasks, inspectionDue, suggestions, seasonChanges };
}
private async loadTasks(today: string): Promise<DigestData['tasks']> {
const rows = await this.prisma.task.findMany({
where: {
status: { in: [...OPEN_TASK_STATUSES] },
dueDate: { lt: dayToDate(addDays(today, 1)) },
},
select: { assignedUserId: true, dueDate: true },
});
return rows.flatMap(({ assignedUserId, dueDate }) =>
dueDate === null
? []
: [{ assignedUserId, overdue: utcDay(dueDate) < today }],
);
}
/** Apiaries with living colonies whose season segment begins today. */
private async loadSeasonChanges(
today: string,
): Promise<DigestData['seasonChanges']> {
const colonies = await this.prisma.colony.findMany({
where: LIVING_COLONY_WHERE,
select: { apiaryId: true },
distinct: ['apiaryId'],
});
const apiaryIds = colonies.map((colony) => colony.apiaryId);
const states = await this.seasons.statesFor(this.prisma, apiaryIds, today);
const changed = [...states.values()].filter(
(state) => state.segmentSince === today,
);
if (changed.length === 0) {
return [];
}
const [apiaries, interval] = await Promise.all([
this.prisma.apiary.findMany({
where: { id: { in: changed.map((state) => state.apiaryId) } },
select: { id: true, name: true },
}),
readRule(this.prisma, 'INSPECTION_INTERVAL'),
]);
const names = new Map(apiaries.map((a) => [a.id, a.name] as const));
return changed
.map((state) => ({
apiaryName: names.get(state.apiaryId) ?? '',
segment: state.segment,
intervalDays:
state.segment === 'WINTERRUHE' || !interval.active
? null
: interval.config.intervalDays[state.segment],
}))
.sort((a, b) => a.apiaryName.localeCompare(b.apiaryName, 'de'));
}
}
@@ -0,0 +1,29 @@
import { JobPartialFailure } from '../system/job-run.service';
import type { DigestService } from './digest.service';
import { DigestStep } from './digest.step';
describe('DigestStep', () => {
it('is the digest step', () => {
expect(new DigestStep({} as DigestService).name).toBe('digest');
});
it('writes the digests of the day and reports their number', async () => {
const runFor = jest.fn().mockResolvedValue(7);
const result = await new DigestStep({
runFor,
} as unknown as DigestService).run('2026-06-10');
expect(runFor).toHaveBeenCalledWith('2026-06-10');
expect(result).toEqual({ digestsWritten: 7 });
});
it('lets a partial failure through with its counter', async () => {
const failure = new JobPartialFailure({ digestsWritten: 3 }, ['1 user']);
const runFor = jest.fn().mockRejectedValue(failure);
await expect(
new DigestStep({ runFor } as unknown as DigestService).run('2026-06-10'),
).rejects.toBe(failure);
});
});
@@ -0,0 +1,21 @@
import { Injectable } from '@nestjs/common';
import type { DailyEvaluationStep } from '../jobs/daily-evaluation-steps';
import type { JobResult } from '../jobs/job';
import { DigestService } from './digest.service';
/**
* Step "digest", the last of the daily evaluation: the in-app morning
* digest of every user. Running it twice for one day writes nothing the
* second time (the key holds the day and the user): `digestsWritten` is 0
* on a same-day rerun.
*/
@Injectable()
export class DigestStep implements DailyEvaluationStep {
readonly name = 'digest';
constructor(private readonly digests: DigestService) {}
async run(today: string): Promise<JobResult> {
return { digestsWritten: await this.digests.runFor(today) };
}
}
@@ -0,0 +1,21 @@
import { Module } from '@nestjs/common';
import { AutopilotModule } from '../autopilot/autopilot.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { SeasonModule } from '../season/season.module';
import { DigestService } from './digest.service';
import { DigestStep } from './digest.step';
import { TaskDueHandlers } from './task-due.handlers';
/**
* The in-app reminders: the notification per due task (event handler
* `notifications.task-due`) and the morning digest (daily step `digest`).
* Imported by AppModule and WorkerModule (the handler) and JobsModule (the
* step). The PermissionResolverService comes from the global
* PermissionsModule (API) or JobPermissionsModule (worker, jobs).
*/
@Module({
imports: [NotificationsModule, SeasonModule, AutopilotModule],
providers: [TaskDueHandlers, DigestService, DigestStep],
exports: [DigestService, DigestStep],
})
export class RemindersModule {}
@@ -0,0 +1,216 @@
import type { Prisma } from '../../generated/prisma';
import type { AnyDomainEventEnvelope } from '../domain-events/domain-event-types';
import { domainEventHandlerMetadata } from '../domain-events/on-domain-event.decorator';
import type { NotificationsService } from '../notifications/notifications.service';
import { TaskDueHandlers } from './task-due.handlers';
function taskDue(
taskId = 'task-1',
dueOn = '2026-06-10',
): AnyDomainEventEnvelope<'TaskDue'> {
return {
id: 'evt-1',
type: 'TaskDue',
aggregateType: 'Task',
aggregateId: taskId,
payload: { dueOn },
actorUserId: null,
correlationId: 'cor-1',
occurredAt: new Date(),
attempts: 0,
};
}
type RecordCall = [string[], Record<string, unknown>, unknown];
describe('TaskDueHandlers', () => {
let task: Record<string, unknown> | null;
let completers: { id: string }[];
let disabled: { userId: string }[];
let taskFindUnique: jest.Mock;
let userFindMany: jest.Mock;
let preferenceFindMany: jest.Mock;
let recordForUsers: jest.Mock;
let handlers: TaskDueHandlers;
const tx = {} as Prisma.TransactionClient;
beforeEach(() => {
jest.useFakeTimers({ now: new Date('2026-06-10T08:00:00.000Z') });
task = {
id: 'task-1',
title: 'Waben pruefen',
status: 'OPEN',
assignedUserId: 'u-assignee',
dueDate: new Date('2026-06-10T00:00:00.000Z'),
};
completers = [{ id: 'u-a' }, { id: 'u-b' }];
disabled = [];
taskFindUnique = jest.fn(() => Promise.resolve(task));
userFindMany = jest.fn(() => Promise.resolve(completers));
preferenceFindMany = jest.fn(() => Promise.resolve(disabled));
recordForUsers = jest.fn().mockResolvedValue([]);
const client = {
task: { findUnique: taskFindUnique },
user: { findMany: userFindMany },
notificationPreference: { findMany: preferenceFindMany },
} as unknown as Prisma.TransactionClient;
handlers = new TaskDueHandlers({
recordForUsers,
} as unknown as NotificationsService);
Object.assign(tx, client);
});
const firstCall = (): RecordCall =>
recordForUsers.mock.calls[0] as RecordCall;
afterEach(() => jest.useRealTimers());
it('is registered as notifications.task-due for TaskDue', () => {
const entries = domainEventHandlerMetadata(
// eslint-disable-next-line @typescript-eslint/unbound-method
TaskDueHandlers.prototype.taskDue,
);
expect(entries).toEqual([
{ types: ['TaskDue'], handlerId: 'notifications.task-due' },
]);
});
it('notifies the assignee only', async () => {
await handlers.taskDue(taskDue(), tx);
expect(userFindMany).not.toHaveBeenCalled();
expect(recordForUsers).toHaveBeenCalledTimes(1);
expect(firstCall()[0]).toEqual(['u-assignee']);
});
it('without an assignee notifies holders of tasks:complete', async () => {
task = { ...task, assignedUserId: null };
await handlers.taskDue(taskDue(), tx);
expect(userFindMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
isActive: true,
role: {
status: 'ACTIVE',
permissions: { some: { permission: { key: 'tasks:complete' } } },
},
},
}),
);
expect(firstCall()[0]).toEqual(['u-a', 'u-b']);
});
it('writes nothing for a user with taskDueEnabled false', async () => {
task = { ...task, assignedUserId: null };
disabled = [{ userId: 'u-a' }];
await handlers.taskDue(taskDue(), tx);
expect(preferenceFindMany).toHaveBeenCalledWith({
where: { userId: { in: ['u-a', 'u-b'] }, taskDueEnabled: false },
select: { userId: true },
});
expect(firstCall()[0]).toEqual(['u-b']);
});
it('writes nothing when the only recipient switched it off', async () => {
disabled = [{ userId: 'u-assignee' }];
await handlers.taskDue(taskDue(), tx);
expect(recordForUsers).not.toHaveBeenCalled();
});
it('uses the key task-due:<taskId>:<day> as prefix, so rows end in :<userId>', async () => {
task = { ...task, dueDate: new Date('2026-06-09T00:00:00.000Z') };
await handlers.taskDue(taskDue('task-1', '2026-06-09'), tx);
expect(firstCall()[1]).toEqual({
priority: 'HINWEIS',
category: 'TASK_DUE',
message: 'Fällig seit 09.06.: Waben pruefen',
relatedType: 'Task',
relatedId: 'task-1',
link: '/tasks',
requiredPermissions: ['tasks:read'],
dedupeKeyPrefix: 'task-due:task-1:2026-06-09',
});
expect(firstCall()[2]).toBe(tx);
});
it.each(['DONE', 'CANCELLED'])('skips a %s task', async (status) => {
task = { ...task, status };
await handlers.taskDue(taskDue(), tx);
expect(recordForUsers).not.toHaveBeenCalled();
});
it('says "Heute fällig" for an event of today', async () => {
await handlers.taskDue(taskDue(), tx);
expect(firstCall()[1].message).toBe('Heute fällig: Waben pruefen');
});
it('says "Fällig seit {TT.MM.}" for a catch-up event', async () => {
task = { ...task, dueDate: new Date('2026-06-07T00:00:00.000Z') };
await handlers.taskDue(taskDue('task-1', '2026-06-07'), tx);
expect(firstCall()[1].message).toBe('Fällig seit 07.06.: Waben pruefen');
});
it('skips a task whose due day was moved after the event', async () => {
task = { ...task, dueDate: new Date('2026-06-20T00:00:00.000Z') };
await handlers.taskDue(taskDue('task-1', '2026-06-10'), tx);
expect(recordForUsers).not.toHaveBeenCalled();
});
it('skips a task whose due date was removed', async () => {
task = { ...task, dueDate: null };
await handlers.taskDue(taskDue(), tx);
expect(recordForUsers).not.toHaveBeenCalled();
});
it('accepts an IN_PROGRESS task', async () => {
task = { ...task, status: 'IN_PROGRESS' };
await handlers.taskDue(taskDue(), tx);
expect(recordForUsers).toHaveBeenCalledTimes(1);
});
it('skips a task that no longer exists', async () => {
task = null;
await handlers.taskDue(taskDue(), tx);
expect(recordForUsers).not.toHaveBeenCalled();
});
it('cuts the title to 200 characters', async () => {
task = { ...task, title: 'x'.repeat(300) };
await handlers.taskDue(taskDue(), tx);
const input = firstCall()[1];
expect(input.message).toBe(`Heute fällig: ${'x'.repeat(200)}`);
});
it('writes nothing when no user holds tasks:complete', async () => {
task = { ...task, assignedUserId: null };
completers = [];
await handlers.taskDue(taskDue(), tx);
expect(recordForUsers).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,113 @@
import { Injectable } from '@nestjs/common';
import type { Prisma } from '../../generated/prisma';
import type { AnyDomainEventEnvelope } from '../domain-events/domain-event-types';
import { berlinToday } from '../common/dates';
import { utcDay } from '../domain-events/domain-event-types';
import { OnDomainEvent } from '../domain-events/on-domain-event.decorator';
import { NotificationsService } from '../notifications/notifications.service';
import { formatDayDe } from '../suggestions/suggestion-texts';
const OPEN_TASK_STATUSES: readonly string[] = ['OPEN', 'IN_PROGRESS'];
const MAX_TITLE_LENGTH = 200;
/** "Heute fällig" on the due day, "Fällig seit 07.06." for a catch-up event. */
function dueLabel(dueOn: string): string {
return dueOn < berlinToday()
? `Fällig seit ${formatDayDe(dueOn)}`
: 'Heute fällig';
}
/**
* The in-app reminder per due task (Spec 9a.2): "Heute fällig: {title}" for
* the assignee, or - without an assignee - for everyone who may complete
* tasks. Only users who hold tasks:read and did not switch the reminder off
* get a row; the key task-due:<taskId>:<day>:<userId> makes a redelivery
* (and the catch-up of the daily job) write nothing twice.
*/
@Injectable()
export class TaskDueHandlers {
constructor(private readonly notifications: NotificationsService) {}
@OnDomainEvent('TaskDue', 'notifications.task-due')
async taskDue(
event: AnyDomainEventEnvelope<'TaskDue'>,
tx: Prisma.TransactionClient,
): Promise<void> {
const task = await tx.task.findUnique({
where: { id: event.aggregateId },
select: {
id: true,
title: true,
status: true,
assignedUserId: true,
dueDate: true,
},
});
if (!task || !OPEN_TASK_STATUSES.includes(task.status)) {
return;
}
// The task was rescheduled (or lost its date) after the event: the
// reminder belongs to the new day, which has its own event.
const { dueOn } = event.payload;
if (task.dueDate === null || utcDay(task.dueDate) !== dueOn) {
return;
}
const candidates = await this.candidates(tx, task.assignedUserId);
const recipients = await this.withReminderOn(tx, candidates);
if (recipients.length === 0) {
return;
}
await this.notifications.recordForUsers(
recipients,
{
priority: 'HINWEIS',
category: 'TASK_DUE',
message: `${dueLabel(dueOn)}: ${[...task.title].slice(0, MAX_TITLE_LENGTH).join('')}`,
relatedType: 'Task',
relatedId: task.id,
link: '/tasks',
requiredPermissions: ['tasks:read'],
dedupeKeyPrefix: `task-due:${task.id}:${dueOn}`,
},
tx,
);
}
/** The assignee, else every active user with tasks:complete (tasks:read is checked on write). */
private async candidates(
tx: Prisma.TransactionClient,
assignedUserId: string | null,
): Promise<string[]> {
if (assignedUserId !== null) {
return [assignedUserId];
}
const users = await tx.user.findMany({
where: {
isActive: true,
role: {
status: 'ACTIVE',
permissions: { some: { permission: { key: 'tasks:complete' } } },
},
},
select: { id: true },
orderBy: { id: 'asc' },
});
return users.map((user) => user.id);
}
/** A missing preference row means the default: on. */
private async withReminderOn(
tx: Prisma.TransactionClient,
userIds: readonly string[],
): Promise<string[]> {
if (userIds.length === 0) {
return [];
}
const off = await tx.notificationPreference.findMany({
where: { userId: { in: [...userIds] }, taskDueEnabled: false },
select: { userId: true },
});
const switchedOff = new Set(off.map((row) => row.userId));
return userIds.filter((id) => !switchedOff.has(id));
}
}
@@ -8,6 +8,7 @@ import { CalendarSyncModule } from '../calendar-sync/calendar-sync.module';
import { DomainEventsModule } from '../domain-events/domain-events.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { PrismaModule } from '../prisma/prisma.module';
import { RemindersModule } from '../reminders/reminders.module';
import { SuggestionsModule } from '../suggestions/suggestions.module';
import { TasksModule } from '../tasks/tasks.module';
import { WebhooksModule } from '../webhooks/webhooks.module';
@@ -37,6 +38,7 @@ import { WorkerRuntimeModule } from './worker-runtime.module';
SuggestionsModule,
AutopilotModule,
WithdrawalModule,
RemindersModule,
WorkerRuntimeModule,
],
providers: [WorkerHealthServer],
+295
View File
@@ -0,0 +1,295 @@
import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import { App } from 'supertest/types';
import { DomainEventsService } from '../src/domain-events/domain-events.service';
import type { AnyDomainEventEnvelope } from '../src/domain-events/domain-event-types';
import { PrismaService } from '../src/prisma/prisma.service';
import { DigestService } from '../src/reminders/digest.service';
import { TaskDueHandlers } from '../src/reminders/task-due.handlers';
import { createTestApp } from './utils/create-test-app';
import { drainDomainEvents } from './utils/domain-events';
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
/**
* Fixed day in 2001 on which the swarm season begins by calendar
* (KIRSCHE 15 April), so an apiary's segment starts "today". Other files
* share the database: assertions only look at the rows and lines of this
* file's own users, apiary and tasks.
*/
const TODAY = '2001-04-15';
describe('Reminders (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
let digests: DigestService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
const roleIds: string[] = [];
const userIds: string[] = [];
const taskIds: string[] = [];
const colonyIds: string[] = [];
let apiaryId: string;
let apiaryName: string;
const users = {} as Record<
'full' | 'noInspection' | 'completer' | 'off' | 'nothing',
string
>;
async function createUser(
label: string,
permissions: string[],
): Promise<string> {
const role = await prisma.role.create({
data: {
key: `REMINDERS_${label.toUpperCase()}_${suffix}`,
name: `Reminders ${label}`,
permissions: {
create: permissions.map((key) => ({
permission: { connect: { key } },
})),
},
},
});
roleIds.push(role.id);
const user = await prisma.user.create({
data: {
email: `reminders-e2e-${label}-${suffix}@example.com`,
passwordHash: await bcrypt.hash('SuperSecret123!', 4),
name: `Reminders ${label}`,
role: { connect: { id: role.id } },
},
});
userIds.push(user.id);
return user.id;
}
async function createTask(
title: string,
assignedUserId: string | null,
dueDate: string,
): Promise<string> {
const task = await prisma.task.create({
data: { title, assignedUserId, dueDate: d(dueDate) },
});
taskIds.push(task.id);
return task.id;
}
async function recordTaskDue(taskId: string, dueOn: string): Promise<void> {
await prisma.$transaction((tx) =>
app.get(DomainEventsService).record(tx, {
type: 'TaskDue',
aggregateType: 'Task',
aggregateId: taskId,
payload: { dueOn },
dedupeKey: `TaskDue:${taskId}:${dueOn}`,
}),
);
}
const notificationsOf = (userId: string, relatedId: string) =>
prisma.notification.findMany({ where: { userId, relatedId } });
const digestOf = (userId: string) =>
prisma.notification.findMany({
where: { userId, dedupeKey: `digest:${TODAY}:${userId}` },
});
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
digests = app.get(DigestService);
users.full = await createUser('full', [
'tasks:read',
'colonies:read',
'inspections:read',
'apiaries:read',
]);
users.noInspection = await createUser('noinspection', [
'tasks:read',
'apiaries:read',
]);
users.completer = await createUser('completer', [
'tasks:read',
'tasks:complete',
]);
users.off = await createUser('off', ['tasks:read', 'apiaries:read']);
users.nothing = await createUser('nothing', ['colonies:read']);
await prisma.notificationPreference.create({
data: { userId: users.off, taskDueEnabled: false, digestEnabled: false },
});
apiaryName = `Reminders e2e ${suffix}`;
apiaryId = (await prisma.apiary.create({ data: { name: apiaryName } })).id;
for (const code of ['A', 'B']) {
const colony = await prisma.colony.create({
data: {
code: `REM-E2E-${suffix}-${code}`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
createdAt: d('2001-03-01'),
},
});
colonyIds.push(colony.id);
await prisma.inspection.create({
data: {
colonyId: colony.id,
userId: users.full,
date: d('2001-03-10'),
},
});
}
});
afterAll(async () => {
await prisma.notification.deleteMany({
where: {
OR: [{ userId: { in: userIds } }, { relatedId: { in: taskIds } }],
},
});
await prisma.domainEvent.deleteMany({
where: { aggregateId: { in: taskIds } },
});
await prisma.inspection.deleteMany({
where: { colonyId: { in: colonyIds } },
});
await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } });
await prisma.apiary.delete({ where: { id: apiaryId } });
await prisma.task.deleteMany({ where: { id: { in: taskIds } } });
await prisma.refreshToken.deleteMany({
where: { userId: { in: userIds } },
});
await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } });
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
await prisma.role.deleteMany({ where: { id: { in: roleIds } } });
await app.close();
});
it('TaskDue after draining yields exactly one notification per recipient, also after redelivery', async () => {
const taskId = await createTask('Reminders e2e Waben', users.full, TODAY);
await recordTaskDue(taskId, TODAY);
await drainDomainEvents(app, [taskId]);
const rows = await notificationsOf(users.full, taskId);
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
priority: 'HINWEIS',
category: 'TASK_DUE',
message: 'Fällig seit 15.04.: Reminders e2e Waben',
link: '/tasks',
relatedType: 'Task',
dedupeKey: `task-due:${taskId}:${TODAY}:${users.full}`,
requiredPermissions: ['tasks:read'],
});
// A redelivery runs the handler a second time.
const envelope: AnyDomainEventEnvelope<'TaskDue'> = {
id: 'redelivery',
type: 'TaskDue',
aggregateType: 'Task',
aggregateId: taskId,
payload: { dueOn: TODAY },
actorUserId: null,
correlationId: 'redelivery',
occurredAt: new Date(),
attempts: 1,
};
await prisma.$transaction((tx) =>
app.get(TaskDueHandlers).taskDue(envelope, tx),
);
expect(await notificationsOf(users.full, taskId)).toHaveLength(1);
for (const other of [users.noInspection, users.completer, users.nothing]) {
expect(await notificationsOf(other, taskId)).toHaveLength(0);
}
});
it('a task without an assignee reaches holders of tasks:complete and not users who switched it off', async () => {
const taskId = await createTask('Reminders e2e offen', null, TODAY);
await recordTaskDue(taskId, TODAY);
await drainDomainEvents(app, [taskId]);
expect(await notificationsOf(users.completer, taskId)).toHaveLength(1);
for (const other of [users.full, users.noInspection, users.off]) {
expect(await notificationsOf(other, taskId)).toHaveLength(0);
}
});
it('writes nothing for a task that was completed before delivery', async () => {
const taskId = await createTask(
'Reminders e2e erledigt',
users.full,
TODAY,
);
await prisma.task.update({
where: { id: taskId },
data: { status: 'DONE', completedAt: new Date() },
});
await recordTaskDue(taskId, TODAY);
await drainDomainEvents(app, [taskId]);
expect(await notificationsOf(users.full, taskId)).toHaveLength(0);
});
it('running the digest step twice yields one digest per user', async () => {
// Plus the open task of the first test: three due, one overdue.
await createTask('Reminders e2e heute', users.full, TODAY);
await createTask('Reminders e2e ueberfaellig', users.full, '2001-04-10');
const own = [
users.full,
users.noInspection,
users.completer,
users.off,
users.nothing,
];
const first = await digests.runFor(TODAY, own);
const second = await digests.runFor(TODAY, own);
expect(first).toBeGreaterThanOrEqual(2);
expect(second).toBe(0);
for (const userId of own) {
expect((await digestOf(userId)).length).toBeLessThanOrEqual(1);
}
const [full] = await digestOf(users.full);
expect(full).toMatchObject({
priority: 'INFO',
category: 'DIGEST',
link: '/dashboard',
});
const lines = full.message.split('\n');
expect(lines[0]).toBe('Guten Morgen. Heute für dich:');
expect(lines).toContain('3 Aufgaben fällig, 1 davon überfällig.');
expect(lines).toContain(
`Durchsicht fällig: 2 Völker am Stand ${apiaryName}.`,
);
expect(lines).toContain(
`Stand ${apiaryName}: Schwarmzeit beginnt. Durchsicht alle 7 Tage.`,
);
expect(full.requiredPermissions).toEqual(
expect.arrayContaining([
'tasks:read',
'colonies:read',
'inspections:read',
'apiaries:read',
]),
);
});
it('the digest of a user without varroa or inspection rights has no such line', async () => {
await digests.runFor(TODAY, [users.noInspection]);
const [digest] = await digestOf(users.noInspection);
expect(digest.message).toContain(`Stand ${apiaryName}: Schwarmzeit`);
expect(digest.message).not.toContain('Durchsicht fällig');
expect(digest.requiredPermissions).not.toContain('inspections:read');
expect(digest.requiredPermissions).not.toContain('colonies:read');
});
it('builds no digest for a user who can see nothing in it, and none for a user who switched it off', async () => {
await digests.runFor(TODAY, [users.nothing, users.off]);
expect(await digestOf(users.nothing)).toHaveLength(0);
expect(await digestOf(users.off)).toHaveLength(0);
});
});
@@ -200,10 +200,16 @@ describe('Worker runtime (e2e)', () => {
where: { aggregateId: `${suffix}-0` },
include: { deliveries: true },
});
expect(event).toMatchObject({
status: 'DONE',
deliveries: [{ handler: 'test.worker-counter', status: 'DONE' }],
});
// TaskDue also has the real reminder handler (the task does not exist: a no-op).
expect(event.status).toBe('DONE');
expect(
event.deliveries.map(({ handler, status }) => ({ handler, status })),
).toEqual(
expect.arrayContaining([
{ handler: 'test.worker-counter', status: 'DONE' },
{ handler: 'notifications.task-due', status: 'DONE' },
]),
);
});
it('wakes the calendar outbox loop on a calendar_sync notification', async () => {