feat: T4 wave 5 - colony end cleanup, withdrawal chain, next steps, inspection takeover UI

- Colony end releases the queen in the same transaction; open
  suggestions become obsolete and a CLEANUP suggestion lists open
  tasks and the queen; apply-cleanup validates evidence and blocks
  reactivated colonies
- Withdrawal chain: plausibility warnings on harvest and bottling,
  conflict notifications also when a treatment is entered or corrected
  after the harvest (naming bottled lots), withdrawal spans in the
  calendar and M365 sync, Berlin-day end events
- Next 3 steps per colony and the "Heute" endpoint with ranking and an
  explicit merge table by intent
- Inspection-triggered rule suggestions follow the colony's latest
  inspection (resolve when the finding is gone, refresh when it stays)
- Frontend: colony card takeover in the inspection form, feed status as
  condition, occupied frames 0-40; legacy offline entries keep syncing
- Notifications and preferences are deleted with their user

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 16:28:07 +02:00
1 parent 02eb16924e
commit ef33e5549b
122 files changed
+10707 -504

No files matched your search

@@ -0,0 +1,585 @@
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 } 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<string, ColonyComputation>;
seasons: Map<string, SeasonState>;
intervalDays: Readonly<
Record<Exclude<SeasonSegmentKey, 'WINTERRUHE'>, 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<ColonyNextSteps> {
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<string>,
): Promise<Map<string, ColonyNextSteps>> {
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<string>,
): Promise<Map<string, WarningCount>> {
const held =
permissions ?? (await this.permissionResolver.getPermissions(userId));
const counts = new Map<string, WarningCount>();
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<AutopilotToday> {
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: sortedApiaries.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:
season.segment === 'WINTERRUHE'
? null
: intervalDays[season.segment],
colonies: due,
},
];
}),
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'),
};
}
private mostUrgent(
colonies: ReadonlyMap<string, ColonyComputation>,
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<string>,
today: string,
): Promise<Computation> {
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<string, ColonyComputation>();
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<string>,
): Promise<Map<string, string>> {
const days = new Map<string, string>();
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<string>,
today: string,
): Promise<Map<string, string>> {
const until = new Map<string, string>();
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<string>,
today: string,
): Promise<Map<string, StepSource[]>> {
const sources = new Map<string, StepSource[]>();
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<Map<AutomationTriggerType, string | null>> {
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),
);
}
}