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

@@ -27,6 +27,7 @@ import { DetachIssueFromReleaseCommandHandler } from './detach-issue-from-releas
import { UnassignIssueCommandHandler } from './unassign-issue.command-handler';
import { AttachFileCommandHandler } from './attach-file.command-handler';
import { DetachFileCommandHandler } from './detach-file.command-handler';
import { WatchIssueCommandHandler } from './watch-issue.command-handler';
export const COMMAND_HANDLERS = [
CreateProjectCommandHandler,
@@ -59,4 +60,5 @@ export const COMMAND_HANDLERS = [
UnassignIssueCommandHandler,
AttachFileCommandHandler,
DetachFileCommandHandler,
WatchIssueCommandHandler,
];

View File

@@ -40,6 +40,7 @@ import { AddProjectMemberCommand } from './add-project-member.command';
import { AssignIssueCommand } from './assign-issue.command';
import { AttachFileCommand } from './attach-file.command';
import { DetachFileCommand } from './detach-file.command';
import { WatchIssueCommand } from './watch-issue.command';
import { ChangeIssueStatusCommand } from './change-issue-status.command';
import { COMMAND_HANDLERS } from './command-handlers';
import { CommandResult } from './command-result';
@@ -258,6 +259,28 @@ describe('tracker commands (event sourced)', () => {
});
});
it('lets any member, viewers included, follow a ticket for themselves', async () => {
const { run, rehydrator } = await setup();
const projectId = await projectWithTeam(run);
const issue = await run(
new CreateIssueCommand('qa', projectId, IssueType.BUG, 'Crash', {}),
);
await run(new WatchIssueCommand('pm', issue.id, 'pm'));
await run(new WatchIssueCommand('dev', issue.id, 'dev'));
await run(new WatchIssueCommand('dev', issue.id, 'dev', false));
expect((await rehydrator.rehydrate(issue.id, Issue)).watchers).toEqual([
'pm',
]);
await expect(
run(new WatchIssueCommand('dev', issue.id, 'pm', false)),
).rejects.toThrow(ForbiddenError);
await expect(
run(new WatchIssueCommand('gone', issue.id, 'gone')),
).rejects.toThrow(ForbiddenError);
});
it('does not let viewers modify tickets', async () => {
const { run } = await setup();
const projectId = await projectWithTeam(run);

View File

@@ -28,3 +28,4 @@ export * from './detach-issue-from-release.command';
export * from './unassign-issue.command';
export * from './attach-file.command';
export * from './detach-file.command';
export * from './watch-issue.command';

View File

@@ -0,0 +1,33 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { ForbiddenError } from '@task-stream/api/shared/domain';
import { CommandResult, ack } from './command-result';
import { WatchIssueCommand } from './watch-issue.command';
import { WriteAccess } from './write-access';
/**
* Following is for members, viewers included: reading a ticket is enough to
* want to hear about it. People only follow or unfollow for themselves.
*/
@CommandHandler(WatchIssueCommand)
export class WatchIssueCommandHandler
implements ICommandHandler<WatchIssueCommand>
{
constructor(private readonly access: WriteAccess) {}
async execute(command: WatchIssueCommand): Promise<CommandResult> {
const issue = await this.access.issue(command.issueId);
const project = await this.access.project(issue.projectId);
if (command.actorId !== command.userId) {
throw new ForbiddenError('You can only follow tickets yourself');
}
if (!project.isMember(command.userId)) {
throw new ForbiddenError(
`You are not a member of project ${project.key}`,
);
}
if (command.watching) issue.watch(command.actorId, command.userId);
else issue.unwatch(command.actorId, command.userId);
await issue.commitAndWait();
return ack(issue);
}
}

View File

@@ -0,0 +1,14 @@
import { Command } from '@nestjs/cqrs';
import { CommandResult } from './command-result';
/** `userId` starts (`watching`) or stops following a ticket. */
export class WatchIssueCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly issueId: string,
public readonly userId: string,
public readonly watching = true,
) {
super();
}
}

