import { Body, Controller, Delete, Get, Param, 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 { 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, ChangeStatusDto, CommentBodyDto, CreateIssueDto, IssueSearchQueryDto, LinkIssueDto, LinkTypeParamDto, MyIssuesQueryDto, PageQueryDto, SetIssueReleaseDto, UpdateIssueDto, } from './dto/requests.dto'; import { ActivityDto, CommandResultDto, CommentPageDto, IssueDto, IssuePageDto, } from './dto/responses.dto'; 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, ) {} @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, statuses: query.status, categories: query.category, limit: query.limit, offset: query.offset, }); return { ...page, items: await this.refs.issueDtos(page.items) }; } @Get('projects/:project/issues') @PROJECT @ApiOperation({ summary: 'Search the tickets of a project' }) @ApiOkResponse({ type: IssuePageDto }) @ApiReadErrors() async search( @CurrentUser() me: User, @Param('project') project: string, @Query() query: IssueSearchQueryDto, ) { const page = await this.issues.search(me.id, { projectId: await this.refs.projectId(me.id, project), 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, search: query.search, limit: query.limit, offset: query.offset, }); return { ...page, items: await this.refs.issueDtos(page.items) }; } @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, ) { return this.issues.create(me.id, { ...withoutNulls(body), projectId: await this.refs.projectId(me.id, project), }); } @Get('issues/:issue') @ISSUE @ApiOperation({ summary: 'A ticket' }) @ApiOkResponse({ type: IssueDto }) @ApiReadErrors() async findOne(@CurrentUser() me: User, @Param('issue') issue: string) { return this.refs.issueDto(await this.issues.findOne(me.id, issue)); } @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, ); } @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') @ISSUE @ApiOperation({ summary: 'Comments of the ticket (oldest first)' }) @ApiOkResponse({ type: CommentPageDto }) @ApiReadErrors() async listComments( @CurrentUser() me: User, @Param('issue') issue: string, @Query() page: PageQueryDto, ) { return this.comments.list( me.id, await this.refs.issueId(me.id, issue), page.limit, page.offset, ); } @Post('issues/:issue/comments') @ISSUE @ApiOperation({ summary: 'Comment on the ticket' }) @ApiCreatedResponse({ type: CommandResultDto }) @ApiCommandErrors() async addComment( @CurrentUser() me: User, @Param('issue') issue: string, @Body() body: CommentBodyDto, ) { return this.comments.add( me.id, await this.refs.issueId(me.id, issue), body.body, ); } @Get('issues/:issue/history') @ISSUE @ApiOperation({ summary: 'History of the ticket (newest first)' }) @ApiOkResponse({ type: [ActivityDto] }) @ApiReadErrors() async history( @CurrentUser() me: User, @Param('issue') issue: string, @Query() page: PageQueryDto, ) { const found = await this.issues.findOne(me.id, issue); return this.issues.activity(me.id, { projectId: found.projectId, issueId: found.id, limit: page.limit, }); } }