first commit

This commit is contained in:
2026-10-03 13:40:26 +02:00
parent 732a5b5a16
commit 566f415fa7
555 changed files with 62704 additions and 0 deletions

View File

@@ -0,0 +1,36 @@
import { Args, ID, Mutation, Resolver } from '@nestjs/graphql';
import { User } from '@task-stream/api/identity/domain';
import { CurrentUser } from '@task-stream/api/identity/presenters';
import { CommentsService } from '@task-stream/api/tracker/application';
import { CommandResultGql, CommentGql } from './types';
@Resolver(() => CommentGql)
export class CommentResolver {
constructor(private readonly comments: CommentsService) {}
@Mutation(() => CommandResultGql)
addComment(
@CurrentUser() me: User,
@Args('issueId', { type: () => ID }) issueId: string,
@Args('body') body: string,
) {
return this.comments.add(me.id, issueId, body);
}
@Mutation(() => CommandResultGql)
editComment(
@CurrentUser() me: User,
@Args('commentId', { type: () => ID }) commentId: string,
@Args('body') body: string,
) {
return this.comments.edit(me.id, commentId, body);
}
@Mutation(() => CommandResultGql, { description: 'Author or project admin' })
deleteComment(
@CurrentUser() me: User,
@Args('commentId', { type: () => ID }) commentId: string,
) {
return this.comments.delete(me.id, commentId);
}
}

View File

@@ -0,0 +1,27 @@
import { registerEnumType } from '@nestjs/graphql';
import {
IssueArea,
IssueLinkType,
IssuePriority,
IssueStatus,
IssueType,
ProjectRole,
ReleaseStatus,
} from '@task-stream/api/tracker/domain';
export enum LinkDirection {
OUTWARD = 'OUTWARD',
INWARD = 'INWARD',
}
registerEnumType(IssueType, { name: 'IssueType' });
registerEnumType(IssueArea, {
name: 'IssueArea',
description: 'Team/layer: links FE ↔ BE ↔ QA work',
});
registerEnumType(IssuePriority, { name: 'IssuePriority' });
registerEnumType(IssueStatus, { name: 'IssueStatus' });
registerEnumType(IssueLinkType, { name: 'IssueLinkType' });
registerEnumType(ProjectRole, { name: 'ProjectRole' });
registerEnumType(ReleaseStatus, { name: 'ReleaseStatus' });
registerEnumType(LinkDirection, { name: 'LinkDirection' });

View File

@@ -0,0 +1,239 @@
import { Field, ID, InputType, Int } from '@nestjs/graphql';
import {
IssueArea,
IssueLinkType,
IssuePriority,
IssueStatus,
IssueType,
ProjectRole,
} from '@task-stream/api/tracker/domain';
import {
ArrayMaxSize,
IsBoolean,
IsEnum,
IsInt,
IsISO8601,
IsOptional,
IsString,
IsUrl,
IsUUID,
Matches,
Max,
MaxLength,
Min,
MinLength,
} from 'class-validator';
@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;
}
@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;
}
@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(() => IssueArea, { nullable: true })
@IsOptional()
@IsEnum(IssueArea)
area?: IssueArea | 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(() => IssueType) @IsEnum(IssueType) type!: IssueType;
@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(() => IssueType, { nullable: true })
@IsOptional()
@IsEnum(IssueType)
type?: IssueType;
@Field({ nullable: true })
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
title?: string;
}
@InputType()
export class ChangeIssueStatusInput {
@Field(() => ID) @IsUUID() issueId!: string;
@Field(() => IssueStatus) @IsEnum(IssueStatus) status!: IssueStatus;
@Field(() => String, {
nullable: true,
description: 'Required for QA_FAILED and CANCELLED',
})
@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(() => [IssueStatus], { nullable: true })
@IsOptional()
@IsEnum(IssueStatus, { each: true })
statuses?: IssueStatus[];
@Field(() => [IssueType], { nullable: true })
@IsOptional()
@IsEnum(IssueType, { each: true })
types?: IssueType[];
@Field(() => [IssueArea], { nullable: true })
@IsOptional()
@IsEnum(IssueArea, { each: true })
areas?: IssueArea[];
@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;
}

View File

