import { Args, ID, Int, Mutation, Parent, Query, ResolveField, Resolver, } from '@nestjs/graphql'; import { User } from '@task-stream/api/identity/domain'; import { CurrentUser, OptionalAuth, } from '@task-stream/api/identity/presenters'; import { BlockersService, CommentsService, IssuesService, PresenceService, WorkflowsService, } from '@task-stream/api/tracker/application'; import { IssuePriority, IssueReadModel, StatusCategory, } from '@task-stream/api/tracker/domain'; import { AddChecklistItemInput, BulkUpdateIssuesInput, ChangeIssueStatusInput, CreateIssueInput, IssueFilterInput, LinkIssuesInput, RankIssueInput, UpdateChecklistItemInput, UpdateIssueInput, } from './inputs'; import { withoutNulls } from '../common/issue-input'; import { viewerOf } from '../common/viewer'; import { toBulkChange } from '../common/bulk'; import { ActivityGql, BulkResultGql, CommandResultGql, CommentPageGql, EpicGql, CustomFieldValueGql, IssueGql, IssuePageGql, IssueParentGql, PresenceGql, } from './types'; @Resolver(() => IssueGql) export class IssueResolver { constructor( private readonly issuesService: IssuesService, private readonly commentsService: CommentsService, private readonly workflows: WorkflowsService, private readonly blockers: BlockersService, private readonly presence: PresenceService, ) {} @Mutation(() => [PresenceGql], { description: 'I am on this ticket (send every ~10 s while the page is open): answers who else is, the ones editing first', }) touchPresence( @CurrentUser() me: User, @Args('issueId', { type: () => ID }) issueId: string, @Args('editing', { defaultValue: false }) editing: boolean, ) { return this.presence.touch({ id: me.id, name: me.name }, issueId, editing); } @Mutation(() => Boolean, { description: "I left this ticket's page" }) async leavePresence( @CurrentUser() me: User, @Args('issueId', { type: () => ID }) issueId: string, ) { await this.presence.leave(me.id, issueId); return true; } @OptionalAuth() @Query(() => IssueGql) issue( @CurrentUser() me: User | undefined, @Args('idOrKey', { description: 'Issue id or key (e.g. "TS-42")' }) idOrKey: string, ) { return this.issuesService.findOne(viewerOf(me), idOrKey); } @OptionalAuth() @Query(() => IssuePageGql, { name: 'issues' }) searchIssues( @CurrentUser() me: User | undefined, @Args('filter') filter: IssueFilterInput, @Args('limit', { type: () => Int, defaultValue: 50 }) limit: number, @Args('offset', { type: () => Int, defaultValue: 0 }) offset: number, ) { const { customFields, ...rest } = filter; return this.issuesService.search(viewerOf(me), { ...rest, customFields: onlyValues(customFields), limit, offset, }); } @OptionalAuth() @Query(() => [EpicGql], { description: "A project's epics: top-level tickets that group others, with the progress of those", }) epics( @CurrentUser() me: User | undefined, @Args('projectId', { type: () => ID }) projectId: string, ) { return this.issuesService.epics(viewerOf(me), projectId); } @Query(() => IssuePageGql, { description: 'Tickets across all my projects: assigned to me (default) or any (e.g. categories [QA])', }) myIssues( @CurrentUser() me: User, @Args('assignedToMe', { defaultValue: true }) assignedToMe: boolean, @Args('watching', { defaultValue: false, description: 'Only the tickets I follow', }) watching: boolean, @Args('statuses', { type: () => [String], nullable: true }) statuses: string[] | undefined, @Args('categories', { type: () => [StatusCategory], nullable: true }) categories: StatusCategory[] | undefined, @Args('limit', { type: () => Int, defaultValue: 50 }) limit: number, @Args('offset', { type: () => Int, defaultValue: 0 }) offset: number, @Args('search', { type: () => String, nullable: true, description: 'Matches key, title or description (global search)', }) search?: string | null, @Args('projectIds', { type: () => [ID], nullable: true, description: 'Only these of my projects', }) projectIds?: string[] | null, @Args('priorities', { type: () => [IssuePriority], nullable: true }) priorities?: IssuePriority[] | null, @Args('bugsOnly', { type: () => Boolean, nullable: true, description: 'Only types drawn with the bug icon', }) bugsOnly?: boolean | null, @Args('overdue', { type: () => Boolean, nullable: true, description: 'Only open tickets past their due date', }) overdue?: boolean | null, ) { return this.issuesService.mine(me.id, { assignedToMe, watching, statuses: statuses ?? undefined, categories: categories ?? undefined, search: search ?? undefined, projectIds: projectIds ?? undefined, priorities: priorities ?? undefined, bugsOnly: bugsOnly ?? undefined, overdue: overdue ?? undefined, limit, offset, }); } @OptionalAuth() @Query(() => [ActivityGql], { description: 'Project activity feed (newest first)', }) activity( @CurrentUser() me: User | undefined, @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(viewerOf(me), { projectId, issueId, limit, before, }); } @ResolveField(() => [CustomFieldValueGql], { description: "Values of the project's custom fields (only those set)", }) customFields(@Parent() issue: IssueReadModel): CustomFieldValueGql[] { return Object.entries(issue.customFields ?? {}).map(([fieldId, value]) => ({ fieldId, value, })); } @ResolveField(() => [String], { description: "Statuses this ticket can move to next (project's workflow)", }) allowedTransitions(@Parent() issue: IssueReadModel): Promise { return this.workflows.allowedTransitions(issue); } @ResolveField(() => [IssueParentGql], { description: 'Open tickets it waits for: the ones that block it and the ones it depends on', }) blockedBy( @CurrentUser() me: User | undefined, @Parent() issue: IssueReadModel, ) { return this.blockers.blockedBy(issue, viewerOf(me)); } @ResolveField(() => Boolean, { description: 'Whether I voted for it' }) voted( @CurrentUser() me: User | undefined, @Parent() issue: IssueReadModel, ): boolean { return !!me && issue.voterIds.includes(me.id); } @ResolveField(() => [IssueGql], { description: 'Linked tickets I can see' }) linkedIssues( @CurrentUser() me: User | undefined, @Parent() issue: IssueReadModel, ) { if (issue.links.length === 0) return []; return this.issuesService.findMany( viewerOf(me), issue.links.map((l) => l.issueId), ); } @ResolveField(() => CommentPageGql) comments( @CurrentUser() me: User | undefined, @Parent() issue: IssueReadModel, @Args('limit', { type: () => Int, defaultValue: 50 }) limit: number, @Args('offset', { type: () => Int, defaultValue: 0 }) offset: number, ) { return this.commentsService.list(viewerOf(me), issue.id, limit, offset); } @ResolveField(() => [ActivityGql], { description: 'History of this ticket' }) history( @CurrentUser() me: User | undefined, @Parent() issue: IssueReadModel, @Args('limit', { type: () => Int, defaultValue: 50 }) limit: number, ) { return this.issuesService.activity(viewerOf(me), { 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: 'Follow a ticket: be told when it moves, is reassigned or gets a comment', }) watchIssue( @CurrentUser() me: User, @Args('issueId', { type: () => ID }) issueId: string, ) { return this.issuesService.watch(me.id, issueId, true); } @Mutation(() => CommandResultGql, { description: 'Stop following a ticket' }) unwatchIssue( @CurrentUser() me: User, @Args('issueId', { type: () => ID }) issueId: string, ) { return this.issuesService.watch(me.id, issueId, false); } @Mutation(() => CommandResultGql, { description: 'Remove a file from a ticket (whoever uploaded it, or an owner/admin). Upload with the REST API.', }) removeAttachment( @CurrentUser() me: User, @Args('issueId', { type: () => ID }) issueId: string, @Args('attachmentId', { type: () => ID }) attachmentId: string, ) { return this.issuesService.detach(me.id, issueId, attachmentId); } @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: 'Place a ticket under an epic or another ticket of the same project (null: back to the top). Three levels at most: epic → ticket → subtask.', }) setIssueParent( @CurrentUser() me: User, @Args('issueId', { type: () => ID }) issueId: string, @Args('parentId', { type: () => ID, nullable: true }) parentId: string | null, ) { return this.issuesService.setParent(me.id, issueId, parentId ?? null); } @Mutation(() => CommandResultGql, { description: 'Move a ticket in the backlog, between two others of the same project', }) rankIssue(@CurrentUser() me: User, @Args('input') input: RankIssueInput) { return this.issuesService.rank( me.id, input.issueId, input.aboveIssueId ?? null, input.belowIssueId ?? null, ); } @Mutation(() => CommandResultGql, { description: 'To the trash (whoever opened it, or an owner/admin): hidden everywhere, deleted for good after 30 days', }) trashIssue( @CurrentUser() me: User, @Args('issueId', { type: () => ID }) issueId: string, ) { return this.issuesService.trash(me.id, issueId); } @Mutation(() => CommandResultGql, { description: 'A new ticket like this one: what it says, not where it stands (the key comes in the result)', }) duplicateIssue( @CurrentUser() me: User, @Args('issueId', { type: () => ID }) issueId: string, ) { return this.issuesService.duplicate(me.id, issueId); } @Mutation(() => CommandResultGql, { description: 'Vote for a ticket (on) or take the vote back: anyone signed in on a public project, members on a private one', }) voteIssue( @CurrentUser() me: User, @Args('issueId', { type: () => ID }) issueId: string, @Args('on') on: boolean, ) { return this.issuesService.vote(me.id, issueId, on); } @Mutation(() => CommandResultGql, { description: 'Back from the trash' }) restoreIssue( @CurrentUser() me: User, @Args('issueId', { type: () => ID }) issueId: string, ) { return this.issuesService.restore(me.id, issueId); } @OptionalAuth() @Query(() => [IssueGql], { description: "A project's tickets in the trash, newest first", }) trash( @CurrentUser() me: User | undefined, @Args('projectId', { type: () => ID }) projectId: string, ) { return this.issuesService.trashed(viewerOf(me), projectId); } @Mutation(() => BulkResultGql, { description: 'The same change on up to 100 tickets; each goes through the normal rules and may refuse on its own', }) bulkUpdateIssues( @CurrentUser() me: User, @Args('input') input: BulkUpdateIssuesInput, ) { return this.issuesService.bulkUpdate( me.id, input.issueIds, toBulkChange(input), ); } @Mutation(() => CommandResultGql, { description: "Add a step to a ticket's checklist", }) addChecklistItem( @CurrentUser() me: User, @Args('input') input: AddChecklistItemInput, ) { return this.issuesService.addChecklistItem( me.id, input.issueId, input.text, ); } @Mutation(() => CommandResultGql, { description: 'Reword, tick or untick a checklist step', }) updateChecklistItem( @CurrentUser() me: User, @Args('input') input: UpdateChecklistItemInput, ) { const { issueId, itemId, text, done } = input; return this.issuesService.updateChecklistItem(me.id, issueId, itemId, { ...(text != null && { text }), ...(done != null && { done }), }); } @Mutation(() => CommandResultGql) removeChecklistItem( @CurrentUser() me: User, @Args('issueId', { type: () => ID }) issueId: string, @Args('itemId', { type: () => ID }) itemId: string, ) { return this.issuesService.removeChecklistItem(me.id, issueId, itemId); } @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); } } /** A filter's custom values: the ones that name something to match. */ function onlyValues( values: { fieldId: string; value?: string | null }[] | undefined, ): Record | undefined { const pairs = (values ?? []).filter((v) => v.value); return pairs.length ? Object.fromEntries(pairs.map((v) => [v.fieldId, v.value as string])) : undefined; }