feat(chronik): add commentPreview field to ActivityFeedItemDTO #454
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Context
ChronikRow.svelterenders a placeholder ellipsis („…") for comment-type activity feed items becauseActivityFeedItemDTOdoes not yet expose acommentPreviewfield. The placeholder was chosen deliberately to avoid duplicating the document title (Leonie, PR #288 review).Problem
Comment rows in the Chronik feed show no content preview, reducing the usefulness of the activity timeline for readers trying to find a specific comment.
Acceptance criteria
ActivityFeedItemDTOgains acommentPreview: Stringfield (truncated, tags stripped server-side — max ~120 chars)ChronikRow.svelterenders{item.commentPreview}via{text}binding — never{@html}npm run generate:apirun after backend changeSecurity note
⚠️ When implementing, render the preview via
{text}(plain text binding), never{@html}. The backend must truncate and strip tags server-side. XSS risk if raw HTML is passed through. (Nora, issue #285 comment #3552)UX note
The preview must handle truncation gracefully for the 60+ audience — line-clamp with
aria-labelcontaining full text for screen readers. (Leonie)Notes
Extracted from a TODO comment in
ChronikRow.svelte:163during CLEANUP-2 (#413).👨💻 Felix Brandt — Senior Fullstack Developer
Observations
DashboardService.getActivity()(line 114) already batch-loads comment data viaCommentService.findAnnotationIdsByIds(). The preview belongs to the same batch pass — not a second round-trip.DocumentComment.contentis stored as raw HTML (TipTap output,TEXTcolumn). It will contain<strong>,<em>,<p>, mention spans, etc. — server-side stripping is non-negotiable.ActivityFeedRowinterface lives inaudit/and does not need agetCommentPreview()method. The preview isdocument.commentdomain data, not audit-log data. It's fetched separately, exactly asannotationIdis fetched.ChronikRow.svelte:162–169already has the placeholder<p>withline-clamp-1 sm:line-clamp-2 font-serif text-sm text-ink-2 italic— only the content binding needs updating.ActivityFeedItemDTOis a Java record — adding a field means changing the constructor call inDashboardService.getActivity()at line 150.Recommendations
Backend (in implementation order):
findPreviewsByIds(Collection<UUID> commentIds): Map<UUID, String>toCommentService— batch fetch, strip HTML, truncate to 120 chars:Use
Jsoup.clean()withSafelist.none()— not a regex. Regex stripping is bypassable.Add
@Nullable String commentPreviewtoActivityFeedItemDTOrecord with@Schema(requiredMode = NOT_REQUIRED).In
DashboardService.getActivity(), extend the existing comment batch to also load previews:Frontend:
Replace the placeholder paragraph body in
ChronikRow.svelte:167–169:The
{text}binding is implicit in Svelte — no{@html}. This is correct. The fallback„…"preserves the current visual for the case where the preview is null (e.g. deleted comment).TDD order:
CommentService.findPreviewsByIds()— HTML stripping, truncation at 120, empty/null inputDashboardService.getActivity()—commentPreviewpopulated forCOMMENT_ADDED, null forTEXT_SAVEDChronikRow—variant === 'comment'shows preview text, not the placeholderOpen Decisions (omit this section entirely if none)
🏗️ Markus Keller — Application Architect
Observations
DashboardServicealready crosses intodocument.commentviaCommentService.findAnnotationIdsByIds(). The new preview lookup follows the exact same cross-domain call pattern. This is correct —DashboardServiceis the assembly point, not a domain boundary violation.ActivityFeedRowinterface should not grow. That interface projects audit-log columns. Comment body text is not in the audit log — it lives indocument_comments. Adding a preview getter toActivityFeedRowwould require either (a) a join in the already-complex native SQL query or (b) a placeholder getter that always returns null from the real implementation. Both are worse than the batch-fetch approach already in place.AuditLogQueryRepository.findRolledUpActivityFeed()is already complex (~100 lines). Do not add a JOIN todocument_commentsthere. The batch-fetch separation keeps the audit query pure and the comment fetch lazy.ActivityFeedItemDTOis a DTO, not an entity.Recommendations
findPreviewsByIds()onCommentService, not onDashboardService.CommentServiceowns the comment domain; preview generation (strip + truncate) is comment business logic, not dashboard logic.stripAndTruncatehelper should beprivateonCommentService— it has no value as a public utility.@Schema(requiredMode = NOT_REQUIRED, description = "Plain-text preview of the comment body; null for non-comment feed items or deleted comments.")on the newcommentPreviewfield to drive accurate TypeScript codegen. Missing description on this field would leave the frontend team guessing when it's populated.ActivityFeedItemDTO(...)construction site inDashboardService.getActivity()— use that as a natural forcing function rather than searching for call sites manually.Open Decisions (omit this section entirely if none)
🔒 Nora "NullX" Steiner — Security Engineer
Observations
The issue already flags the XSS risk and specifies both the correct backend fix (strip server-side) and the correct frontend binding (
{text}not{@html}). That's exactly right. My job here is to make sure the implementation doesn't undermine those intentions.CWE-79 (Stored XSS) — risk present if stripping is regex-based.
DocumentComment.contentis TipTap HTML. A comment could contain:Regex-based stripping (
content.replaceAll("<[^>]*>", "")) is bypassable with malformed or nested tags (<sc<script>ript>). UseJsoup.clean(html, Safelist.none())— it parses the HTML into a DOM and serializes only the text nodes. This is not bypassable.{text}binding in Svelte 5 is safe. Svelte's{expression}interpolation usestextContentassignment internally — notinnerHTML. There is no XSS risk when using{item.commentPreview}without{@html}. Confirm this is enforced via code review; the danger is a future developer "upgrading" to{@html}to preserve formatting, not realizing the XSS risk.Deleted comment edge case — not a security concern, but worth noting. If
commentIdin the audit log points to a deleted comment,findAllById()inCommentServicewill simply not return it. The preview will be null. This is correct behaviour — no error, no data leak.Recommendations
stripAndTruncatehelper:If Jsoup is not yet in
pom.xml, add it. It's already a standard dependency in this stack (Spring uses it transitively in places, but declare it explicitly).This test stays in the suite permanently. If someone replaces Jsoup with regex in the future, this test catches the gap.
commentPreviewDTO field (@Schema description) that reads "plain-text, HTML stripped server-side" — it serves as inline documentation for future developers who might be tempted to render it as markup.Open Decisions (omit this section entirely if none)
🧪 Sara Holt — QA Engineer
Observations
The acceptance criteria are mostly testable, but several edge cases and test scenarios are unspecified. The existing comment batch-fetch pattern (
findAnnotationIdsByIds) is a useful reference for what tests already exist and what gaps remain.I checked
DashboardServiceTest.java— existing tests covergetActivity()at a high level but do not exercise the comment preview path (it doesn't exist yet). The tests forCommentServicedon't cover afindPreviewsByIds()method (also doesn't exist yet).Recommendations
Unit layer —
CommentServiceTestadditions needed:findPreviewsByIds_returns_empty_map_when_input_is_empty()— guard clausefindPreviewsByIds_strips_html_and_truncates_to_120_chars()— core behaviourfindPreviewsByIds_truncates_at_exactly_120_chars()— boundary condition (121 chars → 120, 120 chars → unchanged)findPreviewsByIds_returns_empty_string_for_blank_content()— empty/whitespace commentfindPreviewsByIds_skips_deleted_comments_gracefully()—findAllByIdreturns fewer results than requested; missing IDs must produce no entry in the result map (not null, not an exception)Unit layer —
DashboardServiceTestadditions:6.
getActivity_populates_commentPreview_for_COMMENT_ADDED_rows()— happy path7.
getActivity_leaves_commentPreview_null_for_TEXT_SAVED_rows()— non-comment kinds8.
getActivity_leaves_commentPreview_null_when_comment_not_found()— deleted commentComponent layer —
ChronikRowspec additions:9.
renders comment preview text when variant is comment and commentPreview is present10.
renders placeholder ellipsis when variant is comment and commentPreview is null11.
does not render preview paragraph for non-comment variants— ensures regression safetyEdge case not in AC — deleted comment:
The issue specifies the happy path and the stripping/truncation rules but does not specify behaviour when the
commentIdin the audit log refers to a comment that was subsequently deleted. ThefindAllById()call will silently omit deleted comments from the result map, socommentPreviewwill be null. This is the correct behaviour — the spec should say so explicitly to prevent someone adding a fallback that throws or logs a warning."~120 chars" is not testable as written. The tilde makes the boundary ambiguous. Nail it down to exactly 120 characters (post-strip) before implementation starts.
Open Decisions (omit this section entirely if none)
🎨 Leonie Voss — UX Designer & Accessibility Strategist
Observations
The existing
ChronikRow.svelteplaceholder is already styled correctly for when the preview arrives:line-clamp-1 sm:line-clamp-2 font-serif text-sm text-ink-2 italic. That's consistent with the brand (Tinos serif for body content), readable at the minimum 14px equivalent on mobile, and the clamp behaviour is correct for the dual-audience split (single line on narrow screens, two on wider).The issue mentions
aria-labelfor screen readers — but the current markup doesn't show where this would go. Looking at the component: the whole row is wrapped in<a href={rowHref}>. Thearia-labelon the<a>is the correct placement — it overrides the link's accessible name for screen readers, letting you include the full preview text without it appearing on screen.Recommendations
aria-labelto the<a>wrapper, not the preview<p>. A screen reader announces link text, not paragraph text inside a link. The current<a>has noaria-label— add one for the comment variant:This gives screen reader users the full context without visual duplication. For non-comment variants, let the link text speak for itself.
Specify the empty-preview fallback. When
commentPreviewis null or empty string, the current placeholder„…"is the right visual to preserve — it signals "there's a comment here but the preview isn't available." Don't hide the preview paragraph entirely on null, or the row height changes, causing layout shift in the feed.italicon the preview text is good — it visually differentiates quoted content from the action verb above it. This is appropriate and consistent with the document transcription context of the Familienarchiv.Touch target check: The row uses
p-3(12px padding) on the<a>which gives a minimum hit area of ~48px vertically on most rows. The preview paragraph adds height, which only increases the touch target. ✓Color check:
text-ink-2onbg-surface— verify this meets the AA 4.5:1 minimum. Theink-2token is used throughout for secondary text and should be verified against the actualbg-surfacevalue inlayout.cssif it hasn't been already.Open Decisions (omit this section entirely if none)
MENTION_CREATEDrows — should they also show a preview? Thevariantcomputation assigns'for-you'(not'comment') to mention rows, so they currently won't get the preview paragraph. If mentions should also show a snippet of the comment they're from, that's an additive UI change worth deciding now rather than as a follow-up.⚙️ Tobias Wendt — DevOps & Platform Engineer
Observations
This is a pure application-layer change — no schema migrations, no new services, no Compose changes. From an infrastructure perspective, this is low-risk.
The one operational concern worth flagging:
DashboardService.getActivity()is called on the Chronik page load. It already makes two batch database calls after the main audit query (document titles + annotation IDs). Adding a third (findPreviewsByIds) means three sequential round-trips to the database for each activity feed page load.I checked the existing load: the audit query is native SQL and returns up to 50 rows. The follow-up
findAllByIdcalls are Spring Data batch fetches — singleINqueries. At the current scale (family archive, tens of users), threeINqueries is negligible. Flag this only if the Chronik page ever appears in a slow-query alert on Grafana.Recommendations
Combine the two
commentIdslookups into a single pass.DashboardService.getActivity()currently calls:commentService.findAnnotationIdsByIds(commentIds)— fetchesannotationIdper commentcommentService.findPreviewsByIds(commentIds)— fetches preview per commentBoth iterate
commentRepository.findAllById(commentIds). That's two identicalSELECT ... WHERE id IN (...)queries for the same rows. Combine into a single method that returns both in one fetch:This halves the database round-trips for this path. The implementation is straightforward since both methods already call
findAllByIdon the same entity.npm run generate:apiis already in the AC — make sure CI fails if the generated types are stale. If there's no check for this in the CI pipeline yet, it's worth adding a step that regenerates and diffs.Jsoup as a dependency: If Jsoup is not already declared in
pom.xml, add it explicitly. Don't rely on transitive inclusion — transitive deps can be excluded by an upstream upgrade.Open Decisions (omit this section entirely if none)
📋 Elicit — Requirements Engineer
Observations
The issue is well-structured for a backlog item of this size — it has a clear problem statement, explicit security notes, and a useful UX note. The acceptance criteria are mostly testable. A few precision gaps will create ambiguity at implementation time.
Gap 1 — "~120 chars" is not a testable criterion.
max ~120 charshas no definitive boundary. A test for "exactly 120 chars input → not truncated, 121 chars input → truncated to 120" cannot be written against~. Pick a number and remove the tilde. Recommend: 120 exactly.Gap 2 — Deleted comment behaviour is unspecified.
The audit log permanently records
COMMENT_ADDEDevents. Comments can be deleted after posting. The spec does not say whatcommentPreviewshould contain when the referenced comment no longer exists. Without this, different implementers could choose:null(silent)"""[deleted]"Recommend: specify
null— the feed item still links to the document; the missing preview is a graceful degradation. Add an AC: "If the comment has been deleted,commentPreviewisnull."Gap 3 —
MENTION_CREATEDscope not addressed.The issue specifies the preview for
COMMENT_ADDEDrows.MENTION_CREATEDrows display asvariant === 'for-you'inChronikRow.svelte, notvariant === 'comment'. They also carry acommentIdin the DTO. The issue is silent on whether those rows should also show a preview paragraph.If the answer is "not this issue," the AC should say so: "Preview is shown only for
variant === 'comment'rows;for-yourows are out of scope for this issue."Gap 4 — Empty-after-stripping case.
A comment containing only an image (
<img src="...">) would produce an empty string after HTML stripping. The AC doesn't specify how an empty preview should be rendered (show nothing? show„…"?). The UX note from Leonie (preserve the placeholder height to avoid layout shift) suggests the visual fallback should be„…", but it's not written into the spec.Recommendations
Suggested AC additions before implementation starts:
commentPreviewisnullcommentPreviewisnullor empty, the frontend shows the existing placeholder„…"(no layout shift)variant === 'comment'(COMMENT_ADDEDkind);MENTION_CREATEDrows are out of scopeOpen Decisions (omit this section entirely if none)
MENTION_CREATEDrows: Should they also show a comment preview? They carry acommentIdand the samecommentPreviewfield would be populated. The variant computation assigns them'for-you'today — showing a preview there too would require a UI change to thefor-youvariant. Decide before implementation so the frontend work is scoped correctly.…)? Some implementations dotext.substring(0, 117) + "…"to signal the text is cut. Others truncate at the word boundary. Neither is specified — pick one.🗳️ Decision Queue — Action Required
3 decisions need your input before implementation starts.
Spec Precision
Exact truncation limit — The AC says "max ~120 chars." Tests require an exact boundary. Recommend 120 exactly; just remove the tilde and confirm. (Raised by: Sara, Elicit)
Truncation ellipsis — Should truncated previews end with
…? Option A:text.substring(0, 117) + "…"(visually signals cut text, costs 3 chars). Option B: hard cut at 120 with no suffix (simpler, already matchesline-clampvisual truncation which adds its own…). Sinceline-clamphandles the visual ellipsis in CSS, Option B is recommended — the CSS already communicates truncation. (Raised by: Elicit)Scope
MENTION_CREATEDrows: in or out? Mention rows carry acommentIdand would automatically getcommentPreviewpopulated on the backend once the field exists. However, inChronikRow.sveltethey render asvariant === 'for-you'— the preview paragraph is only shown forvariant === 'comment'. Two options: (A) explicitly out of scope for this issue — add an AC saying so; (B) also show a preview in thefor-youvariant — requires a small additional UI change. Recommend: call it out of scope now, open a follow-up. (Raised by: Leonie, Elicit)Implemented in PR #475.
What was done:
CommentService.findDataByIds()— new batch method returningCommentData(annotationId, preview)for a set of comment IDs in a singlefindAllByIdcall.findAnnotationIdsByIdsnow delegates to it, so existing callers are unaffected.stripAndTruncate()— Jsoup DOM-based HTML stripping (Jsoup.parse(html).text()) + hard cut at 120 chars. No regex, no XSS surface.ActivityFeedItemDTO—@Nullable String commentPreviewadded as last field (OpenAPINOT_REQUIRED).DashboardService.getActivity()— usesfindDataByIds, populatescommentPreviewper activity row.ChronikRow.svelte—{item.commentPreview ?? '„…"'}(plain text binding);aria-labelon the<a>for screen readers.