5.1.2 `commentsService` — add/edit/delete/list with 6.4-role permissions, server-side mention parsing + `comment_mention` rows, paged reads, job events
Estimate: 35m · Depends on: 5.1.1
The business-logic core. Per the 4-layer rule: lib/services/commentsService.ts owning validation, transactions, DTO mapping, and typed errors (lib/comments/errors.ts); HTTP-only routes; the repos from 5.1.1.
addComment(workItemId, { bodyMd, parentCommentId? }, ctx) — validates the caller can view the issue AND holds a commenting role (project admin/member via the 6.4 access logic; the read-only viewer gets a typed CommentForbiddenError — the Jira "Add comments" permission mapped onto the shipped role model). A parentCommentId must point at a ROOT comment on the SAME issue (a reply-to-a-reply is attached to the root by the UI, 5.1.5; the service rejects depth >1 with a typed error — single-level threading). In ONE transaction: create the comment, parse mentions (below), write comment_mention rows. After commit: emit the work-item/comment.created job event (workspaceId, workItemId, commentId, authorId, mentionedUserIds) via the 1.6 sendEvent — events NEVER fire inside the tx (a rollback must not have notified anyone).
Mention parsing — the service is the authority. A shared helper (lib/mentions/parse.ts) extracts [@Name](mention:<userId>) tokens from Markdown. For each id: workspace member AND can view this issue (private projects → project members; open/limited → workspace members — the 6.4 assignableMembersService scoping, reused not duplicated); failures are silently DROPPED from the mention set (the Jira rule — no view permission, no notification), never an error. Dedup repeated tokens. On EDIT, re-parse and diff: newly-added mentions get comment_mention rows + a follow-up event carrying ONLY the new ids (no re-notify on unchanged mentions).
editComment / deleteComment — the Jira permission split. Edit own = author; edit all / delete all = project admin or workspace admin/owner; delete own = author. Edit sets editedAt (the "Edited" tag). Delete is a hard delete — the row (and, for a root, its replies + mention rows, via the 5.1.1 cascade) is gone; in the same tx write a work_item_revision entry recording that a comment by X was deleted by Y (count of replies included) — the History trace Jira keeps and Story 5.5 renders. The deliberate root-delete-cascades decision (mirror behaviour unverifiable) is recorded here: confirm copy in 5.1.5 names the reply count.
listComments(workItemId, { cursor?, order? }, ctx) — view-gated, cursor-paged from the most recent (take 20) with totalCount, returning roots WITH their replies (a thread loads whole — replies are bounded by single-level threading) ordered per order (default oldest-first, the Jira default). NEVER a load-all (finding #57). DTOs carry author (id/name/image), body, editedAt, reply nesting, and mention metadata.
Routes (HTTP-only): GET/POST /api/work-items/[id]/comments, PATCH/DELETE /api/comments/[id] — parse → one service call → typed-error→status mapping (403 forbidden / 404 cross-workspace-invisible per finding #44 — never leak existence).
Acceptance criteria
commentsServiceships add/edit/delete/list with the permission matrix: viewer cannot add; author edits/deletes own; project admin + workspace admin/owner edit/delete all; all writes view-gated; cross-workspace access reads as 404 (finding #44).- Threading: replies attach to roots only (depth >1 → typed error); a root delete cascades its thread and writes the revision-trail deletion record in the same tx.
- Mention parsing:
[@Name](mention:<id>)tokens → deduped, view-validatedcomment_mentionrows in the same tx; non-viewable/non-member ids dropped silently; edit re-parse notifies only NEW mentions;work-item/comment.created(typed inJobEventDataMap) emits AFTER commit, never on rollback. listCommentsis cursor-paged (take 20) +totalCount, threads load whole, order param flips oldest/newest-first; no unbounded read exists on any path.- Edit sets
editedAt; the DTO exposes it; routes are HTTP-only;pnpm test:coveragekeeps the new files ≥90% branch/fn/line with direct empty-input guards (the coverage gate).
Context refs
- 5.1.1 repos + models;
lib/services/workItemsService.ts— service conventions, the finding-#26 workspace gate, the finding-#44 404-not-403 rule lib/services/assignableMembersService.ts(6.4) — the EXACT view-scoping the mention validation + candidate read reuselib/services/workItemRevisionsService.ts(1.4.6) — the revision-trail write for the deletion recordlib/jobs/sendEvent.ts+lib/jobs/types.ts(1.6) — typed event emission (extendJobEventDataMap);motir-core/CLAUDE.md— one service method = one transaction- Story 5.1 description — the Jira-verified permission set, threading, hard-delete + History-trace semantics