Files
task-stream/libs/api/tracker/presenters/src/lib/http/issues.controller.ts
Josep Piguillem d6a434ca1d feat: global search and keyboard shortcuts
Ctrl/⌘ K (or /) opens a search over the projects and tickets of all my
projects, driven by the keyboard. g plus a letter moves between project
pages, c creates a ticket on the board, e edits the open ticket and ?
lists every shortcut. My work can now be searched by text through the API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:32:07 +02:00

523 lines
14 KiB
TypeScript

import {
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
ParseUUIDPipe,
Patch,
Post,
Put,
Query,
} from '@nestjs/common';
import {
ApiCreatedResponse,
ApiOkResponse,
ApiOperation,
ApiParam,
ApiTags,
} from '@nestjs/swagger';
import { User } from '@task-stream/api/identity/domain';
import {
ApiOptionalPireonAuth,
ApiPireonAuth,
CurrentUser,
} from '@task-stream/api/identity/presenters';
import {
ApiCommandErrors,
ApiReadErrors,
} from '@task-stream/api/shared/presenters';
import {
CommentsService,
IssuesService,
} from '@task-stream/api/tracker/application';
import { IssueLinkType } from '@task-stream/api/tracker/domain';
import { withoutNulls } from '../common/issue-input';
import {
AssignIssueDto,
BulkUpdateIssuesDto,
ChangeStatusDto,
ChecklistItemBodyDto,
CommentBodyDto,
CreateIssueDto,
IssueSearchQueryDto,
LinkIssueDto,
LinkTypeParamDto,
MyIssuesQueryDto,
PageQueryDto,
RankIssueDto,
SetIssueParentDto,
SetIssueReleaseDto,
UpdateChecklistItemDto,
UpdateIssueDto,
} from './dto/requests.dto';
import {
ActivityDto,
BulkResultDto,
CommandResultDto,
CommentPageDto,
EpicDto,
IssueDto,
IssuePageDto,
} from './dto/responses.dto';
import { viewerOf } from '../common/viewer';
import { toBulkChange } from '../common/bulk';
import { Refs } from './refs';
const PROJECT = ApiParam({
name: 'project',
description: 'Project id or key',
example: 'WEB',
});
const ISSUE = ApiParam({
name: 'issue',
description: 'Ticket id or key',
example: 'WEB-12',
});
/** REST adapter of the ticket use cases: bugs, features, UI changes, tasks and QA. */
@ApiTags('Issues')
@ApiPireonAuth()
@Controller()
export class IssuesController {
constructor(
private readonly issues: IssuesService,
private readonly comments: CommentsService,
private readonly refs: Refs,
) {}
@Put('issues/:issue/watch')
@ISSUE
@ApiOperation({
summary: 'Follow a ticket (members, viewers included)',
description:
'Followers are told when it moves, is reassigned or gets a comment. Commenting follows it too.',
})
@ApiOkResponse({ type: CommandResultDto })
@ApiCommandErrors()
async watch(@CurrentUser() me: User, @Param('issue') issue: string) {
return this.issues.watch(
me.id,
await this.refs.issueId(me.id, issue),
true,
);
}
@Delete('issues/:issue/watch')
@ISSUE
@ApiOperation({ summary: 'Stop following a ticket' })
@ApiOkResponse({ type: CommandResultDto })
@ApiCommandErrors()
async unwatch(@CurrentUser() me: User, @Param('issue') issue: string) {
return this.issues.watch(
me.id,
await this.refs.issueId(me.id, issue),
false,
);
}
@Get('me/issues')
@ApiOperation({
summary: 'My work: tickets across all my projects',
description:
'Assigned to me by default; with assignedToMe=false, any ticket of my projects (e.g. waiting for QA).',
})
@ApiOkResponse({ type: IssuePageDto })
@ApiReadErrors()
async mine(@CurrentUser() me: User, @Query() query: MyIssuesQueryDto) {
const page = await this.issues.mine(me.id, {
assignedToMe: query.assignedToMe,
statuses: query.status,
categories: query.category,
search: query.search,
limit: query.limit,
offset: query.offset,
});
return { ...page, items: await this.refs.issueDtos(page.items) };
}
@Get('projects/:project/issues')
@ApiOptionalPireonAuth()
@PROJECT
@ApiOperation({ summary: 'Search the tickets of a project' })
@ApiOkResponse({ type: IssuePageDto })
@ApiReadErrors()
async search(
@CurrentUser() me: User | undefined,
@Param('project') project: string,
@Query() query: IssueSearchQueryDto,
) {
const page = await this.issues.search(viewerOf(me), {
projectId: await this.refs.projectId(viewerOf(me), project),
parentId: query.parent
? await this.refs.issueId(viewerOf(me), query.parent)
: undefined,
statuses: query.status,
categories: query.category,
types: query.type,
areas: query.area,
priorities: query.priority,
assigneeId: query.assigneeId,
unassigned: query.unassigned,
releaseId: query.releaseId,
labels: query.label,
sprintId: query.sprintId,
noSprint: query.noSprint,
sort: query.sort,
search: query.search,
limit: query.limit,
offset: query.offset,
});
return { ...page, items: await this.refs.issueDtos(page.items) };
}
@Post('projects/:project/issues')
@PROJECT
@ApiOperation({
summary: 'Create a ticket',
description:
"Starts in the project's first active TODO-category status (BACKLOG by default). The response carries its key (e.g. WEB-13).",
})
@ApiCreatedResponse({ type: CommandResultDto })
@ApiCommandErrors()
async create(
@CurrentUser() me: User,
@Param('project') project: string,
@Body() body: CreateIssueDto,
) {
const { parent, ...fields } = body;
return this.issues.create(me.id, {
...withoutNulls(fields),
projectId: await this.refs.projectId(me.id, project),
parentId: parent ? await this.refs.issueId(me.id, parent) : null,
});
}
@Get('projects/:project/epics')
@ApiOptionalPireonAuth()
@PROJECT
@ApiOperation({
summary: 'Epics: top-level tickets that group others',
description:
'With the progress of the tickets hanging from each one (done or cancelled, and story points).',
})
@ApiOkResponse({ type: [EpicDto] })
@ApiReadErrors()
async epics(
@CurrentUser() me: User | undefined,
@Param('project') project: string,
) {
const epics = await this.issues.epics(
viewerOf(me),
await this.refs.projectId(viewerOf(me), project),
);
return Promise.all(
epics.map(async (epic) => ({
...epic,
issue: await this.refs.issueDto(epic.issue),
})),
);
}
@Get('issues/:issue')
@ApiOptionalPireonAuth()
@ISSUE
@ApiOperation({ summary: 'A ticket' })
@ApiOkResponse({ type: IssueDto })
@ApiReadErrors()
async findOne(
@CurrentUser() me: User | undefined,
@Param('issue') issue: string,
) {
return this.refs.issueDto(await this.issues.findOne(viewerOf(me), issue));
}
@Patch('issues/:issue')
@ISSUE
@ApiOperation({
summary: 'Edit title, description, type, priority, area, labels…',
})
@ApiOkResponse({ type: CommandResultDto })
@ApiCommandErrors()
async update(
@CurrentUser() me: User,
@Param('issue') issue: string,
@Body() body: UpdateIssueDto,
) {
return this.issues.update(
me.id,
await this.refs.issueId(me.id, issue),
withoutNulls(body),
);
}
@Put('issues/:issue/status')
@ISSUE
@ApiOperation({
summary: 'Move the ticket through the workflow (incl. QA)',
description:
'Only the transitions in `allowedTransitions` are accepted (422 otherwise). ' +
'QA_FAILED and CANCELLED need a reason.',
})
@ApiOkResponse({ type: CommandResultDto })
@ApiCommandErrors()
async changeStatus(
@CurrentUser() me: User,
@Param('issue') issue: string,
@Body() body: ChangeStatusDto,
) {
return this.issues.changeStatus(
me.id,
await this.refs.issueId(me.id, issue),
body.status,
body.reason,
);
}
@Put('issues/:issue/assignee')
@ISSUE
@ApiOperation({ summary: 'Assign to a project member (null to unassign)' })
@ApiOkResponse({ type: CommandResultDto })
@ApiCommandErrors()
async assign(
@CurrentUser() me: User,
@Param('issue') issue: string,
@Body() body: AssignIssueDto,
) {
return this.issues.assign(
me.id,
await this.refs.issueId(me.id, issue),
body.assigneeId,
);
}
@Put('issues/:issue/release')
@ISSUE
@ApiOperation({
summary: 'Put the ticket in a planned release (null takes it out)',
})
@ApiOkResponse({ type: CommandResultDto })
@ApiCommandErrors()
async setRelease(
@CurrentUser() me: User,
@Param('issue') issue: string,
@Body() body: SetIssueReleaseDto,
) {
return this.issues.setRelease(
me.id,
await this.refs.issueId(me.id, issue),
body.releaseId,
);
}
@Post('issues/bulk')
@HttpCode(200)
@ApiOperation({
summary: 'The same change on up to 100 tickets',
description:
'Each ticket goes through the normal rules (permissions, workflow, open sprints and releases) and may refuse on its own; the answer says which.',
})
@ApiOkResponse({ type: BulkResultDto })
@ApiCommandErrors()
bulkUpdate(@CurrentUser() me: User, @Body() body: BulkUpdateIssuesDto) {
return this.issues.bulkUpdate(me.id, body.issueIds, toBulkChange(body));
}
@Put('issues/:issue/parent')
@ISSUE
@ApiOperation({
summary: 'Place the ticket under an epic or another ticket',
description:
'Same project, three levels at most (epic → ticket → subtask), no loops. null takes it back to the top.',
})
@ApiOkResponse({ type: CommandResultDto })
@ApiCommandErrors()
async setParent(
@CurrentUser() me: User,
@Param('issue') issue: string,
@Body() body: SetIssueParentDto,
) {
return this.issues.setParent(
me.id,
await this.refs.issueId(me.id, issue),
body.parent ? await this.refs.issueId(me.id, body.parent) : null,
);
}
@Put('issues/:issue/rank')
@ISSUE
@ApiOperation({
summary: 'Move the ticket in the backlog',
description:
'Between the ticket that ends up above it and the one below (either may be left out for the top or the bottom). Board columns follow the same order.',
})
@ApiOkResponse({ type: CommandResultDto })
@ApiCommandErrors()
async rank(
@CurrentUser() me: User,
@Param('issue') issue: string,
@Body() body: RankIssueDto,
) {
const ref = (value?: string | null) =>
value ? this.refs.issueId(me.id, value) : null;
return this.issues.rank(
me.id,
await this.refs.issueId(me.id, issue),
await ref(body.above),
await ref(body.below),
);
}
@Post('issues/:issue/checklist')
@ISSUE
@ApiOperation({ summary: "Add a step to the ticket's checklist" })
@ApiCreatedResponse({ type: CommandResultDto })
@ApiCommandErrors()
async addChecklistItem(
@CurrentUser() me: User,
@Param('issue') issue: string,
@Body() body: ChecklistItemBodyDto,
) {
return this.issues.addChecklistItem(
me.id,
await this.refs.issueId(me.id, issue),
body.text,
);
}
@Patch('issues/:issue/checklist/:item')
@ISSUE
@ApiParam({ name: 'item', format: 'uuid' })
@ApiOperation({ summary: 'Reword, tick or untick a checklist step' })
@ApiOkResponse({ type: CommandResultDto })
@ApiCommandErrors()
async updateChecklistItem(
@CurrentUser() me: User,
@Param('issue') issue: string,
@Param('item', ParseUUIDPipe) item: string,
@Body() body: UpdateChecklistItemDto,
) {
return this.issues.updateChecklistItem(
me.id,
await this.refs.issueId(me.id, issue),
item,
body,
);
}
@Delete('issues/:issue/checklist/:item')
@ISSUE
@ApiParam({ name: 'item', format: 'uuid' })
@ApiOperation({ summary: 'Remove a checklist step' })
@ApiOkResponse({ type: CommandResultDto })
@ApiCommandErrors()
async removeChecklistItem(
@CurrentUser() me: User,
@Param('issue') issue: string,
@Param('item', ParseUUIDPipe) item: string,
) {
return this.issues.removeChecklistItem(
me.id,
await this.refs.issueId(me.id, issue),
item,
);
}
@Post('issues/:issue/links')
@ISSUE
@ApiOperation({
summary: 'Relate tickets (FE ↔ BE ↔ QA)',
description:
'e.g. WEB-3 DEPENDS_ON API-7, WEB-9 TESTS WEB-3. May cross projects.',
})
@ApiCreatedResponse({ type: CommandResultDto })
@ApiCommandErrors()
async link(
@CurrentUser() me: User,
@Param('issue') issue: string,
@Body() body: LinkIssueDto,
) {
return this.issues.link(me.id, {
issueId: await this.refs.issueId(me.id, issue),
type: body.type,
targetIssueId: await this.refs.issueId(me.id, body.targetIssue),
});
}
@Delete('issues/:issue/links/:type/:target')
@ISSUE
@ApiParam({ name: 'type', enum: IssueLinkType, enumName: 'IssueLinkType' })
@ApiParam({ name: 'target', description: 'Other ticket: id or key' })
@ApiOperation({ summary: 'Remove a relation' })
@ApiOkResponse({ type: CommandResultDto })
@ApiCommandErrors()
async unlink(
@CurrentUser() me: User,
@Param('issue') issue: string,
@Param() params: LinkTypeParamDto,
@Param('target') target: string,
) {
return this.issues.unlink(me.id, {
issueId: await this.refs.issueId(me.id, issue),
type: params.type,
targetIssueId: await this.refs.issueId(me.id, target),
});
}
@Get('issues/:issue/comments')
@ApiOptionalPireonAuth()
@ISSUE
@ApiOperation({ summary: 'Comments of the ticket (oldest first)' })
@ApiOkResponse({ type: CommentPageDto })
@ApiReadErrors()
async listComments(
@CurrentUser() me: User | undefined,
@Param('issue') issue: string,
@Query() page: PageQueryDto,
) {
return this.comments.list(
viewerOf(me),
await this.refs.issueId(viewerOf(me), issue),
page.limit,
page.offset,
);
}
@Post('issues/:issue/comments')
@ISSUE
@ApiOperation({ summary: 'Comment on the ticket' })
@ApiCreatedResponse({ type: CommandResultDto })
@ApiCommandErrors()
async addComment(
@CurrentUser() me: User,
@Param('issue') issue: string,
@Body() body: CommentBodyDto,
) {
return this.comments.add(
me.id,
await this.refs.issueId(me.id, issue),
body.body,
);
}
@Get('issues/:issue/history')
@ApiOptionalPireonAuth()
@ISSUE
@ApiOperation({ summary: 'History of the ticket (newest first)' })
@ApiOkResponse({ type: [ActivityDto] })
@ApiReadErrors()
async history(
@CurrentUser() me: User | undefined,
@Param('issue') issue: string,
@Query() page: PageQueryDto,
) {
const found = await this.issues.findOne(viewerOf(me), issue);
return this.issues.activity(viewerOf(me), {
projectId: found.projectId,
issueId: found.id,
limit: page.limit,
});
}
}