Projects keep teams of members in their settings (settings.teams; people outside the project are dropped, teams are archived, not removed; absent when saving means unchanged). Tickets go to a team (`team`, also filtered in the list and in MCP search_issues), and a comment mentioning @[Frontend](team-FRONT) tells each of its members. Settings has the teams with their members, the ticket a «Equipo» select, the list a team filter, and «@» offers the teams after the people. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
448 lines
14 KiB
TypeScript
448 lines
14 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,
|
|
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 { 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),
|
|
);
|
|
}
|
|
|
|
/** 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;
|
|
},
|
|
): Promise<ActivityReadModel[]> {
|
|
return this.queryBus.execute(
|
|
new GetActivityQuery(
|
|
viewerId,
|
|
params.projectId,
|
|
params.issueId,
|
|
params.limit,
|
|
params.before,
|
|
),
|
|
);
|
|
}
|
|
}
|