@@ -0,0 +1,192 @@
import {
Args,
ID,
Int,
Mutation,
Parent,
Query,
ResolveField,
Resolver,
} from '@nestjs/graphql';
import { User } from '@task-stream/api/identity/domain';
import { CurrentUser } from '@task-stream/api/identity/presenters';
import {
CommentsService,
IssuesService,
} from '@task-stream/api/tracker/application';
import {
IssueDetails,
IssueReadModel,
IssueStatus,
IssueWorkflow,
} from '@task-stream/api/tracker/domain';
import {
ChangeIssueStatusInput,
CreateIssueInput,
IssueFilterInput,
LinkIssuesInput,
UpdateIssueInput,
} from './inputs';
import {
ActivityGql,
CommandResultGql,
CommentPageGql,
IssueGql,
IssuePageGql,
} from './types';
/** GraphQL sends explicit nulls; the domain only accepts them on nullable fields. */
function withoutNulls<T extends Partial<IssueDetails>>(input: T): T {
const details = { ...input };
for (const field of ['title', 'type', 'priority', 'labels'] as const) {
if (details[field] === null) delete details[field];
}
return details;
}
@Resolver(() => IssueGql)
export class IssueResolver {
constructor(
private readonly issuesService: IssuesService,
private readonly commentsService: CommentsService,
) {}
@Query(() => IssueGql)
issue(
@CurrentUser() me: User,
@Args('idOrKey', { description: 'Issue id or key (e.g. "TS-42")' })
idOrKey: string,
) {
return this.issuesService.findOne(me.id, idOrKey);
}
@Query(() => IssuePageGql, { name: 'issues' })
searchIssues(
@CurrentUser() me: User,
@Args('filter') filter: IssueFilterInput,
@Args('limit', { type: () => Int, defaultValue: 50 }) limit: number,
@Args('offset', { type: () => Int, defaultValue: 0 }) offset: number,
) {
return this.issuesService.search(me.id, { ...filter, limit, offset });
}
@Query(() => [ActivityGql], {
description: 'Project activity feed (newest first)',
})
activity(
@CurrentUser() me: User,
@Args('projectId', { type: () => ID }) projectId: string,
@Args('issueId', { type: () => ID, nullable: true }) issueId?: string,
@Args('limit', { type: () => Int, defaultValue: 50 }) limit = 50,
@Args('before', { nullable: true, description: 'ISO date cursor' })
before?: string,
) {
return this.issuesService.activity(me.id, {
projectId,
issueId,
limit,
before,
});
}
@ResolveField(() => [IssueStatus], {
description: 'Statuses this ticket can move to next',
})
allowedTransitions(@Parent() issue: IssueReadModel): readonly IssueStatus[] {
return IssueWorkflow.allowedFrom(issue.status);
}
@ResolveField(() => [IssueGql], { description: 'Linked tickets I can see' })
linkedIssues(@CurrentUser() me: User, @Parent() issue: IssueReadModel) {
if (issue.links.length === 0) return [];
return this.issuesService.findMany(
me.id,
issue.links.map((l) => l.issueId),
);
}
@ResolveField(() => CommentPageGql)
comments(
@CurrentUser() me: User,
@Parent() issue: IssueReadModel,
@Args('limit', { type: () => Int, defaultValue: 50 }) limit: number,
@Args('offset', { type: () => Int, defaultValue: 0 }) offset: number,
) {
return this.commentsService.list(me.id, issue.id, limit, offset);
}
@ResolveField(() => [ActivityGql], { description: 'History of this ticket' })
history(
@CurrentUser() me: User,
@Parent() issue: IssueReadModel,
@Args('limit', { type: () => Int, defaultValue: 50 }) limit: number,
) {
return this.issuesService.activity(me.id, {
projectId: issue.projectId,
issueId: issue.id,
limit,
});
}
@Mutation(() => CommandResultGql)
createIssue(@CurrentUser() me: User, @Args('input') input: CreateIssueInput) {
return this.issuesService.create(me.id, withoutNulls(input));
}
@Mutation(() => CommandResultGql)
updateIssue(@CurrentUser() me: User, @Args('input') input: UpdateIssueInput) {
const { issueId, ...changes } = withoutNulls(input);
return this.issuesService.update(me.id, issueId, changes);
}
@Mutation(() => CommandResultGql, {
description: 'Move a ticket through the workflow (incl. QA)',
})
changeIssueStatus(
@CurrentUser() me: User,
@Args('input') input: ChangeIssueStatusInput,
) {
return this.issuesService.changeStatus(
me.id,
input.issueId,
input.status,
input.reason,
);
}
@Mutation(() => CommandResultGql, {
description: 'Assign (or unassign with null) a project member',
})
assignIssue(
@CurrentUser() me: User,
@Args('issueId', { type: () => ID }) issueId: string,
@Args('assigneeId', { type: () => ID, nullable: true })
assigneeId: string | null,
) {
return this.issuesService.assign(me.id, issueId, assigneeId ?? null);
}
@Mutation(() => CommandResultGql, {
description: 'Relate tickets, e.g. FE DEPENDS_ON BE, QA TESTS FE',
})
linkIssues(@CurrentUser() me: User, @Args('input') input: LinkIssuesInput) {
return this.issuesService.link(me.id, input);
}
@Mutation(() => CommandResultGql)
unlinkIssues(@CurrentUser() me: User, @Args('input') input: LinkIssuesInput) {
return this.issuesService.unlink(me.id, input);
}
@Mutation(() => CommandResultGql, {
description: 'Put a ticket in a release (null removes it)',
})
setIssueRelease(
@CurrentUser() me: User,
@Args('issueId', { type: () => ID }) issueId: string,
@Args('releaseId', { type: () => ID, nullable: true })
releaseId: string | null,
) {
return this.issuesService.setRelease(me.id, issueId, releaseId ?? null);
}
}

