feat: T4b/T4c wave 9 - phenology reports, autopilot A4-A10, honey ledger, Telegram delivery
- 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:
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],
|
||||
|
||||
Reference in new issue
Block a user