- Phenology reports API with corrections and revokes; recompute per apiary via PhenologyRecomputeRequested, allowBackward for corrections; season changes are announced in the morning digest (changes since the previous digest, each once) - Season autopilot A4-A10 with per-apiary sync (Apiary before Colony locks) and WinterCheckRecorded - Honey batch ledger at runtime: initial amount, BOTTLING bookings, manual movements, one gram rounding, untracked bottlings never book - Telegram delivery: planning, Berlin delivery times incl. DST, quiet hours, permission re-check and stale skip at send time, rate limits, cleanup of old deliveries - ARCHITECTURE.md: assumptions 47-50 and updated sections 11.x Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
307 lines
9.7 KiB
TypeScript
307 lines
9.7 KiB
TypeScript
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 { NotificationDeliveryPlanner } from './delivery-planner';
|
|
import { assertAppLink, notificationDedupeKey } from './notification-link';
|
|
|
|
export interface CreateNotificationInput {
|
|
userId: string;
|
|
priority: NotificationPriority;
|
|
category: NotificationCategory;
|
|
message: string;
|
|
relatedType?: string | null;
|
|
relatedId?: string | null;
|
|
link?: string | null;
|
|
requiredPermissions?: readonly PermissionKey[];
|
|
dedupeKey?: string | null;
|
|
}
|
|
|
|
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. Right after the insert, in the same transaction, the delivery planner
|
|
* queues the Telegram delivery for users who asked for it.
|
|
*/
|
|
@Injectable()
|
|
export class NotificationsService {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly permissionResolver: PermissionResolverService,
|
|
private readonly deliveryPlanner: NotificationDeliveryPlanner,
|
|
) {}
|
|
|
|
/** One recipient. null: repeated dedupe key, or the recipient lacks a right. */
|
|
async record(
|
|
input: CreateNotificationInput,
|
|
tx?: Prisma.TransactionClient,
|
|
): Promise<Notification | null> {
|
|
const db: DbClient = tx ?? this.prisma;
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* 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 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);
|
|
}
|
|
|
|
/**
|
|
* A foreign, unknown or no longer visible row (same rule as findOwn)
|
|
* answers 404 and is left untouched.
|
|
*/
|
|
async markRead(id: string, userId: string): Promise<NotificationView> {
|
|
const row = await this.prisma.notification.findFirst({
|
|
where: { id, userId },
|
|
select: { requiredPermissions: true },
|
|
});
|
|
const held = await this.permissionResolver.getPermissions(userId);
|
|
if (!row || !row.requiredPermissions.every((key) => held.has(key))) {
|
|
throw new NotFoundException('Notification not found');
|
|
}
|
|
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,
|
|
});
|
|
}
|
|
|
|
/** Marks only the unread rows the user may see. */
|
|
async markAllRead(userId: string): Promise<{ updated: number }> {
|
|
const unread = await this.prisma.notification.findMany({
|
|
where: { userId, read: false },
|
|
select: { id: true, requiredPermissions: true },
|
|
});
|
|
const held = await this.permissionResolver.getPermissions(userId);
|
|
const visible = unread
|
|
.filter((row) => row.requiredPermissions.every((key) => held.has(key)))
|
|
.map((row) => row.id);
|
|
if (visible.length === 0) {
|
|
return { updated: 0 };
|
|
}
|
|
const { count } = await this.prisma.notification.updateMany({
|
|
where: { id: { in: visible }, 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',
|
|
AND: keys.map((key) => ({
|
|
permissions: { some: { permission: { key } } },
|
|
})),
|
|
},
|
|
},
|
|
select: { id: true },
|
|
orderBy: { id: 'asc' },
|
|
});
|
|
return users.map((user) => user.id);
|
|
}
|
|
|
|
private async write(
|
|
db: DbClient,
|
|
targets: readonly Target[],
|
|
content: NotificationContent,
|
|
recipientKeys: readonly PermissionKey[],
|
|
): Promise<Notification[]> {
|
|
if (content.link) {
|
|
assertAppLink(content.link);
|
|
}
|
|
if (targets.length === 0) {
|
|
return [];
|
|
}
|
|
const requiredPermissions = uniqueSorted([
|
|
...recipientKeys,
|
|
...(content.requiredPermissions ?? []),
|
|
]);
|
|
const insertAndPlan = async (client: DbClient): Promise<Notification[]> => {
|
|
const rows = await client.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,
|
|
});
|
|
// Only the rows that were inserted: a repeated dedupe key plans nothing.
|
|
await this.deliveryPlanner.planIn(client, rows);
|
|
return rows;
|
|
};
|
|
// Rows and deliveries belong together: without the caller's transaction
|
|
// they get their own. A caller WITH a transaction gets no nested one: the
|
|
// insert and the planning run on its client and commit or roll back with it.
|
|
return '$connect' in db
|
|
? db.$transaction((tx) => insertAndPlan(tx))
|
|
: insertAndPlan(db);
|
|
}
|
|
}
|