Files
task-stream/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts
Josep Piguillem 8bf7d47b2e feat: guest role for clients and outsiders
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>
2026-10-15 18:02:36 +02:00

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;
}