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/issues/{issue}/status',
'/api/releases/{releaseId}/publish',
'/api/projects/{project}/settings',
'/api/projects/{project}/analytics',
'/api/releases/{releaseId}/burnup',
'/api/users/me',
]),
);
@@ -552,9 +555,323 @@ describe('task-stream (e2e)', () => {
);
expect(comments.body['items'][0]).toMatchObject({ author: { id: '103' } });
// --- My work across projects ---
const mine = await eventually(
() => rest('token-qa', 'GET', '/me/issues'),
(r) => r.body?.['items']?.some((i: { key: string }) => i.key === beKey),
);
// Created by Quinn but assigned to nobody: not in "assigned to me".
expect(mine.body['items'].map((i: { key: string }) => i.key)).not.toContain(
qaKey,
);
const todo = await rest(
'token-ana',
'GET',
'/me/issues?assignedToMe=false&status=TODO',
);
expect(todo.body['items'].map((i: { key: string }) => i.key)).toContain(
beKey,
);
const viaGraphql = await gql(
'token-qa',
`{ myIssues { total items { key assignee { id } } } }`,
);
expect(viaGraphql['myIssues'].items).toEqual(
expect.arrayContaining([{ key: beKey, assignee: { id: '103' } }]),
);
await rest('token-ana', 'POST', `/projects/${key}/releases`, {
name: 'v9.9.9',
targetDate: '2099-01-01',
});
await eventually(
() => rest('token-ana', 'GET', '/me/releases?limit=50'),
(r) =>
Array.isArray(r.body) &&
r.body.some((rel: { name: string }) => rel.name === 'v9.9.9'),
);
// Not a member of the project: none of its tickets.
const outsider = await rest('token-eve', 'GET', '/me/issues');
expect(
outsider.body['items'].map((i: { key: string }) => i.key),
).not.toContain(beKey);
// --- Same authorisation as GraphQL ---
expect((await rest('token-eve', 'GET', `/issues/${beKey}`)).status).toBe(
404,
);
});
it('lets each project customise its workflow, types and areas, and measures it', async () => {
const key = 'S' + Date.now().toString(36).slice(-5).toUpperCase();
const projectId = (
await gql(
'token-ana',
`mutation($i: CreateProjectInput!) { createProject(input: $i) { id } }`,
{ i: { key, name: 'Custom workflow' } },
)
).createProject.id;
await eventually(
() =>
gql('token-ana', `query($k: String!) { project(idOrKey: $k) { id } }`, {
k: key,
}),
(d) => !!d.project,
);
await gql(
'token-ana',
`mutation($i: ProjectMemberInput!) { addProjectMember(input: $i) { version } }`,
{ i: { projectId, userId: '102', role: 'MEMBER' } },
);
// --- Every project starts with the default settings ---
const settingsQuery = `query($k: String!) { project(idOrKey: $k) { settings {
statuses { id name category color requiresReason archived }
transitions { from to }
issueTypes { id name icon color archived }
areas { id name archived }
} } }`;
const { settings } = (await gql('token-ana', settingsQuery, { k: key }))
.project;
expect(settings.statuses[0]).toEqual({
id: 'BACKLOG',
name: 'Backlog',
category: 'TODO',
color: 'stone',
requiresReason: false,
archived: false,
});
expect(settings.areas.map((a: { id: string }) => a.id)).toContain('MOBILE');
// --- A staging step, a Spike type, a Data area; Mobile archived ---
type Item = { id: string; archived?: boolean };
const reviewAt = settings.statuses.findIndex(
(s: Item) => s.id === 'IN_REVIEW',
);
settings.statuses.splice(reviewAt + 1, 0, {
id: 'STAGING',
name: 'En staging',
category: 'QA',
color: 'teal',
requiresReason: false,
archived: false,
});
settings.transitions = settings.transitions.map(
(t: { from: string; to: string[] }) =>
t.from === 'IN_REVIEW'
? { from: 'IN_REVIEW', to: ['STAGING', 'IN_PROGRESS'] }
: t,
);
settings.transitions.push({ from: 'STAGING', to: ['DONE', 'IN_PROGRESS'] });
settings.issueTypes.push({
id: 'SPIKE',
name: 'Spike',
icon: 'light-bulb',
color: 'teal',
archived: false,
});
settings.areas = settings.areas.map((a: Item) =>
a.id === 'MOBILE' ? { ...a, archived: true } : a,
);
settings.areas.push({ id: 'DATA', name: 'Datos', archived: false });
const save = (token: string, next: object) =>
gql(
token,
`mutation($p: ID!, $s: ProjectSettingsInput!) { updateProjectSettings(projectId: $p, settings: $s) { version } }`,
{ p: projectId, s: next },
);
await expect(save('token-bob', settings)).rejects.toThrow(/owner|admin/i);
await expect(
save('token-ana', {
...settings,
areas: settings.areas.filter((a: Item) => a.id !== 'QA'),
}),
).rejects.toThrow(/archívalo/);
await save('token-ana', settings);
await eventually(
() => gql('token-ana', settingsQuery, { k: key }),
(d) => d.project.settings.statuses.some((s: Item) => s.id === 'STAGING'),
);
// --- Tickets follow the project's settings ---
const create = `mutation($i: CreateIssueInput!) { createIssue(input: $i) { id key } }`;
await expect(
gql('token-bob', create, {
i: { projectId, type: 'EPIC', title: 'Unknown type' },
}),
).rejects.toThrow(/Unknown ticket type/);
await expect(
gql('token-bob', create, {
i: { projectId, type: 'TASK', title: 'Old area', area: 'MOBILE' },
}),
).rejects.toThrow(/archived/);
const spike = (
await gql('token-bob', create, {
i: {
projectId,
type: 'SPIKE',
title: 'Evaluate a column store',
area: 'DATA',
assigneeId: '102',
},
})
).createIssue;
const release = (
await gql(
'token-ana',
`mutation($i: CreateReleaseInput!) { createRelease(input: $i) { id } }`,
{ i: { projectId, name: 'v1.0.0' } },
)
).createRelease;
await eventually(
() =>
gql('token-ana', `query($id: ID!) { release(id: $id) { id } }`, {
id: release.id,
}),
(d) => !!d.release,
);
await gql(
'token-ana',
`mutation($i: ID!, $r: ID) { setIssueRelease(issueId: $i, releaseId: $r) { version } }`,
{ i: spike.id, r: release.id },
);
const move = (status: string) =>
gql(
'token-bob',
`mutation($i: ChangeIssueStatusInput!) { changeIssueStatus(input: $i) { version } }`,
{ i: { issueId: spike.id, status } },
);
for (const status of ['TODO', 'IN_PROGRESS', 'IN_REVIEW'])
await move(status);
const inReview = await eventually(
() =>
gql(
'token-bob',
`query($k: String!) { issue(idOrKey: $k) { status statusCategory allowedTransitions } }`,
{ k: spike.key },
),
(d) => d.issue.status === 'IN_REVIEW',
);
expect(inReview.issue).toEqual({
status: 'IN_REVIEW',
statusCategory: 'IN_PROGRESS',
allowedTransitions: ['STAGING', 'IN_PROGRESS'],
});
await expect(move('READY_FOR_QA')).rejects.toThrow(/Cannot move/);
await move('STAGING');
await move('DONE');
// --- Board, history, releases and statistics use the categories ---
const { project } = await eventually(
() =>
gql(
'token-ana',
`query($k: String!) { project(idOrKey: $k) {
board { status total }
stats { total open }
releases { progress { finished percent } burnup { scope done } }
analytics(days: 30) {
createdVsResolved { created resolved }
cumulativeFlow { series { status counts } }
cycleTime { count }
leadTime { count }
throughput { user { id } count }
qa { passed failed failureRate }
}
} }`,
{ k: key },
),
(d) => d.project.analytics.cycleTime.count === 1,
);
const columns = project.board.map((c: { status: string }) => c.status);
expect(columns.indexOf('STAGING')).toBe(columns.indexOf('IN_REVIEW') + 1);
expect(columns).not.toContain('CANCELLED');
expect(project.stats).toEqual({ total: 1, open: 0 });
expect(project.releases[0].progress).toEqual({ finished: 1, percent: 100 });
expect(project.releases[0].burnup.scope.at(-1)).toBe(1);
expect(project.releases[0].burnup.done.at(-1)).toBe(1);
const analytics = project.analytics;
const sum = (
rows: { created: number; resolved: number }[],
f: 'created' | 'resolved',
) => rows.reduce((total, row) => total + row[f], 0);
expect(sum(analytics.createdVsResolved, 'created')).toBe(1);
expect(sum(analytics.createdVsResolved, 'resolved')).toBe(1);
expect(analytics.cumulativeFlow.series).toEqual([
expect.objectContaining({ status: 'DONE' }),
]);
expect(analytics.leadTime.count).toBe(1);
expect(analytics.throughput).toEqual([{ user: { id: '102' }, count: 1 }]);
// STAGING is a QA-category step: leaving it to DONE is a QA pass.
expect(analytics.qa).toEqual({ passed: 1, failed: 0, failureRate: 0 });
const history = await gql(
'token-ana',
`query($k: String!) { issue(idOrKey: $k) { history { summary } } }`,
{ k: spike.key },
);
expect(
history.issue.history.map((h: { summary: string }) => h.summary),
).toContain(
`Bob (frontend) movió ${spike.key} de En revisión a En staging`,
);
const done = await gql(
'token-bob',
`{ myIssues(categories: [DONE]) { items { key } } }`,
);
expect(done.myIssues.items).toEqual(
expect.arrayContaining([{ key: spike.key }]),
);
// --- Same over REST ---
const viaRest = await rest('token-ana', 'GET', `/projects/${key}`);
expect(viaRest.body['settings']['transitions']).toEqual(
expect.arrayContaining([
{ from: 'STAGING', to: ['DONE', 'IN_PROGRESS'] },
]),
);
const renamed = viaRest.body['settings'];
renamed.statuses = renamed.statuses.map((s: Item & { name: string }) =>
s.id === 'STAGING' ? { ...s, name: 'Preproducción' } : s,
);
expect(
(await rest('token-ana', 'PUT', `/projects/${key}/settings`, renamed))
.status,
).toBe(200);
const removed = await rest(
'token-ana',
'PUT',
`/projects/${key}/settings`,
{ ...renamed, statuses: [] },
);
expect(removed.status).toBe(422);
expect(removed.body['message']).toMatch(/archívalo/);
const neon = await rest('token-ana', 'PUT', `/projects/${key}/settings`, {
...renamed,
areas: [...renamed.areas, { id: 'X', name: 'X', color: 'neon' }],
issueTypes: [
...renamed.issueTypes,
{ id: 'Y', name: 'Y', icon: 'bug', color: 'neon' },
],
});
expect(neon.status).toBe(400);
const stats = await rest(
'token-ana',
'GET',
`/projects/${key}/analytics?days=30`,
);
expect(stats.status).toBe(200);
expect(stats.body['cycleTime']['count']).toBe(1);
const burnup = await rest(
'token-ana',
'GET',
`/releases/${release.id}/burnup`,
);
expect(burnup.body).toMatchObject({ releaseId: release.id });
expect(
(await rest('token-eve', 'GET', `/projects/${key}/analytics`)).status,
).toBe(404);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -13,20 +13,32 @@ type Activity {
type: String!
}
type AreaOption {
archived: Boolean!
id: String!
name: String!
}
input AreaOptionInput {
archived: Boolean! = false
id: String!
name: String!
}
type BoardColumn {
issues: [Issue!]!
status: IssueStatus!
status: String!
total: Int!
}
input ChangeIssueStatusInput {
issueId: ID!
"""
Required for QA_FAILED and CANCELLED
"""
"""Required by statuses with requiresReason"""
reason: String
status: IssueStatus!
"""A status id"""
status: String!
}
"""
@@ -52,9 +64,7 @@ type CommentChange {
action: String!
actorId: ID!
"""
Null when deleted
"""
"""Null when deleted"""
comment: Comment
commentId: ID!
issueId: ID!
@@ -69,9 +79,7 @@ type CommentPage {
type CountByAssignee {
count: Int!
"""
Null = unassigned
"""
"""Null = unassigned"""
user: UserRef
}
@@ -82,18 +90,19 @@ type CountByKey {
type CountByStatus {
count: Int!
key: IssueStatus!
"""Status id"""
key: String!
}
input CreateIssueInput {
area: IssueArea
"""An area id"""
area: String
assigneeId: ID
description: String
dueDate: String
"""
Figma frame for UI changes
"""
"""Figma frame for UI changes"""
figmaUrl: String
labels: [String!]
priority: IssuePriority
@@ -101,15 +110,15 @@ input CreateIssueInput {
releaseId: ID
storyPoints: Int
title: String!
type: IssueType!
"""A ticket type id"""
type: String!
}
input CreateProjectInput {
description: String
"""
2-10 chars, e.g. "TS" → tickets TS-1, TS-2...
"""
"""2-10 chars, e.g. "TS" → tickets TS-1, TS-2..."""
key: String!
name: String!
}
@@ -117,20 +126,44 @@ input CreateProjectInput {
input CreateReleaseInput {
description: String
"""
e.g. v1.4.0
"""
"""e.g. v1.4.0"""
name: String!
projectId: ID!
targetDate: String
}
type CreatedResolved {
created: Int!
resolved: Int!
"""Monday of the week (YYYY-MM-DD)"""
week: String!
}
type CumulativeFlow {
dates: [String!]!
series: [FlowSeries!]!
}
type DurationStats {
averageDays: Float
count: Int!
"""85% of tickets take at most this long"""
p85Days: Float
}
type FlowSeries {
counts: [Int!]!
status: String!
}
type Issue {
"""
Statuses this ticket can move to next
"""
allowedTransitions: [IssueStatus!]!
area: IssueArea
"""Statuses this ticket can move to next (project's workflow)"""
allowedTransitions: [String!]!
"""An area id"""
area: String
assignee: UserRef
commentCount: Int!
comments(limit: Int! = 50, offset: Int! = 0): CommentPage!
@@ -139,21 +172,15 @@ type Issue {
dueDate: String
figmaUrl: String
"""
History of this ticket
"""
"""History of this ticket"""
history(limit: Int! = 50): [Activity!]!
id: ID!
"""
Human key, e.g. TS-42
"""
"""Human key, e.g. TS-42"""
key: String!
labels: [String!]!
"""
Linked tickets I can see
"""
"""Linked tickets I can see"""
linkedIssues: [Issue!]!
links: [IssueLink!]!
number: Int!
@@ -162,26 +189,19 @@ type Issue {
projectKey: String!
release: ReleaseRef
reporter: UserRef!
status: IssueStatus!
"""A status id of the project's settings"""
status: String!
statusCategory: StatusCategory!
storyPoints: Int
title: String!
type: IssueType!
"""A ticket type id of the project's settings"""
type: String!
updatedAt: String!
version: Int!
}
"""
Team/layer: links FE ↔ BE ↔ QA work
"""
enum IssueArea {
BACKEND
DESIGN
DEVOPS
FRONTEND
MOBILE
QA
}
type IssueChange {
action: String!
actorId: ID!
@@ -190,26 +210,23 @@ type IssueChange {
}
input IssueFilterInput {
areas: [IssueArea!]
areas: [String!]
assigneeId: ID
categories: [StatusCategory!]
labels: [String!]
priorities: [IssuePriority!]
projectId: ID!
releaseId: ID
"""
Matches key, title or description
"""
"""Matches key, title or description"""
search: String
statuses: [IssueStatus!]
types: [IssueType!]
statuses: [String!]
types: [String!]
unassigned: Boolean
}
type IssueLink {
"""
OUTWARD: this issue → other. INWARD: other → this issue.
"""
"""OUTWARD: this issue → other. INWARD: other → this issue."""
direction: LinkDirection!
issueId: ID!
issueKey: String!
@@ -237,24 +254,20 @@ enum IssuePriority {
MEDIUM
}
enum IssueStatus {
BACKLOG
CANCELLED
DONE
IN_PROGRESS
IN_QA
IN_REVIEW
QA_FAILED
READY_FOR_QA
TODO
type IssueTypeOption {
archived: Boolean!
color: String!
icon: String!
id: String!
name: String!
}
enum IssueType {
BUG
FEATURE
QA
TASK
UI_CHANGE
input IssueTypeOptionInput {
archived: Boolean! = false
color: String!
icon: String!
id: String!
name: String!
}
enum LinkDirection {
@@ -269,53 +282,39 @@ input LinkIssuesInput {
}
type Me {
"""
Roles inside the task-stream OAuth client
"""
"""Roles inside the task-stream OAuth client"""
appRoles: [String!]!
email: String!
id: ID!
isActive: Boolean!
name: String!
"""
Global roles in Pireon
"""
"""Global roles in Pireon"""
roles: [String!]!
}
type Mutation {
addComment(body: String!, issueId: ID!): CommandResult!
"""
Add a Pireon user to the project
"""
"""Add a Pireon user to the project"""
addProjectMember(input: ProjectMemberInput!): CommandResult!
archiveRelease(releaseId: ID!): CommandResult!
"""
Assign (or unassign with null) a project member
"""
"""Assign (or unassign with null) a project member"""
assignIssue(assigneeId: ID, issueId: ID!): CommandResult!
"""
Move a ticket through the workflow (incl. QA)
"""
"""Move a ticket through the workflow (incl. QA)"""
changeIssueStatus(input: ChangeIssueStatusInput!): CommandResult!
changeProjectMemberRole(input: ProjectMemberInput!): CommandResult!
createIssue(input: CreateIssueInput!): CommandResult!
createProject(input: CreateProjectInput!): CommandResult!
createRelease(input: CreateReleaseInput!): CommandResult!
"""
Author or project admin
"""
"""Author or project admin"""
deleteComment(commentId: ID!): CommandResult!
editComment(body: String!, commentId: ID!): CommandResult!
"""
Relate tickets, e.g. FE DEPENDS_ON BE, QA TESTS FE
"""
"""Relate tickets, e.g. FE DEPENDS_ON BE, QA TESTS FE"""
linkIssues(input: LinkIssuesInput!): CommandResult!
"""
@@ -324,26 +323,27 @@ type Mutation {
publishRelease(force: Boolean! = false, releaseId: ID!): CommandResult!
removeProjectMember(projectId: ID!, userId: ID!): CommandResult!
"""
Put a ticket in a release (null removes it)
"""
"""Put a ticket in a release (null removes it)"""
setIssueRelease(issueId: ID!, releaseId: ID): CommandResult!
unlinkIssues(input: LinkIssuesInput!): CommandResult!
updateIssue(input: UpdateIssueInput!): CommandResult!
updateProject(input: UpdateProjectInput!): CommandResult!
"""
Replace statuses, transitions, ticket types and areas (owners and admins). Values can be archived, not deleted.
"""
updateProjectSettings(projectId: ID!, settings: ProjectSettingsInput!): CommandResult!
updateRelease(input: UpdateReleaseInput!): CommandResult!
}
type Project {
"""
Kanban board, one column per status
Statistics of the last `days` days (7-365): created vs resolved, cumulative flow, cycle/lead time, throughput and QA
"""
board(
area: IssueArea
assigneeId: ID
perColumn: Int! = 50
releaseId: ID
): [BoardColumn!]!
analytics(days: Int! = 90): ProjectAnalytics!
"""Kanban board, one column per status"""
board(area: String, assigneeId: ID, perColumn: Int! = 50, releaseId: ID): [BoardColumn!]!
createdAt: String!
description: String
id: ID!
@@ -352,15 +352,33 @@ type Project {
name: String!
owner: UserRef!
releases: [Release!]!
settings: ProjectSettings!
"""
Project tracking dashboard
"""
"""Project tracking dashboard"""
stats: ProjectStats!
updatedAt: String!
version: Int!
}
type ProjectAnalytics {
createdVsResolved: [CreatedResolved!]!
"""Tickets in each status at the end of every day"""
cumulativeFlow: CumulativeFlow!
"""Start of work → done"""
cycleTime: DurationStats!
from: String!
"""Creation → done"""
leadTime: DurationStats!
qa: QaStats!
"""Finished per person and week"""
throughput: [Throughput!]!
to: String!
}
type ProjectChange {
action: String!
actorId: ID!
@@ -377,9 +395,7 @@ input ProjectMemberInput {
projectId: ID!
role: ProjectRole! = MEMBER
"""
Pireon user id
"""
"""Pireon user id"""
userId: ID!
}
@@ -390,12 +406,28 @@ enum ProjectRole {
VIEWER
}
"""Statuses (board order), transitions, ticket types and areas"""
type ProjectSettings {
areas: [AreaOption!]!
issueTypes: [IssueTypeOption!]!
statuses: [WorkflowStatus!]!
transitions: [StatusTransitions!]!
}
"""
The whole configuration (replaces the previous one). Statuses go in board order.
"""
input ProjectSettingsInput {
areas: [AreaOptionInput!]!
issueTypes: [IssueTypeOptionInput!]!
statuses: [WorkflowStatusInput!]!
transitions: [StatusTransitionsInput!]!
}
type ProjectStats {
byArea: [CountByKey!]!
"""
Open tickets per person
"""
"""Open tickets per person"""
byAssignee: [CountByAssignee!]!
byPriority: [CountByKey!]!
byStatus: [CountByStatus!]!
@@ -404,63 +436,59 @@ type ProjectStats {
total: Int!
}
type QaStats {
failed: Int!
failureRate: Float
passed: Int!
}
type Query {
"""
Project activity feed (newest first)
"""
"""Project activity feed (newest first)"""
activity(
"""
ISO date cursor
"""
"""ISO date cursor"""
before: String
issueId: ID
limit: Int! = 50
projectId: ID!
): [Activity!]!
issue(
"""
Issue id or key (e.g. "TS-42")
"""
"""Issue id or key (e.g. "TS-42")"""
idOrKey: String!
): Issue!
issues(
filter: IssueFilterInput!
limit: Int! = 50
offset: Int! = 0
): IssuePage!
issues(filter: IssueFilterInput!, limit: Int! = 50, offset: Int! = 0): IssuePage!
"""
The authenticated user
"""
"""The authenticated user"""
me: Me!
"""
Projects where I am a member
Tickets across all my projects: assigned to me (default) or any (e.g. categories [QA])
"""
myIssues(assignedToMe: Boolean! = true, categories: [StatusCategory!], limit: Int! = 50, offset: Int! = 0, statuses: [String!]): IssuePage!
"""Projects where I am a member"""
myProjects: [Project!]!
project(
"""
Project id or key (e.g. "TS")
"""
"""Project id or key (e.g. "TS")"""
idOrKey: String!
): Project!
release(id: ID!): Release!
"""Planned releases of all my projects, soonest target date first"""
upcomingReleases(limit: Int! = 10): [Release!]!
user(id: String!): User
"""
Search users by name or email (to assign / add members)
"""
"""Search users by name or email (to assign / add members)"""
users(limit: Int! = 20, search: String! = ""): [User!]!
}
type Release {
"""Scope vs finished tickets, day by day"""
burnup: ReleaseBurnup!
createdAt: String!
description: String
id: ID!
"""
Tickets included in this release
"""
"""Tickets included in this release"""
issues(limit: Int! = 100, offset: Int! = 0): IssuePage!
name: String!
progress: ReleaseProgress!
@@ -471,6 +499,16 @@ type Release {
version: Int!
}
type ReleaseBurnup {
dates: [String!]!
done: [Int!]!
releaseId: ID!
"""Tickets in the release (not cancelled)"""
scope: [Int!]!
targetDate: String
}
type ReleaseChange {
action: String!
actorId: ID!
@@ -496,41 +534,61 @@ enum ReleaseStatus {
RELEASED
}
"""
What a status means, whatever each project calls it. DONE and CANCELLED are finished.
"""
enum StatusCategory {
CANCELLED
DONE
IN_PROGRESS
QA
TODO
}
type StatusTransitions {
from: String!
to: [String!]!
}
input StatusTransitionsInput {
from: String!
to: [String!]!
}
type Subscription {
"""
Live activity feed
"""
"""Live activity feed"""
activityAdded(projectId: ID!): Activity!
commentChanged(issueId: ID!): CommentChange!
"""
Tickets created/updated/moved in a project (board in realtime)
"""
"""Tickets created/updated/moved in a project (board in realtime)"""
issueChanged(projectId: ID!): IssueChange!
"""
Projects I am added to / removed from
"""
"""Projects I am added to / removed from"""
myProjectsChanged: ProjectChange!
projectChanged(projectId: ID!): ProjectChange!
releaseChanged(projectId: ID!): ReleaseChange!
}
type Throughput {
count: Int!
user: UserRef
week: String!
}
input UpdateIssueInput {
area: IssueArea
"""An area id"""
area: String
description: String
dueDate: String
"""
Figma frame for UI changes
"""
"""Figma frame for UI changes"""
figmaUrl: String
issueId: ID!
labels: [String!]
priority: IssuePriority
storyPoints: Int
title: String
type: IssueType
type: String
}
input UpdateProjectInput {
@@ -546,9 +604,7 @@ input UpdateReleaseInput {
targetDate: String
}
"""
User managed by the Pireon identity server
"""
"""User managed by the Pireon identity server"""
type User {
email: String!
id: ID!
@@ -560,3 +616,28 @@ type UserRef {
id: ID!
name: String!
}
type WorkflowStatus {
"""No new tickets or transitions into it"""
archived: Boolean!
category: StatusCategory!
"""Colour token: slate, violet, green…"""
color: String!
"""Stable id stored on tickets, e.g. IN_REVIEW"""
id: String!
name: String!
"""Entering it needs a reason"""
requiresReason: Boolean!
}
input WorkflowStatusInput {
archived: Boolean! = false
category: StatusCategory!
color: String!
id: String!
name: String!
requiresReason: Boolean! = false
}

View File

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

View File

@@ -114,6 +114,31 @@ html {
--ts-t-ui: #d6409f;
--ts-t-task: #64748b;
--ts-t-qa: #d97706;
// Palette of the per-project settings (statuses and ticket types pick one).
--ts-c-slate: #64748b;
--ts-c-stone: #8b8b9a;
--ts-c-violet: #a347e1;
--ts-c-blue: #1879bf;
--ts-c-sky: #0284c7;
--ts-c-teal: #0f766e;
--ts-c-green: #15803d;
--ts-c-amber: #b7791f;
--ts-c-orange: #ea580c;
--ts-c-red: #dc2626;
--ts-c-pink: #d6409f;
// Charts (validated with the dataviz checks against --ts-surface, light and
// dark): two categorical slots, an ordinal ramp for flow stages and the
// reserved good/critical status colours.
--ts-viz-1: #2a78d6;
--ts-viz-2: #eb6834;
--ts-viz-ramp-1: #86b6ef;
--ts-viz-ramp-2: #5598e7;
--ts-viz-ramp-3: #256abf;
--ts-viz-ramp-4: #104281;
--ts-viz-good: #0ca30c;
--ts-viz-critical: #d03b3b;
}
html,
@@ -316,6 +341,19 @@ h3 {
}
}
/* Text for screen readers only (same helper as pireon.dev). */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
.ts-prewrap {
white-space: pre-wrap;
}
@@ -501,6 +539,25 @@ html.dark-mode .pi-shell {
--ts-t-task: #94a3b8;
--ts-t-qa: #fbbf24;
--ts-c-slate: #94a3b8;
--ts-c-stone: #8a91a4;
--ts-c-violet: #ae5de5;
--ts-c-blue: #339be6;
--ts-c-sky: #38bdf8;
--ts-c-teal: #2dd4bf;
--ts-c-green: #34c98a;
--ts-c-amber: #eab308;
--ts-c-orange: #fb923c;
--ts-c-red: #f07070;
--ts-c-pink: #f472b6;
--ts-viz-1: #3987e5;
--ts-viz-2: #d95926;
--ts-viz-ramp-1: #184f95;
--ts-viz-ramp-2: #2a78d6;
--ts-viz-ramp-3: #6da7ec;
--ts-viz-ramp-4: #b7d3f6;
--pi-color-grey-10: #12141a;
--pi-color-grey-25: #161922;
--pi-color-grey-50: #1a1e28;

View File

@@ -3,16 +3,19 @@
## Qué resuelve
| Necesidad | Cómo se resuelve |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas |
| 🧩 Configuración | Cada proyecto tiene sus estados (con categoría, color y motivo obligatorio), transiciones, tipos de ticket y áreas; lo usado se archiva, no se borra |
| 🎨 Cambios de UI | Tipo `UI_CHANGE` con enlace al frame de Figma (validado contra `figma.com`) |
| ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo |
| 🧪 QA | Estados `READY_FOR_QA → IN_QA → DONE / QA_FAILED`; fallar QA exige un motivo |
| 🧪 QA | Por defecto, `READY_FOR_QA → IN_QA → DONE / QA_FAILED`; fallar QA exige un motivo |
| 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) |
| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona |
| 💬 Comentarios | Decisiones y explicaciones dentro del ticket; el autor edita, el admin modera |
| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA) |
| 🏠 Inicio | «Mi trabajo»: mis tickets en todos los proyectos, por categoría (QA fallidos primero), lo que espera QA, próximas releases e «Ir a…» por clave |
| 📊 Seguimiento | Panel con contadores, flujo por estado, el equipo con sus tickets abiertos (enlaza a su tablero), feed de actividad e historial por ticket |
| 📈 Estadísticas | Creados vs. resueltos, flujo acumulado, burnup de releases, tiempos de ciclo y entrega, tasa de fallo en QA y resueltos por persona |
| ⚡ Tiempo real | Tablero, comentarios, actividad, releases y membresías se actualizan solos (GraphQL subscriptions) |
Los **usuarios no se gestionan aquí**: vienen del servidor de identidad **Pireon**

View File

@@ -11,7 +11,8 @@ classDiagram
key: "TS"
name
members: (userId, role)[]
addMember() changeMemberRole() removeMember() updateDetails()
settings: estados · transiciones · tipos · áreas
addMember() changeMemberRole() removeMember() updateDetails() updateSettings()
}
class Issue {
key: "TS-42"
@@ -47,7 +48,39 @@ classDiagram
Cualquiera puede salir de un proyecto (salvo el propietario). Los no miembros reciben _not found_, para no revelar que
el proyecto existe.
### Workflow de un ticket
### Configuración por proyecto
Cada proyecto tiene sus **estados**, **transiciones**, **tipos de ticket** y **áreas** (`Project.settings`). Todos
empiezan con los valores por defecto de abajo, y el propietario o un admin los cambia con `updateProjectSettings` (o
`PUT /api/projects/{project}/settings`); el cambio es un evento más (`ProjectSettingsChangedEvent`).
| Valor | Campos | Por defecto |
| ------ | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| Estado | `id`, `name`, `category`, `color`, `requiresReason`, `archived` (el orden es el del tablero) | `BACKLOG`, `TODO`, `IN_PROGRESS`, `IN_REVIEW`, `READY_FOR_QA`, `IN_QA`, `QA_FAILED`, `DONE`, `CANCELLED` |
| Tipo | `id`, `name`, `icon`, `color`, `archived` | `BUG`, `FEATURE`, `UI_CHANGE`, `TASK`, `QA` |
| Área | `id`, `name`, `archived` | `FRONTEND`, `BACKEND`, `MOBILE`, `QA`, `DESIGN`, `DEVOPS` |
La **categoría** de un estado dice qué significa, se llame como se llame: `TODO` (pendiente), `IN_PROGRESS`, `QA`,
`DONE` y `CANCELLED`. Releases («todo terminado» = `DONE` o `CANCELLED`), «Mi trabajo», el seguimiento y las
estadísticas trabajan con categorías, así que siguen funcionando con estados propios. Los tickets guardan el id del
estado y, en el read model, su categoría (`Issue.statusCategory`).
Reglas:
- **No se borra lo que ya existe: se archiva.** Tickets, historial y estadísticas siguen apuntando a ese id. Un valor
archivado no se ofrece para tickets nuevos ni recibe transiciones; los tickets que ya lo tienen lo conservan.
- Los ids son estables (`A-Z`, `0-9`, `_`, hasta 30, empezando por letra); los nombres, de 1 a 40 caracteres, se
pueden cambiar cuando se quiera.
- Hace falta al menos un estado activo de categoría `TODO` (los tickets nuevos entran en el primero), uno `DONE` y un
tipo activo.
- Las transiciones solo pueden apuntar a estados que existen y nunca de un estado a sí mismo.
- Colores: `slate`, `stone`, `violet`, `blue`, `sky`, `teal`, `green`, `amber`, `orange`, `red`, `pink`. Iconos:
`bug`, `feature`, `palette`, `task`, `qa`, `flag`, `star-fill`, `rocket`, `document`, `light-bulb`, `key`, `support`.
Los proyectos creados antes de que existiera la configuración usan los valores por defecto (sus eventos no la
llevan). Al arrancar, la API rellena la categoría de los tickets del read model que no la tienen.
### Workflow por defecto
```mermaid
stateDiagram-v2
@@ -76,9 +109,10 @@ stateDiagram-v2
CANCELLED --> BACKLOG
```
- `QA_FAILED` y `CANCELLED` exigen un **motivo**, que queda en el historial.
- `QA_FAILED` y `CANCELLED` exigen un **motivo** (`requiresReason`), que queda en el historial.
- No se puede pasar a `DONE` saltándose QA salvo desde `IN_REVIEW` (cambios que no requieren QA).
- `Issue.allowedTransitions` en GraphQL devuelve los estados permitidos desde el actual.
- `Issue.allowedTransitions` devuelve los estados activos a los que puede pasar el ticket según el workflow de su
proyecto.
### Otras reglas
@@ -97,12 +131,34 @@ Todo cambio es un evento guardado en el event store (stream = id del agregado).
`aggregateId`, `projectId` y `aggregateVersion` (la posición en el stream).
| Agregado | Eventos |
| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Project | `ProjectCreatedEvent`, `ProjectUpdatedEvent`, `ProjectMemberAddedEvent`, `ProjectMemberRoleChangedEvent`, `ProjectMemberRemovedEvent` |
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Project | `ProjectCreatedEvent`, `ProjectUpdatedEvent`, `ProjectSettingsChangedEvent`, `ProjectMemberAddedEvent`, `ProjectMemberRoleChangedEvent`, `ProjectMemberRemovedEvent` |
| Issue | `IssueCreatedEvent`, `IssueUpdatedEvent`, `IssueStatusChangedEvent`, `IssueAssignedEvent`, `IssueLinkedEvent`, `IssueUnlinkedEvent`, `IssueReleaseChangedEvent` |
| Comment | `CommentAddedEvent`, `CommentEditedEvent`, `CommentDeletedEvent` |
| Release | `ReleaseCreatedEvent`, `ReleaseUpdatedEvent`, `ReleaseShippedEvent`, `ReleaseArchivedEvent` |
`IssueStatusChangedEvent` lleva además `meta` con el nombre y la categoría de los dos estados en el momento del
cambio, para que el historial y las estadísticas no cambien si luego se renombra o recategoriza un estado.
### Estadísticas
Una proyección más (`issue_timeline` en MongoDB) guarda cada paso de cada ticket: a qué estado pasó, con qué categoría,
quién lo tenía asignado, y cuándo entró o salió de una release. Las estadísticas se calculan sobre ella con funciones
puras (`application/src/lib/analytics`):
| Métrica | Definición |
| --------------------- | ----------------------------------------------------------------------------------------------------------------- |
| Creados vs. resueltos | Por semana (lunes, UTC): tickets creados y tickets que entraron en una categoría `DONE` |
| Flujo acumulado | Tickets en cada estado al final de cada día |
| Tiempo de ciclo | Del primer paso a `IN_PROGRESS` o `QA` hasta `DONE`; media y percentil 85 |
| Tiempo de entrega | De la creación hasta `DONE`; media y percentil 85 |
| Throughput | Tickets resueltos por semana y por la persona que los tenía asignados |
| QA | Salidas de un estado `QA`: a `DONE` cuenta como superado; a cualquier otro sitio salvo `CANCELLED`, como fallido |
| Burnup de una release | Por día, desde que se creó hasta que se publica (o hoy, máx. 180 días): tickets en ella (sin cancelados) y hechos |
Los tickets anteriores a esta proyección solo tienen su creación y su estado actual; para tener su historia completa,
reconstruye el read model (ver [Despliegue](07-despliegue.md)).
## 3.2 GraphQL
Todas las operaciones requieren `Authorization: Bearer <token de Pireon>` (en subscriptions, en el `connection_init`).
@@ -110,15 +166,17 @@ Todas las operaciones requieren `Authorization: Bearer <token de Pireon>` (en su
### Queries
| Query | Devuelve |
| ----------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `me` | El usuario autenticado, con sus roles en Pireon |
| `users(search, limit)` | Usuarios de Pireon (para asignar o añadir miembros) |
| `user(id)` | Un usuario |
| `myProjects` | Proyectos de los que soy miembro |
| `project(idOrKey)` | Un proyecto. Campos calculados: `board(releaseId, assigneeId, area, perColumn)`, `stats`, `releases` |
| `myIssues(assignedToMe, statuses, categories, limit, offset)` | «Mi trabajo»: tickets de todos mis proyectos, asignados a mí (por defecto) o de cualquiera (p. ej. `categories: [QA]`) |
| `upcomingReleases(limit)` | Releases planificadas de mis proyectos, por fecha objetivo |
| `project(idOrKey)` | Un proyecto con su `settings`. Campos calculados: `board(releaseId, assigneeId, area, perColumn)`, `stats`, `analytics(days)`, `releases` |
| `issues(filter, limit, offset)` | Búsqueda de tickets (estado, tipo, área, prioridad, persona, release, etiquetas, texto) |
| `issue(idOrKey)` | Un ticket. Campos calculados: `allowedTransitions`, `linkedIssues`, `comments`, `history` |
| `release(id)` | Una release. Campos calculados: `progress`, `issues` |
| `release(id)` | Una release. Campos calculados: `progress`, `burnup`, `issues` |
| `activity(projectId, issueId?, limit, before?)` | Feed de actividad, del más reciente al más antiguo |
### Mutations
@@ -126,8 +184,8 @@ Todas las operaciones requieren `Authorization: Bearer <token de Pireon>` (en su
Todas devuelven `CommandResult { id, key, version }`.
| Ámbito | Mutations |
| ----------- | ----------------------------------------------------------------------------------------------------------------- |
| Proyectos | `createProject`, `updateProject`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------- |
| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease` |
| Comentarios | `addComment`, `editComment`, `deleteComment` |
| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `archiveRelease` |
@@ -140,14 +198,23 @@ Todas devuelven `CommandResult { id, key, version }`.
| `commentChanged(issueId)` | Se añade, edita o borra un comentario (`comment` es `null` al borrar) |
| `activityAdded(projectId)` | Hay una nueva entrada en el feed |
| `releaseChanged(projectId)` | Cambia una release |
| `projectChanged(projectId)` | Cambian los datos o los miembros del proyecto |
| `projectChanged(projectId)` | Cambian los datos, la configuración o los miembros del proyecto |
| `myProjectsChanged` | Me añaden a un proyecto o me quitan de uno |
Solo los miembros pueden suscribirse a un proyecto; las conexiones sin token válido se cierran con el código 4403.
### Ejemplos
Estados, tipos y áreas son texto (ids de la configuración del proyecto), no enums: van entre comillas.
```graphql
mutation ($settings: ProjectSettingsInput!) {
# La configuración entera (la que devuelve project.settings, cambiada).
updateProjectSettings(projectId: "…", settings: $settings) {
version
}
}
mutation {
createProject(input: { key: "SHOP", name: "Checkout revamp" }) {
id
@@ -157,7 +224,7 @@ mutation {
}
mutation {
createIssue(input: { projectId: "…", type: BUG, title: "El botón de pagar no responde en Safari", area: FRONTEND, priority: CRITICAL, assigneeId: "102", labels: ["safari"] }) {
createIssue(input: { projectId: "…", type: "BUG", title: "El botón de pagar no responde en Safari", area: "FRONTEND", priority: CRITICAL, assigneeId: "102", labels: ["safari"] }) {
id
key
version
@@ -165,7 +232,7 @@ mutation {
}
mutation {
changeIssueStatus(input: { issueId: "…", status: QA_FAILED, reason: "Sigue fallando en Safari 17" }) {
changeIssueStatus(input: { issueId: "…", status: "QA_FAILED", reason: "Sigue fallando en Safari 17" }) {
version
}
}
@@ -210,6 +277,27 @@ query {
percent
}
}
analytics(days: 90) {
createdVsResolved {
week
created
resolved
}
cycleTime {
averageDays
p85Days
}
qa {
failureRate
}
}
}
release(id: "…") {
burnup {
dates
scope
done
}
}
issue(idOrKey: "SHOP-1") {
status
@@ -296,15 +384,16 @@ Lo que cada usuario puede ver o cambiar depende de su rol en cada proyecto, como
Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
| Recurso | Rutas |
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` |
| Proyectos | `GET/POST /api/projects`, `GET/PATCH /api/projects/{project}`, `…/board`, `…/stats`, `…/activity`, `…/members` (POST, PATCH, DELETE) |
| Tickets | `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history` |
| Proyectos | `GET/POST /api/projects`, `GET/PATCH /api/projects/{project}`, `PUT …/settings`, `…/board`, `…/stats`, `…/analytics?days=`, `…/activity`, `…/members` (POST, PATCH, DELETE) |
| Tickets | `GET /api/me/issues` (mi trabajo), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history` |
| Comentarios | `PATCH/DELETE /api/comments/{commentId}` |
| Releases | `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `…/progress`, `…/issues`, `POST …/publish`, `POST …/archive` |
| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` |
| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` |
Las escrituras responden `{ id, key?, version }` en cuanto el evento se guarda; las lecturas salen del read model, que
Los filtros de tickets aceptan `status` (ids del proyecto) y `category` (`?category=QA`), repetidos o separados por
comas. Las escrituras responden `{ id, key?, version }` en cuanto el evento se guarda; las lecturas salen del read model, que
se pone al día en milisegundos (un `GET` justo después puede devolver la versión anterior, o 404 tras crear). Para
tiempo real, las subscriptions de GraphQL.

View File

@@ -37,7 +37,7 @@ export const boardPageEvents = eventGroup({
source: 'Board Page',
events: {
opened: type<BoardFilter>(),
issueMoved: type<{ issueId: string; to: IssueStatus; reason?: string | null }>(),
issueMoved: type<{ issueId: string; to: string; reason?: string | null }>(),
},
});
@@ -84,7 +84,7 @@ export class BoardFacade {
else this.dispatch.opened(filter);
}
moveIssue(issueId: string, to: IssueStatus, reason?: string | null): void {
moveIssue(issueId: string, to: string, reason?: string | null): void {
this.dispatch.issueMoved({ issueId, to, reason });
}
}
@@ -110,10 +110,11 @@ Qué gana la página:
- **Tests sin plantilla**: la facade se prueba con gateways falsos (`facades.spec.ts`).
| Facade | Store | Ámbito | Responsabilidad |
| ---------------- | --------------- | ----------------------- | --------------------------------------------------------------------------------------------------------- |
| ---------------- | --------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `AuthFacade` | `AuthStore` | raíz | Sesión, usuario, login (PKCE o email y contraseña de Pireon), renovación del token, restaurar al arrancar |
| `ProjectsFacade` | `ProjectsStore` | raíz | Mis proyectos; se actualiza cuando me añaden o quitan (`myProjectsChanged`) |
| `ProjectFacade` | `ProjectStore` | ruta `/p/:key` | Proyecto, miembros, stats, releases, actividad en vivo y búsqueda de usuarios |
| `HomeFacade` | `HomeStore` | raíz | «Mi trabajo» en la portada: mis tickets abiertos por categoría (QA fallidos primero), esperando QA y próximas releases; se recarga al volver a la pestaña |
| `ProjectFacade` | `ProjectStore` | ruta `/p/:key` | Proyecto, miembros, stats, releases, actividad en vivo, búsqueda de usuarios, configuración (`workflow`, `saveSettings`) y estadísticas (`loadAnalytics`, `loadBurnup`) |
| `BoardFacade` | `BoardStore` | ruta `board` | Tablero kanban, filtros, movimiento optimista, nuevo ticket, cambios en vivo |
| `IssueFacade` | `IssueStore` | ruta `issues/:issueKey` | Ticket, comentarios en vivo, historial y todas sus acciones |
@@ -145,7 +146,9 @@ de identidad (`SessionTokenSource`) le pide el token para el cliente GraphQL.
npx nx run web:codegen # regenera generated.ts (TypedDocumentNode)
```
- Los adaptadores convierten los tipos generados en modelos de dominio en un único sitio (`adapters/mappers.ts`).
- Los adaptadores convierten los tipos generados en modelos de dominio en un único sitio (`adapters/mappers.ts`). Lo
que se lee y luego se reenvía como input (la configuración del proyecto) se copia campo a campo: Apollo añade
`__typename` a cada objeto y los input types de GraphQL lo rechazan.
## 4.4 `@pi/ui`
@@ -193,17 +196,46 @@ versiones) y un color por estado (`--ts-s-*`) y por tipo de ticket (`--ts-t-*`),
gráficas e historial. Salen de `tone` en `ui/copy.ts` y las plantillas los aplican con `[style.--tone]`; utilidades
globales como `.ts-pill` o `.ts-key` leen esa variable. Las animaciones se desactivan con `prefers-reduced-motion`.
### Configuración del proyecto en la UI
Estados, tipos y áreas son de cada proyecto, así que la UI nunca los tiene fijos: `ProjectFacade.workflow()` envuelve
`project.settings` (`Workflow` en el dominio web) y da nombre, color, icono, categoría, transiciones permitidas y si un
estado pide motivo. `statusLook`/`typeLook` de `ui/copy.ts` lo convierten en etiqueta y tono (`--ts-c-<color>`, con su
versión oscura). Un id que ya no está configurado sigue mostrándose (`IN_REVIEW` → «In review»). «Mi trabajo» mezcla
proyectos: cada fila usa el workflow de su proyecto y los grupos son por categoría.
En el tablero, un ticket solo se puede soltar en las columnas a las que su workflow le deja pasar, y los tipos
archivados no se ofrecen al crear. Los tipos con icono `palette` piden un frame de Figma.
### Gráficas
Las de Estadísticas son SVG propios (`ui/series-chart.ts`), no `pi-line-chart`: hacen falta áreas apiladas y una
paleta con versión oscura, que los gráficos de `@pi/ui` (Chart.js con colores fijos) no traen. Siguen las reglas de
visualización de la plataforma:
- Colores `--ts-viz-*` en `styles.scss`, validados contra la superficie clara y la oscura (separación para daltonismo,
contraste): dos series categóricas (azul y naranja), una rampa ordinal de un solo tono para las etapas del flujo y
bueno/crítico reservados para QA superado/fallido, siempre con icono y texto.
- Líneas de 2 px, rejilla de 1 px continua, separación de 2 px entre bandas apiladas, una sola escala por gráfica.
- Cruceta que se ajusta al día o semana más cercano y un único tooltip con todas las series; con teclado, ← → Inicio
Fin sobre la gráfica.
- Leyenda con dos o más series, etiqueta de valor al final de cada línea solo si no se solapan, y «Ver los datos» con
la tabla, para que nada dependa del color ni del ratón.
- Mientras se recalcula un periodo, se queda la gráfica anterior atenuada.
## 4.6 Rutas
| Ruta | Página |
| -------------------------- | ---------------------------------------------------------------------------------------- |
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/login` | Inicio de sesión (solo sin sesión) |
| `/auth/callback` | Retorno de Pireon (PKCE) |
| `/` | Mis proyectos |
| `/` | Inicio: «Ir a…» (`WEB-12`), mi trabajo (`ts-my-work`) y mis proyectos |
| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros) |
| `/p/:key/issues/:issueKey` | Ticket |
| `/p/:key/releases` | Releases |
| `/p/:key/dashboard` | Seguimiento: cifras, flujo por estado, equipo (cada persona enlaza a su tablero) y tipos |
| `/p/:key/analytics` | Estadísticas: creados vs. resueltos, flujo acumulado, burnup de una release, tiempos de ciclo y entrega, QA y resueltos por persona (30 días a 1 año) |
| `/p/:key/settings` | Configuración (propietario y admins; pestaña oculta para el resto): estados (orden, categoría, color, motivo), transiciones, tipos y áreas; archivar en lugar de borrar |
| `/p/:key/activity` | Actividad en vivo |
| `/p/:key/members` | Miembros |

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;`
4. Arranca la API: el relay vuelve a enviar todos los eventos, en orden, y las proyecciones reconstruyen las vistas.
Las notificaciones en tiempo real se reenvían también, sin efecto si no hay clientes conectados.
Hazlo también una vez tras desplegar la configuración por proyecto y las estadísticas si quieres la historia completa
de los tickets anteriores: sin reconstruir, la línea temporal (`issue_timeline`) solo empieza con los cambios nuevos y
las estadísticas usan, para lo anterior, la creación y el estado actual de cada ticket. La categoría de los estados no
lo necesita: la API la rellena al arrancar en los tickets que no la tienen.

Binary file not shown.

Binary file not shown.

View File

@@ -7,10 +7,14 @@ Casos de uso del tracker (CQRS).
## Contenido
- Services: `ProjectsService`, `IssuesService`, `CommentsService`, `ReleasesService`, `ChangesService` (subscriptions).
- Services: `ProjectsService`, `IssuesService`, `CommentsService`, `ReleasesService`, `ChangesService` (subscriptions),
`WorkflowsService` (workflow de cada proyecto para presentar tickets, con una caché de 2 s).
- `commands/`: un fichero por command y otro por handler; cargan con `WriteAccess` (rehydrator) y guardan con `commitAndWait()`.
- `queries/`: un fichero por query y otro por handler; leen de los puertos `Find*Repository` y comprueban la membresía (`ReadAccess`).
- `event-handlers/`: proyecciones (`@EventsHandler`) hacia el read model, notificaciones en tiempo real y feed de actividad.
- `queries/`: un fichero por query y otro por handler; leen de los puertos `Find*Repository` y comprueban la membresía (`ReadAccess`);
`SearchMyIssuesQuery` y `GetUpcomingReleasesQuery` cruzan todos los proyectos del usuario («Mi trabajo»).
- `event-handlers/`: proyecciones (`@EventsHandler`) hacia el read model (incluida la línea temporal de cada ticket),
notificaciones en tiempo real y feed de actividad.
- `analytics/`: estadísticas y burnup calculados con funciones puras sobre la línea temporal (con tests).
- `ports/`: lookups de escritura, `Find*`, `UpsertMaterialized*`, `ReleaseIssuesRepository`, `ChangeFeed`, `UserDirectory`.
- `TrackerApplicationModule.withInfrastructure(...)`.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import { CreateProjectCommandHandler } from './create-project.command-handler';
import { UpdateProjectCommandHandler } from './update-project.command-handler';
import { UpdateProjectSettingsCommandHandler } from './update-project-settings.command-handler';
import { AddProjectMemberCommandHandler } from './add-project-member.command-handler';
import { ChangeProjectMemberRoleCommandHandler } from './change-project-member-role.command-handler';
import { RemoveProjectMemberCommandHandler } from './remove-project-member.command-handler';
@@ -21,6 +22,7 @@ import { ArchiveReleaseCommandHandler } from './archive-release.command-handler'
export const COMMAND_HANDLERS = [
CreateProjectCommandHandler,
UpdateProjectCommandHandler,
UpdateProjectSettingsCommandHandler,
AddProjectMemberCommandHandler,
ChangeProjectMemberRoleCommandHandler,
RemoveProjectMemberCommandHandler,

View File

@@ -19,12 +19,17 @@ export class CreateIssueCommandHandler
async execute(command: CreateIssueCommand): Promise<CommandResult> {
const project = await this.access.project(command.projectId);
project.assertCanContribute(command.actorId);
// Before taking a number: an invalid type or area must not burn one.
const workflow = project.workflow;
workflow.assertActiveType(command.type);
workflow.assertActiveArea(command.details.area);
if (command.assigneeId)
this.access.assertAssignable(project, command.assigneeId);
if (command.releaseId)
await this.access.openReleaseOf(project, command.releaseId);
const issue = this.issueFactory.create({
const issue = this.issueFactory.create(
{
...command.details,
projectId: project.id,
projectKey: project.key,
@@ -33,7 +38,9 @@ export class CreateIssueCommandHandler
title: command.title,
reporterId: command.actorId,
assigneeId: command.assigneeId,
});
},
workflow,
);
if (command.releaseId) issue.setRelease(command.actorId, command.releaseId);
this.eventPublisher.mergeObjectContext(issue);

View File

@@ -1,12 +1,13 @@
import { Command } from '@nestjs/cqrs';
import { IssueDetails, IssueType } from '@task-stream/api/tracker/domain';
import { IssueDetails } from '@task-stream/api/tracker/domain';
import { CommandResult } from './command-result';
export class CreateIssueCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly projectId: string,
public readonly type: IssueType,
/** Ticket type id of the project's settings. */
public readonly type: string,
public readonly title: string,
public readonly details: Omit<IssueDetails, 'title' | 'type'>,
public readonly assigneeId?: string | null,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,9 +7,7 @@ import {
IssueLinkedEvent,
IssueLinkType,
IssueReleaseChangedEvent,
IssueStatus,
IssueStatusChangedEvent,
IssueType,
IssueUnlinkedEvent,
IssueUpdatedEvent,
ProjectCreatedEvent,
@@ -17,7 +15,9 @@ import {
ProjectMemberRemovedEvent,
ProjectMemberRoleChangedEvent,
ProjectRole,
ProjectSettingsChangedEvent,
ProjectUpdatedEvent,
ProjectWorkflow,
ReleaseArchivedEvent,
ReleaseCreatedEvent,
ReleaseShippedEvent,
@@ -31,28 +31,19 @@ export interface SummaryContext {
issueKey: string;
projectKey: string;
releaseName: string;
/** The project's workflow: names of statuses and types. */
workflow: ProjectWorkflow;
}
/* The activity feed is read by people: Spanish, with the same labels as the web app. */
const STATUS: Record<IssueStatus, string> = {
[IssueStatus.BACKLOG]: 'Backlog',
[IssueStatus.TODO]: 'Por hacer',
[IssueStatus.IN_PROGRESS]: 'En curso',
[IssueStatus.IN_REVIEW]: 'En revisión',
[IssueStatus.READY_FOR_QA]: 'Listo para QA',
[IssueStatus.IN_QA]: 'En QA',
[IssueStatus.QA_FAILED]: 'QA fallido',
[IssueStatus.DONE]: 'Hecho',
[IssueStatus.CANCELLED]: 'Cancelado',
};
const TYPE: Record<IssueType, string> = {
[IssueType.BUG]: 'bug',
[IssueType.FEATURE]: 'feature',
[IssueType.UI_CHANGE]: 'cambio de UI',
[IssueType.TASK]: 'tarea',
[IssueType.QA]: 'QA',
/** Default types keep their lowercase wording in sentences ("creó WEB-3 (tarea)"). */
const DEFAULT_TYPE_WORDING: Record<string, string> = {
BUG: 'bug',
FEATURE: 'feature',
UI_CHANGE: 'cambio de UI',
TASK: 'tarea',
QA: 'QA',
};
const LINK: Record<IssueLinkType, string> = {
@@ -98,6 +89,8 @@ export function describeEvent(
return `${actor} creó el proyecto ${event.project.key}`;
if (event instanceof ProjectUpdatedEvent)
return `${actor} actualizó el proyecto ${ctx.projectKey}`;
if (event instanceof ProjectSettingsChangedEvent)
return `${actor} cambió los estados, tipos o áreas del proyecto ${ctx.projectKey}`;
if (event instanceof ProjectMemberAddedEvent) {
return `${actor} añadió a ${ctx.user(event.userId)} como ${ROLE[event.role]}`;
}
@@ -111,13 +104,19 @@ export function describeEvent(
}
if (event instanceof IssueCreatedEvent) {
return `${actor} creó ${event.issue.key} (${TYPE[event.issue.type]}): ${event.issue.title}`;
const type =
DEFAULT_TYPE_WORDING[event.issue.type] ??
ctx.workflow.typeName(event.issue.type);
return `${actor} creó ${event.issue.key} (${type}): ${event.issue.title}`;
}
if (event instanceof IssueUpdatedEvent) {
return `${actor} cambió ${list(Object.keys(event.changes).map((f) => FIELD[f] ?? f))} de ${issueKey}`;
}
if (event instanceof IssueStatusChangedEvent) {
return `${actor} movió ${issueKey} de ${STATUS[event.from]} a ${STATUS[event.to]}${event.reason ? `: ${event.reason}` : ''}`;
// Names as they were then (old events: as configured now).
const from = event.meta?.fromName ?? ctx.workflow.statusName(event.from);
const to = event.meta?.toName ?? ctx.workflow.statusName(event.to);
return `${actor} movió ${issueKey} de ${from} a ${to}${event.reason ? `: ${event.reason}` : ''}`;
}
if (event instanceof IssueAssignedEvent) {
return event.assigneeId

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,8 +5,6 @@ import {
IssueDetails,
IssueLinkType,
IssueReadModel,
IssueStatus,
IssueType,
} from '@task-stream/api/tracker/domain';
import { AssignIssueCommand } from './commands/assign-issue.command';
import { ChangeIssueStatusCommand } from './commands/change-issue-status.command';
@@ -21,10 +19,15 @@ import { GetActivityQuery } from './queries/get-activity.query';
import { GetIssueQuery } from './queries/get-issue.query';
import { GetIssuesByIdsQuery } from './queries/get-issues-by-ids.query';
import { SearchIssuesQuery } from './queries/search-issues.query';
import {
MyIssuesFilter,
SearchMyIssuesQuery,
} from './queries/search-my-issues.query';
export interface CreateIssueInput extends Omit<IssueDetails, 'title' | 'type'> {
projectId: string;
type: IssueType;
/** A type id of the project's settings. */
type: string;
title: string;
assigneeId?: string | null;
releaseId?: string | null;
@@ -71,7 +74,7 @@ export class IssuesService {
changeStatus(
actorId: string,
issueId: string,
status: IssueStatus,
status: string,
reason?: string | null,
): Promise<CommandResult> {
return this.commandBus.execute(
@@ -135,6 +138,14 @@ export class IssuesService {
return this.queryBus.execute(new SearchIssuesQuery(viewerId, filter));
}
/** Across all my projects: assigned to me, or any ticket (e.g. waiting for QA). */
mine(
viewerId: string,
filter: MyIssuesFilter,
): Promise<Page<IssueReadModel>> {
return this.queryBus.execute(new SearchMyIssuesQuery(viewerId, filter));
}
activity(
viewerId: string,
params: {

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import {
IssueLinkReadModel,
IssueReadModel,
StatusCategory,
} from '@task-stream/api/tracker/domain';
export abstract class UpsertMaterializedIssueRepository {
@@ -19,6 +20,11 @@ export abstract class UpsertMaterializedIssueRepository {
issueId: string,
commentCount: number,
): Promise<void>;
/** A project's statuses changed category: update its tickets' `statusCategory`. */
abstract recategorize(
projectId: string,
categories: Record<string, StatusCategory>,
): Promise<void>;
/** Keeps the denormalised release name of every issue in sync. */
abstract renameRelease(releaseId: string, name: string): Promise<void>;
}

View File

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

View File

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

View File

@@ -1,41 +1,49 @@
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { IssueStatus } from '@task-stream/api/tracker/domain';
import {
DEFAULT_PROJECT_SETTINGS,
StatusCategory,
} from '@task-stream/api/tracker/domain';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { BoardColumn, GetBoardQuery } from './get-board.query';
import { ReadAccess } from './read-access';
const BOARD_COLUMNS: IssueStatus[] = [
IssueStatus.BACKLOG,
IssueStatus.TODO,
IssueStatus.IN_PROGRESS,
IssueStatus.IN_REVIEW,
IssueStatus.READY_FOR_QA,
IssueStatus.IN_QA,
IssueStatus.QA_FAILED,
IssueStatus.DONE,
];
@QueryHandler(GetBoardQuery)
export class GetBoardQueryHandler
implements IQueryHandler<GetBoardQuery, BoardColumn[]>
{
constructor(
private readonly findIssues: FindIssuesRepository,
private readonly findProjects: FindProjectsRepository,
private readonly access: ReadAccess,
) {}
/**
* Columns follow the project's statuses in order. Cancelled ones stay off the
* board; archived ones only show while they still hold tickets.
*/
async execute(query: GetBoardQuery): Promise<BoardColumn[]> {
await this.access.assertCanView(query.filter.projectId, query.viewerId);
return Promise.all(
BOARD_COLUMNS.map(async (status) => {
const project = await this.findProjects.findById(query.filter.projectId);
const statuses = (
project?.settings ?? DEFAULT_PROJECT_SETTINGS
).statuses.filter((status) => status.category !== StatusCategory.CANCELLED);
const columns = await Promise.all(
statuses.map(async (status) => {
const page = await this.findIssues.search({
...query.filter,
statuses: [status],
statuses: [status.id],
limit: Math.min(query.perColumn, 200),
offset: 0,
});
return { status, issues: page.items, total: page.total };
return {
column: { status: status.id, issues: page.items, total: page.total },
archived: status.archived,
};
}),
);
return columns
.filter(({ column, archived }) => !archived || column.total > 0)
.map(({ column }) => column);
}
}

View File

@@ -1,14 +1,15 @@
import { Query } from '@nestjs/cqrs';
import { IssueReadModel, IssueStatus } from '@task-stream/api/tracker/domain';
import { IssueReadModel } from '@task-stream/api/tracker/domain';
import { IssueFilter } from '../ports/find-issues.repository';
export interface BoardColumn {
status: IssueStatus;
/** Status id of the project's workflow. */
status: string;
issues: IssueReadModel[];
total: number;
}
/** Kanban board: one column per status. */
/** Kanban board: one column per status of the project (cancelled ones aside). */
export class GetBoardQuery extends Query<BoardColumn[]> {
constructor(
public readonly viewerId: string,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import {
ReleaseBurnupReadModel,
ReleaseProgressReadModel,
ReleaseReadModel,
} from '@task-stream/api/tracker/domain';
@@ -11,6 +12,8 @@ import { PublishReleaseCommand } from './commands/publish-release.command';
import { UpdateReleaseCommand } from './commands/update-release.command';
import { GetReleaseProgressQuery } from './queries/get-release-progress.query';
import { GetReleaseQuery } from './queries/get-release.query';
import { GetUpcomingReleasesQuery } from './queries/get-upcoming-releases.query';
import { GetReleaseBurnupQuery } from './queries/get-release-burnup.query';
interface ReleaseDetails {
name?: string;
@@ -71,6 +74,11 @@ export class ReleasesService {
return this.queryBus.execute(new GetReleaseQuery(viewerId, releaseId));
}
/** Planned releases of all my projects, soonest target date first. */
upcoming(viewerId: string, limit: number): Promise<ReleaseReadModel[]> {
return this.queryBus.execute(new GetUpcomingReleasesQuery(viewerId, limit));
}
progress(
viewerId: string,
releaseId: string,
@@ -79,4 +87,11 @@ export class ReleasesService {
new GetReleaseProgressQuery(viewerId, releaseId),
);
}
/** Scope vs finished tickets of the release, day by day. */
burnup(viewerId: string, releaseId: string): Promise<ReleaseBurnupReadModel> {
return this.queryBus.execute(
new GetReleaseBurnupQuery(viewerId, releaseId),
);
}
}

View File

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

View File

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

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>`.
- `events/`: un fichero por evento (`IssueCreatedEvent`, `IssueStatusChangedEvent`…), todos con `@AutowiredEvent`.
- `factories/`: `ProjectFactory`, `IssueFactory`, `CommentFactory`, `ReleaseFactory` (`@Injectable()`).
- `issue/issue-workflow.ts`: transiciones de estado permitidas y estados que exigen motivo.
- `read-models/`: forma de las vistas de lectura (`ProjectReadModel`, `IssueReadModel`…).
- `project/project-settings.ts`: configuración por proyecto (estados con categoría, transiciones, tipos, áreas), sus
valores por defecto, `ProjectWorkflow` (lo que consultan tickets y queries) y `validateProjectSettings` (archivar,
nunca borrar).
- `read-models/`: forma de las vistas de lectura (`ProjectReadModel`, `IssueReadModel`…), incluida la línea temporal
de los tickets y las estadísticas (`analytics.read-model.ts`).
Reglas y workflow: [API](../../../../docs/03-api.md#31-modelo-de-dominio-contexto-tracker).

View File

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

View File

@@ -2,7 +2,7 @@ export * from './lib/project/project';
export * from './lib/project/project-role';
export * from './lib/issue/issue';
export * from './lib/issue/issue.enums';
export * from './lib/issue/issue-workflow';
export * from './lib/project/project-settings';
export * from './lib/comment/comment';
export * from './lib/release/release';
export * from './lib/release/release-status';

View File

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

View File

@@ -1,17 +1,27 @@
import { AutowiredEvent } from '@task-stream/api/shared/domain';
import { IssueStatus } from '../issue/issue.enums';
import type { StatusCategory } from '../project/project-settings';
import { TrackerEvent } from './tracker.event';
/** A ticket moved through the workflow (incl. QA). */
/** Names and categories of both statuses when the move happened (absent in old events). */
export interface StatusChangeMeta {
fromName: string;
toName: string;
fromCategory: StatusCategory;
toCategory: StatusCategory;
}
/** A ticket moved through its project's workflow (incl. QA). */
@AutowiredEvent
export class IssueStatusChangedEvent extends TrackerEvent {
constructor(
actorId: string,
issueId: string,
projectId: string,
readonly from: IssueStatus,
readonly to: IssueStatus,
/** Status ids of the project's workflow, e.g. IN_QA → QA_FAILED. */
readonly from: string,
readonly to: string,
readonly reason: string | null,
readonly meta?: StatusChangeMeta,
) {
super(actorId, issueId, projectId);
}

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,
normalizeIssueDetails,
} from '../issue/issue';
import { IssuePriority, IssueStatus, IssueType } from '../issue/issue.enums';
import { IssuePriority } from '../issue/issue.enums';
import { ProjectWorkflow } from '../project/project-settings';
export interface CreateIssueProps extends IssueDetails {
projectId: string;
projectKey: string;
number: number;
title: string;
type: IssueType;
type: string;
reporterId: string;
assigneeId?: string | null;
}
@Injectable()
export class IssueFactory {
/** New tickets start in BACKLOG with a project-scoped key (e.g. TS-7). */
create(props: CreateIssueProps): Issue {
/**
* New tickets start in the project's first active TODO status (BACKLOG by
* default) with a project-scoped key (e.g. TS-7).
*/
create(
props: CreateIssueProps,
workflow: ProjectWorkflow = ProjectWorkflow.default(),
): Issue {
const details = normalizeIssueDetails(props);
workflow.assertActiveType(props.type);
workflow.assertActiveArea(props.area);
const title = details.title ?? '';
assertThat(title.length > 0, 'Issue title is required');
@@ -37,7 +46,7 @@ export class IssueFactory {
area: props.area ?? null,
title,
description: details.description ?? null,
status: IssueStatus.BACKLOG,
status: workflow.initialStatus(),
priority: props.priority ?? IssuePriority.MEDIUM,
reporterId: props.reporterId,
assigneeId: props.assigneeId ?? null,

View File

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

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

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

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 './activity.read-model';
export * from './project-stats.read-model';
export * from './analytics.read-model';

View File

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

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';
export class CountBy<TKey = string> {
@@ -10,8 +10,9 @@ export class CountBy<TKey = string> {
export class ProjectStatsReadModel {
total!: number;
open!: number;
byStatus!: CountBy<IssueStatus>[];
byType!: CountBy<IssueType>[];
/** Keys are status / type ids of the project's settings. */
byStatus!: CountBy<string>[];
byType!: CountBy<string>[];
byPriority!: CountBy<IssuePriority>[];
byArea!: CountBy<string>[];
/** Open tickets per person (user null = unassigned). */

View File

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

View File

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

View File

@@ -8,7 +8,9 @@ Adaptadores de salida del tracker.
## Contenido
- `postgres/`: lookups de escritura (`project_keys`, `issue_sequences`, `release_names`) y `ReleaseIssuesRepository` (consulta el event store).
- `mongo/`: esquemas del read model, repositorios `Find*` y `UpsertMaterialized*` (upsert idempotente por versión).
- `mongo/`: esquemas del read model (incluida la línea temporal `issue_timeline`), repositorios `Find*` y
`UpsertMaterialized*` (upsert idempotente por versión) y `StatusCategoryBackfill` (al arrancar, rellena la categoría
del estado en los tickets proyectados antes de existir).
- `realtime/`: `RedisChangeFeed` (canales de Redis pub/sub por proyecto, ticket y usuario).
- `identity/`: `IdentityUserDirectory`, capa anticorrupción hacia el contexto de identidad.

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -10,7 +10,9 @@ API GraphQL y REST (OpenAPI) del tracker.
- Resolvers `ProjectResolver`, `IssueResolver`, `CommentResolver`, `ReleaseResolver` y `SubscriptionResolver`; solo llaman a services.
- Tipos, inputs (con `class-validator`) y enums de GraphQL (code-first).
- `http/`: controladores REST `ProjectsController`, `IssuesController`, `CommentsController`, `ReleasesController`
(mismos services), DTOs con `@nestjs/swagger` y `Refs` (proyectos y tickets por id o clave).
(mismos services), DTOs con `@nestjs/swagger` y `Refs` (proyectos y tickets por id o clave; añade a cada ticket sus
transiciones permitidas según el workflow de su proyecto).
- `common/settings.ts`: configuración del proyecto con las transiciones como lista (lo que viaja por GraphQL y REST).
Operaciones disponibles: [GraphQL](../../../../docs/03-api.md#32-graphql) y
[REST](../../../../docs/03-api.md#33-rest-con-openapi-para-otras-apps).

View File

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

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 {
IssueArea,
IssueLinkType,
IssuePriority,
IssueStatus,
IssueType,
ProjectRole,
ReleaseStatus,
StatusCategory,
} from '@task-stream/api/tracker/domain';
export enum LinkDirection {
@@ -14,13 +12,12 @@ export enum LinkDirection {
INWARD = 'INWARD',
}
registerEnumType(IssueType, { name: 'IssueType' });
registerEnumType(IssueArea, {
name: 'IssueArea',
description: 'Team/layer: links FE ↔ BE ↔ QA work',
});
registerEnumType(IssuePriority, { name: 'IssuePriority' });
registerEnumType(IssueStatus, { name: 'IssueStatus' });
registerEnumType(StatusCategory, {
name: 'StatusCategory',
description:
'What a status means, whatever each project calls it. DONE and CANCELLED are finished.',
});
registerEnumType(IssueLinkType, { name: 'IssueLinkType' });
registerEnumType(ProjectRole, { name: 'ProjectRole' });
registerEnumType(ReleaseStatus, { name: 'ReleaseStatus' });

View File

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

View File

@@ -13,11 +13,11 @@ import { CurrentUser } from '@task-stream/api/identity/presenters';
import {
CommentsService,
IssuesService,
WorkflowsService,
} from '@task-stream/api/tracker/application';
import {
IssueReadModel,
IssueStatus,
IssueWorkflow,
StatusCategory,
} from '@task-stream/api/tracker/domain';
import {
ChangeIssueStatusInput,
@@ -40,6 +40,7 @@ export class IssueResolver {
constructor(
private readonly issuesService: IssuesService,
private readonly commentsService: CommentsService,
private readonly workflows: WorkflowsService,
) {}
@Query(() => IssueGql)
@@ -61,6 +62,29 @@ export class IssueResolver {
return this.issuesService.search(me.id, { ...filter, limit, offset });
}
@Query(() => IssuePageGql, {
description:
'Tickets across all my projects: assigned to me (default) or any (e.g. categories [QA])',
})
myIssues(
@CurrentUser() me: User,
@Args('assignedToMe', { defaultValue: true }) assignedToMe: boolean,
@Args('statuses', { type: () => [String], nullable: true })
statuses: string[] | undefined,
@Args('categories', { type: () => [StatusCategory], nullable: true })
categories: StatusCategory[] | undefined,
@Args('limit', { type: () => Int, defaultValue: 50 }) limit: number,
@Args('offset', { type: () => Int, defaultValue: 0 }) offset: number,
) {
return this.issuesService.mine(me.id, {
assignedToMe,
statuses: statuses ?? undefined,
categories: categories ?? undefined,
limit,
offset,
});
}
@Query(() => [ActivityGql], {
description: 'Project activity feed (newest first)',
})
@@ -80,11 +104,11 @@ export class IssueResolver {
});
}
@ResolveField(() => [IssueStatus], {
description: 'Statuses this ticket can move to next',
@ResolveField(() => [String], {
description: "Statuses this ticket can move to next (project's workflow)",
})
allowedTransitions(@Parent() issue: IssueReadModel): readonly IssueStatus[] {
return IssueWorkflow.allowedFrom(issue.status);
allowedTransitions(@Parent() issue: IssueReadModel): Promise<string[]> {
return this.workflows.allowedTransitions(issue);
}
@ResolveField(() => [IssueGql], { description: 'Linked tickets I can see' })

View File

@@ -11,16 +11,20 @@ import {
import { User } from '@task-stream/api/identity/domain';
import { AccessToken, CurrentUser } from '@task-stream/api/identity/presenters';
import { ProjectsService } from '@task-stream/api/tracker/application';
import { IssueArea, ProjectReadModel } from '@task-stream/api/tracker/domain';
import { ProjectReadModel } from '@task-stream/api/tracker/domain';
import { fromSettingsPayload, toSettingsPayload } from '../common/settings';
import {
CreateProjectInput,
ProjectMemberInput,
ProjectSettingsInput,
UpdateProjectInput,
} from './inputs';
import {
BoardColumnGql,
CommandResultGql,
ProjectAnalyticsGql,
ProjectGql,
ProjectSettingsGql,
ProjectStatsGql,
ReleaseGql,
} from './types';
@@ -43,6 +47,23 @@ export class ProjectResolver {
return this.projects.findOne(me.id, idOrKey);
}
@ResolveField(() => ProjectSettingsGql)
settings(@Parent() project: ProjectReadModel) {
return toSettingsPayload(project.settings);
}
@ResolveField(() => ProjectAnalyticsGql, {
description:
'Statistics of the last `days` days (7-365): created vs resolved, cumulative flow, cycle/lead time, throughput and QA',
})
analytics(
@CurrentUser() me: User,
@Parent() project: ProjectReadModel,
@Args('days', { type: () => Int, defaultValue: 90 }) days: number,
) {
return this.projects.analytics(me.id, project.id, days);
}
@ResolveField(() => [ReleaseGql])
releases(@CurrentUser() me: User, @Parent() project: ProjectReadModel) {
return this.projects.releases(me.id, project.id);
@@ -63,7 +84,7 @@ export class ProjectResolver {
@Parent() project: ProjectReadModel,
@Args('releaseId', { type: () => ID, nullable: true }) releaseId?: string,
@Args('assigneeId', { type: () => ID, nullable: true }) assigneeId?: string,
@Args('area', { type: () => IssueArea, nullable: true }) area?: IssueArea,
@Args('area', { type: () => String, nullable: true }) area?: string,
@Args('perColumn', { type: () => Int, defaultValue: 50 }) perColumn = 50,
) {
return this.projects.board(
@@ -95,6 +116,22 @@ export class ProjectResolver {
return this.projects.update(me.id, projectId, changes);
}
@Mutation(() => CommandResultGql, {
description:
'Replace statuses, transitions, ticket types and areas (owners and admins). Values can be archived, not deleted.',
})
updateProjectSettings(
@CurrentUser() me: User,
@Args('projectId', { type: () => ID }) projectId: string,
@Args('settings') settings: ProjectSettingsInput,
) {
return this.projects.updateSettings(
me.id,
projectId,
fromSettingsPayload(settings),
);
}
@Mutation(() => CommandResultGql, {
description: 'Add a Pireon user to the project',
})

View File

@@ -19,6 +19,7 @@ import { CreateReleaseInput, UpdateReleaseInput } from './inputs';
import {
CommandResultGql,
IssuePageGql,
ReleaseBurnupGql,
ReleaseGql,
ReleaseProgressGql,
} from './types';
@@ -35,11 +36,29 @@ export class ReleaseResolver {
return this.releases.findOne(me.id, id);
}
@Query(() => [ReleaseGql], {
description:
'Planned releases of all my projects, soonest target date first',
})
upcomingReleases(
@CurrentUser() me: User,
@Args('limit', { type: () => Int, defaultValue: 10 }) limit: number,
) {
return this.releases.upcoming(me.id, limit);
}
@ResolveField(() => ReleaseProgressGql)
progress(@CurrentUser() me: User, @Parent() release: ReleaseReadModel) {
return this.releases.progress(me.id, release.id);
}
@ResolveField(() => ReleaseBurnupGql, {
description: 'Scope vs finished tickets, day by day',
})
burnup(@CurrentUser() me: User, @Parent() release: ReleaseReadModel) {
return this.releases.burnup(me.id, release.id);
}
@ResolveField(() => IssuePageGql, {
description: 'Tickets included in this release',
})

View File

@@ -1,12 +1,10 @@
import { Field, ID, Int, ObjectType } from '@nestjs/graphql';
import { Field, Float, ID, Int, ObjectType } from '@nestjs/graphql';
import {
IssueArea,
IssueLinkType,
IssuePriority,
IssueStatus,
IssueType,
ProjectRole,
ReleaseStatus,
StatusCategory,
} from '@task-stream/api/tracker/domain';
import { LinkDirection } from './enums';
@@ -22,6 +20,51 @@ export class ProjectMemberGql {
@Field(() => ProjectRole) role!: ProjectRole;
}
@ObjectType('WorkflowStatus')
export class WorkflowStatusGql {
@Field({ description: 'Stable id stored on tickets, e.g. IN_REVIEW' })
id!: string;
@Field() name!: string;
@Field(() => StatusCategory) category!: StatusCategory;
@Field({ description: 'Colour token: slate, violet, green…' }) color!: string;
@Field({ description: 'Entering it needs a reason' })
requiresReason!: boolean;
@Field({ description: 'No new tickets or transitions into it' })
archived!: boolean;
}
@ObjectType('StatusTransitions')
export class StatusTransitionsGql {
@Field() from!: string;
@Field(() => [String]) to!: string[];
}
@ObjectType('IssueTypeOption')
export class IssueTypeOptionGql {
@Field() id!: string;
@Field() name!: string;
@Field() icon!: string;
@Field() color!: string;
@Field() archived!: boolean;
}
@ObjectType('AreaOption')
export class AreaOptionGql {
@Field() id!: string;
@Field() name!: string;
@Field() archived!: boolean;
}
@ObjectType('ProjectSettings', {
description: 'Statuses (board order), transitions, ticket types and areas',
})
export class ProjectSettingsGql {
@Field(() => [WorkflowStatusGql]) statuses!: WorkflowStatusGql[];
@Field(() => [StatusTransitionsGql]) transitions!: StatusTransitionsGql[];
@Field(() => [IssueTypeOptionGql]) issueTypes!: IssueTypeOptionGql[];
@Field(() => [AreaOptionGql]) areas!: AreaOptionGql[];
}
@ObjectType('Project')
export class ProjectGql {
@Field(() => ID) id!: string;
@@ -59,11 +102,15 @@ export class IssueGql {
@Field() projectKey!: string;
@Field(() => Int) number!: number;
@Field({ description: 'Human key, e.g. TS-42' }) key!: string;
@Field(() => IssueType) type!: IssueType;
@Field(() => IssueArea, { nullable: true }) area!: IssueArea | null;
@Field({ description: "A ticket type id of the project's settings" })
type!: string;
@Field(() => String, { nullable: true, description: 'An area id' })
area!: string | null;
@Field() title!: string;
@Field(() => String, { nullable: true }) description!: string | null;
@Field(() => IssueStatus) status!: IssueStatus;
@Field({ description: "A status id of the project's settings" })
status!: string;
@Field(() => StatusCategory) statusCategory!: StatusCategory;
@Field(() => IssuePriority) priority!: IssuePriority;
@Field(() => UserRefGql) reporter!: UserRefGql;
@Field(() => UserRefGql, { nullable: true }) assignee!: UserRefGql | null;
@@ -88,7 +135,7 @@ export class IssuePageGql {
@ObjectType('BoardColumn')
export class BoardColumnGql {
@Field(() => IssueStatus) status!: IssueStatus;
@Field() status!: string;
@Field(() => [IssueGql]) issues!: IssueGql[];
@Field(() => Int) total!: number;
}
@@ -125,7 +172,7 @@ export class ReleaseGql {
@ObjectType('CountByStatus')
export class CountByStatusGql {
@Field(() => IssueStatus) key!: IssueStatus;
@Field({ description: 'Status id' }) key!: string;
@Field(() => Int) count!: number;
}
@@ -221,3 +268,77 @@ export class ProjectChangeGql {
@Field(() => ID) actorId!: string;
@Field(() => ProjectGql) project!: ProjectGql;
}
/* Statistics */
@ObjectType('CreatedResolved')
export class CreatedResolvedGql {
@Field({ description: 'Monday of the week (YYYY-MM-DD)' }) week!: string;
@Field(() => Int) created!: number;
@Field(() => Int) resolved!: number;
}
@ObjectType('FlowSeries')
export class FlowSeriesGql {
@Field() status!: string;
@Field(() => [Int]) counts!: number[];
}
@ObjectType('CumulativeFlow')
export class CumulativeFlowGql {
@Field(() => [String]) dates!: string[];
@Field(() => [FlowSeriesGql]) series!: FlowSeriesGql[];
}
@ObjectType('DurationStats')
export class DurationStatsGql {
@Field(() => Int) count!: number;
@Field(() => Float, { nullable: true }) averageDays!: number | null;
@Field(() => Float, {
nullable: true,
description: '85% of tickets take at most this long',
})
p85Days!: number | null;
}
@ObjectType('Throughput')
export class ThroughputGql {
@Field() week!: string;
@Field(() => UserRefGql, { nullable: true }) user!: UserRefGql | null;
@Field(() => Int) count!: number;
}
@ObjectType('QaStats')
export class QaStatsGql {
@Field(() => Int) passed!: number;
@Field(() => Int) failed!: number;
@Field(() => Float, { nullable: true }) failureRate!: number | null;
}
@ObjectType('ProjectAnalytics')
export class ProjectAnalyticsGql {
@Field() from!: string;
@Field() to!: string;
@Field(() => [CreatedResolvedGql]) createdVsResolved!: CreatedResolvedGql[];
@Field(() => CumulativeFlowGql, {
description: 'Tickets in each status at the end of every day',
})
cumulativeFlow!: CumulativeFlowGql;
@Field(() => DurationStatsGql, { description: 'Start of work → done' })
cycleTime!: DurationStatsGql;
@Field(() => DurationStatsGql, { description: 'Creation → done' })
leadTime!: DurationStatsGql;
@Field(() => [ThroughputGql], { description: 'Finished per person and week' })
throughput!: ThroughputGql[];
@Field(() => QaStatsGql) qa!: QaStatsGql;
}
@ObjectType('ReleaseBurnup')
export class ReleaseBurnupGql {
@Field(() => ID) releaseId!: string;
@Field(() => String, { nullable: true }) targetDate!: string | null;
@Field(() => [String]) dates!: string[];
@Field(() => [Int], { description: 'Tickets in the release (not cancelled)' })
scope!: number[];
@Field(() => [Int]) done!: number[];
}

View File

@@ -5,16 +5,17 @@ import {
PartialType,
} from '@nestjs/swagger';
import {
IssueArea,
IssueLinkType,
IssuePriority,
IssueStatus,
IssueType,
ProjectRole,
SETTING_COLORS,
StatusCategory,
TYPE_ICONS,
} from '@task-stream/api/tracker/domain';
import { Transform, Type } from 'class-transformer';
import {
ArrayMaxSize,
IsArray,
IsBoolean,
IsEnum,
IsIn,
@@ -29,6 +30,7 @@ import {
Min,
MinLength,
ValidateIf,
ValidateNested,
} from 'class-validator';
/* REST request bodies and query strings, validated like the GraphQL inputs. */
@@ -46,6 +48,9 @@ const toList = ({ value }: { value: unknown }) =>
const toBoolean = ({ value }: { value: unknown }) =>
value === undefined ? undefined : value === true || value === 'true';
/** Ids of statuses, types and areas (checked against the project's settings). */
const SETTING_ID = /^[A-Za-z][A-Za-z0-9_]{0,29}$/;
// ---- Projects ---------------------------------------------------------------
export class CreateProjectDto {
@@ -89,6 +94,121 @@ export class AddMemberDto {
role: ProjectRole = ProjectRole.MEMBER;
}
// ---- Project settings -------------------------------------------------------
export class WorkflowStatusBodyDto {
@ApiProperty({
example: 'IN_REVIEW',
description: 'Never change an existing id',
})
@Matches(SETTING_ID)
id!: string;
@ApiProperty({ example: 'En revisión', maxLength: 40 })
@IsString()
@MinLength(1)
@MaxLength(40)
name!: string;
@ApiProperty({ enum: StatusCategory, enumName: 'StatusCategory' })
@IsEnum(StatusCategory)
category!: StatusCategory;
@ApiProperty({ enum: SETTING_COLORS, enumName: 'SettingColor' })
@IsIn(SETTING_COLORS)
color!: string;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
requiresReason = false;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
archived = false;
}
export class StatusTransitionsBodyDto {
@ApiProperty({ example: 'IN_QA' }) @Matches(SETTING_ID) from!: string;
@ApiProperty({ type: [String], example: ['DONE', 'QA_FAILED'] })
@IsArray()
@ArrayMaxSize(50)
@Matches(SETTING_ID, { each: true })
to!: string[];
}
export class IssueTypeOptionBodyDto {
@ApiProperty({ example: 'SPIKE' }) @Matches(SETTING_ID) id!: string;
@ApiProperty({ example: 'Spike', maxLength: 40 })
@IsString()
@MinLength(1)
@MaxLength(40)
name!: string;
@ApiProperty({ enum: TYPE_ICONS, enumName: 'TypeIcon' })
@IsIn(TYPE_ICONS)
icon!: string;
@ApiProperty({ enum: SETTING_COLORS, enumName: 'SettingColor' })
@IsIn(SETTING_COLORS)
color!: string;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
archived = false;
}
export class AreaOptionBodyDto {
@ApiProperty({ example: 'DATA' }) @Matches(SETTING_ID) id!: string;
@ApiProperty({ example: 'Datos', maxLength: 40 })
@IsString()
@MinLength(1)
@MaxLength(40)
name!: string;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
archived = false;
}
export class ProjectSettingsBodyDto {
@ApiProperty({
type: [WorkflowStatusBodyDto],
description:
'In board order. Values cannot be removed: archive them instead.',
})
@IsArray()
@ArrayMaxSize(50)
@ValidateNested({ each: true })
@Type(() => WorkflowStatusBodyDto)
statuses!: WorkflowStatusBodyDto[];
@ApiProperty({ type: [StatusTransitionsBodyDto] })
@IsArray()
@ArrayMaxSize(50)
@ValidateNested({ each: true })
@Type(() => StatusTransitionsBodyDto)
transitions!: StatusTransitionsBodyDto[];
@ApiProperty({ type: [IssueTypeOptionBodyDto] })
@IsArray()
@ArrayMaxSize(50)
@ValidateNested({ each: true })
@Type(() => IssueTypeOptionBodyDto)
issueTypes!: IssueTypeOptionBodyDto[];
@ApiProperty({ type: [AreaOptionBodyDto] })
@IsArray()
@ArrayMaxSize(50)
@ValidateNested({ each: true })
@Type(() => AreaOptionBodyDto)
areas!: AreaOptionBodyDto[];
}
export class AnalyticsQueryDto {
@ApiPropertyOptional({ default: 90, minimum: 7, maximum: 365 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(7)
@Max(365)
days = 90;
}
export class ChangeMemberRoleDto {
@ApiProperty({ enum: ProjectRole, enumName: 'ProjectRole' })
@IsEnum(ProjectRole)
@@ -106,10 +226,10 @@ export class BoardQueryDto {
@IsString()
assigneeId?: string;
@ApiPropertyOptional({ enum: IssueArea, enumName: 'IssueArea' })
@ApiPropertyOptional({ example: 'FRONTEND', description: 'An area id' })
@IsOptional()
@IsEnum(IssueArea)
area?: IssueArea;
@Matches(SETTING_ID)
area?: string;
@ApiPropertyOptional({ default: 50, minimum: 1, maximum: 200 })
@IsOptional()
@@ -162,37 +282,79 @@ export class PageQueryDto {
// ---- Issues -----------------------------------------------------------------
export class MyIssuesQueryDto extends PageQueryDto {
@ApiPropertyOptional({
default: true,
description:
'true: tickets assigned to me. false: any ticket of my projects (e.g. ?assignedToMe=false&category=QA)',
})
@IsOptional()
@Transform(toBoolean)
@IsBoolean()
assignedToMe = true;
@ApiPropertyOptional({
type: [String],
description: 'Status ids. Repeat the parameter or separate with commas',
})
@IsOptional()
@Transform(toList)
@Matches(SETTING_ID, { each: true })
status?: string[];
@ApiPropertyOptional({
enum: StatusCategory,
enumName: 'StatusCategory',
isArray: true,
description: 'Whatever each project calls its statuses',
})
@IsOptional()
@Transform(toList)
@IsEnum(StatusCategory, { each: true })
category?: StatusCategory[];
}
export class UpcomingReleasesQueryDto {
@ApiPropertyOptional({ default: 10, minimum: 1, maximum: 50 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(50)
limit = 10;
}
export class IssueSearchQueryDto extends PageQueryDto {
@ApiPropertyOptional({
enum: IssueStatus,
enumName: 'IssueStatus',
isArray: true,
description: 'Repeat the parameter or separate with commas',
type: [String],
description: 'Status ids. Repeat the parameter or separate with commas',
})
@IsOptional()
@Transform(toList)
@IsEnum(IssueStatus, { each: true })
status?: IssueStatus[];
@Matches(SETTING_ID, { each: true })
status?: string[];
@ApiPropertyOptional({
enum: IssueType,
enumName: 'IssueType',
enum: StatusCategory,
enumName: 'StatusCategory',
isArray: true,
})
@IsOptional()
@Transform(toList)
@IsEnum(IssueType, { each: true })
type?: IssueType[];
@IsEnum(StatusCategory, { each: true })
category?: StatusCategory[];
@ApiPropertyOptional({
enum: IssueArea,
enumName: 'IssueArea',
isArray: true,
})
@ApiPropertyOptional({ type: [String], description: 'Ticket type ids' })
@IsOptional()
@Transform(toList)
@IsEnum(IssueArea, { each: true })
area?: IssueArea[];
@Matches(SETTING_ID, { each: true })
type?: string[];
@ApiPropertyOptional({ type: [String], description: 'Area ids' })
@IsOptional()
@Transform(toList)
@Matches(SETTING_ID, { each: true })
area?: string[];
@ApiPropertyOptional({
enum: IssuePriority,
@@ -241,13 +403,14 @@ class IssueFieldsDto {
description?: string | null;
@ApiPropertyOptional({
enum: IssueArea,
type: String,
nullable: true,
example: 'FRONTEND',
description: "An area id of the project's settings",
})
@IsOptional()
@IsEnum(IssueArea)
area?: IssueArea | null;
@Matches(SETTING_ID)
area?: string | null;
@ApiPropertyOptional({ enum: IssuePriority, enumName: 'IssuePriority' })
@IsOptional()
@@ -289,9 +452,12 @@ class IssueFieldsDto {
}
export class CreateIssueDto extends IssueFieldsDto {
@ApiProperty({ enum: IssueType, enumName: 'IssueType' })
@IsEnum(IssueType)
type!: IssueType;
@ApiProperty({
example: 'BUG',
description: "A ticket type id of the project's settings",
})
@Matches(SETTING_ID)
type!: string;
@ApiProperty({
example: 'El botón de pagar no responde en Safari',
@@ -318,10 +484,10 @@ export class CreateIssueDto extends IssueFieldsDto {
}
export class UpdateIssueDto extends IssueFieldsDto {
@ApiPropertyOptional({ enum: IssueType, enumName: 'IssueType' })
@ApiPropertyOptional({ example: 'FEATURE', description: 'A ticket type id' })
@IsOptional()
@IsEnum(IssueType)
type?: IssueType;
@Matches(SETTING_ID)
type?: string;
@ApiPropertyOptional({ maxLength: 255 })
@IsOptional()
@@ -332,14 +498,18 @@ export class UpdateIssueDto extends IssueFieldsDto {
}
export class ChangeStatusDto {
@ApiProperty({ enum: IssueStatus, enumName: 'IssueStatus' })
@IsEnum(IssueStatus)
status!: IssueStatus;
@ApiProperty({
example: 'IN_REVIEW',
description:
"A status id of the project's settings, among the ticket's allowedTransitions",
})
@Matches(SETTING_ID)
status!: string;
@ApiPropertyOptional({
type: String,
nullable: true,
description: 'Required for QA_FAILED and CANCELLED',
description: 'Required by statuses with requiresReason (e.g. QA_FAILED)',
})
@IsOptional()
@IsString()

View File

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

View File

@@ -39,6 +39,7 @@ import {
IssueSearchQueryDto,
LinkIssueDto,
LinkTypeParamDto,
MyIssuesQueryDto,
PageQueryDto,
SetIssueReleaseDto,
UpdateIssueDto,
@@ -50,7 +51,7 @@ import {
IssueDto,
IssuePageDto,
} from './dto/responses.dto';
import { Refs, toIssueDto } from './refs';
import { Refs } from './refs';
const PROJECT = ApiParam({
name: 'project',
@@ -74,6 +75,25 @@ export class IssuesController {
private readonly refs: Refs,
) {}
@Get('me/issues')
@ApiOperation({
summary: 'My work: tickets across all my projects',
description:
'Assigned to me by default; with assignedToMe=false, any ticket of my projects (e.g. waiting for QA).',
})
@ApiOkResponse({ type: IssuePageDto })
@ApiReadErrors()
async mine(@CurrentUser() me: User, @Query() query: MyIssuesQueryDto) {
const page = await this.issues.mine(me.id, {
assignedToMe: query.assignedToMe,
statuses: query.status,
categories: query.category,
limit: query.limit,
offset: query.offset,
});
return { ...page, items: await this.refs.issueDtos(page.items) };
}
@Get('projects/:project/issues')
@PROJECT
@ApiOperation({ summary: 'Search the tickets of a project' })
@@ -87,6 +107,7 @@ export class IssuesController {
const page = await this.issues.search(me.id, {
projectId: await this.refs.projectId(me.id, project),
statuses: query.status,
categories: query.category,
types: query.type,
areas: query.area,
priorities: query.priority,
@@ -98,7 +119,7 @@ export class IssuesController {
limit: query.limit,
offset: query.offset,
});
return { ...page, items: page.items.map(toIssueDto) };
return { ...page, items: await this.refs.issueDtos(page.items) };
}
@Post('projects/:project/issues')
@@ -106,7 +127,7 @@ export class IssuesController {
@ApiOperation({
summary: 'Create a ticket',
description:
'Starts in BACKLOG. The response carries its key (e.g. WEB-13).',
"Starts in the project's first active TODO-category status (BACKLOG by default). The response carries its key (e.g. WEB-13).",
})
@ApiCreatedResponse({ type: CommandResultDto })
@ApiCommandErrors()
@@ -127,7 +148,7 @@ export class IssuesController {
@ApiOkResponse({ type: IssueDto })
@ApiReadErrors()
async findOne(@CurrentUser() me: User, @Param('issue') issue: string) {
return toIssueDto(await this.issues.findOne(me.id, issue));
return this.refs.issueDto(await this.issues.findOne(me.id, issue));
}
@Patch('issues/:issue')

View File

@@ -6,6 +6,7 @@ import {
Param,
Patch,
Post,
Put,
Query,
} from '@nestjs/common';
import {
@@ -32,19 +33,23 @@ import {
import {
ActivityQueryDto,
AddMemberDto,
AnalyticsQueryDto,
BoardQueryDto,
ChangeMemberRoleDto,
CreateProjectDto,
ProjectSettingsBodyDto,
UpdateProjectDto,
} from './dto/requests.dto';
import {
ActivityDto,
BoardColumnDto,
CommandResultDto,
ProjectAnalyticsDto,
ProjectDto,
ProjectStatsDto,
} from './dto/responses.dto';
import { Refs, toIssueDto } from './refs';
import { fromSettingsPayload } from '../common/settings';
import { Refs, toProjectDto } from './refs';
const PROJECT = ApiParam({
name: 'project',
@@ -67,8 +72,8 @@ export class ProjectsController {
@ApiOperation({ summary: 'Projects where I am a member' })
@ApiOkResponse({ type: [ProjectDto] })
@ApiReadErrors()
mine(@CurrentUser() me: User) {
return this.projects.findMine(me.id);
async mine(@CurrentUser() me: User) {
return (await this.projects.findMine(me.id)).map(toProjectDto);
}
@Post()
@@ -81,11 +86,11 @@ export class ProjectsController {
@Get(':project')
@PROJECT
@ApiOperation({ summary: 'A project with its members' })
@ApiOperation({ summary: 'A project with its members and settings' })
@ApiOkResponse({ type: ProjectDto })
@ApiReadErrors()
findOne(@CurrentUser() me: User, @Param('project') project: string) {
return this.projects.findOne(me.id, project);
async findOne(@CurrentUser() me: User, @Param('project') project: string) {
return toProjectDto(await this.projects.findOne(me.id, project));
}
@Patch(':project')
@@ -105,6 +110,28 @@ export class ProjectsController {
);
}
@Put(':project/settings')
@PROJECT
@ApiOperation({
summary: 'Statuses, transitions, ticket types and areas (owner/admin)',
description:
'Replaces the whole configuration (send what GET /projects/{project} returns, changed). ' +
'Values can be added, renamed, reordered or archived, never removed: tickets and statistics keep referring to them.',
})
@ApiOkResponse({ type: CommandResultDto })
@ApiCommandErrors()
async updateSettings(
@CurrentUser() me: User,
@Param('project') project: string,
@Body() body: ProjectSettingsBodyDto,
) {
return this.projects.updateSettings(
me.id,
await this.refs.projectId(me.id, project),
fromSettingsPayload(body),
);
}
@Get(':project/board')
@PROJECT
@ApiOperation({ summary: 'Kanban board: one column per status' })
@@ -125,10 +152,12 @@ export class ProjectsController {
},
query.perColumn,
);
return columns.map((column) => ({
return Promise.all(
columns.map(async (column) => ({
...column,
issues: column.issues.map(toIssueDto),
}));
issues: await this.refs.issueDtos(column.issues),
})),
);
}
@Get(':project/stats')
@@ -145,6 +174,27 @@ export class ProjectsController {
);
}
@Get(':project/analytics')
@PROJECT
@ApiOperation({
summary: 'Statistics of the last `days` days',
description:
'Created vs resolved per week, cumulative flow per day, cycle and lead time, throughput per person and QA pass/fail.',
})
@ApiOkResponse({ type: ProjectAnalyticsDto })
@ApiReadErrors()
async analytics(
@CurrentUser() me: User,
@Param('project') project: string,
@Query() query: AnalyticsQueryDto,
) {
return this.projects.analytics(
me.id,
await this.refs.projectId(me.id, project),
query.days,
);
}
@Get(':project/activity')
@PROJECT
@ApiOperation({ summary: 'Activity feed (newest first)' })

View File

@@ -2,8 +2,13 @@ import { Injectable } from '@nestjs/common';
import {
IssuesService,
ProjectsService,
WorkflowsService,
} from '@task-stream/api/tracker/application';
import { IssueReadModel, IssueWorkflow } from '@task-stream/api/tracker/domain';
import {
IssueReadModel,
ProjectReadModel,
} from '@task-stream/api/tracker/domain';
import { toSettingsPayload } from '../common/settings';
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -17,6 +22,7 @@ export class Refs {
constructor(
private readonly projects: ProjectsService,
private readonly issues: IssuesService,
private readonly workflows: WorkflowsService,
) {}
async projectId(viewerId: string, ref: string): Promise<string> {
@@ -28,12 +34,21 @@ export class Refs {
if (UUID.test(ref)) return ref;
return (await this.issues.findOne(viewerId, ref)).id;
}
}
/** The ticket as the REST API returns it: read model + allowed transitions. */
export function toIssueDto(issue: IssueReadModel) {
/** The ticket as the REST API returns it: read model + allowed transitions. */
async issueDto(issue: IssueReadModel) {
return {
...issue,
allowedTransitions: IssueWorkflow.allowedFrom(issue.status),
allowedTransitions: await this.workflows.allowedTransitions(issue),
};
}
issueDtos(issues: IssueReadModel[]) {
return Promise.all(issues.map((issue) => this.issueDto(issue)));
}
}
/** The project as the REST API returns it: transitions as a list. */
export function toProjectDto(project: ProjectReadModel) {
return { ...project, settings: toSettingsPayload(project.settings) };
}

View File

@@ -33,15 +33,17 @@ import {
CreateReleaseDto,
PageQueryDto,
PublishReleaseDto,
UpcomingReleasesQueryDto,
UpdateReleaseDto,
} from './dto/requests.dto';
import {
CommandResultDto,
IssuePageDto,
ReleaseDto,
ReleaseBurnupDto,
ReleaseProgressDto,
} from './dto/responses.dto';
import { Refs, toIssueDto } from './refs';
import { Refs } from './refs';
const PROJECT = ApiParam({
name: 'project',
@@ -61,6 +63,16 @@ export class ReleasesController {
private readonly refs: Refs,
) {}
@Get('me/releases')
@ApiOperation({
summary: 'Planned releases of all my projects, soonest target date first',
})
@ApiOkResponse({ type: [ReleaseDto] })
@ApiReadErrors()
upcoming(@CurrentUser() me: User, @Query() query: UpcomingReleasesQueryDto) {
return this.releases.upcoming(me.id, query.limit);
}
@Get('projects/:project/releases')
@PROJECT
@ApiOperation({ summary: 'Releases of a project' })
@@ -117,6 +129,16 @@ export class ReleasesController {
return this.releases.progress(me.id, releaseId);
}
@Get('releases/:releaseId/burnup')
@ApiOperation({
summary: 'Burnup: tickets in the release and finished ones, day by day',
})
@ApiOkResponse({ type: ReleaseBurnupDto })
@ApiReadErrors()
burnup(@CurrentUser() me: User, @Param('releaseId') releaseId: string) {
return this.releases.burnup(me.id, releaseId);
}
@Get('releases/:releaseId/issues')
@ApiOperation({ summary: 'Tickets included in the release' })
@ApiOkResponse({ type: IssuePageDto })
@@ -133,7 +155,7 @@ export class ReleasesController {
limit: page.limit,
offset: page.offset,
});
return { ...found, items: found.items.map(toIssueDto) };
return { ...found, items: await this.refs.issueDtos(found.items) };
}
@Post('releases/:releaseId/publish')

View File

@@ -7,9 +7,11 @@ Estado del tracker (NgRx Signals con eventos) detrás de facades.
## Contenido
- Facades (lo que se exporta): `ProjectsFacade` (raíz) y `ProjectFacade`, `BoardFacade`, `IssueFacade` (de ruta, con
- Facades (lo que se exporta): `ProjectsFacade` y `HomeFacade` («Mi trabajo») (raíz) y `ProjectFacade`, `BoardFacade`, `IssueFacade` (de ruta, con
`provideProjectFacade()`, `provideBoardFacade()`, `provideIssueFacade()`). Tests en `facades/facades.spec.ts`.
- Interno: stores `ProjectsStore`, `ProjectStore`, `BoardStore`, `IssueStore` y eventos por origen
- `ProjectFacade` también da la configuración del proyecto (`workflow`, `saveSettings`) y sus estadísticas
(`loadAnalytics`, `loadBurnup`).
- Interno: stores `ProjectsStore`, `ProjectStore`, `HomeStore`, `BoardStore`, `IssueStore` y eventos por origen
(`*PageEvents`, `*ApiEvents`, `*RealtimeEvents`).
- `board.reducers.ts`: transformaciones puras del tablero (movimiento optimista, cambios en vivo por versión).
- Puerto `Notifications`.

View File

@@ -3,3 +3,4 @@ export * from './lib/facades/projects.facade';
export * from './lib/facades/project.facade';
export * from './lib/facades/board.facade';
export * from './lib/facades/issue.facade';
export * from './lib/facades/home.facade';

Some files were not shown because too many files have changed in this diff Show More