feat: mentions and following tickets

Mention project members in comments with @, follow and unfollow tickets,
and get told when something changes on the tickets you follow. Commenting
on a ticket starts following it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 09:41:32 +02:00
parent efa06a0723
commit 88928b402f
49 changed files with 1318 additions and 41 deletions

View File

@@ -27,6 +27,7 @@ import { DetachIssueFromReleaseCommandHandler } from './detach-issue-from-releas
import { UnassignIssueCommandHandler } from './unassign-issue.command-handler';
import { AttachFileCommandHandler } from './attach-file.command-handler';
import { DetachFileCommandHandler } from './detach-file.command-handler';
import { WatchIssueCommandHandler } from './watch-issue.command-handler';
export const COMMAND_HANDLERS = [
CreateProjectCommandHandler,
@@ -59,4 +60,5 @@ export const COMMAND_HANDLERS = [
UnassignIssueCommandHandler,
AttachFileCommandHandler,
DetachFileCommandHandler,
WatchIssueCommandHandler,
];

View File

@@ -40,6 +40,7 @@ import { AddProjectMemberCommand } from './add-project-member.command';
import { AssignIssueCommand } from './assign-issue.command';
import { AttachFileCommand } from './attach-file.command';
import { DetachFileCommand } from './detach-file.command';
import { WatchIssueCommand } from './watch-issue.command';
import { ChangeIssueStatusCommand } from './change-issue-status.command';
import { COMMAND_HANDLERS } from './command-handlers';
import { CommandResult } from './command-result';
@@ -258,6 +259,28 @@ describe('tracker commands (event sourced)', () => {
});
});
it('lets any member, viewers included, follow a ticket for themselves', async () => {
const { run, rehydrator } = await setup();
const projectId = await projectWithTeam(run);
const issue = await run(
new CreateIssueCommand('qa', projectId, IssueType.BUG, 'Crash', {}),
);
await run(new WatchIssueCommand('pm', issue.id, 'pm'));
await run(new WatchIssueCommand('dev', issue.id, 'dev'));
await run(new WatchIssueCommand('dev', issue.id, 'dev', false));
expect((await rehydrator.rehydrate(issue.id, Issue)).watchers).toEqual([
'pm',
]);
await expect(
run(new WatchIssueCommand('dev', issue.id, 'pm', false)),
).rejects.toThrow(ForbiddenError);
await expect(
run(new WatchIssueCommand('gone', issue.id, 'gone')),
).rejects.toThrow(ForbiddenError);
});
it('does not let viewers modify tickets', async () => {
const { run } = await setup();
const projectId = await projectWithTeam(run);

View File

@@ -28,3 +28,4 @@ export * from './detach-issue-from-release.command';
export * from './unassign-issue.command';
export * from './attach-file.command';
export * from './detach-file.command';
export * from './watch-issue.command';

View File

@@ -0,0 +1,33 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { ForbiddenError } from '@task-stream/api/shared/domain';
import { CommandResult, ack } from './command-result';
import { WatchIssueCommand } from './watch-issue.command';
import { WriteAccess } from './write-access';
/**
* Following is for members, viewers included: reading a ticket is enough to
* want to hear about it. People only follow or unfollow for themselves.
*/
@CommandHandler(WatchIssueCommand)
export class WatchIssueCommandHandler
implements ICommandHandler<WatchIssueCommand>
{
constructor(private readonly access: WriteAccess) {}
async execute(command: WatchIssueCommand): Promise<CommandResult> {
const issue = await this.access.issue(command.issueId);
const project = await this.access.project(issue.projectId);
if (command.actorId !== command.userId) {
throw new ForbiddenError('You can only follow tickets yourself');
}
if (!project.isMember(command.userId)) {
throw new ForbiddenError(
`You are not a member of project ${project.key}`,
);
}
if (command.watching) issue.watch(command.actorId, command.userId);
else issue.unwatch(command.actorId, command.userId);
await issue.commitAndWait();
return ack(issue);
}
}

View File

@@ -0,0 +1,14 @@
import { Command } from '@nestjs/cqrs';
import { CommandResult } from './command-result';
/** `userId` starts (`watching`) or stops following a ticket. */
export class WatchIssueCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly issueId: string,
public readonly userId: string,
public readonly watching = true,
) {
super();
}
}