dorfteich/apps/api/src/notifications/notifications.service.ts
Claude Fable 5 f9d23bf8d0
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 4m38s
CI / Build container images (pull_request) Successful in 12s
CI / Auth e2e pack (pull_request) Failing after 6m12s
CI / Import/export fidelity gate (pull_request) Has been skipped
#151: Mention-Benachrichtigungen über die Glocke
Neuer Notification-Typ mentioned; abgeleitete Tabelle page_mentions
(Migration), vom Collab-Persist transaktional neu geschrieben — der
Diff gegen den Vorzustand wird als pg_notify
(page_mentions_changed) emittiert, nur NEU Erwähnte lösen aus (kein
Spam bei Folge-Saves). Der api-Listener (erweitert um den zweiten
Kanal) ruft NotificationsService.fanoutMentions: Zustellung nur nach
canAccessPage-Recheck, die Autoren (pending contributors) benachrich-
tigen sich nie selbst; Payload wie gehabt mit Actor-Namen. API-seitig
erzeugte Seiten seeden page_mentions aus deriveContent. Glocken-Text
de+en; DB-Test (Leser ja / Outsider nein / Autor nein); kompletter
Loop live verifiziert (Tippen → Persist → NOTIFY → Notification).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC
2026-07-20 01:25:16 +02:00

211 lines
7.1 KiB
TypeScript

