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

View File

@@ -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');

View File

@@ -33,6 +33,10 @@ export interface IssueFilter {
dueTo?: string;
/** Only tickets in no sprint (wins over `sprintId`). */
noSprint?: boolean;
/** Only the ones the viewer follows (the query handler turns it into `watcherId`). */
watching?: boolean;
/** Only the ones this person follows. */
watcherId?: string;
/** RECENT (default): last updated first. RANK: backlog order. */
sort?: IssueSort;
search?: string;
@@ -44,6 +48,8 @@ export interface IssueFilter {
export interface CrossProjectIssueFilter {
projectIds: string[];
assigneeId?: string;
/** Only the ones this person follows. */
watcherId?: string;
statuses?: string[];
categories?: StatusCategory[];
search?: string;

View File

@@ -15,8 +15,12 @@ export class SearchIssuesQueryHandler
async execute(query: SearchIssuesQuery): Promise<Page<IssueReadModel>> {
await this.access.assertCanView(query.filter.projectId, query.viewerId);
const { watching, ...filter } = query.filter;
// Someone anonymous follows nothing.
if (watching && !query.viewerId) return { items: [], total: 0 };
return this.findIssues.search({
...query.filter,
...filter,
watcherId: watching ? (query.viewerId ?? undefined) : undefined,
limit: Math.min(query.filter.limit, 200),
});
}

View File

@@ -19,6 +19,7 @@ export class SearchMyIssuesQueryHandler
return this.findIssues.searchAcross({
projectIds: projects.map((project) => project.id),
assigneeId: query.filter.assignedToMe ? query.viewerId : undefined,
watcherId: query.filter.watching ? query.viewerId : undefined,
statuses: query.filter.statuses,
categories: query.filter.categories,
search: query.filter.search?.trim().slice(0, 200) || undefined,

View File

@@ -8,6 +8,8 @@ import { Page } from '../ports/find-issues.repository';
export interface MyIssuesFilter {
/** true: assigned to the viewer. false: any ticket of the viewer's projects. */
assignedToMe: boolean;
/** Only the tickets the viewer follows. */
watching?: boolean;
statuses?: string[];
/** e.g. TODO + IN_PROGRESS + QA = still open, whatever the project calls them. */
categories?: StatusCategory[];

View File

@@ -166,6 +166,7 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
trashedAt: null,
};
if (filter.assigneeId) query['assignee.id'] = filter.assigneeId;
if (filter.watcherId) query['watchers.id'] = filter.watcherId;
if (filter.statuses?.length) query['status'] = { $in: filter.statuses };
if (filter.categories?.length)
query['statusCategory'] = { $in: filter.categories };
@@ -431,6 +432,7 @@ function toMongoFilter(filter: IssueFilter): FilterQuery<IssueDoc> {
else if (filter.sprintId) query['sprint.id'] = filter.sprintId;
if (filter.unassigned) query['assignee'] = null;
else if (filter.assigneeId) query['assignee.id'] = filter.assigneeId;
if (filter.watcherId) query['watchers.id'] = filter.watcherId;
if (filter.search?.trim()) {
const pattern = new RegExp(escapeRegex(filter.search.trim()), 'i');
query.$or = [

View File

@@ -508,6 +508,13 @@ export class IssueFilterInput {
@IsOptional()
@IsBoolean()
noSprint?: boolean;
@Field({
nullable: true,
description: 'Only the tickets I follow (none when anonymous)',
})
@IsOptional()
@IsBoolean()
watching?: boolean;
@Field({
nullable: true,
description: 'Due on or after this day (YYYY-MM-DD)',

View File

@@ -98,6 +98,11 @@ export class IssueResolver {
myIssues(
@CurrentUser() me: User,
@Args('assignedToMe', { defaultValue: true }) assignedToMe: boolean,
@Args('watching', {
defaultValue: false,
description: 'Only the tickets I follow',
})
watching: boolean,
@Args('statuses', { type: () => [String], nullable: true })
statuses: string[] | undefined,
@Args('categories', { type: () => [StatusCategory], nullable: true })
@@ -113,6 +118,7 @@ export class IssueResolver {
) {
return this.issuesService.mine(me.id, {
assignedToMe,
watching,
statuses: statuses ?? undefined,
categories: categories ?? undefined,
search: search ?? undefined,

View File

@@ -389,6 +389,16 @@ export class MyIssuesQueryDto extends PageQueryDto {
@IsBoolean()
assignedToMe = true;
@ApiPropertyOptional({
type: Boolean,
description:
'Only the tickets I follow (with assignedToMe=false, whoever they are assigned to)',
})
@IsOptional()
@Transform(toBoolean)
@IsBoolean()
watching?: boolean;
@ApiPropertyOptional({
type: [String],
description: 'Status ids. Repeat the parameter or separate with commas',
@@ -509,6 +519,15 @@ export class IssueSearchQueryDto extends PageQueryDto {
@IsBoolean()
noSprint?: boolean;
@ApiPropertyOptional({
type: Boolean,
description: 'Only the tickets I follow (none without a token)',
})
@IsOptional()
@Transform(toBoolean)
@IsBoolean()
watching?: boolean;
@ApiPropertyOptional({
format: 'date',
description: 'Due on or after this day',

View File

@@ -128,6 +128,7 @@ export class IssuesController {
async mine(@CurrentUser() me: User, @Query() query: MyIssuesQueryDto) {
const page = await this.issues.mine(me.id, {
assignedToMe: query.assignedToMe,
watching: query.watching,
statuses: query.status,
categories: query.category,
search: query.search,
@@ -164,6 +165,7 @@ export class IssuesController {
labels: query.label,
sprintId: query.sprintId,
noSprint: query.noSprint,
watching: query.watching,
dueFrom: query.dueFrom,
dueTo: query.dueTo,
sort: query.sort,