Module: comments¶
Purpose¶
Manages comments on issues, @mention parsing, and the activity feed. Owns the Comment and IssueActivity entities and provides the unified timeline of changes visible on the issue detail view.
Entities Owned¶
| Entity | Table | Key Fields |
|---|---|---|
Comment |
comments |
issueId UUID FK→issues NOT NULL, authorId UUID FK→users nullable (null = system comment), body TEXT NOT NULL, editedAt Instant?, deletedAt Instant? (soft-delete) |
IssueActivity |
issue_activities |
issueId UUID FK→issues NOT NULL, actorId UUID FK→users NOT NULL, type: ActivityType NOT NULL, commentId UUID? FK→comments (set to null on comment delete), oldValue TEXT?, newValue TEXT? |
ActivityType enum values: COMMENT, STATUS_CHANGED, ASSIGNED, UNASSIGNED, PRIORITY_CHANGED, TITLE_CHANGED, DESCRIPTION_CHANGED, STORY_POINTS_CHANGED, DUE_DATE_CHANGED, SPRINT_CHANGED, ATTACHMENT_ADDED, ATTACHMENT_REMOVED.
DB Schema¶
comments (V7)¶
CREATE TABLE comments (
id UUID NOT NULL PRIMARY KEY,
body TEXT NOT NULL,
issue_id UUID NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
author_id UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
edited_at TIMESTAMP,
deleted_at TIMESTAMP
);
deleted_at is set on soft-delete; listComments filters out rows where deleted_at IS NOT NULL. edited_at is set on every editComment call.
issue_activities (V7)¶
CREATE TABLE issue_activities (
id UUID NOT NULL PRIMARY KEY,
issue_id UUID NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
actor_id UUID NOT NULL REFERENCES users(id),
type VARCHAR(40) NOT NULL,
comment_id UUID REFERENCES comments(id) ON DELETE SET NULL,
old_value TEXT,
new_value TEXT,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
Index: idx_issue_activity_issue on (issue_id, created_at DESC).
API Endpoints¶
| Method | Path | Auth | Description |
|---|---|---|---|
POST |
/api/v1/projects/{key}/issues/{issueKey}/comments |
USER | Create a comment; returns 201 with CommentResponse |
GET |
/api/v1/projects/{key}/issues/{issueKey}/comments |
USER | List non-deleted comments for issue |
PUT |
/api/v1/projects/{key}/issues/{issueKey}/comments/{commentId} |
USER | Edit comment body; restricted to author only |
DELETE |
/api/v1/projects/{key}/issues/{issueKey}/comments/{commentId} |
USER | Soft-delete; allowed for author or project admin; returns 204 |
GET |
/api/v1/projects/{key}/issues/{issueKey}/activity |
USER | Paged activity feed; query params page (default 0), size (default 50); sorted by createdAt DESC |
Events Emitted¶
| Event | Published by | Payload |
|---|---|---|
CommentCreatedEvent |
CommentService.addComment() |
comment, issue, actorEmail, actorId |
MentionEvent |
CommentService — not yet wired in production; extractAndPublishMentions() must be called from addComment() |
mentionedUser: User, comment, issue |
MentionEvent is defined and consumed by NotificationService.onMention() and EmailService.onMention(), but CommentService.addComment() does not yet call extractAndPublishMentions(). Wire the call in addComment() to enable mention notifications.
Events Consumed¶
ActivityService subscribes to events from other modules to populate issue_activities:
| Event | Handler | Activity type written |
|---|---|---|
CommentCreatedEvent |
ActivityService.onCommentCreated() |
COMMENT (skipped for system comments where authorId is null) |
IssueFieldChangedEvent |
ActivityService.onIssueFieldChanged() |
Mapped per field value (title, description, priority, storyPoints, dueDate, sprint, assignee) |
IssueStatusChangedEvent |
ActivityService.onIssueStatusChanged() |
STATUS_CHANGED (skipped if actor is null) |
AttachmentAddedEvent |
ActivityService.onAttachmentAdded() |
ATTACHMENT_ADDED with newValue = filename |
Key Files¶
backend/src/main/kotlin/com/taskowolf/comments/domain/Comment.ktbackend/src/main/kotlin/com/taskowolf/comments/domain/IssueActivity.ktbackend/src/main/kotlin/com/taskowolf/comments/domain/ActivityType.ktbackend/src/main/kotlin/com/taskowolf/comments/domain/events/CommentCreatedEvent.ktbackend/src/main/kotlin/com/taskowolf/comments/domain/events/MentionEvent.ktbackend/src/main/kotlin/com/taskowolf/comments/application/CommentService.ktbackend/src/main/kotlin/com/taskowolf/comments/application/ActivityService.ktbackend/src/main/kotlin/com/taskowolf/comments/api/CommentController.ktbackend/src/main/resources/db/migration/V7__create_comments.sql
Extension Points¶
- New mentionable entity type: Extend the @mention parser in
CommentServiceto recognise the new prefix (e.g.#issueKey), resolve the target entity, and publish the appropriate mention event (subclassMentionEventor add a new event type). - New activity type: Add the value to
ActivityType, add an@EventListenerinActivityServicethat handles the corresponding domain event, and save anIssueActivitywith the new type.
Common Pitfalls¶
- Comments can only be edited by their author. Deletion is allowed for the author or a project ADMIN. Do not allow any other mutation.
- DO NOT store raw HTML in comments — sanitize input with the existing utility before persisting;
bodyis rendered by the frontend, not the backend. - System-generated comments (
authorId == null) are valid but do not produceIssueActivityrecords;ActivityService.onCommentCreated()returns early whenauthorIdis null. IssueFieldChangedEventwith an unknownfieldvalue is silently skipped byActivityService(logged at WARN); add the mapping explicitly for any new field.
Example¶
CommentService.addComment saves the comment and publishes CommentCreatedEvent, which triggers both ActivityService and NotificationService listeners in the same transaction boundary:
@Transactional
fun addComment(projectKey: String, issueKey: String, body: String, actor: User): Comment {
val issue = issueService.findByKey(projectKey, issueKey, actor.id)
val comment = commentRepository.save(
Comment(issueId = issue.id, authorId = actor.id, body = body)
)
eventPublisher.publish(
CommentCreatedEvent(comment, issue, actorEmail = actor.email, actorId = actor.id)
)
return comment
}
editComment restricts mutation to the original author; ADMIN deletion uses deleteComment which additionally checks ProjectService.isProjectAdmin. Note the HTML sanitization step — storing raw HTML in body is a pitfall (see Common Pitfalls):
@Transactional
fun editComment(commentId: UUID, body: String, actor: User): Comment {
val comment = commentRepository.findById(commentId)
.orElseThrow { NotFoundException("Comment not found: $commentId") }
if (comment.authorId != actor.id) throw ForbiddenException("Not the comment author")
val sanitizedBody = htmlSanitizer.sanitize(body)
comment.body = sanitizedBody
comment.editedAt = Instant.now()
return commentRepository.save(comment)
}
Mention extraction from comment body:
private val MENTION_REGEX = Regex("""@([\w.]+)""")
private fun extractAndPublishMentions(body: String, comment: Comment, issue: Issue) {
MENTION_REGEX.findAll(body)
.map { it.groupValues[1] }
.distinct()
.forEach { displayName ->
val mentioned = userRepository.findByDisplayNameIgnoreCase(displayName)
?: return@forEach
eventPublisher.publish(
MentionEvent(mentionedUser = mentioned, comment = comment, issue = issue)
)
}
}
extractAndPublishMentions() shows the intended wiring. Call it from addComment() after saving to enable mention notifications. NotificationService.onMention() and EmailService.onMention() are the consumers.
Test Patterns¶
CommentServiceTest— pure unit test with MockK; mocksCommentRepository,IssueService,ProjectService,DomainEventPublisher. Verifies: comment creation publishesCommentCreatedEvent; edit setseditedAt;ForbiddenExceptionwhen non-author edits; soft-delete setsdeletedAt; project admin can delete any comment.ActivityServiceTest— pure unit test with MockK; uses a@BeforeEachstub (repository.save(any()) returnsArgument 0). Verifies: each event type produces the correctActivityTypeand correctoldValue/newValue; system comment (nullauthorId) produces no activity record.