#153: Stabile Task-IDs + Toggle-Rückschreibpfad über den Collab-Server

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC
This commit is contained in:
Claude Fable 5 2026-07-20 01:03:16 +02:00
parent 92a3b2f6d5
commit 3f7190ebcc
11 changed files with 457 additions and 7 deletions

View File

@ -26,7 +26,9 @@ import {
pageDeleteQuerySchema, pageDeleteQuerySchema,
pageListQuerySchema, pageListQuerySchema,
repositionPageInputSchema, repositionPageInputSchema,
toggleTaskInputSchema,
updatePageInputSchema, updatePageInputSchema,
type ToggleTaskInput,
} from '@dorfteich/shared'; } from '@dorfteich/shared';
import type { Response } from 'express'; import type { Response } from 'express';
@ -76,6 +78,21 @@ export class PagesController {
return this.pages.getState(request.user!, id); 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<void> {
await this.pages.toggleTask(request.user!, id, taskId, input.checked);
}
/** /**
* Short-lived collaboration token for the collab server (issue #34). * Short-lived collaboration token for the collab server (issue #34).
* `@Public()` so an anonymous visitor to a public page can obtain a token * `@Public()` so an anonymous visitor to a public page can obtain a token

View File

@ -17,6 +17,8 @@ import {
PluginPageSummary, PluginPageSummary,
RepositionPageInput, RepositionPageInput,
SidebarSortMode, SidebarSortMode,
TASK_TOGGLE_CHANNEL,
TaskToggleRequest,
TreeItem, TreeItem,
UpdatePageInput, UpdatePageInput,
collectSubtreeIds, collectSubtreeIds,
@ -110,6 +112,24 @@ export class PagesService {
return page; 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<void> {
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 /** The live `{id, parentId}` skeleton of a pond input to the tree walks
* (issue #106). Trashed pages keep their `parentId` but never count here. */ * (issue #106). Trashed pages keep their `parentId` but never count here. */
private async livePageTree(pondId: string): Promise<TreeItem[]> { private async livePageTree(pondId: string): Promise<TreeItem[]> {

View File

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

View File

@ -7,6 +7,7 @@ import { createLogger } from './logger.js';
import { createMaintenanceListener, type MaintenanceListener } from './maintenance-listener.js'; import { createMaintenanceListener, type MaintenanceListener } from './maintenance-listener.js';
import { PostgresPagePersistence } from './persistence.js'; import { PostgresPagePersistence } from './persistence.js';
import { createRestoreListener } from './restore-listener.js'; import { createRestoreListener } from './restore-listener.js';
import { createTaskToggleListener } from './task-toggle-listener.js';
import { closeDocumentConnections, createCollabServer } from './server.js'; import { closeDocumentConnections, createCollabServer } from './server.js';
import { PostgresSessionRegistry } from './session-registry.js'; import { PostgresSessionRegistry } from './session-registry.js';
import { PostgresVersionStore } from './version-store.js'; import { PostgresVersionStore } from './version-store.js';
@ -75,9 +76,18 @@ async function bootstrap(): Promise<void> {
logger, 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 server.listen(env.PORT);
await accessListener.start(); await accessListener.start();
await restoreListener.start(); await restoreListener.start();
await taskToggleListener.start();
await maintenanceListener.start(); await maintenanceListener.start();
sessionRegistry.start(() => [...server.hocuspocus.documents.keys()]); sessionRegistry.start(() => [...server.hocuspocus.documents.keys()]);
logger.info({ event: 'listen', port: env.PORT }, 'collaboration server listening'); logger.info({ event: 'listen', port: env.PORT }, 'collaboration server listening');
@ -88,6 +98,7 @@ async function bootstrap(): Promise<void> {
void Promise.allSettled([ void Promise.allSettled([
accessListener.stop(), accessListener.stop(),
restoreListener.stop(), restoreListener.stop(),
taskToggleListener.stop(),
maintenanceListener.stop(), maintenanceListener.stop(),
server.destroy(), server.destroy(),
pool.end(), pool.end(),

View File

@ -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<DirectDocumentConnection>;
logger: Logger;
reconnectDelayMs?: number;
}
export interface TaskToggleListener {
start(): Promise<void>;
stop(): Promise<void>;
}
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<void> {
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<void> {
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<void> {
stopped = false;
await connect();
},
async stop(): Promise<void> {
stopped = true;
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
const current = client;
client = null;
if (current) await current.end().catch(() => undefined);
},
};
}

View File

@ -8,6 +8,7 @@ import { PluginBlock } from './nodes/plugin-block';
import { Table, TableCell, TableHeader, TableRow } from './nodes/table'; import { Table, TableCell, TableHeader, TableRow } from './nodes/table';
import { TaskItem } from './nodes/task-item'; import { TaskItem } from './nodes/task-item';
import { DateMarker } from './nodes/date-marker'; import { DateMarker } from './nodes/date-marker';
import { TaskItemIds } from './task-item-ids';
import { Mention } from './nodes/mention'; import { Mention } from './nodes/mention';
import { Transclusion } from './nodes/transclusion'; import { Transclusion } from './nodes/transclusion';
import { Wikilink } from './nodes/wikilink'; import { Wikilink } from './nodes/wikilink';
@ -48,6 +49,7 @@ export const documentExtensions: AnyExtension[] = [
Wikilink, Wikilink,
Mention, Mention,
DateMarker, DateMarker,
TaskItemIds,
Transclusion, Transclusion,
Table, Table,
TableRow, TableRow,

View File

@ -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<string>();
let tr = null as ReturnType<typeof newState.tr.setNodeMarkup> | 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;
},
}),
];
},
});

View File

@ -47,6 +47,25 @@ export interface PageVersionCreatedEvent {
contributorIds: string[]; 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}. */ /** JSON payload carried on {@link PAGE_RESTORE_CHANNEL}. */
export interface PageRestoreRequest { export interface PageRestoreRequest {
pageId: string; pageId: string;

View File

@ -94,7 +94,8 @@ function renderListItems(node: Node): string {
node.forEach((item) => { node.forEach((item) => {
if (item.type.name === 'task_item') { if (item.type.name === 'task_item') {
const checked = item.attrs.checked === true; const checked = item.attrs.checked === true;
out += `<li data-type="task_item" data-checked="${checked}"><input type="checkbox" disabled${checked ? ' checked' : ''}>${renderBlocks(item)}</li>`; const id = item.attrs.id ? ` data-task-id="${escapeHtml(item.attrs.id as string)}"` : '';
out += `<li data-type="task_item" data-checked="${checked}"${id}><input type="checkbox" disabled${checked ? ' checked' : ''}>${renderBlocks(item)}</li>`;
} else { } else {
out += `<li>${renderBlocks(item)}</li>`; out += `<li>${renderBlocks(item)}</li>`;
} }

View File

@ -167,13 +167,31 @@ export const editorSchema = new Schema({
task_item: { task_item: {
content: 'paragraph block*', content: 'paragraph block*',
attrs: { checked: { default: false, validate: 'boolean' } }, // `id` (issue #153): a stable per-line id (assigned lazily in the
parseDOM: [{ tag: 'li[data-type="task_item"]' }], // editor) so the task overview (#154) can address a single checkbox for
toDOM: (node) => [ // display and server-side toggling. `default: null` keeps every
'li', // existing document valid; Markdown stays id-less by design.
{ 'data-type': 'task_item', 'data-checked': String(node.attrs.checked) }, attrs: {
0, 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<string, string> = {
'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' }, text: { group: 'inline' },

View File

@ -66,6 +66,10 @@ export type RepositionPageInput = z.infer<typeof repositionPageInputSchema>;
* nothing disappears but the page itself); `subtree` trashes every live * nothing disappears but the page itself); `subtree` trashes every live
* descendant along with it, which requires write permission on all of them. * 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<typeof toggleTaskInputSchema>;
export const PAGE_DELETE_MODES = ['promote', 'subtree'] as const; export const PAGE_DELETE_MODES = ['promote', 'subtree'] as const;
export type PageDeleteMode = (typeof PAGE_DELETE_MODES)[number]; export type PageDeleteMode = (typeof PAGE_DELETE_MODES)[number];