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:
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
17
libs/api/tracker/domain/src/lib/issue/mentions.ts
Normal file
17
libs/api/tracker/domain/src/lib/issue/mentions.ts
Normal 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}`);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user