Files
task-stream/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts
Josep Piguillem 034704e46f feat: alerts open bugs, and repeats of the same problem go to that bug
Owners and admins turn on a secret URL per project (Settings →
Integrations), POST /api/webhooks/alerts/{project}/{token}, that Sentry,
Uptime Kuma, Prometheus Alertmanager, Grafana or any tool sending JSON
with a title can call. A new problem opens a bug as TaskStream (label
«alerta», chosen type and priority); while that bug is open, the same
fingerprint goes to it (a comment at most every 30 minutes) and
«resolved» is said on it; once it is closed, a new bug names the old
one. Alerts of a project are handled one at a time, so two at once open
one bug, and a bug not yet projected still counts as open. Unknown
project, alerts off or a bad token all answer 403.

TaskStream may now comment as the system actor, like it already opened
tickets for public proposals. Stored in Mongo (alert_integrations,
alert_traces), not in the event store.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:36:55 +02:00

1591 lines
37 KiB
TypeScript

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<string, string | number | 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 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;
}