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 { 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 { 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 { return this.commands.execute( new UpdateIssueCommand(actorId, issueId, changes), ); } changeStatus( actorId: string, issueId: string, status: string, reason?: string | null, ): Promise { return this.commands.execute( new ChangeIssueStatusCommand(actorId, issueId, status, reason), ); } /** @param assigneeId null to unassign */ assign( actorId: string, issueId: string, assigneeId: string | null, ): Promise { return this.commands.execute( new AssignIssueCommand(actorId, issueId, assigneeId), ); } link(actorId: string, input: IssueLinkInput): Promise { return this.commands.execute( new LinkIssuesCommand( actorId, input.issueId, input.type, input.targetIssueId, ), ); } unlink(actorId: string, input: IssueLinkInput): Promise { 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 { 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 { return this.commands.execute( new AttachFileCommand(actorId, issueId, file, commentId), ); } detach( actorId: string, issueId: string, attachmentId: string, ): Promise { 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 { 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 { return this.queryBus.execute( new GetAttachmentQuery(viewerId, issueId, attachmentId), ); } findOne(viewerId: string | null, idOrKey: string): Promise { return this.queryBus.execute(new GetIssueQuery(viewerId, idOrKey)); } findMany(viewerId: string | null, ids: string[]): Promise { 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 { 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 { 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 { 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 { return this.commands.execute(new TrashIssueCommand(actorId, issueId)); } /** Deletes it for good (the trash is emptied this way). */ delete(actorId: string, issueId: string): Promise { 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 { 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 { 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 { 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 { 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 { return this.queryBus.execute(new GetTrashQuery(viewerId, projectId)); } addChecklistItem( actorId: string, issueId: string, text: string, ): Promise { return this.commands.execute( new AddChecklistItemCommand(actorId, issueId, text), ); } updateChecklistItem( actorId: string, issueId: string, itemId: string, changes: { text?: string; done?: boolean }, ): Promise { return this.commands.execute( new UpdateChecklistItemCommand(actorId, issueId, itemId, changes), ); } removeChecklistItem( actorId: string, issueId: string, itemId: string, ): Promise { return this.commands.execute( new RemoveChecklistItemCommand(actorId, issueId, itemId), ); } epics(viewerId: string | null, projectId: string): Promise { return this.queryBus.execute(new GetEpicsQuery(viewerId, projectId)); } search( viewerId: string | null, filter: IssueFilter, ): Promise> { 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> { return this.queryBus.execute(new SearchMyIssuesQuery(viewerId, filter)); } activity( viewerId: string | null, params: { projectId: string; issueId?: string; limit?: number; before?: string; }, ): Promise { return this.queryBus.execute( new GetActivityQuery( viewerId, params.projectId, params.issueId, params.limit, params.before, ), ); } }