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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
||||
import { TrackerEvent } from './tracker.event';
|
||||
|
||||
/** A comment was marked as a decision of the ticket, or unmarked. */
|
||||
@AutowiredEvent
|
||||
export class CommentDecisionMarkedEvent extends TrackerEvent {
|
||||
constructor(
|
||||
actorId: string,
|
||||
commentId: string,
|
||||
projectId: string,
|
||||
readonly issueId: string,
|
||||
/** False: it is no longer a decision. */
|
||||
readonly decision: boolean,
|
||||
) {
|
||||
super(actorId, commentId, projectId);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export * from './issue-release-changed.event';
|
||||
export * from './comment-added.event';
|
||||
export * from './comment-edited.event';
|
||||
export * from './comment-reacted.event';
|
||||
export * from './comment-decision-marked.event';
|
||||
export * from './comment-deleted.event';
|
||||
export * from './release-created.event';
|
||||
export * from './release-updated.event';
|
||||
|
||||
@@ -21,7 +21,18 @@ export class CommentReadModel {
|
||||
deleted!: boolean;
|
||||
/** In the order they are offered; only the ones somebody chose. */
|
||||
reactions!: CommentReactionReadModel[];
|
||||
/** Marked as a decision of the ticket. */
|
||||
decision!: boolean;
|
||||
/** Who marked it and when (null when it is not a decision). */
|
||||
decidedBy!: UserRefReadModel | null;
|
||||
decidedAt!: string | null;
|
||||
createdAt!: string;
|
||||
editedAt!: string | null;
|
||||
version!: number;
|
||||
}
|
||||
|
||||
/** A decision with the ticket it belongs to, for searching across tickets. */
|
||||
export class DecisionReadModel {
|
||||
comment!: CommentReadModel;
|
||||
issue!: { id: string; key: string; title: string };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user