View File

@@ -27,6 +27,7 @@ import { ReleaseDeletedEventHandler } from './release-deleted.event-handler';
import { IssueAttachmentAddedEventHandler } from './issue-attachment-added.event-handler';
import { IssueAttachmentRemovedEventHandler } from './issue-attachment-removed.event-handler';
import { AttachmentCleanupEventHandler } from './attachment-cleanup.event-handler';
import { IssueWatchersEventHandler } from './issue-watchers.event-handler';
/** Read-model projections (MongoDB) + realtime, fed by the EventsBridge. */
export const EVENT_HANDLERS = [
@@ -58,6 +59,7 @@ export const EVENT_HANDLERS = [
IssueAttachmentAddedEventHandler,
IssueAttachmentRemovedEventHandler,
AttachmentCleanupEventHandler,
IssueWatchersEventHandler,
];
export { ReadModelNotifier };

View File

@@ -0,0 +1,40 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import {
IssueUnwatchedEvent,
IssueWatchedEvent,
} from '@task-stream/api/tracker/domain';
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
import { UserDirectory } from '../ports/user-directory';
import { ReadModelNotifier, versionOf } from './read-model-notifier';
@EventsHandler(IssueWatchedEvent, IssueUnwatchedEvent)
export class IssueWatchersEventHandler
implements IEventHandler<IssueWatchedEvent | IssueUnwatchedEvent>
{
constructor(
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
private readonly users: UserDirectory,
private readonly notifier: ReadModelNotifier,
) {}
async handle(event: IssueWatchedEvent | IssueUnwatchedEvent) {
// Following is not a change to the ticket: `updatedAt` stays.
await this.upsertMaterializedIssueRepository.upsert({
id: event.aggregateId,
version: versionOf(event),
});
if (event instanceof IssueWatchedEvent) {
const users = await this.users.refs([event.userId]);
await this.upsertMaterializedIssueRepository.addWatcher(
event.aggregateId,
users.of(event.userId),
);
} else {
await this.upsertMaterializedIssueRepository.removeWatcher(
event.aggregateId,
event.userId,
);
}
await this.notifier.issue(event.aggregateId, event);
}
}

View File

@@ -12,6 +12,7 @@ import {
IncomingFile,
} from './commands/attach-file.command';
import { DetachFileCommand } from './commands/detach-file.command';
import { WatchIssueCommand } from './commands/watch-issue.command';
import { ChangeIssueStatusCommand } from './commands/change-issue-status.command';
import { CommandResult } from './commands/command-result';
import { CreateIssueCommand } from './commands/create-issue.command';
@@ -157,6 +158,17 @@ export class IssuesService {
);
}
/** The actor follows (or stops following) the ticket. */
watch(
actorId: string,
issueId: string,
watching: boolean,
): Promise<CommandResult> {
return this.commandBus.execute(
new WatchIssueCommand(actorId, issueId, actorId, watching),
);
}
/** A ticket's file, with its bytes, for whoever can read the ticket. */
download(
viewerId: string | null,

View File

@@ -4,10 +4,12 @@ import {
IssueAssignedEvent,
IssueCreatedEvent,
IssueStatusChangedEvent,
mentionedUserIds,
NotificationKind,
ProjectMemberAddedEvent,
StatusCategory,
TrackerEvent,
withoutMentionMarkup,
} from '@task-stream/api/tracker/domain';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';
@@ -17,14 +19,16 @@ import { NotificationDraft, Notifier } from './notifier';
/* Read by people, like the activity feed: Spanish. */
const excerpt = (text: string, max = 120) => {
const flat = text.replace(/\s+/g, ' ').trim();
const flat = withoutMentionMarkup(text).replace(/\s+/g, ' ').trim();
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
};
/**
* Who is told what, for the events that concern someone in particular:
* whoever a ticket is assigned to, whoever reported or works on it when it
* moves or gets a comment, whoever joins a project.
* whoever is mentioned, whoever a ticket is assigned to, whoever reported or
* works on it when it moves or gets a comment, whoever follows it, whoever
* joins a project. The order is the order of importance: each person gets
* one notification per event, for the first reason that applies.
*/
@EventsHandler(
IssueCreatedEvent,
@@ -81,6 +85,9 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
const assigneeId =
'assigneeId' in issue ? issue.assigneeId : (issue.assignee?.id ?? null);
const ticket = `${issue.key}: ${issue.title}`;
const watcherIds =
'watchers' in issue ? issue.watchers.map((w) => w.id) : [];
const memberIds = new Set(project.members.map((m) => m.user.id));
const drafts: NotificationDraft[] = [];
if (event instanceof IssueCreatedEvent && assigneeId) {
@@ -90,12 +97,26 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
summary: `${actor} te asignó ${ticket}`,
});
}
if (event instanceof IssueAssignedEvent && event.assigneeId) {
drafts.push({
userId: event.assigneeId,
kind: NotificationKind.ASSIGNED,
summary: `${actor} te asignó ${ticket}`,
});
if (event instanceof IssueAssignedEvent) {
const assignee = event.assigneeId
? (await this.users.refs([event.assigneeId])).of(event.assigneeId).name
: null;
if (event.assigneeId) {
drafts.push({
userId: event.assigneeId,
kind: NotificationKind.ASSIGNED,
summary: `${actor} te asignó ${ticket}`,
});
}
for (const userId of watcherIds) {
drafts.push({
userId,
kind: NotificationKind.WATCHED,
summary: assignee
? `${actor} asignó ${ticket} a ${assignee}`
: `${actor} dejó sin asignar ${ticket}`,
});
}
}
if (event instanceof IssueStatusChangedEvent) {
const to = event.meta?.toName ?? event.to;
@@ -105,6 +126,9 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
event.meta?.toCategory !== StatusCategory.QA &&
event.meta?.toCategory !== StatusCategory.DONE;
const reason = event.reason ? `: ${excerpt(event.reason)}` : '';
const summary = failedQa
? `${actor} devolvió ${ticket} desde QA${reason}`
: `${actor} movió ${ticket} a ${to}${reason}`;
for (const userId of [assigneeId, reporterId]) {
if (!userId) continue;
drafts.push({
@@ -112,19 +136,37 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
kind: failedQa
? NotificationKind.QA_FAILED
: NotificationKind.STATUS_CHANGED,
summary: failedQa
? `${actor} devolvió ${ticket} desde QA${reason}`
: `${actor} movió ${ticket} a ${to}${reason}`,
summary,
});
}
for (const userId of watcherIds) {
drafts.push({ userId, kind: NotificationKind.WATCHED, summary });
}
}
if (event instanceof CommentAddedEvent) {
const said = `«${excerpt(event.comment.body)}»`;
// Only members: a mention cannot reach someone who cannot read the ticket.
for (const userId of mentionedUserIds(event.comment.body)) {
if (!memberIds.has(userId)) continue;
drafts.push({
userId,
kind: NotificationKind.MENTIONED,
summary: `${actor} te mencionó en ${issue.key}: ${said}`,
});
}
for (const userId of [assigneeId, reporterId]) {
if (!userId) continue;
drafts.push({
userId,
kind: NotificationKind.COMMENTED,
summary: `${actor} comentó en ${issue.key}: «${excerpt(event.comment.body)}»`,
summary: `${actor} comentó en ${issue.key}: ${said}`,
});
}
for (const userId of watcherIds) {
drafts.push({
userId,
kind: NotificationKind.WATCHED,
summary: `${actor} comentó en ${issue.key}, que sigues: ${said}`,
});
}
}

View File

@@ -54,13 +54,21 @@ function setup(mailEnabled = false) {
const repo = new InMemoryNotifications();
const published: NotificationReadModel[] = [];
const mailed: string[] = [];
const users = new FakeUserDirectory().add('ana').add('bob').add('qa');
const users = new FakeUserDirectory()
.add('ana')
.add('bob')
.add('qa')
.add('pm');
const issue = {
id: 'i-1',
key: 'WEB-1',
title: 'Login roto',
reporter: { id: 'qa', name: 'QA' },
assignee: { id: 'bob', name: 'Bob' },
watchers: [
{ id: 'pm', name: 'PM' },
{ id: 'bob', name: 'Bob' },
],
};
const notifier = new Notifier(
repo,
@@ -81,7 +89,15 @@ function setup(mailEnabled = false) {
notifier,
{ findById: async () => issue } as unknown as FindIssuesRepository,
{
findById: async () => ({ id: 'p-1', key: 'WEB', name: 'Web' }),
findById: async () => ({
id: 'p-1',
key: 'WEB',
name: 'Web',
members: ['ana', 'bob', 'qa', 'pm'].map((id) => ({
user: { id, name: id },
role: ProjectRole.MEMBER,
})),
}),
} as unknown as FindProjectsRepository,
users,
);
@@ -100,8 +116,12 @@ describe('notifications', () => {
expect(repo.all.map((n) => [n.userId, n.kind])).toEqual([
['bob', NotificationKind.ASSIGNED],
// Bob follows the ticket too, but is told once: as the assignee.
['pm', NotificationKind.WATCHED],
['pm', NotificationKind.WATCHED],
['bob', NotificationKind.WATCHED],
]);
expect(published).toHaveLength(1);
expect(published).toHaveLength(4);
expect(repo.all[0].summary).toContain('WEB-1');
});
@@ -129,6 +149,7 @@ describe('notifications', () => {
expect(repo.all.map((n) => [n.userId, n.kind])).toEqual([
['bob', NotificationKind.QA_FAILED],
['qa', NotificationKind.QA_FAILED],
['pm', NotificationKind.WATCHED],
]);
expect(repo.all[0].summary).toContain('Falla en Safari');
});
@@ -151,7 +172,32 @@ describe('notifications', () => {
await handler.handle(comment);
await handler.handle(comment);
expect(repo.all.map((n) => n.userId)).toEqual(['qa']);
expect(repo.all.map((n) => n.userId)).toEqual(['qa', 'pm']);
});
it('tells a mentioned member first, and nobody outside the project', async () => {
const { handler, repo } = setup();
await handler.handle(
at(
new CommentAddedEvent('bob', {
id: 'c-2',
issueId: 'i-1',
projectId: 'p-1',
authorId: 'bob',
body: '@[PM](pm) mira esto, y @[Fuera](stranger) también',
deleted: false,
createdAt: '2026-10-08T10:00:00.000Z',
editedAt: null,
}),
1,
),
);
expect(repo.all.map((n) => [n.userId, n.kind])).toEqual([
['pm', NotificationKind.MENTIONED],
['qa', NotificationKind.COMMENTED],
]);
expect(repo.all[0].summary).toContain('«@PM mira esto');
});
it('copies by e-mail only to whoever asked for it, when mail is configured', async () => {

View File

@@ -3,13 +3,19 @@ import {
IssueLinkReadModel,
IssueReadModel,
StatusCategory,
UserRefReadModel,
} from '@task-stream/api/tracker/domain';
export abstract class UpsertMaterializedIssueRepository {
/** Applied only if `version` is newer than the stored one (idempotent). */
abstract upsert(
issue: Pick<IssueReadModel, 'id' | 'version'> &
Partial<Omit<IssueReadModel, 'links' | 'commentCount' | 'attachments'>>,
Partial<
Omit<
IssueReadModel,
'links' | 'commentCount' | 'attachments' | 'watchers'
>
>,
): Promise<void>;
/** Adds a relation to an issue (idempotent). */
abstract addLink(issueId: string, link: IssueLinkReadModel): Promise<void>;
@@ -26,6 +32,9 @@ export abstract class UpsertMaterializedIssueRepository {
issueId: string,
attachmentId: string,
): Promise<void>;
/** Adds a follower (idempotent). */
abstract addWatcher(issueId: string, user: UserRefReadModel): Promise<void>;
abstract removeWatcher(issueId: string, userId: string): Promise<void>;
abstract setCommentCount(
issueId: string,
commentCount: number,

View File

@@ -0,0 +1,25 @@
import { CommentAddedEvent } from '@task-stream/api/tracker/domain';
import { lastValueFrom, of, toArray } from 'rxjs';
import { WatchIssueCommand } from '../commands/watch-issue.command';
import { CommentersFollowSaga } from './commenters-follow.saga';
describe('CommentersFollowSaga', () => {
it('makes whoever comments follow the ticket', async () => {
const event = new CommentAddedEvent('ana', {
id: 'c-1',
issueId: 'i-1',
projectId: 'p-1',
authorId: 'ana',
body: 'Lo miro',
deleted: false,
createdAt: '2026-10-08T10:00:00.000Z',
editedAt: null,
});
const commands = await lastValueFrom(
new CommentersFollowSaga().commented(of(event)).pipe(toArray()),
);
expect(commands).toEqual([new WatchIssueCommand('ana', 'i-1', 'ana')]);
});
});

View File

@@ -0,0 +1,27 @@
import { Injectable } from '@nestjs/common';
import { ICommand, IEvent, ofType, Saga } from '@nestjs/cqrs';
import { CommentAddedEvent } from '@task-stream/api/tracker/domain';
import { map, Observable } from 'rxjs';
import { WatchIssueCommand } from '../commands/watch-issue.command';
/**
* Whoever comments on a ticket follows it from then on: they took part in the
* conversation, so they will want to hear how it ends. Unfollowing stays
* theirs to do. (Idempotent: following twice changes nothing.)
*/
@Injectable()
export class CommentersFollowSaga {
@Saga()
commented = (events$: Observable<IEvent>): Observable<ICommand> =>
events$.pipe(
ofType(CommentAddedEvent),
map(
(event) =>
new WatchIssueCommand(
event.comment.authorId,
event.comment.issueId,
event.comment.authorId,
),
),
);
}

View File

@@ -1,10 +1,12 @@
import { ProjectDeletionSaga } from './project-deletion.saga';
import { ReleaseArchivedSaga } from './release-archived.saga';
import { UserDeactivatedSaga } from './user-deactivated.saga';
import { CommentersFollowSaga } from './commenters-follow.saga';
/** Reactions to events that change other aggregates (run by the events bridge). */
export const SAGAS = [
ProjectDeletionSaga,
ReleaseArchivedSaga,
UserDeactivatedSaga,
CommentersFollowSaga,
];

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;

View File

@@ -77,6 +77,7 @@ export const IssueSchema = new Schema<IssueDoc>(
release: { _id: false, id: String, name: String },
outwardLinks: [link],
inwardLinks: [link],
watchers: [userRef],
attachments: [
{
_id: false,
@@ -97,6 +98,7 @@ export const IssueSchema = new Schema<IssueDoc>(
IssueSchema.index({ projectId: 1, status: 1, updatedAt: -1 });
IssueSchema.index({ projectId: 1, statusCategory: 1 });
IssueSchema.index({ 'assignee.id': 1 });
IssueSchema.index({ 'watchers.id': 1 });
IssueSchema.index({ 'release.id': 1 });
IssueSchema.index({ title: 'text', description: 'text', key: 'text' });
@@ -179,5 +181,6 @@ export function issueFromDoc(doc: IssueDoc): IssueReadModel {
links: [...(outwardLinks ?? []), ...(inwardLinks ?? [])],
// Tickets projected before attachments existed have none.
attachments: rest.attachments ?? [],
watchers: rest.watchers ?? [],
};
}

View File

@@ -18,6 +18,7 @@ import {
ProjectReadModel,
ReleaseReadModel,
StatusCategory,
UserRefReadModel,
} from '@task-stream/api/tracker/domain';
import { Model } from 'mongoose';
import {
@@ -100,15 +101,35 @@ export class MongoUpsertMaterializedIssueRepository extends UpsertMaterializedIs
version,
...fields
}: Pick<IssueReadModel, 'id' | 'version'> &
Partial<Omit<IssueReadModel, 'links' | 'commentCount' | 'attachments'>>) {
Partial<
Omit<
IssueReadModel,
'links' | 'commentCount' | 'attachments' | 'watchers'
>
>) {
await upsertIfNewer(this.issues, id, version, fields, {
outwardLinks: [],
inwardLinks: [],
attachments: [],
watchers: [],
commentCount: 0,
});
}
async addWatcher(issueId: string, user: UserRefReadModel) {
await this.issues.updateOne(
{ _id: issueId, 'watchers.id': { $ne: user.id } },
{ $push: { watchers: user } },
);
}
async removeWatcher(issueId: string, userId: string) {
await this.issues.updateOne(
{ _id: issueId },
{ $pull: { watchers: { id: userId } } },
);
}
async addAttachment(issueId: string, attachment: AttachmentReadModel) {
await this.issues.updateOne(
{ _id: issueId, 'attachments.id': { $ne: attachment.id } },

View File

@@ -183,6 +183,25 @@ export class IssueResolver {
);
}
@Mutation(() => CommandResultGql, {
description:
'Follow a ticket: be told when it moves, is reassigned or gets a comment',
})
watchIssue(
@CurrentUser() me: User,
@Args('issueId', { type: () => ID }) issueId: string,
) {
return this.issuesService.watch(me.id, issueId, true);
}
@Mutation(() => CommandResultGql, { description: 'Stop following a ticket' })
unwatchIssue(
@CurrentUser() me: User,
@Args('issueId', { type: () => ID }) issueId: string,
) {
return this.issuesService.watch(me.id, issueId, false);
}
@Mutation(() => CommandResultGql, {
description:
'Remove a file from a ticket (whoever uploaded it, or an owner/admin). Upload with the REST API.',

View File

@@ -143,6 +143,8 @@ export class IssueGql {
@Field(() => String, { nullable: true }) dueDate!: string | null;
@Field(() => [IssueLinkGql]) links!: IssueLinkGql[];
@Field(() => [AttachmentGql]) attachments!: AttachmentGql[];
@Field(() => [UserRefGql], { description: 'People following the ticket' })
watchers!: UserRefGql[];
@Field(() => Int) commentCount!: number;
@Field() createdAt!: string;
@Field() updatedAt!: string;

View File

@@ -159,6 +159,8 @@ export class IssueDto {
dueDate!: string | null;
@ApiProperty({ type: [IssueLinkDto] }) links!: IssueLinkDto[];
@ApiProperty({ type: [AttachmentDto] }) attachments!: AttachmentDto[];
@ApiProperty({ type: [UserRefDto], description: 'People following it' })
watchers!: UserRefDto[];
@ApiProperty() commentCount!: number;
@ApiProperty({ format: 'date-time' }) createdAt!: string;
@ApiProperty({ format: 'date-time' }) updatedAt!: string;

View File

@@ -77,6 +77,36 @@ export class IssuesController {
private readonly refs: Refs,
) {}
@Put('issues/:issue/watch')
@ISSUE
@ApiOperation({
summary: 'Follow a ticket (members, viewers included)',
description:
'Followers are told when it moves, is reassigned or gets a comment. Commenting follows it too.',
})
@ApiOkResponse({ type: CommandResultDto })
@ApiCommandErrors()
async watch(@CurrentUser() me: User, @Param('issue') issue: string) {
return this.issues.watch(
me.id,
await this.refs.issueId(me.id, issue),
true,
);
}
@Delete('issues/:issue/watch')
@ISSUE
@ApiOperation({ summary: 'Stop following a ticket' })
@ApiOkResponse({ type: CommandResultDto })
@ApiCommandErrors()
async unwatch(@CurrentUser() me: User, @Param('issue') issue: string) {
return this.issues.watch(
me.id,
await this.refs.issueId(me.id, issue),
false,
);
}
@Get('me/issues')
@ApiOperation({
summary: 'My work: tickets across all my projects',