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:
2026-10-20 19:59:04 +02:00
parent 179d6a5e94
commit d79ef418d3
31 changed files with 1072 additions and 62 deletions

View File

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

View 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,
];

View File

@@ -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.

View File

@@ -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(

View File

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

View File

@@ -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,

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

View File

@@ -109,6 +109,7 @@ export const ReadModels = {
PinnedIssue: 'PinnedIssue',
AlertIntegration: 'AlertIntegration',
AlertTrace: 'AlertTrace',
TreeFollow: 'TreeFollow',
Sprint: 'SprintReadModel',
GitIntegration: 'GitIntegration',
OutgoingWebhook: 'OutgoingWebhook',
@@ -360,6 +361,15 @@ export const AlertTraceSchema =
SchemaFactory.createForClass(AlertTraceDocument);
AlertTraceSchema.index({ projectId: 1, fingerprint: 1 }, { unique: true });
@Schema({ ...LOOSE, collection: 'tree_follows' })
class TreeFollowDocument {
@Prop({ type: String }) userId!: string;
@Prop({ type: String, index: true }) issueId!: string;
}
export const TreeFollowSchema =
SchemaFactory.createForClass(TreeFollowDocument);
TreeFollowSchema.index({ userId: 1, issueId: 1 }, { unique: true });
@Schema({ ...LOOSE, collection: 'git_integrations' })
class GitIntegrationDocument {
/** The project's id. */
@@ -465,6 +475,7 @@ export const READ_MODEL_SCHEMAS = [
{ name: ReadModels.PinnedIssue, schema: PinnedIssueSchema },
{ name: ReadModels.AlertIntegration, schema: AlertIntegrationSchema },
{ name: ReadModels.AlertTrace, schema: AlertTraceSchema },
{ name: ReadModels.TreeFollow, schema: TreeFollowSchema },
{ name: ReadModels.CalendarFeed, schema: CalendarFeedSchema },
{ name: ReadModels.DashboardShare, schema: DashboardShareSchema },
{ name: ReadModels.PushSubscription, schema: PushSubscriptionSchema },

View File

@@ -0,0 +1,39 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import {
TreeFollow,
TreeFollowsRepository,
} from '@task-stream/api/tracker/application';
import { Model } from 'mongoose';
import { ReadModels } from './read-model.schemas';
@Injectable()
export class MongoTreeFollowsRepository extends TreeFollowsRepository {
constructor(
@InjectModel(ReadModels.TreeFollow)
private readonly follows: Model<TreeFollow>,
) {
super();
}
async follow(follow: TreeFollow): Promise<void> {
await this.follows.updateOne(
{ userId: follow.userId, issueId: follow.issueId },
{ $setOnInsert: follow },
{ upsert: true },
);
}
async unfollow(userId: string, issueId: string): Promise<void> {
await this.follows.deleteOne({ userId, issueId });
}
async isFollowing(userId: string, issueId: string): Promise<boolean> {
return !!(await this.follows.exists({ userId, issueId }));
}
async followersOf(issueIds: string[]): Promise<string[]> {
if (!issueIds.length) return [];
return this.follows.distinct('userId', { issueId: { $in: issueIds } });
}
}

View File

@@ -15,6 +15,7 @@ import {
MyViewsRepository,
PinsRepository,
AlertIntegrationsRepository,
TreeFollowsRepository,
GitIntegrationsRepository,
OutgoingWebhooksRepository,
AutomationsRepository,
@@ -82,6 +83,7 @@ import { MongoSavedFiltersRepository } from './mongo/saved-filters.repository';
import { MongoMyViewsRepository } from './mongo/my-views.repository';
import { MongoPinsRepository } from './mongo/pins.repository';
import { MongoAlertIntegrationsRepository } from './mongo/alert-integrations.repository';
import { MongoTreeFollowsRepository } from './mongo/tree-follows.repository';
import { MongoGitIntegrationsRepository } from './mongo/git-integrations.repository';
import { MongoOutgoingWebhooksRepository } from './mongo/outgoing-webhooks.repository';
import { HttpWebhookSender } from './webhooks/http-webhook.sender';
@@ -163,6 +165,7 @@ const PORTS = [
provide: AlertIntegrationsRepository,
useClass: MongoAlertIntegrationsRepository,
},
{ provide: TreeFollowsRepository, useClass: MongoTreeFollowsRepository },
{
provide: GitIntegrationsRepository,
useClass: MongoGitIntegrationsRepository,

View File

@@ -18,6 +18,7 @@ import {
CommentsService,
IssuesService,
PinsService,
TreeFollowsService,
PresenceService,
WorkflowsService,
} from '@task-stream/api/tracker/application';
@@ -63,6 +64,7 @@ export class IssueResolver {
private readonly blockers: BlockersService,
private readonly presence: PresenceService,
private readonly pins: PinsService,
private readonly treeFollows: TreeFollowsService,
) {}
@Mutation(() => [PresenceGql], {
@@ -280,6 +282,17 @@ export class IssueResolver {
return this.commentsService.decisions(viewerOf(me), issue.id);
}
@ResolveField(() => Boolean, {
description:
'Whether I follow it with everything under it (false without a token)',
})
followingTree(
@CurrentUser() me: User | undefined,
@Parent() issue: IssueReadModel,
): Promise<boolean> | boolean {
return !!me && this.treeFollows.isFollowing(me.id, issue.id);
}
@ResolveField(() => Boolean, {
description: 'Whether I pinned it (false without a token)',
})
@@ -391,6 +404,29 @@ export class IssueResolver {
return true;
}
@Mutation(() => Boolean, {
description:
'Follow a ticket with everything under it (an epic): its changes and those of every ticket hanging from it, also future ones',
})
async followTree(
@CurrentUser() me: User,
@Args('issueId', { type: () => ID }) issueId: string,
): Promise<boolean> {
await this.treeFollows.follow(me.id, issueId, true);
return true;
}
@Mutation(() => Boolean, {
description: 'Stop following a ticket with everything under it',
})
async unfollowTree(
@CurrentUser() me: User,
@Args('issueId', { type: () => ID }) issueId: string,
): Promise<boolean> {
await this.treeFollows.follow(me.id, issueId, false);
return true;
}
@Mutation(() => CommandResultGql, { description: 'Stop following a ticket' })
unwatchIssue(
@CurrentUser() me: User,

View File

@@ -33,6 +33,7 @@ import {
CommentsService,
IssuesService,
PinsService,
TreeFollowsService,
PresenceService,
ProjectsService,
} from '@task-stream/api/tracker/application';
@@ -99,6 +100,7 @@ export class IssuesController {
private readonly presence: PresenceService,
private readonly projects: ProjectsService,
private readonly pins: PinsService,
private readonly treeFollows: TreeFollowsService,
) {}
@Get('me/home')
@@ -203,6 +205,38 @@ export class IssuesController {
await this.pins.pin(me.id, await this.refs.issueId(me.id, issue), false);
}
@Put('issues/:issue/follow-tree')
@HttpCode(204)
@ISSUE
@ApiOperation({
summary: 'Follow a ticket with everything under it (an epic)',
description:
'Told of its changes and of every ticket hanging from it, at any depth, also the ones added later. Private to me.',
})
@ApiNoContentResponse()
@ApiCommandErrors()
async followTree(@CurrentUser() me: User, @Param('issue') issue: string) {
await this.treeFollows.follow(
me.id,
await this.refs.issueId(me.id, issue),
true,
);
}
@Delete('issues/:issue/follow-tree')
@HttpCode(204)
@ISSUE
@ApiOperation({ summary: 'Stop following a ticket with everything under it' })
@ApiNoContentResponse()
@ApiCommandErrors()
async unfollowTree(@CurrentUser() me: User, @Param('issue') issue: string) {
await this.treeFollows.follow(
me.id,
await this.refs.issueId(me.id, issue),
false,
);
}
@Put('issues/:issue/watch')
@ISSUE
@ApiOperation({