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

@@ -7,10 +7,14 @@ Casos de uso del tracker (CQRS).
## Contenido
- Services: `ProjectsService`, `IssuesService`, `CommentsService`, `ReleasesService`, `ChangesService` (subscriptions).
- Services: `ProjectsService`, `IssuesService`, `CommentsService`, `ReleasesService`, `ChangesService` (subscriptions),
`WorkflowsService` (workflow de cada proyecto para presentar tickets, con una caché de 2 s).
- `commands/`: un fichero por command y otro por handler; cargan con `WriteAccess` (rehydrator) y guardan con `commitAndWait()`.
- `queries/`: un fichero por query y otro por handler; leen de los puertos `Find*Repository` y comprueban la membresía (`ReadAccess`).
- `event-handlers/`: proyecciones (`@EventsHandler`) hacia el read model, notificaciones en tiempo real y feed de actividad.
- `queries/`: un fichero por query y otro por handler; leen de los puertos `Find*Repository` y comprueban la membresía (`ReadAccess`);
`SearchMyIssuesQuery` y `GetUpcomingReleasesQuery` cruzan todos los proyectos del usuario («Mi trabajo»).
- `event-handlers/`: proyecciones (`@EventsHandler`) hacia el read model (incluida la línea temporal de cada ticket),
notificaciones en tiempo real y feed de actividad.
- `analytics/`: estadísticas y burnup calculados con funciones puras sobre la línea temporal (con tests).
- `ports/`: lookups de escritura, `Find*`, `UpsertMaterialized*`, `ReleaseIssuesRepository`, `ChangeFeed`, `UserDirectory`.
- `TrackerApplicationModule.withInfrastructure(...)`.

View File

@@ -3,10 +3,7 @@
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/api/tracker/application/src",
"projectType": "library",
"tags": [
"scope:api-tracker",
"type:application"
],
"tags": ["scope:api-tracker", "type:application"],
"// targets": "to see all targets run: nx show project api-tracker-application --web",
"targets": {}
}

View File

@@ -6,4 +6,5 @@ export * from './lib/issues.service';
export * from './lib/comments.service';
export * from './lib/releases.service';
export * from './lib/changes.service';
export * from './lib/workflows.service';
export * from './lib/tracker-application.module';

View File

@@ -0,0 +1,184 @@
import {
IssueReadModel,
IssueTimelineEntry,
ReleaseReadModel,
ReleaseStatus,
StatusCategory as C,
} from '@task-stream/api/tracker/domain';
import {
computeProjectAnalytics,
computeReleaseBurnup,
weekOf,
} from './analytics';
const ana = { id: 'ana', name: 'Ana' };
const bob = { id: 'bob', name: 'Bob' };
const issue = (
id: string,
createdAt: string,
status: string,
category: C,
releaseId: string | null = null,
): IssueReadModel =>
({
id,
key: id,
createdAt,
status,
statusCategory: category,
reporter: ana,
assignee: bob,
release: releaseId ? { id: releaseId, name: 'v1' } : null,
}) as IssueReadModel;
let seq = 0;
const step = (
issueId: string,
at: string,
status: string,
category: C,
assigneeId: string | null = 'bob',
): IssueTimelineEntry => ({
id: `${issueId}:${++seq}`,
issueId,
projectId: 'p',
at,
kind: 'status',
status,
category,
assigneeId,
});
/*
* A: created Mon 5 Oct, started Tue 6, to QA Wed 7, QA failed Thu 8, back to QA and done Fri 9.
* B: created Mon 5 Oct, started Mon 12, QA Tue 13, done Wed 14 (by Ana).
* C: created Tue 13 Oct, still in backlog.
*/
const issues = [
issue('A', '2026-10-05T09:00:00Z', 'DONE', C.DONE, 'rel'),
issue('B', '2026-10-05T10:00:00Z', 'DONE', C.DONE, 'rel'),
issue('C', '2026-10-13T10:00:00Z', 'BACKLOG', C.TODO),
];
const timeline: IssueTimelineEntry[] = [
step('A', '2026-10-05T09:00:00Z', 'BACKLOG', C.TODO),
{
id: 'A:r',
issueId: 'A',
projectId: 'p',
at: '2026-10-05T09:00:01Z',
kind: 'release',
releaseId: 'rel',
},
step('A', '2026-10-06T09:00:00Z', 'IN_PROGRESS', C.IN_PROGRESS),
step('A', '2026-10-07T09:00:00Z', 'IN_QA', C.QA),
step('A', '2026-10-08T09:00:00Z', 'QA_FAILED', C.IN_PROGRESS),
step('A', '2026-10-09T08:00:00Z', 'IN_QA', C.QA),
step('A', '2026-10-09T09:00:00Z', 'DONE', C.DONE),
step('B', '2026-10-05T10:00:00Z', 'BACKLOG', C.TODO),
{
id: 'B:r',
issueId: 'B',
projectId: 'p',
at: '2026-10-12T08:00:00Z',
kind: 'release',
releaseId: 'rel',
},
step('B', '2026-10-12T10:00:00Z', 'IN_PROGRESS', C.IN_PROGRESS),
step('B', '2026-10-13T10:00:00Z', 'IN_QA', C.QA),
step('B', '2026-10-14T10:00:00Z', 'DONE', C.DONE, 'ana'),
];
describe('project analytics', () => {
const stats = computeProjectAnalytics({
issues,
timeline,
statusOrder: ['BACKLOG', 'IN_PROGRESS', 'IN_QA', 'QA_FAILED', 'DONE'],
from: new Date('2026-10-05T00:00:00Z'),
to: new Date('2026-10-14T23:59:59Z'),
});
it('counts created and resolved tickets per week (Monday)', () => {
expect(weekOf(Date.parse('2026-10-11T12:00:00Z'))).toBe('2026-10-05');
expect(stats.createdVsResolved).toEqual([
{ week: '2026-10-05', created: 2, resolved: 1 },
{ week: '2026-10-12', created: 1, resolved: 1 },
]);
});
it('measures cycle time (start → done) and lead time (created → done)', () => {
// A: 3 days of cycle, 4 of lead. B: 2 and 9.
expect(stats.cycleTime).toEqual({ count: 2, averageDays: 2.5, p85Days: 3 });
expect(stats.leadTime).toEqual({ count: 2, averageDays: 6.5, p85Days: 9 });
});
it('counts QA passes and failures', () => {
expect(stats.qa).toEqual({ passed: 2, failed: 1, failureRate: 0.333 });
});
it('attributes throughput to who had the ticket', () => {
expect(stats.throughput).toEqual([
{ week: '2026-10-05', user: bob, count: 1 },
{ week: '2026-10-12', user: ana, count: 1 },
]);
});
it('builds the cumulative flow day by day, in the workflow order', () => {
const flow = stats.cumulativeFlow;
expect(flow.dates).toHaveLength(10);
expect(flow.series.map((s) => s.status)).toEqual([
'BACKLOG',
'IN_PROGRESS',
'IN_QA',
'QA_FAILED',
'DONE',
]);
const at = (status: string, day: string) =>
flow.series.find((s) => s.status === status)?.counts[
flow.dates.indexOf(day)
];
expect(at('BACKLOG', '2026-10-05')).toBe(2);
expect(at('QA_FAILED', '2026-10-08')).toBe(1);
expect(at('DONE', '2026-10-14')).toBe(2);
expect(at('BACKLOG', '2026-10-14')).toBe(1); // C
});
it('falls back to creation + current status for tickets without timeline', () => {
const old = computeProjectAnalytics({
issues: [issue('OLD', '2026-10-01T00:00:00Z', 'TODO', C.TODO)],
timeline: [],
statusOrder: [],
from: new Date('2026-10-01T00:00:00Z'),
to: new Date('2026-10-02T23:00:00Z'),
});
expect(old.cumulativeFlow.series).toEqual([
{ status: 'TODO', counts: [1, 1] },
]);
});
});
describe('release burnup', () => {
it('tracks scope and done tickets of the release per day', () => {
const release = {
id: 'rel',
createdAt: '2026-10-05T08:00:00Z',
releasedAt: '2026-10-14T18:00:00Z',
targetDate: '2026-10-15',
status: ReleaseStatus.RELEASED,
} as ReleaseReadModel;
const burnup = computeReleaseBurnup({
release,
issues,
timeline,
now: new Date('2026-10-20T00:00:00Z'),
});
const at = (series: number[], day: string) =>
series[burnup.dates.indexOf(day)];
expect(burnup.dates[0]).toBe('2026-10-05');
expect(burnup.dates.at(-1)).toBe('2026-10-14');
expect(at(burnup.scope, '2026-10-05')).toBe(1); // A
expect(at(burnup.scope, '2026-10-12')).toBe(2); // + B
expect(at(burnup.done, '2026-10-09')).toBe(1);
expect(at(burnup.done, '2026-10-14')).toBe(2);
});
});

View File

@@ -0,0 +1,315 @@
import {
DurationStats,
IssueReadModel,
IssueTimelineEntry,
ProjectAnalyticsReadModel,
ReleaseBurnupReadModel,
ReleaseReadModel,
StatusCategory,
UserRefReadModel,
} from '@task-stream/api/tracker/domain';
/*
* Statistics are computed from each ticket's timeline (status and release
* steps). Pure functions: no Nest, no database, easy to test.
*/
const DAY = 86_400_000;
const STARTED = [StatusCategory.IN_PROGRESS, StatusCategory.QA];
interface Step {
at: number;
status: string;
category: StatusCategory;
assigneeId: string | null;
}
/** "2026-10-06" for a timestamp (UTC). */
export const dayOf = (time: number) =>
new Date(time).toISOString().slice(0, 10);
/** Monday of the ISO week of a timestamp (UTC), as "YYYY-MM-DD". */
export function weekOf(time: number): string {
const date = new Date(time);
const shift = (date.getUTCDay() + 6) % 7;
return dayOf(
Date.UTC(
date.getUTCFullYear(),
date.getUTCMonth(),
date.getUTCDate() - shift,
),
);
}
/** End of each UTC day between two instants (inclusive). */
function days(from: number, to: number): number[] {
const out: number[] = [];
const first = Date.UTC(
new Date(from).getUTCFullYear(),
new Date(from).getUTCMonth(),
new Date(from).getUTCDate(),
);
for (let day = first; day <= to; day += DAY) out.push(day + DAY - 1);
return out;
}
function weeks(from: number, to: number): string[] {
const out: string[] = [];
for (let day = Date.parse(weekOf(from)); day <= to; day += 7 * DAY)
out.push(dayOf(day));
return out;
}
/** Status steps per ticket; tickets with no timeline get one from their creation. */
function stepsByIssue(
issues: IssueReadModel[],
timeline: IssueTimelineEntry[],
): Map<string, Step[]> {
const steps = new Map<string, Step[]>();
for (const entry of timeline) {
if (entry.kind !== 'status' || !entry.status || !entry.category) continue;
const list = steps.get(entry.issueId) ?? [];
list.push({
at: Date.parse(entry.at),
status: entry.status,
category: entry.category,
assigneeId: entry.assigneeId ?? null,
});
steps.set(entry.issueId, list);
}
for (const issue of issues) {
if (!steps.has(issue.id)) {
steps.set(issue.id, [
{
at: Date.parse(issue.createdAt),
status: issue.status,
category: issue.statusCategory,
assigneeId: issue.assignee?.id ?? null,
},
]);
}
steps.get(issue.id)?.sort((a, b) => a.at - b.at);
}
return steps;
}
/** Last step at or before `time`. */
function stepAt(steps: Step[], time: number): Step | null {
let current: Step | null = null;
for (const step of steps) {
if (step.at > time) break;
current = step;
}
return current;
}
function durations(values: number[]): DurationStats {
if (values.length === 0)
return { count: 0, averageDays: null, p85Days: null };
const sorted = [...values].sort((a, b) => a - b);
const average = values.reduce((sum, v) => sum + v, 0) / values.length;
const p85 = sorted[Math.max(0, Math.ceil(0.85 * sorted.length) - 1)];
const round = (ms: number) => Math.round((ms / DAY) * 10) / 10;
return {
count: values.length,
averageDays: round(average),
p85Days: round(p85),
};
}
export interface AnalyticsInput {
issues: IssueReadModel[];
timeline: IssueTimelineEntry[];
/** Status ids in the project's order (series follow it). */
statusOrder: string[];
from: Date;
to: Date;
}
export function computeProjectAnalytics(
input: AnalyticsInput,
): ProjectAnalyticsReadModel {
const from = input.from.getTime();
const to = input.to.getTime();
const inRange = (time: number) => time >= from && time <= to;
const steps = stepsByIssue(input.issues, input.timeline);
const byId = new Map(input.issues.map((issue) => [issue.id, issue]));
const people = new Map<string, UserRefReadModel>();
for (const issue of input.issues) {
if (issue.assignee) people.set(issue.assignee.id, issue.assignee);
people.set(issue.reporter.id, issue.reporter);
}
// Created vs resolved, per week.
const weekKeys = weeks(from, to);
const created = new Map(weekKeys.map((week) => [week, 0]));
const resolved = new Map(weekKeys.map((week) => [week, 0]));
for (const issue of input.issues) {
const at = Date.parse(issue.createdAt);
if (inRange(at))
created.set(weekOf(at), (created.get(weekOf(at)) ?? 0) + 1);
}
// Walk every transition once: resolutions, cycle/lead time, throughput, QA.
const cycle: number[] = [];
const lead: number[] = [];
const throughput = new Map<
string,
{ week: string; user: string | null; count: number }
>();
let passed = 0;
let failed = 0;
for (const [issueId, list] of steps) {
const issue = byId.get(issueId);
for (let i = 1; i < list.length; i++) {
const previous = list[i - 1];
const step = list[i];
if (!inRange(step.at)) continue;
if (
previous.category === StatusCategory.QA &&
step.category !== StatusCategory.QA
) {
if (step.category === StatusCategory.DONE) passed++;
else if (step.category !== StatusCategory.CANCELLED) failed++;
}
if (
step.category !== StatusCategory.DONE ||
previous.category === StatusCategory.DONE
)
continue;
const week = weekOf(step.at);
resolved.set(week, (resolved.get(week) ?? 0) + 1);
if (issue) lead.push(step.at - Date.parse(issue.createdAt));
const start = list.slice(0, i).find((s) => STARTED.includes(s.category));
if (start) cycle.push(step.at - start.at);
const key = `${week}|${step.assigneeId ?? ''}`;
const row = throughput.get(key) ?? {
week,
user: step.assigneeId,
count: 0,
};
row.count++;
throughput.set(key, row);
}
}
// Cumulative flow: tickets per status at the end of each day.
const dayEnds = days(from, to);
const counts = new Map<string, number[]>();
for (const list of steps.values()) {
dayEnds.forEach((end, index) => {
const step = stepAt(list, end);
if (!step) return;
const series =
counts.get(step.status) ?? new Array(dayEnds.length).fill(0);
series[index]++;
counts.set(step.status, series);
});
}
const order = (status: string) => {
const index = input.statusOrder.indexOf(status);
return index === -1 ? Number.MAX_SAFE_INTEGER : index;
};
return {
from: input.from.toISOString(),
to: input.to.toISOString(),
createdVsResolved: weekKeys.map((week) => ({
week,
created: created.get(week) ?? 0,
resolved: resolved.get(week) ?? 0,
})),
cumulativeFlow: {
dates: dayEnds.map(dayOf),
series: [...counts.entries()]
.sort(([a], [b]) => order(a) - order(b))
.map(([status, values]) => ({ status, counts: values })),
},
cycleTime: durations(cycle),
leadTime: durations(lead),
throughput: [...throughput.values()]
.sort((a, b) => a.week.localeCompare(b.week) || b.count - a.count)
.map((row) => ({
week: row.week,
user: row.user
? (people.get(row.user) ?? { id: row.user, name: '?' })
: null,
count: row.count,
})),
qa: {
passed,
failed,
failureRate:
passed + failed
? Math.round((failed / (passed + failed)) * 1000) / 1000
: null,
},
};
}
export interface BurnupInput {
release: ReleaseReadModel;
issues: IssueReadModel[];
timeline: IssueTimelineEntry[];
now: Date;
}
/**
* Per day, from the release's creation until it ships (or today): tickets in
* it (scope, cancelled ones aside) and how many of those are done.
*/
export function computeReleaseBurnup(
input: BurnupInput,
): ReleaseBurnupReadModel {
const { release } = input;
const steps = stepsByIssue(input.issues, input.timeline);
const membership = new Map<
string,
{ at: number; releaseId: string | null }[]
>();
for (const entry of input.timeline) {
if (entry.kind !== 'release') continue;
const list = membership.get(entry.issueId) ?? [];
list.push({ at: Date.parse(entry.at), releaseId: entry.releaseId ?? null });
membership.set(entry.issueId, list);
}
for (const issue of input.issues) {
if (!membership.has(issue.id) && issue.release) {
membership.set(issue.id, [
{ at: Date.parse(issue.createdAt), releaseId: issue.release.id },
]);
}
membership.get(issue.id)?.sort((a, b) => a.at - b.at);
}
const end = release.releasedAt
? Date.parse(release.releasedAt)
: input.now.getTime();
// At most the last 180 days, so a long-lived release stays readable.
const start = Math.max(Date.parse(release.createdAt), end - 179 * DAY);
const dayEnds = days(start, end);
const scope: number[] = [];
const done: number[] = [];
for (const time of dayEnds) {
let inScope = 0;
let finished = 0;
for (const [issueId, list] of membership) {
const last = list.filter((m) => m.at <= time).at(-1);
if (last?.releaseId !== release.id) continue;
const step = stepAt(steps.get(issueId) ?? [], time);
if (!step || step.category === StatusCategory.CANCELLED) continue;
inScope++;
if (step.category === StatusCategory.DONE) finished++;
}
scope.push(inScope);
done.push(finished);
}
return {
releaseId: release.id,
targetDate: release.targetDate,
dates: dayEnds.map(dayOf),
scope,
done,
};
}

View File

