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

@@ -3,6 +3,7 @@ export * from './lib/project/project-role';
export * from './lib/issue/issue';
export * from './lib/issue/issue.enums';
export * from './lib/issue/issue-attachment';
export * from './lib/issue/mentions';
export * from './lib/project/project-settings';
export * from './lib/comment/comment';
export * from './lib/release/release';

View File

@@ -24,3 +24,5 @@ export * from './issue-deleted.event';
export * from './release-deleted.event';
export * from './issue-attachment-added.event';
export * from './issue-attachment-removed.event';
export * from './issue-watched.event';
export * from './issue-unwatched.event';

View File

@@ -0,0 +1,15 @@
import { AutowiredEvent } from '@task-stream/api/shared/domain';
import { TrackerEvent } from './tracker.event';
/** Someone stopped following a ticket. */
@AutowiredEvent
export class IssueUnwatchedEvent extends TrackerEvent {
constructor(
actorId: string,
issueId: string,
projectId: string,
readonly userId: string,
) {
super(actorId, issueId, projectId);
}
}

View File

@@ -0,0 +1,15 @@
import { AutowiredEvent } from '@task-stream/api/shared/domain';
import { TrackerEvent } from './tracker.event';
/** Someone started following a ticket. */
@AutowiredEvent
export class IssueWatchedEvent extends TrackerEvent {
constructor(
actorId: string,
issueId: string,
projectId: string,
readonly userId: string,
) {
super(actorId, issueId, projectId);
}
}

View File

@@ -8,6 +8,7 @@ import { IssueStatusChangedEvent } from '../events/issue-status-changed.event';
import { IssueFactory } from '../factories/issue.factory';
import { replay } from '../testing';
import { Issue } from './issue';
import { mentionedUserIds, withoutMentionMarkup } from './mentions';
import { IssueLinkType, IssueStatus, IssueType } from './issue.enums';
const factory = new IssueFactory();
@@ -206,4 +207,31 @@ describe('Issue', () => {
expect(rebuilt.attachments.map((a) => a.id)).toEqual(['a-2']);
});
});
describe('followers', () => {
it('follows and unfollows once, and survives a replay', () => {
const issue = newIssue();
issue.watch('u-dev', 'u-dev');
issue.watch('u-dev', 'u-dev');
issue.watch('u-pm', 'u-pm');
issue.unwatch('u-dev', 'u-dev');
issue.unwatch('u-dev', 'u-dev');
expect(issue.watchers).toEqual(['u-pm']);
expect(replay(issue, Issue).rebuilt.watchers).toEqual(['u-pm']);
expect(issue.getUncommittedEvents()).toHaveLength(4);
});
});
describe('mentions', () => {
it('reads who a text mentions, once each', () => {
const text =
'Hola @[Ana García](u-ana) y @[Bob](u-bob), @[Ana García](u-ana) otra vez. @sinmarca';
expect(mentionedUserIds(text)).toEqual(['u-ana', 'u-bob']);
expect(withoutMentionMarkup(text)).toBe(
'Hola @Ana García y @Bob, @Ana García otra vez. @sinmarca',
);
});
});
});

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;
}

View File

@@ -0,0 +1,17 @@
/**
* Mentions are written `@[Ana García](<user id>)`: the name to show and who
* it is, so renaming someone never breaks an old comment. The web app inserts
* them from a list of the project's members.
*/
const MENTION = /@\[([^\]\n]{1,80})\]\(([\w-]{1,64})\)/g;
/** Who a text mentions, once each, in order. */
export function mentionedUserIds(text: string | null | undefined): string[] {
if (!text) return [];
return [...new Set([...text.matchAll(MENTION)].map((m) => m[2]))];
}
/** The text as a person reads it: `@Ana García` instead of the markup. */
export function withoutMentionMarkup(text: string): string {
return text.replace(MENTION, (_, name: string) => `@${name}`);
}

View File

@@ -47,6 +47,8 @@ export class IssueReadModel {
dueDate!: string | null;
links!: IssueLinkReadModel[];
attachments!: AttachmentReadModel[];
/** People following the ticket. */
watchers!: UserRefReadModel[];
commentCount!: number;
createdAt!: string;
updatedAt!: string;