Compare commits

...

14 Commits

Author SHA1 Message Date
0946e9517d feat: a home page with life
Greeting and summary of the day, the coming week, the latest across my
projects, who is online now, the week in numbers (mine and the team's,
with a streak), sprints under way, «Retomar», project cards with their
activity, what to pick up when nothing is mine and first steps for
newcomers. REST (GET /api/me/home), GraphQL (myHome) and MCP
(get_my_week).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:20:41 +02:00
affce615a7 feat: portfolio of all my projects
/cartera (and GraphQL portfolio, REST /api/me/portfolio, MCP
get_portfolio) shows every active project I belong to (not archived, not
as a guest) at a glance: open, overdue, blocked and stuck tickets, the
sprint under way and whether it gets there, planned releases with their
forecast, and the open epics with their progress. It reuses the
dashboard's computations. A release with no tickets now reads «Sin
tickets» instead of «Todo hecho», here and on the dashboard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 18:27:46 +02:00
8bf7d47b2e feat: guest role for clients and outsiders
A project member can be a GUEST. In a private project a guest only sees
the tickets they take part in (opened, assigned or followed): every other
ticket answers 404, also in my work, realtime changes, blockers and
linked tickets, and project-wide views (activity, statistics, dashboard,
releases, sprints, epics, trash) answer 403. Guests open tickets without
assigning or planning them, and comment, attach, follow and vote on their
own; they change nothing else. In a public project they see what anyone
does. The web gives them only Tablero, Lista and Miembros and loads none
of the project-wide data.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 18:02:36 +02:00
f60d4c8346 feat: presence on tickets
A ticket's page beats every 10 s (touchPresence / PUT
/issues/{issue}/presence, with whether I am editing) and gets back who
else is there, editing ones first. Entries live in Redis, one hash per
ticket, and fade after 30 s without a beat; leaving the page takes me out
at once. Only for people who can read the ticket; never in the event
store. The ticket shows «Bob está editando · Ana también lo está viendo»
above its title; editing means a ticket or comment form is open or a
comment is being written.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:53:47 +02:00
869acd3f1f feat: teams within a project
Projects keep teams of members in their settings (settings.teams; people
outside the project are dropped, teams are archived, not removed; absent
when saving means unchanged). Tickets go to a team (`team`, also filtered
in the list and in MCP search_issues), and a comment mentioning
@[Frontend](team-FRONT) tells each of its members. Settings has the teams
with their members, the ticket a «Equipo» select, the list a team filter,
and «@» offers the teams after the people.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:20:35 +02:00
4acf629106 feat: archive a project instead of deleting it
The owner archives a finished project (archiveProject, POST
/projects/{project}/archive) and brings it back (unarchive). While
archived it stays readable, managers still read its rules and webhooks,
but no command changes it: contributing and managing checks refuse with
422, votes and public proposals too, and automations do not run. The web
shows a read-only notice on every page of it (with «Desarchivar» for the
owner), hides what edits, marks it on the home page and lists it last.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:46:22 +02:00
e598f76e1c feat: definition of done per project
A project can ask that tickets reach a DONE status only with their
checklist ticked (settings.checklistBeforeDone, off by default; absent
when saving means unchanged). Members get a 422 telling how many steps are
left; owners and admins may still close it. Settings has the switch and
the ticket's checklist says when the project asks for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:39:24 +02:00
5b0396bc10 feat: saved views of my work, across my projects
A person saves named views of «my work»: projects, assigned to me, that I
follow, status categories, priorities, bugs only, overdue and text. They
are private (Mongo my_views, no history), listed and deleted by their
owner (GraphQL myViews / saveMyView / deleteMyView, REST /api/me/views),
and run as the cross-project search, which gains project, priority,
bugsOnly and overdue filters (also in MCP my_issues). The home page shows
them as a bar above «my work», with a drawer to save a new one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:22:20 +02:00
e14f445e6b fix(api): retry commands that clash with a concurrent write
Services run commands through a CommandRunner that runs one again (up to
5 times, waiting a little longer each time) when its aggregate changed
underneath, e.g. a saga writing to the same ticket. Each command loads its
aggregate fresh and stores its events in one transaction, so a
ConcurrencyError means nothing was stored and running it again is safe.
People no longer get a 409 for it, and the e2e suite stops flaking.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:00:08 +02:00
76312c079d feat: dashboard weighs quality and splits work by a list field
The insights reading adds quality (open bugs per priority, bugs created
and closed per week for 8 weeks, QA returns and passes per area over 90
days, bugs reported in the 14 days after each of the last three
releases) and, for each list custom field, open tickets per option. The
dashboard shows both; the field to split by can be picked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:06:11 +02:00
0d158a2ed7 feat: dashboard forecasts releases, tells what changed and weighs the load
The insights reading grows (GraphQL insights(since:), REST ?since=, MCP):
- planned releases with a likely (p50) and safe (p85) finish date from a
  Monte Carlo over the last six weeks of finished work, one after another,
  and a verdict against their target;
- with `since` (the viewer's last visit, kept in the browser): tickets
  created, finished and sent back from QA, and their mentions;
- each person's open points against what they closed per sprint lately;
- open tickets without estimate, area or assignee, or untouched for 90
  days, each count opening the list filtered (new noEstimate, noArea and
  updatedBefore filters).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:50:24 +02:00
fe03c5bda8 feat: dashboard reads the sprint, the risks, aging and bottlenecks
A new projectInsights reading (GraphQL Project.insights, REST
/projects/{project}/insights, MCP get_project_insights), computed by pure
functions over the read model and the timeline:
- the sprint under way: days left, points done, pace against what is
  needed and whether it gets there;
- tickets at risk: overdue, due within a week, blocked by an open ticket
  and stuck (7 days or more in an in-progress or QA status);
- aging of the work under way against the p85 cycle time;
- average days in each in-progress and QA status, with WIP limits.
The dashboard shows them in four new panels, reloaded on every change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:55:52 +02:00
86e313efaa feat(web): printable report of a sprint or a release
/p/:key/report/(sprint|release)/🆔 what it is, its totals, the release
notes and its tickets by stage. «Imprimir o guardar en PDF» opens the
browser's dialog; on paper the app's header, tabs, tinted panels and
lights are left out. Linked from Sprints and Releases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:51:40 +02:00
e5f4e6c683 feat: start a project from another one's configuration
Creating a project can name one the creator owns or administers: its
statuses, transitions, types, areas, custom fields and automation rules
are copied once (paused rules stay paused; rules assigning to someone who
is not in the new project are left out). GraphQL templateProjectId, REST
template (id or key), MCP create_project template, and a select in the
web's new-project form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:25:00 +02:00
216 changed files with 18843 additions and 472 deletions

View File

@@ -1279,4 +1279,696 @@ describe('task-stream (e2e)', () => {
history.body.map((h: { summary: string }) => h.summary).join(' | '), history.body.map((h: { summary: string }) => h.summary).join(' | '),
).toContain('los campos personalizados'); ).toContain('los campos personalizados');
}); });
it('starts a project from another one: its configuration and rules', async () => {
const stamp = Date.now().toString(36).slice(-4).toUpperCase();
const source = `T${stamp}`;
await rest('token-ana', 'POST', '/projects', { key: source, name: source });
const created = await eventually(
() => rest('token-ana', 'GET', `/projects/${source}`),
(r) => r.status === 200,
);
const sourceId = created.body['id'];
await rest('token-ana', 'POST', `/projects/${source}/members`, {
userId: '102',
});
const settings = created.body['settings'];
settings.areas.push({ id: 'DATA', name: 'Datos', archived: false });
expect(
(
await rest('token-ana', 'PUT', `/projects/${source}/settings`, {
...settings,
customFields: [
{
id: 'CLIENT',
name: 'Cliente',
type: 'SELECT',
options: ['Acme'],
},
],
})
).status,
).toBe(200);
const rule = (name: string, kind: string, value: string) =>
gql(
'token-ana',
`mutation($i: CreateAutomationInput!) { createAutomation(input: $i) { id } }`,
{
i: {
projectId: sourceId,
name,
trigger: { kind: 'ISSUE_CREATED' },
action: { kind, value },
},
},
);
const paused = (await rule('Etiquetar', 'ADD_LABEL', 'nuevo'))[
'createAutomation'
].id;
await gql(
'token-ana',
`mutation($a: ID!) { updateAutomation(automationId: $a, active: false) { id } }`,
{ a: paused },
);
// Bob is in the source project only: this one stays behind.
await rule('Para Bob', 'ASSIGN', '102');
// Only who manages the source can copy it.
expect(
(
await rest('token-bob', 'POST', '/projects', {
key: `B${stamp}`,
name: 'Copia',
template: source,
})
).status,
).toBe(403);
const copy = `C${stamp}`;
const { createProject } = await gql(
'token-ana',
`mutation($i: CreateProjectInput!) { createProject(input: $i) { id key } }`,
{ i: { key: copy, name: 'Copia', templateProjectId: sourceId } },
);
expect(createProject.key).toBe(copy);
const copied = await eventually(
() => rest('token-ana', 'GET', `/projects/${copy}`),
(r) => r.status === 200 && r.body['settings'].customFields.length === 1,
);
expect(
copied.body['settings'].areas.map((a: { id: string }) => a.id),
).toContain('DATA');
expect(copied.body['settings'].customFields[0].options).toEqual(['Acme']);
const { automations } = await gql(
'token-ana',
`query($p: ID!) { automations(projectId: $p) { name active } }`,
{ p: createProject.id },
);
expect(automations).toEqual([{ name: 'Etiquetar', active: false }]);
});
it('reads the dashboard: sprint under way, risks, aging and bottlenecks', async () => {
const key = 'D' + Date.now().toString(36).slice(-5).toUpperCase();
await rest('token-ana', 'POST', '/projects', { key, name: key });
await eventually(
() => rest('token-ana', 'GET', `/projects/${key}`),
(r) => r.status === 200,
);
const late = (
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'TASK',
title: 'Late',
dueDate: '2020-01-01',
})
).body['key'];
const waits = (
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'TASK',
title: 'Waits',
})
).body['key'];
await eventually(
() => rest('token-ana', 'GET', `/issues/${waits}`),
(r) => r.status === 200,
);
await rest('token-ana', 'POST', `/issues/${late}/links`, {
type: 'BLOCKS',
targetIssue: waits,
});
const insights = await eventually(
() => rest('token-ana', 'GET', `/projects/${key}/insights`),
(r) => r.status === 200 && r.body['risks'].blockedCount === 1,
);
expect(insights.body['sprint']).toBeNull();
expect(insights.body['risks'].overdue[0]).toMatchObject({
key: late,
dueDate: '2020-01-01',
});
expect(insights.body['risks'].blocked[0].key).toBe(waits);
expect(
insights.body['bottlenecks'].map((b: { status: string }) => b.status),
).toContain('IN_PROGRESS');
const { project } = await gql(
'token-ana',
`query($k: String!) { project(idOrKey: $k) { insights { risks { overdueCount } aging { thresholdDays } } } }`,
{ k: key },
);
expect(project.insights.risks.overdueCount).toBe(1);
// What changed since a moment, and what the backlog lacks.
const since = new Date(Date.now() - 60_000).toISOString();
const later = await rest(
'token-ana',
'GET',
`/projects/${key}/insights?since=${encodeURIComponent(since)}`,
);
expect(later.body['changes']).toMatchObject({ since, createdCount: 2 });
expect(later.body['backlog']).toMatchObject({
open: 2,
unestimated: 2,
unassigned: 2,
});
expect(
later.body['team'].map((t: { user: { id: string } }) => t.user.id),
).toContain('101');
const unestimated = await rest(
'token-ana',
'GET',
`/projects/${key}/issues?noEstimate=true&noArea=true`,
);
expect(unestimated.body['total']).toBe(2);
expect(later.body['quality'].openBugsByPriority).toHaveLength(5);
expect(later.body['quality'].bugWeeks).toHaveLength(8);
expect(later.body['fields']).toEqual([]);
});
it('keeps my own views of my work, across my projects', async () => {
const key = 'V' + Date.now().toString(36).slice(-5).toUpperCase();
await rest('token-ana', 'POST', '/projects', { key, name: key });
const project = await eventually(
() => rest('token-ana', 'GET', `/projects/${key}`),
(r) => r.status === 200,
);
const bug = (
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'BUG',
title: 'Late bug',
priority: 'CRITICAL',
dueDate: '2020-01-01',
})
).body['key'];
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'TASK',
title: 'Late task',
priority: 'CRITICAL',
dueDate: '2020-01-01',
});
await eventually(
() => rest('token-ana', 'GET', `/issues/${key}-2`),
(r) => r.status === 200,
);
const criteria = {
projectIds: [project.body['id']],
priorities: ['CRITICAL'],
bugsOnly: true,
overdue: true,
};
const saved = await rest('token-ana', 'POST', '/me/views', {
name: ' Bugs críticos vencidos ',
criteria,
});
expect(saved.status).toBe(201);
const views = await rest('token-ana', 'GET', '/me/views');
expect(views.body).toEqual([
expect.objectContaining({ name: 'Bugs críticos vencidos', criteria }),
]);
expect((await rest('token-bob', 'GET', '/me/views')).body).toEqual([]);
// Running it: the same criteria on my work.
const page = await rest(
'token-ana',
'GET',
`/me/issues?assignedToMe=false&project=${project.body['id']}&priority=CRITICAL&bugsOnly=true&overdue=true`,
);
expect(page.body['items'].map((i: { key: string }) => i.key)).toEqual([
bug,
]);
const { myIssues } = await gql(
'token-ana',
`query($p: [ID!]) { myIssues(assignedToMe: false, projectIds: $p, overdue: true) { total } }`,
{ p: [project.body['id']] },
);
expect(myIssues.total).toBe(2);
// Only its owner deletes it.
const id = saved.body['id'];
expect((await rest('token-bob', 'DELETE', `/me/views/${id}`)).status).toBe(
404,
);
expect((await rest('token-ana', 'DELETE', `/me/views/${id}`)).status).toBe(
204,
);
expect((await rest('token-ana', 'GET', '/me/views')).body).toEqual([]);
});
it('keeps unfinished checklists out of DONE when the project asks', async () => {
const key = 'H' + Date.now().toString(36).slice(-5).toUpperCase();
await rest('token-ana', 'POST', '/projects', { key, name: key });
const project = await eventually(
() => rest('token-ana', 'GET', `/projects/${key}`),
(r) => r.status === 200,
);
expect(project.body['settings'].checklistBeforeDone).toBe(false);
await rest('token-ana', 'POST', `/projects/${key}/members`, {
userId: '102',
});
expect(
(
await rest('token-ana', 'PUT', `/projects/${key}/settings`, {
...project.body['settings'],
checklistBeforeDone: true,
})
).status,
).toBe(200);
const issue = (
await rest('token-bob', 'POST', `/projects/${key}/issues`, {
type: 'TASK',
title: 'Con pasos',
})
).body['key'];
await eventually(
() => rest('token-bob', 'GET', `/issues/${issue}`),
(r) => r.status === 200,
);
await rest('token-bob', 'POST', `/issues/${issue}/checklist`, {
text: 'Probar en Safari',
});
for (const status of [
'TODO',
'IN_PROGRESS',
'IN_REVIEW',
'READY_FOR_QA',
'IN_QA',
])
await rest('token-bob', 'PUT', `/issues/${issue}/status`, { status });
const refused = await rest('token-bob', 'PUT', `/issues/${issue}/status`, {
status: 'DONE',
});
expect(refused.status).toBe(422);
expect(refused.body['message']).toMatch(/queda 1 paso/);
// The owner may close it anyway.
expect(
(
await rest('token-ana', 'PUT', `/issues/${issue}/status`, {
status: 'DONE',
})
).status,
).toBe(200);
});
it('archives a project: readable, read-only, back on demand', async () => {
const key = 'A' + Date.now().toString(36).slice(-5).toUpperCase();
await rest('token-ana', 'POST', '/projects', { key, name: key });
await eventually(
() => rest('token-ana', 'GET', `/projects/${key}`),
(r) => r.status === 200,
);
const added = await rest('token-ana', 'POST', `/projects/${key}/members`, {
userId: '102',
});
expect(added.status).toBe(201);
await eventually(
() => rest('token-bob', 'GET', `/projects/${key}`),
(r) => r.status === 200,
);
const created = await rest('token-bob', 'POST', `/projects/${key}/issues`, {
type: 'TASK',
title: 'Antes de archivar',
});
expect(created.body).toMatchObject({ key: `${key}-1` });
const ticket = created.body['key'] as string;
await eventually(
() => rest('token-bob', 'GET', `/issues/${ticket}`),
(r) => r.status === 200,
);
expect(
(await rest('token-bob', 'POST', `/projects/${key}/archive`)).status,
).toBe(403);
expect(
(await rest('token-ana', 'POST', `/projects/${key}/archive`)).status,
).toBe(200);
const archived = await eventually(
() => rest('token-bob', 'GET', `/projects/${key}`),
(r) => !!r.body['archivedAt'],
);
expect(archived.status).toBe(200);
// Still readable; nothing changes.
expect((await rest('token-bob', 'GET', `/issues/${ticket}`)).status).toBe(
200,
);
const refused = await rest('token-bob', 'PATCH', `/issues/${ticket}`, {
title: 'Después',
});
expect(refused.status).toBe(422);
expect(refused.body['message']).toMatch(/archivado/);
expect(
(
await rest('token-bob', 'POST', `/projects/${key}/issues`, {
type: 'TASK',
title: 'Nuevo',
})
).status,
).toBe(422);
const { unarchiveProject } = await gql(
'token-ana',
`mutation($p: ID!) { unarchiveProject(projectId: $p) { version } }`,
{ p: archived.body['id'] },
);
expect(unarchiveProject.version).toBeGreaterThan(0);
await eventually(
() => rest('token-bob', 'GET', `/projects/${key}`),
(r) => r.body['archivedAt'] === null,
);
expect(
(
await rest('token-bob', 'PATCH', `/issues/${ticket}`, {
title: 'Después',
})
).status,
).toBe(200);
});
it('groups members in teams: tickets go to them and @team tells them', async () => {
const key = 'T' + Date.now().toString(36).slice(-5).toUpperCase();
await rest('token-ana', 'POST', '/projects', { key, name: key });
const project = await eventually(
() => rest('token-ana', 'GET', `/projects/${key}`),
(r) => r.status === 200,
);
await rest('token-ana', 'POST', `/projects/${key}/members`, {
userId: '102',
});
expect(
(
await rest('token-ana', 'PUT', `/projects/${key}/settings`, {
...project.body['settings'],
teams: [
{ id: 'FRONT', name: 'Frontend', memberIds: ['102', 'nobody'] },
],
})
).status,
).toBe(200);
const settings = await eventually(
() => rest('token-ana', 'GET', `/projects/${key}`),
(r) => r.body['settings'].teams.length === 1,
);
expect(settings.body['settings'].teams[0].memberIds).toEqual(['102']);
const created = await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'TASK',
title: 'Para el equipo',
team: 'FRONT',
});
const ticket = created.body['key'];
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'TASK',
title: 'Sin equipo',
});
const mine = await eventually(
() => rest('token-ana', 'GET', `/projects/${key}/issues?team=FRONT`),
(r) => r.body?.['total'] === 1,
);
expect(mine.body['items'][0]).toMatchObject({ key: ticket, team: 'FRONT' });
await rest('token-ana', 'POST', `/issues/${ticket}/comments`, {
body: '¿Lo miráis, @[Frontend](team-FRONT)?',
});
const told = await eventually(
() => rest('token-bob', 'GET', '/me/notifications'),
(r) =>
r.body?.items?.some(
(n: { kind: string; issueKey: string }) =>
n.kind === 'MENTIONED' && n.issueKey === ticket,
),
);
expect(
told.body.items.find((n: { kind: string }) => n.kind === 'MENTIONED')
.summary,
).toMatch(/tu equipo Frontend/);
});
it('tells who else is on a ticket, and who is editing it', async () => {
const key = 'P' + Date.now().toString(36).slice(-5).toUpperCase();
await rest('token-ana', 'POST', '/projects', { key, name: key });
await eventually(
() => rest('token-ana', 'GET', `/projects/${key}`),
(r) => r.status === 200,
);
await rest('token-ana', 'POST', `/projects/${key}/members`, {
userId: '102',
});
const created = await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'TASK',
title: 'Aquí estamos',
});
const issue = await eventually(
() => rest('token-bob', 'GET', `/issues/${created.body['key']}`),
(r) => r.status === 200,
);
const id = issue.body['id'];
expect(
(
await rest('token-ana', 'PUT', `/issues/${id}/presence`, {
editing: false,
})
).body,
).toEqual([]);
const { touchPresence } = await gql(
'token-bob',
`mutation($i: ID!) { touchPresence(issueId: $i, editing: true) { user { id name } editing } }`,
{ i: id },
);
expect(touchPresence).toEqual([
{ user: { id: '101', name: 'Ana (lead)' }, editing: false },
]);
const seen = await rest('token-ana', 'PUT', `/issues/${id}/presence`, {});
expect(seen.body).toEqual([
expect.objectContaining({
user: { id: '102', name: 'Bob (frontend)' },
editing: true,
}),
]);
// Outsiders cannot even ask (a private ticket does not exist for them).
expect(
(await rest('token-eve', 'PUT', `/issues/${id}/presence`, {})).status,
).toBe(404);
expect(
(await rest('token-bob', 'DELETE', `/issues/${id}/presence`)).status,
).toBe(204);
expect(
(await rest('token-ana', 'PUT', `/issues/${id}/presence`, {})).body,
).toEqual([]);
});
it('lets a guest see and talk about only their own tickets', async () => {
const key = 'G' + Date.now().toString(36).slice(-5).toUpperCase();
await rest('token-ana', 'POST', '/projects', { key, name: key });
await eventually(
() => rest('token-ana', 'GET', `/projects/${key}`),
(r) => r.status === 200,
);
// Quinn, a client, joins as a guest.
expect((await rest('token-qa', 'GET', '/users/me')).status).toBe(200);
expect(
(
await rest('token-ana', 'POST', `/projects/${key}/members`, {
userId: '103',
role: 'GUEST',
})
).status,
).toBe(201);
await eventually(
() => rest('token-qa', 'GET', `/projects/${key}`),
(r) => r.status === 200,
);
const internal = (
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'TASK',
title: 'Solo del equipo',
})
).body['key'];
// A guest opens a ticket, but does not plan it.
expect(
(
await rest('token-qa', 'POST', `/projects/${key}/issues`, {
type: 'BUG',
title: 'Con asignación',
assigneeId: '101',
})
).status,
).toBe(403);
const mine = (
await rest('token-qa', 'POST', `/projects/${key}/issues`, {
type: 'BUG',
title: 'No puedo pagar',
})
).body['key'];
await eventually(
() => rest('token-qa', 'GET', `/issues/${mine}`),
(r) => r.status === 200,
);
const list = await eventually(
() => rest('token-ana', 'GET', `/projects/${key}/issues`),
(r) => r.body?.['total'] === 2,
);
expect(list.body['total']).toBe(2);
const guestList = await rest('token-qa', 'GET', `/projects/${key}/issues`);
expect(guestList.body['items'].map((i: { key: string }) => i.key)).toEqual([
mine,
]);
expect((await rest('token-qa', 'GET', `/issues/${internal}`)).status).toBe(
404,
);
expect(
(await rest('token-qa', 'PUT', `/issues/${internal}/watch`)).status,
).toBe(404);
// Project-wide views are not theirs.
expect(
(await rest('token-qa', 'GET', `/projects/${key}/activity`)).status,
).toBe(403);
expect(
(await rest('token-qa', 'GET', `/projects/${key}/stats`)).status,
).toBe(403);
// They talk on their ticket, but do not move it.
expect(
(
await rest('token-qa', 'POST', `/issues/${mine}/comments`, {
body: '¿Hay novedades?',
})
).status,
).toBe(201);
expect(
(
await rest('token-qa', 'PUT', `/issues/${mine}/status`, {
status: 'TODO',
})
).status,
).toBe(403);
// Once the team adds them to a ticket, they see it.
await rest('token-ana', 'PUT', `/issues/${internal}/assignee`, {
assigneeId: '103',
});
await eventually(
() => rest('token-qa', 'GET', `/issues/${internal}`),
(r) => r.status === 200,
);
});
it('shows my active projects at a glance (the portfolio)', async () => {
const key = 'F' + Date.now().toString(36).slice(-5).toUpperCase();
await rest('token-ana', 'POST', '/projects', {
key,
name: `Cartera ${key}`,
});
await eventually(
() => rest('token-ana', 'GET', `/projects/${key}`),
(r) => r.status === 200,
);
const epic = (
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'FEATURE',
title: 'Pagos',
dueDate: '2030-01-01',
})
).body['key'];
await eventually(
() => rest('token-ana', 'GET', `/issues/${epic}`),
(r) => r.status === 200,
);
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'TASK',
title: 'Parte',
parent: epic,
dueDate: '2020-01-01',
});
await rest('token-ana', 'POST', `/projects/${key}/releases`, {
name: '1.0',
targetDate: '2030-06-01',
});
const mine = await eventually(
() => rest('token-ana', 'GET', '/me/portfolio'),
(r) =>
r.body?.find?.((p: { key: string }) => p.key === key)?.epics?.length ===
1 &&
r.body.find((p: { key: string }) => p.key === key).releases.length ===
1,
);
const row = mine.body.find((p: { key: string }) => p.key === key);
expect(row).toMatchObject({ total: 2, open: 2, overdue: 1, sprint: null });
expect(row.releases[0]).toMatchObject({ name: '1.0', total: 0 });
expect(row.epics[0].issue.key).toBe(epic);
// Archived projects leave it.
await rest('token-ana', 'POST', `/projects/${key}/archive`);
await eventually(
() => gql('token-ana', `query { portfolio { key } }`),
(d) => !d['portfolio'].some((p: { key: string }) => p.key === key),
);
});
it('fills the home page: agenda, activity, the week and who is here', async () => {
const key = 'H' + Date.now().toString(36).slice(-5).toUpperCase();
await rest('token-ana', 'POST', '/projects', { key, name: key });
await eventually(
() => rest('token-ana', 'GET', `/projects/${key}`),
(r) => r.status === 200,
);
await rest('token-ana', 'POST', `/projects/${key}/members`, {
userId: '102',
});
const today = new Date().toISOString().slice(0, 10);
const ticket = (
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'TASK',
title: 'Para hoy',
assigneeId: '101',
dueDate: today,
})
).body['key'];
const issue = await eventually(
() => rest('token-bob', 'GET', `/issues/${ticket}`),
(r) => r.status === 200,
);
// Bob is on it right now.
await rest('token-bob', 'PUT', `/issues/${issue.body['id']}/presence`, {
editing: true,
});
const home = await eventually(
() => rest('token-ana', 'GET', '/me/home'),
(r) =>
r.body?.agenda?.some(
(a: { issueKey: string }) => a.issueKey === ticket,
) &&
r.body.activity.some(
(a: { issueKey: string }) => a.issueKey === ticket,
),
);
expect(home.body.agenda).toContainEqual(
expect.objectContaining({ day: today, kind: 'ISSUE', issueKey: ticket }),
);
expect(home.body.online).toContainEqual(
expect.objectContaining({
user: { id: '102', name: 'Bob (frontend)' },
projectKey: key,
issueKey: ticket,
editing: true,
}),
);
expect(home.body.team.perDay).toHaveLength(14);
expect(home.body.firstSteps).toMatchObject({
project: true,
invited: true,
ticket: true,
});
const card = home.body.projects.find(
(p: { projectId: string }) => p.projectId === issue.body['projectId'],
);
expect(card).toMatchObject({ open: 1 });
expect(card.activity).toHaveLength(14);
const { myHome } = await gql(
'token-ana',
`query { myHome { me { thisWeek streak } suggestions { kind } } }`,
);
expect(myHome.me.streak).toBeGreaterThanOrEqual(0);
});
}); });

File diff suppressed because it is too large Load Diff

View File

