# 08 — Collaboration: Comments, Attachments, Links, Wiki, Markdown, Watchers ## Comments - [ ] Flat list per issue, chronological (no threading); markdown bodies - [ ] Edit: author only; sets `updated_at`; UI shows "(edited)" - [ ] Delete: soft; author or project owner; UI shows a "comment deleted" tombstone (author + timestamp, no body) - [ ] **Resolvable**: any member can resolve/unresolve (`resolved_at`, `resolved_by`); resolved comments render collapsed with a "resolved by X" header; issue detail shows resolved count - [ ] Emit `comment.created/updated/resolved/unresolved/deleted`; `comment.created` payload carries parsed mentions ## Markdown pipeline - [ ] **Client**: markdown-it + DOMPurify sanitization; single shared `MarkdownView` component; supported: CommonMark + tables, strikethrough, task lists, fenced code with highlight.js, autolink - [ ] **Server** (on save of any markdown field — issue/project/epic descriptions, comments, wiki bodies): - [ ] Parse `@username` mentions (word-boundary, against existing usernames) → into the event payload for notification fan-out; mentions inside code blocks/spans are ignored - [ ] Parse `#N` references (this project's issues) → validated list into event payload; used for cross-link rendering and future relation hints - [ ] Server does NOT render HTML (client renders); server only extracts entities - [ ] Client renders `@username` as a profile-ish chip and `#N` as a router-link to the issue (resolve via a lightweight `GET /projects/{id}/issues/{num}` title lookup, cached) - [ ] Paste/drag an image into `MarkdownEditor` → uploads as attachment → inserts `![name](/files/{path})` ## Attachments - [ ] Upload targets: project, issue, comment (multipart; ≤ `MAX_UPLOAD_MB`; any mime type, but SVG served with `Content-Type: text/plain` nosniff to avoid stored XSS) - [ ] Disk path: `UPLOAD_DIR/project_{pid}/{uploader_id}_{yyyymmddhhmmss}_{rand16}.{ext}` — path stored relative in DB; never trust client filenames for the path (original name kept separately for download) - [ ] Serving: `GET /files/{path}` checks membership of the owning project (lookup by attachments row, not by path prefix), sets `Content-Disposition` - [ ] Delete: soft; **file stays on disk until cull** ([12-retention.md](12-retention.md) deletes row + file together) - [ ] Image attachments get a thumbnail treatment client-side only (no server thumbnailing in v1) - [ ] Emit `attachment.uploaded/deleted` ## Links - [ ] Simple URL + optional title on project/issue/comment; validated absolute http(s) URL - [ ] Emit `link.added/removed` ## Wiki - [ ] Per-project markdown pages; slug generated from title (kebab-case, deduplicated with `-2` suffix); slug is stable after creation (title edits don't re-slug) - [ ] Page list + view + edit (single editor, last-write-wins with `updated_at` conflict warning: PATCH carries `expect_updated_at`, 409 on mismatch) - [ ] `updated_by` tracked; no page history in v1 ([17-decisions.md](17-decisions.md)) - [ ] Wiki bodies participate in mentions/#refs parsing and search indexing - [ ] Emit `wiki.created/updated/deleted` ## Watchers - [ ] Any member can watch/unwatch any issue in their projects; watcher list shown on issue detail - [ ] **Auto-watch** (insert if no row exists; never flip an existing `muted=true` row): - [ ] creator on issue create - [ ] assignee on assign - [ ] commenter on comment - [ ] mentioned user on mention (issue description or comment) - [ ] **Unwatch = mute**: `DELETE /watch` sets `muted = true` (row kept) so no future auto-watch resurrects the subscription; `PUT /watch` clears muted - [ ] Watchers feed notification fan-out ([09-notifications-realtime.md](09-notifications-realtime.md)); muted watchers receive nothing