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
211 lines
7.1 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|