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
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:
parent
30992a2e6d
commit
f4f27cbe78
@ -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;
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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],
|
||||
})
|
||||
|
||||
@ -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)');
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
59
apps/api/src/watches/watches.controller.ts
Normal file
59
apps/api/src/watches/watches.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
226
apps/api/src/watches/watches.e2e.db.test.ts
Normal file
226
apps/api/src/watches/watches.e2e.db.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
14
apps/api/src/watches/watches.module.ts
Normal file
14
apps/api/src/watches/watches.module.ts
Normal 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 {}
|
||||
149
apps/api/src/watches/watches.service.ts
Normal file
149
apps/api/src/watches/watches.service.ts
Normal 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 } });
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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">
|
||||
<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} />
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
47
apps/web/src/watches/WatchToggle.tsx
Normal file
47
apps/web/src/watches/WatchToggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
apps/web/src/watches/WatchesSection.tsx
Normal file
57
apps/web/src/watches/WatchesSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
packages/shared/i18n/de/watches.json
Normal file
17
packages/shared/i18n/de/watches.json
Normal 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"
|
||||
}
|
||||
}
|
||||
17
packages/shared/i18n/en/watches.json
Normal file
17
packages/shared/i18n/en/watches.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -25,3 +25,4 @@ export * from './system';
|
||||
export * from './ponds';
|
||||
export * from './quotas';
|
||||
export * from './text-diff';
|
||||
export * from './watches';
|
||||
|
||||
29
packages/shared/src/watches.ts
Normal file
29
packages/shared/src/watches.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user