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,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,
});
}
}