feat: per-project workflow settings, statistics and "my work" home

Per-project settings (owners/admins, «Configuración» tab):
- Statuses with category (TODO, IN_PROGRESS, QA, DONE, CANCELLED), colour and
  "requires reason"; configurable transitions; ticket types (icon, colour) and
  areas. Defaults are the previous fixed values; projects created before keep
  them without migration.
- Values are archived, never deleted: tickets, history and statistics keep
  referring to them. Validated in the domain (ProjectSettingsChangedEvent).
- Releases, board, tracking and "my work" use categories, so custom statuses
  keep working. Status changes carry name/category metadata for history.
- GraphQL: status/type/area are strings (breaking for clients sending enum
  literals); Project.settings, updateProjectSettings, statusCategory,
  myIssues(categories). REST: PUT /projects/{project}/settings, category filters.
- Read model: statusCategory on issues (backfilled at startup).

Statistics («Estadísticas» tab):
- New issue_timeline projection; pure functions compute created vs resolved,
  cumulative flow, cycle/lead time (avg + p85), throughput per person, QA
  pass/fail and release burnup. GraphQL Project.analytics(days),
  Release.burnup; REST /projects/{project}/analytics, /releases/{id}/burnup.
- Web: SVG charts with validated light/dark palette, crosshair tooltip,
  keyboard navigation, legend and data table.

Home "Mi trabajo": my open tickets across projects grouped by category (QA
failures first), tickets waiting for QA, upcoming releases and "Ir a…".

Tests: domain/application specs, e2e scenario for settings and analytics,
facade tests. Docs, READMEs, schema.gql/openapi.json and Word/PDF updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 20:53:45 +02:00
parent 240ea9a539
commit 0e301ad545
143 changed files with 10291 additions and 1052 deletions

View File

