Compare commits
14 Commits
21bc598590
...
0946e9517d
| Author | SHA1 | Date | |
|---|---|---|---|
| 0946e9517d | |||
| affce615a7 | |||
| 8bf7d47b2e | |||
| f60d4c8346 | |||
| 869acd3f1f | |||
| 4acf629106 | |||
| e598f76e1c | |||
| 5b0396bc10 | |||
| e14f445e6b | |||
| 76312c079d | |||
| 0d158a2ed7 | |||
| fe03c5bda8 | |||
| 86e313efaa | |||
| e5f4e6c683 |
@@ -1279,4 +1279,696 @@ describe('task-stream (e2e)', () => {
|
||||
history.body.map((h: { summary: string }) => h.summary).join(' | '),
|
||||
).toContain('los campos personalizados');
|
||||
});
|
||||
|
||||
it('starts a project from another one: its configuration and rules', async () => {
|
||||
const stamp = Date.now().toString(36).slice(-4).toUpperCase();
|
||||
const source = `T${stamp}`;
|
||||
await rest('token-ana', 'POST', '/projects', { key: source, name: source });
|
||||
const created = await eventually(
|
||||
() => rest('token-ana', 'GET', `/projects/${source}`),
|
||||
(r) => r.status === 200,
|
||||
);
|
||||
const sourceId = created.body['id'];
|
||||
await rest('token-ana', 'POST', `/projects/${source}/members`, {
|
||||
userId: '102',
|
||||
});
|
||||
const settings = created.body['settings'];
|
||||
settings.areas.push({ id: 'DATA', name: 'Datos', archived: false });
|
||||
expect(
|
||||
(
|
||||
await rest('token-ana', 'PUT', `/projects/${source}/settings`, {
|
||||
...settings,
|
||||
customFields: [
|
||||
{
|
||||
id: 'CLIENT',
|
||||
name: 'Cliente',
|
||||
type: 'SELECT',
|
||||
options: ['Acme'],
|
||||
},
|
||||
],
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
const rule = (name: string, kind: string, value: string) =>
|
||||
gql(
|
||||
'token-ana',
|
||||
`mutation($i: CreateAutomationInput!) { createAutomation(input: $i) { id } }`,
|
||||
{
|
||||
i: {
|
||||
projectId: sourceId,
|
||||
name,
|
||||
trigger: { kind: 'ISSUE_CREATED' },
|
||||
action: { kind, value },
|
||||
},
|
||||
},
|
||||
);
|
||||
const paused = (await rule('Etiquetar', 'ADD_LABEL', 'nuevo'))[
|
||||
'createAutomation'
|
||||
].id;
|
||||
await gql(
|
||||
'token-ana',
|
||||
`mutation($a: ID!) { updateAutomation(automationId: $a, active: false) { id } }`,
|
||||
{ a: paused },
|
||||
);
|
||||
// Bob is in the source project only: this one stays behind.
|
||||
await rule('Para Bob', 'ASSIGN', '102');
|
||||
|
||||
// Only who manages the source can copy it.
|
||||
expect(
|
||||
(
|
||||
await rest('token-bob', 'POST', '/projects', {
|
||||
key: `B${stamp}`,
|
||||
name: 'Copia',
|
||||
template: source,
|
||||
})
|
||||
).status,
|
||||
).toBe(403);
|
||||
|
||||
const copy = `C${stamp}`;
|
||||
const { createProject } = await gql(
|
||||
'token-ana',
|
||||
`mutation($i: CreateProjectInput!) { createProject(input: $i) { id key } }`,
|
||||
{ i: { key: copy, name: 'Copia', templateProjectId: sourceId } },
|
||||
);
|
||||
expect(createProject.key).toBe(copy);
|
||||
const copied = await eventually(
|
||||
() => rest('token-ana', 'GET', `/projects/${copy}`),
|
||||
(r) => r.status === 200 && r.body['settings'].customFields.length === 1,
|
||||
);
|
||||
expect(
|
||||
copied.body['settings'].areas.map((a: { id: string }) => a.id),
|
||||
).toContain('DATA');
|
||||
expect(copied.body['settings'].customFields[0].options).toEqual(['Acme']);
|
||||
const { automations } = await gql(
|
||||
'token-ana',
|
||||
`query($p: ID!) { automations(projectId: $p) { name active } }`,
|
||||
{ p: createProject.id },
|
||||
);
|
||||
expect(automations).toEqual([{ name: 'Etiquetar', active: false }]);
|
||||
});
|
||||
|
||||
it('reads the dashboard: sprint under way, risks, aging and bottlenecks', async () => {
|
||||
const key = 'D' + Date.now().toString(36).slice(-5).toUpperCase();
|
||||
await rest('token-ana', 'POST', '/projects', { key, name: key });
|
||||
await eventually(
|
||||
() => rest('token-ana', 'GET', `/projects/${key}`),
|
||||
(r) => r.status === 200,
|
||||
);
|
||||
const late = (
|
||||
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
|
||||
type: 'TASK',
|
||||
title: 'Late',
|
||||
dueDate: '2020-01-01',
|
||||
})
|
||||
).body['key'];
|
||||
const waits = (
|
||||
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
|
||||
type: 'TASK',
|
||||
title: 'Waits',
|
||||
})
|
||||
).body['key'];
|
||||
await eventually(
|
||||
() => rest('token-ana', 'GET', `/issues/${waits}`),
|
||||
(r) => r.status === 200,
|
||||
);
|
||||
await rest('token-ana', 'POST', `/issues/${late}/links`, {
|
||||
type: 'BLOCKS',
|
||||
targetIssue: waits,
|
||||
});
|
||||
const insights = await eventually(
|
||||
() => rest('token-ana', 'GET', `/projects/${key}/insights`),
|
||||
(r) => r.status === 200 && r.body['risks'].blockedCount === 1,
|
||||
);
|
||||
expect(insights.body['sprint']).toBeNull();
|
||||
expect(insights.body['risks'].overdue[0]).toMatchObject({
|
||||
key: late,
|
||||
dueDate: '2020-01-01',
|
||||
});
|
||||
expect(insights.body['risks'].blocked[0].key).toBe(waits);
|
||||
expect(
|
||||
insights.body['bottlenecks'].map((b: { status: string }) => b.status),
|
||||
).toContain('IN_PROGRESS');
|
||||
const { project } = await gql(
|
||||
'token-ana',
|
||||
`query($k: String!) { project(idOrKey: $k) { insights { risks { overdueCount } aging { thresholdDays } } } }`,
|
||||
{ k: key },
|
||||
);
|
||||
expect(project.insights.risks.overdueCount).toBe(1);
|
||||
|
||||
// What changed since a moment, and what the backlog lacks.
|
||||
const since = new Date(Date.now() - 60_000).toISOString();
|
||||
const later = await rest(
|
||||
'token-ana',
|
||||
'GET',
|
||||
`/projects/${key}/insights?since=${encodeURIComponent(since)}`,
|
||||
);
|
||||
expect(later.body['changes']).toMatchObject({ since, createdCount: 2 });
|
||||
expect(later.body['backlog']).toMatchObject({
|
||||
open: 2,
|
||||
unestimated: 2,
|
||||
unassigned: 2,
|
||||
});
|
||||
expect(
|
||||
later.body['team'].map((t: { user: { id: string } }) => t.user.id),
|
||||
).toContain('101');
|
||||
const unestimated = await rest(
|
||||
'token-ana',
|
||||
'GET',
|
||||
`/projects/${key}/issues?noEstimate=true&noArea=true`,
|
||||
);
|
||||
expect(unestimated.body['total']).toBe(2);
|
||||
expect(later.body['quality'].openBugsByPriority).toHaveLength(5);
|
||||
expect(later.body['quality'].bugWeeks).toHaveLength(8);
|
||||
expect(later.body['fields']).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps my own views of my work, across my projects', async () => {
|
||||
const key = 'V' + Date.now().toString(36).slice(-5).toUpperCase();
|
||||
await rest('token-ana', 'POST', '/projects', { key, name: key });
|
||||
const project = await eventually(
|
||||
() => rest('token-ana', 'GET', `/projects/${key}`),
|
||||
(r) => r.status === 200,
|
||||
);
|
||||
const bug = (
|
||||
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
|
||||
type: 'BUG',
|
||||
title: 'Late bug',
|
||||
priority: 'CRITICAL',
|
||||
dueDate: '2020-01-01',
|
||||
})
|
||||
).body['key'];
|
||||
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
|
||||
type: 'TASK',
|
||||
title: 'Late task',
|
||||
priority: 'CRITICAL',
|
||||
dueDate: '2020-01-01',
|
||||
});
|
||||
await eventually(
|
||||
() => rest('token-ana', 'GET', `/issues/${key}-2`),
|
||||
(r) => r.status === 200,
|
||||
);
|
||||
|
||||
const criteria = {
|
||||
projectIds: [project.body['id']],
|
||||
priorities: ['CRITICAL'],
|
||||
bugsOnly: true,
|
||||
overdue: true,
|
||||
};
|
||||
const saved = await rest('token-ana', 'POST', '/me/views', {
|
||||
name: ' Bugs críticos vencidos ',
|
||||
criteria,
|
||||
});
|
||||
expect(saved.status).toBe(201);
|
||||
const views = await rest('token-ana', 'GET', '/me/views');
|
||||
expect(views.body).toEqual([
|
||||
expect.objectContaining({ name: 'Bugs críticos vencidos', criteria }),
|
||||
]);
|
||||
expect((await rest('token-bob', 'GET', '/me/views')).body).toEqual([]);
|
||||
|
||||
// Running it: the same criteria on my work.
|
||||
const page = await rest(
|
||||
'token-ana',
|
||||
'GET',
|
||||
`/me/issues?assignedToMe=false&project=${project.body['id']}&priority=CRITICAL&bugsOnly=true&overdue=true`,
|
||||
);
|
||||
expect(page.body['items'].map((i: { key: string }) => i.key)).toEqual([
|
||||
bug,
|
||||
]);
|
||||
const { myIssues } = await gql(
|
||||
'token-ana',
|
||||
`query($p: [ID!]) { myIssues(assignedToMe: false, projectIds: $p, overdue: true) { total } }`,
|
||||
{ p: [project.body['id']] },
|
||||
);
|
||||
expect(myIssues.total).toBe(2);
|
||||
|
||||
// Only its owner deletes it.
|
||||
const id = saved.body['id'];
|
||||
expect((await rest('token-bob', 'DELETE', `/me/views/${id}`)).status).toBe(
|
||||
404,
|
||||
);
|
||||
expect((await rest('token-ana', 'DELETE', `/me/views/${id}`)).status).toBe(
|
||||
204,
|
||||
);
|
||||
expect((await rest('token-ana', 'GET', '/me/views')).body).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps unfinished checklists out of DONE when the project asks', async () => {
|
||||
const key = 'H' + Date.now().toString(36).slice(-5).toUpperCase();
|
||||
await rest('token-ana', 'POST', '/projects', { key, name: key });
|
||||
const project = await eventually(
|
||||
() => rest('token-ana', 'GET', `/projects/${key}`),
|
||||
(r) => r.status === 200,
|
||||
);
|
||||
expect(project.body['settings'].checklistBeforeDone).toBe(false);
|
||||
await rest('token-ana', 'POST', `/projects/${key}/members`, {
|
||||
userId: '102',
|
||||
});
|
||||
expect(
|
||||
(
|
||||
await rest('token-ana', 'PUT', `/projects/${key}/settings`, {
|
||||
...project.body['settings'],
|
||||
checklistBeforeDone: true,
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
const issue = (
|
||||
await rest('token-bob', 'POST', `/projects/${key}/issues`, {
|
||||
type: 'TASK',
|
||||
title: 'Con pasos',
|
||||
})
|
||||
).body['key'];
|
||||
await eventually(
|
||||
() => rest('token-bob', 'GET', `/issues/${issue}`),
|
||||
(r) => r.status === 200,
|
||||
);
|
||||
await rest('token-bob', 'POST', `/issues/${issue}/checklist`, {
|
||||
text: 'Probar en Safari',
|
||||
});
|
||||
for (const status of [
|
||||
'TODO',
|
||||
'IN_PROGRESS',
|
||||
'IN_REVIEW',
|
||||
'READY_FOR_QA',
|
||||
'IN_QA',
|
||||
])
|
||||
await rest('token-bob', 'PUT', `/issues/${issue}/status`, { status });
|
||||
|
||||
const refused = await rest('token-bob', 'PUT', `/issues/${issue}/status`, {
|
||||
status: 'DONE',
|
||||
});
|
||||
expect(refused.status).toBe(422);
|
||||
expect(refused.body['message']).toMatch(/queda 1 paso/);
|
||||
// The owner may close it anyway.
|
||||
expect(
|
||||
(
|
||||
await rest('token-ana', 'PUT', `/issues/${issue}/status`, {
|
||||
status: 'DONE',
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
});
|
||||
|
||||
it('archives a project: readable, read-only, back on demand', async () => {
|
||||
const key = 'A' + Date.now().toString(36).slice(-5).toUpperCase();
|
||||
await rest('token-ana', 'POST', '/projects', { key, name: key });
|
||||
await eventually(
|
||||
() => rest('token-ana', 'GET', `/projects/${key}`),
|
||||
(r) => r.status === 200,
|
||||
);
|
||||
const added = await rest('token-ana', 'POST', `/projects/${key}/members`, {
|
||||
userId: '102',
|
||||
});
|
||||
expect(added.status).toBe(201);
|
||||
await eventually(
|
||||
() => rest('token-bob', 'GET', `/projects/${key}`),
|
||||
(r) => r.status === 200,
|
||||
);
|
||||
const created = await rest('token-bob', 'POST', `/projects/${key}/issues`, {
|
||||
type: 'TASK',
|
||||
title: 'Antes de archivar',
|
||||
});
|
||||
expect(created.body).toMatchObject({ key: `${key}-1` });
|
||||
const ticket = created.body['key'] as string;
|
||||
await eventually(
|
||||
() => rest('token-bob', 'GET', `/issues/${ticket}`),
|
||||
(r) => r.status === 200,
|
||||
);
|
||||
|
||||
expect(
|
||||
(await rest('token-bob', 'POST', `/projects/${key}/archive`)).status,
|
||||
).toBe(403);
|
||||
expect(
|
||||
(await rest('token-ana', 'POST', `/projects/${key}/archive`)).status,
|
||||
).toBe(200);
|
||||
const archived = await eventually(
|
||||
() => rest('token-bob', 'GET', `/projects/${key}`),
|
||||
(r) => !!r.body['archivedAt'],
|
||||
);
|
||||
expect(archived.status).toBe(200);
|
||||
// Still readable; nothing changes.
|
||||
expect((await rest('token-bob', 'GET', `/issues/${ticket}`)).status).toBe(
|
||||
200,
|
||||
);
|
||||
const refused = await rest('token-bob', 'PATCH', `/issues/${ticket}`, {
|
||||
title: 'Después',
|
||||
});
|
||||
expect(refused.status).toBe(422);
|
||||
expect(refused.body['message']).toMatch(/archivado/);
|
||||
expect(
|
||||
(
|
||||
await rest('token-bob', 'POST', `/projects/${key}/issues`, {
|
||||
type: 'TASK',
|
||||
title: 'Nuevo',
|
||||
})
|
||||
).status,
|
||||
).toBe(422);
|
||||
|
||||
const { unarchiveProject } = await gql(
|
||||
'token-ana',
|
||||
`mutation($p: ID!) { unarchiveProject(projectId: $p) { version } }`,
|
||||
{ p: archived.body['id'] },
|
||||
);
|
||||
expect(unarchiveProject.version).toBeGreaterThan(0);
|
||||
await eventually(
|
||||
() => rest('token-bob', 'GET', `/projects/${key}`),
|
||||
(r) => r.body['archivedAt'] === null,
|
||||
);
|
||||
expect(
|
||||
(
|
||||
await rest('token-bob', 'PATCH', `/issues/${ticket}`, {
|
||||
title: 'Después',
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
});
|
||||
|
||||
it('groups members in teams: tickets go to them and @team tells them', async () => {
|
||||
const key = 'T' + Date.now().toString(36).slice(-5).toUpperCase();
|
||||
await rest('token-ana', 'POST', '/projects', { key, name: key });
|
||||
const project = await eventually(
|
||||
() => rest('token-ana', 'GET', `/projects/${key}`),
|
||||
(r) => r.status === 200,
|
||||
);
|
||||
await rest('token-ana', 'POST', `/projects/${key}/members`, {
|
||||
userId: '102',
|
||||
});
|
||||
expect(
|
||||
(
|
||||
await rest('token-ana', 'PUT', `/projects/${key}/settings`, {
|
||||
...project.body['settings'],
|
||||
teams: [
|
||||
{ id: 'FRONT', name: 'Frontend', memberIds: ['102', 'nobody'] },
|
||||
],
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
const settings = await eventually(
|
||||
() => rest('token-ana', 'GET', `/projects/${key}`),
|
||||
(r) => r.body['settings'].teams.length === 1,
|
||||
);
|
||||
expect(settings.body['settings'].teams[0].memberIds).toEqual(['102']);
|
||||
|
||||
const created = await rest('token-ana', 'POST', `/projects/${key}/issues`, {
|
||||
type: 'TASK',
|
||||
title: 'Para el equipo',
|
||||
team: 'FRONT',
|
||||
});
|
||||
const ticket = created.body['key'];
|
||||
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
|
||||
type: 'TASK',
|
||||
title: 'Sin equipo',
|
||||
});
|
||||
const mine = await eventually(
|
||||
() => rest('token-ana', 'GET', `/projects/${key}/issues?team=FRONT`),
|
||||
(r) => r.body?.['total'] === 1,
|
||||
);
|
||||
expect(mine.body['items'][0]).toMatchObject({ key: ticket, team: 'FRONT' });
|
||||
|
||||
await rest('token-ana', 'POST', `/issues/${ticket}/comments`, {
|
||||
body: '¿Lo miráis, @[Frontend](team-FRONT)?',
|
||||
});
|
||||
const told = await eventually(
|
||||
() => rest('token-bob', 'GET', '/me/notifications'),
|
||||
(r) =>
|
||||
r.body?.items?.some(
|
||||
(n: { kind: string; issueKey: string }) =>
|
||||
n.kind === 'MENTIONED' && n.issueKey === ticket,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
told.body.items.find((n: { kind: string }) => n.kind === 'MENTIONED')
|
||||
.summary,
|
||||
).toMatch(/tu equipo Frontend/);
|
||||
});
|
||||
|
||||
it('tells who else is on a ticket, and who is editing it', async () => {
|
||||
const key = 'P' + Date.now().toString(36).slice(-5).toUpperCase();
|
||||
await rest('token-ana', 'POST', '/projects', { key, name: key });
|
||||
await eventually(
|
||||
() => rest('token-ana', 'GET', `/projects/${key}`),
|
||||
(r) => r.status === 200,
|
||||
);
|
||||
await rest('token-ana', 'POST', `/projects/${key}/members`, {
|
||||
userId: '102',
|
||||
});
|
||||
const created = await rest('token-ana', 'POST', `/projects/${key}/issues`, {
|
||||
type: 'TASK',
|
||||
title: 'Aquí estamos',
|
||||
});
|
||||
const issue = await eventually(
|
||||
() => rest('token-bob', 'GET', `/issues/${created.body['key']}`),
|
||||
(r) => r.status === 200,
|
||||
);
|
||||
const id = issue.body['id'];
|
||||
|
||||
expect(
|
||||
(
|
||||
await rest('token-ana', 'PUT', `/issues/${id}/presence`, {
|
||||
editing: false,
|
||||
})
|
||||
).body,
|
||||
).toEqual([]);
|
||||
const { touchPresence } = await gql(
|
||||
'token-bob',
|
||||
`mutation($i: ID!) { touchPresence(issueId: $i, editing: true) { user { id name } editing } }`,
|
||||
{ i: id },
|
||||
);
|
||||
expect(touchPresence).toEqual([
|
||||
{ user: { id: '101', name: 'Ana (lead)' }, editing: false },
|
||||
]);
|
||||
const seen = await rest('token-ana', 'PUT', `/issues/${id}/presence`, {});
|
||||
expect(seen.body).toEqual([
|
||||
expect.objectContaining({
|
||||
user: { id: '102', name: 'Bob (frontend)' },
|
||||
editing: true,
|
||||
}),
|
||||
]);
|
||||
// Outsiders cannot even ask (a private ticket does not exist for them).
|
||||
expect(
|
||||
(await rest('token-eve', 'PUT', `/issues/${id}/presence`, {})).status,
|
||||
).toBe(404);
|
||||
|
||||
expect(
|
||||
(await rest('token-bob', 'DELETE', `/issues/${id}/presence`)).status,
|
||||
).toBe(204);
|
||||
expect(
|
||||
(await rest('token-ana', 'PUT', `/issues/${id}/presence`, {})).body,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('lets a guest see and talk about only their own tickets', async () => {
|
||||
const key = 'G' + Date.now().toString(36).slice(-5).toUpperCase();
|
||||
await rest('token-ana', 'POST', '/projects', { key, name: key });
|
||||
await eventually(
|
||||
() => rest('token-ana', 'GET', `/projects/${key}`),
|
||||
(r) => r.status === 200,
|
||||
);
|
||||
// Quinn, a client, joins as a guest.
|
||||
expect((await rest('token-qa', 'GET', '/users/me')).status).toBe(200);
|
||||
expect(
|
||||
(
|
||||
await rest('token-ana', 'POST', `/projects/${key}/members`, {
|
||||
userId: '103',
|
||||
role: 'GUEST',
|
||||
})
|
||||
).status,
|
||||
).toBe(201);
|
||||
await eventually(
|
||||
() => rest('token-qa', 'GET', `/projects/${key}`),
|
||||
(r) => r.status === 200,
|
||||
);
|
||||
const internal = (
|
||||
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
|
||||
type: 'TASK',
|
||||
title: 'Solo del equipo',
|
||||
})
|
||||
).body['key'];
|
||||
|
||||
// A guest opens a ticket, but does not plan it.
|
||||
expect(
|
||||
(
|
||||
await rest('token-qa', 'POST', `/projects/${key}/issues`, {
|
||||
type: 'BUG',
|
||||
title: 'Con asignación',
|
||||
assigneeId: '101',
|
||||
})
|
||||
).status,
|
||||
).toBe(403);
|
||||
const mine = (
|
||||
await rest('token-qa', 'POST', `/projects/${key}/issues`, {
|
||||
type: 'BUG',
|
||||
title: 'No puedo pagar',
|
||||
})
|
||||
).body['key'];
|
||||
await eventually(
|
||||
() => rest('token-qa', 'GET', `/issues/${mine}`),
|
||||
(r) => r.status === 200,
|
||||
);
|
||||
|
||||
const list = await eventually(
|
||||
() => rest('token-ana', 'GET', `/projects/${key}/issues`),
|
||||
(r) => r.body?.['total'] === 2,
|
||||
);
|
||||
expect(list.body['total']).toBe(2);
|
||||
const guestList = await rest('token-qa', 'GET', `/projects/${key}/issues`);
|
||||
expect(guestList.body['items'].map((i: { key: string }) => i.key)).toEqual([
|
||||
mine,
|
||||
]);
|
||||
expect((await rest('token-qa', 'GET', `/issues/${internal}`)).status).toBe(
|
||||
404,
|
||||
);
|
||||
expect(
|
||||
(await rest('token-qa', 'PUT', `/issues/${internal}/watch`)).status,
|
||||
).toBe(404);
|
||||
// Project-wide views are not theirs.
|
||||
expect(
|
||||
(await rest('token-qa', 'GET', `/projects/${key}/activity`)).status,
|
||||
).toBe(403);
|
||||
expect(
|
||||
(await rest('token-qa', 'GET', `/projects/${key}/stats`)).status,
|
||||
).toBe(403);
|
||||
|
||||
// They talk on their ticket, but do not move it.
|
||||
expect(
|
||||
(
|
||||
await rest('token-qa', 'POST', `/issues/${mine}/comments`, {
|
||||
body: '¿Hay novedades?',
|
||||
})
|
||||
).status,
|
||||
).toBe(201);
|
||||
expect(
|
||||
(
|
||||
await rest('token-qa', 'PUT', `/issues/${mine}/status`, {
|
||||
status: 'TODO',
|
||||
})
|
||||
).status,
|
||||
).toBe(403);
|
||||
// Once the team adds them to a ticket, they see it.
|
||||
await rest('token-ana', 'PUT', `/issues/${internal}/assignee`, {
|
||||
assigneeId: '103',
|
||||
});
|
||||
await eventually(
|
||||
() => rest('token-qa', 'GET', `/issues/${internal}`),
|
||||
(r) => r.status === 200,
|
||||
);
|
||||
});
|
||||
|
||||
it('shows my active projects at a glance (the portfolio)', async () => {
|
||||
const key = 'F' + Date.now().toString(36).slice(-5).toUpperCase();
|
||||
await rest('token-ana', 'POST', '/projects', {
|
||||
key,
|
||||
name: `Cartera ${key}`,
|
||||
});
|
||||
await eventually(
|
||||
() => rest('token-ana', 'GET', `/projects/${key}`),
|
||||
(r) => r.status === 200,
|
||||
);
|
||||
const epic = (
|
||||
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
|
||||
type: 'FEATURE',
|
||||
title: 'Pagos',
|
||||
dueDate: '2030-01-01',
|
||||
})
|
||||
).body['key'];
|
||||
await eventually(
|
||||
() => rest('token-ana', 'GET', `/issues/${epic}`),
|
||||
(r) => r.status === 200,
|
||||
);
|
||||
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
|
||||
type: 'TASK',
|
||||
title: 'Parte',
|
||||
parent: epic,
|
||||
dueDate: '2020-01-01',
|
||||
});
|
||||
await rest('token-ana', 'POST', `/projects/${key}/releases`, {
|
||||
name: '1.0',
|
||||
targetDate: '2030-06-01',
|
||||
});
|
||||
const mine = await eventually(
|
||||
() => rest('token-ana', 'GET', '/me/portfolio'),
|
||||
(r) =>
|
||||
r.body?.find?.((p: { key: string }) => p.key === key)?.epics?.length ===
|
||||
1 &&
|
||||
r.body.find((p: { key: string }) => p.key === key).releases.length ===
|
||||
1,
|
||||
);
|
||||
const row = mine.body.find((p: { key: string }) => p.key === key);
|
||||
expect(row).toMatchObject({ total: 2, open: 2, overdue: 1, sprint: null });
|
||||
expect(row.releases[0]).toMatchObject({ name: '1.0', total: 0 });
|
||||
expect(row.epics[0].issue.key).toBe(epic);
|
||||
|
||||
// Archived projects leave it.
|
||||
await rest('token-ana', 'POST', `/projects/${key}/archive`);
|
||||
await eventually(
|
||||
() => gql('token-ana', `query { portfolio { key } }`),
|
||||
(d) => !d['portfolio'].some((p: { key: string }) => p.key === key),
|
||||
);
|
||||
});
|
||||
|
||||
it('fills the home page: agenda, activity, the week and who is here', async () => {
|
||||
const key = 'H' + Date.now().toString(36).slice(-5).toUpperCase();
|
||||
await rest('token-ana', 'POST', '/projects', { key, name: key });
|
||||
await eventually(
|
||||
() => rest('token-ana', 'GET', `/projects/${key}`),
|
||||
(r) => r.status === 200,
|
||||
);
|
||||
await rest('token-ana', 'POST', `/projects/${key}/members`, {
|
||||
userId: '102',
|
||||
});
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const ticket = (
|
||||
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
|
||||
type: 'TASK',
|
||||
title: 'Para hoy',
|
||||
assigneeId: '101',
|
||||
dueDate: today,
|
||||
})
|
||||
).body['key'];
|
||||
const issue = await eventually(
|
||||
() => rest('token-bob', 'GET', `/issues/${ticket}`),
|
||||
(r) => r.status === 200,
|
||||
);
|
||||
// Bob is on it right now.
|
||||
await rest('token-bob', 'PUT', `/issues/${issue.body['id']}/presence`, {
|
||||
editing: true,
|
||||
});
|
||||
|
||||
const home = await eventually(
|
||||
() => rest('token-ana', 'GET', '/me/home'),
|
||||
(r) =>
|
||||
r.body?.agenda?.some(
|
||||
(a: { issueKey: string }) => a.issueKey === ticket,
|
||||
) &&
|
||||
r.body.activity.some(
|
||||
(a: { issueKey: string }) => a.issueKey === ticket,
|
||||
),
|
||||
);
|
||||
expect(home.body.agenda).toContainEqual(
|
||||
expect.objectContaining({ day: today, kind: 'ISSUE', issueKey: ticket }),
|
||||
);
|
||||
expect(home.body.online).toContainEqual(
|
||||
expect.objectContaining({
|
||||
user: { id: '102', name: 'Bob (frontend)' },
|
||||
projectKey: key,
|
||||
issueKey: ticket,
|
||||
editing: true,
|
||||
}),
|
||||
);
|
||||
expect(home.body.team.perDay).toHaveLength(14);
|
||||
expect(home.body.firstSteps).toMatchObject({
|
||||
project: true,
|
||||
invited: true,
|
||||
ticket: true,
|
||||
});
|
||||
const card = home.body.projects.find(
|
||||
(p: { projectId: string }) => p.projectId === issue.body['projectId'],
|
||||
);
|
||||
expect(card).toMatchObject({ open: 1 });
|
||||
expect(card.activity).toHaveLength(14);
|
||||
const { myHome } = await gql(
|
||||
'token-ana',
|
||||
`query { myHome { me { thisWeek streak } suggestions { kind } } }`,
|
||||
);
|
||||
expect(myHome.me.streak).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,25 @@ input AddChecklistItemInput {
|
||||
text: String!
|
||||
}
|
||||
|
||||
"""Something on a day of my week"""
|
||||
type AgendaItem {
|
||||
"""YYYY-MM-DD"""
|
||||
day: String!
|
||||
issueKey: String
|
||||
|
||||
"""ISSUE, SPRINT_END or RELEASE"""
|
||||
kind: String!
|
||||
projectKey: String!
|
||||
title: String!
|
||||
}
|
||||
|
||||
type Aging {
|
||||
issues: [InsightIssue!]!
|
||||
|
||||
"""p85 cycle time of the last 90 days"""
|
||||
thresholdDays: Float
|
||||
}
|
||||
|
||||
type AreaOption {
|
||||
archived: Boolean!
|
||||
id: String!
|
||||
@@ -122,12 +141,30 @@ type AutomationTriggerSpec {
|
||||
status: String
|
||||
}
|
||||
|
||||
type BacklogHealth {
|
||||
noArea: Int!
|
||||
open: Int!
|
||||
stale: Int!
|
||||
staleDays: Int!
|
||||
unassigned: Int!
|
||||
unestimated: Int!
|
||||
}
|
||||
|
||||
type BoardColumn {
|
||||
issues: [Issue!]!
|
||||
status: String!
|
||||
total: Int!
|
||||
}
|
||||
|
||||
"""Bugs created and closed in a week"""
|
||||
type BugWeek {
|
||||
closed: Int!
|
||||
created: Int!
|
||||
|
||||
"""Monday (UTC), YYYY-MM-DD"""
|
||||
week: String!
|
||||
}
|
||||
|
||||
type BulkIssueResult {
|
||||
error: String
|
||||
issueId: ID!
|
||||
@@ -177,6 +214,22 @@ input ChangeIssueStatusInput {
|
||||
status: String!
|
||||
}
|
||||
|
||||
"""What happened after a moment"""
|
||||
type ChangesSince {
|
||||
created: [InsightIssue!]!
|
||||
createdCount: Int!
|
||||
finished: [InsightIssue!]!
|
||||
finishedCount: Int!
|
||||
|
||||
"""Times I was mentioned here"""
|
||||
mentions: Int!
|
||||
|
||||
"""Sent back from QA"""
|
||||
returned: [InsightIssue!]!
|
||||
returnedCount: Int!
|
||||
since: String!
|
||||
}
|
||||
|
||||
"""A step inside a ticket that does not deserve a ticket of its own"""
|
||||
type ChecklistItem {
|
||||
done: Boolean!
|
||||
@@ -298,6 +351,9 @@ input CreateIssueInput {
|
||||
"""When work should start (roadmap), YYYY-MM-DD"""
|
||||
startDate: String
|
||||
storyPoints: Int
|
||||
|
||||
"""A team id"""
|
||||
team: String
|
||||
title: String!
|
||||
|
||||
"""A ticket type id"""
|
||||
@@ -313,6 +369,11 @@ input CreateProjectInput {
|
||||
"""2-10 chars, e.g. "TS" → tickets TS-1, TS-2..."""
|
||||
key: String!
|
||||
name: String!
|
||||
|
||||
"""
|
||||
A project you own or administer to start from: its statuses, transitions, types, areas, custom fields and automation rules are copied
|
||||
"""
|
||||
templateProjectId: ID
|
||||
}
|
||||
|
||||
input CreateReleaseInput {
|
||||
@@ -441,6 +502,30 @@ type Epic {
|
||||
progress: ChildProgress!
|
||||
}
|
||||
|
||||
"""Bugs reported in the 14 days after a release shipped"""
|
||||
type EscapedBugs {
|
||||
bugs: Int!
|
||||
name: String!
|
||||
releaseId: ID!
|
||||
releasedAt: String!
|
||||
}
|
||||
|
||||
"""Open tickets per option of a list custom field"""
|
||||
type FieldBreakdown {
|
||||
counts: [OptionCount!]!
|
||||
fieldId: String!
|
||||
|
||||
"""Open tickets with no value"""
|
||||
none: Int!
|
||||
}
|
||||
|
||||
type FirstSteps {
|
||||
calendar: Boolean!
|
||||
invited: Boolean!
|
||||
project: Boolean!
|
||||
ticket: Boolean!
|
||||
}
|
||||
|
||||
type FlowSeries {
|
||||
counts: [Int!]!
|
||||
status: String!
|
||||
@@ -470,6 +555,53 @@ type GitRepository {
|
||||
secret: String
|
||||
}
|
||||
|
||||
"""The home page beyond my work"""
|
||||
type Home {
|
||||
activity: [Activity!]!
|
||||
agenda: [AgendaItem!]!
|
||||
firstSteps: FirstSteps!
|
||||
me: MyWeek!
|
||||
online: [Online!]!
|
||||
projects: [HomeProject!]!
|
||||
sprints: [HomeSprint!]!
|
||||
suggestions: [Suggestion!]!
|
||||
team: TeamWeek!
|
||||
}
|
||||
|
||||
type HomeProject {
|
||||
"""Activity per day, last 14, oldest first"""
|
||||
activity: [Int!]!
|
||||
open: Int!
|
||||
overdue: Int!
|
||||
projectId: ID!
|
||||
sprint: HomeSprintBrief
|
||||
}
|
||||
|
||||
type HomeSprint {
|
||||
projectKey: String!
|
||||
projectName: String!
|
||||
sprint: SprintPulse!
|
||||
}
|
||||
|
||||
type HomeSprintBrief {
|
||||
behind: Boolean!
|
||||
name: String!
|
||||
percent: Int!
|
||||
}
|
||||
|
||||
"""A ticket the dashboard names, with the number that put it there"""
|
||||
type InsightIssue {
|
||||
assignee: UserRef
|
||||
|
||||
"""Days in its status (aging, stuck) or past / to its due date"""
|
||||
days: Float!
|
||||
dueDate: String
|
||||
id: ID!
|
||||
key: String!
|
||||
status: String!
|
||||
title: String!
|
||||
}
|
||||
|
||||
type Issue {
|
||||
"""Statuses this ticket can move to next (project's workflow)"""
|
||||
allowedTransitions: [String!]!
|
||||
@@ -531,6 +663,9 @@ type Issue {
|
||||
status: String!
|
||||
statusCategory: StatusCategory!
|
||||
storyPoints: Int
|
||||
|
||||
"""A team id"""
|
||||
team: String
|
||||
title: String!
|
||||
|
||||
"""In the trash since then; null when it is not"""
|
||||
@@ -573,6 +708,12 @@ input IssueFilterInput {
|
||||
dueTo: String
|
||||
labels: [String!]
|
||||
|
||||
"""Only tickets with no area"""
|
||||
noArea: Boolean
|
||||
|
||||
"""Only tickets nobody estimated"""
|
||||
noEstimate: Boolean
|
||||
|
||||
"""Only tickets in no sprint"""
|
||||
noSprint: Boolean
|
||||
|
||||
@@ -587,9 +728,15 @@ input IssueFilterInput {
|
||||
sort: IssueSort = RECENT
|
||||
sprintId: ID
|
||||
statuses: [String!]
|
||||
|
||||
"""Team ids"""
|
||||
teams: [String!]
|
||||
types: [String!]
|
||||
unassigned: Boolean
|
||||
|
||||
"""Only tickets not updated since this instant (ISO)"""
|
||||
updatedBefore: String
|
||||
|
||||
"""Only the tickets I follow (none when anonymous)"""
|
||||
watching: Boolean
|
||||
}
|
||||
@@ -707,6 +854,11 @@ type Mutation {
|
||||
|
||||
"""Add a Pireon user to the project"""
|
||||
addProjectMember(input: ProjectMemberInput!): CommandResult!
|
||||
|
||||
"""
|
||||
Archive the project (owner only): everything stays readable, nothing changes until it is unarchived
|
||||
"""
|
||||
archiveProject(projectId: ID!): CommandResult!
|
||||
archiveRelease(
|
||||
"""
|
||||
Open release of the same project that takes its unfinished tickets; none: they leave it
|
||||
@@ -751,6 +903,9 @@ type Mutation {
|
||||
"""Author or project admin"""
|
||||
deleteComment(commentId: ID!): CommandResult!
|
||||
|
||||
"""Delete one of my views"""
|
||||
deleteMyView(id: ID!): Boolean!
|
||||
|
||||
"""
|
||||
Delete the project (owner only), with its tickets, comments and releases. `confirmKey` must be the project key.
|
||||
"""
|
||||
@@ -769,6 +924,9 @@ type Mutation {
|
||||
duplicateIssue(issueId: ID!): CommandResult!
|
||||
editComment(body: String!, commentId: ID!): CommandResult!
|
||||
|
||||
"""I left this ticket's page"""
|
||||
leavePresence(issueId: ID!): Boolean!
|
||||
|
||||
"""Relate tickets, e.g. FE DEPENDS_ON BE, QA TESTS FE"""
|
||||
linkIssues(input: LinkIssuesInput!): CommandResult!
|
||||
|
||||
@@ -808,6 +966,9 @@ type Mutation {
|
||||
"""Save the current board filters under a name (members of the project)"""
|
||||
saveFilter(input: SaveFilterInput!): ID!
|
||||
|
||||
"""Save a view of my work under a name"""
|
||||
saveMyView(input: SaveMyViewInput!): ID!
|
||||
|
||||
"""
|
||||
Place a ticket under an epic or another ticket of the same project (null: back to the top). Three levels at most: epic → ticket → subtask.
|
||||
"""
|
||||
@@ -828,10 +989,18 @@ type Mutation {
|
||||
"""Send a ping now and tell how it went"""
|
||||
testWebhook(webhookId: ID!): WebhookDelivery!
|
||||
|
||||
"""
|
||||
I am on this ticket (send every ~10 s while the page is open): answers who else is, the ones editing first
|
||||
"""
|
||||
touchPresence(editing: Boolean! = false, issueId: ID!): [Presence!]!
|
||||
|
||||
"""
|
||||
To the trash (whoever opened it, or an owner/admin): hidden everywhere, deleted for good after 30 days
|
||||
"""
|
||||
trashIssue(issueId: ID!): CommandResult!
|
||||
|
||||
"""Bring an archived project back (owner only)"""
|
||||
unarchiveProject(projectId: ID!): CommandResult!
|
||||
unlinkIssues(input: LinkIssuesInput!): CommandResult!
|
||||
|
||||
"""Push off on this device"""
|
||||
@@ -880,6 +1049,53 @@ type MyNotifications {
|
||||
unread: Int!
|
||||
}
|
||||
|
||||
"""A named view of my work (private)"""
|
||||
type MyView {
|
||||
createdAt: String!
|
||||
criteria: MyViewCriteria!
|
||||
id: ID!
|
||||
name: String!
|
||||
}
|
||||
|
||||
"""What a view of my work narrows to; empty: every ticket of my projects"""
|
||||
type MyViewCriteria {
|
||||
assignedToMe: Boolean
|
||||
|
||||
"""Only types with the bug icon"""
|
||||
bugsOnly: Boolean
|
||||
categories: [StatusCategory!]
|
||||
|
||||
"""Only open tickets past due"""
|
||||
overdue: Boolean
|
||||
priorities: [IssuePriority!]
|
||||
|
||||
"""Empty: all my projects"""
|
||||
projectIds: [ID!]
|
||||
search: String
|
||||
watching: Boolean
|
||||
}
|
||||
|
||||
input MyViewCriteriaInput {
|
||||
assignedToMe: Boolean
|
||||
bugsOnly: Boolean
|
||||
categories: [StatusCategory!]
|
||||
overdue: Boolean
|
||||
priorities: [IssuePriority!]
|
||||
|
||||
"""Empty: all my projects"""
|
||||
projectIds: [ID!]
|
||||
search: String
|
||||
watching: Boolean
|
||||
}
|
||||
|
||||
type MyWeek {
|
||||
lastWeek: Int!
|
||||
|
||||
"""Days in a row with something finished"""
|
||||
streak: Int!
|
||||
thisWeek: Int!
|
||||
}
|
||||
|
||||
type Notification {
|
||||
actor: UserRef!
|
||||
createdAt: String!
|
||||
@@ -922,6 +1138,57 @@ type NotificationSettings {
|
||||
pushPublicKey: String
|
||||
}
|
||||
|
||||
"""Someone of my projects on a ticket now"""
|
||||
type Online {
|
||||
at: String!
|
||||
editing: Boolean!
|
||||
issueKey: String
|
||||
issueTitle: String
|
||||
projectKey: String!
|
||||
user: UserRef!
|
||||
}
|
||||
|
||||
type OptionCount {
|
||||
count: Int!
|
||||
value: String!
|
||||
}
|
||||
|
||||
"""
|
||||
One of my active projects at a glance: open work, risks, sprint, releases and epics
|
||||
"""
|
||||
type PortfolioProject {
|
||||
blocked: Int!
|
||||
|
||||
"""Open epics, nearest due date first (six at most)"""
|
||||
epics: [Epic!]!
|
||||
isPublic: Boolean!
|
||||
key: String!
|
||||
name: String!
|
||||
open: Int!
|
||||
overdue: Int!
|
||||
projectId: ID!
|
||||
releases: [ReleaseForecast!]!
|
||||
sprint: SprintPulse
|
||||
stuck: Int!
|
||||
total: Int!
|
||||
updatedAt: String!
|
||||
}
|
||||
|
||||
"""Someone on a ticket right now (heartbeat in the last 30 s)"""
|
||||
type Presence {
|
||||
"""Last heartbeat"""
|
||||
at: String!
|
||||
|
||||
"""Editing the ticket or writing a comment"""
|
||||
editing: Boolean!
|
||||
user: UserRef!
|
||||
}
|
||||
|
||||
type PriorityCount {
|
||||
count: Int!
|
||||
priority: IssuePriority!
|
||||
}
|
||||
|
||||
type Project {
|
||||
"""Takes bug reports and ideas from anyone (while public)"""
|
||||
acceptsProposals: Boolean!
|
||||
@@ -931,6 +1198,9 @@ type Project {
|
||||
"""
|
||||
analytics(days: Int! = 90): ProjectAnalytics!
|
||||
|
||||
"""Archived since then (read-only); null while active"""
|
||||
archivedAt: String
|
||||
|
||||
"""Kanban board, one column per status"""
|
||||
board(
|
||||
area: String
|
||||
@@ -950,6 +1220,14 @@ type Project {
|
||||
description: String
|
||||
id: ID!
|
||||
|
||||
"""
|
||||
The dashboard: sprint under way, overdue / blocked / stuck tickets, aging and bottlenecks
|
||||
"""
|
||||
insights(
|
||||
"""ISO instant (e.g. my last visit): also tell what changed after it"""
|
||||
since: String
|
||||
): ProjectInsights!
|
||||
|
||||
"""Anyone can read it, also without signing in"""
|
||||
isPublic: Boolean!
|
||||
key: String!
|
||||
@@ -991,6 +1269,27 @@ type ProjectChange {
|
||||
version: Int!
|
||||
}
|
||||
|
||||
"""
|
||||
The dashboard: sprint under way, risks, aging, bottlenecks, release forecasts, team load, backlog health and changes
|
||||
"""
|
||||
type ProjectInsights {
|
||||
aging: Aging!
|
||||
backlog: BacklogHealth!
|
||||
bottlenecks: [StatusTime!]!
|
||||
|
||||
"""Only when asked with `since`"""
|
||||
changes: ChangesSince
|
||||
|
||||
"""One per active list custom field"""
|
||||
fields: [FieldBreakdown!]!
|
||||
generatedAt: String!
|
||||
quality: Quality!
|
||||
releases: [ReleaseForecast!]!
|
||||
risks: Risks!
|
||||
sprint: SprintPulse
|
||||
team: [TeamLoad!]!
|
||||
}
|
||||
|
||||
type ProjectMember {
|
||||
role: ProjectRole!
|
||||
user: UserRef!
|
||||
@@ -1006,6 +1305,7 @@ input ProjectMemberInput {
|
||||
|
||||
enum ProjectRole {
|
||||
ADMIN
|
||||
GUEST
|
||||
MEMBER
|
||||
OWNER
|
||||
VIEWER
|
||||
@@ -1016,9 +1316,15 @@ Statuses (board order), transitions, ticket types, areas and custom fields
|
||||
"""
|
||||
type ProjectSettings {
|
||||
areas: [AreaOption!]!
|
||||
|
||||
"""
|
||||
Definition of done: tickets reach DONE only with their checklist ticked (owners and admins may override)
|
||||
"""
|
||||
checklistBeforeDone: Boolean!
|
||||
customFields: [CustomFieldDefinition!]!
|
||||
issueTypes: [IssueTypeOption!]!
|
||||
statuses: [WorkflowStatus!]!
|
||||
teams: [Team!]!
|
||||
transitions: [StatusTransitions!]!
|
||||
}
|
||||
|
||||
@@ -1028,10 +1334,18 @@ The whole configuration (replaces the previous one). Statuses go in board order.
|
||||
input ProjectSettingsInput {
|
||||
areas: [AreaOptionInput!]!
|
||||
|
||||
"""
|
||||
Definition of done: DONE only with the checklist ticked. Absent: unchanged
|
||||
"""
|
||||
checklistBeforeDone: Boolean
|
||||
|
||||
"""Absent: unchanged. Fields cannot be removed: archive them."""
|
||||
customFields: [CustomFieldDefinitionInput!]
|
||||
issueTypes: [IssueTypeOptionInput!]!
|
||||
statuses: [WorkflowStatusInput!]!
|
||||
|
||||
"""Absent: unchanged. Teams cannot be removed: archive them."""
|
||||
teams: [TeamInput!]
|
||||
transitions: [StatusTransitionsInput!]!
|
||||
}
|
||||
|
||||
@@ -1054,12 +1368,29 @@ input PushSubscriptionInput {
|
||||
p256dh: String!
|
||||
}
|
||||
|
||||
"""QA verdicts per area (90 days)"""
|
||||
type QaByArea {
|
||||
"""Null: no area"""
|
||||
area: String
|
||||
passed: Int!
|
||||
returned: Int!
|
||||
}
|
||||
|
||||
type QaStats {
|
||||
failed: Int!
|
||||
failureRate: Float
|
||||
passed: Int!
|
||||
}
|
||||
|
||||
"""Bugs (types with the bug icon) and QA"""
|
||||
type Quality {
|
||||
"""Last 8 weeks, oldest first"""
|
||||
bugWeeks: [BugWeek!]!
|
||||
escaped: [EscapedBugs!]!
|
||||
openBugsByPriority: [PriorityCount!]!
|
||||
qaByArea: [QaByArea!]!
|
||||
}
|
||||
|
||||
type Query {
|
||||
"""Project activity feed (newest first)"""
|
||||
activity(
|
||||
@@ -1088,15 +1419,30 @@ type Query {
|
||||
"""My calendar URL, if I made one"""
|
||||
myCalendarFeed: CalendarFeed
|
||||
|
||||
"""
|
||||
The home page beyond my work: agenda of the week, activity of my projects, the week in numbers, sprints, project cards, suggestions, first steps and who is on what now
|
||||
"""
|
||||
myHome: Home!
|
||||
|
||||
"""
|
||||
Tickets across all my projects: assigned to me (default) or any (e.g. categories [QA])
|
||||
"""
|
||||
myIssues(
|
||||
assignedToMe: Boolean! = true
|
||||
|
||||
"""Only types drawn with the bug icon"""
|
||||
bugsOnly: Boolean
|
||||
categories: [StatusCategory!]
|
||||
limit: Int! = 50
|
||||
offset: Int! = 0
|
||||
|
||||
"""Only open tickets past their due date"""
|
||||
overdue: Boolean
|
||||
priorities: [IssuePriority!]
|
||||
|
||||
"""Only these of my projects"""
|
||||
projectIds: [ID!]
|
||||
|
||||
"""Matches key, title or description (global search)"""
|
||||
search: String
|
||||
statuses: [String!]
|
||||
@@ -1110,8 +1456,16 @@ type Query {
|
||||
|
||||
"""Projects where I am a member"""
|
||||
myProjects: [Project!]!
|
||||
|
||||
"""My saved views of my work, across my projects"""
|
||||
myViews: [MyView!]!
|
||||
notificationSettings: NotificationSettings!
|
||||
|
||||
"""
|
||||
My active projects (not archived, not as a guest) at a glance: open work, risks, sprint, release forecasts and epics
|
||||
"""
|
||||
portfolio: [PortfolioProject!]!
|
||||
|
||||
"""A project of mine, or any public one (no sign-in needed)"""
|
||||
project(
|
||||
"""Project id or key (e.g. "TS")"""
|
||||
@@ -1198,6 +1552,24 @@ type ReleaseChange {
|
||||
version: Int!
|
||||
}
|
||||
|
||||
"""When a planned release would be finished, at the real pace"""
|
||||
type ReleaseForecast {
|
||||
finished: Int!
|
||||
|
||||
"""Half of the simulated futures finish by then"""
|
||||
likelyDate: String
|
||||
name: String!
|
||||
releaseId: ID!
|
||||
|
||||
"""85% finish by then"""
|
||||
safeDate: String
|
||||
targetDate: String
|
||||
total: Int!
|
||||
|
||||
"""DONE, NO_DATE, UNKNOWN (no pace yet), ON_TRACK, AT_RISK or LATE"""
|
||||
verdict: String!
|
||||
}
|
||||
|
||||
type ReleaseProgress {
|
||||
byStatus: [CountByStatus!]!
|
||||
finished: Int!
|
||||
@@ -1216,6 +1588,21 @@ enum ReleaseStatus {
|
||||
RELEASED
|
||||
}
|
||||
|
||||
"""Tickets to look at now (worst first)"""
|
||||
type Risks {
|
||||
blocked: [InsightIssue!]!
|
||||
blockedCount: Int!
|
||||
|
||||
"""Due within 7 days"""
|
||||
dueSoon: [InsightIssue!]!
|
||||
dueSoonCount: Int!
|
||||
overdue: [InsightIssue!]!
|
||||
overdueCount: Int!
|
||||
stuck: [InsightIssue!]!
|
||||
stuckCount: Int!
|
||||
stuckDays: Int!
|
||||
}
|
||||
|
||||
input SaveFilterInput {
|
||||
criteria: SavedFilterCriteriaInput!
|
||||
name: String!
|
||||
@@ -1225,6 +1612,11 @@ input SaveFilterInput {
|
||||
shared: Boolean! = false
|
||||
}
|
||||
|
||||
input SaveMyViewInput {
|
||||
criteria: MyViewCriteriaInput!
|
||||
name: String!
|
||||
}
|
||||
|
||||
"""
|
||||
A named view of the board: private to whoever saved it, or shared with the project
|
||||
"""
|
||||
@@ -1316,6 +1708,26 @@ type SprintProgress {
|
||||
total: Int!
|
||||
}
|
||||
|
||||
"""The sprint under way: time gone against work done"""
|
||||
type SprintPulse {
|
||||
"""At this pace, what is open would not be closed"""
|
||||
behind: Boolean!
|
||||
|
||||
"""Days left after today"""
|
||||
daysLeft: Int!
|
||||
donePoints: Int!
|
||||
doneTickets: Int!
|
||||
endDate: String!
|
||||
name: String!
|
||||
neededPerDay: Float
|
||||
pacePerDay: Float
|
||||
points: Int!
|
||||
sprintId: ID!
|
||||
startDate: String!
|
||||
tickets: Int!
|
||||
totalDays: Int!
|
||||
}
|
||||
|
||||
type SprintRef {
|
||||
id: ID!
|
||||
name: String!
|
||||
@@ -1339,6 +1751,15 @@ enum StatusCategory {
|
||||
TODO
|
||||
}
|
||||
|
||||
type StatusTime {
|
||||
averageDays: Float
|
||||
open: Int!
|
||||
overLimit: Boolean!
|
||||
samples: Int!
|
||||
status: String!
|
||||
wipLimit: Int
|
||||
}
|
||||
|
||||
type StatusTransitions {
|
||||
from: String!
|
||||
to: [String!]!
|
||||
@@ -1367,6 +1788,48 @@ type Subscription {
|
||||
sprintChanged(projectId: ID!): SprintChange!
|
||||
}
|
||||
|
||||
type Suggestion {
|
||||
count: Int!
|
||||
|
||||
"""UNASSIGNED, PROPOSALS or QA_WAITING"""
|
||||
kind: String!
|
||||
projectKey: String!
|
||||
}
|
||||
|
||||
"""A group of members: tickets go to it; @team mentions tell them all"""
|
||||
type Team {
|
||||
archived: Boolean!
|
||||
id: String!
|
||||
memberIds: [ID!]!
|
||||
name: String!
|
||||
}
|
||||
|
||||
input TeamInput {
|
||||
archived: Boolean! = false
|
||||
id: String!
|
||||
memberIds: [ID!]! = []
|
||||
name: String!
|
||||
}
|
||||
|
||||
type TeamLoad {
|
||||
blocked: Int!
|
||||
openPoints: Int!
|
||||
openTickets: Int!
|
||||
overloaded: Boolean!
|
||||
|
||||
"""Points closed per sprint, last three closed sprints"""
|
||||
pointsPerSprint: Float
|
||||
user: UserRef!
|
||||
}
|
||||
|
||||
type TeamWeek {
|
||||
lastWeek: Int!
|
||||
|
||||
"""Finished per day, last 14, oldest first"""
|
||||
perDay: [Int!]!
|
||||
thisWeek: Int!
|
||||
}
|
||||
|
||||
type Throughput {
|
||||
count: Int!
|
||||
user: UserRef
|
||||
@@ -1398,6 +1861,9 @@ input UpdateIssueInput {
|
||||
"""When work should start (roadmap), YYYY-MM-DD"""
|
||||
startDate: String
|
||||
storyPoints: Int
|
||||
|
||||
"""A team id"""
|
||||
team: String
|
||||
title: String
|
||||
type: String
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -37,6 +37,10 @@ const ids = (what: string) =>
|
||||
const issueFields = {
|
||||
description: z.string().max(50000).optional(),
|
||||
area: z.string().optional().describe('Area id (see get_project)'),
|
||||
team: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Team id (see get_project settings.teams)'),
|
||||
priority: PRIORITY.optional(),
|
||||
labels: z.array(z.string().max(40)).max(20).optional(),
|
||||
figmaUrl: z.string().url().optional().describe('Figma frame (https)'),
|
||||
@@ -57,6 +61,7 @@ const issueFields = {
|
||||
type IssueFields = {
|
||||
description?: string;
|
||||
area?: string;
|
||||
team?: string;
|
||||
priority?: z.infer<typeof PRIORITY>;
|
||||
labels?: string[];
|
||||
figmaUrl?: string;
|
||||
@@ -88,6 +93,15 @@ export class IssuesResolver {
|
||||
.array(CATEGORY)
|
||||
.optional()
|
||||
.describe('Default: open ones (TODO, IN_PROGRESS, QA)'),
|
||||
priorities: z.array(PRIORITY).optional(),
|
||||
bugsOnly: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Only types drawn with the bug icon, in each project'),
|
||||
overdue: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Only open tickets past their due date'),
|
||||
limit: limit(200, 50),
|
||||
}),
|
||||
annotations: READ,
|
||||
@@ -97,6 +111,9 @@ export class IssuesResolver {
|
||||
assignedToMe: boolean;
|
||||
watching: boolean;
|
||||
categories?: z.infer<typeof CATEGORY>[];
|
||||
priorities?: z.infer<typeof PRIORITY>[];
|
||||
bugsOnly?: boolean;
|
||||
overdue?: boolean;
|
||||
limit: number;
|
||||
},
|
||||
ctx: McpContext,
|
||||
@@ -109,6 +126,9 @@ export class IssuesResolver {
|
||||
assignedToMe: params.assignedToMe,
|
||||
watching: params.watching,
|
||||
category: params.categories ?? ['TODO', 'IN_PROGRESS', 'QA'],
|
||||
priority: params.priorities,
|
||||
bugsOnly: params.bugsOnly,
|
||||
overdue: params.overdue,
|
||||
limit: params.limit,
|
||||
},
|
||||
},
|
||||
@@ -129,6 +149,7 @@ export class IssuesResolver {
|
||||
categories: z.array(CATEGORY).optional(),
|
||||
types: ids('Ticket type'),
|
||||
areas: ids('Area'),
|
||||
teams: ids('Team'),
|
||||
priorities: z.array(PRIORITY).optional(),
|
||||
assigneeId: z.string().optional().describe('User id'),
|
||||
unassigned: z.boolean().optional(),
|
||||
@@ -172,6 +193,7 @@ export class IssuesResolver {
|
||||
categories?: z.infer<typeof CATEGORY>[];
|
||||
types?: string[];
|
||||
areas?: string[];
|
||||
teams?: string[];
|
||||
priorities?: z.infer<typeof PRIORITY>[];
|
||||
assigneeId?: string;
|
||||
unassigned?: boolean;
|
||||
@@ -201,6 +223,7 @@ export class IssuesResolver {
|
||||
category: params.categories,
|
||||
type: params.types,
|
||||
area: params.areas,
|
||||
team: params.teams,
|
||||
priority: params.priorities,
|
||||
assigneeId: params.assigneeId,
|
||||
unassigned: params.unassigned,
|
||||
|
||||
@@ -40,7 +40,7 @@ export class ProjectsResolver {
|
||||
@Tool({
|
||||
name: 'create_project',
|
||||
description:
|
||||
'Create a project; the user becomes its owner. The key prefixes its tickets (WEB → WEB-1) and cannot change later.',
|
||||
'Create a project; the user becomes its owner. The key prefixes its tickets (WEB → WEB-1) and cannot change later. With template, it starts with the statuses, transitions, types, areas, custom fields and automation rules of that project.',
|
||||
paramsSchema: z.object({
|
||||
key: z
|
||||
.string()
|
||||
@@ -52,6 +52,12 @@ export class ProjectsResolver {
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe('Anyone can read it, also without signing in'),
|
||||
template: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Key of a project the user owns or administers to copy its configuration and automation rules from',
|
||||
),
|
||||
}),
|
||||
annotations: { readOnlyHint: false, destructiveHint: false },
|
||||
})
|
||||
@@ -61,6 +67,7 @@ export class ProjectsResolver {
|
||||
name: string;
|
||||
description?: string;
|
||||
isPublic: boolean;
|
||||
template?: string;
|
||||
},
|
||||
ctx: McpContext,
|
||||
) {
|
||||
@@ -197,6 +204,80 @@ export class ProjectsResolver {
|
||||
);
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'get_my_week',
|
||||
description:
|
||||
"The user's coming week and how the last one went: tickets of theirs (assigned or watched) due in the next 7 days, sprint ends and planned releases; tickets finished by them and by their teams this week and last, and their streak; sprints under way; and, when nothing is theirs, what they could pick up (unassigned, waiting for QA, proposals to review).",
|
||||
paramsSchema: z.object({}),
|
||||
annotations: READ,
|
||||
})
|
||||
myWeek(_params: Record<string, never>, ctx: McpContext) {
|
||||
return toolResult(async () => {
|
||||
const home = await this.api.data(this.api.as(ctx).GET('/api/me/home'));
|
||||
// Compact: the page's activity feed, presence and first steps stay out.
|
||||
return {
|
||||
agenda: home.agenda,
|
||||
me: home.me,
|
||||
team: { thisWeek: home.team.thisWeek, lastWeek: home.team.lastWeek },
|
||||
sprints: home.sprints,
|
||||
suggestions: home.suggestions,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'get_portfolio',
|
||||
description:
|
||||
"All the user's active projects at a glance (not archived, not as a guest): open, overdue, blocked and stuck tickets, the sprint under way and whether it gets there, planned releases with their forecast, and open epics with their progress.",
|
||||
paramsSchema: z.object({}),
|
||||
annotations: READ,
|
||||
})
|
||||
portfolio(_params: Record<string, never>, ctx: McpContext) {
|
||||
return toolResult(async () => {
|
||||
const projects = await this.api.data(
|
||||
this.api.as(ctx).GET('/api/me/portfolio'),
|
||||
);
|
||||
// Compact: epics as key, title and progress.
|
||||
return projects.map(({ epics, ...p }) => ({
|
||||
...p,
|
||||
epics: epics.map((e) => ({
|
||||
key: e.issue.key,
|
||||
title: e.issue.title,
|
||||
dueDate: e.issue.dueDate,
|
||||
percent: e.progress.percent,
|
||||
})),
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'get_project_insights',
|
||||
description:
|
||||
"The project's dashboard: the sprint under way (pace against time, behind or not), overdue, due-soon, blocked and stuck tickets, how long the work under way has been where it is (against the p85 cycle time), average days in each in-progress and QA status with WIP limits, when each planned release would be finished at the real pace (Monte Carlo), each person's open points against what they close per sprint, open tickets missing an estimate, an area or an assignee, quality (open bugs per priority, bugs created and closed per week, QA returns per area, bugs reported after each release) and open tickets per option of each list custom field. With since, also what changed after it.",
|
||||
paramsSchema: z.object({
|
||||
project: projectRef,
|
||||
since: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'ISO instant: also tell what was created, finished or sent back from QA after it',
|
||||
),
|
||||
}),
|
||||
annotations: READ,
|
||||
})
|
||||
insights(
|
||||
{ project, since }: { project: string; since?: string },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
return toolResult(() =>
|
||||
this.api.data(
|
||||
this.api.as(ctx).GET('/api/projects/{project}/insights', {
|
||||
params: { path: { project }, query: { since } },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'get_project_activity',
|
||||
description: 'Latest activity of a project (newest first).',
|
||||
|
||||
@@ -12,12 +12,14 @@ describe('appRoutes', () => {
|
||||
const children = app?.children ?? [];
|
||||
expect(children.map((route) => route.path)).toEqual([
|
||||
'',
|
||||
'cartera',
|
||||
'explorar',
|
||||
'p/:key',
|
||||
]);
|
||||
expect(children[0].canActivate).toContain(authGuard);
|
||||
expect(children[1].canActivate).toBeUndefined();
|
||||
expect(children[1].canActivate).toContain(authGuard);
|
||||
expect(children[2].canActivate).toBeUndefined();
|
||||
expect(children[3].canActivate).toBeUndefined();
|
||||
});
|
||||
|
||||
it('exposes every project section', () => {
|
||||
@@ -37,6 +39,7 @@ describe('appRoutes', () => {
|
||||
'epics',
|
||||
'trash',
|
||||
'releases',
|
||||
'report/:kind/:id',
|
||||
'dashboard',
|
||||
'analytics',
|
||||
'settings',
|
||||
|
||||
@@ -41,6 +41,16 @@ import {
|
||||
Proyectos
|
||||
</a>
|
||||
}
|
||||
@if (auth.isAuthenticated()) {
|
||||
<a
|
||||
shellNav
|
||||
class="ts-nav-link"
|
||||
routerLink="/cartera"
|
||||
routerLinkActive="is-active"
|
||||
>
|
||||
Cartera
|
||||
</a>
|
||||
}
|
||||
<a
|
||||
shellNav
|
||||
class="ts-nav-link"
|
||||
|
||||
@@ -618,3 +618,34 @@ html.dark-mode .pi-shell {
|
||||
background-color: var(--ts-surface-raised) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Printing a report (/p/:key/report/…): the report alone, on white paper.
|
||||
@media print {
|
||||
body:has(ts-report-page) {
|
||||
background: #fff !important;
|
||||
color: #000;
|
||||
|
||||
.ts-header,
|
||||
.ts-project-header,
|
||||
.ts-tabs,
|
||||
.ts-main::before,
|
||||
.ts-main::after {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.ts-main {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
// No tinted panels or soft lights on paper.
|
||||
.ts-panel {
|
||||
background: none !important;
|
||||
box-shadow: none !important;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,8 @@ Las reglas no son una convención: las comprueba `@nx/enforce-module-boundaries`
|
||||
- `domain` solo puede importar `@nestjs/common` (por `@Injectable()` de las factories) y `@nestjs/cqrs` (por `AggregateRoot`).
|
||||
Nada de TypeORM, Mongoose, Redis, GraphQL, Angular, NgRx ni Apollo.
|
||||
- `application` no puede importar adaptadores (TypeORM, Mongoose, Redis, Apollo…).
|
||||
- Los buses CQRS (`CommandBus`, `QueryBus`) solo los usan los `*.service.ts` de `application`; todo lo demás pasa por
|
||||
- Los buses CQRS solo los usan los `*.service.ts` de `application` (los comandos, a través del `CommandRunner`, que
|
||||
los reintenta si chocan; las consultas, con el `QueryBus`); todo lo demás pasa por
|
||||
un servicio: resolvers, controllers, temporizadores (recordatorios, papelera; por eso viven en `presenters`), el
|
||||
handler de eventos de las automatizaciones y el directorio de usuarios del tracker (`IdentityService`). La única
|
||||
excepción es el `EventsBridge`, que ejecuta los comandos que devuelven las sagas.
|
||||
@@ -109,7 +110,10 @@ sequenceDiagram
|
||||
- **Guardar**: el `EventStorePublisher` sustituye al publisher del `EventBus`, así que `commit()` escribe en la tabla
|
||||
`events`. `commitAndWait()` espera a que la escritura termine (el `commit()` de Nest no devuelve la promesa).
|
||||
- **Concurrencia optimista**: `UNIQUE(stream_id, position)`. Si dos peticiones modifican el mismo agregado a la vez, la
|
||||
segunda falla con `ConcurrencyError` (código GraphQL `CONFLICT`).
|
||||
segunda falla con `ConcurrencyError`. El `CommandRunner` (`shared/application`) la repite sobre el estado nuevo hasta
|
||||
5 veces, con una pequeña espera creciente: cada comando carga su agregado de cero y guarda sus eventos en una sola
|
||||
transacción, así que un choque significa que no se guardó nada. Solo si sigue chocando llega al cliente (código
|
||||
GraphQL `CONFLICT`, HTTP `409`). Las sagas tienen su propio reintento en el `EventsBridge`.
|
||||
- **Lookups**: lo que no se puede comprobar dentro de un agregado vive en tablas aparte: claves de proyecto únicas
|
||||
(`project_keys`), numeración de tickets (`issue_sequences`) y nombres de release únicos (`release_names`).
|
||||
- **Ids**: todas las tablas tienen `id uuid` (`gen_random_uuid()`); lo que hace única una fila de lookup es una
|
||||
|
||||
@@ -39,11 +39,19 @@ classDiagram
|
||||
### Roles por proyecto
|
||||
|
||||
| Rol | Ver | Crear/modificar tickets, comentar | Gestionar miembros y releases |
|
||||
| -------------------------------- | --- | --------------------------------- | -------------------------------------------- |
|
||||
| -------------------------------- | ---------------- | ----------------------------------------------- | -------------------------------------------- |
|
||||
| `OWNER` (quien crea el proyecto) | ✅ | ✅ | ✅ (no se le puede quitar ni cambiar el rol) |
|
||||
| `ADMIN` | ✅ | ✅ | ✅ |
|
||||
| `MEMBER` | ✅ | ✅ | ❌ |
|
||||
| `VIEWER` | ✅ | ❌ | ❌ |
|
||||
| `GUEST` | Solo sus tickets | Abrir tickets, comentar y adjuntar en los suyos | ❌ |
|
||||
|
||||
Un **invitado** (`GUEST`, para clientes o gente de fuera) de un proyecto privado solo ve los tickets en los que
|
||||
participa: los que abrió, los que tiene asignados y los que sigue. El resto no existen para él (`404`, también en
|
||||
«Mi trabajo», en las suscripciones en tiempo real, en «bloqueado por» y en los enlazados), y las vistas de todo el
|
||||
proyecto (actividad, estadísticas, panel, releases, sprints, épicas, papelera) le responden `403`. Abre tickets sin
|
||||
asignarlos ni planificarlos, comenta y adjunta en los suyos; no cambia su estado ni sus campos. En un proyecto público
|
||||
ve lo mismo que cualquiera.
|
||||
|
||||
Cualquiera puede salir de un proyecto (salvo el propietario). Los no miembros reciben _not found_, para no revelar que
|
||||
el proyecto existe.
|
||||
@@ -80,6 +88,15 @@ sus tickets (y sus comentarios), sus releases y los enlaces que tickets de otros
|
||||
[Arquitectura](02-arquitectura.md)). La clave queda libre para un proyecto nuevo. Los eventos se quedan en el event
|
||||
store como auditoría, pero desde la API no se puede deshacer.
|
||||
|
||||
### Archivar un proyecto
|
||||
|
||||
El propietario archiva un proyecto terminado (`archiveProject` / `POST /api/projects/{project}/archive`) en lugar de
|
||||
borrarlo: se sigue viendo todo (tablero, tickets, historia, estadísticas, y quien lo administra sigue viendo sus
|
||||
reglas y webhooks), pero ninguna orden lo cambia (`422`, «está archivado»): ni tickets, ni comentarios, ni votos,
|
||||
ni ajustes, ni miembros; las automatizaciones no se ejecutan y el formulario público no acepta propuestas. Seguir un
|
||||
ticket sí se puede, porque solo atañe a quien lo sigue. `unarchiveProject` / `POST …/unarchive` lo devuelve.
|
||||
`Project.archivedAt` dice desde cuándo.
|
||||
|
||||
### Configuración por proyecto
|
||||
|
||||
Cada proyecto tiene sus **estados**, **transiciones**, **tipos de ticket**, **áreas** y **campos personalizados**
|
||||
@@ -110,9 +127,36 @@ Reglas:
|
||||
- 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`.
|
||||
|
||||
Un proyecto nuevo puede **empezar con la configuración de otro** que administres: `templateProjectId` en
|
||||
`createProject` o `template` (id o clave) en `POST /api/projects`. Se copian una vez sus estados, transiciones, tipos,
|
||||
áreas, campos personalizados y automatizaciones (las pausadas, pausadas; las que asignan a alguien que no está en el
|
||||
nuevo proyecto se quedan fuera). Las etiquetas no tienen catálogo, así que no hay nada que copiar.
|
||||
|
||||
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.
|
||||
|
||||
### Equipos
|
||||
|
||||
`settings.teams`: grupos de miembros (`id`, `name`, `memberIds`, `archived`), como mucho 30. Al guardar, quien no es
|
||||
miembro del proyecto se quita del equipo; un equipo no se borra, se archiva; y si `teams` no viene, se quedan como
|
||||
estaban. Un ticket va a un equipo con `team` (en `createIssue` / `updateIssue` y por REST), y la lista se filtra con
|
||||
`teams` (`?team=FRONT`). En un comentario, `@[Frontend](team-FRONT)` avisa a cada miembro del equipo («mencionó a tu
|
||||
equipo…»).
|
||||
|
||||
### Presencia
|
||||
|
||||
Quién más está en un ticket ahora. La página de un ticket late cada 10 s con `touchPresence(issueId, editing)` (o
|
||||
`PUT /api/issues/{issue}/presence` con `{ editing }`), y la respuesta es quién más está, primero los que editan. Se
|
||||
guarda en Redis (un hash por ticket, `task-stream:presence:<id>`) y una entrada sin latido en 30 s desaparece sola;
|
||||
`leavePresence` (`DELETE …/presence`) la quita al salir. No es historia: no pasa por el event store. Solo para quien
|
||||
puede leer el ticket.
|
||||
|
||||
### Definición de «Hecho»
|
||||
|
||||
`settings.checklistBeforeDone` (desactivada por defecto): con ella, un ticket no pasa a un estado de categoría `DONE`
|
||||
mientras le quede algún paso de la checklist sin marcar (`422`, «quedan N pasos…»). El propietario y los admins sí
|
||||
pueden cerrarlo; las automatizaciones, no. Al guardar la configuración, si no viene, se queda como estaba.
|
||||
|
||||
### Campos personalizados
|
||||
|
||||
Datos propios del proyecto en cada ticket («Navegador», «Cliente», «Coste»…), en `settings.customFields`: `id`, `name`,
|
||||
@@ -374,6 +418,41 @@ puras (`application/src/lib/analytics`):
|
||||
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)).
|
||||
|
||||
La **cartera** (`portfolio` en GraphQL, `GET /api/me/portfolio`) junta, para cada proyecto activo de quien pregunta
|
||||
(no archivado y en el que no es invitado), los tickets abiertos, vencidos, bloqueados y atascados, el sprint en curso,
|
||||
la previsión de sus releases y sus épicas abiertas (las 6 con fecha límite más cercana). Se calcula con lo mismo que el
|
||||
panel.
|
||||
|
||||
La **portada** (`myHome` en GraphQL, `GET /api/me/home`) junta lo demás que se ve en la página de inicio
|
||||
(`analytics/home.ts`):
|
||||
|
||||
| Bloque | Qué da |
|
||||
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `agenda` | Los próximos 7 días: mis tickets abiertos (asignados o que sigo) que vencen, fin de sprints en curso y releases planificadas |
|
||||
| `activity` | Lo último en mis proyectos (30 entradas; sin los proyectos en los que soy invitado) |
|
||||
| `team` / `me` | Tickets terminados por día en los últimos 14 días; esta semana y la anterior, de mis equipos y míos (por quien lo tenía asignado), y mi racha |
|
||||
| `sprints` | Los sprints en curso, con el mismo pulso que el panel (¿llega?) |
|
||||
| `projects` | Por proyecto: abiertos, vencidos, actividad por día (14 días) y el sprint en curso con su porcentaje |
|
||||
| `suggestions` | Para cuando no tengo nada: sin asignar, esperando QA y propuestas por revisar (estas, solo a quien administra); las 5 mayores |
|
||||
| `firstSteps` | Si ya creé un proyecto, invité a alguien, abrí un ticket y conecté el calendario |
|
||||
| `online` | Quién está ahora en un ticket de mis proyectos (por la presencia, una entrada por persona) |
|
||||
|
||||
El **panel del proyecto** (`Project.insights` en GraphQL, `GET /api/projects/{project}/insights`) lee lo mismo para
|
||||
decir qué mirar ahora (`analytics/insights.ts`; acepta `since`, un instante ISO):
|
||||
|
||||
| Bloque | Qué da |
|
||||
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Sprint en curso | Días que quedan, tickets y puntos hechos, ritmo (puntos al día) y el que haría falta; `behind` si no llega |
|
||||
| Riesgo | Abiertos vencidos, que vencen en 7 días, bloqueados (por un ticket abierto) y atascados (7 días o más sin moverse) |
|
||||
| Antigüedad | Lo que está en curso o en QA, por días en su estado, con el percentil 85 del tiempo de ciclo (90 días) como umbral |
|
||||
| Cuellos de botella | Días de media en cada estado en curso o de QA (estancias que terminaron en 90 días), cuántos hay y su límite WIP |
|
||||
| Próximas releases | Para cada release planificada (por fecha objetivo): fecha probable (p50) y segura (p85) simulando 500 futuros con lo que se cerró cada día de las últimas 6 semanas (Monte Carlo); cada release cuenta lo que queda de las anteriores. `ON_TRACK`, `AT_RISK`, `LATE`, `NO_DATE`, `UNKNOWN` (sin ritmo aún) o `DONE` |
|
||||
| Cambios | Con `since` (por ejemplo, la última visita): tickets creados, terminados y vueltos de QA desde entonces, y mis menciones en el proyecto |
|
||||
| Equipo | Por persona: tickets y puntos abiertos, bloqueados, puntos cerrados por sprint (últimos 3 cerrados) y `overloaded` si tiene abiertos más de los que suele cerrar |
|
||||
| Salud del backlog | Abiertos sin estimar, sin área, sin asignar y sin tocar en 90 días |
|
||||
| Calidad | Bugs (tipos con icono `bug`): abiertos por prioridad, nuevos y cerrados por semana (8 semanas), lo que QA devolvió y dejó pasar por área (90 días) y bugs abiertos en los 14 días tras cada una de las 3 últimas releases |
|
||||
| Campos | Para cada campo personalizado de lista: tickets abiertos por opción y sin valor |
|
||||
|
||||
## 3.2 GraphQL
|
||||
|
||||
Las operaciones requieren `Authorization: Bearer <token de Pireon>` (en subscriptions, en el `connection_init`), salvo
|
||||
@@ -414,7 +493,7 @@ Todas devuelven `CommandResult { id, key, version }`.
|
||||
| Webhooks | `createWebhook` (devuelve el secreto), `updateWebhook`, `deleteWebhook`, `testWebhook` |
|
||||
| Git | `addGitRepository` (devuelve su secreto), `rotateGitRepositorySecret`, `removeGitRepository` |
|
||||
| Sprints | `createSprint`, `updateSprint`, `startSprint`, `closeSprint(carryOverToSprintId)`, `deleteSprint`, `setIssueSprint` |
|
||||
| Vistas | `saveFilter(input)` (devuelve el id), `deleteSavedFilter(id)` |
|
||||
| Vistas | `saveFilter(input)` (devuelve el id), `deleteSavedFilter(id)`, `GET/POST /api/me/views`, `DELETE /api/me/views/{id}` (mis vistas de «Mi trabajo») |
|
||||
| Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` |
|
||||
| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `writeReleaseNotes`, `archiveRelease` |
|
||||
|
||||
@@ -614,10 +693,10 @@ 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/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `PUT …/settings`, `…/board`, `…/epics`, `…/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`, `PUT/DELETE …/watch`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history`, `POST /api/issues/{issue}/duplicate`, `PUT/DELETE …/vote` |
|
||||
| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `POST …/archive`, `POST …/unarchive`, `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/insights`, `…/activity`, `…/members` (POST, PATCH, DELETE) |
|
||||
| Tickets | `GET /api/me/home` (portada), `GET /api/me/portfolio` (cartera), `GET /api/me/issues` (mi trabajo; filtros `project`, `priority`, `bugsOnly`, `overdue`), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `PUT/DELETE …/presence`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history`, `POST /api/issues/{issue}/duplicate`, `PUT/DELETE …/vote` |
|
||||
| Comentarios | `PATCH/DELETE /api/comments/{commentId}`, `POST …/reactions` |
|
||||
| Sprints | `GET/POST /api/projects/{project}/sprints`, `GET/PATCH/DELETE /api/sprints/{sprint}`, `POST …/start`, `POST …/close`, `GET …/progress`, `…/burndown`, `…/issues`, `PUT /api/issues/{issue}/sprint` |
|
||||
| Vistas | `GET/POST /api/projects/{project}/filters`, `DELETE /api/filters/{id}` |
|
||||
@@ -644,7 +723,7 @@ tiempo real, las subscriptions de GraphQL.
|
||||
### Errores
|
||||
|
||||
`{ statusCode, code?, message }`: `400` entrada no válida (`message` es una lista), `401` sin token o token rechazado
|
||||
por Pireon, `403`/`404` sin acceso, `409` cambio concurrente (reintentar) y `422` regla de negocio (p. ej. una
|
||||
por Pireon, `403`/`404` sin acceso, `409` cambio concurrente (la API ya lo ha reintentado 5 veces; raro) y `422` regla de negocio (p. ej. una
|
||||
transición que el workflow no permite).
|
||||
|
||||
### Generar un cliente en otra app
|
||||
|
||||
@@ -114,6 +114,8 @@ Qué gana la página:
|
||||
| `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`) |
|
||||
| `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 |
|
||||
| `MyHomeFacade` | `MyHomeStore` | raíz | El resto de la portada (`myHome`): agenda, actividad, cifras de la semana, sprints, tarjetas, sugerencias, primeros pasos y quién está; se recarga cada minuto y al volver a la pestaña |
|
||||
| `MyViewsFacade` | `MyViewsStore` | raíz | Mis vistas guardadas de «Mi trabajo» (entre proyectos: proyectos, asignados a mí, que sigo, estados, prioridades, solo bugs, vencidos, texto) y los tickets de la elegida |
|
||||
| `ExploreFacade` | `ExploreStore` | raíz | Proyectos públicos («Explorar»), también sin sesión |
|
||||
| `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 |
|
||||
@@ -281,20 +283,22 @@ visualización de la plataforma:
|
||||
## 4.6 Rutas
|
||||
|
||||
| Ruta | Página |
|
||||
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `/login` | Inicio de sesión (solo sin sesión) |
|
||||
| `/auth/callback` | Retorno de Pireon (PKCE) |
|
||||
| `/` | Inicio: «Ir a…» (`WEB-12`), mi trabajo (`ts-my-work`) y mis proyectos |
|
||||
| `/` | Inicio: saludo según la hora con el resumen del día, «Ir a…» (`WEB-12`), primeros pasos, la semana (`ts-week-agenda`), mis vistas guardadas, mi trabajo (`ts-my-work`), lo último en mis proyectos, la semana en cifras, sprints en curso, quién está ahora, «Retomar» y mis proyectos con su actividad |
|
||||
| `/cartera` | Cartera (con sesión): una tarjeta por proyecto activo con lo abierto y en riesgo, el sprint en curso (¿llega?), las próximas releases con su previsión y las épicas abiertas |
|
||||
| `/explorar` | Proyectos públicos (sin sesión) |
|
||||
| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros) |
|
||||
| `/p/:key/issues/:issueKey` | Ticket |
|
||||
| `/p/:key/issues/:issueKey` | Ticket. Encima del título, quién más lo está viendo o editando ahora (`PresenceFacade`: late cada 10 s; editar es tener abierto el formulario del ticket o de un comentario, o estar escribiendo uno) |
|
||||
| `/p/:key/releases` | Releases |
|
||||
| `/p/:key/report/:kind/:id` | Informe para imprimir de un sprint (`sprint`) o una release (`release`): datos, totales, notas y tickets por etapa. «Imprimir o guardar en PDF» abre el diálogo del navegador; al imprimir se quitan la cabecera, las pestañas y los fondos (`styles.scss`) |
|
||||
| `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea |
|
||||
| `/p/:key/dashboard` | Seguimiento: cifras, flujo por estado, equipo (cada persona enlaza a su tablero) y tipos |
|
||||
| `/p/:key/dashboard` | Seguimiento: cifras, flujo por estado, lo que ha cambiado desde tu última visita (guardada en este navegador), sprint en curso, tickets en riesgo, previsión de las próximas releases, antigüedad de lo que está en curso, cuellos de botella, salud del backlog (cada número abre la lista filtrada: `?missing=estimate`, `area`, `assignee` o `?staleDays=90`), calidad (bugs por prioridad y por semana, QA por área, bugs tras cada release), abiertos por un campo de lista a elegir, equipo con sus puntos abiertos y tipos. Se recarga solo con cada cambio |
|
||||
| `/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, áreas y campos personalizados; archivar en lugar de borrar |
|
||||
| `/p/:key/settings` | Configuración (propietario y admins; pestaña oculta para el resto): estados (orden, categoría, color, motivo), transiciones, tipos, áreas, equipos (con sus miembros), definición de «Hecho» y campos personalizados; archivar en lugar de borrar |
|
||||
| `/p/:key/activity` | Actividad en vivo |
|
||||
| `/p/:key/members` | Miembros |
|
||||
| `/p/:key/members` | Miembros, con su rol (propietario, admin, miembro, lector o invitado). Un invitado de un proyecto privado solo tiene Tablero, Lista y Miembros, ve solo sus tickets y abre tickets sin asignarlos ni planificarlos |
|
||||
|
||||
Solo `/` está protegida por `authGuard` (redirige a `/login?returnUrl=…`); `/login` y `/auth/callback` son para quien
|
||||
no tiene sesión, y el resto se puede abrir sin ella si el proyecto es público. Los parámetros de ruta llegan como inputs de
|
||||
|
||||
@@ -32,24 +32,27 @@ flowchart LR
|
||||
|
||||
## 9.2 Herramientas
|
||||
|
||||
57 herramientas, agrupadas por ámbito:
|
||||
60 herramientas, agrupadas por ámbito:
|
||||
|
||||
| Ámbito | Herramienta | Qué hace |
|
||||
| ------------ | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- |
|
||||
| ------------ | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Usuario | `whoami` | El usuario del token |
|
||||
| | `search_users` | Buscar personas de Pireon (para asignar) |
|
||||
| | `my_notifications` | Mis notificaciones (por defecto, las no leídas) |
|
||||
| | `mark_notifications_read` | Marcar como leídas unas o todas |
|
||||
| Proyectos | `list_my_projects` | Mis proyectos |
|
||||
| | `list_public_projects` | Proyectos públicos |
|
||||
| | `create_project` | Crear un proyecto (quien lo crea es su dueño) |
|
||||
| | `create_project` | Crear un proyecto (quien lo crea es su dueño), también copiando la configuración de otro (`template`) |
|
||||
| | `get_project` | Equipo y configuración: ids de estados, tipos y áreas que aceptan las demás herramientas |
|
||||
| | `get_project_showcase` | El escaparate público: hoja de ruta, releases, sprint en curso y tickets para empezar |
|
||||
| | `get_board` | Tablero por columnas (filtros por persona, release, área, sprint) |
|
||||
| | `get_project_stats` | Cifras de seguimiento |
|
||||
| | `get_project_analytics` | Estadísticas de los últimos días (creados/resueltos, flujo, tiempos, throughput, QA) |
|
||||
| | `get_portfolio` | Todos mis proyectos activos de un vistazo: abiertos y en riesgo, sprint, previsión de releases y épicas |
|
||||
| | `get_my_week` | Mi semana: lo mío que vence en 7 días, fin de sprints y releases; lo terminado por mí y por mis equipos, racha; sprints en curso y qué podría coger |
|
||||
| | `get_project_insights` | El panel: sprint en curso, riesgo, antigüedad, cuellos de botella, previsión de releases, carga del equipo, salud del backlog, calidad, abiertos por campo de lista y, con `since`, los cambios |
|
||||
| | `get_project_activity` | Actividad reciente |
|
||||
| Tickets | `my_issues` | Mi trabajo en todos los proyectos (o de cualquiera, por categoría; o los que sigo) |
|
||||
| Tickets | `my_issues` | Mi trabajo en todos los proyectos (o de cualquiera, por categoría; o los que sigo); también por prioridad, solo bugs o vencidos |
|
||||
| | `search_issues` | Buscar por texto y filtros (épica, sprint, fechas, campos…); el backlog: `noSprint` y orden `RANK` |
|
||||
| | `get_issue` | Detalle: épica, lista de comprobación, relaciones, commits, seguidores, transiciones, comentarios |
|
||||
| | `create_issue` | Crear un ticket (también colgado de una épica) |
|
||||
|
||||
@@ -28,50 +28,18 @@ cuando hay pistas, por dónde empezar.
|
||||
|
||||
## 10.3 Siguientes funcionalidades
|
||||
|
||||
### En curso, en este orden
|
||||
### En curso
|
||||
|
||||
1. **Burndown y velocidad de sprint**: puntos pendientes día a día durante el sprint y puntos cerrados por sprint, para
|
||||
planificar el siguiente con datos (el historial de estados ya está en `issue_timeline`).
|
||||
2. **Plantillas de proyecto**: crear un proyecto copiando de otro los estados, transiciones, tipos, áreas, campos
|
||||
personalizados, automatizaciones y etiquetas.
|
||||
3. **Exportar e imprimir un sprint o una release**: un PDF con los tickets, su estado y las notas, para un cliente o
|
||||
para archivar.
|
||||
|
||||
Y en el panel del proyecto:
|
||||
|
||||
4. **Sprint en curso**: días que quedan, puntos hechos y pendientes, y aviso si al ritmo actual no llega.
|
||||
5. **Tickets en riesgo**: vencidos, que vencen esta semana, bloqueados y atascados (días sin moverse en un estado en
|
||||
curso), cada grupo con enlace a la lista filtrada.
|
||||
6. **Antigüedad de lo abierto**: cada ticket en curso por los días que lleva en su estado, con el umbral habitual del
|
||||
proyecto (p85 del tiempo de ciclo).
|
||||
7. **Cuellos de botella**: días medios en cada estado y columnas por encima de su límite WIP.
|
||||
8. **Próximas releases con previsión**: progreso, fecha objetivo y fecha probable según el ritmo real (Monte Carlo),
|
||||
con semáforo.
|
||||
9. **Qué ha cambiado desde tu última visita**: nuevos, cerrados, devueltos por QA y menciones.
|
||||
10. **Carga del equipo en puntos**: asignado frente a lo que cada persona suele cerrar por sprint, con aviso de
|
||||
sobrecarga y sus bloqueados.
|
||||
11. **Salud del backlog**: sin estimar, sin área, sin asignar y sin tocar en 90 días, con enlace a la lista filtrada.
|
||||
12. **Calidad**: bugs abiertos por prioridad y su tendencia, devoluciones de QA por área y bugs abiertos en los 14 días
|
||||
siguientes a cada release.
|
||||
13. **Desglose por campo personalizado**: tickets abiertos según un campo de opciones a elegir («bugs por
|
||||
navegador»).
|
||||
Nada: la tanda está hecha (ver 10.5).
|
||||
|
||||
### Aceptadas, para después de esta tanda
|
||||
|
||||
- **Sincronizar con issues de Gitea o GitHub**: que un issue del repositorio cree o actualice un ticket, y al revés.
|
||||
- **Vistas guardadas en «Mi trabajo»**: filtros guardados de varios proyectos («Bugs críticos de todos mis
|
||||
proyectos»).
|
||||
- **Búsqueda con lenguaje de consulta**: `estado:abierto asignado:yo navegador:Safari prioridad>=alta`, guardable como
|
||||
vista.
|
||||
- **Definición de «Hecho»**: no pasar a un estado `DONE` con la lista de comprobación a medias (salvo un admin).
|
||||
- **Entornos de despliegue**: en qué entorno está cada ticket (pruebas, producción), desde los avisos de la
|
||||
integración continua.
|
||||
- **Equipos dentro del proyecto**: asignar a un equipo y mencionarlo (`@frontend`).
|
||||
- **Presencia**: ver quién está mirando o editando el mismo ticket.
|
||||
- **Rol «invitado»**: solo ve los tickets en los que participa (clientes, externos).
|
||||
- **Página pública de novedades**: las notas de versión publicadas en una página por proyecto, con RSS.
|
||||
- **Vista de cartera**: progreso de épicas y releases de todos los proyectos en una página.
|
||||
- **Archivar un proyecto**: queda en solo lectura en lugar de borrarlo.
|
||||
|
||||
### Otras ideas apuntadas, sin decidir
|
||||
|
||||
@@ -130,3 +98,30 @@ Mucho trabajo para lo que aportan hoy; se pueden recuperar si alguien los necesi
|
||||
MCP).
|
||||
- Dependencias en la hoja de ruta: flechas entre las épicas que se bloquean o dependen unas de otras, y aviso cuando
|
||||
una empieza antes de que acabe la que la bloquea.
|
||||
- Plantillas de proyecto: al crear uno, «Empezar con la configuración de» otro que administras copia sus estados,
|
||||
transiciones, tipos, áreas, campos personalizados y automatizaciones. (El burndown y la velocidad de sprint ya
|
||||
existían, en Sprints.)
|
||||
- Informe para imprimir (o guardar en PDF) de un sprint o una release, desde Sprints y Releases.
|
||||
- Panel del proyecto, primera parte: sprint en curso (¿llega a tiempo?), tickets en riesgo (vencidos, que vencen esta
|
||||
semana, bloqueados y atascados), antigüedad de lo que está en curso y cuellos de botella. También por REST, GraphQL
|
||||
y MCP (`get_project_insights`).
|
||||
- Panel del proyecto, segunda parte: lo que ha cambiado desde tu última visita, previsión de las próximas releases al
|
||||
ritmo real (Monte Carlo), carga del equipo en puntos y salud del backlog con enlaces a la lista filtrada.
|
||||
- Panel del proyecto, tercera parte: calidad (bugs abiertos por prioridad, nuevos y cerrados por semana, QA por área,
|
||||
bugs tras cada release) y abiertos por opción de un campo de lista.
|
||||
- Escrituras a la vez en un ticket: la API repite sola la orden que choca con otra (por ejemplo, con una saga), en
|
||||
lugar de devolver un 409.
|
||||
- Vistas guardadas en «Mi trabajo»: filtros propios entre proyectos (proyectos, asignados a mí, que sigo, estados,
|
||||
prioridades, solo bugs, vencidos, texto), en una barra encima de «Mi trabajo».
|
||||
- Definición de «Hecho» por proyecto: con ella activada, un ticket no se cierra con la checklist a medias (el
|
||||
propietario y los admins sí pueden).
|
||||
- Archivar un proyecto: queda en solo lectura (se ve todo, no cambia nada) hasta que su propietario lo desarchiva.
|
||||
- Equipos dentro del proyecto: grupos de miembros en Configuración; los tickets se asignan a uno, la lista filtra por
|
||||
equipo y «@equipo» avisa a todos sus miembros.
|
||||
- Presencia: en cada ticket se ve quién más lo está viendo o editando en ese momento.
|
||||
- Rol «invitado»: para clientes o gente de fuera; en un proyecto privado solo ve los tickets en los que participa,
|
||||
abre tickets y comenta en los suyos.
|
||||
- Vista de cartera (`/cartera`): todos tus proyectos activos de un vistazo, con su riesgo, sprint, releases y épicas.
|
||||
- Una portada con vida: saludo y resumen del día, la semana que viene, lo último en tus proyectos, quién está ahora,
|
||||
la semana en cifras (tuya y del equipo, con racha), sprints en curso, «Retomar», tarjetas de proyecto con actividad,
|
||||
qué coger cuando no tienes nada y primeros pasos para quien empieza. También por MCP (`get_my_week`).
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,5 +1,6 @@
|
||||
import { CommandRunner } from '@task-stream/api/shared/application';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import { QueryBus } from '@nestjs/cqrs';
|
||||
import {
|
||||
AuthorizationConfig,
|
||||
TokenGrant,
|
||||
@@ -20,7 +21,7 @@ import {
|
||||
@Injectable()
|
||||
export class IdentityService {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly commands: CommandRunner,
|
||||
private readonly queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
@@ -44,14 +45,12 @@ export class IdentityService {
|
||||
}
|
||||
|
||||
handleWebhook(event: string, data: Record<string, unknown>): Promise<void> {
|
||||
return this.commandBus.execute(
|
||||
new HandleIdentityWebhookCommand(event, data),
|
||||
);
|
||||
return this.commands.execute(new HandleIdentityWebhookCommand(event, data));
|
||||
}
|
||||
|
||||
/** Sign in / refresh through the identity server (the browser cannot call it: no CORS). */
|
||||
issueToken(grant: TokenGrant): Promise<TokenSet> {
|
||||
return this.commandBus.execute(new IssueTokenCommand(grant));
|
||||
return this.commands.execute(new IssueTokenCommand(grant));
|
||||
}
|
||||
|
||||
authorizationConfig(): Promise<AuthorizationConfig | null> {
|
||||
|
||||
@@ -2,3 +2,4 @@ export * from './lib/ports/event-store';
|
||||
export * from './lib/aggregate-rehydrator';
|
||||
export * from './lib/shared.module';
|
||||
export * from './lib/event-recorder';
|
||||
export * from './lib/command-runner';
|
||||
|
||||
48
libs/api/shared/application/src/lib/command-runner.spec.ts
Normal file
48
libs/api/shared/application/src/lib/command-runner.spec.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Command, CommandBus } from '@nestjs/cqrs';
|
||||
import {
|
||||
BusinessRuleError,
|
||||
ConcurrencyError,
|
||||
} from '@task-stream/api/shared/domain';
|
||||
import { COMMAND_ATTEMPTS, CommandRunner } from './command-runner';
|
||||
|
||||
class Rename extends Command<string> {}
|
||||
|
||||
const busFailing = (errors: Error[]) => {
|
||||
const execute = jest.fn(async () => {
|
||||
const error = errors.shift();
|
||||
if (error) throw error;
|
||||
return 'done';
|
||||
});
|
||||
return { bus: { execute } as unknown as CommandBus, execute };
|
||||
};
|
||||
|
||||
describe('CommandRunner', () => {
|
||||
it('runs a command again while its aggregate changed underneath', async () => {
|
||||
const { bus, execute } = busFailing([
|
||||
new ConcurrencyError('Aggregate', 'a'),
|
||||
new ConcurrencyError('Aggregate', 'a'),
|
||||
]);
|
||||
await expect(new CommandRunner(bus).execute(new Rename())).resolves.toBe(
|
||||
'done',
|
||||
);
|
||||
expect(execute).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('gives up after a few conflicts, and never retries other errors', async () => {
|
||||
const conflicts = Array.from(
|
||||
{ length: COMMAND_ATTEMPTS },
|
||||
() => new ConcurrencyError('Aggregate', 'a'),
|
||||
);
|
||||
const stubborn = busFailing(conflicts);
|
||||
await expect(
|
||||
new CommandRunner(stubborn.bus).execute(new Rename()),
|
||||
).rejects.toBeInstanceOf(ConcurrencyError);
|
||||
expect(stubborn.execute).toHaveBeenCalledTimes(COMMAND_ATTEMPTS);
|
||||
|
||||
const refused = busFailing([new BusinessRuleError('No')]);
|
||||
await expect(
|
||||
new CommandRunner(refused.bus).execute(new Rename()),
|
||||
).rejects.toBeInstanceOf(BusinessRuleError);
|
||||
expect(refused.execute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
31
libs/api/shared/application/src/lib/command-runner.ts
Normal file
31
libs/api/shared/application/src/lib/command-runner.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Command, CommandBus } from '@nestjs/cqrs';
|
||||
import { ConcurrencyError } from '@task-stream/api/shared/domain';
|
||||
|
||||
/** Attempts before a conflict reaches the caller (as a 409). */
|
||||
export const COMMAND_ATTEMPTS = 5;
|
||||
|
||||
/**
|
||||
* Runs commands for the application services, and runs one again when its
|
||||
* aggregate changed underneath (someone, or a saga, wrote to it at the same
|
||||
* moment). Every command loads its aggregate fresh and stores its events in
|
||||
* one transaction, so a ConcurrencyError means nothing was stored: running
|
||||
* it again on the new state is safe.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CommandRunner {
|
||||
constructor(private readonly commandBus: CommandBus) {}
|
||||
|
||||
async execute<R>(command: Command<R>): Promise<R> {
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
try {
|
||||
return await this.commandBus.execute(command);
|
||||
} catch (error) {
|
||||
if (!(error instanceof ConcurrencyError) || attempt >= COMMAND_ATTEMPTS)
|
||||
throw error;
|
||||
// A little later each time, so the other writer can finish.
|
||||
await new Promise((resolve) => setTimeout(resolve, 25 * attempt));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import { DynamicModule, Global, Module, Type } from '@nestjs/common';
|
||||
import { CqrsModule } from '@nestjs/cqrs';
|
||||
import { AggregateRehydrator } from './aggregate-rehydrator';
|
||||
import { CommandRunner } from './command-runner';
|
||||
import { EventRecorder } from './event-recorder';
|
||||
|
||||
/**
|
||||
* Shared building blocks for every bounded context: the event store (from
|
||||
* the infrastructure module plugged in by the composition root), the
|
||||
* AggregateRehydrator and the EventRecorder.
|
||||
* AggregateRehydrator, the EventRecorder and the CommandRunner.
|
||||
*/
|
||||
@Global()
|
||||
@Module({})
|
||||
@@ -17,8 +18,13 @@ export class SharedModule {
|
||||
return {
|
||||
module: SharedModule,
|
||||
imports: [CqrsModule, infrastructureModule],
|
||||
providers: [AggregateRehydrator, EventRecorder],
|
||||
exports: [infrastructureModule, AggregateRehydrator, EventRecorder],
|
||||
providers: [AggregateRehydrator, EventRecorder, CommandRunner],
|
||||
exports: [
|
||||
infrastructureModule,
|
||||
AggregateRehydrator,
|
||||
EventRecorder,
|
||||
CommandRunner,
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,4 +29,8 @@ export * from './lib/webhooks/webhook-events';
|
||||
export * from './lib/git/git';
|
||||
export * from './lib/git/git-payloads';
|
||||
export * from './lib/saved-filters/saved-filters';
|
||||
export * from './lib/saved-filters/my-views';
|
||||
export * from './lib/ports/my-views.repository';
|
||||
export * from './lib/tracker-application.module';
|
||||
export * from './lib/ports/presence.store';
|
||||
export * from './lib/presence.service';
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
const DAY = 86_400_000;
|
||||
const STARTED = [StatusCategory.IN_PROGRESS, StatusCategory.QA];
|
||||
|
||||
interface Step {
|
||||
export interface Step {
|
||||
at: number;
|
||||
status: string;
|
||||
category: StatusCategory;
|
||||
@@ -63,7 +63,7 @@ function weeks(from: number, to: number): string[] {
|
||||
}
|
||||
|
||||
/** Status steps per ticket; tickets with no timeline get one from their creation. */
|
||||
function stepsByIssue(
|
||||
export function stepsByIssue(
|
||||
issues: IssueReadModel[],
|
||||
timeline: IssueTimelineEntry[],
|
||||
): Map<string, Step[]> {
|
||||
|
||||
215
libs/api/tracker/application/src/lib/analytics/home.spec.ts
Normal file
215
libs/api/tracker/application/src/lib/analytics/home.spec.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import {
|
||||
IssueReadModel,
|
||||
IssueTimelineEntry,
|
||||
ProjectReadModel,
|
||||
ProjectRole,
|
||||
ReleaseReadModel,
|
||||
ReleaseStatus,
|
||||
SprintReadModel,
|
||||
SprintStatus,
|
||||
StatusCategory as C,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { computeHome, HomeProjectInput, lastDays } from './home';
|
||||
|
||||
const NOW = new Date('2026-10-15T12:00:00Z');
|
||||
const ana = { id: 'ana', name: 'Ana' };
|
||||
const bob = { id: 'bob', name: 'Bob' };
|
||||
|
||||
const project = (
|
||||
key: string,
|
||||
role: ProjectRole,
|
||||
extra: Partial<ProjectReadModel> = {},
|
||||
): ProjectReadModel =>
|
||||
({
|
||||
id: key.toLowerCase(),
|
||||
key,
|
||||
name: `Proyecto ${key}`,
|
||||
isPublic: false,
|
||||
archivedAt: null,
|
||||
members: [
|
||||
{ user: ana, role },
|
||||
{ user: bob, role: ProjectRole.MEMBER },
|
||||
],
|
||||
...extra,
|
||||
}) as ProjectReadModel;
|
||||
|
||||
const issue = (
|
||||
id: string,
|
||||
category: C,
|
||||
extra: Partial<IssueReadModel> = {},
|
||||
): IssueReadModel =>
|
||||
({
|
||||
id,
|
||||
key: id,
|
||||
projectId: 'web',
|
||||
title: `Ticket ${id}`,
|
||||
status: category,
|
||||
statusCategory: category,
|
||||
reporter: bob,
|
||||
assignee: null,
|
||||
watchers: [],
|
||||
labels: [],
|
||||
dueDate: null,
|
||||
trashedAt: null,
|
||||
storyPoints: null,
|
||||
sprint: null,
|
||||
createdAt: '2026-09-01T09:00:00Z',
|
||||
updatedAt: '2026-10-01T09:00:00Z',
|
||||
...extra,
|
||||
}) as IssueReadModel;
|
||||
|
||||
let seq = 0;
|
||||
const done = (
|
||||
issueId: string,
|
||||
at: string,
|
||||
assigneeId: string,
|
||||
): IssueTimelineEntry[] => [
|
||||
{
|
||||
id: `${issueId}:${++seq}`,
|
||||
issueId,
|
||||
projectId: 'web',
|
||||
at: '2026-09-01T09:00:00Z',
|
||||
kind: 'status',
|
||||
status: 'IN_PROGRESS',
|
||||
category: C.IN_PROGRESS,
|
||||
assigneeId,
|
||||
},
|
||||
{
|
||||
id: `${issueId}:${++seq}`,
|
||||
issueId,
|
||||
projectId: 'web',
|
||||
at,
|
||||
kind: 'status',
|
||||
status: 'DONE',
|
||||
category: C.DONE,
|
||||
assigneeId,
|
||||
},
|
||||
];
|
||||
|
||||
const input = (projects: HomeProjectInput[]) => ({
|
||||
viewerId: 'ana',
|
||||
projects,
|
||||
activity: [],
|
||||
hasCalendar: false,
|
||||
online: [],
|
||||
now: NOW,
|
||||
});
|
||||
|
||||
const web = (extra: Partial<HomeProjectInput> = {}): HomeProjectInput => ({
|
||||
project: project('WEB', ProjectRole.OWNER),
|
||||
issues: [],
|
||||
timeline: [],
|
||||
sprints: [],
|
||||
releases: [],
|
||||
activity: new Array(14).fill(0),
|
||||
...extra,
|
||||
});
|
||||
|
||||
describe('computeHome', () => {
|
||||
it('puts my coming week on the agenda: tickets, sprint ends, releases', () => {
|
||||
const home = computeHome(
|
||||
input([
|
||||
web({
|
||||
issues: [
|
||||
issue('MINE', C.TODO, { assignee: ana, dueDate: '2026-10-17' }),
|
||||
issue('FOLLOWED', C.TODO, {
|
||||
watchers: [ana],
|
||||
dueDate: '2026-10-15',
|
||||
}),
|
||||
issue('NOT_MINE', C.TODO, { assignee: bob, dueDate: '2026-10-16' }),
|
||||
issue('LATER', C.TODO, { assignee: ana, dueDate: '2026-10-30' }),
|
||||
issue('DONE', C.DONE, { assignee: ana, dueDate: '2026-10-16' }),
|
||||
],
|
||||
sprints: [
|
||||
{
|
||||
id: 's',
|
||||
name: 'Sprint 7',
|
||||
status: SprintStatus.ACTIVE,
|
||||
startDate: '2026-10-08',
|
||||
endDate: '2026-10-21',
|
||||
} as SprintReadModel,
|
||||
],
|
||||
releases: [
|
||||
{
|
||||
id: 'r',
|
||||
name: '2.4',
|
||||
status: ReleaseStatus.PLANNED,
|
||||
targetDate: '2026-10-20',
|
||||
} as ReleaseReadModel,
|
||||
],
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(home.agenda.map((a) => [a.day, a.kind, a.title])).toEqual([
|
||||
['2026-10-15', 'ISSUE', 'Ticket FOLLOWED'],
|
||||
['2026-10-17', 'ISSUE', 'Ticket MINE'],
|
||||
['2026-10-20', 'RELEASE', '2.4'],
|
||||
['2026-10-21', 'SPRINT_END', 'Sprint 7'],
|
||||
]);
|
||||
});
|
||||
|
||||
it("counts the team's week and mine, and my streak", () => {
|
||||
const closed = [
|
||||
['A', '2026-10-15T10:00:00Z', 'ana'],
|
||||
['B', '2026-10-14T10:00:00Z', 'ana'],
|
||||
['C', '2026-10-13T10:00:00Z', 'ana'],
|
||||
['D', '2026-10-11T10:00:00Z', 'ana'],
|
||||
['E', '2026-10-05T10:00:00Z', 'bob'],
|
||||
] as const;
|
||||
const home = computeHome(
|
||||
input([
|
||||
web({
|
||||
issues: closed.map(([id]) => issue(id, C.DONE)),
|
||||
timeline: closed.flatMap(([id, at, who]) => done(id, at, who)),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(home.team.perDay).toHaveLength(14);
|
||||
expect(home.team.perDay[13]).toBe(1);
|
||||
expect(home.team).toMatchObject({ thisWeek: 4, lastWeek: 1 });
|
||||
// Today, yesterday and the day before: three in a row (the 12th breaks it).
|
||||
expect(home.me).toEqual({ thisWeek: 4, lastWeek: 0, streak: 3 });
|
||||
});
|
||||
|
||||
it('suggests what to do, and keeps a guest out of project-wide views', () => {
|
||||
const home = computeHome(
|
||||
input([
|
||||
web({
|
||||
issues: [
|
||||
issue('U1', C.TODO),
|
||||
issue('U2', C.TODO, { labels: ['propuesta'] }),
|
||||
issue('Q1', C.QA, { assignee: bob }),
|
||||
],
|
||||
}),
|
||||
{
|
||||
...web(),
|
||||
project: project('CLI', ProjectRole.GUEST),
|
||||
issues: [issue('X', C.TODO)],
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
home.suggestions.map((s) => [s.kind, s.projectKey, s.count]),
|
||||
).toEqual([
|
||||
['UNASSIGNED', 'WEB', 2],
|
||||
['QA_WAITING', 'WEB', 1],
|
||||
['PROPOSALS', 'WEB', 1],
|
||||
]);
|
||||
// Only the project seen whole has a card.
|
||||
expect(home.projects.map((p) => p.projectId)).toEqual(['web']);
|
||||
expect(home.projects[0]).toMatchObject({ open: 3, overdue: 0 });
|
||||
expect(home.firstSteps).toEqual({
|
||||
project: true,
|
||||
invited: true,
|
||||
ticket: false,
|
||||
calendar: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('lists the last 14 days, oldest first', () => {
|
||||
const days = lastDays(NOW);
|
||||
expect(days).toHaveLength(14);
|
||||
expect(days[0]).toBe('2026-10-02');
|
||||
expect(days[13]).toBe('2026-10-15');
|
||||
});
|
||||
});
|
||||
274
libs/api/tracker/application/src/lib/analytics/home.ts
Normal file
274
libs/api/tracker/application/src/lib/analytics/home.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import {
|
||||
ActivityReadModel,
|
||||
AgendaItemReadModel,
|
||||
HomeProjectReadModel,
|
||||
HomeReadModel,
|
||||
HomeSprintReadModel,
|
||||
IssueReadModel,
|
||||
IssueTimelineEntry,
|
||||
OnlineReadModel,
|
||||
ProjectReadModel,
|
||||
ProjectRole,
|
||||
ReleaseReadModel,
|
||||
ReleaseStatus,
|
||||
SprintReadModel,
|
||||
SprintStatus,
|
||||
StatusCategory,
|
||||
SuggestionReadModel,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { PROPOSAL_LABEL } from '../proposals';
|
||||
import { guestOnly } from '../queries/read-access';
|
||||
import { stepsByIssue } from './analytics';
|
||||
import { sprintPulse } from './insights';
|
||||
|
||||
/*
|
||||
* The home page beyond «my work»: what comes this week, what moves in my
|
||||
* projects, how the week goes, and what to do when nothing is mine. Pure,
|
||||
* like the statistics.
|
||||
*/
|
||||
|
||||
const DAY = 86_400_000;
|
||||
const CLOSED = [StatusCategory.DONE, StatusCategory.CANCELLED];
|
||||
const OPEN = [
|
||||
StatusCategory.TODO,
|
||||
StatusCategory.IN_PROGRESS,
|
||||
StatusCategory.QA,
|
||||
];
|
||||
|
||||
export interface HomeProjectInput {
|
||||
project: ProjectReadModel;
|
||||
issues: IssueReadModel[];
|
||||
timeline: IssueTimelineEntry[];
|
||||
sprints: SprintReadModel[];
|
||||
releases: ReleaseReadModel[];
|
||||
/** Activity per day of the last 14 days, oldest first. */
|
||||
activity: number[];
|
||||
}
|
||||
|
||||
export interface HomeInput {
|
||||
viewerId: string;
|
||||
projects: HomeProjectInput[];
|
||||
/** Latest activity of the projects I see whole, newest first. */
|
||||
activity: ActivityReadModel[];
|
||||
hasCalendar: boolean;
|
||||
online: OnlineReadModel[];
|
||||
now: Date;
|
||||
}
|
||||
|
||||
const utcDay = (time: number) =>
|
||||
Date.UTC(
|
||||
new Date(time).getUTCFullYear(),
|
||||
new Date(time).getUTCMonth(),
|
||||
new Date(time).getUTCDate(),
|
||||
);
|
||||
const dayString = (time: number) => new Date(time).toISOString().slice(0, 10);
|
||||
|
||||
export function computeHome(input: HomeInput): HomeReadModel {
|
||||
const now = input.now.getTime();
|
||||
const today = utcDay(now);
|
||||
const todayStr = dayString(today);
|
||||
const weekEnd = dayString(today + 6 * DAY);
|
||||
const me = input.viewerId;
|
||||
const whole = input.projects.filter((p) => !guestOnly(p.project, me));
|
||||
|
||||
// --- Agenda: the next 7 days ---
|
||||
const agenda: AgendaItemReadModel[] = [];
|
||||
for (const { project, issues, sprints, releases } of input.projects) {
|
||||
for (const issue of issues) {
|
||||
if (issue.trashedAt || CLOSED.includes(issue.statusCategory)) continue;
|
||||
const due = issue.dueDate?.slice(0, 10);
|
||||
if (!due || due < todayStr || due > weekEnd) continue;
|
||||
if (
|
||||
issue.assignee?.id !== me &&
|
||||
!(issue.watchers ?? []).some((w) => w.id === me)
|
||||
)
|
||||
continue;
|
||||
agenda.push({
|
||||
day: due,
|
||||
kind: 'ISSUE',
|
||||
projectKey: project.key,
|
||||
title: issue.title,
|
||||
issueKey: issue.key,
|
||||
});
|
||||
}
|
||||
if (guestOnly(project, me)) continue;
|
||||
for (const sprint of sprints)
|
||||
if (
|
||||
sprint.status === SprintStatus.ACTIVE &&
|
||||
sprint.endDate >= todayStr &&
|
||||
sprint.endDate <= weekEnd
|
||||
)
|
||||
agenda.push({
|
||||
day: sprint.endDate,
|
||||
kind: 'SPRINT_END',
|
||||
projectKey: project.key,
|
||||
title: sprint.name,
|
||||
issueKey: null,
|
||||
});
|
||||
for (const release of releases) {
|
||||
const target = release.targetDate?.slice(0, 10);
|
||||
if (
|
||||
release.status === ReleaseStatus.PLANNED &&
|
||||
target &&
|
||||
target >= todayStr &&
|
||||
target <= weekEnd
|
||||
)
|
||||
agenda.push({
|
||||
day: target,
|
||||
kind: 'RELEASE',
|
||||
projectKey: project.key,
|
||||
title: release.name,
|
||||
issueKey: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
agenda.sort(
|
||||
(a, b) => a.day.localeCompare(b.day) || a.kind.localeCompare(b.kind),
|
||||
);
|
||||
|
||||
// --- Finished per day: the team's and mine ---
|
||||
const teamPerDay = new Array<number>(14).fill(0);
|
||||
const minePerDay = new Map<number, number>();
|
||||
for (const { issues, timeline } of whole) {
|
||||
const steps = stepsByIssue(issues, timeline);
|
||||
for (const list of steps.values())
|
||||
list.forEach((step, i) => {
|
||||
if (step.category !== StatusCategory.DONE) return;
|
||||
if (i > 0 && list[i - 1].category === StatusCategory.DONE) return;
|
||||
if (i === 0) return; // created already done: nobody finished it
|
||||
const day = utcDay(step.at);
|
||||
const ago = Math.round((today - day) / DAY);
|
||||
if (ago >= 0 && ago < 14) teamPerDay[13 - ago]++;
|
||||
if (step.assigneeId === me)
|
||||
minePerDay.set(day, (minePerDay.get(day) ?? 0) + 1);
|
||||
});
|
||||
}
|
||||
const sum = (from: number, to: number) =>
|
||||
teamPerDay.slice(from, to).reduce((a, b) => a + b, 0);
|
||||
const mine = (daysAgoFrom: number, daysAgoTo: number) => {
|
||||
let total = 0;
|
||||
for (let ago = daysAgoFrom; ago < daysAgoTo; ago++)
|
||||
total += minePerDay.get(today - ago * DAY) ?? 0;
|
||||
return total;
|
||||
};
|
||||
// A streak counts up to today, or up to yesterday if today is still empty.
|
||||
let streak = 0;
|
||||
for (
|
||||
let ago = minePerDay.get(today) ? 0 : 1;
|
||||
minePerDay.get(today - ago * DAY);
|
||||
ago++
|
||||
)
|
||||
streak++;
|
||||
|
||||
// --- Sprints and project cards ---
|
||||
const sprints: HomeSprintReadModel[] = [];
|
||||
const projects: HomeProjectReadModel[] = [];
|
||||
for (const { project, issues, sprints: all, activity } of whole) {
|
||||
const live = issues.filter((i) => !i.trashedAt);
|
||||
const active = all.find((s) => s.status === SprintStatus.ACTIVE);
|
||||
const pulse = active ? sprintPulse(active, live, now) : null;
|
||||
if (pulse)
|
||||
sprints.push({
|
||||
projectKey: project.key,
|
||||
projectName: project.name,
|
||||
sprint: pulse,
|
||||
});
|
||||
projects.push({
|
||||
projectId: project.id,
|
||||
open: live.filter((i) => !CLOSED.includes(i.statusCategory)).length,
|
||||
overdue: live.filter(
|
||||
(i) =>
|
||||
!CLOSED.includes(i.statusCategory) &&
|
||||
!!i.dueDate &&
|
||||
i.dueDate.slice(0, 10) < todayStr,
|
||||
).length,
|
||||
activity,
|
||||
sprint: pulse
|
||||
? {
|
||||
name: pulse.name,
|
||||
percent: pulse.points
|
||||
? Math.round((pulse.donePoints / pulse.points) * 100)
|
||||
: pulse.tickets
|
||||
? Math.round((pulse.doneTickets / pulse.tickets) * 100)
|
||||
: 0,
|
||||
behind: pulse.behind,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
// --- Suggestions, for when nothing is mine ---
|
||||
const suggestions: SuggestionReadModel[] = [];
|
||||
for (const { project, issues } of whole) {
|
||||
const role = project.members.find((m) => m.user.id === me)?.role;
|
||||
if (!role || role === ProjectRole.VIEWER) continue;
|
||||
const open = issues.filter(
|
||||
(i) => !i.trashedAt && OPEN.includes(i.statusCategory),
|
||||
);
|
||||
const unassigned = open.filter((i) => !i.assignee).length;
|
||||
if (unassigned)
|
||||
suggestions.push({
|
||||
kind: 'UNASSIGNED',
|
||||
projectKey: project.key,
|
||||
count: unassigned,
|
||||
});
|
||||
const qa = open.filter(
|
||||
(i) => i.statusCategory === StatusCategory.QA,
|
||||
).length;
|
||||
if (qa)
|
||||
suggestions.push({
|
||||
kind: 'QA_WAITING',
|
||||
projectKey: project.key,
|
||||
count: qa,
|
||||
});
|
||||
if (role === ProjectRole.OWNER || role === ProjectRole.ADMIN) {
|
||||
const proposals = open.filter(
|
||||
(i) =>
|
||||
i.statusCategory === StatusCategory.TODO &&
|
||||
i.labels.includes(PROPOSAL_LABEL),
|
||||
).length;
|
||||
if (proposals)
|
||||
suggestions.push({
|
||||
kind: 'PROPOSALS',
|
||||
projectKey: project.key,
|
||||
count: proposals,
|
||||
});
|
||||
}
|
||||
}
|
||||
suggestions.sort((a, b) => b.count - a.count);
|
||||
|
||||
return {
|
||||
agenda,
|
||||
activity: input.activity,
|
||||
team: { perDay: teamPerDay, thisWeek: sum(7, 14), lastWeek: sum(0, 7) },
|
||||
me: { thisWeek: mine(0, 7), lastWeek: mine(7, 14), streak },
|
||||
sprints,
|
||||
projects,
|
||||
suggestions: suggestions.slice(0, 5),
|
||||
firstSteps: {
|
||||
project: input.projects.length > 0,
|
||||
invited: input.projects.some(
|
||||
(p) =>
|
||||
p.project.members.length > 1 &&
|
||||
p.project.members.some(
|
||||
(m) =>
|
||||
m.user.id === me &&
|
||||
(m.role === ProjectRole.OWNER || m.role === ProjectRole.ADMIN),
|
||||
),
|
||||
),
|
||||
ticket: input.projects.some((p) =>
|
||||
p.issues.some((i) => i.reporter?.id === me),
|
||||
),
|
||||
calendar: input.hasCalendar,
|
||||
},
|
||||
online: input.online,
|
||||
};
|
||||
}
|
||||
|
||||
/** The last 14 UTC days (oldest first), as `YYYY-MM-DD`. */
|
||||
export function lastDays(now: Date, count = 14): string[] {
|
||||
const today = utcDay(now.getTime());
|
||||
return Array.from({ length: count }, (_, n) =>
|
||||
dayString(today - (count - 1 - n) * DAY),
|
||||
);
|
||||
}
|
||||
395
libs/api/tracker/application/src/lib/analytics/insights.spec.ts
Normal file
395
libs/api/tracker/application/src/lib/analytics/insights.spec.ts
Normal file
@@ -0,0 +1,395 @@
|
||||
import {
|
||||
CustomFieldType,
|
||||
defaultProjectSettings,
|
||||
IssuePriority,
|
||||
ReleaseReadModel,
|
||||
ReleaseStatus,
|
||||
IssueLinkType,
|
||||
IssueReadModel,
|
||||
IssueTimelineEntry,
|
||||
SprintReadModel,
|
||||
SprintStatus,
|
||||
StatusCategory as C,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import {
|
||||
computeInsights,
|
||||
InsightsInput,
|
||||
simulateDays,
|
||||
STALE_DAYS,
|
||||
STUCK_DAYS,
|
||||
} from './insights';
|
||||
|
||||
const NOW = new Date('2026-10-15T12:00:00Z');
|
||||
const bob = { id: 'bob', name: 'Bob' };
|
||||
|
||||
const issue = (
|
||||
id: string,
|
||||
status: string,
|
||||
category: C,
|
||||
extra: Partial<IssueReadModel> = {},
|
||||
): IssueReadModel =>
|
||||
({
|
||||
id,
|
||||
key: id,
|
||||
title: `Ticket ${id}`,
|
||||
status,
|
||||
statusCategory: category,
|
||||
assignee: bob,
|
||||
reporter: bob,
|
||||
sprint: null,
|
||||
storyPoints: null,
|
||||
dueDate: null,
|
||||
links: [],
|
||||
trashedAt: null,
|
||||
createdAt: '2026-09-01T09:00:00Z',
|
||||
updatedAt: '2026-10-14T09:00:00Z',
|
||||
...extra,
|
||||
}) as IssueReadModel;
|
||||
|
||||
let seq = 0;
|
||||
const step = (
|
||||
issueId: string,
|
||||
at: string,
|
||||
status: string,
|
||||
category: C,
|
||||
): IssueTimelineEntry => ({
|
||||
id: `${issueId}:${++seq}`,
|
||||
issueId,
|
||||
projectId: 'p',
|
||||
at,
|
||||
kind: 'status',
|
||||
status,
|
||||
category,
|
||||
assigneeId: 'bob',
|
||||
});
|
||||
|
||||
const sprint: SprintReadModel = {
|
||||
id: 's',
|
||||
projectId: 'p',
|
||||
name: 'Sprint 7',
|
||||
goal: null,
|
||||
startDate: '2026-10-12',
|
||||
endDate: '2026-10-25',
|
||||
status: SprintStatus.ACTIVE,
|
||||
startedAt: '2026-10-12T08:00:00Z',
|
||||
closedAt: null,
|
||||
createdAt: '2026-10-01T08:00:00Z',
|
||||
version: 2,
|
||||
};
|
||||
const inSprint = { sprint: { id: 's', name: 'Sprint 7' } };
|
||||
|
||||
const BASE: Omit<InsightsInput, 'issues' | 'timeline'> = {
|
||||
settings: defaultProjectSettings(),
|
||||
activeSprint: null,
|
||||
sprints: [],
|
||||
releases: [],
|
||||
members: [],
|
||||
since: null,
|
||||
mentions: 0,
|
||||
now: NOW,
|
||||
};
|
||||
const release = (id: string, targetDate: string | null): ReleaseReadModel =>
|
||||
({
|
||||
id,
|
||||
name: id,
|
||||
targetDate,
|
||||
status: ReleaseStatus.PLANNED,
|
||||
}) as ReleaseReadModel;
|
||||
|
||||
describe('computeInsights', () => {
|
||||
it('names what is late, due soon, blocked and stuck', () => {
|
||||
const issues = [
|
||||
issue('LATE', 'TODO', C.TODO, { dueDate: '2026-10-10' }),
|
||||
issue('SOON', 'TODO', C.TODO, { dueDate: '2026-10-20' }),
|
||||
issue('LATER', 'TODO', C.TODO, { dueDate: '2026-11-30' }),
|
||||
issue('DONE_LATE', 'DONE', C.DONE, { dueDate: '2026-10-01' }),
|
||||
issue('WAITS', 'TODO', C.TODO, {
|
||||
links: [
|
||||
{
|
||||
type: IssueLinkType.BLOCKS,
|
||||
direction: 'INWARD',
|
||||
issueId: 'SLOW',
|
||||
issueKey: 'SLOW',
|
||||
},
|
||||
],
|
||||
}),
|
||||
issue('SLOW', 'IN_PROGRESS', C.IN_PROGRESS),
|
||||
issue('FRESH', 'IN_QA', C.QA),
|
||||
];
|
||||
const timeline = [
|
||||
step('SLOW', '2026-10-01T10:00:00Z', 'IN_PROGRESS', C.IN_PROGRESS),
|
||||
step('FRESH', '2026-10-14T10:00:00Z', 'IN_QA', C.QA),
|
||||
];
|
||||
const { risks, aging } = computeInsights({
|
||||
issues,
|
||||
timeline,
|
||||
...BASE,
|
||||
settings: defaultProjectSettings(),
|
||||
});
|
||||
expect(risks.overdue.map((i) => [i.key, i.days])).toEqual([['LATE', 5]]);
|
||||
expect(risks.dueSoon.map((i) => [i.key, i.days])).toEqual([['SOON', 5]]);
|
||||
expect(risks.blocked.map((i) => i.key)).toEqual(['WAITS']);
|
||||
expect(risks.stuck.map((i) => [i.key, i.days])).toEqual([['SLOW', 14]]);
|
||||
expect(risks.stuckDays).toBe(STUCK_DAYS);
|
||||
// Everything under way, the longest first.
|
||||
expect(aging.issues.map((i) => [i.key, i.days])).toEqual([
|
||||
['SLOW', 14],
|
||||
['FRESH', 1],
|
||||
]);
|
||||
});
|
||||
|
||||
it('measures the sprint under way against the calendar', () => {
|
||||
const issues = [
|
||||
issue('A', 'DONE', C.DONE, { ...inSprint, storyPoints: 3 }),
|
||||
issue('B', 'IN_PROGRESS', C.IN_PROGRESS, { ...inSprint, storyPoints: 8 }),
|
||||
issue('C', 'TODO', C.TODO, { ...inSprint, storyPoints: 5 }),
|
||||
issue('X', 'CANCELLED', C.CANCELLED, { ...inSprint, storyPoints: 13 }),
|
||||
];
|
||||
const { sprint: pulse } = computeInsights({
|
||||
issues,
|
||||
timeline: [],
|
||||
...BASE,
|
||||
settings: defaultProjectSettings(),
|
||||
activeSprint: sprint,
|
||||
});
|
||||
expect(pulse).toMatchObject({
|
||||
totalDays: 14,
|
||||
daysLeft: 10,
|
||||
tickets: 3,
|
||||
doneTickets: 1,
|
||||
points: 16,
|
||||
donePoints: 3,
|
||||
// 3 points in 3 days; 13 left for 11 days.
|
||||
pacePerDay: 1,
|
||||
neededPerDay: 1.2,
|
||||
behind: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('finds where tickets wait, and columns over their limit', () => {
|
||||
const settings = defaultProjectSettings();
|
||||
settings.statuses.find((s) => s.id === 'IN_REVIEW')!.wipLimit = 1;
|
||||
const issues = [
|
||||
issue('A', 'DONE', C.DONE),
|
||||
issue('B', 'IN_REVIEW', C.IN_PROGRESS),
|
||||
issue('C', 'IN_REVIEW', C.IN_PROGRESS),
|
||||
];
|
||||
const timeline = [
|
||||
step('A', '2026-10-01T10:00:00Z', 'IN_PROGRESS', C.IN_PROGRESS),
|
||||
step('A', '2026-10-03T10:00:00Z', 'IN_REVIEW', C.IN_PROGRESS),
|
||||
step('A', '2026-10-07T10:00:00Z', 'DONE', C.DONE),
|
||||
];
|
||||
const rows = computeInsights({
|
||||
issues,
|
||||
timeline,
|
||||
...BASE,
|
||||
settings,
|
||||
}).bottlenecks;
|
||||
expect(rows.map((r) => r.status)).toEqual([
|
||||
'IN_PROGRESS',
|
||||
'IN_REVIEW',
|
||||
'READY_FOR_QA',
|
||||
'IN_QA',
|
||||
'QA_FAILED',
|
||||
]);
|
||||
expect(rows[0]).toMatchObject({ averageDays: 2, samples: 1, open: 0 });
|
||||
expect(rows[1]).toMatchObject({
|
||||
averageDays: 4,
|
||||
open: 2,
|
||||
wipLimit: 1,
|
||||
overLimit: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('forecasts planned releases at the pace of the last weeks', () => {
|
||||
// Two tickets finished a day, every day of the last six weeks.
|
||||
expect(simulateDays(10, [2, 2, 2])).toEqual({ likely: 5, safe: 5 });
|
||||
expect(simulateDays(3, [0, 0])).toBeNull();
|
||||
expect(simulateDays(0, [])).toEqual({ likely: 0, safe: 0 });
|
||||
|
||||
const done = Array.from({ length: 12 }, (_, n) => `D${n}`);
|
||||
const issues = [
|
||||
...done.map((id) => issue(id, 'DONE', C.DONE)),
|
||||
...['A1', 'A2', 'A3'].map((id) =>
|
||||
issue(id, 'TODO', C.TODO, { release: { id: 'a', name: 'a' } }),
|
||||
),
|
||||
...['B1', 'B2', 'B3'].map((id) =>
|
||||
issue(id, 'TODO', C.TODO, { release: { id: 'b', name: 'b' } }),
|
||||
),
|
||||
];
|
||||
// One finished every 3.5 days or so: 12 in the last 42 days.
|
||||
const timeline = done.map((id, n) =>
|
||||
step(
|
||||
id,
|
||||
new Date(NOW.getTime() - (n * 3 + 1) * 86_400_000).toISOString(),
|
||||
'DONE',
|
||||
C.DONE,
|
||||
),
|
||||
);
|
||||
const { releases } = computeInsights({
|
||||
...BASE,
|
||||
issues,
|
||||
timeline,
|
||||
releases: [release('b', '2026-10-20'), release('a', '2026-12-31')],
|
||||
});
|
||||
// Nearer target first; the later one waits for it.
|
||||
expect(releases.map((r) => [r.releaseId, r.verdict])).toEqual([
|
||||
['b', 'LATE'],
|
||||
['a', 'ON_TRACK'],
|
||||
]);
|
||||
expect(releases[1].likelyDate! > releases[0].likelyDate!).toBe(true);
|
||||
});
|
||||
|
||||
it('tells what changed since a moment, and who carries what', () => {
|
||||
const ana = { id: 'ana', name: 'Ana' };
|
||||
const issues = [
|
||||
issue('NEW', 'TODO', C.TODO, {
|
||||
createdAt: '2026-10-15T08:00:00Z',
|
||||
assignee: ana,
|
||||
storyPoints: 8,
|
||||
}),
|
||||
issue('CLOSED', 'DONE', C.DONE, {
|
||||
sprint: { id: 'old', name: 'Old' },
|
||||
storyPoints: 5,
|
||||
}),
|
||||
issue('BACK', 'QA_FAILED', C.IN_PROGRESS, { storyPoints: 2 }),
|
||||
issue('STALE', 'TODO', C.TODO, {
|
||||
assignee: null,
|
||||
area: 'FRONTEND',
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
}),
|
||||
];
|
||||
const timeline = [
|
||||
step('CLOSED', '2026-10-10T09:00:00Z', 'IN_QA', C.QA),
|
||||
step('CLOSED', '2026-10-15T09:00:00Z', 'DONE', C.DONE),
|
||||
step('BACK', '2026-10-10T09:00:00Z', 'IN_QA', C.QA),
|
||||
step('BACK', '2026-10-15T10:00:00Z', 'QA_FAILED', C.IN_PROGRESS),
|
||||
];
|
||||
const reading = computeInsights({
|
||||
...BASE,
|
||||
issues,
|
||||
timeline,
|
||||
since: new Date('2026-10-15T07:00:00Z'),
|
||||
mentions: 2,
|
||||
members: [{ user: ana, role: 'MEMBER' as never }],
|
||||
sprints: [
|
||||
{ id: 'old', closedAt: '2026-10-11T00:00:00Z' } as SprintReadModel,
|
||||
],
|
||||
});
|
||||
expect(reading.changes).toMatchObject({
|
||||
createdCount: 1,
|
||||
finishedCount: 1,
|
||||
returnedCount: 1,
|
||||
mentions: 2,
|
||||
});
|
||||
expect(reading.changes?.returned[0].key).toBe('BACK');
|
||||
// Bob closed 5 points in the one closed sprint and holds 2 open.
|
||||
expect(
|
||||
reading.team.map((t) => [
|
||||
t.user.id,
|
||||
t.openPoints,
|
||||
t.pointsPerSprint,
|
||||
t.overloaded,
|
||||
]),
|
||||
).toEqual([
|
||||
['ana', 8, 0, false],
|
||||
['bob', 2, 5, false],
|
||||
]);
|
||||
expect(reading.backlog).toEqual({
|
||||
open: 3,
|
||||
unestimated: 1,
|
||||
noArea: 2,
|
||||
unassigned: 1,
|
||||
stale: 1,
|
||||
staleDays: STALE_DAYS,
|
||||
});
|
||||
});
|
||||
|
||||
it('weighs quality and splits open tickets by a list field', () => {
|
||||
const settings = defaultProjectSettings();
|
||||
settings.customFields = [
|
||||
{
|
||||
id: 'BROWSER',
|
||||
name: 'Navegador',
|
||||
type: CustomFieldType.SELECT,
|
||||
options: ['Chrome', 'Safari'],
|
||||
archived: false,
|
||||
},
|
||||
];
|
||||
const issues = [
|
||||
issue('B1', 'TODO', C.TODO, {
|
||||
type: 'BUG',
|
||||
priority: IssuePriority.HIGH,
|
||||
createdAt: '2026-10-14T09:00:00Z',
|
||||
customFields: { BROWSER: 'Safari' },
|
||||
}),
|
||||
issue('B2', 'DONE', C.DONE, {
|
||||
type: 'BUG',
|
||||
area: 'FRONTEND',
|
||||
createdAt: '2026-10-02T09:00:00Z',
|
||||
}),
|
||||
issue('T1', 'IN_PROGRESS', C.IN_PROGRESS, {
|
||||
type: 'TASK',
|
||||
area: 'FRONTEND',
|
||||
customFields: { BROWSER: 'Safari' },
|
||||
}),
|
||||
];
|
||||
const timeline = [
|
||||
step('B2', '2026-10-05T09:00:00Z', 'IN_QA', C.QA),
|
||||
step('B2', '2026-10-06T09:00:00Z', 'QA_FAILED', C.IN_PROGRESS),
|
||||
step('B2', '2026-10-07T09:00:00Z', 'IN_QA', C.QA),
|
||||
step('B2', '2026-10-08T09:00:00Z', 'DONE', C.DONE),
|
||||
];
|
||||
const { quality, fields } = computeInsights({
|
||||
...BASE,
|
||||
settings,
|
||||
issues,
|
||||
timeline,
|
||||
releases: [
|
||||
{
|
||||
id: 'r',
|
||||
name: '1.0',
|
||||
status: ReleaseStatus.RELEASED,
|
||||
releasedAt: '2026-10-01T00:00:00Z',
|
||||
} as ReleaseReadModel,
|
||||
],
|
||||
});
|
||||
expect(quality.openBugsByPriority[1]).toEqual({
|
||||
priority: IssuePriority.HIGH,
|
||||
count: 1,
|
||||
});
|
||||
expect(quality.bugWeeks).toHaveLength(8);
|
||||
expect(quality.bugWeeks.at(-1)).toEqual({
|
||||
week: '2026-10-12',
|
||||
created: 1,
|
||||
closed: 0,
|
||||
});
|
||||
expect(quality.bugWeeks.at(-2)).toEqual({
|
||||
week: '2026-10-05',
|
||||
created: 0,
|
||||
closed: 1,
|
||||
});
|
||||
expect(quality.qaByArea).toEqual([
|
||||
{ area: 'FRONTEND', returned: 1, passed: 1 },
|
||||
]);
|
||||
// B2 came 1 day after 1.0; B1 13 days after.
|
||||
expect(quality.escaped).toEqual([
|
||||
{
|
||||
releaseId: 'r',
|
||||
name: '1.0',
|
||||
releasedAt: '2026-10-01T00:00:00Z',
|
||||
bugs: 2,
|
||||
},
|
||||
]);
|
||||
expect(fields).toEqual([
|
||||
{
|
||||
fieldId: 'BROWSER',
|
||||
counts: [
|
||||
{ value: 'Safari', count: 2 },
|
||||
{ value: 'Chrome', count: 0 },
|
||||
],
|
||||
none: 0,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
610
libs/api/tracker/application/src/lib/analytics/insights.ts
Normal file
610
libs/api/tracker/application/src/lib/analytics/insights.ts
Normal file
@@ -0,0 +1,610 @@
|
||||
import {
|
||||
BacklogHealthReadModel,
|
||||
CustomFieldType,
|
||||
FieldBreakdownReadModel,
|
||||
IssuePriority,
|
||||
QualityReadModel,
|
||||
ChangesSinceReadModel,
|
||||
InsightIssueReadModel,
|
||||
IssueLinkType,
|
||||
IssueReadModel,
|
||||
IssueTimelineEntry,
|
||||
ProjectInsightsReadModel,
|
||||
ProjectMemberReadModel,
|
||||
ProjectSettings,
|
||||
ReleaseForecastReadModel,
|
||||
ReleaseReadModel,
|
||||
ReleaseStatus,
|
||||
SprintPulseReadModel,
|
||||
SprintReadModel,
|
||||
StatusCategory,
|
||||
StatusTimeReadModel,
|
||||
TeamLoadReadModel,
|
||||
UserRefReadModel,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import {
|
||||
computeProjectAnalytics,
|
||||
Step,
|
||||
stepsByIssue,
|
||||
weekOf,
|
||||
} from './analytics';
|
||||
|
||||
/*
|
||||
* The dashboard's reading of a project: what needs attention now (risks),
|
||||
* how long the work under way has been where it is (aging) and where it
|
||||
* waits (bottlenecks). Pure functions over the read model and the
|
||||
* timeline, like the statistics.
|
||||
*/
|
||||
|
||||
const DAY = 86_400_000;
|
||||
/** Days without moving that make a ticket under way «stuck». */
|
||||
export const STUCK_DAYS = 7;
|
||||
/** How many tickets each list names. */
|
||||
const LIST = 8;
|
||||
/** Days not updated that make an open ticket «stale». */
|
||||
export const STALE_DAYS = 90;
|
||||
/** Days of finished work the forecasts draw their pace from. */
|
||||
export const PACE_DAYS = 42;
|
||||
const FLOW = [StatusCategory.IN_PROGRESS, StatusCategory.QA];
|
||||
const CLOSED = [StatusCategory.DONE, StatusCategory.CANCELLED];
|
||||
|
||||
export interface InsightsInput {
|
||||
issues: IssueReadModel[];
|
||||
timeline: IssueTimelineEntry[];
|
||||
settings: ProjectSettings;
|
||||
activeSprint: SprintReadModel | null;
|
||||
/** Every sprint of the project (closed ones give each person's pace). */
|
||||
sprints: SprintReadModel[];
|
||||
releases: ReleaseReadModel[];
|
||||
members: ProjectMemberReadModel[];
|
||||
/** The viewer's last visit, to tell what changed since. */
|
||||
since: Date | null;
|
||||
/** Times the viewer was mentioned in the project since then. */
|
||||
mentions: number;
|
||||
now: Date;
|
||||
}
|
||||
|
||||
const utcDay = (time: number) =>
|
||||
Date.UTC(
|
||||
new Date(time).getUTCFullYear(),
|
||||
new Date(time).getUTCMonth(),
|
||||
new Date(time).getUTCDate(),
|
||||
);
|
||||
const dayIndex = (day: string) => Date.parse(`${day.slice(0, 10)}T00:00:00Z`);
|
||||
const round1 = (n: number) => Math.round(n * 10) / 10;
|
||||
|
||||
export function computeInsights(
|
||||
input: InsightsInput,
|
||||
): ProjectInsightsReadModel {
|
||||
const now = input.now.getTime();
|
||||
const today = utcDay(now);
|
||||
const live = input.issues.filter((i) => !i.trashedAt);
|
||||
const open = live.filter((i) => !CLOSED.includes(i.statusCategory));
|
||||
const steps = stepsByIssue(live, input.timeline);
|
||||
|
||||
/** When it got to its current status. */
|
||||
const sinceOf = (issue: IssueReadModel) => {
|
||||
const list = steps.get(issue.id) ?? [];
|
||||
const last = list[list.length - 1];
|
||||
return last && last.status === issue.status
|
||||
? last.at
|
||||
: Date.parse(issue.updatedAt);
|
||||
};
|
||||
const line = (
|
||||
issue: IssueReadModel,
|
||||
days: number,
|
||||
): InsightIssueReadModel => ({
|
||||
id: issue.id,
|
||||
key: issue.key,
|
||||
title: issue.title,
|
||||
status: issue.status,
|
||||
assignee: issue.assignee,
|
||||
days,
|
||||
dueDate: issue.dueDate,
|
||||
});
|
||||
|
||||
// --- Risks ---
|
||||
const due = open
|
||||
.filter((i) => i.dueDate)
|
||||
.map((i) => ({
|
||||
issue: i,
|
||||
days: (dayIndex(i.dueDate as string) - today) / DAY,
|
||||
}));
|
||||
const overdue = due
|
||||
.filter((d) => d.days < 0)
|
||||
.sort((a, b) => a.days - b.days)
|
||||
.map((d) => line(d.issue, -d.days));
|
||||
const dueSoon = due
|
||||
.filter((d) => d.days >= 0 && d.days <= 7)
|
||||
.sort((a, b) => a.days - b.days)
|
||||
.map((d) => line(d.issue, d.days));
|
||||
const openIds = new Set(open.map((i) => i.id));
|
||||
const blocked = open
|
||||
.filter((i) =>
|
||||
i.links.some(
|
||||
(l) =>
|
||||
openIds.has(l.issueId) &&
|
||||
((l.type === IssueLinkType.BLOCKS && l.direction === 'INWARD') ||
|
||||
(l.type === IssueLinkType.DEPENDS_ON && l.direction === 'OUTWARD')),
|
||||
),
|
||||
)
|
||||
.map((i) => line(i, Math.floor((now - sinceOf(i)) / DAY)))
|
||||
.sort((a, b) => b.days - a.days);
|
||||
const underWay = open
|
||||
.filter((i) => FLOW.includes(i.statusCategory))
|
||||
.map((i) => line(i, Math.floor((now - sinceOf(i)) / DAY)))
|
||||
.sort((a, b) => b.days - a.days);
|
||||
const stuck = underWay.filter((i) => i.days >= STUCK_DAYS);
|
||||
|
||||
// --- Aging: against the cycle time of the last 90 days ---
|
||||
const analytics = computeProjectAnalytics({
|
||||
issues: live,
|
||||
timeline: input.timeline,
|
||||
statusOrder: input.settings.statuses.map((s) => s.id),
|
||||
from: new Date(now - 89 * DAY),
|
||||
to: input.now,
|
||||
});
|
||||
|
||||
return {
|
||||
generatedAt: input.now.toISOString(),
|
||||
sprint: input.activeSprint
|
||||
? sprintPulse(input.activeSprint, live, now)
|
||||
: null,
|
||||
risks: {
|
||||
overdue: overdue.slice(0, LIST),
|
||||
overdueCount: overdue.length,
|
||||
dueSoon: dueSoon.slice(0, LIST),
|
||||
dueSoonCount: dueSoon.length,
|
||||
blocked: blocked.slice(0, LIST),
|
||||
blockedCount: blocked.length,
|
||||
stuck: stuck.slice(0, LIST),
|
||||
stuckCount: stuck.length,
|
||||
stuckDays: STUCK_DAYS,
|
||||
},
|
||||
aging: {
|
||||
issues: underWay.slice(0, 15),
|
||||
thresholdDays: analytics.cycleTime.p85Days,
|
||||
},
|
||||
bottlenecks: bottlenecks(input.settings, open, steps, now),
|
||||
releases: forecasts(input.releases, live, steps, now),
|
||||
changes: input.since
|
||||
? changesSince(input.since.getTime(), live, steps, line, input.mentions)
|
||||
: null,
|
||||
team: teamLoad(
|
||||
input.members,
|
||||
open,
|
||||
live,
|
||||
input.sprints,
|
||||
new Set(blocked.map((b) => b.id)),
|
||||
),
|
||||
backlog: backlogHealth(open, now),
|
||||
quality: quality(input.settings, live, steps, input.releases, now),
|
||||
fields: fieldBreakdown(input.settings, open),
|
||||
};
|
||||
}
|
||||
|
||||
const PRIORITIES = [
|
||||
IssuePriority.CRITICAL,
|
||||
IssuePriority.HIGH,
|
||||
IssuePriority.MEDIUM,
|
||||
IssuePriority.LOW,
|
||||
IssuePriority.LOWEST,
|
||||
];
|
||||
|
||||
/** Bugs are the tickets whose type is drawn with the bug icon. */
|
||||
function quality(
|
||||
settings: ProjectSettings,
|
||||
issues: IssueReadModel[],
|
||||
steps: Map<string, Step[]>,
|
||||
releases: ReleaseReadModel[],
|
||||
now: number,
|
||||
): QualityReadModel {
|
||||
const bugTypes = new Set(
|
||||
settings.issueTypes.filter((t) => t.icon === 'bug').map((t) => t.id),
|
||||
);
|
||||
const bugs = issues.filter((i) => bugTypes.has(i.type));
|
||||
const openBugs = bugs.filter((i) => !CLOSED.includes(i.statusCategory));
|
||||
|
||||
// Created and closed per week, the last 8.
|
||||
const thisWeek = Date.parse(weekOf(now));
|
||||
const weeks = Array.from({ length: 8 }, (_, n) =>
|
||||
new Date(thisWeek - (7 - n) * 7 * DAY).toISOString().slice(0, 10),
|
||||
);
|
||||
const tally = new Map(weeks.map((w) => [w, { created: 0, closed: 0 }]));
|
||||
for (const bug of bugs) {
|
||||
const created = tally.get(weekOf(Date.parse(bug.createdAt)));
|
||||
if (created) created.created++;
|
||||
const list = steps.get(bug.id) ?? [];
|
||||
list.forEach((step, i) => {
|
||||
if (i === 0 || !CLOSED.includes(step.category)) return;
|
||||
if (CLOSED.includes(list[i - 1].category)) return;
|
||||
const week = tally.get(weekOf(step.at));
|
||||
if (week) week.closed++;
|
||||
});
|
||||
}
|
||||
|
||||
// QA's verdicts per area, the last 90 days.
|
||||
const from = now - 90 * DAY;
|
||||
const byArea = new Map<string | null, { returned: number; passed: number }>();
|
||||
for (const issue of issues) {
|
||||
const list = steps.get(issue.id) ?? [];
|
||||
list.forEach((step, i) => {
|
||||
const before = list[i - 1];
|
||||
if (!before || step.at < from) return;
|
||||
if (before.category !== StatusCategory.QA) return;
|
||||
if (step.category === StatusCategory.QA) return;
|
||||
if (step.category === StatusCategory.CANCELLED) return;
|
||||
const row = byArea.get(issue.area) ?? { returned: 0, passed: 0 };
|
||||
if (step.category === StatusCategory.DONE) row.passed++;
|
||||
else row.returned++;
|
||||
byArea.set(issue.area, row);
|
||||
});
|
||||
}
|
||||
|
||||
// Bugs reported in the 14 days after each of the last shipped releases.
|
||||
const escaped = releases
|
||||
.filter((r) => r.status === ReleaseStatus.RELEASED && r.releasedAt)
|
||||
.sort((a, b) =>
|
||||
(b.releasedAt as string).localeCompare(a.releasedAt as string),
|
||||
)
|
||||
.slice(0, 3)
|
||||
.map((r) => {
|
||||
const shipped = Date.parse(r.releasedAt as string);
|
||||
return {
|
||||
releaseId: r.id,
|
||||
name: r.name,
|
||||
releasedAt: r.releasedAt as string,
|
||||
bugs: bugs.filter((b) => {
|
||||
const at = Date.parse(b.createdAt);
|
||||
return at > shipped && at <= shipped + 14 * DAY;
|
||||
}).length,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
openBugsByPriority: PRIORITIES.map((priority) => ({
|
||||
priority,
|
||||
count: openBugs.filter((b) => b.priority === priority).length,
|
||||
})),
|
||||
bugWeeks: weeks.map((week) => ({ week, ...tally.get(week)! })),
|
||||
qaByArea: [...byArea.entries()]
|
||||
.map(([area, row]) => ({ area, ...row }))
|
||||
.sort((a, b) => b.returned - a.returned || b.passed - a.passed),
|
||||
escaped,
|
||||
};
|
||||
}
|
||||
|
||||
/** Open tickets per option of each active list field. */
|
||||
function fieldBreakdown(
|
||||
settings: ProjectSettings,
|
||||
open: IssueReadModel[],
|
||||
): FieldBreakdownReadModel[] {
|
||||
return (settings.customFields ?? [])
|
||||
.filter((f) => !f.archived && f.type === CustomFieldType.SELECT)
|
||||
.map((field) => {
|
||||
const values = open.map((i) => i.customFields?.[field.id] ?? null);
|
||||
return {
|
||||
fieldId: field.id,
|
||||
counts: field.options
|
||||
.map((value) => ({
|
||||
value,
|
||||
count: values.filter((v) => v === value).length,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count),
|
||||
none: values.filter((v) => v === null).length,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Tickets that reached a DONE status per day, over the last PACE_DAYS days. */
|
||||
function dailyThroughput(steps: Map<string, Step[]>, now: number): number[] {
|
||||
const today = utcDay(now);
|
||||
const counts = new Array<number>(PACE_DAYS).fill(0);
|
||||
for (const list of steps.values())
|
||||
list.forEach((step, i) => {
|
||||
const before = list[i - 1];
|
||||
if (step.category !== StatusCategory.DONE) return;
|
||||
if (before && before.category === StatusCategory.DONE) return;
|
||||
const ago = Math.floor((today - utcDay(step.at)) / DAY);
|
||||
if (ago >= 0 && ago < PACE_DAYS) counts[ago]++;
|
||||
});
|
||||
return counts;
|
||||
}
|
||||
|
||||
/** Small seeded generator: the same data always gives the same forecast. */
|
||||
function random(seed: number): () => number {
|
||||
let a = seed >>> 0;
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0;
|
||||
let t = a;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Days to finish `remaining` tickets, drawing each day's throughput from
|
||||
* the days measured (Monte Carlo): the 50th and 85th percentiles.
|
||||
*/
|
||||
export function simulateDays(
|
||||
remaining: number,
|
||||
samples: number[],
|
||||
runs = 500,
|
||||
): { likely: number; safe: number } | null {
|
||||
if (remaining <= 0) return { likely: 0, safe: 0 };
|
||||
if (!samples.some((n) => n > 0)) return null;
|
||||
const next = random(remaining * 7919 + samples.length);
|
||||
const results: number[] = [];
|
||||
for (let run = 0; run < runs; run++) {
|
||||
let done = 0;
|
||||
let days = 0;
|
||||
while (done < remaining && days < 3 * 365) {
|
||||
done += samples[Math.floor(next() * samples.length)];
|
||||
days++;
|
||||
}
|
||||
results.push(days);
|
||||
}
|
||||
results.sort((a, b) => a - b);
|
||||
return {
|
||||
likely: results[Math.floor(0.5 * runs)],
|
||||
safe: results[Math.ceil(0.85 * runs) - 1],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Planned releases in target-date order. The team finishes them one after
|
||||
* another, so each one's forecast counts what is left in the earlier ones.
|
||||
*/
|
||||
function forecasts(
|
||||
releases: ReleaseReadModel[],
|
||||
issues: IssueReadModel[],
|
||||
steps: Map<string, Step[]>,
|
||||
now: number,
|
||||
): ReleaseForecastReadModel[] {
|
||||
const pace = dailyThroughput(steps, now);
|
||||
const today = utcDay(now);
|
||||
const dayAfter = (days: number) =>
|
||||
new Date(today + days * DAY).toISOString().slice(0, 10);
|
||||
let ahead = 0;
|
||||
return releases
|
||||
.filter((r) => r.status === ReleaseStatus.PLANNED)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(a.targetDate ?? '9999').localeCompare(b.targetDate ?? '9999') ||
|
||||
a.name.localeCompare(b.name),
|
||||
)
|
||||
.map((release) => {
|
||||
const own = issues.filter(
|
||||
(i) =>
|
||||
i.release?.id === release.id &&
|
||||
i.statusCategory !== StatusCategory.CANCELLED,
|
||||
);
|
||||
const finished = own.filter(
|
||||
(i) => i.statusCategory === StatusCategory.DONE,
|
||||
).length;
|
||||
const left = own.length - finished;
|
||||
ahead += left;
|
||||
const days = left > 0 ? simulateDays(ahead, pace) : null;
|
||||
const likelyDate = days ? dayAfter(days.likely) : null;
|
||||
const safeDate = days ? dayAfter(days.safe) : null;
|
||||
const verdict: ReleaseForecastReadModel['verdict'] =
|
||||
left === 0
|
||||
? 'DONE'
|
||||
: !days
|
||||
? 'UNKNOWN'
|
||||
: !release.targetDate
|
||||
? 'NO_DATE'
|
||||
: (safeDate as string) <= release.targetDate
|
||||
? 'ON_TRACK'
|
||||
: (likelyDate as string) <= release.targetDate
|
||||
? 'AT_RISK'
|
||||
: 'LATE';
|
||||
return {
|
||||
releaseId: release.id,
|
||||
name: release.name,
|
||||
targetDate: release.targetDate,
|
||||
total: own.length,
|
||||
finished,
|
||||
likelyDate,
|
||||
safeDate,
|
||||
verdict,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Created, finished and sent back from QA after `since`. */
|
||||
function changesSince(
|
||||
since: number,
|
||||
issues: IssueReadModel[],
|
||||
steps: Map<string, Step[]>,
|
||||
line: (issue: IssueReadModel, days: number) => InsightIssueReadModel,
|
||||
mentions: number,
|
||||
): ChangesSinceReadModel {
|
||||
const created = issues
|
||||
.filter((i) => Date.parse(i.createdAt) > since)
|
||||
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
const finished: IssueReadModel[] = [];
|
||||
const returned: IssueReadModel[] = [];
|
||||
for (const issue of issues) {
|
||||
const list = steps.get(issue.id) ?? [];
|
||||
const recent = list.filter((s, i) => s.at > since && i > 0);
|
||||
if (
|
||||
issue.statusCategory === StatusCategory.DONE &&
|
||||
recent.some((s) => s.category === StatusCategory.DONE)
|
||||
)
|
||||
finished.push(issue);
|
||||
if (
|
||||
list.some(
|
||||
(s, i) =>
|
||||
s.at > since &&
|
||||
i > 0 &&
|
||||
list[i - 1].category === StatusCategory.QA &&
|
||||
!CLOSED.includes(s.category) &&
|
||||
s.category !== StatusCategory.QA,
|
||||
)
|
||||
)
|
||||
returned.push(issue);
|
||||
}
|
||||
return {
|
||||
since: new Date(since).toISOString(),
|
||||
created: created.slice(0, LIST).map((i) => line(i, 0)),
|
||||
createdCount: created.length,
|
||||
finished: finished.slice(0, LIST).map((i) => line(i, 0)),
|
||||
finishedCount: finished.length,
|
||||
returned: returned.slice(0, LIST).map((i) => line(i, 0)),
|
||||
returnedCount: returned.length,
|
||||
mentions,
|
||||
};
|
||||
}
|
||||
|
||||
const pointsOf = (list: IssueReadModel[]) =>
|
||||
list.reduce((sum, i) => sum + (i.storyPoints ?? 0), 0);
|
||||
|
||||
/** Open work per person against the points they closed per sprint lately. */
|
||||
function teamLoad(
|
||||
members: ProjectMemberReadModel[],
|
||||
open: IssueReadModel[],
|
||||
issues: IssueReadModel[],
|
||||
sprints: SprintReadModel[],
|
||||
blockedIds: Set<string>,
|
||||
): TeamLoadReadModel[] {
|
||||
const people = new Map<string, UserRefReadModel>();
|
||||
for (const m of members) people.set(m.user.id, m.user);
|
||||
for (const i of open) if (i.assignee) people.set(i.assignee.id, i.assignee);
|
||||
const lastClosed = sprints
|
||||
.filter((s) => s.closedAt)
|
||||
.sort((a, b) => (b.closedAt as string).localeCompare(a.closedAt as string))
|
||||
.slice(0, 3)
|
||||
.map((s) => s.id);
|
||||
return [...people.values()]
|
||||
.map((user) => {
|
||||
const mine = open.filter((i) => i.assignee?.id === user.id);
|
||||
const closed = issues.filter(
|
||||
(i) =>
|
||||
i.assignee?.id === user.id &&
|
||||
i.statusCategory === StatusCategory.DONE &&
|
||||
!!i.sprint &&
|
||||
lastClosed.includes(i.sprint.id),
|
||||
);
|
||||
const perSprint = lastClosed.length
|
||||
? round1(pointsOf(closed) / lastClosed.length)
|
||||
: null;
|
||||
const openPoints = pointsOf(mine);
|
||||
return {
|
||||
user,
|
||||
openTickets: mine.length,
|
||||
openPoints,
|
||||
blocked: mine.filter((i) => blockedIds.has(i.id)).length,
|
||||
pointsPerSprint: perSprint,
|
||||
overloaded: !!perSprint && openPoints > perSprint,
|
||||
};
|
||||
})
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.openPoints - a.openPoints ||
|
||||
b.openTickets - a.openTickets ||
|
||||
a.user.name.localeCompare(b.user.name, 'es'),
|
||||
);
|
||||
}
|
||||
|
||||
function backlogHealth(
|
||||
open: IssueReadModel[],
|
||||
now: number,
|
||||
): BacklogHealthReadModel {
|
||||
const staleBefore = now - STALE_DAYS * DAY;
|
||||
return {
|
||||
open: open.length,
|
||||
unestimated: open.filter((i) => i.storyPoints === null).length,
|
||||
noArea: open.filter((i) => !i.area).length,
|
||||
unassigned: open.filter((i) => !i.assignee).length,
|
||||
stale: open.filter((i) => Date.parse(i.updatedAt) < staleBefore).length,
|
||||
staleDays: STALE_DAYS,
|
||||
};
|
||||
}
|
||||
|
||||
export function sprintPulse(
|
||||
sprint: SprintReadModel,
|
||||
issues: IssueReadModel[],
|
||||
now: number,
|
||||
): SprintPulseReadModel {
|
||||
const inSprint = issues.filter(
|
||||
(i) =>
|
||||
i.sprint?.id === sprint.id &&
|
||||
i.statusCategory !== StatusCategory.CANCELLED,
|
||||
);
|
||||
const done = inSprint.filter((i) => i.statusCategory === StatusCategory.DONE);
|
||||
const points = (list: IssueReadModel[]) =>
|
||||
list.reduce((sum, i) => sum + (i.storyPoints ?? 0), 0);
|
||||
const start = dayIndex(sprint.startDate);
|
||||
const end = dayIndex(sprint.endDate);
|
||||
const today = utcDay(now);
|
||||
const totalDays = Math.round((end - start) / DAY) + 1;
|
||||
const daysLeft = Math.round((end - today) / DAY);
|
||||
// Days already worked: today counts once it is half gone.
|
||||
const elapsed = Math.min(
|
||||
totalDays,
|
||||
Math.max(0, Math.round((today - start) / DAY)),
|
||||
);
|
||||
const total = points(inSprint);
|
||||
const closed = points(done);
|
||||
const remaining = total - closed;
|
||||
const pace = elapsed > 0 ? round1(closed / elapsed) : null;
|
||||
const left = Math.max(daysLeft + 1, 0);
|
||||
const needed =
|
||||
remaining > 0 ? (left > 0 ? round1(remaining / left) : null) : null;
|
||||
return {
|
||||
sprintId: sprint.id,
|
||||
name: sprint.name,
|
||||
startDate: sprint.startDate,
|
||||
endDate: sprint.endDate,
|
||||
totalDays,
|
||||
daysLeft,
|
||||
tickets: inSprint.length,
|
||||
doneTickets: done.length,
|
||||
points: total,
|
||||
donePoints: closed,
|
||||
pacePerDay: pace,
|
||||
neededPerDay: needed,
|
||||
behind:
|
||||
remaining > 0 &&
|
||||
(left === 0 || (pace !== null && pace * left < remaining)),
|
||||
};
|
||||
}
|
||||
|
||||
/** Average stay in each in-progress and QA status (stays that ended lately). */
|
||||
function bottlenecks(
|
||||
settings: ProjectSettings,
|
||||
open: IssueReadModel[],
|
||||
steps: Map<string, Step[]>,
|
||||
now: number,
|
||||
): StatusTimeReadModel[] {
|
||||
const from = now - 90 * DAY;
|
||||
const stays = new Map<string, number[]>();
|
||||
for (const list of steps.values())
|
||||
list.forEach((step, i) => {
|
||||
const next = list[i + 1];
|
||||
if (!next || next.at < from) return;
|
||||
stays.set(step.status, [
|
||||
...(stays.get(step.status) ?? []),
|
||||
next.at - step.at,
|
||||
]);
|
||||
});
|
||||
return settings.statuses
|
||||
.filter((s) => !s.archived && FLOW.includes(s.category))
|
||||
.map((s) => {
|
||||
const measured = stays.get(s.id) ?? [];
|
||||
const count = open.filter((i) => i.status === s.id).length;
|
||||
const limit = s.wipLimit ?? null;
|
||||
return {
|
||||
status: s.id,
|
||||
averageDays: measured.length
|
||||
? round1(measured.reduce((a, b) => a + b, 0) / measured.length / DAY)
|
||||
: null,
|
||||
samples: measured.length,
|
||||
open: count,
|
||||
wipLimit: limit,
|
||||
overLimit: limit !== null && count > limit,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CommandRunner } from '@task-stream/api/shared/application';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import { QueryBus } from '@nestjs/cqrs';
|
||||
import {
|
||||
AutomationInput,
|
||||
CreateAutomationCommand,
|
||||
@@ -14,7 +15,7 @@ import { Automation } from './ports/automations.repository';
|
||||
@Injectable()
|
||||
export class AutomationsService {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly commands: CommandRunner,
|
||||
private readonly queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
@@ -27,7 +28,7 @@ export class AutomationsService {
|
||||
projectId: string,
|
||||
rule: AutomationInput,
|
||||
): Promise<Automation> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new CreateAutomationCommand(actorId, projectId, rule),
|
||||
);
|
||||
}
|
||||
@@ -37,20 +38,18 @@ export class AutomationsService {
|
||||
automationId: string,
|
||||
changes: { name?: string; active?: boolean },
|
||||
): Promise<Automation> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new UpdateAutomationCommand(actorId, automationId, changes),
|
||||
);
|
||||
}
|
||||
|
||||
/** Runs a rule on a ticket; the error, if any, as text. */
|
||||
run(automation: Automation, issueId: string): Promise<string | null> {
|
||||
return this.commandBus.execute(
|
||||
new RunAutomationCommand(automation, issueId),
|
||||
);
|
||||
return this.commands.execute(new RunAutomationCommand(automation, issueId));
|
||||
}
|
||||
|
||||
delete(actorId: string, automationId: string): Promise<void> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new DeleteAutomationCommand(actorId, automationId),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -96,9 +96,12 @@ async function managedProject(
|
||||
access: WriteAccess,
|
||||
projectId: string,
|
||||
actorId: string,
|
||||
/** Only to read the rules: an archived project still shows them. */
|
||||
reading = false,
|
||||
): Promise<Project> {
|
||||
const project = await access.project(projectId);
|
||||
project.assertCanManage(actorId);
|
||||
if (reading) project.assertManages(actorId);
|
||||
else project.assertCanManage(actorId);
|
||||
return project;
|
||||
}
|
||||
|
||||
@@ -231,6 +234,7 @@ export class RunAutomationCommandHandler
|
||||
? await this.access.live(issue.projectId, Project)
|
||||
: null;
|
||||
if (!issue || !project) return 'The ticket is gone';
|
||||
if (project.archivedAt) return 'The project is archived';
|
||||
const { kind, value } = command.automation.action;
|
||||
const actor = SYSTEM_ACTOR_ID;
|
||||
switch (kind) {
|
||||
@@ -297,7 +301,7 @@ export class GetAutomationsQueryHandler
|
||||
) {}
|
||||
|
||||
async execute(query: GetAutomationsQuery): Promise<Automation[]> {
|
||||
await managedProject(this.access, query.projectId, query.viewerId);
|
||||
await managedProject(this.access, query.projectId, query.viewerId, true);
|
||||
return this.automations.findByProject(query.projectId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
IssueReadModel,
|
||||
StatusCategory,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { ReadAccess } from './queries/read-access';
|
||||
import { BlockersService } from './blockers.service';
|
||||
import { FindIssuesRepository } from './ports/find-issues.repository';
|
||||
|
||||
@@ -30,12 +31,15 @@ describe('BlockersService', () => {
|
||||
].map((i) => [i.id, i]),
|
||||
);
|
||||
const queries: string[][] = [];
|
||||
const service = new BlockersService({
|
||||
const service = new BlockersService(
|
||||
{
|
||||
findByIds: async (ids: string[]) => {
|
||||
queries.push(ids);
|
||||
return ids.map((id) => store.get(id)).filter(Boolean);
|
||||
},
|
||||
} as unknown as FindIssuesRepository);
|
||||
} as unknown as FindIssuesRepository,
|
||||
{ participantScope: async () => null } as unknown as ReadAccess,
|
||||
);
|
||||
const link = (
|
||||
type: IssueLinkType,
|
||||
direction: 'INWARD' | 'OUTWARD',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { involves, ReadAccess } from './queries/read-access';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
IssueLinkType,
|
||||
@@ -31,19 +32,28 @@ export class BlockersService {
|
||||
result: Promise<Map<string, IssueReadModel>>;
|
||||
} | null = null;
|
||||
|
||||
constructor(private readonly findIssues: FindIssuesRepository) {}
|
||||
constructor(
|
||||
private readonly findIssues: FindIssuesRepository,
|
||||
private readonly access: ReadAccess,
|
||||
) {}
|
||||
|
||||
async blockedBy(issue: IssueReadModel): Promise<Blocker[]> {
|
||||
/** For `viewerId`: a guest only learns of blockers they take part in. */
|
||||
async blockedBy(
|
||||
issue: IssueReadModel,
|
||||
viewerId: string | null = null,
|
||||
): Promise<Blocker[]> {
|
||||
const ids = blockerIdsOf(issue);
|
||||
if (!ids.length) return [];
|
||||
const found = await this.load(ids);
|
||||
const scope = await this.access.participantScope(issue.projectId, viewerId);
|
||||
return ids
|
||||
.map((id) => found.get(id))
|
||||
.filter(
|
||||
(blocker): blocker is IssueReadModel =>
|
||||
!!blocker &&
|
||||
!blocker.trashedAt &&
|
||||
!FINISHED.has(blocker.statusCategory),
|
||||
!FINISHED.has(blocker.statusCategory) &&
|
||||
(!scope || involves(blocker, scope)),
|
||||
)
|
||||
.map(({ id, key, title }) => ({ id, key, title }));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CommandRunner } from '@task-stream/api/shared/application';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import { QueryBus } from '@nestjs/cqrs';
|
||||
import {
|
||||
CreateCalendarFeedCommand,
|
||||
GetCalendarIcsQuery,
|
||||
@@ -12,7 +13,7 @@ import { CalendarFeed } from './ports/calendar-feeds.repository';
|
||||
@Injectable()
|
||||
export class CalendarService {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly commands: CommandRunner,
|
||||
private readonly queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
@@ -22,11 +23,11 @@ export class CalendarService {
|
||||
|
||||
/** A new secret URL; the previous one, if any, stops working. */
|
||||
create(userId: string): Promise<CalendarFeed> {
|
||||
return this.commandBus.execute(new CreateCalendarFeedCommand(userId));
|
||||
return this.commands.execute(new CreateCalendarFeedCommand(userId));
|
||||
}
|
||||
|
||||
revoke(userId: string): Promise<void> {
|
||||
return this.commandBus.execute(new RevokeCalendarFeedCommand(userId));
|
||||
return this.commands.execute(new RevokeCalendarFeedCommand(userId));
|
||||
}
|
||||
|
||||
/** The calendar behind `token`, or null if no feed has it. */
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { guestOnly } from '../queries/read-access';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import {
|
||||
Command,
|
||||
@@ -120,10 +121,14 @@ export class GetCalendarIcsQueryHandler
|
||||
const projects = await this.findProjects.findByMember(feed.userId);
|
||||
const byId = new Map(projects.map((p) => [p.id, p]));
|
||||
const ids = projects.map((p) => p.id);
|
||||
// A guest's calendar has their tickets, not the project's plans.
|
||||
const planned = projects
|
||||
.filter((p) => !guestOnly(p, feed.userId))
|
||||
.map((p) => p.id);
|
||||
const [issues, sprints, releases, me] = await Promise.all([
|
||||
this.findIssues.findDatedFor(feed.userId, ids),
|
||||
Promise.all(ids.map((id) => this.findSprints.findByProject(id))),
|
||||
Promise.all(ids.map((id) => this.findReleases.findByProject(id))),
|
||||
Promise.all(planned.map((id) => this.findSprints.findByProject(id))),
|
||||
Promise.all(planned.map((id) => this.findReleases.findByProject(id))),
|
||||
this.users.findById(feed.userId),
|
||||
]);
|
||||
const link = linkTo(query.webUrl);
|
||||
|
||||
@@ -12,6 +12,17 @@ import {
|
||||
} from './ports/change-feed';
|
||||
import { CanViewProjectQuery } from './queries/can-view-project.query';
|
||||
import { GetIssueQuery } from './queries/get-issue.query';
|
||||
import { GetTicketScopeQuery } from './queries/get-ticket-scope.query';
|
||||
import { involves } from './queries/read-access';
|
||||
|
||||
/** The changes of the tickets `userId` opened, has or follows. */
|
||||
async function* onlyInvolving(
|
||||
changes: AsyncIterableIterator<IssueChange>,
|
||||
userId: string,
|
||||
): AsyncIterableIterator<IssueChange> {
|
||||
for await (const change of changes)
|
||||
if (involves(change.issue, userId)) yield change;
|
||||
}
|
||||
|
||||
/** Realtime subscriptions, authorised at subscribe time. */
|
||||
@Injectable()
|
||||
@@ -26,7 +37,12 @@ export class ChangesService {
|
||||
projectId: string,
|
||||
): Promise<AsyncIterableIterator<IssueChange>> {
|
||||
await this.assertCanView(viewerId, projectId);
|
||||
return this.changeFeed.issues(projectId);
|
||||
const changes = this.changeFeed.issues(projectId);
|
||||
const scope: string | null = await this.queryBus.execute(
|
||||
new GetTicketScopeQuery(viewerId, projectId),
|
||||
);
|
||||
// A guest hears only about the tickets they take part in.
|
||||
return scope ? onlyInvolving(changes, scope) : changes;
|
||||
}
|
||||
|
||||
async watchComments(
|
||||
@@ -41,7 +57,7 @@ export class ChangesService {
|
||||
viewerId: string,
|
||||
projectId: string,
|
||||
): Promise<AsyncIterableIterator<ActivityReadModel>> {
|
||||
await this.assertCanView(viewerId, projectId);
|
||||
await this.assertCanView(viewerId, projectId, true);
|
||||
return this.changeFeed.activity(projectId);
|
||||
}
|
||||
|
||||
@@ -49,7 +65,7 @@ export class ChangesService {
|
||||
viewerId: string,
|
||||
projectId: string,
|
||||
): Promise<AsyncIterableIterator<ReleaseChange>> {
|
||||
await this.assertCanView(viewerId, projectId);
|
||||
await this.assertCanView(viewerId, projectId, true);
|
||||
return this.changeFeed.releases(projectId);
|
||||
}
|
||||
|
||||
@@ -57,7 +73,7 @@ export class ChangesService {
|
||||
viewerId: string,
|
||||
projectId: string,
|
||||
): Promise<AsyncIterableIterator<SprintChange>> {
|
||||
await this.assertCanView(viewerId, projectId);
|
||||
await this.assertCanView(viewerId, projectId, true);
|
||||
return this.changeFeed.sprints(projectId);
|
||||
}
|
||||
|
||||
@@ -77,9 +93,10 @@ export class ChangesService {
|
||||
private async assertCanView(
|
||||
viewerId: string,
|
||||
projectId: string,
|
||||
wholeProject = false,
|
||||
): Promise<void> {
|
||||
const allowed = await this.queryBus.execute(
|
||||
new CanViewProjectQuery(viewerId, projectId),
|
||||
new CanViewProjectQuery(viewerId, projectId, wholeProject),
|
||||
);
|
||||
if (!allowed) throw new NotFoundError('Project', projectId);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ export class AddCommentCommandHandler
|
||||
) {}
|
||||
|
||||
async execute(command: AddCommentCommand): Promise<CommandResult> {
|
||||
const { issue } = await this.access.issueForContributor(
|
||||
const { issue } = await this.access.issueForReporter(
|
||||
command.issueId,
|
||||
command.actorId,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { ArchiveProjectCommand } from './archive-project.command';
|
||||
import { CommandResult, ack } from './command-result';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
@CommandHandler(ArchiveProjectCommand)
|
||||
export class ArchiveProjectCommandHandler
|
||||
implements ICommandHandler<ArchiveProjectCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: ArchiveProjectCommand): Promise<CommandResult> {
|
||||
const project = await this.access.project(command.projectId);
|
||||
if (command.archived) project.archive(command.actorId);
|
||||
else project.unarchive(command.actorId);
|
||||
await project.commitAndWait();
|
||||
return ack(project);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
import { CommandResult } from './command-result';
|
||||
|
||||
/** The owner archives the project (read-only) or, with `archived` false, brings it back. */
|
||||
export class ArchiveProjectCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly projectId: string,
|
||||
public readonly archived: boolean,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export class AttachFileCommandHandler
|
||||
) {}
|
||||
|
||||
async execute(command: AttachFileCommand): Promise<AttachmentResult> {
|
||||
const { issue } = await this.access.issueForContributor(
|
||||
const { issue } = await this.access.issueForReporter(
|
||||
command.issueId,
|
||||
command.actorId,
|
||||
);
|
||||
|
||||
@@ -14,11 +14,14 @@ export class ChangeIssueStatusCommandHandler
|
||||
command.issueId,
|
||||
command.actorId,
|
||||
);
|
||||
const role = project.roleOf(command.actorId);
|
||||
issue.changeStatus(
|
||||
command.actorId,
|
||||
command.status,
|
||||
command.reason,
|
||||
project.workflow,
|
||||
// The definition of done holds back members, not who runs the project.
|
||||
role === 'OWNER' || role === 'ADMIN',
|
||||
);
|
||||
await issue.commitAndWait();
|
||||
return ack(issue);
|
||||
|
||||
@@ -21,6 +21,7 @@ import { UpdateReleaseCommandHandler } from './update-release.command-handler';
|
||||
import { PublishReleaseCommandHandler } from './publish-release.command-handler';
|
||||
import { ArchiveReleaseCommandHandler } from './archive-release.command-handler';
|
||||
import { DeleteProjectCommandHandler } from './delete-project.command-handler';
|
||||
import { ArchiveProjectCommandHandler } from './archive-project.command-handler';
|
||||
import { DeleteIssueCommandHandler } from './delete-issue.command-handler';
|
||||
import { DeleteReleaseCommandHandler } from './delete-release.command-handler';
|
||||
import { PurgeCommentCommandHandler } from './purge-comment.command-handler';
|
||||
@@ -66,6 +67,7 @@ export const COMMAND_HANDLERS = [
|
||||
PublishReleaseCommandHandler,
|
||||
ArchiveReleaseCommandHandler,
|
||||
DeleteProjectCommandHandler,
|
||||
ArchiveProjectCommandHandler,
|
||||
// Emitted by sagas only (cascades and reactions to other aggregates).
|
||||
DeleteIssueCommandHandler,
|
||||
DeleteReleaseCommandHandler,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { CommandBus, CqrsModule, EventBus } from '@nestjs/cqrs';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import {
|
||||
AggregateRehydrator,
|
||||
CommandRunner,
|
||||
EventStore,
|
||||
} from '@task-stream/api/shared/application';
|
||||
import {
|
||||
@@ -151,6 +152,7 @@ async function setup() {
|
||||
},
|
||||
{ provide: GitIntegrationsRepository, useValue: git },
|
||||
AggregateRehydrator,
|
||||
CommandRunner,
|
||||
{ provide: EventStore, useValue: store },
|
||||
{ provide: AttachmentStorage, useValue: files },
|
||||
{ provide: UserDirectory, useValue: users },
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { ForbiddenError } from '@task-stream/api/shared/domain';
|
||||
import {
|
||||
IssueFactory,
|
||||
ProjectRole,
|
||||
SYSTEM_ACTOR_ID,
|
||||
assertCanNest,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
@@ -27,8 +29,21 @@ export class CreateIssueCommandHandler
|
||||
const project = await this.access.project(command.projectId);
|
||||
// TaskStream itself (proposals from the public form) is no member; only
|
||||
// internal commands act as it, never a request from outside.
|
||||
if (command.actorId !== SYSTEM_ACTOR_ID)
|
||||
project.assertCanContribute(command.actorId);
|
||||
if (command.actorId !== SYSTEM_ACTOR_ID) {
|
||||
project.assertCanReport(command.actorId);
|
||||
// A guest opens a ticket; planning it is for the team.
|
||||
if (
|
||||
project.roleOf(command.actorId) === ProjectRole.GUEST &&
|
||||
(command.assigneeId ||
|
||||
command.releaseId ||
|
||||
command.parentId ||
|
||||
command.initialStatus ||
|
||||
command.details.team)
|
||||
)
|
||||
throw new ForbiddenError(
|
||||
'Un invitado abre el ticket; asignarlo, planificarlo o colgarlo de otro lo hace el equipo',
|
||||
);
|
||||
}
|
||||
// Before taking a number: an invalid type or area must not burn one.
|
||||
const workflow = project.workflow;
|
||||
workflow.assertActiveType(command.type);
|
||||
|
||||
@@ -18,6 +18,11 @@ export class ReactToCommentCommandHandler
|
||||
throw new ForbiddenError(
|
||||
`You are not a member of project ${project.key}`,
|
||||
);
|
||||
this.access.assertSees(
|
||||
project,
|
||||
await this.access.issue(comment.issueId),
|
||||
command.actorId,
|
||||
);
|
||||
comment.react(command.actorId, command.emoji, command.on);
|
||||
await comment.commitAndWait();
|
||||
return ack(comment);
|
||||
|
||||
@@ -24,6 +24,8 @@ export class VoteIssueCommandHandler
|
||||
throw new ForbiddenError(
|
||||
`You are not a member of project ${project.key}`,
|
||||
);
|
||||
project.assertNotArchived();
|
||||
this.access.assertSees(project, issue, command.actorId);
|
||||
if (issue.trashedAt)
|
||||
throw new BusinessRuleError(`${issue.key} is in the trash`);
|
||||
issue.vote(command.actorId, command.on);
|
||||
|
||||
@@ -25,6 +25,8 @@ export class WatchIssueCommandHandler
|
||||
`You are not a member of project ${project.key}`,
|
||||
);
|
||||
}
|
||||
// Following would show a guest a ticket they are not part of.
|
||||
this.access.assertSees(project, issue, command.userId);
|
||||
if (command.watching) issue.watch(command.actorId, command.userId);
|
||||
else issue.unwatch(command.actorId, command.userId);
|
||||
await issue.commitAndWait();
|
||||
|
||||
@@ -74,6 +74,31 @@ export class WriteAccess {
|
||||
return { issue, project };
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue + its project, for opening a conversation on it (comments, files,
|
||||
* following): members and the guests who take part in the ticket.
|
||||
*/
|
||||
async issueForReporter(
|
||||
issueId: string,
|
||||
actorId: string,
|
||||
): Promise<{ issue: Issue; project: Project }> {
|
||||
const issue = await this.issue(issueId);
|
||||
const project = await this.project(issue.projectId);
|
||||
project.assertCanReport(actorId);
|
||||
this.assertSees(project, issue, actorId);
|
||||
if (issue.trashedAt)
|
||||
throw new BusinessRuleError(
|
||||
`${issue.key} is in the trash: restore it to change it`,
|
||||
);
|
||||
return { issue, project };
|
||||
}
|
||||
|
||||
/** A guest knows no ticket they do not take part in. */
|
||||
assertSees(project: Project, issue: Issue, userId: string): void {
|
||||
if (project.seesOnlyOwnTickets(userId) && !issue.involves(userId))
|
||||
throw new NotFoundError('Issue', issue.key);
|
||||
}
|
||||
|
||||
/** Release of the same project that still accepts tickets. */
|
||||
async openReleaseOf(project: Project, releaseId: string): Promise<Release> {
|
||||
const release = await this.release(releaseId);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CommandRunner } from '@task-stream/api/shared/application';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import { QueryBus } from '@nestjs/cqrs';
|
||||
import { CommentReadModel } from '@task-stream/api/tracker/domain';
|
||||
import { AddCommentCommand } from './commands/add-comment.command';
|
||||
import { CommandResult } from './commands/command-result';
|
||||
@@ -12,7 +13,7 @@ import { GetCommentsQuery } from './queries/get-comments.query';
|
||||
@Injectable()
|
||||
export class CommentsService {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly commands: CommandRunner,
|
||||
private readonly queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
@@ -22,7 +23,7 @@ export class CommentsService {
|
||||
body: string,
|
||||
replyTo: string | null = null,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new AddCommentCommand(actorId, issueId, body, replyTo),
|
||||
);
|
||||
}
|
||||
@@ -32,7 +33,7 @@ export class CommentsService {
|
||||
commentId: string,
|
||||
body: string,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new EditCommentCommand(actorId, commentId, body),
|
||||
);
|
||||
}
|
||||
@@ -44,15 +45,13 @@ export class CommentsService {
|
||||
emoji: string,
|
||||
on: boolean,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new ReactToCommentCommand(actorId, commentId, emoji, on),
|
||||
);
|
||||
}
|
||||
|
||||
delete(actorId: string, commentId: string): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
new DeleteCommentCommand(actorId, commentId),
|
||||
);
|
||||
return this.commands.execute(new DeleteCommentCommand(actorId, commentId));
|
||||
}
|
||||
|
||||
list(
|
||||
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
ProjectRole,
|
||||
ProjectSettingsChangedEvent,
|
||||
ProjectUpdatedEvent,
|
||||
ProjectArchivedEvent,
|
||||
ProjectUnarchivedEvent,
|
||||
ProjectWorkflow,
|
||||
ReleaseArchivedEvent,
|
||||
ReleaseNotesWrittenEvent,
|
||||
@@ -77,6 +79,7 @@ const ROLE: Record<ProjectRole, string> = {
|
||||
[ProjectRole.ADMIN]: 'admin',
|
||||
[ProjectRole.MEMBER]: 'miembro',
|
||||
[ProjectRole.VIEWER]: 'lector',
|
||||
[ProjectRole.GUEST]: 'invitado',
|
||||
};
|
||||
|
||||
const FIELD: Record<string, string> = {
|
||||
@@ -84,6 +87,7 @@ const FIELD: Record<string, string> = {
|
||||
description: 'la descripción',
|
||||
type: 'el tipo',
|
||||
area: 'el área',
|
||||
team: 'el equipo',
|
||||
priority: 'la prioridad',
|
||||
labels: 'las etiquetas',
|
||||
figmaUrl: 'el enlace de Figma',
|
||||
@@ -109,6 +113,10 @@ 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 ProjectArchivedEvent)
|
||||
return `${actor} archivó el proyecto ${ctx.projectKey}`;
|
||||
if (event instanceof ProjectUnarchivedEvent)
|
||||
return `${actor} desarchivó el proyecto ${ctx.projectKey}`;
|
||||
if (event instanceof ProjectSettingsChangedEvent)
|
||||
return `${actor} cambió los estados, tipos o áreas del proyecto ${ctx.projectKey}`;
|
||||
if (event instanceof ProjectMemberAddedEvent) {
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
ProjectMemberRemovedEvent,
|
||||
ProjectMemberRoleChangedEvent,
|
||||
ProjectUpdatedEvent,
|
||||
ProjectArchivedEvent,
|
||||
ProjectUnarchivedEvent,
|
||||
ReleaseArchivedEvent,
|
||||
ReleaseNotesWrittenEvent,
|
||||
IssueSprintChangedEvent,
|
||||
@@ -57,6 +59,8 @@ import { versionOf } from './read-model-notifier';
|
||||
@EventsHandler(
|
||||
ProjectCreatedEvent,
|
||||
ProjectUpdatedEvent,
|
||||
ProjectArchivedEvent,
|
||||
ProjectUnarchivedEvent,
|
||||
ProjectSettingsChangedEvent,
|
||||
ProjectMemberAddedEvent,
|
||||
ProjectMemberRemovedEvent,
|
||||
|
||||
@@ -16,6 +16,7 @@ import { ProjectMemberAddedEventHandler } from './project-member-added.event-han
|
||||
import { ProjectMemberRemovedEventHandler } from './project-member-removed.event-handler';
|
||||
import { ProjectMemberRoleChangedEventHandler } from './project-member-role-changed.event-handler';
|
||||
import { ProjectUpdatedEventHandler } from './project-updated.event-handler';
|
||||
import { ProjectArchivedEventHandler } from './project-archived.event-handler';
|
||||
import { ReleaseArchivedEventHandler } from './release-archived.event-handler';
|
||||
import { ReleaseCreatedEventHandler } from './release-created.event-handler';
|
||||
import { ReleaseShippedEventHandler } from './release-shipped.event-handler';
|
||||
@@ -63,6 +64,7 @@ export const EVENT_HANDLERS = [
|
||||
ProjectMemberRemovedEventHandler,
|
||||
ProjectMemberRoleChangedEventHandler,
|
||||
ProjectUpdatedEventHandler,
|
||||
ProjectArchivedEventHandler,
|
||||
ProjectSettingsChangedEventHandler,
|
||||
ReleaseArchivedEventHandler,
|
||||
ReleaseCreatedEventHandler,
|
||||
|
||||
@@ -41,6 +41,7 @@ export class IssueCreatedEventHandler
|
||||
key: issue.key,
|
||||
type: issue.type,
|
||||
area: issue.area,
|
||||
team: issue.team ?? null,
|
||||
title: issue.title,
|
||||
description: issue.description,
|
||||
status: issue.status,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
ProjectArchivedEvent,
|
||||
ProjectUnarchivedEvent,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { UpsertMaterializedProjectRepository } from '../ports/upsert-materialized-project.repository';
|
||||
import { ReadModelNotifier, versionOf } from './read-model-notifier';
|
||||
|
||||
@EventsHandler(ProjectArchivedEvent, ProjectUnarchivedEvent)
|
||||
export class ProjectArchivedEventHandler
|
||||
implements IEventHandler<ProjectArchivedEvent | ProjectUnarchivedEvent>
|
||||
{
|
||||
constructor(
|
||||
private readonly upsertMaterializedProjectRepository: UpsertMaterializedProjectRepository,
|
||||
private readonly notifier: ReadModelNotifier,
|
||||
) {}
|
||||
|
||||
async handle(event: ProjectArchivedEvent | ProjectUnarchivedEvent) {
|
||||
await this.upsertMaterializedProjectRepository.upsert({
|
||||
id: event.projectId,
|
||||
archivedAt:
|
||||
event instanceof ProjectArchivedEvent ? event.occurredAt : null,
|
||||
updatedAt: event.occurredAt,
|
||||
version: versionOf(event),
|
||||
});
|
||||
await this.notifier.project(event);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import { QueryBus } from '@nestjs/cqrs';
|
||||
import { CommandRunner } from '@task-stream/api/shared/application';
|
||||
import { ForbiddenError } from '@task-stream/api/shared/domain';
|
||||
import { createHmac } from 'node:crypto';
|
||||
import { RecordGitMentionsCommand } from './git/git';
|
||||
@@ -28,7 +29,7 @@ describe('GitService', () => {
|
||||
executed.push(command);
|
||||
return 1;
|
||||
},
|
||||
} as unknown as CommandBus,
|
||||
} as unknown as CommandRunner,
|
||||
{} as QueryBus,
|
||||
{
|
||||
findById: async (id: string) => (id === 'p1' ? { id: 'p1' } : null),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CommandRunner } from '@task-stream/api/shared/application';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import { QueryBus } from '@nestjs/cqrs';
|
||||
import { ForbiddenError } from '@task-stream/api/shared/domain';
|
||||
import {
|
||||
AddGitRepositoryCommand,
|
||||
@@ -30,7 +31,7 @@ export interface GitDelivery {
|
||||
@Injectable()
|
||||
export class GitService {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly commands: CommandRunner,
|
||||
private readonly queryBus: QueryBus,
|
||||
private readonly findProjects: FindProjectsRepository,
|
||||
private readonly integrations: GitIntegrationsRepository,
|
||||
@@ -47,7 +48,7 @@ export class GitService {
|
||||
projectId: string,
|
||||
name: string,
|
||||
): Promise<GitRepository> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new AddGitRepositoryCommand(actorId, projectId, name),
|
||||
);
|
||||
}
|
||||
@@ -57,7 +58,7 @@ export class GitService {
|
||||
projectId: string,
|
||||
repositoryId: string,
|
||||
): Promise<GitRepository> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new RotateGitRepositorySecretCommand(actorId, projectId, repositoryId),
|
||||
);
|
||||
}
|
||||
@@ -67,7 +68,7 @@ export class GitService {
|
||||
projectId: string,
|
||||
repositoryId: string,
|
||||
): Promise<void> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new RemoveGitRepositoryCommand(actorId, projectId, repositoryId),
|
||||
);
|
||||
}
|
||||
@@ -104,7 +105,7 @@ export class GitService {
|
||||
delivery.body,
|
||||
);
|
||||
if (!mentions.length) return { recorded: 0 };
|
||||
const recorded: number = await this.commandBus.execute(
|
||||
const recorded: number = await this.commands.execute(
|
||||
new RecordGitMentionsCommand(project.id, repository.name, mentions),
|
||||
);
|
||||
return { recorded };
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CommandRunner } from '@task-stream/api/shared/application';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import { QueryBus } from '@nestjs/cqrs';
|
||||
import { assertThat } from '@task-stream/api/shared/domain';
|
||||
import {
|
||||
ActivityReadModel,
|
||||
@@ -79,7 +80,7 @@ export interface IssueLinkInput {
|
||||
@Injectable()
|
||||
export class IssuesService {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly commands: CommandRunner,
|
||||
private readonly queryBus: QueryBus,
|
||||
private readonly access: WriteAccess,
|
||||
private readonly findIssues: FindIssuesRepository,
|
||||
@@ -97,7 +98,7 @@ export class IssuesService {
|
||||
useTemplate,
|
||||
...details
|
||||
} = input;
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new CreateIssueCommand(
|
||||
actorId,
|
||||
projectId,
|
||||
@@ -118,7 +119,7 @@ export class IssuesService {
|
||||
issueId: string,
|
||||
changes: IssueDetails,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new UpdateIssueCommand(actorId, issueId, changes),
|
||||
);
|
||||
}
|
||||
@@ -129,7 +130,7 @@ export class IssuesService {
|
||||
status: string,
|
||||
reason?: string | null,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new ChangeIssueStatusCommand(actorId, issueId, status, reason),
|
||||
);
|
||||
}
|
||||
@@ -140,13 +141,13 @@ export class IssuesService {
|
||||
issueId: string,
|
||||
assigneeId: string | null,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new AssignIssueCommand(actorId, issueId, assigneeId),
|
||||
);
|
||||
}
|
||||
|
||||
link(actorId: string, input: IssueLinkInput): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new LinkIssuesCommand(
|
||||
actorId,
|
||||
input.issueId,
|
||||
@@ -157,7 +158,7 @@ export class IssuesService {
|
||||
}
|
||||
|
||||
unlink(actorId: string, input: IssueLinkInput): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new UnlinkIssuesCommand(
|
||||
actorId,
|
||||
input.issueId,
|
||||
@@ -173,7 +174,7 @@ export class IssuesService {
|
||||
issueId: string,
|
||||
releaseId: string | null,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new SetIssueReleaseCommand(actorId, issueId, releaseId),
|
||||
);
|
||||
}
|
||||
@@ -185,7 +186,7 @@ export class IssuesService {
|
||||
file: IncomingFile,
|
||||
commentId: string | null = null,
|
||||
): Promise<AttachmentResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new AttachFileCommand(actorId, issueId, file, commentId),
|
||||
);
|
||||
}
|
||||
@@ -195,7 +196,7 @@ export class IssuesService {
|
||||
issueId: string,
|
||||
attachmentId: string,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new DetachFileCommand(actorId, issueId, attachmentId),
|
||||
);
|
||||
}
|
||||
@@ -206,7 +207,7 @@ export class IssuesService {
|
||||
issueId: string,
|
||||
watching: boolean,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new WatchIssueCommand(actorId, issueId, actorId, watching),
|
||||
);
|
||||
}
|
||||
@@ -236,7 +237,7 @@ export class IssuesService {
|
||||
issueId: string,
|
||||
parentId: string | null,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new SetIssueParentCommand(actorId, issueId, parentId),
|
||||
);
|
||||
}
|
||||
@@ -248,7 +249,7 @@ export class IssuesService {
|
||||
above: string | null,
|
||||
below: string | null,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new RankIssueCommand(actorId, issueId, above, below),
|
||||
);
|
||||
}
|
||||
@@ -278,7 +279,7 @@ export class IssuesService {
|
||||
if (change.assigneeId !== undefined)
|
||||
await this.assign(actorId, issueId, change.assigneeId);
|
||||
if (change.sprintId !== undefined)
|
||||
await this.commandBus.execute(
|
||||
await this.commands.execute(
|
||||
new SetIssueSprintCommand(actorId, issueId, change.sprintId),
|
||||
);
|
||||
if (change.releaseId !== undefined)
|
||||
@@ -306,12 +307,12 @@ export class IssuesService {
|
||||
|
||||
/** To the trash (whoever opened it, or a manager); back with `restore`. */
|
||||
trash(actorId: string, issueId: string): Promise<CommandResult> {
|
||||
return this.commandBus.execute(new TrashIssueCommand(actorId, issueId));
|
||||
return this.commands.execute(new TrashIssueCommand(actorId, issueId));
|
||||
}
|
||||
|
||||
/** Deletes it for good (the trash is emptied this way). */
|
||||
delete(actorId: string, issueId: string): Promise<void> {
|
||||
return this.commandBus.execute(new DeleteIssueCommand(actorId, issueId));
|
||||
return this.commands.execute(new DeleteIssueCommand(actorId, issueId));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -326,12 +327,12 @@ export class IssuesService {
|
||||
}
|
||||
|
||||
restore(actorId: string, issueId: string): Promise<CommandResult> {
|
||||
return this.commandBus.execute(new RestoreIssueCommand(actorId, issueId));
|
||||
return this.commands.execute(new RestoreIssueCommand(actorId, issueId));
|
||||
}
|
||||
|
||||
/** Votes for the ticket (`on`) or takes the vote back. */
|
||||
vote(actorId: string, issueId: string, on: boolean): Promise<CommandResult> {
|
||||
return this.commandBus.execute(new VoteIssueCommand(actorId, issueId, on));
|
||||
return this.commands.execute(new VoteIssueCommand(actorId, issueId, on));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -349,6 +350,7 @@ export class IssuesService {
|
||||
title: `${issue.title} (copia)`.slice(0, 255),
|
||||
description: issue.description,
|
||||
area: issue.area,
|
||||
team: issue.team,
|
||||
priority: issue.priority,
|
||||
labels: issue.labels,
|
||||
figmaUrl: issue.figmaUrl,
|
||||
@@ -378,7 +380,7 @@ export class IssuesService {
|
||||
issueId: string,
|
||||
text: string,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new AddChecklistItemCommand(actorId, issueId, text),
|
||||
);
|
||||
}
|
||||
@@ -389,7 +391,7 @@ export class IssuesService {
|
||||
itemId: string,
|
||||
changes: { text?: string; done?: boolean },
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new UpdateChecklistItemCommand(actorId, issueId, itemId, changes),
|
||||
);
|
||||
}
|
||||
@@ -399,7 +401,7 @@ export class IssuesService {
|
||||
issueId: string,
|
||||
itemId: string,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new RemoveChecklistItemCommand(actorId, issueId, itemId),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CommandRunner } from '@task-stream/api/shared/application';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import { QueryBus } from '@nestjs/cqrs';
|
||||
import { NotificationReadModel } from '@task-stream/api/tracker/domain';
|
||||
import { SendDueRemindersCommand } from './notifications/reminders';
|
||||
import {
|
||||
@@ -20,7 +21,7 @@ import { ChangeFeed } from './ports/change-feed';
|
||||
@Injectable()
|
||||
export class NotificationsService {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly commands: CommandRunner,
|
||||
private readonly queryBus: QueryBus,
|
||||
private readonly changeFeed: ChangeFeed,
|
||||
) {}
|
||||
@@ -45,25 +46,23 @@ export class NotificationsService {
|
||||
endpoint: string,
|
||||
keys: { p256dh: string; auth: string },
|
||||
): Promise<void> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new SubscribePushCommand(userId, endpoint, keys),
|
||||
);
|
||||
}
|
||||
|
||||
/** Push off on this device. */
|
||||
unsubscribePush(userId: string, endpoint: string): Promise<void> {
|
||||
return this.commandBus.execute(
|
||||
new UnsubscribePushCommand(userId, endpoint),
|
||||
);
|
||||
return this.commands.execute(new UnsubscribePushCommand(userId, endpoint));
|
||||
}
|
||||
|
||||
/** Tells of the tickets due around `day` (YYYY-MM-DD); how many it sent. */
|
||||
sendDueReminders(day: string): Promise<number> {
|
||||
return this.commandBus.execute(new SendDueRemindersCommand(day));
|
||||
return this.commands.execute(new SendDueRemindersCommand(day));
|
||||
}
|
||||
|
||||
markRead(userId: string, ids?: string[] | null): Promise<void> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new MarkNotificationsReadCommand(userId, ids?.length ? ids : null),
|
||||
);
|
||||
}
|
||||
@@ -76,7 +75,7 @@ export class NotificationsService {
|
||||
userId: string,
|
||||
changes: { email: boolean },
|
||||
): Promise<MyNotificationSettings> {
|
||||
await this.commandBus.execute(
|
||||
await this.commands.execute(
|
||||
new UpdateNotificationSettingsCommand(userId, changes.email),
|
||||
);
|
||||
return this.settings(userId);
|
||||
|
||||
@@ -8,6 +8,9 @@ import {
|
||||
IssueStatusChangedEvent,
|
||||
IssueUpdatedEvent,
|
||||
mentionedUserIds,
|
||||
DEFAULT_PROJECT_SETTINGS,
|
||||
ProjectWorkflow,
|
||||
TEAM_MENTION_PREFIX,
|
||||
NotificationKind,
|
||||
ProjectMemberAddedEvent,
|
||||
ProjectRole,
|
||||
@@ -195,7 +198,12 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
|
||||
if (event instanceof CommentAddedEvent) {
|
||||
const said = `«${excerpt(event.comment.body)}»`;
|
||||
// Only members: a mention cannot reach someone who cannot read the ticket.
|
||||
for (const userId of mentionedUserIds(event.comment.body)) {
|
||||
// A team's mention (`team-FRONTEND`) reaches each of its members.
|
||||
const workflow = new ProjectWorkflow(
|
||||
project.settings ?? DEFAULT_PROJECT_SETTINGS,
|
||||
);
|
||||
const named = mentionedUserIds(event.comment.body);
|
||||
for (const userId of named) {
|
||||
if (!memberIds.has(userId)) continue;
|
||||
drafts.push({
|
||||
userId,
|
||||
@@ -203,6 +211,19 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
|
||||
summary: `${actor} te mencionó en ${issue.key}: ${said}`,
|
||||
});
|
||||
}
|
||||
for (const id of named.filter((n) => n.startsWith(TEAM_MENTION_PREFIX))) {
|
||||
const team = workflow.teams.find(
|
||||
(t) => t.id === id.slice(TEAM_MENTION_PREFIX.length),
|
||||
);
|
||||
for (const userId of workflow.mentionedPeople([id])) {
|
||||
if (!memberIds.has(userId)) continue;
|
||||
drafts.push({
|
||||
userId,
|
||||
kind: NotificationKind.MENTIONED,
|
||||
summary: `${actor} mencionó a tu equipo ${team?.name ?? ''} en ${issue.key}: ${said}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
const answered = event.comment.replyTo
|
||||
? await this.findComments.findById(event.comment.replyTo)
|
||||
: null;
|
||||
|
||||
@@ -8,4 +8,14 @@ export abstract class FindActivityRepository {
|
||||
/** ISO date cursor: only activity strictly older than this. */
|
||||
before?: string;
|
||||
}): Promise<ActivityReadModel[]>;
|
||||
/** The latest of several projects, newest first. */
|
||||
abstract findAcross(
|
||||
projectIds: string[],
|
||||
limit: number,
|
||||
): Promise<ActivityReadModel[]>;
|
||||
/** How much happened per project and day (`YYYY-MM-DD`) since `fromDay`. */
|
||||
abstract dailyCounts(
|
||||
projectIds: string[],
|
||||
fromDay: string,
|
||||
): Promise<{ projectId: string; day: string; count: number }[]>;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface IssueFilter {
|
||||
categories?: StatusCategory[];
|
||||
types?: string[];
|
||||
areas?: string[];
|
||||
/** Team ids of the project's settings. */
|
||||
teams?: string[];
|
||||
priorities?: IssuePriority[];
|
||||
assigneeId?: string;
|
||||
unassigned?: boolean;
|
||||
@@ -33,12 +35,20 @@ export interface IssueFilter {
|
||||
dueTo?: string;
|
||||
/** Only tickets in no sprint (wins over `sprintId`). */
|
||||
noSprint?: boolean;
|
||||
/** Only tickets nobody estimated. */
|
||||
noEstimate?: boolean;
|
||||
/** Only tickets with no area. */
|
||||
noArea?: boolean;
|
||||
/** Only tickets not updated since this instant (ISO). */
|
||||
updatedBefore?: string;
|
||||
/** Custom field id → the value it must hold. */
|
||||
customFields?: Record<string, string>;
|
||||
/** Only the ones the viewer follows (the query handler turns it into `watcherId`). */
|
||||
watching?: boolean;
|
||||
/** Only the ones this person follows. */
|
||||
watcherId?: string;
|
||||
/** Only the ones this person opened, has or follows (a guest's view). */
|
||||
participantId?: string;
|
||||
/** RECENT (default): last updated first. RANK: backlog order. */
|
||||
sort?: IssueSort;
|
||||
search?: string;
|
||||
@@ -54,6 +64,14 @@ export interface CrossProjectIssueFilter {
|
||||
watcherId?: string;
|
||||
statuses?: string[];
|
||||
categories?: StatusCategory[];
|
||||
priorities?: IssuePriority[];
|
||||
/** Of these, only the tickets `participantId` opened, has or follows. */
|
||||
guestProjectIds?: string[];
|
||||
participantId?: string;
|
||||
/** Project id → the only type ids taken there. */
|
||||
typesByProject?: Record<string, string[]>;
|
||||
/** Due before this day (`YYYY-MM-DD`, excluded). */
|
||||
dueBefore?: string;
|
||||
search?: string;
|
||||
limit: number;
|
||||
offset: number;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { MyViewReadModel } from '@task-stream/api/tracker/domain';
|
||||
|
||||
/** Saved views of «my work»: one person's preferences, no history. */
|
||||
export abstract class MyViewsRepository {
|
||||
/** By name. */
|
||||
abstract findByOwner(ownerId: string): Promise<MyViewReadModel[]>;
|
||||
abstract findById(id: string): Promise<MyViewReadModel | null>;
|
||||
abstract countByOwner(ownerId: string): Promise<number>;
|
||||
abstract insert(view: MyViewReadModel): Promise<void>;
|
||||
abstract delete(id: string): Promise<void>;
|
||||
}
|
||||
36
libs/api/tracker/application/src/lib/ports/presence.store.ts
Normal file
36
libs/api/tracker/application/src/lib/ports/presence.store.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/** Someone on a ticket's page just now. */
|
||||
export interface PresenceEntry {
|
||||
user: { id: string; name: string };
|
||||
/** Editing the ticket or writing a comment, not only looking. */
|
||||
editing: boolean;
|
||||
/** Last heartbeat (ISO). */
|
||||
at: string;
|
||||
/** The ticket, in the project-wide list (who is on what). */
|
||||
issue?: { key: string; title: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Who is on each ticket's page, shared by every instance of the API.
|
||||
* Entries fade on their own: a page that stops beating is gone.
|
||||
*/
|
||||
export abstract class PresenceStore {
|
||||
/** Records (or refreshes) one person on a ticket; lasts `ttlMs`. */
|
||||
abstract touch(
|
||||
issueId: string,
|
||||
entry: PresenceEntry,
|
||||
ttlMs: number,
|
||||
): Promise<void>;
|
||||
/** Everyone whose heartbeat is newer than `since` (ms epoch). */
|
||||
abstract list(issueId: string, since: number): Promise<PresenceEntry[]>;
|
||||
abstract leave(issueId: string, userId: string): Promise<void>;
|
||||
/** The same, per project: who is on which of its tickets. */
|
||||
abstract touchProject(
|
||||
projectId: string,
|
||||
entry: PresenceEntry,
|
||||
ttlMs: number,
|
||||
): Promise<void>;
|
||||
abstract listProject(
|
||||
projectId: string,
|
||||
since: number,
|
||||
): Promise<PresenceEntry[]>;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { ForbiddenError, NotFoundError } from '@task-stream/api/shared/domain';
|
||||
import { IssueReadModel } from '@task-stream/api/tracker/domain';
|
||||
import { FindIssuesRepository } from './ports/find-issues.repository';
|
||||
import { PresenceEntry, PresenceStore } from './ports/presence.store';
|
||||
import { PRESENCE_TTL_MS, PresenceService } from './presence.service';
|
||||
import { ReadAccess } from './queries/read-access';
|
||||
|
||||
class MemoryPresence extends PresenceStore {
|
||||
readonly entries = new Map<string, Map<string, PresenceEntry>>();
|
||||
async touch(issueId: string, entry: PresenceEntry) {
|
||||
const here = this.entries.get(issueId) ?? new Map();
|
||||
here.set(entry.user.id, entry);
|
||||
this.entries.set(issueId, here);
|
||||
}
|
||||
async list(issueId: string, since: number) {
|
||||
return [...(this.entries.get(issueId)?.values() ?? [])].filter(
|
||||
(e) => Date.parse(e.at) >= since,
|
||||
);
|
||||
}
|
||||
async leave(issueId: string, userId: string) {
|
||||
this.entries.get(issueId)?.delete(userId);
|
||||
}
|
||||
async touchProject() {
|
||||
/* not needed here */
|
||||
}
|
||||
async listProject() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const issues = {
|
||||
findById: async (id: string) =>
|
||||
id === 'i1'
|
||||
? ({ id: 'i1', projectId: 'p1', trashedAt: null } as IssueReadModel)
|
||||
: null,
|
||||
} as unknown as FindIssuesRepository;
|
||||
const access = {
|
||||
assertCanViewIssue: async (_issue: IssueReadModel, viewerId: string) => {
|
||||
if (viewerId === 'eve') throw new ForbiddenError('No');
|
||||
},
|
||||
} as unknown as ReadAccess;
|
||||
|
||||
describe('PresenceService', () => {
|
||||
it('tells who else is on a ticket, the ones editing first', async () => {
|
||||
const store = new MemoryPresence();
|
||||
const presence = new PresenceService(store, issues, access);
|
||||
await presence.touch({ id: 'ana', name: 'Ana' }, 'i1', false);
|
||||
await presence.touch({ id: 'cris', name: 'Cris' }, 'i1', true);
|
||||
const seen = await presence.touch({ id: 'bob', name: 'Bob' }, 'i1', false);
|
||||
expect(seen.map((p) => [p.user.name, p.editing])).toEqual([
|
||||
['Cris', true],
|
||||
['Ana', false],
|
||||
]);
|
||||
|
||||
// A page that stops beating fades out; leaving takes one out at once.
|
||||
store.entries.get('i1')!.get('ana')!.at = new Date(
|
||||
Date.now() - PRESENCE_TTL_MS - 1000,
|
||||
).toISOString();
|
||||
await presence.leave('cris', 'i1');
|
||||
expect(
|
||||
await presence.touch({ id: 'bob', name: 'Bob' }, 'i1', false),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('only for whoever can read the ticket', async () => {
|
||||
const presence = new PresenceService(new MemoryPresence(), issues, access);
|
||||
await expect(
|
||||
presence.touch({ id: 'eve', name: 'Eve' }, 'i1', false),
|
||||
).rejects.toBeInstanceOf(ForbiddenError);
|
||||
await expect(
|
||||
presence.touch({ id: 'ana', name: 'Ana' }, 'nope', false),
|
||||
).rejects.toBeInstanceOf(NotFoundError);
|
||||
});
|
||||
});
|
||||
68
libs/api/tracker/application/src/lib/presence.service.ts
Normal file
68
libs/api/tracker/application/src/lib/presence.service.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { NotFoundError } from '@task-stream/api/shared/domain';
|
||||
import { FindIssuesRepository } from './ports/find-issues.repository';
|
||||
import { PresenceEntry, PresenceStore } from './ports/presence.store';
|
||||
import { ReadAccess } from './queries/read-access';
|
||||
|
||||
/** A page beats every 10 s; three missed beats and it is gone. */
|
||||
export const PRESENCE_TTL_MS = 30_000;
|
||||
|
||||
/**
|
||||
* Who else is on a ticket right now, and whether they are editing it. Not
|
||||
* history: kept in Redis for a few seconds, never in the event store.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PresenceService {
|
||||
constructor(
|
||||
private readonly store: PresenceStore,
|
||||
private readonly findIssues: FindIssuesRepository,
|
||||
private readonly access: ReadAccess,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* I am on the ticket (editing or not): answers who else is there, the
|
||||
* ones editing first. Only for people who can read it.
|
||||
*/
|
||||
async touch(
|
||||
viewer: { id: string; name: string },
|
||||
issueId: string,
|
||||
editing: boolean,
|
||||
): Promise<PresenceEntry[]> {
|
||||
const issue = await this.assertCanView(viewer.id, issueId);
|
||||
const now = Date.now();
|
||||
const entry = {
|
||||
user: { id: viewer.id, name: viewer.name },
|
||||
editing,
|
||||
at: new Date(now).toISOString(),
|
||||
};
|
||||
await Promise.all([
|
||||
this.store.touch(issueId, entry, PRESENCE_TTL_MS),
|
||||
// The project-wide list says on which ticket (home's «who is here»).
|
||||
this.store.touchProject(
|
||||
issue.projectId,
|
||||
{ ...entry, issue: { key: issue.key, title: issue.title } },
|
||||
PRESENCE_TTL_MS,
|
||||
),
|
||||
]);
|
||||
const here = await this.store.list(issueId, now - PRESENCE_TTL_MS);
|
||||
return here
|
||||
.filter((entry) => entry.user.id !== viewer.id)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Number(b.editing) - Number(a.editing) ||
|
||||
a.user.name.localeCompare(b.user.name, 'es'),
|
||||
);
|
||||
}
|
||||
|
||||
/** I left the ticket's page. */
|
||||
leave(viewerId: string, issueId: string): Promise<void> {
|
||||
return this.store.leave(issueId, viewerId);
|
||||
}
|
||||
|
||||
private async assertCanView(viewerId: string, issueId: string) {
|
||||
const issue = await this.findIssues.findById(issueId);
|
||||
if (!issue || issue.trashedAt) throw new NotFoundError('Issue', issueId);
|
||||
await this.access.assertCanViewIssue(issue, viewerId);
|
||||
return issue;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,20 @@
|
||||
import { GetShowcaseQuery, Showcase } from './queries/get-showcase.query';
|
||||
import { CommandRunner } from '@task-stream/api/shared/application';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import { QueryBus } from '@nestjs/cqrs';
|
||||
import {
|
||||
BusinessRuleError,
|
||||
ForbiddenError,
|
||||
NotFoundError,
|
||||
ValidationError,
|
||||
assertThat,
|
||||
} from '@task-stream/api/shared/domain';
|
||||
import {
|
||||
IssuePriority,
|
||||
ProjectAnalyticsReadModel,
|
||||
ProjectInsightsReadModel,
|
||||
PortfolioProjectReadModel,
|
||||
HomeReadModel,
|
||||
ProjectReadModel,
|
||||
ProjectRole,
|
||||
ProjectSettings,
|
||||
@@ -25,6 +31,7 @@ import { ChangeProjectMemberRoleCommand } from './commands/change-project-member
|
||||
import { CommandResult } from './commands/command-result';
|
||||
import { CreateProjectCommand } from './commands/create-project.command';
|
||||
import { DeleteProjectCommand } from './commands/delete-project.command';
|
||||
import { ArchiveProjectCommand } from './commands/archive-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';
|
||||
@@ -32,15 +39,24 @@ 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 { GetProjectInsightsQuery } from './queries/get-project-insights.query';
|
||||
import { GetPortfolioQuery } from './queries/get-portfolio.query';
|
||||
import { GetMyHomeQuery } from './queries/get-my-home.query';
|
||||
import { GetProjectStatsQuery } from './queries/get-project-stats.query';
|
||||
import { GetPublicProjectsQuery } from './queries/get-public-projects.query';
|
||||
import { GetProjectQuery } from './queries/get-project.query';
|
||||
import { GetReleasesQuery } from './queries/get-releases.query';
|
||||
import {
|
||||
CreateAutomationCommand,
|
||||
GetAutomationsQuery,
|
||||
UpdateAutomationCommand,
|
||||
} from './automations/automations';
|
||||
import { Automation } from './ports/automations.repository';
|
||||
|
||||
@Injectable()
|
||||
export class ProjectsService {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly commands: CommandRunner,
|
||||
private readonly queryBus: QueryBus,
|
||||
private readonly findProjects: FindProjectsRepository,
|
||||
private readonly access: WriteAccess,
|
||||
@@ -64,7 +80,7 @@ export class ProjectsService {
|
||||
(await this.findProjects.findById(projectIdOrKey)) ??
|
||||
(await this.findProjects.findByKey(projectIdOrKey.toUpperCase()));
|
||||
if (!found?.isPublic) throw new NotFoundError('Project', projectIdOrKey);
|
||||
if (!found.acceptsProposals)
|
||||
if (!found.acceptsProposals || found.archivedAt)
|
||||
throw new ForbiddenError(`${found.key} does not take proposals`);
|
||||
|
||||
const cleanTitle = title.trim();
|
||||
@@ -82,7 +98,7 @@ export class ProjectsService {
|
||||
const signer = author?.trim().slice(0, 80);
|
||||
const signature = `_Propuesta enviada desde el formulario público${signer ? ` por ${signer}` : ''}._`;
|
||||
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new CreateIssueCommand(
|
||||
SYSTEM_ACTOR_ID,
|
||||
project.id,
|
||||
@@ -108,16 +124,28 @@ export class ProjectsService {
|
||||
return this.queryBus.execute(new GetShowcaseQuery(project));
|
||||
}
|
||||
|
||||
create(
|
||||
/**
|
||||
* A new project. With `templateId`, it starts with a copy of that
|
||||
* project's configuration (statuses, transitions, types, areas and custom
|
||||
* fields) and automation rules, taken once: later changes to either one
|
||||
* stay there. Only from a project the actor manages (rules are only shown
|
||||
* to managers); a rule that names someone who is not in the new project
|
||||
* is left out.
|
||||
*/
|
||||
async create(
|
||||
actorId: string,
|
||||
input: {
|
||||
key: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
isPublic?: boolean;
|
||||
templateId?: string | null;
|
||||
},
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
const template = input.templateId
|
||||
? await this.template(actorId, input.templateId)
|
||||
: null;
|
||||
const created: CommandResult = await this.commands.execute(
|
||||
new CreateProjectCommand(
|
||||
actorId,
|
||||
input.key,
|
||||
@@ -126,6 +154,62 @@ export class ProjectsService {
|
||||
input.isPublic ?? false,
|
||||
),
|
||||
);
|
||||
if (!template) return created;
|
||||
const configured: CommandResult = await this.commands.execute(
|
||||
new UpdateProjectSettingsCommand(
|
||||
actorId,
|
||||
created.id,
|
||||
structuredClone(template.settings),
|
||||
),
|
||||
);
|
||||
for (const rule of template.automations)
|
||||
await this.copyRule(actorId, created.id, rule);
|
||||
return { ...configured, key: created.key };
|
||||
}
|
||||
|
||||
private async template(
|
||||
actorId: string,
|
||||
projectId: string,
|
||||
): Promise<{
|
||||
settings: ProjectSettings;
|
||||
automations: Automation[];
|
||||
}> {
|
||||
const project = await this.access.project(projectId);
|
||||
// Copying only reads it: an archived project makes a fine template.
|
||||
project.assertManages(actorId);
|
||||
const automations: Automation[] = await this.queryBus.execute(
|
||||
new GetAutomationsQuery(actorId, projectId),
|
||||
);
|
||||
return { settings: project.settings, automations };
|
||||
}
|
||||
|
||||
private async copyRule(
|
||||
actorId: string,
|
||||
projectId: string,
|
||||
rule: Automation,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const copy: Automation = await this.commands.execute(
|
||||
new CreateAutomationCommand(actorId, projectId, {
|
||||
name: rule.name,
|
||||
trigger: rule.trigger,
|
||||
// Stored without conditions when it had none.
|
||||
conditions: rule.conditions ?? {},
|
||||
action: rule.action,
|
||||
}),
|
||||
);
|
||||
if (!rule.active)
|
||||
await this.commands.execute(
|
||||
new UpdateAutomationCommand(actorId, copy.id, { active: false }),
|
||||
);
|
||||
} catch (error) {
|
||||
// Someone it assigns to is not a member here: that rule stays behind.
|
||||
if (
|
||||
!(error instanceof ValidationError) &&
|
||||
!(error instanceof BusinessRuleError)
|
||||
)
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
update(
|
||||
@@ -138,7 +222,7 @@ export class ProjectsService {
|
||||
acceptsProposals?: boolean;
|
||||
},
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new UpdateProjectCommand(actorId, projectId, changes),
|
||||
);
|
||||
}
|
||||
@@ -152,7 +236,7 @@ export class ProjectsService {
|
||||
projectId: string,
|
||||
confirmKey: string,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new DeleteProjectCommand(actorId, projectId, confirmKey),
|
||||
);
|
||||
}
|
||||
@@ -163,12 +247,23 @@ export class ProjectsService {
|
||||
}
|
||||
|
||||
/** Statuses, transitions, ticket types and areas (owners and admins). */
|
||||
/** Archives the project (read-only) or brings it back; its owner only. */
|
||||
setArchived(
|
||||
actorId: string,
|
||||
projectId: string,
|
||||
archived: boolean,
|
||||
): Promise<CommandResult> {
|
||||
return this.commands.execute(
|
||||
new ArchiveProjectCommand(actorId, projectId, archived),
|
||||
);
|
||||
}
|
||||
|
||||
updateSettings(
|
||||
actorId: string,
|
||||
projectId: string,
|
||||
settings: ProjectSettings,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new UpdateProjectSettingsCommand(actorId, projectId, settings),
|
||||
);
|
||||
}
|
||||
@@ -178,7 +273,7 @@ export class ProjectsService {
|
||||
input: { projectId: string; userId: string; role: ProjectRole },
|
||||
accessToken?: string,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new AddProjectMemberCommand(
|
||||
actorId,
|
||||
input.projectId,
|
||||
@@ -193,7 +288,7 @@ export class ProjectsService {
|
||||
actorId: string,
|
||||
input: { projectId: string; userId: string; role: ProjectRole },
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new ChangeProjectMemberRoleCommand(
|
||||
actorId,
|
||||
input.projectId,
|
||||
@@ -208,7 +303,7 @@ export class ProjectsService {
|
||||
projectId: string,
|
||||
userId: string,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new RemoveProjectMemberCommand(actorId, projectId, userId),
|
||||
);
|
||||
}
|
||||
@@ -246,6 +341,27 @@ export class ProjectsService {
|
||||
}
|
||||
|
||||
/** Statistics over the last `days` days (created vs resolved, flow, times, QA). */
|
||||
/** The home page beyond «my work». */
|
||||
home(viewerId: string): Promise<HomeReadModel> {
|
||||
return this.queryBus.execute(new GetMyHomeQuery(viewerId));
|
||||
}
|
||||
|
||||
/** All my active projects at a glance (not archived, not as a guest). */
|
||||
portfolio(viewerId: string): Promise<PortfolioProjectReadModel[]> {
|
||||
return this.queryBus.execute(new GetPortfolioQuery(viewerId));
|
||||
}
|
||||
|
||||
/** The dashboard's reading; with `since`, what changed after it too. */
|
||||
insights(
|
||||
viewerId: string | null,
|
||||
projectId: string,
|
||||
since: Date | null = null,
|
||||
): Promise<ProjectInsightsReadModel> {
|
||||
return this.queryBus.execute(
|
||||
new GetProjectInsightsQuery(viewerId, projectId, since),
|
||||
);
|
||||
}
|
||||
|
||||
analytics(
|
||||
viewerId: string | null,
|
||||
projectId: string,
|
||||
|
||||
@@ -9,6 +9,11 @@ export class CanViewProjectQueryHandler
|
||||
constructor(private readonly access: ReadAccess) {}
|
||||
|
||||
async execute(query: CanViewProjectQuery): Promise<boolean> {
|
||||
return this.access.canView(query.projectId, query.viewerId);
|
||||
if (!(await this.access.canView(query.projectId, query.viewerId)))
|
||||
return false;
|
||||
return (
|
||||
!query.wholeProject ||
|
||||
!(await this.access.participantScope(query.projectId, query.viewerId))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ export class CanViewProjectQuery extends Query<boolean> {
|
||||
/** Null: anonymous (public projects only). */
|
||||
public readonly viewerId: string | null,
|
||||
public readonly projectId: string,
|
||||
/** The whole project (activity, releases…), not only a guest's tickets. */
|
||||
public readonly wholeProject = false,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { NotFoundError } from '@task-stream/api/shared/domain';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import { ActivityReadModel } from '@task-stream/api/tracker/domain';
|
||||
import { FindActivityRepository } from '../ports/find-activity.repository';
|
||||
@@ -11,10 +13,23 @@ export class GetActivityQueryHandler
|
||||
constructor(
|
||||
private readonly findActivity: FindActivityRepository,
|
||||
private readonly access: ReadAccess,
|
||||
private readonly findIssues: FindIssuesRepository,
|
||||
) {}
|
||||
|
||||
async execute(query: GetActivityQuery): Promise<ActivityReadModel[]> {
|
||||
await this.access.assertCanView(query.projectId, query.viewerId);
|
||||
// A ticket's history goes with the ticket; the project's feed is for
|
||||
// whoever sees the whole project (not a guest).
|
||||
if (query.issueId) {
|
||||
const issue = this.access.assertFound(
|
||||
await this.findIssues.findById(query.issueId),
|
||||
'Issue',
|
||||
query.issueId,
|
||||
);
|
||||
if (issue.projectId !== query.projectId)
|
||||
throw new NotFoundError('Issue', query.issueId);
|
||||
await this.access.assertCanViewIssue(issue, query.viewerId);
|
||||
} else
|
||||
await this.access.assertSeesWholeProject(query.projectId, query.viewerId);
|
||||
return this.findActivity.find({
|
||||
projectId: query.projectId,
|
||||
issueId: query.issueId,
|
||||
|
||||
@@ -21,7 +21,7 @@ export class GetAttachmentQueryHandler
|
||||
'Attachment',
|
||||
query.attachmentId,
|
||||
);
|
||||
await this.access.assertCanView(issue.projectId, query.viewerId, {
|
||||
await this.access.assertCanViewIssue(issue, query.viewerId, {
|
||||
entity: 'Attachment',
|
||||
ref: query.attachmentId,
|
||||
});
|
||||
|
||||
@@ -27,6 +27,11 @@ export class GetBoardQueryHandler
|
||||
*/
|
||||
async execute(query: GetBoardQuery): Promise<BoardColumn[]> {
|
||||
await this.access.assertCanView(query.filter.projectId, query.viewerId);
|
||||
const participantId =
|
||||
(await this.access.participantScope(
|
||||
query.filter.projectId,
|
||||
query.viewerId,
|
||||
)) ?? undefined;
|
||||
const project = await this.findProjects.findById(query.filter.projectId);
|
||||
const statuses = (
|
||||
project?.settings ?? DEFAULT_PROJECT_SETTINGS
|
||||
@@ -35,6 +40,7 @@ export class GetBoardQueryHandler
|
||||
statuses.map(async (status) => {
|
||||
const page = await this.findIssues.search({
|
||||
...query.filter,
|
||||
participantId,
|
||||
statuses: [status.id],
|
||||
// Each column follows the backlog order.
|
||||
sort: IssueSort.RANK,
|
||||
|
||||
@@ -21,7 +21,7 @@ export class GetCommentsQueryHandler
|
||||
'Issue',
|
||||
query.issueId,
|
||||
);
|
||||
await this.access.assertCanView(issue.projectId, query.viewerId, {
|
||||
await this.access.assertCanViewIssue(issue, query.viewerId, {
|
||||
entity: 'Issue',
|
||||
ref: query.issueId,
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ export class GetEpicsQueryHandler
|
||||
) {}
|
||||
|
||||
async execute(query: GetEpicsQuery): Promise<EpicReadModel[]> {
|
||||
await this.access.assertCanView(query.projectId, query.viewerId);
|
||||
await this.access.assertSeesWholeProject(query.projectId, query.viewerId);
|
||||
return this.findIssues.epics(query.projectId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export class GetIssueQueryHandler
|
||||
? await this.findIssues.findById(query.idOrKey)
|
||||
: await this.findIssues.findByKey(query.idOrKey.toUpperCase());
|
||||
const found = this.access.assertFound(issue, 'Issue', query.idOrKey);
|
||||
await this.access.assertCanView(found.projectId, query.viewerId, {
|
||||
await this.access.assertCanViewIssue(found, query.viewerId, {
|
||||
entity: 'Issue',
|
||||
ref: query.idOrKey,
|
||||
});
|
||||
|
||||
@@ -23,6 +23,13 @@ export class GetIssuesByIdsQueryHandler
|
||||
await this.access.canView(projectId, query.viewerId),
|
||||
);
|
||||
}
|
||||
return issues.filter((issue) => visible.get(issue.projectId));
|
||||
const shown: IssueReadModel[] = [];
|
||||
for (const issue of issues)
|
||||
if (
|
||||
visible.get(issue.projectId) &&
|
||||
(await this.access.canViewIssue(issue, query.viewerId))
|
||||
)
|
||||
shown.push(issue);
|
||||
return shown;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
HomeReadModel,
|
||||
OnlineReadModel,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { computeHome, HomeProjectInput, lastDays } from '../analytics/home';
|
||||
import { CalendarFeedsRepository } from '../ports/calendar-feeds.repository';
|
||||
import { FindActivityRepository } from '../ports/find-activity.repository';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
import { FindReleasesRepository } from '../ports/find-releases.repository';
|
||||
import { FindSprintsRepository } from '../ports/find-sprints.repository';
|
||||
import { FindTimelineRepository } from '../ports/find-timeline.repository';
|
||||
import { PresenceStore } from '../ports/presence.store';
|
||||
import { PRESENCE_TTL_MS } from '../presence.service';
|
||||
import { GetMyHomeQuery } from './get-my-home.query';
|
||||
import { guestOnly } from './read-access';
|
||||
|
||||
/** How much activity the home page lists. */
|
||||
const ACTIVITY = 30;
|
||||
|
||||
@QueryHandler(GetMyHomeQuery)
|
||||
export class GetMyHomeQueryHandler
|
||||
implements IQueryHandler<GetMyHomeQuery, HomeReadModel>
|
||||
{
|
||||
constructor(
|
||||
private readonly findProjects: FindProjectsRepository,
|
||||
private readonly findIssues: FindIssuesRepository,
|
||||
private readonly findTimeline: FindTimelineRepository,
|
||||
private readonly findSprints: FindSprintsRepository,
|
||||
private readonly findReleases: FindReleasesRepository,
|
||||
private readonly findActivity: FindActivityRepository,
|
||||
private readonly feeds: CalendarFeedsRepository,
|
||||
private readonly presence: PresenceStore,
|
||||
) {}
|
||||
|
||||
async execute(query: GetMyHomeQuery): Promise<HomeReadModel> {
|
||||
const me = query.viewerId;
|
||||
const now = new Date();
|
||||
const projects = (await this.findProjects.findByMember(me)).filter(
|
||||
(p) => !p.archivedAt,
|
||||
);
|
||||
// Project-wide views only of the projects I see whole (not as a guest).
|
||||
const whole = projects.filter((p) => !guestOnly(p, me));
|
||||
const days = lastDays(now);
|
||||
const [inputs, activity, counts, feed, online] = await Promise.all([
|
||||
Promise.all(
|
||||
projects.map(async (project) => {
|
||||
const [issues, timeline, sprints, releases] = await Promise.all([
|
||||
this.findIssues.findByProject(project.id),
|
||||
this.findTimeline.findByProject(project.id),
|
||||
this.findSprints.findByProject(project.id),
|
||||
this.findReleases.findByProject(project.id),
|
||||
]);
|
||||
return { project, issues, timeline, sprints, releases };
|
||||
}),
|
||||
),
|
||||
this.findActivity.findAcross(
|
||||
whole.map((p) => p.id),
|
||||
ACTIVITY,
|
||||
),
|
||||
this.findActivity.dailyCounts(
|
||||
whole.map((p) => p.id),
|
||||
days[0],
|
||||
),
|
||||
this.feeds.findByUser(me),
|
||||
Promise.all(
|
||||
whole.map(async (project) =>
|
||||
(
|
||||
await this.presence.listProject(
|
||||
project.id,
|
||||
now.getTime() - PRESENCE_TTL_MS,
|
||||
)
|
||||
)
|
||||
.filter((entry) => entry.user.id !== me)
|
||||
.map(
|
||||
(entry): OnlineReadModel => ({
|
||||
user: entry.user,
|
||||
projectKey: project.key,
|
||||
issueKey: entry.issue?.key ?? null,
|
||||
issueTitle: entry.issue?.title ?? null,
|
||||
editing: entry.editing,
|
||||
at: entry.at,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
]);
|
||||
const perDay = (projectId: string) =>
|
||||
days.map(
|
||||
(day) =>
|
||||
counts.find((c) => c.projectId === projectId && c.day === day)
|
||||
?.count ?? 0,
|
||||
);
|
||||
return computeHome({
|
||||
viewerId: me,
|
||||
projects: inputs.map(
|
||||
(i): HomeProjectInput => ({ ...i, activity: perDay(i.project.id) }),
|
||||
),
|
||||
activity,
|
||||
hasCalendar: !!feed,
|
||||
// One line per person: the latest ticket they are on.
|
||||
online: online
|
||||
.flat()
|
||||
.sort((a, b) => b.at.localeCompare(a.at))
|
||||
.filter(
|
||||
(entry, i, all) =>
|
||||
all.findIndex((e) => e.user.id === entry.user.id) === i,
|
||||
),
|
||||
now,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Query } from '@nestjs/cqrs';
|
||||
import { HomeReadModel } from '@task-stream/api/tracker/domain';
|
||||
|
||||
/**
|
||||
* The home page beyond «my work»: agenda, activity, the week, sprints,
|
||||
* project cards, suggestions, first steps and who is on what now.
|
||||
*/
|
||||
export class GetMyHomeQuery extends Query<HomeReadModel> {
|
||||
constructor(public readonly viewerId: string) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
DEFAULT_PROJECT_SETTINGS,
|
||||
PortfolioProjectReadModel,
|
||||
ProjectReadModel,
|
||||
SprintStatus,
|
||||
StatusCategory,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { computeInsights } from '../analytics/insights';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
import { FindReleasesRepository } from '../ports/find-releases.repository';
|
||||
import { FindSprintsRepository } from '../ports/find-sprints.repository';
|
||||
import { FindTimelineRepository } from '../ports/find-timeline.repository';
|
||||
import { GetPortfolioQuery } from './get-portfolio.query';
|
||||
import { guestOnly } from './read-access';
|
||||
|
||||
const FINISHED = [StatusCategory.DONE, StatusCategory.CANCELLED];
|
||||
/** Epics shown per project. */
|
||||
const EPICS = 6;
|
||||
|
||||
@QueryHandler(GetPortfolioQuery)
|
||||
export class GetPortfolioQueryHandler
|
||||
implements IQueryHandler<GetPortfolioQuery, PortfolioProjectReadModel[]>
|
||||
{
|
||||
constructor(
|
||||
private readonly findProjects: FindProjectsRepository,
|
||||
private readonly findIssues: FindIssuesRepository,
|
||||
private readonly findTimeline: FindTimelineRepository,
|
||||
private readonly findSprints: FindSprintsRepository,
|
||||
private readonly findReleases: FindReleasesRepository,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
query: GetPortfolioQuery,
|
||||
): Promise<PortfolioProjectReadModel[]> {
|
||||
const projects = (await this.findProjects.findByMember(query.viewerId))
|
||||
.filter((p) => !p.archivedAt && !guestOnly(p, query.viewerId))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'es'));
|
||||
return Promise.all(projects.map((p) => this.summary(p)));
|
||||
}
|
||||
|
||||
private async summary(
|
||||
project: ProjectReadModel,
|
||||
): Promise<PortfolioProjectReadModel> {
|
||||
const [issues, timeline, sprints, releases, epics] = await Promise.all([
|
||||
this.findIssues.findByProject(project.id),
|
||||
this.findTimeline.findByProject(project.id),
|
||||
this.findSprints.findByProject(project.id),
|
||||
this.findReleases.findByProject(project.id),
|
||||
this.findIssues.epics(project.id),
|
||||
]);
|
||||
const reading = computeInsights({
|
||||
issues,
|
||||
timeline,
|
||||
settings: project.settings ?? DEFAULT_PROJECT_SETTINGS,
|
||||
activeSprint:
|
||||
sprints.find((s) => s.status === SprintStatus.ACTIVE) ?? null,
|
||||
sprints,
|
||||
releases,
|
||||
members: project.members,
|
||||
since: null,
|
||||
mentions: 0,
|
||||
now: new Date(),
|
||||
});
|
||||
const live = issues.filter((i) => !i.trashedAt);
|
||||
return {
|
||||
projectId: project.id,
|
||||
key: project.key,
|
||||
name: project.name,
|
||||
isPublic: project.isPublic,
|
||||
total: live.length,
|
||||
open: live.filter((i) => !FINISHED.includes(i.statusCategory)).length,
|
||||
overdue: reading.risks.overdueCount,
|
||||
blocked: reading.risks.blockedCount,
|
||||
stuck: reading.risks.stuckCount,
|
||||
sprint: reading.sprint,
|
||||
releases: reading.releases,
|
||||
epics: epics
|
||||
.filter(
|
||||
(e) =>
|
||||
!FINISHED.includes(e.issue.statusCategory) && !e.issue.trashedAt,
|
||||
)
|
||||
.sort((a, b) =>
|
||||
(a.issue.dueDate ?? '9999').localeCompare(b.issue.dueDate ?? '9999'),
|
||||
)
|
||||
.slice(0, EPICS),
|
||||
updatedAt: project.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Query } from '@nestjs/cqrs';
|
||||
import { PortfolioProjectReadModel } from '@task-stream/api/tracker/domain';
|
||||
|
||||
/**
|
||||
* Every active project I take full part in (not archived, not as a guest),
|
||||
* at a glance: open work, risks, sprint, releases and epics.
|
||||
*/
|
||||
export class GetPortfolioQuery extends Query<PortfolioProjectReadModel[]> {
|
||||
constructor(public readonly viewerId: string) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export class GetProjectAnalyticsQueryHandler
|
||||
async execute(
|
||||
query: GetProjectAnalyticsQuery,
|
||||
): Promise<ProjectAnalyticsReadModel> {
|
||||
await this.access.assertCanView(query.projectId, query.viewerId);
|
||||
await this.access.assertSeesWholeProject(query.projectId, query.viewerId);
|
||||
const [project, issues, timeline] = await Promise.all([
|
||||
this.findProjects.findById(query.projectId),
|
||||
this.findIssues.findByProject(query.projectId),
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
DEFAULT_PROJECT_SETTINGS,
|
||||
NotificationKind,
|
||||
ProjectInsightsReadModel,
|
||||
SprintStatus,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { computeInsights } from '../analytics/insights';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
import { FindReleasesRepository } from '../ports/find-releases.repository';
|
||||
import { NotificationsRepository } from '../ports/notifications.repository';
|
||||
import { FindSprintsRepository } from '../ports/find-sprints.repository';
|
||||
import { FindTimelineRepository } from '../ports/find-timeline.repository';
|
||||
import { GetProjectInsightsQuery } from './get-project-insights.query';
|
||||
import { ReadAccess } from './read-access';
|
||||
|
||||
@QueryHandler(GetProjectInsightsQuery)
|
||||
export class GetProjectInsightsQueryHandler
|
||||
implements IQueryHandler<GetProjectInsightsQuery, ProjectInsightsReadModel>
|
||||
{
|
||||
constructor(
|
||||
private readonly access: ReadAccess,
|
||||
private readonly findProjects: FindProjectsRepository,
|
||||
private readonly findIssues: FindIssuesRepository,
|
||||
private readonly findTimeline: FindTimelineRepository,
|
||||
private readonly findSprints: FindSprintsRepository,
|
||||
private readonly findReleases: FindReleasesRepository,
|
||||
private readonly notifications: NotificationsRepository,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
query: GetProjectInsightsQuery,
|
||||
): Promise<ProjectInsightsReadModel> {
|
||||
await this.access.assertSeesWholeProject(query.projectId, query.viewerId);
|
||||
const [project, issues, timeline, sprints, releases, mentions] =
|
||||
await Promise.all([
|
||||
this.findProjects.findById(query.projectId),
|
||||
this.findIssues.findByProject(query.projectId),
|
||||
this.findTimeline.findByProject(query.projectId),
|
||||
this.findSprints.findByProject(query.projectId),
|
||||
this.findReleases.findByProject(query.projectId),
|
||||
this.mentionsSince(query),
|
||||
]);
|
||||
return computeInsights({
|
||||
issues,
|
||||
timeline,
|
||||
settings: project?.settings ?? DEFAULT_PROJECT_SETTINGS,
|
||||
activeSprint:
|
||||
sprints.find((s) => s.status === SprintStatus.ACTIVE) ?? null,
|
||||
sprints,
|
||||
releases,
|
||||
members: project?.members ?? [],
|
||||
since: query.since,
|
||||
mentions,
|
||||
now: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/** The viewer's mentions in this project after `since` (latest 200). */
|
||||
private async mentionsSince(query: GetProjectInsightsQuery): Promise<number> {
|
||||
if (!query.since || !query.viewerId) return 0;
|
||||
const since = query.since.toISOString();
|
||||
const latest = await this.notifications.findForUser(query.viewerId, {
|
||||
unreadOnly: false,
|
||||
limit: 200,
|
||||
});
|
||||
return latest.filter(
|
||||
(n) =>
|
||||
n.projectId === query.projectId &&
|
||||
n.kind === NotificationKind.MENTIONED &&
|
||||
n.createdAt > since,
|
||||
).length;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Query } from '@nestjs/cqrs';
|
||||
import { ProjectInsightsReadModel } from '@task-stream/api/tracker/domain';
|
||||
|
||||
/**
|
||||
* The dashboard's reading of a project: sprint, risks, aging, bottlenecks,
|
||||
* release forecasts, team load, backlog health and, with `since`, what
|
||||
* changed after it.
|
||||
*/
|
||||
export class GetProjectInsightsQuery extends Query<ProjectInsightsReadModel> {
|
||||
constructor(
|
||||
/** Null: anonymous (public projects only). */
|
||||
public readonly viewerId: string | null,
|
||||
public readonly projectId: string,
|
||||
public readonly since: Date | null = null,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ export class GetProjectStatsQueryHandler
|
||||
) {}
|
||||
|
||||
async execute(query: GetProjectStatsQuery): Promise<ProjectStatsReadModel> {
|
||||
await this.access.assertCanView(query.projectId, query.viewerId);
|
||||
await this.access.assertSeesWholeProject(query.projectId, query.viewerId);
|
||||
return this.findIssues.stats(query.projectId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,10 +24,14 @@ export class GetReleaseBurnupQueryHandler
|
||||
'Release',
|
||||
query.releaseId,
|
||||
);
|
||||
await this.access.assertCanView(release.projectId, query.viewerId, {
|
||||
await this.access.assertSeesWholeProject(
|
||||
release.projectId,
|
||||
query.viewerId,
|
||||
{
|
||||
entity: 'Release',
|
||||
ref: query.releaseId,
|
||||
});
|
||||
},
|
||||
);
|
||||
const [issues, timeline] = await Promise.all([
|
||||
this.findIssues.findByProject(release.projectId),
|
||||
this.findTimeline.findByProject(release.projectId),
|
||||
|
||||
@@ -36,10 +36,14 @@ export class GetReleaseNotesDraftQueryHandler
|
||||
'Release',
|
||||
query.releaseId,
|
||||
);
|
||||
await this.access.assertCanView(release.projectId, query.viewerId, {
|
||||
await this.access.assertSeesWholeProject(
|
||||
release.projectId,
|
||||
query.viewerId,
|
||||
{
|
||||
entity: 'Release',
|
||||
ref: query.releaseId,
|
||||
});
|
||||
},
|
||||
);
|
||||
const [page, project] = await Promise.all([
|
||||
this.findIssues.search({
|
||||
projectId: release.projectId,
|
||||
|
||||
@@ -23,10 +23,14 @@ export class GetReleaseProgressQueryHandler
|
||||
'Release',
|
||||
query.releaseId,
|
||||
);
|
||||
await this.access.assertCanView(release.projectId, query.viewerId, {
|
||||
await this.access.assertSeesWholeProject(
|
||||
release.projectId,
|
||||
query.viewerId,
|
||||
{
|
||||
entity: 'Release',
|
||||
ref: query.releaseId,
|
||||
});
|
||||
},
|
||||
);
|
||||
return this.findIssues.releaseProgress(release.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,10 +19,14 @@ export class GetReleaseQueryHandler
|
||||
'Release',
|
||||
query.releaseId,
|
||||
);
|
||||
await this.access.assertCanView(release.projectId, query.viewerId, {
|
||||
await this.access.assertSeesWholeProject(
|
||||
release.projectId,
|
||||
query.viewerId,
|
||||
{
|
||||
entity: 'Release',
|
||||
ref: query.releaseId,
|
||||
});
|
||||
},
|
||||
);
|
||||
return release;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export class GetReleasesQueryHandler
|
||||
) {}
|
||||
|
||||
async execute(query: GetReleasesQuery): Promise<ReleaseReadModel[]> {
|
||||
await this.access.assertCanView(query.projectId, query.viewerId);
|
||||
await this.access.assertSeesWholeProject(query.projectId, query.viewerId);
|
||||
return this.findReleases.findByProject(query.projectId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import { GetTicketScopeQuery } from './get-ticket-scope.query';
|
||||
import { ReadAccess } from './read-access';
|
||||
|
||||
@QueryHandler(GetTicketScopeQuery)
|
||||
export class GetTicketScopeQueryHandler
|
||||
implements IQueryHandler<GetTicketScopeQuery, string | null>
|
||||
{
|
||||
constructor(private readonly access: ReadAccess) {}
|
||||
|
||||
execute(query: GetTicketScopeQuery): Promise<string | null> {
|
||||
return this.access.participantScope(query.projectId, query.viewerId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Query } from '@nestjs/cqrs';
|
||||
|
||||
/**
|
||||
* Whose tickets the viewer sees of a project: null for all; their own id
|
||||
* for a guest of a private project (only the tickets they take part in).
|
||||
*/
|
||||
export class GetTicketScopeQuery extends Query<string | null> {
|
||||
constructor(
|
||||
public readonly viewerId: string | null,
|
||||
public readonly projectId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ export class GetTrashQueryHandler
|
||||
) {}
|
||||
|
||||
async execute(query: GetTrashQuery): Promise<IssueReadModel[]> {
|
||||
await this.access.assertCanView(query.projectId, query.viewerId);
|
||||
await this.access.assertSeesWholeProject(query.projectId, query.viewerId);
|
||||
return this.findIssues.findTrashed(query.projectId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { guestOnly } from './read-access';
|
||||
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import { ReleaseReadModel } from '@task-stream/api/tracker/domain';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
@@ -15,8 +16,11 @@ export class GetUpcomingReleasesQueryHandler
|
||||
|
||||
async execute(query: GetUpcomingReleasesQuery): Promise<ReleaseReadModel[]> {
|
||||
const projects = await this.findProjects.findByMember(query.viewerId);
|
||||
// A guest does not see a private project's releases.
|
||||
return this.findReleases.findUpcoming(
|
||||
projects.map((project) => project.id),
|
||||
projects
|
||||
.filter((project) => !guestOnly(project, query.viewerId))
|
||||
.map((project) => project.id),
|
||||
Math.min(query.limit, 50),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@ 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-insights.query';
|
||||
export * from './get-ticket-scope.query';
|
||||
export * from './get-portfolio.query';
|
||||
export * from './get-my-home.query';
|
||||
export * from './get-project-stats.query';
|
||||
export * from './get-release-burnup.query';
|
||||
export * from './get-project.query';
|
||||
|
||||
@@ -9,6 +9,10 @@ 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 { GetProjectInsightsQueryHandler } from './get-project-insights.query-handler';
|
||||
import { GetTicketScopeQueryHandler } from './get-ticket-scope.query-handler';
|
||||
import { GetPortfolioQueryHandler } from './get-portfolio.query-handler';
|
||||
import { GetMyHomeQueryHandler } from './get-my-home.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';
|
||||
@@ -34,6 +38,10 @@ export const QUERY_HANDLERS = [
|
||||
GetMyProjectsQueryHandler,
|
||||
GetProjectQueryHandler,
|
||||
GetProjectAnalyticsQueryHandler,
|
||||
GetProjectInsightsQueryHandler,
|
||||
GetTicketScopeQueryHandler,
|
||||
GetPortfolioQueryHandler,
|
||||
GetMyHomeQueryHandler,
|
||||
GetProjectStatsQueryHandler,
|
||||
GetReleaseBurnupQueryHandler,
|
||||
GetReleaseQueryHandler,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { NotFoundError } from '@task-stream/api/shared/domain';
|
||||
import { ProjectRole } from '@task-stream/api/tracker/domain';
|
||||
import { ForbiddenError, NotFoundError } from '@task-stream/api/shared/domain';
|
||||
import {
|
||||
IssueReadModel,
|
||||
ProjectReadModel,
|
||||
ProjectRole,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
|
||||
/**
|
||||
@@ -35,6 +39,67 @@ export class ReadAccess {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whose tickets the viewer sees of the project: null for all of them; for
|
||||
* a guest of a private project, their own id (only the tickets they take
|
||||
* part in).
|
||||
*/
|
||||
async participantScope(
|
||||
projectId: string,
|
||||
viewerId: string | null,
|
||||
): Promise<string | null> {
|
||||
if (!viewerId) return null;
|
||||
const role = await this.findProjects.findRole(projectId, viewerId);
|
||||
if (role !== ProjectRole.GUEST) return null;
|
||||
const project = await this.findProjects.findById(projectId);
|
||||
return project?.isPublic ? null : viewerId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Views of the whole project (activity, statistics, releases, sprints…):
|
||||
* not for a guest, who only sees their own tickets.
|
||||
*/
|
||||
async assertSeesWholeProject(
|
||||
projectId: string,
|
||||
viewerId: string | null,
|
||||
asked?: { entity: string; ref: string },
|
||||
): Promise<ProjectRole | null> {
|
||||
const role = await this.assertCanView(projectId, viewerId, asked);
|
||||
if (await this.participantScope(projectId, viewerId))
|
||||
throw new ForbiddenError(
|
||||
'Un invitado solo ve los tickets en los que participa',
|
||||
);
|
||||
return role;
|
||||
}
|
||||
|
||||
/** The ticket, if the viewer may read it (a guest: only theirs). */
|
||||
async assertCanViewIssue(
|
||||
issue: IssueReadModel,
|
||||
viewerId: string | null,
|
||||
asked: { entity: string; ref: string } = {
|
||||
entity: 'Issue',
|
||||
ref: issue.key,
|
||||
},
|
||||
): Promise<void> {
|
||||
await this.assertCanView(issue.projectId, viewerId, asked);
|
||||
const scope = await this.participantScope(issue.projectId, viewerId);
|
||||
if (scope && !involves(issue, scope))
|
||||
throw new NotFoundError(asked.entity, asked.ref);
|
||||
}
|
||||
|
||||
async canViewIssue(
|
||||
issue: IssueReadModel,
|
||||
viewerId: string | null,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await this.assertCanViewIssue(issue, viewerId);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof NotFoundError) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async canView(projectId: string, viewerId: string | null): Promise<boolean> {
|
||||
try {
|
||||
await this.assertCanView(projectId, viewerId);
|
||||
@@ -51,5 +116,24 @@ export class ReadAccess {
|
||||
}
|
||||
}
|
||||
|
||||
/** Opened it, has it or follows it. */
|
||||
export function involves(issue: IssueReadModel, userId: string): boolean {
|
||||
return (
|
||||
issue.reporter?.id === userId ||
|
||||
issue.assignee?.id === userId ||
|
||||
(issue.watchers ?? []).some((w) => w.id === userId)
|
||||
);
|
||||
}
|
||||
|
||||
/** A guest of this private project: sees only the tickets they take part in. */
|
||||
export function guestOnly(project: ProjectReadModel, userId: string): boolean {
|
||||
return (
|
||||
!project.isPublic &&
|
||||
project.members.some(
|
||||
(m) => m.user.id === userId && m.role === ProjectRole.GUEST,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export const UUID =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
@@ -20,6 +20,11 @@ export class SearchIssuesQueryHandler
|
||||
if (watching && !query.viewerId) return { items: [], total: 0 };
|
||||
return this.findIssues.search({
|
||||
...filter,
|
||||
participantId:
|
||||
(await this.access.participantScope(
|
||||
query.filter.projectId,
|
||||
query.viewerId,
|
||||
)) ?? undefined,
|
||||
watcherId: watching ? (query.viewerId ?? undefined) : undefined,
|
||||
limit: Math.min(query.filter.limit, 200),
|
||||
});
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import { IssueReadModel } from '@task-stream/api/tracker/domain';
|
||||
import {
|
||||
DEFAULT_PROJECT_SETTINGS,
|
||||
IssueReadModel,
|
||||
StatusCategory,
|
||||
} 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';
|
||||
import { guestOnly } from './read-access';
|
||||
|
||||
@QueryHandler(SearchMyIssuesQuery)
|
||||
export class SearchMyIssuesQueryHandler
|
||||
@@ -15,13 +20,48 @@ export class SearchMyIssuesQueryHandler
|
||||
|
||||
/** 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);
|
||||
const { filter } = query;
|
||||
const projects = (
|
||||
await this.findProjects.findByMember(query.viewerId)
|
||||
).filter(
|
||||
(p) => !filter.projectIds?.length || filter.projectIds.includes(p.id),
|
||||
);
|
||||
// Bugs are each project's types drawn with the bug icon.
|
||||
const bugTypes = filter.bugsOnly
|
||||
? Object.fromEntries(
|
||||
projects.map((p) => [
|
||||
p.id,
|
||||
(p.settings ?? DEFAULT_PROJECT_SETTINGS).issueTypes
|
||||
.filter((t) => t.icon === 'bug')
|
||||
.map((t) => t.id),
|
||||
]),
|
||||
)
|
||||
: undefined;
|
||||
const open = [
|
||||
StatusCategory.TODO,
|
||||
StatusCategory.IN_PROGRESS,
|
||||
StatusCategory.QA,
|
||||
];
|
||||
const guestProjectIds = projects
|
||||
.filter((p) => guestOnly(p, query.viewerId))
|
||||
.map((p) => p.id);
|
||||
return this.findIssues.searchAcross({
|
||||
projectIds: projects.map((project) => project.id),
|
||||
guestProjectIds,
|
||||
participantId: query.viewerId,
|
||||
priorities: filter.priorities?.length ? filter.priorities : undefined,
|
||||
typesByProject: bugTypes,
|
||||
dueBefore: filter.overdue
|
||||
? new Date().toISOString().slice(0, 10)
|
||||
: undefined,
|
||||
assigneeId: query.filter.assignedToMe ? query.viewerId : undefined,
|
||||
watcherId: query.filter.watching ? query.viewerId : undefined,
|
||||
statuses: query.filter.statuses,
|
||||
categories: query.filter.categories,
|
||||
categories: filter.overdue
|
||||
? (filter.categories?.length ? filter.categories : open).filter((c) =>
|
||||
open.includes(c),
|
||||
)
|
||||
: query.filter.categories,
|
||||
search: query.filter.search?.trim().slice(0, 200) || undefined,
|
||||
limit: Math.min(query.filter.limit, 200),
|
||||
offset: query.filter.offset,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Query } from '@nestjs/cqrs';
|
||||
import {
|
||||
IssuePriority,
|
||||
IssueReadModel,
|
||||
StatusCategory,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
@@ -15,6 +16,13 @@ export interface MyIssuesFilter {
|
||||
categories?: StatusCategory[];
|
||||
/** Matches key, title or description. */
|
||||
search?: string;
|
||||
/** Only these of my projects. */
|
||||
projectIds?: string[];
|
||||
priorities?: IssuePriority[];
|
||||
/** Only tickets of a type drawn with the bug icon (in each project). */
|
||||
bugsOnly?: boolean;
|
||||
/** Only open tickets past their due date. */
|
||||
overdue?: boolean;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ export class GetSprintsQueryHandler
|
||||
) {}
|
||||
|
||||
async execute(query: GetSprintsQuery): Promise<SprintReadModel[]> {
|
||||
await this.access.assertCanView(query.projectId, query.viewerId);
|
||||
await this.access.assertSeesWholeProject(query.projectId, query.viewerId);
|
||||
return this.findSprints.findByProject(query.projectId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CommandRunner } from '@task-stream/api/shared/application';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import { QueryBus } from '@nestjs/cqrs';
|
||||
import {
|
||||
ReleaseBurnupReadModel,
|
||||
ReleaseProgressReadModel,
|
||||
@@ -26,7 +27,7 @@ interface ReleaseDetails {
|
||||
@Injectable()
|
||||
export class ReleasesService {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly commands: CommandRunner,
|
||||
private readonly queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
@@ -34,7 +35,7 @@ export class ReleasesService {
|
||||
actorId: string,
|
||||
input: ReleaseDetails & { projectId: string; name: string },
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new CreateReleaseCommand(
|
||||
actorId,
|
||||
input.projectId,
|
||||
@@ -50,7 +51,7 @@ export class ReleasesService {
|
||||
releaseId: string,
|
||||
changes: ReleaseDetails,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new UpdateReleaseCommand(actorId, releaseId, changes),
|
||||
);
|
||||
}
|
||||
@@ -61,7 +62,7 @@ export class ReleasesService {
|
||||
releaseId: string,
|
||||
force = false,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new PublishReleaseCommand(actorId, releaseId, force),
|
||||
);
|
||||
}
|
||||
@@ -72,7 +73,7 @@ export class ReleasesService {
|
||||
releaseId: string,
|
||||
moveTo: string | null = null,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new ArchiveReleaseCommand(actorId, releaseId, moveTo),
|
||||
);
|
||||
}
|
||||
@@ -95,7 +96,7 @@ export class ReleasesService {
|
||||
releaseId: string,
|
||||
notes: string | null,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new WriteReleaseNotesCommand(actorId, releaseId, notes),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { CommandRunner } from '@task-stream/api/shared/application';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import { QueryBus } from '@nestjs/cqrs';
|
||||
import {
|
||||
MyViewCriteriaReadModel,
|
||||
MyViewReadModel,
|
||||
SavedFilterCriteriaReadModel,
|
||||
SavedFilterReadModel,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import {
|
||||
DeleteMyViewCommand,
|
||||
GetMyViewsQuery,
|
||||
SaveMyViewCommand,
|
||||
} from './saved-filters/my-views';
|
||||
import {
|
||||
DeleteSavedFilterCommand,
|
||||
GetSavedFiltersQuery,
|
||||
@@ -17,11 +25,11 @@ export interface SaveFilterInput {
|
||||
criteria: SavedFilterCriteriaReadModel;
|
||||
}
|
||||
|
||||
/** Saved board views: mine and the ones shared with the project. */
|
||||
/** Saved views: the board's (mine and shared) and my own of «my work». */
|
||||
@Injectable()
|
||||
export class SavedFiltersService {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly commands: CommandRunner,
|
||||
private readonly queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
@@ -30,7 +38,7 @@ export class SavedFiltersService {
|
||||
}
|
||||
|
||||
save(actorId: string, input: SaveFilterInput): Promise<{ id: string }> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new SaveFilterCommand(
|
||||
actorId,
|
||||
input.projectId,
|
||||
@@ -42,8 +50,26 @@ export class SavedFiltersService {
|
||||
}
|
||||
|
||||
delete(actorId: string, filterId: string): Promise<void> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new DeleteSavedFilterCommand(actorId, filterId),
|
||||
);
|
||||
}
|
||||
|
||||
/** My views of «my work», by name. */
|
||||
myViews(ownerId: string): Promise<MyViewReadModel[]> {
|
||||
return this.queryBus.execute(new GetMyViewsQuery(ownerId));
|
||||
}
|
||||
|
||||
saveMyView(
|
||||
ownerId: string,
|
||||
input: { name: string; criteria: MyViewCriteriaReadModel },
|
||||
): Promise<{ id: string }> {
|
||||
return this.commands.execute(
|
||||
new SaveMyViewCommand(ownerId, input.name, input.criteria),
|
||||
);
|
||||
}
|
||||
|
||||
deleteMyView(ownerId: string, viewId: string): Promise<void> {
|
||||
return this.commands.execute(new DeleteMyViewCommand(ownerId, viewId));
|
||||
}
|
||||
}
|
||||
|
||||
133
libs/api/tracker/application/src/lib/saved-filters/my-views.ts
Normal file
133
libs/api/tracker/application/src/lib/saved-filters/my-views.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
Command,
|
||||
CommandHandler,
|
||||
ICommandHandler,
|
||||
IQueryHandler,
|
||||
Query,
|
||||
QueryHandler,
|
||||
} from '@nestjs/cqrs';
|
||||
import {
|
||||
BusinessRuleError,
|
||||
NotFoundError,
|
||||
assertThat,
|
||||
} from '@task-stream/api/shared/domain';
|
||||
import {
|
||||
MyViewCriteriaReadModel,
|
||||
MyViewReadModel,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { MyViewsRepository } from '../ports/my-views.repository';
|
||||
|
||||
/*
|
||||
* Saved views of «my work»: across my projects, private, no history (like
|
||||
* the board's saved views and notification settings).
|
||||
*/
|
||||
|
||||
/** A person keeps at most this many. */
|
||||
export const MAX_MY_VIEWS = 20;
|
||||
|
||||
export class SaveMyViewCommand extends Command<{ id: string }> {
|
||||
constructor(
|
||||
public readonly ownerId: string,
|
||||
public readonly name: string,
|
||||
public readonly criteria: MyViewCriteriaReadModel,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class DeleteMyViewCommand extends Command<void> {
|
||||
constructor(
|
||||
public readonly ownerId: string,
|
||||
public readonly viewId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class GetMyViewsQuery extends Query<MyViewReadModel[]> {
|
||||
constructor(public readonly ownerId: string) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/** Drops empty criteria, trims the text and deduplicates lists. */
|
||||
export function normalizeMyView(
|
||||
criteria: MyViewCriteriaReadModel,
|
||||
): MyViewCriteriaReadModel {
|
||||
const list = <T extends string>(values?: T[] | null) =>
|
||||
values?.length ? [...new Set(values)] : undefined;
|
||||
const out: MyViewCriteriaReadModel = {
|
||||
projectIds: list(criteria.projectIds),
|
||||
assignedToMe: criteria.assignedToMe || undefined,
|
||||
watching: criteria.watching || undefined,
|
||||
categories: list(criteria.categories),
|
||||
priorities: list(criteria.priorities),
|
||||
bugsOnly: criteria.bugsOnly || undefined,
|
||||
overdue: criteria.overdue || undefined,
|
||||
search: criteria.search?.trim().slice(0, 200) || undefined,
|
||||
};
|
||||
return Object.fromEntries(
|
||||
Object.entries(out).filter(([, value]) => value !== undefined),
|
||||
) as MyViewCriteriaReadModel;
|
||||
}
|
||||
|
||||
@CommandHandler(SaveMyViewCommand)
|
||||
export class SaveMyViewCommandHandler
|
||||
implements ICommandHandler<SaveMyViewCommand>
|
||||
{
|
||||
constructor(private readonly views: MyViewsRepository) {}
|
||||
|
||||
async execute(command: SaveMyViewCommand): Promise<{ id: string }> {
|
||||
const name = command.name.trim();
|
||||
assertThat(
|
||||
name.length > 0 && name.length <= 60,
|
||||
'A view needs a name (up to 60 characters)',
|
||||
);
|
||||
if ((await this.views.countByOwner(command.ownerId)) >= MAX_MY_VIEWS)
|
||||
throw new BusinessRuleError(
|
||||
`You already keep ${MAX_MY_VIEWS} views; delete one first`,
|
||||
);
|
||||
const id = randomUUID();
|
||||
await this.views.insert({
|
||||
id,
|
||||
ownerId: command.ownerId,
|
||||
name,
|
||||
criteria: normalizeMyView(command.criteria),
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
return { id };
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(DeleteMyViewCommand)
|
||||
export class DeleteMyViewCommandHandler
|
||||
implements ICommandHandler<DeleteMyViewCommand>
|
||||
{
|
||||
constructor(private readonly views: MyViewsRepository) {}
|
||||
|
||||
async execute(command: DeleteMyViewCommand): Promise<void> {
|
||||
const view = await this.views.findById(command.viewId);
|
||||
// Someone else's view does not exist, as far as this person knows.
|
||||
if (!view || view.ownerId !== command.ownerId)
|
||||
throw new NotFoundError('View', command.viewId);
|
||||
await this.views.delete(view.id);
|
||||
}
|
||||
}
|
||||
|
||||
@QueryHandler(GetMyViewsQuery)
|
||||
export class GetMyViewsQueryHandler
|
||||
implements IQueryHandler<GetMyViewsQuery, MyViewReadModel[]>
|
||||
{
|
||||
constructor(private readonly views: MyViewsRepository) {}
|
||||
|
||||
execute(query: GetMyViewsQuery): Promise<MyViewReadModel[]> {
|
||||
return this.views.findByOwner(query.ownerId);
|
||||
}
|
||||
}
|
||||
|
||||
export const MY_VIEWS = [
|
||||
SaveMyViewCommandHandler,
|
||||
DeleteMyViewCommandHandler,
|
||||
GetMyViewsQueryHandler,
|
||||
];
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user