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

+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,