Notify watchers about page changes and comments, with an in-app center (#94)
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
This commit is contained in:
Claude Fable 5 2026-07-11 23:20:25 +02:00
parent f4f27cbe78
commit 67fb01fe2b
25 changed files with 833 additions and 3 deletions

View File

@ -33,6 +33,7 @@
"multer": "^2.1.1", "multer": "^2.1.1",
"nestjs-pino": "^4.3.0", "nestjs-pino": "^4.3.0",
"nodemailer": "^9.0.3", "nodemailer": "^9.0.3",
"pg": "^8.22.0",
"pino": "^9.6.0", "pino": "^9.6.0",
"pino-http": "^10.4.0", "pino-http": "^10.4.0",
"prisma": "^6.3.0", "prisma": "^6.3.0",
@ -56,6 +57,7 @@
"@types/jsdom": "^28.0.3", "@types/jsdom": "^28.0.3",
"@types/multer": "^2.0.0", "@types/multer": "^2.0.0",
"@types/nodemailer": "^8.0.1", "@types/nodemailer": "^8.0.1",
"@types/pg": "^8.20.0",
"@types/supertest": "^6.0.0", "@types/supertest": "^6.0.0",
"pdf-parse": "^2.4.5", "pdf-parse": "^2.4.5",
"pino-pretty": "^13.0.0", "pino-pretty": "^13.0.0",

View File

@ -0,0 +1,19 @@
-- In-app notifications (issue #94); mailed_at prepares the #95 digests.
CREATE TABLE "notifications" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"type" TEXT NOT NULL,
"payload" JSONB NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"read_at" TIMESTAMP(3),
"mailed_at" TIMESTAMP(3),
CONSTRAINT "notifications_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "notifications_user_id_read_at_created_at_idx"
ON "notifications"("user_id", "read_at", "created_at");
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -55,6 +55,7 @@ model User {
auditEntries AuditEntry[] auditEntries AuditEntry[]
comments Comment[] comments Comment[]
watches Watch[] watches Watch[]
notifications Notification[]
@@map("users") @@map("users")
} }
@ -137,6 +138,26 @@ model Watch {
@@map("watches") @@map("watches")
} }
/// In-app notification (issue #94, data-model.md §notifications). `payload`
/// carries the denormalized display data (page/pond names, actor names) so
/// the list renders without joins; permission is re-checked at generation
/// time, not at read time. `mailedAt` is the e-mail digest's bookkeeping
/// (issue #95) — independent of `readAt`.
model Notification {
id String @id @default(uuid())
userId String @map("user_id")
type String
payload Json
createdAt DateTime @default(now()) @map("created_at")
readAt DateTime? @map("read_at")
mailedAt DateTime? @map("mailed_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, readAt, createdAt])
@@map("notifications")
}
enum PondType { enum PondType {
PERSONAL PERSONAL
SHARED SHARED

View File

@ -31,6 +31,7 @@ import { SettingsModule } from './settings/settings.module';
import { SetupModule } from './setup/setup.module'; import { SetupModule } from './setup/setup.module';
import { TrashModule } from './trash/trash.module'; import { TrashModule } from './trash/trash.module';
import { UsersModule } from './users/users.module'; import { UsersModule } from './users/users.module';
import { NotificationsModule } from './notifications/notifications.module';
import { WatchesModule } from './watches/watches.module'; import { WatchesModule } from './watches/watches.module';
import { VersionsModule } from './versions/versions.module'; import { VersionsModule } from './versions/versions.module';
@ -51,6 +52,7 @@ import { VersionsModule } from './versions/versions.module';
PagesModule, PagesModule,
CommentsModule, CommentsModule,
WatchesModule, WatchesModule,
NotificationsModule,
FilesModule, FilesModule,
TrashModule, TrashModule,
CompactionModule, CompactionModule,

View File

@ -1,13 +1,14 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { PermissionsModule } from '../permissions/permissions.module'; import { PermissionsModule } from '../permissions/permissions.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { WatchesModule } from '../watches/watches.module'; import { WatchesModule } from '../watches/watches.module';
import { CommentsController } from './comments.controller'; import { CommentsController } from './comments.controller';
import { CommentsService } from './comments.service'; import { CommentsService } from './comments.service';
@Module({ @Module({
imports: [PermissionsModule, WatchesModule], imports: [PermissionsModule, WatchesModule, NotificationsModule],
controllers: [CommentsController], controllers: [CommentsController],
providers: [CommentsService], providers: [CommentsService],
exports: [CommentsService], exports: [CommentsService],

View File

@ -19,6 +19,7 @@ import { Comment, Page, User } from '@prisma/client';
import { PermissionService } from '../permissions/permission.service'; import { PermissionService } from '../permissions/permission.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { NotificationsService } from '../notifications/notifications.service';
import { WatchesService } from '../watches/watches.service'; import { WatchesService } from '../watches/watches.service';
type CommentWithAuthor = Comment & { type CommentWithAuthor = Comment & {
@ -38,6 +39,7 @@ export class CommentsService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly permissions: PermissionService, private readonly permissions: PermissionService,
private readonly watches: WatchesService, private readonly watches: WatchesService,
private readonly notifications: NotificationsService,
) {} ) {}
/** Comments live on live pages only — trash hides them (ADR 0013). */ /** Comments live on live pages only — trash hides them (ADR 0013). */
@ -146,6 +148,8 @@ export class CommentsService {
// Commenting subscribes the author to the page (issue #93) — // Commenting subscribes the author to the page (issue #93) —
// preference-gated, never fatal for the comment itself. // preference-gated, never fatal for the comment itself.
await this.watches.autoWatchPage(user, pageId, 'comment').catch(() => {}); await this.watches.autoWatchPage(user, pageId, 'comment').catch(() => {});
// Watchers learn about the new comment (issue #94); never fatal either.
await this.notifications.fanoutPageEvent('comment_added', pageId, [user.id]);
return CommentsService.viewOf(created as CommentWithAuthor); return CommentsService.viewOf(created as CommentWithAuthor);
} }

View File

@ -0,0 +1,43 @@
import { Controller, Get, HttpCode, Param, Post, Query, Req } from '@nestjs/common';
import {
notificationListQuerySchema,
type NotificationListQuery,
type NotificationListView,
type NotificationView,
} from '@dorfteich/shared';
import { AuthedRequest } from '../auth/auth.guard';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { AuthenticatedOnly } from '../permissions/permission.decorators';
import { NotificationsService } from './notifications.service';
/** The in-app notification center's API (issue #94). */
@Controller('notifications')
export class NotificationsController {
constructor(private readonly notifications: NotificationsService) {}
@Get()
@AuthenticatedOnly()
async list(
@Query(new ZodValidationPipe(notificationListQuerySchema)) query: NotificationListQuery,
@Req() request: AuthedRequest,
): Promise<NotificationListView> {
return this.notifications.list(request.user!, query.page);
}
@Post(':id/read')
@AuthenticatedOnly()
async markRead(
@Param('id') id: string,
@Req() request: AuthedRequest,
): Promise<NotificationView> {
return this.notifications.markRead(request.user!, id);
}
@Post('read-all')
@HttpCode(204)
@AuthenticatedOnly()
async markAllRead(@Req() request: AuthedRequest): Promise<void> {
await this.notifications.markAllRead(request.user!);
}
}

View File

@ -0,0 +1,197 @@
import { INestApplication } from '@nestjs/common';
import type { NotificationListView } from '@dorfteich/shared';
import { PrismaClient, User } from '@prisma/client';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { NotificationsService } from './notifications.service';
import { createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service';
/**
* Notification generation + center (issue #94): fan-out to watchers minus
* the actor, the delivery-time permission re-check (a revoked watcher gets
* nothing), the version-event path, and the list/read API with unread-first
* ordering that survives reloads (server state).
*/
describe.skipIf(!hasTestDb)('notifications (e2e, issue #94)', () => {
let app: INestApplication;
let prisma: PrismaClient;
const suffix = uniqueSuffix();
const password = 'bescheid wissen ist gold 1';
const users: Record<string, User> = {};
const cookies: Record<string, string> = {};
let pondId: string;
let pageId: string;
const api = () => request(app.getHttpServer());
async function makeUser(handle: string): Promise<void> {
const service = app.get(UsersService);
const username = `no-${handle}-${suffix}`;
const user = await service.createUser({
username,
email: `${username}@example.org`,
displayName: `No ${handle}`,
password,
locale: 'en',
});
await service.markEmailVerified(user.id);
users[handle] = user;
cookies[handle] = sessionCookieOf(
await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: username, password })
.expect(200),
);
}
async function grant(handle: string, role: 'POND_ADMIN' | 'EDITOR' | 'READER'): Promise<string> {
const created = await prisma.roleGrant.create({
data: {
pondId,
subjectType: 'USER',
subjectId: users[handle]!.id,
role,
scopeType: 'POND',
effect: 'ALLOW',
createdBy: users.owner!.id,
},
});
return created.id;
}
async function listFor(handle: string): Promise<NotificationListView> {
const res = await api()
.get('/api/v1/notifications')
.set('Cookie', cookies[handle]!)
.expect(200);
return res.body as NotificationListView;
}
beforeAll(async () => {
prisma = createTestPrisma();
await prisma.rateLimit.deleteMany({});
app = await createTestApp();
for (const handle of ['owner', 'watcher', 'revoked']) await makeUser(handle);
const pond = await prisma.pond.create({
data: {
slug: `no-pond-${suffix}`,
name: 'Notify Pond',
type: 'SHARED',
ownerId: users.owner!.id,
},
});
pondId = pond.id;
await grant('owner', 'POND_ADMIN');
await grant('watcher', 'READER');
const page = await api()
.post(`/api/v1/ponds/${pondId}/pages`)
.set('Cookie', cookies.owner!)
.send({ title: 'Watched target' })
.expect(201);
pageId = (page.body as { id: string }).id;
await api().put(`/api/v1/watches/page/${pageId}`).set('Cookie', cookies.watcher!).expect(200);
});
afterAll(async () => {
const ids = Object.values(users).map((u) => u.id);
await prisma.notification.deleteMany({ where: { userId: { in: ids } } });
await prisma.watch.deleteMany({ where: { userId: { in: ids } } });
await prisma.comment.deleteMany({ where: { page: { pondId } } });
await prisma.pageVersion.deleteMany({ where: { page: { pondId } } });
await prisma.roleGrant.deleteMany({ where: { pondId } });
await prisma.page.deleteMany({ where: { pondId } });
await prisma.pond.deleteMany({ where: { id: pondId } });
await prisma.auditEntry.deleteMany({ where: { actorId: { in: ids } } });
await prisma.session.deleteMany({ where: { userId: { in: ids } } });
await prisma.userIdentity.deleteMany({ where: { userId: { in: ids } } });
await prisma.user.deleteMany({ where: { id: { in: ids } } });
await prisma.$disconnect();
await app.close();
});
it('notifies the watcher about a version snapshot, never the actor', async () => {
// The collab server's NOTIFY lands in the same service call; drive it
// directly (the LISTEN plumbing is inert under NODE_ENV=test).
await app.get(NotificationsService).fanoutPageEvent('page_changed', pageId, [users.owner!.id]);
const watcher = await listFor('watcher');
expect(watcher.unreadCount).toBe(1);
expect(watcher.notifications[0]).toMatchObject({
type: 'page_changed',
payload: { pageTitle: 'Watched target', actorNames: ['No owner'] },
});
const owner = await listFor('owner');
expect(owner.unreadCount).toBe(0);
});
it('notifies about new comments with the page link payload', async () => {
await api()
.post(`/api/v1/pages/${pageId}/comments`)
.set('Cookie', cookies.owner!)
.send({ body: 'watchers, assemble' })
.expect(201);
const watcher = await listFor('watcher');
const comment = watcher.notifications.find((n) => n.type === 'comment_added');
expect(comment).toBeTruthy();
expect(comment?.payload.pageSlug).toBeTruthy();
expect(comment?.payload.pondSlug).toBeTruthy();
});
it('re-checks read permission at delivery time (revoked watcher gets nothing)', async () => {
// Create and revoke through the API — the permission cache only
// invalidates on service-level grant changes, raw rows go stale.
const created = await api()
.post(`/api/v1/ponds/${pondId}/grants`)
.set('Cookie', cookies.owner!)
.send({
subjectType: 'user',
subjectId: users.revoked!.id,
role: 'reader',
scopeType: 'pond',
scopeId: null,
effect: 'allow',
})
.expect(201);
const grantId = (created.body as { id: string }).id;
await api().put(`/api/v1/watches/page/${pageId}`).set('Cookie', cookies.revoked!).expect(200);
await api()
.delete(`/api/v1/ponds/${pondId}/grants/${grantId}`)
.set('Cookie', cookies.owner!)
.expect(204);
await app.get(NotificationsService).fanoutPageEvent('page_changed', pageId, [users.owner!.id]);
const revoked = await listFor('revoked');
expect(revoked.notifications).toHaveLength(0);
});
it('marks single and all notifications read; unread state is server-side', async () => {
const before = await listFor('watcher');
expect(before.unreadCount).toBeGreaterThan(0);
const first = before.notifications[0]!;
await api()
.post(`/api/v1/notifications/${first.id}/read`)
.set('Cookie', cookies.watcher!)
.expect(201);
const afterOne = await listFor('watcher');
expect(afterOne.unreadCount).toBe(before.unreadCount - 1);
// Unread first: the still-unread entries precede the read one.
expect(afterOne.notifications.findIndex((n) => n.id === first.id)).toBeGreaterThan(0);
await api().post('/api/v1/notifications/read-all').set('Cookie', cookies.watcher!).expect(204);
// A fresh request (≙ reload) still sees the read state — server-side.
expect((await listFor('watcher')).unreadCount).toBe(0);
// Foreign notifications are unreachable (404, not 403).
await api()
.post(`/api/v1/notifications/${first.id}/read`)
.set('Cookie', cookies.owner!)
.expect(404);
});
});

View File

@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { PermissionsModule } from '../permissions/permissions.module';
import { NotificationsController } from './notifications.controller';
import { NotificationsService } from './notifications.service';
import { VersionEventListener } from './version-event-listener.service';
@Module({
imports: [PermissionsModule],
controllers: [NotificationsController],
providers: [NotificationsService, VersionEventListener],
exports: [NotificationsService],
})
export class NotificationsModule {}

View File

@ -0,0 +1,152 @@
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,
};
}
}

View File

@ -0,0 +1,82 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { PAGE_VERSION_CREATED_CHANNEL, type PageVersionCreatedEvent } from '@dorfteich/shared';
import { PinoLogger } from 'nestjs-pino';
import { Client } from 'pg';
import { AppConfig } from '../config/app-config.service';
import { NotificationsService } from './notifications.service';
const RECONNECT_DELAY_MS = 1000;
/**
* Listens for the collab server's "automatic version snapshot written"
* events (issue #94) and fans them out to watchers. The reverse of the
* established apicollab LISTEN/NOTIFY bus (access/restore channels):
* the collab server owns the AUTO snapshots but has no permission
* resolution, so notification generation lives here. `LISTEN` needs its
* own dedicated connection Prisma cannot hold one, hence the raw client.
* Inert under NODE_ENV=test (tests call the service directly).
*/
@Injectable()
export class VersionEventListener implements OnModuleInit, OnModuleDestroy {
private client: Client | null = null;
private stopped = false;
private reconnectTimer: NodeJS.Timeout | null = null;
constructor(
private readonly config: AppConfig,
private readonly notifications: NotificationsService,
private readonly logger: PinoLogger,
) {
this.logger.setContext(VersionEventListener.name);
}
async onModuleInit(): Promise<void> {
if (this.config.env.NODE_ENV === 'test') return;
await this.connect();
}
async onModuleDestroy(): Promise<void> {
this.stopped = true;
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
await this.client?.end().catch(() => undefined);
}
private async connect(): Promise<void> {
if (this.stopped) return;
const client = new Client({ connectionString: this.config.env.DATABASE_URL });
this.client = client;
client.on('error', () => this.scheduleReconnect());
client.on('end', () => this.scheduleReconnect());
client.on('notification', (message) => {
if (message.channel !== PAGE_VERSION_CREATED_CHANNEL || !message.payload) return;
void this.handle(message.payload);
});
try {
await client.connect();
await client.query(`LISTEN ${PAGE_VERSION_CREATED_CHANNEL}`);
this.logger.info({}, 'listening for collab version events');
} catch (error) {
this.logger.warn({ err: error }, 'version-event listener could not connect');
this.scheduleReconnect();
}
}
private scheduleReconnect(): void {
if (this.stopped || this.reconnectTimer) return;
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
void this.connect();
}, RECONNECT_DELAY_MS);
}
private async handle(payload: string): Promise<void> {
try {
const event = JSON.parse(payload) as PageVersionCreatedEvent;
if (!event.pageId || !Array.isArray(event.contributorIds)) return;
await this.notifications.fanoutPageEvent('page_changed', event.pageId, event.contributorIds);
} catch (error) {
this.logger.warn({ err: error }, 'ignoring malformed version event');
}
}
}

