import { Field, ID, InputType, Int } from '@nestjs/graphql'; import { CustomFieldType, IssueLinkType, IssuePriority, ProjectRole, StatusCategory, } from '@task-stream/api/tracker/domain'; import { AutomationActionKind, AutomationTriggerKind, IssueSort, } from '@task-stream/api/tracker/application'; import { Type } from 'class-transformer'; import { ArrayMaxSize, IsArray, IsBoolean, IsEnum, IsInt, IsISO8601, IsOptional, IsString, IsUrl, IsUUID, Matches, Max, MaxLength, Min, MinLength, ValidateNested, } from 'class-validator'; /** Ids of statuses, types and areas (checked against the project's settings). */ const SETTING_ID = /^[A-Za-z][A-Za-z0-9_]{0,29}$/; @InputType() export class CreateProjectInput { @Field({ description: '2-10 chars, e.g. "TS" → tickets TS-1, TS-2...' }) @Matches(/^[A-Za-z][A-Za-z0-9]{1,9}$/) key!: string; @Field() @IsString() @MinLength(1) @MaxLength(120) name!: string; @Field(() => String, { nullable: true }) @IsOptional() @IsString() @MaxLength(5000) description?: string | null; @Field({ nullable: true, description: 'Readable by anyone, also without signing in', }) @IsOptional() @IsBoolean() isPublic?: boolean; @Field(() => ID, { nullable: true, description: 'A project you own or administer to start from: its statuses, transitions, types, areas, custom fields and automation rules are copied', }) @IsOptional() @IsUUID() templateProjectId?: string | null; } @InputType() export class UpdateProjectInput { @Field(() => ID) @IsUUID() projectId!: string; @Field({ nullable: true }) @IsOptional() @IsString() @MinLength(1) @MaxLength(120) name?: string; @Field(() => String, { nullable: true }) @IsOptional() @IsString() @MaxLength(5000) description?: string | null; @Field({ nullable: true }) @IsOptional() @IsBoolean() isPublic?: boolean; @Field({ nullable: true, description: 'Take bug reports and ideas from anyone through the public form (while public)', }) @IsOptional() @IsBoolean() acceptsProposals?: boolean; } @InputType() export class ProjectMemberInput { @Field(() => ID) @IsUUID() projectId!: string; @Field(() => ID, { description: 'Pireon user id' }) @IsString() userId!: string; @Field(() => ProjectRole, { defaultValue: ProjectRole.MEMBER }) @IsEnum(ProjectRole) role!: ProjectRole; } @InputType({ description: "A custom field's value; null or '' clears it" }) export class CustomFieldValueInput { @Field() @Matches(SETTING_ID) fieldId!: string; @Field(() => String, { nullable: true }) @IsOptional() @IsString() @MaxLength(500) value?: string | null; } @InputType({ isAbstract: true }) abstract class IssueFieldsInput { @Field(() => String, { nullable: true }) @IsOptional() @IsString() @MaxLength(50000) description?: string | null; @Field(() => String, { nullable: true, description: 'An area id' }) @IsOptional() @Matches(SETTING_ID) area?: string | null; @Field(() => IssuePriority, { nullable: true }) @IsOptional() @IsEnum(IssuePriority) priority?: IssuePriority; @Field(() => [String], { nullable: true }) @IsOptional() @ArrayMaxSize(20) @IsString({ each: true }) @MaxLength(40, { each: true }) labels?: string[]; @Field(() => String, { nullable: true, description: 'Figma frame for UI changes', }) @IsOptional() @IsUrl({ protocols: ['https'] }) figmaUrl?: string | null; @Field(() => Int, { nullable: true }) @IsOptional() @IsInt() @Min(0) @Max(100) storyPoints?: number | null; @Field(() => String, { nullable: true, description: 'When work should start (roadmap), YYYY-MM-DD', }) @IsOptional() @IsISO8601() startDate?: string | null; @Field(() => String, { nullable: true }) @IsOptional() @IsISO8601() dueDate?: | string | null; @Field(() => [CustomFieldValueInput], { nullable: true, description: 'Only the fields to change', }) @IsOptional() @IsArray() @ArrayMaxSize(30) @ValidateNested({ each: true }) @Type(() => CustomFieldValueInput) customFields?: CustomFieldValueInput[]; } @InputType() export class CreateIssueInput extends IssueFieldsInput { @Field(() => ID) @IsUUID() projectId!: string; @Field({ description: 'A ticket type id' }) @Matches(SETTING_ID) type!: string; @Field() @IsString() @MinLength(1) @MaxLength(255) title!: string; @Field(() => ID, { nullable: true }) @IsOptional() @IsString() assigneeId?: | string | null; @Field(() => ID, { nullable: true }) @IsOptional() @IsUUID() releaseId?: | string | null; @Field(() => ID, { nullable: true, description: 'Epic or ticket of the same project the new one breaks down', }) @IsOptional() @IsUUID() parentId?: string | null; } @InputType() export class UpdateIssueInput extends IssueFieldsInput { @Field(() => ID) @IsUUID() issueId!: string; @Field({ nullable: true }) @IsOptional() @Matches(SETTING_ID) type?: string; @Field({ nullable: true }) @IsOptional() @IsString() @MinLength(1) @MaxLength(255) title?: string; } @InputType() export class ChangeIssueStatusInput { @Field(() => ID) @IsUUID() issueId!: string; @Field({ description: 'A status id' }) @Matches(SETTING_ID) status!: string; @Field(() => String, { nullable: true, description: 'Required by statuses with requiresReason', }) @IsOptional() @IsString() @MaxLength(5000) reason?: string | null; } const DAY = /^\d{4}-\d{2}-\d{2}$/; @InputType() export class CreateSprintInput { @Field(() => ID) @IsUUID() projectId!: string; @Field() @IsString() @MinLength(1) @MaxLength(80) name!: string; @Field(() => String, { nullable: true }) @IsOptional() @IsString() @MaxLength(500) goal?: string | null; @Field({ description: 'YYYY-MM-DD' }) @Matches(DAY) startDate!: string; @Field({ description: 'YYYY-MM-DD, included' }) @Matches(DAY) endDate!: string; } @InputType() export class UpdateSprintInput { @Field(() => ID) @IsUUID() sprintId!: string; @Field({ nullable: true }) @IsOptional() @IsString() @MinLength(1) @MaxLength(80) name?: string; @Field(() => String, { nullable: true }) @IsOptional() @IsString() @MaxLength(500) goal?: string | null; @Field({ nullable: true }) @IsOptional() @Matches(DAY) startDate?: string; @Field({ nullable: true }) @IsOptional() @Matches(DAY) endDate?: string; } @InputType() export class SavedFilterCriteriaInput { @Field(() => [String], { nullable: true }) @IsOptional() @Matches(SETTING_ID, { each: true }) types?: string[]; @Field(() => [String], { nullable: true }) @IsOptional() @Matches(SETTING_ID, { each: true }) areas?: string[]; @Field(() => [IssuePriority], { nullable: true }) @IsOptional() @IsEnum(IssuePriority, { each: true }) priorities?: IssuePriority[]; @Field(() => ID, { nullable: true }) @IsOptional() @IsString() assigneeId?: string | null; @Field({ nullable: true }) @IsOptional() @IsBoolean() unassigned?: boolean; @Field(() => ID, { nullable: true }) @IsOptional() @IsUUID() releaseId?: string | null; @Field(() => [String], { nullable: true }) @IsOptional() @ArrayMaxSize(20) @IsString({ each: true }) labels?: string[]; @Field(() => String, { nullable: true }) @IsOptional() @IsString() @MaxLength(200) search?: string | null; } @InputType() export class SaveFilterInput { @Field(() => ID) @IsUUID() projectId!: string; @Field() @IsString() @MinLength(1) @MaxLength(60) name!: string; @Field({ defaultValue: false, description: 'Visible to the whole project (members, not viewers, share)', }) @IsBoolean() shared!: boolean; @Field(() => SavedFilterCriteriaInput) @ValidateNested() @Type(() => SavedFilterCriteriaInput) criteria!: SavedFilterCriteriaInput; } @InputType() export class BulkUpdateIssuesInput { @Field(() => [ID]) @IsArray() @ArrayMaxSize(100) @IsUUID('all', { each: true }) issueIds!: string[]; @Field({ nullable: true }) @IsOptional() @Matches(SETTING_ID) status?: string; @Field({ nullable: true, description: 'Unassign everything' }) @IsOptional() @IsBoolean() unassign?: boolean; @Field(() => ID, { nullable: true }) @IsOptional() @IsString() assigneeId?: string; @Field({ nullable: true, description: 'Back to the backlog' }) @IsOptional() @IsBoolean() noSprint?: boolean; @Field(() => ID, { nullable: true }) @IsOptional() @IsUUID() sprintId?: string; @Field({ nullable: true, description: 'Out of their release' }) @IsOptional() @IsBoolean() noRelease?: boolean; @Field(() => ID, { nullable: true }) @IsOptional() @IsUUID() releaseId?: string; @Field(() => IssuePriority, { nullable: true }) @IsOptional() @IsEnum(IssuePriority) priority?: IssuePriority; @Field(() => [String], { nullable: true }) @IsOptional() @ArrayMaxSize(10) @IsString({ each: true }) addLabels?: string[]; } @InputType() export class AutomationTriggerInput { @Field(() => AutomationTriggerKind) @IsEnum(AutomationTriggerKind) kind!: AutomationTriggerKind; @Field(() => String, { nullable: true }) @IsOptional() @Matches(SETTING_ID) status?: string | null; } @InputType() export class AutomationConditionsInput { @Field(() => [String], { nullable: true }) @IsOptional() @Matches(SETTING_ID, { each: true }) types?: string[]; @Field(() => [IssuePriority], { nullable: true }) @IsOptional() @IsEnum(IssuePriority, { each: true }) priorities?: IssuePriority[]; @Field(() => [String], { nullable: true }) @IsOptional() @Matches(SETTING_ID, { each: true }) areas?: string[]; @Field(() => String, { nullable: true }) @IsOptional() @IsString() @MaxLength(40) label?: string | null; } @InputType() export class AutomationActionInput { @Field(() => AutomationActionKind) @IsEnum(AutomationActionKind) kind!: AutomationActionKind; @Field(() => String, { nullable: true }) @IsOptional() @IsString() @MaxLength(2000) value?: string | null; } @InputType() export class CreateAutomationInput { @Field(() => ID) @IsUUID() projectId!: string; @Field() @IsString() @MinLength(1) @MaxLength(80) name!: string; @Field(() => AutomationTriggerInput) @ValidateNested() @Type(() => AutomationTriggerInput) trigger!: AutomationTriggerInput; @Field(() => AutomationConditionsInput, { nullable: true }) @IsOptional() @ValidateNested() @Type(() => AutomationConditionsInput) conditions?: AutomationConditionsInput; @Field(() => AutomationActionInput) @ValidateNested() @Type(() => AutomationActionInput) action!: AutomationActionInput; } @InputType() export class CreateWebhookInput { @Field(() => ID) @IsUUID() projectId!: string; @Field() @IsUrl({ require_tld: false, require_protocol: true }) url!: string; @Field(() => [String], { description: 'Event names, family.* or *' }) @IsArray() @ArrayMaxSize(50) @IsString({ each: true }) events!: string[]; } @InputType() export class UpdateWebhookInput { @Field(() => ID) @IsUUID() webhookId!: string; @Field({ nullable: true }) @IsOptional() @IsUrl({ require_tld: false, require_protocol: true }) url?: string; @Field(() => [String], { nullable: true }) @IsOptional() @IsArray() @ArrayMaxSize(50) @IsString({ each: true }) events?: string[]; @Field({ nullable: true }) @IsOptional() @IsBoolean() active?: boolean; } @InputType() export class RankIssueInput { @Field(() => ID) @IsUUID() issueId!: string; @Field(() => ID, { nullable: true, description: 'The ticket that ends up right above (null: the top)', }) @IsOptional() @IsUUID() aboveIssueId?: string | null; @Field(() => ID, { nullable: true, description: 'The ticket that ends up right below (null: the bottom)', }) @IsOptional() @IsUUID() belowIssueId?: string | null; } @InputType() export class AddChecklistItemInput { @Field(() => ID) @IsUUID() issueId!: string; @Field() @IsString() @MinLength(1) @MaxLength(300) text!: string; } @InputType() export class UpdateChecklistItemInput { @Field(() => ID) @IsUUID() issueId!: string; @Field(() => ID) @IsUUID() itemId!: string; @Field({ nullable: true }) @IsOptional() @IsString() @MinLength(1) @MaxLength(300) text?: string; @Field({ nullable: true }) @IsOptional() @IsBoolean() done?: boolean; } @InputType() export class LinkIssuesInput { @Field(() => ID) @IsUUID() issueId!: string; @Field(() => IssueLinkType) @IsEnum(IssueLinkType) type!: IssueLinkType; @Field(() => ID) @IsUUID() targetIssueId!: string; } @InputType() export class IssueFilterInput { @Field(() => ID) @IsUUID() projectId!: string; @Field(() => [String], { nullable: true }) @IsOptional() @IsString({ each: true }) statuses?: string[]; @Field(() => [StatusCategory], { nullable: true }) @IsOptional() @IsEnum(StatusCategory, { each: true }) categories?: StatusCategory[]; @Field(() => [String], { nullable: true }) @IsOptional() @IsString({ each: true }) types?: string[]; @Field(() => [String], { nullable: true }) @IsOptional() @IsString({ each: true }) areas?: string[]; @Field(() => [IssuePriority], { nullable: true }) @IsOptional() @IsEnum(IssuePriority, { each: true }) priorities?: IssuePriority[]; @Field(() => ID, { nullable: true }) @IsOptional() @IsString() assigneeId?: string; @Field({ nullable: true }) @IsOptional() @IsBoolean() unassigned?: boolean; @Field(() => ID, { nullable: true }) @IsOptional() @IsUUID() releaseId?: string; @Field(() => [String], { nullable: true }) @IsOptional() @IsString({ each: true }) labels?: string[]; @Field(() => ID, { nullable: true, description: 'Only the tickets hanging from this one', }) @IsOptional() @IsUUID() parentId?: string; @Field(() => ID, { nullable: true }) @IsOptional() @IsUUID() sprintId?: string; @Field({ nullable: true, description: 'Only tickets in no sprint' }) @IsOptional() @IsBoolean() noSprint?: boolean; @Field({ nullable: true, description: 'Only the tickets I follow (none when anonymous)', }) @IsOptional() @IsBoolean() watching?: boolean; @Field({ nullable: true, description: 'Due on or after this day (YYYY-MM-DD)', }) @IsOptional() @Matches(/^\d{4}-\d{2}-\d{2}$/) dueFrom?: string; @Field({ nullable: true, description: 'Due on or before this day (YYYY-MM-DD)', }) @IsOptional() @Matches(/^\d{4}-\d{2}-\d{2}$/) dueTo?: string; @Field(() => [CustomFieldValueInput], { nullable: true, description: 'Only tickets holding these custom values', }) @IsOptional() @IsArray() @ArrayMaxSize(30) @ValidateNested({ each: true }) @Type(() => CustomFieldValueInput) customFields?: CustomFieldValueInput[]; @Field(() => IssueSort, { nullable: true, defaultValue: IssueSort.RECENT }) @IsOptional() @IsEnum(IssueSort) sort?: IssueSort; @Field({ nullable: true, description: 'Matches key, title or description' }) @IsOptional() @IsString() @MaxLength(200) search?: string; } @InputType() export class CreateReleaseInput { @Field(() => ID) @IsUUID() projectId!: string; @Field({ description: 'e.g. v1.4.0' }) @IsString() @MinLength(1) @MaxLength(60) name!: string; @Field(() => String, { nullable: true }) @IsOptional() @IsString() @MaxLength(5000) description?: string | null; @Field(() => String, { nullable: true }) @IsOptional() @IsISO8601() targetDate?: string | null; } @InputType() export class UpdateReleaseInput { @Field(() => ID) @IsUUID() releaseId!: string; @Field({ nullable: true }) @IsOptional() @IsString() @MinLength(1) @MaxLength(60) name?: string; @Field(() => String, { nullable: true }) @IsOptional() @IsString() @MaxLength(5000) description?: string | null; @Field(() => String, { nullable: true }) @IsOptional() @IsISO8601() targetDate?: string | null; } /* Project settings (owners and admins). Values are archived, never deleted. */ @InputType() export class WorkflowStatusInput { @Field() @Matches(SETTING_ID) id!: string; @Field() @IsString() @MinLength(1) @MaxLength(40) name!: string; @Field(() => StatusCategory) @IsEnum(StatusCategory) category!: StatusCategory; @Field() @IsString() color!: string; @Field({ defaultValue: false }) @IsBoolean() requiresReason!: boolean; @Field({ defaultValue: false }) @IsBoolean() archived!: boolean; @Field(() => Int, { nullable: true, description: 'Work-in-progress limit (null: none)', }) @IsOptional() @IsInt() @Min(1) @Max(999) wipLimit?: number | null; } @InputType() export class StatusTransitionsInput { @Field() @Matches(SETTING_ID) from!: string; @Field(() => [String]) @IsArray() @ArrayMaxSize(50) @Matches(SETTING_ID, { each: true }) to!: string[]; } @InputType('IssueTemplateInput') export class IssueTemplateInput { @Field(() => String, { nullable: true }) @IsOptional() @IsString() @MaxLength(5000) description?: string | null; @Field(() => [String], { defaultValue: [] }) @IsArray() @ArrayMaxSize(20) @IsString({ each: true }) @MaxLength(300, { each: true }) checklist!: string[]; } @InputType() export class IssueTypeOptionInput { @Field() @Matches(SETTING_ID) id!: string; @Field() @IsString() @MinLength(1) @MaxLength(40) name!: string; @Field() @IsString() icon!: string; @Field() @IsString() color!: string; @Field({ defaultValue: false }) @IsBoolean() archived!: boolean; @Field(() => IssueTemplateInput, { nullable: true }) @IsOptional() @ValidateNested() @Type(() => IssueTemplateInput) template?: IssueTemplateInput | null; } @InputType() export class AreaOptionInput { @Field() @Matches(SETTING_ID) id!: string; @Field() @IsString() @MinLength(1) @MaxLength(40) name!: string; @Field({ defaultValue: false }) @IsBoolean() archived!: boolean; } @InputType() export class CustomFieldDefinitionInput { @Field() @Matches(SETTING_ID) id!: string; @Field() @IsString() @MinLength(1) @MaxLength(40) name!: string; @Field(() => CustomFieldType) @IsEnum(CustomFieldType) type!: CustomFieldType; @Field(() => [String], { defaultValue: [], description: 'The choices of a SELECT field', }) @IsArray() @ArrayMaxSize(50) @IsString({ each: true }) @MaxLength(60, { each: true }) options!: string[]; @Field({ defaultValue: false }) @IsBoolean() archived!: boolean; } @InputType({ description: 'The whole configuration (replaces the previous one). Statuses go in board order.', }) export class ProjectSettingsInput { @Field(() => [WorkflowStatusInput]) @IsArray() @ArrayMaxSize(50) @ValidateNested({ each: true }) @Type(() => WorkflowStatusInput) statuses!: WorkflowStatusInput[]; @Field(() => [StatusTransitionsInput]) @IsArray() @ArrayMaxSize(50) @ValidateNested({ each: true }) @Type(() => StatusTransitionsInput) transitions!: StatusTransitionsInput[]; @Field(() => [IssueTypeOptionInput]) @IsArray() @ArrayMaxSize(50) @ValidateNested({ each: true }) @Type(() => IssueTypeOptionInput) issueTypes!: IssueTypeOptionInput[]; @Field(() => [AreaOptionInput]) @IsArray() @ArrayMaxSize(50) @ValidateNested({ each: true }) @Type(() => AreaOptionInput) areas!: AreaOptionInput[]; @Field(() => [CustomFieldDefinitionInput], { nullable: true, description: 'Absent: unchanged. Fields cannot be removed: archive them.', }) @IsOptional() @IsArray() @ArrayMaxSize(30) @ValidateNested({ each: true }) @Type(() => CustomFieldDefinitionInput) customFields?: CustomFieldDefinitionInput[]; } @InputType({ description: "A browser's Web Push subscription (PushSubscription.toJSON())", }) export class PushSubscriptionInput { @Field() @IsUrl({ protocols: ['https'], require_tld: false }) endpoint!: string; @Field() @IsString() @MaxLength(200) p256dh!: string; @Field() @IsString() @MaxLength(100) auth!: string; }