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

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