A ticket's page beats every 10 s (touchPresence / PUT
/issues/{issue}/presence, with whether I am editing) and gets back who
else is there, editing ones first. Entries live in Redis, one hash per
ticket, and fade after 30 s without a beat; leaving the page takes me out
at once. Only for people who can read the ticket; never in the event
store. The ticket shows «Bob está editando · Ana también lo está viendo»
above its title; editing means a ticket or comment form is open or a
comment is being written.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
672 lines
18 KiB
TypeScript
672 lines
18 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,
|
|
PresenceService,
|
|
} 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,
|
|
IssueSearchQueryDto,
|
|
PresenceBodyDto,
|
|
LinkIssueDto,
|
|
LinkTypeParamDto,
|
|
MyIssuesQueryDto,
|
|
PageQueryDto,
|
|
RankIssueDto,
|
|
SetIssueParentDto,
|
|
SetIssueReleaseDto,
|
|
UpdateChecklistItemDto,
|
|
UpdateIssueDto,
|
|
} from './dto/requests.dto';
|
|
import {
|
|
ActivityDto,
|
|
BulkResultDto,
|
|
CommandResultDto,
|
|
CommentPageDto,
|
|
EpicDto,
|
|
IssueDto,
|
|
IssuePageDto,
|
|
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,
|
|
) {}
|
|
|
|
@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));
|
|
}
|
|
|
|
@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,
|
|
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,
|
|
);
|
|
}
|
|
|
|
@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,
|
|
);
|
|
}
|
|
|
|
@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,
|
|
});
|
|
}
|
|
}
|