Files
task-stream/libs/api/tracker/presenters/src/lib/http/issues.controller.ts
Josep Piguillem 0e301ad545 feat: per-project workflow settings, statistics and "my work" home
Per-project settings (owners/admins, «Configuración» tab):
- Statuses with category (TODO, IN_PROGRESS, QA, DONE, CANCELLED), colour and
  "requires reason"; configurable transitions; ticket types (icon, colour) and
  areas. Defaults are the previous fixed values; projects created before keep
  them without migration.
- Values are archived, never deleted: tickets, history and statistics keep
  referring to them. Validated in the domain (ProjectSettingsChangedEvent).
- Releases, board, tracking and "my work" use categories, so custom statuses
  keep working. Status changes carry name/category metadata for history.
- GraphQL: status/type/area are strings (breaking for clients sending enum
  literals); Project.settings, updateProjectSettings, statusCategory,
  myIssues(categories). REST: PUT /projects/{project}/settings, category filters.
- Read model: statusCategory on issues (backfilled at startup).

Statistics («Estadísticas» tab):
- New issue_timeline projection; pure functions compute created vs resolved,
  cumulative flow, cycle/lead time (avg + p85), throughput per person, QA
  pass/fail and release burnup. GraphQL Project.analytics(days),
  Release.burnup; REST /projects/{project}/analytics, /releases/{id}/burnup.
- Web: SVG charts with validated light/dark palette, crosshair tooltip,
  keyboard navigation, legend and data table.

Home "Mi trabajo": my open tickets across projects grouped by category (QA
failures first), tickets waiting for QA, upcoming releases and "Ir a…".

Tests: domain/application specs, e2e scenario for settings and analytics,
facade tests. Docs, READMEs, schema.gql/openapi.json and Word/PDF updated.

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

326 lines
8.3 KiB
TypeScript

import {
Body,
Controller,
Delete,
Get,
Param,
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 {
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,
ChangeStatusDto,
CommentBodyDto,
CreateIssueDto,
IssueSearchQueryDto,
LinkIssueDto,
LinkTypeParamDto,
MyIssuesQueryDto,
PageQueryDto,
SetIssueReleaseDto,
UpdateIssueDto,
} from './dto/requests.dto';
import {
ActivityDto,
CommandResultDto,
CommentPageDto,
IssueDto,
IssuePageDto,
} from './dto/responses.dto';
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,
) {}
@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,
limit: query.limit,
offset: query.offset,
});
return { ...page, items: await this.refs.issueDtos(page.items) };
}
@Get('projects/:project/issues')
@PROJECT
@ApiOperation({ summary: 'Search the tickets of a project' })
@ApiOkResponse({ type: IssuePageDto })
@ApiReadErrors()
async search(
@CurrentUser() me: User,
@Param('project') project: string,
@Query() query: IssueSearchQueryDto,
) {
const page = await this.issues.search(me.id, {
projectId: await this.refs.projectId(me.id, project),
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,
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,
) {
return this.issues.create(me.id, {
...withoutNulls(body),
projectId: await this.refs.projectId(me.id, project),
});
}
@Get('issues/:issue')
@ISSUE
@ApiOperation({ summary: 'A ticket' })
@ApiOkResponse({ type: IssueDto })
@ApiReadErrors()
async findOne(@CurrentUser() me: User, @Param('issue') issue: string) {
return this.refs.issueDto(await this.issues.findOne(me.id, 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/: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')
@ISSUE
@ApiOperation({ summary: 'Comments of the ticket (oldest first)' })
@ApiOkResponse({ type: CommentPageDto })
@ApiReadErrors()
async listComments(
@CurrentUser() me: User,
@Param('issue') issue: string,
@Query() page: PageQueryDto,
) {
return this.comments.list(
me.id,
await this.refs.issueId(me.id, 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')
@ISSUE
@ApiOperation({ summary: 'History of the ticket (newest first)' })
@ApiOkResponse({ type: [ActivityDto] })
@ApiReadErrors()
async history(
@CurrentUser() me: User,
@Param('issue') issue: string,
@Query() page: PageQueryDto,
) {
const found = await this.issues.findOne(me.id, issue);
return this.issues.activity(me.id, {
projectId: found.projectId,
issueId: found.id,
limit: page.limit,
});
}
}