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

@@ -11,7 +11,8 @@ import { MAX_ATTEMPTS, retryDelayMs, STALE_CLAIM_MS } from '../retry-policy';
* comparison and calculation runs in SQL against now() - the database
* clock - never against a JS Date.
*/
export type OutboxTable = 'DomainEvent' | 'WebhookDelivery';
export type OutboxTable =
'DomainEvent' | 'WebhookDelivery' | 'NotificationDelivery';
export type OutboxOrderColumn = 'occurredAt' | 'createdAt';
@@ -29,6 +30,9 @@ interface OutboxTableSql {
successStatus: 'DONE' | 'DELIVERED';
}
/** Outbox tables whose entries can be put back or skipped by the sender. */
export type DeliveryOutboxTable = 'NotificationDelivery';
/**
* Table, enum and column names cannot be bound as parameters. They come
* only from these closed maps - never from caller input - which is what
@@ -47,6 +51,12 @@ const OUTBOX_TABLES: Readonly<Record<OutboxTable, OutboxTableSql>> = {
settledAtColumn: Prisma.raw('"deliveredAt"'),
successStatus: 'DELIVERED',
},
NotificationDelivery: {
table: Prisma.raw('"NotificationDelivery"'),
statusType: Prisma.raw('"NotificationDeliveryStatus"'),
settledAtColumn: Prisma.raw('"deliveredAt"'),
successStatus: 'DELIVERED',
},
};
const ORDER_COLUMNS: Readonly<Record<OutboxOrderColumn, Prisma.Sql>> = {
@@ -54,6 +64,10 @@ const ORDER_COLUMNS: Readonly<Record<OutboxOrderColumn, Prisma.Sql>> = {
createdAt: Prisma.raw('"createdAt"'),
};
/** Longest wait deferClaim allows: a day. */
const MAX_DEFER_MS = 24 * 60 * 60 * 1000;
const MAX_SKIP_REASON_CHARS = 200;
const STALE_CLAIM_ERROR = 'claim expired (worker restarted?)';
function outboxTable(table: OutboxTable): OutboxTableSql {
@@ -206,3 +220,56 @@ export async function settleFailure(
WHERE "id" = ${id} AND "status" = 'PROCESSING' AND "claimedAt" = ${claimedAt}`);
return outcome;
}
/**
* Puts a claimed entry back to PENDING, due after `delayMs` by the database
* clock, without counting an attempt: the entry did not fail, it has to wait
* (rate limit from the receiver, quiet period, paused bot); the delay is
* capped at 24 hours. Compare-and-set
* on id + claimedAt; false if the claim was lost.
*/
export async function deferClaim(
db: DbClient,
table: DeliveryOutboxTable,
id: string,
claimedAt: Date,
delayMs: number,
): Promise<boolean> {
if (!Number.isInteger(delayMs) || delayMs < 0) {
throw new RangeError(
`Defer delay must be a non-negative integer, got ${delayMs}`,
);
}
const { table: name } = outboxTable(table);
const cappedDelayMs = Math.min(delayMs, MAX_DEFER_MS);
const count = await db.$executeRaw(Prisma.sql`
UPDATE ${name}
SET "status" = 'PENDING',
"claimedAt" = NULL,
"availableAt" = now() + (${cappedDelayMs} * interval '1 millisecond')
WHERE "id" = ${id} AND "status" = 'PROCESSING' AND "claimedAt" = ${claimedAt}`);
return count === 1;
}
/**
* Settles a claimed entry as SKIPPED: it will never be sent (the recipient
* unlinked, the message went stale). `reason` lands in lastError and must
* not contain personal data or secrets; it is cut to 200 characters. Compare-and-set on id + claimedAt;
* false if the claim was lost.
*/
export async function settleSkipped(
db: DbClient,
table: DeliveryOutboxTable,
id: string,
claimedAt: Date,
reason: string,
): Promise<boolean> {
const { table: name, statusType } = outboxTable(table);
const count = await db.$executeRaw(Prisma.sql`
UPDATE ${name}
SET "status" = CAST(${'SKIPPED'} AS ${statusType}),
"claimedAt" = NULL,
"lastError" = ${reason.slice(0, MAX_SKIP_REASON_CHARS)}
WHERE "id" = ${id} AND "status" = 'PROCESSING' AND "claimedAt" = ${claimedAt}`);
return count === 1;
}