feat: notifications in the app and, optionally, by e-mail

Derived from events and kept per person: whoever a ticket is assigned to,
whoever reports or works on it when it moves, fails QA or gets a comment,
and whoever joins a project. Never about what you did yourself, once per
event. They arrive live through a subscription to a bell in the header,
and are copied by e-mail to whoever turns it on when SMTP_URL is set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 09:19:59 +02:00
parent 0b65c1c912
commit efa06a0723
60 changed files with 3406 additions and 28 deletions

View File

@@ -0,0 +1,143 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import {
CommentAddedEvent,
IssueAssignedEvent,
IssueCreatedEvent,
IssueStatusChangedEvent,
NotificationKind,
ProjectMemberAddedEvent,
StatusCategory,
TrackerEvent,
} from '@task-stream/api/tracker/domain';
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 = 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.
*/
@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,
) {}
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 drafts: NotificationDraft[] = [];
if (event instanceof IssueCreatedEvent && assigneeId) {
drafts.push({
userId: assigneeId,
kind: NotificationKind.ASSIGNED,
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 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)}` : '';
for (const userId of [assigneeId, reporterId]) {
if (!userId) continue;
drafts.push({
userId,
kind: failedQa
? NotificationKind.QA_FAILED
: NotificationKind.STATUS_CHANGED,
summary: failedQa
? `${actor} devolvió ${ticket} desde QA${reason}`
: `${actor} movió ${ticket} a ${to}${reason}`,
});
}
}
if (event instanceof CommentAddedEvent) {
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)}»`,
});
}
}
await this.notifier.deliver(
event,
{
projectId: project.id,
projectKey: project.key,
issueId: issue.id,
issueKey: issue.key,
},
drafts,
);
}
}