@@ -10,11 +10,16 @@ export class ChangeIssueStatusCommandHandler
constructor(private readonly access: WriteAccess) {}
async execute(command: ChangeIssueStatusCommand): Promise<CommandResult> {
const { issue } = await this.access.issueForContributor(
const { issue, project } = await this.access.issueForContributor(
command.issueId,
command.actorId,
);
issue.changeStatus(command.actorId, command.status, command.reason);
issue.changeStatus(
command.actorId,
command.status,
command.reason,
project.workflow,
);
await issue.commitAndWait();
return ack(issue);
}

View File

@@ -1,12 +1,12 @@
import { Command } from '@nestjs/cqrs';
import { IssueStatus } from '@task-stream/api/tracker/domain';
import { CommandResult } from './command-result';
export class ChangeIssueStatusCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly issueId: string,
public readonly status: IssueStatus,
/** Status id of the project's workflow. */
public readonly status: string,
/** Required for QA_FAILED and CANCELLED. */
public readonly reason?: string | null,
) {

View File

@@ -1,5 +1,6 @@
import { CreateProjectCommandHandler } from './create-project.command-handler';
import { UpdateProjectCommandHandler } from './update-project.command-handler';
import { UpdateProjectSettingsCommandHandler } from './update-project-settings.command-handler';
import { AddProjectMemberCommandHandler } from './add-project-member.command-handler';
import { ChangeProjectMemberRoleCommandHandler } from './change-project-member-role.command-handler';
import { RemoveProjectMemberCommandHandler } from './remove-project-member.command-handler';
@@ -21,6 +22,7 @@ import { ArchiveReleaseCommandHandler } from './archive-release.command-handler'
export const COMMAND_HANDLERS = [
CreateProjectCommandHandler,
UpdateProjectCommandHandler,
UpdateProjectSettingsCommandHandler,
AddProjectMemberCommandHandler,
ChangeProjectMemberRoleCommandHandler,
RemoveProjectMemberCommandHandler,

View File

@@ -19,21 +19,28 @@ export class CreateIssueCommandHandler
async execute(command: CreateIssueCommand): Promise<CommandResult> {
const project = await this.access.project(command.projectId);
project.assertCanContribute(command.actorId);
// Before taking a number: an invalid type or area must not burn one.
const workflow = project.workflow;
workflow.assertActiveType(command.type);
workflow.assertActiveArea(command.details.area);
if (command.assigneeId)
this.access.assertAssignable(project, command.assigneeId);
if (command.releaseId)
await this.access.openReleaseOf(project, command.releaseId);
const issue = this.issueFactory.create({
...command.details,
projectId: project.id,
projectKey: project.key,
number: await this.issueNumbers.next(project.id),
type: command.type,
title: command.title,
reporterId: command.actorId,
assigneeId: command.assigneeId,
});
const issue = this.issueFactory.create(
{
...command.details,
projectId: project.id,
projectKey: project.key,
number: await this.issueNumbers.next(project.id),
type: command.type,
title: command.title,
reporterId: command.actorId,
assigneeId: command.assigneeId,
},
workflow,
);
if (command.releaseId) issue.setRelease(command.actorId, command.releaseId);
this.eventPublisher.mergeObjectContext(issue);

View File

@@ -1,12 +1,13 @@
import { Command } from '@nestjs/cqrs';
import { IssueDetails, IssueType } from '@task-stream/api/tracker/domain';
import { IssueDetails } from '@task-stream/api/tracker/domain';
import { CommandResult } from './command-result';
export class CreateIssueCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly projectId: string,
public readonly type: IssueType,
/** Ticket type id of the project's settings. */
public readonly type: string,
public readonly title: string,
public readonly details: Omit<IssueDetails, 'title' | 'type'>,
public readonly assigneeId?: string | null,

View File

@@ -17,4 +17,5 @@ export * from './set-issue-release.command';
export * from './unlink-issues.command';
export * from './update-issue.command';
export * from './update-project.command';
export * from './update-project-settings.command';
export * from './update-release.command';

View File

@@ -1,5 +1,4 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { IssueWorkflow } from '@task-stream/api/tracker/domain';
import { ReleaseIssuesRepository } from '../ports/release-issues.repository';
import { CommandResult, ack } from './command-result';
import { PublishReleaseCommand } from './publish-release.command';
@@ -16,9 +15,9 @@ export class PublishReleaseCommandHandler
async execute(command: PublishReleaseCommand): Promise<CommandResult> {
const release = await this.access.release(command.releaseId);
(await this.access.project(release.projectId)).assertCanManage(
command.actorId,
);
const project = await this.access.project(release.projectId);
project.assertCanManage(command.actorId);
const workflow = project.workflow;
// Rebuilt from the event store (source of truth), not from the read model.
const candidates = await this.releaseIssues.findIssueIds(release.id);
@@ -27,8 +26,7 @@ export class PublishReleaseCommandHandler
);
const unfinished = issues.filter(
(issue) =>
issue.releaseId === release.id &&
!IssueWorkflow.isFinished(issue.status),
issue.releaseId === release.id && !workflow.isFinished(issue.status),
).length;
release.ship(command.actorId, unfinished, command.force);

View File

@@ -10,11 +10,11 @@ export class UpdateIssueCommandHandler
constructor(private readonly access: WriteAccess) {}
async execute(command: UpdateIssueCommand): Promise<CommandResult> {
const { issue } = await this.access.issueForContributor(
const { issue, project } = await this.access.issueForContributor(
command.issueId,
command.actorId,
);
issue.updateDetails(command.actorId, command.changes);
issue.updateDetails(command.actorId, command.changes, project.workflow);
await issue.commitAndWait();
return ack(issue);
}

View File

@@ -0,0 +1,18 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { CommandResult, ack } from './command-result';
import { UpdateProjectSettingsCommand } from './update-project-settings.command';
import { WriteAccess } from './write-access';
@CommandHandler(UpdateProjectSettingsCommand)
export class UpdateProjectSettingsCommandHandler
implements ICommandHandler<UpdateProjectSettingsCommand>
{
constructor(private readonly access: WriteAccess) {}
async execute(command: UpdateProjectSettingsCommand): Promise<CommandResult> {
const project = await this.access.project(command.projectId);
project.updateSettings(command.actorId, command.settings);
await project.commitAndWait();
return ack(project);
}
}

View File

@@ -0,0 +1,14 @@
import { Command } from '@nestjs/cqrs';
import { ProjectSettings } from '@task-stream/api/tracker/domain';
import { CommandResult } from './command-result';
/** Replaces the statuses, transitions, ticket types and areas of a project. */
export class UpdateProjectSettingsCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly projectId: string,
public readonly settings: ProjectSettings,
) {
super();
}
}

View File

@@ -7,9 +7,7 @@ import {
IssueLinkedEvent,
IssueLinkType,
IssueReleaseChangedEvent,
IssueStatus,
IssueStatusChangedEvent,
IssueType,
IssueUnlinkedEvent,
IssueUpdatedEvent,
ProjectCreatedEvent,
@@ -17,7 +15,9 @@ import {
ProjectMemberRemovedEvent,
ProjectMemberRoleChangedEvent,
ProjectRole,
ProjectSettingsChangedEvent,
ProjectUpdatedEvent,
ProjectWorkflow,
ReleaseArchivedEvent,
ReleaseCreatedEvent,
ReleaseShippedEvent,
@@ -31,28 +31,19 @@ export interface SummaryContext {
issueKey: string;
projectKey: string;
releaseName: string;
/** The project's workflow: names of statuses and types. */
workflow: ProjectWorkflow;
}
/* The activity feed is read by people: Spanish, with the same labels as the web app. */
const STATUS: Record<IssueStatus, string> = {
[IssueStatus.BACKLOG]: 'Backlog',
[IssueStatus.TODO]: 'Por hacer',
[IssueStatus.IN_PROGRESS]: 'En curso',
[IssueStatus.IN_REVIEW]: 'En revisión',
[IssueStatus.READY_FOR_QA]: 'Listo para QA',
[IssueStatus.IN_QA]: 'En QA',
[IssueStatus.QA_FAILED]: 'QA fallido',
[IssueStatus.DONE]: 'Hecho',
[IssueStatus.CANCELLED]: 'Cancelado',
};
const TYPE: Record<IssueType, string> = {
[IssueType.BUG]: 'bug',
[IssueType.FEATURE]: 'feature',
[IssueType.UI_CHANGE]: 'cambio de UI',
[IssueType.TASK]: 'tarea',
[IssueType.QA]: 'QA',
/** Default types keep their lowercase wording in sentences ("creó WEB-3 (tarea)"). */
const DEFAULT_TYPE_WORDING: Record<string, string> = {
BUG: 'bug',
FEATURE: 'feature',
UI_CHANGE: 'cambio de UI',
TASK: 'tarea',
QA: 'QA',
};
const LINK: Record<IssueLinkType, string> = {
@@ -98,6 +89,8 @@ export function describeEvent(
return `${actor} creó el proyecto ${event.project.key}`;
if (event instanceof ProjectUpdatedEvent)
return `${actor} actualizó el proyecto ${ctx.projectKey}`;
if (event instanceof ProjectSettingsChangedEvent)
return `${actor} cambió los estados, tipos o áreas del proyecto ${ctx.projectKey}`;
if (event instanceof ProjectMemberAddedEvent) {
return `${actor} añadió a ${ctx.user(event.userId)} como ${ROLE[event.role]}`;
}
@@ -111,13 +104,19 @@ export function describeEvent(
}
if (event instanceof IssueCreatedEvent) {
return `${actor} creó ${event.issue.key} (${TYPE[event.issue.type]}): ${event.issue.title}`;
const type =
DEFAULT_TYPE_WORDING[event.issue.type] ??
ctx.workflow.typeName(event.issue.type);
return `${actor} creó ${event.issue.key} (${type}): ${event.issue.title}`;
}
if (event instanceof IssueUpdatedEvent) {
return `${actor} cambió ${list(Object.keys(event.changes).map((f) => FIELD[f] ?? f))} de ${issueKey}`;
}
if (event instanceof IssueStatusChangedEvent) {
return `${actor} movió ${issueKey} de ${STATUS[event.from]} a ${STATUS[event.to]}${event.reason ? `: ${event.reason}` : ''}`;
// Names as they were then (old events: as configured now).
const from = event.meta?.fromName ?? ctx.workflow.statusName(event.from);
const to = event.meta?.toName ?? ctx.workflow.statusName(event.to);
return `${actor} movió ${issueKey} de ${from} a ${to}${event.reason ? `: ${event.reason}` : ''}`;
}
if (event instanceof IssueAssignedEvent) {
return event.assigneeId

View File

@@ -21,6 +21,9 @@ import {
ReleaseShippedEvent,
ReleaseUpdatedEvent,
TrackerEvent,
DEFAULT_PROJECT_SETTINGS,
ProjectSettingsChangedEvent,
ProjectWorkflow,
} from '@task-stream/api/tracker/domain';
import { ChangeFeed } from '../ports/change-feed';
import { FindIssuesRepository } from '../ports/find-issues.repository';
@@ -35,6 +38,7 @@ import { versionOf } from './read-model-notifier';
@EventsHandler(
ProjectCreatedEvent,
ProjectUpdatedEvent,
ProjectSettingsChangedEvent,
ProjectMemberAddedEvent,
ProjectMemberRemovedEvent,
ProjectMemberRoleChangedEvent,
@@ -96,6 +100,9 @@ export class ActivityEventHandler implements IEventHandler<TrackerEvent> {
issueKey: issueKey ?? '',
projectKey: project?.key ?? '',
releaseName: release?.name ?? '',
workflow: new ProjectWorkflow(
project?.settings ?? DEFAULT_PROJECT_SETTINGS,
),
}),
occurredAt: event.occurredAt,
};

View File

@@ -19,6 +19,8 @@ import { ReleaseCreatedEventHandler } from './release-created.event-handler';
import { ReleaseShippedEventHandler } from './release-shipped.event-handler';
import { ReleaseUpdatedEventHandler } from './release-updated.event-handler';
import { ReadModelNotifier } from './read-model-notifier';
import { IssueTimelineEventHandler } from './issue-timeline.event-handler';
import { ProjectSettingsChangedEventHandler } from './project-settings-changed.event-handler';
/** Read-model projections (MongoDB) + realtime, fed by the EventsBridge. */
export const EVENT_HANDLERS = [
@@ -31,6 +33,7 @@ export const EVENT_HANDLERS = [
IssueLinkedEventHandler,
IssueReleaseChangedEventHandler,
IssueStatusChangedEventHandler,
IssueTimelineEventHandler,
IssueUnlinkedEventHandler,
IssueUpdatedEventHandler,
ProjectCreatedEventHandler,
@@ -38,6 +41,7 @@ export const EVENT_HANDLERS = [
ProjectMemberRemovedEventHandler,
ProjectMemberRoleChangedEventHandler,
ProjectUpdatedEventHandler,
ProjectSettingsChangedEventHandler,
ReleaseArchivedEventHandler,
ReleaseCreatedEventHandler,
ReleaseShippedEventHandler,

View File

@@ -1,5 +1,10 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { IssueCreatedEvent } from '@task-stream/api/tracker/domain';
import {
DEFAULT_PROJECT_SETTINGS,
IssueCreatedEvent,
ProjectWorkflow,
} from '@task-stream/api/tracker/domain';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { FindReleasesRepository } from '../ports/find-releases.repository';
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
import { UserDirectory } from '../ports/user-directory';
@@ -12,6 +17,7 @@ export class IssueCreatedEventHandler
constructor(
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
private readonly findReleases: FindReleasesRepository,
private readonly findProjects: FindProjectsRepository,
private readonly users: UserDirectory,
private readonly notifier: ReadModelNotifier,
) {}
@@ -19,6 +25,10 @@ export class IssueCreatedEventHandler
async handle(event: IssueCreatedEvent) {
const { issue } = event;
const users = await this.users.refs([issue.reporterId, issue.assigneeId]);
const project = await this.findProjects.findById(issue.projectId);
const workflow = new ProjectWorkflow(
project?.settings ?? DEFAULT_PROJECT_SETTINGS,
);
const release = issue.releaseId
? await this.findReleases.findById(issue.releaseId)
: null;
@@ -33,6 +43,7 @@ export class IssueCreatedEventHandler
title: issue.title,
description: issue.description,
status: issue.status,
statusCategory: workflow.categoryOf(issue.status),
priority: issue.priority,
reporter: users.of(issue.reporterId),
assignee: users.ofNullable(issue.assigneeId),

View File

@@ -1,5 +1,10 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { IssueStatusChangedEvent } from '@task-stream/api/tracker/domain';
import {
DEFAULT_PROJECT_SETTINGS,
IssueStatusChangedEvent,
ProjectWorkflow,
} from '@task-stream/api/tracker/domain';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
import { ReadModelNotifier, versionOf } from './read-model-notifier';
@@ -9,6 +14,7 @@ export class IssueStatusChangedEventHandler
{
constructor(
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
private readonly findProjects: FindProjectsRepository,
private readonly notifier: ReadModelNotifier,
) {}
@@ -16,9 +22,18 @@ export class IssueStatusChangedEventHandler
await this.upsertMaterializedIssueRepository.upsert({
id: event.aggregateId,
status: event.to,
statusCategory: event.meta?.toCategory ?? (await this.categoryOf(event)),
updatedAt: event.occurredAt,
version: versionOf(event),
});
await this.notifier.issue(event.aggregateId, event);
}
/** Old events don't carry the category: ask the project's current settings. */
private async categoryOf(event: IssueStatusChangedEvent) {
const project = await this.findProjects.findById(event.projectId);
return new ProjectWorkflow(
project?.settings ?? DEFAULT_PROJECT_SETTINGS,
).categoryOf(event.to);
}
}

View File

@@ -0,0 +1,76 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import {
DEFAULT_PROJECT_SETTINGS,
IssueCreatedEvent,
IssueReleaseChangedEvent,
IssueStatusChangedEvent,
ProjectWorkflow,
TrackerEvent,
} from '@task-stream/api/tracker/domain';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { UpsertMaterializedTimelineRepository } from '../ports/upsert-materialized-timeline.repository';
import { versionOf } from './read-model-notifier';
/**
* The life of every ticket, step by step: where it went (status + category)
* and which release it joined. Statistics are computed from it.
*/
@EventsHandler(
IssueCreatedEvent,
IssueStatusChangedEvent,
IssueReleaseChangedEvent,
)
export class IssueTimelineEventHandler implements IEventHandler<TrackerEvent> {
constructor(
private readonly timeline: UpsertMaterializedTimelineRepository,
private readonly findProjects: FindProjectsRepository,
private readonly findIssues: FindIssuesRepository,
) {}
async handle(event: TrackerEvent) {
const base = {
id: `${event.aggregateId}:${versionOf(event)}`,
issueId: event.aggregateId,
projectId: event.projectId,
at: event.occurredAt,
};
if (event instanceof IssueCreatedEvent) {
const { issue } = event;
await this.timeline.record({
...base,
kind: 'status',
status: issue.status,
category: (await this.workflowOf(event)).categoryOf(issue.status),
assigneeId: issue.assigneeId,
});
return;
}
if (event instanceof IssueStatusChangedEvent) {
const issue = await this.findIssues.findById(event.aggregateId);
await this.timeline.record({
...base,
kind: 'status',
status: event.to,
category:
event.meta?.toCategory ??
(await this.workflowOf(event)).categoryOf(event.to),
assigneeId: issue?.assignee?.id ?? null,
});
return;
}
if (event instanceof IssueReleaseChangedEvent) {
await this.timeline.record({
...base,
kind: 'release',
releaseId: event.releaseId,
});
}
}
private async workflowOf(event: TrackerEvent): Promise<ProjectWorkflow> {
const project = await this.findProjects.findById(event.projectId);
return new ProjectWorkflow(project?.settings ?? DEFAULT_PROJECT_SETTINGS);
}
}

View File

@@ -1,5 +1,8 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { ProjectCreatedEvent } from '@task-stream/api/tracker/domain';
import {
defaultProjectSettings,
ProjectCreatedEvent,
} from '@task-stream/api/tracker/domain';
import { UpsertMaterializedProjectRepository } from '../ports/upsert-materialized-project.repository';
import { UserDirectory } from '../ports/user-directory';
import { ReadModelNotifier, versionOf } from './read-model-notifier';
@@ -27,6 +30,8 @@ export class ProjectCreatedEventHandler
user: users.of(m.userId),
role: m.role,
})),
// Projects created before settings existed get the defaults.
settings: project.settings ?? defaultProjectSettings(),
createdAt: project.createdAt,
updatedAt: project.updatedAt,
version: versionOf(event),

View File

@@ -0,0 +1,37 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import {
ProjectSettingsChangedEvent,
StatusCategory,
} from '@task-stream/api/tracker/domain';
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
import { UpsertMaterializedProjectRepository } from '../ports/upsert-materialized-project.repository';
import { ReadModelNotifier, versionOf } from './read-model-notifier';
@EventsHandler(ProjectSettingsChangedEvent)
export class ProjectSettingsChangedEventHandler
implements IEventHandler<ProjectSettingsChangedEvent>
{
constructor(
private readonly upsertMaterializedProjectRepository: UpsertMaterializedProjectRepository,
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
private readonly notifier: ReadModelNotifier,
) {}
async handle(event: ProjectSettingsChangedEvent) {
await this.upsertMaterializedProjectRepository.upsert({
id: event.projectId,
settings: event.settings,
updatedAt: event.occurredAt,
version: versionOf(event),
});
// A status may have changed category: open/finished counts depend on it.
const categories: Record<string, StatusCategory> = {};
for (const status of event.settings.statuses)
categories[status.id] = status.category;
await this.upsertMaterializedIssueRepository.recategorize(
event.projectId,
categories,
);
await this.notifier.project(event);
}
}

View File

@@ -5,8 +5,6 @@ import {
IssueDetails,
IssueLinkType,
IssueReadModel,
IssueStatus,
IssueType,
} from '@task-stream/api/tracker/domain';
import { AssignIssueCommand } from './commands/assign-issue.command';
import { ChangeIssueStatusCommand } from './commands/change-issue-status.command';
@@ -21,10 +19,15 @@ import { GetActivityQuery } from './queries/get-activity.query';
import { GetIssueQuery } from './queries/get-issue.query';
import { GetIssuesByIdsQuery } from './queries/get-issues-by-ids.query';
import { SearchIssuesQuery } from './queries/search-issues.query';
import {
MyIssuesFilter,
SearchMyIssuesQuery,
} from './queries/search-my-issues.query';
export interface CreateIssueInput extends Omit<IssueDetails, 'title' | 'type'> {
projectId: string;
type: IssueType;
/** A type id of the project's settings. */
type: string;
title: string;
assigneeId?: string | null;
releaseId?: string | null;
@@ -71,7 +74,7 @@ export class IssuesService {
changeStatus(
actorId: string,
issueId: string,
status: IssueStatus,
status: string,
reason?: string | null,
): Promise<CommandResult> {
return this.commandBus.execute(
@@ -135,6 +138,14 @@ export class IssuesService {
return this.queryBus.execute(new SearchIssuesQuery(viewerId, filter));
}
/** Across all my projects: assigned to me, or any ticket (e.g. waiting for QA). */
mine(
viewerId: string,
filter: MyIssuesFilter,
): Promise<Page<IssueReadModel>> {
return this.queryBus.execute(new SearchMyIssuesQuery(viewerId, filter));
}
activity(
viewerId: string,
params: {

View File

@@ -1,18 +1,18 @@
import {
IssueArea,
IssuePriority,
IssueReadModel,
IssueStatus,
IssueType,
ProjectStatsReadModel,
ReleaseProgressReadModel,
StatusCategory,
} from '@task-stream/api/tracker/domain';
export interface IssueFilter {
projectId: string;
statuses?: IssueStatus[];
types?: IssueType[];
areas?: IssueArea[];
/** Status, type and area ids of the project's settings. */
statuses?: string[];
categories?: StatusCategory[];
types?: string[];
areas?: string[];
priorities?: IssuePriority[];
assigneeId?: string;
unassigned?: boolean;
@@ -23,6 +23,16 @@ export interface IssueFilter {
offset: number;
}
/** Tickets across several projects (the viewer's "my work"). */
export interface CrossProjectIssueFilter {
projectIds: string[];
assigneeId?: string;
statuses?: string[];
categories?: StatusCategory[];
limit: number;
offset: number;
}
export interface Page<T> {
items: T[];
total: number;
@@ -33,7 +43,12 @@ export abstract class FindIssuesRepository {
abstract findByKey(key: string): Promise<IssueReadModel | null>;
abstract findByIds(ids: string[]): Promise<IssueReadModel[]>;
abstract search(filter: IssueFilter): Promise<Page<IssueReadModel>>;
abstract searchAcross(
filter: CrossProjectIssueFilter,
): Promise<Page<IssueReadModel>>;
abstract stats(projectId: string): Promise<ProjectStatsReadModel>;
/** Every ticket of a project (statistics). */
abstract findByProject(projectId: string): Promise<IssueReadModel[]>;
abstract releaseProgress(
releaseId: string,
): Promise<ReleaseProgressReadModel>;

View File

@@ -3,4 +3,9 @@ import { ReleaseReadModel } from '@task-stream/api/tracker/domain';
export abstract class FindReleasesRepository {
abstract findById(id: string): Promise<ReleaseReadModel | null>;
abstract findByProject(projectId: string): Promise<ReleaseReadModel[]>;
/** Planned releases of these projects, soonest target date first (undated last). */
abstract findUpcoming(
projectIds: string[],
limit: number,
): Promise<ReleaseReadModel[]>;
}

View File

@@ -0,0 +1,6 @@
import { IssueTimelineEntry } from '@task-stream/api/tracker/domain';
export abstract class FindTimelineRepository {
/** Every step of every ticket of the project, oldest first. */
abstract findByProject(projectId: string): Promise<IssueTimelineEntry[]>;
}

View File

@@ -14,3 +14,5 @@ export * from './upsert-materialized-activity.repository';
export * from './change-feed';
export * from './user-directory';
export * from './release-issues.repository';
export * from './upsert-materialized-timeline.repository';
export * from './find-timeline.repository';

View File

@@ -1,6 +1,7 @@
import {
IssueLinkReadModel,
IssueReadModel,
StatusCategory,
} from '@task-stream/api/tracker/domain';
export abstract class UpsertMaterializedIssueRepository {
@@ -19,6 +20,11 @@ export abstract class UpsertMaterializedIssueRepository {
issueId: string,
commentCount: number,
): Promise<void>;
/** A project's statuses changed category: update its tickets' `statusCategory`. */
abstract recategorize(
projectId: string,
categories: Record<string, StatusCategory>,
): Promise<void>;
/** Keeps the denormalised release name of every issue in sync. */
abstract renameRelease(releaseId: string, name: string): Promise<void>;
}

View File

@@ -0,0 +1,6 @@
import { IssueTimelineEntry } from '@task-stream/api/tracker/domain';
export abstract class UpsertMaterializedTimelineRepository {
/** Stores a step of a ticket's life (idempotent by `entry.id`). */
abstract record(entry: IssueTimelineEntry): Promise<void>;
}

View File

@@ -1,8 +1,10 @@
import { Injectable } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import {
ProjectAnalyticsReadModel,
ProjectReadModel,
ProjectRole,
ProjectSettings,
ProjectStatsReadModel,
ReleaseReadModel,
} from '@task-stream/api/tracker/domain';
@@ -12,9 +14,11 @@ import { CommandResult } from './commands/command-result';
import { CreateProjectCommand } from './commands/create-project.command';
import { RemoveProjectMemberCommand } from './commands/remove-project-member.command';
import { UpdateProjectCommand } from './commands/update-project.command';
import { UpdateProjectSettingsCommand } from './commands/update-project-settings.command';
import { IssueFilter } from './ports/find-issues.repository';
import { BoardColumn, GetBoardQuery } from './queries/get-board.query';
import { GetMyProjectsQuery } from './queries/get-my-projects.query';
import { GetProjectAnalyticsQuery } from './queries/get-project-analytics.query';
import { GetProjectStatsQuery } from './queries/get-project-stats.query';
import { GetProjectQuery } from './queries/get-project.query';
import { GetReleasesQuery } from './queries/get-releases.query';
@@ -50,6 +54,17 @@ export class ProjectsService {
);
}
/** Statuses, transitions, ticket types and areas (owners and admins). */
updateSettings(
actorId: string,
projectId: string,
settings: ProjectSettings,
): Promise<CommandResult> {
return this.commandBus.execute(
new UpdateProjectSettingsCommand(actorId, projectId, settings),
);
}
addMember(
actorId: string,
input: { projectId: string; userId: string; role: ProjectRole },
@@ -115,4 +130,15 @@ export class ProjectsService {
releases(viewerId: string, projectId: string): Promise<ReleaseReadModel[]> {
return this.queryBus.execute(new GetReleasesQuery(viewerId, projectId));
}
/** Statistics over the last `days` days (created vs resolved, flow, times, QA). */
analytics(
viewerId: string,
projectId: string,
days: number,
): Promise<ProjectAnalyticsReadModel> {
return this.queryBus.execute(
new GetProjectAnalyticsQuery(viewerId, projectId, days),
);
}
}

View File

@@ -1,41 +1,49 @@
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { IssueStatus } from '@task-stream/api/tracker/domain';
import {
DEFAULT_PROJECT_SETTINGS,
StatusCategory,
} from '@task-stream/api/tracker/domain';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { BoardColumn, GetBoardQuery } from './get-board.query';
import { ReadAccess } from './read-access';
const BOARD_COLUMNS: IssueStatus[] = [
IssueStatus.BACKLOG,
IssueStatus.TODO,
IssueStatus.IN_PROGRESS,
IssueStatus.IN_REVIEW,
IssueStatus.READY_FOR_QA,
IssueStatus.IN_QA,
IssueStatus.QA_FAILED,
IssueStatus.DONE,
];
@QueryHandler(GetBoardQuery)
export class GetBoardQueryHandler
implements IQueryHandler<GetBoardQuery, BoardColumn[]>
{
constructor(
private readonly findIssues: FindIssuesRepository,
private readonly findProjects: FindProjectsRepository,
private readonly access: ReadAccess,
) {}
/**
* Columns follow the project's statuses in order. Cancelled ones stay off the
* board; archived ones only show while they still hold tickets.
*/
async execute(query: GetBoardQuery): Promise<BoardColumn[]> {
await this.access.assertCanView(query.filter.projectId, query.viewerId);
return Promise.all(
BOARD_COLUMNS.map(async (status) => {
const project = await this.findProjects.findById(query.filter.projectId);
const statuses = (
project?.settings ?? DEFAULT_PROJECT_SETTINGS
).statuses.filter((status) => status.category !== StatusCategory.CANCELLED);
const columns = await Promise.all(
statuses.map(async (status) => {
const page = await this.findIssues.search({
...query.filter,
statuses: [status],
statuses: [status.id],
limit: Math.min(query.perColumn, 200),
offset: 0,
});
return { status, issues: page.items, total: page.total };
return {
column: { status: status.id, issues: page.items, total: page.total },
archived: status.archived,
};
}),
);
return columns
.filter(({ column, archived }) => !archived || column.total > 0)
.map(({ column }) => column);
}
}

View File

@@ -1,14 +1,15 @@
import { Query } from '@nestjs/cqrs';
import { IssueReadModel, IssueStatus } from '@task-stream/api/tracker/domain';
import { IssueReadModel } from '@task-stream/api/tracker/domain';
import { IssueFilter } from '../ports/find-issues.repository';
export interface BoardColumn {
status: IssueStatus;
/** Status id of the project's workflow. */
status: string;
issues: IssueReadModel[];
total: number;
}
/** Kanban board: one column per status. */
/** Kanban board: one column per status of the project (cancelled ones aside). */
export class GetBoardQuery extends Query<BoardColumn[]> {
constructor(
public readonly viewerId: string,

View File

@@ -0,0 +1,47 @@
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import {
DEFAULT_PROJECT_SETTINGS,
ProjectAnalyticsReadModel,
} from '@task-stream/api/tracker/domain';
import { computeProjectAnalytics } from '../analytics/analytics';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { FindTimelineRepository } from '../ports/find-timeline.repository';
import { GetProjectAnalyticsQuery } from './get-project-analytics.query';
import { ReadAccess } from './read-access';
const DAY = 86_400_000;
@QueryHandler(GetProjectAnalyticsQuery)
export class GetProjectAnalyticsQueryHandler
implements IQueryHandler<GetProjectAnalyticsQuery, ProjectAnalyticsReadModel>
{
constructor(
private readonly access: ReadAccess,
private readonly findProjects: FindProjectsRepository,
private readonly findIssues: FindIssuesRepository,
private readonly findTimeline: FindTimelineRepository,
) {}
async execute(
query: GetProjectAnalyticsQuery,
): Promise<ProjectAnalyticsReadModel> {
await this.access.assertCanView(query.projectId, query.viewerId);
const [project, issues, timeline] = await Promise.all([
this.findProjects.findById(query.projectId),
this.findIssues.findByProject(query.projectId),
this.findTimeline.findByProject(query.projectId),
]);
const days = Math.min(Math.max(Math.round(query.days), 7), 365);
const to = new Date();
return computeProjectAnalytics({
issues,
timeline,
statusOrder: (project?.settings ?? DEFAULT_PROJECT_SETTINGS).statuses.map(
(status) => status.id,
),
from: new Date(to.getTime() - (days - 1) * DAY),
to,
});
}
}

View File

@@ -0,0 +1,13 @@
import { Query } from '@nestjs/cqrs';
import { ProjectAnalyticsReadModel } from '@task-stream/api/tracker/domain';
/** Statistics of a project over the last `days` days. */
export class GetProjectAnalyticsQuery extends Query<ProjectAnalyticsReadModel> {
constructor(
public readonly viewerId: string,
public readonly projectId: string,
public readonly days: number,
) {
super();
}
}

View File

@@ -0,0 +1,34 @@
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { ReleaseBurnupReadModel } from '@task-stream/api/tracker/domain';
import { computeReleaseBurnup } from '../analytics/analytics';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindReleasesRepository } from '../ports/find-releases.repository';
import { FindTimelineRepository } from '../ports/find-timeline.repository';
import { GetReleaseBurnupQuery } from './get-release-burnup.query';
import { ReadAccess } from './read-access';
@QueryHandler(GetReleaseBurnupQuery)
export class GetReleaseBurnupQueryHandler
implements IQueryHandler<GetReleaseBurnupQuery, ReleaseBurnupReadModel>
{
constructor(
private readonly access: ReadAccess,
private readonly findReleases: FindReleasesRepository,
private readonly findIssues: FindIssuesRepository,
private readonly findTimeline: FindTimelineRepository,
) {}
async execute(query: GetReleaseBurnupQuery): Promise<ReleaseBurnupReadModel> {
const release = this.access.assertFound(
await this.findReleases.findById(query.releaseId),
'Release',
query.releaseId,
);
await this.access.assertCanView(release.projectId, query.viewerId);
const [issues, timeline] = await Promise.all([
this.findIssues.findByProject(release.projectId),
this.findTimeline.findByProject(release.projectId),
]);
return computeReleaseBurnup({ release, issues, timeline, now: new Date() });
}
}

View File

@@ -0,0 +1,12 @@
import { Query } from '@nestjs/cqrs';
import { ReleaseBurnupReadModel } from '@task-stream/api/tracker/domain';
/** Scope vs finished tickets of a release, day by day. */
export class GetReleaseBurnupQuery extends Query<ReleaseBurnupReadModel> {
constructor(
public readonly viewerId: string,
public readonly releaseId: string,
) {
super();
}
}

View File

@@ -0,0 +1,23 @@
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { ReleaseReadModel } from '@task-stream/api/tracker/domain';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { FindReleasesRepository } from '../ports/find-releases.repository';
import { GetUpcomingReleasesQuery } from './get-upcoming-releases.query';
@QueryHandler(GetUpcomingReleasesQuery)
export class GetUpcomingReleasesQueryHandler
implements IQueryHandler<GetUpcomingReleasesQuery, ReleaseReadModel[]>
{
constructor(
private readonly findProjects: FindProjectsRepository,
private readonly findReleases: FindReleasesRepository,
) {}
async execute(query: GetUpcomingReleasesQuery): Promise<ReleaseReadModel[]> {
const projects = await this.findProjects.findByMember(query.viewerId);
return this.findReleases.findUpcoming(
projects.map((project) => project.id),
Math.min(query.limit, 50),
);
}
}

View File

@@ -0,0 +1,12 @@
import { Query } from '@nestjs/cqrs';
import { ReleaseReadModel } from '@task-stream/api/tracker/domain';
/** Planned releases of the viewer's projects, soonest first. */
export class GetUpcomingReleasesQuery extends Query<ReleaseReadModel[]> {
constructor(
public readonly viewerId: string,
public readonly limit: number,
) {
super();
}
}

View File

@@ -5,9 +5,13 @@ export * from './get-comments.query';
export * from './get-issue.query';
export * from './get-issues-by-ids.query';
export * from './get-my-projects.query';
export * from './get-project-analytics.query';
export * from './get-project-stats.query';
export * from './get-release-burnup.query';
export * from './get-project.query';
export * from './get-release-progress.query';
export * from './get-release.query';
export * from './get-releases.query';
export * from './get-upcoming-releases.query';
export * from './search-issues.query';
export * from './search-my-issues.query';

View File

@@ -6,11 +6,15 @@ import { GetIssueQueryHandler } from './get-issue.query-handler';
import { GetIssuesByIdsQueryHandler } from './get-issues-by-ids.query-handler';
import { GetMyProjectsQueryHandler } from './get-my-projects.query-handler';
import { GetProjectQueryHandler } from './get-project.query-handler';
import { GetProjectAnalyticsQueryHandler } from './get-project-analytics.query-handler';
import { GetProjectStatsQueryHandler } from './get-project-stats.query-handler';
import { GetReleaseBurnupQueryHandler } from './get-release-burnup.query-handler';
import { GetReleaseQueryHandler } from './get-release.query-handler';
import { GetReleaseProgressQueryHandler } from './get-release-progress.query-handler';
import { GetReleasesQueryHandler } from './get-releases.query-handler';
import { GetUpcomingReleasesQueryHandler } from './get-upcoming-releases.query-handler';
import { SearchIssuesQueryHandler } from './search-issues.query-handler';
import { SearchMyIssuesQueryHandler } from './search-my-issues.query-handler';
export const QUERY_HANDLERS = [
CanViewProjectQueryHandler,
@@ -21,9 +25,13 @@ export const QUERY_HANDLERS = [
GetIssuesByIdsQueryHandler,
GetMyProjectsQueryHandler,
GetProjectQueryHandler,
GetProjectAnalyticsQueryHandler,
GetProjectStatsQueryHandler,
GetReleaseBurnupQueryHandler,
GetReleaseQueryHandler,
GetReleaseProgressQueryHandler,
GetReleasesQueryHandler,
GetUpcomingReleasesQueryHandler,
SearchIssuesQueryHandler,
SearchMyIssuesQueryHandler,
];

View File

@@ -0,0 +1,28 @@
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { IssueReadModel } from '@task-stream/api/tracker/domain';
import { FindIssuesRepository, Page } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { SearchMyIssuesQuery } from './search-my-issues.query';
@QueryHandler(SearchMyIssuesQuery)
export class SearchMyIssuesQueryHandler
implements IQueryHandler<SearchMyIssuesQuery, Page<IssueReadModel>>
{
constructor(
private readonly findProjects: FindProjectsRepository,
private readonly findIssues: FindIssuesRepository,
) {}
/** Membership is the access rule: only projects the viewer belongs to. */
async execute(query: SearchMyIssuesQuery): Promise<Page<IssueReadModel>> {
const projects = await this.findProjects.findByMember(query.viewerId);
return this.findIssues.searchAcross({
projectIds: projects.map((project) => project.id),
assigneeId: query.filter.assignedToMe ? query.viewerId : undefined,
statuses: query.filter.statuses,
categories: query.filter.categories,
limit: Math.min(query.filter.limit, 200),
offset: query.filter.offset,
});
}
}

View File

@@ -0,0 +1,26 @@
import { Query } from '@nestjs/cqrs';
import {
IssueReadModel,
StatusCategory,
} from '@task-stream/api/tracker/domain';
import { Page } from '../ports/find-issues.repository';
export interface MyIssuesFilter {
/** true: assigned to the viewer. false: any ticket of the viewer's projects. */
assignedToMe: boolean;
statuses?: string[];
/** e.g. TODO + IN_PROGRESS + QA = still open, whatever the project calls them. */
categories?: StatusCategory[];
limit: number;
offset: number;
}
/** Tickets across every project the viewer is a member of ("my work"). */
export class SearchMyIssuesQuery extends Query<Page<IssueReadModel>> {
constructor(
public readonly viewerId: string,
public readonly filter: MyIssuesFilter,
) {
super();
}
}

View File

@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import {
ReleaseBurnupReadModel,
ReleaseProgressReadModel,
ReleaseReadModel,
} from '@task-stream/api/tracker/domain';
@@ -11,6 +12,8 @@ import { PublishReleaseCommand } from './commands/publish-release.command';
import { UpdateReleaseCommand } from './commands/update-release.command';
import { GetReleaseProgressQuery } from './queries/get-release-progress.query';
import { GetReleaseQuery } from './queries/get-release.query';
import { GetUpcomingReleasesQuery } from './queries/get-upcoming-releases.query';
import { GetReleaseBurnupQuery } from './queries/get-release-burnup.query';
interface ReleaseDetails {
name?: string;
@@ -71,6 +74,11 @@ export class ReleasesService {
return this.queryBus.execute(new GetReleaseQuery(viewerId, releaseId));
}
/** Planned releases of all my projects, soonest target date first. */
upcoming(viewerId: string, limit: number): Promise<ReleaseReadModel[]> {
return this.queryBus.execute(new GetUpcomingReleasesQuery(viewerId, limit));
}
progress(
viewerId: string,
releaseId: string,
@@ -79,4 +87,11 @@ export class ReleasesService {
new GetReleaseProgressQuery(viewerId, releaseId),
);
}
/** Scope vs finished tickets of the release, day by day. */
burnup(viewerId: string, releaseId: string): Promise<ReleaseBurnupReadModel> {
return this.queryBus.execute(
new GetReleaseBurnupQuery(viewerId, releaseId),
);
}
}

View File

@@ -14,6 +14,7 @@ import { ProjectsService } from './projects.service';
import { QUERY_HANDLERS } from './queries/query-handlers';
import { ReadAccess } from './queries/read-access';
import { ReleasesService } from './releases.service';
import { WorkflowsService } from './workflows.service';
const SERVICES = [
ProjectsService,
@@ -21,6 +22,7 @@ const SERVICES = [
CommentsService,
ReleasesService,
ChangesService,
WorkflowsService,
];
/**

View File

@@ -0,0 +1,45 @@
import { Injectable } from '@nestjs/common';
import {
DEFAULT_PROJECT_SETTINGS,
IssueReadModel,
ProjectWorkflow,
} from '@task-stream/api/tracker/domain';
import { FindProjectsRepository } from './ports/find-projects.repository';
/** How long a project's workflow is reused (a board asks once per ticket). */
const TTL_MS = 2_000;
/**
* The workflow of each project, for presenting tickets (e.g. which statuses a
* ticket can move to). Commands always load the aggregate instead.
*/
@Injectable()
export class WorkflowsService {
private readonly cache = new Map<
string,
{ at: number; workflow: Promise<ProjectWorkflow> }
>();
constructor(private readonly projects: FindProjectsRepository) {}
of(projectId: string): Promise<ProjectWorkflow> {
const now = Date.now();
const hit = this.cache.get(projectId);
if (hit && now - hit.at < TTL_MS) return hit.workflow;
for (const [id, entry] of this.cache)
if (now - entry.at >= TTL_MS) this.cache.delete(id);
const workflow = this.projects
.findById(projectId)
.then(
(project) =>
new ProjectWorkflow(project?.settings ?? DEFAULT_PROJECT_SETTINGS),
);
this.cache.set(projectId, { at: now, workflow });
return workflow;
}
/** Active statuses the ticket can move to next. */
async allowedTransitions(issue: IssueReadModel): Promise<string[]> {
return (await this.of(issue.projectId)).allowedFrom(issue.status);
}
}

View File

@@ -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).

View File

@@ -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": {}
}

View File

@@ -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';

View File

@@ -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';

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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,

View File

@@ -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,
};

View File

@@ -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);
},
};

View File

@@ -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),
},
),
);
}

View 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/);
});
});

View 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;
}

View File

@@ -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,

View File

@@ -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[];
}

View File

@@ -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';

View File

@@ -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;

View File

@@ -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). */

View File

@@ -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. */

View File

@@ -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 }[];
}

View File

@@ -8,7 +8,9 @@ Adaptadores de salida del tracker.
## Contenido
- `postgres/`: lookups de escritura (`project_keys`, `issue_sequences`, `release_names`) y `ReleaseIssuesRepository` (consulta el event store).
- `mongo/`: esquemas del read model, repositorios `Find*` y `UpsertMaterialized*` (upsert idempotente por versión).
- `mongo/`: esquemas del read model (incluida la línea temporal `issue_timeline`), repositorios `Find*` y
`UpsertMaterialized*` (upsert idempotente por versión) y `StatusCategoryBackfill` (al arrancar, rellena la categoría
del estado en los tickets proyectados antes de existir).
- `realtime/`: `RedisChangeFeed` (canales de Redis pub/sub por proyecto, ticket y usuario).
- `identity/`: `IdentityUserDirectory`, capa anticorrupción hacia el contexto de identidad.

View File

@@ -3,10 +3,7 @@
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/api/tracker/infrastructure/src",
"projectType": "library",
"tags": [
"scope:api-tracker",
"type:infrastructure"
],
"tags": ["scope:api-tracker", "type:infrastructure"],
"// targets": "to see all targets run: nx show project api-tracker-infrastructure --web",
"targets": {}
}

