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
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user