Files
task-stream/libs/api/tracker/presenters/src/lib/http/projects.controller.ts
Josep Piguillem 4acf629106 feat: archive a project instead of deleting it
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>
2026-10-15 15:46:22 +02:00

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,
);
}
}