diff --git a/apps/api/package.json b/apps/api/package.json index d2e7d12..7aab0e7 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -33,6 +33,7 @@ "multer": "^2.1.1", "nestjs-pino": "^4.3.0", "nodemailer": "^9.0.3", + "pg": "^8.22.0", "pino": "^9.6.0", "pino-http": "^10.4.0", "prisma": "^6.3.0", @@ -56,6 +57,7 @@ "@types/jsdom": "^28.0.3", "@types/multer": "^2.0.0", "@types/nodemailer": "^8.0.1", + "@types/pg": "^8.20.0", "@types/supertest": "^6.0.0", "pdf-parse": "^2.4.5", "pino-pretty": "^13.0.0", diff --git a/apps/api/prisma/migrations/20260712000000_notifications/migration.sql b/apps/api/prisma/migrations/20260712000000_notifications/migration.sql new file mode 100644 index 0000000..f2e5f1a --- /dev/null +++ b/apps/api/prisma/migrations/20260712000000_notifications/migration.sql @@ -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; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index c49ceec..30c5b46 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -55,6 +55,7 @@ model User { auditEntries AuditEntry[] comments Comment[] watches Watch[] + notifications Notification[] @@map("users") } @@ -137,6 +138,26 @@ model Watch { @@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 { PERSONAL SHARED diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 726bfdd..2a9be17 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -31,6 +31,7 @@ import { SettingsModule } from './settings/settings.module'; import { SetupModule } from './setup/setup.module'; import { TrashModule } from './trash/trash.module'; import { UsersModule } from './users/users.module'; +import { NotificationsModule } from './notifications/notifications.module'; import { WatchesModule } from './watches/watches.module'; import { VersionsModule } from './versions/versions.module'; @@ -51,6 +52,7 @@ import { VersionsModule } from './versions/versions.module'; PagesModule, CommentsModule, WatchesModule, + NotificationsModule, FilesModule, TrashModule, CompactionModule, diff --git a/apps/api/src/comments/comments.module.ts b/apps/api/src/comments/comments.module.ts index ded8b12..4164fae 100644 --- a/apps/api/src/comments/comments.module.ts +++ b/apps/api/src/comments/comments.module.ts @@ -1,13 +1,14 @@ import { Module } from '@nestjs/common'; import { PermissionsModule } from '../permissions/permissions.module'; +import { NotificationsModule } from '../notifications/notifications.module'; import { WatchesModule } from '../watches/watches.module'; import { CommentsController } from './comments.controller'; import { CommentsService } from './comments.service'; @Module({ - imports: [PermissionsModule, WatchesModule], + imports: [PermissionsModule, WatchesModule, NotificationsModule], controllers: [CommentsController], providers: [CommentsService], exports: [CommentsService], diff --git a/apps/api/src/comments/comments.service.ts b/apps/api/src/comments/comments.service.ts index 934eb05..10cceec 100644 --- a/apps/api/src/comments/comments.service.ts +++ b/apps/api/src/comments/comments.service.ts @@ -19,6 +19,7 @@ import { Comment, Page, User } from '@prisma/client'; import { PermissionService } from '../permissions/permission.service'; import { PrismaService } from '../prisma/prisma.service'; +import { NotificationsService } from '../notifications/notifications.service'; import { WatchesService } from '../watches/watches.service'; type CommentWithAuthor = Comment & { @@ -38,6 +39,7 @@ export class CommentsService { private readonly prisma: PrismaService, private readonly permissions: PermissionService, private readonly watches: WatchesService, + private readonly notifications: NotificationsService, ) {} /** 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) — // preference-gated, never fatal for the comment itself. 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); } diff --git a/apps/api/src/notifications/notifications.controller.ts b/apps/api/src/notifications/notifications.controller.ts new file mode 100644 index 0000000..2df963e --- /dev/null +++ b/apps/api/src/notifications/notifications.controller.ts @@ -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 { + return this.notifications.list(request.user!, query.page); + } + + @Post(':id/read') + @AuthenticatedOnly() + async markRead( + @Param('id') id: string, + @Req() request: AuthedRequest, + ): Promise { + return this.notifications.markRead(request.user!, id); + } + + @Post('read-all') + @HttpCode(204) + @AuthenticatedOnly() + async markAllRead(@Req() request: AuthedRequest): Promise { + await this.notifications.markAllRead(request.user!); + } +} diff --git a/apps/api/src/notifications/notifications.e2e.db.test.ts b/apps/api/src/notifications/notifications.e2e.db.test.ts new file mode 100644 index 0000000..8e23150 --- /dev/null +++ b/apps/api/src/notifications/notifications.e2e.db.test.ts @@ -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 = {}; + const cookies: Record = {}; + let pondId: string; + let pageId: string; + + const api = () => request(app.getHttpServer()); + + async function makeUser(handle: string): Promise { + 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 { + 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 { + 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); + }); +}); diff --git a/apps/api/src/notifications/notifications.module.ts b/apps/api/src/notifications/notifications.module.ts new file mode 100644 index 0000000..0c35410 --- /dev/null +++ b/apps/api/src/notifications/notifications.module.ts @@ -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 {} diff --git a/apps/api/src/notifications/notifications.service.ts b/apps/api/src/notifications/notifications.service.ts new file mode 100644 index 0000000..aac1968 --- /dev/null +++ b/apps/api/src/notifications/notifications.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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, + }; + } +} diff --git a/apps/api/src/notifications/version-event-listener.service.ts b/apps/api/src/notifications/version-event-listener.service.ts new file mode 100644 index 0000000..9c6f805 --- /dev/null +++ b/apps/api/src/notifications/version-event-listener.service.ts @@ -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 api→collab 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 { + if (this.config.env.NODE_ENV === 'test') return; + await this.connect(); + } + + async onModuleDestroy(): Promise { + this.stopped = true; + if (this.reconnectTimer) clearTimeout(this.reconnectTimer); + await this.client?.end().catch(() => undefined); + } + + private async connect(): Promise { + 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 { + 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'); + } + } +} diff --git a/apps/api/src/versions/versions.module.ts b/apps/api/src/versions/versions.module.ts index f97f132..d13b339 100644 --- a/apps/api/src/versions/versions.module.ts +++ b/apps/api/src/versions/versions.module.ts @@ -1,5 +1,6 @@ import { Module, OnModuleInit } from '@nestjs/common'; +import { NotificationsModule } from '../notifications/notifications.module'; import { PondsModule } from '../ponds/ponds.module'; import { SchedulerModule } from '../scheduler/scheduler.module'; import { SchedulerService } from '../scheduler/scheduler.service'; @@ -11,7 +12,7 @@ import { VersionsService } from './versions.service'; const VERSION_THINNING_CADENCE_SECONDS = 24 * 60 * 60; @Module({ - imports: [PondsModule, SchedulerModule], + imports: [PondsModule, SchedulerModule, NotificationsModule], controllers: [VersionsController], providers: [VersionsService], exports: [VersionsService], diff --git a/apps/api/src/versions/versions.service.ts b/apps/api/src/versions/versions.service.ts index ef1e19e..7fc196d 100644 --- a/apps/api/src/versions/versions.service.ts +++ b/apps/api/src/versions/versions.service.ts @@ -12,6 +12,7 @@ import { PinoLogger } from 'nestjs-pino'; import * as Y from 'yjs'; import { deriveContent } from '../pages/yjs-content'; +import { NotificationsService } from '../notifications/notifications.service'; import { PrismaService } from '../prisma/prisma.service'; /** @@ -38,6 +39,7 @@ const TRIGGER_TO_VIEW: Record = { export class VersionsService { constructor( private readonly prisma: PrismaService, + private readonly notifications: NotificationsService, private readonly logger: PinoLogger, ) { this.logger.setContext(VersionsService.name); @@ -155,6 +157,8 @@ export class VersionsService { { event: 'audit: version created', pageId, versionId: created.id, userId: user.id }, '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); } diff --git a/apps/collab/src/version-store.ts b/apps/collab/src/version-store.ts index a366f85..d3495c9 100644 --- a/apps/collab/src/version-store.ts +++ b/apps/collab/src/version-store.ts @@ -1,3 +1,4 @@ +import { PAGE_VERSION_CREATED_CHANNEL, type PageVersionCreatedEvent } from '@dorfteich/shared'; import type { Pool } from 'pg'; import type { Logger } from 'pino'; import * as Y from 'yjs'; @@ -143,6 +144,13 @@ export class PostgresVersionStore implements VersionStore { { event: 'version.auto.created', pageId, contributors: contributors.length }, '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; } catch (error) { diff --git a/apps/web/src/i18n/index.ts b/apps/web/src/i18n/index.ts index 21b0b9a..0ebc659 100644 --- a/apps/web/src/i18n/index.ts +++ b/apps/web/src/i18n/index.ts @@ -12,6 +12,7 @@ import deLabels from '@dorfteich/shared/i18n/de/labels.json'; import deLegal from '@dorfteich/shared/i18n/de/legal.json'; import deLinks from '@dorfteich/shared/i18n/de/links.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 dePublic from '@dorfteich/shared/i18n/de/public.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 enLinks from '@dorfteich/shared/i18n/en/links.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 enPublic from '@dorfteich/shared/i18n/en/public.json'; import enQuotas from '@dorfteich/shared/i18n/en/quotas.json'; @@ -75,6 +77,7 @@ void i18n legal: enLegal, links: enLinks, members: enMembers, + notifications: enNotifications, plugins: enPlugins, public: enPublic, quotas: enQuotas, @@ -100,6 +103,7 @@ void i18n legal: deLegal, links: deLinks, members: deMembers, + notifications: deNotifications, plugins: dePlugins, public: dePublic, quotas: deQuotas, diff --git a/apps/web/src/layout/TopBar.tsx b/apps/web/src/layout/TopBar.tsx index b6a32e1..fc66969 100644 --- a/apps/web/src/layout/TopBar.tsx +++ b/apps/web/src/layout/TopBar.tsx @@ -4,6 +4,7 @@ import { Link, useNavigate } from 'react-router-dom'; import { useAuth } from '../auth/auth-context'; import { SearchPalette } from '../search/SearchPalette'; +import { NotificationsBell } from '../notifications/NotificationsBell'; import { PondSwitcher } from './PondSwitcher'; /** True when focus is in a field where "/" should type, not open search. */ @@ -72,6 +73,7 @@ export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): Reac )} {searchOpen && user && setSearchOpen(false)} />} + {user && } {user ? (
+ {open && ( +
+
+ {t('title')} + +
+ {list.data && list.data.notifications.length === 0 && ( +

{t('empty')}

+ )} +
    + {(list.data?.notifications ?? []).map((entry) => ( +
  • + +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/apps/web/src/pages/PageEditorPage.tsx b/apps/web/src/pages/PageEditorPage.tsx index 5662a89..297c630 100644 --- a/apps/web/src/pages/PageEditorPage.tsx +++ b/apps/web/src/pages/PageEditorPage.tsx @@ -73,7 +73,11 @@ function PageEditor({ const { user } = useAuth(); const navigate = useNavigate(); 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); // Created and destroyed within the same effect (not `useMemo` + a separate diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index 2b6a7b4..a44bd90 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -2534,3 +2534,86 @@ button { align-items: center; 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; +} diff --git a/packages/shared/i18n/de/notifications.json b/packages/shared/i18n/de/notifications.json new file mode 100644 index 0000000..7307d8a --- /dev/null +++ b/packages/shared/i18n/de/notifications.json @@ -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" + } +} diff --git a/packages/shared/i18n/en/notifications.json b/packages/shared/i18n/en/notifications.json new file mode 100644 index 0000000..fa5f6bc --- /dev/null +++ b/packages/shared/i18n/en/notifications.json @@ -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}}" + } +} diff --git a/packages/shared/src/collab-token.ts b/packages/shared/src/collab-token.ts index 13fba9b..2693d02 100644 --- a/packages/shared/src/collab-token.ts +++ b/packages/shared/src/collab-token.ts @@ -32,6 +32,21 @@ export const POND_ACCESS_CHANGED_CHANNEL = 'pond_access_changed'; */ 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}. */ export interface PageRestoreRequest { pageId: string; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 58c91d9..7d91c44 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -15,6 +15,7 @@ export * from './labels'; export * from './legal'; export * from './links'; export * from './members'; +export * from './notifications'; export * from './pages'; export * from './permissions'; export * from './plugins'; diff --git a/packages/shared/src/notifications.ts b/packages/shared/src/notifications.ts new file mode 100644 index 0000000..08b272f --- /dev/null +++ b/packages/shared/src/notifications.ts @@ -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; + +export interface NotificationListView { + notifications: NotificationView[]; + unreadCount: number; + page: number; + pageCount: number; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 226cdf6..5db0646 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,6 +80,9 @@ importers: nodemailer: specifier: ^9.0.3 version: 9.0.3 + pg: + specifier: ^8.22.0 + version: 8.22.0 pino: specifier: ^9.6.0 version: 9.14.0 @@ -144,6 +147,9 @@ importers: '@types/nodemailer': specifier: ^8.0.1 version: 8.0.1 + '@types/pg': + specifier: ^8.20.0 + version: 8.20.0 '@types/supertest': specifier: ^6.0.0 version: 6.0.3