From 7471fc70f789d003b6ad953f441588264e8d6ad9 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Mon, 20 Jul 2026 00:56:07 +0200 Subject: [PATCH 1/7] =?UTF-8?q?#150:=20@-Mentions=20=E2=80=94=20Inline-Nod?= =?UTF-8?q?e,=20instanzweite=20User-Suche,=20Autocomplete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neuer Inline-Atom mention {userId, username}: Markdown-Regel @username (E-Mail-sicher über Wortgrenzen), Serializer, HTML-Span dt-mention, Plain-Text für die Suche, Extraktor extractMentionUserIds. Neue Endpoints GET /users/search (auth, min. 2 Zeichen, Limit 10, Rate-Limit) und GET /users/brief (Batch-Auflösung für live Anzeigenamen; gelöschte Nutzer → toter Chip). Editor: MentionView mit Live-displayName, MentionAutocomplete (Klon des Wikilink-Musters), Chip-CSS. 5 Unit-Tests. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC --- apps/api/src/users/user-search.controller.ts | 57 +++++++ apps/api/src/users/users.module.ts | 3 +- apps/web/src/editor/MentionAutocomplete.tsx | 143 ++++++++++++++++++ apps/web/src/editor/document-extensions.ts | 2 + apps/web/src/editor/nodes/mention.tsx | 61 ++++++++ apps/web/src/pages/PageEditorPage.tsx | 2 + apps/web/src/styles/base.css | 16 ++ packages/shared/i18n/de/editor.json | 4 + packages/shared/i18n/en/editor.json | 4 + packages/shared/src/editor-schema/html.ts | 7 + packages/shared/src/editor-schema/index.ts | 1 + packages/shared/src/editor-schema/markdown.ts | 39 +++++ .../shared/src/editor-schema/mention.test.ts | 62 ++++++++ packages/shared/src/editor-schema/mentions.ts | 18 +++ .../shared/src/editor-schema/plain-text.ts | 2 + packages/shared/src/editor-schema/schema.ts | 31 ++++ packages/shared/src/index.ts | 1 + packages/shared/src/users.ts | 10 ++ 18 files changed, 462 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/users/user-search.controller.ts create mode 100644 apps/web/src/editor/MentionAutocomplete.tsx create mode 100644 apps/web/src/editor/nodes/mention.tsx create mode 100644 packages/shared/src/editor-schema/mention.test.ts create mode 100644 packages/shared/src/editor-schema/mentions.ts create mode 100644 packages/shared/src/users.ts diff --git a/apps/api/src/users/user-search.controller.ts b/apps/api/src/users/user-search.controller.ts new file mode 100644 index 0000000..decfdd5 --- /dev/null +++ b/apps/api/src/users/user-search.controller.ts @@ -0,0 +1,57 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import type { UserBriefView } from '@dorfteich/shared'; + +import { AuthenticatedOnly } from '../permissions/permission.decorators'; +import { PrismaService } from '../prisma/prisma.service'; +import { RateLimit } from '../rate-limit/rate-limit.guard'; + +/** Cap for the batch `brief` lookup — a page mentions a handful of people. */ +const BRIEF_MAX_IDS = 50; + +/** + * Instance-wide user lookup for `@` mentions (issue #150). Deliberately + * minimal: only id/username/displayName, only active accounts, only for + * signed-in users, rate-limited, and never enumerable without a query — a + * documented consequence of instance-wide mentions is that logged-in users + * can discover usernames this way. + */ +@Controller('users') +@AuthenticatedOnly() +export class UserSearchController { + constructor(private readonly prisma: PrismaService) {} + + @Get('search') + @RateLimit({ scope: 'user-search', limit: 60, windowSeconds: 60 }) + async search(@Query('q') q: string | undefined): Promise { + const query = (q ?? '').trim(); + if (query.length < 2) return []; + return this.prisma.user.findMany({ + where: { + status: 'ACTIVE', + OR: [ + { username: { contains: query, mode: 'insensitive' } }, + { displayName: { contains: query, mode: 'insensitive' } }, + ], + }, + select: { id: true, username: true, displayName: true }, + orderBy: { username: 'asc' }, + take: 10, + }); + } + + /** Batch resolution of mentioned users for live display names; unknown or + * disabled ids are simply absent (the mention renders as a dead chip). */ + @Get('brief') + async brief(@Query('ids') ids: string | undefined): Promise { + const wanted = (ids ?? '') + .split(',') + .map((id) => id.trim()) + .filter(Boolean) + .slice(0, BRIEF_MAX_IDS); + if (wanted.length === 0) return []; + return this.prisma.user.findMany({ + where: { id: { in: wanted }, status: 'ACTIVE' }, + select: { id: true, username: true, displayName: true }, + }); + } +} diff --git a/apps/api/src/users/users.module.ts b/apps/api/src/users/users.module.ts index 6ecc4c1..74fa37a 100644 --- a/apps/api/src/users/users.module.ts +++ b/apps/api/src/users/users.module.ts @@ -1,12 +1,13 @@ import { Module } from '@nestjs/common'; import { SessionsModule } from '../auth/sessions.module'; +import { UserSearchController } from './user-search.controller'; import { UsersController } from './users.controller'; import { UsersService } from './users.service'; @Module({ imports: [SessionsModule], - controllers: [UsersController], + controllers: [UsersController, UserSearchController], providers: [UsersService], exports: [UsersService], }) diff --git a/apps/web/src/editor/MentionAutocomplete.tsx b/apps/web/src/editor/MentionAutocomplete.tsx new file mode 100644 index 0000000..873fcf6 --- /dev/null +++ b/apps/web/src/editor/MentionAutocomplete.tsx @@ -0,0 +1,143 @@ +import type { UserBriefView } from '@dorfteich/shared'; +import { useQuery } from '@tanstack/react-query'; +import type { Editor } from '@tiptap/react'; +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { apiGet } from '../lib/api'; + +/** An open `@` context: the query typed so far and where the `@` began. */ +interface QueryState { + query: string; + from: number; + coords: { left: number; bottom: number }; +} + +/** Detects `@query` immediately before a collapsed cursor (issue #150) — + * only at a word boundary, so typing an e-mail address never opens it. */ +function detectQuery(editor: Editor): { query: string; from: number } | null { + const { selection } = editor.state; + if (!selection.empty) return null; + const $from = selection.$from; + if (!$from.parent.isTextblock) return null; + const start = Math.max(0, $from.parentOffset - 40); + const before = $from.parent.textBetween(start, $from.parentOffset, undefined, ''); + const match = /(^|[^\w@.-])@([a-zA-Z0-9][\w-]*)$/.exec(before); + if (!match) return null; + const query = match[2] ?? ''; + return { query, from: selection.from - query.length - 1 }; +} + +/** + * Autocomplete popup for `@` mentions (issue #150): instance-wide user + * search (min. two characters), Enter/click inserts the mention node with + * the stable user id. Keyboard handling mirrors the wikilink popup. + */ +export function MentionAutocomplete({ editor }: { editor: Editor }): React.JSX.Element | null { + const { t } = useTranslation('editor'); + const [state, setState] = useState(null); + const [selected, setSelected] = useState(0); + + const search = useQuery({ + queryKey: ['user-search', state?.query ?? ''], + queryFn: () => apiGet(`/users/search?q=${encodeURIComponent(state!.query)}`), + enabled: Boolean(state && state.query.length >= 2), + staleTime: 30 * 1000, + }); + const suggestions = state && state.query.length >= 2 ? (search.data ?? []) : []; + + const live = useRef({ state, suggestions, selected }); + live.current = { state, suggestions, selected }; + + function close(): void { + setState(null); + setSelected(0); + } + + function choose(item: UserBriefView | undefined): void { + const current = live.current.state; + if (!item || !current) return; + const range = { from: current.from, to: editor.state.selection.from }; + editor + .chain() + .focus() + .insertContentAt(range, [ + { type: 'mention', attrs: { userId: item.id, username: item.username } }, + { type: 'text', text: ' ' }, + ]) + .run(); + close(); + } + + // Recompute the open query on every doc/selection change. + useEffect(() => { + const update = (): void => { + const found = detectQuery(editor); + if (!found) { + setState(null); + return; + } + const coords = editor.view.coordsAtPos(editor.state.selection.from); + setState({ ...found, coords: { left: coords.left, bottom: coords.bottom } }); + setSelected(0); + }; + editor.on('transaction', update); + return () => { + editor.off('transaction', update); + }; + }, [editor]); + + // Keyboard navigation, intercepted before ProseMirror (capture phase). + useEffect(() => { + const dom = editor.view.dom; + const onKeyDown = (event: KeyboardEvent): void => { + const { state: s, suggestions: items, selected: sel } = live.current; + if (!s || items.length === 0) return; + if (event.key === 'ArrowDown') { + event.preventDefault(); + setSelected((i) => (i + 1) % items.length); + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + setSelected((i) => (i - 1 + items.length) % items.length); + } else if (event.key === 'Enter') { + event.preventDefault(); + choose(items[sel]); + } else if (event.key === 'Escape') { + event.preventDefault(); + close(); + } + }; + dom.addEventListener('keydown', onKeyDown, true); + return () => dom.removeEventListener('keydown', onKeyDown, true); + }, [editor]); + + if (!state || suggestions.length === 0) return null; + + return ( +
    + {suggestions.map((user, index) => ( +
  • + +
  • + ))} +
