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
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:
parent
f4f27cbe78
commit
67fb01fe2b
@ -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",
|
||||||
|
|||||||
@ -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;
|
||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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],
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
43
apps/api/src/notifications/notifications.controller.ts
Normal file
43
apps/api/src/notifications/notifications.controller.ts
Normal 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!);
|
||||||
|
}
|
||||||
|
}
|
||||||
197
apps/api/src/notifications/notifications.e2e.db.test.ts
Normal file
197
apps/api/src/notifications/notifications.e2e.db.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
15
apps/api/src/notifications/notifications.module.ts
Normal file
15
apps/api/src/notifications/notifications.module.ts
Normal 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 {}
|
||||||
152
apps/api/src/notifications/notifications.service.ts
Normal file
152
apps/api/src/notifications/notifications.service.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
82
apps/api/src/notifications/version-event-listener.service.ts
Normal file
82
apps/api/src/notifications/version-event-listener.service.ts
Normal 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 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<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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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],
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
101
apps/web/src/notifications/NotificationsBell.tsx
Normal file
101
apps/web/src/notifications/NotificationsBell.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
10
packages/shared/i18n/de/notifications.json
Normal file
10
packages/shared/i18n/de/notifications.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
10
packages/shared/i18n/en/notifications.json
Normal file
10
packages/shared/i18n/en/notifications.json
Normal 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}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
43
packages/shared/src/notifications.ts
Normal file
43
packages/shared/src/notifications.ts
Normal 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
6
pnpm-lock.yaml
generated
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user