View File

@@ -0,0 +1,125 @@
import {
Args,
ID,
Int,
Mutation,
Parent,
Query,
ResolveField,
Resolver,
} from '@nestjs/graphql';
import { User } from '@task-stream/api/identity/domain';
import { AccessToken, CurrentUser } from '@task-stream/api/identity/presenters';
import { ProjectsService } from '@task-stream/api/tracker/application';
import { IssueArea, ProjectReadModel } from '@task-stream/api/tracker/domain';
import {
CreateProjectInput,
ProjectMemberInput,
UpdateProjectInput,
} from './inputs';
import {
BoardColumnGql,
CommandResultGql,
ProjectGql,
ProjectStatsGql,
ReleaseGql,
} from './types';
@Resolver(() => ProjectGql)
export class ProjectResolver {
constructor(private readonly projects: ProjectsService) {}
@Query(() => [ProjectGql], { description: 'Projects where I am a member' })
myProjects(@CurrentUser() me: User) {
return this.projects.findMine(me.id);
}
@Query(() => ProjectGql)
project(
@CurrentUser() me: User,
@Args('idOrKey', { description: 'Project id or key (e.g. "TS")' })
idOrKey: string,
) {
return this.projects.findOne(me.id, idOrKey);
}
@ResolveField(() => [ReleaseGql])
releases(@CurrentUser() me: User, @Parent() project: ProjectReadModel) {
return this.projects.releases(me.id, project.id);
}
@ResolveField(() => ProjectStatsGql, {
description: 'Project tracking dashboard',
})
stats(@CurrentUser() me: User, @Parent() project: ProjectReadModel) {
return this.projects.stats(me.id, project.id);
}
@ResolveField(() => [BoardColumnGql], {
description: 'Kanban board, one column per status',
})
board(
@CurrentUser() me: User,
@Parent() project: ProjectReadModel,
@Args('releaseId', { type: () => ID, nullable: true }) releaseId?: string,
@Args('assigneeId', { type: () => ID, nullable: true }) assigneeId?: string,
@Args('area', { type: () => IssueArea, nullable: true }) area?: IssueArea,
@Args('perColumn', { type: () => Int, defaultValue: 50 }) perColumn = 50,
) {
return this.projects.board(
me.id,
{
projectId: project.id,
releaseId,
assigneeId,
areas: area ? [area] : undefined,
},
perColumn,
);
}
@Mutation(() => CommandResultGql)
createProject(
@CurrentUser() me: User,
@Args('input') input: CreateProjectInput,
) {
return this.projects.create(me.id, input);
}
@Mutation(() => CommandResultGql)
updateProject(
@CurrentUser() me: User,
@Args('input') input: UpdateProjectInput,
) {
const { projectId, ...changes } = input;
return this.projects.update(me.id, projectId, changes);
}
@Mutation(() => CommandResultGql, {
description: 'Add a Pireon user to the project',
})
addProjectMember(
@CurrentUser() me: User,
@AccessToken() token: string,
@Args('input') input: ProjectMemberInput,
) {
return this.projects.addMember(me.id, input, token);
}
@Mutation(() => CommandResultGql)
changeProjectMemberRole(
@CurrentUser() me: User,
@Args('input') input: ProjectMemberInput,
) {
return this.projects.changeMemberRole(me.id, input);
}
@Mutation(() => CommandResultGql)
removeProjectMember(
@CurrentUser() me: User,
@Args('projectId', { type: () => ID }) projectId: string,
@Args('userId', { type: () => ID }) userId: string,
) {
return this.projects.removeMember(me.id, projectId, userId);
}
}

