feat: T4b/T4c wave 9 - phenology reports, autopilot A4-A10, honey ledger, Telegram delivery
Backend Image / build-and-push (push) Successful in 4m26s
Frontend Image / build-and-push (push) Successful in 7m19s
Manifests / validate (push) Successful in 17s

- Phenology reports API with corrections and revokes; recompute per
  apiary via PhenologyRecomputeRequested, allowBackward for corrections;
  season changes are announced in the morning digest (changes since the
  previous digest, each once)
- Season autopilot A4-A10 with per-apiary sync (Apiary before Colony
  locks) and WinterCheckRecorded
- Honey batch ledger at runtime: initial amount, BOTTLING bookings,
  manual movements, one gram rounding, untracked bottlings never book
- Telegram delivery: planning, Berlin delivery times incl. DST, quiet
  hours, permission re-check and stale skip at send time, rate limits,
  cleanup of old deliveries
- ARCHITECTURE.md: assumptions 47-50 and updated sections 11.x

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 21:13:23 +02:00
1 parent 1971b2d9c4
commit 16cc557462
74 files changed
+9634 -340

No files matched your search

@@ -0,0 +1,150 @@
import type { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import type { NextStepsService } from '../autopilot/next-steps.service';
import type { NotificationsService } from '../notifications/notifications.service';
import type { PrismaService } from '../prisma/prisma.service';
import { DigestService } from './digest.service';
describe('DigestService season changes since the last digest', () => {
const NOW = new Date('2026-06-10T02:30:00.000Z');
let domainEvents: jest.Mock;
let jobRuns: jest.Mock;
let recordForUsers: jest.Mock;
let service: DigestService;
const change = (
apiaryId: string,
previousSegment: string | null,
segment: string,
occurredAt = '2026-06-09T12:00:00.000Z',
) => ({
occurredAt: new Date(occurredAt),
payload: { apiaryId, previousSegment, segment },
});
const lines = (): string[] =>
(
recordForUsers.mock.calls[0] as [string[], { message: string }]
)[1].message.split('\n');
beforeEach(() => {
domainEvents = jest.fn().mockResolvedValue([]);
jobRuns = jest.fn().mockResolvedValue([]);
recordForUsers = jest.fn().mockResolvedValue([{ id: 'n' }]);
const prisma = {
user: { findMany: jest.fn().mockResolvedValue([{ id: 'u1' }]) },
task: { findMany: jest.fn().mockResolvedValue([]) },
suggestion: { findMany: jest.fn().mockResolvedValue([]) },
colony: {
findMany: jest
.fn()
.mockResolvedValue([{ apiaryId: 'a1' }, { apiaryId: 'a2' }]),
},
apiary: {
findMany: jest.fn().mockResolvedValue([
{ id: 'a1', name: 'Wald' },
{ id: 'a2', name: 'Tal' },
]),
},
automationRule: { findUnique: jest.fn().mockResolvedValue(null) },
domainEvent: { findMany: domainEvents },
jobRun: { findMany: jobRuns },
$transaction: (work: (client: unknown) => unknown) => work({}),
} as unknown as PrismaService;
service = new DigestService(
prisma,
{ recordForUsers } as unknown as NotificationsService,
{
getPermissions: jest.fn().mockResolvedValue(new Set(['apiaries:read'])),
} as unknown as PermissionResolverService,
{
inspectionDueAll: jest.fn().mockResolvedValue([]),
} as unknown as NextStepsService,
);
});
it('reads the changes between the end of the previous window and now', async () => {
jobRuns.mockResolvedValue([
{
result: {
digestsWritten: 1,
digestWindowEnd: '2026-06-09T05:00:00.000Z',
},
},
]);
await service.runFor('2026-06-10', undefined, { until: NOW });
expect(domainEvents).toHaveBeenCalledWith(
expect.objectContaining({
where: {
type: 'SeasonPhaseChanged',
occurredAt: { gte: new Date('2026-06-09T05:00:00.000Z'), lt: NOW },
},
}),
);
});
it('takes the last 24 hours without a previous digest run', async () => {
jobRuns.mockResolvedValue([{ result: { TaskDue: 1 } }, { result: null }]);
await service.runFor('2026-06-10', undefined, { until: NOW });
expect(domainEvents).toHaveBeenCalledWith(
expect.objectContaining({
where: {
type: 'SeasonPhaseChanged',
occurredAt: { gte: new Date('2026-06-09T02:30:00.000Z'), lt: NOW },
},
}),
);
});
it('lists a segment change since the last digest, with its interval', async () => {
domainEvents.mockResolvedValue([change('a1', 'FRUEHJAHR', 'SCHWARMZEIT')]);
await service.runFor('2026-06-10', undefined, { until: NOW });
expect(lines()).toContain(
'Stand Wald: Schwarmzeit beginnt. Durchsicht alle 7 Tage.',
);
});
it('ignores phase-only changes and a first state without previous segment', async () => {
domainEvents.mockResolvedValue([
change('a1', 'SOMMER', 'SOMMER'),
change('a2', null, 'FRUEHJAHR'),
]);
expect(await service.runFor('2026-06-10', undefined, { until: NOW })).toBe(
0,
);
});
it('shows a change back neutrally and nets out several changes of one apiary', async () => {
domainEvents.mockResolvedValue([
change('a1', 'FRUEHJAHR', 'SCHWARMZEIT', '2026-06-09T08:00:00.000Z'),
change('a1', 'SCHWARMZEIT', 'FRUEHJAHR', '2026-06-09T09:00:00.000Z'),
change('a2', 'FRUEHJAHR', 'WINTERRUHE', '2026-06-09T10:00:00.000Z'),
]);
await service.runFor('2026-06-10', undefined, { until: NOW });
expect(lines().slice(1)).toEqual(['Stand Tal: wieder Winterruhe.']);
});
it('treats autumn to winter rest as a beginning, not a return', async () => {
domainEvents.mockResolvedValue([change('a1', 'HERBST', 'WINTERRUHE')]);
await service.runFor('2026-06-10', undefined, { until: NOW });
expect(lines()).toContain('Stand Wald: Winterruhe beginnt.');
});
it('leaves out apiaries without living colonies', async () => {
domainEvents.mockResolvedValue([change('a9', 'FRUEHJAHR', 'SCHWARMZEIT')]);
expect(await service.runFor('2026-06-10', undefined, { until: NOW })).toBe(
0,
);
});
});
@@ -160,6 +160,22 @@ describe('buildDigest', () => {
expect(lines(digest?.message)[1]).toBe('Stand Wald: Winterruhe beginnt.');
});
it('words a segment that went back neutrally', () => {
const digest = buildDigest('u1', new Set(['apiaries:read']), {
...EMPTY,
seasonChanges: [
{
apiaryName: 'Wald',
segment: 'WINTERRUHE',
intervalDays: null,
isReturn: true,
},
],
});
expect(lines(digest?.message)[1]).toBe('Stand Wald: wieder Winterruhe.');
});
it('builds no digest without content', () => {
expect(buildDigest('u1', ALL, EMPTY)).toBeNull();
expect(buildDigest('u1', new Set(), DATA)).toBeNull();
@@ -203,6 +219,8 @@ describe('DigestService', () => {
colony: {
findMany: jest.fn().mockResolvedValue([{ apiaryId: 'a1' }]),
},
domainEvent: { findMany: jest.fn().mockResolvedValue([]) },
jobRun: { findMany: jest.fn().mockResolvedValue([]) },
apiary: {
findMany: jest.fn().mockResolvedValue([{ id: 'a1', name: 'Wald' }]),
},
@@ -215,9 +233,6 @@ describe('DigestService', () => {
{
inspectionDueAll: jest.fn().mockResolvedValue([]),
} as unknown as NextStepsService,
{
statesFor: jest.fn().mockResolvedValue(new Map()),
},
);
});
+154 -29
View File
@@ -12,8 +12,7 @@ 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 { SEASON_SEGMENTS, SeasonSegmentKey } from '../season/season-types';
import { JobPartialFailure } from '../system/job-run.service';
/** One line of the digest and the permissions that entitled the reader to it. */
@@ -36,6 +35,8 @@ export interface DigestData {
apiaryName: string;
segment: SeasonSegmentKey;
intervalDays: number | null;
/** The segment went back (a revoked report): worded neutrally. */
isReturn?: boolean;
}[];
}
@@ -55,6 +56,17 @@ const SEGMENT_NAMES: Readonly<Record<SeasonSegmentKey, string>> = {
HERBST: 'Herbst',
};
/** The time span of season changes a digest run reports. */
export interface DigestWindow {
from?: Date;
until?: Date;
}
/** Key in the JobRun result: where the season window of that digest ended. */
export const WINDOW_END_KEY = 'digestWindowEnd';
const FALLBACK_WINDOW_MS = 24 * 60 * 60 * 1000;
const PREVIOUS_RUNS_LOOKED_AT = 10;
const OPEN_TASK_STATUSES = ['OPEN', 'IN_PROGRESS'] as const;
function plural(count: number, one: string, many: string): string {
@@ -133,11 +145,12 @@ function seasonLines(
return [];
}
return data.seasonChanges.map((change) => ({
text:
`Stand ${change.apiaryName}: ${SEGMENT_NAMES[change.segment]} beginnt.` +
(change.intervalDays === null
? ''
: ` Durchsicht alle ${change.intervalDays} Tage.`),
text: change.isReturn
? `Stand ${change.apiaryName}: wieder ${SEGMENT_NAMES[change.segment]}.`
: `Stand ${change.apiaryName}: ${SEGMENT_NAMES[change.segment]} beginnt.` +
(change.intervalDays === null
? ''
: ` Durchsicht alle ${change.intervalDays} Tage.`),
permissions: ['apiaries:read'],
}));
}
@@ -183,16 +196,23 @@ export class DigestService {
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.
* 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. `window` bounds the season changes
* this digest reports: `until` (default now) is stored by DigestStep as the
* start of the next window; `from` (default: the previous window end, else
* 24 hours before `until`) is for tests.
*/
async runFor(today: string, userIds?: readonly string[]): Promise<number> {
async runFor(
today: string,
userIds?: readonly string[],
window: DigestWindow = {},
): Promise<number> {
const users = await this.prisma.user.findMany({
where: {
isActive: true,
@@ -206,7 +226,7 @@ export class DigestService {
if (users.length === 0) {
return 0;
}
const data = await this.loadData(today);
const data = await this.loadData(today, window);
let written = 0;
let failed = 0;
for (const { id } of users) {
@@ -249,7 +269,10 @@ export class DigestService {
return rows.length;
}
private async loadData(today: string): Promise<DigestData> {
private async loadData(
today: string,
window: DigestWindow,
): Promise<DigestData> {
const [tasks, suggestions, inspectionDue, seasonChanges] =
await Promise.all([
this.loadTasks(today),
@@ -259,7 +282,7 @@ export class DigestService {
}),
// Every colony, whatever the reader may see: the lines are filtered per user.
this.nextSteps.inspectionDueAll(today),
this.loadSeasonChanges(today),
this.loadSeasonChanges(window),
]);
return { today, tasks, inspectionDue, suggestions, seasonChanges };
}
@@ -279,40 +302,142 @@ export class DigestService {
);
}
/** Apiaries with living colonies whose season segment begins today. */
/**
* The start of the window: the end of the window the previous digest run
* reported (stored in its JobRun result), else 24 hours before `until`.
* Only runs that reached the digest step carry a window end.
*/
private async windowStart(until: Date): Promise<Date> {
const runs = await this.prisma.jobRun.findMany({
where: {
job: 'daily-evaluation',
status: { in: ['SUCCEEDED', 'FAILED'] },
},
orderBy: { startedAt: 'desc' },
take: PREVIOUS_RUNS_LOOKED_AT,
select: { result: true },
});
for (const { result } of runs) {
const end =
result !== null && typeof result === 'object' && !Array.isArray(result)
? result[WINDOW_END_KEY]
: undefined;
if (typeof end === 'string' && !Number.isNaN(Date.parse(end))) {
return new Date(end);
}
}
return new Date(until.getTime() - FALLBACK_WINDOW_MS);
}
/**
* Apiaries with living colonies whose season segment changed in the window
* [previous window end, until) according to the SeasonPhaseChanged events.
* Several changes of one apiary count as one (first previous segment to
* last segment); no net change, no line. A change that happened after
* `until` belongs to the next digest, so every change is reported once.
*/
private async loadSeasonChanges(
today: string,
window: DigestWindow,
): Promise<DigestData['seasonChanges']> {
const colonies = await this.prisma.colony.findMany({
where: LIVING_COLONY_WHERE,
const until = window.until ?? new Date();
const from = window.from ?? (await this.windowStart(until));
const events = await this.prisma.domainEvent.findMany({
where: {
type: 'SeasonPhaseChanged',
occurredAt: { gte: from, lt: until },
},
orderBy: [{ occurredAt: 'asc' }, { id: 'asc' }],
select: { payload: true },
});
const nets = netSegmentChanges(events.map((event) => event.payload));
if (nets.length === 0) {
return [];
}
const withColonies = await this.prisma.colony.findMany({
where: {
...LIVING_COLONY_WHERE,
apiaryId: { in: nets.map((net) => net.apiaryId) },
},
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,
);
const living = new Set(withColonies.map((colony) => colony.apiaryId));
const changed = nets.filter((net) => living.has(net.apiaryId));
if (changed.length === 0) {
return [];
}
const [apiaries, interval] = await Promise.all([
this.prisma.apiary.findMany({
where: { id: { in: changed.map((state) => state.apiaryId) } },
where: { id: { in: changed.map((net) => net.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,
.map((net) => ({
apiaryName: names.get(net.apiaryId) ?? '',
segment: net.segment,
intervalDays:
state.segment === 'WINTERRUHE' || !interval.active
net.segment === 'WINTERRUHE' || !interval.active
? null
: interval.config.intervalDays[state.segment],
: interval.config.intervalDays[net.segment],
isReturn: net.isReturn,
}))
.sort((a, b) => a.apiaryName.localeCompare(b.apiaryName, 'de'));
}
}
interface NetSegmentChange {
apiaryId: string;
segment: SeasonSegmentKey;
isReturn: boolean;
}
function isSegment(value: unknown): value is SeasonSegmentKey {
return SEASON_SEGMENTS.some((segment) => segment === value);
}
/** Autumn to winter rest is the normal course of the year, not a step back. */
function wentBack(from: SeasonSegmentKey, to: SeasonSegmentKey): boolean {
if (from === 'HERBST' && to === 'WINTERRUHE') {
return false;
}
return SEASON_SEGMENTS.indexOf(to) < SEASON_SEGMENTS.indexOf(from);
}
/**
* First previous segment to last segment per apiary, in event order. Events
* without a valid previous or new segment (a first state, phase-only
* changes) say nothing about a segment change.
*/
function netSegmentChanges(payloads: readonly unknown[]): NetSegmentChange[] {
const firstPrevious = new Map<string, SeasonSegmentKey>();
const last = new Map<string, SeasonSegmentKey>();
for (const payload of payloads) {
if (typeof payload !== 'object' || payload === null) {
continue;
}
const { apiaryId, previousSegment, segment } = payload as Record<
string,
unknown
>;
if (
typeof apiaryId !== 'string' ||
!isSegment(previousSegment) ||
!isSegment(segment)
) {
continue;
}
if (!firstPrevious.has(apiaryId)) {
firstPrevious.set(apiaryId, previousSegment);
}
last.set(apiaryId, segment);
}
return [...last].flatMap(([apiaryId, segment]) => {
const previous = firstPrevious.get(apiaryId);
return previous === undefined || previous === segment
? []
: [{ apiaryId, segment, isReturn: wentBack(previous, segment) }];
});
}
@@ -14,8 +14,26 @@ describe('DigestStep', () => {
runFor,
} as unknown as DigestService).run('2026-06-10');
expect(runFor).toHaveBeenCalledWith('2026-06-10');
expect(result).toEqual({ digestsWritten: 7 });
expect(runFor).toHaveBeenCalledWith('2026-06-10', undefined, {
until: expect.any(Date) as unknown,
});
const until = (
runFor.mock.calls[0] as [string, undefined, { until: Date }]
)[2].until;
expect(result).toEqual({
digestsWritten: 7,
digestWindowEnd: until.toISOString(),
});
});
it('stores no window end when nothing was written, so the next run reads the same window', async () => {
const runFor = jest.fn().mockResolvedValue(0);
const result = await new DigestStep({
runFor,
} as unknown as DigestService).run('2026-06-10');
expect(result).toEqual({ digestsWritten: 0 });
});
it('lets a partial failure through with its counter', async () => {
+12 -2
View File
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import type { DailyEvaluationStep } from '../jobs/daily-evaluation-steps';
import type { JobResult } from '../jobs/job';
import { DigestService } from './digest.service';
import { DigestService, WINDOW_END_KEY } from './digest.service';
/**
* Step "digest", the last of the daily evaluation: the in-app morning
@@ -16,6 +16,16 @@ export class DigestStep implements DailyEvaluationStep {
constructor(private readonly digests: DigestService) {}
async run(today: string): Promise<JobResult> {
return { digestsWritten: await this.digests.runFor(today) };
// The window of season changes ends here; the next run starts where
// this one stopped, so every change is reported once.
const until = new Date();
const digestsWritten = await this.digests.runFor(today, undefined, {
until,
});
// A run that wrote nothing (a same-day rerun, or no content) reported
// no change: the next run must still read the earlier window.
return digestsWritten === 0
? { digestsWritten }
: { digestsWritten, [WINDOW_END_KEY]: until.toISOString() };
}
}
@@ -1,7 +1,6 @@
import { Module } from '@nestjs/common';
import { AutopilotModule } from '../autopilot/autopilot.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { SeasonModule } from '../season/season.module';
import { DigestService } from './digest.service';
import { DigestStep } from './digest.step';
import { TaskDueHandlers } from './task-due.handlers';
@@ -14,7 +13,7 @@ import { TaskDueHandlers } from './task-due.handlers';
* PermissionsModule (API) or JobPermissionsModule (worker, jobs).
*/
@Module({
imports: [NotificationsModule, SeasonModule, AutopilotModule],
imports: [NotificationsModule, AutopilotModule],
providers: [TaskDueHandlers, DigestService, DigestStep],
exports: [DigestService, DigestStep],
})