feat: mentions and following tickets

Mention project members in comments with @, follow and unfollow tickets,
and get told when something changes on the tickets you follow. Commenting
on a ticket starts following it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 09:41:32 +02:00
parent efa06a0723
commit 88928b402f
49 changed files with 1318 additions and 41 deletions

View File

@@ -14,6 +14,8 @@ import { IssueReleaseChangedEvent } from '../events/issue-release-changed.event'
import { IssueStatusChangedEvent } from '../events/issue-status-changed.event';
import { IssueUnlinkedEvent } from '../events/issue-unlinked.event';
import { IssueUpdatedEvent } from '../events/issue-updated.event';
import { IssueUnwatchedEvent } from '../events/issue-unwatched.event';
import { IssueWatchedEvent } from '../events/issue-watched.event';
import { ProjectWorkflow } from '../project/project-settings';
import {
IssueAttachment,
@@ -91,6 +93,8 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
public dueDate: string | null = null;
public links: IssueLink[] = [];
public attachments: IssueAttachment[] = [];
/** People who asked to be told about this ticket. */
public watchers: string[] = [];
public createdAt!: string;
public updatedAt!: string;
public deleted = false;
@@ -262,6 +266,20 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
);
}
/** `userId` starts following the ticket. Idempotent. */
watch(actorId: string, userId: string): void {
if (this.watchers.includes(userId)) return;
this.apply(new IssueWatchedEvent(actorId, this.id, this.projectId, userId));
}
/** `userId` stops following the ticket. Idempotent. */
unwatch(actorId: string, userId: string): void {
if (!this.watchers.includes(userId)) return;
this.apply(
new IssueUnwatchedEvent(actorId, this.id, this.projectId, userId),
);
}
/** Deletes the ticket (its project was deleted). Idempotent. */
delete(actorId: string): void {
if (this.deleted) return;
@@ -334,6 +352,14 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
this.updatedAt = event.occurredAt;
}
onIssueWatchedEvent(event: IssueWatchedEvent): void {
this.watchers = [...this.watchers, event.userId];
}
onIssueUnwatchedEvent(event: IssueUnwatchedEvent): void {
this.watchers = this.watchers.filter((id) => id !== event.userId);
}
onIssueDeletedEvent(): void {
this.deleted = true;
}