@@ -18,6 +18,25 @@ input AddChecklistItemInput {
text: String! text: String!
} }
"""Something on a day of my week"""
type AgendaItem {
"""YYYY-MM-DD"""
day: String!
issueKey: String
"""ISSUE, SPRINT_END or RELEASE"""
kind: String!
projectKey: String!
title: String!
}
type Aging {
issues: [InsightIssue!]!
"""p85 cycle time of the last 90 days"""
thresholdDays: Float
}
type AreaOption { type AreaOption {
archived: Boolean! archived: Boolean!
id: String! id: String!
@@ -122,12 +141,30 @@ type AutomationTriggerSpec {
status: String status: String
} }
type BacklogHealth {
noArea: Int!
open: Int!
stale: Int!
staleDays: Int!
unassigned: Int!
unestimated: Int!
}
type BoardColumn { type BoardColumn {
issues: [Issue!]! issues: [Issue!]!
status: String! status: String!
total: Int! total: Int!
} }
"""Bugs created and closed in a week"""
type BugWeek {
closed: Int!
created: Int!
"""Monday (UTC), YYYY-MM-DD"""
week: String!
}
type BulkIssueResult { type BulkIssueResult {
error: String error: String
issueId: ID! issueId: ID!
@@ -177,6 +214,22 @@ input ChangeIssueStatusInput {
status: String! status: String!
} }
"""What happened after a moment"""
type ChangesSince {
created: [InsightIssue!]!
createdCount: Int!
finished: [InsightIssue!]!
finishedCount: Int!
"""Times I was mentioned here"""
mentions: Int!
"""Sent back from QA"""
returned: [InsightIssue!]!
returnedCount: Int!
since: String!
}
"""A step inside a ticket that does not deserve a ticket of its own""" """A step inside a ticket that does not deserve a ticket of its own"""
type ChecklistItem { type ChecklistItem {
done: Boolean! done: Boolean!
@@ -298,6 +351,9 @@ input CreateIssueInput {
"""When work should start (roadmap), YYYY-MM-DD""" """When work should start (roadmap), YYYY-MM-DD"""
startDate: String startDate: String
storyPoints: Int storyPoints: Int
"""A team id"""
team: String
title: String! title: String!
"""A ticket type id""" """A ticket type id"""
@@ -313,6 +369,11 @@ input CreateProjectInput {
"""2-10 chars, e.g. "TS" → tickets TS-1, TS-2...""" """2-10 chars, e.g. "TS" → tickets TS-1, TS-2..."""
key: String! key: String!
name: String! name: String!
"""
A project you own or administer to start from: its statuses, transitions, types, areas, custom fields and automation rules are copied
"""
templateProjectId: ID
} }
input CreateReleaseInput { input CreateReleaseInput {
@@ -441,6 +502,30 @@ type Epic {
progress: ChildProgress! progress: ChildProgress!
} }
"""Bugs reported in the 14 days after a release shipped"""
type EscapedBugs {
bugs: Int!
name: String!
releaseId: ID!
releasedAt: String!
}
"""Open tickets per option of a list custom field"""
type FieldBreakdown {
counts: [OptionCount!]!
fieldId: String!
"""Open tickets with no value"""
none: Int!
}
type FirstSteps {
calendar: Boolean!
invited: Boolean!
project: Boolean!
ticket: Boolean!
}
type FlowSeries { type FlowSeries {
counts: [Int!]! counts: [Int!]!
status: String! status: String!
@@ -470,6 +555,53 @@ type GitRepository {
secret: String secret: String
} }
"""The home page beyond my work"""
type Home {
activity: [Activity!]!
agenda: [AgendaItem!]!
firstSteps: FirstSteps!
me: MyWeek!
online: [Online!]!
projects: [HomeProject!]!
sprints: [HomeSprint!]!
suggestions: [Suggestion!]!
team: TeamWeek!
}
type HomeProject {
"""Activity per day, last 14, oldest first"""
activity: [Int!]!
open: Int!
overdue: Int!
projectId: ID!
sprint: HomeSprintBrief
}
type HomeSprint {
projectKey: String!
projectName: String!
sprint: SprintPulse!
}
type HomeSprintBrief {
behind: Boolean!
name: String!
percent: Int!
}
"""A ticket the dashboard names, with the number that put it there"""
type InsightIssue {
assignee: UserRef
"""Days in its status (aging, stuck) or past / to its due date"""
days: Float!
dueDate: String
id: ID!
key: String!
status: String!
title: String!
}
type Issue { type Issue {
"""Statuses this ticket can move to next (project's workflow)""" """Statuses this ticket can move to next (project's workflow)"""
allowedTransitions: [String!]! allowedTransitions: [String!]!
@@ -531,6 +663,9 @@ type Issue {
status: String! status: String!
statusCategory: StatusCategory! statusCategory: StatusCategory!
storyPoints: Int storyPoints: Int
"""A team id"""
team: String
title: String! title: String!
"""In the trash since then; null when it is not""" """In the trash since then; null when it is not"""
@@ -573,6 +708,12 @@ input IssueFilterInput {
dueTo: String dueTo: String
labels: [String!] labels: [String!]
"""Only tickets with no area"""
noArea: Boolean
"""Only tickets nobody estimated"""
noEstimate: Boolean
"""Only tickets in no sprint""" """Only tickets in no sprint"""
noSprint: Boolean noSprint: Boolean
@@ -587,9 +728,15 @@ input IssueFilterInput {
sort: IssueSort = RECENT sort: IssueSort = RECENT
sprintId: ID sprintId: ID
statuses: [String!] statuses: [String!]
"""Team ids"""
teams: [String!]
types: [String!] types: [String!]
unassigned: Boolean unassigned: Boolean
"""Only tickets not updated since this instant (ISO)"""
updatedBefore: String
"""Only the tickets I follow (none when anonymous)""" """Only the tickets I follow (none when anonymous)"""
watching: Boolean watching: Boolean
} }
@@ -707,6 +854,11 @@ type Mutation {
"""Add a Pireon user to the project""" """Add a Pireon user to the project"""
addProjectMember(input: ProjectMemberInput!): CommandResult! addProjectMember(input: ProjectMemberInput!): CommandResult!
"""
Archive the project (owner only): everything stays readable, nothing changes until it is unarchived
"""
archiveProject(projectId: ID!): CommandResult!
archiveRelease( archiveRelease(
""" """
Open release of the same project that takes its unfinished tickets; none: they leave it Open release of the same project that takes its unfinished tickets; none: they leave it
@@ -751,6 +903,9 @@ type Mutation {
"""Author or project admin""" """Author or project admin"""
deleteComment(commentId: ID!): CommandResult! deleteComment(commentId: ID!): CommandResult!
"""Delete one of my views"""
deleteMyView(id: ID!): Boolean!
""" """
Delete the project (owner only), with its tickets, comments and releases. `confirmKey` must be the project key. Delete the project (owner only), with its tickets, comments and releases. `confirmKey` must be the project key.
""" """
@@ -769,6 +924,9 @@ type Mutation {
duplicateIssue(issueId: ID!): CommandResult! duplicateIssue(issueId: ID!): CommandResult!
editComment(body: String!, commentId: ID!): CommandResult! editComment(body: String!, commentId: ID!): CommandResult!
"""I left this ticket's page"""
leavePresence(issueId: ID!): Boolean!
"""Relate tickets, e.g. FE DEPENDS_ON BE, QA TESTS FE""" """Relate tickets, e.g. FE DEPENDS_ON BE, QA TESTS FE"""
linkIssues(input: LinkIssuesInput!): CommandResult! linkIssues(input: LinkIssuesInput!): CommandResult!
@@ -808,6 +966,9 @@ type Mutation {
"""Save the current board filters under a name (members of the project)""" """Save the current board filters under a name (members of the project)"""
saveFilter(input: SaveFilterInput!): ID! saveFilter(input: SaveFilterInput!): ID!
"""Save a view of my work under a name"""
saveMyView(input: SaveMyViewInput!): ID!
""" """
Place a ticket under an epic or another ticket of the same project (null: back to the top). Three levels at most: epic → ticket → subtask. Place a ticket under an epic or another ticket of the same project (null: back to the top). Three levels at most: epic → ticket → subtask.
""" """
@@ -828,10 +989,18 @@ type Mutation {
"""Send a ping now and tell how it went""" """Send a ping now and tell how it went"""
testWebhook(webhookId: ID!): WebhookDelivery! testWebhook(webhookId: ID!): WebhookDelivery!
"""
I am on this ticket (send every ~10 s while the page is open): answers who else is, the ones editing first
"""
touchPresence(editing: Boolean! = false, issueId: ID!): [Presence!]!
""" """
To the trash (whoever opened it, or an owner/admin): hidden everywhere, deleted for good after 30 days To the trash (whoever opened it, or an owner/admin): hidden everywhere, deleted for good after 30 days
""" """
trashIssue(issueId: ID!): CommandResult! trashIssue(issueId: ID!): CommandResult!
"""Bring an archived project back (owner only)"""
unarchiveProject(projectId: ID!): CommandResult!
unlinkIssues(input: LinkIssuesInput!): CommandResult! unlinkIssues(input: LinkIssuesInput!): CommandResult!
"""Push off on this device""" """Push off on this device"""
@@ -880,6 +1049,53 @@ type MyNotifications {
unread: Int! unread: Int!
} }
"""A named view of my work (private)"""
type MyView {
createdAt: String!
criteria: MyViewCriteria!
id: ID!
name: String!
}
"""What a view of my work narrows to; empty: every ticket of my projects"""
type MyViewCriteria {
assignedToMe: Boolean
"""Only types with the bug icon"""
bugsOnly: Boolean
categories: [StatusCategory!]
"""Only open tickets past due"""
overdue: Boolean
priorities: [IssuePriority!]
"""Empty: all my projects"""
projectIds: [ID!]
search: String
watching: Boolean
}
input MyViewCriteriaInput {
assignedToMe: Boolean
bugsOnly: Boolean
categories: [StatusCategory!]
overdue: Boolean
priorities: [IssuePriority!]
"""Empty: all my projects"""
projectIds: [ID!]
search: String
watching: Boolean
}
type MyWeek {
lastWeek: Int!
"""Days in a row with something finished"""
streak: Int!
thisWeek: Int!
}
type Notification { type Notification {
actor: UserRef! actor: UserRef!
createdAt: String! createdAt: String!
@@ -922,6 +1138,57 @@ type NotificationSettings {
pushPublicKey: String pushPublicKey: String
} }
"""Someone of my projects on a ticket now"""
type Online {
at: String!
editing: Boolean!
issueKey: String
issueTitle: String
projectKey: String!
user: UserRef!
}
type OptionCount {
count: Int!
value: String!
}
"""
One of my active projects at a glance: open work, risks, sprint, releases and epics
"""
type PortfolioProject {
blocked: Int!
"""Open epics, nearest due date first (six at most)"""
epics: [Epic!]!
isPublic: Boolean!
key: String!
name: String!
open: Int!
overdue: Int!
projectId: ID!
releases: [ReleaseForecast!]!
sprint: SprintPulse
stuck: Int!
total: Int!
updatedAt: String!
}
"""Someone on a ticket right now (heartbeat in the last 30 s)"""
type Presence {
"""Last heartbeat"""
at: String!
"""Editing the ticket or writing a comment"""
editing: Boolean!
user: UserRef!
}
type PriorityCount {
count: Int!
priority: IssuePriority!
}
type Project { type Project {
"""Takes bug reports and ideas from anyone (while public)""" """Takes bug reports and ideas from anyone (while public)"""
acceptsProposals: Boolean! acceptsProposals: Boolean!
@@ -931,6 +1198,9 @@ type Project {
""" """
analytics(days: Int! = 90): ProjectAnalytics! analytics(days: Int! = 90): ProjectAnalytics!
"""Archived since then (read-only); null while active"""
archivedAt: String
"""Kanban board, one column per status""" """Kanban board, one column per status"""
board( board(
area: String area: String
@@ -950,6 +1220,14 @@ type Project {
description: String description: String
id: ID! id: ID!
"""
The dashboard: sprint under way, overdue / blocked / stuck tickets, aging and bottlenecks
"""
insights(
"""ISO instant (e.g. my last visit): also tell what changed after it"""
since: String
): ProjectInsights!
"""Anyone can read it, also without signing in""" """Anyone can read it, also without signing in"""
isPublic: Boolean! isPublic: Boolean!
key: String! key: String!
@@ -991,6 +1269,27 @@ type ProjectChange {
version: Int! version: Int!
} }
"""
The dashboard: sprint under way, risks, aging, bottlenecks, release forecasts, team load, backlog health and changes
"""
type ProjectInsights {
aging: Aging!
backlog: BacklogHealth!
bottlenecks: [StatusTime!]!
"""Only when asked with `since`"""
changes: ChangesSince
"""One per active list custom field"""
fields: [FieldBreakdown!]!
generatedAt: String!
quality: Quality!
releases: [ReleaseForecast!]!
risks: Risks!
sprint: SprintPulse
team: [TeamLoad!]!
}
type ProjectMember { type ProjectMember {
role: ProjectRole! role: ProjectRole!
user: UserRef! user: UserRef!
@@ -1006,6 +1305,7 @@ input ProjectMemberInput {
enum ProjectRole { enum ProjectRole {
ADMIN ADMIN
GUEST
MEMBER MEMBER
OWNER OWNER
VIEWER VIEWER
@@ -1016,9 +1316,15 @@ Statuses (board order), transitions, ticket types, areas and custom fields
""" """
type ProjectSettings { type ProjectSettings {
areas: [AreaOption!]! areas: [AreaOption!]!
"""
Definition of done: tickets reach DONE only with their checklist ticked (owners and admins may override)
"""
checklistBeforeDone: Boolean!
customFields: [CustomFieldDefinition!]! customFields: [CustomFieldDefinition!]!
issueTypes: [IssueTypeOption!]! issueTypes: [IssueTypeOption!]!
statuses: [WorkflowStatus!]! statuses: [WorkflowStatus!]!
teams: [Team!]!
transitions: [StatusTransitions!]! transitions: [StatusTransitions!]!
} }
@@ -1028,10 +1334,18 @@ The whole configuration (replaces the previous one). Statuses go in board order.
input ProjectSettingsInput { input ProjectSettingsInput {
areas: [AreaOptionInput!]! areas: [AreaOptionInput!]!
"""
Definition of done: DONE only with the checklist ticked. Absent: unchanged
"""
checklistBeforeDone: Boolean
"""Absent: unchanged. Fields cannot be removed: archive them.""" """Absent: unchanged. Fields cannot be removed: archive them."""
customFields: [CustomFieldDefinitionInput!] customFields: [CustomFieldDefinitionInput!]
issueTypes: [IssueTypeOptionInput!]! issueTypes: [IssueTypeOptionInput!]!
statuses: [WorkflowStatusInput!]! statuses: [WorkflowStatusInput!]!
"""Absent: unchanged. Teams cannot be removed: archive them."""
teams: [TeamInput!]
transitions: [StatusTransitionsInput!]! transitions: [StatusTransitionsInput!]!
} }
@@ -1054,12 +1368,29 @@ input PushSubscriptionInput {
p256dh: String! p256dh: String!
} }
"""QA verdicts per area (90 days)"""
type QaByArea {
"""Null: no area"""
area: String
passed: Int!
returned: Int!
}
type QaStats { type QaStats {
failed: Int! failed: Int!
failureRate: Float failureRate: Float
passed: Int! passed: Int!
} }
"""Bugs (types with the bug icon) and QA"""
type Quality {
"""Last 8 weeks, oldest first"""
bugWeeks: [BugWeek!]!
escaped: [EscapedBugs!]!
openBugsByPriority: [PriorityCount!]!
qaByArea: [QaByArea!]!
}
type Query { type Query {
"""Project activity feed (newest first)""" """Project activity feed (newest first)"""
activity( activity(
@@ -1088,15 +1419,30 @@ type Query {
"""My calendar URL, if I made one""" """My calendar URL, if I made one"""
myCalendarFeed: CalendarFeed myCalendarFeed: CalendarFeed
"""
The home page beyond my work: agenda of the week, activity of my projects, the week in numbers, sprints, project cards, suggestions, first steps and who is on what now
"""
myHome: Home!
""" """
Tickets across all my projects: assigned to me (default) or any (e.g. categories [QA]) Tickets across all my projects: assigned to me (default) or any (e.g. categories [QA])
""" """
myIssues( myIssues(
assignedToMe: Boolean! = true assignedToMe: Boolean! = true
"""Only types drawn with the bug icon"""
bugsOnly: Boolean
categories: [StatusCategory!] categories: [StatusCategory!]
limit: Int! = 50 limit: Int! = 50
offset: Int! = 0 offset: Int! = 0
"""Only open tickets past their due date"""
overdue: Boolean
priorities: [IssuePriority!]
"""Only these of my projects"""
projectIds: [ID!]
"""Matches key, title or description (global search)""" """Matches key, title or description (global search)"""
search: String search: String
statuses: [String!] statuses: [String!]
@@ -1110,8 +1456,16 @@ type Query {
"""Projects where I am a member""" """Projects where I am a member"""
myProjects: [Project!]! myProjects: [Project!]!
"""My saved views of my work, across my projects"""
myViews: [MyView!]!
notificationSettings: NotificationSettings! notificationSettings: NotificationSettings!
"""
My active projects (not archived, not as a guest) at a glance: open work, risks, sprint, release forecasts and epics
"""
portfolio: [PortfolioProject!]!
"""A project of mine, or any public one (no sign-in needed)""" """A project of mine, or any public one (no sign-in needed)"""
project( project(
"""Project id or key (e.g. "TS")""" """Project id or key (e.g. "TS")"""
@@ -1198,6 +1552,24 @@ type ReleaseChange {
version: Int! version: Int!
} }
"""When a planned release would be finished, at the real pace"""
type ReleaseForecast {
finished: Int!
"""Half of the simulated futures finish by then"""
likelyDate: String
name: String!
releaseId: ID!
"""85% finish by then"""
safeDate: String
targetDate: String
total: Int!
"""DONE, NO_DATE, UNKNOWN (no pace yet), ON_TRACK, AT_RISK or LATE"""
verdict: String!
}
type ReleaseProgress { type ReleaseProgress {
byStatus: [CountByStatus!]! byStatus: [CountByStatus!]!
finished: Int! finished: Int!
@@ -1216,6 +1588,21 @@ enum ReleaseStatus {
RELEASED RELEASED
} }
"""Tickets to look at now (worst first)"""
type Risks {
blocked: [InsightIssue!]!
blockedCount: Int!
"""Due within 7 days"""
dueSoon: [InsightIssue!]!
dueSoonCount: Int!
overdue: [InsightIssue!]!
overdueCount: Int!
stuck: [InsightIssue!]!
stuckCount: Int!
stuckDays: Int!
}
input SaveFilterInput { input SaveFilterInput {
criteria: SavedFilterCriteriaInput! criteria: SavedFilterCriteriaInput!
name: String! name: String!
@@ -1225,6 +1612,11 @@ input SaveFilterInput {
shared: Boolean! = false shared: Boolean! = false
} }
input SaveMyViewInput {
criteria: MyViewCriteriaInput!
name: String!
}
""" """
A named view of the board: private to whoever saved it, or shared with the project A named view of the board: private to whoever saved it, or shared with the project
""" """
@@ -1316,6 +1708,26 @@ type SprintProgress {
total: Int! total: Int!
} }
"""The sprint under way: time gone against work done"""
type SprintPulse {
"""At this pace, what is open would not be closed"""
behind: Boolean!
"""Days left after today"""
daysLeft: Int!
donePoints: Int!
doneTickets: Int!
endDate: String!
name: String!
neededPerDay: Float
pacePerDay: Float
points: Int!
sprintId: ID!
startDate: String!
tickets: Int!
totalDays: Int!
}
type SprintRef { type SprintRef {
id: ID! id: ID!
name: String! name: String!
@@ -1339,6 +1751,15 @@ enum StatusCategory {
TODO TODO
} }
type StatusTime {
averageDays: Float
open: Int!
overLimit: Boolean!
samples: Int!
status: String!
wipLimit: Int
}
type StatusTransitions { type StatusTransitions {
from: String! from: String!
to: [String!]! to: [String!]!
@@ -1367,6 +1788,48 @@ type Subscription {
sprintChanged(projectId: ID!): SprintChange! sprintChanged(projectId: ID!): SprintChange!
} }
type Suggestion {
count: Int!
"""UNASSIGNED, PROPOSALS or QA_WAITING"""
kind: String!
projectKey: String!
}
"""A group of members: tickets go to it; @team mentions tell them all"""
type Team {
archived: Boolean!
id: String!
memberIds: [ID!]!
name: String!
}
input TeamInput {
archived: Boolean! = false
id: String!
memberIds: [ID!]! = []
name: String!
}
type TeamLoad {
blocked: Int!
openPoints: Int!
openTickets: Int!
overloaded: Boolean!
"""Points closed per sprint, last three closed sprints"""
pointsPerSprint: Float
user: UserRef!
}
type TeamWeek {
lastWeek: Int!
"""Finished per day, last 14, oldest first"""
perDay: [Int!]!
thisWeek: Int!
}
type Throughput { type Throughput {
count: Int! count: Int!
user: UserRef user: UserRef
@@ -1398,6 +1861,9 @@ input UpdateIssueInput {
"""When work should start (roadmap), YYYY-MM-DD""" """When work should start (roadmap), YYYY-MM-DD"""
startDate: String startDate: String
storyPoints: Int storyPoints: Int
"""A team id"""
team: String
title: String title: String
type: String type: String
} }

File diff suppressed because it is too large Load Diff

View File

@@ -37,6 +37,10 @@ const ids = (what: string) =>
const issueFields = { const issueFields = {
description: z.string().max(50000).optional(), description: z.string().max(50000).optional(),
area: z.string().optional().describe('Area id (see get_project)'), 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(), priority: PRIORITY.optional(),
labels: z.array(z.string().max(40)).max(20).optional(), labels: z.array(z.string().max(40)).max(20).optional(),
figmaUrl: z.string().url().optional().describe('Figma frame (https)'), figmaUrl: z.string().url().optional().describe('Figma frame (https)'),
@@ -57,6 +61,7 @@ const issueFields = {
type IssueFields = { type IssueFields = {
description?: string; description?: string;
area?: string; area?: string;
team?: string;
priority?: z.infer<typeof PRIORITY>; priority?: z.infer<typeof PRIORITY>;
labels?: string[]; labels?: string[];
figmaUrl?: string; figmaUrl?: string;
@@ -88,6 +93,15 @@ export class IssuesResolver {
.array(CATEGORY) .array(CATEGORY)
.optional() .optional()
.describe('Default: open ones (TODO, IN_PROGRESS, QA)'), .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), limit: limit(200, 50),
}), }),
annotations: READ, annotations: READ,
@@ -97,6 +111,9 @@ export class IssuesResolver {
assignedToMe: boolean; assignedToMe: boolean;
watching: boolean; watching: boolean;
categories?: z.infer<typeof CATEGORY>[]; categories?: z.infer<typeof CATEGORY>[];
priorities?: z.infer<typeof PRIORITY>[];
bugsOnly?: boolean;
overdue?: boolean;
limit: number; limit: number;
}, },
ctx: McpContext, ctx: McpContext,
@@ -109,6 +126,9 @@ export class IssuesResolver {
assignedToMe: params.assignedToMe, assignedToMe: params.assignedToMe,
watching: params.watching, watching: params.watching,
category: params.categories ?? ['TODO', 'IN_PROGRESS', 'QA'], category: params.categories ?? ['TODO', 'IN_PROGRESS', 'QA'],
priority: params.priorities,
bugsOnly: params.bugsOnly,
overdue: params.overdue,
limit: params.limit, limit: params.limit,
}, },
}, },
@@ -129,6 +149,7 @@ export class IssuesResolver {
categories: z.array(CATEGORY).optional(), categories: z.array(CATEGORY).optional(),
types: ids('Ticket type'), types: ids('Ticket type'),
areas: ids('Area'), areas: ids('Area'),
teams: ids('Team'),
priorities: z.array(PRIORITY).optional(), priorities: z.array(PRIORITY).optional(),
assigneeId: z.string().optional().describe('User id'), assigneeId: z.string().optional().describe('User id'),
unassigned: z.boolean().optional(), unassigned: z.boolean().optional(),
@@ -172,6 +193,7 @@ export class IssuesResolver {
categories?: z.infer<typeof CATEGORY>[]; categories?: z.infer<typeof CATEGORY>[];
types?: string[]; types?: string[];
areas?: string[]; areas?: string[];
teams?: string[];
priorities?: z.infer<typeof PRIORITY>[]; priorities?: z.infer<typeof PRIORITY>[];
assigneeId?: string; assigneeId?: string;
unassigned?: boolean; unassigned?: boolean;
@@ -201,6 +223,7 @@ export class IssuesResolver {
category: params.categories, category: params.categories,
type: params.types, type: params.types,
area: params.areas, area: params.areas,
team: params.teams,
priority: params.priorities, priority: params.priorities,
assigneeId: params.assigneeId, assigneeId: params.assigneeId,
unassigned: params.unassigned, unassigned: params.unassigned,

View File

@@ -40,7 +40,7 @@ export class ProjectsResolver {
@Tool({ @Tool({
name: 'create_project', name: 'create_project',
description: 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({ paramsSchema: z.object({
key: z key: z
.string() .string()
@@ -52,6 +52,12 @@ export class ProjectsResolver {
.boolean() .boolean()
.default(false) .default(false)
.describe('Anyone can read it, also without signing in'), .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 }, annotations: { readOnlyHint: false, destructiveHint: false },
}) })
@@ -61,6 +67,7 @@ export class ProjectsResolver {
name: string; name: string;
description?: string; description?: string;
isPublic: boolean; isPublic: boolean;
template?: string;
}, },
ctx: McpContext, ctx: McpContext,
) { ) {
@@ -197,6 +204,80 @@ export class ProjectsResolver {
); );
} }
@Tool({
name: 'get_my_week',
description:
"The user's coming week and how the last one went: tickets of theirs (assigned or watched) due in the next 7 days, sprint ends and planned releases; tickets finished by them and by their teams this week and last, and their streak; sprints under way; and, when nothing is theirs, what they could pick up (unassigned, waiting for QA, proposals to review).",
paramsSchema: z.object({}),
annotations: READ,
})
myWeek(_params: Record<string, never>, ctx: McpContext) {
return toolResult(async () => {
const home = await this.api.data(this.api.as(ctx).GET('/api/me/home'));
// Compact: the page's activity feed, presence and first steps stay out.
return {
agenda: home.agenda,
me: home.me,
team: { thisWeek: home.team.thisWeek, lastWeek: home.team.lastWeek },
sprints: home.sprints,
suggestions: home.suggestions,
};
});
}
@Tool({
name: 'get_portfolio',
description:
"All the user's active projects at a glance (not archived, not as a guest): open, overdue, blocked and stuck tickets, the sprint under way and whether it gets there, planned releases with their forecast, and open epics with their progress.",
paramsSchema: z.object({}),
annotations: READ,
})
portfolio(_params: Record<string, never>, ctx: McpContext) {
return toolResult(async () => {
const projects = await this.api.data(
this.api.as(ctx).GET('/api/me/portfolio'),
);
// Compact: epics as key, title and progress.
return projects.map(({ epics, ...p }) => ({
...p,
epics: epics.map((e) => ({
key: e.issue.key,
title: e.issue.title,
dueDate: e.issue.dueDate,
percent: e.progress.percent,
})),
}));
});
}
@Tool({
name: 'get_project_insights',
description:
"The project's dashboard: the sprint under way (pace against time, behind or not), overdue, due-soon, blocked and stuck tickets, how long the work under way has been where it is (against the p85 cycle time), average days in each in-progress and QA status with WIP limits, when each planned release would be finished at the real pace (Monte Carlo), each person's open points against what they close per sprint, open tickets missing an estimate, an area or an assignee, quality (open bugs per priority, bugs created and closed per week, QA returns per area, bugs reported after each release) and open tickets per option of each list custom field. With since, also what changed after it.",
paramsSchema: z.object({
project: projectRef,
since: z
.string()
.optional()
.describe(
'ISO instant: also tell what was created, finished or sent back from QA after it',
),
}),
annotations: READ,
})
insights(
{ project, since }: { project: string; since?: string },
ctx: McpContext,
) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).GET('/api/projects/{project}/insights', {
params: { path: { project }, query: { since } },
}),
),
);
}
@Tool({ @Tool({
name: 'get_project_activity', name: 'get_project_activity',
description: 'Latest activity of a project (newest first).', description: 'Latest activity of a project (newest first).',

View File

@@ -12,12 +12,14 @@ describe('appRoutes', () => {
const children = app?.children ?? []; const children = app?.children ?? [];
expect(children.map((route) => route.path)).toEqual([ expect(children.map((route) => route.path)).toEqual([
'', '',
'cartera',
'explorar', 'explorar',
'p/:key', 'p/:key',
]); ]);
expect(children[0].canActivate).toContain(authGuard); expect(children[0].canActivate).toContain(authGuard);
expect(children[1].canActivate).toBeUndefined(); expect(children[1].canActivate).toContain(authGuard);
expect(children[2].canActivate).toBeUndefined(); expect(children[2].canActivate).toBeUndefined();
expect(children[3].canActivate).toBeUndefined();
}); });
it('exposes every project section', () => { it('exposes every project section', () => {
@@ -37,6 +39,7 @@ describe('appRoutes', () => {
'epics', 'epics',
'trash', 'trash',
'releases', 'releases',
'report/:kind/:id',
'dashboard', 'dashboard',
'analytics', 'analytics',
'settings', 'settings',

View File

@@ -41,6 +41,16 @@ import {
Proyectos Proyectos
</a> </a>
} }
@if (auth.isAuthenticated()) {
<a
shellNav
class="ts-nav-link"
routerLink="/cartera"
routerLinkActive="is-active"
>
Cartera
</a>
}
<a <a
shellNav shellNav
class="ts-nav-link" class="ts-nav-link"

View File

@@ -618,3 +618,34 @@ html.dark-mode .pi-shell {
background-color: var(--ts-surface-raised) !important; 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;
}
}
}
}

View File

@@ -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`). - `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. Nada de TypeORM, Mongoose, Redis, GraphQL, Angular, NgRx ni Apollo.
- `application` no puede importar adaptadores (TypeORM, Mongoose, Redis, 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 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 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. excepción es el `EventsBridge`, que ejecuta los comandos que devuelven las sagas.
@@ -109,7 +110,10 @@ sequenceDiagram
- **Guardar**: el `EventStorePublisher` sustituye al publisher del `EventBus`, así que `commit()` escribe en la tabla - **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). `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 - **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 - **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`). (`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 - **Ids**: todas las tablas tienen `id uuid` (`gen_random_uuid()`); lo que hace única una fila de lookup es una

View File

@@ -38,12 +38,20 @@ classDiagram
### Roles por proyecto ### Roles por proyecto
| Rol | Ver | Crear/modificar tickets, comentar | Gestionar miembros y releases | | Rol | Ver | Crear/modificar tickets, comentar | Gestionar miembros y releases |
| -------------------------------- | --- | --------------------------------- | -------------------------------------------- | | -------------------------------- | ---------------- | ----------------------------------------------- | -------------------------------------------- |
| `OWNER` (quien crea el proyecto) | ✅ | ✅ | ✅ (no se le puede quitar ni cambiar el rol) | | `OWNER` (quien crea el proyecto) | ✅ | ✅ | ✅ (no se le puede quitar ni cambiar el rol) |
| `ADMIN` | ✅ | ✅ | ✅ | | `ADMIN` | ✅ | ✅ | ✅ |
| `MEMBER` | ✅ | ✅ | ❌ | | `MEMBER` | ✅ | ✅ | ❌ |
| `VIEWER` | ✅ | ❌ | ❌ | | `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 Cualquiera puede salir de un proyecto (salvo el propietario). Los no miembros reciben _not found_, para no revelar que
el proyecto existe. el proyecto existe.
@@ -80,6 +88,15 @@ sus tickets (y sus comentarios), sus releases y los enlaces que tickets de otros
[Arquitectura](02-arquitectura.md)). La clave queda libre para un proyecto nuevo. Los eventos se quedan en el event [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. 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 ### Configuración por proyecto
Cada proyecto tiene sus **estados**, **transiciones**, **tipos de ticket**, **áreas** y **campos personalizados** Cada proyecto tiene sus **estados**, **transiciones**, **tipos de ticket**, **áreas** y **campos personalizados**
@@ -110,9 +127,36 @@ Reglas:
- Colores: `slate`, `stone`, `violet`, `blue`, `sky`, `teal`, `green`, `amber`, `orange`, `red`, `pink`. Iconos: - Colores: `slate`, `stone`, `violet`, `blue`, `sky`, `teal`, `green`, `amber`, `orange`, `red`, `pink`. Iconos:
`bug`, `feature`, `palette`, `task`, `qa`, `flag`, `star-fill`, `rocket`, `document`, `light-bulb`, `key`, `support`. `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 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. 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 ### Campos personalizados
Datos propios del proyecto en cada ticket («Navegador», «Cliente», «Coste»…), en `settings.customFields`: `id`, `name`, Datos propios del proyecto en cada ticket («Navegador», «Cliente», «Coste»…), en `settings.customFields`: `id`, `name`,
@@ -374,6 +418,41 @@ puras (`application/src/lib/analytics`):
Los tickets anteriores a esta proyección solo tienen su creación y su estado actual; para tener su historia completa, 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)). reconstruye el read model (ver [Despliegue](07-despliegue.md)).
La **cartera** (`portfolio` en GraphQL, `GET /api/me/portfolio`) junta, para cada proyecto activo de quien pregunta
(no archivado y en el que no es invitado), los tickets abiertos, vencidos, bloqueados y atascados, el sprint en curso,
la previsión de sus releases y sus épicas abiertas (las 6 con fecha límite más cercana). Se calcula con lo mismo que el
panel.
La **portada** (`myHome` en GraphQL, `GET /api/me/home`) junta lo demás que se ve en la página de inicio
(`analytics/home.ts`):
| Bloque | Qué da |
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `agenda` | Los próximos 7 días: mis tickets abiertos (asignados o que sigo) que vencen, fin de sprints en curso y releases planificadas |
| `activity` | Lo último en mis proyectos (30 entradas; sin los proyectos en los que soy invitado) |
| `team` / `me` | Tickets terminados por día en los últimos 14 días; esta semana y la anterior, de mis equipos y míos (por quien lo tenía asignado), y mi racha |
| `sprints` | Los sprints en curso, con el mismo pulso que el panel (¿llega?) |
| `projects` | Por proyecto: abiertos, vencidos, actividad por día (14 días) y el sprint en curso con su porcentaje |
| `suggestions` | Para cuando no tengo nada: sin asignar, esperando QA y propuestas por revisar (estas, solo a quien administra); las 5 mayores |
| `firstSteps` | Si ya creé un proyecto, invité a alguien, abrí un ticket y conecté el calendario |
| `online` | Quién está ahora en un ticket de mis proyectos (por la presencia, una entrada por persona) |
El **panel del proyecto** (`Project.insights` en GraphQL, `GET /api/projects/{project}/insights`) lee lo mismo para
decir qué mirar ahora (`analytics/insights.ts`; acepta `since`, un instante ISO):
| Bloque | Qué da |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Sprint en curso | Días que quedan, tickets y puntos hechos, ritmo (puntos al día) y el que haría falta; `behind` si no llega |
| Riesgo | Abiertos vencidos, que vencen en 7 días, bloqueados (por un ticket abierto) y atascados (7 días o más sin moverse) |
| Antigüedad | Lo que está en curso o en QA, por días en su estado, con el percentil 85 del tiempo de ciclo (90 días) como umbral |
| Cuellos de botella | Días de media en cada estado en curso o de QA (estancias que terminaron en 90 días), cuántos hay y su límite WIP |
| Próximas releases | Para cada release planificada (por fecha objetivo): fecha probable (p50) y segura (p85) simulando 500 futuros con lo que se cerró cada día de las últimas 6 semanas (Monte Carlo); cada release cuenta lo que queda de las anteriores. `ON_TRACK`, `AT_RISK`, `LATE`, `NO_DATE`, `UNKNOWN` (sin ritmo aún) o `DONE` |
| Cambios | Con `since` (por ejemplo, la última visita): tickets creados, terminados y vueltos de QA desde entonces, y mis menciones en el proyecto |
| Equipo | Por persona: tickets y puntos abiertos, bloqueados, puntos cerrados por sprint (últimos 3 cerrados) y `overloaded` si tiene abiertos más de los que suele cerrar |
| Salud del backlog | Abiertos sin estimar, sin área, sin asignar y sin tocar en 90 días |
| Calidad | Bugs (tipos con icono `bug`): abiertos por prioridad, nuevos y cerrados por semana (8 semanas), lo que QA devolvió y dejó pasar por área (90 días) y bugs abiertos en los 14 días tras cada una de las 3 últimas releases |
| Campos | Para cada campo personalizado de lista: tickets abiertos por opción y sin valor |
## 3.2 GraphQL ## 3.2 GraphQL
Las operaciones requieren `Authorization: Bearer <token de Pireon>` (en subscriptions, en el `connection_init`), salvo Las operaciones requieren `Authorization: Bearer <token de Pireon>` (en subscriptions, en el `connection_init`), salvo
@@ -414,7 +493,7 @@ Todas devuelven `CommandResult { id, key, version }`.
| Webhooks | `createWebhook` (devuelve el secreto), `updateWebhook`, `deleteWebhook`, `testWebhook` | | Webhooks | `createWebhook` (devuelve el secreto), `updateWebhook`, `deleteWebhook`, `testWebhook` |
| Git | `addGitRepository` (devuelve su secreto), `rotateGitRepositorySecret`, `removeGitRepository` | | Git | `addGitRepository` (devuelve su secreto), `rotateGitRepositorySecret`, `removeGitRepository` |
| Sprints | `createSprint`, `updateSprint`, `startSprint`, `closeSprint(carryOverToSprintId)`, `deleteSprint`, `setIssueSprint` | | Sprints | `createSprint`, `updateSprint`, `startSprint`, `closeSprint(carryOverToSprintId)`, `deleteSprint`, `setIssueSprint` |
| Vistas | `saveFilter(input)` (devuelve el id), `deleteSavedFilter(id)` | | Vistas | `saveFilter(input)` (devuelve el id), `deleteSavedFilter(id)`, `GET/POST /api/me/views`, `DELETE /api/me/views/{id}` (mis vistas de «Mi trabajo») |
| Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` | | Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` |
| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `writeReleaseNotes`, `archiveRelease` | | Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `writeReleaseNotes`, `archiveRelease` |
@@ -613,23 +692,23 @@ Lo que cada usuario puede ver o cambiar depende de su rol en cada proyecto, como
Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`). Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
| Recurso | Rutas | | Recurso | Rutas |
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` | | Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` |
| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/activity`, `…/members` (POST, PATCH, DELETE) | | 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/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` | | Tickets | `GET /api/me/home` (portada), `GET /api/me/portfolio` (cartera), `GET /api/me/issues` (mi trabajo; filtros `project`, `priority`, `bugsOnly`, `overdue`), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `PUT/DELETE …/presence`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history`, `POST /api/issues/{issue}/duplicate`, `PUT/DELETE …/vote` |
| Comentarios | `PATCH/DELETE /api/comments/{commentId}`, `POST …/reactions` | | 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` | | 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}` | | 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` | | 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}` | | 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` | | 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) | | 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`) | | 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` | | 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) | | 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) | | 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` | | 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 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 ticket (`attachments`, en GraphQL y en REST). La descarga sirve en línea solo imágenes, vídeo, audio, PDF y texto
@@ -644,7 +723,7 @@ tiempo real, las subscriptions de GraphQL.
### Errores ### Errores
`{ statusCode, code?, message }`: `400` entrada no válida (`message` es una lista), `401` sin token o token rechazado `{ 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). transición que el workflow no permite).
### Generar un cliente en otra app ### Generar un cliente en otra app

View File

@@ -114,6 +114,8 @@ Qué gana la página:
| `AuthFacade` | `AuthStore` | raíz | Sesión, usuario, login (PKCE o email y contraseña de Pireon), renovación del token, restaurar al arrancar | | `AuthFacade` | `AuthStore` | raíz | Sesión, usuario, login (PKCE o email y contraseña de Pireon), renovación del token, restaurar al arrancar |
| `ProjectsFacade` | `ProjectsStore` | raíz | Mis proyectos; se actualiza cuando me añaden o quitan (`myProjectsChanged`) | | `ProjectsFacade` | `ProjectsStore` | raíz | Mis proyectos; se actualiza cuando me añaden o quitan (`myProjectsChanged`) |
| `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 | | `HomeFacade` | `HomeStore` | raíz | «Mi trabajo» en la portada: mis tickets abiertos por categoría (QA fallidos primero), esperando QA y próximas releases; se recarga al volver a la pestaña |
| `MyHomeFacade` | `MyHomeStore` | raíz | El resto de la portada (`myHome`): agenda, actividad, cifras de la semana, sprints, tarjetas, sugerencias, primeros pasos y quién está; se recarga cada minuto y al volver a la pestaña |
| `MyViewsFacade` | `MyViewsStore` | raíz | Mis vistas guardadas de «Mi trabajo» (entre proyectos: proyectos, asignados a mí, que sigo, estados, prioridades, solo bugs, vencidos, texto) y los tickets de la elegida |
| `ExploreFacade` | `ExploreStore` | raíz | Proyectos públicos («Explorar»), también sin sesión | | `ExploreFacade` | `ExploreStore` | raíz | Proyectos públicos («Explorar»), también sin sesión |
| `ProjectFacade` | `ProjectStore` | ruta `/p/:key` | Proyecto, miembros, stats, releases, actividad en vivo, búsqueda de usuarios, configuración (`workflow`, `saveSettings`) y estadísticas (`loadAnalytics`, `loadBurnup`) | | `ProjectFacade` | `ProjectStore` | ruta `/p/:key` | Proyecto, miembros, stats, releases, actividad en vivo, búsqueda de usuarios, configuración (`workflow`, `saveSettings`) y estadísticas (`loadAnalytics`, `loadBurnup`) |
| `BoardFacade` | `BoardStore` | ruta `board` | Tablero kanban, filtros, movimiento optimista, nuevo ticket, cambios en vivo | | `BoardFacade` | `BoardStore` | ruta `board` | Tablero kanban, filtros, movimiento optimista, nuevo ticket, cambios en vivo |
@@ -280,21 +282,23 @@ visualización de la plataforma:
## 4.6 Rutas ## 4.6 Rutas
| Ruta | Página | | Ruta | Página |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `/login` | Inicio de sesión (solo sin sesión) | | `/login` | Inicio de sesión (solo sin sesión) |
| `/auth/callback` | Retorno de Pireon (PKCE) | | `/auth/callback` | Retorno de Pireon (PKCE) |
| `/` | Inicio: «Ir a…» (`WEB-12`), mi trabajo (`ts-my-work`) y mis proyectos | | `/` | Inicio: saludo según la hora con el resumen del día, «Ir a…» (`WEB-12`), primeros pasos, la semana (`ts-week-agenda`), mis vistas guardadas, mi trabajo (`ts-my-work`), lo último en mis proyectos, la semana en cifras, sprints en curso, quién está ahora, «Retomar» y mis proyectos con su actividad |
| `/explorar` | Proyectos públicos (sin sesión) | | `/cartera` | Cartera (con sesión): una tarjeta por proyecto activo con lo abierto y en riesgo, el sprint en curso (¿llega?), las próximas releases con su previsión y las épicas abiertas |
| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros) | | `/explorar` | Proyectos públicos (sin sesión) |
| `/p/:key/issues/:issueKey` | Ticket | | `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros) |
| `/p/:key/releases` | Releases | | `/p/:key/issues/:issueKey` | Ticket. Encima del título, quién más lo está viendo o editando ahora (`PresenceFacade`: late cada 10 s; editar es tener abierto el formulario del ticket o de un comentario, o estar escribiendo uno) |
| `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea | | `/p/:key/releases` | Releases |
| `/p/:key/dashboard` | Seguimiento: cifras, flujo por estado, equipo (cada persona enlaza a su tablero) y tipos | | `/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/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/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea |
| `/p/:key/settings` | Configuración (propietario y admins; pestaña oculta para el resto): estados (orden, categoría, color, motivo), transiciones, tipos, áreas y campos personalizados; archivar en lugar de borrar | | `/p/:key/dashboard` | Seguimiento: cifras, flujo por estado, lo que ha cambiado desde tu última visita (guardada en este navegador), sprint en curso, tickets en riesgo, previsión de las próximas releases, antigüedad de lo que está en curso, cuellos de botella, salud del backlog (cada número abre la lista filtrada: `?missing=estimate`, `area`, `assignee` o `?staleDays=90`), calidad (bugs por prioridad y por semana, QA por área, bugs tras cada release), abiertos por un campo de lista a elegir, equipo con sus puntos abiertos y tipos. Se recarga solo con cada cambio |
| `/p/:key/activity` | Actividad en vivo | | `/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/members` | Miembros | | `/p/:key/settings` | Configuración (propietario y admins; pestaña oculta para el resto): estados (orden, categoría, color, motivo), transiciones, tipos, áreas, equipos (con sus miembros), definición de «Hecho» y campos personalizados; archivar en lugar de borrar |
| `/p/:key/activity` | Actividad en vivo |
| `/p/:key/members` | Miembros, 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 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 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

View File

@@ -32,55 +32,58 @@ flowchart LR
## 9.2 Herramientas ## 9.2 Herramientas
57 herramientas, agrupadas por ámbito: 60 herramientas, agrupadas por ámbito:
| Ámbito | Herramienta | Qué hace | | Ámbito | Herramienta | Qué hace |
| ------------ | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- | | ------------ | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Usuario | `whoami` | El usuario del token | | Usuario | `whoami` | El usuario del token |
| | `search_users` | Buscar personas de Pireon (para asignar) | | | `search_users` | Buscar personas de Pireon (para asignar) |
| | `my_notifications` | Mis notificaciones (por defecto, las no leídas) | | | `my_notifications` | Mis notificaciones (por defecto, las no leídas) |
| | `mark_notifications_read` | Marcar como leídas unas o todas | | | `mark_notifications_read` | Marcar como leídas unas o todas |
| Proyectos | `list_my_projects` | Mis proyectos | | Proyectos | `list_my_projects` | Mis proyectos |
| | `list_public_projects` | Proyectos públicos | | | `list_public_projects` | Proyectos públicos |
| | `create_project` | Crear un proyecto (quien lo crea es su dueño) | | | `create_project` | Crear un proyecto (quien lo crea es su dueño), también copiando la configuración de otro (`template`) |
| | `get_project` | Equipo y configuración: ids de estados, tipos y áreas que aceptan las demás herramientas | | | `get_project` | 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_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_board` | Tablero por columnas (filtros por persona, release, área, sprint) |
| | `get_project_stats` | Cifras de seguimiento | | | `get_project_stats` | Cifras de seguimiento |
| | `get_project_analytics` | Estadísticas de los últimos días (creados/resueltos, flujo, tiempos, throughput, QA) | | | `get_project_analytics` | Estadísticas de los últimos días (creados/resueltos, flujo, tiempos, throughput, QA) |
| | `get_project_activity` | Actividad reciente | | | `get_portfolio` | Todos mis proyectos activos de un vistazo: abiertos y en riesgo, sprint, previsión de releases y épicas |
| Tickets | `my_issues` | Mi trabajo en todos los proyectos (o de cualquiera, por categoría; o los que sigo) | | | `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 |
| | `search_issues` | Buscar por texto y filtros (épica, sprint, fechas, campos…); el backlog: `noSprint` y orden `RANK` | | | `get_project_insights` | El panel: sprint en curso, riesgo, antigüedad, cuellos de botella, previsión de releases, carga del equipo, salud del backlog, calidad, abiertos por campo de lista y, con `since`, los cambios |
| | `get_issue` | Detalle: épica, lista de comprobación, relaciones, commits, seguidores, transiciones, comentarios | | | `get_project_activity` | Actividad reciente |
| | `create_issue` | Crear un ticket (también colgado de una épica) | | 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 |
| | `update_issue` | Cambiar título, descripción, tipo, área, prioridad, etiquetas, fechas, campos personalizados… | | | `search_issues` | Buscar por texto y filtros (épica, sprint, fechas, campos…); el backlog: `noSprint` y orden `RANK` |
| | `change_issue_status` | Mover de estado (con motivo cuando el estado lo pide) | | | `get_issue` | Detalle: épica, lista de comprobación, relaciones, commits, seguidores, transiciones, comentarios |
| | `assign_issue` | Asignar o desasignar | | | `create_issue` | Crear un ticket (también colgado de una épica) |
| | `set_issue_release` | Meter en una release o sacar | | | `update_issue` | Cambiar título, descripción, tipo, área, prioridad, etiquetas, fechas, campos personalizados… |
| | `add_comment` / `edit_comment` / `delete_comment` | Comentar (o responder a un comentario), corregir o borrar | | | `change_issue_status` | Mover de estado (con motivo cuando el estado lo pide) |
| | `link_issues` / `unlink_issues` | Relacionar tickets (`DEPENDS_ON`, `BLOCKS`, `TESTS`…) o quitar la relación | | | `assign_issue` | Asignar o desasignar |
| | `watch_issue` | Seguir un ticket o dejar de seguirlo | | | `set_issue_release` | Meter en una release o sacar |
| | `vote_issue` | Votar un ticket o quitar el voto | | | `add_comment` / `edit_comment` / `delete_comment` | Comentar (o responder a un comentario), corregir o borrar |
| | `duplicate_issue` | Crear otro ticket con lo mismo (sin estado, persona ni comentarios) | | | `link_issues` / `unlink_issues` | Relacionar tickets (`DEPENDS_ON`, `BLOCKS`, `TESTS`…) o quitar la relación |
| | `react_to_comment` | Reaccionar a un comentario (👍 🎉…) o quitar la reacción | | | `watch_issue` | Seguir un ticket o dejar de seguirlo |
| | `add_checklist_item` / `update_checklist_item` / `remove_checklist_item` | La lista de comprobación: añadir, marcar o reescribir, quitar | | | `vote_issue` | Votar un ticket o quitar el voto |
| Organización | `list_epics` | Épicas con el progreso de lo que cuelga de cada una | | | `duplicate_issue` | Crear otro ticket con lo mismo (sin estado, persona ni comentarios) |
| | `set_issue_parent` | Colgar un ticket de una épica u otro ticket, o soltarlo | | | `react_to_comment` | Reaccionar a un comentario (👍 🎉…) o quitar la reacción |
| | `rank_issue` | Mover un ticket en el backlog | | | `add_checklist_item` / `update_checklist_item` / `remove_checklist_item` | La lista de comprobación: añadir, marcar o reescribir, quitar |
| | `bulk_update_issues` | El mismo cambio en hasta 100 tickets (por clave); dice cuáles no se dejaron | | Organización | `list_epics` | Épicas con el progreso de lo que cuelga de cada una |
| | `delete_issue` / `restore_issue` / `list_trash` | Papelera: borrar (30 días para recuperar), recuperar y ver | | | `set_issue_parent` | Colgar un ticket de una épica u otro ticket, o soltarlo |
| Sprints | `list_sprints` | Sprints de un proyecto | | | `rank_issue` | Mover un ticket en el backlog |
| | `get_sprint` | Progreso, tickets y burndown | | | `bulk_update_issues` | El mismo cambio en hasta 100 tickets (por clave); dice cuáles no se dejaron |
| | `create_sprint` / `update_sprint` / `delete_sprint` | Planificar, cambiar o borrar (si no empezó) un sprint | | | `delete_issue` / `restore_issue` / `list_trash` | Papelera: borrar (30 días para recuperar), recuperar y ver |
| | `start_sprint` / `close_sprint` | Empezarlo; cerrarlo pasando lo pendiente a otro sprint o al backlog | | Sprints | `list_sprints` | Sprints de un proyecto |
| | `set_issue_sprint` | Meter un ticket en un sprint o devolverlo al backlog | | | `get_sprint` | Progreso, tickets y burndown |
| Releases | `list_releases` | Releases de un proyecto | | | `create_sprint` / `update_sprint` / `delete_sprint` | Planificar, cambiar o borrar (si no empezó) un sprint |
| | `upcoming_releases` | Próximas releases de mis proyectos | | | `start_sprint` / `close_sprint` | Empezarlo; cerrarlo pasando lo pendiente a otro sprint o al backlog |
| | `get_release` | Progreso, notas, tickets y burnup | | | `set_issue_sprint` | Meter un ticket en un sprint o devolverlo al backlog |
| | `create_release` / `update_release` | Planificar una release o cambiar su nombre, descripción o fecha | | Releases | `list_releases` | Releases de un proyecto |
| | `draft_release_notes` / `set_release_notes` | Borrador de las notas desde los tickets terminados, y guardarlas | | | `upcoming_releases` | Próximas releases de mis proyectos |
| | `publish_release` | Publicar (con `force` si quedan tickets sin terminar) | | | `get_release` | Progreso, notas, tickets y burnup |
| | `archive_release` | Archivar una release que no saldrá; lo pendiente pasa a `moveTo` (otra release abierta) o la deja | | | `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 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, archivan, como `destructiveHint`, para que la pida. Lo que es de administración (miembros, configuración, visibilidad,

View File

@@ -28,50 +28,18 @@ cuando hay pistas, por dónde empezar.
## 10.3 Siguientes funcionalidades ## 10.3 Siguientes funcionalidades
### En curso, en este orden ### En curso
1. **Burndown y velocidad de sprint**: puntos pendientes día a día durante el sprint y puntos cerrados por sprint, para Nada: la tanda está hecha (ver 10.5).
planificar el siguiente con datos (el historial de estados ya está en `issue_timeline`).
2. **Plantillas de proyecto**: crear un proyecto copiando de otro los estados, transiciones, tipos, áreas, campos
personalizados, automatizaciones y etiquetas.
3. **Exportar e imprimir un sprint o una release**: un PDF con los tickets, su estado y las notas, para un cliente o
para archivar.
Y en el panel del proyecto:
4. **Sprint en curso**: días que quedan, puntos hechos y pendientes, y aviso si al ritmo actual no llega.
5. **Tickets en riesgo**: vencidos, que vencen esta semana, bloqueados y atascados (días sin moverse en un estado en
curso), cada grupo con enlace a la lista filtrada.
6. **Antigüedad de lo abierto**: cada ticket en curso por los días que lleva en su estado, con el umbral habitual del
proyecto (p85 del tiempo de ciclo).
7. **Cuellos de botella**: días medios en cada estado y columnas por encima de su límite WIP.
8. **Próximas releases con previsión**: progreso, fecha objetivo y fecha probable según el ritmo real (Monte Carlo),
con semáforo.
9. **Qué ha cambiado desde tu última visita**: nuevos, cerrados, devueltos por QA y menciones.
10. **Carga del equipo en puntos**: asignado frente a lo que cada persona suele cerrar por sprint, con aviso de
sobrecarga y sus bloqueados.
11. **Salud del backlog**: sin estimar, sin área, sin asignar y sin tocar en 90 días, con enlace a la lista filtrada.
12. **Calidad**: bugs abiertos por prioridad y su tendencia, devoluciones de QA por área y bugs abiertos en los 14 días
siguientes a cada release.
13. **Desglose por campo personalizado**: tickets abiertos según un campo de opciones a elegir («bugs por
navegador»).
### Aceptadas, para después de esta tanda ### 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. - **Sincronizar con issues de Gitea o GitHub**: que un issue del repositorio cree o actualice un ticket, y al revés.
- **Vistas guardadas en «Mi trabajo»**: filtros guardados de varios proyectos («Bugs críticos de todos mis
proyectos»).
- **Búsqueda con lenguaje de consulta**: `estado:abierto asignado:yo navegador:Safari prioridad>=alta`, guardable como - **Búsqueda con lenguaje de consulta**: `estado:abierto asignado:yo navegador:Safari prioridad>=alta`, guardable como
vista. vista.
- **Definición de «Hecho»**: no pasar a un estado `DONE` con la lista de comprobación a medias (salvo un admin).
- **Entornos de despliegue**: en qué entorno está cada ticket (pruebas, producción), desde los avisos de la - **Entornos de despliegue**: en qué entorno está cada ticket (pruebas, producción), desde los avisos de la
integración continua. integración continua.
- **Equipos dentro del proyecto**: asignar a un equipo y mencionarlo (`@frontend`).
- **Presencia**: ver quién está mirando o editando el mismo ticket.
- **Rol «invitado»**: solo ve los tickets en los que participa (clientes, externos).
- **Página pública de novedades**: las notas de versión publicadas en una página por proyecto, con RSS. - **Página pública de novedades**: las notas de versión publicadas en una página por proyecto, con RSS.
- **Vista de cartera**: progreso de épicas y releases de todos los proyectos en una página.
- **Archivar un proyecto**: queda en solo lectura en lugar de borrarlo.
### Otras ideas apuntadas, sin decidir ### Otras ideas apuntadas, sin decidir
@@ -130,3 +98,30 @@ Mucho trabajo para lo que aportan hoy; se pueden recuperar si alguien los necesi
MCP). MCP).
- Dependencias en la hoja de ruta: flechas entre las épicas que se bloquean o dependen unas de otras, y aviso cuando - 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. una empieza antes de que acabe la que la bloquea.
- Plantillas de proyecto: al crear uno, «Empezar con la configuración de» otro que administras copia sus estados,
transiciones, tipos, áreas, campos personalizados y automatizaciones. (El burndown y la velocidad de sprint ya
existían, en Sprints.)
- Informe para imprimir (o guardar en PDF) de un sprint o una release, desde Sprints y Releases.
- Panel del proyecto, primera parte: sprint en curso (¿llega a tiempo?), tickets en riesgo (vencidos, que vencen esta
semana, bloqueados y atascados), antigüedad de lo que está en curso y cuellos de botella. También por REST, GraphQL
y MCP (`get_project_insights`).
- Panel del proyecto, segunda parte: lo que ha cambiado desde tu última visita, previsión de las próximas releases al
ritmo real (Monte Carlo), carga del equipo en puntos y salud del backlog con enlaces a la lista filtrada.
- Panel del proyecto, tercera parte: calidad (bugs abiertos por prioridad, nuevos y cerrados por semana, QA por área,
bugs tras cada release) y abiertos por opción de un campo de lista.
- Escrituras a la vez en un ticket: la API repite sola la orden que choca con otra (por ejemplo, con una saga), en
lugar de devolver un 409.
- Vistas guardadas en «Mi trabajo»: filtros propios entre proyectos (proyectos, asignados a mí, que sigo, estados,
prioridades, solo bugs, vencidos, texto), en una barra encima de «Mi trabajo».
- Definición de «Hecho» por proyecto: con ella activada, un ticket no se cierra con la checklist a medias (el
propietario y los admins sí pueden).
- Archivar un proyecto: queda en solo lectura (se ve todo, no cambia nada) hasta que su propietario lo desarchiva.
- Equipos dentro del proyecto: grupos de miembros en Configuración; los tickets se asignan a uno, la lista filtra por
equipo y «@equipo» avisa a todos sus miembros.
- Presencia: en cada ticket se ve quién más lo está viendo o editando en ese momento.
- Rol «invitado»: para clientes o gente de fuera; en un proyecto privado solo ve los tickets en los que participa,
abre tickets y comenta en los suyos.
- Vista de cartera (`/cartera`): todos tus proyectos activos de un vistazo, con su riesgo, sprint, releases y épicas.
- Una portada con vida: saludo y resumen del día, la semana que viene, lo último en tus proyectos, quién está ahora,
la semana en cifras (tuya y del equipo, con racha), sprints en curso, «Retomar», tarjetas de proyecto con actividad,
qué coger cuando no tienes nada y primeros pasos para quien empieza. También por MCP (`get_my_week`).

Binary file not shown.

Binary file not shown.

View File

@@ -1,5 +1,6 @@
import { CommandRunner } from '@task-stream/api/shared/application';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs'; import { QueryBus } from '@nestjs/cqrs';
import { import {
AuthorizationConfig, AuthorizationConfig,
TokenGrant, TokenGrant,
@@ -20,7 +21,7 @@ import {
@Injectable() @Injectable()
export class IdentityService { export class IdentityService {
constructor( constructor(
private readonly commandBus: CommandBus, private readonly commands: CommandRunner,
private readonly queryBus: QueryBus, private readonly queryBus: QueryBus,
) {} ) {}
@@ -44,14 +45,12 @@ export class IdentityService {
} }
handleWebhook(event: string, data: Record<string, unknown>): Promise<void> { handleWebhook(event: string, data: Record<string, unknown>): Promise<void> {
return this.commandBus.execute( return this.commands.execute(new HandleIdentityWebhookCommand(event, data));
new HandleIdentityWebhookCommand(event, data),
);
} }
/** Sign in / refresh through the identity server (the browser cannot call it: no CORS). */ /** Sign in / refresh through the identity server (the browser cannot call it: no CORS). */
issueToken(grant: TokenGrant): Promise<TokenSet> { issueToken(grant: TokenGrant): Promise<TokenSet> {
return this.commandBus.execute(new IssueTokenCommand(grant)); return this.commands.execute(new IssueTokenCommand(grant));
} }
authorizationConfig(): Promise<AuthorizationConfig | null> { authorizationConfig(): Promise<AuthorizationConfig | null> {

View File

@@ -2,3 +2,4 @@ export * from './lib/ports/event-store';
export * from './lib/aggregate-rehydrator'; export * from './lib/aggregate-rehydrator';
export * from './lib/shared.module'; export * from './lib/shared.module';
export * from './lib/event-recorder'; export * from './lib/event-recorder';
export * from './lib/command-runner';

View 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);
});
});

View 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));
}
}
}
}

View File

@@ -1,12 +1,13 @@
import { DynamicModule, Global, Module, Type } from '@nestjs/common'; import { DynamicModule, Global, Module, Type } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs'; import { CqrsModule } from '@nestjs/cqrs';
import { AggregateRehydrator } from './aggregate-rehydrator'; import { AggregateRehydrator } from './aggregate-rehydrator';
import { CommandRunner } from './command-runner';
import { EventRecorder } from './event-recorder'; import { EventRecorder } from './event-recorder';
/** /**
* Shared building blocks for every bounded context: the event store (from * Shared building blocks for every bounded context: the event store (from
* the infrastructure module plugged in by the composition root), the * the infrastructure module plugged in by the composition root), the
* AggregateRehydrator and the EventRecorder. * AggregateRehydrator, the EventRecorder and the CommandRunner.
*/ */
@Global() @Global()
@Module({}) @Module({})
@@ -17,8 +18,13 @@ export class SharedModule {
return { return {
module: SharedModule, module: SharedModule,
imports: [CqrsModule, infrastructureModule], imports: [CqrsModule, infrastructureModule],
providers: [AggregateRehydrator, EventRecorder], providers: [AggregateRehydrator, EventRecorder, CommandRunner],
exports: [infrastructureModule, AggregateRehydrator, EventRecorder], exports: [
infrastructureModule,
AggregateRehydrator,
EventRecorder,
CommandRunner,
],
}; };
} }
} }

View File

@@ -29,4 +29,8 @@ export * from './lib/webhooks/webhook-events';
export * from './lib/git/git'; export * from './lib/git/git';
export * from './lib/git/git-payloads'; export * from './lib/git/git-payloads';
export * from './lib/saved-filters/saved-filters'; export * from './lib/saved-filters/saved-filters';
export * from './lib/saved-filters/my-views';
export * from './lib/ports/my-views.repository';
export * from './lib/tracker-application.module'; export * from './lib/tracker-application.module';
export * from './lib/ports/presence.store';
export * from './lib/presence.service';

View File

@@ -19,7 +19,7 @@ import {
const DAY = 86_400_000; const DAY = 86_400_000;
const STARTED = [StatusCategory.IN_PROGRESS, StatusCategory.QA]; const STARTED = [StatusCategory.IN_PROGRESS, StatusCategory.QA];
interface Step { export interface Step {
at: number; at: number;
status: string; status: string;
category: StatusCategory; 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. */ /** Status steps per ticket; tickets with no timeline get one from their creation. */
function stepsByIssue( export function stepsByIssue(
issues: IssueReadModel[], issues: IssueReadModel[],
timeline: IssueTimelineEntry[], timeline: IssueTimelineEntry[],
): Map<string, Step[]> { ): Map<string, Step[]> {

View 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');
});
});

View 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),
);
}

View File

@@ -0,0 +1,395 @@
import {
CustomFieldType,
defaultProjectSettings,
IssuePriority,
ReleaseReadModel,
ReleaseStatus,
IssueLinkType,
IssueReadModel,
IssueTimelineEntry,
SprintReadModel,
SprintStatus,
StatusCategory as C,
} from '@task-stream/api/tracker/domain';
import {
computeInsights,
InsightsInput,
simulateDays,
STALE_DAYS,
STUCK_DAYS,
} from './insights';
const NOW = new Date('2026-10-15T12:00:00Z');
const bob = { id: 'bob', name: 'Bob' };
const issue = (
id: string,
status: string,
category: C,
extra: Partial<IssueReadModel> = {},
): IssueReadModel =>
({
id,
key: id,
title: `Ticket ${id}`,
status,
statusCategory: category,
assignee: bob,
reporter: bob,
sprint: null,
storyPoints: null,
dueDate: null,
links: [],
trashedAt: null,
createdAt: '2026-09-01T09:00:00Z',
updatedAt: '2026-10-14T09:00:00Z',
...extra,
}) as IssueReadModel;
let seq = 0;
const step = (
issueId: string,
at: string,
status: string,
category: C,
): IssueTimelineEntry => ({
id: `${issueId}:${++seq}`,
issueId,
projectId: 'p',
at,
kind: 'status',
status,
category,
assigneeId: 'bob',
});
const sprint: SprintReadModel = {
id: 's',
projectId: 'p',
name: 'Sprint 7',
goal: null,
startDate: '2026-10-12',
endDate: '2026-10-25',
status: SprintStatus.ACTIVE,
startedAt: '2026-10-12T08:00:00Z',
closedAt: null,
createdAt: '2026-10-01T08:00:00Z',
version: 2,
};
const inSprint = { sprint: { id: 's', name: 'Sprint 7' } };
const BASE: Omit<InsightsInput, 'issues' | 'timeline'> = {
settings: defaultProjectSettings(),
activeSprint: null,
sprints: [],
releases: [],
members: [],
since: null,
mentions: 0,
now: NOW,
};
const release = (id: string, targetDate: string | null): ReleaseReadModel =>
({
id,
name: id,
targetDate,
status: ReleaseStatus.PLANNED,
}) as ReleaseReadModel;
describe('computeInsights', () => {
it('names what is late, due soon, blocked and stuck', () => {
const issues = [
issue('LATE', 'TODO', C.TODO, { dueDate: '2026-10-10' }),
issue('SOON', 'TODO', C.TODO, { dueDate: '2026-10-20' }),
issue('LATER', 'TODO', C.TODO, { dueDate: '2026-11-30' }),
issue('DONE_LATE', 'DONE', C.DONE, { dueDate: '2026-10-01' }),
issue('WAITS', 'TODO', C.TODO, {
links: [
{
type: IssueLinkType.BLOCKS,
direction: 'INWARD',
issueId: 'SLOW',
issueKey: 'SLOW',
},
],
}),
issue('SLOW', 'IN_PROGRESS', C.IN_PROGRESS),
issue('FRESH', 'IN_QA', C.QA),
];
const timeline = [
step('SLOW', '2026-10-01T10:00:00Z', 'IN_PROGRESS', C.IN_PROGRESS),
step('FRESH', '2026-10-14T10:00:00Z', 'IN_QA', C.QA),
];
const { risks, aging } = computeInsights({
issues,
timeline,
...BASE,
settings: defaultProjectSettings(),
});
expect(risks.overdue.map((i) => [i.key, i.days])).toEqual([['LATE', 5]]);
expect(risks.dueSoon.map((i) => [i.key, i.days])).toEqual([['SOON', 5]]);
expect(risks.blocked.map((i) => i.key)).toEqual(['WAITS']);
expect(risks.stuck.map((i) => [i.key, i.days])).toEqual([['SLOW', 14]]);
expect(risks.stuckDays).toBe(STUCK_DAYS);
// Everything under way, the longest first.
expect(aging.issues.map((i) => [i.key, i.days])).toEqual([
['SLOW', 14],
['FRESH', 1],
]);
});
it('measures the sprint under way against the calendar', () => {
const issues = [
issue('A', 'DONE', C.DONE, { ...inSprint, storyPoints: 3 }),
issue('B', 'IN_PROGRESS', C.IN_PROGRESS, { ...inSprint, storyPoints: 8 }),
issue('C', 'TODO', C.TODO, { ...inSprint, storyPoints: 5 }),
issue('X', 'CANCELLED', C.CANCELLED, { ...inSprint, storyPoints: 13 }),
];
const { sprint: pulse } = computeInsights({
issues,
timeline: [],
...BASE,
settings: defaultProjectSettings(),
activeSprint: sprint,
});
expect(pulse).toMatchObject({
totalDays: 14,
daysLeft: 10,
tickets: 3,
doneTickets: 1,
points: 16,
donePoints: 3,
// 3 points in 3 days; 13 left for 11 days.
pacePerDay: 1,
neededPerDay: 1.2,
behind: true,
});
});
it('finds where tickets wait, and columns over their limit', () => {
const settings = defaultProjectSettings();
settings.statuses.find((s) => s.id === 'IN_REVIEW')!.wipLimit = 1;
const issues = [
issue('A', 'DONE', C.DONE),
issue('B', 'IN_REVIEW', C.IN_PROGRESS),
issue('C', 'IN_REVIEW', C.IN_PROGRESS),
];
const timeline = [
step('A', '2026-10-01T10:00:00Z', 'IN_PROGRESS', C.IN_PROGRESS),
step('A', '2026-10-03T10:00:00Z', 'IN_REVIEW', C.IN_PROGRESS),
step('A', '2026-10-07T10:00:00Z', 'DONE', C.DONE),
];
const rows = computeInsights({
issues,
timeline,
...BASE,
settings,
}).bottlenecks;
expect(rows.map((r) => r.status)).toEqual([
'IN_PROGRESS',
'IN_REVIEW',
'READY_FOR_QA',
'IN_QA',
'QA_FAILED',
]);
expect(rows[0]).toMatchObject({ averageDays: 2, samples: 1, open: 0 });
expect(rows[1]).toMatchObject({
averageDays: 4,
open: 2,
wipLimit: 1,
overLimit: true,
});
});
it('forecasts planned releases at the pace of the last weeks', () => {
// Two tickets finished a day, every day of the last six weeks.
expect(simulateDays(10, [2, 2, 2])).toEqual({ likely: 5, safe: 5 });
expect(simulateDays(3, [0, 0])).toBeNull();
expect(simulateDays(0, [])).toEqual({ likely: 0, safe: 0 });
const done = Array.from({ length: 12 }, (_, n) => `D${n}`);
const issues = [
...done.map((id) => issue(id, 'DONE', C.DONE)),
...['A1', 'A2', 'A3'].map((id) =>
issue(id, 'TODO', C.TODO, { release: { id: 'a', name: 'a' } }),
),
...['B1', 'B2', 'B3'].map((id) =>
issue(id, 'TODO', C.TODO, { release: { id: 'b', name: 'b' } }),
),
];
// One finished every 3.5 days or so: 12 in the last 42 days.
const timeline = done.map((id, n) =>
step(
id,
new Date(NOW.getTime() - (n * 3 + 1) * 86_400_000).toISOString(),
'DONE',
C.DONE,
),
);
const { releases } = computeInsights({
...BASE,
issues,
timeline,
releases: [release('b', '2026-10-20'), release('a', '2026-12-31')],
});
// Nearer target first; the later one waits for it.
expect(releases.map((r) => [r.releaseId, r.verdict])).toEqual([
['b', 'LATE'],
['a', 'ON_TRACK'],
]);
expect(releases[1].likelyDate! > releases[0].likelyDate!).toBe(true);
});
it('tells what changed since a moment, and who carries what', () => {
const ana = { id: 'ana', name: 'Ana' };
const issues = [
issue('NEW', 'TODO', C.TODO, {
createdAt: '2026-10-15T08:00:00Z',
assignee: ana,
storyPoints: 8,
}),
issue('CLOSED', 'DONE', C.DONE, {
sprint: { id: 'old', name: 'Old' },
storyPoints: 5,
}),
issue('BACK', 'QA_FAILED', C.IN_PROGRESS, { storyPoints: 2 }),
issue('STALE', 'TODO', C.TODO, {
assignee: null,
area: 'FRONTEND',
updatedAt: '2026-01-01T00:00:00Z',
}),
];
const timeline = [
step('CLOSED', '2026-10-10T09:00:00Z', 'IN_QA', C.QA),
step('CLOSED', '2026-10-15T09:00:00Z', 'DONE', C.DONE),
step('BACK', '2026-10-10T09:00:00Z', 'IN_QA', C.QA),
step('BACK', '2026-10-15T10:00:00Z', 'QA_FAILED', C.IN_PROGRESS),
];
const reading = computeInsights({
...BASE,
issues,
timeline,
since: new Date('2026-10-15T07:00:00Z'),
mentions: 2,
members: [{ user: ana, role: 'MEMBER' as never }],
sprints: [
{ id: 'old', closedAt: '2026-10-11T00:00:00Z' } as SprintReadModel,
],
});
expect(reading.changes).toMatchObject({
createdCount: 1,
finishedCount: 1,
returnedCount: 1,
mentions: 2,
});
expect(reading.changes?.returned[0].key).toBe('BACK');
// Bob closed 5 points in the one closed sprint and holds 2 open.
expect(
reading.team.map((t) => [
t.user.id,
t.openPoints,
t.pointsPerSprint,
t.overloaded,
]),
).toEqual([
['ana', 8, 0, false],
['bob', 2, 5, false],
]);
expect(reading.backlog).toEqual({
open: 3,
unestimated: 1,
noArea: 2,
unassigned: 1,
stale: 1,
staleDays: STALE_DAYS,
});
});
it('weighs quality and splits open tickets by a list field', () => {
const settings = defaultProjectSettings();
settings.customFields = [
{
id: 'BROWSER',
name: 'Navegador',
type: CustomFieldType.SELECT,
options: ['Chrome', 'Safari'],
archived: false,
},
];
const issues = [
issue('B1', 'TODO', C.TODO, {
type: 'BUG',
priority: IssuePriority.HIGH,
createdAt: '2026-10-14T09:00:00Z',
customFields: { BROWSER: 'Safari' },
}),
issue('B2', 'DONE', C.DONE, {
type: 'BUG',
area: 'FRONTEND',
createdAt: '2026-10-02T09:00:00Z',
}),
issue('T1', 'IN_PROGRESS', C.IN_PROGRESS, {
type: 'TASK',
area: 'FRONTEND',
customFields: { BROWSER: 'Safari' },
}),
];
const timeline = [
step('B2', '2026-10-05T09:00:00Z', 'IN_QA', C.QA),
step('B2', '2026-10-06T09:00:00Z', 'QA_FAILED', C.IN_PROGRESS),
step('B2', '2026-10-07T09:00:00Z', 'IN_QA', C.QA),
step('B2', '2026-10-08T09:00:00Z', 'DONE', C.DONE),
];
const { quality, fields } = computeInsights({
...BASE,
settings,
issues,
timeline,
releases: [
{
id: 'r',
name: '1.0',
status: ReleaseStatus.RELEASED,
releasedAt: '2026-10-01T00:00:00Z',
} as ReleaseReadModel,
],
});
expect(quality.openBugsByPriority[1]).toEqual({
priority: IssuePriority.HIGH,
count: 1,
});
expect(quality.bugWeeks).toHaveLength(8);
expect(quality.bugWeeks.at(-1)).toEqual({
week: '2026-10-12',
created: 1,
closed: 0,
});
expect(quality.bugWeeks.at(-2)).toEqual({
week: '2026-10-05',
created: 0,
closed: 1,
});
expect(quality.qaByArea).toEqual([
{ area: 'FRONTEND', returned: 1, passed: 1 },
]);
// B2 came 1 day after 1.0; B1 13 days after.
expect(quality.escaped).toEqual([
{
releaseId: 'r',
name: '1.0',
releasedAt: '2026-10-01T00:00:00Z',
bugs: 2,
},
]);
expect(fields).toEqual([
{
fieldId: 'BROWSER',
counts: [
{ value: 'Safari', count: 2 },
{ value: 'Chrome', count: 0 },
],
none: 0,
},
]);
});
});

View File

@@ -0,0 +1,610 @@
import {
BacklogHealthReadModel,
CustomFieldType,
FieldBreakdownReadModel,
IssuePriority,
QualityReadModel,
ChangesSinceReadModel,
InsightIssueReadModel,
IssueLinkType,
IssueReadModel,
IssueTimelineEntry,
ProjectInsightsReadModel,
ProjectMemberReadModel,
ProjectSettings,
ReleaseForecastReadModel,
ReleaseReadModel,
ReleaseStatus,
SprintPulseReadModel,
SprintReadModel,
StatusCategory,
StatusTimeReadModel,
TeamLoadReadModel,
UserRefReadModel,
} from '@task-stream/api/tracker/domain';
import {
computeProjectAnalytics,
Step,
stepsByIssue,
weekOf,
} from './analytics';
/*
* The dashboard's reading of a project: what needs attention now (risks),
* how long the work under way has been where it is (aging) and where it
* waits (bottlenecks). Pure functions over the read model and the
* timeline, like the statistics.
*/
const DAY = 86_400_000;
/** Days without moving that make a ticket under way «stuck». */
export const STUCK_DAYS = 7;
/** How many tickets each list names. */
const LIST = 8;
/** Days not updated that make an open ticket «stale». */
export const STALE_DAYS = 90;
/** Days of finished work the forecasts draw their pace from. */
export const PACE_DAYS = 42;
const FLOW = [StatusCategory.IN_PROGRESS, StatusCategory.QA];
const CLOSED = [StatusCategory.DONE, StatusCategory.CANCELLED];
export interface InsightsInput {
issues: IssueReadModel[];
timeline: IssueTimelineEntry[];
settings: ProjectSettings;
activeSprint: SprintReadModel | null;
/** Every sprint of the project (closed ones give each person's pace). */
sprints: SprintReadModel[];
releases: ReleaseReadModel[];
members: ProjectMemberReadModel[];
/** The viewer's last visit, to tell what changed since. */
since: Date | null;
/** Times the viewer was mentioned in the project since then. */
mentions: number;
now: Date;
}
const utcDay = (time: number) =>
Date.UTC(
new Date(time).getUTCFullYear(),
new Date(time).getUTCMonth(),
new Date(time).getUTCDate(),
);
const dayIndex = (day: string) => Date.parse(`${day.slice(0, 10)}T00:00:00Z`);
const round1 = (n: number) => Math.round(n * 10) / 10;
export function computeInsights(
input: InsightsInput,
): ProjectInsightsReadModel {
const now = input.now.getTime();
const today = utcDay(now);
const live = input.issues.filter((i) => !i.trashedAt);
const open = live.filter((i) => !CLOSED.includes(i.statusCategory));
const steps = stepsByIssue(live, input.timeline);
/** When it got to its current status. */
const sinceOf = (issue: IssueReadModel) => {
const list = steps.get(issue.id) ?? [];
const last = list[list.length - 1];
return last && last.status === issue.status
? last.at
: Date.parse(issue.updatedAt);
};
const line = (
issue: IssueReadModel,
days: number,
): InsightIssueReadModel => ({
id: issue.id,
key: issue.key,
title: issue.title,
status: issue.status,
assignee: issue.assignee,
days,
dueDate: issue.dueDate,
});
// --- Risks ---
const due = open
.filter((i) => i.dueDate)
.map((i) => ({
issue: i,
days: (dayIndex(i.dueDate as string) - today) / DAY,
}));
const overdue = due
.filter((d) => d.days < 0)
.sort((a, b) => a.days - b.days)
.map((d) => line(d.issue, -d.days));
const dueSoon = due
.filter((d) => d.days >= 0 && d.days <= 7)
.sort((a, b) => a.days - b.days)
.map((d) => line(d.issue, d.days));
const openIds = new Set(open.map((i) => i.id));
const blocked = open
.filter((i) =>
i.links.some(
(l) =>
openIds.has(l.issueId) &&
((l.type === IssueLinkType.BLOCKS && l.direction === 'INWARD') ||
(l.type === IssueLinkType.DEPENDS_ON && l.direction === 'OUTWARD')),
),
)
.map((i) => line(i, Math.floor((now - sinceOf(i)) / DAY)))
.sort((a, b) => b.days - a.days);
const underWay = open
.filter((i) => FLOW.includes(i.statusCategory))
.map((i) => line(i, Math.floor((now - sinceOf(i)) / DAY)))
.sort((a, b) => b.days - a.days);
const stuck = underWay.filter((i) => i.days >= STUCK_DAYS);
// --- Aging: against the cycle time of the last 90 days ---
const analytics = computeProjectAnalytics({
issues: live,
timeline: input.timeline,
statusOrder: input.settings.statuses.map((s) => s.id),
from: new Date(now - 89 * DAY),
to: input.now,
});
return {
generatedAt: input.now.toISOString(),
sprint: input.activeSprint
? sprintPulse(input.activeSprint, live, now)
: null,
risks: {
overdue: overdue.slice(0, LIST),
overdueCount: overdue.length,
dueSoon: dueSoon.slice(0, LIST),
dueSoonCount: dueSoon.length,
blocked: blocked.slice(0, LIST),
blockedCount: blocked.length,
stuck: stuck.slice(0, LIST),
stuckCount: stuck.length,
stuckDays: STUCK_DAYS,
},
aging: {
issues: underWay.slice(0, 15),
thresholdDays: analytics.cycleTime.p85Days,
},
bottlenecks: bottlenecks(input.settings, open, steps, now),
releases: forecasts(input.releases, live, steps, now),
changes: input.since
? changesSince(input.since.getTime(), live, steps, line, input.mentions)
: null,
team: teamLoad(
input.members,
open,
live,
input.sprints,
new Set(blocked.map((b) => b.id)),
),
backlog: backlogHealth(open, now),
quality: quality(input.settings, live, steps, input.releases, now),
fields: fieldBreakdown(input.settings, open),
};
}
const PRIORITIES = [
IssuePriority.CRITICAL,
IssuePriority.HIGH,
IssuePriority.MEDIUM,
IssuePriority.LOW,
IssuePriority.LOWEST,
];
/** Bugs are the tickets whose type is drawn with the bug icon. */
function quality(
settings: ProjectSettings,
issues: IssueReadModel[],
steps: Map<string, Step[]>,
releases: ReleaseReadModel[],
now: number,
): QualityReadModel {
const bugTypes = new Set(
settings.issueTypes.filter((t) => t.icon === 'bug').map((t) => t.id),
);
const bugs = issues.filter((i) => bugTypes.has(i.type));
const openBugs = bugs.filter((i) => !CLOSED.includes(i.statusCategory));
// Created and closed per week, the last 8.
const thisWeek = Date.parse(weekOf(now));
const weeks = Array.from({ length: 8 }, (_, n) =>
new Date(thisWeek - (7 - n) * 7 * DAY).toISOString().slice(0, 10),
);
const tally = new Map(weeks.map((w) => [w, { created: 0, closed: 0 }]));
for (const bug of bugs) {
const created = tally.get(weekOf(Date.parse(bug.createdAt)));
if (created) created.created++;
const list = steps.get(bug.id) ?? [];
list.forEach((step, i) => {
if (i === 0 || !CLOSED.includes(step.category)) return;
if (CLOSED.includes(list[i - 1].category)) return;
const week = tally.get(weekOf(step.at));
if (week) week.closed++;
});
}
// QA's verdicts per area, the last 90 days.
const from = now - 90 * DAY;
const byArea = new Map<string | null, { returned: number; passed: number }>();
for (const issue of issues) {
const list = steps.get(issue.id) ?? [];
list.forEach((step, i) => {
const before = list[i - 1];
if (!before || step.at < from) return;
if (before.category !== StatusCategory.QA) return;
if (step.category === StatusCategory.QA) return;
if (step.category === StatusCategory.CANCELLED) return;
const row = byArea.get(issue.area) ?? { returned: 0, passed: 0 };
if (step.category === StatusCategory.DONE) row.passed++;
else row.returned++;
byArea.set(issue.area, row);
});
}
// Bugs reported in the 14 days after each of the last shipped releases.
const escaped = releases
.filter((r) => r.status === ReleaseStatus.RELEASED && r.releasedAt)
.sort((a, b) =>
(b.releasedAt as string).localeCompare(a.releasedAt as string),
)
.slice(0, 3)
.map((r) => {
const shipped = Date.parse(r.releasedAt as string);
return {
releaseId: r.id,
name: r.name,
releasedAt: r.releasedAt as string,
bugs: bugs.filter((b) => {
const at = Date.parse(b.createdAt);
return at > shipped && at <= shipped + 14 * DAY;
}).length,
};
});
return {
openBugsByPriority: PRIORITIES.map((priority) => ({
priority,
count: openBugs.filter((b) => b.priority === priority).length,
})),
bugWeeks: weeks.map((week) => ({ week, ...tally.get(week)! })),
qaByArea: [...byArea.entries()]
.map(([area, row]) => ({ area, ...row }))
.sort((a, b) => b.returned - a.returned || b.passed - a.passed),
escaped,
};
}
/** Open tickets per option of each active list field. */
function fieldBreakdown(
settings: ProjectSettings,
open: IssueReadModel[],
): FieldBreakdownReadModel[] {
return (settings.customFields ?? [])
.filter((f) => !f.archived && f.type === CustomFieldType.SELECT)
.map((field) => {
const values = open.map((i) => i.customFields?.[field.id] ?? null);
return {
fieldId: field.id,
counts: field.options
.map((value) => ({
value,
count: values.filter((v) => v === value).length,
}))
.sort((a, b) => b.count - a.count),
none: values.filter((v) => v === null).length,
};
});
}
/** Tickets that reached a DONE status per day, over the last PACE_DAYS days. */
function dailyThroughput(steps: Map<string, Step[]>, now: number): number[] {
const today = utcDay(now);
const counts = new Array<number>(PACE_DAYS).fill(0);
for (const list of steps.values())
list.forEach((step, i) => {
const before = list[i - 1];
if (step.category !== StatusCategory.DONE) return;
if (before && before.category === StatusCategory.DONE) return;
const ago = Math.floor((today - utcDay(step.at)) / DAY);
if (ago >= 0 && ago < PACE_DAYS) counts[ago]++;
});
return counts;
}
/** Small seeded generator: the same data always gives the same forecast. */
function random(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/**
* Days to finish `remaining` tickets, drawing each day's throughput from
* the days measured (Monte Carlo): the 50th and 85th percentiles.
*/
export function simulateDays(
remaining: number,
samples: number[],
runs = 500,
): { likely: number; safe: number } | null {
if (remaining <= 0) return { likely: 0, safe: 0 };
if (!samples.some((n) => n > 0)) return null;
const next = random(remaining * 7919 + samples.length);
const results: number[] = [];
for (let run = 0; run < runs; run++) {
let done = 0;
let days = 0;
while (done < remaining && days < 3 * 365) {
done += samples[Math.floor(next() * samples.length)];
days++;
}
results.push(days);
}
results.sort((a, b) => a - b);
return {
likely: results[Math.floor(0.5 * runs)],
safe: results[Math.ceil(0.85 * runs) - 1],
};
}
/**
* Planned releases in target-date order. The team finishes them one after
* another, so each one's forecast counts what is left in the earlier ones.
*/
function forecasts(
releases: ReleaseReadModel[],
issues: IssueReadModel[],
steps: Map<string, Step[]>,
now: number,
): ReleaseForecastReadModel[] {
const pace = dailyThroughput(steps, now);
const today = utcDay(now);
const dayAfter = (days: number) =>
new Date(today + days * DAY).toISOString().slice(0, 10);
let ahead = 0;
return releases
.filter((r) => r.status === ReleaseStatus.PLANNED)
.sort(
(a, b) =>
(a.targetDate ?? '9999').localeCompare(b.targetDate ?? '9999') ||
a.name.localeCompare(b.name),
)
.map((release) => {
const own = issues.filter(
(i) =>
i.release?.id === release.id &&
i.statusCategory !== StatusCategory.CANCELLED,
);
const finished = own.filter(
(i) => i.statusCategory === StatusCategory.DONE,
).length;
const left = own.length - finished;
ahead += left;
const days = left > 0 ? simulateDays(ahead, pace) : null;
const likelyDate = days ? dayAfter(days.likely) : null;
const safeDate = days ? dayAfter(days.safe) : null;
const verdict: ReleaseForecastReadModel['verdict'] =
left === 0
? 'DONE'
: !days
? 'UNKNOWN'
: !release.targetDate
? 'NO_DATE'
: (safeDate as string) <= release.targetDate
? 'ON_TRACK'
: (likelyDate as string) <= release.targetDate
? 'AT_RISK'
: 'LATE';
return {
releaseId: release.id,
name: release.name,
targetDate: release.targetDate,
total: own.length,
finished,
likelyDate,
safeDate,
verdict,
};
});
}
/** Created, finished and sent back from QA after `since`. */
function changesSince(
since: number,
issues: IssueReadModel[],
steps: Map<string, Step[]>,
line: (issue: IssueReadModel, days: number) => InsightIssueReadModel,
mentions: number,
): ChangesSinceReadModel {
const created = issues
.filter((i) => Date.parse(i.createdAt) > since)
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
const finished: IssueReadModel[] = [];
const returned: IssueReadModel[] = [];
for (const issue of issues) {
const list = steps.get(issue.id) ?? [];
const recent = list.filter((s, i) => s.at > since && i > 0);
if (
issue.statusCategory === StatusCategory.DONE &&
recent.some((s) => s.category === StatusCategory.DONE)
)
finished.push(issue);
if (
list.some(
(s, i) =>
s.at > since &&
i > 0 &&
list[i - 1].category === StatusCategory.QA &&
!CLOSED.includes(s.category) &&
s.category !== StatusCategory.QA,
)
)
returned.push(issue);
}
return {
since: new Date(since).toISOString(),
created: created.slice(0, LIST).map((i) => line(i, 0)),
createdCount: created.length,
finished: finished.slice(0, LIST).map((i) => line(i, 0)),
finishedCount: finished.length,
returned: returned.slice(0, LIST).map((i) => line(i, 0)),
returnedCount: returned.length,
mentions,
};
}
const pointsOf = (list: IssueReadModel[]) =>
list.reduce((sum, i) => sum + (i.storyPoints ?? 0), 0);
/** Open work per person against the points they closed per sprint lately. */
function teamLoad(
members: ProjectMemberReadModel[],
open: IssueReadModel[],
issues: IssueReadModel[],
sprints: SprintReadModel[],
blockedIds: Set<string>,
): TeamLoadReadModel[] {
const people = new Map<string, UserRefReadModel>();
for (const m of members) people.set(m.user.id, m.user);
for (const i of open) if (i.assignee) people.set(i.assignee.id, i.assignee);
const lastClosed = sprints
.filter((s) => s.closedAt)
.sort((a, b) => (b.closedAt as string).localeCompare(a.closedAt as string))
.slice(0, 3)
.map((s) => s.id);
return [...people.values()]
.map((user) => {
const mine = open.filter((i) => i.assignee?.id === user.id);
const closed = issues.filter(
(i) =>
i.assignee?.id === user.id &&
i.statusCategory === StatusCategory.DONE &&
!!i.sprint &&
lastClosed.includes(i.sprint.id),
);
const perSprint = lastClosed.length
? round1(pointsOf(closed) / lastClosed.length)
: null;
const openPoints = pointsOf(mine);
return {
user,
openTickets: mine.length,
openPoints,
blocked: mine.filter((i) => blockedIds.has(i.id)).length,
pointsPerSprint: perSprint,
overloaded: !!perSprint && openPoints > perSprint,
};
})
.sort(
(a, b) =>
b.openPoints - a.openPoints ||
b.openTickets - a.openTickets ||
a.user.name.localeCompare(b.user.name, 'es'),
);
}
function backlogHealth(
open: IssueReadModel[],
now: number,
): BacklogHealthReadModel {
const staleBefore = now - STALE_DAYS * DAY;
return {
open: open.length,
unestimated: open.filter((i) => i.storyPoints === null).length,
noArea: open.filter((i) => !i.area).length,
unassigned: open.filter((i) => !i.assignee).length,
stale: open.filter((i) => Date.parse(i.updatedAt) < staleBefore).length,
staleDays: STALE_DAYS,
};
}
export function sprintPulse(
sprint: SprintReadModel,
issues: IssueReadModel[],
now: number,
): SprintPulseReadModel {
const inSprint = issues.filter(
(i) =>
i.sprint?.id === sprint.id &&
i.statusCategory !== StatusCategory.CANCELLED,
);
const done = inSprint.filter((i) => i.statusCategory === StatusCategory.DONE);
const points = (list: IssueReadModel[]) =>
list.reduce((sum, i) => sum + (i.storyPoints ?? 0), 0);
const start = dayIndex(sprint.startDate);
const end = dayIndex(sprint.endDate);
const today = utcDay(now);
const totalDays = Math.round((end - start) / DAY) + 1;
const daysLeft = Math.round((end - today) / DAY);
// Days already worked: today counts once it is half gone.
const elapsed = Math.min(
totalDays,
Math.max(0, Math.round((today - start) / DAY)),
);
const total = points(inSprint);
const closed = points(done);
const remaining = total - closed;
const pace = elapsed > 0 ? round1(closed / elapsed) : null;
const left = Math.max(daysLeft + 1, 0);
const needed =
remaining > 0 ? (left > 0 ? round1(remaining / left) : null) : null;
return {
sprintId: sprint.id,
name: sprint.name,
startDate: sprint.startDate,
endDate: sprint.endDate,
totalDays,
daysLeft,
tickets: inSprint.length,
doneTickets: done.length,
points: total,
donePoints: closed,
pacePerDay: pace,
neededPerDay: needed,
behind:
remaining > 0 &&
(left === 0 || (pace !== null && pace * left < remaining)),
};
}
/** Average stay in each in-progress and QA status (stays that ended lately). */
function bottlenecks(
settings: ProjectSettings,
open: IssueReadModel[],
steps: Map<string, Step[]>,
now: number,
): StatusTimeReadModel[] {
const from = now - 90 * DAY;
const stays = new Map<string, number[]>();
for (const list of steps.values())
list.forEach((step, i) => {
const next = list[i + 1];
if (!next || next.at < from) return;
stays.set(step.status, [
...(stays.get(step.status) ?? []),
next.at - step.at,
]);
});
return settings.statuses
.filter((s) => !s.archived && FLOW.includes(s.category))
.map((s) => {
const measured = stays.get(s.id) ?? [];
const count = open.filter((i) => i.status === s.id).length;
const limit = s.wipLimit ?? null;
return {
status: s.id,
averageDays: measured.length
? round1(measured.reduce((a, b) => a + b, 0) / measured.length / DAY)
: null,
samples: measured.length,
open: count,
wipLimit: limit,
overLimit: limit !== null && count > limit,
};
});
}

View File

@@ -1,5 +1,6 @@
import { CommandRunner } from '@task-stream/api/shared/application';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs'; import { QueryBus } from '@nestjs/cqrs';
import { import {
AutomationInput, AutomationInput,
CreateAutomationCommand, CreateAutomationCommand,
@@ -14,7 +15,7 @@ import { Automation } from './ports/automations.repository';
@Injectable() @Injectable()
export class AutomationsService { export class AutomationsService {
constructor( constructor(
private readonly commandBus: CommandBus, private readonly commands: CommandRunner,
private readonly queryBus: QueryBus, private readonly queryBus: QueryBus,
) {} ) {}
@@ -27,7 +28,7 @@ export class AutomationsService {
projectId: string, projectId: string,
rule: AutomationInput, rule: AutomationInput,
): Promise<Automation> { ): Promise<Automation> {
return this.commandBus.execute( return this.commands.execute(
new CreateAutomationCommand(actorId, projectId, rule), new CreateAutomationCommand(actorId, projectId, rule),
); );
} }
@@ -37,20 +38,18 @@ export class AutomationsService {
automationId: string, automationId: string,
changes: { name?: string; active?: boolean }, changes: { name?: string; active?: boolean },
): Promise<Automation> { ): Promise<Automation> {
return this.commandBus.execute( return this.commands.execute(
new UpdateAutomationCommand(actorId, automationId, changes), new UpdateAutomationCommand(actorId, automationId, changes),
); );
} }
/** Runs a rule on a ticket; the error, if any, as text. */ /** Runs a rule on a ticket; the error, if any, as text. */
run(automation: Automation, issueId: string): Promise<string | null> { run(automation: Automation, issueId: string): Promise<string | null> {
return this.commandBus.execute( return this.commands.execute(new RunAutomationCommand(automation, issueId));
new RunAutomationCommand(automation, issueId),
);
} }
delete(actorId: string, automationId: string): Promise<void> { delete(actorId: string, automationId: string): Promise<void> {
return this.commandBus.execute( return this.commands.execute(
new DeleteAutomationCommand(actorId, automationId), new DeleteAutomationCommand(actorId, automationId),
); );
} }

View File

@@ -96,9 +96,12 @@ async function managedProject(
access: WriteAccess, access: WriteAccess,
projectId: string, projectId: string,
actorId: string, actorId: string,
/** Only to read the rules: an archived project still shows them. */
reading = false,
): Promise<Project> { ): Promise<Project> {
const project = await access.project(projectId); const project = await access.project(projectId);
project.assertCanManage(actorId); if (reading) project.assertManages(actorId);
else project.assertCanManage(actorId);
return project; return project;
} }
@@ -231,6 +234,7 @@ export class RunAutomationCommandHandler
? await this.access.live(issue.projectId, Project) ? await this.access.live(issue.projectId, Project)
: null; : null;
if (!issue || !project) return 'The ticket is gone'; if (!issue || !project) return 'The ticket is gone';
if (project.archivedAt) return 'The project is archived';
const { kind, value } = command.automation.action; const { kind, value } = command.automation.action;
const actor = SYSTEM_ACTOR_ID; const actor = SYSTEM_ACTOR_ID;
switch (kind) { switch (kind) {
@@ -297,7 +301,7 @@ export class GetAutomationsQueryHandler
) {} ) {}
async execute(query: GetAutomationsQuery): Promise<Automation[]> { 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); return this.automations.findByProject(query.projectId);
} }
} }

View File

@@ -3,6 +3,7 @@ import {
IssueReadModel, IssueReadModel,
StatusCategory, StatusCategory,
} from '@task-stream/api/tracker/domain'; } from '@task-stream/api/tracker/domain';
import { ReadAccess } from './queries/read-access';
import { BlockersService } from './blockers.service'; import { BlockersService } from './blockers.service';
import { FindIssuesRepository } from './ports/find-issues.repository'; import { FindIssuesRepository } from './ports/find-issues.repository';
@@ -30,12 +31,15 @@ describe('BlockersService', () => {
].map((i) => [i.id, i]), ].map((i) => [i.id, i]),
); );
const queries: string[][] = []; const queries: string[][] = [];
const service = new BlockersService({ const service = new BlockersService(
findByIds: async (ids: string[]) => { {
queries.push(ids); findByIds: async (ids: string[]) => {
return ids.map((id) => store.get(id)).filter(Boolean); queries.push(ids);
}, return ids.map((id) => store.get(id)).filter(Boolean);
} as unknown as FindIssuesRepository); },
} as unknown as FindIssuesRepository,
{ participantScope: async () => null } as unknown as ReadAccess,
);
const link = ( const link = (
type: IssueLinkType, type: IssueLinkType,
direction: 'INWARD' | 'OUTWARD', direction: 'INWARD' | 'OUTWARD',

View File

@@ -1,3 +1,4 @@
import { involves, ReadAccess } from './queries/read-access';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { import {
IssueLinkType, IssueLinkType,
@@ -31,19 +32,28 @@ export class BlockersService {
result: Promise<Map<string, IssueReadModel>>; result: Promise<Map<string, IssueReadModel>>;
} | null = null; } | 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); const ids = blockerIdsOf(issue);
if (!ids.length) return []; if (!ids.length) return [];
const found = await this.load(ids); const found = await this.load(ids);
const scope = await this.access.participantScope(issue.projectId, viewerId);
return ids return ids
.map((id) => found.get(id)) .map((id) => found.get(id))
.filter( .filter(
(blocker): blocker is IssueReadModel => (blocker): blocker is IssueReadModel =>
!!blocker && !!blocker &&
!blocker.trashedAt && !blocker.trashedAt &&
!FINISHED.has(blocker.statusCategory), !FINISHED.has(blocker.statusCategory) &&
(!scope || involves(blocker, scope)),
) )
.map(({ id, key, title }) => ({ id, key, title })); .map(({ id, key, title }) => ({ id, key, title }));
} }

View File

@@ -1,5 +1,6 @@
import { CommandRunner } from '@task-stream/api/shared/application';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs'; import { QueryBus } from '@nestjs/cqrs';
import { import {
CreateCalendarFeedCommand, CreateCalendarFeedCommand,
GetCalendarIcsQuery, GetCalendarIcsQuery,
@@ -12,7 +13,7 @@ import { CalendarFeed } from './ports/calendar-feeds.repository';
@Injectable() @Injectable()
export class CalendarService { export class CalendarService {
constructor( constructor(
private readonly commandBus: CommandBus, private readonly commands: CommandRunner,
private readonly queryBus: QueryBus, private readonly queryBus: QueryBus,
) {} ) {}
@@ -22,11 +23,11 @@ export class CalendarService {
/** A new secret URL; the previous one, if any, stops working. */ /** A new secret URL; the previous one, if any, stops working. */
create(userId: string): Promise<CalendarFeed> { create(userId: string): Promise<CalendarFeed> {
return this.commandBus.execute(new CreateCalendarFeedCommand(userId)); return this.commands.execute(new CreateCalendarFeedCommand(userId));
} }
revoke(userId: string): Promise<void> { revoke(userId: string): Promise<void> {
return this.commandBus.execute(new RevokeCalendarFeedCommand(userId)); return this.commands.execute(new RevokeCalendarFeedCommand(userId));
} }
/** The calendar behind `token`, or null if no feed has it. */ /** The calendar behind `token`, or null if no feed has it. */

View File

@@ -1,3 +1,4 @@
import { guestOnly } from '../queries/read-access';
import { randomBytes } from 'node:crypto'; import { randomBytes } from 'node:crypto';
import { import {
Command, Command,
@@ -120,10 +121,14 @@ export class GetCalendarIcsQueryHandler
const projects = await this.findProjects.findByMember(feed.userId); const projects = await this.findProjects.findByMember(feed.userId);
const byId = new Map(projects.map((p) => [p.id, p])); const byId = new Map(projects.map((p) => [p.id, p]));
const ids = projects.map((p) => p.id); 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([ const [issues, sprints, releases, me] = await Promise.all([
this.findIssues.findDatedFor(feed.userId, ids), this.findIssues.findDatedFor(feed.userId, ids),
Promise.all(ids.map((id) => this.findSprints.findByProject(id))), Promise.all(planned.map((id) => this.findSprints.findByProject(id))),
Promise.all(ids.map((id) => this.findReleases.findByProject(id))), Promise.all(planned.map((id) => this.findReleases.findByProject(id))),
this.users.findById(feed.userId), this.users.findById(feed.userId),
]); ]);
const link = linkTo(query.webUrl); const link = linkTo(query.webUrl);

View File

@@ -12,6 +12,17 @@ import {
} from './ports/change-feed'; } from './ports/change-feed';
import { CanViewProjectQuery } from './queries/can-view-project.query'; import { CanViewProjectQuery } from './queries/can-view-project.query';
import { GetIssueQuery } from './queries/get-issue.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. */ /** Realtime subscriptions, authorised at subscribe time. */
@Injectable() @Injectable()
@@ -26,7 +37,12 @@ export class ChangesService {
projectId: string, projectId: string,
): Promise<AsyncIterableIterator<IssueChange>> { ): Promise<AsyncIterableIterator<IssueChange>> {
await this.assertCanView(viewerId, projectId); 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( async watchComments(
@@ -41,7 +57,7 @@ export class ChangesService {
viewerId: string, viewerId: string,
projectId: string, projectId: string,
): Promise<AsyncIterableIterator<ActivityReadModel>> { ): Promise<AsyncIterableIterator<ActivityReadModel>> {
await this.assertCanView(viewerId, projectId); await this.assertCanView(viewerId, projectId, true);
return this.changeFeed.activity(projectId); return this.changeFeed.activity(projectId);
} }
@@ -49,7 +65,7 @@ export class ChangesService {
viewerId: string, viewerId: string,
projectId: string, projectId: string,
): Promise<AsyncIterableIterator<ReleaseChange>> { ): Promise<AsyncIterableIterator<ReleaseChange>> {
await this.assertCanView(viewerId, projectId); await this.assertCanView(viewerId, projectId, true);
return this.changeFeed.releases(projectId); return this.changeFeed.releases(projectId);
} }
@@ -57,7 +73,7 @@ export class ChangesService {
viewerId: string, viewerId: string,
projectId: string, projectId: string,
): Promise<AsyncIterableIterator<SprintChange>> { ): Promise<AsyncIterableIterator<SprintChange>> {
await this.assertCanView(viewerId, projectId); await this.assertCanView(viewerId, projectId, true);
return this.changeFeed.sprints(projectId); return this.changeFeed.sprints(projectId);
} }
@@ -77,9 +93,10 @@ export class ChangesService {
private async assertCanView( private async assertCanView(
viewerId: string, viewerId: string,
projectId: string, projectId: string,
wholeProject = false,
): Promise<void> { ): Promise<void> {
const allowed = await this.queryBus.execute( const allowed = await this.queryBus.execute(
new CanViewProjectQuery(viewerId, projectId), new CanViewProjectQuery(viewerId, projectId, wholeProject),
); );
if (!allowed) throw new NotFoundError('Project', projectId); if (!allowed) throw new NotFoundError('Project', projectId);
} }

View File

@@ -16,7 +16,7 @@ export class AddCommentCommandHandler
) {} ) {}
async execute(command: AddCommentCommand): Promise<CommandResult> { async execute(command: AddCommentCommand): Promise<CommandResult> {
const { issue } = await this.access.issueForContributor( const { issue } = await this.access.issueForReporter(
command.issueId, command.issueId,
command.actorId, command.actorId,
); );

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -20,7 +20,7 @@ export class AttachFileCommandHandler
) {} ) {}
async execute(command: AttachFileCommand): Promise<AttachmentResult> { async execute(command: AttachFileCommand): Promise<AttachmentResult> {
const { issue } = await this.access.issueForContributor( const { issue } = await this.access.issueForReporter(
command.issueId, command.issueId,
command.actorId, command.actorId,
); );

View File

@@ -14,11 +14,14 @@ export class ChangeIssueStatusCommandHandler
command.issueId, command.issueId,
command.actorId, command.actorId,
); );
const role = project.roleOf(command.actorId);
issue.changeStatus( issue.changeStatus(
command.actorId, command.actorId,
command.status, command.status,
command.reason, command.reason,
project.workflow, project.workflow,
// The definition of done holds back members, not who runs the project.
role === 'OWNER' || role === 'ADMIN',
); );
await issue.commitAndWait(); await issue.commitAndWait();
return ack(issue); return ack(issue);

View File

@@ -21,6 +21,7 @@ import { UpdateReleaseCommandHandler } from './update-release.command-handler';
import { PublishReleaseCommandHandler } from './publish-release.command-handler'; import { PublishReleaseCommandHandler } from './publish-release.command-handler';
import { ArchiveReleaseCommandHandler } from './archive-release.command-handler'; import { ArchiveReleaseCommandHandler } from './archive-release.command-handler';
import { DeleteProjectCommandHandler } from './delete-project.command-handler'; import { DeleteProjectCommandHandler } from './delete-project.command-handler';
import { ArchiveProjectCommandHandler } from './archive-project.command-handler';
import { DeleteIssueCommandHandler } from './delete-issue.command-handler'; import { DeleteIssueCommandHandler } from './delete-issue.command-handler';
import { DeleteReleaseCommandHandler } from './delete-release.command-handler'; import { DeleteReleaseCommandHandler } from './delete-release.command-handler';
import { PurgeCommentCommandHandler } from './purge-comment.command-handler'; import { PurgeCommentCommandHandler } from './purge-comment.command-handler';
@@ -66,6 +67,7 @@ export const COMMAND_HANDLERS = [
PublishReleaseCommandHandler, PublishReleaseCommandHandler,
ArchiveReleaseCommandHandler, ArchiveReleaseCommandHandler,
DeleteProjectCommandHandler, DeleteProjectCommandHandler,
ArchiveProjectCommandHandler,
// Emitted by sagas only (cascades and reactions to other aggregates). // Emitted by sagas only (cascades and reactions to other aggregates).
DeleteIssueCommandHandler, DeleteIssueCommandHandler,
DeleteReleaseCommandHandler, DeleteReleaseCommandHandler,

View File

@@ -2,6 +2,7 @@ import { CommandBus, CqrsModule, EventBus } from '@nestjs/cqrs';
import { Test } from '@nestjs/testing'; import { Test } from '@nestjs/testing';
import { import {
AggregateRehydrator, AggregateRehydrator,
CommandRunner,
EventStore, EventStore,
} from '@task-stream/api/shared/application'; } from '@task-stream/api/shared/application';
import { import {
@@ -151,6 +152,7 @@ async function setup() {
}, },
{ provide: GitIntegrationsRepository, useValue: git }, { provide: GitIntegrationsRepository, useValue: git },
AggregateRehydrator, AggregateRehydrator,
CommandRunner,
{ provide: EventStore, useValue: store }, { provide: EventStore, useValue: store },
{ provide: AttachmentStorage, useValue: files }, { provide: AttachmentStorage, useValue: files },
{ provide: UserDirectory, useValue: users }, { provide: UserDirectory, useValue: users },

View File

@@ -1,7 +1,9 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs'; import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs';
import { ForbiddenError } from '@task-stream/api/shared/domain';
import { import {
IssueFactory, IssueFactory,
ProjectRole,
SYSTEM_ACTOR_ID, SYSTEM_ACTOR_ID,
assertCanNest, assertCanNest,
} from '@task-stream/api/tracker/domain'; } from '@task-stream/api/tracker/domain';
@@ -27,8 +29,21 @@ export class CreateIssueCommandHandler
const project = await this.access.project(command.projectId); const project = await this.access.project(command.projectId);
// TaskStream itself (proposals from the public form) is no member; only // TaskStream itself (proposals from the public form) is no member; only
// internal commands act as it, never a request from outside. // internal commands act as it, never a request from outside.
if (command.actorId !== SYSTEM_ACTOR_ID) if (command.actorId !== SYSTEM_ACTOR_ID) {
project.assertCanContribute(command.actorId); 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. // Before taking a number: an invalid type or area must not burn one.
const workflow = project.workflow; const workflow = project.workflow;
workflow.assertActiveType(command.type); workflow.assertActiveType(command.type);

View File

@@ -18,6 +18,11 @@ export class ReactToCommentCommandHandler
throw new ForbiddenError( throw new ForbiddenError(
`You are not a member of project ${project.key}`, `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); comment.react(command.actorId, command.emoji, command.on);
await comment.commitAndWait(); await comment.commitAndWait();
return ack(comment); return ack(comment);

View File

@@ -24,6 +24,8 @@ export class VoteIssueCommandHandler
throw new ForbiddenError( throw new ForbiddenError(
`You are not a member of project ${project.key}`, `You are not a member of project ${project.key}`,
); );
project.assertNotArchived();
this.access.assertSees(project, issue, command.actorId);
if (issue.trashedAt) if (issue.trashedAt)
throw new BusinessRuleError(`${issue.key} is in the trash`); throw new BusinessRuleError(`${issue.key} is in the trash`);
issue.vote(command.actorId, command.on); issue.vote(command.actorId, command.on);

View File

@@ -25,6 +25,8 @@ export class WatchIssueCommandHandler
`You are not a member of project ${project.key}`, `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); if (command.watching) issue.watch(command.actorId, command.userId);
else issue.unwatch(command.actorId, command.userId); else issue.unwatch(command.actorId, command.userId);
await issue.commitAndWait(); await issue.commitAndWait();

View File

@@ -74,6 +74,31 @@ export class WriteAccess {
return { issue, project }; 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. */ /** Release of the same project that still accepts tickets. */
async openReleaseOf(project: Project, releaseId: string): Promise<Release> { async openReleaseOf(project: Project, releaseId: string): Promise<Release> {
const release = await this.release(releaseId); const release = await this.release(releaseId);

View File

@@ -1,5 +1,6 @@
import { CommandRunner } from '@task-stream/api/shared/application';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs'; import { QueryBus } from '@nestjs/cqrs';
import { CommentReadModel } from '@task-stream/api/tracker/domain'; import { CommentReadModel } from '@task-stream/api/tracker/domain';
import { AddCommentCommand } from './commands/add-comment.command'; import { AddCommentCommand } from './commands/add-comment.command';
import { CommandResult } from './commands/command-result'; import { CommandResult } from './commands/command-result';
@@ -12,7 +13,7 @@ import { GetCommentsQuery } from './queries/get-comments.query';
@Injectable() @Injectable()
export class CommentsService { export class CommentsService {
constructor( constructor(
private readonly commandBus: CommandBus, private readonly commands: CommandRunner,
private readonly queryBus: QueryBus, private readonly queryBus: QueryBus,
) {} ) {}
@@ -22,7 +23,7 @@ export class CommentsService {
body: string, body: string,
replyTo: string | null = null, replyTo: string | null = null,
): Promise<CommandResult> { ): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new AddCommentCommand(actorId, issueId, body, replyTo), new AddCommentCommand(actorId, issueId, body, replyTo),
); );
} }
@@ -32,7 +33,7 @@ export class CommentsService {
commentId: string, commentId: string,
body: string, body: string,
): Promise<CommandResult> { ): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new EditCommentCommand(actorId, commentId, body), new EditCommentCommand(actorId, commentId, body),
); );
} }
@@ -44,15 +45,13 @@ export class CommentsService {
emoji: string, emoji: string,
on: boolean, on: boolean,
): Promise<CommandResult> { ): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new ReactToCommentCommand(actorId, commentId, emoji, on), new ReactToCommentCommand(actorId, commentId, emoji, on),
); );
} }
delete(actorId: string, commentId: string): Promise<CommandResult> { delete(actorId: string, commentId: string): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(new DeleteCommentCommand(actorId, commentId));
new DeleteCommentCommand(actorId, commentId),
);
} }
list( list(

View File

@@ -26,6 +26,8 @@ import {
ProjectRole, ProjectRole,
ProjectSettingsChangedEvent, ProjectSettingsChangedEvent,
ProjectUpdatedEvent, ProjectUpdatedEvent,
ProjectArchivedEvent,
ProjectUnarchivedEvent,
ProjectWorkflow, ProjectWorkflow,
ReleaseArchivedEvent, ReleaseArchivedEvent,
ReleaseNotesWrittenEvent, ReleaseNotesWrittenEvent,
@@ -77,6 +79,7 @@ const ROLE: Record<ProjectRole, string> = {
[ProjectRole.ADMIN]: 'admin', [ProjectRole.ADMIN]: 'admin',
[ProjectRole.MEMBER]: 'miembro', [ProjectRole.MEMBER]: 'miembro',
[ProjectRole.VIEWER]: 'lector', [ProjectRole.VIEWER]: 'lector',
[ProjectRole.GUEST]: 'invitado',
}; };
const FIELD: Record<string, string> = { const FIELD: Record<string, string> = {
@@ -84,6 +87,7 @@ const FIELD: Record<string, string> = {
description: 'la descripción', description: 'la descripción',
type: 'el tipo', type: 'el tipo',
area: 'el área', area: 'el área',
team: 'el equipo',
priority: 'la prioridad', priority: 'la prioridad',
labels: 'las etiquetas', labels: 'las etiquetas',
figmaUrl: 'el enlace de Figma', figmaUrl: 'el enlace de Figma',
@@ -109,6 +113,10 @@ export function describeEvent(
return `${actor} creó el proyecto ${event.project.key}`; return `${actor} creó el proyecto ${event.project.key}`;
if (event instanceof ProjectUpdatedEvent) if (event instanceof ProjectUpdatedEvent)
return `${actor} actualizó el proyecto ${ctx.projectKey}`; return `${actor} actualizó el proyecto ${ctx.projectKey}`;
if (event instanceof ProjectArchivedEvent)
return `${actor} archivó el proyecto ${ctx.projectKey}`;
if (event instanceof ProjectUnarchivedEvent)
return `${actor} desarchivó el proyecto ${ctx.projectKey}`;
if (event instanceof ProjectSettingsChangedEvent) if (event instanceof ProjectSettingsChangedEvent)
return `${actor} cambió los estados, tipos o áreas del proyecto ${ctx.projectKey}`; return `${actor} cambió los estados, tipos o áreas del proyecto ${ctx.projectKey}`;
if (event instanceof ProjectMemberAddedEvent) { if (event instanceof ProjectMemberAddedEvent) {

View File

@@ -25,6 +25,8 @@ import {
ProjectMemberRemovedEvent, ProjectMemberRemovedEvent,
ProjectMemberRoleChangedEvent, ProjectMemberRoleChangedEvent,
ProjectUpdatedEvent, ProjectUpdatedEvent,
ProjectArchivedEvent,
ProjectUnarchivedEvent,
ReleaseArchivedEvent, ReleaseArchivedEvent,
ReleaseNotesWrittenEvent, ReleaseNotesWrittenEvent,
IssueSprintChangedEvent, IssueSprintChangedEvent,
@@ -57,6 +59,8 @@ import { versionOf } from './read-model-notifier';
@EventsHandler( @EventsHandler(
ProjectCreatedEvent, ProjectCreatedEvent,
ProjectUpdatedEvent, ProjectUpdatedEvent,
ProjectArchivedEvent,
ProjectUnarchivedEvent,
ProjectSettingsChangedEvent, ProjectSettingsChangedEvent,
ProjectMemberAddedEvent, ProjectMemberAddedEvent,
ProjectMemberRemovedEvent, ProjectMemberRemovedEvent,

View File

@@ -16,6 +16,7 @@ import { ProjectMemberAddedEventHandler } from './project-member-added.event-han
import { ProjectMemberRemovedEventHandler } from './project-member-removed.event-handler'; import { ProjectMemberRemovedEventHandler } from './project-member-removed.event-handler';
import { ProjectMemberRoleChangedEventHandler } from './project-member-role-changed.event-handler'; import { ProjectMemberRoleChangedEventHandler } from './project-member-role-changed.event-handler';
import { ProjectUpdatedEventHandler } from './project-updated.event-handler'; import { ProjectUpdatedEventHandler } from './project-updated.event-handler';
import { ProjectArchivedEventHandler } from './project-archived.event-handler';
import { ReleaseArchivedEventHandler } from './release-archived.event-handler'; import { ReleaseArchivedEventHandler } from './release-archived.event-handler';
import { ReleaseCreatedEventHandler } from './release-created.event-handler'; import { ReleaseCreatedEventHandler } from './release-created.event-handler';
import { ReleaseShippedEventHandler } from './release-shipped.event-handler'; import { ReleaseShippedEventHandler } from './release-shipped.event-handler';
@@ -63,6 +64,7 @@ export const EVENT_HANDLERS = [
ProjectMemberRemovedEventHandler, ProjectMemberRemovedEventHandler,
ProjectMemberRoleChangedEventHandler, ProjectMemberRoleChangedEventHandler,
ProjectUpdatedEventHandler, ProjectUpdatedEventHandler,
ProjectArchivedEventHandler,
ProjectSettingsChangedEventHandler, ProjectSettingsChangedEventHandler,
ReleaseArchivedEventHandler, ReleaseArchivedEventHandler,
ReleaseCreatedEventHandler, ReleaseCreatedEventHandler,

View File

@@ -41,6 +41,7 @@ export class IssueCreatedEventHandler
key: issue.key, key: issue.key,
type: issue.type, type: issue.type,
area: issue.area, area: issue.area,
team: issue.team ?? null,
title: issue.title, title: issue.title,
description: issue.description, description: issue.description,
status: issue.status, status: issue.status,

View File

@@ -0,0 +1,28 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import {
ProjectArchivedEvent,
ProjectUnarchivedEvent,
} from '@task-stream/api/tracker/domain';
import { UpsertMaterializedProjectRepository } from '../ports/upsert-materialized-project.repository';
import { ReadModelNotifier, versionOf } from './read-model-notifier';
@EventsHandler(ProjectArchivedEvent, ProjectUnarchivedEvent)
export class ProjectArchivedEventHandler
implements IEventHandler<ProjectArchivedEvent | ProjectUnarchivedEvent>
{
constructor(
private readonly upsertMaterializedProjectRepository: UpsertMaterializedProjectRepository,
private readonly notifier: ReadModelNotifier,
) {}
async handle(event: ProjectArchivedEvent | ProjectUnarchivedEvent) {
await this.upsertMaterializedProjectRepository.upsert({
id: event.projectId,
archivedAt:
event instanceof ProjectArchivedEvent ? event.occurredAt : null,
updatedAt: event.occurredAt,
version: versionOf(event),
});
await this.notifier.project(event);
}
}

View File

@@ -1,4 +1,5 @@
import { CommandBus, QueryBus } from '@nestjs/cqrs'; import { QueryBus } from '@nestjs/cqrs';
import { CommandRunner } from '@task-stream/api/shared/application';
import { ForbiddenError } from '@task-stream/api/shared/domain'; import { ForbiddenError } from '@task-stream/api/shared/domain';
import { createHmac } from 'node:crypto'; import { createHmac } from 'node:crypto';
import { RecordGitMentionsCommand } from './git/git'; import { RecordGitMentionsCommand } from './git/git';
@@ -28,7 +29,7 @@ describe('GitService', () => {
executed.push(command); executed.push(command);
return 1; return 1;
}, },
} as unknown as CommandBus, } as unknown as CommandRunner,
{} as QueryBus, {} as QueryBus,
{ {
findById: async (id: string) => (id === 'p1' ? { id: 'p1' } : null), findById: async (id: string) => (id === 'p1' ? { id: 'p1' } : null),

View File

@@ -1,5 +1,6 @@
import { CommandRunner } from '@task-stream/api/shared/application';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs'; import { QueryBus } from '@nestjs/cqrs';
import { ForbiddenError } from '@task-stream/api/shared/domain'; import { ForbiddenError } from '@task-stream/api/shared/domain';
import { import {
AddGitRepositoryCommand, AddGitRepositoryCommand,
@@ -30,7 +31,7 @@ export interface GitDelivery {
@Injectable() @Injectable()
export class GitService { export class GitService {
constructor( constructor(
private readonly commandBus: CommandBus, private readonly commands: CommandRunner,
private readonly queryBus: QueryBus, private readonly queryBus: QueryBus,
private readonly findProjects: FindProjectsRepository, private readonly findProjects: FindProjectsRepository,
private readonly integrations: GitIntegrationsRepository, private readonly integrations: GitIntegrationsRepository,
@@ -47,7 +48,7 @@ export class GitService {
projectId: string, projectId: string,
name: string, name: string,
): Promise<GitRepository> { ): Promise<GitRepository> {
return this.commandBus.execute( return this.commands.execute(
new AddGitRepositoryCommand(actorId, projectId, name), new AddGitRepositoryCommand(actorId, projectId, name),
); );
} }
@@ -57,7 +58,7 @@ export class GitService {
projectId: string, projectId: string,
repositoryId: string, repositoryId: string,
): Promise<GitRepository> { ): Promise<GitRepository> {
return this.commandBus.execute( return this.commands.execute(
new RotateGitRepositorySecretCommand(actorId, projectId, repositoryId), new RotateGitRepositorySecretCommand(actorId, projectId, repositoryId),
); );
} }
@@ -67,7 +68,7 @@ export class GitService {
projectId: string, projectId: string,
repositoryId: string, repositoryId: string,
): Promise<void> { ): Promise<void> {
return this.commandBus.execute( return this.commands.execute(
new RemoveGitRepositoryCommand(actorId, projectId, repositoryId), new RemoveGitRepositoryCommand(actorId, projectId, repositoryId),
); );
} }
@@ -104,7 +105,7 @@ export class GitService {
delivery.body, delivery.body,
); );
if (!mentions.length) return { recorded: 0 }; if (!mentions.length) return { recorded: 0 };
const recorded: number = await this.commandBus.execute( const recorded: number = await this.commands.execute(
new RecordGitMentionsCommand(project.id, repository.name, mentions), new RecordGitMentionsCommand(project.id, repository.name, mentions),
); );
return { recorded }; return { recorded };

View File

@@ -1,5 +1,6 @@
import { CommandRunner } from '@task-stream/api/shared/application';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs'; import { QueryBus } from '@nestjs/cqrs';
import { assertThat } from '@task-stream/api/shared/domain'; import { assertThat } from '@task-stream/api/shared/domain';
import { import {
ActivityReadModel, ActivityReadModel,
@@ -79,7 +80,7 @@ export interface IssueLinkInput {
@Injectable() @Injectable()
export class IssuesService { export class IssuesService {
constructor( constructor(
private readonly commandBus: CommandBus, private readonly commands: CommandRunner,
private readonly queryBus: QueryBus, private readonly queryBus: QueryBus,
private readonly access: WriteAccess, private readonly access: WriteAccess,
private readonly findIssues: FindIssuesRepository, private readonly findIssues: FindIssuesRepository,
@@ -97,7 +98,7 @@ export class IssuesService {
useTemplate, useTemplate,
...details ...details
} = input; } = input;
return this.commandBus.execute( return this.commands.execute(
new CreateIssueCommand( new CreateIssueCommand(
actorId, actorId,
projectId, projectId,
@@ -118,7 +119,7 @@ export class IssuesService {
issueId: string, issueId: string,
changes: IssueDetails, changes: IssueDetails,
): Promise<CommandResult> { ): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new UpdateIssueCommand(actorId, issueId, changes), new UpdateIssueCommand(actorId, issueId, changes),
); );
} }
@@ -129,7 +130,7 @@ export class IssuesService {
status: string, status: string,
reason?: string | null, reason?: string | null,
): Promise<CommandResult> { ): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new ChangeIssueStatusCommand(actorId, issueId, status, reason), new ChangeIssueStatusCommand(actorId, issueId, status, reason),
); );
} }
@@ -140,13 +141,13 @@ export class IssuesService {
issueId: string, issueId: string,
assigneeId: string | null, assigneeId: string | null,
): Promise<CommandResult> { ): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new AssignIssueCommand(actorId, issueId, assigneeId), new AssignIssueCommand(actorId, issueId, assigneeId),
); );
} }
link(actorId: string, input: IssueLinkInput): Promise<CommandResult> { link(actorId: string, input: IssueLinkInput): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new LinkIssuesCommand( new LinkIssuesCommand(
actorId, actorId,
input.issueId, input.issueId,
@@ -157,7 +158,7 @@ export class IssuesService {
} }
unlink(actorId: string, input: IssueLinkInput): Promise<CommandResult> { unlink(actorId: string, input: IssueLinkInput): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new UnlinkIssuesCommand( new UnlinkIssuesCommand(
actorId, actorId,
input.issueId, input.issueId,
@@ -173,7 +174,7 @@ export class IssuesService {
issueId: string, issueId: string,
releaseId: string | null, releaseId: string | null,
): Promise<CommandResult> { ): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new SetIssueReleaseCommand(actorId, issueId, releaseId), new SetIssueReleaseCommand(actorId, issueId, releaseId),
); );
} }
@@ -185,7 +186,7 @@ export class IssuesService {
file: IncomingFile, file: IncomingFile,
commentId: string | null = null, commentId: string | null = null,
): Promise<AttachmentResult> { ): Promise<AttachmentResult> {
return this.commandBus.execute( return this.commands.execute(
new AttachFileCommand(actorId, issueId, file, commentId), new AttachFileCommand(actorId, issueId, file, commentId),
); );
} }
@@ -195,7 +196,7 @@ export class IssuesService {
issueId: string, issueId: string,
attachmentId: string, attachmentId: string,
): Promise<CommandResult> { ): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new DetachFileCommand(actorId, issueId, attachmentId), new DetachFileCommand(actorId, issueId, attachmentId),
); );
} }
@@ -206,7 +207,7 @@ export class IssuesService {
issueId: string, issueId: string,
watching: boolean, watching: boolean,
): Promise<CommandResult> { ): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new WatchIssueCommand(actorId, issueId, actorId, watching), new WatchIssueCommand(actorId, issueId, actorId, watching),
); );
} }
@@ -236,7 +237,7 @@ export class IssuesService {
issueId: string, issueId: string,
parentId: string | null, parentId: string | null,
): Promise<CommandResult> { ): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new SetIssueParentCommand(actorId, issueId, parentId), new SetIssueParentCommand(actorId, issueId, parentId),
); );
} }
@@ -248,7 +249,7 @@ export class IssuesService {
above: string | null, above: string | null,
below: string | null, below: string | null,
): Promise<CommandResult> { ): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new RankIssueCommand(actorId, issueId, above, below), new RankIssueCommand(actorId, issueId, above, below),
); );
} }
@@ -278,7 +279,7 @@ export class IssuesService {
if (change.assigneeId !== undefined) if (change.assigneeId !== undefined)
await this.assign(actorId, issueId, change.assigneeId); await this.assign(actorId, issueId, change.assigneeId);
if (change.sprintId !== undefined) if (change.sprintId !== undefined)
await this.commandBus.execute( await this.commands.execute(
new SetIssueSprintCommand(actorId, issueId, change.sprintId), new SetIssueSprintCommand(actorId, issueId, change.sprintId),
); );
if (change.releaseId !== undefined) if (change.releaseId !== undefined)
@@ -306,12 +307,12 @@ export class IssuesService {
/** To the trash (whoever opened it, or a manager); back with `restore`. */ /** To the trash (whoever opened it, or a manager); back with `restore`. */
trash(actorId: string, issueId: string): Promise<CommandResult> { trash(actorId: string, issueId: string): Promise<CommandResult> {
return this.commandBus.execute(new TrashIssueCommand(actorId, issueId)); return this.commands.execute(new TrashIssueCommand(actorId, issueId));
} }
/** Deletes it for good (the trash is emptied this way). */ /** Deletes it for good (the trash is emptied this way). */
delete(actorId: string, issueId: string): Promise<void> { delete(actorId: string, issueId: string): Promise<void> {
return this.commandBus.execute(new DeleteIssueCommand(actorId, issueId)); return this.commands.execute(new DeleteIssueCommand(actorId, issueId));
} }
/** /**
@@ -326,12 +327,12 @@ export class IssuesService {
} }
restore(actorId: string, issueId: string): Promise<CommandResult> { restore(actorId: string, issueId: string): Promise<CommandResult> {
return this.commandBus.execute(new RestoreIssueCommand(actorId, issueId)); return this.commands.execute(new RestoreIssueCommand(actorId, issueId));
} }
/** Votes for the ticket (`on`) or takes the vote back. */ /** Votes for the ticket (`on`) or takes the vote back. */
vote(actorId: string, issueId: string, on: boolean): Promise<CommandResult> { vote(actorId: string, issueId: string, on: boolean): Promise<CommandResult> {
return this.commandBus.execute(new VoteIssueCommand(actorId, issueId, on)); return this.commands.execute(new VoteIssueCommand(actorId, issueId, on));
} }
/** /**
@@ -349,6 +350,7 @@ export class IssuesService {
title: `${issue.title} (copia)`.slice(0, 255), title: `${issue.title} (copia)`.slice(0, 255),
description: issue.description, description: issue.description,
area: issue.area, area: issue.area,
team: issue.team,
priority: issue.priority, priority: issue.priority,
labels: issue.labels, labels: issue.labels,
figmaUrl: issue.figmaUrl, figmaUrl: issue.figmaUrl,
@@ -378,7 +380,7 @@ export class IssuesService {
issueId: string, issueId: string,
text: string, text: string,
): Promise<CommandResult> { ): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new AddChecklistItemCommand(actorId, issueId, text), new AddChecklistItemCommand(actorId, issueId, text),
); );
} }
@@ -389,7 +391,7 @@ export class IssuesService {
itemId: string, itemId: string,
changes: { text?: string; done?: boolean }, changes: { text?: string; done?: boolean },
): Promise<CommandResult> { ): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new UpdateChecklistItemCommand(actorId, issueId, itemId, changes), new UpdateChecklistItemCommand(actorId, issueId, itemId, changes),
); );
} }
@@ -399,7 +401,7 @@ export class IssuesService {
issueId: string, issueId: string,
itemId: string, itemId: string,
): Promise<CommandResult> { ): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new RemoveChecklistItemCommand(actorId, issueId, itemId), new RemoveChecklistItemCommand(actorId, issueId, itemId),
); );
} }

View File

@@ -1,5 +1,6 @@
import { CommandRunner } from '@task-stream/api/shared/application';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs'; import { QueryBus } from '@nestjs/cqrs';
import { NotificationReadModel } from '@task-stream/api/tracker/domain'; import { NotificationReadModel } from '@task-stream/api/tracker/domain';
import { SendDueRemindersCommand } from './notifications/reminders'; import { SendDueRemindersCommand } from './notifications/reminders';
import { import {
@@ -20,7 +21,7 @@ import { ChangeFeed } from './ports/change-feed';
@Injectable() @Injectable()
export class NotificationsService { export class NotificationsService {
constructor( constructor(
private readonly commandBus: CommandBus, private readonly commands: CommandRunner,
private readonly queryBus: QueryBus, private readonly queryBus: QueryBus,
private readonly changeFeed: ChangeFeed, private readonly changeFeed: ChangeFeed,
) {} ) {}
@@ -45,25 +46,23 @@ export class NotificationsService {
endpoint: string, endpoint: string,
keys: { p256dh: string; auth: string }, keys: { p256dh: string; auth: string },
): Promise<void> { ): Promise<void> {
return this.commandBus.execute( return this.commands.execute(
new SubscribePushCommand(userId, endpoint, keys), new SubscribePushCommand(userId, endpoint, keys),
); );
} }
/** Push off on this device. */ /** Push off on this device. */
unsubscribePush(userId: string, endpoint: string): Promise<void> { unsubscribePush(userId: string, endpoint: string): Promise<void> {
return this.commandBus.execute( return this.commands.execute(new UnsubscribePushCommand(userId, endpoint));
new UnsubscribePushCommand(userId, endpoint),
);
} }
/** Tells of the tickets due around `day` (YYYY-MM-DD); how many it sent. */ /** Tells of the tickets due around `day` (YYYY-MM-DD); how many it sent. */
sendDueReminders(day: string): Promise<number> { sendDueReminders(day: string): Promise<number> {
return this.commandBus.execute(new SendDueRemindersCommand(day)); return this.commands.execute(new SendDueRemindersCommand(day));
} }
markRead(userId: string, ids?: string[] | null): Promise<void> { markRead(userId: string, ids?: string[] | null): Promise<void> {
return this.commandBus.execute( return this.commands.execute(
new MarkNotificationsReadCommand(userId, ids?.length ? ids : null), new MarkNotificationsReadCommand(userId, ids?.length ? ids : null),
); );
} }
@@ -76,7 +75,7 @@ export class NotificationsService {
userId: string, userId: string,
changes: { email: boolean }, changes: { email: boolean },
): Promise<MyNotificationSettings> { ): Promise<MyNotificationSettings> {
await this.commandBus.execute( await this.commands.execute(
new UpdateNotificationSettingsCommand(userId, changes.email), new UpdateNotificationSettingsCommand(userId, changes.email),
); );
return this.settings(userId); return this.settings(userId);

View File

@@ -8,6 +8,9 @@ import {
IssueStatusChangedEvent, IssueStatusChangedEvent,
IssueUpdatedEvent, IssueUpdatedEvent,
mentionedUserIds, mentionedUserIds,
DEFAULT_PROJECT_SETTINGS,
ProjectWorkflow,
TEAM_MENTION_PREFIX,
NotificationKind, NotificationKind,
ProjectMemberAddedEvent, ProjectMemberAddedEvent,
ProjectRole, ProjectRole,
@@ -195,7 +198,12 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
if (event instanceof CommentAddedEvent) { if (event instanceof CommentAddedEvent) {
const said = `«${excerpt(event.comment.body)}»`; const said = `«${excerpt(event.comment.body)}»`;
// Only members: a mention cannot reach someone who cannot read the ticket. // Only members: a mention cannot reach someone who cannot read the ticket.
for (const userId of mentionedUserIds(event.comment.body)) { // A team's mention (`team-FRONTEND`) reaches each of its members.
const workflow = new ProjectWorkflow(
project.settings ?? DEFAULT_PROJECT_SETTINGS,
);
const named = mentionedUserIds(event.comment.body);
for (const userId of named) {
if (!memberIds.has(userId)) continue; if (!memberIds.has(userId)) continue;
drafts.push({ drafts.push({
userId, userId,
@@ -203,6 +211,19 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
summary: `${actor} te mencionó en ${issue.key}: ${said}`, summary: `${actor} te mencionó en ${issue.key}: ${said}`,
}); });
} }
for (const id of named.filter((n) => n.startsWith(TEAM_MENTION_PREFIX))) {
const team = workflow.teams.find(
(t) => t.id === id.slice(TEAM_MENTION_PREFIX.length),
);
for (const userId of workflow.mentionedPeople([id])) {
if (!memberIds.has(userId)) continue;
drafts.push({
userId,
kind: NotificationKind.MENTIONED,
summary: `${actor} mencionó a tu equipo ${team?.name ?? ''} en ${issue.key}: ${said}`,
});
}
}
const answered = event.comment.replyTo const answered = event.comment.replyTo
? await this.findComments.findById(event.comment.replyTo) ? await this.findComments.findById(event.comment.replyTo)
: null; : null;

View File

@@ -8,4 +8,14 @@ export abstract class FindActivityRepository {
/** ISO date cursor: only activity strictly older than this. */ /** ISO date cursor: only activity strictly older than this. */
before?: string; before?: string;
}): Promise<ActivityReadModel[]>; }): Promise<ActivityReadModel[]>;
/** The latest of several projects, newest first. */
abstract findAcross(
projectIds: string[],
limit: number,
): Promise<ActivityReadModel[]>;
/** How much happened per project and day (`YYYY-MM-DD`) since `fromDay`. */
abstract dailyCounts(
projectIds: string[],
fromDay: string,
): Promise<{ projectId: string; day: string; count: number }[]>;
} }

View File

@@ -20,6 +20,8 @@ export interface IssueFilter {
categories?: StatusCategory[]; categories?: StatusCategory[];
types?: string[]; types?: string[];
areas?: string[]; areas?: string[];
/** Team ids of the project's settings. */
teams?: string[];
priorities?: IssuePriority[]; priorities?: IssuePriority[];
assigneeId?: string; assigneeId?: string;
unassigned?: boolean; unassigned?: boolean;
@@ -33,12 +35,20 @@ export interface IssueFilter {
dueTo?: string; dueTo?: string;
/** Only tickets in no sprint (wins over `sprintId`). */ /** Only tickets in no sprint (wins over `sprintId`). */
noSprint?: boolean; noSprint?: boolean;
/** Only tickets nobody estimated. */
noEstimate?: boolean;
/** Only tickets with no area. */
noArea?: boolean;
/** Only tickets not updated since this instant (ISO). */
updatedBefore?: string;
/** Custom field id → the value it must hold. */ /** Custom field id → the value it must hold. */
customFields?: Record<string, string>; customFields?: Record<string, string>;
/** Only the ones the viewer follows (the query handler turns it into `watcherId`). */ /** Only the ones the viewer follows (the query handler turns it into `watcherId`). */
watching?: boolean; watching?: boolean;
/** Only the ones this person follows. */ /** Only the ones this person follows. */
watcherId?: string; watcherId?: string;
/** Only the ones this person opened, has or follows (a guest's view). */
participantId?: string;
/** RECENT (default): last updated first. RANK: backlog order. */ /** RECENT (default): last updated first. RANK: backlog order. */
sort?: IssueSort; sort?: IssueSort;
search?: string; search?: string;
@@ -54,6 +64,14 @@ export interface CrossProjectIssueFilter {
watcherId?: string; watcherId?: string;
statuses?: string[]; statuses?: string[];
categories?: StatusCategory[]; categories?: StatusCategory[];
priorities?: IssuePriority[];
/** Of these, only the tickets `participantId` opened, has or follows. */
guestProjectIds?: string[];
participantId?: string;
/** Project id → the only type ids taken there. */
typesByProject?: Record<string, string[]>;
/** Due before this day (`YYYY-MM-DD`, excluded). */
dueBefore?: string;
search?: string; search?: string;
limit: number; limit: number;
offset: number; offset: number;

View File

@@ -0,0 +1,11 @@
import { MyViewReadModel } from '@task-stream/api/tracker/domain';
/** Saved views of «my work»: one person's preferences, no history. */
export abstract class MyViewsRepository {
/** By name. */
abstract findByOwner(ownerId: string): Promise<MyViewReadModel[]>;
abstract findById(id: string): Promise<MyViewReadModel | null>;
abstract countByOwner(ownerId: string): Promise<number>;
abstract insert(view: MyViewReadModel): Promise<void>;
abstract delete(id: string): Promise<void>;
}

View File

@@ -0,0 +1,36 @@
/** Someone on a ticket's page just now. */
export interface PresenceEntry {
user: { id: string; name: string };
/** Editing the ticket or writing a comment, not only looking. */
editing: boolean;
/** Last heartbeat (ISO). */
at: string;
/** The ticket, in the project-wide list (who is on what). */
issue?: { key: string; title: string };
}
/**
* Who is on each ticket's page, shared by every instance of the API.
* Entries fade on their own: a page that stops beating is gone.
*/
export abstract class PresenceStore {
/** Records (or refreshes) one person on a ticket; lasts `ttlMs`. */
abstract touch(
issueId: string,
entry: PresenceEntry,
ttlMs: number,
): Promise<void>;
/** Everyone whose heartbeat is newer than `since` (ms epoch). */
abstract list(issueId: string, since: number): Promise<PresenceEntry[]>;
abstract leave(issueId: string, userId: string): Promise<void>;
/** The same, per project: who is on which of its tickets. */
abstract touchProject(
projectId: string,
entry: PresenceEntry,
ttlMs: number,
): Promise<void>;
abstract listProject(
projectId: string,
since: number,
): Promise<PresenceEntry[]>;
}

View File

@@ -0,0 +1,74 @@
import { ForbiddenError, NotFoundError } from '@task-stream/api/shared/domain';
import { IssueReadModel } from '@task-stream/api/tracker/domain';
import { FindIssuesRepository } from './ports/find-issues.repository';
import { PresenceEntry, PresenceStore } from './ports/presence.store';
import { PRESENCE_TTL_MS, PresenceService } from './presence.service';
import { ReadAccess } from './queries/read-access';
class MemoryPresence extends PresenceStore {
readonly entries = new Map<string, Map<string, PresenceEntry>>();
async touch(issueId: string, entry: PresenceEntry) {
const here = this.entries.get(issueId) ?? new Map();
here.set(entry.user.id, entry);
this.entries.set(issueId, here);
}
async list(issueId: string, since: number) {
return [...(this.entries.get(issueId)?.values() ?? [])].filter(
(e) => Date.parse(e.at) >= since,
);
}
async leave(issueId: string, userId: string) {
this.entries.get(issueId)?.delete(userId);
}
async touchProject() {
/* not needed here */
}
async listProject() {
return [];
}
}
const issues = {
findById: async (id: string) =>
id === 'i1'
? ({ id: 'i1', projectId: 'p1', trashedAt: null } as IssueReadModel)
: null,
} as unknown as FindIssuesRepository;
const access = {
assertCanViewIssue: async (_issue: IssueReadModel, viewerId: string) => {
if (viewerId === 'eve') throw new ForbiddenError('No');
},
} as unknown as ReadAccess;
describe('PresenceService', () => {
it('tells who else is on a ticket, the ones editing first', async () => {
const store = new MemoryPresence();
const presence = new PresenceService(store, issues, access);
await presence.touch({ id: 'ana', name: 'Ana' }, 'i1', false);
await presence.touch({ id: 'cris', name: 'Cris' }, 'i1', true);
const seen = await presence.touch({ id: 'bob', name: 'Bob' }, 'i1', false);
expect(seen.map((p) => [p.user.name, p.editing])).toEqual([
['Cris', true],
['Ana', false],
]);
// A page that stops beating fades out; leaving takes one out at once.
store.entries.get('i1')!.get('ana')!.at = new Date(
Date.now() - PRESENCE_TTL_MS - 1000,
).toISOString();
await presence.leave('cris', 'i1');
expect(
await presence.touch({ id: 'bob', name: 'Bob' }, 'i1', false),
).toEqual([]);
});
it('only for whoever can read the ticket', async () => {
const presence = new PresenceService(new MemoryPresence(), issues, access);
await expect(
presence.touch({ id: 'eve', name: 'Eve' }, 'i1', false),
).rejects.toBeInstanceOf(ForbiddenError);
await expect(
presence.touch({ id: 'ana', name: 'Ana' }, 'nope', false),
).rejects.toBeInstanceOf(NotFoundError);
});
});

View File

@@ -0,0 +1,68 @@
import { Injectable } from '@nestjs/common';
import { NotFoundError } from '@task-stream/api/shared/domain';
import { FindIssuesRepository } from './ports/find-issues.repository';
import { PresenceEntry, PresenceStore } from './ports/presence.store';
import { ReadAccess } from './queries/read-access';
/** A page beats every 10 s; three missed beats and it is gone. */
export const PRESENCE_TTL_MS = 30_000;
/**
* Who else is on a ticket right now, and whether they are editing it. Not
* history: kept in Redis for a few seconds, never in the event store.
*/
@Injectable()
export class PresenceService {
constructor(
private readonly store: PresenceStore,
private readonly findIssues: FindIssuesRepository,
private readonly access: ReadAccess,
) {}
/**
* I am on the ticket (editing or not): answers who else is there, the
* ones editing first. Only for people who can read it.
*/
async touch(
viewer: { id: string; name: string },
issueId: string,
editing: boolean,
): Promise<PresenceEntry[]> {
const issue = await this.assertCanView(viewer.id, issueId);
const now = Date.now();
const entry = {
user: { id: viewer.id, name: viewer.name },
editing,
at: new Date(now).toISOString(),
};
await Promise.all([
this.store.touch(issueId, entry, PRESENCE_TTL_MS),
// The project-wide list says on which ticket (home's «who is here»).
this.store.touchProject(
issue.projectId,
{ ...entry, issue: { key: issue.key, title: issue.title } },
PRESENCE_TTL_MS,
),
]);
const here = await this.store.list(issueId, now - PRESENCE_TTL_MS);
return here
.filter((entry) => entry.user.id !== viewer.id)
.sort(
(a, b) =>
Number(b.editing) - Number(a.editing) ||
a.user.name.localeCompare(b.user.name, 'es'),
);
}
/** I left the ticket's page. */
leave(viewerId: string, issueId: string): Promise<void> {
return this.store.leave(issueId, viewerId);
}
private async assertCanView(viewerId: string, issueId: string) {
const issue = await this.findIssues.findById(issueId);
if (!issue || issue.trashedAt) throw new NotFoundError('Issue', issueId);
await this.access.assertCanViewIssue(issue, viewerId);
return issue;
}
}

View File

@@ -1,14 +1,20 @@
import { GetShowcaseQuery, Showcase } from './queries/get-showcase.query'; import { GetShowcaseQuery, Showcase } from './queries/get-showcase.query';
import { CommandRunner } from '@task-stream/api/shared/application';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs'; import { QueryBus } from '@nestjs/cqrs';
import { import {
BusinessRuleError,
ForbiddenError, ForbiddenError,
NotFoundError, NotFoundError,
ValidationError,
assertThat, assertThat,
} from '@task-stream/api/shared/domain'; } from '@task-stream/api/shared/domain';
import { import {
IssuePriority, IssuePriority,
ProjectAnalyticsReadModel, ProjectAnalyticsReadModel,
ProjectInsightsReadModel,
PortfolioProjectReadModel,
HomeReadModel,
ProjectReadModel, ProjectReadModel,
ProjectRole, ProjectRole,
ProjectSettings, ProjectSettings,
@@ -25,6 +31,7 @@ import { ChangeProjectMemberRoleCommand } from './commands/change-project-member
import { CommandResult } from './commands/command-result'; import { CommandResult } from './commands/command-result';
import { CreateProjectCommand } from './commands/create-project.command'; import { CreateProjectCommand } from './commands/create-project.command';
import { DeleteProjectCommand } from './commands/delete-project.command'; import { DeleteProjectCommand } from './commands/delete-project.command';
import { ArchiveProjectCommand } from './commands/archive-project.command';
import { RemoveProjectMemberCommand } from './commands/remove-project-member.command'; import { RemoveProjectMemberCommand } from './commands/remove-project-member.command';
import { UpdateProjectCommand } from './commands/update-project.command'; import { UpdateProjectCommand } from './commands/update-project.command';
import { UpdateProjectSettingsCommand } from './commands/update-project-settings.command'; import { UpdateProjectSettingsCommand } from './commands/update-project-settings.command';
@@ -32,15 +39,24 @@ import { IssueFilter } from './ports/find-issues.repository';
import { BoardColumn, GetBoardQuery } from './queries/get-board.query'; import { BoardColumn, GetBoardQuery } from './queries/get-board.query';
import { GetMyProjectsQuery } from './queries/get-my-projects.query'; import { GetMyProjectsQuery } from './queries/get-my-projects.query';
import { GetProjectAnalyticsQuery } from './queries/get-project-analytics.query'; import { GetProjectAnalyticsQuery } from './queries/get-project-analytics.query';
import { GetProjectInsightsQuery } from './queries/get-project-insights.query';
import { GetPortfolioQuery } from './queries/get-portfolio.query';
import { GetMyHomeQuery } from './queries/get-my-home.query';
import { GetProjectStatsQuery } from './queries/get-project-stats.query'; import { GetProjectStatsQuery } from './queries/get-project-stats.query';
import { GetPublicProjectsQuery } from './queries/get-public-projects.query'; import { GetPublicProjectsQuery } from './queries/get-public-projects.query';
import { GetProjectQuery } from './queries/get-project.query'; import { GetProjectQuery } from './queries/get-project.query';
import { GetReleasesQuery } from './queries/get-releases.query'; import { GetReleasesQuery } from './queries/get-releases.query';
import {
CreateAutomationCommand,
GetAutomationsQuery,
UpdateAutomationCommand,
} from './automations/automations';
import { Automation } from './ports/automations.repository';
@Injectable() @Injectable()
export class ProjectsService { export class ProjectsService {
constructor( constructor(
private readonly commandBus: CommandBus, private readonly commands: CommandRunner,
private readonly queryBus: QueryBus, private readonly queryBus: QueryBus,
private readonly findProjects: FindProjectsRepository, private readonly findProjects: FindProjectsRepository,
private readonly access: WriteAccess, private readonly access: WriteAccess,
@@ -64,7 +80,7 @@ export class ProjectsService {
(await this.findProjects.findById(projectIdOrKey)) ?? (await this.findProjects.findById(projectIdOrKey)) ??
(await this.findProjects.findByKey(projectIdOrKey.toUpperCase())); (await this.findProjects.findByKey(projectIdOrKey.toUpperCase()));
if (!found?.isPublic) throw new NotFoundError('Project', projectIdOrKey); if (!found?.isPublic) throw new NotFoundError('Project', projectIdOrKey);
if (!found.acceptsProposals) if (!found.acceptsProposals || found.archivedAt)
throw new ForbiddenError(`${found.key} does not take proposals`); throw new ForbiddenError(`${found.key} does not take proposals`);
const cleanTitle = title.trim(); const cleanTitle = title.trim();
@@ -82,7 +98,7 @@ export class ProjectsService {
const signer = author?.trim().slice(0, 80); const signer = author?.trim().slice(0, 80);
const signature = `_Propuesta enviada desde el formulario público${signer ? ` por ${signer}` : ''}._`; const signature = `_Propuesta enviada desde el formulario público${signer ? ` por ${signer}` : ''}._`;
return this.commandBus.execute( return this.commands.execute(
new CreateIssueCommand( new CreateIssueCommand(
SYSTEM_ACTOR_ID, SYSTEM_ACTOR_ID,
project.id, project.id,
@@ -108,16 +124,28 @@ export class ProjectsService {
return this.queryBus.execute(new GetShowcaseQuery(project)); return this.queryBus.execute(new GetShowcaseQuery(project));
} }
create( /**
* A new project. With `templateId`, it starts with a copy of that
* project's configuration (statuses, transitions, types, areas and custom
* fields) and automation rules, taken once: later changes to either one
* stay there. Only from a project the actor manages (rules are only shown
* to managers); a rule that names someone who is not in the new project
* is left out.
*/
async create(
actorId: string, actorId: string,
input: { input: {
key: string; key: string;
name: string; name: string;
description?: string | null; description?: string | null;
isPublic?: boolean; isPublic?: boolean;
templateId?: string | null;
}, },
): Promise<CommandResult> { ): Promise<CommandResult> {
return this.commandBus.execute( const template = input.templateId
? await this.template(actorId, input.templateId)
: null;
const created: CommandResult = await this.commands.execute(
new CreateProjectCommand( new CreateProjectCommand(
actorId, actorId,
input.key, input.key,
@@ -126,6 +154,62 @@ export class ProjectsService {
input.isPublic ?? false, input.isPublic ?? false,
), ),
); );
if (!template) return created;
const configured: CommandResult = await this.commands.execute(
new UpdateProjectSettingsCommand(
actorId,
created.id,
structuredClone(template.settings),
),
);
for (const rule of template.automations)
await this.copyRule(actorId, created.id, rule);
return { ...configured, key: created.key };
}
private async template(
actorId: string,
projectId: string,
): Promise<{
settings: ProjectSettings;
automations: Automation[];
}> {
const project = await this.access.project(projectId);
// Copying only reads it: an archived project makes a fine template.
project.assertManages(actorId);
const automations: Automation[] = await this.queryBus.execute(
new GetAutomationsQuery(actorId, projectId),
);
return { settings: project.settings, automations };
}
private async copyRule(
actorId: string,
projectId: string,
rule: Automation,
): Promise<void> {
try {
const copy: Automation = await this.commands.execute(
new CreateAutomationCommand(actorId, projectId, {
name: rule.name,
trigger: rule.trigger,
// Stored without conditions when it had none.
conditions: rule.conditions ?? {},
action: rule.action,
}),
);
if (!rule.active)
await this.commands.execute(
new UpdateAutomationCommand(actorId, copy.id, { active: false }),
);
} catch (error) {
// Someone it assigns to is not a member here: that rule stays behind.
if (
!(error instanceof ValidationError) &&
!(error instanceof BusinessRuleError)
)
throw error;
}
} }
update( update(
@@ -138,7 +222,7 @@ export class ProjectsService {
acceptsProposals?: boolean; acceptsProposals?: boolean;
}, },
): Promise<CommandResult> { ): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new UpdateProjectCommand(actorId, projectId, changes), new UpdateProjectCommand(actorId, projectId, changes),
); );
} }
@@ -152,7 +236,7 @@ export class ProjectsService {
projectId: string, projectId: string,
confirmKey: string, confirmKey: string,
): Promise<CommandResult> { ): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new DeleteProjectCommand(actorId, projectId, confirmKey), new DeleteProjectCommand(actorId, projectId, confirmKey),
); );
} }
@@ -163,12 +247,23 @@ export class ProjectsService {
} }
/** Statuses, transitions, ticket types and areas (owners and admins). */ /** Statuses, transitions, ticket types and areas (owners and admins). */
/** Archives the project (read-only) or brings it back; its owner only. */
setArchived(
actorId: string,
projectId: string,
archived: boolean,
): Promise<CommandResult> {
return this.commands.execute(
new ArchiveProjectCommand(actorId, projectId, archived),
);
}
updateSettings( updateSettings(
actorId: string, actorId: string,
projectId: string, projectId: string,
settings: ProjectSettings, settings: ProjectSettings,
): Promise<CommandResult> { ): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new UpdateProjectSettingsCommand(actorId, projectId, settings), new UpdateProjectSettingsCommand(actorId, projectId, settings),
); );
} }
@@ -178,7 +273,7 @@ export class ProjectsService {
input: { projectId: string; userId: string; role: ProjectRole }, input: { projectId: string; userId: string; role: ProjectRole },
accessToken?: string, accessToken?: string,
): Promise<CommandResult> { ): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new AddProjectMemberCommand( new AddProjectMemberCommand(
actorId, actorId,
input.projectId, input.projectId,
@@ -193,7 +288,7 @@ export class ProjectsService {
actorId: string, actorId: string,
input: { projectId: string; userId: string; role: ProjectRole }, input: { projectId: string; userId: string; role: ProjectRole },
): Promise<CommandResult> { ): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new ChangeProjectMemberRoleCommand( new ChangeProjectMemberRoleCommand(
actorId, actorId,
input.projectId, input.projectId,
@@ -208,7 +303,7 @@ export class ProjectsService {
projectId: string, projectId: string,
userId: string, userId: string,
): Promise<CommandResult> { ): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new RemoveProjectMemberCommand(actorId, projectId, userId), new RemoveProjectMemberCommand(actorId, projectId, userId),
); );
} }
@@ -246,6 +341,27 @@ export class ProjectsService {
} }
/** Statistics over the last `days` days (created vs resolved, flow, times, QA). */ /** Statistics over the last `days` days (created vs resolved, flow, times, QA). */
/** The home page beyond «my work». */
home(viewerId: string): Promise<HomeReadModel> {
return this.queryBus.execute(new GetMyHomeQuery(viewerId));
}
/** All my active projects at a glance (not archived, not as a guest). */
portfolio(viewerId: string): Promise<PortfolioProjectReadModel[]> {
return this.queryBus.execute(new GetPortfolioQuery(viewerId));
}
/** The dashboard's reading; with `since`, what changed after it too. */
insights(
viewerId: string | null,
projectId: string,
since: Date | null = null,
): Promise<ProjectInsightsReadModel> {
return this.queryBus.execute(
new GetProjectInsightsQuery(viewerId, projectId, since),
);
}
analytics( analytics(
viewerId: string | null, viewerId: string | null,
projectId: string, projectId: string,

View File

@@ -9,6 +9,11 @@ export class CanViewProjectQueryHandler
constructor(private readonly access: ReadAccess) {} constructor(private readonly access: ReadAccess) {}
async execute(query: CanViewProjectQuery): Promise<boolean> { async execute(query: CanViewProjectQuery): Promise<boolean> {
return this.access.canView(query.projectId, query.viewerId); if (!(await this.access.canView(query.projectId, query.viewerId)))
return false;
return (
!query.wholeProject ||
!(await this.access.participantScope(query.projectId, query.viewerId))
);
} }
} }

View File

@@ -5,6 +5,8 @@ export class CanViewProjectQuery extends Query<boolean> {
/** Null: anonymous (public projects only). */ /** Null: anonymous (public projects only). */
public readonly viewerId: string | null, public readonly viewerId: string | null,
public readonly projectId: string, public readonly projectId: string,
/** The whole project (activity, releases…), not only a guest's tickets. */
public readonly wholeProject = false,
) { ) {
super(); super();
} }

View File

@@ -1,3 +1,5 @@
import { NotFoundError } from '@task-stream/api/shared/domain';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { ActivityReadModel } from '@task-stream/api/tracker/domain'; import { ActivityReadModel } from '@task-stream/api/tracker/domain';
import { FindActivityRepository } from '../ports/find-activity.repository'; import { FindActivityRepository } from '../ports/find-activity.repository';
@@ -11,10 +13,23 @@ export class GetActivityQueryHandler
constructor( constructor(
private readonly findActivity: FindActivityRepository, private readonly findActivity: FindActivityRepository,
private readonly access: ReadAccess, private readonly access: ReadAccess,
private readonly findIssues: FindIssuesRepository,
) {} ) {}
async execute(query: GetActivityQuery): Promise<ActivityReadModel[]> { async execute(query: GetActivityQuery): Promise<ActivityReadModel[]> {
await this.access.assertCanView(query.projectId, query.viewerId); // A ticket's history goes with the ticket; the project's feed is for
// whoever sees the whole project (not a guest).
if (query.issueId) {
const issue = this.access.assertFound(
await this.findIssues.findById(query.issueId),
'Issue',
query.issueId,
);
if (issue.projectId !== query.projectId)
throw new NotFoundError('Issue', query.issueId);
await this.access.assertCanViewIssue(issue, query.viewerId);
} else
await this.access.assertSeesWholeProject(query.projectId, query.viewerId);
return this.findActivity.find({ return this.findActivity.find({
projectId: query.projectId, projectId: query.projectId,
issueId: query.issueId, issueId: query.issueId,

View File

@@ -21,7 +21,7 @@ export class GetAttachmentQueryHandler
'Attachment', 'Attachment',
query.attachmentId, query.attachmentId,
); );
await this.access.assertCanView(issue.projectId, query.viewerId, { await this.access.assertCanViewIssue(issue, query.viewerId, {
entity: 'Attachment', entity: 'Attachment',
ref: query.attachmentId, ref: query.attachmentId,
}); });

View File

@@ -27,6 +27,11 @@ export class GetBoardQueryHandler
*/ */
async execute(query: GetBoardQuery): Promise<BoardColumn[]> { async execute(query: GetBoardQuery): Promise<BoardColumn[]> {
await this.access.assertCanView(query.filter.projectId, query.viewerId); await this.access.assertCanView(query.filter.projectId, query.viewerId);
const participantId =
(await this.access.participantScope(
query.filter.projectId,
query.viewerId,
)) ?? undefined;
const project = await this.findProjects.findById(query.filter.projectId); const project = await this.findProjects.findById(query.filter.projectId);
const statuses = ( const statuses = (
project?.settings ?? DEFAULT_PROJECT_SETTINGS project?.settings ?? DEFAULT_PROJECT_SETTINGS
@@ -35,6 +40,7 @@ export class GetBoardQueryHandler
statuses.map(async (status) => { statuses.map(async (status) => {
const page = await this.findIssues.search({ const page = await this.findIssues.search({
...query.filter, ...query.filter,
participantId,
statuses: [status.id], statuses: [status.id],
// Each column follows the backlog order. // Each column follows the backlog order.
sort: IssueSort.RANK, sort: IssueSort.RANK,

View File

@@ -21,7 +21,7 @@ export class GetCommentsQueryHandler
'Issue', 'Issue',
query.issueId, query.issueId,
); );
await this.access.assertCanView(issue.projectId, query.viewerId, { await this.access.assertCanViewIssue(issue, query.viewerId, {
entity: 'Issue', entity: 'Issue',
ref: query.issueId, ref: query.issueId,
}); });

View File

@@ -14,7 +14,7 @@ export class GetEpicsQueryHandler
) {} ) {}
async execute(query: GetEpicsQuery): Promise<EpicReadModel[]> { async execute(query: GetEpicsQuery): Promise<EpicReadModel[]> {
await this.access.assertCanView(query.projectId, query.viewerId); await this.access.assertSeesWholeProject(query.projectId, query.viewerId);
return this.findIssues.epics(query.projectId); return this.findIssues.epics(query.projectId);
} }
} }

View File

@@ -18,7 +18,7 @@ export class GetIssueQueryHandler
? await this.findIssues.findById(query.idOrKey) ? await this.findIssues.findById(query.idOrKey)
: await this.findIssues.findByKey(query.idOrKey.toUpperCase()); : await this.findIssues.findByKey(query.idOrKey.toUpperCase());
const found = this.access.assertFound(issue, 'Issue', query.idOrKey); const found = this.access.assertFound(issue, 'Issue', query.idOrKey);
await this.access.assertCanView(found.projectId, query.viewerId, { await this.access.assertCanViewIssue(found, query.viewerId, {
entity: 'Issue', entity: 'Issue',
ref: query.idOrKey, ref: query.idOrKey,
}); });

View File

@@ -23,6 +23,13 @@ export class GetIssuesByIdsQueryHandler
await this.access.canView(projectId, query.viewerId), await this.access.canView(projectId, query.viewerId),
); );
} }
return issues.filter((issue) => visible.get(issue.projectId)); const shown: IssueReadModel[] = [];
for (const issue of issues)
if (
visible.get(issue.projectId) &&
(await this.access.canViewIssue(issue, query.viewerId))
)
shown.push(issue);
return shown;
} }
} }

View File

@@ -0,0 +1,113 @@
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import {
HomeReadModel,
OnlineReadModel,
} from '@task-stream/api/tracker/domain';
import { computeHome, HomeProjectInput, lastDays } from '../analytics/home';
import { CalendarFeedsRepository } from '../ports/calendar-feeds.repository';
import { FindActivityRepository } from '../ports/find-activity.repository';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { FindReleasesRepository } from '../ports/find-releases.repository';
import { FindSprintsRepository } from '../ports/find-sprints.repository';
import { FindTimelineRepository } from '../ports/find-timeline.repository';
import { PresenceStore } from '../ports/presence.store';
import { PRESENCE_TTL_MS } from '../presence.service';
import { GetMyHomeQuery } from './get-my-home.query';
import { guestOnly } from './read-access';
/** How much activity the home page lists. */
const ACTIVITY = 30;
@QueryHandler(GetMyHomeQuery)
export class GetMyHomeQueryHandler
implements IQueryHandler<GetMyHomeQuery, HomeReadModel>
{
constructor(
private readonly findProjects: FindProjectsRepository,
private readonly findIssues: FindIssuesRepository,
private readonly findTimeline: FindTimelineRepository,
private readonly findSprints: FindSprintsRepository,
private readonly findReleases: FindReleasesRepository,
private readonly findActivity: FindActivityRepository,
private readonly feeds: CalendarFeedsRepository,
private readonly presence: PresenceStore,
) {}
async execute(query: GetMyHomeQuery): Promise<HomeReadModel> {
const me = query.viewerId;
const now = new Date();
const projects = (await this.findProjects.findByMember(me)).filter(
(p) => !p.archivedAt,
);
// Project-wide views only of the projects I see whole (not as a guest).
const whole = projects.filter((p) => !guestOnly(p, me));
const days = lastDays(now);
const [inputs, activity, counts, feed, online] = await Promise.all([
Promise.all(
projects.map(async (project) => {
const [issues, timeline, sprints, releases] = await Promise.all([
this.findIssues.findByProject(project.id),
this.findTimeline.findByProject(project.id),
this.findSprints.findByProject(project.id),
this.findReleases.findByProject(project.id),
]);
return { project, issues, timeline, sprints, releases };
}),
),
this.findActivity.findAcross(
whole.map((p) => p.id),
ACTIVITY,
),
this.findActivity.dailyCounts(
whole.map((p) => p.id),
days[0],
),
this.feeds.findByUser(me),
Promise.all(
whole.map(async (project) =>
(
await this.presence.listProject(
project.id,
now.getTime() - PRESENCE_TTL_MS,
)
)
.filter((entry) => entry.user.id !== me)
.map(
(entry): OnlineReadModel => ({
user: entry.user,
projectKey: project.key,
issueKey: entry.issue?.key ?? null,
issueTitle: entry.issue?.title ?? null,
editing: entry.editing,
at: entry.at,
}),
),
),
),
]);
const perDay = (projectId: string) =>
days.map(
(day) =>
counts.find((c) => c.projectId === projectId && c.day === day)
?.count ?? 0,
);
return computeHome({
viewerId: me,
projects: inputs.map(
(i): HomeProjectInput => ({ ...i, activity: perDay(i.project.id) }),
),
activity,
hasCalendar: !!feed,
// One line per person: the latest ticket they are on.
online: online
.flat()
.sort((a, b) => b.at.localeCompare(a.at))
.filter(
(entry, i, all) =>
all.findIndex((e) => e.user.id === entry.user.id) === i,
),
now,
});
}
}

View File

@@ -0,0 +1,12 @@
import { Query } from '@nestjs/cqrs';
import { HomeReadModel } from '@task-stream/api/tracker/domain';
/**
* The home page beyond «my work»: agenda, activity, the week, sprints,
* project cards, suggestions, first steps and who is on what now.
*/
export class GetMyHomeQuery extends Query<HomeReadModel> {
constructor(public readonly viewerId: string) {
super();
}
}

View File

@@ -0,0 +1,91 @@
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import {
DEFAULT_PROJECT_SETTINGS,
PortfolioProjectReadModel,
ProjectReadModel,
SprintStatus,
StatusCategory,
} from '@task-stream/api/tracker/domain';
import { computeInsights } from '../analytics/insights';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { FindReleasesRepository } from '../ports/find-releases.repository';
import { FindSprintsRepository } from '../ports/find-sprints.repository';
import { FindTimelineRepository } from '../ports/find-timeline.repository';
import { GetPortfolioQuery } from './get-portfolio.query';
import { guestOnly } from './read-access';
const FINISHED = [StatusCategory.DONE, StatusCategory.CANCELLED];
/** Epics shown per project. */
const EPICS = 6;
@QueryHandler(GetPortfolioQuery)
export class GetPortfolioQueryHandler
implements IQueryHandler<GetPortfolioQuery, PortfolioProjectReadModel[]>
{
constructor(
private readonly findProjects: FindProjectsRepository,
private readonly findIssues: FindIssuesRepository,
private readonly findTimeline: FindTimelineRepository,
private readonly findSprints: FindSprintsRepository,
private readonly findReleases: FindReleasesRepository,
) {}
async execute(
query: GetPortfolioQuery,
): Promise<PortfolioProjectReadModel[]> {
const projects = (await this.findProjects.findByMember(query.viewerId))
.filter((p) => !p.archivedAt && !guestOnly(p, query.viewerId))
.sort((a, b) => a.name.localeCompare(b.name, 'es'));
return Promise.all(projects.map((p) => this.summary(p)));
}
private async summary(
project: ProjectReadModel,
): Promise<PortfolioProjectReadModel> {
const [issues, timeline, sprints, releases, epics] = await Promise.all([
this.findIssues.findByProject(project.id),
this.findTimeline.findByProject(project.id),
this.findSprints.findByProject(project.id),
this.findReleases.findByProject(project.id),
this.findIssues.epics(project.id),
]);
const reading = computeInsights({
issues,
timeline,
settings: project.settings ?? DEFAULT_PROJECT_SETTINGS,
activeSprint:
sprints.find((s) => s.status === SprintStatus.ACTIVE) ?? null,
sprints,
releases,
members: project.members,
since: null,
mentions: 0,
now: new Date(),
});
const live = issues.filter((i) => !i.trashedAt);
return {
projectId: project.id,
key: project.key,
name: project.name,
isPublic: project.isPublic,
total: live.length,
open: live.filter((i) => !FINISHED.includes(i.statusCategory)).length,
overdue: reading.risks.overdueCount,
blocked: reading.risks.blockedCount,
stuck: reading.risks.stuckCount,
sprint: reading.sprint,
releases: reading.releases,
epics: epics
.filter(
(e) =>
!FINISHED.includes(e.issue.statusCategory) && !e.issue.trashedAt,
)
.sort((a, b) =>
(a.issue.dueDate ?? '9999').localeCompare(b.issue.dueDate ?? '9999'),
)
.slice(0, EPICS),
updatedAt: project.updatedAt,
};
}
}

View File

@@ -0,0 +1,12 @@
import { Query } from '@nestjs/cqrs';
import { PortfolioProjectReadModel } from '@task-stream/api/tracker/domain';
/**
* Every active project I take full part in (not archived, not as a guest),
* at a glance: open work, risks, sprint, releases and epics.
*/
export class GetPortfolioQuery extends Query<PortfolioProjectReadModel[]> {
constructor(public readonly viewerId: string) {
super();
}
}

View File

@@ -26,7 +26,7 @@ export class GetProjectAnalyticsQueryHandler
async execute( async execute(
query: GetProjectAnalyticsQuery, query: GetProjectAnalyticsQuery,
): Promise<ProjectAnalyticsReadModel> { ): Promise<ProjectAnalyticsReadModel> {
await this.access.assertCanView(query.projectId, query.viewerId); await this.access.assertSeesWholeProject(query.projectId, query.viewerId);
const [project, issues, timeline] = await Promise.all([ const [project, issues, timeline] = await Promise.all([
this.findProjects.findById(query.projectId), this.findProjects.findById(query.projectId),
this.findIssues.findByProject(query.projectId), this.findIssues.findByProject(query.projectId),

View File

@@ -0,0 +1,75 @@
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import {
DEFAULT_PROJECT_SETTINGS,
NotificationKind,
ProjectInsightsReadModel,
SprintStatus,
} from '@task-stream/api/tracker/domain';
import { computeInsights } from '../analytics/insights';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { FindReleasesRepository } from '../ports/find-releases.repository';
import { NotificationsRepository } from '../ports/notifications.repository';
import { FindSprintsRepository } from '../ports/find-sprints.repository';
import { FindTimelineRepository } from '../ports/find-timeline.repository';
import { GetProjectInsightsQuery } from './get-project-insights.query';
import { ReadAccess } from './read-access';
@QueryHandler(GetProjectInsightsQuery)
export class GetProjectInsightsQueryHandler
implements IQueryHandler<GetProjectInsightsQuery, ProjectInsightsReadModel>
{
constructor(
private readonly access: ReadAccess,
private readonly findProjects: FindProjectsRepository,
private readonly findIssues: FindIssuesRepository,
private readonly findTimeline: FindTimelineRepository,
private readonly findSprints: FindSprintsRepository,
private readonly findReleases: FindReleasesRepository,
private readonly notifications: NotificationsRepository,
) {}
async execute(
query: GetProjectInsightsQuery,
): Promise<ProjectInsightsReadModel> {
await this.access.assertSeesWholeProject(query.projectId, query.viewerId);
const [project, issues, timeline, sprints, releases, mentions] =
await Promise.all([
this.findProjects.findById(query.projectId),
this.findIssues.findByProject(query.projectId),
this.findTimeline.findByProject(query.projectId),
this.findSprints.findByProject(query.projectId),
this.findReleases.findByProject(query.projectId),
this.mentionsSince(query),
]);
return computeInsights({
issues,
timeline,
settings: project?.settings ?? DEFAULT_PROJECT_SETTINGS,
activeSprint:
sprints.find((s) => s.status === SprintStatus.ACTIVE) ?? null,
sprints,
releases,
members: project?.members ?? [],
since: query.since,
mentions,
now: new Date(),
});
}
/** The viewer's mentions in this project after `since` (latest 200). */
private async mentionsSince(query: GetProjectInsightsQuery): Promise<number> {
if (!query.since || !query.viewerId) return 0;
const since = query.since.toISOString();
const latest = await this.notifications.findForUser(query.viewerId, {
unreadOnly: false,
limit: 200,
});
return latest.filter(
(n) =>
n.projectId === query.projectId &&
n.kind === NotificationKind.MENTIONED &&
n.createdAt > since,
).length;
}
}

View File

@@ -0,0 +1,18 @@
import { Query } from '@nestjs/cqrs';
import { ProjectInsightsReadModel } from '@task-stream/api/tracker/domain';
/**
* The dashboard's reading of a project: sprint, risks, aging, bottlenecks,
* release forecasts, team load, backlog health and, with `since`, what
* changed after it.
*/
export class GetProjectInsightsQuery extends Query<ProjectInsightsReadModel> {
constructor(
/** Null: anonymous (public projects only). */
public readonly viewerId: string | null,
public readonly projectId: string,
public readonly since: Date | null = null,
) {
super();
}
}

View File

@@ -14,7 +14,7 @@ export class GetProjectStatsQueryHandler
) {} ) {}
async execute(query: GetProjectStatsQuery): Promise<ProjectStatsReadModel> { async execute(query: GetProjectStatsQuery): Promise<ProjectStatsReadModel> {
await this.access.assertCanView(query.projectId, query.viewerId); await this.access.assertSeesWholeProject(query.projectId, query.viewerId);
return this.findIssues.stats(query.projectId); return this.findIssues.stats(query.projectId);
} }
} }

View File

@@ -24,10 +24,14 @@ export class GetReleaseBurnupQueryHandler
'Release', 'Release',
query.releaseId, query.releaseId,
); );
await this.access.assertCanView(release.projectId, query.viewerId, { await this.access.assertSeesWholeProject(
entity: 'Release', release.projectId,
ref: query.releaseId, query.viewerId,
}); {
entity: 'Release',
ref: query.releaseId,
},
);
const [issues, timeline] = await Promise.all([ const [issues, timeline] = await Promise.all([
this.findIssues.findByProject(release.projectId), this.findIssues.findByProject(release.projectId),
this.findTimeline.findByProject(release.projectId), this.findTimeline.findByProject(release.projectId),

View File

@@ -36,10 +36,14 @@ export class GetReleaseNotesDraftQueryHandler
'Release', 'Release',
query.releaseId, query.releaseId,
); );
await this.access.assertCanView(release.projectId, query.viewerId, { await this.access.assertSeesWholeProject(
entity: 'Release', release.projectId,
ref: query.releaseId, query.viewerId,
}); {
entity: 'Release',
ref: query.releaseId,
},
);
const [page, project] = await Promise.all([ const [page, project] = await Promise.all([
this.findIssues.search({ this.findIssues.search({
projectId: release.projectId, projectId: release.projectId,

View File

@@ -23,10 +23,14 @@ export class GetReleaseProgressQueryHandler
'Release', 'Release',
query.releaseId, query.releaseId,
); );
await this.access.assertCanView(release.projectId, query.viewerId, { await this.access.assertSeesWholeProject(
entity: 'Release', release.projectId,
ref: query.releaseId, query.viewerId,
}); {
entity: 'Release',
ref: query.releaseId,
},
);
return this.findIssues.releaseProgress(release.id); return this.findIssues.releaseProgress(release.id);
} }
} }

View File

@@ -19,10 +19,14 @@ export class GetReleaseQueryHandler
'Release', 'Release',
query.releaseId, query.releaseId,
); );
await this.access.assertCanView(release.projectId, query.viewerId, { await this.access.assertSeesWholeProject(
entity: 'Release', release.projectId,
ref: query.releaseId, query.viewerId,
}); {
entity: 'Release',
ref: query.releaseId,
},
);
return release; return release;
} }
} }

View File

@@ -14,7 +14,7 @@ export class GetReleasesQueryHandler
) {} ) {}
async execute(query: GetReleasesQuery): Promise<ReleaseReadModel[]> { async execute(query: GetReleasesQuery): Promise<ReleaseReadModel[]> {
await this.access.assertCanView(query.projectId, query.viewerId); await this.access.assertSeesWholeProject(query.projectId, query.viewerId);
return this.findReleases.findByProject(query.projectId); return this.findReleases.findByProject(query.projectId);
} }
} }

View File

@@ -0,0 +1,14 @@
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { GetTicketScopeQuery } from './get-ticket-scope.query';
import { ReadAccess } from './read-access';
@QueryHandler(GetTicketScopeQuery)
export class GetTicketScopeQueryHandler
implements IQueryHandler<GetTicketScopeQuery, string | null>
{
constructor(private readonly access: ReadAccess) {}
execute(query: GetTicketScopeQuery): Promise<string | null> {
return this.access.participantScope(query.projectId, query.viewerId);
}
}

View File

@@ -0,0 +1,14 @@
import { Query } from '@nestjs/cqrs';
/**
* Whose tickets the viewer sees of a project: null for all; their own id
* for a guest of a private project (only the tickets they take part in).
*/
export class GetTicketScopeQuery extends Query<string | null> {
constructor(
public readonly viewerId: string | null,
public readonly projectId: string,
) {
super();
}
}

View File

@@ -23,7 +23,7 @@ export class GetTrashQueryHandler
) {} ) {}
async execute(query: GetTrashQuery): Promise<IssueReadModel[]> { async execute(query: GetTrashQuery): Promise<IssueReadModel[]> {
await this.access.assertCanView(query.projectId, query.viewerId); await this.access.assertSeesWholeProject(query.projectId, query.viewerId);
return this.findIssues.findTrashed(query.projectId); return this.findIssues.findTrashed(query.projectId);
} }
} }

View File

@@ -1,3 +1,4 @@
import { guestOnly } from './read-access';
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { ReleaseReadModel } from '@task-stream/api/tracker/domain'; import { ReleaseReadModel } from '@task-stream/api/tracker/domain';
import { FindProjectsRepository } from '../ports/find-projects.repository'; import { FindProjectsRepository } from '../ports/find-projects.repository';
@@ -15,8 +16,11 @@ export class GetUpcomingReleasesQueryHandler
async execute(query: GetUpcomingReleasesQuery): Promise<ReleaseReadModel[]> { async execute(query: GetUpcomingReleasesQuery): Promise<ReleaseReadModel[]> {
const projects = await this.findProjects.findByMember(query.viewerId); const projects = await this.findProjects.findByMember(query.viewerId);
// A guest does not see a private project's releases.
return this.findReleases.findUpcoming( return this.findReleases.findUpcoming(
projects.map((project) => project.id), projects
.filter((project) => !guestOnly(project, query.viewerId))
.map((project) => project.id),
Math.min(query.limit, 50), Math.min(query.limit, 50),
); );
} }

View File

@@ -6,6 +6,10 @@ export * from './get-issue.query';
export * from './get-issues-by-ids.query'; export * from './get-issues-by-ids.query';
export * from './get-my-projects.query'; export * from './get-my-projects.query';
export * from './get-project-analytics.query'; export * from './get-project-analytics.query';
export * from './get-project-insights.query';
export * from './get-ticket-scope.query';
export * from './get-portfolio.query';
export * from './get-my-home.query';
export * from './get-project-stats.query'; export * from './get-project-stats.query';
export * from './get-release-burnup.query'; export * from './get-release-burnup.query';
export * from './get-project.query'; export * from './get-project.query';

View File

@@ -9,6 +9,10 @@ import { GetIssuesByIdsQueryHandler } from './get-issues-by-ids.query-handler';
import { GetMyProjectsQueryHandler } from './get-my-projects.query-handler'; import { GetMyProjectsQueryHandler } from './get-my-projects.query-handler';
import { GetProjectQueryHandler } from './get-project.query-handler'; import { GetProjectQueryHandler } from './get-project.query-handler';
import { GetProjectAnalyticsQueryHandler } from './get-project-analytics.query-handler'; import { GetProjectAnalyticsQueryHandler } from './get-project-analytics.query-handler';
import { GetProjectInsightsQueryHandler } from './get-project-insights.query-handler';
import { GetTicketScopeQueryHandler } from './get-ticket-scope.query-handler';
import { GetPortfolioQueryHandler } from './get-portfolio.query-handler';
import { GetMyHomeQueryHandler } from './get-my-home.query-handler';
import { GetProjectStatsQueryHandler } from './get-project-stats.query-handler'; import { GetProjectStatsQueryHandler } from './get-project-stats.query-handler';
import { GetReleaseBurnupQueryHandler } from './get-release-burnup.query-handler'; import { GetReleaseBurnupQueryHandler } from './get-release-burnup.query-handler';
import { GetReleaseQueryHandler } from './get-release.query-handler'; import { GetReleaseQueryHandler } from './get-release.query-handler';
@@ -34,6 +38,10 @@ export const QUERY_HANDLERS = [
GetMyProjectsQueryHandler, GetMyProjectsQueryHandler,
GetProjectQueryHandler, GetProjectQueryHandler,
GetProjectAnalyticsQueryHandler, GetProjectAnalyticsQueryHandler,
GetProjectInsightsQueryHandler,
GetTicketScopeQueryHandler,
GetPortfolioQueryHandler,
GetMyHomeQueryHandler,
GetProjectStatsQueryHandler, GetProjectStatsQueryHandler,
GetReleaseBurnupQueryHandler, GetReleaseBurnupQueryHandler,
GetReleaseQueryHandler, GetReleaseQueryHandler,

View File

@@ -1,6 +1,10 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { NotFoundError } from '@task-stream/api/shared/domain'; import { ForbiddenError, NotFoundError } from '@task-stream/api/shared/domain';
import { ProjectRole } from '@task-stream/api/tracker/domain'; import {
IssueReadModel,
ProjectReadModel,
ProjectRole,
} from '@task-stream/api/tracker/domain';
import { FindProjectsRepository } from '../ports/find-projects.repository'; import { FindProjectsRepository } from '../ports/find-projects.repository';
/** /**
@@ -35,6 +39,67 @@ export class ReadAccess {
return null; return null;
} }
/**
* Whose tickets the viewer sees of the project: null for all of them; for
* a guest of a private project, their own id (only the tickets they take
* part in).
*/
async participantScope(
projectId: string,
viewerId: string | null,
): Promise<string | null> {
if (!viewerId) return null;
const role = await this.findProjects.findRole(projectId, viewerId);
if (role !== ProjectRole.GUEST) return null;
const project = await this.findProjects.findById(projectId);
return project?.isPublic ? null : viewerId;
}
/**
* Views of the whole project (activity, statistics, releases, sprints…):
* not for a guest, who only sees their own tickets.
*/
async assertSeesWholeProject(
projectId: string,
viewerId: string | null,
asked?: { entity: string; ref: string },
): Promise<ProjectRole | null> {
const role = await this.assertCanView(projectId, viewerId, asked);
if (await this.participantScope(projectId, viewerId))
throw new ForbiddenError(
'Un invitado solo ve los tickets en los que participa',
);
return role;
}
/** The ticket, if the viewer may read it (a guest: only theirs). */
async assertCanViewIssue(
issue: IssueReadModel,
viewerId: string | null,
asked: { entity: string; ref: string } = {
entity: 'Issue',
ref: issue.key,
},
): Promise<void> {
await this.assertCanView(issue.projectId, viewerId, asked);
const scope = await this.participantScope(issue.projectId, viewerId);
if (scope && !involves(issue, scope))
throw new NotFoundError(asked.entity, asked.ref);
}
async canViewIssue(
issue: IssueReadModel,
viewerId: string | null,
): Promise<boolean> {
try {
await this.assertCanViewIssue(issue, viewerId);
return true;
} catch (error) {
if (error instanceof NotFoundError) return false;
throw error;
}
}
async canView(projectId: string, viewerId: string | null): Promise<boolean> { async canView(projectId: string, viewerId: string | null): Promise<boolean> {
try { try {
await this.assertCanView(projectId, viewerId); await this.assertCanView(projectId, viewerId);
@@ -51,5 +116,24 @@ export class ReadAccess {
} }
} }
/** Opened it, has it or follows it. */
export function involves(issue: IssueReadModel, userId: string): boolean {
return (
issue.reporter?.id === userId ||
issue.assignee?.id === userId ||
(issue.watchers ?? []).some((w) => w.id === userId)
);
}
/** A guest of this private project: sees only the tickets they take part in. */
export function guestOnly(project: ProjectReadModel, userId: string): boolean {
return (
!project.isPublic &&
project.members.some(
(m) => m.user.id === userId && m.role === ProjectRole.GUEST,
)
);
}
export const UUID = export const UUID =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

View File

@@ -20,6 +20,11 @@ export class SearchIssuesQueryHandler
if (watching && !query.viewerId) return { items: [], total: 0 }; if (watching && !query.viewerId) return { items: [], total: 0 };
return this.findIssues.search({ return this.findIssues.search({
...filter, ...filter,
participantId:
(await this.access.participantScope(
query.filter.projectId,
query.viewerId,
)) ?? undefined,
watcherId: watching ? (query.viewerId ?? undefined) : undefined, watcherId: watching ? (query.viewerId ?? undefined) : undefined,
limit: Math.min(query.filter.limit, 200), limit: Math.min(query.filter.limit, 200),
}); });

View File

@@ -1,8 +1,13 @@
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { IssueReadModel } from '@task-stream/api/tracker/domain'; import {
DEFAULT_PROJECT_SETTINGS,
IssueReadModel,
StatusCategory,
} from '@task-stream/api/tracker/domain';
import { FindIssuesRepository, Page } from '../ports/find-issues.repository'; import { FindIssuesRepository, Page } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository'; import { FindProjectsRepository } from '../ports/find-projects.repository';
import { SearchMyIssuesQuery } from './search-my-issues.query'; import { SearchMyIssuesQuery } from './search-my-issues.query';
import { guestOnly } from './read-access';
@QueryHandler(SearchMyIssuesQuery) @QueryHandler(SearchMyIssuesQuery)
export class SearchMyIssuesQueryHandler export class SearchMyIssuesQueryHandler
@@ -15,13 +20,48 @@ export class SearchMyIssuesQueryHandler
/** Membership is the access rule: only projects the viewer belongs to. */ /** Membership is the access rule: only projects the viewer belongs to. */
async execute(query: SearchMyIssuesQuery): Promise<Page<IssueReadModel>> { async execute(query: SearchMyIssuesQuery): Promise<Page<IssueReadModel>> {
const projects = await this.findProjects.findByMember(query.viewerId); const { filter } = query;
const projects = (
await this.findProjects.findByMember(query.viewerId)
).filter(
(p) => !filter.projectIds?.length || filter.projectIds.includes(p.id),
);
// Bugs are each project's types drawn with the bug icon.
const bugTypes = filter.bugsOnly
? Object.fromEntries(
projects.map((p) => [
p.id,
(p.settings ?? DEFAULT_PROJECT_SETTINGS).issueTypes
.filter((t) => t.icon === 'bug')
.map((t) => t.id),
]),
)
: undefined;
const open = [
StatusCategory.TODO,
StatusCategory.IN_PROGRESS,
StatusCategory.QA,
];
const guestProjectIds = projects
.filter((p) => guestOnly(p, query.viewerId))
.map((p) => p.id);
return this.findIssues.searchAcross({ return this.findIssues.searchAcross({
projectIds: projects.map((project) => project.id), projectIds: projects.map((project) => project.id),
guestProjectIds,
participantId: query.viewerId,
priorities: filter.priorities?.length ? filter.priorities : undefined,
typesByProject: bugTypes,
dueBefore: filter.overdue
? new Date().toISOString().slice(0, 10)
: undefined,
assigneeId: query.filter.assignedToMe ? query.viewerId : undefined, assigneeId: query.filter.assignedToMe ? query.viewerId : undefined,
watcherId: query.filter.watching ? query.viewerId : undefined, watcherId: query.filter.watching ? query.viewerId : undefined,
statuses: query.filter.statuses, statuses: query.filter.statuses,
categories: query.filter.categories, categories: filter.overdue
? (filter.categories?.length ? filter.categories : open).filter((c) =>
open.includes(c),
)
: query.filter.categories,
search: query.filter.search?.trim().slice(0, 200) || undefined, search: query.filter.search?.trim().slice(0, 200) || undefined,
limit: Math.min(query.filter.limit, 200), limit: Math.min(query.filter.limit, 200),
offset: query.filter.offset, offset: query.filter.offset,

View File

@@ -1,5 +1,6 @@
import { Query } from '@nestjs/cqrs'; import { Query } from '@nestjs/cqrs';
import { import {
IssuePriority,
IssueReadModel, IssueReadModel,
StatusCategory, StatusCategory,
} from '@task-stream/api/tracker/domain'; } from '@task-stream/api/tracker/domain';
@@ -15,6 +16,13 @@ export interface MyIssuesFilter {
categories?: StatusCategory[]; categories?: StatusCategory[];
/** Matches key, title or description. */ /** Matches key, title or description. */
search?: string; search?: string;
/** Only these of my projects. */
projectIds?: string[];
priorities?: IssuePriority[];
/** Only tickets of a type drawn with the bug icon (in each project). */
bugsOnly?: boolean;
/** Only open tickets past their due date. */
overdue?: boolean;
limit: number; limit: number;
offset: number; offset: number;
} }

View File

@@ -76,7 +76,7 @@ export class GetSprintsQueryHandler
) {} ) {}
async execute(query: GetSprintsQuery): Promise<SprintReadModel[]> { async execute(query: GetSprintsQuery): Promise<SprintReadModel[]> {
await this.access.assertCanView(query.projectId, query.viewerId); await this.access.assertSeesWholeProject(query.projectId, query.viewerId);
return this.findSprints.findByProject(query.projectId); return this.findSprints.findByProject(query.projectId);
} }
} }

View File

@@ -1,5 +1,6 @@
import { CommandRunner } from '@task-stream/api/shared/application';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs'; import { QueryBus } from '@nestjs/cqrs';
import { import {
ReleaseBurnupReadModel, ReleaseBurnupReadModel,
ReleaseProgressReadModel, ReleaseProgressReadModel,
@@ -26,7 +27,7 @@ interface ReleaseDetails {
@Injectable() @Injectable()
export class ReleasesService { export class ReleasesService {
constructor( constructor(
private readonly commandBus: CommandBus, private readonly commands: CommandRunner,
private readonly queryBus: QueryBus, private readonly queryBus: QueryBus,
) {} ) {}
@@ -34,7 +35,7 @@ export class ReleasesService {
actorId: string, actorId: string,
input: ReleaseDetails & { projectId: string; name: string }, input: ReleaseDetails & { projectId: string; name: string },
): Promise<CommandResult> { ): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new CreateReleaseCommand( new CreateReleaseCommand(
actorId, actorId,
input.projectId, input.projectId,
@@ -50,7 +51,7 @@ export class ReleasesService {
releaseId: string, releaseId: string,
changes: ReleaseDetails, changes: ReleaseDetails,
): Promise<CommandResult> { ): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new UpdateReleaseCommand(actorId, releaseId, changes), new UpdateReleaseCommand(actorId, releaseId, changes),
); );
} }
@@ -61,7 +62,7 @@ export class ReleasesService {
releaseId: string, releaseId: string,
force = false, force = false,
): Promise<CommandResult> { ): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new PublishReleaseCommand(actorId, releaseId, force), new PublishReleaseCommand(actorId, releaseId, force),
); );
} }
@@ -72,7 +73,7 @@ export class ReleasesService {
releaseId: string, releaseId: string,
moveTo: string | null = null, moveTo: string | null = null,
): Promise<CommandResult> { ): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new ArchiveReleaseCommand(actorId, releaseId, moveTo), new ArchiveReleaseCommand(actorId, releaseId, moveTo),
); );
} }
@@ -95,7 +96,7 @@ export class ReleasesService {
releaseId: string, releaseId: string,
notes: string | null, notes: string | null,
): Promise<CommandResult> { ): Promise<CommandResult> {
return this.commandBus.execute( return this.commands.execute(
new WriteReleaseNotesCommand(actorId, releaseId, notes), new WriteReleaseNotesCommand(actorId, releaseId, notes),
); );
} }

View File

@@ -1,9 +1,17 @@
import { CommandRunner } from '@task-stream/api/shared/application';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs'; import { QueryBus } from '@nestjs/cqrs';
import { import {
MyViewCriteriaReadModel,
MyViewReadModel,
SavedFilterCriteriaReadModel, SavedFilterCriteriaReadModel,
SavedFilterReadModel, SavedFilterReadModel,
} from '@task-stream/api/tracker/domain'; } from '@task-stream/api/tracker/domain';
import {
DeleteMyViewCommand,
GetMyViewsQuery,
SaveMyViewCommand,
} from './saved-filters/my-views';
import { import {
DeleteSavedFilterCommand, DeleteSavedFilterCommand,
GetSavedFiltersQuery, GetSavedFiltersQuery,
@@ -17,11 +25,11 @@ export interface SaveFilterInput {
criteria: SavedFilterCriteriaReadModel; criteria: SavedFilterCriteriaReadModel;
} }
/** Saved board views: mine and the ones shared with the project. */ /** Saved views: the board's (mine and shared) and my own of «my work». */
@Injectable() @Injectable()
export class SavedFiltersService { export class SavedFiltersService {
constructor( constructor(
private readonly commandBus: CommandBus, private readonly commands: CommandRunner,
private readonly queryBus: QueryBus, private readonly queryBus: QueryBus,
) {} ) {}
@@ -30,7 +38,7 @@ export class SavedFiltersService {
} }
save(actorId: string, input: SaveFilterInput): Promise<{ id: string }> { save(actorId: string, input: SaveFilterInput): Promise<{ id: string }> {
return this.commandBus.execute( return this.commands.execute(
new SaveFilterCommand( new SaveFilterCommand(
actorId, actorId,
input.projectId, input.projectId,
@@ -42,8 +50,26 @@ export class SavedFiltersService {
} }
delete(actorId: string, filterId: string): Promise<void> { delete(actorId: string, filterId: string): Promise<void> {
return this.commandBus.execute( return this.commands.execute(
new DeleteSavedFilterCommand(actorId, filterId), new DeleteSavedFilterCommand(actorId, filterId),
); );
} }
/** My views of «my work», by name. */
myViews(ownerId: string): Promise<MyViewReadModel[]> {
return this.queryBus.execute(new GetMyViewsQuery(ownerId));
}
saveMyView(
ownerId: string,
input: { name: string; criteria: MyViewCriteriaReadModel },
): Promise<{ id: string }> {
return this.commands.execute(
new SaveMyViewCommand(ownerId, input.name, input.criteria),
);
}
deleteMyView(ownerId: string, viewId: string): Promise<void> {
return this.commands.execute(new DeleteMyViewCommand(ownerId, viewId));
}
} }

View File

@@ -0,0 +1,133 @@
import { randomUUID } from 'node:crypto';
import {
Command,
CommandHandler,
ICommandHandler,
IQueryHandler,
Query,
QueryHandler,
} from '@nestjs/cqrs';
import {
BusinessRuleError,
NotFoundError,
assertThat,
} from '@task-stream/api/shared/domain';
import {
MyViewCriteriaReadModel,
MyViewReadModel,
} from '@task-stream/api/tracker/domain';
import { MyViewsRepository } from '../ports/my-views.repository';
/*
* Saved views of «my work»: across my projects, private, no history (like
* the board's saved views and notification settings).
*/
/** A person keeps at most this many. */
export const MAX_MY_VIEWS = 20;
export class SaveMyViewCommand extends Command<{ id: string }> {
constructor(
public readonly ownerId: string,
public readonly name: string,
public readonly criteria: MyViewCriteriaReadModel,
) {
super();
}
}
export class DeleteMyViewCommand extends Command<void> {
constructor(
public readonly ownerId: string,
public readonly viewId: string,
) {
super();
}
}
export class GetMyViewsQuery extends Query<MyViewReadModel[]> {
constructor(public readonly ownerId: string) {
super();
}
}
/** Drops empty criteria, trims the text and deduplicates lists. */
export function normalizeMyView(
criteria: MyViewCriteriaReadModel,
): MyViewCriteriaReadModel {
const list = <T extends string>(values?: T[] | null) =>
values?.length ? [...new Set(values)] : undefined;
const out: MyViewCriteriaReadModel = {
projectIds: list(criteria.projectIds),
assignedToMe: criteria.assignedToMe || undefined,
watching: criteria.watching || undefined,
categories: list(criteria.categories),
priorities: list(criteria.priorities),
bugsOnly: criteria.bugsOnly || undefined,
overdue: criteria.overdue || undefined,
search: criteria.search?.trim().slice(0, 200) || undefined,
};
return Object.fromEntries(
Object.entries(out).filter(([, value]) => value !== undefined),
) as MyViewCriteriaReadModel;
}
@CommandHandler(SaveMyViewCommand)
export class SaveMyViewCommandHandler
implements ICommandHandler<SaveMyViewCommand>
{
constructor(private readonly views: MyViewsRepository) {}
async execute(command: SaveMyViewCommand): Promise<{ id: string }> {
const name = command.name.trim();
assertThat(
name.length > 0 && name.length <= 60,
'A view needs a name (up to 60 characters)',
);
if ((await this.views.countByOwner(command.ownerId)) >= MAX_MY_VIEWS)
throw new BusinessRuleError(
`You already keep ${MAX_MY_VIEWS} views; delete one first`,
);
const id = randomUUID();
await this.views.insert({
id,
ownerId: command.ownerId,
name,
criteria: normalizeMyView(command.criteria),
createdAt: new Date().toISOString(),
});
return { id };
}
}
@CommandHandler(DeleteMyViewCommand)
export class DeleteMyViewCommandHandler
implements ICommandHandler<DeleteMyViewCommand>
{
constructor(private readonly views: MyViewsRepository) {}
async execute(command: DeleteMyViewCommand): Promise<void> {
const view = await this.views.findById(command.viewId);
// Someone else's view does not exist, as far as this person knows.
if (!view || view.ownerId !== command.ownerId)
throw new NotFoundError('View', command.viewId);
await this.views.delete(view.id);
}
}
@QueryHandler(GetMyViewsQuery)
export class GetMyViewsQueryHandler
implements IQueryHandler<GetMyViewsQuery, MyViewReadModel[]>
{
constructor(private readonly views: MyViewsRepository) {}
execute(query: GetMyViewsQuery): Promise<MyViewReadModel[]> {
return this.views.findByOwner(query.ownerId);
}
}
export const MY_VIEWS = [
SaveMyViewCommandHandler,
DeleteMyViewCommandHandler,
GetMyViewsQueryHandler,
];

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