Files
task-stream/libs/api/tracker/application/src/lib/issues.service.ts
Josep Piguillem 93d028e870 feat: ask a teammate to review a ticket
«Pedir revisión» on a ticket asks a member (with an optional note): they
are notified and see it under «Te piden revisión» in my work until they
answer «Todo bien» or «Pedir cambios» (with what to change) from the
ticket's Revisiones box; whoever asked hears the answer and can withdraw
an open request. Events of the ticket (requested, answered, cancelled),
so they show in its history and the activity feed. Viewers can be asked
and answer; not oneself, nor twice the same person with one pending.

API: requestReview / answerReview / cancelReview, Issue.reviews,
myIssues(reviewing); POST /api/issues/{issue}/reviews,
POST …/reviews/{id}/answer, DELETE …/reviews/{id}. MCP: request_review,
answer_review, my_issues reviewing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 20:38:30 +02:00

491 lines
15 KiB
TypeScript

import { CommandRunner } from '@task-stream/api/shared/application';
import { Injectable } from '@nestjs/common';
import { QueryBus } from '@nestjs/cqrs';
import { assertThat } from '@task-stream/api/shared/domain';
import {
ActivityReadModel,
EpicReadModel,
IssueDetails,
IssueLinkType,
IssuePriority,
IssueReadModel,
ReviewVerdict,
SYSTEM_ACTOR_ID,
} from '@task-stream/api/tracker/domain';
import { BulkChange, BulkResult, MAX_BULK } from './bulk-change';
import { AssignIssueCommand } from './commands/assign-issue.command';
import { VoteIssueCommand } from './commands/vote-issue.command';
import {
AttachFileCommand,
AttachmentResult,
IncomingFile,
} from './commands/attach-file.command';
import { DetachFileCommand } from './commands/detach-file.command';
import { WatchIssueCommand } from './commands/watch-issue.command';
import {
AnswerReviewCommand,
CancelReviewCommand,
RequestReviewCommand,
} from './commands/reviews';
import { SetIssueParentCommand } from './commands/set-issue-parent.command';
import { RankIssueCommand } from './commands/rank-issue.command';
import { DeleteIssueCommand } from './commands/delete-issue.command';
import { RestoreIssueCommand, TrashIssueCommand } from './commands/trash';
import { SetIssueSprintCommand } from './commands/sprint.commands';
import { WriteAccess } from './commands/write-access';
import { FindIssuesRepository } from './ports/find-issues.repository';
import { GetTrashQuery } from './queries/get-trash.query';
import {
AddChecklistItemCommand,
RemoveChecklistItemCommand,
UpdateChecklistItemCommand,
} from './commands/change-checklist.command';
import { ChangeIssueStatusCommand } from './commands/change-issue-status.command';
import { CommandResult } from './commands/command-result';
import { CreateIssueCommand } from './commands/create-issue.command';
import { LinkIssuesCommand } from './commands/link-issues.command';
import { SetIssueReleaseCommand } from './commands/set-issue-release.command';
import { UnlinkIssuesCommand } from './commands/unlink-issues.command';
import { UpdateIssueCommand } from './commands/update-issue.command';
import { IssueFilter, Page } from './ports/find-issues.repository';
import { GetActivityQuery } from './queries/get-activity.query';
import {
AttachmentDownload,
GetAttachmentQuery,
} from './queries/get-attachment.query';
import { GetIssueQuery } from './queries/get-issue.query';
import { GetIssuesByIdsQuery } from './queries/get-issues-by-ids.query';
import { SearchIssuesQuery } from './queries/search-issues.query';
import { GetEpicsQuery } from './queries/get-epics.query';
import {
MyIssuesFilter,
SearchMyIssuesQuery,
} from './queries/search-my-issues.query';
export interface CreateIssueInput extends Omit<IssueDetails, 'title' | 'type'> {
projectId: string;
/** A type id of the project's settings. */
type: string;
title: string;
assigneeId?: string | null;
releaseId?: string | null;
/** Epic or ticket the new one breaks down. */
parentId?: string | null;
/** Imports only: start in this (active) status instead of the first one. */
initialStatus?: string | null;
/** Fill in the type's template (false when the content comes with it). */
useTemplate?: boolean;
}
export interface IssueLinkInput {
issueId: string;
type: IssueLinkType;
targetIssueId: string;
}
@Injectable()
export class IssuesService {
constructor(
private readonly commands: CommandRunner,
private readonly queryBus: QueryBus,
private readonly access: WriteAccess,
private readonly findIssues: FindIssuesRepository,
) {}
create(actorId: string, input: CreateIssueInput): Promise<CommandResult> {
const {
projectId,
type,
title,
assigneeId,
releaseId,
parentId,
initialStatus,
useTemplate,
...details
} = input;
return this.commands.execute(
new CreateIssueCommand(
actorId,
projectId,
type,
title,
details,
assigneeId,
releaseId,
parentId,
initialStatus,
useTemplate ?? true,
),
);
}
update(
actorId: string,
issueId: string,
changes: IssueDetails,
): Promise<CommandResult> {
return this.commands.execute(
new UpdateIssueCommand(actorId, issueId, changes),
);
}
changeStatus(
actorId: string,
issueId: string,
status: string,
reason?: string | null,
): Promise<CommandResult> {
return this.commands.execute(
new ChangeIssueStatusCommand(actorId, issueId, status, reason),
);
}
/** @param assigneeId null to unassign */
assign(
actorId: string,
issueId: string,
assigneeId: string | null,
): Promise<CommandResult> {
return this.commands.execute(
new AssignIssueCommand(actorId, issueId, assigneeId),
);
}
link(actorId: string, input: IssueLinkInput): Promise<CommandResult> {
return this.commands.execute(
new LinkIssuesCommand(
actorId,
input.issueId,
input.type,
input.targetIssueId,
),
);
}
unlink(actorId: string, input: IssueLinkInput): Promise<CommandResult> {
return this.commands.execute(
new UnlinkIssuesCommand(
actorId,
input.issueId,
input.type,
input.targetIssueId,
),
);
}
/** @param releaseId null to take the ticket out of its release */
setRelease(
actorId: string,
issueId: string,
releaseId: string | null,
): Promise<CommandResult> {
return this.commands.execute(
new SetIssueReleaseCommand(actorId, issueId, releaseId),
);
}
/** Attaches a file to the ticket, or to one of its comments. */
attach(
actorId: string,
issueId: string,
file: IncomingFile,
commentId: string | null = null,
): Promise<AttachmentResult> {
return this.commands.execute(
new AttachFileCommand(actorId, issueId, file, commentId),
);
}
detach(
actorId: string,
issueId: string,
attachmentId: string,
): Promise<CommandResult> {
return this.commands.execute(
new DetachFileCommand(actorId, issueId, attachmentId),
);
}
/** The actor follows (or stops following) the ticket. */
watch(
actorId: string,
issueId: string,
watching: boolean,
): Promise<CommandResult> {
return this.commands.execute(
new WatchIssueCommand(actorId, issueId, actorId, watching),
);
}
/** Asks `reviewerId` (a member) to review the ticket. */
requestReview(
actorId: string,
issueId: string,
reviewerId: string,
note: string | null,
): Promise<CommandResult> {
return this.commands.execute(
new RequestReviewCommand(actorId, issueId, reviewerId, note),
);
}
/** The person asked answers: all good, or changes wanted. */
answerReview(
actorId: string,
issueId: string,
reviewId: string,
verdict: ReviewVerdict,
answer: string | null,
): Promise<CommandResult> {
return this.commands.execute(
new AnswerReviewCommand(actorId, issueId, reviewId, verdict, answer),
);
}
cancelReview(
actorId: string,
issueId: string,
reviewId: string,
): Promise<CommandResult> {
return this.commands.execute(
new CancelReviewCommand(actorId, issueId, reviewId),
);
}
/** A ticket's file, with its bytes, for whoever can read the ticket. */
download(
viewerId: string | null,
issueId: string,
attachmentId: string,
): Promise<AttachmentDownload> {
return this.queryBus.execute(
new GetAttachmentQuery(viewerId, issueId, attachmentId),
);
}
findOne(viewerId: string | null, idOrKey: string): Promise<IssueReadModel> {
return this.queryBus.execute(new GetIssueQuery(viewerId, idOrKey));
}
findMany(viewerId: string | null, ids: string[]): Promise<IssueReadModel[]> {
return this.queryBus.execute(new GetIssuesByIdsQuery(viewerId, ids));
}
/** Places the ticket under `parentId` (null: back to the top). */
setParent(
actorId: string,
issueId: string,
parentId: string | null,
): Promise<CommandResult> {
return this.commands.execute(
new SetIssueParentCommand(actorId, issueId, parentId),
);
}
/** Moves a ticket in the backlog between `above` and `below` (null: an end). */
rank(
actorId: string,
issueId: string,
above: string | null,
below: string | null,
): Promise<CommandResult> {
return this.commands.execute(
new RankIssueCommand(actorId, issueId, above, below),
);
}
/**
* The same change on several tickets. Each ticket goes through the normal
* commands (permissions, workflow, open sprints and releases), one after
* another; one refusing does not stop the rest.
*/
async bulkUpdate(
actorId: string,
issueIds: string[],
change: BulkChange,
): Promise<BulkResult> {
const ids = [...new Set(issueIds)];
assertThat(ids.length > 0, 'Pick at least one ticket');
assertThat(ids.length <= MAX_BULK, `At most ${MAX_BULK} tickets at once`);
assertThat(
Object.values(change).some((v) => v !== undefined),
'Nothing to change',
);
const result: BulkResult = { updated: 0, results: [] };
for (const issueId of ids) {
try {
if (change.status !== undefined)
await this.changeStatus(actorId, issueId, change.status);
if (change.assigneeId !== undefined)
await this.assign(actorId, issueId, change.assigneeId);
if (change.sprintId !== undefined)
await this.commands.execute(
new SetIssueSprintCommand(actorId, issueId, change.sprintId),
);
if (change.releaseId !== undefined)
await this.setRelease(actorId, issueId, change.releaseId);
const details: { priority?: IssuePriority; labels?: string[] } = {};
if (change.priority) details.priority = change.priority;
if (change.addLabels?.length) {
const issue = await this.access.issue(issueId);
details.labels = [...issue.labels, ...change.addLabels];
}
if (Object.keys(details).length)
await this.update(actorId, issueId, details);
result.updated++;
result.results.push({ issueId, ok: true, error: null });
} catch (error) {
result.results.push({
issueId,
ok: false,
error: error instanceof Error ? error.message : String(error),
});
}
}
return result;
}
/** To the trash (whoever opened it, or a manager); back with `restore`. */
trash(actorId: string, issueId: string): Promise<CommandResult> {
return this.commands.execute(new TrashIssueCommand(actorId, issueId));
}
/** Deletes it for good (the trash is emptied this way). */
delete(actorId: string, issueId: string): Promise<void> {
return this.commands.execute(new DeleteIssueCommand(actorId, issueId));
}
/**
* Deletes for good what went to the trash before `before`; how many. Each
* deletion is the usual one: comments purged, links dropped and subtasks
* back to the top, by the sagas.
*/
async emptyTrash(before: string): Promise<number> {
const old = await this.findIssues.findTrashedBefore(before);
for (const issue of old) await this.delete(SYSTEM_ACTOR_ID, issue.id);
return old.length;
}
restore(actorId: string, issueId: string): Promise<CommandResult> {
return this.commands.execute(new RestoreIssueCommand(actorId, issueId));
}
/** Votes for the ticket (`on`) or takes the vote back. */
vote(actorId: string, issueId: string, on: boolean): Promise<CommandResult> {
return this.commands.execute(new VoteIssueCommand(actorId, issueId, on));
}
/**
* A new ticket like this one: what it says (type, title, description,
* area, priority, labels, Figma, estimate, dates, parent and checklist
* steps, unticked), not where it stands. The copy starts in the first
* status, unassigned, out of sprints and releases, without comments or
* files; the copy's key comes in the result.
*/
async duplicate(actorId: string, issueId: string): Promise<CommandResult> {
const { issue } = await this.access.issueForContributor(issueId, actorId);
const copy = await this.create(actorId, {
projectId: issue.projectId,
type: issue.type,
title: `${issue.title} (copia)`.slice(0, 255),
description: issue.description,
area: issue.area,
team: issue.team,
priority: issue.priority,
labels: issue.labels,
figmaUrl: issue.figmaUrl,
storyPoints: issue.storyPoints,
startDate: issue.startDate,
dueDate: issue.dueDate,
customFields: issue.customFields,
parentId: issue.parentId,
// The original's text is the content, not the type's template.
useTemplate: false,
});
let result = copy;
for (const step of issue.checklist)
result = await this.addChecklistItem(actorId, copy.id, step.text);
return { ...result, key: copy.key };
}
trashed(
viewerId: string | null,
projectId: string,
): Promise<IssueReadModel[]> {
return this.queryBus.execute(new GetTrashQuery(viewerId, projectId));
}
addChecklistItem(
actorId: string,
issueId: string,
text: string,
): Promise<CommandResult> {
return this.commands.execute(
new AddChecklistItemCommand(actorId, issueId, text),
);
}
updateChecklistItem(
actorId: string,
issueId: string,
itemId: string,
changes: { text?: string; done?: boolean },
): Promise<CommandResult> {
return this.commands.execute(
new UpdateChecklistItemCommand(actorId, issueId, itemId, changes),
);
}
removeChecklistItem(
actorId: string,
issueId: string,
itemId: string,
): Promise<CommandResult> {
return this.commands.execute(
new RemoveChecklistItemCommand(actorId, issueId, itemId),
);
}
epics(viewerId: string | null, projectId: string): Promise<EpicReadModel[]> {
return this.queryBus.execute(new GetEpicsQuery(viewerId, projectId));
}
search(
viewerId: string | null,
filter: IssueFilter,
): Promise<Page<IssueReadModel>> {
return this.queryBus.execute(new SearchIssuesQuery(viewerId, filter));
}
/** Across all my projects: assigned to me, or any ticket (e.g. waiting for QA). */
mine(
viewerId: string,
filter: MyIssuesFilter,
): Promise<Page<IssueReadModel>> {
return this.queryBus.execute(new SearchMyIssuesQuery(viewerId, filter));
}
activity(
viewerId: string | null,
params: {
projectId: string;
issueId?: string;
limit?: number;
before?: string;
after?: string;
},
): Promise<ActivityReadModel[]> {
return this.queryBus.execute(
new GetActivityQuery(
viewerId,
params.projectId,
params.issueId,
params.limit,
params.before,
params.after,
),
);
}
}