import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; import { CommentAddedEvent, IssueAssignedEvent, IssueCreatedEvent, IssuePriority, IssueSprintChangedEvent, IssueStatusChangedEvent, IssueUpdatedEvent, mentionedUserIds, DEFAULT_PROJECT_SETTINGS, ProjectWorkflow, TEAM_MENTION_PREFIX, NotificationKind, ProjectMemberAddedEvent, ProjectRole, StatusCategory, SYSTEM_ACTOR_ID, TrackerEvent, withoutMentionMarkup, } from '@task-stream/api/tracker/domain'; import { PROPOSAL_LABEL } from '../proposals'; import { FindCommentsRepository } from '../ports/find-comments.repository'; import { FindIssuesRepository } from '../ports/find-issues.repository'; import { FindProjectsRepository } from '../ports/find-projects.repository'; import { FindSprintsRepository } from '../ports/find-sprints.repository'; import { UserDirectory } from '../ports/user-directory'; import { NotificationDraft, Notifier } from './notifier'; /* Read by people, like the activity feed: Spanish. */ /** Same labels as the web app. */ const PRIORITY: Record = { [IssuePriority.CRITICAL]: 'Crítica', [IssuePriority.HIGH]: 'Alta', [IssuePriority.MEDIUM]: 'Media', [IssuePriority.LOW]: 'Baja', [IssuePriority.LOWEST]: 'Mínima', }; /** 2026-11-14 → 14 de noviembre de 2026. */ const day = (date: string) => new Date(`${date.slice(0, 10)}T00:00:00Z`).toLocaleDateString('es-ES', { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC', }); 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, IssueUpdatedEvent, IssueSprintChangedEvent, 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, private readonly findSprints: FindSprintsRepository, ) {} 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[] = []; // A proposal from the public form: whoever runs the project should know. if ( event instanceof IssueCreatedEvent && event.actorId === SYSTEM_ACTOR_ID && event.issue.labels?.includes(PROPOSAL_LABEL) ) { for (const member of project.members) { if ( member.role !== ProjectRole.OWNER && member.role !== ProjectRole.ADMIN ) continue; drafts.push({ userId: member.user.id, kind: NotificationKind.PROPOSED, summary: `Nueva propuesta en ${project.key}: ${ticket}`, }); } } 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. // A team's mention (`team-FRONTEND`) reaches each of its members. const workflow = new ProjectWorkflow( project.settings ?? DEFAULT_PROJECT_SETTINGS, ); const named = mentionedUserIds(event.comment.body); for (const userId of named) { if (!memberIds.has(userId)) continue; drafts.push({ userId, kind: NotificationKind.MENTIONED, summary: `${actor} te mencionó en ${issue.key}: ${said}`, }); } for (const id of named.filter((n) => n.startsWith(TEAM_MENTION_PREFIX))) { const team = workflow.teams.find( (t) => t.id === id.slice(TEAM_MENTION_PREFIX.length), ); for (const userId of workflow.mentionedPeople([id])) { if (!memberIds.has(userId)) continue; drafts.push({ userId, kind: NotificationKind.MENTIONED, summary: `${actor} mencionó a tu equipo ${team?.name ?? ''} 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}`, }); } } // Followers also hear of the changes that move their plans. const news = await this.followerNews(event, actor, ticket); if (news) { for (const userId of watcherIds) { drafts.push({ userId, kind: NotificationKind.WATCHED, summary: news }); } } await this.notifier.deliver( event, { projectId: project.id, projectKey: project.key, issueId: issue.id, issueKey: issue.key, }, drafts, ); } /** * What a follower should hear of a change of priority, due date or * sprint; null for the other edits (title, labels…), too small to tell. */ private async followerNews( event: TrackerEvent, actor: string, ticket: string, ): Promise { if (event instanceof IssueUpdatedEvent) { const { priority, dueDate } = event.changes; const parts: string[] = []; if (priority) parts.push(`prioridad ${PRIORITY[priority]}`); if (dueDate !== undefined) parts.push( dueDate ? `fecha límite el ${day(dueDate)}` : 'sin fecha límite', ); return parts.length ? `${actor} cambió ${ticket}: ${parts.join(', ')}` : null; } if (event instanceof IssueSprintChangedEvent) { if (!event.sprintId) return `${actor} sacó ${ticket} del sprint y lo devolvió al backlog`; const sprint = await this.findSprints.findById(event.sprintId); return `${actor} metió ${ticket} en ${sprint ? `el sprint ${sprint.name}` : 'un sprint'}`; } return null; } }