feat: what is new since my last visit, and turning a comment into a ticket

Opening a ticket notes the visit and answers the one before (issue_visits),
so the page marks others' comments and changes since. A comment can become
a ticket of its own, as a subtask or linked; it remembers where it went.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 21:05:44 +02:00
parent 429b52e6f5
commit 285bd5c454
50 changed files with 1953 additions and 79 deletions

View File

@@ -7,6 +7,7 @@ import {
import { CommentAddedEvent } from '../events/comment-added.event';
import { CommentDecisionMarkedEvent } from '../events/comment-decision-marked.event';
import { CommentThreadResolvedEvent } from '../events/comment-thread-resolved.event';
import { CommentTurnedIntoIssueEvent } from '../events/comment-turned-into-issue.event';
import {
CommentPollClosedEvent,
CommentPollVotedEvent,
@@ -82,6 +83,8 @@ export class Comment extends VersionedAggregateRoot implements CommentProps {
public pollVotes: Record<string, number> = {};
public pollClosed = false;
public pollWinner: number | null = null;
/** Tickets made from it, oldest first. */
public spawned: { id: string; key: string }[] = [];
public deleted = false;
public createdAt!: string;
public editedAt: string | null = null;
@@ -277,10 +280,32 @@ export class Comment extends VersionedAggregateRoot implements CommentProps {
this.resolved = event.resolved;
}
onCommentTurnedIntoIssueEvent(event: CommentTurnedIntoIssueEvent): void {
this.spawned = event.spawned;
}
onCommentDeletedEvent(): void {
this.deleted = true;
}
/**
* Notes a ticket made from it (its text became the ticket's description).
* Who may create it is the caller's check.
*/
turnedInto(actorId: string, issue: { id: string; key: string }): void {
this.assertAlive();
if (this.spawned.some((s) => s.id === issue.id)) return;
this.apply(
new CommentTurnedIntoIssueEvent(
actorId,
this.id,
this.projectId,
this.issueId,
[...this.spawned, issue],
),
);
}
private assertAlive(): void {
if (this.deleted) throw new BusinessRuleError('Comment was deleted');
}

View File

@@ -0,0 +1,17 @@
import { AutowiredEvent } from '@task-stream/api/shared/domain';
import { TrackerEvent } from './tracker.event';
/** A ticket of its own was made from a comment («→ WEB-45» on it). */
@AutowiredEvent
export class CommentTurnedIntoIssueEvent extends TrackerEvent {
constructor(
actorId: string,
commentId: string,
projectId: string,
readonly issueId: string,
/** Every ticket made from it so far, this one last. */
readonly spawned: { id: string; key: string }[],
) {
super(actorId, commentId, projectId);
}
}

View File

@@ -17,6 +17,7 @@ export * from './comment-edited.event';
export * from './comment-reacted.event';
export * from './comment-decision-marked.event';
export * from './comment-thread-resolved.event';
export * from './comment-turned-into-issue.event';
export * from './issue-review.events';
export * from './comment-poll.events';
export * from './comment-deleted.event';

View File

@@ -41,6 +41,8 @@ export class CommentReadModel {
/** Who settled it and when (null while open). */
resolvedBy!: UserRefReadModel | null;
resolvedAt!: string | null;
/** Tickets made from it («Convertir en ticket»), oldest first. */
spawned!: { id: string; key: string }[];
createdAt!: string;
editedAt!: string | null;
version!: number;