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:
221
libs/api/tracker/presenters/src/lib/http/projects.controller.ts
Normal file
221
libs/api/tracker/presenters/src/lib/http/projects.controller.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user