+ ); +} diff --git a/apps/web/src/editor/document-extensions.ts b/apps/web/src/editor/document-extensions.ts index 4a8730c..7442699 100644 --- a/apps/web/src/editor/document-extensions.ts +++ b/apps/web/src/editor/document-extensions.ts @@ -7,6 +7,7 @@ import { BulletList, ListItem, OrderedList, TaskList } from './nodes/lists'; import { PluginBlock } from './nodes/plugin-block'; import { Table, TableCell, TableHeader, TableRow } from './nodes/table'; import { TaskItem } from './nodes/task-item'; +import { Mention } from './nodes/mention'; import { Transclusion } from './nodes/transclusion'; import { Wikilink } from './nodes/wikilink'; import { @@ -44,6 +45,7 @@ export const documentExtensions: AnyExtension[] = [ Image, PluginBlock, Wikilink, + Mention, Transclusion, Table, TableRow, diff --git a/apps/web/src/editor/nodes/mention.tsx b/apps/web/src/editor/nodes/mention.tsx new file mode 100644 index 0000000..0243596 --- /dev/null +++ b/apps/web/src/editor/nodes/mention.tsx @@ -0,0 +1,61 @@ +import type { UserBriefView } from '@dorfteich/shared'; +import { useQuery } from '@tanstack/react-query'; +import { Node } from '@tiptap/core'; +import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'; +import type { NodeViewProps } from '@tiptap/react'; +import { useTranslation } from 'react-i18next'; + +import { apiGet } from '../../lib/api'; +import { attributesFromSpec, nodeSpec } from '../spec-utils'; + +/** + * Renders an `@username` mention (issue #150). The stable reference is the + * user id; the shown text is the user's *current* display name (a rename + * shows everywhere immediately), falling back to `@username`. A mention whose + * user no longer resolves — deleted account, or an unresolved Markdown + * import — renders as a muted "dead" chip and never notifies anyone. + */ +function MentionView({ node }: NodeViewProps): React.JSX.Element { + const { t } = useTranslation('editor'); + const userId = node.attrs.userId as string; + const username = node.attrs.username as string; + + const brief = useQuery({ + queryKey: ['user-brief', userId], + queryFn: () => apiGet(`/users/brief?ids=${encodeURIComponent(userId)}`), + enabled: Boolean(userId), + staleTime: 5 * 60 * 1000, + }); + + const resolved = brief.data?.find((user) => user.id === userId); + const dead = Boolean(userId) && brief.isSuccess && !resolved; + const label = resolved ? `@${resolved.displayName}` : `@${username}`; + + return ( + + + {label} + + + ); +} + +const mentionSpec = nodeSpec('mention'); +export const Mention = Node.create({ + name: 'mention', + group: mentionSpec.group, + inline: mentionSpec.inline, + atom: mentionSpec.atom, + addAttributes() { + return attributesFromSpec(mentionSpec); + }, + parseHTML: () => mentionSpec.parseDOM, + renderHTML: ({ node }) => mentionSpec.toDOM!(node), + addNodeView() { + return ReactNodeViewRenderer(MentionView); + }, +}); diff --git a/apps/web/src/pages/PageEditorPage.tsx b/apps/web/src/pages/PageEditorPage.tsx index 68b2afb..db5aff5 100644 --- a/apps/web/src/pages/PageEditorPage.tsx +++ b/apps/web/src/pages/PageEditorPage.tsx @@ -27,6 +27,7 @@ import { ImageUpload } from '../editor/image-upload'; import { PresenceStrip } from '../editor/PresenceStrip'; import { Toolbar } from '../editor/Toolbar'; import { useCollabProvider } from '../editor/use-collab-provider'; +import { MentionAutocomplete } from '../editor/MentionAutocomplete'; import { WikilinkAutocomplete } from '../editor/WikilinkAutocomplete'; import { WikilinkContext, makeWikilinkResolver } from '../editor/wikilink-context'; import { usePageActionsSlot } from '../layout/page-actions'; @@ -337,6 +338,7 @@ function PageEditor({ )} {canEdit && } + {canEdit && } diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index 7cb3e8a..4dd2eb4 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -3808,3 +3808,19 @@ ul[data-type='task_list'] li p:last-of-type { border-bottom-color: var(--color-accent); } } + +/* `@username` mention chip (issue #150). */ +.dt-mention { + display: inline-block; + padding: 0 0.35em; + border-radius: 999px; + background: var(--color-bg-subtle); + color: var(--color-accent); + font-weight: 500; + white-space: nowrap; +} + +.dt-mention--dead { + color: var(--color-text-muted); + text-decoration: line-through; +} diff --git a/packages/shared/i18n/de/editor.json b/packages/shared/i18n/de/editor.json index 08fe3f6..49f973d 100644 --- a/packages/shared/i18n/de/editor.json +++ b/packages/shared/i18n/de/editor.json @@ -181,5 +181,9 @@ "notFound": { "hint": "Du kannst sie direkt hier anlegen — alle Wikilinks auf diese Adresse zeigen dann auf die neue Seite.", "create": "Seite „{{slug}}“ anlegen" + }, + "mention": { + "unresolved": "Unbekannter Nutzer", + "suggestLabel": "Nutzer-Vorschläge" } } diff --git a/packages/shared/i18n/en/editor.json b/packages/shared/i18n/en/editor.json index 846f907..c183b6e 100644 --- a/packages/shared/i18n/en/editor.json +++ b/packages/shared/i18n/en/editor.json @@ -181,5 +181,9 @@ "notFound": { "hint": "You can create it right here — every wikilink pointing at this address will resolve to the new page.", "create": "Create the page “{{slug}}”" + }, + "mention": { + "unresolved": "Unknown user", + "suggestLabel": "User suggestions" } } diff --git a/packages/shared/src/editor-schema/html.ts b/packages/shared/src/editor-schema/html.ts index 28014c1..ad47a44 100644 --- a/packages/shared/src/editor-schema/html.ts +++ b/packages/shared/src/editor-schema/html.ts @@ -69,6 +69,13 @@ function renderInline(node: Node): string { const text = escapeHtml(display ?? (child.attrs.targetSlug as string)); const displayAttr = display ? ` data-display="${escapeHtml(display)}"` : ''; out += `${text}`; + } else if (child.type.name === 'mention') { + // A user mention (issue #150): static HTML shows the @username; the + // stable user id rides along for consumers that can resolve it. + const username = escapeHtml(child.attrs.username as string); + const userId = child.attrs.userId ? escapeHtml(child.attrs.userId as string) : ''; + const idAttr = userId ? ` data-mention-user-id="${userId}"` : ''; + out += `@${username}`; } }); return out; diff --git a/packages/shared/src/editor-schema/index.ts b/packages/shared/src/editor-schema/index.ts index 9ee25c1..a871a31 100644 --- a/packages/shared/src/editor-schema/index.ts +++ b/packages/shared/src/editor-schema/index.ts @@ -5,3 +5,4 @@ export * from './html'; export * from './plain-text'; export * from './outline'; export * from './wikilinks'; +export * from './mentions'; diff --git a/packages/shared/src/editor-schema/markdown.ts b/packages/shared/src/editor-schema/markdown.ts index d2a9a83..9590c66 100644 --- a/packages/shared/src/editor-schema/markdown.ts +++ b/packages/shared/src/editor-schema/markdown.ts @@ -192,6 +192,32 @@ function wikilinkRule(state: StateInline, silent: boolean): boolean { return true; } +/** Username shape after the `@` (mirrors the signup `usernameSchema`). */ +const MENTION_NAME = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/i; + +/** markdown-it inline rule for `@username` mentions (issue #150). Only fires + * at a word boundary — `stefan@example.org` stays plain text. The parser is + * database-less, so the user id stays empty (purely visual) until the editor + * or a resolver fills it in. */ +function mentionRule(state: StateInline, silent: boolean): boolean { + const start = state.pos; + if (state.src.charCodeAt(start) !== 0x40 /* @ */) return false; + const before = start > 0 ? state.src[start - 1]! : ''; + if (/[\w@.-]/.test(before)) return false; + const match = MENTION_NAME.exec(state.src.slice(start + 1)); + if (!match || match[0].length < 3) return false; + const username = match[0]; + // Trailing user-ish characters would make this an address, not a mention. + const after = state.src[start + 1 + username.length] ?? ''; + if (after === '@') return false; + if (!silent) { + const token = state.push('mention', '', 0); + token.attrs = [['username', username]]; + } + state.pos = start + 1 + username.length; + return true; +} + /** A whole line that is only `![[slug]]` / `![[slug|display]]` embeds a page * (#135); the `$[[slug]]` prefix embeds without frame or title (#146). */ const TRANSCLUSION_LINE = /^([!$])\[\[([^[\]\n|]+)(?:\|([^[\]\n]+))?\]\]\s*$/; @@ -280,6 +306,8 @@ function createTokenizer(): MarkdownIt { const md = new MarkdownIt('default', { html: false }); // Run before `link` so `[[…]]` is not first eaten as two nested `[…]` links. md.inline.ruler.before('link', 'wikilink', wikilinkRule); + // `@username` mentions (issue #150). + md.inline.ruler.before('link', 'mention', mentionRule); // Run before `paragraph` so a lone `![[slug]]` line embeds rather than reads // as plain text (issue #135). md.block.ruler.before('paragraph', 'transclusion', transclusionRule); @@ -347,6 +375,14 @@ const markdownParser = new MarkdownParser(editorSchema, createTokenizer(), { displayText: tok.attrGet('display') || null, }), }, + mention: { + node: 'mention', + getAttrs: (tok) => ({ + username: tok.attrGet('username') ?? '', + // A Markdown import cannot resolve users — the mention stays visual. + userId: tok.attrGet('userId') ?? '', + }), + }, transclusion: { node: 'transclusion', getAttrs: (tok) => ({ @@ -478,6 +514,9 @@ const markdownSerializer = new MarkdownSerializer( const display = node.attrs.displayText as string | null; state.write(display ? `[[${slug}|${display}]]` : `[[${slug}]]`); }, + mention(state, node) { + state.write(`@${node.attrs.username as string}`); + }, transclusion(state, node) { const slug = node.attrs.targetSlug as string; const display = node.attrs.displayText as string | null; diff --git a/packages/shared/src/editor-schema/mention.test.ts b/packages/shared/src/editor-schema/mention.test.ts new file mode 100644 index 0000000..e312112 --- /dev/null +++ b/packages/shared/src/editor-schema/mention.test.ts @@ -0,0 +1,62 @@ +import { Node } from 'prosemirror-model'; +import { describe, expect, it } from 'vitest'; + +import { docToHtml } from './html'; +import { docToMarkdown, markdownToDoc } from './markdown'; +import { extractMentionUserIds } from './mentions'; +import { docToPlainText } from './plain-text'; +import { editorSchema } from './schema'; + +function firstMention(doc: Node): Node | null { + let found: Node | null = null; + doc.descendants((node) => { + if (!found && node.type.name === 'mention') found = node; + }); + return found; +} + +describe('@username mention (issue #150)', () => { + it('parses @username to a mention node and round-trips', () => { + const doc = markdownToDoc('Hallo @nadia, schau mal.'); + const node = firstMention(doc); + expect(node).not.toBeNull(); + expect(node!.attrs.username).toBe('nadia'); + expect(node!.attrs.userId).toBe(''); + expect(docToMarkdown(doc)).toContain('@nadia'); + }); + + it('leaves e-mail addresses untouched', () => { + const doc = markdownToDoc('Schreib an stefan@example.org bitte.'); + expect(firstMention(doc)).toBeNull(); + expect(docToMarkdown(doc)).toContain('stefan@example.org'); + }); + + it('renders HTML with the username and optional user id', () => { + const doc = markdownToDoc('Ping @zoraya-shahin!'); + expect(docToHtml(doc)).toContain('class="dt-mention" data-mention="zoraya-shahin"'); + expect(docToHtml(doc)).toContain('@zoraya-shahin'); + }); + + it('appears in the plain text for search', () => { + expect(docToPlainText(markdownToDoc('Frag @nadia.'))).toContain('@nadia'); + }); + + it('extracts only resolved user ids', () => { + const doc = Node.fromJSON(editorSchema, { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { type: 'mention', attrs: { userId: 'u-1', username: 'nadia' } }, + { type: 'text', text: ' und ' }, + { type: 'mention', attrs: { userId: '', username: 'import-only' } }, + { type: 'text', text: ' und nochmal ' }, + { type: 'mention', attrs: { userId: 'u-1', username: 'nadia' } }, + ], + }, + ], + }); + expect(extractMentionUserIds(doc)).toEqual(['u-1']); + }); +}); diff --git a/packages/shared/src/editor-schema/mentions.ts b/packages/shared/src/editor-schema/mentions.ts new file mode 100644 index 0000000..5d95fb5 --- /dev/null +++ b/packages/shared/src/editor-schema/mentions.ts @@ -0,0 +1,18 @@ +import { Node } from 'prosemirror-model'; + +/** + * The distinct user ids mentioned in a document (issue #150) — the input for + * the derived `page_mentions` rows and the mention notifications (#151). + * Mentions without a resolved user id (e.g. from a Markdown import) are + * purely visual and excluded. + */ +export function extractMentionUserIds(doc: Node): string[] { + const ids = new Set(); + doc.descendants((node) => { + if (node.type.name === 'mention') { + const userId = node.attrs.userId as string; + if (userId) ids.add(userId); + } + }); + return [...ids]; +} diff --git a/packages/shared/src/editor-schema/plain-text.ts b/packages/shared/src/editor-schema/plain-text.ts index 1a9e7b3..45a9671 100644 --- a/packages/shared/src/editor-schema/plain-text.ts +++ b/packages/shared/src/editor-schema/plain-text.ts @@ -9,6 +9,8 @@ export function docToPlainText(doc: Node): string { if (leaf.type.name === 'wikilink') { return (leaf.attrs.displayText as string | null) ?? (leaf.attrs.targetSlug as string); } + // A mention reads as `@username` (issue #150), so search finds it. + if (leaf.type.name === 'mention') return `@${leaf.attrs.username as string}`; return ''; }); return text.replace(/\n{3,}/g, '\n\n').trim(); diff --git a/packages/shared/src/editor-schema/schema.ts b/packages/shared/src/editor-schema/schema.ts index 640b5e8..b15aeff 100644 --- a/packages/shared/src/editor-schema/schema.ts +++ b/packages/shared/src/editor-schema/schema.ts @@ -256,6 +256,37 @@ export const editorSchema = new Schema({ }, }, + // `@username` user mention (issue #150). An inline atom carrying the + // mentioned user's stable id plus the username as serialization/display + // fallback. Live display-name resolution happens in the editor where a + // user lookup is available; a Markdown import cannot resolve users, so it + // leaves `userId` empty — such a mention is purely visual. + mention: { + group: 'inline', + inline: true, + atom: true, + attrs: { + userId: { default: '' }, + username: { validate: 'string' }, + }, + parseDOM: [ + { + tag: 'span[data-mention]', + getAttrs: (dom) => ({ + username: dom.getAttribute('data-mention'), + userId: dom.getAttribute('data-mention-user-id') || '', + }), + }, + ], + toDOM: (node) => { + const username = node.attrs.username as string; + const userId = node.attrs.userId as string; + const attrs: Record = { 'data-mention': username, class: 'dt-mention' }; + if (userId) attrs['data-mention-user-id'] = userId; + return ['span', attrs, `@${username}`]; + }, + }, + // Obsidian-style page embed `![[slug]]` (issue #135). A block atom that // references another page by slug; the read view / public renderer expands // it to the target page's rendered HTML (permission-checked, recursion diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 900b450..3d06a0f 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -20,6 +20,7 @@ export * from './labels'; export * from './legal'; export * from './links'; export * from './members'; +export * from './users'; export * from './notifications'; export * from './pages'; export * from './permissions'; diff --git a/packages/shared/src/users.ts b/packages/shared/src/users.ts new file mode 100644 index 0000000..c365bfe --- /dev/null +++ b/packages/shared/src/users.ts @@ -0,0 +1,10 @@ +/** + * Minimal public identity of a user (issue #150): what the instance-wide + * mention search and the mention rendering expose — never more than id, + * username, and display name. + */ +export interface UserBriefView { + id: string; + username: string; + displayName: string; +} -- 2.45.2 From 92a3b2f6d50e44564ca029a492205af108f4c40f Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Mon, 20 Jul 2026 00:59:05 +0200 Subject: [PATCH 2/7] #152: Datums-Marker >> (Zieldatum) / << (Startdatum) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neuer Inline-Atom date_marker {kind: due|start, date: ISO}. Markdown kanonisch ISO (>>2026-12-31), Eingabe-Kulanz dd.mm.yyyy; Block-Guard vor blockquote hält zeilenführende >>Daten aus dem Zitat-Parser; ungültige Kalenderdaten bleiben Text. Editor: InputRule beim Tippen (+Leerzeichen), Anzeige per Intl.DateTimeFormat in Nutzersprache, Überfällig-Färbung. Die User.locale-Verdrahtung existierte bereits (auth-context, #17) — keine Änderung nötig. 6 Unit-Tests inkl. Task-Listen-Zeile mit Marker und Mention. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC --- apps/web/src/editor/document-extensions.ts | 2 + apps/web/src/editor/nodes/date-marker.tsx | 91 +++++++++++++++++++ apps/web/src/styles/base.css | 19 ++++ packages/shared/i18n/de/editor.json | 4 + packages/shared/i18n/en/editor.json | 4 + .../src/editor-schema/date-marker.test.ts | 73 +++++++++++++++ packages/shared/src/editor-schema/html.ts | 8 ++ packages/shared/src/editor-schema/markdown.ts | 91 +++++++++++++++++++ .../shared/src/editor-schema/plain-text.ts | 2 + packages/shared/src/editor-schema/schema.ts | 32 +++++++ 10 files changed, 326 insertions(+) create mode 100644 apps/web/src/editor/nodes/date-marker.tsx create mode 100644 packages/shared/src/editor-schema/date-marker.test.ts diff --git a/apps/web/src/editor/document-extensions.ts b/apps/web/src/editor/document-extensions.ts index 7442699..46e2fbc 100644 --- a/apps/web/src/editor/document-extensions.ts +++ b/apps/web/src/editor/document-extensions.ts @@ -7,6 +7,7 @@ import { BulletList, ListItem, OrderedList, TaskList } from './nodes/lists'; 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 { Mention } from './nodes/mention'; import { Transclusion } from './nodes/transclusion'; import { Wikilink } from './nodes/wikilink'; @@ -46,6 +47,7 @@ export const documentExtensions: AnyExtension[] = [ PluginBlock, Wikilink, Mention, + DateMarker, Transclusion, Table, TableRow, diff --git a/apps/web/src/editor/nodes/date-marker.tsx b/apps/web/src/editor/nodes/date-marker.tsx new file mode 100644 index 0000000..37ab2ad --- /dev/null +++ b/apps/web/src/editor/nodes/date-marker.tsx @@ -0,0 +1,91 @@ +import { InputRule, Node } from '@tiptap/core'; +import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'; +import type { NodeViewProps } from '@tiptap/react'; +import { useTranslation } from 'react-i18next'; + +import { attributesFromSpec, nodeSpec } from '../spec-utils'; + +/** `>>`/`<<` + ISO or `dd.mm.yyyy`, completed by a space (the trigger). */ +const INPUT_PATTERN = + /(?:^|\s)([<>])\1\s?(?:(\d{4})-(\d{2})-(\d{2})|(\d{1,2})\.(\d{1,2})\.(\d{4}))\s$/; + +function isoDateOf(match: RegExpMatchArray): string | null { + const [year, month, day] = match[2] + ? [Number(match[2]), Number(match[3]), Number(match[4])] + : [Number(match[7]), Number(match[6]), Number(match[5])]; + const date = new Date(Date.UTC(year, month - 1, day)); + const valid = + date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day; + if (!valid) return null; + const pad = (value: number): string => String(value).padStart(2, '0'); + return `${year}-${pad(month)}-${pad(day)}`; +} + +/** + * Renders a date marker (issue #152): `»` = due date (`>>`), `«` = start + * date (`<<`), formatted per the viewer's language. A due date in the past + * gets an "overdue" tint. The stored attr is always canonical ISO. + */ +function DateMarkerView({ node }: NodeViewProps): React.JSX.Element { + const { t, i18n } = useTranslation('editor'); + const kind = node.attrs.kind as 'due' | 'start'; + const iso = node.attrs.date as string; + + const formatted = new Intl.DateTimeFormat(i18n.language, { dateStyle: 'medium' }).format( + new Date(`${iso}T00:00:00`), + ); + const overdue = kind === 'due' && iso < new Date().toISOString().slice(0, 10); + const classes = ['dt-date', `dt-date--${kind}`]; + if (overdue) classes.push('dt-date--overdue'); + + return ( + + + {kind === 'due' ? '»' : '«'} {formatted} + + + ); +} + +const dateMarkerSpec = nodeSpec('date_marker'); +export const DateMarker = Node.create({ + name: 'date_marker', + group: dateMarkerSpec.group, + inline: dateMarkerSpec.inline, + atom: dateMarkerSpec.atom, + addAttributes() { + return attributesFromSpec(dateMarkerSpec); + }, + parseHTML: () => dateMarkerSpec.parseDOM, + renderHTML: ({ node }) => dateMarkerSpec.toDOM!(node), + addInputRules() { + return [ + new InputRule({ + find: INPUT_PATTERN, + handler: ({ range, match, chain }) => { + const iso = isoDateOf(match); + if (!iso) return; + // Keep a leading boundary character (space/line start) intact. + const full = match[0]; + const markerStart = range.from + (full.length - full.trimStart().length); + chain() + .insertContentAt({ from: markerStart, to: range.to }, [ + { + type: 'date_marker', + attrs: { kind: match[1] === '>' ? 'due' : 'start', date: iso }, + }, + { type: 'text', text: ' ' }, + ]) + .run(); + }, + }), + ]; + }, + addNodeView() { + return ReactNodeViewRenderer(DateMarkerView); + }, +}); diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index 4dd2eb4..77df6f4 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -3824,3 +3824,22 @@ ul[data-type='task_list'] li p:last-of-type { color: var(--color-text-muted); text-decoration: line-through; } + +/* Date marker chip (issue #152): » = due date, « = start date. */ +.dt-date { + display: inline-block; + padding: 0 0.35em; + border-radius: 6px; + background: var(--color-bg-subtle); + color: var(--color-text-muted); + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +.dt-date--due { + color: var(--color-accent); +} + +.dt-date--overdue { + color: #b91c1c; +} diff --git a/packages/shared/i18n/de/editor.json b/packages/shared/i18n/de/editor.json index 49f973d..73708f4 100644 --- a/packages/shared/i18n/de/editor.json +++ b/packages/shared/i18n/de/editor.json @@ -185,5 +185,9 @@ "mention": { "unresolved": "Unbekannter Nutzer", "suggestLabel": "Nutzer-Vorschläge" + }, + "dateMarker": { + "due": "Zieldatum", + "start": "Startdatum" } } diff --git a/packages/shared/i18n/en/editor.json b/packages/shared/i18n/en/editor.json index c183b6e..57a5e9e 100644 --- a/packages/shared/i18n/en/editor.json +++ b/packages/shared/i18n/en/editor.json @@ -185,5 +185,9 @@ "mention": { "unresolved": "Unknown user", "suggestLabel": "User suggestions" + }, + "dateMarker": { + "due": "Due date", + "start": "Start date" } } diff --git a/packages/shared/src/editor-schema/date-marker.test.ts b/packages/shared/src/editor-schema/date-marker.test.ts new file mode 100644 index 0000000..813efec --- /dev/null +++ b/packages/shared/src/editor-schema/date-marker.test.ts @@ -0,0 +1,73 @@ +import { Node } from 'prosemirror-model'; +import { describe, expect, it } from 'vitest'; + +import { docToHtml } from './html'; +import { docToMarkdown, markdownToDoc } from './markdown'; +import { docToPlainText } from './plain-text'; + +function markers(doc: Node): { kind: string; date: string }[] { + const found: { kind: string; date: string }[] = []; + doc.descendants((node) => { + if (node.type.name === 'date_marker') { + found.push({ kind: node.attrs.kind as string, date: node.attrs.date as string }); + } + }); + return found; +} + +describe('date markers >>/<< (issue #152)', () => { + it('parses ISO due and start dates and round-trips canonically', () => { + const doc = markdownToDoc('Projekt <<2026-07-01 bis >>2026-12-31 fertig.'); + expect(markers(doc)).toEqual([ + { kind: 'start', date: '2026-07-01' }, + { kind: 'due', date: '2026-12-31' }, + ]); + const markdown = docToMarkdown(doc); + expect(markdown).toContain('<<2026-07-01'); + expect(markdown).toContain('>>2026-12-31'); + }); + + it('accepts dd.mm.yyyy as input lenience but serializes ISO', () => { + const doc = markdownToDoc('Zieltermin >>31.12.2026 bitte.'); + expect(markers(doc)).toEqual([{ kind: 'due', date: '2026-12-31' }]); + expect(docToMarkdown(doc)).toContain('>>2026-12-31'); + }); + + it('keeps a line-leading >>date out of blockquote parsing', () => { + const doc = markdownToDoc('>>2026-12-31 ist die Deadline.'); + expect(markers(doc)).toEqual([{ kind: 'due', date: '2026-12-31' }]); + let quotes = 0; + doc.descendants((node) => { + if (node.type.name === 'blockquote') quotes += 1; + }); + expect(quotes).toBe(0); + // A real blockquote still works. + const quote = markdownToDoc('> ein Zitat'); + let realQuotes = 0; + quote.descendants((node) => { + if (node.type.name === 'blockquote') realQuotes += 1; + }); + expect(realQuotes).toBe(1); + }); + + it('leaves invalid calendar dates as plain text', () => { + const doc = markdownToDoc('Kaputt: >>31.02.2026 bleibt Text.'); + expect(markers(doc)).toEqual([]); + expect(docToMarkdown(doc)).toContain('31.02.2026'); + }); + + it('renders HTML with kind, ISO date, and shows up in plain text', () => { + const doc = markdownToDoc('Bis >>2026-12-31.'); + const html = docToHtml(doc); + expect(html).toContain('data-date-marker="due"'); + expect(html).toContain('data-date="2026-12-31"'); + expect(docToPlainText(doc)).toContain('2026-12-31'); + }); + + it('works inside task list lines', () => { + const doc = markdownToDoc('- [ ] Bühne buchen >>2026-08-01 @nadia'); + expect(markers(doc)).toEqual([{ kind: 'due', date: '2026-08-01' }]); + const markdown = docToMarkdown(doc); + expect(markdown).toContain('- [ ] Bühne buchen >>2026-08-01 @nadia'); + }); +}); diff --git a/packages/shared/src/editor-schema/html.ts b/packages/shared/src/editor-schema/html.ts index ad47a44..40d3fd9 100644 --- a/packages/shared/src/editor-schema/html.ts +++ b/packages/shared/src/editor-schema/html.ts @@ -69,6 +69,14 @@ function renderInline(node: Node): string { const text = escapeHtml(display ?? (child.attrs.targetSlug as string)); const displayAttr = display ? ` data-display="${escapeHtml(display)}"` : ''; out += `${text}`; + } else if (child.type.name === 'date_marker') { + // A date marker (issue #152): static HTML shows the unambiguous ISO + // date; locale-aware formatting happens where the viewer is known. + const kind = escapeHtml(child.attrs.kind as string); + const date = escapeHtml(child.attrs.date as string); + out += + `${kind === 'due' ? '»' : '«'} ${date}`; } else if (child.type.name === 'mention') { // A user mention (issue #150): static HTML shows the @username; the // stable user id rides along for consumers that can resolve it. diff --git a/packages/shared/src/editor-schema/markdown.ts b/packages/shared/src/editor-schema/markdown.ts index 9590c66..322ae79 100644 --- a/packages/shared/src/editor-schema/markdown.ts +++ b/packages/shared/src/editor-schema/markdown.ts @@ -192,6 +192,82 @@ function wikilinkRule(state: StateInline, silent: boolean): boolean { return true; } +/** `>>`/`<<` + ISO or `dd.mm.yyyy` date (issue #152). */ +const DATE_MARKER = /^([<>])\1\s?(?:(\d{4})-(\d{2})-(\d{2})|(\d{1,2})\.(\d{1,2})\.(\d{4}))/; + +/** Canonical `YYYY-MM-DD` from the regex groups, or null for a non-date. */ +function isoDateOf(match: RegExpExecArray): string | null { + const [year, month, day] = match[2] + ? [Number(match[2]), Number(match[3]), Number(match[4])] + : [Number(match[7]), Number(match[6]), Number(match[5])]; + const date = new Date(Date.UTC(year, month - 1, day)); + const valid = + date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day; + if (!valid) return null; + const pad = (value: number): string => String(value).padStart(2, '0'); + return `${year}-${pad(month)}-${pad(day)}`; +} + +/** markdown-it inline rule for date markers (issue #152): `>>` = due date, + * `<<` = start date; ISO is canonical, `dd.mm.yyyy` is accepted as input + * lenience. An invalid calendar date stays plain text. */ +function dateMarkerRule(state: StateInline, silent: boolean): boolean { + const match = DATE_MARKER.exec(state.src.slice(state.pos)); + if (!match) return false; + const iso = isoDateOf(match); + if (!iso) return false; + if (!silent) { + const token = state.push('date_marker', '', 0); + token.attrs = [ + ['kind', match[1] === '>' ? 'due' : 'start'], + ['date', iso], + ]; + } + state.pos += match[0].length; + return true; +} + +/** Block guard (issue #152): a line starting with `>>2026-…` is a paragraph + * with a due-date marker, not a nested blockquote — registered before + * `blockquote` so markdown-it never sees the `>` as a quote. */ +function dateLineRule( + state: StateBlock, + startLine: number, + endLine: number, + silent: boolean, +): boolean { + const start = state.bMarks[startLine]! + state.tShift[startLine]!; + const max = state.eMarks[startLine]!; + const match = DATE_MARKER.exec(state.src.slice(start, max)); + if (!match || !isoDateOf(match)) return false; + if (silent) return true; + // Consume the paragraph like markdown-it's own paragraph rule would. + const terminatorRules = state.md.block.ruler.getRules('paragraph'); + let nextLine = startLine + 1; + for (; nextLine < endLine && !state.isEmpty(nextLine); nextLine += 1) { + if (state.sCount[nextLine]! - state.blkIndent > 3) continue; + if (state.sCount[nextLine]! < 0) continue; + let terminate = false; + for (const rule of terminatorRules) { + if (rule(state, nextLine, endLine, true)) { + terminate = true; + break; + } + } + if (terminate) break; + } + const content = state.getLines(startLine, nextLine, state.blkIndent, false).trim(); + const open = state.push('paragraph_open', 'p', 1); + open.map = [startLine, nextLine]; + const inline = state.push('inline', '', 0); + inline.content = content; + inline.map = [startLine, nextLine]; + inline.children = []; + state.push('paragraph_close', 'p', -1); + state.line = nextLine; + return true; +} + /** Username shape after the `@` (mirrors the signup `usernameSchema`). */ const MENTION_NAME = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/i; @@ -308,6 +384,10 @@ function createTokenizer(): MarkdownIt { md.inline.ruler.before('link', 'wikilink', wikilinkRule); // `@username` mentions (issue #150). md.inline.ruler.before('link', 'mention', mentionRule); + // `>>`/`<<` date markers (issue #152) … + md.inline.ruler.before('link', 'date_marker', dateMarkerRule); + // … and the guard that keeps a line-leading `>>date` out of blockquote. + md.block.ruler.before('blockquote', 'date_line', dateLineRule); // Run before `paragraph` so a lone `![[slug]]` line embeds rather than reads // as plain text (issue #135). md.block.ruler.before('paragraph', 'transclusion', transclusionRule); @@ -383,6 +463,13 @@ const markdownParser = new MarkdownParser(editorSchema, createTokenizer(), { userId: tok.attrGet('userId') ?? '', }), }, + date_marker: { + node: 'date_marker', + getAttrs: (tok) => ({ + kind: tok.attrGet('kind') ?? 'due', + date: tok.attrGet('date') ?? '', + }), + }, transclusion: { node: 'transclusion', getAttrs: (tok) => ({ @@ -517,6 +604,10 @@ const markdownSerializer = new MarkdownSerializer( mention(state, node) { state.write(`@${node.attrs.username as string}`); }, + date_marker(state, node) { + const prefix = node.attrs.kind === 'due' ? '>>' : '<<'; + state.write(`${prefix}${node.attrs.date as string}`); + }, transclusion(state, node) { const slug = node.attrs.targetSlug as string; const display = node.attrs.displayText as string | null; diff --git a/packages/shared/src/editor-schema/plain-text.ts b/packages/shared/src/editor-schema/plain-text.ts index 45a9671..5d3901b 100644 --- a/packages/shared/src/editor-schema/plain-text.ts +++ b/packages/shared/src/editor-schema/plain-text.ts @@ -11,6 +11,8 @@ export function docToPlainText(doc: Node): string { } // A mention reads as `@username` (issue #150), so search finds it. if (leaf.type.name === 'mention') return `@${leaf.attrs.username as string}`; + // A date marker contributes its ISO date (issue #152). + if (leaf.type.name === 'date_marker') return leaf.attrs.date as string; return ''; }); return text.replace(/\n{3,}/g, '\n\n').trim(); diff --git a/packages/shared/src/editor-schema/schema.ts b/packages/shared/src/editor-schema/schema.ts index b15aeff..035405b 100644 --- a/packages/shared/src/editor-schema/schema.ts +++ b/packages/shared/src/editor-schema/schema.ts @@ -256,6 +256,38 @@ export const editorSchema = new Schema({ }, }, + // `>>2026-12-31` / `<<2026-07-01` date marker (issue #152). An inline + // atom carrying a kind ('due' for `>>`, 'start' for `<<`) and the date as + // a canonical ISO `YYYY-MM-DD` string; locale-aware formatting happens in + // the editor, where the viewer's language is known. + date_marker: { + group: 'inline', + inline: true, + atom: true, + attrs: { + kind: { validate: 'string' }, + date: { validate: 'string' }, + }, + parseDOM: [ + { + tag: 'span[data-date-marker]', + getAttrs: (dom) => ({ + kind: dom.getAttribute('data-date-marker'), + date: dom.getAttribute('data-date'), + }), + }, + ], + toDOM: (node) => { + const kind = node.attrs.kind as string; + const date = node.attrs.date as string; + return [ + 'span', + { 'data-date-marker': kind, 'data-date': date, class: `dt-date dt-date--${kind}` }, + `${kind === 'due' ? '»' : '«'} ${date}`, + ]; + }, + }, + // `@username` user mention (issue #150). An inline atom carrying the // mentioned user's stable id plus the username as serialization/display // fallback. Live display-name resolution happens in the editor where a -- 2.45.2 From 3f7190ebcca831cb1ff4c1e9a40d02a63d0844f5 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Mon, 20 Jul 2026 01:03:16 +0200 Subject: [PATCH 3/7] =?UTF-8?q?#153:=20Stabile=20Task-IDs=20+=20Toggle-R?= =?UTF-8?q?=C3=BCckschreibpfad=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]; -- 2.45.2 From e164370691e476679d1e6f0eb2ea887c6d741fe0 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Mon, 20 Jul 2026 01:20:30 +0200 Subject: [PATCH 4/7] =?UTF-8?q?#154:=20Aufgaben=C3=BCbersicht=20als=20Kern?= =?UTF-8?q?-Block=20(Seite=20+=20Unterseiten)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neuer Block-Atom task_overview (Markdown-Fence dorfteich-tasks, HTML-Placeholder). Shared extractTaskRows liest Task-Zeilen mit Text, Mentions (#150) und Start-/Zieldaten (#152); TasksService sammelt zur Lesezeit den Teilbaum (rekursiv via collectSubtreeIds, canAccessPage- Filter je Quellseite) aus Basis-State + page_updates-Log — KEINE abgeleitete Tabelle nötig (Teilbäume sind klein, kein Drift). Neuer auth-Endpoint GET /read/:pond/:slug/tasks; die öffentliche Ansicht expandiert den Placeholder serverseitig zur statischen Tabelle (Instanz-Sprache). NodeView mit Live-Tabelle und Rückschreib-Checkboxen (optimistisch, Override bis der debounced Collab-Persist nachzieht); Einfügen über die Block-Auswahl (eingebauter Eintrag). Unit- + DB-Tests, neuer CI-Pack tasks.spec (voller Loop inkl. Rückschreiben end-to-end), User-Guide-Doku en+de. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC --- .gitea/workflows/ci.yml | 10 + apps/api/src/i18n/api-i18n.ts | 6 +- apps/api/src/pages/pages.module.ts | 5 +- .../src/pages/task-overview.e2e.db.test.ts | 170 +++++++++++++++++ apps/api/src/pages/tasks.service.ts | 180 ++++++++++++++++++ apps/api/src/pages/yjs-content.ts | 22 ++- apps/api/src/public/public.service.ts | 20 +- .../api/src/public/read-content.controller.ts | 19 +- apps/web/e2e/tasks.spec.ts | 119 ++++++++++++ apps/web/src/editor/PluginBlockMenu.tsx | 9 +- apps/web/src/editor/document-extensions.ts | 2 + apps/web/src/editor/nodes/task-overview.tsx | 155 +++++++++++++++ apps/web/src/i18n/index.ts | 4 + apps/web/src/styles/base.css | 29 +++ docs/de/manual/user-guide.md | 12 ++ docs/manual/user-guide.md | 11 ++ packages/shared/i18n/de/tasks.json | 11 ++ packages/shared/i18n/en/tasks.json | 11 ++ packages/shared/src/editor-schema/html.ts | 4 + packages/shared/src/editor-schema/index.ts | 1 + packages/shared/src/editor-schema/markdown.ts | 13 ++ packages/shared/src/editor-schema/schema.ts | 13 ++ .../src/editor-schema/task-overview.test.ts | 45 +++++ packages/shared/src/editor-schema/tasks.ts | 80 ++++++++ 24 files changed, 942 insertions(+), 9 deletions(-) create mode 100644 apps/api/src/pages/task-overview.e2e.db.test.ts create mode 100644 apps/api/src/pages/tasks.service.ts create mode 100644 apps/web/e2e/tasks.spec.ts create mode 100644 apps/web/src/editor/nodes/task-overview.tsx create mode 100644 packages/shared/i18n/de/tasks.json create mode 100644 packages/shared/i18n/en/tasks.json create mode 100644 packages/shared/src/editor-schema/task-overview.test.ts create mode 100644 packages/shared/src/editor-schema/tasks.ts diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 5815f24..4d04484 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -406,6 +406,16 @@ jobs: E2E_BASE_URL=http://localhost:5173 \ pnpm --filter @dorfteich/web exec playwright test e2e/settings-nav.spec.ts + - name: Reset login rate limit before tasks pack + run: | + echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ + pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL" + + - name: Run tasks pack + run: | + E2E_BASE_URL=http://localhost:5173 \ + pnpm --filter @dorfteich/web exec playwright test e2e/tasks.spec.ts + - name: Reset login rate limit before create-missing-page pack run: | echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ diff --git a/apps/api/src/i18n/api-i18n.ts b/apps/api/src/i18n/api-i18n.ts index e5a3819..c22fa4f 100644 --- a/apps/api/src/i18n/api-i18n.ts +++ b/apps/api/src/i18n/api-i18n.ts @@ -1,9 +1,11 @@ import deErrors from '@dorfteich/shared/i18n/de/errors.json'; import deLegal from '@dorfteich/shared/i18n/de/legal.json'; import deMails from '@dorfteich/shared/i18n/de/mails.json'; +import deTasks from '@dorfteich/shared/i18n/de/tasks.json'; import enErrors from '@dorfteich/shared/i18n/en/errors.json'; import enLegal from '@dorfteich/shared/i18n/en/legal.json'; import enMails from '@dorfteich/shared/i18n/en/mails.json'; +import enTasks from '@dorfteich/shared/i18n/en/tasks.json'; import { createInstance, type i18n as I18n } from 'i18next'; /** @@ -15,8 +17,8 @@ export const apiI18n: I18n = createInstance(); void apiI18n.init({ resources: { - en: { errors: enErrors, mails: enMails, legal: enLegal }, - de: { errors: deErrors, mails: deMails, legal: deLegal }, + en: { errors: enErrors, mails: enMails, legal: enLegal, tasks: enTasks }, + de: { errors: deErrors, mails: deMails, legal: deLegal, tasks: deTasks }, }, fallbackLng: 'en', supportedLngs: ['de', 'en'], diff --git a/apps/api/src/pages/pages.module.ts b/apps/api/src/pages/pages.module.ts index b6d5a6e..ab9eace 100644 --- a/apps/api/src/pages/pages.module.ts +++ b/apps/api/src/pages/pages.module.ts @@ -7,11 +7,12 @@ import { SearchModule } from '../search/search.module'; import { PagesController } from './pages.controller'; import { PagesService } from './pages.service'; import { PluginApiController } from './plugin-api.controller'; +import { TasksService } from './tasks.service'; @Module({ imports: [PondsModule, SearchModule, WatchesModule], controllers: [PagesController, PluginApiController], - providers: [PagesService], - exports: [PagesService], + providers: [PagesService, TasksService], + exports: [PagesService, TasksService], }) export class PagesModule {} diff --git a/apps/api/src/pages/task-overview.e2e.db.test.ts b/apps/api/src/pages/task-overview.e2e.db.test.ts new file mode 100644 index 0000000..362ccde --- /dev/null +++ b/apps/api/src/pages/task-overview.e2e.db.test.ts @@ -0,0 +1,170 @@ +import { INestApplication } from '@nestjs/common'; +import { markdownToDoc } from '@dorfteich/shared'; +import { PrismaClient } from '@prisma/client'; +import request from 'supertest'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { PondPermissionCache } from '../permissions/pond-permission-cache'; +import { createTestApp, sessionCookieOf } from '../testing/test-app'; +import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; +import { UsersService } from '../users/users.service'; +import { docToState } from './yjs-content'; + +/** + * The task overview collection (issue #154): tasks of the page and its live + * subtree, permission-filtered per source page; the public rendering expands + * the placeholder into a static table. + */ +describe.skipIf(!hasTestDb)('task overview endpoint (e2e, issue #154)', () => { + let app: INestApplication; + let prisma: PrismaClient; + const suffix = uniqueSuffix(); + const password = 'uebersicht zeigt alles 1'; + + let ownerId: string; + let ownerCookie: string; + let pondSlug: string; + let pondId: string; + + const api = () => request(app.getHttpServer()); + + async function makePage( + slug: string, + title: string, + markdown: string, + parentId: string | null = null, + ): Promise { + const doc = markdownToDoc(markdown); + const page = await prisma.page.create({ + data: { + pondId, + slug, + title, + parentId, + createdBy: ownerId, + sortKey: 'a0', + ydocState: docToState(doc), + contentCache: { + create: { plainText: markdown, markdown, html: '', outline: [] }, + }, + }, + }); + return page.id; + } + + beforeAll(async () => { + prisma = createTestPrisma(); + app = await createTestApp(); + const users = app.get(UsersService); + const username = `overview-owner-${suffix}`; + const owner = await users.createUser({ + username, + email: `${username}@example.test`, + displayName: 'Overview Owner', + password, + locale: 'en', + }); + ownerId = owner.id; + await users.markEmailVerified(ownerId); + ownerCookie = sessionCookieOf( + await api() + .post('/api/v1/auth/login') + .send({ usernameOrEmail: username, password }) + .expect(200), + ); + + pondSlug = `overview-pond-${suffix}`; + const pond = await prisma.pond.create({ + data: { slug: pondSlug, name: 'Overview Pond', type: 'SHARED', ownerId }, + }); + pondId = pond.id; + await prisma.roleGrant.create({ + data: { + pondId, + subjectType: 'USER', + subjectId: ownerId, + role: 'EDITOR', + scopeType: 'POND', + scopeId: null, + effect: 'ALLOW', + createdBy: ownerId, + }, + }); + }); + + afterAll(async () => { + await prisma.roleGrant.deleteMany({ where: { pond: { ownerId } } }); + await prisma.pageContentCache.deleteMany({ where: { page: { pond: { ownerId } } } }); + await prisma.page.deleteMany({ where: { pond: { ownerId } } }); + await prisma.pond.deleteMany({ where: { ownerId } }); + await prisma.session.deleteMany({ where: { userId: ownerId } }); + await prisma.user.deleteMany({ where: { id: ownerId } }); + await prisma.$disconnect(); + await app.close(); + }); + + it('collects tasks of the page and its subtree with mentions and dates', async () => { + const rootId = await makePage( + `plan-${suffix}`, + 'Plan', + '- [ ] Bühne buchen >>2026-08-01\n\n```dorfteich-tasks\n```', + ); + await makePage(`kabel-${suffix}`, 'Kabel', '- [x] Kabel prüfen <<2026-07-01', rootId); + // A sibling outside the subtree contributes nothing. + await makePage(`anders-${suffix}`, 'Anders', '- [ ] Fremde Aufgabe'); + + const res = await api() + .get(`/api/v1/read/${pondSlug}/plan-${suffix}/tasks`) + .set('Cookie', ownerCookie) + .expect(200); + const pages = res.body as { + title: string; + tasks: { text: string; checked: boolean; dueDate: string | null }[]; + }[]; + expect(pages.map((p) => p.title)).toEqual(['Plan', 'Kabel']); + expect(pages[0]!.tasks[0]).toMatchObject({ + text: 'Bühne buchen', + checked: false, + dueDate: '2026-08-01', + }); + expect(pages[1]!.tasks[0]).toMatchObject({ text: 'Kabel prüfen', checked: true }); + const allTexts = pages.flatMap((p) => p.tasks.map((t) => t.text)); + expect(allTexts).not.toContain('Fremde Aufgabe'); + }); + + it('requires a session and hides unreadable pages', async () => { + await api().get(`/api/v1/read/${pondSlug}/plan-${suffix}/tasks`).expect(401); + }); + + it('expands the placeholder into a static table in the public rendering', async () => { + await prisma.roleGrant.create({ + data: { + pondId, + subjectType: 'PUBLIC', + subjectId: null, + role: 'READER', + scopeType: 'POND', + scopeId: null, + effect: 'ALLOW', + createdBy: ownerId, + }, + }); + // Raw grant rows bypass the permission cache — drop the pond's entry. + app.get(PondPermissionCache).invalidate(pondId); + // The public body comes from the content cache — regenerate it with the + // real renderer so the placeholder div is present. + const { docToHtml } = await import('@dorfteich/shared'); + const doc = markdownToDoc('- [ ] Bühne buchen >>2026-08-01\n\n```dorfteich-tasks\n```'); + await prisma.pageContentCache.updateMany({ + where: { page: { pondId, slug: `plan-${suffix}` } }, + data: { html: docToHtml(doc) }, + }); + + const res = await api().get(`/api/v1/public/${pondSlug}/plan-${suffix}/content`).expect(200); + const html = (res.body as { html: string }).html; + expect(html).toContain('dt-task-overview-table'); + expect(html).toContain('Bühne buchen'); + expect(html).toContain('Kabel prüfen'); + expect(html).not.toContain('data-task-overview'); + }); +}); diff --git a/apps/api/src/pages/tasks.service.ts b/apps/api/src/pages/tasks.service.ts new file mode 100644 index 0000000..3298de6 --- /dev/null +++ b/apps/api/src/pages/tasks.service.ts @@ -0,0 +1,180 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { + collectSubtreeIds, + extractTaskRows, + type TaskOverviewPage, + type TaskRow, +} from '@dorfteich/shared'; +import { User } from '@prisma/client'; + +import { apiI18n } from '../i18n/api-i18n'; +import { PermissionService } from '../permissions/permission.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { escapeHtml } from '../public/html-shell'; +import { docFromStateAndUpdates } from './yjs-content'; + +/** + * The task overview's collection (issue #154): every task-list line of a page + * and its live subtree, permission-filtered per source page — a page the + * viewer may not read contributes nothing (same no-leak rule as the + * transclusion expansion). Rows are extracted at read time from the stored + * Yjs states; subtrees are small (depth ≤ 6), so no derived table is needed. + */ +@Injectable() +export class TasksService { + constructor( + private readonly prisma: PrismaService, + private readonly permissions: PermissionService, + ) {} + + async collect( + user: User | null, + pondSlug: string, + pageSlug: string, + ): Promise { + const pond = await this.prisma.pond.findFirst({ where: { slug: pondSlug, deletedAt: null } }); + if (!pond) throw new NotFoundException(); + const root = await this.prisma.page.findFirst({ + where: { pondId: pond.id, slug: pageSlug, deletedAt: null }, + select: { id: true, pondId: true }, + }); + if (!root || !(await this.permissions.canAccessPage(user, root, 'read'))) { + throw new NotFoundException(); + } + return this.collectForPage(user, pond.id, root.id); + } + + async collectForPage( + user: User | null, + pondId: string, + rootPageId: string, + ): Promise { + const tree = await this.prisma.page.findMany({ + where: { pondId, deletedAt: null }, + select: { id: true, parentId: true }, + }); + const subtree = collectSubtreeIds(tree, rootPageId); + const pages = await this.prisma.page.findMany({ + where: { id: { in: [...subtree] } }, + select: { + id: true, + slug: true, + title: true, + ydocState: true, + labels: { select: { labelId: true } }, + }, + orderBy: { title: 'asc' }, + }); + const readable = await this.permissions.filterPages( + user, + pondId, + pages.map((page) => ({ id: page.id, labelIds: page.labels.map((l) => l.labelId) })), + 'read', + ); + + // The live document is the base state plus the update log (merged back + // only at compaction) — apply both, or fresh edits would be invisible. + const updateRows = await this.prisma.pageUpdate.findMany({ + where: { pageId: { in: [...subtree] } }, + orderBy: { seq: 'asc' }, + select: { pageId: true, update: true }, + }); + const updatesByPage = new Map(); + for (const row of updateRows) { + const list = updatesByPage.get(row.pageId) ?? []; + list.push(row.update); + updatesByPage.set(row.pageId, list); + } + + const result: TaskOverviewPage[] = []; + // The root page leads; the readable descendants follow alphabetically. + const ordered = [ + ...pages.filter((page) => page.id === rootPageId), + ...pages.filter((page) => page.id !== rootPageId), + ]; + const mentionIds = new Set(); + const raw: { page: (typeof pages)[number]; rows: TaskRow[] }[] = []; + for (const page of ordered) { + if (!readable.has(page.id)) continue; + let rows: TaskRow[] = []; + try { + rows = extractTaskRows( + docFromStateAndUpdates(page.ydocState, updatesByPage.get(page.id) ?? []), + ); + } catch { + // An undecodable state contributes nothing rather than failing the view. + rows = []; + } + if (rows.length === 0) continue; + rows.forEach((row) => row.mentions.forEach((m) => m.userId && mentionIds.add(m.userId))); + raw.push({ page, rows }); + } + + const users = mentionIds.size + ? await this.prisma.user.findMany({ + where: { id: { in: [...mentionIds] } }, + select: { id: true, username: true, displayName: true }, + }) + : []; + const userById = new Map(users.map((user_) => [user_.id, user_])); + + for (const { page, rows } of raw) { + result.push({ + pageId: page.id, + slug: page.slug, + title: page.title, + tasks: rows.map((row) => ({ + id: row.id, + checked: row.checked, + text: row.text, + mentions: row.mentions + .map((mention) => { + const resolved = mention.userId ? userById.get(mention.userId) : undefined; + return resolved + ? { + id: resolved.id, + username: resolved.username, + displayName: resolved.displayName, + } + : { id: '', username: mention.username, displayName: mention.username }; + }) + .filter((mention) => mention.username), + startDate: row.startDate, + dueDate: row.dueDate, + })), + }); + } + return result; + } + + /** Static table for the public view / exports (issue #154) — read-only. */ + async renderStaticTable( + user: User | null, + pondId: string, + rootPageId: string, + lang: 'de' | 'en', + ): Promise { + const pages = await this.collectForPage(user, pondId, rootPageId); + const t = (key: string): string => apiI18n.t(`tasks:${key}`, { lng: lang }); + const rows = pages.flatMap((page) => + page.tasks.map( + (task) => + `` + + `${escapeHtml(task.text)}` + + `${task.mentions.map((m) => `@${escapeHtml(m.displayName)}`).join(', ')}` + + `${task.startDate ?? ''}${task.dueDate ?? ''}` + + `${escapeHtml(page.title)}`, + ), + ); + if (rows.length === 0) { + return `
    ${escapeHtml(t('empty'))}
    `; + } + return ( + `` + + `` + + `` + + `` + + `${rows.join('')}
    ${escapeHtml(t('colTask'))}${escapeHtml(t('colMentions'))}${escapeHtml(t('colStart'))}${escapeHtml(t('colDue'))}${escapeHtml(t('colPage'))}
    ` + ); + } +} diff --git a/apps/api/src/pages/yjs-content.ts b/apps/api/src/pages/yjs-content.ts index bc8d2b2..c0dc742 100644 --- a/apps/api/src/pages/yjs-content.ts +++ b/apps/api/src/pages/yjs-content.ts @@ -21,7 +21,27 @@ const FRAGMENT_NAME = 'default'; /** Thrown for state bytes that are not a well-formed Yjs update for this schema. */ export class InvalidPageStateError extends Error {} -function docFromState(state: Uint8Array): Node { +/** + * Decode a page's full current document from its base state plus the + * `page_updates` log (issue #154) — the persisted base alone lags behind + * the live document until the next compaction merges the log back. + */ +export function docFromStateAndUpdates(state: Uint8Array, updates: Uint8Array[]): Node { + const ydoc = new Y.Doc(); + try { + Y.applyUpdate(ydoc, state); + for (const update of updates) Y.applyUpdate(ydoc, update); + return yXmlFragmentToProseMirrorRootNode(ydoc.getXmlFragment(FRAGMENT_NAME), editorSchema); + } catch (error) { + throw new InvalidPageStateError(error instanceof Error ? error.message : 'invalid Yjs state'); + } finally { + ydoc.destroy(); + } +} + +/** Decode a page's stored Yjs state back into its ProseMirror document — + * also the entry point for read-time task extraction (issue #154). */ +export function docFromState(state: Uint8Array): Node { const ydoc = new Y.Doc(); try { Y.applyUpdate(ydoc, state); diff --git a/apps/api/src/public/public.service.ts b/apps/api/src/public/public.service.ts index 5e052f7..1c596b3 100644 --- a/apps/api/src/public/public.service.ts +++ b/apps/api/src/public/public.service.ts @@ -3,6 +3,7 @@ import type { PageCommentsView } from '@dorfteich/shared'; import { Pond, User } from '@prisma/client'; import { CommentsService } from '../comments/comments.service'; +import { TasksService } from '../pages/tasks.service'; import { PermissionService } from '../permissions/permission.service'; import { PluginFallbackRenderer } from '../plugins/plugin-fallback-renderer'; import { PrismaService } from '../prisma/prisma.service'; @@ -41,6 +42,7 @@ export class PublicService { private readonly fallbacks: PluginFallbackRenderer, private readonly settings: InstanceSettingsService, private readonly commentsService: CommentsService, + private readonly tasks: TasksService, ) {} private async resolve( @@ -99,7 +101,23 @@ export class PublicService { ): Promise { const cache = await this.prisma.pageContentCache.findUnique({ where: { pageId: page.id } }); const withFallbacks = await this.fallbacks.applyToHtml(cache?.html ?? ''); - return this.expandEmbeds(withFallbacks, user, pondId, depth, visited); + const withTasks = await this.expandTaskOverviews(withFallbacks, user, pondId, page.id); + return this.expandEmbeds(withTasks, user, pondId, depth, visited); + } + + /** Replaces each task-overview placeholder (issue #154) with the static, + * permission-filtered table — read-only in the public rendering. */ + private async expandTaskOverviews( + html: string, + user: User | null, + pondId: string, + pageId: string, + ): Promise { + const placeholder = /
    [^<]*<\/div>/g; + if (!placeholder.test(html)) return html; + const lang = await this.settings.get('instance.defaultLocale'); + const table = await this.tasks.renderStaticTable(user, pondId, pageId, lang); + return html.replace(placeholder, () => table); } /** diff --git a/apps/api/src/public/read-content.controller.ts b/apps/api/src/public/read-content.controller.ts index 902417d..227645a 100644 --- a/apps/api/src/public/read-content.controller.ts +++ b/apps/api/src/public/read-content.controller.ts @@ -1,6 +1,8 @@ import { Controller, Get, Param, Req } from '@nestjs/common'; +import type { TaskOverviewPage } from '@dorfteich/shared'; import { AuthedRequest } from '../auth/auth.guard'; +import { TasksService } from '../pages/tasks.service'; import { AuthenticatedOnly } from '../permissions/permission.decorators'; import { PublicPageContent, PublicService } from './public.service'; @@ -15,7 +17,22 @@ import { PublicPageContent, PublicService } from './public.service'; */ @Controller('read') export class ReadContentController { - constructor(private readonly publicPages: PublicService) {} + constructor( + private readonly publicPages: PublicService, + private readonly tasks: TasksService, + ) {} + + // The task collection (issue #154) — registered before the generic + // two-segment route so `tasks` is not read as a page slug. + @Get(':pondSlug/:pageSlug/tasks') + @AuthenticatedOnly() + async tasksOf( + @Param('pondSlug') pondSlug: string, + @Param('pageSlug') pageSlug: string, + @Req() request: AuthedRequest, + ): Promise { + return this.tasks.collect(request.user ?? null, pondSlug, pageSlug); + } // Session required (explicit access rule, issue #52); per-page read // permission is enforced in the service (resolve → canAccessPage → 404). diff --git a/apps/web/e2e/tasks.spec.ts b/apps/web/e2e/tasks.spec.ts new file mode 100644 index 0000000..cb3ea66 --- /dev/null +++ b/apps/web/e2e/tasks.spec.ts @@ -0,0 +1,119 @@ +import { expect, test } from '@playwright/test'; + +import { contextForUser } from './helpers'; + +/** + * Tasks pack (issues #150/#152/#153/#154): task lines get stable ids, the + * task overview block collects the page + subtree into a table with mention + * and date columns, and checking a box in the overview writes back to the + * source page through the collab server. Language-independent selectors. + */ +const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173'; + +type Ctx = Awaited>; + +async function personalPond(context: Ctx): Promise<{ id: string; slug: string }> { + const ponds = await context.request.get('/api/v1/ponds'); + const pond = (await ponds.json()).find((p: { type: string }) => p.type === 'personal'); + return { id: pond.id, slug: pond.slug }; +} + +test('task overview collects subtree tasks and toggles write back', async ({ browser }) => { + test.setTimeout(120_000); + const context = await contextForUser(browser, BASE_URL, 'fixture-user'); + const pond = await personalPond(context); + const ts = Date.now(); + + const parentRes = await context.request.post(`/api/v1/ponds/${pond.id}/pages`, { + data: { title: `Tasks Parent ${ts}` }, + }); + const parent = await parentRes.json(); + const childRes = await context.request.post(`/api/v1/ponds/${pond.id}/pages`, { + data: { title: `Tasks Child ${ts}`, parentId: parent.id }, + }); + const child = await childRes.json(); + + const page = await context.newPage(); + + // Child: one task line with a due date (typed, so it gets an id, #153). + await page.goto(`/p/${pond.slug}/${child.slug}`); + await page.locator('.editor-page__mode-toggle').click(); + const body = page.locator('.editor-content .ProseMirror'); + await body.click(); + await page.locator('.editor-toolbar button', { hasText: '☑' }).click(); + await page.keyboard.type('Kabel prüfen >>31.12.2026 '); + await expect(page.locator('.editor-content .dt-date--due')).toBeVisible(); + // The collab server persists debounced (~2 s) — wait until the task shows + // up in the collection before moving on. + await expect + .poll( + async () => { + const res = await context.request.get(`/api/v1/read/${pond.slug}/${child.slug}/tasks`); + return JSON.stringify(await res.json()); + }, + { timeout: 20_000 }, + ) + .toContain('Kabel prüfen'); + + // Parent: a task line plus the overview block. + await page.goto(`/p/${pond.slug}/${parent.slug}`); + await page.locator('.editor-page__mode-toggle').click(); + await page.locator('.editor-content .ProseMirror').click(); + await page.locator('.editor-toolbar button', { hasText: '☑' }).click(); + await page.keyboard.type('Bühne buchen'); + await page.keyboard.press('Enter'); + // Leave the task list (an empty item converts back to a paragraph). + await page.keyboard.press('Enter'); + await page.locator('.editor-toolbar__block-select').selectOption('builtin/tasks'); + await expect(page.locator('.dt-transclusion-card')).toBeVisible(); + await expect + .poll( + async () => { + const res = await context.request.get(`/api/v1/read/${pond.slug}/${parent.slug}/tasks`); + return ((await res.json()) as { tasks: unknown[] }[]).reduce( + (sum, p) => sum + p.tasks.length, + 0, + ); + }, + { timeout: 20_000 }, + ) + .toBe(2); + + // Read mode: the overview table lists both tasks with the date column. + await page.reload(); + const table = page.locator('.dt-task-overview-table'); + await expect(table).toBeVisible(); + await expect(table).toContainText('Bühne buchen'); + await expect(table).toContainText('Kabel prüfen'); + await expect(table.locator('tbody tr')).toHaveCount(2); + + // Toggle the child's task from the overview — it writes back through the + // collab server; after the refetch the box stays checked. + const childRow = table.locator('tbody tr', { hasText: 'Kabel prüfen' }); + const checkbox = childRow.locator('input[type="checkbox"]'); + await expect(checkbox).toBeEnabled(); + await checkbox.check(); + // Write-back travels api → NOTIFY → collab → debounced persist. + await expect + .poll( + async () => { + const res = await context.request.get(`/api/v1/read/${pond.slug}/${child.slug}/tasks`); + const pages = (await res.json()) as { tasks: { checked: boolean }[] }[]; + return pages[0]?.tasks[0]?.checked ?? false; + }, + { timeout: 20_000 }, + ) + .toBe(true); + await page.reload(); + await expect( + page + .locator('.dt-task-overview-table tbody tr', { hasText: 'Kabel prüfen' }) + .locator('input[type="checkbox"]'), + ).toBeChecked(); + + // The source page itself now shows the checked box. + await page.goto(`/p/${pond.slug}/${child.slug}`); + await expect(page.locator('.editor-content li[data-type="task_item"] input')).toBeChecked(); + + await context.close(); +}); diff --git a/apps/web/src/editor/PluginBlockMenu.tsx b/apps/web/src/editor/PluginBlockMenu.tsx index cc258a5..74a6e2c 100644 --- a/apps/web/src/editor/PluginBlockMenu.tsx +++ b/apps/web/src/editor/PluginBlockMenu.tsx @@ -24,10 +24,14 @@ export function PluginBlockMenu({ options: PluginBlockOption[]; }): React.JSX.Element | null { const { t, i18n } = useTranslation('editor'); - - if (options.length === 0) return null; + const { t: tTasks } = useTranslation('tasks'); function insert(key: string): void { + if (key === 'builtin/tasks') { + // The built-in task overview block (issue #154). + editor.chain().focus().insertContent({ type: 'task_overview' }).run(); + return; + } const [pluginId, blockType] = key.split('/'); if (!pluginId || !blockType) return; editor.chain().focus().insertPluginBlock({ pluginId, blockType }).run(); @@ -47,6 +51,7 @@ export function PluginBlockMenu({ + {options.map((option) => { const key = `${option.pluginId}/${option.blockType}`; return ( diff --git a/apps/web/src/editor/document-extensions.ts b/apps/web/src/editor/document-extensions.ts index 52ab57e..eb67fd8 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 { TaskOverview } from './nodes/task-overview'; import { TaskItemIds } from './task-item-ids'; import { Mention } from './nodes/mention'; import { Transclusion } from './nodes/transclusion'; @@ -49,6 +50,7 @@ export const documentExtensions: AnyExtension[] = [ Wikilink, Mention, DateMarker, + TaskOverview, TaskItemIds, Transclusion, Table, diff --git a/apps/web/src/editor/nodes/task-overview.tsx b/apps/web/src/editor/nodes/task-overview.tsx new file mode 100644 index 0000000..1ff1367 --- /dev/null +++ b/apps/web/src/editor/nodes/task-overview.tsx @@ -0,0 +1,155 @@ +import type { TaskOverviewPage } from '@dorfteich/shared'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { Node } from '@tiptap/core'; +import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'; +import type { NodeViewProps } from '@tiptap/react'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Link, useParams } from 'react-router-dom'; + +import { apiGet, apiPost } from '../../lib/api'; +import { attributesFromSpec, nodeSpec } from '../spec-utils'; +import { useWikilinks } from '../wikilink-context'; + +/** + * The task overview block (issue #154). Edit mode shows a placeholder card; + * read mode fetches the permission-filtered collection of the current page + + * subtree (`/read/:pond/:slug/tasks`) and renders the table. Checking a box + * posts the toggle (#153) — applied asynchronously through the collab + * server — so the UI flips optimistically and refetches shortly after. + */ +function TaskOverviewView({ editor }: NodeViewProps): React.JSX.Element { + const { t, i18n } = useTranslation('tasks'); + const { pondSlug } = useWikilinks(); + const { pageSlug = '' } = useParams<{ pageSlug: string }>(); + const queryClient = useQueryClient(); + const [optimistic, setOptimistic] = useState>({}); + const editable = editor.isEditable; + + const overview = useQuery({ + queryKey: ['page-tasks', pondSlug, pageSlug], + queryFn: () => apiGet(`/read/${pondSlug}/${pageSlug}/tasks`), + enabled: !editable && Boolean(pageSlug), + }); + + if (editable) { + return ( + + + ☑ + + {t('editorCard')} + + ); + } + + const formatDate = (iso: string | null): string => + iso + ? new Intl.DateTimeFormat(i18n.language, { dateStyle: 'medium' }).format( + new Date(`${iso}T00:00:00`), + ) + : ''; + + const toggle = async (pageId: string, taskId: string, checked: boolean): Promise => { + setOptimistic((prev) => ({ ...prev, [taskId]: checked })); + await apiPost(`/pages/${pageId}/tasks/${taskId}`, { checked }); + // The collab server applies the edit and persists it debounced (~2 s) — + // re-read a couple of times; the optimistic override stays until the + // server agrees (cleared below when the data catches up). + for (const delay of [2500, 6000]) { + setTimeout(() => { + void queryClient.invalidateQueries({ queryKey: ['page-tasks', pondSlug, pageSlug] }); + }, delay); + } + }; + + const pages = overview.data ?? []; + // Drop optimistic overrides the server data now agrees with. + const agreed = pages + .flatMap((page) => page.tasks) + .filter((task) => task.id && task.id in optimistic && optimistic[task.id] === task.checked) + .map((task) => task.id as string); + if (agreed.length > 0) { + setOptimistic((prev) => { + const next = { ...prev }; + for (const id of agreed) delete next[id]; + return next; + }); + } + const total = pages.reduce((sum, page) => sum + page.tasks.length, 0); + + return ( + + {total === 0 ? ( +
    {t('empty')}
    + ) : ( + + + + + + + + + + + + + {pages.flatMap((page) => + page.tasks.map((task, index) => { + const key = task.id ?? `${page.pageId}:${index}`; + const checked = + task.id && task.id in optimistic ? optimistic[task.id]! : task.checked; + return ( + + + + + + + + + ); + }), + )} + +
    {t('colTask')}{t('colMentions')}{t('colStart')}{t('colDue')}{t('colPage')}
    + + task.id && void toggle(page.pageId, task.id, event.target.checked) + } + /> + {task.text} + {task.mentions.map((mention) => ( + + @{mention.displayName} + + ))} + {formatDate(task.startDate)}{formatDate(task.dueDate)} + + {page.title} + +
    + )} +
    + ); +} + +const taskOverviewSpec = nodeSpec('task_overview'); +export const TaskOverview = Node.create({ + name: 'task_overview', + group: taskOverviewSpec.group, + atom: taskOverviewSpec.atom, + addAttributes() { + return attributesFromSpec(taskOverviewSpec); + }, + parseHTML: () => taskOverviewSpec.parseDOM, + renderHTML: ({ node }) => taskOverviewSpec.toDOM!(node), + addNodeView() { + return ReactNodeViewRenderer(TaskOverviewView); + }, +}); diff --git a/apps/web/src/i18n/index.ts b/apps/web/src/i18n/index.ts index befaf1d..2b946e0 100644 --- a/apps/web/src/i18n/index.ts +++ b/apps/web/src/i18n/index.ts @@ -21,6 +21,7 @@ import deSearch from '@dorfteich/shared/i18n/de/search.json'; import deSetup from '@dorfteich/shared/i18n/de/setup.json'; import deApiTokens from '@dorfteich/shared/i18n/de/apiTokens.json'; import deSystem from '@dorfteich/shared/i18n/de/system.json'; +import deTasks from '@dorfteich/shared/i18n/de/tasks.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'; @@ -47,6 +48,7 @@ import enSearch from '@dorfteich/shared/i18n/en/search.json'; import enSetup from '@dorfteich/shared/i18n/en/setup.json'; import enApiTokens from '@dorfteich/shared/i18n/en/apiTokens.json'; import enSystem from '@dorfteich/shared/i18n/en/system.json'; +import enTasks from '@dorfteich/shared/i18n/en/tasks.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'; @@ -90,6 +92,7 @@ void i18n setup: enSetup, apiTokens: enApiTokens, system: enSystem, + tasks: enTasks, users: enUsers, watches: enWatches, }, @@ -118,6 +121,7 @@ void i18n setup: deSetup, apiTokens: deApiTokens, system: deSystem, + tasks: deTasks, users: deUsers, watches: deWatches, }, diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index 77df6f4..3cedb5a 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -3843,3 +3843,32 @@ ul[data-type='task_list'] li p:last-of-type { .dt-date--overdue { color: #b91c1c; } + +/* Task overview block (issue #154). */ +.dt-task-overview-table { + width: 100%; + border-collapse: collapse; + margin: var(--space-3) 0; + font-size: 0.9375rem; +} + +.dt-task-overview-table th, +.dt-task-overview-table td { + padding: var(--space-1) var(--space-2); + border-bottom: 1px solid var(--color-border); + text-align: left; + vertical-align: top; +} + +.dt-task-overview-table th { + color: var(--color-text-muted); + font-weight: var(--font-weight-heading); +} + +.dt-task-overview-empty { + margin: var(--space-3) 0; + padding: var(--space-2) var(--space-3); + border: 1px dashed var(--color-border); + border-radius: 8px; + color: var(--color-text-muted); +} diff --git a/docs/de/manual/user-guide.md b/docs/de/manual/user-guide.md index 33be5cd..c783cfd 100644 --- a/docs/de/manual/user-guide.md +++ b/docs/de/manual/user-guide.md @@ -177,6 +177,18 @@ Hat ein Teich-Admin eine Seite öffentlich geschaltet, ist sie ohne Konto unter `/public//` lesbar — mit der Typografie des Teichs und einem Link auf die Rechtsseiten der Instanz. +## Aufgaben über Seiten hinweg + +Aufgabenzeilen können strukturierte Extras tragen: `@nutzername` +erwähnt einen Nutzer (instanzweites Autocomplete; neu Erwähnte mit +Leserecht bekommen eine Benachrichtigung), `>>2026-12-31` setzt ein +Zieldatum, `<<2026-07-01` ein Startdatum (auch `>>31.12.2026` geht — +gespeichert wird kanonisch, angezeigt in deiner Sprache). Der Block +**Aufgabenübersicht** (über die Block-Auswahl einfügen) sammelt alle +Aufgabenzeilen der aktuellen Seite und ihrer Unterseiten in einer +Tabelle — Aufgabe, Wer, Daten, Quellseite — und ein Haken dort ändert +die Quellseite für alle, live. + ## Feeds (Atom) Jeder Teich hat einen Atom-Feed seiner zuletzt angelegten und geänderten diff --git a/docs/manual/user-guide.md b/docs/manual/user-guide.md index 2483a07..4d17c5d 100644 --- a/docs/manual/user-guide.md +++ b/docs/manual/user-guide.md @@ -158,6 +158,17 @@ If a pond admin has published a page for the public, it is readable without an account at `/public//` — with the pond's typography and a link to the instance's legal pages. +## Tasks across pages + +Task lines can carry structured extras: `@username` mentions a user +(instance-wide autocomplete; newly mentioned people with read access get +a notification), `>>2026-12-31` sets a due date and `<<2026-07-01` a +start date (type `>>31.12.2026` if you prefer — stored canonically, +shown in your language). The **task overview** block (insert via the +block picker) collects every task line of the current page and its +subpages into a table — task, people, dates, source page — and checking +a box there updates the source page for everyone, live. + ## Feeds (Atom) Every pond has an Atom feed of its recently created and updated pages at diff --git a/packages/shared/i18n/de/tasks.json b/packages/shared/i18n/de/tasks.json new file mode 100644 index 0000000..2405bef --- /dev/null +++ b/packages/shared/i18n/de/tasks.json @@ -0,0 +1,11 @@ +{ + "title": "Aufgabenübersicht", + "editorCard": "Aufgabenübersicht — diese Seite und ihre Unterseiten", + "colTask": "Aufgabe", + "colMentions": "Wer", + "colStart": "Start", + "colDue": "Ziel", + "colPage": "Seite", + "empty": "Keine Aufgaben auf dieser Seite oder ihren Unterseiten.", + "unsavedHint": "Öffne die Seite einmal im Editor, damit ihre Checkboxen hier abhakbar werden." +} diff --git a/packages/shared/i18n/en/tasks.json b/packages/shared/i18n/en/tasks.json new file mode 100644 index 0000000..40f9797 --- /dev/null +++ b/packages/shared/i18n/en/tasks.json @@ -0,0 +1,11 @@ +{ + "title": "Task overview", + "editorCard": "Task overview — this page and its subpages", + "colTask": "Task", + "colMentions": "Who", + "colStart": "Start", + "colDue": "Due", + "colPage": "Page", + "empty": "No tasks on this page or its subpages.", + "unsavedHint": "Open this page once in the editor to make its checkboxes toggleable here." +} diff --git a/packages/shared/src/editor-schema/html.ts b/packages/shared/src/editor-schema/html.ts index 44f63bf..90d0847 100644 --- a/packages/shared/src/editor-schema/html.ts +++ b/packages/shared/src/editor-schema/html.ts @@ -148,6 +148,10 @@ function renderBlock(node: Node): string { ` data-plugin-data="${data}">[${pluginId}/${blockType}]
    ` ); } + case 'task_overview': + // The task overview (issue #154): a placeholder the permission-aware + // renderers (public view, exports) replace with the static table. + return '
    [tasks]
    '; case 'transclusion': { // A page embed (#135). The static HTML is a placeholder carrying the // target slug; the read view / public renderer expands it server-side to diff --git a/packages/shared/src/editor-schema/index.ts b/packages/shared/src/editor-schema/index.ts index a871a31..873cfac 100644 --- a/packages/shared/src/editor-schema/index.ts +++ b/packages/shared/src/editor-schema/index.ts @@ -6,3 +6,4 @@ export * from './plain-text'; export * from './outline'; export * from './wikilinks'; export * from './mentions'; +export * from './tasks'; diff --git a/packages/shared/src/editor-schema/markdown.ts b/packages/shared/src/editor-schema/markdown.ts index 322ae79..f779785 100644 --- a/packages/shared/src/editor-schema/markdown.ts +++ b/packages/shared/src/editor-schema/markdown.ts @@ -128,6 +128,13 @@ function transformTokens(tokens: Token[]): Token[] { } } + if (tok.type === 'fence' && tok.info.trim() === 'dorfteich-tasks') { + // The task overview block (issue #154) — an empty fence marker. + out.push(retype(tok, 'task_overview')); + i += 1; + continue; + } + if (tok.type === 'fence') { const info = PLUGIN_BLOCK_INFO.exec(tok.info.trim()); if (info) { @@ -415,6 +422,7 @@ const markdownParser = new MarkdownParser(editorSchema, createTokenizer(), { data: pluginBlockData(tok.content.trim()), }), }, + task_overview: { node: 'task_overview' }, paragraph: { block: 'paragraph' }, list_item: { block: 'list_item' }, task_item: { @@ -557,6 +565,11 @@ const markdownSerializer = new MarkdownSerializer( state.write(fence); state.closeBlock(node); }, + task_overview(state, node) { + // An empty marker fence (issue #154) — the overview has no payload. + state.write('```dorfteich-tasks\n```'); + state.closeBlock(node); + }, heading(state, node) { state.write(`${state.repeat('#', node.attrs.level as number)} `); state.renderInline(node, false); diff --git a/packages/shared/src/editor-schema/schema.ts b/packages/shared/src/editor-schema/schema.ts index 8bde30f..25c8622 100644 --- a/packages/shared/src/editor-schema/schema.ts +++ b/packages/shared/src/editor-schema/schema.ts @@ -274,6 +274,19 @@ export const editorSchema = new Schema({ }, }, + // Task overview block (issue #154): collects the task-list lines of the + // current page and its subtree into a table (text, mentioned users, + // start/due dates, source page) with write-back checkboxes. A block atom + // without payload — the collection is computed where permissions are + // known (server-side for the public view, the /read tasks endpoint for + // the editor's read mode). + task_overview: { + group: 'block', + atom: true, + parseDOM: [{ tag: 'div[data-task-overview]' }], + toDOM: () => ['div', { 'data-task-overview': '1', class: 'dt-task-overview' }, 'Tasks'], + }, + // `>>2026-12-31` / `<<2026-07-01` date marker (issue #152). An inline // atom carrying a kind ('due' for `>>`, 'start' for `<<`) and the date as // a canonical ISO `YYYY-MM-DD` string; locale-aware formatting happens in diff --git a/packages/shared/src/editor-schema/task-overview.test.ts b/packages/shared/src/editor-schema/task-overview.test.ts new file mode 100644 index 0000000..c5793bf --- /dev/null +++ b/packages/shared/src/editor-schema/task-overview.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; + +import { extractTaskRows } from './tasks'; +import { docToHtml } from './html'; +import { docToMarkdown, markdownToDoc } from './markdown'; + +describe('task overview block + task extraction (issue #154)', () => { + it('round-trips the marker fence', () => { + const doc = markdownToDoc('Intro\n\n```dorfteich-tasks\n```\n\nOutro'); + let found = 0; + doc.descendants((node) => { + if (node.type.name === 'task_overview') found += 1; + }); + expect(found).toBe(1); + expect(docToMarkdown(doc)).toContain('```dorfteich-tasks'); + }); + + it('renders the placeholder div for the permission-aware expansion', () => { + const html = docToHtml(markdownToDoc('```dorfteich-tasks\n```')); + expect(html).toContain('class="dt-task-overview" data-task-overview="1"'); + }); + + it('extracts rows with text, mentions, and dates from task lists', () => { + const doc = markdownToDoc( + '- [ ] Bühne buchen @nadia >>2026-08-01\n- [x] Kabel prüfen <<2026-07-01\n\nKein Task.', + ); + const rows = extractTaskRows(doc); + expect(rows).toHaveLength(2); + expect(rows[0]).toMatchObject({ + checked: false, + text: 'Bühne buchen', + dueDate: '2026-08-01', + startDate: null, + }); + expect(rows[0]!.mentions).toEqual([{ userId: '', username: 'nadia' }]); + expect(rows[1]).toMatchObject({ + checked: true, + text: 'Kabel prüfen', + startDate: '2026-07-01', + dueDate: null, + }); + // Markdown-born rows have no stable id yet (assigned in the editor, #153). + expect(rows[0]!.id).toBeNull(); + }); +}); diff --git a/packages/shared/src/editor-schema/tasks.ts b/packages/shared/src/editor-schema/tasks.ts new file mode 100644 index 0000000..e7c4590 --- /dev/null +++ b/packages/shared/src/editor-schema/tasks.ts @@ -0,0 +1,80 @@ +import { Node } from 'prosemirror-model'; + +/** + * Task extraction for the task overview block (issue #154): every + * `task_item` of a document as a flat row — its stable id (#153, null for + * lines not yet opened in an editor), checked state, the line's text + * (mentions and date markers excluded — they become their own columns), + * the mentioned users (#150), and the start/due dates (#152). + */ +export interface TaskRow { + id: string | null; + checked: boolean; + text: string; + mentions: { userId: string; username: string }[]; + startDate: string | null; + dueDate: string | null; +} + +export function extractTaskRows(doc: Node): TaskRow[] { + const rows: TaskRow[] = []; + doc.descendants((node) => { + if (node.type.name !== 'task_item') return; + const row: TaskRow = { + id: (node.attrs.id as string | null) ?? null, + checked: node.attrs.checked === true, + text: '', + mentions: [], + startDate: null, + dueDate: null, + }; + // The line's own content is its first paragraph; nested task lists + // produce their own rows via the outer descendants walk. + const paragraph = node.firstChild; + if (paragraph && paragraph.type.name === 'paragraph') { + paragraph.forEach((child) => { + if (child.isText) { + row.text += child.text ?? ''; + } else if (child.type.name === 'mention') { + row.mentions.push({ + userId: child.attrs.userId as string, + username: child.attrs.username as string, + }); + } else if (child.type.name === 'date_marker') { + if (child.attrs.kind === 'due') row.dueDate = child.attrs.date as string; + else row.startDate = child.attrs.date as string; + } else if (child.type.name === 'wikilink') { + row.text += + (child.attrs.displayText as string | null) ?? (child.attrs.targetSlug as string); + } + }); + } + row.text = row.text.replace(/\s+/g, ' ').trim(); + rows.push(row); + }); + return rows; +} + +/** Wire shapes of `GET /read/:pond/:slug/tasks` (issue #154). */ +export interface TaskOverviewMention { + id: string; + username: string; + displayName: string; +} + +export interface TaskOverviewRow { + /** Null for lines that never got an id — shown read-only. */ + id: string | null; + checked: boolean; + text: string; + mentions: TaskOverviewMention[]; + startDate: string | null; + dueDate: string | null; +} + +export interface TaskOverviewPage { + pageId: string; + slug: string; + title: string; + tasks: TaskOverviewRow[]; +} -- 2.45.2 From f9d23bf8d0488a5b060cba87c7984d97b18c6c2a Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Mon, 20 Jul 2026 01:25:16 +0200 Subject: [PATCH 5/7] =?UTF-8?q?#151:=20Mention-Benachrichtigungen=20=C3=BC?= =?UTF-8?q?ber=20die=20Glocke?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neuer Notification-Typ mentioned; abgeleitete Tabelle page_mentions (Migration), vom Collab-Persist transaktional neu geschrieben — der Diff gegen den Vorzustand wird als pg_notify (page_mentions_changed) emittiert, nur NEU Erwähnte lösen aus (kein Spam bei Folge-Saves). Der api-Listener (erweitert um den zweiten Kanal) ruft NotificationsService.fanoutMentions: Zustellung nur nach canAccessPage-Recheck, die Autoren (pending contributors) benachrich- tigen sich nie selbst; Payload wie gehabt mit Actor-Namen. API-seitig erzeugte Seiten seeden page_mentions aus deriveContent. Glocken-Text de+en; DB-Test (Leser ja / Outsider nein / Autor nein); kompletter Loop live verifiziert (Tippen → Persist → NOTIFY → Notification). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC --- .../migration.sql | 16 +++ apps/api/prisma/schema.prisma | 17 +++ .../src/notifications/mentions.e2e.db.test.ts | 111 ++++++++++++++++++ .../notifications/notifications.service.ts | 58 +++++++++ .../version-event-listener.service.ts | 28 ++++- apps/api/src/pages/yjs-content.ts | 5 + apps/collab/src/persistence.ts | 31 ++++- apps/collab/src/yjs-content.ts | 5 + packages/shared/i18n/de/notifications.json | 3 +- packages/shared/i18n/en/notifications.json | 3 +- packages/shared/src/collab-token.ts | 15 +++ packages/shared/src/notifications.ts | 2 +- 12 files changed, 287 insertions(+), 7 deletions(-) create mode 100644 apps/api/prisma/migrations/20260719232202_page_mentions/migration.sql create mode 100644 apps/api/src/notifications/mentions.e2e.db.test.ts diff --git a/apps/api/prisma/migrations/20260719232202_page_mentions/migration.sql b/apps/api/prisma/migrations/20260719232202_page_mentions/migration.sql new file mode 100644 index 0000000..45aa2e4 --- /dev/null +++ b/apps/api/prisma/migrations/20260719232202_page_mentions/migration.sql @@ -0,0 +1,16 @@ +-- CreateTable +CREATE TABLE "page_mentions" ( + "page_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + + CONSTRAINT "page_mentions_pkey" PRIMARY KEY ("page_id","user_id") +); + +-- CreateIndex +CREATE INDEX "page_mentions_user_id_idx" ON "page_mentions"("user_id"); + +-- AddForeignKey +ALTER TABLE "page_mentions" ADD CONSTRAINT "page_mentions_page_id_fkey" FOREIGN KEY ("page_id") REFERENCES "pages"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "page_mentions" ADD CONSTRAINT "page_mentions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index a3b88e4..227080c 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -52,6 +52,7 @@ model User { authTokens AuthToken[] apiTokens ApiToken[] feedTokens FeedToken[] + mentionRows PageMention[] ponds Pond[] pages Page[] attachments Attachment[] @@ -282,6 +283,7 @@ model Page { attachments Attachment[] versions PageVersion[] pendingContributors PagePendingContributor[] + mentionRows PageMention[] labels PageLabel[] outgoingLinks PageLink[] @relation("outgoingLinks") comments Comment[] @@ -349,6 +351,21 @@ model PageVersion { /// Collab flushes the current session's contributors here (deduplicated by the /// composite key); version creation on either side reads and clears it in the /// same transaction as writing the snapshot. Cascades on page purge (ADR 0013). +/// Derived mention index (issue #151): one row per user currently +/// mentioned in the page's document. Rewritten on every collab persist; +/// the diff against the previous rows drives the `mentioned` notifications. +model PageMention { + pageId String @map("page_id") + userId String @map("user_id") + + page Page @relation(fields: [pageId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@id([pageId, userId]) + @@index([userId]) + @@map("page_mentions") +} + model PagePendingContributor { pageId String @map("page_id") userId String @map("user_id") diff --git a/apps/api/src/notifications/mentions.e2e.db.test.ts b/apps/api/src/notifications/mentions.e2e.db.test.ts new file mode 100644 index 0000000..74d62ad --- /dev/null +++ b/apps/api/src/notifications/mentions.e2e.db.test.ts @@ -0,0 +1,111 @@ +import { INestApplication } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { createTestApp } from '../testing/test-app'; +import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; +import { NotificationsService } from './notifications.service'; + +/** + * Mention notifications (issue #151): newly mentioned users get a + * `mentioned` notification — but only with read access (no leak), and the + * mention's author (a pending contributor) never notifies themselves. + */ +describe.skipIf(!hasTestDb)('mention notifications (e2e, issue #151)', () => { + let app: INestApplication; + let prisma: PrismaClient; + const suffix = uniqueSuffix(); + + let authorId: string; + let readerId: string; + let outsiderId: string; + let pageId: string; + + beforeAll(async () => { + prisma = createTestPrisma(); + app = await createTestApp(); + + const mkUser = async (handle: string) => + ( + await prisma.user.create({ + data: { + username: `mention-${handle}-${suffix}`, + email: `mention-${handle}-${suffix}@example.test`, + displayName: `Mention ${handle}`, + status: 'ACTIVE', + }, + }) + ).id; + authorId = await mkUser('author'); + readerId = await mkUser('reader'); + outsiderId = await mkUser('outsider'); + + const pond = await prisma.pond.create({ + data: { + slug: `mention-pond-${suffix}`, + name: 'Mention Pond', + type: 'SHARED', + ownerId: authorId, + }, + }); + const page = await prisma.page.create({ + data: { + pondId: pond.id, + slug: `notes-${suffix}`, + title: 'Notes', + createdBy: authorId, + sortKey: 'a0', + ydocState: new Uint8Array(), + }, + }); + pageId = page.id; + for (const [userId, role] of [ + [authorId, 'EDITOR'], + [readerId, 'READER'], + ] as const) { + await prisma.roleGrant.create({ + data: { + pondId: pond.id, + subjectType: 'USER', + subjectId: userId, + role, + scopeType: 'POND', + scopeId: null, + effect: 'ALLOW', + createdBy: authorId, + }, + }); + } + // The author edited last — pending contributor, i.e. the acting user. + await prisma.pagePendingContributor.create({ data: { pageId, userId: authorId } }); + }); + + afterAll(async () => { + await prisma.notification.deleteMany({ + where: { userId: { in: [authorId, readerId, outsiderId] } }, + }); + await prisma.pagePendingContributor.deleteMany({ where: { pageId } }); + await prisma.roleGrant.deleteMany({ where: { pond: { ownerId: authorId } } }); + await prisma.page.deleteMany({ where: { pond: { ownerId: authorId } } }); + await prisma.pond.deleteMany({ where: { ownerId: authorId } }); + await prisma.user.deleteMany({ where: { id: { in: [authorId, readerId, outsiderId] } } }); + await prisma.$disconnect(); + await app.close(); + }); + + it('notifies mentioned readers, skips outsiders and the author', async () => { + await app.get(NotificationsService).fanoutMentions(pageId, [readerId, outsiderId, authorId]); + + const readerRows = await prisma.notification.findMany({ where: { userId: readerId } }); + expect(readerRows).toHaveLength(1); + expect(readerRows[0]!.type).toBe('mentioned'); + expect(readerRows[0]!.payload).toMatchObject({ + pageTitle: 'Notes', + actorNames: ['Mention author'], + }); + + // No read access → nothing; the author never notifies themselves. + expect(await prisma.notification.count({ where: { userId: outsiderId } })).toBe(0); + expect(await prisma.notification.count({ where: { userId: authorId } })).toBe(0); + }); +}); diff --git a/apps/api/src/notifications/notifications.service.ts b/apps/api/src/notifications/notifications.service.ts index aac1968..fc46069 100644 --- a/apps/api/src/notifications/notifications.service.ts +++ b/apps/api/src/notifications/notifications.service.ts @@ -94,6 +94,64 @@ export class NotificationsService { } } + /** + * Notifies newly mentioned users (issue #151). Independent of the watch + * table — a mention addresses the person directly — but with the same + * delivery-time read-permission re-check: whoever may not read the page + * gets nothing (no leak). The mention's authors (the page's pending + * contributors at persist time) never notify themselves. + */ + async fanoutMentions(pageId: string, mentionedUserIds: string[]): Promise { + try { + const page = await this.prisma.page.findFirst({ + where: { id: pageId, deletedAt: null }, + select: { id: true, pondId: true, title: true, slug: true }, + }); + if (!page) return; + const pond = await this.prisma.pond.findFirst({ + where: { id: page.pondId, deletedAt: null }, + select: { name: true, slug: true }, + }); + if (!pond) return; + + const contributors = await this.prisma.pagePendingContributor.findMany({ + where: { pageId }, + select: { userId: true }, + }); + const actorIds = contributors.map((row) => row.userId); + const actors = actorIds.length + ? await this.prisma.user.findMany({ + where: { id: { in: actorIds } }, + select: { displayName: true }, + }) + : []; + const payload: NotificationPayload = { + pageId: page.id, + pageTitle: page.title, + pageSlug: page.slug, + pondSlug: pond.slug, + pondName: pond.name, + actorNames: actors.slice(0, 3).map((actor) => actor.displayName), + }; + + const targets = await this.prisma.user.findMany({ + where: { id: { in: mentionedUserIds.filter((id) => !actorIds.includes(id)) } }, + }); + for (const target of targets) { + if (!(await this.permissions.canAccessPage(target, page, 'read'))) continue; + await this.prisma.notification.create({ + data: { + userId: target.id, + type: 'mentioned', + payload: payload as unknown as Prisma.InputJsonObject, + }, + }); + } + } catch (error) { + this.logger.warn({ pageId, err: error }, 'mention fan-out failed'); + } + } + async list(user: User, page: number): Promise { const where = { userId: user.id }; const [total, unreadCount] = [ diff --git a/apps/api/src/notifications/version-event-listener.service.ts b/apps/api/src/notifications/version-event-listener.service.ts index 9c6f805..c4d4b79 100644 --- a/apps/api/src/notifications/version-event-listener.service.ts +++ b/apps/api/src/notifications/version-event-listener.service.ts @@ -1,5 +1,10 @@ import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; -import { PAGE_VERSION_CREATED_CHANNEL, type PageVersionCreatedEvent } from '@dorfteich/shared'; +import { + PAGE_MENTIONS_CHANGED_CHANNEL, + PAGE_VERSION_CREATED_CHANNEL, + type PageMentionsChangedEvent, + type PageVersionCreatedEvent, +} from '@dorfteich/shared'; import { PinoLogger } from 'nestjs-pino'; import { Client } from 'pg'; @@ -49,12 +54,17 @@ export class VersionEventListener implements OnModuleInit, OnModuleDestroy { client.on('error', () => this.scheduleReconnect()); client.on('end', () => this.scheduleReconnect()); client.on('notification', (message) => { - if (message.channel !== PAGE_VERSION_CREATED_CHANNEL || !message.payload) return; - void this.handle(message.payload); + if (!message.payload) return; + if (message.channel === PAGE_VERSION_CREATED_CHANNEL) void this.handle(message.payload); + // Newly added mentions from a collab persist (issue #151). + if (message.channel === PAGE_MENTIONS_CHANGED_CHANNEL) { + void this.handleMentions(message.payload); + } }); try { await client.connect(); await client.query(`LISTEN ${PAGE_VERSION_CREATED_CHANNEL}`); + await client.query(`LISTEN ${PAGE_MENTIONS_CHANGED_CHANNEL}`); this.logger.info({}, 'listening for collab version events'); } catch (error) { this.logger.warn({ err: error }, 'version-event listener could not connect'); @@ -79,4 +89,16 @@ export class VersionEventListener implements OnModuleInit, OnModuleDestroy { this.logger.warn({ err: error }, 'ignoring malformed version event'); } } + + private async handleMentions(payload: string): Promise { + try { + const event = JSON.parse(payload) as PageMentionsChangedEvent; + if (!event.pageId || !Array.isArray(event.addedUserIds) || event.addedUserIds.length === 0) { + return; + } + await this.notifications.fanoutMentions(event.pageId, event.addedUserIds); + } catch (error) { + this.logger.warn({ err: error }, 'ignoring malformed mentions event'); + } + } } diff --git a/apps/api/src/pages/yjs-content.ts b/apps/api/src/pages/yjs-content.ts index c0dc742..a216d27 100644 --- a/apps/api/src/pages/yjs-content.ts +++ b/apps/api/src/pages/yjs-content.ts @@ -5,6 +5,7 @@ import { editorSchema, extractOutline, OutlineEntry, + extractMentionUserIds, extractWikilinkSlugs, } from '@dorfteich/shared'; import { Node } from 'prosemirror-model'; @@ -93,6 +94,9 @@ export interface DerivedPageContent { * api (imports, phantom-create) seed their `page_links` rows from this — * collab, the content writer, rewrites them on every later save. */ wikilinkSlugs: string[]; + /** Resolved user ids of every `@mention` (issue #151) — api-created pages + * seed their `page_mentions` rows from this; collab rewrites on save. */ + mentionUserIds: string[]; } function imageFileIdsOf(doc: Node): string[] { @@ -119,5 +123,6 @@ export function deriveContent(state: Uint8Array): DerivedPageContent { outline: extractOutline(doc), imageFileIds: imageFileIdsOf(doc), wikilinkSlugs: extractWikilinkSlugs(doc), + mentionUserIds: extractMentionUserIds(doc), }; } diff --git a/apps/collab/src/persistence.ts b/apps/collab/src/persistence.ts index b220683..f8bca38 100644 --- a/apps/collab/src/persistence.ts +++ b/apps/collab/src/persistence.ts @@ -1,4 +1,8 @@ -import { MAX_PAGE_DOCUMENT_BYTES, normalizeForSearch } from '@dorfteich/shared'; +import { + MAX_PAGE_DOCUMENT_BYTES, + PAGE_MENTIONS_CHANGED_CHANNEL, + normalizeForSearch, +} from '@dorfteich/shared'; import type { Pool } from 'pg'; import * as Y from 'yjs'; @@ -203,6 +207,31 @@ export class PostgresPagePersistence implements PagePersistence { ); } + // Rewrite the mention index (issue #151); newly added user ids become a + // NOTIFY the api turns into `mentioned` notifications. Inside the + // transaction on purpose — pg_notify only fires on COMMIT. + const previousMentions = await client.query<{ user_id: string }>( + 'SELECT user_id FROM page_mentions WHERE page_id = $1', + [pageId], + ); + await client.query('DELETE FROM page_mentions WHERE page_id = $1', [pageId]); + if (derived.mentionUserIds.length > 0) { + await client.query( + `INSERT INTO page_mentions (page_id, user_id) + SELECT $1, u.id FROM unnest($2::text[]) AS m(user_id) + JOIN users u ON u.id = m.user_id`, + [pageId, derived.mentionUserIds], + ); + } + const known = new Set(previousMentions.rows.map((row) => row.user_id)); + const added = derived.mentionUserIds.filter((id) => !known.has(id)); + if (added.length > 0) { + await client.query('SELECT pg_notify($1, $2)', [ + PAGE_MENTIONS_CHANGED_CHANNEL, + JSON.stringify({ pageId, addedUserIds: added }), + ]); + } + await client.query('COMMIT'); this.lastStoredVector.set(pageId, nextVector); return { outcome: 'stored', bytes: full.byteLength, durationMs: durationOf(), merged }; diff --git a/apps/collab/src/yjs-content.ts b/apps/collab/src/yjs-content.ts index d19c353..b8468e5 100644 --- a/apps/collab/src/yjs-content.ts +++ b/apps/collab/src/yjs-content.ts @@ -4,6 +4,7 @@ import { docToPlainText, editorSchema, extractOutline, + extractMentionUserIds, extractWikilinkSlugs, type OutlineEntry, } from '@dorfteich/shared'; @@ -44,6 +45,9 @@ export interface DerivedPageContent { /** Distinct target slugs of every `[[wikilink]]`, for the `page_links` * index (issue #47). */ wikilinkSlugs: string[]; + /** Distinct resolved user ids of every `@mention`, for the + * `page_mentions` index and the mention notifications (issue #151). */ + mentionUserIds: string[]; } function imageFileIdsOf(doc: Node): string[] { @@ -70,5 +74,6 @@ export function deriveContentFromDoc(ydoc: Y.Doc): DerivedPageContent { outline: extractOutline(doc), imageFileIds: imageFileIdsOf(doc), wikilinkSlugs: extractWikilinkSlugs(doc), + mentionUserIds: extractMentionUserIds(doc), }; } diff --git a/packages/shared/i18n/de/notifications.json b/packages/shared/i18n/de/notifications.json index 7459b8b..ef9614c 100644 --- a/packages/shared/i18n/de/notifications.json +++ b/packages/shared/i18n/de/notifications.json @@ -5,7 +5,8 @@ "someone": "Jemand", "types": { "page_changed": "{{actor}} hat „{{page}}“ in {{pond}} geändert", - "comment_added": "{{actor}} hat „{{page}}“ in {{pond}} kommentiert" + "comment_added": "{{actor}} hat „{{page}}“ in {{pond}} kommentiert", + "mentioned": "{{actor}} hat dich auf „{{page}}“ in {{pond}} erwähnt" }, "digest": { "label": "E-Mail-Digest", diff --git a/packages/shared/i18n/en/notifications.json b/packages/shared/i18n/en/notifications.json index 201bf7d..88799e9 100644 --- a/packages/shared/i18n/en/notifications.json +++ b/packages/shared/i18n/en/notifications.json @@ -5,7 +5,8 @@ "someone": "Someone", "types": { "page_changed": "{{actor}} changed “{{page}}” in {{pond}}", - "comment_added": "{{actor}} commented on “{{page}}” in {{pond}}" + "comment_added": "{{actor}} commented on “{{page}}” in {{pond}}", + "mentioned": "{{actor}} mentioned you on “{{page}}” in {{pond}}" }, "digest": { "label": "E-mail digest", diff --git a/packages/shared/src/collab-token.ts b/packages/shared/src/collab-token.ts index a81fd8b..2cf0578 100644 --- a/packages/shared/src/collab-token.ts +++ b/packages/shared/src/collab-token.ts @@ -56,6 +56,21 @@ export interface PageVersionCreatedEvent { */ export const TASK_TOGGLE_CHANNEL = 'task_toggle'; +/** + * PostgreSQL `NOTIFY` channel over which the collab server announces that a + * persist added new user mentions to a page (issue #151). The api listens + * and creates the `mentioned` notifications — permission-checked there. + * Payload is a JSON {@link PageMentionsChangedEvent}. + */ +export const PAGE_MENTIONS_CHANGED_CHANNEL = 'page_mentions_changed'; + +/** JSON payload carried on {@link PAGE_MENTIONS_CHANGED_CHANNEL}. */ +export interface PageMentionsChangedEvent { + pageId: string; + /** Users newly mentioned by this persist (diff against the stored rows). */ + addedUserIds: string[]; +} + /** JSON payload carried on {@link TASK_TOGGLE_CHANNEL}. */ export interface TaskToggleRequest { pageId: string; diff --git a/packages/shared/src/notifications.ts b/packages/shared/src/notifications.ts index 5dc050f..e097204 100644 --- a/packages/shared/src/notifications.ts +++ b/packages/shared/src/notifications.ts @@ -7,7 +7,7 @@ import { z } from 'zod'; * permission at delivery time. */ -export const NOTIFICATION_TYPES = ['page_changed', 'comment_added'] as const; +export const NOTIFICATION_TYPES = ['page_changed', 'comment_added', 'mentioned'] as const; export type NotificationType = (typeof NOTIFICATION_TYPES)[number]; export interface NotificationPayload { -- 2.45.2 From 5444c39458bd4af54a46d1bd36e2660da530cab4 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Mon, 20 Jul 2026 01:54:29 +0200 Subject: [PATCH 6/7] e2e-Fixes nach CI: section-styles-Selektor eindeutig, settings-nav-Timing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Das Block-Menü ist seit dem eingebauten Aufgabenübersicht-Eintrag (#154) immer sichtbar und teilt die Styling-Klasse editor-toolbar__section-select — der section-styles-Pack adressiert das Abschnitts-Select jetzt per :not(.editor-toolbar__block-select). settings-nav: toBeInViewport bekommt 10 s für Smooth-Scroll auf langsamen Runnern. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC --- apps/web/e2e/section-styles.spec.ts | 4 +++- apps/web/e2e/settings-nav.spec.ts | 7 +++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/web/e2e/section-styles.spec.ts b/apps/web/e2e/section-styles.spec.ts index 9d37233..46c20ec 100644 --- a/apps/web/e2e/section-styles.spec.ts +++ b/apps/web/e2e/section-styles.spec.ts @@ -83,7 +83,9 @@ test('wrap, restyle in read mode, unwrap, and neutral fallback when disabled', a await page.keyboard.type('Boxed content'); // Wrap the paragraph in the plugin's style via the toolbar picker. - const picker = page.locator('.editor-toolbar__section-select'); + // Not the block picker: it shares the styling class and is always + // visible since the built-in task overview entry (#154). + const picker = page.locator('.editor-toolbar__section-select:not(.editor-toolbar__block-select)'); await picker.selectOption(`${PLUGIN_ID}/boxed`); const section = content.locator(`.dt-section.dt-style-${PLUGIN_ID}-boxed`); await expect(section).toContainText('Boxed content'); diff --git a/apps/web/e2e/settings-nav.spec.ts b/apps/web/e2e/settings-nav.spec.ts index 7e199fa..bab26b2 100644 --- a/apps/web/e2e/settings-nav.spec.ts +++ b/apps/web/e2e/settings-nav.spec.ts @@ -31,7 +31,8 @@ test('user settings show the jump nav and clicking scrolls + activates', async ( const last = links.last(); await last.click(); const lastSection = page.locator('.settings-layout section[id]').last(); - await expect(lastSection).toBeInViewport(); + // Smooth scrolling needs a moment on a loaded CI runner. + await expect(lastSection).toBeInViewport({ timeout: 10_000 }); await expect(last).toHaveClass(/settings-nav__link--active/); await context.close(); @@ -54,7 +55,9 @@ test('pond settings derive the nav from their sections', async ({ browser }) => expect(await links.count()).toBeGreaterThanOrEqual(8); await links.last().click(); - await expect(page.locator('.settings-layout section[id]').last()).toBeInViewport(); + await expect(page.locator('.settings-layout section[id]').last()).toBeInViewport({ + timeout: 10_000, + }); await context.close(); }); -- 2.45.2 From 58f175af32a43ccb654c175896526482d6454780 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Mon, 20 Jul 2026 02:11:35 +0200 Subject: [PATCH 7/7] settings-nav robust: Sofort-Sprung statt Smooth-Scroll, Spec wartet auf networkidle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der animierte scrollIntoView landete auf einer veralteten Zielposition, wenn Query-Sektionen (Sessions/Tokens) während der Animation noch wuchsen — auf dem CI-Runner deterministisch rot. Jetzt springt die Navigation sofort; der Spec lässt die asynchronen Inhalte vor dem Klick settlen (networkidle) und lief lokal 10× ohne Retry grün. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC --- apps/web/e2e/settings-nav.spec.ts | 4 ++++ apps/web/src/components/SettingsLayout.tsx | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/web/e2e/settings-nav.spec.ts b/apps/web/e2e/settings-nav.spec.ts index bab26b2..87accf8 100644 --- a/apps/web/e2e/settings-nav.spec.ts +++ b/apps/web/e2e/settings-nav.spec.ts @@ -20,6 +20,9 @@ test('user settings show the jump nav and clicking scrolls + activates', async ( const context = await contextForUser(browser, BASE_URL, 'fixture-user'); const page = await context.newPage(); await page.goto('/settings'); + // Let the async section content (sessions, tokens, …) settle first — + // sections growing above the target would push it out of view again. + await page.waitForLoadState('networkidle'); const nav = page.locator('.settings-nav'); await expect(nav).toBeVisible(); @@ -46,6 +49,7 @@ test('pond settings derive the nav from their sections', async ({ browser }) => const page = await context.newPage(); await page.goto(`/p/${pond.slug}/settings`); + await page.waitForLoadState('networkidle'); const links = page.locator('.settings-nav .settings-nav__link'); // The pond owner sees the full section stack — at least members, labels, diff --git a/apps/web/src/components/SettingsLayout.tsx b/apps/web/src/components/SettingsLayout.tsx index 5c7e88e..4589ea3 100644 --- a/apps/web/src/components/SettingsLayout.tsx +++ b/apps/web/src/components/SettingsLayout.tsx @@ -102,7 +102,9 @@ export function SettingsLayout({ children }: { children: React.ReactNode }): Rea }, []); const jump = (id: string): void => { - document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + // Instant, not smooth: async section content (queries) can still grow + // during an animation, leaving it at a stale target position. + document.getElementById(id)?.scrollIntoView({ block: 'start' }); setActive(id); }; -- 2.45.2