On the project dashboard, a day with activity can be picked on the map: a side panel lists that day's moves grouped by ticket (each key opens it), with the time of each, and the project's own (sprints, releases, members) apart. The activity feed takes an `after` date (GraphQL, REST), so with `before` it reads a window such as a day; the MCP's get_project_activity takes a `day`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
450 lines
14 KiB
TypeScript
450 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;
|
|
after?: string;
|
|
},
|
|
): Promise<ActivityReadModel[]> {
|
|
return this.queryBus.execute(
|
|
new GetActivityQuery(
|
|
viewerId,
|
|
params.projectId,
|
|
params.issueId,
|
|
params.limit,
|
|
params.before,
|
|
params.after,
|
|
),
|
|
);
|
|
}
|
|
}
|