feat: T6 wave 4 – notice PDFs, compliance notifications, readiness core
- 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:
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
|
||||
|
||||
Reference in new issue
Block a user