dorfteich/apps/api/src/notifications/notifications.service.ts
Claude Fable 5 67fb01fe2b
All checks were successful
CI / Lint, typecheck, test (push) Successful in 3m26s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m49s
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m12s
CD / Promote to Int (push) Successful in 10s
CI / Auth e2e pack (push) Successful in 5m27s
CI / Import/export fidelity gate (push) Successful in 46s
Notify watchers about page changes and comments, with an in-app center (#94)
New notifications table (payload denormalized for join-free rendering;
mailed_at already prepares the #95 digests). Generation fans page events
out to page and pond watchers, excluding the actors, and re-checks page
read permission per watcher at delivery time — a revoked watcher gets
nothing. Sources: named version snapshots (api), new comments (api), and
the collab server's automatic session-close snapshots — announced over a
new pg NOTIFY channel (the reverse of the established api→collab bus)
consumed by a dedicated LISTEN client in the api, since the collab server
has no permission resolution of its own. API: paginated list (unread
first via nulls-first ordering), mark read, mark all read. UI: bell with
unread badge in the top bar (30 s polling, no push in v1) and a dropdown
whose entries navigate and mark themselves read; comment notifications
deep-link with ?comments=1, which now opens the comments panel on load.

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

153 lines
5.0 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,
},
});
}
}
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,
};
}
}