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:
@@ -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(...)`.
|
||||
|
||||
|
||||
@@ -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": {}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
184
libs/api/tracker/application/src/lib/analytics/analytics.spec.ts
Normal file
184
libs/api/tracker/application/src/lib/analytics/analytics.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
315
libs/api/tracker/application/src/lib/analytics/analytics.ts
Normal file
315
libs/api/tracker/application/src/lib/analytics/analytics.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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[]>;
|
||||
}
|
||||
|
||||
@@ -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[]>;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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() });
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
45
libs/api/tracker/application/src/lib/workflows.service.ts
Normal file
45
libs/api/tracker/application/src/lib/workflows.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user