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

306 lines
14 KiB
Markdown

# 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.
- `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 `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`
```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 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.
```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: `findTop10ByFirstNameContainingIgnoreCaseOrLastNameContainingIgnoreCaseOrUsernameContainingIgnoreCase(q, q, q)` — or a `@Query` with LIKE for the concat case.
### 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}')`.
- "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`