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> = { 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, 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, 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, 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, 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, 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::) 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 { 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 { 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 { 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 { 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 { 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')); } }