The owner archives a finished project (archiveProject, POST
/projects/{project}/archive) and brings it back (unarchive). While
archived it stays readable, managers still read its rules and webhooks,
but no command changes it: contributing and managing checks refuse with
422, votes and public proposals too, and automations do not run. The web
shows a read-only notice on every page of it (with «Desarchivar» for the
owner), hides what edits, marks it on the home page and lists it last.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
378 lines
10 KiB
TypeScript
378 lines
10 KiB
TypeScript
import {
|
|
Body,
|
|
Controller,
|
|
Delete,
|
|
Get,
|
|
Param,
|
|
Patch,
|
|
HttpCode,
|
|
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,
|
|
InsightsQueryDto,
|
|
BoardQueryDto,
|
|
ChangeMemberRoleDto,
|
|
CreateProjectDto,
|
|
DeleteProjectQueryDto,
|
|
ProjectSettingsBodyDto,
|
|
UpdateProjectDto,
|
|
} from './dto/requests.dto';
|
|
import {
|
|
ActivityDto,
|
|
BoardColumnDto,
|
|
CommandResultDto,
|
|
ProjectAnalyticsDto,
|
|
ProjectInsightsDto,
|
|
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()
|
|
async create(@CurrentUser() me: User, @Body() body: CreateProjectDto) {
|
|
const { template, ...fields } = body;
|
|
return this.projects.create(me.id, {
|
|
...fields,
|
|
templateId: template ? await this.refs.projectId(me.id, template) : null,
|
|
});
|
|
}
|
|
|
|
@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,
|
|
);
|
|
}
|
|
|
|
@Post(':project/archive')
|
|
@HttpCode(200)
|
|
@PROJECT
|
|
@ApiOperation({
|
|
summary: 'Archive a project (owner only)',
|
|
description:
|
|
'It stays readable (board, tickets, history, statistics); nothing can change until it is unarchived.',
|
|
})
|
|
@ApiOkResponse({ type: CommandResultDto })
|
|
@ApiCommandErrors()
|
|
async archive(@CurrentUser() me: User, @Param('project') project: string) {
|
|
return this.projects.setArchived(
|
|
me.id,
|
|
await this.refs.projectId(me.id, project),
|
|
true,
|
|
);
|
|
}
|
|
|
|
@Post(':project/unarchive')
|
|
@HttpCode(200)
|
|
@PROJECT
|
|
@ApiOperation({ summary: 'Bring an archived project back (owner only)' })
|
|
@ApiOkResponse({ type: CommandResultDto })
|
|
@ApiCommandErrors()
|
|
async unarchive(@CurrentUser() me: User, @Param('project') project: string) {
|
|
return this.projects.setArchived(
|
|
me.id,
|
|
await this.refs.projectId(me.id, project),
|
|
false,
|
|
);
|
|
}
|
|
|
|
@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, viewerOf(me)),
|
|
})),
|
|
);
|
|
}
|
|
|
|
@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/insights')
|
|
@ApiOptionalPireonAuth()
|
|
@PROJECT
|
|
@ApiOperation({
|
|
summary: 'The dashboard',
|
|
description:
|
|
'Sprint under way (pace against time), overdue, due-soon, blocked and stuck tickets, aging of the work under way against the p85 cycle time, and average days in each in-progress and QA status.',
|
|
})
|
|
@ApiOkResponse({ type: ProjectInsightsDto })
|
|
@ApiReadErrors()
|
|
async insights(
|
|
@CurrentUser() me: User | undefined,
|
|
@Param('project') project: string,
|
|
@Query() query: InsightsQueryDto,
|
|
) {
|
|
return this.projects.insights(
|
|
viewerOf(me),
|
|
await this.refs.projectId(viewerOf(me), project),
|
|
query.since ? new Date(query.since) : null,
|
|
);
|
|
}
|
|
|
|
@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,
|
|
);
|
|
}
|
|
}
|