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:
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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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: [
|
||||
|
||||
Reference in new issue
Block a user