View File

@ -1,5 +1,6 @@
import { Module, OnModuleInit } from '@nestjs/common'; import { Module, OnModuleInit } from '@nestjs/common';
import { NotificationsModule } from '../notifications/notifications.module';
import { PondsModule } from '../ponds/ponds.module'; import { PondsModule } from '../ponds/ponds.module';
import { SchedulerModule } from '../scheduler/scheduler.module'; import { SchedulerModule } from '../scheduler/scheduler.module';
import { SchedulerService } from '../scheduler/scheduler.service'; import { SchedulerService } from '../scheduler/scheduler.service';
@ -11,7 +12,7 @@ import { VersionsService } from './versions.service';
const VERSION_THINNING_CADENCE_SECONDS = 24 * 60 * 60; const VERSION_THINNING_CADENCE_SECONDS = 24 * 60 * 60;
@Module({ @Module({
imports: [PondsModule, SchedulerModule], imports: [PondsModule, SchedulerModule, NotificationsModule],
controllers: [VersionsController], controllers: [VersionsController],
providers: [VersionsService], providers: [VersionsService],
exports: [VersionsService], exports: [VersionsService],

View File

@ -12,6 +12,7 @@ import { PinoLogger } from 'nestjs-pino';
import * as Y from 'yjs'; import * as Y from 'yjs';
import { deriveContent } from '../pages/yjs-content'; import { deriveContent } from '../pages/yjs-content';
import { NotificationsService } from '../notifications/notifications.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
/** /**
@ -38,6 +39,7 @@ const TRIGGER_TO_VIEW: Record<PrismaTrigger, PageVersionTrigger> = {
export class VersionsService { export class VersionsService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly notifications: NotificationsService,
private readonly logger: PinoLogger, private readonly logger: PinoLogger,
) { ) {
this.logger.setContext(VersionsService.name); this.logger.setContext(VersionsService.name);
@ -155,6 +157,8 @@ export class VersionsService {
{ event: 'audit: version created', pageId, versionId: created.id, userId: user.id }, { event: 'audit: version created', pageId, versionId: created.id, userId: user.id },
'named version created', 'named version created',
); );
// A named snapshot is a meaningful change unit — notify watchers (#94).
await this.notifications.fanoutPageEvent('page_changed', pageId, [user.id]);
return this.viewOf(created); return this.viewOf(created);
} }

View File

@ -1,3 +1,4 @@
import { PAGE_VERSION_CREATED_CHANNEL, type PageVersionCreatedEvent } from '@dorfteich/shared';
import type { Pool } from 'pg'; import type { Pool } from 'pg';
import type { Logger } from 'pino'; import type { Logger } from 'pino';
import * as Y from 'yjs'; import * as Y from 'yjs';
@ -143,6 +144,13 @@ export class PostgresVersionStore implements VersionStore {
{ event: 'version.auto.created', pageId, contributors: contributors.length }, { event: 'version.auto.created', pageId, contributors: contributors.length },
'automatic version snapshot created', 'automatic version snapshot created',
); );
// Tell the api so it can notify watchers (issue #94) — it owns the
// permission resolution. Fire-and-forget: a lost event costs a
// notification, never the snapshot.
const event: PageVersionCreatedEvent = { pageId, contributorIds: contributors };
await client
.query('SELECT pg_notify($1, $2)', [PAGE_VERSION_CREATED_CHANNEL, JSON.stringify(event)])
.catch(() => undefined);
} }
return created; return created;
} catch (error) { } catch (error) {

View File

@ -12,6 +12,7 @@ import deLabels from '@dorfteich/shared/i18n/de/labels.json';
import deLegal from '@dorfteich/shared/i18n/de/legal.json'; import deLegal from '@dorfteich/shared/i18n/de/legal.json';
import deLinks from '@dorfteich/shared/i18n/de/links.json'; import deLinks from '@dorfteich/shared/i18n/de/links.json';
import deMembers from '@dorfteich/shared/i18n/de/members.json'; import deMembers from '@dorfteich/shared/i18n/de/members.json';
import deNotifications from '@dorfteich/shared/i18n/de/notifications.json';
import dePlugins from '@dorfteich/shared/i18n/de/plugins.json'; import dePlugins from '@dorfteich/shared/i18n/de/plugins.json';
import dePublic from '@dorfteich/shared/i18n/de/public.json'; import dePublic from '@dorfteich/shared/i18n/de/public.json';
import deQuotas from '@dorfteich/shared/i18n/de/quotas.json'; import deQuotas from '@dorfteich/shared/i18n/de/quotas.json';
@ -35,6 +36,7 @@ import enLabels from '@dorfteich/shared/i18n/en/labels.json';
import enLegal from '@dorfteich/shared/i18n/en/legal.json'; import enLegal from '@dorfteich/shared/i18n/en/legal.json';
import enLinks from '@dorfteich/shared/i18n/en/links.json'; import enLinks from '@dorfteich/shared/i18n/en/links.json';
import enMembers from '@dorfteich/shared/i18n/en/members.json'; import enMembers from '@dorfteich/shared/i18n/en/members.json';
import enNotifications from '@dorfteich/shared/i18n/en/notifications.json';
import enPlugins from '@dorfteich/shared/i18n/en/plugins.json'; import enPlugins from '@dorfteich/shared/i18n/en/plugins.json';
import enPublic from '@dorfteich/shared/i18n/en/public.json'; import enPublic from '@dorfteich/shared/i18n/en/public.json';
import enQuotas from '@dorfteich/shared/i18n/en/quotas.json'; import enQuotas from '@dorfteich/shared/i18n/en/quotas.json';
@ -75,6 +77,7 @@ void i18n
legal: enLegal, legal: enLegal,
links: enLinks, links: enLinks,
members: enMembers, members: enMembers,
notifications: enNotifications,
plugins: enPlugins, plugins: enPlugins,
public: enPublic, public: enPublic,
quotas: enQuotas, quotas: enQuotas,
@ -100,6 +103,7 @@ void i18n
legal: deLegal, legal: deLegal,
links: deLinks, links: deLinks,
members: deMembers, members: deMembers,
notifications: deNotifications,
plugins: dePlugins, plugins: dePlugins,
public: dePublic, public: dePublic,
quotas: deQuotas, quotas: deQuotas,

View File

@ -4,6 +4,7 @@ import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../auth/auth-context'; import { useAuth } from '../auth/auth-context';
import { SearchPalette } from '../search/SearchPalette'; import { SearchPalette } from '../search/SearchPalette';
import { NotificationsBell } from '../notifications/NotificationsBell';
import { PondSwitcher } from './PondSwitcher'; import { PondSwitcher } from './PondSwitcher';
/** True when focus is in a field where "/" should type, not open search. */ /** True when focus is in a field where "/" should type, not open search. */
@ -72,6 +73,7 @@ export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): Reac
</button> </button>
)} )}
{searchOpen && user && <SearchPalette onClose={() => setSearchOpen(false)} />} {searchOpen && user && <SearchPalette onClose={() => setSearchOpen(false)} />}
{user && <NotificationsBell />}
{user ? ( {user ? (
<div className="user-menu"> <div className="user-menu">
<button <button

View File

@ -0,0 +1,101 @@
import type { NotificationListView, NotificationView } from '@dorfteich/shared';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { apiGet, apiPost } from '../lib/api';
/**
* The in-app notification center (issue #94): a bell with an unread badge,
* polling every 30 s (no push infrastructure in v1), and a dropdown whose
* entries navigate to the page comment notifications open the comments
* panel via the `comments=1` deep link and mark themselves read.
*/
export function NotificationsBell(): React.JSX.Element {
const { t, i18n } = useTranslation('notifications');
const navigate = useNavigate();
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const list = useQuery({
queryKey: ['notifications'],
queryFn: () => apiGet<NotificationListView>('/notifications'),
refetchInterval: 30_000,
});
const refresh = (): Promise<void> =>
queryClient.invalidateQueries({ queryKey: ['notifications'] });
const openEntry = async (entry: NotificationView): Promise<void> => {
setOpen(false);
await apiPost(`/notifications/${entry.id}/read`, {}).catch(() => undefined);
await refresh();
const base = `/p/${entry.payload.pondSlug}/${entry.payload.pageSlug}`;
navigate(entry.type === 'comment_added' ? `${base}?comments=1` : base);
};
const markAll = async (): Promise<void> => {
await apiPost('/notifications/read-all', {});
await refresh();
};
const unread = list.data?.unreadCount ?? 0;
return (
<div className="notifications-bell">
<button
type="button"
className="notifications-bell__button"
aria-haspopup="menu"
aria-expanded={open}
aria-label={t('title')}
onClick={() => setOpen((value) => !value)}
>
🔔
{unread > 0 && <span className="notifications-bell__badge">{unread}</span>}
</button>
{open && (
<div className="notifications-bell__dropdown" role="menu">
<div className="notifications-bell__header">
<span>{t('title')}</span>
<button
type="button"
className="comments-link-button"
disabled={unread === 0}
onClick={() => void markAll()}
>
{t('markAllRead')}
</button>
</div>
{list.data && list.data.notifications.length === 0 && (
<p className="notifications-bell__empty">{t('empty')}</p>
)}
<ul className="notifications-bell__list">
{(list.data?.notifications ?? []).map((entry) => (
<li key={entry.id}>
<button
type="button"
role="menuitem"
className={`notifications-bell__entry${entry.readAt ? '' : ' notifications-bell__entry--unread'}`}
onClick={() => void openEntry(entry)}
>
<span className="notifications-bell__text">
{t(`types.${entry.type}`, {
actor: entry.payload.actorNames[0] ?? t('someone'),
page: entry.payload.pageTitle,
pond: entry.payload.pondName,
})}
</span>
<time dateTime={entry.createdAt}>
{new Date(entry.createdAt).toLocaleString(i18n.language)}
</time>
</button>
</li>
))}
</ul>
</div>
)}
</div>
);
}

View File

@ -73,7 +73,11 @@ function PageEditor({
const { user } = useAuth(); const { user } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const [showAttachments, setShowAttachments] = useState(false); const [showAttachments, setShowAttachments] = useState(false);
const [showComments, setShowComments] = useState(false); // Deep link from a comment notification (issue #94): ?comments=1 opens
// the panel immediately.
const [showComments, setShowComments] = useState(
() => new URLSearchParams(window.location.search).get('comments') === '1',
);
const [showPageTools, setShowPageTools] = useState(false); const [showPageTools, setShowPageTools] = useState(false);
// Created and destroyed within the same effect (not `useMemo` + a separate // Created and destroyed within the same effect (not `useMemo` + a separate

View File

@ -2534,3 +2534,86 @@ button {
align-items: center; align-items: center;
gap: var(--space-3); gap: var(--space-3);
} }
/* Notification center (issue #94) */
.notifications-bell {
position: relative;
}
.notifications-bell__button {
background: none;
border: none;
cursor: pointer;
font-size: 1.1rem;
position: relative;
padding: var(--space-1);
}
.notifications-bell__badge {
position: absolute;
top: -2px;
right: -4px;
background: #a02818;
color: #fff;
border-radius: 999px;
font-size: 0.7rem;
padding: 0 0.35rem;
line-height: 1.2rem;
}
.notifications-bell__dropdown {
position: absolute;
right: 0;
top: 100%;
z-index: 30;
width: 22rem;
max-height: 24rem;
overflow-y: auto;
background: var(--color-surface, #fff);
border: 1px solid var(--color-border, #cbd5e1);
border-radius: 8px;
box-shadow: 0 6px 24px rgb(0 0 0 / 0.12);
padding: var(--space-2);
}
.notifications-bell__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-2);
font-weight: 600;
}
.notifications-bell__empty {
color: var(--color-text-muted);
margin: var(--space-2) 0;
}
.notifications-bell__list {
list-style: none;
margin: 0;
padding: 0;
}
.notifications-bell__entry {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
text-align: left;
background: none;
border: none;
border-top: 1px solid var(--color-border, #e2e8f0);
padding: var(--space-2);
cursor: pointer;
}
.notifications-bell__entry--unread {
font-weight: 600;
}
.notifications-bell__entry time {
color: var(--color-text-muted);
font-size: 0.75rem;
font-weight: 400;
}

View File

@ -0,0 +1,10 @@
{
"title": "Benachrichtigungen",
"markAllRead": "Alle als gelesen markieren",
"empty": "Noch keine Benachrichtigungen.",
"someone": "Jemand",
"types": {
"page_changed": "{{actor}} hat „{{page}}“ in {{pond}} geändert",
"comment_added": "{{actor}} hat „{{page}}“ in {{pond}} kommentiert"
}
}

View File

@ -0,0 +1,10 @@
{
"title": "Notifications",
"markAllRead": "Mark all read",
"empty": "No notifications yet.",
"someone": "Someone",
"types": {
"page_changed": "{{actor}} changed “{{page}}” in {{pond}}",
"comment_added": "{{actor}} commented on “{{page}}” in {{pond}}"
}
}

View File

@ -32,6 +32,21 @@ export const POND_ACCESS_CHANGED_CHANNEL = 'pond_access_changed';
*/ */
export const PAGE_RESTORE_CHANNEL = 'page_restore'; export const PAGE_RESTORE_CHANNEL = 'page_restore';
/**
* PostgreSQL `NOTIFY` channel over which the collab server announces that it
* wrote an automatic version snapshot (issue #94): the api listens and fans
* the change out to watchers as notifications permission-checked there,
* where the resolution lives. Payload is a JSON {@link PageVersionCreatedEvent}.
*/
export const PAGE_VERSION_CREATED_CHANNEL = 'page_version_created';
/** JSON payload carried on {@link PAGE_VERSION_CREATED_CHANNEL}. */
export interface PageVersionCreatedEvent {
pageId: string;
/** Everyone who contributed to the snapshot — all excluded from fan-out. */
contributorIds: string[];
}
/** JSON payload carried on {@link PAGE_RESTORE_CHANNEL}. */ /** JSON payload carried on {@link PAGE_RESTORE_CHANNEL}. */
export interface PageRestoreRequest { export interface PageRestoreRequest {
pageId: string; pageId: string;

View File

@ -15,6 +15,7 @@ export * from './labels';
export * from './legal'; export * from './legal';
export * from './links'; export * from './links';
export * from './members'; export * from './members';
export * from './notifications';
export * from './pages'; export * from './pages';
export * from './permissions'; export * from './permissions';
export * from './plugins'; export * from './plugins';

View File

@ -0,0 +1,43 @@
import { z } from 'zod';
/**
* In-app notifications (issue #94, data-model.md §notifications): watchers
* learn about page changes (version snapshots, ADR 0013's change unit) and
* new comments. Generation excludes the actor and re-checks page read
* permission at delivery time.
*/
export const NOTIFICATION_TYPES = ['page_changed', 'comment_added'] as const;
export type NotificationType = (typeof NOTIFICATION_TYPES)[number];
export interface NotificationPayload {
pageId: string;
pageTitle: string;
pageSlug: string;
pondSlug: string;
pondName: string;
/** Display names of the acting users (first few, for the list entry). */
actorNames: string[];
}
export interface NotificationView {
id: string;
type: NotificationType;
payload: NotificationPayload;
createdAt: string;
readAt: string | null;
}
export const NOTIFICATION_PAGE_SIZE = 20;
export const notificationListQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
});
export type NotificationListQuery = z.infer<typeof notificationListQuerySchema>;
export interface NotificationListView {
notifications: NotificationView[];
unreadCount: number;
page: number;
pageCount: number;
}

6
pnpm-lock.yaml generated
View File

@ -80,6 +80,9 @@ importers:
nodemailer: nodemailer:
specifier: ^9.0.3 specifier: ^9.0.3
version: 9.0.3 version: 9.0.3
pg:
specifier: ^8.22.0
version: 8.22.0
pino: pino:
specifier: ^9.6.0 specifier: ^9.6.0
version: 9.14.0 version: 9.14.0
@ -144,6 +147,9 @@ importers:
'@types/nodemailer': '@types/nodemailer':
specifier: ^8.0.1 specifier: ^8.0.1
version: 8.0.1 version: 8.0.1
'@types/pg':
specifier: ^8.20.0
version: 8.20.0
'@types/supertest': '@types/supertest':
specifier: ^6.0.0 specifier: ^6.0.0
version: 6.0.3 version: 6.0.3