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
@@ -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