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:
@@ -1,6 +1,6 @@
|
||||
# api-tracker-presenters
|
||||
|
||||
API GraphQL del tracker.
|
||||
API GraphQL y REST (OpenAPI) del tracker.
|
||||
|
||||
- **Import**: `@task-stream/api/tracker/presenters`
|
||||
- **Capa**: `presenters` · **Tags**: `scope:api-tracker`, `type:presenters`
|
||||
@@ -9,8 +9,11 @@ API GraphQL del tracker.
|
||||
|
||||
- Resolvers `ProjectResolver`, `IssueResolver`, `CommentResolver`, `ReleaseResolver` y `SubscriptionResolver`; solo llaman a services.
|
||||
- Tipos, inputs (con `class-validator`) y enums de GraphQL (code-first).
|
||||
- `http/`: controladores REST `ProjectsController`, `IssuesController`, `CommentsController`, `ReleasesController`
|
||||
(mismos services), DTOs con `@nestjs/swagger` y `Refs` (proyectos y tickets por id o clave).
|
||||
|
||||
Operaciones disponibles: [API](../../../../docs/03-api.md#32-graphql).
|
||||
Operaciones disponibles: [GraphQL](../../../../docs/03-api.md#32-graphql) y
|
||||
[REST](../../../../docs/03-api.md#33-rest-con-openapi-para-otras-apps).
|
||||
|
||||
## Comandos
|
||||
|
||||
|
||||
10
libs/api/tracker/presenters/src/lib/common/issue-input.ts
Normal file
10
libs/api/tracker/presenters/src/lib/common/issue-input.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { IssueDetails } from '@task-stream/api/tracker/domain';
|
||||
|
||||
/** Clients may send explicit nulls; the domain only accepts them on nullable fields. */
|
||||
export function withoutNulls<T extends Partial<IssueDetails>>(input: T): T {
|
||||
const details = { ...input };
|
||||
for (const field of ['title', 'type', 'priority', 'labels'] as const) {
|
||||
if (details[field] === null) delete details[field];
|
||||
}
|
||||
return details;
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
IssuesService,
|
||||
} from '@task-stream/api/tracker/application';
|
||||
import {
|
||||
IssueDetails,
|
||||
IssueReadModel,
|
||||
IssueStatus,
|
||||
IssueWorkflow,
|
||||
@@ -27,6 +26,7 @@ import {
|
||||
LinkIssuesInput,
|
||||
UpdateIssueInput,
|
||||
} from './inputs';
|
||||
import { withoutNulls } from '../common/issue-input';
|
||||
import {
|
||||
ActivityGql,
|
||||
CommandResultGql,
|
||||
@@ -35,15 +35,6 @@ import {
|
||||
IssuePageGql,
|
||||
} from './types';
|
||||
|
||||
/** GraphQL sends explicit nulls; the domain only accepts them on nullable fields. */
|
||||
function withoutNulls<T extends Partial<IssueDetails>>(input: T): T {
|
||||
const details = { ...input };
|
||||
for (const field of ['title', 'type', 'priority', 'labels'] as const) {
|
||||
if (details[field] === null) delete details[field];
|
||||
}
|
||||
return details;
|
||||
}
|
||||
|
||||
@Resolver(() => IssueGql)
|
||||
export class IssueResolver {
|
||||
constructor(
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Body, Controller, Delete, Param, Patch } from '@nestjs/common';
|
||||
import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { User } from '@task-stream/api/identity/domain';
|
||||
import {
|
||||
ApiPireonAuth,
|
||||
CurrentUser,
|
||||
} from '@task-stream/api/identity/presenters';
|
||||
import { ApiCommandErrors } from '@task-stream/api/shared/presenters';
|
||||
import { CommentsService } from '@task-stream/api/tracker/application';
|
||||
import { CommentBodyDto } from './dto/requests.dto';
|
||||
import { CommandResultDto } from './dto/responses.dto';
|
||||
|
||||
/** REST adapter of the comment use cases (listing and adding live under /issues). */
|
||||
@ApiTags('Comments')
|
||||
@ApiPireonAuth()
|
||||
@Controller('comments')
|
||||
export class CommentsController {
|
||||
constructor(private readonly comments: CommentsService) {}
|
||||
|
||||
@Patch(':commentId')
|
||||
@ApiOperation({ summary: 'Edit a comment (author only)' })
|
||||
@ApiOkResponse({ type: CommandResultDto })
|
||||
@ApiCommandErrors()
|
||||
edit(
|
||||
@CurrentUser() me: User,
|
||||
@Param('commentId') commentId: string,
|
||||
@Body() body: CommentBodyDto,
|
||||
) {
|
||||
return this.comments.edit(me.id, commentId, body.body);
|
||||
}
|
||||
|
||||
@Delete(':commentId')
|
||||
@ApiOperation({ summary: 'Delete a comment (author or project admin)' })
|
||||
@ApiOkResponse({ type: CommandResultDto })
|
||||
@ApiCommandErrors()
|
||||
delete(@CurrentUser() me: User, @Param('commentId') commentId: string) {
|
||||
return this.comments.delete(me.id, commentId);
|
||||
}
|
||||
}
|
||||
434
libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts
Normal file
434
libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts
Normal file
@@ -0,0 +1,434 @@
|
||||
import {
|
||||
ApiProperty,
|
||||
ApiPropertyOptional,
|
||||
OmitType,
|
||||
PartialType,
|
||||
} from '@nestjs/swagger';
|
||||
import {
|
||||
IssueArea,
|
||||
IssueLinkType,
|
||||
IssuePriority,
|
||||
IssueStatus,
|
||||
IssueType,
|
||||
ProjectRole,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsISO8601,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUrl,
|
||||
Matches,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
|
||||
/* REST request bodies and query strings, validated like the GraphQL inputs. */
|
||||
|
||||
/** `?status=TODO&status=DONE` or `?status=TODO,DONE` → ['TODO', 'DONE']. */
|
||||
const toList = ({ value }: { value: unknown }) =>
|
||||
value === undefined || value === ''
|
||||
? undefined
|
||||
: ([] as unknown[])
|
||||
.concat(value)
|
||||
.flatMap((v) => String(v).split(','))
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const toBoolean = ({ value }: { value: unknown }) =>
|
||||
value === undefined ? undefined : value === true || value === 'true';
|
||||
|
||||
// ---- Projects ---------------------------------------------------------------
|
||||
|
||||
export class CreateProjectDto {
|
||||
@ApiProperty({
|
||||
example: 'WEB',
|
||||
description:
|
||||
'2-10 letters/digits starting with a letter → tickets WEB-1, WEB-2…',
|
||||
})
|
||||
@Matches(/^[A-Za-z][A-Za-z0-9]{1,9}$/)
|
||||
key!: string;
|
||||
|
||||
@ApiProperty({ example: 'Checkout revamp', maxLength: 120 })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(120)
|
||||
name!: string;
|
||||
|
||||
@ApiPropertyOptional({ type: String, nullable: true, maxLength: 5000 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(5000)
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export class UpdateProjectDto extends PartialType(
|
||||
OmitType(CreateProjectDto, ['key'] as const),
|
||||
) {}
|
||||
|
||||
export class AddMemberDto {
|
||||
@ApiProperty({ description: 'Pireon user id (see GET /users)' })
|
||||
@IsString()
|
||||
userId!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: ProjectRole,
|
||||
enumName: 'ProjectRole',
|
||||
default: ProjectRole.MEMBER,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(ProjectRole)
|
||||
role: ProjectRole = ProjectRole.MEMBER;
|
||||
}
|
||||
|
||||
export class ChangeMemberRoleDto {
|
||||
@ApiProperty({ enum: ProjectRole, enumName: 'ProjectRole' })
|
||||
@IsEnum(ProjectRole)
|
||||
role!: ProjectRole;
|
||||
}
|
||||
|
||||
export class BoardQueryDto {
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Only this release' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
releaseId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Only this person (Pireon user id)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
assigneeId?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: IssueArea, enumName: 'IssueArea' })
|
||||
@IsOptional()
|
||||
@IsEnum(IssueArea)
|
||||
area?: IssueArea;
|
||||
|
||||
@ApiPropertyOptional({ default: 50, minimum: 1, maximum: 200 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(200)
|
||||
perColumn = 50;
|
||||
}
|
||||
|
||||
export class ActivityQueryDto {
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Only this ticket' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
issueId?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: 50, minimum: 1, maximum: 200 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(200)
|
||||
limit = 50;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
format: 'date-time',
|
||||
description: 'Cursor: entries older than this date',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
before?: string;
|
||||
}
|
||||
|
||||
export class PageQueryDto {
|
||||
@ApiPropertyOptional({ default: 50, minimum: 1, maximum: 200 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(200)
|
||||
limit = 50;
|
||||
|
||||
@ApiPropertyOptional({ default: 0, minimum: 0 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
offset = 0;
|
||||
}
|
||||
|
||||
// ---- Issues -----------------------------------------------------------------
|
||||
|
||||
export class IssueSearchQueryDto extends PageQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
enum: IssueStatus,
|
||||
enumName: 'IssueStatus',
|
||||
isArray: true,
|
||||
description: 'Repeat the parameter or separate with commas',
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(toList)
|
||||
@IsEnum(IssueStatus, { each: true })
|
||||
status?: IssueStatus[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: IssueType,
|
||||
enumName: 'IssueType',
|
||||
isArray: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(toList)
|
||||
@IsEnum(IssueType, { each: true })
|
||||
type?: IssueType[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: IssueArea,
|
||||
enumName: 'IssueArea',
|
||||
isArray: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(toList)
|
||||
@IsEnum(IssueArea, { each: true })
|
||||
area?: IssueArea[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: IssuePriority,
|
||||
enumName: 'IssuePriority',
|
||||
isArray: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(toList)
|
||||
@IsEnum(IssuePriority, { each: true })
|
||||
priority?: IssuePriority[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Pireon user id' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
assigneeId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Only tickets without assignee' })
|
||||
@IsOptional()
|
||||
@Transform(toBoolean)
|
||||
@IsBoolean()
|
||||
unassigned?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
releaseId?: string;
|
||||
|
||||
@ApiPropertyOptional({ type: [String] })
|
||||
@IsOptional()
|
||||
@Transform(toList)
|
||||
@IsString({ each: true })
|
||||
label?: string[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Matches key, title or description' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
search?: string;
|
||||
}
|
||||
|
||||
class IssueFieldsDto {
|
||||
@ApiPropertyOptional({ type: String, nullable: true, maxLength: 50000 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50000)
|
||||
description?: string | null;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: IssueArea,
|
||||
type: String,
|
||||
nullable: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(IssueArea)
|
||||
area?: IssueArea | null;
|
||||
|
||||
@ApiPropertyOptional({ enum: IssuePriority, enumName: 'IssuePriority' })
|
||||
@IsOptional()
|
||||
@IsEnum(IssuePriority)
|
||||
priority?: IssuePriority;
|
||||
|
||||
@ApiPropertyOptional({ type: [String], maxItems: 20 })
|
||||
@IsOptional()
|
||||
@ArrayMaxSize(20)
|
||||
@IsString({ each: true })
|
||||
@MaxLength(40, { each: true })
|
||||
labels?: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
nullable: true,
|
||||
description: 'Figma frame (https) for UI changes',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUrl({ protocols: ['https'] })
|
||||
figmaUrl?: string | null;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: Number,
|
||||
nullable: true,
|
||||
minimum: 0,
|
||||
maximum: 100,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(100)
|
||||
storyPoints?: number | null;
|
||||
|
||||
@ApiPropertyOptional({ type: String, nullable: true, format: 'date' })
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
dueDate?: string | null;
|
||||
}
|
||||
|
||||
export class CreateIssueDto extends IssueFieldsDto {
|
||||
@ApiProperty({ enum: IssueType, enumName: 'IssueType' })
|
||||
@IsEnum(IssueType)
|
||||
type!: IssueType;
|
||||
|
||||
@ApiProperty({
|
||||
example: 'El botón de pagar no responde en Safari',
|
||||
maxLength: 255,
|
||||
})
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
title!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
nullable: true,
|
||||
description: 'Pireon user id',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
assigneeId?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ type: String, nullable: true, format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
releaseId?: string | null;
|
||||
}
|
||||
|
||||
export class UpdateIssueDto extends IssueFieldsDto {
|
||||
@ApiPropertyOptional({ enum: IssueType, enumName: 'IssueType' })
|
||||
@IsOptional()
|
||||
@IsEnum(IssueType)
|
||||
type?: IssueType;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 255 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export class ChangeStatusDto {
|
||||
@ApiProperty({ enum: IssueStatus, enumName: 'IssueStatus' })
|
||||
@IsEnum(IssueStatus)
|
||||
status!: IssueStatus;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
nullable: true,
|
||||
description: 'Required for QA_FAILED and CANCELLED',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(5000)
|
||||
reason?: string | null;
|
||||
}
|
||||
|
||||
export class AssignIssueDto {
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
nullable: true,
|
||||
description: 'Pireon user id of a project member, or null to unassign',
|
||||
})
|
||||
@ValidateIf((dto: AssignIssueDto) => dto.assigneeId !== null)
|
||||
@IsString()
|
||||
assigneeId!: string | null;
|
||||
}
|
||||
|
||||
export class SetIssueReleaseDto {
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
nullable: true,
|
||||
format: 'uuid',
|
||||
description: 'Planned release, or null to take the ticket out',
|
||||
})
|
||||
@ValidateIf((dto: SetIssueReleaseDto) => dto.releaseId !== null)
|
||||
@IsString()
|
||||
releaseId!: string | null;
|
||||
}
|
||||
|
||||
export class LinkIssueDto {
|
||||
@ApiProperty({ enum: IssueLinkType, enumName: 'IssueLinkType' })
|
||||
@IsEnum(IssueLinkType)
|
||||
type!: IssueLinkType;
|
||||
|
||||
@ApiProperty({ example: 'API-3', description: 'Other ticket: id or key' })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
targetIssue!: string;
|
||||
}
|
||||
|
||||
export class LinkTypeParamDto {
|
||||
@ApiProperty({ enum: IssueLinkType, enumName: 'IssueLinkType' })
|
||||
@IsIn(Object.values(IssueLinkType))
|
||||
type!: IssueLinkType;
|
||||
}
|
||||
|
||||
export class CommentBodyDto {
|
||||
@ApiProperty({ example: 'Reproducido en Safari 17.', maxLength: 20000 })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(20000)
|
||||
body!: string;
|
||||
}
|
||||
|
||||
// ---- Releases ---------------------------------------------------------------
|
||||
|
||||
export class CreateReleaseDto {
|
||||
@ApiProperty({ example: 'v1.4.0', maxLength: 60 })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(60)
|
||||
name!: string;
|
||||
|
||||
@ApiPropertyOptional({ type: String, nullable: true, maxLength: 5000 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(5000)
|
||||
description?: string | null;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
nullable: true,
|
||||
format: 'date',
|
||||
example: '2026-11-14',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
targetDate?: string | null;
|
||||
}
|
||||
|
||||
export class UpdateReleaseDto extends PartialType(CreateReleaseDto) {}
|
||||
|
||||
export class PublishReleaseDto {
|
||||
@ApiPropertyOptional({
|
||||
default: false,
|
||||
description: 'Publish even with unfinished tickets',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
force = false;
|
||||
}
|
||||
215
libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts
Normal file
215
libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IssueArea,
|
||||
IssueLinkType,
|
||||
IssuePriority,
|
||||
IssueStatus,
|
||||
IssueType,
|
||||
ProjectRole,
|
||||
ReleaseStatus,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
|
||||
/*
|
||||
* REST response bodies. Same shapes as the GraphQL types (both are views of
|
||||
* the read models); here they only document the contract for OpenAPI.
|
||||
*/
|
||||
|
||||
export class UserRefDto {
|
||||
@ApiProperty({ description: 'Pireon user id' }) id!: string;
|
||||
@ApiProperty() name!: string;
|
||||
}
|
||||
|
||||
export class ProjectMemberDto {
|
||||
@ApiProperty({ type: UserRefDto }) user!: UserRefDto;
|
||||
@ApiProperty({ enum: ProjectRole, enumName: 'ProjectRole' })
|
||||
role!: ProjectRole;
|
||||
}
|
||||
|
||||
export class ProjectDto {
|
||||
@ApiProperty({ format: 'uuid' }) id!: string;
|
||||
@ApiProperty({ example: 'WEB', description: 'Prefix of the ticket keys' })
|
||||
key!: string;
|
||||
@ApiProperty({ example: 'Checkout revamp' }) name!: string;
|
||||
@ApiProperty({ type: String, nullable: true }) description!: string | null;
|
||||
@ApiProperty({ type: UserRefDto }) owner!: UserRefDto;
|
||||
@ApiProperty({ type: [ProjectMemberDto] }) members!: ProjectMemberDto[];
|
||||
@ApiProperty({ format: 'date-time' }) createdAt!: string;
|
||||
@ApiProperty({ format: 'date-time' }) updatedAt!: string;
|
||||
@ApiProperty({ description: 'Version of the aggregate (event count)' })
|
||||
version!: number;
|
||||
}
|
||||
|
||||
export class IssueLinkDto {
|
||||
@ApiProperty({ enum: IssueLinkType, enumName: 'IssueLinkType' })
|
||||
type!: IssueLinkType;
|
||||
@ApiProperty({
|
||||
enum: ['OUTWARD', 'INWARD'],
|
||||
enumName: 'LinkDirection',
|
||||
description: 'OUTWARD: this issue → other. INWARD: other → this issue.',
|
||||
})
|
||||
direction!: 'OUTWARD' | 'INWARD';
|
||||
@ApiProperty({ format: 'uuid' }) issueId!: string;
|
||||
@ApiProperty({ example: 'WEB-3' }) issueKey!: string;
|
||||
}
|
||||
|
||||
export class ReleaseRefDto {
|
||||
@ApiProperty({ format: 'uuid' }) id!: string;
|
||||
@ApiProperty({ example: 'v1.4.0' }) name!: string;
|
||||
}
|
||||
|
||||
export class IssueDto {
|
||||
@ApiProperty({ format: 'uuid' }) id!: string;
|
||||
@ApiProperty({ format: 'uuid' }) projectId!: string;
|
||||
@ApiProperty({ example: 'WEB' }) projectKey!: string;
|
||||
@ApiProperty({ example: 12 }) number!: number;
|
||||
@ApiProperty({ example: 'WEB-12', description: 'Human key' }) key!: string;
|
||||
@ApiProperty({ enum: IssueType, enumName: 'IssueType' }) type!: IssueType;
|
||||
@ApiProperty({ enum: IssueArea, type: String, nullable: true })
|
||||
area!: IssueArea | null;
|
||||
@ApiProperty() title!: string;
|
||||
@ApiProperty({ type: String, nullable: true }) description!: string | null;
|
||||
@ApiProperty({ enum: IssueStatus, enumName: 'IssueStatus' })
|
||||
status!: IssueStatus;
|
||||
@ApiProperty({
|
||||
enum: IssueStatus,
|
||||
enumName: 'IssueStatus',
|
||||
isArray: true,
|
||||
description: 'Statuses this ticket can move to next',
|
||||
})
|
||||
allowedTransitions!: IssueStatus[];
|
||||
@ApiProperty({ enum: IssuePriority, enumName: 'IssuePriority' })
|
||||
priority!: IssuePriority;
|
||||
@ApiProperty({ type: UserRefDto }) reporter!: UserRefDto;
|
||||
@ApiProperty({ type: UserRefDto, nullable: true })
|
||||
assignee!: UserRefDto | null;
|
||||
@ApiProperty({ type: ReleaseRefDto, nullable: true })
|
||||
release!: ReleaseRefDto | null;
|
||||
@ApiProperty({ type: [String] }) labels!: string[];
|
||||
@ApiProperty({ type: String, nullable: true, description: 'Figma frame' })
|
||||
figmaUrl!: string | null;
|
||||
@ApiProperty({ type: Number, nullable: true }) storyPoints!: number | null;
|
||||
@ApiProperty({ type: String, nullable: true, format: 'date' })
|
||||
dueDate!: string | null;
|
||||
@ApiProperty({ type: [IssueLinkDto] }) links!: IssueLinkDto[];
|
||||
@ApiProperty() commentCount!: number;
|
||||
@ApiProperty({ format: 'date-time' }) createdAt!: string;
|
||||
@ApiProperty({ format: 'date-time' }) updatedAt!: string;
|
||||
@ApiProperty() version!: number;
|
||||
}
|
||||
|
||||
export class IssuePageDto {
|
||||
@ApiProperty({ type: [IssueDto] }) items!: IssueDto[];
|
||||
@ApiProperty({ description: 'Total matches (for pagination)' })
|
||||
total!: number;
|
||||
}
|
||||
|
||||
export class BoardColumnDto {
|
||||
@ApiProperty({ enum: IssueStatus, enumName: 'IssueStatus' })
|
||||
status!: IssueStatus;
|
||||
@ApiProperty({ type: [IssueDto] }) issues!: IssueDto[];
|
||||
@ApiProperty({ description: 'Tickets in the column (may exceed `issues`)' })
|
||||
total!: number;
|
||||
}
|
||||
|
||||
export class CommentDto {
|
||||
@ApiProperty({ format: 'uuid' }) id!: string;
|
||||
@ApiProperty({ format: 'uuid' }) issueId!: string;
|
||||
@ApiProperty({ type: UserRefDto }) author!: UserRefDto;
|
||||
@ApiProperty() body!: string;
|
||||
@ApiProperty({ format: 'date-time' }) createdAt!: string;
|
||||
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
|
||||
editedAt!: string | null;
|
||||
@ApiProperty() version!: number;
|
||||
}
|
||||
|
||||
export class CommentPageDto {
|
||||
@ApiProperty({ type: [CommentDto] }) items!: CommentDto[];
|
||||
@ApiProperty() total!: number;
|
||||
}
|
||||
|
||||
export class ReleaseDto {
|
||||
@ApiProperty({ format: 'uuid' }) id!: string;
|
||||
@ApiProperty({ format: 'uuid' }) projectId!: string;
|
||||
@ApiProperty({ example: 'v1.4.0' }) name!: string;
|
||||
@ApiProperty({ type: String, nullable: true }) description!: string | null;
|
||||
@ApiProperty({ type: String, nullable: true, format: 'date' })
|
||||
targetDate!: string | null;
|
||||
@ApiProperty({ enum: ReleaseStatus, enumName: 'ReleaseStatus' })
|
||||
status!: ReleaseStatus;
|
||||
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
|
||||
releasedAt!: string | null;
|
||||
@ApiProperty({ format: 'date-time' }) createdAt!: string;
|
||||
@ApiProperty() version!: number;
|
||||
}
|
||||
|
||||
export class CountByStatusDto {
|
||||
@ApiProperty({ enum: IssueStatus, enumName: 'IssueStatus' })
|
||||
key!: IssueStatus;
|
||||
@ApiProperty() count!: number;
|
||||
}
|
||||
|
||||
export class CountByKeyDto {
|
||||
@ApiProperty({ description: 'Type, priority or area ("NONE" = unset)' })
|
||||
key!: string;
|
||||
@ApiProperty() count!: number;
|
||||
}
|
||||
|
||||
export class CountByAssigneeDto {
|
||||
@ApiProperty({
|
||||
type: UserRefDto,
|
||||
nullable: true,
|
||||
description: 'Null = unassigned',
|
||||
})
|
||||
user!: UserRefDto | null;
|
||||
@ApiProperty() count!: number;
|
||||
}
|
||||
|
||||
export class ReleaseProgressDto {
|
||||
@ApiProperty() total!: number;
|
||||
@ApiProperty() finished!: number;
|
||||
@ApiProperty({ minimum: 0, maximum: 100 }) percent!: number;
|
||||
@ApiProperty({ type: [CountByStatusDto] }) byStatus!: CountByStatusDto[];
|
||||
}
|
||||
|
||||
export class ProjectStatsDto {
|
||||
@ApiProperty() total!: number;
|
||||
@ApiProperty({ description: 'Not done nor cancelled' }) open!: number;
|
||||
@ApiProperty({ type: [CountByStatusDto] }) byStatus!: CountByStatusDto[];
|
||||
@ApiProperty({ type: [CountByKeyDto] }) byType!: CountByKeyDto[];
|
||||
@ApiProperty({ type: [CountByKeyDto] }) byPriority!: CountByKeyDto[];
|
||||
@ApiProperty({ type: [CountByKeyDto] }) byArea!: CountByKeyDto[];
|
||||
@ApiProperty({
|
||||
type: [CountByAssigneeDto],
|
||||
description: 'Open tickets per person',
|
||||
})
|
||||
byAssignee!: CountByAssigneeDto[];
|
||||
}
|
||||
|
||||
export class ActivityDto {
|
||||
@ApiProperty() id!: string;
|
||||
@ApiProperty({ example: 'IssueStatusChanged' }) type!: string;
|
||||
@ApiProperty({ format: 'uuid' }) projectId!: string;
|
||||
@ApiProperty({ type: String, nullable: true }) issueId!: string | null;
|
||||
@ApiProperty({ type: String, nullable: true, example: 'WEB-12' })
|
||||
issueKey!: string | null;
|
||||
@ApiProperty({ type: UserRefDto }) actor!: UserRefDto;
|
||||
@ApiProperty({ example: 'Ana movió WEB-12 de En curso a En revisión' })
|
||||
summary!: string;
|
||||
@ApiProperty({ format: 'date-time' }) occurredAt!: string;
|
||||
}
|
||||
|
||||
export class CommandResultDto {
|
||||
@ApiProperty({ description: 'Id of the aggregate written' }) id!: string;
|
||||
@ApiPropertyOptional({
|
||||
description: 'Human key when one was assigned (project or issue)',
|
||||
example: 'WEB-13',
|
||||
})
|
||||
key?: string;
|
||||
@ApiProperty({
|
||||
description:
|
||||
'Version after the write. Writes are acknowledged at once and the read ' +
|
||||
'model catches up asynchronously: a GET may briefly return the previous ' +
|
||||
'version (or 404 right after a create).',
|
||||
})
|
||||
version!: number;
|
||||
}
|
||||
304
libs/api/tracker/presenters/src/lib/http/issues.controller.ts
Normal file
304
libs/api/tracker/presenters/src/lib/http/issues.controller.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
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 {
|
||||
ApiPireonAuth,
|
||||
CurrentUser,
|
||||
} from '@task-stream/api/identity/presenters';
|
||||
import {
|
||||
ApiCommandErrors,
|
||||
ApiReadErrors,
|
||||
} from '@task-stream/api/shared/presenters';
|
||||
import {
|
||||
CommentsService,
|
||||
IssuesService,
|
||||
} from '@task-stream/api/tracker/application';
|
||||
import { IssueLinkType } from '@task-stream/api/tracker/domain';
|
||||
import { withoutNulls } from '../common/issue-input';
|
||||
import {
|
||||
AssignIssueDto,
|
||||
ChangeStatusDto,
|
||||
CommentBodyDto,
|
||||
CreateIssueDto,
|
||||
IssueSearchQueryDto,
|
||||
LinkIssueDto,
|
||||
LinkTypeParamDto,
|
||||
PageQueryDto,
|
||||
SetIssueReleaseDto,
|
||||
UpdateIssueDto,
|
||||
} from './dto/requests.dto';
|
||||
import {
|
||||
ActivityDto,
|
||||
CommandResultDto,
|
||||
CommentPageDto,
|
||||
IssueDto,
|
||||
IssuePageDto,
|
||||
} from './dto/responses.dto';
|
||||
import { Refs, toIssueDto } from './refs';
|
||||
|
||||
const PROJECT = ApiParam({
|
||||
name: 'project',
|
||||
description: 'Project id or key',
|
||||
example: 'WEB',
|
||||
});
|
||||
const ISSUE = ApiParam({
|
||||
name: 'issue',
|
||||
description: 'Ticket id or key',
|
||||
example: 'WEB-12',
|
||||
});
|
||||
|
||||
/** REST adapter of the ticket use cases: bugs, features, UI changes, tasks and QA. */
|
||||
@ApiTags('Issues')
|
||||
@ApiPireonAuth()
|
||||
@Controller()
|
||||
export class IssuesController {
|
||||
constructor(
|
||||
private readonly issues: IssuesService,
|
||||
private readonly comments: CommentsService,
|
||||
private readonly refs: Refs,
|
||||
) {}
|
||||
|
||||
@Get('projects/:project/issues')
|
||||
@PROJECT
|
||||
@ApiOperation({ summary: 'Search the tickets of a project' })
|
||||
@ApiOkResponse({ type: IssuePageDto })
|
||||
@ApiReadErrors()
|
||||
async search(
|
||||
@CurrentUser() me: User,
|
||||
@Param('project') project: string,
|
||||
@Query() query: IssueSearchQueryDto,
|
||||
) {
|
||||
const page = await this.issues.search(me.id, {
|
||||
projectId: await this.refs.projectId(me.id, project),
|
||||
statuses: query.status,
|
||||
types: query.type,
|
||||
areas: query.area,
|
||||
priorities: query.priority,
|
||||
assigneeId: query.assigneeId,
|
||||
unassigned: query.unassigned,
|
||||
releaseId: query.releaseId,
|
||||
labels: query.label,
|
||||
search: query.search,
|
||||
limit: query.limit,
|
||||
offset: query.offset,
|
||||
});
|
||||
return { ...page, items: page.items.map(toIssueDto) };
|
||||
}
|
||||
|
||||
@Post('projects/:project/issues')
|
||||
@PROJECT
|
||||
@ApiOperation({
|
||||
summary: 'Create a ticket',
|
||||
description:
|
||||
'Starts in BACKLOG. The response carries its key (e.g. WEB-13).',
|
||||
})
|
||||
@ApiCreatedResponse({ type: CommandResultDto })
|
||||
@ApiCommandErrors()
|
||||
async create(
|
||||
@CurrentUser() me: User,
|
||||
@Param('project') project: string,
|
||||
@Body() body: CreateIssueDto,
|
||||
) {
|
||||
return this.issues.create(me.id, {
|
||||
...withoutNulls(body),
|
||||
projectId: await this.refs.projectId(me.id, project),
|
||||
});
|
||||
}
|
||||
|
||||
@Get('issues/:issue')
|
||||
@ISSUE
|
||||
@ApiOperation({ summary: 'A ticket' })
|
||||
@ApiOkResponse({ type: IssueDto })
|
||||
@ApiReadErrors()
|
||||
async findOne(@CurrentUser() me: User, @Param('issue') issue: string) {
|
||||
return toIssueDto(await this.issues.findOne(me.id, issue));
|
||||
}
|
||||
|
||||
@Patch('issues/:issue')
|
||||
@ISSUE
|
||||
@ApiOperation({
|
||||
summary: 'Edit title, description, type, priority, area, labels…',
|
||||
})
|
||||
@ApiOkResponse({ type: CommandResultDto })
|
||||
@ApiCommandErrors()
|
||||
async update(
|
||||
@CurrentUser() me: User,
|
||||
@Param('issue') issue: string,
|
||||
@Body() body: UpdateIssueDto,
|
||||
) {
|
||||
return this.issues.update(
|
||||
me.id,
|
||||
await this.refs.issueId(me.id, issue),
|
||||
withoutNulls(body),
|
||||
);
|
||||
}
|
||||
|
||||
@Put('issues/:issue/status')
|
||||
@ISSUE
|
||||
@ApiOperation({
|
||||
summary: 'Move the ticket through the workflow (incl. QA)',
|
||||
description:
|
||||
'Only the transitions in `allowedTransitions` are accepted (422 otherwise). ' +
|
||||
'QA_FAILED and CANCELLED need a reason.',
|
||||
})
|
||||
@ApiOkResponse({ type: CommandResultDto })
|
||||
@ApiCommandErrors()
|
||||
async changeStatus(
|
||||
@CurrentUser() me: User,
|
||||
@Param('issue') issue: string,
|
||||
@Body() body: ChangeStatusDto,
|
||||
) {
|
||||
return this.issues.changeStatus(
|
||||
me.id,
|
||||
await this.refs.issueId(me.id, issue),
|
||||
body.status,
|
||||
body.reason,
|
||||
);
|
||||
}
|
||||
|
||||
@Put('issues/:issue/assignee')
|
||||
@ISSUE
|
||||
@ApiOperation({ summary: 'Assign to a project member (null to unassign)' })
|
||||
@ApiOkResponse({ type: CommandResultDto })
|
||||
@ApiCommandErrors()
|
||||
async assign(
|
||||
@CurrentUser() me: User,
|
||||
@Param('issue') issue: string,
|
||||
@Body() body: AssignIssueDto,
|
||||
) {
|
||||
return this.issues.assign(
|
||||
me.id,
|
||||
await this.refs.issueId(me.id, issue),
|
||||
body.assigneeId,
|
||||
);
|
||||
}
|
||||
|
||||
@Put('issues/:issue/release')
|
||||
@ISSUE
|
||||
@ApiOperation({
|
||||
summary: 'Put the ticket in a planned release (null takes it out)',
|
||||
})
|
||||
@ApiOkResponse({ type: CommandResultDto })
|
||||
@ApiCommandErrors()
|
||||
async setRelease(
|
||||
@CurrentUser() me: User,
|
||||
@Param('issue') issue: string,
|
||||
@Body() body: SetIssueReleaseDto,
|
||||
) {
|
||||
return this.issues.setRelease(
|
||||
me.id,
|
||||
await this.refs.issueId(me.id, issue),
|
||||
body.releaseId,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('issues/:issue/links')
|
||||
@ISSUE
|
||||
@ApiOperation({
|
||||
summary: 'Relate tickets (FE ↔ BE ↔ QA)',
|
||||
description:
|
||||
'e.g. WEB-3 DEPENDS_ON API-7, WEB-9 TESTS WEB-3. May cross projects.',
|
||||
})
|
||||
@ApiCreatedResponse({ type: CommandResultDto })
|
||||
@ApiCommandErrors()
|
||||
async link(
|
||||
@CurrentUser() me: User,
|
||||
@Param('issue') issue: string,
|
||||
@Body() body: LinkIssueDto,
|
||||
) {
|
||||
return this.issues.link(me.id, {
|
||||
issueId: await this.refs.issueId(me.id, issue),
|
||||
type: body.type,
|
||||
targetIssueId: await this.refs.issueId(me.id, body.targetIssue),
|
||||
});
|
||||
}
|
||||
|
||||
@Delete('issues/:issue/links/:type/:target')
|
||||
@ISSUE
|
||||
@ApiParam({ name: 'type', enum: IssueLinkType, enumName: 'IssueLinkType' })
|
||||
@ApiParam({ name: 'target', description: 'Other ticket: id or key' })
|
||||
@ApiOperation({ summary: 'Remove a relation' })
|
||||
@ApiOkResponse({ type: CommandResultDto })
|
||||
@ApiCommandErrors()
|
||||
async unlink(
|
||||
@CurrentUser() me: User,
|
||||
@Param('issue') issue: string,
|
||||
@Param() params: LinkTypeParamDto,
|
||||
@Param('target') target: string,
|
||||
) {
|
||||
return this.issues.unlink(me.id, {
|
||||
issueId: await this.refs.issueId(me.id, issue),
|
||||
type: params.type,
|
||||
targetIssueId: await this.refs.issueId(me.id, target),
|
||||
});
|
||||
}
|
||||
|
||||
@Get('issues/:issue/comments')
|
||||
@ISSUE
|
||||
@ApiOperation({ summary: 'Comments of the ticket (oldest first)' })
|
||||
@ApiOkResponse({ type: CommentPageDto })
|
||||
@ApiReadErrors()
|
||||
async listComments(
|
||||
@CurrentUser() me: User,
|
||||
@Param('issue') issue: string,
|
||||
@Query() page: PageQueryDto,
|
||||
) {
|
||||
return this.comments.list(
|
||||
me.id,
|
||||
await this.refs.issueId(me.id, issue),
|
||||
page.limit,
|
||||
page.offset,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('issues/:issue/comments')
|
||||
@ISSUE
|
||||
@ApiOperation({ summary: 'Comment on the ticket' })
|
||||
@ApiCreatedResponse({ type: CommandResultDto })
|
||||
@ApiCommandErrors()
|
||||
async addComment(
|
||||
@CurrentUser() me: User,
|
||||
@Param('issue') issue: string,
|
||||
@Body() body: CommentBodyDto,
|
||||
) {
|
||||
return this.comments.add(
|
||||
me.id,
|
||||
await this.refs.issueId(me.id, issue),
|
||||
body.body,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('issues/:issue/history')
|
||||
@ISSUE
|
||||
@ApiOperation({ summary: 'History of the ticket (newest first)' })
|
||||
@ApiOkResponse({ type: [ActivityDto] })
|
||||
@ApiReadErrors()
|
||||
async history(
|
||||
@CurrentUser() me: User,
|
||||
@Param('issue') issue: string,
|
||||
@Query() page: PageQueryDto,
|
||||
) {
|
||||
const found = await this.issues.findOne(me.id, issue);
|
||||
return this.issues.activity(me.id, {
|
||||
projectId: found.projectId,
|
||||
issueId: found.id,
|
||||
limit: page.limit,
|
||||
});
|
||||
}
|
||||
}
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
39
libs/api/tracker/presenters/src/lib/http/refs.ts
Normal file
39
libs/api/tracker/presenters/src/lib/http/refs.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
IssuesService,
|
||||
ProjectsService,
|
||||
} from '@task-stream/api/tracker/application';
|
||||
import { IssueReadModel, IssueWorkflow } from '@task-stream/api/tracker/domain';
|
||||
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/**
|
||||
* REST routes accept a project or ticket by id or by key (`WEB`, `WEB-12`),
|
||||
* which is what other apps usually have. Keys are looked up in the read model;
|
||||
* ids go straight to the command (the service still checks membership).
|
||||
*/
|
||||
@Injectable()
|
||||
export class Refs {
|
||||
constructor(
|
||||
private readonly projects: ProjectsService,
|
||||
private readonly issues: IssuesService,
|
||||
) {}
|
||||
|
||||
async projectId(viewerId: string, ref: string): Promise<string> {
|
||||
if (UUID.test(ref)) return ref;
|
||||
return (await this.projects.findOne(viewerId, ref)).id;
|
||||
}
|
||||
|
||||
async issueId(viewerId: string, ref: string): Promise<string> {
|
||||
if (UUID.test(ref)) return ref;
|
||||
return (await this.issues.findOne(viewerId, ref)).id;
|
||||
}
|
||||
}
|
||||
|
||||
/** The ticket as the REST API returns it: read model + allowed transitions. */
|
||||
export function toIssueDto(issue: IssueReadModel) {
|
||||
return {
|
||||
...issue,
|
||||
allowedTransitions: IssueWorkflow.allowedFrom(issue.status),
|
||||
};
|
||||
}
|
||||
164
libs/api/tracker/presenters/src/lib/http/releases.controller.ts
Normal file
164
libs/api/tracker/presenters/src/lib/http/releases.controller.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
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 {
|
||||
ApiPireonAuth,
|
||||
CurrentUser,
|
||||
} from '@task-stream/api/identity/presenters';
|
||||
import {
|
||||
ApiCommandErrors,
|
||||
ApiReadErrors,
|
||||
} from '@task-stream/api/shared/presenters';
|
||||
import {
|
||||
IssuesService,
|
||||
ProjectsService,
|
||||
ReleasesService,
|
||||
} from '@task-stream/api/tracker/application';
|
||||
import {
|
||||
CreateReleaseDto,
|
||||
PageQueryDto,
|
||||
PublishReleaseDto,
|
||||
UpdateReleaseDto,
|
||||
} from './dto/requests.dto';
|
||||
import {
|
||||
CommandResultDto,
|
||||
IssuePageDto,
|
||||
ReleaseDto,
|
||||
ReleaseProgressDto,
|
||||
} 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 release use cases: versions that group tickets. */
|
||||
@ApiTags('Releases')
|
||||
@ApiPireonAuth()
|
||||
@Controller()
|
||||
export class ReleasesController {
|
||||
constructor(
|
||||
private readonly releases: ReleasesService,
|
||||
private readonly projects: ProjectsService,
|
||||
private readonly issues: IssuesService,
|
||||
private readonly refs: Refs,
|
||||
) {}
|
||||
|
||||
@Get('projects/:project/releases')
|
||||
@PROJECT
|
||||
@ApiOperation({ summary: 'Releases of a project' })
|
||||
@ApiOkResponse({ type: [ReleaseDto] })
|
||||
@ApiReadErrors()
|
||||
async list(@CurrentUser() me: User, @Param('project') project: string) {
|
||||
return this.projects.releases(
|
||||
me.id,
|
||||
await this.refs.projectId(me.id, project),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('projects/:project/releases')
|
||||
@PROJECT
|
||||
@ApiOperation({ summary: 'Plan a release (owner/admin)' })
|
||||
@ApiCreatedResponse({ type: CommandResultDto })
|
||||
@ApiCommandErrors()
|
||||
async create(
|
||||
@CurrentUser() me: User,
|
||||
@Param('project') project: string,
|
||||
@Body() body: CreateReleaseDto,
|
||||
) {
|
||||
return this.releases.create(me.id, {
|
||||
...body,
|
||||
projectId: await this.refs.projectId(me.id, project),
|
||||
});
|
||||
}
|
||||
|
||||
@Get('releases/:releaseId')
|
||||
@ApiOperation({ summary: 'A release' })
|
||||
@ApiOkResponse({ type: ReleaseDto })
|
||||
@ApiReadErrors()
|
||||
findOne(@CurrentUser() me: User, @Param('releaseId') releaseId: string) {
|
||||
return this.releases.findOne(me.id, releaseId);
|
||||
}
|
||||
|
||||
@Patch('releases/:releaseId')
|
||||
@ApiOperation({ summary: 'Rename, describe or move the target date' })
|
||||
@ApiOkResponse({ type: CommandResultDto })
|
||||
@ApiCommandErrors()
|
||||
update(
|
||||
@CurrentUser() me: User,
|
||||
@Param('releaseId') releaseId: string,
|
||||
@Body() body: UpdateReleaseDto,
|
||||
) {
|
||||
return this.releases.update(me.id, releaseId, body);
|
||||
}
|
||||
|
||||
@Get('releases/:releaseId/progress')
|
||||
@ApiOperation({ summary: 'Finished tickets out of the total' })
|
||||
@ApiOkResponse({ type: ReleaseProgressDto })
|
||||
@ApiReadErrors()
|
||||
progress(@CurrentUser() me: User, @Param('releaseId') releaseId: string) {
|
||||
return this.releases.progress(me.id, releaseId);
|
||||
}
|
||||
|
||||
@Get('releases/:releaseId/issues')
|
||||
@ApiOperation({ summary: 'Tickets included in the release' })
|
||||
@ApiOkResponse({ type: IssuePageDto })
|
||||
@ApiReadErrors()
|
||||
async issuesOf(
|
||||
@CurrentUser() me: User,
|
||||
@Param('releaseId') releaseId: string,
|
||||
@Query() page: PageQueryDto,
|
||||
) {
|
||||
const release = await this.releases.findOne(me.id, releaseId);
|
||||
const found = await this.issues.search(me.id, {
|
||||
projectId: release.projectId,
|
||||
releaseId: release.id,
|
||||
limit: page.limit,
|
||||
offset: page.offset,
|
||||
});
|
||||
return { ...found, items: found.items.map(toIssueDto) };
|
||||
}
|
||||
|
||||
@Post('releases/:releaseId/publish')
|
||||
@HttpCode(200)
|
||||
@ApiOperation({
|
||||
summary: 'Ship the release',
|
||||
description:
|
||||
'Fails (422) while it has unfinished tickets unless `force` is true.',
|
||||
})
|
||||
@ApiOkResponse({ type: CommandResultDto })
|
||||
@ApiCommandErrors()
|
||||
publish(
|
||||
@CurrentUser() me: User,
|
||||
@Param('releaseId') releaseId: string,
|
||||
@Body() body: PublishReleaseDto,
|
||||
) {
|
||||
return this.releases.publish(me.id, releaseId, body.force);
|
||||
}
|
||||
|
||||
@Post('releases/:releaseId/archive')
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: 'Archive a planned release (its tickets leave it)' })
|
||||
@ApiOkResponse({ type: CommandResultDto })
|
||||
@ApiCommandErrors()
|
||||
archive(@CurrentUser() me: User, @Param('releaseId') releaseId: string) {
|
||||
return this.releases.archive(me.id, releaseId);
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,26 @@ import { IssueResolver } from './graphql/issue.resolver';
|
||||
import { ProjectResolver } from './graphql/project.resolver';
|
||||
import { ReleaseResolver } from './graphql/release.resolver';
|
||||
import { SubscriptionResolver } from './graphql/subscription.resolver';
|
||||
import { CommentsController } from './http/comments.controller';
|
||||
import { IssuesController } from './http/issues.controller';
|
||||
import { ProjectsController } from './http/projects.controller';
|
||||
import { Refs } from './http/refs';
|
||||
import { ReleasesController } from './http/releases.controller';
|
||||
|
||||
/** Driving adapters of the tracker context (GraphQL queries, mutations, subscriptions). */
|
||||
/**
|
||||
* Driving adapters of the tracker context: GraphQL (queries, mutations,
|
||||
* subscriptions) and REST with OpenAPI for other apps. Both call the same
|
||||
* application services.
|
||||
*/
|
||||
@Module({
|
||||
controllers: [
|
||||
ProjectsController,
|
||||
IssuesController,
|
||||
CommentsController,
|
||||
ReleasesController,
|
||||
],
|
||||
providers: [
|
||||
Refs,
|
||||
ProjectResolver,
|
||||
IssueResolver,
|
||||
CommentResolver,
|
||||
|
||||
Reference in New Issue
Block a user