Files
familienarchiv/.agent/current-plan.md
2026-05-05 12:39:20 +02:00

14 KiB

Plan: Notifications (#71) + @mentions (#72)

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 mentions: [{id, firstName, lastName}] on every comment response; frontend uses this list to turn @Name text into links.
  • contenteditable div replaces <textarea> in the comment editor.
  • Only AppUser is searchable for mentions — not Person records.
  • Notification scope: replies in threads you're part of + @mentions only.
  • Polling interval configurable via PUBLIC_NOTIFICATION_POLL_MS Vite env var (default 60 000 ms).

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 should expose only {id, firstName, lastName} — use a MentionDTO record and a @Transient List<MentionDTO> mentionDTOs field populated by the service, with @JsonIgnore on the mentions 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: findTop10ByFirstNameContainingIgnoreCaseOrLastNameContainingIgnoreCaseOrUsernameContainingIgnoreCase(q, q, q) — or a @Query with LIKE for the concat case.

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}').
  • "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

Branch name

feat/71-72-notifications-and-mentions

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