From f4f27cbe7860efe0a1abe8331e52d6207237a501 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Sat, 11 Jul 2026 22:59:11 +0200 Subject: [PATCH] Add watches: follow pages and ponds with auto-watch preferences (#93) New watches table (polymorphic target, unique per user+target; page purge removes its rows via the trash service, and the list endpoint drops targets the user can no longer read). Endpoints: idempotent PUT/DELETE /watches/{page|pond}/:id gated by read access (404 hides the target), GET state for the header toggles, and GET /users/me/watches resolving names and links. Auto-watch hooks: creating a page and commenting subscribe the actor, each behind a new user preference (autoWatchOwnPages / autoWatchOnComment, default on) editable via the profile PATCH and surfaced as checkboxes in the settings. UI: watch toggle on the page header and the pond settings header, watch list with unwatch in the account settings; new watches i18n namespace (de+en). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1 --- .../20260711230000_watches/migration.sql | 23 ++ apps/api/prisma/schema.prisma | 27 +++ apps/api/src/app.module.ts | 2 + apps/api/src/auth/auth.guard.ts | 2 + apps/api/src/comments/comments.module.ts | 3 +- apps/api/src/comments/comments.service.ts | 5 + apps/api/src/pages/pages.module.ts | 3 +- apps/api/src/pages/pages.service.ts | 4 + apps/api/src/trash/trash.module.ts | 11 +- apps/api/src/trash/trash.service.ts | 3 + apps/api/src/users/users.controller.ts | 7 +- apps/api/src/users/users.service.ts | 7 +- apps/api/src/watches/watches.controller.ts | 59 +++++ apps/api/src/watches/watches.e2e.db.test.ts | 226 ++++++++++++++++++ apps/api/src/watches/watches.module.ts | 14 ++ apps/api/src/watches/watches.service.ts | 149 ++++++++++++ apps/web/src/i18n/index.ts | 4 + apps/web/src/pages/PageEditorPage.tsx | 2 + apps/web/src/pages/PondSettingsPage.tsx | 6 +- apps/web/src/pages/SettingsPage.tsx | 24 +- apps/web/src/styles/base.css | 39 +++ apps/web/src/watches/WatchToggle.tsx | 47 ++++ apps/web/src/watches/WatchesSection.tsx | 57 +++++ packages/shared/i18n/de/watches.json | 17 ++ packages/shared/i18n/en/watches.json | 17 ++ packages/shared/src/auth.ts | 5 + packages/shared/src/index.ts | 1 + packages/shared/src/watches.ts | 29 +++ 28 files changed, 785 insertions(+), 8 deletions(-) create mode 100644 apps/api/prisma/migrations/20260711230000_watches/migration.sql create mode 100644 apps/api/src/watches/watches.controller.ts create mode 100644 apps/api/src/watches/watches.e2e.db.test.ts create mode 100644 apps/api/src/watches/watches.module.ts create mode 100644 apps/api/src/watches/watches.service.ts create mode 100644 apps/web/src/watches/WatchToggle.tsx create mode 100644 apps/web/src/watches/WatchesSection.tsx create mode 100644 packages/shared/i18n/de/watches.json create mode 100644 packages/shared/i18n/en/watches.json create mode 100644 packages/shared/src/watches.ts diff --git a/apps/api/prisma/migrations/20260711230000_watches/migration.sql b/apps/api/prisma/migrations/20260711230000_watches/migration.sql new file mode 100644 index 0000000..d8f3aa5 --- /dev/null +++ b/apps/api/prisma/migrations/20260711230000_watches/migration.sql @@ -0,0 +1,23 @@ +-- Watches + auto-watch preferences (issue #93). + +ALTER TABLE "users" ADD COLUMN "auto_watch_own_pages" BOOLEAN NOT NULL DEFAULT true; +ALTER TABLE "users" ADD COLUMN "auto_watch_on_comment" BOOLEAN NOT NULL DEFAULT true; + +CREATE TYPE "WatchTargetType" AS ENUM ('PAGE', 'POND'); + +CREATE TABLE "watches" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "target_type" "WatchTargetType" NOT NULL, + "target_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "watches_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "watches_user_id_target_type_target_id_key" + ON "watches"("user_id", "target_type", "target_id"); +CREATE INDEX "watches_target_type_target_id_idx" ON "watches"("target_type", "target_id"); + +ALTER TABLE "watches" ADD CONSTRAINT "watches_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 dbf602c..c49ceec 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -37,6 +37,9 @@ model User { displayName String @map("display_name") locale String @default("en") isSiteAdmin Boolean @default(false) @map("is_site_admin") + /// Auto-watch preferences (issue #93): watch pages I create / comment on. + autoWatchOwnPages Boolean @default(true) @map("auto_watch_own_pages") + autoWatchOnComment Boolean @default(true) @map("auto_watch_on_comment") status UserStatus @default(PENDING_VERIFICATION) emailVerifiedAt DateTime? @map("email_verified_at") createdAt DateTime @default(now()) @map("created_at") @@ -51,6 +54,7 @@ model User { conversionJobs ConversionJob[] auditEntries AuditEntry[] comments Comment[] + watches Watch[] @@map("users") } @@ -110,6 +114,29 @@ model Comment { @@map("comments") } +enum WatchTargetType { + PAGE + POND +} + +/// Explicit subscription (issue #93, data-model.md §watches): the target is +/// polymorphic (no FK) — page purge removes its watches via the trash +/// service, and the list endpoint filters targets the user can no longer +/// read, so stale rows are invisible and harmless. +model Watch { + id String @id @default(uuid()) + userId String @map("user_id") + targetType WatchTargetType @map("target_type") + targetId String @map("target_id") + createdAt DateTime @default(now()) @map("created_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, targetType, targetId]) + @@index([targetType, targetId]) + @@map("watches") +} + enum PondType { PERSONAL SHARED diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 55dfc1f..726bfdd 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 { WatchesModule } from './watches/watches.module'; import { VersionsModule } from './versions/versions.module'; @Module({ @@ -49,6 +50,7 @@ import { VersionsModule } from './versions/versions.module'; PondsModule, PagesModule, CommentsModule, + WatchesModule, FilesModule, TrashModule, CompactionModule, diff --git a/apps/api/src/auth/auth.guard.ts b/apps/api/src/auth/auth.guard.ts index 80ba2e5..b6c87a0 100644 --- a/apps/api/src/auth/auth.guard.ts +++ b/apps/api/src/auth/auth.guard.ts @@ -40,6 +40,8 @@ export function toCurrentUser(user: User): CurrentUserShape { displayName: user.displayName, locale: user.locale === 'de' ? 'de' : 'en', isSiteAdmin: user.isSiteAdmin, + autoWatchOwnPages: user.autoWatchOwnPages, + autoWatchOnComment: user.autoWatchOnComment, }; } diff --git a/apps/api/src/comments/comments.module.ts b/apps/api/src/comments/comments.module.ts index b14e90b..ded8b12 100644 --- a/apps/api/src/comments/comments.module.ts +++ b/apps/api/src/comments/comments.module.ts @@ -1,12 +1,13 @@ import { Module } from '@nestjs/common'; import { PermissionsModule } from '../permissions/permissions.module'; +import { WatchesModule } from '../watches/watches.module'; import { CommentsController } from './comments.controller'; import { CommentsService } from './comments.service'; @Module({ - imports: [PermissionsModule], + imports: [PermissionsModule, WatchesModule], 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 11ae88e..934eb05 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 { WatchesService } from '../watches/watches.service'; type CommentWithAuthor = Comment & { author: { id: string; username: string; displayName: string } | null; @@ -36,6 +37,7 @@ export class CommentsService { constructor( private readonly prisma: PrismaService, private readonly permissions: PermissionService, + private readonly watches: WatchesService, ) {} /** Comments live on live pages only — trash hides them (ADR 0013). */ @@ -141,6 +143,9 @@ export class CommentsService { }, include: { author: { select: { id: true, username: true, displayName: true } } }, }); + // 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(() => {}); return CommentsService.viewOf(created as CommentWithAuthor); } diff --git a/apps/api/src/pages/pages.module.ts b/apps/api/src/pages/pages.module.ts index 371d8bb..b6d5a6e 100644 --- a/apps/api/src/pages/pages.module.ts +++ b/apps/api/src/pages/pages.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { PondsModule } from '../ponds/ponds.module'; +import { WatchesModule } from '../watches/watches.module'; import { SearchModule } from '../search/search.module'; import { PagesController } from './pages.controller'; @@ -8,7 +9,7 @@ import { PagesService } from './pages.service'; import { PluginApiController } from './plugin-api.controller'; @Module({ - imports: [PondsModule, SearchModule], + imports: [PondsModule, SearchModule, WatchesModule], controllers: [PagesController, PluginApiController], providers: [PagesService], exports: [PagesService], diff --git a/apps/api/src/pages/pages.service.ts b/apps/api/src/pages/pages.service.ts index 04370b1..ddc3df6 100644 --- a/apps/api/src/pages/pages.service.ts +++ b/apps/api/src/pages/pages.service.ts @@ -21,6 +21,7 @@ import { PinoLogger } from 'nestjs-pino'; import { AppConfig } from '../config/app-config.service'; import { PermissionService } from '../permissions/permission.service'; import { PrismaService } from '../prisma/prisma.service'; +import { WatchesService } from '../watches/watches.service'; import { SearchProvider } from '../search/search.provider'; import { evenlySpacedKeys, nextKeyOrRebalance } from './sort-key'; import { deriveContent, DerivedPageContent, emptyPageState } from './yjs-content'; @@ -49,6 +50,7 @@ export class PagesService { private readonly logger: PinoLogger, private readonly config: AppConfig, private readonly search: SearchProvider, + private readonly watches: WatchesService, ) { this.logger.setContext(PagesService.name); } @@ -153,6 +155,8 @@ export class PagesService { async create(user: User, pondId: string, input: CreatePageInput): Promise { const page = await this.insertPage(user, pondId, input.title, emptyPageState()); + // Auto-watch own pages (issue #93) — preference-gated, never fatal. + await this.watches.autoWatchPage(user, page.id, 'ownPage').catch(() => {}); return this.viewOf(page); } diff --git a/apps/api/src/trash/trash.module.ts b/apps/api/src/trash/trash.module.ts index 9110e3c..da4637d 100644 --- a/apps/api/src/trash/trash.module.ts +++ b/apps/api/src/trash/trash.module.ts @@ -5,6 +5,7 @@ import { FilesModule } from '../files/files.module'; import { PagesModule } from '../pages/pages.module'; import { PondsModule } from '../ponds/ponds.module'; import { QuotasModule } from '../quotas/quotas.module'; +import { WatchesModule } from '../watches/watches.module'; import { SchedulerModule } from '../scheduler/scheduler.module'; import { SchedulerService } from '../scheduler/scheduler.service'; @@ -15,7 +16,15 @@ import { TrashService } from './trash.service'; const TRASH_PURGE_CADENCE_SECONDS = 24 * 60 * 60; @Module({ - imports: [CommonModule, PondsModule, QuotasModule, FilesModule, PagesModule, SchedulerModule], + imports: [ + CommonModule, + PondsModule, + QuotasModule, + FilesModule, + PagesModule, + SchedulerModule, + WatchesModule, + ], controllers: [TrashController], providers: [TrashService], }) diff --git a/apps/api/src/trash/trash.service.ts b/apps/api/src/trash/trash.service.ts index 40355e9..d4faddc 100644 --- a/apps/api/src/trash/trash.service.ts +++ b/apps/api/src/trash/trash.service.ts @@ -7,6 +7,7 @@ import { ClockService } from '../common/clock.service'; import { PagesService } from '../pages/pages.service'; import { PermissionService } from '../permissions/permission.service'; import { PrismaService } from '../prisma/prisma.service'; +import { WatchesService } from '../watches/watches.service'; import { QuotaService } from '../quotas/quota.service'; import { InstanceSettingsService } from '../settings/instance-settings.service'; import { FileStorageService } from '../files/file-storage.service'; @@ -30,6 +31,7 @@ export class TrashService { private readonly quotas: QuotaService, private readonly storage: FileStorageService, private readonly clock: ClockService, + private readonly watches: WatchesService, private readonly logger: PinoLogger, ) { this.logger.setContext(TrashService.name); @@ -102,6 +104,7 @@ export class TrashService { await this.prisma.attachment.deleteMany({ where: { pageId } }); await this.prisma.pageContentCache.deleteMany({ where: { pageId } }); await this.prisma.pageUpdate.deleteMany({ where: { pageId } }); + await this.watches.removeForPage(pageId); await this.prisma.page.delete({ where: { id: pageId } }); this.logger.info({ pageId }, 'audit: page purged (retention)'); } diff --git a/apps/api/src/users/users.controller.ts b/apps/api/src/users/users.controller.ts index 506cb4c..a8f7efc 100644 --- a/apps/api/src/users/users.controller.ts +++ b/apps/api/src/users/users.controller.ts @@ -44,7 +44,12 @@ export class UsersController { @Patch() async updateProfile( @Body(new ZodValidationPipe(updateProfileInputSchema)) - input: { displayName?: string; locale?: 'de' | 'en' }, + input: { + displayName?: string; + locale?: 'de' | 'en'; + autoWatchOwnPages?: boolean; + autoWatchOnComment?: boolean; + }, @Req() request: AuthedRequest, ): Promise { const updated = await this.users.updateProfile(request.user!.id, input); diff --git a/apps/api/src/users/users.service.ts b/apps/api/src/users/users.service.ts index df1f47b..79d7a2b 100644 --- a/apps/api/src/users/users.service.ts +++ b/apps/api/src/users/users.service.ts @@ -94,7 +94,12 @@ export class UsersService { async updateProfile( userId: string, - data: { displayName?: string; locale?: string }, + data: { + displayName?: string; + locale?: string; + autoWatchOwnPages?: boolean; + autoWatchOnComment?: boolean; + }, ): Promise { return this.prisma.user.update({ where: { id: userId }, data }); } diff --git a/apps/api/src/watches/watches.controller.ts b/apps/api/src/watches/watches.controller.ts new file mode 100644 index 0000000..c227141 --- /dev/null +++ b/apps/api/src/watches/watches.controller.ts @@ -0,0 +1,59 @@ +import { Controller, Delete, Get, Param, Put, Req } from '@nestjs/common'; +import { + WATCH_TARGET_TYPES, + type WatchListView, + type WatchStateView, + type WatchTargetType, +} from '@dorfteich/shared'; +import { NotFoundException } from '@nestjs/common'; + +import { AuthedRequest } from '../auth/auth.guard'; +import { AuthenticatedOnly } from '../permissions/permission.decorators'; +import { WatchesService } from './watches.service'; + +function asTargetType(value: string): WatchTargetType { + if (!(WATCH_TARGET_TYPES as readonly string[]).includes(value)) throw new NotFoundException(); + return value as WatchTargetType; +} + +/** Watch/unwatch pages and ponds + the account's watch list (issue #93). */ +@Controller() +export class WatchesController { + constructor(private readonly watches: WatchesService) {} + + @Get('users/me/watches') + @AuthenticatedOnly() + async list(@Req() request: AuthedRequest): Promise { + return this.watches.listOwn(request.user!); + } + + @Get('watches/:targetType/:id') + @AuthenticatedOnly() + async state( + @Param('targetType') targetType: string, + @Param('id') id: string, + @Req() request: AuthedRequest, + ): Promise { + return this.watches.state(request.user!, asTargetType(targetType), id); + } + + @Put('watches/:targetType/:id') + @AuthenticatedOnly() + async watch( + @Param('targetType') targetType: string, + @Param('id') id: string, + @Req() request: AuthedRequest, + ): Promise { + return this.watches.watch(request.user!, asTargetType(targetType), id); + } + + @Delete('watches/:targetType/:id') + @AuthenticatedOnly() + async unwatch( + @Param('targetType') targetType: string, + @Param('id') id: string, + @Req() request: AuthedRequest, + ): Promise { + return this.watches.unwatch(request.user!, asTargetType(targetType), id); + } +} diff --git a/apps/api/src/watches/watches.e2e.db.test.ts b/apps/api/src/watches/watches.e2e.db.test.ts new file mode 100644 index 0000000..63cffd8 --- /dev/null +++ b/apps/api/src/watches/watches.e2e.db.test.ts @@ -0,0 +1,226 @@ +import { INestApplication } from '@nestjs/common'; +import type { WatchListView, WatchStateView } from '@dorfteich/shared'; +import { PrismaClient, User } from '@prisma/client'; +import request from 'supertest'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { createTestApp, sessionCookieOf } from '../testing/test-app'; +import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; +import { UsersService } from '../users/users.service'; + +/** + * Watches end to end (issue #93): per-user round-trip, read-gated targets, + * preference-gated auto-watch for own pages and comments, the settings + * list with unwatch for both target types, and purge cleanup. + */ +describe.skipIf(!hasTestDb)('watches (e2e, issue #93)', () => { + let app: INestApplication; + let prisma: PrismaClient; + const suffix = uniqueSuffix(); + const password = 'beobachten heisst kuemmern 1'; + const users: Record = {}; + const cookies: Record = {}; + let pondId: string; + + const api = () => request(app.getHttpServer()); + + async function makeUser(handle: string): Promise { + const service = app.get(UsersService); + const username = `wa-${handle}-${suffix}`; + const user = await service.createUser({ + username, + email: `${username}@example.org`, + displayName: `Wa ${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 createPage(cookie: string, title: string): Promise { + const res = await api() + .post(`/api/v1/ponds/${pondId}/pages`) + .set('Cookie', cookie) + .send({ title }) + .expect(201); + return (res.body as { id: string }).id; + } + + beforeAll(async () => { + prisma = createTestPrisma(); + await prisma.rateLimit.deleteMany({}); + app = await createTestApp(); + for (const handle of ['owner', 'member', 'outsider']) await makeUser(handle); + + const pond = await prisma.pond.create({ + data: { + slug: `wa-pond-${suffix}`, + name: 'Watch Pond', + type: 'SHARED', + ownerId: users.owner!.id, + }, + }); + pondId = pond.id; + for (const [handle, role] of [ + ['owner', 'POND_ADMIN'], + ['member', 'EDITOR'], + ] as const) { + await prisma.roleGrant.create({ + data: { + pondId, + subjectType: 'USER', + subjectId: users[handle]!.id, + role, + scopeType: 'POND', + effect: 'ALLOW', + createdBy: users.owner!.id, + }, + }); + } + }); + + afterAll(async () => { + const ids = Object.values(users).map((u) => u.id); + await prisma.watch.deleteMany({ where: { userId: { in: ids } } }); + await prisma.comment.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('round-trips watch state per user for pages and ponds', async () => { + const pageId = await createPage(cookies.owner!, 'Watched page'); + // Page creation auto-watched it for the owner; drop that to test manually. + await api().delete(`/api/v1/watches/page/${pageId}`).set('Cookie', cookies.owner!).expect(200); + + await api().put(`/api/v1/watches/page/${pageId}`).set('Cookie', cookies.member!).expect(200); + await api().put(`/api/v1/watches/pond/${pondId}`).set('Cookie', cookies.member!).expect(200); + + const memberState = ( + await api().get(`/api/v1/watches/page/${pageId}`).set('Cookie', cookies.member!).expect(200) + ).body as WatchStateView; + expect(memberState.watched).toBe(true); + // Per-user: the owner is not subscribed just because the member is. + const ownerState = ( + await api().get(`/api/v1/watches/page/${pageId}`).set('Cookie', cookies.owner!).expect(200) + ).body as WatchStateView; + expect(ownerState.watched).toBe(false); + + // Watching needs read access: the outsider gets a 404, never a row. + await api().put(`/api/v1/watches/page/${pageId}`).set('Cookie', cookies.outsider!).expect(404); + await api().put(`/api/v1/watches/pond/${pondId}`).set('Cookie', cookies.outsider!).expect(404); + }); + + it('auto-watches own pages and commented pages, gated by the preferences', async () => { + // Default preferences: creating a page subscribes its author … + const created = await createPage(cookies.member!, 'Auto watched'); + expect( + ( + (await api() + .get(`/api/v1/watches/page/${created}`) + .set('Cookie', cookies.member!) + .expect(200)) as { body: WatchStateView } + ).body.watched, + ).toBe(true); + + // … and commenting subscribes the commenter. + await api() + .post(`/api/v1/pages/${created}/comments`) + .set('Cookie', cookies.owner!) + .send({ body: 'watching this now' }) + .expect(201); + expect( + ( + (await api() + .get(`/api/v1/watches/page/${created}`) + .set('Cookie', cookies.owner!) + .expect(200)) as { body: WatchStateView } + ).body.watched, + ).toBe(true); + + // Disabling the preferences stops both behaviors. + await api() + .patch('/api/v1/users/me') + .set('Cookie', cookies.member!) + .send({ autoWatchOwnPages: false, autoWatchOnComment: false }) + .expect(200); + const second = await createPage(cookies.member!, 'Not auto watched'); + expect( + ( + (await api() + .get(`/api/v1/watches/page/${second}`) + .set('Cookie', cookies.member!) + .expect(200)) as { body: WatchStateView } + ).body.watched, + ).toBe(false); + await api() + .post(`/api/v1/pages/${second}/comments`) + .set('Cookie', cookies.member!) + .send({ body: 'no subscription please' }) + .expect(201); + expect( + ( + (await api() + .get(`/api/v1/watches/page/${second}`) + .set('Cookie', cookies.member!) + .expect(200)) as { body: WatchStateView } + ).body.watched, + ).toBe(false); + }); + + it('lists own watches with names and unwatches both types from settings', async () => { + const pageId = await createPage(cookies.owner!, 'Listed page'); + await api().put(`/api/v1/watches/page/${pageId}`).set('Cookie', cookies.member!).expect(200); + await api().put(`/api/v1/watches/pond/${pondId}`).set('Cookie', cookies.member!).expect(200); + + const list = ( + await api().get('/api/v1/users/me/watches').set('Cookie', cookies.member!).expect(200) + ).body as WatchListView; + const pageEntry = list.watches.find((w) => w.targetId === pageId); + const pondEntry = list.watches.find((w) => w.targetId === pondId); + expect(pageEntry).toMatchObject({ targetType: 'page', name: 'Listed page' }); + expect(pageEntry?.slug).toBeTruthy(); + expect(pondEntry).toMatchObject({ targetType: 'pond', name: 'Watch Pond', slug: null }); + + // Unwatch both types (the settings list's action). + await api().delete(`/api/v1/watches/page/${pageId}`).set('Cookie', cookies.member!).expect(200); + await api().delete(`/api/v1/watches/pond/${pondId}`).set('Cookie', cookies.member!).expect(200); + const after = ( + await api().get('/api/v1/users/me/watches').set('Cookie', cookies.member!).expect(200) + ).body as WatchListView; + expect(after.watches.find((w) => w.targetId === pageId)).toBeUndefined(); + expect(after.watches.find((w) => w.targetId === pondId)).toBeUndefined(); + }); + + it('drops unreadable targets from the list and cleans up on purge', async () => { + const pageId = await createPage(cookies.owner!, 'Vanishing page'); + await api().put(`/api/v1/watches/page/${pageId}`).set('Cookie', cookies.member!).expect(200); + + // Trash hides it from the list (target no longer live) … + await prisma.page.update({ + where: { id: pageId }, + data: { deletedAt: new Date(), deletedBy: users.owner!.id }, + }); + const list = ( + await api().get('/api/v1/users/me/watches').set('Cookie', cookies.member!).expect(200) + ).body as WatchListView; + expect(list.watches.find((w) => w.targetId === pageId)).toBeUndefined(); + + // … and purge removes the rows entirely (trash service hook). + await api().delete(`/api/v1/pages/${pageId}/purge`).set('Cookie', cookies.owner!).expect(204); + expect(await prisma.watch.count({ where: { targetId: pageId } })).toBe(0); + }); +}); diff --git a/apps/api/src/watches/watches.module.ts b/apps/api/src/watches/watches.module.ts new file mode 100644 index 0000000..b528a0b --- /dev/null +++ b/apps/api/src/watches/watches.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; + +import { PermissionsModule } from '../permissions/permissions.module'; + +import { WatchesController } from './watches.controller'; +import { WatchesService } from './watches.service'; + +@Module({ + imports: [PermissionsModule], + controllers: [WatchesController], + providers: [WatchesService], + exports: [WatchesService], +}) +export class WatchesModule {} diff --git a/apps/api/src/watches/watches.service.ts b/apps/api/src/watches/watches.service.ts new file mode 100644 index 0000000..94f47de --- /dev/null +++ b/apps/api/src/watches/watches.service.ts @@ -0,0 +1,149 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import type { WatchListView, WatchStateView, WatchTargetType, WatchView } from '@dorfteich/shared'; +import { User, WatchTargetType as DbTargetType } from '@prisma/client'; + +import { PermissionService } from '../permissions/permission.service'; +import { PrismaService } from '../prisma/prisma.service'; + +const dbType = (targetType: WatchTargetType): DbTargetType => + targetType === 'page' ? 'PAGE' : 'POND'; + +/** + * Explicit page/pond subscriptions (issue #93) — the input side of the + * notification model (#94). Watching needs read access to the target (404 + * hides what the user cannot see, issue #60); both watch and unwatch are + * idempotent. The list resolves current names and silently drops targets + * that vanished or became unreadable — stale rows are harmless. + */ +@Injectable() +export class WatchesService { + constructor( + private readonly prisma: PrismaService, + private readonly permissions: PermissionService, + ) {} + + /** Read access to the (live) target, or 404. */ + private async assertReadable( + user: User, + targetType: WatchTargetType, + targetId: string, + ): Promise { + if (targetType === 'page') { + const page = await this.prisma.page.findFirst({ + where: { id: targetId, deletedAt: null }, + select: { id: true, pondId: true }, + }); + if (!page || !(await this.permissions.canAccessPage(user, page, 'read'))) { + throw new NotFoundException(); + } + return; + } + const pond = await this.prisma.pond.findFirst({ where: { id: targetId, deletedAt: null } }); + if (!pond || !(await this.permissions.canSeePond(user, targetId))) { + throw new NotFoundException(); + } + } + + async watch(user: User, targetType: WatchTargetType, targetId: string): Promise { + await this.assertReadable(user, targetType, targetId); + await this.upsert(user.id, targetType, targetId); + return { watched: true }; + } + + async unwatch( + user: User, + targetType: WatchTargetType, + targetId: string, + ): Promise { + await this.prisma.watch.deleteMany({ + where: { userId: user.id, targetType: dbType(targetType), targetId }, + }); + return { watched: false }; + } + + async state(user: User, targetType: WatchTargetType, targetId: string): Promise { + const existing = await this.prisma.watch.findFirst({ + where: { userId: user.id, targetType: dbType(targetType), targetId }, + }); + return { watched: existing !== null }; + } + + /** + * Auto-watch hook (issue #93): creating a page or commenting subscribes + * the actor — each behind its user preference. Fire-and-forget semantics: + * a failure here must never break the page/comment write. + */ + async autoWatchPage(user: User, pageId: string, kind: 'ownPage' | 'comment'): Promise { + const enabled = kind === 'ownPage' ? user.autoWatchOwnPages : user.autoWatchOnComment; + if (!enabled) return; + await this.upsert(user.id, 'page', pageId); + } + + private async upsert( + userId: string, + targetType: WatchTargetType, + targetId: string, + ): Promise { + await this.prisma.watch.upsert({ + where: { + userId_targetType_targetId: { userId, targetType: dbType(targetType), targetId }, + }, + create: { userId, targetType: dbType(targetType), targetId }, + update: {}, + }); + } + + /** The account-settings list: resolved names, unreadable targets dropped. */ + async listOwn(user: User): Promise { + const rows = await this.prisma.watch.findMany({ + where: { userId: user.id }, + orderBy: { createdAt: 'desc' }, + }); + + const watches: WatchView[] = []; + for (const row of rows) { + if (row.targetType === 'PAGE') { + const page = await this.prisma.page.findFirst({ + where: { id: row.targetId, deletedAt: null }, + select: { id: true, pondId: true, title: true, slug: true }, + }); + if (!page || !(await this.permissions.canAccessPage(user, page, 'read'))) continue; + const pond = await this.prisma.pond.findFirst({ + where: { id: page.pondId, deletedAt: null }, + select: { slug: true }, + }); + if (!pond) continue; + watches.push({ + id: row.id, + targetType: 'page', + targetId: row.targetId, + name: page.title, + pondSlug: pond.slug, + slug: page.slug, + createdAt: row.createdAt.toISOString(), + }); + } else { + const pond = await this.prisma.pond.findFirst({ + where: { id: row.targetId, deletedAt: null }, + select: { name: true, slug: true }, + }); + if (!pond || !(await this.permissions.canSeePond(user, row.targetId))) continue; + watches.push({ + id: row.id, + targetType: 'pond', + targetId: row.targetId, + name: pond.name, + pondSlug: pond.slug, + slug: null, + createdAt: row.createdAt.toISOString(), + }); + } + } + return { watches }; + } + + /** Page purge (trash retention or manual) removes its watches. */ + async removeForPage(pageId: string): Promise { + await this.prisma.watch.deleteMany({ where: { targetType: 'PAGE', targetId: pageId } }); + } +} diff --git a/apps/web/src/i18n/index.ts b/apps/web/src/i18n/index.ts index 893da5d..21b0b9a 100644 --- a/apps/web/src/i18n/index.ts +++ b/apps/web/src/i18n/index.ts @@ -19,6 +19,7 @@ import deSearch from '@dorfteich/shared/i18n/de/search.json'; import deSetup from '@dorfteich/shared/i18n/de/setup.json'; import deSystem from '@dorfteich/shared/i18n/de/system.json'; import deUsers from '@dorfteich/shared/i18n/de/users.json'; +import deWatches from '@dorfteich/shared/i18n/de/watches.json'; import deSettings from '@dorfteich/shared/i18n/de/settings.json'; import enAccess from '@dorfteich/shared/i18n/en/access.json'; import enAuth from '@dorfteich/shared/i18n/en/auth.json'; @@ -41,6 +42,7 @@ import enSearch from '@dorfteich/shared/i18n/en/search.json'; import enSetup from '@dorfteich/shared/i18n/en/setup.json'; import enSystem from '@dorfteich/shared/i18n/en/system.json'; import enUsers from '@dorfteich/shared/i18n/en/users.json'; +import enWatches from '@dorfteich/shared/i18n/en/watches.json'; import enSettings from '@dorfteich/shared/i18n/en/settings.json'; import i18n from 'i18next'; import LanguageDetector from 'i18next-browser-languagedetector'; @@ -80,6 +82,7 @@ void i18n setup: enSetup, system: enSystem, users: enUsers, + watches: enWatches, }, de: { common: deCommon, @@ -104,6 +107,7 @@ void i18n setup: deSetup, system: deSystem, users: deUsers, + watches: deWatches, }, }, defaultNS: 'common', diff --git a/apps/web/src/pages/PageEditorPage.tsx b/apps/web/src/pages/PageEditorPage.tsx index b2af922..5662a89 100644 --- a/apps/web/src/pages/PageEditorPage.tsx +++ b/apps/web/src/pages/PageEditorPage.tsx @@ -28,6 +28,7 @@ import { useCollabProvider } from '../editor/use-collab-provider'; import { WikilinkAutocomplete } from '../editor/WikilinkAutocomplete'; import { WikilinkContext, makeWikilinkResolver } from '../editor/wikilink-context'; import { useForceSidebarHidden } from '../layout/sidebar-chrome'; +import { WatchToggle } from '../watches/WatchToggle'; import { ApiError, apiDelete, apiGet, apiGetText, apiPatch } from '../lib/api'; import { recallPage, rememberPage } from '../offline/page-cache'; import { PluginBlockContext } from '../editor/plugin-block-context'; @@ -429,6 +430,7 @@ export function PageEditorPage(): React.JSX.Element { onChange={(event) => setTitle(event.target.value)} onBlur={() => void saveTitle()} /> + diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index 094e076..2b6a7b4 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -2495,3 +2495,42 @@ button { color: var(--color-text-muted); font-size: 0.8125rem; } + +/* Watches (issue #93) */ +.watch-toggle--active { + background: var(--color-primary, #2f6f4f); + color: #fff; +} + +.watches-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.watches-list li { + display: flex; + align-items: center; + gap: var(--space-3); +} + +.watches-list__type { + color: var(--color-text-muted); + font-size: 0.8125rem; +} + +.settings-checkbox { + display: flex; + align-items: center; + gap: var(--space-2); + margin: var(--space-2) 0; +} + +.pond-settings-page__header { + display: flex; + align-items: center; + gap: var(--space-3); +} diff --git a/apps/web/src/watches/WatchToggle.tsx b/apps/web/src/watches/WatchToggle.tsx new file mode 100644 index 0000000..7f0f026 --- /dev/null +++ b/apps/web/src/watches/WatchToggle.tsx @@ -0,0 +1,47 @@ +import type { WatchStateView, WatchTargetType } from '@dorfteich/shared'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; + +import { apiDelete, apiGet, apiPut } from '../lib/api'; + +/** + * Watch/unwatch button for page and pond headers (issue #93). The state is + * per-user (server-side); toggling updates optimistically via refetch. + */ +export function WatchToggle({ + targetType, + targetId, +}: { + targetType: WatchTargetType; + targetId: string; +}): React.JSX.Element { + const { t } = useTranslation('watches'); + const queryClient = useQueryClient(); + const key = ['watch', targetType, targetId]; + + const state = useQuery({ + queryKey: key, + queryFn: () => apiGet(`/watches/${targetType}/${targetId}`), + }); + + const toggle = async (): Promise => { + const watched = state.data?.watched ?? false; + if (watched) await apiDelete(`/watches/${targetType}/${targetId}`); + else await apiPut(`/watches/${targetType}/${targetId}`, {}); + await queryClient.invalidateQueries({ queryKey: key }); + await queryClient.invalidateQueries({ queryKey: ['watches'] }); + }; + + const watched = state.data?.watched ?? false; + return ( + + ); +} diff --git a/apps/web/src/watches/WatchesSection.tsx b/apps/web/src/watches/WatchesSection.tsx new file mode 100644 index 0000000..f556fcf --- /dev/null +++ b/apps/web/src/watches/WatchesSection.tsx @@ -0,0 +1,57 @@ +import type { WatchListView } from '@dorfteich/shared'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; + +import { apiDelete, apiGet } from '../lib/api'; + +/** + * The account's watch list (issue #93): everything the user follows, with + * links to the targets and an unwatch action per entry. + */ +export function WatchesSection(): React.JSX.Element { + const { t } = useTranslation('watches'); + const queryClient = useQueryClient(); + const list = useQuery({ + queryKey: ['watches'], + queryFn: () => apiGet('/users/me/watches'), + }); + + const unwatch = async (targetType: string, targetId: string): Promise => { + await apiDelete(`/watches/${targetType}/${targetId}`); + await queryClient.invalidateQueries({ queryKey: ['watches'] }); + await queryClient.invalidateQueries({ queryKey: ['watch', targetType, targetId] }); + }; + + return ( +
+

{t('settings.title')}

+ {list.data && list.data.watches.length === 0 &&

{t('settings.empty')}

} + {list.data && list.data.watches.length > 0 && ( +
    + {list.data.watches.map((watch) => ( +
  • + + {watch.name} + + {t(`settings.types.${watch.targetType}`)} + +
  • + ))} +
+ )} +
+ ); +} diff --git a/packages/shared/i18n/de/watches.json b/packages/shared/i18n/de/watches.json new file mode 100644 index 0000000..61403e3 --- /dev/null +++ b/packages/shared/i18n/de/watches.json @@ -0,0 +1,17 @@ +{ + "watch": "Beobachten", + "watching": "Beobachtet", + "settings": { + "title": "Beobachtete Seiten und Teiche", + "empty": "Du beobachtest noch nichts — nutze den Beobachten-Button auf einer Seite oder einem Teich.", + "unwatch": "Nicht mehr beobachten", + "types": { + "page": "Seite", + "pond": "Teich" + } + }, + "prefs": { + "autoWatchOwnPages": "Seiten, die ich anlege, automatisch beobachten", + "autoWatchOnComment": "Seiten, die ich kommentiere, automatisch beobachten" + } +} diff --git a/packages/shared/i18n/en/watches.json b/packages/shared/i18n/en/watches.json new file mode 100644 index 0000000..c9eff95 --- /dev/null +++ b/packages/shared/i18n/en/watches.json @@ -0,0 +1,17 @@ +{ + "watch": "Watch", + "watching": "Watching", + "settings": { + "title": "Watched pages and ponds", + "empty": "You are not watching anything yet — use the Watch button on a page or pond.", + "unwatch": "Unwatch", + "types": { + "page": "Page", + "pond": "Pond" + } + }, + "prefs": { + "autoWatchOwnPages": "Automatically watch pages I create", + "autoWatchOnComment": "Automatically watch pages I comment on" + } +} diff --git a/packages/shared/src/auth.ts b/packages/shared/src/auth.ts index 333fb78..57648ef 100644 --- a/packages/shared/src/auth.ts +++ b/packages/shared/src/auth.ts @@ -71,6 +71,9 @@ export const resetPasswordFormSchema = z.object({ password: passwordSchema }); export const updateProfileInputSchema = z.object({ displayName: z.string().trim().min(1, 'validation.displayName.required').max(80).optional(), locale: z.enum(['de', 'en']).optional(), + /** Auto-watch preferences (issue #93). */ + autoWatchOwnPages: z.boolean().optional(), + autoWatchOnComment: z.boolean().optional(), }); export const changePasswordInputSchema = z.object({ currentPassword: z.string().min(1, 'validation.required'), @@ -85,4 +88,6 @@ export interface CurrentUser { displayName: string; locale: 'de' | 'en'; isSiteAdmin: boolean; + autoWatchOwnPages: boolean; + autoWatchOnComment: boolean; } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 356c593..58c91d9 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -25,3 +25,4 @@ export * from './system'; export * from './ponds'; export * from './quotas'; export * from './text-diff'; +export * from './watches'; diff --git a/packages/shared/src/watches.ts b/packages/shared/src/watches.ts new file mode 100644 index 0000000..bb185a6 --- /dev/null +++ b/packages/shared/src/watches.ts @@ -0,0 +1,29 @@ +/** + * Watches (issue #93, data-model.md §watches): explicit per-user + * subscriptions to pages or whole ponds — the input side of the + * notification model (#94). + */ + +export const WATCH_TARGET_TYPES = ['page', 'pond'] as const; +export type WatchTargetType = (typeof WATCH_TARGET_TYPES)[number]; + +export interface WatchView { + id: string; + targetType: WatchTargetType; + targetId: string; + /** Page title or pond name. */ + name: string; + /** Link target: `/p/` for ponds, `/p//` for pages. */ + pondSlug: string; + slug: string | null; + createdAt: string; +} + +export interface WatchListView { + watches: WatchView[]; +} + +/** The toggle state the page/pond headers need. */ +export interface WatchStateView { + watched: boolean; +}