Projects keep teams of members in their settings (settings.teams; people outside the project are dropped, teams are archived, not removed; absent when saving means unchanged). Tickets go to a team (`team`, also filtered in the list and in MCP search_issues), and a comment mentioning @[Frontend](team-FRONT) tells each of its members. Settings has the teams with their members, the ticket a «Equipo» select, the list a team filter, and «@» offers the teams after the people. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
304 lines
10 KiB
TypeScript
304 lines
10 KiB
TypeScript
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, string> = {
|
|
[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<TrackerEvent> {
|
|
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<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[] = [];
|
|
// 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<string | null> {
|
|
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;
|
|
}
|
|
}
|