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

+82 -6
View File
@@ -127,6 +127,9 @@ model User {
rawMaterialLots RawMaterialLot[]
notifications Notification[]
notificationPreference NotificationPreference?
telegramLink TelegramLink?
telegramLinkTokens TelegramLinkToken[]
notificationDeliveries NotificationDelivery[]
queenEvaluations QueenEvaluation[]
webhookSubscriptions WebhookSubscription[]
documents Document[]
@@ -1408,19 +1411,20 @@ enum NotificationCategory {
// beim Schreiben). requiredPermissions = Rechte des Empfaengers plus Rechte
// des Inhalts; dedupeKey = "<praefix>:<userId>".
model Notification {
id String @id @default(cuid())
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
priority NotificationPriority
category NotificationCategory
message String
relatedType String?
relatedId String?
link String?
requiredPermissions String[] @default([])
dedupeKey String? @unique
read Boolean @default(false)
createdAt DateTime @default(now())
requiredPermissions String[] @default([])
dedupeKey String? @unique
read Boolean @default(false)
createdAt DateTime @default(now())
deliveries NotificationDelivery[]
@@index([userId, read])
@@index([priority])
@@ -1441,6 +1445,78 @@ model NotificationPreference {
updatedAt DateTime @updatedAt
}
enum NotificationChannel {
TELEGRAM
}
enum NotificationDeliveryStatus {
PENDING
PROCESSING
DELIVERED
SKIPPED
FAILED
}
// Chat binding of a user to this deployment's Telegram bot (T4c). One private
// chat belongs to exactly one user.
model TelegramLink {
userId String @id
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
chatId BigInt @unique
telegramFirstName String?
linkedAt DateTime @default(now())
}
// Single-use link token, valid for 10 minutes; only the SHA-256 is stored.
model TelegramLinkToken {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
tokenHash String @unique
expiresAt DateTime
consumedAt DateTime?
createdAt DateTime @default(now())
@@index([userId])
}
// Outbox like WebhookDelivery. The text is not stored: the sender renders it
// from the Notification or the templateCode. CHECK (migration): exactly one of
// (notificationId with userId) or (templateCode with chatId).
model NotificationDelivery {
id String @id @default(cuid())
channel NotificationChannel
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
chatId BigInt?
notificationId String?
notification Notification? @relation(fields: [notificationId], references: [id], onDelete: Cascade)
templateCode String?
status NotificationDeliveryStatus @default(PENDING)
attempts Int @default(0)
availableAt DateTime @default(now())
claimedAt DateTime?
lastStatusCode Int?
lastError String?
deliveredAt DateTime?
createdAt DateTime @default(now())
@@unique([notificationId, channel])
@@index([status, availableAt])
@@index([userId, createdAt])
@@index([notificationId])
}
// One row: update offset and poll lease of the Telegram receiver.
model TelegramPollState {
id String @id @default("singleton")
updateOffset BigInt @default(0)
leaseOwner String?
leaseUntil DateTime?
lastPollAt DateTime?
lastError String?
}
// ---------------------------------------------------------------------------
// Webhooks (Abschnitt 27) – v1.x Modul 28
// ---------------------------------------------------------------------------