bug: REPLY notifications not surfaced in Chronik "Für dich" feed #295
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?
Problem
When user A replies to a comment thread that user B started (or participated in), user B receives a REPLY notification in the bell. However, the corresponding
COMMENT_ADDEDevent never appears in the Chronik activity feed under the "Für dich" filter — even after the notification has been read/dismissed.The "Für dich" box at the top correctly hides read notifications (that is expected behaviour). But the activity timeline below it should surface the event regardless of read status.
Root Cause
The
"fuer-dich"filter on the timeline is:youMentionedis computed in SQL (AuditLogQueryRepository.java:72-73) as:Because
youMentionedis derived exclusively fromMENTION_CREATEDrows, it is structurally impossible for aCOMMENT_ADDEDrow to ever haveyouMentioned = true. The filter is therefore equivalent tokind === 'MENTION_CREATED'only.A reply to your comment creates a
COMMENT_ADDEDaudit event (CommentService.java:184). That event never qualifies for the "Für dich" filter, so it silently disappears from the user's personalised view.Inconsistency
Proposed Fix
Add a
youParticipatedflag toActivityFeedItemDTO, computed in the activity-feed SQL query via a correlated subquery against thenotificationstable:Then widen the frontend filter:
This keeps the SQL cheap (index on
notifications.recipient_id+notifications.reference_id) and avoids any schema changes.Affected files
backend/src/main/java/org/raddatz/familienarchiv/audit/AuditLogQueryRepository.java— addyou_participatedCTE/subquerybackend/src/main/java/org/raddatz/familienarchiv/dashboard/DashboardService.java— map new columnfrontend/src/lib/generated/api.ts— regen after schema changefrontend/src/routes/chronik/+page.svelte— widen filter predicate🏗️ Markus Keller — Senior Application Architect
Observations
AuditLogQueryRepositorylives in theauditpackage and the fix adds a correlated subquery against thenotificationstable. These are two separate domains. This is a pragmatic choice for a monolith, but it should be a conscious decision, not an accident.ActivityFeedRow.java— this interface inaudit/ActivityFeedRow.javaneeds a newisYouParticipated()getter before anything else in the chain can work.COMMENT_ADDEDpayload already storescommentId(set inCommentService.java:184), andnotifications.reference_idis that samecommentId(per V16 comment: "commentId that triggered this notification"). The join point is solid.Recommendations
ActivityFeedRow.javato the affected files list — it's the missing link between the SQL projection and the DTO mapper inDashboardService.getActivity(). WithoutisYouParticipated()on the interface, the new SQL column has nowhere to land.:currentUserId::uuidrather thanrecipient_id::textlets the query planner use the existing index onrecipient_id.ActivityFeedItemDTOrecord needs the new field added with@Schema(requiredMode = REQUIRED)to drive TypeScript codegen — consistent with howyouMentionedis currently declared at line 17.Open Decisions
DashboardServicecallNotificationService.findReplyCommentIds(currentUserId)and pass the result set into the SQL query as anIN (:ids)list. This keeps domain ownership cleaner (notification data fetched via the notification service, not queried from the audit repo) at the cost of a second round-trip. For a family archive with tens of notifications this is noise; at thousands it becomes a real question. Decide which direction you want the boundary to go before implementation.👨💻 Felix Brandt — Senior Fullstack Developer
Observations
ActivityFeedRow.java(audit/ActivityFeedRow.java). This interface is the projection interface for the native SQL query result. It currently hasisYouMentioned()and needs a newisYouParticipated()getter — without it the new SQL columnyou_participatedhas no target and Spring Data JPA will throw at startup.::textcasts on UUID columns —recipient_id::text = :currentUserIdandreference_id::text = (s.payload->>'commentId'). Casting the column to text kills index usage. The correct form casts the parameter:recipient_id = :currentUserId::uuid.DashboardService.getActivity()(line 136) constructsActivityFeedItemDTO— onceyouParticipatedis added to the record, the constructor call there needs updating too. It's not in the affected files list.Recommendations
SQL cast fix — invert the cast direction so the index on
recipient_idcan be used:Complete affected files list:
audit/ActivityFeedRow.java— addboolean isYouParticipated()dashboard/ActivityFeedItemDTO.java— add@Schema(requiredMode = REQUIRED) boolean youParticipatedaudit/AuditLogQueryRepository.java— addyou_participatedsubquerydashboard/DashboardService.java— passrow.isYouParticipated()in the DTO constructor at line 136frontend/src/lib/generated/api.ts— regen (already listed)frontend/src/routes/chronik/+page.svelte— widen filter (already listed)Frontend filter — the widened predicate is clean as proposed:
No further split needed; the three conditions are semantically distinct and naming each in a
$derivedwould be overkill here.🔒 Nora Steiner — Application Security Engineer
Observations
recipient_id = :currentUserId, so a user can only ever see feed items as "participated" if they themselves have a REPLY notification for that comment. Good.::textcast onrecipient_id(as written in the issue) is not a security vulnerability, but it is a correctness smell: comparing UUIDs via text representations has different collation semantics and disables index scans. It should be fixed (see Felix's comment) but it is not a security issue.s.payload->>'commentId'matchingnotifications.reference_id. This is documented in V16 ("commentId that triggered this notification") and inAuditKind.java:24("Payload:{"commentId": "uuid"}"). The contract is explicit in two places. If the payload key were ever renamed, the subquery would silently returnfalsefor all rows rather than failing loudly. This is an operational risk, not an exploitable one.Recommendations
youParticipated = falsewhen queried as a user who has NO reply notification for that comment (i.e., someone who was not a thread participant). This proves the subquery is properly scoped and doesn't bleed across users.🧪 Sara Holt — QA Engineer & Test Strategist
Observations
AuditLogQueryRepositoryRolledUpTestuses@DataJpaTest+@Import(PostgresContainerConfig.class)with a real Postgres container. It already hasinsertUserAndDocs()andinsertAuditEvent()helpers. This is the exact right class to addyouParticipatedtests to.youMentioned. A grep across the test directory finds zero assertions againstyouMentionedin the test suite. This means the newyouParticipatedbehaviour has no model test to follow, but it also means we should write both together.Recommendations
Layer 1 — SQL / Repository (integration test in
AuditLogQueryRepositoryRolledUpTest):Layer 2 — Load function (Vitest, import
+page.server.tsdirectly):unreadNotificationsis loaded regardless of thefilterparam.Layer 3 — Frontend filter (
+page.svelte):displayFeedderivation: given a feed item withyouParticipated=trueandkind='COMMENT_ADDED', it should appear in'fuer-dich'and'kommentare'but not'hochgeladen'or'transkription'.Also: add a
insertAuditEventoverload (or update the existing one) that accepts aMap<String, String>payload — the current helper inAuditLogQueryRepositoryRolledUpTestinserts events without payload, which won't work for theCOMMENT_ADDEDtest that relies onpayload->>'commentId'.🎨 Leonie Voss — UX Designer & Accessibility Strategist
Observations
youParticipatedis wired up,COMMENT_ADDEDevents will appear in the "Für dich" timeline, which is the right place for them.ChronikTimelinecomponent already rendersCOMMENT_ADDEDevents (they appear in the "alle" and "kommentare" filters). The only change is that they now also appear in "fuer-dich". No new visual treatment is needed — the existing comment event row design is appropriate.Recommendations
⚙️ Tobias Wendt — DevOps & Platform Engineer
Observations
notificationsbyrecipient_idandreference_id. The existing index from V16 is:reference_idis NOT in this index. At family archive scale (tens to low hundreds of notifications per user), a sequential scan within the user's notification rows is fine — the index onrecipient_idreduces the scan to one user's rows. At thousands of notifications, addingreference_idto the index would help.reference_idindex later, that would be V50.Recommendations
type = 'REPLY'keeps it lean — only the rows the subquery actually cares about.--spring.profiles.active=devto expose/v3/api-docs. Make sure the dev Docker Compose stack is up before runningnpm run generate:apiinfrontend/.🗳️ Decision Queue — Action Required
1 decision needs your input before implementation starts.
Architecture
notificationstable directly fromAuditLogQueryRepository(in theauditdomain). The alternative is:DashboardServicecallsNotificationService.findReplyCommentIds(currentUserId)first, then passes the resulting UUID set into the SQL query asIN (:ids). The correlated subquery is simpler to implement and performs identically at family archive scale; the service-layer join is cleaner architecturally (each domain fetches its own data) but adds a second DB round-trip. (Raised by: Markus)🏗️ Markus Keller — Architect Discussion Summary
One open architectural decision worked through with @marcel.
✅ Resolved
AuditLogQueryRepositoryquerying thenotificationstable directly is a conscious, deliberate decision. YAGNI applies: aNotificationService.findReplyCommentIds()abstraction only earns its keep when notification data is needed from audit/dashboard in multiple other places. That hasn't happened yet. Accept the cross-domain read, name it explicitly in a SQL comment (as Nora recommended), and revisit the boundary if it recurs.Overall read
The fix is well-scoped and the team's pre-review caught every structural gap (missing
ActivityFeedRow.java, SQL cast direction, DTO constructor, test coverage). The one architectural question was the domain boundary — pragmatism wins here. Ready to implement.👨💻 Felix Brandt — Developer Discussion Summary
Three implementation decisions worked through with @marcel before coding starts.
✅ Resolved
insertReplyNotificationhelper — useJdbcTemplate— The test needs to pass in the samecommentIdUUID generated in the test body (to link theCOMMENT_ADDEDaudit event to the notification). A@Sqlfixture file would require string interpolation to inject it.JdbcTemplateinline is consistent withinsertAuditEventandinsertUserAndDocs, keeps the UUID wiring visible, and reads top-to-bottom without jumping to a fixture file.youMentionedretroactive tests — write alongsideyouParticipated— Zero existing assertions againstyouMentioned. While the test infrastructure is warm and the helpers are in place, the cost to close that gap is near zero. Both get covered in the same pass.Frontend filter test — extract predicate to
chronik/feedFilters.ts, Vitest unit test — No existing Vitest file for the chronik route. Rather than build a full component harness for+page.svelte, extract the filter logic to a purefilterFeed(items, filter)function. The$derivedin the page just calls it. The function is trivially testable with no DOM setup, and it keeps business logic out of the template — consistent with the code style guide.Overall read
Implementation is fully specified. TDD order: failing repository test first (
isYouParticipated()onActivityFeedRow), then SQL, then DTO, then service, then extract and testfeedFilters.ts, then widen the$derivedpredicate. No open decisions remain.✅ Implementation Complete
All tasks from the plan have been implemented on branch
feat/issue-295-you-participated.Commits
ef333d80test(audit): add youParticipated and youMentioned repository tests— Added
isYouParticipated()toActivityFeedRowinterface; 4 new tests covering the positive/negative cases for bothyouParticipatedandyouMentioned(retroactive);insertAuditEventoverload with payload;insertReplyNotificationJdbcTemplate helper.64058caffeat(audit): surface youParticipated via REPLY notification subquery— Added
you_participatedcorrelated EXISTS subquery tofindRolledUpActivityFeed. Carriespayloadthrough theaggregatedCTE viaMIN(payload::text)::jsonb. UsesCAST(:currentUserId AS uuid)to avoid Spring Data JPA misparse of::cast syntax as parameter name.9dd0dbdafeat(dashboard): expose youParticipated in ActivityFeedItemDTO— Added
@Schema(requiredMode = REQUIRED) boolean youParticipatedto the DTO record and wiredrow.isYouParticipated()throughDashboardService.getActivity().8b7cedb7feat(chronik): extract feedFilters.ts with youParticipated in fuer-dich— Extracted
filterFeed(items, filter)pure function; widenedfuer-dichbranch tokind === 'MENTION_CREATED' || youMentioned || youParticipated; 13 unit tests covering all 5 filters and edge cases; manually syncedActivityFeedItemDTOgenerated type.7429db4frefactor(chronik): replace inline filter switch with filterFeed()— Replaced 20-line inline switch in
+page.sveltewith a singlefilterFeed()call.Test results
SQL note
Following Markus's recommendation, the cross-domain correlated subquery is deliberate — the SQL comment names the
payload->>'commentId'/notifications.reference_idcontract perAuditKind.COMMENT_ADDED. Nora's scoping concern is satisfied: therecipient_id = CAST(:currentUserId AS uuid)predicate ensures only the querying user's own notifications are matched.