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

@@ -0,0 +1,63 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import {
ArrayMaxSize,
ArrayUnique,
IsArray,
IsBoolean,
IsEnum,
Matches,
ValidateIf,
} from 'class-validator';
import { NotificationCategory } from '../../../generated/prisma';
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
const TIME_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/;
const TIME_MESSAGE = 'Bitte eine Uhrzeit im Format HH:MM angeben.';
const MAX_TELEGRAM_CATEGORIES = Object.keys(NotificationCategory).length;
// PATCH null rule: an omitted field stays unchanged; the switches, the digest
// time and the category list are NOT NULL columns, so null on them is a 400.
// quietFrom/quietUntil are nullable: null clears them - only as a pair (the
// service judges the merged values). telegramEnabled is not changeable here:
// connecting and disconnecting set it.
export class UpdateNotificationSettingsDto {
@ApiPropertyOptional()
@IsOptionalNotNull()
@IsBoolean()
taskDueEnabled?: boolean;
@ApiPropertyOptional()
@IsOptionalNotNull()
@IsBoolean()
digestEnabled?: boolean;
@ApiPropertyOptional({
description: 'HH:MM, 05:00 bis 12:00 (Europe/Berlin)',
})
@IsOptionalNotNull()
@Matches(TIME_PATTERN, { message: TIME_MESSAGE })
digestTime?: string;
@ApiPropertyOptional({ type: String, nullable: true })
@ValidateIf((_object, value) => value !== undefined && value !== null)
@Matches(TIME_PATTERN, { message: TIME_MESSAGE })
quietFrom?: string | null;
@ApiPropertyOptional({ type: String, nullable: true })
@ValidateIf((_object, value) => value !== undefined && value !== null)
@Matches(TIME_PATTERN, { message: TIME_MESSAGE })
quietUntil?: string | null;
@ApiPropertyOptional({ enum: NotificationCategory, isArray: true })
@IsOptionalNotNull()
@IsArray()
@ArrayUnique()
@ArrayMaxSize(MAX_TELEGRAM_CATEGORIES)
@IsEnum(NotificationCategory, { each: true })
telegramCategories?: NotificationCategory[];
@ApiPropertyOptional()
@IsOptionalNotNull()
@IsBoolean()
telegramDetail?: boolean;
}
@@ -0,0 +1,76 @@
import { readdirSync, readFileSync } from 'node:fs';
import { join, relative, sep } from 'node:path';
// Spec 14.3: a Notification row always has a recipient. Rows are written only
// by NotificationsService (fan-out, permission check, dedupe key) and by the
// backup restore (which resolves the userId itself).
const SRC = join(__dirname, '..');
const WRITE_SOURCE =
'\\bnotification\\.(create|createMany|createManyAndReturn|upsert)\\(';
const RAW_INSERT_SOURCE = 'INSERT\\s+INTO\\s+"?Notification"?(?![A-Za-z])';
const ALLOWED = [
'backup/backup.service.ts',
'notifications/notifications.service.ts',
];
function sources(dir: string): string[] {
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const path = join(dir, entry.name);
if (entry.isDirectory()) {
return sources(path);
}
return entry.name.endsWith('.ts') && !entry.name.endsWith('.spec.ts')
? [path]
: [];
});
}
const rel = (file: string) => relative(SRC, file).split(sep).join('/');
function filesMatching(source: string): string[] {
return sources(SRC)
.filter((file) => new RegExp(source, 'i').test(readFileSync(file, 'utf8')))
.map(rel)
.sort();
}
/** Index of the parenthesis that closes the one at `open`. */
function closingParen(text: string, open: number): number {
let depth = 0;
for (let i = open; i < text.length; i += 1) {
if (text[i] === '(') depth += 1;
if (text[i] === ')') {
depth -= 1;
if (depth === 0) return i;
}
}
throw new Error('unbalanced parentheses');
}
describe('no broadcast notifications', () => {
it('Notification rows are written only by NotificationsService and the backup restore', () =>
expect(filesMatching(WRITE_SOURCE)).toEqual(ALLOWED));
it('no raw INSERT INTO "Notification" in src', () =>
expect(filesMatching(RAW_INSERT_SOURCE)).toEqual([]));
it('every Notification write in those two files carries a userId (spec 14.3)', () => {
for (const file of ALLOWED) {
const text = readFileSync(join(SRC, file), 'utf8');
const hits = [...text.matchAll(new RegExp(WRITE_SOURCE, 'g'))];
expect(hits.length).toBeGreaterThan(0);
for (const hit of hits) {
const open = hit.index + hit[0].length - 1;
const call = text.slice(open, closingParen(text, open) + 1);
if (call.includes('userId')) {
continue;
}
// The data object is built above the call: its definition must set userId.
const before = text.slice(Math.max(0, hit.index - 3000), hit.index);
const definition = [...before.matchAll(/const data = \{[\s\S]*?\};/g)];
expect(call).toMatch(/create: data\b/);
expect(definition.at(-1)?.[0]).toMatch(/\buserId\b/);
}
}
});
});
@@ -0,0 +1,18 @@
/**
* A notification link is a relative app path: it starts with "/", not with
* "//" or "/\", carries no scheme and is at most 300 characters long. Anything
* else is a programming error, never user input (the code that records the
* notification builds the link).
*/
const APP_LINK_PATTERN = /^\/(?![/\\])[A-Za-z0-9\-._~/?=&%]{0,299}$/;
export function assertAppLink(link: string): void {
if (!APP_LINK_PATTERN.test(link)) {
throw new Error(`Notification link is not a relative app path: ${link}`);
}
}
/** One key per recipient, so a fan-out repeated for the same event writes nothing new. */
export function notificationDedupeKey(prefix: string, userId: string): string {
return `${prefix}:${userId}`;
}
@@ -0,0 +1,183 @@
import { BadRequestException } from '@nestjs/common';
import { NotificationPreferencesService } from './notification-preferences.service';
import { PrismaService } from '../prisma/prisma.service';
import { lockRowForUpdate } from '../prisma/row-lock';
jest.mock('../prisma/row-lock', () => ({
lockRowForUpdate: jest.fn().mockResolvedValue(true),
}));
const stored = {
userId: 'u1',
taskDueEnabled: true,
digestEnabled: true,
digestTime: '06:30',
quietFrom: '21:00' as string | null,
quietUntil: '06:30' as string | null,
telegramEnabled: false,
telegramCategories: ['TASK_DUE', 'DIGEST'],
telegramDetail: true,
updatedAt: new Date(0),
};
describe('NotificationPreferencesService', () => {
let service: NotificationPreferencesService;
let tx: {
notificationPreference: {
upsert: jest.Mock;
findUniqueOrThrow: jest.Mock;
update: jest.Mock;
};
};
let prisma: {
notificationPreference: { upsert: jest.Mock };
$transaction: jest.Mock;
};
const order: string[] = [];
beforeEach(() => {
order.length = 0;
(lockRowForUpdate as jest.Mock).mockImplementation(() => {
order.push('lock');
return Promise.resolve(true);
});
tx = {
notificationPreference: {
upsert: jest.fn().mockImplementation(() => {
order.push('upsert');
return Promise.resolve(stored);
}),
findUniqueOrThrow: jest.fn().mockImplementation(() => {
order.push('read');
return Promise.resolve(stored);
}),
update: jest
.fn()
.mockImplementation(({ data }: { data: object }) =>
Promise.resolve({ ...stored, ...data }),
),
},
};
prisma = {
notificationPreference: {
upsert: jest.fn().mockResolvedValue(stored),
},
$transaction: jest
.fn()
.mockImplementation((work: (t: unknown) => Promise<unknown>) =>
work(tx),
),
};
service = new NotificationPreferencesService(
prisma as unknown as PrismaService,
);
});
it('creates the defaults on first read', async () => {
await expect(service.getOrCreate('u1')).resolves.toBe(stored);
expect(prisma.notificationPreference.upsert).toHaveBeenCalledWith({
where: { userId: 'u1' },
create: { userId: 'u1' },
update: {},
});
});
it('uses the given client for the first read', async () => {
await service.getOrCreate('u1', tx as never);
expect(tx.notificationPreference.upsert).toHaveBeenCalledTimes(1);
expect(prisma.notificationPreference.upsert).not.toHaveBeenCalled();
});
it.each(['04:30', '12:01', '00:00', '23:59'])(
'rejects %s as digest time with the German message',
async (digestTime) => {
const failure = service.update('u1', { digestTime });
await expect(failure).rejects.toBeInstanceOf(BadRequestException);
await expect(failure).rejects.toThrow(
'Bitte eine Uhrzeit zwischen 05:00 und 12:00 wählen.',
);
expect(tx.notificationPreference.update).not.toHaveBeenCalled();
},
);
it.each(['05:00', '12:00', '09:15'])(
'accepts %s as digest time',
async (digestTime) => {
await service.update('u1', { digestTime });
expect(tx.notificationPreference.update).toHaveBeenCalledWith({
where: { userId: 'u1' },
data: expect.objectContaining({ digestTime }) as unknown,
});
},
);
it('clears quiet hours only as a pair', async () => {
const failure = service.update('u1', { quietFrom: null });
await expect(failure).rejects.toBeInstanceOf(BadRequestException);
await expect(failure).rejects.toThrow(
'Bitte Beginn und Ende der Ruhezeit zusammen leeren.',
);
await service.update('u1', { quietFrom: null, quietUntil: null });
expect(tx.notificationPreference.update).toHaveBeenCalledWith({
where: { userId: 'u1' },
data: expect.objectContaining({
quietFrom: null,
quietUntil: null,
}) as unknown,
});
});
it('judges the pair rule on the merged values, not on the dto alone', async () => {
tx.notificationPreference.findUniqueOrThrow.mockResolvedValue({
...stored,
quietFrom: null,
quietUntil: null,
});
await expect(
service.update('u1', { quietFrom: '22:00' }),
).rejects.toBeInstanceOf(BadRequestException);
await expect(
service.update('u1', { quietFrom: '22:00', quietUntil: '05:30' }),
).resolves.toBeDefined();
});
it('update locks the preference row by userId and merges with the stored values read inside the transaction', async () => {
tx.notificationPreference.findUniqueOrThrow.mockImplementation(() => {
order.push('read');
return Promise.resolve({ ...stored, digestTime: '07:00' });
});
const result = await service.update('u1', { telegramDetail: false });
expect(order).toEqual(['upsert', 'lock', 'read']);
expect(lockRowForUpdate).toHaveBeenCalledWith(
tx,
'NotificationPreference',
'u1',
);
expect(tx.notificationPreference.update).toHaveBeenCalledWith({
where: { userId: 'u1' },
data: {
taskDueEnabled: true,
digestEnabled: true,
digestTime: '07:00',
quietFrom: '21:00',
quietUntil: '06:30',
telegramCategories: ['TASK_DUE', 'DIGEST'],
telegramDetail: false,
},
});
expect(result.telegramDetail).toBe(false);
});
it('never writes telegramEnabled', async () => {
await service.update('u1', { telegramCategories: ['SYSTEM'] });
const [[arg]] = tx.notificationPreference.update.mock.calls as [
[{ data: Record<string, unknown> }],
];
expect(arg.data).not.toHaveProperty('telegramEnabled');
expect(arg.data.telegramCategories).toEqual(['SYSTEM']);
});
});
@@ -0,0 +1,101 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import {
NotificationCategory,
NotificationPreference,
} from '../../generated/prisma';
import { DbClient } from '../prisma/db-client';
import { PrismaService } from '../prisma/prisma.service';
import { lockRowForUpdate } from '../prisma/row-lock';
import { UpdateNotificationSettingsDto } from './dto/update-notification-settings.dto';
/** The digest time the owner may choose, Europe/Berlin (inclusive). */
const DIGEST_TIME_MIN = '05:00';
const DIGEST_TIME_MAX = '12:00';
const DIGEST_TIME_MESSAGE =
'Bitte eine Uhrzeit zwischen 05:00 und 12:00 wählen.';
const QUIET_PAIR_MESSAGE =
'Bitte Beginn und Ende der Ruhezeit zusammen leeren.';
export interface NotificationSettingsView {
taskDueEnabled: boolean;
digestEnabled: boolean;
digestTime: string;
quietFrom: string | null;
quietUntil: string | null;
telegramCategories: NotificationCategory[];
telegramDetail: boolean;
}
export function toNotificationSettingsView(
preference: NotificationPreference,
): NotificationSettingsView {
return {
taskDueEnabled: preference.taskDueEnabled,
digestEnabled: preference.digestEnabled,
digestTime: preference.digestTime,
quietFrom: preference.quietFrom,
quietUntil: preference.quietUntil,
telegramCategories: preference.telegramCategories,
telegramDetail: preference.telegramDetail,
};
}
/** HH:MM strings of one day compare correctly as text. */
function assertDigestTime(digestTime: string): void {
if (digestTime < DIGEST_TIME_MIN || digestTime > DIGEST_TIME_MAX) {
throw new BadRequestException(DIGEST_TIME_MESSAGE);
}
}
/** Per-user notification settings; a missing row means the defaults. */
@Injectable()
export class NotificationPreferencesService {
constructor(private readonly prisma: PrismaService) {}
getOrCreate(
userId: string,
db: DbClient = this.prisma,
): Promise<NotificationPreference> {
return db.notificationPreference.upsert({
where: { userId },
create: { userId },
update: {},
});
}
async update(
userId: string,
dto: UpdateNotificationSettingsDto,
): Promise<NotificationPreference> {
if (dto.digestTime !== undefined) {
assertDigestTime(dto.digestTime);
}
return this.prisma.$transaction(async (tx) => {
await this.getOrCreate(userId, tx);
await lockRowForUpdate(tx, 'NotificationPreference', userId);
const stored = await tx.notificationPreference.findUniqueOrThrow({
where: { userId },
});
const quietFrom =
dto.quietFrom !== undefined ? dto.quietFrom : stored.quietFrom;
const quietUntil =
dto.quietUntil !== undefined ? dto.quietUntil : stored.quietUntil;
if ((quietFrom === null) !== (quietUntil === null)) {
throw new BadRequestException(QUIET_PAIR_MESSAGE);
}
return tx.notificationPreference.update({
where: { userId },
data: {
taskDueEnabled: dto.taskDueEnabled ?? stored.taskDueEnabled,
digestEnabled: dto.digestEnabled ?? stored.digestEnabled,
digestTime: dto.digestTime ?? stored.digestTime,
quietFrom,
quietUntil,
telegramCategories:
dto.telegramCategories ?? stored.telegramCategories,
telegramDetail: dto.telegramDetail ?? stored.telegramDetail,
},
});
});
}
}
@@ -0,0 +1,42 @@
import { Body, Controller, Get, Patch } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { AnyAuthenticatedUser } from '../auth/decorators/any-authenticated-user.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { UpdateNotificationSettingsDto } from './dto/update-notification-settings.dto';
import {
NotificationPreferencesService,
NotificationSettingsView,
toNotificationSettingsView,
} from './notification-preferences.service';
/**
* The caller's own notification settings: self-scoped like ProfileController
* (no permission applies), @AnyAuthenticatedUser keeps API keys out.
*/
@ApiTags('profile')
@ApiBearerAuth()
@AnyAuthenticatedUser()
@Controller('profile/notification-settings')
export class NotificationSettingsController {
constructor(private readonly preferences: NotificationPreferencesService) {}
@Get()
async get(
@CurrentUser() actor: AuthenticatedUser,
): Promise<NotificationSettingsView> {
return toNotificationSettingsView(
await this.preferences.getOrCreate(actor.id),
);
}
@Patch()
async update(
@Body() dto: UpdateNotificationSettingsDto,
@CurrentUser() actor: AuthenticatedUser,
): Promise<NotificationSettingsView> {
return toNotificationSettingsView(
await this.preferences.update(actor.id, dto),
);
}
}
@@ -13,16 +13,21 @@ export class NotificationsController {
constructor(private readonly notificationsService: NotificationsService) {}
@Get()
findAll(
findOwn(
@CurrentUser() actor: AuthenticatedUser,
@Query('unreadOnly') unreadOnly?: string,
) {
return this.notificationsService.findAll({
userId: actor.id,
return this.notificationsService.findOwn(actor.id, {
unreadOnly: unreadOnly === 'true',
});
}
// Declared before ':id/read' so the fixed path is matched first.
@Post('read-all')
markAllRead(@CurrentUser() actor: AuthenticatedUser) {
return this.notificationsService.markAllRead(actor.id);
}
@Post(':id/read')
markRead(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
return this.notificationsService.markRead(id, actor.id);
@@ -1,10 +1,12 @@
import { Module } from '@nestjs/common';
import { NotificationPreferencesService } from './notification-preferences.service';
import { NotificationSettingsController } from './notification-settings.controller';
import { NotificationsController } from './notifications.controller';
import { NotificationsService } from './notifications.service';
@Module({
controllers: [NotificationsController],
providers: [NotificationsService],
exports: [NotificationsService],
controllers: [NotificationsController, NotificationSettingsController],
providers: [NotificationsService, NotificationPreferencesService],
exports: [NotificationsService, NotificationPreferencesService],
})
export class NotificationsModule {}
@@ -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();
});
});
@@ -1,98 +1,269 @@
import {
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { DbClient } from '../prisma/db-client';
import { PermissionKey } from '../auth/permissions/permission-catalog';
import {
Notification,
NotificationCategory,
NotificationPriority,
Prisma,
} from '../../generated/prisma';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { assertAppLink, notificationDedupeKey } from './notification-link';
export interface CreateNotificationInput {
userId?: string;
userId: string;
priority: NotificationPriority;
category: NotificationCategory;
message: string;
relatedType?: string;
relatedId?: string;
relatedType?: string | null;
relatedId?: string | null;
link?: string | null;
requiredPermissions?: readonly PermissionKey[];
dedupeKey?: string | null;
}
export interface NotificationFilter {
userId?: string;
unreadOnly?: boolean;
export type FanOutNotificationInput = Omit<
CreateNotificationInput,
'userId' | 'dedupeKey'
> & { dedupeKeyPrefix?: string };
export interface NotificationView {
id: string;
priority: NotificationPriority;
category: NotificationCategory;
message: string;
relatedType: string | null;
relatedId: string | null;
link: string | null;
read: boolean;
createdAt: Date;
}
type NotificationContent = Omit<FanOutNotificationInput, 'dedupeKeyPrefix'>;
interface Target {
userId: string;
dedupeKey: string | null;
}
const VIEW_SELECT = {
id: true,
priority: true,
category: true,
message: true,
relatedType: true,
relatedId: true,
link: true,
read: true,
createdAt: true,
} satisfies Prisma.NotificationSelect;
function toView(row: NotificationView): NotificationView {
return {
id: row.id,
priority: row.priority,
category: row.category,
message: row.message,
relatedType: row.relatedType,
relatedId: row.relatedId,
link: row.link,
read: row.read,
createdAt: row.createdAt,
};
}
const MAX_LISTED = 100;
/** Rows read before the permission filter, so hidden rows do not empty the list. */
const MAX_SCANNED = 500;
function uniqueSorted(keys: readonly PermissionKey[]): PermissionKey[] {
return [...new Set(keys)].sort();
}
/**
* Every notification belongs to exactly one user. Rows are written only to
* active users with an active role who hold every permission the content
* needs (the rule the PermissionsGuard applies). The stored
* requiredPermissions are the recipient's and the content's permissions
* together, so a later consumer (e.g. the Telegram sender) can check them
* again.
*/
@Injectable()
export class NotificationsService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly permissionResolver: PermissionResolverService,
) {}
/** One recipient. null: repeated dedupe key, or the recipient lacks a right. */
async record(
input: CreateNotificationInput,
tx?: Prisma.TransactionClient,
): Promise<Notification> {
): Promise<Notification | null> {
const db: DbClient = tx ?? this.prisma;
return db.notification.create({ data: { ...input } });
const { userId, dedupeKey, ...content } = input;
const keys = uniqueSorted(content.requiredPermissions ?? []);
const recipients = await this.recipients(db, keys, [userId]);
const [row] = await this.write(
db,
recipients.map((id) => ({ userId: id, dedupeKey: dedupeKey ?? null })),
content,
keys,
);
return row ?? null;
}
/** The given users, minus those missing a permission the content needs. */
async recordForUsers(
userIds: readonly string[],
input: FanOutNotificationInput,
tx?: Prisma.TransactionClient,
): Promise<Notification[]> {
if (userIds.length === 0) {
return [];
}
const keys = uniqueSorted(input.requiredPermissions ?? []);
return this.fanOut(tx ?? this.prisma, keys, input, userIds);
}
/** Every user holding all of `permission` (and the content's permissions). */
async recordForPermission(
permission: PermissionKey | readonly PermissionKey[],
input: FanOutNotificationInput,
tx?: Prisma.TransactionClient,
): Promise<Notification[]> {
const recipientKeys =
typeof permission === 'string' ? [permission] : permission;
if (recipientKeys.length === 0) {
// An empty list would match every user: a silent broadcast.
throw new Error('recordForPermission needs at least one permission');
}
const keys = uniqueSorted([
...recipientKeys,
...(input.requiredPermissions ?? []),
]);
return this.fanOut(tx ?? this.prisma, keys, input, undefined);
}
/**
* One notification per user who may act on it: active account, active
* role, permission granted - the same rule the PermissionsGuard applies.
* Returns how many were written.
* The own notifications, newest first, at most 100. A row whose
* requiredPermissions the user no longer holds (role changed after the
* write) stays hidden.
*/
async recordForPermission(
permission: PermissionKey,
input: Omit<CreateNotificationInput, 'userId'>,
tx?: Prisma.TransactionClient,
): Promise<number> {
const db: DbClient = tx ?? this.prisma;
const recipients = await db.user.findMany({
async findOwn(
userId: string,
options: { unreadOnly?: boolean } = {},
): Promise<NotificationView[]> {
const rows = await this.prisma.notification.findMany({
where: { userId, ...(options.unreadOnly ? { read: false } : {}) },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
take: MAX_SCANNED,
select: { ...VIEW_SELECT, requiredPermissions: true },
});
const held = await this.permissionResolver.getPermissions(userId);
return rows
.filter((row) => row.requiredPermissions.every((key) => held.has(key)))
.slice(0, MAX_LISTED)
.map(toView);
}
/** One statement; a foreign or unknown id matches nothing and answers 404. */
async markRead(id: string, userId: string): Promise<NotificationView> {
const { count } = await this.prisma.notification.updateMany({
where: { id, userId },
data: { read: true },
});
if (count === 0) {
throw new NotFoundException('Notification not found');
}
return this.prisma.notification.findFirstOrThrow({
where: { id, userId },
select: VIEW_SELECT,
});
}
async markAllRead(userId: string): Promise<{ updated: number }> {
const { count } = await this.prisma.notification.updateMany({
where: { userId, read: false },
data: { read: true },
});
return { updated: count };
}
private async fanOut(
db: DbClient,
keys: readonly PermissionKey[],
input: FanOutNotificationInput,
userIds: readonly string[] | undefined,
): Promise<Notification[]> {
const { dedupeKeyPrefix, ...content } = input;
const recipients = await this.recipients(db, keys, userIds);
return this.write(
db,
recipients.map((userId) => ({
userId,
dedupeKey: dedupeKeyPrefix
? notificationDedupeKey(dedupeKeyPrefix, userId)
: null,
})),
content,
keys,
);
}
/** Active user, active role, every key granted - in one query. */
private async recipients(
db: DbClient,
keys: readonly PermissionKey[],
userIds: readonly string[] | undefined,
): Promise<string[]> {
const users = await db.user.findMany({
where: {
...(userIds ? { id: { in: [...userIds] } } : {}),
isActive: true,
role: {
status: 'ACTIVE',
permissions: { some: { permission: { key: permission } } },
AND: keys.map((key) => ({
permissions: { some: { permission: { key } } },
})),
},
},
select: { id: true },
orderBy: { id: 'asc' },
});
if (recipients.length === 0) {
return 0;
}
const { count } = await db.notification.createMany({
data: recipients.map((recipient) => ({ ...input, userId: recipient.id })),
});
return count;
return users.map((user) => user.id);
}
async findAll(filter: NotificationFilter = {}): Promise<Notification[]> {
return this.prisma.notification.findMany({
where: {
OR: filter.userId
? [{ userId: filter.userId }, { userId: null }]
: undefined,
read: filter.unreadOnly ? false : undefined,
},
orderBy: { createdAt: 'desc' },
take: 100,
});
}
async markRead(id: string, actingUserId: string): Promise<Notification> {
const notification = await this.prisma.notification.findUnique({
where: { id },
});
if (!notification) {
throw new NotFoundException('Notification not found');
private async write(
db: DbClient,
targets: readonly Target[],
content: NotificationContent,
recipientKeys: readonly PermissionKey[],
): Promise<Notification[]> {
if (content.link) {
assertAppLink(content.link);
}
if (notification.userId !== null && notification.userId !== actingUserId) {
throw new ForbiddenException("Cannot modify another user's notification");
if (targets.length === 0) {
return [];
}
return this.prisma.notification.update({
where: { id },
data: { read: true },
const requiredPermissions = uniqueSorted([
...recipientKeys,
...(content.requiredPermissions ?? []),
]);
return db.notification.createManyAndReturn({
data: targets.map(({ userId, dedupeKey }) => ({
userId,
priority: content.priority,
category: content.category,
message: content.message,
relatedType: content.relatedType ?? null,
relatedId: content.relatedId ?? null,
link: content.link ?? null,
requiredPermissions,
dedupeKey,
})),
skipDuplicates: true,
});
}
}