feat: mentions and following tickets

Mention project members in comments with @, follow and unfollow tickets,
and get told when something changes on the tickets you follow. Commenting
on a ticket starts following it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 09:41:32 +02:00
parent efa06a0723
commit 88928b402f
49 changed files with 1318 additions and 41 deletions

View File

@@ -54,13 +54,21 @@ 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 users = new FakeUserDirectory()
.add('ana')
.add('bob')
.add('qa')
.add('pm');
const issue = {
id: 'i-1',
key: 'WEB-1',
title: 'Login roto',
reporter: { id: 'qa', name: 'QA' },
assignee: { id: 'bob', name: 'Bob' },
watchers: [
{ id: 'pm', name: 'PM' },
{ id: 'bob', name: 'Bob' },
],
};
const notifier = new Notifier(
repo,
@@ -81,7 +89,15 @@ function setup(mailEnabled = false) {
notifier,
{ findById: async () => issue } as unknown as FindIssuesRepository,
{
findById: async () => ({ id: 'p-1', key: 'WEB', name: 'Web' }),
findById: async () => ({
id: 'p-1',
key: 'WEB',
name: 'Web',
members: ['ana', 'bob', 'qa', 'pm'].map((id) => ({
user: { id, name: id },
role: ProjectRole.MEMBER,
})),
}),
} as unknown as FindProjectsRepository,
users,
);
@@ -100,8 +116,12 @@ describe('notifications', () => {
expect(repo.all.map((n) => [n.userId, n.kind])).toEqual([
['bob', NotificationKind.ASSIGNED],
// Bob follows the ticket too, but is told once: as the assignee.
['pm', NotificationKind.WATCHED],
['pm', NotificationKind.WATCHED],
['bob', NotificationKind.WATCHED],
]);
expect(published).toHaveLength(1);
expect(published).toHaveLength(4);
expect(repo.all[0].summary).toContain('WEB-1');
});
@@ -129,6 +149,7 @@ describe('notifications', () => {
expect(repo.all.map((n) => [n.userId, n.kind])).toEqual([
['bob', NotificationKind.QA_FAILED],
['qa', NotificationKind.QA_FAILED],
['pm', NotificationKind.WATCHED],
]);
expect(repo.all[0].summary).toContain('Falla en Safari');
});
@@ -151,7 +172,32 @@ describe('notifications', () => {
await handler.handle(comment);
await handler.handle(comment);
expect(repo.all.map((n) => n.userId)).toEqual(['qa']);
expect(repo.all.map((n) => n.userId)).toEqual(['qa', 'pm']);
});
it('tells a mentioned member first, and nobody outside the project', async () => {
const { handler, repo } = setup();
await handler.handle(
at(
new CommentAddedEvent('bob', {
id: 'c-2',
issueId: 'i-1',
projectId: 'p-1',
authorId: 'bob',
body: '@[PM](pm) mira esto, y @[Fuera](stranger) también',
deleted: false,
createdAt: '2026-10-08T10:00:00.000Z',
editedAt: null,
}),
1,
),
);
expect(repo.all.map((n) => [n.userId, n.kind])).toEqual([
['pm', NotificationKind.MENTIONED],
['qa', NotificationKind.COMMENTED],
]);
expect(repo.all[0].summary).toContain('«@PM mira esto');
});
it('copies by e-mail only to whoever asked for it, when mail is configured', async () => {