feat: whoever opens or gets a ticket follows it

Like a commenter, whoever opens a ticket and whoever it is assigned to
(also on reassigning) follow it from then on, so they hear of its
priority, due date and sprint changes and find it under "Que sigo".
Existing tickets are left as they are. Docs, Word and PDF updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 13:29:28 +02:00
parent 5ae1d05d30
commit f986f17968
8 changed files with 95 additions and 2 deletions

View File

@@ -0,0 +1,47 @@
import {
IssueAssignedEvent,
IssueCreatedEvent,
IssuePriority,
} from '@task-stream/api/tracker/domain';
import { lastValueFrom, of, toArray } from 'rxjs';
import { WatchIssueCommand } from '../commands/watch-issue.command';
import { PeopleFollowSaga } from './people-follow.saga';
describe('PeopleFollowSaga', () => {
const saga = new PeopleFollowSaga();
const created = (assigneeId: string | null) =>
new IssueCreatedEvent('ana', {
id: 'i-1',
projectId: 'p-1',
reporterId: 'ana',
assigneeId,
priority: IssuePriority.MEDIUM,
} as never);
it('makes whoever opens a ticket, and whoever gets it, follow it', async () => {
expect(
await lastValueFrom(saga.created(of(created('bob'))).pipe(toArray())),
).toEqual([
new WatchIssueCommand('ana', 'i-1', 'ana'),
new WatchIssueCommand('bob', 'i-1', 'bob'),
]);
// Opening a ticket for oneself: once.
expect(
await lastValueFrom(saga.created(of(created('ana'))).pipe(toArray())),
).toEqual([new WatchIssueCommand('ana', 'i-1', 'ana')]);
});
it('makes whoever a ticket is assigned to follow it, not when unassigned', async () => {
const commands = await lastValueFrom(
saga
.assigned(
of(
new IssueAssignedEvent('ana', 'i-1', 'p-1', 'bob', null),
new IssueAssignedEvent('ana', 'i-1', 'p-1', null, 'bob'),
),
)
.pipe(toArray()),
);
expect(commands).toEqual([new WatchIssueCommand('bob', 'i-1', 'bob')]);
});
});

View File

@@ -0,0 +1,42 @@
import { Injectable } from '@nestjs/common';
import { ICommand, IEvent, ofType, Saga } from '@nestjs/cqrs';
import {
IssueAssignedEvent,
IssueCreatedEvent,
} from '@task-stream/api/tracker/domain';
import { filter, mergeMap, Observable } from 'rxjs';
import { WatchIssueCommand } from '../commands/watch-issue.command';
/** Follows a ticket as `userId` would by pressing "Seguir". */
const follow = (userId: string, issueId: string) =>
new WatchIssueCommand(userId, issueId, userId);
/**
* Whoever opens a ticket and whoever it is assigned to follow it from then
* on, as a commenter does: its changes concern them. Unfollowing stays
* theirs to do. (Idempotent: following twice changes nothing; someone who
* is not a member, like the system, is refused and skipped.)
*/
@Injectable()
export class PeopleFollowSaga {
@Saga()
created = (events$: Observable<IEvent>): Observable<ICommand> =>
events$.pipe(
ofType(IssueCreatedEvent),
mergeMap(({ issue }) =>
[...new Set([issue.reporterId, issue.assigneeId])]
.filter((userId): userId is string => !!userId)
.map((userId) => follow(userId, issue.id)),
),
);
@Saga()
assigned = (events$: Observable<IEvent>): Observable<ICommand> =>
events$.pipe(
ofType(IssueAssignedEvent),
filter((event) => !!event.assigneeId),
mergeMap((event) => [
follow(event.assigneeId as string, event.aggregateId),
]),
);
}

View File

@@ -2,6 +2,7 @@ import { ProjectDeletionSaga } from './project-deletion.saga';
import { ReleaseArchivedSaga } from './release-archived.saga';
import { UserDeactivatedSaga } from './user-deactivated.saga';
import { CommentersFollowSaga } from './commenters-follow.saga';
import { PeopleFollowSaga } from './people-follow.saga';
import { SprintCarryOverSaga } from './sprint-carry-over.saga';
/** Reactions to events that change other aggregates (run by the events bridge). */
@@ -10,5 +11,6 @@ export const SAGAS = [
ReleaseArchivedSaga,
UserDeactivatedSaga,
CommentersFollowSaga,
PeopleFollowSaga,
SprintCarryOverSaga,
];