Compare commits
65 Commits
0a73eda9b2
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 41f81cd2b0 | |||
| f36c31e66d | |||
| 425db24eea | |||
| 285bd5c454 | |||
| 429b52e6f5 | |||
| 3c211f0e56 | |||
| 909edc9f4d | |||
| c27aa4b1d7 | |||
| 79b8094011 | |||
| 8ed16f133e | |||
| 3a46a236e4 | |||
| 93d028e870 | |||
| 172c53d21d | |||
| dfb5f973f4 | |||
| b88296c22b | |||
| d79ef418d3 | |||
| 179d6a5e94 | |||
| 199b60e4ce | |||
| c0b0417b6d | |||
| 33c593521e | |||
| 034704e46f | |||
| 42a21412d9 | |||
| 70e4fbd399 | |||
| 8f83fdefb6 | |||
| dfa42a8a1a | |||
| 423f82c1a3 | |||
| a32c937765 | |||
| d66b1f3ece | |||
| de9c8a7780 | |||
| 4969fb3f6d | |||
| e10c9b84bf | |||
| fc90c79f7f | |||
| 8fe581ff3e | |||
| 4dc673997d | |||
| 1101b6e926 | |||
| 9ccc3b5967 | |||
| f448207e88 | |||
| d0efaeba80 | |||
| 9044690348 | |||
| 0564810d36 | |||
| 2cbe6af878 | |||
| 2add1b6a31 | |||
| e006d245d7 | |||
| f4e40ffabc | |||
| a32b669c8b | |||
| 0946e9517d | |||
| affce615a7 | |||
| 8bf7d47b2e | |||
| f60d4c8346 | |||
| 869acd3f1f | |||
| 4acf629106 | |||
| e598f76e1c | |||
| 5b0396bc10 | |||
| e14f445e6b | |||
| 76312c079d | |||
| 0d158a2ed7 | |||
| fe03c5bda8 | |||
| 86e313efaa | |||
| e5f4e6c683 | |||
| 21bc598590 | |||
| f7e850068b | |||
| 233c46e9fa | |||
| 5ee627e689 | |||
| f45069a327 | |||
| 993f80b4b9 |
@@ -48,6 +48,13 @@ SMTP_URL=
|
||||
MAIL_FROM=
|
||||
WEB_URL=http://localhost:4200
|
||||
|
||||
# Optional push notifications (Web Push). Without both keys nothing is sent.
|
||||
# Make them once with `npx web-push generate-vapid-keys` (changing them drops
|
||||
# every device's subscription). VAPID_SUBJECT: a mailto: or https: contact.
|
||||
VAPID_PUBLIC_KEY=
|
||||
VAPID_PRIVATE_KEY=
|
||||
VAPID_SUBJECT=mailto:no-reply@pireon.dev
|
||||
|
||||
# Outgoing webhooks refuse hosts on this machine or its private network unless true.
|
||||
WEBHOOKS_ALLOW_PRIVATE=false
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ describe('task-stream (e2e)', () => {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test reads arbitrary GraphQL shapes
|
||||
async function gql<T = Record<string, any>>(
|
||||
token: string,
|
||||
token: string | null,
|
||||
query: string,
|
||||
variables?: object,
|
||||
): Promise<T> {
|
||||
@@ -63,7 +63,7 @@ describe('task-stream (e2e)', () => {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${token}`,
|
||||
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ query, variables }),
|
||||
});
|
||||
@@ -1168,4 +1168,949 @@ describe('task-stream (e2e)', () => {
|
||||
history.body.map((h: { actor: { name: string } }) => h.actor.name),
|
||||
).toContain('TaskStream');
|
||||
});
|
||||
|
||||
it('adds custom fields to a project and lets tickets hold and filter by them', async () => {
|
||||
const key = 'C' + 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 settings = project.body['settings'];
|
||||
expect(settings.customFields).toEqual([]);
|
||||
|
||||
// REST: define them (a client that leaves them out keeps them).
|
||||
const fields = [
|
||||
{
|
||||
id: 'BROWSER',
|
||||
name: 'Navegador',
|
||||
type: 'SELECT',
|
||||
options: ['Chrome', 'Safari'],
|
||||
},
|
||||
{ id: 'COST', name: 'Coste', type: 'NUMBER' },
|
||||
];
|
||||
expect(
|
||||
(
|
||||
await rest('token-ana', 'PUT', `/projects/${key}/settings`, {
|
||||
...settings,
|
||||
customFields: fields,
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
await eventually(
|
||||
() => rest('token-ana', 'GET', `/projects/${key}`),
|
||||
(r) => r.body['settings'].customFields.length === 2,
|
||||
);
|
||||
const { customFields: _left, ...older } = settings;
|
||||
expect(
|
||||
(await rest('token-ana', 'PUT', `/projects/${key}/settings`, older))
|
||||
.status,
|
||||
).toBe(200);
|
||||
|
||||
// REST: a ticket with values; a wrong one is refused.
|
||||
const created = await rest('token-ana', 'POST', `/projects/${key}/issues`, {
|
||||
type: 'BUG',
|
||||
title: 'Pago roto',
|
||||
customFields: { BROWSER: 'Safari', COST: '1,5' },
|
||||
});
|
||||
expect(created.status).toBe(201);
|
||||
const ticket = created.body['key'] as string;
|
||||
const issue = await eventually(
|
||||
() => rest('token-ana', 'GET', `/issues/${ticket}`),
|
||||
(r) => r.status === 200,
|
||||
);
|
||||
expect(issue.body['customFields']).toEqual({
|
||||
BROWSER: 'Safari',
|
||||
COST: '1.5',
|
||||
});
|
||||
expect(
|
||||
(
|
||||
await rest('token-ana', 'PATCH', `/issues/${ticket}`, {
|
||||
customFields: { BROWSER: 'Edge' },
|
||||
})
|
||||
).status,
|
||||
).toBe(400);
|
||||
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
|
||||
type: 'BUG',
|
||||
title: 'Otro',
|
||||
customFields: { BROWSER: 'Chrome' },
|
||||
});
|
||||
|
||||
// GraphQL: change one, clear another; filter by value.
|
||||
await gql(
|
||||
'token-ana',
|
||||
`mutation($i: UpdateIssueInput!) { updateIssue(input: $i) { version } }`,
|
||||
{
|
||||
i: {
|
||||
issueId: issue.body['id'],
|
||||
customFields: [
|
||||
{ fieldId: 'BROWSER', value: 'Chrome' },
|
||||
{ fieldId: 'COST', value: null },
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
const filtered = await eventually(
|
||||
() =>
|
||||
gql(
|
||||
'token-ana',
|
||||
`query($f: IssueFilterInput!) { issues(filter: $f) { total items { key customFields { fieldId value } } } }`,
|
||||
{
|
||||
f: {
|
||||
projectId: project.body['id'],
|
||||
customFields: [{ fieldId: 'BROWSER', value: 'Chrome' }],
|
||||
},
|
||||
},
|
||||
),
|
||||
(d) => d['issues'].total === 2,
|
||||
);
|
||||
expect(
|
||||
filtered['issues'].items.find((i: { key: string }) => i.key === ticket)
|
||||
.customFields,
|
||||
).toEqual([{ fieldId: 'BROWSER', value: 'Chrome' }]);
|
||||
const bySafari = await rest(
|
||||
'token-ana',
|
||||
'GET',
|
||||
`/projects/${key}/issues?field=BROWSER:Safari`,
|
||||
);
|
||||
expect(bySafari.body['total']).toBe(0);
|
||||
const history = await rest('token-ana', 'GET', `/issues/${ticket}/history`);
|
||||
expect(
|
||||
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);
|
||||
|
||||
// Activity per day (today's creations among it), the journey, no proposals.
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
expect(insights.body['heatmap']).toHaveLength(84);
|
||||
expect(
|
||||
insights.body['heatmap'].find((d: { day: string }) => d.day === today)
|
||||
?.count,
|
||||
).toBeGreaterThan(0);
|
||||
expect(insights.body['journey']).toMatchObject({ samples: 0, steps: [] });
|
||||
expect(insights.body['proposals']).toBeNull();
|
||||
|
||||
// A day of it: only what happened between after and before.
|
||||
const tomorrow = new Date(Date.parse(`${today}T00:00:00Z`) + 86_400_000);
|
||||
const ofToday = await rest(
|
||||
'token-ana',
|
||||
'GET',
|
||||
`/projects/${key}/activity?after=${today}T00:00:00.000Z&before=${tomorrow.toISOString()}&limit=200`,
|
||||
);
|
||||
expect(ofToday.body.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
ofToday.body.every((a: { occurredAt: string }) =>
|
||||
a.occurredAt.startsWith(today),
|
||||
),
|
||||
).toBe(true);
|
||||
const ofYesterday = await rest(
|
||||
'token-ana',
|
||||
'GET',
|
||||
`/projects/${key}/activity?after=2020-01-01T00:00:00.000Z&before=2020-01-02T00:00:00.000Z`,
|
||||
);
|
||||
expect(ofYesterday.body).toEqual([]);
|
||||
|
||||
// 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('shares a dashboard with clients through a secret link', async () => {
|
||||
const key = 'K' + Date.now().toString(36).slice(-5).toUpperCase();
|
||||
await rest('token-ana', 'POST', '/projects', {
|
||||
key,
|
||||
name: `Cliente ${key}`,
|
||||
});
|
||||
await eventually(
|
||||
() => rest('token-ana', 'GET', `/projects/${key}`),
|
||||
(r) => r.status === 200,
|
||||
);
|
||||
await rest('token-ana', 'POST', `/projects/${key}/members`, {
|
||||
userId: '102',
|
||||
role: 'MEMBER',
|
||||
});
|
||||
const epic = (
|
||||
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
|
||||
type: 'FEATURE',
|
||||
title: 'Pagos',
|
||||
})
|
||||
).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,
|
||||
});
|
||||
|
||||
expect(
|
||||
(await rest('token-ana', 'GET', `/projects/${key}/share`)).status,
|
||||
).toBe(204);
|
||||
// Only known blocks are kept; a member cannot share.
|
||||
const made = await rest('token-ana', 'PUT', `/projects/${key}/share`, {
|
||||
blocks: ['epics', 'flow', 'nope'],
|
||||
});
|
||||
expect(made.status).toBe(200);
|
||||
expect(made.body['blocks']).toEqual(['flow', 'epics']);
|
||||
const token = made.body['token'] as string;
|
||||
expect(token.length).toBeGreaterThan(30);
|
||||
expect(
|
||||
(
|
||||
await rest('token-bob', 'PUT', `/projects/${key}/share`, {
|
||||
blocks: ['flow'],
|
||||
})
|
||||
).status,
|
||||
).toBe(403);
|
||||
|
||||
// Anyone with the link sees the chosen blocks, nothing else.
|
||||
const seen = await eventually(
|
||||
() => rest(null, 'GET', `/shared/dashboards/${token}`),
|
||||
(r) => r.status === 200 && r.body['epics']?.[0]?.total === 1,
|
||||
);
|
||||
expect(seen.body).toMatchObject({
|
||||
projectKey: key,
|
||||
sprint: null,
|
||||
releases: null,
|
||||
activity: null,
|
||||
});
|
||||
expect(
|
||||
seen.body['flow'].reduce(
|
||||
(n: number, f: { count: number }) => n + f.count,
|
||||
0,
|
||||
),
|
||||
).toBe(2);
|
||||
expect(seen.body['epics'][0]).toMatchObject({
|
||||
key: epic,
|
||||
title: 'Pagos',
|
||||
done: 0,
|
||||
});
|
||||
const viaGql = await gql(
|
||||
null,
|
||||
`query($t: String!) { sharedDashboard(token: $t) { projectKey blocks } }`,
|
||||
{ t: token },
|
||||
);
|
||||
expect(viaGql.sharedDashboard).toEqual({
|
||||
projectKey: key,
|
||||
blocks: ['flow', 'epics'],
|
||||
});
|
||||
|
||||
// Changing blocks keeps the link; renewing it kills the old one.
|
||||
const changed = await rest('token-ana', 'PUT', `/projects/${key}/share`, {
|
||||
blocks: ['activity'],
|
||||
});
|
||||
expect(changed.body['token']).toBe(token);
|
||||
const renewed = await rest('token-ana', 'PUT', `/projects/${key}/share`, {
|
||||
blocks: ['activity'],
|
||||
renew: true,
|
||||
});
|
||||
expect(renewed.body['token']).not.toBe(token);
|
||||
expect(
|
||||
(await rest(null, 'GET', `/shared/dashboards/${token}`)).status,
|
||||
).toBe(404);
|
||||
const fresh = await rest(
|
||||
null,
|
||||
'GET',
|
||||
`/shared/dashboards/${renewed.body['token']}`,
|
||||
);
|
||||
expect(fresh.body['activity']).toHaveLength(84);
|
||||
expect(fresh.body['flow']).toBeNull();
|
||||
|
||||
// Off: the link stops working.
|
||||
expect(
|
||||
(await rest('token-ana', 'DELETE', `/projects/${key}/share`)).status,
|
||||
).toBe(204);
|
||||
expect(
|
||||
(await rest(null, 'GET', `/shared/dashboards/${renewed.body['token']}`))
|
||||
.status,
|
||||
).toBe(404);
|
||||
});
|
||||
|
||||
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
@@ -31,6 +31,7 @@
|
||||
"rxjs": "^7.8.0",
|
||||
"tslib": "^2.8.1",
|
||||
"typeorm": "^0.3.31",
|
||||
"web-push": "3.6.7",
|
||||
"ws": "^8.22.0"
|
||||
}
|
||||
}
|
||||
|
||||
1035
apps/api/schema.gql
1035
apps/api/schema.gql
File diff suppressed because it is too large
Load Diff
@@ -52,6 +52,11 @@ export const configSchema = Joi.object({
|
||||
// E-mail copies of notifications (optional): without SMTP_URL nothing is sent.
|
||||
SMTP_URL: Joi.string().allow('').optional(),
|
||||
MAIL_FROM: Joi.string().allow('').optional(),
|
||||
// Push notifications (optional): without both VAPID keys nothing is sent.
|
||||
// Make them once with `npx web-push generate-vapid-keys`.
|
||||
VAPID_PUBLIC_KEY: Joi.string().allow('').optional(),
|
||||
VAPID_PRIVATE_KEY: Joi.string().allow('').optional(),
|
||||
VAPID_SUBJECT: Joi.string().allow('').optional(),
|
||||
// Public address of the web app, for links in e-mails.
|
||||
WEB_URL: Joi.string().allow('').optional(),
|
||||
});
|
||||
|
||||
@@ -12,7 +12,11 @@ import {
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { Public } from '@task-stream/api/identity/presenters';
|
||||
import { REDIS_CLIENT } from '@task-stream/api/shared/infrastructure';
|
||||
import {
|
||||
EventPipelineReading,
|
||||
EventPipelineStatus,
|
||||
REDIS_CLIENT,
|
||||
} from '@task-stream/api/shared/infrastructure';
|
||||
import Redis from 'ioredis';
|
||||
import { Connection } from 'mongoose';
|
||||
import { DataSource } from 'typeorm';
|
||||
@@ -24,14 +28,31 @@ export class HealthController {
|
||||
private readonly postgres: DataSource,
|
||||
@InjectConnection() private readonly mongo: Connection,
|
||||
@Inject(REDIS_CLIENT) private readonly redis: Redis,
|
||||
private readonly events: EventPipelineStatus,
|
||||
) {}
|
||||
|
||||
@Public()
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'API and databases status' })
|
||||
@ApiOperation({
|
||||
summary: 'API, databases and the way from write to read',
|
||||
description:
|
||||
'503 when a database is down. With the databases up, `status` is `degraded` (still 200, so a lagging read side does not get the container restarted) when events wait in the outbox over a minute, 500 or more wait to be projected, or some were set aside after failing again and again (`task-stream:events:dead`); `events.problems` says which.',
|
||||
})
|
||||
@ApiOkResponse({
|
||||
schema: {
|
||||
example: { status: 'ok', postgres: 'up', mongo: 'up', redis: 'up' },
|
||||
example: {
|
||||
status: 'ok',
|
||||
postgres: 'up',
|
||||
mongo: 'up',
|
||||
redis: 'up',
|
||||
events: {
|
||||
unpublished: 0,
|
||||
oldestUnpublishedSeconds: null,
|
||||
pending: 0,
|
||||
dead: 0,
|
||||
problems: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiServiceUnavailableResponse({ description: 'A database is down' })
|
||||
@@ -44,10 +65,38 @@ export class HealthController {
|
||||
const [postgres, mongo, redis] = checks.map((c) =>
|
||||
c.status === 'fulfilled' ? 'up' : 'down',
|
||||
);
|
||||
const result = { status: 'ok', postgres, mongo, redis };
|
||||
const events = await this.events.read().then(
|
||||
(reading) => ({ ...reading, problems: problemsOf(reading) }),
|
||||
() => null,
|
||||
);
|
||||
const result = {
|
||||
status: events?.problems.length ? 'degraded' : 'ok',
|
||||
postgres,
|
||||
mongo,
|
||||
redis,
|
||||
events,
|
||||
};
|
||||
if (checks.some((c) => c.status === 'rejected')) {
|
||||
throw new ServiceUnavailableException({ ...result, status: 'degraded' });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/** What is wrong on the way from write to read, if anything. */
|
||||
function problemsOf(reading: EventPipelineReading): string[] {
|
||||
const problems: string[] = [];
|
||||
if ((reading.oldestUnpublishedSeconds ?? 0) > 60)
|
||||
problems.push(
|
||||
`${reading.unpublished} events wait in the outbox (the oldest, ${reading.oldestUnpublishedSeconds} s): is Redis reachable?`,
|
||||
);
|
||||
if (reading.pending >= 500)
|
||||
problems.push(
|
||||
`${reading.pending} events wait to be projected: is Mongo reachable?`,
|
||||
);
|
||||
if (reading.dead > 0)
|
||||
problems.push(
|
||||
`${reading.dead} events were set aside after failing again and again (task-stream:events:dead)`,
|
||||
);
|
||||
return problems;
|
||||
}
|
||||
|
||||
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)'),
|
||||
@@ -46,17 +50,25 @@ const issueFields = {
|
||||
.optional()
|
||||
.describe('ISO date when work should start (roadmap)'),
|
||||
dueDate: z.string().optional().describe('ISO date, e.g. 2026-11-14'),
|
||||
customFields: z
|
||||
.record(z.string(), z.string().nullable())
|
||||
.optional()
|
||||
.describe(
|
||||
'Custom field id → value (see get_project settings.customFields): SELECT one of its options, DATE YYYY-MM-DD, NUMBER a number. Only the fields to change; null clears one',
|
||||
),
|
||||
};
|
||||
|
||||
type IssueFields = {
|
||||
description?: string;
|
||||
area?: string;
|
||||
team?: string;
|
||||
priority?: z.infer<typeof PRIORITY>;
|
||||
labels?: string[];
|
||||
figmaUrl?: string;
|
||||
storyPoints?: number;
|
||||
startDate?: string;
|
||||
dueDate?: string;
|
||||
customFields?: Record<string, string | null>;
|
||||
};
|
||||
|
||||
@Resolver()
|
||||
@@ -77,10 +89,25 @@ export class IssuesResolver {
|
||||
.describe(
|
||||
'Only the tickets the user follows (use with assignedToMe false)',
|
||||
),
|
||||
reviewing: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe(
|
||||
'Only the tickets the user was asked to review and has not answered (use with assignedToMe false)',
|
||||
),
|
||||
categories: z
|
||||
.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,
|
||||
@@ -89,7 +116,11 @@ export class IssuesResolver {
|
||||
params: {
|
||||
assignedToMe: boolean;
|
||||
watching: boolean;
|
||||
reviewing: boolean;
|
||||
categories?: z.infer<typeof CATEGORY>[];
|
||||
priorities?: z.infer<typeof PRIORITY>[];
|
||||
bugsOnly?: boolean;
|
||||
overdue?: boolean;
|
||||
limit: number;
|
||||
},
|
||||
ctx: McpContext,
|
||||
@@ -101,7 +132,11 @@ export class IssuesResolver {
|
||||
query: {
|
||||
assignedToMe: params.assignedToMe,
|
||||
watching: params.watching,
|
||||
reviewing: params.reviewing,
|
||||
category: params.categories ?? ['TODO', 'IN_PROGRESS', 'QA'],
|
||||
priority: params.priorities,
|
||||
bugsOnly: params.bugsOnly,
|
||||
overdue: params.overdue,
|
||||
limit: params.limit,
|
||||
},
|
||||
},
|
||||
@@ -122,6 +157,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(),
|
||||
@@ -142,6 +178,12 @@ export class IssuesResolver {
|
||||
.describe('Only the tickets the user follows'),
|
||||
dueFrom: z.string().optional().describe('Due on or after (ISO date)'),
|
||||
dueTo: z.string().optional().describe('Due on or before (ISO date)'),
|
||||
customFields: z
|
||||
.record(z.string(), z.string())
|
||||
.optional()
|
||||
.describe(
|
||||
'Only tickets holding these custom values (field id → value)',
|
||||
),
|
||||
sort: z
|
||||
.enum(['RECENT', 'RANK'])
|
||||
.default('RECENT')
|
||||
@@ -159,6 +201,7 @@ export class IssuesResolver {
|
||||
categories?: z.infer<typeof CATEGORY>[];
|
||||
types?: string[];
|
||||
areas?: string[];
|
||||
teams?: string[];
|
||||
priorities?: z.infer<typeof PRIORITY>[];
|
||||
assigneeId?: string;
|
||||
unassigned?: boolean;
|
||||
@@ -170,6 +213,7 @@ export class IssuesResolver {
|
||||
watching?: boolean;
|
||||
dueFrom?: string;
|
||||
dueTo?: string;
|
||||
customFields?: Record<string, string>;
|
||||
sort: 'RECENT' | 'RANK';
|
||||
limit: number;
|
||||
offset: number;
|
||||
@@ -187,6 +231,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,
|
||||
@@ -198,6 +243,11 @@ export class IssuesResolver {
|
||||
watching: params.watching,
|
||||
dueFrom: params.dueFrom,
|
||||
dueTo: params.dueTo,
|
||||
field: params.customFields
|
||||
? Object.entries(params.customFields).map(
|
||||
([id, value]) => `${id}:${value}`,
|
||||
)
|
||||
: undefined,
|
||||
sort: params.sort,
|
||||
limit: params.limit,
|
||||
offset: params.offset,
|
||||
@@ -212,7 +262,7 @@ export class IssuesResolver {
|
||||
@Tool({
|
||||
name: 'get_issue',
|
||||
description:
|
||||
'A ticket in full: description, parent, checklist (with item ids), links, commits and pull requests, followers, allowed next statuses, and optionally its comments and history.',
|
||||
'A ticket in full: description, parent, checklist (with item ids), links, commits and pull requests, followers, allowed next statuses, the comments marked as decisions, and optionally its comments and history.',
|
||||
paramsSchema: z.object({
|
||||
issue: issueRef,
|
||||
withComments: z.boolean().default(true),
|
||||
@@ -247,8 +297,32 @@ export class IssuesResolver {
|
||||
emoji: r.emoji,
|
||||
by: r.users.map((u) => u.name),
|
||||
})),
|
||||
decision: c.decision || undefined,
|
||||
resolved: c.resolved || undefined,
|
||||
poll: c.poll
|
||||
? {
|
||||
options: c.poll.options.map((o, index) => ({
|
||||
index,
|
||||
text: o.text,
|
||||
votes: o.voters.length,
|
||||
})),
|
||||
closed: c.poll.closed,
|
||||
winner: c.poll.winner,
|
||||
}
|
||||
: undefined,
|
||||
}))
|
||||
: undefined;
|
||||
const decisions = (
|
||||
await this.api.data(
|
||||
client.GET('/api/issues/{issue}/decisions', { params: { path } }),
|
||||
)
|
||||
).map((c) => ({
|
||||
commentId: c.id,
|
||||
by: c.author.name,
|
||||
at: c.createdAt,
|
||||
markedBy: c.decidedBy?.name ?? null,
|
||||
body: c.body,
|
||||
}));
|
||||
const history = params.withHistory
|
||||
? (
|
||||
await this.api.data(
|
||||
@@ -277,9 +351,18 @@ export class IssuesResolver {
|
||||
url: d.url,
|
||||
})),
|
||||
watchers: issue.watchers.map((w) => w.name),
|
||||
reviews: issue.reviews.map((r) => ({
|
||||
id: r.id,
|
||||
requester: r.requester.name,
|
||||
reviewer: r.reviewer.name,
|
||||
note: r.note,
|
||||
verdict: r.verdict,
|
||||
answer: r.answer,
|
||||
})),
|
||||
attachments: issue.attachments.length,
|
||||
trashedAt: issue.trashedAt,
|
||||
createdAt: issue.createdAt,
|
||||
decisions,
|
||||
comments,
|
||||
history,
|
||||
};
|
||||
@@ -440,18 +523,108 @@ export class IssuesResolver {
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Id of the comment it answers (see get_issue)'),
|
||||
poll: z
|
||||
.array(z.string().min(1).max(80))
|
||||
.min(2)
|
||||
.max(6)
|
||||
.optional()
|
||||
.describe('A quick poll in the comment: 2 to 6 options'),
|
||||
}),
|
||||
annotations: WRITE,
|
||||
})
|
||||
comment(
|
||||
{ issue, body, replyTo }: { issue: string; body: string; replyTo?: string },
|
||||
{
|
||||
issue,
|
||||
body,
|
||||
replyTo,
|
||||
poll,
|
||||
}: { issue: string; body: string; replyTo?: string; poll?: string[] },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
return toolResult(() =>
|
||||
this.api.data(
|
||||
this.api.as(ctx).POST('/api/issues/{issue}/comments', {
|
||||
params: { path: { issue } },
|
||||
body: { body, replyTo },
|
||||
body: { body, replyTo, poll },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'vote_poll',
|
||||
description:
|
||||
"Vote in a comment's poll (option index from get_issue), or take the vote back (option null).",
|
||||
paramsSchema: z.object({
|
||||
commentId: z.string().min(1),
|
||||
option: z.number().int().min(0).nullable(),
|
||||
}),
|
||||
annotations: { ...WRITE, idempotentHint: true },
|
||||
})
|
||||
votePoll(
|
||||
{ commentId, option }: { commentId: string; option: number | null },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
return toolResult(() =>
|
||||
this.api.data(
|
||||
this.api.as(ctx).POST('/api/comments/{commentId}/poll/vote', {
|
||||
params: { path: { commentId } },
|
||||
body: { option },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'close_poll',
|
||||
description:
|
||||
'Close a poll the user started (or as admin); with decide (default), the winner stays as a decision of the ticket.',
|
||||
paramsSchema: z.object({
|
||||
commentId: z.string().min(1),
|
||||
decide: z.boolean().default(true),
|
||||
}),
|
||||
annotations: WRITE,
|
||||
})
|
||||
closePoll(
|
||||
{ commentId, decide }: { commentId: string; decide: boolean },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
return toolResult(() =>
|
||||
this.api.data(
|
||||
this.api.as(ctx).POST('/api/comments/{commentId}/poll/close', {
|
||||
params: { path: { commentId } },
|
||||
body: { decide },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'turn_comment_into_issue',
|
||||
description:
|
||||
"Make a ticket of a comment that turned out to be work of its own: its text becomes the description, as a subtask of the comment's ticket (asSubtask) or linked to it. Read type ids with get_project. Answers the new ticket's key.",
|
||||
paramsSchema: z.object({
|
||||
commentId: z.string().min(1),
|
||||
title: z.string().min(1).max(200),
|
||||
type: z.string().min(1).describe("One of the project's ticket type ids"),
|
||||
asSubtask: z.boolean().default(false),
|
||||
}),
|
||||
annotations: WRITE,
|
||||
})
|
||||
turnCommentIntoIssue(
|
||||
{
|
||||
commentId,
|
||||
title,
|
||||
type,
|
||||
asSubtask,
|
||||
}: { commentId: string; title: string; type: string; asSubtask: boolean },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
return toolResult(() =>
|
||||
this.api.data(
|
||||
this.api.as(ctx).POST('/api/comments/{commentId}/issue', {
|
||||
params: { path: { commentId } },
|
||||
body: { title, type, asSubtask },
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -509,19 +682,29 @@ export class IssuesResolver {
|
||||
@Tool({
|
||||
name: 'watch_issue',
|
||||
description:
|
||||
'Follow a ticket (notifications of its changes and comments) or stop following it.',
|
||||
'Follow a ticket (notifications of its changes and comments) or stop following it. With tree: true, an epic (or any ticket with subtasks) with everything under it, also tickets added later.',
|
||||
paramsSchema: z.object({
|
||||
issue: issueRef,
|
||||
follow: z.boolean().default(true),
|
||||
tree: z.boolean().default(false),
|
||||
}),
|
||||
annotations: { ...WRITE, idempotentHint: true },
|
||||
})
|
||||
watch(
|
||||
{ issue, follow }: { issue: string; follow: boolean },
|
||||
{ issue, follow, tree }: { issue: string; follow: boolean; tree: boolean },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
const client = this.api.as(ctx);
|
||||
const options = { params: { path: { issue } } };
|
||||
if (tree)
|
||||
return toolResult(async () => {
|
||||
await this.api.data(
|
||||
follow
|
||||
? client.PUT('/api/issues/{issue}/follow-tree', options)
|
||||
: client.DELETE('/api/issues/{issue}/follow-tree', options),
|
||||
);
|
||||
return { issue, followingTree: follow };
|
||||
});
|
||||
return toolResult(() =>
|
||||
this.api.data(
|
||||
follow
|
||||
@@ -692,6 +875,233 @@ export class IssuesResolver {
|
||||
);
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'mark_decision',
|
||||
description:
|
||||
'Mark a comment as a decision of its ticket (what was agreed), or unmark it (on: false). Whoever can change the ticket. Comment ids come with get_issue.',
|
||||
paramsSchema: z.object({
|
||||
commentId: z.string().min(1),
|
||||
on: z.boolean().default(true),
|
||||
}),
|
||||
annotations: { ...WRITE, idempotentHint: true },
|
||||
})
|
||||
markDecision(
|
||||
{ commentId, on }: { commentId: string; on: boolean },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
const client = this.api.as(ctx);
|
||||
const options = { params: { path: { commentId } } };
|
||||
return toolResult(() =>
|
||||
this.api.data(
|
||||
on
|
||||
? client.PUT('/api/comments/{commentId}/decision', options)
|
||||
: client.DELETE('/api/comments/{commentId}/decision', options),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'request_review',
|
||||
description:
|
||||
'Ask a member of the project to review a ticket (with an optional note): they are told and see it in my_issues with reviewing. reviewer is a user id (get_project lists members).',
|
||||
paramsSchema: z.object({
|
||||
issue: issueRef,
|
||||
reviewer: z.string().min(1),
|
||||
note: z.string().max(1000).optional(),
|
||||
}),
|
||||
annotations: WRITE,
|
||||
})
|
||||
requestReview(
|
||||
params: { issue: string; reviewer: string; note?: string },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
return toolResult(() =>
|
||||
this.api.data(
|
||||
this.api.as(ctx).POST('/api/issues/{issue}/reviews', {
|
||||
params: { path: { issue: params.issue } },
|
||||
body: { reviewer: params.reviewer, note: params.note },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'answer_review',
|
||||
description:
|
||||
'Answer a review the user was asked for: APPROVED (all good) or CHANGES, with an optional answer. Review ids come with get_issue.',
|
||||
paramsSchema: z.object({
|
||||
issue: issueRef,
|
||||
reviewId: z.string().min(1),
|
||||
verdict: z.enum(['APPROVED', 'CHANGES']),
|
||||
answer: z.string().max(1000).optional(),
|
||||
}),
|
||||
annotations: WRITE,
|
||||
})
|
||||
answerReview(
|
||||
params: {
|
||||
issue: string;
|
||||
reviewId: string;
|
||||
verdict: 'APPROVED' | 'CHANGES';
|
||||
answer?: string;
|
||||
},
|
||||
ctx: McpContext,
|
||||
) {
|
||||
return toolResult(() =>
|
||||
this.api.data(
|
||||
this.api.as(ctx).POST('/api/issues/{issue}/reviews/{review}/answer', {
|
||||
params: { path: { issue: params.issue, review: params.reviewId } },
|
||||
body: { verdict: params.verdict, answer: params.answer },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'my_day',
|
||||
description:
|
||||
'«Mi día»: the tickets I picked for today (done ones marked), and what was left unfinished on my last day. Optionally add or remove one first. day is today in my time zone, YYYY-MM-DD.',
|
||||
paramsSchema: z.object({
|
||||
day: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
add: issueRef.optional(),
|
||||
remove: issueRef.optional(),
|
||||
}),
|
||||
annotations: WRITE,
|
||||
})
|
||||
myDay(
|
||||
params: { day: string; add?: string; remove?: string },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
const client = this.api.as(ctx);
|
||||
const query = { day: params.day };
|
||||
return toolResult(async () => {
|
||||
if (params.add)
|
||||
await this.api.data(
|
||||
client.PUT('/api/me/day/{issue}', {
|
||||
params: { path: { issue: params.add }, query },
|
||||
}),
|
||||
);
|
||||
if (params.remove)
|
||||
await this.api.data(
|
||||
client.DELETE('/api/me/day/{issue}', {
|
||||
params: { path: { issue: params.remove }, query },
|
||||
}),
|
||||
);
|
||||
const myDay = await this.api.data(
|
||||
client.GET('/api/me/day', { params: { query } }),
|
||||
);
|
||||
return {
|
||||
day: myDay.day,
|
||||
issues: myDay.issues.map(issueLine),
|
||||
leftover: myDay.leftover && {
|
||||
day: myDay.leftover.day,
|
||||
issues: myDay.leftover.issues.map(issueLine),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'remind_me',
|
||||
description:
|
||||
'Remind me of a ticket at a date and time (a notification), with an optional note; one per ticket, it replaces the waiting one. clear: true drops it. Times are ISO with offset, e.g. 2026-10-23T09:00:00+02:00.',
|
||||
paramsSchema: z.object({
|
||||
issue: issueRef,
|
||||
at: z.string().optional(),
|
||||
note: z.string().max(280).optional(),
|
||||
clear: z.boolean().default(false),
|
||||
}),
|
||||
annotations: { ...WRITE, idempotentHint: true },
|
||||
})
|
||||
remindMe(
|
||||
params: { issue: string; at?: string; note?: string; clear: boolean },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
const client = this.api.as(ctx);
|
||||
const path = { issue: params.issue };
|
||||
return toolResult(async () => {
|
||||
if (params.clear) {
|
||||
await this.api.data(
|
||||
client.DELETE('/api/issues/{issue}/reminder', { params: { path } }),
|
||||
);
|
||||
return { issue: params.issue, reminder: null };
|
||||
}
|
||||
if (!params.at) throw new Error('at is required unless clear is true');
|
||||
return this.api.data(
|
||||
client.PUT('/api/issues/{issue}/reminder', {
|
||||
params: { path },
|
||||
body: { at: params.at, note: params.note },
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'resolve_thread',
|
||||
description:
|
||||
'Settle a conversation (a top-level comment and its replies), optionally with a conclusion that is added as a reply marked as a decision; or reopen it (resolved: false). Whoever can change the ticket. Comment ids come with get_issue.',
|
||||
paramsSchema: z.object({
|
||||
commentId: z.string().min(1),
|
||||
resolved: z.boolean().default(true),
|
||||
conclusion: z.string().max(20000).optional(),
|
||||
}),
|
||||
annotations: { ...WRITE, idempotentHint: true },
|
||||
})
|
||||
resolveThread(
|
||||
params: { commentId: string; resolved: boolean; conclusion?: string },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
const client = this.api.as(ctx);
|
||||
const path = { commentId: params.commentId };
|
||||
return toolResult(() =>
|
||||
this.api.data(
|
||||
params.resolved
|
||||
? client.PUT('/api/comments/{commentId}/resolution', {
|
||||
params: { path },
|
||||
body: { conclusion: params.conclusion },
|
||||
})
|
||||
: client.DELETE('/api/comments/{commentId}/resolution', {
|
||||
params: { path },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'search_decisions',
|
||||
description:
|
||||
'What was decided, by words in the decision: comments marked as decisions, most recent first, with their ticket. Of one project, or of all my projects without one.',
|
||||
paramsSchema: z.object({
|
||||
project: projectRef.optional(),
|
||||
search: z.string().max(200).optional(),
|
||||
limit: limit(100, 20),
|
||||
}),
|
||||
annotations: READ,
|
||||
})
|
||||
searchDecisions(
|
||||
params: { project?: string; search?: string; limit: number },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
return toolResult(async () => {
|
||||
const client = this.api.as(ctx);
|
||||
const query = { search: params.search, limit: params.limit };
|
||||
const found = await this.api.data(
|
||||
params.project
|
||||
? client.GET('/api/projects/{project}/decisions', {
|
||||
params: { path: { project: params.project }, query },
|
||||
})
|
||||
: client.GET('/api/me/decisions', { params: { query } }),
|
||||
);
|
||||
return found.map((d) => ({
|
||||
issue: d.issue.key,
|
||||
title: d.issue.title,
|
||||
commentId: d.comment.id,
|
||||
by: d.comment.author.name,
|
||||
at: d.comment.createdAt,
|
||||
body: d.comment.body,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'vote_issue',
|
||||
description:
|
||||
|
||||
@@ -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,21 +204,112 @@ 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) open tickets per option of each list custom field, activity per day over the last 12 weeks, where the time of the tickets done lately went from created to done (days and share per status, and the part spent at work: flow efficiency) and, if the public has proposed anything, the proposals funnel (received, waiting, under way, done, discarded, most voted waiting). 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).',
|
||||
paramsSchema: z.object({ project: projectRef, limit: limit(200, 30) }),
|
||||
description:
|
||||
'Latest activity of a project (newest first), or that of one day (UTC) with day.',
|
||||
paramsSchema: z.object({
|
||||
project: projectRef,
|
||||
limit: limit(200, 30),
|
||||
day: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
.optional()
|
||||
.describe('YYYY-MM-DD: only what happened that day (UTC)'),
|
||||
}),
|
||||
annotations: READ,
|
||||
})
|
||||
activity(
|
||||
{ project, limit }: { project: string; limit: number },
|
||||
{ project, limit, day }: { project: string; limit: number; day?: string },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
const window = day
|
||||
? {
|
||||
after: `${day}T00:00:00.000Z`,
|
||||
before: new Date(
|
||||
Date.parse(`${day}T00:00:00Z`) + 86_400_000,
|
||||
).toISOString(),
|
||||
}
|
||||
: {};
|
||||
return toolResult(async () =>
|
||||
(
|
||||
await this.api.data(
|
||||
this.api.as(ctx).GET('/api/projects/{project}/activity', {
|
||||
params: { path: { project }, query: { limit } },
|
||||
params: { path: { project }, query: { limit, ...window } },
|
||||
}),
|
||||
)
|
||||
).map((a) => ({
|
||||
|
||||
@@ -188,15 +188,26 @@ export class ReleasesResolver {
|
||||
@Tool({
|
||||
name: 'archive_release',
|
||||
description:
|
||||
'Archive a planned release that will not ship; its tickets leave it.',
|
||||
paramsSchema: z.object({ releaseId }),
|
||||
'Archive a planned release that will not ship. Its unfinished tickets move to moveTo (another open release of the project, from list_releases) or, without it, leave it.',
|
||||
paramsSchema: z.object({
|
||||
releaseId,
|
||||
moveTo: z
|
||||
.string()
|
||||
.uuid()
|
||||
.optional()
|
||||
.describe('Open release that takes the unfinished tickets'),
|
||||
}),
|
||||
annotations: { readOnlyHint: false, destructiveHint: true },
|
||||
})
|
||||
archive({ releaseId }: { releaseId: string }, ctx: McpContext) {
|
||||
archive(
|
||||
{ releaseId, moveTo }: { releaseId: string; moveTo?: string },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
return toolResult(() =>
|
||||
this.api.data(
|
||||
this.api.as(ctx).POST('/api/releases/{releaseId}/archive', {
|
||||
params: { path: { releaseId } },
|
||||
body: { moveTo: moveTo ?? null },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -27,6 +27,7 @@ export const issueLine = (issue: Issue) => ({
|
||||
votes: issue.votes,
|
||||
storyPoints: issue.storyPoints,
|
||||
dueDate: issue.dueDate,
|
||||
customFields: issue.customFields,
|
||||
labels: issue.labels,
|
||||
updatedAt: issue.updatedAt,
|
||||
});
|
||||
|
||||
@@ -28,6 +28,19 @@ server {
|
||||
proxy_read_timeout 1h;
|
||||
}
|
||||
|
||||
# The service worker and the manifest have no hash in their name: never
|
||||
# cached, or browsers would keep an old one (exact matches win over the
|
||||
# regex below).
|
||||
location = /push-sw.js {
|
||||
add_header Cache-Control "no-cache";
|
||||
try_files $uri =404;
|
||||
}
|
||||
location = /manifest.webmanifest {
|
||||
add_header Cache-Control "no-cache";
|
||||
default_type application/manifest+json;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# Hashed bundles can be cached forever; index.html never.
|
||||
location ~* \.(js|css|woff2?|svg|png|ico)$ {
|
||||
expires 1y;
|
||||
|
||||
BIN
apps/web/public/logo-192.png
Normal file
BIN
apps/web/public/logo-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
BIN
apps/web/public/logo-512.png
Normal file
BIN
apps/web/public/logo-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
25
apps/web/public/manifest.webmanifest
Normal file
25
apps/web/public/manifest.webmanifest
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "TaskStream",
|
||||
"short_name": "TaskStream",
|
||||
"description": "Gestión de proyectos y tickets en tiempo real",
|
||||
"lang": "es",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f7f7fa",
|
||||
"theme_color": "#a347e1",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/logo-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/logo-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
}
|
||||
]
|
||||
}
|
||||
52
apps/web/public/push-sw.js
Normal file
52
apps/web/public/push-sw.js
Normal file
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* TaskStream's service worker: push notifications only. It caches nothing,
|
||||
* so a new deploy never leaves an old version of the app behind.
|
||||
*
|
||||
* The API sends `{ title, body, url, tag }` (see Notifier's payloadOf).
|
||||
*/
|
||||
self.addEventListener('install', () => self.skipWaiting());
|
||||
self.addEventListener('activate', (event) =>
|
||||
event.waitUntil(self.clients.claim()),
|
||||
);
|
||||
|
||||
self.addEventListener('push', (event) => {
|
||||
let data = {};
|
||||
try {
|
||||
data = event.data ? event.data.json() : {};
|
||||
} catch {
|
||||
data = { body: event.data ? event.data.text() : '' };
|
||||
}
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(data.title || 'TaskStream', {
|
||||
body: data.body || '',
|
||||
tag: data.tag,
|
||||
icon: '/logo-192.png',
|
||||
badge: '/logo-96.png',
|
||||
data: { url: data.url || '/' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// A tap opens the ticket: in a TaskStream tab already open, or in a new one.
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
const target = new URL(
|
||||
event.notification.data?.url || '/',
|
||||
self.location.origin,
|
||||
).href;
|
||||
event.waitUntil(
|
||||
self.clients
|
||||
.matchAll({ type: 'window', includeUncontrolled: true })
|
||||
.then((tabs) => {
|
||||
for (const tab of tabs) {
|
||||
if (
|
||||
new URL(tab.url).origin === self.location.origin &&
|
||||
'focus' in tab
|
||||
) {
|
||||
return tab.focus().then(() => tab.navigate(target));
|
||||
}
|
||||
}
|
||||
return self.clients.openWindow(target);
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from '@task-stream/web/shared/infrastructure';
|
||||
import { providePiUi, ToastService } from '@task-stream/web/shared/ui';
|
||||
import { Notifications } from '@task-stream/web/tracker/application';
|
||||
import { provideTrackerInfrastructure } from '@task-stream/web/tracker/infrastructure';
|
||||
import { provideTrackerShellInfrastructure } from '@task-stream/web/tracker/infrastructure';
|
||||
import { appRoutes } from './app.routes';
|
||||
|
||||
/*
|
||||
@@ -39,9 +39,9 @@ export const appConfig: ApplicationConfig = {
|
||||
provideApiConfig(apiConfigFromLocation()),
|
||||
provideGraphql(),
|
||||
|
||||
// Ports → adapters
|
||||
// Ports → adapters: the shell's here; the pages' with their lazy routes (tracker.routes.ts).
|
||||
provideIdentityInfrastructure(),
|
||||
provideTrackerInfrastructure(),
|
||||
provideTrackerShellInfrastructure(),
|
||||
{ provide: Notifications, useExisting: ToastService },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,33 +1,62 @@
|
||||
import { Route } from '@angular/router';
|
||||
import { authGuard } from '@task-stream/web/identity/presenters';
|
||||
import { appRoutes } from './app.routes';
|
||||
|
||||
/** The tracker's routes, behind the lazy `''` route (and its providers' parent route). */
|
||||
async function trackerChildren(): Promise<Route[]> {
|
||||
const lazy = appRoutes.find(
|
||||
(route) => route.path === '' && route.loadChildren,
|
||||
);
|
||||
const loaded = (await lazy?.loadChildren?.()) as Route[];
|
||||
expect(loaded[0].providers?.length).toBeGreaterThan(0);
|
||||
return loaded[0].children ?? [];
|
||||
}
|
||||
|
||||
describe('appRoutes', () => {
|
||||
it('keeps home behind the auth guard, and public projects and «Explorar» open', () => {
|
||||
it('keeps home behind the auth guard, and public projects and «Explorar» open', async () => {
|
||||
const paths = appRoutes.map((route) => route.path);
|
||||
expect(paths).toEqual(
|
||||
expect.arrayContaining(['login', 'auth/callback', '', '**']),
|
||||
);
|
||||
|
||||
const app = appRoutes.find((route) => route.path === '' && route.children);
|
||||
const children = app?.children ?? [];
|
||||
const children = await trackerChildren();
|
||||
expect(children.map((route) => route.path)).toEqual([
|
||||
'',
|
||||
'portfolio',
|
||||
'explore',
|
||||
'share/:token',
|
||||
'notes/:token',
|
||||
'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();
|
||||
// The pages for clients need no session: their link is the key.
|
||||
expect(children[3].canActivate).toBeUndefined();
|
||||
expect(children[4].canActivate).toBeUndefined();
|
||||
expect(children[7].canActivate).toBeUndefined();
|
||||
// The Spanish paths of shared links lead to the English ones.
|
||||
expect(children[5].redirectTo).toBe('portfolio');
|
||||
expect(children[6].redirectTo).toBe('explore');
|
||||
});
|
||||
|
||||
it('exposes every project section', () => {
|
||||
const project = appRoutes
|
||||
.find((route) => route.path === '')
|
||||
?.children?.find((route) => route.path === 'p/:key');
|
||||
it('gives each page its own state, not the root', async () => {
|
||||
const children = await trackerChildren();
|
||||
expect(children[0].providers?.length).toBe(4);
|
||||
expect(children[2].providers?.length).toBe(1);
|
||||
});
|
||||
|
||||
it('exposes every project section', async () => {
|
||||
const project = (await trackerChildren()).find(
|
||||
(route) => route.path === 'p/:key',
|
||||
);
|
||||
expect(project?.children?.map((route) => route.path)).toEqual([
|
||||
'',
|
||||
'board',
|
||||
'proponer',
|
||||
'board',
|
||||
'propose',
|
||||
'list',
|
||||
'issues/:issueKey',
|
||||
'backlog',
|
||||
@@ -37,6 +66,7 @@ describe('appRoutes', () => {
|
||||
'epics',
|
||||
'trash',
|
||||
'releases',
|
||||
'report/:kind/:id',
|
||||
'dashboard',
|
||||
'analytics',
|
||||
'settings',
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Route } from '@angular/router';
|
||||
import { identityRoutes } from '@task-stream/web/identity/presenters';
|
||||
import { trackerRoutes } from '@task-stream/web/tracker/presenters';
|
||||
|
||||
export const appRoutes: Route[] = [
|
||||
...identityRoutes,
|
||||
// Not all behind the guard: public projects are readable without a session.
|
||||
{ path: '', children: trackerRoutes },
|
||||
{
|
||||
path: '',
|
||||
loadChildren: () => import('./tracker.routes').then((m) => m.routes),
|
||||
},
|
||||
{ path: '**', redirectTo: '' },
|
||||
];
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { filter, map } from 'rxjs';
|
||||
import { AuthFacade } from '@task-stream/web/identity/application';
|
||||
import { UserMenuComponent } from '@task-stream/web/identity/presenters';
|
||||
import { PiIconButtonComponent } from '@pi/ui';
|
||||
import { AppShellComponent } from '@task-stream/web/shared/ui';
|
||||
import {
|
||||
CommandPalette,
|
||||
@@ -27,7 +28,24 @@ import {
|
||||
UserMenuComponent,
|
||||
NotificationsBell,
|
||||
CommandPalette,
|
||||
PiIconButtonComponent,
|
||||
],
|
||||
styles: `
|
||||
/* The palette's button, still: same box, so nothing moves when it loads. */
|
||||
.ts-shell-search {
|
||||
display: inline-flex;
|
||||
gap: 0.375rem;
|
||||
align-items: center;
|
||||
border: 0;
|
||||
background: none;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
kbd {
|
||||
font-size: 0.6875rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
`,
|
||||
template: `
|
||||
<ts-app-shell>
|
||||
@if (auth.isAuthenticated()) {
|
||||
@@ -41,10 +59,20 @@ import {
|
||||
Proyectos
|
||||
</a>
|
||||
}
|
||||
@if (auth.isAuthenticated()) {
|
||||
<a
|
||||
shellNav
|
||||
class="ts-nav-link"
|
||||
routerLink="/portfolio"
|
||||
routerLinkActive="is-active"
|
||||
>
|
||||
Cartera
|
||||
</a>
|
||||
}
|
||||
<a
|
||||
shellNav
|
||||
class="ts-nav-link"
|
||||
routerLink="/explorar"
|
||||
routerLink="/explore"
|
||||
routerLinkActive="is-active"
|
||||
>
|
||||
Explorar
|
||||
@@ -52,8 +80,30 @@ import {
|
||||
@if (auth.isAuthenticated()) {
|
||||
<!-- One projectable node per @if branch, or neither reaches the slot. -->
|
||||
<ng-container shellUser>
|
||||
<ts-command-palette />
|
||||
<ts-notifications-bell />
|
||||
<!-- Palette and bell load once the page is up: their code and their
|
||||
queries stay out of the start. Meanwhile, a still copy. -->
|
||||
@defer (on idle; on interaction) {
|
||||
<ts-command-palette />
|
||||
} @placeholder {
|
||||
<button
|
||||
type="button"
|
||||
class="ts-nav-link ts-shell-search"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
>
|
||||
Buscar <kbd>⌘K</kbd>
|
||||
</button>
|
||||
}
|
||||
@defer (on idle; on interaction) {
|
||||
<ts-notifications-bell />
|
||||
} @placeholder {
|
||||
<pi-icon-button
|
||||
icon="bell"
|
||||
color="grey"
|
||||
appearance="ghost"
|
||||
aria-label="Notificaciones"
|
||||
/>
|
||||
}
|
||||
<ts-user-menu />
|
||||
</ng-container>
|
||||
} @else if (auth.settled() && !onLogin()) {
|
||||
|
||||
15
apps/web/src/app/tracker.routes.ts
Normal file
15
apps/web/src/app/tracker.routes.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Route } from '@angular/router';
|
||||
import { provideTrackerPagesInfrastructure } from '@task-stream/web/tracker/infrastructure';
|
||||
import { trackerRoutes } from '@task-stream/web/tracker/presenters';
|
||||
|
||||
/**
|
||||
* The tracker, loaded lazily: its pages, and the adapters only they use,
|
||||
* come in this chunk instead of the one that boots the app.
|
||||
*/
|
||||
export const routes: Route[] = [
|
||||
{
|
||||
path: '',
|
||||
providers: [provideTrackerPagesInfrastructure()],
|
||||
children: trackerRoutes,
|
||||
},
|
||||
];
|
||||
@@ -12,6 +12,8 @@
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="logo-96.png" />
|
||||
<link rel="apple-touch-icon" href="logo.png" />
|
||||
<!-- Installable (on iPhone, push only reaches an installed web app) -->
|
||||
<link rel="manifest" href="manifest.webmanifest" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<!-- Inter, like pireon.dev · Geist Mono only for data (ticket keys, counters, versions) -->
|
||||
|
||||
@@ -406,6 +406,13 @@ h3 {
|
||||
.ts-form {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
|
||||
// A field's label floats above its box; other fields leave room for it
|
||||
// below them (their hint line), a segmented control does not.
|
||||
> pi-segmented-control,
|
||||
> p:has(+ pi-text-field, + pi-textarea, + pi-select-field, + pi-date-field) {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.ts-form__row {
|
||||
@@ -618,3 +625,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,10 @@ services:
|
||||
SMTP_URL: ${SMTP_URL:-}
|
||||
MAIL_FROM: ${MAIL_FROM:-}
|
||||
WEB_URL: ${WEB_URL:-}
|
||||
# Push notifications (optional): `npx web-push generate-vapid-keys`.
|
||||
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
|
||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
|
||||
VAPID_SUBJECT: ${VAPID_SUBJECT:-}
|
||||
# Outgoing webhooks to private addresses (off: they could reach internal services).
|
||||
WEBHOOKS_ALLOW_PRIVATE: ${WEBHOOKS_ALLOW_PRIVATE:-false}
|
||||
# Due-date reminders, checked every hour.
|
||||
|
||||
@@ -2,47 +2,51 @@
|
||||
|
||||
## Qué resuelve
|
||||
|
||||
| Necesidad | Cómo se resuelve |
|
||||
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas |
|
||||
| 🌍 Públicos | Un proyecto público se puede leer sin iniciar sesión (web, GraphQL y REST): tablero, tickets, comentarios, releases, actividad y estadísticas; «Explorar» los lista |
|
||||
| 🗑️ Borrar | El propietario borra un proyecto confirmando su clave; tickets, comentarios, releases y enlaces desde otros proyectos se borran en cascada (sagas) y la clave queda libre |
|
||||
| 🧩 Configuración | Cada proyecto tiene sus estados (con categoría, color y motivo obligatorio), transiciones, tipos de ticket y áreas; lo usado se archiva, no se borra |
|
||||
| 🎨 Cambios de UI | Tipo `UI_CHANGE` con enlace al frame de Figma (validado contra `figma.com`) |
|
||||
| ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo |
|
||||
| 🧪 QA | Por defecto, `READY_FOR_QA → IN_QA → DONE / QA_FAILED`; fallar QA exige un motivo |
|
||||
| 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) |
|
||||
| 📝 Notas de versión | Borrador en Markdown con los tickets terminados por tipo, editable y copiable, también tras publicar |
|
||||
| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona |
|
||||
| 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con formato (editor visual, guardado en Markdown), capturas pegadas, respuestas, reacciones (👍 🎉…) y `@menciones`; el autor edita, el admin modera |
|
||||
| 🔎 Vistas | El tablero se filtra por texto, persona (o sin asignar), tipo, prioridad, área y release; los filtros se guardan como vistas propias o compartidas con el proyecto |
|
||||
| 📋 Lista | Todos los tickets en filas: ordenar por cualquier columna, filtrar, cambiar estado, prioridad o persona sin abrirlos, editar en bloque y duplicar |
|
||||
| 🚦 WIP y carriles | Límite de trabajo en curso por estado (la columna se marca al pasarse) y tablero agrupable por persona, épica, prioridad o tipo |
|
||||
| 🎲 Estimación | Puntos por ticket (escala 0-1-2-3-5-8-13-21) en la ficha, las tarjetas, las columnas y el backlog; velocidad por sprint y aviso si un sprint planificado supera la media |
|
||||
| 🏃 Sprints | Iteraciones con fechas y objetivo, una en curso por proyecto, con burndown (tickets o puntos) y traspaso de lo pendiente al cerrar |
|
||||
| 📅 Calendario | Los sprints mes a mes como franjas, con las releases y los vencimientos de cada día; se planifica un sprint pulsando su primer día y todo se descarga en .ics |
|
||||
| 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden |
|
||||
| 🗑️ Papelera | Borrar un ticket lo manda a la papelera (quien lo abrió o un admin); se recupera durante 30 días y luego se borra del todo |
|
||||
| ☑️ En bloque | Selecciona varios tickets del backlog y cámbiales a la vez estado, persona, sprint, prioridad o etiqueta |
|
||||
| ⌨️ Atajos y búsqueda | Ctrl/⌘ K o / busca tickets y proyectos de todos tus proyectos; `g b`, `g k`, `g s`, `g r` navegan, `c` crea, `e` edita, `?` los lista |
|
||||
| 🗺️ Hoja de ruta | Las épicas en el tiempo (fecha de inicio y límite), con los sprints y las releases como referencias |
|
||||
| 🗳️ Propuestas y votos | Un proyecto público puede abrir un formulario para que cualquiera, sin cuenta, reporte un error o proponga una idea; quien tenga sesión vota lo que quiere |
|
||||
| 🌐 Web del colectivo | La página de cada proyecto en pireon.dev enseña su hoja de ruta, versiones, sprint y tickets para recién llegados (`good-first-issue`) |
|
||||
| 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños |
|
||||
| 👀 Seguir | Cualquier miembro sigue los tickets que le interesan y los ve juntos en «Que sigo»; quien lo abre, lo tiene asignado o comenta, lo sigue solo |
|
||||
| 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios, respuestas), en vivo y opcionalmente por correo |
|
||||
| ⏰ Recordatorios | Aviso (y correo, si se quiere) el día antes, el mismo día y al vencer la fecha límite, a quien lo tiene y a quien lo sigue; las tarjetas marcan lo que vence |
|
||||
| 📎 Adjuntos | Capturas, logs y vídeos en el ticket o bajo un comentario; arrastrar, elegir o pegar con Ctrl+V; miniaturas de las imágenes |
|
||||
| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA); lo que espera a otro ticket abierto sale marcado como «Bloqueado» |
|
||||
| 🌿 Git | Webhook para GitHub, Gitea o GitLab: commits, ramas y pull requests que nombran un ticket (WEB-12) aparecen en su ficha |
|
||||
| 🧾 Plantillas | Cada tipo de ticket puede traer una descripción inicial y una checklist que se aplican al crearlo |
|
||||
| 🤖 Automatizaciones | Reglas «cuando… si… entonces…»: al crear, mover, asignar, comentar o fusionar una PR, mover, asignar, etiquetar, priorizar, comentar o meter en el sprint |
|
||||
| 📡 Webhooks | Un POST firmado (HMAC-SHA256) a otro sistema cuando pasa algo: tickets, comentarios, releases, sprints; con prueba y último envío |
|
||||
| 📦 Importar/exportar | Todos los tickets a CSV; importar un CSV propio o una exportación de Jira, con vista previa de lo que se va a crear |
|
||||
| 🏠 Inicio | «Mi trabajo»: mis tickets en todos los proyectos, por categoría (QA fallidos primero), lo que espera QA, próximas releases e «Ir a…» por clave |
|
||||
| 📊 Seguimiento | Panel con contadores, flujo por estado, el equipo con sus tickets abiertos (enlaza a su tablero), feed de actividad e historial por ticket |
|
||||
| 📈 Estadísticas | Creados vs. resueltos, flujo acumulado, burnup de releases, tiempos de ciclo y entrega, tasa de fallo en QA y resueltos por persona |
|
||||
| ⚡ Tiempo real | Tablero, comentarios, actividad, releases y membresías se actualizan solos (GraphQL subscriptions) |
|
||||
| Necesidad | Cómo se resuelve |
|
||||
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas |
|
||||
| 🌍 Públicos | Un proyecto público se puede leer sin iniciar sesión (web, GraphQL y REST): tablero, tickets, comentarios, releases, actividad y estadísticas; «Explorar» los lista |
|
||||
| 🗑️ Borrar | El propietario borra un proyecto confirmando su clave; tickets, comentarios, releases y enlaces desde otros proyectos se borran en cascada (sagas) y la clave queda libre |
|
||||
| 🧩 Configuración | Cada proyecto tiene sus estados (con categoría, color y motivo obligatorio), transiciones, tipos de ticket y áreas; lo usado se archiva, no se borra |
|
||||
| 🎨 Cambios de UI | Tipo `UI_CHANGE` con enlace al frame de Figma (validado contra `figma.com`) |
|
||||
| ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo |
|
||||
| 🧪 QA | Por defecto, `READY_FOR_QA → IN_QA → DONE / QA_FAILED`; fallar QA exige un motivo |
|
||||
| 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) |
|
||||
| 📝 Notas de versión | Borrador en Markdown con los tickets terminados por tipo, editable y copiable, también tras publicar; y una página pública por release para los clientes, con enlace secreto |
|
||||
| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona |
|
||||
| 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con formato (editor visual, guardado en Markdown), capturas pegadas, respuestas, reacciones (👍 🎉…), decisiones (un comentario marcado sale encima del ticket y se encuentra al buscar), conversaciones que se resuelven con su conclusión, convertir un comentario en ticket, lo nuevo desde tu última visita marcado, encuestas rápidas que acaban en decisión, «está escribiendo…» en vivo y `@menciones`; el autor edita, el admin modera |
|
||||
| 🔎 Vistas | El tablero se filtra por texto, persona (o sin asignar), tipo, prioridad, área y release; los filtros se guardan como vistas propias o compartidas con el proyecto |
|
||||
| 📋 Lista | Todos los tickets en filas: ordenar por cualquier columna, filtrar, cambiar estado, prioridad o persona sin abrirlos, editar en bloque y duplicar |
|
||||
| ↩️ Deshacer | Mover, reasignar o mandar a la papelera ofrece «Deshacer» unos segundos (también Ctrl/⌘ Z), con la orden contraria |
|
||||
| ☀ Mi día | Cada mañana eliges los tickets de hoy; se tachan al terminar y lo que quede se pasa al día siguiente; y «Recordármelo» en cualquier ticket |
|
||||
| 🚦 Salud | Un semáforo por proyecto (en su panel y en la cartera) que dice qué va mal y por qué: atascados, vencidos, bugs urgentes, QA, releases tarde |
|
||||
| 🚦 WIP y carriles | Límite de trabajo en curso por estado (la columna se marca al pasarse) y tablero agrupable por persona, épica, prioridad o tipo |
|
||||
| 🎲 Estimación | Puntos por ticket (escala 0-1-2-3-5-8-13-21) en la ficha, las tarjetas, las columnas y el backlog; velocidad por sprint y aviso si un sprint planificado supera la media |
|
||||
| 🏃 Sprints | Iteraciones con fechas y objetivo, una en curso por proyecto, con burndown (tickets o puntos) y traspaso de lo pendiente al cerrar |
|
||||
| 📅 Calendario | Los sprints mes a mes como franjas, con las releases y los vencimientos de cada día; se planifica un sprint pulsando su primer día y todo se descarga en .ics |
|
||||
| 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden |
|
||||
| 🗑️ Papelera | Borrar un ticket lo manda a la papelera (quien lo abrió o un admin); se recupera durante 30 días y luego se borra del todo |
|
||||
| ☑️ En bloque | Selecciona varios tickets del backlog y cámbiales a la vez estado, persona, sprint, prioridad o etiqueta |
|
||||
| ⌨️ Atajos y búsqueda | Ctrl/⌘ K o / busca tickets, decisiones y proyectos de todos tus proyectos (antes de escribir, tus tickets fijados); `g b`, `g k`, `g s`, `g r` navegan, `c` crea, en el tablero las flechas recorren las tarjetas y Shift+←/→ las cambia de columna, `e` edita, `?` los lista |
|
||||
| 🗺️ Hoja de ruta | Las épicas en el tiempo (fecha de inicio y límite), con los sprints y las releases como referencias y flechas entre las que dependen unas de otras |
|
||||
| 🗳️ Propuestas y votos | Un proyecto público puede abrir un formulario para que cualquiera, sin cuenta, reporte un error o proponga una idea; quien tenga sesión vota lo que quiere |
|
||||
| 🌐 Web del colectivo | La página de cada proyecto en pireon.dev enseña su hoja de ruta, versiones, sprint y tickets para recién llegados (`good-first-issue`) |
|
||||
| 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños |
|
||||
| 👀 Seguir | Cualquier miembro sigue los tickets que le interesan y los ve juntos en «Que sigo»; quien lo abre, lo tiene asignado o comenta, lo sigue solo |
|
||||
| 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios, respuestas), en vivo y opcionalmente por correo |
|
||||
| ⏰ Recordatorios | Aviso (y correo, si se quiere) el día antes, el mismo día y al vencer la fecha límite, a quien lo tiene y a quien lo sigue; las tarjetas marcan lo que vence |
|
||||
| 📎 Adjuntos | Capturas, logs y vídeos en el ticket o bajo un comentario; arrastrar, elegir o pegar con Ctrl+V; miniaturas de las imágenes; grabar la pantalla o la voz desde el comentario y verlo ahí mismo |
|
||||
| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA); lo que espera a otro ticket abierto sale marcado como «Bloqueado» |
|
||||
| 🌿 Git | Webhook para GitHub, Gitea o GitLab: commits, ramas y pull requests que nombran un ticket (WEB-12) aparecen en su ficha |
|
||||
| 🚨 Alertas → bugs | Sentry, Uptime Kuma, Grafana o cualquier herramienta abren bugs por una URL secreta; el mismo problema repetido va al bug abierto, y «resuelta» se dice en él |
|
||||
| 🧾 Plantillas | Cada tipo de ticket puede traer una descripción inicial y una checklist que se aplican al crearlo |
|
||||
| 🤖 Automatizaciones | Reglas «cuando… si… entonces…»: al crear, mover, asignar, comentar o fusionar una PR, mover, asignar, etiquetar, priorizar, comentar o meter en el sprint |
|
||||
| 📡 Webhooks | Un POST firmado (HMAC-SHA256) a otro sistema cuando pasa algo: tickets, comentarios, releases, sprints; con prueba y último envío |
|
||||
| 📦 Importar/exportar | Todos los tickets a CSV; importar un CSV propio o una exportación de Jira, con vista previa de lo que se va a crear |
|
||||
| 🏠 Inicio | «Mi trabajo»: mis tickets en todos los proyectos, por categoría (QA fallidos primero), lo que espera QA, próximas releases e «Ir a…» por clave |
|
||||
| 📊 Seguimiento | Panel con contadores, flujo por estado, el equipo con sus tickets abiertos (enlaza a su tablero), feed de actividad e historial por ticket |
|
||||
| 📈 Estadísticas | Creados vs. resueltos, flujo acumulado, burnup de releases, tiempos de ciclo y entrega, tasa de fallo en QA y resueltos por persona |
|
||||
| ⚡ Tiempo real | Tablero, comentarios, actividad, releases y membresías se actualizan solos (GraphQL subscriptions) |
|
||||
|
||||
Los **usuarios no se gestionan aquí**: vienen del servidor de identidad **Pireon**
|
||||
([Swagger](https://oauth.pireon.dev/api/documentation)). TaskStream gestiona proyectos, membresías y roles por
|
||||
|
||||
@@ -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.
|
||||
@@ -58,8 +59,11 @@ TrackerPresentersModule.withApplication(
|
||||
|
||||
```ts
|
||||
// apps/web/src/app/app.config.ts
|
||||
provideTrackerInfrastructure(); // puertos → adaptadores GraphQL
|
||||
provideTrackerShellInfrastructure(); // puertos de la cabecera → adaptadores GraphQL
|
||||
{ provide: Notifications, useExisting: ToastService } // puerto de la aplicación → toasts de @pi/ui
|
||||
|
||||
// apps/web/src/app/tracker.routes.ts (en diferido)
|
||||
{ path: '', providers: [provideTrackerPagesInfrastructure()], children: trackerRoutes }
|
||||
```
|
||||
|
||||
## 2.2 Flujo completo de una escritura
|
||||
@@ -109,7 +113,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
|
||||
@@ -118,12 +125,26 @@ sequenceDiagram
|
||||
|
||||
### De escritura a lectura: Redis
|
||||
|
||||
- La tabla `events` es la **bandeja de salida (outbox)**: cada evento entra en la misma transacción que la escritura,
|
||||
con `published_at` vacío. Si Postgres falla, la escritura falla entera y no queda nada a medias.
|
||||
- El **`EventStoreRelay`** copia los eventos nuevos (`published_at IS NULL`) al Redis Stream `task-stream:events`,
|
||||
en orden y con `FOR UPDATE SKIP LOCKED` (varias instancias no duplican). La entrega es _at-least-once_.
|
||||
en orden y con `FOR UPDATE SKIP LOCKED` (varias instancias no duplican). La entrega es _at-least-once_. Si Redis
|
||||
falla, los eventos esperan en la tabla y salen al volver.
|
||||
- El **`EventsBridge`** consume el stream con un _consumer group_, deserializa cada evento (`EventClsRegistry` +
|
||||
`@AutowiredEvent`) y lo entrega a los `@EventsHandler` registrados para su clase. A diferencia de
|
||||
`eventBus.subject$.next()`, **espera** a cada handler y procesa los eventos en orden; solo hace `XACK` cuando todos
|
||||
terminan. Si uno falla, el evento queda pendiente y se reintenta (y `XAUTOCLAIM` recupera los de instancias caídas).
|
||||
terminan. El grupo de consumidores hace de **bandeja de entrada (inbox)**, y la idempotencia la ponen las
|
||||
proyecciones (versión, abajo).
|
||||
- **Si un evento falla, se reintenta en su sitio** (`EventStreamConsumer`), esperando cada vez más (hasta 30 s), y el
|
||||
siguiente espera: saltárselo aplicaría antes un cambio posterior y la comprobación de versión descartaría después el
|
||||
anterior para siempre. Si el fallo es de la infraestructura (Mongo, Redis o la red caídos, ver `isTransientError`),
|
||||
espera lo que haga falta y se pone al día solo al volver, sin reiniciar. Si es del propio evento (un fallo del
|
||||
código), tras 5 intentos va al stream `task-stream:events:dead` con el error, para no bloquear el resto. Lo que
|
||||
queda pendiente al parar se reintenta al arrancar (y `XAUTOCLAIM` recupera lo de instancias caídas).
|
||||
- `GET /api/health` dice en `events` cuántos eventos esperan en la bandeja de salida, cuántos están pendientes de
|
||||
proyectar y cuántos se apartaron; con alguno de más, `status: degraded` (pero 200: un retraso de la lectura no
|
||||
reinicia el contenedor). El stream se recorta a `EVENT_STREAM_MAX_LENGTH` (un millón): solo un corte larguísimo
|
||||
podría recortar algo pendiente, y Postgres siempre permite reconstruir la lectura.
|
||||
- Después de los handlers, el bridge ejecuta también las **sagas** (ver abajo).
|
||||
|
||||
### Lectura: MongoDB
|
||||
@@ -160,7 +181,7 @@ uno y esperando. Así heredan las garantías del bridge: orden, reintento y nada
|
||||
| ---------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ProjectDeletionSaga` | `ProjectDeletedEvent` | `DeleteIssueCommand` por cada ticket, `DeleteReleaseCommand` por cada release y `DeleteSprintCommand` por cada sprint del proyecto |
|
||||
| | `IssueDeletedEvent` | `PurgeCommentCommand` por cada comentario, `DropLinksToIssueCommand` en los tickets que lo enlazaban y `DetachFromParentCommand` en los que colgaban de él |
|
||||
| `ReleaseArchivedSaga` | `ReleaseArchivedEvent` | `DetachIssueFromReleaseCommand` para sus tickets sin terminar (los terminados se quedan) |
|
||||
| `ReleaseArchivedSaga` | `ReleaseArchivedEvent` | `DetachIssueFromReleaseCommand` para sus tickets sin terminar, a la release elegida al archivar o a ninguna (los terminados se quedan) |
|
||||
| `SprintCarryOverSaga` | `SprintClosedEvent` / `SprintDeletedEvent` | `CarryOverIssueCommand`: lo pendiente pasa al sprint elegido o al backlog; al borrar, todos vuelven al backlog |
|
||||
| `CommentersFollowSaga` | `CommentAddedEvent` | `WatchIssueCommand`: quien comenta pasa a seguir el ticket |
|
||||
| `PeopleFollowSaga` | `IssueCreatedEvent`, `IssueAssignedEvent` | `WatchIssueCommand`: quien abre el ticket y quien lo tiene asignado pasan a seguirlo |
|
||||
|
||||
329
docs/03-api.md
329
docs/03-api.md
@@ -38,12 +38,20 @@ 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` | ✅ | ❌ | ❌ |
|
||||
| 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.
|
||||
@@ -62,7 +70,7 @@ lee igual que un anónimo. Las subscriptions exigen sesión; con ella, también
|
||||
en el primer estado, de tipo Bug o Feature (o el primer tipo activo). No se pide correo, porque quedaría a la vista. Contra
|
||||
el spam, el campo `website` es una trampa (si llega relleno se responde como si nada y no se guarda) y cada dirección
|
||||
envía como mucho 5 cada 15 minutos (429); el contador vive en Redis (`RateLimitStore`), así que vale igual con
|
||||
varias instancias. Los propietarios y admins del proyecto reciben un aviso (`PROPOSED`). La web tiene el formulario en `/p/{CLAVE}/proponer`, enlazado en la cabecera del proyecto.
|
||||
varias instancias. Los propietarios y admins del proyecto reciben un aviso (`PROPOSED`). La web tiene el formulario en `/p/{CLAVE}/propose`, enlazado en la cabecera del proyecto.
|
||||
|
||||
**Votos**: quien tenga sesión vota los tickets de un proyecto público, y los miembros los de uno privado
|
||||
(`voteIssue(issueId, on)`, `PUT/DELETE /api/issues/{issue}/vote`). Cada ticket da `votes` y `voted` (si he votado
|
||||
@@ -80,9 +88,19 @@ 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** y **áreas** (`Project.settings`). Todos
|
||||
Cada proyecto tiene sus **estados**, **transiciones**, **tipos de ticket**, **áreas** y **campos personalizados**
|
||||
(`Project.settings`). Todos
|
||||
empiezan con los valores por defecto de abajo, y el propietario o un admin los cambia con `updateProjectSettings` (o
|
||||
`PUT /api/projects/{project}/settings`); el cambio es un evento más (`ProjectSettingsChangedEvent`).
|
||||
|
||||
@@ -106,12 +124,63 @@ Reglas:
|
||||
- Hace falta al menos un estado activo de categoría `TODO` (los tickets nuevos entran en el primero), uno `DONE` y un
|
||||
tipo activo.
|
||||
- Las transiciones solo pueden apuntar a estados que existen y nunca de un estado a sí mismo.
|
||||
- Colores: `slate`, `stone`, `violet`, `blue`, `sky`, `teal`, `green`, `amber`, `orange`, `red`, `pink`. Iconos:
|
||||
- Colores: uno de la paleta (`slate`, `stone`, `violet`, `blue`, `sky`, `teal`, `green`, `amber`, `orange`, `red`,
|
||||
`pink`), que tiene su versión para modo oscuro, o cualquier `#rrggbb`. 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`,
|
||||
`type` y `archived`, más `options` si es una lista. Un proyecto tiene como mucho 30, y empieza sin ninguno.
|
||||
|
||||
| Tipo | Valor |
|
||||
| -------- | ---------------------------------------------------------------- |
|
||||
| `TEXT` | Hasta 500 caracteres |
|
||||
| `NUMBER` | Un número (se guarda en forma canónica: `1,5` → `1.5`) |
|
||||
| `DATE` | `YYYY-MM-DD` |
|
||||
| `SELECT` | Una de sus `options` (de 1 a 50, de hasta 60 caracteres, únicas) |
|
||||
|
||||
- Como el resto de la configuración, un campo no se borra: se archiva. Su **tipo no cambia** una vez guardado, para que
|
||||
los valores que ya hay sigan teniendo sentido. Un campo archivado se puede vaciar en un ticket, pero no rellenar.
|
||||
- Al guardar la configuración, si `customFields` no viene se quedan como estaban (los clientes que no los conocen no
|
||||
los pierden).
|
||||
- En el ticket: GraphQL `Issue.customFields` es una lista `{ fieldId, value }` (solo los que tienen valor) y se cambia
|
||||
con `customFields: [{ fieldId, value }]` en `createIssue` / `updateIssue`; por REST es un objeto
|
||||
`{ "BROWSER": "Safari" }`. En ambos se mandan solo los campos que cambian, y `null` o `""` vacía uno.
|
||||
- Filtrar: `issues(filter: { customFields: [{ fieldId, value }] })` o `GET /api/projects/{project}/issues?field=BROWSER:Safari`
|
||||
(se puede repetir).
|
||||
- `IssueUpdatedEvent` lleva el mapa entero de valores ya validado, así la proyección solo lo copia.
|
||||
|
||||
### Workflow por defecto
|
||||
|
||||
```mermaid
|
||||
@@ -170,6 +239,30 @@ stateDiagram-v2
|
||||
- **Reacciones**: 👍 👎 😄 🎉 😕 ❤️ 🚀 👀 en los comentarios (`reactToComment(commentId, emoji, on)`,
|
||||
`POST /api/comments/{id}/reactions`); los miembros, también los lectores. Cada comentario da `reactions` con quién
|
||||
eligió cada una. No generan actividad ni avisos.
|
||||
- **Encuestas**: un comentario puede llevar una encuesta de 2 a 6 opciones (`addComment(…, poll)`, `poll` en
|
||||
`POST …/comments`). Los miembros, también los lectores, votan una opción y pueden cambiarla o retirarla
|
||||
(`votePoll(commentId, option)`, `POST /api/comments/{id}/poll/vote`; `CommentPollVotedEvent`). Quien la abrió, o un
|
||||
admin, la cierra (`closePoll(commentId, decide)`, `POST …/poll/close`; `CommentPollClosedEvent`): gana la más votada
|
||||
(con empate, ninguna) y, con `decide`, se añade una respuesta «Decidido: …» marcada como decisión. El comentario da
|
||||
`poll { options { text voters } closed winner }`.
|
||||
- **Convertir en ticket**: un comentario que resulta ser trabajo propio se convierte en ticket
|
||||
(`turnCommentIntoIssue(commentId, title, type, asSubtask)`, `POST /api/comments/{id}/issue`), como subtarea del ticket
|
||||
del comentario o enlazado a él (`RELATES_TO`), por quien puede cambiar tickets. El texto del comentario es la
|
||||
descripción (sin la plantilla del tipo), con una línea de dónde sale; el comentario lo recuerda
|
||||
(`CommentTurnedIntoIssueEvent`) y da `spawned { id key }`. Devuelve el ticket nuevo.
|
||||
- **Conversaciones**: un comentario raíz y sus respuestas se resuelven (`resolveThread(commentId, resolved, conclusion)`,
|
||||
`PUT/DELETE /api/comments/{id}/resolution`; evento `CommentThreadResolvedEvent`) por quien puede cambiar el ticket;
|
||||
una respuesta no se resuelve sola. Si trae `conclusion`, antes de cerrarla se añade como respuesta marcada como
|
||||
decisión. El comentario da `resolved`, `resolvedBy` y `resolvedAt`. **Escribiendo**: `typing(issueId, threadId)`
|
||||
(mutation, cada pocos segundos mientras se escribe) avisa al momento por la subscription `typing(issueId)` a quien
|
||||
tenga el ticket abierto; no se guarda (canal de Redis `tracker.typing.<ticket>`), y cada cliente descarta sus propios
|
||||
avisos.
|
||||
- **Decisiones**: quien puede cambiar el ticket marca un comentario como decisión (`markDecision(commentId, on)`,
|
||||
`PUT/DELETE /api/comments/{id}/decision`; evento `CommentDecisionMarkedEvent`). El comentario da `decision`,
|
||||
`decidedBy` y `decidedAt`; el ticket, `decisions` (más antiguas primero, `GET /api/issues/{issue}/decisions`), que la
|
||||
web muestra encima de la descripción. Se buscan por su texto: `decisions(projectId?, search, limit)`,
|
||||
`GET /api/projects/{project}/decisions` y, de todos mis proyectos, `GET /api/me/decisions` (más recientes primero; sin
|
||||
las de la papelera y, para un invitado, solo las de sus tickets).
|
||||
- **Bloqueos**: un ticket espera a otro si ese otro lo bloquea (`BLOCKS`) o si él depende de ese otro (`DEPENDS_ON`),
|
||||
mientras el otro no esté hecho ni cancelado. `blockedBy` lo calcula al leer (los enlaces no guardan el estado del
|
||||
otro ticket), con una sola consulta por tablero o página.
|
||||
@@ -181,7 +274,9 @@ stateDiagram-v2
|
||||
- **Adjuntos**: los suben los miembros (no los lectores), hasta `ATTACHMENTS_MAX_MB` (25 MB) por archivo y 50 por
|
||||
ticket; pueden ir en el ticket o bajo uno de sus comentarios (`commentId`). Quita uno quien lo subió o un admin.
|
||||
Los bytes se guardan primero y el evento después, así que nunca queda un adjunto apuntando a un archivo que no
|
||||
existe; si el evento no se guarda, el archivo se borra. Al borrar un ticket se borran sus archivos.
|
||||
existe; si el evento no se guarda, el archivo se borra. Al borrar un ticket se borran sus archivos. Las imágenes,
|
||||
los PDF, el texto y el vídeo o audio que reproduce el navegador (mp4, webm, mp3, ogg, wav) se sirven para verse en
|
||||
la página; el resto, como descarga.
|
||||
- **Épicas y subtareas**: un ticket puede colgar de otro del mismo proyecto (`parent`), hasta tres niveles
|
||||
(épica → ticket → subtarea) y sin bucles; se comprueba sobre el event store, no sobre el read model. Una «épica» es
|
||||
cualquier ticket de primer nivel del que cuelgan otros (`epics(projectId)` da su progreso: terminados = hechos o
|
||||
@@ -191,6 +286,20 @@ stateDiagram-v2
|
||||
encima y el de quien queda debajo, leídos del event store; si entretanto el orden cambió, la API lo rechaza y la web
|
||||
recarga. Un ticket que nunca se movió ocupa su orden de creación (`IssueRankedEvent` no existe para él). Las
|
||||
columnas del tablero y `issues(filter: { sort: RANK })` siguen este orden.
|
||||
- **Alertas → bugs**: un owner o admin activa en Integraciones una URL secreta por proyecto,
|
||||
`POST /api/webhooks/alerts/{proyecto}/{token}` (el token es el secreto: Sentry o Uptime Kuma solo dejan pegar una
|
||||
URL). Entiende Sentry (integración interna, cabecera `sentry-hook-resource`, o el plugin de webhooks), Uptime Kuma,
|
||||
Prometheus Alertmanager y Grafana; cualquier otra herramienta manda JSON con `title` y, si quiere, `fingerprint`,
|
||||
`description`, `url` y `resolved`. Cada aviso tiene una huella (el id del problema en Sentry, el monitor en Uptime
|
||||
Kuma, el `groupKey` en Alertmanager o `fingerprint`): la primera vez TaskStream abre un bug (actor `system`, etiqueta
|
||||
`alerta`, el tipo y la prioridad elegidos; sin tipo, el primero con icono de bug); mientras ese bug siga abierto, las
|
||||
repeticiones van a él (un comentario como mucho cada 30 minutos, con cuántas van) y «resuelta» se dice en él; si ya
|
||||
está cerrado, abre otro que nombra el anterior. Responde `{ action: created | repeated | resolved | ignored, issueKey
|
||||
}`; proyecto desconocido, alertas apagadas o token malo responden igual: 403. Las alertas de un proyecto se atienden
|
||||
de una en una, así que dos iguales a la vez abren un solo bug. Configuración: `alertIntegration`,
|
||||
`saveAlertIntegration(input: { typeId, priority })`, `rotateAlertToken`, `disableAlertIntegration`;
|
||||
`GET/PUT/DELETE /api/projects/{project}/alert-integration` y `POST …/token`. Vive en MongoDB (`alert_integrations`,
|
||||
y `alert_traces` con qué bug abrió cada huella), no en el event store.
|
||||
- **Integración con Git**: un owner o admin añade en la configuración del proyecto sus repositorios (`api`, `web`,
|
||||
`admin`…), cada uno con su propio secreto. Todos llaman a la misma URL, `POST /api/webhooks/git/{proyecto}`, con los
|
||||
eventos _push_ y _pull/merge request_; GitHub y Gitea firman el cuerpo con el secreto (HMAC-SHA256) y GitLab lo envía
|
||||
@@ -213,7 +322,17 @@ stateDiagram-v2
|
||||
- **Edición en bloque**: `bulkUpdateIssues` / `POST /api/issues/bulk` aplica el mismo cambio (estado, persona o
|
||||
`unassign`, sprint o `noSprint`, release o `noRelease`, prioridad, etiquetas que añadir) a hasta 100 tickets. Cada
|
||||
uno pasa por los comandos normales (permisos, flujo, sprints y releases abiertos) y puede negarse sin parar al resto;
|
||||
la respuesta dice cuáles.
|
||||
la respuesta dice cuáles. La web parte selecciones mayores (la Lista enseña hasta 200) en tandas de 100, una tras
|
||||
otra, y suma el resultado.
|
||||
- **Notas públicas de una release**: quien gestiona el proyecto abre un enlace secreto con las notas de una release,
|
||||
para clientes y sin cuenta (también en proyectos privados): `shareReleaseNotes(releaseId, renew)`,
|
||||
`unshareReleaseNotes`, `releaseNotesShare`; `GET/PUT/DELETE /api/releases/{id}/public-notes`. La página
|
||||
(`publicReleaseNotes(token)`, `GET /api/shared/release-notes/{token}`, la web en `/notes/{token}`) enseña las notas
|
||||
escritas o, mientras no las haya, el borrador de sus tickets terminados (`written: false`). Renovar hace un enlace
|
||||
nuevo; retirarlo, o borrar la release, lo deja sin efecto. Vive en MongoDB (`release_note_shares`).
|
||||
- **Archivar una release**: `archiveRelease(releaseId, moveTo)` / `POST /api/releases/{id}/archive` (`{ moveTo }`).
|
||||
Sus tickets sin terminar pasan a `moveTo` (otra release abierta del mismo proyecto) o, sin él, se quedan sin release;
|
||||
los terminados se quedan, como registro de lo que salió. Igual que al cerrar un sprint con `carryOverTo`.
|
||||
- **Plantillas por tipo**: en la configuración, cada tipo de ticket puede llevar `template` con una descripción
|
||||
(hasta 5000 caracteres) y una checklist (hasta 20 pasos). Al crear un ticket de ese tipo, la descripción se usa si
|
||||
llega vacía y los pasos se añaden siempre (las importaciones no aplican plantillas).
|
||||
@@ -253,6 +372,40 @@ secreto>`. El secreto solo se muestra al crearlo. Se reintenta a los 2 y 10 s (n
|
||||
y se borran con el proyecto.
|
||||
- **Checklist**: hasta 100 pasos de hasta 300 caracteres por ticket; los cambia quien puede editar el ticket.
|
||||
|
||||
### Calendario (iCalendar)
|
||||
|
||||
Cada persona puede suscribir sus fechas en Google Calendar, Outlook o Apple Calendar con una URL secreta:
|
||||
`GET /api/calendar/{token}.ics` (sin sesión: las aplicaciones de calendario no pueden iniciarla). Trae, como eventos de
|
||||
día completo:
|
||||
|
||||
- sus tickets sin terminar con fecha límite, asignados o que sigue;
|
||||
- los sprints planificados o en curso de sus proyectos;
|
||||
- las releases planificadas con fecha objetivo.
|
||||
|
||||
Cada evento enlaza a la web (`WEB_URL`). El token (32 bytes aleatorios) se crea, renueva o retira con `GET`, `POST` y
|
||||
`DELETE /api/me/calendar` (GraphQL: `myCalendarFeed`, `createCalendarFeed`, `revokeCalendarFeed`); renovarlo deja sin
|
||||
servicio la URL anterior. Se guarda tal cual en `calendar_feeds` para poder volver a copiar la URL: solo da lectura de
|
||||
fechas y títulos que ya están en el read model.
|
||||
|
||||
### Panel para clientes
|
||||
|
||||
Cada proyecto puede enseñar su estado a sus clientes con un enlace de solo lectura, sin cuenta: `/share/{token}` en la
|
||||
web, que lee `GET /api/shared/dashboards/{token}` (GraphQL: `sharedDashboard(token)`), ambos sin sesión. Solo trae los
|
||||
bloques elegidos, y los demás llegan a `null`:
|
||||
|
||||
| Bloque | Qué da |
|
||||
| ---------- | -------------------------------------------------------------------------- |
|
||||
| `flow` | Tickets en cada estado activo, con su nombre y color |
|
||||
| `sprint` | El sprint en curso, como en el panel (¿llega?) |
|
||||
| `releases` | Las releases planificadas con su previsión al ritmo real |
|
||||
| `epics` | Las épicas abiertas (8, por fecha): título, fecha, terminados y porcentaje |
|
||||
| `activity` | Movimientos por día de las últimas 12 semanas |
|
||||
|
||||
No enseña personas ni tickets sueltos (salvo las épicas). Propietarios y admins lo activan, cambian sus bloques o lo
|
||||
renuevan con `GET`, `PUT` y `DELETE /api/projects/{project}/share` (`{ blocks, renew }`; GraphQL: `dashboardShare`,
|
||||
`shareDashboard`, `unshareDashboard`). Renovarlo deja sin servicio el enlace anterior. El token (32 bytes aleatorios)
|
||||
se guarda tal cual en `dashboard_shares` para poder volver a copiar el enlace, y se borra con el proyecto.
|
||||
|
||||
### Notificaciones
|
||||
|
||||
Se derivan de los eventos (`NotificationsEventHandler`) y se guardan **por persona** en MongoDB: no son estado de
|
||||
@@ -268,6 +421,13 @@ ningún agregado, solo cada uno lee y descarta las suyas.
|
||||
| Te añaden a un proyecto | La persona añadida | `ADDED_TO_PROJECT` |
|
||||
| Llega una propuesta del público | Propietarios y admins del proyecto | `PROPOSED` |
|
||||
|
||||
**Push** (Web Push): además de en la campana y, si se pidió, por correo, cada aviso llega a los dispositivos donde la
|
||||
persona activó «Avisos en este dispositivo», con TaskStream cerrado. El navegador se suscribe con la clave pública
|
||||
VAPID (`notificationSettings.pushPublicKey`, null si no hay claves) y la API guarda la suscripción
|
||||
(`subscribePush` / `POST /api/me/push-subscriptions`; se retira con `unsubscribePush` / `DELETE`). El mensaje va
|
||||
cifrado (`aes128gcm`) y firmado con VAPID, con `{ title, body, url, tag }`; si el servicio push responde 404 o 410, la
|
||||
suscripción se olvida. Como el correo, un fallo de envío no frena los eventos.
|
||||
|
||||
**Recordatorios** (`REMINDER`): cada hora la API mira los tickets sin terminar con fecha límite y avisa a quien lo
|
||||
tiene asignado y a quien lo sigue el día antes, el mismo día y una vez cuando ya ha vencido (el día cambia en
|
||||
`REMINDERS_TIMEZONE`). Cada aviso tiene id `reminder:<ticket>:<fecha>:<fase>:<persona>`, así que no se repite aunque
|
||||
@@ -284,7 +444,33 @@ eventos `IssueWatchedEvent`/`IssueUnwatchedEvent`), y quien lo abre, quien lo ti
|
||||
sigue un ticket (`WATCHED`) sabe cuándo cambia de estado, se asigna, se comenta, cambia su prioridad o su fecha límite,
|
||||
o entra o sale de un sprint; las ediciones menores (título, etiquetas…) no avisan. «Los que sigo»: `watching` en
|
||||
`myIssues` / `GET /api/me/issues` y en la búsqueda de un proyecto (`issues(filter: { watching: true })`,
|
||||
`?watching=true`; sin sesión, ninguno). El id es
|
||||
`?watching=true`; sin sesión, ninguno). **Revisiones**: quien puede cambiar un ticket pide a un miembro que lo revise, con nota opcional
|
||||
(`requestReview(issueId, reviewerId, note)`, `POST /api/issues/{issue}/reviews`; evento `IssueReviewRequestedEvent`).
|
||||
Esa persona, aunque sea lectora, responde «todo bien» o «cambios» con su respuesta (`answerReview`, `POST
|
||||
…/reviews/{id}/answer`, `IssueReviewAnsweredEvent`); quien la pidió (o un admin) puede retirarla (`cancelReview`, `DELETE
|
||||
…/reviews/{id}`, `IssueReviewCancelledEvent`). No a uno mismo ni dos veces a la misma persona con una pendiente. Avisos
|
||||
`REVIEW_REQUESTED` y `REVIEW_ANSWERED`; `Issue.reviews`; «Te piden revisión» es `myIssues(reviewing: true)` /
|
||||
`GET /api/me/issues?reviewing=true`. **Mi día**: los tickets que cada persona elige para hoy (`myDay(day)`, `addToMyDay(day, issueIds)`,
|
||||
`removeFromMyDay(day, issueId)`; `GET /api/me/day?day=`, `PUT/DELETE /api/me/day/{issue}?day=`), hasta 10. `day` es
|
||||
hoy en la zona horaria de quien pregunta (`YYYY-MM-DD`, a dos días como mucho de la del servidor). La respuesta trae
|
||||
también lo que quedó sin terminar el último día anterior (si fue en la última semana), para pasarlo a hoy. Privado y sin
|
||||
historial (`my_days`). **Recordármelo**: cada persona pone en un ticket un recordatorio con fecha y hora y nota opcional
|
||||
(`setReminder(issueId, at, note)`, `clearReminder`, `Issue.myReminder`, `myReminders`; `PUT/DELETE
|
||||
/api/issues/{issue}/reminder`, `GET /api/me/reminders`): uno por ticket, en el futuro y como mucho a un año. Cada minuto
|
||||
`IssueReminderScheduler` avisa de los que tocan (`REMINDER`, por la campana, el navegador y el correo; una vez, por su
|
||||
id) y olvida los de tickets en la papelera o fuera de alcance. Privados y sin historial (`issue_reminders`);
|
||||
`REMINDERS_ENABLED=false` los apaga junto con los de fecha límite. **Lo nuevo desde tu última visita**: al abrir un
|
||||
ticket, `visitIssue(issueId)` (`PUT /api/issues/{issue}/visit`) apunta la visita y devuelve la anterior (null la
|
||||
primera vez); con ella el cliente marca lo que otros han hecho desde entonces. Personal y sin historial (colección
|
||||
`issue_visits`). **Seguir todo**: seguir un ticket con todo lo que cuelga de él (una épica, un ticket con subtareas):
|
||||
`followTree`/`unfollowTree`, `Issue.followingTree`; `PUT/DELETE /api/issues/{issue}/follow-tree`. Quien lo hace oye lo
|
||||
mismo que un seguidor de cada ticket de debajo, a cualquier profundidad, también de los que se añadan, y además cuándo
|
||||
uno entra (`IssueParentChangedEvent`). Personal y sin historial (colección `tree_follows`); los invitados de un proyecto
|
||||
privado no oyen los tickets que no son suyos. **Fijar**: cada persona fija hasta 20 tickets para tenerlos a mano en el
|
||||
inicio y en la búsqueda (`pinIssue`/`unpinIssue`, `myPins`, `Issue.pinned`; `GET /api/me/pins`,
|
||||
`PUT/DELETE /api/issues/{issue}/pin`). Son privados y sin historial (colección `pinned_issues`, como las vistas de «Mi
|
||||
trabajo»); los que están en la papelera o fuera de su alcance no se listan, y al llegar a 20 se olvidan primero los
|
||||
que ya no puede leer. El id es
|
||||
`<agregado>:<versión>:<persona>`, así que reentregar un evento no duplica avisos. Llegan en vivo por la subscription
|
||||
`notificationAdded` y, si la instalación tiene `SMTP_URL`, también por correo a quien lo active (`email`, desactivado
|
||||
por defecto); un correo que falla se registra y no bloquea el stream.
|
||||
@@ -325,6 +511,44 @@ 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 |
|
||||
| Actividad | Movimientos por día (todo lo de la actividad) de las últimas 12 semanas, desde un lunes: 84 días, en `heatmap` |
|
||||
| Recorrido | De los tickets terminados en 90 días: días de media de creado a hecho, días y parte del total en cada estado por el que pasaron (también la espera en Backlog o Por hacer) y `flowEfficiency`, la parte en un estado «en curso» |
|
||||
| Propuestas | Si el público ha propuesto algo (tickets con la etiqueta «propuesta»): recibidas (y en 30 días), esperando (sin empezar ni planificar), en marcha, hechas y descartadas, y las 5 más votadas que esperan; si no, `null` |
|
||||
|
||||
## 3.2 GraphQL
|
||||
|
||||
Las operaciones requieren `Authorization: Bearer <token de Pireon>` (en subscriptions, en el `connection_init`), salvo
|
||||
@@ -351,35 +575,36 @@ que funcionan también sin token.
|
||||
| `epics(projectId)` | Épicas del proyecto (tickets de primer nivel de los que cuelgan otros) con el progreso de sus hijos |
|
||||
| `issue(idOrKey)` | Un ticket. Campos calculados: `allowedTransitions`, `linkedIssues`, `comments`, `history` |
|
||||
| `release(id)` | Una release. Campos calculados: `progress`, `burnup`, `issues` |
|
||||
| `activity(projectId, issueId?, limit, before?)` | Feed de actividad, del más reciente al más antiguo |
|
||||
| `activity(projectId, issueId?, limit, before?, after?)` | Feed de actividad, del más reciente al más antiguo; con `after` y `before`, una ventana (un día, por ejemplo). Por REST, los mismos parámetros en `…/activity` |
|
||||
|
||||
### Mutations
|
||||
|
||||
Todas devuelven `CommandResult { id, key, version }`.
|
||||
|
||||
| Ámbito | Mutations |
|
||||
| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
|
||||
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `removeAttachment` (subir: por REST), `duplicateIssue`, `voteIssue` |
|
||||
| Comentarios | `addComment(replyTo)`, `editComment`, `deleteComment`, `reactToComment` |
|
||||
| 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)` |
|
||||
| Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` |
|
||||
| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `writeReleaseNotes`, `archiveRelease` |
|
||||
| Ámbito | Mutations |
|
||||
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
|
||||
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `requestReview`, `answerReview`, `cancelReview`, `pinIssue`, `unpinIssue`, `followTree`, `unfollowTree`, `visitIssue`, `clearReminder`, `addToMyDay`, `removeFromMyDay` (devuelven `true`), `setReminder`, `removeAttachment` (subir: por REST), `duplicateIssue`, `voteIssue` |
|
||||
| Comentarios | `addComment(replyTo, poll)`, `votePoll`, `closePoll`, `editComment`, `deleteComment`, `reactToComment`, `markDecision(commentId, on)`, `resolveThread(commentId, resolved, conclusion)`, `turnCommentIntoIssue(commentId, title, type, asSubtask)`, `typing(issueId, threadId)`; consulta `decisions(projectId, search)` |
|
||||
| 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)`, `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` |
|
||||
|
||||
### Subscriptions
|
||||
|
||||
| Subscription | Se emite cuando |
|
||||
| --------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||
| `issueChanged(projectId)` | Se crea o cambia cualquier ticket del proyecto (incluye el ticket completo y `action` = nombre del evento) |
|
||||
| `commentChanged(issueId)` | Se añade, edita o borra un comentario (`comment` es `null` al borrar, o el que queda sin texto si tenía respuestas) |
|
||||
| `activityAdded(projectId)` | Hay una nueva entrada en el feed |
|
||||
| `releaseChanged(projectId)` | Cambia una release |
|
||||
| `sprintChanged(projectId)` | Se planifica, cambia, empieza, cierra o borra un sprint (`sprint` es `null` al borrar) |
|
||||
| `projectChanged(projectId)` | Cambian los datos, la configuración o los miembros del proyecto |
|
||||
| `myProjectsChanged` | Me añaden a un proyecto o me quitan de uno |
|
||||
| Subscription | Se emite cuando |
|
||||
| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `issueChanged(projectId)` | Se crea o cambia cualquier ticket del proyecto (incluye el ticket completo y `action` = nombre del evento) |
|
||||
| `commentChanged(issueId)` | Se añade, edita o borra un comentario (`comment` es `null` al borrar, o el que queda sin texto si tenía respuestas) |
|
||||
| `typing(issueId)` | Alguien escribe en el ticket (`threadId`: la conversación que responde; `null`, un comentario nuevo). Llegan también los propios |
|
||||
| `activityAdded(projectId)` | Hay una nueva entrada en el feed |
|
||||
| `releaseChanged(projectId)` | Cambia una release |
|
||||
| `sprintChanged(projectId)` | Se planifica, cambia, empieza, cierra o borra un sprint (`sprint` es `null` al borrar) |
|
||||
| `projectChanged(projectId)` | Cambian los datos, la configuración o los miembros del proyecto |
|
||||
| `myProjectsChanged` | Me añaden a un proyecto o me quitan de uno |
|
||||
|
||||
Solo los miembros pueden suscribirse a un proyecto; las conexiones sin token válido se cierran con el código 4403.
|
||||
|
||||
@@ -564,23 +789,25 @@ 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` |
|
||||
| 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}` |
|
||||
| Avisos | `GET /api/me/notifications`, `POST /api/me/notifications/read`, `GET/PUT /api/me/notification-settings` |
|
||||
| Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` |
|
||||
| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `PUT …/notes`, `GET …/notes/draft`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` |
|
||||
| Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) |
|
||||
| Importar | `GET /api/projects/{project}/export.csv`, `POST /api/projects/{project}/import?dryRun=` (multipart `file`) |
|
||||
| Webhooks | `GET /api/webhook-events`, `GET/POST /api/projects/{project}/webhooks`, `PATCH/DELETE /api/outgoing-webhooks/{id}`, `POST …/test` |
|
||||
| Git | `GET /api/projects/{project}/git-integration`, `POST …/git-integration/repositories`, `POST …/repositories/{id}/secret`, `DELETE …/repositories/{id}`, `POST /api/webhooks/git/{project}` (lo llama el host de Git) |
|
||||
| Explorar | `GET /api/explore/projects/{project}/showcase` (sin token, proyectos públicos): hoja de ruta con votos, versiones, sprint en curso, tickets `good-first-issue` y propuestas abiertas; `POST …/proposals` (sin token) |
|
||||
| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` |
|
||||
| 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`), `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`, `POST …/reviews`, `POST …/reviews/{id}/answer`, `DELETE …/reviews/{id}`, `PUT/DELETE …/follow-tree`, `PUT/DELETE …/reminder`, `GET /api/me/reminders`, `GET /api/me/day`, `PUT/DELETE /api/me/day/{issue}`, `GET /api/me/pins` y `PUT/DELETE …/pin` (fijados), `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`, `PUT/DELETE …/decision`, `PUT/DELETE …/resolution`, `POST …/poll/vote`, `POST …/poll/close`, `POST …/issue` (convertir en ticket); `GET /api/issues/{issue}/decisions`, `GET /api/projects/{project}/decisions`, `GET /api/me/decisions` |
|
||||
| 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}` |
|
||||
| Avisos | `GET /api/me/notifications`, `POST /api/me/notifications/read`, `GET/PUT /api/me/notification-settings` |
|
||||
| Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` |
|
||||
| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `PUT …/notes`, `GET …/notes/draft`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` |
|
||||
| Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) |
|
||||
| Compartir | `GET/PUT/DELETE /api/projects/{project}/share` (enlace para clientes), `GET /api/shared/dashboards/{token}` (lo que enseña, sin token) |
|
||||
| Importar | `GET /api/projects/{project}/export.csv`, `POST /api/projects/{project}/import?dryRun=` (multipart `file`) |
|
||||
| Webhooks | `GET /api/webhook-events`, `GET/POST /api/projects/{project}/webhooks`, `PATCH/DELETE /api/outgoing-webhooks/{id}`, `POST …/test` |
|
||||
| Git | `GET /api/projects/{project}/git-integration`, `POST …/git-integration/repositories`, `POST …/repositories/{id}/secret`, `DELETE …/repositories/{id}`, `POST /api/webhooks/git/{project}` (lo llama el host de Git) |
|
||||
| Alertas | `GET/PUT/DELETE /api/projects/{project}/alert-integration`, `POST …/alert-integration/token`, `POST /api/webhooks/alerts/{project}/{token}` |
|
||||
| Explorar | `GET /api/explore/projects/{project}/showcase` (sin token, proyectos públicos): hoja de ruta con votos, versiones, sprint en curso, tickets `good-first-issue` y propuestas abiertas; `POST …/proposals` (sin token) |
|
||||
| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` |
|
||||
|
||||
Los archivos van por REST porque GraphQL no está hecho para bytes; la lista de adjuntos de un ticket llega con el
|
||||
ticket (`attachments`, en GraphQL y en REST). La descarga sirve en línea solo imágenes, vídeo, audio, PDF y texto
|
||||
@@ -595,7 +822,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
|
||||
|
||||
@@ -8,7 +8,7 @@ API repartida en `libs/web/*`.
|
||||
| Librería | Contenido |
|
||||
| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `web/shared/infrastructure` | Cliente Apollo (`provideGraphql`: HTTP + `graphql-ws`), `API_CONFIG`, puerto `AccessTokenSource` |
|
||||
| `web/shared/ui` | Integración de `@pi/ui` (`providePiUi`), modo oscuro, iconos, `ToastService`, `DrawerService`, `AppShellComponent` |
|
||||
| `web/shared/ui` | Integración de `@pi/ui` (`providePiUi`), modo oscuro, iconos, `ToastService` (con `undoable`: aviso con «Deshacer», `UndoToastComponent`), `DrawerService`, `AppShellComponent` |
|
||||
| `web/identity/domain` | `Session`, `CurrentUser`, `AuthConfig` y puertos `AuthGateway`, `ProfileGateway`, `SessionStorage`, `AuthorizationFlow` |
|
||||
| `web/identity/application` | `AuthFacade` (pública); por dentro `AuthStore` y eventos `authPageEvents`, `authApiEvents`, `sessionEvents` |
|
||||
| `web/identity/infrastructure` | `/api/auth/*` (HTTP), `me` (GraphQL), `localStorage`, PKCE con Web Crypto; enlaza `AccessTokenSource` con la `AuthFacade` |
|
||||
@@ -113,20 +113,52 @@ 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 |
|
||||
| `ExploreFacade` | `ExploreStore` | raíz | Proyectos públicos («Explorar»), también sin sesión |
|
||||
| `HomeFacade` | `HomeStore` | ruta `/` (portada) | «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` | ruta `/` (portada) | 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` | ruta `/` (portada) | 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` | ruta `explore` | 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 |
|
||||
| `BoardFacade` | `BoardStore` | ruta `board` | Tablero kanban, filtros, movimiento optimista (con «Deshacer»), prioridad, estimación y persona cambiadas desde la tarjeta, nuevo ticket, cambios en vivo |
|
||||
| `ListFacade` | `ListStore` | ruta `list` | Todos los tickets del proyecto en filas: filtros (enviados a la API), cambios de estado, prioridad y persona en la fila, edición en bloque; se recarga tras cada cambio propio y con los de otros |
|
||||
| `IssueFacade` | `IssueStore` | ruta `issues/:issueKey` | Ticket, comentarios en vivo, historial y todas sus acciones |
|
||||
|
||||
Las de ámbito de ruta se registran con `provideProjectFacade()`, `provideBoardFacade()` o `provideIssueFacade()` en los
|
||||
`providers` del componente de la página (store + facade) y desaparecen al salir (con sus subscriptions). Las páginas
|
||||
hijas de `/p/:key` reciben la misma `ProjectFacade`.
|
||||
En la raíz solo queda lo que usa la cabecera: la sesión (`AuthFacade`), mis proyectos para la paleta de comandos
|
||||
(`ProjectsFacade`) y la campana (`InboxFacade`, `PushFacade`). La paleta y la campana se cargan con `@defer (on idle;
|
||||
on interaction)`: su código y sus consultas no están en el arranque, y mientras tanto la cabecera muestra una copia
|
||||
quieta con la misma caja, así que nada se mueve al llegar. Lo demás es de su página:
|
||||
|
||||
- Las de una página que se vuelve a visitar a menudo (portada, Explorar, el formulario de propuestas) van en los
|
||||
`providers` de su ruta (`provideHomeFacade()`, `provideMyHomeFacade()`, `provideMyViewsFacade()`,
|
||||
`provideCalendarFacade()`, `provideExploreFacade()`, `provideProposeFacade()`, `provideSharedDashboardFacade()`): cargan con la página y el inyector de
|
||||
la ruta las conserva, así que al volver se ve lo último al instante mientras se recarga.
|
||||
- `SharingFacade` (el enlace para clientes en Configuración) va en los `providers` de su propio componente.
|
||||
- Las de un proyecto o un ticket se registran con `provideProjectFacade()`, `provideBoardFacade()` o
|
||||
`provideIssueFacade()` en los `providers` del componente de la página (store + facade) y desaparecen al salir (con sus
|
||||
subscriptions). Las páginas hijas de `/p/:key` reciben la misma `ProjectFacade`.
|
||||
|
||||
Los adaptadores siguen el mismo reparto: `provideTrackerShellInfrastructure()` (proyectos, tickets, avisos, push y tiempo
|
||||
real) va en la raíz, y `provideTrackerPagesInfrastructure()` (el resto) en la ruta del tracker, que se carga en diferido
|
||||
(`apps/web/src/app/tracker.routes.ts`). Las librerías web declaran `"sideEffects": false` (`libs/web/package.json`), para
|
||||
que el empaquetador deje en cada trozo solo lo que usa y no todo lo que exporta un `index.ts`.
|
||||
|
||||
Otros contextos también usan la facade: los stores del tracker leen el usuario de `AuthFacade`, y la infraestructura
|
||||
de identidad (`SessionTokenSource`) le pide el token para el cliente GraphQL.
|
||||
|
||||
### Notificaciones push
|
||||
|
||||
En la campana, «Avisos en este dispositivo» (si la API tiene claves VAPID y el navegador admite push) registra
|
||||
`/push-sw.js`, pide permiso, suscribe el navegador y manda la suscripción a la API (`PushFacade` + el puerto `PushDevice`,
|
||||
con `BrowserPushDevice` como adaptador). El _service worker_ solo hace push: muestra el aviso y, al pulsarlo, enfoca una
|
||||
pestaña de TaskStream y la lleva al ticket, o abre una. No guarda nada en caché, así que un despliegue nunca deja una
|
||||
versión vieja; nginx sirve `push-sw.js` y `manifest.webmanifest` sin caché. Con el manifiesto la web se puede instalar,
|
||||
que en iPhone es condición para recibir push (iOS 16.4+).
|
||||
|
||||
### Calendario
|
||||
|
||||
«Mi trabajo» tiene un botón «Calendario» que abre `ts-calendar-feed`: crea la URL secreta, la copia, la renueva o la
|
||||
desactiva (`CalendarFacade`, sin store). La URL es absoluta, compuesta con `restUrl` y el origen de la página, porque es
|
||||
lo que se pega en la aplicación de calendario.
|
||||
|
||||
### Texto con formato
|
||||
|
||||
Descripciones y comentarios se escriben con `ts-rich-editor`, un editor visual sobre
|
||||
@@ -223,13 +255,21 @@ globales como `.ts-pill` o `.ts-key` leen esa variable. Las animaciones se desac
|
||||
|
||||
Estados, tipos y áreas son de cada proyecto, así que la UI nunca los tiene fijos: `ProjectFacade.workflow()` envuelve
|
||||
`project.settings` (`Workflow` en el dominio web) y da nombre, color, icono, categoría, transiciones permitidas y si un
|
||||
estado pide motivo. `statusLook`/`typeLook` de `ui/copy.ts` lo convierten en etiqueta y tono (`--ts-c-<color>`, con su
|
||||
versión oscura). Un id que ya no está configurado sigue mostrándose (`IN_REVIEW` → «In review»). «Mi trabajo» mezcla
|
||||
estado pide motivo. `statusLook`/`typeLook` de `ui/copy.ts` lo convierten en etiqueta y tono: `--ts-c-<color>` para
|
||||
la paleta, con su versión oscura hecha a mano, y para un `#rrggbb` propio, `light-dark()` con una variante por tema
|
||||
que `ui/colors.ts` calcula con la librería `color` (se acerca al blanco o al negro hasta contrastar 3:1 con la
|
||||
superficie). En Configuración, cada estado y tipo elige su color con el selector nativo (`<input type="color">`), con
|
||||
la paleta como sugerencias; si se elige un color de la paleta, se guarda su nombre. Un id que ya no está configurado sigue mostrándose (`IN_REVIEW` → «In review»). «Mi trabajo» mezcla
|
||||
proyectos: cada fila usa el workflow de su proyecto y los grupos son por categoría.
|
||||
|
||||
En el tablero, un ticket solo se puede soltar en las columnas a las que su workflow le deja pasar, y los tipos
|
||||
archivados no se ofrecen al crear. Los tipos con icono `palette` piden un frame de Figma.
|
||||
|
||||
Los campos personalizados se definen en Configuración (el tipo se elige al crearlos y luego queda fijo; las opciones
|
||||
de una lista, una por línea). En la ficha, `ui/custom-field.ts` pone el control de cada tipo: las listas y las fechas
|
||||
se guardan al elegir; el texto y los números, al salir del campo. En la lista, cada campo de opciones añade un filtro y
|
||||
los valores del ticket se ven bajo su título.
|
||||
|
||||
### Gráficas
|
||||
|
||||
Las de Estadísticas son SVG propios (`ui/series-chart.ts`), no `pi-line-chart`: hacen falta áreas apiladas y una
|
||||
@@ -248,33 +288,40 @@ visualización de la plataforma:
|
||||
|
||||
### Proyectos públicos y visitantes sin sesión
|
||||
|
||||
- Solo la portada (`/`) exige sesión. `/explorar` y las páginas de proyecto (`/p/:key/…`) no: la API decide qué ve
|
||||
- Solo la portada (`/`) exige sesión. `/explore` y las páginas de proyecto (`/p/:key/…`) no: la API decide qué ve
|
||||
cada visitante, y las páginas ya ocultan lo que no puede cambiar (`canContribute`, `canManage`).
|
||||
- Sin sesión, la cabecera muestra «Explorar» e «Iniciar sesión»; dentro de un proyecto público aparece un aviso de modo
|
||||
lectura, y en uno privado (o inexistente), el enlace para iniciar sesión volviendo a esa página (`returnUrl`).
|
||||
- El tiempo real necesita sesión: `GraphqlRealtimeGateway` no abre subscriptions sin token (si lo hiciera, la API
|
||||
cerraría el WebSocket y el cliente reintentaría sin fin). Los visitantes anónimos ven los datos al cargar la página.
|
||||
- Configuración tiene el interruptor «Público» (propietario y admins) y, solo para el propietario, «Eliminar el
|
||||
- Configuración tiene en General la casilla «Proyecto público» (propietario y admins) y, en Zona peligrosa y solo para el propietario, «Eliminar el
|
||||
proyecto», que exige escribir la clave. Al borrarlo se vuelve a la portada; si otro lo borra mientras lo tienes
|
||||
abierto, la subscription avisa (`ProjectChanged.deleted`) y también se vuelve a la portada.
|
||||
|
||||
## 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 |
|
||||
| `/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/releases` | Releases |
|
||||
| `/p/:key/dashboard` | Seguimiento: cifras, flujo por estado, equipo (cada persona enlaza a su tablero) y tipos |
|
||||
| `/p/:key/analytics` | Estadísticas: creados vs. resueltos, flujo acumulado, burnup de una release, tiempos de ciclo y entrega, QA y resueltos por persona (30 días a 1 año) |
|
||||
| `/p/:key/settings` | Configuración (propietario y admins; pestaña oculta para el resto): estados (orden, categoría, color, motivo), transiciones, tipos y áreas; archivar en lugar de borrar |
|
||||
| `/p/:key/activity` | Actividad en vivo |
|
||||
| `/p/:key/members` | Miembros |
|
||||
| Ruta | Página |
|
||||
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `/login` | Inicio de sesión (solo sin sesión) |
|
||||
| `/auth/callback` | Retorno de Pireon (PKCE) |
|
||||
| `/` | Inicio: saludo según la hora con el resumen del día, «Ir a…» (`WEB-12`), primeros pasos, mis proyectos con su actividad (lo primero, para llegar antes), «Mi día» (`ts-my-day`: los tickets que elijo para hoy, tachados al terminar, mis abiertos para añadir y lo que quedó del último día), mis fijados (`ts-pinned`, una franja que se quita con ×; solo si hay), cuatro cifras (`ts-home-kpis`: abiertos míos, esperando QA, terminados por mí y por el equipo esta semana), la semana (`ts-week-agenda`), mis vistas guardadas y dos columnas: mis tickets y lo último en mis proyectos; sprints en curso, esperando QA, que sigo, próximas releases, quién está ahora y «Retomar» |
|
||||
| `/portfolio` | 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; en directo: se vuelve a leer sola cuando cambia un ticket, sprint o release de esos proyectos. Cada proyecto lleva su semáforo de salud (`portfolioHealth`), con las causas al pasar por encima |
|
||||
| `/explore` | Proyectos públicos (sin sesión) |
|
||||
| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros). Quien puede contribuir cambia prioridad, estimación y persona desde la propia tarjeta (selectores nativos sobre sus distintivos; el título es el enlace). Mover una tarjeta o cambiar su persona ofrece «Deshacer» unos segundos (o Ctrl/⌘ Z): la orden contraria, que el flujo puede rechazar. Con el teclado: Tab entra en las tarjetas, las flechas se mueven entre ellas, Shift+←/→ pasa la tarjeta de columna (mismas reglas que arrastrar), `p`/`e`/`a` abren su prioridad, estimación o persona y Enter la abre |
|
||||
| `/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). A la vista, «Votar», «Seguir» y «Editar»; el resto (pedir revisión, mi día, recordármelo, seguir todo, fijar y duplicar) está en el menú «Más» (menú del CDK: se recorre con las flechas y Escape lo cierra). «Fijar» lo deja a mano en el inicio y en la paleta de búsqueda. Arriba, sus decisiones (comentarios marcados con la bandera por quien puede cambiar el ticket); `?comment=<id>` lleva a un comentario y lo señala. Cambiar el estado o la persona y mandarlo a la papelera (sin confirmación) ofrecen «Deshacer». Cada conversación con respuestas se resuelve («✓ Resolver», con una conclusión opcional que queda como decisión) y se pliega en una línea; la cabecera cuenta las abiertas y terminar el ticket con alguna pregunta antes. Se ve quién está escribiendo en cada conversación («Ana está escribiendo…», `PresenceFacade.typists`), y en las respuestas Enter envía (Shift+Enter, otra línea). Si tiene subtareas, «Seguir todo» avisa de ella y de todo lo que cuelga, también lo que se añada (también desde Épicas). «Recordármelo» pone un aviso solo para mí (dentro de 1 hora, esta tarde, mañana o el lunes a las 9, en una semana o cuando diga), con nota opcional. «☀ Mi día» lo añade a los de hoy. «Pedir revisión» a alguien del equipo: lo ve en «Mi trabajo» («Te piden revisión») y responde «Todo bien» o «Pedir cambios» desde el recuadro Revisiones. Un comentario puede llevar una encuesta («Encuesta»; con su pregunta y de 2 a 6 opciones; el botón pasa a «Publicar encuesta» y el texto del comentario es opcional): se vota pulsando una opción y, al cerrarla, la ganadora queda como decisión. «🎥 Grabar pantalla» (con la voz, si se da permiso al micrófono) y «🎙 Grabar audio» graban en el navegador hasta 3 minutos y lo adjuntan al comentario, que puede ir sin texto; los vídeos y audios adjuntos se reproducen ahí mismo. Al abrirlo, lo que otros han hecho desde tu última visita: un aviso arriba («Desde tu última visita, hace 2 días: 3 comentarios y 1 cambio de Ana y Bob», con «Ver lo nuevo»), los comentarios nuevos marcados («Nuevo», también en una conversación plegada) y los cambios del historial resaltados. Cada comentario se puede «Convertir en ticket» (subtarea de este o enlazado, con el título sacado de su primera línea): el comentario enlaza al ticket nuevo («→ Convertido en WEB-45») |
|
||||
| `/p/:key/releases` | Releases. «Página pública» (quien gestiona): un enlace secreto con las notas para clientes, que se copia, renueva o retira |
|
||||
| `/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. Los bloqueos con tickets de otros proyectos (en los dos sentidos, `externalDependencies`) salen en «De otros proyectos» con barra discontinua, sus flechas y el mismo aviso si las fechas no encajan; los que no tienen fechas o no se pueden leer se nombran debajo |
|
||||
| `/p/:key/dashboard` | Arriba, la salud del proyecto (`ts-project-health`): un semáforo (bien, atención, en riesgo) con cada causa y sus tickets, calculado en la web (`projectHealth`) con los mismos datos: atascados, vencidos, bloqueados, bugs críticos o altos, lo que devuelve QA, releases que no llegan y columnas sobre su WIP. 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, el recorrido de un ticket (dónde se va el tiempo de creado a hecho y qué parte es trabajo), la actividad de 12 semanas en un mapa de calor (pulsar un día abre lo que pasó, ticket a ticket), el embudo de propuestas del público (si las hay), equipo con sus puntos abiertos y tipos. Se recarga solo con cada cambio. «Modo pantalla» lo pone a pantalla completa para una tele del equipo: un bloque cada vez, ampliado, que cambia cada 15 s (← → para moverse, espacio para pausar, Esc para salir), con la hora y en directo |
|
||||
| `/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), con un menú de secciones (`?section=`): General (visibilidad, propuestas, panel para clientes), Flujo de trabajo (estados, transiciones, definición de «Hecho»), Tickets (tipos, áreas, campos), Equipos, Automatizaciones, Integraciones (Git, alertas → bugs con su URL secreta y cómo conectar Sentry, Uptime Kuma o Grafana, webhooks), Importar y exportar y Zona peligrosa; archivar en lugar de borrar, y un punto en el menú donde hay cambios sin guardar |
|
||||
| `/p/:key/activity` | Actividad en vivo |
|
||||
| `/p/:key/propose` | Formulario público para reportar un error o proponer una idea sin cuenta (si el proyecto lo tiene abierto); llegan como tickets con la etiqueta «propuesta» |
|
||||
| `/share/:token` | Panel para clientes, sin sesión: los bloques que eligió el proyecto (flujo, sprint, releases, épicas, actividad), sin enlaces hacia dentro de la aplicación; se vuelve a leer cada minuto. Si el enlace se renovó o retiró, «Este enlace ya no funciona» |
|
||||
| `/notes/:token` | Notas de una release para sus clientes, sin cuenta: las escritas o las de sus tickets terminados |
|
||||
| `/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
|
||||
los componentes (`withComponentInputBinding`).
|
||||
los componentes (`withComponentInputBinding`). Las rutas van en inglés; las antiguas en castellano (`/cartera`,
|
||||
`/explorar`, `/p/:key/proponer`) redirigen a las nuevas, para que sigan valiendo los enlaces ya compartidos.
|
||||
|
||||
@@ -36,7 +36,10 @@ genéricos, el DNS de Docker devuelve también los suyos y nginx reparte las pet
|
||||
de las llamadas a `/api/*` responden `404 Cannot GET …` y el login dice «no configurado»). Si añades un servicio,
|
||||
dale su alias `task-stream-*` y úsalo en las URLs.
|
||||
|
||||
Todos tienen healthcheck; `web` espera a que `api` esté sana, y `api` a las tres bases de datos.
|
||||
Todos tienen healthcheck; `web` espera a que `api` esté sana, y `api` a las tres bases de datos. `GET /api/health` responde 503 si cae una base de datos; con
|
||||
las tres en pie, `status: degraded` (200) avisa de eventos que no llegan a la lectura (`events.problems`). Los eventos
|
||||
apartados por fallar una y otra vez se ven con `redis-cli XRANGE task-stream:events:dead - +`; arreglado el fallo, se
|
||||
reconstruye la lectura (abajo) o se vuelven a publicar.
|
||||
|
||||
### Imágenes
|
||||
|
||||
@@ -70,39 +73,41 @@ donde los lee Compose.
|
||||
|
||||
## 7.2 Variables de entorno de la API
|
||||
|
||||
| Variable | Por defecto | Descripción |
|
||||
| ------------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `PORT` | `3000` | Puerto HTTP |
|
||||
| `NODE_ENV` | `development` | En `production` no se devuelven stacktraces |
|
||||
| `POSTGRES_URL` | — (obligatoria) | Event store y lookups |
|
||||
| `POSTGRES_SYNCHRONIZE` | `false` | Crea las tablas desde las entidades. Cómodo en desarrollo; en producción, migraciones |
|
||||
| `MONGO_URL` | — (obligatoria) | Read model |
|
||||
| `REDIS_URL` | — (obligatoria) | Stream, pub/sub y caché |
|
||||
| `EVENT_STREAM_KEY` | `task-stream:events` | Clave del Redis Stream |
|
||||
| `EVENT_STREAM_MAX_LENGTH` | `1000000` | Longitud aproximada que se conserva en el stream |
|
||||
| `EVENTS_BRIDGE_ENABLED` | `true` | Si esta instancia proyecta eventos hacia Mongo (ver escalado) |
|
||||
| `PIREON_BASE_URL` | `https://oauth.pireon.dev` | Servidor de identidad |
|
||||
| `PIREON_CLIENT_ID` / `PIREON_CLIENT_SECRET` | — | Cliente OAuth `authorization_code`: «Entrar con Pireon» y Swagger UI |
|
||||
| `PIREON_PASSWORD_CLIENT_ID` / `_SECRET` | el anterior | Cliente OAuth `password`: login con email y contraseña |
|
||||
| `PIREON_SCOPES` | `users:read` | Scopes que se piden al iniciar sesión |
|
||||
| `PIREON_PASSWORD_GRANT` | `true` | Permite el login con email y contraseña |
|
||||
| `PIREON_WEBHOOK_SECRET` | — | Activa y firma los webhooks de Pireon |
|
||||
| `PIREON_PASSWORD_WEBHOOK_SECRET` | — | El del webhook del cliente de contraseña, si es otro cliente |
|
||||
| `PIREON_TIMEOUT_MS` | `5000` | Timeout de las llamadas a Pireon |
|
||||
| `AUTH_SESSION_TTL_SECONDS` | `60` | Cuánto se confía en un token validado antes de volver a preguntar a Pireon |
|
||||
| `GRAPHQL_PLAYGROUND` | `true` | GraphiQL e introspección |
|
||||
| `GRAPHQL_SCHEMA_FILE` | `apps/api/schema.gql` | Dónde escribir el SDL (vacío = no escribir; así está en Docker) |
|
||||
| `SWAGGER_ENABLED` | `true` | API REST documentada: Swagger UI en `/api/docs`, OpenAPI en `/api/docs-json` |
|
||||
| `OPENAPI_FILE` | `apps/api/openapi.json` | Dónde escribir el documento OpenAPI al arrancar (vacío = no escribir; así en Docker) |
|
||||
| `ATTACHMENTS_DIR` | `data/attachments` | Carpeta de los adjuntos de los tickets; en Docker, el volumen `attachments-data` |
|
||||
| `SMTP_URL` | — | Servidor de correo para las copias de las notificaciones (`smtps://user:pass@host`); sin él no se envía nada |
|
||||
| `MAIL_FROM` | `TaskStream <no-reply@pireon.dev>` | Remitente de esos correos |
|
||||
| `WEB_URL` | — | Dirección pública de la web, para los enlaces de los correos |
|
||||
| `ATTACHMENTS_MAX_MB` | `25` | Tamaño máximo de un adjunto (nginx admite hasta 30 MB por petición) |
|
||||
| `WEBHOOKS_ALLOW_PRIVATE` | `false` | Deja que los webhooks salientes llamen a direcciones de esta máquina o de su red privada (apagado, un webhook podría alcanzar servicios internos) |
|
||||
| `REMINDERS_ENABLED` | `true` | Avisa cada hora de los tickets que vencen mañana, hoy o ya han vencido |
|
||||
| `REMINDERS_TIMEZONE` | `Europe/Madrid` | Zona horaria en la que cambia el día para los recordatorios |
|
||||
| `TRASH_DAYS` | `30` | Días que un ticket pasa en la papelera antes de borrarse del todo |
|
||||
| Variable | Por defecto | Descripción |
|
||||
| ------------------------------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `PORT` | `3000` | Puerto HTTP |
|
||||
| `NODE_ENV` | `development` | En `production` no se devuelven stacktraces |
|
||||
| `POSTGRES_URL` | — (obligatoria) | Event store y lookups |
|
||||
| `POSTGRES_SYNCHRONIZE` | `false` | Crea las tablas desde las entidades. Cómodo en desarrollo; en producción, migraciones |
|
||||
| `MONGO_URL` | — (obligatoria) | Read model |
|
||||
| `REDIS_URL` | — (obligatoria) | Stream, pub/sub y caché |
|
||||
| `EVENT_STREAM_KEY` | `task-stream:events` | Clave del Redis Stream |
|
||||
| `EVENT_STREAM_MAX_LENGTH` | `1000000` | Longitud aproximada que se conserva en el stream |
|
||||
| `EVENTS_BRIDGE_ENABLED` | `true` | Si esta instancia proyecta eventos hacia Mongo (ver escalado) |
|
||||
| `PIREON_BASE_URL` | `https://oauth.pireon.dev` | Servidor de identidad |
|
||||
| `PIREON_CLIENT_ID` / `PIREON_CLIENT_SECRET` | — | Cliente OAuth `authorization_code`: «Entrar con Pireon» y Swagger UI |
|
||||
| `PIREON_PASSWORD_CLIENT_ID` / `_SECRET` | el anterior | Cliente OAuth `password`: login con email y contraseña |
|
||||
| `PIREON_SCOPES` | `users:read` | Scopes que se piden al iniciar sesión |
|
||||
| `PIREON_PASSWORD_GRANT` | `true` | Permite el login con email y contraseña |
|
||||
| `PIREON_WEBHOOK_SECRET` | — | Activa y firma los webhooks de Pireon |
|
||||
| `PIREON_PASSWORD_WEBHOOK_SECRET` | — | El del webhook del cliente de contraseña, si es otro cliente |
|
||||
| `PIREON_TIMEOUT_MS` | `5000` | Timeout de las llamadas a Pireon |
|
||||
| `AUTH_SESSION_TTL_SECONDS` | `60` | Cuánto se confía en un token validado antes de volver a preguntar a Pireon |
|
||||
| `GRAPHQL_PLAYGROUND` | `true` | GraphiQL e introspección |
|
||||
| `GRAPHQL_SCHEMA_FILE` | `apps/api/schema.gql` | Dónde escribir el SDL (vacío = no escribir; así está en Docker) |
|
||||
| `SWAGGER_ENABLED` | `true` | API REST documentada: Swagger UI en `/api/docs`, OpenAPI en `/api/docs-json` |
|
||||
| `OPENAPI_FILE` | `apps/api/openapi.json` | Dónde escribir el documento OpenAPI al arrancar (vacío = no escribir; así en Docker) |
|
||||
| `ATTACHMENTS_DIR` | `data/attachments` | Carpeta de los adjuntos de los tickets; en Docker, el volumen `attachments-data` |
|
||||
| `SMTP_URL` | — | Servidor de correo para las copias de las notificaciones (`smtps://user:pass@host`); sin él no se envía nada |
|
||||
| `MAIL_FROM` | `TaskStream <no-reply@pireon.dev>` | Remitente de esos correos |
|
||||
| `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` | — | Notificaciones push (Web Push). Sin las dos no se envía nada. Se crean una vez con `npx web-push generate-vapid-keys`; cambiarlas anula las suscripciones |
|
||||
| `VAPID_SUBJECT` | `mailto:no-reply@pireon.dev` | Contacto (`mailto:` o `https:`) que los servicios push pueden usar si algo va mal |
|
||||
| `WEB_URL` | — | Dirección pública de la web, para los enlaces de los correos |
|
||||
| `ATTACHMENTS_MAX_MB` | `25` | Tamaño máximo de un adjunto (nginx admite hasta 30 MB por petición) |
|
||||
| `WEBHOOKS_ALLOW_PRIVATE` | `false` | Deja que los webhooks salientes llamen a direcciones de esta máquina o de su red privada (apagado, un webhook podría alcanzar servicios internos) |
|
||||
| `REMINDERS_ENABLED` | `true` | Avisa cada hora de los tickets que vencen mañana, hoy o ya han vencido |
|
||||
| `REMINDERS_TIMEZONE` | `Europe/Madrid` | Zona horaria en la que cambia el día para los recordatorios |
|
||||
| `TRASH_DAYS` | `30` | Días que un ticket pasa en la papelera antes de borrarse del todo |
|
||||
|
||||
La web no tiene configuración de entorno: llama a la API en su mismo origen (`/api`, `/graphql`).
|
||||
|
||||
|
||||
109
docs/09-mcp.md
109
docs/09-mcp.md
@@ -32,55 +32,68 @@ 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) |
|
||||
| | `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_project_activity` | Actividad reciente |
|
||||
| Tickets | `my_issues` | Mi trabajo en todos los proyectos (o de cualquiera, por categoría; o los que sigo) |
|
||||
| | `search_issues` | Buscar por texto y filtros (épica, sprint, fechas…); 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) |
|
||||
| | `update_issue` | Cambiar título, descripción, tipo, área, prioridad, etiquetas, fechas… |
|
||||
| | `change_issue_status` | Mover de estado (con motivo cuando el estado lo pide) |
|
||||
| | `assign_issue` | Asignar o desasignar |
|
||||
| | `set_issue_release` | Meter en una release o sacar |
|
||||
| | `add_comment` / `edit_comment` / `delete_comment` | Comentar (o responder a un comentario), corregir o borrar |
|
||||
| | `link_issues` / `unlink_issues` | Relacionar tickets (`DEPENDS_ON`, `BLOCKS`, `TESTS`…) o quitar la relación |
|
||||
| | `watch_issue` | Seguir un ticket o dejar de seguirlo |
|
||||
| | `vote_issue` | Votar un ticket o quitar el voto |
|
||||
| | `duplicate_issue` | Crear otro ticket con lo mismo (sin estado, persona ni comentarios) |
|
||||
| | `react_to_comment` | Reaccionar a un comentario (👍 🎉…) o quitar la reacción |
|
||||
| | `add_checklist_item` / `update_checklist_item` / `remove_checklist_item` | La lista de comprobación: añadir, marcar o reescribir, quitar |
|
||||
| Organización | `list_epics` | Épicas con el progreso de lo que cuelga de cada una |
|
||||
| | `set_issue_parent` | Colgar un ticket de una épica u otro ticket, o soltarlo |
|
||||
| | `rank_issue` | Mover un ticket en el backlog |
|
||||
| | `bulk_update_issues` | El mismo cambio en hasta 100 tickets (por clave); dice cuáles no se dejaron |
|
||||
| | `delete_issue` / `restore_issue` / `list_trash` | Papelera: borrar (30 días para recuperar), recuperar y ver |
|
||||
| Sprints | `list_sprints` | Sprints de un proyecto |
|
||||
| | `get_sprint` | Progreso, tickets y burndown |
|
||||
| | `create_sprint` / `update_sprint` / `delete_sprint` | Planificar, cambiar o borrar (si no empezó) un sprint |
|
||||
| | `start_sprint` / `close_sprint` | Empezarlo; cerrarlo pasando lo pendiente a otro sprint o al backlog |
|
||||
| | `set_issue_sprint` | Meter un ticket en un sprint o devolverlo al backlog |
|
||||
| Releases | `list_releases` | Releases de un proyecto |
|
||||
| | `upcoming_releases` | Próximas releases de mis proyectos |
|
||||
| | `get_release` | Progreso, notas, tickets y burnup |
|
||||
| | `create_release` / `update_release` | Planificar una release o cambiar su nombre, descripción o fecha |
|
||||
| | `draft_release_notes` / `set_release_notes` | Borrador de las notas desde los tickets terminados, y guardarlas |
|
||||
| | `publish_release` | Publicar (con `force` si quedan tickets sin terminar) |
|
||||
| | `archive_release` | Archivar una release que no saldrá (sus tickets la dejan) |
|
||||
| Á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), 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, riesgo, antigüedad, cuellos de botella, releases, equipo, backlog, calidad, campos, actividad por día, recorrido de un ticket, propuestas y, con `since`, los cambios |
|
||||
| | `get_project_activity` | Actividad reciente, o la de un día (`day`) |
|
||||
| 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; `reviewing`: los que me piden revisar |
|
||||
| | `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, decisiones y comentarios |
|
||||
| | `create_issue` | Crear un ticket (también colgado de una épica) |
|
||||
| | `update_issue` | Cambiar título, descripción, tipo, área, prioridad, etiquetas, fechas, campos personalizados… |
|
||||
| | `change_issue_status` | Mover de estado (con motivo cuando el estado lo pide) |
|
||||
| | `assign_issue` | Asignar o desasignar |
|
||||
| | `set_issue_release` | Meter en una release o sacar |
|
||||
| | `add_comment` / `edit_comment` / `delete_comment` | Comentar (o responder a un comentario), corregir o borrar |
|
||||
| | `link_issues` / `unlink_issues` | Relacionar tickets (`DEPENDS_ON`, `BLOCKS`, `TESTS`…) o quitar la relación |
|
||||
| | `watch_issue` | Seguir un ticket o dejar de seguirlo; con `tree`, una épica con todo lo que cuelga de ella |
|
||||
| | `vote_issue` | Votar un ticket o quitar el voto |
|
||||
| | `duplicate_issue` | Crear otro ticket con lo mismo (sin estado, persona ni comentarios) |
|
||||
| | `react_to_comment` | Reaccionar a un comentario (👍 🎉…) o quitar la reacción |
|
||||
| | `mark_decision` | Marcar un comentario como decisión del ticket, o quitarla |
|
||||
| | `resolve_thread` | Resolver una conversación (con conclusión opcional, que queda como decisión) o reabrirla |
|
||||
| | `remind_me` | Recordármelo: un aviso solo para mí sobre un ticket, a una fecha y hora, con nota (o quitarlo) |
|
||||
| | `my_day` | Mi día: los tickets elegidos para hoy (y lo que quedó del último día); añadir o quitar uno |
|
||||
| | `request_review` | Pedir a alguien del proyecto que revise un ticket |
|
||||
| | `answer_review` | Responder una revisión: todo bien o cambios |
|
||||
| | `vote_poll` | Votar en la encuesta de un comentario (o retirar el voto) |
|
||||
| | `close_poll` | Cerrar una encuesta; la ganadora queda como decisión |
|
||||
| | `turn_comment_into_issue` | Convertir un comentario en ticket: subtarea del suyo o enlazado; su texto es la descripción |
|
||||
| | `search_decisions` | Qué se decidió: decisiones por su texto, de un proyecto o de todos los míos |
|
||||
| | `add_checklist_item` / `update_checklist_item` / `remove_checklist_item` | La lista de comprobación: añadir, marcar o reescribir, quitar |
|
||||
| Organización | `list_epics` | Épicas con el progreso de lo que cuelga de cada una |
|
||||
| | `set_issue_parent` | Colgar un ticket de una épica u otro ticket, o soltarlo |
|
||||
| | `rank_issue` | Mover un ticket en el backlog |
|
||||
| | `bulk_update_issues` | El mismo cambio en hasta 100 tickets (por clave); dice cuáles no se dejaron |
|
||||
| | `delete_issue` / `restore_issue` / `list_trash` | Papelera: borrar (30 días para recuperar), recuperar y ver |
|
||||
| Sprints | `list_sprints` | Sprints de un proyecto |
|
||||
| | `get_sprint` | Progreso, tickets y burndown |
|
||||
| | `create_sprint` / `update_sprint` / `delete_sprint` | Planificar, cambiar o borrar (si no empezó) un sprint |
|
||||
| | `start_sprint` / `close_sprint` | Empezarlo; cerrarlo pasando lo pendiente a otro sprint o al backlog |
|
||||
| | `set_issue_sprint` | Meter un ticket en un sprint o devolverlo al backlog |
|
||||
| Releases | `list_releases` | Releases de un proyecto |
|
||||
| | `upcoming_releases` | Próximas releases de mis proyectos |
|
||||
| | `get_release` | Progreso, notas, tickets y burnup |
|
||||
| | `create_release` / `update_release` | Planificar una release o cambiar su nombre, descripción o fecha |
|
||||
| | `draft_release_notes` / `set_release_notes` | Borrador de las notas desde los tickets terminados, y guardarlas |
|
||||
| | `publish_release` | Publicar (con `force` si quedan tickets sin terminar) |
|
||||
| | `archive_release` | Archivar una release que no saldrá; lo pendiente pasa a `moveTo` (otra release abierta) o la deja |
|
||||
|
||||
Las de lectura van marcadas como `readOnlyHint`, para que el cliente no pida confirmación al usarlas, y las que borran o
|
||||
archivan, como `destructiveHint`, para que la pida. Lo que es de administración (miembros, configuración, visibilidad,
|
||||
|
||||
@@ -14,7 +14,7 @@ cuando hay pistas, por dónde empezar.
|
||||
| Limpiar los datos de prueba de producción | DEMO, TEST y los tickets de las pruebas del MCP | Borrar los proyectos desde Configuración (borrado definitivo con su clave) |
|
||||
| Registrar el webhook del cliente de contraseña en Pireon | Sin él, revocar un token de quien entra con contraseña no se nota hasta que caduca la caché | Solo si `PIREON_PASSWORD_CLIENT_ID` tiene valor: webhook en ese cliente y su secret en `PIREON_PASSWORD_WEBHOOK_SECRET` (ver [Autenticación](05-autenticacion.md)) |
|
||||
| Una versión 1.0 | Un punto fijo al que volver | Desplegar todo, etiquetar `v1.0.0` y redactar la lista de cambios (las notas de versión de TaskStream dan el borrador) |
|
||||
| Revisión de seguridad | Hay rutas sin sesión (proyectos públicos, escaparate, propuestas) y un MCP abierto a Internet | Permisos por rol, qué ve un anónimo, límites de peticiones (solo las propuestas tienen), CORS, cabeceras |
|
||||
| Revisión de seguridad | Hay rutas sin sesión (proyectos públicos, escaparate, propuestas, panel para clientes) y un MCP abierto a Internet | Permisos por rol, qué ve un anónimo, límites de peticiones (solo las propuestas tienen), CORS, cabeceras |
|
||||
| Registro de errores en producción | Hoy un fallo solo se conoce si alguien avisa | Sentry (o similar) en la API, la web y el MCP |
|
||||
|
||||
## 10.2 Fallos y deuda conocidos
|
||||
@@ -28,17 +28,32 @@ cuando hay pistas, por dónde empezar.
|
||||
|
||||
## 10.3 Siguientes funcionalidades
|
||||
|
||||
En orden de lo que más aportaría:
|
||||
### En curso
|
||||
|
||||
1. **Asistente en la web** (la base ya está en el MCP):
|
||||
- resumir un ticket largo con sus comentarios;
|
||||
- al crear un ticket, sugerir tipo, prioridad y área, y avisar si se parece a otro que ya existe;
|
||||
- redactar las notas de versión en lenguaje natural a partir del borrador.
|
||||
2. **Avisos a Discord, Slack o Telegram**: los webhooks salientes mandan JSON en bruto, que esos chats no muestran
|
||||
bien. Una integración por canal (o un formato por destino en los webhooks) mandaría mensajes ya legibles: «Ana
|
||||
movió WEB-12 a En revisión», con enlace.
|
||||
3. **Conector de claude.ai**: el MCP necesitaría el flujo OAuth del protocolo, y Pireon publicar su metadata de
|
||||
autorización y admitir el registro dinámico de clientes.
|
||||
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.
|
||||
- **Búsqueda con lenguaje de consulta**: `estado:abierto asignado:yo navegador:Safari prioridad>=alta`, guardable como
|
||||
vista.
|
||||
- **Entornos de despliegue**: en qué entorno está cada ticket (pruebas, producción), desde los avisos de la
|
||||
integración continua.
|
||||
- **Página pública de novedades**: las notas de versión publicadas en una página por proyecto, con RSS.
|
||||
|
||||
### Otras ideas apuntadas, sin decidir
|
||||
|
||||
- **Resumen diario por correo**: lo asignado, lo que vence hoy y lo que ha cambiado en lo que sigues, en un solo correo.
|
||||
- **Tickets recurrentes**: «revisar copias cada lunes», creados solos con la plantilla del tipo.
|
||||
- **Crear tickets por correo**: una dirección por proyecto; lo que llega es un ticket (con adjuntos) y las respuestas,
|
||||
comentarios. Necesita un buzón de entrada.
|
||||
- **Posponer un ticket**: «recuérdamelo el jueves».
|
||||
- **Reunión diaria**: qué cerró cada uno ayer, qué tiene hoy y qué le bloquea, sacado del historial.
|
||||
- **Requisitos al cambiar de estado**: campos obligatorios para entrar en un estado (estimación para «En curso»…).
|
||||
- **Plazos por prioridad**: «lo crítico, en 24 h», con aviso antes de vencer.
|
||||
- **Matriz impacto/esfuerzo** para ordenar el backlog.
|
||||
- **Planning poker** y **retrospectiva del sprint** (las acciones se convierten en tickets).
|
||||
- **Avisar a quien propuso** cuando su propuesta se hace o se descarta.
|
||||
|
||||
## 10.4 Descartado por ahora
|
||||
|
||||
@@ -47,6 +62,11 @@ Mucho trabajo para lo que aportan hoy; se pueden recuperar si alguien los necesi
|
||||
- **Registro de horas** dedicadas a cada ticket.
|
||||
- **Objetivos u OKR** por encima de las épicas.
|
||||
- **App móvil**: la web ya se adapta a pantallas estrechas.
|
||||
- **Asistente en la web** (resumir tickets, sugerir tipo, prioridad y área, redactar notas de versión): el MCP ya deja
|
||||
hacerlo desde un cliente de IA.
|
||||
- **Avisos a Discord, Slack o Telegram** con mensajes legibles: los webhooks salientes siguen ahí para quien los
|
||||
necesite.
|
||||
- **Conector de claude.ai** (OAuth del protocolo en el MCP y registro dinámico de clientes en Pireon).
|
||||
|
||||
## 10.5 Hecho desde la primera versión de esta lista
|
||||
|
||||
@@ -68,3 +88,50 @@ Mucho trabajo para lo que aportan hoy; se pueden recuperar si alguien los necesi
|
||||
- Ids: `uuid` en todas las tablas de Postgres (el orden de los eventos en `seq`) y, en Mongo, `id` con el uuid del
|
||||
dominio más el `_id` `ObjectId` propio.
|
||||
- Enlazar tickets y colgar de un padre eligiendo en un selector con búsqueda; casillas de `@pi/ui` en toda la web.
|
||||
- Mover tickets entre releases: «Release…» en la edición en bloque, filtro por release en la lista (también desde cada
|
||||
release, «Ver en la lista»), y al archivar una release, elegir adónde va lo pendiente.
|
||||
- Calendario suscribible (iCalendar) desde «Mi trabajo»: tickets con fecha, sprints y releases, con URL secreta
|
||||
renovable.
|
||||
- Notificaciones push (Web Push) en cada dispositivo, con la web instalable.
|
||||
- Campos personalizados por proyecto (texto, número, fecha o lista de opciones): se definen en Configuración, se
|
||||
editan en la ficha, se ven bajo el título en la lista y se filtran por los de opciones (también por GraphQL, REST y
|
||||
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 (`/portfolio`): 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`).
|
||||
- Panel del proyecto, cuarta parte: actividad de 12 semanas en un mapa de calor, el recorrido de un ticket (dónde se
|
||||
va el tiempo de creado a hecho, espera incluida, y la parte que es trabajo) y el embudo de propuestas del público
|
||||
con las más votadas sin respuesta.
|
||||
- Modo pantalla del panel: a pantalla completa para una tele del equipo, un bloque cada vez que cambia solo, en
|
||||
directo y con la hora.
|
||||
- Panel para clientes: un enlace de solo lectura, sin cuenta, con los bloques que elijan el propietario y los admins
|
||||
(flujo, sprint, releases, épicas, actividad); se renueva o se retira desde Configuración.
|
||||
- De escritura a lectura: si un evento no se puede proyectar, se reintenta en su sitio (el siguiente espera) hasta
|
||||
que Mongo vuelve, sin reiniciar ni perder cambios; los que fallan por sí mismos se apartan a
|
||||
`task-stream:events:dead`, y `GET /api/health` avisa de eventos atascados o apartados.
|
||||
|
||||
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,
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@ Event store en PostgreSQL y el camino de los eventos hasta la lectura.
|
||||
- `EventStoreRelay`: copia los eventos nuevos al Redis Stream (`FOR UPDATE SKIP LOCKED`).
|
||||
- `EventsBridge`: consume el stream y entrega cada evento a sus `@EventsHandler`, en orden y esperando (ACK al final);
|
||||
después ejecuta los `@Saga()` (el `EventBus` en memoria nunca ve los eventos guardados) y sus comandos, uno a uno.
|
||||
- `EventStreamConsumer`: reintenta en su sitio el evento que falla (el siguiente espera); los fallos de
|
||||
infraestructura (`isTransientError`) sin límite, los del propio evento 5 veces y luego a `<stream>:dead`.
|
||||
- `EventPipelineStatus`: bandeja de salida, pendientes y apartados, para `GET /api/health`.
|
||||
- Conexiones Redis (`REDIS_CLIENT`, `GRAPHQL_PUB_SUB`) y `SharedInfrastructureModule.forRootAsync`.
|
||||
|
||||
Flujo completo: [Arquitectura](../../../../docs/02-arquitectura.md#22-flujo-completo-de-una-escritura).
|
||||
|
||||
@@ -6,3 +6,4 @@ export * from './lib/event-stream/event-stream.consumer';
|
||||
export * from './lib/event-store/entities/event.entity';
|
||||
export * from './lib/event-store/deserializers/event.deserializer';
|
||||
export * from './lib/event-store/serializers/event.serializer';
|
||||
export * from './lib/event-store/event-pipeline.status';
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { deadLetterKey } from '../event-stream/event-stream.consumer';
|
||||
import {
|
||||
EVENT_STREAM_OPTIONS,
|
||||
EventStreamOptions,
|
||||
} from '../event-stream/event-stream.options';
|
||||
import { REDIS_CLIENT } from '../redis/redis.tokens';
|
||||
import { EVENTS_BRIDGE_GROUP } from './events-bridge';
|
||||
|
||||
/** How write reaches read, for the health check. */
|
||||
export interface EventPipelineReading {
|
||||
/** Events in Postgres not yet copied to the Redis stream (the outbox). */
|
||||
unpublished: number;
|
||||
/** Seconds the oldest of them has waited (null: none). */
|
||||
oldestUnpublishedSeconds: number | null;
|
||||
/** Entries handed to the read side and not yet projected (the inbox). */
|
||||
pending: number;
|
||||
/** Events set aside after failing again and again. */
|
||||
dead: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the backlog at each step from the event store to the read model:
|
||||
* a growing one means the read side is falling behind.
|
||||
*/
|
||||
@Injectable()
|
||||
export class EventPipelineStatus {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
@Inject(REDIS_CLIENT) private readonly redis: Redis,
|
||||
@Inject(EVENT_STREAM_OPTIONS) private readonly stream: EventStreamOptions,
|
||||
) {}
|
||||
|
||||
async read(): Promise<EventPipelineReading> {
|
||||
const [outbox, pending, dead] = await Promise.all([
|
||||
this.dataSource.query(
|
||||
`SELECT count(*)::int AS count,
|
||||
EXTRACT(EPOCH FROM now() - min(created_at))::int AS age
|
||||
FROM events WHERE published_at IS NULL`,
|
||||
) as Promise<{ count: number; age: number | null }[]>,
|
||||
this.pending(),
|
||||
this.redis.xlen(deadLetterKey(this.stream.streamKey)),
|
||||
]);
|
||||
return {
|
||||
unpublished: outbox[0]?.count ?? 0,
|
||||
oldestUnpublishedSeconds: outbox[0]?.age ?? null,
|
||||
pending,
|
||||
dead,
|
||||
};
|
||||
}
|
||||
|
||||
/** XPENDING's summary; no group yet (nothing consumed) means none. */
|
||||
private async pending(): Promise<number> {
|
||||
try {
|
||||
const summary = (await this.redis.xpending(
|
||||
this.stream.streamKey,
|
||||
EVENTS_BRIDGE_GROUP,
|
||||
)) as [number, ...unknown[]];
|
||||
return Number(summary[0] ?? 0);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,8 @@ import { RedisConnectionFactory } from '../redis/redis-connection.factory';
|
||||
import { EventDeserializer } from './deserializers/event.deserializer';
|
||||
|
||||
export const EVENTS_BRIDGE_ENABLED = Symbol('EVENTS_BRIDGE_ENABLED');
|
||||
/** The Redis consumer group of the read side. */
|
||||
export const EVENTS_BRIDGE_GROUP = 'events-bridge';
|
||||
|
||||
/** Metadata written by @EventsHandler(...events) and @Saga() (@nestjs/cqrs). */
|
||||
const EVENTS_HANDLER_METADATA = '__eventsHandler__';
|
||||
@@ -84,7 +86,7 @@ export class EventsBridge
|
||||
this.connections.create('events-bridge'),
|
||||
{
|
||||
streamKey: this.stream.streamKey,
|
||||
group: 'events-bridge',
|
||||
group: EVENTS_BRIDGE_GROUP,
|
||||
consumer: hostname(),
|
||||
},
|
||||
(event) => this.handleEvent(event),
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { SerializableEvent } from '@task-stream/api/shared/domain';
|
||||
import Redis from 'ioredis';
|
||||
import { deadLetterKey, EventStreamConsumer } from './event-stream.consumer';
|
||||
import { isTransientError } from './transient-error';
|
||||
|
||||
const KEY = 'test:events';
|
||||
|
||||
/** Just enough of a Redis stream with a consumer group. */
|
||||
class FakeRedis {
|
||||
acked: string[] = [];
|
||||
dead: Record<string, string>[] = [];
|
||||
private handedOut = false;
|
||||
|
||||
constructor(private readonly entries: [string, string[]][]) {}
|
||||
|
||||
async xgroup() {
|
||||
return 'OK';
|
||||
}
|
||||
async xautoclaim() {
|
||||
return ['0-0', []];
|
||||
}
|
||||
async xreadgroup(...args: string[]) {
|
||||
const cursor = args[args.length - 1];
|
||||
if (cursor === '>' && !this.handedOut) {
|
||||
this.handedOut = true;
|
||||
return [[KEY, this.entries]];
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 2));
|
||||
return null;
|
||||
}
|
||||
async xack(_key: string, _group: string, id: string) {
|
||||
this.acked.push(id);
|
||||
return 1;
|
||||
}
|
||||
async xadd(key: string, _id: string, ...fields: string[]) {
|
||||
const entry: Record<string, string> = { key };
|
||||
for (let i = 0; i < fields.length; i += 2) entry[fields[i]] = fields[i + 1];
|
||||
this.dead.push(entry);
|
||||
return '1-0';
|
||||
}
|
||||
disconnect() {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const entry = (id: string, position: number): [string, string[]] => [
|
||||
id,
|
||||
[
|
||||
'event',
|
||||
JSON.stringify({ streamId: 's', type: 'Moved', position, data: {} }),
|
||||
],
|
||||
];
|
||||
|
||||
async function until(done: () => boolean) {
|
||||
const deadline = Date.now() + 2000;
|
||||
while (!done()) {
|
||||
if (Date.now() > deadline) throw new Error('timed out');
|
||||
await new Promise((resolve) => setTimeout(resolve, 2));
|
||||
}
|
||||
}
|
||||
|
||||
function consumer(
|
||||
redis: FakeRedis,
|
||||
handle: (event: SerializableEvent) => Promise<void>,
|
||||
) {
|
||||
return new EventStreamConsumer(
|
||||
redis as unknown as Redis,
|
||||
{
|
||||
streamKey: KEY,
|
||||
group: 'g',
|
||||
consumer: 'c',
|
||||
retryBaseMs: 1,
|
||||
maxRetryMs: 2,
|
||||
maxAttempts: 3,
|
||||
},
|
||||
handle,
|
||||
);
|
||||
}
|
||||
|
||||
const mongoDown = () =>
|
||||
Object.assign(new Error('connection refused'), {
|
||||
name: 'MongoNetworkError',
|
||||
});
|
||||
|
||||
describe('EventStreamConsumer', () => {
|
||||
it('retries a failing event in place: the next one waits for it', async () => {
|
||||
const redis = new FakeRedis([entry('1-0', 1), entry('2-0', 2)]);
|
||||
const handled: number[] = [];
|
||||
let failures = 2;
|
||||
const c = consumer(redis, async (event) => {
|
||||
if (event.position === 1 && failures-- > 0) throw mongoDown();
|
||||
handled.push(event.position);
|
||||
});
|
||||
await c.start();
|
||||
await until(() => redis.acked.length === 2);
|
||||
await c.stop();
|
||||
|
||||
expect(handled).toEqual([1, 2]);
|
||||
expect(redis.acked).toEqual(['1-0', '2-0']);
|
||||
expect(redis.dead).toEqual([]);
|
||||
});
|
||||
|
||||
it('waits out the infrastructure however long: it never sets it aside', async () => {
|
||||
const redis = new FakeRedis([entry('1-0', 1)]);
|
||||
let failures = 10; // well past maxAttempts
|
||||
const c = consumer(redis, async () => {
|
||||
if (failures-- > 0) throw mongoDown();
|
||||
});
|
||||
await c.start();
|
||||
await until(() => redis.acked.length === 1);
|
||||
await c.stop();
|
||||
|
||||
expect(redis.dead).toEqual([]);
|
||||
});
|
||||
|
||||
it('sets aside an event that keeps failing on its own, and goes on', async () => {
|
||||
const redis = new FakeRedis([entry('1-0', 1), entry('2-0', 2)]);
|
||||
const handled: number[] = [];
|
||||
let calls = 0;
|
||||
const c = consumer(redis, async (event) => {
|
||||
if (event.position === 1) {
|
||||
calls++;
|
||||
throw new Error('Cannot read properties of undefined');
|
||||
}
|
||||
handled.push(event.position);
|
||||
});
|
||||
await c.start();
|
||||
await until(() => redis.acked.length === 2);
|
||||
await c.stop();
|
||||
|
||||
expect(calls).toBe(3);
|
||||
expect(handled).toEqual([2]);
|
||||
expect(redis.dead).toHaveLength(1);
|
||||
expect(redis.dead[0]).toMatchObject({
|
||||
key: deadLetterKey(KEY),
|
||||
attempts: '3',
|
||||
sourceId: '1-0',
|
||||
error: 'Cannot read properties of undefined',
|
||||
});
|
||||
expect(JSON.parse(redis.dead[0]['event'])).toMatchObject({ position: 1 });
|
||||
});
|
||||
|
||||
it('stopping while it retries leaves the event pending', async () => {
|
||||
const redis = new FakeRedis([entry('1-0', 1)]);
|
||||
let calls = 0;
|
||||
const c = consumer(redis, async () => {
|
||||
calls++;
|
||||
throw mongoDown();
|
||||
});
|
||||
await c.start();
|
||||
await until(() => calls >= 2);
|
||||
await c.stop();
|
||||
|
||||
expect(redis.acked).toEqual([]);
|
||||
expect(redis.dead).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isTransientError', () => {
|
||||
it('tells the infrastructure being down from a fault of its own', () => {
|
||||
expect(isTransientError(mongoDown())).toBe(true);
|
||||
expect(
|
||||
isTransientError(Object.assign(new Error('x'), { code: 'ECONNRESET' })),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isTransientError(
|
||||
Object.assign(new Error('write failed'), {
|
||||
cause: Object.assign(new Error('y'), {
|
||||
name: 'MongoServerSelectionError',
|
||||
}),
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(isTransientError(new Error('Connection is closed.'))).toBe(true);
|
||||
expect(isTransientError(new TypeError('x is undefined'))).toBe(false);
|
||||
expect(isTransientError(new Error('E11000 duplicate key'))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
import { SerializableEvent } from '@task-stream/api/shared/domain';
|
||||
import { isTransientError } from './transient-error';
|
||||
|
||||
type StreamEntry = [id: string, fields: string[]];
|
||||
type StreamReply = [stream: string, entries: StreamEntry[]][] | null;
|
||||
@@ -11,17 +12,35 @@ export interface EventStreamConsumerOptions {
|
||||
consumer: string;
|
||||
batchSize?: number;
|
||||
blockMs?: number;
|
||||
/** First wait before retrying an event; it doubles up to `maxRetryMs`. */
|
||||
retryBaseMs?: number;
|
||||
maxRetryMs?: number;
|
||||
/** Attempts of an event that keeps failing for a reason of its own. */
|
||||
maxAttempts?: number;
|
||||
}
|
||||
|
||||
/** Where events that keep failing go, next to the stream: `<key>:dead`. */
|
||||
export const deadLetterKey = (streamKey: string) => `${streamKey}:dead`;
|
||||
|
||||
/**
|
||||
* Reads the event stream with a Redis consumer group. Entries are ACKed only
|
||||
* after the handler succeeds; failed ones stay pending and are retried on
|
||||
* Reads the event stream with a Redis consumer group (the read side's inbox).
|
||||
* Entries are ACKed only after the handler succeeds.
|
||||
*
|
||||
* An event that fails is retried in place, waiting longer each time, and the
|
||||
* next one waits for it: skipping ahead would apply a later change first, and
|
||||
* the version check of the projections would then drop the earlier one for
|
||||
* good. When the infrastructure is down (Mongo, Redis, the network) it waits
|
||||
* as long as it takes; when the event itself keeps failing, after
|
||||
* `maxAttempts` it goes to the dead-letter stream (`<key>:dead`) so that it
|
||||
* does not hold up the rest. Entries still pending at a stop are retried on
|
||||
* the next start (or by another consumer of the group).
|
||||
*/
|
||||
export class EventStreamConsumer {
|
||||
private readonly logger: Logger;
|
||||
private running = false;
|
||||
private loop?: Promise<void>;
|
||||
/** Ends the current wait between retries (on stop). */
|
||||
private wake?: () => void;
|
||||
|
||||
constructor(
|
||||
private readonly redis: Redis,
|
||||
@@ -39,6 +58,7 @@ export class EventStreamConsumer {
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.running = false;
|
||||
this.wake?.();
|
||||
this.redis.disconnect();
|
||||
await this.loop?.catch(() => undefined);
|
||||
}
|
||||
@@ -108,15 +128,98 @@ export class EventStreamConsumer {
|
||||
return;
|
||||
}
|
||||
const event = JSON.parse(fields[index + 1]) as SerializableEvent;
|
||||
const name = `${event.type} (${event.streamId}#${event.position})`;
|
||||
const maxAttempts = this.options.maxAttempts ?? 5;
|
||||
|
||||
for (let attempt = 1; this.running; attempt++) {
|
||||
try {
|
||||
await this.handle(event);
|
||||
await this.redis.xack(this.options.streamKey, this.options.group, id);
|
||||
if (attempt > 1)
|
||||
this.logger.log(`${name} handled on attempt ${attempt}`);
|
||||
return;
|
||||
} catch (error) {
|
||||
const transient = isTransientError(error);
|
||||
if (!transient && attempt >= maxAttempts) {
|
||||
if (
|
||||
await this.deadLetter(id, fields[index + 1], name, error, attempt)
|
||||
)
|
||||
return;
|
||||
}
|
||||
const wait = this.backoff(attempt);
|
||||
const reason = (error as Error).message;
|
||||
if (attempt === 1)
|
||||
this.logger.error(
|
||||
`Failed to handle ${name}: ${reason}; retrying in ${wait} ms`,
|
||||
(error as Error).stack,
|
||||
);
|
||||
else
|
||||
this.logger.warn(
|
||||
`${name} still failing (attempt ${attempt}${transient ? ', infrastructure' : `/${maxAttempts}`}): ${reason}; retrying in ${wait} ms`,
|
||||
);
|
||||
await this.sleep(wait);
|
||||
}
|
||||
}
|
||||
// Stopped while retrying: it stays pending for the next start.
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves an event that keeps failing aside, with why, and lets the stream
|
||||
* go on. False if even that failed (Redis down): it is then retried.
|
||||
*/
|
||||
private async deadLetter(
|
||||
id: string,
|
||||
payload: string,
|
||||
name: string,
|
||||
error: unknown,
|
||||
attempts: number,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await this.handle(event);
|
||||
await this.redis.xack(this.options.streamKey, this.options.group, id);
|
||||
} catch (error) {
|
||||
// Left pending: it will be retried after a restart.
|
||||
this.logger.error(
|
||||
`Failed to handle ${event.type} (${event.streamId}#${event.position}): ${(error as Error).message}`,
|
||||
(error as Error).stack,
|
||||
await this.redis.xadd(
|
||||
deadLetterKey(this.options.streamKey),
|
||||
'*',
|
||||
'event',
|
||||
payload,
|
||||
'error',
|
||||
(error as Error).message ?? String(error),
|
||||
'attempts',
|
||||
String(attempts),
|
||||
'group',
|
||||
this.options.group,
|
||||
'sourceId',
|
||||
id,
|
||||
'failedAt',
|
||||
new Date().toISOString(),
|
||||
);
|
||||
await this.redis.xack(this.options.streamKey, this.options.group, id);
|
||||
this.logger.error(
|
||||
`${name} failed ${attempts} times and went to ${deadLetterKey(this.options.streamKey)}: ${(error as Error).message}`,
|
||||
);
|
||||
return true;
|
||||
} catch (failure) {
|
||||
this.logger.error(
|
||||
`Could not set ${name} aside: ${(failure as Error).message}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private backoff(attempt: number): number {
|
||||
const base = this.options.retryBaseMs ?? 500;
|
||||
return Math.min(
|
||||
this.options.maxRetryMs ?? 30_000,
|
||||
base * 2 ** (attempt - 1),
|
||||
);
|
||||
}
|
||||
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(done, ms);
|
||||
function done() {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
}
|
||||
this.wake = done;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/** Errors of the infrastructure itself: waiting is the cure, not skipping. */
|
||||
const TRANSIENT_NAMES =
|
||||
/^(Mongo(Network|NetworkTimeout|ServerSelection|NotConnected|TopologyClosed|PoolCleared)Error|MaxRetriesPerRequestError)$/;
|
||||
const TRANSIENT_CODES = new Set([
|
||||
'ECONNREFUSED',
|
||||
'ECONNRESET',
|
||||
'ETIMEDOUT',
|
||||
'EPIPE',
|
||||
'EHOSTUNREACH',
|
||||
'ENETUNREACH',
|
||||
'ENOTFOUND',
|
||||
'EAI_AGAIN',
|
||||
]);
|
||||
const TRANSIENT_MESSAGES =
|
||||
/connection (is )?closed|connection refused|timed out|getaddrinfo|socket hang up/i;
|
||||
|
||||
/**
|
||||
* Whether an error comes from a database or the network being down (and
|
||||
* will go away by itself) rather than from the event or the code that
|
||||
* handles it. Follows `cause`, as drivers wrap the original error.
|
||||
*/
|
||||
export function isTransientError(error: unknown): boolean {
|
||||
for (let current = error, depth = 0; current && depth < 5; depth++) {
|
||||
const e = current as {
|
||||
name?: string;
|
||||
code?: string;
|
||||
message?: string;
|
||||
cause?: unknown;
|
||||
};
|
||||
if (e.name && TRANSIENT_NAMES.test(e.name)) return true;
|
||||
if (e.code && TRANSIENT_CODES.has(e.code)) return true;
|
||||
if (e.message && TRANSIENT_MESSAGES.test(e.message)) return true;
|
||||
current = e.cause;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { EventStore } from '@task-stream/api/shared/application';
|
||||
import { RedisPubSub } from 'graphql-redis-subscriptions';
|
||||
import { EventDeserializer } from './event-store/deserializers/event.deserializer';
|
||||
import { EventEntity } from './event-store/entities/event.entity';
|
||||
import { EventPipelineStatus } from './event-store/event-pipeline.status';
|
||||
import { EventStoreRelay } from './event-store/event-store.relay';
|
||||
import { EventStoreSignal } from './event-store/event-store.signal';
|
||||
import {
|
||||
@@ -103,6 +104,7 @@ export class SharedInfrastructureModule {
|
||||
EventStreamPublisher,
|
||||
EventStoreRelay,
|
||||
EventsBridge,
|
||||
EventPipelineStatus,
|
||||
],
|
||||
exports: [
|
||||
EventStore,
|
||||
@@ -111,6 +113,7 @@ export class SharedInfrastructureModule {
|
||||
REDIS_CLIENT,
|
||||
GRAPHQL_PUB_SUB,
|
||||
EVENT_STREAM_OPTIONS,
|
||||
EventPipelineStatus,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19,6 +19,10 @@ export * from './lib/git.service';
|
||||
export * from './lib/webhooks.service';
|
||||
export * from './lib/transfer.service';
|
||||
export * from './lib/automations.service';
|
||||
export * from './lib/calendar.service';
|
||||
export * from './lib/calendar/calendar';
|
||||
export * from './lib/sharing.service';
|
||||
export * from './lib/sharing/dashboard-shares';
|
||||
export * from './lib/automations/automations';
|
||||
export * from './lib/automations/automation-rules';
|
||||
export * from './lib/transfer/import-export';
|
||||
@@ -27,4 +31,32 @@ 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/saved-filters/pins';
|
||||
export * from './lib/ports/pins.repository';
|
||||
export * from './lib/pins.service';
|
||||
export * from './lib/alerts/alert-payloads';
|
||||
export * from './lib/alerts/alerts';
|
||||
export * from './lib/ports/alert-integrations.repository';
|
||||
export * from './lib/alerts.service';
|
||||
export * from './lib/follows/tree-follows';
|
||||
export * from './lib/ports/tree-follows.repository';
|
||||
export * from './lib/tree-follows.service';
|
||||
export * from './lib/visits/issue-visits';
|
||||
export * from './lib/ports/issue-visits.repository';
|
||||
export * from './lib/issue-visits.service';
|
||||
export * from './lib/reminders/issue-reminders';
|
||||
export * from './lib/ports/issue-reminders.repository';
|
||||
export * from './lib/issue-reminders.service';
|
||||
export * from './lib/my-day/my-day';
|
||||
export * from './lib/ports/my-days.repository';
|
||||
export * from './lib/my-day.service';
|
||||
export * from './lib/commands/reviews';
|
||||
export * from './lib/commands/polls';
|
||||
export * from './lib/commands/turn-comment-into-issue';
|
||||
export * from './lib/sharing/release-notes-shares';
|
||||
export * from './lib/ports/release-note-shares.repository';
|
||||
export * from './lib/tracker-application.module';
|
||||
export * from './lib/ports/presence.store';
|
||||
export * from './lib/presence.service';
|
||||
|
||||
86
libs/api/tracker/application/src/lib/alerts.service.ts
Normal file
86
libs/api/tracker/application/src/lib/alerts.service.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { timingSafeEqual } from 'node:crypto';
|
||||
import { CommandRunner } from '@task-stream/api/shared/application';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { QueryBus } from '@nestjs/cqrs';
|
||||
import { ForbiddenError } from '@task-stream/api/shared/domain';
|
||||
import { alertOf } from './alerts/alert-payloads';
|
||||
import {
|
||||
AlertIntegration,
|
||||
AlertOutcome,
|
||||
AlertSettings,
|
||||
DisableAlertIntegrationCommand,
|
||||
GetAlertIntegrationQuery,
|
||||
ReceiveAlertCommand,
|
||||
RotateAlertTokenCommand,
|
||||
SaveAlertIntegrationCommand,
|
||||
} from './alerts/alerts';
|
||||
import { AlertIntegrationsRepository } from './ports/alert-integrations.repository';
|
||||
import { FindProjectsRepository } from './ports/find-projects.repository';
|
||||
|
||||
/** What a monitoring tool sent to a project's alert URL. */
|
||||
export interface AlertDelivery {
|
||||
/** Project id or key, from the URL. */
|
||||
project: string;
|
||||
token: string;
|
||||
body: unknown;
|
||||
headers: Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
/** Alerts → bugs: the project's secret URL, and what arrives through it. */
|
||||
@Injectable()
|
||||
export class AlertsService {
|
||||
constructor(
|
||||
private readonly commands: CommandRunner,
|
||||
private readonly queryBus: QueryBus,
|
||||
private readonly findProjects: FindProjectsRepository,
|
||||
private readonly integrations: AlertIntegrationsRepository,
|
||||
) {}
|
||||
|
||||
get(viewerId: string, projectId: string): Promise<AlertIntegration> {
|
||||
return this.queryBus.execute(
|
||||
new GetAlertIntegrationQuery(viewerId, projectId),
|
||||
);
|
||||
}
|
||||
|
||||
save(
|
||||
actorId: string,
|
||||
projectId: string,
|
||||
settings: AlertSettings,
|
||||
): Promise<AlertIntegration> {
|
||||
return this.commands.execute(
|
||||
new SaveAlertIntegrationCommand(actorId, projectId, settings),
|
||||
);
|
||||
}
|
||||
|
||||
rotateToken(actorId: string, projectId: string): Promise<AlertIntegration> {
|
||||
return this.commands.execute(
|
||||
new RotateAlertTokenCommand(actorId, projectId),
|
||||
);
|
||||
}
|
||||
|
||||
disable(actorId: string, projectId: string): Promise<void> {
|
||||
return this.commands.execute(
|
||||
new DisableAlertIntegrationCommand(actorId, projectId),
|
||||
);
|
||||
}
|
||||
|
||||
/** Lets an alert in only with the project's current token. */
|
||||
async receive(delivery: AlertDelivery): Promise<AlertOutcome> {
|
||||
const project =
|
||||
(await this.findProjects.findById(delivery.project)) ??
|
||||
(await this.findProjects.findByKey(delivery.project.toUpperCase()));
|
||||
const config = project ? await this.integrations.find(project.id) : null;
|
||||
// One answer for an unknown project, the integration off or a bad token.
|
||||
if (!project || !config || !sameToken(config.token, delivery.token))
|
||||
throw new ForbiddenError('Unknown project, alerts off or bad token');
|
||||
const alert = alertOf(delivery.body, delivery.headers);
|
||||
if (!alert) return { action: 'ignored', issueKey: null };
|
||||
return this.commands.execute(new ReceiveAlertCommand(project.id, alert));
|
||||
}
|
||||
}
|
||||
|
||||
function sameToken(expected: string, given: string): boolean {
|
||||
const a = Buffer.from(expected);
|
||||
const b = Buffer.from(given ?? '');
|
||||
return a.length === b.length && timingSafeEqual(a, b);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { alertOf } from './alert-payloads';
|
||||
|
||||
describe('alertOf', () => {
|
||||
it('reads a Sentry issue (internal integration), resolved too', () => {
|
||||
const body = {
|
||||
action: 'created',
|
||||
data: {
|
||||
issue: {
|
||||
id: '42',
|
||||
title: 'TypeError: x is undefined',
|
||||
culprit: 'app/checkout.ts',
|
||||
permalink: 'https://sentry.io/organizations/acme/issues/42/',
|
||||
level: 'error',
|
||||
},
|
||||
},
|
||||
};
|
||||
const alert = alertOf(body, { 'sentry-hook-resource': 'issue' });
|
||||
expect(alert).toMatchObject({
|
||||
source: 'sentry',
|
||||
fingerprint: 'sentry:42',
|
||||
title: 'TypeError: x is undefined',
|
||||
url: 'https://sentry.io/organizations/acme/issues/42/',
|
||||
resolved: false,
|
||||
});
|
||||
expect(alert?.details).toContain('app/checkout.ts');
|
||||
expect(
|
||||
alertOf(
|
||||
{ ...body, action: 'resolved' },
|
||||
{ 'sentry-hook-resource': 'issue' },
|
||||
)?.resolved,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('reads a Sentry issue alert and the legacy webhook', () => {
|
||||
expect(
|
||||
alertOf(
|
||||
{
|
||||
action: 'triggered',
|
||||
data: {
|
||||
event: { issue_id: '7', title: 'Boom', web_url: 'https://s.io/7' },
|
||||
triggered_rule: 'Errores nuevos',
|
||||
},
|
||||
},
|
||||
{ 'sentry-hook-resource': 'event_alert' },
|
||||
),
|
||||
).toMatchObject({ fingerprint: 'sentry:7', title: 'Boom' });
|
||||
expect(
|
||||
alertOf({
|
||||
id: '99',
|
||||
project_name: 'web',
|
||||
message: 'Boom',
|
||||
url: 'https://s.io/99',
|
||||
event: { title: 'ReferenceError: y' },
|
||||
}),
|
||||
).toMatchObject({ fingerprint: 'sentry:99', title: 'ReferenceError: y' });
|
||||
});
|
||||
|
||||
it('reads Uptime Kuma: down opens, up resolves', () => {
|
||||
const down = {
|
||||
heartbeat: { status: 0, msg: 'timeout', time: '2026-10-17 10:00' },
|
||||
monitor: { id: 3, name: 'API', url: 'https://api.acme.dev' },
|
||||
msg: '[API] [🔴 Down] timeout',
|
||||
};
|
||||
expect(alertOf(down)).toMatchObject({
|
||||
source: 'uptime-kuma',
|
||||
fingerprint: 'uptime-kuma:3',
|
||||
title: 'API no responde',
|
||||
resolved: false,
|
||||
});
|
||||
expect(alertOf({ ...down, heartbeat: { status: 1 } })?.resolved).toBe(true);
|
||||
});
|
||||
|
||||
it('reads Alertmanager and Grafana', () => {
|
||||
expect(
|
||||
alertOf({
|
||||
status: 'firing',
|
||||
groupKey: '{}:{alertname="HighLatency"}',
|
||||
alerts: [
|
||||
{
|
||||
labels: { alertname: 'HighLatency', service: 'api' },
|
||||
annotations: { summary: 'p95 por encima de 2 s' },
|
||||
generatorURL: 'https://grafana.acme.dev/alerting/1',
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toMatchObject({
|
||||
source: 'alertmanager',
|
||||
fingerprint: 'alertmanager:{}:{alertname="HighLatency"}',
|
||||
title: 'p95 por encima de 2 s',
|
||||
resolved: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('takes a plain title, and nothing without one', () => {
|
||||
expect(
|
||||
alertOf({ title: 'Cola atascada', fingerprint: 'queue', resolved: true }),
|
||||
).toMatchObject({
|
||||
source: 'generic',
|
||||
fingerprint: 'generic:queue',
|
||||
resolved: true,
|
||||
});
|
||||
expect(alertOf({ title: 'Cola atascada' })?.fingerprint).toBe(
|
||||
'generic:cola atascada',
|
||||
);
|
||||
expect(alertOf({ ping: true })).toBeNull();
|
||||
expect(alertOf('nonsense')).toBeNull();
|
||||
});
|
||||
|
||||
it('only links http(s) addresses', () => {
|
||||
expect(alertOf({ title: 'x', url: 'javascript:alert(1)' })?.url).toBeNull();
|
||||
});
|
||||
});
|
||||
199
libs/api/tracker/application/src/lib/alerts/alert-payloads.ts
Normal file
199
libs/api/tracker/application/src/lib/alerts/alert-payloads.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
/*
|
||||
* What monitoring tools send when something breaks, read into one shape.
|
||||
* Each tool has its own payload; none of them is trusted beyond being text.
|
||||
*/
|
||||
|
||||
export type AlertSource = 'sentry' | 'uptime-kuma' | 'alertmanager' | 'generic';
|
||||
|
||||
export interface Alert {
|
||||
source: AlertSource;
|
||||
/**
|
||||
* The same problem gives the same fingerprint every time: repeats go to
|
||||
* the ticket it opened instead of opening another.
|
||||
*/
|
||||
fingerprint: string;
|
||||
title: string;
|
||||
/** Markdown for the ticket's description. */
|
||||
details: string;
|
||||
url: string | null;
|
||||
/** The tool says it is over (back up, resolved). */
|
||||
resolved: boolean;
|
||||
}
|
||||
|
||||
const MAX_TITLE = 200;
|
||||
const MAX_DETAILS = 4000;
|
||||
|
||||
type Json = Record<string, unknown>;
|
||||
|
||||
const obj = (value: unknown): Json =>
|
||||
value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Json)
|
||||
: {};
|
||||
const str = (value: unknown): string =>
|
||||
typeof value === 'string'
|
||||
? value.trim()
|
||||
: typeof value === 'number'
|
||||
? String(value)
|
||||
: '';
|
||||
/** An http(s) link, or null: anything else is not offered as a link. */
|
||||
const link = (value: unknown): string | null => {
|
||||
const text = str(value);
|
||||
return /^https?:\/\/\S+$/i.test(text) ? text.slice(0, 500) : null;
|
||||
};
|
||||
|
||||
/** The alert in a payload, or null when it is not one (a test ping…). */
|
||||
export function alertOf(
|
||||
body: unknown,
|
||||
headers: Record<string, string | undefined> = {},
|
||||
): Alert | null {
|
||||
const payload = obj(body);
|
||||
const alert =
|
||||
sentry(payload, headers) ??
|
||||
uptimeKuma(payload) ??
|
||||
alertmanager(payload) ??
|
||||
generic(payload);
|
||||
if (!alert?.title || !alert.fingerprint) return null;
|
||||
return {
|
||||
...alert,
|
||||
title: alert.title.replace(/\s+/g, ' ').slice(0, MAX_TITLE),
|
||||
details: alert.details.slice(0, MAX_DETAILS),
|
||||
fingerprint: alert.fingerprint.slice(0, 300),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sentry: an internal integration (header `sentry-hook-resource`: `issue`
|
||||
* or `event_alert`) or the legacy webhooks plugin (`project_name`, `event`).
|
||||
*/
|
||||
function sentry(
|
||||
payload: Json,
|
||||
headers: Record<string, string | undefined>,
|
||||
): Alert | null {
|
||||
const resource = str(headers['sentry-hook-resource']);
|
||||
const data = obj(payload['data']);
|
||||
if (resource === 'issue') {
|
||||
const issue = obj(data['issue']);
|
||||
const id = str(issue['id']);
|
||||
if (!id) return null;
|
||||
const action = str(payload['action']);
|
||||
return {
|
||||
source: 'sentry',
|
||||
fingerprint: `sentry:${id}`,
|
||||
title: str(issue['title']),
|
||||
details: lines([
|
||||
str(issue['culprit']) && `En \`${str(issue['culprit'])}\``,
|
||||
str(issue['level']) && `Nivel: ${str(issue['level'])}`,
|
||||
]),
|
||||
url: link(issue['permalink'] ?? issue['web_url']),
|
||||
resolved: action === 'resolved',
|
||||
};
|
||||
}
|
||||
if (resource === 'event_alert') {
|
||||
const event = obj(data['event']);
|
||||
const id = str(event['issue_id']) || str(event['event_id']);
|
||||
if (!id) return null;
|
||||
return {
|
||||
source: 'sentry',
|
||||
fingerprint: `sentry:${id}`,
|
||||
title: str(event['title']) || str(event['message']),
|
||||
details: lines([
|
||||
str(event['culprit']) && `En \`${str(event['culprit'])}\``,
|
||||
str(event['level']) && `Nivel: ${str(event['level'])}`,
|
||||
str(data['triggered_rule']) && `Regla: ${str(data['triggered_rule'])}`,
|
||||
]),
|
||||
url: link(event['web_url'] ?? event['url']),
|
||||
resolved: false,
|
||||
};
|
||||
}
|
||||
if ('project_name' in payload && 'event' in payload) {
|
||||
const event = obj(payload['event']);
|
||||
const id = str(payload['id']) || str(event['event_id']);
|
||||
if (!id) return null;
|
||||
return {
|
||||
source: 'sentry',
|
||||
fingerprint: `sentry:${id}`,
|
||||
title: str(event['title']) || str(payload['message']),
|
||||
details: lines([
|
||||
str(payload['culprit']) && `En \`${str(payload['culprit'])}\``,
|
||||
str(payload['level']) && `Nivel: ${str(payload['level'])}`,
|
||||
str(payload['project_name']) &&
|
||||
`Proyecto de Sentry: ${str(payload['project_name'])}`,
|
||||
]),
|
||||
url: link(payload['url']),
|
||||
resolved: false,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Uptime Kuma's webhook: `monitor` and `heartbeat` (status 0 down, 1 up). */
|
||||
function uptimeKuma(payload: Json): Alert | null {
|
||||
const monitor = obj(payload['monitor']);
|
||||
const heartbeat = obj(payload['heartbeat']);
|
||||
if (!('monitor' in payload) || !('heartbeat' in payload)) return null;
|
||||
const name = str(monitor['name']);
|
||||
const id = str(monitor['id']) || name;
|
||||
if (!id) return null;
|
||||
const up = heartbeat['status'] === 1;
|
||||
return {
|
||||
source: 'uptime-kuma',
|
||||
fingerprint: `uptime-kuma:${id}`,
|
||||
title: `${name || 'Un monitor'} no responde`,
|
||||
details: lines([
|
||||
str(heartbeat['msg']) && `Respuesta: ${str(heartbeat['msg'])}`,
|
||||
str(monitor['url']) && `Vigila: ${str(monitor['url'])}`,
|
||||
str(heartbeat['time']) && `Desde: ${str(heartbeat['time'])}`,
|
||||
]),
|
||||
url: link(monitor['url']),
|
||||
resolved: up,
|
||||
};
|
||||
}
|
||||
|
||||
/** Prometheus Alertmanager and Grafana: `status` and `alerts[]`. */
|
||||
function alertmanager(payload: Json): Alert | null {
|
||||
if (!Array.isArray(payload['alerts'])) return null;
|
||||
const first = obj(payload['alerts'][0]);
|
||||
const labels = obj(first['labels']);
|
||||
const annotations = obj(first['annotations']);
|
||||
const key = str(payload['groupKey']) || str(first['fingerprint']);
|
||||
if (!key) return null;
|
||||
return {
|
||||
source: 'alertmanager',
|
||||
fingerprint: `alertmanager:${key}`,
|
||||
title:
|
||||
str(annotations['summary']) ||
|
||||
str(labels['alertname']) ||
|
||||
str(payload['title']),
|
||||
details: lines([
|
||||
str(annotations['description']) || str(payload['message']),
|
||||
Object.keys(labels).length > 0 &&
|
||||
`Etiquetas: ${Object.entries(labels)
|
||||
.map(([k, v]) => `\`${k}=${str(v)}\``)
|
||||
.join(' ')}`,
|
||||
]),
|
||||
url: link(first['generatorURL'] ?? payload['externalURL']),
|
||||
resolved: str(payload['status']) === 'resolved',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Anything else that says what broke: `title` (or `message`), and
|
||||
* optionally `fingerprint`, `description`, `url` and `resolved`.
|
||||
*/
|
||||
function generic(payload: Json): Alert | null {
|
||||
const title = str(payload['title']) || str(payload['message']);
|
||||
if (!title) return null;
|
||||
return {
|
||||
source: 'generic',
|
||||
fingerprint: `generic:${str(payload['fingerprint']) || title.toLowerCase()}`,
|
||||
title,
|
||||
details: str(payload['description']),
|
||||
url: link(payload['url']),
|
||||
resolved:
|
||||
payload['resolved'] === true || str(payload['status']) === 'resolved',
|
||||
};
|
||||
}
|
||||
|
||||
function lines(parts: (string | false | undefined)[]): string {
|
||||
return parts.filter((part): part is string => !!part).join('\n\n');
|
||||
}
|
||||
220
libs/api/tracker/application/src/lib/alerts/alerts.spec.ts
Normal file
220
libs/api/tracker/application/src/lib/alerts/alerts.spec.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import {
|
||||
CommandBus,
|
||||
CommandHandler,
|
||||
CqrsModule,
|
||||
ICommandHandler,
|
||||
} from '@nestjs/cqrs';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { CommandRunner } from '@task-stream/api/shared/application';
|
||||
import {
|
||||
IssuePriority,
|
||||
IssueReadModel,
|
||||
StatusCategory,
|
||||
SYSTEM_ACTOR_ID,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { AddCommentCommand } from '../commands/add-comment.command';
|
||||
import { CreateIssueCommand } from '../commands/create-issue.command';
|
||||
import { WriteAccess } from '../commands/write-access';
|
||||
import {
|
||||
AlertIntegrationConfig,
|
||||
AlertIntegrationsRepository,
|
||||
AlertTrace,
|
||||
} from '../ports/alert-integrations.repository';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
import { ReadAccess } from '../queries/read-access';
|
||||
import { Alert } from './alert-payloads';
|
||||
import { ALERT_HANDLERS, ReceiveAlertCommand, REPEAT_QUIET_MS } from './alerts';
|
||||
|
||||
class InMemoryAlerts extends AlertIntegrationsRepository {
|
||||
configs = new Map<string, AlertIntegrationConfig>();
|
||||
traces = new Map<string, AlertTrace>();
|
||||
async find(projectId: string) {
|
||||
return this.configs.get(projectId) ?? null;
|
||||
}
|
||||
async save(config: AlertIntegrationConfig) {
|
||||
this.configs.set(config.projectId, config);
|
||||
}
|
||||
async remove(projectId: string) {
|
||||
this.configs.delete(projectId);
|
||||
}
|
||||
async trace(projectId: string, fingerprint: string) {
|
||||
return this.traces.get(`${projectId}:${fingerprint}`) ?? null;
|
||||
}
|
||||
async saveTrace(trace: AlertTrace) {
|
||||
this.traces.set(`${trace.projectId}:${trace.fingerprint}`, trace);
|
||||
}
|
||||
}
|
||||
|
||||
/** The tickets alerts open, and what TaskStream says on them. */
|
||||
const issues = new Map<string, IssueReadModel>();
|
||||
const created: CreateIssueCommand[] = [];
|
||||
const comments: AddCommentCommand[] = [];
|
||||
|
||||
@CommandHandler(CreateIssueCommand)
|
||||
class FakeCreate implements ICommandHandler<CreateIssueCommand> {
|
||||
async execute(command: CreateIssueCommand) {
|
||||
created.push(command);
|
||||
const id = `i${created.length}`;
|
||||
const key = `WEB-${created.length}`;
|
||||
issues.set(id, {
|
||||
id,
|
||||
key,
|
||||
statusCategory: StatusCategory.TODO,
|
||||
trashedAt: null,
|
||||
} as IssueReadModel);
|
||||
return { id, key, version: 1 };
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(AddCommentCommand)
|
||||
class FakeComment implements ICommandHandler<AddCommentCommand> {
|
||||
async execute(command: AddCommentCommand) {
|
||||
comments.push(command);
|
||||
return { id: 'c', version: 1 };
|
||||
}
|
||||
}
|
||||
|
||||
const alert = (over: Partial<Alert> = {}): Alert => ({
|
||||
source: 'uptime-kuma',
|
||||
fingerprint: 'uptime-kuma:3',
|
||||
title: 'API no responde',
|
||||
details: 'timeout',
|
||||
url: 'https://api.acme.dev',
|
||||
resolved: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
async function setup() {
|
||||
issues.clear();
|
||||
created.length = 0;
|
||||
comments.length = 0;
|
||||
const repo = new InMemoryAlerts();
|
||||
await repo.save({
|
||||
projectId: 'p1',
|
||||
token: 't',
|
||||
typeId: null,
|
||||
priority: IssuePriority.HIGH,
|
||||
createdAt: '2026-10-01T00:00:00Z',
|
||||
});
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [CqrsModule.forRoot()],
|
||||
providers: [
|
||||
...ALERT_HANDLERS,
|
||||
FakeCreate,
|
||||
FakeComment,
|
||||
CommandRunner,
|
||||
{ provide: AlertIntegrationsRepository, useValue: repo },
|
||||
{
|
||||
provide: FindProjectsRepository,
|
||||
useValue: {
|
||||
findById: async () => ({
|
||||
id: 'p1',
|
||||
settings: {
|
||||
issueTypes: [
|
||||
{ id: 'task', icon: 'task', archived: false },
|
||||
{ id: 'bug', icon: 'bug', archived: false },
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: FindIssuesRepository,
|
||||
useValue: { findById: async (id: string) => issues.get(id) ?? null },
|
||||
},
|
||||
{ provide: WriteAccess, useValue: {} },
|
||||
{ provide: ReadAccess, useValue: {} },
|
||||
],
|
||||
}).compile();
|
||||
await moduleRef.init();
|
||||
const bus = moduleRef.get(CommandBus);
|
||||
return {
|
||||
repo,
|
||||
receive: (a: Alert) => bus.execute(new ReceiveAlertCommand('p1', a)),
|
||||
};
|
||||
}
|
||||
|
||||
describe('alerts → bugs', () => {
|
||||
it('opens a bug as TaskStream, with the bug type and the priority set', async () => {
|
||||
const { receive } = await setup();
|
||||
expect(await receive(alert())).toEqual({
|
||||
action: 'created',
|
||||
issueKey: 'WEB-1',
|
||||
});
|
||||
const [command] = created;
|
||||
expect(command.actorId).toBe(SYSTEM_ACTOR_ID);
|
||||
expect(command.type).toBe('bug');
|
||||
expect(command.details.priority).toBe(IssuePriority.HIGH);
|
||||
expect(command.details.labels).toEqual(['alerta']);
|
||||
expect(command.details.description).toContain('Uptime Kuma');
|
||||
});
|
||||
|
||||
it('sends repeats to the open bug, telling it at most every half hour', async () => {
|
||||
const { receive, repo } = await setup();
|
||||
await receive(alert());
|
||||
expect(await receive(alert())).toEqual({
|
||||
action: 'repeated',
|
||||
issueKey: 'WEB-1',
|
||||
});
|
||||
// Just told when it opened: this repeat stays quiet.
|
||||
expect(comments).toHaveLength(0);
|
||||
const trace = await repo.trace('p1', 'uptime-kuma:3');
|
||||
if (!trace) throw new Error('no trace');
|
||||
expect(trace.count).toBe(2);
|
||||
await repo.saveTrace({
|
||||
...trace,
|
||||
notedAt: new Date(Date.now() - REPEAT_QUIET_MS - 1000).toISOString(),
|
||||
});
|
||||
await receive(alert());
|
||||
expect(comments).toHaveLength(1);
|
||||
expect(comments[0].body).toContain('3 veces');
|
||||
expect(created).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('says on the bug when it is resolved, and ignores one with nothing open', async () => {
|
||||
const { receive } = await setup();
|
||||
expect(await receive(alert({ resolved: true }))).toEqual({
|
||||
action: 'ignored',
|
||||
issueKey: null,
|
||||
});
|
||||
await receive(alert());
|
||||
expect(await receive(alert({ resolved: true }))).toEqual({
|
||||
action: 'resolved',
|
||||
issueKey: 'WEB-1',
|
||||
});
|
||||
expect(comments[0].body).toContain('se ha resuelto');
|
||||
});
|
||||
|
||||
it('opens a new bug when the old one is closed, naming it', async () => {
|
||||
const { receive } = await setup();
|
||||
await receive(alert());
|
||||
issues.set('i1', {
|
||||
...(issues.get('i1') as IssueReadModel),
|
||||
statusCategory: StatusCategory.DONE,
|
||||
});
|
||||
expect(await receive(alert())).toEqual({
|
||||
action: 'created',
|
||||
issueKey: 'WEB-2',
|
||||
});
|
||||
expect(created[1].details.description).toContain('Ya pasó antes: WEB-1');
|
||||
});
|
||||
|
||||
it('sends a repeat to a bug not yet in the read model', async () => {
|
||||
const { receive } = await setup();
|
||||
await receive(alert());
|
||||
issues.clear();
|
||||
expect(await receive(alert())).toEqual({
|
||||
action: 'repeated',
|
||||
issueKey: 'WEB-1',
|
||||
});
|
||||
expect(created).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('opens one bug when the same alert arrives twice at once', async () => {
|
||||
const { receive } = await setup();
|
||||
const both = await Promise.all([receive(alert()), receive(alert())]);
|
||||
expect(both.map((o) => o.action).sort()).toEqual(['created', 'repeated']);
|
||||
expect(created).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
357
libs/api/tracker/application/src/lib/alerts/alerts.ts
Normal file
357
libs/api/tracker/application/src/lib/alerts/alerts.ts
Normal file
@@ -0,0 +1,357 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { CommandRunner } from '@task-stream/api/shared/application';
|
||||
import {
|
||||
Command,
|
||||
CommandHandler,
|
||||
ICommandHandler,
|
||||
IQueryHandler,
|
||||
Query,
|
||||
QueryHandler,
|
||||
} from '@nestjs/cqrs';
|
||||
import { assertThat, NotFoundError } from '@task-stream/api/shared/domain';
|
||||
import {
|
||||
IssuePriority,
|
||||
ProjectRole,
|
||||
StatusCategory,
|
||||
SYSTEM_ACTOR_ID,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { AddCommentCommand } from '../commands/add-comment.command';
|
||||
import { CommandResult } from '../commands/command-result';
|
||||
import { CreateIssueCommand } from '../commands/create-issue.command';
|
||||
import { WriteAccess } from '../commands/write-access';
|
||||
import {
|
||||
AlertIntegrationConfig,
|
||||
AlertIntegrationsRepository,
|
||||
} from '../ports/alert-integrations.repository';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
import { ReadAccess } from '../queries/read-access';
|
||||
import { Alert, AlertSource } from './alert-payloads';
|
||||
|
||||
/*
|
||||
* Alerts → bugs: monitoring tools (Sentry, Uptime Kuma, Grafana…) call a
|
||||
* project's secret URL; a new problem opens a bug, a repeat goes to the bug
|
||||
* it opened while that one is open, and «resolved» is said on it. Nobody's
|
||||
* ticket: TaskStream opens and comments them.
|
||||
*/
|
||||
|
||||
export interface AlertIntegration {
|
||||
enabled: boolean;
|
||||
/** Only for whoever manages the project. */
|
||||
token: string | null;
|
||||
typeId: string | null;
|
||||
priority: IssuePriority;
|
||||
}
|
||||
|
||||
export interface AlertSettings {
|
||||
typeId?: string | null;
|
||||
priority?: IssuePriority;
|
||||
}
|
||||
|
||||
export type AlertOutcome =
|
||||
| { action: 'created' | 'repeated' | 'resolved'; issueKey: string }
|
||||
| { action: 'ignored'; issueKey: null };
|
||||
|
||||
/** Repeats tell the ticket at most this often. */
|
||||
export const REPEAT_QUIET_MS = 30 * 60_000;
|
||||
|
||||
const SOURCE_NAME: Record<AlertSource, string> = {
|
||||
sentry: 'Sentry',
|
||||
'uptime-kuma': 'Uptime Kuma',
|
||||
alertmanager: 'Alertmanager',
|
||||
generic: 'la alerta',
|
||||
};
|
||||
|
||||
const OFF: AlertIntegration = {
|
||||
enabled: false,
|
||||
token: null,
|
||||
typeId: null,
|
||||
priority: IssuePriority.HIGH,
|
||||
};
|
||||
|
||||
const newToken = () => randomBytes(24).toString('hex');
|
||||
|
||||
/** Turns it on, or changes what it opens (the token stays). */
|
||||
export class SaveAlertIntegrationCommand extends Command<AlertIntegration> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly projectId: string,
|
||||
public readonly settings: AlertSettings,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/** A new URL; the old one stops working at once. */
|
||||
export class RotateAlertTokenCommand extends Command<AlertIntegration> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly projectId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class DisableAlertIntegrationCommand extends Command<void> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly projectId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/** An alert already let in (its token checked by the caller). */
|
||||
export class ReceiveAlertCommand extends Command<AlertOutcome> {
|
||||
constructor(
|
||||
public readonly projectId: string,
|
||||
public readonly alert: Alert,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class GetAlertIntegrationQuery extends Query<AlertIntegration> {
|
||||
constructor(
|
||||
public readonly viewerId: string,
|
||||
public readonly projectId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/** What owners and admins see: the token included. */
|
||||
const view = (config: AlertIntegrationConfig): AlertIntegration => ({
|
||||
enabled: true,
|
||||
token: config.token,
|
||||
typeId: config.typeId,
|
||||
priority: config.priority,
|
||||
});
|
||||
|
||||
@CommandHandler(SaveAlertIntegrationCommand)
|
||||
export class SaveAlertIntegrationCommandHandler
|
||||
implements ICommandHandler<SaveAlertIntegrationCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly access: WriteAccess,
|
||||
private readonly findProjects: FindProjectsRepository,
|
||||
private readonly integrations: AlertIntegrationsRepository,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
command: SaveAlertIntegrationCommand,
|
||||
): Promise<AlertIntegration> {
|
||||
(await this.access.project(command.projectId)).assertCanManage(
|
||||
command.actorId,
|
||||
);
|
||||
const { typeId, priority } = command.settings;
|
||||
if (typeId) {
|
||||
const project = await this.findProjects.findById(command.projectId);
|
||||
assertThat(
|
||||
!!project?.settings.issueTypes.some(
|
||||
(t) => t.id === typeId && !t.archived,
|
||||
),
|
||||
`Unknown ticket type: ${typeId}`,
|
||||
);
|
||||
}
|
||||
const current = await this.integrations.find(command.projectId);
|
||||
const config: AlertIntegrationConfig = {
|
||||
projectId: command.projectId,
|
||||
token: current?.token ?? newToken(),
|
||||
typeId: typeId === undefined ? (current?.typeId ?? null) : typeId,
|
||||
priority: priority ?? current?.priority ?? IssuePriority.HIGH,
|
||||
createdAt: current?.createdAt ?? new Date().toISOString(),
|
||||
};
|
||||
await this.integrations.save(config);
|
||||
return view(config);
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(RotateAlertTokenCommand)
|
||||
export class RotateAlertTokenCommandHandler
|
||||
implements ICommandHandler<RotateAlertTokenCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly access: WriteAccess,
|
||||
private readonly integrations: AlertIntegrationsRepository,
|
||||
) {}
|
||||
|
||||
async execute(command: RotateAlertTokenCommand): Promise<AlertIntegration> {
|
||||
(await this.access.project(command.projectId)).assertCanManage(
|
||||
command.actorId,
|
||||
);
|
||||
const current = await this.integrations.find(command.projectId);
|
||||
if (!current)
|
||||
throw new NotFoundError('Alert integration', command.projectId);
|
||||
const rotated = { ...current, token: newToken() };
|
||||
await this.integrations.save(rotated);
|
||||
return view(rotated);
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(DisableAlertIntegrationCommand)
|
||||
export class DisableAlertIntegrationCommandHandler
|
||||
implements ICommandHandler<DisableAlertIntegrationCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly access: WriteAccess,
|
||||
private readonly integrations: AlertIntegrationsRepository,
|
||||
) {}
|
||||
|
||||
async execute(command: DisableAlertIntegrationCommand): Promise<void> {
|
||||
(await this.access.project(command.projectId)).assertCanManage(
|
||||
command.actorId,
|
||||
);
|
||||
await this.integrations.remove(command.projectId);
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(ReceiveAlertCommand)
|
||||
export class ReceiveAlertCommandHandler
|
||||
implements ICommandHandler<ReceiveAlertCommand>
|
||||
{
|
||||
/** One alert of a project after another: two at once open one ticket. */
|
||||
private readonly queues = new Map<string, Promise<unknown>>();
|
||||
|
||||
constructor(
|
||||
private readonly integrations: AlertIntegrationsRepository,
|
||||
private readonly findProjects: FindProjectsRepository,
|
||||
private readonly findIssues: FindIssuesRepository,
|
||||
private readonly commands: CommandRunner,
|
||||
) {}
|
||||
|
||||
execute(command: ReceiveAlertCommand): Promise<AlertOutcome> {
|
||||
const previous = this.queues.get(command.projectId) ?? Promise.resolve();
|
||||
const next = previous.catch(() => null).then(() => this.receive(command));
|
||||
this.queues.set(command.projectId, next);
|
||||
void next.finally(() => {
|
||||
if (this.queues.get(command.projectId) === next)
|
||||
this.queues.delete(command.projectId);
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
private async receive({
|
||||
projectId,
|
||||
alert,
|
||||
}: ReceiveAlertCommand): Promise<AlertOutcome> {
|
||||
const config = await this.integrations.find(projectId);
|
||||
if (!config) throw new NotFoundError('Alert integration', projectId);
|
||||
const now = new Date().toISOString();
|
||||
const trace = await this.integrations.trace(projectId, alert.fingerprint);
|
||||
const opened = trace ? await this.findIssues.findById(trace.issueId) : null;
|
||||
// Not in the read model yet: it was opened a moment ago, so it is open.
|
||||
const open = !trace
|
||||
? null
|
||||
: !opened
|
||||
? { id: trace.issueId, key: trace.issueKey }
|
||||
: !opened.trashedAt &&
|
||||
opened.statusCategory !== StatusCategory.DONE &&
|
||||
opened.statusCategory !== StatusCategory.CANCELLED
|
||||
? opened
|
||||
: null;
|
||||
const source = SOURCE_NAME[alert.source];
|
||||
|
||||
if (trace && open) {
|
||||
if (alert.resolved) {
|
||||
await this.comment(open.id, `✅ Según ${source}, se ha resuelto.`);
|
||||
await this.integrations.saveTrace({ ...trace, notedAt: now });
|
||||
return { action: 'resolved', issueKey: open.key };
|
||||
}
|
||||
const count = trace.count + 1;
|
||||
const quiet = Date.now() - Date.parse(trace.notedAt) < REPEAT_QUIET_MS;
|
||||
if (!quiet)
|
||||
await this.comment(
|
||||
open.id,
|
||||
`🔁 Ha vuelto a pasar: ${count} veces desde ${trace.firstAt.slice(0, 16).replace('T', ' ')} (UTC).`,
|
||||
);
|
||||
await this.integrations.saveTrace({
|
||||
...trace,
|
||||
count,
|
||||
lastAt: now,
|
||||
notedAt: quiet ? trace.notedAt : now,
|
||||
});
|
||||
return { action: 'repeated', issueKey: open.key };
|
||||
}
|
||||
// Over before anyone looked, or about a ticket already closed.
|
||||
if (alert.resolved) return { action: 'ignored', issueKey: null };
|
||||
|
||||
const project = await this.findProjects.findById(projectId);
|
||||
const types = (project?.settings.issueTypes ?? []).filter(
|
||||
(t) => !t.archived,
|
||||
);
|
||||
const type =
|
||||
types.find((t) => t.id === config.typeId) ??
|
||||
types.find((t) => t.icon === 'bug') ??
|
||||
types[0];
|
||||
if (!type) throw new NotFoundError('Ticket type', config.typeId ?? 'bug');
|
||||
const description = [
|
||||
alert.details,
|
||||
alert.url && `[Ver en ${source}](${alert.url})`,
|
||||
opened && `Ya pasó antes: ${opened.key}.`,
|
||||
`_Abierto por una alerta de ${source}._`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
const result: CommandResult = await this.commands.execute(
|
||||
new CreateIssueCommand(
|
||||
SYSTEM_ACTOR_ID,
|
||||
projectId,
|
||||
type.id,
|
||||
alert.title,
|
||||
{ description, priority: config.priority, labels: ['alerta'] },
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
),
|
||||
);
|
||||
await this.integrations.saveTrace({
|
||||
projectId,
|
||||
fingerprint: alert.fingerprint,
|
||||
issueId: result.id,
|
||||
issueKey: result.key ?? result.id,
|
||||
count: 1,
|
||||
firstAt: now,
|
||||
lastAt: now,
|
||||
notedAt: now,
|
||||
});
|
||||
return { action: 'created', issueKey: result.key ?? result.id };
|
||||
}
|
||||
|
||||
private comment(issueId: string, body: string): Promise<CommandResult> {
|
||||
return this.commands.execute(
|
||||
new AddCommentCommand(SYSTEM_ACTOR_ID, issueId, body),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@QueryHandler(GetAlertIntegrationQuery)
|
||||
export class GetAlertIntegrationQueryHandler
|
||||
implements IQueryHandler<GetAlertIntegrationQuery, AlertIntegration>
|
||||
{
|
||||
constructor(
|
||||
private readonly access: ReadAccess,
|
||||
private readonly integrations: AlertIntegrationsRepository,
|
||||
) {}
|
||||
|
||||
async execute(query: GetAlertIntegrationQuery): Promise<AlertIntegration> {
|
||||
const role = await this.access.assertSeesWholeProject(
|
||||
query.projectId,
|
||||
query.viewerId,
|
||||
);
|
||||
const config = await this.integrations.find(query.projectId);
|
||||
if (!config) return OFF;
|
||||
const manages = role === ProjectRole.OWNER || role === ProjectRole.ADMIN;
|
||||
return { ...view(config), token: manages ? config.token : null };
|
||||
}
|
||||
}
|
||||
|
||||
export const ALERT_HANDLERS = [
|
||||
SaveAlertIntegrationCommandHandler,
|
||||
RotateAlertTokenCommandHandler,
|
||||
DisableAlertIntegrationCommandHandler,
|
||||
ReceiveAlertCommandHandler,
|
||||
GetAlertIntegrationQueryHandler,
|
||||
];
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
468
libs/api/tracker/application/src/lib/analytics/insights.spec.ts
Normal file
468
libs/api/tracker/application/src/lib/analytics/insights.spec.ts
Normal file
@@ -0,0 +1,468 @@
|
||||
import {
|
||||
CustomFieldType,
|
||||
defaultProjectSettings,
|
||||
IssuePriority,
|
||||
ReleaseReadModel,
|
||||
ReleaseStatus,
|
||||
IssueLinkType,
|
||||
IssueReadModel,
|
||||
IssueTimelineEntry,
|
||||
SprintReadModel,
|
||||
SprintStatus,
|
||||
StatusCategory as C,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import {
|
||||
computeInsights,
|
||||
HEATMAP_DAYS,
|
||||
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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps 12 weeks of activity, the whole way of done tickets and the proposals', () => {
|
||||
const proposal = { labels: ['propuesta'] };
|
||||
const issues = [
|
||||
// Done: 2 days waiting in TODO, 3 at work, 1 in QA.
|
||||
issue('A', 'DONE', C.DONE, { createdAt: '2026-10-01T10:00:00Z' }),
|
||||
issue('P1', 'TODO', C.TODO, {
|
||||
...proposal,
|
||||
votes: 2,
|
||||
} as Partial<IssueReadModel>),
|
||||
issue('P2', 'TODO', C.TODO, {
|
||||
...proposal,
|
||||
votes: 5,
|
||||
} as Partial<IssueReadModel>),
|
||||
issue('P3', 'TODO', C.TODO, { ...proposal, ...inSprint }),
|
||||
issue('P4', 'DONE', C.DONE, proposal),
|
||||
issue('P5', 'CANCELLED', C.CANCELLED, proposal),
|
||||
];
|
||||
const timeline = [
|
||||
step('A', '2026-10-01T10:00:00Z', 'TODO', C.TODO),
|
||||
step('A', '2026-10-03T10:00:00Z', 'IN_PROGRESS', C.IN_PROGRESS),
|
||||
step('A', '2026-10-06T10:00:00Z', 'IN_QA', C.QA),
|
||||
step('A', '2026-10-07T10:00:00Z', 'DONE', C.DONE),
|
||||
];
|
||||
const insights = computeInsights({
|
||||
issues,
|
||||
timeline,
|
||||
...BASE,
|
||||
activity: [
|
||||
{ day: '2026-10-15', count: 3 },
|
||||
{ day: '2026-10-14', count: 1 },
|
||||
{ day: '2026-01-01', count: 9 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(insights.heatmap).toHaveLength(HEATMAP_DAYS);
|
||||
// From the Monday 11 weeks back to this week's Sunday.
|
||||
expect(insights.heatmap[0].day).toBe('2026-07-27');
|
||||
expect(insights.heatmap.at(-1)?.day).toBe('2026-10-18');
|
||||
expect(insights.heatmap.find((d) => d.day === '2026-10-15')?.count).toBe(3);
|
||||
|
||||
expect(insights.journey).toEqual({
|
||||
samples: 1,
|
||||
leadDays: 6,
|
||||
flowEfficiency: 50,
|
||||
steps: [
|
||||
{ status: 'TODO', days: 2, share: 33 },
|
||||
{ status: 'IN_PROGRESS', days: 3, share: 50 },
|
||||
{ status: 'IN_QA', days: 1, share: 17 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(insights.proposals).toMatchObject({
|
||||
received: 5,
|
||||
waiting: 2,
|
||||
underWay: 1,
|
||||
done: 1,
|
||||
discarded: 1,
|
||||
});
|
||||
expect(insights.proposals?.topWaiting.map((p) => [p.key, p.votes])).toEqual(
|
||||
[
|
||||
['P2', 5],
|
||||
['P1', 2],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it('has no proposals block while nobody has proposed anything', () => {
|
||||
expect(
|
||||
computeInsights({ issues: [], timeline: [], ...BASE }).proposals,
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
739
libs/api/tracker/application/src/lib/analytics/insights.ts
Normal file
739
libs/api/tracker/application/src/lib/analytics/insights.ts
Normal file
@@ -0,0 +1,739 @@
|
||||
import {
|
||||
ActivityDayReadModel,
|
||||
BacklogHealthReadModel,
|
||||
JourneyReadModel,
|
||||
ProposalsFunnelReadModel,
|
||||
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';
|
||||
import { PROPOSAL_LABEL } from '../proposals';
|
||||
|
||||
/*
|
||||
* 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;
|
||||
/** Activity events per day (`YYYY-MM-DD`), the last 12 weeks or more. */
|
||||
activity?: { day: string; count: 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;
|
||||
/** Hundredths of a day (about a quarter of an hour): short stays still show. */
|
||||
const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||
|
||||
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),
|
||||
heatmap: heatmap(input.activity ?? [], now),
|
||||
journey: journey(input.settings, live, steps, now),
|
||||
proposals: proposalsFunnel(live, now),
|
||||
};
|
||||
}
|
||||
|
||||
/** Days shown by the heatmap: 12 weeks. */
|
||||
export const HEATMAP_DAYS = 84;
|
||||
|
||||
/** The first day of the heatmap: the Monday 11 weeks before this week's. */
|
||||
export function heatmapStart(now: number): string {
|
||||
return new Date(Date.parse(weekOf(now)) - 11 * 7 * DAY)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
/** 12 weeks of activity, a day each, zero where nothing happened. */
|
||||
function heatmap(
|
||||
activity: { day: string; count: number }[],
|
||||
now: number,
|
||||
): ActivityDayReadModel[] {
|
||||
const counts = new Map<string, number>();
|
||||
for (const a of activity)
|
||||
counts.set(a.day, (counts.get(a.day) ?? 0) + a.count);
|
||||
const start = Date.parse(heatmapStart(now));
|
||||
return Array.from({ length: HEATMAP_DAYS }, (_, n) => {
|
||||
const day = new Date(start + n * DAY).toISOString().slice(0, 10);
|
||||
return { day, count: counts.get(day) ?? 0 };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the time of a closed ticket goes, from created to done, status by
|
||||
* status (waiting included), for the tickets done in the last 90 days.
|
||||
*/
|
||||
function journey(
|
||||
settings: ProjectSettings,
|
||||
issues: IssueReadModel[],
|
||||
steps: Map<string, Step[]>,
|
||||
now: number,
|
||||
): JourneyReadModel {
|
||||
const from = now - 90 * DAY;
|
||||
const spent = new Map<string, number>();
|
||||
let lead = 0;
|
||||
let working = 0;
|
||||
let samples = 0;
|
||||
for (const issue of issues) {
|
||||
if (issue.statusCategory !== StatusCategory.DONE) continue;
|
||||
const list = steps.get(issue.id) ?? [];
|
||||
// The last arrival at a done status, from somewhere else.
|
||||
let end = -1;
|
||||
list.forEach((step, i) => {
|
||||
if (
|
||||
i > 0 &&
|
||||
step.category === StatusCategory.DONE &&
|
||||
list[i - 1].category !== StatusCategory.DONE
|
||||
)
|
||||
end = i;
|
||||
});
|
||||
if (end < 0 || list[end].at < from) continue;
|
||||
samples++;
|
||||
lead += list[end].at - list[0].at;
|
||||
for (let i = 0; i < end; i++) {
|
||||
const stay = list[i + 1].at - list[i].at;
|
||||
spent.set(list[i].status, (spent.get(list[i].status) ?? 0) + stay);
|
||||
if (list[i].category === StatusCategory.IN_PROGRESS) working += stay;
|
||||
}
|
||||
}
|
||||
const order = settings.statuses.map((s) => s.id);
|
||||
const rank = (id: string) =>
|
||||
order.includes(id) ? order.indexOf(id) : order.length;
|
||||
return {
|
||||
samples,
|
||||
leadDays: samples ? round2(lead / samples / DAY) : null,
|
||||
flowEfficiency: lead ? Math.round((working / lead) * 100) : null,
|
||||
steps: [...spent.entries()]
|
||||
.filter(([, time]) => time > 0)
|
||||
.sort(([a], [b]) => rank(a) - rank(b))
|
||||
.map(([status, time]) => ({
|
||||
status,
|
||||
days: round2(time / samples / DAY),
|
||||
share: Math.round((time / lead) * 100),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** The public's proposals, from received to done or discarded. */
|
||||
function proposalsFunnel(
|
||||
issues: IssueReadModel[],
|
||||
now: number,
|
||||
): ProposalsFunnelReadModel | null {
|
||||
const proposals = issues.filter((i) =>
|
||||
(i.labels ?? []).includes(PROPOSAL_LABEL),
|
||||
);
|
||||
if (!proposals.length) return null;
|
||||
const isWaiting = (i: IssueReadModel) =>
|
||||
i.statusCategory === StatusCategory.TODO && !i.sprint && !i.release;
|
||||
const open = proposals.filter((i) => !CLOSED.includes(i.statusCategory));
|
||||
const waiting = open.filter(isWaiting);
|
||||
const age = (i: IssueReadModel) =>
|
||||
Math.floor((now - Date.parse(i.createdAt)) / DAY);
|
||||
return {
|
||||
received: proposals.length,
|
||||
receivedLately: proposals.filter((i) => age(i) < 30).length,
|
||||
waiting: waiting.length,
|
||||
underWay: open.length - waiting.length,
|
||||
done: proposals.filter((i) => i.statusCategory === StatusCategory.DONE)
|
||||
.length,
|
||||
discarded: proposals.filter(
|
||||
(i) => i.statusCategory === StatusCategory.CANCELLED,
|
||||
).length,
|
||||
topWaiting: waiting
|
||||
.sort((a, b) => (b.votes ?? 0) - (a.votes ?? 0) || age(b) - age(a))
|
||||
.slice(0, 5)
|
||||
.map((i) => ({
|
||||
id: i.id,
|
||||
key: i.key,
|
||||
title: i.title,
|
||||
votes: i.votes ?? 0,
|
||||
days: age(i),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
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({
|
||||
findByIds: async (ids: string[]) => {
|
||||
queries.push(ids);
|
||||
return ids.map((id) => store.get(id)).filter(Boolean);
|
||||
},
|
||||
} as unknown as FindIssuesRepository);
|
||||
const service = new BlockersService(
|
||||
{
|
||||
findByIds: async (ids: string[]) => {
|
||||
queries.push(ids);
|
||||
return ids.map((id) => store.get(id)).filter(Boolean);
|
||||
},
|
||||
} 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 }));
|
||||
}
|
||||
|
||||
37
libs/api/tracker/application/src/lib/calendar.service.ts
Normal file
37
libs/api/tracker/application/src/lib/calendar.service.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { CommandRunner } from '@task-stream/api/shared/application';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { QueryBus } from '@nestjs/cqrs';
|
||||
import {
|
||||
CreateCalendarFeedCommand,
|
||||
GetCalendarIcsQuery,
|
||||
GetMyCalendarFeedQuery,
|
||||
RevokeCalendarFeedCommand,
|
||||
} from './calendar/calendar';
|
||||
import { CalendarFeed } from './ports/calendar-feeds.repository';
|
||||
|
||||
/** A person's calendar subscription (iCalendar), behind a secret URL. */
|
||||
@Injectable()
|
||||
export class CalendarService {
|
||||
constructor(
|
||||
private readonly commands: CommandRunner,
|
||||
private readonly queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
mine(userId: string): Promise<CalendarFeed | null> {
|
||||
return this.queryBus.execute(new GetMyCalendarFeedQuery(userId));
|
||||
}
|
||||
|
||||
/** A new secret URL; the previous one, if any, stops working. */
|
||||
create(userId: string): Promise<CalendarFeed> {
|
||||
return this.commands.execute(new CreateCalendarFeedCommand(userId));
|
||||
}
|
||||
|
||||
revoke(userId: string): Promise<void> {
|
||||
return this.commands.execute(new RevokeCalendarFeedCommand(userId));
|
||||
}
|
||||
|
||||
/** The calendar behind `token`, or null if no feed has it. */
|
||||
ics(token: string, webUrl: string): Promise<string | null> {
|
||||
return this.queryBus.execute(new GetCalendarIcsQuery(token, webUrl));
|
||||
}
|
||||
}
|
||||
198
libs/api/tracker/application/src/lib/calendar/calendar.ts
Normal file
198
libs/api/tracker/application/src/lib/calendar/calendar.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { guestOnly } from '../queries/read-access';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import {
|
||||
Command,
|
||||
CommandHandler,
|
||||
ICommandHandler,
|
||||
IQueryHandler,
|
||||
Query,
|
||||
QueryHandler,
|
||||
} from '@nestjs/cqrs';
|
||||
import {
|
||||
IssueReadModel,
|
||||
ProjectReadModel,
|
||||
ReleaseStatus,
|
||||
SprintStatus,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import {
|
||||
CalendarFeed,
|
||||
CalendarFeedsRepository,
|
||||
} from '../ports/calendar-feeds.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 { UserDirectory } from '../ports/user-directory';
|
||||
import { CalendarEntry, buildIcs } from './ics';
|
||||
|
||||
/** A new secret URL for the person's calendar; the old one stops working. */
|
||||
export class CreateCalendarFeedCommand extends Command<CalendarFeed> {
|
||||
constructor(public readonly userId: string) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/** The person's calendar URL stops working. */
|
||||
export class RevokeCalendarFeedCommand extends Command<void> {
|
||||
constructor(public readonly userId: string) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class GetMyCalendarFeedQuery extends Query<CalendarFeed | null> {
|
||||
constructor(public readonly userId: string) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/** The calendar behind a token, as iCalendar text; null for an unknown one. */
|
||||
export class GetCalendarIcsQuery extends Query<string | null> {
|
||||
constructor(
|
||||
public readonly token: string,
|
||||
/** Where tickets open (`WEB_URL`), for the events' links; may be empty. */
|
||||
public readonly webUrl: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(CreateCalendarFeedCommand)
|
||||
export class CreateCalendarFeedCommandHandler
|
||||
implements ICommandHandler<CreateCalendarFeedCommand>
|
||||
{
|
||||
constructor(private readonly feeds: CalendarFeedsRepository) {}
|
||||
|
||||
async execute(command: CreateCalendarFeedCommand): Promise<CalendarFeed> {
|
||||
const feed: CalendarFeed = {
|
||||
userId: command.userId,
|
||||
// 32 random bytes: nobody guesses it.
|
||||
token: randomBytes(32).toString('base64url'),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
await this.feeds.save(feed);
|
||||
return feed;
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(RevokeCalendarFeedCommand)
|
||||
export class RevokeCalendarFeedCommandHandler
|
||||
implements ICommandHandler<RevokeCalendarFeedCommand>
|
||||
{
|
||||
constructor(private readonly feeds: CalendarFeedsRepository) {}
|
||||
|
||||
async execute(command: RevokeCalendarFeedCommand): Promise<void> {
|
||||
await this.feeds.remove(command.userId);
|
||||
}
|
||||
}
|
||||
|
||||
@QueryHandler(GetMyCalendarFeedQuery)
|
||||
export class GetMyCalendarFeedQueryHandler
|
||||
implements IQueryHandler<GetMyCalendarFeedQuery, CalendarFeed | null>
|
||||
{
|
||||
constructor(private readonly feeds: CalendarFeedsRepository) {}
|
||||
|
||||
execute(query: GetMyCalendarFeedQuery): Promise<CalendarFeed | null> {
|
||||
return this.feeds.findByUser(query.userId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What the person has to keep an eye on, as all-day events: their unfinished
|
||||
* tickets with a due date (assigned or followed), the planned and running
|
||||
* sprints of their projects and their planned releases with a date. Read at
|
||||
* every download, so it is as fresh as the calendar app asks.
|
||||
*/
|
||||
@QueryHandler(GetCalendarIcsQuery)
|
||||
export class GetCalendarIcsQueryHandler
|
||||
implements IQueryHandler<GetCalendarIcsQuery, string | null>
|
||||
{
|
||||
constructor(
|
||||
private readonly feeds: CalendarFeedsRepository,
|
||||
private readonly findProjects: FindProjectsRepository,
|
||||
private readonly findIssues: FindIssuesRepository,
|
||||
private readonly findSprints: FindSprintsRepository,
|
||||
private readonly findReleases: FindReleasesRepository,
|
||||
private readonly users: UserDirectory,
|
||||
) {}
|
||||
|
||||
async execute(query: GetCalendarIcsQuery): Promise<string | null> {
|
||||
const feed = await this.feeds.findByToken(query.token);
|
||||
if (!feed) return null;
|
||||
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(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);
|
||||
const entries: CalendarEntry[] = [
|
||||
...issues.flatMap((issue) => {
|
||||
const project = byId.get(issue.projectId);
|
||||
return project ? [issueEntry(issue, project, link)] : [];
|
||||
}),
|
||||
...sprints
|
||||
.flat()
|
||||
.filter(
|
||||
(s) =>
|
||||
s.status === SprintStatus.PLANNED ||
|
||||
s.status === SprintStatus.ACTIVE,
|
||||
)
|
||||
.map((s) => ({
|
||||
uid: `sprint-${s.id}@taskstream`,
|
||||
start: s.startDate,
|
||||
end: s.endDate,
|
||||
summary: `${s.name} · ${byId.get(s.projectId)?.key ?? ''}`.trim(),
|
||||
description: s.goal ?? undefined,
|
||||
url: link(`/p/${byId.get(s.projectId)?.key}/sprints`),
|
||||
})),
|
||||
...releases
|
||||
.flat()
|
||||
.filter((r) => r.status === ReleaseStatus.PLANNED && r.targetDate)
|
||||
.map((r) => ({
|
||||
uid: `release-${r.id}@taskstream`,
|
||||
start: r.targetDate as string,
|
||||
summary:
|
||||
`Release ${r.name} · ${byId.get(r.projectId)?.key ?? ''}`.trim(),
|
||||
description: r.description ?? undefined,
|
||||
url: link(`/p/${byId.get(r.projectId)?.key}/releases`),
|
||||
})),
|
||||
];
|
||||
return buildIcs(me ? `TaskStream · ${me.name}` : 'TaskStream', entries);
|
||||
}
|
||||
}
|
||||
|
||||
function issueEntry(
|
||||
issue: IssueReadModel,
|
||||
project: ProjectReadModel,
|
||||
link: (path: string) => string | null,
|
||||
): CalendarEntry {
|
||||
const status =
|
||||
project.settings?.statuses.find((s) => s.id === issue.status)?.name ??
|
||||
issue.status;
|
||||
return {
|
||||
uid: `issue-${issue.id}@taskstream`,
|
||||
start: issue.dueDate as string,
|
||||
summary: `${issue.key} · ${issue.title}`,
|
||||
description: `${project.name} · ${status}`,
|
||||
url: link(`/p/${project.key}/issues/${issue.key}`),
|
||||
};
|
||||
}
|
||||
|
||||
function linkTo(webUrl: string): (path: string) => string | null {
|
||||
const base = webUrl.replace(/\/+$/, '');
|
||||
return (path) => (base ? `${base}${path}` : null);
|
||||
}
|
||||
|
||||
export const CALENDAR_HANDLERS = [
|
||||
CreateCalendarFeedCommandHandler,
|
||||
RevokeCalendarFeedCommandHandler,
|
||||
GetMyCalendarFeedQueryHandler,
|
||||
GetCalendarIcsQueryHandler,
|
||||
];
|
||||
62
libs/api/tracker/application/src/lib/calendar/ics.spec.ts
Normal file
62
libs/api/tracker/application/src/lib/calendar/ics.spec.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { buildIcs } from './ics';
|
||||
|
||||
const NOW = new Date('2026-10-14T09:30:00.000Z');
|
||||
|
||||
describe('buildIcs', () => {
|
||||
it('writes all-day events with an exclusive end', () => {
|
||||
const ics = buildIcs(
|
||||
'TaskStream',
|
||||
[
|
||||
{
|
||||
uid: 'issue-1@taskstream',
|
||||
start: '2026-10-20',
|
||||
summary: 'WEB-1 · Login',
|
||||
},
|
||||
{
|
||||
uid: 'sprint-1@taskstream',
|
||||
start: '2026-10-05',
|
||||
end: '2026-10-19',
|
||||
summary: 'Sprint 4',
|
||||
},
|
||||
],
|
||||
NOW,
|
||||
);
|
||||
expect(ics.startsWith('BEGIN:VCALENDAR\r\nVERSION:2.0\r\n')).toBe(true);
|
||||
expect(ics.endsWith('END:VCALENDAR\r\n')).toBe(true);
|
||||
expect(ics).toContain('DTSTAMP:20261014T093000Z');
|
||||
expect(ics).toContain(
|
||||
'DTSTART;VALUE=DATE:20261020\r\nDTEND;VALUE=DATE:20261021',
|
||||
);
|
||||
expect(ics).toContain(
|
||||
'DTSTART;VALUE=DATE:20261005\r\nDTEND;VALUE=DATE:20261020',
|
||||
);
|
||||
expect(ics.split('BEGIN:VEVENT')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('escapes text and skips what has no date', () => {
|
||||
const ics = buildIcs(
|
||||
'Ana; López, QA',
|
||||
[
|
||||
{ uid: 'a', start: '2026-10-20', summary: 'Pago; roto, en\nSafari' },
|
||||
{ uid: 'b', start: '', summary: 'sin fecha' },
|
||||
],
|
||||
NOW,
|
||||
);
|
||||
expect(ics).toContain('X-WR-CALNAME:Ana\\; López\\, QA');
|
||||
expect(ics).toContain('SUMMARY:Pago\\; roto\\, en\\nSafari');
|
||||
expect(ics).not.toContain('sin fecha');
|
||||
});
|
||||
|
||||
it('folds long lines at 75 octets without splitting a character', () => {
|
||||
const summary = 'Revisión de la exportación á'.repeat(5);
|
||||
const ics = buildIcs(
|
||||
'x',
|
||||
[{ uid: 'u', start: '2026-10-20', summary }],
|
||||
NOW,
|
||||
);
|
||||
for (const line of ics.split('\r\n'))
|
||||
expect(Buffer.byteLength(line, 'utf8')).toBeLessThanOrEqual(75);
|
||||
// Unfolding gives the text back.
|
||||
expect(ics.replace(/\r\n /g, '')).toContain(`SUMMARY:${summary}`);
|
||||
});
|
||||
});
|
||||
97
libs/api/tracker/application/src/lib/calendar/ics.ts
Normal file
97
libs/api/tracker/application/src/lib/calendar/ics.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/** One all-day event of the calendar feed. */
|
||||
export interface CalendarEntry {
|
||||
/** Stable across downloads, so calendars update the event, not duplicate it. */
|
||||
uid: string;
|
||||
/** `YYYY-MM-DD`. */
|
||||
start: string;
|
||||
/** `YYYY-MM-DD`, inclusive; the same day when absent. */
|
||||
end?: string;
|
||||
summary: string;
|
||||
description?: string;
|
||||
url?: string | null;
|
||||
}
|
||||
|
||||
const DAY = /^\d{4}-\d{2}-\d{2}/;
|
||||
|
||||
/**
|
||||
* An iCalendar (RFC 5545) document of all-day events: what Google Calendar,
|
||||
* Outlook and Apple Calendar subscribe to. All-day dates carry no time
|
||||
* zone, so a deadline falls on its day wherever the reader is.
|
||||
*/
|
||||
export function buildIcs(
|
||||
name: string,
|
||||
entries: CalendarEntry[],
|
||||
now = new Date(),
|
||||
): string {
|
||||
const stamp = now.toISOString().replace(/[-:]/g, '').replace(/\.\d+/, '');
|
||||
const lines = [
|
||||
'BEGIN:VCALENDAR',
|
||||
'VERSION:2.0',
|
||||
'PRODID:-//Pireon//TaskStream//ES',
|
||||
'CALSCALE:GREGORIAN',
|
||||
'METHOD:PUBLISH',
|
||||
`X-WR-CALNAME:${text(name)}`,
|
||||
// How often to look again: calendars mostly decide for themselves.
|
||||
'REFRESH-INTERVAL;VALUE=DURATION:PT1H',
|
||||
'X-PUBLISHED-TTL:PT1H',
|
||||
];
|
||||
for (const entry of entries) {
|
||||
const start = DAY.exec(entry.start)?.[0];
|
||||
if (!start) continue;
|
||||
const last = DAY.exec(entry.end ?? '')?.[0] ?? start;
|
||||
lines.push(
|
||||
'BEGIN:VEVENT',
|
||||
`UID:${text(entry.uid)}`,
|
||||
`DTSTAMP:${stamp}`,
|
||||
`DTSTART;VALUE=DATE:${compact(start)}`,
|
||||
// DTEND is exclusive: the day after the last one.
|
||||
`DTEND;VALUE=DATE:${compact(nextDay(last < start ? start : last))}`,
|
||||
`SUMMARY:${text(entry.summary)}`,
|
||||
...(entry.description ? [`DESCRIPTION:${text(entry.description)}`] : []),
|
||||
...(entry.url ? [`URL:${entry.url}`] : []),
|
||||
'TRANSP:TRANSPARENT',
|
||||
'END:VEVENT',
|
||||
);
|
||||
}
|
||||
lines.push('END:VCALENDAR');
|
||||
return lines.map(fold).join('\r\n') + '\r\n';
|
||||
}
|
||||
|
||||
/** TEXT values escape backslashes, semicolons, commas and line breaks. */
|
||||
function text(value: string): string {
|
||||
return value
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/;/g, '\\;')
|
||||
.replace(/,/g, '\\,')
|
||||
.replace(/\r?\n/g, '\\n');
|
||||
}
|
||||
|
||||
const compact = (day: string) => day.replace(/-/g, '');
|
||||
|
||||
function nextDay(day: string): string {
|
||||
const date = new Date(`${day}T00:00:00Z`);
|
||||
date.setUTCDate(date.getUTCDate() + 1);
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** Lines of more than 75 octets go on in the next one, after a space. */
|
||||
function fold(line: string): string {
|
||||
const bytes = Buffer.from(line, 'utf8');
|
||||
if (bytes.length <= 75) return line;
|
||||
const parts: string[] = [];
|
||||
let current = '';
|
||||
let size = 0;
|
||||
for (const char of line) {
|
||||
const length = Buffer.byteLength(char, 'utf8');
|
||||
// The first line holds 75 octets; the next ones 74 after their space.
|
||||
if (size + length > (parts.length ? 74 : 75)) {
|
||||
parts.push(current);
|
||||
current = '';
|
||||
size = 0;
|
||||
}
|
||||
current += char;
|
||||
size += length;
|
||||
}
|
||||
parts.push(current);
|
||||
return parts.join('\r\n ');
|
||||
}
|
||||
@@ -9,9 +9,21 @@ import {
|
||||
ProjectChange,
|
||||
ReleaseChange,
|
||||
SprintChange,
|
||||
Typing,
|
||||
} 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 +38,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(
|
||||
@@ -37,11 +54,20 @@ export class ChangesService {
|
||||
return this.changeFeed.comments(issueId);
|
||||
}
|
||||
|
||||
/** Who is writing on the ticket (the client leaves itself out). */
|
||||
async watchTyping(
|
||||
viewerId: string,
|
||||
issueId: string,
|
||||
): Promise<AsyncIterableIterator<Typing>> {
|
||||
await this.queryBus.execute(new GetIssueQuery(viewerId, issueId)); // access check
|
||||
return this.changeFeed.typing(issueId);
|
||||
}
|
||||
|
||||
async watchActivity(
|
||||
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 +75,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 +83,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 +103,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);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { BusinessRuleError } from '@task-stream/api/shared/domain';
|
||||
import { CommentFactory } from '@task-stream/api/tracker/domain';
|
||||
import {
|
||||
CommentFactory,
|
||||
SYSTEM_ACTOR_ID,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { AddCommentCommand } from './add-comment.command';
|
||||
import { CommandResult, ack } from './command-result';
|
||||
import { WriteAccess } from './write-access';
|
||||
@@ -16,10 +19,17 @@ export class AddCommentCommandHandler
|
||||
) {}
|
||||
|
||||
async execute(command: AddCommentCommand): Promise<CommandResult> {
|
||||
const { issue } = await this.access.issueForContributor(
|
||||
command.issueId,
|
||||
command.actorId,
|
||||
);
|
||||
// TaskStream itself (alerts that repeat) is no member; only internal
|
||||
// commands act as it, never a request from outside.
|
||||
const issue =
|
||||
command.actorId === SYSTEM_ACTOR_ID
|
||||
? await this.access.issue(command.issueId)
|
||||
: (await this.access.issueForReporter(command.issueId, command.actorId))
|
||||
.issue;
|
||||
if (issue.trashedAt)
|
||||
throw new BusinessRuleError(
|
||||
`${issue.key} is in the trash: restore it to change it`,
|
||||
);
|
||||
const comment = this.commentFactory.create({
|
||||
issueId: issue.id,
|
||||
projectId: issue.projectId,
|
||||
@@ -28,6 +38,7 @@ export class AddCommentCommandHandler
|
||||
replyTo: command.replyTo
|
||||
? await this.threadOf(command.replyTo, issue.id)
|
||||
: null,
|
||||
poll: command.poll,
|
||||
});
|
||||
|
||||
this.eventPublisher.mergeObjectContext(comment);
|
||||
|
||||
@@ -8,6 +8,8 @@ export class AddCommentCommand extends Command<CommandResult> {
|
||||
public readonly body: string,
|
||||
/** Comment it answers; a reply to a reply joins the same thread. */
|
||||
public readonly replyTo: string | null = null,
|
||||
/** A quick poll's options (2–6), if any. */
|
||||
public readonly poll: string[] | null = null,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,12 @@ export class ArchiveReleaseCommandHandler
|
||||
|
||||
async execute(command: ArchiveReleaseCommand): Promise<CommandResult> {
|
||||
const release = await this.access.release(command.releaseId);
|
||||
(await this.access.project(release.projectId)).assertCanManage(
|
||||
command.actorId,
|
||||
);
|
||||
release.archive(command.actorId);
|
||||
const project = await this.access.project(release.projectId);
|
||||
project.assertCanManage(command.actorId);
|
||||
// The tickets can only go to an open release of the same project.
|
||||
if (command.moveTo && command.moveTo !== release.id)
|
||||
await this.access.openReleaseOf(project, command.moveTo);
|
||||
release.archive(command.actorId, command.moveTo);
|
||||
await release.commitAndWait();
|
||||
return ack(release);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ export class ArchiveReleaseCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly releaseId: string,
|
||||
/** Open release of the same project for its unfinished tickets. */
|
||||
public readonly moveTo: string | null = null,
|
||||
) {
|
||||
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);
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { ReactToCommentCommandHandler } from './react-to-comment.command-handler';
|
||||
import { MarkDecisionCommandHandler } from './mark-decision.command-handler';
|
||||
import { ResolveThreadCommandHandler } from './resolve-thread.command-handler';
|
||||
import { REVIEW_HANDLERS } from './reviews';
|
||||
import { POLL_HANDLERS } from './polls';
|
||||
import { TURN_INTO_ISSUE_HANDLERS } from './turn-comment-into-issue';
|
||||
import { VoteIssueCommandHandler } from './vote-issue.command-handler';
|
||||
import { CreateProjectCommandHandler } from './create-project.command-handler';
|
||||
import { UpdateProjectCommandHandler } from './update-project.command-handler';
|
||||
@@ -21,6 +26,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';
|
||||
@@ -45,6 +51,11 @@ import {
|
||||
export const COMMAND_HANDLERS = [
|
||||
VoteIssueCommandHandler,
|
||||
ReactToCommentCommandHandler,
|
||||
MarkDecisionCommandHandler,
|
||||
ResolveThreadCommandHandler,
|
||||
...REVIEW_HANDLERS,
|
||||
...POLL_HANDLERS,
|
||||
...TURN_INTO_ISSUE_HANDLERS,
|
||||
CreateProjectCommandHandler,
|
||||
UpdateProjectCommandHandler,
|
||||
UpdateProjectSettingsCommandHandler,
|
||||
@@ -66,6 +77,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 {
|
||||
@@ -14,10 +15,12 @@ import {
|
||||
import {
|
||||
Comment,
|
||||
Issue,
|
||||
IssueLinkType,
|
||||
IssueStatus,
|
||||
Project,
|
||||
IssuePriority,
|
||||
IssueType,
|
||||
ReviewVerdict,
|
||||
ProjectRole,
|
||||
TRACKER_FACTORIES,
|
||||
defaultProjectSettings,
|
||||
@@ -76,6 +79,15 @@ import { UserDirectory } from '../ports/user-directory';
|
||||
import { AttachmentStorage } from '../ports/attachment-storage';
|
||||
import { AddCommentCommand } from './add-comment.command';
|
||||
import { ReactToCommentCommand } from './react-to-comment.command';
|
||||
import { MarkDecisionCommand } from './mark-decision.command';
|
||||
import { ResolveThreadCommand } from './resolve-thread.command';
|
||||
import { ClosePollCommand, VotePollCommand } from './polls';
|
||||
import { TurnCommentIntoIssueCommand } from './turn-comment-into-issue';
|
||||
import {
|
||||
AnswerReviewCommand,
|
||||
CancelReviewCommand,
|
||||
RequestReviewCommand,
|
||||
} from './reviews';
|
||||
import { UpdateProjectCommand } from './update-project.command';
|
||||
import { VoteIssueCommand } from './vote-issue.command';
|
||||
import { AddProjectMemberCommand } from './add-project-member.command';
|
||||
@@ -151,6 +163,7 @@ async function setup() {
|
||||
},
|
||||
{ provide: GitIntegrationsRepository, useValue: git },
|
||||
AggregateRehydrator,
|
||||
CommandRunner,
|
||||
{ provide: EventStore, useValue: store },
|
||||
{ provide: AttachmentStorage, useValue: files },
|
||||
{ provide: UserDirectory, useValue: users },
|
||||
@@ -914,6 +927,255 @@ describe('tracker commands (event sourced)', () => {
|
||||
).rejects.toThrow(ForbiddenError);
|
||||
});
|
||||
|
||||
it('lets the team mark a comment as a decision, and only the team', async () => {
|
||||
const { run, rehydrator } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
const issue = await run(
|
||||
new CreateIssueCommand('dev', projectId, IssueType.TASK, 'Caché', {}),
|
||||
);
|
||||
const comment = await run(
|
||||
new AddCommentCommand('qa', issue.id, 'Usamos Redis'),
|
||||
);
|
||||
await run(new MarkDecisionCommand('dev', comment.id, true));
|
||||
// Twice is still once.
|
||||
const again = await run(new MarkDecisionCommand('dev', comment.id, true));
|
||||
expect(again.version).toBe(2);
|
||||
expect((await rehydrator.rehydrate(comment.id, Comment)).decision).toBe(
|
||||
true,
|
||||
);
|
||||
// A viewer reads the decisions but does not make them.
|
||||
await expect(
|
||||
run(new MarkDecisionCommand('pm', comment.id, false)),
|
||||
).rejects.toThrow(ForbiddenError);
|
||||
await run(new MarkDecisionCommand('owner', comment.id, false));
|
||||
expect((await rehydrator.rehydrate(comment.id, Comment)).decision).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('makes a ticket of a comment, as a subtask or linked, and marks the comment', async () => {
|
||||
const { run, rehydrator } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
const issue = await run(
|
||||
new CreateIssueCommand('dev', projectId, IssueType.BUG, 'Pago', {}),
|
||||
);
|
||||
const comment = await run(
|
||||
new AddCommentCommand('qa', issue.id, 'También falla en **Android**'),
|
||||
);
|
||||
// Viewers comment and react; making tickets is for the team.
|
||||
await expect(
|
||||
run(
|
||||
new TurnCommentIntoIssueCommand(
|
||||
'pm',
|
||||
comment.id,
|
||||
'Android',
|
||||
IssueType.BUG,
|
||||
false,
|
||||
),
|
||||
),
|
||||
).rejects.toThrow(ForbiddenError);
|
||||
|
||||
const linked = await run(
|
||||
new TurnCommentIntoIssueCommand(
|
||||
'dev',
|
||||
comment.id,
|
||||
'Falla en Android',
|
||||
IssueType.BUG,
|
||||
false,
|
||||
),
|
||||
);
|
||||
const sub = await run(
|
||||
new TurnCommentIntoIssueCommand(
|
||||
'dev',
|
||||
comment.id,
|
||||
'Probar en Android',
|
||||
IssueType.TASK,
|
||||
true,
|
||||
),
|
||||
);
|
||||
|
||||
const fromLink = await rehydrator.rehydrate(linked.id, Issue);
|
||||
expect(fromLink.title).toBe('Falla en Android');
|
||||
expect(fromLink.description).toBe(
|
||||
'También falla en **Android**\n\n---\n_Sale de un comentario de user qa en TS-1._',
|
||||
);
|
||||
expect(fromLink.links).toEqual([
|
||||
expect.objectContaining({
|
||||
type: IssueLinkType.RELATES_TO,
|
||||
targetIssueId: issue.id,
|
||||
}),
|
||||
]);
|
||||
expect((await rehydrator.rehydrate(sub.id, Issue)).parentId).toBe(issue.id);
|
||||
expect((await rehydrator.rehydrate(comment.id, Comment)).spawned).toEqual([
|
||||
{ id: linked.id, key: 'TS-2' },
|
||||
{ id: sub.id, key: 'TS-3' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('takes votes in a comment poll and keeps the winner as a decision', async () => {
|
||||
const { run, rehydrator, store } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
const issue = await run(
|
||||
new CreateIssueCommand('dev', projectId, IssueType.TASK, 'Pagos', {}),
|
||||
);
|
||||
await expect(
|
||||
run(new AddCommentCommand('dev', issue.id, '¿Cuál?', null, ['Solo'])),
|
||||
).rejects.toThrow(ValidationError);
|
||||
const poll = await run(
|
||||
new AddCommentCommand('dev', issue.id, '¿Cuál?', null, [
|
||||
' Stripe ',
|
||||
'Redsys',
|
||||
]),
|
||||
);
|
||||
await run(new VotePollCommand('qa', poll.id, 0));
|
||||
await run(new VotePollCommand('pm', poll.id, 1));
|
||||
await run(new VotePollCommand('pm', poll.id, 0));
|
||||
await run(new VotePollCommand('owner', poll.id, 1));
|
||||
await expect(run(new VotePollCommand('qa', poll.id, 5))).rejects.toThrow(
|
||||
ValidationError,
|
||||
);
|
||||
// Only whoever started it (or an admin) closes it.
|
||||
await expect(run(new ClosePollCommand('qa', poll.id))).rejects.toThrow(
|
||||
ForbiddenError,
|
||||
);
|
||||
await run(new ClosePollCommand('dev', poll.id));
|
||||
const closed = await rehydrator.rehydrate(poll.id, Comment);
|
||||
expect([closed.poll, closed.pollClosed, closed.pollWinner]).toEqual([
|
||||
['Stripe', 'Redsys'],
|
||||
true,
|
||||
0,
|
||||
]);
|
||||
await expect(run(new VotePollCommand('qa', poll.id, 1))).rejects.toThrow(
|
||||
BusinessRuleError,
|
||||
);
|
||||
const decided = store.events.find(
|
||||
(e) =>
|
||||
e.type === 'CommentAddedEvent' &&
|
||||
(e.data as { comment: { body: string } }).comment.body.startsWith(
|
||||
'Decidido',
|
||||
),
|
||||
);
|
||||
expect((decided?.data as { comment: { body: string } }).comment.body).toBe(
|
||||
'Decidido: **Stripe** (2 de 3 votos)',
|
||||
);
|
||||
expect(
|
||||
(await rehydrator.rehydrate(decided?.streamId ?? '', Comment)).decision,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('asks a teammate to review a ticket, who answers it', async () => {
|
||||
const { run, rehydrator } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
const issue = await run(
|
||||
new CreateIssueCommand('dev', projectId, IssueType.TASK, 'Login', {}),
|
||||
);
|
||||
await run(
|
||||
new RequestReviewCommand('dev', issue.id, 'qa', ' Mira el móvil '),
|
||||
);
|
||||
// Not oneself, not twice the same open one, not outsiders.
|
||||
await expect(
|
||||
run(new RequestReviewCommand('dev', issue.id, 'dev')),
|
||||
).rejects.toThrow(ValidationError);
|
||||
await expect(
|
||||
run(new RequestReviewCommand('dev', issue.id, 'qa')),
|
||||
).rejects.toThrow(BusinessRuleError);
|
||||
await expect(
|
||||
run(new RequestReviewCommand('dev', issue.id, 'stranger')),
|
||||
).rejects.toThrow(BusinessRuleError);
|
||||
// A viewer can be asked and answer, but not ask.
|
||||
await run(new RequestReviewCommand('dev', issue.id, 'pm'));
|
||||
await expect(
|
||||
run(new RequestReviewCommand('pm', issue.id, 'qa')),
|
||||
).rejects.toThrow(ForbiddenError);
|
||||
|
||||
let reviews = (await rehydrator.rehydrate(issue.id, Issue)).reviews;
|
||||
expect(reviews.map((r) => [r.reviewerId, r.note, r.verdict])).toEqual([
|
||||
['qa', 'Mira el móvil', null],
|
||||
['pm', null, null],
|
||||
]);
|
||||
const [forQa, forPm] = reviews;
|
||||
await expect(
|
||||
run(
|
||||
new AnswerReviewCommand(
|
||||
'dev',
|
||||
issue.id,
|
||||
forQa.id,
|
||||
ReviewVerdict.APPROVED,
|
||||
),
|
||||
),
|
||||
).rejects.toThrow(ForbiddenError);
|
||||
await run(
|
||||
new AnswerReviewCommand(
|
||||
'qa',
|
||||
issue.id,
|
||||
forQa.id,
|
||||
ReviewVerdict.CHANGES,
|
||||
'Falla en iOS',
|
||||
),
|
||||
);
|
||||
await expect(
|
||||
run(
|
||||
new AnswerReviewCommand(
|
||||
'qa',
|
||||
issue.id,
|
||||
forQa.id,
|
||||
ReviewVerdict.APPROVED,
|
||||
),
|
||||
),
|
||||
).rejects.toThrow(BusinessRuleError);
|
||||
await run(new CancelReviewCommand('dev', issue.id, forPm.id));
|
||||
|
||||
reviews = (await rehydrator.rehydrate(issue.id, Issue)).reviews;
|
||||
expect(reviews.map((r) => [r.reviewerId, r.verdict, r.answer])).toEqual([
|
||||
['qa', ReviewVerdict.CHANGES, 'Falla en iOS'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('settles a conversation, keeping its conclusion as a decision', async () => {
|
||||
const { run, rehydrator, store } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
const issue = await run(
|
||||
new CreateIssueCommand('dev', projectId, IssueType.TASK, 'Pagos', {}),
|
||||
);
|
||||
const thread = await run(
|
||||
new AddCommentCommand('qa', issue.id, '¿Stripe o Redsys?'),
|
||||
);
|
||||
const reply = await run(
|
||||
new AddCommentCommand('dev', issue.id, 'Stripe', thread.id),
|
||||
);
|
||||
// A reply is not a conversation; a viewer does not settle one.
|
||||
await expect(
|
||||
run(new ResolveThreadCommand('dev', reply.id, true)),
|
||||
).rejects.toThrow(BusinessRuleError);
|
||||
await expect(
|
||||
run(new ResolveThreadCommand('pm', thread.id, true)),
|
||||
).rejects.toThrow(ForbiddenError);
|
||||
|
||||
await run(
|
||||
new ResolveThreadCommand('dev', thread.id, true, ' Vamos con Stripe '),
|
||||
);
|
||||
expect((await rehydrator.rehydrate(thread.id, Comment)).resolved).toBe(
|
||||
true,
|
||||
);
|
||||
const conclusion = store.events.find(
|
||||
(e) =>
|
||||
e.type === 'CommentAddedEvent' &&
|
||||
(e.data as { comment: { body: string } }).comment.body ===
|
||||
'Vamos con Stripe',
|
||||
);
|
||||
expect(conclusion).toBeDefined();
|
||||
const added = await rehydrator.rehydrate(
|
||||
conclusion?.streamId ?? '',
|
||||
Comment,
|
||||
);
|
||||
expect(added).toMatchObject({ replyTo: thread.id, decision: true });
|
||||
|
||||
await run(new ResolveThreadCommand('owner', thread.id, false));
|
||||
expect((await rehydrator.rehydrate(thread.id, Comment)).resolved).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('takes votes from members, and from anyone once it is public', async () => {
|
||||
const { run, rehydrator } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -15,7 +15,21 @@ export class DetachIssueFromReleaseCommandHandler
|
||||
const project = await this.access.live(issue.projectId, Project);
|
||||
// Finished tickets stay: they are what the release shipped.
|
||||
if (!project || project.workflow.isFinished(issue.status)) return;
|
||||
issue.setRelease(command.actorId, null);
|
||||
issue.setRelease(command.actorId, await this.target(project, command));
|
||||
await issue.commitAndWait();
|
||||
}
|
||||
|
||||
/** The release chosen when archiving, if it still takes tickets. */
|
||||
private async target(
|
||||
project: Project,
|
||||
command: DetachIssueFromReleaseCommand,
|
||||
): Promise<string | null> {
|
||||
if (!command.toReleaseId) return null;
|
||||
try {
|
||||
await this.access.openReleaseOf(project, command.toReleaseId);
|
||||
return command.toReleaseId;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
|
||||
/** Saga only: takes an unfinished ticket out of an archived release. */
|
||||
/**
|
||||
* Saga only: takes an unfinished ticket out of an archived release, to
|
||||
* `toReleaseId` or to none.
|
||||
*/
|
||||
export class DetachIssueFromReleaseCommand extends Command<void> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly issueId: string,
|
||||
public readonly releaseId: string,
|
||||
public readonly toReleaseId: string | null = null,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { CommandResult, ack } from './command-result';
|
||||
import { MarkDecisionCommand } from './mark-decision.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
/** The team decides: whoever can change the ticket marks its decisions. */
|
||||
@CommandHandler(MarkDecisionCommand)
|
||||
export class MarkDecisionCommandHandler
|
||||
implements ICommandHandler<MarkDecisionCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: MarkDecisionCommand): Promise<CommandResult> {
|
||||
const comment = await this.access.comment(command.commentId);
|
||||
await this.access.issueForContributor(comment.issueId, command.actorId);
|
||||
comment.markDecision(command.actorId, command.on);
|
||||
await comment.commitAndWait();
|
||||
return ack(comment);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
import { CommandResult } from './command-result';
|
||||
|
||||
/** Marks a comment as a decision of its ticket (`on`) or unmarks it. */
|
||||
export class MarkDecisionCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly commentId: string,
|
||||
public readonly on: boolean,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
108
libs/api/tracker/application/src/lib/commands/polls.ts
Normal file
108
libs/api/tracker/application/src/lib/commands/polls.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import {
|
||||
Command,
|
||||
CommandHandler,
|
||||
EventPublisher,
|
||||
ICommandHandler,
|
||||
} from '@nestjs/cqrs';
|
||||
import { ForbiddenError } from '@task-stream/api/shared/domain';
|
||||
import { CommentFactory, ProjectRole } from '@task-stream/api/tracker/domain';
|
||||
import { CommandResult, ack } from './command-result';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
/*
|
||||
* A quick poll in a comment («¿Stripe o Redsys?»): members vote, one vote
|
||||
* each; whoever started it closes it, and the winner can stay as a decision.
|
||||
*/
|
||||
|
||||
export class VotePollCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly commentId: string,
|
||||
/** Null takes the vote back. */
|
||||
public readonly option: number | null,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class ClosePollCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly commentId: string,
|
||||
/** Keep the winner as a decision of the ticket (a reply marked so). */
|
||||
public readonly decide = true,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/** Voting is for members, viewers included: like reacting. */
|
||||
@CommandHandler(VotePollCommand)
|
||||
export class VotePollCommandHandler
|
||||
implements ICommandHandler<VotePollCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: VotePollCommand): Promise<CommandResult> {
|
||||
const comment = await this.access.comment(command.commentId);
|
||||
const project = await this.access.project(comment.projectId);
|
||||
if (!project.isMember(command.actorId))
|
||||
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.votePoll(command.actorId, command.option);
|
||||
await comment.commitAndWait();
|
||||
return ack(comment);
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(ClosePollCommand)
|
||||
export class ClosePollCommandHandler
|
||||
implements ICommandHandler<ClosePollCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly access: WriteAccess,
|
||||
private readonly commentFactory: CommentFactory,
|
||||
private readonly eventPublisher: EventPublisher,
|
||||
) {}
|
||||
|
||||
async execute(command: ClosePollCommand): Promise<CommandResult> {
|
||||
const comment = await this.access.comment(command.commentId);
|
||||
const project = await this.access.project(comment.projectId);
|
||||
if (!project.isMember(command.actorId))
|
||||
throw new ForbiddenError(
|
||||
`You are not a member of project ${project.key}`,
|
||||
);
|
||||
const role = project.roleOf(command.actorId);
|
||||
const voters = Object.keys(comment.pollVotes).length;
|
||||
const winner = comment.closePoll(
|
||||
command.actorId,
|
||||
role === ProjectRole.OWNER || role === ProjectRole.ADMIN,
|
||||
);
|
||||
await comment.commitAndWait();
|
||||
if (command.decide && winner !== null && comment.poll) {
|
||||
const votes = Object.values(comment.pollVotes).filter(
|
||||
(v) => v === winner,
|
||||
).length;
|
||||
// The result, as a decision above the ticket.
|
||||
const decision = this.commentFactory.create({
|
||||
issueId: comment.issueId,
|
||||
projectId: comment.projectId,
|
||||
authorId: command.actorId,
|
||||
body: `Decidido: **${comment.poll[winner]}** (${votes} de ${voters} ${voters === 1 ? 'voto' : 'votos'})`,
|
||||
replyTo: comment.replyTo ?? comment.id,
|
||||
});
|
||||
decision.markDecision(command.actorId, true);
|
||||
this.eventPublisher.mergeObjectContext(decision);
|
||||
await decision.commitAndWait();
|
||||
}
|
||||
return ack(comment);
|
||||
}
|
||||
}
|
||||
|
||||
export const POLL_HANDLERS = [VotePollCommandHandler, ClosePollCommandHandler];
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { BusinessRuleError } from '@task-stream/api/shared/domain';
|
||||
import { CommentFactory } from '@task-stream/api/tracker/domain';
|
||||
import { CommandResult, ack } from './command-result';
|
||||
import { ResolveThreadCommand } from './resolve-thread.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
/** The team settles its conversations: whoever can change the ticket. */
|
||||
@CommandHandler(ResolveThreadCommand)
|
||||
export class ResolveThreadCommandHandler
|
||||
implements ICommandHandler<ResolveThreadCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly access: WriteAccess,
|
||||
private readonly commentFactory: CommentFactory,
|
||||
private readonly eventPublisher: EventPublisher,
|
||||
) {}
|
||||
|
||||
async execute(command: ResolveThreadCommand): Promise<CommandResult> {
|
||||
const thread = await this.access.comment(command.commentId);
|
||||
if (thread.replyTo)
|
||||
throw new BusinessRuleError(
|
||||
'Only a conversation is resolved, not a reply',
|
||||
);
|
||||
await this.access.issueForContributor(thread.issueId, command.actorId);
|
||||
const conclusion = command.conclusion?.trim();
|
||||
if (command.resolved && conclusion && !thread.resolved) {
|
||||
// What was settled stays as a decision, shown above the ticket.
|
||||
const reply = this.commentFactory.create({
|
||||
issueId: thread.issueId,
|
||||
projectId: thread.projectId,
|
||||
authorId: command.actorId,
|
||||
body: conclusion,
|
||||
replyTo: thread.id,
|
||||
});
|
||||
reply.markDecision(command.actorId, true);
|
||||
this.eventPublisher.mergeObjectContext(reply);
|
||||
await reply.commitAndWait();
|
||||
}
|
||||
thread.resolveThread(command.actorId, command.resolved);
|
||||
await thread.commitAndWait();
|
||||
return ack(thread);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
import { CommandResult } from './command-result';
|
||||
|
||||
/**
|
||||
* Settles a conversation (`resolved`) or reopens it. A conclusion, if
|
||||
* given, is added as a reply marked as a decision before it closes.
|
||||
*/
|
||||
export class ResolveThreadCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly commentId: string,
|
||||
public readonly resolved: boolean,
|
||||
public readonly conclusion: string | null = null,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
132
libs/api/tracker/application/src/lib/commands/reviews.ts
Normal file
132
libs/api/tracker/application/src/lib/commands/reviews.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Command, CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
BusinessRuleError,
|
||||
ForbiddenError,
|
||||
} from '@task-stream/api/shared/domain';
|
||||
import { ProjectRole, ReviewVerdict } from '@task-stream/api/tracker/domain';
|
||||
import { CommandResult, ack } from './command-result';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
/*
|
||||
* «Ana, can you review this?»: a request on a ticket that the reviewer
|
||||
* answers (all good, or changes wanted). Events of the ticket, so they show
|
||||
* in its history and in the activity feed.
|
||||
*/
|
||||
|
||||
export class RequestReviewCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly issueId: string,
|
||||
public readonly reviewerId: string,
|
||||
public readonly note: string | null = null,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class AnswerReviewCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly issueId: string,
|
||||
public readonly reviewId: string,
|
||||
public readonly verdict: ReviewVerdict,
|
||||
public readonly answer: string | null = null,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class CancelReviewCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly issueId: string,
|
||||
public readonly reviewId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/** The team asks for reviews: whoever can change the ticket. */
|
||||
@CommandHandler(RequestReviewCommand)
|
||||
export class RequestReviewCommandHandler
|
||||
implements ICommandHandler<RequestReviewCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: RequestReviewCommand): Promise<CommandResult> {
|
||||
const { issue, project } = await this.access.issueForContributor(
|
||||
command.issueId,
|
||||
command.actorId,
|
||||
);
|
||||
// Someone who reads the whole project: a guest would not see the ticket.
|
||||
const role = project.roleOf(command.reviewerId);
|
||||
if (!role || (role === ProjectRole.GUEST && !project.isPublic))
|
||||
throw new BusinessRuleError(
|
||||
`Only members of ${project.key} can be asked to review`,
|
||||
);
|
||||
issue.requestReview(
|
||||
command.actorId,
|
||||
randomUUID(),
|
||||
command.reviewerId,
|
||||
command.note,
|
||||
);
|
||||
await issue.commitAndWait();
|
||||
return ack(issue);
|
||||
}
|
||||
}
|
||||
|
||||
/** The person asked answers; reading the ticket is enough (viewers too). */
|
||||
@CommandHandler(AnswerReviewCommand)
|
||||
export class AnswerReviewCommandHandler
|
||||
implements ICommandHandler<AnswerReviewCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: AnswerReviewCommand): Promise<CommandResult> {
|
||||
const issue = await this.access.issue(command.issueId);
|
||||
const project = await this.access.project(issue.projectId);
|
||||
if (!project.isMember(command.actorId))
|
||||
throw new ForbiddenError(
|
||||
`You are not a member of project ${project.key}`,
|
||||
);
|
||||
issue.answerReview(
|
||||
command.actorId,
|
||||
command.reviewId,
|
||||
command.verdict,
|
||||
command.answer,
|
||||
);
|
||||
await issue.commitAndWait();
|
||||
return ack(issue);
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(CancelReviewCommand)
|
||||
export class CancelReviewCommandHandler
|
||||
implements ICommandHandler<CancelReviewCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: CancelReviewCommand): Promise<CommandResult> {
|
||||
const issue = await this.access.issue(command.issueId);
|
||||
const project = await this.access.project(issue.projectId);
|
||||
if (!project.isMember(command.actorId))
|
||||
throw new ForbiddenError(
|
||||
`You are not a member of project ${project.key}`,
|
||||
);
|
||||
const role = project.roleOf(command.actorId);
|
||||
issue.cancelReview(
|
||||
command.actorId,
|
||||
command.reviewId,
|
||||
role === ProjectRole.OWNER || role === ProjectRole.ADMIN,
|
||||
);
|
||||
await issue.commitAndWait();
|
||||
return ack(issue);
|
||||
}
|
||||
}
|
||||
|
||||
export const REVIEW_HANDLERS = [
|
||||
RequestReviewCommandHandler,
|
||||
AnswerReviewCommandHandler,
|
||||
CancelReviewCommandHandler,
|
||||
];
|
||||
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
Command,
|
||||
CommandBus,
|
||||
CommandHandler,
|
||||
ICommandHandler,
|
||||
} from '@nestjs/cqrs';
|
||||
import { BusinessRuleError } from '@task-stream/api/shared/domain';
|
||||
import { IssueLinkType } from '@task-stream/api/tracker/domain';
|
||||
import { UserDirectory } from '../ports/user-directory';
|
||||
import { CommandResult } from './command-result';
|
||||
import { CreateIssueCommand } from './create-issue.command';
|
||||
import { LinkIssuesCommand } from './link-issues.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
/*
|
||||
* «Convertir en ticket»: a comment that turns out to be work of its own
|
||||
* («también falla en Android») becomes a ticket, as a subtask of the one it
|
||||
* was said on or linked to it. Its text is the new description, and the
|
||||
* comment shows where it went («→ WEB-45»).
|
||||
*/
|
||||
|
||||
export class TurnCommentIntoIssueCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly commentId: string,
|
||||
public readonly title: string,
|
||||
/** Ticket type id of the project's settings. */
|
||||
public readonly type: string,
|
||||
/** Hang it from the ticket of the comment; otherwise, link them. */
|
||||
public readonly asSubtask: boolean,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/** Answers the new ticket (id, key, version). */
|
||||
@CommandHandler(TurnCommentIntoIssueCommand)
|
||||
export class TurnCommentIntoIssueCommandHandler
|
||||
implements ICommandHandler<TurnCommentIntoIssueCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly access: WriteAccess,
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly users: UserDirectory,
|
||||
) {}
|
||||
|
||||
async execute(command: TurnCommentIntoIssueCommand): Promise<CommandResult> {
|
||||
const comment = await this.access.comment(command.commentId);
|
||||
const source = await this.access.issue(comment.issueId);
|
||||
const project = await this.access.project(comment.projectId);
|
||||
this.access.assertSees(project, source, command.actorId);
|
||||
// Creating and linking tickets is for the team: those commands check it.
|
||||
project.assertCanContribute(command.actorId);
|
||||
if (comment.deleted) throw new BusinessRuleError('Comment was deleted');
|
||||
|
||||
const author = (await this.users.refs([comment.authorId])).of(
|
||||
comment.authorId,
|
||||
);
|
||||
const created = await this.commandBus.execute(
|
||||
new CreateIssueCommand(
|
||||
command.actorId,
|
||||
project.id,
|
||||
command.type,
|
||||
command.title,
|
||||
{
|
||||
description: `${comment.body}\n\n---\n_Sale de un comentario de ${author.name} en ${source.key}._`,
|
||||
},
|
||||
null,
|
||||
null,
|
||||
command.asSubtask ? source.id : null,
|
||||
null,
|
||||
// The comment is the content: no template on top of it.
|
||||
false,
|
||||
),
|
||||
);
|
||||
if (!command.asSubtask)
|
||||
await this.commandBus.execute(
|
||||
new LinkIssuesCommand(
|
||||
command.actorId,
|
||||
created.id,
|
||||
IssueLinkType.RELATES_TO,
|
||||
source.id,
|
||||
),
|
||||
);
|
||||
comment.turnedInto(command.actorId, {
|
||||
id: created.id,
|
||||
key: created.key ?? '',
|
||||
});
|
||||
await comment.commitAndWait();
|
||||
return created;
|
||||
}
|
||||
}
|
||||
|
||||
export const TURN_INTO_ISSUE_HANDLERS = [TurnCommentIntoIssueCommandHandler];
|
||||
@@ -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,18 +1,30 @@
|
||||
import { CommandRunner } from '@task-stream/api/shared/application';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import { CommentReadModel } from '@task-stream/api/tracker/domain';
|
||||
import { QueryBus } from '@nestjs/cqrs';
|
||||
import {
|
||||
CommentReadModel,
|
||||
DecisionReadModel,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { AddCommentCommand } from './commands/add-comment.command';
|
||||
import { CommandResult } from './commands/command-result';
|
||||
import { DeleteCommentCommand } from './commands/delete-comment.command';
|
||||
import { EditCommentCommand } from './commands/edit-comment.command';
|
||||
import { ReactToCommentCommand } from './commands/react-to-comment.command';
|
||||
import { MarkDecisionCommand } from './commands/mark-decision.command';
|
||||
import { TurnCommentIntoIssueCommand } from './commands/turn-comment-into-issue';
|
||||
import { ResolveThreadCommand } from './commands/resolve-thread.command';
|
||||
import { ClosePollCommand, VotePollCommand } from './commands/polls';
|
||||
import { Page } from './ports/find-issues.repository';
|
||||
import { GetCommentsQuery } from './queries/get-comments.query';
|
||||
import {
|
||||
GetIssueDecisionsQuery,
|
||||
SearchDecisionsQuery,
|
||||
} from './queries/decisions.query';
|
||||
|
||||
@Injectable()
|
||||
export class CommentsService {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly commands: CommandRunner,
|
||||
private readonly queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
@@ -21,9 +33,32 @@ export class CommentsService {
|
||||
issueId: string,
|
||||
body: string,
|
||||
replyTo: string | null = null,
|
||||
poll: string[] | null = null,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
new AddCommentCommand(actorId, issueId, body, replyTo),
|
||||
return this.commands.execute(
|
||||
new AddCommentCommand(actorId, issueId, body, replyTo, poll),
|
||||
);
|
||||
}
|
||||
|
||||
/** Votes in a comment's poll (null takes the vote back). */
|
||||
votePoll(
|
||||
actorId: string,
|
||||
commentId: string,
|
||||
option: number | null,
|
||||
): Promise<CommandResult> {
|
||||
return this.commands.execute(
|
||||
new VotePollCommand(actorId, commentId, option),
|
||||
);
|
||||
}
|
||||
|
||||
/** Closes a poll; with `decide`, its winner stays as a decision. */
|
||||
closePoll(
|
||||
actorId: string,
|
||||
commentId: string,
|
||||
decide = true,
|
||||
): Promise<CommandResult> {
|
||||
return this.commands.execute(
|
||||
new ClosePollCommand(actorId, commentId, decide),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,7 +67,7 @@ export class CommentsService {
|
||||
commentId: string,
|
||||
body: string,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
return this.commands.execute(
|
||||
new EditCommentCommand(actorId, commentId, body),
|
||||
);
|
||||
}
|
||||
@@ -44,17 +79,78 @@ 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),
|
||||
/** Marks it as a decision of its ticket (`on`) or unmarks it: the team does. */
|
||||
markDecision(
|
||||
actorId: string,
|
||||
commentId: string,
|
||||
on: boolean,
|
||||
): Promise<CommandResult> {
|
||||
return this.commands.execute(
|
||||
new MarkDecisionCommand(actorId, commentId, on),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Settles a conversation or reopens it; a conclusion stays as a reply
|
||||
* marked as a decision.
|
||||
*/
|
||||
resolveThread(
|
||||
actorId: string,
|
||||
commentId: string,
|
||||
resolved: boolean,
|
||||
conclusion: string | null = null,
|
||||
): Promise<CommandResult> {
|
||||
return this.commands.execute(
|
||||
new ResolveThreadCommand(actorId, commentId, resolved, conclusion),
|
||||
);
|
||||
}
|
||||
|
||||
/** Makes a ticket of a comment: a subtask of its ticket, or linked to it. */
|
||||
turnIntoIssue(
|
||||
actorId: string,
|
||||
commentId: string,
|
||||
title: string,
|
||||
type: string,
|
||||
asSubtask: boolean,
|
||||
): Promise<CommandResult> {
|
||||
return this.commands.execute(
|
||||
new TurnCommentIntoIssueCommand(
|
||||
actorId,
|
||||
commentId,
|
||||
title,
|
||||
type,
|
||||
asSubtask,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** A ticket's decisions, oldest first. */
|
||||
decisions(
|
||||
viewerId: string | null,
|
||||
issueId: string,
|
||||
): Promise<CommentReadModel[]> {
|
||||
return this.queryBus.execute(new GetIssueDecisionsQuery(viewerId, issueId));
|
||||
}
|
||||
|
||||
/** Decisions by their text: of a project, or of all mine without one. */
|
||||
searchDecisions(
|
||||
viewerId: string | null,
|
||||
filter: { projectId?: string; search?: string; limit?: number },
|
||||
): Promise<DecisionReadModel[]> {
|
||||
return this.queryBus.execute(
|
||||
new SearchDecisionsQuery(viewerId, { limit: 20, ...filter }),
|
||||
);
|
||||
}
|
||||
|
||||
delete(actorId: string, commentId: string): Promise<CommandResult> {
|
||||
return this.commands.execute(new DeleteCommentCommand(actorId, commentId));
|
||||
}
|
||||
|
||||
list(
|
||||
viewerId: string | null,
|
||||
issueId: string,
|
||||
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
IssueChecklistItemRemovedEvent,
|
||||
IssueChecklistItemUpdatedEvent,
|
||||
IssueParentChangedEvent,
|
||||
IssueReviewAnsweredEvent,
|
||||
IssueReviewCancelledEvent,
|
||||
IssueReviewRequestedEvent,
|
||||
ReviewVerdict,
|
||||
IssueDevLinkRecordedEvent,
|
||||
IssueTrashedEvent,
|
||||
IssueRestoredEvent,
|
||||
@@ -26,6 +30,8 @@ import {
|
||||
ProjectRole,
|
||||
ProjectSettingsChangedEvent,
|
||||
ProjectUpdatedEvent,
|
||||
ProjectArchivedEvent,
|
||||
ProjectUnarchivedEvent,
|
||||
ProjectWorkflow,
|
||||
ReleaseArchivedEvent,
|
||||
ReleaseNotesWrittenEvent,
|
||||
@@ -77,6 +83,7 @@ const ROLE: Record<ProjectRole, string> = {
|
||||
[ProjectRole.ADMIN]: 'admin',
|
||||
[ProjectRole.MEMBER]: 'miembro',
|
||||
[ProjectRole.VIEWER]: 'lector',
|
||||
[ProjectRole.GUEST]: 'invitado',
|
||||
};
|
||||
|
||||
const FIELD: Record<string, string> = {
|
||||
@@ -84,12 +91,14 @@ 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',
|
||||
storyPoints: 'los puntos',
|
||||
startDate: 'la fecha de inicio',
|
||||
dueDate: 'la fecha límite',
|
||||
customFields: 'los campos personalizados',
|
||||
};
|
||||
|
||||
const list = (items: string[]) =>
|
||||
@@ -108,6 +117,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) {
|
||||
@@ -182,6 +195,14 @@ export function describeEvent(
|
||||
: 'abierta';
|
||||
return `Pull request ${ref} ${verb} para ${issueKey}${who}`;
|
||||
}
|
||||
if (event instanceof IssueReviewRequestedEvent)
|
||||
return `${actor} pidió a ${ctx.user(event.reviewerId)} que revise ${issueKey}`;
|
||||
if (event instanceof IssueReviewAnsweredEvent)
|
||||
return event.verdict === ReviewVerdict.APPROVED
|
||||
? `${actor} dio por bueno ${issueKey} al revisarlo`
|
||||
: `${actor} pidió cambios en ${issueKey} al revisarlo`;
|
||||
if (event instanceof IssueReviewCancelledEvent)
|
||||
return `${actor} retiró una petición de revisión de ${issueKey}`;
|
||||
if (event instanceof IssueParentChangedEvent) {
|
||||
return event.parentKey
|
||||
? `${actor} colocó ${issueKey} bajo ${event.parentKey}`
|
||||
|
||||
@@ -11,6 +11,9 @@ import {
|
||||
IssueChecklistItemRemovedEvent,
|
||||
IssueChecklistItemUpdatedEvent,
|
||||
IssueParentChangedEvent,
|
||||
IssueReviewAnsweredEvent,
|
||||
IssueReviewCancelledEvent,
|
||||
IssueReviewRequestedEvent,
|
||||
IssueDevLinkRecordedEvent,
|
||||
IssueTrashedEvent,
|
||||
IssueRestoredEvent,
|
||||
@@ -25,6 +28,8 @@ import {
|
||||
ProjectMemberRemovedEvent,
|
||||
ProjectMemberRoleChangedEvent,
|
||||
ProjectUpdatedEvent,
|
||||
ProjectArchivedEvent,
|
||||
ProjectUnarchivedEvent,
|
||||
ReleaseArchivedEvent,
|
||||
ReleaseNotesWrittenEvent,
|
||||
IssueSprintChangedEvent,
|
||||
@@ -57,6 +62,8 @@ import { versionOf } from './read-model-notifier';
|
||||
@EventsHandler(
|
||||
ProjectCreatedEvent,
|
||||
ProjectUpdatedEvent,
|
||||
ProjectArchivedEvent,
|
||||
ProjectUnarchivedEvent,
|
||||
ProjectSettingsChangedEvent,
|
||||
ProjectMemberAddedEvent,
|
||||
ProjectMemberRemovedEvent,
|
||||
@@ -71,6 +78,9 @@ import { versionOf } from './read-model-notifier';
|
||||
IssueAttachmentAddedEvent,
|
||||
IssueAttachmentRemovedEvent,
|
||||
IssueParentChangedEvent,
|
||||
IssueReviewRequestedEvent,
|
||||
IssueReviewAnsweredEvent,
|
||||
IssueReviewCancelledEvent,
|
||||
IssueDevLinkRecordedEvent,
|
||||
IssueTrashedEvent,
|
||||
IssueRestoredEvent,
|
||||
@@ -123,6 +133,7 @@ export class ActivityEventHandler implements IEventHandler<TrackerEvent> {
|
||||
event.actorId,
|
||||
(event as { userId?: string }).userId,
|
||||
(event as { assigneeId?: string }).assigneeId,
|
||||
(event as { reviewerId?: string }).reviewerId,
|
||||
];
|
||||
const users = await this.users.refs(mentioned);
|
||||
const actorOf = (id: string) =>
|
||||
|
||||
@@ -30,6 +30,20 @@ export class CommentAddedEventHandler
|
||||
replyTo: comment.replyTo ?? null,
|
||||
deleted: false,
|
||||
reactions: [],
|
||||
decision: false,
|
||||
decidedBy: null,
|
||||
decidedAt: null,
|
||||
poll: comment.poll?.length
|
||||
? {
|
||||
options: comment.poll.map((text) => ({ text, voters: [] })),
|
||||
closed: false,
|
||||
winner: null,
|
||||
}
|
||||
: null,
|
||||
resolved: false,
|
||||
resolvedBy: null,
|
||||
resolvedAt: null,
|
||||
spawned: [],
|
||||
createdAt: comment.createdAt,
|
||||
editedAt: null,
|
||||
version: versionOf(event),
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import { CommentDecisionMarkedEvent } from '@task-stream/api/tracker/domain';
|
||||
import { UpsertMaterializedCommentRepository } from '../ports/upsert-materialized-comment.repository';
|
||||
import { UserDirectory } from '../ports/user-directory';
|
||||
import { ReadModelNotifier, versionOf } from './read-model-notifier';
|
||||
|
||||
@EventsHandler(CommentDecisionMarkedEvent)
|
||||
export class CommentDecisionMarkedEventHandler
|
||||
implements IEventHandler<CommentDecisionMarkedEvent>
|
||||
{
|
||||
constructor(
|
||||
private readonly upsertMaterializedCommentRepository: UpsertMaterializedCommentRepository,
|
||||
private readonly users: UserDirectory,
|
||||
private readonly notifier: ReadModelNotifier,
|
||||
) {}
|
||||
|
||||
async handle(event: CommentDecisionMarkedEvent) {
|
||||
const refs = await this.users.refs([event.actorId]);
|
||||
await this.upsertMaterializedCommentRepository.upsert({
|
||||
id: event.aggregateId,
|
||||
decision: event.decision,
|
||||
decidedBy: event.decision ? refs.of(event.actorId) : null,
|
||||
decidedAt: event.decision ? event.occurredAt : null,
|
||||
version: versionOf(event),
|
||||
});
|
||||
await this.notifier.comment(event.aggregateId, event.issueId, event);
|
||||
// The ticket shows its decisions on top: its page reloads them.
|
||||
await this.notifier.issue(event.issueId, event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
CommentPollClosedEvent,
|
||||
CommentPollVotedEvent,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { FindCommentsRepository } from '../ports/find-comments.repository';
|
||||
import { UpsertMaterializedCommentRepository } from '../ports/upsert-materialized-comment.repository';
|
||||
import { UserDirectory } from '../ports/user-directory';
|
||||
import { ReadModelNotifier, versionOf } from './read-model-notifier';
|
||||
|
||||
/** A comment's poll as the read side shows it: who voted each option. */
|
||||
@EventsHandler(CommentPollVotedEvent, CommentPollClosedEvent)
|
||||
export class CommentPollEventHandler
|
||||
implements IEventHandler<CommentPollVotedEvent | CommentPollClosedEvent>
|
||||
{
|
||||
constructor(
|
||||
private readonly upsertMaterializedCommentRepository: UpsertMaterializedCommentRepository,
|
||||
private readonly findComments: FindCommentsRepository,
|
||||
private readonly users: UserDirectory,
|
||||
private readonly notifier: ReadModelNotifier,
|
||||
) {}
|
||||
|
||||
async handle(event: CommentPollVotedEvent | CommentPollClosedEvent) {
|
||||
const comment = await this.findComments.findById(event.aggregateId);
|
||||
if (!comment?.poll) return;
|
||||
let poll = comment.poll;
|
||||
if (event instanceof CommentPollVotedEvent) {
|
||||
const refs = await this.users.refs(Object.keys(event.votes));
|
||||
poll = {
|
||||
...poll,
|
||||
options: poll.options.map((option, index) => ({
|
||||
text: option.text,
|
||||
voters: Object.entries(event.votes)
|
||||
.filter(([, vote]) => vote === index)
|
||||
.map(([userId]) => refs.of(userId)),
|
||||
})),
|
||||
};
|
||||
} else {
|
||||
poll = { ...poll, closed: true, winner: event.winner };
|
||||
}
|
||||
await this.upsertMaterializedCommentRepository.upsert({
|
||||
id: event.aggregateId,
|
||||
poll,
|
||||
version: versionOf(event),
|
||||
});
|
||||
await this.notifier.comment(event.aggregateId, event.issueId, event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import { CommentThreadResolvedEvent } from '@task-stream/api/tracker/domain';
|
||||
import { UpsertMaterializedCommentRepository } from '../ports/upsert-materialized-comment.repository';
|
||||
import { UserDirectory } from '../ports/user-directory';
|
||||
import { ReadModelNotifier, versionOf } from './read-model-notifier';
|
||||
|
||||
@EventsHandler(CommentThreadResolvedEvent)
|
||||
export class CommentThreadResolvedEventHandler
|
||||
implements IEventHandler<CommentThreadResolvedEvent>
|
||||
{
|
||||
constructor(
|
||||
private readonly upsertMaterializedCommentRepository: UpsertMaterializedCommentRepository,
|
||||
private readonly users: UserDirectory,
|
||||
private readonly notifier: ReadModelNotifier,
|
||||
) {}
|
||||
|
||||
async handle(event: CommentThreadResolvedEvent) {
|
||||
const refs = await this.users.refs([event.actorId]);
|
||||
await this.upsertMaterializedCommentRepository.upsert({
|
||||
id: event.aggregateId,
|
||||
resolved: event.resolved,
|
||||
resolvedBy: event.resolved ? refs.of(event.actorId) : null,
|
||||
resolvedAt: event.resolved ? event.occurredAt : null,
|
||||
version: versionOf(event),
|
||||
});
|
||||
await this.notifier.comment(event.aggregateId, event.issueId, event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import { CommentTurnedIntoIssueEvent } from '@task-stream/api/tracker/domain';
|
||||
import { UpsertMaterializedCommentRepository } from '../ports/upsert-materialized-comment.repository';
|
||||
import { ReadModelNotifier, versionOf } from './read-model-notifier';
|
||||
|
||||
@EventsHandler(CommentTurnedIntoIssueEvent)
|
||||
export class CommentTurnedIntoIssueEventHandler
|
||||
implements IEventHandler<CommentTurnedIntoIssueEvent>
|
||||
{
|
||||
constructor(
|
||||
private readonly upsertMaterializedCommentRepository: UpsertMaterializedCommentRepository,
|
||||
private readonly notifier: ReadModelNotifier,
|
||||
) {}
|
||||
|
||||
async handle(event: CommentTurnedIntoIssueEvent) {
|
||||
await this.upsertMaterializedCommentRepository.upsert({
|
||||
id: event.aggregateId,
|
||||
spawned: event.spawned,
|
||||
version: versionOf(event),
|
||||
});
|
||||
await this.notifier.comment(event.aggregateId, event.issueId, event);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,11 @@ import { CommentAddedEventHandler } from './comment-added.event-handler';
|
||||
import { CommentDeletedEventHandler } from './comment-deleted.event-handler';
|
||||
import { CommentEditedEventHandler } from './comment-edited.event-handler';
|
||||
import { CommentReactedEventHandler } from './comment-reacted.event-handler';
|
||||
import { CommentDecisionMarkedEventHandler } from './comment-decision-marked.event-handler';
|
||||
import { CommentThreadResolvedEventHandler } from './comment-thread-resolved.event-handler';
|
||||
import { CommentTurnedIntoIssueEventHandler } from './comment-turned-into-issue.event-handler';
|
||||
import { IssueReviewsEventHandler } from './issue-reviews.event-handler';
|
||||
import { CommentPollEventHandler } from './comment-poll.event-handler';
|
||||
import { IssueVotedEventHandler } from './issue-voted.event-handler';
|
||||
import { IssueAssignedEventHandler } from './issue-assigned.event-handler';
|
||||
import { IssueCreatedEventHandler } from './issue-created.event-handler';
|
||||
@@ -16,6 +21,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';
|
||||
@@ -49,6 +55,11 @@ export const EVENT_HANDLERS = [
|
||||
CommentDeletedEventHandler,
|
||||
CommentEditedEventHandler,
|
||||
CommentReactedEventHandler,
|
||||
CommentDecisionMarkedEventHandler,
|
||||
CommentThreadResolvedEventHandler,
|
||||
CommentTurnedIntoIssueEventHandler,
|
||||
IssueReviewsEventHandler,
|
||||
CommentPollEventHandler,
|
||||
IssueVotedEventHandler,
|
||||
IssueAssignedEventHandler,
|
||||
IssueCreatedEventHandler,
|
||||
@@ -63,6 +74,7 @@ export const EVENT_HANDLERS = [
|
||||
ProjectMemberRemovedEventHandler,
|
||||
ProjectMemberRoleChangedEventHandler,
|
||||
ProjectUpdatedEventHandler,
|
||||
ProjectArchivedEventHandler,
|
||||
ProjectSettingsChangedEventHandler,
|
||||
ReleaseArchivedEventHandler,
|
||||
ReleaseCreatedEventHandler,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user