View File

@@ -1,11 +1,13 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import {
CrossProjectIssueFilter,
FindActivityRepository,
FindCommentsRepository,
FindIssuesRepository,
FindProjectsRepository,
FindReleasesRepository,
FindTimelineRepository,
IssueFilter,
Page,
} from '@task-stream/api/tracker/application';
@@ -13,14 +15,16 @@ import {
ActivityReadModel,
CommentReadModel,
CountBy,
FINISHED_STATUSES,
defaultProjectSettings,
IssueReadModel,
IssueStatus,
IssueTimelineEntry,
ProjectReadModel,
ProjectRole,
ProjectStatsReadModel,
ReleaseProgressReadModel,
ReleaseReadModel,
ReleaseStatus,
StatusCategory,
UserRefReadModel,
} from '@task-stream/api/tracker/domain';
import { FilterQuery, Model } from 'mongoose';
@@ -31,6 +35,7 @@ import {
ProjectDoc,
ReadModels,
ReleaseDoc,
TimelineDoc,
fromDoc,
issueFromDoc,
} from './read-model.schemas';
@@ -40,9 +45,15 @@ import {
const escapeRegex = (text: string) =>
text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const FINISHED = [StatusCategory.DONE, StatusCategory.CANCELLED];
function toProject(doc: ProjectDoc): ProjectReadModel {
const { memberIds: _ignored, ...rest } = doc;
return fromDoc(rest);
// Projects projected before settings existed use the defaults.
return {
...fromDoc(rest),
settings: rest.settings ?? defaultProjectSettings(),
};
}
@Injectable()
@@ -123,6 +134,29 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
return { items: docs.map(issueFromDoc), total };
}
async searchAcross(
filter: CrossProjectIssueFilter,
): Promise<Page<IssueReadModel>> {
if (filter.projectIds.length === 0) return { items: [], total: 0 };
const query: Record<string, unknown> = {
projectId: { $in: filter.projectIds },
};
if (filter.assigneeId) query['assignee.id'] = filter.assigneeId;
if (filter.statuses?.length) query['status'] = { $in: filter.statuses };
if (filter.categories?.length)
query['statusCategory'] = { $in: filter.categories };
const [docs, total] = await Promise.all([
this.issues
.find(query)
.sort({ updatedAt: -1 })
.skip(filter.offset)
.limit(filter.limit)
.lean<IssueDoc[]>(),
this.issues.countDocuments(query),
]);
return { items: docs.map(issueFromDoc), total };
}
async stats(projectId: string): Promise<ProjectStatsReadModel> {
const countBy = (field: string) => [
{ $group: { _id: `$${field}`, count: { $sum: 1 } } },
@@ -131,7 +165,7 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
const [facets] = await this.issues.aggregate<{
total: { count: number }[];
open: { count: number }[];
byStatus: { _id: IssueStatus; count: number }[];
byStatus: { _id: string; count: number }[];
byType: { _id: string; count: number }[];
byPriority: { _id: string; count: number }[];
byArea: { _id: string | null; count: number }[];
@@ -142,7 +176,7 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
$facet: {
total: [{ $count: 'count' }],
open: [
{ $match: { status: { $nin: FINISHED_STATUSES } } },
{ $match: { statusCategory: { $nin: FINISHED } } },
{ $count: 'count' },
],
byStatus: countBy('status'),
@@ -150,7 +184,7 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
byPriority: countBy('priority'),
byArea: countBy('area'),
byAssignee: [
{ $match: { status: { $nin: FINISHED_STATUSES } } },
{ $match: { statusCategory: { $nin: FINISHED } } },
...countBy('assignee'),
],
},
@@ -173,23 +207,36 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
};
}
async findByProject(projectId: string): Promise<IssueReadModel[]> {
const docs = await this.issues.find({ projectId }).lean<IssueDoc[]>();
return docs.map(issueFromDoc);
}
async releaseProgress(releaseId: string): Promise<ReleaseProgressReadModel> {
const rows = await this.issues.aggregate<{
_id: IssueStatus;
_id: { status: string; category: StatusCategory };
count: number;
}>([
{ $match: { 'release.id': releaseId } },
{ $group: { _id: '$status', count: { $sum: 1 } } },
{
$group: {
_id: { status: '$status', category: '$statusCategory' },
count: { $sum: 1 },
},
},
]);
const total = rows.reduce((sum, r) => sum + r.count, 0);
const finished = rows
.filter((r) => FINISHED_STATUSES.includes(r._id))
.filter((r) => FINISHED.includes(r._id.category))
.reduce((sum, r) => sum + r.count, 0);
const byStatus = new Map<string, number>();
for (const r of rows)
byStatus.set(r._id.status, (byStatus.get(r._id.status) ?? 0) + r.count);
return {
total,
finished,
percent: total === 0 ? 0 : Math.round((finished / total) * 100),
byStatus: rows.map((r) => ({ key: r._id, count: r.count })),
byStatus: [...byStatus].map(([key, count]) => ({ key, count })),
};
}
}
@@ -197,6 +244,8 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
function toMongoFilter(filter: IssueFilter): FilterQuery<IssueDoc> {
const query: FilterQuery<IssueDoc> = { projectId: filter.projectId };
if (filter.statuses?.length) query.status = { $in: filter.statuses };
if (filter.categories?.length)
query['statusCategory'] = { $in: filter.categories };
if (filter.types?.length) query['type'] = { $in: filter.types };
if (filter.areas?.length) query['area'] = { $in: filter.areas };
if (filter.priorities?.length) query['priority'] = { $in: filter.priorities };
@@ -273,6 +322,25 @@ export class MongoFindReleasesRepository extends FindReleasesRepository {
.lean<ReleaseDoc[]>();
return docs.map(fromDoc);
}
async findUpcoming(
projectIds: string[],
limit: number,
): Promise<ReleaseReadModel[]> {
if (projectIds.length === 0) return [];
const docs = await this.releases
.find({ projectId: { $in: projectIds }, status: ReleaseStatus.PLANNED })
.lean<ReleaseDoc[]>();
// Ascending targetDate puts nulls first in Mongo: sort here instead.
return docs
.map(fromDoc)
.sort(
(a, b) =>
(a.targetDate ?? '9999').localeCompare(b.targetDate ?? '9999') ||
a.name.localeCompare(b.name),
)
.slice(0, limit);
}
}
@Injectable()
@@ -301,3 +369,21 @@ export class MongoFindActivityRepository extends FindActivityRepository {
return docs.map(fromDoc);
}
}
@Injectable()
export class MongoFindTimelineRepository extends FindTimelineRepository {
constructor(
@InjectModel(ReadModels.Timeline)
private readonly timeline: Model<TimelineDoc>,
) {
super();
}
async findByProject(projectId: string): Promise<IssueTimelineEntry[]> {
const docs = await this.timeline
.find({ projectId })
.sort({ at: 1 })
.lean<TimelineDoc[]>();
return docs.map(fromDoc);
}
}

