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>
This commit is contained in:
2026-10-06 20:53:45 +02:00
parent 240ea9a539
commit 0e301ad545
143 changed files with 10291 additions and 1052 deletions

View File

@@ -6,6 +6,7 @@ import {
Param,
Patch,
Post,
Put,
Query,
} from '@nestjs/common';
import {
@@ -32,19 +33,23 @@ import {
import {
ActivityQueryDto,
AddMemberDto,
AnalyticsQueryDto,
BoardQueryDto,
ChangeMemberRoleDto,
CreateProjectDto,
ProjectSettingsBodyDto,
UpdateProjectDto,
} from './dto/requests.dto';
import {
ActivityDto,
BoardColumnDto,
CommandResultDto,
ProjectAnalyticsDto,
ProjectDto,
ProjectStatsDto,
} from './dto/responses.dto';
import { Refs, toIssueDto } from './refs';
import { fromSettingsPayload } from '../common/settings';
import { Refs, toProjectDto } from './refs';
const PROJECT = ApiParam({
name: 'project',
@@ -67,8 +72,8 @@ export class ProjectsController {
@ApiOperation({ summary: 'Projects where I am a member' })
@ApiOkResponse({ type: [ProjectDto] })
@ApiReadErrors()
mine(@CurrentUser() me: User) {
return this.projects.findMine(me.id);
async mine(@CurrentUser() me: User) {
return (await this.projects.findMine(me.id)).map(toProjectDto);
}
@Post()
@@ -81,11 +86,11 @@ export class ProjectsController {
@Get(':project')
@PROJECT
@ApiOperation({ summary: 'A project with its members' })
@ApiOperation({ summary: 'A project with its members and settings' })
@ApiOkResponse({ type: ProjectDto })
@ApiReadErrors()
findOne(@CurrentUser() me: User, @Param('project') project: string) {
return this.projects.findOne(me.id, project);
async findOne(@CurrentUser() me: User, @Param('project') project: string) {
return toProjectDto(await this.projects.findOne(me.id, project));
}
@Patch(':project')
@@ -105,6 +110,28 @@ export class ProjectsController {
);
}
@Put(':project/settings')
@PROJECT
@ApiOperation({
summary: 'Statuses, transitions, ticket types and areas (owner/admin)',
description:
'Replaces the whole configuration (send what GET /projects/{project} returns, changed). ' +
'Values can be added, renamed, reordered or archived, never removed: tickets and statistics keep referring to them.',
})
@ApiOkResponse({ type: CommandResultDto })
@ApiCommandErrors()
async updateSettings(
@CurrentUser() me: User,
@Param('project') project: string,
@Body() body: ProjectSettingsBodyDto,
) {
return this.projects.updateSettings(
me.id,
await this.refs.projectId(me.id, project),
fromSettingsPayload(body),
);
}
@Get(':project/board')
@PROJECT
@ApiOperation({ summary: 'Kanban board: one column per status' })
@@ -125,10 +152,12 @@ export class ProjectsController {
},
query.perColumn,
);
return columns.map((column) => ({
...column,
issues: column.issues.map(toIssueDto),
}));
return Promise.all(
columns.map(async (column) => ({
...column,
issues: await this.refs.issueDtos(column.issues),
})),
);
}
@Get(':project/stats')
@@ -145,6 +174,27 @@ export class ProjectsController {
);
}
@Get(':project/analytics')
@PROJECT
@ApiOperation({
summary: 'Statistics of the last `days` days',
description:
'Created vs resolved per week, cumulative flow per day, cycle and lead time, throughput per person and QA pass/fail.',
})
@ApiOkResponse({ type: ProjectAnalyticsDto })
@ApiReadErrors()
async analytics(
@CurrentUser() me: User,
@Param('project') project: string,
@Query() query: AnalyticsQueryDto,
) {
return this.projects.analytics(
me.id,
await this.refs.projectId(me.id, project),
query.days,
);
}
@Get(':project/activity')
@PROJECT
@ApiOperation({ summary: 'Activity feed (newest first)' })