feat: comments marked as decisions, on top of the ticket and searchable

Whoever can change the ticket flags a comment as a decision (new event
CommentDecisionMarkedEvent on the comment). The ticket page lists its
decisions above the description, with a link to the comment in the
conversation (`?comment=<id>` scrolls to it and points at it), and the
search palette finds them by their text across my projects.

API: markDecision / PUT-DELETE /api/comments/{id}/decision; Issue.decisions
and GET /api/issues/{issue}/decisions; decisions(projectId?, search) and
GET /api/projects/{project}/decisions, GET /api/me/decisions. MCP:
mark_decision, search_decisions, and get_issue includes them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 11:32:07 +02:00
parent 8f83fdefb6
commit 70e4fbd399
45 changed files with 2515 additions and 84 deletions

View File

@@ -5,6 +5,7 @@ import {
assertThat,
} from '@task-stream/api/shared/domain';
import { CommentAddedEvent } from '../events/comment-added.event';
import { CommentDecisionMarkedEvent } from '../events/comment-decision-marked.event';
import { CommentDeletedEvent } from '../events/comment-deleted.event';
import { CommentEditedEvent } from '../events/comment-edited.event';
import { CommentReactedEvent } from '../events/comment-reacted.event';
@@ -41,6 +42,8 @@ export class Comment extends VersionedAggregateRoot implements CommentProps {
public replyTo: string | null = null;
/** Who chose each emoji (only the ones somebody chose). */
public reactions: Record<string, string[]> = {};
/** What was decided on the ticket: shown above it and searchable. */
public decision = false;
public deleted = false;
public createdAt!: string;
public editedAt: string | null = null;
@@ -93,6 +96,24 @@ export class Comment extends VersionedAggregateRoot implements CommentProps {
);
}
/**
* Marks it as a decision of the ticket (`on`) or unmarks it; repeating it
* changes nothing. Who may do it (the team) is the caller's check.
*/
markDecision(actorId: string, on: boolean): void {
this.assertAlive();
if (on === this.decision) return;
this.apply(
new CommentDecisionMarkedEvent(
actorId,
this.id,
this.projectId,
this.issueId,
on,
),
);
}
/** @param canModerate whether the actor manages the project (may delete others' comments). */
delete(actorId: string, canModerate: boolean): void {
this.assertAlive();
@@ -121,6 +142,10 @@ export class Comment extends VersionedAggregateRoot implements CommentProps {
this.reactions = event.reactions;
}
onCommentDecisionMarkedEvent(event: CommentDecisionMarkedEvent): void {
this.decision = event.decision;
}
onCommentDeletedEvent(): void {
this.deleted = true;
}