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")
|
displayName String @map("display_name")
|
||||||
locale String @default("en")
|
locale String @default("en")
|
||||||
isSiteAdmin Boolean @default(false) @map("is_site_admin")
|
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)
|
status UserStatus @default(PENDING_VERIFICATION)
|
||||||
emailVerifiedAt DateTime? @map("email_verified_at")
|
emailVerifiedAt DateTime? @map("email_verified_at")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
@ -51,6 +54,7 @@ model User {
|
|||||||
conversionJobs ConversionJob[]
|
conversionJobs ConversionJob[]
|
||||||
auditEntries AuditEntry[]
|
auditEntries AuditEntry[]
|
||||||
comments Comment[]
|
comments Comment[]
|
||||||
|
watches Watch[]
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
@ -110,6 +114,29 @@ model Comment {
|
|||||||
@@map("comments")
|
@@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 {
|
enum PondType {
|
||||||
PERSONAL
|
PERSONAL
|
||||||
SHARED
|
SHARED
|
||||||
|
|||||||
@ -31,6 +31,7 @@ import { SettingsModule } from './settings/settings.module';
|
|||||||
import { SetupModule } from './setup/setup.module';
|
import { SetupModule } from './setup/setup.module';
|
||||||
import { TrashModule } from './trash/trash.module';
|
import { TrashModule } from './trash/trash.module';
|
||||||
import { UsersModule } from './users/users.module';
|
import { UsersModule } from './users/users.module';
|
||||||
|
import { WatchesModule } from './watches/watches.module';
|
||||||
import { VersionsModule } from './versions/versions.module';
|
import { VersionsModule } from './versions/versions.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@ -49,6 +50,7 @@ import { VersionsModule } from './versions/versions.module';
|
|||||||
PondsModule,
|
PondsModule,
|
||||||
PagesModule,
|
PagesModule,
|
||||||
CommentsModule,
|
CommentsModule,
|
||||||
|
WatchesModule,
|
||||||
FilesModule,
|
FilesModule,
|
||||||
TrashModule,
|
TrashModule,
|
||||||
CompactionModule,
|
CompactionModule,
|
||||||
|
|||||||
@ -40,6 +40,8 @@ export function toCurrentUser(user: User): CurrentUserShape {
|
|||||||
displayName: user.displayName,
|
displayName: user.displayName,
|
||||||
locale: user.locale === 'de' ? 'de' : 'en',
|
locale: user.locale === 'de' ? 'de' : 'en',
|
||||||
isSiteAdmin: user.isSiteAdmin,
|
isSiteAdmin: user.isSiteAdmin,
|
||||||
|
autoWatchOwnPages: user.autoWatchOwnPages,
|
||||||
|
autoWatchOnComment: user.autoWatchOnComment,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,12 +1,13 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { PermissionsModule } from '../permissions/permissions.module';
|
import { PermissionsModule } from '../permissions/permissions.module';
|
||||||
|
import { WatchesModule } from '../watches/watches.module';
|
||||||
|
|
||||||
import { CommentsController } from './comments.controller';
|
import { CommentsController } from './comments.controller';
|
||||||
import { CommentsService } from './comments.service';
|
import { CommentsService } from './comments.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PermissionsModule],
|
imports: [PermissionsModule, WatchesModule],
|
||||||
controllers: [CommentsController],
|
controllers: [CommentsController],
|
||||||
providers: [CommentsService],
|
providers: [CommentsService],
|
||||||
exports: [CommentsService],
|
exports: [CommentsService],
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import { Comment, Page, User } from '@prisma/client';
|
|||||||
|
|
||||||
import { PermissionService } from '../permissions/permission.service';
|
import { PermissionService } from '../permissions/permission.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { WatchesService } from '../watches/watches.service';
|
||||||
|
|
||||||
type CommentWithAuthor = Comment & {
|
type CommentWithAuthor = Comment & {
|
||||||
author: { id: string; username: string; displayName: string } | null;
|
author: { id: string; username: string; displayName: string } | null;
|
||||||
@ -36,6 +37,7 @@ export class CommentsService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly permissions: PermissionService,
|
private readonly permissions: PermissionService,
|
||||||
|
private readonly watches: WatchesService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Comments live on live pages only — trash hides them (ADR 0013). */
|
/** 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 } } },
|
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);
|
return CommentsService.viewOf(created as CommentWithAuthor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { PondsModule } from '../ponds/ponds.module';
|
import { PondsModule } from '../ponds/ponds.module';
|
||||||
|
import { WatchesModule } from '../watches/watches.module';
|
||||||
import { SearchModule } from '../search/search.module';
|
import { SearchModule } from '../search/search.module';
|
||||||
|
|
||||||
import { PagesController } from './pages.controller';
|
import { PagesController } from './pages.controller';
|
||||||
@ -8,7 +9,7 @@ import { PagesService } from './pages.service';
|
|||||||
import { PluginApiController } from './plugin-api.controller';
|
import { PluginApiController } from './plugin-api.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PondsModule, SearchModule],
|
imports: [PondsModule, SearchModule, WatchesModule],
|
||||||
controllers: [PagesController, PluginApiController],
|
controllers: [PagesController, PluginApiController],
|
||||||
providers: [PagesService],
|
providers: [PagesService],
|
||||||
exports: [PagesService],
|
exports: [PagesService],
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import { PinoLogger } from 'nestjs-pino';
|
|||||||
import { AppConfig } from '../config/app-config.service';
|
import { AppConfig } from '../config/app-config.service';
|
||||||
import { PermissionService } from '../permissions/permission.service';
|
import { PermissionService } from '../permissions/permission.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { WatchesService } from '../watches/watches.service';
|
||||||
import { SearchProvider } from '../search/search.provider';
|
import { SearchProvider } from '../search/search.provider';
|
||||||
import { evenlySpacedKeys, nextKeyOrRebalance } from './sort-key';
|
import { evenlySpacedKeys, nextKeyOrRebalance } from './sort-key';
|
||||||
import { deriveContent, DerivedPageContent, emptyPageState } from './yjs-content';
|
import { deriveContent, DerivedPageContent, emptyPageState } from './yjs-content';
|
||||||
@ -49,6 +50,7 @@ export class PagesService {
|
|||||||
private readonly logger: PinoLogger,
|
private readonly logger: PinoLogger,
|
||||||
private readonly config: AppConfig,
|
private readonly config: AppConfig,
|
||||||
private readonly search: SearchProvider,
|
private readonly search: SearchProvider,
|
||||||
|
private readonly watches: WatchesService,
|
||||||
) {
|
) {
|
||||||
this.logger.setContext(PagesService.name);
|
this.logger.setContext(PagesService.name);
|
||||||
}
|
}
|
||||||
@ -153,6 +155,8 @@ export class PagesService {
|
|||||||
|
|
||||||
async create(user: User, pondId: string, input: CreatePageInput): Promise<PageView> {
|
async create(user: User, pondId: string, input: CreatePageInput): Promise<PageView> {
|
||||||
const page = await this.insertPage(user, pondId, input.title, emptyPageState());
|
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);
|
return this.viewOf(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { FilesModule } from '../files/files.module';
|
|||||||
import { PagesModule } from '../pages/pages.module';
|
import { PagesModule } from '../pages/pages.module';
|
||||||
import { PondsModule } from '../ponds/ponds.module';
|
import { PondsModule } from '../ponds/ponds.module';
|
||||||
import { QuotasModule } from '../quotas/quotas.module';
|
import { QuotasModule } from '../quotas/quotas.module';
|
||||||
|
import { WatchesModule } from '../watches/watches.module';
|
||||||
import { SchedulerModule } from '../scheduler/scheduler.module';
|
import { SchedulerModule } from '../scheduler/scheduler.module';
|
||||||
import { SchedulerService } from '../scheduler/scheduler.service';
|
import { SchedulerService } from '../scheduler/scheduler.service';
|
||||||
|
|
||||||
@ -15,7 +16,15 @@ import { TrashService } from './trash.service';
|
|||||||
const TRASH_PURGE_CADENCE_SECONDS = 24 * 60 * 60;
|
const TRASH_PURGE_CADENCE_SECONDS = 24 * 60 * 60;
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [CommonModule, PondsModule, QuotasModule, FilesModule, PagesModule, SchedulerModule],
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
PondsModule,
|
||||||
|
QuotasModule,
|
||||||
|
FilesModule,
|
||||||
|
PagesModule,
|
||||||
|
SchedulerModule,
|
||||||
|
WatchesModule,
|
||||||
|
],
|
||||||
controllers: [TrashController],
|
controllers: [TrashController],
|
||||||
providers: [TrashService],
|
providers: [TrashService],
|
||||||
})
|
})
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { ClockService } from '../common/clock.service';
|
|||||||
import { PagesService } from '../pages/pages.service';
|
import { PagesService } from '../pages/pages.service';
|
||||||
import { PermissionService } from '../permissions/permission.service';
|
import { PermissionService } from '../permissions/permission.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { WatchesService } from '../watches/watches.service';
|
||||||
import { QuotaService } from '../quotas/quota.service';
|
import { QuotaService } from '../quotas/quota.service';
|
||||||
import { InstanceSettingsService } from '../settings/instance-settings.service';
|
import { InstanceSettingsService } from '../settings/instance-settings.service';
|
||||||
import { FileStorageService } from '../files/file-storage.service';
|
import { FileStorageService } from '../files/file-storage.service';
|
||||||
@ -30,6 +31,7 @@ export class TrashService {
|
|||||||
private readonly quotas: QuotaService,
|
private readonly quotas: QuotaService,
|
||||||
private readonly storage: FileStorageService,
|
private readonly storage: FileStorageService,
|
||||||
private readonly clock: ClockService,
|
private readonly clock: ClockService,
|
||||||
|
private readonly watches: WatchesService,
|
||||||
private readonly logger: PinoLogger,
|
private readonly logger: PinoLogger,
|
||||||
) {
|
) {
|
||||||
this.logger.setContext(TrashService.name);
|
this.logger.setContext(TrashService.name);
|
||||||
@ -102,6 +104,7 @@ export class TrashService {
|
|||||||
await this.prisma.attachment.deleteMany({ where: { pageId } });
|
await this.prisma.attachment.deleteMany({ where: { pageId } });
|
||||||
await this.prisma.pageContentCache.deleteMany({ where: { pageId } });
|
await this.prisma.pageContentCache.deleteMany({ where: { pageId } });
|
||||||
await this.prisma.pageUpdate.deleteMany({ where: { pageId } });
|
await this.prisma.pageUpdate.deleteMany({ where: { pageId } });
|
||||||
|
await this.watches.removeForPage(pageId);
|
||||||
await this.prisma.page.delete({ where: { id: pageId } });
|
await this.prisma.page.delete({ where: { id: pageId } });
|
||||||
this.logger.info({ pageId }, 'audit: page purged (retention)');
|
this.logger.info({ pageId }, 'audit: page purged (retention)');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -44,7 +44,12 @@ export class UsersController {
|
|||||||
@Patch()
|
@Patch()
|
||||||
async updateProfile(
|
async updateProfile(
|
||||||
@Body(new ZodValidationPipe(updateProfileInputSchema))
|
@Body(new ZodValidationPipe(updateProfileInputSchema))
|
||||||
input: { displayName?: string; locale?: 'de' | 'en' },
|
input: {
|
||||||
|
displayName?: string;
|
||||||
|
locale?: 'de' | 'en';
|
||||||
|
autoWatchOwnPages?: boolean;
|
||||||
|
autoWatchOnComment?: boolean;
|
||||||
|
},
|
||||||
@Req() request: AuthedRequest,
|
@Req() request: AuthedRequest,
|
||||||
): Promise<CurrentUserShape> {
|
): Promise<CurrentUserShape> {
|
||||||
const updated = await this.users.updateProfile(request.user!.id, input);
|
const updated = await this.users.updateProfile(request.user!.id, input);
|
||||||
|
|||||||
@ -94,7 +94,12 @@ export class UsersService {
|
|||||||
|
|
||||||
async updateProfile(
|
async updateProfile(
|
||||||
userId: string,
|
userId: string,
|
||||||
data: { displayName?: string; locale?: string },
|
data: {
|
||||||
|
displayName?: string;
|
||||||
|
locale?: string;
|
||||||
|
autoWatchOwnPages?: boolean;
|
||||||
|
autoWatchOnComment?: boolean;
|
||||||
|
},
|
||||||
): Promise<User> {
|
): Promise<User> {
|
||||||
return this.prisma.user.update({ where: { id: userId }, data });
|
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 deSetup from '@dorfteich/shared/i18n/de/setup.json';
|
||||||
import deSystem from '@dorfteich/shared/i18n/de/system.json';
|
import deSystem from '@dorfteich/shared/i18n/de/system.json';
|
||||||
import deUsers from '@dorfteich/shared/i18n/de/users.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 deSettings from '@dorfteich/shared/i18n/de/settings.json';
|
||||||
import enAccess from '@dorfteich/shared/i18n/en/access.json';
|
import enAccess from '@dorfteich/shared/i18n/en/access.json';
|
||||||
import enAuth from '@dorfteich/shared/i18n/en/auth.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 enSetup from '@dorfteich/shared/i18n/en/setup.json';
|
||||||
import enSystem from '@dorfteich/shared/i18n/en/system.json';
|
import enSystem from '@dorfteich/shared/i18n/en/system.json';
|
||||||
import enUsers from '@dorfteich/shared/i18n/en/users.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 enSettings from '@dorfteich/shared/i18n/en/settings.json';
|
||||||
import i18n from 'i18next';
|
import i18n from 'i18next';
|
||||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||||
@ -80,6 +82,7 @@ void i18n
|
|||||||
setup: enSetup,
|
setup: enSetup,
|
||||||
system: enSystem,
|
system: enSystem,
|
||||||
users: enUsers,
|
users: enUsers,
|
||||||
|
watches: enWatches,
|
||||||
},
|
},
|
||||||
de: {
|
de: {
|
||||||
common: deCommon,
|
common: deCommon,
|
||||||
@ -104,6 +107,7 @@ void i18n
|
|||||||
setup: deSetup,
|
setup: deSetup,
|
||||||
system: deSystem,
|
system: deSystem,
|
||||||
users: deUsers,
|
users: deUsers,
|
||||||
|
watches: deWatches,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultNS: 'common',
|
defaultNS: 'common',
|
||||||
|
|||||||
@ -28,6 +28,7 @@ import { useCollabProvider } from '../editor/use-collab-provider';
|
|||||||
import { WikilinkAutocomplete } from '../editor/WikilinkAutocomplete';
|
import { WikilinkAutocomplete } from '../editor/WikilinkAutocomplete';
|
||||||
import { WikilinkContext, makeWikilinkResolver } from '../editor/wikilink-context';
|
import { WikilinkContext, makeWikilinkResolver } from '../editor/wikilink-context';
|
||||||
import { useForceSidebarHidden } from '../layout/sidebar-chrome';
|
import { useForceSidebarHidden } from '../layout/sidebar-chrome';
|
||||||
|
import { WatchToggle } from '../watches/WatchToggle';
|
||||||
import { ApiError, apiDelete, apiGet, apiGetText, apiPatch } from '../lib/api';
|
import { ApiError, apiDelete, apiGet, apiGetText, apiPatch } from '../lib/api';
|
||||||
import { recallPage, rememberPage } from '../offline/page-cache';
|
import { recallPage, rememberPage } from '../offline/page-cache';
|
||||||
import { PluginBlockContext } from '../editor/plugin-block-context';
|
import { PluginBlockContext } from '../editor/plugin-block-context';
|
||||||
@ -429,6 +430,7 @@ export function PageEditorPage(): React.JSX.Element {
|
|||||||
onChange={(event) => setTitle(event.target.value)}
|
onChange={(event) => setTitle(event.target.value)}
|
||||||
onBlur={() => void saveTitle()}
|
onBlur={() => void saveTitle()}
|
||||||
/>
|
/>
|
||||||
|
<WatchToggle targetType="page" targetId={resolved.id} />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="button editor-page__mode-toggle"
|
className="button editor-page__mode-toggle"
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { useParams } from 'react-router-dom';
|
|||||||
|
|
||||||
import { useAuth } from '../auth/auth-context';
|
import { useAuth } from '../auth/auth-context';
|
||||||
import { CommentPolicySetting } from '../comments/CommentPolicySetting';
|
import { CommentPolicySetting } from '../comments/CommentPolicySetting';
|
||||||
|
import { WatchToggle } from '../watches/WatchToggle';
|
||||||
import { FormError } from '../components/forms';
|
import { FormError } from '../components/forms';
|
||||||
import { AppearanceManager } from '../fonts/AppearanceManager';
|
import { AppearanceManager } from '../fonts/AppearanceManager';
|
||||||
import { LabelManager } from '../labels/LabelManager';
|
import { LabelManager } from '../labels/LabelManager';
|
||||||
@ -48,7 +49,10 @@ export function PondSettingsPage(): React.JSX.Element {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pond-settings-page">
|
<div className="pond-settings-page">
|
||||||
|
<div className="pond-settings-page__header">
|
||||||
<h1>{pond.data.name}</h1>
|
<h1>{pond.data.name}</h1>
|
||||||
|
<WatchToggle targetType="pond" targetId={pond.data.id} />
|
||||||
|
</div>
|
||||||
<section>
|
<section>
|
||||||
<h2>{tMembers('title')}</h2>
|
<h2>{tMembers('title')}</h2>
|
||||||
<MemberManager pondId={pond.data.id} />
|
<MemberManager pondId={pond.data.id} />
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { useAuth } from '../auth/auth-context';
|
|||||||
import { Field, FormError, FormSuccess, applyFieldErrors } from '../components/forms';
|
import { Field, FormError, FormSuccess, applyFieldErrors } from '../components/forms';
|
||||||
import { useDataExport } from '../export/use-data-export';
|
import { useDataExport } from '../export/use-data-export';
|
||||||
import { apiDelete, apiGet, apiPatch, apiPost } from '../lib/api';
|
import { apiDelete, apiGet, apiPatch, apiPost } from '../lib/api';
|
||||||
|
import { WatchesSection } from '../watches/WatchesSection';
|
||||||
|
|
||||||
interface SessionView {
|
interface SessionView {
|
||||||
id: string;
|
id: string;
|
||||||
@ -26,6 +27,7 @@ export function SettingsPage(): React.JSX.Element {
|
|||||||
<ProfileSection />
|
<ProfileSection />
|
||||||
<PasswordSection />
|
<PasswordSection />
|
||||||
<SessionsSection />
|
<SessionsSection />
|
||||||
|
<WatchesSection />
|
||||||
<DataExportSection />
|
<DataExportSection />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@ -83,9 +85,19 @@ function ProfileSection(): React.JSX.Element {
|
|||||||
const [error, setError] = useState<unknown>(null);
|
const [error, setError] = useState<unknown>(null);
|
||||||
const [saved, setSaved] = useState(false);
|
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),
|
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) => {
|
const onSubmit = form.handleSubmit(async (input) => {
|
||||||
@ -123,6 +135,14 @@ function ProfileSection(): React.JSX.Element {
|
|||||||
<option value="en">{t('settings:profile.locales.en')}</option>
|
<option value="en">{t('settings:profile.locales.en')}</option>
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</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}>
|
<button type="submit" className="button" disabled={form.formState.isSubmitting}>
|
||||||
{t('settings:profile.save')}
|
{t('settings:profile.save')}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -2495,3 +2495,42 @@ button {
|
|||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
font-size: 0.8125rem;
|
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({
|
export const updateProfileInputSchema = z.object({
|
||||||
displayName: z.string().trim().min(1, 'validation.displayName.required').max(80).optional(),
|
displayName: z.string().trim().min(1, 'validation.displayName.required').max(80).optional(),
|
||||||
locale: z.enum(['de', 'en']).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({
|
export const changePasswordInputSchema = z.object({
|
||||||
currentPassword: z.string().min(1, 'validation.required'),
|
currentPassword: z.string().min(1, 'validation.required'),
|
||||||
@ -85,4 +88,6 @@ export interface CurrentUser {
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
locale: 'de' | 'en';
|
locale: 'de' | 'en';
|
||||||
isSiteAdmin: boolean;
|
isSiteAdmin: boolean;
|
||||||
|
autoWatchOwnPages: boolean;
|
||||||
|
autoWatchOnComment: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,3 +25,4 @@ export * from './system';
|
|||||||
export * from './ponds';
|
export * from './ponds';
|
||||||
export * from './quotas';
|
export * from './quotas';
|
||||||
export * from './text-diff';
|
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