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

@@ -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));
}
}