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

+18 -4
View File
@@ -4,8 +4,12 @@ import {
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import {
WeatherHistoryConfig,
weatherHistoryConfig,
roundPoint,
} from './history/weather-history.config';
const OPEN_METEO_FORECAST_URL = 'https://api.open-meteo.com/v1/forecast';
const PAST_DAYS = 14;
const FORECAST_DAYS = 7;
const RECENT_WINDOW_DAYS = 7;
@@ -66,6 +70,10 @@ interface OpenMeteoResponse {
@Injectable()
export class WeatherService {
// Same variables as the history source (one place for URL and key); a bad
// setting stops the process at startup.
private readonly config: WeatherHistoryConfig = weatherHistoryConfig();
constructor(private readonly prisma: PrismaService) {}
async getWeather(apiaryId: string): Promise<ApiaryWeather> {
@@ -107,9 +115,11 @@ export class WeatherService {
latitude: number,
longitude: number,
): Promise<DailyWeather[]> {
const url = new URL(OPEN_METEO_FORECAST_URL);
url.searchParams.set('latitude', String(latitude));
url.searchParams.set('longitude', String(longitude));
const url = new URL(this.config.forecastUrl);
// 0.01 degrees: no exact stand location leaves the app.
const point = roundPoint(latitude, longitude);
url.searchParams.set('latitude', String(point.lat));
url.searchParams.set('longitude', String(point.lon));
url.searchParams.set(
'daily',
'temperature_2m_max,temperature_2m_min,precipitation_sum,wind_speed_10m_max,weather_code',
@@ -118,11 +128,15 @@ export class WeatherService {
url.searchParams.set('past_days', String(PAST_DAYS));
url.searchParams.set('forecast_days', String(FORECAST_DAYS));
url.searchParams.set('timezone', 'auto');
if (this.config.apiKey) {
url.searchParams.set('apikey', this.config.apiKey);
}
let response: Response;
try {
response = await fetch(url, {
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
headers: { 'User-Agent': this.config.userAgent },
});
} catch {
throw new BadRequestException('Wetterdaten konnten nicht geladen werden');