View File

@@ -0,0 +1,96 @@
import {
Args,
ID,
Int,
Mutation,
Parent,
Query,
ResolveField,
Resolver,
} from '@nestjs/graphql';
import { User } from '@task-stream/api/identity/domain';
import { ReleaseReadModel } from '@task-stream/api/tracker/domain';
import { CurrentUser } from '@task-stream/api/identity/presenters';
import {
IssuesService,
ReleasesService,
} from '@task-stream/api/tracker/application';
import { CreateReleaseInput, UpdateReleaseInput } from './inputs';
import {
CommandResultGql,
IssuePageGql,
ReleaseGql,
ReleaseProgressGql,
} from './types';
@Resolver(() => ReleaseGql)
export class ReleaseResolver {
constructor(
private readonly releases: ReleasesService,
private readonly issuesService: IssuesService,
) {}
@Query(() => ReleaseGql)
release(@CurrentUser() me: User, @Args('id', { type: () => ID }) id: string) {
return this.releases.findOne(me.id, id);
}
@ResolveField(() => ReleaseProgressGql)
progress(@CurrentUser() me: User, @Parent() release: ReleaseReadModel) {
return this.releases.progress(me.id, release.id);
}
@ResolveField(() => IssuePageGql, {
description: 'Tickets included in this release',
})
issues(
@CurrentUser() me: User,
@Parent() release: ReleaseReadModel,
@Args('limit', { type: () => Int, defaultValue: 100 }) limit: number,
@Args('offset', { type: () => Int, defaultValue: 0 }) offset: number,
) {
return this.issuesService.search(me.id, {
projectId: release.projectId,
releaseId: release.id,
limit,
offset,
});
}
@Mutation(() => CommandResultGql)
createRelease(
@CurrentUser() me: User,
@Args('input') input: CreateReleaseInput,
) {
return this.releases.create(me.id, input);
}
@Mutation(() => CommandResultGql)
updateRelease(
@CurrentUser() me: User,
@Args('input') input: UpdateReleaseInput,
) {
const { releaseId, ...changes } = input;
return this.releases.update(me.id, releaseId, changes);
}
@Mutation(() => CommandResultGql, {
description:
'Ship the release. Fails while it has unfinished tickets unless force = true.',
})
publishRelease(
@CurrentUser() me: User,
@Args('releaseId', { type: () => ID }) releaseId: string,
@Args('force', { defaultValue: false }) force: boolean,
) {
return this.releases.publish(me.id, releaseId, force);
}
@Mutation(() => CommandResultGql)
archiveRelease(
@CurrentUser() me: User,
@Args('releaseId', { type: () => ID }) releaseId: string,
) {
return this.releases.archive(me.id, releaseId);
}
}

View File

@@ -0,0 +1,77 @@
import { Args, ID, Resolver, Subscription } from '@nestjs/graphql';
import { User } from '@task-stream/api/identity/domain';
import { CurrentUser } from '@task-stream/api/identity/presenters';
import { ChangesService } from '@task-stream/api/tracker/application';
import {
ActivityGql,
CommentChangeGql,
IssueChangeGql,
ProjectChangeGql,
ReleaseChangeGql,
} from './types';
const asIs = { resolve: <T>(payload: T) => payload };
/**
* Realtime API (graphql-ws). Events are emitted after the read model is
* updated, so a client can refetch and always see the new state.
*/
@Resolver()
export class SubscriptionResolver {
constructor(private readonly changes: ChangesService) {}
@Subscription(() => IssueChangeGql, {
...asIs,
description:
'Tickets created/updated/moved in a project (board in realtime)',
})
issueChanged(
@CurrentUser() me: User,
@Args('projectId', { type: () => ID }) projectId: string,
) {
return this.changes.watchIssues(me.id, projectId);
}
@Subscription(() => CommentChangeGql, asIs)
commentChanged(
@CurrentUser() me: User,
@Args('issueId', { type: () => ID }) issueId: string,
) {
return this.changes.watchComments(me.id, issueId);
}
@Subscription(() => ActivityGql, {
...asIs,
description: 'Live activity feed',
})
activityAdded(
@CurrentUser() me: User,
@Args('projectId', { type: () => ID }) projectId: string,
) {
return this.changes.watchActivity(me.id, projectId);
}
@Subscription(() => ReleaseChangeGql, asIs)
releaseChanged(
@CurrentUser() me: User,
@Args('projectId', { type: () => ID }) projectId: string,
) {
return this.changes.watchReleases(me.id, projectId);
}
@Subscription(() => ProjectChangeGql, asIs)
projectChanged(
@CurrentUser() me: User,
@Args('projectId', { type: () => ID }) projectId: string,
) {
return this.changes.watchProject(me.id, projectId);
}
@Subscription(() => ProjectChangeGql, {
...asIs,
description: 'Projects I am added to / removed from',
})
myProjectsChanged(@CurrentUser() me: User) {
return this.changes.watchMyProjects(me.id);
}
}

