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

@@ -4,6 +4,7 @@ import type {
DomainEventEnvelope,
DomainEventType,
} from '../domain-events/domain-event-types';
import { honeyBatchInclude } from '../honey-batches/honey-batch-include';
import { domainEventHandlerMetadata } from '../domain-events/on-domain-event.decorator';
import {
DOMAIN_EVENT_WEBHOOKS,
@@ -78,6 +79,7 @@ describe('WebhookEventHandlers', () => {
HarvestRecorded: 'harvest.created',
BatchBottled: 'batch.updated',
BottlingCorrected: 'batch.updated',
HoneyBatchRecorded: 'batch.created',
StockChanged: 'stock.changed',
TaskCreated: 'task.created',
TaskCompleted: 'task.completed',
@@ -232,6 +234,43 @@ describe('WebhookEventHandlers', () => {
},
);
it('enqueues batch.created with the honey batch and its relations for HoneyBatchRecorded', async () => {
const honeyBatch = { id: 'hb1', batchNumber: 'H-1', extraction: {} };
tx.honeyBatch.findUnique.mockResolvedValueOnce(honeyBatch);
await forward(
envelope('HoneyBatchRecorded', 'hb1', { extractionId: 'e1' }),
);
expect(tx.honeyBatch.findUnique).toHaveBeenCalledWith({
where: { id: 'hb1' },
include: honeyBatchInclude,
});
expect(webhooks.enqueueDeliveries).toHaveBeenCalledWith(
tx,
'batch.created',
honeyBatch,
);
});
it('sends nothing for HoneyBatchRecorded when the batch is gone', async () => {
tx.honeyBatch.findUnique.mockResolvedValueOnce(null);
await forward(
envelope('HoneyBatchRecorded', 'hb1', { extractionId: 'e1' }),
);
expect(webhooks.enqueueDeliveries).not.toHaveBeenCalled();
});
it('sends no webhook for a corrected honey batch (as before)', async () => {
await forward(
envelope('HoneyBatchCorrected', 'hb1', { extractionId: 'e1' }),
);
expect(webhooks.enqueueDeliveries).not.toHaveBeenCalled();
});
it('enqueues stock.changed with the bottling batch and product ids, without raw material kinds', async () => {
await forward(
envelope('StockChanged', 'm1', {