import { ApiProperty, ApiPropertyOptional, OmitType, PartialType, } from '@nestjs/swagger'; import { CustomFieldType, IssueLinkType, IssuePriority, ProjectRole, REACTIONS, SETTING_COLORS, StatusCategory, TYPE_ICONS, } from '@task-stream/api/tracker/domain'; import { IssueSort } from '@task-stream/api/tracker/application'; import { Transform, Type } from 'class-transformer'; import { ArrayMaxSize, IsArray, IsBoolean, IsEnum, IsIn, IsInt, IsISO8601, IsObject, 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; @ApiPropertyOptional({ type: String, nullable: true, example: 'API', description: 'A project you own or administer (id or key) to start from: its statuses, transitions, types, areas, custom fields and automation rules are copied', }) @IsOptional() @IsString() template?: string | null; } export class UpdateProjectDto extends PartialType( OmitType(CreateProjectDto, ['key', 'template'] as const), ) { @ApiPropertyOptional({ description: 'Take bug reports and ideas from anyone through the public form (while public)', }) @IsOptional() @IsBoolean() acceptsProposals?: boolean; } 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; @ApiPropertyOptional({ type: Number, nullable: true, minimum: 1, maximum: 999, description: 'Work-in-progress limit (null: none)', }) @IsOptional() @IsInt() @Min(1) @Max(999) wipLimit?: number | null; } 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 IssueTemplateBodyDto { @ApiPropertyOptional({ type: String, nullable: true, maxLength: 5000 }) @IsOptional() @IsString() @MaxLength(5000) description?: string | null; @ApiPropertyOptional({ type: [String], maxItems: 20 }) @IsOptional() @IsArray() @ArrayMaxSize(20) @IsString({ each: true }) @MaxLength(300, { each: true }) checklist?: 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; @ApiPropertyOptional({ type: IssueTemplateBodyDto, nullable: true, description: 'What a new ticket of the type starts with', }) @IsOptional() @ValidateNested() @Type(() => IssueTemplateBodyDto) template?: IssueTemplateBodyDto | null; } 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 TeamBodyDto { @ApiProperty({ example: 'FRONTEND' }) @Matches(SETTING_ID) id!: string; @ApiProperty({ example: 'Frontend', maxLength: 40 }) @IsString() @MinLength(1) @MaxLength(40) name!: string; @ApiPropertyOptional({ type: [String], description: 'Pireon user ids of project members (others are dropped)', }) @IsOptional() @IsArray() @ArrayMaxSize(200) @IsString({ each: true }) memberIds: string[] = []; @ApiPropertyOptional({ type: Boolean, default: false }) @IsOptional() @IsBoolean() archived = false; } export class CustomFieldDefinitionBodyDto { @ApiProperty({ example: 'BROWSER' }) @Matches(SETTING_ID) id!: string; @ApiProperty({ example: 'Navegador', maxLength: 40 }) @IsString() @MinLength(1) @MaxLength(40) name!: string; @ApiProperty({ enum: CustomFieldType, enumName: 'CustomFieldType', description: 'Cannot change once the field exists. DATE: YYYY-MM-DD', }) @IsEnum(CustomFieldType) type!: CustomFieldType; @ApiPropertyOptional({ type: [String], maxItems: 50, example: ['Chrome', 'Safari', 'Firefox'], description: 'The choices of a SELECT field', }) @IsOptional() @IsArray() @ArrayMaxSize(50) @IsString({ each: true }) @MaxLength(60, { each: true }) options: 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[]; @ApiPropertyOptional({ type: [CustomFieldDefinitionBodyDto], description: 'Absent: unchanged. Fields cannot be removed: archive them.', }) @IsOptional() @IsArray() @ArrayMaxSize(30) @ValidateNested({ each: true }) @Type(() => CustomFieldDefinitionBodyDto) customFields?: CustomFieldDefinitionBodyDto[]; @ApiPropertyOptional({ type: [TeamBodyDto], description: 'Absent: unchanged. Teams cannot be removed: archive them.', }) @IsOptional() @IsArray() @ArrayMaxSize(30) @ValidateNested({ each: true }) @Type(() => TeamBodyDto) teams?: TeamBodyDto[]; @ApiPropertyOptional({ description: 'Definition of done: tickets reach a DONE status only with their checklist ticked (owners and admins may override). Absent: unchanged', }) @IsOptional() @IsBoolean() checklistBeforeDone?: boolean; } export class AnalyticsQueryDto { @ApiPropertyOptional({ type: Number, default: 90, minimum: 7, maximum: 365 }) @IsOptional() @Type(() => Number) @IsInt() @Min(7) @Max(365) days = 90; } export class InsightsQueryDto { @ApiPropertyOptional({ description: 'ISO instant (e.g. your last visit): the answer also tells what changed after it', example: '2026-10-14T08:00:00Z', }) @IsOptional() @IsISO8601() since?: string; } 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: [String], description: 'Ticket type ids' }) @IsOptional() @Transform(toList) @Matches(SETTING_ID, { each: true }) type?: string[]; @ApiPropertyOptional({ enum: IssuePriority, enumName: 'IssuePriority', isArray: true, }) @IsOptional() @Transform(toList) @IsEnum(IssuePriority, { each: true }) priority?: IssuePriority[]; @ApiPropertyOptional({ type: [String] }) @IsOptional() @Transform(toList) @IsString({ each: true }) label?: string[]; @ApiPropertyOptional({ description: 'Matches key, title or description' }) @IsOptional() @IsString() @MaxLength(200) search?: string; @ApiPropertyOptional({ description: 'Only tickets without assignee' }) @IsOptional() @Transform(toBoolean) @IsBoolean() unassigned?: boolean; @ApiPropertyOptional({ format: 'uuid', description: 'Only this sprint' }) @IsOptional() @IsUUID() sprintId?: 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; @ApiPropertyOptional({ format: 'date-time', description: 'Entries at or after this date (with before, a window such as a day)', }) @IsOptional() @IsISO8601() after?: 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; } export class DecisionsQueryDto { @ApiPropertyOptional({ description: 'Words in the decision' }) @IsOptional() @IsString() @MaxLength(200) search?: string; @ApiPropertyOptional({ type: Number, default: 20, minimum: 1, maximum: 100 }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100) limit = 20; } // ---- 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: Boolean, description: 'Only the tickets I follow (with assignedToMe=false, whoever they are assigned to)', }) @IsOptional() @Transform(toBoolean) @IsBoolean() watching?: boolean; @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[]; @ApiPropertyOptional({ description: 'Matches key, title or description' }) @IsOptional() @IsString() @MaxLength(200) search?: string; @ApiPropertyOptional({ type: [String], description: 'Only these of my projects (ids). Repeat or separate with commas', }) @IsOptional() @Transform(toList) @IsUUID('all', { each: true }) project?: string[]; @ApiPropertyOptional({ enum: IssuePriority, enumName: 'IssuePriority', isArray: true, }) @IsOptional() @Transform(toList) @IsEnum(IssuePriority, { each: true }) priority?: IssuePriority[]; @ApiPropertyOptional({ description: 'Only types with the bug icon' }) @IsOptional() @Transform(toBoolean) @IsBoolean() bugsOnly?: boolean; @ApiPropertyOptional({ description: 'Only open tickets past their due date' }) @IsOptional() @Transform(toBoolean) @IsBoolean() overdue?: boolean; } 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({ type: [String], description: 'Team ids' }) @IsOptional() @Transform(toList) @Matches(SETTING_ID, { each: true }) team?: 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({ format: 'uuid', description: 'Only this sprint' }) @IsOptional() @IsUUID() sprintId?: string; @ApiPropertyOptional({ description: 'Only tickets in no sprint' }) @IsOptional() @Transform(toBoolean) @IsBoolean() noSprint?: boolean; @ApiPropertyOptional({ type: Boolean, description: 'Only the tickets I follow (none without a token)', }) @IsOptional() @Transform(toBoolean) @IsBoolean() watching?: boolean; @ApiPropertyOptional({ format: 'date', description: 'Due on or after this day', }) @IsOptional() @Matches(/^\d{4}-\d{2}-\d{2}$/) dueFrom?: string; @ApiPropertyOptional({ format: 'date', description: 'Due on or before this day', }) @IsOptional() @Matches(/^\d{4}-\d{2}-\d{2}$/) dueTo?: string; @ApiPropertyOptional({ description: 'Only tickets nobody estimated' }) @IsOptional() @Transform(toBoolean) @IsBoolean() noEstimate?: boolean; @ApiPropertyOptional({ description: 'Only tickets with no area' }) @IsOptional() @Transform(toBoolean) @IsBoolean() noArea?: boolean; @ApiPropertyOptional({ description: 'Only tickets not updated since this instant (ISO)', }) @IsOptional() @IsISO8601() updatedBefore?: string; @ApiPropertyOptional({ type: [String], example: ['BROWSER:Safari'], description: 'Custom values the tickets must hold, as FIELD:value. Repeat the parameter for several', }) @IsOptional() @Transform(({ value }) => value === undefined ? undefined : ([] as unknown[]).concat(value).map(String), ) @IsArray() @ArrayMaxSize(30) @Matches(/^[A-Za-z][A-Za-z0-9_]{0,29}:[\s\S]+$/, { each: true }) field?: string[]; @ApiPropertyOptional({ enum: IssueSort, enumName: 'IssueSort', description: 'RECENT (default): last updated first. RANK: backlog order.', }) @IsOptional() @IsEnum(IssueSort) sort?: IssueSort; @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({ type: String, nullable: true, example: 'FRONTEND', description: "A team id of the project's settings", }) @IsOptional() @Matches(SETTING_ID) team?: 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', description: 'When work should start (roadmap)', }) @IsOptional() @IsISO8601() startDate?: string | null; @ApiPropertyOptional({ type: String, nullable: true, format: 'date' }) @IsOptional() @IsISO8601() dueDate?: string | null; @ApiPropertyOptional({ type: 'object', additionalProperties: { type: 'string', nullable: true }, example: { BROWSER: 'Safari' }, description: "Custom field id → value. Only the fields to change; null or '' clears one", }) @IsOptional() @IsObject() customFields?: Record; } 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 SavedFilterCriteriaDto { @ApiPropertyOptional({ type: [String], description: 'Ticket type ids' }) @IsOptional() @Matches(SETTING_ID, { each: true }) types?: string[]; @ApiPropertyOptional({ type: [String], description: 'Area ids' }) @IsOptional() @Matches(SETTING_ID, { each: true }) areas?: string[]; @ApiPropertyOptional({ enum: IssuePriority, enumName: 'IssuePriority', isArray: true, }) @IsOptional() @IsEnum(IssuePriority, { each: true }) priorities?: IssuePriority[]; @ApiPropertyOptional({ type: String, nullable: true, description: 'Pireon user id', }) @IsOptional() @IsString() assigneeId?: string | null; @ApiPropertyOptional() @IsOptional() @IsBoolean() unassigned?: boolean; @ApiPropertyOptional({ type: String, nullable: true, format: 'uuid' }) @IsOptional() @IsString() releaseId?: string | null; @ApiPropertyOptional({ type: [String] }) @IsOptional() @ArrayMaxSize(20) @IsString({ each: true }) labels?: string[]; @ApiPropertyOptional({ type: String, nullable: true, maxLength: 200 }) @IsOptional() @IsString() @MaxLength(200) search?: string | null; } export class MyViewCriteriaDto { @ApiPropertyOptional({ type: [String], format: 'uuid', description: 'Only these of my projects (empty: all)', }) @IsOptional() @IsArray() @ArrayMaxSize(50) @IsUUID('all', { each: true }) projectIds?: string[]; @ApiPropertyOptional() @IsOptional() @IsBoolean() assignedToMe?: boolean; @ApiPropertyOptional({ description: 'Only the tickets I follow' }) @IsOptional() @IsBoolean() watching?: boolean; @ApiPropertyOptional({ enum: StatusCategory, enumName: 'StatusCategory', isArray: true, }) @IsOptional() @IsEnum(StatusCategory, { each: true }) categories?: StatusCategory[]; @ApiPropertyOptional({ enum: IssuePriority, enumName: 'IssuePriority', isArray: true, }) @IsOptional() @IsEnum(IssuePriority, { each: true }) priorities?: IssuePriority[]; @ApiPropertyOptional({ description: 'Only types with the bug icon' }) @IsOptional() @IsBoolean() bugsOnly?: boolean; @ApiPropertyOptional({ description: 'Only open tickets past due' }) @IsOptional() @IsBoolean() overdue?: boolean; @ApiPropertyOptional({ maxLength: 200 }) @IsOptional() @IsString() @MaxLength(200) search?: string; } export class SaveMyViewDto { @ApiProperty({ example: 'Bugs críticos de todos mis proyectos', maxLength: 60, }) @IsString() @MinLength(1) @MaxLength(60) name!: string; @ApiProperty({ type: MyViewCriteriaDto }) @ValidateNested() @Type(() => MyViewCriteriaDto) criteria!: MyViewCriteriaDto; } export class SaveFilterDto { @ApiProperty({ example: 'Bugs críticos de frontend', maxLength: 60 }) @IsString() @MinLength(1) @MaxLength(60) name!: string; @ApiPropertyOptional({ default: false, description: 'Visible to the whole project (members, not viewers, share)', }) @IsOptional() @IsBoolean() shared = false; @ApiProperty({ type: SavedFilterCriteriaDto }) @ValidateNested() @Type(() => SavedFilterCriteriaDto) criteria!: SavedFilterCriteriaDto; } export class CreateWebhookDto { @ApiProperty({ example: 'https://chat.example.org/hooks/taskstream' }) @IsUrl({ require_tld: false, require_protocol: true }) url!: string; @ApiProperty({ type: [String], example: ['issue.status_changed', 'release.*'], description: 'Event names (GET /api/webhook-events), family.* or *', }) @IsArray() @ArrayMaxSize(50) @IsString({ each: true }) events!: string[]; } export class UpdateWebhookDto extends PartialType(CreateWebhookDto) { @ApiPropertyOptional({ description: 'Paused webhooks get nothing' }) @IsOptional() @IsBoolean() active?: boolean; } export class ImportQueryDto { @ApiPropertyOptional({ type: Boolean, default: false, description: 'Only tell what would be created', }) @IsOptional() @Transform(toBoolean) @IsBoolean() dryRun = false; } export class BulkUpdateIssuesDto { @ApiProperty({ type: [String], format: 'uuid', maxItems: 100 }) @IsArray() @ArrayMaxSize(100) @IsUUID('all', { each: true }) issueIds!: string[]; @ApiPropertyOptional({ example: 'IN_PROGRESS' }) @IsOptional() @Matches(SETTING_ID) status?: string; @ApiPropertyOptional({ description: 'Unassign everything' }) @IsOptional() @IsBoolean() unassign?: boolean; @ApiPropertyOptional({ description: 'Pireon user id of a member' }) @IsOptional() @IsString() assigneeId?: string; @ApiPropertyOptional({ description: 'Back to the backlog' }) @IsOptional() @IsBoolean() noSprint?: boolean; @ApiPropertyOptional({ format: 'uuid' }) @IsOptional() @IsUUID() sprintId?: string; @ApiPropertyOptional({ description: 'Out of their release' }) @IsOptional() @IsBoolean() noRelease?: boolean; @ApiPropertyOptional({ format: 'uuid' }) @IsOptional() @IsUUID() releaseId?: string; @ApiPropertyOptional({ enum: IssuePriority, enumName: 'IssuePriority' }) @IsOptional() @IsEnum(IssuePriority) priority?: IssuePriority; @ApiPropertyOptional({ type: [String] }) @IsOptional() @ArrayMaxSize(10) @IsString({ each: true }) addLabels?: string[]; } export class RankIssueDto { @ApiPropertyOptional({ type: String, nullable: true, example: 'WEB-4', description: 'The ticket that ends up right above (id or key; null: the top)', }) @IsOptional() @IsString() above?: string | null; @ApiPropertyOptional({ type: String, nullable: true, example: 'WEB-9', description: 'The ticket that ends up right below (id or key; null: the bottom)', }) @IsOptional() @IsString() below?: 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 PresenceBodyDto { @ApiPropertyOptional({ default: false, description: 'Editing the ticket or writing a comment', }) @IsOptional() @IsBoolean() editing = false; } 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; } export class AddCommentDto extends CommentBodyDto { @ApiPropertyOptional({ format: 'uuid', description: 'Comment it answers (one level: answering a reply joins its thread)', }) @IsOptional() @IsUUID() replyTo?: string; } export class ReactToCommentDto { @ApiProperty({ enum: REACTIONS, example: '👍' }) @IsIn(REACTIONS) emoji!: string; @ApiProperty({ description: 'true adds the reaction, false takes it back' }) @IsBoolean() on!: boolean; } // ---- Public proposals ------------------------------------------------------- export class ProposalDto { @ApiProperty({ enum: ['BUG', 'IDEA'], description: 'A bug report or an idea', }) @IsIn(['BUG', 'IDEA']) kind!: 'BUG' | 'IDEA'; @ApiProperty({ minLength: 3, maxLength: 150 }) @IsString() @MinLength(3) @MaxLength(150) title!: string; @ApiPropertyOptional({ maxLength: 5000, description: 'What happens, or what they would like (Markdown)', }) @IsOptional() @IsString() @MaxLength(5000) description?: string; @ApiPropertyOptional({ maxLength: 80, description: 'How they sign it; shown in the ticket (no e-mail: it would be public)', }) @IsOptional() @IsString() @MaxLength(80) author?: string; @ApiPropertyOptional({ description: 'Leave empty: a field only bots fill in', }) @IsOptional() @IsString() website?: 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 ReleaseNotesDto { @ApiProperty({ type: String, nullable: true, maxLength: 20000, description: 'Markdown; null removes the notes', }) @ValidateIf((dto: ReleaseNotesDto) => dto.notes !== null) @IsString() @MaxLength(20000) notes!: string | null; } const DAY = /^\d{4}-\d{2}-\d{2}$/; export class CreateSprintDto { @ApiProperty({ example: 'Sprint 14', maxLength: 80 }) @IsString() @MinLength(1) @MaxLength(80) name!: string; @ApiPropertyOptional({ type: String, nullable: true, maxLength: 500, example: 'Pagos con tarjeta en producción', }) @IsOptional() @IsString() @MaxLength(500) goal?: string | null; @ApiProperty({ format: 'date', example: '2026-10-12' }) @Matches(DAY) startDate!: string; @ApiProperty({ format: 'date', example: '2026-10-23', description: 'Last day, included', }) @Matches(DAY) endDate!: string; } export class UpdateSprintDto extends PartialType(CreateSprintDto) {} export class CloseSprintDto { @ApiPropertyOptional({ type: String, nullable: true, format: 'uuid', description: 'Open sprint of the same project that takes the unfinished tickets; null or absent: back to the backlog', }) @IsOptional() @IsUUID() carryOverTo?: string | null; } export class SetIssueSprintDto { @ApiProperty({ type: String, nullable: true, format: 'uuid', description: 'Open sprint of the project, or null for the backlog', }) @ValidateIf((dto: SetIssueSprintDto) => dto.sprintId !== null) @IsUUID() sprintId!: string | null; } export class ArchiveReleaseDto { @ApiPropertyOptional({ type: String, nullable: true, format: 'uuid', description: 'Open release of the same project that takes its unfinished tickets; null or absent: they leave it', }) @IsOptional() @IsUUID() moveTo?: string | null; } export class PublishReleaseDto { @ApiPropertyOptional({ type: Boolean, default: false, description: 'Publish even with unfinished tickets', }) @IsOptional() @IsBoolean() force = false; } // ---- Notifications ---------------------------------------------------------- export class MyNotificationsQueryDto { @ApiPropertyOptional({ type: Boolean, 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; } export class PushSubscriptionDto { @ApiProperty({ description: 'The push service URL (PushSubscription.endpoint)', }) @IsUrl({ protocols: ['https'], require_tld: false }) endpoint!: string; @ApiProperty({ description: 'keys.p256dh of the subscription' }) @IsString() @MaxLength(200) p256dh!: string; @ApiProperty({ description: 'keys.auth of the subscription' }) @IsString() @MaxLength(100) auth!: string; } export class PushEndpointDto { @ApiProperty({ description: 'The endpoint of the device to turn off' }) @IsString() @MaxLength(2000) endpoint!: string; } // ---- Alerts → bugs ---------------------------------------------------------- export class AlertSettingsDto { @ApiPropertyOptional({ type: String, nullable: true, description: "Ticket type to open; null: the project's first bug type", }) @IsOptional() @IsString() typeId?: string | null; @ApiPropertyOptional({ enum: IssuePriority, enumName: 'IssuePriority' }) @IsOptional() @IsEnum(IssuePriority) priority?: IssuePriority; } // ---- Git -------------------------------------------------------------------- export class AddGitRepositoryDto { @ApiProperty({ example: 'api', maxLength: 40, description: "The project's name for it (api, web, admin…); unique", }) @IsString() @MinLength(1) @MaxLength(40) name!: string; } export class ShareDashboardDto { @ApiProperty({ type: [String], example: ['flow', 'sprint', 'releases'], description: 'Blocks to show: flow, sprint, releases, epics, activity', }) @IsArray() @ArrayMaxSize(10) @IsString({ each: true }) blocks!: string[]; @ApiPropertyOptional({ description: 'A new secret: the old link stops working', }) @IsOptional() @IsBoolean() renew?: boolean; }