feat: T4 wave 3 - notifications per user, suggestions API, varroa bands, split/queen events

- Schema III: per-user notifications (broadcast fan-out migration,
  category, link, requiredPermissions filter), notification preferences
  and settings endpoints, calendar withdrawal switch, occupiedFrames
- Suggestions API: list/summary with permission visibility and paging,
  accept/dismiss/reopen incl. bulk, SuggestionView on all responses
- Varroa traffic light by season and method, young colonies, treatment
  effect window, sample-based percentage; shared withdrawal-window helper
  on Berlin calendar days; GET /varroa-measurements/thresholds
- ColonySplit and QueenAssigned domain events with calendar and webhook
  handlers
- Outbox claims compare against now() rounded to timestamp(3); fixes the
  flaky e2e claims of rows written with SQL now()

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 14:25:24 +02:00
1 parent d99a697739
commit f468dc5f75
87 files changed
+6022 -510

No files matched your search

@@ -1,18 +1,30 @@
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { NotFoundException } from '@nestjs/common';
import { NotificationsService } from './notifications.service';
import { assertAppLink } from './notification-link';
import { PrismaService } from '../prisma/prisma.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
type CreateManyArg = { data: { userId: string; message: string }[] };
type CreateManyArg = {
data: {
userId: string;
message: string;
dedupeKey: string | null;
requiredPermissions: string[];
category: string;
link: string | null;
}[];
skipDuplicates: boolean;
};
describe('NotificationsService', () => {
let service: NotificationsService;
let held: Set<string>;
let prisma: {
notification: {
create: jest.Mock;
createMany: jest.Mock;
createManyAndReturn: jest.Mock;
findMany: jest.Mock;
findUnique: jest.Mock;
update: jest.Mock;
findFirstOrThrow: jest.Mock;
updateMany: jest.Mock;
};
user: { findMany: jest.Mock };
};
@@ -20,177 +32,375 @@ describe('NotificationsService', () => {
beforeEach(() => {
prisma = {
notification: {
create: jest.fn(),
createMany: jest
createManyAndReturn: jest
.fn()
.mockImplementation(({ data }: CreateManyArg) =>
Promise.resolve({ count: data.length }),
Promise.resolve(data.map((row, i) => ({ id: `n${i}`, ...row }))),
),
findMany: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
findMany: jest.fn().mockResolvedValue([]),
findFirstOrThrow: jest.fn(),
updateMany: jest.fn(),
},
user: { findMany: jest.fn().mockResolvedValue([]) },
};
service = new NotificationsService(prisma as unknown as PrismaService);
});
it('records a notification with the given priority and message', async () => {
const created = {
id: 'n1',
priority: 'KRITISCH',
message: 'Keine Königin gesehen',
};
prisma.notification.create.mockResolvedValueOnce(created);
const result = await service.record({
priority: 'KRITISCH',
message: 'Keine Königin gesehen',
});
expect(result).toEqual(created);
expect(prisma.notification.create).toHaveBeenCalledWith({
data: { priority: 'KRITISCH', message: 'Keine Königin gesehen' },
});
});
it('records through the transaction client when one is given', async () => {
const tx = { notification: { create: jest.fn() } };
await service.record(
{ priority: 'HINWEIS', message: 'Aufgabe fällig' },
tx as never,
held = new Set();
service = new NotificationsService(
prisma as unknown as PrismaService,
{
getPermissions: jest
.fn()
.mockImplementation(() => Promise.resolve(held)),
} as unknown as PermissionResolverService,
);
});
expect(tx.notification.create).toHaveBeenCalledTimes(1);
expect(prisma.notification.create).not.toHaveBeenCalled();
const lastCreate = () =>
(
prisma.notification.createManyAndReturn.mock.calls[0] as [CreateManyArg]
)[0];
it('record without userId does not compile', () => {
// @ts-expect-error userId fehlt
void service.record({ priority: 'INFO', category: 'SYSTEM', message: 'm' });
});
describe('record', () => {
it('writes one row for an eligible recipient and returns it', async () => {
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }]);
const result = await service.record({
userId: 'u1',
priority: 'KRITISCH',
category: 'SYSTEM',
message: 'Keine Königin gesehen',
dedupeKey: 'k:u1',
});
expect(result).toMatchObject({ userId: 'u1', dedupeKey: 'k:u1' });
expect(lastCreate().skipDuplicates).toBe(true);
expect(lastCreate().data).toEqual([
{
userId: 'u1',
priority: 'KRITISCH',
category: 'SYSTEM',
message: 'Keine Königin gesehen',
relatedType: null,
relatedId: null,
link: null,
requiredPermissions: [],
dedupeKey: 'k:u1',
},
]);
});
it('returns null for a recipient without the permission', async () => {
await expect(
service.record({
userId: 'u1',
priority: 'INFO',
category: 'SYSTEM',
message: 'm',
requiredPermissions: ['colonies:read'],
}),
).resolves.toBeNull();
expect(prisma.notification.createManyAndReturn).not.toHaveBeenCalled();
});
it('returns null for a repeated dedupe key', async () => {
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }]);
prisma.notification.createManyAndReturn.mockResolvedValue([]);
await expect(
service.record({
userId: 'u1',
priority: 'INFO',
category: 'SYSTEM',
message: 'm',
dedupeKey: 'k:u1',
}),
).resolves.toBeNull();
});
it('writes through the transaction client when one is given', async () => {
const tx = {
user: { findMany: jest.fn().mockResolvedValue([{ id: 'u1' }]) },
notification: {
createManyAndReturn: jest.fn().mockResolvedValue([{ id: 'n' }]),
},
};
await service.record(
{ userId: 'u1', priority: 'INFO', category: 'SYSTEM', message: 'm' },
tx as never,
);
expect(tx.notification.createManyAndReturn).toHaveBeenCalledTimes(1);
expect(prisma.notification.createManyAndReturn).not.toHaveBeenCalled();
});
it('rejects an unsafe link before writing', async () => {
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }]);
await expect(
service.record({
userId: 'u1',
priority: 'INFO',
category: 'SYSTEM',
message: 'm',
link: 'https://evil.example',
}),
).rejects.toThrow();
expect(prisma.notification.createManyAndReturn).not.toHaveBeenCalled();
});
});
describe('recordForPermission', () => {
it('notifies every active user whose active role holds the permission', async () => {
const input = {
priority: 'WARNUNG' as const,
category: 'SYSTEM' as const,
message: 'Job „App-Backup“ fehlgeschlagen',
};
it('writes one row per active user holding every permission, with a key per recipient', async () => {
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }, { id: 'u2' }]);
await expect(
service.recordForPermission('backup:configure', {
priority: 'WARNUNG',
message: 'Job „App-Backup“ fehlgeschlagen',
}),
).resolves.toBe(2);
const [[createArg]] = prisma.notification.createMany.mock.calls as [
[CreateManyArg],
];
expect(createArg.data.map((n) => n.userId)).toEqual(['u1', 'u2']);
expect(createArg.data[0]).toEqual({
userId: 'u1',
priority: 'WARNUNG',
message: 'Job „App-Backup“ fehlgeschlagen',
const rows = await service.recordForPermission('backup:configure', {
...input,
dedupeKeyPrefix: 'p',
});
expect(rows).toHaveLength(2);
expect(lastCreate().data.map((r) => [r.userId, r.dedupeKey])).toEqual([
['u1', 'p:u1'],
['u2', 'p:u2'],
]);
});
it('selects recipients like the permission guard: active user, active role, granted key', async () => {
await service.recordForPermission('backup:configure', {
priority: 'WARNUNG',
message: 'x',
});
it('selects recipients like the permission guard: active user, active role, every key granted', async () => {
await service.recordForPermission(
['backup:configure', 'colonies:read'],
input,
);
expect(prisma.user.findMany).toHaveBeenCalledWith({
where: {
isActive: true,
role: {
status: 'ACTIVE',
permissions: {
some: { permission: { key: 'backup:configure' } },
},
AND: [
{
permissions: {
some: { permission: { key: 'backup:configure' } },
},
},
{
permissions: { some: { permission: { key: 'colonies:read' } } },
},
],
},
},
select: { id: true },
orderBy: { id: 'asc' },
});
});
it('writes nothing and returns 0 when nobody holds the permission', async () => {
it('stores the union of recipient and content permissions', async () => {
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }]);
await service.recordForPermission('colonies:read', {
...input,
requiredPermissions: ['inspections:read', 'colonies:read'],
});
expect(lastCreate().data[0].requiredPermissions).toEqual([
'colonies:read',
'inspections:read',
]);
});
it('throws for an empty permission list instead of broadcasting', async () => {
await expect(service.recordForPermission([], input)).rejects.toThrow();
expect(prisma.user.findMany).not.toHaveBeenCalled();
});
it('writes nothing when nobody holds the permission', async () => {
await expect(
service.recordForPermission('backup:configure', input),
).resolves.toEqual([]);
expect(prisma.notification.createManyAndReturn).not.toHaveBeenCalled();
});
it('a repeated dedupe key writes nothing', async () => {
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }]);
prisma.notification.createManyAndReturn.mockResolvedValue([]);
await expect(
service.recordForPermission('backup:configure', {
priority: 'WARNUNG',
message: 'x',
...input,
dedupeKeyPrefix: 'p',
}),
).resolves.toBe(0);
expect(prisma.notification.createMany).not.toHaveBeenCalled();
).resolves.toEqual([]);
});
it('reads and writes through the transaction client when one is given', async () => {
const tx = {
user: { findMany: jest.fn().mockResolvedValue([{ id: 'u1' }]) },
notification: {
createMany: jest.fn().mockResolvedValue({ count: 1 }),
createManyAndReturn: jest.fn().mockResolvedValue([{ id: 'n1' }]),
},
};
await expect(
service.recordForPermission(
'backup:configure',
{ priority: 'WARNUNG', message: 'x' },
tx as never,
),
).resolves.toBe(1);
service.recordForPermission('backup:configure', input, tx as never),
).resolves.toHaveLength(1);
expect(tx.notification.createMany).toHaveBeenCalledTimes(1);
expect(prisma.user.findMany).not.toHaveBeenCalled();
expect(prisma.notification.createMany).not.toHaveBeenCalled();
expect(prisma.notification.createManyAndReturn).not.toHaveBeenCalled();
});
});
it('lists notifications for a user including broadcast notifications', async () => {
await service.findAll({ userId: 'user-1' });
describe('recordForUsers', () => {
it('skips users missing a required permission', async () => {
prisma.user.findMany.mockResolvedValue([{ id: 'u2' }]);
const [[queryArg]] = prisma.notification.findMany.mock.calls as [
[{ where: { OR?: { userId: string | null }[] } }],
];
expect(queryArg.where.OR).toEqual([{ userId: 'user-1' }, { userId: null }]);
});
const rows = await service.recordForUsers(['u1', 'u2'], {
priority: 'INFO',
category: 'SUGGESTION',
message: 'm',
requiredPermissions: ['colonies:read'],
});
it('throws NotFoundException when marking an unknown notification as read', async () => {
prisma.notification.findUnique.mockResolvedValueOnce(null);
await expect(service.markRead('missing', 'user-1')).rejects.toBeInstanceOf(
NotFoundException,
);
});
expect(prisma.user.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
id: { in: ['u1', 'u2'] },
}) as unknown,
}),
);
expect(rows.map((r) => r.userId)).toEqual(['u2']);
});
it("throws ForbiddenException when marking another user's notification as read", async () => {
prisma.notification.findUnique.mockResolvedValueOnce({
id: 'n1',
userId: 'owner',
read: false,
it('does nothing for an empty user list', async () => {
await expect(
service.recordForUsers([], {
priority: 'INFO',
category: 'SYSTEM',
message: 'm',
}),
).resolves.toEqual([]);
expect(prisma.user.findMany).not.toHaveBeenCalled();
});
await expect(service.markRead('n1', 'someone-else')).rejects.toBeInstanceOf(
ForbiddenException,
);
});
it('marks a broadcast notification (no owner) as read for any user', async () => {
prisma.notification.findUnique.mockResolvedValueOnce({
id: 'n1',
userId: null,
read: false,
describe('reading', () => {
it('lists only the own notifications, newest first, at most 100', async () => {
await service.findOwn('user-1', { unreadOnly: true });
expect(prisma.notification.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: 'user-1', read: false },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
take: 500,
}),
);
});
prisma.notification.update.mockResolvedValueOnce({ id: 'n1', read: true });
const result = await service.markRead('n1', 'any-user');
it('hides a row whose permissions the user no longer holds (revoked after the write)', async () => {
const row = (id: string, requiredPermissions: string[]) => ({
id,
priority: 'INFO',
category: 'SYSTEM',
message: 'm',
relatedType: null,
relatedId: null,
link: null,
read: false,
createdAt: new Date(0),
requiredPermissions,
});
prisma.notification.findMany.mockResolvedValue([
row('open', []),
row('revoked', ['colonies:read', 'varroa:read']),
row('kept', ['colonies:read']),
]);
held.add('colonies:read');
expect(result.read).toBe(true);
});
const views = await service.findOwn('user-1');
it("marks the owner's own notification as read", async () => {
prisma.notification.findUnique.mockResolvedValueOnce({
id: 'n1',
userId: 'user-1',
read: false,
expect(views.map((view) => view.id)).toEqual(['open', 'kept']);
expect(views[0]).not.toHaveProperty('requiredPermissions');
});
prisma.notification.update.mockResolvedValueOnce({ id: 'n1', read: true });
const result = await service.markRead('n1', 'user-1');
it('selects the view fields plus the permission list for filtering, never the dedupe key', async () => {
await service.findOwn('user-1');
expect(result.read).toBe(true);
const [[arg]] = prisma.notification.findMany.mock.calls as [
[{ select: Record<string, true> }],
];
expect(Object.keys(arg.select).sort()).toEqual([
'category',
'createdAt',
'id',
'link',
'message',
'priority',
'read',
'relatedId',
'relatedType',
'requiredPermissions',
]);
});
it('answers 404 when the notification is foreign or unknown', async () => {
prisma.notification.updateMany.mockResolvedValue({ count: 0 });
await expect(service.markRead('n1', 'user-1')).rejects.toBeInstanceOf(
NotFoundException,
);
expect(prisma.notification.updateMany).toHaveBeenCalledWith({
where: { id: 'n1', userId: 'user-1' },
data: { read: true },
});
});
it('marks the own notification as read', async () => {
prisma.notification.updateMany.mockResolvedValue({ count: 1 });
prisma.notification.findFirstOrThrow.mockResolvedValue({
id: 'n1',
read: true,
});
await expect(service.markRead('n1', 'user-1')).resolves.toMatchObject({
read: true,
});
});
it('marks only the own unread notifications as read', async () => {
prisma.notification.updateMany.mockResolvedValue({ count: 3 });
await expect(service.markAllRead('user-1')).resolves.toEqual({
updated: 3,
});
expect(prisma.notification.updateMany).toHaveBeenCalledWith({
where: { userId: 'user-1', read: false },
data: { read: true },
});
});
});
});
describe('assertAppLink', () => {
it('accepts a relative app path', () => {
expect(() => assertAppLink('/tasks?status=OPEN&x=1')).not.toThrow();
});
it.each([
'//evil.example',
'https://evil.example',
'/\\evil',
'tasks',
'/a b',
])('rejects %s', (link) => expect(() => assertAppLink(link)).toThrow());
it('rejects a link over 300 characters', () => {
expect(() => assertAppLink(`/${'a'.repeat(300)}`)).toThrow();
});
});