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

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