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:
@@ -10,14 +10,8 @@ import { IssueReleaseChangedEvent } from '../events/issue-release-changed.event'
|
||||
import { IssueStatusChangedEvent } from '../events/issue-status-changed.event';
|
||||
import { IssueUnlinkedEvent } from '../events/issue-unlinked.event';
|
||||
import { IssueUpdatedEvent } from '../events/issue-updated.event';
|
||||
import {
|
||||
IssueArea,
|
||||
IssueLinkType,
|
||||
IssuePriority,
|
||||
IssueStatus,
|
||||
IssueType,
|
||||
} from './issue.enums';
|
||||
import { IssueWorkflow } from './issue-workflow';
|
||||
import { ProjectWorkflow } from '../project/project-settings';
|
||||
import { IssueLinkType, IssuePriority } from './issue.enums';
|
||||
|
||||
export interface IssueLink {
|
||||
type: IssueLinkType;
|
||||
@@ -29,8 +23,10 @@ export interface IssueLink {
|
||||
export interface IssueDetails {
|
||||
title?: string;
|
||||
description?: string | null;
|
||||
type?: IssueType;
|
||||
area?: IssueArea | null;
|
||||
/** Id of one of the project's ticket types. */
|
||||
type?: string;
|
||||
/** Id of one of the project's areas. */
|
||||
area?: string | null;
|
||||
priority?: IssuePriority;
|
||||
labels?: string[];
|
||||
figmaUrl?: string | null;
|
||||
@@ -44,11 +40,12 @@ export interface IssueProps {
|
||||
projectId: string;
|
||||
number: number;
|
||||
key: string;
|
||||
type: IssueType;
|
||||
area: IssueArea | null;
|
||||
type: string;
|
||||
area: string | null;
|
||||
title: string;
|
||||
description: string | null;
|
||||
status: IssueStatus;
|
||||
/** Id of a status of the project's workflow. */
|
||||
status: string;
|
||||
priority: IssuePriority;
|
||||
reporterId: string;
|
||||
assigneeId: string | null;
|
||||
@@ -70,11 +67,11 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
||||
public projectId!: string;
|
||||
public number!: number;
|
||||
public key!: string;
|
||||
public type!: IssueType;
|
||||
public area: IssueArea | null = null;
|
||||
public type!: string;
|
||||
public area: string | null = null;
|
||||
public title!: string;
|
||||
public description: string | null = null;
|
||||
public status: IssueStatus = IssueStatus.BACKLOG;
|
||||
public status!: string;
|
||||
public priority: IssuePriority = IssuePriority.MEDIUM;
|
||||
public reporterId!: string;
|
||||
public assigneeId: string | null = null;
|
||||
@@ -92,7 +89,12 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
updateDetails(actorId: string, changes: IssueDetails): void {
|
||||
/** `workflow` (the project's) validates a new type or area. */
|
||||
updateDetails(
|
||||
actorId: string,
|
||||
changes: IssueDetails,
|
||||
workflow: ProjectWorkflow = ProjectWorkflow.default(),
|
||||
): void {
|
||||
const next = normalizeIssueDetails(changes);
|
||||
const patch: IssueDetails = {};
|
||||
for (const field of Object.keys(next) as (keyof IssueDetails)[]) {
|
||||
@@ -103,22 +105,36 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
||||
}
|
||||
if (patch.title !== undefined)
|
||||
assertThat(patch.title.length > 0, 'Issue title is required');
|
||||
if (patch.type !== undefined) workflow.assertActiveType(patch.type);
|
||||
if (patch.area !== undefined) workflow.assertActiveArea(patch.area);
|
||||
if (Object.keys(patch).length === 0) return;
|
||||
this.apply(new IssueUpdatedEvent(actorId, this.id, this.projectId, patch));
|
||||
}
|
||||
|
||||
changeStatus(actorId: string, to: IssueStatus, reason?: string | null): void {
|
||||
/** Moves the ticket along its project's workflow. */
|
||||
changeStatus(
|
||||
actorId: string,
|
||||
to: string,
|
||||
reason?: string | null,
|
||||
workflow: ProjectWorkflow = ProjectWorkflow.default(),
|
||||
): void {
|
||||
const from = this.status;
|
||||
if (from === to) return;
|
||||
if (!IssueWorkflow.canTransition(from, to)) {
|
||||
if (!workflow.canTransition(from, to)) {
|
||||
throw new BusinessRuleError(
|
||||
`Cannot move ${this.key} from ${from} to ${to}. Allowed: ${IssueWorkflow.allowedFrom(from).join(', ')}`,
|
||||
`Cannot move ${this.key} from ${workflow.statusName(from)} to ${workflow.statusName(to)}. ` +
|
||||
`Allowed: ${
|
||||
workflow
|
||||
.allowedFrom(from)
|
||||
.map((s) => workflow.statusName(s))
|
||||
.join(', ') || 'none'
|
||||
}`,
|
||||
);
|
||||
}
|
||||
const trimmedReason = reason?.trim() || null;
|
||||
if (IssueWorkflow.requiresReason(to) && !trimmedReason) {
|
||||
if (workflow.requiresReason(to) && !trimmedReason) {
|
||||
throw new BusinessRuleError(
|
||||
`A reason is required to move ${this.key} to ${to}`,
|
||||
`A reason is required to move ${this.key} to ${workflow.statusName(to)}`,
|
||||
);
|
||||
}
|
||||
this.apply(
|
||||
@@ -129,6 +145,12 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
||||
from,
|
||||
to,
|
||||
trimmedReason,
|
||||
{
|
||||
fromName: workflow.statusName(from),
|
||||
toName: workflow.statusName(to),
|
||||
fromCategory: workflow.categoryOf(from),
|
||||
toCategory: workflow.categoryOf(to),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user