import { ApiProperty, ApiPropertyOptional, OmitType, PartialType, } from '@nestjs/swagger'; import { IssueLinkType, IssuePriority, ProjectRole, SETTING_COLORS, StatusCategory, TYPE_ICONS, } from '@task-stream/api/tracker/domain'; import { Transform, Type } from 'class-transformer'; import { ArrayMaxSize, IsArray, IsBoolean, IsEnum, IsIn, IsInt, IsISO8601, IsOptional, IsUUID, IsString, IsUrl, Matches, Max, MaxLength, Min, MinLength, ValidateIf, ValidateNested, } 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'; /** Ids of statuses, types and areas (checked against the project's settings). */ const SETTING_ID = /^[A-Za-z][A-Za-z0-9_]{0,29}$/; // ---- 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; @ApiPropertyOptional({ default: false, description: 'Anyone can read it without signing in (board, tickets, comments, releases, activity, statistics)', }) @IsOptional() @IsBoolean() isPublic?: boolean; } export class UpdateProjectDto extends PartialType( OmitType(CreateProjectDto, ['key'] as const), ) {} export class DeleteProjectQueryDto { @ApiProperty({ example: 'WEB', description: 'The project key, to confirm the deletion', }) @IsString() confirm!: string; } 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; } // ---- Project settings ------------------------------------------------------- export class WorkflowStatusBodyDto { @ApiProperty({ example: 'IN_REVIEW', description: 'Never change an existing id', }) @Matches(SETTING_ID) id!: string; @ApiProperty({ example: 'En revisión', maxLength: 40 }) @IsString() @MinLength(1) @MaxLength(40) name!: string; @ApiProperty({ enum: StatusCategory, enumName: 'StatusCategory' }) @IsEnum(StatusCategory) category!: StatusCategory; @ApiProperty({ enum: SETTING_COLORS, enumName: 'SettingColor' }) @IsIn(SETTING_COLORS) color!: string; @ApiPropertyOptional({ type: Boolean, default: false }) @IsOptional() @IsBoolean() requiresReason = false; @ApiPropertyOptional({ type: Boolean, default: false }) @IsOptional() @IsBoolean() archived = false; } export class StatusTransitionsBodyDto { @ApiProperty({ example: 'IN_QA' }) @Matches(SETTING_ID) from!: string; @ApiProperty({ type: [String], example: ['DONE', 'QA_FAILED'] }) @IsArray() @ArrayMaxSize(50) @Matches(SETTING_ID, { each: true }) to!: string[]; } export class IssueTypeOptionBodyDto { @ApiProperty({ example: 'SPIKE' }) @Matches(SETTING_ID) id!: string; @ApiProperty({ example: 'Spike', maxLength: 40 }) @IsString() @MinLength(1) @MaxLength(40) name!: string; @ApiProperty({ enum: TYPE_ICONS, enumName: 'TypeIcon' }) @IsIn(TYPE_ICONS) icon!: string; @ApiProperty({ enum: SETTING_COLORS, enumName: 'SettingColor' }) @IsIn(SETTING_COLORS) color!: string; @ApiPropertyOptional({ type: Boolean, default: false }) @IsOptional() @IsBoolean() archived = false; } export class AreaOptionBodyDto { @ApiProperty({ example: 'DATA' }) @Matches(SETTING_ID) id!: string; @ApiProperty({ example: 'Datos', maxLength: 40 }) @IsString() @MinLength(1) @MaxLength(40) name!: string; @ApiPropertyOptional({ type: Boolean, default: false }) @IsOptional() @IsBoolean() archived = false; } export class ProjectSettingsBodyDto { @ApiProperty({ type: [WorkflowStatusBodyDto], description: 'In board order. Values cannot be removed: archive them instead.', }) @IsArray() @ArrayMaxSize(50) @ValidateNested({ each: true }) @Type(() => WorkflowStatusBodyDto) statuses!: WorkflowStatusBodyDto[]; @ApiProperty({ type: [StatusTransitionsBodyDto] }) @IsArray() @ArrayMaxSize(50) @ValidateNested({ each: true }) @Type(() => StatusTransitionsBodyDto) transitions!: StatusTransitionsBodyDto[]; @ApiProperty({ type: [IssueTypeOptionBodyDto] }) @IsArray() @ArrayMaxSize(50) @ValidateNested({ each: true }) @Type(() => IssueTypeOptionBodyDto) issueTypes!: IssueTypeOptionBodyDto[]; @ApiProperty({ type: [AreaOptionBodyDto] }) @IsArray() @ArrayMaxSize(50) @ValidateNested({ each: true }) @Type(() => AreaOptionBodyDto) areas!: AreaOptionBodyDto[]; } export class AnalyticsQueryDto { @ApiPropertyOptional({ type: Number, default: 90, minimum: 7, maximum: 365 }) @IsOptional() @Type(() => Number) @IsInt() @Min(7) @Max(365) days = 90; } 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({ example: 'FRONTEND', description: 'An area id' }) @IsOptional() @Matches(SETTING_ID) area?: string; @ApiPropertyOptional({ type: Number, 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({ type: Number, 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({ type: Number, default: 50, minimum: 1, maximum: 200 }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(200) limit = 50; @ApiPropertyOptional({ type: Number, default: 0, minimum: 0 }) @IsOptional() @Type(() => Number) @IsInt() @Min(0) offset = 0; } // ---- Issues ----------------------------------------------------------------- export class MyIssuesQueryDto extends PageQueryDto { @ApiPropertyOptional({ type: Boolean, default: true, description: 'true: tickets assigned to me. false: any ticket of my projects (e.g. ?assignedToMe=false&category=QA)', }) @IsOptional() @Transform(toBoolean) @IsBoolean() assignedToMe = true; @ApiPropertyOptional({ type: [String], description: 'Status ids. Repeat the parameter or separate with commas', }) @IsOptional() @Transform(toList) @Matches(SETTING_ID, { each: true }) status?: string[]; @ApiPropertyOptional({ enum: StatusCategory, enumName: 'StatusCategory', isArray: true, description: 'Whatever each project calls its statuses', }) @IsOptional() @Transform(toList) @IsEnum(StatusCategory, { each: true }) category?: StatusCategory[]; } export class UpcomingReleasesQueryDto { @ApiPropertyOptional({ type: Number, default: 10, minimum: 1, maximum: 50 }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(50) limit = 10; } export class IssueSearchQueryDto extends PageQueryDto { @ApiPropertyOptional({ type: [String], description: 'Status ids. Repeat the parameter or separate with commas', }) @IsOptional() @Transform(toList) @Matches(SETTING_ID, { each: true }) status?: string[]; @ApiPropertyOptional({ enum: StatusCategory, enumName: 'StatusCategory', isArray: true, }) @IsOptional() @Transform(toList) @IsEnum(StatusCategory, { each: true }) category?: StatusCategory[]; @ApiPropertyOptional({ type: [String], description: 'Ticket type ids' }) @IsOptional() @Transform(toList) @Matches(SETTING_ID, { each: true }) type?: string[]; @ApiPropertyOptional({ type: [String], description: 'Area ids' }) @IsOptional() @Transform(toList) @Matches(SETTING_ID, { each: true }) area?: string[]; @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: 'Only the tickets hanging from this one (id or key)', example: 'WEB-3', }) @IsOptional() @IsString() parent?: 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({ type: String, nullable: true, example: 'FRONTEND', description: "An area id of the project's settings", }) @IsOptional() @Matches(SETTING_ID) area?: string | 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({ example: 'BUG', description: "A ticket type id of the project's settings", }) @Matches(SETTING_ID) type!: string; @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; @ApiPropertyOptional({ type: String, nullable: true, example: 'WEB-3', description: 'Epic or ticket of the same project the new one breaks down (id or key)', }) @IsOptional() @IsString() parent?: string | null; } export class UpdateIssueDto extends IssueFieldsDto { @ApiPropertyOptional({ example: 'FEATURE', description: 'A ticket type id' }) @IsOptional() @Matches(SETTING_ID) type?: string; @ApiPropertyOptional({ maxLength: 255 }) @IsOptional() @IsString() @MinLength(1) @MaxLength(255) title?: string; } export class ChangeStatusDto { @ApiProperty({ example: 'IN_REVIEW', description: "A status id of the project's settings, among the ticket's allowedTransitions", }) @Matches(SETTING_ID) status!: string; @ApiPropertyOptional({ type: String, nullable: true, description: 'Required by statuses with requiresReason (e.g. QA_FAILED)', }) @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 SetIssueParentDto { @ApiProperty({ type: String, nullable: true, example: 'WEB-3', description: 'Epic or ticket of the same project (id or key), or null to take it back to the top', }) @ValidateIf((dto: SetIssueParentDto) => dto.parent !== null) @IsString() parent!: string | null; } export class ChecklistItemBodyDto { @ApiProperty({ example: 'Actualizar el changelog', maxLength: 300 }) @IsString() @MinLength(1) @MaxLength(300) text!: string; } export class UpdateChecklistItemDto { @ApiPropertyOptional({ maxLength: 300 }) @IsOptional() @IsString() @MinLength(1) @MaxLength(300) text?: string; @ApiPropertyOptional() @IsOptional() @IsBoolean() done?: boolean; } 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; } /** Multipart body of an upload; the file itself travels as `file`. */ export class AttachFileDto { @ApiProperty({ type: 'string', format: 'binary', description: 'The file' }) file?: unknown; @ApiPropertyOptional({ format: 'uuid', description: "One of the ticket's comments, to show the file under it", }) @IsOptional() @IsUUID() commentId?: string; } 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({ type: Boolean, default: false, description: 'Publish even with unfinished tickets', }) @IsOptional() @IsBoolean() force = false; } // ---- Notifications ---------------------------------------------------------- export class MyNotificationsQueryDto { @ApiPropertyOptional({ default: false, description: 'Only unread ones' }) @IsOptional() @Transform(toBoolean) @IsBoolean() unreadOnly = false; @ApiPropertyOptional({ type: Number, default: 30, minimum: 1, maximum: 100 }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100) limit = 30; } export class MarkNotificationsReadDto { @ApiPropertyOptional({ type: [String], description: 'Omit to mark all of them', }) @IsOptional() @IsArray() @ArrayMaxSize(200) @IsString({ each: true }) ids?: string[]; } export class NotificationSettingsDto { @ApiProperty({ description: 'Copies by e-mail' }) @IsBoolean() email!: boolean; }