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:
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user