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:
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;
|
||||
}
|
||||
Reference in New Issue
Block a user