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);
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,11 @@ El modelo del producto: proyectos, tickets, comentarios y releases.
|
||||
- Agregados event-sourced: `Project`, `Issue`, `Comment`, `Release`. Validan en sus métodos y cambian de estado en `on<Evento>`.
|
||||
- `events/`: un fichero por evento (`IssueCreatedEvent`, `IssueStatusChangedEvent`…), todos con `@AutowiredEvent`.
|
||||
- `factories/`: `ProjectFactory`, `IssueFactory`, `CommentFactory`, `ReleaseFactory` (`@Injectable()`).
|
||||
- `issue/issue-workflow.ts`: transiciones de estado permitidas y estados que exigen motivo.
|
||||
- `read-models/`: forma de las vistas de lectura (`ProjectReadModel`, `IssueReadModel`…).
|
||||
- `project/project-settings.ts`: configuración por proyecto (estados con categoría, transiciones, tipos, áreas), sus
|
||||
valores por defecto, `ProjectWorkflow` (lo que consultan tickets y queries) y `validateProjectSettings` (archivar,
|
||||
nunca borrar).
|
||||
- `read-models/`: forma de las vistas de lectura (`ProjectReadModel`, `IssueReadModel`…), incluida la línea temporal
|
||||
de los tickets y las estadísticas (`analytics.read-model.ts`).
|
||||
|
||||
Reglas y workflow: [API](../../../../docs/03-api.md#31-modelo-de-dominio-contexto-tracker).
|
||||
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/api/tracker/domain/src",
|
||||
"projectType": "library",
|
||||
"tags": [
|
||||
"scope:api-tracker",
|
||||
"type:domain"
|
||||
],
|
||||
"tags": ["scope:api-tracker", "type:domain"],
|
||||
"// targets": "to see all targets run: nx show project api-tracker-domain --web",
|
||||
"targets": {}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ export * from './lib/project/project';
|
||||
export * from './lib/project/project-role';
|
||||
export * from './lib/issue/issue';
|
||||
export * from './lib/issue/issue.enums';
|
||||
export * from './lib/issue/issue-workflow';
|
||||
export * from './lib/project/project-settings';
|
||||
export * from './lib/comment/comment';
|
||||
export * from './lib/release/release';
|
||||
export * from './lib/release/release-status';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from './tracker.event';
|
||||
export * from './project-created.event';
|
||||
export * from './project-updated.event';
|
||||
export * from './project-settings-changed.event';
|
||||
export * from './project-member-added.event';
|
||||
export * from './project-member-removed.event';
|
||||
export * from './project-member-role-changed.event';
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
||||
import { IssueStatus } from '../issue/issue.enums';
|
||||
import type { StatusCategory } from '../project/project-settings';
|
||||
import { TrackerEvent } from './tracker.event';
|
||||
|
||||
/** A ticket moved through the workflow (incl. QA). */
|
||||
/** Names and categories of both statuses when the move happened (absent in old events). */
|
||||
export interface StatusChangeMeta {
|
||||
fromName: string;
|
||||
toName: string;
|
||||
fromCategory: StatusCategory;
|
||||
toCategory: StatusCategory;
|
||||
}
|
||||
|
||||
/** A ticket moved through its project's workflow (incl. QA). */
|
||||
@AutowiredEvent
|
||||
export class IssueStatusChangedEvent extends TrackerEvent {
|
||||
constructor(
|
||||
actorId: string,
|
||||
issueId: string,
|
||||
projectId: string,
|
||||
readonly from: IssueStatus,
|
||||
readonly to: IssueStatus,
|
||||
/** Status ids of the project's workflow, e.g. IN_QA → QA_FAILED. */
|
||||
readonly from: string,
|
||||
readonly to: string,
|
||||
readonly reason: string | null,
|
||||
readonly meta?: StatusChangeMeta,
|
||||
) {
|
||||
super(actorId, issueId, projectId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
||||
import type { ProjectSettings } from '../project/project-settings';
|
||||
import { TrackerEvent } from './tracker.event';
|
||||
|
||||
/** Statuses, transitions, ticket types or areas of a project changed (full snapshot). */
|
||||
@AutowiredEvent
|
||||
export class ProjectSettingsChangedEvent extends TrackerEvent {
|
||||
constructor(
|
||||
actorId: string,
|
||||
projectId: string,
|
||||
readonly settings: ProjectSettings,
|
||||
) {
|
||||
super(actorId, projectId, projectId);
|
||||
}
|
||||
}
|
||||
@@ -7,23 +7,32 @@ import {
|
||||
IssueProps,
|
||||
normalizeIssueDetails,
|
||||
} from '../issue/issue';
|
||||
import { IssuePriority, IssueStatus, IssueType } from '../issue/issue.enums';
|
||||
import { IssuePriority } from '../issue/issue.enums';
|
||||
import { ProjectWorkflow } from '../project/project-settings';
|
||||
|
||||
export interface CreateIssueProps extends IssueDetails {
|
||||
projectId: string;
|
||||
projectKey: string;
|
||||
number: number;
|
||||
title: string;
|
||||
type: IssueType;
|
||||
type: string;
|
||||
reporterId: string;
|
||||
assigneeId?: string | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class IssueFactory {
|
||||
/** New tickets start in BACKLOG with a project-scoped key (e.g. TS-7). */
|
||||
create(props: CreateIssueProps): Issue {
|
||||
/**
|
||||
* New tickets start in the project's first active TODO status (BACKLOG by
|
||||
* default) with a project-scoped key (e.g. TS-7).
|
||||
*/
|
||||
create(
|
||||
props: CreateIssueProps,
|
||||
workflow: ProjectWorkflow = ProjectWorkflow.default(),
|
||||
): Issue {
|
||||
const details = normalizeIssueDetails(props);
|
||||
workflow.assertActiveType(props.type);
|
||||
workflow.assertActiveArea(props.area);
|
||||
const title = details.title ?? '';
|
||||
assertThat(title.length > 0, 'Issue title is required');
|
||||
|
||||
@@ -37,7 +46,7 @@ export class IssueFactory {
|
||||
area: props.area ?? null,
|
||||
title,
|
||||
description: details.description ?? null,
|
||||
status: IssueStatus.BACKLOG,
|
||||
status: workflow.initialStatus(),
|
||||
priority: props.priority ?? IssuePriority.MEDIUM,
|
||||
reporterId: props.reporterId,
|
||||
assigneeId: props.assigneeId ?? null,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { assertThat, newId } from '@task-stream/api/shared/domain';
|
||||
import { ProjectCreatedEvent } from '../events/project-created.event';
|
||||
import { Project, ProjectProps } from '../project/project';
|
||||
import { ProjectRole } from '../project/project-role';
|
||||
import { defaultProjectSettings } from '../project/project-settings';
|
||||
|
||||
const KEY_PATTERN = /^[A-Z][A-Z0-9]{1,9}$/;
|
||||
|
||||
@@ -30,6 +31,7 @@ export class ProjectFactory {
|
||||
description: props.description?.trim() || null,
|
||||
ownerId: props.ownerId,
|
||||
members: [{ userId: props.ownerId, role: ProjectRole.OWNER }],
|
||||
settings: defaultProjectSettings(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { IssueStatus as S } from './issue.enums';
|
||||
|
||||
/**
|
||||
* Allowed status transitions. QA is a first-class step: work goes
|
||||
* READY_FOR_QA → IN_QA → DONE | QA_FAILED → IN_PROGRESS.
|
||||
*/
|
||||
const TRANSITIONS: Record<S, readonly S[]> = {
|
||||
[S.BACKLOG]: [S.TODO, S.IN_PROGRESS, S.CANCELLED],
|
||||
[S.TODO]: [S.BACKLOG, S.IN_PROGRESS, S.CANCELLED],
|
||||
[S.IN_PROGRESS]: [S.TODO, S.IN_REVIEW, S.READY_FOR_QA, S.CANCELLED],
|
||||
[S.IN_REVIEW]: [S.IN_PROGRESS, S.READY_FOR_QA, S.DONE],
|
||||
[S.READY_FOR_QA]: [S.IN_QA, S.IN_PROGRESS],
|
||||
[S.IN_QA]: [S.DONE, S.QA_FAILED],
|
||||
[S.QA_FAILED]: [S.IN_PROGRESS, S.TODO],
|
||||
[S.DONE]: [S.TODO, S.IN_PROGRESS],
|
||||
[S.CANCELLED]: [S.BACKLOG],
|
||||
};
|
||||
|
||||
/** Statuses that require an explanation when entered. */
|
||||
const REASON_REQUIRED: readonly S[] = [S.QA_FAILED, S.CANCELLED];
|
||||
|
||||
export const FINISHED_STATUSES: readonly S[] = [S.DONE, S.CANCELLED];
|
||||
|
||||
export const IssueWorkflow = {
|
||||
canTransition(from: S, to: S): boolean {
|
||||
return TRANSITIONS[from].includes(to);
|
||||
},
|
||||
allowedFrom(from: S): readonly S[] {
|
||||
return TRANSITIONS[from];
|
||||
},
|
||||
requiresReason(to: S): boolean {
|
||||
return REASON_REQUIRED.includes(to);
|
||||
},
|
||||
isFinished(status: S): boolean {
|
||||
return FINISHED_STATUSES.includes(status);
|
||||
},
|
||||
};
|
||||
@@ -10,14 +10,8 @@ import { IssueReleaseChangedEvent } from '../events/issue-release-changed.event'
|
||||
import { IssueStatusChangedEvent } from '../events/issue-status-changed.event';
|
||||
import { IssueUnlinkedEvent } from '../events/issue-unlinked.event';
|
||||
import { IssueUpdatedEvent } from '../events/issue-updated.event';
|
||||
import {
|
||||
IssueArea,
|
||||
IssueLinkType,
|
||||
IssuePriority,
|
||||
IssueStatus,
|
||||
IssueType,
|
||||
} from './issue.enums';
|
||||
import { IssueWorkflow } from './issue-workflow';
|
||||
import { ProjectWorkflow } from '../project/project-settings';
|
||||
import { IssueLinkType, IssuePriority } from './issue.enums';
|
||||
|
||||
export interface IssueLink {
|
||||
type: IssueLinkType;
|
||||
@@ -29,8 +23,10 @@ export interface IssueLink {
|
||||
export interface IssueDetails {
|
||||
title?: string;
|
||||
description?: string | null;
|
||||
type?: IssueType;
|
||||
area?: IssueArea | null;
|
||||
/** Id of one of the project's ticket types. */
|
||||
type?: string;
|
||||
/** Id of one of the project's areas. */
|
||||
area?: string | null;
|
||||
priority?: IssuePriority;
|
||||
labels?: string[];
|
||||
figmaUrl?: string | null;
|
||||
@@ -44,11 +40,12 @@ export interface IssueProps {
|
||||
projectId: string;
|
||||
number: number;
|
||||
key: string;
|
||||
type: IssueType;
|
||||
area: IssueArea | null;
|
||||
type: string;
|
||||
area: string | null;
|
||||
title: string;
|
||||
description: string | null;
|
||||
status: IssueStatus;
|
||||
/** Id of a status of the project's workflow. */
|
||||
status: string;
|
||||
priority: IssuePriority;
|
||||
reporterId: string;
|
||||
assigneeId: string | null;
|
||||
@@ -70,11 +67,11 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
||||
public projectId!: string;
|
||||
public number!: number;
|
||||
public key!: string;
|
||||
public type!: IssueType;
|
||||
public area: IssueArea | null = null;
|
||||
public type!: string;
|
||||
public area: string | null = null;
|
||||
public title!: string;
|
||||
public description: string | null = null;
|
||||
public status: IssueStatus = IssueStatus.BACKLOG;
|
||||
public status!: string;
|
||||
public priority: IssuePriority = IssuePriority.MEDIUM;
|
||||
public reporterId!: string;
|
||||
public assigneeId: string | null = null;
|
||||
@@ -92,7 +89,12 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
updateDetails(actorId: string, changes: IssueDetails): void {
|
||||
/** `workflow` (the project's) validates a new type or area. */
|
||||
updateDetails(
|
||||
actorId: string,
|
||||
changes: IssueDetails,
|
||||
workflow: ProjectWorkflow = ProjectWorkflow.default(),
|
||||
): void {
|
||||
const next = normalizeIssueDetails(changes);
|
||||
const patch: IssueDetails = {};
|
||||
for (const field of Object.keys(next) as (keyof IssueDetails)[]) {
|
||||
@@ -103,22 +105,36 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
||||
}
|
||||
if (patch.title !== undefined)
|
||||
assertThat(patch.title.length > 0, 'Issue title is required');
|
||||
if (patch.type !== undefined) workflow.assertActiveType(patch.type);
|
||||
if (patch.area !== undefined) workflow.assertActiveArea(patch.area);
|
||||
if (Object.keys(patch).length === 0) return;
|
||||
this.apply(new IssueUpdatedEvent(actorId, this.id, this.projectId, patch));
|
||||
}
|
||||
|
||||
changeStatus(actorId: string, to: IssueStatus, reason?: string | null): void {
|
||||
/** Moves the ticket along its project's workflow. */
|
||||
changeStatus(
|
||||
actorId: string,
|
||||
to: string,
|
||||
reason?: string | null,
|
||||
workflow: ProjectWorkflow = ProjectWorkflow.default(),
|
||||
): void {
|
||||
const from = this.status;
|
||||
if (from === to) return;
|
||||
if (!IssueWorkflow.canTransition(from, to)) {
|
||||
if (!workflow.canTransition(from, to)) {
|
||||
throw new BusinessRuleError(
|
||||
`Cannot move ${this.key} from ${from} to ${to}. Allowed: ${IssueWorkflow.allowedFrom(from).join(', ')}`,
|
||||
`Cannot move ${this.key} from ${workflow.statusName(from)} to ${workflow.statusName(to)}. ` +
|
||||
`Allowed: ${
|
||||
workflow
|
||||
.allowedFrom(from)
|
||||
.map((s) => workflow.statusName(s))
|
||||
.join(', ') || 'none'
|
||||
}`,
|
||||
);
|
||||
}
|
||||
const trimmedReason = reason?.trim() || null;
|
||||
if (IssueWorkflow.requiresReason(to) && !trimmedReason) {
|
||||
if (workflow.requiresReason(to) && !trimmedReason) {
|
||||
throw new BusinessRuleError(
|
||||
`A reason is required to move ${this.key} to ${to}`,
|
||||
`A reason is required to move ${this.key} to ${workflow.statusName(to)}`,
|
||||
);
|
||||
}
|
||||
this.apply(
|
||||
@@ -129,6 +145,12 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
||||
from,
|
||||
to,
|
||||
trimmedReason,
|
||||
{
|
||||
fromName: workflow.statusName(from),
|
||||
toName: workflow.statusName(to),
|
||||
fromCategory: workflow.categoryOf(from),
|
||||
toCategory: workflow.categoryOf(to),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
166
libs/api/tracker/domain/src/lib/project/project-settings.spec.ts
Normal file
166
libs/api/tracker/domain/src/lib/project/project-settings.spec.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import {
|
||||
BusinessRuleError,
|
||||
ForbiddenError,
|
||||
ValidationError,
|
||||
} from '@task-stream/api/shared/domain';
|
||||
import { ProjectSettingsChangedEvent } from '../events/project-settings-changed.event';
|
||||
import { IssueStatusChangedEvent } from '../events/issue-status-changed.event';
|
||||
import { IssueFactory } from '../factories/issue.factory';
|
||||
import { ProjectFactory } from '../factories/project.factory';
|
||||
import { replay } from '../testing';
|
||||
import { Project } from './project';
|
||||
import { ProjectRole } from './project-role';
|
||||
import {
|
||||
defaultProjectSettings,
|
||||
ProjectSettings,
|
||||
StatusCategory,
|
||||
} from './project-settings';
|
||||
|
||||
const newProject = () => {
|
||||
const project = new ProjectFactory().create({
|
||||
key: 'WEB',
|
||||
name: 'Web',
|
||||
ownerId: 'owner',
|
||||
});
|
||||
project.addMember('owner', 'dev', ProjectRole.MEMBER);
|
||||
return project;
|
||||
};
|
||||
|
||||
/** Defaults plus a STAGING step between review and QA, and a SPIKE type. */
|
||||
const withStaging = (): ProjectSettings => {
|
||||
const settings = defaultProjectSettings();
|
||||
settings.statuses.splice(4, 0, {
|
||||
id: 'STAGING',
|
||||
name: 'En staging',
|
||||
category: StatusCategory.QA,
|
||||
color: 'teal',
|
||||
requiresReason: false,
|
||||
archived: false,
|
||||
});
|
||||
settings.transitions['IN_REVIEW'] = ['STAGING'];
|
||||
settings.transitions['STAGING'] = ['IN_QA', 'IN_PROGRESS'];
|
||||
settings.issueTypes.push({
|
||||
id: 'SPIKE',
|
||||
name: 'Spike',
|
||||
icon: 'light-bulb',
|
||||
color: 'teal',
|
||||
archived: false,
|
||||
});
|
||||
return settings;
|
||||
};
|
||||
|
||||
describe('Project settings', () => {
|
||||
it('starts with the default workflow, also when rebuilt from old events', () => {
|
||||
const project = newProject();
|
||||
expect(project.workflow.initialStatus()).toBe('BACKLOG');
|
||||
expect(project.workflow.canTransition('IN_QA', 'QA_FAILED')).toBe(true);
|
||||
|
||||
// Projects created before settings existed have no `settings` in their event.
|
||||
const { history } = replay(project, Project);
|
||||
delete (history[0].data as unknown as { project: { settings?: unknown } })
|
||||
.project.settings;
|
||||
const old = new Project(project.id);
|
||||
old.loadFromHistory(history);
|
||||
expect(old.settings).toEqual(defaultProjectSettings());
|
||||
});
|
||||
|
||||
it('lets owners and admins customise it, as an event that replays', () => {
|
||||
const project = newProject();
|
||||
project.updateSettings('owner', withStaging());
|
||||
|
||||
const event = project.getUncommittedEvents().at(-1);
|
||||
expect(event).toBeInstanceOf(ProjectSettingsChangedEvent);
|
||||
expect(project.workflow.allowedFrom('IN_REVIEW')).toEqual(['STAGING']);
|
||||
|
||||
const { rebuilt } = replay(project, Project);
|
||||
expect(rebuilt.workflow.status('STAGING')?.category).toBe(
|
||||
StatusCategory.QA,
|
||||
);
|
||||
expect(() => project.updateSettings('dev', withStaging())).toThrow(
|
||||
ForbiddenError,
|
||||
);
|
||||
});
|
||||
|
||||
it('archives values instead of deleting them', () => {
|
||||
const project = newProject();
|
||||
const settings = defaultProjectSettings();
|
||||
settings.areas = settings.areas.filter((a) => a.id !== 'MOBILE');
|
||||
expect(() => project.updateSettings('owner', settings)).toThrow(
|
||||
/archívalo/,
|
||||
);
|
||||
|
||||
const archived = defaultProjectSettings();
|
||||
archived.areas = archived.areas.map((a) =>
|
||||
a.id === 'MOBILE' ? { ...a, archived: true } : a,
|
||||
);
|
||||
project.updateSettings('owner', archived);
|
||||
expect(() => project.workflow.assertActiveArea('MOBILE')).toThrow(
|
||||
BusinessRuleError,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects settings that would break the board', () => {
|
||||
const project = newProject();
|
||||
const noStart = defaultProjectSettings();
|
||||
noStart.statuses
|
||||
.filter((s) => s.category === StatusCategory.TODO)
|
||||
.forEach((s) => (s.archived = true));
|
||||
expect(() => project.updateSettings('owner', noStart)).toThrow(
|
||||
ValidationError,
|
||||
);
|
||||
|
||||
const badTransition = defaultProjectSettings();
|
||||
badTransition.transitions['TODO'] = ['NOPE'];
|
||||
expect(() => project.updateSettings('owner', badTransition)).toThrow(
|
||||
/no existe/,
|
||||
);
|
||||
|
||||
const duplicate = defaultProjectSettings();
|
||||
duplicate.issueTypes.push({ ...duplicate.issueTypes[0] });
|
||||
expect(() => project.updateSettings('owner', duplicate)).toThrow(/id BUG/);
|
||||
});
|
||||
|
||||
it('drives tickets: types, transitions and names in the history', () => {
|
||||
const project = newProject();
|
||||
project.updateSettings('owner', withStaging());
|
||||
const workflow = project.workflow;
|
||||
const issue = new IssueFactory().create(
|
||||
{
|
||||
projectId: project.id,
|
||||
projectKey: 'WEB',
|
||||
number: 1,
|
||||
title: 'Investigar pagos',
|
||||
type: 'SPIKE',
|
||||
reporterId: 'dev',
|
||||
},
|
||||
workflow,
|
||||
);
|
||||
for (const to of ['TODO', 'IN_PROGRESS', 'IN_REVIEW', 'STAGING'])
|
||||
issue.changeStatus('dev', to, null, workflow);
|
||||
|
||||
const last = issue.getUncommittedEvents().at(-1) as IssueStatusChangedEvent;
|
||||
expect(last.meta).toEqual({
|
||||
fromName: 'En revisión',
|
||||
toName: 'En staging',
|
||||
fromCategory: StatusCategory.IN_PROGRESS,
|
||||
toCategory: StatusCategory.QA,
|
||||
});
|
||||
// The default IN_REVIEW → READY_FOR_QA was replaced by STAGING.
|
||||
expect(() =>
|
||||
issue.changeStatus('dev', 'READY_FOR_QA', null, workflow),
|
||||
).toThrow(BusinessRuleError);
|
||||
expect(() =>
|
||||
new IssueFactory().create(
|
||||
{
|
||||
projectId: project.id,
|
||||
projectKey: 'WEB',
|
||||
number: 2,
|
||||
title: 'x',
|
||||
type: 'EPIC',
|
||||
reporterId: 'dev',
|
||||
},
|
||||
workflow,
|
||||
),
|
||||
).toThrow(/Unknown ticket type/);
|
||||
});
|
||||
});
|
||||
395
libs/api/tracker/domain/src/lib/project/project-settings.ts
Normal file
395
libs/api/tracker/domain/src/lib/project/project-settings.ts
Normal file
@@ -0,0 +1,395 @@
|
||||
import {
|
||||
BusinessRuleError,
|
||||
ValidationError,
|
||||
assertThat,
|
||||
} from '@task-stream/api/shared/domain';
|
||||
import { IssueArea, IssueStatus as S, IssueType } from '../issue/issue.enums';
|
||||
|
||||
/**
|
||||
* What a status means, whatever it is called in each project: it keeps
|
||||
* releases ("everything finished"), "waiting for QA" and statistics working
|
||||
* with custom statuses.
|
||||
*/
|
||||
export enum StatusCategory {
|
||||
TODO = 'TODO',
|
||||
IN_PROGRESS = 'IN_PROGRESS',
|
||||
QA = 'QA',
|
||||
DONE = 'DONE',
|
||||
CANCELLED = 'CANCELLED',
|
||||
}
|
||||
|
||||
/** Colour tokens the web app knows (light and dark). */
|
||||
export const SETTING_COLORS = [
|
||||
'slate',
|
||||
'stone',
|
||||
'violet',
|
||||
'blue',
|
||||
'sky',
|
||||
'teal',
|
||||
'green',
|
||||
'amber',
|
||||
'orange',
|
||||
'red',
|
||||
'pink',
|
||||
] as const;
|
||||
export type SettingColor = (typeof SETTING_COLORS)[number];
|
||||
|
||||
/** Icons a ticket type can use (registered in the web app). */
|
||||
export const TYPE_ICONS = [
|
||||
'bug',
|
||||
'feature',
|
||||
'palette',
|
||||
'task',
|
||||
'qa',
|
||||
'flag',
|
||||
'star-fill',
|
||||
'rocket',
|
||||
'document',
|
||||
'light-bulb',
|
||||
'key',
|
||||
'support',
|
||||
] as const;
|
||||
export type TypeIcon = (typeof TYPE_ICONS)[number];
|
||||
|
||||
export interface WorkflowStatus {
|
||||
/** Stable id stored on tickets, e.g. IN_REVIEW. */
|
||||
id: string;
|
||||
name: string;
|
||||
category: StatusCategory;
|
||||
color: SettingColor;
|
||||
/** Entering it needs a reason (kept in the history). */
|
||||
requiresReason: boolean;
|
||||
/** Archived: no new tickets or transitions into it; existing ones keep it. */
|
||||
archived: boolean;
|
||||
}
|
||||
|
||||
export interface IssueTypeOption {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: TypeIcon;
|
||||
color: SettingColor;
|
||||
archived: boolean;
|
||||
}
|
||||
|
||||
export interface AreaOption {
|
||||
id: string;
|
||||
name: string;
|
||||
archived: boolean;
|
||||
}
|
||||
|
||||
/** Per-project configuration of the tracker. */
|
||||
export interface ProjectSettings {
|
||||
/** In board order. New tickets start in the first active TODO status. */
|
||||
statuses: WorkflowStatus[];
|
||||
/** Allowed moves: status id → status ids it can go to. */
|
||||
transitions: Record<string, string[]>;
|
||||
issueTypes: IssueTypeOption[];
|
||||
areas: AreaOption[];
|
||||
}
|
||||
|
||||
const status = (
|
||||
id: string,
|
||||
name: string,
|
||||
category: StatusCategory,
|
||||
color: SettingColor,
|
||||
requiresReason = false,
|
||||
): WorkflowStatus => ({
|
||||
id,
|
||||
name,
|
||||
category,
|
||||
color,
|
||||
requiresReason,
|
||||
archived: false,
|
||||
});
|
||||
|
||||
/** What every project starts with (and what projects created before settings had). */
|
||||
export const DEFAULT_PROJECT_SETTINGS: ProjectSettings = {
|
||||
statuses: [
|
||||
status(S.BACKLOG, 'Backlog', StatusCategory.TODO, 'stone'),
|
||||
status(S.TODO, 'Por hacer', StatusCategory.TODO, 'slate'),
|
||||
status(S.IN_PROGRESS, 'En curso', StatusCategory.IN_PROGRESS, 'violet'),
|
||||
status(S.IN_REVIEW, 'En revisión', StatusCategory.IN_PROGRESS, 'blue'),
|
||||
status(S.READY_FOR_QA, 'Listo para QA', StatusCategory.QA, 'amber'),
|
||||
status(S.IN_QA, 'En QA', StatusCategory.QA, 'orange'),
|
||||
status(S.QA_FAILED, 'QA fallido', StatusCategory.IN_PROGRESS, 'red', true),
|
||||
status(S.DONE, 'Hecho', StatusCategory.DONE, 'green'),
|
||||
status(S.CANCELLED, 'Cancelado', StatusCategory.CANCELLED, 'stone', true),
|
||||
],
|
||||
// QA is a first-class step: READY_FOR_QA → IN_QA → DONE | QA_FAILED → IN_PROGRESS.
|
||||
transitions: {
|
||||
[S.BACKLOG]: [S.TODO, S.IN_PROGRESS, S.CANCELLED],
|
||||
[S.TODO]: [S.BACKLOG, S.IN_PROGRESS, S.CANCELLED],
|
||||
[S.IN_PROGRESS]: [S.TODO, S.IN_REVIEW, S.READY_FOR_QA, S.CANCELLED],
|
||||
[S.IN_REVIEW]: [S.IN_PROGRESS, S.READY_FOR_QA, S.DONE],
|
||||
[S.READY_FOR_QA]: [S.IN_QA, S.IN_PROGRESS],
|
||||
[S.IN_QA]: [S.DONE, S.QA_FAILED],
|
||||
[S.QA_FAILED]: [S.IN_PROGRESS, S.TODO],
|
||||
[S.DONE]: [S.TODO, S.IN_PROGRESS],
|
||||
[S.CANCELLED]: [S.BACKLOG],
|
||||
},
|
||||
issueTypes: [
|
||||
{
|
||||
id: IssueType.BUG,
|
||||
name: 'Bug',
|
||||
icon: 'bug',
|
||||
color: 'red',
|
||||
archived: false,
|
||||
},
|
||||
{
|
||||
id: IssueType.FEATURE,
|
||||
name: 'Feature',
|
||||
icon: 'feature',
|
||||
color: 'violet',
|
||||
archived: false,
|
||||
},
|
||||
{
|
||||
id: IssueType.UI_CHANGE,
|
||||
name: 'Cambio de UI',
|
||||
icon: 'palette',
|
||||
color: 'pink',
|
||||
archived: false,
|
||||
},
|
||||
{
|
||||
id: IssueType.TASK,
|
||||
name: 'Tarea',
|
||||
icon: 'task',
|
||||
color: 'slate',
|
||||
archived: false,
|
||||
},
|
||||
{
|
||||
id: IssueType.QA,
|
||||
name: 'QA',
|
||||
icon: 'qa',
|
||||
color: 'amber',
|
||||
archived: false,
|
||||
},
|
||||
],
|
||||
areas: [
|
||||
{ id: IssueArea.FRONTEND, name: 'Frontend', archived: false },
|
||||
{ id: IssueArea.BACKEND, name: 'Backend', archived: false },
|
||||
{ id: IssueArea.MOBILE, name: 'Mobile', archived: false },
|
||||
{ id: IssueArea.QA, name: 'QA', archived: false },
|
||||
{ id: IssueArea.DESIGN, name: 'Diseño', archived: false },
|
||||
{ id: IssueArea.DEVOPS, name: 'DevOps', archived: false },
|
||||
],
|
||||
};
|
||||
|
||||
export function defaultProjectSettings(): ProjectSettings {
|
||||
return structuredClone(DEFAULT_PROJECT_SETTINGS);
|
||||
}
|
||||
|
||||
const ID = /^[A-Z][A-Z0-9_]{0,29}$/;
|
||||
const FINISHED = [StatusCategory.DONE, StatusCategory.CANCELLED];
|
||||
|
||||
/** Read-only view of a project's workflow, used by tickets and queries. */
|
||||
export class ProjectWorkflow {
|
||||
constructor(readonly settings: ProjectSettings) {}
|
||||
|
||||
static default(): ProjectWorkflow {
|
||||
return new ProjectWorkflow(DEFAULT_PROJECT_SETTINGS);
|
||||
}
|
||||
|
||||
status(id: string): WorkflowStatus | undefined {
|
||||
return this.settings.statuses.find((s) => s.id === id);
|
||||
}
|
||||
|
||||
/** Name to show, also for ids no longer configured. */
|
||||
statusName(id: string): string {
|
||||
return (
|
||||
this.status(id)?.name ??
|
||||
DEFAULT_PROJECT_SETTINGS.statuses.find((s) => s.id === id)?.name ??
|
||||
id
|
||||
);
|
||||
}
|
||||
|
||||
typeName(id: string): string {
|
||||
return (
|
||||
this.settings.issueTypes.find((t) => t.id === id)?.name ??
|
||||
DEFAULT_PROJECT_SETTINGS.issueTypes.find((t) => t.id === id)?.name ??
|
||||
id
|
||||
);
|
||||
}
|
||||
|
||||
areaName(id: string): string {
|
||||
return (
|
||||
this.settings.areas.find((a) => a.id === id)?.name ??
|
||||
DEFAULT_PROJECT_SETTINGS.areas.find((a) => a.id === id)?.name ??
|
||||
id
|
||||
);
|
||||
}
|
||||
|
||||
categoryOf(id: string): StatusCategory {
|
||||
return (
|
||||
this.status(id)?.category ??
|
||||
DEFAULT_PROJECT_SETTINGS.statuses.find((s) => s.id === id)?.category ??
|
||||
StatusCategory.TODO
|
||||
);
|
||||
}
|
||||
|
||||
isFinished(id: string): boolean {
|
||||
return FINISHED.includes(this.categoryOf(id));
|
||||
}
|
||||
|
||||
/** Where new tickets start: the first active status of category TODO. */
|
||||
initialStatus(): string {
|
||||
const initial = this.settings.statuses.find(
|
||||
(s) => !s.archived && s.category === StatusCategory.TODO,
|
||||
);
|
||||
if (!initial)
|
||||
throw new BusinessRuleError('The project has no active starting status');
|
||||
return initial.id;
|
||||
}
|
||||
|
||||
/** Active statuses a ticket can move to from `from`. */
|
||||
allowedFrom(from: string): string[] {
|
||||
return (this.settings.transitions[from] ?? []).filter(
|
||||
(to) => this.status(to) && !this.status(to)?.archived,
|
||||
);
|
||||
}
|
||||
|
||||
canTransition(from: string, to: string): boolean {
|
||||
return this.allowedFrom(from).includes(to);
|
||||
}
|
||||
|
||||
requiresReason(id: string): boolean {
|
||||
return this.status(id)?.requiresReason ?? false;
|
||||
}
|
||||
|
||||
assertActiveType(id: string): void {
|
||||
const type = this.settings.issueTypes.find((t) => t.id === id);
|
||||
if (!type) throw new ValidationError(`Unknown ticket type ${id}`);
|
||||
if (type.archived)
|
||||
throw new BusinessRuleError(`Ticket type ${type.name} is archived`);
|
||||
}
|
||||
|
||||
assertActiveArea(id: string | null | undefined): void {
|
||||
if (id === null || id === undefined) return;
|
||||
const area = this.settings.areas.find((a) => a.id === id);
|
||||
if (!area) throw new ValidationError(`Unknown area ${id}`);
|
||||
if (area.archived)
|
||||
throw new BusinessRuleError(`Area ${area.name} is archived`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks and normalises new settings against the previous ones. Values can be
|
||||
* added, renamed, reordered or archived, never deleted: tickets, history and
|
||||
* statistics keep referring to them.
|
||||
*/
|
||||
export function validateProjectSettings(
|
||||
next: ProjectSettings,
|
||||
previous: ProjectSettings,
|
||||
): ProjectSettings {
|
||||
const statuses = next.statuses.map((s) => ({
|
||||
id: normId(s.id),
|
||||
name: normName(s.name),
|
||||
category: s.category,
|
||||
color: s.color,
|
||||
requiresReason: !!s.requiresReason,
|
||||
archived: !!s.archived,
|
||||
}));
|
||||
const issueTypes = next.issueTypes.map((t) => ({
|
||||
id: normId(t.id),
|
||||
name: normName(t.name),
|
||||
icon: t.icon,
|
||||
color: t.color,
|
||||
archived: !!t.archived,
|
||||
}));
|
||||
const areas = next.areas.map((a) => ({
|
||||
id: normId(a.id),
|
||||
name: normName(a.name),
|
||||
archived: !!a.archived,
|
||||
}));
|
||||
|
||||
for (const [label, list, before] of [
|
||||
['estado', statuses, previous.statuses],
|
||||
['tipo', issueTypes, previous.issueTypes],
|
||||
['área', areas, previous.areas],
|
||||
] as const) {
|
||||
const ids = list.map((item) => item.id);
|
||||
const duplicate = ids.find((id, i) => ids.indexOf(id) !== i);
|
||||
assertThat(!duplicate, `Hay dos ${label}s con el id ${duplicate}`);
|
||||
const removed = before.find((item) => !ids.includes(item.id));
|
||||
if (removed) {
|
||||
throw new BusinessRuleError(
|
||||
`No se puede eliminar el ${label} «${removed.name}»: archívalo para que no se use más`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const s of statuses) {
|
||||
assertThat(
|
||||
Object.values(StatusCategory).includes(s.category),
|
||||
`Categoría no válida en el estado ${s.name}`,
|
||||
);
|
||||
assertThat(
|
||||
SETTING_COLORS.includes(s.color),
|
||||
`Color no válido en el estado ${s.name}`,
|
||||
);
|
||||
}
|
||||
for (const t of issueTypes) {
|
||||
assertThat(
|
||||
TYPE_ICONS.includes(t.icon),
|
||||
`Icono no válido en el tipo ${t.name}`,
|
||||
);
|
||||
assertThat(
|
||||
SETTING_COLORS.includes(t.color),
|
||||
`Color no válido en el tipo ${t.name}`,
|
||||
);
|
||||
}
|
||||
const active = statuses.filter((s) => !s.archived);
|
||||
assertThat(
|
||||
active.some((s) => s.category === StatusCategory.TODO),
|
||||
'Hace falta al menos un estado activo «pendiente» donde empiecen los tickets',
|
||||
);
|
||||
assertThat(
|
||||
active.some((s) => s.category === StatusCategory.DONE),
|
||||
'Hace falta al menos un estado activo «hecho»',
|
||||
);
|
||||
assertThat(
|
||||
issueTypes.some((t) => !t.archived),
|
||||
'Hace falta al menos un tipo de ticket activo',
|
||||
);
|
||||
|
||||
const known = new Set(statuses.map((s) => s.id));
|
||||
const transitions: Record<string, string[]> = {};
|
||||
for (const [from, targets] of Object.entries(next.transitions ?? {})) {
|
||||
const source = normId(from);
|
||||
assertThat(
|
||||
known.has(source),
|
||||
`Transición desde un estado que no existe: ${from}`,
|
||||
);
|
||||
const to = [...new Set(targets.map(normId))];
|
||||
for (const target of to) {
|
||||
assertThat(
|
||||
known.has(target),
|
||||
`Transición hacia un estado que no existe: ${target}`,
|
||||
);
|
||||
assertThat(
|
||||
target !== source,
|
||||
`Un estado no puede pasar a sí mismo (${source})`,
|
||||
);
|
||||
}
|
||||
if (to.length) transitions[source] = to;
|
||||
}
|
||||
|
||||
return { statuses, transitions, issueTypes, areas };
|
||||
}
|
||||
|
||||
function normId(id: string): string {
|
||||
const value = (id ?? '').trim().toUpperCase();
|
||||
assertThat(
|
||||
ID.test(value),
|
||||
`Id no válido «${id}»: mayúsculas, números y _, empezando por letra`,
|
||||
);
|
||||
return value;
|
||||
}
|
||||
|
||||
function normName(name: string): string {
|
||||
const value = (name ?? '').trim();
|
||||
assertThat(
|
||||
value.length > 0 && value.length <= 40,
|
||||
'Los nombres llevan 1-40 caracteres',
|
||||
);
|
||||
return value;
|
||||
}
|
||||
@@ -8,8 +8,15 @@ import { ProjectCreatedEvent } from '../events/project-created.event';
|
||||
import { ProjectMemberAddedEvent } from '../events/project-member-added.event';
|
||||
import { ProjectMemberRemovedEvent } from '../events/project-member-removed.event';
|
||||
import { ProjectMemberRoleChangedEvent } from '../events/project-member-role-changed.event';
|
||||
import { ProjectSettingsChangedEvent } from '../events/project-settings-changed.event';
|
||||
import { ProjectUpdatedEvent } from '../events/project-updated.event';
|
||||
import { ProjectRole } from './project-role';
|
||||
import {
|
||||
defaultProjectSettings,
|
||||
ProjectSettings,
|
||||
ProjectWorkflow,
|
||||
validateProjectSettings,
|
||||
} from './project-settings';
|
||||
|
||||
export interface ProjectMember {
|
||||
userId: string;
|
||||
@@ -24,6 +31,8 @@ export interface ProjectProps {
|
||||
description: string | null;
|
||||
ownerId: string;
|
||||
members: ProjectMember[];
|
||||
/** Absent in projects created before settings existed: defaults apply. */
|
||||
settings?: ProjectSettings;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -38,6 +47,7 @@ export class Project extends VersionedAggregateRoot implements ProjectProps {
|
||||
public description: string | null = null;
|
||||
public ownerId!: string;
|
||||
public members: ProjectMember[] = [];
|
||||
public settings: ProjectSettings = defaultProjectSettings();
|
||||
public createdAt!: string;
|
||||
public updatedAt!: string;
|
||||
|
||||
@@ -46,6 +56,11 @@ export class Project extends VersionedAggregateRoot implements ProjectProps {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
/** Statuses, transitions, types and areas of this project. */
|
||||
get workflow(): ProjectWorkflow {
|
||||
return new ProjectWorkflow(this.settings);
|
||||
}
|
||||
|
||||
roleOf(userId: string): ProjectRole | null {
|
||||
return this.members.find((m) => m.userId === userId)?.role ?? null;
|
||||
}
|
||||
@@ -98,6 +113,14 @@ export class Project extends VersionedAggregateRoot implements ProjectProps {
|
||||
this.apply(new ProjectUpdatedEvent(actorId, this.id, patch));
|
||||
}
|
||||
|
||||
/** Owners and admins customise statuses, transitions, types and areas. */
|
||||
updateSettings(actorId: string, next: ProjectSettings): void {
|
||||
this.assertCanManage(actorId);
|
||||
const settings = validateProjectSettings(next, this.settings);
|
||||
if (JSON.stringify(settings) === JSON.stringify(this.settings)) return;
|
||||
this.apply(new ProjectSettingsChangedEvent(actorId, this.id, settings));
|
||||
}
|
||||
|
||||
addMember(actorId: string, userId: string, role: ProjectRole): void {
|
||||
this.assertCanManage(actorId);
|
||||
assertThat(role !== ProjectRole.OWNER, 'A project can only have one owner');
|
||||
@@ -154,6 +177,9 @@ export class Project extends VersionedAggregateRoot implements ProjectProps {
|
||||
this.description = project.description;
|
||||
this.ownerId = project.ownerId;
|
||||
this.members = project.members.map((m) => ({ ...m }));
|
||||
this.settings = structuredClone(
|
||||
project.settings ?? defaultProjectSettings(),
|
||||
);
|
||||
this.createdAt = project.createdAt;
|
||||
this.updatedAt = project.updatedAt;
|
||||
}
|
||||
@@ -163,6 +189,11 @@ export class Project extends VersionedAggregateRoot implements ProjectProps {
|
||||
this.updatedAt = event.occurredAt;
|
||||
}
|
||||
|
||||
onProjectSettingsChangedEvent(event: ProjectSettingsChangedEvent): void {
|
||||
this.settings = structuredClone(event.settings);
|
||||
this.updatedAt = event.occurredAt;
|
||||
}
|
||||
|
||||
onProjectMemberAddedEvent(event: ProjectMemberAddedEvent): void {
|
||||
this.members = [
|
||||
...this.members,
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { StatusCategory } from '../project/project-settings';
|
||||
import { UserRefReadModel } from './user-ref.read-model';
|
||||
|
||||
/**
|
||||
* One step in a ticket's life (projected from its events): the raw material
|
||||
* of the statistics. `status` entries record where the ticket went (with the
|
||||
* category it had then), `release` entries which release it joined or left.
|
||||
*/
|
||||
export class IssueTimelineEntry {
|
||||
/** Id of the event that produced it (projection is idempotent). */
|
||||
id!: string;
|
||||
issueId!: string;
|
||||
projectId!: string;
|
||||
at!: string;
|
||||
kind!: 'status' | 'release';
|
||||
status?: string;
|
||||
category?: StatusCategory;
|
||||
/** Assignee when the status changed (throughput per person). */
|
||||
assigneeId?: string | null;
|
||||
releaseId?: string | null;
|
||||
}
|
||||
|
||||
export class DurationStats {
|
||||
count!: number;
|
||||
averageDays!: number | null;
|
||||
/** 85th percentile: "85% of tickets take at most this long". */
|
||||
p85Days!: number | null;
|
||||
}
|
||||
|
||||
/** Statistics of a project over a period. */
|
||||
export class ProjectAnalyticsReadModel {
|
||||
from!: string;
|
||||
to!: string;
|
||||
/** Per week (Monday): tickets created and tickets finished. */
|
||||
createdVsResolved!: { week: string; created: number; resolved: number }[];
|
||||
/** Tickets in each status at the end of every day (cumulative flow). */
|
||||
cumulativeFlow!: {
|
||||
dates: string[];
|
||||
series: { status: string; counts: number[] }[];
|
||||
};
|
||||
/** From starting work to done. */
|
||||
cycleTime!: DurationStats;
|
||||
/** From creation to done. */
|
||||
leadTime!: DurationStats;
|
||||
/** Tickets finished per person and week. */
|
||||
throughput!: { week: string; user: UserRefReadModel | null; count: number }[];
|
||||
/** Tickets leaving QA: to done (passed) or back to work (failed). */
|
||||
qa!: { passed: number; failed: number; failureRate: number | null };
|
||||
}
|
||||
|
||||
/** Scope vs finished tickets of a release, day by day. */
|
||||
export class ReleaseBurnupReadModel {
|
||||
releaseId!: string;
|
||||
targetDate!: string | null;
|
||||
dates!: string[];
|
||||
scope!: number[];
|
||||
done!: number[];
|
||||
}
|
||||
@@ -5,3 +5,4 @@ export * from './comment.read-model';
|
||||
export * from './release.read-model';
|
||||
export * from './activity.read-model';
|
||||
export * from './project-stats.read-model';
|
||||
export * from './analytics.read-model';
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import {
|
||||
IssueArea,
|
||||
IssueLinkType,
|
||||
IssuePriority,
|
||||
IssueStatus,
|
||||
IssueType,
|
||||
} from '../issue/issue.enums';
|
||||
import { IssueLinkType, IssuePriority } from '../issue/issue.enums';
|
||||
import { StatusCategory } from '../project/project-settings';
|
||||
import { UserRefReadModel } from './user-ref.read-model';
|
||||
|
||||
export type LinkDirection = 'OUTWARD' | 'INWARD';
|
||||
@@ -23,11 +18,14 @@ export class IssueReadModel {
|
||||
projectKey!: string;
|
||||
number!: number;
|
||||
key!: string;
|
||||
type!: IssueType;
|
||||
area!: IssueArea | null;
|
||||
/** Ids from the project's settings. */
|
||||
type!: string;
|
||||
area!: string | null;
|
||||
title!: string;
|
||||
description!: string | null;
|
||||
status!: IssueStatus;
|
||||
status!: string;
|
||||
/** Category of `status` (kept in sync when the project's settings change). */
|
||||
statusCategory!: StatusCategory;
|
||||
priority!: IssuePriority;
|
||||
reporter!: UserRefReadModel;
|
||||
assignee!: UserRefReadModel | null;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IssuePriority, IssueStatus, IssueType } from '../issue/issue.enums';
|
||||
import { IssuePriority } from '../issue/issue.enums';
|
||||
import { UserRefReadModel } from './user-ref.read-model';
|
||||
|
||||
export class CountBy<TKey = string> {
|
||||
@@ -10,8 +10,9 @@ export class CountBy<TKey = string> {
|
||||
export class ProjectStatsReadModel {
|
||||
total!: number;
|
||||
open!: number;
|
||||
byStatus!: CountBy<IssueStatus>[];
|
||||
byType!: CountBy<IssueType>[];
|
||||
/** Keys are status / type ids of the project's settings. */
|
||||
byStatus!: CountBy<string>[];
|
||||
byType!: CountBy<string>[];
|
||||
byPriority!: CountBy<IssuePriority>[];
|
||||
byArea!: CountBy<string>[];
|
||||
/** Open tickets per person (user null = unassigned). */
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ProjectRole } from '../project/project-role';
|
||||
import { ProjectSettings } from '../project/project-settings';
|
||||
import { UserRefReadModel } from './user-ref.read-model';
|
||||
|
||||
export class ProjectMemberReadModel {
|
||||
@@ -13,6 +14,8 @@ export class ProjectReadModel {
|
||||
description!: string | null;
|
||||
owner!: UserRefReadModel;
|
||||
members!: ProjectMemberReadModel[];
|
||||
/** Statuses, transitions, ticket types and areas. */
|
||||
settings!: ProjectSettings;
|
||||
createdAt!: string;
|
||||
updatedAt!: string;
|
||||
/** Position of the last projected event. */
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { IssueStatus } from '../issue/issue.enums';
|
||||
import { ReleaseStatus } from '../release/release-status';
|
||||
|
||||
export class ReleaseReadModel {
|
||||
@@ -17,5 +16,5 @@ export class ReleaseProgressReadModel {
|
||||
total!: number;
|
||||
finished!: number;
|
||||
percent!: number;
|
||||
byStatus!: { key: IssueStatus; count: number }[];
|
||||
byStatus!: { key: string; count: number }[];
|
||||
}
|
||||
|
||||
@@ -8,7 +8,9 @@ Adaptadores de salida del tracker.
|
||||
## Contenido
|
||||
|
||||
- `postgres/`: lookups de escritura (`project_keys`, `issue_sequences`, `release_names`) y `ReleaseIssuesRepository` (consulta el event store).
|
||||
- `mongo/`: esquemas del read model, repositorios `Find*` y `UpsertMaterialized*` (upsert idempotente por versión).
|
||||
- `mongo/`: esquemas del read model (incluida la línea temporal `issue_timeline`), repositorios `Find*` y
|
||||
`UpsertMaterialized*` (upsert idempotente por versión) y `StatusCategoryBackfill` (al arrancar, rellena la categoría
|
||||
del estado en los tickets proyectados antes de existir).
|
||||
- `realtime/`: `RedisChangeFeed` (canales de Redis pub/sub por proyecto, ticket y usuario).
|
||||
- `identity/`: `IdentityUserDirectory`, capa anticorrupción hacia el contexto de identidad.
|
||||
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/api/tracker/infrastructure/src",
|
||||
"projectType": "library",
|
||||
"tags": [
|
||||
"scope:api-tracker",
|
||||
"type:infrastructure"
|
||||
],
|
||||
"tags": ["scope:api-tracker", "type:infrastructure"],
|
||||
"// targets": "to see all targets run: nx show project api-tracker-infrastructure --web",
|
||||
"targets": {}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import {
|
||||
CrossProjectIssueFilter,
|
||||
FindActivityRepository,
|
||||
FindCommentsRepository,
|
||||
FindIssuesRepository,
|
||||
FindProjectsRepository,
|
||||
FindReleasesRepository,
|
||||
FindTimelineRepository,
|
||||
IssueFilter,
|
||||
Page,
|
||||
} from '@task-stream/api/tracker/application';
|
||||
@@ -13,14 +15,16 @@ import {
|
||||
ActivityReadModel,
|
||||
CommentReadModel,
|
||||
CountBy,
|
||||
FINISHED_STATUSES,
|
||||
defaultProjectSettings,
|
||||
IssueReadModel,
|
||||
IssueStatus,
|
||||
IssueTimelineEntry,
|
||||
ProjectReadModel,
|
||||
ProjectRole,
|
||||
ProjectStatsReadModel,
|
||||
ReleaseProgressReadModel,
|
||||
ReleaseReadModel,
|
||||
ReleaseStatus,
|
||||
StatusCategory,
|
||||
UserRefReadModel,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { FilterQuery, Model } from 'mongoose';
|
||||
@@ -31,6 +35,7 @@ import {
|
||||
ProjectDoc,
|
||||
ReadModels,
|
||||
ReleaseDoc,
|
||||
TimelineDoc,
|
||||
fromDoc,
|
||||
issueFromDoc,
|
||||
} from './read-model.schemas';
|
||||
@@ -40,9 +45,15 @@ import {
|
||||
const escapeRegex = (text: string) =>
|
||||
text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
const FINISHED = [StatusCategory.DONE, StatusCategory.CANCELLED];
|
||||
|
||||
function toProject(doc: ProjectDoc): ProjectReadModel {
|
||||
const { memberIds: _ignored, ...rest } = doc;
|
||||
return fromDoc(rest);
|
||||
// Projects projected before settings existed use the defaults.
|
||||
return {
|
||||
...fromDoc(rest),
|
||||
settings: rest.settings ?? defaultProjectSettings(),
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -123,6 +134,29 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
|
||||
return { items: docs.map(issueFromDoc), total };
|
||||
}
|
||||
|
||||
async searchAcross(
|
||||
filter: CrossProjectIssueFilter,
|
||||
): Promise<Page<IssueReadModel>> {
|
||||
if (filter.projectIds.length === 0) return { items: [], total: 0 };
|
||||
const query: Record<string, unknown> = {
|
||||
projectId: { $in: filter.projectIds },
|
||||
};
|
||||
if (filter.assigneeId) query['assignee.id'] = filter.assigneeId;
|
||||
if (filter.statuses?.length) query['status'] = { $in: filter.statuses };
|
||||
if (filter.categories?.length)
|
||||
query['statusCategory'] = { $in: filter.categories };
|
||||
const [docs, total] = await Promise.all([
|
||||
this.issues
|
||||
.find(query)
|
||||
.sort({ updatedAt: -1 })
|
||||
.skip(filter.offset)
|
||||
.limit(filter.limit)
|
||||
.lean<IssueDoc[]>(),
|
||||
this.issues.countDocuments(query),
|
||||
]);
|
||||
return { items: docs.map(issueFromDoc), total };
|
||||
}
|
||||
|
||||
async stats(projectId: string): Promise<ProjectStatsReadModel> {
|
||||
const countBy = (field: string) => [
|
||||
{ $group: { _id: `$${field}`, count: { $sum: 1 } } },
|
||||
@@ -131,7 +165,7 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
|
||||
const [facets] = await this.issues.aggregate<{
|
||||
total: { count: number }[];
|
||||
open: { count: number }[];
|
||||
byStatus: { _id: IssueStatus; count: number }[];
|
||||
byStatus: { _id: string; count: number }[];
|
||||
byType: { _id: string; count: number }[];
|
||||
byPriority: { _id: string; count: number }[];
|
||||
byArea: { _id: string | null; count: number }[];
|
||||
@@ -142,7 +176,7 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
|
||||
$facet: {
|
||||
total: [{ $count: 'count' }],
|
||||
open: [
|
||||
{ $match: { status: { $nin: FINISHED_STATUSES } } },
|
||||
{ $match: { statusCategory: { $nin: FINISHED } } },
|
||||
{ $count: 'count' },
|
||||
],
|
||||
byStatus: countBy('status'),
|
||||
@@ -150,7 +184,7 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
|
||||
byPriority: countBy('priority'),
|
||||
byArea: countBy('area'),
|
||||
byAssignee: [
|
||||
{ $match: { status: { $nin: FINISHED_STATUSES } } },
|
||||
{ $match: { statusCategory: { $nin: FINISHED } } },
|
||||
...countBy('assignee'),
|
||||
],
|
||||
},
|
||||
@@ -173,23 +207,36 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
|
||||
};
|
||||
}
|
||||
|
||||
async findByProject(projectId: string): Promise<IssueReadModel[]> {
|
||||
const docs = await this.issues.find({ projectId }).lean<IssueDoc[]>();
|
||||
return docs.map(issueFromDoc);
|
||||
}
|
||||
|
||||
async releaseProgress(releaseId: string): Promise<ReleaseProgressReadModel> {
|
||||
const rows = await this.issues.aggregate<{
|
||||
_id: IssueStatus;
|
||||
_id: { status: string; category: StatusCategory };
|
||||
count: number;
|
||||
}>([
|
||||
{ $match: { 'release.id': releaseId } },
|
||||
{ $group: { _id: '$status', count: { $sum: 1 } } },
|
||||
{
|
||||
$group: {
|
||||
_id: { status: '$status', category: '$statusCategory' },
|
||||
count: { $sum: 1 },
|
||||
},
|
||||
},
|
||||
]);
|
||||
const total = rows.reduce((sum, r) => sum + r.count, 0);
|
||||
const finished = rows
|
||||
.filter((r) => FINISHED_STATUSES.includes(r._id))
|
||||
.filter((r) => FINISHED.includes(r._id.category))
|
||||
.reduce((sum, r) => sum + r.count, 0);
|
||||
const byStatus = new Map<string, number>();
|
||||
for (const r of rows)
|
||||
byStatus.set(r._id.status, (byStatus.get(r._id.status) ?? 0) + r.count);
|
||||
return {
|
||||
total,
|
||||
finished,
|
||||
percent: total === 0 ? 0 : Math.round((finished / total) * 100),
|
||||
byStatus: rows.map((r) => ({ key: r._id, count: r.count })),
|
||||
byStatus: [...byStatus].map(([key, count]) => ({ key, count })),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -197,6 +244,8 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
|
||||
function toMongoFilter(filter: IssueFilter): FilterQuery<IssueDoc> {
|
||||
const query: FilterQuery<IssueDoc> = { projectId: filter.projectId };
|
||||
if (filter.statuses?.length) query.status = { $in: filter.statuses };
|
||||
if (filter.categories?.length)
|
||||
query['statusCategory'] = { $in: filter.categories };
|
||||
if (filter.types?.length) query['type'] = { $in: filter.types };
|
||||
if (filter.areas?.length) query['area'] = { $in: filter.areas };
|
||||
if (filter.priorities?.length) query['priority'] = { $in: filter.priorities };
|
||||
@@ -273,6 +322,25 @@ export class MongoFindReleasesRepository extends FindReleasesRepository {
|
||||
.lean<ReleaseDoc[]>();
|
||||
return docs.map(fromDoc);
|
||||
}
|
||||
|
||||
async findUpcoming(
|
||||
projectIds: string[],
|
||||
limit: number,
|
||||
): Promise<ReleaseReadModel[]> {
|
||||
if (projectIds.length === 0) return [];
|
||||
const docs = await this.releases
|
||||
.find({ projectId: { $in: projectIds }, status: ReleaseStatus.PLANNED })
|
||||
.lean<ReleaseDoc[]>();
|
||||
// Ascending targetDate puts nulls first in Mongo: sort here instead.
|
||||
return docs
|
||||
.map(fromDoc)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(a.targetDate ?? '9999').localeCompare(b.targetDate ?? '9999') ||
|
||||
a.name.localeCompare(b.name),
|
||||
)
|
||||
.slice(0, limit);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -301,3 +369,21 @@ export class MongoFindActivityRepository extends FindActivityRepository {
|
||||
return docs.map(fromDoc);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MongoFindTimelineRepository extends FindTimelineRepository {
|
||||
constructor(
|
||||
@InjectModel(ReadModels.Timeline)
|
||||
private readonly timeline: Model<TimelineDoc>,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async findByProject(projectId: string): Promise<IssueTimelineEntry[]> {
|
||||
const docs = await this.timeline
|
||||
.find({ projectId })
|
||||
.sort({ at: 1 })
|
||||
.lean<TimelineDoc[]>();
|
||||
return docs.map(fromDoc);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
CommentReadModel,
|
||||
IssueLinkReadModel,
|
||||
IssueReadModel,
|
||||
IssueTimelineEntry,
|
||||
ProjectReadModel,
|
||||
ReleaseReadModel,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
@@ -21,6 +22,7 @@ export type IssueDoc = Doc<Omit<IssueReadModel, 'links'>> & {
|
||||
export type CommentDoc = Doc<CommentReadModel>;
|
||||
export type ReleaseDoc = Doc<ReleaseReadModel>;
|
||||
export type ActivityDoc = Doc<ActivityReadModel>;
|
||||
export type TimelineDoc = Doc<IssueTimelineEntry>;
|
||||
|
||||
export const ReadModels = {
|
||||
Project: 'ProjectReadModel',
|
||||
@@ -28,6 +30,7 @@ export const ReadModels = {
|
||||
Comment: 'CommentReadModel',
|
||||
Release: 'ReleaseReadModel',
|
||||
Activity: 'ActivityReadModel',
|
||||
Timeline: 'IssueTimelineReadModel',
|
||||
} as const;
|
||||
|
||||
const userRef = { _id: false, id: String, name: String };
|
||||
@@ -59,6 +62,7 @@ export const IssueSchema = new Schema<IssueDoc>(
|
||||
projectId: { type: String, index: true },
|
||||
key: { type: String, unique: true },
|
||||
status: String,
|
||||
statusCategory: String,
|
||||
reporter: userRef,
|
||||
assignee: userRef,
|
||||
release: { _id: false, id: String, name: String },
|
||||
@@ -70,6 +74,7 @@ export const IssueSchema = new Schema<IssueDoc>(
|
||||
{ ...loose, collection: 'issues_view' },
|
||||
);
|
||||
IssueSchema.index({ projectId: 1, status: 1, updatedAt: -1 });
|
||||
IssueSchema.index({ projectId: 1, statusCategory: 1 });
|
||||
IssueSchema.index({ 'assignee.id': 1 });
|
||||
IssueSchema.index({ 'release.id': 1 });
|
||||
IssueSchema.index({ title: 'text', description: 'text', key: 'text' });
|
||||
@@ -102,12 +107,20 @@ export const ActivitySchema = new Schema<ActivityDoc>(
|
||||
ActivitySchema.index({ projectId: 1, occurredAt: -1 });
|
||||
ActivitySchema.index({ issueId: 1, occurredAt: -1 });
|
||||
|
||||
/** Every status and release step of every ticket (statistics). */
|
||||
export const TimelineSchema = new Schema<TimelineDoc>(
|
||||
{ _id: String, projectId: String, issueId: String, at: String },
|
||||
{ ...loose, collection: 'issue_timeline' },
|
||||
);
|
||||
TimelineSchema.index({ projectId: 1, at: 1 });
|
||||
|
||||
export const READ_MODEL_SCHEMAS = [
|
||||
{ name: ReadModels.Project, schema: ProjectSchema },
|
||||
{ name: ReadModels.Issue, schema: IssueSchema },
|
||||
{ name: ReadModels.Comment, schema: CommentSchema },
|
||||
{ name: ReadModels.Release, schema: ReleaseSchema },
|
||||
{ name: ReadModels.Activity, schema: ActivitySchema },
|
||||
{ name: ReadModels.Timeline, schema: TimelineSchema },
|
||||
];
|
||||
|
||||
export function fromDoc<T extends { _id: string }>(
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import { ProjectWorkflow } from '@task-stream/api/tracker/domain';
|
||||
import { Model } from 'mongoose';
|
||||
import { IssueDoc, ProjectDoc, ReadModels } from './read-model.schemas';
|
||||
|
||||
/**
|
||||
* Tickets projected before statuses had categories lack `statusCategory`;
|
||||
* fill it in once at startup from each project's settings (or the defaults).
|
||||
*/
|
||||
@Injectable()
|
||||
export class StatusCategoryBackfill implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(StatusCategoryBackfill.name);
|
||||
|
||||
constructor(
|
||||
@InjectModel(ReadModels.Issue) private readonly issues: Model<IssueDoc>,
|
||||
@InjectModel(ReadModels.Project)
|
||||
private readonly projects: Model<ProjectDoc>,
|
||||
) {}
|
||||
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
const missing = { statusCategory: { $exists: false } };
|
||||
const groups = await this.issues.aggregate<{
|
||||
_id: { projectId: string; status: string };
|
||||
}>([
|
||||
{ $match: missing },
|
||||
{ $group: { _id: { projectId: '$projectId', status: '$status' } } },
|
||||
]);
|
||||
if (groups.length === 0) return;
|
||||
|
||||
const projectIds = [...new Set(groups.map((g) => g._id.projectId))];
|
||||
const projects = await this.projects
|
||||
.find({ _id: { $in: projectIds } }, { settings: 1 })
|
||||
.lean<Pick<ProjectDoc, '_id' | 'settings'>[]>();
|
||||
const workflows = new Map(
|
||||
projects.map((p) => [
|
||||
p._id,
|
||||
p.settings
|
||||
? new ProjectWorkflow(p.settings)
|
||||
: ProjectWorkflow.default(),
|
||||
]),
|
||||
);
|
||||
const result = await this.issues.bulkWrite(
|
||||
groups.map(({ _id: { projectId, status } }) => ({
|
||||
updateMany: {
|
||||
filter: { ...missing, projectId, status },
|
||||
update: {
|
||||
$set: {
|
||||
statusCategory: (
|
||||
workflows.get(projectId) ?? ProjectWorkflow.default()
|
||||
).categoryOf(status),
|
||||
},
|
||||
},
|
||||
},
|
||||
})),
|
||||
);
|
||||
this.logger.log(
|
||||
`Status category filled in on ${result.modifiedCount} tickets`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,14 +6,17 @@ import {
|
||||
UpsertMaterializedIssueRepository,
|
||||
UpsertMaterializedProjectRepository,
|
||||
UpsertMaterializedReleaseRepository,
|
||||
UpsertMaterializedTimelineRepository,
|
||||
} from '@task-stream/api/tracker/application';
|
||||
import {
|
||||
ActivityReadModel,
|
||||
CommentReadModel,
|
||||
IssueLinkReadModel,
|
||||
IssueReadModel,
|
||||
IssueTimelineEntry,
|
||||
ProjectReadModel,
|
||||
ReleaseReadModel,
|
||||
StatusCategory,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { Model } from 'mongoose';
|
||||
import {
|
||||
@@ -23,6 +26,7 @@ import {
|
||||
ProjectDoc,
|
||||
ReadModels,
|
||||
ReleaseDoc,
|
||||
TimelineDoc,
|
||||
} from './read-model.schemas';
|
||||
import { upsertIfNewer } from './upsert-if-newer';
|
||||
|
||||
@@ -101,6 +105,19 @@ export class MongoUpsertMaterializedIssueRepository extends UpsertMaterializedIs
|
||||
await this.issues.updateOne({ _id: issueId }, { $set: { commentCount } });
|
||||
}
|
||||
|
||||
async recategorize(
|
||||
projectId: string,
|
||||
categories: Record<string, StatusCategory>,
|
||||
) {
|
||||
const ops = Object.entries(categories).map(([status, category]) => ({
|
||||
updateMany: {
|
||||
filter: { projectId, status, statusCategory: { $ne: category } },
|
||||
update: { $set: { statusCategory: category } },
|
||||
},
|
||||
}));
|
||||
if (ops.length) await this.issues.bulkWrite(ops);
|
||||
}
|
||||
|
||||
async renameRelease(releaseId: string, name: string) {
|
||||
await this.issues.updateMany(
|
||||
{ 'release.id': releaseId },
|
||||
@@ -167,3 +184,21 @@ export class MongoUpsertMaterializedActivityRepository extends UpsertMaterialize
|
||||
return result.upsertedCount > 0;
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MongoUpsertMaterializedTimelineRepository extends UpsertMaterializedTimelineRepository {
|
||||
constructor(
|
||||
@InjectModel(ReadModels.Timeline)
|
||||
private readonly timeline: Model<TimelineDoc>,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async record({ id, ...fields }: IssueTimelineEntry): Promise<void> {
|
||||
await this.timeline.updateOne(
|
||||
{ _id: id },
|
||||
{ $setOnInsert: fields },
|
||||
{ upsert: true },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
FindIssuesRepository,
|
||||
FindProjectsRepository,
|
||||
FindReleasesRepository,
|
||||
FindTimelineRepository,
|
||||
IssueNumbersRepository,
|
||||
ProjectKeysRepository,
|
||||
ReleaseIssuesRepository,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
UpsertMaterializedIssueRepository,
|
||||
UpsertMaterializedProjectRepository,
|
||||
UpsertMaterializedReleaseRepository,
|
||||
UpsertMaterializedTimelineRepository,
|
||||
UserDirectory,
|
||||
} from '@task-stream/api/tracker/application';
|
||||
import { IdentityUserDirectory } from './identity/identity-user-directory';
|
||||
@@ -27,6 +29,7 @@ import {
|
||||
MongoFindIssuesRepository,
|
||||
MongoFindProjectsRepository,
|
||||
MongoFindReleasesRepository,
|
||||
MongoFindTimelineRepository,
|
||||
} from './mongo/find.repositories';
|
||||
import { READ_MODEL_SCHEMAS } from './mongo/read-model.schemas';
|
||||
import {
|
||||
@@ -35,7 +38,9 @@ import {
|
||||
MongoUpsertMaterializedIssueRepository,
|
||||
MongoUpsertMaterializedProjectRepository,
|
||||
MongoUpsertMaterializedReleaseRepository,
|
||||
MongoUpsertMaterializedTimelineRepository,
|
||||
} from './mongo/upsert-materialized.repositories';
|
||||
import { StatusCategoryBackfill } from './mongo/status-category.backfill';
|
||||
import { LOOKUP_ENTITIES } from './postgres/lookup.entities';
|
||||
import {
|
||||
PostgresIssueNumbersRepository,
|
||||
@@ -60,6 +65,7 @@ const PORTS = [
|
||||
{ provide: FindCommentsRepository, useClass: MongoFindCommentsRepository },
|
||||
{ provide: FindReleasesRepository, useClass: MongoFindReleasesRepository },
|
||||
{ provide: FindActivityRepository, useClass: MongoFindActivityRepository },
|
||||
{ provide: FindTimelineRepository, useClass: MongoFindTimelineRepository },
|
||||
{
|
||||
provide: UpsertMaterializedProjectRepository,
|
||||
useClass: MongoUpsertMaterializedProjectRepository,
|
||||
@@ -80,6 +86,10 @@ const PORTS = [
|
||||
provide: UpsertMaterializedActivityRepository,
|
||||
useClass: MongoUpsertMaterializedActivityRepository,
|
||||
},
|
||||
{
|
||||
provide: UpsertMaterializedTimelineRepository,
|
||||
useClass: MongoUpsertMaterializedTimelineRepository,
|
||||
},
|
||||
// Realtime → Redis pub/sub
|
||||
{ provide: ChangeFeed, useClass: RedisChangeFeed },
|
||||
// Identity context (anti-corruption layer)
|
||||
@@ -93,7 +103,7 @@ const PORTS = [
|
||||
TypeOrmModule.forFeature(LOOKUP_ENTITIES),
|
||||
MongooseModule.forFeature(READ_MODEL_SCHEMAS),
|
||||
],
|
||||
providers: PORTS,
|
||||
providers: [...PORTS, StatusCategoryBackfill],
|
||||
exports: PORTS.map((port) => port.provide),
|
||||
})
|
||||
export class TrackerInfrastructureModule {}
|
||||
|
||||
@@ -10,7 +10,9 @@ API GraphQL y REST (OpenAPI) del tracker.
|
||||
- Resolvers `ProjectResolver`, `IssueResolver`, `CommentResolver`, `ReleaseResolver` y `SubscriptionResolver`; solo llaman a services.
|
||||
- Tipos, inputs (con `class-validator`) y enums de GraphQL (code-first).
|
||||
- `http/`: controladores REST `ProjectsController`, `IssuesController`, `CommentsController`, `ReleasesController`
|
||||
(mismos services), DTOs con `@nestjs/swagger` y `Refs` (proyectos y tickets por id o clave).
|
||||
(mismos services), DTOs con `@nestjs/swagger` y `Refs` (proyectos y tickets por id o clave; añade a cada ticket sus
|
||||
transiciones permitidas según el workflow de su proyecto).
|
||||
- `common/settings.ts`: configuración del proyecto con las transiciones como lista (lo que viaja por GraphQL y REST).
|
||||
|
||||
Operaciones disponibles: [GraphQL](../../../../docs/03-api.md#32-graphql) y
|
||||
[REST](../../../../docs/03-api.md#33-rest-con-openapi-para-otras-apps).
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/api/tracker/presenters/src",
|
||||
"projectType": "library",
|
||||
"tags": [
|
||||
"scope:api-tracker",
|
||||
"type:presenters"
|
||||
],
|
||||
"tags": ["scope:api-tracker", "type:presenters"],
|
||||
"// targets": "to see all targets run: nx show project api-tracker-presenters --web",
|
||||
"targets": {}
|
||||
}
|
||||
|
||||
56
libs/api/tracker/presenters/src/lib/common/settings.ts
Normal file
56
libs/api/tracker/presenters/src/lib/common/settings.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
ProjectSettings,
|
||||
SettingColor,
|
||||
StatusCategory,
|
||||
TypeIcon,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
|
||||
/** Settings as the APIs carry them: transitions as a list instead of a map. */
|
||||
export interface SettingsPayload {
|
||||
statuses: {
|
||||
id: string;
|
||||
name: string;
|
||||
category: StatusCategory;
|
||||
color: string;
|
||||
requiresReason: boolean;
|
||||
archived: boolean;
|
||||
}[];
|
||||
transitions: { from: string; to: string[] }[];
|
||||
issueTypes: {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
archived: boolean;
|
||||
}[];
|
||||
areas: { id: string; name: string; archived: boolean }[];
|
||||
}
|
||||
|
||||
export function toSettingsPayload(settings: ProjectSettings): SettingsPayload {
|
||||
return {
|
||||
...settings,
|
||||
transitions: settings.statuses.map((s) => ({
|
||||
from: s.id,
|
||||
to: settings.transitions[s.id] ?? [],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Colours and icons are checked by the domain (validateProjectSettings). */
|
||||
export function fromSettingsPayload(payload: SettingsPayload): ProjectSettings {
|
||||
return {
|
||||
statuses: payload.statuses.map((s) => ({
|
||||
...s,
|
||||
color: s.color as SettingColor,
|
||||
})),
|
||||
transitions: Object.fromEntries(
|
||||
payload.transitions.map((t) => [t.from, t.to]),
|
||||
),
|
||||
issueTypes: payload.issueTypes.map((t) => ({
|
||||
...t,
|
||||
icon: t.icon as TypeIcon,
|
||||
color: t.color as SettingColor,
|
||||
})),
|
||||
areas: payload.areas,
|
||||
};
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
import { registerEnumType } from '@nestjs/graphql';
|
||||
import {
|
||||
IssueArea,
|
||||
IssueLinkType,
|
||||
IssuePriority,
|
||||
IssueStatus,
|
||||
IssueType,
|
||||
ProjectRole,
|
||||
ReleaseStatus,
|
||||
StatusCategory,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
|
||||
export enum LinkDirection {
|
||||
@@ -14,13 +12,12 @@ export enum LinkDirection {
|
||||
INWARD = 'INWARD',
|
||||
}
|
||||
|
||||
registerEnumType(IssueType, { name: 'IssueType' });
|
||||
registerEnumType(IssueArea, {
|
||||
name: 'IssueArea',
|
||||
description: 'Team/layer: links FE ↔ BE ↔ QA work',
|
||||
});
|
||||
registerEnumType(IssuePriority, { name: 'IssuePriority' });
|
||||
registerEnumType(IssueStatus, { name: 'IssueStatus' });
|
||||
registerEnumType(StatusCategory, {
|
||||
name: 'StatusCategory',
|
||||
description:
|
||||
'What a status means, whatever each project calls it. DONE and CANCELLED are finished.',
|
||||
});
|
||||
registerEnumType(IssueLinkType, { name: 'IssueLinkType' });
|
||||
registerEnumType(ProjectRole, { name: 'ProjectRole' });
|
||||
registerEnumType(ReleaseStatus, { name: 'ReleaseStatus' });
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { Field, ID, InputType, Int } from '@nestjs/graphql';
|
||||
import {
|
||||
IssueArea,
|
||||
IssueLinkType,
|
||||
IssuePriority,
|
||||
IssueStatus,
|
||||
IssueType,
|
||||
ProjectRole,
|
||||
StatusCategory,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
@@ -22,8 +22,12 @@ import {
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
/** Ids of statuses, types and areas (checked against the project's settings). */
|
||||
const SETTING_ID = /^[A-Za-z][A-Za-z0-9_]{0,29}$/;
|
||||
|
||||
@InputType()
|
||||
export class CreateProjectInput {
|
||||
@Field({ description: '2-10 chars, e.g. "TS" → tickets TS-1, TS-2...' })
|
||||
@@ -74,10 +78,10 @@ abstract class IssueFieldsInput {
|
||||
@MaxLength(50000)
|
||||
description?: string | null;
|
||||
|
||||
@Field(() => IssueArea, { nullable: true })
|
||||
@Field(() => String, { nullable: true, description: 'An area id' })
|
||||
@IsOptional()
|
||||
@IsEnum(IssueArea)
|
||||
area?: IssueArea | null;
|
||||
@Matches(SETTING_ID)
|
||||
area?: string | null;
|
||||
|
||||
@Field(() => IssuePriority, { nullable: true })
|
||||
@IsOptional()
|
||||
@@ -114,7 +118,9 @@ abstract class IssueFieldsInput {
|
||||
@InputType()
|
||||
export class CreateIssueInput extends IssueFieldsInput {
|
||||
@Field(() => ID) @IsUUID() projectId!: string;
|
||||
@Field(() => IssueType) @IsEnum(IssueType) type!: IssueType;
|
||||
@Field({ description: 'A ticket type id' })
|
||||
@Matches(SETTING_ID)
|
||||
type!: string;
|
||||
@Field() @IsString() @MinLength(1) @MaxLength(255) title!: string;
|
||||
@Field(() => ID, { nullable: true }) @IsOptional() @IsString() assigneeId?:
|
||||
| string
|
||||
@@ -127,10 +133,10 @@ export class CreateIssueInput extends IssueFieldsInput {
|
||||
@InputType()
|
||||
export class UpdateIssueInput extends IssueFieldsInput {
|
||||
@Field(() => ID) @IsUUID() issueId!: string;
|
||||
@Field(() => IssueType, { nullable: true })
|
||||
@Field({ nullable: true })
|
||||
@IsOptional()
|
||||
@IsEnum(IssueType)
|
||||
type?: IssueType;
|
||||
@Matches(SETTING_ID)
|
||||
type?: string;
|
||||
@Field({ nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@@ -142,10 +148,12 @@ export class UpdateIssueInput extends IssueFieldsInput {
|
||||
@InputType()
|
||||
export class ChangeIssueStatusInput {
|
||||
@Field(() => ID) @IsUUID() issueId!: string;
|
||||
@Field(() => IssueStatus) @IsEnum(IssueStatus) status!: IssueStatus;
|
||||
@Field({ description: 'A status id' })
|
||||
@Matches(SETTING_ID)
|
||||
status!: string;
|
||||
@Field(() => String, {
|
||||
nullable: true,
|
||||
description: 'Required for QA_FAILED and CANCELLED',
|
||||
description: 'Required by statuses with requiresReason',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@@ -163,18 +171,22 @@ export class LinkIssuesInput {
|
||||
@InputType()
|
||||
export class IssueFilterInput {
|
||||
@Field(() => ID) @IsUUID() projectId!: string;
|
||||
@Field(() => [IssueStatus], { nullable: true })
|
||||
@Field(() => [String], { nullable: true })
|
||||
@IsOptional()
|
||||
@IsEnum(IssueStatus, { each: true })
|
||||
statuses?: IssueStatus[];
|
||||
@Field(() => [IssueType], { nullable: true })
|
||||
@IsString({ each: true })
|
||||
statuses?: string[];
|
||||
@Field(() => [StatusCategory], { nullable: true })
|
||||
@IsOptional()
|
||||
@IsEnum(IssueType, { each: true })
|
||||
types?: IssueType[];
|
||||
@Field(() => [IssueArea], { nullable: true })
|
||||
@IsEnum(StatusCategory, { each: true })
|
||||
categories?: StatusCategory[];
|
||||
@Field(() => [String], { nullable: true })
|
||||
@IsOptional()
|
||||
@IsEnum(IssueArea, { each: true })
|
||||
areas?: IssueArea[];
|
||||
@IsString({ each: true })
|
||||
types?: string[];
|
||||
@Field(() => [String], { nullable: true })
|
||||
@IsOptional()
|
||||
@IsString({ each: true })
|
||||
areas?: string[];
|
||||
@Field(() => [IssuePriority], { nullable: true })
|
||||
@IsOptional()
|
||||
@IsEnum(IssuePriority, { each: true })
|
||||
@@ -237,3 +249,77 @@ export class UpdateReleaseInput {
|
||||
@IsISO8601()
|
||||
targetDate?: string | null;
|
||||
}
|
||||
|
||||
/* Project settings (owners and admins). Values are archived, never deleted. */
|
||||
|
||||
@InputType()
|
||||
export class WorkflowStatusInput {
|
||||
@Field() @Matches(SETTING_ID) id!: string;
|
||||
@Field() @IsString() @MinLength(1) @MaxLength(40) name!: string;
|
||||
@Field(() => StatusCategory)
|
||||
@IsEnum(StatusCategory)
|
||||
category!: StatusCategory;
|
||||
@Field() @IsString() color!: string;
|
||||
@Field({ defaultValue: false }) @IsBoolean() requiresReason!: boolean;
|
||||
@Field({ defaultValue: false }) @IsBoolean() archived!: boolean;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class StatusTransitionsInput {
|
||||
@Field() @Matches(SETTING_ID) from!: string;
|
||||
@Field(() => [String])
|
||||
@IsArray()
|
||||
@ArrayMaxSize(50)
|
||||
@Matches(SETTING_ID, { each: true })
|
||||
to!: string[];
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class IssueTypeOptionInput {
|
||||
@Field() @Matches(SETTING_ID) id!: string;
|
||||
@Field() @IsString() @MinLength(1) @MaxLength(40) name!: string;
|
||||
@Field() @IsString() icon!: string;
|
||||
@Field() @IsString() color!: string;
|
||||
@Field({ defaultValue: false }) @IsBoolean() archived!: boolean;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class AreaOptionInput {
|
||||
@Field() @Matches(SETTING_ID) id!: string;
|
||||
@Field() @IsString() @MinLength(1) @MaxLength(40) name!: string;
|
||||
@Field({ defaultValue: false }) @IsBoolean() archived!: boolean;
|
||||
}
|
||||
|
||||
@InputType({
|
||||
description:
|
||||
'The whole configuration (replaces the previous one). Statuses go in board order.',
|
||||
})
|
||||
export class ProjectSettingsInput {
|
||||
@Field(() => [WorkflowStatusInput])
|
||||
@IsArray()
|
||||
@ArrayMaxSize(50)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => WorkflowStatusInput)
|
||||
statuses!: WorkflowStatusInput[];
|
||||
|
||||
@Field(() => [StatusTransitionsInput])
|
||||
@IsArray()
|
||||
@ArrayMaxSize(50)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => StatusTransitionsInput)
|
||||
transitions!: StatusTransitionsInput[];
|
||||
|
||||
@Field(() => [IssueTypeOptionInput])
|
||||
@IsArray()
|
||||
@ArrayMaxSize(50)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => IssueTypeOptionInput)
|
||||
issueTypes!: IssueTypeOptionInput[];
|
||||
|
||||
@Field(() => [AreaOptionInput])
|
||||
@IsArray()
|
||||
@ArrayMaxSize(50)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => AreaOptionInput)
|
||||
areas!: AreaOptionInput[];
|
||||
}
|
||||
|
||||
@@ -13,11 +13,11 @@ import { CurrentUser } from '@task-stream/api/identity/presenters';
|
||||
import {
|
||||
CommentsService,
|
||||
IssuesService,
|
||||
WorkflowsService,
|
||||
} from '@task-stream/api/tracker/application';
|
||||
import {
|
||||
IssueReadModel,
|
||||
IssueStatus,
|
||||
IssueWorkflow,
|
||||
StatusCategory,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import {
|
||||
ChangeIssueStatusInput,
|
||||
@@ -40,6 +40,7 @@ export class IssueResolver {
|
||||
constructor(
|
||||
private readonly issuesService: IssuesService,
|
||||
private readonly commentsService: CommentsService,
|
||||
private readonly workflows: WorkflowsService,
|
||||
) {}
|
||||
|
||||
@Query(() => IssueGql)
|
||||
@@ -61,6 +62,29 @@ export class IssueResolver {
|
||||
return this.issuesService.search(me.id, { ...filter, limit, offset });
|
||||
}
|
||||
|
||||
@Query(() => IssuePageGql, {
|
||||
description:
|
||||
'Tickets across all my projects: assigned to me (default) or any (e.g. categories [QA])',
|
||||
})
|
||||
myIssues(
|
||||
@CurrentUser() me: User,
|
||||
@Args('assignedToMe', { defaultValue: true }) assignedToMe: boolean,
|
||||
@Args('statuses', { type: () => [String], nullable: true })
|
||||
statuses: string[] | undefined,
|
||||
@Args('categories', { type: () => [StatusCategory], nullable: true })
|
||||
categories: StatusCategory[] | undefined,
|
||||
@Args('limit', { type: () => Int, defaultValue: 50 }) limit: number,
|
||||
@Args('offset', { type: () => Int, defaultValue: 0 }) offset: number,
|
||||
) {
|
||||
return this.issuesService.mine(me.id, {
|
||||
assignedToMe,
|
||||
statuses: statuses ?? undefined,
|
||||
categories: categories ?? undefined,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
}
|
||||
|
||||
@Query(() => [ActivityGql], {
|
||||
description: 'Project activity feed (newest first)',
|
||||
})
|
||||
@@ -80,11 +104,11 @@ export class IssueResolver {
|
||||
});
|
||||
}
|
||||
|
||||
@ResolveField(() => [IssueStatus], {
|
||||
description: 'Statuses this ticket can move to next',
|
||||
@ResolveField(() => [String], {
|
||||
description: "Statuses this ticket can move to next (project's workflow)",
|
||||
})
|
||||
allowedTransitions(@Parent() issue: IssueReadModel): readonly IssueStatus[] {
|
||||
return IssueWorkflow.allowedFrom(issue.status);
|
||||
allowedTransitions(@Parent() issue: IssueReadModel): Promise<string[]> {
|
||||
return this.workflows.allowedTransitions(issue);
|
||||
}
|
||||
|
||||
@ResolveField(() => [IssueGql], { description: 'Linked tickets I can see' })
|
||||
|
||||
@@ -11,16 +11,20 @@ import {
|
||||
import { User } from '@task-stream/api/identity/domain';
|
||||
import { AccessToken, CurrentUser } from '@task-stream/api/identity/presenters';
|
||||
import { ProjectsService } from '@task-stream/api/tracker/application';
|
||||
import { IssueArea, ProjectReadModel } from '@task-stream/api/tracker/domain';
|
||||
import { ProjectReadModel } from '@task-stream/api/tracker/domain';
|
||||
import { fromSettingsPayload, toSettingsPayload } from '../common/settings';
|
||||
import {
|
||||
CreateProjectInput,
|
||||
ProjectMemberInput,
|
||||
ProjectSettingsInput,
|
||||
UpdateProjectInput,
|
||||
} from './inputs';
|
||||
import {
|
||||
BoardColumnGql,
|
||||
CommandResultGql,
|
||||
ProjectAnalyticsGql,
|
||||
ProjectGql,
|
||||
ProjectSettingsGql,
|
||||
ProjectStatsGql,
|
||||
ReleaseGql,
|
||||
} from './types';
|
||||
@@ -43,6 +47,23 @@ export class ProjectResolver {
|
||||
return this.projects.findOne(me.id, idOrKey);
|
||||
}
|
||||
|
||||
@ResolveField(() => ProjectSettingsGql)
|
||||
settings(@Parent() project: ProjectReadModel) {
|
||||
return toSettingsPayload(project.settings);
|
||||
}
|
||||
|
||||
@ResolveField(() => ProjectAnalyticsGql, {
|
||||
description:
|
||||
'Statistics of the last `days` days (7-365): created vs resolved, cumulative flow, cycle/lead time, throughput and QA',
|
||||
})
|
||||
analytics(
|
||||
@CurrentUser() me: User,
|
||||
@Parent() project: ProjectReadModel,
|
||||
@Args('days', { type: () => Int, defaultValue: 90 }) days: number,
|
||||
) {
|
||||
return this.projects.analytics(me.id, project.id, days);
|
||||
}
|
||||
|
||||
@ResolveField(() => [ReleaseGql])
|
||||
releases(@CurrentUser() me: User, @Parent() project: ProjectReadModel) {
|
||||
return this.projects.releases(me.id, project.id);
|
||||
@@ -63,7 +84,7 @@ export class ProjectResolver {
|
||||
@Parent() project: ProjectReadModel,
|
||||
@Args('releaseId', { type: () => ID, nullable: true }) releaseId?: string,
|
||||
@Args('assigneeId', { type: () => ID, nullable: true }) assigneeId?: string,
|
||||
@Args('area', { type: () => IssueArea, nullable: true }) area?: IssueArea,
|
||||
@Args('area', { type: () => String, nullable: true }) area?: string,
|
||||
@Args('perColumn', { type: () => Int, defaultValue: 50 }) perColumn = 50,
|
||||
) {
|
||||
return this.projects.board(
|
||||
@@ -95,6 +116,22 @@ export class ProjectResolver {
|
||||
return this.projects.update(me.id, projectId, changes);
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql, {
|
||||
description:
|
||||
'Replace statuses, transitions, ticket types and areas (owners and admins). Values can be archived, not deleted.',
|
||||
})
|
||||
updateProjectSettings(
|
||||
@CurrentUser() me: User,
|
||||
@Args('projectId', { type: () => ID }) projectId: string,
|
||||
@Args('settings') settings: ProjectSettingsInput,
|
||||
) {
|
||||
return this.projects.updateSettings(
|
||||
me.id,
|
||||
projectId,
|
||||
fromSettingsPayload(settings),
|
||||
);
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql, {
|
||||
description: 'Add a Pireon user to the project',
|
||||
})
|
||||
|
||||
@@ -19,6 +19,7 @@ import { CreateReleaseInput, UpdateReleaseInput } from './inputs';
|
||||
import {
|
||||
CommandResultGql,
|
||||
IssuePageGql,
|
||||
ReleaseBurnupGql,
|
||||
ReleaseGql,
|
||||
ReleaseProgressGql,
|
||||
} from './types';
|
||||
@@ -35,11 +36,29 @@ export class ReleaseResolver {
|
||||
return this.releases.findOne(me.id, id);
|
||||
}
|
||||
|
||||
@Query(() => [ReleaseGql], {
|
||||
description:
|
||||
'Planned releases of all my projects, soonest target date first',
|
||||
})
|
||||
upcomingReleases(
|
||||
@CurrentUser() me: User,
|
||||
@Args('limit', { type: () => Int, defaultValue: 10 }) limit: number,
|
||||
) {
|
||||
return this.releases.upcoming(me.id, limit);
|
||||
}
|
||||
|
||||
@ResolveField(() => ReleaseProgressGql)
|
||||
progress(@CurrentUser() me: User, @Parent() release: ReleaseReadModel) {
|
||||
return this.releases.progress(me.id, release.id);
|
||||
}
|
||||
|
||||
@ResolveField(() => ReleaseBurnupGql, {
|
||||
description: 'Scope vs finished tickets, day by day',
|
||||
})
|
||||
burnup(@CurrentUser() me: User, @Parent() release: ReleaseReadModel) {
|
||||
return this.releases.burnup(me.id, release.id);
|
||||
}
|
||||
|
||||
@ResolveField(() => IssuePageGql, {
|
||||
description: 'Tickets included in this release',
|
||||
})
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { Field, ID, Int, ObjectType } from '@nestjs/graphql';
|
||||
import { Field, Float, ID, Int, ObjectType } from '@nestjs/graphql';
|
||||
import {
|
||||
IssueArea,
|
||||
IssueLinkType,
|
||||
IssuePriority,
|
||||
IssueStatus,
|
||||
IssueType,
|
||||
ProjectRole,
|
||||
ReleaseStatus,
|
||||
StatusCategory,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { LinkDirection } from './enums';
|
||||
|
||||
@@ -22,6 +20,51 @@ export class ProjectMemberGql {
|
||||
@Field(() => ProjectRole) role!: ProjectRole;
|
||||
}
|
||||
|
||||
@ObjectType('WorkflowStatus')
|
||||
export class WorkflowStatusGql {
|
||||
@Field({ description: 'Stable id stored on tickets, e.g. IN_REVIEW' })
|
||||
id!: string;
|
||||
@Field() name!: string;
|
||||
@Field(() => StatusCategory) category!: StatusCategory;
|
||||
@Field({ description: 'Colour token: slate, violet, green…' }) color!: string;
|
||||
@Field({ description: 'Entering it needs a reason' })
|
||||
requiresReason!: boolean;
|
||||
@Field({ description: 'No new tickets or transitions into it' })
|
||||
archived!: boolean;
|
||||
}
|
||||
|
||||
@ObjectType('StatusTransitions')
|
||||
export class StatusTransitionsGql {
|
||||
@Field() from!: string;
|
||||
@Field(() => [String]) to!: string[];
|
||||
}
|
||||
|
||||
@ObjectType('IssueTypeOption')
|
||||
export class IssueTypeOptionGql {
|
||||
@Field() id!: string;
|
||||
@Field() name!: string;
|
||||
@Field() icon!: string;
|
||||
@Field() color!: string;
|
||||
@Field() archived!: boolean;
|
||||
}
|
||||
|
||||
@ObjectType('AreaOption')
|
||||
export class AreaOptionGql {
|
||||
@Field() id!: string;
|
||||
@Field() name!: string;
|
||||
@Field() archived!: boolean;
|
||||
}
|
||||
|
||||
@ObjectType('ProjectSettings', {
|
||||
description: 'Statuses (board order), transitions, ticket types and areas',
|
||||
})
|
||||
export class ProjectSettingsGql {
|
||||
@Field(() => [WorkflowStatusGql]) statuses!: WorkflowStatusGql[];
|
||||
@Field(() => [StatusTransitionsGql]) transitions!: StatusTransitionsGql[];
|
||||
@Field(() => [IssueTypeOptionGql]) issueTypes!: IssueTypeOptionGql[];
|
||||
@Field(() => [AreaOptionGql]) areas!: AreaOptionGql[];
|
||||
}
|
||||
|
||||
@ObjectType('Project')
|
||||
export class ProjectGql {
|
||||
@Field(() => ID) id!: string;
|
||||
@@ -59,11 +102,15 @@ export class IssueGql {
|
||||
@Field() projectKey!: string;
|
||||
@Field(() => Int) number!: number;
|
||||
@Field({ description: 'Human key, e.g. TS-42' }) key!: string;
|
||||
@Field(() => IssueType) type!: IssueType;
|
||||
@Field(() => IssueArea, { nullable: true }) area!: IssueArea | null;
|
||||
@Field({ description: "A ticket type id of the project's settings" })
|
||||
type!: string;
|
||||
@Field(() => String, { nullable: true, description: 'An area id' })
|
||||
area!: string | null;
|
||||
@Field() title!: string;
|
||||
@Field(() => String, { nullable: true }) description!: string | null;
|
||||
@Field(() => IssueStatus) status!: IssueStatus;
|
||||
@Field({ description: "A status id of the project's settings" })
|
||||
status!: string;
|
||||
@Field(() => StatusCategory) statusCategory!: StatusCategory;
|
||||
@Field(() => IssuePriority) priority!: IssuePriority;
|
||||
@Field(() => UserRefGql) reporter!: UserRefGql;
|
||||
@Field(() => UserRefGql, { nullable: true }) assignee!: UserRefGql | null;
|
||||
@@ -88,7 +135,7 @@ export class IssuePageGql {
|
||||
|
||||
@ObjectType('BoardColumn')
|
||||
export class BoardColumnGql {
|
||||
@Field(() => IssueStatus) status!: IssueStatus;
|
||||
@Field() status!: string;
|
||||
@Field(() => [IssueGql]) issues!: IssueGql[];
|
||||
@Field(() => Int) total!: number;
|
||||
}
|
||||
@@ -125,7 +172,7 @@ export class ReleaseGql {
|
||||
|
||||
@ObjectType('CountByStatus')
|
||||
export class CountByStatusGql {
|
||||
@Field(() => IssueStatus) key!: IssueStatus;
|
||||
@Field({ description: 'Status id' }) key!: string;
|
||||
@Field(() => Int) count!: number;
|
||||
}
|
||||
|
||||
@@ -221,3 +268,77 @@ export class ProjectChangeGql {
|
||||
@Field(() => ID) actorId!: string;
|
||||
@Field(() => ProjectGql) project!: ProjectGql;
|
||||
}
|
||||
|
||||
/* Statistics */
|
||||
|
||||
@ObjectType('CreatedResolved')
|
||||
export class CreatedResolvedGql {
|
||||
@Field({ description: 'Monday of the week (YYYY-MM-DD)' }) week!: string;
|
||||
@Field(() => Int) created!: number;
|
||||
@Field(() => Int) resolved!: number;
|
||||
}
|
||||
|
||||
@ObjectType('FlowSeries')
|
||||
export class FlowSeriesGql {
|
||||
@Field() status!: string;
|
||||
@Field(() => [Int]) counts!: number[];
|
||||
}
|
||||
|
||||
@ObjectType('CumulativeFlow')
|
||||
export class CumulativeFlowGql {
|
||||
@Field(() => [String]) dates!: string[];
|
||||
@Field(() => [FlowSeriesGql]) series!: FlowSeriesGql[];
|
||||
}
|
||||
|
||||
@ObjectType('DurationStats')
|
||||
export class DurationStatsGql {
|
||||
@Field(() => Int) count!: number;
|
||||
@Field(() => Float, { nullable: true }) averageDays!: number | null;
|
||||
@Field(() => Float, {
|
||||
nullable: true,
|
||||
description: '85% of tickets take at most this long',
|
||||
})
|
||||
p85Days!: number | null;
|
||||
}
|
||||
|
||||
@ObjectType('Throughput')
|
||||
export class ThroughputGql {
|
||||
@Field() week!: string;
|
||||
@Field(() => UserRefGql, { nullable: true }) user!: UserRefGql | null;
|
||||
@Field(() => Int) count!: number;
|
||||
}
|
||||
|
||||
@ObjectType('QaStats')
|
||||
export class QaStatsGql {
|
||||
@Field(() => Int) passed!: number;
|
||||
@Field(() => Int) failed!: number;
|
||||
@Field(() => Float, { nullable: true }) failureRate!: number | null;
|
||||
}
|
||||
|
||||
@ObjectType('ProjectAnalytics')
|
||||
export class ProjectAnalyticsGql {
|
||||
@Field() from!: string;
|
||||
@Field() to!: string;
|
||||
@Field(() => [CreatedResolvedGql]) createdVsResolved!: CreatedResolvedGql[];
|
||||
@Field(() => CumulativeFlowGql, {
|
||||
description: 'Tickets in each status at the end of every day',
|
||||
})
|
||||
cumulativeFlow!: CumulativeFlowGql;
|
||||
@Field(() => DurationStatsGql, { description: 'Start of work → done' })
|
||||
cycleTime!: DurationStatsGql;
|
||||
@Field(() => DurationStatsGql, { description: 'Creation → done' })
|
||||
leadTime!: DurationStatsGql;
|
||||
@Field(() => [ThroughputGql], { description: 'Finished per person and week' })
|
||||
throughput!: ThroughputGql[];
|
||||
@Field(() => QaStatsGql) qa!: QaStatsGql;
|
||||
}
|
||||
|
||||
@ObjectType('ReleaseBurnup')
|
||||
export class ReleaseBurnupGql {
|
||||
@Field(() => ID) releaseId!: string;
|
||||
@Field(() => String, { nullable: true }) targetDate!: string | null;
|
||||
@Field(() => [String]) dates!: string[];
|
||||
@Field(() => [Int], { description: 'Tickets in the release (not cancelled)' })
|
||||
scope!: number[];
|
||||
@Field(() => [Int]) done!: number[];
|
||||
}
|
||||
|
||||
@@ -5,16 +5,17 @@ import {
|
||||
PartialType,
|
||||
} from '@nestjs/swagger';
|
||||
import {
|
||||
IssueArea,
|
||||
IssueLinkType,
|
||||
IssuePriority,
|
||||
IssueStatus,
|
||||
IssueType,
|
||||
ProjectRole,
|
||||
SETTING_COLORS,
|
||||
StatusCategory,
|
||||
TYPE_ICONS,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsIn,
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateIf,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
/* REST request bodies and query strings, validated like the GraphQL inputs. */
|
||||
@@ -46,6 +48,9 @@ const toList = ({ value }: { value: unknown }) =>
|
||||
const toBoolean = ({ value }: { value: unknown }) =>
|
||||
value === undefined ? undefined : value === true || value === 'true';
|
||||
|
||||
/** Ids of statuses, types and areas (checked against the project's settings). */
|
||||
const SETTING_ID = /^[A-Za-z][A-Za-z0-9_]{0,29}$/;
|
||||
|
||||
// ---- Projects ---------------------------------------------------------------
|
||||
|
||||
export class CreateProjectDto {
|
||||
@@ -89,6 +94,121 @@ export class AddMemberDto {
|
||||
role: ProjectRole = ProjectRole.MEMBER;
|
||||
}
|
||||
|
||||
// ---- Project settings -------------------------------------------------------
|
||||
|
||||
export class WorkflowStatusBodyDto {
|
||||
@ApiProperty({
|
||||
example: 'IN_REVIEW',
|
||||
description: 'Never change an existing id',
|
||||
})
|
||||
@Matches(SETTING_ID)
|
||||
id!: string;
|
||||
@ApiProperty({ example: 'En revisión', maxLength: 40 })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(40)
|
||||
name!: string;
|
||||
@ApiProperty({ enum: StatusCategory, enumName: 'StatusCategory' })
|
||||
@IsEnum(StatusCategory)
|
||||
category!: StatusCategory;
|
||||
@ApiProperty({ enum: SETTING_COLORS, enumName: 'SettingColor' })
|
||||
@IsIn(SETTING_COLORS)
|
||||
color!: string;
|
||||
@ApiPropertyOptional({ default: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
requiresReason = false;
|
||||
@ApiPropertyOptional({ default: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
archived = false;
|
||||
}
|
||||
|
||||
export class StatusTransitionsBodyDto {
|
||||
@ApiProperty({ example: 'IN_QA' }) @Matches(SETTING_ID) from!: string;
|
||||
@ApiProperty({ type: [String], example: ['DONE', 'QA_FAILED'] })
|
||||
@IsArray()
|
||||
@ArrayMaxSize(50)
|
||||
@Matches(SETTING_ID, { each: true })
|
||||
to!: string[];
|
||||
}
|
||||
|
||||
export class IssueTypeOptionBodyDto {
|
||||
@ApiProperty({ example: 'SPIKE' }) @Matches(SETTING_ID) id!: string;
|
||||
@ApiProperty({ example: 'Spike', maxLength: 40 })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(40)
|
||||
name!: string;
|
||||
@ApiProperty({ enum: TYPE_ICONS, enumName: 'TypeIcon' })
|
||||
@IsIn(TYPE_ICONS)
|
||||
icon!: string;
|
||||
@ApiProperty({ enum: SETTING_COLORS, enumName: 'SettingColor' })
|
||||
@IsIn(SETTING_COLORS)
|
||||
color!: string;
|
||||
@ApiPropertyOptional({ default: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
archived = false;
|
||||
}
|
||||
|
||||
export class AreaOptionBodyDto {
|
||||
@ApiProperty({ example: 'DATA' }) @Matches(SETTING_ID) id!: string;
|
||||
@ApiProperty({ example: 'Datos', maxLength: 40 })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(40)
|
||||
name!: string;
|
||||
@ApiPropertyOptional({ default: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
archived = false;
|
||||
}
|
||||
|
||||
export class ProjectSettingsBodyDto {
|
||||
@ApiProperty({
|
||||
type: [WorkflowStatusBodyDto],
|
||||
description:
|
||||
'In board order. Values cannot be removed: archive them instead.',
|
||||
})
|
||||
@IsArray()
|
||||
@ArrayMaxSize(50)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => WorkflowStatusBodyDto)
|
||||
statuses!: WorkflowStatusBodyDto[];
|
||||
|
||||
@ApiProperty({ type: [StatusTransitionsBodyDto] })
|
||||
@IsArray()
|
||||
@ArrayMaxSize(50)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => StatusTransitionsBodyDto)
|
||||
transitions!: StatusTransitionsBodyDto[];
|
||||
|
||||
@ApiProperty({ type: [IssueTypeOptionBodyDto] })
|
||||
@IsArray()
|
||||
@ArrayMaxSize(50)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => IssueTypeOptionBodyDto)
|
||||
issueTypes!: IssueTypeOptionBodyDto[];
|
||||
|
||||
@ApiProperty({ type: [AreaOptionBodyDto] })
|
||||
@IsArray()
|
||||
@ArrayMaxSize(50)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => AreaOptionBodyDto)
|
||||
areas!: AreaOptionBodyDto[];
|
||||
}
|
||||
|
||||
export class AnalyticsQueryDto {
|
||||
@ApiPropertyOptional({ default: 90, minimum: 7, maximum: 365 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(7)
|
||||
@Max(365)
|
||||
days = 90;
|
||||
}
|
||||
|
||||
export class ChangeMemberRoleDto {
|
||||
@ApiProperty({ enum: ProjectRole, enumName: 'ProjectRole' })
|
||||
@IsEnum(ProjectRole)
|
||||
@@ -106,10 +226,10 @@ export class BoardQueryDto {
|
||||
@IsString()
|
||||
assigneeId?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: IssueArea, enumName: 'IssueArea' })
|
||||
@ApiPropertyOptional({ example: 'FRONTEND', description: 'An area id' })
|
||||
@IsOptional()
|
||||
@IsEnum(IssueArea)
|
||||
area?: IssueArea;
|
||||
@Matches(SETTING_ID)
|
||||
area?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: 50, minimum: 1, maximum: 200 })
|
||||
@IsOptional()
|
||||
@@ -162,37 +282,79 @@ export class PageQueryDto {
|
||||
|
||||
// ---- Issues -----------------------------------------------------------------
|
||||
|
||||
export class MyIssuesQueryDto extends PageQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
default: true,
|
||||
description:
|
||||
'true: tickets assigned to me. false: any ticket of my projects (e.g. ?assignedToMe=false&category=QA)',
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(toBoolean)
|
||||
@IsBoolean()
|
||||
assignedToMe = true;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: [String],
|
||||
description: 'Status ids. Repeat the parameter or separate with commas',
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(toList)
|
||||
@Matches(SETTING_ID, { each: true })
|
||||
status?: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: StatusCategory,
|
||||
enumName: 'StatusCategory',
|
||||
isArray: true,
|
||||
description: 'Whatever each project calls its statuses',
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(toList)
|
||||
@IsEnum(StatusCategory, { each: true })
|
||||
category?: StatusCategory[];
|
||||
}
|
||||
|
||||
export class UpcomingReleasesQueryDto {
|
||||
@ApiPropertyOptional({ default: 10, minimum: 1, maximum: 50 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(50)
|
||||
limit = 10;
|
||||
}
|
||||
|
||||
export class IssueSearchQueryDto extends PageQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
enum: IssueStatus,
|
||||
enumName: 'IssueStatus',
|
||||
isArray: true,
|
||||
description: 'Repeat the parameter or separate with commas',
|
||||
type: [String],
|
||||
description: 'Status ids. Repeat the parameter or separate with commas',
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(toList)
|
||||
@IsEnum(IssueStatus, { each: true })
|
||||
status?: IssueStatus[];
|
||||
@Matches(SETTING_ID, { each: true })
|
||||
status?: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: IssueType,
|
||||
enumName: 'IssueType',
|
||||
enum: StatusCategory,
|
||||
enumName: 'StatusCategory',
|
||||
isArray: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(toList)
|
||||
@IsEnum(IssueType, { each: true })
|
||||
type?: IssueType[];
|
||||
@IsEnum(StatusCategory, { each: true })
|
||||
category?: StatusCategory[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: IssueArea,
|
||||
enumName: 'IssueArea',
|
||||
isArray: true,
|
||||
})
|
||||
@ApiPropertyOptional({ type: [String], description: 'Ticket type ids' })
|
||||
@IsOptional()
|
||||
@Transform(toList)
|
||||
@IsEnum(IssueArea, { each: true })
|
||||
area?: IssueArea[];
|
||||
@Matches(SETTING_ID, { each: true })
|
||||
type?: string[];
|
||||
|
||||
@ApiPropertyOptional({ type: [String], description: 'Area ids' })
|
||||
@IsOptional()
|
||||
@Transform(toList)
|
||||
@Matches(SETTING_ID, { each: true })
|
||||
area?: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: IssuePriority,
|
||||
@@ -241,13 +403,14 @@ class IssueFieldsDto {
|
||||
description?: string | null;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: IssueArea,
|
||||
type: String,
|
||||
nullable: true,
|
||||
example: 'FRONTEND',
|
||||
description: "An area id of the project's settings",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(IssueArea)
|
||||
area?: IssueArea | null;
|
||||
@Matches(SETTING_ID)
|
||||
area?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ enum: IssuePriority, enumName: 'IssuePriority' })
|
||||
@IsOptional()
|
||||
@@ -289,9 +452,12 @@ class IssueFieldsDto {
|
||||
}
|
||||
|
||||
export class CreateIssueDto extends IssueFieldsDto {
|
||||
@ApiProperty({ enum: IssueType, enumName: 'IssueType' })
|
||||
@IsEnum(IssueType)
|
||||
type!: IssueType;
|
||||
@ApiProperty({
|
||||
example: 'BUG',
|
||||
description: "A ticket type id of the project's settings",
|
||||
})
|
||||
@Matches(SETTING_ID)
|
||||
type!: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: 'El botón de pagar no responde en Safari',
|
||||
@@ -318,10 +484,10 @@ export class CreateIssueDto extends IssueFieldsDto {
|
||||
}
|
||||
|
||||
export class UpdateIssueDto extends IssueFieldsDto {
|
||||
@ApiPropertyOptional({ enum: IssueType, enumName: 'IssueType' })
|
||||
@ApiPropertyOptional({ example: 'FEATURE', description: 'A ticket type id' })
|
||||
@IsOptional()
|
||||
@IsEnum(IssueType)
|
||||
type?: IssueType;
|
||||
@Matches(SETTING_ID)
|
||||
type?: string;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 255 })
|
||||
@IsOptional()
|
||||
@@ -332,14 +498,18 @@ export class UpdateIssueDto extends IssueFieldsDto {
|
||||
}
|
||||
|
||||
export class ChangeStatusDto {
|
||||
@ApiProperty({ enum: IssueStatus, enumName: 'IssueStatus' })
|
||||
@IsEnum(IssueStatus)
|
||||
status!: IssueStatus;
|
||||
@ApiProperty({
|
||||
example: 'IN_REVIEW',
|
||||
description:
|
||||
"A status id of the project's settings, among the ticket's allowedTransitions",
|
||||
})
|
||||
@Matches(SETTING_ID)
|
||||
status!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
nullable: true,
|
||||
description: 'Required for QA_FAILED and CANCELLED',
|
||||
description: 'Required by statuses with requiresReason (e.g. QA_FAILED)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IssueArea,
|
||||
IssueLinkType,
|
||||
IssuePriority,
|
||||
IssueStatus,
|
||||
IssueType,
|
||||
ProjectRole,
|
||||
ReleaseStatus,
|
||||
SETTING_COLORS,
|
||||
StatusCategory,
|
||||
TYPE_ICONS,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
|
||||
/*
|
||||
@@ -25,6 +25,51 @@ export class ProjectMemberDto {
|
||||
role!: ProjectRole;
|
||||
}
|
||||
|
||||
export class WorkflowStatusDto {
|
||||
@ApiProperty({ example: 'IN_REVIEW', description: 'Stable id on tickets' })
|
||||
id!: string;
|
||||
@ApiProperty({ example: 'En revisión' }) name!: string;
|
||||
@ApiProperty({ enum: StatusCategory, enumName: 'StatusCategory' })
|
||||
category!: StatusCategory;
|
||||
@ApiProperty({ enum: SETTING_COLORS, enumName: 'SettingColor' })
|
||||
color!: string;
|
||||
@ApiProperty({ description: 'Entering it needs a reason' })
|
||||
requiresReason!: boolean;
|
||||
@ApiProperty({ description: 'No new tickets or transitions into it' })
|
||||
archived!: boolean;
|
||||
}
|
||||
|
||||
export class StatusTransitionsDto {
|
||||
@ApiProperty({ example: 'IN_QA' }) from!: string;
|
||||
@ApiProperty({ type: [String], example: ['DONE', 'QA_FAILED'] })
|
||||
to!: string[];
|
||||
}
|
||||
|
||||
export class IssueTypeOptionDto {
|
||||
@ApiProperty({ example: 'BUG' }) id!: string;
|
||||
@ApiProperty({ example: 'Bug' }) name!: string;
|
||||
@ApiProperty({ enum: TYPE_ICONS, enumName: 'TypeIcon' }) icon!: string;
|
||||
@ApiProperty({ enum: SETTING_COLORS, enumName: 'SettingColor' })
|
||||
color!: string;
|
||||
@ApiProperty() archived!: boolean;
|
||||
}
|
||||
|
||||
export class AreaOptionDto {
|
||||
@ApiProperty({ example: 'FRONTEND' }) id!: string;
|
||||
@ApiProperty({ example: 'Frontend' }) name!: string;
|
||||
@ApiProperty() archived!: boolean;
|
||||
}
|
||||
|
||||
export class ProjectSettingsDto {
|
||||
@ApiProperty({ type: [WorkflowStatusDto], description: 'In board order' })
|
||||
statuses!: WorkflowStatusDto[];
|
||||
@ApiProperty({ type: [StatusTransitionsDto] })
|
||||
transitions!: StatusTransitionsDto[];
|
||||
@ApiProperty({ type: [IssueTypeOptionDto] })
|
||||
issueTypes!: IssueTypeOptionDto[];
|
||||
@ApiProperty({ type: [AreaOptionDto] }) areas!: AreaOptionDto[];
|
||||
}
|
||||
|
||||
export class ProjectDto {
|
||||
@ApiProperty({ format: 'uuid' }) id!: string;
|
||||
@ApiProperty({ example: 'WEB', description: 'Prefix of the ticket keys' })
|
||||
@@ -33,6 +78,8 @@ export class ProjectDto {
|
||||
@ApiProperty({ type: String, nullable: true }) description!: string | null;
|
||||
@ApiProperty({ type: UserRefDto }) owner!: UserRefDto;
|
||||
@ApiProperty({ type: [ProjectMemberDto] }) members!: ProjectMemberDto[];
|
||||
@ApiProperty({ type: ProjectSettingsDto })
|
||||
settings!: ProjectSettingsDto;
|
||||
@ApiProperty({ format: 'date-time' }) createdAt!: string;
|
||||
@ApiProperty({ format: 'date-time' }) updatedAt!: string;
|
||||
@ApiProperty({ description: 'Version of the aggregate (event count)' })
|
||||
@@ -63,20 +110,21 @@ export class IssueDto {
|
||||
@ApiProperty({ example: 'WEB' }) projectKey!: string;
|
||||
@ApiProperty({ example: 12 }) number!: number;
|
||||
@ApiProperty({ example: 'WEB-12', description: 'Human key' }) key!: string;
|
||||
@ApiProperty({ enum: IssueType, enumName: 'IssueType' }) type!: IssueType;
|
||||
@ApiProperty({ enum: IssueArea, type: String, nullable: true })
|
||||
area!: IssueArea | null;
|
||||
@ApiProperty({ example: 'BUG', description: 'A ticket type id' })
|
||||
type!: string;
|
||||
@ApiProperty({ type: String, nullable: true, example: 'FRONTEND' })
|
||||
area!: string | null;
|
||||
@ApiProperty() title!: string;
|
||||
@ApiProperty({ type: String, nullable: true }) description!: string | null;
|
||||
@ApiProperty({ enum: IssueStatus, enumName: 'IssueStatus' })
|
||||
status!: IssueStatus;
|
||||
@ApiProperty({ example: 'IN_PROGRESS', description: 'A status id' })
|
||||
status!: string;
|
||||
@ApiProperty({ enum: StatusCategory, enumName: 'StatusCategory' })
|
||||
statusCategory!: StatusCategory;
|
||||
@ApiProperty({
|
||||
enum: IssueStatus,
|
||||
enumName: 'IssueStatus',
|
||||
isArray: true,
|
||||
description: 'Statuses this ticket can move to next',
|
||||
type: [String],
|
||||
description: "Statuses this ticket can move to next (project's workflow)",
|
||||
})
|
||||
allowedTransitions!: IssueStatus[];
|
||||
allowedTransitions!: string[];
|
||||
@ApiProperty({ enum: IssuePriority, enumName: 'IssuePriority' })
|
||||
priority!: IssuePriority;
|
||||
@ApiProperty({ type: UserRefDto }) reporter!: UserRefDto;
|
||||
@@ -104,8 +152,7 @@ export class IssuePageDto {
|
||||
}
|
||||
|
||||
export class BoardColumnDto {
|
||||
@ApiProperty({ enum: IssueStatus, enumName: 'IssueStatus' })
|
||||
status!: IssueStatus;
|
||||
@ApiProperty({ example: 'IN_PROGRESS' }) status!: string;
|
||||
@ApiProperty({ type: [IssueDto] }) issues!: IssueDto[];
|
||||
@ApiProperty({ description: 'Tickets in the column (may exceed `issues`)' })
|
||||
total!: number;
|
||||
@@ -143,8 +190,8 @@ export class ReleaseDto {
|
||||
}
|
||||
|
||||
export class CountByStatusDto {
|
||||
@ApiProperty({ enum: IssueStatus, enumName: 'IssueStatus' })
|
||||
key!: IssueStatus;
|
||||
@ApiProperty({ example: 'IN_PROGRESS', description: 'Status id' })
|
||||
key!: string;
|
||||
@ApiProperty() count!: number;
|
||||
}
|
||||
|
||||
@@ -213,3 +260,78 @@ export class CommandResultDto {
|
||||
})
|
||||
version!: number;
|
||||
}
|
||||
|
||||
// ---- Statistics ---------------------------------------------------------------
|
||||
|
||||
export class CreatedResolvedDto {
|
||||
@ApiProperty({ format: 'date', description: 'Monday of the week' })
|
||||
week!: string;
|
||||
@ApiProperty() created!: number;
|
||||
@ApiProperty() resolved!: number;
|
||||
}
|
||||
|
||||
export class FlowSeriesDto {
|
||||
@ApiProperty({ example: 'IN_PROGRESS' }) status!: string;
|
||||
@ApiProperty({ type: [Number], description: 'One value per date' })
|
||||
counts!: number[];
|
||||
}
|
||||
|
||||
export class CumulativeFlowDto {
|
||||
@ApiProperty({ type: [String], format: 'date' }) dates!: string[];
|
||||
@ApiProperty({ type: [FlowSeriesDto] }) series!: FlowSeriesDto[];
|
||||
}
|
||||
|
||||
export class DurationStatsDto {
|
||||
@ApiProperty() count!: number;
|
||||
@ApiProperty({ type: Number, nullable: true }) averageDays!: number | null;
|
||||
@ApiProperty({
|
||||
type: Number,
|
||||
nullable: true,
|
||||
description: '85% of tickets take at most this long',
|
||||
})
|
||||
p85Days!: number | null;
|
||||
}
|
||||
|
||||
export class ThroughputDto {
|
||||
@ApiProperty({ format: 'date' }) week!: string;
|
||||
@ApiProperty({ type: UserRefDto, nullable: true }) user!: UserRefDto | null;
|
||||
@ApiProperty() count!: number;
|
||||
}
|
||||
|
||||
export class QaStatsDto {
|
||||
@ApiProperty({ description: 'Left QA to a done status' }) passed!: number;
|
||||
@ApiProperty({ description: 'Left QA back to work' }) failed!: number;
|
||||
@ApiProperty({ type: Number, nullable: true, minimum: 0, maximum: 1 })
|
||||
failureRate!: number | null;
|
||||
}
|
||||
|
||||
export class ProjectAnalyticsDto {
|
||||
@ApiProperty({ format: 'date-time' }) from!: string;
|
||||
@ApiProperty({ format: 'date-time' }) to!: string;
|
||||
@ApiProperty({ type: [CreatedResolvedDto] })
|
||||
createdVsResolved!: CreatedResolvedDto[];
|
||||
@ApiProperty({
|
||||
type: CumulativeFlowDto,
|
||||
description: 'Tickets in each status at the end of every day',
|
||||
})
|
||||
cumulativeFlow!: CumulativeFlowDto;
|
||||
@ApiProperty({ type: DurationStatsDto, description: 'Start of work → done' })
|
||||
cycleTime!: DurationStatsDto;
|
||||
@ApiProperty({ type: DurationStatsDto, description: 'Creation → done' })
|
||||
leadTime!: DurationStatsDto;
|
||||
@ApiProperty({ type: [ThroughputDto] }) throughput!: ThroughputDto[];
|
||||
@ApiProperty({ type: QaStatsDto }) qa!: QaStatsDto;
|
||||
}
|
||||
|
||||
export class ReleaseBurnupDto {
|
||||
@ApiProperty({ format: 'uuid' }) releaseId!: string;
|
||||
@ApiProperty({ type: String, nullable: true, format: 'date' })
|
||||
targetDate!: string | null;
|
||||
@ApiProperty({ type: [String], format: 'date' }) dates!: string[];
|
||||
@ApiProperty({
|
||||
type: [Number],
|
||||
description: 'Tickets in the release (not cancelled)',
|
||||
})
|
||||
scope!: number[];
|
||||
@ApiProperty({ type: [Number] }) done!: number[];
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
IssueSearchQueryDto,
|
||||
LinkIssueDto,
|
||||
LinkTypeParamDto,
|
||||
MyIssuesQueryDto,
|
||||
PageQueryDto,
|
||||
SetIssueReleaseDto,
|
||||
UpdateIssueDto,
|
||||
@@ -50,7 +51,7 @@ import {
|
||||
IssueDto,
|
||||
IssuePageDto,
|
||||
} from './dto/responses.dto';
|
||||
import { Refs, toIssueDto } from './refs';
|
||||
import { Refs } from './refs';
|
||||
|
||||
const PROJECT = ApiParam({
|
||||
name: 'project',
|
||||
@@ -74,6 +75,25 @@ export class IssuesController {
|
||||
private readonly refs: Refs,
|
||||
) {}
|
||||
|
||||
@Get('me/issues')
|
||||
@ApiOperation({
|
||||
summary: 'My work: tickets across all my projects',
|
||||
description:
|
||||
'Assigned to me by default; with assignedToMe=false, any ticket of my projects (e.g. waiting for QA).',
|
||||
})
|
||||
@ApiOkResponse({ type: IssuePageDto })
|
||||
@ApiReadErrors()
|
||||
async mine(@CurrentUser() me: User, @Query() query: MyIssuesQueryDto) {
|
||||
const page = await this.issues.mine(me.id, {
|
||||
assignedToMe: query.assignedToMe,
|
||||
statuses: query.status,
|
||||
categories: query.category,
|
||||
limit: query.limit,
|
||||
offset: query.offset,
|
||||
});
|
||||
return { ...page, items: await this.refs.issueDtos(page.items) };
|
||||
}
|
||||
|
||||
@Get('projects/:project/issues')
|
||||
@PROJECT
|
||||
@ApiOperation({ summary: 'Search the tickets of a project' })
|
||||
@@ -87,6 +107,7 @@ export class IssuesController {
|
||||
const page = await this.issues.search(me.id, {
|
||||
projectId: await this.refs.projectId(me.id, project),
|
||||
statuses: query.status,
|
||||
categories: query.category,
|
||||
types: query.type,
|
||||
areas: query.area,
|
||||
priorities: query.priority,
|
||||
@@ -98,7 +119,7 @@ export class IssuesController {
|
||||
limit: query.limit,
|
||||
offset: query.offset,
|
||||
});
|
||||
return { ...page, items: page.items.map(toIssueDto) };
|
||||
return { ...page, items: await this.refs.issueDtos(page.items) };
|
||||
}
|
||||
|
||||
@Post('projects/:project/issues')
|
||||
@@ -106,7 +127,7 @@ export class IssuesController {
|
||||
@ApiOperation({
|
||||
summary: 'Create a ticket',
|
||||
description:
|
||||
'Starts in BACKLOG. The response carries its key (e.g. WEB-13).',
|
||||
"Starts in the project's first active TODO-category status (BACKLOG by default). The response carries its key (e.g. WEB-13).",
|
||||
})
|
||||
@ApiCreatedResponse({ type: CommandResultDto })
|
||||
@ApiCommandErrors()
|
||||
@@ -127,7 +148,7 @@ export class IssuesController {
|
||||
@ApiOkResponse({ type: IssueDto })
|
||||
@ApiReadErrors()
|
||||
async findOne(@CurrentUser() me: User, @Param('issue') issue: string) {
|
||||
return toIssueDto(await this.issues.findOne(me.id, issue));
|
||||
return this.refs.issueDto(await this.issues.findOne(me.id, issue));
|
||||
}
|
||||
|
||||
@Patch('issues/:issue')
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
@@ -32,19 +33,23 @@ import {
|
||||
import {
|
||||
ActivityQueryDto,
|
||||
AddMemberDto,
|
||||
AnalyticsQueryDto,
|
||||
BoardQueryDto,
|
||||
ChangeMemberRoleDto,
|
||||
CreateProjectDto,
|
||||
ProjectSettingsBodyDto,
|
||||
UpdateProjectDto,
|
||||
} from './dto/requests.dto';
|
||||
import {
|
||||
ActivityDto,
|
||||
BoardColumnDto,
|
||||
CommandResultDto,
|
||||
ProjectAnalyticsDto,
|
||||
ProjectDto,
|
||||
ProjectStatsDto,
|
||||
} from './dto/responses.dto';
|
||||
import { Refs, toIssueDto } from './refs';
|
||||
import { fromSettingsPayload } from '../common/settings';
|
||||
import { Refs, toProjectDto } from './refs';
|
||||
|
||||
const PROJECT = ApiParam({
|
||||
name: 'project',
|
||||
@@ -67,8 +72,8 @@ export class ProjectsController {
|
||||
@ApiOperation({ summary: 'Projects where I am a member' })
|
||||
@ApiOkResponse({ type: [ProjectDto] })
|
||||
@ApiReadErrors()
|
||||
mine(@CurrentUser() me: User) {
|
||||
return this.projects.findMine(me.id);
|
||||
async mine(@CurrentUser() me: User) {
|
||||
return (await this.projects.findMine(me.id)).map(toProjectDto);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@@ -81,11 +86,11 @@ export class ProjectsController {
|
||||
|
||||
@Get(':project')
|
||||
@PROJECT
|
||||
@ApiOperation({ summary: 'A project with its members' })
|
||||
@ApiOperation({ summary: 'A project with its members and settings' })
|
||||
@ApiOkResponse({ type: ProjectDto })
|
||||
@ApiReadErrors()
|
||||
findOne(@CurrentUser() me: User, @Param('project') project: string) {
|
||||
return this.projects.findOne(me.id, project);
|
||||
async findOne(@CurrentUser() me: User, @Param('project') project: string) {
|
||||
return toProjectDto(await this.projects.findOne(me.id, project));
|
||||
}
|
||||
|
||||
@Patch(':project')
|
||||
@@ -105,6 +110,28 @@ export class ProjectsController {
|
||||
);
|
||||
}
|
||||
|
||||
@Put(':project/settings')
|
||||
@PROJECT
|
||||
@ApiOperation({
|
||||
summary: 'Statuses, transitions, ticket types and areas (owner/admin)',
|
||||
description:
|
||||
'Replaces the whole configuration (send what GET /projects/{project} returns, changed). ' +
|
||||
'Values can be added, renamed, reordered or archived, never removed: tickets and statistics keep referring to them.',
|
||||
})
|
||||
@ApiOkResponse({ type: CommandResultDto })
|
||||
@ApiCommandErrors()
|
||||
async updateSettings(
|
||||
@CurrentUser() me: User,
|
||||
@Param('project') project: string,
|
||||
@Body() body: ProjectSettingsBodyDto,
|
||||
) {
|
||||
return this.projects.updateSettings(
|
||||
me.id,
|
||||
await this.refs.projectId(me.id, project),
|
||||
fromSettingsPayload(body),
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':project/board')
|
||||
@PROJECT
|
||||
@ApiOperation({ summary: 'Kanban board: one column per status' })
|
||||
@@ -125,10 +152,12 @@ export class ProjectsController {
|
||||
},
|
||||
query.perColumn,
|
||||
);
|
||||
return columns.map((column) => ({
|
||||
...column,
|
||||
issues: column.issues.map(toIssueDto),
|
||||
}));
|
||||
return Promise.all(
|
||||
columns.map(async (column) => ({
|
||||
...column,
|
||||
issues: await this.refs.issueDtos(column.issues),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':project/stats')
|
||||
@@ -145,6 +174,27 @@ export class ProjectsController {
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':project/analytics')
|
||||
@PROJECT
|
||||
@ApiOperation({
|
||||
summary: 'Statistics of the last `days` days',
|
||||
description:
|
||||
'Created vs resolved per week, cumulative flow per day, cycle and lead time, throughput per person and QA pass/fail.',
|
||||
})
|
||||
@ApiOkResponse({ type: ProjectAnalyticsDto })
|
||||
@ApiReadErrors()
|
||||
async analytics(
|
||||
@CurrentUser() me: User,
|
||||
@Param('project') project: string,
|
||||
@Query() query: AnalyticsQueryDto,
|
||||
) {
|
||||
return this.projects.analytics(
|
||||
me.id,
|
||||
await this.refs.projectId(me.id, project),
|
||||
query.days,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':project/activity')
|
||||
@PROJECT
|
||||
@ApiOperation({ summary: 'Activity feed (newest first)' })
|
||||
|
||||
@@ -2,8 +2,13 @@ import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
IssuesService,
|
||||
ProjectsService,
|
||||
WorkflowsService,
|
||||
} from '@task-stream/api/tracker/application';
|
||||
import { IssueReadModel, IssueWorkflow } from '@task-stream/api/tracker/domain';
|
||||
import {
|
||||
IssueReadModel,
|
||||
ProjectReadModel,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { toSettingsPayload } from '../common/settings';
|
||||
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
@@ -17,6 +22,7 @@ export class Refs {
|
||||
constructor(
|
||||
private readonly projects: ProjectsService,
|
||||
private readonly issues: IssuesService,
|
||||
private readonly workflows: WorkflowsService,
|
||||
) {}
|
||||
|
||||
async projectId(viewerId: string, ref: string): Promise<string> {
|
||||
@@ -28,12 +34,21 @@ export class Refs {
|
||||
if (UUID.test(ref)) return ref;
|
||||
return (await this.issues.findOne(viewerId, ref)).id;
|
||||
}
|
||||
|
||||
/** The ticket as the REST API returns it: read model + allowed transitions. */
|
||||
async issueDto(issue: IssueReadModel) {
|
||||
return {
|
||||
...issue,
|
||||
allowedTransitions: await this.workflows.allowedTransitions(issue),
|
||||
};
|
||||
}
|
||||
|
||||
issueDtos(issues: IssueReadModel[]) {
|
||||
return Promise.all(issues.map((issue) => this.issueDto(issue)));
|
||||
}
|
||||
}
|
||||
|
||||
/** The ticket as the REST API returns it: read model + allowed transitions. */
|
||||
export function toIssueDto(issue: IssueReadModel) {
|
||||
return {
|
||||
...issue,
|
||||
allowedTransitions: IssueWorkflow.allowedFrom(issue.status),
|
||||
};
|
||||
/** The project as the REST API returns it: transitions as a list. */
|
||||
export function toProjectDto(project: ProjectReadModel) {
|
||||
return { ...project, settings: toSettingsPayload(project.settings) };
|
||||
}
|
||||
|
||||
@@ -33,15 +33,17 @@ import {
|
||||
CreateReleaseDto,
|
||||
PageQueryDto,
|
||||
PublishReleaseDto,
|
||||
UpcomingReleasesQueryDto,
|
||||
UpdateReleaseDto,
|
||||
} from './dto/requests.dto';
|
||||
import {
|
||||
CommandResultDto,
|
||||
IssuePageDto,
|
||||
ReleaseDto,
|
||||
ReleaseBurnupDto,
|
||||
ReleaseProgressDto,
|
||||
} from './dto/responses.dto';
|
||||
import { Refs, toIssueDto } from './refs';
|
||||
import { Refs } from './refs';
|
||||
|
||||
const PROJECT = ApiParam({
|
||||
name: 'project',
|
||||
@@ -61,6 +63,16 @@ export class ReleasesController {
|
||||
private readonly refs: Refs,
|
||||
) {}
|
||||
|
||||
@Get('me/releases')
|
||||
@ApiOperation({
|
||||
summary: 'Planned releases of all my projects, soonest target date first',
|
||||
})
|
||||
@ApiOkResponse({ type: [ReleaseDto] })
|
||||
@ApiReadErrors()
|
||||
upcoming(@CurrentUser() me: User, @Query() query: UpcomingReleasesQueryDto) {
|
||||
return this.releases.upcoming(me.id, query.limit);
|
||||
}
|
||||
|
||||
@Get('projects/:project/releases')
|
||||
@PROJECT
|
||||
@ApiOperation({ summary: 'Releases of a project' })
|
||||
@@ -117,6 +129,16 @@ export class ReleasesController {
|
||||
return this.releases.progress(me.id, releaseId);
|
||||
}
|
||||
|
||||
@Get('releases/:releaseId/burnup')
|
||||
@ApiOperation({
|
||||
summary: 'Burnup: tickets in the release and finished ones, day by day',
|
||||
})
|
||||
@ApiOkResponse({ type: ReleaseBurnupDto })
|
||||
@ApiReadErrors()
|
||||
burnup(@CurrentUser() me: User, @Param('releaseId') releaseId: string) {
|
||||
return this.releases.burnup(me.id, releaseId);
|
||||
}
|
||||
|
||||
@Get('releases/:releaseId/issues')
|
||||
@ApiOperation({ summary: 'Tickets included in the release' })
|
||||
@ApiOkResponse({ type: IssuePageDto })
|
||||
@@ -133,7 +155,7 @@ export class ReleasesController {
|
||||
limit: page.limit,
|
||||
offset: page.offset,
|
||||
});
|
||||
return { ...found, items: found.items.map(toIssueDto) };
|
||||
return { ...found, items: await this.refs.issueDtos(found.items) };
|
||||
}
|
||||
|
||||
@Post('releases/:releaseId/publish')
|
||||
|
||||
@@ -7,9 +7,11 @@ Estado del tracker (NgRx Signals con eventos) detrás de facades.
|
||||
|
||||
## Contenido
|
||||
|
||||
- Facades (lo que se exporta): `ProjectsFacade` (raíz) y `ProjectFacade`, `BoardFacade`, `IssueFacade` (de ruta, con
|
||||
- Facades (lo que se exporta): `ProjectsFacade` y `HomeFacade` («Mi trabajo») (raíz) y `ProjectFacade`, `BoardFacade`, `IssueFacade` (de ruta, con
|
||||
`provideProjectFacade()`, `provideBoardFacade()`, `provideIssueFacade()`). Tests en `facades/facades.spec.ts`.
|
||||
- Interno: stores `ProjectsStore`, `ProjectStore`, `BoardStore`, `IssueStore` y eventos por origen
|
||||
- `ProjectFacade` también da la configuración del proyecto (`workflow`, `saveSettings`) y sus estadísticas
|
||||
(`loadAnalytics`, `loadBurnup`).
|
||||
- Interno: stores `ProjectsStore`, `ProjectStore`, `HomeStore`, `BoardStore`, `IssueStore` y eventos por origen
|
||||
(`*PageEvents`, `*ApiEvents`, `*RealtimeEvents`).
|
||||
- `board.reducers.ts`: transformaciones puras del tablero (movimiento optimista, cambios en vivo por versión).
|
||||
- Puerto `Notifications`.
|
||||
|
||||
@@ -3,3 +3,4 @@ export * from './lib/facades/projects.facade';
|
||||
export * from './lib/facades/project.facade';
|
||||
export * from './lib/facades/board.facade';
|
||||
export * from './lib/facades/issue.facade';
|
||||
export * from './lib/facades/home.facade';
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
CommandResult,
|
||||
CreateIssueInput,
|
||||
IssueChanged,
|
||||
IssueStatus,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
|
||||
export const boardPageEvents = eventGroup({
|
||||
@@ -17,7 +16,7 @@ export const boardPageEvents = eventGroup({
|
||||
/** Optimistic: the card moves immediately and comes back if the API refuses. */
|
||||
issueMoved: type<{
|
||||
issueId: string;
|
||||
to: IssueStatus;
|
||||
to: string;
|
||||
reason?: string | null;
|
||||
}>(),
|
||||
issueCreateRequested: type<CreateIssueInput>(),
|
||||
|
||||
23
libs/web/tracker/application/src/lib/events/home.events.ts
Normal file
23
libs/web/tracker/application/src/lib/events/home.events.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { type } from '@ngrx/signals';
|
||||
import { eventGroup } from '@ngrx/signals/events';
|
||||
import { IssuePage, Release } from '@task-stream/web/tracker/domain';
|
||||
|
||||
export const homePageEvents = eventGroup({
|
||||
source: 'Home Page',
|
||||
events: {
|
||||
/** Loads (or reloads) "my work": also used to refresh when coming back. */
|
||||
opened: type<void>(),
|
||||
},
|
||||
});
|
||||
|
||||
export const homeApiEvents = eventGroup({
|
||||
source: 'Home API',
|
||||
events: {
|
||||
loaded: type<{
|
||||
mine: IssuePage;
|
||||
awaitingQa: IssuePage;
|
||||
releases: Release[];
|
||||
}>(),
|
||||
loadFailed: type<string>(),
|
||||
},
|
||||
});
|
||||
@@ -5,8 +5,11 @@ import {
|
||||
CreateReleaseInput,
|
||||
MemberInput,
|
||||
Project,
|
||||
ProjectAnalytics,
|
||||
ProjectSettings,
|
||||
ProjectStats,
|
||||
Release,
|
||||
ReleaseBurnup,
|
||||
UserDirectoryEntry,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
|
||||
@@ -21,6 +24,12 @@ export const projectPageEvents = eventGroup({
|
||||
releasePublishRequested: type<{ releaseId: string; force: boolean }>(),
|
||||
releaseArchiveRequested: type<{ releaseId: string }>(),
|
||||
userSearchRequested: type<string>(),
|
||||
settingsSaveRequested: type<{
|
||||
projectId: string;
|
||||
settings: ProjectSettings;
|
||||
}>(),
|
||||
analyticsRequested: type<{ projectId: string; days: number }>(),
|
||||
burnupRequested: type<{ releaseId: string }>(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -35,6 +44,10 @@ export const projectApiEvents = eventGroup({
|
||||
commandSucceeded: type<string>(),
|
||||
commandFailed: type<string>(),
|
||||
usersFound: type<UserDirectoryEntry[]>(),
|
||||
analyticsLoaded: type<ProjectAnalytics>(),
|
||||
analyticsFailed: type<string>(),
|
||||
burnupLoaded: type<ReleaseBurnup>(),
|
||||
settingsSaved: type<string>(),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { inject, Injectable, Provider } from '@angular/core';
|
||||
import { injectDispatch } from '@ngrx/signals/events';
|
||||
import {
|
||||
BoardFilter,
|
||||
CreateIssueInput,
|
||||
IssueStatus,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { BoardFilter, CreateIssueInput } from '@task-stream/web/tracker/domain';
|
||||
import { boardPageEvents } from '../events/board.events';
|
||||
import { BoardStore } from '../stores/board.store';
|
||||
|
||||
@@ -30,7 +26,7 @@ export class BoardFacade {
|
||||
}
|
||||
|
||||
/** Optimistic: the card moves at once and comes back if the API refuses. */
|
||||
moveIssue(issueId: string, to: IssueStatus, reason?: string | null): void {
|
||||
moveIssue(issueId: string, to: string, reason?: string | null): void {
|
||||
this.dispatch.issueMoved({ issueId, to, reason });
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
ActivityGateway,
|
||||
CommentsGateway,
|
||||
IssuesGateway,
|
||||
IssueStatus,
|
||||
Project,
|
||||
ProjectRole,
|
||||
ProjectsGateway,
|
||||
@@ -19,12 +18,14 @@ import { boardPageEvents } from '../events/board.events';
|
||||
import { projectPageEvents } from '../events/project.events';
|
||||
import { Notifications } from '../notifications';
|
||||
import { BoardFacade, provideBoardFacade } from './board.facade';
|
||||
import { HomeFacade } from './home.facade';
|
||||
import { IssueFacade, provideIssueFacade } from './issue.facade';
|
||||
import { ProjectFacade, provideProjectFacade } from './project.facade';
|
||||
|
||||
const ok = { id: 'x', key: null, aggregateVersion: 1 };
|
||||
const project = { id: 'p1', key: 'WEB', members: [] } as unknown as Project;
|
||||
const issue = { id: 'i1', key: 'WEB-1', links: [] };
|
||||
const analytics = { from: 'a', to: 'b', qa: { failureRate: 0.25 } };
|
||||
|
||||
/** Every command succeeds, every query answers once, nothing arrives in real time. */
|
||||
function gateways() {
|
||||
@@ -33,6 +34,8 @@ function gateways() {
|
||||
project: vi.fn(() => of(project)),
|
||||
stats: vi.fn(() => NEVER),
|
||||
addMember: vi.fn(() => of(ok)),
|
||||
updateSettings: vi.fn(() => of(ok)),
|
||||
analytics: vi.fn(() => of(analytics)),
|
||||
},
|
||||
issues: {
|
||||
board: vi.fn(() => of([])),
|
||||
@@ -127,6 +130,28 @@ describe('ProjectFacade', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('saves settings and loads statistics of the open project', () => {
|
||||
const { project, fakes } = setup();
|
||||
project.open('WEB');
|
||||
const settings = {
|
||||
statuses: [],
|
||||
transitions: [],
|
||||
issueTypes: [],
|
||||
areas: [],
|
||||
};
|
||||
|
||||
project.saveSettings(settings);
|
||||
expect(fakes.projects.updateSettings).toHaveBeenCalledWith('p1', settings);
|
||||
expect(project.savingSettings()).toBe(false);
|
||||
|
||||
project.loadAnalytics(30);
|
||||
expect(fakes.projects.analytics).toHaveBeenCalledWith('p1', 30);
|
||||
expect(project.analyticsDays()).toBe(30);
|
||||
expect(project.analytics()).toBe(analytics);
|
||||
// Unknown ids still get a readable name.
|
||||
expect(project.workflow().status('IN_REVIEW').name).toBe('In review');
|
||||
});
|
||||
|
||||
it('searches the directory from two characters', () => {
|
||||
const { project } = setup();
|
||||
const seen: string[] = [];
|
||||
@@ -145,15 +170,71 @@ describe('IssueFacade', () => {
|
||||
it('acts on the open ticket', () => {
|
||||
const { issue, fakes } = setup();
|
||||
|
||||
issue.changeStatus(IssueStatus.IN_PROGRESS);
|
||||
issue.changeStatus('IN_PROGRESS');
|
||||
expect(fakes.issues.changeStatus).not.toHaveBeenCalled();
|
||||
|
||||
issue.open('WEB-1');
|
||||
issue.changeStatus(IssueStatus.QA_FAILED, 'Falla en Safari');
|
||||
issue.changeStatus('QA_FAILED', 'Falla en Safari');
|
||||
expect(fakes.issues.changeStatus).toHaveBeenCalledWith({
|
||||
issueId: 'i1',
|
||||
status: IssueStatus.QA_FAILED,
|
||||
status: 'QA_FAILED',
|
||||
reason: 'Falla en Safari',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('HomeFacade', () => {
|
||||
const issue = (key: string, status: string, statusCategory: string) =>
|
||||
({ id: key, key, status, statusCategory, title: key }) as never;
|
||||
|
||||
it('loads my work and groups it by category, QA failures first', () => {
|
||||
const mine = vi.fn((filter: { assignedToMe: boolean }) =>
|
||||
of(
|
||||
filter.assignedToMe
|
||||
? {
|
||||
items: [
|
||||
issue('WEB-1', 'IN_PROGRESS', 'IN_PROGRESS'),
|
||||
issue('WEB-4', 'BACKLOG', 'TODO'),
|
||||
issue('WEB-2', 'QA_FAILED', 'IN_PROGRESS'),
|
||||
],
|
||||
total: 3,
|
||||
}
|
||||
: { items: [issue('WEB-3', 'STAGING', 'QA')], total: 1 },
|
||||
),
|
||||
);
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: IssuesGateway, useValue: { mine } },
|
||||
{
|
||||
provide: ReleasesGateway,
|
||||
useValue: { upcoming: () => of([{ id: 'r1', name: 'v1.4.0' }]) },
|
||||
},
|
||||
],
|
||||
});
|
||||
const home = TestBed.inject(HomeFacade);
|
||||
|
||||
home.load();
|
||||
|
||||
expect(mine).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ assignedToMe: true }),
|
||||
);
|
||||
expect(mine).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
assignedToMe: false,
|
||||
categories: ['QA'],
|
||||
}),
|
||||
);
|
||||
expect(home.loaded()).toBe(true);
|
||||
expect(
|
||||
home
|
||||
.mineByCategory()
|
||||
.map((g) => [g.category, g.issues.map((i) => i.key)]),
|
||||
).toEqual([
|
||||
['IN_PROGRESS', ['WEB-2', 'WEB-1']],
|
||||
['TODO', ['WEB-4']],
|
||||
]);
|
||||
expect(home.qaFailed()).toBe(1);
|
||||
expect(home.awaitingQaTotal()).toBe(1);
|
||||
expect(home.releases().map((r) => r.name)).toEqual(['v1.4.0']);
|
||||
});
|
||||
});
|
||||
|
||||
32
libs/web/tracker/application/src/lib/facades/home.facade.ts
Normal file
32
libs/web/tracker/application/src/lib/facades/home.facade.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { injectDispatch } from '@ngrx/signals/events';
|
||||
import { homePageEvents } from '../events/home.events';
|
||||
import { HomeStore } from '../stores/home.store';
|
||||
|
||||
/** "My work" on the home page: my tickets, waiting for QA, upcoming releases. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class HomeFacade {
|
||||
private readonly store = inject(HomeStore);
|
||||
private readonly dispatch = injectDispatch(homePageEvents);
|
||||
|
||||
/** My open tickets (most recently updated first) and how many there are. */
|
||||
readonly mine = this.store.mine;
|
||||
readonly mineTotal = this.store.mineTotal;
|
||||
/** The same, grouped by category (in progress, QA, pending), QA failures first. */
|
||||
readonly mineByCategory = this.store.mineByCategory;
|
||||
readonly qaFailed = this.store.qaFailed;
|
||||
/** Tickets of my projects in a QA-category status (anyone's). */
|
||||
readonly awaitingQa = this.store.awaitingQa;
|
||||
readonly awaitingQaTotal = this.store.awaitingQaTotal;
|
||||
/** Planned releases of my projects, soonest first. */
|
||||
readonly releases = this.store.releases;
|
||||
/** False until the first load: show skeletons, not empty states. */
|
||||
readonly loaded = this.store.loaded;
|
||||
readonly loading = this.store.loading;
|
||||
readonly error = this.store.error;
|
||||
|
||||
/** Loads my work; call again to refresh (e.g. when the tab regains focus). */
|
||||
load(): void {
|
||||
this.dispatch.opened();
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { injectDispatch } from '@ngrx/signals/events';
|
||||
import {
|
||||
IssueLink,
|
||||
IssueLinkType,
|
||||
IssueStatus,
|
||||
UpdateIssueInput,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { issuePageEvents } from '../events/issue.events';
|
||||
@@ -34,7 +33,7 @@ export class IssueFacade {
|
||||
}
|
||||
|
||||
/** QA_FAILED and CANCELLED need a reason. */
|
||||
changeStatus(status: IssueStatus, reason?: string | null): void {
|
||||
changeStatus(status: string, reason?: string | null): void {
|
||||
this.withIssue((issueId) =>
|
||||
this.dispatch.statusChangeRequested({ issueId, status, reason }),
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { injectDispatch } from '@ngrx/signals/events';
|
||||
import {
|
||||
CreateReleaseInput,
|
||||
ProjectRole,
|
||||
ProjectSettings,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { projectPageEvents } from '../events/project.events';
|
||||
import { ProjectStore } from '../stores/project.store';
|
||||
@@ -30,6 +31,16 @@ export class ProjectFacade {
|
||||
readonly canManage = this.store.canManage;
|
||||
/** Any role but viewer. */
|
||||
readonly canContribute = this.store.canContribute;
|
||||
/** Statuses, ticket types and areas of this project (names, colours, lookups). */
|
||||
readonly workflow = this.store.workflow;
|
||||
readonly savingSettings = this.store.savingSettings;
|
||||
/** Statistics of the last `analyticsDays()` days (see `loadAnalytics`). */
|
||||
readonly analytics = this.store.analytics;
|
||||
readonly analyticsDays = this.store.analyticsDays;
|
||||
readonly analyticsLoading = this.store.analyticsLoading;
|
||||
readonly analyticsError = this.store.analyticsError;
|
||||
/** Burnup of the release asked for with `loadBurnup`. */
|
||||
readonly burnup = this.store.burnup;
|
||||
|
||||
/** Loads the project (by id or key) and follows its changes. */
|
||||
open(idOrKey: string): void {
|
||||
@@ -74,6 +85,21 @@ export class ProjectFacade {
|
||||
this.dispatch.releaseArchiveRequested({ releaseId });
|
||||
}
|
||||
|
||||
/** Replaces statuses, transitions, types and areas (owners and admins). */
|
||||
saveSettings(settings: ProjectSettings): void {
|
||||
const projectId = this.projectId();
|
||||
if (projectId) this.dispatch.settingsSaveRequested({ projectId, settings });
|
||||
}
|
||||
|
||||
loadAnalytics(days = this.store.analyticsDays()): void {
|
||||
const projectId = this.projectId();
|
||||
if (projectId) this.dispatch.analyticsRequested({ projectId, days });
|
||||
}
|
||||
|
||||
loadBurnup(releaseId: string): void {
|
||||
this.dispatch.burnupRequested({ releaseId });
|
||||
}
|
||||
|
||||
private projectId(): string | null {
|
||||
return this.store.project()?.id ?? null;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import {
|
||||
BoardColumn,
|
||||
IssueStatus,
|
||||
IssueSummary,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { BoardColumn, IssueSummary } from '@task-stream/web/tracker/domain';
|
||||
import { applyIssueChange, moveIssue } from './board.reducers';
|
||||
|
||||
const issue = (
|
||||
id: string,
|
||||
status: IssueStatus,
|
||||
status: string,
|
||||
version = 1,
|
||||
assignee: string | null = null,
|
||||
): IssueSummary =>
|
||||
@@ -27,55 +23,42 @@ const issue = (
|
||||
|
||||
const board = (): BoardColumn[] => [
|
||||
{
|
||||
status: IssueStatus.TODO,
|
||||
status: 'TODO',
|
||||
total: 1,
|
||||
issues: [issue('a', IssueStatus.TODO)],
|
||||
issues: [issue('a', 'TODO')],
|
||||
},
|
||||
{ status: IssueStatus.IN_PROGRESS, total: 0, issues: [] },
|
||||
{ status: 'IN_PROGRESS', total: 0, issues: [] },
|
||||
];
|
||||
|
||||
describe('board reducers', () => {
|
||||
it('moves a card optimistically', () => {
|
||||
const next = moveIssue(board(), 'a', IssueStatus.IN_PROGRESS);
|
||||
const next = moveIssue(board(), 'a', 'IN_PROGRESS');
|
||||
expect(next[0]).toMatchObject({ total: 0, issues: [] });
|
||||
expect(next[1].issues[0]).toMatchObject({
|
||||
id: 'a',
|
||||
status: IssueStatus.IN_PROGRESS,
|
||||
status: 'IN_PROGRESS',
|
||||
});
|
||||
expect(next[1].total).toBe(1);
|
||||
});
|
||||
|
||||
it('applies newer realtime versions and ignores stale ones', () => {
|
||||
const moved = applyIssueChange(
|
||||
board(),
|
||||
issue('a', IssueStatus.IN_PROGRESS, 2),
|
||||
null,
|
||||
);
|
||||
const moved = applyIssueChange(board(), issue('a', 'IN_PROGRESS', 2), null);
|
||||
expect(moved[1].issues.map((i) => i.id)).toEqual(['a']);
|
||||
|
||||
const stale = applyIssueChange(
|
||||
moved,
|
||||
issue('a', IssueStatus.TODO, 1),
|
||||
null,
|
||||
);
|
||||
const stale = applyIssueChange(moved, issue('a', 'TODO', 1), null);
|
||||
expect(stale).toBe(moved);
|
||||
});
|
||||
|
||||
it('drops issues that no longer match the filter', () => {
|
||||
const next = applyIssueChange(
|
||||
board(),
|
||||
issue('a', IssueStatus.TODO, 2, 'bob'),
|
||||
{ projectId: 'p', assigneeId: 'ana' },
|
||||
);
|
||||
const next = applyIssueChange(board(), issue('a', 'TODO', 2, 'bob'), {
|
||||
projectId: 'p',
|
||||
assigneeId: 'ana',
|
||||
});
|
||||
expect(next[0].issues).toEqual([]);
|
||||
});
|
||||
|
||||
it('adds new issues from other users', () => {
|
||||
const next = applyIssueChange(
|
||||
board(),
|
||||
issue('b', IssueStatus.TODO, 1),
|
||||
null,
|
||||
);
|
||||
const next = applyIssueChange(board(), issue('b', 'TODO', 1), null);
|
||||
expect(next[0].issues.map((i) => i.id)).toEqual(['b', 'a']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
BoardColumn,
|
||||
BoardFilter,
|
||||
IssueStatus,
|
||||
IssueSummary,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
|
||||
@@ -56,7 +55,7 @@ export function placeIssue(
|
||||
export function moveIssue(
|
||||
columns: BoardColumn[],
|
||||
issueId: string,
|
||||
to: IssueStatus,
|
||||
to: string,
|
||||
): BoardColumn[] {
|
||||
const issue = findIssue(columns, issueId);
|
||||
return issue
|
||||
|
||||
125
libs/web/tracker/application/src/lib/stores/home.store.ts
Normal file
125
libs/web/tracker/application/src/lib/stores/home.store.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { computed, inject } from '@angular/core';
|
||||
import { mapResponse } from '@ngrx/operators';
|
||||
import { signalStore, withComputed, withState } from '@ngrx/signals';
|
||||
import {
|
||||
Events,
|
||||
on,
|
||||
withEventHandlers,
|
||||
withReducer,
|
||||
} from '@ngrx/signals/events';
|
||||
import {
|
||||
IssuesGateway,
|
||||
IssueSummary,
|
||||
OPEN_CATEGORIES,
|
||||
QA_FAILED,
|
||||
Release,
|
||||
ReleasesGateway,
|
||||
StatusCategory,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { forkJoin, switchMap } from 'rxjs';
|
||||
import { homeApiEvents, homePageEvents } from '../events/home.events';
|
||||
import { describeError } from '../notifications';
|
||||
|
||||
/** Groups of "my work": what is moving first, then QA, then what is pending. */
|
||||
const GROUP_ORDER = [
|
||||
StatusCategory.IN_PROGRESS,
|
||||
StatusCategory.QA,
|
||||
StatusCategory.TODO,
|
||||
];
|
||||
|
||||
interface HomeState {
|
||||
mine: IssueSummary[];
|
||||
mineTotal: number;
|
||||
awaitingQa: IssueSummary[];
|
||||
awaitingQaTotal: number;
|
||||
releases: Release[];
|
||||
loaded: boolean;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* "My work" across every project I belong to: my open tickets, tickets
|
||||
* waiting for QA and upcoming releases. Root-scoped, so coming back to the
|
||||
* home page shows the last data at once while it refreshes.
|
||||
*/
|
||||
export const HomeStore = signalStore(
|
||||
{ providedIn: 'root' },
|
||||
withState<HomeState>({
|
||||
mine: [],
|
||||
mineTotal: 0,
|
||||
awaitingQa: [],
|
||||
awaitingQaTotal: 0,
|
||||
releases: [],
|
||||
loaded: false,
|
||||
loading: false,
|
||||
error: null,
|
||||
}),
|
||||
withComputed(({ mine }) => ({
|
||||
/**
|
||||
* My open tickets grouped by category (statuses are per project), QA
|
||||
* failures first within their group.
|
||||
*/
|
||||
mineByCategory: computed(() =>
|
||||
GROUP_ORDER.map((category) => ({
|
||||
category,
|
||||
issues: mine()
|
||||
.filter((issue) => issue.statusCategory === category)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Number(b.status === QA_FAILED) - Number(a.status === QA_FAILED),
|
||||
),
|
||||
})).filter((group) => group.issues.length > 0),
|
||||
),
|
||||
qaFailed: computed(
|
||||
() => mine().filter((issue) => issue.status === QA_FAILED).length,
|
||||
),
|
||||
})),
|
||||
withReducer(
|
||||
on(homePageEvents.opened, () => ({ loading: true, error: null })),
|
||||
on(homeApiEvents.loaded, ({ payload }) => ({
|
||||
mine: payload.mine.items,
|
||||
mineTotal: payload.mine.total,
|
||||
awaitingQa: payload.awaitingQa.items,
|
||||
awaitingQaTotal: payload.awaitingQa.total,
|
||||
releases: payload.releases,
|
||||
loaded: true,
|
||||
loading: false,
|
||||
})),
|
||||
on(homeApiEvents.loadFailed, ({ payload }) => ({
|
||||
loading: false,
|
||||
error: payload,
|
||||
})),
|
||||
),
|
||||
withEventHandlers(
|
||||
(
|
||||
_store,
|
||||
events = inject(Events),
|
||||
issues = inject(IssuesGateway),
|
||||
releases = inject(ReleasesGateway),
|
||||
) => ({
|
||||
load$: events.on(homePageEvents.opened).pipe(
|
||||
switchMap(() =>
|
||||
forkJoin({
|
||||
mine: issues.mine({
|
||||
assignedToMe: true,
|
||||
categories: [...OPEN_CATEGORIES],
|
||||
limit: 50,
|
||||
}),
|
||||
awaitingQa: issues.mine({
|
||||
assignedToMe: false,
|
||||
categories: [StatusCategory.QA],
|
||||
limit: 20,
|
||||
}),
|
||||
releases: releases.upcoming(5),
|
||||
}).pipe(
|
||||
mapResponse({
|
||||
next: (data) => homeApiEvents.loaded(data),
|
||||
error: (error) => homeApiEvents.loadFailed(describeError(error)),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
}),
|
||||
),
|
||||
);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user