feat: T4b/T4c wave 9 - phenology reports, autopilot A4-A10, honey ledger, Telegram delivery
- 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:
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']);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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()),
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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\u0007bcd\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],
|
||||
|
||||
Reference in new issue
Block a user