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