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

+25
View File
@@ -108,3 +108,28 @@ ENABLE_SWAGGER=false
# APICORE_ROLE=api
# Port des Health-Endpunkts des Worker-Prozesses.
# WORKER_HEALTH_PORT=3002
# Wetterquelle fuer die Pflanzenentwicklung (Gradtagszahl) und die
# Wetteranzeige. Einzige Quelle ist die freie Open-Meteo-API (bewusst
# uebernommenes Lizenzrisiko, nicht-kommerzielle Bedingungen); der Adapter
# (src/weather/history) erlaubt einen Wechsel. Nur https:-URLs; ein
# unbekannter Quellenname oder eine http:-URL stoppt den Start. Der Schluessel
# ist optional, gehoert in ein Secret und steht nie in Logs oder Fehlermeldungen.
# WEATHER_HISTORY_SOURCE=open-meteo
# OPEN_METEO_FORECAST_URL=https://api.open-meteo.com/v1/forecast
# OPEN_METEO_ARCHIVE_URL=https://archive-api.open-meteo.com/v1/archive
# OPEN_METEO_API_KEY=
# Optional: Kontakt (E-Mail oder URL) fuer den User-Agent-Header der
# Wetteranfragen ("ApiCore/<Version> (+<Kontakt>)"); ohne Wert
# "ApiCore/<Version> (self-hosted beekeeping app)".
# WEATHER_USER_AGENT_CONTACT=
# Telegram-Erinnerungen (optional, T4c). TELEGRAM_BOT_USERNAME (ohne @, 5-32
# Zeichen: Buchstaben, Ziffern, Unterstrich) steht in API und Worker und
# erzeugt den Deep Link. TELEGRAM_BOT_TOKEN (BotFather) gehoert NUR dem Worker,
# im Cluster in das eigene Secret apicore-telegram; der API-Pod darf es nicht
# einbinden. Ein fehlerhaftes Token stoppt nichts: Telegram bleibt aus, der
# Worker loggt einen Fehler (nie den Wert). Bei BotFather Gruppen ausschalten
# (/setjoingroups) und keinen Webhook setzen.
# TELEGRAM_BOT_USERNAME=
# TELEGRAM_BOT_TOKEN=
@@ -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
// ---------------------------------------------------------------------------
+2
View File
@@ -28,6 +28,7 @@ import { DashboardModule } from './dashboard/dashboard.module';
import { SearchModule } from './search/search.module';
import { ExportModule } from './export/export.module';
import { QrModule } from './qr/qr.module';
import { PhenologyModule } from './phenology/phenology.module';
import { RemindersModule } from './reminders/reminders.module';
import { ProductsModule } from './products/products.module';
import { BottlingModule } from './bottling/bottling.module';
@@ -114,6 +115,7 @@ import { IdempotencyInterceptor } from './common/idempotency/idempotency.interce
SuggestionsModule,
CleanupModule,
AutopilotModule,
PhenologyModule,
WithdrawalModule,
RemindersModule,
LabelTemplatesModule,
@@ -18,6 +18,7 @@ describe('NextStepsService', () => {
treatment: { findMany: jest.Mock };
automationRule: { findUnique: jest.Mock; findMany: jest.Mock };
apiary: { findMany: jest.Mock };
apiaryPhenology: { findMany: jest.Mock };
};
let permissionResolver: { getPermissions: jest.Mock };
@@ -70,6 +71,7 @@ describe('NextStepsService', () => {
findMany: jest.fn().mockResolvedValue([]),
},
apiary: { findMany: jest.fn().mockResolvedValue([]) },
apiaryPhenology: { findMany: jest.fn().mockResolvedValue([]) },
};
permissionResolver = { getPermissions: jest.fn() };
service = new NextStepsService(
@@ -1486,6 +1486,26 @@ describe('BackupService', () => {
expect(MODELS_EXCLUDED_FROM_BACKUP).not.toHaveProperty('PhenologyReport');
});
it('excludes the Telegram models and the delivery queue from the backup, each with a reason', async () => {
const bundle = await service.exportAll();
for (const [model, key] of [
['TelegramLink', 'telegramLink'],
['TelegramLinkToken', 'telegramLinkToken'],
['NotificationDelivery', 'notificationDelivery'],
['TelegramPollState', 'telegramPollState'],
] as const) {
expect(bundle.data).not.toHaveProperty(key);
expect(MODELS_EXCLUDED_FROM_BACKUP[model]).toEqual(
expect.stringMatching(/.{30,}/),
);
}
expect(MODELS_EXCLUDED_FROM_BACKUP.TelegramLink).toMatch(/re-link/);
expect(MODELS_EXCLUDED_FROM_BACKUP.NotificationDelivery).toMatch(
/resend old messages/,
);
});
it('restores phenology reports after the apiaries, remapping a user that does not exist', async () => {
await importData({
apiary: [{ id: 'apiary-1' }],
@@ -146,6 +146,12 @@ export const MODELS_EXCLUDED_FROM_BACKUP: Readonly<
ThrottlerHit: 'Rate-limit counters (UNLOGGED), valid for seconds to minutes.',
ApiaryPhenology: 'Derived season state, recomputed by daily-evaluation.',
ApiaryWeatherDay: 'Weather cache, refetched by the worker.',
TelegramLink:
"Chat binding to this deployment's bot - users re-link after a restore (like ApiKey).",
TelegramLinkToken: 'Single-use link tokens, valid for minutes.',
NotificationDelivery:
'Delivery queue - restoring it would resend old messages.',
TelegramPollState: 'Update offset and poll lease of this deployment.',
};
const DOCUMENT_ENTITY_TYPES = new Set<string>(
@@ -49,7 +49,6 @@ describe('legacy after-commit paths', () => {
it('only the modules without an event still dispatch webhooks directly', () => {
expect(filesCalling('webhooks.dispatch(')).toEqual([
'src/colonies/colonies.service.ts',
'src/honey-batches/honey-batches.service.ts',
'src/queens/queens.service.ts',
'src/storage/storage.service.ts',
]);
@@ -0,0 +1,255 @@
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { dirname, join, resolve, sep } from 'node:path';
/**
* Guard for outbound calls: the network is only reached from the worker
* loops' own directories. A static source scan (like handler-contract.spec):
* - weather hosts appear only in src/weather/, api.telegram.org only in
* src/telegram/,
* - `fetch` (call, reference or globalThis.fetch) and network packages
* (http, https, undici, axios, got, node-fetch, with or without `node:`)
* appear only in src/weather/, src/telegram/ and the older files that
* predate this guard,
* - a *.handlers.ts file or a file under src/jobs/ imports no network
* package and no module that itself reaches the network - checked on its
* own, whatever the fetch rule says.
*/
const SRC = join(__dirname, '..');
interface SourceFile {
path: string;
source: string;
}
const WEATHER_HOSTS = /open-meteo\.com/i;
const TELEGRAM_HOST = /api\.telegram\.org/i;
const FETCH_USE = /(?<![\w$.])fetch\b(?!\s*:)|\bglobalThis\s*\.\s*fetch\b/;
const NETWORK_PACKAGES = new Set([
'http',
'https',
'undici',
'axios',
'got',
'node-fetch',
]);
const IMPORT_SPECIFIER =
/(?:\bfrom\s*|\bimport\s*\(\s*|\bimport\s+|\brequire\s*\(\s*)['"]([^'"]+)['"]/g;
const ALLOWED_DIRECTORIES = ['weather/', 'telegram/'];
// Files that reach the network and predate the guard.
const ALLOWED_FILES = [
'calendar-sync/graph-client.service.ts',
'common/password/password-policy.ts',
'webhooks/webhook-delivery.ts',
'worker/worker-health.ts',
'worker/worker-health-server.service.ts',
];
function relativePath(path: string): string {
return path
.slice(SRC.length + 1)
.split(sep)
.join('/');
}
/** Source without comments, so prose like "fetch the data" does not count. */
function withoutComments(source: string): string {
return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
}
function sourceFiles(): SourceFile[] {
return readdirSync(SRC, { recursive: true, encoding: 'utf8' })
.filter((file) => file.endsWith('.ts') && !file.endsWith('.spec.ts'))
.map((file) => {
const path = join(SRC, file);
return {
path: relativePath(path),
source: readFileSync(path, 'utf8'),
};
});
}
function isNetworkPackage(specifier: string): boolean {
return NETWORK_PACKAGES.has(specifier.replace(/^node:/, ''));
}
function importsOf(source: string): string[] {
return [...source.matchAll(IMPORT_SPECIFIER)].map((match) => match[1]);
}
function inAllowedPlace(path: string): boolean {
return (
ALLOWED_DIRECTORIES.some((directory) => path.startsWith(directory)) ||
ALLOWED_FILES.includes(path)
);
}
function reachesNetwork(file: SourceFile): boolean {
return (
FETCH_USE.test(file.source) || importsOf(file.source).some(isNetworkPackage)
);
}
function isHandlerOrJob(path: string): boolean {
return path.endsWith('.handlers.ts') || path.startsWith('jobs/');
}
/** The src-relative path a relative import points to, if it is one of `known`. */
function resolveImport(
from: string,
specifier: string,
known: ReadonlySet<string>,
): string | undefined {
if (!specifier.startsWith('.')) {
return undefined;
}
const base = resolve(dirname(join(SRC, from)), specifier);
return [`${base}.ts`, join(base, 'index.ts')]
.map(relativePath)
.find((candidate) => known.has(candidate));
}
/** Where the code breaks the rule; empty when it keeps it. */
function violations(rawFiles: readonly SourceFile[]): string[] {
const files = rawFiles.map(({ path, source }) => ({
path,
source: withoutComments(source),
}));
const networkFiles = new Set(
files.filter(reachesNetwork).map((file) => file.path),
);
return files.flatMap(({ path, source }) => {
const found: string[] = [];
if (WEATHER_HOSTS.test(source) && !path.startsWith('weather/')) {
found.push(`${path}: weather host outside src/weather/`);
}
if (TELEGRAM_HOST.test(source) && !path.startsWith('telegram/')) {
found.push(`${path}: api.telegram.org outside src/telegram/`);
}
if (FETCH_USE.test(source) && !inAllowedPlace(path)) {
found.push(`${path}: fetch outside the allowed places`);
}
const specifiers = importsOf(source);
if (specifiers.some(isNetworkPackage) && !inAllowedPlace(path)) {
found.push(`${path}: network package import outside the allowed places`);
}
if (isHandlerOrJob(path)) {
if (FETCH_USE.test(source)) {
found.push(`${path}: fetch in a handler or job`);
}
if (specifiers.some(isNetworkPackage)) {
found.push(`${path}: network package imported by a handler or job`);
}
if (
specifiers.some(
(specifier) =>
resolveImport(path, specifier, networkFiles) !== undefined,
)
) {
found.push(
`${path}: handler or job imports a module that reaches the network`,
);
}
}
return found;
});
}
describe('network calls stay inside their loop directories', () => {
it('network calls stay inside their loop directories', () => {
expect(violations(sourceFiles())).toEqual([]);
});
it('knows the real network files (the scan is not vacuous)', () => {
const found = sourceFiles()
.map(({ path, source }) => ({ path, source: withoutComments(source) }))
.filter(reachesNetwork)
.map((file) => file.path);
expect(found).toEqual(
expect.arrayContaining([
...ALLOWED_FILES,
'weather/weather.service.ts',
'telegram/telegram-client.ts',
]),
);
expect(found.every(inAllowedPlace)).toBe(true);
expect(existsSync(join(SRC, 'jobs'))).toBe(true);
});
describe('with planted violations', () => {
it.each([
['colonies/colonies.service.ts', 'await fetch(url);'],
['colonies/colonies.service.ts', 'const f = fetch;'],
['colonies/colonies.service.ts', 'await globalThis.fetch(url);'],
['colonies/colonies.service.ts', "import https from 'node:https';"],
['colonies/colonies.service.ts', "import http from 'http';"],
['colonies/colonies.service.ts', "import { fetch } from 'undici';"],
['colonies/colonies.service.ts', "import axios from 'axios';"],
['colonies/colonies.service.ts', "import got from 'got';"],
['colonies/colonies.service.ts', "import fetch from 'node-fetch';"],
['colonies/colonies.service.ts', "const h = require('node:http');"],
['colonies/colonies.handlers.ts', 'await fetch(url);'],
['jobs/daily-evaluation.job.ts', 'await fetch(url);'],
['jobs/steps/phenology.step.ts', "import { get } from 'node:https';"],
['colonies/colonies.handlers.ts', "import axios from 'axios';"],
[
'notifications/notifications.service.ts',
"const u = 'https://api.telegram.org/bot';",
],
[
'notifications/notifications.service.ts',
"const u = 'https://api.open-meteo.com/v1';",
],
[
'telegram/telegram-client.ts',
"const u = 'https://api.open-meteo.com/v1';",
],
[
'weather/weather.service.ts',
"const u = 'https://api.telegram.org/bot';",
],
])('flags %s: %s', (path, source) => {
expect(violations([{ path, source }])).not.toEqual([]);
});
it('flags a handler or job importing a module that reaches the network, even without fetch of its own', () => {
const client = {
path: 'weather/weather.service.ts',
source: 'await fetch(url);',
};
for (const path of [
'colonies/colonies.handlers.ts',
'jobs/daily-evaluation-steps.ts',
]) {
const importer = {
path,
source:
"import { WeatherService } from '../weather/weather.service';",
};
expect(violations([client, importer])).toEqual([
`${path}: handler or job imports a module that reaches the network`,
]);
}
});
it.each([
[
'weather/weather.service.ts',
"await fetch('https://api.open-meteo.com/v1');",
],
[
'telegram/telegram-client.ts',
"await fetch('https://api.telegram.org/bot');",
],
['calendar-sync/graph-client.service.ts', 'await fetch(url);'],
['webhooks/webhook-delivery.ts', "import * as https from 'node:https';"],
['colonies/colonies.service.ts', 'const prefetched = refetch + fetched;'],
['colonies/colonies.service.ts', '// fetch the data later\nconst a = 1;'],
['colonies/colonies.service.ts', "const scheme = 'https';"],
['colonies/colonies.service.ts', 'const o = { fetch: 1 };'],
])('lets %s pass: %s', (path, source) => {
expect(violations([{ path, source }])).toEqual([]);
});
});
});
@@ -0,0 +1,165 @@
import { Prisma } from '../../../generated/prisma';
import { deferClaim, settleSkipped, settleSuccess } from './outbox-claims';
function fakeDb(count = 1) {
return { $executeRaw: jest.fn().mockResolvedValue(count) };
}
function sqlOf(db: ReturnType<typeof fakeDb>): Prisma.Sql {
return (db.$executeRaw.mock.calls as [Prisma.Sql][])[0][0];
}
describe('NotificationDelivery outbox helpers', () => {
const claimedAt = new Date('2026-10-15T10:00:00.000Z');
it('settleSuccess settles NotificationDelivery as DELIVERED into deliveredAt', async () => {
const db = fakeDb();
await expect(
settleSuccess(
db as never,
'NotificationDelivery',
'd1',
claimedAt,
'DELIVERED',
),
).resolves.toBe(true);
const query = sqlOf(db);
expect(query.sql).toContain('UPDATE "NotificationDelivery"');
expect(query.sql).toContain('"deliveredAt" = now()');
expect(query.sql).toContain('"NotificationDeliveryStatus"');
});
it('settleSuccess refuses DONE for NotificationDelivery', async () => {
await expect(
settleSuccess(
fakeDb() as never,
'NotificationDelivery',
'd1',
claimedAt,
'DONE',
),
).rejects.toThrow('NotificationDelivery settles as DELIVERED, not DONE');
});
it('settleSkipped is compare-and-set on id and claimedAt and keeps attempts', async () => {
const db = fakeDb();
await expect(
settleSkipped(
db as never,
'NotificationDelivery',
'd1',
claimedAt,
'chat gone',
),
).resolves.toBe(true);
const query = sqlOf(db);
expect(query.sql).toContain('UPDATE "NotificationDelivery"');
expect(query.sql).toContain(`CAST(? AS "NotificationDeliveryStatus")`);
expect(query.sql).toContain(
`WHERE "id" = ? AND "status" = 'PROCESSING' AND "claimedAt" = ?`,
);
expect(query.sql).not.toContain('"attempts"');
expect(query.values).toEqual(['SKIPPED', 'chat gone', 'd1', claimedAt]);
});
it('settleSkipped reports a lost claim', async () => {
await expect(
settleSkipped(
fakeDb(0) as never,
'NotificationDelivery',
'd1',
claimedAt,
'x',
),
).resolves.toBe(false);
});
it('deferClaim puts the entry back to PENDING after the delay by the database clock, attempts unchanged, compare-and-set', async () => {
const db = fakeDb();
await expect(
deferClaim(
db as never,
'NotificationDelivery',
'd1',
claimedAt,
15 * 60_000,
),
).resolves.toBe(true);
const query = sqlOf(db);
expect(query.sql).toContain('UPDATE "NotificationDelivery"');
expect(query.sql).toContain(`"status" = 'PENDING'`);
expect(query.sql).toContain(
`"availableAt" = now() + (? * interval '1 millisecond')`,
);
expect(query.sql).toContain('"claimedAt" = NULL');
expect(query.sql).not.toContain('"attempts"');
expect(query.sql).toContain(
`WHERE "id" = ? AND "status" = 'PROCESSING' AND "claimedAt" = ?`,
);
expect(query.values).toEqual([900_000, 'd1', claimedAt]);
});
it('deferClaim reports a lost claim and rejects a negative or non-integer delay', async () => {
await expect(
deferClaim(
fakeDb(0) as never,
'NotificationDelivery',
'd1',
claimedAt,
1,
),
).resolves.toBe(false);
await expect(
deferClaim(
fakeDb() as never,
'NotificationDelivery',
'd1',
claimedAt,
-1,
),
).rejects.toThrow(RangeError);
await expect(
deferClaim(
fakeDb() as never,
'NotificationDelivery',
'd1',
claimedAt,
1.5,
),
).rejects.toThrow(RangeError);
});
it('deferClaim caps the delay at 24 hours', async () => {
const db = fakeDb();
await deferClaim(
db as never,
'NotificationDelivery',
'd1',
claimedAt,
30 * 24 * 3_600_000,
);
expect(sqlOf(db).values[0]).toBe(24 * 3_600_000);
});
it('settleSkipped truncates the reason to 200 characters', async () => {
const db = fakeDb();
await settleSkipped(
db as never,
'NotificationDelivery',
'd1',
claimedAt,
'r'.repeat(500),
);
expect(sqlOf(db).values[1]).toBe('r'.repeat(200));
});
});
@@ -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;
}
@@ -5,6 +5,7 @@ export const WAKE_CHANNELS = {
domainEvents: 'domain_event',
calendarSync: 'calendar_sync',
webhookDeliveries: 'webhook_delivery',
notificationDeliveries: 'notification_delivery',
} as const;
export type WakeChannel = (typeof WAKE_CHANNELS)[keyof typeof WAKE_CHANNELS];
@@ -39,7 +39,7 @@ const NETWORK_PACKAGES = new Set([
'ky',
'superagent',
]);
const NETWORK_MODULE_NAME = /graph-client|webhook-delivery/;
const NETWORK_MODULE_NAME = /graph-client|webhook-delivery|telegram-client/;
const FETCH_CALL = /\bfetch\s*\(/;
const IMPORT_SPECIFIER =
/(?:\bfrom\s*|\bimport\s*\(\s*|\bimport\s+|\brequire\s*\(\s*)['"]([^'"]+)['"]/g;
@@ -147,6 +147,7 @@ describe('domain event handler contract', () => {
'calendar-sync/graph-client.service.ts',
'webhooks/webhook-delivery.ts',
'weather/weather.service.ts',
'telegram/telegram-client.ts',
'common/password/password-policy.ts',
]),
);
@@ -187,6 +188,7 @@ describe('domain event handler contract', () => {
"import { postWebhook } from '../webhooks/webhook-delivery';",
"import { WebhookDeliveryWorker } from '../webhooks/webhook-delivery.worker';",
"import { WeatherService } from '../weather/weather.service';",
"import { TelegramClient } from '../telegram/telegram-client';",
"const { checkPassword } = await import('../common/password/password-policy');",
])('flags %s', (line) => {
expect(
@@ -0,0 +1,64 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import {
ExtractionPrefillQuery,
MAX_PREFILL_HARVESTS,
} from './extraction-prefill.query';
async function check(query: Record<string, unknown>) {
const dto = plainToInstance(ExtractionPrefillQuery, query);
const errors = await validate(dto, {
whitelist: true,
forbidNonWhitelisted: true,
});
return {
dto,
messages: errors.flatMap((error) => Object.values(error.constraints ?? {})),
};
}
describe('ExtractionPrefillQuery', () => {
it('splits the comma-separated ids and drops empty segments', async () => {
const { dto, messages } = await check({ harvestIds: 'ha,hb,' });
expect(messages).toEqual([]);
expect(dto.harvestIds).toEqual(['ha', 'hb']);
});
it('accepts a repeated query parameter', async () => {
const { dto, messages } = await check({ harvestIds: ['ha', 'hb'] });
expect(messages).toEqual([]);
expect(dto.harvestIds).toEqual(['ha', 'hb']);
});
it('requires at least one id', async () => {
expect((await check({})).messages).not.toEqual([]);
expect((await check({ harvestIds: ',' })).messages).toContain(
'harvestIds darf nicht leer sein',
);
});
it('allows at most 50 ids', async () => {
const ids = Array.from(
{ length: MAX_PREFILL_HARVESTS + 1 },
(_, i) => `h${i}`,
);
expect((await check({ harvestIds: ids.join(',') })).messages).toContain(
`harvestIds darf höchstens ${MAX_PREFILL_HARVESTS} Ernte-IDs enthalten`,
);
expect(
(await check({ harvestIds: ids.slice(0, 50).join(',') })).messages,
).toEqual([]);
});
it('rejects duplicates and ids with odd characters', async () => {
expect((await check({ harvestIds: 'ha,ha' })).messages).toContain(
'harvestIds enthält eine Ernte doppelt',
);
expect((await check({ harvestIds: 'ha,h b' })).messages).toContain(
'harvestIds enthält eine ungültige Ernte-ID',
);
});
});
@@ -0,0 +1,42 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform, TransformFnParams } from 'class-transformer';
import {
ArrayMaxSize,
ArrayMinSize,
ArrayUnique,
IsArray,
IsString,
Matches,
MaxLength,
} from 'class-validator';
export const MAX_PREFILL_HARVESTS = 50;
const MAX_ID_LENGTH = 64;
const IDS_MESSAGE = 'harvestIds enthält eine ungültige Ernte-ID';
// "a,b" and the repeated form harvestIds=a&harvestIds=b both become a list;
// empty segments carry no id and are dropped.
function toIdList({ value }: TransformFnParams): unknown {
const parts: unknown =
typeof value === 'string' ? value.split(',') : (value as unknown);
return Array.isArray(parts) ? parts.filter((part) => part !== '') : parts;
}
export class ExtractionPrefillQuery {
@ApiProperty({
type: String,
description: `Kommagetrennte Ernte-IDs, 1 bis ${MAX_PREFILL_HARVESTS}`,
example: 'harvest-a,harvest-b',
})
@Transform(toIdList)
@IsArray({ message: IDS_MESSAGE })
@ArrayMinSize(1, { message: 'harvestIds darf nicht leer sein' })
@ArrayMaxSize(MAX_PREFILL_HARVESTS, {
message: `harvestIds darf höchstens ${MAX_PREFILL_HARVESTS} Ernte-IDs enthalten`,
})
@ArrayUnique({ message: 'harvestIds enthält eine Ernte doppelt' })
@IsString({ each: true, message: IDS_MESSAGE })
@MaxLength(MAX_ID_LENGTH, { each: true, message: IDS_MESSAGE })
@Matches(/^[A-Za-z0-9_-]+$/, { each: true, message: IDS_MESSAGE })
harvestIds!: string[];
}
@@ -1,9 +1,18 @@
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateExtractionDto } from './dto/create-extraction.dto';
import { ExtractionPrefillQuery } from './dto/extraction-prefill.query';
import { UpdateExtractionDto } from './dto/update-extraction.dto';
import { ExtractionsService } from './extractions.service';
@@ -19,6 +28,15 @@ export class ExtractionsController {
return this.extractionsService.findAll();
}
// Before ':id', so 'prefill' is not read as an id. Needs the create
// permission, not read: it offers the same data the create flow needs, and
// POST answers it anyway (ruling, Task 28 review).
@RequirePermission('extractions:create')
@Get('prefill')
prefill(@Query() query: ExtractionPrefillQuery) {
return this.extractionsService.prefill(query.harvestIds);
}
@RequirePermission('extractions:read')
@Get(':id')
findOne(@Param('id') id: string) {
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { WithdrawalModule } from '../withdrawal/withdrawal.module';
import { ExtractionsController } from './extractions.controller';
import { ExtractionsService } from './extractions.service';
@Module({
imports: [WithdrawalModule],
controllers: [ExtractionsController],
providers: [ExtractionsService],
exports: [ExtractionsService],
@@ -2,32 +2,59 @@ import { BadRequestException, NotFoundException } from '@nestjs/common';
import { ExtractionsService } from './extractions.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { WithdrawalCheckService } from '../withdrawal/withdrawal-check.service';
const entry = (harvestId: string, netWeight: number | null) => ({
harvestId,
harvest: { id: harvestId, netWeight },
});
describe('ExtractionsService', () => {
let service: ExtractionsService;
let prisma: {
extraction: { findUnique: jest.Mock; update: jest.Mock };
extraction: { findUnique: jest.Mock };
harvest: { findMany: jest.Mock };
$transaction: jest.Mock;
};
let auditLog: { record: jest.Mock };
let events: { record: jest.Mock };
let withdrawalCheck: {
conflictsForExtraction: jest.Mock;
conflictsForHarvests: jest.Mock;
warningsFor: jest.Mock;
};
let tx: {
extraction: { create: jest.Mock; update: jest.Mock };
extraction: {
create: jest.Mock;
update: jest.Mock;
findUnique: jest.Mock;
findUniqueOrThrow: jest.Mock;
};
extractionHarvest: { createMany: jest.Mock; deleteMany: jest.Mock };
$queryRaw: jest.Mock;
};
const extraction = { id: 'extraction-1', date: new Date() };
const extraction = {
id: 'extraction-1',
date: new Date(),
amount: null as number | null,
harvests: [entry('harvest-1', 20), entry('harvest-2', 22.5)],
};
beforeEach(() => {
tx = {
extraction: {
create: jest.fn().mockResolvedValue(extraction),
update: jest.fn(),
findUnique: jest.fn().mockResolvedValue(extraction),
findUniqueOrThrow: jest.fn().mockResolvedValue(extraction),
},
extractionHarvest: { createMany: jest.fn(), deleteMany: jest.fn() },
$queryRaw: jest.fn().mockResolvedValue([]),
};
prisma = {
extraction: { findUnique: jest.fn(), update: jest.fn() },
extraction: { findUnique: jest.fn() },
harvest: {
findMany: jest
.fn()
@@ -38,9 +65,17 @@ describe('ExtractionsService', () => {
),
};
auditLog = { record: jest.fn() };
events = { record: jest.fn() };
withdrawalCheck = {
conflictsForExtraction: jest.fn().mockResolvedValue([]),
conflictsForHarvests: jest.fn().mockResolvedValue([]),
warningsFor: jest.fn().mockResolvedValue([]),
};
service = new ExtractionsService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
events as unknown as DomainEventsService,
withdrawalCheck as unknown as WithdrawalCheckService,
);
});
@@ -52,8 +87,6 @@ describe('ExtractionsService', () => {
});
it('create links every harvest to the extraction inside a transaction', async () => {
prisma.extraction.findUnique.mockResolvedValue(extraction);
await service.create(
{ date: '2026-07-05', harvestIds: ['harvest-1', 'harvest-2'] },
'user-1',
@@ -68,6 +101,61 @@ describe('ExtractionsService', () => {
]);
});
it('create writes the audit entry and ExtractionRecorded in the same transaction', async () => {
await service.create(
{ date: '2026-07-05', harvestIds: ['harvest-1', 'harvest-2'] },
'user-1',
);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'Extraction',
entityId: 'extraction-1',
action: 'CREATE',
userId: 'user-1',
}),
tx,
);
expect(events.record).toHaveBeenCalledWith(tx, {
type: 'ExtractionRecorded',
aggregateType: 'Extraction',
aggregateId: 'extraction-1',
payload: { harvestIds: ['harvest-1', 'harvest-2'] },
actorUserId: 'user-1',
});
});
it('create answers the extraction with withdrawal and plausibility warnings, never an error', async () => {
const conflict = { harvestId: 'harvest-1' };
withdrawalCheck.conflictsForExtraction.mockResolvedValue([conflict]);
withdrawalCheck.warningsFor.mockResolvedValue([
{ code: 'WITHDRAWAL_ACTIVE' },
]);
tx.extraction.findUniqueOrThrow.mockResolvedValue({
...extraction,
amount: 50,
});
const result = await service.create(
{
date: '2026-07-05',
harvestIds: ['harvest-1', 'harvest-2'],
amount: 50,
},
'user-1',
);
expect(withdrawalCheck.conflictsForExtraction).toHaveBeenCalledWith(
tx,
'extraction-1',
);
expect(withdrawalCheck.warningsFor).toHaveBeenCalledWith(tx, [conflict]);
expect(result.warnings.map((warning) => warning.code)).toEqual([
'WITHDRAWAL_ACTIVE',
'EXTRACTION_EXCEEDS_HARVEST',
]);
});
it('create rejects an unknown harvest id instead of failing on the DB foreign-key constraint', async () => {
prisma.harvest.findMany.mockResolvedValue([{ id: 'harvest-1' }]);
@@ -82,9 +170,8 @@ describe('ExtractionsService', () => {
it('update writes an audit log entry with before and after data', async () => {
const updated = { ...extraction, notes: 'geändert' };
prisma.extraction.findUnique
.mockResolvedValueOnce(extraction)
.mockResolvedValueOnce(updated);
tx.extraction.findUnique.mockResolvedValueOnce(extraction);
tx.extraction.findUniqueOrThrow.mockResolvedValueOnce(updated);
const result = await service.update(
'extraction-1',
@@ -92,7 +179,7 @@ describe('ExtractionsService', () => {
'user-1',
);
expect(result).toEqual(updated);
expect(result).toEqual({ ...updated, warnings: [] });
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'Extraction',
@@ -101,13 +188,49 @@ describe('ExtractionsService', () => {
beforeData: extraction,
afterData: updated,
}),
tx,
);
expect(tx.extractionHarvest.deleteMany).not.toHaveBeenCalled();
});
it('update replaces the whole harvest set when harvestIds is given', async () => {
prisma.extraction.findUnique.mockResolvedValue(extraction);
it('update locks the row first and takes "before" from the locked row', async () => {
const locked = { ...extraction, notes: 'gesperrt gelesen' };
tx.extraction.findUnique.mockResolvedValue(locked);
await service.update('extraction-1', { notes: 'neu' }, 'user-1');
expect(tx.$queryRaw).toHaveBeenCalledTimes(1);
expect(tx.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan(
tx.extraction.findUnique.mock.invocationCallOrder[0],
);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ beforeData: locked }),
tx,
);
});
it('update throws NotFoundException for an unknown extraction', async () => {
tx.extraction.findUnique.mockResolvedValue(null);
await expect(
service.update('missing', { notes: 'x' }, 'user-1'),
).rejects.toBeInstanceOf(NotFoundException);
expect(events.record).not.toHaveBeenCalled();
});
it('update records ExtractionCorrected with the harvests after the change', async () => {
await service.update('extraction-1', { notes: 'x' }, 'user-1');
expect(events.record).toHaveBeenCalledWith(tx, {
type: 'ExtractionCorrected',
aggregateType: 'Extraction',
aggregateId: 'extraction-1',
payload: { harvestIds: ['harvest-1', 'harvest-2'] },
actorUserId: 'user-1',
});
});
it('update replaces the whole harvest set when harvestIds is given', async () => {
await service.update(
'extraction-1',
{ harvestIds: ['harvest-1', 'harvest-2'] },
@@ -127,7 +250,6 @@ describe('ExtractionsService', () => {
});
it('update rejects an unknown harvest id before touching the database', async () => {
prisma.extraction.findUnique.mockResolvedValue(extraction);
prisma.harvest.findMany.mockResolvedValue([{ id: 'harvest-1' }]);
await expect(
@@ -139,4 +261,100 @@ describe('ExtractionsService', () => {
).rejects.toBeInstanceOf(BadRequestException);
expect(prisma.$transaction).not.toHaveBeenCalled();
});
describe('prefill', () => {
const harvestRow = (
id: string,
netWeight: number | null,
honeyType: string | null,
) => ({
id,
date: new Date('2026-07-05T00:00:00.000Z'),
netWeight,
honeyType,
});
it('sums the net weights and keeps a shared honey type', async () => {
prisma.harvest.findMany.mockResolvedValue([
harvestRow('harvest-1', 20, 'Blütenhonig'),
harvestRow('harvest-2', 22.5, 'Blütenhonig'),
]);
const result = await service.prefill(['harvest-1', 'harvest-2']);
expect(result).toEqual({
amountKg: 42.5,
honeyType: 'Blütenhonig',
sources: [
{ harvestId: 'harvest-1', date: '2026-07-05', netWeight: 20 },
{ harvestId: 'harvest-2', date: '2026-07-05', netWeight: 22.5 },
],
warnings: [],
});
});
it('warns before saving when a chosen harvest lies in a withdrawal window', async () => {
const conflict = { harvestId: 'harvest-1' };
prisma.harvest.findMany.mockResolvedValue([
harvestRow('harvest-1', 20, null),
]);
withdrawalCheck.conflictsForHarvests.mockResolvedValue([conflict]);
withdrawalCheck.warningsFor.mockResolvedValue([
{ code: 'WITHDRAWAL_ACTIVE' },
]);
const result = await service.prefill(['harvest-1']);
expect(withdrawalCheck.conflictsForHarvests).toHaveBeenCalledWith(
prisma,
['harvest-1'],
);
expect(withdrawalCheck.warningsFor).toHaveBeenCalledWith(prisma, [
conflict,
]);
expect(result.warnings.map((warning) => warning.code)).toEqual([
'WITHDRAWAL_ACTIVE',
]);
});
it('offers no honey type when the harvests differ or one has none', async () => {
prisma.harvest.findMany.mockResolvedValue([
harvestRow('harvest-1', 20, 'Blütenhonig'),
harvestRow('harvest-2', 22.5, 'Waldhonig'),
]);
const differing = await service.prefill(['harvest-1', 'harvest-2']);
expect(differing.honeyType).toBeNull();
prisma.harvest.findMany.mockResolvedValue([
harvestRow('harvest-1', 20, 'Blütenhonig'),
harvestRow('harvest-2', 22.5, null),
]);
const partly = await service.prefill(['harvest-1', 'harvest-2']);
expect(partly.honeyType).toBeNull();
});
it('offers no amount when a net weight is missing and hints instead', async () => {
prisma.harvest.findMany.mockResolvedValue([
harvestRow('harvest-1', 20, null),
harvestRow('harvest-2', null, null),
]);
const result = await service.prefill(['harvest-1', 'harvest-2']);
expect(result.amountKg).toBeNull();
expect(result.warnings.map((warning) => warning.code)).toEqual([
'HARVEST_NET_MISSING',
]);
});
it('rejects an unknown harvest id', async () => {
prisma.harvest.findMany.mockResolvedValue([
harvestRow('harvest-1', 1, null),
]);
await expect(
service.prefill(['harvest-1', 'missing-harvest']),
).rejects.toBeInstanceOf(BadRequestException);
});
});
});
@@ -3,8 +3,23 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { ApiProperty } from '@nestjs/swagger';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { berlinDayOf } from '../common/dates';
import {
withWarnings,
type PlausibilityWarning,
} from '../common/warnings/plausibility-warning';
import {
extractionWarnings,
roundKg,
sharedHoneyType,
} from '../honey-batches/honey-plausibility';
import { DbClient } from '../prisma/db-client';
import { lockRowForUpdate } from '../prisma/row-lock';
import { WithdrawalCheckService } from '../withdrawal/withdrawal-check.service';
import { CreateExtractionDto } from './dto/create-extraction.dto';
import { UpdateExtractionDto } from './dto/update-extraction.dto';
@@ -16,11 +31,37 @@ const extractionInclude = {
},
} as const;
const PROPOSAL =
'Vorschlag, keine Messung \u2013 nie stillschweigend \u00fcbernehmen';
export class ExtractionPrefillSource {
@ApiProperty() harvestId!: string;
@ApiProperty({ description: 'Berliner Tag, JJJJ-MM-TT' }) date!: string;
@ApiProperty({ type: Number, nullable: true }) netWeight!: number | null;
}
/** What the extraction form may offer from the chosen harvests (spec 7.6). */
export class ExtractionPrefill {
@ApiProperty({ type: Number, nullable: true, description: PROPOSAL })
amountKg!: number | null;
@ApiProperty({ type: String, nullable: true, description: PROPOSAL })
honeyType!: string | null;
@ApiProperty({ type: [ExtractionPrefillSource] })
sources!: ExtractionPrefillSource[];
@ApiProperty({ type: 'array', items: { type: 'object' } })
warnings!: PlausibilityWarning[];
}
@Injectable()
export class ExtractionsService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly events: DomainEventsService,
private readonly withdrawalCheck: WithdrawalCheckService,
) {}
async findAll() {
@@ -55,9 +96,42 @@ export class ExtractionsService {
}
}
/**
* The extraction as the response body plus its warnings: a harvest inside
* a withdrawal window and the amount against the net weight of the
* harvests. Warnings inform, they never block (Annahme 10). The
* withdrawal warning goes to everyone who records the extraction, whether
* or not they may read treatments (ruling: food safety beats the read
* split); the text names only colony, product and end day.
*/
private async withAllWarnings<
T extends {
id: string;
amount: number | null;
harvests: { harvestId: string; harvest: { netWeight: number | null } }[];
},
>(db: DbClient, extraction: T) {
const conflicts = await this.withdrawalCheck.conflictsForExtraction(
db,
extraction.id,
);
return withWarnings(extraction, [
...(await this.withdrawalCheck.warningsFor(db, conflicts)),
...extractionWarnings(
extraction.amount,
extraction.harvests.map((entry) => ({
id: entry.harvestId,
netWeight: entry.harvest.netWeight,
})),
),
]);
}
async create(dto: CreateExtractionDto, actingUserId: string) {
await this.assertHarvestsExist(dto.harvestIds);
const extraction = await this.prisma.$transaction(async (tx) => {
// Extraction, harvest links, audit entry and ExtractionRecorded commit
// together; the withdrawal notification follows from the event.
return this.prisma.$transaction(async (tx) => {
const created = await tx.extraction.create({
data: {
date: new Date(dto.date),
@@ -75,26 +149,48 @@ export class ExtractionsService {
harvestId,
})),
});
return created;
await this.auditLog.record(
{
entityType: 'Extraction',
entityId: created.id,
action: 'CREATE',
userId: actingUserId,
afterData: created,
},
tx,
);
await this.events.record(tx, {
type: 'ExtractionRecorded',
aggregateType: 'Extraction',
aggregateId: created.id,
payload: { harvestIds: dto.harvestIds },
actorUserId: actingUserId,
});
const extraction = await tx.extraction.findUniqueOrThrow({
where: { id: created.id },
include: extractionInclude,
});
return this.withAllWarnings(tx, extraction);
});
await this.auditLog.record({
entityType: 'Extraction',
entityId: extraction.id,
action: 'CREATE',
userId: actingUserId,
afterData: extraction,
});
return this.findById(extraction.id);
}
async update(id: string, dto: UpdateExtractionDto, actingUserId: string) {
const before = await this.findById(id);
const { harvestIds, ...scalarFields } = dto;
if (harvestIds) {
await this.assertHarvestsExist(harvestIds);
}
await this.prisma.$transaction(async (tx) => {
return this.prisma.$transaction(async (tx) => {
// "Before" is read under the row lock, not before the transaction: a
// concurrent edit cannot slip in between.
await lockRowForUpdate(tx, 'Extraction', id);
const before = await tx.extraction.findUnique({
where: { id },
include: extractionInclude,
});
if (!before) {
throw new NotFoundException('Extraction not found');
}
await tx.extraction.update({
where: { id },
data: {
@@ -113,17 +209,85 @@ export class ExtractionsService {
})),
});
}
const extraction = await tx.extraction.findUniqueOrThrow({
where: { id },
include: extractionInclude,
});
await this.auditLog.record(
{
entityType: 'Extraction',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: extraction,
},
tx,
);
await this.events.record(tx, {
type: 'ExtractionCorrected',
aggregateType: 'Extraction',
aggregateId: id,
payload: {
harvestIds: extraction.harvests.map((entry) => entry.harvestId),
},
actorUserId: actingUserId,
});
return this.withAllWarnings(tx, extraction);
});
}
const extraction = await this.findById(id);
await this.auditLog.record({
entityType: 'Extraction',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: extraction,
/**
* What the form may offer for the chosen harvests (spec 7.6). The amount is
* a measurement and so only a proposal: the sum of the net weights, and
* only when every one is known. Read only, no lock.
*/
async prefill(harvestIds: readonly string[]): Promise<ExtractionPrefill> {
const ids = [...new Set(harvestIds)];
const harvests = await this.prisma.harvest.findMany({
where: { id: { in: ids } },
select: { id: true, date: true, netWeight: true, honeyType: true },
orderBy: [{ date: 'asc' }, { id: 'asc' }],
});
return extraction;
const found = new Set(harvests.map((harvest) => harvest.id));
const missing = ids.filter((id) => !found.has(id));
if (missing.length > 0) {
throw new BadRequestException(
`Unknown harvest id(s): ${missing.join(', ')}`,
);
}
const conflicts = await this.withdrawalCheck.conflictsForHarvests(
this.prisma,
ids,
);
const allNetKnown = harvests.every((harvest) => harvest.netWeight !== null);
return {
amountKg:
allNetKnown && harvests.length > 0
? roundKg(
harvests.reduce(
(sum, harvest) => sum + (harvest.netWeight ?? 0),
0,
),
)
: null,
honeyType: sharedHoneyType(harvests),
sources: harvests.map((harvest) => ({
harvestId: harvest.id,
date: berlinDayOf(harvest.date),
netWeight: harvest.netWeight,
})),
// The same warnings the save will answer, so the form can warn first.
warnings: [
...(await this.withdrawalCheck.warningsFor(this.prisma, conflicts)),
...extractionWarnings(
null,
harvests.map((harvest) => ({
id: harvest.id,
netWeight: harvest.netWeight,
})),
),
],
};
}
}
@@ -0,0 +1,12 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, Matches, MaxLength } from 'class-validator';
const MAX_ID_LENGTH = 64;
export class HoneyBatchPrefillQuery {
@ApiProperty({ description: 'Die Schleuderung, aus der die Charge entsteht' })
@IsString()
@MaxLength(MAX_ID_LENGTH)
@Matches(/^[A-Za-z0-9_-]+$/)
extractionId!: string;
}
@@ -0,0 +1,18 @@
/**
* The honey batch as the API and the webhook `batch.created` carry it. One
* definition: the service answers with it and the webhook handler loads the
* batch with it, so both payloads keep the same shape.
*/
export const honeyBatchInclude = {
measurements: true,
storageContainer: true,
extraction: {
include: {
harvests: {
include: {
harvest: { include: { colonies: { include: { colony: true } } } },
},
},
},
},
} as const;
@@ -20,6 +20,7 @@ import { CreateHoneyMeasurementDto } from './dto/create-honey-measurement.dto';
import { UpdateHoneyMeasurementDto } from './dto/update-honey-measurement.dto';
import { RelocateHoneyBatchDto } from './dto/relocate-honey-batch.dto';
import { SetHoneyBatchContainerAllocationDto } from './dto/set-honey-batch-container-allocation.dto';
import { HoneyBatchPrefillQuery } from './dto/honey-batch-prefill.query';
import { HoneyBatchesService } from './honey-batches.service';
@ApiTags('honey-batches')
@@ -34,6 +35,15 @@ export class HoneyBatchesController {
return this.honeyBatchesService.findAll({ status });
}
// Before ':id', so 'prefill' is not read as an id. Needs the create
// permission, not read: it offers the same data the create flow needs, and
// POST answers it anyway (ruling, Task 28 review).
@RequirePermission('honey-batches:create')
@Get('prefill')
prefill(@Query() query: HoneyBatchPrefillQuery) {
return this.honeyBatchesService.prefill(query.extractionId);
}
@RequirePermission('honey-batches:read')
@Get(':id')
findOne(@Param('id') id: string) {
@@ -2,7 +2,7 @@ import { BadRequestException, NotFoundException } from '@nestjs/common';
import { HoneyBatchesService } from './honey-batches.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { AutomationService } from '../automation/automation.service';
describe('HoneyBatchesService', () => {
@@ -11,6 +11,7 @@ describe('HoneyBatchesService', () => {
honeyBatch: {
findMany: jest.Mock;
findUnique: jest.Mock;
findUniqueOrThrow: jest.Mock;
create: jest.Mock;
update: jest.Mock;
};
@@ -19,6 +20,7 @@ describe('HoneyBatchesService', () => {
findUnique: jest.Mock;
update: jest.Mock;
};
extraction: { findUnique: jest.Mock };
storageContainer: { findUnique: jest.Mock };
honeyBatchRelocation: { create: jest.Mock; findMany: jest.Mock };
honeyBatchContainerAllocation: {
@@ -28,8 +30,11 @@ describe('HoneyBatchesService', () => {
delete: jest.Mock;
};
$transaction: jest.Mock;
$queryRaw: jest.Mock;
};
let auditLog: { record: jest.Mock };
let events: { record: jest.Mock };
let automation: { generateNumber: jest.Mock };
const extraction = {
id: 'extraction-1',
@@ -49,6 +54,7 @@ describe('HoneyBatchesService', () => {
const honeyBatch = {
id: 'batch-1',
batchNumber: 'HC-2026-001',
extractionId: 'extraction-1',
honeyType: 'Blütenhonig',
amount: 20,
status: 'RAW',
@@ -61,6 +67,7 @@ describe('HoneyBatchesService', () => {
honeyBatch: {
findMany: jest.fn(),
findUnique: jest.fn(),
findUniqueOrThrow: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
@@ -69,6 +76,7 @@ describe('HoneyBatchesService', () => {
findUnique: jest.fn(),
update: jest.fn(),
},
extraction: { findUnique: jest.fn() },
storageContainer: { findUnique: jest.fn() },
honeyBatchRelocation: { create: jest.fn(), findMany: jest.fn() },
honeyBatchContainerAllocation: {
@@ -77,16 +85,21 @@ describe('HoneyBatchesService', () => {
upsert: jest.fn(),
delete: jest.fn(),
},
$transaction: jest.fn(),
$transaction: jest.fn(
async (
callback: (tx: typeof prisma) => Promise<unknown>,
): Promise<unknown> => callback(prisma),
),
$queryRaw: jest.fn().mockResolvedValue([]),
};
auditLog = { record: jest.fn() };
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
const automation = { generateNumber: jest.fn().mockResolvedValue(null) };
events = { record: jest.fn() };
automation = { generateNumber: jest.fn().mockResolvedValue(null) };
service = new HoneyBatchesService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
webhooks as unknown as WebhooksService,
automation as unknown as AutomationService,
events as unknown as DomainEventsService,
);
});
@@ -97,20 +110,77 @@ describe('HoneyBatchesService', () => {
);
});
it('create stores the batch and returns it with relations', async () => {
prisma.honeyBatch.create.mockResolvedValue(honeyBatch);
prisma.honeyBatch.findUnique.mockResolvedValue(honeyBatch);
describe('create', () => {
const dto = { batchNumber: 'HC-2026-001', extractionId: 'extraction-1' };
await service.create(
{ batchNumber: 'HC-2026-001', extractionId: 'extraction-1' },
'user-1',
);
beforeEach(() => {
prisma.honeyBatch.create.mockResolvedValue(honeyBatch);
prisma.honeyBatch.findUniqueOrThrow.mockResolvedValue(honeyBatch);
prisma.honeyBatch.findMany.mockResolvedValue([]);
prisma.extraction.findUnique.mockResolvedValue({
id: 'extraction-1',
amount: null,
});
});
const [[createArg]] = prisma.honeyBatch.create.mock.calls as [
[{ data: { batchNumber: string; extractionId: string } }],
];
expect(createArg.data.batchNumber).toBe('HC-2026-001');
expect(createArg.data.extractionId).toBe('extraction-1');
it('stores the batch and returns it with relations and warnings', async () => {
const result = await service.create(dto, 'user-1');
const [[createArg]] = prisma.honeyBatch.create.mock.calls as [
[{ data: { batchNumber: string; extractionId: string } }],
];
expect(createArg.data.batchNumber).toBe('HC-2026-001');
expect(createArg.data.extractionId).toBe('extraction-1');
expect(result).toEqual({ ...honeyBatch, warnings: [] });
});
it('writes the audit entry and HoneyBatchRecorded in the same transaction', async () => {
await service.create(dto, 'user-1');
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'HoneyBatch',
action: 'CREATE',
userId: 'user-1',
}),
prisma,
);
expect(events.record).toHaveBeenCalledWith(prisma, {
type: 'HoneyBatchRecorded',
aggregateType: 'HoneyBatch',
aggregateId: 'batch-1',
payload: { extractionId: 'extraction-1' },
actorUserId: 'user-1',
});
});
it('locks the extraction row before it writes and counts the batches', async () => {
await service.create(dto, 'user-1');
expect(prisma.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan(
prisma.honeyBatch.create.mock.invocationCallOrder[0],
);
expect(prisma.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan(
prisma.honeyBatch.findMany.mock.invocationCallOrder[0],
);
});
it('warns when the batches exceed the extraction, but still answers', async () => {
prisma.extraction.findUnique.mockResolvedValue({
id: 'extraction-1',
amount: 10,
});
prisma.honeyBatch.findMany.mockResolvedValue([
{ id: 'batch-0', initialAmount: 6, amount: 6 },
{ id: 'batch-1', initialAmount: null, amount: 8 },
]);
const result = await service.create(dto, 'user-1');
expect(result.warnings.map((warning) => warning.code)).toEqual([
'BATCHES_EXCEED_EXTRACTION',
]);
});
});
it('traceability flattens the extraction, harvest, and colony chain', async () => {
@@ -365,11 +435,68 @@ describe('HoneyBatchesService', () => {
describe('update', () => {
beforeEach(() => {
prisma.$transaction.mockImplementation(
async (
callback: (tx: typeof prisma) => Promise<unknown>,
): Promise<unknown> => callback(prisma),
prisma.honeyBatch.findUniqueOrThrow.mockResolvedValue(honeyBatch);
prisma.honeyBatch.findMany.mockResolvedValue([]);
prisma.extraction.findUnique.mockResolvedValue({
id: 'extraction-1',
amount: null,
});
});
it('locks the row first, reads "before" from the locked row and records HoneyBatchCorrected', async () => {
prisma.honeyBatch.findUnique.mockResolvedValue(honeyBatch);
const result = await service.update(
'batch-1',
{ honeyType: 'Waldhonig' },
'user-1',
);
// "Before" is the read after the last lock.
const reads = prisma.honeyBatch.findUnique.mock.invocationCallOrder;
expect(prisma.$queryRaw.mock.invocationCallOrder[1]).toBeLessThan(
reads[reads.length - 1],
);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
action: 'UPDATE',
beforeData: honeyBatch,
}),
prisma,
);
expect(events.record).toHaveBeenCalledWith(prisma, {
type: 'HoneyBatchCorrected',
aggregateType: 'HoneyBatch',
aggregateId: 'batch-1',
payload: { extractionId: 'extraction-1' },
actorUserId: 'user-1',
});
expect(result.warnings).toEqual([]);
});
it('locks the extraction before the batch and before counting the batches', async () => {
prisma.honeyBatch.findUnique.mockResolvedValue(honeyBatch);
await service.update('batch-1', { honeyType: 'Waldhonig' }, 'user-1');
// Extraction lock, then batch lock: two statements, both before the
// update and the count of the batches.
expect(prisma.$queryRaw).toHaveBeenCalledTimes(2);
expect(prisma.$queryRaw.mock.invocationCallOrder[1]).toBeLessThan(
prisma.honeyBatch.update.mock.invocationCallOrder[0],
);
expect(prisma.$queryRaw.mock.invocationCallOrder[1]).toBeLessThan(
prisma.honeyBatch.findMany.mock.invocationCallOrder[0],
);
});
it('throws NotFoundException for an unknown batch', async () => {
prisma.honeyBatch.findUnique.mockResolvedValue(null);
await expect(
service.update('missing', { honeyType: 'x' }, 'user-1'),
).rejects.toBeInstanceOf(NotFoundException);
expect(events.record).not.toHaveBeenCalled();
});
it('records a relocation when an edit corrects the storage container', async () => {
@@ -418,6 +545,7 @@ describe('HoneyBatchesService', () => {
entityId: 'batch-1',
action: 'UPDATE',
}),
prisma,
);
});
@@ -433,6 +561,72 @@ describe('HoneyBatchesService', () => {
});
});
describe('prefill', () => {
it('proposes the rest of the extraction with its honey type and water content', async () => {
prisma.extraction.findUnique.mockResolvedValue({
id: 'extraction-1',
amount: 42.5,
waterContent: 17.2,
harvests: [
{ harvest: { honeyType: 'Bl\u00fctenhonig' } },
{ harvest: { honeyType: 'Bl\u00fctenhonig' } },
],
});
prisma.honeyBatch.findMany.mockResolvedValue([
{ id: 'batch-0', initialAmount: 20, amount: 20 },
{ id: 'batch-9', initialAmount: null, amount: null },
]);
const result = await service.prefill('extraction-1');
expect(result).toEqual({
remainingKg: 22.5,
honeyType: 'Bl\u00fctenhonig',
waterContent: 17.2,
warnings: [],
});
});
it('proposes no amount when the extraction has none and never a negative one', async () => {
prisma.extraction.findUnique.mockResolvedValue({
id: 'extraction-1',
amount: null,
waterContent: null,
harvests: [{ harvest: { honeyType: null } }],
});
prisma.honeyBatch.findMany.mockResolvedValue([]);
const none = await service.prefill('extraction-1');
expect(none).toMatchObject({
remainingKg: null,
honeyType: null,
waterContent: null,
});
prisma.extraction.findUnique.mockResolvedValue({
id: 'extraction-1',
amount: 10,
waterContent: null,
harvests: [],
});
prisma.honeyBatch.findMany.mockResolvedValue([
{ id: 'batch-0', initialAmount: 12, amount: 12 },
]);
const over = await service.prefill('extraction-1');
expect(over.remainingKg).toBe(0);
expect(over.warnings.map((warning) => warning.code)).toEqual([
'BATCHES_EXCEED_EXTRACTION',
]);
});
it('throws NotFoundException for an unknown extraction', async () => {
prisma.extraction.findUnique.mockResolvedValue(null);
await expect(service.prefill('missing')).rejects.toBeInstanceOf(
NotFoundException,
);
});
});
it('updateMeasurement corrects a reading and logs before/after', async () => {
const before = {
id: 'measurement-1',
@@ -1,12 +1,20 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { ApiProperty } from '@nestjs/swagger';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { AutomationService } from '../automation/automation.service';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { DbClient } from '../prisma/db-client';
import { lockRowForUpdate, lockRowsForUpdate } from '../prisma/row-lock';
import {
withWarnings,
type PlausibilityWarning,
} from '../common/warnings/plausibility-warning';
import { HoneyBatchStatus } from '../../generated/prisma';
import { CreateHoneyBatchDto } from './dto/create-honey-batch.dto';
import { UpdateHoneyBatchDto } from './dto/update-honey-batch.dto';
@@ -14,32 +22,43 @@ import { CreateHoneyMeasurementDto } from './dto/create-honey-measurement.dto';
import { UpdateHoneyMeasurementDto } from './dto/update-honey-measurement.dto';
import { RelocateHoneyBatchDto } from './dto/relocate-honey-batch.dto';
import { SetHoneyBatchContainerAllocationDto } from './dto/set-honey-batch-container-allocation.dto';
const honeyBatchInclude = {
measurements: true,
storageContainer: true,
extraction: {
include: {
harvests: {
include: {
harvest: { include: { colonies: { include: { colony: true } } } },
},
},
},
},
} as const;
import { honeyBatchInclude } from './honey-batch-include';
import {
batchWarnings,
roundKg,
sharedHoneyType,
sumInitialAmounts,
} from './honey-plausibility';
export interface HoneyBatchFilter {
status?: HoneyBatchStatus;
}
const PROPOSAL =
'Vorschlag, keine Messung \u2013 nie stillschweigend \u00fcbernehmen';
/** What the honey batch form may offer for an extraction (spec 7.6). */
export class HoneyBatchPrefill {
@ApiProperty({ type: Number, nullable: true, description: PROPOSAL })
remainingKg!: number | null;
@ApiProperty({ type: String, nullable: true, description: PROPOSAL })
honeyType!: string | null;
@ApiProperty({ type: Number, nullable: true, description: PROPOSAL })
waterContent!: number | null;
@ApiProperty({ type: 'array', items: { type: 'object' } })
warnings!: PlausibilityWarning[];
}
@Injectable()
export class HoneyBatchesService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly webhooks: WebhooksService,
private readonly automation: AutomationService,
private readonly events: DomainEventsService,
) {}
async findAll(filter: HoneyBatchFilter = {}) {
@@ -61,6 +80,38 @@ export class HoneyBatchesService {
return honeyBatch;
}
/**
* The batches of one extraction with the amount they started with. A batch
* the ledger does not know yet (no initialAmount) counts with its current
* amount: it is the best known start, and the rest can only be lower.
*/
private async batchesOf(db: DbClient, extractionId: string) {
const batches = await db.honeyBatch.findMany({
where: { extractionId },
select: { id: true, initialAmount: true, amount: true },
orderBy: { id: 'asc' },
});
return batches.map((batch) => ({
id: batch.id,
initialAmount: batch.initialAmount ?? batch.amount,
}));
}
/** The batches of the extraction against its amount. Never blocks. */
private async warningsFor(
db: DbClient,
extractionId: string,
): Promise<PlausibilityWarning[]> {
const extraction = await db.extraction.findUnique({
where: { id: extractionId },
select: { id: true, amount: true },
});
if (extraction === null) {
return [];
}
return batchWarnings(extraction, await this.batchesOf(db, extractionId));
}
async create(dto: CreateHoneyBatchDto, actingUserId: string) {
const batchNumber =
dto.batchNumber ??
@@ -70,34 +121,83 @@ export class HoneyBatchesService {
'Chargennummer erforderlich (oder Automatisierung "Chargennummer-Format" aktivieren)',
);
}
const honeyBatch = await this.prisma.honeyBatch.create({
data: { ...dto, batchNumber },
// Batch, audit entry and HoneyBatchRecorded commit together; the webhook
// batch.created follows from the event.
return this.prisma.$transaction(async (tx) => {
// The extraction first: batches of one extraction are counted against
// its amount, so two of them must not be written side by side.
await lockRowForUpdate(tx, 'Extraction', dto.extractionId);
const honeyBatch = await tx.honeyBatch.create({
data: { ...dto, batchNumber },
});
await this.auditLog.record(
{
entityType: 'HoneyBatch',
entityId: honeyBatch.id,
action: 'CREATE',
userId: actingUserId,
afterData: honeyBatch,
},
tx,
);
await this.events.record(tx, {
type: 'HoneyBatchRecorded',
aggregateType: 'HoneyBatch',
aggregateId: honeyBatch.id,
payload: { extractionId: honeyBatch.extractionId },
actorUserId: actingUserId,
});
const created = await tx.honeyBatch.findUniqueOrThrow({
where: { id: honeyBatch.id },
include: honeyBatchInclude,
});
return withWarnings(
created,
await this.warningsFor(tx, created.extractionId),
);
});
await this.auditLog.record({
entityType: 'HoneyBatch',
entityId: honeyBatch.id,
action: 'CREATE',
userId: actingUserId,
afterData: honeyBatch,
});
const created = await this.findById(honeyBatch.id);
await this.webhooks.dispatch('batch.created', created);
return created;
}
async update(id: string, dto: UpdateHoneyBatchDto, actingUserId: string) {
const before = await this.findById(id);
// A relocation is a move into a container. null only removes the
// assignment - a batch may stand in no container, as it may be created
// without one - so it is recorded by the audit entry below, not as a
// relocation (which always needs a target container).
const movedToContainerId =
typeof dto.storageContainerId === 'string' &&
dto.storageContainerId !== before.storageContainerId
? dto.storageContainerId
: null;
return this.prisma.$transaction(async (tx) => {
// Lock order Extraction -> HoneyBatch. The extraction ids (current and
// a new one) come from an unlocked peek; the locked re-read below
// proves the peek still holds.
const peek = await tx.honeyBatch.findUnique({
where: { id },
select: { extractionId: true },
});
if (!peek) {
throw new NotFoundException('Honey batch not found');
}
await lockRowsForUpdate(tx, 'Extraction', [
...new Set([peek.extractionId, dto.extractionId ?? peek.extractionId]),
]);
// "Before" is read under the row lock, not before the transaction: a
// concurrent edit cannot slip in between.
await lockRowForUpdate(tx, 'HoneyBatch', id);
const before = await tx.honeyBatch.findUnique({
where: { id },
include: honeyBatchInclude,
});
if (!before) {
throw new NotFoundException('Honey batch not found');
}
if (before.extractionId !== peek.extractionId) {
throw new ConflictException(
'Die Honigcharge wurde gerade ge\u00e4ndert. Bitte erneut versuchen.',
);
}
// A relocation is a move into a container. null only removes the
// assignment - a batch may stand in no container, as it may be created
// without one - so it is recorded by the audit entry below, not as a
// relocation (which always needs a target container).
const movedToContainerId =
typeof dto.storageContainerId === 'string' &&
dto.storageContainerId !== before.storageContainerId
? dto.storageContainerId
: null;
await this.prisma.$transaction(async (tx) => {
await tx.honeyBatch.update({
where: { id },
data: { ...dto },
@@ -116,17 +216,69 @@ export class HoneyBatchesService {
},
});
}
const updated = await tx.honeyBatch.findUniqueOrThrow({
where: { id },
include: honeyBatchInclude,
});
await this.auditLog.record(
{
entityType: 'HoneyBatch',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: updated,
},
tx,
);
await this.events.record(tx, {
type: 'HoneyBatchCorrected',
aggregateType: 'HoneyBatch',
aggregateId: id,
payload: { extractionId: updated.extractionId },
actorUserId: actingUserId,
});
return withWarnings(
updated,
await this.warningsFor(tx, updated.extractionId),
);
});
const updated = await this.findById(id);
await this.auditLog.record({
entityType: 'HoneyBatch',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: updated,
}
/**
* What the form may offer for a new batch of the extraction (spec 7.6): the
* rest of its amount after the existing batches, and its honey type (the
* one its harvests share) and water content. Proposals, never filled in
* silently. Read only, no lock.
*/
async prefill(extractionId: string): Promise<HoneyBatchPrefill> {
const extraction = await this.prisma.extraction.findUnique({
where: { id: extractionId },
select: {
id: true,
amount: true,
waterContent: true,
harvests: { select: { harvest: { select: { honeyType: true } } } },
},
});
return updated;
if (!extraction) {
throw new NotFoundException('Extraction not found');
}
const batches = await this.batchesOf(this.prisma, extractionId);
return {
remainingKg:
extraction.amount === null
? null
: Math.max(
0,
roundKg(extraction.amount - sumInitialAmounts(batches)),
),
honeyType: sharedHoneyType(
extraction.harvests.map((entry) => entry.harvest),
),
waterContent: extraction.waterContent,
warnings: batchWarnings(extraction, batches),
};
}
async updateMeasurement(
@@ -0,0 +1,128 @@
import {
batchWarnings,
extractionWarnings,
sharedHoneyType,
} from './honey-plausibility';
describe('extractionWarnings', () => {
it('warns when the extraction exceeds the known net harvest', () => {
const warnings = extractionWarnings(50, [
{ id: 'h1', netWeight: 20 },
{ id: 'h2', netWeight: 22.5 },
]);
expect(warnings).toEqual([
{
code: 'EXTRACTION_EXCEEDS_HARVEST',
severity: 'WARNUNG',
message:
'Die Schleudermenge (50 kg) liegt über dem Netto der Ernten (42,5 kg).',
refs: [
{ type: 'Harvest', id: 'h1' },
{ type: 'Harvest', id: 'h2' },
],
},
]);
});
it('stays silent when the extraction does not exceed the net harvest', () => {
expect(extractionWarnings(42.5, [{ id: 'h1', netWeight: 42.5 }])).toEqual(
[],
);
expect(extractionWarnings(10, [{ id: 'h1', netWeight: 42.5 }])).toEqual([]);
});
it('hints when a net weight is missing and does not compare then', () => {
const warnings = extractionWarnings(500, [
{ id: 'h1', netWeight: 20 },
{ id: 'h2', netWeight: null },
]);
expect(warnings).toEqual([
{
code: 'HARVEST_NET_MISSING',
severity: 'HINWEIS',
message:
'Bei einer Ernte fehlt das Nettogewicht. Die Menge wurde nicht geprüft.',
refs: [{ type: 'Harvest', id: 'h2' }],
},
]);
});
it('says how many harvests lack the net weight', () => {
const [warning] = extractionWarnings(null, [
{ id: 'h1', netWeight: null },
{ id: 'h2', netWeight: null },
]);
expect(warning.message).toBe(
'Bei 2 Ernten fehlt das Nettogewicht. Die Menge wurde nicht geprüft.',
);
});
it('has nothing to compare without an amount or without harvests', () => {
expect(extractionWarnings(null, [{ id: 'h1', netWeight: 5 }])).toEqual([]);
expect(extractionWarnings(5, [])).toEqual([]);
});
});
describe('batchWarnings', () => {
it('warns when the initial amounts of the batches exceed the extraction', () => {
const warnings = batchWarnings({ id: 'e1', amount: 40 }, [
{ id: 'b1', initialAmount: 25 },
{ id: 'b2', initialAmount: 20.5 },
]);
expect(warnings).toEqual([
{
code: 'BATCHES_EXCEED_EXTRACTION',
severity: 'WARNUNG',
message:
'Die Chargen (45,5 kg) übersteigen die Schleudermenge (40 kg).',
refs: [
{ type: 'Extraction', id: 'e1' },
{ type: 'HoneyBatch', id: 'b1' },
{ type: 'HoneyBatch', id: 'b2' },
],
},
]);
});
it('does not count batches without an initial amount and ignores an unknown extraction amount', () => {
expect(
batchWarnings({ id: 'e1', amount: 40 }, [
{ id: 'b1', initialAmount: 40 },
{ id: 'b2', initialAmount: null },
]),
).toEqual([]);
expect(
batchWarnings({ id: 'e1', amount: null }, [
{ id: 'b1', initialAmount: 400 },
]),
).toEqual([]);
});
it('tolerates float noise at the limit', () => {
expect(
batchWarnings({ id: 'e1', amount: 0.3 }, [
{ id: 'b1', initialAmount: 0.1 },
{ id: 'b2', initialAmount: 0.2 },
]),
).toEqual([]);
});
});
describe('sharedHoneyType', () => {
it('keeps the type all harvests share and nothing otherwise', () => {
expect(
sharedHoneyType([{ honeyType: 'Linde' }, { honeyType: 'Linde' }]),
).toBe('Linde');
expect(
sharedHoneyType([{ honeyType: 'Linde' }, { honeyType: 'Raps' }]),
).toBeNull();
expect(
sharedHoneyType([{ honeyType: 'Linde' }, { honeyType: null }]),
).toBeNull();
expect(sharedHoneyType([])).toBeNull();
});
});
@@ -0,0 +1,112 @@
import { formatNumberDe } from '../suggestions/texts/format-number';
import {
WARNING_SEVERITY,
type PlausibilityWarning,
} from '../common/warnings/plausibility-warning';
/** Half a gram in kg: the tolerance of the honey ledger (spec 4.6). */
const TOLERANCE_KG = 0.0005;
const MAX_FRACTION_DIGITS = 3;
const kgText = (value: number): string =>
`${formatNumberDe(value, MAX_FRACTION_DIGITS)} kg`;
/** The honey type all harvests share; null when they differ or one has none. */
export function sharedHoneyType(
harvests: readonly { honeyType: string | null }[],
): string | null {
const [first] = harvests;
if (first === undefined || first.honeyType === null) {
return null;
}
return harvests.every((harvest) => harvest.honeyType === first.honeyType)
? first.honeyType
: null;
}
/** kg on a gram grid, so sums of floats compare and print cleanly. */
export function roundKg(value: number): number {
return Math.round(value * 1000) / 1000;
}
/**
* The extraction (Schleuderung) against the net weight of its harvests
* (spec 7.6). The amount is compared only when every net weight is known;
* a missing one is a hint instead. Never blocks.
*/
export function extractionWarnings(
amountKg: number | null,
harvests: readonly { id: string; netWeight: number | null }[],
): PlausibilityWarning[] {
const missing = harvests.filter((harvest) => harvest.netWeight === null);
if (missing.length > 0) {
return [
{
code: 'HARVEST_NET_MISSING',
severity: WARNING_SEVERITY.HARVEST_NET_MISSING,
message:
missing.length === 1
? 'Bei einer Ernte fehlt das Nettogewicht. Die Menge wurde nicht geprüft.'
: `Bei ${missing.length} Ernten fehlt das Nettogewicht. Die Menge wurde nicht geprüft.`,
refs: missing.map((harvest) => ({ type: 'Harvest', id: harvest.id })),
},
];
}
if (amountKg === null || harvests.length === 0) {
return [];
}
const netKg = roundKg(
harvests.reduce((sum, harvest) => sum + (harvest.netWeight ?? 0), 0),
);
if (amountKg <= netKg + TOLERANCE_KG) {
return [];
}
return [
{
code: 'EXTRACTION_EXCEEDS_HARVEST',
severity: WARNING_SEVERITY.EXTRACTION_EXCEEDS_HARVEST,
message: `Die Schleudermenge (${kgText(amountKg)}) liegt über dem Netto der Ernten (${kgText(netKg)}).`,
refs: harvests.map((harvest) => ({ type: 'Harvest', id: harvest.id })),
},
];
}
/** Σ initial amount of the batches; a batch without one does not count. */
export function sumInitialAmounts(
batches: readonly { initialAmount: number | null }[],
): number {
return roundKg(
batches.reduce((sum, batch) => sum + (batch.initialAmount ?? 0), 0),
);
}
/**
* The honey batches of one extraction against its amount (spec 7.6): their
* initial amounts together must not exceed what was extracted. Nothing to
* compare without an extraction amount. Never blocks.
*/
export function batchWarnings(
extraction: { id: string; amount: number | null },
batches: readonly { id: string; initialAmount: number | null }[],
): PlausibilityWarning[] {
if (extraction.amount === null) {
return [];
}
const total = sumInitialAmounts(batches);
if (total <= extraction.amount + TOLERANCE_KG) {
return [];
}
return [
{
code: 'BATCHES_EXCEED_EXTRACTION',
severity: WARNING_SEVERITY.BATCHES_EXCEED_EXTRACTION,
message: `Die Chargen (${kgText(total)}) übersteigen die Schleudermenge (${kgText(extraction.amount)}).`,
refs: [
{ type: 'Extraction', id: extraction.id },
...batches
.filter((batch) => batch.initialAmount !== null)
.map((batch) => ({ type: 'HoneyBatch', id: batch.id })),
],
},
];
}
@@ -1,6 +1,7 @@
import { IdempotencyCleanupService } from '../common/idempotency/idempotency-cleanup.service';
import { PrismaService } from '../prisma/prisma.service';
import { JobRunService } from '../system/job-run.service';
import { WEATHER_RETENTION_YEARS } from '../weather/history/weather-history.config';
import {
CleanupJob,
DOMAIN_EVENT_RETENTION_DAYS,
@@ -59,6 +60,16 @@ describe('CleanupJob', () => {
expect(JOB_RUN_STALE_HOURS).toBe(6);
});
it('cleanup deletes weather days before January 1st four years back', async () => {
await job.run();
const { sql, values } = statementOn('ApiaryWeatherDay');
expect(sql).toContain('"day" <');
expect(sql).toContain('Europe/Berlin');
expect(sql).toContain('make_date');
expect(values).toEqual([WEATHER_RETENTION_YEARS]);
});
it('cleanup removes only expired rows and reports counts', async () => {
const result = await job.run();
@@ -76,6 +87,7 @@ describe('CleanupJob', () => {
'webhookDeliveries.failed': 14,
'jobRuns.aborted': 1,
'jobRuns.deleted': 15,
weatherDays: 16,
});
});
+8
View File
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { IdempotencyCleanupService } from '../common/idempotency/idempotency-cleanup.service';
import { PrismaService } from '../prisma/prisma.service';
import { JobRunService } from '../system/job-run.service';
import { WEATHER_RETENTION_YEARS } from '../weather/history/weather-history.config';
import { Job, JobResult } from './job';
import { JobName } from './job-names';
@@ -63,6 +64,12 @@ export class CleanupJob implements Job {
const jobRunsDeleted = await this.prisma.$executeRaw`
DELETE FROM "JobRun"
WHERE "startedAt" < now() - (${JOB_RUN_RETENTION_DAYS} * interval '1 day')`;
// Daily means before January 1st of (year - 4): the season needs the
// current year only, the rest is a short history.
const weatherDays = await this.prisma.$executeRaw`
DELETE FROM "ApiaryWeatherDay"
WHERE "day" < make_date(
extract(year FROM now() AT TIME ZONE 'Europe/Berlin')::int - ${WEATHER_RETENTION_YEARS}, 1, 1)`;
return {
idempotencyRecords,
consumedTokens,
@@ -73,6 +80,7 @@ export class CleanupJob implements Job {
'webhookDeliveries.failed': webhookDeliveriesFailed,
'jobRuns.aborted': jobRunsAborted,
'jobRuns.deleted': jobRunsDeleted,
weatherDays,
};
}
}
@@ -3,6 +3,7 @@ import { JobPartialFailure } from '../system/job-run.service';
import { DailyEvaluationJob } from './daily-evaluation.job';
import type { AutopilotStep } from '../autopilot/autopilot.step';
import type { SuggestionsExpiryStep } from '../autopilot/suggestions-expiry.step';
import type { PhenologyStep } from '../phenology/phenology.step';
import type { DigestStep } from '../reminders/digest.step';
import type { TimeEventsStep } from './steps/time-events.step';
@@ -14,11 +15,13 @@ describe('DailyEvaluationJob', () => {
VarroaMeasurementDue: 0,
InspectionOverdue: 0,
};
const PHENOLOGY_COUNTS = { phenologyApiaries: 2, seasonChanges: 1 };
const AUTOPILOT_COUNTS = {
autopilotColonies: 4,
suggestionsOpened: 2,
suggestionsResolved: 1,
};
let phenology: { name: 'phenology'; run: jest.Mock };
let timeEvents: { name: 'time-events'; run: jest.Mock };
let autopilot: { name: 'autopilot'; run: jest.Mock };
let expiry: { name: 'suggestions-expiry'; run: jest.Mock };
@@ -27,6 +30,8 @@ describe('DailyEvaluationJob', () => {
beforeEach(() => {
jest.useFakeTimers({ now: new Date('2026-06-10T02:30:00.000Z') });
phenology = { name: 'phenology', run: jest.fn() };
phenology.run.mockResolvedValue(PHENOLOGY_COUNTS);
timeEvents = { name: 'time-events', run: jest.fn() };
timeEvents.run.mockResolvedValue(COUNTS);
autopilot = { name: 'autopilot', run: jest.fn() };
@@ -36,6 +41,7 @@ describe('DailyEvaluationJob', () => {
digest = { name: 'digest', run: jest.fn() };
digest.run.mockResolvedValue({ digestsWritten: 5 });
job = new DailyEvaluationJob(
phenology as unknown as PhenologyStep,
timeEvents as unknown as TimeEventsStep,
autopilot as unknown as AutopilotStep,
expiry as unknown as SuggestionsExpiryStep,
@@ -54,20 +60,25 @@ describe('DailyEvaluationJob', () => {
it('runs the steps for the Berlin day of the run and reports their counters', async () => {
expect(await job.run()).toEqual({
...PHENOLOGY_COUNTS,
...COUNTS,
...AUTOPILOT_COUNTS,
suggestionsExpired: 3,
digestsWritten: 5,
});
expect(phenology.run).toHaveBeenCalledWith('2026-06-10');
expect(timeEvents.run).toHaveBeenCalledWith('2026-06-10');
expect(autopilot.run).toHaveBeenCalledWith('2026-06-10');
expect(expiry.run).toHaveBeenCalledWith('2026-06-10');
expect(digest.run).toHaveBeenCalledWith('2026-06-10');
});
it('runs time-events, autopilot, suggestions-expiry and digest in that order', async () => {
it('runs phenology, time-events, autopilot, suggestions-expiry and digest in that order', async () => {
await job.run();
expect(phenology.run.mock.invocationCallOrder[0]).toBeLessThan(
timeEvents.run.mock.invocationCallOrder[0],
);
expect(timeEvents.run.mock.invocationCallOrder[0]).toBeLessThan(
autopilot.run.mock.invocationCallOrder[0],
);
@@ -95,6 +106,24 @@ describe('DailyEvaluationJob', () => {
});
});
it('a failing phenology step does not stop the others', async () => {
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
phenology.run.mockRejectedValue(
new JobPartialFailure(PHENOLOGY_COUNTS, ['1 apiary']),
);
const failure = await job.run().then(
() => null,
(error: unknown) => error,
);
expect(timeEvents.run).toHaveBeenCalled();
expect(failure).toMatchObject({
failedSteps: ['phenology'],
result: { phenologyApiaries: 2, seasonChanges: 1, digestsWritten: 5 },
});
});
it('a failing autopilot step does not stop the expiry step', async () => {
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
autopilot.run.mockRejectedValue(
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { AutopilotStep } from '../autopilot/autopilot.step';
import { SuggestionsExpiryStep } from '../autopilot/suggestions-expiry.step';
import { berlinToday } from '../common/dates';
import { PhenologyStep } from '../phenology/phenology.step';
import { DigestStep } from '../reminders/digest.step';
import { JobPartialFailure } from '../system/job-run.service';
import {
@@ -25,6 +26,7 @@ export class DailyEvaluationJob implements Job {
private readonly logger = new Logger(DailyEvaluationJob.name);
constructor(
private readonly phenology: PhenologyStep,
private readonly timeEvents: TimeEventsStep,
private readonly autopilot: AutopilotStep,
private readonly suggestionsExpiry: SuggestionsExpiryStep,
@@ -34,6 +36,7 @@ export class DailyEvaluationJob implements Job {
/** Evaluates the current Berlin day. */
async run(): Promise<JobResult> {
const steps: DailyEvaluationStep[] = [
this.phenology,
this.timeEvents,
this.autopilot,
this.suggestionsExpiry,
@@ -87,6 +87,8 @@ function fakeDb(rows: FakeRows): FakeDb {
})),
),
},
// No stored phenology: every apiary gets the calendar state.
apiaryPhenology: { findMany: jest.fn().mockResolvedValue([]) },
automationRule: {
findUnique: jest.fn(({ where }: { where: { triggerType: string } }) => {
const config = rows.rules?.[where.triggerType];
+2
View File
@@ -7,6 +7,7 @@ import { BackupModule } from '../backup/backup.module';
import { IdempotencyCleanupService } from '../common/idempotency/idempotency-cleanup.service';
import { DomainEventsModule } from '../domain-events/domain-events.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { PhenologyModule } from '../phenology/phenology.module';
import { PrismaModule } from '../prisma/prisma.module';
import { RemindersModule } from '../reminders/reminders.module';
import { SeasonModule } from '../season/season.module';
@@ -50,6 +51,7 @@ const JOB_CLASSES = [
SystemModule,
SeasonModule,
AutopilotModule,
PhenologyModule,
RemindersModule,
],
providers: [
+122
View File
@@ -0,0 +1,122 @@
import { grasslandTemperatureSum } from './gts';
import type { DailyMean } from '../season/season-types';
function daysOf(
year: number,
from: string,
count: number,
meanTempC: number | null,
): DailyMean[] {
const start = Date.parse(`${year}-${from}T00:00:00.000Z`);
return Array.from({ length: count }, (_, i) => ({
day: new Date(start + i * 86_400_000).toISOString().slice(0, 10),
meanTempC,
}));
}
describe('grasslandTemperatureSum', () => {
it('weights January 0.5, February 0.75 and March 1', () => {
const days = [
...daysOf(2026, '01-01', 1, 10),
...daysOf(2026, '02-01', 1, 10),
...daysOf(2026, '03-01', 1, 10),
];
const january = grasslandTemperatureSum(days, 2026, '2026-01-01', 1);
const february = grasslandTemperatureSum(days, 2026, '2026-02-01', 1);
const march = grasslandTemperatureSum(days, 2026, '2026-03-01', 1);
expect(january.gts).toBe(5);
// The days in between are missing, not zero: only the three count.
expect(february.gts).toBe(12.5);
expect(march.gts).toBe(22.5);
});
it('negative daily means count 0', () => {
const result = grasslandTemperatureSum(
[...daysOf(2026, '03-01', 2, -4), ...daysOf(2026, '03-03', 1, 3)],
2026,
'2026-03-03',
1,
);
expect(result.gts).toBe(3);
});
it('is unusable with more than 10 % missing days', () => {
const full = daysOf(2026, '01-01', 90, 5);
const gappy = full.map((d, i) => (i < 10 ? { ...d, meanTempC: null } : d));
const edge = full.map((d, i) => (i < 9 ? { ...d, meanTempC: null } : d));
const ok = grasslandTemperatureSum(full, 2026, '2026-03-31', 0.1);
const bad = grasslandTemperatureSum(gappy, 2026, '2026-03-31', 0.1);
expect(ok).toMatchObject({
expectedDays: 90,
missingDays: 0,
usable: true,
});
expect(bad).toMatchObject({
expectedDays: 90,
missingDays: 10,
usable: false,
});
expect(grasslandTemperatureSum(edge, 2026, '2026-03-31', 0.1).usable).toBe(
true,
);
});
it('reaches 200 °C on the expected day', () => {
// January 31 x 10 x 0.5 = 155; each February day adds 7.5: 200 on Feb 6.
const days = [
...daysOf(2026, '01-01', 31, 10),
...daysOf(2026, '02-01', 10, 10),
];
expect(grasslandTemperatureSum(days, 2026, '2026-02-05', 0.1).gts).toBe(
192.5,
);
expect(grasslandTemperatureSum(days, 2026, '2026-02-06', 0.1).gts).toBe(
200,
);
});
it('reports the last day with data and nothing before the year starts', () => {
const days = daysOf(2026, '01-01', 10, 4);
expect(
grasslandTemperatureSum(days, 2026, '2026-01-20', 1).throughDay,
).toBe('2026-01-10');
expect(grasslandTemperatureSum([], 2026, '2025-12-31', 1)).toEqual({
gts: 0,
throughDay: null,
expectedDays: 0,
missingDays: 0,
usable: false,
});
});
it('counts February 28th and March 1st with their own weights', () => {
const days = [
{ day: '2026-02-28', meanTempC: 10 },
{ day: '2026-03-01', meanTempC: 10 },
];
const result = grasslandTemperatureSum(days, 2026, '2026-03-01', 1);
// 28 + 31 + 1 = 60 days from January 1st; 10 x 0.75 + 10 x 1.
expect(result).toMatchObject({ gts: 17.5, expectedDays: 60 });
});
it('knows February 29th in a leap year', () => {
const days = [
{ day: '2028-02-28', meanTempC: 10 },
{ day: '2028-02-29', meanTempC: 10 },
{ day: '2028-03-01', meanTempC: 10 },
];
const result = grasslandTemperatureSum(days, 2028, '2028-03-01', 1);
expect(result).toMatchObject({ gts: 25, expectedDays: 61 });
});
});
+77
View File
@@ -0,0 +1,77 @@
import { addDays } from '../common/dates';
import type { DailyMean } from '../season/season-types';
/** Month weights of the grassland temperature sum (DWD): January 0.5, February 0.75. */
export const GTS_MONTH_FACTORS: Readonly<Record<number, number>> = {
1: 0.5,
2: 0.75,
};
export interface GtsResult {
/** Sum of the positive daily means, weighted by month, from January 1st. */
gts: number;
/** The last day with a mean at or before the requested end; null if none. */
throughDay: string | null;
/** Days from January 1st to the requested end. */
expectedDays: number;
/** Of those, the days without a mean. */
missingDays: number;
/** False when more than `maxGapShare` of the days are missing. */
usable: boolean;
}
const monthFactor = (day: string): number =>
GTS_MONTH_FACTORS[Number(day.slice(5, 7))] ?? 1;
/**
* The grassland temperature sum (Grünlandtemperatursumme, DWD) of `year` up
* to and including `throughDay`: every day's positive mean counts, January
* with 0.5, February with 0.75, from March with 1; negative means count 0.
* Days without a mean count as missing; with more than `maxGapShare` of them
* the sum is not usable.
*/
export function grasslandTemperatureSum(
days: readonly DailyMean[],
year: number,
throughDay: string,
maxGapShare: number,
): GtsResult {
const first = `${year}-01-01`;
const last = throughDay > `${year}-12-31` ? `${year}-12-31` : throughDay;
if (last < first) {
return {
gts: 0,
throughDay: null,
expectedDays: 0,
missingDays: 0,
usable: false,
};
}
const means = new Map<string, number>();
for (const { day, meanTempC } of days) {
if (meanTempC !== null && Number.isFinite(meanTempC)) {
means.set(day, meanTempC);
}
}
let gts = 0;
let expectedDays = 0;
let missingDays = 0;
let lastDataDay: string | null = null;
for (let day = first; day <= last; day = addDays(day, 1)) {
expectedDays += 1;
const mean = means.get(day);
if (mean === undefined) {
missingDays += 1;
continue;
}
lastDataDay = day;
gts += Math.max(0, mean) * monthFactor(day);
}
return {
gts,
throughDay: lastDataDay,
expectedDays,
missingDays,
usable: missingDays / expectedDays <= maxGapShare,
};
}
@@ -0,0 +1,45 @@
import { calibrateThresholds } from './phenology-calibration';
const year = (
firstReportDay: Record<string, string>,
gtsByDay: Record<string, number>,
) => ({
firstReportDay,
gtsOn: (day: string): number | null => gtsByDay[day] ?? null,
});
describe('calibrateThresholds', () => {
it('uses the median GTS on the first report day of up to three previous years', () => {
// Most recent year first; the fourth year is ignored.
const years = [
year({ KIRSCHE: '2025-04-10' }, { '2025-04-10': 300 }),
year({ KIRSCHE: '2024-04-20' }, { '2024-04-20': 340 }),
year({ KIRSCHE: '2023-04-05' }, { '2023-04-05': 280 }),
year({ KIRSCHE: '2022-04-05' }, { '2022-04-05': 9999 }),
];
expect(calibrateThresholds(years, ['KIRSCHE', 'APFEL'])).toEqual({
KIRSCHE: 300,
});
});
it('averages the two middle values for an even count', () => {
const years = [
year({ APFEL: '2025-04-25' }, { '2025-04-25': 400 }),
year({ APFEL: '2024-04-25' }, { '2024-04-25': 440 }),
];
expect(calibrateThresholds(years, ['APFEL'])).toEqual({ APFEL: 420 });
});
it('skips years without a report or without GTS and never invents a threshold', () => {
const years = [
year({}, {}),
year({ KIRSCHE: '2024-04-20' }, {}),
year({ HOLUNDER: '2023-05-30' }, { '2023-05-30': 700 }),
];
expect(calibrateThresholds(years, ['KIRSCHE', 'APFEL'])).toEqual({});
expect(calibrateThresholds([], ['KIRSCHE'])).toEqual({});
});
});
@@ -0,0 +1,47 @@
import type { PhenologyPhaseKey } from '../season/season-types';
/** At most this many previous years take part in the calibration. */
export const CALIBRATION_YEARS = 3;
export interface CalibrationYear {
/** First report day (YYYY-MM-DD) per phase in that year. */
firstReportDay: Partial<Record<PhenologyPhaseKey, string>>;
/** GTS of that year on a day; null if the weather data does not allow it. */
gtsOn(day: string): number | null;
}
function median(values: readonly number[]): number {
const sorted = [...values].sort((a, b) => a - b);
const middle = Math.floor(sorted.length / 2);
return sorted.length % 2 === 1
? sorted[middle]
: (sorted[middle - 1] + sorted[middle]) / 2;
}
/**
* GTS thresholds from the reports of the previous years: per phase the median
* of the GTS on the first report day, over up to CALIBRATION_YEARS years
* (`years` most recent first). A phase without any usable year gets no
* threshold - nothing is invented.
*/
export function calibrateThresholds(
years: readonly CalibrationYear[],
phases: readonly PhenologyPhaseKey[],
): Partial<Record<PhenologyPhaseKey, number>> {
const thresholds: Partial<Record<PhenologyPhaseKey, number>> = {};
const recent = years.slice(0, CALIBRATION_YEARS);
for (const phase of phases) {
const values: number[] = [];
for (const year of recent) {
const day = year.firstReportDay[phase];
const gts = day === undefined ? null : year.gtsOn(day);
if (gts !== null) {
values.push(gts);
}
}
if (values.length > 0) {
thresholds[phase] = median(values);
}
}
return thresholds;
}
@@ -0,0 +1,278 @@
import { DEFAULT_PHENOLOGY_CONFIG } from '../automation/rule-config/phenology.config';
import type { GtsResult } from './gts';
import {
effectivePhaseOf,
reportsApplyingTo,
seasonNoteFor,
type PhaseInputs,
} from './phenology-phase';
const fresh = (gts: number, throughDay = '2026-04-19'): GtsResult => ({
gts,
throughDay,
expectedDays: 109,
missingDays: 0,
usable: true,
});
function inputs(overrides: Partial<PhaseInputs> = {}): PhaseInputs {
return {
today: '2026-04-20',
reports: [],
gts: fresh(250),
hasCoordinates: true,
thresholds: { SALWEIDE: 200 },
config: DEFAULT_PHENOLOGY_CONFIG,
previous: null,
allowBackward: false,
...overrides,
};
}
describe('effectivePhaseOf', () => {
it('takes the maximum of report, GTS and calendar', () => {
// GTS: SALWEIDE; calendar (04-15): KIRSCHE; report: APFEL.
const result = effectivePhaseOf(
inputs({
reports: [{ phase: 'APFEL', date: '2026-04-18', revoked: false }],
}),
);
expect(result).toMatchObject({
reportedPhase: 'APFEL',
reportedOn: '2026-04-18',
estimatedPhase: 'KIRSCHE',
effectivePhase: 'APFEL',
phaseSource: 'REPORT',
note: null,
});
});
it('uses GTS when it is ahead of the calendar', () => {
const result = effectivePhaseOf(
inputs({ today: '2026-03-25', gts: fresh(250, '2026-03-24') }),
);
expect(result).toMatchObject({
effectivePhase: 'SALWEIDE',
phaseSource: 'GTS',
reportedPhase: null,
});
});
it('ignores revoked reports', () => {
const result = effectivePhaseOf(
inputs({
reports: [{ phase: 'APFEL', date: '2026-04-18', revoked: true }],
}),
);
expect(result.reportedPhase).toBeNull();
expect(result.effectivePhase).toBe('KIRSCHE');
expect(result.phaseSource).toBe('CALENDAR');
});
it('ignores reports dated in the future', () => {
const result = effectivePhaseOf(
inputs({
reports: [{ phase: 'LINDE', date: '2026-04-21', revoked: false }],
}),
);
expect(result.reportedPhase).toBeNull();
});
it('without a previous-year report there is no KIRSCHE threshold, the calendar applies', () => {
// GTS is usable but only SALWEIDE has a threshold: KIRSCHE and later
// phases come from the calendar.
const result = effectivePhaseOf(
inputs({ gts: fresh(10, '2026-04-15'), today: '2026-04-16' }),
);
expect(result).toMatchObject({
effectivePhase: 'KIRSCHE',
phaseSource: 'CALENDAR',
});
});
it('does not let the calendar decide a phase that has a GTS threshold', () => {
const result = effectivePhaseOf(
inputs({
gts: fresh(10, '2026-04-15'),
today: '2026-04-16',
thresholds: { SALWEIDE: 200, KIRSCHE: 400 },
}),
);
expect(result.effectivePhase).toBeNull();
});
it('a calibrated threshold lets GTS reach KIRSCHE', () => {
const result = effectivePhaseOf(
inputs({
gts: fresh(320, '2026-04-09'),
today: '2026-04-10',
thresholds: { SALWEIDE: 200, KIRSCHE: 300 },
}),
);
expect(result).toMatchObject({
effectivePhase: 'KIRSCHE',
phaseSource: 'GTS',
});
});
it('falls back to the calendar for every phase without usable weather', () => {
const gappy: GtsResult = { ...fresh(250), usable: false, missingDays: 30 };
const result = effectivePhaseOf(inputs({ gts: gappy }));
expect(result).toMatchObject({
effectivePhase: 'KIRSCHE',
phaseSource: 'CALENDAR',
note: 'WEATHER_GAPS',
});
});
it('does not use GTS older than 3 days', () => {
const result = effectivePhaseOf(
inputs({ gts: fresh(250, '2026-04-16'), today: '2026-04-20' }),
);
expect(result.note).toBe('WEATHER_STALE');
expect(result.phaseSource).toBe('CALENDAR');
});
it('flags missing coordinates and uses the calendar', () => {
const result = effectivePhaseOf(
inputs({ gts: null, hasCoordinates: false }),
);
expect(result).toMatchObject({
note: 'NO_COORDINATES',
effectivePhase: 'KIRSCHE',
phaseSource: 'CALENDAR',
});
});
it('has no phase before spring, with the calendar as source', () => {
const result = effectivePhaseOf(
inputs({ gts: fresh(5, '2026-01-30'), today: '2026-01-31' }),
);
expect(result).toMatchObject({
effectivePhase: null,
phaseSource: 'CALENDAR',
});
});
it('never moves backwards unless a report was revoked', () => {
const base = inputs({ previous: { phase: 'APFEL', source: 'REPORT' } });
const held = effectivePhaseOf(base);
const released = effectivePhaseOf({ ...base, allowBackward: true });
expect(held).toMatchObject({
effectivePhase: 'APFEL',
phaseSource: 'REPORT',
estimatedPhase: 'KIRSCHE',
});
expect(released.effectivePhase).toBe('KIRSCHE');
});
});
describe('effectivePhaseOf edge cases', () => {
it('still uses GTS whose last day is exactly 3 days old', () => {
const base = {
today: '2026-04-20',
reports: [],
hasCoordinates: true,
thresholds: { SALWEIDE: 200, KIRSCHE: 240 },
config: DEFAULT_PHENOLOGY_CONFIG,
previous: null,
allowBackward: false,
} satisfies Omit<PhaseInputs, 'gts'>;
const used = effectivePhaseOf({ ...base, gts: fresh(250, '2026-04-17') });
const stale = effectivePhaseOf({ ...base, gts: fresh(250, '2026-04-16') });
expect(used).toMatchObject({
effectivePhase: 'KIRSCHE',
phaseSource: 'GTS',
note: null,
});
expect(stale.note).toBe('WEATHER_STALE');
expect(stale.phaseSource).toBe('CALENDAR');
});
it('reports the earliest of two reports of the same phase', () => {
const result = effectivePhaseOf(
inputs({
reports: [
{ phase: 'APFEL', date: '2026-04-18', revoked: false },
{ phase: 'APFEL', date: '2026-04-12', revoked: false },
],
}),
);
expect(result).toMatchObject({
reportedPhase: 'APFEL',
reportedOn: '2026-04-12',
});
});
});
describe('reportsApplyingTo', () => {
it('a report for all apiaries counts for each apiary', () => {
const reports = [
{
apiaryId: null,
phase: 'APFEL' as const,
date: '2026-04-18',
revoked: false,
},
{
apiaryId: 'a',
phase: 'KIRSCHE' as const,
date: '2026-04-10',
revoked: false,
},
{
apiaryId: 'b',
phase: 'LINDE' as const,
date: '2026-04-11',
revoked: false,
},
];
expect(reportsApplyingTo('a', reports).map((r) => r.phase)).toEqual([
'APFEL',
'KIRSCHE',
]);
expect(reportsApplyingTo('c', reports).map((r) => r.phase)).toEqual([
'APFEL',
]);
});
});
describe('seasonNoteFor', () => {
const base = {
useGts: true,
hasCoordinates: true,
today: '2026-04-20',
lastDataDay: '2026-04-19' as string | null,
gtsUsable: true,
};
it.each([
[{}, null],
[{ useGts: false, hasCoordinates: false }, null],
[{ hasCoordinates: false }, 'NO_COORDINATES'],
[{ lastDataDay: null }, 'WEATHER_STALE'],
[{ lastDataDay: '2026-04-16' }, 'WEATHER_STALE'],
[{ lastDataDay: '2026-04-17' }, null],
[{ gtsUsable: false }, 'WEATHER_GAPS'],
[{ today: '2026-01-01', lastDataDay: null, gtsUsable: false }, null],
])('%j -> %s', (override, note) => {
expect(seasonNoteFor({ ...base, ...override })).toBe(note);
});
});
@@ -0,0 +1,219 @@
import type { PhenologyConfig } from '../automation/rule-config/rule-config.types';
import { daysBetween } from '../common/dates';
import {
PHENOLOGY_PHASES,
type PhaseSourceKey,
type PhenologyPhaseKey,
type SeasonNote,
} from '../season/season-types';
import type { GtsResult } from './gts';
/** GTS whose last day with data is older than this many days is not used. */
export const GTS_MAX_AGE_DAYS = 3;
export interface PhaseReport {
phase: PhenologyPhaseKey;
date: string;
revoked: boolean;
}
export interface PhaseInputs {
today: string;
reports: readonly PhaseReport[];
gts: GtsResult | null;
hasCoordinates: boolean;
/** GTS thresholds in force: configured plus calibrated. */
thresholds: Partial<Record<PhenologyPhaseKey, number>>;
config: PhenologyConfig;
previous: {
phase: PhenologyPhaseKey | null;
source?: PhaseSourceKey | null;
} | null;
/** True after a report was revoked: the phase may fall back. */
allowBackward: boolean;
}
export interface EffectivePhase {
reportedPhase: PhenologyPhaseKey | null;
reportedOn: string | null;
estimatedPhase: PhenologyPhaseKey | null;
effectivePhase: PhenologyPhaseKey | null;
phaseSource: PhaseSourceKey | null;
note: SeasonNote;
}
interface Candidate {
phase: PhenologyPhaseKey;
source: PhaseSourceKey;
}
const rankOf = (phase: PhenologyPhaseKey): number =>
PHENOLOGY_PHASES.indexOf(phase);
/** The higher phase; the first wins a tie. */
function higher(a: Candidate | null, b: Candidate | null): Candidate | null {
if (a === null || b === null) {
return a ?? b;
}
return rankOf(b.phase) > rankOf(a.phase) ? b : a;
}
/** Reports that count for an apiary: its own and those for all apiaries. */
export function reportsApplyingTo<T extends { apiaryId: string | null }>(
apiaryId: string,
reports: readonly T[],
): T[] {
return reports.filter(
(report) => report.apiaryId === null || report.apiaryId === apiaryId,
);
}
/**
* Why the weather cannot decide the phase, if it cannot: no coordinates, the
* last day with data too old, or too many gaps. Null if GTS is off or fine.
*/
export function seasonNoteFor(input: {
useGts: boolean;
hasCoordinates: boolean;
today: string;
lastDataDay: string | null;
gtsUsable: boolean;
}): SeasonNote {
if (!input.useGts) {
return null;
}
if (!input.hasCoordinates) {
return 'NO_COORDINATES';
}
// January 1st: no day of the year has passed, there is nothing to judge.
if (input.today.endsWith('-01-01')) {
return null;
}
if (
input.lastDataDay === null ||
daysBetween(input.lastDataDay, input.today) > GTS_MAX_AGE_DAYS
) {
return 'WEATHER_STALE';
}
return input.gtsUsable ? null : 'WEATHER_GAPS';
}
/** The GTS that may decide today's phase; null if it is off, stale or gappy. */
export function usableGts(
inputs: Pick<PhaseInputs, 'today' | 'gts' | 'hasCoordinates' | 'config'>,
): GtsResult | null {
const { gts } = inputs;
if (gts === null || !gts.usable || gts.throughDay === null) {
return null;
}
const note = seasonNoteFor({
useGts: inputs.config.useGts,
hasCoordinates: inputs.hasCoordinates,
today: inputs.today,
lastDataDay: gts.throughDay,
gtsUsable: gts.usable,
});
return inputs.config.useGts && note === null ? gts : null;
}
function reported(inputs: PhaseInputs): {
phase: PhenologyPhaseKey | null;
on: string | null;
} {
const yearStart = `${inputs.today.slice(0, 4)}-01-01`;
const valid = inputs.reports.filter(
(report) =>
!report.revoked &&
report.date >= yearStart &&
report.date <= inputs.today,
);
let phase: PhenologyPhaseKey | null = null;
for (const report of valid) {
if (phase === null || rankOf(report.phase) > rankOf(phase)) {
phase = report.phase;
}
}
const dates = valid.filter((r) => r.phase === phase).map((r) => r.date);
return { phase, on: dates.length > 0 ? dates.sort()[0] : null };
}
function gtsCandidate(
inputs: PhaseInputs,
gts: GtsResult | null,
): Candidate | null {
if (gts === null) {
return null;
}
let best: Candidate | null = null;
for (const phase of PHENOLOGY_PHASES) {
const threshold = inputs.thresholds[phase];
if (threshold !== undefined && gts.gts >= threshold) {
best = { phase, source: 'GTS' };
}
}
return best;
}
/** The latest calendar phase reached; phases with a GTS threshold are skipped while GTS decides. */
function calendarCandidate(
inputs: PhaseInputs,
gtsDecides: boolean,
): Candidate | null {
const year = inputs.today.slice(0, 4);
let best: Candidate | null = null;
for (const phase of PHENOLOGY_PHASES) {
if (gtsDecides && inputs.thresholds[phase] !== undefined) {
continue;
}
if (`${year}-${inputs.config.calendarFallback[phase]}` <= inputs.today) {
best = { phase, source: 'CALENDAR' };
}
}
return best;
}
/**
* The phase of an apiary today: the maximum of the user's report, the GTS
* estimate and the calendar (the calendar only for phases without a GTS
* threshold, or everywhere while the weather cannot be used). Within a year it
* only moves forward, unless `allowBackward` (a report was revoked). Before
* the first phase of the year there is no phase, and the calendar is the source.
*/
export function effectivePhaseOf(inputs: PhaseInputs): EffectivePhase {
const gts = usableGts(inputs);
const note = seasonNoteFor({
useGts: inputs.config.useGts,
hasCoordinates: inputs.hasCoordinates,
today: inputs.today,
lastDataDay: inputs.gts?.throughDay ?? null,
gtsUsable: inputs.gts?.usable ?? false,
});
const report = reported(inputs);
const reportCandidate: Candidate | null =
report.phase === null ? null : { phase: report.phase, source: 'REPORT' };
const estimate = higher(
gtsCandidate(inputs, gts),
calendarCandidate(inputs, gts !== null),
);
let chosen = higher(reportCandidate, estimate);
const previous = inputs.previous?.phase ?? null;
if (
previous !== null &&
!inputs.allowBackward &&
(chosen === null || rankOf(previous) > rankOf(chosen.phase))
) {
chosen = {
phase: previous,
source: inputs.previous?.source ?? chosen?.source ?? 'CALENDAR',
};
}
return {
reportedPhase: report.phase,
reportedOn: report.on,
estimatedPhase: estimate?.phase ?? null,
effectivePhase: chosen?.phase ?? null,
phaseSource: chosen?.source ?? 'CALENDAR',
note,
};
}
@@ -0,0 +1,52 @@
import type { ApiaryPhenology } from '../../generated/prisma';
import type { PhenologyConfig } from '../automation/rule-config/rule-config.types';
import { utcDay } from '../domain-events/domain-event-types';
import type { SeasonState } from '../season/season-types';
import { seasonNoteFor } from './phenology-phase';
/**
* The season state a stored ApiaryPhenology row stands for.
*
* `reportedOn` is not stored and stays null. `springSince` (the day FRUEHJAHR
* began) is not stored either: in FRUEHJAHR it is `segmentSince`; later in the
* year it is the calendar day of SALWEIDE, but not later than `segmentSince`;
* before the spring it is null.
*/
export function seasonStateFromRow(
row: Omit<ApiaryPhenology, 'updatedAt'>,
config: PhenologyConfig,
hasCoordinates: boolean,
today: string,
): SeasonState {
const segmentSince = utcDay(row.segmentSince);
const calendarSpring = `${row.year}-${config.calendarFallback.SALWEIDE}`;
let springSince: string | null;
if (row.segment === 'FRUEHJAHR') {
springSince = segmentSince;
} else if (row.segment === 'WINTERRUHE' && row.effectivePhase !== 'WINTER') {
springSince = null;
} else {
springSince = calendarSpring < segmentSince ? calendarSpring : segmentSince;
}
const gtsThroughDay =
row.gtsThroughDay === null ? null : utcDay(row.gtsThroughDay);
return {
apiaryId: row.apiaryId,
year: row.year,
phase: row.effectivePhase,
phaseSource: row.phaseSource,
reportedOn: null,
segment: row.segment,
segmentSince,
springSince,
gts: row.gts,
gtsThroughDay,
note: seasonNoteFor({
useGts: config.useGts,
hasCoordinates,
today,
lastDataDay: gtsThroughDay,
gtsUsable: row.gts !== null,
}),
};
}
@@ -0,0 +1,425 @@
import type { Prisma } from '../../generated/prisma';
import { DEFAULT_PHENOLOGY_CONFIG } from '../automation/rule-config/phenology.config';
import { calendarSeasonState } from '../season/season-calendar';
import { DomainEventsService } from '../domain-events/domain-events.service';
import type { PrismaService } from '../prisma/prisma.service';
import { PhenologyStateService } from './phenology-state.service';
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
interface Fixture {
apiary: { latitude: number | null; longitude: number | null } | null;
row: Record<string, unknown> | null;
reports: Record<string, unknown>[];
weather: { day: Date; meanTempC: number | null }[];
}
function setup(fixture: Partial<Fixture> = {}) {
const data: Fixture = {
apiary: { latitude: 48.1, longitude: 9.9 },
row: null,
reports: [],
weather: [],
...fixture,
};
const upsert = jest.fn().mockResolvedValue(undefined);
const record = jest.fn().mockResolvedValue(undefined);
const queryRaw = jest.fn().mockResolvedValue([{ id: 'apiary-1' }]);
const tx = {
$queryRaw: queryRaw,
$executeRaw: jest.fn(),
$transaction: undefined,
apiary: { findUnique: jest.fn().mockResolvedValue(data.apiary) },
automationRule: {
findUnique: jest.fn().mockResolvedValue({
id: 'rule-1',
active: true,
actionTemplate: null,
config: DEFAULT_PHENOLOGY_CONFIG,
}),
},
apiaryPhenology: {
findUnique: jest.fn().mockResolvedValue(data.row),
upsert,
},
phenologyReport: { findMany: jest.fn().mockResolvedValue(data.reports) },
apiaryWeatherDay: { findMany: jest.fn().mockResolvedValue(data.weather) },
};
const events = { record } as unknown as DomainEventsService;
const service = new PhenologyStateService({} as PrismaService, events);
return { service, tx, upsert, record, queryRaw };
}
/** Every day of 2026 from January 1 to `through` at `mean` degrees. */
function weather(through: string, mean: number) {
const days: { day: Date; meanTempC: number | null }[] = [];
for (
let t = d('2026-01-01').getTime();
t <= d(through).getTime();
t += 86_400_000
) {
days.push({ day: new Date(t), meanTempC: mean });
}
return days;
}
const asTx = (tx: unknown) => tx as Prisma.TransactionClient;
describe('PhenologyStateService.recomputeIn', () => {
it('records SeasonPhaseChanged once per apiary and day on a change', async () => {
// Stored: FRUEHJAHR since March 20th; today the report puts it on APFEL.
const { service, tx, record } = setup({
row: {
apiaryId: 'apiary-1',
year: 2026,
gts: null,
gtsThroughDay: null,
missingDays: 0,
estimatedPhase: 'SALWEIDE',
reportedPhase: null,
effectivePhase: 'SALWEIDE',
phaseSource: 'CALENDAR',
segment: 'FRUEHJAHR',
segmentSince: d('2026-03-20'),
},
reports: [
{
apiaryId: null,
phase: 'APFEL',
date: d('2026-04-18'),
revokedAt: null,
},
],
weather: weather('2026-04-19', 10),
});
const first = await service.recomputeIn(asTx(tx), 'apiary-1', '2026-04-20');
const second = await service.recomputeIn(
asTx(tx),
'apiary-1',
'2026-04-20',
);
expect(first.changed).toBe(true);
expect(first.state).toMatchObject({
phase: 'APFEL',
phaseSource: 'REPORT',
segment: 'SCHWARMZEIT',
segmentSince: '2026-04-20',
});
// Both runs report the same change (the mocked row stays unchanged); the
// event carries the same dedupe key, so the outbox keeps one of them.
expect(second.changed).toBe(true);
const keys = record.mock.calls.map(
([, event]: [unknown, { dedupeKey: string }]) => event.dedupeKey,
);
expect(keys).toEqual([
'SeasonPhaseChanged:apiary-1:2026-04-20',
'SeasonPhaseChanged:apiary-1:2026-04-20',
]);
expect(record).toHaveBeenNthCalledWith(
1,
expect.anything(),
expect.objectContaining({
type: 'SeasonPhaseChanged',
aggregateType: 'Apiary',
aggregateId: 'apiary-1',
payload: {
apiaryId: 'apiary-1',
phase: 'APFEL',
segment: 'SCHWARMZEIT',
previousPhase: 'SALWEIDE',
previousSegment: 'FRUEHJAHR',
source: 'REPORT',
},
}),
);
});
it('records nothing when phase and segment stay', async () => {
const { service, tx, record, upsert } = setup({
row: {
apiaryId: 'apiary-1',
year: 2026,
gts: 800,
gtsThroughDay: d('2026-04-19'),
missingDays: 0,
estimatedPhase: 'KIRSCHE',
reportedPhase: null,
effectivePhase: 'KIRSCHE',
phaseSource: 'CALENDAR',
segment: 'SCHWARMZEIT',
segmentSince: d('2026-04-15'),
},
weather: weather('2026-04-19', 10),
});
const result = await service.recomputeIn(
asTx(tx),
'apiary-1',
'2026-04-20',
);
expect(result.changed).toBe(false);
expect(result.state.segmentSince).toBe('2026-04-15');
expect(record).not.toHaveBeenCalled();
expect(upsert).toHaveBeenCalledTimes(1);
});
it('locks the apiary row before it reads the stored state', async () => {
const { service, tx, queryRaw } = setup();
const order: string[] = [];
queryRaw.mockImplementation(() => {
order.push('lock');
return Promise.resolve([{ id: 'apiary-1' }]);
});
tx.apiaryPhenology.findUnique.mockImplementation(() => {
order.push('read');
return Promise.resolve(null);
});
await service.recomputeIn(asTx(tx), 'apiary-1', '2026-04-20');
expect(order.slice(0, 2)).toEqual(['lock', 'read']);
});
it('first row of the year equals the calendar state: no event', async () => {
const { service, tx, record, upsert } = setup({
apiary: { latitude: null, longitude: null },
});
const result = await service.recomputeIn(
asTx(tx),
'apiary-1',
'2026-04-20',
);
expect(result.changed).toBe(false);
expect(record).not.toHaveBeenCalled();
expect(result.state).toMatchObject({
note: 'NO_COORDINATES',
phase: 'KIRSCHE',
segment: 'SCHWARMZEIT',
segmentSince: '2026-04-15',
});
const call = upsert.mock.calls[0] as [{ create: Record<string, unknown> }];
expect(call[0].create).toMatchObject({
apiaryId: 'apiary-1',
year: 2026,
gts: null,
effectivePhase: 'KIRSCHE',
});
});
it('allows going backwards only with allowBackward', async () => {
const stored = {
apiaryId: 'apiary-1',
year: 2026,
gts: null,
gtsThroughDay: null,
missingDays: 0,
estimatedPhase: 'KIRSCHE',
reportedPhase: 'LINDE',
effectivePhase: 'LINDE',
phaseSource: 'REPORT',
segment: 'SOMMER',
segmentSince: d('2026-04-18'),
};
const held = setup({
row: stored,
apiary: { latitude: null, longitude: null },
});
const released = setup({
row: stored,
apiary: { latitude: null, longitude: null },
});
const keep = await held.service.recomputeIn(
asTx(held.tx),
'apiary-1',
'2026-04-20',
);
const back = await released.service.recomputeIn(
asTx(released.tx),
'apiary-1',
'2026-04-20',
{ allowBackward: true },
);
expect(keep.state.phase).toBe('LINDE');
expect(keep.changed).toBe(false);
expect(back.state.phase).toBe('KIRSCHE');
expect(back.changed).toBe(true);
expect(released.record).toHaveBeenCalledTimes(1);
});
it('throws for an apiary that does not exist', async () => {
const { service, tx } = setup({ apiary: null });
await expect(
service.recomputeIn(asTx(tx), 'gone', '2026-04-20'),
).rejects.toThrow('Apiary gone not found');
});
});
function weatherRange(from: string, through: string, mean: number) {
const days: { day: Date; meanTempC: number | null }[] = [];
for (let t = d(from).getTime(); t <= d(through).getTime(); t += 86_400_000) {
days.push({ day: new Date(t), meanTempC: mean });
}
return days;
}
describe('PhenologyStateService fix round 1', () => {
const noCoordinates = { latitude: null, longitude: null };
it('filters the reports in SQL: own and those for all apiaries', async () => {
const { service, tx } = setup({ apiary: noCoordinates });
await service.recomputeIn(asTx(tx), 'apiary-1', '2026-04-20');
const call = tx.phenologyReport.findMany.mock.calls[0] as [
{ where: { revokedAt: null; OR: unknown } },
];
expect(call[0].where.revokedAt).toBeNull();
expect(call[0].where.OR).toEqual([
{ apiaryId: 'apiary-1' },
{ apiaryId: null },
]);
});
it('recomputeAll reads the rule and the reports once, not per apiary', async () => {
const { tx, record } = setup({ apiary: noCoordinates });
const prisma = {
apiary: {
findMany: jest
.fn()
.mockResolvedValue([{ id: 'apiary-1' }, { id: 'apiary-2' }]),
},
automationRule: tx.automationRule,
phenologyReport: {
findMany: jest.fn().mockResolvedValue([
{
apiaryId: 'apiary-2',
phase: 'LINDE',
date: d('2026-04-18'),
},
]),
},
$transaction: (fn: (t: unknown) => Promise<unknown>) => fn(tx),
};
const all = new PhenologyStateService(
prisma as unknown as PrismaService,
{ record } as unknown as DomainEventsService,
);
const result = await all.recomputeAll('2026-04-20');
expect(result).toEqual({ apiaries: 2, changes: 1, failed: 0 });
expect(prisma.automationRule.findUnique).toHaveBeenCalledTimes(1);
expect(prisma.phenologyReport.findMany).toHaveBeenCalledTimes(1);
expect(tx.phenologyReport.findMany).not.toHaveBeenCalled();
// The LINDE report of apiary-2 moved only apiary-2 (SOMMER).
expect(record).toHaveBeenCalledTimes(1);
});
it('recomputeAll counts a failing apiary and goes on', async () => {
const { tx, record } = setup({ apiary: noCoordinates });
const prisma = {
apiary: {
findMany: jest
.fn()
.mockResolvedValue([{ id: 'apiary-1' }, { id: 'apiary-2' }]),
},
automationRule: tx.automationRule,
phenologyReport: { findMany: jest.fn().mockResolvedValue([]) },
$transaction: jest
.fn()
.mockRejectedValueOnce(new Error('boom'))
.mockImplementation((fn: (t: unknown) => Promise<unknown>) => fn(tx)),
};
const all = new PhenologyStateService(
prisma as unknown as PrismaService,
{ record } as unknown as DomainEventsService,
);
jest
.spyOn(
(all as unknown as { logger: { error: () => void } }).logger,
'error',
)
.mockImplementation(() => undefined);
expect(await all.recomputeAll('2026-04-20')).toMatchObject({
apiaries: 1,
failed: 1,
});
});
it('pins the pre-spring state: phase null here, WINTER in the calendar, WINTERRUHE in both', async () => {
const { service, tx } = setup({ apiary: noCoordinates });
const { state } = await service.recomputeIn(
asTx(tx),
'apiary-1',
'2026-02-10',
);
const calendar = calendarSeasonState(
'apiary-1',
'2026-02-10',
DEFAULT_PHENOLOGY_CONFIG,
);
expect(state.phase).toBeNull();
expect(calendar.phase).toBe('WINTER');
expect(state.segment).toBe('WINTERRUHE');
expect(calendar.segment).toBe('WINTERRUHE');
});
it('calibrates KIRSCHE from last year report and weather end to end', async () => {
// 2025: 10 degrees all year; KIRSCHE reported 2025-04-10 -> GTS through
// April 9th = 155 + 210 + 310 + 90 = 765. 2026: the same weather, so the
// threshold is reached on 2026-04-10, five days before the calendar.
const { service, tx } = setup({
reports: [
{
apiaryId: null,
phase: 'KIRSCHE',
date: d('2025-04-10'),
revokedAt: null,
},
],
weather: [
...weatherRange('2025-01-01', '2025-12-31', 10),
...weatherRange('2026-01-01', '2026-04-09', 10),
],
});
const { state } = await service.recomputeIn(
asTx(tx),
'apiary-1',
'2026-04-10',
);
expect(state).toMatchObject({
phase: 'KIRSCHE',
phaseSource: 'GTS',
gts: 765,
segment: 'SCHWARMZEIT',
});
});
it('looks up the row of the new year at rollover, not last year', async () => {
const { service, tx } = setup({ apiary: noCoordinates });
const { state } = await service.recomputeIn(
asTx(tx),
'apiary-1',
'2027-01-02',
);
expect(tx.apiaryPhenology.findUnique).toHaveBeenCalledWith({
where: { apiaryId_year: { apiaryId: 'apiary-1', year: 2027 } },
});
expect(state).toMatchObject({ year: 2027, phase: null });
});
});
@@ -0,0 +1,369 @@
import { Injectable, Logger } from '@nestjs/common';
import type { Prisma } from '../../generated/prisma';
import type { DbClient } from '../prisma/db-client';
import { readRule } from '../automation/rule-config';
import type { PhenologyConfig } from '../automation/rule-config/rule-config.types';
import { addDays, dayToDate } from '../common/dates';
import { loggableError } from '../common/loggable-error';
import { dailyDedupeKey, utcDay } from '../domain-events/domain-event-types';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { PrismaService } from '../prisma/prisma.service';
import { lockRowForUpdate } from '../prisma/row-lock';
import { calendarSeasonState } from '../season/season-calendar';
import { segmentOf } from '../season/season-segment';
import type {
DailyMean,
PhenologyPhaseKey,
SeasonState,
} from '../season/season-types';
import { grasslandTemperatureSum } from './gts';
import {
CALIBRATION_YEARS,
calibrateThresholds,
type CalibrationYear,
} from './phenology-calibration';
import {
effectivePhaseOf,
reportsApplyingTo,
usableGts,
} from './phenology-phase';
import { seasonStateFromRow } from './phenology-row';
export interface DatedReport {
apiaryId: string | null;
phase: PhenologyPhaseKey;
date: string;
revoked: boolean;
}
/** What recomputeAll reads once per run instead of once per apiary. */
export interface PreloadedPhenology {
config: PhenologyConfig;
/** Reports of all apiaries (apiaryId null = all), revoked ones excluded. */
reports: readonly DatedReport[];
}
export interface RecomputeOptions {
/** After a revoked report: the phase may fall back. */
allowBackward?: boolean;
preloaded?: PreloadedPhenology;
}
export interface RecomputeResult {
changed: boolean;
state: SeasonState;
}
export interface RecomputeAllResult {
apiaries: number;
changes: number;
/** Apiaries whose recomputation failed; the next run tries again. */
failed: number;
}
function groupByYear(days: readonly DailyMean[]): Map<number, DailyMean[]> {
const byYear = new Map<number, DailyMean[]>();
for (const day of days) {
const year = Number(day.day.slice(0, 4));
byYear.set(year, [...(byYear.get(year) ?? []), day]);
}
return byYear;
}
/** The calibration input of one previous year. */
function calibrationYear(
year: number,
reports: readonly DatedReport[],
days: readonly DailyMean[],
config: PhenologyConfig,
): CalibrationYear {
const firstReportDay: CalibrationYear['firstReportDay'] = {};
for (const report of reports) {
if (
report.date.startsWith(`${year}-`) &&
config.calibratePhases.includes(report.phase) &&
(firstReportDay[report.phase] ?? '9999') > report.date
) {
firstReportDay[report.phase] = report.date;
}
}
return {
firstReportDay,
// The GTS the morning of that day would have shown: through the day before.
gtsOn: (day) => {
const result = grasslandTemperatureSum(
days,
year,
addDays(day, -1),
config.maxGapShare,
);
return result.usable ? result.gts : null;
},
};
}
/**
* Computes and stores the season state of apiaries (ApiaryPhenology): the
* phase from the user's reports, the GTS and the calendar (see
* effectivePhaseOf), and the season segment. A change of phase or segment is
* recorded as SeasonPhaseChanged (once per apiary and day).
*/
@Injectable()
export class PhenologyStateService {
private readonly logger = new Logger(PhenologyStateService.name);
constructor(
private readonly prisma: PrismaService,
private readonly events: DomainEventsService,
) {}
/**
* Recomputes one apiary for `today` (YYYY-MM-DD, Berlin) inside the
* caller's transaction: locks the apiary row, then reads the stored state,
* so two recomputations of one apiary run one after the other.
*/
async recomputeIn(
tx: Prisma.TransactionClient,
apiaryId: string,
today: string,
options: RecomputeOptions = {},
): Promise<RecomputeResult> {
const exists = await lockRowForUpdate(tx, 'Apiary', apiaryId);
const apiary = exists
? await tx.apiary.findUnique({
where: { id: apiaryId },
select: { latitude: true, longitude: true },
})
: null;
if (apiary === null) {
throw new Error(`Apiary ${apiaryId} not found`);
}
const year = Number(today.slice(0, 4));
const config =
options.preloaded?.config ?? (await readRule(tx, 'PHENOLOGY')).config;
const stored = await tx.apiaryPhenology.findUnique({
where: { apiaryId_year: { apiaryId, year } },
});
const hasCoordinates =
apiary.latitude !== null && apiary.longitude !== null;
const useWeather = config.useGts && hasCoordinates;
const reports = options.preloaded
? reportsApplyingTo(apiaryId, options.preloaded.reports)
: await this.loadReports(tx, apiaryId, year, today);
const days = useWeather
? await this.loadWeather(tx, apiaryId, year, today, config, reports)
: [];
const byYear = groupByYear(days);
const gts = useWeather
? grasslandTemperatureSum(
byYear.get(year) ?? [],
year,
addDays(today, -1),
config.maxGapShare,
)
: null;
const calibrated =
useWeather && config.calibrateFromReports
? calibrateThresholds(
Array.from(
{ length: CALIBRATION_YEARS },
(_, i) => year - 1 - i,
).map((previousYear) =>
calibrationYear(
previousYear,
reports,
byYear.get(previousYear) ?? [],
config,
),
),
config.calibratePhases,
)
: {};
const phaseInputs = {
today,
reports: reports.filter((report) => report.date.startsWith(`${year}-`)),
gts,
hasCoordinates,
thresholds: config.useGts
? { ...config.gtsThresholds, ...calibrated }
: {},
config,
previous: stored
? { phase: stored.effectivePhase, source: stored.phaseSource }
: null,
allowBackward: options.allowBackward ?? false,
};
const effective = effectivePhaseOf(phaseInputs);
const segment = segmentOf(effective.effectivePhase, config.swarmSeason);
// Without a stored row the calendar state is what the users saw so far.
const baseline = calendarSeasonState(apiaryId, today, config);
const previousPhase = stored
? stored.effectivePhase
: baseline.springSince === null
? null
: baseline.phase;
const previousSegment = stored ? stored.segment : baseline.segment;
const segmentSince =
previousSegment === segment
? stored
? utcDay(stored.segmentSince)
: baseline.segmentSince
: today;
// Pre-spring rows hold phase null; calendarSeasonState says WINTER there
// (segment WINTERRUHE in both). A stored WINTER would block every later
// phase under the never-backwards rule.
const changed =
previousPhase !== effective.effectivePhase || previousSegment !== segment;
const used = usableGts(phaseInputs);
const data = {
gts: used === null ? null : used.gts,
gtsThroughDay: gts?.throughDay ? dayToDate(gts.throughDay) : null,
missingDays: gts?.missingDays ?? 0,
estimatedPhase: effective.estimatedPhase,
reportedPhase: effective.reportedPhase,
effectivePhase: effective.effectivePhase,
phaseSource: effective.phaseSource,
segment,
segmentSince: dayToDate(segmentSince),
};
await tx.apiaryPhenology.upsert({
where: { apiaryId_year: { apiaryId, year } },
create: { apiaryId, year, ...data },
update: data,
});
if (changed) {
await this.events.record(tx, {
type: 'SeasonPhaseChanged',
aggregateType: 'Apiary',
aggregateId: apiaryId,
payload: {
apiaryId,
phase: effective.effectivePhase,
segment,
previousPhase,
previousSegment,
source: effective.phaseSource,
},
// By design at most one SeasonPhaseChanged per apiary and day: a second
// change the same day updates the row but adds no event.
dedupeKey: dailyDedupeKey('SeasonPhaseChanged', apiaryId, today),
});
}
return {
changed,
state: seasonStateFromRow(
{ apiaryId, year, ...data },
config,
hasCoordinates,
today,
),
};
}
/**
* Recomputes every active apiary, one transaction each. A failing apiary is
* logged and does not stop the others.
*/
async recomputeAll(today: string): Promise<RecomputeAllResult> {
const apiaries = await this.prisma.apiary.findMany({
where: { status: 'ACTIVE' },
select: { id: true },
orderBy: { id: 'asc' },
});
const preloaded: PreloadedPhenology = {
config: (await readRule(this.prisma, 'PHENOLOGY')).config,
reports: await this.loadReports(
this.prisma,
null,
Number(today.slice(0, 4)),
today,
),
};
let done = 0;
let changes = 0;
let failed = 0;
for (const { id } of apiaries) {
try {
const result = await this.prisma.$transaction((tx) =>
this.recomputeIn(tx, id, today, { preloaded }),
);
done += 1;
changes += result.changed ? 1 : 0;
} catch (error: unknown) {
const { message, stack } = loggableError(error);
this.logger.error(
`Phenology of apiary ${id} failed: ${message}`,
stack,
);
failed += 1;
}
}
return { apiaries: done, changes, failed };
}
/**
* Reports of the last years, revoked ones excluded: those that count for
* the apiary (its own and those for all apiaries), or all when `apiaryId`
* is null (the preload of recomputeAll).
*/
private async loadReports(
db: DbClient,
apiaryId: string | null,
year: number,
today: string,
): Promise<DatedReport[]> {
const rows = await db.phenologyReport.findMany({
where: {
revokedAt: null,
...(apiaryId === null
? {}
: { OR: [{ apiaryId }, { apiaryId: null }] }),
date: {
gte: dayToDate(`${year - CALIBRATION_YEARS}-01-01`),
lte: dayToDate(today),
},
},
select: { apiaryId: true, phase: true, date: true },
});
return rows.map((row) => ({
apiaryId: row.apiaryId,
phase: row.phase,
date: utcDay(row.date),
revoked: false,
}));
}
/** The daily means of this year, and of earlier years that have reports to calibrate on. */
private async loadWeather(
tx: Prisma.TransactionClient,
apiaryId: string,
year: number,
today: string,
config: PhenologyConfig,
reports: readonly DatedReport[],
): Promise<DailyMean[]> {
const calibrate = config.calibrateFromReports;
const earliest = calibrate
? Math.min(
year,
...reports
.filter((report) => config.calibratePhases.includes(report.phase))
.map((report) => Number(report.date.slice(0, 4))),
)
: year;
const rows = await tx.apiaryWeatherDay.findMany({
where: {
apiaryId,
day: { gte: dayToDate(`${earliest}-01-01`), lt: dayToDate(today) },
},
select: { day: true, meanTempC: true },
});
return rows.map((row) => ({
day: utcDay(row.day),
meanTempC: row.meanTempC,
}));
}
}
@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { DomainEventsModule } from '../domain-events/domain-events.module';
import { PhenologyStateService } from './phenology-state.service';
import { PhenologyStep } from './phenology.step';
/**
* The season state of apiaries (ApiaryPhenology): the service that computes
* it and the first step of the daily evaluation. Imported by AppModule and
* WorkerModule (the reports of Task 28 recompute through the service) and by
* JobsModule (the step).
*/
@Module({
imports: [DomainEventsModule],
providers: [PhenologyStateService, PhenologyStep],
exports: [PhenologyStateService, PhenologyStep],
})
export class PhenologyModule {}
@@ -0,0 +1,32 @@
import { Injectable } from '@nestjs/common';
import type { DailyEvaluationStep } from '../jobs/daily-evaluation-steps';
import type { JobResult } from '../jobs/job';
import { JobPartialFailure } from '../system/job-run.service';
import { PhenologyStateService } from './phenology-state.service';
/**
* Step "phenology" of the daily evaluation (first, so the later steps see
* today's season): recomputes the season state of every active apiary, one
* transaction each. If apiaries failed, the step fails with the counters of
* the others; the next run tries again.
*/
@Injectable()
export class PhenologyStep implements DailyEvaluationStep {
readonly name = 'phenology';
constructor(private readonly state: PhenologyStateService) {}
async run(today: string): Promise<JobResult> {
const { apiaries, changes, failed } = await this.state.recomputeAll(today);
const counts: JobResult = {
phenologyApiaries: apiaries,
seasonChanges: changes,
};
if (failed > 0) {
throw new JobPartialFailure(counts, [
`${failed} ${failed === 1 ? 'apiary' : 'apiaries'}`,
]);
}
return counts;
}
}
@@ -199,6 +199,7 @@ describe('lock keys per table', () => {
'StockItem',
'Treatment',
'VarroaMeasurement',
'NotificationDelivery',
] as const)('locks %s by "id"', async (table) => {
const tx = { $queryRaw: jest.fn().mockResolvedValue([]) };
@@ -240,6 +241,7 @@ describe('lock keys per table', () => {
'PhenologyReport',
'Extraction',
'HoneyBatch',
'NotificationDelivery',
]);
});
});
+2
View File
@@ -38,6 +38,8 @@ const LOCKABLE_TABLES = {
PhenologyReport: lockable('PhenologyReport'),
Extraction: lockable('Extraction'),
HoneyBatch: lockable('HoneyBatch'),
// T4c (Task 34): the Telegram delivery outbox.
NotificationDelivery: lockable('NotificationDelivery'),
} as const;
export type LockableTable = keyof typeof LOCKABLE_TABLES;
@@ -321,4 +321,43 @@ describe('assertStartupEnvironment', () => {
).not.toThrow();
});
});
describe('telegram bot token', () => {
const TOKEN = '123456789:AAEhBOweik6ad9r_QXMENQjcrGbqCr4K-RM';
it('starts without a token in every role (Telegram stays disabled)', () => {
for (const role of ['api', 'worker', 'job', 'all'] as const) {
expect(() => assertStartupEnvironment(role, PROD_ENV)).not.toThrow();
}
});
it('the worker accepts a well-formed token in production', () => {
expect(() =>
assertStartupEnvironment('worker', {
...PROD_ENV,
TELEGRAM_BOT_TOKEN: TOKEN,
}),
).not.toThrow();
});
it('a malformed or placeholder token never stops the worker (Telegram stays off)', () => {
for (const bad of ['garbage', 'change-me-telegram-token']) {
expect(() =>
assertStartupEnvironment('worker', {
...PROD_ENV,
TELEGRAM_BOT_TOKEN: bad,
}),
).not.toThrow();
}
});
it('the API pod does not look at the token', () => {
expect(() =>
assertStartupEnvironment('api', {
...PROD_ENV,
TELEGRAM_BOT_TOKEN: 'garbage',
}),
).not.toThrow();
});
});
});
@@ -3,6 +3,7 @@ import {
PREVIOUS_KEY_SUFFIX,
readRingSecrets,
} from '../common/crypto/key-ring';
import { assertTelegramToken } from '../telegram/telegram-config';
import type { ApicoreRole } from './role';
const REQUIRED_ENV_VARS = ['DATABASE_URL', 'JWT_ACCESS_SECRET'] as const;
@@ -130,16 +131,17 @@ function assertEncryptionKeys(env: NodeJS.ProcessEnv): void {
/**
* Startup checks shared by every entry point (API, worker, CLI job). All
* roles load the same modules and secrets today, so the checks do not differ
* per role yet; the role is part of the signature so role-specific checks can
* be added without touching the callers.
* roles load the same modules and secrets today, so the checks hardly differ
* per role; the Telegram bot token is the first role-specific one (only the
* worker holds it).
*/
export function assertStartupEnvironment(
_role: ApicoreRole,
role: ApicoreRole,
env: NodeJS.ProcessEnv = process.env,
): void {
assertRequiredEnvVars(env);
assertProductionSettings(env);
assertNoPlaceholderSecrets(env);
assertEncryptionKeys(env);
assertTelegramToken(role, env);
}
@@ -4,11 +4,16 @@ import { SeasonService } from './season.service';
describe('SeasonService', () => {
const findUnique = jest.fn();
const db = { automationRule: { findUnique } } as unknown as DbClient;
const findMany = jest.fn();
const db = {
automationRule: { findUnique },
apiaryPhenology: { findMany },
} as unknown as DbClient;
const service = new SeasonService();
beforeEach(() => {
findUnique.mockReset().mockResolvedValue(null);
findMany.mockReset().mockResolvedValue([]);
});
it('gives every apiary the calendar state, reading PHENOLOGY once', async () => {
@@ -55,4 +60,130 @@ describe('SeasonService', () => {
springSince: '2026-03-01',
});
});
describe('with stored phenology', () => {
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
const row = (overrides: Record<string, unknown> = {}) => ({
apiaryId: 'apiary-1',
year: 2026,
gts: 412.5,
gtsThroughDay: d('2026-05-11'),
missingDays: 0,
estimatedPhase: 'KIRSCHE',
reportedPhase: 'APFEL',
effectivePhase: 'APFEL',
phaseSource: 'REPORT',
segment: 'SCHWARMZEIT',
segmentSince: d('2026-05-02'),
updatedAt: d('2026-05-12'),
apiary: { latitude: 48.1, longitude: 9.9 },
...overrides,
});
it('prefers the row of the current year over the calendar', async () => {
findMany.mockResolvedValue([row()]);
const states = await service.statesFor(
db,
['apiary-1', 'apiary-2'],
'2026-05-12',
);
expect(states.get('apiary-1')).toEqual({
apiaryId: 'apiary-1',
year: 2026,
phase: 'APFEL',
phaseSource: 'REPORT',
reportedOn: null,
segment: 'SCHWARMZEIT',
segmentSince: '2026-05-02',
springSince: '2026-03-20',
gts: 412.5,
gtsThroughDay: '2026-05-11',
note: null,
});
expect(states.get('apiary-2')).toMatchObject({ phaseSource: 'CALENDAR' });
expect(findMany).toHaveBeenCalledTimes(1);
expect(findUnique).toHaveBeenCalledTimes(1);
});
it('asks for the rows of the year of today', async () => {
await service.statesFor(db, ['apiary-1'], '2026-05-12');
expect(findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { apiaryId: { in: ['apiary-1'] }, year: 2026 },
}),
);
});
it('takes the segment start as spring start while in FRUEHJAHR', async () => {
findMany.mockResolvedValue([
row({ segment: 'FRUEHJAHR', segmentSince: d('2026-03-28') }),
]);
const states = await service.statesFor(db, ['apiary-1'], '2026-04-01');
expect(states.get('apiary-1')?.springSince).toBe('2026-03-28');
});
it('has no spring start before the spring', async () => {
findMany.mockResolvedValue([
row({
segment: 'WINTERRUHE',
effectivePhase: null,
phaseSource: 'CALENDAR',
segmentSince: d('2026-01-01'),
gts: 60,
}),
]);
const states = await service.statesFor(db, ['apiary-1'], '2026-02-10');
expect(states.get('apiary-1')?.springSince).toBeNull();
});
it('explains why the weather was not used', async () => {
findMany.mockResolvedValue([
row({ gts: null, gtsThroughDay: d('2026-05-02') }),
row({
apiaryId: 'apiary-2',
apiary: { latitude: null, longitude: null },
gts: null,
gtsThroughDay: null,
}),
row({ apiaryId: 'apiary-3', gts: null }),
]);
const states = await service.statesFor(
db,
['apiary-1', 'apiary-2', 'apiary-3'],
'2026-05-12',
);
expect(states.get('apiary-1')?.note).toBe('WEATHER_STALE');
expect(states.get('apiary-2')?.note).toBe('NO_COORDINATES');
expect(states.get('apiary-3')?.note).toBe('WEATHER_GAPS');
});
it('uses a row updated 2 days ago but not one updated 3 days ago', async () => {
findMany.mockResolvedValue([
row({ updatedAt: new Date('2026-05-10T04:31:00.000Z') }),
row({
apiaryId: 'apiary-2',
updatedAt: new Date('2026-05-09T04:31:00.000Z'),
}),
]);
const states = await service.statesFor(
db,
['apiary-1', 'apiary-2'],
'2026-05-12',
);
expect(states.get('apiary-1')?.phaseSource).toBe('REPORT');
// The job missed its runs: back to the calendar.
expect(states.get('apiary-2')?.phaseSource).toBe('CALENDAR');
});
});
});
+36 -5
View File
@@ -1,13 +1,22 @@
import { Injectable } from '@nestjs/common';
import { berlinDayOf, daysBetween } from '../common/dates';
import { readRule } from '../automation/rule-config';
import { seasonStateFromRow } from '../phenology/phenology-row';
import type { DbClient } from '../prisma/db-client';
import { calendarSeasonState } from './season-calendar';
import type { SeasonState } from './season-types';
/** A stored row not updated for longer than this (days) means the job missed its runs. */
const MAX_ROW_AGE_DAYS = 2;
/**
* The season of apiaries. Until the phenology data exists (Task 25) every
* apiary gets the calendar state; then ApiaryPhenology takes over here, and
* the callers stay as they are.
* The season of apiaries: the stored state of the current year
* (ApiaryPhenology, written by the phenology step and the reports) where
* there is one and the job kept it fresh (updated at most MAX_ROW_AGE_DAYS
* days ago), else the calendar state. A stale row falls back to the calendar
* instead of recomputing GTS on the fly: that would read a year of weather per
* apiary on every read path, the calendar costs nothing. Callers do not see
* the difference.
*/
@Injectable()
export class SeasonService {
@@ -21,9 +30,31 @@ export class SeasonService {
if (apiaryIds.length === 0) {
return states;
}
const ids = [...new Set(apiaryIds)];
const { config } = await readRule(db, 'PHENOLOGY');
for (const apiaryId of new Set(apiaryIds)) {
states.set(apiaryId, calendarSeasonState(apiaryId, today, config));
const rows = await db.apiaryPhenology.findMany({
where: { apiaryId: { in: ids }, year: Number(today.slice(0, 4)) },
include: { apiary: { select: { latitude: true, longitude: true } } },
});
const stored = new Map(rows.map((row) => [row.apiaryId, row]));
for (const apiaryId of ids) {
const found = stored.get(apiaryId);
const row =
found !== undefined &&
daysBetween(berlinDayOf(found.updatedAt), today) <= MAX_ROW_AGE_DAYS
? found
: undefined;
states.set(
apiaryId,
row === undefined
? calendarSeasonState(apiaryId, today, config)
: seasonStateFromRow(
row,
config,
row.apiary.latitude !== null && row.apiary.longitude !== null,
today,
),
);
}
return states;
}
@@ -0,0 +1,92 @@
import { loggableError } from '../common/loggable-error';
import {
describeTelegramFailure,
sanitizeTelegramDescription,
TelegramApiError,
} from './telegram-api-error';
describe('TelegramApiError', () => {
it('carries method, status, description and retry_after, and a message without a URL', () => {
const error = new TelegramApiError(
'sendMessage',
429,
'Too Many Requests',
7,
);
expect(error).toBeInstanceOf(Error);
expect(error.name).toBe('TelegramApiError');
expect(error.method).toBe('sendMessage');
expect(error.statusCode).toBe(429);
expect(error.description).toBe('Too Many Requests');
expect(error.retryAfterSec).toBe(7);
expect(error.message).toBe(
'Telegram sendMessage failed: Too Many Requests',
);
expect(loggableError(error).message).not.toMatch(/https?:/);
});
it('allows a network failure without status', () => {
const error = new TelegramApiError('getUpdates', null, 'timeout', null);
expect(error.statusCode).toBeNull();
expect(error.retryAfterSec).toBeNull();
});
});
describe('sanitizeTelegramDescription', () => {
it('removes bot URLs, tokens, control characters and caps the length', () => {
const token = '123456789:AAEhBOweik6ad9r_QXMENQjcrGbqCr4K-RM';
const dirty = `bad https://api.telegram.org/bot${token}/sendMessage \u0007x ${token} ${'y'.repeat(400)}`;
const clean = sanitizeTelegramDescription(dirty);
expect(clean).not.toContain('AAEhBOweik6ad9r');
expect(clean).not.toContain('api.telegram.org');
expect(clean).not.toContain('\u0007');
expect(clean.length).toBeLessThanOrEqual(200);
});
it('turns a non-string into a fixed text', () => {
expect(sanitizeTelegramDescription(undefined)).toBe('unknown error');
expect(sanitizeTelegramDescription(42)).toBe('unknown error');
});
});
describe('describeTelegramFailure', () => {
it('classifies abort and timeout without using the underlying message', () => {
const timeout = Object.assign(
new Error('boom bot123456789:SECRETSECRETSECRETSECRETSECRETSECRET'),
{ name: 'TimeoutError' },
);
const abort = Object.assign(new Error('x'), { name: 'AbortError' });
expect(describeTelegramFailure(timeout)).toBe('timeout');
expect(describeTelegramFailure(abort)).toBe('aborted');
expect(describeTelegramFailure(new TypeError('fetch failed'))).toBe(
'network error',
);
});
it.each([['text'], [null], [undefined], [42], [{ message: 'x' }]])(
'classifies a non-Error rejection (%p) as a network error',
(value) => {
expect(describeTelegramFailure(value)).toBe('network error');
},
);
it('finds a timeout or abort in the cause chain, and never returns its message', () => {
const secret = 'bot123456789:SECRETSECRETSECRETSECRETSECRETSECRET';
const timeout = Object.assign(new Error(secret), { name: 'TimeoutError' });
const wrapped = new TypeError('fetch failed', {
cause: new Error('socket', { cause: timeout }),
});
expect(describeTelegramFailure(wrapped)).toBe('timeout');
});
it('survives a cyclic cause chain', () => {
const a = new Error('a');
const b = new Error('b', { cause: a });
(a as { cause?: unknown }).cause = b;
expect(describeTelegramFailure(a)).toBe('network error');
});
});
@@ -0,0 +1,68 @@
const MAX_DESCRIPTION_CHARS = 200;
const UNKNOWN_DESCRIPTION = 'unknown error';
const MAX_CAUSE_DEPTH = 5;
// A request URL carries the bot token ("…/bot<id>:<secret>/<method>"), and a
// description could quote one. Both shapes are removed wherever text from
// outside ends up in an error.
const BOT_URL = /https?:\/\/\S*/gi;
const BOT_TOKEN = /\b(?:bot)?\d{5,}:[A-Za-z0-9_-]{20,}/g;
// eslint-disable-next-line no-control-regex
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/g;
/**
* A failed Telegram call. Carries the method and what Telegram answered -
* never the request URL, so the bot token cannot reach a log or lastError
* through it.
*/
export class TelegramApiError extends Error {
constructor(
readonly method: string,
readonly statusCode: number | null,
readonly description: string,
readonly retryAfterSec: number | null,
) {
super(`Telegram ${method} failed: ${description}`);
this.name = 'TelegramApiError';
}
}
/** Text from Telegram, safe for an error: no URL, no token, no control characters, short. */
export function sanitizeTelegramDescription(value: unknown): string {
if (typeof value !== 'string') {
return UNKNOWN_DESCRIPTION;
}
const clean = value
.replace(BOT_URL, '')
.replace(BOT_TOKEN, '')
.replace(CONTROL_CHARACTERS, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, MAX_DESCRIPTION_CHARS);
return clean === '' ? UNKNOWN_DESCRIPTION : clean;
}
/**
* Fixed description for a call that failed before an answer arrived. The
* underlying message is dropped on purpose: a fetch error can quote the URL.
*/
export function describeTelegramFailure(error: unknown): string {
const seen = new Set<unknown>();
let current: unknown = error;
// fetch wraps the real reason in `cause`; the depth limit and the visited
// set keep a cyclic chain from looping.
for (let depth = 0; depth < MAX_CAUSE_DEPTH; depth++) {
if (!(current instanceof Error) || seen.has(current)) {
break;
}
seen.add(current);
if (current.name === 'TimeoutError') {
return 'timeout';
}
if (current.name === 'AbortError') {
return 'aborted';
}
current = current.cause;
}
return 'network error';
}
@@ -0,0 +1,345 @@
import { loggableError } from '../common/loggable-error';
import { TelegramApiError } from './telegram-api-error';
import {
sanitizeTelegramText,
TELEGRAM_MAX_MESSAGE_CHARS,
TelegramClient,
} from './telegram-client';
const TOKEN = '123456789:AAEhBOweik6ad9r_QXMENQjcrGbqCr4K-RM';
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
});
}
describe('TelegramClient', () => {
let fetchMock: jest.SpyInstance;
let client: TelegramClient;
beforeEach(() => {
fetchMock = jest.spyOn(globalThis, 'fetch');
client = new TelegramClient({ token: TOKEN, botUsername: 'apicore_bot' });
});
afterEach(() => {
fetchMock.mockRestore();
});
it('is configured only with a well-formed token', () => {
expect(client.isConfigured()).toBe(true);
expect(
new TelegramClient({ token: null, botUsername: 'b' }).isConfigured(),
).toBe(false);
expect(
new TelegramClient({ token: 'junk', botUsername: 'b' }).isConfigured(),
).toBe(false);
});
it('the token never appears in TelegramApiError or loggableError', async () => {
fetchMock.mockRejectedValue(
new TypeError(
`connect failed https://api.telegram.org/bot${TOKEN}/sendMessage`,
),
);
const error = await client.sendMessage(1n, 'x').catch((e: unknown) => e);
expect(error).toBeInstanceOf(TelegramApiError);
const apiError = error as TelegramApiError;
expect(
JSON.stringify({
...apiError,
message: apiError.message,
log: loggableError(apiError),
}),
).not.toContain(TOKEN);
expect(apiError.statusCode).toBeNull();
});
it('the token does not leak through a description returned by Telegram', async () => {
fetchMock.mockResolvedValue(
jsonResponse({ ok: false, description: `Unauthorized ${TOKEN}` }, 401),
);
const error = (await client
.sendMessage(1n, 'x')
.catch((e: unknown) => e)) as TelegramApiError;
expect(error.statusCode).toBe(401);
expect(`${error.message}${error.description}`).not.toContain(TOKEN);
});
it('sends plain text without parse_mode and with link preview disabled', async () => {
fetchMock.mockResolvedValue(jsonResponse({ ok: true, result: {} }));
await client.sendMessage(4242n, 'Hallo');
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe(`https://api.telegram.org/bot${TOKEN}/sendMessage`);
expect(init.method).toBe('POST');
const body = JSON.parse(init.body as string) as Record<string, unknown>;
expect(body).toEqual({
chat_id: 4242,
text: 'Hallo',
link_preview_options: { is_disabled: true },
});
expect(body).not.toHaveProperty('parse_mode');
});
it('does not follow redirects (the URL carries the token)', async () => {
fetchMock.mockResolvedValue(jsonResponse({ ok: true, result: {} }));
await client.sendMessage(1n, 'x');
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(init.redirect).toBe('error');
});
it('a redirect error becomes a network error without the URL', async () => {
fetchMock.mockRejectedValue(
new TypeError('fetch failed', {
cause: new Error(`redirect to https://evil.example/bot${TOKEN}`),
}),
);
const error = (await client
.sendMessage(1n, 'x')
.catch((e: unknown) => e)) as TelegramApiError;
expect(error.description).toBe('network error');
expect(JSON.stringify({ ...error, m: error.message })).not.toContain(TOKEN);
});
it('maps 429 with retry_after into TelegramApiError.retryAfterSec', async () => {
fetchMock.mockResolvedValue(
jsonResponse(
{
ok: false,
description: 'Too Many Requests: retry after 12',
parameters: { retry_after: 12 },
},
429,
),
);
const error = (await client
.sendMessage(1n, 'x')
.catch((e: unknown) => e)) as TelegramApiError;
expect(error).toBeInstanceOf(TelegramApiError);
expect(error.statusCode).toBe(429);
expect(error.retryAfterSec).toBe(12);
expect(error.method).toBe('sendMessage');
});
it('maps 429 without parameters to retryAfterSec null', async () => {
fetchMock.mockResolvedValue(
jsonResponse({ ok: false, description: 'Too Many Requests' }, 429),
);
await expect(client.sendMessage(1n, 'x')).rejects.toMatchObject({
statusCode: 429,
retryAfterSec: null,
});
});
it.each([
[12, 12],
[0, 1],
[-5, 1],
[999_999, 3600],
['12', null],
[1.5, null],
[null, null],
])('clamps retry_after %p to %p', async (raw, expected) => {
fetchMock.mockResolvedValue(
jsonResponse(
{ ok: false, description: 'x', parameters: { retry_after: raw } },
429,
),
);
await expect(client.sendMessage(1n, 'x')).rejects.toMatchObject({
retryAfterSec: expected,
});
});
it('passes a 403 description through unchanged (callers decide)', async () => {
fetchMock.mockResolvedValue(
jsonResponse(
{ ok: false, description: 'Forbidden: bot was blocked by the user' },
403,
),
);
await expect(client.sendMessage(1n, 'x')).rejects.toMatchObject({
statusCode: 403,
description: 'Forbidden: bot was blocked by the user',
retryAfterSec: null,
});
});
it('rejects an ok:false answer and a non-JSON body as TelegramApiError', async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({ ok: false }));
await expect(client.sendMessage(1n, 'x')).rejects.toBeInstanceOf(
TelegramApiError,
);
fetchMock.mockResolvedValueOnce(new Response('<html>', { status: 502 }));
await expect(client.sendMessage(1n, 'x')).rejects.toMatchObject({
statusCode: 502,
description: 'invalid response',
});
});
it('refuses an oversized response body', async () => {
fetchMock.mockResolvedValue(
new Response('x'.repeat(300_000), { status: 200 }),
);
await expect(client.sendMessage(1n, 'x')).rejects.toMatchObject({
description: 'invalid response',
});
});
it('stops reading a streamed body past the cap even without Content-Length', async () => {
const chunk = new Uint8Array(100 * 1024).fill(120);
let sent = 0;
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
sent++;
controller.enqueue(chunk);
if (sent >= 50) {
controller.close();
}
},
});
fetchMock.mockResolvedValue(new Response(stream, { status: 200 }));
await expect(client.sendMessage(1n, 'x')).rejects.toMatchObject({
description: 'invalid response',
});
expect(sent).toBeLessThan(10);
});
it('classifies a caller abort and a timeout without status', async () => {
fetchMock.mockRejectedValueOnce(
Object.assign(new Error('x'), { name: 'AbortError' }),
);
await expect(client.sendMessage(1n, 'x')).rejects.toMatchObject({
statusCode: null,
description: 'aborted',
});
fetchMock.mockRejectedValueOnce(
Object.assign(new Error('x'), { name: 'TimeoutError' }),
);
await expect(client.sendMessage(1n, 'x')).rejects.toMatchObject({
statusCode: null,
description: 'timeout',
});
});
it('throws a TelegramApiError when no token is configured, without calling out', async () => {
const bare = new TelegramClient({ token: null, botUsername: null });
await expect(bare.sendMessage(1n, 'x')).rejects.toMatchObject({
description: 'bot token not configured',
});
expect(fetchMock).not.toHaveBeenCalled();
});
it('does not call out with a malformed token either', async () => {
const bad = new TelegramClient({ token: 'junk', botUsername: 'b' });
await expect(bad.sendMessage(1n, 'x')).rejects.toMatchObject({
description: 'bot token not configured',
});
expect(fetchMock).not.toHaveBeenCalled();
});
it('refuses a chat id that does not fit a JSON number', async () => {
await expect(client.sendMessage(2n ** 60n, 'x')).rejects.toBeInstanceOf(
TelegramApiError,
);
expect(fetchMock).not.toHaveBeenCalled();
});
describe('getUpdates', () => {
it('asks for messages only with a 25 s long poll and parses the updates', async () => {
fetchMock.mockResolvedValue(
jsonResponse({
ok: true,
result: [
{
update_id: 100,
message: {
text: '/start abc',
chat: { id: 555, type: 'private' },
from: { first_name: 'Imke' },
},
},
{ update_id: 101, edited_message: {} },
{ update_id: 102, message: { chat: { id: -9, type: 'group' } } },
],
}),
);
const updates = await client.getUpdates(100n);
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe(`https://api.telegram.org/bot${TOKEN}/getUpdates`);
expect(JSON.parse(init.body as string)).toEqual({
offset: 100,
timeout: 25,
allowed_updates: ['message'],
});
expect(updates).toEqual([
{
updateId: 100n,
chatId: 555n,
chatType: 'private',
text: '/start abc',
firstName: 'Imke',
},
{
updateId: 102n,
chatId: -9n,
chatType: 'group',
text: null,
firstName: null,
},
]);
});
it('returns an empty list when nothing arrived', async () => {
fetchMock.mockResolvedValue(jsonResponse({ ok: true, result: [] }));
await expect(client.getUpdates(0n)).resolves.toEqual([]);
});
});
});
describe('sanitizeTelegramText', () => {
it('strips control characters but keeps line breaks', () => {
expect(sanitizeTelegramText('a\u0000b\u001bc\r\nd\te')).toBe('abc\nd e');
});
it('caps the text at the maximum length', () => {
const out = sanitizeTelegramText('x'.repeat(2000));
expect(out.length).toBe(TELEGRAM_MAX_MESSAGE_CHARS);
expect(out.endsWith('…')).toBe(true);
});
it('cuts by code point, never inside a surrogate pair', () => {
const out = sanitizeTelegramText('😀'.repeat(700));
expect([...out]).toHaveLength(TELEGRAM_MAX_MESSAGE_CHARS);
expect(out.endsWith('…')).toBe(true);
expect(out).not.toMatch(/[\ud800-\udbff](?![\udc00-\udfff])/);
});
it('strips bidi overrides, isolates and zero-width characters', () => {
const dirty = 'a​b‌c‍d‎e‏f‪g‫h‬i‭j‮k⁦l⁧m⁨n⁩o';
expect(sanitizeTelegramText(dirty)).toBe('abcdefghijklmno');
});
});
@@ -0,0 +1,260 @@
import { Inject, Injectable } from '@nestjs/common';
import {
describeTelegramFailure,
sanitizeTelegramDescription,
TelegramApiError,
} from './telegram-api-error';
import { TELEGRAM_TOKEN_PATTERN, type TelegramConfig } from './telegram-config';
export const TELEGRAM_CONFIG = Symbol('TELEGRAM_CONFIG');
export const TELEGRAM_SEND_TIMEOUT_MS = 10_000;
export const TELEGRAM_POLL_TIMEOUT_SEC = 25;
export const TELEGRAM_POLL_FETCH_TIMEOUT_MS = 35_000;
export const TELEGRAM_MAX_MESSAGE_CHARS = 600;
const MIN_RETRY_AFTER_SEC = 1;
const MAX_RETRY_AFTER_SEC = 3600;
const API_BASE = 'https://api.telegram.org';
const MAX_RESPONSE_BYTES = 256 * 1024;
const INVALID_RESPONSE = 'invalid response';
export interface TelegramUpdate {
updateId: bigint;
chatId: bigint;
chatType: string;
text: string | null;
firstName: string | null;
}
// eslint-disable-next-line no-control-regex
const CONTROL_CHARACTERS = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g;
// Zero-width characters, bidi marks, overrides and isolates: invisible, and
// they can make a text read differently from what it says.
const INVISIBLE_CHARACTERS = /[\u200b-\u200f\u202a-\u202e\u2066-\u2069]/g;
/**
* Plain text for a message: tabs and line breaks kept as space and newline,
* other control characters and invisible/bidi characters removed, at most
* 600 characters (cut by code point, never inside a surrogate pair).
*/
export function sanitizeTelegramText(text: string): string {
const characters = [
...text
.replace(/\r\n?/g, '\n')
.replace(/\t/g, ' ')
.replace(CONTROL_CHARACTERS, '')
.replace(INVISIBLE_CHARACTERS, ''),
];
return characters.length > TELEGRAM_MAX_MESSAGE_CHARS
? `${characters.slice(0, TELEGRAM_MAX_MESSAGE_CHARS - 1).join('')}…`
: characters.join('');
}
type JsonObject = Record<string, unknown>;
function asObject(value: unknown): JsonObject | null {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as JsonObject)
: null;
}
function asString(value: unknown): string | null {
return typeof value === 'string' ? value : null;
}
function asInteger(value: unknown): bigint | null {
return typeof value === 'number' && Number.isSafeInteger(value)
? BigInt(value)
: null;
}
/** Reads the body up to MAX_RESPONSE_BYTES; null if it is larger or unreadable. */
async function readLimitedText(response: Response): Promise<string | null> {
const reader = response.body?.getReader();
if (!reader) {
return '';
}
const chunks: Uint8Array[] = [];
let size = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) {
break;
}
size += value.byteLength;
if (size > MAX_RESPONSE_BYTES) {
await reader.cancel();
return null;
}
chunks.push(value);
}
return Buffer.concat(chunks).toString('utf8');
}
/** parameters.retry_after as whole seconds clamped to 1..3600; null if absent or not an integer. */
function retryAfterOf(parsed: JsonObject): number | null {
const seconds = asInteger(asObject(parsed.parameters)?.retry_after);
if (seconds === null) {
return null;
}
return Math.min(
MAX_RETRY_AFTER_SEC,
Math.max(MIN_RETRY_AFTER_SEC, Number(seconds)),
);
}
function parseUpdate(raw: unknown): TelegramUpdate | null {
const update = asObject(raw);
const updateId = asInteger(update?.update_id);
const message = asObject(update?.message);
const chat = asObject(message?.chat);
const chatId = asInteger(chat?.id);
const chatType = asString(chat?.type);
if (updateId === null || chatId === null || chatType === null) {
return null;
}
return {
updateId,
chatId,
chatType,
text: asString(message?.text),
firstName: asString(asObject(message?.from)?.first_name),
};
}
/**
* Telegram Bot API client. Used by the worker loops only (never from a
* request, handler or job; see no-network-outside-loops.spec). The request
* URL contains the token and stays inside this class: every failure becomes
* a TelegramApiError that carries no URL, and text taken from the answer is
* sanitized.
*/
@Injectable()
export class TelegramClient {
constructor(
@Inject(TELEGRAM_CONFIG) private readonly config: TelegramConfig,
) {}
/** Worker only: a token is present and well-formed. */
isConfigured(): boolean {
return (
this.config.token !== null &&
TELEGRAM_TOKEN_PATTERN.test(this.config.token)
);
}
/** Plain text, no parse_mode, link preview off. Throws TelegramApiError. */
async sendMessage(
chatId: bigint,
text: string,
signal?: AbortSignal,
): Promise<void> {
await this.call(
'sendMessage',
{
chat_id: this.jsonNumber('sendMessage', chatId),
text: sanitizeTelegramText(text),
link_preview_options: { is_disabled: true },
},
TELEGRAM_SEND_TIMEOUT_MS,
signal,
);
}
/** Long poll for messages from `offset` on. Throws TelegramApiError. */
async getUpdates(
offset: bigint,
signal?: AbortSignal,
): Promise<TelegramUpdate[]> {
const result = await this.call(
'getUpdates',
{
offset: this.jsonNumber('getUpdates', offset),
timeout: TELEGRAM_POLL_TIMEOUT_SEC,
allowed_updates: ['message'],
},
TELEGRAM_POLL_FETCH_TIMEOUT_MS,
signal,
);
if (!Array.isArray(result)) {
throw new TelegramApiError('getUpdates', null, INVALID_RESPONSE, null);
}
return result
.map(parseUpdate)
.filter((update): update is TelegramUpdate => update !== null);
}
private jsonNumber(method: string, value: bigint): number {
const number = Number(value);
if (!Number.isSafeInteger(number)) {
throw new TelegramApiError(method, null, 'id out of range', null);
}
return number;
}
private async call(
method: string,
body: JsonObject,
timeoutMs: number,
signal?: AbortSignal,
): Promise<unknown> {
const { token } = this.config;
if (token === null || !this.isConfigured()) {
throw new TelegramApiError(
method,
null,
'bot token not configured',
null,
);
}
const timeout = AbortSignal.timeout(timeoutMs);
let response: Response;
let text: string | null;
try {
response = await fetch(`${API_BASE}/bot${token}/${method}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
// The URL carries the token: never follow it somewhere else.
redirect: 'error',
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
});
text = await readLimitedText(response);
} catch (error) {
throw new TelegramApiError(
method,
null,
describeTelegramFailure(error),
null,
);
}
return this.interpret(method, response.status, text);
}
/** Reads only ok, description, parameters.retry_after and result. */
private interpret(
method: string,
status: number,
text: string | null,
): unknown {
let parsed: JsonObject | null = null;
try {
parsed = text === null ? null : asObject(JSON.parse(text));
} catch {
parsed = null;
}
if (parsed === null) {
throw new TelegramApiError(method, status, INVALID_RESPONSE, null);
}
if (status >= 200 && status < 300 && parsed.ok === true) {
return parsed.result;
}
throw new TelegramApiError(
method,
status,
sanitizeTelegramDescription(parsed.description),
retryAfterOf(parsed),
);
}
}
@@ -0,0 +1,134 @@
import {
assertTelegramToken,
telegramBotAvailable,
telegramConfig,
} from './telegram-config';
const TOKEN = '123456789:AAEhBOweik6ad9r_QXMENQjcrGbqCr4K-RM';
describe('telegramConfig', () => {
it('reads token and bot username, trimmed', () => {
expect(
telegramConfig({
TELEGRAM_BOT_TOKEN: ` ${TOKEN} `,
TELEGRAM_BOT_USERNAME: ' apicore_bot ',
}),
).toEqual({ token: TOKEN, botUsername: 'apicore_bot' });
});
it('maps empty and missing values to null', () => {
expect(
telegramConfig({ TELEGRAM_BOT_TOKEN: ' ', TELEGRAM_BOT_USERNAME: '' }),
).toEqual({ token: null, botUsername: null });
expect(telegramConfig({})).toEqual({ token: null, botUsername: null });
});
});
describe('telegramBotAvailable', () => {
it('is true with the username set and the token unset (API pod)', () => {
expect(telegramBotAvailable({ TELEGRAM_BOT_USERNAME: 'apicore_bot' })).toBe(
true,
);
});
it('is false with an empty or blank username, even with a token', () => {
expect(telegramBotAvailable({ TELEGRAM_BOT_USERNAME: ' ' })).toBe(false);
expect(telegramBotAvailable({ TELEGRAM_BOT_TOKEN: TOKEN })).toBe(false);
expect(telegramBotAvailable({})).toBe(false);
});
});
describe('telegramConfig bot username', () => {
it.each([
['@apicore_bot', 'apicore_bot'],
['ApiCore_Bot', 'ApiCore_Bot'],
['a'.repeat(32), 'a'.repeat(32)],
])('accepts %s', (raw, expected) => {
expect(telegramConfig({ TELEGRAM_BOT_USERNAME: raw }).botUsername).toBe(
expected,
);
});
it.each([
'abcd',
'1abcde',
'a'.repeat(33),
'api core bot',
'bot/../x',
'@@apicore_bot',
'https://t.me/x',
'_apicore',
])('treats %s as unset', (raw) => {
expect(
telegramConfig({ TELEGRAM_BOT_USERNAME: raw }).botUsername,
).toBeNull();
expect(telegramBotAvailable({ TELEGRAM_BOT_USERNAME: raw })).toBe(false);
});
});
describe('assertTelegramToken', () => {
const production = { NODE_ENV: 'production' };
let logger: { warn: jest.Mock; error: jest.Mock };
beforeEach(() => {
logger = { warn: jest.fn(), error: jest.fn() };
});
it('accepts a missing token in every role (Telegram is optional)', () => {
for (const role of ['api', 'worker', 'job', 'all'] as const) {
expect(() => assertTelegramToken(role, production, logger)).not.toThrow();
}
expect(logger.error).not.toHaveBeenCalled();
});
it('accepts a well-formed token in the worker without a log', () => {
assertTelegramToken(
'worker',
{
...production,
TELEGRAM_BOT_TOKEN: TOKEN,
TELEGRAM_BOT_USERNAME: 'apicore_bot',
},
logger,
);
expect(logger.error).not.toHaveBeenCalled();
expect(logger.warn).not.toHaveBeenCalled();
});
it.each([
['worker', 'not-a-token-SECRETPART', {}],
['all', 'not-a-token-SECRETPART', {}],
['worker', 'change-me-SECRETPART', production],
] as const)(
'the %s never crashes on a malformed token (%s): logs an error without the value',
(role, bad, extra) => {
expect(() =>
assertTelegramToken(
role,
{ ...extra, TELEGRAM_BOT_TOKEN: bad },
logger,
),
).not.toThrow();
expect(logger.error).toHaveBeenCalledTimes(1);
const logged = (logger.error.mock.calls as [string][])[0][0];
expect(logged).toMatch(/TELEGRAM_BOT_TOKEN/);
expect(logged).toMatch(/disabled/);
expect(logged).not.toContain('SECRETPART');
},
);
it('warns about an invalid bot username in every role', () => {
assertTelegramToken('api', { TELEGRAM_BOT_USERNAME: 'no good' }, logger);
expect(logger.warn).toHaveBeenCalledTimes(1);
expect((logger.warn.mock.calls as [string][])[0][0]).toMatch(
/TELEGRAM_BOT_USERNAME/,
);
});
it('the API pod and jobs ignore the token entirely (they never have one)', () => {
assertTelegramToken('api', { TELEGRAM_BOT_TOKEN: 'garbage' }, logger);
assertTelegramToken('job', { TELEGRAM_BOT_TOKEN: 'garbage' }, logger);
expect(logger.error).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,100 @@
import { Logger } from '@nestjs/common';
import type { ApicoreRole } from '../runtime/role';
/** Bot token format issued by BotFather: "<bot id>:<secret>". */
export const TELEGRAM_TOKEN_PATTERN = /^\d{5,}:[A-Za-z0-9_-]{30,}$/;
const PLACEHOLDER_PATTERN = /^change-me/i;
/** Bot usernames: 5-32 characters, letters, digits, underscore, starting with a letter. */
const BOT_USERNAME_PATTERN = /^[A-Za-z][A-Za-z0-9_]{4,31}$/;
const WORKER_ROLES: readonly ApicoreRole[] = ['worker', 'all'];
export interface TelegramConfig {
token: string | null;
botUsername: string | null;
}
function trimmed(value: string | undefined): string | null {
const text = value?.trim();
return text ? text : null;
}
/** The configured bot name without a leading "@"; null if unset or not a valid username. */
function validBotUsername(raw: string | undefined): string | null {
const name = trimmed(raw)?.replace(/^@/, '') ?? null;
return name !== null && BOT_USERNAME_PATTERN.test(name) ? name : null;
}
/**
* Token and bot name from the environment. Empty values count as unset, and
* so does a bot name that is not a valid username (it goes into a link).
*/
export function telegramConfig(
env: NodeJS.ProcessEnv = process.env,
): TelegramConfig {
return {
token: trimmed(env.TELEGRAM_BOT_TOKEN),
botUsername: validBotUsername(env.TELEGRAM_BOT_USERNAME),
};
}
/**
* "Telegram is set up" as the API sees it: the bot name is known. Never
* reads the token - the API pod does not have it (Secret apicore-telegram
* goes to the worker only). Whether updates really arrive shows
* TelegramPollState.
*/
export function telegramBotAvailable(
env: NodeJS.ProcessEnv = process.env,
): boolean {
return telegramConfig(env).botUsername !== null;
}
/**
* Startup check. Telegram is optional and a bad setting never stops a pod:
* a bot username that is not valid is ignored with a warning (every role);
* in the worker, a token that is malformed - or, in production, an example
* placeholder - is logged as an error and Telegram stays off (the client
* refuses to call out with it). Logs name the variable, never its value.
* API and job pods never hold the token and skip its check.
*/
export function assertTelegramToken(
role: ApicoreRole,
env: NodeJS.ProcessEnv = process.env,
logger: Pick<Logger, 'warn' | 'error'> = new Logger('TelegramStartup'),
): void {
const { token, botUsername } = telegramConfig(env);
if (botUsername === null && trimmed(env.TELEGRAM_BOT_USERNAME) !== null) {
logger.warn(
'TELEGRAM_BOT_USERNAME is not a valid bot username (5-32 letters, digits or underscores): ignored, users cannot connect.',
);
}
if (!WORKER_ROLES.includes(role)) {
return;
}
if (token === null) {
if (botUsername !== null) {
logger.warn(
'TELEGRAM_BOT_USERNAME is set but TELEGRAM_BOT_TOKEN is not: Telegram delivery and receiving stay disabled.',
);
}
return;
}
if (env.NODE_ENV === 'production' && PLACEHOLDER_PATTERN.test(token)) {
logger.error(
'TELEGRAM_BOT_TOKEN is still set to an example placeholder: Telegram stays disabled - set the real token from BotFather or remove it.',
);
return;
}
if (!TELEGRAM_TOKEN_PATTERN.test(token)) {
logger.error(
'TELEGRAM_BOT_TOKEN is malformed (expected "<bot id>:<secret>" as issued by BotFather): Telegram stays disabled.',
);
return;
}
if (botUsername === null) {
logger.warn(
'TELEGRAM_BOT_TOKEN is set but TELEGRAM_BOT_USERNAME is not: users cannot connect, replies still work.',
);
}
}
@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { telegramConfig } from './telegram-config';
import { TELEGRAM_CONFIG, TelegramClient } from './telegram-client';
/**
* The Bot API client for the worker's Telegram loops. Reads the token from
* the environment once; without it the client reports isConfigured() false
* and the loops do not run.
*/
@Module({
providers: [
{ provide: TELEGRAM_CONFIG, useFactory: () => telegramConfig() },
TelegramClient,
],
exports: [TelegramClient],
})
export class TelegramModule {}
@@ -0,0 +1,30 @@
import { abortableDelay } from './abortable-delay';
describe('abortableDelay', () => {
beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());
it('resolves after the delay', async () => {
const done = jest.fn();
void abortableDelay(300, new AbortController().signal).then(done);
await jest.advanceTimersByTimeAsync(299);
expect(done).not.toHaveBeenCalled();
await jest.advanceTimersByTimeAsync(1);
expect(done).toHaveBeenCalledTimes(1);
});
it('resolves at once when the signal aborts, and when it is already aborted', async () => {
const controller = new AbortController();
const done = jest.fn();
void abortableDelay(60_000, controller.signal).then(done);
controller.abort();
await jest.advanceTimersByTimeAsync(0);
expect(done).toHaveBeenCalledTimes(1);
expect(jest.getTimerCount()).toBe(0);
await expect(
abortableDelay(60_000, controller.signal),
).resolves.toBeUndefined();
});
});
@@ -0,0 +1,13 @@
/** Resolves after `ms`, or early (without error) when the signal aborts. */
export function abortableDelay(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve) => {
if (signal.aborted) return resolve();
const done = () => {
clearTimeout(timer);
signal.removeEventListener('abort', done);
resolve();
};
const timer = setTimeout(done, ms);
signal.addEventListener('abort', done, { once: true });
});
}
@@ -0,0 +1,339 @@
import { addDays, berlinToday } from '../../common/dates';
import {
RECENT_WINDOW_DAYS,
WEATHER_FETCH_TIMEOUT_MS,
WEATHER_MAX_RETRY_AFTER_MS,
WEATHER_MAX_RESPONSE_BYTES,
WeatherHistoryConfig,
} from './weather-history.config';
import {
OpenMeteoHistorySource,
WeatherSourceError,
} from './open-meteo-history.source';
const RECENT = { window: 'recent' } as const;
const ARCHIVE = { window: 'archive' } as const;
const SECRET = 'topsecretkey';
const POINT = { lat: 52.52, lon: 13.4 };
function config(apiKey: string | null = null): WeatherHistoryConfig {
return {
source: 'open-meteo',
forecastUrl: new URL('https://forecast.example/v1/forecast'),
archiveUrl: new URL('https://archive.example/v1/archive'),
apiKey,
userAgent: 'ApiCore/test (self-hosted beekeeping app)',
};
}
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { status });
}
describe('OpenMeteoHistorySource', () => {
let fetchMock: jest.SpiedFunction<typeof fetch>;
const today = berlinToday();
const recentFrom = addDays(today, -10);
const recentTo = addDays(today, -8);
const oldFrom = addDays(today, -RECENT_WINDOW_DAYS - 20);
const oldTo = addDays(today, -RECENT_WINDOW_DAYS - 18);
beforeEach(() => {
fetchMock = jest.spyOn(global, 'fetch');
});
afterEach(() => {
jest.restoreAllMocks();
});
const requestedUrl = (): URL => fetchMock.mock.calls[0][0] as URL;
it('reads recent days from the forecast endpoint with past_days=92', async () => {
fetchMock.mockResolvedValue(
json({
daily: {
time: [addDays(today, -11), recentFrom, addDays(today, -9), recentTo],
temperature_2m_mean: [1, 8.5, 9, 10],
},
}),
);
const source = new OpenMeteoHistorySource(config());
const result = await source.dailyMeans(POINT, recentFrom, recentTo, RECENT);
expect(source.name).toBe('open-meteo');
const url = requestedUrl();
expect(url.origin).toBe('https://forecast.example');
expect(url.searchParams.get('latitude')).toBe('52.52');
expect(url.searchParams.get('longitude')).toBe('13.40');
expect(url.searchParams.get('daily')).toBe('temperature_2m_mean');
expect(url.searchParams.get('timezone')).toBe('Europe/Berlin');
expect(url.searchParams.get('past_days')).toBe('92');
expect(url.searchParams.has('apikey')).toBe(false);
// Only the requested range comes back.
expect(result).toEqual([
{ day: recentFrom, meanTempC: 8.5 },
{ day: addDays(today, -9), meanTempC: 9 },
{ day: recentTo, meanTempC: 10 },
]);
});
it('reads older days from the archive endpoint with start_date and end_date', async () => {
fetchMock.mockResolvedValue(
json({ daily: { time: [oldFrom], temperature_2m_mean: [3] } }),
);
await new OpenMeteoHistorySource(config()).dailyMeans(
POINT,
oldFrom,
oldTo,
ARCHIVE,
);
const url = requestedUrl();
expect(url.origin).toBe('https://archive.example');
expect(url.searchParams.get('start_date')).toBe(oldFrom);
expect(url.searchParams.get('end_date')).toBe(oldTo);
expect(url.searchParams.has('past_days')).toBe(false);
});
it('sends apikey only when one is configured', async () => {
fetchMock.mockResolvedValue(
json({ daily: { time: [], temperature_2m_mean: [] } }),
);
await new OpenMeteoHistorySource(config(SECRET)).dailyMeans(
POINT,
recentFrom,
recentTo,
RECENT,
);
expect(requestedUrl().searchParams.get('apikey')).toBe(SECRET);
});
it('maps values outside -50..50 °C and non-numbers to null', async () => {
const days = [0, 1, 2, 3, 4, 5].map((n) => addDays(recentFrom, n));
fetchMock.mockResolvedValue(
json({
daily: {
time: days,
temperature_2m_mean: [-50, 50, -50.1, 50.1, null, '12'],
},
}),
);
const result = await new OpenMeteoHistorySource(config()).dailyMeans(
POINT,
recentFrom,
days[5],
RECENT,
);
expect(result.map((entry) => entry.meanTempC)).toEqual([
-50,
50,
null,
null,
null,
null,
]);
});
it('never puts the API key or the URL into an error message', async () => {
const source = new OpenMeteoHistorySource(config(SECRET));
const failures: unknown[] = [];
const attempt = async () =>
source
.dailyMeans(POINT, recentFrom, recentTo, RECENT)
.catch((e: unknown) => e);
fetchMock.mockRejectedValueOnce(
new Error(`fetch failed https://forecast.example/?apikey=${SECRET}`),
);
failures.push(await attempt());
fetchMock.mockResolvedValueOnce(new Response('x', { status: 500 }));
failures.push(await attempt());
fetchMock.mockResolvedValueOnce(new Response('not json', { status: 200 }));
failures.push(await attempt());
fetchMock.mockResolvedValueOnce(json({ daily: { time: 'x' } }));
failures.push(await attempt());
for (const failure of failures) {
expect(failure).toBeInstanceOf(Error);
const { message, stack, cause } = failure as Error;
expect(message).not.toContain(SECRET);
expect(message).not.toContain('forecast.example');
expect(stack ?? '').not.toContain(SECRET);
expect(cause).toBeUndefined();
}
expect((failures[1] as Error).message).toContain('500');
});
it('stops reading after 1 000 000 bytes', async () => {
let pulls = 0;
let cancelled = false;
const chunk = new Uint8Array(100_000).fill(32);
const body = new ReadableStream<Uint8Array>({
pull(controller) {
pulls += 1;
controller.enqueue(chunk);
},
cancel() {
cancelled = true;
},
});
fetchMock.mockResolvedValue(new Response(body, { status: 200 }));
await expect(
new OpenMeteoHistorySource(config()).dailyMeans(
POINT,
recentFrom,
recentTo,
RECENT,
),
).rejects.toThrow('zu groß');
expect(cancelled).toBe(true);
// 10 chunks reach the limit, the 11th exceeds it; the stream is not drained.
expect(pulls).toBeLessThanOrEqual(
Math.ceil(WEATHER_MAX_RESPONSE_BYTES / 100_000) + 3,
);
});
it('aborts after 8 s and when the caller aborts', async () => {
const timeout = new AbortController();
const timeoutSpy = jest
.spyOn(AbortSignal, 'timeout')
.mockReturnValue(timeout.signal);
fetchMock.mockImplementation(
(_url, init) =>
new Promise((_resolve, reject) => {
init?.signal?.addEventListener('abort', () =>
reject(new Error('aborted')),
);
}),
);
const source = new OpenMeteoHistorySource(config(SECRET));
const timedOut = source.dailyMeans(POINT, recentFrom, recentTo, RECENT);
expect(timeoutSpy).toHaveBeenCalledWith(WEATHER_FETCH_TIMEOUT_MS);
timeout.abort();
await expect(timedOut).rejects.toThrow('Zeitüberschreitung');
const caller = new AbortController();
timeoutSpy.mockReturnValue(new AbortController().signal);
const cancelled = source.dailyMeans(POINT, recentFrom, recentTo, {
window: 'recent',
signal: caller.signal,
});
caller.abort();
await expect(cancelled).rejects.toThrow('Zeitüberschreitung');
});
it('does not follow redirects (the key travels in the query)', async () => {
fetchMock.mockResolvedValue(
json({ daily: { time: [], temperature_2m_mean: [] } }),
);
await new OpenMeteoHistorySource(config(SECRET)).dailyMeans(
POINT,
recentFrom,
recentTo,
RECENT,
);
expect(fetchMock.mock.calls[0][1]).toEqual(
expect.objectContaining({ redirect: 'error' }),
);
});
it('sends the configured User-Agent', async () => {
fetchMock.mockResolvedValue(
json({ daily: { time: [], temperature_2m_mean: [] } }),
);
await new OpenMeteoHistorySource(config()).dailyMeans(
POINT,
recentFrom,
recentTo,
RECENT,
);
expect(fetchMock.mock.calls[0][1]).toEqual(
expect.objectContaining({
headers: { 'User-Agent': 'ApiCore/test (self-hosted beekeeping app)' },
}),
);
});
it('reports status and Retry-After (seconds or date, capped) of a 429', async () => {
const source = new OpenMeteoHistorySource(config(SECRET));
fetchMock.mockResolvedValueOnce(
new Response('', { status: 429, headers: { 'Retry-After': '120' } }),
);
const bySeconds = (await source
.dailyMeans(POINT, recentFrom, recentTo, RECENT)
.catch((e: unknown) => e)) as WeatherSourceError;
expect(bySeconds).toBeInstanceOf(WeatherSourceError);
expect(bySeconds.status).toBe(429);
expect(bySeconds.retryAfterMs).toBe(120_000);
fetchMock.mockResolvedValueOnce(
new Response('', {
status: 429,
headers: { 'Retry-After': new Date(Date.now() + 60_000).toUTCString() },
}),
);
const byDate = (await source
.dailyMeans(POINT, recentFrom, recentTo, RECENT)
.catch((e: unknown) => e)) as WeatherSourceError;
expect(byDate.retryAfterMs).toBeGreaterThan(50_000);
expect(byDate.retryAfterMs).toBeLessThanOrEqual(61_000);
fetchMock.mockResolvedValueOnce(
new Response('', { status: 429, headers: { 'Retry-After': '99999999' } }),
);
const capped = (await source
.dailyMeans(POINT, recentFrom, recentTo, RECENT)
.catch((e: unknown) => e)) as WeatherSourceError;
expect(capped.retryAfterMs).toBe(WEATHER_MAX_RETRY_AFTER_MS);
fetchMock.mockResolvedValueOnce(new Response('', { status: 503 }));
const none = (await source
.dailyMeans(POINT, recentFrom, recentTo, RECENT)
.catch((e: unknown) => e)) as WeatherSourceError;
expect(none.retryAfterMs).toBeUndefined();
});
it('names a refused redirect "Weiterleitung"', async () => {
fetchMock.mockRejectedValueOnce(
new TypeError('fetch failed', {
cause: new Error('unexpected redirect to https://x.example/?apikey=1'),
}),
);
const error = (await new OpenMeteoHistorySource(config(SECRET))
.dailyMeans(POINT, recentFrom, recentTo, RECENT)
.catch((e: unknown) => e)) as WeatherSourceError;
expect(error.message).toContain('Weiterleitung');
expect(error.message).not.toContain('x.example');
});
// Daily values are plain calendar days: the 23 h and 25 h days of the
// clock changes (last Sundays of March and October) are days like any other.
it.each([
['2026-03-28', '2026-03-30'],
['2026-10-24', '2026-10-26'],
])('keeps every day around the clock change %s..%s', async (from, to) => {
const time = [from, addDays(from, 1), to];
fetchMock.mockResolvedValue(
json({ daily: { time, temperature_2m_mean: [1, 2, 3] } }),
);
const result = await new OpenMeteoHistorySource(config()).dailyMeans(
POINT,
from,
to,
ARCHIVE,
);
expect(result).toEqual([
{ day: from, meanTempC: 1 },
{ day: time[1], meanTempC: 2 },
{ day: to, meanTempC: 3 },
]);
expect(addDays(from, 1)).toBe(time[1]);
});
});
@@ -0,0 +1,192 @@
import { dayToDate } from '../../common/dates';
import type { DailyMean } from '../../season/season-types';
import {
FORECAST_PAST_DAYS,
WEATHER_MAX_RETRY_AFTER_MS,
WEATHER_FETCH_TIMEOUT_MS,
WEATHER_MAX_RESPONSE_BYTES,
WeatherHistoryConfig,
} from './weather-history.config';
import type {
DailyMeansOptions,
GeoPoint,
WeatherHistorySource,
} from './weather-history-source';
const MIN_PLAUSIBLE_TEMP_C = -50;
const MAX_PLAUSIBLE_TEMP_C = 50;
/**
* Every failure of this adapter. The message is fixed text plus an HTTP
* status: no URL (it carries the API key as a query parameter), no cause
* (a fetch error names the host). `status` and `retryAfterMs` let the caller
* back off.
*/
export class WeatherSourceError extends Error {
constructor(
readonly reason: string,
readonly status?: number,
readonly retryAfterMs?: number,
) {
super(`Wetterquelle open-meteo: ${reason}`);
this.name = 'WeatherSourceError';
}
}
/** Retry-After as seconds or HTTP date; capped; undefined when absent or unreadable. */
function retryAfterMs(
header: string | null,
now = Date.now(),
): number | undefined {
if (!header) return undefined;
const value = header.trim();
let ms: number;
if (/^\d+$/.test(value)) {
ms = Number(value) * 1000;
} else {
const at = Date.parse(value);
if (Number.isNaN(at)) return undefined;
ms = at - now;
}
return Math.min(Math.max(ms, 0), WEATHER_MAX_RETRY_AFTER_MS);
}
/** undici reports a refused redirect as TypeError("fetch failed") with the cause. */
function isRedirectError(error: unknown): boolean {
if (!(error instanceof Error)) return false;
const { cause } = error;
const causeMessage = cause instanceof Error ? cause.message : '';
return /redirect/i.test(`${error.message} ${causeMessage}`);
}
interface DailyPayload {
time: unknown[];
values: unknown[];
}
function temperature(value: unknown): number | null {
return typeof value === 'number' &&
Number.isFinite(value) &&
value >= MIN_PLAUSIBLE_TEMP_C &&
value <= MAX_PLAUSIBLE_TEMP_C
? value
: null;
}
function parseDaily(text: string): DailyPayload {
let body: unknown;
try {
body = JSON.parse(text);
} catch {
throw new WeatherSourceError('Antwort ist kein JSON');
}
const daily = (body as { daily?: unknown } | null)?.daily as
{ time?: unknown; temperature_2m_mean?: unknown } | undefined;
if (
!daily ||
!Array.isArray(daily.time) ||
!Array.isArray(daily.temperature_2m_mean)
) {
throw new WeatherSourceError('Antwort hat ein unerwartetes Format');
}
return { time: daily.time, values: daily.temperature_2m_mean };
}
/** Reads the body up to the byte limit; a bigger answer is cut off, not buffered. */
async function readLimited(response: Response): Promise<string> {
const reader = response.body?.getReader();
if (!reader) {
throw new WeatherSourceError('Antwort ohne Inhalt');
}
const chunks: Uint8Array[] = [];
let received = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
received += value.byteLength;
if (received > WEATHER_MAX_RESPONSE_BYTES) {
await reader.cancel().catch(() => undefined);
throw new WeatherSourceError('Antwort ist zu groß');
}
chunks.push(value);
}
return Buffer.concat(chunks).toString('utf8');
}
/**
* Daily mean temperatures from the free Open-Meteo API (forecast endpoint
* for the last 90 days, archive endpoint for older ones). The owner accepted
* its non-commercial terms; the adapter interface keeps a switch cheap.
*/
export class OpenMeteoHistorySource implements WeatherHistorySource {
readonly name = 'open-meteo';
constructor(private readonly config: WeatherHistoryConfig) {}
async dailyMeans(
point: GeoPoint,
fromDay: string,
toDay: string,
{ window, signal }: DailyMeansOptions,
): Promise<DailyMean[]> {
dayToDate(fromDay);
dayToDate(toDay);
const recent = window === 'recent';
const url = new URL(
recent ? this.config.forecastUrl : this.config.archiveUrl,
);
url.searchParams.set('latitude', point.lat.toFixed(2));
url.searchParams.set('longitude', point.lon.toFixed(2));
url.searchParams.set('daily', 'temperature_2m_mean');
url.searchParams.set('timezone', 'Europe/Berlin');
if (recent) {
url.searchParams.set('past_days', String(FORECAST_PAST_DAYS));
url.searchParams.set('forecast_days', '1');
} else {
url.searchParams.set('start_date', fromDay);
url.searchParams.set('end_date', toDay);
}
if (this.config.apiKey) {
url.searchParams.set('apikey', this.config.apiKey);
}
const payload = parseDaily(await this.fetchText(url, signal));
const means: DailyMean[] = [];
payload.time.forEach((day, index) => {
if (typeof day === 'string' && day >= fromDay && day <= toDay) {
means.push({ day, meanTempC: temperature(payload.values[index]) });
}
});
return means;
}
private async fetchText(url: URL, signal?: AbortSignal): Promise<string> {
const timeout = AbortSignal.timeout(WEATHER_FETCH_TIMEOUT_MS);
const combined = signal ? AbortSignal.any([signal, timeout]) : timeout;
try {
// No redirects: the key travels in the query string.
const response = await fetch(url, {
signal: combined,
redirect: 'error',
headers: { 'User-Agent': this.config.userAgent },
});
if (!response.ok) {
await response.body?.cancel().catch(() => undefined);
throw new WeatherSourceError(
`HTTP ${response.status}`,
response.status,
retryAfterMs(response.headers.get('Retry-After')),
);
}
return await readLimited(response);
} catch (error: unknown) {
if (error instanceof WeatherSourceError) throw error;
if (combined.aborted) {
throw new WeatherSourceError('Zeitüberschreitung oder Abbruch');
}
throw new WeatherSourceError(
isRedirectError(error) ? 'Weiterleitung' : 'Netzwerkfehler',
);
}
}
}
@@ -0,0 +1,41 @@
import type { DailyMean } from '../../season/season-types';
/** A place, rounded to 0.01 degrees (roundPoint) before it leaves the app. */
export interface GeoPoint {
lat: number;
lon: number;
}
/**
* Where daily mean temperatures come from. The phenology feature depends on
* this interface only, so the free Open-Meteo API can be swapped for another
* adapter (WEATHER_HISTORY_SOURCE) without touching the callers.
*/
/**
* Which endpoint family serves a range: `recent` (the last 90 days, forecast
* endpoint with past days) or `archive`. The caller decides, so the adapter
* needs no clock.
*/
export type HistoryWindow = 'recent' | 'archive';
export interface DailyMeansOptions {
window: HistoryWindow;
signal?: AbortSignal;
}
export interface WeatherHistorySource {
readonly name: string;
/**
* Daily means from `fromDay` to `toDay` (both YYYY-MM-DD, Europe/Berlin,
* inclusive). Throws on any failure; the message never carries a URL or
* a key.
*/
dailyMeans(
point: GeoPoint,
fromDay: string,
toDay: string,
options: DailyMeansOptions,
): Promise<DailyMean[]>;
}
export const WEATHER_HISTORY_SOURCE = Symbol('WEATHER_HISTORY_SOURCE');
@@ -0,0 +1,117 @@
import {
FORECAST_PAST_DAYS,
RECENT_WINDOW_DAYS,
roundPoint,
WEATHER_FETCH_TIMEOUT_MS,
WEATHER_FIRST_TICK_DELAY_MS,
WEATHER_HISTORY_POLL_MS,
WEATHER_MAX_RESPONSE_BYTES,
WEATHER_RETENTION_YEARS,
weatherHistoryConfig,
} from './weather-history.config';
describe('weatherHistoryConfig', () => {
it('keeps the documented constants', () => {
expect(WEATHER_HISTORY_POLL_MS).toBe(3_600_000);
expect(WEATHER_FETCH_TIMEOUT_MS).toBe(8_000);
expect(WEATHER_MAX_RESPONSE_BYTES).toBe(1_000_000);
expect(FORECAST_PAST_DAYS).toBe(92);
expect(RECENT_WINDOW_DAYS).toBe(90);
expect(WEATHER_RETENTION_YEARS).toBe(4);
expect(WEATHER_FIRST_TICK_DELAY_MS).toBe(10_000);
});
it('selects open-meteo by WEATHER_HISTORY_SOURCE and refuses http: URLs', () => {
const config = weatherHistoryConfig({});
expect(config.source).toBe('open-meteo');
expect(config.forecastUrl.href).toBe(
'https://api.open-meteo.com/v1/forecast',
);
expect(config.archiveUrl.href).toBe(
'https://archive-api.open-meteo.com/v1/archive',
);
expect(config.apiKey).toBeNull();
expect(
weatherHistoryConfig({ WEATHER_HISTORY_SOURCE: 'open-meteo' }).source,
).toBe('open-meteo');
expect(() =>
weatherHistoryConfig({ WEATHER_HISTORY_SOURCE: 'dwd' }),
).toThrow('WEATHER_HISTORY_SOURCE');
expect(() =>
weatherHistoryConfig({
OPEN_METEO_FORECAST_URL: 'http://api.open-meteo.com/v1/forecast',
}),
).toThrow('OPEN_METEO_FORECAST_URL');
expect(() =>
weatherHistoryConfig({ OPEN_METEO_ARCHIVE_URL: 'ftp://example.com/x' }),
).toThrow('OPEN_METEO_ARCHIVE_URL');
expect(() =>
weatherHistoryConfig({ OPEN_METEO_ARCHIVE_URL: 'not a url' }),
).toThrow('OPEN_METEO_ARCHIVE_URL');
});
it('takes URLs and key from the environment; an empty key is none', () => {
const config = weatherHistoryConfig({
OPEN_METEO_FORECAST_URL: 'https://weather.example/forecast',
OPEN_METEO_API_KEY: ' secret-key ',
});
expect(config.forecastUrl.href).toBe('https://weather.example/forecast');
expect(config.apiKey).toBe('secret-key');
expect(
weatherHistoryConfig({ OPEN_METEO_API_KEY: ' ' }).apiKey,
).toBeNull();
});
it('never puts the API key or the URL into an error message', () => {
let message = '';
try {
weatherHistoryConfig({
OPEN_METEO_API_KEY: 'topsecretkey',
OPEN_METEO_FORECAST_URL: 'http://user:[email protected]/forecast?x=1',
});
} catch (error) {
message = (error as Error).message;
}
expect(message).not.toBe('');
expect(message).not.toContain('topsecretkey');
expect(message).not.toContain('evil.example');
expect(message).not.toContain('user:pw');
});
});
describe('weatherUserAgent', () => {
it('names the app and version, with the contact when configured', () => {
expect(weatherHistoryConfig({}).userAgent).toMatch(
/^ApiCore\/\S+ \(self-hosted beekeeping app\)$/,
);
expect(
weatherHistoryConfig({
WEATHER_USER_AGENT_CONTACT: '[email protected]',
}).userAgent,
).toMatch(/^ApiCore\/\S+ \(\+imker@example\.org\)$/);
});
it('strips control characters from the contact (header injection)', () => {
const { userAgent } = weatherHistoryConfig({
WEATHER_USER_AGENT_CONTACT: '[email protected]\r\nX-Evil: 1',
});
expect(userAgent).not.toMatch(/[\r\n]/);
});
});
describe('roundPoint', () => {
it('rounds to 0.01 degrees', () => {
expect(roundPoint(52.5234, 13.4049)).toEqual({ lat: 52.52, lon: 13.4 });
expect(roundPoint(-33.8688, 151.2093)).toEqual({
lat: -33.87,
lon: 151.21,
});
});
it('never yields negative zero', () => {
const point = roundPoint(-0.004, -0.001);
expect(Object.is(point.lat, 0)).toBe(true);
expect(Object.is(point.lon, 0)).toBe(true);
});
});
@@ -0,0 +1,123 @@
import { existsSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { addDays } from '../../common/dates';
import type { GeoPoint } from './weather-history-source';
export const WEATHER_HISTORY_POLL_MS = 3_600_000;
export const WEATHER_FETCH_TIMEOUT_MS = 8_000;
export const WEATHER_MAX_RESPONSE_BYTES = 1_000_000;
/** The forecast endpoint reaches this far back (past_days). */
export const FORECAST_PAST_DAYS = 92;
/** Days up to this age come from the forecast endpoint, older ones from the archive. */
export const RECENT_WINDOW_DAYS = 90;
export const WEATHER_RETENTION_YEARS = 4;
/** Pause between two points of one pass: no burst against the free API. */
export const WEATHER_POINT_DELAY_MS = 300;
/** This many failed points in a row end the pass; the next hourly tick retries. */
export const WEATHER_MAX_CONSECUTIVE_FAILURES = 3;
/** Upserts per transaction. */
export const WEATHER_WRITE_CHUNK_SIZE = 200;
/** An old forecast row is offered to the archive again after this long. */
export const FORECAST_RECHECK_MS = 7 * 24 * 3_600_000;
/** The first pass waits this long after the worker started. */
export const WEATHER_FIRST_TICK_DELAY_MS = 10_000;
/** A Retry-After longer than this is capped. */
export const WEATHER_MAX_RETRY_AFTER_MS = 24 * 3_600_000;
const DEFAULT_SOURCE = 'open-meteo';
const DEFAULT_FORECAST_URL = 'https://api.open-meteo.com/v1/forecast';
const DEFAULT_ARCHIVE_URL = 'https://archive-api.open-meteo.com/v1/archive';
export interface WeatherHistoryConfig {
source: 'open-meteo';
forecastUrl: URL;
archiveUrl: URL;
apiKey: string | null;
/** Identifies the app (and, if configured, a contact) to the provider. */
userAgent: string;
}
/**
* Only https: URLs, only from the configuration. Errors name the variable,
* never its value (a URL can carry credentials).
*/
function httpsUrl(env: NodeJS.ProcessEnv, name: string, fallback: string): URL {
const raw = env[name]?.trim() || fallback;
let url: URL;
try {
url = new URL(raw);
} catch {
throw new Error(`${name} ist keine gültige URL.`);
}
if (url.protocol !== 'https:') {
throw new Error(`${name} muss eine https:-URL sein.`);
}
return url;
}
function appVersion(): string {
let dir = __dirname;
for (let level = 0; level < 6; level += 1) {
const file = join(dir, 'package.json');
if (existsSync(file)) {
try {
const { version } = JSON.parse(readFileSync(file, 'utf8')) as {
version?: unknown;
};
if (typeof version === 'string') return version;
} catch {
return 'unknown';
}
}
dir = dirname(dir);
}
return 'unknown';
}
/** Printable ASCII only: the value ends up in a request header. */
function headerSafe(value: string): string {
return value.replace(/[^ -~]/g, '').trim();
}
export function weatherUserAgent(env: NodeJS.ProcessEnv): string {
const contact = headerSafe(env.WEATHER_USER_AGENT_CONTACT ?? '');
return `ApiCore/${appVersion()} (${contact ? `+${contact}` : 'self-hosted beekeeping app'})`;
}
/** Read once at startup (module factory, WeatherService); throws on bad settings. */
export function weatherHistoryConfig(
env: NodeJS.ProcessEnv = process.env,
): WeatherHistoryConfig {
const source = env.WEATHER_HISTORY_SOURCE?.trim() || DEFAULT_SOURCE;
if (source !== DEFAULT_SOURCE) {
throw new Error(
`WEATHER_HISTORY_SOURCE: unbekannte Wetterquelle (erlaubt: ${DEFAULT_SOURCE}).`,
);
}
return {
source,
forecastUrl: httpsUrl(env, 'OPEN_METEO_FORECAST_URL', DEFAULT_FORECAST_URL),
archiveUrl: httpsUrl(env, 'OPEN_METEO_ARCHIVE_URL', DEFAULT_ARCHIVE_URL),
apiKey: env.OPEN_METEO_API_KEY?.trim() || null,
userAgent: weatherUserAgent(env),
};
}
/** Coordinates at 0.01 degrees (about 1 km): enough for a daily mean, and no exact address leaves the app. */
export function roundPoint(lat: number, lon: number): GeoPoint {
// "+ 0" turns -0 into 0.
return {
lat: Math.round(lat * 100) / 100 + 0,
lon: Math.round(lon * 100) / 100 + 0,
};
}
/** First day of the window served by the forecast endpoint. */
export function recentWindowStart(today: string): string {
return addDays(today, -RECENT_WINDOW_DAYS);
}
/** True when a range starting at `fromDay` is read from the forecast endpoint. */
export function isRecentRange(fromDay: string, today: string): boolean {
return fromDay >= recentWindowStart(today);
}
@@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
import { OpenMeteoHistorySource } from './open-meteo-history.source';
import { weatherHistoryConfig } from './weather-history.config';
import { WeatherHistoryService } from './weather-history.service';
import { WEATHER_HISTORY_SOURCE } from './weather-history-source';
/**
* Daily mean temperatures for the phenology. Imported by WorkerRuntimeModule
* only (the loop `phenology-weather`); the API never calls the source. The
* factory reads WEATHER_HISTORY_SOURCE and the URLs once at startup: an
* unknown source or an http: URL stops the process.
*/
@Module({
providers: [
{
provide: WEATHER_HISTORY_SOURCE,
useFactory: () => new OpenMeteoHistorySource(weatherHistoryConfig()),
},
WeatherHistoryService,
],
exports: [WeatherHistoryService],
})
export class WeatherHistoryModule {}
@@ -0,0 +1,469 @@
import { addDays, dayToDate } from '../../common/dates';
import type { PrismaService } from '../../prisma/prisma.service';
import { RECENT_WINDOW_DAYS } from './weather-history.config';
import type { WeatherHistorySource } from './weather-history-source';
import { abortableDelay } from './abortable-delay';
import { WeatherSourceError } from './open-meteo-history.source';
import { WeatherHistoryService } from './weather-history.service';
interface ApiaryRow {
id: string;
latitude: number;
longitude: number;
}
interface DayRow {
apiaryId: string;
day: Date;
meanTempC: number | null;
source: string;
fetchedAt?: Date;
}
interface Upsert {
create: {
apiaryId: string;
day: Date;
meanTempC: number | null;
source: string;
};
}
const dayKey = (date: Date): string => date.toISOString().slice(0, 10);
jest.mock('./abortable-delay', () => ({
abortableDelay: jest.fn(() => Promise.resolve()),
}));
describe('WeatherHistoryService', () => {
const today = '2026-05-20';
const yesterday = '2026-05-19';
const recentStart = addDays(today, -RECENT_WINDOW_DAYS);
const signal = new AbortController().signal;
let apiaries: ApiaryRow[];
let existing: DayRow[];
let dailyMeans: jest.Mock;
let apiaryFindMany: jest.Mock;
let upserts: Upsert[];
let transactionSizes: number[];
let touches: unknown[];
let service: WeatherHistoryService;
beforeEach(() => {
apiaries = [];
existing = [];
upserts = [];
(abortableDelay as jest.Mock).mockClear();
transactionSizes = [];
touches = [];
dailyMeans = jest
.fn()
.mockImplementation((_point: unknown, from: string, to: string) => {
const out: { day: string; meanTempC: number | null }[] = [];
for (let d = from; d <= to; d = addDays(d, 1)) {
out.push({ day: d, meanTempC: 5 });
}
return Promise.resolve(out);
});
apiaryFindMany = jest.fn(() => Promise.resolve(apiaries));
const prisma = {
apiary: { findMany: apiaryFindMany },
apiaryWeatherDay: {
findMany: jest.fn(() =>
Promise.resolve(
existing.map((row) => ({ fetchedAt: new Date(0), ...row })),
),
),
update: jest.fn((args: unknown) => {
touches.push(args);
return Promise.resolve(args);
}),
upsert: jest.fn((args: Upsert) => {
upserts.push(args);
return Promise.resolve(args);
}),
},
$transaction: jest.fn((ops: Promise<unknown>[]) => {
transactionSizes.push(ops.length);
return Promise.all(ops);
}),
};
const source: WeatherHistorySource = { name: 'fake', dailyMeans };
service = new WeatherHistoryService(
prisma as unknown as PrismaService,
source,
);
});
it('apiaries on the same rounded point share one request', async () => {
apiaries = [
{ id: 'a', latitude: 52.5234, longitude: 13.4049 },
{ id: 'b', latitude: 52.5199, longitude: 13.3951 },
{ id: 'c', latitude: 48.1, longitude: 11.5 },
];
const result = await service.syncOnce(today, signal);
// Two points; each needs an archive range and a forecast range.
expect(result.points).toBe(2);
expect(result.failedPoints).toBe(0);
const forLat = (lat: number) =>
dailyMeans.mock.calls.filter(
([point]) => (point as { lat: number }).lat === lat,
) as [
{ lat: number; lon: number },
string,
string,
{ window: string; signal: AbortSignal },
][];
expect(forLat(52.52)).toHaveLength(2);
expect(forLat(48.1)).toHaveLength(2);
const [point, from, to, options] = forLat(52.52)[0];
expect(point).toEqual({ lat: 52.52, lon: 13.4 });
expect([from, to]).toEqual(['2026-01-01', addDays(recentStart, -1)]);
expect(options).toEqual({ window: 'archive', signal });
expect(forLat(52.52)[1][3].window).toBe('recent');
expect(forLat(52.52).map(([, f, t]) => [f, t])).toContainEqual([
recentStart,
yesterday,
]);
// Both apiaries of the shared point got their rows (Jan 1 .. May 19).
const rowsOf = (id: string) =>
upserts.filter((u) => u.create.apiaryId === id);
expect(rowsOf('a')).toHaveLength(139);
expect(rowsOf('b')).toHaveLength(139);
expect(result.daysWritten).toBe(139 * 3);
});
it('fetches only missing days and labels the source by window', async () => {
apiaries = [{ id: 'a', latitude: 52.5, longitude: 13.4 }];
// Complete, except one old day, one recent day and one recent null day.
for (let d = '2026-01-01'; d <= yesterday; d = addDays(d, 1)) {
if (d === '2026-01-10' || d === '2026-04-01') continue;
existing.push({
apiaryId: 'a',
day: dayToDate(d),
meanTempC: d === '2026-05-01' ? null : 4,
source: d < recentStart ? 'archive' : 'forecast',
});
}
const result = await service.syncOnce(today, signal);
expect(
(dailyMeans.mock.calls as [unknown, string, string][]).map(([, f, t]) => [
f,
t,
]),
).toEqual([
['2026-01-10', '2026-01-10'],
['2026-04-01', '2026-05-01'],
]);
const written = upserts.map((u) => [dayKey(u.create.day), u.create.source]);
// Days inside the range that already have a value are not rewritten.
expect(written).toEqual([
['2026-01-10', 'archive'],
['2026-04-01', 'forecast'],
['2026-05-01', 'forecast'],
]);
expect(result.daysWritten).toBe(3);
});
it('does not refetch old days whose value is null', async () => {
apiaries = [{ id: 'a', latitude: 52.5, longitude: 13.4 }];
for (let d = '2026-01-01'; d <= yesterday; d = addDays(d, 1)) {
existing.push({
apiaryId: 'a',
day: dayToDate(d),
meanTempC: d < recentStart ? null : 4,
source: d < recentStart ? 'archive' : 'forecast',
});
}
const result = await service.syncOnce(today, signal);
expect(dailyMeans).not.toHaveBeenCalled();
expect(result).toEqual({
points: 0,
daysWritten: 0,
failedPoints: 0,
endedEarly: false,
});
});
it('writes nothing for a point on an error and tries the others', async () => {
apiaries = [
{ id: 'a', latitude: 52.5, longitude: 13.4 },
{ id: 'c', latitude: 48.1, longitude: 11.5 },
];
dailyMeans.mockImplementation(
(point: { lat: number }, from: string, to: string) =>
point.lat === 52.5 && from !== '2026-01-01'
? Promise.reject(new Error('boom 52.5'))
: Promise.resolve([
{ day: from, meanTempC: 1 },
{ day: to, meanTempC: 1 },
]),
);
const warn = jest
.spyOn(service['logger'], 'warn')
.mockImplementation(() => undefined);
const result = await service.syncOnce(today, signal);
expect(result.failedPoints).toBe(1);
expect(upserts.length).toBeGreaterThan(0);
expect(upserts.every((u) => u.create.apiaryId === 'c')).toBe(true);
expect(warn).toHaveBeenCalledTimes(1);
});
it('reads active apiaries with coordinates and stops when aborted', async () => {
apiaries = [{ id: 'a', latitude: 52.5, longitude: 13.4 }];
const controller = new AbortController();
controller.abort();
const result = await service.syncOnce(today, controller.signal);
expect(dailyMeans).not.toHaveBeenCalled();
expect(result.points).toBe(0);
expect(apiaryFindMany.mock.calls[0]).toEqual([
expect.objectContaining({
where: expect.objectContaining({
status: 'ACTIVE',
latitude: { not: null },
longitude: { not: null },
}) as unknown,
}),
]);
});
describe('backoff', () => {
const manyPoints = (count: number): ApiaryRow[] =>
Array.from({ length: count }, (_, i) => ({
id: `p${i}`,
latitude: 40 + i,
longitude: 10,
}));
const quiet = () =>
jest.spyOn(service['logger'], 'warn').mockImplementation(() => undefined);
it('pauses between points, abortable', async () => {
apiaries = manyPoints(3);
await service.syncOnce(today, signal);
expect(abortableDelay).toHaveBeenCalledTimes(2);
expect(abortableDelay).toHaveBeenCalledWith(300, signal);
});
it('ends the pass after 3 failed points in a row', async () => {
apiaries = manyPoints(6);
dailyMeans.mockRejectedValue(new WeatherSourceError('HTTP 500', 500));
quiet();
const result = await service.syncOnce(today, signal);
expect(result).toEqual({
points: 3,
daysWritten: 0,
failedPoints: 3,
endedEarly: true,
});
expect(dailyMeans).toHaveBeenCalledTimes(3);
});
it('a success resets the failure streak', async () => {
apiaries = manyPoints(5);
dailyMeans.mockImplementation(
(point: { lat: number }, from: string, to: string) =>
point.lat === 42
? Promise.resolve([
{ day: from, meanTempC: 1 },
{ day: to, meanTempC: 1 },
])
: Promise.reject(new WeatherSourceError('HTTP 500', 500)),
);
quiet();
const result = await service.syncOnce(today, signal);
expect(result.points).toBe(5);
expect(result.failedPoints).toBe(4);
expect(result.endedEarly).toBe(false);
});
it('stops on a 429 and sends no request before Retry-After has passed', async () => {
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] });
try {
apiaries = manyPoints(4);
dailyMeans.mockRejectedValueOnce(
new WeatherSourceError('HTTP 429', 429, 120_000),
);
quiet();
const first = await service.syncOnce(today, signal);
expect(first.endedEarly).toBe(true);
expect(first.points).toBe(1);
expect(dailyMeans).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(119_000);
const blocked = await service.syncOnce(today, signal);
expect(blocked.endedEarly).toBe(true);
expect(blocked.points).toBe(0);
expect(dailyMeans).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(2_000);
dailyMeans.mockClear();
const resumed = await service.syncOnce(today, signal);
expect(resumed.points).toBe(4);
expect(dailyMeans).toHaveBeenCalled();
} finally {
jest.useRealTimers();
}
});
});
it('replaces forecast values older than the recent window with archive values', async () => {
apiaries = [{ id: 'a', latitude: 52.5, longitude: 13.4 }];
for (let d = '2026-01-01'; d <= yesterday; d = addDays(d, 1)) {
existing.push({
apiaryId: 'a',
day: dayToDate(d),
meanTempC: 4,
source: d === '2026-01-15' || d >= recentStart ? 'forecast' : 'archive',
});
}
await service.syncOnce(today, signal);
const calls = dailyMeans.mock.calls as [
unknown,
string,
string,
{ window: string },
][];
expect(calls.map(([, f, t, o]) => [f, t, o.window])).toEqual([
['2026-01-15', '2026-01-15', 'archive'],
]);
expect(upserts.map((u) => [dayKey(u.create.day), u.create.source])).toEqual(
[['2026-01-15', 'archive']],
);
});
it('writes a large backfill in several transactions', async () => {
apiaries = [{ id: 'a', latitude: 52.5, longitude: 13.4 }];
const result = await service.syncOnce('2026-12-31', signal);
expect(result.daysWritten).toBe(364);
const sizes = transactionSizes;
expect(sizes.length).toBeGreaterThan(1);
expect(Math.max(...sizes)).toBeLessThanOrEqual(200);
expect(sizes.reduce((a, b) => a + b, 0)).toBe(364);
});
it('counts the days of the clock changes like any other day', async () => {
apiaries = [{ id: 'a', latitude: 52.5, longitude: 13.4 }];
await service.syncOnce('2026-11-02', signal);
const written = upserts.map((u) => dayKey(u.create.day));
expect(written).toHaveLength(305); // Jan 1 .. Nov 1
expect(new Set(written).size).toBe(305);
expect(written).toEqual(
expect.arrayContaining(['2026-03-28', '2026-03-29', '2026-03-30']),
);
expect(written).toEqual(
expect.arrayContaining(['2026-10-24', '2026-10-25', '2026-10-26']),
);
});
it('logs the pass with points, daysWritten, failedPoints and endedEarly', async () => {
apiaries = [{ id: 'a', latitude: 52.5, longitude: 13.4 }];
const log = jest
.spyOn(service['logger'], 'log')
.mockImplementation(() => undefined);
await service.syncOnce(today, signal);
expect(log).toHaveBeenCalledWith(
expect.stringMatching(
/points=1, daysWritten=139, failedPoints=0, endedEarly=false/,
),
);
});
describe('old forecast rows', () => {
const oldForecastRows = (fetchedAt?: Date): void => {
for (let d = '2026-01-01'; d <= yesterday; d = addDays(d, 1)) {
existing.push({
apiaryId: 'a',
day: dayToDate(d),
meanTempC: 4,
source: d === '2026-01-15' ? 'forecast' : 'archive',
...(d === '2026-01-15' && fetchedAt ? { fetchedAt } : {}),
});
}
};
it('keeps the value when the archive has none and only notes the check', async () => {
apiaries = [{ id: 'a', latitude: 52.5, longitude: 13.4 }];
oldForecastRows();
dailyMeans.mockResolvedValue([{ day: '2026-01-15', meanTempC: null }]);
const result = await service.syncOnce(today, signal);
expect(upserts).toHaveLength(0);
expect(touches).toEqual([
expect.objectContaining({
where: {
apiaryId_day: { apiaryId: 'a', day: dayToDate('2026-01-15') },
},
data: { fetchedAt: expect.any(Date) as unknown },
}),
]);
expect(result.daysWritten).toBe(0);
});
it('does not ask again within 7 days of the last check', async () => {
apiaries = [{ id: 'a', latitude: 52.5, longitude: 13.4 }];
oldForecastRows(new Date(Date.now() - 6 * 24 * 3_600_000));
await service.syncOnce(today, signal);
expect(dailyMeans).not.toHaveBeenCalled();
existing = [];
oldForecastRows(new Date(Date.now() - 8 * 24 * 3_600_000));
await service.syncOnce(today, signal);
expect(dailyMeans).toHaveBeenCalledTimes(1);
});
});
it('honours Retry-After of a 503 like a 429, but not a 503 without it', async () => {
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] });
try {
apiaries = [
{ id: 'a', latitude: 40, longitude: 10 },
{ id: 'b', latitude: 41, longitude: 10 },
];
jest.spyOn(service['logger'], 'warn').mockImplementation(() => undefined);
dailyMeans.mockRejectedValueOnce(
new WeatherSourceError('HTTP 503', 503, 60_000),
);
const first = await service.syncOnce(today, signal);
expect(first).toEqual(
expect.objectContaining({ points: 1, endedEarly: true }),
);
jest.advanceTimersByTime(30_000);
dailyMeans.mockClear();
expect((await service.syncOnce(today, signal)).points).toBe(0);
expect(dailyMeans).not.toHaveBeenCalled();
jest.advanceTimersByTime(31_000);
dailyMeans.mockRejectedValueOnce(new WeatherSourceError('HTTP 503', 503));
const plain = await service.syncOnce(today, signal);
expect(plain.points).toBe(2);
} finally {
jest.useRealTimers();
}
});
});
@@ -0,0 +1,314 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { addDays, dayToDate } from '../../common/dates';
import { loggableError } from '../../common/loggable-error';
import { PrismaService } from '../../prisma/prisma.service';
import {
type GeoPoint,
WEATHER_HISTORY_SOURCE,
type WeatherHistorySource,
} from './weather-history-source';
import { WeatherSourceError } from './open-meteo-history.source';
import { abortableDelay } from './abortable-delay';
import {
isRecentRange,
recentWindowStart,
roundPoint,
FORECAST_RECHECK_MS,
WEATHER_MAX_CONSECUTIVE_FAILURES,
WEATHER_POINT_DELAY_MS,
WEATHER_WRITE_CHUNK_SIZE,
} from './weather-history.config';
export interface WeatherSyncResult {
points: number;
daysWritten: number;
failedPoints: number;
/** The pass stopped before the last point (rate limit, repeated failures, shutdown). */
endedEarly: boolean;
}
interface PlannedApiary {
id: string;
/** Days this apiary lacks (no row, or no value inside the recent window). */
missing: Set<string>;
/** Days that already have a row (with no value): not rewritten by another null. */
stored: Set<string>;
/** Forecast values past the window that the archive is asked to replace. */
replaceable: Set<string>;
}
interface PlannedPoint {
point: GeoPoint;
apiaries: PlannedApiary[];
}
interface Range {
from: string;
to: string;
}
type WeatherLabel = 'forecast' | 'archive';
const dayKey = (date: Date): string => date.toISOString().slice(0, 10);
/** The smallest range covering the given days, or null for none. */
function spanOf(days: Iterable<string>): Range | null {
let from: string | null = null;
let to: string | null = null;
for (const day of days) {
if (from === null || day < from) from = day;
if (to === null || day > to) to = day;
}
return from === null || to === null ? null : { from, to };
}
/**
* Keeps the daily mean temperatures of the active apiaries (ApiaryWeatherDay)
* complete from January 1st to yesterday, for the growing-degree-day sum of
* the phenology. Runs in the worker loop `phenology-weather`; the only code
* that talks to the weather source for history.
*/
@Injectable()
export class WeatherHistoryService {
private readonly logger = new Logger(WeatherHistoryService.name);
/** Wall-clock ms before which no request goes out (Retry-After of a 429). */
private blockedUntil = 0;
constructor(
private readonly prisma: PrismaService,
@Inject(WEATHER_HISTORY_SOURCE)
private readonly source: WeatherHistorySource,
) {}
/**
* One pass. A point whose request fails writes nothing and is tried again
* by the next pass; no event, no notification.
*/
async syncOnce(
today: string,
signal: AbortSignal,
): Promise<WeatherSyncResult> {
const result: WeatherSyncResult = {
points: 0,
daysWritten: 0,
failedPoints: 0,
endedEarly: false,
};
if (Date.now() < this.blockedUntil) {
this.logger.log('Wetterabgleich pausiert (Retry-After der Quelle).');
return { ...result, endedEarly: true };
}
const plan = await this.plan(today);
let consecutiveFailures = 0;
for (const planned of plan) {
if (signal.aborted) {
result.endedEarly = true;
break;
}
if (result.points > 0) {
await abortableDelay(WEATHER_POINT_DELAY_MS, signal);
if (signal.aborted) {
result.endedEarly = true;
break;
}
}
result.points += 1;
try {
result.daysWritten += await this.syncPoint(planned, today, signal);
consecutiveFailures = 0;
} catch (error: unknown) {
result.failedPoints += 1;
consecutiveFailures += 1;
// Message only (no stack): nothing of the request goes to the log.
this.logger.warn(
`Wetterabruf für einen Standortpunkt fehlgeschlagen: ${loggableError(error).message}`,
);
if (
error instanceof WeatherSourceError &&
(error.status === 429 ||
(error.status === 503 && error.retryAfterMs !== undefined))
) {
// The source asks us to slow down: stop, and stay quiet until
// Retry-After has passed (at least until the next tick).
this.blockedUntil = Date.now() + (error.retryAfterMs ?? 0);
result.endedEarly = true;
break;
}
if (consecutiveFailures >= WEATHER_MAX_CONSECUTIVE_FAILURES) {
result.endedEarly = true;
break;
}
}
}
if (result.points > 0 || result.endedEarly) {
this.logger.log(
`Wetterabgleich: points=${result.points}, daysWritten=${result.daysWritten}, failedPoints=${result.failedPoints}, endedEarly=${result.endedEarly}`,
);
}
return result;
}
/** Active apiaries with coordinates, grouped by rounded point, minus what is complete. */
private async plan(today: string): Promise<PlannedPoint[]> {
const yesterday = addDays(today, -1);
const januaryFirst = `${today.slice(0, 4)}-01-01`;
if (yesterday < januaryFirst) return [];
const apiaries = await this.prisma.apiary.findMany({
where: {
status: 'ACTIVE',
latitude: { not: null },
longitude: { not: null },
},
select: { id: true, latitude: true, longitude: true },
orderBy: { id: 'asc' },
});
if (apiaries.length === 0) return [];
const stored = await this.prisma.apiaryWeatherDay.findMany({
where: {
apiaryId: { in: apiaries.map(({ id }) => id) },
day: { gte: dayToDate(januaryFirst), lte: dayToDate(yesterday) },
},
select: {
apiaryId: true,
day: true,
meanTempC: true,
source: true,
fetchedAt: true,
},
});
const complete = new Map<string, Set<string>>();
const storedDays = new Map<string, Set<string>>();
const replaceable = new Map<string, Set<string>>();
const now = Date.now();
const recentStart = recentWindowStart(today);
for (const row of stored) {
const day = dayKey(row.day);
const rows = storedDays.get(row.apiaryId) ?? new Set<string>();
rows.add(day);
storedDays.set(row.apiaryId, rows);
// A null inside the recent window is asked for again; an older one
// stays (the archive does not know it either).
if (row.meanTempC === null && day >= recentStart) continue;
// A forecast value older than the window is replaced by the archive's
// (the forecast endpoint's model values are not the reanalysis).
// Asked at most once per FORECAST_RECHECK_DAYS: when the archive has
// no value either, the row is touched (fetchedAt) and left alone.
if (
row.source === 'forecast' &&
day < recentStart &&
now - row.fetchedAt.getTime() >= FORECAST_RECHECK_MS
) {
const days = replaceable.get(row.apiaryId) ?? new Set<string>();
days.add(day);
replaceable.set(row.apiaryId, days);
continue;
}
const days = complete.get(row.apiaryId) ?? new Set<string>();
days.add(day);
complete.set(row.apiaryId, days);
}
const allDays: string[] = [];
for (let day = januaryFirst; day <= yesterday; day = addDays(day, 1)) {
allDays.push(day);
}
const byPoint = new Map<string, PlannedPoint>();
for (const apiary of apiaries) {
if (apiary.latitude === null || apiary.longitude === null) continue;
const point = roundPoint(apiary.latitude, apiary.longitude);
const have = complete.get(apiary.id);
const missing = new Set(allDays.filter((day) => !have?.has(day)));
if (missing.size === 0) continue;
const key = `${point.lat}/${point.lon}`;
const planned = byPoint.get(key) ?? { point, apiaries: [] };
planned.apiaries.push({
id: apiary.id,
missing,
stored: storedDays.get(apiary.id) ?? new Set<string>(),
replaceable: replaceable.get(apiary.id) ?? new Set<string>(),
});
byPoint.set(key, planned);
}
return [...byPoint.values()];
}
/** Fetches both ranges of a point first, then writes: all or nothing. */
private async syncPoint(
planned: PlannedPoint,
today: string,
signal: AbortSignal,
): Promise<number> {
const union = new Set(
planned.apiaries.flatMap(({ missing }) => [...missing]),
);
const ranges: Range[] = [];
const older = spanOf([...union].filter((d) => !isRecentRange(d, today)));
const recent = spanOf([...union].filter((d) => isRecentRange(d, today)));
for (const range of [older, recent]) {
if (range) ranges.push(range);
}
const fetched = new Map<
string,
{ value: number | null; label: WeatherLabel }
>();
for (const range of ranges) {
const recent = isRecentRange(range.from, today);
const label: WeatherLabel = recent ? 'forecast' : 'archive';
const means = await this.source.dailyMeans(
planned.point,
range.from,
range.to,
{ window: recent ? 'recent' : 'archive', signal },
);
for (const { day, meanTempC } of means) {
fetched.set(day, { value: meanTempC, label });
}
}
let written = 0;
for (const { id, missing, stored, replaceable } of planned.apiaries) {
let touched = 0;
const writes = [...missing].flatMap((day) => {
const entry = fetched.get(day);
if (!entry) return [];
if (entry.value === null && stored.has(day)) {
// The archive has no value for an old forecast row: keep the
// value, only note the check so it is not repeated every hour.
if (!replaceable.has(day)) return [];
touched += 1;
return [this.touchDay(id, day)];
}
return [this.upsertDay(id, day, entry.value, entry.label)];
});
// Chunked: a year of backfill is no single transaction.
for (let i = 0; i < writes.length; i += WEATHER_WRITE_CHUNK_SIZE) {
await this.prisma.$transaction(
writes.slice(i, i + WEATHER_WRITE_CHUNK_SIZE),
);
}
written += writes.length - touched;
}
return written;
}
private touchDay(apiaryId: string, day: string) {
return this.prisma.apiaryWeatherDay.update({
where: { apiaryId_day: { apiaryId, day: dayToDate(day) } },
data: { fetchedAt: new Date() },
});
}
private upsertDay(
apiaryId: string,
day: string,
meanTempC: number | null,
source: WeatherLabel,
) {
const date = dayToDate(day);
return this.prisma.apiaryWeatherDay.upsert({
where: { apiaryId_day: { apiaryId, day: date } },
create: { apiaryId, day: date, meanTempC, source },
update: { meanTempC, source, fetchedAt: new Date() },
});
}
}
@@ -105,6 +105,77 @@ describe('WeatherService', () => {
expect(url.toString()).toContain('longitude=13.4');
});
it('takes the forecast URL and API key from weatherHistoryConfig', async () => {
const saved = {
url: process.env.OPEN_METEO_FORECAST_URL,
key: process.env.OPEN_METEO_API_KEY,
};
process.env.OPEN_METEO_FORECAST_URL = 'https://weather.example/forecast';
process.env.OPEN_METEO_API_KEY = 'configured-key';
try {
const configured = new WeatherService(
prisma as unknown as PrismaService,
);
prisma.apiary.findUnique.mockResolvedValue({
latitude: 52.5,
longitude: 13.4,
});
fetchMock.mockResolvedValue(
new Response(JSON.stringify(openMeteoResponse([])), { status: 200 }),
);
await configured.getWeather('apiary-1');
const [url] = fetchMock.mock.calls[0] as [URL];
expect(url.origin).toBe('https://weather.example');
expect(url.searchParams.get('apikey')).toBe('configured-key');
} finally {
for (const [name, value] of [
['OPEN_METEO_FORECAST_URL', saved.url],
['OPEN_METEO_API_KEY', saved.key],
] as const) {
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
}
});
it('rounds the coordinates to 0.01 degrees and sends a User-Agent', async () => {
prisma.apiary.findUnique.mockResolvedValue({
latitude: 52.5234,
longitude: 13.4049,
});
fetchMock.mockResolvedValue(
new Response(JSON.stringify(openMeteoResponse([])), { status: 200 }),
);
await weatherService.getWeather('apiary-1');
const [url, init] = fetchMock.mock.calls[0] as [
URL,
{ headers: Record<string, string> },
];
expect(url.searchParams.get('latitude')).toBe('52.52');
expect(url.searchParams.get('longitude')).toBe('13.4');
expect(init.headers['User-Agent']).toMatch(/^ApiCore\/\S+ \(/);
});
it('sends no apikey parameter when none is configured', async () => {
prisma.apiary.findUnique.mockResolvedValue({
latitude: 52.5,
longitude: 13.4,
});
fetchMock.mockResolvedValue(
new Response(JSON.stringify(openMeteoResponse([])), { status: 200 }),
);
await weatherService.getWeather('apiary-1');
const [url] = fetchMock.mock.calls[0] as [URL];
expect(url.origin).toBe('https://api.open-meteo.com');
expect(url.searchParams.has('apikey')).toBe(false);
});
it('maps daily weather_code to weatherCode', async () => {
prisma.apiary.findUnique.mockResolvedValue({
latitude: 52.5,
+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');
@@ -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', {
@@ -6,6 +6,7 @@ import type {
} from '../domain-events/domain-event-types';
import { OnDomainEvent } from '../domain-events/on-domain-event.decorator';
import { Prisma } from '../../generated/prisma';
import { honeyBatchInclude } from '../honey-batches/honey-batch-include';
import { WebhookEvent } from './webhook-events';
import { WebhooksService } from './webhooks.service';
@@ -20,6 +21,7 @@ export const DOMAIN_EVENT_WEBHOOKS: Readonly<
HarvestRecorded: 'harvest.created',
BatchBottled: 'batch.updated',
BottlingCorrected: 'batch.updated',
HoneyBatchRecorded: 'batch.created',
StockChanged: 'stock.changed',
TaskCreated: 'task.created',
TaskCompleted: 'task.completed',
@@ -88,6 +90,13 @@ async function webhookData(
return tx.honeyBatch.findUnique({
where: { id: event.payload.honeyBatchId },
});
// The new honey batch with the relations the API shows, as the legacy
// path sent it after the create.
case 'HoneyBatchRecorded':
return tx.honeyBatch.findUnique({
...byAggregate,
include: honeyBatchInclude,
});
case 'StockChanged':
return stockChange(event.payload);
case 'TaskCreated':
@@ -53,6 +53,7 @@ describe('WithdrawalCheckService', () => {
treatment: { findMany: jest.Mock; findUnique: jest.Mock };
colony: { findMany: jest.Mock };
honeyBatch: { findUnique: jest.Mock };
extractionHarvest: { findMany: jest.Mock };
bottlingBatch: { findMany: jest.Mock };
};
let notifications: { recordForPermission: jest.Mock };
@@ -69,6 +70,7 @@ describe('WithdrawalCheckService', () => {
findMany: jest.fn().mockResolvedValue([{ id: 'c1', code: 'B3' }]),
},
honeyBatch: { findUnique: jest.fn() },
extractionHarvest: { findMany: jest.fn() },
bottlingBatch: { findMany: jest.fn().mockResolvedValue([]) },
};
notifications = { recordForPermission: jest.fn().mockResolvedValue([]) };
@@ -269,6 +271,35 @@ describe('WithdrawalCheckService', () => {
});
});
describe('conflictsForExtraction', () => {
it('checks the harvests of the extraction', async () => {
db.extractionHarvest.findMany.mockResolvedValue([
{ harvestId: 'h1' },
{ harvestId: 'h2' },
]);
db.harvest.findMany.mockResolvedValue([
harvestOn('h1', '2026-08-10', ['c1']),
]);
db.treatment.findMany.mockResolvedValue([treatment({})]);
const conflicts = await service.conflictsForExtraction(asDb(), 'e1');
expect(db.extractionHarvest.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { extractionId: 'e1' } }),
);
expect(db.harvest.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: { in: ['h1', 'h2'] } } }),
);
expect(conflicts).toEqual([conflict()]);
});
it('answers nothing for an extraction without harvests', async () => {
db.extractionHarvest.findMany.mockResolvedValue([]);
expect(await service.conflictsForExtraction(asDb(), 'e1')).toEqual([]);
});
});
describe('warningsFor', () => {
it('words the warning with colony code, product and the end day', async () => {
const warnings = await service.warningsFor(asDb(), [conflict()]);
@@ -199,6 +199,22 @@ export class WithdrawalCheckService {
);
}
/** Extraction -> harvests. */
async conflictsForExtraction(
db: DbClient,
extractionId: string,
): Promise<WithdrawalConflict[]> {
const links = await db.extractionHarvest.findMany({
where: { extractionId },
select: { harvestId: true },
orderBy: { harvestId: 'asc' },
});
return this.conflictsForHarvests(
db,
links.map((link) => link.harvestId),
);
}
async warningsFor(
db: DbClient,
conflicts: readonly WithdrawalConflict[],
@@ -50,6 +50,7 @@ describe('WithdrawalEventHandlers', () => {
let check: {
conflictsForHarvests: jest.Mock;
conflictsForHoneyBatch: jest.Mock;
conflictsForExtraction: jest.Mock;
conflictsForTreatment: jest.Mock;
notifyConflictsIn: jest.Mock;
};
@@ -64,6 +65,7 @@ describe('WithdrawalEventHandlers', () => {
check = {
conflictsForHarvests: jest.fn().mockResolvedValue([conflict]),
conflictsForHoneyBatch: jest.fn().mockResolvedValue([conflict]),
conflictsForExtraction: jest.fn().mockResolvedValue([conflict]),
conflictsForTreatment: jest.fn().mockResolvedValue([conflict]),
notifyConflictsIn: jest.fn().mockResolvedValue(1),
};
@@ -133,6 +135,37 @@ describe('WithdrawalEventHandlers', () => {
);
});
describe('extraction check', () => {
it('registers under its id', () => {
expect(
domainEventHandlerMetadata(
Object.getOwnPropertyDescriptor(
WithdrawalEventHandlers.prototype,
'extractionCheck',
)?.value as object,
),
).toEqual([
{
types: ['ExtractionRecorded', 'ExtractionCorrected'],
handlerId: 'withdrawal.extraction-check',
},
]);
});
it.each(['ExtractionRecorded', 'ExtractionCorrected'] as const)(
'notifies the conflicts of the extraction on %s',
async (type) => {
await handlers.extractionCheck(
envelope(type, 'e1', { harvestIds: ['h1'] }),
asTx(),
);
expect(check.conflictsForExtraction).toHaveBeenCalledWith(tx, 'e1');
expect(check.notifyConflictsIn).toHaveBeenCalledWith(tx, [conflict]);
},
);
});
describe('treatment check', () => {
it.each(['TreatmentRecorded', 'TreatmentCorrected'] as const)(
'notifies harvests that already lie in the window of the treatment on %s',
@@ -62,8 +62,24 @@ export class WithdrawalEventHandlers {
await this.check.notifyConflictsIn(tx, conflicts);
}
// The extraction (Schleuderung) joins the check with T4b; until then the
// bottling is the second stage. A removed bottling has nothing to check.
// Second stage: the extraction (Schleuderung). Same key as the harvest, so
// a conflict found at the harvest is not notified again here.
@OnDomainEvent(
['ExtractionRecorded', 'ExtractionCorrected'],
'withdrawal.extraction-check',
)
async extractionCheck(
event: AnyDomainEventEnvelope<'ExtractionRecorded' | 'ExtractionCorrected'>,
tx: Prisma.TransactionClient,
): Promise<void> {
const conflicts = await this.check.conflictsForExtraction(
tx,
event.aggregateId,
);
await this.check.notifyConflictsIn(tx, conflicts);
}
// Third stage: the bottling. A removed bottling has nothing to check.
@OnDomainEvent(
['BatchBottled', 'BottlingCorrected'],
'withdrawal.bottling-check',
+3 -1
View File
@@ -6,6 +6,8 @@ export interface PollingLoopOptions {
intervalMs: number;
tick: () => Promise<unknown>;
logger: Logger;
/** Wait before the first tick after start() (default: none). */
initialDelayMs?: number;
}
/**
@@ -29,7 +31,7 @@ export class PollingLoop {
// schedules the next one, so ticks never overlap across stop()/start().
// The first tick after start() runs immediately, also in that case.
if (this.running) this.rerunRequested = true;
else this.schedule(0);
else this.schedule(this.options.initialDelayMs ?? 0);
}
wake(): void {
@@ -1,13 +1,16 @@
import { Module } from '@nestjs/common';
import { WeatherHistoryModule } from '../weather/history/weather-history.module';
import { WorkerRuntime } from './worker-runtime.service';
/**
* The worker loops. Imported by WorkerModule (dist/worker.js) and
* AllInOneModule ("all"), never by AppModule: API pods run no background
* loops. Its dependencies come from the global Prisma, DomainEvents,
* CalendarSync and Webhooks modules of the importing root module.
* CalendarSync and Webhooks modules of the importing root module; the weather
* source comes with WeatherHistoryModule.
*/
@Module({
imports: [WeatherHistoryModule],
providers: [WorkerRuntime],
exports: [WorkerRuntime],
})
@@ -7,6 +7,11 @@ import {
DRAIN_MAX_ROUNDS,
} from '../domain-events/domain-event-dispatcher';
import type { PrismaService } from '../prisma/prisma.service';
import type { WeatherHistoryService } from '../weather/history/weather-history.service';
import {
WEATHER_FIRST_TICK_DELAY_MS,
WEATHER_HISTORY_POLL_MS,
} from '../weather/history/weather-history.config';
import type { WebhookDeliveryWorker } from '../webhooks/webhook-delivery.worker';
import { PgListener, PgListenerOptions } from './pg-listener';
import { HEALTH_STALE_MS } from './worker-health';
@@ -64,6 +69,7 @@ describe('WorkerRuntime', () => {
processOutbox: jest.Mock<Promise<void>, [AbortSignal?]>;
};
let webhookDeliveries: FakeWebhookDeliveries;
let weatherHistory: { syncOnce: jest.Mock };
let runtime: WorkerRuntime;
let logError: jest.SpyInstance;
@@ -93,6 +99,11 @@ describe('WorkerRuntime', () => {
.mockResolvedValue(undefined),
};
webhookDeliveries = new FakeWebhookDeliveries();
weatherHistory = {
syncOnce: jest
.fn()
.mockResolvedValue({ points: 0, daysWritten: 0, failedPoints: 0 }),
};
logError = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
@@ -102,6 +113,7 @@ describe('WorkerRuntime', () => {
dispatcher as unknown as DomainEventDispatcher,
calendarSync as unknown as CalendarSyncService,
webhookDeliveries as unknown as WebhookDeliveryWorker,
weatherHistory as unknown as WeatherHistoryService,
);
});
@@ -122,6 +134,7 @@ describe('WorkerRuntime', () => {
expect(CALENDAR_OUTBOX_POLL_MS).toBe(60_000);
expect(WEBHOOK_DELIVERY_POLL_MS).toBe(5_000);
expect(HEALTH_QUERY_TIMEOUT_MS).toBe(5_000);
expect(WEATHER_HISTORY_POLL_MS).toBe(3_600_000);
});
it('starts the listener and every loop at bootstrap', async () => {
@@ -381,4 +394,37 @@ describe('WorkerRuntime', () => {
await shutdown;
expect(stopped).toBe(true);
});
it('runs the loop without a wake channel and stops on shutdown', async () => {
await bootstrap();
// The phenology-weather loop has no channel: nothing to LISTEN to.
expect(listenerOptions().channels).toEqual([
'domain_event',
'calendar_sync',
'webhook_delivery',
]);
// The first pass waits 10 s: not in the startup burst of the other loops.
expect(weatherHistory.syncOnce).not.toHaveBeenCalled();
await jest.advanceTimersByTimeAsync(WEATHER_FIRST_TICK_DELAY_MS - 1);
expect(weatherHistory.syncOnce).not.toHaveBeenCalled();
await jest.advanceTimersByTimeAsync(1);
expect(weatherHistory.syncOnce).toHaveBeenCalledTimes(1);
const [today, signal] = weatherHistory.syncOnce.mock.calls[0] as [
string,
AbortSignal,
];
expect(today).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(signal.aborted).toBe(false);
await jest.advanceTimersByTimeAsync(WEATHER_HISTORY_POLL_MS - 1);
expect(weatherHistory.syncOnce).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(1);
expect(weatherHistory.syncOnce).toHaveBeenCalledTimes(2);
await runtime.onModuleDestroy();
expect(signal.aborted).toBe(true);
await jest.advanceTimersByTimeAsync(WEATHER_HISTORY_POLL_MS * 2);
expect(weatherHistory.syncOnce).toHaveBeenCalledTimes(2);
});
});
@@ -5,6 +5,7 @@ import {
OnModuleDestroy,
} from '@nestjs/common';
import { CalendarSyncService } from '../calendar-sync/calendar-sync.service';
import { berlinToday } from '../common/dates';
import { loggableError } from '../common/loggable-error';
import { withTimeout } from '../common/with-timeout';
import { WAKE_CHANNELS, WakeChannel } from '../common/worker-wakeup';
@@ -13,6 +14,11 @@ import {
DRAIN_MAX_ROUNDS,
} from '../domain-events/domain-event-dispatcher';
import { PrismaService } from '../prisma/prisma.service';
import {
WEATHER_FIRST_TICK_DELAY_MS,
WEATHER_HISTORY_POLL_MS,
} from '../weather/history/weather-history.config';
import { WeatherHistoryService } from '../weather/history/weather-history.service';
import { WebhookDeliveryWorker } from '../webhooks/webhook-delivery.worker';
import { PgListener } from './pg-listener';
import { PollingLoop } from './polling-loop';
@@ -24,11 +30,16 @@ export const WEBHOOK_DELIVERY_POLL_MS = 5_000;
/** The health check's SELECT 1 must answer within this. */
export const HEALTH_QUERY_TIMEOUT_MS = 5_000;
/** One background loop: its poll interval and the channel that wakes it. */
/**
* One background loop: its poll interval and, if an outbox feeds it, the
* channel that wakes it early. Loops without a channel only poll.
*/
interface WorkerLoop {
name: string;
intervalMs: number;
channel: WakeChannel;
channel?: WakeChannel;
/** Wait before the first tick after the worker started. */
initialDelayMs?: number;
tick: () => Promise<unknown>;
}
@@ -50,7 +61,8 @@ function databaseUrl(): string {
@Injectable()
export class WorkerRuntime implements OnApplicationBootstrap, OnModuleDestroy {
private readonly logger = new Logger(WorkerRuntime.name);
private readonly loops: ReadonlyMap<WakeChannel, PollingLoop>;
private readonly loops: ReadonlyMap<string, PollingLoop>;
private readonly loopsByChannel: ReadonlyMap<WakeChannel, PollingLoop>;
private listener: PgListener | undefined;
/** Aborted at shutdown: running ticks claim no further work. */
private readonly stopping = new AbortController();
@@ -64,12 +76,25 @@ export class WorkerRuntime implements OnApplicationBootstrap, OnModuleDestroy {
private readonly dispatcher: DomainEventDispatcher,
private readonly calendarSync: CalendarSyncService,
private readonly webhookDeliveries: WebhookDeliveryWorker,
private readonly weatherHistory: WeatherHistoryService,
) {
const loops = this.workerLoops().map((definition) => ({
definition,
loop: new PollingLoop({
name: definition.name,
intervalMs: definition.intervalMs,
tick: definition.tick,
initialDelayMs: definition.initialDelayMs,
logger: this.logger,
}),
}));
this.loops = new Map(
this.workerLoops().map(({ channel, name, intervalMs, tick }) => [
channel,
new PollingLoop({ name, intervalMs, tick, logger: this.logger }),
]),
loops.map(({ definition, loop }) => [definition.name, loop]),
);
this.loopsByChannel = new Map(
loops.flatMap(({ definition, loop }) =>
definition.channel ? [[definition.channel, loop] as const] : [],
),
);
}
@@ -94,6 +119,15 @@ export class WorkerRuntime implements OnApplicationBootstrap, OnModuleDestroy {
channel: WAKE_CHANNELS.webhookDeliveries,
tick: () => this.deliverWebhooks(),
},
{
// Daily mean temperatures for the phenology; hourly, no outbox.
name: 'phenology-weather',
intervalMs: WEATHER_HISTORY_POLL_MS,
// Not in the startup burst of the other loops.
initialDelayMs: WEATHER_FIRST_TICK_DELAY_MS,
tick: () =>
this.weatherHistory.syncOnce(berlinToday(), this.stopping.signal),
},
];
}
@@ -101,8 +135,8 @@ export class WorkerRuntime implements OnApplicationBootstrap, OnModuleDestroy {
this.progressAt = performance.now();
this.listener = new PgListener({
connectionString: databaseUrl(),
channels: [...this.loops.keys()],
onNotify: (channel) => this.loops.get(channel)?.wake(),
channels: [...this.loopsByChannel.keys()],
onNotify: (channel) => this.loopsByChannel.get(channel)?.wake(),
logger: this.logger,
});
// LISTEN first: work committed from now on wakes a loop, and work
@@ -8,6 +8,7 @@ import { CalendarSyncModule } from '../calendar-sync/calendar-sync.module';
import { DomainEventsModule } from '../domain-events/domain-events.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { PrismaModule } from '../prisma/prisma.module';
import { PhenologyModule } from '../phenology/phenology.module';
import { RemindersModule } from '../reminders/reminders.module';
import { SuggestionsModule } from '../suggestions/suggestions.module';
import { TasksModule } from '../tasks/tasks.module';
@@ -37,6 +38,7 @@ import { WorkerRuntimeModule } from './worker-runtime.module';
AutomationModule,
SuggestionsModule,
AutopilotModule,
PhenologyModule,
WithdrawalModule,
RemindersModule,
WorkerRuntimeModule,
@@ -5,8 +5,10 @@ import { AutopilotStep } from '../src/autopilot/autopilot.step';
import { SuggestionsExpiryStep } from '../src/autopilot/suggestions-expiry.step';
import { AutopilotService } from '../src/autopilot/autopilot.service';
import { JobsModule } from '../src/jobs/jobs.module';
import { PhenologyStep } from '../src/phenology/phenology.step';
import { TimeEventsStep } from '../src/jobs/steps/time-events.step';
import { PrismaService } from '../src/prisma/prisma.service';
import { JobPartialFailure } from '../src/system/job-run.service';
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
@@ -30,6 +32,7 @@ describe('daily-evaluation (e2e)', () => {
let app: INestApplicationContext;
let prisma: PrismaService;
let step: TimeEventsStep;
let phenologyStep: PhenologyStep;
let autopilotStep: AutopilotStep;
let expiryStep: SuggestionsExpiryStep;
const ids: Record<string, string> = {};
@@ -56,6 +59,7 @@ describe('daily-evaluation (e2e)', () => {
app = await moduleRef.init();
prisma = app.get(PrismaService);
step = app.get(TimeEventsStep);
phenologyStep = app.get(PhenologyStep);
expiryStep = app.get(SuggestionsExpiryStep);
// The real AutopilotStep, but on the colonies of this file only: other
// files share the database and must not be reconciled on a 2001 day.
@@ -71,6 +75,7 @@ describe('daily-evaluation (e2e)', () => {
}),
},
automationRule: prisma.automationRule,
apiaryPhenology: prisma.apiaryPhenology,
$transaction: prisma.$transaction.bind(prisma) as unknown,
} as unknown as PrismaService,
app.get(AutopilotService),
@@ -340,6 +345,119 @@ describe('daily-evaluation (e2e)', () => {
expect(await eventsOf(ids['queen:reserve'])).toEqual([]);
});
describe('phenology step', () => {
const DAY = '2001-04-30';
let weatherApiaryId = '';
beforeAll(async () => {
const apiary = await prisma.apiary.create({
data: {
name: `Daily Evaluation Weather ${suffix}`,
latitude: 48.1,
longitude: 9.9,
},
});
weatherApiaryId = apiary.id;
// 10 degrees every day from January 1st to April 29th: GTS 965.
const days: Prisma.ApiaryWeatherDayCreateManyInput[] = [];
for (
let t = d('2001-01-01').getTime();
t <= d('2001-04-29').getTime();
t += 86_400_000
) {
days.push({
apiaryId: apiary.id,
day: new Date(t),
meanTempC: 10,
source: 'archive',
});
}
await prisma.apiaryWeatherDay.createMany({ data: days });
await prisma.phenologyReport.create({
data: {
apiaryId: apiary.id,
phase: 'HOLUNDER',
date: d('2001-04-29'),
userId: ids.user,
},
});
});
afterAll(async () => {
await prisma.domainEvent.deleteMany({
where: { aggregateId: weatherApiaryId },
});
await prisma.phenologyReport.deleteMany({
where: { apiaryId: weatherApiaryId },
});
// Rows of 2001 would feed the season of the other tests of this file.
await prisma.apiaryPhenology.deleteMany({ where: { year: 2001 } });
await prisma.apiary.deleteMany({ where: { id: weatherApiaryId } });
});
// Other files create and delete apiaries concurrently: an apiary that
// vanishes mid-run fails the step, the apiaries of this file do not.
const runStep = () =>
phenologyStep.run(DAY).catch((error: unknown) => {
if (!(error instanceof JobPartialFailure)) {
throw error;
}
return error.result;
});
it('the phenology step writes ApiaryPhenology for every active apiary', async () => {
const result = await runStep();
expect(Number(result.phenologyApiaries)).toBeGreaterThanOrEqual(2);
const rows = await prisma.apiaryPhenology.findMany({
where: { apiaryId: { in: [ids.apiary, weatherApiaryId] }, year: 2001 },
orderBy: { apiaryId: 'asc' },
});
expect(rows).toHaveLength(2);
const byApiary = new Map(rows.map((row) => [row.apiaryId, row]));
// Calendar only (no coordinates): APFEL from April 28th.
expect(byApiary.get(ids.apiary)).toMatchObject({
gts: null,
effectivePhase: 'APFEL',
phaseSource: 'CALENDAR',
segment: 'SCHWARMZEIT',
});
// A report for HOLUNDER beats GTS and calendar.
expect(byApiary.get(weatherApiaryId)).toMatchObject({
gts: 965,
gtsThroughDay: d('2001-04-29'),
estimatedPhase: 'APFEL',
reportedPhase: 'HOLUNDER',
effectivePhase: 'HOLUNDER',
phaseSource: 'REPORT',
segment: 'SCHWARMZEIT',
});
});
it('records the phase change once per apiary and day', async () => {
await runStep();
await runStep();
expect(await eventsOf(weatherApiaryId)).toEqual([
{
type: 'SeasonPhaseChanged',
aggregateType: 'Apiary',
payload: {
apiaryId: weatherApiaryId,
phase: 'HOLUNDER',
segment: 'SCHWARMZEIT',
previousPhase: 'APFEL',
previousSegment: 'SCHWARMZEIT',
source: 'REPORT',
},
dedupeKey: `SeasonPhaseChanged:${weatherApiaryId}:${DAY}`,
actorUserId: null,
},
]);
expect(await eventsOf(ids.apiary)).toEqual([]);
});
});
describe('autopilot and suggestions-expiry steps', () => {
/** Living colony, inspected 2001-05-03 and measured 2001-05-10. */
async function lateColony(name: string): Promise<string> {
@@ -9,6 +9,7 @@ import { login } from './utils/api';
interface ExtractionBody {
id: string;
harvests: { harvestId: string }[];
warnings: { code: string }[];
}
describe('Extractions (e2e)', () => {
@@ -113,6 +114,10 @@ describe('Extractions (e2e)', () => {
const extraction = response.body as ExtractionBody;
extractionId = extraction.id;
expect(extraction.harvests).toHaveLength(1);
// The harvest has no net weight: a hint, not an error.
expect(extraction.warnings.map((warning) => warning.code)).toEqual([
'HARVEST_NET_MISSING',
]);
});
it('returns the extraction with its source harvests on lookup', async () => {
@@ -11,6 +11,7 @@ interface HoneyBatchBody {
batchNumber: string;
measurements: { id: string }[];
storageContainerId: string | null;
warnings: { code: string }[];
}
interface RelocationBody {
@@ -163,6 +164,8 @@ describe('Honey batches (e2e)', () => {
const honeyBatch = response.body as HoneyBatchBody;
honeyBatchId = honeyBatch.id;
expect(honeyBatch.batchNumber).toEqual(expect.stringContaining('HC-'));
// The answer carries the warnings; nothing to warn about here.
expect(honeyBatch.warnings).toEqual([]);
});
it('adds a water content measurement to the batch', async () => {
@@ -0,0 +1,709 @@
import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
import { PrismaService } from '../src/prisma/prisma.service';
import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
import { drainDomainEvents } from './utils/domain-events';
interface Warning {
code: string;
severity: string;
message: string;
refs: { type: string; id: string }[];
}
interface WithWarningsBody {
id: string;
warnings: Warning[];
}
interface ExtractionPrefillBody {
amountKg: number | null;
honeyType: string | null;
sources: { harvestId: string; date: string; netWeight: number | null }[];
warnings: Warning[];
}
interface HoneyBatchPrefillBody {
remainingKg: number | null;
honeyType: string | null;
waterContent: number | null;
warnings: Warning[];
}
/**
* The honey chain against a real PostgreSQL: the prefill proposals, the
* plausibility warnings of extraction and honey batch (never a 4xx), the
* withdrawal check at the extraction stage and the webhook batch.created
* that now comes from the event handler.
*/
describe('Honey chain (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
const password = 'SuperSecret123!';
let apiaryId: string;
let adminId: string;
let staffId: string;
let readerId: string;
let roleId: string;
let subscriptionId: string;
const colonyIds: string[] = [];
const harvestIds: string[] = [];
const treatmentIds: string[] = [];
const extractionIds: string[] = [];
const honeyBatchIds: string[] = [];
const server = () => app.getHttpServer();
const asAdmin = { Authorization: '' };
const asStaff = { Authorization: '' };
async function harvestWith(
colonyId: string,
day: string,
netWeight: number | null,
honeyType: string | null,
): Promise<string> {
const harvest = await prisma.harvest.create({
data: {
apiaryId,
userId: adminId,
date: new Date(`${day}T00:00:00.000Z`),
netWeight,
honeyType,
},
});
await prisma.harvestColony.create({
data: { harvestId: harvest.id, colonyId },
});
harvestIds.push(harvest.id);
return harvest.id;
}
async function postExtraction(
harvests: string[],
amount?: number,
): Promise<WithWarningsBody> {
const response = await request(server())
.post('/api/v1/extractions')
.set(asAdmin)
.send({
date: '2026-08-01T00:00:00.000Z',
harvestIds: harvests,
amount,
})
.expect(201);
const body = response.body as WithWarningsBody;
extractionIds.push(body.id);
return body;
}
async function postBatch(
extractionId: string,
amount: number,
label: string,
): Promise<WithWarningsBody> {
const response = await request(server())
.post('/api/v1/honey-batches')
.set(asAdmin)
.send({
batchNumber: `HC-${label}-${suffix}`,
extractionId,
amount,
})
.expect(201);
const body = response.body as WithWarningsBody;
honeyBatchIds.push(body.id);
return body;
}
/** Deliveries of this file's subscription that carry the given batch. */
async function deliveriesOfBatch(batchId: string) {
const deliveries = await prisma.webhookDelivery.findMany({
where: { subscriptionId },
});
return deliveries.filter((delivery) => {
const sent = JSON.parse(delivery.body) as { data?: { id?: string } };
return sent.data?.id === batchId;
});
}
const conflictRowsOf = (harvestId: string, colonyId: string) =>
prisma.notification.findMany({
where: {
userId: readerId,
dedupeKey: {
startsWith: `withdrawal-conflict:${harvestId}:${colonyId}:`,
},
},
});
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
const passwordHash = await bcrypt.hash(password, 12);
const admin = await prisma.user.create({
data: {
email: `honey-chain-admin-${suffix}@example.com`,
passwordHash,
name: 'Honey chain admin',
role: { connect: { key: 'ADMIN' } },
},
});
adminId = admin.id;
const staff = await prisma.user.create({
data: {
email: `honey-chain-staff-${suffix}@example.com`,
passwordHash,
name: 'Honey chain staff',
role: { connect: { key: 'STAFF' } },
},
});
staffId = staff.id;
asAdmin.Authorization = `Bearer ${(await login(app, admin.email, password)).accessToken}`;
asStaff.Authorization = `Bearer ${(await login(app, staff.email, password)).accessToken}`;
const role = await prisma.role.create({
data: {
key: `HC_READER_${suffix}`,
name: 'Honigkette Leser',
permissions: {
create: ['harvests:read', 'treatments:read'].map((key) => ({
permission: { connect: { key } },
})),
},
},
});
roleId = role.id;
readerId = (
await prisma.user.create({
data: {
email: `honey-chain-reader-${suffix}@example.com`,
passwordHash,
name: 'Honey chain reader',
roleId,
},
})
).id;
apiaryId = (
await prisma.apiary.create({ data: { name: `Honigkette ${suffix}` } })
).id;
subscriptionId = (
await prisma.webhookSubscription.create({
data: {
url: 'https://hooks.example.com/honey-chain-e2e',
events: ['batch.created'],
secret: `honey-chain-${suffix}`,
createdByUserId: adminId,
},
})
).id;
});
afterAll(async () => {
await prisma.notification.deleteMany({
where: {
category: 'WITHDRAWAL',
relatedId: { in: [...harvestIds, ...colonyIds] },
},
});
const rootIds = [...extractionIds, ...honeyBatchIds, ...harvestIds];
const roots = await prisma.domainEvent.findMany({
where: { aggregateId: { in: rootIds } },
select: { correlationId: true },
});
await prisma.domainEvent.deleteMany({
where: {
OR: [
{ aggregateId: { in: rootIds } },
{ correlationId: { in: roots.map((root) => root.correlationId) } },
],
},
});
await prisma.webhookDelivery.deleteMany({ where: { subscriptionId } });
await prisma.webhookSubscription.deleteMany({
where: { id: subscriptionId },
});
await prisma.auditLog.deleteMany({
where: {
OR: [
{ userId: { in: [adminId, staffId, readerId] } },
{
entityId: {
in: [...extractionIds, ...honeyBatchIds, ...treatmentIds],
},
},
],
},
});
await prisma.honeyBatch.deleteMany({
where: { id: { in: honeyBatchIds } },
});
await prisma.extractionHarvest.deleteMany({
where: { extractionId: { in: extractionIds } },
});
await prisma.extraction.deleteMany({
where: { id: { in: extractionIds } },
});
await prisma.harvestColony.deleteMany({
where: { harvestId: { in: harvestIds } },
});
await prisma.harvest.deleteMany({ where: { id: { in: harvestIds } } });
await prisma.treatment.deleteMany({ where: { id: { in: treatmentIds } } });
await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } });
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
await prisma.refreshToken.deleteMany({
where: { userId: { in: [adminId, staffId, readerId] } },
});
await prisma.user.deleteMany({
where: { id: { in: [adminId, staffId, readerId] } },
});
await prisma.role.deleteMany({ where: { id: roleId } });
await app.close();
});
let colonyId: string;
beforeAll(async () => {
const colony = await prisma.colony.create({
data: {
code: `HC-${suffix}`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
},
});
colonyId = colony.id;
colonyIds.push(colony.id);
});
describe('prefill', () => {
let harvestA: string;
let harvestB: string;
let harvestC: string;
let harvestNoNet: string;
beforeAll(async () => {
harvestA = await harvestWith(colonyId, '2026-05-10', 20, 'Blütenhonig');
harvestB = await harvestWith(colonyId, '2026-05-12', 22.5, 'Blütenhonig');
harvestC = await harvestWith(colonyId, '2026-05-14', 5, 'Waldhonig');
harvestNoNet = await harvestWith(colonyId, '2026-05-16', null, null);
});
it('prefill sums the net weights of the chosen harvests and keeps a shared honey type', async () => {
const response = await request(server())
.get(`/api/v1/extractions/prefill?harvestIds=${harvestA},${harvestB}`)
.set(asAdmin)
.expect(200);
expect(response.body as ExtractionPrefillBody).toEqual({
amountKg: 42.5,
honeyType: 'Blütenhonig',
sources: [
{ harvestId: harvestA, date: '2026-05-10', netWeight: 20 },
{ harvestId: harvestB, date: '2026-05-12', netWeight: 22.5 },
],
warnings: [],
});
});
it('offers no honey type for different types and no amount when a net weight is missing', async () => {
const mixed = await request(server())
.get(`/api/v1/extractions/prefill?harvestIds=${harvestA},${harvestC}`)
.set(asAdmin)
.expect(200);
expect((mixed.body as ExtractionPrefillBody).honeyType).toBeNull();
expect((mixed.body as ExtractionPrefillBody).amountKg).toBe(25);
const missing = await request(server())
.get(
`/api/v1/extractions/prefill?harvestIds=${harvestA},${harvestNoNet}`,
)
.set(asAdmin)
.expect(200);
const body = missing.body as ExtractionPrefillBody;
expect(body.amountKg).toBeNull();
expect(body.warnings.map((warning) => warning.code)).toEqual([
'HARVEST_NET_MISSING',
]);
});
it('rejects unknown, missing and too many harvest ids, and a user without extractions:create', async () => {
await request(server())
.get(`/api/v1/extractions/prefill?harvestIds=${harvestA},nope`)
.set(asAdmin)
.expect(400);
await request(server())
.get('/api/v1/extractions/prefill')
.set(asAdmin)
.expect(400);
const tooMany = Array.from({ length: 51 }, (_, i) => `h${i}`).join(',');
await request(server())
.get(`/api/v1/extractions/prefill?harvestIds=${tooMany}`)
.set(asAdmin)
.expect(400);
await request(server())
.get(`/api/v1/extractions/prefill?harvestIds=${harvestA}`)
.set(asStaff)
.expect(403);
});
it('batch prefill proposes the rest of the extraction with its honey type and water content', async () => {
const extraction = await prisma.extraction.create({
data: {
date: new Date('2026-08-01T00:00:00.000Z'),
userId: adminId,
amount: 42.5,
waterContent: 17.2,
},
});
extractionIds.push(extraction.id);
await prisma.extractionHarvest.createMany({
data: [harvestA, harvestB].map((harvestId) => ({
extractionId: extraction.id,
harvestId,
})),
});
await prisma.honeyBatch.create({
data: {
batchNumber: `HC-PRE-1-${suffix}`,
extractionId: extraction.id,
amount: 20,
initialAmount: 20,
},
});
// A batch without a recorded amount does not count.
await prisma.honeyBatch.create({
data: {
batchNumber: `HC-PRE-2-${suffix}`,
extractionId: extraction.id,
},
});
honeyBatchIds.push(
...(
await prisma.honeyBatch.findMany({
where: { extractionId: extraction.id },
select: { id: true },
})
).map((batch) => batch.id),
);
const response = await request(server())
.get(`/api/v1/honey-batches/prefill?extractionId=${extraction.id}`)
.set(asAdmin)
.expect(200);
expect(response.body as HoneyBatchPrefillBody).toEqual({
remainingKg: 22.5,
honeyType: 'Blütenhonig',
waterContent: 17.2,
warnings: [],
});
await request(server())
.get('/api/v1/honey-batches/prefill?extractionId=nope')
.set(asAdmin)
.expect(404);
await request(server())
.get('/api/v1/honey-batches/prefill')
.set(asAdmin)
.expect(400);
await request(server())
.get(`/api/v1/honey-batches/prefill?extractionId=${extraction.id}`)
.set(asStaff)
.expect(403);
});
});
describe('plausibility warnings', () => {
let harvestA: string;
let harvestNoNet: string;
beforeAll(async () => {
harvestA = await harvestWith(colonyId, '2026-06-10', 20, null);
harvestNoNet = await harvestWith(colonyId, '2026-06-12', null, null);
});
it('extraction and batch answers carry both plausibility warnings, never 4xx', async () => {
// 30 kg out of 20 kg net.
const extraction = await postExtraction([harvestA], 30);
expect(extraction.warnings.map((warning) => warning.code)).toEqual([
'EXTRACTION_EXCEEDS_HARVEST',
]);
expect(extraction.warnings[0]).toMatchObject({
severity: 'WARNUNG',
refs: [{ type: 'Harvest', id: harvestA }],
});
// 25 + 10 kg of batches out of 30 kg.
const first = await postBatch(extraction.id, 25, 'W1');
expect(first.warnings).toEqual([]);
const second = await postBatch(extraction.id, 10, 'W2');
expect(second.warnings).toHaveLength(1);
expect(second.warnings[0]).toMatchObject({
code: 'BATCHES_EXCEED_EXTRACTION',
severity: 'WARNUNG',
});
// The write happened all the same.
expect(await prisma.honeyBatch.count({ where: { id: second.id } })).toBe(
1,
);
const patched = await request(server())
.patch(`/api/v1/honey-batches/${second.id}`)
.set(asAdmin)
.send({ honeyType: 'Blütenhonig' })
.expect(200);
expect(
(patched.body as WithWarningsBody).warnings.map((w) => w.code),
).toEqual(['BATCHES_EXCEED_EXTRACTION']);
const corrected = await request(server())
.patch(`/api/v1/extractions/${extraction.id}`)
.set(asAdmin)
.send({ amount: 20 })
.expect(200);
expect(
(corrected.body as WithWarningsBody).warnings.map((w) => w.code),
).toEqual([]);
const noNet = await request(server())
.patch(`/api/v1/extractions/${extraction.id}`)
.set(asAdmin)
.send({ harvestIds: [harvestA, harvestNoNet], amount: 500 })
.expect(200);
expect(
(noNet.body as WithWarningsBody).warnings.map((w) => w.code),
).toEqual(['HARVEST_NET_MISSING']);
});
it('records the events with audit entries on create and update', async () => {
const own = await postExtraction([harvestA], 5);
const id = own.id;
await request(server())
.patch(`/api/v1/extractions/${id}`)
.set(asAdmin)
.send({ amount: 6 })
.expect(200);
await request(server())
.patch(`/api/v1/extractions/${id}`)
.set(asAdmin)
.send({ harvestIds: [harvestA, harvestNoNet] })
.expect(200);
const events = await prisma.domainEvent.findMany({
where: { aggregateType: 'Extraction', aggregateId: id },
orderBy: { occurredAt: 'asc' },
});
expect(events.map((event) => event.type)).toEqual([
'ExtractionRecorded',
'ExtractionCorrected',
'ExtractionCorrected',
]);
expect(events[2].payload).toEqual({
harvestIds: expect.arrayContaining([
harvestA,
harvestNoNet,
]) as string[],
});
expect(
await prisma.auditLog.count({
where: { entityType: 'Extraction', entityId: id },
}),
).toBe(3);
});
});
describe('withdrawal at the extraction stage', () => {
it('an extraction of a harvest inside a withdrawal window warns but notifies no second time', async () => {
const colony = await prisma.colony.create({
data: {
code: `HC-W-${suffix}`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
},
});
colonyIds.push(colony.id);
// 2026-07-10 + 20 days: harvestable again from 2026-07-30.
const treatment = await prisma.treatment.create({
data: {
colonyId: colony.id,
userId: adminId,
date: new Date('2026-07-10T00:00:00.000Z'),
product: 'Oxuvar',
withdrawalPeriodDays: 20,
},
});
treatmentIds.push(treatment.id);
const harvestResponse = await request(server())
.post('/api/v1/harvests')
.set(asAdmin)
.send({
apiaryId,
date: '2026-07-20T00:00:00.000Z',
colonyIds: [colony.id],
netWeight: 12,
})
.expect(201);
const harvestId = (harvestResponse.body as WithWarningsBody).id;
harvestIds.push(harvestId);
await drainDomainEvents(app, [harvestId]);
expect(await conflictRowsOf(harvestId, colony.id)).toHaveLength(1);
const extraction = await postExtraction([harvestId], 10);
expect(extraction.warnings).toHaveLength(1);
expect(extraction.warnings[0]).toMatchObject({
code: 'WITHDRAWAL_ACTIVE',
severity: 'WARNUNG',
});
expect(extraction.warnings[0].refs).toEqual(
expect.arrayContaining([
{ type: 'Colony', id: colony.id },
{ type: 'Treatment', id: treatment.id },
{ type: 'Harvest', id: harvestId },
]),
);
await drainDomainEvents(app, [extraction.id]);
await drainDomainEvents(app, [extraction.id]);
expect(await conflictRowsOf(harvestId, colony.id)).toHaveLength(1);
});
it('the prefill already warns about a harvest inside a withdrawal window', async () => {
const colony = await prisma.colony.create({
data: {
code: `HC-P-${suffix}`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
},
});
colonyIds.push(colony.id);
const treatment = await prisma.treatment.create({
data: {
colonyId: colony.id,
userId: adminId,
date: new Date('2026-07-10T00:00:00.000Z'),
product: 'Oxuvar',
withdrawalPeriodDays: 20,
},
});
treatmentIds.push(treatment.id);
const harvestId = await harvestWith(colony.id, '2026-07-20', 12, null);
const response = await request(server())
.get(`/api/v1/extractions/prefill?harvestIds=${harvestId}`)
.set(asAdmin)
.expect(200);
const body = response.body as ExtractionPrefillBody;
expect(body.warnings.map((warning) => warning.code)).toEqual([
'WITHDRAWAL_ACTIVE',
]);
// Proposals only: nothing was written or notified.
expect(await conflictRowsOf(harvestId, colony.id)).toHaveLength(0);
});
it('an extraction notifies a conflict the harvest did not (harvest entered without an event)', async () => {
const colony = await prisma.colony.create({
data: {
code: `HC-X-${suffix}`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
},
});
colonyIds.push(colony.id);
const treatment = await prisma.treatment.create({
data: {
colonyId: colony.id,
userId: adminId,
date: new Date('2026-07-10T00:00:00.000Z'),
product: 'Oxuvar',
withdrawalPeriodDays: 20,
},
});
treatmentIds.push(treatment.id);
const harvestId = await harvestWith(colony.id, '2026-07-20', 12, null);
expect(await conflictRowsOf(harvestId, colony.id)).toHaveLength(0);
const extraction = await postExtraction([harvestId], 10);
await drainDomainEvents(app, [extraction.id]);
await drainDomainEvents(app, [extraction.id]);
const rows = await conflictRowsOf(harvestId, colony.id);
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
category: 'WITHDRAWAL',
relatedType: 'Harvest',
relatedId: harvestId,
});
});
});
describe('webhook batch.created', () => {
let batchId: string;
it('a new honey batch delivers batch.created through the handler', async () => {
const harvestId = await harvestWith(colonyId, '2026-09-01', 8, null);
const extraction = await postExtraction([harvestId], 8);
const batch = await postBatch(extraction.id, 8, 'WH');
batchId = batch.id;
// The request itself queued nothing.
expect(await deliveriesOfBatch(batchId)).toHaveLength(0);
const events = await prisma.domainEvent.findMany({
where: { aggregateType: 'HoneyBatch', aggregateId: batchId },
});
expect(events.map((event) => event.type)).toEqual(['HoneyBatchRecorded']);
expect(events[0].payload).toEqual({ extractionId: extraction.id });
await drainDomainEvents(app, [batchId]);
await drainDomainEvents(app, [batchId]);
const deliveries = await deliveriesOfBatch(batchId);
expect(deliveries).toHaveLength(1);
expect(deliveries[0].event).toBe('batch.created');
});
it('the batch.created payload has the same shape as before (honeyBatchInclude)', async () => {
const [delivery] = await deliveriesOfBatch(batchId);
const sent = JSON.parse(delivery.body) as {
event: string;
data: Record<string, unknown>;
};
const response = await request(server())
.get(`/api/v1/honey-batches/${batchId}`)
.set(asAdmin)
.expect(200);
expect(sent.event).toBe('batch.created');
expect(sent.data).toEqual(response.body);
expect(Object.keys(sent.data).sort()).toEqual(
expect.arrayContaining([
'measurements',
'storageContainer',
'extraction',
'batchNumber',
]),
);
});
it('a correction records HoneyBatchCorrected and sends no second webhook', async () => {
await request(server())
.patch(`/api/v1/honey-batches/${batchId}`)
.set(asAdmin)
.send({ honeyType: 'Sommertracht' })
.expect(200);
await drainDomainEvents(app, [batchId]);
const events = await prisma.domainEvent.findMany({
where: { aggregateType: 'HoneyBatch', aggregateId: batchId },
orderBy: { occurredAt: 'asc' },
});
expect(events.map((event) => event.type)).toEqual([
'HoneyBatchRecorded',
'HoneyBatchCorrected',
]);
expect(await deliveriesOfBatch(batchId)).toHaveLength(1);
});
});
});
@@ -0,0 +1,326 @@
import { Prisma } from '../generated/prisma';
import {
deferClaim,
claimBatch,
releaseStaleClaims,
settleSkipped,
settleSuccess,
} from '../src/common/outbox/outbox-claims';
import { PrismaService } from '../src/prisma/prisma.service';
interface Claimed {
id: string;
claimedAt: Date;
}
/**
* Schema-level guarantees of the T4 Telegram migration and the outbox
* helpers on NotificationDelivery: the target CHECK, the poll-state
* singleton, cascade with the user, compare-and-set settling.
*/
describe('Telegram schema and delivery outbox (e2e)', () => {
let prisma: PrismaService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
let userId: string;
let notificationId: string;
const chatBase = BigInt(9_000_000_000) + BigInt(Date.now() % 1_000_000);
beforeAll(async () => {
prisma = new PrismaService();
const user = await prisma.user.create({
data: {
email: `tg-schema-${suffix}@example.com`,
passwordHash: 'x',
name: 'Telegram Schema',
role: { connect: { key: 'READONLY' } },
},
});
userId = user.id;
const notification = await prisma.notification.create({
data: {
userId,
priority: 'INFO',
category: 'SYSTEM',
message: 'm',
},
});
notificationId = notification.id;
});
afterAll(async () => {
await prisma.user.deleteMany({ where: { id: userId } });
await prisma.$disconnect();
});
afterEach(async () => {
await prisma.notificationDelivery.deleteMany({
where: { OR: [{ userId }, { chatId: { gte: chatBase } }] },
});
});
it('has exactly one TelegramPollState row, created by the migration', async () => {
const rows = await prisma.telegramPollState.findMany();
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({ id: 'singleton', updateOffset: 0n });
});
describe('NotificationDelivery target CHECK', () => {
const base = { channel: 'TELEGRAM' } as const;
it('accepts a notification for a user', async () => {
await expect(
prisma.notificationDelivery.create({
data: { ...base, userId, notificationId },
}),
).resolves.toMatchObject({ status: 'PENDING', attempts: 0 });
});
it('accepts a system reply for a chat', async () => {
await expect(
prisma.notificationDelivery.create({
data: { ...base, chatId: chatBase, templateCode: 'HELP' },
}),
).resolves.toMatchObject({ status: 'PENDING' });
});
it.each([
['an empty row', {}],
['notification without user', { notificationId: 'n' }],
['template without chat', { templateCode: 'HELP' }],
['chat without template', { chatId: chatBase }],
[
'notification and template',
{
userId: 'u',
notificationId: 'n',
templateCode: 'HELP',
chatId: chatBase,
},
],
])('rejects %s', async (_name, extra) => {
const insert = prisma.$executeRaw`INSERT INTO "NotificationDelivery" ("id","channel","userId","chatId","notificationId","templateCode")
VALUES (${`chk-${suffix}-${Math.random()}`}, 'TELEGRAM',
${(extra as { userId?: string }).userId ?? null},
${(extra as { chatId?: bigint }).chatId ?? null},
${(extra as { notificationId?: string }).notificationId ?? null},
${(extra as { templateCode?: string }).templateCode ?? null})`;
await expect(insert).rejects.toThrow();
});
});
it('allows only the singleton row in TelegramPollState', async () => {
await expect(
prisma.$executeRaw`INSERT INTO "TelegramPollState" ("id") VALUES (${`other-${suffix}`})`,
).rejects.toThrow();
});
it('creates at most one delivery per notification and channel', async () => {
await prisma.notificationDelivery.create({
data: { channel: 'TELEGRAM', userId, notificationId },
});
await expect(
prisma.notificationDelivery.create({
data: { channel: 'TELEGRAM', userId, notificationId },
}),
).rejects.toThrow();
});
it('indexes NotificationDelivery by notificationId', async () => {
const rows = await prisma.$queryRaw<{ indexdef: string }[]>`
SELECT indexdef FROM pg_indexes
WHERE tablename = 'NotificationDelivery' AND indexdef LIKE '%("notificationId")%'`;
expect(rows.length).toBeGreaterThanOrEqual(1);
});
describe('outbox helpers', () => {
async function claimOne(): Promise<Claimed> {
const created = await prisma.notificationDelivery.create({
data: { channel: 'TELEGRAM', userId, notificationId },
});
const [claimed] = await claimBatch<Claimed>(
prisma,
'NotificationDelivery',
{
limit: 1,
filter: Prisma.sql`"id" = ${created.id}`,
orderBy: 'createdAt',
},
);
expect(claimed.id).toBe(created.id);
return claimed;
}
it('settleSkipped needs the matching claim and records the reason', async () => {
const claimed = await claimOne();
await expect(
settleSkipped(
prisma,
'NotificationDelivery',
claimed.id,
new Date(claimed.claimedAt.getTime() + 1),
'nope',
),
).resolves.toBe(false);
await expect(
settleSkipped(
prisma,
'NotificationDelivery',
claimed.id,
claimed.claimedAt,
'chat gone',
),
).resolves.toBe(true);
await expect(
prisma.notificationDelivery.findUnique({ where: { id: claimed.id } }),
).resolves.toMatchObject({
status: 'SKIPPED',
lastError: 'chat gone',
claimedAt: null,
attempts: 0,
});
});
it('deferClaim puts the entry back for later without counting an attempt', async () => {
const claimed = await claimOne();
await expect(
deferClaim(
prisma,
'NotificationDelivery',
claimed.id,
new Date(0),
1000,
),
).resolves.toBe(false);
await expect(
deferClaim(
prisma,
'NotificationDelivery',
claimed.id,
claimed.claimedAt,
60_000,
),
).resolves.toBe(true);
const row = await prisma.notificationDelivery.findUniqueOrThrow({
where: { id: claimed.id },
});
expect(row).toMatchObject({
status: 'PENDING',
attempts: 0,
claimedAt: null,
});
const [{ ahead }] = await prisma.$queryRaw<{ ahead: number }[]>`
SELECT EXTRACT(EPOCH FROM ("availableAt" - now()))::float8 AS ahead
FROM "NotificationDelivery" WHERE "id" = ${claimed.id}`;
expect(ahead).toBeGreaterThan(50);
expect(ahead).toBeLessThanOrEqual(61);
});
it('settleSuccess marks DELIVERED with deliveredAt', async () => {
const claimed = await claimOne();
await expect(
settleSuccess(
prisma,
'NotificationDelivery',
claimed.id,
claimed.claimedAt,
'DELIVERED',
),
).resolves.toBe(true);
const row = await prisma.notificationDelivery.findUniqueOrThrow({
where: { id: claimed.id },
});
expect(row.status).toBe('DELIVERED');
expect(row.deliveredAt).not.toBeNull();
});
it('the stale release works on NotificationDelivery', async () => {
const claimed = await claimOne();
await prisma.$executeRaw`UPDATE "NotificationDelivery" SET "claimedAt" = now() - interval '1 day' WHERE "id" = ${claimed.id}`;
expect(
await releaseStaleClaims(prisma, 'NotificationDelivery'),
).toBeGreaterThanOrEqual(1);
await expect(
prisma.notificationDelivery.findUniqueOrThrow({
where: { id: claimed.id },
}),
).resolves.toMatchObject({ status: 'PENDING', attempts: 1 });
});
});
it('deleting the user removes the chat binding, tokens and deliveries', async () => {
const other = await prisma.user.create({
data: {
email: `tg-cascade-${suffix}@example.com`,
passwordHash: 'x',
name: 'Cascade',
role: { connect: { key: 'READONLY' } },
},
});
const note = await prisma.notification.create({
data: {
userId: other.id,
priority: 'INFO',
category: 'SYSTEM',
message: 'm',
},
});
await prisma.telegramLink.create({
data: { userId: other.id, chatId: chatBase + 1n },
});
await prisma.telegramLinkToken.create({
data: {
userId: other.id,
tokenHash: `hash-${suffix}`,
expiresAt: new Date(Date.now() + 60_000),
},
});
await prisma.notificationDelivery.create({
data: { channel: 'TELEGRAM', userId: other.id, notificationId: note.id },
});
await prisma.user.delete({ where: { id: other.id } });
expect(
await prisma.telegramLink.count({ where: { userId: other.id } }),
).toBe(0);
expect(
await prisma.telegramLinkToken.count({ where: { userId: other.id } }),
).toBe(0);
expect(
await prisma.notificationDelivery.count({ where: { userId: other.id } }),
).toBe(0);
});
it('one chat belongs to exactly one user', async () => {
const link = await prisma.telegramLink.create({
data: { userId, chatId: chatBase + 2n },
});
const second = await prisma.user.create({
data: {
email: `tg-chat-${suffix}@example.com`,
passwordHash: 'x',
name: 'Second',
role: { connect: { key: 'READONLY' } },
},
});
try {
await expect(
prisma.telegramLink.create({
data: { userId: second.id, chatId: link.chatId },
}),
).rejects.toThrow();
} finally {
await prisma.telegramLink.deleteMany({ where: { userId } });
await prisma.user.delete({ where: { id: second.id } });
}
});
});
@@ -0,0 +1,173 @@
import { Test, TestingModule } from '@nestjs/testing';
import {
addDays,
berlinToday,
daysBetween,
dayToDate,
} from '../src/common/dates';
import { PrismaModule } from '../src/prisma/prisma.module';
import { PrismaService } from '../src/prisma/prisma.service';
import { RECENT_WINDOW_DAYS } from '../src/weather/history/weather-history.config';
import { WeatherHistoryModule } from '../src/weather/history/weather-history.module';
import { WeatherHistoryService } from '../src/weather/history/weather-history.service';
// Two stands on one rounded point (61.23 / 5.31) and one on a point that fails.
// Other e2e files share the database, so nothing here counts requests or rows
// of other stands.
const GOOD = { lat: '61.23', lon: '5.31' };
const BAD = { lat: '62.11', lon: '6.12' };
function json(body: unknown): Response {
return new Response(JSON.stringify(body), { status: 200 });
}
function days(from: string, to: string): string[] {
const out: string[] = [];
for (let day = from; day <= to; day = addDays(day, 1)) out.push(day);
return out;
}
describe('Weather history (e2e)', () => {
const today = berlinToday();
const yesterday = addDays(today, -1);
const januaryFirst = `${today.slice(0, 4)}-01-01`;
const expectedDays = Math.max(0, daysBetween(januaryFirst, yesterday) + 1);
const suffix = `${Date.now()}`;
let moduleRef: TestingModule;
let prisma: PrismaService;
let service: WeatherHistoryService;
let fetchMock: jest.SpiedFunction<typeof fetch>;
let apiaryIds: string[];
let badApiaryId: string;
const ownCalls = (point: { lat: string; lon: string }): URL[] =>
fetchMock.mock.calls
.map(([url]) => url as URL)
.filter(
(url) =>
url.searchParams.get('latitude') === point.lat &&
url.searchParams.get('longitude') === point.lon,
);
beforeAll(async () => {
moduleRef = await Test.createTestingModule({
imports: [PrismaModule, WeatherHistoryModule],
}).compile();
await moduleRef.init();
prisma = moduleRef.get(PrismaService);
service = moduleRef.get(WeatherHistoryService);
const a = await prisma.apiary.create({
data: {
name: `Weather history A ${suffix}`,
latitude: 61.2345,
longitude: 5.3109,
},
});
const b = await prisma.apiary.create({
data: {
name: `Weather history B ${suffix}`,
latitude: 61.2349,
longitude: 5.3101,
},
});
const bad = await prisma.apiary.create({
data: {
name: `Weather history bad ${suffix}`,
latitude: 62.1149,
longitude: 6.1201,
},
});
apiaryIds = [a.id, b.id];
badApiaryId = bad.id;
});
afterAll(async () => {
await prisma.apiary.deleteMany({
where: { id: { in: [...apiaryIds, badApiaryId] } },
});
await moduleRef.close();
});
beforeEach(() => {
fetchMock = jest.spyOn(global, 'fetch').mockImplementation((input) => {
const url = input as URL;
const lat = url.searchParams.get('latitude');
if (lat === BAD.lat) {
return Promise.resolve(new Response('nope', { status: 500 }));
}
if (lat !== GOOD.lat) {
// Stands of other e2e files: no data.
return Promise.resolve(
json({ daily: { time: [], temperature_2m_mean: [] } }),
);
}
const archive = url.searchParams.has('start_date');
const time = archive
? days(
url.searchParams.get('start_date') as string,
url.searchParams.get('end_date') as string,
)
: days(addDays(today, -92), today);
return Promise.resolve(
json({
daily: { time, temperature_2m_mean: time.map(() => 10.5) },
}),
);
});
});
afterEach(() => {
fetchMock.mockRestore();
});
it('fetches missing days once, with rounded coordinates, and writes nothing on an error', async () => {
const controller = new AbortController();
const result = await service.syncOnce(today, controller.signal);
// The failing point wrote nothing and counted as failed.
expect(result.failedPoints).toBeGreaterThanOrEqual(1);
expect(
await prisma.apiaryWeatherDay.count({ where: { apiaryId: badApiaryId } }),
).toBe(0);
// One archive and one forecast request for the shared rounded point.
const calls = ownCalls(GOOD);
expect(calls.length).toBe(expectedDays > 0 ? 2 : 0);
for (const url of calls) {
expect(url.searchParams.get('latitude')).toBe('61.23');
expect(url.searchParams.get('longitude')).toBe('5.31');
expect(url.searchParams.get('daily')).toBe('temperature_2m_mean');
expect(url.searchParams.get('timezone')).toBe('Europe/Berlin');
}
// Both stands of the point carry every day from January 1st to yesterday.
const recentStart = addDays(today, -RECENT_WINDOW_DAYS);
for (const apiaryId of apiaryIds) {
const rows = await prisma.apiaryWeatherDay.findMany({
where: { apiaryId },
orderBy: { day: 'asc' },
});
expect(rows).toHaveLength(expectedDays);
for (const row of rows) {
const day = row.day.toISOString().slice(0, 10);
expect(row.meanTempC).toBe(10.5);
expect(row.source).toBe(day >= recentStart ? 'forecast' : 'archive');
expect(day >= januaryFirst && day <= yesterday).toBe(true);
}
}
if (expectedDays > 0) {
expect(
await prisma.apiaryWeatherDay.count({
where: { apiaryId: apiaryIds[0], day: dayToDate(yesterday) },
}),
).toBe(1);
}
});
it('asks for nothing when every day is already stored', async () => {
await service.syncOnce(today, new AbortController().signal);
expect(ownCalls(GOOD)).toHaveLength(0);
});
});
@@ -15,6 +15,7 @@ import type { DomainEventEnvelope } from '../src/domain-events/domain-event-type
import { DomainEventsService } from '../src/domain-events/domain-events.service';
import { OnDomainEvent } from '../src/domain-events/on-domain-event.decorator';
import { PrismaService } from '../src/prisma/prisma.service';
import { WEATHER_HISTORY_SOURCE } from '../src/weather/history/weather-history-source';
import { WebhookDeliveryWorker } from '../src/webhooks/webhook-delivery.worker';
import { WorkerModule } from '../src/worker/worker.module';
import { DOMAIN_EVENT_POLL_MS } from '../src/worker/worker-runtime.service';
@@ -159,6 +160,10 @@ describe('Worker runtime (e2e)', () => {
})
.overrideProvider(DomainEventDispatcher)
.useClass(IsolatedDispatcher)
// The phenology-weather loop ticks right after bootstrap: no real
// network call in tests (weather-history.e2e-spec.ts covers the sync).
.overrideProvider(WEATHER_HISTORY_SOURCE)
.useValue({ name: 'test', dailyMeans: () => Promise.resolve([]) })
.compile();
prisma = worker.get(PrismaService);
// The outbox itself is covered by calendar-sync.e2e-spec.ts; here only