chore: remove .agent planning docs from branch
These are LLM-generated planning documents for a different issue (import pipeline work), unrelated to the domain packaging refactor. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
274
.agent/PLAN.md
274
.agent/PLAN.md
@@ -1,274 +0,0 @@
|
||||
# Import Pipeline: ODS Alignment Plan
|
||||
|
||||
## Context
|
||||
|
||||
The real data source is an ODS spreadsheet (`zzfamilienarchiv Walter und Eugenie 2025-04-10.ods`) with 1,508 rows and 14 columns, living alongside PDF files (`W-0001.pdf`, `C-0451.pdf`, etc.) in `familienarchiv_raw/`. The existing import pipeline was built speculatively without seeing the actual data. It has several structural mismatches that need to be resolved before any real import can run.
|
||||
|
||||
`ExcelService` (the web-upload import path) will be **deleted entirely**. The only import path is `MassImportService`, which reads an ODS file from the `/import` directory on the filesystem. This simplifies the scope significantly.
|
||||
|
||||
---
|
||||
|
||||
## What the ODS Actually Contains
|
||||
|
||||
| Col | Header | Example value | Action |
|
||||
|-----|----------------------|------------------------------------------|-----------------|
|
||||
| 0 | Index | `W-0001` | → `originalFilename` (+ `.pdf`) |
|
||||
| 1 | Box | `V` | → `archiveBox` (new field) |
|
||||
| 2 | Mappe | `1` | → `archiveFolder` (new field) |
|
||||
| 3 | Von | `Walter de Gruyter` | → `sender` (Person) |
|
||||
| 4 | BriefeschreiberIn | `Walter de Gruyter` | Ignored (redundant with col 3) |
|
||||
| 5 | An | `Eugenie de Gruyter geb. Müller` | → `receivers` (Person, parse multi) |
|
||||
| 6 | EmpfängerIn | `Eugenie Müller` | Ignored (redundant with col 5) |
|
||||
| 7 | Datum | `1888-02-15` (ISO date string) | → `documentDate` |
|
||||
| 8 | Datum Originalformat | `15.2.1888` | Ignored |
|
||||
| 9 | Ort | `Rotterdam` | → `location` |
|
||||
| 10 | Schlagwort | `Brautbriefe` | → `tags` |
|
||||
| 11 | Inhalt | `Geschäftsreise` | → `summary` |
|
||||
| 12 | Zeitlicher Kontext | `Brautbriefe von Walter...` | Skipped (no clear mapping) |
|
||||
| 13 | Transkript | (mostly empty for now) | → `transcription` |
|
||||
|
||||
---
|
||||
|
||||
## Changes
|
||||
|
||||
### 1. Delete ExcelService
|
||||
|
||||
`ExcelService.java` is deleted. All references to it (in `AdminController` or wherever it is injected) are removed. Going forward, `MassImportService` is the sole import mechanism. The web-upload flow that previously called `ExcelService` is removed from the controller.
|
||||
|
||||
**Why:** The user confirmed the ODS-from-filesystem path is the only import workflow. Keeping dead code would create maintenance confusion.
|
||||
|
||||
---
|
||||
|
||||
### 2. File Format: ODS support via WorkbookFactory
|
||||
|
||||
**Current behaviour:** `MassImportService` constructs `new XSSFWorkbook(inputStream)`, which only handles `.xlsx`. The ODS file throws immediately.
|
||||
|
||||
**Fix:** Replace with `WorkbookFactory.create(fis)`. Apache POI 5.x's `WorkbookFactory` auto-detects the format and handles `.xlsx`, `.xls`, and `.ods` without any extra dependencies. Also update `findExcelFile()` which currently filters by `.endsWith(".xlsx")` — change the filter to accept `.ods`, `.xlsx`, and `.xls`.
|
||||
|
||||
**Why not add `odftoolkit`?** We already have `poi` and `poi-ooxml` at 5.5.0. `WorkbookFactory` covers this case. A second spreadsheet library would be redundant.
|
||||
|
||||
---
|
||||
|
||||
### 3. Column Index Defaults
|
||||
|
||||
**Current defaults (wrong):**
|
||||
```
|
||||
app.import.excel.col.filename=0 date=1 location=2 transcription=3
|
||||
```
|
||||
|
||||
**Correct indices:**
|
||||
```
|
||||
filename=0 box=1 folder=2 sender=3 receivers=5 date=7 location=9 tags=10 summary=11 transcription=13
|
||||
```
|
||||
|
||||
**Fix:** Update `@Value` defaults in `MassImportService` and set explicit values in `application.properties`. Remove the old defaults from `ExcelService` (which is deleted). Rename the property prefix from `app.import.excel.col.*` to `app.import.col.*` since the format is no longer Excel-specific.
|
||||
|
||||
---
|
||||
|
||||
### 4. Filename Resolution: Index → PDF
|
||||
|
||||
**Current behaviour:** Cell value used directly as `originalFilename`.
|
||||
|
||||
**Actual situation:** Col 0 is the bare index (e.g., `W-0001`). PDF files are named `W-0001.pdf`. The import must append `.pdf`.
|
||||
|
||||
**Fix:** After reading col 0, append `.pdf` if the value contains no `.`:
|
||||
```java
|
||||
if (!filename.contains(".")) filename = filename + ".pdf";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. Document Title: German Date Format
|
||||
|
||||
**Current behaviour:** Title is set to the raw filename, e.g. `W-0001.pdf`.
|
||||
|
||||
**Fix:** Build title from `{Index} – {date in German format} – {location}`. Use `DateTimeFormatter` with locale `de`:
|
||||
```
|
||||
W-0001 – 15. Februar 1888 – Rotterdam
|
||||
```
|
||||
If date is missing, omit date segment. If location is missing, omit location segment. The index alone is acceptable as a minimum title.
|
||||
|
||||
**German month formatting:** Use `DateTimeFormatter.ofPattern("d. MMMM yyyy", Locale.GERMAN)`.
|
||||
|
||||
---
|
||||
|
||||
### 6. Date Parsing: Add String Fallback
|
||||
|
||||
**Current behaviour:** Only handles numeric date-formatted cells (`DateUtil.isCellDateFormatted()`).
|
||||
|
||||
**Actual data:** Col 7 contains ISO date strings (`1888-02-15`) stored as text in LibreOffice ODS. These have `CellType.STRING`, so the existing code silently produces `null` dates for every row.
|
||||
|
||||
**Fix:** Extract a helper method `parseDate(Cell)`:
|
||||
```java
|
||||
private LocalDate parseDate(Cell cell) {
|
||||
if (cell == null) return null;
|
||||
if (cell.getCellType() == CellType.NUMERIC && DateUtil.isCellDateFormatted(cell))
|
||||
return cell.getDateCellValue().toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
|
||||
if (cell.getCellType() == CellType.STRING) {
|
||||
try { return LocalDate.parse(cell.getStringCellValue().trim()); }
|
||||
catch (DateTimeParseException e) { return null; }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. Sender: Text → Person (lookup-or-create)
|
||||
|
||||
**Current behaviour:** Sender is never set.
|
||||
|
||||
**Actual data:** Col 3 (`Von`) is always a single name string, e.g. `Walter de Gruyter`, `Eugenie de Gruyter geb. Müller`.
|
||||
|
||||
**Fix:** Extract a `findOrCreatePerson(String rawName)` helper:
|
||||
1. Look up by `alias` exact match (case-insensitive). Use a new repository method `findByAliasIgnoreCase(String)` on `PersonRepository`.
|
||||
2. If not found, create with:
|
||||
- `alias` = full raw string
|
||||
- `firstName` / `lastName` = best-effort split (see §9 below)
|
||||
3. Return the `Person` and set on `document.setSender(...)`.
|
||||
|
||||
---
|
||||
|
||||
### 8. Receivers: Text → Person(s) with Normalization
|
||||
|
||||
**Current behaviour:** Receivers are never set.
|
||||
|
||||
**Actual data (exhaustive set of multi-receiver patterns):**
|
||||
```
|
||||
'Clara Cram u Ellen B-M'
|
||||
'Clara u Familie'
|
||||
'Clara u Herbert Cram'
|
||||
'Ella u Walter Dieckmann'
|
||||
'Eugenie u Walter de Gruyter'
|
||||
'Hedi und Tutu (Gruber)'
|
||||
'Herbert und Clara Cram'
|
||||
'Walter und Eugenie'
|
||||
'Walter und Eugenie de Gruyter'
|
||||
```
|
||||
|
||||
**Parsing algorithm for col 5 (`An`):**
|
||||
|
||||
1. **Strip `geb.` clauses** — remove ` geb. \w+` from the string (maiden name annotations are not useful for matching).
|
||||
2. **Extract parenthesised last name** — if the string ends with `(Something)`, capture `Something` as the shared last name and strip it.
|
||||
3. **Split on separator** — split on ` und ` or ` u ` (whole-word match with `\s+u\s+` or `\s+und\s+`).
|
||||
4. **Filter** — discard any segment that is exactly `Familie` (it's not a person).
|
||||
5. **Distribute shared last name** — find the last name in the rightmost segment. Known multi-word last name particles: `de Gruyter`. Known single-word last names: `Cram`, `Dieckmann`, `Gruber`, `Müller`, `Wolff`. These are hardcoded as a lookup list. If the last segment ends with a known last name and an earlier segment has no last name (i.e., it is a single token), append that last name to the earlier segment.
|
||||
6. **Handle no-last-name cases** — if no last name can be determined at all (e.g., `Walter und Eugenie`), proceed with just the first name; `lastName` will be set to `""` (empty string — tolerated since the model has `nullable = false` and we need something; using `"?"` as placeholder is clearer).
|
||||
7. **findOrCreatePerson** for each resulting name segment, then add all to `document.getReceivers()`.
|
||||
|
||||
**Examples:**
|
||||
| Raw | Result |
|
||||
|-----|--------|
|
||||
| `Walter und Eugenie de Gruyter` | [Walter de Gruyter, Eugenie de Gruyter] |
|
||||
| `Herbert und Clara Cram` | [Herbert Cram, Clara Cram] |
|
||||
| `Hedi und Tutu (Gruber)` | [Hedi Gruber, Tutu Gruber] |
|
||||
| `Clara Cram u Ellen B-M` | [Clara Cram, Ellen B-M] |
|
||||
| `Clara u Familie` | [Clara] |
|
||||
| `Walter und Eugenie` | [Walter (?), Eugenie (?)] |
|
||||
| `Eugenie de Gruyter geb. Müller` | [Eugenie de Gruyter] |
|
||||
|
||||
**Why normalise?** Without normalisation, `Herbert und Clara Cram` would become one person with a nonsensical name and would never match separate `Herbert Cram` or `Clara Cram` entries from other rows. Normalisation means subsequent rows referencing the same individual will reuse the same `Person` record.
|
||||
|
||||
**Why hardcode the last names?** There are only 6 known family names in this archive. Adding a configurable list would be over-engineering for a one-family archive. If the archive expands, the list can be extended.
|
||||
|
||||
---
|
||||
|
||||
### 9. Name Splitting Helper (firstName / lastName)
|
||||
|
||||
Used when creating a new `Person` who cannot be found by alias.
|
||||
|
||||
**Algorithm:**
|
||||
1. Strip any ` geb. \w+` suffix.
|
||||
2. Check if the string ends with a known last name (from the list in §8). If yes, everything before it is `firstName`, and that is `lastName`.
|
||||
3. If `de Gruyter` is detected as the last name, it is multi-word — `firstName` is everything before `de Gruyter`.
|
||||
4. Otherwise, split on the last space: `firstName` = everything before, `lastName` = last word.
|
||||
5. If only one token (no space), `firstName` = token, `lastName` = `"?"`.
|
||||
|
||||
This logic lives in a single static utility method `PersonNameParser.split(String)` returning a record `SplitName(String firstName, String lastName)`. Keeping it static and pure makes it straightforward to unit-test without a Spring context.
|
||||
|
||||
---
|
||||
|
||||
### 10. Tags: Lookup-or-Create
|
||||
|
||||
**Current behaviour:** Tags are never imported.
|
||||
|
||||
**Fix:** Read col 10 (`Schlagwort`). If non-blank:
|
||||
```java
|
||||
Tag tag = tagRepository.findByNameIgnoreCase(value)
|
||||
.orElseGet(() -> tagRepository.save(Tag.builder().name(value).build()));
|
||||
document.getTags().add(tag);
|
||||
```
|
||||
|
||||
Tags are imported as-is. The `TagRepository` already has `findByNameIgnoreCase`, so deduplication is free.
|
||||
|
||||
---
|
||||
|
||||
### 11. Summary: Map "Inhalt" (Col 11)
|
||||
|
||||
Read col 11 (`Inhalt`) and set on `document.setSummary(...)`. Short content keywords (`Geschäftsreise`, `Reisepläne`) are useful for full-text search even if they're terse.
|
||||
|
||||
Col 12 (`Zeitlicher Kontext`) is skipped — it is often a duplicate of context already encoded in sender/receiver/tags.
|
||||
|
||||
---
|
||||
|
||||
### 12. New Model Fields: archiveBox and archiveFolder
|
||||
|
||||
Cols 1 and 2 (`Box`, `Mappe`) identify the physical storage location of the original document. They have no counterpart in the model today.
|
||||
|
||||
**Changes:**
|
||||
1. Add to `Document.java`:
|
||||
```java
|
||||
@Column(name = "archive_box")
|
||||
private String archiveBox;
|
||||
|
||||
@Column(name = "archive_folder")
|
||||
private String archiveFolder;
|
||||
```
|
||||
2. Flyway migration `V4__add_archive_fields_to_documents.sql`:
|
||||
```sql
|
||||
ALTER TABLE documents ADD COLUMN archive_box VARCHAR(255);
|
||||
ALTER TABLE documents ADD COLUMN archive_folder VARCHAR(255);
|
||||
```
|
||||
3. Import logic reads col 1 → `archiveBox`, col 2 → `archiveFolder`.
|
||||
|
||||
---
|
||||
|
||||
### 13. PersonRepository: Add findByAliasIgnoreCase
|
||||
|
||||
Add one method to `PersonRepository`:
|
||||
```java
|
||||
Optional<Person> findByAliasIgnoreCase(String alias);
|
||||
```
|
||||
Spring Data generates the query automatically. No other repository changes are needed.
|
||||
|
||||
---
|
||||
|
||||
## Overwrite Behaviour (No Change)
|
||||
|
||||
The existing skip logic stays: if a document already exists in the DB and its status is not `PLACEHOLDER`, it is skipped. This prevents accidental data loss on re-runs. The assumption is that if someone has manually enriched a document beyond placeholder stage, that work should not be overwritten by a re-import.
|
||||
|
||||
---
|
||||
|
||||
## Summary of All File Changes
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `ExcelService.java` | **Deleted** |
|
||||
| `AdminController.java` (or wherever ExcelService is injected) | Remove ExcelService injection and its endpoint |
|
||||
| `MassImportService.java` | `WorkbookFactory`, new column indices, `.ods` discovery, filename fix, title, date parsing, sender, receivers, tags, summary, archiveBox/archiveFolder |
|
||||
| `PersonNameParser.java` (new) | Static utility: `split(String)` → `SplitName`, `parseReceivers(String)` → `List<String>` |
|
||||
| `PersonRepository.java` | Add `findByAliasIgnoreCase(String)` |
|
||||
| `Document.java` | Add `archiveBox`, `archiveFolder` fields |
|
||||
| `V4__add_archive_fields_to_documents.sql` (new) | `ALTER TABLE` for both new columns |
|
||||
| `application.properties` | Update/add `app.import.col.*` properties |
|
||||
|
||||
---
|
||||
|
||||
## What We Are Not Changing
|
||||
|
||||
- **Col 4 (`BriefeschreiberIn`)** — redundant with col 3.
|
||||
- **Col 6 (`EmpfängerIn`)** — redundant with col 5.
|
||||
- **Col 8 (`Datum Originalformat`)** — ISO date in col 7 is strictly better.
|
||||
- **Col 12 (`Zeitlicher Kontext`)** — no clear mapping, often duplicates other fields.
|
||||
- **`persons` table schema** — `alias` serves as the full-name store without a schema change.
|
||||
- **`TagRepository`** — existing `findByNameIgnoreCase` is sufficient.
|
||||
@@ -1,305 +0,0 @@
|
||||
# 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`
|
||||
Reference in New Issue
Block a user