Files
task-stream/libs/api/tracker/presenters/src/lib/http/issues.controller.ts
Josep Piguillem 3a46a236e4 feat: quick polls in comments that end in a decision
«📊 Añadir una encuesta» adds 2 to 6 options to a new comment. Members,
viewers too, vote by clicking an option (one vote each, which they can
change or take back) and see each option's votes as a bar. Whoever
started it, or an admin, closes it with «Cerrar y decidir»: the most
voted wins (none on a tie) and a «Decidido: …» reply marked as a
decision shows above the ticket. Events of the comment (voted, closed).

API: addComment(…, poll), votePoll, closePoll, Comment.poll;
POST /api/comments/{id}/poll/vote and …/poll/close. MCP: add_comment
poll, vote_poll, close_poll.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 20:48:12 +02:00

1016 lines
28 KiB
TypeScript

import {
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
ParseUUIDPipe,
Patch,
Post,
Put,
Query,
} from '@nestjs/common';
import {
ApiCreatedResponse,
ApiNoContentResponse,
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,
PinsService,
TreeFollowsService,
IssueRemindersService,
MyDayService,
PresenceService,
ProjectsService,
} 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,
AddCommentDto,
CreateIssueDto,
DecisionsQueryDto,
SetReminderDto,
RequestReviewDto,
AnswerReviewDto,
MyDayQueryDto,
IssueSearchQueryDto,
PresenceBodyDto,
LinkIssueDto,
LinkTypeParamDto,
MyIssuesQueryDto,
PageQueryDto,
RankIssueDto,
SetIssueParentDto,
SetIssueReleaseDto,
UpdateChecklistItemDto,
UpdateIssueDto,
} from './dto/requests.dto';
import {
ActivityDto,
BulkResultDto,
CommandResultDto,
CommentDto,
CommentPageDto,
DecisionDto,
IssueReminderDto,
MyDayDto,
ReminderOnIssueDto,
EpicDto,
IssueDto,
IssuePageDto,
PortfolioProjectDto,
HomeDto,
PresenceDto,
} 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,
private readonly presence: PresenceService,
private readonly projects: ProjectsService,
private readonly pins: PinsService,
private readonly treeFollows: TreeFollowsService,
private readonly reminders: IssueRemindersService,
private readonly myDayService: MyDayService,
) {}
@Get('me/home')
@ApiOperation({
summary: 'The home page beyond my work',
description:
'Agenda of my week, latest activity of my projects, the week in numbers (the team and me), sprints under way, project cards, suggestions, first steps and who is on what now.',
})
@ApiOkResponse({ type: HomeDto })
@ApiReadErrors()
home(@CurrentUser() me: User) {
return this.projects.home(me.id);
}
@Get('me/portfolio')
@ApiOperation({
summary: 'My projects at a glance',
description:
'Every active project I am a member of (not archived, not as a guest): open and at-risk tickets, the sprint under way, release forecasts and open epics.',
})
@ApiOkResponse({ type: [PortfolioProjectDto] })
@ApiReadErrors()
async portfolio(@CurrentUser() me: User) {
const projects = await this.projects.portfolio(me.id);
// Epics as the REST API shows tickets (what blocks them, my vote…).
return Promise.all(
projects.map(async (p) => ({
...p,
epics: await Promise.all(
p.epics.map(async (e) => ({
...e,
issue: await this.refs.issueDto(e.issue, me.id),
})),
),
})),
);
}
@Put('issues/:issue/presence')
@ISSUE
@ApiOperation({
summary: 'I am on this ticket (heartbeat)',
description:
'Send it every ~10 s while the page is open; entries fade after 30 s without one. Answers who else is there, the ones editing first.',
})
@ApiOkResponse({ type: [PresenceDto] })
@ApiReadErrors()
async touchPresence(
@CurrentUser() me: User,
@Param('issue') issue: string,
@Body() body: PresenceBodyDto,
) {
return this.presence.touch(
{ id: me.id, name: me.name },
await this.refs.issueId(me.id, issue),
body.editing,
);
}
@Delete('issues/:issue/presence')
@HttpCode(204)
@ISSUE
@ApiOperation({ summary: "I left this ticket's page" })
@ApiNoContentResponse()
@ApiReadErrors()
async leavePresence(@CurrentUser() me: User, @Param('issue') issue: string) {
await this.presence.leave(me.id, await this.refs.issueId(me.id, issue));
}
@Get('me/pins')
@ApiOperation({
summary: 'My pinned tickets, most recently pinned first',
description:
'Across my projects; tickets in the trash or out of my reach are left out.',
})
@ApiOkResponse({ type: [IssueDto] })
@ApiReadErrors()
async myPins(@CurrentUser() me: User) {
return this.refs.issueDtos(await this.pins.mine(me.id), viewerOf(me));
}
@Put('issues/:issue/pin')
@HttpCode(204)
@ISSUE
@ApiOperation({
summary: 'Pin a ticket: keep it at hand on my home page and in search',
description: 'Private to me; at most 20. Pinning it again changes nothing.',
})
@ApiNoContentResponse()
@ApiCommandErrors()
async pin(@CurrentUser() me: User, @Param('issue') issue: string) {
await this.pins.pin(me.id, await this.refs.issueId(me.id, issue), true);
}
@Delete('issues/:issue/pin')
@HttpCode(204)
@ISSUE
@ApiOperation({ summary: 'Unpin a ticket' })
@ApiNoContentResponse()
@ApiCommandErrors()
async unpin(@CurrentUser() me: User, @Param('issue') issue: string) {
await this.pins.pin(me.id, await this.refs.issueId(me.id, issue), false);
}
@Get('me/day')
@ApiOperation({
summary: '«Mi día»: the tickets I picked for today',
description: 'And what was left unfinished on my latest earlier day.',
})
@ApiOkResponse({ type: MyDayDto })
@ApiReadErrors()
async myDay(@CurrentUser() me: User, @Query() query: MyDayQueryDto) {
const myDay = await this.myDayService.get(me.id, query.day);
return {
day: myDay.day,
issues: await this.refs.issueDtos(myDay.issues, viewerOf(me)),
leftover: myDay.leftover && {
day: myDay.leftover.day,
issues: await this.refs.issueDtos(myDay.leftover.issues, viewerOf(me)),
},
};
}
@Put('me/day/:issue')
@HttpCode(204)
@ISSUE
@ApiOperation({ summary: 'Add a ticket to «Mi día» (10 at most)' })
@ApiNoContentResponse()
@ApiCommandErrors()
async addToMyDay(
@CurrentUser() me: User,
@Param('issue') issue: string,
@Query() query: MyDayQueryDto,
) {
await this.myDayService.add(me.id, query.day, [
await this.refs.issueId(me.id, issue),
]);
}
@Delete('me/day/:issue')
@HttpCode(204)
@ISSUE
@ApiOperation({ summary: 'Take a ticket out of «Mi día»' })
@ApiNoContentResponse()
@ApiCommandErrors()
async removeFromMyDay(
@CurrentUser() me: User,
@Param('issue') issue: string,
@Query() query: MyDayQueryDto,
) {
await this.myDayService.remove(
me.id,
query.day,
await this.refs.issueId(me.id, issue),
);
}
@Get('me/reminders')
@ApiOperation({ summary: 'My waiting reminders on tickets, soonest first' })
@ApiOkResponse({ type: [ReminderOnIssueDto] })
@ApiReadErrors()
async myReminders(@CurrentUser() me: User) {
const mine = await this.reminders.mine(me.id);
const issues = await this.refs.issueDtos(
mine.map((m) => m.issue),
viewerOf(me),
);
return mine.map((m, i) => ({
reminder: { at: m.reminder.at, note: m.reminder.note },
issue: issues[i],
}));
}
@Put('issues/:issue/reminder')
@ISSUE
@ApiOperation({
summary: 'Remind me of a ticket at a date and time',
description:
'A notification (bell, push, e-mail) when the time comes. One per ticket: it replaces the waiting one. Private to me.',
})
@ApiOkResponse({ type: IssueReminderDto })
@ApiCommandErrors()
async setReminder(
@CurrentUser() me: User,
@Param('issue') issue: string,
@Body() body: SetReminderDto,
) {
const reminder = await this.reminders.set(
me.id,
await this.refs.issueId(me.id, issue),
body.at,
body.note ?? null,
);
return { at: reminder.at, note: reminder.note };
}
@Delete('issues/:issue/reminder')
@HttpCode(204)
@ISSUE
@ApiOperation({ summary: 'Drop my reminder on a ticket' })
@ApiNoContentResponse()
@ApiCommandErrors()
async clearReminder(@CurrentUser() me: User, @Param('issue') issue: string) {
await this.reminders.clear(me.id, await this.refs.issueId(me.id, issue));
}
@Post('issues/:issue/reviews')
@ISSUE
@ApiOperation({
summary: 'Ask a member to review the ticket',
description:
'Whoever can change the ticket asks; the reviewer is told and sees it in GET /api/me/issues?reviewing=true until they answer.',
})
@ApiCreatedResponse({ type: CommandResultDto })
@ApiCommandErrors()
async requestReview(
@CurrentUser() me: User,
@Param('issue') issue: string,
@Body() body: RequestReviewDto,
) {
return this.issues.requestReview(
me.id,
await this.refs.issueId(me.id, issue),
body.reviewer,
body.note ?? null,
);
}
@Post('issues/:issue/reviews/:review/answer')
@HttpCode(200)
@ISSUE
@ApiParam({ name: 'review', format: 'uuid' })
@ApiOperation({
summary: 'Answer a review I was asked for (all good, or changes)',
})
@ApiOkResponse({ type: CommandResultDto })
@ApiCommandErrors()
async answerReview(
@CurrentUser() me: User,
@Param('issue') issue: string,
@Param('review') review: string,
@Body() body: AnswerReviewDto,
) {
return this.issues.answerReview(
me.id,
await this.refs.issueId(me.id, issue),
review,
body.verdict,
body.answer ?? null,
);
}
@Delete('issues/:issue/reviews/:review')
@ISSUE
@ApiParam({ name: 'review', format: 'uuid' })
@ApiOperation({ summary: 'Withdraw a review request I made' })
@ApiOkResponse({ type: CommandResultDto })
@ApiCommandErrors()
async cancelReview(
@CurrentUser() me: User,
@Param('issue') issue: string,
@Param('review') review: string,
) {
return this.issues.cancelReview(
me.id,
await this.refs.issueId(me.id, issue),
review,
);
}
@Put('issues/:issue/follow-tree')
@HttpCode(204)
@ISSUE
@ApiOperation({
summary: 'Follow a ticket with everything under it (an epic)',
description:
'Told of its changes and of every ticket hanging from it, at any depth, also the ones added later. Private to me.',
})
@ApiNoContentResponse()
@ApiCommandErrors()
async followTree(@CurrentUser() me: User, @Param('issue') issue: string) {
await this.treeFollows.follow(
me.id,
await this.refs.issueId(me.id, issue),
true,
);
}
@Delete('issues/:issue/follow-tree')
@HttpCode(204)
@ISSUE
@ApiOperation({ summary: 'Stop following a ticket with everything under it' })
@ApiNoContentResponse()
@ApiCommandErrors()
async unfollowTree(@CurrentUser() me: User, @Param('issue') issue: string) {
await this.treeFollows.follow(
me.id,
await this.refs.issueId(me.id, issue),
false,
);
}
@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,
watching: query.watching,
reviewing: query.reviewing,
statuses: query.status,
categories: query.category,
search: query.search,
projectIds: query.project,
priorities: query.priority,
bugsOnly: query.bugsOnly,
overdue: query.overdue,
limit: query.limit,
offset: query.offset,
});
return {
...page,
items: await this.refs.issueDtos(page.items, viewerOf(me)),
};
}
@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,
teams: query.team,
priorities: query.priority,
assigneeId: query.assigneeId,
unassigned: query.unassigned,
releaseId: query.releaseId,
labels: query.label,
sprintId: query.sprintId,
noSprint: query.noSprint,
watching: query.watching,
dueFrom: query.dueFrom,
dueTo: query.dueTo,
noEstimate: query.noEstimate,
noArea: query.noArea,
updatedBefore: query.updatedBefore,
customFields: query.field?.length
? Object.fromEntries(
query.field.map((f) => [
f.slice(0, f.indexOf(':')),
f.slice(f.indexOf(':') + 1),
]),
)
: undefined,
sort: query.sort,
search: query.search,
limit: query.limit,
offset: query.offset,
});
return {
...page,
items: await this.refs.issueDtos(page.items, viewerOf(me)),
};
}
@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, viewerOf(me)),
})),
);
}
@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),
viewerOf(me),
);
}
@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,
);
}
@Delete('issues/:issue')
@ISSUE
@ApiOperation({
summary: 'Move the ticket to the trash',
description:
'Whoever opened it, or an owner/admin. Hidden everywhere; restore it within 30 days or it is deleted for good.',
})
@ApiOkResponse({ type: CommandResultDto })
@ApiCommandErrors()
async trash(@CurrentUser() me: User, @Param('issue') issue: string) {
return this.issues.trash(me.id, await this.refs.issueId(me.id, issue));
}
@Post('issues/:issue/restore')
@HttpCode(200)
@ISSUE
@ApiOperation({ summary: 'Bring the ticket back from the trash' })
@ApiOkResponse({ type: CommandResultDto })
@ApiCommandErrors()
async restore(@CurrentUser() me: User, @Param('issue') issue: string) {
return this.issues.restore(me.id, await this.refs.issueId(me.id, issue));
}
@Put('issues/:issue/vote')
@ISSUE
@ApiOperation({
summary: 'Vote for the ticket',
description:
'Anyone signed in, on a public project (that is the point: the people it is for say what they want); members on a private one. Voting twice changes nothing.',
})
@ApiOkResponse({ type: CommandResultDto })
@ApiCommandErrors()
async vote(@CurrentUser() me: User, @Param('issue') issue: string) {
return this.issues.vote(me.id, await this.refs.issueId(me.id, issue), true);
}
@Delete('issues/:issue/vote')
@ISSUE
@ApiOperation({ summary: 'Take back my vote for the ticket' })
@ApiOkResponse({ type: CommandResultDto })
@ApiCommandErrors()
async unvote(@CurrentUser() me: User, @Param('issue') issue: string) {
return this.issues.vote(
me.id,
await this.refs.issueId(me.id, issue),
false,
);
}
@Post('issues/:issue/duplicate')
@ISSUE
@ApiOperation({
summary: 'Duplicate the ticket',
description:
'Copies what it says (type, title, description, area, priority, labels, Figma, estimate, dates, parent and checklist steps, unticked), not where it stands: the copy starts in the first status, unassigned, out of sprints and releases, without comments or files. The result carries its key.',
})
@ApiCreatedResponse({ type: CommandResultDto })
@ApiCommandErrors()
async duplicate(@CurrentUser() me: User, @Param('issue') issue: string) {
return this.issues.duplicate(me.id, await this.refs.issueId(me.id, issue));
}
@Get('projects/:project/trash')
@ApiOptionalPireonAuth()
@PROJECT
@ApiOperation({ summary: "The project's tickets in the trash, newest first" })
@ApiOkResponse({ type: [IssueDto] })
@ApiReadErrors()
async trashed(
@CurrentUser() me: User | undefined,
@Param('project') project: string,
) {
return this.refs.issueDtos(
await this.issues.trashed(
viewerOf(me),
await this.refs.projectId(viewerOf(me), project),
),
viewerOf(me),
);
}
@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,
);
}
@Get('issues/:issue/decisions')
@ApiOptionalPireonAuth()
@ISSUE
@ApiOperation({
summary: 'Comments of the ticket marked as decisions (oldest first)',
})
@ApiOkResponse({ type: [CommentDto] })
@ApiReadErrors()
async issueDecisions(
@CurrentUser() me: User | undefined,
@Param('issue') issue: string,
) {
return this.comments.decisions(
viewerOf(me),
await this.refs.issueId(viewerOf(me), issue),
);
}
@Get('projects/:project/decisions')
@ApiOptionalPireonAuth()
@PROJECT
@ApiOperation({
summary: "Search a project's decisions (most recent first)",
})
@ApiOkResponse({ type: [DecisionDto] })
@ApiReadErrors()
async projectDecisions(
@CurrentUser() me: User | undefined,
@Param('project') project: string,
@Query() query: DecisionsQueryDto,
) {
return this.comments.searchDecisions(viewerOf(me), {
projectId: await this.refs.projectId(viewerOf(me), project),
search: query.search,
limit: query.limit,
});
}
@Get('me/decisions')
@ApiOperation({
summary: 'Search the decisions of all my projects (most recent first)',
})
@ApiOkResponse({ type: [DecisionDto] })
@ApiReadErrors()
myDecisions(@CurrentUser() me: User, @Query() query: DecisionsQueryDto) {
return this.comments.searchDecisions(me.id, {
search: query.search,
limit: query.limit,
});
}
@Post('issues/:issue/comments')
@ISSUE
@ApiOperation({
summary: 'Comment on the ticket, or answer a comment (replyTo)',
})
@ApiCreatedResponse({ type: CommandResultDto })
@ApiCommandErrors()
async addComment(
@CurrentUser() me: User,
@Param('issue') issue: string,
@Body() body: AddCommentDto,
) {
return this.comments.add(
me.id,
await this.refs.issueId(me.id, issue),
body.body,
body.replyTo ?? null,
body.poll ?? null,
);
}
@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,
});
}
}