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 { AccessToken, ApiOptionalPireonAuth, ApiPireonAuth, CurrentUser, } from '@task-stream/api/identity/presenters'; import { ApiCommandErrors, ApiReadErrors, } from '@task-stream/api/shared/presenters'; import { IssuesService, ProjectsService, } from '@task-stream/api/tracker/application'; import { ActivityQueryDto, AddMemberDto, AnalyticsQueryDto, BoardQueryDto, ChangeMemberRoleDto, CreateProjectDto, DeleteProjectQueryDto, ProjectSettingsBodyDto, UpdateProjectDto, } from './dto/requests.dto'; import { ActivityDto, BoardColumnDto, CommandResultDto, ProjectAnalyticsDto, ProjectDto, ProjectStatsDto, } from './dto/responses.dto'; import { fromSettingsPayload } from '../common/settings'; import { viewerOf } from '../common/viewer'; import { Refs, toProjectDto } from './refs'; const PROJECT = ApiParam({ name: 'project', description: 'Project id or key', example: 'WEB', }); /** REST adapter of the project use cases (same services as GraphQL). */ @ApiTags('Projects') @ApiPireonAuth() @Controller('projects') export class ProjectsController { constructor( private readonly projects: ProjectsService, private readonly issues: IssuesService, private readonly refs: Refs, ) {} @Get() @ApiOperation({ summary: 'Projects where I am a member' }) @ApiOkResponse({ type: [ProjectDto] }) @ApiReadErrors() async mine(@CurrentUser() me: User) { return (await this.projects.findMine(me.id)).map(toProjectDto); } @Post() @ApiOperation({ summary: 'Create a project (you become its owner)' }) @ApiCreatedResponse({ type: CommandResultDto }) @ApiCommandErrors() create(@CurrentUser() me: User, @Body() body: CreateProjectDto) { return this.projects.create(me.id, body); } @Get(':project') @ApiOptionalPireonAuth() @PROJECT @ApiOperation({ summary: 'A project with its members and settings' }) @ApiOkResponse({ type: ProjectDto }) @ApiReadErrors() async findOne( @CurrentUser() me: User | undefined, @Param('project') project: string, ) { return toProjectDto(await this.projects.findOne(viewerOf(me), project)); } @Patch(':project') @PROJECT @ApiOperation({ summary: 'Rename, describe or make public/private a project (owner/admin)', }) @ApiOkResponse({ type: CommandResultDto }) @ApiCommandErrors() async update( @CurrentUser() me: User, @Param('project') project: string, @Body() body: UpdateProjectDto, ) { return this.projects.update( me.id, await this.refs.projectId(me.id, project), body, ); } @Delete(':project') @PROJECT @ApiOperation({ summary: 'Delete a project (owner only)', description: 'Deletes the project and, right after (asynchronously, through a saga), its tickets, comments and releases; ' + 'links from tickets of other projects are removed and the key becomes free. Cannot be undone from the API. ' + '`confirm` must be the project key.', }) @ApiOkResponse({ type: CommandResultDto }) @ApiCommandErrors() async remove( @CurrentUser() me: User, @Param('project') project: string, @Query() query: DeleteProjectQueryDto, ) { return this.projects.delete( me.id, await this.refs.projectId(me.id, project), query.confirm, ); } @Put(':project/settings') @PROJECT @ApiOperation({ summary: 'Statuses, transitions, ticket types and areas (owner/admin)', description: 'Replaces the whole configuration (send what GET /projects/{project} returns, changed). ' + 'Values can be added, renamed, reordered or archived, never removed: tickets and statistics keep referring to them.', }) @ApiOkResponse({ type: CommandResultDto }) @ApiCommandErrors() async updateSettings( @CurrentUser() me: User, @Param('project') project: string, @Body() body: ProjectSettingsBodyDto, ) { return this.projects.updateSettings( me.id, await this.refs.projectId(me.id, project), fromSettingsPayload(body), ); } @Get(':project/board') @ApiOptionalPireonAuth() @PROJECT @ApiOperation({ summary: 'Kanban board: one column per status' }) @ApiOkResponse({ type: [BoardColumnDto] }) @ApiReadErrors() async board( @CurrentUser() me: User | undefined, @Param('project') project: string, @Query() query: BoardQueryDto, ) { const columns = await this.projects.board( viewerOf(me), { projectId: await this.refs.projectId(viewerOf(me), project), releaseId: query.releaseId, assigneeId: query.assigneeId, areas: query.area ? [query.area] : undefined, types: query.type, priorities: query.priority, labels: query.label, search: query.search, unassigned: query.unassigned, sprintId: query.sprintId, }, query.perColumn, ); return Promise.all( columns.map(async (column) => ({ ...column, issues: await this.refs.issueDtos(column.issues), })), ); } @Get(':project/stats') @ApiOptionalPireonAuth() @PROJECT @ApiOperation({ summary: 'Tracking dashboard: counts by status, type, person…', }) @ApiOkResponse({ type: ProjectStatsDto }) @ApiReadErrors() async stats( @CurrentUser() me: User | undefined, @Param('project') project: string, ) { return this.projects.stats( viewerOf(me), await this.refs.projectId(viewerOf(me), project), ); } @Get(':project/analytics') @ApiOptionalPireonAuth() @PROJECT @ApiOperation({ summary: 'Statistics of the last `days` days', description: 'Created vs resolved per week, cumulative flow per day, cycle and lead time, throughput per person and QA pass/fail.', }) @ApiOkResponse({ type: ProjectAnalyticsDto }) @ApiReadErrors() async analytics( @CurrentUser() me: User | undefined, @Param('project') project: string, @Query() query: AnalyticsQueryDto, ) { return this.projects.analytics( viewerOf(me), await this.refs.projectId(viewerOf(me), project), query.days, ); } @Get(':project/activity') @ApiOptionalPireonAuth() @PROJECT @ApiOperation({ summary: 'Activity feed (newest first)' }) @ApiOkResponse({ type: [ActivityDto] }) @ApiReadErrors() async activity( @CurrentUser() me: User | undefined, @Param('project') project: string, @Query() query: ActivityQueryDto, ) { return this.issues.activity(viewerOf(me), { projectId: await this.refs.projectId(viewerOf(me), project), issueId: query.issueId, limit: query.limit, before: query.before, }); } @Post(':project/members') @PROJECT @ApiOperation({ summary: 'Add a Pireon user to the project (owner/admin)', description: 'The user must exist and be active in Pireon.', }) @ApiCreatedResponse({ type: CommandResultDto }) @ApiCommandErrors() async addMember( @CurrentUser() me: User, @AccessToken() token: string, @Param('project') project: string, @Body() body: AddMemberDto, ) { const projectId = await this.refs.projectId(me.id, project); return this.projects.addMember(me.id, { projectId, ...body }, token); } @Patch(':project/members/:userId') @PROJECT @ApiOperation({ summary: "Change a member's role (owner/admin)" }) @ApiOkResponse({ type: CommandResultDto }) @ApiCommandErrors() async changeMemberRole( @CurrentUser() me: User, @Param('project') project: string, @Param('userId') userId: string, @Body() body: ChangeMemberRoleDto, ) { const projectId = await this.refs.projectId(me.id, project); return this.projects.changeMemberRole(me.id, { projectId, userId, role: body.role, }); } @Delete(':project/members/:userId') @PROJECT @ApiOperation({ summary: 'Remove a member (owner/admin, or yourself to leave)', }) @ApiOkResponse({ type: CommandResultDto }) @ApiCommandErrors() async removeMember( @CurrentUser() me: User, @Param('project') project: string, @Param('userId') userId: string, ) { return this.projects.removeMember( me.id, await this.refs.projectId(me.id, project), userId, ); } }