From 3f7190ebcca831cb1ff4c1e9a40d02a63d0844f5 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Mon, 20 Jul 2026 01:03:16 +0200 Subject: [PATCH] =?UTF-8?q?#153:=20Stabile=20Task-IDs=20+=20Toggle-R=C3=BC?= =?UTF-8?q?ckschreibpfad=20=C3=BCber=20den=20Collab-Server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit task_item bekommt ein optionales id-Attr (default null — Bestandsdocs bleiben gültig), durchgereicht in toDOM/parseDOM und dem Lese-HTML; der Editor vergibt/entdoppelt IDs lazy per appendTransaction (TaskItemIds-Extension, auch gegen Copy/Paste). Neuer Kanal TASK_TOGGLE_CHANNEL; POST /pages/:id/tasks/:taskId {checked} prüft Schreibrecht, registriert den Toggler als pending contributor und feuert pg_notify; neuer collab task-toggle-listener (Struktur = restore-listener) öffnet eine DirectConnection und flippt das checked-Attribut in einer Transaktion — offene Editoren konvergieren, unbekannte taskId = geloggter No-op. DB-Test (NOTIFY-Payload, Attribution, 403/404/400). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC --- apps/api/src/pages/pages.controller.ts | 17 ++ apps/api/src/pages/pages.service.ts | 20 +++ apps/api/src/pages/task-toggle.e2e.db.test.ts | 154 +++++++++++++++++ apps/collab/src/index.ts | 11 ++ apps/collab/src/task-toggle-listener.ts | 156 ++++++++++++++++++ apps/web/src/editor/document-extensions.ts | 2 + apps/web/src/editor/task-item-ids.ts | 48 ++++++ packages/shared/src/collab-token.ts | 19 +++ packages/shared/src/editor-schema/html.ts | 3 +- packages/shared/src/editor-schema/schema.ts | 30 +++- packages/shared/src/pages.ts | 4 + 11 files changed, 457 insertions(+), 7 deletions(-) create mode 100644 apps/api/src/pages/task-toggle.e2e.db.test.ts create mode 100644 apps/collab/src/task-toggle-listener.ts create mode 100644 apps/web/src/editor/task-item-ids.ts diff --git a/apps/api/src/pages/pages.controller.ts b/apps/api/src/pages/pages.controller.ts index e1f2449..c3d2196 100644 --- a/apps/api/src/pages/pages.controller.ts +++ b/apps/api/src/pages/pages.controller.ts @@ -26,7 +26,9 @@ import { pageDeleteQuerySchema, pageListQuerySchema, repositionPageInputSchema, + toggleTaskInputSchema, updatePageInputSchema, + type ToggleTaskInput, } from '@dorfteich/shared'; import type { Response } from 'express'; @@ -76,6 +78,21 @@ export class PagesController { return this.pages.getState(request.user!, id); } + /** Toggles one task-list checkbox (issue #153). Applied asynchronously + * through the collab server, so open editors converge — callers toggle + * optimistically and refetch. */ + @Post('pages/:id/tasks/:taskId') + @RequiresPagePermission('write', { idParam: 'id' }) + @HttpCode(202) + async toggleTask( + @Param('id') id: string, + @Param('taskId') taskId: string, + @Body(new ZodValidationPipe(toggleTaskInputSchema)) input: ToggleTaskInput, + @Req() request: AuthedRequest, + ): Promise { + await this.pages.toggleTask(request.user!, id, taskId, input.checked); + } + /** * Short-lived collaboration token for the collab server (issue #34). * `@Public()` so an anonymous visitor to a public page can obtain a token diff --git a/apps/api/src/pages/pages.service.ts b/apps/api/src/pages/pages.service.ts index f118813..85f3865 100644 --- a/apps/api/src/pages/pages.service.ts +++ b/apps/api/src/pages/pages.service.ts @@ -17,6 +17,8 @@ import { PluginPageSummary, RepositionPageInput, SidebarSortMode, + TASK_TOGGLE_CHANNEL, + TaskToggleRequest, TreeItem, UpdatePageInput, collectSubtreeIds, @@ -110,6 +112,24 @@ export class PagesService { return page; } + /** + * Toggles one task-list checkbox (issue #153). The collab server owns the + * live document, so this only records the toggler as a pending contributor + * (version attribution) and emits the NOTIFY — the listener applies the + * attribute change as a normal edit and every open client converges. + */ + async toggleTask(user: User, pageId: string, taskId: string, checked: boolean): Promise { + await this.findLivePage(pageId); + await this.prisma.pagePendingContributor.upsert({ + where: { pageId_userId: { pageId, userId: user.id } }, + update: {}, + create: { pageId, userId: user.id }, + }); + const payload: TaskToggleRequest = { pageId, taskId, checked, userId: user.id }; + await this.prisma + .$executeRaw`SELECT pg_notify(${TASK_TOGGLE_CHANNEL}, ${JSON.stringify(payload)})`; + } + /** The live `{id, parentId}` skeleton of a pond — input to the tree walks * (issue #106). Trashed pages keep their `parentId` but never count here. */ private async livePageTree(pondId: string): Promise { diff --git a/apps/api/src/pages/task-toggle.e2e.db.test.ts b/apps/api/src/pages/task-toggle.e2e.db.test.ts new file mode 100644 index 0000000..9066d40 --- /dev/null +++ b/apps/api/src/pages/task-toggle.e2e.db.test.ts @@ -0,0 +1,154 @@ +import { INestApplication } from '@nestjs/common'; +import { TASK_TOGGLE_CHANNEL, TaskToggleRequest } from '@dorfteich/shared'; +import { PrismaClient } from '@prisma/client'; +import { Client } from 'pg'; +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'; + +/** + * Task-toggle request path (issue #153): the api checks write permission, + * records the toggler as a pending contributor, and emits the NOTIFY the + * collab server consumes. The Yjs application itself lives in the collab + * listener (verified through the collab e2e stack). + */ +describe.skipIf(!hasTestDb)('task toggle endpoint (e2e, issue #153)', () => { + let app: INestApplication; + let prisma: PrismaClient; + const suffix = uniqueSuffix(); + const password = 'aufgaben sind erledigt 1'; + + let editorId: string; + let editorCookie: string; + let readerCookie: string; + let pageId: string; + + const notifies: TaskToggleRequest[] = []; + let listenClient: Client; + + const api = () => request(app.getHttpServer()); + + beforeAll(async () => { + prisma = createTestPrisma(); + app = await createTestApp(); + const users = app.get(UsersService); + + const mkUser = async (handle: string) => { + const username = `task-${handle}-${suffix}`; + const user = await users.createUser({ + username, + email: `${username}@example.test`, + displayName: `Task ${handle}`, + password, + locale: 'en', + }); + await users.markEmailVerified(user.id); + const cookie = sessionCookieOf( + await api() + .post('/api/v1/auth/login') + .send({ usernameOrEmail: username, password }) + .expect(200), + ); + return { id: user.id, cookie }; + }; + const editor = await mkUser('editor'); + editorId = editor.id; + editorCookie = editor.cookie; + const reader = await mkUser('reader'); + readerCookie = reader.cookie; + + const pond = await prisma.pond.create({ + data: { slug: `task-pond-${suffix}`, name: 'Task Pond', type: 'SHARED', ownerId: editorId }, + }); + const page = await prisma.page.create({ + data: { + pondId: pond.id, + slug: `tasks-${suffix}`, + title: 'Tasks', + createdBy: editorId, + sortKey: 'a0', + ydocState: new Uint8Array(), + }, + }); + pageId = page.id; + for (const [userId, role] of [ + [editorId, 'EDITOR'], + [reader.id, 'READER'], + ] as const) { + await prisma.roleGrant.create({ + data: { + pondId: pond.id, + subjectType: 'USER', + subjectId: userId, + role, + scopeType: 'POND', + scopeId: null, + effect: 'ALLOW', + createdBy: editorId, + }, + }); + } + + listenClient = new Client({ connectionString: process.env.TEST_DATABASE_URL }); + await listenClient.connect(); + listenClient.on('notification', (message) => { + if (message.channel === TASK_TOGGLE_CHANNEL && message.payload) { + notifies.push(JSON.parse(message.payload) as TaskToggleRequest); + } + }); + await listenClient.query(`LISTEN ${TASK_TOGGLE_CHANNEL}`); + }); + + afterAll(async () => { + await listenClient.end().catch(() => undefined); + await prisma.pagePendingContributor.deleteMany({ where: { pageId } }); + await prisma.roleGrant.deleteMany({ where: { pond: { ownerId: editorId } } }); + await prisma.page.deleteMany({ where: { pond: { ownerId: editorId } } }); + await prisma.pond.deleteMany({ where: { ownerId: editorId } }); + await prisma.session.deleteMany({ where: { user: { username: { contains: suffix } } } }); + await prisma.user.deleteMany({ where: { username: { contains: suffix } } }); + await prisma.$disconnect(); + await app.close(); + }); + + it('emits the toggle NOTIFY and records the pending contributor', async () => { + await api() + .post(`/api/v1/pages/${pageId}/tasks/abc123defg`) + .set('Cookie', editorCookie) + .send({ checked: true }) + .expect(202); + + await expect.poll(() => notifies.length, { timeout: 5000 }).toBeGreaterThan(0); + expect(notifies[0]).toEqual({ + pageId, + taskId: 'abc123defg', + checked: true, + userId: editorId, + }); + + const pending = await prisma.pagePendingContributor.findMany({ where: { pageId } }); + expect(pending.map((row) => row.userId)).toContain(editorId); + }); + + it('refuses read-only users and hides unknown pages', async () => { + // A reader may see the page, so the write refusal is a 403 (#60). + await api() + .post(`/api/v1/pages/${pageId}/tasks/abc123defg`) + .set('Cookie', readerCookie) + .send({ checked: true }) + .expect(403); + await api() + .post(`/api/v1/pages/00000000-0000-4000-8000-000000000000/tasks/x`) + .set('Cookie', editorCookie) + .send({ checked: true }) + .expect(404); + await api() + .post(`/api/v1/pages/${pageId}/tasks/abc123defg`) + .set('Cookie', editorCookie) + .send({ checked: 'yes' }) + .expect(400); + }); +}); diff --git a/apps/collab/src/index.ts b/apps/collab/src/index.ts index c65f527..201f379 100644 --- a/apps/collab/src/index.ts +++ b/apps/collab/src/index.ts @@ -7,6 +7,7 @@ import { createLogger } from './logger.js'; import { createMaintenanceListener, type MaintenanceListener } from './maintenance-listener.js'; import { PostgresPagePersistence } from './persistence.js'; import { createRestoreListener } from './restore-listener.js'; +import { createTaskToggleListener } from './task-toggle-listener.js'; import { closeDocumentConnections, createCollabServer } from './server.js'; import { PostgresSessionRegistry } from './session-registry.js'; import { PostgresVersionStore } from './version-store.js'; @@ -75,9 +76,18 @@ async function bootstrap(): Promise { logger, }); + // Applies api-requested task-checkbox toggles (issue #153). + const taskToggleListener = createTaskToggleListener({ + createClient: () => new Client({ connectionString: env.DATABASE_URL }), + openDirectConnection: (documentName) => + server.hocuspocus.openDirectConnection(documentName, { userId: 'task-toggle', mode: 'rw' }), + logger, + }); + await server.listen(env.PORT); await accessListener.start(); await restoreListener.start(); + await taskToggleListener.start(); await maintenanceListener.start(); sessionRegistry.start(() => [...server.hocuspocus.documents.keys()]); logger.info({ event: 'listen', port: env.PORT }, 'collaboration server listening'); @@ -88,6 +98,7 @@ async function bootstrap(): Promise { void Promise.allSettled([ accessListener.stop(), restoreListener.stop(), + taskToggleListener.stop(), maintenanceListener.stop(), server.destroy(), pool.end(), diff --git a/apps/collab/src/task-toggle-listener.ts b/apps/collab/src/task-toggle-listener.ts new file mode 100644 index 0000000..22a1ad1 --- /dev/null +++ b/apps/collab/src/task-toggle-listener.ts @@ -0,0 +1,156 @@ +import { TASK_TOGGLE_CHANNEL, TaskToggleRequest } from '@dorfteich/shared'; +import type { Client } from 'pg'; +import type { Logger } from 'pino'; +import * as Y from 'yjs'; + +import type { DirectDocumentConnection } from './restore-listener.js'; + +export interface TaskToggleListenerDeps { + /** Dedicated `LISTEN` connection factory (connection-bound, not pooled). */ + createClient: () => Client; + /** Opens a server-side connection to a document so edits broadcast + persist. */ + openDirectConnection: (documentName: string) => Promise; + logger: Logger; + reconnectDelayMs?: number; +} + +export interface TaskToggleListener { + start(): Promise; + stop(): Promise; +} + +const DEFAULT_RECONNECT_DELAY_MS = 1000; +const FRAGMENT_NAME = 'default'; + +/** Depth-first search for the task item carrying the wanted stable id. */ +function findTaskItem(fragment: Y.XmlFragment, taskId: string): Y.XmlElement | null { + let found: Y.XmlElement | null = null; + const walk = (element: Y.XmlElement | Y.XmlFragment): void => { + for (const child of element.toArray()) { + if (found) return; + if (child instanceof Y.XmlElement) { + if (child.nodeName === 'task_item' && child.getAttribute('id') === taskId) { + found = child; + return; + } + walk(child); + } + } + }; + walk(fragment); + return found; +} + +/** + * Applies task-checkbox toggles requested by the api (issue #153). The api + * checks write permission and emits a {@link TASK_TOGGLE_CHANNEL} + * notification; this listener owns the live document, opens a direct + * connection (loading the document if nobody has it open) and flips the + * `checked` attribute in a transaction — a normal edit that Hocuspocus + * broadcasts to all clients and persists. An unknown task id is a warn-level + * no-op (the source line may have been deleted meanwhile). + */ +export function createTaskToggleListener(deps: TaskToggleListenerDeps): TaskToggleListener { + const reconnectDelayMs = deps.reconnectDelayMs ?? DEFAULT_RECONNECT_DELAY_MS; + let client: Client | null = null; + let stopped = false; + let reconnectTimer: NodeJS.Timeout | null = null; + + async function toggle(request: TaskToggleRequest): Promise { + const { pageId, taskId, checked, userId } = request; + const connection = await deps.openDirectConnection(pageId); + try { + let applied = false; + await connection.transact((doc) => { + const item = findTaskItem(doc.getXmlFragment(FRAGMENT_NAME), taskId); + if (!item) return; + item.setAttribute('checked', checked as unknown as string); + applied = true; + }); + if (applied) { + deps.logger.info( + { event: 'task_toggle.applied', pageId, taskId, checked, userId }, + 'toggled task item', + ); + } else { + deps.logger.warn( + { event: 'task_toggle.target_missing', pageId, taskId }, + 'task item not found; toggle skipped', + ); + } + } finally { + await connection.disconnect(); + } + } + + function scheduleReconnect(): void { + if (stopped || reconnectTimer) return; + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + void connect(); + }, reconnectDelayMs); + reconnectTimer.unref?.(); + } + + async function connect(): Promise { + if (stopped) return; + const next = deps.createClient(); + next.on('error', (error) => { + deps.logger.warn( + { event: 'task_toggle.listen.error', err: error.message }, + 'task toggle listener connection error; will reconnect', + ); + if (client === next) client = null; + scheduleReconnect(); + }); + next.on('notification', (message) => { + if (message.channel !== TASK_TOGGLE_CHANNEL || !message.payload) return; + let request: TaskToggleRequest; + try { + request = JSON.parse(message.payload) as TaskToggleRequest; + } catch { + return; + } + void toggle(request).catch((error: unknown) => { + deps.logger.error( + { event: 'task_toggle.failed', err: (error as Error).message }, + 'failed to apply task toggle', + ); + }); + }); + + try { + await next.connect(); + await next.query(`LISTEN ${TASK_TOGGLE_CHANNEL}`); + client = next; + deps.logger.info( + { event: 'task_toggle.listen.ready', channel: TASK_TOGGLE_CHANNEL }, + 'listening for task toggle requests', + ); + } catch (error) { + deps.logger.warn( + { event: 'task_toggle.listen.connect_failed', err: (error as Error).message }, + 'could not start task toggle listener; will retry', + ); + await next.end().catch(() => undefined); + scheduleReconnect(); + } + } + + return { + async start(): Promise { + stopped = false; + await connect(); + }, + async stop(): Promise { + stopped = true; + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + const current = client; + client = null; + if (current) await current.end().catch(() => undefined); + }, + }; +} diff --git a/apps/web/src/editor/document-extensions.ts b/apps/web/src/editor/document-extensions.ts index 46e2fbc..52ab57e 100644 --- a/apps/web/src/editor/document-extensions.ts +++ b/apps/web/src/editor/document-extensions.ts @@ -8,6 +8,7 @@ import { PluginBlock } from './nodes/plugin-block'; import { Table, TableCell, TableHeader, TableRow } from './nodes/table'; import { TaskItem } from './nodes/task-item'; import { DateMarker } from './nodes/date-marker'; +import { TaskItemIds } from './task-item-ids'; import { Mention } from './nodes/mention'; import { Transclusion } from './nodes/transclusion'; import { Wikilink } from './nodes/wikilink'; @@ -48,6 +49,7 @@ export const documentExtensions: AnyExtension[] = [ Wikilink, Mention, DateMarker, + TaskItemIds, Transclusion, Table, TableRow, diff --git a/apps/web/src/editor/task-item-ids.ts b/apps/web/src/editor/task-item-ids.ts new file mode 100644 index 0000000..449972e --- /dev/null +++ b/apps/web/src/editor/task-item-ids.ts @@ -0,0 +1,48 @@ +import { Extension } from '@tiptap/core'; +import { Plugin, PluginKey } from '@tiptap/pm/state'; + +/** ~10 URL-safe random chars — plenty for per-document uniqueness. */ +function freshTaskId(): string { + const bytes = new Uint8Array(8); + crypto.getRandomValues(bytes); + return Array.from(bytes, (byte) => (byte % 36).toString(36)).join(''); +} + +/** + * Lazily assigns stable ids to task items (issue #153): every `task_item` + * without an id — and every duplicate created by copy/paste — gets a fresh + * one in an appended transaction. Runs through the normal editing pipeline, + * so ids replicate via the collaboration document like any other change and + * never conflict. Existing documents pick up ids the next time they are + * opened for editing. + */ +export const TaskItemIds = Extension.create({ + name: 'taskItemIds', + addProseMirrorPlugins() { + return [ + new Plugin({ + key: new PluginKey('taskItemIds'), + appendTransaction: (transactions, _oldState, newState) => { + if (!transactions.some((tr) => tr.docChanged)) return null; + const seen = new Set(); + let tr = null as ReturnType | null; + newState.doc.descendants((node, pos) => { + if (node.type.name !== 'task_item') return; + const id = node.attrs.id as string | null; + if (id && !seen.has(id)) { + seen.add(id); + return; + } + const next = freshTaskId(); + seen.add(next); + tr = (tr ?? newState.tr).setNodeMarkup(pos, undefined, { + ...node.attrs, + id: next, + }); + }); + return tr; + }, + }), + ]; + }, +}); diff --git a/packages/shared/src/collab-token.ts b/packages/shared/src/collab-token.ts index 2693d02..a81fd8b 100644 --- a/packages/shared/src/collab-token.ts +++ b/packages/shared/src/collab-token.ts @@ -47,6 +47,25 @@ export interface PageVersionCreatedEvent { contributorIds: string[]; } +/** + * PostgreSQL `NOTIFY` channel over which the api asks the collab server to + * toggle a single task-list checkbox (issue #153). The api has already + * checked write permission; the collab server owns the live document and + * applies the attribute change as a normal edit, so every open client + * converges. Payload is a JSON {@link TaskToggleRequest}. + */ +export const TASK_TOGGLE_CHANNEL = 'task_toggle'; + +/** JSON payload carried on {@link TASK_TOGGLE_CHANNEL}. */ +export interface TaskToggleRequest { + pageId: string; + /** The task item's stable `id` attribute (issue #153). */ + taskId: string; + checked: boolean; + /** The user who toggled — recorded as a pending contributor. */ + userId: string; +} + /** JSON payload carried on {@link PAGE_RESTORE_CHANNEL}. */ export interface PageRestoreRequest { pageId: string; diff --git a/packages/shared/src/editor-schema/html.ts b/packages/shared/src/editor-schema/html.ts index 40d3fd9..44f63bf 100644 --- a/packages/shared/src/editor-schema/html.ts +++ b/packages/shared/src/editor-schema/html.ts @@ -94,7 +94,8 @@ function renderListItems(node: Node): string { node.forEach((item) => { if (item.type.name === 'task_item') { const checked = item.attrs.checked === true; - out += `
  • ${renderBlocks(item)}
  • `; + const id = item.attrs.id ? ` data-task-id="${escapeHtml(item.attrs.id as string)}"` : ''; + out += `
  • ${renderBlocks(item)}
  • `; } else { out += `
  • ${renderBlocks(item)}
  • `; } diff --git a/packages/shared/src/editor-schema/schema.ts b/packages/shared/src/editor-schema/schema.ts index 035405b..8bde30f 100644 --- a/packages/shared/src/editor-schema/schema.ts +++ b/packages/shared/src/editor-schema/schema.ts @@ -167,13 +167,31 @@ export const editorSchema = new Schema({ task_item: { content: 'paragraph block*', - attrs: { checked: { default: false, validate: 'boolean' } }, - parseDOM: [{ tag: 'li[data-type="task_item"]' }], - toDOM: (node) => [ - 'li', - { 'data-type': 'task_item', 'data-checked': String(node.attrs.checked) }, - 0, + // `id` (issue #153): a stable per-line id (assigned lazily in the + // editor) so the task overview (#154) can address a single checkbox for + // display and server-side toggling. `default: null` keeps every + // existing document valid; Markdown stays id-less by design. + attrs: { + checked: { default: false, validate: 'boolean' }, + id: { default: null }, + }, + parseDOM: [ + { + tag: 'li[data-type="task_item"]', + getAttrs: (dom) => ({ + checked: dom.getAttribute('data-checked') === 'true', + id: dom.getAttribute('data-task-id') || null, + }), + }, ], + toDOM: (node) => { + const attrs: Record = { + 'data-type': 'task_item', + 'data-checked': String(node.attrs.checked), + }; + if (node.attrs.id) attrs['data-task-id'] = node.attrs.id as string; + return ['li', attrs, 0]; + }, }, text: { group: 'inline' }, diff --git a/packages/shared/src/pages.ts b/packages/shared/src/pages.ts index 0cea91e..cc6e2e8 100644 --- a/packages/shared/src/pages.ts +++ b/packages/shared/src/pages.ts @@ -66,6 +66,10 @@ export type RepositionPageInput = z.infer; * nothing disappears but the page itself); `subtree` trashes every live * descendant along with it, which requires write permission on all of them. */ +/** Body of `POST /pages/:id/tasks/:taskId` (issue #153). */ +export const toggleTaskInputSchema = z.object({ checked: z.boolean() }); +export type ToggleTaskInput = z.infer; + export const PAGE_DELETE_MODES = ['promote', 'subtree'] as const; export type PageDeleteMode = (typeof PAGE_DELETE_MODES)[number];