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:
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user