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:
1 parent
ef33e5549b
commit
32c61c8e14
77 files changed
+5309
-142
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user