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

@@ -1,7 +1,6 @@
import type { Prisma } from '../../generated/prisma';
import { berlinDayOf, dayToDate } from '../common/dates';
import type { SeasonState } from '../season/season-types';
import { utcDay } from '../domain-events/domain-event-types';
import type { AutopilotInput } from './autopilot.types';
/**
@@ -72,8 +71,8 @@ export async function loadAutopilotInputIn(
const lastTreatment = treatments
.map((treatment) => ({
id: treatment.id,
startDay: utcDay(treatment.startDate ?? treatment.date),
endDay: utcDay(treatment.endDate ?? treatment.date),
startDay: berlinDayOf(treatment.startDate ?? treatment.date),
endDay: berlinDayOf(treatment.endDate ?? treatment.date),
}))
.reduce<{ id: string; startDay: string; endDay: string } | null>(
(latest, candidate) =>
@@ -124,9 +123,11 @@ export async function loadAutopilotInputIn(
lastInspection:
inspection === null
? null
: { ...inspection, date: utcDay(inspection.date) },
: { ...inspection, date: berlinDayOf(inspection.date) },
lastVarroa:
varroa === null ? null : { id: varroa.id, date: utcDay(varroa.date) },
varroa === null
? null
: { id: varroa.id, date: berlinDayOf(varroa.date) },
lastTreatment,
varroaSinceLastTreatmentEnd,
feedingsSinceInspection,
@@ -0,0 +1,26 @@
import { Controller, Get } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { AutopilotTodayDto } from './dto/next-steps-response.dto';
import { NextStepsService, type AutopilotToday } from './next-steps.service';
@ApiTags('autopilot')
@ApiBearerAuth()
@Controller('autopilot')
export class AutopilotTodayController {
constructor(private readonly nextSteps: NextStepsService) {}
/**
* "Today": the most urgent step over all colonies, the due inspections
* per apiary, the number of open suggestions and the season per apiary.
* Tasks and suggestions only with `tasks:read`.
*/
@ApiOkResponse({ type: AutopilotTodayDto })
@RequirePermission('colonies:read')
@Get('today')
today(@CurrentUser() actor: AuthenticatedUser): Promise<AutopilotToday> {
return this.nextSteps.today(actor.id);
}
}
@@ -2,23 +2,34 @@ import { Module } from '@nestjs/common';
import { SeasonModule } from '../season/season.module';
import { SuggestionsModule } from '../suggestions/suggestions.module';
import { AutopilotEventHandlers } from './autopilot.handlers';
import { AutopilotTodayController } from './autopilot-today.controller';
import { AutopilotService } from './autopilot.service';
import { AutopilotStep } from './autopilot.step';
import { ColonyNextStepsController } from './colony-next-steps.controller';
import { NextStepsService } from './next-steps.service';
import { SuggestionsExpiryStep } from './suggestions-expiry.step';
/**
* The season autopilot: the event handler and the daily steps that keep the
* autopilot suggestions of each colony in line with the rules. Imported by
* AppModule and WorkerModule (the handler) and by JobsModule (the steps).
* autopilot suggestions of each colony in line with the rules, and the read
* side "next steps" / "today". Imported by AppModule and WorkerModule (the
* handler), JobsModule (the steps) and ApiariesModule (the colony overview).
*/
@Module({
imports: [SeasonModule, SuggestionsModule],
controllers: [ColonyNextStepsController, AutopilotTodayController],
providers: [
AutopilotService,
NextStepsService,
AutopilotEventHandlers,
AutopilotStep,
SuggestionsExpiryStep,
],
exports: [AutopilotService, AutopilotStep, SuggestionsExpiryStep],
exports: [
AutopilotService,
NextStepsService,
AutopilotStep,
SuggestionsExpiryStep,
],
})
export class AutopilotModule {}
@@ -0,0 +1,26 @@
import { Controller, Get, Param } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { ColonyNextStepsDto } from './dto/next-steps-response.dto';
import type { ColonyNextSteps } from './next-step-ranking';
import { NextStepsService } from './next-steps.service';
@ApiTags('colonies')
@ApiBearerAuth()
@Controller('colonies')
export class ColonyNextStepsController {
constructor(private readonly nextSteps: NextStepsService) {}
/** The next steps of one colony, only what the caller may see. */
@ApiOkResponse({ type: ColonyNextStepsDto })
@RequirePermission('colonies:read')
@Get(':id/next-steps')
nextStepsOf(
@Param('id') id: string,
@CurrentUser() actor: AuthenticatedUser,
): Promise<ColonyNextSteps> {
return this.nextSteps.forColony(id, actor.id);
}
}
@@ -0,0 +1,100 @@
import { ApiProperty } from '@nestjs/swagger';
import { SuggestionIntent } from '../../../generated/prisma';
import {
PHASE_SOURCES,
PHENOLOGY_PHASES,
SEASON_SEGMENTS,
} from '../../season/season-types';
import type { ColonyNextSteps, NextStep } from '../next-step-ranking';
import type { AutopilotToday } from '../next-steps.service';
const FORMS = [
'inspection',
'varroa',
'feeding',
'treatment',
'winter-check',
] as const;
class NextStepActionDto {
@ApiProperty({ enum: ['CAPTURE', 'ACCEPT', 'CLEANUP', 'OPEN_TASK'] })
type!: NextStep['action']['type'];
@ApiProperty({ enum: FORMS, required: false }) form?: (typeof FORMS)[number];
@ApiProperty({ required: false }) href?: string;
@ApiProperty({ required: false }) suggestionId?: string;
@ApiProperty({ required: false }) taskId?: string;
}
export class NextStepDto {
@ApiProperty() key!: string;
@ApiProperty({ enum: ['TASK', 'SUGGESTION'] }) kind!: NextStep['kind'];
@ApiProperty({ enum: SuggestionIntent, nullable: true })
intent!: SuggestionIntent | null;
@ApiProperty() title!: string;
@ApiProperty({ type: [String] }) reasons!: string[];
@ApiProperty({ nullable: true, type: String }) dueOn!: string | null;
@ApiProperty({ enum: ['OVERDUE', 'TODAY', 'SOON', 'LATER', 'NONE'] })
urgency!: NextStep['urgency'];
@ApiProperty({ type: [String] }) suggestionIds!: string[];
@ApiProperty({ type: NextStepActionDto }) action!: NextStepActionDto;
@ApiProperty() dismissible!: boolean;
}
export class ColonyNextStepsDto {
@ApiProperty() colonyId!: string;
@ApiProperty({ enum: SEASON_SEGMENTS }) segment!: ColonyNextSteps['segment'];
@ApiProperty({ enum: PHENOLOGY_PHASES, nullable: true })
phase!: ColonyNextSteps['phase'];
@ApiProperty({ enum: PHASE_SOURCES, nullable: true })
phaseSource!: ColonyNextSteps['phaseSource'];
@ApiProperty({ nullable: true, type: String }) inspectionDueOn!:
string | null;
@ApiProperty({ nullable: true, type: String }) withdrawalUntil!:
string | null;
@ApiProperty({ type: [NextStepDto] }) steps!: NextStepDto[];
}
class TodayNextStepDto extends NextStepDto {
@ApiProperty() colonyId!: string;
@ApiProperty() colonyCode!: string;
}
class DueColonyDto {
@ApiProperty() id!: string;
@ApiProperty() code!: string;
@ApiProperty() dueOn!: string;
@ApiProperty() overdueDays!: number;
}
class InspectionDueDto {
@ApiProperty() apiaryId!: string;
@ApiProperty() apiaryName!: string;
@ApiProperty({ enum: SEASON_SEGMENTS })
segment!: AutopilotToday['seasons'][number]['segment'];
@ApiProperty({ nullable: true, type: Number }) intervalDays!: number | null;
@ApiProperty({ type: [DueColonyDto] }) colonies!: DueColonyDto[];
}
class SeasonOfApiaryDto {
@ApiProperty() apiaryId!: string;
@ApiProperty() apiaryName!: string;
@ApiProperty({ enum: SEASON_SEGMENTS })
segment!: AutopilotToday['seasons'][number]['segment'];
@ApiProperty({ enum: PHENOLOGY_PHASES, nullable: true })
phase!: AutopilotToday['seasons'][number]['phase'];
@ApiProperty({ enum: PHASE_SOURCES, nullable: true })
phaseSource!: AutopilotToday['seasons'][number]['phaseSource'];
}
export class AutopilotTodayDto {
@ApiProperty({ type: TodayNextStepDto, nullable: true })
nextStep!: TodayNextStepDto | null;
@ApiProperty({
type: [InspectionDueDto],
description: 'Colonies whose inspection is due today or overdue.',
})
inspectionDue!: InspectionDueDto[];
@ApiProperty() suggestionsOpen!: number;
@ApiProperty({ type: [SeasonOfApiaryDto] }) seasons!: SeasonOfApiaryDto[];
@ApiProperty() tasksVisible!: boolean;
}
@@ -0,0 +1,460 @@
import type { SuggestionIntent } from '../../generated/prisma';
import {
FORM_OF_INTENT,
INTENT_ORDER,
mergeSameForm,
rankSteps,
topSteps,
toNextStep,
type StepSource,
} from './next-step-ranking';
const TODAY = '2026-05-12';
let counter = 0;
function source(overrides: Partial<StepSource> = {}): StepSource {
counter += 1;
const id = overrides.id ?? `s${String(counter).padStart(3, '0')}`;
return {
kind: 'SUGGESTION',
id,
intent: 'INSPECT',
mode: 'DO',
status: 'OPEN',
priority: 'NORMAL',
dueOn: TODAY,
createdAt: new Date('2026-05-01T08:00:00Z'),
title: 'Durchsicht',
reasons: ['Grund.'],
colonyId: 'col-1',
...overrides,
};
}
const ids = (items: readonly StepSource[]): string[] => items.map((i) => i.id);
describe('rankSteps', () => {
it('IN_PROGRESS comes first, then overdue, then by priority and the intent table', () => {
const inProgress = source({
id: 'a-progress',
kind: 'TASK',
mode: null,
intent: null,
status: 'IN_PROGRESS',
priority: 'URGENT',
dueOn: '2026-06-30',
});
const overdueLow = source({
id: 'b-overdue-low',
priority: 'LOW',
dueOn: '2026-05-01',
});
const todayHigh = source({
id: 'c-today-high',
priority: 'HIGH',
intent: 'FEED',
});
const todayHighVarroa = source({
id: 'd-today-high-varroa',
priority: 'HIGH',
intent: 'VARROA_TREAT',
});
const soon = source({ id: 'e-soon', dueOn: '2026-05-14' });
const later = source({ id: 'f-later', dueOn: '2026-05-30' });
const dateless = source({ id: 'g-none', dueOn: null });
const ranked = rankSteps(
[
dateless,
later,
soon,
todayHigh,
overdueLow,
todayHighVarroa,
inProgress,
],
TODAY,
);
// Urgency 0 holds the in-progress task, today and overdue steps; inside
// it priority decides, then the intent table (varroa before feed).
expect(ids(ranked)).toEqual([
'a-progress',
'd-today-high-varroa',
'c-today-high',
'b-overdue-low',
'e-soon',
'f-later',
'g-none',
]);
});
it('a dateless URGENT step counts as urgent', () => {
const urgentNoDate = source({
id: 'urgent',
dueOn: null,
priority: 'URGENT',
});
const soon = source({ id: 'soon', dueOn: '2026-05-14', priority: 'HIGH' });
const normalDateless = source({ id: 'plain', dueOn: null });
expect(ids(rankSteps([normalDateless, soon, urgentNoDate], TODAY))).toEqual(
['urgent', 'soon', 'plain'],
);
});
it('treats a task without an intent like FOLLOW_UP', () => {
const task = source({ id: 'task', kind: 'TASK', mode: null, intent: null });
const cleanup = source({ id: 'cleanup', intent: 'CLEANUP', mode: 'DO' });
const varroa = source({ id: 'varroa', intent: 'VARROA_TREAT' });
expect(ids(rankSteps([cleanup, task, varroa], TODAY))).toEqual([
'varroa',
'task',
'cleanup',
]);
});
it('orders ties by due day, creation time and id', () => {
const early = source({ id: 'z', dueOn: '2026-05-10' });
const sameDayOlder = source({
id: 'y',
dueOn: '2026-05-11',
createdAt: new Date('2026-05-01T00:00:00Z'),
});
const sameDayNewer = source({
id: 'x',
dueOn: '2026-05-11',
createdAt: new Date('2026-05-02T00:00:00Z'),
});
const tieB = source({ id: 'b', dueOn: '2026-05-11' });
const tieA = source({ id: 'a', dueOn: '2026-05-11' });
const ranked = rankSteps(
[sameDayNewer, tieB, early, tieA, sameDayOlder],
TODAY,
);
expect(ids(ranked)).toEqual(['z', 'y', 'a', 'b', 'x']);
});
it('does not change its input', () => {
const input = [source({ id: 'b' }), source({ id: 'a' })];
const copy = [...input];
rankSteps(input, TODAY);
expect(input).toEqual(copy);
});
});
describe('INTENT_ORDER and FORM_OF_INTENT', () => {
it('lists every intent exactly once', () => {
expect(new Set(INTENT_ORDER).size).toBe(INTENT_ORDER.length);
expect(INTENT_ORDER).toHaveLength(17);
expect(INTENT_ORDER[0]).toBe('VARROA_TREAT');
});
it('maps the intents to the capture forms of the spec', () => {
const forms: [SuggestionIntent, string][] = [
['INSPECT', 'inspection'],
['SWARM_CHECK', 'inspection'],
['QUEEN_CHECK', 'inspection'],
['QUEEN_ACCEPTANCE', 'inspection'],
['HONEY_SUPER', 'inspection'],
['HEALTH_CHECK', 'inspection'],
['VARROA_MEASURE', 'varroa'],
['VARROA_RECHECK', 'varroa'],
['FEED', 'feeding'],
['VARROA_TREAT', 'treatment'],
['SUMMER_TREATMENT', 'treatment'],
['WINTER_CHECK', 'winter-check'],
];
for (const [intent, form] of forms) {
expect(FORM_OF_INTENT[intent]).toBe(form);
}
expect(FORM_OF_INTENT.SPLIT_PLAN).toBeUndefined();
expect(FORM_OF_INTENT.CLEANUP).toBeUndefined();
});
});
describe('mergeSameForm', () => {
it('INSPECT (DO) and SWARM_CHECK merge into one step with two reasons and CAPTURE', () => {
const swarm = source({
id: 'swarm',
intent: 'SWARM_CHECK',
mode: 'PLAN',
title: 'Schwarmkontrolle',
reasons: ['Weiselzellen am 05.05. gesehen.'],
dueOn: '2026-05-11',
});
const inspect = source({
id: 'inspect',
intent: 'INSPECT',
mode: 'DO',
title: 'Durchsicht',
reasons: [
'Letzte Durchsicht vor 8 Tagen.',
'In der Schwarmzeit alle 7 Tage.',
],
dueOn: '2026-05-11',
});
const groups = mergeSameForm(rankSteps([inspect, swarm], TODAY));
expect(groups).toHaveLength(1);
const step = toNextStep(groups[0], TODAY);
expect(step.title).toBe('Durchsicht mit Schwarmkontrolle');
expect(step.reasons).toEqual([
'Weiselzellen am 05.05. gesehen.',
'Letzte Durchsicht vor 8 Tagen.',
]);
expect(step.action).toEqual({
type: 'CAPTURE',
form: 'inspection',
href: '/inspections/new?colonyId=col-1',
});
expect(step.urgency).toBe('OVERDUE');
expect(step.dismissible).toBe(true);
});
it('merges the inspection family, but never HONEY_SUPER (spec example B3)', () => {
const inspect = source({ id: 'inspect', intent: 'INSPECT', mode: 'DO' });
const swarm = source({ id: 'swarm', intent: 'SWARM_CHECK', mode: 'PLAN' });
const queen = source({ id: 'queen', intent: 'QUEEN_CHECK', mode: 'PLAN' });
const honeyPlan = source({
id: 'honey-plan',
intent: 'HONEY_SUPER',
mode: 'PLAN',
});
const honeyDo = source({
id: 'honey-do',
intent: 'HONEY_SUPER',
mode: 'DO',
});
const groups = mergeSameForm([queen, swarm, inspect, honeyPlan, honeyDo]);
expect(groups.map(ids)).toEqual([
['queen', 'swarm', 'inspect'],
['honey-plan'],
['honey-do'],
]);
});
it('merges VARROA_MEASURE and VARROA_RECHECK, not VARROA_TREAT', () => {
const measure = source({ id: 'm', intent: 'VARROA_MEASURE', mode: 'DO' });
const recheck = source({ id: 'r', intent: 'VARROA_RECHECK', mode: 'DO' });
const treat = source({ id: 't', intent: 'VARROA_TREAT', mode: 'DO' });
expect(mergeSameForm([treat, recheck, measure]).map(ids)).toEqual([
['t'],
['r', 'm'],
]);
});
it('a merged step takes the earliest due day, all suggestion ids and is dismissible', () => {
const inspect = source({
id: 'inspect',
intent: 'INSPECT',
dueOn: '2026-05-14',
title: 'Durchsicht',
reasons: ['Letzte Durchsicht vor 8 Tagen.'],
});
const queen = source({
id: 'queen',
intent: 'QUEEN_ACCEPTANCE',
mode: 'PLAN',
dueOn: '2026-05-10',
title: 'Annahme der Königin prüfen',
reasons: ['Königin am 05.05. zugesetzt.'],
});
const step = toNextStep([inspect, queen], TODAY);
expect(step.title).toBe('Durchsicht mit Annahme der Königin prüfen');
expect(step.dueOn).toBe('2026-05-10');
expect(step.urgency).toBe('OVERDUE');
expect(step.suggestionIds).toEqual(['inspect', 'queen']);
expect(step.dismissible).toBe(true);
expect(step.reasons[0]).toContain('zugesetzt');
});
it('an IN_PROGRESS task counts as TODAY', () => {
const step = toNextStep(
[
source({
kind: 'TASK',
mode: null,
intent: null,
status: 'IN_PROGRESS',
dueOn: '2026-07-01',
}),
],
TODAY,
);
expect(step.urgency).toBe('TODAY');
expect(step.suggestionIds).toEqual([]);
});
it('two PLAN steps of the same form do not merge', () => {
const a = source({ id: 'a', intent: 'SWARM_CHECK', mode: 'PLAN' });
const b = source({ id: 'b', intent: 'QUEEN_ACCEPTANCE', mode: 'PLAN' });
expect(mergeSameForm([a, b]).map(ids)).toEqual([['a'], ['b']]);
});
it('never merges tasks or steps of other colonies', () => {
const doStep = source({ id: 'do', intent: 'INSPECT', mode: 'DO' });
const task = source({
id: 'task',
kind: 'TASK',
mode: null,
intent: 'SWARM_CHECK',
});
const otherColony = source({
id: 'other',
intent: 'SWARM_CHECK',
mode: 'PLAN',
colonyId: 'col-2',
});
expect(mergeSameForm([doStep, task, otherColony]).map(ids)).toEqual([
['do'],
['task'],
['other'],
]);
});
it('keeps the rank order of the groups (a group sits where its best member sits)', () => {
const varroa = source({
id: 'varroa',
intent: 'VARROA_TREAT',
mode: 'PLAN',
});
const swarm = source({ id: 'swarm', intent: 'SWARM_CHECK', mode: 'PLAN' });
const feed = source({ id: 'feed', intent: 'FEED', mode: 'PLAN' });
const inspect = source({ id: 'inspect', intent: 'INSPECT', mode: 'DO' });
const groups = mergeSameForm([varroa, swarm, feed, inspect]);
expect(groups.map(ids)).toEqual([
['varroa'],
['swarm', 'inspect'],
['feed'],
]);
});
});
describe('toNextStep', () => {
it('opens a task with OPEN_TASK and never lets it be dismissed', () => {
const step = toNextStep(
[
source({
id: 't1',
kind: 'TASK',
mode: null,
intent: null,
title: 'Wachs',
}),
],
TODAY,
);
expect(step).toMatchObject({
key: 'task:t1',
kind: 'TASK',
intent: null,
action: { type: 'OPEN_TASK', taskId: 't1', href: '/tasks' },
dismissible: false,
});
});
it('accepts a PLAN suggestion and lets it be dismissed', () => {
const step = toNextStep(
[source({ id: 's1', intent: 'VARROA_TREAT', mode: 'PLAN' })],
TODAY,
);
expect(step).toMatchObject({
key: 'suggestion:s1',
action: { type: 'ACCEPT', suggestionId: 's1' },
dismissible: true,
});
});
it('falls back to the capture link of a PLAN suggestion when accepting is not allowed', () => {
const step = toNextStep(
[source({ id: 's1', intent: 'VARROA_TREAT', mode: 'PLAN' })],
TODAY,
{ canAccept: false },
);
expect(step.action).toEqual({
type: 'CAPTURE',
form: 'treatment',
href: '/treatments/new?colonyId=col-1',
});
});
it('points a CLEANUP suggestion at the cleanup', () => {
const step = toNextStep(
[source({ id: 'c1', intent: 'CLEANUP', mode: 'DO' })],
TODAY,
);
expect(step.action).toEqual({ type: 'CLEANUP', suggestionId: 'c1' });
});
it('builds the capture links of every form', () => {
const href = (intent: SuggestionIntent, today = TODAY): string => {
const { action } = toNextStep([source({ intent, mode: 'DO' })], today);
return action.type === 'CAPTURE' ? action.href : 'no-capture';
};
expect(href('VARROA_MEASURE')).toBe('/varroa/new?colonyId=col-1');
expect(href('FEED')).toBe('/feedings/new?colonyId=col-1');
expect(href('SUMMER_TREATMENT')).toBe('/treatments/new?colonyId=col-1');
expect(href('WINTER_CHECK', '2026-11-20')).toBe(
'/winter?colonyId=col-1&season=AUTUMN',
);
expect(href('WINTER_CHECK', '2026-03-20')).toBe(
'/winter?colonyId=col-1&season=SPRING',
);
});
it('derives the urgency from the due day', () => {
const urgency = (dueOn: string | null) =>
toNextStep([source({ dueOn })], TODAY).urgency;
expect(urgency('2026-05-11')).toBe('OVERDUE');
expect(urgency('2026-05-12')).toBe('TODAY');
expect(urgency('2026-05-15')).toBe('SOON');
expect(urgency('2026-05-16')).toBe('LATER');
expect(urgency(null)).toBe('NONE');
});
});
describe('topSteps', () => {
it('never returns more than 3 and is stable by id', () => {
const items = ['e', 'c', 'a', 'd', 'b'].map((id) =>
source({ id, intent: 'FEED', mode: 'PLAN' }),
);
const first = topSteps(items, TODAY, { limit: 3 });
const second = topSteps([...items].reverse(), TODAY, { limit: 3 });
expect(first.map((step) => step.key)).toEqual([
'suggestion:a',
'suggestion:b',
'suggestion:c',
]);
expect(second.map((step) => step.key)).toEqual(
first.map((step) => step.key),
);
});
it('drops a step the user can neither accept nor capture', () => {
const split = source({ id: 'split', intent: 'SPLIT_PLAN', mode: 'PLAN' });
const feed = source({ id: 'feed', intent: 'FEED', mode: 'PLAN' });
const steps = topSteps([split, feed], TODAY, {
limit: 3,
canAccept: false,
});
expect(steps.map((step) => step.key)).toEqual(['suggestion:feed']);
});
});
@@ -0,0 +1,438 @@
import type {
SuggestionIntent,
SuggestionMode,
TaskPriority,
} from '../../generated/prisma';
import { daysBetween } from '../common/dates';
import type {
PhaseSourceKey,
PhenologyPhaseKey,
SeasonSegmentKey,
} from '../season/season-types';
export type NextStepForm =
'inspection' | 'varroa' | 'feeding' | 'treatment' | 'winter-check';
export interface NextStep {
/** 'task:<id>' | 'suggestion:<id>' */
key: string;
kind: 'TASK' | 'SUGGESTION';
intent: SuggestionIntent | null;
title: string;
/** At most 2 short sentences. */
reasons: string[];
/** YYYY-MM-DD */
dueOn: string | null;
urgency: 'OVERDUE' | 'TODAY' | 'SOON' | 'LATER' | 'NONE';
/** The suggestions behind the step (all members of a merged step); [] for a task. */
suggestionIds: string[];
action:
| { type: 'CAPTURE'; form: NextStepForm; href: string }
| { type: 'ACCEPT'; suggestionId: string }
| { type: 'CLEANUP'; suggestionId: string }
| { type: 'OPEN_TASK'; taskId: string; href: string };
dismissible: boolean;
}
export interface ColonyNextSteps {
colonyId: string;
segment: SeasonSegmentKey;
phase: PhenologyPhaseKey | null;
phaseSource: PhaseSourceKey | null;
inspectionDueOn: string | null;
withdrawalUntil: string | null;
/** At most `maxStepsPerColony` (3). */
steps: NextStep[];
}
/** An open task or an open suggestion of one colony, ready to be ranked. */
export interface StepSource {
kind: 'TASK' | 'SUGGESTION';
id: string;
intent: SuggestionIntent | null;
mode: SuggestionMode | null;
status: string;
priority: TaskPriority;
/** YYYY-MM-DD */
dueOn: string | null;
createdAt: Date;
title: string;
reasons: string[];
colonyId: string;
}
/** Spec 8.4 point 3. A task without a suggestion counts as FOLLOW_UP. */
export const INTENT_ORDER: readonly SuggestionIntent[] = [
'VARROA_TREAT',
'QUEEN_CHECK',
'QUEEN_ACCEPTANCE',
'SWARM_CHECK',
'SPLIT_PLAN',
'FEED',
'INSPECT',
'VARROA_RECHECK',
'HEALTH_CHECK',
'SUMMER_TREATMENT',
'VARROA_MEASURE',
'WINTER_CHECK',
'HONEY_SUPER',
'QUEEN_REPLACE',
'FOLLOW_UP',
'CLEANUP',
'STOCK_REORDER',
];
export const FORM_OF_INTENT: Readonly<
Partial<Record<SuggestionIntent, NextStepForm>>
> = {
INSPECT: 'inspection',
SWARM_CHECK: 'inspection',
QUEEN_CHECK: 'inspection',
QUEEN_ACCEPTANCE: 'inspection',
HONEY_SUPER: 'inspection',
HEALTH_CHECK: 'inspection',
VARROA_MEASURE: 'varroa',
VARROA_RECHECK: 'varroa',
FEED: 'feeding',
VARROA_TREAT: 'treatment',
SUMMER_TREATMENT: 'treatment',
WINTER_CHECK: 'winter-check',
};
/** Title of the merged step; the second title is appended with "mit". */
const FORM_TITLES: Readonly<Record<NextStepForm, string>> = {
inspection: 'Durchsicht',
varroa: 'Varroa messen',
feeding: 'Füttern',
treatment: 'Behandlung',
'winter-check': 'Winterkontrolle',
};
const PRIORITY_RANK: Readonly<Record<TaskPriority, number>> = {
URGENT: 0,
HIGH: 1,
NORMAL: 2,
LOW: 3,
};
const INTENT_RANK = new Map(
INTENT_ORDER.map((intent, index) => [intent, index] as const),
);
const SOON_DAYS = 3;
const LATER_TIER_DAYS = 14;
const MAX_REASONS = 2;
const FIRST_AUTUMN_MONTH = 7;
const TASKS_HREF = '/tasks';
const DATELESS_TIER = 4;
export interface StepOptions {
/** The user may accept suggestions (tasks:create). Default true. */
canAccept?: boolean;
}
export interface TopStepsOptions extends StepOptions {
limit: number;
}
function intentRank(source: StepSource): number {
return INTENT_RANK.get(source.intent ?? 'FOLLOW_UP') ?? INTENT_ORDER.length;
}
/** Spec 8.4 point 1: 0 = now, 1 <= 3 days, 2 <= 14 days, 3 later, 4 no date. */
function urgencyTier(source: StepSource, today: string): number {
if (source.status === 'IN_PROGRESS') {
return 0;
}
if (source.dueOn === null) {
return source.priority === 'URGENT' ? 0 : DATELESS_TIER;
}
const days = daysBetween(today, source.dueOn);
if (days <= 0) {
return 0;
}
if (days <= SOON_DAYS) {
return 1;
}
return days <= LATER_TIER_DAYS ? 2 : 3;
}
function compareText(a: string, b: string): number {
if (a === b) {
return 0;
}
return a < b ? -1 : 1;
}
/** The total order of the spec: urgency, priority, intent, due day, createdAt, id. */
export function compareSources(
a: StepSource,
b: StepSource,
today: string,
): number {
return (
urgencyTier(a, today) - urgencyTier(b, today) ||
PRIORITY_RANK[a.priority] - PRIORITY_RANK[b.priority] ||
intentRank(a) - intentRank(b) ||
compareText(a.dueOn ?? '9999-12-31', b.dueOn ?? '9999-12-31') ||
a.createdAt.getTime() - b.createdAt.getTime() ||
compareText(a.id, b.id)
);
}
export function rankSteps(
items: readonly StepSource[],
today: string,
): StepSource[] {
return [...items].sort((a, b) => compareSources(a, b, today));
}
function formOf(source: StepSource): NextStepForm | undefined {
return source.kind === 'SUGGESTION' && source.intent !== null
? FORM_OF_INTENT[source.intent]
: undefined;
}
/**
* Intents that merge into one step with the "plain" member of their family
* (spec 8.4 says: same form = one step; the B3 example of spec 8.5 shows
* "Durchsicht mit Schwarmkontrolle" next to a separate "Honigraum prüfen",
* although both open the inspection form - the example wins).
* inspection family: INSPECT, SWARM_CHECK, QUEEN_CHECK, QUEEN_ACCEPTANCE
* varroa family: VARROA_MEASURE, VARROA_RECHECK
* Everything else (HONEY_SUPER, HEALTH_CHECK, FEED, VARROA_TREAT,
* SUMMER_TREATMENT, WINTER_CHECK ...) never merges.
*/
const MERGE_FAMILY: Readonly<Partial<Record<SuggestionIntent, NextStepForm>>> =
{
INSPECT: 'inspection',
SWARM_CHECK: 'inspection',
QUEEN_CHECK: 'inspection',
QUEEN_ACCEPTANCE: 'inspection',
VARROA_MEASURE: 'varroa',
VARROA_RECHECK: 'varroa',
};
/** The member whose title is the form's own title, by intent. */
const PLAIN_INTENT: Readonly<
Record<'inspection' | 'varroa', SuggestionIntent>
> = { inspection: 'INSPECT', varroa: 'VARROA_MEASURE' };
function familyOf(source: StepSource): NextStepForm | undefined {
return source.kind === 'SUGGESTION' && source.intent !== null
? MERGE_FAMILY[source.intent]
: undefined;
}
/**
* Suggestions of one merge family (table above) of one colony become one
* step, when at least one of them is a DO suggestion (PLAN suggestions alone
* are decisions, not a form to fill in). The group keeps the position of its
* best-ranked member; `ranked` must already be ranked.
*/
export function mergeSameForm(ranked: readonly StepSource[]): StepSource[][] {
const members = new Map<string, StepSource[]>();
for (const item of ranked) {
const family = familyOf(item);
if (family !== undefined) {
const key = `${item.colonyId}|${family}`;
members.set(key, [...(members.get(key) ?? []), item]);
}
}
const groups: StepSource[][] = [];
const placed = new Set<string>();
for (const item of ranked) {
const family = familyOf(item);
const key = family === undefined ? null : `${item.colonyId}|${family}`;
const group = key === null ? undefined : members.get(key);
const mergeable =
group !== undefined &&
group.length > 1 &&
group.some((member) => member.mode === 'DO');
if (key === null || !mergeable) {
groups.push([item]);
} else if (!placed.has(key)) {
placed.add(key);
groups.push(group);
}
}
return groups;
}
function urgencyOf(dueOn: string | null, today: string): NextStep['urgency'] {
if (dueOn === null) {
return 'NONE';
}
const days = daysBetween(today, dueOn);
if (days < 0) {
return 'OVERDUE';
}
if (days === 0) {
return 'TODAY';
}
return days <= SOON_DAYS ? 'SOON' : 'LATER';
}
function captureHref(
form: NextStepForm,
colonyId: string,
today: string,
): string {
const query = `colonyId=${encodeURIComponent(colonyId)}`;
switch (form) {
case 'inspection':
return `/inspections/new?${query}`;
case 'varroa':
return `/varroa/new?${query}`;
case 'feeding':
return `/feedings/new?${query}`;
case 'treatment':
return `/treatments/new?${query}`;
case 'winter-check': {
const month = Number(today.slice(5, 7));
const season = month >= FIRST_AUTUMN_MONTH ? 'AUTUMN' : 'SPRING';
return `/winter?${query}&season=${season}`;
}
}
}
function capture(
form: NextStepForm,
colonyId: string,
today: string,
): NextStep['action'] {
return {
type: 'CAPTURE',
form,
href: captureHref(form, colonyId, today),
};
}
function actionOf(
source: StepSource,
today: string,
canAccept: boolean,
): NextStep['action'] | null {
if (source.kind === 'TASK') {
return { type: 'OPEN_TASK', taskId: source.id, href: TASKS_HREF };
}
if (source.intent === 'CLEANUP') {
return { type: 'CLEANUP', suggestionId: source.id };
}
if (source.mode === 'PLAN' && canAccept) {
return { type: 'ACCEPT', suggestionId: source.id };
}
const form = formOf(source);
return form === undefined ? null : capture(form, source.colonyId, today);
}
/** Whether the user can do anything with this step (else it is not shown). */
function isActionable(
source: StepSource,
today: string,
canAccept: boolean,
): boolean {
return actionOf(source, today, canAccept) !== null;
}
/**
* The specific cause first (the step that is not the plain form, "Weiselzellen
* gesehen"), then the plain one ("Letzte Durchsicht vor 8 Tagen").
*/
function isPlain(form: NextStepForm, item: StepSource): boolean {
return (
(form === 'inspection' || form === 'varroa') &&
item.intent === PLAIN_INTENT[form]
);
}
/**
* The specific cause first ("Weiselzellen gesehen"), then the plain member
* ("Letzte Durchsicht vor 8 Tagen"), at most two.
*/
function mergedReasons(
form: NextStepForm,
group: readonly StepSource[],
): string[] {
const ordered = [
...group.filter((item) => !isPlain(form, item)),
...group.filter((item) => isPlain(form, item)),
];
const firsts = ordered.flatMap((item) => item.reasons.slice(0, 1));
const rest = ordered.flatMap((item) => item.reasons.slice(1));
return [...new Set([...firsts, ...rest])].slice(0, MAX_REASONS);
}
function mergedTitle(form: NextStepForm, group: readonly StepSource[]): string {
const second = group.find((item) => !isPlain(form, item));
const formTitle = FORM_TITLES[form];
return second === undefined ? formTitle : `${formTitle} mit ${second.title}`;
}
/** The earliest due day of the members (null when none has one). */
function earliestDue(group: readonly StepSource[]): string | null {
const days = group.flatMap((item) =>
item.dueOn === null ? [] : [item.dueOn],
);
return days.length === 0 ? null : days.reduce((a, b) => (a <= b ? a : b));
}
/** One step from a ranked group (a single item, or a merged group). */
export function toNextStep(
group: readonly StepSource[],
today: string,
options: StepOptions = {},
): NextStep {
const [head] = group;
const canAccept = options.canAccept ?? true;
const form = formOf(head);
if (group.length > 1 && form !== undefined) {
return {
key: `suggestion:${head.id}`,
kind: 'SUGGESTION',
intent: head.intent,
title: mergedTitle(form, group),
reasons: mergedReasons(form, group),
dueOn: earliestDue(group),
urgency: urgencyOf(earliestDue(group), today),
suggestionIds: group.map((item) => item.id),
action: capture(form, head.colonyId, today),
dismissible: true,
};
}
const action = actionOf(head, today, canAccept);
if (action === null) {
throw new Error(`Step ${head.id} has no action`);
}
return {
key: `${head.kind === 'TASK' ? 'task' : 'suggestion'}:${head.id}`,
kind: head.kind,
intent: head.intent,
title: head.title,
reasons: head.reasons.slice(0, MAX_REASONS),
dueOn: head.dueOn,
urgency:
head.status === 'IN_PROGRESS' ? 'TODAY' : urgencyOf(head.dueOn, today),
suggestionIds: head.kind === 'SUGGESTION' ? [head.id] : [],
action,
dismissible: head.kind === 'SUGGESTION',
};
}
/**
* The next steps of one colony: what the user can act on, ranked, merged by
* form and cut to `limit`.
*/
export function topSteps(
items: readonly StepSource[],
today: string,
options: TopStepsOptions,
): NextStep[] {
const canAccept = options.canAccept ?? true;
const actionable = items.filter((item) =>
isActionable(item, today, canAccept),
);
return mergeSameForm(rankSteps(actionable, today))
.slice(0, options.limit)
.map((group) => toNextStep(group, today, { canAccept }));
}
@@ -0,0 +1,413 @@
import { NotFoundException } from '@nestjs/common';
import type { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import type { PrismaService } from '../prisma/prisma.service';
import { SeasonService } from '../season/season.service';
import { SuggestionViewsService } from '../suggestions/suggestion-views.service';
import { NextStepsService } from './next-steps.service';
const TODAY = '2026-05-12';
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
describe('NextStepsService', () => {
let service: NextStepsService;
let prisma: {
colony: { findMany: jest.Mock };
suggestion: { findMany: jest.Mock; groupBy: jest.Mock; count: jest.Mock };
task: { findMany: jest.Mock };
inspection: { groupBy: jest.Mock };
treatment: { findMany: jest.Mock };
automationRule: { findUnique: jest.Mock; findMany: jest.Mock };
apiary: { findMany: jest.Mock };
};
let permissionResolver: { getPermissions: jest.Mock };
const grant = (...keys: string[]) =>
permissionResolver.getPermissions.mockResolvedValue(new Set(keys));
const suggestionRow = (overrides: Record<string, unknown> = {}) => ({
id: 's1',
ruleKey: 'VARROA_THRESHOLD',
reasonCode: 'UNKNOWN_CODE',
evidence: {},
intent: 'VARROA_TREAT',
mode: 'PLAN',
status: 'OPEN',
priority: 'HIGH',
dueDate: d('2026-05-12'),
createdAt: new Date('2026-05-10T08:00:00Z'),
colonyId: 'c1',
...overrides,
});
beforeEach(() => {
prisma = {
colony: {
findMany: jest.fn().mockResolvedValue([
{
id: 'c1',
code: 'B3',
apiaryId: 'a1',
createdAt: new Date('2026-04-01T08:00:00Z'),
},
]),
},
suggestion: {
findMany: jest.fn().mockResolvedValue([]),
groupBy: jest.fn().mockResolvedValue([]),
count: jest.fn().mockResolvedValue(0),
},
task: { findMany: jest.fn().mockResolvedValue([]) },
inspection: {
groupBy: jest
.fn()
.mockResolvedValue([
{ colonyId: 'c1', _max: { date: d('2026-05-03') } },
]),
},
treatment: { findMany: jest.fn().mockResolvedValue([]) },
automationRule: {
findUnique: jest.fn().mockResolvedValue(null),
findMany: jest.fn().mockResolvedValue([]),
},
apiary: { findMany: jest.fn().mockResolvedValue([]) },
};
permissionResolver = { getPermissions: jest.fn() };
service = new NextStepsService(
prisma as unknown as PrismaService,
permissionResolver as unknown as PermissionResolverService,
new SeasonService(),
new SuggestionViewsService(
prisma as unknown as PrismaService,
permissionResolver as unknown as PermissionResolverService,
),
);
});
it('answers 404 for an unknown colony', async () => {
grant('colonies:read');
prisma.colony.findMany.mockResolvedValue([]);
await expect(service.forColony('nope', 'u1', TODAY)).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('reads no suggestions and no tasks without tasks:read', async () => {
grant('colonies:read', 'varroa:read');
const steps = await service.forColony('c1', 'u1', TODAY);
expect(steps.steps).toEqual([]);
expect(prisma.suggestion.findMany).not.toHaveBeenCalled();
expect(prisma.task.findMany).not.toHaveBeenCalled();
});
it('filters the suggestions by the permissions the user holds', async () => {
grant('colonies:read', 'tasks:read', 'inspections:read');
await service.forColony('c1', 'u1', TODAY);
expect(prisma.suggestion.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
AND: [
{
OR: [
{ requiredPermission: null },
{
requiredPermission: {
in: ['colonies:read', 'tasks:read', 'inspections:read'],
},
},
],
},
{ status: 'OPEN', colonyId: { in: ['c1'] } },
],
},
}),
);
});
it('offers ACCEPT only with tasks:create, else the capture link', async () => {
prisma.suggestion.findMany.mockResolvedValue([suggestionRow()]);
grant('colonies:read', 'tasks:read', 'tasks:create');
const withCreate = await service.forColony('c1', 'u1', TODAY);
grant('colonies:read', 'tasks:read');
const withoutCreate = await service.forColony('c1', 'u1', TODAY);
expect(withCreate.steps[0].action).toEqual({
type: 'ACCEPT',
suggestionId: 's1',
});
expect(withoutCreate.steps[0].action).toEqual({
type: 'CAPTURE',
form: 'treatment',
href: '/treatments/new?colonyId=c1',
});
});
it('cuts the steps to maxStepsPerColony', async () => {
grant('colonies:read', 'tasks:read');
prisma.suggestion.findMany.mockResolvedValue(
[
['a', 'WINTER_CHECK'],
['b', 'VARROA_MEASURE'],
['c', 'INSPECT'],
['d', 'FEED'],
['e', 'VARROA_TREAT'],
].map(([id, intent]) => suggestionRow({ id, intent, mode: 'DO' })),
);
const steps = await service.forColony('c1', 'u1', TODAY);
expect(steps.steps.map((step) => step.key)).toEqual([
'suggestion:e',
'suggestion:d',
'suggestion:c',
]);
});
it('shows inspectionDueOn only with inspections:read', async () => {
grant('colonies:read');
const hidden = await service.forColony('c1', 'u1', TODAY);
grant('colonies:read', 'inspections:read');
const shown = await service.forColony('c1', 'u1', TODAY);
expect(hidden.inspectionDueOn).toBeNull();
// Swarm season by calendar: 7 days after 2026-05-03.
expect(shown.inspectionDueOn).toBe('2026-05-10');
expect(shown.segment).toBe('SCHWARMZEIT');
expect(prisma.inspection.groupBy).toHaveBeenCalledTimes(1);
});
it('reports the latest running withdrawal period as a hint with treatments:read', async () => {
prisma.treatment.findMany.mockResolvedValue([
{
id: 't1',
colonyId: 'c1',
product: 'A',
date: d('2026-05-01'),
startDate: null,
endDate: d('2026-05-05'),
withdrawalPeriodDays: 10,
},
{
id: 't2',
colonyId: 'c1',
product: 'B',
date: d('2026-05-02'),
startDate: null,
endDate: d('2026-05-08'),
withdrawalPeriodDays: 30,
},
{
id: 't3',
colonyId: 'c1',
product: 'C',
date: d('2026-01-02'),
startDate: null,
endDate: null,
withdrawalPeriodDays: 10,
},
]);
grant('colonies:read', 'treatments:read');
const shown = await service.forColony('c1', 'u1', TODAY);
grant('colonies:read');
const hidden = await service.forColony('c1', 'u1', TODAY);
expect(shown.withdrawalUntil).toBe('2026-06-07');
expect(shown.steps).toEqual([]);
expect(hidden.withdrawalUntil).toBeNull();
expect(prisma.treatment.findMany).toHaveBeenCalledTimes(1);
});
it('ranks a task like its intent and shows it with OPEN_TASK', async () => {
grant('colonies:read', 'tasks:read');
prisma.task.findMany.mockResolvedValue([
{
id: 't1',
title: 'Wachs einschmelzen',
status: 'IN_PROGRESS',
priority: 'NORMAL',
dueDate: null,
createdAt: new Date('2026-05-01T00:00:00Z'),
relatedId: 'c1',
sourceAutomationTrigger: null,
sourceSuggestion: null,
},
]);
const steps = await service.forColony('c1', 'u1', TODAY);
expect(steps.steps).toHaveLength(1);
expect(steps.steps[0]).toMatchObject({
key: 'task:t1',
title: 'Wachs einschmelzen',
action: { type: 'OPEN_TASK', taskId: 't1', href: '/tasks' },
});
});
it('hides an open suggestion when an open task of the colony has the same intent', async () => {
grant('colonies:read', 'tasks:read');
prisma.suggestion.findMany.mockResolvedValue([
suggestionRow({ id: 'varroa', intent: 'VARROA_TREAT' }),
suggestionRow({ id: 'feed', intent: 'FEED', mode: 'DO' }),
suggestionRow({ id: 'swarm', intent: 'SWARM_CHECK', colonyId: 'c9' }),
]);
prisma.task.findMany.mockResolvedValue([
{
id: 'task-accepted',
title: 'Behandeln',
status: 'OPEN',
priority: 'NORMAL',
dueDate: null,
createdAt: new Date('2026-05-01T00:00:00Z'),
relatedId: 'c1',
sourceAutomationTrigger: null,
sourceSuggestion: { intent: 'VARROA_TREAT' },
},
{
id: 'task-legacy',
title: 'Füttern',
status: 'OPEN',
priority: 'NORMAL',
dueDate: null,
createdAt: new Date('2026-05-01T00:00:00Z'),
relatedId: 'c1',
sourceAutomationTrigger: 'FEED_STATUS_CRITICAL',
sourceSuggestion: null,
},
]);
const steps = await service.forColony('c1', 'u1', TODAY);
expect(steps.steps.map((step) => step.key).sort()).toEqual([
'task:task-accepted',
'task:task-legacy',
]);
});
it('reads a fixed number of queries for many colonies', async () => {
grant('colonies:read', 'tasks:read', 'inspections:read', 'treatments:read');
prisma.colony.findMany.mockResolvedValue(
['c1', 'c2', 'c3'].map((id) => ({
id,
code: id,
apiaryId: 'a1',
createdAt: new Date('2026-04-01T08:00:00Z'),
})),
);
const result = await service.forColonies(['c1', 'c2', 'c3'], 'u1', TODAY);
expect([...result.keys()]).toEqual(['c1', 'c2', 'c3']);
expect(prisma.colony.findMany).toHaveBeenCalledTimes(1);
expect(prisma.suggestion.findMany).toHaveBeenCalledTimes(1);
expect(prisma.task.findMany).toHaveBeenCalledTimes(1);
expect(prisma.inspection.groupBy).toHaveBeenCalledTimes(1);
expect(prisma.treatment.findMany).toHaveBeenCalledTimes(1);
});
describe('today', () => {
it('picks the most urgent first step over all colonies', async () => {
grant('colonies:read', 'tasks:read');
prisma.colony.findMany.mockImplementation((args: { select: object }) =>
Promise.resolve(
'code' in args.select
? ['c1', 'c2'].map((id) => ({
id,
code: id.toUpperCase(),
apiaryId: 'a1',
createdAt: new Date('2026-04-01T08:00:00Z'),
}))
: [{ id: 'c1' }, { id: 'c2' }],
),
);
prisma.apiary.findMany.mockResolvedValue([
{ id: 'a1', name: 'Heimstand' },
]);
prisma.suggestion.count.mockResolvedValue(2);
prisma.suggestion.findMany.mockResolvedValue([
suggestionRow({ id: 'late', colonyId: 'c1', priority: 'LOW' }),
suggestionRow({ id: 'urgent', colonyId: 'c2', priority: 'URGENT' }),
]);
const today = await service.today('u1', TODAY);
expect(today.nextStep).toMatchObject({
key: 'suggestion:urgent',
colonyId: 'c2',
colonyCode: 'C2',
});
expect(today.suggestionsOpen).toBe(2);
expect(today.tasksVisible).toBe(true);
expect(today.seasons).toEqual([
{
apiaryId: 'a1',
apiaryName: 'Heimstand',
segment: 'SCHWARMZEIT',
phase: 'APFEL',
phaseSource: 'CALENDAR',
},
]);
});
it('has no next step and counts no suggestions without tasks:read', async () => {
grant('colonies:read');
const today = await service.today('u1', TODAY);
expect(today.nextStep).toBeNull();
expect(today.suggestionsOpen).toBe(0);
expect(today.tasksVisible).toBe(false);
expect(prisma.suggestion.count).not.toHaveBeenCalled();
});
it('lists only colonies whose inspection is due, with the overdue days', async () => {
grant('colonies:read', 'inspections:read');
prisma.apiary.findMany.mockResolvedValue([
{ id: 'a1', name: 'Heimstand' },
]);
const today = await service.today('u1', TODAY);
expect(today.inspectionDue).toEqual([
{
apiaryId: 'a1',
apiaryName: 'Heimstand',
segment: 'SCHWARMZEIT',
intervalDays: 7,
colonies: [
{ id: 'c1', code: 'B3', dueOn: '2026-05-10', overdueDays: 2 },
],
},
]);
});
});
describe('warningCounts', () => {
it('counts open HIGH and URGENT suggestions per colony', async () => {
grant('colonies:read', 'tasks:read');
prisma.suggestion.groupBy.mockResolvedValue([
{ colonyId: 'c1', priority: 'HIGH', _count: { _all: 2 } },
{ colonyId: 'c1', priority: 'URGENT', _count: { _all: 1 } },
{ colonyId: null, priority: 'HIGH', _count: { _all: 5 } },
]);
const counts = await service.warningCounts(['c1'], 'u1');
expect(counts).toEqual(new Map([['c1', { warnings: 2, critical: 1 }]]));
});
it('counts nothing without tasks:read', async () => {
grant('colonies:read');
const counts = await service.warningCounts(['c1'], 'u1');
expect(counts.size).toBe(0);
expect(prisma.suggestion.groupBy).not.toHaveBeenCalled();
});
});
});
@@ -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),
);
}
}