Files
task-stream/libs/api/tracker/application/src/lib/notifications/notifications.event-handler.ts
Josep Piguillem ea4ef82239 feat: replies to comments
A comment can answer another one (replyTo), one level deep: answering a
reply joins the same thread, and it must be on the same ticket. Whoever
wrote the comment gets a new REPLIED notification, and the activity says
"respondió a un comentario".

A comment deleted while it has replies stays, without its text
(deleted: true), so the thread still reads; it goes with its last reply
and does not count in commentCount.

The ticket page shows threads with their replies indented and a
"Responder" box; REST, GraphQL and the MCP (add_comment, get_issue) take
and show replyTo. Existing comments are unchanged. Docs, Word and PDF
updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:14:37 +02:00

198 lines
6.5 KiB
TypeScript

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<TrackerEvent> {
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<void> {
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,
);
}
}