feat: T4b/T4c wave 8 - weather history, phenology, honey chain, Telegram base

- Weather history from Open-Meteo behind an adapter: worker loop
  phenology-weather with backoff, Retry-After, User-Agent, chunked
  writes and forecast-to-archive correction
- Phenology: growing degree sum per DWD, phase from reports, GTS and
  calendar, calibration from previous years, daily job step,
  SeasonService reads the stored state
- Honey chain: prefill for extractions and batches (proposals only),
  plausibility and withdrawal warnings, Extraction lock, chain events
  and batch.created webhook from HoneyBatchRecorded
- Telegram base: schema, NotificationDelivery outbox, hardened bot
  client, startup check without crash, no-network-outside-loops guard
- ARCHITECTURE.md: sections 11.11-11.13, assumptions 42-46, env table

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 20:00:46 +02:00
1 parent 22382d53a2
commit 1971b2d9c4
90 files changed
+8431 -155

No files matched your search

@@ -1,6 +1,7 @@
import { IdempotencyCleanupService } from '../common/idempotency/idempotency-cleanup.service';
import { PrismaService } from '../prisma/prisma.service';
import { JobRunService } from '../system/job-run.service';
import { WEATHER_RETENTION_YEARS } from '../weather/history/weather-history.config';
import {
CleanupJob,
DOMAIN_EVENT_RETENTION_DAYS,
@@ -59,6 +60,16 @@ describe('CleanupJob', () => {
expect(JOB_RUN_STALE_HOURS).toBe(6);
});
it('cleanup deletes weather days before January 1st four years back', async () => {
await job.run();
const { sql, values } = statementOn('ApiaryWeatherDay');
expect(sql).toContain('"day" <');
expect(sql).toContain('Europe/Berlin');
expect(sql).toContain('make_date');
expect(values).toEqual([WEATHER_RETENTION_YEARS]);
});
it('cleanup removes only expired rows and reports counts', async () => {
const result = await job.run();
@@ -76,6 +87,7 @@ describe('CleanupJob', () => {
'webhookDeliveries.failed': 14,
'jobRuns.aborted': 1,
'jobRuns.deleted': 15,
weatherDays: 16,
});
});
+8
View File
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { IdempotencyCleanupService } from '../common/idempotency/idempotency-cleanup.service';
import { PrismaService } from '../prisma/prisma.service';
import { JobRunService } from '../system/job-run.service';
import { WEATHER_RETENTION_YEARS } from '../weather/history/weather-history.config';
import { Job, JobResult } from './job';
import { JobName } from './job-names';
@@ -63,6 +64,12 @@ export class CleanupJob implements Job {
const jobRunsDeleted = await this.prisma.$executeRaw`
DELETE FROM "JobRun"
WHERE "startedAt" < now() - (${JOB_RUN_RETENTION_DAYS} * interval '1 day')`;
// Daily means before January 1st of (year - 4): the season needs the
// current year only, the rest is a short history.
const weatherDays = await this.prisma.$executeRaw`
DELETE FROM "ApiaryWeatherDay"
WHERE "day" < make_date(
extract(year FROM now() AT TIME ZONE 'Europe/Berlin')::int - ${WEATHER_RETENTION_YEARS}, 1, 1)`;
return {
idempotencyRecords,
consumedTokens,
@@ -73,6 +80,7 @@ export class CleanupJob implements Job {
'webhookDeliveries.failed': webhookDeliveriesFailed,
'jobRuns.aborted': jobRunsAborted,
'jobRuns.deleted': jobRunsDeleted,
weatherDays,
};
}
}
@@ -3,6 +3,7 @@ import { JobPartialFailure } from '../system/job-run.service';
import { DailyEvaluationJob } from './daily-evaluation.job';
import type { AutopilotStep } from '../autopilot/autopilot.step';
import type { SuggestionsExpiryStep } from '../autopilot/suggestions-expiry.step';
import type { PhenologyStep } from '../phenology/phenology.step';
import type { DigestStep } from '../reminders/digest.step';
import type { TimeEventsStep } from './steps/time-events.step';
@@ -14,11 +15,13 @@ describe('DailyEvaluationJob', () => {
VarroaMeasurementDue: 0,
InspectionOverdue: 0,
};
const PHENOLOGY_COUNTS = { phenologyApiaries: 2, seasonChanges: 1 };
const AUTOPILOT_COUNTS = {
autopilotColonies: 4,
suggestionsOpened: 2,
suggestionsResolved: 1,
};
let phenology: { name: 'phenology'; run: jest.Mock };
let timeEvents: { name: 'time-events'; run: jest.Mock };
let autopilot: { name: 'autopilot'; run: jest.Mock };
let expiry: { name: 'suggestions-expiry'; run: jest.Mock };
@@ -27,6 +30,8 @@ describe('DailyEvaluationJob', () => {
beforeEach(() => {
jest.useFakeTimers({ now: new Date('2026-06-10T02:30:00.000Z') });
phenology = { name: 'phenology', run: jest.fn() };
phenology.run.mockResolvedValue(PHENOLOGY_COUNTS);
timeEvents = { name: 'time-events', run: jest.fn() };
timeEvents.run.mockResolvedValue(COUNTS);
autopilot = { name: 'autopilot', run: jest.fn() };
@@ -36,6 +41,7 @@ describe('DailyEvaluationJob', () => {
digest = { name: 'digest', run: jest.fn() };
digest.run.mockResolvedValue({ digestsWritten: 5 });
job = new DailyEvaluationJob(
phenology as unknown as PhenologyStep,
timeEvents as unknown as TimeEventsStep,
autopilot as unknown as AutopilotStep,
expiry as unknown as SuggestionsExpiryStep,
@@ -54,20 +60,25 @@ describe('DailyEvaluationJob', () => {
it('runs the steps for the Berlin day of the run and reports their counters', async () => {
expect(await job.run()).toEqual({
...PHENOLOGY_COUNTS,
...COUNTS,
...AUTOPILOT_COUNTS,
suggestionsExpired: 3,
digestsWritten: 5,
});
expect(phenology.run).toHaveBeenCalledWith('2026-06-10');
expect(timeEvents.run).toHaveBeenCalledWith('2026-06-10');
expect(autopilot.run).toHaveBeenCalledWith('2026-06-10');
expect(expiry.run).toHaveBeenCalledWith('2026-06-10');
expect(digest.run).toHaveBeenCalledWith('2026-06-10');
});
it('runs time-events, autopilot, suggestions-expiry and digest in that order', async () => {
it('runs phenology, time-events, autopilot, suggestions-expiry and digest in that order', async () => {
await job.run();
expect(phenology.run.mock.invocationCallOrder[0]).toBeLessThan(
timeEvents.run.mock.invocationCallOrder[0],
);
expect(timeEvents.run.mock.invocationCallOrder[0]).toBeLessThan(
autopilot.run.mock.invocationCallOrder[0],
);
@@ -95,6 +106,24 @@ describe('DailyEvaluationJob', () => {
});
});
it('a failing phenology step does not stop the others', async () => {
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
phenology.run.mockRejectedValue(
new JobPartialFailure(PHENOLOGY_COUNTS, ['1 apiary']),
);
const failure = await job.run().then(
() => null,
(error: unknown) => error,
);
expect(timeEvents.run).toHaveBeenCalled();
expect(failure).toMatchObject({
failedSteps: ['phenology'],
result: { phenologyApiaries: 2, seasonChanges: 1, digestsWritten: 5 },
});
});
it('a failing autopilot step does not stop the expiry step', async () => {
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
autopilot.run.mockRejectedValue(
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { AutopilotStep } from '../autopilot/autopilot.step';
import { SuggestionsExpiryStep } from '../autopilot/suggestions-expiry.step';
import { berlinToday } from '../common/dates';
import { PhenologyStep } from '../phenology/phenology.step';
import { DigestStep } from '../reminders/digest.step';
import { JobPartialFailure } from '../system/job-run.service';
import {
@@ -25,6 +26,7 @@ export class DailyEvaluationJob implements Job {
private readonly logger = new Logger(DailyEvaluationJob.name);
constructor(
private readonly phenology: PhenologyStep,
private readonly timeEvents: TimeEventsStep,
private readonly autopilot: AutopilotStep,
private readonly suggestionsExpiry: SuggestionsExpiryStep,
@@ -34,6 +36,7 @@ export class DailyEvaluationJob implements Job {
/** Evaluates the current Berlin day. */
async run(): Promise<JobResult> {
const steps: DailyEvaluationStep[] = [
this.phenology,
this.timeEvents,
this.autopilot,
this.suggestionsExpiry,
@@ -87,6 +87,8 @@ function fakeDb(rows: FakeRows): FakeDb {
})),
),
},
// No stored phenology: every apiary gets the calendar state.
apiaryPhenology: { findMany: jest.fn().mockResolvedValue([]) },
automationRule: {
findUnique: jest.fn(({ where }: { where: { triggerType: string } }) => {
const config = rows.rules?.[where.triggerType];
+2
View File
@@ -7,6 +7,7 @@ import { BackupModule } from '../backup/backup.module';
import { IdempotencyCleanupService } from '../common/idempotency/idempotency-cleanup.service';
import { DomainEventsModule } from '../domain-events/domain-events.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { PhenologyModule } from '../phenology/phenology.module';
import { PrismaModule } from '../prisma/prisma.module';
import { RemindersModule } from '../reminders/reminders.module';
import { SeasonModule } from '../season/season.module';
@@ -50,6 +51,7 @@ const JOB_CLASSES = [
SystemModule,
SeasonModule,
AutopilotModule,
PhenologyModule,
RemindersModule,
],
providers: [