diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index c88a544..5b67244 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -222,6 +222,16 @@ jobs: E2E_BASE_URL=http://localhost:5173 \ pnpm --filter @dorfteich/web exec playwright test e2e/system.spec.ts + - name: Reset login rate limit before comments 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 comments pack + run: | + E2E_BASE_URL=http://localhost:5173 \ + pnpm --filter @dorfteich/web exec playwright test e2e/comments.spec.ts + - name: Reset login rate limit before admin-quotas pack run: | echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ diff --git a/apps/api/src/comments/comments.e2e.db.test.ts b/apps/api/src/comments/comments.e2e.db.test.ts index 2b9030d..32b5551 100644 --- a/apps/api/src/comments/comments.e2e.db.test.ts +++ b/apps/api/src/comments/comments.e2e.db.test.ts @@ -194,7 +194,14 @@ describe.skipIf(!hasTestDb)('comments (e2e, issue #91)', () => { }); it('enforces the editors-only policy', async () => { - await setPolicy('editors'); + // Through the real pond PATCH — guards the settings merge in + // PondsService.update (a silently dropped commentPolicy broke the UI + // pack during #92). + await api() + .patch(`/api/v1/ponds/${pondId}`) + .set('Cookie', cookies.owner!) + .send({ commentPolicy: 'editors' }) + .expect(200); await api() .post(`/api/v1/pages/${pageId}/comments`) .set('Cookie', cookies.reader!) diff --git a/apps/api/src/ponds/ponds.service.ts b/apps/api/src/ponds/ponds.service.ts index 06207ff..072a5d6 100644 --- a/apps/api/src/ponds/ponds.service.ts +++ b/apps/api/src/ponds/ponds.service.ts @@ -146,16 +146,20 @@ export class PondsService { async update(_user: User, id: string, input: UpdatePondInput): Promise { const pond = await this.prisma.pond.findFirst({ where: { id, deletedAt: null } }); if (!pond) throw new NotFoundException(); - // Stored settings hold only deviations from the defaults; merge in whichever - // of `sidebarSort` / `fonts` this request changes (issue #26 / #66). - const settings = - input.sidebarSort === undefined && input.fonts === undefined - ? undefined - : { - ...(pond.settings as object), - ...(input.sidebarSort !== undefined ? { sidebarSort: input.sidebarSort } : {}), - ...(input.fonts !== undefined ? { fonts: input.fonts } : {}), - }; + // Stored settings hold only deviations from the defaults; merge in + // whichever of the settings keys this request changes (#26/#66/#91). + const settingsChanged = + input.sidebarSort !== undefined || + input.fonts !== undefined || + input.commentPolicy !== undefined; + const settings = !settingsChanged + ? undefined + : { + ...(pond.settings as object), + ...(input.sidebarSort !== undefined ? { sidebarSort: input.sidebarSort } : {}), + ...(input.fonts !== undefined ? { fonts: input.fonts } : {}), + ...(input.commentPolicy !== undefined ? { commentPolicy: input.commentPolicy } : {}), + }; const updated = await this.prisma.pond.update({ where: { id }, data: { name: input.name, description: input.description, settings }, diff --git a/apps/web/e2e/comments.spec.ts b/apps/web/e2e/comments.spec.ts new file mode 100644 index 0000000..0e0d855 --- /dev/null +++ b/apps/web/e2e/comments.spec.ts @@ -0,0 +1,150 @@ +import { expect, test, type BrowserContext } from '@playwright/test'; + +import { contextForUser } from './helpers'; + +const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173'; + +/** + * Comments UI (issue #92): the full two-user lifecycle in the panel, + * resolve/unresolve with the collapsed section, the permission variant + * (composer hidden with a hint), and the localStorage unread badge. + * The pack provisions its own page and grant, so it is repeatable. + */ + +let pondId: string; +let pageUrl: string; +let pageId: string; +let readerUserId: string; + +async function pondOf(owner: BrowserContext): Promise<{ id: string; slug: string }> { + const ponds = (await (await owner.request.get('/api/v1/ponds')).json()) as { + id: string; + slug: string; + type: string; + }[]; + const shared = ponds.find((p) => p.type === 'shared'); + expect(shared).toBeTruthy(); + return shared!; +} + +test.beforeAll(async ({ browser }) => { + const owner = await contextForUser(browser, BASE_URL, 'fixture-user'); + const pond = await pondOf(owner); + pondId = pond.id; + + // Fresh page per run — never depends on fixture pages' trash state. + const created = await owner.request.post(`/api/v1/ponds/${pondId}/pages`, { + data: { title: `Comment stage ${Date.now()}` }, + }); + expect(created.ok()).toBe(true); + const page = (await created.json()) as { id: string; slug: string }; + pageId = page.id; + pageUrl = `/p/${pond.slug}/${page.slug}`; + + // fixture-editor becomes a reader-member of the pond (the second user); + // a leftover membership from an aborted run just yields a conflict we ignore. + await owner.request.post(`/api/v1/ponds/${pondId}/members`, { + data: { usernameOrEmail: 'fixture-editor', role: 'reader' }, + }); + const members = (await (await owner.request.get(`/api/v1/ponds/${pondId}/members`)).json()) as { + members: { id: string; username: string }[]; + }; + readerUserId = members.members.find((m) => m.username === 'fixture-editor')!.id; + // Deterministic starting policy. + await owner.request.patch(`/api/v1/ponds/${pondId}`, { data: { commentPolicy: 'readers' } }); + await owner.close(); +}); + +test.afterAll(async ({ browser }) => { + const owner = await contextForUser(browser, BASE_URL, 'fixture-user'); + await owner.request.patch(`/api/v1/ponds/${pondId}`, { data: { commentPolicy: 'readers' } }); + if (readerUserId) await owner.request.delete(`/api/v1/ponds/${pondId}/members/${readerUserId}`); + if (pageId) await owner.request.delete(`/api/v1/pages/${pageId}`); + await owner.close(); +}); + +test('two users run the full comment lifecycle with resolve/unresolve', async ({ browser }) => { + // The reader starts the thread. + const reader = await contextForUser(browser, BASE_URL, 'fixture-editor'); + const readerPage = await reader.newPage(); + await readerPage.goto(pageUrl); + await readerPage.locator('.editor-shell__comments-toggle').click(); + const readerPanel = readerPage.locator('.comments-panel'); + await readerPanel.locator('.comments-composer textarea').fill('Is this **final**?'); + await readerPanel.locator('.comments-composer button[type="submit"]').click(); + await expect(readerPanel.locator('.comment__body strong')).toHaveText('final'); + + // The owner sees the unread badge, replies, and resolves the thread. + const owner = await contextForUser(browser, BASE_URL, 'fixture-user'); + const ownerPage = await owner.newPage(); + await ownerPage.goto(pageUrl); + await expect(ownerPage.locator('.comments-unread-badge')).toBeVisible(); + await ownerPage.locator('.editor-shell__comments-toggle').click(); + const ownerPanel = ownerPage.locator('.comments-panel'); + await ownerPanel + .locator('.comments-thread .comments-link-button', { hasText: /reply|antwort/i }) + .click(); + await ownerPanel.locator('.comments-thread textarea').fill('Yes, shipping it.'); + await ownerPanel.locator('.comments-thread button[type="submit"]').click(); + await expect(ownerPanel.locator('.comments-thread__replies .comment__body')).toContainText( + 'shipping', + ); + + // Resolve collapses the thread into the resolved section. + await ownerPanel + .locator('.comment__actions .comments-link-button', { hasText: /resolve|erledig/i }) + .first() + .click(); + const resolvedSection = ownerPanel.locator('.comments-panel__resolved'); + await expect(resolvedSection).toBeVisible(); + await expect(resolvedSection.locator('details, summary').first()).toBeVisible(); + // Collapsed: the thread body is hidden until the section is opened. + await expect(resolvedSection.locator('.comment__body strong')).toBeHidden(); + await resolvedSection.locator('summary').click(); + await expect(resolvedSection.locator('.comment__body strong')).toBeVisible(); + + // Unresolve restores it to the open list. + await resolvedSection.locator('.comments-link-button', { hasText: /reopen|öffnen/i }).click(); + await expect(ownerPanel.locator('.comments-panel__resolved')).toHaveCount(0); + await expect(ownerPanel.locator('.comments-thread .comment__body strong')).toBeVisible(); + + // Author edits and deletes own reply. + const replyItem = ownerPanel.locator('.comments-thread__replies .comment'); + await replyItem.locator('.comments-link-button', { hasText: /edit|bearbeit/i }).click(); + await replyItem.locator('textarea').fill('Yes — shipped.'); + await replyItem.locator('button[type="submit"]').click(); + await expect(replyItem.locator('.comment__body')).toContainText('shipped.'); + await expect(replyItem.locator('.comment__edited')).toBeVisible(); + await replyItem.locator('.comments-link-button', { hasText: /delete|löschen/i }).click(); + await expect(ownerPanel.locator('.comments-thread__replies .comment')).toHaveCount(0); + + // Cleanup: the reader deletes their own (now reply-free) root. + await readerPage.reload(); + await readerPage.locator('.editor-shell__comments-toggle').click(); + await readerPanel.locator('.comments-link-button', { hasText: /delete|löschen/i }).click(); + await expect(readerPanel.locator('.comments-panel__empty')).toBeVisible(); + + await reader.close(); + await owner.close(); +}); + +test('the composer hides with a hint when the policy bars readers', async ({ browser }) => { + const owner = await contextForUser(browser, BASE_URL, 'fixture-user'); + await owner.request.patch(`/api/v1/ponds/${pondId}`, { data: { commentPolicy: 'editors' } }); + await owner.close(); + + const reader = await contextForUser(browser, BASE_URL, 'fixture-editor'); + const page = await reader.newPage(); + await page.goto(pageUrl); + await page.locator('.editor-shell__comments-toggle').click(); + const panel = page.locator('.comments-panel'); + await expect(panel.locator('.comments-panel__policy-hint')).toBeVisible(); + await expect(panel.locator('.comments-composer')).toHaveCount(0); + await reader.close(); + + const ownerAgain = await contextForUser(browser, BASE_URL, 'fixture-user'); + await ownerAgain.request.patch(`/api/v1/ponds/${pondId}`, { + data: { commentPolicy: 'readers' }, + }); + await ownerAgain.close(); +}); diff --git a/apps/web/src/comments/CommentPolicySetting.tsx b/apps/web/src/comments/CommentPolicySetting.tsx new file mode 100644 index 0000000..399ead2 --- /dev/null +++ b/apps/web/src/comments/CommentPolicySetting.tsx @@ -0,0 +1,53 @@ +import type { CommentPolicy } from '@dorfteich/shared'; +import { useQueryClient } from '@tanstack/react-query'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { FormError, FormSuccess } from '../components/forms'; +import { apiPatch } from '../lib/api'; + +/** + * The pond's "who may comment" setting (issue #91/#92): every reader, or + * pond-wide editors only. Rides the generic pond PATCH. + */ +export function CommentPolicySetting({ + pondId, + pondSlug, + value, +}: { + pondId: string; + pondSlug: string; + value: CommentPolicy; +}): React.JSX.Element { + const { t } = useTranslation('comments'); + const queryClient = useQueryClient(); + const [error, setError] = useState(null); + const [saved, setSaved] = useState(false); + + const save = async (policy: CommentPolicy): Promise => { + setError(null); + setSaved(false); + try { + await apiPatch(`/ponds/${pondId}`, { commentPolicy: policy }); + await queryClient.invalidateQueries({ queryKey: ['pond', pondSlug] }); + setSaved(true); + } catch (err) { + setError(err); + } + }; + + return ( +
+ + + +

