import { Field, ID, InputType, Int } from '@nestjs/graphql'; import { IssueLinkType, IssuePriority, ProjectRole, StatusCategory, } from '@task-stream/api/tracker/domain'; 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; } @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; } @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({ 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 }) @IsOptional() @IsISO8601() dueDate?: | string | null; } @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; } @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; } @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({ 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; } @InputType() export class StatusTransitionsInput { @Field() @Matches(SETTING_ID) from!: string; @Field(() => [String]) @IsArray() @ArrayMaxSize(50) @Matches(SETTING_ID, { each: true }) to!: 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; } @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({ 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[]; }