feat: follow an epic with everything under it
«Seguir todo» on a ticket with subtasks (and on each epic of the Epics
page) tells you of it and of every ticket hanging from it, at any depth,
also the ones added later: their moves, assignments, comments and plan
changes, as a follower of each, plus when a ticket is placed under it.
Guests of a private project are left out. Personal, no history (Mongo
`tree_follows`).
API: followTree / unfollowTree, Issue.followingTree, PUT/DELETE
/api/issues/{issue}/follow-tree. MCP: watch_issue with tree.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -40,6 +40,9 @@ export * from './lib/alerts/alert-payloads';
|
||||
export * from './lib/alerts/alerts';
|
||||
export * from './lib/ports/alert-integrations.repository';
|
||||
export * from './lib/alerts.service';
|
||||
export * from './lib/follows/tree-follows';
|
||||
export * from './lib/ports/tree-follows.repository';
|
||||
export * from './lib/tree-follows.service';
|
||||
export * from './lib/tracker-application.module';
|
||||
export * from './lib/ports/presence.store';
|
||||
export * from './lib/presence.service';
|
||||
|
||||
82
libs/api/tracker/application/src/lib/follows/tree-follows.ts
Normal file
82
libs/api/tracker/application/src/lib/follows/tree-follows.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
Command,
|
||||
CommandHandler,
|
||||
ICommandHandler,
|
||||
IQueryHandler,
|
||||
Query,
|
||||
QueryBus,
|
||||
QueryHandler,
|
||||
} from '@nestjs/cqrs';
|
||||
import { NotFoundError } from '@task-stream/api/shared/domain';
|
||||
import { IssueReadModel } from '@task-stream/api/tracker/domain';
|
||||
import { TreeFollowsRepository } from '../ports/tree-follows.repository';
|
||||
import { GetIssuesByIdsQuery } from '../queries/get-issues-by-ids.query';
|
||||
|
||||
/*
|
||||
* Following a ticket with everything under it (an epic, or a ticket split
|
||||
* into subtasks): told of its changes and of every ticket that hangs from
|
||||
* it, at any depth, also the ones created later. Personal, no history
|
||||
* (like pinned tickets).
|
||||
*/
|
||||
|
||||
export class FollowTreeCommand extends Command<void> {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly issueId: string,
|
||||
/** False: stop following it. */
|
||||
public readonly on: boolean,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class IsFollowingTreeQuery extends Query<boolean> {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly issueId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(FollowTreeCommand)
|
||||
export class FollowTreeCommandHandler
|
||||
implements ICommandHandler<FollowTreeCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly follows: TreeFollowsRepository,
|
||||
private readonly queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
async execute(command: FollowTreeCommand): Promise<void> {
|
||||
const { userId, issueId } = command;
|
||||
if (!command.on) return this.follows.unfollow(userId, issueId);
|
||||
const [issue]: IssueReadModel[] = await this.queryBus.execute(
|
||||
new GetIssuesByIdsQuery(userId, [issueId]),
|
||||
);
|
||||
// A ticket I cannot read does not exist, as far as I know.
|
||||
if (!issue || issue.trashedAt) throw new NotFoundError('Issue', issueId);
|
||||
await this.follows.follow({
|
||||
userId,
|
||||
issueId,
|
||||
projectId: issue.projectId,
|
||||
since: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@QueryHandler(IsFollowingTreeQuery)
|
||||
export class IsFollowingTreeQueryHandler
|
||||
implements IQueryHandler<IsFollowingTreeQuery, boolean>
|
||||
{
|
||||
constructor(private readonly follows: TreeFollowsRepository) {}
|
||||
|
||||
execute(query: IsFollowingTreeQuery): Promise<boolean> {
|
||||
return this.follows.isFollowing(query.userId, query.issueId);
|
||||
}
|
||||
}
|
||||
|
||||
export const TREE_FOLLOWS = [
|
||||
FollowTreeCommandHandler,
|
||||
IsFollowingTreeQueryHandler,
|
||||
];
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
CommentAddedEvent,
|
||||
IssueAssignedEvent,
|
||||
IssueCreatedEvent,
|
||||
IssueParentChangedEvent,
|
||||
IssuePriority,
|
||||
IssueSprintChangedEvent,
|
||||
IssueStatusChangedEvent,
|
||||
@@ -24,6 +25,7 @@ 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 { TreeFollowsRepository } from '../ports/tree-follows.repository';
|
||||
import { UserDirectory } from '../ports/user-directory';
|
||||
import { NotificationDraft, Notifier } from './notifier';
|
||||
|
||||
@@ -61,10 +63,12 @@ const excerpt = (text: string, max = 120) => {
|
||||
*/
|
||||
@EventsHandler(
|
||||
IssueCreatedEvent,
|
||||
IssueParentChangedEvent,
|
||||
IssueAssignedEvent,
|
||||
IssueStatusChangedEvent,
|
||||
IssueUpdatedEvent,
|
||||
IssueSprintChangedEvent,
|
||||
IssueParentChangedEvent,
|
||||
CommentAddedEvent,
|
||||
ProjectMemberAddedEvent,
|
||||
)
|
||||
@@ -76,6 +80,7 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
|
||||
private readonly users: UserDirectory,
|
||||
private readonly findComments: FindCommentsRepository,
|
||||
private readonly findSprints: FindSprintsRepository,
|
||||
private readonly treeFollows: TreeFollowsRepository,
|
||||
) {}
|
||||
|
||||
async handle(event: TrackerEvent): Promise<void> {
|
||||
@@ -118,9 +123,23 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
|
||||
const assigneeId =
|
||||
'assigneeId' in issue ? issue.assigneeId : (issue.assignee?.id ?? null);
|
||||
const ticket = `${issue.key}: ${issue.title}`;
|
||||
const watcherIds =
|
||||
'watchers' in issue ? issue.watchers.map((w) => w.id) : [];
|
||||
const memberIds = new Set(project.members.map((m) => m.user.id));
|
||||
// Who follows it with everything under it, or any ticket above it.
|
||||
// Placed under another ticket: its new place counts (the read model may
|
||||
// not have it yet).
|
||||
const parentId =
|
||||
event instanceof IssueParentChangedEvent
|
||||
? event.parentId
|
||||
: 'parent' in issue
|
||||
? (issue.parent?.id ?? null)
|
||||
: null;
|
||||
const tree = await this.treeFollowersOf(issue.id, parentId, project);
|
||||
const watcherIds = [
|
||||
...new Set([
|
||||
...('watchers' in issue ? issue.watchers.map((w) => w.id) : []),
|
||||
...tree.followers,
|
||||
]),
|
||||
];
|
||||
|
||||
const drafts: NotificationDraft[] = [];
|
||||
// A proposal from the public form: whoever runs the project should know.
|
||||
@@ -142,6 +161,16 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
|
||||
});
|
||||
}
|
||||
}
|
||||
// A ticket new under a followed one (created there or moved in): the
|
||||
// only way to hear of it.
|
||||
if (event instanceof IssueParentChangedEvent && event.parentId) {
|
||||
for (const userId of tree.followers)
|
||||
drafts.push({
|
||||
userId,
|
||||
kind: NotificationKind.WATCHED,
|
||||
summary: `${actor} añadió ${ticket} a ${event.parentKey ?? tree.parentKey}`,
|
||||
});
|
||||
}
|
||||
if (event instanceof IssueCreatedEvent && assigneeId) {
|
||||
drafts.push({
|
||||
userId: assigneeId,
|
||||
@@ -271,6 +300,40 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Those following the ticket with its subtree, or any ticket above it
|
||||
* (an epic, a parent), who can read it all: members that are not guests
|
||||
* limited to their own tickets. Also the key of its parent.
|
||||
*/
|
||||
private async treeFollowersOf(
|
||||
issueId: string,
|
||||
parentId: string | null,
|
||||
project: {
|
||||
isPublic: boolean;
|
||||
members: { user: { id: string }; role: ProjectRole }[];
|
||||
},
|
||||
): Promise<{ followers: string[]; parentKey: string | null }> {
|
||||
const ids = [issueId];
|
||||
let parentKey: string | null = null;
|
||||
let next = parentId;
|
||||
// A few levels: epic → story → subtask is as deep as trees go.
|
||||
for (let depth = 0; next && depth < 5 && !ids.includes(next); depth++) {
|
||||
ids.push(next);
|
||||
const parent = await this.findIssues.findById(next);
|
||||
parentKey ??= parent?.key ?? null;
|
||||
next = parent?.parent?.id ?? null;
|
||||
}
|
||||
const readers = new Set(
|
||||
project.members
|
||||
.filter((m) => project.isPublic || m.role !== ProjectRole.GUEST)
|
||||
.map((m) => m.user.id),
|
||||
);
|
||||
const followers = (await this.treeFollows.followersOf(ids)).filter((id) =>
|
||||
readers.has(id),
|
||||
);
|
||||
return { followers, parentKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
IssuePriority,
|
||||
IssueSprintChangedEvent,
|
||||
IssueStatusChangedEvent,
|
||||
IssueParentChangedEvent,
|
||||
IssueUpdatedEvent,
|
||||
NotificationKind,
|
||||
NotificationReadModel,
|
||||
@@ -23,6 +24,7 @@ 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 { TreeFollowsRepository } from '../ports/tree-follows.repository';
|
||||
import { Notifier } from './notifier';
|
||||
import {
|
||||
PushPayload,
|
||||
@@ -62,7 +64,12 @@ const at = <T extends TrackerEvent>(event: T, version = 2): T =>
|
||||
occurredAt: '2026-10-08T10:00:00.000Z',
|
||||
});
|
||||
|
||||
function setup(mailEnabled = false, pushEnabled = false) {
|
||||
function setup(
|
||||
mailEnabled = false,
|
||||
pushEnabled = false,
|
||||
/** Ticket id → who follows it with everything under it. */
|
||||
follows: Record<string, string[]> = {},
|
||||
) {
|
||||
const repo = new InMemoryNotifications();
|
||||
const published: NotificationReadModel[] = [];
|
||||
const mailed: string[] = [];
|
||||
@@ -74,7 +81,9 @@ function setup(mailEnabled = false, pushEnabled = false) {
|
||||
.add('ana')
|
||||
.add('bob')
|
||||
.add('qa')
|
||||
.add('pm');
|
||||
.add('pm')
|
||||
.add('lia')
|
||||
.add('gus');
|
||||
const issue = {
|
||||
id: 'i-1',
|
||||
key: 'WEB-1',
|
||||
@@ -85,7 +94,10 @@ function setup(mailEnabled = false, pushEnabled = false) {
|
||||
{ id: 'pm', name: 'PM' },
|
||||
{ id: 'bob', name: 'Bob' },
|
||||
],
|
||||
parent: { id: 'e-1', key: 'WEB-10', title: 'Checkout' },
|
||||
};
|
||||
/** The epic it hangs from. */
|
||||
const epic = { id: 'e-1', key: 'WEB-10', title: 'Checkout', parent: null };
|
||||
const notifier = new Notifier(
|
||||
repo,
|
||||
{
|
||||
@@ -118,15 +130,23 @@ function setup(mailEnabled = false, pushEnabled = false) {
|
||||
);
|
||||
const handler = new NotificationsEventHandler(
|
||||
notifier,
|
||||
{ findById: async () => issue } as unknown as FindIssuesRepository,
|
||||
{
|
||||
findById: async (id: string) => (id === 'e-1' ? epic : issue),
|
||||
} as unknown as FindIssuesRepository,
|
||||
{
|
||||
findById: async () => ({
|
||||
id: 'p-1',
|
||||
key: 'WEB',
|
||||
name: 'Web',
|
||||
members: ['ana', 'bob', 'qa', 'pm'].map((id) => ({
|
||||
isPublic: false,
|
||||
members: ['ana', 'bob', 'qa', 'pm', 'lia', 'gus'].map((id) => ({
|
||||
user: { id, name: id },
|
||||
role: id === 'ana' ? ProjectRole.OWNER : ProjectRole.MEMBER,
|
||||
role:
|
||||
id === 'ana'
|
||||
? ProjectRole.OWNER
|
||||
: id === 'gus'
|
||||
? ProjectRole.GUEST
|
||||
: ProjectRole.MEMBER,
|
||||
})),
|
||||
}),
|
||||
} as unknown as FindProjectsRepository,
|
||||
@@ -142,11 +162,49 @@ function setup(mailEnabled = false, pushEnabled = false) {
|
||||
findById: async (id: string) =>
|
||||
id === 's-14' ? { id, name: 'Sprint 14' } : null,
|
||||
} as unknown as FindSprintsRepository,
|
||||
{
|
||||
followersOf: async (ids: string[]) => [
|
||||
...new Set(ids.flatMap((id) => follows[id] ?? [])),
|
||||
],
|
||||
} as unknown as TreeFollowsRepository,
|
||||
);
|
||||
return { repo, published, mailed, pushed, devices, forgotten, handler };
|
||||
}
|
||||
|
||||
describe('notifications', () => {
|
||||
it('tells whoever follows the epic of what happens to its tickets, not guests', async () => {
|
||||
const { handler, repo } = setup(false, false, { 'e-1': ['lia', 'gus'] });
|
||||
await handler.handle(
|
||||
at(
|
||||
new IssueStatusChangedEvent(
|
||||
'ana',
|
||||
'i-1',
|
||||
'p-1',
|
||||
'TODO',
|
||||
'IN_PROGRESS',
|
||||
null,
|
||||
),
|
||||
),
|
||||
);
|
||||
const lia = repo.all.filter((n) => n.userId === 'lia');
|
||||
expect(lia.map((n) => n.kind)).toEqual([NotificationKind.WATCHED]);
|
||||
expect(lia[0].summary).toContain('WEB-1');
|
||||
// A guest only sees their own tickets: not told of the epic's.
|
||||
expect(repo.all.some((n) => n.userId === 'gus')).toBe(false);
|
||||
});
|
||||
|
||||
it('tells whoever follows the epic of a ticket added to it', async () => {
|
||||
const { handler, repo } = setup(false, false, { 'e-1': ['lia'] });
|
||||
await handler.handle(
|
||||
at(
|
||||
new IssueParentChangedEvent('ana', 'i-1', 'p-1', 'e-1', 'WEB-10', null),
|
||||
),
|
||||
);
|
||||
expect(repo.all.map((n) => [n.userId, n.summary])).toEqual([
|
||||
['lia', expect.stringContaining('añadió WEB-1: Login roto a WEB-10')],
|
||||
]);
|
||||
});
|
||||
|
||||
it('tells whoever a ticket is assigned to, not whoever assigned it', async () => {
|
||||
const { handler, repo, published } = setup();
|
||||
await handler.handle(
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/** Who follows a ticket with everything under it: personal, no history. */
|
||||
export interface TreeFollow {
|
||||
userId: string;
|
||||
issueId: string;
|
||||
projectId: string;
|
||||
since: string;
|
||||
}
|
||||
|
||||
export abstract class TreeFollowsRepository {
|
||||
/** Following it again keeps the first date. */
|
||||
abstract follow(follow: TreeFollow): Promise<void>;
|
||||
abstract unfollow(userId: string, issueId: string): Promise<void>;
|
||||
abstract isFollowing(userId: string, issueId: string): Promise<boolean>;
|
||||
/** Everyone following any of these tickets (each once). */
|
||||
abstract followersOf(issueIds: string[]): Promise<string[]>;
|
||||
}
|
||||
@@ -37,6 +37,8 @@ import { SAVED_FILTERS } from './saved-filters/saved-filters';
|
||||
import { MY_VIEWS } from './saved-filters/my-views';
|
||||
import { PINS } from './saved-filters/pins';
|
||||
import { ALERT_HANDLERS } from './alerts/alerts';
|
||||
import { TREE_FOLLOWS } from './follows/tree-follows';
|
||||
import { TreeFollowsService } from './tree-follows.service';
|
||||
import { AlertsService } from './alerts.service';
|
||||
import { PinsService } from './pins.service';
|
||||
import { SendDueRemindersCommandHandler } from './notifications/reminders';
|
||||
@@ -84,6 +86,7 @@ const SERVICES = [
|
||||
SavedFiltersService,
|
||||
PinsService,
|
||||
AlertsService,
|
||||
TreeFollowsService,
|
||||
SprintsService,
|
||||
GitService,
|
||||
WebhooksService,
|
||||
@@ -116,6 +119,7 @@ const SERVICES = [
|
||||
...MY_VIEWS,
|
||||
...PINS,
|
||||
...ALERT_HANDLERS,
|
||||
...TREE_FOLLOWS,
|
||||
...GIT_HANDLERS,
|
||||
...WEBHOOK_HANDLERS,
|
||||
...TRANSFER_HANDLERS,
|
||||
|
||||
24
libs/api/tracker/application/src/lib/tree-follows.service.ts
Normal file
24
libs/api/tracker/application/src/lib/tree-follows.service.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { CommandRunner } from '@task-stream/api/shared/application';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { QueryBus } from '@nestjs/cqrs';
|
||||
import {
|
||||
FollowTreeCommand,
|
||||
IsFollowingTreeQuery,
|
||||
} from './follows/tree-follows';
|
||||
|
||||
/** Following a ticket with everything under it (an epic). */
|
||||
@Injectable()
|
||||
export class TreeFollowsService {
|
||||
constructor(
|
||||
private readonly commands: CommandRunner,
|
||||
private readonly queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
follow(userId: string, issueId: string, on: boolean): Promise<void> {
|
||||
return this.commands.execute(new FollowTreeCommand(userId, issueId, on));
|
||||
}
|
||||
|
||||
isFollowing(userId: string, issueId: string): Promise<boolean> {
|
||||
return this.queryBus.execute(new IsFollowingTreeQuery(userId, issueId));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user