@@ -433,6 +433,9 @@ describe('task-stream (e2e)', () => {
'/api/projects/{project}/issues', '/api/projects/{project}/issues',
'/api/issues/{issue}/status', '/api/issues/{issue}/status',
'/api/releases/{releaseId}/publish', '/api/releases/{releaseId}/publish',
'/api/projects/{project}/settings',
'/api/projects/{project}/analytics',
'/api/releases/{releaseId}/burnup',
'/api/users/me', '/api/users/me',
]), ]),
); );
@@ -552,9 +555,323 @@ describe('task-stream (e2e)', () => {
); );
expect(comments.body['items'][0]).toMatchObject({ author: { id: '103' } }); 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 --- // --- Same authorisation as GraphQL ---
expect((await rest('token-eve', 'GET', `/issues/${beKey}`)).status).toBe( expect((await rest('token-eve', 'GET', `/issues/${beKey}`)).status).toBe(
404, 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

View File

@@ -13,20 +13,32 @@ type Activity {
type: String! type: String!
} }
type AreaOption {
archived: Boolean!
id: String!
name: String!
}
input AreaOptionInput {
archived: Boolean! = false
id: String!
name: String!
}
type BoardColumn { type BoardColumn {
issues: [Issue!]! issues: [Issue!]!
status: IssueStatus! status: String!
total: Int! total: Int!
} }
input ChangeIssueStatusInput { input ChangeIssueStatusInput {
issueId: ID! issueId: ID!
""" """Required by statuses with requiresReason"""
Required for QA_FAILED and CANCELLED
"""
reason: String reason: String
status: IssueStatus!
"""A status id"""
status: String!
} }
""" """
@@ -52,9 +64,7 @@ type CommentChange {
action: String! action: String!
actorId: ID! actorId: ID!
""" """Null when deleted"""
Null when deleted
"""
comment: Comment comment: Comment
commentId: ID! commentId: ID!
issueId: ID! issueId: ID!
@@ -69,9 +79,7 @@ type CommentPage {
type CountByAssignee { type CountByAssignee {
count: Int! count: Int!
""" """Null = unassigned"""
Null = unassigned
"""
user: UserRef user: UserRef
} }
@@ -82,18 +90,19 @@ type CountByKey {
type CountByStatus { type CountByStatus {
count: Int! count: Int!
key: IssueStatus!
"""Status id"""
key: String!
} }
input CreateIssueInput { input CreateIssueInput {
area: IssueArea """An area id"""
area: String
assigneeId: ID assigneeId: ID
description: String description: String
dueDate: String dueDate: String
""" """Figma frame for UI changes"""
Figma frame for UI changes
"""
figmaUrl: String figmaUrl: String
labels: [String!] labels: [String!]
priority: IssuePriority priority: IssuePriority
@@ -101,15 +110,15 @@ input CreateIssueInput {
releaseId: ID releaseId: ID
storyPoints: Int storyPoints: Int
title: String! title: String!
type: IssueType!
"""A ticket type id"""
type: String!
} }
input CreateProjectInput { input CreateProjectInput {
description: String 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! key: String!
name: String! name: String!
} }
@@ -117,20 +126,44 @@ input CreateProjectInput {
input CreateReleaseInput { input CreateReleaseInput {
description: String description: String
""" """e.g. v1.4.0"""
e.g. v1.4.0
"""
name: String! name: String!
projectId: ID! projectId: ID!
targetDate: String 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 { type Issue {
""" """Statuses this ticket can move to next (project's workflow)"""
Statuses this ticket can move to next allowedTransitions: [String!]!
"""
allowedTransitions: [IssueStatus!]! """An area id"""
area: IssueArea area: String
assignee: UserRef assignee: UserRef
commentCount: Int! commentCount: Int!
comments(limit: Int! = 50, offset: Int! = 0): CommentPage! comments(limit: Int! = 50, offset: Int! = 0): CommentPage!
@@ -139,21 +172,15 @@ type Issue {
dueDate: String dueDate: String
figmaUrl: String figmaUrl: String
""" """History of this ticket"""
History of this ticket
"""
history(limit: Int! = 50): [Activity!]! history(limit: Int! = 50): [Activity!]!
id: ID! id: ID!
""" """Human key, e.g. TS-42"""
Human key, e.g. TS-42
"""
key: String! key: String!
labels: [String!]! labels: [String!]!
""" """Linked tickets I can see"""
Linked tickets I can see
"""
linkedIssues: [Issue!]! linkedIssues: [Issue!]!
links: [IssueLink!]! links: [IssueLink!]!
number: Int! number: Int!
@@ -162,26 +189,19 @@ type Issue {
projectKey: String! projectKey: String!
release: ReleaseRef release: ReleaseRef
reporter: UserRef! reporter: UserRef!
status: IssueStatus!
"""A status id of the project's settings"""
status: String!
statusCategory: StatusCategory!
storyPoints: Int storyPoints: Int
title: String! title: String!
type: IssueType!
"""A ticket type id of the project's settings"""
type: String!
updatedAt: String! updatedAt: String!
version: Int! version: Int!
} }
"""
Team/layer: links FE ↔ BE ↔ QA work
"""
enum IssueArea {
BACKEND
DESIGN
DEVOPS
FRONTEND
MOBILE
QA
}
type IssueChange { type IssueChange {
action: String! action: String!
actorId: ID! actorId: ID!
@@ -190,26 +210,23 @@ type IssueChange {
} }
input IssueFilterInput { input IssueFilterInput {
areas: [IssueArea!] areas: [String!]
assigneeId: ID assigneeId: ID
categories: [StatusCategory!]
labels: [String!] labels: [String!]
priorities: [IssuePriority!] priorities: [IssuePriority!]
projectId: ID! projectId: ID!
releaseId: ID releaseId: ID
""" """Matches key, title or description"""
Matches key, title or description
"""
search: String search: String
statuses: [IssueStatus!] statuses: [String!]
types: [IssueType!] types: [String!]
unassigned: Boolean unassigned: Boolean
} }
type IssueLink { type IssueLink {
""" """OUTWARD: this issue → other. INWARD: other → this issue."""
OUTWARD: this issue → other. INWARD: other → this issue.
"""
direction: LinkDirection! direction: LinkDirection!
issueId: ID! issueId: ID!
issueKey: String! issueKey: String!
@@ -237,24 +254,20 @@ enum IssuePriority {
MEDIUM MEDIUM
} }
enum IssueStatus { type IssueTypeOption {
BACKLOG archived: Boolean!
CANCELLED color: String!
DONE icon: String!
IN_PROGRESS id: String!
IN_QA name: String!
IN_REVIEW
QA_FAILED
READY_FOR_QA
TODO
} }
enum IssueType { input IssueTypeOptionInput {
BUG archived: Boolean! = false
FEATURE color: String!
QA icon: String!
TASK id: String!
UI_CHANGE name: String!
} }
enum LinkDirection { enum LinkDirection {
@@ -269,53 +282,39 @@ input LinkIssuesInput {
} }
type Me { type Me {
""" """Roles inside the task-stream OAuth client"""
Roles inside the task-stream OAuth client
"""
appRoles: [String!]! appRoles: [String!]!
email: String! email: String!
id: ID! id: ID!
isActive: Boolean! isActive: Boolean!
name: String! name: String!
""" """Global roles in Pireon"""
Global roles in Pireon
"""
roles: [String!]! roles: [String!]!
} }
type Mutation { type Mutation {
addComment(body: String!, issueId: ID!): CommandResult! addComment(body: String!, issueId: ID!): CommandResult!
""" """Add a Pireon user to the project"""
Add a Pireon user to the project
"""
addProjectMember(input: ProjectMemberInput!): CommandResult! addProjectMember(input: ProjectMemberInput!): CommandResult!
archiveRelease(releaseId: ID!): 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! 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! changeIssueStatus(input: ChangeIssueStatusInput!): CommandResult!
changeProjectMemberRole(input: ProjectMemberInput!): CommandResult! changeProjectMemberRole(input: ProjectMemberInput!): CommandResult!
createIssue(input: CreateIssueInput!): CommandResult! createIssue(input: CreateIssueInput!): CommandResult!
createProject(input: CreateProjectInput!): CommandResult! createProject(input: CreateProjectInput!): CommandResult!
createRelease(input: CreateReleaseInput!): CommandResult! createRelease(input: CreateReleaseInput!): CommandResult!
""" """Author or project admin"""
Author or project admin
"""
deleteComment(commentId: ID!): CommandResult! deleteComment(commentId: ID!): CommandResult!
editComment(body: String!, 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! linkIssues(input: LinkIssuesInput!): CommandResult!
""" """
@@ -324,26 +323,27 @@ type Mutation {
publishRelease(force: Boolean! = false, releaseId: ID!): CommandResult! publishRelease(force: Boolean! = false, releaseId: ID!): CommandResult!
removeProjectMember(projectId: ID!, userId: 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! setIssueRelease(issueId: ID!, releaseId: ID): CommandResult!
unlinkIssues(input: LinkIssuesInput!): CommandResult! unlinkIssues(input: LinkIssuesInput!): CommandResult!
updateIssue(input: UpdateIssueInput!): CommandResult! updateIssue(input: UpdateIssueInput!): CommandResult!
updateProject(input: UpdateProjectInput!): 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! updateRelease(input: UpdateReleaseInput!): CommandResult!
} }
type Project { 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( analytics(days: Int! = 90): ProjectAnalytics!
area: IssueArea
assigneeId: ID """Kanban board, one column per status"""
perColumn: Int! = 50 board(area: String, assigneeId: ID, perColumn: Int! = 50, releaseId: ID): [BoardColumn!]!
releaseId: ID
): [BoardColumn!]!
createdAt: String! createdAt: String!
description: String description: String
id: ID! id: ID!
@@ -352,15 +352,33 @@ type Project {
name: String! name: String!
owner: UserRef! owner: UserRef!
releases: [Release!]! releases: [Release!]!
settings: ProjectSettings!
""" """Project tracking dashboard"""
Project tracking dashboard
"""
stats: ProjectStats! stats: ProjectStats!
updatedAt: String! updatedAt: String!
version: Int! 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 { type ProjectChange {
action: String! action: String!
actorId: ID! actorId: ID!
@@ -377,9 +395,7 @@ input ProjectMemberInput {
projectId: ID! projectId: ID!
role: ProjectRole! = MEMBER role: ProjectRole! = MEMBER
""" """Pireon user id"""
Pireon user id
"""
userId: ID! userId: ID!
} }
@@ -390,12 +406,28 @@ enum ProjectRole {
VIEWER 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 { type ProjectStats {
byArea: [CountByKey!]! byArea: [CountByKey!]!
""" """Open tickets per person"""
Open tickets per person
"""
byAssignee: [CountByAssignee!]! byAssignee: [CountByAssignee!]!
byPriority: [CountByKey!]! byPriority: [CountByKey!]!
byStatus: [CountByStatus!]! byStatus: [CountByStatus!]!
@@ -404,63 +436,59 @@ type ProjectStats {
total: Int! total: Int!
} }
type QaStats {
failed: Int!
failureRate: Float
passed: Int!
}
type Query { type Query {
""" """Project activity feed (newest first)"""
Project activity feed (newest first)
"""
activity( activity(
""" """ISO date cursor"""
ISO date cursor
"""
before: String before: String
issueId: ID issueId: ID
limit: Int! = 50 limit: Int! = 50
projectId: ID! projectId: ID!
): [Activity!]! ): [Activity!]!
issue( issue(
""" """Issue id or key (e.g. "TS-42")"""
Issue id or key (e.g. "TS-42")
"""
idOrKey: String! idOrKey: String!
): Issue! ): Issue!
issues( issues(filter: IssueFilterInput!, limit: Int! = 50, offset: Int! = 0): IssuePage!
filter: IssueFilterInput!
limit: Int! = 50
offset: Int! = 0
): IssuePage!
""" """The authenticated user"""
The authenticated user
"""
me: Me! 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!]! myProjects: [Project!]!
project( project(
""" """Project id or key (e.g. "TS")"""
Project id or key (e.g. "TS")
"""
idOrKey: String! idOrKey: String!
): Project! ): Project!
release(id: ID!): Release! release(id: ID!): Release!
"""Planned releases of all my projects, soonest target date first"""
upcomingReleases(limit: Int! = 10): [Release!]!
user(id: String!): User 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!]! users(limit: Int! = 20, search: String! = ""): [User!]!
} }
type Release { type Release {
"""Scope vs finished tickets, day by day"""
burnup: ReleaseBurnup!
createdAt: String! createdAt: String!
description: String description: String
id: ID! id: ID!
""" """Tickets included in this release"""
Tickets included in this release
"""
issues(limit: Int! = 100, offset: Int! = 0): IssuePage! issues(limit: Int! = 100, offset: Int! = 0): IssuePage!
name: String! name: String!
progress: ReleaseProgress! progress: ReleaseProgress!
@@ -471,6 +499,16 @@ type Release {
version: Int! version: Int!
} }
type ReleaseBurnup {
dates: [String!]!
done: [Int!]!
releaseId: ID!
"""Tickets in the release (not cancelled)"""
scope: [Int!]!
targetDate: String
}
type ReleaseChange { type ReleaseChange {
action: String! action: String!
actorId: ID! actorId: ID!
@@ -496,41 +534,61 @@ enum ReleaseStatus {
RELEASED 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 { type Subscription {
""" """Live activity feed"""
Live activity feed
"""
activityAdded(projectId: ID!): Activity! activityAdded(projectId: ID!): Activity!
commentChanged(issueId: ID!): CommentChange! 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! issueChanged(projectId: ID!): IssueChange!
""" """Projects I am added to / removed from"""
Projects I am added to / removed from
"""
myProjectsChanged: ProjectChange! myProjectsChanged: ProjectChange!
projectChanged(projectId: ID!): ProjectChange! projectChanged(projectId: ID!): ProjectChange!
releaseChanged(projectId: ID!): ReleaseChange! releaseChanged(projectId: ID!): ReleaseChange!
} }
type Throughput {
count: Int!
user: UserRef
week: String!
}
input UpdateIssueInput { input UpdateIssueInput {
area: IssueArea """An area id"""
area: String
description: String description: String
dueDate: String dueDate: String
""" """Figma frame for UI changes"""
Figma frame for UI changes
"""
figmaUrl: String figmaUrl: String
issueId: ID! issueId: ID!
labels: [String!] labels: [String!]
priority: IssuePriority priority: IssuePriority
storyPoints: Int storyPoints: Int
title: String title: String
type: IssueType type: String
} }
input UpdateProjectInput { input UpdateProjectInput {
@@ -546,9 +604,7 @@ input UpdateReleaseInput {
targetDate: String targetDate: String
} }
""" """User managed by the Pireon identity server"""
User managed by the Pireon identity server
"""
type User { type User {
email: String! email: String!
id: ID! id: ID!
@@ -560,3 +616,28 @@ type UserRef {
id: ID! id: ID!
name: String! 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
}

View File

@@ -19,6 +19,8 @@ describe('appRoutes', () => {
'issues/:issueKey', 'issues/:issueKey',
'releases', 'releases',
'dashboard', 'dashboard',
'analytics',
'settings',
'activity', 'activity',
'members', 'members',
]); ]);

View File

@@ -114,6 +114,31 @@ html {
--ts-t-ui: #d6409f; --ts-t-ui: #d6409f;
--ts-t-task: #64748b; --ts-t-task: #64748b;
--ts-t-qa: #d97706; --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, 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 { .ts-prewrap {
white-space: pre-wrap; white-space: pre-wrap;
} }
@@ -501,6 +539,25 @@ html.dark-mode .pi-shell {
--ts-t-task: #94a3b8; --ts-t-task: #94a3b8;
--ts-t-qa: #fbbf24; --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-10: #12141a;
--pi-color-grey-25: #161922; --pi-color-grey-25: #161922;
--pi-color-grey-50: #1a1e28; --pi-color-grey-50: #1a1e28;

View File

@@ -3,16 +3,19 @@
## Qué resuelve ## Qué resuelve
| Necesidad | Cómo se resuelve | | Necesidad | Cómo se resuelve |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas | | 🐛 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`) | | 🎨 Cambios de UI | Tipo `UI_CHANGE` con enlace al frame de Figma (validado contra `figma.com`) |
| ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo | | ⚙️ 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) | | 🚀 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 | | 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona |
| 💬 Comentarios | Decisiones y explicaciones dentro del ticket; el autor edita, el admin modera | | 💬 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) | | 🔗 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 | | 📊 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) | | ⚡ 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** Los **usuarios no se gestionan aquí**: vienen del servidor de identidad **Pireon**

View File

@@ -11,7 +11,8 @@ classDiagram
key: "TS" key: "TS"
name name
members: (userId, role)[] members: (userId, role)[]
addMember() changeMemberRole() removeMember() updateDetails() settings: estados · transiciones · tipos · áreas
addMember() changeMemberRole() removeMember() updateDetails() updateSettings()
} }
class Issue { class Issue {
key: "TS-42" 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 Cualquiera puede salir de un proyecto (salvo el propietario). Los no miembros reciben _not found_, para no revelar que
el proyecto existe. 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 ```mermaid
stateDiagram-v2 stateDiagram-v2
@@ -76,9 +109,10 @@ stateDiagram-v2
CANCELLED --> BACKLOG 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). - 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 ### 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). `aggregateId`, `projectId` y `aggregateVersion` (la posición en el stream).
| Agregado | Eventos | | Agregado | Eventos |
| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Project | `ProjectCreatedEvent`, `ProjectUpdatedEvent`, `ProjectMemberAddedEvent`, `ProjectMemberRoleChangedEvent`, `ProjectMemberRemovedEvent` | | Project | `ProjectCreatedEvent`, `ProjectUpdatedEvent`, `ProjectSettingsChangedEvent`, `ProjectMemberAddedEvent`, `ProjectMemberRoleChangedEvent`, `ProjectMemberRemovedEvent` |
| Issue | `IssueCreatedEvent`, `IssueUpdatedEvent`, `IssueStatusChangedEvent`, `IssueAssignedEvent`, `IssueLinkedEvent`, `IssueUnlinkedEvent`, `IssueReleaseChangedEvent` | | Issue | `IssueCreatedEvent`, `IssueUpdatedEvent`, `IssueStatusChangedEvent`, `IssueAssignedEvent`, `IssueLinkedEvent`, `IssueUnlinkedEvent`, `IssueReleaseChangedEvent` |
| Comment | `CommentAddedEvent`, `CommentEditedEvent`, `CommentDeletedEvent` | | Comment | `CommentAddedEvent`, `CommentEditedEvent`, `CommentDeletedEvent` |
| Release | `ReleaseCreatedEvent`, `ReleaseUpdatedEvent`, `ReleaseShippedEvent`, `ReleaseArchivedEvent` | | 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 ## 3.2 GraphQL
Todas las operaciones requieren `Authorization: Bearer <token de Pireon>` (en subscriptions, en el `connection_init`). 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 ### Queries
| Query | Devuelve | | Query | Devuelve |
| ----------------------------------------------- | ---------------------------------------------------------------------------------------------------- | | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `me` | El usuario autenticado, con sus roles en Pireon | | `me` | El usuario autenticado, con sus roles en Pireon |
| `users(search, limit)` | Usuarios de Pireon (para asignar o añadir miembros) | | `users(search, limit)` | Usuarios de Pireon (para asignar o añadir miembros) |
| `user(id)` | Un usuario | | `user(id)` | Un usuario |
| `myProjects` | Proyectos de los que soy miembro | | `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) | | `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` | | `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 | | `activity(projectId, issueId?, limit, before?)` | Feed de actividad, del más reciente al más antiguo |
### Mutations ### Mutations
@@ -126,8 +184,8 @@ Todas las operaciones requieren `Authorization: Bearer <token de Pireon>` (en su
Todas devuelven `CommandResult { id, key, version }`. Todas devuelven `CommandResult { id, key, version }`.
| Ámbito | Mutations | | Ámbito | Mutations |
| ----------- | ----------------------------------------------------------------------------------------------------------------- | | ----------- | ------------------------------------------------------------------------------------------------------------------------------- |
| Proyectos | `createProject`, `updateProject`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` | | Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease` | | Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease` |
| Comentarios | `addComment`, `editComment`, `deleteComment` | | Comentarios | `addComment`, `editComment`, `deleteComment` |
| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `archiveRelease` | | 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) | | `commentChanged(issueId)` | Se añade, edita o borra un comentario (`comment` es `null` al borrar) |
| `activityAdded(projectId)` | Hay una nueva entrada en el feed | | `activityAdded(projectId)` | Hay una nueva entrada en el feed |
| `releaseChanged(projectId)` | Cambia una release | | `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 | | `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. Solo los miembros pueden suscribirse a un proyecto; las conexiones sin token válido se cierran con el código 4403.
### Ejemplos ### Ejemplos
Estados, tipos y áreas son texto (ids de la configuración del proyecto), no enums: van entre comillas.
```graphql ```graphql
mutation ($settings: ProjectSettingsInput!) {
# La configuración entera (la que devuelve project.settings, cambiada).
updateProjectSettings(projectId: "…", settings: $settings) {
version
}
}
mutation { mutation {
createProject(input: { key: "SHOP", name: "Checkout revamp" }) { createProject(input: { key: "SHOP", name: "Checkout revamp" }) {
id id
@@ -157,7 +224,7 @@ mutation {
} }
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 id
key key
version version
@@ -165,7 +232,7 @@ mutation {
} }
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 version
} }
} }
@@ -210,6 +277,27 @@ query {
percent percent
} }
} }
analytics(days: 90) {
createdVsResolved {
week
created
resolved
}
cycleTime {
averageDays
p85Days
}
qa {
failureRate
}
}
}
release(id: "…") {
burnup {
dates
scope
done
}
} }
issue(idOrKey: "SHOP-1") { issue(idOrKey: "SHOP-1") {
status 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`). Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
| Recurso | Rutas | | Recurso | Rutas |
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` | | 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) | | Proyectos | `GET/POST /api/projects`, `GET/PATCH /api/projects/{project}`, `PUT …/settings`, `…/board`, `…/stats`, `…/analytics?days=`, `…/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` | | 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}` | | 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` | | 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 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. tiempo real, las subscriptions de GraphQL.

View File

@@ -37,7 +37,7 @@ export const boardPageEvents = eventGroup({
source: 'Board Page', source: 'Board Page',
events: { events: {
opened: type<BoardFilter>(), 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); 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 }); 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`). - **Tests sin plantilla**: la facade se prueba con gateways falsos (`facades.spec.ts`).
| Facade | Store | Ámbito | Responsabilidad | | 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 | | `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`) | | `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 | | `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 | | `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) 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` ## 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 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`. 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 ## 4.6 Rutas
| Ruta | Página | | Ruta | Página |
| -------------------------- | ---------------------------------------------------------------------------------------- | | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/login` | Inicio de sesión (solo sin sesión) | | `/login` | Inicio de sesión (solo sin sesión) |
| `/auth/callback` | Retorno de Pireon (PKCE) | | `/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/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros) |
| `/p/:key/issues/:issueKey` | Ticket | | `/p/:key/issues/:issueKey` | Ticket |
| `/p/:key/releases` | Releases | | `/p/:key/releases` | Releases |
| `/p/:key/dashboard` | Seguimiento: cifras, flujo por estado, equipo (cada persona enlaza a su tablero) y tipos | | `/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/activity` | Actividad en vivo |
| `/p/:key/members` | Miembros | | `/p/:key/members` | Miembros |

View File

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

View File

@@ -7,10 +7,14 @@ Casos de uso del tracker (CQRS).
## Contenido ## 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()`. - `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`). - `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. `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`. - `ports/`: lookups de escritura, `Find*`, `UpsertMaterialized*`, `ReleaseIssuesRepository`, `ChangeFeed`, `UserDirectory`.
- `TrackerApplicationModule.withInfrastructure(...)`. - `TrackerApplicationModule.withInfrastructure(...)`.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import { CreateProjectCommandHandler } from './create-project.command-handler'; import { CreateProjectCommandHandler } from './create-project.command-handler';
import { UpdateProjectCommandHandler } from './update-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 { AddProjectMemberCommandHandler } from './add-project-member.command-handler';
import { ChangeProjectMemberRoleCommandHandler } from './change-project-member-role.command-handler'; import { ChangeProjectMemberRoleCommandHandler } from './change-project-member-role.command-handler';
import { RemoveProjectMemberCommandHandler } from './remove-project-member.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 = [ export const COMMAND_HANDLERS = [
CreateProjectCommandHandler, CreateProjectCommandHandler,
UpdateProjectCommandHandler, UpdateProjectCommandHandler,
UpdateProjectSettingsCommandHandler,
AddProjectMemberCommandHandler, AddProjectMemberCommandHandler,
ChangeProjectMemberRoleCommandHandler, ChangeProjectMemberRoleCommandHandler,
RemoveProjectMemberCommandHandler, RemoveProjectMemberCommandHandler,

View File

@@ -19,12 +19,17 @@ export class CreateIssueCommandHandler
async execute(command: CreateIssueCommand): Promise<CommandResult> { async execute(command: CreateIssueCommand): Promise<CommandResult> {
const project = await this.access.project(command.projectId); const project = await this.access.project(command.projectId);
project.assertCanContribute(command.actorId); 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) if (command.assigneeId)
this.access.assertAssignable(project, command.assigneeId); this.access.assertAssignable(project, command.assigneeId);
if (command.releaseId) if (command.releaseId)
await this.access.openReleaseOf(project, command.releaseId); await this.access.openReleaseOf(project, command.releaseId);
const issue = this.issueFactory.create({ const issue = this.issueFactory.create(
{
...command.details, ...command.details,
projectId: project.id, projectId: project.id,
projectKey: project.key, projectKey: project.key,
@@ -33,7 +38,9 @@ export class CreateIssueCommandHandler
title: command.title, title: command.title,
reporterId: command.actorId, reporterId: command.actorId,
assigneeId: command.assigneeId, assigneeId: command.assigneeId,
}); },
workflow,
);
if (command.releaseId) issue.setRelease(command.actorId, command.releaseId); if (command.releaseId) issue.setRelease(command.actorId, command.releaseId);
this.eventPublisher.mergeObjectContext(issue); this.eventPublisher.mergeObjectContext(issue);

View File

@@ -1,12 +1,13 @@
import { Command } from '@nestjs/cqrs'; 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'; import { CommandResult } from './command-result';
export class CreateIssueCommand extends Command<CommandResult> { export class CreateIssueCommand extends Command<CommandResult> {
constructor( constructor(
public readonly actorId: string, public readonly actorId: string,
public readonly projectId: 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 title: string,
public readonly details: Omit<IssueDetails, 'title' | 'type'>, public readonly details: Omit<IssueDetails, 'title' | 'type'>,
public readonly assigneeId?: string | null, public readonly assigneeId?: string | null,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,9 +7,7 @@ import {
IssueLinkedEvent, IssueLinkedEvent,
IssueLinkType, IssueLinkType,
IssueReleaseChangedEvent, IssueReleaseChangedEvent,
IssueStatus,
IssueStatusChangedEvent, IssueStatusChangedEvent,
IssueType,
IssueUnlinkedEvent, IssueUnlinkedEvent,
IssueUpdatedEvent, IssueUpdatedEvent,
ProjectCreatedEvent, ProjectCreatedEvent,
@@ -17,7 +15,9 @@ import {
ProjectMemberRemovedEvent, ProjectMemberRemovedEvent,
ProjectMemberRoleChangedEvent, ProjectMemberRoleChangedEvent,
ProjectRole, ProjectRole,
ProjectSettingsChangedEvent,
ProjectUpdatedEvent, ProjectUpdatedEvent,
ProjectWorkflow,
ReleaseArchivedEvent, ReleaseArchivedEvent,
ReleaseCreatedEvent, ReleaseCreatedEvent,
ReleaseShippedEvent, ReleaseShippedEvent,
@@ -31,28 +31,19 @@ export interface SummaryContext {
issueKey: string; issueKey: string;
projectKey: string; projectKey: string;
releaseName: 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. */ /* The activity feed is read by people: Spanish, with the same labels as the web app. */
const STATUS: Record<IssueStatus, string> = { /** Default types keep their lowercase wording in sentences ("creó WEB-3 (tarea)"). */
[IssueStatus.BACKLOG]: 'Backlog', const DEFAULT_TYPE_WORDING: Record<string, string> = {
[IssueStatus.TODO]: 'Por hacer', BUG: 'bug',
[IssueStatus.IN_PROGRESS]: 'En curso', FEATURE: 'feature',
[IssueStatus.IN_REVIEW]: 'En revisión', UI_CHANGE: 'cambio de UI',
[IssueStatus.READY_FOR_QA]: 'Listo para QA', TASK: 'tarea',
[IssueStatus.IN_QA]: 'En QA', QA: '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',
}; };
const LINK: Record<IssueLinkType, string> = { const LINK: Record<IssueLinkType, string> = {
@@ -98,6 +89,8 @@ export function describeEvent(
return `${actor} creó el proyecto ${event.project.key}`; return `${actor} creó el proyecto ${event.project.key}`;
if (event instanceof ProjectUpdatedEvent) if (event instanceof ProjectUpdatedEvent)
return `${actor} actualizó el proyecto ${ctx.projectKey}`; 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) { if (event instanceof ProjectMemberAddedEvent) {
return `${actor} añadió a ${ctx.user(event.userId)} como ${ROLE[event.role]}`; return `${actor} añadió a ${ctx.user(event.userId)} como ${ROLE[event.role]}`;
} }
@@ -111,13 +104,19 @@ export function describeEvent(
} }
if (event instanceof IssueCreatedEvent) { 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) { if (event instanceof IssueUpdatedEvent) {
return `${actor} cambió ${list(Object.keys(event.changes).map((f) => FIELD[f] ?? f))} de ${issueKey}`; return `${actor} cambió ${list(Object.keys(event.changes).map((f) => FIELD[f] ?? f))} de ${issueKey}`;
} }
if (event instanceof IssueStatusChangedEvent) { 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) { if (event instanceof IssueAssignedEvent) {
return event.assigneeId return event.assigneeId

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,8 +5,6 @@ import {
IssueDetails, IssueDetails,
IssueLinkType, IssueLinkType,
IssueReadModel, IssueReadModel,
IssueStatus,
IssueType,
} from '@task-stream/api/tracker/domain'; } from '@task-stream/api/tracker/domain';
import { AssignIssueCommand } from './commands/assign-issue.command'; import { AssignIssueCommand } from './commands/assign-issue.command';
import { ChangeIssueStatusCommand } from './commands/change-issue-status.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 { GetIssueQuery } from './queries/get-issue.query';
import { GetIssuesByIdsQuery } from './queries/get-issues-by-ids.query'; import { GetIssuesByIdsQuery } from './queries/get-issues-by-ids.query';
import { SearchIssuesQuery } from './queries/search-issues.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'> { export interface CreateIssueInput extends Omit<IssueDetails, 'title' | 'type'> {
projectId: string; projectId: string;
type: IssueType; /** A type id of the project's settings. */
type: string;
title: string; title: string;
assigneeId?: string | null; assigneeId?: string | null;
releaseId?: string | null; releaseId?: string | null;
@@ -71,7 +74,7 @@ export class IssuesService {
changeStatus( changeStatus(
actorId: string, actorId: string,
issueId: string, issueId: string,
status: IssueStatus, status: string,
reason?: string | null, reason?: string | null,
): Promise<CommandResult> { ): Promise<CommandResult> {
return this.commandBus.execute( return this.commandBus.execute(
@@ -135,6 +138,14 @@ export class IssuesService {
return this.queryBus.execute(new SearchIssuesQuery(viewerId, filter)); 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( activity(
viewerId: string, viewerId: string,
params: { params: {

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import { import {
IssueLinkReadModel, IssueLinkReadModel,
IssueReadModel, IssueReadModel,
StatusCategory,
} from '@task-stream/api/tracker/domain'; } from '@task-stream/api/tracker/domain';
export abstract class UpsertMaterializedIssueRepository { export abstract class UpsertMaterializedIssueRepository {
@@ -19,6 +20,11 @@ export abstract class UpsertMaterializedIssueRepository {
issueId: string, issueId: string,
commentCount: number, commentCount: number,
): Promise<void>; ): 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. */ /** Keeps the denormalised release name of every issue in sync. */
abstract renameRelease(releaseId: string, name: string): Promise<void>; abstract renameRelease(releaseId: string, name: string): Promise<void>;
} }

View File

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

View File

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

View File

@@ -1,41 +1,49 @@
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; import { 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 { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { BoardColumn, GetBoardQuery } from './get-board.query'; import { BoardColumn, GetBoardQuery } from './get-board.query';
import { ReadAccess } from './read-access'; 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) @QueryHandler(GetBoardQuery)
export class GetBoardQueryHandler export class GetBoardQueryHandler
implements IQueryHandler<GetBoardQuery, BoardColumn[]> implements IQueryHandler<GetBoardQuery, BoardColumn[]>
{ {
constructor( constructor(
private readonly findIssues: FindIssuesRepository, private readonly findIssues: FindIssuesRepository,
private readonly findProjects: FindProjectsRepository,
private readonly access: ReadAccess, 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[]> { async execute(query: GetBoardQuery): Promise<BoardColumn[]> {
await this.access.assertCanView(query.filter.projectId, query.viewerId); await this.access.assertCanView(query.filter.projectId, query.viewerId);
return Promise.all( const project = await this.findProjects.findById(query.filter.projectId);
BOARD_COLUMNS.map(async (status) => { 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({ const page = await this.findIssues.search({
...query.filter, ...query.filter,
statuses: [status], statuses: [status.id],
limit: Math.min(query.perColumn, 200), limit: Math.min(query.perColumn, 200),
offset: 0, offset: 0,
}); });
return { status, issues: page.items, total: page.total }; return {
column: { status: status.id, issues: page.items, total: page.total },
archived: status.archived,
};
}), }),
); );
return columns
.filter(({ column, archived }) => !archived || column.total > 0)
.map(({ column }) => column);
} }
} }

View File

@@ -1,14 +1,15 @@
import { Query } from '@nestjs/cqrs'; import { 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'; import { IssueFilter } from '../ports/find-issues.repository';
export interface BoardColumn { export interface BoardColumn {
status: IssueStatus; /** Status id of the project's workflow. */
status: string;
issues: IssueReadModel[]; issues: IssueReadModel[];
total: number; 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[]> { export class GetBoardQuery extends Query<BoardColumn[]> {
constructor( constructor(
public readonly viewerId: string, public readonly viewerId: string,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs'; import { CommandBus, QueryBus } from '@nestjs/cqrs';
import { import {
ReleaseBurnupReadModel,
ReleaseProgressReadModel, ReleaseProgressReadModel,
ReleaseReadModel, ReleaseReadModel,
} from '@task-stream/api/tracker/domain'; } 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 { UpdateReleaseCommand } from './commands/update-release.command';
import { GetReleaseProgressQuery } from './queries/get-release-progress.query'; import { GetReleaseProgressQuery } from './queries/get-release-progress.query';
import { GetReleaseQuery } from './queries/get-release.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 { interface ReleaseDetails {
name?: string; name?: string;
@@ -71,6 +74,11 @@ export class ReleasesService {
return this.queryBus.execute(new GetReleaseQuery(viewerId, releaseId)); 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( progress(
viewerId: string, viewerId: string,
releaseId: string, releaseId: string,
@@ -79,4 +87,11 @@ export class ReleasesService {
new GetReleaseProgressQuery(viewerId, releaseId), new GetReleaseProgressQuery(viewerId, releaseId),
); );
} }
/** Scope vs finished tickets of the release, day by day. */
burnup(viewerId: string, releaseId: string): Promise<ReleaseBurnupReadModel> {
return this.queryBus.execute(
new GetReleaseBurnupQuery(viewerId, releaseId),
);
}
} }

View File

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

View File

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

View File

@@ -10,8 +10,11 @@ El modelo del producto: proyectos, tickets, comentarios y releases.
- Agregados event-sourced: `Project`, `Issue`, `Comment`, `Release`. Validan en sus métodos y cambian de estado en `on<Evento>`. - 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`. - `events/`: un fichero por evento (`IssueCreatedEvent`, `IssueStatusChangedEvent`…), todos con `@AutowiredEvent`.
- `factories/`: `ProjectFactory`, `IssueFactory`, `CommentFactory`, `ReleaseFactory` (`@Injectable()`). - `factories/`: `ProjectFactory`, `IssueFactory`, `CommentFactory`, `ReleaseFactory` (`@Injectable()`).
- `issue/issue-workflow.ts`: transiciones de estado permitidas y estados que exigen motivo. - `project/project-settings.ts`: configuración por proyecto (estados con categoría, transiciones, tipos, áreas), sus
- `read-models/`: forma de las vistas de lectura (`ProjectReadModel`, `IssueReadModel`…). 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). Reglas y workflow: [API](../../../../docs/03-api.md#31-modelo-de-dominio-contexto-tracker).

View File

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

View File

@@ -2,7 +2,7 @@ export * from './lib/project/project';
export * from './lib/project/project-role'; export * from './lib/project/project-role';
export * from './lib/issue/issue'; export * from './lib/issue/issue';
export * from './lib/issue/issue.enums'; 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/comment/comment';
export * from './lib/release/release'; export * from './lib/release/release';
export * from './lib/release/release-status'; export * from './lib/release/release-status';

View File

@@ -1,6 +1,7 @@
export * from './tracker.event'; export * from './tracker.event';
export * from './project-created.event'; export * from './project-created.event';
export * from './project-updated.event'; export * from './project-updated.event';
export * from './project-settings-changed.event';
export * from './project-member-added.event'; export * from './project-member-added.event';
export * from './project-member-removed.event'; export * from './project-member-removed.event';
export * from './project-member-role-changed.event'; export * from './project-member-role-changed.event';

View File

@@ -1,17 +1,27 @@
import { AutowiredEvent } from '@task-stream/api/shared/domain'; 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'; 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 @AutowiredEvent
export class IssueStatusChangedEvent extends TrackerEvent { export class IssueStatusChangedEvent extends TrackerEvent {
constructor( constructor(
actorId: string, actorId: string,
issueId: string, issueId: string,
projectId: string, projectId: string,
readonly from: IssueStatus, /** Status ids of the project's workflow, e.g. IN_QA → QA_FAILED. */
readonly to: IssueStatus, readonly from: string,
readonly to: string,
readonly reason: string | null, readonly reason: string | null,
readonly meta?: StatusChangeMeta,
) { ) {
super(actorId, issueId, projectId); super(actorId, issueId, projectId);
} }

View File

@@ -0,0 +1,15 @@
import { AutowiredEvent } from '@task-stream/api/shared/domain';
import type { ProjectSettings } from '../project/project-settings';
import { TrackerEvent } from './tracker.event';
/** Statuses, transitions, ticket types or areas of a project changed (full snapshot). */
@AutowiredEvent
export class ProjectSettingsChangedEvent extends TrackerEvent {
constructor(
actorId: string,
projectId: string,
readonly settings: ProjectSettings,
) {
super(actorId, projectId, projectId);
}
}

View File

@@ -7,23 +7,32 @@ import {
IssueProps, IssueProps,
normalizeIssueDetails, normalizeIssueDetails,
} from '../issue/issue'; } 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 { export interface CreateIssueProps extends IssueDetails {
projectId: string; projectId: string;
projectKey: string; projectKey: string;
number: number; number: number;
title: string; title: string;
type: IssueType; type: string;
reporterId: string; reporterId: string;
assigneeId?: string | null; assigneeId?: string | null;
} }
@Injectable() @Injectable()
export class IssueFactory { 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); const details = normalizeIssueDetails(props);
workflow.assertActiveType(props.type);
workflow.assertActiveArea(props.area);
const title = details.title ?? ''; const title = details.title ?? '';
assertThat(title.length > 0, 'Issue title is required'); assertThat(title.length > 0, 'Issue title is required');
@@ -37,7 +46,7 @@ export class IssueFactory {
area: props.area ?? null, area: props.area ?? null,
title, title,
description: details.description ?? null, description: details.description ?? null,
status: IssueStatus.BACKLOG, status: workflow.initialStatus(),
priority: props.priority ?? IssuePriority.MEDIUM, priority: props.priority ?? IssuePriority.MEDIUM,
reporterId: props.reporterId, reporterId: props.reporterId,
assigneeId: props.assigneeId ?? null, assigneeId: props.assigneeId ?? null,

View File

@@ -3,6 +3,7 @@ import { assertThat, newId } from '@task-stream/api/shared/domain';
import { ProjectCreatedEvent } from '../events/project-created.event'; import { ProjectCreatedEvent } from '../events/project-created.event';
import { Project, ProjectProps } from '../project/project'; import { Project, ProjectProps } from '../project/project';
import { ProjectRole } from '../project/project-role'; import { ProjectRole } from '../project/project-role';
import { defaultProjectSettings } from '../project/project-settings';
const KEY_PATTERN = /^[A-Z][A-Z0-9]{1,9}$/; const KEY_PATTERN = /^[A-Z][A-Z0-9]{1,9}$/;
@@ -30,6 +31,7 @@ export class ProjectFactory {
description: props.description?.trim() || null, description: props.description?.trim() || null,
ownerId: props.ownerId, ownerId: props.ownerId,
members: [{ userId: props.ownerId, role: ProjectRole.OWNER }], members: [{ userId: props.ownerId, role: ProjectRole.OWNER }],
settings: defaultProjectSettings(),
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}; };

View File

@@ -1,37 +0,0 @@
import { IssueStatus as S } from './issue.enums';
/**
* Allowed status transitions. QA is a first-class step: work goes
* READY_FOR_QA → IN_QA → DONE | QA_FAILED → IN_PROGRESS.
*/
const TRANSITIONS: Record<S, readonly S[]> = {
[S.BACKLOG]: [S.TODO, S.IN_PROGRESS, S.CANCELLED],
[S.TODO]: [S.BACKLOG, S.IN_PROGRESS, S.CANCELLED],
[S.IN_PROGRESS]: [S.TODO, S.IN_REVIEW, S.READY_FOR_QA, S.CANCELLED],
[S.IN_REVIEW]: [S.IN_PROGRESS, S.READY_FOR_QA, S.DONE],
[S.READY_FOR_QA]: [S.IN_QA, S.IN_PROGRESS],
[S.IN_QA]: [S.DONE, S.QA_FAILED],
[S.QA_FAILED]: [S.IN_PROGRESS, S.TODO],
[S.DONE]: [S.TODO, S.IN_PROGRESS],
[S.CANCELLED]: [S.BACKLOG],
};
/** Statuses that require an explanation when entered. */
const REASON_REQUIRED: readonly S[] = [S.QA_FAILED, S.CANCELLED];
export const FINISHED_STATUSES: readonly S[] = [S.DONE, S.CANCELLED];
export const IssueWorkflow = {
canTransition(from: S, to: S): boolean {
return TRANSITIONS[from].includes(to);
},
allowedFrom(from: S): readonly S[] {
return TRANSITIONS[from];
},
requiresReason(to: S): boolean {
return REASON_REQUIRED.includes(to);
},
isFinished(status: S): boolean {
return FINISHED_STATUSES.includes(status);
},
};

View File

@@ -10,14 +10,8 @@ import { IssueReleaseChangedEvent } from '../events/issue-release-changed.event'
import { IssueStatusChangedEvent } from '../events/issue-status-changed.event'; import { IssueStatusChangedEvent } from '../events/issue-status-changed.event';
import { IssueUnlinkedEvent } from '../events/issue-unlinked.event'; import { IssueUnlinkedEvent } from '../events/issue-unlinked.event';
import { IssueUpdatedEvent } from '../events/issue-updated.event'; import { IssueUpdatedEvent } from '../events/issue-updated.event';
import { import { ProjectWorkflow } from '../project/project-settings';
IssueArea, import { IssueLinkType, IssuePriority } from './issue.enums';
IssueLinkType,
IssuePriority,
IssueStatus,
IssueType,
} from './issue.enums';
import { IssueWorkflow } from './issue-workflow';
export interface IssueLink { export interface IssueLink {
type: IssueLinkType; type: IssueLinkType;
@@ -29,8 +23,10 @@ export interface IssueLink {
export interface IssueDetails { export interface IssueDetails {
title?: string; title?: string;
description?: string | null; description?: string | null;
type?: IssueType; /** Id of one of the project's ticket types. */
area?: IssueArea | null; type?: string;
/** Id of one of the project's areas. */
area?: string | null;
priority?: IssuePriority; priority?: IssuePriority;
labels?: string[]; labels?: string[];
figmaUrl?: string | null; figmaUrl?: string | null;
@@ -44,11 +40,12 @@ export interface IssueProps {
projectId: string; projectId: string;
number: number; number: number;
key: string; key: string;
type: IssueType; type: string;
area: IssueArea | null; area: string | null;
title: string; title: string;
description: string | null; description: string | null;
status: IssueStatus; /** Id of a status of the project's workflow. */
status: string;
priority: IssuePriority; priority: IssuePriority;
reporterId: string; reporterId: string;
assigneeId: string | null; assigneeId: string | null;
@@ -70,11 +67,11 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
public projectId!: string; public projectId!: string;
public number!: number; public number!: number;
public key!: string; public key!: string;
public type!: IssueType; public type!: string;
public area: IssueArea | null = null; public area: string | null = null;
public title!: string; public title!: string;
public description: string | null = null; public description: string | null = null;
public status: IssueStatus = IssueStatus.BACKLOG; public status!: string;
public priority: IssuePriority = IssuePriority.MEDIUM; public priority: IssuePriority = IssuePriority.MEDIUM;
public reporterId!: string; public reporterId!: string;
public assigneeId: string | null = null; public assigneeId: string | null = null;
@@ -92,7 +89,12 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
this.id = id; 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 next = normalizeIssueDetails(changes);
const patch: IssueDetails = {}; const patch: IssueDetails = {};
for (const field of Object.keys(next) as (keyof 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) if (patch.title !== undefined)
assertThat(patch.title.length > 0, 'Issue title is required'); 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; if (Object.keys(patch).length === 0) return;
this.apply(new IssueUpdatedEvent(actorId, this.id, this.projectId, patch)); 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; const from = this.status;
if (from === to) return; if (from === to) return;
if (!IssueWorkflow.canTransition(from, to)) { if (!workflow.canTransition(from, to)) {
throw new BusinessRuleError( 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; const trimmedReason = reason?.trim() || null;
if (IssueWorkflow.requiresReason(to) && !trimmedReason) { if (workflow.requiresReason(to) && !trimmedReason) {
throw new BusinessRuleError( 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( this.apply(
@@ -129,6 +145,12 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
from, from,
to, to,
trimmedReason, trimmedReason,
{
fromName: workflow.statusName(from),
toName: workflow.statusName(to),
fromCategory: workflow.categoryOf(from),
toCategory: workflow.categoryOf(to),
},
), ),
); );
} }

View File

@@ -0,0 +1,166 @@
import {
BusinessRuleError,
ForbiddenError,
ValidationError,
} from '@task-stream/api/shared/domain';
import { ProjectSettingsChangedEvent } from '../events/project-settings-changed.event';
import { IssueStatusChangedEvent } from '../events/issue-status-changed.event';
import { IssueFactory } from '../factories/issue.factory';
import { ProjectFactory } from '../factories/project.factory';
import { replay } from '../testing';
import { Project } from './project';
import { ProjectRole } from './project-role';
import {
defaultProjectSettings,
ProjectSettings,
StatusCategory,
} from './project-settings';
const newProject = () => {
const project = new ProjectFactory().create({
key: 'WEB',
name: 'Web',
ownerId: 'owner',
});
project.addMember('owner', 'dev', ProjectRole.MEMBER);
return project;
};
/** Defaults plus a STAGING step between review and QA, and a SPIKE type. */
const withStaging = (): ProjectSettings => {
const settings = defaultProjectSettings();
settings.statuses.splice(4, 0, {
id: 'STAGING',
name: 'En staging',
category: StatusCategory.QA,
color: 'teal',
requiresReason: false,
archived: false,
});
settings.transitions['IN_REVIEW'] = ['STAGING'];
settings.transitions['STAGING'] = ['IN_QA', 'IN_PROGRESS'];
settings.issueTypes.push({
id: 'SPIKE',
name: 'Spike',
icon: 'light-bulb',
color: 'teal',
archived: false,
});
return settings;
};
describe('Project settings', () => {
it('starts with the default workflow, also when rebuilt from old events', () => {
const project = newProject();
expect(project.workflow.initialStatus()).toBe('BACKLOG');
expect(project.workflow.canTransition('IN_QA', 'QA_FAILED')).toBe(true);
// Projects created before settings existed have no `settings` in their event.
const { history } = replay(project, Project);
delete (history[0].data as unknown as { project: { settings?: unknown } })
.project.settings;
const old = new Project(project.id);
old.loadFromHistory(history);
expect(old.settings).toEqual(defaultProjectSettings());
});
it('lets owners and admins customise it, as an event that replays', () => {
const project = newProject();
project.updateSettings('owner', withStaging());
const event = project.getUncommittedEvents().at(-1);
expect(event).toBeInstanceOf(ProjectSettingsChangedEvent);
expect(project.workflow.allowedFrom('IN_REVIEW')).toEqual(['STAGING']);
const { rebuilt } = replay(project, Project);
expect(rebuilt.workflow.status('STAGING')?.category).toBe(
StatusCategory.QA,
);
expect(() => project.updateSettings('dev', withStaging())).toThrow(
ForbiddenError,
);
});
it('archives values instead of deleting them', () => {
const project = newProject();
const settings = defaultProjectSettings();
settings.areas = settings.areas.filter((a) => a.id !== 'MOBILE');
expect(() => project.updateSettings('owner', settings)).toThrow(
/archívalo/,
);
const archived = defaultProjectSettings();
archived.areas = archived.areas.map((a) =>
a.id === 'MOBILE' ? { ...a, archived: true } : a,
);
project.updateSettings('owner', archived);
expect(() => project.workflow.assertActiveArea('MOBILE')).toThrow(
BusinessRuleError,
);
});
it('rejects settings that would break the board', () => {
const project = newProject();
const noStart = defaultProjectSettings();
noStart.statuses
.filter((s) => s.category === StatusCategory.TODO)
.forEach((s) => (s.archived = true));
expect(() => project.updateSettings('owner', noStart)).toThrow(
ValidationError,
);
const badTransition = defaultProjectSettings();
badTransition.transitions['TODO'] = ['NOPE'];
expect(() => project.updateSettings('owner', badTransition)).toThrow(
/no existe/,
);
const duplicate = defaultProjectSettings();
duplicate.issueTypes.push({ ...duplicate.issueTypes[0] });
expect(() => project.updateSettings('owner', duplicate)).toThrow(/id BUG/);
});
it('drives tickets: types, transitions and names in the history', () => {
const project = newProject();
project.updateSettings('owner', withStaging());
const workflow = project.workflow;
const issue = new IssueFactory().create(
{
projectId: project.id,
projectKey: 'WEB',
number: 1,
title: 'Investigar pagos',
type: 'SPIKE',
reporterId: 'dev',
},
workflow,
);
for (const to of ['TODO', 'IN_PROGRESS', 'IN_REVIEW', 'STAGING'])
issue.changeStatus('dev', to, null, workflow);
const last = issue.getUncommittedEvents().at(-1) as IssueStatusChangedEvent;
expect(last.meta).toEqual({
fromName: 'En revisión',
toName: 'En staging',
fromCategory: StatusCategory.IN_PROGRESS,
toCategory: StatusCategory.QA,
});
// The default IN_REVIEW → READY_FOR_QA was replaced by STAGING.
expect(() =>
issue.changeStatus('dev', 'READY_FOR_QA', null, workflow),
).toThrow(BusinessRuleError);
expect(() =>
new IssueFactory().create(
{
projectId: project.id,
projectKey: 'WEB',
number: 2,
title: 'x',
type: 'EPIC',
reporterId: 'dev',
},
workflow,
),
).toThrow(/Unknown ticket type/);
});
});

View File

@@ -0,0 +1,395 @@
import {
BusinessRuleError,
ValidationError,
assertThat,
} from '@task-stream/api/shared/domain';
import { IssueArea, IssueStatus as S, IssueType } from '../issue/issue.enums';
/**
* What a status means, whatever it is called in each project: it keeps
* releases ("everything finished"), "waiting for QA" and statistics working
* with custom statuses.
*/
export enum StatusCategory {
TODO = 'TODO',
IN_PROGRESS = 'IN_PROGRESS',
QA = 'QA',
DONE = 'DONE',
CANCELLED = 'CANCELLED',
}
/** Colour tokens the web app knows (light and dark). */
export const SETTING_COLORS = [
'slate',
'stone',
'violet',
'blue',
'sky',
'teal',
'green',
'amber',
'orange',
'red',
'pink',
] as const;
export type SettingColor = (typeof SETTING_COLORS)[number];
/** Icons a ticket type can use (registered in the web app). */
export const TYPE_ICONS = [
'bug',
'feature',
'palette',
'task',
'qa',
'flag',
'star-fill',
'rocket',
'document',
'light-bulb',
'key',
'support',
] as const;
export type TypeIcon = (typeof TYPE_ICONS)[number];
export interface WorkflowStatus {
/** Stable id stored on tickets, e.g. IN_REVIEW. */
id: string;
name: string;
category: StatusCategory;
color: SettingColor;
/** Entering it needs a reason (kept in the history). */
requiresReason: boolean;
/** Archived: no new tickets or transitions into it; existing ones keep it. */
archived: boolean;
}
export interface IssueTypeOption {
id: string;
name: string;
icon: TypeIcon;
color: SettingColor;
archived: boolean;
}
export interface AreaOption {
id: string;
name: string;
archived: boolean;
}
/** Per-project configuration of the tracker. */
export interface ProjectSettings {
/** In board order. New tickets start in the first active TODO status. */
statuses: WorkflowStatus[];
/** Allowed moves: status id → status ids it can go to. */
transitions: Record<string, string[]>;
issueTypes: IssueTypeOption[];
areas: AreaOption[];
}
const status = (
id: string,
name: string,
category: StatusCategory,
color: SettingColor,
requiresReason = false,
): WorkflowStatus => ({
id,
name,
category,
color,
requiresReason,
archived: false,
});
/** What every project starts with (and what projects created before settings had). */
export const DEFAULT_PROJECT_SETTINGS: ProjectSettings = {
statuses: [
status(S.BACKLOG, 'Backlog', StatusCategory.TODO, 'stone'),
status(S.TODO, 'Por hacer', StatusCategory.TODO, 'slate'),
status(S.IN_PROGRESS, 'En curso', StatusCategory.IN_PROGRESS, 'violet'),
status(S.IN_REVIEW, 'En revisión', StatusCategory.IN_PROGRESS, 'blue'),
status(S.READY_FOR_QA, 'Listo para QA', StatusCategory.QA, 'amber'),
status(S.IN_QA, 'En QA', StatusCategory.QA, 'orange'),
status(S.QA_FAILED, 'QA fallido', StatusCategory.IN_PROGRESS, 'red', true),
status(S.DONE, 'Hecho', StatusCategory.DONE, 'green'),
status(S.CANCELLED, 'Cancelado', StatusCategory.CANCELLED, 'stone', true),
],
// QA is a first-class step: READY_FOR_QA → IN_QA → DONE | QA_FAILED → IN_PROGRESS.
transitions: {
[S.BACKLOG]: [S.TODO, S.IN_PROGRESS, S.CANCELLED],
[S.TODO]: [S.BACKLOG, S.IN_PROGRESS, S.CANCELLED],
[S.IN_PROGRESS]: [S.TODO, S.IN_REVIEW, S.READY_FOR_QA, S.CANCELLED],
[S.IN_REVIEW]: [S.IN_PROGRESS, S.READY_FOR_QA, S.DONE],
[S.READY_FOR_QA]: [S.IN_QA, S.IN_PROGRESS],
[S.IN_QA]: [S.DONE, S.QA_FAILED],
[S.QA_FAILED]: [S.IN_PROGRESS, S.TODO],
[S.DONE]: [S.TODO, S.IN_PROGRESS],
[S.CANCELLED]: [S.BACKLOG],
},
issueTypes: [
{
id: IssueType.BUG,
name: 'Bug',
icon: 'bug',
color: 'red',
archived: false,
},
{
id: IssueType.FEATURE,
name: 'Feature',
icon: 'feature',
color: 'violet',
archived: false,
},
{
id: IssueType.UI_CHANGE,
name: 'Cambio de UI',
icon: 'palette',
color: 'pink',
archived: false,
},
{
id: IssueType.TASK,
name: 'Tarea',
icon: 'task',
color: 'slate',
archived: false,
},
{
id: IssueType.QA,
name: 'QA',
icon: 'qa',
color: 'amber',
archived: false,
},
],
areas: [
{ id: IssueArea.FRONTEND, name: 'Frontend', archived: false },
{ id: IssueArea.BACKEND, name: 'Backend', archived: false },
{ id: IssueArea.MOBILE, name: 'Mobile', archived: false },
{ id: IssueArea.QA, name: 'QA', archived: false },
{ id: IssueArea.DESIGN, name: 'Diseño', archived: false },
{ id: IssueArea.DEVOPS, name: 'DevOps', archived: false },
],
};
export function defaultProjectSettings(): ProjectSettings {
return structuredClone(DEFAULT_PROJECT_SETTINGS);
}
const ID = /^[A-Z][A-Z0-9_]{0,29}$/;
const FINISHED = [StatusCategory.DONE, StatusCategory.CANCELLED];
/** Read-only view of a project's workflow, used by tickets and queries. */
export class ProjectWorkflow {
constructor(readonly settings: ProjectSettings) {}
static default(): ProjectWorkflow {
return new ProjectWorkflow(DEFAULT_PROJECT_SETTINGS);
}
status(id: string): WorkflowStatus | undefined {
return this.settings.statuses.find((s) => s.id === id);
}
/** Name to show, also for ids no longer configured. */
statusName(id: string): string {
return (
this.status(id)?.name ??
DEFAULT_PROJECT_SETTINGS.statuses.find((s) => s.id === id)?.name ??
id
);
}
typeName(id: string): string {
return (
this.settings.issueTypes.find((t) => t.id === id)?.name ??
DEFAULT_PROJECT_SETTINGS.issueTypes.find((t) => t.id === id)?.name ??
id
);
}
areaName(id: string): string {
return (
this.settings.areas.find((a) => a.id === id)?.name ??
DEFAULT_PROJECT_SETTINGS.areas.find((a) => a.id === id)?.name ??
id
);
}
categoryOf(id: string): StatusCategory {
return (
this.status(id)?.category ??
DEFAULT_PROJECT_SETTINGS.statuses.find((s) => s.id === id)?.category ??
StatusCategory.TODO
);
}
isFinished(id: string): boolean {
return FINISHED.includes(this.categoryOf(id));
}
/** Where new tickets start: the first active status of category TODO. */
initialStatus(): string {
const initial = this.settings.statuses.find(
(s) => !s.archived && s.category === StatusCategory.TODO,
);
if (!initial)
throw new BusinessRuleError('The project has no active starting status');
return initial.id;
}
/** Active statuses a ticket can move to from `from`. */
allowedFrom(from: string): string[] {
return (this.settings.transitions[from] ?? []).filter(
(to) => this.status(to) && !this.status(to)?.archived,
);
}
canTransition(from: string, to: string): boolean {
return this.allowedFrom(from).includes(to);
}
requiresReason(id: string): boolean {
return this.status(id)?.requiresReason ?? false;
}
assertActiveType(id: string): void {
const type = this.settings.issueTypes.find((t) => t.id === id);
if (!type) throw new ValidationError(`Unknown ticket type ${id}`);
if (type.archived)
throw new BusinessRuleError(`Ticket type ${type.name} is archived`);
}
assertActiveArea(id: string | null | undefined): void {
if (id === null || id === undefined) return;
const area = this.settings.areas.find((a) => a.id === id);
if (!area) throw new ValidationError(`Unknown area ${id}`);
if (area.archived)
throw new BusinessRuleError(`Area ${area.name} is archived`);
}
}
/**
* Checks and normalises new settings against the previous ones. Values can be
* added, renamed, reordered or archived, never deleted: tickets, history and
* statistics keep referring to them.
*/
export function validateProjectSettings(
next: ProjectSettings,
previous: ProjectSettings,
): ProjectSettings {
const statuses = next.statuses.map((s) => ({
id: normId(s.id),
name: normName(s.name),
category: s.category,
color: s.color,
requiresReason: !!s.requiresReason,
archived: !!s.archived,
}));
const issueTypes = next.issueTypes.map((t) => ({
id: normId(t.id),
name: normName(t.name),
icon: t.icon,
color: t.color,
archived: !!t.archived,
}));
const areas = next.areas.map((a) => ({
id: normId(a.id),
name: normName(a.name),
archived: !!a.archived,
}));
for (const [label, list, before] of [
['estado', statuses, previous.statuses],
['tipo', issueTypes, previous.issueTypes],
['área', areas, previous.areas],
] as const) {
const ids = list.map((item) => item.id);
const duplicate = ids.find((id, i) => ids.indexOf(id) !== i);
assertThat(!duplicate, `Hay dos ${label}s con el id ${duplicate}`);
const removed = before.find((item) => !ids.includes(item.id));
if (removed) {
throw new BusinessRuleError(
`No se puede eliminar el ${label} «${removed.name}»: archívalo para que no se use más`,
);
}
}
for (const s of statuses) {
assertThat(
Object.values(StatusCategory).includes(s.category),
`Categoría no válida en el estado ${s.name}`,
);
assertThat(
SETTING_COLORS.includes(s.color),
`Color no válido en el estado ${s.name}`,
);
}
for (const t of issueTypes) {
assertThat(
TYPE_ICONS.includes(t.icon),
`Icono no válido en el tipo ${t.name}`,
);
assertThat(
SETTING_COLORS.includes(t.color),
`Color no válido en el tipo ${t.name}`,
);
}
const active = statuses.filter((s) => !s.archived);
assertThat(
active.some((s) => s.category === StatusCategory.TODO),
'Hace falta al menos un estado activo «pendiente» donde empiecen los tickets',
);
assertThat(
active.some((s) => s.category === StatusCategory.DONE),
'Hace falta al menos un estado activo «hecho»',
);
assertThat(
issueTypes.some((t) => !t.archived),
'Hace falta al menos un tipo de ticket activo',
);
const known = new Set(statuses.map((s) => s.id));
const transitions: Record<string, string[]> = {};
for (const [from, targets] of Object.entries(next.transitions ?? {})) {
const source = normId(from);
assertThat(
known.has(source),
`Transición desde un estado que no existe: ${from}`,
);
const to = [...new Set(targets.map(normId))];
for (const target of to) {
assertThat(
known.has(target),
`Transición hacia un estado que no existe: ${target}`,
);
assertThat(
target !== source,
`Un estado no puede pasar a sí mismo (${source})`,
);
}
if (to.length) transitions[source] = to;
}
return { statuses, transitions, issueTypes, areas };
}
function normId(id: string): string {
const value = (id ?? '').trim().toUpperCase();
assertThat(
ID.test(value),
`Id no válido «${id}»: mayúsculas, números y _, empezando por letra`,
);
return value;
}
function normName(name: string): string {
const value = (name ?? '').trim();
assertThat(
value.length > 0 && value.length <= 40,
'Los nombres llevan 1-40 caracteres',
);
return value;
}

View File

@@ -8,8 +8,15 @@ import { ProjectCreatedEvent } from '../events/project-created.event';
import { ProjectMemberAddedEvent } from '../events/project-member-added.event'; import { ProjectMemberAddedEvent } from '../events/project-member-added.event';
import { ProjectMemberRemovedEvent } from '../events/project-member-removed.event'; import { ProjectMemberRemovedEvent } from '../events/project-member-removed.event';
import { ProjectMemberRoleChangedEvent } from '../events/project-member-role-changed.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 { ProjectUpdatedEvent } from '../events/project-updated.event';
import { ProjectRole } from './project-role'; import { ProjectRole } from './project-role';
import {
defaultProjectSettings,
ProjectSettings,
ProjectWorkflow,
validateProjectSettings,
} from './project-settings';
export interface ProjectMember { export interface ProjectMember {
userId: string; userId: string;
@@ -24,6 +31,8 @@ export interface ProjectProps {
description: string | null; description: string | null;
ownerId: string; ownerId: string;
members: ProjectMember[]; members: ProjectMember[];
/** Absent in projects created before settings existed: defaults apply. */
settings?: ProjectSettings;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@@ -38,6 +47,7 @@ export class Project extends VersionedAggregateRoot implements ProjectProps {
public description: string | null = null; public description: string | null = null;
public ownerId!: string; public ownerId!: string;
public members: ProjectMember[] = []; public members: ProjectMember[] = [];
public settings: ProjectSettings = defaultProjectSettings();
public createdAt!: string; public createdAt!: string;
public updatedAt!: string; public updatedAt!: string;
@@ -46,6 +56,11 @@ export class Project extends VersionedAggregateRoot implements ProjectProps {
this.id = id; this.id = id;
} }
/** Statuses, transitions, types and areas of this project. */
get workflow(): ProjectWorkflow {
return new ProjectWorkflow(this.settings);
}
roleOf(userId: string): ProjectRole | null { roleOf(userId: string): ProjectRole | null {
return this.members.find((m) => m.userId === userId)?.role ?? 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)); 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 { addMember(actorId: string, userId: string, role: ProjectRole): void {
this.assertCanManage(actorId); this.assertCanManage(actorId);
assertThat(role !== ProjectRole.OWNER, 'A project can only have one owner'); 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.description = project.description;
this.ownerId = project.ownerId; this.ownerId = project.ownerId;
this.members = project.members.map((m) => ({ ...m })); this.members = project.members.map((m) => ({ ...m }));
this.settings = structuredClone(
project.settings ?? defaultProjectSettings(),
);
this.createdAt = project.createdAt; this.createdAt = project.createdAt;
this.updatedAt = project.updatedAt; this.updatedAt = project.updatedAt;
} }
@@ -163,6 +189,11 @@ export class Project extends VersionedAggregateRoot implements ProjectProps {
this.updatedAt = event.occurredAt; this.updatedAt = event.occurredAt;
} }
onProjectSettingsChangedEvent(event: ProjectSettingsChangedEvent): void {
this.settings = structuredClone(event.settings);
this.updatedAt = event.occurredAt;
}
onProjectMemberAddedEvent(event: ProjectMemberAddedEvent): void { onProjectMemberAddedEvent(event: ProjectMemberAddedEvent): void {
this.members = [ this.members = [
...this.members, ...this.members,

View File

@@ -0,0 +1,58 @@
import { StatusCategory } from '../project/project-settings';
import { UserRefReadModel } from './user-ref.read-model';
/**
* One step in a ticket's life (projected from its events): the raw material
* of the statistics. `status` entries record where the ticket went (with the
* category it had then), `release` entries which release it joined or left.
*/
export class IssueTimelineEntry {
/** Id of the event that produced it (projection is idempotent). */
id!: string;
issueId!: string;
projectId!: string;
at!: string;
kind!: 'status' | 'release';
status?: string;
category?: StatusCategory;
/** Assignee when the status changed (throughput per person). */
assigneeId?: string | null;
releaseId?: string | null;
}
export class DurationStats {
count!: number;
averageDays!: number | null;
/** 85th percentile: "85% of tickets take at most this long". */
p85Days!: number | null;
}
/** Statistics of a project over a period. */
export class ProjectAnalyticsReadModel {
from!: string;
to!: string;
/** Per week (Monday): tickets created and tickets finished. */
createdVsResolved!: { week: string; created: number; resolved: number }[];
/** Tickets in each status at the end of every day (cumulative flow). */
cumulativeFlow!: {
dates: string[];
series: { status: string; counts: number[] }[];
};
/** From starting work to done. */
cycleTime!: DurationStats;
/** From creation to done. */
leadTime!: DurationStats;
/** Tickets finished per person and week. */
throughput!: { week: string; user: UserRefReadModel | null; count: number }[];
/** Tickets leaving QA: to done (passed) or back to work (failed). */
qa!: { passed: number; failed: number; failureRate: number | null };
}
/** Scope vs finished tickets of a release, day by day. */
export class ReleaseBurnupReadModel {
releaseId!: string;
targetDate!: string | null;
dates!: string[];
scope!: number[];
done!: number[];
}

View File

@@ -5,3 +5,4 @@ export * from './comment.read-model';
export * from './release.read-model'; export * from './release.read-model';
export * from './activity.read-model'; export * from './activity.read-model';
export * from './project-stats.read-model'; export * from './project-stats.read-model';
export * from './analytics.read-model';

View File

@@ -1,10 +1,5 @@
import { import { IssueLinkType, IssuePriority } from '../issue/issue.enums';
IssueArea, import { StatusCategory } from '../project/project-settings';
IssueLinkType,
IssuePriority,
IssueStatus,
IssueType,
} from '../issue/issue.enums';
import { UserRefReadModel } from './user-ref.read-model'; import { UserRefReadModel } from './user-ref.read-model';
export type LinkDirection = 'OUTWARD' | 'INWARD'; export type LinkDirection = 'OUTWARD' | 'INWARD';
@@ -23,11 +18,14 @@ export class IssueReadModel {
projectKey!: string; projectKey!: string;
number!: number; number!: number;
key!: string; key!: string;
type!: IssueType; /** Ids from the project's settings. */
area!: IssueArea | null; type!: string;
area!: string | null;
title!: string; title!: string;
description!: string | null; description!: string | null;
status!: IssueStatus; status!: string;
/** Category of `status` (kept in sync when the project's settings change). */
statusCategory!: StatusCategory;
priority!: IssuePriority; priority!: IssuePriority;
reporter!: UserRefReadModel; reporter!: UserRefReadModel;
assignee!: UserRefReadModel | null; assignee!: UserRefReadModel | null;

View File

@@ -1,4 +1,4 @@
import { IssuePriority, IssueStatus, IssueType } from '../issue/issue.enums'; import { IssuePriority } from '../issue/issue.enums';
import { UserRefReadModel } from './user-ref.read-model'; import { UserRefReadModel } from './user-ref.read-model';
export class CountBy<TKey = string> { export class CountBy<TKey = string> {
@@ -10,8 +10,9 @@ export class CountBy<TKey = string> {
export class ProjectStatsReadModel { export class ProjectStatsReadModel {
total!: number; total!: number;
open!: number; open!: number;
byStatus!: CountBy<IssueStatus>[]; /** Keys are status / type ids of the project's settings. */
byType!: CountBy<IssueType>[]; byStatus!: CountBy<string>[];
byType!: CountBy<string>[];
byPriority!: CountBy<IssuePriority>[]; byPriority!: CountBy<IssuePriority>[];
byArea!: CountBy<string>[]; byArea!: CountBy<string>[];
/** Open tickets per person (user null = unassigned). */ /** Open tickets per person (user null = unassigned). */

View File

@@ -1,4 +1,5 @@
import { ProjectRole } from '../project/project-role'; import { ProjectRole } from '../project/project-role';
import { ProjectSettings } from '../project/project-settings';
import { UserRefReadModel } from './user-ref.read-model'; import { UserRefReadModel } from './user-ref.read-model';
export class ProjectMemberReadModel { export class ProjectMemberReadModel {
@@ -13,6 +14,8 @@ export class ProjectReadModel {
description!: string | null; description!: string | null;
owner!: UserRefReadModel; owner!: UserRefReadModel;
members!: ProjectMemberReadModel[]; members!: ProjectMemberReadModel[];
/** Statuses, transitions, ticket types and areas. */
settings!: ProjectSettings;
createdAt!: string; createdAt!: string;
updatedAt!: string; updatedAt!: string;
/** Position of the last projected event. */ /** Position of the last projected event. */

View File

@@ -1,4 +1,3 @@
import { IssueStatus } from '../issue/issue.enums';
import { ReleaseStatus } from '../release/release-status'; import { ReleaseStatus } from '../release/release-status';
export class ReleaseReadModel { export class ReleaseReadModel {
@@ -17,5 +16,5 @@ export class ReleaseProgressReadModel {
total!: number; total!: number;
finished!: number; finished!: number;
percent!: number; percent!: number;
byStatus!: { key: IssueStatus; count: number }[]; byStatus!: { key: string; count: number }[];
} }

View File

@@ -8,7 +8,9 @@ Adaptadores de salida del tracker.
## Contenido ## Contenido
- `postgres/`: lookups de escritura (`project_keys`, `issue_sequences`, `release_names`) y `ReleaseIssuesRepository` (consulta el event store). - `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). - `realtime/`: `RedisChangeFeed` (canales de Redis pub/sub por proyecto, ticket y usuario).
- `identity/`: `IdentityUserDirectory`, capa anticorrupción hacia el contexto de identidad. - `identity/`: `IdentityUserDirectory`, capa anticorrupción hacia el contexto de identidad.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,9 @@ API GraphQL y REST (OpenAPI) del tracker.
- Resolvers `ProjectResolver`, `IssueResolver`, `CommentResolver`, `ReleaseResolver` y `SubscriptionResolver`; solo llaman a services. - Resolvers `ProjectResolver`, `IssueResolver`, `CommentResolver`, `ReleaseResolver` y `SubscriptionResolver`; solo llaman a services.
- Tipos, inputs (con `class-validator`) y enums de GraphQL (code-first). - Tipos, inputs (con `class-validator`) y enums de GraphQL (code-first).
- `http/`: controladores REST `ProjectsController`, `IssuesController`, `CommentsController`, `ReleasesController` - `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 Operaciones disponibles: [GraphQL](../../../../docs/03-api.md#32-graphql) y
[REST](../../../../docs/03-api.md#33-rest-con-openapi-para-otras-apps). [REST](../../../../docs/03-api.md#33-rest-con-openapi-para-otras-apps).

View File

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

View File

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

View File

@@ -1,12 +1,10 @@
import { registerEnumType } from '@nestjs/graphql'; import { registerEnumType } from '@nestjs/graphql';
import { import {
IssueArea,
IssueLinkType, IssueLinkType,
IssuePriority, IssuePriority,
IssueStatus,
IssueType,
ProjectRole, ProjectRole,
ReleaseStatus, ReleaseStatus,
StatusCategory,
} from '@task-stream/api/tracker/domain'; } from '@task-stream/api/tracker/domain';
export enum LinkDirection { export enum LinkDirection {
@@ -14,13 +12,12 @@ export enum LinkDirection {
INWARD = 'INWARD', INWARD = 'INWARD',
} }
registerEnumType(IssueType, { name: 'IssueType' });
registerEnumType(IssueArea, {
name: 'IssueArea',
description: 'Team/layer: links FE ↔ BE ↔ QA work',
});
registerEnumType(IssuePriority, { name: 'IssuePriority' }); 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(IssueLinkType, { name: 'IssueLinkType' });
registerEnumType(ProjectRole, { name: 'ProjectRole' }); registerEnumType(ProjectRole, { name: 'ProjectRole' });
registerEnumType(ReleaseStatus, { name: 'ReleaseStatus' }); registerEnumType(ReleaseStatus, { name: 'ReleaseStatus' });

View File

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

View File

@@ -13,11 +13,11 @@ import { CurrentUser } from '@task-stream/api/identity/presenters';
import { import {
CommentsService, CommentsService,
IssuesService, IssuesService,
WorkflowsService,
} from '@task-stream/api/tracker/application'; } from '@task-stream/api/tracker/application';
import { import {
IssueReadModel, IssueReadModel,
IssueStatus, StatusCategory,
IssueWorkflow,
} from '@task-stream/api/tracker/domain'; } from '@task-stream/api/tracker/domain';
import { import {
ChangeIssueStatusInput, ChangeIssueStatusInput,
@@ -40,6 +40,7 @@ export class IssueResolver {
constructor( constructor(
private readonly issuesService: IssuesService, private readonly issuesService: IssuesService,
private readonly commentsService: CommentsService, private readonly commentsService: CommentsService,
private readonly workflows: WorkflowsService,
) {} ) {}
@Query(() => IssueGql) @Query(() => IssueGql)
@@ -61,6 +62,29 @@ export class IssueResolver {
return this.issuesService.search(me.id, { ...filter, limit, offset }); 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], { @Query(() => [ActivityGql], {
description: 'Project activity feed (newest first)', description: 'Project activity feed (newest first)',
}) })
@@ -80,11 +104,11 @@ export class IssueResolver {
}); });
} }
@ResolveField(() => [IssueStatus], { @ResolveField(() => [String], {
description: 'Statuses this ticket can move to next', description: "Statuses this ticket can move to next (project's workflow)",
}) })
allowedTransitions(@Parent() issue: IssueReadModel): readonly IssueStatus[] { allowedTransitions(@Parent() issue: IssueReadModel): Promise<string[]> {
return IssueWorkflow.allowedFrom(issue.status); return this.workflows.allowedTransitions(issue);
} }
@ResolveField(() => [IssueGql], { description: 'Linked tickets I can see' }) @ResolveField(() => [IssueGql], { description: 'Linked tickets I can see' })

View File

@@ -11,16 +11,20 @@ import {
import { User } from '@task-stream/api/identity/domain'; import { User } from '@task-stream/api/identity/domain';
import { AccessToken, CurrentUser } from '@task-stream/api/identity/presenters'; import { AccessToken, CurrentUser } from '@task-stream/api/identity/presenters';
import { ProjectsService } from '@task-stream/api/tracker/application'; 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 { import {
CreateProjectInput, CreateProjectInput,
ProjectMemberInput, ProjectMemberInput,
ProjectSettingsInput,
UpdateProjectInput, UpdateProjectInput,
} from './inputs'; } from './inputs';
import { import {
BoardColumnGql, BoardColumnGql,
CommandResultGql, CommandResultGql,
ProjectAnalyticsGql,
ProjectGql, ProjectGql,
ProjectSettingsGql,
ProjectStatsGql, ProjectStatsGql,
ReleaseGql, ReleaseGql,
} from './types'; } from './types';
@@ -43,6 +47,23 @@ export class ProjectResolver {
return this.projects.findOne(me.id, idOrKey); 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]) @ResolveField(() => [ReleaseGql])
releases(@CurrentUser() me: User, @Parent() project: ProjectReadModel) { releases(@CurrentUser() me: User, @Parent() project: ProjectReadModel) {
return this.projects.releases(me.id, project.id); return this.projects.releases(me.id, project.id);
@@ -63,7 +84,7 @@ export class ProjectResolver {
@Parent() project: ProjectReadModel, @Parent() project: ProjectReadModel,
@Args('releaseId', { type: () => ID, nullable: true }) releaseId?: string, @Args('releaseId', { type: () => ID, nullable: true }) releaseId?: string,
@Args('assigneeId', { type: () => ID, nullable: true }) assigneeId?: 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, @Args('perColumn', { type: () => Int, defaultValue: 50 }) perColumn = 50,
) { ) {
return this.projects.board( return this.projects.board(
@@ -95,6 +116,22 @@ export class ProjectResolver {
return this.projects.update(me.id, projectId, changes); 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, { @Mutation(() => CommandResultGql, {
description: 'Add a Pireon user to the project', description: 'Add a Pireon user to the project',
}) })

View File

@@ -19,6 +19,7 @@ import { CreateReleaseInput, UpdateReleaseInput } from './inputs';
import { import {
CommandResultGql, CommandResultGql,
IssuePageGql, IssuePageGql,
ReleaseBurnupGql,
ReleaseGql, ReleaseGql,
ReleaseProgressGql, ReleaseProgressGql,
} from './types'; } from './types';
@@ -35,11 +36,29 @@ export class ReleaseResolver {
return this.releases.findOne(me.id, id); 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) @ResolveField(() => ReleaseProgressGql)
progress(@CurrentUser() me: User, @Parent() release: ReleaseReadModel) { progress(@CurrentUser() me: User, @Parent() release: ReleaseReadModel) {
return this.releases.progress(me.id, release.id); 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, { @ResolveField(() => IssuePageGql, {
description: 'Tickets included in this release', description: 'Tickets included in this release',
}) })

View File

@@ -1,12 +1,10 @@
import { Field, ID, Int, ObjectType } from '@nestjs/graphql'; import { Field, Float, ID, Int, ObjectType } from '@nestjs/graphql';
import { import {
IssueArea,
IssueLinkType, IssueLinkType,
IssuePriority, IssuePriority,
IssueStatus,
IssueType,
ProjectRole, ProjectRole,
ReleaseStatus, ReleaseStatus,
StatusCategory,
} from '@task-stream/api/tracker/domain'; } from '@task-stream/api/tracker/domain';
import { LinkDirection } from './enums'; import { LinkDirection } from './enums';
@@ -22,6 +20,51 @@ export class ProjectMemberGql {
@Field(() => ProjectRole) role!: ProjectRole; @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') @ObjectType('Project')
export class ProjectGql { export class ProjectGql {
@Field(() => ID) id!: string; @Field(() => ID) id!: string;
@@ -59,11 +102,15 @@ export class IssueGql {
@Field() projectKey!: string; @Field() projectKey!: string;
@Field(() => Int) number!: number; @Field(() => Int) number!: number;
@Field({ description: 'Human key, e.g. TS-42' }) key!: string; @Field({ description: 'Human key, e.g. TS-42' }) key!: string;
@Field(() => IssueType) type!: IssueType; @Field({ description: "A ticket type id of the project's settings" })
@Field(() => IssueArea, { nullable: true }) area!: IssueArea | null; type!: string;
@Field(() => String, { nullable: true, description: 'An area id' })
area!: string | null;
@Field() title!: string; @Field() title!: string;
@Field(() => String, { nullable: true }) description!: string | null; @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(() => IssuePriority) priority!: IssuePriority;
@Field(() => UserRefGql) reporter!: UserRefGql; @Field(() => UserRefGql) reporter!: UserRefGql;
@Field(() => UserRefGql, { nullable: true }) assignee!: UserRefGql | null; @Field(() => UserRefGql, { nullable: true }) assignee!: UserRefGql | null;
@@ -88,7 +135,7 @@ export class IssuePageGql {
@ObjectType('BoardColumn') @ObjectType('BoardColumn')
export class BoardColumnGql { export class BoardColumnGql {
@Field(() => IssueStatus) status!: IssueStatus; @Field() status!: string;
@Field(() => [IssueGql]) issues!: IssueGql[]; @Field(() => [IssueGql]) issues!: IssueGql[];
@Field(() => Int) total!: number; @Field(() => Int) total!: number;
} }
@@ -125,7 +172,7 @@ export class ReleaseGql {
@ObjectType('CountByStatus') @ObjectType('CountByStatus')
export class CountByStatusGql { export class CountByStatusGql {
@Field(() => IssueStatus) key!: IssueStatus; @Field({ description: 'Status id' }) key!: string;
@Field(() => Int) count!: number; @Field(() => Int) count!: number;
} }
@@ -221,3 +268,77 @@ export class ProjectChangeGql {
@Field(() => ID) actorId!: string; @Field(() => ID) actorId!: string;
@Field(() => ProjectGql) project!: ProjectGql; @Field(() => ProjectGql) project!: ProjectGql;
} }
/* Statistics */
@ObjectType('CreatedResolved')
export class CreatedResolvedGql {
@Field({ description: 'Monday of the week (YYYY-MM-DD)' }) week!: string;
@Field(() => Int) created!: number;
@Field(() => Int) resolved!: number;
}
@ObjectType('FlowSeries')
export class FlowSeriesGql {
@Field() status!: string;
@Field(() => [Int]) counts!: number[];
}
@ObjectType('CumulativeFlow')
export class CumulativeFlowGql {
@Field(() => [String]) dates!: string[];
@Field(() => [FlowSeriesGql]) series!: FlowSeriesGql[];
}
@ObjectType('DurationStats')
export class DurationStatsGql {
@Field(() => Int) count!: number;
@Field(() => Float, { nullable: true }) averageDays!: number | null;
@Field(() => Float, {
nullable: true,
description: '85% of tickets take at most this long',
})
p85Days!: number | null;
}
@ObjectType('Throughput')
export class ThroughputGql {
@Field() week!: string;
@Field(() => UserRefGql, { nullable: true }) user!: UserRefGql | null;
@Field(() => Int) count!: number;
}
@ObjectType('QaStats')
export class QaStatsGql {
@Field(() => Int) passed!: number;
@Field(() => Int) failed!: number;
@Field(() => Float, { nullable: true }) failureRate!: number | null;
}
@ObjectType('ProjectAnalytics')
export class ProjectAnalyticsGql {
@Field() from!: string;
@Field() to!: string;
@Field(() => [CreatedResolvedGql]) createdVsResolved!: CreatedResolvedGql[];
@Field(() => CumulativeFlowGql, {
description: 'Tickets in each status at the end of every day',
})
cumulativeFlow!: CumulativeFlowGql;
@Field(() => DurationStatsGql, { description: 'Start of work → done' })
cycleTime!: DurationStatsGql;
@Field(() => DurationStatsGql, { description: 'Creation → done' })
leadTime!: DurationStatsGql;
@Field(() => [ThroughputGql], { description: 'Finished per person and week' })
throughput!: ThroughputGql[];
@Field(() => QaStatsGql) qa!: QaStatsGql;
}
@ObjectType('ReleaseBurnup')
export class ReleaseBurnupGql {
@Field(() => ID) releaseId!: string;
@Field(() => String, { nullable: true }) targetDate!: string | null;
@Field(() => [String]) dates!: string[];
@Field(() => [Int], { description: 'Tickets in the release (not cancelled)' })
scope!: number[];
@Field(() => [Int]) done!: number[];
}

View File

@@ -5,16 +5,17 @@ import {
PartialType, PartialType,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { import {
IssueArea,
IssueLinkType, IssueLinkType,
IssuePriority, IssuePriority,
IssueStatus,
IssueType,
ProjectRole, ProjectRole,
SETTING_COLORS,
StatusCategory,
TYPE_ICONS,
} from '@task-stream/api/tracker/domain'; } from '@task-stream/api/tracker/domain';
import { Transform, Type } from 'class-transformer'; import { Transform, Type } from 'class-transformer';
import { import {
ArrayMaxSize, ArrayMaxSize,
IsArray,
IsBoolean, IsBoolean,
IsEnum, IsEnum,
IsIn, IsIn,
@@ -29,6 +30,7 @@ import {
Min, Min,
MinLength, MinLength,
ValidateIf, ValidateIf,
ValidateNested,
} from 'class-validator'; } from 'class-validator';
/* REST request bodies and query strings, validated like the GraphQL inputs. */ /* 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 }) => const toBoolean = ({ value }: { value: unknown }) =>
value === undefined ? undefined : value === true || value === 'true'; 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 --------------------------------------------------------------- // ---- Projects ---------------------------------------------------------------
export class CreateProjectDto { export class CreateProjectDto {
@@ -89,6 +94,121 @@ export class AddMemberDto {
role: ProjectRole = ProjectRole.MEMBER; 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 { export class ChangeMemberRoleDto {
@ApiProperty({ enum: ProjectRole, enumName: 'ProjectRole' }) @ApiProperty({ enum: ProjectRole, enumName: 'ProjectRole' })
@IsEnum(ProjectRole) @IsEnum(ProjectRole)
@@ -106,10 +226,10 @@ export class BoardQueryDto {
@IsString() @IsString()
assigneeId?: string; assigneeId?: string;
@ApiPropertyOptional({ enum: IssueArea, enumName: 'IssueArea' }) @ApiPropertyOptional({ example: 'FRONTEND', description: 'An area id' })
@IsOptional() @IsOptional()
@IsEnum(IssueArea) @Matches(SETTING_ID)
area?: IssueArea; area?: string;
@ApiPropertyOptional({ default: 50, minimum: 1, maximum: 200 }) @ApiPropertyOptional({ default: 50, minimum: 1, maximum: 200 })
@IsOptional() @IsOptional()
@@ -162,37 +282,79 @@ export class PageQueryDto {
// ---- Issues ----------------------------------------------------------------- // ---- 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 { export class IssueSearchQueryDto extends PageQueryDto {
@ApiPropertyOptional({ @ApiPropertyOptional({
enum: IssueStatus, type: [String],
enumName: 'IssueStatus', description: 'Status ids. Repeat the parameter or separate with commas',
isArray: true,
description: 'Repeat the parameter or separate with commas',
}) })
@IsOptional() @IsOptional()
@Transform(toList) @Transform(toList)
@IsEnum(IssueStatus, { each: true }) @Matches(SETTING_ID, { each: true })
status?: IssueStatus[]; status?: string[];
@ApiPropertyOptional({ @ApiPropertyOptional({
enum: IssueType, enum: StatusCategory,
enumName: 'IssueType', enumName: 'StatusCategory',
isArray: true, isArray: true,
}) })
@IsOptional() @IsOptional()
@Transform(toList) @Transform(toList)
@IsEnum(IssueType, { each: true }) @IsEnum(StatusCategory, { each: true })
type?: IssueType[]; category?: StatusCategory[];
@ApiPropertyOptional({ @ApiPropertyOptional({ type: [String], description: 'Ticket type ids' })
enum: IssueArea,
enumName: 'IssueArea',
isArray: true,
})
@IsOptional() @IsOptional()
@Transform(toList) @Transform(toList)
@IsEnum(IssueArea, { each: true }) @Matches(SETTING_ID, { each: true })
area?: IssueArea[]; type?: string[];
@ApiPropertyOptional({ type: [String], description: 'Area ids' })
@IsOptional()
@Transform(toList)
@Matches(SETTING_ID, { each: true })
area?: string[];
@ApiPropertyOptional({ @ApiPropertyOptional({
enum: IssuePriority, enum: IssuePriority,
@@ -241,13 +403,14 @@ class IssueFieldsDto {
description?: string | null; description?: string | null;
@ApiPropertyOptional({ @ApiPropertyOptional({
enum: IssueArea,
type: String, type: String,
nullable: true, nullable: true,
example: 'FRONTEND',
description: "An area id of the project's settings",
}) })
@IsOptional() @IsOptional()
@IsEnum(IssueArea) @Matches(SETTING_ID)
area?: IssueArea | null; area?: string | null;
@ApiPropertyOptional({ enum: IssuePriority, enumName: 'IssuePriority' }) @ApiPropertyOptional({ enum: IssuePriority, enumName: 'IssuePriority' })
@IsOptional() @IsOptional()
@@ -289,9 +452,12 @@ class IssueFieldsDto {
} }
export class CreateIssueDto extends IssueFieldsDto { export class CreateIssueDto extends IssueFieldsDto {
@ApiProperty({ enum: IssueType, enumName: 'IssueType' }) @ApiProperty({
@IsEnum(IssueType) example: 'BUG',
type!: IssueType; description: "A ticket type id of the project's settings",
})
@Matches(SETTING_ID)
type!: string;
@ApiProperty({ @ApiProperty({
example: 'El botón de pagar no responde en Safari', example: 'El botón de pagar no responde en Safari',
@@ -318,10 +484,10 @@ export class CreateIssueDto extends IssueFieldsDto {
} }
export class UpdateIssueDto extends IssueFieldsDto { export class UpdateIssueDto extends IssueFieldsDto {
@ApiPropertyOptional({ enum: IssueType, enumName: 'IssueType' }) @ApiPropertyOptional({ example: 'FEATURE', description: 'A ticket type id' })
@IsOptional() @IsOptional()
@IsEnum(IssueType) @Matches(SETTING_ID)
type?: IssueType; type?: string;
@ApiPropertyOptional({ maxLength: 255 }) @ApiPropertyOptional({ maxLength: 255 })
@IsOptional() @IsOptional()
@@ -332,14 +498,18 @@ export class UpdateIssueDto extends IssueFieldsDto {
} }
export class ChangeStatusDto { export class ChangeStatusDto {
@ApiProperty({ enum: IssueStatus, enumName: 'IssueStatus' }) @ApiProperty({
@IsEnum(IssueStatus) example: 'IN_REVIEW',
status!: IssueStatus; description:
"A status id of the project's settings, among the ticket's allowedTransitions",
})
@Matches(SETTING_ID)
status!: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
type: String, type: String,
nullable: true, nullable: true,
description: 'Required for QA_FAILED and CANCELLED', description: 'Required by statuses with requiresReason (e.g. QA_FAILED)',
}) })
@IsOptional() @IsOptional()
@IsString() @IsString()

View File

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

View File

@@ -39,6 +39,7 @@ import {
IssueSearchQueryDto, IssueSearchQueryDto,
LinkIssueDto, LinkIssueDto,
LinkTypeParamDto, LinkTypeParamDto,
MyIssuesQueryDto,
PageQueryDto, PageQueryDto,
SetIssueReleaseDto, SetIssueReleaseDto,
UpdateIssueDto, UpdateIssueDto,
@@ -50,7 +51,7 @@ import {
IssueDto, IssueDto,
IssuePageDto, IssuePageDto,
} from './dto/responses.dto'; } from './dto/responses.dto';
import { Refs, toIssueDto } from './refs'; import { Refs } from './refs';
const PROJECT = ApiParam({ const PROJECT = ApiParam({
name: 'project', name: 'project',
@@ -74,6 +75,25 @@ export class IssuesController {
private readonly refs: Refs, 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') @Get('projects/:project/issues')
@PROJECT @PROJECT
@ApiOperation({ summary: 'Search the tickets of a project' }) @ApiOperation({ summary: 'Search the tickets of a project' })
@@ -87,6 +107,7 @@ export class IssuesController {
const page = await this.issues.search(me.id, { const page = await this.issues.search(me.id, {
projectId: await this.refs.projectId(me.id, project), projectId: await this.refs.projectId(me.id, project),
statuses: query.status, statuses: query.status,
categories: query.category,
types: query.type, types: query.type,
areas: query.area, areas: query.area,
priorities: query.priority, priorities: query.priority,
@@ -98,7 +119,7 @@ export class IssuesController {
limit: query.limit, limit: query.limit,
offset: query.offset, offset: query.offset,
}); });
return { ...page, items: page.items.map(toIssueDto) }; return { ...page, items: await this.refs.issueDtos(page.items) };
} }
@Post('projects/:project/issues') @Post('projects/:project/issues')
@@ -106,7 +127,7 @@ export class IssuesController {
@ApiOperation({ @ApiOperation({
summary: 'Create a ticket', summary: 'Create a ticket',
description: 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 }) @ApiCreatedResponse({ type: CommandResultDto })
@ApiCommandErrors() @ApiCommandErrors()
@@ -127,7 +148,7 @@ export class IssuesController {
@ApiOkResponse({ type: IssueDto }) @ApiOkResponse({ type: IssueDto })
@ApiReadErrors() @ApiReadErrors()
async findOne(@CurrentUser() me: User, @Param('issue') issue: string) { 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') @Patch('issues/:issue')

View File

@@ -6,6 +6,7 @@ import {
Param, Param,
Patch, Patch,
Post, Post,
Put,
Query, Query,
} from '@nestjs/common'; } from '@nestjs/common';
import { import {
@@ -32,19 +33,23 @@ import {
import { import {
ActivityQueryDto, ActivityQueryDto,
AddMemberDto, AddMemberDto,
AnalyticsQueryDto,
BoardQueryDto, BoardQueryDto,
ChangeMemberRoleDto, ChangeMemberRoleDto,
CreateProjectDto, CreateProjectDto,
ProjectSettingsBodyDto,
UpdateProjectDto, UpdateProjectDto,
} from './dto/requests.dto'; } from './dto/requests.dto';
import { import {
ActivityDto, ActivityDto,
BoardColumnDto, BoardColumnDto,
CommandResultDto, CommandResultDto,
ProjectAnalyticsDto,
ProjectDto, ProjectDto,
ProjectStatsDto, ProjectStatsDto,
} from './dto/responses.dto'; } from './dto/responses.dto';
import { Refs, toIssueDto } from './refs'; import { fromSettingsPayload } from '../common/settings';
import { Refs, toProjectDto } from './refs';
const PROJECT = ApiParam({ const PROJECT = ApiParam({
name: 'project', name: 'project',
@@ -67,8 +72,8 @@ export class ProjectsController {
@ApiOperation({ summary: 'Projects where I am a member' }) @ApiOperation({ summary: 'Projects where I am a member' })
@ApiOkResponse({ type: [ProjectDto] }) @ApiOkResponse({ type: [ProjectDto] })
@ApiReadErrors() @ApiReadErrors()
mine(@CurrentUser() me: User) { async mine(@CurrentUser() me: User) {
return this.projects.findMine(me.id); return (await this.projects.findMine(me.id)).map(toProjectDto);
} }
@Post() @Post()
@@ -81,11 +86,11 @@ export class ProjectsController {
@Get(':project') @Get(':project')
@PROJECT @PROJECT
@ApiOperation({ summary: 'A project with its members' }) @ApiOperation({ summary: 'A project with its members and settings' })
@ApiOkResponse({ type: ProjectDto }) @ApiOkResponse({ type: ProjectDto })
@ApiReadErrors() @ApiReadErrors()
findOne(@CurrentUser() me: User, @Param('project') project: string) { async findOne(@CurrentUser() me: User, @Param('project') project: string) {
return this.projects.findOne(me.id, project); return toProjectDto(await this.projects.findOne(me.id, project));
} }
@Patch(':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') @Get(':project/board')
@PROJECT @PROJECT
@ApiOperation({ summary: 'Kanban board: one column per status' }) @ApiOperation({ summary: 'Kanban board: one column per status' })
@@ -125,10 +152,12 @@ export class ProjectsController {
}, },
query.perColumn, query.perColumn,
); );
return columns.map((column) => ({ return Promise.all(
columns.map(async (column) => ({
...column, ...column,
issues: column.issues.map(toIssueDto), issues: await this.refs.issueDtos(column.issues),
})); })),
);
} }
@Get(':project/stats') @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') @Get(':project/activity')
@PROJECT @PROJECT
@ApiOperation({ summary: 'Activity feed (newest first)' }) @ApiOperation({ summary: 'Activity feed (newest first)' })

View File

@@ -2,8 +2,13 @@ import { Injectable } from '@nestjs/common';
import { import {
IssuesService, IssuesService,
ProjectsService, ProjectsService,
WorkflowsService,
} from '@task-stream/api/tracker/application'; } 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; 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( constructor(
private readonly projects: ProjectsService, private readonly projects: ProjectsService,
private readonly issues: IssuesService, private readonly issues: IssuesService,
private readonly workflows: WorkflowsService,
) {} ) {}
async projectId(viewerId: string, ref: string): Promise<string> { async projectId(viewerId: string, ref: string): Promise<string> {
@@ -28,12 +34,21 @@ export class Refs {
if (UUID.test(ref)) return ref; if (UUID.test(ref)) return ref;
return (await this.issues.findOne(viewerId, ref)).id; return (await this.issues.findOne(viewerId, ref)).id;
} }
}
/** The ticket as the REST API returns it: read model + allowed transitions. */ /** The ticket as the REST API returns it: read model + allowed transitions. */
export function toIssueDto(issue: IssueReadModel) { async issueDto(issue: IssueReadModel) {
return { return {
...issue, ...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) };
}

View File

@@ -33,15 +33,17 @@ import {
CreateReleaseDto, CreateReleaseDto,
PageQueryDto, PageQueryDto,
PublishReleaseDto, PublishReleaseDto,
UpcomingReleasesQueryDto,
UpdateReleaseDto, UpdateReleaseDto,
} from './dto/requests.dto'; } from './dto/requests.dto';
import { import {
CommandResultDto, CommandResultDto,
IssuePageDto, IssuePageDto,
ReleaseDto, ReleaseDto,
ReleaseBurnupDto,
ReleaseProgressDto, ReleaseProgressDto,
} from './dto/responses.dto'; } from './dto/responses.dto';
import { Refs, toIssueDto } from './refs'; import { Refs } from './refs';
const PROJECT = ApiParam({ const PROJECT = ApiParam({
name: 'project', name: 'project',
@@ -61,6 +63,16 @@ export class ReleasesController {
private readonly refs: Refs, 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') @Get('projects/:project/releases')
@PROJECT @PROJECT
@ApiOperation({ summary: 'Releases of a project' }) @ApiOperation({ summary: 'Releases of a project' })
@@ -117,6 +129,16 @@ export class ReleasesController {
return this.releases.progress(me.id, releaseId); 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') @Get('releases/:releaseId/issues')
@ApiOperation({ summary: 'Tickets included in the release' }) @ApiOperation({ summary: 'Tickets included in the release' })
@ApiOkResponse({ type: IssuePageDto }) @ApiOkResponse({ type: IssuePageDto })
@@ -133,7 +155,7 @@ export class ReleasesController {
limit: page.limit, limit: page.limit,
offset: page.offset, offset: page.offset,
}); });
return { ...found, items: found.items.map(toIssueDto) }; return { ...found, items: await this.refs.issueDtos(found.items) };
} }
@Post('releases/:releaseId/publish') @Post('releases/:releaseId/publish')

View File

@@ -7,9 +7,11 @@ Estado del tracker (NgRx Signals con eventos) detrás de facades.
## Contenido ## 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`. `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`). (`*PageEvents`, `*ApiEvents`, `*RealtimeEvents`).
- `board.reducers.ts`: transformaciones puras del tablero (movimiento optimista, cambios en vivo por versión). - `board.reducers.ts`: transformaciones puras del tablero (movimiento optimista, cambios en vivo por versión).
- Puerto `Notifications`. - Puerto `Notifications`.

View File

@@ -3,3 +3,4 @@ export * from './lib/facades/projects.facade';
export * from './lib/facades/project.facade'; export * from './lib/facades/project.facade';
export * from './lib/facades/board.facade'; export * from './lib/facades/board.facade';
export * from './lib/facades/issue.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