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

@@ -4,11 +4,13 @@ import { Prisma } from '../generated/prisma';
import { AutopilotStep } from '../src/autopilot/autopilot.step';
import { SuggestionsExpiryStep } from '../src/autopilot/suggestions-expiry.step';
import { AutopilotService } from '../src/autopilot/autopilot.service';
import { ComplianceModule } from '../src/compliance/compliance.module';
import { JobsModule } from '../src/jobs/jobs.module';
import { PhenologyStep } from '../src/phenology/phenology.step';
import { TimeEventsStep } from '../src/jobs/steps/time-events.step';
import { PrismaService } from '../src/prisma/prisma.service';
import { JobPartialFailure } from '../src/system/job-run.service';
import { drainDomainEvents } from './utils/domain-events';
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
@@ -18,6 +20,7 @@ const NOTHING_NEW = {
QueenAged: 0,
VarroaMeasurementDue: 0,
InspectionOverdue: 0,
TskReferenceDayReached: 0,
};
/**
@@ -54,7 +57,8 @@ describe('daily-evaluation (e2e)', () => {
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [JobsModule],
// The compliance handlers turn the TSK reference day into a draft.
imports: [JobsModule, ComplianceModule],
}).compile();
app = await moduleRef.init();
prisma = app.get(PrismaService);
@@ -239,6 +243,107 @@ describe('daily-evaluation (e2e)', () => {
expect(await eventsOf(ids['treatment:no-withdrawal'])).toEqual([]);
});
describe('TSK reference day', () => {
let savedReferenceDay: string;
let permittedUsers: number;
beforeEach(async () => {
// The seeded singleton stays: only its reference day is borrowed.
const saved = await prisma.complianceSettings.upsert({
where: { id: 'singleton' },
update: {},
create: { id: 'singleton' },
});
savedReferenceDay = saved.tskReferenceDay;
await prisma.complianceSettings.update({
where: { id: 'singleton' },
data: { tskReferenceDay: '03-03' },
});
permittedUsers = await prisma.user.count({
where: {
isActive: true,
role: {
status: 'ACTIVE',
permissions: { some: { permission: { key: 'compliance:manage' } } },
},
},
});
});
afterEach(async () => {
await prisma.notification.deleteMany({
where: { relatedType: 'ComplianceItem', relatedId: 'tsk-report:2001' },
});
await prisma.authorityNotice.deleteMany({ where: { year: 2001 } });
await prisma.domainEvent.deleteMany({
where: { aggregateId: 'tsk:2001' },
});
await prisma.complianceSettings.update({
where: { id: 'singleton' },
data: { tskReferenceDay: savedReferenceDay },
});
});
async function expectOneDraftAndNotices(): Promise<void> {
expect(await eventsOf('tsk:2001')).toEqual([
{
type: 'TskReferenceDayReached',
aggregateType: 'Compliance',
payload: { year: 2001, referenceDay: '2001-03-03' },
dedupeKey: 'TskReferenceDayReached:tsk:2001:2001-03-03',
actorUserId: null,
},
]);
const drafts = await prisma.authorityNotice.findMany({
where: { kind: 'TSK_REPORT', year: 2001 },
});
expect(drafts).toHaveLength(1);
expect(drafts[0]).toMatchObject({
status: 'DRAFT',
openKey: 'TSK_REPORT:2001',
});
const notifications = await prisma.notification.findMany({
where: { relatedType: 'ComplianceItem', relatedId: 'tsk-report:2001' },
select: { userId: true, priority: true, category: true, message: true },
});
expect(notifications).toHaveLength(permittedUsers);
expect(new Set(notifications.map((row) => row.userId)).size).toBe(
permittedUsers,
);
for (const row of notifications) {
expect(row).toMatchObject({
priority: 'WARNUNG',
category: 'COMPLIANCE',
});
expect(row.message).toMatch(
/^TSK-Stichtag 03\.03\.2001: \d+ Völker gezählt\. Bitte prüfen und melden\.$/,
);
}
}
it('daily evaluation twice on the reference day: one event, one draft', async () => {
const first = await step.run('2001-03-03');
const second = await step.run('2001-03-03');
await drainDomainEvents(app, ['tsk:2001']);
await step.run('2001-03-04');
await drainDomainEvents(app, ['tsk:2001']);
expect(first.TskReferenceDayReached).toBe(1);
expect(second.TskReferenceDayReached).toBe(0);
await expectOneDraftAndNotices();
});
it('catches up a missed reference day on the 5th exactly once', async () => {
const catchUp = await step.run('2001-03-05');
const again = await step.run('2001-03-05');
await drainDomainEvents(app, ['tsk:2001']);
expect(catchUp.TskReferenceDayReached).toBe(1);
expect(again.TskReferenceDayReached).toBe(0);
await expectOneDraftAndNotices();
});
});
it('records due tasks and varroa checks only for open tasks and living colonies', async () => {
await step.run('2001-05-20');