As a user I want to comment on documents and reply to others so we can discuss and annotate our findings #50

Closed
opened 2026-03-23 08:52:03 +01:00 by marcel · 0 comments
Owner

Background

Family members need a way to discuss documents — to ask questions, share insights, confirm transcriptions, or note historical context. Comments can be left on a document in general, or attached to a specific PDF annotation (from #40). Replies are shown nested one level deep so threads stay readable.

Depends on #35 (profile page) for meaningful author_name values.
Works alongside #40 (PDF annotations) — comments can optionally be linked to an annotation.

Desired behaviour

Top-level comments can be attached to:

  1. A document — visible in a „Diskussion" section below the document detail
  2. A PDF annotation — shown in the comment panel that opens when the highlight is clicked

Replies — UI vs. storage:

  • A „Antworten" (Reply) button appears only on the last comment in a thread (whether that is the top-level comment itself or the most recent reply) — so users never have to scroll back to find it, and the UI stays uncluttered
  • Clicking Reply on any last comment always adds a new reply to the top-level parent of that thread
  • All replies in a thread are siblings in the database, ordered chronologically by created_at
  • This keeps the data model flat (one level deep) while the UI remains ergonomic

Editing:

  • The author of a comment can edit its text at any time to fix typos or clarify wording
  • Clicking „Bearbeiten" replaces the comment text with an inline text area, pre-filled with the current content; saving sends a PATCH request
  • The sort order of comments and replies is never affected by an edit — ordering is always by created_at
  • Once a comment has been edited (updated_at > created_at), a small „bearbeitet" label with the edit timestamp appears next to the comment's original timestamp — on hover the full absolute date/time is shown

Deleting:

  • The author of a comment can delete their own comment
  • A user with the ADMIN permission can delete any comment
  • Deleting a top-level comment also deletes all its replies

Author display:

  • author_name is captured at write time (same pattern as conversations in #38)
  • If a user later changes their name, old comments keep the name they were posted under

Permission model

Action Required permission
Read comments READ_ALL, ANNOTATE_ALL, or WRITE_ALL
Post / reply / edit own comment ANNOTATE_ALL or WRITE_ALL
Delete own comment any authenticated user
Delete any comment ADMIN

Data model

CREATE TABLE document_comments (
    id              UUID         PRIMARY KEY DEFAULT gen_random_uuid(),
    document_id     UUID         NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
    -- null = general document comment; set = linked to a specific annotation
    annotation_id   UUID         REFERENCES document_annotations(id) ON DELETE CASCADE,
    -- null = top-level comment; set = reply (always points to a top-level row)
    parent_id       UUID         REFERENCES document_comments(id) ON DELETE CASCADE,
    author_id       UUID         REFERENCES users(id) ON DELETE SET NULL,
    author_name     VARCHAR(200) NOT NULL,
    content         TEXT         NOT NULL,
    created_at      TIMESTAMP    NOT NULL DEFAULT now(),
    updated_at      TIMESTAMP    NOT NULL DEFAULT now()
);

CREATE INDEX idx_dc_document   ON document_comments(document_id);
CREATE INDEX idx_dc_annotation ON document_comments(annotation_id);
CREATE INDEX idx_dc_parent     ON document_comments(parent_id);

updated_at is set to now() on every edit. The frontend compares updated_at to created_at to decide whether to show the „bearbeitet" label — no separate flag needed.

Invariant: parent_id always points to a top-level comment (parent_id IS NULL on the target row). This is resolved by the backend: when a reply is submitted with any commentId, the backend walks up to the root and stores it under that root.

A comment has annotation_id set when it belongs to a PDF annotation thread; annotation_id = NULL means it is a general document comment. Replies inherit annotation_id from their parent (set by the backend).

REST endpoints

Method Path Purpose
GET /api/documents/{id}/comments All general comments with their replies
POST /api/documents/{id}/comments Post a top-level general comment
POST /api/documents/{id}/comments/{commentId}/replies Reply to any comment in a thread
PATCH /api/documents/{id}/comments/{commentId} Edit own comment (updates content and updated_at)
DELETE /api/documents/{id}/comments/{commentId} Delete comment (author or ADMIN)
GET /api/documents/{id}/annotations/{annId}/comments Comments for a specific annotation
POST /api/documents/{id}/annotations/{annId}/comments Post a top-level comment on an annotation
POST /api/documents/{id}/annotations/{annId}/comments/{commentId}/replies Reply to any comment in an annotation thread

The GET endpoints return comments grouped with their replies, ordered by created_at:

[
  {
    "id": "...",
    "authorName": "Hans Müller",
    "content": "Is this word 'Berlin' or 'Bertin'?",
    "createdAt": "2026-03-20T10:00:00Z",
    "updatedAt": "2026-03-20T10:00:00Z",
    "replies": [
      {
        "id": "...",
        "authorName": "Anna Schmidt",
        "content": "Pretty sure it's Berlin.",
        "createdAt": "2026-03-20T10:05:00Z",
        "updatedAt": "2026-03-20T10:12:00Z"
      }
    ]
  }
]

Frontend

General document comments

Appear in a „Diskussion" card at the bottom of the document detail page (/documents/{id}).

Annotation comments — UI design decisions

Comment count pill:
Each annotation overlay shows a small pill badge below the highlight rect (top-right corner is occupied by the delete button). The pill shows the comment count and is only rendered when count > 0. Since every annotation is created with a mandatory first comment (no empty annotation threads exist), the pill is always visible on persisted annotations.

Comment panel — responsive behaviour:

Viewport Behaviour
Desktop / tablet (≥ 640px) Floating panel (does not push the PDF — overlays it), anchored near the clicked annotation
Mobile (< 640px) Full-screen modal, slides up from the bottom

The floating panel on desktop is positioned to the right of the annotation when space allows, otherwise to the left. It does not resize or reflow the PDF viewer.

Closing: explicit ✕ button only (clicking outside does not close the panel).

Panel header: „Kommentare" — the annotated PDF text is not available as a string, so no quote is shown.

No empty state: Annotations always carry at least one comment. The annotation creation flow (see #40) requires the user to enter a first comment before saving the annotation.

Shared comment thread UI (both contexts)

  • Top-level comments shown chronologically by created_at
  • Replies shown indented beneath their parent, also chronologically by created_at
  • A „Antworten" link appears only on the last comment in the thread — clicking it expands an inline reply text area
  • Each comment shows: author name · relative posted timestamp („vor 2 Stunden") · optionally „· bearbeitet vor X Minuten" when updatedAt > createdAt (absolute datetime on hover)
  • „Bearbeiten" / „Löschen" actions visible to the author and users with ADMIN
  • Clicking „Bearbeiten" replaces the comment text with a pre-filled inline text area; saving sends PATCH and updates the displayed content and edit timestamp in place

Testing

  • CommentServiceTest — replying via a reply ID resolves to the correct root parent; author can edit own; non-author edit is rejected (403); ADMIN can delete any comment; non-admin non-author delete is rejected (403); updated_at is updated on edit; created_at is unchanged on edit; deleting parent cascades to replies; author_name captured at post time
  • @WebMvcTest — post returns 201; edit returns 200; unauthenticated gets 401; unauthorised gets 403
  • E2E — post a comment, edit it, verify „bearbeitet" label appears; reply to it, verify Reply button moves to the new reply; verify sort order is unchanged after an edit

User journey

General comment: User views a document and scrolls to „Diskussion". They post a comment, then notice a typo and click „Bearbeiten". An inline text area appears with their original text. They fix the typo and save. The comment now shows „vor 5 Minuten · bearbeitet vor 1 Minute". The sort order in the thread is unchanged.

Annotation comment: User clicks a yellow highlight on page 2. A floating panel opens overlaying the PDF. The panel header reads „Kommentare". The annotation pill already shows „1" from the first comment written at creation time. Others reply using the Reply button on the last comment in the thread.

Dependencies

  • #35 (profile page) — author_name populated from user profile
  • #40 (PDF annotations) — annotation overlay shows comment count pill; annotation creation flow requires a first comment; side panel opens on annotation click
## Background Family members need a way to discuss documents — to ask questions, share insights, confirm transcriptions, or note historical context. Comments can be left on a document in general, or attached to a specific PDF annotation (from #40). Replies are shown nested one level deep so threads stay readable. **Depends on #35** (profile page) for meaningful `author_name` values. **Works alongside #40** (PDF annotations) — comments can optionally be linked to an annotation. ## Desired behaviour **Top-level comments** can be attached to: 1. A **document** — visible in a „Diskussion" section below the document detail 2. A **PDF annotation** — shown in the comment panel that opens when the highlight is clicked **Replies — UI vs. storage:** - A „Antworten" (Reply) button appears only on the **last comment in a thread** (whether that is the top-level comment itself or the most recent reply) — so users never have to scroll back to find it, and the UI stays uncluttered - Clicking Reply on any last comment always adds a new reply to the **top-level parent** of that thread - All replies in a thread are siblings in the database, ordered chronologically by `created_at` - This keeps the data model flat (one level deep) while the UI remains ergonomic **Editing:** - The author of a comment can edit its text at any time to fix typos or clarify wording - Clicking „Bearbeiten" replaces the comment text with an inline text area, pre-filled with the current content; saving sends a `PATCH` request - The sort order of comments and replies is never affected by an edit — ordering is always by `created_at` - Once a comment has been edited (`updated_at > created_at`), a small **„bearbeitet"** label with the edit timestamp appears next to the comment's original timestamp — on hover the full absolute date/time is shown **Deleting:** - The author of a comment can delete their own comment - A user with the `ADMIN` permission can delete any comment - Deleting a top-level comment also deletes all its replies **Author display:** - `author_name` is captured at write time (same pattern as conversations in #38) - If a user later changes their name, old comments keep the name they were posted under ## Permission model | Action | Required permission | |--------|-------------------| | Read comments | `READ_ALL`, `ANNOTATE_ALL`, or `WRITE_ALL` | | Post / reply / edit own comment | `ANNOTATE_ALL` or `WRITE_ALL` | | Delete own comment | any authenticated user | | Delete any comment | `ADMIN` | ## Data model ```sql CREATE TABLE document_comments ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE, -- null = general document comment; set = linked to a specific annotation annotation_id UUID REFERENCES document_annotations(id) ON DELETE CASCADE, -- null = top-level comment; set = reply (always points to a top-level row) parent_id UUID REFERENCES document_comments(id) ON DELETE CASCADE, author_id UUID REFERENCES users(id) ON DELETE SET NULL, author_name VARCHAR(200) NOT NULL, content TEXT NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT now(), updated_at TIMESTAMP NOT NULL DEFAULT now() ); CREATE INDEX idx_dc_document ON document_comments(document_id); CREATE INDEX idx_dc_annotation ON document_comments(annotation_id); CREATE INDEX idx_dc_parent ON document_comments(parent_id); ``` `updated_at` is set to `now()` on every edit. The frontend compares `updated_at` to `created_at` to decide whether to show the „bearbeitet" label — no separate flag needed. **Invariant:** `parent_id` always points to a top-level comment (`parent_id IS NULL` on the target row). This is resolved by the backend: when a reply is submitted with any `commentId`, the backend walks up to the root and stores it under that root. A comment has `annotation_id` set when it belongs to a PDF annotation thread; `annotation_id = NULL` means it is a general document comment. Replies inherit `annotation_id` from their parent (set by the backend). ## REST endpoints | Method | Path | Purpose | |--------|------|---------| | `GET` | `/api/documents/{id}/comments` | All general comments with their replies | | `POST` | `/api/documents/{id}/comments` | Post a top-level general comment | | `POST` | `/api/documents/{id}/comments/{commentId}/replies` | Reply to any comment in a thread | | `PATCH` | `/api/documents/{id}/comments/{commentId}` | Edit own comment (updates `content` and `updated_at`) | | `DELETE` | `/api/documents/{id}/comments/{commentId}` | Delete comment (author or `ADMIN`) | | `GET` | `/api/documents/{id}/annotations/{annId}/comments` | Comments for a specific annotation | | `POST` | `/api/documents/{id}/annotations/{annId}/comments` | Post a top-level comment on an annotation | | `POST` | `/api/documents/{id}/annotations/{annId}/comments/{commentId}/replies` | Reply to any comment in an annotation thread | The `GET` endpoints return comments grouped with their replies, ordered by `created_at`: ```json [ { "id": "...", "authorName": "Hans Müller", "content": "Is this word 'Berlin' or 'Bertin'?", "createdAt": "2026-03-20T10:00:00Z", "updatedAt": "2026-03-20T10:00:00Z", "replies": [ { "id": "...", "authorName": "Anna Schmidt", "content": "Pretty sure it's Berlin.", "createdAt": "2026-03-20T10:05:00Z", "updatedAt": "2026-03-20T10:12:00Z" } ] } ] ``` ## Frontend ### General document comments Appear in a „Diskussion" card at the bottom of the document detail page (`/documents/{id}`). ### Annotation comments — UI design decisions **Comment count pill:** Each annotation overlay shows a small pill badge **below** the highlight rect (top-right corner is occupied by the delete button). The pill shows the comment count and is only rendered when `count > 0`. Since every annotation is created with a mandatory first comment (no empty annotation threads exist), the pill is always visible on persisted annotations. **Comment panel — responsive behaviour:** | Viewport | Behaviour | |---|---| | Desktop / tablet (`≥ 640px`) | Floating panel (does **not** push the PDF — overlays it), anchored near the clicked annotation | | Mobile (`< 640px`) | Full-screen modal, slides up from the bottom | The floating panel on desktop is positioned to the right of the annotation when space allows, otherwise to the left. It does not resize or reflow the PDF viewer. **Closing:** explicit ✕ button only (clicking outside does **not** close the panel). **Panel header:** „Kommentare" — the annotated PDF text is not available as a string, so no quote is shown. **No empty state:** Annotations always carry at least one comment. The annotation creation flow (see #40) requires the user to enter a first comment before saving the annotation. ### Shared comment thread UI (both contexts) - Top-level comments shown chronologically by `created_at` - Replies shown indented beneath their parent, also chronologically by `created_at` - A „Antworten" link appears only on the **last comment in the thread** — clicking it expands an inline reply text area - Each comment shows: author name · relative posted timestamp („vor 2 Stunden") · optionally **„· bearbeitet vor X Minuten"** when `updatedAt > createdAt` (absolute datetime on hover) - „Bearbeiten" / „Löschen" actions visible to the author and users with `ADMIN` - Clicking „Bearbeiten" replaces the comment text with a pre-filled inline text area; saving sends `PATCH` and updates the displayed content and edit timestamp in place ## Testing - `CommentServiceTest` — replying via a reply ID resolves to the correct root parent; author can edit own; non-author edit is rejected (403); `ADMIN` can delete any comment; non-admin non-author delete is rejected (403); `updated_at` is updated on edit; `created_at` is unchanged on edit; deleting parent cascades to replies; `author_name` captured at post time - `@WebMvcTest` — post returns 201; edit returns 200; unauthenticated gets 401; unauthorised gets 403 - E2E — post a comment, edit it, verify „bearbeitet" label appears; reply to it, verify Reply button moves to the new reply; verify sort order is unchanged after an edit ## User journey **General comment:** User views a document and scrolls to „Diskussion". They post a comment, then notice a typo and click „Bearbeiten". An inline text area appears with their original text. They fix the typo and save. The comment now shows „vor 5 Minuten · bearbeitet vor 1 Minute". The sort order in the thread is unchanged. **Annotation comment:** User clicks a yellow highlight on page 2. A floating panel opens overlaying the PDF. The panel header reads „Kommentare". The annotation pill already shows „1" from the first comment written at creation time. Others reply using the Reply button on the last comment in the thread. ## Dependencies - **#35** (profile page) — `author_name` populated from user profile - **#40** (PDF annotations) — annotation overlay shows comment count pill; annotation creation flow requires a first comment; side panel opens on annotation click
marcel added the collaborationfeature labels 2026-03-23 08:52:12 +01:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#50