feat(api): REST API with OpenAPI for other apps, behind Pireon OAuth

A REST adapter next to GraphQL (presenters/http) that calls the same
application services, so validation, permissions and domain errors are the
same: projects (members, board, stats, activity), issues (search, workflow,
assignee, release, links, comments, history), comments, releases and users.
Projects and issues can be addressed by id or key (WEB, WEB-12).

- Swagger UI at /api/docs, document at /api/docs-json and apps/api/openapi.json
  (rewritten by `nx serve api`, like schema.gql) for client generation.
- Every route but health/auth/webhook needs a Pireon token: the document
  declares Pireon OAuth2 (authorization code + PKCE, password) through the
  /api/auth/token proxy, and a bearer scheme for apps with their own client.
- configureApp() shares main.ts setup with the e2e, which now also covers REST.
- SWAGGER_ENABLED / OPENAPI_FILE config; docs 03/05 and library READMEs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 15:26:47 +02:00
parent b2c9be72c3
commit 341241a52f
36 changed files with 7386 additions and 42 deletions

View File

@@ -0,0 +1,221 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import {
ApiCreatedResponse,
ApiOkResponse,
ApiOperation,
ApiParam,
ApiTags,
} from '@nestjs/swagger';
import { User } from '@task-stream/api/identity/domain';
import {
AccessToken,
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,
BoardQueryDto,
ChangeMemberRoleDto,
CreateProjectDto,
UpdateProjectDto,
} from './dto/requests.dto';
import {
ActivityDto,
BoardColumnDto,
CommandResultDto,
ProjectDto,
ProjectStatsDto,
} from './dto/responses.dto';
import { Refs, toIssueDto } 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()
mine(@CurrentUser() me: User) {
return this.projects.findMine(me.id);
}
@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')
@PROJECT
@ApiOperation({ summary: 'A project with its members' })
@ApiOkResponse({ type: ProjectDto })
@ApiReadErrors()
findOne(@CurrentUser() me: User, @Param('project') project: string) {
return this.projects.findOne(me.id, project);
}
@Patch(':project')
@PROJECT
@ApiOperation({ summary: 'Rename or describe 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,
);
}
@Get(':project/board')
@PROJECT
@ApiOperation({ summary: 'Kanban board: one column per status' })
@ApiOkResponse({ type: [BoardColumnDto] })
@ApiReadErrors()
async board(
@CurrentUser() me: User,
@Param('project') project: string,
@Query() query: BoardQueryDto,
) {
const columns = await this.projects.board(
me.id,
{
projectId: await this.refs.projectId(me.id, project),
releaseId: query.releaseId,
assigneeId: query.assigneeId,
areas: query.area ? [query.area] : undefined,
},
query.perColumn,
);
return columns.map((column) => ({
...column,
issues: column.issues.map(toIssueDto),
}));
}
@Get(':project/stats')
@PROJECT
@ApiOperation({
summary: 'Tracking dashboard: counts by status, type, person…',
})
@ApiOkResponse({ type: ProjectStatsDto })
@ApiReadErrors()
async stats(@CurrentUser() me: User, @Param('project') project: string) {
return this.projects.stats(
me.id,
await this.refs.projectId(me.id, project),
);
}
@Get(':project/activity')
@PROJECT
@ApiOperation({ summary: 'Activity feed (newest first)' })
@ApiOkResponse({ type: [ActivityDto] })
@ApiReadErrors()
async activity(
@CurrentUser() me: User,
@Param('project') project: string,
@Query() query: ActivityQueryDto,
) {
return this.issues.activity(me.id, {
projectId: await this.refs.projectId(me.id, 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,
);
}
}