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,176 @@
import {
CommentAddedEvent,
IssueAssignedEvent,
IssueStatusChangedEvent,
NotificationKind,
NotificationReadModel,
NotificationSettingsReadModel,
ProjectMemberAddedEvent,
ProjectRole,
StatusCategory,
TrackerEvent,
} from '@task-stream/api/tracker/domain';
import { FakeUserDirectory } from '../../testing/in-memory';
import { ChangeFeed } from '../ports/change-feed';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { NotificationMailer } from '../ports/notification-mailer';
import { NotificationsRepository } from '../ports/notifications.repository';
import { NotificationsEventHandler } from './notifications.event-handler';
import { Notifier } from './notifier';
class InMemoryNotifications extends NotificationsRepository {
readonly all: NotificationReadModel[] = [];
readonly emailOn = new Set<string>();
async insert(n: NotificationReadModel) {
if (this.all.some((x) => x.id === n.id)) return false;
this.all.push(n);
return true;
}
async findForUser(userId: string) {
return this.all.filter((n) => n.userId === userId);
}
async countUnread(userId: string) {
return this.all.filter((n) => n.userId === userId && !n.readAt).length;
}
async markRead() {
return;
}
async settings(userId: string): Promise<NotificationSettingsReadModel> {
return { userId, email: this.emailOn.has(userId) };
}
async saveSettings() {
return;
}
}
const at = <T extends TrackerEvent>(event: T, version = 2): T =>
Object.assign(event, {
aggregateVersion: version,
occurredAt: '2026-10-08T10:00:00.000Z',
});
function setup(mailEnabled = false) {
const repo = new InMemoryNotifications();
const published: NotificationReadModel[] = [];
const mailed: string[] = [];
const users = new FakeUserDirectory().add('ana').add('bob').add('qa');
const issue = {
id: 'i-1',
key: 'WEB-1',
title: 'Login roto',
reporter: { id: 'qa', name: 'QA' },
assignee: { id: 'bob', name: 'Bob' },
};
const notifier = new Notifier(
repo,
{
publishNotification: async (n: NotificationReadModel) => {
published.push(n);
},
} as unknown as ChangeFeed,
{
enabled: mailEnabled,
send: async (to: { email: string }) => {
mailed.push(to.email);
},
} as unknown as NotificationMailer,
users,
);
const handler = new NotificationsEventHandler(
notifier,
{ findById: async () => issue } as unknown as FindIssuesRepository,
{
findById: async () => ({ id: 'p-1', key: 'WEB', name: 'Web' }),
} as unknown as FindProjectsRepository,
users,
);
return { repo, published, mailed, handler };
}
describe('notifications', () => {
it('tells whoever a ticket is assigned to, not whoever assigned it', async () => {
const { handler, repo, published } = setup();
await handler.handle(
at(new IssueAssignedEvent('ana', 'i-1', 'p-1', 'bob', null)),
);
await handler.handle(
at(new IssueAssignedEvent('ana', 'i-1', 'p-1', 'ana', null), 3),
);
expect(repo.all.map((n) => [n.userId, n.kind])).toEqual([
['bob', NotificationKind.ASSIGNED],
]);
expect(published).toHaveLength(1);
expect(repo.all[0].summary).toContain('WEB-1');
});
it('marks a ticket sent back from QA as such, for whoever works on it and reported it', async () => {
const { handler, repo } = setup();
await handler.handle(
at(
new IssueStatusChangedEvent(
'ana',
'i-1',
'p-1',
'IN_QA',
'QA_FAILED',
'Falla en Safari',
{
fromName: 'En QA',
toName: 'QA fallido',
fromCategory: StatusCategory.QA,
toCategory: StatusCategory.IN_PROGRESS,
},
),
),
);
expect(repo.all.map((n) => [n.userId, n.kind])).toEqual([
['bob', NotificationKind.QA_FAILED],
['qa', NotificationKind.QA_FAILED],
]);
expect(repo.all[0].summary).toContain('Falla en Safari');
});
it('tells about a comment everyone but its author, once even when redelivered', async () => {
const { handler, repo } = setup();
const comment = at(
new CommentAddedEvent('bob', {
id: 'c-1',
issueId: 'i-1',
projectId: 'p-1',
authorId: 'bob',
body: 'Ya está arreglado',
deleted: false,
createdAt: '2026-10-08T10:00:00.000Z',
editedAt: null,
}),
1,
);
await handler.handle(comment);
await handler.handle(comment);
expect(repo.all.map((n) => n.userId)).toEqual(['qa']);
});
it('copies by e-mail only to whoever asked for it, when mail is configured', async () => {
const { handler, repo, mailed } = setup(true);
repo.emailOn.add('bob');
await handler.handle(
at(new ProjectMemberAddedEvent('ana', 'p-1', 'bob', ProjectRole.MEMBER)),
);
await handler.handle(
at(
new ProjectMemberAddedEvent('ana', 'p-1', 'qa', ProjectRole.MEMBER),
3,
),
);
expect(mailed).toEqual(['bob@example.org']);
expect(repo.all.map((n) => n.kind)).toEqual([
NotificationKind.ADDED_TO_PROJECT,
NotificationKind.ADDED_TO_PROJECT,
]);
});
});