View File

@@ -0,0 +1,223 @@
import { Field, ID, Int, ObjectType } from '@nestjs/graphql';
import {
IssueArea,
IssueLinkType,
IssuePriority,
IssueStatus,
IssueType,
ProjectRole,
ReleaseStatus,
} from '@task-stream/api/tracker/domain';
import { LinkDirection } from './enums';
@ObjectType('UserRef')
export class UserRefGql {
@Field(() => ID) id!: string;
@Field() name!: string;
}
@ObjectType('ProjectMember')
export class ProjectMemberGql {
@Field(() => UserRefGql) user!: UserRefGql;
@Field(() => ProjectRole) role!: ProjectRole;
}
@ObjectType('Project')
export class ProjectGql {
@Field(() => ID) id!: string;
@Field() key!: string;
@Field() name!: string;
@Field(() => String, { nullable: true }) description!: string | null;
@Field(() => UserRefGql) owner!: UserRefGql;
@Field(() => [ProjectMemberGql]) members!: ProjectMemberGql[];
@Field() createdAt!: string;
@Field() updatedAt!: string;
@Field(() => Int) version!: number;
}
@ObjectType('IssueLink')
export class IssueLinkGql {
@Field(() => IssueLinkType) type!: IssueLinkType;
@Field(() => LinkDirection, {
description: 'OUTWARD: this issue → other. INWARD: other → this issue.',
})
direction!: LinkDirection;
@Field(() => ID) issueId!: string;
@Field() issueKey!: string;
}
@ObjectType('ReleaseRef')
export class ReleaseRefGql {
@Field(() => ID) id!: string;
@Field() name!: string;
}
@ObjectType('Issue')
export class IssueGql {
@Field(() => ID) id!: string;
@Field(() => ID) projectId!: string;
@Field() projectKey!: string;
@Field(() => Int) number!: number;
@Field({ description: 'Human key, e.g. TS-42' }) key!: string;
@Field(() => IssueType) type!: IssueType;
@Field(() => IssueArea, { nullable: true }) area!: IssueArea | null;
@Field() title!: string;
@Field(() => String, { nullable: true }) description!: string | null;
@Field(() => IssueStatus) status!: IssueStatus;
@Field(() => IssuePriority) priority!: IssuePriority;
@Field(() => UserRefGql) reporter!: UserRefGql;
@Field(() => UserRefGql, { nullable: true }) assignee!: UserRefGql | null;
@Field(() => ReleaseRefGql, { nullable: true })
release!: ReleaseRefGql | null;
@Field(() => [String]) labels!: string[];
@Field(() => String, { nullable: true }) figmaUrl!: string | null;
@Field(() => Int, { nullable: true }) storyPoints!: number | null;
@Field(() => String, { nullable: true }) dueDate!: string | null;
@Field(() => [IssueLinkGql]) links!: IssueLinkGql[];
@Field(() => Int) commentCount!: number;
@Field() createdAt!: string;
@Field() updatedAt!: string;
@Field(() => Int) version!: number;
}
@ObjectType('IssuePage')
export class IssuePageGql {
@Field(() => [IssueGql]) items!: IssueGql[];
@Field(() => Int) total!: number;
}
@ObjectType('BoardColumn')
export class BoardColumnGql {
@Field(() => IssueStatus) status!: IssueStatus;
@Field(() => [IssueGql]) issues!: IssueGql[];
@Field(() => Int) total!: number;
}
@ObjectType('Comment')
export class CommentGql {
@Field(() => ID) id!: string;
@Field(() => ID) issueId!: string;
@Field(() => UserRefGql) author!: UserRefGql;
@Field() body!: string;
@Field() createdAt!: string;
@Field(() => String, { nullable: true }) editedAt!: string | null;
@Field(() => Int) version!: number;
}
@ObjectType('CommentPage')
export class CommentPageGql {
@Field(() => [CommentGql]) items!: CommentGql[];
@Field(() => Int) total!: number;
}
@ObjectType('Release')
export class ReleaseGql {
@Field(() => ID) id!: string;
@Field(() => ID) projectId!: string;
@Field() name!: string;
@Field(() => String, { nullable: true }) description!: string | null;
@Field(() => String, { nullable: true }) targetDate!: string | null;
@Field(() => ReleaseStatus) status!: ReleaseStatus;
@Field(() => String, { nullable: true }) releasedAt!: string | null;
@Field() createdAt!: string;
@Field(() => Int) version!: number;
}
@ObjectType('CountByStatus')
export class CountByStatusGql {
@Field(() => IssueStatus) key!: IssueStatus;
@Field(() => Int) count!: number;
}
@ObjectType('CountByKey')
export class CountByKeyGql {
@Field() key!: string;
@Field(() => Int) count!: number;
}
@ObjectType('CountByAssignee')
export class CountByAssigneeGql {
@Field(() => UserRefGql, { nullable: true, description: 'Null = unassigned' })
user!: UserRefGql | null;
@Field(() => Int) count!: number;
}
@ObjectType('ReleaseProgress')
export class ReleaseProgressGql {
@Field(() => Int) total!: number;
@Field(() => Int) finished!: number;
@Field(() => Int) percent!: number;
@Field(() => [CountByStatusGql]) byStatus!: CountByStatusGql[];
}
@ObjectType('ProjectStats')
export class ProjectStatsGql {
@Field(() => Int) total!: number;
@Field(() => Int) open!: number;
@Field(() => [CountByStatusGql]) byStatus!: CountByStatusGql[];
@Field(() => [CountByKeyGql]) byType!: CountByKeyGql[];
@Field(() => [CountByKeyGql]) byPriority!: CountByKeyGql[];
@Field(() => [CountByKeyGql]) byArea!: CountByKeyGql[];
@Field(() => [CountByAssigneeGql], { description: 'Open tickets per person' })
byAssignee!: CountByAssigneeGql[];
}
@ObjectType('Activity')
export class ActivityGql {
@Field(() => ID) id!: string;
@Field() type!: string;
@Field(() => ID) projectId!: string;
@Field(() => ID, { nullable: true }) issueId!: string | null;
@Field(() => String, { nullable: true }) issueKey!: string | null;
@Field(() => UserRefGql) actor!: UserRefGql;
@Field() summary!: string;
@Field() occurredAt!: string;
}
@ObjectType('CommandResult', {
description:
'Writes are acknowledged immediately; the read model catches up asynchronously. ' +
'Wait for a subscription event with version >= this version to see the change.',
})
export class CommandResultGql {
@Field(() => ID) id!: string;
@Field(() => String, { nullable: true }) key?: string;
@Field(() => Int) version!: number;
}
/* Subscription payloads */
@ObjectType('IssueChange')
export class IssueChangeGql {
@Field() action!: string;
@Field(() => Int) version!: number;
@Field(() => ID) actorId!: string;
@Field(() => IssueGql) issue!: IssueGql;
}
@ObjectType('CommentChange')
export class CommentChangeGql {
@Field() action!: string;
@Field(() => Int) version!: number;
@Field(() => ID) actorId!: string;
@Field(() => ID) issueId!: string;
@Field(() => ID) commentId!: string;
@Field(() => CommentGql, { nullable: true, description: 'Null when deleted' })
comment!: CommentGql | null;
}
@ObjectType('ReleaseChange')
export class ReleaseChangeGql {
@Field() action!: string;
@Field(() => Int) version!: number;
@Field(() => ID) actorId!: string;
@Field(() => ReleaseGql) release!: ReleaseGql;
}
@ObjectType('ProjectChange')
export class ProjectChangeGql {
@Field() action!: string;
@Field(() => Int) version!: number;
@Field(() => ID) actorId!: string;
@Field(() => ProjectGql) project!: ProjectGql;
}