- 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]>
319 lines
10 KiB
TypeScript
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'));
|
|
}
|
|
}
|