View File

@@ -3,6 +3,7 @@ import {
CommentReadModel,
IssueLinkReadModel,
IssueReadModel,
IssueTimelineEntry,
ProjectReadModel,
ReleaseReadModel,
} from '@task-stream/api/tracker/domain';
@@ -21,6 +22,7 @@ export type IssueDoc = Doc<Omit<IssueReadModel, 'links'>> & {
export type CommentDoc = Doc<CommentReadModel>;
export type ReleaseDoc = Doc<ReleaseReadModel>;
export type ActivityDoc = Doc<ActivityReadModel>;
export type TimelineDoc = Doc<IssueTimelineEntry>;
export const ReadModels = {
Project: 'ProjectReadModel',
@@ -28,6 +30,7 @@ export const ReadModels = {
Comment: 'CommentReadModel',
Release: 'ReleaseReadModel',
Activity: 'ActivityReadModel',
Timeline: 'IssueTimelineReadModel',
} as const;
const userRef = { _id: false, id: String, name: String };
@@ -59,6 +62,7 @@ export const IssueSchema = new Schema<IssueDoc>(
projectId: { type: String, index: true },
key: { type: String, unique: true },
status: String,
statusCategory: String,
reporter: userRef,
assignee: userRef,
release: { _id: false, id: String, name: String },
@@ -70,6 +74,7 @@ export const IssueSchema = new Schema<IssueDoc>(
{ ...loose, collection: 'issues_view' },
);
IssueSchema.index({ projectId: 1, status: 1, updatedAt: -1 });
IssueSchema.index({ projectId: 1, statusCategory: 1 });
IssueSchema.index({ 'assignee.id': 1 });
IssueSchema.index({ 'release.id': 1 });
IssueSchema.index({ title: 'text', description: 'text', key: 'text' });
@@ -102,12 +107,20 @@ export const ActivitySchema = new Schema<ActivityDoc>(
ActivitySchema.index({ projectId: 1, occurredAt: -1 });
ActivitySchema.index({ issueId: 1, occurredAt: -1 });
/** Every status and release step of every ticket (statistics). */
export const TimelineSchema = new Schema<TimelineDoc>(
{ _id: String, projectId: String, issueId: String, at: String },
{ ...loose, collection: 'issue_timeline' },
);
TimelineSchema.index({ projectId: 1, at: 1 });
export const READ_MODEL_SCHEMAS = [
{ name: ReadModels.Project, schema: ProjectSchema },
{ name: ReadModels.Issue, schema: IssueSchema },
{ name: ReadModels.Comment, schema: CommentSchema },
{ name: ReadModels.Release, schema: ReleaseSchema },
{ name: ReadModels.Activity, schema: ActivitySchema },
{ name: ReadModels.Timeline, schema: TimelineSchema },
];
export function fromDoc<T extends { _id: string }>(

View File

@@ -0,0 +1,61 @@
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { ProjectWorkflow } from '@task-stream/api/tracker/domain';
import { Model } from 'mongoose';
import { IssueDoc, ProjectDoc, ReadModels } from './read-model.schemas';
/**
* Tickets projected before statuses had categories lack `statusCategory`;
* fill it in once at startup from each project's settings (or the defaults).
*/
@Injectable()
export class StatusCategoryBackfill implements OnApplicationBootstrap {
private readonly logger = new Logger(StatusCategoryBackfill.name);
constructor(
@InjectModel(ReadModels.Issue) private readonly issues: Model<IssueDoc>,
@InjectModel(ReadModels.Project)
private readonly projects: Model<ProjectDoc>,
) {}
async onApplicationBootstrap(): Promise<void> {
const missing = { statusCategory: { $exists: false } };
const groups = await this.issues.aggregate<{
_id: { projectId: string; status: string };
}>([
{ $match: missing },
{ $group: { _id: { projectId: '$projectId', status: '$status' } } },
]);
if (groups.length === 0) return;
const projectIds = [...new Set(groups.map((g) => g._id.projectId))];
const projects = await this.projects
.find({ _id: { $in: projectIds } }, { settings: 1 })
.lean<Pick<ProjectDoc, '_id' | 'settings'>[]>();
const workflows = new Map(
projects.map((p) => [
p._id,
p.settings
? new ProjectWorkflow(p.settings)
: ProjectWorkflow.default(),
]),
);
const result = await this.issues.bulkWrite(
groups.map(({ _id: { projectId, status } }) => ({
updateMany: {
filter: { ...missing, projectId, status },
update: {
$set: {
statusCategory: (
workflows.get(projectId) ?? ProjectWorkflow.default()
).categoryOf(status),
},
},
},
})),
);
this.logger.log(
`Status category filled in on ${result.modifiedCount} tickets`,
);
}
}

View File

@@ -6,14 +6,17 @@ import {
UpsertMaterializedIssueRepository,
UpsertMaterializedProjectRepository,
UpsertMaterializedReleaseRepository,
UpsertMaterializedTimelineRepository,
} from '@task-stream/api/tracker/application';
import {
ActivityReadModel,
CommentReadModel,
IssueLinkReadModel,
IssueReadModel,
IssueTimelineEntry,
ProjectReadModel,
ReleaseReadModel,
StatusCategory,
} from '@task-stream/api/tracker/domain';
import { Model } from 'mongoose';
import {
@@ -23,6 +26,7 @@ import {
ProjectDoc,
ReadModels,
ReleaseDoc,
TimelineDoc,
} from './read-model.schemas';
import { upsertIfNewer } from './upsert-if-newer';
@@ -101,6 +105,19 @@ export class MongoUpsertMaterializedIssueRepository extends UpsertMaterializedIs
await this.issues.updateOne({ _id: issueId }, { $set: { commentCount } });
}
async recategorize(
projectId: string,
categories: Record<string, StatusCategory>,
) {
const ops = Object.entries(categories).map(([status, category]) => ({
updateMany: {
filter: { projectId, status, statusCategory: { $ne: category } },
update: { $set: { statusCategory: category } },
},
}));
if (ops.length) await this.issues.bulkWrite(ops);
}
async renameRelease(releaseId: string, name: string) {
await this.issues.updateMany(
{ 'release.id': releaseId },
@@ -167,3 +184,21 @@ export class MongoUpsertMaterializedActivityRepository extends UpsertMaterialize
return result.upsertedCount > 0;
}
}
@Injectable()
export class MongoUpsertMaterializedTimelineRepository extends UpsertMaterializedTimelineRepository {
constructor(
@InjectModel(ReadModels.Timeline)
private readonly timeline: Model<TimelineDoc>,
) {
super();
}
async record({ id, ...fields }: IssueTimelineEntry): Promise<void> {
await this.timeline.updateOne(
{ _id: id },
{ $setOnInsert: fields },
{ upsert: true },
);
}
}

View File

@@ -9,6 +9,7 @@ import {
FindIssuesRepository,
FindProjectsRepository,
FindReleasesRepository,
FindTimelineRepository,
IssueNumbersRepository,
ProjectKeysRepository,
ReleaseIssuesRepository,
@@ -18,6 +19,7 @@ import {
UpsertMaterializedIssueRepository,
UpsertMaterializedProjectRepository,
UpsertMaterializedReleaseRepository,
UpsertMaterializedTimelineRepository,
UserDirectory,
} from '@task-stream/api/tracker/application';
import { IdentityUserDirectory } from './identity/identity-user-directory';
@@ -27,6 +29,7 @@ import {
MongoFindIssuesRepository,
MongoFindProjectsRepository,
MongoFindReleasesRepository,
MongoFindTimelineRepository,
} from './mongo/find.repositories';
import { READ_MODEL_SCHEMAS } from './mongo/read-model.schemas';
import {
@@ -35,7 +38,9 @@ import {
MongoUpsertMaterializedIssueRepository,
MongoUpsertMaterializedProjectRepository,
MongoUpsertMaterializedReleaseRepository,
MongoUpsertMaterializedTimelineRepository,
} from './mongo/upsert-materialized.repositories';
import { StatusCategoryBackfill } from './mongo/status-category.backfill';
import { LOOKUP_ENTITIES } from './postgres/lookup.entities';
import {
PostgresIssueNumbersRepository,
@@ -60,6 +65,7 @@ const PORTS = [
{ provide: FindCommentsRepository, useClass: MongoFindCommentsRepository },
{ provide: FindReleasesRepository, useClass: MongoFindReleasesRepository },
{ provide: FindActivityRepository, useClass: MongoFindActivityRepository },
{ provide: FindTimelineRepository, useClass: MongoFindTimelineRepository },
{
provide: UpsertMaterializedProjectRepository,
useClass: MongoUpsertMaterializedProjectRepository,
@@ -80,6 +86,10 @@ const PORTS = [
provide: UpsertMaterializedActivityRepository,
useClass: MongoUpsertMaterializedActivityRepository,
},
{
provide: UpsertMaterializedTimelineRepository,
useClass: MongoUpsertMaterializedTimelineRepository,
},
// Realtime → Redis pub/sub
{ provide: ChangeFeed, useClass: RedisChangeFeed },
// Identity context (anti-corruption layer)
@@ -93,7 +103,7 @@ const PORTS = [
TypeOrmModule.forFeature(LOOKUP_ENTITIES),
MongooseModule.forFeature(READ_MODEL_SCHEMAS),
],
providers: PORTS,
providers: [...PORTS, StatusCategoryBackfill],
exports: PORTS.map((port) => port.provide),
})
export class TrackerInfrastructureModule {}

View File

@@ -10,7 +10,9 @@ API GraphQL y REST (OpenAPI) del tracker.
- Resolvers `ProjectResolver`, `IssueResolver`, `CommentResolver`, `ReleaseResolver` y `SubscriptionResolver`; solo llaman a services.
- Tipos, inputs (con `class-validator`) y enums de GraphQL (code-first).
- `http/`: controladores REST `ProjectsController`, `IssuesController`, `CommentsController`, `ReleasesController`
(mismos services), DTOs con `@nestjs/swagger` y `Refs` (proyectos y tickets por id o clave).
(mismos services), DTOs con `@nestjs/swagger` y `Refs` (proyectos y tickets por id o clave; añade a cada ticket sus
transiciones permitidas según el workflow de su proyecto).
- `common/settings.ts`: configuración del proyecto con las transiciones como lista (lo que viaja por GraphQL y REST).
Operaciones disponibles: [GraphQL](../../../../docs/03-api.md#32-graphql) y
[REST](../../../../docs/03-api.md#33-rest-con-openapi-para-otras-apps).

View File

@@ -3,10 +3,7 @@
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/api/tracker/presenters/src",
"projectType": "library",
"tags": [
"scope:api-tracker",
"type:presenters"
],
"tags": ["scope:api-tracker", "type:presenters"],
"// targets": "to see all targets run: nx show project api-tracker-presenters --web",
"targets": {}
}

View File

@@ -0,0 +1,56 @@
import {
ProjectSettings,
SettingColor,
StatusCategory,
TypeIcon,
} from '@task-stream/api/tracker/domain';
/** Settings as the APIs carry them: transitions as a list instead of a map. */
export interface SettingsPayload {
statuses: {
id: string;
name: string;
category: StatusCategory;
color: string;
requiresReason: boolean;
archived: boolean;
}[];
transitions: { from: string; to: string[] }[];
issueTypes: {
id: string;
name: string;
icon: string;
color: string;
archived: boolean;
}[];
areas: { id: string; name: string; archived: boolean }[];
}
export function toSettingsPayload(settings: ProjectSettings): SettingsPayload {
return {
...settings,
transitions: settings.statuses.map((s) => ({
from: s.id,
to: settings.transitions[s.id] ?? [],
})),
};
}
/** Colours and icons are checked by the domain (validateProjectSettings). */
export function fromSettingsPayload(payload: SettingsPayload): ProjectSettings {
return {
statuses: payload.statuses.map((s) => ({
...s,
color: s.color as SettingColor,
})),
transitions: Object.fromEntries(
payload.transitions.map((t) => [t.from, t.to]),
),
issueTypes: payload.issueTypes.map((t) => ({
...t,
icon: t.icon as TypeIcon,
color: t.color as SettingColor,
})),
areas: payload.areas,
};
}

View File

@@ -1,12 +1,10 @@
import { registerEnumType } from '@nestjs/graphql';
import {
IssueArea,
IssueLinkType,
IssuePriority,
IssueStatus,
IssueType,
ProjectRole,
ReleaseStatus,
StatusCategory,
} from '@task-stream/api/tracker/domain';
export enum LinkDirection {
@@ -14,13 +12,12 @@ export enum LinkDirection {
INWARD = 'INWARD',
}
registerEnumType(IssueType, { name: 'IssueType' });
registerEnumType(IssueArea, {
name: 'IssueArea',
description: 'Team/layer: links FE ↔ BE ↔ QA work',
});
registerEnumType(IssuePriority, { name: 'IssuePriority' });
registerEnumType(IssueStatus, { name: 'IssueStatus' });
registerEnumType(StatusCategory, {
name: 'StatusCategory',
description:
'What a status means, whatever each project calls it. DONE and CANCELLED are finished.',
});
registerEnumType(IssueLinkType, { name: 'IssueLinkType' });
registerEnumType(ProjectRole, { name: 'ProjectRole' });
registerEnumType(ReleaseStatus, { name: 'ReleaseStatus' });

View File

@@ -1,14 +1,14 @@
import { Field, ID, InputType, Int } from '@nestjs/graphql';
import {
IssueArea,
IssueLinkType,
IssuePriority,
IssueStatus,
IssueType,
ProjectRole,
StatusCategory,
} from '@task-stream/api/tracker/domain';
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
IsArray,
IsBoolean,
IsEnum,
IsInt,
@@ -22,8 +22,12 @@ import {
MaxLength,
Min,
MinLength,
ValidateNested,
} from 'class-validator';
/** Ids of statuses, types and areas (checked against the project's settings). */
const SETTING_ID = /^[A-Za-z][A-Za-z0-9_]{0,29}$/;
@InputType()
export class CreateProjectInput {
@Field({ description: '2-10 chars, e.g. "TS" → tickets TS-1, TS-2...' })
@@ -74,10 +78,10 @@ abstract class IssueFieldsInput {
@MaxLength(50000)
description?: string | null;
@Field(() => IssueArea, { nullable: true })
@Field(() => String, { nullable: true, description: 'An area id' })
@IsOptional()
@IsEnum(IssueArea)
area?: IssueArea | null;
@Matches(SETTING_ID)
area?: string | null;
@Field(() => IssuePriority, { nullable: true })
@IsOptional()
@@ -114,7 +118,9 @@ abstract class IssueFieldsInput {
@InputType()
export class CreateIssueInput extends IssueFieldsInput {
@Field(() => ID) @IsUUID() projectId!: string;
@Field(() => IssueType) @IsEnum(IssueType) type!: IssueType;
@Field({ description: 'A ticket type id' })
@Matches(SETTING_ID)
type!: string;
@Field() @IsString() @MinLength(1) @MaxLength(255) title!: string;
@Field(() => ID, { nullable: true }) @IsOptional() @IsString() assigneeId?:
| string
@@ -127,10 +133,10 @@ export class CreateIssueInput extends IssueFieldsInput {
@InputType()
export class UpdateIssueInput extends IssueFieldsInput {
@Field(() => ID) @IsUUID() issueId!: string;
@Field(() => IssueType, { nullable: true })
@Field({ nullable: true })
@IsOptional()
@IsEnum(IssueType)
type?: IssueType;
@Matches(SETTING_ID)
type?: string;
@Field({ nullable: true })
@IsOptional()
@IsString()
@@ -142,10 +148,12 @@ export class UpdateIssueInput extends IssueFieldsInput {
@InputType()
export class ChangeIssueStatusInput {
@Field(() => ID) @IsUUID() issueId!: string;
@Field(() => IssueStatus) @IsEnum(IssueStatus) status!: IssueStatus;
@Field({ description: 'A status id' })
@Matches(SETTING_ID)
status!: string;
@Field(() => String, {
nullable: true,
description: 'Required for QA_FAILED and CANCELLED',
description: 'Required by statuses with requiresReason',
})
@IsOptional()
@IsString()
@@ -163,18 +171,22 @@ export class LinkIssuesInput {
@InputType()
export class IssueFilterInput {
@Field(() => ID) @IsUUID() projectId!: string;
@Field(() => [IssueStatus], { nullable: true })
@Field(() => [String], { nullable: true })
@IsOptional()
@IsEnum(IssueStatus, { each: true })
statuses?: IssueStatus[];
@Field(() => [IssueType], { nullable: true })
@IsString({ each: true })
statuses?: string[];
@Field(() => [StatusCategory], { nullable: true })
@IsOptional()
@IsEnum(IssueType, { each: true })
types?: IssueType[];
@Field(() => [IssueArea], { nullable: true })
@IsEnum(StatusCategory, { each: true })
categories?: StatusCategory[];
@Field(() => [String], { nullable: true })
@IsOptional()
@IsEnum(IssueArea, { each: true })
areas?: IssueArea[];
@IsString({ each: true })
types?: string[];
@Field(() => [String], { nullable: true })
@IsOptional()
@IsString({ each: true })
areas?: string[];
@Field(() => [IssuePriority], { nullable: true })
@IsOptional()
@IsEnum(IssuePriority, { each: true })
@@ -237,3 +249,77 @@ export class UpdateReleaseInput {
@IsISO8601()
targetDate?: string | null;
}
/* Project settings (owners and admins). Values are archived, never deleted. */
@InputType()
export class WorkflowStatusInput {
@Field() @Matches(SETTING_ID) id!: string;
@Field() @IsString() @MinLength(1) @MaxLength(40) name!: string;
@Field(() => StatusCategory)
@IsEnum(StatusCategory)
category!: StatusCategory;
@Field() @IsString() color!: string;
@Field({ defaultValue: false }) @IsBoolean() requiresReason!: boolean;
@Field({ defaultValue: false }) @IsBoolean() archived!: boolean;
}
@InputType()
export class StatusTransitionsInput {
@Field() @Matches(SETTING_ID) from!: string;
@Field(() => [String])
@IsArray()
@ArrayMaxSize(50)
@Matches(SETTING_ID, { each: true })
to!: string[];
}
@InputType()
export class IssueTypeOptionInput {
@Field() @Matches(SETTING_ID) id!: string;
@Field() @IsString() @MinLength(1) @MaxLength(40) name!: string;
@Field() @IsString() icon!: string;
@Field() @IsString() color!: string;
@Field({ defaultValue: false }) @IsBoolean() archived!: boolean;
}
@InputType()
export class AreaOptionInput {
@Field() @Matches(SETTING_ID) id!: string;
@Field() @IsString() @MinLength(1) @MaxLength(40) name!: string;
@Field({ defaultValue: false }) @IsBoolean() archived!: boolean;
}
@InputType({
description:
'The whole configuration (replaces the previous one). Statuses go in board order.',
})
export class ProjectSettingsInput {
@Field(() => [WorkflowStatusInput])
@IsArray()
@ArrayMaxSize(50)
@ValidateNested({ each: true })
@Type(() => WorkflowStatusInput)
statuses!: WorkflowStatusInput[];
@Field(() => [StatusTransitionsInput])
@IsArray()
@ArrayMaxSize(50)
@ValidateNested({ each: true })
@Type(() => StatusTransitionsInput)
transitions!: StatusTransitionsInput[];
@Field(() => [IssueTypeOptionInput])
@IsArray()
@ArrayMaxSize(50)
@ValidateNested({ each: true })
@Type(() => IssueTypeOptionInput)
issueTypes!: IssueTypeOptionInput[];
@Field(() => [AreaOptionInput])
@IsArray()
@ArrayMaxSize(50)
@ValidateNested({ each: true })
@Type(() => AreaOptionInput)
areas!: AreaOptionInput[];
}

View File

@@ -13,11 +13,11 @@ import { CurrentUser } from '@task-stream/api/identity/presenters';
import {
CommentsService,
IssuesService,
WorkflowsService,
} from '@task-stream/api/tracker/application';
import {
IssueReadModel,
IssueStatus,
IssueWorkflow,
StatusCategory,
} from '@task-stream/api/tracker/domain';
import {
ChangeIssueStatusInput,
@@ -40,6 +40,7 @@ export class IssueResolver {
constructor(
private readonly issuesService: IssuesService,
private readonly commentsService: CommentsService,
private readonly workflows: WorkflowsService,
) {}
@Query(() => IssueGql)
@@ -61,6 +62,29 @@ export class IssueResolver {
return this.issuesService.search(me.id, { ...filter, limit, offset });
}
@Query(() => IssuePageGql, {
description:
'Tickets across all my projects: assigned to me (default) or any (e.g. categories [QA])',
})
myIssues(
@CurrentUser() me: User,
@Args('assignedToMe', { defaultValue: true }) assignedToMe: boolean,
@Args('statuses', { type: () => [String], nullable: true })
statuses: string[] | undefined,
@Args('categories', { type: () => [StatusCategory], nullable: true })
categories: StatusCategory[] | undefined,
@Args('limit', { type: () => Int, defaultValue: 50 }) limit: number,
@Args('offset', { type: () => Int, defaultValue: 0 }) offset: number,
) {
return this.issuesService.mine(me.id, {
assignedToMe,
statuses: statuses ?? undefined,
categories: categories ?? undefined,
limit,
offset,
});
}
@Query(() => [ActivityGql], {
description: 'Project activity feed (newest first)',
})
@@ -80,11 +104,11 @@ export class IssueResolver {
});
}
@ResolveField(() => [IssueStatus], {
description: 'Statuses this ticket can move to next',
@ResolveField(() => [String], {
description: "Statuses this ticket can move to next (project's workflow)",
})
allowedTransitions(@Parent() issue: IssueReadModel): readonly IssueStatus[] {
return IssueWorkflow.allowedFrom(issue.status);
allowedTransitions(@Parent() issue: IssueReadModel): Promise<string[]> {
return this.workflows.allowedTransitions(issue);
}
@ResolveField(() => [IssueGql], { description: 'Linked tickets I can see' })

View File

@@ -11,16 +11,20 @@ import {
import { User } from '@task-stream/api/identity/domain';
import { AccessToken, CurrentUser } from '@task-stream/api/identity/presenters';
import { ProjectsService } from '@task-stream/api/tracker/application';
import { IssueArea, ProjectReadModel } from '@task-stream/api/tracker/domain';
import { ProjectReadModel } from '@task-stream/api/tracker/domain';
import { fromSettingsPayload, toSettingsPayload } from '../common/settings';
import {
CreateProjectInput,
ProjectMemberInput,
ProjectSettingsInput,
UpdateProjectInput,
} from './inputs';
import {
BoardColumnGql,
CommandResultGql,
ProjectAnalyticsGql,
ProjectGql,
ProjectSettingsGql,
ProjectStatsGql,
ReleaseGql,
} from './types';
@@ -43,6 +47,23 @@ export class ProjectResolver {
return this.projects.findOne(me.id, idOrKey);
}
@ResolveField(() => ProjectSettingsGql)
settings(@Parent() project: ProjectReadModel) {
return toSettingsPayload(project.settings);
}
@ResolveField(() => ProjectAnalyticsGql, {
description:
'Statistics of the last `days` days (7-365): created vs resolved, cumulative flow, cycle/lead time, throughput and QA',
})
analytics(
@CurrentUser() me: User,
@Parent() project: ProjectReadModel,
@Args('days', { type: () => Int, defaultValue: 90 }) days: number,
) {
return this.projects.analytics(me.id, project.id, days);
}
@ResolveField(() => [ReleaseGql])
releases(@CurrentUser() me: User, @Parent() project: ProjectReadModel) {
return this.projects.releases(me.id, project.id);
@@ -63,7 +84,7 @@ export class ProjectResolver {
@Parent() project: ProjectReadModel,
@Args('releaseId', { type: () => ID, nullable: true }) releaseId?: string,
@Args('assigneeId', { type: () => ID, nullable: true }) assigneeId?: string,
@Args('area', { type: () => IssueArea, nullable: true }) area?: IssueArea,
@Args('area', { type: () => String, nullable: true }) area?: string,
@Args('perColumn', { type: () => Int, defaultValue: 50 }) perColumn = 50,
) {
return this.projects.board(
@@ -95,6 +116,22 @@ export class ProjectResolver {
return this.projects.update(me.id, projectId, changes);
}
@Mutation(() => CommandResultGql, {
description:
'Replace statuses, transitions, ticket types and areas (owners and admins). Values can be archived, not deleted.',
})
updateProjectSettings(
@CurrentUser() me: User,
@Args('projectId', { type: () => ID }) projectId: string,
@Args('settings') settings: ProjectSettingsInput,
) {
return this.projects.updateSettings(
me.id,
projectId,
fromSettingsPayload(settings),
);
}
@Mutation(() => CommandResultGql, {
description: 'Add a Pireon user to the project',
})

View File

@@ -19,6 +19,7 @@ import { CreateReleaseInput, UpdateReleaseInput } from './inputs';
import {
CommandResultGql,
IssuePageGql,
ReleaseBurnupGql,
ReleaseGql,
ReleaseProgressGql,
} from './types';
@@ -35,11 +36,29 @@ export class ReleaseResolver {
return this.releases.findOne(me.id, id);
}
@Query(() => [ReleaseGql], {
description:
'Planned releases of all my projects, soonest target date first',
})
upcomingReleases(
@CurrentUser() me: User,
@Args('limit', { type: () => Int, defaultValue: 10 }) limit: number,
) {
return this.releases.upcoming(me.id, limit);
}
@ResolveField(() => ReleaseProgressGql)
progress(@CurrentUser() me: User, @Parent() release: ReleaseReadModel) {
return this.releases.progress(me.id, release.id);
}
@ResolveField(() => ReleaseBurnupGql, {
description: 'Scope vs finished tickets, day by day',
})
burnup(@CurrentUser() me: User, @Parent() release: ReleaseReadModel) {
return this.releases.burnup(me.id, release.id);
}
@ResolveField(() => IssuePageGql, {
description: 'Tickets included in this release',
})

View File

@@ -1,12 +1,10 @@
import { Field, ID, Int, ObjectType } from '@nestjs/graphql';
import { Field, Float, ID, Int, ObjectType } from '@nestjs/graphql';
import {
IssueArea,
IssueLinkType,
IssuePriority,
IssueStatus,
IssueType,
ProjectRole,
ReleaseStatus,
StatusCategory,
} from '@task-stream/api/tracker/domain';
import { LinkDirection } from './enums';
@@ -22,6 +20,51 @@ export class ProjectMemberGql {
@Field(() => ProjectRole) role!: ProjectRole;
}
@ObjectType('WorkflowStatus')
export class WorkflowStatusGql {
@Field({ description: 'Stable id stored on tickets, e.g. IN_REVIEW' })
id!: string;
@Field() name!: string;
@Field(() => StatusCategory) category!: StatusCategory;
@Field({ description: 'Colour token: slate, violet, green…' }) color!: string;
@Field({ description: 'Entering it needs a reason' })
requiresReason!: boolean;
@Field({ description: 'No new tickets or transitions into it' })
archived!: boolean;
}
@ObjectType('StatusTransitions')
export class StatusTransitionsGql {
@Field() from!: string;
@Field(() => [String]) to!: string[];
}
@ObjectType('IssueTypeOption')
export class IssueTypeOptionGql {
@Field() id!: string;
@Field() name!: string;
@Field() icon!: string;
@Field() color!: string;
@Field() archived!: boolean;
}
@ObjectType('AreaOption')
export class AreaOptionGql {
@Field() id!: string;
@Field() name!: string;
@Field() archived!: boolean;
}
@ObjectType('ProjectSettings', {
description: 'Statuses (board order), transitions, ticket types and areas',
})
export class ProjectSettingsGql {
@Field(() => [WorkflowStatusGql]) statuses!: WorkflowStatusGql[];
@Field(() => [StatusTransitionsGql]) transitions!: StatusTransitionsGql[];
@Field(() => [IssueTypeOptionGql]) issueTypes!: IssueTypeOptionGql[];
@Field(() => [AreaOptionGql]) areas!: AreaOptionGql[];
}
@ObjectType('Project')
export class ProjectGql {
@Field(() => ID) id!: string;
@@ -59,11 +102,15 @@ export class IssueGql {
@Field() projectKey!: string;
@Field(() => Int) number!: number;
@Field({ description: 'Human key, e.g. TS-42' }) key!: string;
@Field(() => IssueType) type!: IssueType;
@Field(() => IssueArea, { nullable: true }) area!: IssueArea | null;
@Field({ description: "A ticket type id of the project's settings" })
type!: string;
@Field(() => String, { nullable: true, description: 'An area id' })
area!: string | null;
@Field() title!: string;
@Field(() => String, { nullable: true }) description!: string | null;
@Field(() => IssueStatus) status!: IssueStatus;
@Field({ description: "A status id of the project's settings" })
status!: string;
@Field(() => StatusCategory) statusCategory!: StatusCategory;
@Field(() => IssuePriority) priority!: IssuePriority;
@Field(() => UserRefGql) reporter!: UserRefGql;
@Field(() => UserRefGql, { nullable: true }) assignee!: UserRefGql | null;
@@ -88,7 +135,7 @@ export class IssuePageGql {
@ObjectType('BoardColumn')
export class BoardColumnGql {
@Field(() => IssueStatus) status!: IssueStatus;
@Field() status!: string;
@Field(() => [IssueGql]) issues!: IssueGql[];
@Field(() => Int) total!: number;
}
@@ -125,7 +172,7 @@ export class ReleaseGql {
@ObjectType('CountByStatus')
export class CountByStatusGql {
@Field(() => IssueStatus) key!: IssueStatus;
@Field({ description: 'Status id' }) key!: string;
@Field(() => Int) count!: number;
}
@@ -221,3 +268,77 @@ export class ProjectChangeGql {
@Field(() => ID) actorId!: string;
@Field(() => ProjectGql) project!: ProjectGql;
}
/* Statistics */
@ObjectType('CreatedResolved')
export class CreatedResolvedGql {
@Field({ description: 'Monday of the week (YYYY-MM-DD)' }) week!: string;
@Field(() => Int) created!: number;
@Field(() => Int) resolved!: number;
}
@ObjectType('FlowSeries')
export class FlowSeriesGql {
@Field() status!: string;
@Field(() => [Int]) counts!: number[];
}
@ObjectType('CumulativeFlow')
export class CumulativeFlowGql {
@Field(() => [String]) dates!: string[];
@Field(() => [FlowSeriesGql]) series!: FlowSeriesGql[];
}
@ObjectType('DurationStats')
export class DurationStatsGql {
@Field(() => Int) count!: number;
@Field(() => Float, { nullable: true }) averageDays!: number | null;
@Field(() => Float, {
nullable: true,
description: '85% of tickets take at most this long',
})
p85Days!: number | null;
}
@ObjectType('Throughput')
export class ThroughputGql {
@Field() week!: string;
@Field(() => UserRefGql, { nullable: true }) user!: UserRefGql | null;
@Field(() => Int) count!: number;
}
@ObjectType('QaStats')
export class QaStatsGql {
@Field(() => Int) passed!: number;
@Field(() => Int) failed!: number;
@Field(() => Float, { nullable: true }) failureRate!: number | null;
}
@ObjectType('ProjectAnalytics')
export class ProjectAnalyticsGql {
@Field() from!: string;
@Field() to!: string;
@Field(() => [CreatedResolvedGql]) createdVsResolved!: CreatedResolvedGql[];
@Field(() => CumulativeFlowGql, {
description: 'Tickets in each status at the end of every day',
})
cumulativeFlow!: CumulativeFlowGql;
@Field(() => DurationStatsGql, { description: 'Start of work → done' })
cycleTime!: DurationStatsGql;
@Field(() => DurationStatsGql, { description: 'Creation → done' })
leadTime!: DurationStatsGql;
@Field(() => [ThroughputGql], { description: 'Finished per person and week' })
throughput!: ThroughputGql[];
@Field(() => QaStatsGql) qa!: QaStatsGql;
}
@ObjectType('ReleaseBurnup')
export class ReleaseBurnupGql {
@Field(() => ID) releaseId!: string;
@Field(() => String, { nullable: true }) targetDate!: string | null;
@Field(() => [String]) dates!: string[];
@Field(() => [Int], { description: 'Tickets in the release (not cancelled)' })
scope!: number[];
@Field(() => [Int]) done!: number[];
}

View File

@@ -5,16 +5,17 @@ import {
PartialType,
} from '@nestjs/swagger';
import {
IssueArea,
IssueLinkType,
IssuePriority,
IssueStatus,
IssueType,
ProjectRole,
SETTING_COLORS,
StatusCategory,
TYPE_ICONS,
} from '@task-stream/api/tracker/domain';
import { Transform, Type } from 'class-transformer';
import {
ArrayMaxSize,
IsArray,
IsBoolean,
IsEnum,
IsIn,
@@ -29,6 +30,7 @@ import {
Min,
MinLength,
ValidateIf,
ValidateNested,
} from 'class-validator';
/* REST request bodies and query strings, validated like the GraphQL inputs. */
@@ -46,6 +48,9 @@ const toList = ({ value }: { value: unknown }) =>
const toBoolean = ({ value }: { value: unknown }) =>
value === undefined ? undefined : value === true || value === 'true';
/** Ids of statuses, types and areas (checked against the project's settings). */
const SETTING_ID = /^[A-Za-z][A-Za-z0-9_]{0,29}$/;
// ---- Projects ---------------------------------------------------------------
export class CreateProjectDto {
@@ -89,6 +94,121 @@ export class AddMemberDto {
role: ProjectRole = ProjectRole.MEMBER;
}
// ---- Project settings -------------------------------------------------------
export class WorkflowStatusBodyDto {
@ApiProperty({
example: 'IN_REVIEW',
description: 'Never change an existing id',
})
@Matches(SETTING_ID)
id!: string;
@ApiProperty({ example: 'En revisión', maxLength: 40 })
@IsString()
@MinLength(1)
@MaxLength(40)
name!: string;
@ApiProperty({ enum: StatusCategory, enumName: 'StatusCategory' })
@IsEnum(StatusCategory)
category!: StatusCategory;
@ApiProperty({ enum: SETTING_COLORS, enumName: 'SettingColor' })
@IsIn(SETTING_COLORS)
color!: string;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
requiresReason = false;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
archived = false;
}
export class StatusTransitionsBodyDto {
@ApiProperty({ example: 'IN_QA' }) @Matches(SETTING_ID) from!: string;
@ApiProperty({ type: [String], example: ['DONE', 'QA_FAILED'] })
@IsArray()
@ArrayMaxSize(50)
@Matches(SETTING_ID, { each: true })
to!: string[];
}
export class IssueTypeOptionBodyDto {
@ApiProperty({ example: 'SPIKE' }) @Matches(SETTING_ID) id!: string;
@ApiProperty({ example: 'Spike', maxLength: 40 })
@IsString()
@MinLength(1)
@MaxLength(40)
name!: string;
@ApiProperty({ enum: TYPE_ICONS, enumName: 'TypeIcon' })
@IsIn(TYPE_ICONS)
icon!: string;
@ApiProperty({ enum: SETTING_COLORS, enumName: 'SettingColor' })
@IsIn(SETTING_COLORS)
color!: string;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
archived = false;
}
export class AreaOptionBodyDto {
@ApiProperty({ example: 'DATA' }) @Matches(SETTING_ID) id!: string;
@ApiProperty({ example: 'Datos', maxLength: 40 })
@IsString()
@MinLength(1)
@MaxLength(40)
name!: string;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
archived = false;
}
export class ProjectSettingsBodyDto {
@ApiProperty({
type: [WorkflowStatusBodyDto],
description:
'In board order. Values cannot be removed: archive them instead.',
})
@IsArray()
@ArrayMaxSize(50)
@ValidateNested({ each: true })
@Type(() => WorkflowStatusBodyDto)
statuses!: WorkflowStatusBodyDto[];
@ApiProperty({ type: [StatusTransitionsBodyDto] })
@IsArray()
@ArrayMaxSize(50)
@ValidateNested({ each: true })
@Type(() => StatusTransitionsBodyDto)
transitions!: StatusTransitionsBodyDto[];
@ApiProperty({ type: [IssueTypeOptionBodyDto] })
@IsArray()
@ArrayMaxSize(50)
@ValidateNested({ each: true })
@Type(() => IssueTypeOptionBodyDto)
issueTypes!: IssueTypeOptionBodyDto[];
@ApiProperty({ type: [AreaOptionBodyDto] })
@IsArray()
@ArrayMaxSize(50)
@ValidateNested({ each: true })
@Type(() => AreaOptionBodyDto)
areas!: AreaOptionBodyDto[];
}
export class AnalyticsQueryDto {
@ApiPropertyOptional({ default: 90, minimum: 7, maximum: 365 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(7)
@Max(365)
days = 90;
}
export class ChangeMemberRoleDto {
@ApiProperty({ enum: ProjectRole, enumName: 'ProjectRole' })
@IsEnum(ProjectRole)
@@ -106,10 +226,10 @@ export class BoardQueryDto {
@IsString()
assigneeId?: string;
@ApiPropertyOptional({ enum: IssueArea, enumName: 'IssueArea' })
@ApiPropertyOptional({ example: 'FRONTEND', description: 'An area id' })
@IsOptional()
@IsEnum(IssueArea)
area?: IssueArea;
@Matches(SETTING_ID)
area?: string;
@ApiPropertyOptional({ default: 50, minimum: 1, maximum: 200 })
@IsOptional()
@@ -162,37 +282,79 @@ export class PageQueryDto {
// ---- Issues -----------------------------------------------------------------
export class MyIssuesQueryDto extends PageQueryDto {
@ApiPropertyOptional({
default: true,
description:
'true: tickets assigned to me. false: any ticket of my projects (e.g. ?assignedToMe=false&category=QA)',
})
@IsOptional()
@Transform(toBoolean)
@IsBoolean()
assignedToMe = true;
@ApiPropertyOptional({
type: [String],
description: 'Status ids. Repeat the parameter or separate with commas',
})
@IsOptional()
@Transform(toList)
@Matches(SETTING_ID, { each: true })
status?: string[];
@ApiPropertyOptional({
enum: StatusCategory,
enumName: 'StatusCategory',
isArray: true,
description: 'Whatever each project calls its statuses',
})
@IsOptional()
@Transform(toList)
@IsEnum(StatusCategory, { each: true })
category?: StatusCategory[];
}
export class UpcomingReleasesQueryDto {
@ApiPropertyOptional({ default: 10, minimum: 1, maximum: 50 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(50)
limit = 10;
}
export class IssueSearchQueryDto extends PageQueryDto {
@ApiPropertyOptional({
enum: IssueStatus,
enumName: 'IssueStatus',
isArray: true,
description: 'Repeat the parameter or separate with commas',
type: [String],
description: 'Status ids. Repeat the parameter or separate with commas',
})
@IsOptional()
@Transform(toList)
@IsEnum(IssueStatus, { each: true })
status?: IssueStatus[];
@Matches(SETTING_ID, { each: true })
status?: string[];
@ApiPropertyOptional({
enum: IssueType,
enumName: 'IssueType',
enum: StatusCategory,
enumName: 'StatusCategory',
isArray: true,
})
@IsOptional()
@Transform(toList)
@IsEnum(IssueType, { each: true })
type?: IssueType[];
@IsEnum(StatusCategory, { each: true })
category?: StatusCategory[];
@ApiPropertyOptional({
enum: IssueArea,
enumName: 'IssueArea',
isArray: true,
})
@ApiPropertyOptional({ type: [String], description: 'Ticket type ids' })
@IsOptional()
@Transform(toList)
@IsEnum(IssueArea, { each: true })
area?: IssueArea[];
@Matches(SETTING_ID, { each: true })
type?: string[];
@ApiPropertyOptional({ type: [String], description: 'Area ids' })
@IsOptional()
@Transform(toList)
@Matches(SETTING_ID, { each: true })
area?: string[];
@ApiPropertyOptional({
enum: IssuePriority,
@@ -241,13 +403,14 @@ class IssueFieldsDto {
description?: string | null;
@ApiPropertyOptional({
enum: IssueArea,
type: String,
nullable: true,
example: 'FRONTEND',
description: "An area id of the project's settings",
})
@IsOptional()
@IsEnum(IssueArea)
area?: IssueArea | null;
@Matches(SETTING_ID)
area?: string | null;
@ApiPropertyOptional({ enum: IssuePriority, enumName: 'IssuePriority' })
@IsOptional()
@@ -289,9 +452,12 @@ class IssueFieldsDto {
}
export class CreateIssueDto extends IssueFieldsDto {
@ApiProperty({ enum: IssueType, enumName: 'IssueType' })
@IsEnum(IssueType)
type!: IssueType;
@ApiProperty({
example: 'BUG',
description: "A ticket type id of the project's settings",
})
@Matches(SETTING_ID)
type!: string;
@ApiProperty({
example: 'El botón de pagar no responde en Safari',
@@ -318,10 +484,10 @@ export class CreateIssueDto extends IssueFieldsDto {
}
export class UpdateIssueDto extends IssueFieldsDto {
@ApiPropertyOptional({ enum: IssueType, enumName: 'IssueType' })
@ApiPropertyOptional({ example: 'FEATURE', description: 'A ticket type id' })
@IsOptional()
@IsEnum(IssueType)
type?: IssueType;
@Matches(SETTING_ID)
type?: string;
@ApiPropertyOptional({ maxLength: 255 })
@IsOptional()
@@ -332,14 +498,18 @@ export class UpdateIssueDto extends IssueFieldsDto {
}
export class ChangeStatusDto {
@ApiProperty({ enum: IssueStatus, enumName: 'IssueStatus' })
@IsEnum(IssueStatus)
status!: IssueStatus;
@ApiProperty({
example: 'IN_REVIEW',
description:
"A status id of the project's settings, among the ticket's allowedTransitions",
})
@Matches(SETTING_ID)
status!: string;
@ApiPropertyOptional({
type: String,
nullable: true,
description: 'Required for QA_FAILED and CANCELLED',
description: 'Required by statuses with requiresReason (e.g. QA_FAILED)',
})
@IsOptional()
@IsString()

View File

@@ -1,12 +1,12 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IssueArea,
IssueLinkType,
IssuePriority,
IssueStatus,
IssueType,
ProjectRole,
ReleaseStatus,
SETTING_COLORS,
StatusCategory,
TYPE_ICONS,
} from '@task-stream/api/tracker/domain';
/*
@@ -25,6 +25,51 @@ export class ProjectMemberDto {
role!: ProjectRole;
}
export class WorkflowStatusDto {
@ApiProperty({ example: 'IN_REVIEW', description: 'Stable id on tickets' })
id!: string;
@ApiProperty({ example: 'En revisión' }) name!: string;
@ApiProperty({ enum: StatusCategory, enumName: 'StatusCategory' })
category!: StatusCategory;
@ApiProperty({ enum: SETTING_COLORS, enumName: 'SettingColor' })
color!: string;
@ApiProperty({ description: 'Entering it needs a reason' })
requiresReason!: boolean;
@ApiProperty({ description: 'No new tickets or transitions into it' })
archived!: boolean;
}
export class StatusTransitionsDto {
@ApiProperty({ example: 'IN_QA' }) from!: string;
@ApiProperty({ type: [String], example: ['DONE', 'QA_FAILED'] })
to!: string[];
}
export class IssueTypeOptionDto {
@ApiProperty({ example: 'BUG' }) id!: string;
@ApiProperty({ example: 'Bug' }) name!: string;
@ApiProperty({ enum: TYPE_ICONS, enumName: 'TypeIcon' }) icon!: string;
@ApiProperty({ enum: SETTING_COLORS, enumName: 'SettingColor' })
color!: string;
@ApiProperty() archived!: boolean;
}
export class AreaOptionDto {
@ApiProperty({ example: 'FRONTEND' }) id!: string;
@ApiProperty({ example: 'Frontend' }) name!: string;
@ApiProperty() archived!: boolean;
}
export class ProjectSettingsDto {
@ApiProperty({ type: [WorkflowStatusDto], description: 'In board order' })
statuses!: WorkflowStatusDto[];
@ApiProperty({ type: [StatusTransitionsDto] })
transitions!: StatusTransitionsDto[];
@ApiProperty({ type: [IssueTypeOptionDto] })
issueTypes!: IssueTypeOptionDto[];
@ApiProperty({ type: [AreaOptionDto] }) areas!: AreaOptionDto[];
}
export class ProjectDto {
@ApiProperty({ format: 'uuid' }) id!: string;
@ApiProperty({ example: 'WEB', description: 'Prefix of the ticket keys' })
@@ -33,6 +78,8 @@ export class ProjectDto {
@ApiProperty({ type: String, nullable: true }) description!: string | null;
@ApiProperty({ type: UserRefDto }) owner!: UserRefDto;
@ApiProperty({ type: [ProjectMemberDto] }) members!: ProjectMemberDto[];
@ApiProperty({ type: ProjectSettingsDto })
settings!: ProjectSettingsDto;
@ApiProperty({ format: 'date-time' }) createdAt!: string;
@ApiProperty({ format: 'date-time' }) updatedAt!: string;
@ApiProperty({ description: 'Version of the aggregate (event count)' })
@@ -63,20 +110,21 @@ export class IssueDto {
@ApiProperty({ example: 'WEB' }) projectKey!: string;
@ApiProperty({ example: 12 }) number!: number;
@ApiProperty({ example: 'WEB-12', description: 'Human key' }) key!: string;
@ApiProperty({ enum: IssueType, enumName: 'IssueType' }) type!: IssueType;
@ApiProperty({ enum: IssueArea, type: String, nullable: true })
area!: IssueArea | null;
@ApiProperty({ example: 'BUG', description: 'A ticket type id' })
type!: string;
@ApiProperty({ type: String, nullable: true, example: 'FRONTEND' })
area!: string | null;
@ApiProperty() title!: string;
@ApiProperty({ type: String, nullable: true }) description!: string | null;
@ApiProperty({ enum: IssueStatus, enumName: 'IssueStatus' })
status!: IssueStatus;
@ApiProperty({ example: 'IN_PROGRESS', description: 'A status id' })
status!: string;
@ApiProperty({ enum: StatusCategory, enumName: 'StatusCategory' })
statusCategory!: StatusCategory;
@ApiProperty({
enum: IssueStatus,
enumName: 'IssueStatus',
isArray: true,
description: 'Statuses this ticket can move to next',
type: [String],
description: "Statuses this ticket can move to next (project's workflow)",
})
allowedTransitions!: IssueStatus[];
allowedTransitions!: string[];
@ApiProperty({ enum: IssuePriority, enumName: 'IssuePriority' })
priority!: IssuePriority;
@ApiProperty({ type: UserRefDto }) reporter!: UserRefDto;
@@ -104,8 +152,7 @@ export class IssuePageDto {
}
export class BoardColumnDto {
@ApiProperty({ enum: IssueStatus, enumName: 'IssueStatus' })
status!: IssueStatus;
@ApiProperty({ example: 'IN_PROGRESS' }) status!: string;
@ApiProperty({ type: [IssueDto] }) issues!: IssueDto[];
@ApiProperty({ description: 'Tickets in the column (may exceed `issues`)' })
total!: number;
@@ -143,8 +190,8 @@ export class ReleaseDto {
}
export class CountByStatusDto {
@ApiProperty({ enum: IssueStatus, enumName: 'IssueStatus' })
key!: IssueStatus;
@ApiProperty({ example: 'IN_PROGRESS', description: 'Status id' })
key!: string;
@ApiProperty() count!: number;
}
@@ -213,3 +260,78 @@ export class CommandResultDto {
})
version!: number;
}
// ---- Statistics ---------------------------------------------------------------
export class CreatedResolvedDto {
@ApiProperty({ format: 'date', description: 'Monday of the week' })
week!: string;
@ApiProperty() created!: number;
@ApiProperty() resolved!: number;
}
export class FlowSeriesDto {
@ApiProperty({ example: 'IN_PROGRESS' }) status!: string;
@ApiProperty({ type: [Number], description: 'One value per date' })
counts!: number[];
}
export class CumulativeFlowDto {
@ApiProperty({ type: [String], format: 'date' }) dates!: string[];
@ApiProperty({ type: [FlowSeriesDto] }) series!: FlowSeriesDto[];
}
export class DurationStatsDto {
@ApiProperty() count!: number;
@ApiProperty({ type: Number, nullable: true }) averageDays!: number | null;
@ApiProperty({
type: Number,
nullable: true,
description: '85% of tickets take at most this long',
})
p85Days!: number | null;
}
export class ThroughputDto {
@ApiProperty({ format: 'date' }) week!: string;
@ApiProperty({ type: UserRefDto, nullable: true }) user!: UserRefDto | null;
@ApiProperty() count!: number;
}
export class QaStatsDto {
@ApiProperty({ description: 'Left QA to a done status' }) passed!: number;
@ApiProperty({ description: 'Left QA back to work' }) failed!: number;
@ApiProperty({ type: Number, nullable: true, minimum: 0, maximum: 1 })
failureRate!: number | null;
}
export class ProjectAnalyticsDto {
@ApiProperty({ format: 'date-time' }) from!: string;
@ApiProperty({ format: 'date-time' }) to!: string;
@ApiProperty({ type: [CreatedResolvedDto] })
createdVsResolved!: CreatedResolvedDto[];
@ApiProperty({
type: CumulativeFlowDto,
description: 'Tickets in each status at the end of every day',
})
cumulativeFlow!: CumulativeFlowDto;
@ApiProperty({ type: DurationStatsDto, description: 'Start of work → done' })
cycleTime!: DurationStatsDto;
@ApiProperty({ type: DurationStatsDto, description: 'Creation → done' })
leadTime!: DurationStatsDto;
@ApiProperty({ type: [ThroughputDto] }) throughput!: ThroughputDto[];
@ApiProperty({ type: QaStatsDto }) qa!: QaStatsDto;
}
export class ReleaseBurnupDto {
@ApiProperty({ format: 'uuid' }) releaseId!: string;
@ApiProperty({ type: String, nullable: true, format: 'date' })
targetDate!: string | null;
@ApiProperty({ type: [String], format: 'date' }) dates!: string[];
@ApiProperty({
type: [Number],
description: 'Tickets in the release (not cancelled)',
})
scope!: number[];
@ApiProperty({ type: [Number] }) done!: number[];
}

View File

@@ -39,6 +39,7 @@ import {
IssueSearchQueryDto,
LinkIssueDto,
LinkTypeParamDto,
MyIssuesQueryDto,
PageQueryDto,
SetIssueReleaseDto,
UpdateIssueDto,
@@ -50,7 +51,7 @@ import {
IssueDto,
IssuePageDto,
} from './dto/responses.dto';
import { Refs, toIssueDto } from './refs';
import { Refs } from './refs';
const PROJECT = ApiParam({
name: 'project',
@@ -74,6 +75,25 @@ export class IssuesController {
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' })
@@ -87,6 +107,7 @@ export class IssuesController {
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,
@@ -98,7 +119,7 @@ export class IssuesController {
limit: query.limit,
offset: query.offset,
});
return { ...page, items: page.items.map(toIssueDto) };
return { ...page, items: await this.refs.issueDtos(page.items) };
}
@Post('projects/:project/issues')
@@ -106,7 +127,7 @@ export class IssuesController {
@ApiOperation({
summary: 'Create a ticket',
description:
'Starts in BACKLOG. The response carries its key (e.g. WEB-13).',
"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()
@@ -127,7 +148,7 @@ export class IssuesController {
@ApiOkResponse({ type: IssueDto })
@ApiReadErrors()
async findOne(@CurrentUser() me: User, @Param('issue') issue: string) {
return toIssueDto(await this.issues.findOne(me.id, issue));
return this.refs.issueDto(await this.issues.findOne(me.id, issue));
}
@Patch('issues/:issue')

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)' })

View File

@@ -2,8 +2,13 @@ import { Injectable } from '@nestjs/common';
import {
IssuesService,
ProjectsService,
WorkflowsService,
} from '@task-stream/api/tracker/application';
import { IssueReadModel, IssueWorkflow } from '@task-stream/api/tracker/domain';
import {
IssueReadModel,
ProjectReadModel,
} from '@task-stream/api/tracker/domain';
import { toSettingsPayload } from '../common/settings';
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -17,6 +22,7 @@ export class Refs {
constructor(
private readonly projects: ProjectsService,
private readonly issues: IssuesService,
private readonly workflows: WorkflowsService,
) {}
async projectId(viewerId: string, ref: string): Promise<string> {
@@ -28,12 +34,21 @@ export class Refs {
if (UUID.test(ref)) return ref;
return (await this.issues.findOne(viewerId, ref)).id;
}
/** The ticket as the REST API returns it: read model + allowed transitions. */
async issueDto(issue: IssueReadModel) {
return {
...issue,
allowedTransitions: await this.workflows.allowedTransitions(issue),
};
}
issueDtos(issues: IssueReadModel[]) {
return Promise.all(issues.map((issue) => this.issueDto(issue)));
}
}
/** The ticket as the REST API returns it: read model + allowed transitions. */
export function toIssueDto(issue: IssueReadModel) {
return {
...issue,
allowedTransitions: IssueWorkflow.allowedFrom(issue.status),
};
/** The project as the REST API returns it: transitions as a list. */
export function toProjectDto(project: ProjectReadModel) {
return { ...project, settings: toSettingsPayload(project.settings) };
}

View File

@@ -33,15 +33,17 @@ import {
CreateReleaseDto,
PageQueryDto,
PublishReleaseDto,
UpcomingReleasesQueryDto,
UpdateReleaseDto,
} from './dto/requests.dto';
import {
CommandResultDto,
IssuePageDto,
ReleaseDto,
ReleaseBurnupDto,
ReleaseProgressDto,
} from './dto/responses.dto';
import { Refs, toIssueDto } from './refs';
import { Refs } from './refs';
const PROJECT = ApiParam({
name: 'project',
@@ -61,6 +63,16 @@ export class ReleasesController {
private readonly refs: Refs,
) {}
@Get('me/releases')
@ApiOperation({
summary: 'Planned releases of all my projects, soonest target date first',
})
@ApiOkResponse({ type: [ReleaseDto] })
@ApiReadErrors()
upcoming(@CurrentUser() me: User, @Query() query: UpcomingReleasesQueryDto) {
return this.releases.upcoming(me.id, query.limit);
}
@Get('projects/:project/releases')
@PROJECT
@ApiOperation({ summary: 'Releases of a project' })
@@ -117,6 +129,16 @@ export class ReleasesController {
return this.releases.progress(me.id, releaseId);
}
@Get('releases/:releaseId/burnup')
@ApiOperation({
summary: 'Burnup: tickets in the release and finished ones, day by day',
})
@ApiOkResponse({ type: ReleaseBurnupDto })
@ApiReadErrors()
burnup(@CurrentUser() me: User, @Param('releaseId') releaseId: string) {
return this.releases.burnup(me.id, releaseId);
}
@Get('releases/:releaseId/issues')
@ApiOperation({ summary: 'Tickets included in the release' })
@ApiOkResponse({ type: IssuePageDto })
@@ -133,7 +155,7 @@ export class ReleasesController {
limit: page.limit,
offset: page.offset,
});
return { ...found, items: found.items.map(toIssueDto) };
return { ...found, items: await this.refs.issueDtos(found.items) };
}
@Post('releases/:releaseId/publish')

View File

@@ -7,9 +7,11 @@ Estado del tracker (NgRx Signals con eventos) detrás de facades.
## Contenido
- Facades (lo que se exporta): `ProjectsFacade` (raíz) y `ProjectFacade`, `BoardFacade`, `IssueFacade` (de ruta, con
- Facades (lo que se exporta): `ProjectsFacade` y `HomeFacade` («Mi trabajo») (raíz) y `ProjectFacade`, `BoardFacade`, `IssueFacade` (de ruta, con
`provideProjectFacade()`, `provideBoardFacade()`, `provideIssueFacade()`). Tests en `facades/facades.spec.ts`.
- Interno: stores `ProjectsStore`, `ProjectStore`, `BoardStore`, `IssueStore` y eventos por origen
- `ProjectFacade` también da la configuración del proyecto (`workflow`, `saveSettings`) y sus estadísticas
(`loadAnalytics`, `loadBurnup`).
- Interno: stores `ProjectsStore`, `ProjectStore`, `HomeStore`, `BoardStore`, `IssueStore` y eventos por origen
(`*PageEvents`, `*ApiEvents`, `*RealtimeEvents`).
- `board.reducers.ts`: transformaciones puras del tablero (movimiento optimista, cambios en vivo por versión).
- Puerto `Notifications`.

View File

@@ -3,3 +3,4 @@ export * from './lib/facades/projects.facade';
export * from './lib/facades/project.facade';
export * from './lib/facades/board.facade';
export * from './lib/facades/issue.facade';
export * from './lib/facades/home.facade';

View File

@@ -6,7 +6,6 @@ import {
CommandResult,
CreateIssueInput,
IssueChanged,
IssueStatus,
} from '@task-stream/web/tracker/domain';
export const boardPageEvents = eventGroup({
@@ -17,7 +16,7 @@ export const boardPageEvents = eventGroup({
/** Optimistic: the card moves immediately and comes back if the API refuses. */
issueMoved: type<{
issueId: string;
to: IssueStatus;
to: string;
reason?: string | null;
}>(),
issueCreateRequested: type<CreateIssueInput>(),

View File

@@ -0,0 +1,23 @@
import { type } from '@ngrx/signals';
import { eventGroup } from '@ngrx/signals/events';
import { IssuePage, Release } from '@task-stream/web/tracker/domain';
export const homePageEvents = eventGroup({
source: 'Home Page',
events: {
/** Loads (or reloads) "my work": also used to refresh when coming back. */
opened: type<void>(),
},
});
export const homeApiEvents = eventGroup({
source: 'Home API',
events: {
loaded: type<{
mine: IssuePage;
awaitingQa: IssuePage;
releases: Release[];
}>(),
loadFailed: type<string>(),
},
});

View File

@@ -5,8 +5,11 @@ import {
CreateReleaseInput,
MemberInput,
Project,
ProjectAnalytics,
ProjectSettings,
ProjectStats,
Release,
ReleaseBurnup,
UserDirectoryEntry,
} from '@task-stream/web/tracker/domain';
@@ -21,6 +24,12 @@ export const projectPageEvents = eventGroup({
releasePublishRequested: type<{ releaseId: string; force: boolean }>(),
releaseArchiveRequested: type<{ releaseId: string }>(),
userSearchRequested: type<string>(),
settingsSaveRequested: type<{
projectId: string;
settings: ProjectSettings;
}>(),
analyticsRequested: type<{ projectId: string; days: number }>(),
burnupRequested: type<{ releaseId: string }>(),
},
});
@@ -35,6 +44,10 @@ export const projectApiEvents = eventGroup({
commandSucceeded: type<string>(),
commandFailed: type<string>(),
usersFound: type<UserDirectoryEntry[]>(),
analyticsLoaded: type<ProjectAnalytics>(),
analyticsFailed: type<string>(),
burnupLoaded: type<ReleaseBurnup>(),
settingsSaved: type<string>(),
},
});

View File

@@ -1,10 +1,6 @@
import { inject, Injectable, Provider } from '@angular/core';
import { injectDispatch } from '@ngrx/signals/events';
import {
BoardFilter,
CreateIssueInput,
IssueStatus,
} from '@task-stream/web/tracker/domain';
import { BoardFilter, CreateIssueInput } from '@task-stream/web/tracker/domain';
import { boardPageEvents } from '../events/board.events';
import { BoardStore } from '../stores/board.store';
@@ -30,7 +26,7 @@ export class BoardFacade {
}
/** Optimistic: the card moves at once and comes back if the API refuses. */
moveIssue(issueId: string, to: IssueStatus, reason?: string | null): void {
moveIssue(issueId: string, to: string, reason?: string | null): void {
this.dispatch.issueMoved({ issueId, to, reason });
}

View File

@@ -6,7 +6,6 @@ import {
ActivityGateway,
CommentsGateway,
IssuesGateway,
IssueStatus,
Project,
ProjectRole,
ProjectsGateway,
@@ -19,12 +18,14 @@ import { boardPageEvents } from '../events/board.events';
import { projectPageEvents } from '../events/project.events';
import { Notifications } from '../notifications';
import { BoardFacade, provideBoardFacade } from './board.facade';
import { HomeFacade } from './home.facade';
import { IssueFacade, provideIssueFacade } from './issue.facade';
import { ProjectFacade, provideProjectFacade } from './project.facade';
const ok = { id: 'x', key: null, aggregateVersion: 1 };
const project = { id: 'p1', key: 'WEB', members: [] } as unknown as Project;
const issue = { id: 'i1', key: 'WEB-1', links: [] };
const analytics = { from: 'a', to: 'b', qa: { failureRate: 0.25 } };
/** Every command succeeds, every query answers once, nothing arrives in real time. */
function gateways() {
@@ -33,6 +34,8 @@ function gateways() {
project: vi.fn(() => of(project)),
stats: vi.fn(() => NEVER),
addMember: vi.fn(() => of(ok)),
updateSettings: vi.fn(() => of(ok)),
analytics: vi.fn(() => of(analytics)),
},
issues: {
board: vi.fn(() => of([])),
@@ -127,6 +130,28 @@ describe('ProjectFacade', () => {
});
});
it('saves settings and loads statistics of the open project', () => {
const { project, fakes } = setup();
project.open('WEB');
const settings = {
statuses: [],
transitions: [],
issueTypes: [],
areas: [],
};
project.saveSettings(settings);
expect(fakes.projects.updateSettings).toHaveBeenCalledWith('p1', settings);
expect(project.savingSettings()).toBe(false);
project.loadAnalytics(30);
expect(fakes.projects.analytics).toHaveBeenCalledWith('p1', 30);
expect(project.analyticsDays()).toBe(30);
expect(project.analytics()).toBe(analytics);
// Unknown ids still get a readable name.
expect(project.workflow().status('IN_REVIEW').name).toBe('In review');
});
it('searches the directory from two characters', () => {
const { project } = setup();
const seen: string[] = [];
@@ -145,15 +170,71 @@ describe('IssueFacade', () => {
it('acts on the open ticket', () => {
const { issue, fakes } = setup();
issue.changeStatus(IssueStatus.IN_PROGRESS);
issue.changeStatus('IN_PROGRESS');
expect(fakes.issues.changeStatus).not.toHaveBeenCalled();
issue.open('WEB-1');
issue.changeStatus(IssueStatus.QA_FAILED, 'Falla en Safari');
issue.changeStatus('QA_FAILED', 'Falla en Safari');
expect(fakes.issues.changeStatus).toHaveBeenCalledWith({
issueId: 'i1',
status: IssueStatus.QA_FAILED,
status: 'QA_FAILED',
reason: 'Falla en Safari',
});
});
});
describe('HomeFacade', () => {
const issue = (key: string, status: string, statusCategory: string) =>
({ id: key, key, status, statusCategory, title: key }) as never;
it('loads my work and groups it by category, QA failures first', () => {
const mine = vi.fn((filter: { assignedToMe: boolean }) =>
of(
filter.assignedToMe
? {
items: [
issue('WEB-1', 'IN_PROGRESS', 'IN_PROGRESS'),
issue('WEB-4', 'BACKLOG', 'TODO'),
issue('WEB-2', 'QA_FAILED', 'IN_PROGRESS'),
],
total: 3,
}
: { items: [issue('WEB-3', 'STAGING', 'QA')], total: 1 },
),
);
TestBed.configureTestingModule({
providers: [
{ provide: IssuesGateway, useValue: { mine } },
{
provide: ReleasesGateway,
useValue: { upcoming: () => of([{ id: 'r1', name: 'v1.4.0' }]) },
},
],
});
const home = TestBed.inject(HomeFacade);
home.load();
expect(mine).toHaveBeenCalledWith(
expect.objectContaining({ assignedToMe: true }),
);
expect(mine).toHaveBeenCalledWith(
expect.objectContaining({
assignedToMe: false,
categories: ['QA'],
}),
);
expect(home.loaded()).toBe(true);
expect(
home
.mineByCategory()
.map((g) => [g.category, g.issues.map((i) => i.key)]),
).toEqual([
['IN_PROGRESS', ['WEB-2', 'WEB-1']],
['TODO', ['WEB-4']],
]);
expect(home.qaFailed()).toBe(1);
expect(home.awaitingQaTotal()).toBe(1);
expect(home.releases().map((r) => r.name)).toEqual(['v1.4.0']);
});
});

View File

@@ -0,0 +1,32 @@
import { inject, Injectable } from '@angular/core';
import { injectDispatch } from '@ngrx/signals/events';
import { homePageEvents } from '../events/home.events';
import { HomeStore } from '../stores/home.store';
/** "My work" on the home page: my tickets, waiting for QA, upcoming releases. */
@Injectable({ providedIn: 'root' })
export class HomeFacade {
private readonly store = inject(HomeStore);
private readonly dispatch = injectDispatch(homePageEvents);
/** My open tickets (most recently updated first) and how many there are. */
readonly mine = this.store.mine;
readonly mineTotal = this.store.mineTotal;
/** The same, grouped by category (in progress, QA, pending), QA failures first. */
readonly mineByCategory = this.store.mineByCategory;
readonly qaFailed = this.store.qaFailed;
/** Tickets of my projects in a QA-category status (anyone's). */
readonly awaitingQa = this.store.awaitingQa;
readonly awaitingQaTotal = this.store.awaitingQaTotal;
/** Planned releases of my projects, soonest first. */
readonly releases = this.store.releases;
/** False until the first load: show skeletons, not empty states. */
readonly loaded = this.store.loaded;
readonly loading = this.store.loading;
readonly error = this.store.error;
/** Loads my work; call again to refresh (e.g. when the tab regains focus). */
load(): void {
this.dispatch.opened();
}
}

View File

@@ -3,7 +3,6 @@ import { injectDispatch } from '@ngrx/signals/events';
import {
IssueLink,
IssueLinkType,
IssueStatus,
UpdateIssueInput,
} from '@task-stream/web/tracker/domain';
import { issuePageEvents } from '../events/issue.events';
@@ -34,7 +33,7 @@ export class IssueFacade {
}
/** QA_FAILED and CANCELLED need a reason. */
changeStatus(status: IssueStatus, reason?: string | null): void {
changeStatus(status: string, reason?: string | null): void {
this.withIssue((issueId) =>
this.dispatch.statusChangeRequested({ issueId, status, reason }),
);

View File

@@ -3,6 +3,7 @@ import { injectDispatch } from '@ngrx/signals/events';
import {
CreateReleaseInput,
ProjectRole,
ProjectSettings,
} from '@task-stream/web/tracker/domain';
import { projectPageEvents } from '../events/project.events';
import { ProjectStore } from '../stores/project.store';
@@ -30,6 +31,16 @@ export class ProjectFacade {
readonly canManage = this.store.canManage;
/** Any role but viewer. */
readonly canContribute = this.store.canContribute;
/** Statuses, ticket types and areas of this project (names, colours, lookups). */
readonly workflow = this.store.workflow;
readonly savingSettings = this.store.savingSettings;
/** Statistics of the last `analyticsDays()` days (see `loadAnalytics`). */
readonly analytics = this.store.analytics;
readonly analyticsDays = this.store.analyticsDays;
readonly analyticsLoading = this.store.analyticsLoading;
readonly analyticsError = this.store.analyticsError;
/** Burnup of the release asked for with `loadBurnup`. */
readonly burnup = this.store.burnup;
/** Loads the project (by id or key) and follows its changes. */
open(idOrKey: string): void {
@@ -74,6 +85,21 @@ export class ProjectFacade {
this.dispatch.releaseArchiveRequested({ releaseId });
}
/** Replaces statuses, transitions, types and areas (owners and admins). */
saveSettings(settings: ProjectSettings): void {
const projectId = this.projectId();
if (projectId) this.dispatch.settingsSaveRequested({ projectId, settings });
}
loadAnalytics(days = this.store.analyticsDays()): void {
const projectId = this.projectId();
if (projectId) this.dispatch.analyticsRequested({ projectId, days });
}
loadBurnup(releaseId: string): void {
this.dispatch.burnupRequested({ releaseId });
}
private projectId(): string | null {
return this.store.project()?.id ?? null;
}

View File

@@ -1,13 +1,9 @@
import {
BoardColumn,
IssueStatus,
IssueSummary,
} from '@task-stream/web/tracker/domain';
import { BoardColumn, IssueSummary } from '@task-stream/web/tracker/domain';
import { applyIssueChange, moveIssue } from './board.reducers';
const issue = (
id: string,
status: IssueStatus,
status: string,
version = 1,
assignee: string | null = null,
): IssueSummary =>
@@ -27,55 +23,42 @@ const issue = (
const board = (): BoardColumn[] => [
{
status: IssueStatus.TODO,
status: 'TODO',
total: 1,
issues: [issue('a', IssueStatus.TODO)],
issues: [issue('a', 'TODO')],
},
{ status: IssueStatus.IN_PROGRESS, total: 0, issues: [] },
{ status: 'IN_PROGRESS', total: 0, issues: [] },
];
describe('board reducers', () => {
it('moves a card optimistically', () => {
const next = moveIssue(board(), 'a', IssueStatus.IN_PROGRESS);
const next = moveIssue(board(), 'a', 'IN_PROGRESS');
expect(next[0]).toMatchObject({ total: 0, issues: [] });
expect(next[1].issues[0]).toMatchObject({
id: 'a',
status: IssueStatus.IN_PROGRESS,
status: 'IN_PROGRESS',
});
expect(next[1].total).toBe(1);
});
it('applies newer realtime versions and ignores stale ones', () => {
const moved = applyIssueChange(
board(),
issue('a', IssueStatus.IN_PROGRESS, 2),
null,
);
const moved = applyIssueChange(board(), issue('a', 'IN_PROGRESS', 2), null);
expect(moved[1].issues.map((i) => i.id)).toEqual(['a']);
const stale = applyIssueChange(
moved,
issue('a', IssueStatus.TODO, 1),
null,
);
const stale = applyIssueChange(moved, issue('a', 'TODO', 1), null);
expect(stale).toBe(moved);
});
it('drops issues that no longer match the filter', () => {
const next = applyIssueChange(
board(),
issue('a', IssueStatus.TODO, 2, 'bob'),
{ projectId: 'p', assigneeId: 'ana' },
);
const next = applyIssueChange(board(), issue('a', 'TODO', 2, 'bob'), {
projectId: 'p',
assigneeId: 'ana',
});
expect(next[0].issues).toEqual([]);
});
it('adds new issues from other users', () => {
const next = applyIssueChange(
board(),
issue('b', IssueStatus.TODO, 1),
null,
);
const next = applyIssueChange(board(), issue('b', 'TODO', 1), null);
expect(next[0].issues.map((i) => i.id)).toEqual(['b', 'a']);
});
});

View File

@@ -1,7 +1,6 @@
import {
BoardColumn,
BoardFilter,
IssueStatus,
IssueSummary,
} from '@task-stream/web/tracker/domain';
@@ -56,7 +55,7 @@ export function placeIssue(
export function moveIssue(
columns: BoardColumn[],
issueId: string,
to: IssueStatus,
to: string,
): BoardColumn[] {
const issue = findIssue(columns, issueId);
return issue

View File

@@ -0,0 +1,125 @@
import { computed, inject } from '@angular/core';
import { mapResponse } from '@ngrx/operators';
import { signalStore, withComputed, withState } from '@ngrx/signals';
import {
Events,
on,
withEventHandlers,
withReducer,
} from '@ngrx/signals/events';
import {
IssuesGateway,
IssueSummary,
OPEN_CATEGORIES,
QA_FAILED,
Release,
ReleasesGateway,
StatusCategory,
} from '@task-stream/web/tracker/domain';
import { forkJoin, switchMap } from 'rxjs';
import { homeApiEvents, homePageEvents } from '../events/home.events';
import { describeError } from '../notifications';
/** Groups of "my work": what is moving first, then QA, then what is pending. */
const GROUP_ORDER = [
StatusCategory.IN_PROGRESS,
StatusCategory.QA,
StatusCategory.TODO,
];
interface HomeState {
mine: IssueSummary[];
mineTotal: number;
awaitingQa: IssueSummary[];
awaitingQaTotal: number;
releases: Release[];
loaded: boolean;
loading: boolean;
error: string | null;
}
/**
* "My work" across every project I belong to: my open tickets, tickets
* waiting for QA and upcoming releases. Root-scoped, so coming back to the
* home page shows the last data at once while it refreshes.
*/
export const HomeStore = signalStore(
{ providedIn: 'root' },
withState<HomeState>({
mine: [],
mineTotal: 0,
awaitingQa: [],
awaitingQaTotal: 0,
releases: [],
loaded: false,
loading: false,
error: null,
}),
withComputed(({ mine }) => ({
/**
* My open tickets grouped by category (statuses are per project), QA
* failures first within their group.
*/
mineByCategory: computed(() =>
GROUP_ORDER.map((category) => ({
category,
issues: mine()
.filter((issue) => issue.statusCategory === category)
.sort(
(a, b) =>
Number(b.status === QA_FAILED) - Number(a.status === QA_FAILED),
),
})).filter((group) => group.issues.length > 0),
),
qaFailed: computed(
() => mine().filter((issue) => issue.status === QA_FAILED).length,
),
})),
withReducer(
on(homePageEvents.opened, () => ({ loading: true, error: null })),
on(homeApiEvents.loaded, ({ payload }) => ({
mine: payload.mine.items,
mineTotal: payload.mine.total,
awaitingQa: payload.awaitingQa.items,
awaitingQaTotal: payload.awaitingQa.total,
releases: payload.releases,
loaded: true,
loading: false,
})),
on(homeApiEvents.loadFailed, ({ payload }) => ({
loading: false,
error: payload,
})),
),
withEventHandlers(
(
_store,
events = inject(Events),
issues = inject(IssuesGateway),
releases = inject(ReleasesGateway),
) => ({
load$: events.on(homePageEvents.opened).pipe(
switchMap(() =>
forkJoin({
mine: issues.mine({
assignedToMe: true,
categories: [...OPEN_CATEGORIES],
limit: 50,
}),
awaitingQa: issues.mine({
assignedToMe: false,
categories: [StatusCategory.QA],
limit: 20,
}),
releases: releases.upcoming(5),
}).pipe(
mapResponse({
next: (data) => homeApiEvents.loaded(data),
error: (error) => homeApiEvents.loadFailed(describeError(error)),
}),
),
),
),
}),
),
);

Some files were not shown because too many files have changed in this diff Show More