import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; import { CommentAddedEvent, IssueAssignedEvent, IssueCreatedEvent, IssueStatusChangedEvent, mentionedUserIds, NotificationKind, ProjectMemberAddedEvent, StatusCategory, TrackerEvent, withoutMentionMarkup, } from '@task-stream/api/tracker/domain'; import { FindCommentsRepository } from '../ports/find-comments.repository'; import { FindIssuesRepository } from '../ports/find-issues.repository'; import { FindProjectsRepository } from '../ports/find-projects.repository'; import { UserDirectory } from '../ports/user-directory'; import { NotificationDraft, Notifier } from './notifier'; /* Read by people, like the activity feed: Spanish. */ const excerpt = (text: string, max = 120) => { 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 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, IssueAssignedEvent, IssueStatusChangedEvent, CommentAddedEvent, ProjectMemberAddedEvent, ) export class NotificationsEventHandler implements IEventHandler { constructor( private readonly notifier: Notifier, private readonly findIssues: FindIssuesRepository, private readonly findProjects: FindProjectsRepository, private readonly users: UserDirectory, private readonly findComments: FindCommentsRepository, ) {} async handle(event: TrackerEvent): Promise { const project = await this.findProjects.findById(event.projectId); if (!project) return; const actor = (await this.users.refs([event.actorId])).of( event.actorId, ).name; if (event instanceof ProjectMemberAddedEvent) { return this.notifier.deliver( event, { projectId: project.id, projectKey: project.key, issueId: null, issueKey: null, }, [ { userId: event.userId, kind: NotificationKind.ADDED_TO_PROJECT, summary: `${actor} te añadió al proyecto ${project.name} (${project.key})`, }, ], ); } const issueId = event instanceof CommentAddedEvent ? event.comment.issueId : event.aggregateId; const issue = event instanceof IssueCreatedEvent ? event.issue : await this.findIssues.findById(issueId); if (!issue) return; const reporterId = 'reporterId' in issue ? issue.reporterId : issue.reporter.id; 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) { drafts.push({ userId: 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; // Back from QA without passing: the one move whoever did the work must hear about. const failedQa = event.meta?.fromCategory === StatusCategory.QA && 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({ userId, kind: failedQa ? NotificationKind.QA_FAILED : NotificationKind.STATUS_CHANGED, 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}`, }); } const answered = event.comment.replyTo ? await this.findComments.findById(event.comment.replyTo) : null; if (answered && !answered.deleted) { drafts.push({ userId: answered.author.id, kind: NotificationKind.REPLIED, summary: `${actor} respondió a tu comentario 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}: ${said}`, }); } for (const userId of watcherIds) { drafts.push({ userId, kind: NotificationKind.WATCHED, summary: `${actor} comentó en ${issue.key}, que sigues: ${said}`, }); } } await this.notifier.deliver( event, { projectId: project.id, projectKey: project.key, issueId: issue.id, issueKey: issue.key, }, drafts, ); } }