Add watches: follow pages and ponds with auto-watch preferences (#93)
All checks were successful
CI / Lint, typecheck, test (push) Successful in 3m23s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m47s
CD / Deploy to Test (push) Successful in 11s
CD / Smoke tests against Test (push) Successful in 1m14s
CD / Promote to Int (push) Successful in 10s
CI / Auth e2e pack (push) Successful in 5m28s
CI / Import/export fidelity gate (push) Successful in 46s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
This commit is contained in:
Claude Fable 5 2026-07-11 22:59:11 +02:00
parent 30992a2e6d
commit f4f27cbe78
28 changed files with 785 additions and 8 deletions

View File

@ -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;

View File

@ -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

View File

@ -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,

View File

@ -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,
};
}

View File

@ -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],

View File

@ -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);
}

View File

@ -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],

View File

@ -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<PageView> {
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);
}

View File

@ -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],
})

View File

@ -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)');
}

View File

@ -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<CurrentUserShape> {
const updated = await this.users.updateProfile(request.user!.id, input);

View File

@ -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<User> {
return this.prisma.user.update({ where: { id: userId }, data });
}

View File

@ -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<WatchListView> {
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<WatchStateView> {
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<WatchStateView> {
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<WatchStateView> {
return this.watches.unwatch(request.user!, asTargetType(targetType), id);
}
}

View File

@ -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<string, User> = {};
const cookies: Record<string, string> = {};
let pondId: string;
const api = () => request(app.getHttpServer());
async function makeUser(handle: string): Promise<void> {
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<string> {
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);
});
});

View File

@ -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 {}

View File

@ -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<void> {
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<WatchStateView> {
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<WatchStateView> {
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<WatchStateView> {
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<void> {
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<void> {
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<WatchListView> {
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<void> {
await this.prisma.watch.deleteMany({ where: { targetType: 'PAGE', targetId: pageId } });
}
}

View File

@ -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',

View File

@ -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()}
/>
<WatchToggle targetType="page" targetId={resolved.id} />
<button
type="button"
className="button editor-page__mode-toggle"

View File

@ -5,6 +5,7 @@ import { useParams } from 'react-router-dom';
import { useAuth } from '../auth/auth-context';
import { CommentPolicySetting } from '../comments/CommentPolicySetting';
import { WatchToggle } from '../watches/WatchToggle';
import { FormError } from '../components/forms';
import { AppearanceManager } from '../fonts/AppearanceManager';
import { LabelManager } from '../labels/LabelManager';
@ -48,7 +49,10 @@ export function PondSettingsPage(): React.JSX.Element {
return (
<div className="pond-settings-page">
<h1>{pond.data.name}</h1>
<div className="pond-settings-page__header">
<h1>{pond.data.name}</h1>
<WatchToggle targetType="pond" targetId={pond.data.id} />
</div>
<section>
<h2>{tMembers('title')}</h2>
<MemberManager pondId={pond.data.id} />

View File

@ -9,6 +9,7 @@ import { useAuth } from '../auth/auth-context';
import { Field, FormError, FormSuccess, applyFieldErrors } from '../components/forms';
import { useDataExport } from '../export/use-data-export';
import { apiDelete, apiGet, apiPatch, apiPost } from '../lib/api';
import { WatchesSection } from '../watches/WatchesSection';
interface SessionView {
id: string;
@ -26,6 +27,7 @@ export function SettingsPage(): React.JSX.Element {
<ProfileSection />
<PasswordSection />
<SessionsSection />
<WatchesSection />
<DataExportSection />
</>
);
@ -83,9 +85,19 @@ function ProfileSection(): React.JSX.Element {
const [error, setError] = useState<unknown>(null);
const [saved, setSaved] = useState(false);
const form = useForm<{ displayName?: string; locale?: 'de' | 'en' }>({
const form = useForm<{
displayName?: string;
locale?: 'de' | 'en';
autoWatchOwnPages?: boolean;
autoWatchOnComment?: boolean;
}>({
resolver: zodResolver(updateProfileInputSchema),
values: { displayName: user?.displayName, locale: user?.locale },
values: {
displayName: user?.displayName,
locale: user?.locale,
autoWatchOwnPages: user?.autoWatchOwnPages,
autoWatchOnComment: user?.autoWatchOnComment,
},
});
const onSubmit = form.handleSubmit(async (input) => {
@ -123,6 +135,14 @@ function ProfileSection(): React.JSX.Element {
<option value="en">{t('settings:profile.locales.en')}</option>
</select>
</Field>
<label className="settings-checkbox">
<input type="checkbox" {...form.register('autoWatchOwnPages')} />
{t('watches:prefs.autoWatchOwnPages')}
</label>
<label className="settings-checkbox">
<input type="checkbox" {...form.register('autoWatchOnComment')} />
{t('watches:prefs.autoWatchOnComment')}
</label>
<button type="submit" className="button" disabled={form.formState.isSubmitting}>
{t('settings:profile.save')}
</button>

View File

@ -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);
}

View File

@ -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<WatchStateView>(`/watches/${targetType}/${targetId}`),
});
const toggle = async (): Promise<void> => {
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 (
<button
type="button"
className={`button watch-toggle${watched ? ' watch-toggle--active' : ''}`}
aria-pressed={watched}
disabled={state.isLoading}
onClick={() => void toggle()}
>
{watched ? t('watching') : t('watch')}
</button>
);
}

View File

@ -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<WatchListView>('/users/me/watches'),
});
const unwatch = async (targetType: string, targetId: string): Promise<void> => {
await apiDelete(`/watches/${targetType}/${targetId}`);
await queryClient.invalidateQueries({ queryKey: ['watches'] });
await queryClient.invalidateQueries({ queryKey: ['watch', targetType, targetId] });
};
return (
<section className="settings-section">
<h2>{t('settings.title')}</h2>
{list.data && list.data.watches.length === 0 && <p>{t('settings.empty')}</p>}
{list.data && list.data.watches.length > 0 && (
<ul className="watches-list">
{list.data.watches.map((watch) => (
<li key={watch.id}>
<Link
to={
watch.targetType === 'pond'
? `/p/${watch.pondSlug}`
: `/p/${watch.pondSlug}/${watch.slug}`
}
>
{watch.name}
</Link>
<span className="watches-list__type">{t(`settings.types.${watch.targetType}`)}</span>
<button
type="button"
className="button"
onClick={() => void unwatch(watch.targetType, watch.targetId)}
>
{t('settings.unwatch')}
</button>
</li>
))}
</ul>
)}
</section>
);
}

View File

@ -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"
}
}

View File

@ -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"
}
}

View File

@ -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;
}

View File

@ -25,3 +25,4 @@ export * from './system';
export * from './ponds';
export * from './quotas';
export * from './text-diff';
export * from './watches';

View File

@ -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/<pondSlug>` for ponds, `/p/<pondSlug>/<slug>` 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;
}