import { Body, Controller, Delete, Get, HttpCode, Param, ParseUUIDPipe, Patch, Post, Put, Query, } from '@nestjs/common'; import { ApiCreatedResponse, ApiOkResponse, ApiOperation, ApiParam, ApiTags, } from '@nestjs/swagger'; import { User } from '@task-stream/api/identity/domain'; import { ApiOptionalPireonAuth, ApiPireonAuth, CurrentUser, } from '@task-stream/api/identity/presenters'; import { ApiCommandErrors, ApiReadErrors, } from '@task-stream/api/shared/presenters'; import { CommentsService, IssuesService, } from '@task-stream/api/tracker/application'; import { IssueLinkType } from '@task-stream/api/tracker/domain'; import { withoutNulls } from '../common/issue-input'; import { AssignIssueDto, BulkUpdateIssuesDto, ChangeStatusDto, ChecklistItemBodyDto, AddCommentDto, CreateIssueDto, IssueSearchQueryDto, LinkIssueDto, LinkTypeParamDto, MyIssuesQueryDto, PageQueryDto, RankIssueDto, SetIssueParentDto, SetIssueReleaseDto, UpdateChecklistItemDto, UpdateIssueDto, } from './dto/requests.dto'; import { ActivityDto, BulkResultDto, CommandResultDto, CommentPageDto, EpicDto, IssueDto, IssuePageDto, } from './dto/responses.dto'; import { viewerOf } from '../common/viewer'; import { toBulkChange } from '../common/bulk'; import { Refs } from './refs'; const PROJECT = ApiParam({ name: 'project', description: 'Project id or key', example: 'WEB', }); const ISSUE = ApiParam({ name: 'issue', description: 'Ticket id or key', example: 'WEB-12', }); /** REST adapter of the ticket use cases: bugs, features, UI changes, tasks and QA. */ @ApiTags('Issues') @ApiPireonAuth() @Controller() export class IssuesController { constructor( private readonly issues: IssuesService, private readonly comments: CommentsService, private readonly refs: Refs, ) {} @Put('issues/:issue/watch') @ISSUE @ApiOperation({ summary: 'Follow a ticket (members, viewers included)', description: 'Followers are told when it moves, is reassigned or gets a comment. Commenting follows it too.', }) @ApiOkResponse({ type: CommandResultDto }) @ApiCommandErrors() async watch(@CurrentUser() me: User, @Param('issue') issue: string) { return this.issues.watch( me.id, await this.refs.issueId(me.id, issue), true, ); } @Delete('issues/:issue/watch') @ISSUE @ApiOperation({ summary: 'Stop following a ticket' }) @ApiOkResponse({ type: CommandResultDto }) @ApiCommandErrors() async unwatch(@CurrentUser() me: User, @Param('issue') issue: string) { return this.issues.watch( me.id, await this.refs.issueId(me.id, issue), false, ); } @Get('me/issues') @ApiOperation({ summary: 'My work: tickets across all my projects', description: 'Assigned to me by default; with assignedToMe=false, any ticket of my projects (e.g. waiting for QA).', }) @ApiOkResponse({ type: IssuePageDto }) @ApiReadErrors() async mine(@CurrentUser() me: User, @Query() query: MyIssuesQueryDto) { const page = await this.issues.mine(me.id, { assignedToMe: query.assignedToMe, watching: query.watching, statuses: query.status, categories: query.category, search: query.search, limit: query.limit, offset: query.offset, }); return { ...page, items: await this.refs.issueDtos(page.items, viewerOf(me)), }; } @Get('projects/:project/issues') @ApiOptionalPireonAuth() @PROJECT @ApiOperation({ summary: 'Search the tickets of a project' }) @ApiOkResponse({ type: IssuePageDto }) @ApiReadErrors() async search( @CurrentUser() me: User | undefined, @Param('project') project: string, @Query() query: IssueSearchQueryDto, ) { const page = await this.issues.search(viewerOf(me), { projectId: await this.refs.projectId(viewerOf(me), project), parentId: query.parent ? await this.refs.issueId(viewerOf(me), query.parent) : undefined, statuses: query.status, categories: query.category, types: query.type, areas: query.area, priorities: query.priority, assigneeId: query.assigneeId, unassigned: query.unassigned, releaseId: query.releaseId, labels: query.label, sprintId: query.sprintId, noSprint: query.noSprint, watching: query.watching, dueFrom: query.dueFrom, dueTo: query.dueTo, customFields: query.field?.length ? Object.fromEntries( query.field.map((f) => [ f.slice(0, f.indexOf(':')), f.slice(f.indexOf(':') + 1), ]), ) : undefined, sort: query.sort, search: query.search, limit: query.limit, offset: query.offset, }); return { ...page, items: await this.refs.issueDtos(page.items, viewerOf(me)), }; } @Post('projects/:project/issues') @PROJECT @ApiOperation({ summary: 'Create a ticket', description: "Starts in the project's first active TODO-category status (BACKLOG by default). The response carries its key (e.g. WEB-13).", }) @ApiCreatedResponse({ type: CommandResultDto }) @ApiCommandErrors() async create( @CurrentUser() me: User, @Param('project') project: string, @Body() body: CreateIssueDto, ) { const { parent, ...fields } = body; return this.issues.create(me.id, { ...withoutNulls(fields), projectId: await this.refs.projectId(me.id, project), parentId: parent ? await this.refs.issueId(me.id, parent) : null, }); } @Get('projects/:project/epics') @ApiOptionalPireonAuth() @PROJECT @ApiOperation({ summary: 'Epics: top-level tickets that group others', description: 'With the progress of the tickets hanging from each one (done or cancelled, and story points).', }) @ApiOkResponse({ type: [EpicDto] }) @ApiReadErrors() async epics( @CurrentUser() me: User | undefined, @Param('project') project: string, ) { const epics = await this.issues.epics( viewerOf(me), await this.refs.projectId(viewerOf(me), project), ); return Promise.all( epics.map(async (epic) => ({ ...epic, issue: await this.refs.issueDto(epic.issue, viewerOf(me)), })), ); } @Get('issues/:issue') @ApiOptionalPireonAuth() @ISSUE @ApiOperation({ summary: 'A ticket' }) @ApiOkResponse({ type: IssueDto }) @ApiReadErrors() async findOne( @CurrentUser() me: User | undefined, @Param('issue') issue: string, ) { return this.refs.issueDto( await this.issues.findOne(viewerOf(me), issue), viewerOf(me), ); } @Patch('issues/:issue') @ISSUE @ApiOperation({ summary: 'Edit title, description, type, priority, area, labels…', }) @ApiOkResponse({ type: CommandResultDto }) @ApiCommandErrors() async update( @CurrentUser() me: User, @Param('issue') issue: string, @Body() body: UpdateIssueDto, ) { return this.issues.update( me.id, await this.refs.issueId(me.id, issue), withoutNulls(body), ); } @Put('issues/:issue/status') @ISSUE @ApiOperation({ summary: 'Move the ticket through the workflow (incl. QA)', description: 'Only the transitions in `allowedTransitions` are accepted (422 otherwise). ' + 'QA_FAILED and CANCELLED need a reason.', }) @ApiOkResponse({ type: CommandResultDto }) @ApiCommandErrors() async changeStatus( @CurrentUser() me: User, @Param('issue') issue: string, @Body() body: ChangeStatusDto, ) { return this.issues.changeStatus( me.id, await this.refs.issueId(me.id, issue), body.status, body.reason, ); } @Put('issues/:issue/assignee') @ISSUE @ApiOperation({ summary: 'Assign to a project member (null to unassign)' }) @ApiOkResponse({ type: CommandResultDto }) @ApiCommandErrors() async assign( @CurrentUser() me: User, @Param('issue') issue: string, @Body() body: AssignIssueDto, ) { return this.issues.assign( me.id, await this.refs.issueId(me.id, issue), body.assigneeId, ); } @Put('issues/:issue/release') @ISSUE @ApiOperation({ summary: 'Put the ticket in a planned release (null takes it out)', }) @ApiOkResponse({ type: CommandResultDto }) @ApiCommandErrors() async setRelease( @CurrentUser() me: User, @Param('issue') issue: string, @Body() body: SetIssueReleaseDto, ) { return this.issues.setRelease( me.id, await this.refs.issueId(me.id, issue), body.releaseId, ); } @Delete('issues/:issue') @ISSUE @ApiOperation({ summary: 'Move the ticket to the trash', description: 'Whoever opened it, or an owner/admin. Hidden everywhere; restore it within 30 days or it is deleted for good.', }) @ApiOkResponse({ type: CommandResultDto }) @ApiCommandErrors() async trash(@CurrentUser() me: User, @Param('issue') issue: string) { return this.issues.trash(me.id, await this.refs.issueId(me.id, issue)); } @Post('issues/:issue/restore') @HttpCode(200) @ISSUE @ApiOperation({ summary: 'Bring the ticket back from the trash' }) @ApiOkResponse({ type: CommandResultDto }) @ApiCommandErrors() async restore(@CurrentUser() me: User, @Param('issue') issue: string) { return this.issues.restore(me.id, await this.refs.issueId(me.id, issue)); } @Put('issues/:issue/vote') @ISSUE @ApiOperation({ summary: 'Vote for the ticket', description: 'Anyone signed in, on a public project (that is the point: the people it is for say what they want); members on a private one. Voting twice changes nothing.', }) @ApiOkResponse({ type: CommandResultDto }) @ApiCommandErrors() async vote(@CurrentUser() me: User, @Param('issue') issue: string) { return this.issues.vote(me.id, await this.refs.issueId(me.id, issue), true); } @Delete('issues/:issue/vote') @ISSUE @ApiOperation({ summary: 'Take back my vote for the ticket' }) @ApiOkResponse({ type: CommandResultDto }) @ApiCommandErrors() async unvote(@CurrentUser() me: User, @Param('issue') issue: string) { return this.issues.vote( me.id, await this.refs.issueId(me.id, issue), false, ); } @Post('issues/:issue/duplicate') @ISSUE @ApiOperation({ summary: 'Duplicate the ticket', description: 'Copies 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 result carries its key.', }) @ApiCreatedResponse({ type: CommandResultDto }) @ApiCommandErrors() async duplicate(@CurrentUser() me: User, @Param('issue') issue: string) { return this.issues.duplicate(me.id, await this.refs.issueId(me.id, issue)); } @Get('projects/:project/trash') @ApiOptionalPireonAuth() @PROJECT @ApiOperation({ summary: "The project's tickets in the trash, newest first" }) @ApiOkResponse({ type: [IssueDto] }) @ApiReadErrors() async trashed( @CurrentUser() me: User | undefined, @Param('project') project: string, ) { return this.refs.issueDtos( await this.issues.trashed( viewerOf(me), await this.refs.projectId(viewerOf(me), project), ), viewerOf(me), ); } @Post('issues/bulk') @HttpCode(200) @ApiOperation({ summary: 'The same change on up to 100 tickets', description: 'Each ticket goes through the normal rules (permissions, workflow, open sprints and releases) and may refuse on its own; the answer says which.', }) @ApiOkResponse({ type: BulkResultDto }) @ApiCommandErrors() bulkUpdate(@CurrentUser() me: User, @Body() body: BulkUpdateIssuesDto) { return this.issues.bulkUpdate(me.id, body.issueIds, toBulkChange(body)); } @Put('issues/:issue/parent') @ISSUE @ApiOperation({ summary: 'Place the ticket under an epic or another ticket', description: 'Same project, three levels at most (epic → ticket → subtask), no loops. null takes it back to the top.', }) @ApiOkResponse({ type: CommandResultDto }) @ApiCommandErrors() async setParent( @CurrentUser() me: User, @Param('issue') issue: string, @Body() body: SetIssueParentDto, ) { return this.issues.setParent( me.id, await this.refs.issueId(me.id, issue), body.parent ? await this.refs.issueId(me.id, body.parent) : null, ); } @Put('issues/:issue/rank') @ISSUE @ApiOperation({ summary: 'Move the ticket in the backlog', description: 'Between the ticket that ends up above it and the one below (either may be left out for the top or the bottom). Board columns follow the same order.', }) @ApiOkResponse({ type: CommandResultDto }) @ApiCommandErrors() async rank( @CurrentUser() me: User, @Param('issue') issue: string, @Body() body: RankIssueDto, ) { const ref = (value?: string | null) => value ? this.refs.issueId(me.id, value) : null; return this.issues.rank( me.id, await this.refs.issueId(me.id, issue), await ref(body.above), await ref(body.below), ); } @Post('issues/:issue/checklist') @ISSUE @ApiOperation({ summary: "Add a step to the ticket's checklist" }) @ApiCreatedResponse({ type: CommandResultDto }) @ApiCommandErrors() async addChecklistItem( @CurrentUser() me: User, @Param('issue') issue: string, @Body() body: ChecklistItemBodyDto, ) { return this.issues.addChecklistItem( me.id, await this.refs.issueId(me.id, issue), body.text, ); } @Patch('issues/:issue/checklist/:item') @ISSUE @ApiParam({ name: 'item', format: 'uuid' }) @ApiOperation({ summary: 'Reword, tick or untick a checklist step' }) @ApiOkResponse({ type: CommandResultDto }) @ApiCommandErrors() async updateChecklistItem( @CurrentUser() me: User, @Param('issue') issue: string, @Param('item', ParseUUIDPipe) item: string, @Body() body: UpdateChecklistItemDto, ) { return this.issues.updateChecklistItem( me.id, await this.refs.issueId(me.id, issue), item, body, ); } @Delete('issues/:issue/checklist/:item') @ISSUE @ApiParam({ name: 'item', format: 'uuid' }) @ApiOperation({ summary: 'Remove a checklist step' }) @ApiOkResponse({ type: CommandResultDto }) @ApiCommandErrors() async removeChecklistItem( @CurrentUser() me: User, @Param('issue') issue: string, @Param('item', ParseUUIDPipe) item: string, ) { return this.issues.removeChecklistItem( me.id, await this.refs.issueId(me.id, issue), item, ); } @Post('issues/:issue/links') @ISSUE @ApiOperation({ summary: 'Relate tickets (FE ↔ BE ↔ QA)', description: 'e.g. WEB-3 DEPENDS_ON API-7, WEB-9 TESTS WEB-3. May cross projects.', }) @ApiCreatedResponse({ type: CommandResultDto }) @ApiCommandErrors() async link( @CurrentUser() me: User, @Param('issue') issue: string, @Body() body: LinkIssueDto, ) { return this.issues.link(me.id, { issueId: await this.refs.issueId(me.id, issue), type: body.type, targetIssueId: await this.refs.issueId(me.id, body.targetIssue), }); } @Delete('issues/:issue/links/:type/:target') @ISSUE @ApiParam({ name: 'type', enum: IssueLinkType, enumName: 'IssueLinkType' }) @ApiParam({ name: 'target', description: 'Other ticket: id or key' }) @ApiOperation({ summary: 'Remove a relation' }) @ApiOkResponse({ type: CommandResultDto }) @ApiCommandErrors() async unlink( @CurrentUser() me: User, @Param('issue') issue: string, @Param() params: LinkTypeParamDto, @Param('target') target: string, ) { return this.issues.unlink(me.id, { issueId: await this.refs.issueId(me.id, issue), type: params.type, targetIssueId: await this.refs.issueId(me.id, target), }); } @Get('issues/:issue/comments') @ApiOptionalPireonAuth() @ISSUE @ApiOperation({ summary: 'Comments of the ticket (oldest first)' }) @ApiOkResponse({ type: CommentPageDto }) @ApiReadErrors() async listComments( @CurrentUser() me: User | undefined, @Param('issue') issue: string, @Query() page: PageQueryDto, ) { return this.comments.list( viewerOf(me), await this.refs.issueId(viewerOf(me), issue), page.limit, page.offset, ); } @Post('issues/:issue/comments') @ISSUE @ApiOperation({ summary: 'Comment on the ticket, or answer a comment (replyTo)', }) @ApiCreatedResponse({ type: CommandResultDto }) @ApiCommandErrors() async addComment( @CurrentUser() me: User, @Param('issue') issue: string, @Body() body: AddCommentDto, ) { return this.comments.add( me.id, await this.refs.issueId(me.id, issue), body.body, body.replyTo ?? null, ); } @Get('issues/:issue/history') @ApiOptionalPireonAuth() @ISSUE @ApiOperation({ summary: 'History of the ticket (newest first)' }) @ApiOkResponse({ type: [ActivityDto] }) @ApiReadErrors() async history( @CurrentUser() me: User | undefined, @Param('issue') issue: string, @Query() page: PageQueryDto, ) { const found = await this.issues.findOne(viewerOf(me), issue); return this.issues.activity(viewerOf(me), { projectId: found.projectId, issueId: found.id, limit: page.limit, }); } }