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:
@@ -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)' })
|
||||
|
||||
Reference in New Issue
Block a user