feat: T4b/T4c wave 9 - phenology reports, autopilot A4-A10, honey ledger, Telegram delivery
Backend Image / build-and-push (push) Successful in 4m26s
Frontend Image / build-and-push (push) Successful in 7m19s
Manifests / validate (push) Successful in 17s

- Phenology reports API with corrections and revokes; recompute per
  apiary via PhenologyRecomputeRequested, allowBackward for corrections;
  season changes are announced in the morning digest (changes since the
  previous digest, each once)
- Season autopilot A4-A10 with per-apiary sync (Apiary before Colony
  locks) and WinterCheckRecorded
- Honey batch ledger at runtime: initial amount, BOTTLING bookings,
  manual movements, one gram rounding, untracked bottlings never book
- Telegram delivery: planning, Berlin delivery times incl. DST, quiet
  hours, permission re-check and stale skip at send time, rate limits,
  cleanup of old deliveries
- ARCHITECTURE.md: assumptions 47-50 and updated sections 11.x

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 21:13:23 +02:00
1 parent 1971b2d9c4
commit 16cc557462
74 files changed
+9634 -340

No files matched your search

@@ -94,6 +94,59 @@ describe('PermissionResolverService', () => {
expect(prisma.user.findUnique).toHaveBeenCalledTimes(2);
});
describe('getFreshPermissions', () => {
it('the permission check reads fresh, not the 30 s cache', async () => {
prisma.user.findUnique.mockResolvedValueOnce(
activeUserWithPermissions('tasks:read'),
);
await resolver.getPermissions('user-1');
// The right is revoked on another replica: this process has no
// invalidateUser call, the cache still holds the old set.
prisma.user.findUnique.mockResolvedValueOnce(
activeUserWithPermissions('colonies:read'),
);
const fresh = await resolver.getFreshPermissions('user-1');
expect([...fresh]).toEqual(['colonies:read']);
expect(prisma.user.findUnique).toHaveBeenCalledTimes(2);
});
it('fills the cache for getPermissions', async () => {
prisma.user.findUnique.mockResolvedValue(
activeUserWithPermissions('tasks:read'),
);
await resolver.getFreshPermissions('user-1');
const cached = await resolver.getPermissions('user-1');
expect([...cached]).toEqual(['tasks:read']);
expect(prisma.user.findUnique).toHaveBeenCalledTimes(1);
});
it('is empty for an inactive user, an inactive role and a missing user', async () => {
prisma.user.findUnique.mockResolvedValueOnce({
isActive: false,
role: {
status: 'ACTIVE',
permissions: [{ permission: { key: 'tasks:read' } }],
},
});
prisma.user.findUnique.mockResolvedValueOnce({
isActive: true,
role: {
status: 'INACTIVE',
permissions: [{ permission: { key: 'tasks:read' } }],
},
});
prisma.user.findUnique.mockResolvedValueOnce(null);
expect((await resolver.getFreshPermissions('u1')).size).toBe(0);
expect((await resolver.getFreshPermissions('u2')).size).toBe(0);
expect((await resolver.getFreshPermissions('u3')).size).toBe(0);
});
});
describe('isTokenValid', () => {
const nowSeconds = Math.floor(Date.now() / 1000);
@@ -37,6 +37,19 @@ export class PermissionResolverService {
return entry.permissions;
}
/**
* The permissions read fresh (one primary-key lookup), never from the
* cache, and stored as the cache entry. For a process that does not hear
* invalidateUser of the API pods (the worker): a right revoked there must
* stop mattering at once, not after the 30 s cache window. Empty for an
* inactive user or role.
*/
async getFreshPermissions(userId: string): Promise<ReadonlySet<string>> {
const entry = await this.loadEntry(userId);
this.cache.set(userId, entry);
return entry.permissions;
}
/**
* Always read fresh (one primary-key lookup) and stored as the cache
* entry the PermissionsGuard then uses: a deactivation, role change or
@@ -1,6 +1,6 @@
import { DEFAULT_RULES, ruleConfigOf } from '../automation/rule-config';
import { calendarSeasonState } from '../season/season-calendar';
import { loadAutopilotInputIn } from './autopilot-input';
import { lateSummerStart, loadAutopilotInputIn } from './autopilot-input';
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
const season = calendarSeasonState(
@@ -55,7 +55,13 @@ describe('loadAutopilotInputIn', () => {
{ id: 'single', date: d('2026-08-01'), startDate: null, endDate: null },
]);
const input = await loadAutopilotInputIn(db as never, 'c1', season);
const input = await loadAutopilotInputIn(
db as never,
'c1',
season,
'08-01',
'2026-08-20',
);
expect(input?.lastTreatment).toEqual({
id: 'old',
@@ -74,7 +80,13 @@ describe('loadAutopilotInputIn', () => {
},
]);
await loadAutopilotInputIn(db as never, 'c1', season);
await loadAutopilotInputIn(
db as never,
'c1',
season,
'08-01',
'2026-08-20',
);
expect(db.varroaMeasurement.count).toHaveBeenCalledWith({
where: { colonyId: 'c1', date: { gt: d('2026-08-10') } },
@@ -82,9 +94,119 @@ describe('loadAutopilotInputIn', () => {
});
it('has no last treatment without treatments', async () => {
const input = await loadAutopilotInputIn(tx([]) as never, 'c1', season);
const input = await loadAutopilotInputIn(
tx([]) as never,
'c1',
season,
'08-01',
'2026-08-20',
);
expect(input?.lastTreatment).toBeNull();
expect(input?.varroaSinceLastTreatmentEnd).toBe(false);
});
it('counts feedings and treatments from the real start of SPAETSOMMER', async () => {
const lateSummer = {
...season,
segment: 'SPAETSOMMER' as const,
segmentSince: '2026-07-25',
};
const db = tx([]);
await loadAutopilotInputIn(
db as never,
'c1',
lateSummer,
'08-01',
'2026-08-20',
);
expect(db.feeding.count).toHaveBeenLastCalledWith({
where: { colonyId: 'c1', date: { gte: d('2026-07-25') } },
});
expect(db.treatment.count).toHaveBeenCalledWith({
where: { colonyId: 'c1', date: { gte: d('2026-07-25') } },
});
});
it('counts nothing for late summer outside SPAETSOMMER and HERBST', async () => {
const db = tx([]);
const input = await loadAutopilotInputIn(
db as never,
'c1',
{ ...season, segment: 'SOMMER' },
'08-01',
'2026-08-20',
);
expect(input?.feedingsSinceLateSummer).toBe(0);
expect(input?.treatmentsSinceLateSummer).toBe(0);
expect(db.treatment.count).not.toHaveBeenCalled();
});
it('without an inspection counts feedings from the later of creation and the last 60 days', async () => {
const db = tx([]);
await loadAutopilotInputIn(
db as never,
'c1',
season,
'08-01',
'2026-08-20',
);
// created 2026-04-01, today 2026-08-20: 60 days back wins
expect(db.feeding.count).toHaveBeenNthCalledWith(1, {
where: { colonyId: 'c1', date: { gte: d('2026-06-21') } },
});
const young = tx([]);
young.colony.findUnique.mockResolvedValue({
id: 'c1',
code: 'B3',
status: 'ACTIVE',
colonyType: 'PRODUCTION_COLONY',
apiaryId: 'api-1',
createdAt: d('2026-08-10'),
feedStatus: null,
currentQueen: null,
});
await loadAutopilotInputIn(
young as never,
'c1',
season,
'08-01',
'2026-08-20',
);
expect(young.feeding.count).toHaveBeenNthCalledWith(1, {
where: { colonyId: 'c1', date: { gte: d('2026-08-10') } },
});
});
});
describe('lateSummerStart', () => {
const state = (
segment: 'SPAETSOMMER' | 'HERBST' | 'SOMMER',
segmentSince: string,
) => ({ ...season, year: 2026, segment, segmentSince });
it('is the start of the segment in SPAETSOMMER', () => {
expect(lateSummerStart(state('SPAETSOMMER', '2026-07-25'), '08-01')).toBe(
'2026-07-25',
);
});
it('is the calendar day of TRACHTENDE in HERBST, at the latest the start of HERBST', () => {
expect(lateSummerStart(state('HERBST', '2026-09-15'), '08-01')).toBe(
'2026-08-01',
);
expect(lateSummerStart(state('HERBST', '2026-07-20'), '08-01')).toBe(
'2026-07-20',
);
});
it('is null in every other segment', () => {
expect(lateSummerStart(state('SOMMER', '2026-06-20'), '08-01')).toBeNull();
});
});
@@ -1,21 +1,35 @@
import type { Prisma } from '../../generated/prisma';
import { berlinDayOf, dayToDate } from '../common/dates';
import { addDays, berlinDayOf, dayToDate } from '../common/dates';
import type { SeasonState } from '../season/season-types';
import type { AutopilotInput } from './autopilot.types';
/**
* Until Task 27 passes the real start of the late summer, the feedings and
* treatments of A4-A6 are counted from this day of the year (the built-in
* calendar day of TRACHTENDE).
*/
const LATE_SUMMER_FALLBACK_START = '08-01';
const LATEST_FIRST = [{ date: 'desc' }, { createdAt: 'desc' }] as const;
function lateSummerStart(season: SeasonState): string | null {
return season.segment === 'SPAETSOMMER' || season.segment === 'HERBST'
? `${season.year}-${LATE_SUMMER_FALLBACK_START}`
: null;
/** Without an inspection a feeding counts for A6 if it is at most this old. */
const FEEDING_LOOKBACK_DAYS = 60;
/**
* The day SPAETSOMMER began, for the feedings and treatments of A4/A5. In
* SPAETSOMMER that is the real start of the segment. The stored state keeps
* only the start of the current segment, so in HERBST it is the calendar day
* of TRACHTENDE (`calendarTrachtende`, MM-DD), at the latest the start of
* HERBST.
*
* TODO: store the real start as `lateSummerSince` on ApiaryPhenology (needs
* a migration) so HERBST no longer depends on the calendar day.
*/
export function lateSummerStart(
season: SeasonState,
calendarTrachtende: string,
): string | null {
if (season.segment === 'SPAETSOMMER') {
return season.segmentSince;
}
if (season.segment !== 'HERBST') {
return null;
}
const calendarDay = `${season.year}-${calendarTrachtende}`;
return calendarDay < season.segmentSince ? calendarDay : season.segmentSince;
}
/**
@@ -28,6 +42,8 @@ export async function loadAutopilotInputIn(
tx: Prisma.TransactionClient,
colonyId: string,
season: SeasonState,
calendarTrachtende: string,
today: string,
): Promise<AutopilotInput | null> {
const colony = await tx.colony.findUnique({
where: { id: colonyId },
@@ -86,13 +102,18 @@ export async function loadAutopilotInputIn(
(await tx.varroaMeasurement.count({
where: { colonyId, date: { gt: dayToDate(lastTreatment.endDay) } },
})) > 0;
// Feedings that answer a low feed status: since the inspection; without
// one only recent ones, from the colony's creation at the earliest.
const createdOn = berlinDayOf(colony.createdAt);
const lookbackFrom = addDays(today, -FEEDING_LOOKBACK_DAYS);
const feedingsSince =
inspection === null
? dayToDate(createdOn > lookbackFrom ? createdOn : lookbackFrom)
: inspection.date;
const feedingsSinceInspection = await tx.feeding.count({
where: {
colonyId,
...(inspection === null ? {} : { date: { gte: inspection.date } }),
},
where: { colonyId, date: { gte: feedingsSince } },
});
const lateSummer = lateSummerStart(season);
const lateSummer = lateSummerStart(season, calendarTrachtende);
const sinceLateSummer =
lateSummer === null ? null : { gte: dayToDate(lateSummer) };
const feedingsSinceLateSummer =
@@ -1,5 +1,11 @@
import { DEFAULT_RULES, ruleConfigOf } from '../automation/rule-config';
import { calendarSeasonState } from '../season/season-calendar';
import type {
PhaseSourceKey,
PhenologyPhaseKey,
SeasonSegmentKey,
SeasonState,
} from '../season/season-types';
import { evaluateAutopilot } from './autopilot-rules';
import type { AutopilotConfigs, AutopilotInput } from './autopilot.types';
@@ -62,6 +68,12 @@ const inspectionOn = (date: string) => ({
queenCellsPresent: null,
});
/** Treated and fed in late summer: keeps A4 and A5 out of the A3 tests. */
const LATE_SUMMER_DONE = {
treatmentsSinceLateSummer: 1,
feedingsSinceLateSummer: 1,
};
const TREATMENT = { id: 'tre_1', startDay: '2026-07-20', endDay: '2026-08-02' };
describe('autopilot rules', () => {
@@ -227,6 +239,7 @@ describe('autopilot rules', () => {
lastInspection: inspectionOn('2026-08-10'),
lastVarroa: { id: 'var_3', date: '2026-08-01' },
lastTreatment: TREATMENT,
...LATE_SUMMER_DONE,
}),
configs(),
today,
@@ -256,6 +269,8 @@ describe('autopilot rules', () => {
input(early, {
lastInspection: inspectionOn('2026-08-10'),
lastTreatment: TREATMENT,
...LATE_SUMMER_DONE,
...LATE_SUMMER_DONE,
}),
configs(),
early,
@@ -268,6 +283,8 @@ describe('autopilot rules', () => {
input(late, {
lastInspection: inspectionOn('2026-08-15'),
lastTreatment: TREATMENT,
...LATE_SUMMER_DONE,
...LATE_SUMMER_DONE,
varroaSinceLastTreatmentEnd: true,
}),
configs(),
@@ -286,6 +303,7 @@ describe('autopilot rules', () => {
input(today, {
lastInspection: inspectionOn('2026-08-05'),
lastTreatment: TREATMENT,
...LATE_SUMMER_DONE,
}),
cfg,
today,
@@ -370,3 +388,437 @@ describe('autopilot rules', () => {
expect(evaluateAutopilot(base, noInterval, today)).toEqual([]);
});
});
describe('autopilot rules A4-A10', () => {
/** A stored season state; the helper keeps the tests free of calendar maths. */
function inSeason(
segment: SeasonSegmentKey,
phase: PhenologyPhaseKey | null,
segmentSince: string,
phaseSource: PhaseSourceKey = 'REPORT',
): SeasonState {
return {
apiaryId: 'api_1',
year: Number(segmentSince.slice(0, 4)),
phase,
phaseSource,
reportedOn: null,
segment,
segmentSince,
springSince: `${segmentSince.slice(0, 4)}-03-20`,
gts: null,
gtsThroughDay: null,
note: null,
};
}
/** Keeps A1 and A2 quiet: a fresh inspection and measurement. */
function quiet(today: string, patch: Partial<AutopilotInput>) {
return input(today, {
lastInspection: inspectionOn(today),
lastVarroa: { id: 'var_1', date: today },
...patch,
});
}
const only = (rule: string, candidates: { rule: string }[]) =>
candidates.filter((candidate) => candidate.rule === rule);
it('A4: SPAETSOMMER without treatment since segment start -> SUMMER_TREATMENT, PLAN, HIGH, anchor year', () => {
const today = '2026-08-05';
const result = evaluateAutopilot(
quiet(today, {
season: inSeason('SPAETSOMMER', 'TRACHTENDE', '2026-07-25'),
}),
configs(),
today,
);
expect(only('A4', result)).toEqual([
{
rule: 'A4',
ruleKey: 'SEASON_AUTOPILOT',
intent: 'SUMMER_TREATMENT',
mode: 'PLAN',
reasonCode: 'SUMMER_TREATMENT_DUE',
evidence: {
colonyId: 'col_1',
phase: 'TRACHTENDE',
phaseSource: 'REPORT',
since: '2026-07-25',
reportedOn: null,
},
dueDate: today,
priority: 'HIGH',
anchor: '2026',
requiredPermission: 'treatments:read',
},
]);
});
it('A4: a treatment since the segment start, another segment or the rule off -> nothing', () => {
const today = '2026-08-05';
const season = inSeason('SPAETSOMMER', 'TRACHTENDE', '2026-07-25');
const a4 = (candidates: { rule: string }[]) => only('A4', candidates);
expect(
a4(
evaluateAutopilot(
quiet(today, { season, treatmentsSinceLateSummer: 1 }),
configs(),
today,
),
),
).toEqual([]);
expect(
a4(
evaluateAutopilot(
quiet(today, { season: inSeason('SOMMER', 'LINDE', '2026-06-20') }),
configs(),
today,
),
),
).toEqual([]);
expect(
a4(
evaluateAutopilot(
quiet(today, { season }),
configs({
rules: { ...configs().autopilot.config.rules, A4: false },
}),
today,
),
),
).toEqual([]);
});
it('A5: SPAETSOMMER or HERBST without feeding since late summer -> FEED, DO, NORMAL, anchor year', () => {
for (const [segment, since, today] of [
['SPAETSOMMER', '2026-07-25', '2026-08-05'],
['HERBST', '2026-09-15', '2026-09-20'],
] as const) {
const result = evaluateAutopilot(
quiet(today, { season: inSeason(segment, 'TRACHTENDE', since) }),
configs(),
today,
);
expect(only('A5', result)).toMatchObject([
{
rule: 'A5',
intent: 'FEED',
mode: 'DO',
reasonCode: 'AUTUMN_FEED_MISSING',
priority: 'NORMAL',
anchor: '2026',
dueDate: today,
requiredPermission: 'feedings:read',
},
]);
}
});
it('A5 and A6 never fire in WINTERRUHE; A5 is quiet after a feeding', () => {
const today = '2026-12-05';
const winter = quiet(today, {
season: inSeason('WINTERRUHE', 'WINTER', '2026-11-15'),
colony: { ...input(today).colony, feedStatus: 'CRITICAL' },
lastInspection: { ...inspectionOn('2026-11-01'), feedStatus: 'CRITICAL' },
});
expect(
evaluateAutopilot(winter, configs(), today).filter((c) =>
['A5', 'A6'].includes(c.rule),
),
).toEqual([]);
const augustToday = '2026-08-05';
const fed = quiet(augustToday, {
season: inSeason('SPAETSOMMER', 'TRACHTENDE', '2026-07-25'),
feedingsSinceLateSummer: 1,
});
expect(only('A5', evaluateAutopilot(fed, configs(), augustToday))).toEqual(
[],
);
});
it('A6 anchors on the inspection: WEAK or CRITICAL feed of the last inspection, no feeding since', () => {
const today = '2026-04-10';
const base: Partial<AutopilotInput> = {
season: inSeason('FRUEHJAHR', 'SALWEIDE', '2026-03-20'),
lastInspection: { ...inspectionOn('2026-04-03'), feedStatus: 'WEAK' },
lastVarroa: { id: 'var_1', date: today },
};
const result = only(
'A6',
evaluateAutopilot(input(today, base), configs(), today),
);
expect(result).toMatchObject([
{
intent: 'FEED',
mode: 'DO',
reasonCode: 'FEED_LOW',
anchor: 'insp_9',
priority: 'NORMAL',
dueDate: today,
requiredPermission: 'inspections:read',
evidence: {
colonyId: 'col_1',
inspectionId: 'insp_9',
inspectedOn: '2026-04-03',
condition: 'WEAK',
source: 'INSPECTION',
},
},
]);
const fedSince = input(today, { ...base, feedingsSinceInspection: 1 });
expect(only('A6', evaluateAutopilot(fedSince, configs(), today))).toEqual(
[],
);
const good = input(today, {
...base,
lastInspection: { ...inspectionOn('2026-04-03'), feedStatus: 'GOOD' },
});
expect(only('A6', evaluateAutopilot(good, configs(), today))).toEqual([]);
});
it('A6: a CRITICAL colony card counts too, anchored on the inspection or none', () => {
const today = '2026-04-10';
const critical = input(today, {
season: inSeason('FRUEHJAHR', 'SALWEIDE', '2026-03-20'),
colony: { ...input(today).colony, feedStatus: 'CRITICAL' },
lastInspection: inspectionOn('2026-04-03'),
});
expect(
only('A6', evaluateAutopilot(critical, configs(), today)),
).toMatchObject([
{
anchor: 'insp_9',
evidence: { condition: 'CRITICAL', source: 'COLONY_CARD' },
},
]);
const noInspection = { ...critical, lastInspection: null };
expect(
only('A6', evaluateAutopilot(noInspection, configs(), today)),
).toMatchObject([{ anchor: 'none' }]);
});
it('A7: KIRSCHE and 9 frames -> HONEY_SUPER; 7 frames -> nothing; LINDE -> nothing', () => {
const today = '2026-04-28';
const withFrames = (frames: number | null, phase: PhenologyPhaseKey) =>
only(
'A7',
evaluateAutopilot(
quiet(today, {
season: inSeason('SCHWARMZEIT', phase, '2026-04-15', 'GTS'),
lastInspection: { ...inspectionOn(today), colonyStrength: frames },
}),
configs(),
today,
),
);
expect(withFrames(9, 'KIRSCHE')).toMatchObject([
{
rule: 'A7',
intent: 'HONEY_SUPER',
mode: 'DO',
reasonCode: 'HONEY_SUPER_CHECK',
anchor: '2026',
priority: 'NORMAL',
dueDate: today,
requiredPermission: 'inspections:read',
evidence: {
colonyId: 'col_1',
inspectionId: 'insp_9',
frames: 9,
phase: 'KIRSCHE',
phaseSource: 'GTS',
},
},
]);
expect(withFrames(8, 'APFEL')).toHaveLength(1);
expect(withFrames(7, 'KIRSCHE')).toEqual([]);
expect(withFrames(null, 'KIRSCHE')).toEqual([]);
expect(withFrames(9, 'SALWEIDE')).toEqual([]);
expect(withFrames(9, 'LINDE')).toEqual([]);
});
it('A7 ignores an inspection from before the spring of this year', () => {
const today = '2026-04-28';
// inSeason puts springSince on 2026-03-20
const season = inSeason('SCHWARMZEIT', 'KIRSCHE', '2026-04-15');
const withInspection = (date: string) =>
only(
'A7',
evaluateAutopilot(
quiet(today, {
season,
lastInspection: { ...inspectionOn(date), colonyStrength: 12 },
}),
configs(),
today,
),
);
expect(withInspection('2025-09-10')).toEqual([]);
expect(withInspection('2026-03-19')).toEqual([]);
expect(withInspection('2026-03-20')).toHaveLength(1);
});
it('A7 honours honeySuperMinFrames and a missing inspection', () => {
const today = '2026-04-28';
const season = inSeason('SCHWARMZEIT', 'KIRSCHE', '2026-04-15');
const nine = quiet(today, {
season,
lastInspection: { ...inspectionOn(today), colonyStrength: 9 },
});
expect(
only(
'A7',
evaluateAutopilot(nine, configs({ honeySuperMinFrames: 10 }), today),
),
).toEqual([]);
const none = input(today, { season, lastInspection: null });
expect(only('A7', evaluateAutopilot(none, configs(), today))).toEqual([]);
});
it('A8: hatchYear 2024 in 2026 SCHWARMZEIT -> QUEEN_REPLACE, PLAN, anchor queen-year', () => {
const today = '2026-05-12';
const aged = quiet(today, {
season: inSeason('SCHWARMZEIT', 'KIRSCHE', '2026-04-15'),
queen: { id: 'q_12', hatchYear: 2024 },
});
expect(only('A8', evaluateAutopilot(aged, configs(), today))).toMatchObject(
[
{
rule: 'A8',
intent: 'QUEEN_REPLACE',
mode: 'PLAN',
reasonCode: 'QUEEN_OLD',
anchor: 'q_12-2026',
priority: 'NORMAL',
requiredPermission: 'queens:read',
evidence: { colonyId: 'col_1', queenId: 'q_12', hatchYear: 2024 },
},
],
);
const sommerDay = '2026-07-01';
const sommer = {
...aged,
season: inSeason('SOMMER', 'LINDE', '2026-06-20'),
};
expect(
only('A8', evaluateAutopilot(sommer, configs(), sommerDay)),
).toHaveLength(1);
});
it('A8: young queen, unknown hatch year, other segment or queenMaxAgeYears 3 -> nothing', () => {
const today = '2026-05-12';
const swarm = inSeason('SCHWARMZEIT', 'KIRSCHE', '2026-04-15');
const a8 = (
queen: AutopilotInput['queen'],
over?: object,
season = swarm,
) =>
only(
'A8',
evaluateAutopilot(
quiet(today, { season, queen }),
configs(over),
today,
),
);
expect(a8({ id: 'q', hatchYear: 2025 })).toEqual([]);
expect(a8({ id: 'q', hatchYear: null })).toEqual([]);
expect(a8(null)).toEqual([]);
expect(a8({ id: 'q', hatchYear: 2024 }, { queenMaxAgeYears: 3 })).toEqual(
[],
);
expect(
a8(
{ id: 'q', hatchYear: 2024 },
undefined,
inSeason('FRUEHJAHR', 'SALWEIDE', '2026-03-20'),
),
).toEqual([]);
});
it('A9 and A10 follow the WinterCheck rows', () => {
const autumnDay = '2026-09-20';
const autumn = (winterChecks: AutopilotInput['winterChecks']) =>
only(
'A9',
evaluateAutopilot(
quiet(autumnDay, {
season: inSeason('HERBST', 'HERBST', '2026-09-15'),
winterChecks,
}),
configs(),
autumnDay,
),
);
expect(autumn([])).toMatchObject([
{
rule: 'A9',
intent: 'WINTER_CHECK',
mode: 'DO',
reasonCode: 'WINTER_CHECK_AUTUMN_MISSING',
anchor: '2026-AUTUMN',
priority: 'NORMAL',
dueDate: autumnDay,
requiredPermission: 'winter-checks:read',
evidence: { colonyId: 'col_1', winterYear: 2026 },
},
]);
expect(autumn([{ winterYear: 2026, season: 'AUTUMN' }])).toEqual([]);
// The previous winter does not count for this one.
expect(autumn([{ winterYear: 2025, season: 'AUTUMN' }])).toHaveLength(1);
const springDay = '2026-03-25';
const spring = (winterChecks: AutopilotInput['winterChecks']) =>
only(
'A10',
evaluateAutopilot(
quiet(springDay, {
season: inSeason('FRUEHJAHR', 'SALWEIDE', '2026-03-20'),
winterChecks,
}),
configs(),
springDay,
),
);
expect(spring([{ winterYear: 2025, season: 'AUTUMN' }])).toMatchObject([
{
rule: 'A10',
intent: 'WINTER_CHECK',
mode: 'DO',
reasonCode: 'WINTER_CHECK_SPRING_MISSING',
anchor: '2025-SPRING',
dueDate: springDay,
requiredPermission: 'winter-checks:read',
evidence: { colonyId: 'col_1', winterYear: 2025, phase: 'SALWEIDE' },
},
]);
// No wintering-in last autumn -> nothing to wind out; done -> nothing.
expect(spring([])).toEqual([]);
expect(
spring([
{ winterYear: 2025, season: 'AUTUMN' },
{ winterYear: 2025, season: 'SPRING' },
]),
).toEqual([]);
});
it('keeps the order A1...A10', () => {
const today = '2026-08-05';
const result = evaluateAutopilot(
input(today, {
season: inSeason('SPAETSOMMER', 'TRACHTENDE', '2026-07-25'),
lastInspection: inspectionOn('2026-07-01'),
lastVarroa: { id: 'var_0', date: '2026-06-01' },
}),
configs(),
today,
);
expect(result.map((c) => c.rule)).toEqual(['A1', 'A2', 'A4', 'A5']);
});
});
+243 -10
View File
@@ -1,6 +1,8 @@
import type { AutopilotRuleId } from '../automation/rule-config';
import { addDays, daysBetween } from '../common/dates';
import { inspectionDueOn } from '../season/inspection-due';
import { isQueenAged } from '../season/queen-age';
import { PHENOLOGY_PHASES } from '../season/season-types';
import { varroaMeasurementDue } from '../season/varroa-due';
import type {
AutopilotCandidate,
@@ -127,15 +129,247 @@ const a3VarroaRecheck: AutopilotRule = (input, configs, today) => {
};
};
/** A4-A10 follow with Task 27. */
export const AUTOPILOT_RULES: Readonly<
Partial<Record<AutopilotRuleId, AutopilotRule>>
> = {
A1: a1Inspection,
A2: a2VarroaMeasure,
A3: a3VarroaRecheck,
/** The phase window of A7: from KIRSCHE up to, not including, LINDE. */
const HONEY_SUPER_FROM_PHASE = PHENOLOGY_PHASES.indexOf('KIRSCHE');
const HONEY_SUPER_UNTIL_PHASE = PHENOLOGY_PHASES.indexOf('LINDE');
/** The season facts the texts of A4, A5, A7 and A10 quote. */
function seasonEvidence(input: AutopilotInput): {
phase: string | null;
phaseSource: string | null;
} {
return {
phase: input.season.phase,
phaseSource: input.season.phaseSource,
};
}
/** A4: the honey flow is over and nothing was treated since. */
const a4SummerTreatment: AutopilotRule = (input, _configs, today) => {
const { season } = input;
if (season.segment !== 'SPAETSOMMER' || input.treatmentsSinceLateSummer > 0) {
return null;
}
return {
rule: 'A4',
ruleKey: 'SEASON_AUTOPILOT',
intent: 'SUMMER_TREATMENT',
mode: 'PLAN',
reasonCode: 'SUMMER_TREATMENT_DUE',
evidence: {
colonyId: input.colony.id,
...seasonEvidence(input),
since: season.segmentSince,
reportedOn: season.reportedOn,
},
dueDate: today,
priority: 'HIGH',
anchor: String(season.year),
requiredPermission: 'treatments:read',
};
};
/** A5: late summer or autumn and no feeding since the honey flow ended. */
const a5AutumnFeeding: AutopilotRule = (input, _configs, today) => {
const { season } = input;
if (
(season.segment !== 'SPAETSOMMER' && season.segment !== 'HERBST') ||
input.feedingsSinceLateSummer > 0
) {
return null;
}
return {
rule: 'A5',
ruleKey: 'SEASON_AUTOPILOT',
intent: 'FEED',
mode: 'DO',
reasonCode: 'AUTUMN_FEED_MISSING',
evidence: {
colonyId: input.colony.id,
...seasonEvidence(input),
since: season.segmentSince,
},
dueDate: today,
priority: 'NORMAL',
anchor: String(season.year),
requiredPermission: 'feedings:read',
};
};
/** A6: low feed at the last inspection (or a critical colony card), not fed since. */
const a6LowFeed: AutopilotRule = (input, _configs, today) => {
if (
input.season.segment === 'WINTERRUHE' ||
input.feedingsSinceInspection > 0
) {
return null;
}
const { lastInspection, colony } = input;
const inspectionFeed = lastInspection?.feedStatus ?? null;
const isInspectionLow =
lastInspection !== null &&
(inspectionFeed === 'WEAK' || inspectionFeed === 'CRITICAL');
if (!isInspectionLow && colony.feedStatus !== 'CRITICAL') {
return null;
}
return {
rule: 'A6',
ruleKey: 'SEASON_AUTOPILOT',
intent: 'FEED',
mode: 'DO',
reasonCode: 'FEED_LOW',
evidence: {
colonyId: colony.id,
inspectionId: lastInspection?.id ?? null,
inspectedOn: isInspectionLow ? lastInspection.date : null,
condition: isInspectionLow ? inspectionFeed : 'CRITICAL',
source: isInspectionLow ? 'INSPECTION' : 'COLONY_CARD',
},
dueDate: today,
priority: 'NORMAL',
anchor: lastInspection?.id ?? 'none',
requiredPermission: 'inspections:read',
};
};
/** A7: the cherry blooms and the colony covers enough frames for a honey super. */
const a7HoneySuper: AutopilotRule = (input, configs, today) => {
const phaseIndex =
input.season.phase === null
? -1
: PHENOLOGY_PHASES.indexOf(input.season.phase);
// Only an inspection of this season says how strong the colony is now.
const seasonStart = input.season.springSince ?? `${input.season.year}-01-01`;
const inspection =
input.lastInspection !== null && input.lastInspection.date >= seasonStart
? input.lastInspection
: null;
const frames = inspection?.colonyStrength ?? null;
if (
phaseIndex < HONEY_SUPER_FROM_PHASE ||
phaseIndex >= HONEY_SUPER_UNTIL_PHASE ||
frames === null ||
frames < configs.autopilot.config.honeySuperMinFrames
) {
return null;
}
return {
rule: 'A7',
ruleKey: 'SEASON_AUTOPILOT',
intent: 'HONEY_SUPER',
mode: 'DO',
reasonCode: 'HONEY_SUPER_CHECK',
evidence: {
colonyId: input.colony.id,
inspectionId: inspection?.id ?? null,
frames,
...seasonEvidence(input),
},
dueDate: today,
priority: 'NORMAL',
anchor: String(input.season.year),
requiredPermission: 'inspections:read',
};
};
/** A8: the queen has reached the age the beekeeper plans to replace her at. */
const a8QueenReplace: AutopilotRule = (input, configs, today) => {
const { queen, season } = input;
if (
queen === null ||
(season.segment !== 'SCHWARMZEIT' && season.segment !== 'SOMMER') ||
!isQueenAged(
queen.hatchYear,
today,
configs.autopilot.config.queenMaxAgeYears,
)
) {
return null;
}
return {
rule: 'A8',
ruleKey: 'SEASON_AUTOPILOT',
intent: 'QUEEN_REPLACE',
mode: 'PLAN',
reasonCode: 'QUEEN_OLD',
evidence: {
colonyId: input.colony.id,
queenId: queen.id,
hatchYear: queen.hatchYear,
},
dueDate: today,
priority: 'NORMAL',
anchor: `${queen.id}-${season.year}`,
requiredPermission: 'queens:read',
};
};
/** A9: autumn and no wintering-in entry for the winter that begins now. */
const a9WinteringIn: AutopilotRule = (input, _configs, today) => {
const { season } = input;
const isRecorded = input.winterChecks.some(
(check) => check.winterYear === season.year && check.season === 'AUTUMN',
);
if (season.segment !== 'HERBST' || isRecorded) {
return null;
}
return {
rule: 'A9',
ruleKey: 'SEASON_AUTOPILOT',
intent: 'WINTER_CHECK',
mode: 'DO',
reasonCode: 'WINTER_CHECK_AUTUMN_MISSING',
evidence: { colonyId: input.colony.id, winterYear: season.year },
dueDate: today,
priority: 'NORMAL',
anchor: `${season.year}-AUTUMN`,
requiredPermission: 'winter-checks:read',
};
};
/** A10: spring, wintered in last autumn, not yet wintered out. */
const a10WinteringOut: AutopilotRule = (input, _configs, today) => {
const { season } = input;
const winterYear = season.year - 1;
const has = (kind: 'AUTUMN' | 'SPRING') =>
input.winterChecks.some(
(check) => check.winterYear === winterYear && check.season === kind,
);
if (season.segment !== 'FRUEHJAHR' || !has('AUTUMN') || has('SPRING')) {
return null;
}
return {
rule: 'A10',
ruleKey: 'SEASON_AUTOPILOT',
intent: 'WINTER_CHECK',
mode: 'DO',
reasonCode: 'WINTER_CHECK_SPRING_MISSING',
evidence: {
colonyId: input.colony.id,
winterYear,
...seasonEvidence(input),
},
dueDate: today,
priority: 'NORMAL',
anchor: `${winterYear}-SPRING`,
requiredPermission: 'winter-checks:read',
};
};
export const AUTOPILOT_RULES: Readonly<Record<AutopilotRuleId, AutopilotRule>> =
{
A1: a1Inspection,
A2: a2VarroaMeasure,
A3: a3VarroaRecheck,
A4: a4SummerTreatment,
A5: a5AutumnFeeding,
A6: a6LowFeed,
A7: a7HoneySuper,
A8: a8QueenReplace,
A9: a9WinteringIn,
A10: a10WinteringOut,
};
const RULE_ORDER: readonly AutopilotRuleId[] = [
'A1',
'A2',
@@ -164,11 +398,10 @@ export function evaluateAutopilot(
}
const candidates: AutopilotCandidate[] = [];
for (const id of RULE_ORDER) {
const rule = AUTOPILOT_RULES[id];
if (rule === undefined || !configs.autopilot.config.rules[id]) {
if (!configs.autopilot.config.rules[id]) {
continue;
}
const candidate = rule(input, configs, today);
const candidate = AUTOPILOT_RULES[id](input, configs, today);
if (candidate !== null) {
candidates.push(candidate);
}
@@ -4,9 +4,13 @@ import type {
DomainEventType,
} from '../domain-events/domain-event-types';
import { domainEventHandlerMetadata } from '../domain-events/on-domain-event.decorator';
import { lockRowsForUpdate } from '../prisma/row-lock';
import { AutopilotEventHandlers } from './autopilot.handlers';
import type { AutopilotService } from './autopilot.service';
jest.mock('../prisma/row-lock');
const lockRows = jest.mocked(lockRowsForUpdate);
const EXPECTED_TYPES: DomainEventType[] = [
'InspectionRecorded',
'InspectionCorrected',
@@ -27,6 +31,7 @@ const EXPECTED_TYPES: DomainEventType[] = [
'QueenAged',
'InspectionOverdue',
'VarroaMeasurementDue',
'WinterCheckRecorded',
];
function event(
@@ -48,13 +53,17 @@ function event(
}
describe('AutopilotEventHandlers', () => {
let autopilot: { syncColonyIn: jest.Mock };
let autopilot: { syncColonyIn: jest.Mock; syncApiaryIn: jest.Mock };
let tx: { task: { findUnique: jest.Mock } };
let handlers: AutopilotEventHandlers;
beforeEach(() => {
lockRows.mockReset();
jest.useFakeTimers({ now: new Date('2026-05-12T08:00:00.000Z') });
autopilot = { syncColonyIn: jest.fn().mockResolvedValue({}) };
autopilot = {
syncColonyIn: jest.fn().mockResolvedValue({}),
syncApiaryIn: jest.fn().mockResolvedValue({}),
};
tx = { task: { findUnique: jest.fn() } };
handlers = new AutopilotEventHandlers(
autopilot as unknown as AutopilotService,
@@ -93,6 +102,7 @@ describe('AutopilotEventHandlers', () => {
['TreatmentCorrected', { colonyId: 'c1' }],
['FeedingRecorded', { colonyId: 'c1' }],
['FeedingCorrected', { colonyId: 'c1' }],
['WinterCheckRecorded', { colonyId: 'c1' }],
['WithdrawalEnded', { colonyId: 'c1', endedOn: '2026-05-12' }],
['QueenAged', { colonyId: 'c1', hatchYear: 2024 }],
] as [DomainEventType, object][])(
@@ -130,6 +140,26 @@ describe('AutopilotEventHandlers', () => {
expect(synced()).toEqual(['c1', 'c2', 'c3']);
});
it('locks all colonies of an event in one statement before syncing any', async () => {
await run(
event('HarvestRecorded', 'h1', {
apiaryId: 'a',
colonyIds: ['c3', 'c1', 'c2'],
}),
);
expect(lockRows).toHaveBeenCalledWith(tx, 'Colony', ['c1', 'c2', 'c3']);
expect(lockRows.mock.invocationCallOrder[0]).toBeLessThan(
autopilot.syncColonyIn.mock.invocationCallOrder[0],
);
});
it('takes no extra lock for a single colony', async () => {
await run(event('InspectionRecorded', 'i1', { colonyId: 'c1' }));
expect(lockRows).not.toHaveBeenCalled();
});
it('syncs the new and the parent colony of a split', async () => {
await run(event('ColonySplit', 'new', { parentColonyId: 'mother' }));
@@ -178,4 +208,54 @@ describe('AutopilotEventHandlers', () => {
run(event('InspectionRecorded', 'i1', { colonyId: 'c1' })),
).rejects.toThrow('db down');
});
describe('apiarySync', () => {
it('is registered as autopilot.apiary-sync for SeasonPhaseChanged only', () => {
const entries = domainEventHandlerMetadata(
// eslint-disable-next-line @typescript-eslint/unbound-method
AutopilotEventHandlers.prototype.apiarySync,
);
expect(entries).toHaveLength(1);
expect(entries[0].handlerId).toBe('autopilot.apiary-sync');
expect([...entries[0].types]).toEqual(['SeasonPhaseChanged']);
});
// One event per apiary and day (dailyDedupeKey): a change later the same
// day is caught by the daily autopilot step.
it('syncs the apiary of the payload for the Berlin day', async () => {
await handlers.apiarySync(
event('SeasonPhaseChanged', 'api-1', {
apiaryId: 'api-1',
phase: 'KIRSCHE',
segment: 'SCHWARMZEIT',
previousPhase: 'SALWEIDE',
previousSegment: 'FRUEHJAHR',
source: 'REPORT',
}) as Parameters<AutopilotEventHandlers['apiarySync']>[0],
tx as unknown as Prisma.TransactionClient,
);
expect(autopilot.syncApiaryIn).toHaveBeenCalledTimes(1);
expect(autopilot.syncApiaryIn).toHaveBeenCalledWith(
tx,
'api-1',
'2026-05-12',
);
expect(autopilot.syncColonyIn).not.toHaveBeenCalled();
});
it('lets errors propagate so the delivery is retried', async () => {
autopilot.syncApiaryIn.mockRejectedValue(new Error('db down'));
await expect(
handlers.apiarySync(
event('SeasonPhaseChanged', 'api-1', {
apiaryId: 'api-1',
}) as Parameters<AutopilotEventHandlers['apiarySync']>[0],
tx as unknown as Prisma.TransactionClient,
),
).rejects.toThrow('db down');
});
});
});
@@ -6,6 +6,7 @@ import type {
DomainEventType,
} from '../domain-events/domain-event-types';
import { OnDomainEvent } from '../domain-events/on-domain-event.decorator';
import { lockRowsForUpdate } from '../prisma/row-lock';
import { AutopilotService } from './autopilot.service';
const COLONY_SYNC_EVENTS = [
@@ -28,6 +29,7 @@ const COLONY_SYNC_EVENTS = [
'QueenAged',
'InspectionOverdue',
'VarroaMeasurementDue',
'WinterCheckRecorded',
] as const satisfies readonly DomainEventType[];
type ColonySyncEvent = AnyDomainEventEnvelope<
@@ -61,6 +63,7 @@ async function colonyIdsOf(
case 'FeedingRecorded':
case 'FeedingCorrected':
case 'WithdrawalEnded':
case 'WinterCheckRecorded':
case 'QueenAged':
return [event.payload.colonyId];
case 'HarvestRecorded':
@@ -85,7 +88,9 @@ async function colonyIdsOf(
}
/**
* Reconciles the autopilot suggestions of the colonies an event touches.
* Reconciles the autopilot suggestions of the colonies an event touches
* (autopilot.colony-sync) and, for a season change, of all colonies of the
* apiary (autopilot.apiary-sync).
* Pure DB writer through the delivery transaction; the colonies are synced
* in id order, so two deliveries never lock them the other way round.
*/
@@ -104,9 +109,31 @@ export class AutopilotEventHandlers {
if (colonyIds.length === 0) {
return;
}
if (colonyIds.length > 1) {
// All colonies in one statement before any sync, like syncApiaryIn.
await lockRowsForUpdate(tx, 'Colony', colonyIds);
}
const today = berlinToday();
for (const colonyId of colonyIds) {
await this.autopilot.syncColonyIn(tx, colonyId, today);
}
}
/**
* A season change (new phase or segment) alters the rules for every colony
* of the apiary: reconcile them all. There is one such event per apiary and
* day (dailyDedupeKey); a change later the same day is caught by the daily
* autopilot step.
*/
@OnDomainEvent(['SeasonPhaseChanged'], 'autopilot.apiary-sync')
async apiarySync(
event: AnyDomainEventEnvelope<'SeasonPhaseChanged'>,
tx: Prisma.TransactionClient,
): Promise<void> {
await this.autopilot.syncApiaryIn(
tx,
event.payload.apiaryId,
berlinToday(),
);
}
}
@@ -4,7 +4,7 @@ import {
readRule,
ruleConfigOf,
} from '../automation/rule-config';
import { lockRowForUpdate } from '../prisma/row-lock';
import { lockRowForUpdate, lockRowsForUpdate } from '../prisma/row-lock';
import { calendarSeasonState } from '../season/season-calendar';
import type { SuggestionsService } from '../suggestions/suggestions.service';
import { loadAutopilotInputIn } from './autopilot-input';
@@ -19,6 +19,7 @@ jest.mock('../automation/rule-config', () => ({
jest.mock('./autopilot-input');
const lockRow = jest.mocked(lockRowForUpdate);
const lockRows = jest.mocked(lockRowsForUpdate);
const readRuleMock = jest.mocked(readRule);
const loadInput = jest.mocked(loadAutopilotInputIn);
@@ -73,7 +74,7 @@ function input(lastInspectedOn: string | null): AutopilotInput {
describe('AutopilotService', () => {
let tx: {
$connect?: undefined;
colony: { findUnique: jest.Mock };
colony: { findUnique: jest.Mock; findMany: jest.Mock };
suggestion: { findMany: jest.Mock };
};
let suggestions: {
@@ -92,6 +93,7 @@ describe('AutopilotService', () => {
findUnique: jest
.fn()
.mockResolvedValue({ status: 'ACTIVE', apiaryId: 'api-1' }),
findMany: jest.fn().mockResolvedValue([]),
},
suggestion: { findMany: jest.fn().mockResolvedValue([]) },
};
@@ -301,7 +303,7 @@ describe('AutopilotService', () => {
expect(result.opened).toBe(1);
expect(readRuleMock).not.toHaveBeenCalled();
expect(seasons.statesFor).not.toHaveBeenCalled();
expect(loadInput).toHaveBeenCalledWith(tx, 'col-1', season);
expect(loadInput).toHaveBeenCalledWith(tx, 'col-1', season, '08-01', TODAY);
});
it('preloadFor reads the four rules and the seasons once', async () => {
@@ -330,4 +332,66 @@ describe('AutopilotService', () => {
expect(warn).toHaveBeenCalledTimes(2);
warn.mockRestore();
});
describe('syncApiaryIn', () => {
it('syncs every living colony of the apiary in id order with one read of rules and seasons', async () => {
tx.colony.findMany.mockResolvedValue([{ id: 'col-1' }, { id: 'col-2' }]);
loadInput.mockResolvedValue(input(null));
readRuleMock.mockClear();
const result = await service.syncApiaryIn(tx as never, 'api-1', TODAY);
expect(tx.colony.findMany).toHaveBeenCalledWith({
where: {
apiaryId: 'api-1',
status: { notIn: ['DISSOLVED', 'SOLD', 'DECEASED', 'MERGED'] },
},
select: { id: true },
orderBy: { id: 'asc' },
});
expect(lockRow.mock.calls.map((call) => call.slice(1))).toEqual([
['Apiary', 'api-1'],
['Colony', 'col-1'],
['Colony', 'col-2'],
]);
expect(readRuleMock).toHaveBeenCalledTimes(4);
expect(seasons.statesFor).toHaveBeenCalledTimes(1);
expect(seasons.statesFor).toHaveBeenCalledWith(tx, ['api-1'], TODAY);
expect(result).toEqual({ opened: 2, refreshed: 0, resolved: 0 });
});
it('locks the apiary, then all its colonies in one statement, before any colony is synced', async () => {
tx.colony.findMany.mockResolvedValue([{ id: 'col-1' }, { id: 'col-2' }]);
loadInput.mockResolvedValue(input(null));
await service.syncApiaryIn(tx as never, 'api-1', TODAY);
expect(lockRows).toHaveBeenCalledWith(tx, 'Colony', ['col-1', 'col-2']);
const apiaryLock = lockRow.mock.invocationCallOrder[0];
const listing = tx.colony.findMany.mock.invocationCallOrder[0];
const colonyLock = lockRows.mock.invocationCallOrder[0];
const firstSync = lockRow.mock.invocationCallOrder[1];
expect(apiaryLock).toBeLessThan(listing);
expect(listing).toBeLessThan(colonyLock);
expect(colonyLock).toBeLessThan(firstSync);
});
it('does nothing and reads nothing for an apiary without living colonies', async () => {
readRuleMock.mockClear();
expect(await service.syncApiaryIn(tx as never, 'api-1', TODAY)).toEqual({
opened: 0,
refreshed: 0,
resolved: 0,
});
expect(readRuleMock).not.toHaveBeenCalled();
expect(seasons.statesFor).not.toHaveBeenCalled();
});
it('refuses a client that is not a transaction', async () => {
await expect(
service.syncApiaryIn({ $connect: jest.fn() } as never, 'api-1', TODAY),
).rejects.toThrow('AutopilotService.syncApiaryIn');
});
});
});
@@ -1,11 +1,14 @@
import { Injectable, Logger } from '@nestjs/common';
import type { Prisma } from '../../generated/prisma';
import { readRule } from '../automation/rule-config';
import { isLivingColonyStatus } from '../colonies/colony-status';
import {
isLivingColonyStatus,
LIVING_COLONY_WHERE,
} from '../colonies/colony-status';
import { addDays } from '../common/dates';
import { utcDay } from '../domain-events/domain-event-types';
import { assertTransactionClient, type DbClient } from '../prisma/db-client';
import { lockRowForUpdate } from '../prisma/row-lock';
import { lockRowForUpdate, lockRowsForUpdate } from '../prisma/row-lock';
import type { SeasonState } from '../season/season-types';
import { SeasonService } from '../season/season.service';
import { SuggestionsService } from '../suggestions/suggestions.service';
@@ -113,7 +116,13 @@ export class AutopilotService {
this.logger.warn(`No season state for the apiary of colony ${colonyId}`);
return NOTHING;
}
const input = await loadAutopilotInputIn(tx, colonyId, season);
const input = await loadAutopilotInputIn(
tx,
colonyId,
season,
configs.phenology.calendarFallback.TRACHTENDE,
today,
);
if (input === null) {
this.logger.warn(`Colony ${colonyId} vanished during the autopilot sync`);
return NOTHING;
@@ -173,6 +182,51 @@ export class AutopilotService {
return { opened, refreshed, resolved };
}
/**
* Reconciles every living colony of the apiary, for a season change.
* Lock order: the apiary row, then (after reading the list under that
* lock) all its colonies in ONE statement in ascending id order, before any
* colony is synced. A concurrent single-colony or multi-colony sync takes
* its colony locks the same way, so no two syncs wait for each other while
* the proposals lock suggestion and rule rows. Rules and season are read
* once for all colonies. About ten queries per colony run in this one
* transaction.
*/
async syncApiaryIn(
tx: Prisma.TransactionClient,
apiaryId: string,
today: string,
): Promise<AutopilotSyncResult> {
assertTransactionClient(tx, 'AutopilotService.syncApiaryIn');
if (!(await lockRowForUpdate(tx, 'Apiary', apiaryId))) {
return NOTHING;
}
const colonies = await tx.colony.findMany({
where: { apiaryId, ...LIVING_COLONY_WHERE },
select: { id: true },
orderBy: { id: 'asc' },
});
if (colonies.length === 0) {
return NOTHING;
}
await lockRowsForUpdate(
tx,
'Colony',
colonies.map((colony) => colony.id),
);
const preload = await this.preloadFor(tx, [apiaryId], today);
let total = NOTHING;
for (const { id } of colonies) {
const result = await this.syncColonyIn(tx, id, today, preload);
total = {
opened: total.opened + result.opened,
refreshed: total.refreshed + result.refreshed,
resolved: total.resolved + result.resolved,
};
}
return total;
}
private async readConfigs(db: DbClient): Promise<AutopilotConfigs> {
return {
autopilot: await readRule(db, 'SEASON_AUTOPILOT'),
@@ -1,12 +1,18 @@
import { Module } from '@nestjs/common';
import { AutomationModule } from '../automation/automation.module';
import { HoneyBatchesModule } from '../honey-batches/honey-batches.module';
import { StockModule } from '../stock/stock.module';
import { WithdrawalModule } from '../withdrawal/withdrawal.module';
import { BottlingController } from './bottling.controller';
import { BottlingService } from './bottling.service';
@Module({
imports: [AutomationModule, StockModule, WithdrawalModule],
imports: [
AutomationModule,
HoneyBatchesModule,
StockModule,
WithdrawalModule,
],
controllers: [BottlingController],
providers: [BottlingService],
})
@@ -11,18 +11,10 @@ import { AutomationService } from '../automation/automation.service';
import { StockService } from '../stock/stock.service';
import type { PlausibilityWarning } from '../common/warnings/plausibility-warning';
import { WithdrawalCheckService } from '../withdrawal/withdrawal-check.service';
type AmountChange = number | { increment?: number; decrement?: number };
function applyAmountChange(
current: number | null,
change: AmountChange | undefined,
): number | null {
if (change === undefined) return current;
if (typeof change === 'number') return change;
if (current === null) return null;
return current + (change.increment ?? 0) - (change.decrement ?? 0);
}
import {
HoneyBatchLedgerService,
type LedgerBooking,
} from '../honey-batches/honey-batch-ledger.service';
/**
* What one bottling write records in a single recordAll - so both share a
@@ -78,12 +70,10 @@ describe('BottlingService', () => {
update: jest.Mock;
delete: jest.Mock;
};
honeyBatch: {
findUnique: jest.Mock;
update: jest.Mock;
updateMany: jest.Mock;
};
honeyBatch: { findUnique: jest.Mock; update: jest.Mock };
batchMovement: { count: jest.Mock };
};
let ledger: { bookIn: jest.Mock };
let stock: {
remainingByBottling: jest.Mock;
stockChangedEvent: jest.Mock;
@@ -101,9 +91,11 @@ describe('BottlingService', () => {
tx = {
$queryRaw: jest.fn(() => {
steps.push('lock');
return Promise.resolve([]);
return Promise.resolve([{ id: 'row' }]);
}),
product: { findUnique: jest.fn() },
// By default a bottling has drawn from the ledger (own BOTTLING booking).
batchMovement: { count: jest.fn().mockResolvedValue(1) },
bottlingBatch: {
findUnique: jest.fn(),
create: jest.fn(),
@@ -115,48 +107,51 @@ describe('BottlingService', () => {
const row = honeyBatches.get(where.id);
return Promise.resolve(row ? { id: where.id, ...row } : null);
}),
// Only the status is written here; the amount belongs to the ledger.
update: jest.fn(
({
where,
data,
}: {
where: { id: string };
data: { amount?: AmountChange; status?: string };
data: { status?: string };
}) => {
const row = honeyBatches.get(where.id) ?? { amount: null };
honeyBatches.set(where.id, {
amount: applyAmountChange(row.amount, data.amount),
...row,
status: data.status ?? row.status,
});
return Promise.resolve({});
},
),
updateMany: jest.fn(
({
where,
data,
}: {
where: { id: string; amount?: { gte?: number } };
data: { amount: AmountChange; status?: string };
}) => {
const row = honeyBatches.get(where.id);
const minimum = where.amount?.gte;
if (
!row ||
row.amount === null ||
(minimum !== undefined && row.amount < minimum)
) {
return Promise.resolve({ count: 0 });
}
honeyBatches.set(where.id, {
amount: applyAmountChange(row.amount, data.amount),
status: data.status ?? row.status,
});
return Promise.resolve({ count: 1 });
},
),
},
};
// The ledger as the movement book keeps it, reduced to the rest: a
// negative booking needs enough rest, an untracked batch stays untracked.
ledger = {
bookIn: jest.fn((_tx: unknown, booking: LedgerBooking) => {
const row = honeyBatches.get(booking.honeyBatchId);
if (!row) {
return Promise.reject(new NotFoundException('Honey batch not found'));
}
if (row.amount === null) {
return Promise.resolve({ amountKg: null, movementId: null });
}
if (booking.deltaKg < 0 && row.amount < -booking.deltaKg - 0.0005) {
return Promise.reject(
new BadRequestException(
'Abfüllmenge übersteigt die verfügbare Menge der Honigcharge',
),
);
}
const amountKg = Math.max(
0,
Math.round((row.amount + booking.deltaKg) * 1000) / 1000,
);
honeyBatches.set(booking.honeyBatchId, { ...row, amount: amountKg });
return Promise.resolve({ amountKg, movementId: 'm1' });
}),
};
prisma = {
honeyBatch: { findUnique: jest.fn(), update: jest.fn() },
product: { findUnique: jest.fn() },
@@ -202,6 +197,7 @@ describe('BottlingService', () => {
automation as unknown as AutomationService,
stock as unknown as StockService,
withdrawalCheck as unknown as WithdrawalCheckService,
ledger as unknown as HoneyBatchLedgerService,
);
});
@@ -237,6 +233,12 @@ describe('BottlingService', () => {
id: 'h1',
amount: 5,
});
honeyBatches.set('h1', { amount: 5 });
tx.bottlingBatch.create.mockResolvedValueOnce({
id: 'b1',
honeyBatchId: 'h1',
totalAmount: 10,
});
await expect(
service.create(
@@ -327,14 +329,19 @@ describe('BottlingService', () => {
);
});
it('refuses a bottling when the stored amount shrank after the first check', async () => {
// The pre-check saw 25 kg, but a concurrent bottling already took most
// of it - the conditional booking inside the transaction must catch that.
it('refuses a bottling when the rest under the lock is too small and writes nothing', async () => {
// The first read saw 25 kg, but a concurrent bottling already took most
// of it - the ledger decides under the batch lock.
prisma.honeyBatch.findUnique.mockResolvedValueOnce({
id: 'h1',
amount: 25,
});
honeyBatches.set('h1', { amount: 3 });
tx.bottlingBatch.create.mockResolvedValueOnce({
id: 'b1',
honeyBatchId: 'h1',
totalAmount: 10,
});
await expect(
service.create(
@@ -349,9 +356,66 @@ describe('BottlingService', () => {
'user-1',
),
).rejects.toBeInstanceOf(BadRequestException);
expect(tx.bottlingBatch.create).not.toHaveBeenCalled();
expect(honeyBatches.get('h1')?.amount).toBe(3);
expect(events.recordAll).not.toHaveBeenCalled();
expect(auditLog.record).not.toHaveBeenCalled();
});
it('create books the bottling in the ledger against the bottling it created', async () => {
prisma.honeyBatch.findUnique.mockResolvedValueOnce({ id: 'h1' });
honeyBatches.set('h1', { amount: 25 });
tx.bottlingBatch.create.mockResolvedValueOnce({
id: 'b1',
honeyBatchId: 'h1',
totalAmount: 5,
});
tx.bottlingBatch.findUnique.mockResolvedValueOnce({ id: 'b1' });
await service.create(
{
honeyBatchId: 'h1',
date: '2026-01-01',
jarSize: 500,
jarCount: 10,
bestBeforeDate: '2027-01-01',
lotNumber: 'L-1',
},
'user-1',
);
expect(ledger.bookIn).toHaveBeenCalledTimes(1);
expect(ledger.bookIn).toHaveBeenCalledWith(tx, {
honeyBatchId: 'h1',
deltaKg: -5,
type: 'BOTTLING',
sourceBottlingBatchId: 'b1',
actingUserId: 'user-1',
});
});
it('a batch without a recorded amount is bottled without a booking and marked BOTTLED', async () => {
prisma.honeyBatch.findUnique.mockResolvedValueOnce({ id: 'h1' });
honeyBatches.set('h1', { amount: null });
tx.bottlingBatch.create.mockResolvedValueOnce({
id: 'b1',
honeyBatchId: 'h1',
totalAmount: 5,
});
tx.bottlingBatch.findUnique.mockResolvedValueOnce({ id: 'b1' });
await service.create(
{
honeyBatchId: 'h1',
date: '2026-01-01',
jarSize: 500,
jarCount: 10,
bestBeforeDate: '2027-01-01',
lotNumber: 'L-1',
},
'user-1',
);
expect(honeyBatches.get('h1')).toEqual({ amount: null, status: 'BOTTLED' });
});
it('update writes an audit log entry with before and after data in its transaction', async () => {
@@ -417,7 +481,7 @@ describe('BottlingService', () => {
tx,
);
// Nothing moved: the stale honey batch is never touched.
expect(tx.honeyBatch.updateMany).not.toHaveBeenCalled();
expect(ledger.bookIn).not.toHaveBeenCalled();
});
it('update records BottlingCorrected and StockChanged in its transaction', async () => {
@@ -554,6 +618,7 @@ describe('BottlingService', () => {
jarCount: 10,
totalAmount: 5,
});
honeyBatches.set('h1', { amount: 0 });
await expect(
service.update('b1', { jarCount: 40 }, 'user-1'),
@@ -687,6 +752,184 @@ describe('BottlingService', () => {
);
});
describe('ledger bookings', () => {
const before = {
id: 'b1',
honeyBatchId: 'h1',
honeyBatch: { id: 'h1', amount: 2 },
jarSize: 500,
jarCount: 10,
totalAmount: 5,
};
it('a smaller jar count gives the difference back as a BOTTLING booking', async () => {
lockedRows(before, { ...before, jarCount: 6, totalAmount: 3 });
honeyBatches.set('h1', { amount: 2 });
await service.update('b1', { jarCount: 6 }, 'user-1');
expect(ledger.bookIn).toHaveBeenCalledTimes(1);
expect(ledger.bookIn).toHaveBeenCalledWith(tx, {
honeyBatchId: 'h1',
deltaKg: 2,
type: 'BOTTLING',
sourceBottlingBatchId: 'b1',
actingUserId: 'user-1',
});
});
it('books nothing when the amount of the bottling stays the same', async () => {
lockedRows(before, { ...before, lotNumber: 'L-2' });
await service.update('b1', { lotNumber: 'L-2' }, 'user-1');
expect(ledger.bookIn).not.toHaveBeenCalled();
});
it('a change of the honey batch gives back to the old one and takes from the new one', async () => {
lockedRows(before, { ...before, honeyBatchId: 'h2' });
honeyBatches.set('h1', { amount: 2 });
honeyBatches.set('h2', { amount: 8 });
await service.update('b1', { honeyBatchId: 'h2' }, 'user-1');
expect(
(ledger.bookIn.mock.calls as [unknown, LedgerBooking][]).map(
([, booking]) => booking,
),
).toEqual([
{
honeyBatchId: 'h1',
deltaKg: 5,
type: 'BOTTLING',
sourceBottlingBatchId: 'b1',
actingUserId: 'user-1',
},
{
honeyBatchId: 'h2',
deltaKg: -5,
type: 'BOTTLING',
sourceBottlingBatchId: 'b1',
actingUserId: 'user-1',
},
]);
});
it('locks the bottling, then both honey batches in one statement, before it books', async () => {
lockedRows(before, { ...before, honeyBatchId: 'h2' });
honeyBatches.set('h1', { amount: 2 });
honeyBatches.set('h2', { amount: 8 });
await service.update('b1', { honeyBatchId: 'h2' }, 'user-1');
expect(tx.$queryRaw).toHaveBeenCalledTimes(2);
expect(tx.$queryRaw.mock.invocationCallOrder[1]).toBeLessThan(
ledger.bookIn.mock.invocationCallOrder[0],
);
});
it('a failed take from the new honey batch leaves the bottling row unchanged', async () => {
lockedRows(before, { ...before, honeyBatchId: 'h2' });
honeyBatches.set('h1', { amount: 2 });
honeyBatches.set('h2', { amount: 1 });
await expect(
service.update('b1', { honeyBatchId: 'h2' }, 'user-1'),
).rejects.toBeInstanceOf(BadRequestException);
expect(tx.bottlingBatch.update).not.toHaveBeenCalled();
});
it('create locks the honey batch before it writes the bottling', async () => {
prisma.honeyBatch.findUnique.mockResolvedValueOnce({ id: 'h1' });
honeyBatches.set('h1', { amount: 25 });
tx.bottlingBatch.create.mockResolvedValueOnce({
id: 'b1',
honeyBatchId: 'h1',
totalAmount: 5,
});
tx.bottlingBatch.findUnique.mockResolvedValueOnce({ id: 'b1' });
await service.create(
{
honeyBatchId: 'h1',
date: '2026-01-01',
jarSize: 500,
jarCount: 10,
bestBeforeDate: '2027-01-01',
lotNumber: 'L-1',
},
'user-1',
);
expect(tx.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan(
tx.bottlingBatch.create.mock.invocationCallOrder[0],
);
});
it('a bottling that never drew from the ledger books nothing when it changes', async () => {
tx.batchMovement.count.mockResolvedValue(0);
lockedRows(before, { ...before, jarCount: 6, totalAmount: 3 });
honeyBatches.set('h1', { amount: 2 });
await service.update('b1', { jarCount: 6 }, 'user-1');
expect(ledger.bookIn).not.toHaveBeenCalled();
expect(tx.batchMovement.count).toHaveBeenCalledWith({
where: {
honeyBatchId: 'h1',
sourceBottlingBatchId: 'b1',
type: 'BOTTLING',
},
});
});
it('moving a bottling that never drew from the ledger only takes from the new batch', async () => {
tx.batchMovement.count.mockResolvedValue(0);
lockedRows(before, { ...before, honeyBatchId: 'h2' });
honeyBatches.set('h1', { amount: 2 });
honeyBatches.set('h2', { amount: 8 });
await service.update('b1', { honeyBatchId: 'h2' }, 'user-1');
expect(
(ledger.bookIn.mock.calls as [unknown, LedgerBooking][]).map(
([, booking]) => [booking.honeyBatchId, booking.deltaKg],
),
).toEqual([['h2', -5]]);
});
it('removing a bottling that never drew from the ledger gives nothing back', async () => {
tx.batchMovement.count.mockResolvedValue(0);
lockedRows(before);
honeyBatches.set('h1', { amount: 2 });
await service.remove('b1', 'user-1');
expect(ledger.bookIn).not.toHaveBeenCalled();
expect(tx.bottlingBatch.delete).toHaveBeenCalled();
});
it('remove books the amount back with the note "Abfüllung entfernt"', async () => {
lockedRows(before);
honeyBatches.set('h1', { amount: 2 });
await service.remove('b1', 'user-1');
expect(ledger.bookIn).toHaveBeenCalledWith(tx, {
honeyBatchId: 'h1',
deltaKg: 5,
type: 'BOTTLING',
notes: 'Abfüllung entfernt',
actingUserId: 'user-1',
});
// The booking is made while the bottling still exists (FK); the delete
// follows.
expect(ledger.bookIn.mock.invocationCallOrder[0]).toBeLessThan(
tx.bottlingBatch.delete.mock.invocationCallOrder[0],
);
});
});
describe('withdrawal warnings', () => {
const conflict = {
harvestId: 'harvest-1',
+119 -107
View File
@@ -6,7 +6,7 @@ import {
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { DbClient } from '../prisma/db-client';
import { lockRowForUpdate } from '../prisma/row-lock';
import { lockRowForUpdate, lockRowsForUpdate } from '../prisma/row-lock';
import { AuditLogService } from '../audit/audit-log.service';
import { DomainEventsService } from '../domain-events/domain-events.service';
import type { AnyNewDomainEvent } from '../domain-events/domain-event-types';
@@ -15,6 +15,7 @@ import { StockService } from '../stock/stock.service';
import { withWarnings } from '../common/warnings/plausibility-warning';
import { WithdrawalCheckService } from '../withdrawal/withdrawal-check.service';
import { Prisma } from '../../generated/prisma';
import { HoneyBatchLedgerService } from '../honey-batches/honey-batch-ledger.service';
import { CreateBottlingBatchDto } from './dto/create-bottling-batch.dto';
import { UpdateBottlingBatchDto } from './dto/update-bottling-batch.dto';
@@ -27,82 +28,14 @@ export interface BottlingBatchFilter {
honeyBatchId?: string;
}
/**
* Amounts are kept in kilograms with gram precision. Rounding on every write
* keeps a honey batch's remaining amount from drifting when a bottling batch
* is corrected or removed and its amount is given back.
*/
/** Amounts are kept in kilograms with gram precision. */
function roundKg(amount: number): number {
return Math.round(amount * 1000) / 1000;
}
const INSUFFICIENT_AMOUNT_MESSAGE =
'Abfüllmenge übersteigt die verfügbare Menge der Honigcharge';
type Tx = Prisma.TransactionClient;
/**
* Deducts `amount` kg from a honey batch in one conditional UPDATE, so the
* database - not a value read earlier - decides whether enough is left.
* A batch without a recorded amount (null) is not tracked and stays null.
*/
async function takeFromHoneyBatch(
tx: Tx,
honeyBatchId: string,
amount: number,
): Promise<void> {
const current = await tx.honeyBatch.findUnique({
where: { id: honeyBatchId },
select: { amount: true },
});
if (!current) {
throw new NotFoundException('Honey batch not found');
}
if (current.amount === null) {
await tx.honeyBatch.update({
where: { id: honeyBatchId },
data: { status: 'BOTTLED' },
});
return;
}
// Tolerance of half a gram absorbs float noise from earlier writes.
const booked = await tx.honeyBatch.updateMany({
where: { id: honeyBatchId, amount: { gte: amount - 0.0005 } },
data: { amount: { decrement: amount }, status: 'BOTTLED' },
});
if (booked.count === 0) {
throw new BadRequestException(INSUFFICIENT_AMOUNT_MESSAGE);
}
await normalizeAmount(tx, honeyBatchId);
}
async function giveBackToHoneyBatch(
tx: Tx,
honeyBatchId: string,
amount: number,
): Promise<void> {
await tx.honeyBatch.updateMany({
where: { id: honeyBatchId, amount: { not: null } },
data: { amount: { increment: amount } },
});
await normalizeAmount(tx, honeyBatchId);
}
/** Keeps gram precision and clamps float noise below zero to exactly 0. */
async function normalizeAmount(tx: Tx, honeyBatchId: string): Promise<void> {
const row = await tx.honeyBatch.findUnique({
where: { id: honeyBatchId },
select: { amount: true },
});
if (row?.amount == null) return;
const normalized = Math.max(0, roundKg(row.amount));
if (normalized !== row.amount) {
await tx.honeyBatch.update({
where: { id: honeyBatchId },
data: { amount: normalized },
});
}
}
const REMOVED_NOTE = 'Abfüllung entfernt';
/**
* Bottlings of a honey batch. Every write records its audit entry and its
@@ -119,8 +52,59 @@ export class BottlingService {
private readonly automation: AutomationService,
private readonly stock: StockService,
private readonly withdrawalCheck: WithdrawalCheckService,
private readonly ledger: HoneyBatchLedgerService,
) {}
/**
* Books a bottling's honey in the movement book of its honey batch: minus
* for a bottling taking honey, plus for giving it back. The ledger locks
* the batch and decides whether the rest is enough.
*/
private async book(
tx: Tx,
honeyBatchId: string,
deltaKg: number,
actingUserId: string,
source: { sourceBottlingBatchId: string } | { notes: string },
): Promise<void> {
await this.ledger.bookIn(tx, {
honeyBatchId,
deltaKg,
type: 'BOTTLING',
actingUserId,
...source,
});
}
/**
* Whether this bottling took honey out of the batch's ledger. A bottling
* made while the batch had no recorded amount has no BOTTLING booking of
* its own: later changes or its removal must not give back or take
* anything the ledger never lost.
*/
private async drewFromLedger(
tx: Tx,
honeyBatchId: string,
bottlingId: string,
): Promise<boolean> {
const bookings = await tx.batchMovement.count({
where: {
honeyBatchId,
sourceBottlingBatchId: bottlingId,
type: 'BOTTLING',
},
});
return bookings > 0;
}
/** A bottled batch changes its status, whether or not its amount is kept. */
private async markBottled(tx: Tx, honeyBatchId: string): Promise<void> {
await tx.honeyBatch.update({
where: { id: honeyBatchId },
data: { status: 'BOTTLED' },
});
}
/**
* The bottling as the response body plus the withdrawal warnings of the
* harvests behind its honey batch. Bottling inside a window is written all
@@ -195,6 +179,7 @@ export class BottlingService {
async create(dto: CreateBottlingBatchDto, actingUserId: string) {
const honeyBatch = await this.prisma.honeyBatch.findUnique({
where: { id: dto.honeyBatchId },
select: { id: true },
});
if (!honeyBatch) {
throw new NotFoundException('Honey batch not found');
@@ -204,9 +189,6 @@ export class BottlingService {
}
const totalAmount = roundKg((dto.jarSize * dto.jarCount) / 1000);
if (honeyBatch.amount !== null && totalAmount > honeyBatch.amount) {
throw new BadRequestException(INSUFFICIENT_AMOUNT_MESSAGE);
}
const lotNumber =
dto.lotNumber ??
@@ -218,11 +200,11 @@ export class BottlingService {
}
return this.prisma.$transaction(async (tx) => {
// Booked against the stored amount inside the transaction, never
// against the value read above: two bottlings submitted at the same
// time (double tap, two devices) must not both pass the check and
// overwrite each other's deduction.
await takeFromHoneyBatch(tx, dto.honeyBatchId, totalAmount);
// The honey batch first (like update and remove, which hold the
// bottling and then the batch): the ledger books under this lock.
if (!(await lockRowForUpdate(tx, 'HoneyBatch', dto.honeyBatchId))) {
throw new NotFoundException('Honey batch not found');
}
const created = await tx.bottlingBatch.create({
data: {
honeyBatchId: dto.honeyBatchId,
@@ -236,6 +218,14 @@ export class BottlingService {
userId: actingUserId,
},
});
// Booked in the ledger under the batch lock, never against a value read
// earlier: two bottlings submitted at the same time (double tap, two
// devices) must not both pass the check. A refusal rolls the bottling
// back with it.
await this.book(tx, dto.honeyBatchId, -totalAmount, actingUserId, {
sourceBottlingBatchId: created.id,
});
await this.markBottled(tx, dto.honeyBatchId);
await this.auditLog.record(
{
entityType: 'BottlingBatch',
@@ -289,39 +279,51 @@ export class BottlingService {
const jarCount = dto.jarCount ?? before.jarCount;
const totalAmount = roundKg((jarSize * jarCount) / 1000);
const targetHoneyBatch = movedToOtherHoneyBatch
? await tx.honeyBatch.findUnique({ where: { id: honeyBatchId } })
: before.honeyBatch;
if (!targetHoneyBatch) {
throw new NotFoundException('Honey batch not found');
}
// Early, friendly check; the booking below is still conditional on
// the stored amount (takeFromHoneyBatch).
const availableOnTarget =
targetHoneyBatch.amount === null
? null
: roundKg(
targetHoneyBatch.amount +
(movedToOtherHoneyBatch ? 0 : before.totalAmount),
);
if (availableOnTarget !== null && totalAmount > availableOnTarget) {
throw new BadRequestException(INSUFFICIENT_AMOUNT_MESSAGE);
if (movedToOtherHoneyBatch) {
// Both batches in one statement in ascending id order, so two
// bottlings moving the other way cannot wait for each other.
await lockRowsForUpdate(tx, 'HoneyBatch', [
before.honeyBatchId,
honeyBatchId,
]);
const target = await tx.honeyBatch.findUnique({
where: { id: honeyBatchId },
select: { id: true },
});
if (!target) {
throw new NotFoundException('Honey batch not found');
}
}
const amountChanged =
movedToOtherHoneyBatch || totalAmount !== before.totalAmount;
if (amountChanged) {
// Give the previous amount back first, then take the corrected one -
// a correction never double-charges the source batch.
await giveBackToHoneyBatch(tx, before.honeyBatchId, before.totalAmount);
await takeFromHoneyBatch(tx, honeyBatchId, totalAmount);
}
const tracked = await this.drewFromLedger(tx, before.honeyBatchId, id);
if (movedToOtherHoneyBatch) {
await tx.honeyBatch.update({
where: { id: honeyBatchId },
data: { status: 'BOTTLED' },
// Give the previous amount back first (only if it was taken), then
// take the corrected one - a correction never double-charges.
if (tracked) {
await this.book(
tx,
before.honeyBatchId,
before.totalAmount,
actingUserId,
{ sourceBottlingBatchId: id },
);
}
await this.book(tx, honeyBatchId, -totalAmount, actingUserId, {
sourceBottlingBatchId: id,
});
} else if (amountChanged && tracked) {
await this.book(
tx,
honeyBatchId,
roundKg(before.totalAmount - totalAmount),
actingUserId,
{ sourceBottlingBatchId: id },
);
}
if (amountChanged) {
await this.markBottled(tx, honeyBatchId);
}
await tx.bottlingBatch.update({
where: { id },
@@ -387,8 +389,18 @@ export class BottlingService {
'Abfüllung hat bereits Bestandsbewegungen und kann nicht gelöscht werden - Bestand über "Inventur korrigieren" anpassen',
);
}
// Booked first, while the bottling still exists; deleting it then
// clears the reference of the booking (ON DELETE SET NULL).
if (await this.drewFromLedger(tx, before.honeyBatchId, id)) {
await this.book(
tx,
before.honeyBatchId,
before.totalAmount,
actingUserId,
{ notes: REMOVED_NOTE },
);
}
await tx.bottlingBatch.delete({ where: { id } });
await giveBackToHoneyBatch(tx, before.honeyBatchId, before.totalAmount);
await this.auditLog.record(
{
entityType: 'BottlingBatch',
@@ -16,6 +16,7 @@ import { UpdateHoneyMeasurementDto } from '../../honey-batches/dto/update-honey-
import { UpdateInspectionDto } from '../../inspections/dto/update-inspection.dto';
import { UpdateLabelTemplateDto } from '../../label-templates/dto/update-label-template.dto';
import { UpdateNotificationSettingsDto } from '../../notifications/dto/update-notification-settings.dto';
import { UpdatePhenologyReportDto } from '../../phenology/dto/update-phenology-report.dto';
import { UpdateProductionBatchDto } from '../../production-batches/dto/update-production-batch.dto';
import { UpdateProductDto } from '../../products/dto/update-product.dto';
import { UpdateProfileDto } from '../../profile/dto/update-profile.dto';
@@ -210,6 +211,12 @@ const contracts: Record<string, NullContract> = {
notNull: ['name', 'status', 'kind', 'shopCategory'],
clearable: ['description', 'sku', 'packagingSize', 'weight', 'salesUnit'],
},
// apiaryId: null widens the report to all apiaries; notes: null clears them.
UpdatePhenologyReportDto: {
dto: UpdatePhenologyReportDto,
notNull: ['phase', 'date'],
clearable: ['apiaryId', 'notes'],
},
UpdateProductionBatchDto: {
dto: UpdateProductionBatchDto,
notNull: ['lotNumber', 'date', 'quantity'],
@@ -104,11 +104,22 @@ export interface DomainEventPayloads {
phase: PhenologyPhase;
reportedOn: string;
};
// previousApiaryId: the scope before a correction (null = all apiaries).
PhenologyCorrected: {
apiaryId: string | null;
previousApiaryId: string | null;
phase: PhenologyPhase;
reportedOn: string;
};
// Aggregate Apiary; internal follow-up of a report for all apiaries: one
// apiary is recomputed per delivery.
PhenologyRecomputeRequested: {
apiaryId: string;
allowBackward: boolean;
day: string;
};
// Aggregate WinterCheck: a wintering-in or -out entry was written or changed.
WinterCheckRecorded: { colonyId: string };
// Aggregate Apiary; time-based (dedupeKey per apiary and day).
SeasonPhaseChanged: {
apiaryId: string;
@@ -155,7 +166,9 @@ const CATALOG: Record<DomainEventType, true> = {
HoneyBatchCorrected: true,
PhenologyReported: true,
PhenologyCorrected: true,
PhenologyRecomputeRequested: true,
SeasonPhaseChanged: true,
WinterCheckRecorded: true,
};
export const DOMAIN_EVENT_TYPES: readonly DomainEventType[] = Object.freeze(
@@ -178,6 +191,7 @@ export type AggregateType =
| 'Extraction'
| 'HoneyBatch'
| 'PhenologyReport'
| 'WinterCheck'
| 'Apiary';
export interface NewDomainEvent<T extends DomainEventType = DomainEventType> {
@@ -183,7 +183,7 @@ describe('DomainEventsService', () => {
describe('domain event catalog', () => {
it('lists every event type exactly once', () => {
expect(DOMAIN_EVENT_TYPES).toHaveLength(32);
expect(DOMAIN_EVENT_TYPES).toHaveLength(34);
expect(new Set(DOMAIN_EVENT_TYPES).size).toBe(DOMAIN_EVENT_TYPES.length);
expect(DOMAIN_EVENT_TYPES).toEqual(
expect.arrayContaining([
@@ -199,7 +199,9 @@ describe('domain event catalog', () => {
'HoneyBatchCorrected',
'PhenologyReported',
'PhenologyCorrected',
'PhenologyRecomputeRequested',
'SeasonPhaseChanged',
'WinterCheckRecorded',
]),
);
});
@@ -0,0 +1,54 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsEnum,
IsIn,
IsNumber,
IsOptional,
IsString,
Max,
MaxLength,
Min,
NotEquals,
} from 'class-validator';
import { WithdrawalReason } from '../../../generated/prisma';
/** The limit of one booking, in kg: far above any batch, below float noise. */
export const MAX_MOVEMENT_KG = 100000;
export const MOVEMENT_TYPES = ['WITHDRAWAL', 'CORRECTION'] as const;
export type ManualMovementType = (typeof MOVEMENT_TYPES)[number];
/**
* A manual booking in the movement book of a honey batch. Bottlings book
* themselves; this is for loose goods, samples, breakage (WITHDRAWAL, minus)
* and for weighing again (CORRECTION, plus or minus).
*/
export class CreateHoneyBatchMovementDto {
@ApiProperty({ enum: MOVEMENT_TYPES })
@IsIn(MOVEMENT_TYPES)
type!: ManualMovementType;
@ApiProperty({
description:
'Änderung der Restmenge in kg mit Vorzeichen, nicht 0 (Entnahme: negativ)',
})
@IsNumber({ allowNaN: false, allowInfinity: false })
@NotEquals(0)
@Min(-MAX_MOVEMENT_KG)
@Max(MAX_MOVEMENT_KG)
deltaKg!: number;
@ApiPropertyOptional({
enum: WithdrawalReason,
description: 'Pflicht bei einer Entnahme',
})
@IsOptional()
@IsEnum(WithdrawalReason)
reason?: WithdrawalReason;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(500)
notes?: string;
}
@@ -0,0 +1,49 @@
import { readdirSync, readFileSync } from 'node:fs';
import { join, relative, sep } from 'node:path';
// HoneyBatch.amount is the running rest of the movement book (spec 7.7): only
// HoneyBatchLedgerService writes it. A second writer would break the
// invariant amount = initialAmount + sum(quantity) / 1000.
//
// The regexes see an `amount` inside the call itself. A spread such as
// `data: { ...dto }` is invisible to them; honey-batches.service.spec.ts
// covers that case.
const SRC = join(__dirname, '..');
const LEDGER = 'src/honey-batches/honey-batch-ledger.service.ts';
function productionSources(dir: string): string[] {
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const path = join(dir, entry.name);
if (entry.isDirectory()) {
return productionSources(path);
}
return entry.name.endsWith('.ts') && !entry.name.endsWith('.spec.ts')
? [path]
: [];
});
}
function writersOf(...patterns: RegExp[]): string[] {
return productionSources(SRC)
.filter((file) => {
const source = readFileSync(file, 'utf8');
return patterns.some((pattern) => pattern.test(source));
})
.map((file) => `src/${relative(SRC, file).split(sep).join('/')}`)
.sort();
}
describe('honey batch amount writes', () => {
it('honey batch amount is written only by the ledger', () =>
expect(
writersOf(
/honeyBatch\.(update|updateMany|upsert)\([^)]*amount/s,
/UPDATE "HoneyBatch"[^;]*"amount"/,
),
).toEqual([LEDGER]));
it('the guard sees a write: the ledger itself is found', () =>
expect(writersOf(/honeyBatch\.update\(\{[^)]*data: \{ amount/s)).toContain(
LEDGER,
));
});
@@ -0,0 +1,417 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { HoneyBatchLedgerService } from './honey-batch-ledger.service';
import type { PrismaService } from '../prisma/prisma.service';
interface BatchState {
amount: number | null;
initialAmount: number | null;
/** Sum of the booked grams so far. */
grams: number;
}
describe('HoneyBatchLedgerService', () => {
let service: HoneyBatchLedgerService;
let state: BatchState;
let exists: boolean;
let steps: string[];
let tx: {
$queryRaw: jest.Mock;
honeyBatch: { findUnique: jest.Mock; update: jest.Mock };
batchMovement: { aggregate: jest.Mock; create: jest.Mock };
};
let prisma: {
honeyBatch: { findUnique: jest.Mock };
batchMovement: { findMany: jest.Mock };
};
beforeEach(() => {
state = { amount: 25, initialAmount: 25, grams: 0 };
exists = true;
steps = [];
tx = {
$queryRaw: jest.fn(() => {
steps.push('lock');
return Promise.resolve(exists ? [{ id: 'h1' }] : []);
}),
honeyBatch: {
findUnique: jest.fn(() => {
steps.push('read');
return Promise.resolve({
amount: state.amount,
initialAmount: state.initialAmount,
});
}),
update: jest.fn(({ data }: { data: Partial<BatchState> }) => {
steps.push('write');
state = { ...state, ...data };
return Promise.resolve({});
}),
},
batchMovement: {
aggregate: jest.fn(() =>
Promise.resolve({
_sum: { quantity: state.grams },
_count: { _all: state.grams === 0 ? 0 : 1 },
}),
),
create: jest.fn(({ data }: { data: { quantity: number } }) => {
state = { ...state, grams: state.grams + data.quantity };
return Promise.resolve({ id: 'm1', ...data });
}),
},
};
prisma = {
honeyBatch: { findUnique: jest.fn() },
batchMovement: { findMany: jest.fn() },
};
service = new HoneyBatchLedgerService(prisma as unknown as PrismaService);
});
const book = (
deltaKg: number,
type: 'BOTTLING' | 'CORRECTION' | 'WITHDRAWAL' = 'BOTTLING',
extra: Record<string, unknown> = {},
) =>
service.bookIn(tx as never, {
honeyBatchId: 'h1',
deltaKg,
type,
actingUserId: 'user-1',
...extra,
});
it('books in grams and rounds the amount to the gram', async () => {
const result = await book(-5.0004, 'BOTTLING', {
sourceBottlingBatchId: 'b1',
});
expect(tx.batchMovement.create).toHaveBeenCalledWith({
data: {
honeyBatchId: 'h1',
type: 'BOTTLING',
quantity: -5000,
reason: null,
notes: null,
sourceBottlingBatchId: 'b1',
userId: 'user-1',
},
});
expect(result).toEqual({ amountKg: 20, movementId: 'm1' });
expect(state.amount).toBe(20);
});
it('rounds half a gram up with floor(x*1000+0.5), like the migration', async () => {
await book(0.0125, 'CORRECTION');
await book(-0.0125, 'CORRECTION');
const quantities = (
tx.batchMovement.create.mock.calls as [{ data: { quantity: number } }][]
).map(([arg]) => arg.data.quantity);
expect(quantities).toEqual([13, -12]);
});
it('books exactly the rest when it is off the gram grid, and ends at 0', async () => {
state = { amount: 3.0004, initialAmount: 3.0004, grams: 0 };
const result = await book(-3.0004);
expect(tx.batchMovement.create).toHaveBeenCalledWith({
data: expect.objectContaining({ quantity: -3000 }) as unknown,
});
expect(result.amountKg).toBe(0);
expect(state.grams).toBe(-3000);
});
it('a reason on a correction is stored', async () => {
await book(1, 'CORRECTION', { reason: 'SONSTIGES' });
expect(tx.batchMovement.create).toHaveBeenCalledWith({
data: expect.objectContaining({ reason: 'SONSTIGES' }) as unknown,
});
});
it('locks the batch before it reads it', async () => {
await book(-1);
expect(steps.slice(0, 2)).toEqual(['lock', 'read']);
});
it('refuses a booking larger than the rest with the German message', async () => {
state = { amount: 3, initialAmount: 25, grams: -22000 };
await expect(book(-6)).rejects.toThrow(
new BadRequestException(
'Abfüllmenge übersteigt die verfügbare Menge der Honigcharge',
),
);
expect(tx.batchMovement.create).not.toHaveBeenCalled();
expect(tx.honeyBatch.update).not.toHaveBeenCalled();
});
it('accepts a booking within half a gram of the rest', async () => {
state = { amount: 3, initialAmount: 25, grams: -22000 };
const result = await book(-3.0004);
expect(result.amountKg).toBe(0);
});
it('a correction or withdrawal below zero says the amount cannot go below 0', async () => {
state = { amount: 1, initialAmount: 1, grams: 0 };
await expect(book(-2, 'CORRECTION')).rejects.toThrow(
new BadRequestException('Die Menge kann nicht unter 0 sinken.'),
);
await expect(book(-2, 'WITHDRAWAL')).rejects.toThrow(
new BadRequestException('Die Menge kann nicht unter 0 sinken.'),
);
});
it('gives back with a plus booking and a note', async () => {
state = { amount: 20, initialAmount: 25, grams: -5000 };
const result = await book(5, 'BOTTLING', { notes: 'Abfüllung entfernt' });
expect(result.amountKg).toBe(25);
expect(tx.batchMovement.create).toHaveBeenCalledWith({
data: expect.objectContaining({
quantity: 5000,
notes: 'Abfüllung entfernt',
}) as unknown,
});
});
it('a correction books the difference new minus current', async () => {
state = { amount: 20, initialAmount: 25, grams: -5000 };
await book(22.5 - 20, 'CORRECTION', { notes: 'Nachgewogen' });
expect(tx.batchMovement.create).toHaveBeenCalledWith({
data: expect.objectContaining({
type: 'CORRECTION',
quantity: 2500,
notes: 'Nachgewogen',
}) as unknown,
});
expect(state.amount).toBe(22.5);
});
it('a withdrawal carries its reason', async () => {
await book(-1, 'WITHDRAWAL', { reason: 'EIGENBEDARF' });
expect(tx.batchMovement.create).toHaveBeenCalledWith({
data: expect.objectContaining({ reason: 'EIGENBEDARF' }) as unknown,
});
});
it('sets the amount from the ledger sum, never from a stored value that drifted', async () => {
state = { amount: 9.9, initialAmount: 10, grams: -2000 };
const result = await book(-1);
expect(result.amountKg).toBe(7);
});
it('books nothing for a batch without initialAmount', async () => {
state = { amount: null, initialAmount: null, grams: 0 };
const result = await book(-5);
expect(result).toEqual({ amountKg: null, movementId: null });
expect(tx.batchMovement.create).not.toHaveBeenCalled();
expect(tx.honeyBatch.update).not.toHaveBeenCalled();
});
it('books nothing for a delta below one gram', async () => {
const result = await book(0.0003);
expect(result).toEqual({ amountKg: 25, movementId: null });
expect(tx.batchMovement.create).not.toHaveBeenCalled();
});
it('answers 404 for a missing batch', async () => {
exists = false;
await expect(book(-1)).rejects.toBeInstanceOf(NotFoundException);
});
it('refuses the root client', async () => {
await expect(
service.bookIn({ $connect: jest.fn() } as never, {
honeyBatchId: 'h1',
deltaKg: -1,
type: 'BOTTLING',
actingUserId: null,
}),
).rejects.toThrow('needs the transaction client');
});
describe('setInitialIn', () => {
it('sets amount and initialAmount together, without a booking', async () => {
state = { amount: null, initialAmount: null, grams: 0 };
await service.setInitialIn(tx as never, 'h1', 12.5);
expect(tx.honeyBatch.update).toHaveBeenCalledWith({
where: { id: 'h1' },
data: { amount: 12.5, initialAmount: 12.5 },
});
expect(tx.batchMovement.create).not.toHaveBeenCalled();
});
it('rounds the amount to the gram, like a booking', async () => {
state = { amount: null, initialAmount: null, grams: 0 };
await service.setInitialIn(tx as never, 'h1', 12.3456);
expect(tx.honeyBatch.update).toHaveBeenCalledWith({
where: { id: 'h1' },
data: { amount: 12.346, initialAmount: 12.346 },
});
});
it('refuses a batch that already has an initial amount', async () => {
await expect(
service.setInitialIn(tx as never, 'h1', 12.5),
).rejects.toThrow('initialAmount already set');
});
it('refuses a negative or non-finite amount', async () => {
state = { amount: null, initialAmount: null, grams: 0 };
await expect(service.setInitialIn(tx as never, 'h1', -1)).rejects.toThrow(
BadRequestException,
);
await expect(
service.setInitialIn(tx as never, 'h1', Number.NaN),
).rejects.toThrow(BadRequestException);
});
});
describe('clearIn', () => {
it('removes amount and initialAmount while nothing is booked', async () => {
await service.clearIn(tx as never, 'h1');
expect(tx.honeyBatch.update).toHaveBeenCalledWith({
where: { id: 'h1' },
data: { amount: null, initialAmount: null },
});
});
it('refuses with the ledger message once there are bookings', async () => {
state = { ...state, grams: -5000 };
await expect(service.clearIn(tx as never, 'h1')).rejects.toThrow(
new BadRequestException(
'Die Menge wird über das Bewegungsbuch geführt. Bitte eine Korrektur buchen.',
),
);
expect(tx.honeyBatch.update).not.toHaveBeenCalled();
});
});
describe('movements', () => {
it('lists the bookings as views, newest first', async () => {
prisma.honeyBatch.findUnique.mockResolvedValue({ id: 'h1' });
const createdAt = new Date('2026-10-01T10:00:00Z');
prisma.batchMovement.findMany.mockResolvedValue([
{
id: 'm1',
type: 'BOTTLING',
quantity: -5000,
reason: null,
notes: null,
sourceBottlingBatchId: 'b1',
createdAt,
userId: 'user-1',
shopRequestId: null,
apiKeyId: null,
},
]);
const result = await service.movements('h1');
expect(prisma.batchMovement.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { honeyBatchId: 'h1' },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
take: 201,
}),
);
expect(result).toEqual({
items: [
{
id: 'm1',
type: 'BOTTLING',
quantityGrams: -5000,
reason: null,
notes: null,
sourceBottlingBatchId: 'b1',
createdAt,
userId: 'user-1',
},
],
hasMore: false,
});
});
it('returns 200 bookings and says there are more', async () => {
prisma.honeyBatch.findUnique.mockResolvedValue({ id: 'h1' });
const row = (i: number) => ({
id: `m${i}`,
type: 'BOTTLING',
quantity: -1,
reason: null,
notes: null,
sourceBottlingBatchId: null,
createdAt: new Date(),
userId: null,
});
prisma.batchMovement.findMany.mockResolvedValue(
Array.from({ length: 201 }, (_, i) => row(i)),
);
const result = await service.movements('h1');
expect(result.items).toHaveLength(200);
expect(result.hasMore).toBe(true);
});
it('reads one booking through the given client', async () => {
const createdAt = new Date('2026-10-01T10:00:00Z');
const db = {
batchMovement: {
findUniqueOrThrow: jest.fn().mockResolvedValue({
id: 'm2',
type: 'WITHDRAWAL',
quantity: -1500,
reason: 'BRUCH',
notes: null,
sourceBottlingBatchId: null,
createdAt,
userId: 'user-1',
}),
},
};
const view = await service.movementView(db as never, 'm2');
expect(db.batchMovement.findUniqueOrThrow).toHaveBeenCalledWith({
where: { id: 'm2' },
});
expect(view).toMatchObject({
id: 'm2',
quantityGrams: -1500,
reason: 'BRUCH',
});
});
it('answers 404 for an unknown batch', async () => {
prisma.honeyBatch.findUnique.mockResolvedValue(null);
await expect(service.movements('x')).rejects.toBeInstanceOf(
NotFoundException,
);
});
});
});
@@ -0,0 +1,282 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import {
Prisma,
type BatchMovementType,
type WithdrawalReason,
} from '../../generated/prisma';
import { PrismaService } from '../prisma/prisma.service';
import { assertTransactionClient, type DbClient } from '../prisma/db-client';
import { lockRowForUpdate } from '../prisma/row-lock';
/** Half a gram in kg: the tolerance of the ledger (spec 4.6). */
const TOLERANCE_KG = 0.0005;
const GRAMS_PER_KG = 1000;
export const INSUFFICIENT_AMOUNT_MESSAGE =
'Abfüllmenge übersteigt die verfügbare Menge der Honigcharge';
export const BELOW_ZERO_MESSAGE = 'Die Menge kann nicht unter 0 sinken.';
export const LEDGER_MANAGED_MESSAGE =
'Die Menge wird über das Bewegungsbuch geführt. Bitte eine Korrektur buchen.';
export interface LedgerBooking {
honeyBatchId: string;
deltaKg: number;
type: 'BOTTLING' | 'CORRECTION' | 'WITHDRAWAL';
reason?: WithdrawalReason | null;
notes?: string | null;
sourceBottlingBatchId?: string | null;
actingUserId: string | null;
}
export interface LedgerResult {
/** The rest after the booking; null for a batch whose amount is not kept. */
amountKg: number | null;
/** null when nothing was booked. */
movementId: string | null;
}
export interface HoneyBatchMovementView {
id: string;
type: BatchMovementType;
quantityGrams: number;
reason: WithdrawalReason | null;
notes: string | null;
sourceBottlingBatchId: string | null;
createdAt: Date;
userId: string | null;
}
function toView(row: {
id: string;
type: BatchMovementType;
quantity: number;
reason: WithdrawalReason | null;
notes: string | null;
sourceBottlingBatchId: string | null;
createdAt: Date;
userId: string | null;
}): HoneyBatchMovementView {
return {
id: row.id,
type: row.type,
quantityGrams: row.quantity,
reason: row.reason,
notes: row.notes,
sourceBottlingBatchId: row.sourceBottlingBatchId,
createdAt: row.createdAt,
userId: row.userId,
};
}
/** One rounding for every gram: floor(x * 1000 + 0.5), as in the migration. */
function toGrams(kg: number): number {
return Math.floor(kg * GRAMS_PER_KG + 0.5);
}
/** A kg amount on the gram grid. */
export function roundToGram(kg: number): number {
return toGrams(kg) / GRAMS_PER_KG;
}
/** Bookings returned by `movements`; more are reported, not sent. */
export const MOVEMENTS_PAGE_SIZE = 200;
/** The rest implied by the ledger, on the gram grid, never below 0. */
function restFromLedger(initialAmountKg: number, grams: number): number {
return Math.max(
0,
Math.round((initialAmountKg + grams / GRAMS_PER_KG) * GRAMS_PER_KG) /
GRAMS_PER_KG,
);
}
/**
* The movement book of a honey batch (spec 4.6, 7.7): the ONE place that
* writes HoneyBatch.amount. Invariant: amount = initialAmount + sum(quantity)
* / 1000, rounded to the gram, never below 0. A batch without initialAmount
* is not booked (its amount is not recorded). Every method runs in the
* caller's transaction, so the booking commits or rolls back together with
* the business write behind it.
*/
@Injectable()
export class HoneyBatchLedgerService {
constructor(private readonly prisma: PrismaService) {}
/**
* Books a change of the rest under the batch row lock. A negative delta
* needs enough rest (half a gram of tolerance), a positive one gives back.
* The stored amount is set from the ledger sum, so it cannot drift.
*/
async bookIn(
tx: Prisma.TransactionClient,
booking: LedgerBooking,
): Promise<LedgerResult> {
assertTransactionClient(tx, 'HoneyBatchLedgerService.bookIn');
const { honeyBatchId } = booking;
if (!(await lockRowForUpdate(tx, 'HoneyBatch', honeyBatchId))) {
throw new NotFoundException('Honey batch not found');
}
const batch = await tx.honeyBatch.findUnique({
where: { id: honeyBatchId },
select: { amount: true, initialAmount: true },
});
if (batch === null) {
throw new NotFoundException('Honey batch not found');
}
if (batch.initialAmount === null) {
return { amountKg: null, movementId: null };
}
let grams = toGrams(booking.deltaKg);
if (grams === 0) {
return { amountKg: batch.amount, movementId: null };
}
const rest = batch.amount ?? 0;
if (grams < 0 && rest < -grams / GRAMS_PER_KG - TOLERANCE_KG) {
throw new BadRequestException(
booking.type === 'BOTTLING'
? INSUFFICIENT_AMOUNT_MESSAGE
: BELOW_ZERO_MESSAGE,
);
}
// Within the tolerance a booking takes exactly the rest on the gram
// grid, so the sum of the bookings and the amount stay equal.
const restGrams = toGrams(rest);
if (grams < 0 && -grams > restGrams) {
grams = -restGrams;
}
if (grams === 0) {
return { amountKg: batch.amount, movementId: null };
}
const booked = await tx.batchMovement.aggregate({
where: { honeyBatchId },
_sum: { quantity: true },
});
const amountKg = restFromLedger(
batch.initialAmount,
(booked._sum.quantity ?? 0) + grams,
);
const movement = await tx.batchMovement.create({
data: {
honeyBatchId,
type: booking.type,
quantity: grams,
reason: booking.reason ?? null,
notes: booking.notes ?? null,
sourceBottlingBatchId: booking.sourceBottlingBatchId ?? null,
userId: booking.actingUserId,
},
});
await tx.honeyBatch.update({
where: { id: honeyBatchId },
data: { amount: amountKg },
});
return { amountKg, movementId: movement.id };
}
/**
* The first amount of a batch that had none: it becomes the rest and the
* initial amount at once, without a booking. For a new batch with an amount
* and for the PATCH from "not recorded" to a value.
*/
async setInitialIn(
tx: Prisma.TransactionClient,
honeyBatchId: string,
amountKg: number,
): Promise<void> {
assertTransactionClient(tx, 'HoneyBatchLedgerService.setInitialIn');
if (!Number.isFinite(amountKg) || amountKg < 0) {
throw new BadRequestException('Die Menge muss eine Zahl ab 0 sein.');
}
const batch = await this.lockedBatch(tx, honeyBatchId);
const rounded = roundToGram(amountKg);
if (batch.initialAmount !== null) {
throw new Error(
`HoneyBatchLedgerService.setInitialIn: initialAmount already set for ${honeyBatchId}`,
);
}
await tx.honeyBatch.update({
where: { id: honeyBatchId },
data: { amount: rounded, initialAmount: rounded },
});
}
/**
* Takes the amount away again ("not recorded"). Only while nothing is
* booked: with bookings the amount is corrected, not erased.
*/
async clearIn(
tx: Prisma.TransactionClient,
honeyBatchId: string,
): Promise<void> {
assertTransactionClient(tx, 'HoneyBatchLedgerService.clearIn');
const batch = await this.lockedBatch(tx, honeyBatchId);
if (batch.initialAmount === null) {
return;
}
const booked = await tx.batchMovement.aggregate({
where: { honeyBatchId },
_count: { _all: true },
});
if (booked._count._all > 0) {
throw new BadRequestException(LEDGER_MANAGED_MESSAGE);
}
await tx.honeyBatch.update({
where: { id: honeyBatchId },
data: { amount: null, initialAmount: null },
});
}
/** The bookings of one batch, newest first. */
async movements(honeyBatchId: string): Promise<{
items: HoneyBatchMovementView[];
hasMore: boolean;
}> {
const batch = await this.prisma.honeyBatch.findUnique({
where: { id: honeyBatchId },
select: { id: true },
});
if (batch === null) {
throw new NotFoundException('Honey batch not found');
}
const rows = await this.prisma.batchMovement.findMany({
where: { honeyBatchId },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
take: MOVEMENTS_PAGE_SIZE + 1,
});
return {
items: rows.slice(0, MOVEMENTS_PAGE_SIZE).map(toView),
hasMore: rows.length > MOVEMENTS_PAGE_SIZE,
};
}
/** One booking, read through `db` (a transaction sees its own writes). */
async movementView(
db: DbClient,
movementId: string,
): Promise<HoneyBatchMovementView> {
return toView(
await db.batchMovement.findUniqueOrThrow({ where: { id: movementId } }),
);
}
private async lockedBatch(
tx: Prisma.TransactionClient,
honeyBatchId: string,
) {
if (!(await lockRowForUpdate(tx, 'HoneyBatch', honeyBatchId))) {
throw new NotFoundException('Honey batch not found');
}
const batch = await tx.honeyBatch.findUnique({
where: { id: honeyBatchId },
select: { amount: true, initialAmount: true },
});
if (batch === null) {
throw new NotFoundException('Honey batch not found');
}
return batch;
}
}
@@ -15,19 +15,25 @@ import { RequirePermission } from '../auth/decorators/require-permission.decorat
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateHoneyBatchDto } from './dto/create-honey-batch.dto';
import { CreateHoneyBatchMovementDto } from './dto/create-honey-batch-movement.dto';
import { UpdateHoneyBatchDto } from './dto/update-honey-batch.dto';
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 { HoneyBatchLedgerService } from './honey-batch-ledger.service';
import { HoneyBatchesService } from './honey-batches.service';
import { Idempotent } from '../common/idempotency/idempotent.decorator';
@ApiTags('honey-batches')
@ApiBearerAuth()
@Controller('honey-batches')
export class HoneyBatchesController {
constructor(private readonly honeyBatchesService: HoneyBatchesService) {}
constructor(
private readonly honeyBatchesService: HoneyBatchesService,
private readonly ledger: HoneyBatchLedgerService,
) {}
@RequirePermission('honey-batches:read')
@Get()
@@ -50,6 +56,24 @@ export class HoneyBatchesController {
return this.honeyBatchesService.findById(id);
}
// The movement book: every change of the rest, newest first.
@RequirePermission('honey-batches:read')
@Get(':id/movements')
movements(@Param('id') id: string) {
return this.ledger.movements(id);
}
@RequirePermission('honey-batches:update')
@Idempotent()
@Post(':id/movements')
createMovement(
@Param('id') id: string,
@Body() dto: CreateHoneyBatchMovementDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.honeyBatchesService.createMovement(id, dto, actor.id);
}
@RequirePermission('honey-batches:read')
@Get(':id/traceability')
traceability(@Param('id') id: string) {
@@ -1,12 +1,13 @@
import { Module } from '@nestjs/common';
import { AutomationModule } from '../automation/automation.module';
import { HoneyBatchesController } from './honey-batches.controller';
import { HoneyBatchLedgerService } from './honey-batch-ledger.service';
import { HoneyBatchesService } from './honey-batches.service';
@Module({
imports: [AutomationModule],
controllers: [HoneyBatchesController],
providers: [HoneyBatchesService],
exports: [HoneyBatchesService],
providers: [HoneyBatchesService, HoneyBatchLedgerService],
exports: [HoneyBatchesService, HoneyBatchLedgerService],
})
export class HoneyBatchesModule {}
@@ -1,9 +1,14 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import {
BadRequestException,
ConflictException,
NotFoundException,
} from '@nestjs/common';
import { HoneyBatchesService } from './honey-batches.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { AutomationService } from '../automation/automation.service';
import { HoneyBatchLedgerService } from './honey-batch-ledger.service';
describe('HoneyBatchesService', () => {
let service: HoneyBatchesService;
@@ -35,6 +40,13 @@ describe('HoneyBatchesService', () => {
let auditLog: { record: jest.Mock };
let events: { record: jest.Mock };
let automation: { generateNumber: jest.Mock };
let ledger: {
bookIn: jest.Mock;
setInitialIn: jest.Mock;
clearIn: jest.Mock;
movements: jest.Mock;
movementView: jest.Mock;
};
const extraction = {
id: 'extraction-1',
@@ -95,11 +107,19 @@ describe('HoneyBatchesService', () => {
auditLog = { record: jest.fn() };
events = { record: jest.fn() };
automation = { generateNumber: jest.fn().mockResolvedValue(null) };
ledger = {
bookIn: jest.fn().mockResolvedValue({ amountKg: 20, movementId: 'm1' }),
setInitialIn: jest.fn().mockResolvedValue(undefined),
clearIn: jest.fn().mockResolvedValue(undefined),
movements: jest.fn().mockResolvedValue([]),
movementView: jest.fn(),
};
service = new HoneyBatchesService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
automation as unknown as AutomationService,
events as unknown as DomainEventsService,
ledger as unknown as HoneyBatchLedgerService,
);
});
@@ -172,7 +192,7 @@ describe('HoneyBatchesService', () => {
});
prisma.honeyBatch.findMany.mockResolvedValue([
{ id: 'batch-0', initialAmount: 6, amount: 6 },
{ id: 'batch-1', initialAmount: null, amount: 8 },
{ id: 'batch-1', initialAmount: 8, amount: 8 },
]);
const result = await service.create(dto, 'user-1');
@@ -181,6 +201,53 @@ describe('HoneyBatchesService', () => {
'BATCHES_EXCEED_EXTRACTION',
]);
});
it('create writes amount and initialAmount together, through the ledger', async () => {
await service.create({ ...dto, amount: 20 }, 'user-1');
const [[createArg]] = prisma.honeyBatch.create.mock.calls as [
[{ data: Record<string, unknown> }],
];
expect(createArg.data).not.toHaveProperty('amount');
expect(createArg.data).not.toHaveProperty('initialAmount');
expect(ledger.setInitialIn).toHaveBeenCalledWith(prisma, 'batch-1', 20);
});
it('the audit entry of a new batch shows the amount the ledger set', async () => {
const withAmount = { ...honeyBatch, amount: 20, initialAmount: 20 };
prisma.honeyBatch.findUniqueOrThrow.mockResolvedValue(withAmount);
await service.create({ ...dto, amount: 20 }, 'user-1');
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ action: 'CREATE', afterData: withAmount }),
prisma,
);
expect(ledger.setInitialIn.mock.invocationCallOrder[0]).toBeLessThan(
prisma.honeyBatch.findUniqueOrThrow.mock.invocationCallOrder[0],
);
});
it('create without an amount books nothing', async () => {
await service.create(dto, 'user-1');
expect(ledger.setInitialIn).not.toHaveBeenCalled();
});
it('a batch without initialAmount does not count against the extraction', 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).toEqual([]);
});
});
it('traceability flattens the extraction, harvest, and colony chain', async () => {
@@ -561,6 +628,225 @@ describe('HoneyBatchesService', () => {
});
});
describe('update of the amount (movement book)', () => {
const tracked = { ...honeyBatch, initialAmount: 25, amount: 20 };
beforeEach(() => {
prisma.honeyBatch.findUniqueOrThrow.mockResolvedValue(tracked);
prisma.honeyBatch.findUnique.mockResolvedValue(tracked);
prisma.honeyBatch.findMany.mockResolvedValue([]);
prisma.extraction.findUnique.mockResolvedValue({
id: 'extraction-1',
amount: null,
});
});
function dataOfUpdate(): Record<string, unknown> {
const [[arg]] = prisma.honeyBatch.update.mock.calls as [
[{ data: Record<string, unknown> }],
];
return arg.data;
}
it('update never passes amount to Prisma, even when the dto carries it', async () => {
await service.update(
'batch-1',
{ amount: 22, honeyType: 'Waldhonig' },
'user-1',
);
expect(dataOfUpdate()).toEqual({ honeyType: 'Waldhonig' });
});
it('books a CORRECTION for the difference new minus current', async () => {
await service.update('batch-1', { amount: 22.5 }, 'user-1');
expect(ledger.bookIn).toHaveBeenCalledWith(prisma, {
honeyBatchId: 'batch-1',
deltaKg: 2.5,
type: 'CORRECTION',
actingUserId: 'user-1',
});
});
it('rounds the new amount to the gram before it takes the difference', async () => {
await service.update('batch-1', { amount: 22.5004 }, 'user-1');
expect(ledger.bookIn).toHaveBeenCalledWith(
prisma,
expect.objectContaining({ deltaKg: 2.5 }),
);
});
it('answers 409 for a batch with an initial amount but no rest', async () => {
prisma.honeyBatch.findUnique.mockResolvedValue({
...honeyBatch,
initialAmount: 25,
amount: null,
});
await expect(
service.update('batch-1', { amount: 22 }, 'user-1'),
).rejects.toBeInstanceOf(ConflictException);
expect(ledger.bookIn).not.toHaveBeenCalled();
});
it('books nothing when the amount stays the same', async () => {
await service.update('batch-1', { amount: 20 }, 'user-1');
expect(ledger.bookIn).not.toHaveBeenCalled();
});
it('reads the current rest under the batch lock, then books', async () => {
await service.update('batch-1', { amount: 22 }, 'user-1');
const reads = prisma.honeyBatch.findUnique.mock.invocationCallOrder;
expect(prisma.$queryRaw.mock.invocationCallOrder[1]).toBeLessThan(
reads[reads.length - 1],
);
expect(reads[reads.length - 1]).toBeLessThan(
ledger.bookIn.mock.invocationCallOrder[0],
);
});
it('sets the first amount of a batch that had none without a booking', async () => {
const untracked = { ...honeyBatch, initialAmount: null, amount: null };
prisma.honeyBatch.findUnique.mockResolvedValue(untracked);
await service.update('batch-1', { amount: 12 }, 'user-1');
expect(ledger.setInitialIn).toHaveBeenCalledWith(prisma, 'batch-1', 12);
expect(ledger.bookIn).not.toHaveBeenCalled();
});
it('amount null goes to the ledger, which refuses it once bookings exist', async () => {
await service.update('batch-1', { amount: null as never }, 'user-1');
expect(ledger.clearIn).toHaveBeenCalledWith(prisma, 'batch-1');
expect(prisma.honeyBatch.update).not.toHaveBeenCalled();
});
it('passes the 400 of the ledger on', async () => {
ledger.clearIn.mockRejectedValue(new BadRequestException('x'));
await expect(
service.update('batch-1', { amount: null as never }, 'user-1'),
).rejects.toBeInstanceOf(BadRequestException);
expect(events.record).not.toHaveBeenCalled();
});
});
describe('createMovement', () => {
const tracked = { ...honeyBatch, initialAmount: 25, amount: 20 };
beforeEach(() => {
prisma.honeyBatch.findUnique.mockResolvedValue(tracked);
prisma.honeyBatch.findUniqueOrThrow.mockResolvedValue(tracked);
ledger.movementView.mockResolvedValue({
id: 'm1',
type: 'WITHDRAWAL',
quantityGrams: -1000,
});
});
it('books a withdrawal with its reason, with audit and event in the transaction', async () => {
const result = await service.createMovement(
'batch-1',
{ type: 'WITHDRAWAL', deltaKg: -1, reason: 'EIGENBEDARF' },
'user-1',
);
expect(ledger.bookIn).toHaveBeenCalledWith(prisma, {
honeyBatchId: 'batch-1',
deltaKg: -1,
type: 'WITHDRAWAL',
reason: 'EIGENBEDARF',
notes: null,
actingUserId: 'user-1',
});
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'HoneyBatch',
entityId: 'batch-1',
action: 'UPDATE',
}),
prisma,
);
expect(events.record).toHaveBeenCalledWith(
prisma,
expect.objectContaining({
type: 'HoneyBatchCorrected',
aggregateId: 'batch-1',
}),
);
expect(result).toEqual(
expect.objectContaining({ id: 'm1', quantityGrams: -1000 }),
);
});
it('a withdrawal needs a reason and a negative amount', async () => {
await expect(
service.createMovement(
'batch-1',
{ type: 'WITHDRAWAL', deltaKg: -1 },
'user-1',
),
).rejects.toThrow(new BadRequestException('Bitte einen Grund angeben.'));
await expect(
service.createMovement(
'batch-1',
{ type: 'WITHDRAWAL', deltaKg: 1, reason: 'BRUCH' },
'user-1',
),
).rejects.toThrow(
new BadRequestException('Eine Entnahme braucht eine negative Menge.'),
);
expect(ledger.bookIn).not.toHaveBeenCalled();
});
it('a correction may carry a reason and stores it', async () => {
await service.createMovement(
'batch-1',
{ type: 'CORRECTION', deltaKg: 1, reason: 'SONSTIGES' },
'user-1',
);
expect(ledger.bookIn).toHaveBeenCalledWith(
prisma,
expect.objectContaining({ type: 'CORRECTION', reason: 'SONSTIGES' }),
);
});
it('refuses a batch whose amount is not recorded', async () => {
ledger.bookIn.mockResolvedValue({ amountKg: null, movementId: null });
await expect(
service.createMovement(
'batch-1',
{ type: 'CORRECTION', deltaKg: 1 },
'user-1',
),
).rejects.toThrow(
new BadRequestException(
'Die Menge dieser Honigcharge ist nicht erfasst. Bitte zuerst die Menge eintragen.',
),
);
expect(events.record).not.toHaveBeenCalled();
});
it('answers 404 for an unknown batch', async () => {
prisma.honeyBatch.findUnique.mockResolvedValue(null);
await expect(
service.createMovement(
'x',
{ type: 'CORRECTION', deltaKg: 1 },
'user-1',
),
).rejects.toBeInstanceOf(NotFoundException);
});
});
describe('prefill', () => {
it('proposes the rest of the extraction with its honey type and water content', async () => {
prisma.extraction.findUnique.mockResolvedValue({
@@ -15,8 +15,14 @@ import {
withWarnings,
type PlausibilityWarning,
} from '../common/warnings/plausibility-warning';
import { HoneyBatchStatus } from '../../generated/prisma';
import { HoneyBatchStatus, type Prisma } from '../../generated/prisma';
import { CreateHoneyBatchDto } from './dto/create-honey-batch.dto';
import { CreateHoneyBatchMovementDto } from './dto/create-honey-batch-movement.dto';
import {
HoneyBatchLedgerService,
roundToGram,
type HoneyBatchMovementView,
} from './honey-batch-ledger.service';
import { UpdateHoneyBatchDto } from './dto/update-honey-batch.dto';
import { CreateHoneyMeasurementDto } from './dto/create-honey-measurement.dto';
import { UpdateHoneyMeasurementDto } from './dto/update-honey-measurement.dto';
@@ -59,6 +65,7 @@ export class HoneyBatchesService {
private readonly auditLog: AuditLogService,
private readonly automation: AutomationService,
private readonly events: DomainEventsService,
private readonly ledger: HoneyBatchLedgerService,
) {}
async findAll(filter: HoneyBatchFilter = {}) {
@@ -82,19 +89,14 @@ export class HoneyBatchesService {
/**
* 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.
* without initialAmount (its amount was never recorded) does not count.
*/
private async batchesOf(db: DbClient, extractionId: string) {
const batches = await db.honeyBatch.findMany({
return db.honeyBatch.findMany({
where: { extractionId },
select: { id: true, initialAmount: true, amount: true },
select: { id: true, initialAmount: 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. */
@@ -127,8 +129,18 @@ export class HoneyBatchesService {
// 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);
// The amount never goes to Prisma directly: the ledger sets it together
// with the initial amount.
const { amount, ...rest } = dto;
const honeyBatch = await tx.honeyBatch.create({
data: { ...dto, batchNumber },
data: { ...rest, batchNumber },
});
if (typeof amount === 'number') {
await this.ledger.setInitialIn(tx, honeyBatch.id, amount);
}
const created = await tx.honeyBatch.findUniqueOrThrow({
where: { id: honeyBatch.id },
include: honeyBatchInclude,
});
await this.auditLog.record(
{
@@ -136,7 +148,7 @@ export class HoneyBatchesService {
entityId: honeyBatch.id,
action: 'CREATE',
userId: actingUserId,
afterData: honeyBatch,
afterData: created,
},
tx,
);
@@ -147,10 +159,6 @@ export class HoneyBatchesService {
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),
@@ -198,10 +206,16 @@ export class HoneyBatchesService {
? dto.storageContainerId
: null;
await tx.honeyBatch.update({
where: { id },
data: { ...dto },
});
const { amount, ...rest } = dto;
if (amount !== undefined) {
await this.changeAmount(tx, before, amount, actingUserId);
}
if (Object.keys(rest).length > 0) {
await tx.honeyBatch.update({
where: { id },
data: { ...rest },
});
}
// Correcting the container has to leave the same trace a deliberate
// relocation does - otherwise the batch's storage history would skip a
// container it was recorded in.
@@ -245,6 +259,114 @@ export class HoneyBatchesService {
});
}
/**
* A PATCH of the amount, as a booking (spec 7.7). `before` was read under
* the batch lock, so the difference is taken against the real rest. From
* "not recorded" to a value sets the initial amount; with a recorded amount
* the difference is a CORRECTION; null takes the amount away, which the
* ledger refuses once bookings exist.
*/
private async changeAmount(
tx: Prisma.TransactionClient,
before: { id: string; amount: number | null; initialAmount: number | null },
amount: number | null,
actingUserId: string,
): Promise<void> {
if (amount === null) {
await this.ledger.clearIn(tx, before.id);
return;
}
if (before.initialAmount === null) {
await this.ledger.setInitialIn(tx, before.id, amount);
return;
}
if (before.amount === null) {
throw new ConflictException(
'Die Menge der Honigcharge ist uneinheitlich gespeichert. Bitte die Charge neu laden oder den Support fragen.',
);
}
const deltaKg = roundKg(roundToGram(amount) - before.amount);
if (deltaKg === 0) {
return;
}
await this.ledger.bookIn(tx, {
honeyBatchId: before.id,
deltaKg,
type: 'CORRECTION',
actingUserId,
});
}
/**
* A manual booking in the movement book: a withdrawal (loose goods, sample,
* breakage) or a correction after weighing again. Bottlings book
* themselves. Booking, audit entry and event commit together.
*/
async createMovement(
id: string,
dto: CreateHoneyBatchMovementDto,
actingUserId: string,
): Promise<HoneyBatchMovementView> {
if (dto.type === 'WITHDRAWAL') {
if (dto.reason === undefined) {
throw new BadRequestException('Bitte einen Grund angeben.');
}
if (dto.deltaKg > 0) {
throw new BadRequestException(
'Eine Entnahme braucht eine negative Menge.',
);
}
}
return this.prisma.$transaction(async (tx) => {
await lockRowForUpdate(tx, 'HoneyBatch', id);
const before = await tx.honeyBatch.findUnique({
where: { id },
include: honeyBatchInclude,
});
if (!before) {
throw new NotFoundException('Honey batch not found');
}
const booked = await this.ledger.bookIn(tx, {
honeyBatchId: id,
deltaKg: dto.deltaKg,
type: dto.type,
reason: dto.reason ?? null,
notes: dto.notes ?? null,
actingUserId,
});
if (booked.movementId === null) {
throw new BadRequestException(
booked.amountKg === null
? 'Die Menge dieser Honigcharge ist nicht erfasst. Bitte zuerst die Menge eintragen.'
: 'Die Buchung ist kleiner als ein Gramm.',
);
}
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 this.ledger.movementView(tx, booked.movementId);
});
}
/**
* 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
+46 -3
View File
@@ -1,3 +1,4 @@
import { Logger } from '@nestjs/common';
import { IdempotencyCleanupService } from '../common/idempotency/idempotency-cleanup.service';
import { PrismaService } from '../prisma/prisma.service';
import { JobRunService } from '../system/job-run.service';
@@ -8,6 +9,7 @@ import {
FAILED_RETENTION_DAYS,
JOB_RUN_RETENTION_DAYS,
JOB_RUN_STALE_HOURS,
PENDING_DELIVERY_MAX_AGE_DAYS,
WEBHOOK_DELIVERY_RETENTION_DAYS,
} from './cleanup.job';
@@ -85,9 +87,13 @@ describe('CleanupJob', () => {
'domainEvents.failed': 12,
'webhookDeliveries.delivered': 13,
'webhookDeliveries.failed': 14,
'notificationDeliveries.done': 15,
'notificationDeliveries.failed': 16,
'notificationDeliveries.pending': 17,
telegramLinkTokens: 18,
'jobRuns.aborted': 1,
'jobRuns.deleted': 15,
weatherDays: 16,
'jobRuns.deleted': 19,
weatherDays: 20,
});
});
@@ -124,11 +130,48 @@ describe('CleanupJob', () => {
'"createdAt" < now() - (? * interval \'1 day\')',
);
// PENDING and PROCESSING entries are never touched, however old.
for (const { sql } of statements()) {
for (const { sql } of statements().filter(
({ sql }) => !sql.includes('FROM "NotificationDelivery"'),
)) {
expect(sql).not.toMatch(/PENDING|PROCESSING/);
}
});
it('cleanup deletes terminal NotificationDelivery rows after 30 days (FAILED after 90) and TelegramLinkToken rows expired for 30 days', async () => {
await job.run();
const failed = statementOn('NotificationDelivery', 'FAILED');
expect(failed.sql).toContain(
'"createdAt" < now() - (? * interval \'1 day\')',
);
expect(failed.values).toEqual([FAILED_RETENTION_DAYS]);
const delivered = statements().find(
({ sql }) =>
sql.includes('DELETE FROM "NotificationDelivery"') &&
sql.includes("IN ('DELIVERED', 'SKIPPED')"),
);
expect(delivered?.values).toEqual([WEBHOOK_DELIVERY_RETENTION_DAYS]);
expect(delivered?.sql).toContain('"createdAt" < now()');
const tokens = statementOn('TelegramLinkToken');
expect(tokens.sql).toContain('"expiresAt" < now() - interval \'30 days\'');
expect(tokens.values).toEqual([]);
});
it('removes PENDING NotificationDelivery rows older than 7 days and logs the count', async () => {
const warn = jest
.spyOn(Logger.prototype, 'warn')
.mockImplementation(() => undefined);
await job.run();
const { sql, values } = statementOn('NotificationDelivery', 'PENDING');
expect(sql).toContain('"createdAt" < now() - (? * interval \'1 day\')');
expect(values).toEqual([PENDING_DELIVERY_MAX_AGE_DAYS]);
expect(PENDING_DELIVERY_MAX_AGE_DAYS).toBe(7);
expect(warn).toHaveBeenCalledWith(expect.stringContaining('17 Telegram'));
warn.mockRestore();
});
it('deletes job runs after 90 days, only after stale runs were aborted', async () => {
await job.run();
+33 -1
View File
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { IdempotencyCleanupService } from '../common/idempotency/idempotency-cleanup.service';
import { PrismaService } from '../prisma/prisma.service';
import { JobRunService } from '../system/job-run.service';
@@ -14,6 +14,8 @@ export const WEBHOOK_DELIVERY_RETENTION_DAYS = 30;
/** FAILED events and deliveries stay longer, for diagnosis and retry. */
export const FAILED_RETENTION_DAYS = 90;
export const JOB_RUN_RETENTION_DAYS = 90;
/** A Telegram delivery that waited this long is stale anyway (24 h at most). */
export const PENDING_DELIVERY_MAX_AGE_DAYS = 7;
/** Far beyond activeDeadlineSeconds (at most 1 h) of every job CronJob. */
export const JOB_RUN_STALE_HOURS = 6;
@@ -24,6 +26,7 @@ export const JOB_RUN_STALE_HOURS = 6;
*/
@Injectable()
export class CleanupJob implements Job {
private readonly logger = new Logger(CleanupJob.name);
readonly name: JobName = 'cleanup';
constructor(
@@ -60,6 +63,31 @@ export class CleanupJob implements Job {
DELETE FROM "WebhookDelivery"
WHERE "status" = 'FAILED'
AND "createdAt" < now() - (${FAILED_RETENTION_DAYS} * interval '1 day')`;
// Template deliveries (a reply to a chat) carry no user, so the user
// cascade never reaches them: they go by age like all the others.
const notificationDeliveriesDone = await this.prisma.$executeRaw`
DELETE FROM "NotificationDelivery"
WHERE "status" IN ('DELIVERED', 'SKIPPED')
AND "createdAt" < now() - (${WEBHOOK_DELIVERY_RETENTION_DAYS} * interval '1 day')`;
const notificationDeliveriesFailed = await this.prisma.$executeRaw`
DELETE FROM "NotificationDelivery"
WHERE "status" = 'FAILED'
AND "createdAt" < now() - (${FAILED_RETENTION_DAYS} * interval '1 day')`;
// Unsent for a week (bot token never fixed, loop off): the sender would
// skip it as stale anyway. The only PENDING rows this job removes.
const notificationDeliveriesPending = await this.prisma.$executeRaw`
DELETE FROM "NotificationDelivery"
WHERE "status" = 'PENDING'
AND "createdAt" < now() - (${PENDING_DELIVERY_MAX_AGE_DAYS} * interval '1 day')`;
if (notificationDeliveriesPending > 0) {
this.logger.warn(
`Removed ${notificationDeliveriesPending} Telegram deliveries that stayed pending for over ${PENDING_DELIVERY_MAX_AGE_DAYS} days`,
);
}
// A link token lives 10 minutes; used or not, it is of no use after that.
const telegramLinkTokens = await this.prisma.$executeRaw`
DELETE FROM "TelegramLinkToken"
WHERE "expiresAt" < now() - interval '30 days'`;
// After abortStale: no run this old can still be RUNNING.
const jobRunsDeleted = await this.prisma.$executeRaw`
DELETE FROM "JobRun"
@@ -78,6 +106,10 @@ export class CleanupJob implements Job {
'domainEvents.failed': domainEventsFailed,
'webhookDeliveries.delivered': webhookDeliveriesDelivered,
'webhookDeliveries.failed': webhookDeliveriesFailed,
'notificationDeliveries.done': notificationDeliveriesDone,
'notificationDeliveries.failed': notificationDeliveriesFailed,
'notificationDeliveries.pending': notificationDeliveriesPending,
telegramLinkTokens,
'jobRuns.aborted': jobRunsAborted,
'jobRuns.deleted': jobRunsDeleted,
weatherDays,
@@ -0,0 +1,202 @@
import type { Notification } from '../../generated/prisma';
import { berlinDateTime } from '../common/dates';
import type { DbClient } from '../prisma/db-client';
import { NotificationDeliveryPlanner } from './delivery-planner';
const NOW = berlinDateTime('2026-10-13', '04:30');
function notification(
id: string,
userId: string,
category: Notification['category'],
): Notification {
return { id, userId, category } as Notification;
}
interface Preference {
userId: string;
telegramCategories: Notification['category'][];
digestTime: string;
quietFrom: string | null;
quietUntil: string | null;
}
const preference = (
userId: string,
overrides: Partial<Preference> = {},
): Preference => ({
userId,
telegramCategories: ['TASK_DUE', 'DIGEST'],
digestTime: '06:30',
quietFrom: '21:00',
quietUntil: '06:30',
...overrides,
});
describe('NotificationDeliveryPlanner', () => {
let db: {
notificationPreference: { findMany: jest.Mock };
notificationDelivery: { createMany: jest.Mock };
$queryRaw: jest.Mock;
$executeRaw: jest.Mock;
};
let planner: NotificationDeliveryPlanner;
const created = () =>
(
db.notificationDelivery.createMany.mock.calls[0] as [
{
data: {
channel: string;
userId: string;
notificationId: string;
availableAt: Date;
}[];
skipDuplicates: boolean;
},
]
)[0];
beforeEach(() => {
db = {
notificationPreference: { findMany: jest.fn().mockResolvedValue([]) },
notificationDelivery: {
createMany: jest
.fn()
.mockImplementation(({ data }: { data: unknown[] }) =>
Promise.resolve({ count: data.length }),
),
},
$queryRaw: jest.fn().mockResolvedValue([{ now: NOW }]),
$executeRaw: jest.fn().mockResolvedValue(1),
};
planner = new NotificationDeliveryPlanner();
});
const plan = (notifications: Notification[]) =>
planner.planIn(db as unknown as DbClient, notifications);
it('plans nothing for an empty list and asks no question', async () => {
expect(await plan([])).toBe(0);
expect(db.notificationPreference.findMany).not.toHaveBeenCalled();
});
it('asks only for users with Telegram switched on and a link', async () => {
await plan([notification('n1', 'u1', 'TASK_DUE')]);
expect(db.notificationPreference.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
userId: { in: ['u1'] },
telegramEnabled: true,
user: { telegramLink: { isNot: null } },
},
}),
);
});
it('plans nothing without an eligible user (no row, nothing sent to the worker)', async () => {
expect(await plan([notification('n1', 'u1', 'TASK_DUE')])).toBe(0);
expect(db.notificationDelivery.createMany).not.toHaveBeenCalled();
expect(db.$executeRaw).not.toHaveBeenCalled();
});
it('plans a TELEGRAM row for a category the user chose, available after the quiet hours', async () => {
db.notificationPreference.findMany.mockResolvedValue([preference('u1')]);
const count = await plan([notification('n1', 'u1', 'TASK_DUE')]);
expect(count).toBe(1);
expect(created().skipDuplicates).toBe(true);
expect(created().data).toEqual([
{
channel: 'TELEGRAM',
userId: 'u1',
notificationId: 'n1',
availableAt: berlinDateTime('2026-10-13', '06:31'),
},
]);
});
it('skips a category the user did not switch on', async () => {
db.notificationPreference.findMany.mockResolvedValue([
preference('u1', { telegramCategories: ['DIGEST'] }),
]);
const count = await plan([
notification('n1', 'u1', 'TASK_DUE'),
notification('n2', 'u1', 'SUGGESTION'),
]);
expect(count).toBe(0);
expect(db.notificationDelivery.createMany).not.toHaveBeenCalled();
});
it('plans each notification with the settings of its own user', async () => {
db.notificationPreference.findMany.mockResolvedValue([
preference('u1', { quietFrom: null, quietUntil: null }),
preference('u2', { telegramCategories: ['SYSTEM'] }),
]);
await plan([
notification('n1', 'u1', 'DIGEST'),
notification('n2', 'u2', 'SYSTEM'),
notification('n3', 'u3', 'SYSTEM'),
]);
expect(created().data).toEqual([
expect.objectContaining({
userId: 'u1',
availableAt: berlinDateTime('2026-10-13', '06:30'),
}),
expect.objectContaining({
userId: 'u2',
// 04:30 is inside the quiet hours of u2.
availableAt: berlinDateTime('2026-10-13', '06:30'),
}),
]);
});
it('reads the clock of the database once for the whole list', async () => {
db.notificationPreference.findMany.mockResolvedValue([
preference('u1'),
preference('u2'),
]);
await plan([
notification('n1', 'u1', 'DIGEST'),
notification('n2', 'u2', 'DIGEST'),
]);
expect(db.$queryRaw).toHaveBeenCalledTimes(1);
});
it('wakes the worker once through notification_delivery after inserting', async () => {
db.notificationPreference.findMany.mockResolvedValue([
preference('u1'),
preference('u2'),
]);
await plan([
notification('n1', 'u1', 'DIGEST'),
notification('n2', 'u2', 'DIGEST'),
]);
expect(db.$executeRaw).toHaveBeenCalledTimes(1);
const [strings, channel] = db.$executeRaw.mock.calls[0] as [
readonly string[],
string,
];
expect(strings.join('?')).toContain('pg_notify');
expect(channel).toBe('notification_delivery');
});
it('does not wake the worker if every row existed already', async () => {
db.notificationPreference.findMany.mockResolvedValue([preference('u1')]);
db.notificationDelivery.createMany.mockResolvedValue({ count: 0 });
expect(await plan([notification('n1', 'u1', 'DIGEST')])).toBe(0);
expect(db.$executeRaw).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,76 @@
import { Injectable } from '@nestjs/common';
import type { Notification } from '../../generated/prisma';
import { notifyWorker, WAKE_CHANNELS } from '../common/worker-wakeup';
import type { DbClient } from '../prisma/db-client';
import { deliveryAvailableAt } from './delivery-time';
/**
* Queues the Telegram delivery of new notifications (Spec 9a.4). Runs in the
* transaction that wrote the notifications, so a rollback takes the
* deliveries with it. Only users who switched Telegram on, hold a link and
* chose the category get a row; its text is rendered at send time.
*/
@Injectable()
export class NotificationDeliveryPlanner {
/** Returns how many deliveries were queued. */
async planIn(
db: DbClient,
notifications: readonly Notification[],
): Promise<number> {
if (notifications.length === 0) {
return 0;
}
const userIds = [...new Set(notifications.map((row) => row.userId))];
const preferences = await db.notificationPreference.findMany({
where: {
userId: { in: userIds },
telegramEnabled: true,
user: { telegramLink: { isNot: null } },
},
select: {
userId: true,
telegramCategories: true,
digestTime: true,
quietFrom: true,
quietUntil: true,
},
});
if (preferences.length === 0) {
return 0;
}
const byUser = new Map(preferences.map((row) => [row.userId, row]));
const now = await this.databaseNow(db);
const data = notifications.flatMap((row) => {
const preference = byUser.get(row.userId);
if (!preference?.telegramCategories.includes(row.category)) {
return [];
}
return [
{
channel: 'TELEGRAM' as const,
userId: row.userId,
notificationId: row.id,
availableAt: deliveryAvailableAt(now, row.category, preference),
},
];
});
if (data.length === 0) {
return 0;
}
// A repeated call (retried handler) finds the row of its notification.
const { count } = await db.notificationDelivery.createMany({
data,
skipDuplicates: true,
});
if (count > 0) {
await notifyWorker(db, WAKE_CHANNELS.notificationDeliveries);
}
return count;
}
/** "now" once, from the database clock the claim compares against. */
private async databaseNow(db: DbClient): Promise<Date> {
const [row] = await db.$queryRaw<{ now: Date }[]>`SELECT now() AS "now"`;
return row.now;
}
}
@@ -0,0 +1,194 @@
import { berlinDateTime } from '../common/dates';
import {
DeliveryPreferences,
deliveryAvailableAt,
isInQuietHours,
} from './delivery-time';
const DEFAULTS: DeliveryPreferences = {
digestTime: '06:30',
quietFrom: '21:00',
quietUntil: '06:30',
};
const NO_QUIET: DeliveryPreferences = {
digestTime: '06:30',
quietFrom: null,
quietUntil: null,
};
const at = (day: string, hhmm: string): Date => berlinDateTime(day, hhmm);
describe('isInQuietHours', () => {
it('is false without quiet hours', () => {
expect(isInQuietHours(at('2026-10-13', '23:30'), NO_QUIET)).toBe(false);
});
it('spans midnight: 21:00 up to, not including, 06:30', () => {
expect(isInQuietHours(at('2026-10-13', '20:59'), DEFAULTS)).toBe(false);
expect(isInQuietHours(at('2026-10-13', '21:00'), DEFAULTS)).toBe(true);
expect(isInQuietHours(at('2026-10-13', '23:59'), DEFAULTS)).toBe(true);
expect(isInQuietHours(at('2026-10-14', '00:00'), DEFAULTS)).toBe(true);
expect(isInQuietHours(at('2026-10-14', '06:29'), DEFAULTS)).toBe(true);
expect(isInQuietHours(at('2026-10-14', '06:30'), DEFAULTS)).toBe(false);
});
it('handles a window inside one day', () => {
const prefs = { ...NO_QUIET, quietFrom: '13:00', quietUntil: '15:00' };
expect(isInQuietHours(at('2026-10-13', '12:59'), prefs)).toBe(false);
expect(isInQuietHours(at('2026-10-13', '13:00'), prefs)).toBe(true);
expect(isInQuietHours(at('2026-10-13', '15:00'), prefs)).toBe(false);
});
it('reads the Berlin wall clock in summer and winter time', () => {
expect(isInQuietHours(at('2026-07-01', '22:00'), DEFAULTS)).toBe(true);
expect(isInQuietHours(at('2026-01-15', '22:00'), DEFAULTS)).toBe(true);
expect(isInQuietHours(at('2026-01-15', '12:00'), DEFAULTS)).toBe(false);
});
it('treats equal bounds as no quiet hours', () => {
const prefs = { ...NO_QUIET, quietFrom: '21:00', quietUntil: '21:00' };
expect(isInQuietHours(at('2026-10-13', '22:00'), prefs)).toBe(false);
});
});
describe('deliveryAvailableAt', () => {
it('a delivery created at 22:00 inside quiet hours 21:00–06:30 waits until 06:30 the next day', () => {
const now = at('2026-10-13', '22:00');
expect(deliveryAvailableAt(now, 'SUGGESTION', DEFAULTS)).toEqual(
at('2026-10-14', '06:30'),
);
});
it('a delivery created after midnight waits until 06:30 the same day', () => {
const now = at('2026-10-14', '02:15');
expect(deliveryAvailableAt(now, 'SYSTEM', DEFAULTS)).toEqual(
at('2026-10-14', '06:30'),
);
});
it('TASK_DUE waits until one minute after the digest time, DIGEST until the digest time', () => {
const now = at('2026-10-13', '04:30');
const prefs = { ...NO_QUIET, digestTime: '07:15' };
expect(deliveryAvailableAt(now, 'TASK_DUE', prefs)).toEqual(
at('2026-10-13', '07:16'),
);
expect(deliveryAvailableAt(now, 'DIGEST', prefs)).toEqual(
at('2026-10-13', '07:15'),
);
});
it('with default quiet hours a task due at 04:30 comes at 06:31 and the digest at 06:30', () => {
const now = at('2026-10-13', '04:30');
expect(deliveryAvailableAt(now, 'TASK_DUE', DEFAULTS)).toEqual(
at('2026-10-13', '06:31'),
);
expect(deliveryAvailableAt(now, 'DIGEST', DEFAULTS)).toEqual(
at('2026-10-13', '06:30'),
);
});
it('never goes back in time: after the digest time the category waits no longer', () => {
const now = at('2026-10-13', '09:00');
expect(deliveryAvailableAt(now, 'TASK_DUE', DEFAULTS)).toEqual(now);
expect(deliveryAvailableAt(now, 'DIGEST', DEFAULTS)).toEqual(now);
});
it('a category time that falls into quiet hours moves to the end of them', () => {
const now = at('2026-10-13', '04:30');
const prefs = { ...DEFAULTS, quietUntil: '07:00' };
expect(deliveryAvailableAt(now, 'TASK_DUE', prefs)).toEqual(
at('2026-10-13', '07:00'),
);
});
it('without quiet hours a SUGGESTION is available now', () => {
const now = at('2026-10-13', '23:00');
expect(deliveryAvailableAt(now, 'SUGGESTION', NO_QUIET)).toEqual(now);
});
it('ends quiet hours at 06:30 Berlin time across the clock change', () => {
const now = at('2026-10-24', '22:00');
const result = deliveryAvailableAt(now, 'SYSTEM', DEFAULTS);
expect(result).toEqual(at('2026-10-25', '06:30'));
expect(result.toISOString()).toBe('2026-10-25T05:30:00.000Z');
});
it('returns a new Date and leaves the input untouched', () => {
const now = at('2026-10-13', '12:00');
const result = deliveryAvailableAt(now, 'SYSTEM', NO_QUIET);
expect(result).not.toBe(now);
expect(result).toEqual(now);
});
});
describe('clock changes', () => {
const quietUntil0230: DeliveryPreferences = {
digestTime: '02:30',
quietFrom: '21:00',
quietUntil: '02:30',
};
const utc = (iso: string): Date => new Date(iso);
it('spring (2026-03-29, 02:00 jumps to 03:00): 01:59 is quiet, 03:00 is not', () => {
expect(isInQuietHours(utc('2026-03-29T00:59:00Z'), quietUntil0230)).toBe(
true,
);
expect(isInQuietHours(utc('2026-03-29T01:00:00Z'), quietUntil0230)).toBe(
false,
);
});
it('spring: an end time inside the gap becomes the instant one hour later (03:30 CEST)', () => {
const result = deliveryAvailableAt(
utc('2026-03-28T21:00:00Z'),
'SYSTEM',
quietUntil0230,
);
expect(result.toISOString()).toBe('2026-03-29T01:30:00.000Z');
});
it('autumn (2026-10-25, 03:00 falls back to 02:00): both 02:30 are outside the quiet hours, 02:29 is inside', () => {
expect(isInQuietHours(utc('2026-10-25T00:29:00Z'), quietUntil0230)).toBe(
true,
);
expect(isInQuietHours(utc('2026-10-25T00:30:00Z'), quietUntil0230)).toBe(
false,
);
expect(isInQuietHours(utc('2026-10-25T01:30:00Z'), quietUntil0230)).toBe(
false,
);
});
it('autumn: the end of quiet hours is the FIRST 02:30 (no extra hour of waiting)', () => {
const result = deliveryAvailableAt(
utc('2026-10-24T20:00:00Z'),
'SYSTEM',
quietUntil0230,
);
expect(result.toISOString()).toBe('2026-10-25T00:30:00.000Z');
});
it('autumn: the digest time 02:30 is the first occurrence as well', () => {
const prefs = { ...quietUntil0230, quietFrom: null, quietUntil: null };
const result = deliveryAvailableAt(
utc('2026-10-24T22:10:00Z'),
'DIGEST',
prefs,
);
expect(result.toISOString()).toBe('2026-10-25T00:30:00.000Z');
});
});
@@ -0,0 +1,110 @@
import type { NotificationCategory } from '../../generated/prisma';
import { addDays, berlinDateTime, berlinDayOf } from '../common/dates';
export interface DeliveryPreferences {
/** HH:MM, Europe/Berlin. */
digestTime: string;
/** HH:MM; null together with quietUntil: no quiet hours. */
quietFrom: string | null;
quietUntil: string | null;
}
const ONE_MINUTE_MS = 60_000;
const ONE_HOUR_MS = 3_600_000;
const berlinClock = new Intl.DateTimeFormat('en-GB', {
timeZone: 'Europe/Berlin',
hourCycle: 'h23',
hour: '2-digit',
minute: '2-digit',
});
/** The Berlin wall clock of an instant as HH:MM (compares as text). */
function berlinClockOf(instant: Date): string {
return berlinClock.format(instant);
}
interface QuietWindow {
from: string;
until: string;
}
/** Equal bounds or a missing bound mean: no quiet hours. */
function quietWindow(prefs: DeliveryPreferences): QuietWindow | null {
const { quietFrom: from, quietUntil: until } = prefs;
if (from === null || until === null || from === until) {
return null;
}
return { from, until };
}
/** Quiet hours may span midnight (21:00-06:30); the end is exclusive. */
export function isInQuietHours(now: Date, prefs: DeliveryPreferences): boolean {
const window = quietWindow(prefs);
if (window === null) {
return false;
}
const time = berlinClockOf(now);
return window.from < window.until
? time >= window.from && time < window.until
: time >= window.from || time < window.until;
}
/**
* The instant the Berlin clock shows `hhmm` on `day`. A wall time that
* happens twice (autumn change) is taken at its FIRST occurrence; one inside
* the spring gap becomes the instant one hour later (02:30 -> 03:30 CEST).
*/
function berlinWallTime(day: string, hhmm: string): Date {
const instant = berlinDateTime(day, hhmm);
const earlier = new Date(instant.getTime() - ONE_HOUR_MS);
return berlinClockOf(earlier) === hhmm && berlinDayOf(earlier) === day
? earlier
: instant;
}
/** The next instant after `instant` at which the Berlin clock shows `hhmm`. */
function nextBerlinTime(instant: Date, hhmm: string): Date {
const day = berlinDayOf(instant);
const today = berlinWallTime(day, hhmm);
return today.getTime() > instant.getTime()
? today
: berlinWallTime(addDays(day, 1), hhmm);
}
/** TASK_DUE follows the morning overview by a minute; the digest is the overview. */
function categoryTime(
now: Date,
category: NotificationCategory,
prefs: DeliveryPreferences,
): Date | null {
if (category !== 'TASK_DUE' && category !== 'DIGEST') {
return null;
}
const digest = berlinWallTime(berlinDayOf(now), prefs.digestTime);
return category === 'TASK_DUE'
? new Date(digest.getTime() + ONE_MINUTE_MS)
: digest;
}
/**
* When a Telegram delivery may go out (Spec 9a.4): not before `now`, not
* before the category's time of day (TASK_DUE: one minute after the digest
* time, DIGEST: the digest time), and not inside quiet hours - an instant
* inside them moves to their end.
*/
export function deliveryAvailableAt(
now: Date,
category: NotificationCategory,
prefs: DeliveryPreferences,
): Date {
const categoryAt = categoryTime(now, category, prefs);
const earliest = new Date(
Math.max(now.getTime(), categoryAt?.getTime() ?? now.getTime()),
);
const window = quietWindow(prefs);
if (window === null || !isInQuietHours(earliest, prefs)) {
return earliest;
}
return nextBerlinTime(earliest, window.until);
}
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
import { NotificationDeliveryPlanner } from './delivery-planner';
import { NotificationPreferencesService } from './notification-preferences.service';
import { NotificationSettingsController } from './notification-settings.controller';
import { NotificationsController } from './notifications.controller';
@@ -6,7 +7,15 @@ import { NotificationsService } from './notifications.service';
@Module({
controllers: [NotificationsController, NotificationSettingsController],
providers: [NotificationsService, NotificationPreferencesService],
exports: [NotificationsService, NotificationPreferencesService],
providers: [
NotificationsService,
NotificationPreferencesService,
NotificationDeliveryPlanner,
],
exports: [
NotificationsService,
NotificationPreferencesService,
NotificationDeliveryPlanner,
],
})
export class NotificationsModule {}
@@ -3,6 +3,7 @@ import { NotificationsService } from './notifications.service';
import { assertAppLink } from './notification-link';
import { PrismaService } from '../prisma/prisma.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { NotificationDeliveryPlanner } from './delivery-planner';
type CreateManyArg = {
data: {
@@ -19,6 +20,7 @@ type CreateManyArg = {
describe('NotificationsService', () => {
let service: NotificationsService;
let held: Set<string>;
let planner: { planIn: jest.Mock };
let prisma: {
notification: {
createManyAndReturn: jest.Mock;
@@ -28,9 +30,12 @@ describe('NotificationsService', () => {
updateMany: jest.Mock;
};
user: { findMany: jest.Mock };
$connect: jest.Mock;
$transaction: jest.Mock;
};
beforeEach(() => {
planner = { planIn: jest.fn().mockResolvedValue(0) };
prisma = {
notification: {
createManyAndReturn: jest
@@ -44,7 +49,13 @@ describe('NotificationsService', () => {
updateMany: jest.fn(),
},
user: { findMany: jest.fn().mockResolvedValue([]) },
// A transaction client lacks $connect; the root client has it.
$connect: jest.fn(),
$transaction: jest.fn(),
};
prisma.$transaction.mockImplementation((work: (tx: unknown) => unknown) =>
Promise.resolve(work(prisma)),
);
held = new Set();
service = new NotificationsService(
prisma as unknown as PrismaService,
@@ -53,6 +64,7 @@ describe('NotificationsService', () => {
.fn()
.mockImplementation(() => Promise.resolve(held)),
} as unknown as PermissionResolverService,
planner as unknown as NotificationDeliveryPlanner,
);
});
@@ -155,6 +167,64 @@ describe('NotificationsService', () => {
});
});
describe('Telegram delivery planning', () => {
const base = {
priority: 'INFO' as const,
category: 'SYSTEM' as const,
message: 'm',
};
it('plans the deliveries of the inserted rows right after the insert', async () => {
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }, { id: 'u2' }]);
const rows = await service.recordForUsers(['u1', 'u2'], base);
expect(planner.planIn).toHaveBeenCalledTimes(1);
expect(planner.planIn).toHaveBeenCalledWith(prisma, rows);
expect(
prisma.notification.createManyAndReturn.mock.invocationCallOrder[0],
).toBeLessThan(planner.planIn.mock.invocationCallOrder[0]);
});
it('plans with the caller transaction and opens none of its own', async () => {
const tx = {
user: { findMany: jest.fn().mockResolvedValue([{ id: 'u1' }]) },
notification: {
createManyAndReturn: jest.fn().mockResolvedValue([{ id: 'n' }]),
},
};
await service.record({ userId: 'u1', ...base }, tx as never);
expect(planner.planIn).toHaveBeenCalledWith(tx, [{ id: 'n' }]);
expect(prisma.$transaction).not.toHaveBeenCalled();
});
it('without a caller transaction, rows and deliveries share one of their own', async () => {
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }]);
await service.record({ userId: 'u1', ...base });
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
expect(planner.planIn).toHaveBeenCalledTimes(1);
});
it('plans only what was inserted: a repeated dedupe key plans nothing new', async () => {
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }]);
prisma.notification.createManyAndReturn.mockResolvedValue([]);
await service.record({ userId: 'u1', ...base, dedupeKey: 'k:u1' });
expect(planner.planIn).toHaveBeenCalledWith(expect.anything(), []);
});
it('plans nothing when no recipient is eligible', async () => {
await service.recordForUsers(['u1'], base);
expect(planner.planIn).not.toHaveBeenCalled();
});
});
describe('recordForPermission', () => {
const input = {
priority: 'WARNUNG' as const,
@@ -9,6 +9,7 @@ import {
Prisma,
} from '../../generated/prisma';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { NotificationDeliveryPlanner } from './delivery-planner';
import { assertAppLink, notificationDedupeKey } from './notification-link';
export interface CreateNotificationInput {
@@ -87,13 +88,15 @@ function uniqueSorted(keys: readonly PermissionKey[]): PermissionKey[] {
* needs (the rule the PermissionsGuard applies). The stored
* requiredPermissions are the recipient's and the content's permissions
* together, so a later consumer (e.g. the Telegram sender) can check them
* again.
* again. Right after the insert, in the same transaction, the delivery planner
* queues the Telegram delivery for users who asked for it.
*/
@Injectable()
export class NotificationsService {
constructor(
private readonly prisma: PrismaService,
private readonly permissionResolver: PermissionResolverService,
private readonly deliveryPlanner: NotificationDeliveryPlanner,
) {}
/** One recipient. null: repeated dedupe key, or the recipient lacks a right. */
@@ -274,19 +277,30 @@ export class NotificationsService {
...recipientKeys,
...(content.requiredPermissions ?? []),
]);
return db.notification.createManyAndReturn({
data: targets.map(({ userId, dedupeKey }) => ({
userId,
priority: content.priority,
category: content.category,
message: content.message,
relatedType: content.relatedType ?? null,
relatedId: content.relatedId ?? null,
link: content.link ?? null,
requiredPermissions,
dedupeKey,
})),
skipDuplicates: true,
});
const insertAndPlan = async (client: DbClient): Promise<Notification[]> => {
const rows = await client.notification.createManyAndReturn({
data: targets.map(({ userId, dedupeKey }) => ({
userId,
priority: content.priority,
category: content.category,
message: content.message,
relatedType: content.relatedType ?? null,
relatedId: content.relatedId ?? null,
link: content.link ?? null,
requiredPermissions,
dedupeKey,
})),
skipDuplicates: true,
});
// Only the rows that were inserted: a repeated dedupe key plans nothing.
await this.deliveryPlanner.planIn(client, rows);
return rows;
};
// Rows and deliveries belong together: without the caller's transaction
// they get their own. A caller WITH a transaction gets no nested one: the
// insert and the planning run on its client and commit or roll back with it.
return '$connect' in db
? db.$transaction((tx) => insertAndPlan(tx))
: insertAndPlan(db);
}
}
@@ -0,0 +1,22 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { CreatePhenologyReportDto } from './create-phenology-report.dto';
const errorsFor = async (body: object): Promise<string[]> =>
(await validate(plainToInstance(CreatePhenologyReportDto, body))).map(
(error) => error.property,
);
describe('CreatePhenologyReportDto', () => {
const base = { phase: 'SALWEIDE', date: '2026-03-10' };
it.each([undefined, null, 'a1'])('accepts apiaryId %p', async (apiaryId) => {
await expect(errorsFor({ ...base, apiaryId })).resolves.toEqual([]);
});
it('rejects an empty apiaryId', async () => {
await expect(errorsFor({ ...base, apiaryId: '' })).resolves.toEqual([
'apiaryId',
]);
});
});
@@ -0,0 +1,43 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsEnum,
IsNotEmpty,
IsOptional,
IsString,
Matches,
MaxLength,
} from 'class-validator';
import { PhenologyPhase } from '../../../generated/prisma';
export const MAX_NOTES_LENGTH = 500;
export class CreatePhenologyReportDto {
@ApiProperty({ enum: PhenologyPhase })
@IsEnum(PhenologyPhase)
phase!: PhenologyPhase;
@ApiProperty({
description: 'Day of the observation (YYYY-MM-DD), not after today.',
example: '2026-03-20',
})
@Matches(/^\d{4}-\d{2}-\d{2}$/, {
message: 'date must be a day in the format YYYY-MM-DD',
})
date!: string;
@ApiPropertyOptional({
nullable: true,
description:
'The apiary the report is for; null or omitted = all apiaries.',
})
@IsOptional()
@IsString()
@IsNotEmpty()
apiaryId?: string | null;
@ApiPropertyOptional({ nullable: true, maxLength: MAX_NOTES_LENGTH })
@IsOptional()
@IsString()
@MaxLength(MAX_NOTES_LENGTH)
notes?: string | null;
}
@@ -0,0 +1,13 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsOptional, Max, Min } from 'class-validator';
export class ListPhenologyReportsQuery {
@ApiPropertyOptional({ description: 'Calendar year; default: this year.' })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(2000)
@Max(2100)
year?: number;
}
@@ -0,0 +1,11 @@
import { PartialType } from '@nestjs/swagger';
import { CreatePhenologyReportDto } from './create-phenology-report.dto';
/**
* apiaryId null widens the report to all apiaries, notes null clears the
* notes; phase and date cannot be null (400).
*/
export class UpdatePhenologyReportDto extends PartialType(
CreatePhenologyReportDto,
{ skipNullProperties: false },
) {}
@@ -0,0 +1,66 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { berlinToday } from '../common/dates';
import { Idempotent } from '../common/idempotency/idempotent.decorator';
import { CreatePhenologyReportDto } from './dto/create-phenology-report.dto';
import { ListPhenologyReportsQuery } from './dto/list-phenology-reports.query';
import { UpdatePhenologyReportDto } from './dto/update-phenology-report.dto';
import { PhenologyService } from './phenology.service';
@ApiTags('phenology')
@ApiBearerAuth()
@Controller('phenology')
export class PhenologyController {
constructor(private readonly phenology: PhenologyService) {}
/** Season state of every active apiary; no coordinates. */
@RequirePermission('apiaries:read')
@Get('state')
state() {
return this.phenology.state();
}
@RequirePermission('phenology:read')
@Get('reports')
list(@Query() query: ListPhenologyReportsQuery) {
return this.phenology.list(query.year ?? Number(berlinToday().slice(0, 4)));
}
@RequirePermission('phenology:create')
@Idempotent()
@Post('reports')
create(
@Body() dto: CreatePhenologyReportDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.phenology.create(dto, actor.id);
}
@RequirePermission('phenology:update')
@Patch('reports/:id')
update(
@Param('id') id: string,
@Body() dto: UpdatePhenologyReportDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.phenology.update(id, dto, actor.id);
}
@RequirePermission('phenology:update')
@Idempotent()
@Post('reports/:id/revoke')
revoke(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
return this.phenology.revoke(id, actor.id);
}
}
@@ -0,0 +1,257 @@
import type { Prisma } from '../../generated/prisma';
import type {
AnyDomainEventEnvelope,
DomainEventType,
} from '../domain-events/domain-event-types';
import type { DomainEventsService } from '../domain-events/domain-events.service';
import { domainEventHandlerMetadata } from '../domain-events/on-domain-event.decorator';
import { PhenologyEventHandlers } from './phenology.handlers';
import type { PhenologyStateService } from './phenology-state.service';
// 2026-04-01 in Berlin, but still 2026-03-31 in UTC: the event day is the
// Berlin day.
const OCCURRED_AT = new Date('2026-03-31T22:30:00.000Z');
const EVENT_DAY = '2026-04-01';
// Delivery runs later (the fake clock): the later of the two days counts.
const DELIVERY_DAY = '2026-04-09';
const envelope = <T extends DomainEventType>(
type: T,
aggregateId: string,
payload: Record<string, unknown>,
) =>
({
type,
aggregateId,
payload,
occurredAt: OCCURRED_AT,
}) as unknown as AnyDomainEventEnvelope<T>;
describe('PhenologyEventHandlers', () => {
beforeAll(() => {
// Delivery runs much later than the event.
jest.useFakeTimers({ now: new Date('2026-04-09T10:00:00Z') });
});
afterAll(() => {
jest.useRealTimers();
});
let state: { recomputeIn: jest.Mock };
let events: { recordAll: jest.Mock };
let tx: { apiary: { findMany: jest.Mock } };
let handlers: PhenologyEventHandlers;
const asTx = () => tx as unknown as Prisma.TransactionClient;
beforeEach(() => {
state = { recomputeIn: jest.fn().mockResolvedValue({ changed: false }) };
events = { recordAll: jest.fn().mockResolvedValue(2) };
tx = {
apiary: {
findMany: jest.fn().mockResolvedValue([{ id: 'a1' }, { id: 'a2' }]),
},
};
handlers = new PhenologyEventHandlers(
state as unknown as PhenologyStateService,
events as unknown as DomainEventsService,
);
});
it('registers the handlers under their ids and has no season notification', () => {
const entries = (method: keyof PhenologyEventHandlers) =>
domainEventHandlerMetadata(
Object.getOwnPropertyDescriptor(
PhenologyEventHandlers.prototype,
method,
)?.value as object,
);
expect(entries('recompute')).toEqual([
{
types: ['PhenologyReported', 'PhenologyCorrected'],
handlerId: 'phenology.recompute',
},
]);
expect(entries('recomputeApiary')).toEqual([
{
types: ['PhenologyRecomputeRequested'],
handlerId: 'phenology.recompute-apiary',
},
]);
expect(
Object.getOwnPropertyNames(PhenologyEventHandlers.prototype),
).toEqual(['constructor', 'recompute', 'recomputeApiary']);
});
describe('recompute', () => {
it('a report for one apiary recomputes that apiary on the delivery day', async () => {
tx.apiary.findMany.mockResolvedValue([{ id: 'a1' }]);
await handlers.recompute(
envelope('PhenologyReported', 'r1', { apiaryId: 'a1' }),
asTx(),
);
expect(tx.apiary.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { status: 'ACTIVE', id: { in: ['a1'] } },
}),
);
expect(state.recomputeIn).toHaveBeenCalledTimes(1);
expect(state.recomputeIn).toHaveBeenCalledWith(tx, 'a1', DELIVERY_DAY, {
allowBackward: false,
});
expect(events.recordAll).not.toHaveBeenCalled();
});
it('a report for all apiaries records one follow-up event per active apiary', async () => {
await handlers.recompute(
envelope('PhenologyReported', 'r1', { apiaryId: null }),
asTx(),
);
expect(state.recomputeIn).not.toHaveBeenCalled();
expect(tx.apiary.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { status: 'ACTIVE' },
orderBy: { id: 'asc' },
}),
);
expect(events.recordAll).toHaveBeenCalledWith(tx, [
{
type: 'PhenologyRecomputeRequested',
aggregateType: 'Apiary',
aggregateId: 'a1',
payload: { apiaryId: 'a1', allowBackward: false, day: DELIVERY_DAY },
},
{
type: 'PhenologyRecomputeRequested',
aggregateType: 'Apiary',
aggregateId: 'a2',
payload: { apiaryId: 'a2', allowBackward: false, day: DELIVERY_DAY },
},
]);
});
it('a delayed delivery never writes an older day than the event', async () => {
jest.setSystemTime(new Date('2026-03-30T10:00:00Z'));
try {
tx.apiary.findMany.mockResolvedValue([{ id: 'a1' }]);
await handlers.recompute(
envelope('PhenologyReported', 'r1', { apiaryId: 'a1' }),
asTx(),
);
expect(state.recomputeIn).toHaveBeenCalledWith(tx, 'a1', EVENT_DAY, {
allowBackward: false,
});
} finally {
jest.setSystemTime(new Date('2026-04-09T10:00:00Z'));
}
});
it('a correction lets the phase go back', async () => {
await handlers.recompute(
envelope('PhenologyCorrected', 'r1', {
apiaryId: 'a1',
previousApiaryId: 'a1',
}),
asTx(),
);
expect(state.recomputeIn).toHaveBeenCalledWith(tx, 'a1', DELIVERY_DAY, {
allowBackward: true,
});
});
it('a scope move recomputes the old and the new apiary only', async () => {
tx.apiary.findMany.mockResolvedValue([{ id: 'a1' }, { id: 'a2' }]);
await handlers.recompute(
envelope('PhenologyCorrected', 'r1', {
apiaryId: 'a2',
previousApiaryId: 'a1',
}),
asTx(),
);
expect(tx.apiary.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { status: 'ACTIVE', id: { in: ['a1', 'a2'] } },
}),
);
expect(
(state.recomputeIn.mock.calls as unknown[][]).map((call) => call[1]),
).toEqual(['a1', 'a2']);
});
it.each([
['widened to all', { apiaryId: null, previousApiaryId: 'a1' }],
['narrowed from all', { apiaryId: 'a1', previousApiaryId: null }],
])('a correction %s fans out to all apiaries', async (_name, payload) => {
await handlers.recompute(
envelope('PhenologyCorrected', 'r1', payload),
asTx(),
);
expect(state.recomputeIn).not.toHaveBeenCalled();
expect(events.recordAll).toHaveBeenCalledTimes(1);
const [, recorded] = events.recordAll.mock.calls[0] as [
unknown,
{ payload: { allowBackward: boolean } }[],
];
expect(recorded).toHaveLength(2);
expect(recorded[0].payload.allowBackward).toBe(true);
});
it('lets an error propagate so the delivery is retried', async () => {
state.recomputeIn.mockRejectedValue(new Error('boom'));
await expect(
handlers.recompute(
envelope('PhenologyReported', 'r1', { apiaryId: 'a1' }),
asTx(),
),
).rejects.toThrow('boom');
});
});
describe('recomputeApiary', () => {
it('recomputes the one apiary on the requested day', async () => {
tx.apiary.findMany.mockResolvedValue([{ id: 'a2' }]);
await handlers.recomputeApiary(
envelope('PhenologyRecomputeRequested', 'a2', {
apiaryId: 'a2',
allowBackward: true,
day: '2026-04-01',
}),
asTx(),
);
expect(state.recomputeIn).toHaveBeenCalledWith(tx, 'a2', '2026-04-01', {
allowBackward: true,
});
});
it('skips an apiary that is no longer active', async () => {
tx.apiary.findMany.mockResolvedValue([]);
await handlers.recomputeApiary(
envelope('PhenologyRecomputeRequested', 'a2', {
apiaryId: 'a2',
allowBackward: false,
day: '2026-04-01',
}),
asTx(),
);
expect(state.recomputeIn).not.toHaveBeenCalled();
expect(tx.apiary.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'a2', status: 'ACTIVE' },
}),
);
});
});
});
@@ -0,0 +1,97 @@
import { Injectable } from '@nestjs/common';
import type { Prisma } from '../../generated/prisma';
import { berlinDayOf, berlinToday } from '../common/dates';
import type { AnyDomainEventEnvelope } from '../domain-events/domain-event-types';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { OnDomainEvent } from '../domain-events/on-domain-event.decorator';
import { PhenologyStateService } from './phenology-state.service';
type ReportEvent = AnyDomainEventEnvelope<
'PhenologyReported' | 'PhenologyCorrected'
>;
/** The apiaries a report event touches; null = all of them. */
function scopeOf(event: ReportEvent): string[] | null {
const scopes =
event.type === 'PhenologyCorrected'
? [event.payload.apiaryId, event.payload.previousApiaryId]
: [event.payload.apiaryId];
if (scopes.includes(null)) {
return null;
}
return [...new Set(scopes as string[])].sort();
}
/**
* The reactions to plant development reports. Pure DB writers through the
* delivery transaction; errors propagate so a delivery is retried. A season
* change is not announced one by one: the morning digest carries it.
*/
@Injectable()
export class PhenologyEventHandlers {
constructor(
private readonly state: PhenologyStateService,
private readonly events: DomainEventsService,
) {}
/**
* A report (or its correction or withdrawal) recomputes the season state of
* the apiaries it touched, on the later of the event's day and today (Berlin). After a correction the
* phase may fall back. One or two apiaries are recomputed right here, in id
* order; for all apiaries one PhenologyRecomputeRequested per active apiary
* is recorded, so each apiary is recomputed (and retried) on its own.
*/
@OnDomainEvent(
['PhenologyReported', 'PhenologyCorrected'],
'phenology.recompute',
)
async recompute(
event: ReportEvent,
tx: Prisma.TransactionClient,
): Promise<void> {
// A delayed delivery must not write an older day than the event's own.
const day = [berlinDayOf(event.occurredAt), berlinToday()].sort()[1];
const allowBackward = event.type === 'PhenologyCorrected';
const scope = scopeOf(event);
const apiaries = await tx.apiary.findMany({
where:
scope === null
? { status: 'ACTIVE' }
: { status: 'ACTIVE', id: { in: scope } },
select: { id: true },
orderBy: { id: 'asc' },
});
if (scope === null) {
await this.events.recordAll(
tx,
apiaries.map(({ id }) => ({
type: 'PhenologyRecomputeRequested' as const,
aggregateType: 'Apiary' as const,
aggregateId: id,
payload: { apiaryId: id, allowBackward, day },
})),
);
return;
}
for (const { id } of apiaries) {
await this.state.recomputeIn(tx, id, day, { allowBackward });
}
}
/** One apiary of a fan-out; an apiary that is no longer active is skipped. */
@OnDomainEvent('PhenologyRecomputeRequested', 'phenology.recompute-apiary')
async recomputeApiary(
event: AnyDomainEventEnvelope<'PhenologyRecomputeRequested'>,
tx: Prisma.TransactionClient,
): Promise<void> {
const { apiaryId, allowBackward, day } = event.payload;
const active = await tx.apiary.findMany({
where: { id: apiaryId, status: 'ACTIVE' },
select: { id: true },
});
if (active.length === 0) {
return;
}
await this.state.recomputeIn(tx, apiaryId, day, { allowBackward });
}
}
@@ -1,17 +1,27 @@
import { Module } from '@nestjs/common';
import { DomainEventsModule } from '../domain-events/domain-events.module';
import { SeasonModule } from '../season/season.module';
import { PhenologyStateService } from './phenology-state.service';
import { PhenologyController } from './phenology.controller';
import { PhenologyEventHandlers } from './phenology.handlers';
import { PhenologyService } from './phenology.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).
* it, the first step of the daily evaluation, the plant development reports
* (API) and the event handlers that recompute after a report. Imported by AppModule and WorkerModule (the handlers)
* and by JobsModule (the step).
*/
@Module({
imports: [DomainEventsModule],
providers: [PhenologyStateService, PhenologyStep],
imports: [DomainEventsModule, SeasonModule],
controllers: [PhenologyController],
providers: [
PhenologyStateService,
PhenologyStep,
PhenologyService,
PhenologyEventHandlers,
],
exports: [PhenologyStateService, PhenologyStep],
})
export class PhenologyModule {}
@@ -0,0 +1,455 @@
import {
BadRequestException,
ConflictException,
NotFoundException,
} from '@nestjs/common';
import type { AuditLogService } from '../audit/audit-log.service';
import type { DomainEventsService } from '../domain-events/domain-events.service';
import type { PrismaService } from '../prisma/prisma.service';
import { PhenologyService } from './phenology.service';
const day = (value: string) => new Date(`${value}T00:00:00.000Z`);
/** Prisma leaves out the fields that are undefined. */
const defined = (data: object): Record<string, unknown> =>
Object.fromEntries(
Object.entries(data).filter(([, value]) => value !== undefined),
);
const stored = (overrides: Record<string, unknown> = {}) => ({
id: 'r1',
apiaryId: 'a1',
phase: 'SALWEIDE',
date: day('2026-03-10'),
notes: null,
userId: 'u0',
revokedAt: null,
createdAt: day('2026-03-10'),
updatedAt: day('2026-03-10'),
...overrides,
});
const seasonState = (overrides: Record<string, unknown> = {}) => ({
apiaryId: 'a1',
year: 2026,
phase: 'SALWEIDE',
phaseSource: 'REPORT',
reportedOn: null,
segment: 'FRUEHJAHR',
segmentSince: '2026-03-10',
springSince: '2026-03-10',
gts: 210.5,
gtsThroughDay: '2026-03-31',
note: null,
...overrides,
});
describe('PhenologyService', () => {
beforeAll(() => {
jest.useFakeTimers({ now: new Date('2026-04-01T10:00:00Z') });
});
afterAll(() => {
jest.useRealTimers();
});
let service: PhenologyService;
let tx: {
$queryRaw: jest.Mock;
apiary: { findUnique: jest.Mock };
phenologyReport: {
create: jest.Mock;
findUnique: jest.Mock;
update: jest.Mock;
};
};
let prisma: {
$transaction: jest.Mock;
apiary: { findUnique: jest.Mock; findMany: jest.Mock };
phenologyReport: { findMany: jest.Mock };
};
let audit: { record: jest.Mock };
let events: { record: jest.Mock };
let season: { statesFor: jest.Mock };
beforeEach(() => {
tx = {
$queryRaw: jest.fn().mockResolvedValue([{ id: 'r1' }]),
apiary: { findUnique: jest.fn().mockResolvedValue({ id: 'a1' }) },
phenologyReport: {
create: jest
.fn()
.mockImplementation(({ data }: { data: object }) =>
Promise.resolve(stored(defined(data))),
),
findUnique: jest.fn().mockResolvedValue(stored()),
update: jest
.fn()
.mockImplementation(({ data }: { data: object }) =>
Promise.resolve(stored(defined(data))),
),
},
};
prisma = {
$transaction: jest
.fn()
.mockImplementation((work: (client: typeof tx) => Promise<unknown>) =>
work(tx),
),
apiary: {
findUnique: jest.fn().mockResolvedValue({ id: 'a1' }),
findMany: jest.fn().mockResolvedValue([]),
},
phenologyReport: { findMany: jest.fn().mockResolvedValue([]) },
};
audit = { record: jest.fn() };
events = { record: jest.fn() };
season = { statesFor: jest.fn().mockResolvedValue(new Map()) };
service = new PhenologyService(
prisma as unknown as PrismaService,
audit as unknown as AuditLogService,
events as unknown as DomainEventsService,
season,
);
});
describe('create', () => {
it('writes report, audit entry and PhenologyReported in one transaction', async () => {
await service.create(
{ phase: 'SALWEIDE', date: '2026-03-10', apiaryId: 'a1' },
'u1',
);
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
expect(tx.phenologyReport.create).toHaveBeenCalledWith({
data: {
phase: 'SALWEIDE',
date: day('2026-03-10'),
apiaryId: 'a1',
notes: null,
userId: 'u1',
},
});
expect(audit.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'PhenologyReport',
action: 'CREATE',
userId: 'u1',
}),
tx,
);
expect(events.record).toHaveBeenCalledWith(tx, {
type: 'PhenologyReported',
aggregateType: 'PhenologyReport',
aggregateId: 'r1',
actorUserId: 'u1',
payload: {
apiaryId: 'a1',
phase: 'SALWEIDE',
reportedOn: '2026-03-10',
},
});
});
it('stores apiaryId null for all apiaries', async () => {
await service.create({ phase: 'KIRSCHE', date: '2026-03-31' }, 'u1');
expect(tx.phenologyReport.create).toHaveBeenCalledWith({
data: expect.objectContaining({ apiaryId: null }) as object,
});
expect(tx.apiary.findUnique).not.toHaveBeenCalled();
});
it('rejects a day in the future with the German message', async () => {
await expect(
service.create({ phase: 'SALWEIDE', date: '2026-04-02' }, 'u1'),
).rejects.toThrow(
new BadRequestException('Das Datum liegt in der Zukunft.'),
);
expect(prisma.$transaction).not.toHaveBeenCalled();
});
it('accepts today (Berlin)', async () => {
await expect(
service.create({ phase: 'SALWEIDE', date: '2026-04-01' }, 'u1'),
).resolves.toBeDefined();
});
it('rejects a day that does not exist', async () => {
await expect(
service.create({ phase: 'SALWEIDE', date: '2026-02-30' }, 'u1'),
).rejects.toBeInstanceOf(BadRequestException);
});
it('answers 404 for an unknown apiary', async () => {
tx.apiary.findUnique.mockResolvedValue(null);
await expect(
service.create(
{ phase: 'SALWEIDE', date: '2026-03-10', apiaryId: 'nope' },
'u1',
),
).rejects.toBeInstanceOf(NotFoundException);
});
});
describe('update', () => {
it('locks the report, reads before afterwards, audits with beforeData and records PhenologyCorrected', async () => {
await service.update('r1', { phase: 'KIRSCHE' }, 'u1');
expect(tx.$queryRaw).toHaveBeenCalledTimes(1);
expect(tx.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan(
tx.phenologyReport.findUnique.mock.invocationCallOrder[0],
);
expect(audit.record).toHaveBeenCalledWith(
expect.objectContaining({
action: 'UPDATE',
entityId: 'r1',
beforeData: expect.objectContaining({ phase: 'SALWEIDE' }) as object,
}),
tx,
);
expect(events.record).toHaveBeenCalledWith(
tx,
expect.objectContaining({
type: 'PhenologyCorrected',
aggregateId: 'r1',
payload: {
apiaryId: 'a1',
previousApiaryId: 'a1',
phase: 'KIRSCHE',
reportedOn: '2026-03-10',
},
}),
);
});
it('apiaryId null widens the report to all apiaries; the event names both scopes', async () => {
await service.update('r1', { apiaryId: null }, 'u1');
expect(tx.phenologyReport.update).toHaveBeenCalledWith({
where: { id: 'r1' },
data: expect.objectContaining({ apiaryId: null }) as object,
});
expect(events.record).toHaveBeenCalledWith(
tx,
expect.objectContaining({
payload: expect.objectContaining({
apiaryId: null,
previousApiaryId: 'a1',
}) as object,
}),
);
});
it('moving the report to another apiary: the event names the old and the new one', async () => {
tx.apiary.findUnique.mockResolvedValue({ id: 'a2' });
await service.update('r1', { apiaryId: 'a2' }, 'u1');
expect(events.record).toHaveBeenCalledWith(
tx,
expect.objectContaining({
payload: expect.objectContaining({
apiaryId: 'a2',
previousApiaryId: 'a1',
}) as object,
}),
);
});
it('a patch that changes nothing writes no audit entry and no event', async () => {
const result = await service.update(
'r1',
{
phase: 'SALWEIDE',
date: '2026-03-10',
apiaryId: 'a1',
notes: null,
},
'u1',
);
expect(result).toEqual(stored());
expect(tx.phenologyReport.update).not.toHaveBeenCalled();
expect(audit.record).not.toHaveBeenCalled();
expect(events.record).not.toHaveBeenCalled();
});
it('an unknown new apiary answers 404 inside the transaction', async () => {
tx.apiary.findUnique.mockResolvedValue(null);
await expect(
service.update('r1', { apiaryId: 'nope' }, 'u1'),
).rejects.toBeInstanceOf(NotFoundException);
expect(tx.phenologyReport.update).not.toHaveBeenCalled();
});
it('notes null clears the notes', async () => {
tx.phenologyReport.findUnique.mockResolvedValue(
stored({ notes: 'früh' }),
);
await service.update('r1', { notes: null }, 'u1');
expect(tx.phenologyReport.update).toHaveBeenCalledWith({
where: { id: 'r1' },
data: expect.objectContaining({ notes: null }) as object,
});
});
it('404 when the report is gone', async () => {
tx.$queryRaw.mockResolvedValue([]);
tx.phenologyReport.findUnique.mockResolvedValue(null);
await expect(
service.update('r1', { phase: 'KIRSCHE' }, 'u1'),
).rejects.toBeInstanceOf(NotFoundException);
});
it('409 for a revoked report', async () => {
tx.phenologyReport.findUnique.mockResolvedValue(
stored({ revokedAt: day('2026-03-11') }),
);
await expect(
service.update('r1', { phase: 'KIRSCHE' }, 'u1'),
).rejects.toBeInstanceOf(ConflictException);
});
it('rejects a future date', async () => {
await expect(
service.update('r1', { date: '2026-05-01' }, 'u1'),
).rejects.toBeInstanceOf(BadRequestException);
});
});
describe('revoke', () => {
it('sets revokedAt, audits with beforeData and records PhenologyCorrected', async () => {
await service.revoke('r1', 'u1');
expect(tx.phenologyReport.update).toHaveBeenCalledWith({
where: { id: 'r1' },
data: { revokedAt: expect.any(Date) as Date },
});
expect(audit.record).toHaveBeenCalledWith(
expect.objectContaining({
action: 'UPDATE',
beforeData: expect.objectContaining({ revokedAt: null }) as object,
}),
tx,
);
expect(events.record).toHaveBeenCalledWith(
tx,
expect.objectContaining({
type: 'PhenologyCorrected',
payload: expect.objectContaining({
apiaryId: 'a1',
previousApiaryId: 'a1',
}) as object,
}),
);
});
it('409 when already revoked', async () => {
tx.phenologyReport.findUnique.mockResolvedValue(
stored({ revokedAt: day('2026-03-11') }),
);
await expect(service.revoke('r1', 'u1')).rejects.toBeInstanceOf(
ConflictException,
);
expect(tx.phenologyReport.update).not.toHaveBeenCalled();
});
it('404 when unknown', async () => {
tx.$queryRaw.mockResolvedValue([]);
tx.phenologyReport.findUnique.mockResolvedValue(null);
await expect(service.revoke('r1', 'u1')).rejects.toBeInstanceOf(
NotFoundException,
);
});
});
describe('list', () => {
it('returns the reports of the year with days, apiary name and revokedAt', async () => {
prisma.phenologyReport.findMany.mockResolvedValue([
{
...stored({ revokedAt: day('2026-03-12') }),
apiary: { name: 'Hang' },
user: { name: 'Imker' },
},
{
...stored({ id: 'r2', apiaryId: null }),
apiary: null,
user: { name: 'Imker' },
},
]);
const views = await service.list(2026);
expect(prisma.phenologyReport.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { date: { gte: day('2026-01-01'), lt: day('2027-01-01') } },
}),
);
expect(views).toEqual([
expect.objectContaining({
id: 'r1',
apiaryName: 'Hang',
date: '2026-03-10',
revokedAt: '2026-03-12T00:00:00.000Z',
userName: 'Imker',
}),
expect.objectContaining({
id: 'r2',
apiaryId: null,
apiaryName: null,
revokedAt: null,
}),
]);
});
});
describe('state', () => {
it('lists active apiaries with phase and segment, the report day, no coordinates', async () => {
prisma.apiary.findMany.mockResolvedValue([{ id: 'a1', name: 'Hang' }]);
season.statesFor.mockResolvedValue(new Map([['a1', seasonState()]]));
prisma.phenologyReport.findMany.mockResolvedValue([
stored({ date: day('2026-03-12') }),
stored({ id: 'r2', date: day('2026-03-10'), apiaryId: null }),
stored({ id: 'r3', phase: 'KIRSCHE', date: day('2026-03-01') }),
]);
const states = await service.state();
expect(prisma.apiary.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { status: 'ACTIVE' } }),
);
expect(states).toEqual([
{
apiaryId: 'a1',
apiaryName: 'Hang',
phase: 'SALWEIDE',
segment: 'FRUEHJAHR',
phaseSource: 'REPORT',
reportedOn: '2026-03-10',
gts: 210.5,
gtsThroughDay: '2026-03-31',
note: null,
},
]);
expect(JSON.stringify(states)).not.toMatch(/latitude|longitude/);
});
it('reportedOn is null unless the phase comes from a report', async () => {
prisma.apiary.findMany.mockResolvedValue([{ id: 'a1', name: 'Hang' }]);
season.statesFor.mockResolvedValue(
new Map([['a1', seasonState({ phaseSource: 'GTS' })]]),
);
const [state] = await service.state();
expect(state.reportedOn).toBeNull();
});
});
});
@@ -0,0 +1,340 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
PhaseSource,
PhenologyPhase,
PhenologyReport,
Prisma,
SeasonSegment,
} from '../../generated/prisma';
import { AuditLogService } from '../audit/audit-log.service';
import { berlinToday, dayToDate } from '../common/dates';
import { utcDay } from '../domain-events/domain-event-types';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { PrismaService } from '../prisma/prisma.service';
import { lockRowForUpdate, lockRowsForShare } from '../prisma/row-lock';
import type { SeasonNote } from '../season/season-types';
import { SeasonService } from '../season/season.service';
import type { CreatePhenologyReportDto } from './dto/create-phenology-report.dto';
import type { UpdatePhenologyReportDto } from './dto/update-phenology-report.dto';
const FUTURE_MESSAGE = 'Das Datum liegt in der Zukunft.';
const NOT_FOUND_MESSAGE = 'Meldung nicht gefunden.';
const REVOKED_MESSAGE = 'Die Meldung wurde schon zurückgezogen.';
export interface PhenologyReportView {
id: string;
apiaryId: string | null;
/** null = the report counts for all apiaries. */
apiaryName: string | null;
phase: PhenologyPhase;
/** YYYY-MM-DD */
date: string;
notes: string | null;
userId: string;
userName: string;
revokedAt: string | null;
createdAt: string;
}
/** The season state of an apiary as the API shows it: never coordinates. */
export interface PhenologyApiaryState {
apiaryId: string;
apiaryName: string;
phase: PhenologyPhase | null;
segment: SeasonSegment;
phaseSource: PhaseSource | null;
reportedOn: string | null;
gts: number | null;
gtsThroughDay: string | null;
note: SeasonNote;
}
/** A report day must exist in the calendar and must not lie after today. */
function assertReportDay(day: string): Date {
let date: Date;
try {
date = dayToDate(day);
} catch {
throw new BadRequestException('Das Datum ist ungültig.');
}
if (day > berlinToday()) {
throw new BadRequestException(FUTURE_MESSAGE);
}
return date;
}
function yearRange(year: number): { gte: Date; lt: Date } {
return {
gte: dayToDate(`${year}-01-01`),
lt: dayToDate(`${year + 1}-01-01`),
};
}
/**
* The plant development reports of the beekeeper (first bloom of Salweide,
* Kirsche, ...). A report changes the season state of its apiary (or of all);
* the recomputation runs from the events (PhenologyEventHandlers).
*/
@Injectable()
export class PhenologyService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly events: DomainEventsService,
private readonly season: SeasonService,
) {}
async create(
dto: CreatePhenologyReportDto,
userId: string,
): Promise<PhenologyReport> {
const date = assertReportDay(dto.date);
const apiaryId = dto.apiaryId ?? null;
return this.prisma.$transaction(async (tx) => {
await this.assertApiaryIn(tx, apiaryId);
const created = await tx.phenologyReport.create({
data: {
phase: dto.phase,
date,
apiaryId,
notes: dto.notes ?? null,
userId,
},
});
await this.auditLog.record(
{
entityType: 'PhenologyReport',
entityId: created.id,
action: 'CREATE',
userId,
afterData: created,
},
tx,
);
await this.events.record(tx, {
type: 'PhenologyReported',
aggregateType: 'PhenologyReport',
aggregateId: created.id,
actorUserId: userId,
payload: {
apiaryId: created.apiaryId,
phase: created.phase,
reportedOn: utcDay(created.date),
},
});
return created;
});
}
async update(
id: string,
dto: UpdatePhenologyReportDto,
userId: string,
): Promise<PhenologyReport> {
const date = dto.date === undefined ? undefined : assertReportDay(dto.date);
return this.prisma.$transaction(async (tx) => {
// Lock order: Apiary before PhenologyReport (LOCKABLE_TABLES).
await this.assertApiaryIn(tx, dto.apiaryId ?? null);
const before = await this.lockActive(tx, id);
const next = {
phase: dto.phase ?? before.phase,
date: date ?? before.date,
apiaryId: dto.apiaryId === undefined ? before.apiaryId : dto.apiaryId,
notes: dto.notes === undefined ? before.notes : dto.notes,
};
if (
next.phase === before.phase &&
next.date.getTime() === before.date.getTime() &&
next.apiaryId === before.apiaryId &&
next.notes === before.notes
) {
return before;
}
const updated = await tx.phenologyReport.update({
where: { id },
data: {
phase: dto.phase,
date,
apiaryId: dto.apiaryId,
notes: dto.notes,
},
});
await this.auditLog.record(
{
entityType: 'PhenologyReport',
entityId: id,
action: 'UPDATE',
userId,
beforeData: before,
afterData: updated,
},
tx,
);
await this.events.record(tx, {
type: 'PhenologyCorrected',
aggregateType: 'PhenologyReport',
aggregateId: id,
actorUserId: userId,
payload: {
apiaryId: updated.apiaryId,
previousApiaryId: before.apiaryId,
phase: updated.phase,
reportedOn: utcDay(updated.date),
},
});
return updated;
});
}
/** Withdraws a report; the apiaries it counted for are recomputed. */
async revoke(id: string, userId: string): Promise<PhenologyReport> {
return this.prisma.$transaction(async (tx) => {
const before = await this.lockActive(tx, id);
const revoked = await tx.phenologyReport.update({
where: { id },
data: { revokedAt: new Date() },
});
await this.auditLog.record(
{
entityType: 'PhenologyReport',
entityId: id,
action: 'UPDATE',
userId,
beforeData: before,
afterData: revoked,
},
tx,
);
await this.events.record(tx, {
type: 'PhenologyCorrected',
aggregateType: 'PhenologyReport',
aggregateId: id,
actorUserId: userId,
payload: {
apiaryId: revoked.apiaryId,
previousApiaryId: revoked.apiaryId,
phase: revoked.phase,
reportedOn: utcDay(revoked.date),
},
});
return revoked;
});
}
/** All reports of a year, withdrawn ones included (with revokedAt). */
async list(year: number): Promise<PhenologyReportView[]> {
const rows = await this.prisma.phenologyReport.findMany({
where: { date: yearRange(year) },
include: {
apiary: { select: { name: true } },
user: { select: { name: true } },
},
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }],
});
return rows.map((row) => ({
id: row.id,
apiaryId: row.apiaryId,
apiaryName: row.apiary?.name ?? null,
phase: row.phase,
date: utcDay(row.date),
notes: row.notes,
userId: row.userId,
userName: row.user.name,
revokedAt: row.revokedAt?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
}));
}
/** The season state of every active apiary. Never coordinates. */
async state(): Promise<PhenologyApiaryState[]> {
const today = berlinToday();
const apiaries = await this.prisma.apiary.findMany({
where: { status: 'ACTIVE' },
select: { id: true, name: true },
orderBy: { name: 'asc' },
});
const states = await this.season.statesFor(
this.prisma,
apiaries.map((apiary) => apiary.id),
today,
);
const reports = await this.prisma.phenologyReport.findMany({
where: { revokedAt: null, date: yearRange(Number(today.slice(0, 4))) },
select: { apiaryId: true, phase: true, date: true },
});
return apiaries.flatMap((apiary) => {
const state = states.get(apiary.id);
if (state === undefined) {
return [];
}
const reportedOn =
state.phaseSource === 'REPORT'
? (reports
.filter(
(report) =>
report.phase === state.phase &&
(report.apiaryId === null || report.apiaryId === apiary.id),
)
.map((report) => utcDay(report.date))
.sort()[0] ?? null)
: null;
return [
{
apiaryId: apiary.id,
apiaryName: apiary.name,
phase: state.phase,
segment: state.segment,
phaseSource: state.phaseSource,
reportedOn,
gts: state.gts,
gtsThroughDay: state.gtsThroughDay,
note: state.note,
},
];
});
}
/** Locks the report and reads it afterwards: 404 if gone, 409 if withdrawn. */
private async lockActive(
tx: Prisma.TransactionClient,
id: string,
): Promise<PhenologyReport> {
const exists = await lockRowForUpdate(tx, 'PhenologyReport', id);
const before = exists
? await tx.phenologyReport.findUnique({ where: { id } })
: null;
if (before === null) {
throw new NotFoundException(NOT_FOUND_MESSAGE);
}
if (before.revokedAt !== null) {
throw new ConflictException(REVOKED_MESSAGE);
}
return before;
}
/**
* 404 for an unknown apiary; the share lock keeps it from being deleted
* before the report that points at it is written.
*/
private async assertApiaryIn(
tx: Prisma.TransactionClient,
apiaryId: string | null,
): Promise<void> {
if (apiaryId === null) {
return;
}
await lockRowsForShare(tx, 'Apiary', [apiaryId]);
const apiary = await tx.apiary.findUnique({
where: { id: apiaryId },
select: { id: true },
});
if (apiary === null) {
throw new NotFoundException('Stand nicht gefunden.');
}
}
}
@@ -0,0 +1,150 @@
import type { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import type { NextStepsService } from '../autopilot/next-steps.service';
import type { NotificationsService } from '../notifications/notifications.service';
import type { PrismaService } from '../prisma/prisma.service';
import { DigestService } from './digest.service';
describe('DigestService season changes since the last digest', () => {
const NOW = new Date('2026-06-10T02:30:00.000Z');
let domainEvents: jest.Mock;
let jobRuns: jest.Mock;
let recordForUsers: jest.Mock;
let service: DigestService;
const change = (
apiaryId: string,
previousSegment: string | null,
segment: string,
occurredAt = '2026-06-09T12:00:00.000Z',
) => ({
occurredAt: new Date(occurredAt),
payload: { apiaryId, previousSegment, segment },
});
const lines = (): string[] =>
(
recordForUsers.mock.calls[0] as [string[], { message: string }]
)[1].message.split('\n');
beforeEach(() => {
domainEvents = jest.fn().mockResolvedValue([]);
jobRuns = jest.fn().mockResolvedValue([]);
recordForUsers = jest.fn().mockResolvedValue([{ id: 'n' }]);
const prisma = {
user: { findMany: jest.fn().mockResolvedValue([{ id: 'u1' }]) },
task: { findMany: jest.fn().mockResolvedValue([]) },
suggestion: { findMany: jest.fn().mockResolvedValue([]) },
colony: {
findMany: jest
.fn()
.mockResolvedValue([{ apiaryId: 'a1' }, { apiaryId: 'a2' }]),
},
apiary: {
findMany: jest.fn().mockResolvedValue([
{ id: 'a1', name: 'Wald' },
{ id: 'a2', name: 'Tal' },
]),
},
automationRule: { findUnique: jest.fn().mockResolvedValue(null) },
domainEvent: { findMany: domainEvents },
jobRun: { findMany: jobRuns },
$transaction: (work: (client: unknown) => unknown) => work({}),
} as unknown as PrismaService;
service = new DigestService(
prisma,
{ recordForUsers } as unknown as NotificationsService,
{
getPermissions: jest.fn().mockResolvedValue(new Set(['apiaries:read'])),
} as unknown as PermissionResolverService,
{
inspectionDueAll: jest.fn().mockResolvedValue([]),
} as unknown as NextStepsService,
);
});
it('reads the changes between the end of the previous window and now', async () => {
jobRuns.mockResolvedValue([
{
result: {
digestsWritten: 1,
digestWindowEnd: '2026-06-09T05:00:00.000Z',
},
},
]);
await service.runFor('2026-06-10', undefined, { until: NOW });
expect(domainEvents).toHaveBeenCalledWith(
expect.objectContaining({
where: {
type: 'SeasonPhaseChanged',
occurredAt: { gte: new Date('2026-06-09T05:00:00.000Z'), lt: NOW },
},
}),
);
});
it('takes the last 24 hours without a previous digest run', async () => {
jobRuns.mockResolvedValue([{ result: { TaskDue: 1 } }, { result: null }]);
await service.runFor('2026-06-10', undefined, { until: NOW });
expect(domainEvents).toHaveBeenCalledWith(
expect.objectContaining({
where: {
type: 'SeasonPhaseChanged',
occurredAt: { gte: new Date('2026-06-09T02:30:00.000Z'), lt: NOW },
},
}),
);
});
it('lists a segment change since the last digest, with its interval', async () => {
domainEvents.mockResolvedValue([change('a1', 'FRUEHJAHR', 'SCHWARMZEIT')]);
await service.runFor('2026-06-10', undefined, { until: NOW });
expect(lines()).toContain(
'Stand Wald: Schwarmzeit beginnt. Durchsicht alle 7 Tage.',
);
});
it('ignores phase-only changes and a first state without previous segment', async () => {
domainEvents.mockResolvedValue([
change('a1', 'SOMMER', 'SOMMER'),
change('a2', null, 'FRUEHJAHR'),
]);
expect(await service.runFor('2026-06-10', undefined, { until: NOW })).toBe(
0,
);
});
it('shows a change back neutrally and nets out several changes of one apiary', async () => {
domainEvents.mockResolvedValue([
change('a1', 'FRUEHJAHR', 'SCHWARMZEIT', '2026-06-09T08:00:00.000Z'),
change('a1', 'SCHWARMZEIT', 'FRUEHJAHR', '2026-06-09T09:00:00.000Z'),
change('a2', 'FRUEHJAHR', 'WINTERRUHE', '2026-06-09T10:00:00.000Z'),
]);
await service.runFor('2026-06-10', undefined, { until: NOW });
expect(lines().slice(1)).toEqual(['Stand Tal: wieder Winterruhe.']);
});
it('treats autumn to winter rest as a beginning, not a return', async () => {
domainEvents.mockResolvedValue([change('a1', 'HERBST', 'WINTERRUHE')]);
await service.runFor('2026-06-10', undefined, { until: NOW });
expect(lines()).toContain('Stand Wald: Winterruhe beginnt.');
});
it('leaves out apiaries without living colonies', async () => {
domainEvents.mockResolvedValue([change('a9', 'FRUEHJAHR', 'SCHWARMZEIT')]);
expect(await service.runFor('2026-06-10', undefined, { until: NOW })).toBe(
0,
);
});
});
@@ -160,6 +160,22 @@ describe('buildDigest', () => {
expect(lines(digest?.message)[1]).toBe('Stand Wald: Winterruhe beginnt.');
});
it('words a segment that went back neutrally', () => {
const digest = buildDigest('u1', new Set(['apiaries:read']), {
...EMPTY,
seasonChanges: [
{
apiaryName: 'Wald',
segment: 'WINTERRUHE',
intervalDays: null,
isReturn: true,
},
],
});
expect(lines(digest?.message)[1]).toBe('Stand Wald: wieder Winterruhe.');
});
it('builds no digest without content', () => {
expect(buildDigest('u1', ALL, EMPTY)).toBeNull();
expect(buildDigest('u1', new Set(), DATA)).toBeNull();
@@ -203,6 +219,8 @@ describe('DigestService', () => {
colony: {
findMany: jest.fn().mockResolvedValue([{ apiaryId: 'a1' }]),
},
domainEvent: { findMany: jest.fn().mockResolvedValue([]) },
jobRun: { findMany: jest.fn().mockResolvedValue([]) },
apiary: {
findMany: jest.fn().mockResolvedValue([{ id: 'a1', name: 'Wald' }]),
},
@@ -215,9 +233,6 @@ describe('DigestService', () => {
{
inspectionDueAll: jest.fn().mockResolvedValue([]),
} as unknown as NextStepsService,
{
statesFor: jest.fn().mockResolvedValue(new Map()),
},
);
});
+154 -29
View File
@@ -12,8 +12,7 @@ import { loggableError } from '../common/loggable-error';
import { utcDay } from '../domain-events/domain-event-types';
import { NotificationsService } from '../notifications/notifications.service';
import { PrismaService } from '../prisma/prisma.service';
import { SeasonSegmentKey } from '../season/season-types';
import { SeasonService } from '../season/season.service';
import { SEASON_SEGMENTS, SeasonSegmentKey } from '../season/season-types';
import { JobPartialFailure } from '../system/job-run.service';
/** One line of the digest and the permissions that entitled the reader to it. */
@@ -36,6 +35,8 @@ export interface DigestData {
apiaryName: string;
segment: SeasonSegmentKey;
intervalDays: number | null;
/** The segment went back (a revoked report): worded neutrally. */
isReturn?: boolean;
}[];
}
@@ -55,6 +56,17 @@ const SEGMENT_NAMES: Readonly<Record<SeasonSegmentKey, string>> = {
HERBST: 'Herbst',
};
/** The time span of season changes a digest run reports. */
export interface DigestWindow {
from?: Date;
until?: Date;
}
/** Key in the JobRun result: where the season window of that digest ended. */
export const WINDOW_END_KEY = 'digestWindowEnd';
const FALLBACK_WINDOW_MS = 24 * 60 * 60 * 1000;
const PREVIOUS_RUNS_LOOKED_AT = 10;
const OPEN_TASK_STATUSES = ['OPEN', 'IN_PROGRESS'] as const;
function plural(count: number, one: string, many: string): string {
@@ -133,11 +145,12 @@ function seasonLines(
return [];
}
return data.seasonChanges.map((change) => ({
text:
`Stand ${change.apiaryName}: ${SEGMENT_NAMES[change.segment]} beginnt.` +
(change.intervalDays === null
? ''
: ` Durchsicht alle ${change.intervalDays} Tage.`),
text: change.isReturn
? `Stand ${change.apiaryName}: wieder ${SEGMENT_NAMES[change.segment]}.`
: `Stand ${change.apiaryName}: ${SEGMENT_NAMES[change.segment]} beginnt.` +
(change.intervalDays === null
? ''
: ` Durchsicht alle ${change.intervalDays} Tage.`),
permissions: ['apiaries:read'],
}));
}
@@ -183,16 +196,23 @@ export class DigestService {
private readonly notifications: NotificationsService,
private readonly permissionResolver: PermissionResolverService,
private readonly nextSteps: NextStepsService,
private readonly seasons: SeasonService,
) {}
/**
* The digests of the Berlin day `today` for every active user who did not
* switch them off (`userIds` narrows that, for tests). Returns how many
* were written; a rerun for the same day writes 0 (the keys exist). A failing user is logged and does not stop the others;
* the run then fails with the count of the digests that went through.
* were written; a rerun for the same day writes 0 (the keys exist). A failing
* user is logged and does not stop the others; the run then fails with the
* count of the digests that went through. `window` bounds the season changes
* this digest reports: `until` (default now) is stored by DigestStep as the
* start of the next window; `from` (default: the previous window end, else
* 24 hours before `until`) is for tests.
*/
async runFor(today: string, userIds?: readonly string[]): Promise<number> {
async runFor(
today: string,
userIds?: readonly string[],
window: DigestWindow = {},
): Promise<number> {
const users = await this.prisma.user.findMany({
where: {
isActive: true,
@@ -206,7 +226,7 @@ export class DigestService {
if (users.length === 0) {
return 0;
}
const data = await this.loadData(today);
const data = await this.loadData(today, window);
let written = 0;
let failed = 0;
for (const { id } of users) {
@@ -249,7 +269,10 @@ export class DigestService {
return rows.length;
}
private async loadData(today: string): Promise<DigestData> {
private async loadData(
today: string,
window: DigestWindow,
): Promise<DigestData> {
const [tasks, suggestions, inspectionDue, seasonChanges] =
await Promise.all([
this.loadTasks(today),
@@ -259,7 +282,7 @@ export class DigestService {
}),
// Every colony, whatever the reader may see: the lines are filtered per user.
this.nextSteps.inspectionDueAll(today),
this.loadSeasonChanges(today),
this.loadSeasonChanges(window),
]);
return { today, tasks, inspectionDue, suggestions, seasonChanges };
}
@@ -279,40 +302,142 @@ export class DigestService {
);
}
/** Apiaries with living colonies whose season segment begins today. */
/**
* The start of the window: the end of the window the previous digest run
* reported (stored in its JobRun result), else 24 hours before `until`.
* Only runs that reached the digest step carry a window end.
*/
private async windowStart(until: Date): Promise<Date> {
const runs = await this.prisma.jobRun.findMany({
where: {
job: 'daily-evaluation',
status: { in: ['SUCCEEDED', 'FAILED'] },
},
orderBy: { startedAt: 'desc' },
take: PREVIOUS_RUNS_LOOKED_AT,
select: { result: true },
});
for (const { result } of runs) {
const end =
result !== null && typeof result === 'object' && !Array.isArray(result)
? result[WINDOW_END_KEY]
: undefined;
if (typeof end === 'string' && !Number.isNaN(Date.parse(end))) {
return new Date(end);
}
}
return new Date(until.getTime() - FALLBACK_WINDOW_MS);
}
/**
* Apiaries with living colonies whose season segment changed in the window
* [previous window end, until) according to the SeasonPhaseChanged events.
* Several changes of one apiary count as one (first previous segment to
* last segment); no net change, no line. A change that happened after
* `until` belongs to the next digest, so every change is reported once.
*/
private async loadSeasonChanges(
today: string,
window: DigestWindow,
): Promise<DigestData['seasonChanges']> {
const colonies = await this.prisma.colony.findMany({
where: LIVING_COLONY_WHERE,
const until = window.until ?? new Date();
const from = window.from ?? (await this.windowStart(until));
const events = await this.prisma.domainEvent.findMany({
where: {
type: 'SeasonPhaseChanged',
occurredAt: { gte: from, lt: until },
},
orderBy: [{ occurredAt: 'asc' }, { id: 'asc' }],
select: { payload: true },
});
const nets = netSegmentChanges(events.map((event) => event.payload));
if (nets.length === 0) {
return [];
}
const withColonies = await this.prisma.colony.findMany({
where: {
...LIVING_COLONY_WHERE,
apiaryId: { in: nets.map((net) => net.apiaryId) },
},
select: { apiaryId: true },
distinct: ['apiaryId'],
});
const apiaryIds = colonies.map((colony) => colony.apiaryId);
const states = await this.seasons.statesFor(this.prisma, apiaryIds, today);
const changed = [...states.values()].filter(
(state) => state.segmentSince === today,
);
const living = new Set(withColonies.map((colony) => colony.apiaryId));
const changed = nets.filter((net) => living.has(net.apiaryId));
if (changed.length === 0) {
return [];
}
const [apiaries, interval] = await Promise.all([
this.prisma.apiary.findMany({
where: { id: { in: changed.map((state) => state.apiaryId) } },
where: { id: { in: changed.map((net) => net.apiaryId) } },
select: { id: true, name: true },
}),
readRule(this.prisma, 'INSPECTION_INTERVAL'),
]);
const names = new Map(apiaries.map((a) => [a.id, a.name] as const));
return changed
.map((state) => ({
apiaryName: names.get(state.apiaryId) ?? '',
segment: state.segment,
.map((net) => ({
apiaryName: names.get(net.apiaryId) ?? '',
segment: net.segment,
intervalDays:
state.segment === 'WINTERRUHE' || !interval.active
net.segment === 'WINTERRUHE' || !interval.active
? null
: interval.config.intervalDays[state.segment],
: interval.config.intervalDays[net.segment],
isReturn: net.isReturn,
}))
.sort((a, b) => a.apiaryName.localeCompare(b.apiaryName, 'de'));
}
}
interface NetSegmentChange {
apiaryId: string;
segment: SeasonSegmentKey;
isReturn: boolean;
}
function isSegment(value: unknown): value is SeasonSegmentKey {
return SEASON_SEGMENTS.some((segment) => segment === value);
}
/** Autumn to winter rest is the normal course of the year, not a step back. */
function wentBack(from: SeasonSegmentKey, to: SeasonSegmentKey): boolean {
if (from === 'HERBST' && to === 'WINTERRUHE') {
return false;
}
return SEASON_SEGMENTS.indexOf(to) < SEASON_SEGMENTS.indexOf(from);
}
/**
* First previous segment to last segment per apiary, in event order. Events
* without a valid previous or new segment (a first state, phase-only
* changes) say nothing about a segment change.
*/
function netSegmentChanges(payloads: readonly unknown[]): NetSegmentChange[] {
const firstPrevious = new Map<string, SeasonSegmentKey>();
const last = new Map<string, SeasonSegmentKey>();
for (const payload of payloads) {
if (typeof payload !== 'object' || payload === null) {
continue;
}
const { apiaryId, previousSegment, segment } = payload as Record<
string,
unknown
>;
if (
typeof apiaryId !== 'string' ||
!isSegment(previousSegment) ||
!isSegment(segment)
) {
continue;
}
if (!firstPrevious.has(apiaryId)) {
firstPrevious.set(apiaryId, previousSegment);
}
last.set(apiaryId, segment);
}
return [...last].flatMap(([apiaryId, segment]) => {
const previous = firstPrevious.get(apiaryId);
return previous === undefined || previous === segment
? []
: [{ apiaryId, segment, isReturn: wentBack(previous, segment) }];
});
}
@@ -14,8 +14,26 @@ describe('DigestStep', () => {
runFor,
} as unknown as DigestService).run('2026-06-10');
expect(runFor).toHaveBeenCalledWith('2026-06-10');
expect(result).toEqual({ digestsWritten: 7 });
expect(runFor).toHaveBeenCalledWith('2026-06-10', undefined, {
until: expect.any(Date) as unknown,
});
const until = (
runFor.mock.calls[0] as [string, undefined, { until: Date }]
)[2].until;
expect(result).toEqual({
digestsWritten: 7,
digestWindowEnd: until.toISOString(),
});
});
it('stores no window end when nothing was written, so the next run reads the same window', async () => {
const runFor = jest.fn().mockResolvedValue(0);
const result = await new DigestStep({
runFor,
} as unknown as DigestService).run('2026-06-10');
expect(result).toEqual({ digestsWritten: 0 });
});
it('lets a partial failure through with its counter', async () => {
+12 -2
View File
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import type { DailyEvaluationStep } from '../jobs/daily-evaluation-steps';
import type { JobResult } from '../jobs/job';
import { DigestService } from './digest.service';
import { DigestService, WINDOW_END_KEY } from './digest.service';
/**
* Step "digest", the last of the daily evaluation: the in-app morning
@@ -16,6 +16,16 @@ export class DigestStep implements DailyEvaluationStep {
constructor(private readonly digests: DigestService) {}
async run(today: string): Promise<JobResult> {
return { digestsWritten: await this.digests.runFor(today) };
// The window of season changes ends here; the next run starts where
// this one stopped, so every change is reported once.
const until = new Date();
const digestsWritten = await this.digests.runFor(today, undefined, {
until,
});
// A run that wrote nothing (a same-day rerun, or no content) reported
// no change: the next run must still read the earlier window.
return digestsWritten === 0
? { digestsWritten }
: { digestsWritten, [WINDOW_END_KEY]: until.toISOString() };
}
}
@@ -1,7 +1,6 @@
import { Module } from '@nestjs/common';
import { AutopilotModule } from '../autopilot/autopilot.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { SeasonModule } from '../season/season.module';
import { DigestService } from './digest.service';
import { DigestStep } from './digest.step';
import { TaskDueHandlers } from './task-due.handlers';
@@ -14,7 +13,7 @@ import { TaskDueHandlers } from './task-due.handlers';
* PermissionsModule (API) or JobPermissionsModule (worker, jobs).
*/
@Module({
imports: [NotificationsModule, SeasonModule, AutopilotModule],
imports: [NotificationsModule, AutopilotModule],
providers: [TaskDueHandlers, DigestService, DigestStep],
exports: [DigestService, DigestStep],
})
@@ -0,0 +1,147 @@
import { readdirSync, readFileSync } from 'node:fs';
import { join, relative, sep } from 'node:path';
import { AutomationTriggerType } from '../../generated/prisma';
import {
SUGGESTION_TEXT_REGISTERS,
SUGGESTION_TEXTS,
} from './suggestion-texts';
/**
* Guard (spec 14.3): a trigger that proposes suggestions has a reason text,
* and every reason code a producer passes on is registered. A suggestion
* whose code has no template renders with the bare intent title and no
* reasons, which is a silent loss of the explanation.
*/
/**
* Triggers that never are the `ruleKey` of a suggestion: number formats,
* notification-only rules, warnings and the season state. A new trigger is
* expected to suggest, so it fails here until it has a text template or is
* named in this set on purpose.
*/
const NON_SUGGESTING_TRIGGERS: ReadonlySet<AutomationTriggerType> = new Set([
'HONEY_BATCH_NUMBER_FORMAT',
'BOTTLING_LOT_NUMBER_FORMAT',
'RAW_MATERIAL_LOT_NUMBER_FORMAT',
'PRODUCTION_BATCH_NUMBER_FORMAT',
'WITHDRAWAL_ENDED',
'HONEY_WATER_CONTENT',
'PHENOLOGY',
]);
const SUGGESTING_TRIGGERS: readonly AutomationTriggerType[] = Object.values(
AutomationTriggerType,
).filter((trigger) => !NON_SUGGESTING_TRIGGERS.has(trigger));
const SRC = join(__dirname, '..');
function sourceFiles(dir: string): string[] {
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const path = join(dir, entry.name);
if (entry.isDirectory()) {
return sourceFiles(path);
}
return entry.name.endsWith('.ts') && !entry.name.endsWith('.spec.ts')
? [path]
: [];
});
}
/** `reasonCode: <expression>,` of a producer: the expression up to the comma that ends the line. */
const REASON_CODE_PROPERTY = /\breasonCode:\s*([^;]*?),[ \t]*\r?\n/g;
const STRING_LITERAL = /'([A-Z][A-Z0-9_]*)'/g;
const TEMPLATE_PREFIX = /`([A-Z][A-Z0-9_]*)\$\{/g;
/** The codes of a ternary are its branches; its condition may compare other strings. */
function valueOf(expression: string): string {
const branch = /\?(?![.?])/.exec(expression);
return branch === null ? expression : expression.slice(branch.index);
}
interface ReasonCodeUse {
file: string;
literals: string[];
prefixes: string[];
}
/**
* The reason codes written into the producers of suggestions: files that
* name a `ruleKey` (the candidate and propose inputs). Band ratings and
* view types have a `reasonCode` of their own and do not.
*/
function reasonCodeUses(): ReasonCodeUse[] {
return sourceFiles(SRC).flatMap((path) => {
const source = readFileSync(path, 'utf8');
if (!/\bruleKey\b/.test(source)) {
return [];
}
const file = relative(SRC, path).split(sep).join('/');
return [...source.matchAll(REASON_CODE_PROPERTY)].map((match) => {
const value = valueOf(match[1]);
return {
file,
literals: [...value.matchAll(STRING_LITERAL)].map((m) => m[1]),
prefixes: [...value.matchAll(TEMPLATE_PREFIX)].map((m) => m[1]),
};
});
});
}
describe('suggesting triggers', () => {
it('every suggesting AutomationTriggerType has at least one registered reason text template', () => {
const registeredKeys = new Set(
SUGGESTION_TEXT_REGISTERS.flatMap((register) =>
Object.values(register).map((template) => template.ruleKey),
),
);
const missing = SUGGESTING_TRIGGERS.filter(
(trigger) => !registeredKeys.has(trigger),
);
expect(missing).toEqual([]);
});
it('every template belongs to a suggesting trigger, and the deny set holds real triggers without templates', () => {
const known = new Set<string>(Object.values(AutomationTriggerType));
const listed = new Set<string>(SUGGESTING_TRIGGERS);
expect([...NON_SUGGESTING_TRIGGERS].filter((t) => !known.has(t))).toEqual(
[],
);
expect(
Object.entries(SUGGESTION_TEXTS)
.filter(([, template]) => !listed.has(template.ruleKey))
.map(([reasonCode]) => reasonCode),
).toEqual([]);
});
it('every reasonCode passed to proposeIn in src is registered', () => {
const uses = reasonCodeUses();
const registered = Object.keys(SUGGESTION_TEXTS);
// The scan itself must see the producers, or the guard guards nothing.
expect(uses.length).toBeGreaterThanOrEqual(5);
const unregistered = uses.flatMap(({ file, literals, prefixes }) => [
...literals
.filter((code) => !registered.includes(code))
.map((code) => `${file}: ${code}`),
...prefixes
.filter((prefix) => !registered.some((code) => code.startsWith(prefix)))
.map((prefix) => `${file}: ${prefix}\${...}`),
]);
expect(unregistered).toEqual([]);
});
it('the scan knows the autopilot producer', () => {
const files = reasonCodeUses().map((use) => use.file);
expect(files).toEqual(
expect.arrayContaining([
'autopilot/autopilot-rules.ts',
'automation/automation.service.ts',
'suggestions/chains/chain-planner.ts',
]),
);
});
});
@@ -3,7 +3,7 @@ import { renderSuggestion } from '../suggestion-texts';
const lookup: EvidenceLookup = {
colonyCode: () => 'B3',
queenCode: () => null,
queenCode: (id) => (id === 'q12' ? 'Q-12' : null),
treatmentProduct: (id) => (id === 't1' ? 'Oxuvar' : null),
apiaryName: () => null,
stockItemName: () => null,
@@ -95,4 +95,125 @@ describe('autopilot texts', () => {
],
});
});
it('SUMMER_TREATMENT_DUE quotes how the end of the honey flow is known', () => {
expect(
render('SUMMER_TREATMENT_DUE', {
phaseSource: 'REPORT',
reportedOn: '2026-07-25',
since: '2026-07-25',
}),
).toEqual({
title: 'Sommerbehandlung planen',
reasons: [
'Tracht vorbei (gemeldet am 25.07.).',
'Noch keine Behandlung seit 25.07.',
],
});
expect(
render('SUMMER_TREATMENT_DUE', {
phaseSource: 'REPORT',
reportedOn: null,
since: '2026-07-27',
}).reasons[0],
).toBe('Tracht vorbei (gemeldet).');
expect(
render('SUMMER_TREATMENT_DUE', {
phaseSource: 'GTS',
since: '2026-07-25',
}).reasons[0],
).toBe('Tracht vorbei (geschätzt).');
expect(
render('SUMMER_TREATMENT_DUE', {
phaseSource: 'CALENDAR',
since: '2026-08-01',
}).reasons[0],
).toBe('Tracht vorbei (Kalender).');
});
it('AUTUMN_FEED_MISSING', () => {
expect(
render('AUTUMN_FEED_MISSING', {
phaseSource: 'REPORT',
since: '2026-07-25',
}),
).toEqual({
title: 'Einfüttern',
reasons: ['Tracht vorbei.', 'Noch keine Fütterung erfasst.'],
});
});
it('FEED_LOW names the inspection day or the colony card, in German', () => {
expect(
render('FEED_LOW', {
inspectedOn: '2026-04-03',
condition: 'WEAK',
source: 'INSPECTION',
}),
).toEqual({
title: 'Füttern',
reasons: ['Futter bei der Durchsicht am 03.04.: niedrig.'],
});
expect(
render('FEED_LOW', {
inspectedOn: null,
condition: 'CRITICAL',
source: 'COLONY_CARD',
}).reasons,
).toEqual(['Futter laut Volkskarte: kritisch.']);
});
it('HONEY_SUPER_CHECK names the phase, its source, the colony and the frames', () => {
expect(
render('HONEY_SUPER_CHECK', {
colonyId: 'c1',
phase: 'KIRSCHE',
phaseSource: 'GTS',
frames: 9,
}),
).toEqual({
title: 'Honigraum prüfen',
reasons: ['Kirsche blüht (geschätzt).', 'B3 besetzt 9 Waben.'],
});
expect(
render('HONEY_SUPER_CHECK', {
colonyId: 'c1',
phase: 'APFEL',
phaseSource: 'REPORT',
frames: 1,
}).reasons,
).toEqual(['Apfel blüht (gemeldet).', 'B3 besetzt 1 Wabe.']);
});
it('QUEEN_OLD names the queen and her hatch year', () => {
expect(render('QUEEN_OLD', { queenId: 'q12', hatchYear: 2024 })).toEqual({
title: 'Umweiseln planen',
reasons: ['Königin Q-12 ist von 2024.'],
});
expect(
render('QUEEN_OLD', { queenId: 'gone', hatchYear: 2024 }).reasons,
).toEqual(['Die Königin ist von 2024.']);
});
it('WINTER_CHECK_AUTUMN_MISSING and WINTER_CHECK_SPRING_MISSING name the winter', () => {
expect(render('WINTER_CHECK_AUTUMN_MISSING', { winterYear: 2026 })).toEqual(
{
title: 'Einwinterung erfassen',
reasons: ['Für den Winter 2026/27 fehlt die Einwinterung.'],
},
);
expect(
render('WINTER_CHECK_SPRING_MISSING', {
winterYear: 2025,
phase: 'SALWEIDE',
}),
).toEqual({
title: 'Auswinterung erfassen',
reasons: ['Salweide blüht.', 'Auswinterung 2025/26 fehlt.'],
});
expect(
render('WINTER_CHECK_SPRING_MISSING', { winterYear: 2099, phase: null })
.reasons,
).toEqual(['Auswinterung 2099/00 fehlt.']);
});
});
@@ -1,4 +1,5 @@
import { daysBetween } from '../../common/dates';
import { FEED_CONDITION_LABELS_DE } from '../../common/feed-vocabulary';
import type { SuggestionEvidence } from '../suggestion.types';
import {
formatDayDe,
@@ -16,6 +17,20 @@ const SEGMENT_PHRASES: Readonly<Record<string, string>> = {
HERBST: 'Im Herbst',
};
const PHASE_PHRASES: Readonly<Record<string, string>> = {
SALWEIDE: 'Salweide blüht',
KIRSCHE: 'Kirsche blüht',
APFEL: 'Apfel blüht',
HOLUNDER: 'Holunder blüht',
LINDE: 'Linde blüht',
};
const PHASE_SOURCE_WORDS: Readonly<Record<string, string>> = {
REPORT: 'gemeldet',
GTS: 'geschätzt',
CALENDAR: 'Kalender',
};
function text(evidence: SuggestionEvidence, key: string): string | null {
const value = evidence[key];
return typeof value === 'string' ? value : null;
@@ -165,6 +180,182 @@ const VARROA_RECHECK_DUE: SuggestionTextTemplate = {
},
};
/** 'Tracht vorbei (gemeldet am 25.07.)' - the day only where the report day is known. */
function honeyFlowOverReason(evidence: SuggestionEvidence): string {
const source = text(evidence, 'phaseSource');
const reportedOn = text(evidence, 'reportedOn');
if (source === 'REPORT' && reportedOn !== null) {
return `Tracht vorbei (gemeldet am ${formatDayDe(reportedOn)}).`;
}
const word = PHASE_SOURCE_WORDS[source ?? ''];
return word === undefined ? 'Tracht vorbei.' : `Tracht vorbei (${word}).`;
}
/** 'Kirsche blüht (geschätzt).' for a known phase, else nothing. */
function phaseReason(
evidence: SuggestionEvidence,
withSource: boolean,
): string[] {
const phrase = PHASE_PHRASES[text(evidence, 'phase') ?? ''];
if (phrase === undefined) {
return [];
}
const word = PHASE_SOURCE_WORDS[text(evidence, 'phaseSource') ?? ''];
return [
withSource && word !== undefined ? `${phrase} (${word}).` : `${phrase}.`,
];
}
/** '2026/27' for the winter that begins in 2026. */
function winterLabel(winterYear: number): string {
return `${winterYear}/${String((winterYear + 1) % 100).padStart(2, '0')}`;
}
const SUMMER_TREATMENT_DUE: SuggestionTextTemplate = {
ruleKey: 'SEASON_AUTOPILOT',
example: {
colonyId: 'c1',
phase: 'TRACHTENDE',
phaseSource: 'REPORT',
since: '2026-07-25',
},
render: (evidence) => {
const since = text(evidence, 'since');
return {
title: 'Sommerbehandlung planen',
reasons: [
honeyFlowOverReason(evidence),
since === null
? 'Noch keine Behandlung erfasst.'
: `Noch keine Behandlung seit ${formatDayDe(since)}`,
],
};
},
};
const AUTUMN_FEED_MISSING: SuggestionTextTemplate = {
ruleKey: 'SEASON_AUTOPILOT',
example: {
colonyId: 'c1',
phase: 'TRACHTENDE',
phaseSource: 'REPORT',
since: '2026-07-25',
},
render: () => ({
title: 'Einfüttern',
reasons: ['Tracht vorbei.', 'Noch keine Fütterung erfasst.'],
}),
};
const FEED_LOW: SuggestionTextTemplate = {
ruleKey: 'SEASON_AUTOPILOT',
example: {
colonyId: 'c1',
inspectionId: 'i1',
inspectedOn: '2026-04-03',
condition: 'WEAK',
source: 'INSPECTION',
},
render: (evidence) => {
const condition = text(evidence, 'condition') ?? '';
const label = Object.hasOwn(FEED_CONDITION_LABELS_DE, condition)
? FEED_CONDITION_LABELS_DE[
condition as keyof typeof FEED_CONDITION_LABELS_DE
].toLowerCase()
: null;
const inspectedOn = text(evidence, 'inspectedOn');
const where =
text(evidence, 'source') === 'INSPECTION' && inspectedOn !== null
? `bei der Durchsicht am ${formatDayDe(inspectedOn)}`
: 'laut Volkskarte';
return {
title: 'Füttern',
reasons: [
label === null
? `Futter ${where} niedrig.`
: `Futter ${where}: ${label}.`,
],
};
},
};
const HONEY_SUPER_CHECK: SuggestionTextTemplate = {
ruleKey: 'SEASON_AUTOPILOT',
example: {
colonyId: 'c1',
inspectionId: 'i1',
frames: 9,
phase: 'KIRSCHE',
phaseSource: 'GTS',
},
render: (evidence, lookup) => {
const colonyId = text(evidence, 'colonyId');
const code = colonyId === null ? null : lookup.colonyCode(colonyId);
const frames = count(evidence, 'frames');
return {
title: 'Honigraum prüfen',
reasons: [
...phaseReason(evidence, true),
...(frames === null
? []
: [
`${code ?? 'Das Volk'} besetzt ${frames} ${frames === 1 ? 'Wabe' : 'Waben'}.`,
]),
],
};
},
};
const QUEEN_OLD: SuggestionTextTemplate = {
ruleKey: 'SEASON_AUTOPILOT',
example: { colonyId: 'c1', queenId: 'q1', hatchYear: 2024 },
render: (evidence, lookup) => {
const queenId = text(evidence, 'queenId');
const code = queenId === null ? null : lookup.queenCode(queenId);
const year = count(evidence, 'hatchYear');
const subject = code === null ? 'Die Königin' : `Königin ${code}`;
return {
title: 'Umweiseln planen',
reasons: [
year === null ? `${subject} ist alt.` : `${subject} ist von ${year}.`,
],
};
},
};
const WINTER_CHECK_AUTUMN_MISSING: SuggestionTextTemplate = {
ruleKey: 'SEASON_AUTOPILOT',
example: { colonyId: 'c1', winterYear: 2026 },
render: (evidence) => {
const year = count(evidence, 'winterYear');
return {
title: 'Einwinterung erfassen',
reasons: [
year === null
? 'Die Einwinterung fehlt.'
: `Für den Winter ${winterLabel(year)} fehlt die Einwinterung.`,
],
};
},
};
const WINTER_CHECK_SPRING_MISSING: SuggestionTextTemplate = {
ruleKey: 'SEASON_AUTOPILOT',
example: { colonyId: 'c1', winterYear: 2025, phase: 'SALWEIDE' },
render: (evidence) => {
const year = count(evidence, 'winterYear');
return {
title: 'Auswinterung erfassen',
reasons: [
...phaseReason(evidence, false),
year === null
? 'Die Auswinterung fehlt.'
: `Auswinterung ${winterLabel(year)} fehlt.`,
],
};
},
};
/** Texts of the season autopilot suggestions, by reason code. */
export const AUTOPILOT_TEXTS: SuggestionTextRegister = {
INSPECTION_DUE,
@@ -172,4 +363,11 @@ export const AUTOPILOT_TEXTS: SuggestionTextRegister = {
VARROA_MEASURE_DUE,
VARROA_WINTER_MEASURE,
VARROA_RECHECK_DUE,
SUMMER_TREATMENT_DUE,
AUTUMN_FEED_MISSING,
FEED_LOW,
HONEY_SUPER_CHECK,
QUEEN_OLD,
WINTER_CHECK_AUTUMN_MISSING,
WINTER_CHECK_SPRING_MISSING,
};
@@ -0,0 +1,892 @@
import { Logger } from '@nestjs/common';
import type { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import * as claims from '../common/outbox/outbox-claims';
import type { NotificationsService } from '../notifications/notifications.service';
import type { PrismaService } from '../prisma/prisma.service';
import * as rowLock from '../prisma/row-lock';
import { TelegramApiError } from './telegram-api-error';
import type { TelegramClient } from './telegram-client';
import {
TELEGRAM_BATCH_BUDGET_MS,
TELEGRAM_CHAT_SPACING_MS,
TELEGRAM_MAX_PER_SECOND,
TELEGRAM_SEND_BATCH,
TELEGRAM_SEND_POLL_MS,
TELEGRAM_TOKEN_INVALID_PAUSE_MS,
TelegramSender,
} from './telegram-sender';
import { TELEGRAM_TEMPLATES } from './telegram-texts';
jest.mock('../common/outbox/outbox-claims');
jest.mock('../prisma/row-lock');
const claimBatch = jest.mocked(claims.claimBatch);
const deferClaim = jest.mocked(claims.deferClaim);
const settleSkipped = jest.mocked(claims.settleSkipped);
const settleSuccess = jest.mocked(claims.settleSuccess);
const settleFailure = jest.mocked(claims.settleFailure);
const releaseStaleClaimRows = jest.mocked(claims.releaseStaleClaimRows);
const lockRowForUpdate = jest.mocked(rowLock.lockRowForUpdate);
const CLAIMED_AT = new Date('2026-10-14T05:00:00.000Z');
const CHAT = 4_711_000_001n;
const ORIGIN = 'https://app.example.org';
interface Row {
id: string;
userId: string | null;
chatId: bigint | null;
notificationId: string | null;
templateCode: string | null;
attempts: number;
claimedAt: Date;
}
const delivery = (id: string, userId = `u-${id}`, attempts = 0): Row => ({
id,
userId,
chatId: null,
notificationId: `n-${id}`,
templateCode: null,
attempts,
claimedAt: CLAIMED_AT,
});
const template = (id: string, code: string, chatId = CHAT): Row => ({
id,
userId: null,
chatId,
notificationId: null,
templateCode: code,
attempts: 0,
claimedAt: CLAIMED_AT,
});
const context = (overrides: Record<string, unknown> = {}) => ({
userActive: true,
telegramEnabled: true,
categories: ['TASK_DUE', 'DIGEST'],
detail: true,
chatId: CHAT,
category: 'TASK_DUE',
message: 'Heute fällig: Futter bestellen',
link: '/tasks',
requiredPermissions: ['tasks:read'],
stale: false,
taskOpen: true,
...overrides,
});
describe('TelegramSender', () => {
let prisma: { $queryRaw: jest.Mock; $transaction: jest.Mock };
let tx: {
telegramLink: { deleteMany: jest.Mock };
notificationPreference: { updateMany: jest.Mock };
};
let client: {
sendMessage: jest.Mock<Promise<void>, [bigint, string, AbortSignal?]>;
};
let permissions: {
getFreshPermissions: jest.Mock;
getPermissions: jest.Mock;
};
let notifications: { record: jest.Mock; recordForPermission: jest.Mock };
let sender: TelegramSender;
let signal: AbortSignal;
let logError: jest.SpyInstance;
/** Queue the claim and the answers of the send-time check, in order. */
function claim(rows: Row[], ...contexts: (object | null)[]): void {
claimBatch.mockResolvedValueOnce(rows);
for (const answer of contexts) {
prisma.$queryRaw.mockResolvedValueOnce(answer === null ? [] : [answer]);
}
}
beforeEach(() => {
jest.resetAllMocks();
process.env.FRONTEND_ORIGIN = ORIGIN;
prisma = {
$queryRaw: jest.fn(),
$transaction: jest.fn(),
};
tx = {
telegramLink: { deleteMany: jest.fn().mockResolvedValue({ count: 1 }) },
notificationPreference: {
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
};
prisma.$transaction.mockImplementation((work: (t: unknown) => unknown) =>
Promise.resolve(work(tx)),
);
client = {
sendMessage: jest
.fn<Promise<void>, [bigint, string, AbortSignal?]>()
.mockResolvedValue(undefined),
};
permissions = {
getFreshPermissions: jest.fn().mockResolvedValue(new Set(['tasks:read'])),
getPermissions: jest.fn(),
};
notifications = {
record: jest.fn().mockResolvedValue({ id: 'n' }),
recordForPermission: jest.fn().mockResolvedValue([]),
};
claimBatch.mockResolvedValue([]);
deferClaim.mockResolvedValue(true);
settleSkipped.mockResolvedValue(true);
settleSuccess.mockResolvedValue(true);
settleFailure.mockResolvedValue('retry');
releaseStaleClaimRows.mockResolvedValue([]);
lockRowForUpdate.mockResolvedValue(true);
signal = new AbortController().signal;
logError = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
sender = new TelegramSender(
prisma as unknown as PrismaService,
client as unknown as TelegramClient,
permissions as unknown as PermissionResolverService,
notifications as unknown as NotificationsService,
);
});
afterEach(() => {
jest.restoreAllMocks();
delete process.env.FRONTEND_ORIGIN;
});
it('exposes the documented limits', () => {
expect(TELEGRAM_SEND_POLL_MS).toBe(5_000);
expect(TELEGRAM_SEND_BATCH).toBe(25);
expect(TELEGRAM_MAX_PER_SECOND).toBe(25);
expect(TELEGRAM_CHAT_SPACING_MS).toBe(1_100);
expect(TELEGRAM_TOKEN_INVALID_PAUSE_MS).toBe(900_000);
});
describe('claiming', () => {
it('claims a batch of 25 oldest first and returns how many it got', async () => {
claim([delivery('d1')], context());
const claimed = await sender.runOnce(signal);
expect(claimed).toBe(1);
expect(claimBatch).toHaveBeenCalledWith(prisma, 'NotificationDelivery', {
limit: 25,
orderBy: 'createdAt',
filter: undefined,
});
});
it('sends only the oldest delivery of a user and puts the rest back for 1.1 s', async () => {
claim([delivery('d1', 'u1'), delivery('d2', 'u1')], context());
await sender.runOnce(signal);
expect(client.sendMessage).toHaveBeenCalledTimes(1);
expect(settleSuccess).toHaveBeenCalledWith(
prisma,
'NotificationDelivery',
'd1',
CLAIMED_AT,
'DELIVERED',
);
expect(deferClaim).toHaveBeenCalledWith(
prisma,
'NotificationDelivery',
'd2',
CLAIMED_AT,
1_100,
);
expect(settleFailure).not.toHaveBeenCalled();
});
it('treats replies to one chat like one target', async () => {
claim([
template('t1', 'LINKED'),
template('t2', 'HELP'),
template('t3', 'STOPPED', 5n),
]);
await sender.runOnce(signal);
expect(client.sendMessage.mock.calls.map((call) => call[0])).toEqual([
CHAT,
5n,
]);
expect(deferClaim).toHaveBeenCalledWith(
prisma,
'NotificationDelivery',
't2',
CLAIMED_AT,
1_100,
);
});
it('limits the claim to given users', async () => {
claim([]);
await sender.runOnce(signal, { userIds: ['u1', 'u2'] });
const options = claimBatch.mock.calls[0][2];
expect(options.filter).toBeDefined();
});
it('claims nothing once the signal is aborted', async () => {
const controller = new AbortController();
controller.abort();
expect(await sender.runOnce(controller.signal)).toBe(0);
expect(claimBatch).not.toHaveBeenCalled();
});
it('puts claimed deliveries back without an attempt when the signal aborts mid-batch', async () => {
const controller = new AbortController();
claim([delivery('d1'), delivery('d2')], context());
client.sendMessage.mockImplementation(() => {
controller.abort();
return Promise.resolve();
});
await sender.runOnce(controller.signal);
expect(settleSuccess).toHaveBeenCalledTimes(1);
expect(deferClaim).toHaveBeenCalledWith(
prisma,
'NotificationDelivery',
'd2',
CLAIMED_AT,
0,
);
});
});
describe('checks at send time', () => {
it.each([
['user inactive', { userActive: false }],
['telegram disabled', { telegramEnabled: false }],
['not linked', { chatId: null }],
['category off', { categories: ['DIGEST'] }],
['stale', { stale: true }],
['task already done', { taskOpen: false }],
])('skips with "%s"', async (reason, overrides) => {
claim([delivery('d1')], context(overrides));
await sender.runOnce(signal);
expect(client.sendMessage).not.toHaveBeenCalled();
expect(settleSkipped).toHaveBeenCalledWith(
prisma,
'NotificationDelivery',
'd1',
CLAIMED_AT,
reason,
);
});
it('checks permissions and the link at send time and skips with a reason', async () => {
permissions.getFreshPermissions.mockResolvedValue(
new Set(['colonies:read']),
);
claim(
[delivery('d1', 'u1')],
context({ requiredPermissions: ['tasks:read', 'colonies:read'] }),
);
await sender.runOnce(signal);
expect(permissions.getFreshPermissions).toHaveBeenCalledWith('u1');
expect(client.sendMessage).not.toHaveBeenCalled();
expect(settleSkipped).toHaveBeenCalledWith(
prisma,
'NotificationDelivery',
'd1',
CLAIMED_AT,
'permission revoked',
);
});
it('the permission check reads fresh, not the 30 s cache', async () => {
claim([delivery('d1')], context());
await sender.runOnce(signal);
expect(permissions.getFreshPermissions).toHaveBeenCalledTimes(1);
expect(permissions.getPermissions).not.toHaveBeenCalled();
});
it('a stale DIGEST from yesterday is SKIPPED', async () => {
claim(
[delivery('d1')],
context({ category: 'DIGEST', requiredPermissions: [], stale: true }),
);
await sender.runOnce(signal);
expect(client.sendMessage).not.toHaveBeenCalled();
expect(settleSkipped).toHaveBeenCalledWith(
prisma,
'NotificationDelivery',
'd1',
CLAIMED_AT,
'stale',
);
});
it('asks the database once for the staleness: Berlin day for TASK_DUE and DIGEST, 24 hours for the rest', async () => {
claim([delivery('d1')], context());
await sender.runOnce(signal);
expect(prisma.$queryRaw).toHaveBeenCalledTimes(1);
const [query] = prisma.$queryRaw.mock.calls[0] as [{ sql: string }];
expect(query.sql).toContain("'TASK_DUE', 'DIGEST'");
expect(query.sql).toContain("AT TIME ZONE 'Europe/Berlin'");
expect(query.sql).toContain("interval '24 hours'");
expect(query.sql).toContain('"Task"');
});
it('skips when the notification is gone', async () => {
claim([delivery('d1')], null);
await sender.runOnce(signal);
expect(settleSkipped).toHaveBeenCalledWith(
prisma,
'NotificationDelivery',
'd1',
CLAIMED_AT,
'notification gone',
);
});
it('answers a bot reply without any of these checks', async () => {
claim([template('t1', 'LINK_INVALID')]);
await sender.runOnce(signal);
expect(prisma.$queryRaw).not.toHaveBeenCalled();
expect(permissions.getFreshPermissions).not.toHaveBeenCalled();
expect(client.sendMessage).toHaveBeenCalledWith(
CHAT,
TELEGRAM_TEMPLATES.LINK_INVALID,
signal,
);
expect(settleSuccess).toHaveBeenCalledWith(
prisma,
'NotificationDelivery',
't1',
CLAIMED_AT,
'DELIVERED',
);
});
it('skips a reply with an unknown template code', async () => {
claim([template('t1', 'NOPE')]);
await sender.runOnce(signal);
expect(client.sendMessage).not.toHaveBeenCalled();
expect(settleSkipped).toHaveBeenCalledWith(
prisma,
'NotificationDelivery',
't1',
CLAIMED_AT,
'unknown template',
);
});
});
describe('text', () => {
it('sends plain text of at most 600 characters with the app link', async () => {
claim([delivery('d1')], context({ message: 'x'.repeat(2_000) }));
await sender.runOnce(signal);
const [chatId, text] = client.sendMessage.mock.calls[0] as [
bigint,
string,
AbortSignal,
];
expect(chatId).toBe(CHAT);
expect([...text].length).toBeLessThanOrEqual(600);
expect(text.endsWith(`\n\n${ORIGIN}/tasks`)).toBe(true);
});
it('sends only a hint and the link when the user turned details off', async () => {
claim([delivery('d1')], context({ detail: false }));
await sender.runOnce(signal);
expect(client.sendMessage.mock.calls[0][1]).toBe(
`Neue Meldung in ApiCore.\n\n${ORIGIN}/tasks`,
);
});
});
describe('answers of Telegram', () => {
it('200 settles as DELIVERED', async () => {
claim([delivery('d1')], context());
await sender.runOnce(signal);
expect(settleSuccess).toHaveBeenCalledWith(
prisma,
'NotificationDelivery',
'd1',
CLAIMED_AT,
'DELIVERED',
);
});
it('429 defers by retry_after without counting an attempt', async () => {
claim([delivery('d1', 'u1', 2)], context());
client.sendMessage.mockRejectedValue(
new TelegramApiError('sendMessage', 429, 'Too Many Requests', 17),
);
await sender.runOnce(signal);
expect(deferClaim).toHaveBeenCalledWith(
prisma,
'NotificationDelivery',
'd1',
CLAIMED_AT,
17_000,
);
expect(settleFailure).not.toHaveBeenCalled();
expect(settleSkipped).not.toHaveBeenCalled();
});
it('403 removes the link, switches Telegram off and notifies the user in the app', async () => {
claim([delivery('d1', 'u1')], context());
client.sendMessage.mockRejectedValue(
new TelegramApiError(
'sendMessage',
403,
'Forbidden: bot was blocked',
null,
),
);
await sender.runOnce(signal);
expect(lockRowForUpdate).toHaveBeenCalledWith(
tx,
'NotificationPreference',
'u1',
);
expect(tx.telegramLink.deleteMany).toHaveBeenCalledWith({
where: { userId: 'u1', chatId: CHAT },
});
expect(tx.notificationPreference.updateMany).toHaveBeenCalledWith({
where: { userId: 'u1' },
data: { telegramEnabled: false },
});
expect(notifications.record).toHaveBeenCalledWith(
expect.objectContaining({
userId: 'u1',
category: 'SYSTEM',
message:
'Telegram-Zustellung beendet. Der Bot wurde blockiert oder der Chat gelöscht.',
}),
tx,
);
expect(settleSkipped).toHaveBeenCalledWith(
tx,
'NotificationDelivery',
'd1',
CLAIMED_AT,
'chat unavailable',
);
});
it('400 "chat not found" ends the link like a 403', async () => {
claim([delivery('d1', 'u1')], context());
client.sendMessage.mockRejectedValue(
new TelegramApiError(
'sendMessage',
400,
'Bad Request: chat not found',
null,
),
);
await sender.runOnce(signal);
expect(tx.telegramLink.deleteMany).toHaveBeenCalledTimes(1);
expect(settleSkipped).toHaveBeenCalledWith(
tx,
'NotificationDelivery',
'd1',
CLAIMED_AT,
'chat unavailable',
);
});
it('any other 400 is a failed attempt, not the end of the link', async () => {
claim([delivery('d1', 'u1', 1)], context());
client.sendMessage.mockRejectedValue(
new TelegramApiError(
'sendMessage',
400,
'Bad Request: message is too long',
null,
),
);
await sender.runOnce(signal);
expect(tx.telegramLink.deleteMany).not.toHaveBeenCalled();
expect(settleFailure).toHaveBeenCalledWith(
prisma,
'NotificationDelivery',
'd1',
CLAIMED_AT,
1,
'Telegram sendMessage failed: Bad Request: message is too long',
);
});
it('a 403 for an old chat does not remove a newer link of the same user', async () => {
tx.telegramLink.deleteMany.mockResolvedValue({ count: 0 });
claim([delivery('d1', 'u1')], context());
client.sendMessage.mockRejectedValue(
new TelegramApiError('sendMessage', 403, 'Forbidden', null),
);
await sender.runOnce(signal);
expect(tx.notificationPreference.updateMany).not.toHaveBeenCalled();
expect(notifications.record).not.toHaveBeenCalled();
expect(settleSkipped).toHaveBeenCalledWith(
tx,
'NotificationDelivery',
'd1',
CLAIMED_AT,
'chat unavailable',
);
});
it('a 403 for a bot reply only skips it', async () => {
claim([template('t1', 'LINKED')]);
client.sendMessage.mockRejectedValue(
new TelegramApiError('sendMessage', 403, 'Forbidden', null),
);
await sender.runOnce(signal);
expect(prisma.$transaction).not.toHaveBeenCalled();
expect(settleSkipped).toHaveBeenCalledWith(
prisma,
'NotificationDelivery',
't1',
CLAIMED_AT,
'chat unavailable',
);
});
it.each([
['5xx', new TelegramApiError('sendMessage', 502, 'Bad Gateway', null)],
['timeout', new TelegramApiError('sendMessage', null, 'timeout', null)],
[
'network error',
new TelegramApiError('sendMessage', null, 'network error', null),
],
])('%s is a failed attempt under the retry rule', async (_name, error) => {
claim([delivery('d1', 'u1', 3)], context());
client.sendMessage.mockRejectedValue(error);
await sender.runOnce(signal);
expect(settleFailure).toHaveBeenCalledWith(
prisma,
'NotificationDelivery',
'd1',
CLAIMED_AT,
3,
error.message,
);
expect(settleSuccess).not.toHaveBeenCalled();
});
it('settles an unexpected error as a failed attempt and goes on with the batch', async () => {
prisma.$queryRaw.mockRejectedValueOnce(new Error('connection reset'));
claim([delivery('d1'), delivery('d2')], context());
await sender.runOnce(signal);
expect(settleFailure).toHaveBeenCalledWith(
prisma,
'NotificationDelivery',
'd1',
CLAIMED_AT,
0,
'connection reset',
);
expect(client.sendMessage).toHaveBeenCalledTimes(1);
});
it('logs a failed settle instead of throwing', async () => {
claim([delivery('d1')], context());
client.sendMessage.mockRejectedValue(
new TelegramApiError('sendMessage', 500, 'oops', null),
);
settleFailure.mockRejectedValue(new Error('db down'));
await expect(sender.runOnce(signal)).resolves.toBe(1);
expect(logError).toHaveBeenCalledWith(
expect.stringContaining('d1 not settled: db down'),
);
});
});
describe('invalid token', () => {
const unauthorized = () =>
new TelegramApiError('sendMessage', 401, 'Unauthorized', null);
it('401 keeps the delivery pending, pauses 15 min and notifies system:manage once a day', async () => {
claim([delivery('d1'), delivery('d2')], context());
client.sendMessage.mockRejectedValue(unauthorized());
await sender.runOnce(signal);
expect(deferClaim).toHaveBeenCalledWith(
prisma,
'NotificationDelivery',
'd1',
CLAIMED_AT,
900_000,
);
expect(settleFailure).not.toHaveBeenCalled();
expect(settleSkipped).not.toHaveBeenCalled();
expect(notifications.recordForPermission).toHaveBeenCalledWith(
'system:manage',
expect.objectContaining({
category: 'SYSTEM',
priority: 'WARNUNG',
message: 'Telegram: Bot-Token ungültig.',
link: '/settings/system',
dedupeKeyPrefix: expect.stringMatching(
/^telegram-token-invalid:\d{4}-\d{2}-\d{2}$/,
) as string,
}),
);
// The second delivery of the batch is not tried with the bad token.
expect(client.sendMessage).toHaveBeenCalledTimes(1);
expect(deferClaim).toHaveBeenCalledWith(
prisma,
'NotificationDelivery',
'd2',
CLAIMED_AT,
expect.any(Number),
);
});
it('claims nothing while paused', async () => {
claim([delivery('d1')], context());
client.sendMessage.mockRejectedValue(unauthorized());
await sender.runOnce(signal);
claimBatch.mockClear();
expect(await sender.runOnce(signal)).toBe(0);
expect(await sender.drain(signal)).toBe(0);
expect(claimBatch).not.toHaveBeenCalled();
});
it('does not let a failing notice break the settle', async () => {
claim([delivery('d1')], context());
client.sendMessage.mockRejectedValue(unauthorized());
notifications.recordForPermission.mockRejectedValue(new Error('db down'));
await expect(sender.runOnce(signal)).resolves.toBe(1);
expect(deferClaim).toHaveBeenCalledTimes(1);
});
it('never puts the token or a chat id into the log', async () => {
claim([delivery('d1')], context());
client.sendMessage.mockRejectedValue(unauthorized());
await sender.runOnce(signal);
const logged = JSON.stringify(logError.mock.calls);
expect(logged).not.toContain(String(CHAT));
});
});
describe('rates', () => {
it('keeps 1.1 s between two messages to the same chat', async () => {
claim([delivery('d1', 'u1')], context());
await sender.runOnce(signal);
deferClaim.mockClear();
claim([delivery('d2', 'u1')], context());
await sender.runOnce(signal);
expect(client.sendMessage).toHaveBeenCalledTimes(1);
const [, , id, , delay] = deferClaim.mock.calls[0];
expect(id).toBe('d2');
expect(delay).toBeGreaterThan(0);
expect(delay).toBeLessThanOrEqual(1_100);
});
it('sends at most 25 messages per second over all chats', async () => {
const rows = Array.from({ length: 4 }, (_, i) => delivery(`d${i}`));
claim(rows, ...rows.map((_, i) => context({ chatId: BigInt(i + 1) })));
const started = performance.now();
await sender.runOnce(signal);
// Four messages need three gaps of 40 ms.
expect(performance.now() - started).toBeGreaterThanOrEqual(100);
expect(client.sendMessage).toHaveBeenCalledTimes(4);
});
it('drain waits the chat spacing after a batch that held messages back, then sends the next', async () => {
claim([delivery('d1', 'u1'), delivery('d2', 'u1')], context());
claim([delivery('d2', 'u1')], context());
jest.useFakeTimers();
try {
const done = sender.drain(signal);
await jest.advanceTimersByTimeAsync(2_000);
await expect(done).resolves.toBe(3);
} finally {
jest.useRealTimers();
}
});
it('drain stops after maxRounds', async () => {
claimBatch.mockResolvedValue([template('t', 'HELP', 1n)]);
jest.useFakeTimers();
try {
const done = sender.drain(signal, 3);
await jest.advanceTimersByTimeAsync(5_000);
await expect(done).resolves.toBe(3);
} finally {
jest.useRealTimers();
}
expect(claimBatch).toHaveBeenCalledTimes(3);
});
});
describe('releaseStaleClaims', () => {
it('returns how many claims were released and logs the FAILED ones', async () => {
releaseStaleClaimRows.mockResolvedValue([
{ id: 'a', status: 'PENDING' },
{ id: 'b', status: 'FAILED' },
]);
expect(await sender.releaseStaleClaims()).toBe(2);
expect(releaseStaleClaimRows).toHaveBeenCalledWith(
prisma,
'NotificationDelivery',
);
expect(logError).toHaveBeenCalledWith(expect.stringContaining('b'));
});
});
describe('fix round 1', () => {
it('429 pauses the whole pod for retry_after, not only the row', async () => {
claim([delivery('d1'), delivery('d2')], context());
client.sendMessage.mockRejectedValue(
new TelegramApiError('sendMessage', 429, 'Too Many Requests', 30),
);
await sender.runOnce(signal);
// The second delivery of the batch is put back, not tried.
expect(client.sendMessage).toHaveBeenCalledTimes(1);
expect(deferClaim).toHaveBeenCalledWith(
prisma,
'NotificationDelivery',
'd2',
CLAIMED_AT,
expect.any(Number),
);
claimBatch.mockClear();
expect(await sender.runOnce(signal)).toBe(0);
expect(claimBatch).not.toHaveBeenCalled();
});
it('a batch stops after about 120 s and puts the rest back without an attempt', async () => {
jest.useFakeTimers();
try {
claim(
[delivery('d1'), delivery('d2'), delivery('d3')],
context({ chatId: 1n }),
context({ chatId: 2n }),
context({ chatId: 3n }),
);
client.sendMessage.mockImplementationOnce(async () => {
jest.advanceTimersByTime(TELEGRAM_BATCH_BUDGET_MS + 1);
return Promise.resolve();
});
const done = sender.runOnce(signal);
await jest.advanceTimersByTimeAsync(100);
await done;
expect(client.sendMessage).toHaveBeenCalledTimes(1);
for (const id of ['d2', 'd3']) {
expect(deferClaim).toHaveBeenCalledWith(
prisma,
'NotificationDelivery',
id,
CLAIMED_AT,
0,
);
}
} finally {
jest.useRealTimers();
}
});
describe('missing FRONTEND_ORIGIN', () => {
const environment = process.env.NODE_ENV;
let warn: jest.SpyInstance;
beforeEach(() => {
delete process.env.FRONTEND_ORIGIN;
warn = jest
.spyOn(Logger.prototype, 'warn')
.mockImplementation(() => undefined);
});
afterEach(() => {
(process.env as Record<string, string | undefined>).NODE_ENV =
environment;
});
it('outside development sends no link and warns once', async () => {
(process.env as Record<string, string | undefined>).NODE_ENV =
'production';
claim([delivery('d1')], context());
claim([delivery('d2')], context({ chatId: 77n }));
await sender.runOnce(signal);
await sender.runOnce(signal);
const texts = client.sendMessage.mock.calls.map((call) => call[1]);
expect(texts).toEqual([
'Heute fällig: Futter bestellen',
'Heute fällig: Futter bestellen',
]);
expect(warn).toHaveBeenCalledTimes(1);
});
it('development falls back to localhost', async () => {
(process.env as Record<string, string | undefined>).NODE_ENV =
'development';
claim([delivery('d1')], context());
await sender.runOnce(signal);
expect(client.sendMessage.mock.calls[0][1]).toContain(
'http://localhost:3000/tasks',
);
expect(warn).not.toHaveBeenCalled();
});
});
});
});
@@ -0,0 +1,634 @@
import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '../../generated/prisma';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { berlinToday } from '../common/dates';
import { loggableError } from '../common/loggable-error';
import {
claimBatch,
deferClaim,
releaseStaleClaimRows,
settleFailure,
settleSkipped,
settleSuccess,
} from '../common/outbox/outbox-claims';
import { DRAIN_MAX_ROUNDS } from '../domain-events/domain-event-dispatcher';
import { NotificationsService } from '../notifications/notifications.service';
import { PrismaService } from '../prisma/prisma.service';
import { lockRowForUpdate } from '../prisma/row-lock';
import { TelegramApiError } from './telegram-api-error';
import { TelegramClient } from './telegram-client';
import {
renderTelegramText,
TELEGRAM_TEMPLATES,
TelegramTemplateCode,
} from './telegram-texts';
export const TELEGRAM_SEND_POLL_MS = 5_000;
export const TELEGRAM_SEND_BATCH = 25;
export const TELEGRAM_MAX_PER_SECOND = 25;
export const TELEGRAM_CHAT_SPACING_MS = 1_100;
export const TELEGRAM_TOKEN_INVALID_PAUSE_MS = 900_000;
const TABLE = 'NotificationDelivery';
const MIN_SEND_GAP_MS = Math.ceil(1_000 / TELEGRAM_MAX_PER_SECOND);
const DEFAULT_RETRY_AFTER_SEC = 5;
/** How long the memory of "last message to this chat" is kept. */
const CHAT_MEMORY_MS = 10_000;
const CHAT_GONE_MESSAGE =
'Telegram-Zustellung beendet. Der Bot wurde blockiert oder der Chat gelöscht.';
const TOKEN_INVALID_MESSAGE = 'Telegram: Bot-Token ungültig.';
/** Reasons a delivery is skipped; they land in lastError, so no personal data. */
const SKIP = {
userInactive: 'user inactive',
telegramDisabled: 'telegram disabled',
notLinked: 'not linked',
categoryOff: 'category off',
permissionRevoked: 'permission revoked',
stale: 'stale',
taskDone: 'task already done',
chatUnavailable: 'chat unavailable',
unknownTemplate: 'unknown template',
notificationGone: 'notification gone',
} as const;
/** A claimed NotificationDelivery row, as RETURNING * yields it. */
interface ClaimedDelivery {
id: string;
userId: string | null;
chatId: bigint | null;
notificationId: string | null;
templateCode: string | null;
attempts: number;
claimedAt: Date;
}
/** What the send-time check reads about a delivery, in one query. */
interface DeliveryContext {
userActive: boolean;
telegramEnabled: boolean;
categories: string[];
detail: boolean;
chatId: bigint | null;
category: string;
message: string;
link: string | null;
requiredPermissions: string[];
stale: boolean;
taskOpen: boolean;
}
type Target = { skip: string } | { chatId: bigint; text: string };
/** 'spaced': put back only because the chat got a message a moment ago. */
type Outcome = 'settled' | 'spaced';
interface RoundResult {
claimed: number;
spaced: number;
}
export interface TelegramSendFilter {
/** Only deliveries of these users (e2e tests: no foreign rows). */
userIds?: readonly string[];
}
function claimFilter(filter?: TelegramSendFilter): Prisma.Sql | undefined {
if (filter?.userIds === undefined) {
return undefined;
}
return filter.userIds.length === 0
? Prisma.sql`FALSE`
: Prisma.sql`"userId" IN (${Prisma.join([...filter.userIds])})`;
}
function targetKey(delivery: ClaimedDelivery): string {
return delivery.userId !== null
? `user:${delivery.userId}`
: `chat:${String(delivery.chatId)}`;
}
/** The oldest delivery of each user or chat; the claim is ordered by age. */
function splitOldestPerTarget(claimed: readonly ClaimedDelivery[]): {
oldest: ClaimedDelivery[];
rest: ClaimedDelivery[];
} {
const seen = new Set<string>();
const oldest: ClaimedDelivery[] = [];
const rest: ClaimedDelivery[] = [];
for (const delivery of claimed) {
const key = targetKey(delivery);
if (seen.has(key)) {
rest.push(delivery);
} else {
seen.add(key);
oldest.push(delivery);
}
}
return { oldest, rest };
}
function sleep(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve) => {
if (signal.aborted) {
resolve();
return;
}
const done = (): void => {
clearTimeout(timer);
signal.removeEventListener('abort', done);
resolve();
};
const timer = setTimeout(done, ms);
signal.addEventListener('abort', done, { once: true });
});
}
function isChatGone(error: TelegramApiError): boolean {
return (
error.statusCode === 403 ||
(error.statusCode === 400 && /chat not found/i.test(error.description))
);
}
const DEVELOPMENT_ORIGIN = 'http://localhost:3000';
/** A batch stops after this long; the claim is stale after 5 minutes. */
export const TELEGRAM_BATCH_BUDGET_MS = 120_000;
/**
* Sends the queued Telegram deliveries (Spec 9a.6): claims up to 25 due
* rows, takes the oldest of each user or chat, checks again at send time
* that the message may still go out, and settles each row. Bot replies
* (templateCode) skip the checks: they answer a person in a chat.
* Run by the worker's telegram-send loop. ASSUMPTION: one worker replica
* (the worker deployment has replicas: 1). The per-chat spacing, the global
* rate limit and the pause after a 401 or 429 live in this pod's memory; with
* several pods each would keep its own (the one-delivery-per-target rule in
* SQL and Telegram's 429 answers would still protect the bot). Never logs the token, a chat id
* or a message text.
*/
@Injectable()
export class TelegramSender {
private readonly logger = new Logger(TelegramSender.name);
private pausedUntil = 0;
private warnedMissingOrigin = false;
private lastSendAt = 0;
private readonly lastSendToChat = new Map<string, number>();
constructor(
private readonly prisma: PrismaService,
private readonly client: TelegramClient,
private readonly permissions: PermissionResolverService,
private readonly notifications: NotificationsService,
) {}
/** One batch. Returns how many deliveries it claimed. */
async runOnce(
signal: AbortSignal,
filter?: TelegramSendFilter,
): Promise<number> {
return (await this.round(signal, filter)).claimed;
}
/**
* Runs batches until one claims nothing, at most `maxRounds`. After a
* batch that put messages back for the chat spacing it waits that
* spacing, so a burst for one person does not wait for the next poll.
*/
async drain(
signal: AbortSignal,
maxRounds = DRAIN_MAX_ROUNDS,
filter?: TelegramSendFilter,
): Promise<number> {
let total = 0;
for (let round = 0; round < maxRounds && !signal.aborted; round++) {
const result = await this.round(signal, filter);
if (result.claimed === 0) {
break;
}
total += result.claimed;
if (result.spaced > 0) {
await sleep(TELEGRAM_CHAT_SPACING_MS, signal);
}
}
return total;
}
/** Puts deliveries whose worker died back in line (or to FAILED). */
async releaseStaleClaims(): Promise<number> {
const released = await releaseStaleClaimRows(this.prisma, TABLE);
const failed = released
.filter((claim) => claim.status === 'FAILED')
.map((claim) => claim.id);
if (failed.length > 0) {
this.logger.error(
`Telegram deliveries FAILED, an expired claim used up their attempts: ${failed.join(', ')}`,
);
}
return released.length;
}
private async round(
signal: AbortSignal,
filter?: TelegramSendFilter,
): Promise<RoundResult> {
if (signal.aborted || this.remainingPauseMs() > 0) {
return { claimed: 0, spaced: 0 };
}
const claimed = await claimBatch<ClaimedDelivery>(this.prisma, TABLE, {
limit: TELEGRAM_SEND_BATCH,
orderBy: 'createdAt',
filter: claimFilter(filter),
});
const { oldest, rest } = splitOldestPerTarget(claimed);
let spaced = rest.length;
for (const delivery of rest) {
await deferClaim(
this.prisma,
TABLE,
delivery.id,
delivery.claimedAt,
TELEGRAM_CHAT_SPACING_MS,
);
}
const started = performance.now();
for (const delivery of oldest) {
if (performance.now() - started > TELEGRAM_BATCH_BUDGET_MS) {
// Out of time: the claim would go stale. Back without an attempt.
await deferClaim(
this.prisma,
TABLE,
delivery.id,
delivery.claimedAt,
0,
);
continue;
}
const outcome = await this.handle(delivery, signal);
if (outcome === 'spaced') {
spaced++;
}
}
return { claimed: claimed.length, spaced };
}
/** Shutdown and a paused bot put the claim back without an attempt. */
private async handle(
delivery: ClaimedDelivery,
signal: AbortSignal,
): Promise<Outcome> {
const pauseMs = this.remainingPauseMs();
if (signal.aborted || pauseMs > 0) {
await deferClaim(
this.prisma,
TABLE,
delivery.id,
delivery.claimedAt,
pauseMs,
);
return 'settled';
}
try {
return await this.process(delivery, signal);
} catch (error: unknown) {
await this.failSafely(delivery, error);
return 'settled';
}
}
private async process(
delivery: ClaimedDelivery,
signal: AbortSignal,
): Promise<Outcome> {
const target =
delivery.templateCode !== null
? this.templateTarget(delivery)
: await this.notificationTarget(delivery);
if ('skip' in target) {
await settleSkipped(
this.prisma,
TABLE,
delivery.id,
delivery.claimedAt,
target.skip,
);
return 'settled';
}
const waitMs = this.chatWaitMs(target.chatId);
if (waitMs > 0) {
await deferClaim(
this.prisma,
TABLE,
delivery.id,
delivery.claimedAt,
waitMs,
);
return 'spaced';
}
await this.keepRateLimit(signal);
await this.send(delivery, target, signal);
return 'settled';
}
private templateTarget(delivery: ClaimedDelivery): Target {
const code = delivery.templateCode;
if (
code === null ||
delivery.chatId === null ||
!Object.hasOwn(TELEGRAM_TEMPLATES, code)
) {
return { skip: SKIP.unknownTemplate };
}
return {
chatId: delivery.chatId,
text: TELEGRAM_TEMPLATES[code as TelegramTemplateCode],
};
}
/** Spec 9a.6: everything that may have changed since the delivery was queued. */
private async notificationTarget(delivery: ClaimedDelivery): Promise<Target> {
const context = await this.loadContext(delivery);
if (context === null) {
return { skip: SKIP.notificationGone };
}
if (!context.userActive) {
return { skip: SKIP.userInactive };
}
if (!context.telegramEnabled) {
return { skip: SKIP.telegramDisabled };
}
if (context.chatId === null) {
return { skip: SKIP.notLinked };
}
if (!context.categories.includes(context.category)) {
return { skip: SKIP.categoryOff };
}
// Fresh, not the 30 s cache: this process never hears the invalidation
// of the API pods, so a revoked right would keep sending for 30 s.
const held = await this.permissions.getFreshPermissions(
delivery.userId as string,
);
if (!context.requiredPermissions.every((key) => held.has(key))) {
return { skip: SKIP.permissionRevoked };
}
if (context.stale) {
return { skip: SKIP.stale };
}
if (!context.taskOpen) {
return { skip: SKIP.taskDone };
}
return {
chatId: context.chatId,
text: renderTelegramText(context, context.detail, this.frontendOrigin()),
};
}
/**
* One query for user, settings, link, notification, age and task. Stale:
* TASK_DUE and DIGEST only on the Berlin day they were written, every
* other category for 24 hours. "createdAt" is a UTC timestamp without
* zone; AT TIME ZONE 'UTC' makes it an instant first.
*/
private async loadContext(
delivery: ClaimedDelivery,
): Promise<DeliveryContext | null> {
const rows = await this.prisma.$queryRaw<DeliveryContext[]>(Prisma.sql`
SELECT
(u."isActive" AND r."status" = 'ACTIVE') AS "userActive",
COALESCE(p."telegramEnabled", FALSE) AS "telegramEnabled",
COALESCE(p."telegramCategories"::text[], ARRAY[]::text[]) AS "categories",
COALESCE(p."telegramDetail", TRUE) AS "detail",
l."chatId" AS "chatId",
n."category"::text AS "category",
n."message" AS "message",
n."link" AS "link",
n."requiredPermissions" AS "requiredPermissions",
CASE
WHEN n."category" IN ('TASK_DUE', 'DIGEST') THEN
to_char((n."createdAt" AT TIME ZONE 'UTC') AT TIME ZONE 'Europe/Berlin', 'YYYY-MM-DD')
<> to_char(now() AT TIME ZONE 'Europe/Berlin', 'YYYY-MM-DD')
ELSE now() - (n."createdAt" AT TIME ZONE 'UTC') > interval '24 hours'
END AS "stale",
CASE
WHEN n."category" = 'TASK_DUE' AND n."relatedType" = 'Task' AND n."relatedId" IS NOT NULL THEN
COALESCE((SELECT t."status" IN ('OPEN', 'IN_PROGRESS') FROM "Task" t WHERE t."id" = n."relatedId"), FALSE)
ELSE TRUE
END AS "taskOpen"
FROM "NotificationDelivery" d
JOIN "Notification" n ON n."id" = d."notificationId"
JOIN "User" u ON u."id" = d."userId"
JOIN "Role" r ON r."id" = u."roleId"
LEFT JOIN "NotificationPreference" p ON p."userId" = u."id"
LEFT JOIN "TelegramLink" l ON l."userId" = u."id"
WHERE d."id" = ${delivery.id}`);
return rows[0] ?? null;
}
private async send(
delivery: ClaimedDelivery,
target: { chatId: bigint; text: string },
signal: AbortSignal,
): Promise<void> {
try {
await this.client.sendMessage(target.chatId, target.text, signal);
} catch (error: unknown) {
await this.onSendError(delivery, target.chatId, error);
return;
}
this.lastSendToChat.set(String(target.chatId), performance.now());
await settleSuccess(
this.prisma,
TABLE,
delivery.id,
delivery.claimedAt,
'DELIVERED',
);
}
private async onSendError(
delivery: ClaimedDelivery,
chatId: bigint,
error: unknown,
): Promise<void> {
if (!(error instanceof TelegramApiError)) {
await this.failSafely(delivery, error);
return;
}
if (error.statusCode === 429) {
const seconds = error.retryAfterSec ?? DEFAULT_RETRY_AFTER_SEC;
// Telegram limits the whole bot: the pod waits, not only this row.
this.pausedUntil = Math.max(
this.pausedUntil,
performance.now() + seconds * 1_000,
);
await deferClaim(
this.prisma,
TABLE,
delivery.id,
delivery.claimedAt,
seconds * 1_000,
);
return;
}
if (isChatGone(error)) {
await this.endDelivery(delivery, chatId);
return;
}
if (error.statusCode === 401) {
await this.pauseForInvalidToken(delivery);
return;
}
await this.failSafely(delivery, error);
}
/**
* The bot is blocked or the chat is gone: the link ends, but only if it
* still points to this chat - a newer link of the same user to another
* chat stays. Settings, link and message to the user change together.
*/
private async endDelivery(
delivery: ClaimedDelivery,
chatId: bigint,
): Promise<void> {
const userId = delivery.userId;
if (userId === null) {
await settleSkipped(
this.prisma,
TABLE,
delivery.id,
delivery.claimedAt,
SKIP.chatUnavailable,
);
return;
}
await this.prisma.$transaction(async (tx) => {
await lockRowForUpdate(tx, 'NotificationPreference', userId);
const { count } = await tx.telegramLink.deleteMany({
where: { userId, chatId },
});
if (count === 1) {
await tx.notificationPreference.updateMany({
where: { userId },
data: { telegramEnabled: false },
});
await this.notifications.record(
{
userId,
priority: 'HINWEIS',
category: 'SYSTEM',
message: CHAT_GONE_MESSAGE,
},
tx,
);
}
await settleSkipped(
tx,
TABLE,
delivery.id,
delivery.claimedAt,
SKIP.chatUnavailable,
);
});
}
/** The token is rejected: nothing can go out, so the whole loop waits. */
private async pauseForInvalidToken(delivery: ClaimedDelivery): Promise<void> {
this.pausedUntil = performance.now() + TELEGRAM_TOKEN_INVALID_PAUSE_MS;
await deferClaim(
this.prisma,
TABLE,
delivery.id,
delivery.claimedAt,
TELEGRAM_TOKEN_INVALID_PAUSE_MS,
);
try {
// One per user and day: the dedupe key carries the Berlin day.
await this.notifications.recordForPermission('system:manage', {
category: 'SYSTEM',
priority: 'WARNUNG',
message: TOKEN_INVALID_MESSAGE,
link: '/settings/system',
dedupeKeyPrefix: `telegram-token-invalid:${berlinToday()}`,
});
} catch (error: unknown) {
this.logger.error(
`Telegram token notice not recorded: ${loggableError(error).message}`,
);
}
this.logger.error(
'Telegram rejected the bot token (401); sending paused for 15 minutes',
);
}
/** A failed attempt under the shared retry rule; the error text carries no secret. */
private async failSafely(
delivery: ClaimedDelivery,
error: unknown,
): Promise<void> {
const { message } = loggableError(error);
try {
await settleFailure(
this.prisma,
TABLE,
delivery.id,
delivery.claimedAt,
delivery.attempts,
message,
);
} catch (settleError: unknown) {
this.logger.error(
`Telegram delivery ${delivery.id} not settled: ${loggableError(settleError).message}`,
);
}
}
/**
* Only development may fall back to localhost. Elsewhere a missing origin
* (the startup check normally prevents it) sends the text without a link.
*/
private frontendOrigin(): string | null {
const configured = process.env.FRONTEND_ORIGIN;
if (configured) {
return configured;
}
const environment = process.env.NODE_ENV;
if (environment === undefined || environment === 'development') {
return DEVELOPMENT_ORIGIN;
}
if (!this.warnedMissingOrigin) {
this.warnedMissingOrigin = true;
this.logger.warn(
'FRONTEND_ORIGIN is not set: Telegram messages go out without a link',
);
}
return null;
}
private remainingPauseMs(): number {
return Math.max(0, Math.ceil(this.pausedUntil - performance.now()));
}
/** Milliseconds until the chat may get its next message; 0: now. */
private chatWaitMs(chatId: bigint): number {
const now = performance.now();
for (const [chat, sentAt] of this.lastSendToChat) {
if (now - sentAt > CHAT_MEMORY_MS) {
this.lastSendToChat.delete(chat);
}
}
const sentAt = this.lastSendToChat.get(String(chatId));
return sentAt === undefined
? 0
: Math.max(0, Math.ceil(TELEGRAM_CHAT_SPACING_MS - (now - sentAt)));
}
/** At most TELEGRAM_MAX_PER_SECOND messages per second over all chats. */
private async keepRateLimit(signal: AbortSignal): Promise<void> {
const waitMs = MIN_SEND_GAP_MS - (performance.now() - this.lastSendAt);
if (waitMs > 0) {
await sleep(waitMs, signal);
}
this.lastSendAt = performance.now();
}
}
@@ -0,0 +1,105 @@
import {
renderTelegramText,
TELEGRAM_TEMPLATES,
TelegramTemplateCode,
} from './telegram-texts';
const ORIGIN = 'https://app.example.org';
describe('TELEGRAM_TEMPLATES', () => {
it('has the exact texts of the bot replies', () => {
expect(TELEGRAM_TEMPLATES).toEqual({
LINKED:
'Verbunden mit ApiCore. Hier kommen deine Erinnerungen. /stop beendet das.',
LINK_INVALID:
'Der Link ist ungültig oder abgelaufen. Bitte in ApiCore neu verbinden.',
STOPPED: 'Abgemeldet. Du bekommst keine Nachrichten mehr.',
HELP: 'Dieser Bot schickt Erinnerungen aus ApiCore. Verbinden geht nur in ApiCore unter Profil > Benachrichtigungen. /stop beendet.',
} satisfies Record<TelegramTemplateCode, string>);
});
});
describe('renderTelegramText', () => {
it('sends the message, a blank line and the app link', () => {
const text = renderTelegramText(
{ message: 'Heute fällig: Futter bestellen', link: '/tasks' },
true,
ORIGIN,
);
expect(text).toBe(
'Heute fällig: Futter bestellen\n\nhttps://app.example.org/tasks',
);
});
it('falls back to /dashboard without a link', () => {
const text = renderTelegramText({ message: 'm', link: null }, true, ORIGIN);
expect(text).toBe('m\n\nhttps://app.example.org/dashboard');
});
it('without details only says there is a new message, plus the link', () => {
const text = renderTelegramText(
{ message: 'Heute fällig: Schwarmzelle Völker', link: '/tasks' },
false,
ORIGIN,
);
expect(text).toBe(
'Neue Meldung in ApiCore.\n\nhttps://app.example.org/tasks',
);
expect(text).not.toContain('Schwarm');
});
it('tolerates a trailing slash of the origin', () => {
const text = renderTelegramText(
{ message: 'm', link: '/tasks' },
true,
`${ORIGIN}/`,
);
expect(text.endsWith('https://app.example.org/tasks')).toBe(true);
});
it('cuts a long message to at most 600 characters and keeps the link whole', () => {
const text = renderTelegramText(
{ message: 'ä'.repeat(2000), link: '/tasks' },
true,
ORIGIN,
);
expect([...text].length).toBeLessThanOrEqual(600);
expect(text.endsWith('\n\nhttps://app.example.org/tasks')).toBe(true);
expect(text).toContain('…\n\n');
});
it('counts code points, not UTF-16 units', () => {
const text = renderTelegramText(
{ message: '🐝'.repeat(1000), link: '/tasks' },
true,
ORIGIN,
);
expect([...text].length).toBeLessThanOrEqual(600);
expect(text).not.toMatch(/[\ud800-\udbff](?![\udc00-\udfff])/);
});
it('removes control characters and invisible characters from the message', () => {
const text = renderTelegramText(
{ message: 'a\u0007b‮c​d\r\ne', link: '/tasks' },
true,
ORIGIN,
);
expect(text).toBe('abcd\ne\n\nhttps://app.example.org/tasks');
});
it('without an origin sends the text alone, no link', () => {
expect(
renderTelegramText({ message: 'Hallo', link: '/tasks' }, true, null),
).toBe('Hallo');
expect(
renderTelegramText({ message: 'Hallo', link: '/tasks' }, false, null),
).toBe('Neue Meldung in ApiCore.');
});
});
@@ -0,0 +1,51 @@
import {
sanitizeTelegramText,
TELEGRAM_MAX_MESSAGE_CHARS,
} from './telegram-client';
export type TelegramTemplateCode =
'LINKED' | 'LINK_INVALID' | 'STOPPED' | 'HELP';
/** The replies of the bot to what a person does in the chat (Spec 9a.5). */
export const TELEGRAM_TEMPLATES: Record<TelegramTemplateCode, string> = {
LINKED:
'Verbunden mit ApiCore. Hier kommen deine Erinnerungen. /stop beendet das.',
LINK_INVALID:
'Der Link ist ungültig oder abgelaufen. Bitte in ApiCore neu verbinden.',
STOPPED: 'Abgemeldet. Du bekommst keine Nachrichten mehr.',
HELP: 'Dieser Bot schickt Erinnerungen aus ApiCore. Verbinden geht nur in ApiCore unter Profil > Benachrichtigungen. /stop beendet.',
};
const DEFAULT_LINK = '/dashboard';
const DETAIL_OFF_TEXT = 'Neue Meldung in ApiCore.';
const ELLIPSIS = '…';
/**
* The plain text of a notification: its message (or, without details, only
* the hint that there is one), a blank line and the app link. At most 600
* characters; a long message is cut, the link stays whole. No parse_mode on
* the wire, so a title cannot inject formatting.
*/
export function renderTelegramText(
notification: { message: string; link: string | null },
detail: boolean,
frontendOrigin: string | null,
): string {
// Without a known origin no link at all: never a localhost link.
const tail =
frontendOrigin === null
? ''
: `\n\n${sanitizeTelegramText(
`${frontendOrigin.replace(/\/+$/, '')}${notification.link ?? DEFAULT_LINK}`,
)}`;
const head = detail
? sanitizeTelegramText(notification.message)
: DETAIL_OFF_TEXT;
const budget = TELEGRAM_MAX_MESSAGE_CHARS - [...tail].length;
const characters = [...head];
if (characters.length <= budget) {
return `${head}${tail}`;
}
const kept = characters.slice(0, Math.max(budget - 1, 0)).join('');
return sanitizeTelegramText(`${kept}${ELLIPSIS}${tail}`);
}
@@ -1,17 +1,24 @@
import { Module } from '@nestjs/common';
import { NotificationsModule } from '../notifications/notifications.module';
import { telegramConfig } from './telegram-config';
import { TELEGRAM_CONFIG, TelegramClient } from './telegram-client';
import { TelegramSender } from './telegram-sender';
/**
* 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.
* and the loops do not run. The sender needs the permission resolver, which
* the global JobPermissionsModule (worker) or PermissionsModule (API) provides:
* this module imports neither, so the permission catalog is never rewritten
* by it.
*/
@Module({
imports: [NotificationsModule],
providers: [
{ provide: TELEGRAM_CONFIG, useFactory: () => telegramConfig() },
TelegramClient,
TelegramSender,
],
exports: [TelegramClient],
exports: [TelegramClient, TelegramSender],
})
export class TelegramModule {}
@@ -33,7 +33,7 @@ describe('WinterChecksService', () => {
$transaction: jest.Mock;
};
let auditLog: { record: jest.Mock };
let events: { recordAll: jest.Mock };
let events: { recordAll: jest.Mock; record: jest.Mock };
beforeEach(() => {
tx = {
@@ -63,7 +63,10 @@ describe('WinterChecksService', () => {
),
};
auditLog = { record: jest.fn() };
events = { recordAll: jest.fn().mockResolvedValue(0) };
events = {
recordAll: jest.fn().mockResolvedValue(0),
record: jest.fn().mockResolvedValue(undefined),
};
service = new WinterChecksService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
@@ -71,6 +74,18 @@ describe('WinterChecksService', () => {
);
});
it('records WinterCheckRecorded with ids only in the same transaction', async () => {
await service.create({ ...lostCheck, markColonyDeceased: false }, 'u1');
expect(events.record).toHaveBeenCalledWith(tx, {
type: 'WinterCheckRecorded',
aggregateType: 'WinterCheck',
aggregateId: 'w1',
actorUserId: 'u1',
payload: { colonyId: 'c1' },
});
});
it('audits a colony marked deceased through the audit service inside the transaction', async () => {
await service.create(lostCheck, 'u1');
@@ -146,6 +146,7 @@ export class WinterChecksService {
},
tx,
);
await this.recordWinterCheck(tx, created, actingUserId);
return created;
}),
);
@@ -196,12 +197,35 @@ export class WinterChecksService {
},
tx,
);
await this.recordWinterCheck(tx, updated, actingUserId);
if (updated.colonyId !== before.colonyId) {
await this.recordWinterCheck(
tx,
{ ...updated, colonyId: before.colonyId },
actingUserId,
);
}
return updated;
}),
);
return check;
}
/** Lets the autopilot see the entry: A9/A10 resolve right after it. */
private recordWinterCheck(
tx: Prisma.TransactionClient,
check: WinterCheck,
actingUserId: string,
): Promise<void> {
return this.events.record(tx, {
type: 'WinterCheckRecorded',
aggregateType: 'WinterCheck',
aggregateId: check.id,
actorUserId: actingUserId,
payload: { colonyId: check.colonyId },
});
}
private assertConsistent(check: WinterCheckShape): void {
const problem = validateWinterCheck(check);
if (problem) {
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
import { TelegramModule } from '../telegram/telegram.module';
import { WeatherHistoryModule } from '../weather/history/weather-history.module';
import { WorkerRuntime } from './worker-runtime.service';
@@ -7,10 +8,11 @@ import { WorkerRuntime } from './worker-runtime.service';
* 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; the weather
* source comes with WeatherHistoryModule.
* source comes with WeatherHistoryModule and the Telegram client and sender
* with TelegramModule.
*/
@Module({
imports: [WeatherHistoryModule],
imports: [WeatherHistoryModule, TelegramModule],
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 { TelegramClient } from '../telegram/telegram-client';
import {
TELEGRAM_SEND_POLL_MS,
type TelegramSender,
} from '../telegram/telegram-sender';
import type { WeatherHistoryService } from '../weather/history/weather-history.service';
import {
WEATHER_FIRST_TICK_DELAY_MS,
@@ -59,6 +64,11 @@ class FakeWebhookDeliveries {
drain = jest.fn<Promise<number>, unknown[]>().mockResolvedValue(0);
}
class FakeTelegramSender {
releaseStaleClaims = jest.fn<Promise<number>, []>().mockResolvedValue(0);
drain = jest.fn<Promise<number>, unknown[]>().mockResolvedValue(0);
}
describe('WorkerRuntime', () => {
const ListenerMock = PgListener as unknown as jest.Mock;
const originalDatabaseUrl = process.env.DATABASE_URL;
@@ -70,6 +80,8 @@ describe('WorkerRuntime', () => {
};
let webhookDeliveries: FakeWebhookDeliveries;
let weatherHistory: { syncOnce: jest.Mock };
let telegramConfigured: boolean;
let telegramSender: FakeTelegramSender;
let runtime: WorkerRuntime;
let logError: jest.SpyInstance;
@@ -108,14 +120,22 @@ describe('WorkerRuntime', () => {
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
runtime = new WorkerRuntime(
telegramConfigured = false;
telegramSender = new FakeTelegramSender();
runtime = createRuntime();
});
function createRuntime(): WorkerRuntime {
return new WorkerRuntime(
prisma as unknown as PrismaService,
dispatcher as unknown as DomainEventDispatcher,
calendarSync as unknown as CalendarSyncService,
webhookDeliveries as unknown as WebhookDeliveryWorker,
weatherHistory as unknown as WeatherHistoryService,
{ isConfigured: () => telegramConfigured } as unknown as TelegramClient,
telegramSender as unknown as TelegramSender,
);
});
}
afterEach(async () => {
await runtime.onModuleDestroy();
@@ -183,6 +203,72 @@ describe('WorkerRuntime', () => {
expect(webhookReleaseOrder).toBeLessThan(webhookDrainOrder);
});
describe('telegram-send loop', () => {
it('is not registered without a bot token', async () => {
await bootstrap();
await jest.advanceTimersByTimeAsync(TELEGRAM_SEND_POLL_MS * 2);
expect(telegramSender.drain).not.toHaveBeenCalled();
expect(listenerOptions().channels).not.toContain('notification_delivery');
});
describe('with a bot token', () => {
beforeEach(async () => {
await runtime.onModuleDestroy();
telegramConfigured = true;
runtime = createRuntime();
});
it('listens on notification_delivery', async () => {
await runtime.onApplicationBootstrap();
expect(listenerOptions().channels).toContain('notification_delivery');
});
it('releases stale claims, then drains every due delivery, stoppable by shutdown', async () => {
await bootstrap();
expect(telegramSender.releaseStaleClaims).toHaveBeenCalledTimes(1);
expect(telegramSender.drain).toHaveBeenCalledWith(
expect.any(AbortSignal),
);
const [releaseOrder] =
telegramSender.releaseStaleClaims.mock.invocationCallOrder;
const [drainOrder] = telegramSender.drain.mock.invocationCallOrder;
expect(releaseOrder).toBeLessThan(drainOrder);
});
it('polls every 5 s', async () => {
await bootstrap();
expect(telegramSender.drain).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(TELEGRAM_SEND_POLL_MS - 1);
expect(telegramSender.drain).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(1);
expect(telegramSender.drain).toHaveBeenCalledTimes(2);
});
it('wakes on a notification_delivery notification', async () => {
await bootstrap();
expect(telegramSender.drain).toHaveBeenCalledTimes(1);
notify('notification_delivery');
await jest.advanceTimersByTimeAsync(0);
expect(telegramSender.drain).toHaveBeenCalledTimes(2);
});
it('aborts the signal at shutdown', async () => {
await bootstrap();
const [signal] = telegramSender.drain.mock.calls[0] as [AbortSignal];
await runtime.onModuleDestroy();
expect(signal.aborted).toBe(true);
});
});
});
it('polls webhook deliveries every 5 s', async () => {
await bootstrap();
expect(webhookDeliveries.drain).toHaveBeenCalledTimes(1);
@@ -14,6 +14,11 @@ import {
DRAIN_MAX_ROUNDS,
} from '../domain-events/domain-event-dispatcher';
import { PrismaService } from '../prisma/prisma.service';
import { TelegramClient } from '../telegram/telegram-client';
import {
TELEGRAM_SEND_POLL_MS,
TelegramSender,
} from '../telegram/telegram-sender';
import {
WEATHER_FIRST_TICK_DELAY_MS,
WEATHER_HISTORY_POLL_MS,
@@ -77,6 +82,8 @@ export class WorkerRuntime implements OnApplicationBootstrap, OnModuleDestroy {
private readonly calendarSync: CalendarSyncService,
private readonly webhookDeliveries: WebhookDeliveryWorker,
private readonly weatherHistory: WeatherHistoryService,
private readonly telegramClient: TelegramClient,
private readonly telegramSender: TelegramSender,
) {
const loops = this.workerLoops().map((definition) => ({
definition,
@@ -119,6 +126,17 @@ export class WorkerRuntime implements OnApplicationBootstrap, OnModuleDestroy {
channel: WAKE_CHANNELS.webhookDeliveries,
tick: () => this.deliverWebhooks(),
},
// Without a bot token the loop is not registered at all.
...(this.telegramClient.isConfigured()
? [
{
name: 'telegram-send',
intervalMs: TELEGRAM_SEND_POLL_MS,
channel: WAKE_CHANNELS.notificationDeliveries,
tick: () => this.sendTelegram(),
},
]
: []),
{
// Daily mean temperatures for the phenology; hourly, no outbox.
name: 'phenology-weather',
@@ -216,6 +234,11 @@ export class WorkerRuntime implements OnApplicationBootstrap, OnModuleDestroy {
}
}
private async sendTelegram(): Promise<void> {
await this.telegramSender.releaseStaleClaims();
await this.telegramSender.drain(this.stopping.signal);
}
private async deliverWebhooks(): Promise<void> {
await this.webhookDeliveries.releaseStaleClaims();
// No filter: every due delivery of the database.
@@ -12,6 +12,7 @@ import { PhenologyModule } from '../phenology/phenology.module';
import { RemindersModule } from '../reminders/reminders.module';
import { SuggestionsModule } from '../suggestions/suggestions.module';
import { TasksModule } from '../tasks/tasks.module';
import { TelegramModule } from '../telegram/telegram.module';
import { WebhooksModule } from '../webhooks/webhooks.module';
import { WithdrawalModule } from '../withdrawal/withdrawal.module';
import { WorkerHealthServer } from './worker-health-server.service';
@@ -41,6 +42,7 @@ import { WorkerRuntimeModule } from './worker-runtime.module';
PhenologyModule,
WithdrawalModule,
RemindersModule,
TelegramModule,
WorkerRuntimeModule,
],
providers: [WorkerHealthServer],