Reference in New Issue
Block a user
Delete Branch "feat/71-72-73-notifications-mentions-deeplinks"
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?
Summary
Implements three related features from the notification milestone:
#71 — Notification system (bell, email, preferences)
Notificationentity + Flyway migration (V16),NotificationService(creates REPLY/MENTION notifications, optional email viaJavaMailSender),NotificationController(GET /api/notifications,POST /api/notifications/read-all,PATCH /api/notifications/{id}/read, GET/PUT notification-preferences on/api/users/me)NotificationBell.sveltein header (polling, unread badge, dropdown, mark-all-read, keyboard/focus management); notification preferences card on profile page (notifyOnReply / notifyOnMention toggles)NotificationServiceTest+ 9NotificationControllerTest(all green)#72 — @mentions in comments
comment_mentionsjoin table (V17 migration),@ManyToManyonDocumentComment,MentionDTO,/api/users/search?q=endpoint,CommentServicestores mentions + firesnotifyMentionsMentionEditor.svelte(textarea with @-trigger popup, debounced user search, keyboard navigation ↑↓ Enter Esc, Ctrl+Enter submit, @ button for a11y);mention.tsutilities (detectMention,extractContent,renderBody— XSS-safe escape-first pattern); comment bodies rendered with{@html renderBody(...)}CommentControllerTest+CommentServiceTest#73 — Deep-link to specific comments
NotificationBellnavigates to/documents/{docId}?commentId={commentId}when clicking a notification?commentId=on mount, opens bottom panel to discussion tabCommentThreadacceptstargetCommentIdprop: scrolls to[data-comment-id="..."], applies a ring highlight, removes highlight on first user interaction (click/keydown/scroll)Test plan
./mvnw test -Dtest=NotificationServiceTest,NotificationControllerTest,UserSearchControllerTest,CommentServiceTest,CommentControllerTestnpm run test(all 24 pass)@in comment box → popup appears → select user →@Nameinserted → post → mention link rendered in comment body🤖 Generated with Claude Code
- mention.ts: detectMention (cursor-aware), extractContent (parse @Name → UUID), renderBody (XSS-safe: escape-first then inject anchor tags, replaceAll for all occurrences) - 19 unit tests in mention.spec.ts (all green) - MentionEditor.svelte: textarea with @-trigger popup, debounced /api/users/search, keyboard navigation (↑↓ Enter Esc), Ctrl+Enter submit, @ button for accessibility - CommentThread.svelte: replace plain textareas with MentionEditor, send mentionedUserIds on post/reply/edit, render comment bodies with {@html renderBody(...)} - types.ts: add MentionDTO, add optional mentionDTOs to Comment and CommentReply Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>Review — @mkeller
Solid chunk of work: three features landed together, tests cover the happy paths, migrations are properly versioned, i18n keys are consistent across all three locales. The architecture is mostly sound — polling over SSE is the right call for this traffic volume, the XSS-escape-first pattern in
renderBodyis correct.That said I found a genuine data bug (actorName always null), several N+1 queries, and two layering violations that need to be fixed before this merges. The other comments are things I'd want addressed in a follow-up but won't block the merge.
@@ -0,0 +23,4 @@private final UserService userService;@GetMapping("/api/notifications")public Page<Notification> getNotifications(Avoid returning the
Notificationentity directly from the controller.Notificationhas@ManyToOne(fetch = FetchType.LAZY)onrecipient. Jackson will trigger lazy loading for every notification in the page to serialize theAppUser— that's another N+1 in the serialization path. Worse,AppUsercontains fields (password hash, groups) that should never leave the backend.Create a
NotificationDTOrecord:Return
Page<NotificationDTO>fromgetNotificationsandNotificationDTOfrommarkOneRead. The service mapsNotification → NotificationDTObefore returning. This sidesteps both the N+1 and the data-leakage risk.@@ -0,0 +50,4 @@@Transient@Schema(requiredMode = Schema.RequiredMode.REQUIRED)private String actorName;}Bug:
actorNameis never set — will always benullin every API response.actorNameis@Transient(not persisted), so it must be populated before serialization. But neithernotifyReplynornotifyMentionsinNotificationServiceever calls.actorName(...)on the builder. The frontend rendersnotification.actorNamedirectly in the bell dropdown — users will see an empty string where the sender's name should be.Fix in
notifyReply:Fix in
notifyMentions:For
getNotifications()(the paginated fetch), stored notifications already lackactorName, so you'll also need to re-hydrate it from the comment or store it as a plain column — storing it as aVARCHARcolumn in the migration is the simplest option, sinceauthorNameonDocumentCommentis already a denormalized string.@@ -0,0 +22,4 @@void markAllReadByRecipientId(@Param("userId") UUID userId);List<Notification> findByRecipientIdOrderByCreatedAtDesc(UUID recipientId);}Dead code: the
List<Notification>overload is never called.You have two methods with the same name:
Page<Notification> findByRecipientIdOrderByCreatedAtDesc(UUID recipientId, Pageable pageable)— used by the serviceList<Notification> findByRecipientIdOrderByCreatedAtDesc(UUID recipientId)— unusedDelete the
Listvariant. If you ever need it, add it back then.@@ -17,20 +19,23 @@ import java.util.UUID;public class CommentService {private final CommentRepository commentRepository;Layering violation:
CommentServicedirectly injectsAppUserRepository.From CLAUDE.md (strictly enforced):
AppUserRepositorybelongs to the user domain.CommentServiceshould injectUserServiceand call a method likeuserService.findAllById(mentionedUserIds). IfUserServicedoesn't expose that method yet, add it there.Same violation exists in
NotificationService, which injects bothAppUserRepositoryandCommentRepository.NotificationServiceis a new service so there's more latitude, but it should still go throughUserServicefor user lookups andCommentServicefor comment lookups rather than reaching into their repositories directly.@@ -0,0 +55,4 @@participantIds.remove(reply.getAuthorId());for (UUID participantId : participantIds) {Optional<AppUser> recipientOpt = userRepository.findById(participantId);N+1:
findByIdinside a loop.For every participant ID you're issuing a separate SELECT. If a thread has 10 participants that's 10 round-trips to the database before a single notification is written.
Replace the loop body with a single bulk fetch:
Same pattern applies to
notifyMentionsbelow.@@ -0,0 +79,4 @@@Transactionalpublic void notifyMentions(List<UUID> mentionedUserIds, DocumentComment comment) {for (UUID mentionedUserId : mentionedUserIds) {Optional<AppUser> recipientOpt = userRepository.findById(mentionedUserId);N+1: same issue as
notifyReply—findByIdin a loop.For
notifyMentions:findAllByIdemits a singleSELECT … WHERE id IN (…)instead of one query per user.@@ -0,0 +122,4 @@const diffMs = now - then;const diffMin = Math.floor(diffMs / 60000);if (diffMin < 1) return 'gerade eben';if (diffMin < 60) return `vor ${diffMin} Min.`;Hardcoded German strings bypass Paraglide.
relativeTime()returns'gerade eben','vor X Min.','vor X Std.','vor X Tagen'as raw string literals. Thearia-label="ungelesen"on the unread dot a few lines below has the same problem.Both need translation keys in
de.json/en.json/es.jsonand should go throughm.xxx()like every other user-facing string in this codebase. The app already has Spanish and English users — relative timestamps showing German to them is a bug, not a cosmetic issue.@@ -0,0 +60,4 @@for (const mention of mentions) {const displayName = `${mention.firstName} ${mention.lastName}`.trim();const link = `<a class="mention" data-user-id="${mention.id}" href="#">@${displayName}</a>`;escaped = escaped.replaceAll(`@${displayName}`, link);href="#"scrolls the page to the top on click.Mention links currently point to
href="#". In most browsers clicking them fires the default anchor behaviour — the page jumps to the top, which is disorienting inside a document viewer.Either use
href="javascript:void(0)"(old-school but harmless) or, better, drop the<a>entirely and use a<span>styled as a mention chip:There's no profile page to navigate to for family archive members, so a link that goes nowhere is actively worse than a styled span.
QA Review — Sara Holt, Senior QA Engineer
3 BLOCKERs, 7 MAJORs, 8 MINORs, 3 INFOs. Details as inline comments. Overall verdict: REQUEST_CHANGES — the BLOCKERs must be resolved before merge.
@@ -0,0 +1,71 @@package org.raddatz.familienarchiv.controller;⚠️ MAJOR — No
@RequirePermissionon notification controllerAll five notification endpoints have no
@RequirePermissionannotation. Resolving the current user viaauthentication.getName()is not the same as the AOPPermissionAspectgate — a disabled or low-privilege account that passes the Spring Security filter layer can still call these endpoints.The
NotificationControllerTesttests 401 for unauthenticated, but never tests that a user with no permissions is blocked (403). This gap means a regression here would not be caught by CI.Fix: Add
@RequirePermission(Permission.READ_ALL)at the controller class level. Update tests to cover the 403 case.@@ -0,0 +1,29 @@package org.raddatz.familienarchiv.controller;🚨 BLOCKER — User enumeration endpoint has no permission check
GET /api/users/search?q=exposes full user list (name + UUID) to any authenticated session with zero permission check. An attacker with any valid cookie can enumerate all users by iterating single-character queries (?q=a,?q=b, …). For a family archive this is a real privacy violation.Fix: Add
@RequirePermission(Permission.READ_ALL)at the controller class level, consistent with every other controller in this codebase. TheUserSearchControllerTestmust then also test the 403 path for a user without permissions.@@ -1,10 +1,12 @@package org.raddatz.familienarchiv.model;⚠️ MAJOR —
@Transientfield +FetchType.LAZY= potentialLazyInitializationExceptionat runtimementionDTOsis a@Transientfield that is populated by the service before returning the entity.mentionsis mapped asFetchType.LAZY. Read methods inCommentServiceare intentionally non-@Transactional(per architecture rules) — which means callingcomment.getMentions()fromwithMentionDTOs()outside a transaction will trigger aLazyInitializationException.This will not show up in unit tests (Mockito) but will explode in the integration test or production when the entity is fetched fresh from the repo.
Fix: Either change
mentionstoFetchType.EAGER(acceptable for a small bounded collection like mentions), or — better — introduce a properCommentResponseDTOso you're not mutating a JPA entity with transient display state.@@ -0,0 +1,25 @@package org.raddatz.familienarchiv.repository;ℹ️ INFO — Unused method:
findByRecipientIdOrderByCreatedAtDescThe non-paginated
List<Notification> findByRecipientIdOrderByCreatedAtDesc(UUID)is declared but never called — the paginatedfindByRecipientId(UUID, Pageable)is used exclusively. Dead code in repositories adds noise and risks someone accidentally calling the unbounded query on a user with thousands of notifications.Fix: Remove the method.
@@ -1,10 +1,12 @@package org.raddatz.familienarchiv.service;🚨 BLOCKER — Architecture violation: direct repository access across domain boundary
CommentServicedirectly injectsAppUserRepositoryinsaveMentions(). CLAUDE.md is explicit: services must never reach into another domain's repository. Cross-domain data access must go through the owning service.This breaks the layering contract and untestable in isolation through the intended interface — any
CommentServiceTestnow has to mock a repository it shouldn't even know about.Fix: Add
UserService.findAllById(Collection<UUID>), injectUserServiceintoCommentService, and call that instead.@@ -0,0 +1,187 @@package org.raddatz.familienarchiv.service;🚨 BLOCKER — Fragile mixed injection + ReflectionTestUtils hack
The class uses
@RequiredArgsConstructorbutmailSenderis injected via@Autowired(required = false)— a mixed injection approach. The test is forced to useReflectionTestUtils.setField()to inject the mock. This is a maintenance trap: ifmailSenderis ever renamed, the reflection call silently breaks at runtime with no compile-time error — the email path stops being tested without any CI failure.Fix: Inject
Optional<JavaMailSender>as a constructor parameter (works with Lombok@RequiredArgsConstructor) or use@ConditionalOnBean(JavaMailSender.class)on a separate email-sending component. Either approach is injection-safe and doesn't require reflection in tests.@@ -0,0 +1,187 @@package org.raddatz.familienarchiv.service;⚠️ MAJOR — Notification failure can silently roll back the parent comment
notifyReply()andnotifyMentions()are@Transactionaland are called from within the already-@TransactionalreplyToComment()/postComment()inCommentService. They join the outer transaction. If a notification save fails (DB constraint, unexpected null), it rolls back the entire outer transaction — the comment itself is lost.A user's comment should never disappear because a notification couldn't be persisted.
Fix: Annotate both methods with
@Transactional(propagation = Propagation.REQUIRES_NEW)to isolate notification failures, or use@Asyncto fire them outside the comment transaction entirely.@@ -0,0 +1,162 @@package org.raddatz.familienarchiv.controller;⚠️ MAJOR —
PATCH /api/notifications/{id}/readmissing 401 testEvery other endpoint in this controller has an unauthenticated (401) test. The
markOneReadendpoint is the only one missing it. This inconsistency means a security regression specifically on this endpoint would not be caught by CI.Fix: Add
markOneRead_returns401_whenUnauthenticated()mirroring the pattern already used forgetNotifications_returns401_whenUnauthenticated.@@ -0,0 +1,71 @@package org.raddatz.familienarchiv.controller;🔵 MINOR —
search_returnsAtMostTenResultsdoes not assert the countThe test name promises a cap of 10 results but the assertion only checks
isOk(). If theLIMIT 10in the repository query is accidentally removed, this test still passes.Fix:
⚠️ MAJOR —
notifyMentions()call path has no test coverageThe new
replyToComment_triggersNotification_afterSavetest verifies reply notifications, but there is no equivalent test thatnotificationService.notifyMentions()is called whenmentionedUserIdsis non-empty — for eitherpostCommentorreplyToComment. If that call is accidentally removed or the condition changes, nothing in CI will catch it.Fix: Add:
postComment_triggersNotifyMentions_whenMentionedUserIdsProvidedreplyToComment_triggersNotifyMentions_whenMentionedUserIdsProvided@@ -0,0 +1,201 @@package org.raddatz.familienarchiv.service;⚠️ MAJOR — Several service method paths are untested
Missing test cases:
markRead_throwsNotFound_whenNotificationDoesNotExist— theNOTIFICATION_NOT_FOUNDcode path is real and user-facing (stale ID from frontend). No test covers it.markAllRead_delegatesToRepository—markAllRead()is tested end-to-end via the controller but never in isolation at the service layer.countUnread_delegatesToRepository— same gap.These are not exotic edge cases — they are production paths that the frontend already exercises.
🔵 MINOR —
setTimeout(100)magic delay for deep-link scroll is flakyOn slow devices or slow network responses, 100ms may not be enough for the panel to render and the
data-comment-idelement to be in the DOM. ThescrollIntoViewcall then silently no-ops — the deep link appears broken with no error.Fix: Use
await tick()(Svelte's DOM-flush promise) followed by arequestAnimationFrame, or aMutationObserverthat waits for the element to appear rather than assuming a fixed delay.@@ -0,0 +1,235 @@<script lang="ts">🔵 MINOR —
debounceTimernot cleared on component destroyThe
debounceTimeris cleared insideclosePopup(), but there is noonDestroyhook. If the component unmounts while a debounced search is pending (user navigates away mid-typing), the timeout fires on an unmounted component and causes a stale state update.Fix:
@@ -0,0 +1,304 @@<script lang="ts">🔵 MINOR —
relativeTime()returns hard-coded German strings"gerade eben","vor {n} Min.","vor {n} Std.","vor {n} Tag(en)"are hard-coded German regardless of the active Paraglide locale. The project supports de/en/es and notification messages usem.*keys — but timestamps fall back to German for all locales.Fix: Use
Intl.RelativeTimeFormatwith the current locale, or add Paraglide keys (e.g.m.notification_time_just_now(),m.notification_time_minutes_ago({ n })).@@ -0,0 +1,304 @@<script lang="ts">import { onMount, onDestroy } from 'svelte';🔵 MINOR —
aria-label="ungelesen"is hard-coded GermanThe unread indicator dot has
aria-label="ungelesen". Screen readers in English or Spanish will read "ungelesen" — a German word. This is an a11y regression.Fix: Use a Paraglide key:
aria-label={m.notification_unread()}.@@ -0,0 +1,304 @@<script lang="ts">import { onMount, onDestroy } from 'svelte';import { goto } from '$app/navigation';🔵 MINOR —
<div role="button">does not handle the Space keyEach notification item is a
<div role="button" tabindex="0">with a manualonkeydownhandler. The handler only handlesEnter— but ARIA authoring practices specify that bothEnterandSpaceactivate buttons. Keyboard-only users pressing Space will have no response.Fix: Replace
<div role="button">with a real<button>element. Native<button>handles both Enter and Space for free, is focusable by default, and requires notabindexor manual keyboard wiring.@@ -0,0 +1,120 @@import { describe, it, expect } from 'vitest';🔵 MINOR — XSS coverage missing in
renderBodytest suiteThe spec covers HTML escaping of the comment body content, but has no test where a
mention.firstNameormention.lastNamecontains HTML-special characters. This means the XSS vector identified inrenderBody(Finding #9 above) would not be caught by CI even after the fix is applied — leaving the regression unguarded.Fix: Add a test case:
@@ -0,0 +1,67 @@import type { MentionDTO } from '$lib/types';⚠️ MAJOR — Stored XSS vector in
renderBody: mention display names are not escapedThe comment body text is correctly escape-first-then-process, but
mention.firstNameandmention.lastNameare injected into the link string without any HTML escaping. An admin who registers a user withlastName: '"\><img src=x onerror=alert(1)>'produces a working XSS payload rendered in every comment that mentions that user.Fix: Apply the same HTML escape treatment to
displayNamebefore injecting it. Also add a test inmention.spec.ts:@@ -1,10 +1,15 @@import { fail } from '@sveltejs/kit';⚠️ MAJOR — Checkbox preference values are unreliable without JS
The
updateNotificationPrefsaction reads:…from a
<input type="hidden">that is synced to a Svelte$stateboolean via JS. When JS is disabled oruse:enhancefails, unchecked checkboxes are not submitted in standard HTML forms — the hidden input will always carry its last JS-bound value, not the actual checkbox state. Preferences can be silently saved incorrectly.Fix: Either read the checkbox directly using
formData.has('notifyOnReply')(standard HTML checkbox presence pattern), or explicitly document and guard that this form requires JavaScript to function correctly.QA Review — Sara Holt, Senior QA Engineer
Verdict: REQUEST_CHANGES (posted as COMMENT — Gitea blocks self-review REQUEST_CHANGES)
3 BLOCKERs, 7 MAJORs, 8 MINORs, 3 INFOs. All inline comments are filed on the affected files.
Priority summary
BLOCKERs (must fix before merge):
CommentServicedirectly accessesAppUserRepository— violates the layering contract in CLAUDE.md@Autowired(required = false)+ReflectionTestUtilshack formailSender— fragile, breaks silently on renameGET /api/users/searchhas no@RequirePermission— any authenticated session can enumerate all usersMAJORs (strong recommendation to fix before merge):
NotificationControllermissing@RequirePermission— AOP gate never firesmentionDTOs@Transient+FetchType.LAZY=LazyInitializationExceptionoutside a transactionnotifyReply/notifyMentionsjoin the outer transaction — notification failure silently rolls back the user's commentrenderBody:mention.firstName/lastNamenot HTML-escaped before injection into{@html ...}markRead404 path,markAllRead,countUnread,PATCH401,notifyMentionscall verificationMINORs / INFOs: filed as inline comments — most are straightforward one-liners (
onDestroy, aria-label Paraglide key,<button>instead of<div role=button>,tick()instead ofsetTimeout(100)).The XSS finding is the one I'd push hardest on — the escape-first pattern is correctly applied to the comment body text, but the mention display names bypass it entirely. Given that
{@html renderBody(...)}is already in use, one unescaped name field is enough for a stored XSS. This needs a fix and a regression test before merge.@@ -0,0 +1,71 @@package org.raddatz.familienarchiv.controller;⚠️ MAJOR — No
@RequirePermissionon notification controllerAll five notification endpoints have no
@RequirePermissionannotation. Resolving the current user viaauthentication.getName()is not the same as the AOPPermissionAspectgate — a disabled or low-privilege account that passes the Spring Security filter layer can still call these endpoints.The
NotificationControllerTesttests 401 for unauthenticated, but never tests that a user with no permissions is blocked (403). This gap means a regression here would not be caught by CI.Fix: Add
@RequirePermission(Permission.READ_ALL)at the controller class level. Update tests to cover the 403 case.@@ -0,0 +1,29 @@package org.raddatz.familienarchiv.controller;🚨 BLOCKER — User enumeration endpoint has no permission check
GET /api/users/search?q=exposes full user list (name + UUID) to any authenticated session with zero permission check. An attacker with any valid cookie can enumerate all users by iterating single-character queries (?q=a,?q=b, …). For a family archive this is a real privacy violation.Fix: Add
@RequirePermission(Permission.READ_ALL)at the controller class level, consistent with every other controller in this codebase. TheUserSearchControllerTestmust then also test the 403 path for a user without permissions.@@ -1,10 +1,12 @@package org.raddatz.familienarchiv.model;⚠️ MAJOR —
@Transientfield +FetchType.LAZY= potentialLazyInitializationExceptionat runtimementionDTOsis a@Transientfield that is populated by the service before returning the entity.mentionsis mapped asFetchType.LAZY. Read methods inCommentServiceare intentionally non-@Transactional(per architecture rules) — which means callingcomment.getMentions()fromwithMentionDTOs()outside a transaction will trigger aLazyInitializationException.This will not show up in unit tests (Mockito) but will explode in the integration test or production when the entity is fetched fresh from the repo.
Fix: Either change
mentionstoFetchType.EAGER(acceptable for a small bounded collection like mentions), or — better — introduce a properCommentResponseDTOso you're not mutating a JPA entity with transient display state.@@ -0,0 +1,25 @@package org.raddatz.familienarchiv.repository;ℹ️ INFO — Unused method:
findByRecipientIdOrderByCreatedAtDescThe non-paginated
List<Notification> findByRecipientIdOrderByCreatedAtDesc(UUID)is declared but never called — the paginatedfindByRecipientId(UUID, Pageable)is used exclusively. Dead code in repositories adds noise and risks someone accidentally calling the unbounded query on a user with thousands of notifications.Fix: Remove the method.
@@ -1,10 +1,12 @@package org.raddatz.familienarchiv.service;🚨 BLOCKER — Architecture violation: direct repository access across domain boundary
CommentServicedirectly injectsAppUserRepositoryinsaveMentions(). CLAUDE.md is explicit: services must never reach into another domain's repository. Cross-domain data access must go through the owning service.This breaks the layering contract and untestable in isolation through the intended interface — any
CommentServiceTestnow has to mock a repository it shouldn't even know about.Fix: Add
UserService.findAllById(Collection<UUID>), injectUserServiceintoCommentService, and call that instead.@@ -0,0 +1,187 @@package org.raddatz.familienarchiv.service;🚨 BLOCKER — Fragile mixed injection + ReflectionTestUtils hack
The class uses
@RequiredArgsConstructorbutmailSenderis injected via@Autowired(required = false)— a mixed injection approach. The test is forced to useReflectionTestUtils.setField()to inject the mock. This is a maintenance trap: ifmailSenderis ever renamed, the reflection call silently breaks at runtime with no compile-time error — the email path stops being tested without any CI failure.Fix: Inject
Optional<JavaMailSender>as a constructor parameter (works with Lombok@RequiredArgsConstructor) or use@ConditionalOnBean(JavaMailSender.class)on a separate email-sending component. Either approach is injection-safe and doesn't require reflection in tests.@@ -0,0 +1,187 @@package org.raddatz.familienarchiv.service;⚠️ MAJOR — Notification failure can silently roll back the parent comment
notifyReply()andnotifyMentions()are@Transactionaland are called from within the already-@TransactionalreplyToComment()/postComment()inCommentService. They join the outer transaction. If a notification save fails (DB constraint, unexpected null), it rolls back the entire outer transaction — the comment itself is lost.A user's comment should never disappear because a notification couldn't be persisted.
Fix: Annotate both methods with
@Transactional(propagation = Propagation.REQUIRES_NEW)to isolate notification failures, or use@Asyncto fire them outside the comment transaction entirely.@@ -0,0 +1,162 @@package org.raddatz.familienarchiv.controller;⚠️ MAJOR —
PATCH /api/notifications/{id}/readmissing 401 testEvery other endpoint in this controller has an unauthenticated (401) test. The
markOneReadendpoint is the only one missing it. This inconsistency means a security regression specifically on this endpoint would not be caught by CI.Fix: Add
markOneRead_returns401_whenUnauthenticated()mirroring the pattern already used forgetNotifications_returns401_whenUnauthenticated.@@ -0,0 +1,71 @@package org.raddatz.familienarchiv.controller;🔵 MINOR —
search_returnsAtMostTenResultsdoes not assert the countThe test name promises a cap of 10 results but the assertion only checks
isOk(). If theLIMIT 10in the repository query is accidentally removed, this test still passes.Fix:
⚠️ MAJOR —
notifyMentions()call path has no test coverageThe new
replyToComment_triggersNotification_afterSavetest verifies reply notifications, but there is no equivalent test thatnotificationService.notifyMentions()is called whenmentionedUserIdsis non-empty — for eitherpostCommentorreplyToComment. If that call is accidentally removed or the condition changes, nothing in CI will catch it.Fix: Add:
postComment_triggersNotifyMentions_whenMentionedUserIdsProvidedreplyToComment_triggersNotifyMentions_whenMentionedUserIdsProvided@@ -0,0 +1,201 @@package org.raddatz.familienarchiv.service;⚠️ MAJOR — Several service method paths are untested
Missing test cases:
markRead_throwsNotFound_whenNotificationDoesNotExist— theNOTIFICATION_NOT_FOUNDcode path is real and user-facing (stale ID from frontend). No test covers it.markAllRead_delegatesToRepository—markAllRead()is tested end-to-end via the controller but never in isolation at the service layer.countUnread_delegatesToRepository— same gap.These are not exotic edge cases — they are production paths that the frontend already exercises.
🔵 MINOR —
setTimeout(100)magic delay for deep-link scroll is flakyOn slow devices or slow network responses, 100ms may not be enough for the panel to render and the
data-comment-idelement to be in the DOM. ThescrollIntoViewcall then silently no-ops — the deep link appears broken with no error.Fix: Use
await tick()(Svelte's DOM-flush promise) followed by arequestAnimationFrame, or aMutationObserverthat waits for the element to appear rather than assuming a fixed delay.@@ -0,0 +1,235 @@<script lang="ts">🔵 MINOR —
debounceTimernot cleared on component destroyThe
debounceTimeris cleared insideclosePopup(), but there is noonDestroyhook. If the component unmounts while a debounced search is pending (user navigates away mid-typing), the timeout fires on an unmounted component and causes a stale state update.Fix:
@@ -0,0 +1,304 @@<script lang="ts">🔵 MINOR —
relativeTime()returns hard-coded German strings"gerade eben","vor {n} Min.","vor {n} Std.","vor {n} Tag(en)"are hard-coded German regardless of the active Paraglide locale. The project supports de/en/es and notification messages usem.*keys — but timestamps fall back to German for all locales.Fix: Use
Intl.RelativeTimeFormatwith the current locale, or add Paraglide keys (e.g.m.notification_time_just_now(),m.notification_time_minutes_ago({ n })).@@ -0,0 +1,304 @@<script lang="ts">import { onMount, onDestroy } from 'svelte';🔵 MINOR —
aria-label="ungelesen"is hard-coded GermanThe unread indicator dot has
aria-label="ungelesen". Screen readers in English or Spanish will read "ungelesen" — a German word. This is an a11y regression.Fix: Use a Paraglide key:
aria-label={m.notification_unread()}.@@ -0,0 +1,304 @@<script lang="ts">import { onMount, onDestroy } from 'svelte';import { goto } from '$app/navigation';🔵 MINOR —
<div role="button">does not handle the Space keyEach notification item is a
<div role="button" tabindex="0">with a manualonkeydownhandler. The handler only handlesEnter— but ARIA authoring practices specify that bothEnterandSpaceactivate buttons. Keyboard-only users pressing Space will have no response.Fix: Replace
<div role="button">with a real<button>element. Native<button>handles both Enter and Space for free, is focusable by default, and requires notabindexor manual keyboard wiring.@@ -0,0 +1,120 @@import { describe, it, expect } from 'vitest';🔵 MINOR — XSS coverage missing in
renderBodytest suiteThe spec covers HTML escaping of the comment body content, but has no test where a
mention.firstNameormention.lastNamecontains HTML-special characters. This means the XSS vector identified inrenderBody(Finding #9 above) would not be caught by CI even after the fix is applied — leaving the regression unguarded.Fix: Add a test case:
@@ -0,0 +1,67 @@import type { MentionDTO } from '$lib/types';⚠️ MAJOR — Stored XSS vector in
renderBody: mention display names are not escapedThe comment body text is correctly escape-first-then-process, but
mention.firstNameandmention.lastNameare injected into the link string without any HTML escaping. An admin who registers a user withlastName: '"\><img src=x onerror=alert(1)>'produces a working XSS payload rendered in every comment that mentions that user.Fix: Apply the same HTML escape treatment to
displayNamebefore injecting it. Also add a test inmention.spec.ts:@@ -1,10 +1,15 @@import { fail } from '@sveltejs/kit';⚠️ MAJOR — Checkbox preference values are unreliable without JS
The
updateNotificationPrefsaction reads:…from a
<input type="hidden">that is synced to a Svelte$stateboolean via JS. When JS is disabled oruse:enhancefails, unchecked checkboxes are not submitted in standard HTML forms — the hidden input will always carry its last JS-bound value, not the actual checkbox state. Preferences can be silently saved incorrectly.Fix: Either read the checkbox directly using
formData.has('notifyOnReply')(standard HTML checkbox presence pattern), or explicitly document and guard that this form requires JavaScript to function correctly.I also noticed a bug
There is no name in the notification, just null mentioned you
Leonie Voss (@leonievoss) — UI/UX & Accessibility Review
I read the diff end-to-end and tested
NotificationBell,MentionEditor, and the deep-link flow at 320px. Sara and @mkeller have the backend and security angles covered; I'm focusing on what the user sees and how the screen reader experiences it.Critical
renderBody— unescaped display names are a stored XSS vector (mention.ts:58–61)Sara flagged this, but I want to call it out from the UI side because this hits our users directly. The escape-first pattern is applied correctly to the comment body, but then:
displayNameis injected raw into the{@html renderBody(...)}output. A family member with a name containing>or"would produce broken HTML; a malicious name like"><script>would execute in every comment reader's browser. Fix:High
relativeTime()is German-only — breaks EN and ES locales (NotificationBell.svelte:168–177)Every string is hardcoded German. English and Spanish users see "vor 3 Min." in their notifications. This is a regression against the existing tri-lingual setup. Add Paraglide keys (
notification_time_just_now,notification_time_minutes,notification_time_hours,notification_time_days) and pass the interpolated values.Badge text is 10px — below the 12px hard floor (
NotificationBell.svelte)My hard rule: nothing renders below 12px. At 10px this badge is unreadable for a 65-year-old user in bright daylight.
text-xs(12px) is the minimum;text-[11px]still fails. Change totext-xsand compensate for the extra width withmin-w-5 h-5staying as-is — the badge will still look proportionate.Unread dot says
aria-label="ungelesen"in hardcoded German (NotificationBell.svelte)Two problems: it's hardcoded German, and the label is on a decorative
<span>inside an already-described<li>. The correct pattern for "unread" state is to surface it in the notification's text description, not on a separate decorative element. Either incorporate it into the<p>text (m.notification_type_reply_unread({ actor })) or add a visually-hidden<span class="sr-only">{m.notification_unread()}</span>inside the list item, and mark the dotaria-hidden="true".aria-livebadge announces a bare number (NotificationBell.svelte)When the badge appears or its count changes, a screen reader announces "3". That's meaningless without context. The
aria-liveregion should contain the full human-readable string. Move it out of the button element (live regions inside interactive elements have unreliable announcement behaviour across screen readers) and put a visually-hiddenaria-livesibling outside the button:The button's
aria-labelalready updates — that's enough for when the user tabs to it. The live region handles the ambient announcement when count changes while focus is elsewhere.Medium
Notification list items use
<div role="button">missing the Space key (NotificationBell.svelte)Sara flagged this. From a UX angle:
role="button"demands both Enter and Space. Our senior users who navigate by keyboard expect Space to activate buttons — it's muscle memory from native OS widgets. Either use a<button type="button">(simplest fix, gets both keys for free, correct semantics) or adde.key === ' 'to the keydown handler. I'd go with<button>.MentionEditor popup can overflow off-screen on mobile (
MentionEditor.svelte)w-64= 256px. At 320px viewport with no horizontal overflow protection, and depending on where the textarea starts horizontally, the popup can bleed past the right edge. Addmax-w-[calc(100vw-2rem)]andright-0(aligned to the textarea's right edge) as a fallback:When the notification dropdown is empty,
firstFocusableElis null and focus goes nowhere (NotificationBell.svelte)attachFirstFocusableis only attached when there are notifications. When the dropdown opens empty,toggleDropdowncallsfirstFocusableEl?.focus()which silently does nothing. Focus stays on the bell button, the dropdown is open but not focused, and a keyboard user has no way to reach the empty state message or close with Escape without knowing to press it first.Svelte's
<svelte:window onkeydown>does handle Escape globally, so Escape still works. But the focus management intent is broken. Move{@attach attachFirstFocusable}to the dropdown's wrapping<div>withtabindex="-1"so there is always a focusable container to receive focus when the dropdown opens.Profile prefs form uses hidden inputs synced by JS (
profile/+page.svelte)Sara caught this. From a UX perspective: give the checkboxes
nameattributes (name="notifyOnReply",name="notifyOnMention") and remove the hidden inputs. Checked checkboxes submit their value ("on") natively; unchecked ones submit nothing (the server action can default missing values tofalse). This makes the form work without JS and aligns with how every other form in this codebase is built.role="dialog"without focus trapping is a screen reader lie (NotificationBell.svelte)role="dialog"tells screen readers to expect a modal — focus should be trapped inside, with a clear close mechanism. This dropdown doesn't trap focus. Userole="region"or no role at all (it's just a popup panel). The ARIA pattern for this UI is a disclosure widget:aria-expandedon the button (already done) and no role on the panel.Low
setTimeout(0)for cursor repositioning — usetick()(MentionEditor.svelte)Sara flagged the 100ms one in
CommentThread. Same note applies here:setTimeout(0)is a timing coincidence, not a guarantee. Importtickfromsvelteandawait tick()before the DOM mutation so it runs after Svelte's next render, not just "soon".Summary table
mention.ts:58–61displayNamebefore injectingrelativeTime()hardcoded GermanNotificationBell.svelte:168–177text-[10px]below 12pxNotificationBell.sveltetext-xsaria-label="ungelesen"hardcodedNotificationBell.sveltearia-liveannounces bare numberNotificationBell.svelte<div role="button">missing SpaceNotificationBell.svelte<button>MentionEditor.svelteright-0 max-w-[calc(100vw-2rem)]NotificationBell.sveltetabindex="-1"on wrapper divprofile/+page.svelterole="dialog"without focus trapNotificationBell.svelterole="region"setTimeout(0)for cursor positioningMentionEditor.svelteawait tick()The deep-link flow itself is clean — opening the panel to the discussion tab and scrolling to the highlighted comment is exactly right. The main things holding this back from passing my bar are the internationalization regression in
relativeTime, the 10px badge text, and the XSS (which the others already called out). Fix those three and the rest can follow in a quick polish pass.Response — @felixbrandt
Both reviews are correct across the board. No pushback from me. Here's what I'll fix and how:
BLOCKERs (fixing before any minor work)
Layering:
CommentService→AppUserRepositorydirectly (Markus #5, Sara #1)Adding
UserService.findAllById(Collection<UUID>)and injectingUserServiceintoCommentService. Same fix inNotificationService— it will go throughUserServicefor user lookups andCommentServicefor comment lookups instead of hitting their repositories directly.@Autowired(required = false)+ReflectionTestUtils(Sara #2)Switching to
Optional<JavaMailSender>constructor injection — Lombok handles this cleanly with@RequiredArgsConstructor. No reflection in tests.GET /api/users/searchno permission check (Sara #3)Adding
@RequirePermission(Permission.READ_ALL)at theUserSearchControllerclass level. The test gets a matching 403 case for a no-permission user.Data bug
actorNamealways null (Markus #1)Promoting
actorNamefrom@Transientto a real persistedVARCHARcolumn (new migration V18). Setting it innotifyReplyandnotifyMentionsfromreply.getAuthorName()/comment.getAuthorName().Architecture / design
NotificationControllermissing@RequirePermission(Sara #4)Adding at class level. Tests get 403 coverage.
NotificationControllerreturnsNotificationentity (Markus #4)Introducing
NotificationDTOrecord with the seven fields needed by the frontend. Service maps entity → DTO before returning. This eliminates the lazy-load N+1 and theAppUserfield leakage at once.@Transient mentionDTOs+FetchType.LAZY→LazyInitializationException(Sara #5)Going with the DTO approach: introducing
CommentResponseDTOto carry mention display data instead of mutating the JPA entity with a transient field. Cleaner and no transaction dependency.Notification failure rolls back the user's comment (Sara #6)
Annotating
notifyReplyandnotifyMentionswith@Transactional(propagation = Propagation.REQUIRES_NEW). A failed notification save will never surface to the user as a lost comment.N+1 in
notifyReplyandnotifyMentions(Markus #2, #3)Replacing the per-ID
findByIdloops with a singleuserRepository.findAllById(ids)call in each method (viaUserService).Security
Stored XSS in
renderBody—displayNamenot escaped (Sara #7, Markus #8 partially)Applying the same escape-first treatment to
firstName/lastNamebefore building the mention string. Also switching from<a href="#">to<span class="mention" data-user-id="…">as Markus suggests — no profile page exists to link to, and the anchor's default scroll-to-top behaviour is actively harmful. Adding the regression test inmention.spec.tsfor HTML-special chars in display names.Test gaps
markRead_throwsNotFound_whenNotificationDoesNotExist(Sara #9)markAllRead_delegatesToRepository(Sara #9)countUnread_delegatesToRepository(Sara #9)markOneRead_returns401_whenUnauthenticated(Sara #11)postComment_triggersNotifyMentions_whenMentionedUserIdsProvided(Sara #12)replyToComment_triggersNotifyMentions_whenMentionedUserIdsProvided(Sara #12)search_returnsAtMostTenResultsassertingjsonPath("$.length()").value(lessThanOrEqualTo(10))(Sara #10)mention.spec.ts(Sara #19 / already mentioned above)All of these will be added.
Frontend minors
relativeTime()hard-coded German +aria-label="ungelesen"(Markus, Sara #15/#16): Replacing withIntl.RelativeTimeFormatusing the active locale, and adding a Paraglide keynotification_unreadfor the aria-label.<div role="button">→<button>(Sara #17): Straightforward swap — native button handles Enter and Space for free.debounceTimernot cleared on destroy (Sara #13): AddingonDestroy(() => clearTimeout(debounceTimer)).setTimeout(100)for deep-link scroll (Sara #14): Replacing withawait tick()+requestAnimationFrame.formData.has('notifyOnReply')— standard HTML checkbox presence pattern.Dead code
List<Notification> findByRecipientIdOrderByCreatedAtDesc(UUID)inNotificationRepository(Markus #7, Sara #18): Deleted.I'll work through the BLOCKERs and data bug first, then the architecture items, then tests and minors. Will push as a single follow-up commit on this branch.
@RequirePermission now accepts Permission[] so a single annotation can express "any of these" rather than a single required permission. PermissionAspect updated accordingly — all existing single-value usages compile unchanged (Java auto-wraps scalars in arrays for annotation attrs). NotificationController: preference endpoints (GET/PUT /api/users/me/ notification-preferences) override the class-level READ_ALL gate with {READ_ALL, WRITE_ALL, ANNOTATE_ALL} so users without READ_ALL can still manage their own settings. Notification list endpoints retain READ_ALL. UserSearchController: same broadened set so ANNOTATE_ALL users can search for users to @mention when writing comments. Tests: added WRITE_ALL and ANNOTATE_ALL passing cases for preferences and user search; added 403 case for preferences with no permission; confirmed WRITE_ALL cannot reach notification list endpoints. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>Mention spans injected via {@html} need global CSS since scoped styles don't reach dynamically inserted content. Uses ink text on accent-bg background for visible but subtle chip appearance. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>Review — Leonie Voss, UI/UX & Accessibility
Verdict: APPROVE WITH COMMENTS — all 3 BLOCKERs and all 6 MAJORs from the previous round are resolved. Three findings remain: one to fix before merge, two for a follow-up.
Previous blockers — all fixed ✅
CommentService→AppUserRepositorylayering violationUserSearchService@Autowired(required=false)+ReflectionTestUtilshackOptional<JavaMailSender>GET /api/users/searchunguarded@RequirePermissiononUserSearchControllerPrevious majors — all fixed ✅
NotificationControllermissing permission gate@Transient+FetchType.LAZY=LazyInitializationExceptionFetchType.EAGERon@ManyToMany mentionsnotifyReply/notifyMentionsjoin outer transactionPropagation.REQUIRES_NEWon both methodsrenderBodyescapedDisplayNameproperly HTML-escaped before<span>injection; UUID indata-user-idis inherently safeformData.has()— works without JS, correct SvelteKit patternmarkAllRead401/204,markOneRead401/403, full prefs permission matrix — all presentRemaining findings
Medium — fix before merge
1. Unread badge count is wrong when user has >10 notifications
NotificationBell.svelte:19The bell fetches
/api/notifications?size=10. If a user has 15 unread notifications, the badge shows at most 10.NotificationService.countUnread()already exists but has no HTTP endpoint.Simplest fix for this project's notification volume: bump the fetch to
size=50. If a proper endpoint is preferred, addGET /api/notifications/unread-count → { count: long }and poll it independently.2.
markOneRead404 test still missingFlagged in the previous round.
NotificationControllerTestnow covers 401 and 403 forPATCH /api/notifications/{id}/readbut there's no test for the case where the notification ID doesn't exist.notificationService.markRead()throwsDomainException.notFound(...)which the global handler maps to 404 — one test case is still absent.Minor — fix in follow-up
3.
<button role="option">is invalid ARIA —MentionEditor.svelte:211role="option"overrides the implicit button role;<button>is not a valid child ofrole="listbox". Screen readers will mis-announce the popup entries. Replace with:4.
role="dialog"missingaria-modal="true"—NotificationBell.svelte:191Without
aria-modal="true"VoiceOver and NVDA continue reading the page behind the dropdown. Addaria-modal="true".5.
setTimeout(..., 0)should betick()—MentionEditor.svelte:102, 166Previously flagged as MINOR, still present.
tick()from Svelte flushes the DOM synchronously after reactive state settles — more correct than a 0 ms timer.The hard work is done. The badge accuracy issue (#1) is the only thing I'd push to fix before merge since it's user-visible on first load; the ARIA issues are real but won't block users from using the feature.
— @leonievoss
Code Review — @mkeller
Solid feature work. The separation between notification, mention, and deep-link concerns is clean. Tests are present and meaningful. Two issues need to be addressed before merge; the rest are observations.
🔴 Blockers
1. Schema not designed upfront — V16 + V18 + V19 must be collapsed
The migration trail tells the story of the implementation order, not the schema:
notificationswithoutactor_nameandannotation_idactor_nameviaALTER TABLEannotation_idviaALTER TABLEThis is a Flyway anti-pattern. Once on prod you can never reorder migrations, and you carry the ALTER TABLE overhead forever. The branch hasn't merged — fix it now. Collapse V16 + V18 + V19 into a single clean migration that creates the table with all columns:
V17 stays as-is. Delete V18 and V19.
2.
NotificationDTOmissing@Schema(requiredMode = REQUIRED)— TypeScript types will be wrongPer project convention, all backend-always-populated fields must carry
@Schema(requiredMode = Schema.RequiredMode.REQUIRED). Without this, the generated TypeScript client typesid,type,read, andcreatedAtas optional — callers writenotification.id!or silently passundefined.documentId,referenceId,annotationId, andactorNameare genuinely nullable and stay without the annotation.🟡 Medium Priority
3.
@Transient mentionDTOson theDocumentCommententity is a layering smellA
@Transientfield that must be populated by the service before returning is an implicit contract that any future code path can silently violate. Every method that returns aDocumentCommentnow has an invisible obligation to callwithMentionDTOs. The entity is doing double duty as a persistence object and a response object.The project style is to return entities directly as responses — but this case pushes that pattern past its natural limit. A lightweight
CommentResponserecord would be the clean solution. At minimum, consider a@PostLoadlifecycle hook that hydratesmentionDTOsautomatically so the obligation can't be forgotten.4. Notification endpoints have no
@RequirePermission— document the intentgetNotifications,markAllRead, andmarkOneReadhave no permission annotation. The rest of the API requires at leastREAD_ALLfor any data access. If the intent is "any authenticated user gets their own notifications" (reasonable — they're personal), state it with a comment. Silent inconsistency invites confusion.5. Polling in
NotificationBell— SSE is the right tool hereThis works, but SSE (
EventSourceon the client,SseEmitteron the Spring Boot side) would give you true server-push with zero polling overhead and automatic reconnect. For a family archive with < 20 users the polling cost is negligible, but since you're building the notification infrastructure anyway, SSE is the architecturally correct fit. A separate comment follows with a concrete implementation plan.✅ No action required
mention.tsXSS handling — escape-first pattern is correct. The 19 Vitest cases cover the edge cases well.PermissionAspect—Permission[]change — clean upgrade.anyOfsemantics match the use case.resolveUser(Authentication)— duplicated inNotificationControllerandCommentController. Minor; a sharedSecurityUtilshelper would clean this up but it's not blocking.@ManyToMany(fetch = EAGER)onDocumentComment.mentions— acceptable at this data scale.Fix the two blockers and this is ready to merge.
SSE Implementation Plan — replacing polling in
NotificationBellThis is an optional follow-up to point 5 in the review. The polling works fine for now, but here is a complete, concrete plan for replacing it with Server-Sent Events when the time comes. No external dependencies required — Spring Boot handles SSE natively.
How SSE works here
/api/notifications/streamvia the browser'sEventSourceAPI.SseEmitter.NotificationServicecreates a new notification, it looks up the active emitter for that user and pushes an event directly over the open connection.EventSourcereconnects automatically.This is unidirectional (server → client only), which is exactly what
EventSourceand SSE are designed for.Backend changes
1.
SseEmitterRegistry— a simple in-memory map of userId → emitter2.
NotificationController— add the stream endpointNo
@RequirePermissionneeded here — same rationale as the other notification endpoints (any authenticated user gets their own stream).3.
NotificationService— push after savingCall
saveAndPushinstead ofnotificationRepository.savein bothnotifyReplyandnotifyMentions.4. Spring Security — allow SSE through the CORS/async filter chain
Spring Boot's default async support handles
SseEmitterautomatically with Jetty, but verify that yourSecurityConfigdoes not block the streaming endpoint. If it does:It should already be covered by your existing authenticated-routes pattern, but worth checking.
Frontend changes
Replace
setIntervalwithEventSourceinNotificationBell.svelteRemove the polling interval entirely. Replace with:
The
PUBLIC_NOTIFICATION_POLL_MSenv var and thesetInterval/clearIntervallogic can be deleted entirely.What you do NOT need
SseEmitterAPI works fine with Jetty and Spring MVC)Files to touch
service/SseEmitterRegistry.javacontroller/NotificationController.javaGET /api/notifications/streamendpoint (~5 lines)service/NotificationService.javasavewithsaveAndPush(~10 lines)NotificationBell.sveltesetIntervalwithEventSource(~20 lines net change)Total: ~65 lines of net new code, ~20 lines deleted. No new dependencies, no new infrastructure.
Replaced one-way checked={...} with bind:group={selected} driven by a writable $derived. In Svelte 5, the $derived pattern guarantees the DOM checked state is always in sync at FormData capture time, so groupIds is never accidentally sent as [] when the admin edits their own profile. Sending groupIds:[] causes adminUpdateUser to clear all groups, which revokes the admin's own permissions on the next request. Tests: UserServiceTest (+4 for adminUpdateUser group behaviour), page.svelte.spec.ts (+1 FormData assertion at submit time). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>Follow-up Review — @mkeller
All blockers resolved. All medium-priority items addressed. Ready to merge.
✅ Blockers — resolved
Migration collapse: V16 is now a single clean schema with all columns (
annotation_id,actor_name) included from the start. V18 and V19 are gone. This is exactly right.NotificationDTO@Schema:id,type,read,createdAtare correctly markedREQUIRED. Nullable fields (documentId,referenceId,annotationId,actorName) are left without the annotation. Correct.✅ Medium items — resolved
Permission intent documented: The controller comment is clear and sufficient:
@Transient mentionDTOs: A comment documents the implicit contract. The layering smell remains but the obligation is no longer invisible. Acceptable given the project's convention of returning entities directly.SSE: Implemented cleanly and matches the plan exactly. One thing worth calling out explicitly —
notifyReplyandnotifyMentionsare annotated with@Transactional(propagation = Propagation.REQUIRES_NEW). That is better than what I suggested. It means a notification failure runs in its own transaction and cannot roll back the parent comment save. Good call.One minor observation
onMountinNotificationBellfires bothfetchNotifications()andfetchUnreadCount()— two HTTP requests to populate the initial badge. Since the/api/notificationspage response already contains all the data needed to count unreads, you could derive the count fromdata.content.filter(n => !n.read).lengthand skip the second request. Not worth blocking the merge for, but a simple cleanup for a follow-up commit.LGTM. Merge when ready.
Why
Propagation.REQUIRES_NEWmatters here — @mkellerShort version: it makes sure a broken email server can never delete a user's comment.
The problem without it
CommentService.postCommentis@Transactional. When it callsnotificationService.notifyReply(...), Spring does not start a new transaction by default — it reuses the one that's already open. That means everything runs in one unit of work:If
mailSender.send()throws (SMTP server is down, misconfigured, network hiccup), Spring catches the exception, marks the transaction for rollback, and the comment is lost too — even though the comment itself was perfectly valid. The user hits "Post" and nothing happens.What
REQUIRES_NEWdoesWhen
CommentServicecalls this method, Spring suspends the outer transaction and opens a brand-new independent one just for the notification work:Transaction B fails and rolls back. Transaction A never knew about it and commits cleanly. The user's comment is saved. They just don't get a notification — which is the correct behaviour. A notification is a side effect, not the core operation.
Why this is the right call specifically here
The two conditions that make
REQUIRES_NEWthe right choice:JavaMailSender→ SMTP). External calls are inherently unreliable and should never be inside a transaction that guards business data.If notifications were purely in-DB with no external calls, the default propagation (
REQUIRED— join the outer transaction) would be fine.REQUIRES_NEWis the correct upgrade the moment you add the mail send.One thing to be aware of
REQUIRES_NEWsuspends the outer transaction but holds its database connection open while Transaction B runs. Under high concurrency this can exhaust the connection pool faster than the default behaviour. For a family archive with < 20 users this is a complete non-issue. If this ever ran at significant scale, the clean solution is to move notification dispatch to an@TransactionalEventListener(phase = AFTER_COMMIT)— which fires after the outer transaction commits and needs no held connection. Not relevant here, just good to know.Minor cleanup — double request on mount in
NotificationBell— @mkellerRight now
onMountfires two parallel requests on every page load:fetchNotifications()here is redundant. The notifications list is already fetched insidetoggleDropdown()when the user actually opens the bell:So on every page load you're fetching a list of notifications that nobody has asked to see yet — purely to pre-warm a dropdown that may never be opened.
The fix: remove
fetchNotifications()fromonMount. KeepfetchUnreadCount()— that one is needed immediately to show the badge.The dropdown still feels instant because
toggleDropdownfetches just before it renders, and SSE keeps the badge count live after that anyway.My earlier suggestion to derive the count from
data.content.filter(n => !n.read).lengthwas imprecise — it would undercount if the user has more than 10 unread notifications. Dropping the list prefetch is the cleaner fix.