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

@@ -330,6 +330,163 @@ describe('NotificationsService', () => {
});
});
describe('recordForPermissionOnce', () => {
const input = {
priority: 'HINWEIS' as const,
category: 'COMPLIANCE' as const,
message: 'Bestand geändert',
relatedType: 'ComplianceItem',
relatedId: 'herd-change',
link: '/compliance',
};
let calls: string[];
let tx: {
$executeRaw: jest.Mock;
user: { findMany: jest.Mock };
notification: { findMany: jest.Mock; createManyAndReturn: jest.Mock };
};
beforeEach(() => {
calls = [];
tx = {
$executeRaw: jest.fn().mockImplementation(() => {
calls.push('lock');
return Promise.resolve(1);
}),
user: {
// Like the database: the id filter narrows the three eligible users.
findMany: jest
.fn()
.mockImplementation(
({ where }: { where: { id?: { in: string[] } } }) =>
Promise.resolve(
['u1', 'u2', 'u3']
.filter((id) => !where.id || where.id.in.includes(id))
.map((id) => ({ id })),
),
),
},
notification: {
findMany: jest.fn().mockImplementation(() => {
calls.push('unread');
return Promise.resolve([{ userId: 'u2' }]);
}),
createManyAndReturn: jest
.fn()
.mockImplementation(({ data }: CreateManyArg) =>
Promise.resolve(data.map((row, i) => ({ id: `n${i}`, ...row }))),
),
},
};
});
it('skips users with an unread notification of the same topic', async () => {
const rows = await service.recordForPermissionOnce(
'compliance:manage',
input,
tx as never,
);
expect(rows.map((row) => (row as { userId: string }).userId)).toEqual([
'u1',
'u3',
]);
expect(tx.notification.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
relatedType: 'ComplianceItem',
relatedId: 'herd-change',
read: false,
}) as unknown,
}),
);
});
it('writes again after the user read the earlier one', async () => {
tx.notification.findMany.mockResolvedValue([]);
const rows = await service.recordForPermissionOnce(
'compliance:manage',
input,
tx as never,
);
expect(rows).toHaveLength(3);
});
it('takes the advisory lock before reading the unread rows', async () => {
await service.recordForPermissionOnce(
'compliance:manage',
input,
tx as never,
);
expect(calls).toEqual(['lock', 'unread']);
});
it('stores no dedupe key and the recipient permission', async () => {
await service.recordForPermissionOnce(
'compliance:manage',
input,
tx as never,
);
const [arg] = tx.notification.createManyAndReturn.mock.calls[0] as [
CreateManyArg,
];
expect(arg.data.map((row) => row.dedupeKey)).toEqual([null, null]);
expect(arg.data[0].requiredPermissions).toEqual(['compliance:manage']);
});
it('plans the Telegram delivery for the new rows only', async () => {
const rows = await service.recordForPermissionOnce(
'compliance:manage',
input,
tx as never,
);
expect(planner.planIn).toHaveBeenCalledTimes(1);
expect(planner.planIn).toHaveBeenCalledWith(tx, rows);
expect(rows).toHaveLength(2);
});
it('writes nothing and plans nothing when everybody has an unread one', async () => {
tx.notification.findMany.mockResolvedValue([
{ userId: 'u1' },
{ userId: 'u2' },
{ userId: 'u3' },
]);
await expect(
service.recordForPermissionOnce(
'compliance:manage',
input,
tx as never,
),
).resolves.toEqual([]);
expect(tx.notification.createManyAndReturn).not.toHaveBeenCalled();
expect(planner.planIn).not.toHaveBeenCalled();
});
it('throws for an empty permission list instead of broadcasting', async () => {
await expect(
service.recordForPermissionOnce([], input, tx as never),
).rejects.toThrow();
});
});
it('recordForPermission is unchanged: it never reads unread rows', async () => {
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }]);
await service.recordForPermission('compliance:manage', {
priority: 'INFO',
category: 'SYSTEM',
message: 'm',
});
expect(prisma.notification.findMany).not.toHaveBeenCalled();
});
describe('recordForUsers', () => {
it('skips users missing a required permission', async () => {
prisma.user.findMany.mockResolvedValue([{ id: 'u2' }]);
@@ -149,6 +149,53 @@ export class NotificationsService {
return this.fanOut(tx ?? this.prisma, keys, input, undefined);
}
/**
* Like recordForPermission, but a user who still has an unread notification
* of the same topic (relatedType + relatedId) gets no second one. There is
* deliberately no dedupe key: the topic comes back once the user has read
* the earlier notice. An advisory lock per topic serialises the check and
* the write of concurrent transactions; it is held until the caller commits.
*/
async recordForPermissionOnce(
permission: PermissionKey | readonly PermissionKey[],
input: Omit<FanOutNotificationInput, 'dedupeKeyPrefix'> & {
relatedType: string;
relatedId: string;
},
tx: Prisma.TransactionClient,
): Promise<Notification[]> {
const recipientKeys =
typeof permission === 'string' ? [permission] : permission;
if (recipientKeys.length === 0) {
throw new Error('recordForPermissionOnce needs at least one permission');
}
const keys = uniqueSorted([
...recipientKeys,
...(input.requiredPermissions ?? []),
]);
const { relatedType, relatedId } = input;
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${`notify-once:${relatedType}:${relatedId}`}, 0))`;
const recipients = await this.recipients(tx, keys, undefined);
if (recipients.length === 0) {
return [];
}
const unread = await tx.notification.findMany({
where: {
userId: { in: recipients },
relatedType,
relatedId,
read: false,
},
select: { userId: true },
});
const hasUnread = new Set(unread.map((row) => row.userId));
const fresh = recipients.filter((userId) => !hasUnread.has(userId));
if (fresh.length === 0) {
return [];
}
return this.fanOut(tx, keys, input, fresh);
}
/**
* The own notifications, newest first, at most 100. A row whose
* requiredPermissions the user no longer holds (role changed after the