import { Injectable, NotFoundException } from '@nestjs/common';
import {
NOTIFICATION_PAGE_SIZE,
type NotificationListView,
type NotificationPayload,
type NotificationType,
type NotificationView,
} from '@dorfteich/shared';
import { Prisma, User } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino';
import { PermissionService } from '../permissions/permission.service';
import { PrismaService } from '../prisma/prisma.service';
/**
* Notification generation and the in-app center's data (issue #94).
* Fan-out: everyone watching the page or its pond, minus the actors, and
* only where page read permission holds at delivery time (a revoked
* watcher gets nothing — permissions.md). The payload is denormalized so
* the list renders without joins; it reflects the state at event time.
*/
@Injectable()
export class NotificationsService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionService,
private readonly logger: PinoLogger,
) {
this.logger.setContext(NotificationsService.name);
}
/**
* Fans a page event out to its watchers. Never throws — a notification
* failure must not break the write that caused it.
*/
async fanoutPageEvent(type: NotificationType, pageId: string, actorIds: string[]): Promise<void> {
try {
await this.fanout(type, pageId, actorIds);
} catch (error) {
this.logger.warn({ pageId, type, err: error }, 'notification fan-out failed');
}
}
private async fanout(type: NotificationType, pageId: string, actorIds: string[]): Promise<void> {
const page = await this.prisma.page.findFirst({
where: { id: pageId, deletedAt: null },
select: { id: true, pondId: true, title: true, slug: true },
});
if (!page) return;
const pond = await this.prisma.pond.findFirst({
where: { id: page.pondId, deletedAt: null },
select: { name: true, slug: true },
});
if (!pond) return;
const watches = await this.prisma.watch.findMany({
where: {
OR: [
{ targetType: 'PAGE', targetId: page.id },
{ targetType: 'POND', targetId: page.pondId },
],
},
select: { userId: true },
});
const watcherIds = [...new Set(watches.map((watch) => watch.userId))].filter(
(userId) => !actorIds.includes(userId),
);
if (watcherIds.length === 0) return;
const actors = await this.prisma.user.findMany({
where: { id: { in: actorIds } },
select: { displayName: true },
});
const payload: NotificationPayload = {
pageId: page.id,
pageTitle: page.title,
pageSlug: page.slug,
pondSlug: pond.slug,
pondName: pond.name,
actorNames: actors.slice(0, 3).map((actor) => actor.displayName),
};
const watchers = await this.prisma.user.findMany({ where: { id: { in: watcherIds } } });
for (const watcher of watchers) {
// Delivery-time permission re-check: only watchers who may still read.
if (!(await this.permissions.canAccessPage(watcher, page, 'read'))) continue;
await this.prisma.notification.create({
data: {
userId: watcher.id,
type,
payload: payload as unknown as Prisma.InputJsonObject,
},
});
}
}
/**
* Notifies newly mentioned users (issue #151). Independent of the watch
* table — a mention addresses the person directly — but with the same
* delivery-time read-permission re-check: whoever may not read the page
* gets nothing (no leak). The mention's authors (the page's pending
* contributors at persist time) never notify themselves.
*/
async fanoutMentions(pageId: string, mentionedUserIds: string[]): Promise<void> {
try {
const page = await this.prisma.page.findFirst({
where: { id: pageId, deletedAt: null },
select: { id: true, pondId: true, title: true, slug: true },
});
if (!page) return;
const pond = await this.prisma.pond.findFirst({
where: { id: page.pondId, deletedAt: null },
select: { name: true, slug: true },
});
if (!pond) return;
const contributors = await this.prisma.pagePendingContributor.findMany({
where: { pageId },
select: { userId: true },
});
const actorIds = contributors.map((row) => row.userId);
const actors = actorIds.length
? await this.prisma.user.findMany({
where: { id: { in: actorIds } },
select: { displayName: true },
})
: [];
const payload: NotificationPayload = {
pageId: page.id,
pageTitle: page.title,
pageSlug: page.slug,
pondSlug: pond.slug,
pondName: pond.name,
actorNames: actors.slice(0, 3).map((actor) => actor.displayName),
};
const targets = await this.prisma.user.findMany({
where: { id: { in: mentionedUserIds.filter((id) => !actorIds.includes(id)) } },
});
for (const target of targets) {
if (!(await this.permissions.canAccessPage(target, page, 'read'))) continue;
await this.prisma.notification.create({
data: {
userId: target.id,
type: 'mentioned',
payload: payload as unknown as Prisma.InputJsonObject,
},
});
}
} catch (error) {
this.logger.warn({ pageId, err: error }, 'mention fan-out failed');
}
}
async list(user: User, page: number): Promise<NotificationListView> {
const where = { userId: user.id };
const [total, unreadCount] = [
await this.prisma.notification.count({ where }),
await this.prisma.notification.count({ where: { ...where, readAt: null } }),
];
const pageCount = Math.max(1, Math.ceil(total / NOTIFICATION_PAGE_SIZE));
const current = Math.min(page, pageCount);
const rows = await this.prisma.notification.findMany({
where,
// Unread first, newest first within each group.
orderBy: [{ readAt: { sort: 'asc', nulls: 'first' } }, { createdAt: 'desc' }],
skip: (current - 1) * NOTIFICATION_PAGE_SIZE,
take: NOTIFICATION_PAGE_SIZE,
});
return {
notifications: rows.map((row) => this.viewOf(row)),
unreadCount,
page: current,
pageCount,
};
}
async markRead(user: User, id: string): Promise<NotificationView> {
const row = await this.prisma.notification.findFirst({ where: { id, userId: user.id } });
if (!row) throw new NotFoundException();
const updated = await this.prisma.notification.update({
where: { id },
data: { readAt: row.readAt ?? new Date() },
});
return this.viewOf(updated);
}
async markAllRead(user: User): Promise<void> {
await this.prisma.notification.updateMany({
where: { userId: user.id, readAt: null },
data: { readAt: new Date() },
});
}
private viewOf(row: {
id: string;
type: string;
payload: Prisma.JsonValue;
createdAt: Date;
readAt: Date | null;
}): NotificationView {
return {
id: row.id,
type: row.type as NotificationType,
payload: row.payload as unknown as NotificationView['payload'],
createdAt: row.createdAt.toISOString(),
readAt: row.readAt?.toISOString() ?? null,
};
}
}