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

@@ -0,0 +1,111 @@
-- T4 migration 11/n (part Telegram and delivery): chat binding, single-use
-- link tokens, the delivery outbox and the receiver's poll state. All four
-- tables are excluded from the backup (MODELS_EXCLUDED_FROM_BACKUP).
-- NotificationDelivery carries either a notification for a user or a bot
-- system reply for a chat, never both (CHECK below), and at most one
-- delivery per notification and channel. TelegramPollState holds exactly one
-- row (CHECK below), created here.
-- CreateEnum
CREATE TYPE "NotificationChannel" AS ENUM ('TELEGRAM');
-- CreateEnum
CREATE TYPE "NotificationDeliveryStatus" AS ENUM ('PENDING', 'PROCESSING', 'DELIVERED', 'SKIPPED', 'FAILED');
-- CreateTable
CREATE TABLE "TelegramLink" (
"userId" TEXT NOT NULL,
"chatId" BIGINT NOT NULL,
"telegramFirstName" TEXT,
"linkedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TelegramLink_pkey" PRIMARY KEY ("userId")
);
-- CreateTable
CREATE TABLE "TelegramLinkToken" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"consumedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TelegramLinkToken_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "NotificationDelivery" (
"id" TEXT NOT NULL,
"channel" "NotificationChannel" NOT NULL,
"userId" TEXT,
"chatId" BIGINT,
"notificationId" TEXT,
"templateCode" TEXT,
"status" "NotificationDeliveryStatus" NOT NULL DEFAULT 'PENDING',
"attempts" INTEGER NOT NULL DEFAULT 0,
"availableAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"claimedAt" TIMESTAMP(3),
"lastStatusCode" INTEGER,
"lastError" TEXT,
"deliveredAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "NotificationDelivery_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TelegramPollState" (
"id" TEXT NOT NULL DEFAULT 'singleton',
"updateOffset" BIGINT NOT NULL DEFAULT 0,
"leaseOwner" TEXT,
"leaseUntil" TIMESTAMP(3),
"lastPollAt" TIMESTAMP(3),
"lastError" TEXT,
CONSTRAINT "TelegramPollState_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "TelegramLink_chatId_key" ON "TelegramLink"("chatId");
-- CreateIndex
CREATE UNIQUE INDEX "TelegramLinkToken_tokenHash_key" ON "TelegramLinkToken"("tokenHash");
-- CreateIndex
CREATE INDEX "TelegramLinkToken_userId_idx" ON "TelegramLinkToken"("userId");
-- CreateIndex
CREATE INDEX "NotificationDelivery_status_availableAt_idx" ON "NotificationDelivery"("status", "availableAt");
-- CreateIndex
CREATE INDEX "NotificationDelivery_userId_createdAt_idx" ON "NotificationDelivery"("userId", "createdAt");
-- CreateIndex
CREATE INDEX "NotificationDelivery_notificationId_idx" ON "NotificationDelivery"("notificationId");
-- CreateIndex
CREATE UNIQUE INDEX "NotificationDelivery_notificationId_channel_key" ON "NotificationDelivery"("notificationId", "channel");
-- AddForeignKey
ALTER TABLE "TelegramLink" ADD CONSTRAINT "TelegramLink_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TelegramLinkToken" ADD CONSTRAINT "TelegramLinkToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "NotificationDelivery" ADD CONSTRAINT "NotificationDelivery_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "NotificationDelivery" ADD CONSTRAINT "NotificationDelivery_notificationId_fkey" FOREIGN KEY ("notificationId") REFERENCES "Notification"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- A delivery is either a notification for a user (rendered at send time) or a
-- system reply for a chat (rendered from templateCode).
ALTER TABLE "NotificationDelivery" ADD CONSTRAINT "NotificationDelivery_target_check" CHECK (
("notificationId" IS NOT NULL AND "userId" IS NOT NULL AND "templateCode" IS NULL AND "chatId" IS NULL)
OR ("notificationId" IS NULL AND "templateCode" IS NOT NULL AND "chatId" IS NOT NULL)
);
-- The receiver's single state row.
ALTER TABLE "TelegramPollState" ADD CONSTRAINT "TelegramPollState_singleton_check" CHECK ("id" = 'singleton');
INSERT INTO "TelegramPollState" ("id") VALUES ('singleton');
+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
// ---------------------------------------------------------------------------