A project member can be a GUEST. In a private project a guest only sees the tickets they take part in (opened, assigned or followed): every other ticket answers 404, also in my work, realtime changes, blockers and linked tickets, and project-wide views (activity, statistics, dashboard, releases, sprints, epics, trash) answer 403. Guests open tickets without assigning or planning them, and comment, attach, follow and vote on their own; they change nothing else. In a public project they see what anyone does. The web gives them only Tablero, Lista and Miembros and loads none of the project-wide data. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
510 lines
15 KiB
TypeScript
510 lines
15 KiB
TypeScript
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<string[]> {
|
|
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<string, string> | undefined {
|
|
const pairs = (values ?? []).filter((v) => v.value);
|
|
return pairs.length
|
|
? Object.fromEntries(pairs.map((v) => [v.fieldId, v.value as string]))
|
|
: undefined;
|
|
}
|