Files
ApiCore/ApiCore-Backend/src/reminders/digest.service.ts
T
Nick SzaunigandClaude Opus 5.5 32c61c8e14 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]>
2026-10-14 17:05:32 +02:00

319 lines
10 KiB
TypeScript

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