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:
@@ -433,6 +433,9 @@ describe('task-stream (e2e)', () => {
|
||||
'/api/projects/{project}/issues',
|
||||
'/api/issues/{issue}/status',
|
||||
'/api/releases/{releaseId}/publish',
|
||||
'/api/projects/{project}/settings',
|
||||
'/api/projects/{project}/analytics',
|
||||
'/api/releases/{releaseId}/burnup',
|
||||
'/api/users/me',
|
||||
]),
|
||||
);
|
||||
@@ -552,9 +555,323 @@ describe('task-stream (e2e)', () => {
|
||||
);
|
||||
expect(comments.body['items'][0]).toMatchObject({ author: { id: '103' } });
|
||||
|
||||
// --- My work across projects ---
|
||||
const mine = await eventually(
|
||||
() => rest('token-qa', 'GET', '/me/issues'),
|
||||
(r) => r.body?.['items']?.some((i: { key: string }) => i.key === beKey),
|
||||
);
|
||||
// Created by Quinn but assigned to nobody: not in "assigned to me".
|
||||
expect(mine.body['items'].map((i: { key: string }) => i.key)).not.toContain(
|
||||
qaKey,
|
||||
);
|
||||
const todo = await rest(
|
||||
'token-ana',
|
||||
'GET',
|
||||
'/me/issues?assignedToMe=false&status=TODO',
|
||||
);
|
||||
expect(todo.body['items'].map((i: { key: string }) => i.key)).toContain(
|
||||
beKey,
|
||||
);
|
||||
const viaGraphql = await gql(
|
||||
'token-qa',
|
||||
`{ myIssues { total items { key assignee { id } } } }`,
|
||||
);
|
||||
expect(viaGraphql['myIssues'].items).toEqual(
|
||||
expect.arrayContaining([{ key: beKey, assignee: { id: '103' } }]),
|
||||
);
|
||||
await rest('token-ana', 'POST', `/projects/${key}/releases`, {
|
||||
name: 'v9.9.9',
|
||||
targetDate: '2099-01-01',
|
||||
});
|
||||
await eventually(
|
||||
() => rest('token-ana', 'GET', '/me/releases?limit=50'),
|
||||
(r) =>
|
||||
Array.isArray(r.body) &&
|
||||
r.body.some((rel: { name: string }) => rel.name === 'v9.9.9'),
|
||||
);
|
||||
// Not a member of the project: none of its tickets.
|
||||
const outsider = await rest('token-eve', 'GET', '/me/issues');
|
||||
expect(
|
||||
outsider.body['items'].map((i: { key: string }) => i.key),
|
||||
).not.toContain(beKey);
|
||||
|
||||
// --- Same authorisation as GraphQL ---
|
||||
expect((await rest('token-eve', 'GET', `/issues/${beKey}`)).status).toBe(
|
||||
404,
|
||||
);
|
||||
});
|
||||
|
||||
it('lets each project customise its workflow, types and areas, and measures it', async () => {
|
||||
const key = 'S' + Date.now().toString(36).slice(-5).toUpperCase();
|
||||
const projectId = (
|
||||
await gql(
|
||||
'token-ana',
|
||||
`mutation($i: CreateProjectInput!) { createProject(input: $i) { id } }`,
|
||||
{ i: { key, name: 'Custom workflow' } },
|
||||
)
|
||||
).createProject.id;
|
||||
await eventually(
|
||||
() =>
|
||||
gql('token-ana', `query($k: String!) { project(idOrKey: $k) { id } }`, {
|
||||
k: key,
|
||||
}),
|
||||
(d) => !!d.project,
|
||||
);
|
||||
await gql(
|
||||
'token-ana',
|
||||
`mutation($i: ProjectMemberInput!) { addProjectMember(input: $i) { version } }`,
|
||||
{ i: { projectId, userId: '102', role: 'MEMBER' } },
|
||||
);
|
||||
|
||||
// --- Every project starts with the default settings ---
|
||||
const settingsQuery = `query($k: String!) { project(idOrKey: $k) { settings {
|
||||
statuses { id name category color requiresReason archived }
|
||||
transitions { from to }
|
||||
issueTypes { id name icon color archived }
|
||||
areas { id name archived }
|
||||
} } }`;
|
||||
const { settings } = (await gql('token-ana', settingsQuery, { k: key }))
|
||||
.project;
|
||||
expect(settings.statuses[0]).toEqual({
|
||||
id: 'BACKLOG',
|
||||
name: 'Backlog',
|
||||
category: 'TODO',
|
||||
color: 'stone',
|
||||
requiresReason: false,
|
||||
archived: false,
|
||||
});
|
||||
expect(settings.areas.map((a: { id: string }) => a.id)).toContain('MOBILE');
|
||||
|
||||
// --- A staging step, a Spike type, a Data area; Mobile archived ---
|
||||
type Item = { id: string; archived?: boolean };
|
||||
const reviewAt = settings.statuses.findIndex(
|
||||
(s: Item) => s.id === 'IN_REVIEW',
|
||||
);
|
||||
settings.statuses.splice(reviewAt + 1, 0, {
|
||||
id: 'STAGING',
|
||||
name: 'En staging',
|
||||
category: 'QA',
|
||||
color: 'teal',
|
||||
requiresReason: false,
|
||||
archived: false,
|
||||
});
|
||||
settings.transitions = settings.transitions.map(
|
||||
(t: { from: string; to: string[] }) =>
|
||||
t.from === 'IN_REVIEW'
|
||||
? { from: 'IN_REVIEW', to: ['STAGING', 'IN_PROGRESS'] }
|
||||
: t,
|
||||
);
|
||||
settings.transitions.push({ from: 'STAGING', to: ['DONE', 'IN_PROGRESS'] });
|
||||
settings.issueTypes.push({
|
||||
id: 'SPIKE',
|
||||
name: 'Spike',
|
||||
icon: 'light-bulb',
|
||||
color: 'teal',
|
||||
archived: false,
|
||||
});
|
||||
settings.areas = settings.areas.map((a: Item) =>
|
||||
a.id === 'MOBILE' ? { ...a, archived: true } : a,
|
||||
);
|
||||
settings.areas.push({ id: 'DATA', name: 'Datos', archived: false });
|
||||
|
||||
const save = (token: string, next: object) =>
|
||||
gql(
|
||||
token,
|
||||
`mutation($p: ID!, $s: ProjectSettingsInput!) { updateProjectSettings(projectId: $p, settings: $s) { version } }`,
|
||||
{ p: projectId, s: next },
|
||||
);
|
||||
await expect(save('token-bob', settings)).rejects.toThrow(/owner|admin/i);
|
||||
await expect(
|
||||
save('token-ana', {
|
||||
...settings,
|
||||
areas: settings.areas.filter((a: Item) => a.id !== 'QA'),
|
||||
}),
|
||||
).rejects.toThrow(/archívalo/);
|
||||
await save('token-ana', settings);
|
||||
await eventually(
|
||||
() => gql('token-ana', settingsQuery, { k: key }),
|
||||
(d) => d.project.settings.statuses.some((s: Item) => s.id === 'STAGING'),
|
||||
);
|
||||
|
||||
// --- Tickets follow the project's settings ---
|
||||
const create = `mutation($i: CreateIssueInput!) { createIssue(input: $i) { id key } }`;
|
||||
await expect(
|
||||
gql('token-bob', create, {
|
||||
i: { projectId, type: 'EPIC', title: 'Unknown type' },
|
||||
}),
|
||||
).rejects.toThrow(/Unknown ticket type/);
|
||||
await expect(
|
||||
gql('token-bob', create, {
|
||||
i: { projectId, type: 'TASK', title: 'Old area', area: 'MOBILE' },
|
||||
}),
|
||||
).rejects.toThrow(/archived/);
|
||||
const spike = (
|
||||
await gql('token-bob', create, {
|
||||
i: {
|
||||
projectId,
|
||||
type: 'SPIKE',
|
||||
title: 'Evaluate a column store',
|
||||
area: 'DATA',
|
||||
assigneeId: '102',
|
||||
},
|
||||
})
|
||||
).createIssue;
|
||||
|
||||
const release = (
|
||||
await gql(
|
||||
'token-ana',
|
||||
`mutation($i: CreateReleaseInput!) { createRelease(input: $i) { id } }`,
|
||||
{ i: { projectId, name: 'v1.0.0' } },
|
||||
)
|
||||
).createRelease;
|
||||
await eventually(
|
||||
() =>
|
||||
gql('token-ana', `query($id: ID!) { release(id: $id) { id } }`, {
|
||||
id: release.id,
|
||||
}),
|
||||
(d) => !!d.release,
|
||||
);
|
||||
await gql(
|
||||
'token-ana',
|
||||
`mutation($i: ID!, $r: ID) { setIssueRelease(issueId: $i, releaseId: $r) { version } }`,
|
||||
{ i: spike.id, r: release.id },
|
||||
);
|
||||
|
||||
const move = (status: string) =>
|
||||
gql(
|
||||
'token-bob',
|
||||
`mutation($i: ChangeIssueStatusInput!) { changeIssueStatus(input: $i) { version } }`,
|
||||
{ i: { issueId: spike.id, status } },
|
||||
);
|
||||
for (const status of ['TODO', 'IN_PROGRESS', 'IN_REVIEW'])
|
||||
await move(status);
|
||||
const inReview = await eventually(
|
||||
() =>
|
||||
gql(
|
||||
'token-bob',
|
||||
`query($k: String!) { issue(idOrKey: $k) { status statusCategory allowedTransitions } }`,
|
||||
{ k: spike.key },
|
||||
),
|
||||
(d) => d.issue.status === 'IN_REVIEW',
|
||||
);
|
||||
expect(inReview.issue).toEqual({
|
||||
status: 'IN_REVIEW',
|
||||
statusCategory: 'IN_PROGRESS',
|
||||
allowedTransitions: ['STAGING', 'IN_PROGRESS'],
|
||||
});
|
||||
await expect(move('READY_FOR_QA')).rejects.toThrow(/Cannot move/);
|
||||
await move('STAGING');
|
||||
await move('DONE');
|
||||
|
||||
// --- Board, history, releases and statistics use the categories ---
|
||||
const { project } = await eventually(
|
||||
() =>
|
||||
gql(
|
||||
'token-ana',
|
||||
`query($k: String!) { project(idOrKey: $k) {
|
||||
board { status total }
|
||||
stats { total open }
|
||||
releases { progress { finished percent } burnup { scope done } }
|
||||
analytics(days: 30) {
|
||||
createdVsResolved { created resolved }
|
||||
cumulativeFlow { series { status counts } }
|
||||
cycleTime { count }
|
||||
leadTime { count }
|
||||
throughput { user { id } count }
|
||||
qa { passed failed failureRate }
|
||||
}
|
||||
} }`,
|
||||
{ k: key },
|
||||
),
|
||||
(d) => d.project.analytics.cycleTime.count === 1,
|
||||
);
|
||||
const columns = project.board.map((c: { status: string }) => c.status);
|
||||
expect(columns.indexOf('STAGING')).toBe(columns.indexOf('IN_REVIEW') + 1);
|
||||
expect(columns).not.toContain('CANCELLED');
|
||||
expect(project.stats).toEqual({ total: 1, open: 0 });
|
||||
expect(project.releases[0].progress).toEqual({ finished: 1, percent: 100 });
|
||||
expect(project.releases[0].burnup.scope.at(-1)).toBe(1);
|
||||
expect(project.releases[0].burnup.done.at(-1)).toBe(1);
|
||||
const analytics = project.analytics;
|
||||
const sum = (
|
||||
rows: { created: number; resolved: number }[],
|
||||
f: 'created' | 'resolved',
|
||||
) => rows.reduce((total, row) => total + row[f], 0);
|
||||
expect(sum(analytics.createdVsResolved, 'created')).toBe(1);
|
||||
expect(sum(analytics.createdVsResolved, 'resolved')).toBe(1);
|
||||
expect(analytics.cumulativeFlow.series).toEqual([
|
||||
expect.objectContaining({ status: 'DONE' }),
|
||||
]);
|
||||
expect(analytics.leadTime.count).toBe(1);
|
||||
expect(analytics.throughput).toEqual([{ user: { id: '102' }, count: 1 }]);
|
||||
// STAGING is a QA-category step: leaving it to DONE is a QA pass.
|
||||
expect(analytics.qa).toEqual({ passed: 1, failed: 0, failureRate: 0 });
|
||||
|
||||
const history = await gql(
|
||||
'token-ana',
|
||||
`query($k: String!) { issue(idOrKey: $k) { history { summary } } }`,
|
||||
{ k: spike.key },
|
||||
);
|
||||
expect(
|
||||
history.issue.history.map((h: { summary: string }) => h.summary),
|
||||
).toContain(
|
||||
`Bob (frontend) movió ${spike.key} de En revisión a En staging`,
|
||||
);
|
||||
const done = await gql(
|
||||
'token-bob',
|
||||
`{ myIssues(categories: [DONE]) { items { key } } }`,
|
||||
);
|
||||
expect(done.myIssues.items).toEqual(
|
||||
expect.arrayContaining([{ key: spike.key }]),
|
||||
);
|
||||
|
||||
// --- Same over REST ---
|
||||
const viaRest = await rest('token-ana', 'GET', `/projects/${key}`);
|
||||
expect(viaRest.body['settings']['transitions']).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ from: 'STAGING', to: ['DONE', 'IN_PROGRESS'] },
|
||||
]),
|
||||
);
|
||||
const renamed = viaRest.body['settings'];
|
||||
renamed.statuses = renamed.statuses.map((s: Item & { name: string }) =>
|
||||
s.id === 'STAGING' ? { ...s, name: 'Preproducción' } : s,
|
||||
);
|
||||
expect(
|
||||
(await rest('token-ana', 'PUT', `/projects/${key}/settings`, renamed))
|
||||
.status,
|
||||
).toBe(200);
|
||||
const removed = await rest(
|
||||
'token-ana',
|
||||
'PUT',
|
||||
`/projects/${key}/settings`,
|
||||
{ ...renamed, statuses: [] },
|
||||
);
|
||||
expect(removed.status).toBe(422);
|
||||
expect(removed.body['message']).toMatch(/archívalo/);
|
||||
const neon = await rest('token-ana', 'PUT', `/projects/${key}/settings`, {
|
||||
...renamed,
|
||||
areas: [...renamed.areas, { id: 'X', name: 'X', color: 'neon' }],
|
||||
issueTypes: [
|
||||
...renamed.issueTypes,
|
||||
{ id: 'Y', name: 'Y', icon: 'bug', color: 'neon' },
|
||||
],
|
||||
});
|
||||
expect(neon.status).toBe(400);
|
||||
const stats = await rest(
|
||||
'token-ana',
|
||||
'GET',
|
||||
`/projects/${key}/analytics?days=30`,
|
||||
);
|
||||
expect(stats.status).toBe(200);
|
||||
expect(stats.body['cycleTime']['count']).toBe(1);
|
||||
const burnup = await rest(
|
||||
'token-ana',
|
||||
'GET',
|
||||
`/releases/${release.id}/burnup`,
|
||||
);
|
||||
expect(burnup.body).toMatchObject({ releaseId: release.id });
|
||||
expect(
|
||||
(await rest('token-eve', 'GET', `/projects/${key}/analytics`)).status,
|
||||
).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,20 +13,32 @@ type Activity {
|
||||
type: String!
|
||||
}
|
||||
|
||||
type AreaOption {
|
||||
archived: Boolean!
|
||||
id: String!
|
||||
name: String!
|
||||
}
|
||||
|
||||
input AreaOptionInput {
|
||||
archived: Boolean! = false
|
||||
id: String!
|
||||
name: String!
|
||||
}
|
||||
|
||||
type BoardColumn {
|
||||
issues: [Issue!]!
|
||||
status: IssueStatus!
|
||||
status: String!
|
||||
total: Int!
|
||||
}
|
||||
|
||||
input ChangeIssueStatusInput {
|
||||
issueId: ID!
|
||||
|
||||
"""
|
||||
Required for QA_FAILED and CANCELLED
|
||||
"""
|
||||
"""Required by statuses with requiresReason"""
|
||||
reason: String
|
||||
status: IssueStatus!
|
||||
|
||||
"""A status id"""
|
||||
status: String!
|
||||
}
|
||||
|
||||
"""
|
||||
@@ -52,9 +64,7 @@ type CommentChange {
|
||||
action: String!
|
||||
actorId: ID!
|
||||
|
||||
"""
|
||||
Null when deleted
|
||||
"""
|
||||
"""Null when deleted"""
|
||||
comment: Comment
|
||||
commentId: ID!
|
||||
issueId: ID!
|
||||
@@ -69,9 +79,7 @@ type CommentPage {
|
||||
type CountByAssignee {
|
||||
count: Int!
|
||||
|
||||
"""
|
||||
Null = unassigned
|
||||
"""
|
||||
"""Null = unassigned"""
|
||||
user: UserRef
|
||||
}
|
||||
|
||||
@@ -82,18 +90,19 @@ type CountByKey {
|
||||
|
||||
type CountByStatus {
|
||||
count: Int!
|
||||
key: IssueStatus!
|
||||
|
||||
"""Status id"""
|
||||
key: String!
|
||||
}
|
||||
|
||||
input CreateIssueInput {
|
||||
area: IssueArea
|
||||
"""An area id"""
|
||||
area: String
|
||||
assigneeId: ID
|
||||
description: String
|
||||
dueDate: String
|
||||
|
||||
"""
|
||||
Figma frame for UI changes
|
||||
"""
|
||||
"""Figma frame for UI changes"""
|
||||
figmaUrl: String
|
||||
labels: [String!]
|
||||
priority: IssuePriority
|
||||
@@ -101,15 +110,15 @@ input CreateIssueInput {
|
||||
releaseId: ID
|
||||
storyPoints: Int
|
||||
title: String!
|
||||
type: IssueType!
|
||||
|
||||
"""A ticket type id"""
|
||||
type: String!
|
||||
}
|
||||
|
||||
input CreateProjectInput {
|
||||
description: String
|
||||
|
||||
"""
|
||||
2-10 chars, e.g. "TS" → tickets TS-1, TS-2...
|
||||
"""
|
||||
"""2-10 chars, e.g. "TS" → tickets TS-1, TS-2..."""
|
||||
key: String!
|
||||
name: String!
|
||||
}
|
||||
@@ -117,20 +126,44 @@ input CreateProjectInput {
|
||||
input CreateReleaseInput {
|
||||
description: String
|
||||
|
||||
"""
|
||||
e.g. v1.4.0
|
||||
"""
|
||||
"""e.g. v1.4.0"""
|
||||
name: String!
|
||||
projectId: ID!
|
||||
targetDate: String
|
||||
}
|
||||
|
||||
type CreatedResolved {
|
||||
created: Int!
|
||||
resolved: Int!
|
||||
|
||||
"""Monday of the week (YYYY-MM-DD)"""
|
||||
week: String!
|
||||
}
|
||||
|
||||
type CumulativeFlow {
|
||||
dates: [String!]!
|
||||
series: [FlowSeries!]!
|
||||
}
|
||||
|
||||
type DurationStats {
|
||||
averageDays: Float
|
||||
count: Int!
|
||||
|
||||
"""85% of tickets take at most this long"""
|
||||
p85Days: Float
|
||||
}
|
||||
|
||||
type FlowSeries {
|
||||
counts: [Int!]!
|
||||
status: String!
|
||||
}
|
||||
|
||||
type Issue {
|
||||
"""
|
||||
Statuses this ticket can move to next
|
||||
"""
|
||||
allowedTransitions: [IssueStatus!]!
|
||||
area: IssueArea
|
||||
"""Statuses this ticket can move to next (project's workflow)"""
|
||||
allowedTransitions: [String!]!
|
||||
|
||||
"""An area id"""
|
||||
area: String
|
||||
assignee: UserRef
|
||||
commentCount: Int!
|
||||
comments(limit: Int! = 50, offset: Int! = 0): CommentPage!
|
||||
@@ -139,21 +172,15 @@ type Issue {
|
||||
dueDate: String
|
||||
figmaUrl: String
|
||||
|
||||
"""
|
||||
History of this ticket
|
||||
"""
|
||||
"""History of this ticket"""
|
||||
history(limit: Int! = 50): [Activity!]!
|
||||
id: ID!
|
||||
|
||||
"""
|
||||
Human key, e.g. TS-42
|
||||
"""
|
||||
"""Human key, e.g. TS-42"""
|
||||
key: String!
|
||||
labels: [String!]!
|
||||
|
||||
"""
|
||||
Linked tickets I can see
|
||||
"""
|
||||
"""Linked tickets I can see"""
|
||||
linkedIssues: [Issue!]!
|
||||
links: [IssueLink!]!
|
||||
number: Int!
|
||||
@@ -162,26 +189,19 @@ type Issue {
|
||||
projectKey: String!
|
||||
release: ReleaseRef
|
||||
reporter: UserRef!
|
||||
status: IssueStatus!
|
||||
|
||||
"""A status id of the project's settings"""
|
||||
status: String!
|
||||
statusCategory: StatusCategory!
|
||||
storyPoints: Int
|
||||
title: String!
|
||||
type: IssueType!
|
||||
|
||||
"""A ticket type id of the project's settings"""
|
||||
type: String!
|
||||
updatedAt: String!
|
||||
version: Int!
|
||||
}
|
||||
|
||||
"""
|
||||
Team/layer: links FE ↔ BE ↔ QA work
|
||||
"""
|
||||
enum IssueArea {
|
||||
BACKEND
|
||||
DESIGN
|
||||
DEVOPS
|
||||
FRONTEND
|
||||
MOBILE
|
||||
QA
|
||||
}
|
||||
|
||||
type IssueChange {
|
||||
action: String!
|
||||
actorId: ID!
|
||||
@@ -190,26 +210,23 @@ type IssueChange {
|
||||
}
|
||||
|
||||
input IssueFilterInput {
|
||||
areas: [IssueArea!]
|
||||
areas: [String!]
|
||||
assigneeId: ID
|
||||
categories: [StatusCategory!]
|
||||
labels: [String!]
|
||||
priorities: [IssuePriority!]
|
||||
projectId: ID!
|
||||
releaseId: ID
|
||||
|
||||
"""
|
||||
Matches key, title or description
|
||||
"""
|
||||
"""Matches key, title or description"""
|
||||
search: String
|
||||
statuses: [IssueStatus!]
|
||||
types: [IssueType!]
|
||||
statuses: [String!]
|
||||
types: [String!]
|
||||
unassigned: Boolean
|
||||
}
|
||||
|
||||
type IssueLink {
|
||||
"""
|
||||
OUTWARD: this issue → other. INWARD: other → this issue.
|
||||
"""
|
||||
"""OUTWARD: this issue → other. INWARD: other → this issue."""
|
||||
direction: LinkDirection!
|
||||
issueId: ID!
|
||||
issueKey: String!
|
||||
@@ -237,24 +254,20 @@ enum IssuePriority {
|
||||
MEDIUM
|
||||
}
|
||||
|
||||
enum IssueStatus {
|
||||
BACKLOG
|
||||
CANCELLED
|
||||
DONE
|
||||
IN_PROGRESS
|
||||
IN_QA
|
||||
IN_REVIEW
|
||||
QA_FAILED
|
||||
READY_FOR_QA
|
||||
TODO
|
||||
type IssueTypeOption {
|
||||
archived: Boolean!
|
||||
color: String!
|
||||
icon: String!
|
||||
id: String!
|
||||
name: String!
|
||||
}
|
||||
|
||||
enum IssueType {
|
||||
BUG
|
||||
FEATURE
|
||||
QA
|
||||
TASK
|
||||
UI_CHANGE
|
||||
input IssueTypeOptionInput {
|
||||
archived: Boolean! = false
|
||||
color: String!
|
||||
icon: String!
|
||||
id: String!
|
||||
name: String!
|
||||
}
|
||||
|
||||
enum LinkDirection {
|
||||
@@ -269,53 +282,39 @@ input LinkIssuesInput {
|
||||
}
|
||||
|
||||
type Me {
|
||||
"""
|
||||
Roles inside the task-stream OAuth client
|
||||
"""
|
||||
"""Roles inside the task-stream OAuth client"""
|
||||
appRoles: [String!]!
|
||||
email: String!
|
||||
id: ID!
|
||||
isActive: Boolean!
|
||||
name: String!
|
||||
|
||||
"""
|
||||
Global roles in Pireon
|
||||
"""
|
||||
"""Global roles in Pireon"""
|
||||
roles: [String!]!
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
addComment(body: String!, issueId: ID!): CommandResult!
|
||||
|
||||
"""
|
||||
Add a Pireon user to the project
|
||||
"""
|
||||
"""Add a Pireon user to the project"""
|
||||
addProjectMember(input: ProjectMemberInput!): CommandResult!
|
||||
archiveRelease(releaseId: ID!): CommandResult!
|
||||
|
||||
"""
|
||||
Assign (or unassign with null) a project member
|
||||
"""
|
||||
"""Assign (or unassign with null) a project member"""
|
||||
assignIssue(assigneeId: ID, issueId: ID!): CommandResult!
|
||||
|
||||
"""
|
||||
Move a ticket through the workflow (incl. QA)
|
||||
"""
|
||||
"""Move a ticket through the workflow (incl. QA)"""
|
||||
changeIssueStatus(input: ChangeIssueStatusInput!): CommandResult!
|
||||
changeProjectMemberRole(input: ProjectMemberInput!): CommandResult!
|
||||
createIssue(input: CreateIssueInput!): CommandResult!
|
||||
createProject(input: CreateProjectInput!): CommandResult!
|
||||
createRelease(input: CreateReleaseInput!): CommandResult!
|
||||
|
||||
"""
|
||||
Author or project admin
|
||||
"""
|
||||
"""Author or project admin"""
|
||||
deleteComment(commentId: ID!): CommandResult!
|
||||
editComment(body: String!, commentId: ID!): CommandResult!
|
||||
|
||||
"""
|
||||
Relate tickets, e.g. FE DEPENDS_ON BE, QA TESTS FE
|
||||
"""
|
||||
"""Relate tickets, e.g. FE DEPENDS_ON BE, QA TESTS FE"""
|
||||
linkIssues(input: LinkIssuesInput!): CommandResult!
|
||||
|
||||
"""
|
||||
@@ -324,26 +323,27 @@ type Mutation {
|
||||
publishRelease(force: Boolean! = false, releaseId: ID!): CommandResult!
|
||||
removeProjectMember(projectId: ID!, userId: ID!): CommandResult!
|
||||
|
||||
"""
|
||||
Put a ticket in a release (null removes it)
|
||||
"""
|
||||
"""Put a ticket in a release (null removes it)"""
|
||||
setIssueRelease(issueId: ID!, releaseId: ID): CommandResult!
|
||||
unlinkIssues(input: LinkIssuesInput!): CommandResult!
|
||||
updateIssue(input: UpdateIssueInput!): CommandResult!
|
||||
updateProject(input: UpdateProjectInput!): CommandResult!
|
||||
|
||||
"""
|
||||
Replace statuses, transitions, ticket types and areas (owners and admins). Values can be archived, not deleted.
|
||||
"""
|
||||
updateProjectSettings(projectId: ID!, settings: ProjectSettingsInput!): CommandResult!
|
||||
updateRelease(input: UpdateReleaseInput!): CommandResult!
|
||||
}
|
||||
|
||||
type Project {
|
||||
"""
|
||||
Kanban board, one column per status
|
||||
Statistics of the last `days` days (7-365): created vs resolved, cumulative flow, cycle/lead time, throughput and QA
|
||||
"""
|
||||
board(
|
||||
area: IssueArea
|
||||
assigneeId: ID
|
||||
perColumn: Int! = 50
|
||||
releaseId: ID
|
||||
): [BoardColumn!]!
|
||||
analytics(days: Int! = 90): ProjectAnalytics!
|
||||
|
||||
"""Kanban board, one column per status"""
|
||||
board(area: String, assigneeId: ID, perColumn: Int! = 50, releaseId: ID): [BoardColumn!]!
|
||||
createdAt: String!
|
||||
description: String
|
||||
id: ID!
|
||||
@@ -352,15 +352,33 @@ type Project {
|
||||
name: String!
|
||||
owner: UserRef!
|
||||
releases: [Release!]!
|
||||
settings: ProjectSettings!
|
||||
|
||||
"""
|
||||
Project tracking dashboard
|
||||
"""
|
||||
"""Project tracking dashboard"""
|
||||
stats: ProjectStats!
|
||||
updatedAt: String!
|
||||
version: Int!
|
||||
}
|
||||
|
||||
type ProjectAnalytics {
|
||||
createdVsResolved: [CreatedResolved!]!
|
||||
|
||||
"""Tickets in each status at the end of every day"""
|
||||
cumulativeFlow: CumulativeFlow!
|
||||
|
||||
"""Start of work → done"""
|
||||
cycleTime: DurationStats!
|
||||
from: String!
|
||||
|
||||
"""Creation → done"""
|
||||
leadTime: DurationStats!
|
||||
qa: QaStats!
|
||||
|
||||
"""Finished per person and week"""
|
||||
throughput: [Throughput!]!
|
||||
to: String!
|
||||
}
|
||||
|
||||
type ProjectChange {
|
||||
action: String!
|
||||
actorId: ID!
|
||||
@@ -377,9 +395,7 @@ input ProjectMemberInput {
|
||||
projectId: ID!
|
||||
role: ProjectRole! = MEMBER
|
||||
|
||||
"""
|
||||
Pireon user id
|
||||
"""
|
||||
"""Pireon user id"""
|
||||
userId: ID!
|
||||
}
|
||||
|
||||
@@ -390,12 +406,28 @@ enum ProjectRole {
|
||||
VIEWER
|
||||
}
|
||||
|
||||
"""Statuses (board order), transitions, ticket types and areas"""
|
||||
type ProjectSettings {
|
||||
areas: [AreaOption!]!
|
||||
issueTypes: [IssueTypeOption!]!
|
||||
statuses: [WorkflowStatus!]!
|
||||
transitions: [StatusTransitions!]!
|
||||
}
|
||||
|
||||
"""
|
||||
The whole configuration (replaces the previous one). Statuses go in board order.
|
||||
"""
|
||||
input ProjectSettingsInput {
|
||||
areas: [AreaOptionInput!]!
|
||||
issueTypes: [IssueTypeOptionInput!]!
|
||||
statuses: [WorkflowStatusInput!]!
|
||||
transitions: [StatusTransitionsInput!]!
|
||||
}
|
||||
|
||||
type ProjectStats {
|
||||
byArea: [CountByKey!]!
|
||||
|
||||
"""
|
||||
Open tickets per person
|
||||
"""
|
||||
"""Open tickets per person"""
|
||||
byAssignee: [CountByAssignee!]!
|
||||
byPriority: [CountByKey!]!
|
||||
byStatus: [CountByStatus!]!
|
||||
@@ -404,63 +436,59 @@ type ProjectStats {
|
||||
total: Int!
|
||||
}
|
||||
|
||||
type QaStats {
|
||||
failed: Int!
|
||||
failureRate: Float
|
||||
passed: Int!
|
||||
}
|
||||
|
||||
type Query {
|
||||
"""
|
||||
Project activity feed (newest first)
|
||||
"""
|
||||
"""Project activity feed (newest first)"""
|
||||
activity(
|
||||
"""
|
||||
ISO date cursor
|
||||
"""
|
||||
"""ISO date cursor"""
|
||||
before: String
|
||||
issueId: ID
|
||||
limit: Int! = 50
|
||||
projectId: ID!
|
||||
): [Activity!]!
|
||||
issue(
|
||||
"""
|
||||
Issue id or key (e.g. "TS-42")
|
||||
"""
|
||||
"""Issue id or key (e.g. "TS-42")"""
|
||||
idOrKey: String!
|
||||
): Issue!
|
||||
issues(
|
||||
filter: IssueFilterInput!
|
||||
limit: Int! = 50
|
||||
offset: Int! = 0
|
||||
): IssuePage!
|
||||
issues(filter: IssueFilterInput!, limit: Int! = 50, offset: Int! = 0): IssuePage!
|
||||
|
||||
"""
|
||||
The authenticated user
|
||||
"""
|
||||
"""The authenticated user"""
|
||||
me: Me!
|
||||
|
||||
"""
|
||||
Projects where I am a member
|
||||
Tickets across all my projects: assigned to me (default) or any (e.g. categories [QA])
|
||||
"""
|
||||
myIssues(assignedToMe: Boolean! = true, categories: [StatusCategory!], limit: Int! = 50, offset: Int! = 0, statuses: [String!]): IssuePage!
|
||||
|
||||
"""Projects where I am a member"""
|
||||
myProjects: [Project!]!
|
||||
project(
|
||||
"""
|
||||
Project id or key (e.g. "TS")
|
||||
"""
|
||||
"""Project id or key (e.g. "TS")"""
|
||||
idOrKey: String!
|
||||
): Project!
|
||||
release(id: ID!): Release!
|
||||
|
||||
"""Planned releases of all my projects, soonest target date first"""
|
||||
upcomingReleases(limit: Int! = 10): [Release!]!
|
||||
user(id: String!): User
|
||||
|
||||
"""
|
||||
Search users by name or email (to assign / add members)
|
||||
"""
|
||||
"""Search users by name or email (to assign / add members)"""
|
||||
users(limit: Int! = 20, search: String! = ""): [User!]!
|
||||
}
|
||||
|
||||
type Release {
|
||||
"""Scope vs finished tickets, day by day"""
|
||||
burnup: ReleaseBurnup!
|
||||
createdAt: String!
|
||||
description: String
|
||||
id: ID!
|
||||
|
||||
"""
|
||||
Tickets included in this release
|
||||
"""
|
||||
"""Tickets included in this release"""
|
||||
issues(limit: Int! = 100, offset: Int! = 0): IssuePage!
|
||||
name: String!
|
||||
progress: ReleaseProgress!
|
||||
@@ -471,6 +499,16 @@ type Release {
|
||||
version: Int!
|
||||
}
|
||||
|
||||
type ReleaseBurnup {
|
||||
dates: [String!]!
|
||||
done: [Int!]!
|
||||
releaseId: ID!
|
||||
|
||||
"""Tickets in the release (not cancelled)"""
|
||||
scope: [Int!]!
|
||||
targetDate: String
|
||||
}
|
||||
|
||||
type ReleaseChange {
|
||||
action: String!
|
||||
actorId: ID!
|
||||
@@ -496,41 +534,61 @@ enum ReleaseStatus {
|
||||
RELEASED
|
||||
}
|
||||
|
||||
"""
|
||||
What a status means, whatever each project calls it. DONE and CANCELLED are finished.
|
||||
"""
|
||||
enum StatusCategory {
|
||||
CANCELLED
|
||||
DONE
|
||||
IN_PROGRESS
|
||||
QA
|
||||
TODO
|
||||
}
|
||||
|
||||
type StatusTransitions {
|
||||
from: String!
|
||||
to: [String!]!
|
||||
}
|
||||
|
||||
input StatusTransitionsInput {
|
||||
from: String!
|
||||
to: [String!]!
|
||||
}
|
||||
|
||||
type Subscription {
|
||||
"""
|
||||
Live activity feed
|
||||
"""
|
||||
"""Live activity feed"""
|
||||
activityAdded(projectId: ID!): Activity!
|
||||
commentChanged(issueId: ID!): CommentChange!
|
||||
|
||||
"""
|
||||
Tickets created/updated/moved in a project (board in realtime)
|
||||
"""
|
||||
"""Tickets created/updated/moved in a project (board in realtime)"""
|
||||
issueChanged(projectId: ID!): IssueChange!
|
||||
|
||||
"""
|
||||
Projects I am added to / removed from
|
||||
"""
|
||||
"""Projects I am added to / removed from"""
|
||||
myProjectsChanged: ProjectChange!
|
||||
projectChanged(projectId: ID!): ProjectChange!
|
||||
releaseChanged(projectId: ID!): ReleaseChange!
|
||||
}
|
||||
|
||||
type Throughput {
|
||||
count: Int!
|
||||
user: UserRef
|
||||
week: String!
|
||||
}
|
||||
|
||||
input UpdateIssueInput {
|
||||
area: IssueArea
|
||||
"""An area id"""
|
||||
area: String
|
||||
description: String
|
||||
dueDate: String
|
||||
|
||||
"""
|
||||
Figma frame for UI changes
|
||||
"""
|
||||
"""Figma frame for UI changes"""
|
||||
figmaUrl: String
|
||||
issueId: ID!
|
||||
labels: [String!]
|
||||
priority: IssuePriority
|
||||
storyPoints: Int
|
||||
title: String
|
||||
type: IssueType
|
||||
type: String
|
||||
}
|
||||
|
||||
input UpdateProjectInput {
|
||||
@@ -546,9 +604,7 @@ input UpdateReleaseInput {
|
||||
targetDate: String
|
||||
}
|
||||
|
||||
"""
|
||||
User managed by the Pireon identity server
|
||||
"""
|
||||
"""User managed by the Pireon identity server"""
|
||||
type User {
|
||||
email: String!
|
||||
id: ID!
|
||||
@@ -560,3 +616,28 @@ type UserRef {
|
||||
id: ID!
|
||||
name: String!
|
||||
}
|
||||
|
||||
type WorkflowStatus {
|
||||
"""No new tickets or transitions into it"""
|
||||
archived: Boolean!
|
||||
category: StatusCategory!
|
||||
|
||||
"""Colour token: slate, violet, green…"""
|
||||
color: String!
|
||||
|
||||
"""Stable id stored on tickets, e.g. IN_REVIEW"""
|
||||
id: String!
|
||||
name: String!
|
||||
|
||||
"""Entering it needs a reason"""
|
||||
requiresReason: Boolean!
|
||||
}
|
||||
|
||||
input WorkflowStatusInput {
|
||||
archived: Boolean! = false
|
||||
category: StatusCategory!
|
||||
color: String!
|
||||
id: String!
|
||||
name: String!
|
||||
requiresReason: Boolean! = false
|
||||
}
|
||||
@@ -19,6 +19,8 @@ describe('appRoutes', () => {
|
||||
'issues/:issueKey',
|
||||
'releases',
|
||||
'dashboard',
|
||||
'analytics',
|
||||
'settings',
|
||||
'activity',
|
||||
'members',
|
||||
]);
|
||||
|
||||
@@ -114,6 +114,31 @@ html {
|
||||
--ts-t-ui: #d6409f;
|
||||
--ts-t-task: #64748b;
|
||||
--ts-t-qa: #d97706;
|
||||
|
||||
// Palette of the per-project settings (statuses and ticket types pick one).
|
||||
--ts-c-slate: #64748b;
|
||||
--ts-c-stone: #8b8b9a;
|
||||
--ts-c-violet: #a347e1;
|
||||
--ts-c-blue: #1879bf;
|
||||
--ts-c-sky: #0284c7;
|
||||
--ts-c-teal: #0f766e;
|
||||
--ts-c-green: #15803d;
|
||||
--ts-c-amber: #b7791f;
|
||||
--ts-c-orange: #ea580c;
|
||||
--ts-c-red: #dc2626;
|
||||
--ts-c-pink: #d6409f;
|
||||
|
||||
// Charts (validated with the dataviz checks against --ts-surface, light and
|
||||
// dark): two categorical slots, an ordinal ramp for flow stages and the
|
||||
// reserved good/critical status colours.
|
||||
--ts-viz-1: #2a78d6;
|
||||
--ts-viz-2: #eb6834;
|
||||
--ts-viz-ramp-1: #86b6ef;
|
||||
--ts-viz-ramp-2: #5598e7;
|
||||
--ts-viz-ramp-3: #256abf;
|
||||
--ts-viz-ramp-4: #104281;
|
||||
--ts-viz-good: #0ca30c;
|
||||
--ts-viz-critical: #d03b3b;
|
||||
}
|
||||
|
||||
html,
|
||||
@@ -316,6 +341,19 @@ h3 {
|
||||
}
|
||||
}
|
||||
|
||||
/* Text for screen readers only (same helper as pireon.dev). */
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.ts-prewrap {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
@@ -501,6 +539,25 @@ html.dark-mode .pi-shell {
|
||||
--ts-t-task: #94a3b8;
|
||||
--ts-t-qa: #fbbf24;
|
||||
|
||||
--ts-c-slate: #94a3b8;
|
||||
--ts-c-stone: #8a91a4;
|
||||
--ts-c-violet: #ae5de5;
|
||||
--ts-c-blue: #339be6;
|
||||
--ts-c-sky: #38bdf8;
|
||||
--ts-c-teal: #2dd4bf;
|
||||
--ts-c-green: #34c98a;
|
||||
--ts-c-amber: #eab308;
|
||||
--ts-c-orange: #fb923c;
|
||||
--ts-c-red: #f07070;
|
||||
--ts-c-pink: #f472b6;
|
||||
|
||||
--ts-viz-1: #3987e5;
|
||||
--ts-viz-2: #d95926;
|
||||
--ts-viz-ramp-1: #184f95;
|
||||
--ts-viz-ramp-2: #2a78d6;
|
||||
--ts-viz-ramp-3: #6da7ec;
|
||||
--ts-viz-ramp-4: #b7d3f6;
|
||||
|
||||
--pi-color-grey-10: #12141a;
|
||||
--pi-color-grey-25: #161922;
|
||||
--pi-color-grey-50: #1a1e28;
|
||||
|
||||
@@ -3,16 +3,19 @@
|
||||
## Qué resuelve
|
||||
|
||||
| Necesidad | Cómo se resuelve |
|
||||
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas |
|
||||
| 🧩 Configuración | Cada proyecto tiene sus estados (con categoría, color y motivo obligatorio), transiciones, tipos de ticket y áreas; lo usado se archiva, no se borra |
|
||||
| 🎨 Cambios de UI | Tipo `UI_CHANGE` con enlace al frame de Figma (validado contra `figma.com`) |
|
||||
| ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo |
|
||||
| 🧪 QA | Estados `READY_FOR_QA → IN_QA → DONE / QA_FAILED`; fallar QA exige un motivo |
|
||||
| 🧪 QA | Por defecto, `READY_FOR_QA → IN_QA → DONE / QA_FAILED`; fallar QA exige un motivo |
|
||||
| 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) |
|
||||
| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona |
|
||||
| 💬 Comentarios | Decisiones y explicaciones dentro del ticket; el autor edita, el admin modera |
|
||||
| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA) |
|
||||
| 🏠 Inicio | «Mi trabajo»: mis tickets en todos los proyectos, por categoría (QA fallidos primero), lo que espera QA, próximas releases e «Ir a…» por clave |
|
||||
| 📊 Seguimiento | Panel con contadores, flujo por estado, el equipo con sus tickets abiertos (enlaza a su tablero), feed de actividad e historial por ticket |
|
||||
| 📈 Estadísticas | Creados vs. resueltos, flujo acumulado, burnup de releases, tiempos de ciclo y entrega, tasa de fallo en QA y resueltos por persona |
|
||||
| ⚡ Tiempo real | Tablero, comentarios, actividad, releases y membresías se actualizan solos (GraphQL subscriptions) |
|
||||
|
||||
Los **usuarios no se gestionan aquí**: vienen del servidor de identidad **Pireon**
|
||||
|
||||
127
docs/03-api.md
127
docs/03-api.md
@@ -11,7 +11,8 @@ classDiagram
|
||||
key: "TS"
|
||||
name
|
||||
members: (userId, role)[]
|
||||
addMember() changeMemberRole() removeMember() updateDetails()
|
||||
settings: estados · transiciones · tipos · áreas
|
||||
addMember() changeMemberRole() removeMember() updateDetails() updateSettings()
|
||||
}
|
||||
class Issue {
|
||||
key: "TS-42"
|
||||
@@ -47,7 +48,39 @@ classDiagram
|
||||
Cualquiera puede salir de un proyecto (salvo el propietario). Los no miembros reciben _not found_, para no revelar que
|
||||
el proyecto existe.
|
||||
|
||||
### Workflow de un ticket
|
||||
### Configuración por proyecto
|
||||
|
||||
Cada proyecto tiene sus **estados**, **transiciones**, **tipos de ticket** y **áreas** (`Project.settings`). Todos
|
||||
empiezan con los valores por defecto de abajo, y el propietario o un admin los cambia con `updateProjectSettings` (o
|
||||
`PUT /api/projects/{project}/settings`); el cambio es un evento más (`ProjectSettingsChangedEvent`).
|
||||
|
||||
| Valor | Campos | Por defecto |
|
||||
| ------ | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| Estado | `id`, `name`, `category`, `color`, `requiresReason`, `archived` (el orden es el del tablero) | `BACKLOG`, `TODO`, `IN_PROGRESS`, `IN_REVIEW`, `READY_FOR_QA`, `IN_QA`, `QA_FAILED`, `DONE`, `CANCELLED` |
|
||||
| Tipo | `id`, `name`, `icon`, `color`, `archived` | `BUG`, `FEATURE`, `UI_CHANGE`, `TASK`, `QA` |
|
||||
| Área | `id`, `name`, `archived` | `FRONTEND`, `BACKEND`, `MOBILE`, `QA`, `DESIGN`, `DEVOPS` |
|
||||
|
||||
La **categoría** de un estado dice qué significa, se llame como se llame: `TODO` (pendiente), `IN_PROGRESS`, `QA`,
|
||||
`DONE` y `CANCELLED`. Releases («todo terminado» = `DONE` o `CANCELLED`), «Mi trabajo», el seguimiento y las
|
||||
estadísticas trabajan con categorías, así que siguen funcionando con estados propios. Los tickets guardan el id del
|
||||
estado y, en el read model, su categoría (`Issue.statusCategory`).
|
||||
|
||||
Reglas:
|
||||
|
||||
- **No se borra lo que ya existe: se archiva.** Tickets, historial y estadísticas siguen apuntando a ese id. Un valor
|
||||
archivado no se ofrece para tickets nuevos ni recibe transiciones; los tickets que ya lo tienen lo conservan.
|
||||
- Los ids son estables (`A-Z`, `0-9`, `_`, hasta 30, empezando por letra); los nombres, de 1 a 40 caracteres, se
|
||||
pueden cambiar cuando se quiera.
|
||||
- Hace falta al menos un estado activo de categoría `TODO` (los tickets nuevos entran en el primero), uno `DONE` y un
|
||||
tipo activo.
|
||||
- Las transiciones solo pueden apuntar a estados que existen y nunca de un estado a sí mismo.
|
||||
- Colores: `slate`, `stone`, `violet`, `blue`, `sky`, `teal`, `green`, `amber`, `orange`, `red`, `pink`. Iconos:
|
||||
`bug`, `feature`, `palette`, `task`, `qa`, `flag`, `star-fill`, `rocket`, `document`, `light-bulb`, `key`, `support`.
|
||||
|
||||
Los proyectos creados antes de que existiera la configuración usan los valores por defecto (sus eventos no la
|
||||
llevan). Al arrancar, la API rellena la categoría de los tickets del read model que no la tienen.
|
||||
|
||||
### Workflow por defecto
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
@@ -76,9 +109,10 @@ stateDiagram-v2
|
||||
CANCELLED --> BACKLOG
|
||||
```
|
||||
|
||||
- `QA_FAILED` y `CANCELLED` exigen un **motivo**, que queda en el historial.
|
||||
- `QA_FAILED` y `CANCELLED` exigen un **motivo** (`requiresReason`), que queda en el historial.
|
||||
- No se puede pasar a `DONE` saltándose QA salvo desde `IN_REVIEW` (cambios que no requieren QA).
|
||||
- `Issue.allowedTransitions` en GraphQL devuelve los estados permitidos desde el actual.
|
||||
- `Issue.allowedTransitions` devuelve los estados activos a los que puede pasar el ticket según el workflow de su
|
||||
proyecto.
|
||||
|
||||
### Otras reglas
|
||||
|
||||
@@ -97,12 +131,34 @@ Todo cambio es un evento guardado en el event store (stream = id del agregado).
|
||||
`aggregateId`, `projectId` y `aggregateVersion` (la posición en el stream).
|
||||
|
||||
| Agregado | Eventos |
|
||||
| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Project | `ProjectCreatedEvent`, `ProjectUpdatedEvent`, `ProjectMemberAddedEvent`, `ProjectMemberRoleChangedEvent`, `ProjectMemberRemovedEvent` |
|
||||
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Project | `ProjectCreatedEvent`, `ProjectUpdatedEvent`, `ProjectSettingsChangedEvent`, `ProjectMemberAddedEvent`, `ProjectMemberRoleChangedEvent`, `ProjectMemberRemovedEvent` |
|
||||
| Issue | `IssueCreatedEvent`, `IssueUpdatedEvent`, `IssueStatusChangedEvent`, `IssueAssignedEvent`, `IssueLinkedEvent`, `IssueUnlinkedEvent`, `IssueReleaseChangedEvent` |
|
||||
| Comment | `CommentAddedEvent`, `CommentEditedEvent`, `CommentDeletedEvent` |
|
||||
| Release | `ReleaseCreatedEvent`, `ReleaseUpdatedEvent`, `ReleaseShippedEvent`, `ReleaseArchivedEvent` |
|
||||
|
||||
`IssueStatusChangedEvent` lleva además `meta` con el nombre y la categoría de los dos estados en el momento del
|
||||
cambio, para que el historial y las estadísticas no cambien si luego se renombra o recategoriza un estado.
|
||||
|
||||
### Estadísticas
|
||||
|
||||
Una proyección más (`issue_timeline` en MongoDB) guarda cada paso de cada ticket: a qué estado pasó, con qué categoría,
|
||||
quién lo tenía asignado, y cuándo entró o salió de una release. Las estadísticas se calculan sobre ella con funciones
|
||||
puras (`application/src/lib/analytics`):
|
||||
|
||||
| Métrica | Definición |
|
||||
| --------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| Creados vs. resueltos | Por semana (lunes, UTC): tickets creados y tickets que entraron en una categoría `DONE` |
|
||||
| Flujo acumulado | Tickets en cada estado al final de cada día |
|
||||
| Tiempo de ciclo | Del primer paso a `IN_PROGRESS` o `QA` hasta `DONE`; media y percentil 85 |
|
||||
| Tiempo de entrega | De la creación hasta `DONE`; media y percentil 85 |
|
||||
| Throughput | Tickets resueltos por semana y por la persona que los tenía asignados |
|
||||
| QA | Salidas de un estado `QA`: a `DONE` cuenta como superado; a cualquier otro sitio salvo `CANCELLED`, como fallido |
|
||||
| Burnup de una release | Por día, desde que se creó hasta que se publica (o hoy, máx. 180 días): tickets en ella (sin cancelados) y hechos |
|
||||
|
||||
Los tickets anteriores a esta proyección solo tienen su creación y su estado actual; para tener su historia completa,
|
||||
reconstruye el read model (ver [Despliegue](07-despliegue.md)).
|
||||
|
||||
## 3.2 GraphQL
|
||||
|
||||
Todas las operaciones requieren `Authorization: Bearer <token de Pireon>` (en subscriptions, en el `connection_init`).
|
||||
@@ -110,15 +166,17 @@ Todas las operaciones requieren `Authorization: Bearer <token de Pireon>` (en su
|
||||
### Queries
|
||||
|
||||
| Query | Devuelve |
|
||||
| ----------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `me` | El usuario autenticado, con sus roles en Pireon |
|
||||
| `users(search, limit)` | Usuarios de Pireon (para asignar o añadir miembros) |
|
||||
| `user(id)` | Un usuario |
|
||||
| `myProjects` | Proyectos de los que soy miembro |
|
||||
| `project(idOrKey)` | Un proyecto. Campos calculados: `board(releaseId, assigneeId, area, perColumn)`, `stats`, `releases` |
|
||||
| `myIssues(assignedToMe, statuses, categories, limit, offset)` | «Mi trabajo»: tickets de todos mis proyectos, asignados a mí (por defecto) o de cualquiera (p. ej. `categories: [QA]`) |
|
||||
| `upcomingReleases(limit)` | Releases planificadas de mis proyectos, por fecha objetivo |
|
||||
| `project(idOrKey)` | Un proyecto con su `settings`. Campos calculados: `board(releaseId, assigneeId, area, perColumn)`, `stats`, `analytics(days)`, `releases` |
|
||||
| `issues(filter, limit, offset)` | Búsqueda de tickets (estado, tipo, área, prioridad, persona, release, etiquetas, texto) |
|
||||
| `issue(idOrKey)` | Un ticket. Campos calculados: `allowedTransitions`, `linkedIssues`, `comments`, `history` |
|
||||
| `release(id)` | Una release. Campos calculados: `progress`, `issues` |
|
||||
| `release(id)` | Una release. Campos calculados: `progress`, `burnup`, `issues` |
|
||||
| `activity(projectId, issueId?, limit, before?)` | Feed de actividad, del más reciente al más antiguo |
|
||||
|
||||
### Mutations
|
||||
@@ -126,8 +184,8 @@ Todas las operaciones requieren `Authorization: Bearer <token de Pireon>` (en su
|
||||
Todas devuelven `CommandResult { id, key, version }`.
|
||||
|
||||
| Ámbito | Mutations |
|
||||
| ----------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| Proyectos | `createProject`, `updateProject`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
|
||||
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease` |
|
||||
| Comentarios | `addComment`, `editComment`, `deleteComment` |
|
||||
| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `archiveRelease` |
|
||||
@@ -140,14 +198,23 @@ Todas devuelven `CommandResult { id, key, version }`.
|
||||
| `commentChanged(issueId)` | Se añade, edita o borra un comentario (`comment` es `null` al borrar) |
|
||||
| `activityAdded(projectId)` | Hay una nueva entrada en el feed |
|
||||
| `releaseChanged(projectId)` | Cambia una release |
|
||||
| `projectChanged(projectId)` | Cambian los datos o los miembros del proyecto |
|
||||
| `projectChanged(projectId)` | Cambian los datos, la configuración o los miembros del proyecto |
|
||||
| `myProjectsChanged` | Me añaden a un proyecto o me quitan de uno |
|
||||
|
||||
Solo los miembros pueden suscribirse a un proyecto; las conexiones sin token válido se cierran con el código 4403.
|
||||
|
||||
### Ejemplos
|
||||
|
||||
Estados, tipos y áreas son texto (ids de la configuración del proyecto), no enums: van entre comillas.
|
||||
|
||||
```graphql
|
||||
mutation ($settings: ProjectSettingsInput!) {
|
||||
# La configuración entera (la que devuelve project.settings, cambiada).
|
||||
updateProjectSettings(projectId: "…", settings: $settings) {
|
||||
version
|
||||
}
|
||||
}
|
||||
|
||||
mutation {
|
||||
createProject(input: { key: "SHOP", name: "Checkout revamp" }) {
|
||||
id
|
||||
@@ -157,7 +224,7 @@ mutation {
|
||||
}
|
||||
|
||||
mutation {
|
||||
createIssue(input: { projectId: "…", type: BUG, title: "El botón de pagar no responde en Safari", area: FRONTEND, priority: CRITICAL, assigneeId: "102", labels: ["safari"] }) {
|
||||
createIssue(input: { projectId: "…", type: "BUG", title: "El botón de pagar no responde en Safari", area: "FRONTEND", priority: CRITICAL, assigneeId: "102", labels: ["safari"] }) {
|
||||
id
|
||||
key
|
||||
version
|
||||
@@ -165,7 +232,7 @@ mutation {
|
||||
}
|
||||
|
||||
mutation {
|
||||
changeIssueStatus(input: { issueId: "…", status: QA_FAILED, reason: "Sigue fallando en Safari 17" }) {
|
||||
changeIssueStatus(input: { issueId: "…", status: "QA_FAILED", reason: "Sigue fallando en Safari 17" }) {
|
||||
version
|
||||
}
|
||||
}
|
||||
@@ -210,6 +277,27 @@ query {
|
||||
percent
|
||||
}
|
||||
}
|
||||
analytics(days: 90) {
|
||||
createdVsResolved {
|
||||
week
|
||||
created
|
||||
resolved
|
||||
}
|
||||
cycleTime {
|
||||
averageDays
|
||||
p85Days
|
||||
}
|
||||
qa {
|
||||
failureRate
|
||||
}
|
||||
}
|
||||
}
|
||||
release(id: "…") {
|
||||
burnup {
|
||||
dates
|
||||
scope
|
||||
done
|
||||
}
|
||||
}
|
||||
issue(idOrKey: "SHOP-1") {
|
||||
status
|
||||
@@ -296,15 +384,16 @@ Lo que cada usuario puede ver o cambiar depende de su rol en cada proyecto, como
|
||||
Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
|
||||
|
||||
| Recurso | Rutas |
|
||||
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` |
|
||||
| Proyectos | `GET/POST /api/projects`, `GET/PATCH /api/projects/{project}`, `…/board`, `…/stats`, `…/activity`, `…/members` (POST, PATCH, DELETE) |
|
||||
| Tickets | `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history` |
|
||||
| Proyectos | `GET/POST /api/projects`, `GET/PATCH /api/projects/{project}`, `PUT …/settings`, `…/board`, `…/stats`, `…/analytics?days=`, `…/activity`, `…/members` (POST, PATCH, DELETE) |
|
||||
| Tickets | `GET /api/me/issues` (mi trabajo), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history` |
|
||||
| Comentarios | `PATCH/DELETE /api/comments/{commentId}` |
|
||||
| Releases | `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `…/progress`, `…/issues`, `POST …/publish`, `POST …/archive` |
|
||||
| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` |
|
||||
| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` |
|
||||
|
||||
Las escrituras responden `{ id, key?, version }` en cuanto el evento se guarda; las lecturas salen del read model, que
|
||||
Los filtros de tickets aceptan `status` (ids del proyecto) y `category` (`?category=QA`), repetidos o separados por
|
||||
comas. Las escrituras responden `{ id, key?, version }` en cuanto el evento se guarda; las lecturas salen del read model, que
|
||||
se pone al día en milisegundos (un `GET` justo después puede devolver la versión anterior, o 404 tras crear). Para
|
||||
tiempo real, las subscriptions de GraphQL.
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ export const boardPageEvents = eventGroup({
|
||||
source: 'Board Page',
|
||||
events: {
|
||||
opened: type<BoardFilter>(),
|
||||
issueMoved: type<{ issueId: string; to: IssueStatus; reason?: string | null }>(),
|
||||
issueMoved: type<{ issueId: string; to: string; reason?: string | null }>(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -84,7 +84,7 @@ export class BoardFacade {
|
||||
else this.dispatch.opened(filter);
|
||||
}
|
||||
|
||||
moveIssue(issueId: string, to: IssueStatus, reason?: string | null): void {
|
||||
moveIssue(issueId: string, to: string, reason?: string | null): void {
|
||||
this.dispatch.issueMoved({ issueId, to, reason });
|
||||
}
|
||||
}
|
||||
@@ -110,10 +110,11 @@ Qué gana la página:
|
||||
- **Tests sin plantilla**: la facade se prueba con gateways falsos (`facades.spec.ts`).
|
||||
|
||||
| Facade | Store | Ámbito | Responsabilidad |
|
||||
| ---------------- | --------------- | ----------------------- | --------------------------------------------------------------------------------------------------------- |
|
||||
| ---------------- | --------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `AuthFacade` | `AuthStore` | raíz | Sesión, usuario, login (PKCE o email y contraseña de Pireon), renovación del token, restaurar al arrancar |
|
||||
| `ProjectsFacade` | `ProjectsStore` | raíz | Mis proyectos; se actualiza cuando me añaden o quitan (`myProjectsChanged`) |
|
||||
| `ProjectFacade` | `ProjectStore` | ruta `/p/:key` | Proyecto, miembros, stats, releases, actividad en vivo y búsqueda de usuarios |
|
||||
| `HomeFacade` | `HomeStore` | raíz | «Mi trabajo» en la portada: mis tickets abiertos por categoría (QA fallidos primero), esperando QA y próximas releases; se recarga al volver a la pestaña |
|
||||
| `ProjectFacade` | `ProjectStore` | ruta `/p/:key` | Proyecto, miembros, stats, releases, actividad en vivo, búsqueda de usuarios, configuración (`workflow`, `saveSettings`) y estadísticas (`loadAnalytics`, `loadBurnup`) |
|
||||
| `BoardFacade` | `BoardStore` | ruta `board` | Tablero kanban, filtros, movimiento optimista, nuevo ticket, cambios en vivo |
|
||||
| `IssueFacade` | `IssueStore` | ruta `issues/:issueKey` | Ticket, comentarios en vivo, historial y todas sus acciones |
|
||||
|
||||
@@ -145,7 +146,9 @@ de identidad (`SessionTokenSource`) le pide el token para el cliente GraphQL.
|
||||
npx nx run web:codegen # regenera generated.ts (TypedDocumentNode)
|
||||
```
|
||||
|
||||
- Los adaptadores convierten los tipos generados en modelos de dominio en un único sitio (`adapters/mappers.ts`).
|
||||
- Los adaptadores convierten los tipos generados en modelos de dominio en un único sitio (`adapters/mappers.ts`). Lo
|
||||
que se lee y luego se reenvía como input (la configuración del proyecto) se copia campo a campo: Apollo añade
|
||||
`__typename` a cada objeto y los input types de GraphQL lo rechazan.
|
||||
|
||||
## 4.4 `@pi/ui`
|
||||
|
||||
@@ -193,17 +196,46 @@ versiones) y un color por estado (`--ts-s-*`) y por tipo de ticket (`--ts-t-*`),
|
||||
gráficas e historial. Salen de `tone` en `ui/copy.ts` y las plantillas los aplican con `[style.--tone]`; utilidades
|
||||
globales como `.ts-pill` o `.ts-key` leen esa variable. Las animaciones se desactivan con `prefers-reduced-motion`.
|
||||
|
||||
### Configuración del proyecto en la UI
|
||||
|
||||
Estados, tipos y áreas son de cada proyecto, así que la UI nunca los tiene fijos: `ProjectFacade.workflow()` envuelve
|
||||
`project.settings` (`Workflow` en el dominio web) y da nombre, color, icono, categoría, transiciones permitidas y si un
|
||||
estado pide motivo. `statusLook`/`typeLook` de `ui/copy.ts` lo convierten en etiqueta y tono (`--ts-c-<color>`, con su
|
||||
versión oscura). Un id que ya no está configurado sigue mostrándose (`IN_REVIEW` → «In review»). «Mi trabajo» mezcla
|
||||
proyectos: cada fila usa el workflow de su proyecto y los grupos son por categoría.
|
||||
|
||||
En el tablero, un ticket solo se puede soltar en las columnas a las que su workflow le deja pasar, y los tipos
|
||||
archivados no se ofrecen al crear. Los tipos con icono `palette` piden un frame de Figma.
|
||||
|
||||
### Gráficas
|
||||
|
||||
Las de Estadísticas son SVG propios (`ui/series-chart.ts`), no `pi-line-chart`: hacen falta áreas apiladas y una
|
||||
paleta con versión oscura, que los gráficos de `@pi/ui` (Chart.js con colores fijos) no traen. Siguen las reglas de
|
||||
visualización de la plataforma:
|
||||
|
||||
- Colores `--ts-viz-*` en `styles.scss`, validados contra la superficie clara y la oscura (separación para daltonismo,
|
||||
contraste): dos series categóricas (azul y naranja), una rampa ordinal de un solo tono para las etapas del flujo y
|
||||
bueno/crítico reservados para QA superado/fallido, siempre con icono y texto.
|
||||
- Líneas de 2 px, rejilla de 1 px continua, separación de 2 px entre bandas apiladas, una sola escala por gráfica.
|
||||
- Cruceta que se ajusta al día o semana más cercano y un único tooltip con todas las series; con teclado, ← → Inicio
|
||||
Fin sobre la gráfica.
|
||||
- Leyenda con dos o más series, etiqueta de valor al final de cada línea solo si no se solapan, y «Ver los datos» con
|
||||
la tabla, para que nada dependa del color ni del ratón.
|
||||
- Mientras se recalcula un periodo, se queda la gráfica anterior atenuada.
|
||||
|
||||
## 4.6 Rutas
|
||||
|
||||
| Ruta | Página |
|
||||
| -------------------------- | ---------------------------------------------------------------------------------------- |
|
||||
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `/login` | Inicio de sesión (solo sin sesión) |
|
||||
| `/auth/callback` | Retorno de Pireon (PKCE) |
|
||||
| `/` | Mis proyectos |
|
||||
| `/` | Inicio: «Ir a…» (`WEB-12`), mi trabajo (`ts-my-work`) y mis proyectos |
|
||||
| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros) |
|
||||
| `/p/:key/issues/:issueKey` | Ticket |
|
||||
| `/p/:key/releases` | Releases |
|
||||
| `/p/:key/dashboard` | Seguimiento: cifras, flujo por estado, equipo (cada persona enlaza a su tablero) y tipos |
|
||||
| `/p/:key/analytics` | Estadísticas: creados vs. resueltos, flujo acumulado, burnup de una release, tiempos de ciclo y entrega, QA y resueltos por persona (30 días a 1 año) |
|
||||
| `/p/:key/settings` | Configuración (propietario y admins; pestaña oculta para el resto): estados (orden, categoría, color, motivo), transiciones, tipos y áreas; archivar en lugar de borrar |
|
||||
| `/p/:key/activity` | Actividad en vivo |
|
||||
| `/p/:key/members` | Miembros |
|
||||
|
||||
|
||||
@@ -120,3 +120,8 @@ MongoDB solo contiene proyecciones, así que se puede regenerar desde el event s
|
||||
3. Marca los eventos como no publicados: `UPDATE events SET published_at = NULL;`
|
||||
4. Arranca la API: el relay vuelve a enviar todos los eventos, en orden, y las proyecciones reconstruyen las vistas.
|
||||
Las notificaciones en tiempo real se reenvían también, sin efecto si no hay clientes conectados.
|
||||
|
||||
Hazlo también una vez tras desplegar la configuración por proyecto y las estadísticas si quieres la historia completa
|
||||
de los tickets anteriores: sin reconstruir, la línea temporal (`issue_timeline`) solo empieza con los cambios nuevos y
|
||||
las estadísticas usan, para lo anterior, la creación y el estado actual de cada ticket. La categoría de los estados no
|
||||
lo necesita: la API la rellena al arrancar en los tickets que no la tienen.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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,12 +19,17 @@ 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({
|
||||
const issue = this.issueFactory.create(
|
||||
{
|
||||
...command.details,
|
||||
projectId: project.id,
|
||||
projectKey: project.key,
|
||||
@@ -33,7 +38,9 @@ export class CreateIssueCommandHandler
|
||||
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) => ({
|
||||
return Promise.all(
|
||||
columns.map(async (column) => ({
|
||||
...column,
|
||||
issues: column.issues.map(toIssueDto),
|
||||
}));
|
||||
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. */
|
||||
export function toIssueDto(issue: IssueReadModel) {
|
||||
/** The ticket as the REST API returns it: read model + allowed transitions. */
|
||||
async issueDto(issue: IssueReadModel) {
|
||||
return {
|
||||
...issue,
|
||||
allowedTransitions: IssueWorkflow.allowedFrom(issue.status),
|
||||
allowedTransitions: await this.workflows.allowedTransitions(issue),
|
||||
};
|
||||
}
|
||||
|
||||
issueDtos(issues: IssueReadModel[]) {
|
||||
return Promise.all(issues.map((issue) => this.issueDto(issue)));
|
||||
}
|
||||
}
|
||||
|
||||
/** 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';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user