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

@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
import { TelegramModule } from '../telegram/telegram.module';
import { WeatherHistoryModule } from '../weather/history/weather-history.module';
import { WorkerRuntime } from './worker-runtime.service';
@@ -7,10 +8,11 @@ import { WorkerRuntime } from './worker-runtime.service';
* AllInOneModule ("all"), never by AppModule: API pods run no background
* loops. Its dependencies come from the global Prisma, DomainEvents,
* CalendarSync and Webhooks modules of the importing root module; the weather
* source comes with WeatherHistoryModule.
* source comes with WeatherHistoryModule and the Telegram client and sender
* with TelegramModule.
*/
@Module({
imports: [WeatherHistoryModule],
imports: [WeatherHistoryModule, TelegramModule],
providers: [WorkerRuntime],
exports: [WorkerRuntime],
})
@@ -7,6 +7,11 @@ import {
DRAIN_MAX_ROUNDS,
} from '../domain-events/domain-event-dispatcher';
import type { PrismaService } from '../prisma/prisma.service';
import type { TelegramClient } from '../telegram/telegram-client';
import {
TELEGRAM_SEND_POLL_MS,
type TelegramSender,
} from '../telegram/telegram-sender';
import type { WeatherHistoryService } from '../weather/history/weather-history.service';
import {
WEATHER_FIRST_TICK_DELAY_MS,
@@ -59,6 +64,11 @@ class FakeWebhookDeliveries {
drain = jest.fn<Promise<number>, unknown[]>().mockResolvedValue(0);
}
class FakeTelegramSender {
releaseStaleClaims = jest.fn<Promise<number>, []>().mockResolvedValue(0);
drain = jest.fn<Promise<number>, unknown[]>().mockResolvedValue(0);
}
describe('WorkerRuntime', () => {
const ListenerMock = PgListener as unknown as jest.Mock;
const originalDatabaseUrl = process.env.DATABASE_URL;
@@ -70,6 +80,8 @@ describe('WorkerRuntime', () => {
};
let webhookDeliveries: FakeWebhookDeliveries;
let weatherHistory: { syncOnce: jest.Mock };
let telegramConfigured: boolean;
let telegramSender: FakeTelegramSender;
let runtime: WorkerRuntime;
let logError: jest.SpyInstance;
@@ -108,14 +120,22 @@ describe('WorkerRuntime', () => {
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
runtime = new WorkerRuntime(
telegramConfigured = false;
telegramSender = new FakeTelegramSender();
runtime = createRuntime();
});
function createRuntime(): WorkerRuntime {
return new WorkerRuntime(
prisma as unknown as PrismaService,
dispatcher as unknown as DomainEventDispatcher,
calendarSync as unknown as CalendarSyncService,
webhookDeliveries as unknown as WebhookDeliveryWorker,
weatherHistory as unknown as WeatherHistoryService,
{ isConfigured: () => telegramConfigured } as unknown as TelegramClient,
telegramSender as unknown as TelegramSender,
);
});
}
afterEach(async () => {
await runtime.onModuleDestroy();
@@ -183,6 +203,72 @@ describe('WorkerRuntime', () => {
expect(webhookReleaseOrder).toBeLessThan(webhookDrainOrder);
});
describe('telegram-send loop', () => {
it('is not registered without a bot token', async () => {
await bootstrap();
await jest.advanceTimersByTimeAsync(TELEGRAM_SEND_POLL_MS * 2);
expect(telegramSender.drain).not.toHaveBeenCalled();
expect(listenerOptions().channels).not.toContain('notification_delivery');
});
describe('with a bot token', () => {
beforeEach(async () => {
await runtime.onModuleDestroy();
telegramConfigured = true;
runtime = createRuntime();
});
it('listens on notification_delivery', async () => {
await runtime.onApplicationBootstrap();
expect(listenerOptions().channels).toContain('notification_delivery');
});
it('releases stale claims, then drains every due delivery, stoppable by shutdown', async () => {
await bootstrap();
expect(telegramSender.releaseStaleClaims).toHaveBeenCalledTimes(1);
expect(telegramSender.drain).toHaveBeenCalledWith(
expect.any(AbortSignal),
);
const [releaseOrder] =
telegramSender.releaseStaleClaims.mock.invocationCallOrder;
const [drainOrder] = telegramSender.drain.mock.invocationCallOrder;
expect(releaseOrder).toBeLessThan(drainOrder);
});
it('polls every 5 s', async () => {
await bootstrap();
expect(telegramSender.drain).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(TELEGRAM_SEND_POLL_MS - 1);
expect(telegramSender.drain).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(1);
expect(telegramSender.drain).toHaveBeenCalledTimes(2);
});
it('wakes on a notification_delivery notification', async () => {
await bootstrap();
expect(telegramSender.drain).toHaveBeenCalledTimes(1);
notify('notification_delivery');
await jest.advanceTimersByTimeAsync(0);
expect(telegramSender.drain).toHaveBeenCalledTimes(2);
});
it('aborts the signal at shutdown', async () => {
await bootstrap();
const [signal] = telegramSender.drain.mock.calls[0] as [AbortSignal];
await runtime.onModuleDestroy();
expect(signal.aborted).toBe(true);
});
});
});
it('polls webhook deliveries every 5 s', async () => {
await bootstrap();
expect(webhookDeliveries.drain).toHaveBeenCalledTimes(1);
@@ -14,6 +14,11 @@ import {
DRAIN_MAX_ROUNDS,
} from '../domain-events/domain-event-dispatcher';
import { PrismaService } from '../prisma/prisma.service';
import { TelegramClient } from '../telegram/telegram-client';
import {
TELEGRAM_SEND_POLL_MS,
TelegramSender,
} from '../telegram/telegram-sender';
import {
WEATHER_FIRST_TICK_DELAY_MS,
WEATHER_HISTORY_POLL_MS,
@@ -77,6 +82,8 @@ export class WorkerRuntime implements OnApplicationBootstrap, OnModuleDestroy {
private readonly calendarSync: CalendarSyncService,
private readonly webhookDeliveries: WebhookDeliveryWorker,
private readonly weatherHistory: WeatherHistoryService,
private readonly telegramClient: TelegramClient,
private readonly telegramSender: TelegramSender,
) {
const loops = this.workerLoops().map((definition) => ({
definition,
@@ -119,6 +126,17 @@ export class WorkerRuntime implements OnApplicationBootstrap, OnModuleDestroy {
channel: WAKE_CHANNELS.webhookDeliveries,
tick: () => this.deliverWebhooks(),
},
// Without a bot token the loop is not registered at all.
...(this.telegramClient.isConfigured()
? [
{
name: 'telegram-send',
intervalMs: TELEGRAM_SEND_POLL_MS,
channel: WAKE_CHANNELS.notificationDeliveries,
tick: () => this.sendTelegram(),
},
]
: []),
{
// Daily mean temperatures for the phenology; hourly, no outbox.
name: 'phenology-weather',
@@ -216,6 +234,11 @@ export class WorkerRuntime implements OnApplicationBootstrap, OnModuleDestroy {
}
}
private async sendTelegram(): Promise<void> {
await this.telegramSender.releaseStaleClaims();
await this.telegramSender.drain(this.stopping.signal);
}
private async deliverWebhooks(): Promise<void> {
await this.webhookDeliveries.releaseStaleClaims();
// No filter: every due delivery of the database.
@@ -12,6 +12,7 @@ import { PhenologyModule } from '../phenology/phenology.module';
import { RemindersModule } from '../reminders/reminders.module';
import { SuggestionsModule } from '../suggestions/suggestions.module';
import { TasksModule } from '../tasks/tasks.module';
import { TelegramModule } from '../telegram/telegram.module';
import { WebhooksModule } from '../webhooks/webhooks.module';
import { WithdrawalModule } from '../withdrawal/withdrawal.module';
import { WorkerHealthServer } from './worker-health-server.service';
@@ -41,6 +42,7 @@ import { WorkerRuntimeModule } from './worker-runtime.module';
PhenologyModule,
WithdrawalModule,
RemindersModule,
TelegramModule,
WorkerRuntimeModule,
],
providers: [WorkerHealthServer],