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:
2026-10-09 11:29:17 +02:00
parent ea4ef82239
commit f6417248c7
29 changed files with 636 additions and 11573 deletions

View File

@@ -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;
}
}