feat: tickets I follow, and more news for followers
"Que sigo" on the home page lists the open tickets I follow, whoever works on them; `watching` filters my issues (REST, GraphQL, MCP) and a project's search (none without a session). Followers now also hear when a ticket's priority or due date changes, and when it enters or leaves a sprint. Smaller edits (title, labels…) still do not notify. Docs (with the REPLIED row the replies change missed), Word and PDF updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,10 @@ import {
|
||||
CommentAddedEvent,
|
||||
IssueAssignedEvent,
|
||||
IssueCreatedEvent,
|
||||
IssuePriority,
|
||||
IssueSprintChangedEvent,
|
||||
IssueStatusChangedEvent,
|
||||
IssueUpdatedEvent,
|
||||
mentionedUserIds,
|
||||
NotificationKind,
|
||||
ProjectMemberAddedEvent,
|
||||
@@ -14,11 +17,30 @@ import {
|
||||
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;
|
||||
@@ -35,6 +57,8 @@ const excerpt = (text: string, max = 120) => {
|
||||
IssueCreatedEvent,
|
||||
IssueAssignedEvent,
|
||||
IssueStatusChangedEvent,
|
||||
IssueUpdatedEvent,
|
||||
IssueSprintChangedEvent,
|
||||
CommentAddedEvent,
|
||||
ProjectMemberAddedEvent,
|
||||
)
|
||||
@@ -45,6 +69,7 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
|
||||
private readonly findProjects: FindProjectsRepository,
|
||||
private readonly users: UserDirectory,
|
||||
private readonly findComments: FindCommentsRepository,
|
||||
private readonly findSprints: FindSprintsRepository,
|
||||
) {}
|
||||
|
||||
async handle(event: TrackerEvent): Promise<void> {
|
||||
@@ -183,6 +208,14 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
{
|
||||
@@ -194,4 +227,34 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import {
|
||||
CommentAddedEvent,
|
||||
IssueAssignedEvent,
|
||||
IssuePriority,
|
||||
IssueSprintChangedEvent,
|
||||
IssueStatusChangedEvent,
|
||||
IssueUpdatedEvent,
|
||||
NotificationKind,
|
||||
NotificationReadModel,
|
||||
NotificationSettingsReadModel,
|
||||
@@ -14,6 +17,7 @@ import { FakeUserDirectory } from '../../testing/in-memory';
|
||||
import { ChangeFeed } from '../ports/change-feed';
|
||||
import { FindCommentsRepository } from '../ports/find-comments.repository';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import { FindSprintsRepository } from '../ports/find-sprints.repository';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
import { NotificationMailer } from '../ports/notification-mailer';
|
||||
import { NotificationsRepository } from '../ports/notifications.repository';
|
||||
@@ -108,6 +112,10 @@ function setup(mailEnabled = false) {
|
||||
? { id, author: { id: 'pm', name: 'PM' }, deleted: false }
|
||||
: null,
|
||||
} as unknown as FindCommentsRepository,
|
||||
{
|
||||
findById: async (id: string) =>
|
||||
id === 's-14' ? { id, name: 'Sprint 14' } : null,
|
||||
} as unknown as FindSprintsRepository,
|
||||
);
|
||||
return { repo, published, mailed, handler };
|
||||
}
|
||||
@@ -236,6 +244,60 @@ describe('notifications', () => {
|
||||
expect(repo.all[0].summary).toContain('respondió a tu comentario');
|
||||
});
|
||||
|
||||
it('tells followers of a new priority, due date or sprint, not of smaller edits', async () => {
|
||||
const { handler, repo } = setup();
|
||||
await handler.handle(
|
||||
at(
|
||||
new IssueUpdatedEvent('ana', 'i-1', 'p-1', {
|
||||
priority: IssuePriority.HIGH,
|
||||
dueDate: '2026-11-14',
|
||||
}),
|
||||
),
|
||||
);
|
||||
await handler.handle(
|
||||
at(new IssueUpdatedEvent('ana', 'i-1', 'p-1', { title: 'Login' }), 3),
|
||||
);
|
||||
await handler.handle(
|
||||
at(new IssueSprintChangedEvent('ana', 'i-1', 'p-1', 's-14', null), 4),
|
||||
);
|
||||
await handler.handle(
|
||||
at(new IssueSprintChangedEvent('ana', 'i-1', 'p-1', null, 's-14'), 5),
|
||||
);
|
||||
|
||||
expect(repo.all.map((n) => [n.userId, n.kind, n.summary])).toEqual([
|
||||
[
|
||||
'pm',
|
||||
NotificationKind.WATCHED,
|
||||
'user ana cambió WEB-1: Login roto: prioridad Alta, fecha límite el 14 de noviembre de 2026',
|
||||
],
|
||||
[
|
||||
'bob',
|
||||
NotificationKind.WATCHED,
|
||||
'user ana cambió WEB-1: Login roto: prioridad Alta, fecha límite el 14 de noviembre de 2026',
|
||||
],
|
||||
[
|
||||
'pm',
|
||||
NotificationKind.WATCHED,
|
||||
'user ana metió WEB-1: Login roto en el sprint Sprint 14',
|
||||
],
|
||||
[
|
||||
'bob',
|
||||
NotificationKind.WATCHED,
|
||||
'user ana metió WEB-1: Login roto en el sprint Sprint 14',
|
||||
],
|
||||
[
|
||||
'pm',
|
||||
NotificationKind.WATCHED,
|
||||
'user ana sacó WEB-1: Login roto del sprint y lo devolvió al backlog',
|
||||
],
|
||||
[
|
||||
'bob',
|
||||
NotificationKind.WATCHED,
|
||||
'user ana sacó WEB-1: Login roto del sprint y lo devolvió al backlog',
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
Reference in New Issue
Block a user