As a user I want to receive notifications for archive activity so I stay informed when family members annotate, comment, or start conversations #71

Closed
opened 2026-03-25 19:27:06 +01:00 by marcel · 5 comments
Owner

Background

The archive is a shared family tool. When someone replies in a comment thread or @mentions a user, the relevant person currently has no way of knowing. This issue adds an in-app notification centre (bell icon) and opt-in email notifications with per-user preferences in the profile.

Notification triggers

Event Who gets notified
Someone replies in a comment thread All users who have previously posted in that thread
A user is @mentioned in a comment The mentioned user (see #72)

Document edits, new annotations, and new conversations do not trigger notifications — too noisy for a family archive.

User Journey — in-app bell

User opens the app and sees a bell icon in the nav bar. If there are unread notifications, a small badge shows the count. Clicking the bell opens a dropdown showing recent events in reverse chronological order:

🔔 Hans hat auf deinen Kommentar geantwortet — Brief vom 12. März 1965 — vor 2 Stunden
🔔 Oma hat dich in einem Kommentar erwähnt — Weihnachtsbrief 1962 — gestern

Clicking a notification marks it as read and navigates to the relevant document and thread. "Mark all as read" clears the badge.

User Journey — email notifications

User opens their profile page. Under a new "Benachrichtigungen" section they see two toggles:

  • Antworten in Kommentarfäden — email when someone replies in a thread they participated in
  • Erwähnungen — email when someone @mentions them

Both default to off. Emails are plain text and link directly to the relevant document/thread. The existing JavaMailSender + SimpleMailMessage infrastructure (used by PasswordResetService) is reused as-is.

E2E Scenarios

Scenario: Bell shows unread count after a reply
  Given I have posted a comment on a document
  And another user has replied to that thread
  When I open the app
  Then the bell icon shows a badge with the unread count

Scenario: Clicking a notification navigates to the document
  Given I have an unread notification for a reply
  When I click the notification
  Then I am taken to the relevant document
  And the notification is marked as read

Scenario: Email preferences default to off
  Given I am on my profile page
  Then both email notification toggles are off

Scenario: Enabling email notifications for replies
  Given I am on my profile page
  When I enable "Antworten in Kommentarfäden"
  And another user replies in a thread I participated in
  Then I receive an email about the reply

Backend implementation notes

  • New Notification entity: id, recipientUser, type (enum: REPLY, MENTION), documentId, referenceId (comment/annotation id), read (boolean), createdAt
  • Notification preferences: two boolean columns on AppUser (notifyOnReply, notifyOnMention) — no separate entity needed
  • NotificationService: creates Notification records and conditionally sends email. Injects JavaMailSender with @Autowired(required = false) exactly like PasswordResetService. Uses app.mail.from property already in place.
  • New endpoints:
    • GET /api/notifications — paginated list for current user
    • POST /api/notifications/read-all — mark all as read
    • PATCH /api/notifications/{id}/read — mark one as read
    • GET /api/users/me/notification-preferences + PUT — load/save preferences

Frontend implementation notes

  • Bell icon in the global nav (right side, next to theme toggle)
  • Poll /api/notifications?unread=true&size=1 on a configurable interval. Interval driven by a Vite env variable PUBLIC_NOTIFICATION_POLL_MS (default 60000) so it can be tuned without a code change.
  • Notification dropdown: last 10 notifications, "Mark all as read", "Show all" link
  • Profile page: new "Benachrichtigungen" card with two toggle switches

Related: #72 (@mention — triggers MENTION notifications via this system)

## Background The archive is a shared family tool. When someone replies in a comment thread or @mentions a user, the relevant person currently has no way of knowing. This issue adds an in-app notification centre (bell icon) and opt-in email notifications with per-user preferences in the profile. ## Notification triggers | Event | Who gets notified | |---|---| | Someone replies in a comment thread | All users who have previously posted in that thread | | A user is @mentioned in a comment | The mentioned user (see #72) | Document edits, new annotations, and new conversations do **not** trigger notifications — too noisy for a family archive. ## User Journey — in-app bell User opens the app and sees a bell icon in the nav bar. If there are unread notifications, a small badge shows the count. Clicking the bell opens a dropdown showing recent events in reverse chronological order: > 🔔 **Hans** hat auf deinen Kommentar geantwortet — *Brief vom 12. März 1965* — vor 2 Stunden > 🔔 **Oma** hat dich in einem Kommentar erwähnt — *Weihnachtsbrief 1962* — gestern Clicking a notification marks it as read and navigates to the relevant document and thread. "Mark all as read" clears the badge. ## User Journey — email notifications User opens their profile page. Under a new "Benachrichtigungen" section they see two toggles: - **Antworten in Kommentarfäden** — email when someone replies in a thread they participated in - **Erwähnungen** — email when someone @mentions them Both default to **off**. Emails are plain text and link directly to the relevant document/thread. The existing `JavaMailSender` + `SimpleMailMessage` infrastructure (used by `PasswordResetService`) is reused as-is. ## E2E Scenarios ``` Scenario: Bell shows unread count after a reply Given I have posted a comment on a document And another user has replied to that thread When I open the app Then the bell icon shows a badge with the unread count Scenario: Clicking a notification navigates to the document Given I have an unread notification for a reply When I click the notification Then I am taken to the relevant document And the notification is marked as read Scenario: Email preferences default to off Given I am on my profile page Then both email notification toggles are off Scenario: Enabling email notifications for replies Given I am on my profile page When I enable "Antworten in Kommentarfäden" And another user replies in a thread I participated in Then I receive an email about the reply ``` ## Backend implementation notes - New `Notification` entity: `id`, `recipientUser`, `type` (enum: `REPLY`, `MENTION`), `documentId`, `referenceId` (comment/annotation id), `read` (boolean), `createdAt` - Notification preferences: two boolean columns on `AppUser` (`notifyOnReply`, `notifyOnMention`) — no separate entity needed - `NotificationService`: creates `Notification` records and conditionally sends email. Injects `JavaMailSender` with `@Autowired(required = false)` exactly like `PasswordResetService`. Uses `app.mail.from` property already in place. - New endpoints: - `GET /api/notifications` — paginated list for current user - `POST /api/notifications/read-all` — mark all as read - `PATCH /api/notifications/{id}/read` — mark one as read - `GET /api/users/me/notification-preferences` + `PUT` — load/save preferences ## Frontend implementation notes - Bell icon in the global nav (right side, next to theme toggle) - Poll `/api/notifications?unread=true&size=1` on a configurable interval. Interval driven by a Vite env variable `PUBLIC_NOTIFICATION_POLL_MS` (default `60000`) so it can be tuned without a code change. - Notification dropdown: last 10 notifications, "Mark all as read", "Show all" link - Profile page: new "Benachrichtigungen" card with two toggle switches Related: #72 (@mention — triggers MENTION notifications via this system)
Author
Owner

Implementation plan finalised

See .agent/current-plan.md (Phases 1 + 3) for the full step-by-step plan. Summary of decisions locked in:

Scope — two triggers only: replies in threads you participated in, and @mentions. No document-edit or new-annotation notifications.

Email — reuses existing JavaMailSender + SimpleMailMessage infrastructure from PasswordResetService verbatim. @Autowired(required = false) means it degrades gracefully when no SMTP is configured.

Preferences — two boolean columns directly on AppUser (notify_on_reply, notify_on_mention), no separate entity. Both default false.

Polling — frontend polls every PUBLIC_NOTIFICATION_POLL_MS ms (Vite env var, default 60 000). No WebSocket.

Migration order — V14 (this issue), V15 (@mentions, #72).

Branchfeat/71-72-notifications-and-mentions (both issues share one branch and PR)

## Implementation plan finalised See `.agent/current-plan.md` (Phases 1 + 3) for the full step-by-step plan. Summary of decisions locked in: **Scope** — two triggers only: replies in threads you participated in, and @mentions. No document-edit or new-annotation notifications. **Email** — reuses existing `JavaMailSender` + `SimpleMailMessage` infrastructure from `PasswordResetService` verbatim. `@Autowired(required = false)` means it degrades gracefully when no SMTP is configured. **Preferences** — two boolean columns directly on `AppUser` (`notify_on_reply`, `notify_on_mention`), no separate entity. Both default `false`. **Polling** — frontend polls every `PUBLIC_NOTIFICATION_POLL_MS` ms (Vite env var, default 60 000). No WebSocket. **Migration order** — V14 (this issue), V15 (@mentions, #72). **Branch** — `feat/71-72-notifications-and-mentions` (both issues share one branch and PR)
marcel added the notification label 2026-03-25 20:30:06 +01:00
Author
Owner

Full Implementation Plan

This plan covers #71 (notifications) and #72 (@mentions) together. Branch: feat/71-72-notifications-and-mentions.


Context — existing code that matters

  • DocumentComment — entity with id, documentId, annotationId, parentId, authorId, authorName, content, replies (transient). No mention storage yet.
  • CommentServicepostComment, replyToComment, editComment, deleteComment. Returns DocumentComment directly (no response DTO).
  • CreateCommentDTO — only has content. Needs mentionedUserIds added.
  • AppUser — has id, username, firstName, lastName, email. No notification preferences yet.
  • PasswordResetService — uses JavaMailSender (@Autowired(required = false)) + SimpleMailMessage. NotificationService follows the exact same pattern.
  • Latest migration: V13__add_file_hash.sql.
  • CommentThread.svelte — uses fetch-based API calls (not SvelteKit form actions), plain <textarea> for input.

Key decisions

  • Mention rendering is server-side: backend returns mentionDTOs: [{id, firstName, lastName}] on every comment response; frontend uses this list to turn @Name text into links — no client-side text parsing.
  • contenteditable div replaces <textarea> in the comment editor. Use bind:this, never bind:innerHTML — own the DOM directly.
  • Only AppUser is searchable for mentions — not Person records.
  • Notification scope: replies in threads you're part of + @mentions only. No notification for document edits or new annotations.
  • Polling interval configurable via PUBLIC_NOTIFICATION_POLL_MS Vite env var (default 60 000 ms).
  • Deep links include ?commentId= and optionally ?annotationId= — see #73.

Phase 1 — Notifications backend (#71)

Step 1 — Migration V14

File: V14__notifications_and_preferences.sql

-- Notification preferences on users
ALTER TABLE users ADD COLUMN notify_on_reply   BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE users ADD COLUMN notify_on_mention BOOLEAN NOT NULL DEFAULT false;

-- Notifications table
CREATE TABLE notifications (
    id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    recipient_id  UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    type          VARCHAR(32) NOT NULL,   -- 'REPLY' | 'MENTION'
    document_id   UUID,
    reference_id  UUID,                   -- commentId
    read          BOOLEAN NOT NULL DEFAULT false,
    created_at    TIMESTAMP NOT NULL DEFAULT now()
);
CREATE INDEX idx_notifications_recipient ON notifications(recipient_id, read, created_at DESC);

Step 2 — NotificationType enum

public enum NotificationType { REPLY, MENTION }

Step 3 — Notification entity

@Entity @Table(name = "notifications") @Data @NoArgsConstructor @AllArgsConstructor @Builder
public class Notification {
    @Id @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "recipient_id", nullable = false)
    private AppUser recipient;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private NotificationType type;

    @Column(name = "document_id")
    private UUID documentId;

    @Column(name = "reference_id")
    private UUID referenceId;  // commentId

    @Column(nullable = false)
    @Builder.Default
    private boolean read = false;

    @CreationTimestamp
    private LocalDateTime createdAt;
}

Step 4 — NotificationRepository

Page<Notification> findByRecipientIdOrderByCreatedAtDesc(UUID recipientId, Pageable pageable);
long countByRecipientIdAndReadFalse(UUID recipientId);

@Modifying
@Query("UPDATE Notification n SET n.read = true WHERE n.recipient.id = :userId")
void markAllReadByRecipientId(UUID userId);

Step 5 — AppUser model update

Add two boolean fields:

@Column(nullable = false) @Builder.Default private boolean notifyOnReply   = false;
@Column(nullable = false) @Builder.Default private boolean notifyOnMention = false;

Step 6 — NotificationService

Injections: NotificationRepository, CommentRepository, AppUserRepository, JavaMailSender (optional), @Value app.mail.from, @Value app.base-url.

Key methods:

  • notifyReply(DocumentComment reply) — collects all unique authorIds from the root comment + its siblings, removes the replier, creates a REPLY notification per participant, sends email to those with notifyOnReply=true.
  • notifyMentions(List<UUID> mentionedUserIds, DocumentComment comment) — creates a MENTION notification per mentioned user, sends email to those with notifyOnMention=true.
  • getNotifications(UUID userId, Pageable pageable) — delegates to repository.
  • countUnread(UUID userId) — delegates to repository.
  • markAllRead(UUID userId) — delegates to repository.
  • markRead(UUID notificationId, UUID userId) — loads, verifies ownership, sets read=true, saves.

Email bodies follow the same plain-text pattern as PasswordResetService. Link includes deep-link params (Refs #73):

String commentPath = comment.getAnnotationId() != null
    ? "?commentId=" + comment.getId() + "&annotationId=" + comment.getAnnotationId()
    : "?commentId=" + comment.getId();
// → {baseUrl}/documents/{documentId}?commentId={id}[&annotationId={id}]

Step 7 — NotificationController

GET  /api/notifications                     → paginated list (params: page, size)
POST /api/notifications/read-all            → mark all read (204)
PATCH /api/notifications/{id}/read          → mark one read (200)
GET  /api/users/me/notification-preferences → NotificationPreferenceDTO
PUT  /api/users/me/notification-preferences → update + return updated DTO

NotificationPreferenceDTO:

public record NotificationPreferenceDTO(boolean notifyOnReply, boolean notifyOnMention) {}

Step 8 — Hook CommentServiceNotificationService

Inject NotificationService into CommentService. After replyToComment saves: call notificationService.notifyReply(reply). Mention notifications are wired in Phase 2.


Phase 2 — Mentions backend (#72)

Step 9 — Migration V15

File: V15__comment_mentions.sql

CREATE TABLE comment_mentions (
    comment_id UUID NOT NULL REFERENCES document_comments(id) ON DELETE CASCADE,
    user_id    UUID NOT NULL REFERENCES users(id)             ON DELETE CASCADE,
    PRIMARY KEY (comment_id, user_id)
);

Step 10 — DocumentComment entity update

Add a @ManyToMany join to AppUser via comment_mentions. The serialized form exposes only {id, firstName, lastName} — use a MentionDTO record and a @Transient List<MentionDTO> mentionDTOs field populated by the service, with @JsonIgnore on the JPA collection.

// JPA — not serialized
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(name = "comment_mentions",
    joinColumns = @JoinColumn(name = "comment_id"),
    inverseJoinColumns = @JoinColumn(name = "user_id"))
@JsonIgnore
@Builder.Default
private List<AppUser> mentions = new ArrayList<>();

// Serialized — populated by service
@Transient
@Builder.Default
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private List<MentionDTO> mentionDTOs = new ArrayList<>();

MentionDTO:

public record MentionDTO(UUID id, String firstName, String lastName) {}

Step 11 — CreateCommentDTO update

Add List<UUID> mentionedUserIds = new ArrayList<>() field.

Step 12 — CommentService update

  • Inject AppUserRepository.
  • In postComment and replyToComment: look up AppUser by each mentionedUserId, set comment.setMentions(resolvedUsers), save, then call notificationService.notifyMentions(mentionedUserIds, comment).
  • In withReplies and everywhere a comment is returned: populate mentionDTOs from mentions.
  • Extract a withMentionDTOs(DocumentComment c) private helper to keep it DRY.

Step 13 — UserSearchController

GET /api/users/search?q={query}   → List<MentionDTO>
  • Requires authentication.
  • Query matches LOWER(first_name || ' ' || last_name) LIKE %q% OR LOWER(username) LIKE %q%.
  • Returns at most 10 results.
  • New AppUserRepository query method or @Query with LIKE on concatenated name + username.

Step 14 — Regenerate TypeScript API types

cd backend && ./mvnw clean package -DskipTests
# start backend with --spring.profiles.active=dev
cd frontend && npm run generate:api

Phase 3 — Notifications frontend (#71)

Step 15 — Vite env variable

Add to frontend/.env:

PUBLIC_NOTIFICATION_POLL_MS=60000

Step 16 — NotificationBell.svelte

New component. Props: none (reads current user implicitly via API).

State: unreadCount, open, notifications[].

Behaviour:

  • onMount: poll GET /api/notifications?size=10 every PUBLIC_NOTIFICATION_POLL_MS ms. Store interval ref for cleanup.
  • Bell SVG with badge (unreadCount > 0).
  • Click bell → toggle open, fetch fresh list.
  • Dropdown: last 10 notifications, each showing type icon + text + relative time + unread dot. Clicking an item → PATCH /api/notifications/{id}/read + goto('/documents/{documentId}?commentId={referenceId}[&annotationId=...]') (see #73).
  • "Alle gelesen" button → POST /api/notifications/read-all + reset unreadCount.
  • Click-outside directive (copy pattern from layout user menu) closes dropdown.

Step 17 — Wire bell into +layout.svelte

Add <NotificationBell /> to the nav right side, between ThemeToggle and user menu. Only render when data.user is present (not on auth pages).

Step 18 — Profile page preferences

In profile/+page.server.ts: add GET /api/users/me/notification-preferences to the load function.
In profile/+page.svelte: new "Benachrichtigungen" card with two checkbox toggles. Save via existing form action pattern (POST with new action key) or a dedicated PUT fetch call.


Phase 4 — Mentions frontend (#72)

Step 19 — MentionEditor.svelte

Replaces <textarea> in comment forms. Emits onsubmit: (payload: { body: string; mentionedUserIds: string[] }) => void.

Internal logic:

  • detectMention(el) — on each input event: get Selection, scan backwards in the current text node for an @ with no whitespace between it and the cursor. Returns { query, range } or null.
  • fetchUsers(query) — debounced (200 ms), calls GET /api/users/search?q={query}, updates users state.
  • insertChip(el, user, mentionRange):
    1. Delete the @query text covered by mentionRange.
    2. Create <span contenteditable="false" data-mention-id="{user.id}" class="mention-chip">@{firstName} {lastName}</span>.
    3. Insert span at current range position.
    4. Insert a " " text node after the span.
    5. Move cursor to after the space.
  • extractContent(el) — walks childNodes:
    • TEXT_NODE → append textContent
    • SPAN[data-mention-id] → append @{textContent}, push dataset.mentionId to mentionedUserIds
    • BR → append \n
  • onPaste(e) — prevent default, document.execCommand('insertText', false, plainText).
  • onKeyDown(e) — when popup open: ↑↓ navigate, Enter/Tab select (preventDefault), Escape dismiss. Ctrl/Cmd+Enter → submit.
  • Submit: call extractContent, invoke onsubmit prop, clear editor (el.innerHTML = '').

Step 20 — MentionPopup.svelte

Props: users, selectedIndex, anchor: DOMRect, onSelect.

  • position: fixed; top: {anchor.bottom + 4}px; left: {anchor.left}px
  • role="listbox", rows role="option".
  • Highlight selectedIndex row with bg-accent-bg.
  • Mouse click on row → onSelect(user).

Step 21 — CommentThread.svelte updates

  • Replace root-comment and reply <textarea> + submit buttons with <MentionEditor onsubmit={handlePost} /> / <MentionEditor onsubmit={handleReply} />.
  • handlePost({ body, mentionedUserIds }) — POST { content: body, mentionedUserIds }.
  • handleReply({ body, mentionedUserIds }) — POST reply with same shape.
  • Add renderBody(comment: DocumentComment): string — for each entry in comment.mentionDTOs, replace @{firstName} {lastName} in the body with <a href="/users/{id}" class="mention-link">@{firstName} {lastName}</a>. Use {@html renderBody(comment)} in the template.
  • DocumentComment TypeScript type gains mentionDTOs: { id: string; firstName: string; lastName: string }[].

TDD checkpoints

Step Test Type
Step 6 notifyReply creates notifications for all thread participants except replier Unit
Step 6 notifyReply sends email only to users with notifyOnReply=true Unit
Step 6 notifyMentions creates MENTION notification per mentioned user Unit
Step 7 GET /api/notifications returns 200 + list Controller slice
Step 7 POST /api/notifications/read-all marks all as read Controller slice
Step 7 GET /api/users/me/notification-preferences returns current prefs Controller slice
Step 13 GET /api/users/search?q=Hans returns matching users Controller slice
Step 13 Unauthenticated GET /api/users/search returns 401 Controller slice
Step 19 detectMention returns query after @ Vitest unit
Step 19 extractContent returns correct body + IDs for mixed text+chips Vitest unit
E2E Bell badge appears after another user replies Playwright
E2E @mention popup appears when typing @, user can select Playwright
E2E Mentioned user has unread notification after comment is posted Playwright

Commit order (each atomic)

  1. feat(backend): add notifications table and user preferences columns — V14
  2. feat(backend): add Notification entity, NotificationService, and tests
  3. feat(backend): add NotificationController endpoints
  4. feat(backend): trigger reply notifications from CommentService
  5. feat(backend): add comment_mentions table — V15
  6. feat(backend): add MentionDTO and wire mentions into DocumentComment
  7. feat(backend): save mentions on comment post/reply and trigger mention notifications
  8. feat(backend): add GET /api/users/search endpoint
  9. chore(api): regenerate TypeScript types
  10. feat(frontend): add NotificationBell component with polling dropdown
  11. feat(frontend): add notification preferences to profile page
  12. feat(frontend): add MentionEditor and MentionPopup components
  13. feat(frontend): wire MentionEditor into CommentThread with mention rendering
## Full Implementation Plan This plan covers #71 (notifications) and #72 (@mentions) together. Branch: `feat/71-72-notifications-and-mentions`. --- ### Context — existing code that matters - `DocumentComment` — entity with `id`, `documentId`, `annotationId`, `parentId`, `authorId`, `authorName`, `content`, `replies` (transient). No mention storage yet. - `CommentService` — `postComment`, `replyToComment`, `editComment`, `deleteComment`. Returns `DocumentComment` directly (no response DTO). - `CreateCommentDTO` — only has `content`. Needs `mentionedUserIds` added. - `AppUser` — has `id`, `username`, `firstName`, `lastName`, `email`. No notification preferences yet. - `PasswordResetService` — uses `JavaMailSender` (`@Autowired(required = false)`) + `SimpleMailMessage`. `NotificationService` follows the exact same pattern. - Latest migration: `V13__add_file_hash.sql`. - `CommentThread.svelte` — uses fetch-based API calls (not SvelteKit form actions), plain `<textarea>` for input. ### Key decisions - Mention rendering is **server-side**: backend returns `mentionDTOs: [{id, firstName, lastName}]` on every comment response; frontend uses this list to turn `@Name` text into links — no client-side text parsing. - `contenteditable` div replaces `<textarea>` in the comment editor. Use `bind:this`, never `bind:innerHTML` — own the DOM directly. - Only `AppUser` is searchable for mentions — not `Person` records. - Notification scope: replies in threads you're part of + @mentions only. No notification for document edits or new annotations. - Polling interval configurable via `PUBLIC_NOTIFICATION_POLL_MS` Vite env var (default 60 000 ms). - Deep links include `?commentId=` and optionally `?annotationId=` — see #73. --- ## Phase 1 — Notifications backend (#71) ### Step 1 — Migration V14 File: `V14__notifications_and_preferences.sql` ```sql -- Notification preferences on users ALTER TABLE users ADD COLUMN notify_on_reply BOOLEAN NOT NULL DEFAULT false; ALTER TABLE users ADD COLUMN notify_on_mention BOOLEAN NOT NULL DEFAULT false; -- Notifications table CREATE TABLE notifications ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), recipient_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, type VARCHAR(32) NOT NULL, -- 'REPLY' | 'MENTION' document_id UUID, reference_id UUID, -- commentId read BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMP NOT NULL DEFAULT now() ); CREATE INDEX idx_notifications_recipient ON notifications(recipient_id, read, created_at DESC); ``` ### Step 2 — `NotificationType` enum ```java public enum NotificationType { REPLY, MENTION } ``` ### Step 3 — `Notification` entity ```java @Entity @Table(name = "notifications") @Data @NoArgsConstructor @AllArgsConstructor @Builder public class Notification { @Id @GeneratedValue(strategy = GenerationType.UUID) private UUID id; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "recipient_id", nullable = false) private AppUser recipient; @Enumerated(EnumType.STRING) @Column(nullable = false) private NotificationType type; @Column(name = "document_id") private UUID documentId; @Column(name = "reference_id") private UUID referenceId; // commentId @Column(nullable = false) @Builder.Default private boolean read = false; @CreationTimestamp private LocalDateTime createdAt; } ``` ### Step 4 — `NotificationRepository` ```java Page<Notification> findByRecipientIdOrderByCreatedAtDesc(UUID recipientId, Pageable pageable); long countByRecipientIdAndReadFalse(UUID recipientId); @Modifying @Query("UPDATE Notification n SET n.read = true WHERE n.recipient.id = :userId") void markAllReadByRecipientId(UUID userId); ``` ### Step 5 — `AppUser` model update Add two boolean fields: ```java @Column(nullable = false) @Builder.Default private boolean notifyOnReply = false; @Column(nullable = false) @Builder.Default private boolean notifyOnMention = false; ``` ### Step 6 — `NotificationService` Injections: `NotificationRepository`, `CommentRepository`, `AppUserRepository`, `JavaMailSender` (optional), `@Value app.mail.from`, `@Value app.base-url`. Key methods: - `notifyReply(DocumentComment reply)` — collects all unique `authorId`s from the root comment + its siblings, removes the replier, creates a `REPLY` notification per participant, sends email to those with `notifyOnReply=true`. - `notifyMentions(List<UUID> mentionedUserIds, DocumentComment comment)` — creates a `MENTION` notification per mentioned user, sends email to those with `notifyOnMention=true`. - `getNotifications(UUID userId, Pageable pageable)` — delegates to repository. - `countUnread(UUID userId)` — delegates to repository. - `markAllRead(UUID userId)` — delegates to repository. - `markRead(UUID notificationId, UUID userId)` — loads, verifies ownership, sets `read=true`, saves. Email bodies follow the same plain-text pattern as `PasswordResetService`. Link includes deep-link params (Refs #73): ```java String commentPath = comment.getAnnotationId() != null ? "?commentId=" + comment.getId() + "&annotationId=" + comment.getAnnotationId() : "?commentId=" + comment.getId(); // → {baseUrl}/documents/{documentId}?commentId={id}[&annotationId={id}] ``` ### Step 7 — `NotificationController` ``` GET /api/notifications → paginated list (params: page, size) POST /api/notifications/read-all → mark all read (204) PATCH /api/notifications/{id}/read → mark one read (200) GET /api/users/me/notification-preferences → NotificationPreferenceDTO PUT /api/users/me/notification-preferences → update + return updated DTO ``` `NotificationPreferenceDTO`: ```java public record NotificationPreferenceDTO(boolean notifyOnReply, boolean notifyOnMention) {} ``` ### Step 8 — Hook `CommentService` → `NotificationService` Inject `NotificationService` into `CommentService`. After `replyToComment` saves: call `notificationService.notifyReply(reply)`. Mention notifications are wired in Phase 2. --- ## Phase 2 — Mentions backend (#72) ### Step 9 — Migration V15 File: `V15__comment_mentions.sql` ```sql CREATE TABLE comment_mentions ( comment_id UUID NOT NULL REFERENCES document_comments(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, PRIMARY KEY (comment_id, user_id) ); ``` ### Step 10 — `DocumentComment` entity update Add a `@ManyToMany` join to `AppUser` via `comment_mentions`. The serialized form exposes only `{id, firstName, lastName}` — use a `MentionDTO` record and a `@Transient List<MentionDTO> mentionDTOs` field populated by the service, with `@JsonIgnore` on the JPA collection. ```java // JPA — not serialized @ManyToMany(fetch = FetchType.LAZY) @JoinTable(name = "comment_mentions", joinColumns = @JoinColumn(name = "comment_id"), inverseJoinColumns = @JoinColumn(name = "user_id")) @JsonIgnore @Builder.Default private List<AppUser> mentions = new ArrayList<>(); // Serialized — populated by service @Transient @Builder.Default @Schema(requiredMode = Schema.RequiredMode.REQUIRED) private List<MentionDTO> mentionDTOs = new ArrayList<>(); ``` `MentionDTO`: ```java public record MentionDTO(UUID id, String firstName, String lastName) {} ``` ### Step 11 — `CreateCommentDTO` update Add `List<UUID> mentionedUserIds = new ArrayList<>()` field. ### Step 12 — `CommentService` update - Inject `AppUserRepository`. - In `postComment` and `replyToComment`: look up `AppUser` by each `mentionedUserId`, set `comment.setMentions(resolvedUsers)`, save, then call `notificationService.notifyMentions(mentionedUserIds, comment)`. - In `withReplies` and everywhere a comment is returned: populate `mentionDTOs` from `mentions`. - Extract a `withMentionDTOs(DocumentComment c)` private helper to keep it DRY. ### Step 13 — `UserSearchController` ``` GET /api/users/search?q={query} → List<MentionDTO> ``` - Requires authentication. - Query matches `LOWER(first_name || ' ' || last_name)` LIKE `%q%` OR `LOWER(username)` LIKE `%q%`. - Returns at most 10 results. - New `AppUserRepository` query method or `@Query` with LIKE on concatenated name + username. ### Step 14 — Regenerate TypeScript API types ```bash cd backend && ./mvnw clean package -DskipTests # start backend with --spring.profiles.active=dev cd frontend && npm run generate:api ``` --- ## Phase 3 — Notifications frontend (#71) ### Step 15 — Vite env variable Add to `frontend/.env`: ``` PUBLIC_NOTIFICATION_POLL_MS=60000 ``` ### Step 16 — `NotificationBell.svelte` New component. Props: none (reads current user implicitly via API). State: `unreadCount`, `open`, `notifications[]`. Behaviour: - `onMount`: poll `GET /api/notifications?size=10` every `PUBLIC_NOTIFICATION_POLL_MS` ms. Store interval ref for cleanup. - Bell SVG with badge (`unreadCount > 0`). - Click bell → toggle `open`, fetch fresh list. - Dropdown: last 10 notifications, each showing type icon + text + relative time + unread dot. Clicking an item → `PATCH /api/notifications/{id}/read` + `goto('/documents/{documentId}?commentId={referenceId}[&annotationId=...]')` (see #73). - "Alle gelesen" button → `POST /api/notifications/read-all` + reset `unreadCount`. - Click-outside directive (copy pattern from layout user menu) closes dropdown. ### Step 17 — Wire bell into `+layout.svelte` Add `<NotificationBell />` to the nav right side, between `ThemeToggle` and user menu. Only render when `data.user` is present (not on auth pages). ### Step 18 — Profile page preferences In `profile/+page.server.ts`: add `GET /api/users/me/notification-preferences` to the load function. In `profile/+page.svelte`: new "Benachrichtigungen" card with two checkbox toggles. Save via existing form action pattern (POST with new action key) or a dedicated `PUT` fetch call. --- ## Phase 4 — Mentions frontend (#72) ### Step 19 — `MentionEditor.svelte` Replaces `<textarea>` in comment forms. Emits `onsubmit: (payload: { body: string; mentionedUserIds: string[] }) => void`. Internal logic: - `detectMention(el)` — on each `input` event: get `Selection`, scan backwards in the current text node for an `@` with no whitespace between it and the cursor. Returns `{ query, range }` or null. - `fetchUsers(query)` — debounced (200 ms), calls `GET /api/users/search?q={query}`, updates `users` state. - `insertChip(el, user, mentionRange)`: 1. Delete the `@query` text covered by `mentionRange`. 2. Create `<span contenteditable="false" data-mention-id="{user.id}" class="mention-chip">@{firstName} {lastName}</span>`. 3. Insert span at current range position. 4. Insert a `" "` text node after the span. 5. Move cursor to after the space. - `extractContent(el)` — walks `childNodes`: - `TEXT_NODE` → append `textContent` - `SPAN[data-mention-id]` → append `@{textContent}`, push `dataset.mentionId` to `mentionedUserIds` - `BR` → append `\n` - `onPaste(e)` — prevent default, `document.execCommand('insertText', false, plainText)`. - `onKeyDown(e)` — when popup open: ↑↓ navigate, Enter/Tab select (`preventDefault`), Escape dismiss. Ctrl/Cmd+Enter → submit. - Submit: call `extractContent`, invoke `onsubmit` prop, clear editor (`el.innerHTML = ''`). ### Step 20 — `MentionPopup.svelte` Props: `users`, `selectedIndex`, `anchor: DOMRect`, `onSelect`. - `position: fixed; top: {anchor.bottom + 4}px; left: {anchor.left}px` - `role="listbox"`, rows `role="option"`. - Highlight `selectedIndex` row with `bg-accent-bg`. - Mouse click on row → `onSelect(user)`. ### Step 21 — `CommentThread.svelte` updates - Replace root-comment and reply `<textarea>` + submit buttons with `<MentionEditor onsubmit={handlePost} />` / `<MentionEditor onsubmit={handleReply} />`. - `handlePost({ body, mentionedUserIds })` — POST `{ content: body, mentionedUserIds }`. - `handleReply({ body, mentionedUserIds })` — POST reply with same shape. - Add `renderBody(comment: DocumentComment): string` — for each entry in `comment.mentionDTOs`, replace `@{firstName} {lastName}` in the body with `<a href="/users/{id}" class="mention-link">@{firstName} {lastName}</a>`. Use `{@html renderBody(comment)}` in the template. - `DocumentComment` TypeScript type gains `mentionDTOs: { id: string; firstName: string; lastName: string }[]`. --- ## TDD checkpoints | Step | Test | Type | |---|---|---| | Step 6 | `notifyReply` creates notifications for all thread participants except replier | Unit | | Step 6 | `notifyReply` sends email only to users with `notifyOnReply=true` | Unit | | Step 6 | `notifyMentions` creates MENTION notification per mentioned user | Unit | | Step 7 | `GET /api/notifications` returns 200 + list | Controller slice | | Step 7 | `POST /api/notifications/read-all` marks all as read | Controller slice | | Step 7 | `GET /api/users/me/notification-preferences` returns current prefs | Controller slice | | Step 13 | `GET /api/users/search?q=Hans` returns matching users | Controller slice | | Step 13 | Unauthenticated `GET /api/users/search` returns 401 | Controller slice | | Step 19 | `detectMention` returns query after `@` | Vitest unit | | Step 19 | `extractContent` returns correct body + IDs for mixed text+chips | Vitest unit | | E2E | Bell badge appears after another user replies | Playwright | | E2E | @mention popup appears when typing `@`, user can select | Playwright | | E2E | Mentioned user has unread notification after comment is posted | Playwright | --- ## Commit order (each atomic) 1. `feat(backend): add notifications table and user preferences columns — V14` 2. `feat(backend): add Notification entity, NotificationService, and tests` 3. `feat(backend): add NotificationController endpoints` 4. `feat(backend): trigger reply notifications from CommentService` 5. `feat(backend): add comment_mentions table — V15` 6. `feat(backend): add MentionDTO and wire mentions into DocumentComment` 7. `feat(backend): save mentions on comment post/reply and trigger mention notifications` 8. `feat(backend): add GET /api/users/search endpoint` 9. `chore(api): regenerate TypeScript types` 10. `feat(frontend): add NotificationBell component with polling dropdown` 11. `feat(frontend): add notification preferences to profile page` 12. `feat(frontend): add MentionEditor and MentionPopup components` 13. `feat(frontend): wire MentionEditor into CommentThread with mention rendering`
Author
Owner

Architecture review — @mkeller

Overall the backend design is clean. Schema is minimal, email reuse is pragmatic, preference storage as two boolean columns on AppUser is the right call (no separate entity needed). A few notes:

Polling — correct choice, no change needed
The 60-second poll is the right call for this app. SSE would give real-time push without WebSockets, but the persistent-connection management in SvelteKit streaming adds operational friction that isn't justified for a family archive with a handful of users. Boring technology wins here.

@Transient List<MentionDTO> on DocumentComment
The plan puts mentionDTOs as a @Transient field on the entity, populated by the service before serialization. This is a consequence of the no-response-DTO pattern already established in this codebase — consistent, so not a blocker. Add an explicit comment on the field so it's clear to anyone reading the entity later:

// Populated by CommentService before serialization — not persisted.
@Transient
@Builder.Default
private List<MentionDTO> mentionDTOs = new ArrayList<>();

The critical issue is in #72 — see my comment there before any frontend work begins.

## Architecture review — @mkeller Overall the backend design is clean. Schema is minimal, email reuse is pragmatic, preference storage as two boolean columns on `AppUser` is the right call (no separate entity needed). A few notes: **Polling — correct choice, no change needed** The 60-second poll is the right call for this app. SSE would give real-time push without WebSockets, but the persistent-connection management in SvelteKit streaming adds operational friction that isn't justified for a family archive with a handful of users. Boring technology wins here. **`@Transient List<MentionDTO>` on `DocumentComment`** The plan puts `mentionDTOs` as a `@Transient` field on the entity, populated by the service before serialization. This is a consequence of the no-response-DTO pattern already established in this codebase — consistent, so not a blocker. Add an explicit comment on the field so it's clear to anyone reading the entity later: ```java // Populated by CommentService before serialization — not persisted. @Transient @Builder.Default private List<MentionDTO> mentionDTOs = new ArrayList<>(); ``` **The critical issue is in #72 — see my comment there before any frontend work begins.**
Author
Owner

UX Review — @leonievoss

The backend design (confirmed clean by the architect) and the functional scope are solid. Accessibility and senior usability gaps need addressing on the frontend side.

🔴 Critical — Bell badge is silent to screen readers

When the unread count changes after a poll, there is no announcement. The badge element must use aria-live="polite" and the bell button needs a meaningful label — not just the number:

<button
  aria-label={unreadCount > 0 ? `${unreadCount} ungelesene Benachrichtigungen` : 'Benachrichtigungen'}
>
  <BellIcon />
  {#if unreadCount > 0}
    <span aria-live="polite" class="badge">{unreadCount}</span>
  {/if}
</button>

Without this, screen reader users have no way of knowing new notifications have arrived.

🔴 Critical — Dropdown has no focus management spec

The issue says nothing about keyboard behavior for the dropdown. Two rules are non-negotiable:

  • On open: focus moves into the dropdown (first notification item or the "Alle gelesen" button).
  • On close (Escape or click outside): focus returns to the bell button.

Without this, keyboard-only users are stranded after opening the dropdown.

🔴 High — Empty state is unspecified

When there are no notifications, the dropdown opens to a blank box. Users will think the feature is broken. Add to the implementation notes:

Empty state: "Keine neuen Benachrichtigungen" centered in the dropdown with a small neutral icon. Same card styling as the populated state.

🟡 Medium — Badge must meet contrast and size minimums for seniors

A small low-contrast badge will be missed by a 65-year-old user. Specify: minimum 20×20px, background: #002850 (brand-navy), white text. Do not use a muted gray.

🟡 Medium — "Alle gelesen" must be visually deprioritized

This button must never appear as a primary CTA — accidental mass-dismissal is a real risk. Use small, secondary styling (e.g. text-xs font-medium text-gray-500 hover:text-brand-navy), never a filled button.

🟡 Medium — Email opt-in copy: plain language for seniors

"Antworten in Kommentarfäden" is technically correct but jargon-heavy. Suggest:

  • "E-Mail, wenn jemand auf meinen Kommentar antwortet"
  • "E-Mail, wenn jemand mich in einem Kommentar erwähnt"

Full sentences, no abbreviations.

On polling interval — architect confirmed, no change needed

The architect's assessment that 60s polling is the right call for this app is correct. SSE would give real-time delivery but the persistent-connection management in SvelteKit adds operational friction not justified here. Boring technology wins.

What's good

  • Notification scope (replies + mentions only, no document edits) is correct — well-calibrated for a family archive.
  • Email defaults to off — respects user autonomy, correct privacy default.
  • Preference storage as two booleans on AppUser — no separate entity needed, right call.
  • Deep-link format ties cleanly into #73.
## UX Review — @leonievoss The backend design (confirmed clean by the architect) and the functional scope are solid. Accessibility and senior usability gaps need addressing on the frontend side. ### 🔴 Critical — Bell badge is silent to screen readers When the unread count changes after a poll, there is no announcement. The badge element must use `aria-live="polite"` and the bell button needs a meaningful label — not just the number: ```svelte <button aria-label={unreadCount > 0 ? `${unreadCount} ungelesene Benachrichtigungen` : 'Benachrichtigungen'} > <BellIcon /> {#if unreadCount > 0} <span aria-live="polite" class="badge">{unreadCount}</span> {/if} </button> ``` Without this, screen reader users have no way of knowing new notifications have arrived. ### 🔴 Critical — Dropdown has no focus management spec The issue says nothing about keyboard behavior for the dropdown. Two rules are non-negotiable: - **On open**: focus moves into the dropdown (first notification item or the "Alle gelesen" button). - **On close** (Escape or click outside): focus returns to the bell button. Without this, keyboard-only users are stranded after opening the dropdown. ### 🔴 High — Empty state is unspecified When there are no notifications, the dropdown opens to a blank box. Users will think the feature is broken. Add to the implementation notes: > Empty state: `"Keine neuen Benachrichtigungen"` centered in the dropdown with a small neutral icon. Same card styling as the populated state. ### 🟡 Medium — Badge must meet contrast and size minimums for seniors A small low-contrast badge will be missed by a 65-year-old user. Specify: minimum 20×20px, `background: #002850` (brand-navy), white text. Do not use a muted gray. ### 🟡 Medium — "Alle gelesen" must be visually deprioritized This button must never appear as a primary CTA — accidental mass-dismissal is a real risk. Use small, secondary styling (e.g. `text-xs font-medium text-gray-500 hover:text-brand-navy`), never a filled button. ### 🟡 Medium — Email opt-in copy: plain language for seniors `"Antworten in Kommentarfäden"` is technically correct but jargon-heavy. Suggest: - → *"E-Mail, wenn jemand auf meinen Kommentar antwortet"* - → *"E-Mail, wenn jemand mich in einem Kommentar erwähnt"* Full sentences, no abbreviations. ### On polling interval — architect confirmed, no change needed The architect's assessment that 60s polling is the right call for this app is correct. SSE would give real-time delivery but the persistent-connection management in SvelteKit adds operational friction not justified here. Boring technology wins. ✅ ### ✅ What's good - Notification scope (replies + mentions only, no document edits) is correct — well-calibrated for a family archive. - Email defaults to off — respects user autonomy, correct privacy default. - Preference storage as two booleans on `AppUser` — no separate entity needed, right call. - Deep-link format ties cleanly into #73.
Author
Owner

QA Review — @saraholt

The implementation plan is thorough and the TDD checkpoint table is a good start. Several critical test cases are missing that must be in this PR before merge.


🔴 Blocking — cross-user notification isolation

We are building per-user data. We must explicitly verify isolation. Two @WebMvcTest controller slice tests:

  • GET /api/notifications returns only the current user's notifications — never another user's
  • PATCH /api/notifications/{id}/read returns 403 when the notification belongs to a different user

This is the notification equivalent of what we'd test for any tenant-scoped table. If these aren't tested, we won't know until a production bug.


🔴 Blocking — notifyReply must not notify the replier themselves

Unit test for NotificationService.notifyReply:

@Test
void notifyReply_doesNotNotifyTheReplierThemselves() {
    // replier is already a thread participant
    // verify no notification is created for them
}

Without this, a user replying to their own thread gets spammed with their own notification.


🟡 Missing unit tests for NotificationService

The TDD table covers happy paths. Add:

Test Why
notifyReply deduplicates participants — same user posting twice in a thread gets one notification, not two Edge case, easy to miss in the authorId collection logic
notifyReply sends email only to users with notifyOnReply=true, not all participants Email opt-in must be verified in isolation
Preferences PUT persists both booleans independently — saving one does not overwrite the other The two columns on AppUser are independent; verify neither clobbers the other

🟡 Missing E2E scenarios

The issue lists the critical journeys. Add these to the Playwright suite:

  • Bell badge is announced by screen readers via aria-liveaxe-playwright will catch this automatically if the attribute is missing, so it's low overhead
  • Focus lands inside the dropdown on open; Escape returns focus to the bell button — without this keyboard-only users are stranded
  • Empty notification state shows placeholder text, not a blank box — users will think the feature is broken otherwise
  • "Alle gelesen" clears the badge but does not navigate away

What's solid

  • Schema is minimal, notify_on_reply / notify_on_mention as booleans directly on AppUser is the right call
  • @Autowired(required = false) on JavaMailSender — graceful degradation without SMTP config, same pattern as PasswordResetService
  • Polling via Vite env var is testable without code changes
  • Commit order is correctly atomic (13 commits, each single-responsibility)

The backend plan is clean. The test gaps above are the only things blocking merge from a QA standpoint.

## QA Review — @saraholt The implementation plan is thorough and the TDD checkpoint table is a good start. Several critical test cases are missing that must be in this PR before merge. --- ### 🔴 Blocking — cross-user notification isolation We are building per-user data. We must explicitly verify isolation. Two `@WebMvcTest` controller slice tests: - `GET /api/notifications` returns only the current user's notifications — never another user's - `PATCH /api/notifications/{id}/read` returns 403 when the notification belongs to a different user This is the notification equivalent of what we'd test for any tenant-scoped table. If these aren't tested, we won't know until a production bug. --- ### 🔴 Blocking — `notifyReply` must not notify the replier themselves Unit test for `NotificationService.notifyReply`: ```java @Test void notifyReply_doesNotNotifyTheReplierThemselves() { // replier is already a thread participant // verify no notification is created for them } ``` Without this, a user replying to their own thread gets spammed with their own notification. --- ### 🟡 Missing unit tests for `NotificationService` The TDD table covers happy paths. Add: | Test | Why | |---|---| | `notifyReply` deduplicates participants — same user posting twice in a thread gets one notification, not two | Edge case, easy to miss in the `authorId` collection logic | | `notifyReply` sends email only to users with `notifyOnReply=true`, not all participants | Email opt-in must be verified in isolation | | Preferences `PUT` persists both booleans independently — saving one does not overwrite the other | The two columns on `AppUser` are independent; verify neither clobbers the other | --- ### 🟡 Missing E2E scenarios The issue lists the critical journeys. Add these to the Playwright suite: - Bell badge is announced by screen readers via `aria-live` — `axe-playwright` will catch this automatically if the attribute is missing, so it's low overhead - Focus lands inside the dropdown on open; Escape returns focus to the bell button — without this keyboard-only users are stranded - Empty notification state shows placeholder text, not a blank box — users will think the feature is broken otherwise - "Alle gelesen" clears the badge but does not navigate away --- ### ✅ What's solid - Schema is minimal, `notify_on_reply` / `notify_on_mention` as booleans directly on `AppUser` is the right call - `@Autowired(required = false)` on `JavaMailSender` — graceful degradation without SMTP config, same pattern as `PasswordResetService` - Polling via Vite env var is testable without code changes - Commit order is correctly atomic (13 commits, each single-responsibility) The backend plan is clean. The test gaps above are the only things blocking merge from a QA standpoint.
Sign in to join this conversation.
No Label notification
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#71