{t('policy.hint')}

+
+ ); +} diff --git a/apps/web/src/comments/CommentsPanel.tsx b/apps/web/src/comments/CommentsPanel.tsx new file mode 100644 index 0000000..12c1f9f --- /dev/null +++ b/apps/web/src/comments/CommentsPanel.tsx @@ -0,0 +1,330 @@ +import type { CommentThreadView, CommentView } from '@dorfteich/shared'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useAuth } from '../auth/auth-context'; +import { FormError } from '../components/forms'; +import { apiDelete, apiPatch, apiPost } from '../lib/api'; + +import { markCommentsSeen, useComments, useInvalidateComments } from './use-comments'; + +/** + * The page's discussion panel (issue #92): threaded comments with a + * Markdown composer, edit/delete for authors, resolve with a collapsed + * resolved section, and the permission-aware composer (hidden with a hint + * when the pond's policy bars the viewer). + */ +export function CommentsPanel({ + pageId, + mayComment, + onClose, +}: { + pageId: string; + /** Resolved by the caller from the pond's commentPolicy + collab mode. */ + mayComment: boolean; + onClose: () => void; +}): React.JSX.Element { + const { t } = useTranslation('comments'); + const comments = useComments(pageId); + const refresh = useInvalidateComments(pageId); + const [error, setError] = useState(null); + + // Opening the panel is "visiting" the discussion: the unread badge resets. + useEffect(() => { + markCommentsSeen(pageId); + return () => markCommentsSeen(pageId); + }, [pageId, comments.data]); + + const run = async (action: () => Promise): Promise => { + setError(null); + try { + await action(); + await refresh(); + } catch (err) { + setError(err); + } + }; + + const view = comments.data; + const open = (view?.threads ?? []).filter((thread) => !thread.resolved); + const resolved = (view?.threads ?? []).filter((thread) => thread.resolved); + + return ( +
+
+

+ {t('title')} + {view && view.openCount > 0 && ( + {view.openCount} + )} +

+ +
+ + + {mayComment ? ( + run(() => apiPost(`/pages/${pageId}/comments`, { body }))} + /> + ) : ( +

{t('composer.editorsOnly')}

+ )} + + {view && open.length === 0 && resolved.length === 0 && ( +

{t('empty')}

+ )} + +
    + {open.map((thread) => ( + + ))} +
