import { Injectable, NotFoundException } from '@nestjs/common'; import type { AutomationTriggerType, SuggestionIntent, TaskPriority, } from '../../generated/prisma'; import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; import { readRule } from '../automation/rule-config'; import { LIVING_COLONY_WHERE } from '../colonies/colony-status'; import { addDays, berlinDayOf, berlinToday, daysBetween, dayToDate, } from '../common/dates'; import { PrismaService } from '../prisma/prisma.service'; import { SeasonService } from '../season/season.service'; import { inspectionDueOn, inspectionIntervalOf, } from '../season/inspection-due'; import type { SeasonSegmentKey, SeasonState } from '../season/season-types'; import { buildEvidenceLookup } from '../suggestions/evidence-lookup'; import { renderSuggestion } from '../suggestions/suggestion-texts'; import { SuggestionViewsService } from '../suggestions/suggestion-views.service'; import { INTENT_OF_LEGACY_TRIGGER, type SuggestionEvidence, } from '../suggestions/suggestion.types'; import { isInWindow, withdrawalWindowOf, } from '../treatments/withdrawal-window'; import { compareSources, topSteps, type ColonyNextSteps, type NextStep, type StepSource, } from './next-step-ranking'; export interface AutopilotToday { nextStep: (NextStep & { colonyId: string; colonyCode: string }) | null; /** Colonies whose inspection is due today or overdue (`dueOn <= today`), not those coming up. */ inspectionDue: { apiaryId: string; apiaryName: string; segment: SeasonSegmentKey; intervalDays: number | null; colonies: { id: string; code: string; dueOn: string; overdueDays: number; }[]; }[]; suggestionsOpen: number; seasons: { apiaryId: string; apiaryName: string; segment: SeasonSegmentKey; phase: ColonyNextSteps['phase']; phaseSource: ColonyNextSteps['phaseSource']; }[]; tasksVisible: boolean; } /** Open suggestions of HIGH / URGENT priority that the caller may see. */ export interface WarningCount { warnings: number; critical: number; } interface ColonyComputation { code: string; apiaryId: string; /** Leading source of the first step, for the comparison across colonies. */ head: StepSource | null; view: ColonyNextSteps; } interface Computation { colonies: Map; seasons: Map; intervalDays: Readonly< Record, number> >; } /** A withdrawal period longer than this is not looked for. */ const WITHDRAWAL_LOOKBACK_DAYS = 400; const OPEN_TASK_STATUSES = ['OPEN', 'IN_PROGRESS'] as const; const WARNING_PRIORITIES: readonly TaskPriority[] = ['HIGH', 'URGENT']; /** The intent an open task stands for; null for a plain task. */ function taskIntentOf(task: { sourceSuggestion: { intent: SuggestionIntent } | null; sourceAutomationTrigger: AutomationTriggerType | null; }): SuggestionIntent | null { return ( task.sourceSuggestion?.intent ?? (task.sourceAutomationTrigger === null ? null : (INTENT_OF_LEGACY_TRIGGER[task.sourceAutomationTrigger] ?? null)) ); } function byCode(a: { code: string }, b: { code: string }): number { return a.code.localeCompare(b.code, 'de', { numeric: true }); } /** * "The next 3 steps" of a colony (Spec 8.4, 8.5) and the day overview: what * the user may see of the open tasks and suggestions, ranked, merged by form * and cut to `maxStepsPerColony`. Reads only; a fixed number of queries for * any number of colonies. */ @Injectable() export class NextStepsService { constructor( private readonly prisma: PrismaService, private readonly permissionResolver: PermissionResolverService, private readonly seasons: SeasonService, private readonly suggestionViews: SuggestionViewsService, ) {} async forColony( colonyId: string, userId: string, today: string = berlinToday(), ): Promise { const view = (await this.forColonies([colonyId], userId, today)).get( colonyId, ); if (view === undefined) { throw new NotFoundException('Colony not found'); } return view; } /** Unknown colonies are left out of the map. */ async forColonies( colonyIds: readonly string[], userId: string, today: string = berlinToday(), permissions?: ReadonlySet, ): Promise> { const held = permissions ?? (await this.permissionResolver.getPermissions(userId)); const { colonies } = await this.compute(colonyIds, held, today); return new Map( [...colonies].map(([id, colony]) => [id, colony.view] as const), ); } /** * Open HIGH / URGENT suggestions per colony: the "warnings" and * "critical" of the colony traffic light. Only what the user may see * (`tasks:read` and the permission of each suggestion). */ async warningCounts( colonyIds: readonly string[], userId: string, permissions?: ReadonlySet, ): Promise> { const held = permissions ?? (await this.permissionResolver.getPermissions(userId)); const counts = new Map(); if (!held.has('tasks:read') || colonyIds.length === 0) { return counts; } const groups = await this.prisma.suggestion.groupBy({ by: ['colonyId', 'priority'], where: { AND: [ this.suggestionViews.visibleWhere(held), { status: 'OPEN', colonyId: { in: [...colonyIds] }, priority: { in: [...WARNING_PRIORITIES] }, }, ], }, _count: { _all: true }, }); for (const group of groups) { if (group.colonyId === null) { continue; } const current = counts.get(group.colonyId) ?? { warnings: 0, critical: 0, }; counts.set( group.colonyId, group.priority === 'URGENT' ? { ...current, critical: group._count._all } : { ...current, warnings: group._count._all }, ); } return counts; } async today( userId: string, today: string = berlinToday(), ): Promise { const held = await this.permissionResolver.getPermissions(userId); const living = await this.prisma.colony.findMany({ where: LIVING_COLONY_WHERE, select: { id: true }, }); const { colonies, seasons, intervalDays } = await this.compute( living.map((colony) => colony.id), held, today, ); const apiaries = await this.prisma.apiary.findMany({ where: { id: { in: [...seasons.keys()] } }, select: { id: true, name: true }, }); const apiaryName = new Map(apiaries.map((a) => [a.id, a.name] as const)); const nameOf = (apiaryId: string): string => apiaryName.get(apiaryId) ?? ''; const suggestionsOpen = held.has('tasks:read') ? await this.prisma.suggestion.count({ where: { AND: [this.suggestionViews.visibleWhere(held), { status: 'OPEN' }], }, }) : 0; const sortedApiaries = [...seasons.values()].sort((a, b) => nameOf(a.apiaryId).localeCompare(nameOf(b.apiaryId), 'de', { numeric: true, }), ); return { nextStep: this.mostUrgent(colonies, today), inspectionDue: this.dueInspections( colonies, sortedApiaries, intervalDays, nameOf, today, ), suggestionsOpen, seasons: sortedApiaries.map((season) => ({ apiaryId: season.apiaryId, apiaryName: nameOf(season.apiaryId), segment: season.segment, phase: season.phase, phaseSource: season.phaseSource, })), tasksVisible: held.has('tasks:read'), }; } /** * Only the due inspections of all living colonies, whatever the reader * may see (the caller filters by right): the digest's input. */ async inspectionDueAll( today: string = berlinToday(), ): Promise { const living = await this.prisma.colony.findMany({ where: LIVING_COLONY_WHERE, select: { id: true }, }); const { colonies, seasons, intervalDays } = await this.compute( living.map((colony) => colony.id), new Set(['inspections:read']), today, ); const apiaries = await this.prisma.apiary.findMany({ where: { id: { in: [...seasons.keys()] } }, select: { id: true, name: true }, }); const names = new Map(apiaries.map((a) => [a.id, a.name] as const)); const nameOf = (apiaryId: string): string => names.get(apiaryId) ?? ''; const sorted = [...seasons.values()].sort((a, b) => nameOf(a.apiaryId).localeCompare(nameOf(b.apiaryId), 'de', { numeric: true, }), ); return this.dueInspections(colonies, sorted, intervalDays, nameOf, today); } private dueInspections( colonies: ReadonlyMap, sortedSeasons: readonly SeasonState[], intervalDays: Computation['intervalDays'], nameOf: (apiaryId: string) => string, today: string, ): AutopilotToday['inspectionDue'] { return sortedSeasons.flatMap((season) => { const due = [...colonies] .filter( ([, c]) => c.apiaryId === season.apiaryId && c.view.inspectionDueOn !== null && c.view.inspectionDueOn <= today, ) .map(([id, c]) => ({ id, code: c.code, dueOn: c.view.inspectionDueOn ?? today, overdueDays: daysBetween(c.view.inspectionDueOn ?? today, today), })) .sort((a, b) => a.dueOn.localeCompare(b.dueOn) || byCode(a, b)); return due.length === 0 ? [] : [ { apiaryId: season.apiaryId, apiaryName: nameOf(season.apiaryId), segment: season.segment, intervalDays: inspectionIntervalOf(season.segment, intervalDays), colonies: due, }, ]; }); } private mostUrgent( colonies: ReadonlyMap, today: string, ): AutopilotToday['nextStep'] { const candidates = [...colonies].flatMap(([colonyId, colony]) => colony.head === null || colony.view.steps.length === 0 ? [] : [{ colonyId, colony, head: colony.head }], ); candidates.sort((a, b) => compareSources(a.head, b.head, today)); const best = candidates.at(0); return best === undefined ? null : { ...best.colony.view.steps[0], colonyId: best.colonyId, colonyCode: best.colony.code, }; } private async compute( colonyIds: readonly string[], held: ReadonlySet, today: string, ): Promise { const ids = [...new Set(colonyIds)]; const rows = ids.length === 0 ? [] : await this.prisma.colony.findMany({ where: { id: { in: ids } }, select: { id: true, code: true, apiaryId: true, createdAt: true }, }); const [states, interval, autopilot] = await Promise.all([ this.seasons.statesFor( this.prisma, rows.map((row) => row.apiaryId), today, ), readRule(this.prisma, 'INSPECTION_INTERVAL'), readRule(this.prisma, 'SEASON_AUTOPILOT'), ]); const foundIds = rows.map((row) => row.id); const [lastInspections, sources, withdrawals] = await Promise.all([ this.lastInspectionDays(foundIds, held), this.stepSources(foundIds, held, today), this.withdrawalUntil(foundIds, held, today), ]); const colonies = new Map(); for (const row of rows) { const season = states.get(row.apiaryId); if (season === undefined) { continue; } const due = interval.active ? inspectionDueOn( { createdOn: berlinDayOf(row.createdAt), lastInspectedOn: lastInspections.get(row.id) ?? null, }, season, interval.config, ) : null; const own = sources.get(row.id) ?? []; const steps = topSteps(own, today, { limit: autopilot.config.maxStepsPerColony, canAccept: held.has('tasks:create'), }); colonies.set(row.id, { code: row.code, apiaryId: row.apiaryId, head: this.headOf(own, steps), view: { colonyId: row.id, segment: season.segment, phase: season.phase, phaseSource: season.phaseSource, inspectionDueOn: due !== null && held.has('inspections:read') ? due.dueOn : null, withdrawalUntil: withdrawals.get(row.id) ?? null, steps, }, }); } return { colonies, seasons: states, intervalDays: interval.config.intervalDays, }; } /** The source that stands for the first step (for the cross-colony order). */ private headOf( own: readonly StepSource[], steps: readonly NextStep[], ): StepSource | null { const first = steps.at(0); if (first === undefined) { return null; } const byId = new Map(own.map((source) => [source.id, source] as const)); const id = first.suggestionIds.at(0) ?? (first.action.type === 'OPEN_TASK' ? first.action.taskId : null); return id === null ? null : (byId.get(id) ?? null); } /** Last inspection day per colony; nothing without `inspections:read`. */ private async lastInspectionDays( colonyIds: readonly string[], held: ReadonlySet, ): Promise> { const days = new Map(); if (!held.has('inspections:read') || colonyIds.length === 0) { return days; } const groups = await this.prisma.inspection.groupBy({ by: ['colonyId'], where: { colonyId: { in: [...colonyIds] } }, _max: { date: true }, }); for (const group of groups) { if (group._max.date) { days.set(group.colonyId, berlinDayOf(group._max.date)); } } return days; } /** The youngest running withdrawal period per colony (treatments:read). */ private async withdrawalUntil( colonyIds: readonly string[], held: ReadonlySet, today: string, ): Promise> { const until = new Map(); if (!held.has('treatments:read') || colonyIds.length === 0) { return until; } const cutoff = dayToDate(addDays(today, -WITHDRAWAL_LOOKBACK_DAYS)); const treatments = await this.prisma.treatment.findMany({ where: { colonyId: { in: [...colonyIds] }, withdrawalPeriodDays: { not: null }, OR: [{ endDate: { gte: cutoff } }, { date: { gte: cutoff } }], }, select: { id: true, colonyId: true, product: true, date: true, startDate: true, endDate: true, withdrawalPeriodDays: true, }, }); for (const treatment of treatments) { const window = withdrawalWindowOf(treatment); if (window === null || !isInWindow(window, today)) { continue; } const current = until.get(treatment.colonyId); if (current === undefined || window.untilDay > current) { until.set(treatment.colonyId, window.untilDay); } } return until; } /** * Open suggestions (visible ones, and only with `tasks:read`) and open * tasks (`tasks:read`) of the colonies, as rankable sources. */ private async stepSources( colonyIds: readonly string[], held: ReadonlySet, today: string, ): Promise> { const sources = new Map(); if (!held.has('tasks:read') || colonyIds.length === 0) { return sources; } const [suggestions, tasks] = await Promise.all([ this.prisma.suggestion.findMany({ where: { AND: [ this.suggestionViews.visibleWhere(held), { status: 'OPEN', colonyId: { in: [...colonyIds] } }, ], }, select: { id: true, ruleKey: true, reasonCode: true, evidence: true, intent: true, mode: true, status: true, priority: true, dueDate: true, createdAt: true, colonyId: true, }, }), this.prisma.task.findMany({ where: { status: { in: [...OPEN_TASK_STATUSES] }, relatedType: 'COLONY', relatedId: { in: [...colonyIds] }, }, select: { id: true, title: true, status: true, priority: true, dueDate: true, createdAt: true, relatedId: true, sourceAutomationTrigger: true, sourceSuggestion: { select: { intent: true } }, }, }), ]); const [lookup, templates] = await Promise.all([ buildEvidenceLookup( this.prisma, suggestions.map((row) => row.evidence as SuggestionEvidence), ), this.titleTemplates(suggestions.map((row) => row.ruleKey)), ]); const add = (colonyId: string, source: StepSource): void => { sources.set(colonyId, [...(sources.get(colonyId) ?? []), source]); }; // One open thing per intent and colony: an open task of the same intent // (accepted suggestion or legacy rule task) hides an open suggestion. const openTaskIntents = new Set( tasks.flatMap((row) => { const intent = taskIntentOf(row); return row.relatedId === null || intent === null ? [] : [`${row.relatedId}|${intent}`]; }), ); for (const row of suggestions) { if ( row.colonyId === null || openTaskIntents.has(`${row.colonyId}|${row.intent}`) ) { continue; } const text = renderSuggestion( row, lookup, today, templates.get(row.ruleKey), ); add(row.colonyId, { kind: 'SUGGESTION', id: row.id, intent: row.intent, mode: row.mode, status: row.status, priority: row.priority, dueOn: row.dueDate === null ? null : berlinDayOf(row.dueDate), createdAt: row.createdAt, title: text.title, reasons: text.reasons, colonyId: row.colonyId, }); } for (const row of tasks) { if (row.relatedId === null) { continue; } add(row.relatedId, { kind: 'TASK', id: row.id, intent: taskIntentOf(row), mode: null, status: row.status, priority: row.priority, dueOn: row.dueDate === null ? null : berlinDayOf(row.dueDate), createdAt: row.createdAt, title: row.title, reasons: [], colonyId: row.relatedId, }); } return sources; } /** The `actionTemplate` of each rule replaces the title of its suggestions. */ private async titleTemplates( ruleKeys: readonly AutomationTriggerType[], ): Promise> { if (ruleKeys.length === 0) { return new Map(); } const rules = await this.prisma.automationRule.findMany({ where: { triggerType: { in: [...new Set(ruleKeys)] } }, select: { triggerType: true, actionTemplate: true }, }); return new Map( rules.map((rule) => [rule.triggerType, rule.actionTemplate] as const), ); } }