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,8 +10,11 @@ El modelo del producto: proyectos, tickets, comentarios y releases.
|
||||
- Agregados event-sourced: `Project`, `Issue`, `Comment`, `Release`. Validan en sus métodos y cambian de estado en `on<Evento>`.
|
||||
- `events/`: un fichero por evento (`IssueCreatedEvent`, `IssueStatusChangedEvent`…), todos con `@AutowiredEvent`.
|
||||
- `factories/`: `ProjectFactory`, `IssueFactory`, `CommentFactory`, `ReleaseFactory` (`@Injectable()`).
|
||||
- `issue/issue-workflow.ts`: transiciones de estado permitidas y estados que exigen motivo.
|
||||
- `read-models/`: forma de las vistas de lectura (`ProjectReadModel`, `IssueReadModel`…).
|
||||
- `project/project-settings.ts`: configuración por proyecto (estados con categoría, transiciones, tipos, áreas), sus
|
||||
valores por defecto, `ProjectWorkflow` (lo que consultan tickets y queries) y `validateProjectSettings` (archivar,
|
||||
nunca borrar).
|
||||
- `read-models/`: forma de las vistas de lectura (`ProjectReadModel`, `IssueReadModel`…), incluida la línea temporal
|
||||
de los tickets y las estadísticas (`analytics.read-model.ts`).
|
||||
|
||||
Reglas y workflow: [API](../../../../docs/03-api.md#31-modelo-de-dominio-contexto-tracker).
|
||||
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/api/tracker/domain/src",
|
||||
"projectType": "library",
|
||||
"tags": [
|
||||
"scope:api-tracker",
|
||||
"type:domain"
|
||||
],
|
||||
"tags": ["scope:api-tracker", "type:domain"],
|
||||
"// targets": "to see all targets run: nx show project api-tracker-domain --web",
|
||||
"targets": {}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ export * from './lib/project/project';
|
||||
export * from './lib/project/project-role';
|
||||
export * from './lib/issue/issue';
|
||||
export * from './lib/issue/issue.enums';
|
||||
export * from './lib/issue/issue-workflow';
|
||||
export * from './lib/project/project-settings';
|
||||
export * from './lib/comment/comment';
|
||||
export * from './lib/release/release';
|
||||
export * from './lib/release/release-status';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from './tracker.event';
|
||||
export * from './project-created.event';
|
||||
export * from './project-updated.event';
|
||||
export * from './project-settings-changed.event';
|
||||
export * from './project-member-added.event';
|
||||
export * from './project-member-removed.event';
|
||||
export * from './project-member-role-changed.event';
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
||||
import { IssueStatus } from '../issue/issue.enums';
|
||||
import type { StatusCategory } from '../project/project-settings';
|
||||
import { TrackerEvent } from './tracker.event';
|
||||
|
||||
/** A ticket moved through the workflow (incl. QA). */
|
||||
/** Names and categories of both statuses when the move happened (absent in old events). */
|
||||
export interface StatusChangeMeta {
|
||||
fromName: string;
|
||||
toName: string;
|
||||
fromCategory: StatusCategory;
|
||||
toCategory: StatusCategory;
|
||||
}
|
||||
|
||||
/** A ticket moved through its project's workflow (incl. QA). */
|
||||
@AutowiredEvent
|
||||
export class IssueStatusChangedEvent extends TrackerEvent {
|
||||
constructor(
|
||||
actorId: string,
|
||||
issueId: string,
|
||||
projectId: string,
|
||||
readonly from: IssueStatus,
|
||||
readonly to: IssueStatus,
|
||||
/** Status ids of the project's workflow, e.g. IN_QA → QA_FAILED. */
|
||||
readonly from: string,
|
||||
readonly to: string,
|
||||
readonly reason: string | null,
|
||||
readonly meta?: StatusChangeMeta,
|
||||
) {
|
||||
super(actorId, issueId, projectId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
||||
import type { ProjectSettings } from '../project/project-settings';
|
||||
import { TrackerEvent } from './tracker.event';
|
||||
|
||||
/** Statuses, transitions, ticket types or areas of a project changed (full snapshot). */
|
||||
@AutowiredEvent
|
||||
export class ProjectSettingsChangedEvent extends TrackerEvent {
|
||||
constructor(
|
||||
actorId: string,
|
||||
projectId: string,
|
||||
readonly settings: ProjectSettings,
|
||||
) {
|
||||
super(actorId, projectId, projectId);
|
||||
}
|
||||
}
|
||||
@@ -7,23 +7,32 @@ import {
|
||||
IssueProps,
|
||||
normalizeIssueDetails,
|
||||
} from '../issue/issue';
|
||||
import { IssuePriority, IssueStatus, IssueType } from '../issue/issue.enums';
|
||||
import { IssuePriority } from '../issue/issue.enums';
|
||||
import { ProjectWorkflow } from '../project/project-settings';
|
||||
|
||||
export interface CreateIssueProps extends IssueDetails {
|
||||
projectId: string;
|
||||
projectKey: string;
|
||||
number: number;
|
||||
title: string;
|
||||
type: IssueType;
|
||||
type: string;
|
||||
reporterId: string;
|
||||
assigneeId?: string | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class IssueFactory {
|
||||
/** New tickets start in BACKLOG with a project-scoped key (e.g. TS-7). */
|
||||
create(props: CreateIssueProps): Issue {
|
||||
/**
|
||||
* New tickets start in the project's first active TODO status (BACKLOG by
|
||||
* default) with a project-scoped key (e.g. TS-7).
|
||||
*/
|
||||
create(
|
||||
props: CreateIssueProps,
|
||||
workflow: ProjectWorkflow = ProjectWorkflow.default(),
|
||||
): Issue {
|
||||
const details = normalizeIssueDetails(props);
|
||||
workflow.assertActiveType(props.type);
|
||||
workflow.assertActiveArea(props.area);
|
||||
const title = details.title ?? '';
|
||||
assertThat(title.length > 0, 'Issue title is required');
|
||||
|
||||
@@ -37,7 +46,7 @@ export class IssueFactory {
|
||||
area: props.area ?? null,
|
||||
title,
|
||||
description: details.description ?? null,
|
||||
status: IssueStatus.BACKLOG,
|
||||
status: workflow.initialStatus(),
|
||||
priority: props.priority ?? IssuePriority.MEDIUM,
|
||||
reporterId: props.reporterId,
|
||||
assigneeId: props.assigneeId ?? null,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { assertThat, newId } from '@task-stream/api/shared/domain';
|
||||
import { ProjectCreatedEvent } from '../events/project-created.event';
|
||||
import { Project, ProjectProps } from '../project/project';
|
||||
import { ProjectRole } from '../project/project-role';
|
||||
import { defaultProjectSettings } from '../project/project-settings';
|
||||
|
||||
const KEY_PATTERN = /^[A-Z][A-Z0-9]{1,9}$/;
|
||||
|
||||
@@ -30,6 +31,7 @@ export class ProjectFactory {
|
||||
description: props.description?.trim() || null,
|
||||
ownerId: props.ownerId,
|
||||
members: [{ userId: props.ownerId, role: ProjectRole.OWNER }],
|
||||
settings: defaultProjectSettings(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { IssueStatus as S } from './issue.enums';
|
||||
|
||||
/**
|
||||
* Allowed status transitions. QA is a first-class step: work goes
|
||||
* READY_FOR_QA → IN_QA → DONE | QA_FAILED → IN_PROGRESS.
|
||||
*/
|
||||
const TRANSITIONS: Record<S, readonly S[]> = {
|
||||
[S.BACKLOG]: [S.TODO, S.IN_PROGRESS, S.CANCELLED],
|
||||
[S.TODO]: [S.BACKLOG, S.IN_PROGRESS, S.CANCELLED],
|
||||
[S.IN_PROGRESS]: [S.TODO, S.IN_REVIEW, S.READY_FOR_QA, S.CANCELLED],
|
||||
[S.IN_REVIEW]: [S.IN_PROGRESS, S.READY_FOR_QA, S.DONE],
|
||||
[S.READY_FOR_QA]: [S.IN_QA, S.IN_PROGRESS],
|
||||
[S.IN_QA]: [S.DONE, S.QA_FAILED],
|
||||
[S.QA_FAILED]: [S.IN_PROGRESS, S.TODO],
|
||||
[S.DONE]: [S.TODO, S.IN_PROGRESS],
|
||||
[S.CANCELLED]: [S.BACKLOG],
|
||||
};
|
||||
|
||||
/** Statuses that require an explanation when entered. */
|
||||
const REASON_REQUIRED: readonly S[] = [S.QA_FAILED, S.CANCELLED];
|
||||
|
||||
export const FINISHED_STATUSES: readonly S[] = [S.DONE, S.CANCELLED];
|
||||
|
||||
export const IssueWorkflow = {
|
||||
canTransition(from: S, to: S): boolean {
|
||||
return TRANSITIONS[from].includes(to);
|
||||
},
|
||||
allowedFrom(from: S): readonly S[] {
|
||||
return TRANSITIONS[from];
|
||||
},
|
||||
requiresReason(to: S): boolean {
|
||||
return REASON_REQUIRED.includes(to);
|
||||
},
|
||||
isFinished(status: S): boolean {
|
||||
return FINISHED_STATUSES.includes(status);
|
||||
},
|
||||
};
|
||||
@@ -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),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
166
libs/api/tracker/domain/src/lib/project/project-settings.spec.ts
Normal file
166
libs/api/tracker/domain/src/lib/project/project-settings.spec.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import {
|
||||
BusinessRuleError,
|
||||
ForbiddenError,
|
||||
ValidationError,
|
||||
} from '@task-stream/api/shared/domain';
|
||||
import { ProjectSettingsChangedEvent } from '../events/project-settings-changed.event';
|
||||
import { IssueStatusChangedEvent } from '../events/issue-status-changed.event';
|
||||
import { IssueFactory } from '../factories/issue.factory';
|
||||
import { ProjectFactory } from '../factories/project.factory';
|
||||
import { replay } from '../testing';
|
||||
import { Project } from './project';
|
||||
import { ProjectRole } from './project-role';
|
||||
import {
|
||||
defaultProjectSettings,
|
||||
ProjectSettings,
|
||||
StatusCategory,
|
||||
} from './project-settings';
|
||||
|
||||
const newProject = () => {
|
||||
const project = new ProjectFactory().create({
|
||||
key: 'WEB',
|
||||
name: 'Web',
|
||||
ownerId: 'owner',
|
||||
});
|
||||
project.addMember('owner', 'dev', ProjectRole.MEMBER);
|
||||
return project;
|
||||
};
|
||||
|
||||
/** Defaults plus a STAGING step between review and QA, and a SPIKE type. */
|
||||
const withStaging = (): ProjectSettings => {
|
||||
const settings = defaultProjectSettings();
|
||||
settings.statuses.splice(4, 0, {
|
||||
id: 'STAGING',
|
||||
name: 'En staging',
|
||||
category: StatusCategory.QA,
|
||||
color: 'teal',
|
||||
requiresReason: false,
|
||||
archived: false,
|
||||
});
|
||||
settings.transitions['IN_REVIEW'] = ['STAGING'];
|
||||
settings.transitions['STAGING'] = ['IN_QA', 'IN_PROGRESS'];
|
||||
settings.issueTypes.push({
|
||||
id: 'SPIKE',
|
||||
name: 'Spike',
|
||||
icon: 'light-bulb',
|
||||
color: 'teal',
|
||||
archived: false,
|
||||
});
|
||||
return settings;
|
||||
};
|
||||
|
||||
describe('Project settings', () => {
|
||||
it('starts with the default workflow, also when rebuilt from old events', () => {
|
||||
const project = newProject();
|
||||
expect(project.workflow.initialStatus()).toBe('BACKLOG');
|
||||
expect(project.workflow.canTransition('IN_QA', 'QA_FAILED')).toBe(true);
|
||||
|
||||
// Projects created before settings existed have no `settings` in their event.
|
||||
const { history } = replay(project, Project);
|
||||
delete (history[0].data as unknown as { project: { settings?: unknown } })
|
||||
.project.settings;
|
||||
const old = new Project(project.id);
|
||||
old.loadFromHistory(history);
|
||||
expect(old.settings).toEqual(defaultProjectSettings());
|
||||
});
|
||||
|
||||
it('lets owners and admins customise it, as an event that replays', () => {
|
||||
const project = newProject();
|
||||
project.updateSettings('owner', withStaging());
|
||||
|
||||
const event = project.getUncommittedEvents().at(-1);
|
||||
expect(event).toBeInstanceOf(ProjectSettingsChangedEvent);
|
||||
expect(project.workflow.allowedFrom('IN_REVIEW')).toEqual(['STAGING']);
|
||||
|
||||
const { rebuilt } = replay(project, Project);
|
||||
expect(rebuilt.workflow.status('STAGING')?.category).toBe(
|
||||
StatusCategory.QA,
|
||||
);
|
||||
expect(() => project.updateSettings('dev', withStaging())).toThrow(
|
||||
ForbiddenError,
|
||||
);
|
||||
});
|
||||
|
||||
it('archives values instead of deleting them', () => {
|
||||
const project = newProject();
|
||||
const settings = defaultProjectSettings();
|
||||
settings.areas = settings.areas.filter((a) => a.id !== 'MOBILE');
|
||||
expect(() => project.updateSettings('owner', settings)).toThrow(
|
||||
/archívalo/,
|
||||
);
|
||||
|
||||
const archived = defaultProjectSettings();
|
||||
archived.areas = archived.areas.map((a) =>
|
||||
a.id === 'MOBILE' ? { ...a, archived: true } : a,
|
||||
);
|
||||
project.updateSettings('owner', archived);
|
||||
expect(() => project.workflow.assertActiveArea('MOBILE')).toThrow(
|
||||
BusinessRuleError,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects settings that would break the board', () => {
|
||||
const project = newProject();
|
||||
const noStart = defaultProjectSettings();
|
||||
noStart.statuses
|
||||
.filter((s) => s.category === StatusCategory.TODO)
|
||||
.forEach((s) => (s.archived = true));
|
||||
expect(() => project.updateSettings('owner', noStart)).toThrow(
|
||||
ValidationError,
|
||||
);
|
||||
|
||||
const badTransition = defaultProjectSettings();
|
||||
badTransition.transitions['TODO'] = ['NOPE'];
|
||||
expect(() => project.updateSettings('owner', badTransition)).toThrow(
|
||||
/no existe/,
|
||||
);
|
||||
|
||||
const duplicate = defaultProjectSettings();
|
||||
duplicate.issueTypes.push({ ...duplicate.issueTypes[0] });
|
||||
expect(() => project.updateSettings('owner', duplicate)).toThrow(/id BUG/);
|
||||
});
|
||||
|
||||
it('drives tickets: types, transitions and names in the history', () => {
|
||||
const project = newProject();
|
||||
project.updateSettings('owner', withStaging());
|
||||
const workflow = project.workflow;
|
||||
const issue = new IssueFactory().create(
|
||||
{
|
||||
projectId: project.id,
|
||||
projectKey: 'WEB',
|
||||
number: 1,
|
||||
title: 'Investigar pagos',
|
||||
type: 'SPIKE',
|
||||
reporterId: 'dev',
|
||||
},
|
||||
workflow,
|
||||
);
|
||||
for (const to of ['TODO', 'IN_PROGRESS', 'IN_REVIEW', 'STAGING'])
|
||||
issue.changeStatus('dev', to, null, workflow);
|
||||
|
||||
const last = issue.getUncommittedEvents().at(-1) as IssueStatusChangedEvent;
|
||||
expect(last.meta).toEqual({
|
||||
fromName: 'En revisión',
|
||||
toName: 'En staging',
|
||||
fromCategory: StatusCategory.IN_PROGRESS,
|
||||
toCategory: StatusCategory.QA,
|
||||
});
|
||||
// The default IN_REVIEW → READY_FOR_QA was replaced by STAGING.
|
||||
expect(() =>
|
||||
issue.changeStatus('dev', 'READY_FOR_QA', null, workflow),
|
||||
).toThrow(BusinessRuleError);
|
||||
expect(() =>
|
||||
new IssueFactory().create(
|
||||
{
|
||||
projectId: project.id,
|
||||
projectKey: 'WEB',
|
||||
number: 2,
|
||||
title: 'x',
|
||||
type: 'EPIC',
|
||||
reporterId: 'dev',
|
||||
},
|
||||
workflow,
|
||||
),
|
||||
).toThrow(/Unknown ticket type/);
|
||||
});
|
||||
});
|
||||
395
libs/api/tracker/domain/src/lib/project/project-settings.ts
Normal file
395
libs/api/tracker/domain/src/lib/project/project-settings.ts
Normal file
@@ -0,0 +1,395 @@
|
||||
import {
|
||||
BusinessRuleError,
|
||||
ValidationError,
|
||||
assertThat,
|
||||
} from '@task-stream/api/shared/domain';
|
||||
import { IssueArea, IssueStatus as S, IssueType } from '../issue/issue.enums';
|
||||
|
||||
/**
|
||||
* What a status means, whatever it is called in each project: it keeps
|
||||
* releases ("everything finished"), "waiting for QA" and statistics working
|
||||
* with custom statuses.
|
||||
*/
|
||||
export enum StatusCategory {
|
||||
TODO = 'TODO',
|
||||
IN_PROGRESS = 'IN_PROGRESS',
|
||||
QA = 'QA',
|
||||
DONE = 'DONE',
|
||||
CANCELLED = 'CANCELLED',
|
||||
}
|
||||
|
||||
/** Colour tokens the web app knows (light and dark). */
|
||||
export const SETTING_COLORS = [
|
||||
'slate',
|
||||
'stone',
|
||||
'violet',
|
||||
'blue',
|
||||
'sky',
|
||||
'teal',
|
||||
'green',
|
||||
'amber',
|
||||
'orange',
|
||||
'red',
|
||||
'pink',
|
||||
] as const;
|
||||
export type SettingColor = (typeof SETTING_COLORS)[number];
|
||||
|
||||
/** Icons a ticket type can use (registered in the web app). */
|
||||
export const TYPE_ICONS = [
|
||||
'bug',
|
||||
'feature',
|
||||
'palette',
|
||||
'task',
|
||||
'qa',
|
||||
'flag',
|
||||
'star-fill',
|
||||
'rocket',
|
||||
'document',
|
||||
'light-bulb',
|
||||
'key',
|
||||
'support',
|
||||
] as const;
|
||||
export type TypeIcon = (typeof TYPE_ICONS)[number];
|
||||
|
||||
export interface WorkflowStatus {
|
||||
/** Stable id stored on tickets, e.g. IN_REVIEW. */
|
||||
id: string;
|
||||
name: string;
|
||||
category: StatusCategory;
|
||||
color: SettingColor;
|
||||
/** Entering it needs a reason (kept in the history). */
|
||||
requiresReason: boolean;
|
||||
/** Archived: no new tickets or transitions into it; existing ones keep it. */
|
||||
archived: boolean;
|
||||
}
|
||||
|
||||
export interface IssueTypeOption {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: TypeIcon;
|
||||
color: SettingColor;
|
||||
archived: boolean;
|
||||
}
|
||||
|
||||
export interface AreaOption {
|
||||
id: string;
|
||||
name: string;
|
||||
archived: boolean;
|
||||
}
|
||||
|
||||
/** Per-project configuration of the tracker. */
|
||||
export interface ProjectSettings {
|
||||
/** In board order. New tickets start in the first active TODO status. */
|
||||
statuses: WorkflowStatus[];
|
||||
/** Allowed moves: status id → status ids it can go to. */
|
||||
transitions: Record<string, string[]>;
|
||||
issueTypes: IssueTypeOption[];
|
||||
areas: AreaOption[];
|
||||
}
|
||||
|
||||
const status = (
|
||||
id: string,
|
||||
name: string,
|
||||
category: StatusCategory,
|
||||
color: SettingColor,
|
||||
requiresReason = false,
|
||||
): WorkflowStatus => ({
|
||||
id,
|
||||
name,
|
||||
category,
|
||||
color,
|
||||
requiresReason,
|
||||
archived: false,
|
||||
});
|
||||
|
||||
/** What every project starts with (and what projects created before settings had). */
|
||||
export const DEFAULT_PROJECT_SETTINGS: ProjectSettings = {
|
||||
statuses: [
|
||||
status(S.BACKLOG, 'Backlog', StatusCategory.TODO, 'stone'),
|
||||
status(S.TODO, 'Por hacer', StatusCategory.TODO, 'slate'),
|
||||
status(S.IN_PROGRESS, 'En curso', StatusCategory.IN_PROGRESS, 'violet'),
|
||||
status(S.IN_REVIEW, 'En revisión', StatusCategory.IN_PROGRESS, 'blue'),
|
||||
status(S.READY_FOR_QA, 'Listo para QA', StatusCategory.QA, 'amber'),
|
||||
status(S.IN_QA, 'En QA', StatusCategory.QA, 'orange'),
|
||||
status(S.QA_FAILED, 'QA fallido', StatusCategory.IN_PROGRESS, 'red', true),
|
||||
status(S.DONE, 'Hecho', StatusCategory.DONE, 'green'),
|
||||
status(S.CANCELLED, 'Cancelado', StatusCategory.CANCELLED, 'stone', true),
|
||||
],
|
||||
// QA is a first-class step: READY_FOR_QA → IN_QA → DONE | QA_FAILED → IN_PROGRESS.
|
||||
transitions: {
|
||||
[S.BACKLOG]: [S.TODO, S.IN_PROGRESS, S.CANCELLED],
|
||||
[S.TODO]: [S.BACKLOG, S.IN_PROGRESS, S.CANCELLED],
|
||||
[S.IN_PROGRESS]: [S.TODO, S.IN_REVIEW, S.READY_FOR_QA, S.CANCELLED],
|
||||
[S.IN_REVIEW]: [S.IN_PROGRESS, S.READY_FOR_QA, S.DONE],
|
||||
[S.READY_FOR_QA]: [S.IN_QA, S.IN_PROGRESS],
|
||||
[S.IN_QA]: [S.DONE, S.QA_FAILED],
|
||||
[S.QA_FAILED]: [S.IN_PROGRESS, S.TODO],
|
||||
[S.DONE]: [S.TODO, S.IN_PROGRESS],
|
||||
[S.CANCELLED]: [S.BACKLOG],
|
||||
},
|
||||
issueTypes: [
|
||||
{
|
||||
id: IssueType.BUG,
|
||||
name: 'Bug',
|
||||
icon: 'bug',
|
||||
color: 'red',
|
||||
archived: false,
|
||||
},
|
||||
{
|
||||
id: IssueType.FEATURE,
|
||||
name: 'Feature',
|
||||
icon: 'feature',
|
||||
color: 'violet',
|
||||
archived: false,
|
||||
},
|
||||
{
|
||||
id: IssueType.UI_CHANGE,
|
||||
name: 'Cambio de UI',
|
||||
icon: 'palette',
|
||||
color: 'pink',
|
||||
archived: false,
|
||||
},
|
||||
{
|
||||
id: IssueType.TASK,
|
||||
name: 'Tarea',
|
||||
icon: 'task',
|
||||
color: 'slate',
|
||||
archived: false,
|
||||
},
|
||||
{
|
||||
id: IssueType.QA,
|
||||
name: 'QA',
|
||||
icon: 'qa',
|
||||
color: 'amber',
|
||||
archived: false,
|
||||
},
|
||||
],
|
||||
areas: [
|
||||
{ id: IssueArea.FRONTEND, name: 'Frontend', archived: false },
|
||||
{ id: IssueArea.BACKEND, name: 'Backend', archived: false },
|
||||
{ id: IssueArea.MOBILE, name: 'Mobile', archived: false },
|
||||
{ id: IssueArea.QA, name: 'QA', archived: false },
|
||||
{ id: IssueArea.DESIGN, name: 'Diseño', archived: false },
|
||||
{ id: IssueArea.DEVOPS, name: 'DevOps', archived: false },
|
||||
],
|
||||
};
|
||||
|
||||
export function defaultProjectSettings(): ProjectSettings {
|
||||
return structuredClone(DEFAULT_PROJECT_SETTINGS);
|
||||
}
|
||||
|
||||
const ID = /^[A-Z][A-Z0-9_]{0,29}$/;
|
||||
const FINISHED = [StatusCategory.DONE, StatusCategory.CANCELLED];
|
||||
|
||||
/** Read-only view of a project's workflow, used by tickets and queries. */
|
||||
export class ProjectWorkflow {
|
||||
constructor(readonly settings: ProjectSettings) {}
|
||||
|
||||
static default(): ProjectWorkflow {
|
||||
return new ProjectWorkflow(DEFAULT_PROJECT_SETTINGS);
|
||||
}
|
||||
|
||||
status(id: string): WorkflowStatus | undefined {
|
||||
return this.settings.statuses.find((s) => s.id === id);
|
||||
}
|
||||
|
||||
/** Name to show, also for ids no longer configured. */
|
||||
statusName(id: string): string {
|
||||
return (
|
||||
this.status(id)?.name ??
|
||||
DEFAULT_PROJECT_SETTINGS.statuses.find((s) => s.id === id)?.name ??
|
||||
id
|
||||
);
|
||||
}
|
||||
|
||||
typeName(id: string): string {
|
||||
return (
|
||||
this.settings.issueTypes.find((t) => t.id === id)?.name ??
|
||||
DEFAULT_PROJECT_SETTINGS.issueTypes.find((t) => t.id === id)?.name ??
|
||||
id
|
||||
);
|
||||
}
|
||||
|
||||
areaName(id: string): string {
|
||||
return (
|
||||
this.settings.areas.find((a) => a.id === id)?.name ??
|
||||
DEFAULT_PROJECT_SETTINGS.areas.find((a) => a.id === id)?.name ??
|
||||
id
|
||||
);
|
||||
}
|
||||
|
||||
categoryOf(id: string): StatusCategory {
|
||||
return (
|
||||
this.status(id)?.category ??
|
||||
DEFAULT_PROJECT_SETTINGS.statuses.find((s) => s.id === id)?.category ??
|
||||
StatusCategory.TODO
|
||||
);
|
||||
}
|
||||
|
||||
isFinished(id: string): boolean {
|
||||
return FINISHED.includes(this.categoryOf(id));
|
||||
}
|
||||
|
||||
/** Where new tickets start: the first active status of category TODO. */
|
||||
initialStatus(): string {
|
||||
const initial = this.settings.statuses.find(
|
||||
(s) => !s.archived && s.category === StatusCategory.TODO,
|
||||
);
|
||||
if (!initial)
|
||||
throw new BusinessRuleError('The project has no active starting status');
|
||||
return initial.id;
|
||||
}
|
||||
|
||||
/** Active statuses a ticket can move to from `from`. */
|
||||
allowedFrom(from: string): string[] {
|
||||
return (this.settings.transitions[from] ?? []).filter(
|
||||
(to) => this.status(to) && !this.status(to)?.archived,
|
||||
);
|
||||
}
|
||||
|
||||
canTransition(from: string, to: string): boolean {
|
||||
return this.allowedFrom(from).includes(to);
|
||||
}
|
||||
|
||||
requiresReason(id: string): boolean {
|
||||
return this.status(id)?.requiresReason ?? false;
|
||||
}
|
||||
|
||||
assertActiveType(id: string): void {
|
||||
const type = this.settings.issueTypes.find((t) => t.id === id);
|
||||
if (!type) throw new ValidationError(`Unknown ticket type ${id}`);
|
||||
if (type.archived)
|
||||
throw new BusinessRuleError(`Ticket type ${type.name} is archived`);
|
||||
}
|
||||
|
||||
assertActiveArea(id: string | null | undefined): void {
|
||||
if (id === null || id === undefined) return;
|
||||
const area = this.settings.areas.find((a) => a.id === id);
|
||||
if (!area) throw new ValidationError(`Unknown area ${id}`);
|
||||
if (area.archived)
|
||||
throw new BusinessRuleError(`Area ${area.name} is archived`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks and normalises new settings against the previous ones. Values can be
|
||||
* added, renamed, reordered or archived, never deleted: tickets, history and
|
||||
* statistics keep referring to them.
|
||||
*/
|
||||
export function validateProjectSettings(
|
||||
next: ProjectSettings,
|
||||
previous: ProjectSettings,
|
||||
): ProjectSettings {
|
||||
const statuses = next.statuses.map((s) => ({
|
||||
id: normId(s.id),
|
||||
name: normName(s.name),
|
||||
category: s.category,
|
||||
color: s.color,
|
||||
requiresReason: !!s.requiresReason,
|
||||
archived: !!s.archived,
|
||||
}));
|
||||
const issueTypes = next.issueTypes.map((t) => ({
|
||||
id: normId(t.id),
|
||||
name: normName(t.name),
|
||||
icon: t.icon,
|
||||
color: t.color,
|
||||
archived: !!t.archived,
|
||||
}));
|
||||
const areas = next.areas.map((a) => ({
|
||||
id: normId(a.id),
|
||||
name: normName(a.name),
|
||||
archived: !!a.archived,
|
||||
}));
|
||||
|
||||
for (const [label, list, before] of [
|
||||
['estado', statuses, previous.statuses],
|
||||
['tipo', issueTypes, previous.issueTypes],
|
||||
['área', areas, previous.areas],
|
||||
] as const) {
|
||||
const ids = list.map((item) => item.id);
|
||||
const duplicate = ids.find((id, i) => ids.indexOf(id) !== i);
|
||||
assertThat(!duplicate, `Hay dos ${label}s con el id ${duplicate}`);
|
||||
const removed = before.find((item) => !ids.includes(item.id));
|
||||
if (removed) {
|
||||
throw new BusinessRuleError(
|
||||
`No se puede eliminar el ${label} «${removed.name}»: archívalo para que no se use más`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const s of statuses) {
|
||||
assertThat(
|
||||
Object.values(StatusCategory).includes(s.category),
|
||||
`Categoría no válida en el estado ${s.name}`,
|
||||
);
|
||||
assertThat(
|
||||
SETTING_COLORS.includes(s.color),
|
||||
`Color no válido en el estado ${s.name}`,
|
||||
);
|
||||
}
|
||||
for (const t of issueTypes) {
|
||||
assertThat(
|
||||
TYPE_ICONS.includes(t.icon),
|
||||
`Icono no válido en el tipo ${t.name}`,
|
||||
);
|
||||
assertThat(
|
||||
SETTING_COLORS.includes(t.color),
|
||||
`Color no válido en el tipo ${t.name}`,
|
||||
);
|
||||
}
|
||||
const active = statuses.filter((s) => !s.archived);
|
||||
assertThat(
|
||||
active.some((s) => s.category === StatusCategory.TODO),
|
||||
'Hace falta al menos un estado activo «pendiente» donde empiecen los tickets',
|
||||
);
|
||||
assertThat(
|
||||
active.some((s) => s.category === StatusCategory.DONE),
|
||||
'Hace falta al menos un estado activo «hecho»',
|
||||
);
|
||||
assertThat(
|
||||
issueTypes.some((t) => !t.archived),
|
||||
'Hace falta al menos un tipo de ticket activo',
|
||||
);
|
||||
|
||||
const known = new Set(statuses.map((s) => s.id));
|
||||
const transitions: Record<string, string[]> = {};
|
||||
for (const [from, targets] of Object.entries(next.transitions ?? {})) {
|
||||
const source = normId(from);
|
||||
assertThat(
|
||||
known.has(source),
|
||||
`Transición desde un estado que no existe: ${from}`,
|
||||
);
|
||||
const to = [...new Set(targets.map(normId))];
|
||||
for (const target of to) {
|
||||
assertThat(
|
||||
known.has(target),
|
||||
`Transición hacia un estado que no existe: ${target}`,
|
||||
);
|
||||
assertThat(
|
||||
target !== source,
|
||||
`Un estado no puede pasar a sí mismo (${source})`,
|
||||
);
|
||||
}
|
||||
if (to.length) transitions[source] = to;
|
||||
}
|
||||
|
||||
return { statuses, transitions, issueTypes, areas };
|
||||
}
|
||||
|
||||
function normId(id: string): string {
|
||||
const value = (id ?? '').trim().toUpperCase();
|
||||
assertThat(
|
||||
ID.test(value),
|
||||
`Id no válido «${id}»: mayúsculas, números y _, empezando por letra`,
|
||||
);
|
||||
return value;
|
||||
}
|
||||
|
||||
function normName(name: string): string {
|
||||
const value = (name ?? '').trim();
|
||||
assertThat(
|
||||
value.length > 0 && value.length <= 40,
|
||||
'Los nombres llevan 1-40 caracteres',
|
||||
);
|
||||
return value;
|
||||
}
|
||||
@@ -8,8 +8,15 @@ import { ProjectCreatedEvent } from '../events/project-created.event';
|
||||
import { ProjectMemberAddedEvent } from '../events/project-member-added.event';
|
||||
import { ProjectMemberRemovedEvent } from '../events/project-member-removed.event';
|
||||
import { ProjectMemberRoleChangedEvent } from '../events/project-member-role-changed.event';
|
||||
import { ProjectSettingsChangedEvent } from '../events/project-settings-changed.event';
|
||||
import { ProjectUpdatedEvent } from '../events/project-updated.event';
|
||||
import { ProjectRole } from './project-role';
|
||||
import {
|
||||
defaultProjectSettings,
|
||||
ProjectSettings,
|
||||
ProjectWorkflow,
|
||||
validateProjectSettings,
|
||||
} from './project-settings';
|
||||
|
||||
export interface ProjectMember {
|
||||
userId: string;
|
||||
@@ -24,6 +31,8 @@ export interface ProjectProps {
|
||||
description: string | null;
|
||||
ownerId: string;
|
||||
members: ProjectMember[];
|
||||
/** Absent in projects created before settings existed: defaults apply. */
|
||||
settings?: ProjectSettings;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -38,6 +47,7 @@ export class Project extends VersionedAggregateRoot implements ProjectProps {
|
||||
public description: string | null = null;
|
||||
public ownerId!: string;
|
||||
public members: ProjectMember[] = [];
|
||||
public settings: ProjectSettings = defaultProjectSettings();
|
||||
public createdAt!: string;
|
||||
public updatedAt!: string;
|
||||
|
||||
@@ -46,6 +56,11 @@ export class Project extends VersionedAggregateRoot implements ProjectProps {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
/** Statuses, transitions, types and areas of this project. */
|
||||
get workflow(): ProjectWorkflow {
|
||||
return new ProjectWorkflow(this.settings);
|
||||
}
|
||||
|
||||
roleOf(userId: string): ProjectRole | null {
|
||||
return this.members.find((m) => m.userId === userId)?.role ?? null;
|
||||
}
|
||||
@@ -98,6 +113,14 @@ export class Project extends VersionedAggregateRoot implements ProjectProps {
|
||||
this.apply(new ProjectUpdatedEvent(actorId, this.id, patch));
|
||||
}
|
||||
|
||||
/** Owners and admins customise statuses, transitions, types and areas. */
|
||||
updateSettings(actorId: string, next: ProjectSettings): void {
|
||||
this.assertCanManage(actorId);
|
||||
const settings = validateProjectSettings(next, this.settings);
|
||||
if (JSON.stringify(settings) === JSON.stringify(this.settings)) return;
|
||||
this.apply(new ProjectSettingsChangedEvent(actorId, this.id, settings));
|
||||
}
|
||||
|
||||
addMember(actorId: string, userId: string, role: ProjectRole): void {
|
||||
this.assertCanManage(actorId);
|
||||
assertThat(role !== ProjectRole.OWNER, 'A project can only have one owner');
|
||||
@@ -154,6 +177,9 @@ export class Project extends VersionedAggregateRoot implements ProjectProps {
|
||||
this.description = project.description;
|
||||
this.ownerId = project.ownerId;
|
||||
this.members = project.members.map((m) => ({ ...m }));
|
||||
this.settings = structuredClone(
|
||||
project.settings ?? defaultProjectSettings(),
|
||||
);
|
||||
this.createdAt = project.createdAt;
|
||||
this.updatedAt = project.updatedAt;
|
||||
}
|
||||
@@ -163,6 +189,11 @@ export class Project extends VersionedAggregateRoot implements ProjectProps {
|
||||
this.updatedAt = event.occurredAt;
|
||||
}
|
||||
|
||||
onProjectSettingsChangedEvent(event: ProjectSettingsChangedEvent): void {
|
||||
this.settings = structuredClone(event.settings);
|
||||
this.updatedAt = event.occurredAt;
|
||||
}
|
||||
|
||||
onProjectMemberAddedEvent(event: ProjectMemberAddedEvent): void {
|
||||
this.members = [
|
||||
...this.members,
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { StatusCategory } from '../project/project-settings';
|
||||
import { UserRefReadModel } from './user-ref.read-model';
|
||||
|
||||
/**
|
||||
* One step in a ticket's life (projected from its events): the raw material
|
||||
* of the statistics. `status` entries record where the ticket went (with the
|
||||
* category it had then), `release` entries which release it joined or left.
|
||||
*/
|
||||
export class IssueTimelineEntry {
|
||||
/** Id of the event that produced it (projection is idempotent). */
|
||||
id!: string;
|
||||
issueId!: string;
|
||||
projectId!: string;
|
||||
at!: string;
|
||||
kind!: 'status' | 'release';
|
||||
status?: string;
|
||||
category?: StatusCategory;
|
||||
/** Assignee when the status changed (throughput per person). */
|
||||
assigneeId?: string | null;
|
||||
releaseId?: string | null;
|
||||
}
|
||||
|
||||
export class DurationStats {
|
||||
count!: number;
|
||||
averageDays!: number | null;
|
||||
/** 85th percentile: "85% of tickets take at most this long". */
|
||||
p85Days!: number | null;
|
||||
}
|
||||
|
||||
/** Statistics of a project over a period. */
|
||||
export class ProjectAnalyticsReadModel {
|
||||
from!: string;
|
||||
to!: string;
|
||||
/** Per week (Monday): tickets created and tickets finished. */
|
||||
createdVsResolved!: { week: string; created: number; resolved: number }[];
|
||||
/** Tickets in each status at the end of every day (cumulative flow). */
|
||||
cumulativeFlow!: {
|
||||
dates: string[];
|
||||
series: { status: string; counts: number[] }[];
|
||||
};
|
||||
/** From starting work to done. */
|
||||
cycleTime!: DurationStats;
|
||||
/** From creation to done. */
|
||||
leadTime!: DurationStats;
|
||||
/** Tickets finished per person and week. */
|
||||
throughput!: { week: string; user: UserRefReadModel | null; count: number }[];
|
||||
/** Tickets leaving QA: to done (passed) or back to work (failed). */
|
||||
qa!: { passed: number; failed: number; failureRate: number | null };
|
||||
}
|
||||
|
||||
/** Scope vs finished tickets of a release, day by day. */
|
||||
export class ReleaseBurnupReadModel {
|
||||
releaseId!: string;
|
||||
targetDate!: string | null;
|
||||
dates!: string[];
|
||||
scope!: number[];
|
||||
done!: number[];
|
||||
}
|
||||
@@ -5,3 +5,4 @@ export * from './comment.read-model';
|
||||
export * from './release.read-model';
|
||||
export * from './activity.read-model';
|
||||
export * from './project-stats.read-model';
|
||||
export * from './analytics.read-model';
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import {
|
||||
IssueArea,
|
||||
IssueLinkType,
|
||||
IssuePriority,
|
||||
IssueStatus,
|
||||
IssueType,
|
||||
} from '../issue/issue.enums';
|
||||
import { IssueLinkType, IssuePriority } from '../issue/issue.enums';
|
||||
import { StatusCategory } from '../project/project-settings';
|
||||
import { UserRefReadModel } from './user-ref.read-model';
|
||||
|
||||
export type LinkDirection = 'OUTWARD' | 'INWARD';
|
||||
@@ -23,11 +18,14 @@ export class IssueReadModel {
|
||||
projectKey!: string;
|
||||
number!: number;
|
||||
key!: string;
|
||||
type!: IssueType;
|
||||
area!: IssueArea | null;
|
||||
/** Ids from the project's settings. */
|
||||
type!: string;
|
||||
area!: string | null;
|
||||
title!: string;
|
||||
description!: string | null;
|
||||
status!: IssueStatus;
|
||||
status!: string;
|
||||
/** Category of `status` (kept in sync when the project's settings change). */
|
||||
statusCategory!: StatusCategory;
|
||||
priority!: IssuePriority;
|
||||
reporter!: UserRefReadModel;
|
||||
assignee!: UserRefReadModel | null;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IssuePriority, IssueStatus, IssueType } from '../issue/issue.enums';
|
||||
import { IssuePriority } from '../issue/issue.enums';
|
||||
import { UserRefReadModel } from './user-ref.read-model';
|
||||
|
||||
export class CountBy<TKey = string> {
|
||||
@@ -10,8 +10,9 @@ export class CountBy<TKey = string> {
|
||||
export class ProjectStatsReadModel {
|
||||
total!: number;
|
||||
open!: number;
|
||||
byStatus!: CountBy<IssueStatus>[];
|
||||
byType!: CountBy<IssueType>[];
|
||||
/** Keys are status / type ids of the project's settings. */
|
||||
byStatus!: CountBy<string>[];
|
||||
byType!: CountBy<string>[];
|
||||
byPriority!: CountBy<IssuePriority>[];
|
||||
byArea!: CountBy<string>[];
|
||||
/** Open tickets per person (user null = unassigned). */
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ProjectRole } from '../project/project-role';
|
||||
import { ProjectSettings } from '../project/project-settings';
|
||||
import { UserRefReadModel } from './user-ref.read-model';
|
||||
|
||||
export class ProjectMemberReadModel {
|
||||
@@ -13,6 +14,8 @@ export class ProjectReadModel {
|
||||
description!: string | null;
|
||||
owner!: UserRefReadModel;
|
||||
members!: ProjectMemberReadModel[];
|
||||
/** Statuses, transitions, ticket types and areas. */
|
||||
settings!: ProjectSettings;
|
||||
createdAt!: string;
|
||||
updatedAt!: string;
|
||||
/** Position of the last projected event. */
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { IssueStatus } from '../issue/issue.enums';
|
||||
import { ReleaseStatus } from '../release/release-status';
|
||||
|
||||
export class ReleaseReadModel {
|
||||
@@ -17,5 +16,5 @@ export class ReleaseProgressReadModel {
|
||||
total!: number;
|
||||
finished!: number;
|
||||
percent!: number;
|
||||
byStatus!: { key: IssueStatus; count: number }[];
|
||||
byStatus!: { key: string; count: number }[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user