feat: T6 wave 4 – notice PDFs, compliance notifications, readiness core
Backend Image / build-and-push (push) Successful in 4m58s
Frontend Image / build-and-push (push) Successful in 7m57s

- Authority notice PDFs: pure model plus renderer, TSK count in A4
  landscape, FEHLT for missing particulars, stored totals with correction
  note; GET /authority-notices/:id/pdf with compliance:read.
- Compliance notifications in category COMPLIANCE: herd change and TSK
  reference day, at most one unread per topic and user (advisory lock);
  daily rule TskReferenceDayReached with Berlin day and catch-up; handlers
  in API and worker.
- Readiness core: live evaluation of seven pure rules, fingerprinted
  acknowledgements that reopen when revoked or stale, subject masking,
  read-only GET paths; READINESS_RULES injectable; kept out of the worker.
- e2e hygiene: no unscoped deletes, seeded settings rows saved/restored.
- ARCHITECTURE.md §12.8–12.10, §10 handler rows, Annahmen 78–85.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-15 14:48:57 +02:00
1 parent edc193f128
commit b912d29166
54 files changed
+5300 -14

No files matched your search

@@ -43,6 +43,11 @@ interface FakeRows {
varroa?: { id: string; colonyId: string; date: Date }[];
/** The latest inspection per colony. */
inspections?: { colonyId: string; date: Date }[];
/**
* The ComplianceSettings reference day (MM-TT); null: no settings row.
* Without a value a day far from the other tests' days is used.
*/
tskReferenceDay?: string | null;
/** Stored rule configs by trigger type; others read as the built-in setting. */
rules?: Record<string, object>;
}
@@ -87,6 +92,20 @@ function fakeDb(rows: FakeRows): FakeDb {
})),
),
},
complianceSettings: {
findUnique: jest.fn().mockResolvedValue(
rows.tskReferenceDay === null
? null
: {
id: 'singleton',
tskReferenceDay: rows.tskReferenceDay ?? '10-10',
},
),
// A missing row is created with the schema default.
upsert: jest
.fn()
.mockResolvedValue({ id: 'singleton', tskReferenceDay: '01-01' }),
},
// No stored phenology: every apiary gets the calendar state.
apiaryPhenology: { findMany: jest.fn().mockResolvedValue([]) },
automationRule: {
@@ -154,9 +173,60 @@ describe('daily evaluation rules', () => {
'QueenAged',
'VarroaMeasurementDue',
'InspectionOverdue',
'TskReferenceDayReached',
]);
});
describe('TskReferenceDayReached', () => {
const only = (events: DailyEvent[]) =>
events.filter((event) => event.type === 'TskReferenceDayReached');
it('emits TskReferenceDayReached on the configured day', async () => {
const { db } = fakeDb({ tskReferenceDay: '03-15' });
expect(only(await eventsOn(db, '2027-03-15'))).toEqual([
{
type: 'TskReferenceDayReached',
aggregateType: 'Compliance',
aggregateId: 'tsk:2027',
payload: { year: 2027, referenceDay: '2027-03-15' },
dedupeKey: 'TskReferenceDayReached:tsk:2027:2027-03-15',
},
]);
expect(only(await eventsOn(db, '2027-03-14'))).toEqual([]);
});
it('catches up within 7 days exactly once', async () => {
const { db } = fakeDb({ tskReferenceDay: '03-15' });
const store = await runDaily(db, daysBetween('2027-03-15', '2027-03-22'));
expect(keysOf(store, 'TskReferenceDayReached')).toEqual([
'TskReferenceDayReached:tsk:2027:2027-03-15',
]);
expect(only(await eventsOn(db, '2027-03-23'))).toEqual([]);
});
it("covers the previous year's day in early January", async () => {
const { db } = fakeDb({ tskReferenceDay: '12-30' });
const events = only(await eventsOn(db, '2027-01-03'));
expect(events.map((event) => event.payload)).toEqual([
{ year: 2026, referenceDay: '2026-12-30' },
]);
});
it('creates the settings row with its default 01-01 when it is missing', async () => {
const { db } = fakeDb({ tskReferenceDay: null });
const events = only(await eventsOn(db, '2027-01-04'));
expect(events.map((event) => event.payload)).toEqual([
{ year: 2027, referenceDay: '2027-01-01' },
]);
});
});
describe('withdrawalEndDay', () => {
it('a 14-day withdrawal of a treatment ending 2026-10-01 ends on 2026-10-15', () =>
expect(
@@ -387,7 +457,8 @@ describe('daily evaluation rules', () => {
});
it('QueenAged fires on the first job day a queen assigned in June is aged, and not a second time in the same year', async () => {
const { db, colonyFindMany } = fakeDb({});
// The TSK day lies outside every window this test runs.
const { db, colonyFindMany } = fakeDb({ tskReferenceDay: '03-03' });
// The queen (hatched 2024) is assigned to the colony on 2026-06-15.
let assigned = false;
colonyFindMany.mockImplementation(
@@ -1,6 +1,8 @@
import { TaskStatus, Treatment } from '../../generated/prisma';
import { readRule } from '../automation/rule-config';
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
import { complianceSettingsIn } from '../compliance/herd-status';
import { tskReferenceDayOf } from '../compliance/tsk-day';
import { addDays, berlinDayOf } from '../common/dates';
import {
AnyNewDomainEvent,
@@ -26,7 +28,8 @@ export type DailyEventType =
| 'TaskDue'
| 'QueenAged'
| 'VarroaMeasurementDue'
| 'InspectionOverdue';
| 'InspectionOverdue'
| 'TskReferenceDayReached';
/** An event as the job records it: always with its daily dedupe key. */
export type DailyEvent<T extends DailyEventType = DailyEventType> =
@@ -381,6 +384,32 @@ const inspectionOverdue: DailyRule<'InspectionOverdue'> = {
},
};
/**
* The TSK reference day (ComplianceSettings.tskReferenceDay, MM-TT) of the
* run's year and of the year before: in early January the previous year's
* day can still lie inside the catch-up window. One event per year.
*/
const tskReferenceDay: DailyRule<'TskReferenceDayReached'> = {
type: 'TskReferenceDayReached',
async occurrences(db, _today, { day }) {
// The seeded singleton; a missing row is created with the schema default.
const { tskReferenceDay: monthDay } = await complianceSettingsIn(db);
const runYear = Number(day.slice(0, 4));
return [runYear - 1, runYear].map((year) => {
const referenceDay = tskReferenceDayOf(year, monthDay);
return {
day: referenceDay,
event: {
type: 'TskReferenceDayReached',
aggregateType: 'Compliance',
aggregateId: `tsk:${year}`,
payload: { year, referenceDay },
},
};
});
},
};
/** The rules of the daily evaluation, one row per time-based event type. */
export const DAILY_EVALUATION_RULES: readonly DailyRule[] = [
withdrawalEnded,
@@ -388,4 +417,5 @@ export const DAILY_EVALUATION_RULES: readonly DailyRule[] = [
queenAged,
varroaDue,
inspectionOverdue,
tskReferenceDay,
];
@@ -50,6 +50,12 @@ describe('TimeEventsStep', () => {
varroaMeasurement: { findMany: jest.fn().mockResolvedValue([]) },
inspection: { groupBy: jest.fn().mockResolvedValue([]) },
automationRule: { findUnique: jest.fn().mockResolvedValue(null) },
// A reference day far from the days of these tests.
complianceSettings: {
findUnique: jest
.fn()
.mockResolvedValue({ id: 'singleton', tskReferenceDay: '10-10' }),
},
} as unknown as PrismaService;
step = new TimeEventsStep(
prisma,
@@ -73,6 +79,7 @@ describe('TimeEventsStep', () => {
QueenAged: 0,
VarroaMeasurementDue: 0,
InspectionOverdue: 0,
TskReferenceDayReached: 0,
});
});
@@ -112,6 +119,7 @@ describe('TimeEventsStep', () => {
QueenAged: 0,
VarroaMeasurementDue: 0,
InspectionOverdue: 0,
TskReferenceDayReached: 0,
});
});
@@ -151,6 +159,7 @@ describe('TimeEventsStep', () => {
QueenAged: 0,
VarroaMeasurementDue: 0,
InspectionOverdue: 0,
TskReferenceDayReached: 0,
},
});
expect(recordAll).toHaveBeenCalledWith(tx, [