Files
ApiCore/ApiCore-Backend/src/notifications/notifications.service.ts
T
Nick SzaunigandClaude Opus 5.5 16cc557462
Backend Image / build-and-push (push) Successful in 4m26s
Frontend Image / build-and-push (push) Successful in 7m19s
Manifests / validate (push) Successful in 17s
feat: T4b/T4c wave 9 - phenology reports, autopilot A4-A10, honey ledger, Telegram delivery
- 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]>
2026-10-14 21:13:23 +02:00

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