Files
task-stream/libs/api/tracker/presenters/src/lib/graphql/inputs.ts
Josep Piguillem f2001aca88 feat: formatted text, a list view, ticket details, public proposals and votes
Formatted text
- Descriptions, comments, replies and release notes render Markdown,
  sanitised with DOMPurify (tested against scripts, handlers and
  javascript: links). A pasted image is uploaded and written as
  ![name](attachment:<id>); the web fetches it with the reader's session,
  so it works on private projects. Every text box has a preview. The
  attachment upload now answers its attachmentId.

List view
- /p/:key/list: every ticket as rows, sortable by any column, filtered by
  text, open/closed, type, priority and assignee; status, priority and
  assignee change in place; bulk edit (the backlog's bar, now shared).

Ticket details
- Reactions on comments (GitHub's eight), for members and viewers.
- Duplicate a ticket: what it says, not where it stands.
- blockedBy: open tickets it waits for (BLOCKS / DEPENDS_ON), worked out
  when reading with one query per board or page; shown on cards, list
  rows and the ticket.

Public proposals
- A public project can take bug reports and ideas from anyone
  (acceptsProposals): POST /api/explore/projects/{project}/proposals and
  the form at /p/:key/proponer. They become tickets labelled «propuesta»,
  opened by TaskStream; a honeypot field and 5 per address every 15
  minutes keep spam out. No e-mail is asked: it would be public.

Votes
- Anyone signed in votes on a public project's tickets, members on a
  private one (voteIssue, PUT/DELETE /api/issues/{issue}/vote): votes and
  voted on each ticket, buttons on the ticket and the roadmap, a column
  in the list. The showcase carries votes and the open proposals.

MCP: vote_issue, duplicate_issue, react_to_comment; listings show
blockers and votes. Docs, Word and PDF updated; chat notifications
(Discord, Slack, Telegram) noted for later.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:36:11 +02:00

692 lines
17 KiB
TypeScript

import { Field, ID, InputType, Int } from '@nestjs/graphql';
import {
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;
}
@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({ 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;
}
@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(() => 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({
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[];
}