+ + {resolved.length > 0 && ( +
+ {t('resolvedSection', { count: resolved.length })} +
    + {resolved.map((thread) => ( + + ))} +
+
+ )} +
+ ); +} + +function Thread({ + thread, + pageId, + mayComment, + run, +}: { + thread: CommentThreadView; + pageId: string; + mayComment: boolean; + run: (action: () => Promise) => Promise; +}): React.JSX.Element { + const { t } = useTranslation('comments'); + const [replying, setReplying] = useState(false); + + return ( +
  • + +
      + {thread.replies.map((reply) => ( +
    • + +
    • + ))} +
    + {mayComment && !thread.resolved && ( +
    + {replying ? ( + setReplying(false)} + onSubmit={async (body) => { + await run(() => + apiPost(`/pages/${pageId}/comments`, { body, parentId: thread.root.id }), + ); + setReplying(false); + }} + /> + ) : ( + + )} +
    + )} +
  • + ); +} + +function CommentItem({ + comment, + run, + isRoot, + resolved, +}: { + comment: CommentView; + run: (action: () => Promise) => Promise; + isRoot: boolean; + resolved: boolean; +}): React.JSX.Element { + const { t, i18n } = useTranslation('comments'); + const { user } = useAuth(); + const [editing, setEditing] = useState(false); + const own = user?.id === comment.author?.id; + + return ( +
    +
    + {authorName(comment, t)} + + {comment.editedAt && {t('edited')}} +
    + {editing ? ( + setEditing(false)} + onSubmit={async (body) => { + await run(() => apiPatch(`/comments/${comment.id}`, { body })); + setEditing(false); + }} + /> + ) : ( + // Server-sanitized render (shared pipeline, issue #91) — safe by contract. +
    + )} +
    + {own && !editing && ( + <> + + + + )} + {isRoot && + (resolved ? ( + + ) : ( + + ))} +
    +
    + ); +} + +function Composer({ + label, + submitLabel, + onSubmit, + onCancel, + initialValue = '', + autoFocus = false, +}: { + label: string; + submitLabel: string; + onSubmit: (body: string) => Promise | void; + onCancel?: () => void; + initialValue?: string; + autoFocus?: boolean; +}): React.JSX.Element { + const { t } = useTranslation('comments'); + const [body, setBody] = useState(initialValue); + const [busy, setBusy] = useState(false); + + const submit = async (): Promise => { + const trimmed = body.trim(); + if (!trimmed || busy) return; + setBusy(true); + try { + await onSubmit(trimmed); + setBody(''); + } finally { + setBusy(false); + } + }; + + return ( +
    { + event.preventDefault(); + void submit(); + }} + > +