Compare commits

..

113 Commits

Author SHA1 Message Date
41f81cd2b0 fix(web): a poll asks its own question instead of a stock one
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:45:07 +02:00
f36c31e66d fix(web): a poll says how it is published, and needs no text of its own
With a poll open the button reads «Publicar encuesta» and two options are
enough. The «Más» menu keeps the page's gutter on a phone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:20:00 +02:00
425db24eea fix(web): the ticket's header fits, with «Más» for the less frequent actions
The reminder and what is new are pi-notes; the poll editor's buttons align.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:17:24 +02:00
285bd5c454 feat: what is new since my last visit, and turning a comment into a ticket
Opening a ticket notes the visit and answers the one before (issue_visits),
so the page marks others' comments and changes since. A comment can become
a ticket of its own, as a subtask or linked; it remembers where it went.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:05:44 +02:00
429b52e6f5 fix(web): polls, recordings, reviews and reminders use @pi/ui's fields and buttons
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:24:37 +02:00
3c211f0e56 test(web): the routes spec knows the public release notes page
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:24:37 +02:00
909edc9f4d feat(web): record the screen or the voice from a comment, and play it there
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 06:58:18 +02:00
c27aa4b1d7 feat(web): the roadmap shows dependencies on other projects
Tickets of other projects that block a dated epic, or that it depends
on, now show under «De otros proyectos» with a dashed bar, their arrows,
and the same warning when the dates clash («EPI-1 empieza el 25 oct,
pero BACK-1 (otro proyecto), que la bloquea, acaba el 5 nov»). Both
halves of a relation are read, since one recorded from the other project
only leaves its inward half here. The ones without dates, or out of
reach, are named under the chart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 21:12:37 +02:00
79b8094011 feat(web): project health as a traffic light, with its causes
The project dashboard opens with «Salud del proyecto»: good, attention or
at risk, the worst of its signals, each with the cause and the tickets
behind it: stuck work, overdue and blocked tickets, critical or several
high bugs, QA sending back 30 % or more (with enough verdicts), releases
late or at risk by the forecast, columns over their WIP limit. Worked
out in the web from the insights it already reads (projectHealth). The
portfolio shows each project's light too, with its causes on hover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 21:08:21 +02:00
8ed16f133e feat: a public notes page per release, for clients
On the releases page, «Página pública» (owners and admins) opens a
secret link to a page with the release's notes, readable without an
account, also for private projects: the notes written, or the draft
from its finished tickets while there are none (without repeating the
title the page already shows). The link can be copied, renewed (the old
one stops working) or withdrawn. Stored in Mongo (`release_note_shares`).

API: shareReleaseNotes / unshareReleaseNotes / releaseNotesShare,
publicReleaseNotes(token); GET/PUT/DELETE /api/releases/{id}/public-notes,
GET /api/shared/release-notes/{token}. Web: /notes/:token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 20:57:40 +02:00
3a46a236e4 feat: quick polls in comments that end in a decision
«📊 Añadir una encuesta» adds 2 to 6 options to a new comment. Members,
viewers too, vote by clicking an option (one vote each, which they can
change or take back) and see each option's votes as a bar. Whoever
started it, or an admin, closes it with «Cerrar y decidir»: the most
voted wins (none on a tie) and a «Decidido: …» reply marked as a
decision shows above the ticket. Events of the comment (voted, closed).

API: addComment(…, poll), votePoll, closePoll, Comment.poll;
POST /api/comments/{id}/poll/vote and …/poll/close. MCP: add_comment
poll, vote_poll, close_poll.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 20:48:12 +02:00
93d028e870 feat: ask a teammate to review a ticket
«Pedir revisión» on a ticket asks a member (with an optional note): they
are notified and see it under «Te piden revisión» in my work until they
answer «Todo bien» or «Pedir cambios» (with what to change) from the
ticket's Revisiones box; whoever asked hears the answer and can withdraw
an open request. Events of the ticket (requested, answered, cancelled),
so they show in its history and the activity feed. Viewers can be asked
and answer; not oneself, nor twice the same person with one pending.

API: requestReview / answerReview / cancelReview, Issue.reviews,
myIssues(reviewing); POST /api/issues/{issue}/reviews,
POST …/reviews/{id}/answer, DELETE …/reviews/{id}. MCP: request_review,
answer_review, my_issues reviewing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 20:38:30 +02:00
172c53d21d feat(web): the board works from the keyboard
With a card focused (Tab reaches them): arrows move between cards, also
across swimlanes' columns; Shift+←/→ moves the card to the next column
with the same rules as dragging it (allowed transitions, WIP warning,
reason when the status asks for one) and keeps the focus on it; p, e
and a open its priority, estimate or assignee; Enter opens it. The «?»
help lists them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 20:20:30 +02:00
dfb5f973f4 feat: «Mi día», the few tickets I pick for today
A home panel with the tickets picked for today (10 at most), crossed out
as they get done and counted («1 de 3 terminados»), my open tickets to
add with one click, and what was left unfinished on my last day to carry
over. «☀ Mi día» on a ticket adds it or takes it out. The day is the
person's own (YYYY-MM-DD from the browser); private, no history (Mongo
`my_days`).

API: myDay(day), addToMyDay, removeFromMyDay; GET /api/me/day,
PUT/DELETE /api/me/day/{issue}. MCP: my_day.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 20:18:10 +02:00
b88296c22b feat: «Recordármelo», a reminder of a ticket only for me
On a ticket, «Recordármelo» offers in an hour, this evening, tomorrow or
next Monday at 9, in a week, or any date and time, with an optional note.
The ticket shows the waiting one («Te lo recordaré el mié 21 a las 9:00»)
to change or drop. Every minute a scheduler tells the ones whose time
came as a REMINDER notification (bell, push, e-mail), once, and forgets
those whose ticket went to the trash or out of reach. One per person and
ticket, private, no history (Mongo `issue_reminders`).

API: setReminder / clearReminder, Issue.myReminder, myReminders;
PUT/DELETE /api/issues/{issue}/reminder, GET /api/me/reminders. MCP:
remind_me.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 20:08:50 +02:00
d79ef418d3 feat: follow an epic with everything under it
«Seguir todo» on a ticket with subtasks (and on each epic of the Epics
page) tells you of it and of every ticket hanging from it, at any depth,
also the ones added later: their moves, assignments, comments and plan
changes, as a follower of each, plus when a ticket is placed under it.
Guests of a private project are left out. Personal, no history (Mongo
`tree_follows`).

API: followTree / unfollowTree, Issue.followingTree, PUT/DELETE
/api/issues/{issue}/follow-tree. MCP: watch_issue with tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:59:04 +02:00
179d6a5e94 fix(web): bulk changes of more than 100 tickets go in batches
The list shows up to 200 tickets and «select all» picks them all, but
the API takes 100 per bulk change: above that the whole change failed
(«At most 100 tickets at once») and nothing changed. The gateway now
sends batches of 100 one after another and adds up the results, for the
list and the backlog alike.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:34:09 +02:00
199b60e4ce feat: conversations on a ticket are settled, and you see who is typing
A top-level comment and its replies can be resolved by whoever can
change the ticket (CommentThreadResolvedEvent), with an optional
conclusion added as a reply marked as a decision, so it shows above the
ticket. A settled conversation folds to one line (topic, who settled it
and the conclusion) and can be unfolded or reopened; the header counts
the open ones, and finishing the ticket with one open asks first.

«Ana está escribiendo…» shows in the conversation being answered, at
once: a `typing` mutation publishes to a Redis channel per ticket
(`typing` subscription), never stored. In replies, Enter sends and
Shift+Enter breaks the line, except inside lists and code.

API: resolveThread / PUT-DELETE /api/comments/{id}/resolution, Comment
.resolved/resolvedBy/resolvedAt, typing mutation and subscription. MCP:
resolve_thread.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 22:35:46 +02:00
c0b0417b6d fix(api): automations without conditions list and run again
Mongo leaves empty objects out, so a rule saved with no conditions came
back without them: the automations list failed («Cannot return null for
non-nullable field Automation.conditions») and the rules engine threw
on it, so those rules never ran. The repository now gives them an empty
object, as the views of «my work» already did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 19:53:56 +02:00
33c593521e feat: statuses and ticket types take any colour from a colour picker
Settings shows a native colour picker (the palette as suggestions)
instead of the list of eleven names. The API takes a palette name, as
before, or any #rrggbb; picking a palette colour still saves its name,
so it keeps its hand-made dark version. A free colour gets one variant
per theme, computed with the `color` library (moved towards white or
black until it contrasts 3:1 with the surface) and applied with
light-dark(): a pale yellow darkens on white, a navy lightens on dark.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 16:08:47 +02:00
034704e46f feat: alerts open bugs, and repeats of the same problem go to that bug
Owners and admins turn on a secret URL per project (Settings →
Integrations), POST /api/webhooks/alerts/{project}/{token}, that Sentry,
Uptime Kuma, Prometheus Alertmanager, Grafana or any tool sending JSON
with a title can call. A new problem opens a bug as TaskStream (label
«alerta», chosen type and priority); while that bug is open, the same
fingerprint goes to it (a comment at most every 30 minutes) and
«resolved» is said on it; once it is closed, a new bug names the old
one. Alerts of a project are handled one at a time, so two at once open
one bug, and a bug not yet projected still counts as open. Unknown
project, alerts off or a bad token all answer 403.

TaskStream may now comment as the system actor, like it already opened
tickets for public proposals. Stored in Mongo (alert_integrations,
alert_traces), not in the event store.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:36:55 +02:00
42a21412d9 feat(web): «Deshacer» after moving, reassigning or trashing a ticket
A toast offers to undo for seven seconds (or Ctrl/⌘ Z outside a field)
by running the opposite command: the card back to its column or the
ticket to its status (the workflow may refuse it), the previous
assignee, or the ticket back from the trash. The undo itself only says
«Deshecho». On the board, from the card's assignee; on the ticket page,
status, assignee and trash, which no longer asks for confirmation.

The toast is the app's own (UndoToastComponent): pi-snack-bar cannot
tell its button from the timeout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:43:54 +02:00
70e4fbd399 feat: comments marked as decisions, on top of the ticket and searchable
Whoever can change the ticket flags a comment as a decision (new event
CommentDecisionMarkedEvent on the comment). The ticket page lists its
decisions above the description, with a link to the comment in the
conversation (`?comment=<id>` scrolls to it and points at it), and the
search palette finds them by their text across my projects.

API: markDecision / PUT-DELETE /api/comments/{id}/decision; Issue.decisions
and GET /api/issues/{issue}/decisions; decisions(projectId?, search) and
GET /api/projects/{project}/decisions, GET /api/me/decisions. MCP:
mark_decision, search_decisions, and get_issue includes them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:32:07 +02:00
8f83fdefb6 fix(web): the ticket page fits a phone again
Its main column was a grid without columns, so it grew to the width of
its widest form field (650 px on a 390 px screen) and cut the text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:23:27 +02:00
dfa42a8a1a feat: pinned tickets, at hand on the home page and in search
Each person pins up to 20 tickets from the ticket's page («Fijar»). They
show as a strip under the projects on the home page and in the search
palette before typing. Private and without history, like the views of
«my work» (Mongo `pinned_issues`); tickets in the trash or out of reach
are left out, and when the list is full the unreadable ones are dropped
first.

API: GET /api/me/pins, PUT/DELETE /api/issues/{issue}/pin; GraphQL
myPins, pinIssue, unpinIssue and Issue.pinned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 10:15:28 +02:00
423f82c1a3 feat(web): priority, estimate and assignee change right on the board card
The card's title is now the link (stretched over the card), and the three
chips carry an invisible native select each, so they work by keyboard,
on a phone and with a screen reader without opening the ticket. Dragging
still works from anywhere on the card.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 09:41:23 +02:00
a32c937765 fix(api): a read-side failure waits for its event instead of losing it
The Redis consumer used to leave a failed event pending and go on to the
next ones, retrying it only at the next start. With Mongo down for a
while, a later change of the same ticket was then projected first, and
the version check dropped the earlier one for good (a status change
could vanish from the read model), besides the read side lagging until
a restart.

Now a failed event is retried in place, waiting longer each time (up to
30 s), and the next one waits for it. Failures of the infrastructure
(Mongo, Redis, the network: isTransientError) wait as long as it takes,
so the read side catches up by itself; an event that keeps failing on
its own goes, after 5 attempts, to <stream>:dead with its error, so it
does not hold up the rest.

GET /api/health reports the way from write to read (events waiting in
the outbox, pending to be projected, set aside) and says `degraded`,
still with 200, when something is stuck.

Tried for real: with Mongo stopped, two status changes went in; the
first failed to project with ECONNREFUSED, and once Mongo was back the
ticket's history had both steps, in order, without restarting the API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 22:24:19 +02:00
d66b1f3ece feat: a day of the activity map opens what happened, ticket by ticket
On the project dashboard, a day with activity can be picked on the map:
a side panel lists that day's moves grouped by ticket (each key opens
it), with the time of each, and the project's own (sprints, releases,
members) apart.

The activity feed takes an `after` date (GraphQL, REST), so with
`before` it reads a window such as a day; the MCP's
get_project_activity takes a `day`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:42:30 +02:00
de9c8a7780 fix(web): «Desde tu última visita» keeps its four numbers in line
Each number sat under its label, and in a narrow panel «Vueltos de QA»
takes two lines, so its number fell lower than the others. The numbers
go on top now (the markup stays label then value).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:42:27 +02:00
4969fb3f6d fix(web): settings and bell use accessible checkboxes, not slide toggles
@pi/ui's slide toggle hides its input with display: none and gives it
no name: neither the Tab key nor a screen reader could reach the
switches in settings (visibility, the public's proposals, the
definition of done, the link for clients) or the bell's (e-mail copy,
notices on this device). They are pi-checkbox now, each with a name
saying what it turns on: reachable by keyboard, toggled with the space
bar and announced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:20:06 +02:00
e10c9b84bf refactor(web): settings switches are @pi/ui's slide toggle
Visibility, the public's proposals, the definition of done and the link
for clients each had a hand-made switch; they use pi-slide-toggle now,
like the bell's, with their state written beside it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:12:13 +02:00
fc90c79f7f fix(web): notices, day numbers and «Responder» as tall as they are
@pi/ui gives every button a fixed height, side padding and a minimum
width. Three of ours, laid out by their own component, took it:
- a notice in the bell's panel stayed 40 px tall, so a two-line one ran
  over the next;
- a day number in the calendar became a 40 px pill that ran into the
  sprint bands under it (today's, coloured, showed it);
- «Responder» under a comment became a grey pill sitting on its line.
They keep their own box now, and «Responder» is a text link.

The home page shows my projects first, right under the greeting: what
most visits come to open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:00:19 +02:00
8fe581ff3e feat: a dashboard for clients, behind a secret link
Owners and admins turn it on in Configuración → General, pick what it
shows (tickets per status, the sprint under way, release forecasts,
open epics and activity per day) and can renew or drop the link. Whoever
has it sees /share/{token} without an account: only those blocks, no
people nor single tickets but the epics, no links into the app, read
again every minute. A renewed or dropped link says it no longer works.

REST (GET/PUT/DELETE /api/projects/{project}/share and the public
GET /api/shared/dashboards/{token}) and GraphQL (dashboardShare,
shareDashboard, unshareDashboard, sharedDashboard). The token is kept
in dashboard_shares and goes with the project.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 10:39:26 +02:00
4dc673997d feat(web): the project dashboard on the team's TV
«Modo pantalla» puts the dashboard full screen, over the rest of the
app: one block at a time and larger (the figures and the flow first),
moving on every 15 seconds, with the project, the time and the live
data. ← and → move, the space bar pauses and Esc leaves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 09:41:59 +02:00
1101b6e926 feat: dashboard maps the activity, follows a ticket's way and the proposals
Three more blocks on the project dashboard, also by REST, GraphQL and
MCP:
- Activity: what moved each day of the last 12 weeks, in a heatmap,
  with the busiest weekday and the longest lull.
- A ticket's way: for the tickets done in the last 90 days, where the
  time from created to done went, status by status (waiting included),
  and how much of it was work (flow efficiency).
- The public's proposals: received, waiting, under way, done and
  discarded, and the most voted still unanswered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 09:29:38 +02:00
9ccc3b5967 perf(web): the command palette and the bell load once the page is up
They come with @defer (on idle; on interaction): their code, their
stores and the bell's queries and live subscription leave the start,
and main no longer carries anything of the tracker's presenters or
application. Meanwhile the header shows a still copy of each with the
same box, so nothing moves when they arrive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 08:55:19 +02:00
f448207e88 refactor(web): page state with its page, the root keeps the shell's
Only what the header needs from the start stays at the root: the
session, my projects for the command palette, the inbox and push for
the bell, and their adapters. The home page's facades (my work, the
rest of the home, my views, the calendar), Explore's and the proposal
form's are provided by their routes, whose injectors keep them for
when you come back. The other adapters come with the tracker's routes,
now loaded lazily.

The web libs declare "sideEffects": false, so a chunk carries what it
uses and not everything an index.ts exports: main goes from 1.31 MB
to 432 kB of the same initial total, which is still held up by @pi/ui
(one module, with all of Material) and the GraphQL documents, both
needed by the header.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 08:34:17 +02:00
d0efaeba80 feat(web): English paths, and a portfolio that updates itself
/cartera, /explorar and /p/:key/proponer are /portfolio, /explore and
/p/:key/propose now, like every other path; the Spanish ones redirect,
so links already shared keep working. Settings sections go by
?section= with English names.

The portfolio reads itself again, quietly, when a ticket, sprint or
release of the projects shown changes, when my projects change, or when
the live connection comes back; «En directo» replaces the refresh
button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 22:15:32 +02:00
9044690348 feat(web): a home page in two columns, with four figures on top
The home page was two grids of panels side by side, each row as tall as
its tallest panel, which left gaps. Now: four figures of the same size
(my open tickets, waiting for QA, finished by me and by the team this
week), the week, my views, and two columns that stack on their own: my
tickets and the latest on the left; sprints, QA, following, releases,
who is online and «Retomar» on the right. Project cards are as tall as
their row, with the team at the bottom, and a phone only lists the
days of the week that have something.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:37:45 +02:00
0564810d36 feat(web): settings by sections, with a menu
The settings page was one long strip of panels of uneven widths. It has
a menu now (General, Flujo de trabajo, Tickets, Equipos,
Automatizaciones, Integraciones, Importar y exportar, Zona peligrosa),
one section at a time and in the URL (?seccion=), each panel full width.
A dot marks the sections with unsaved changes, and the save bar follows
whichever section is open. On a phone the menu is a row of chips.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:20:56 +02:00
2cbe6af878 fix(web): ticket types fit their settings panel
A type row needed some 41rem in a panel of about 26rem, so names were
cut short and the selects spilled out. It takes two lines now (three
on a phone), and on a phone an area keeps «Archivar» beside its name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 20:55:22 +02:00
2add1b6a31 fix(web): room for the title in the new ticket form
A field's label floats above its box and only other fields leave room
for it, so «Título» sat on the type selector. Also: the mention hint
had the shell's paragraph size instead of a hint's, and «Asignar a» and
«Release» offered «Todas las personas» / «Todas las releases», filter
words, for «nobody» and «none».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 20:55:22 +02:00
e006d245d7 chore(web): @pi/ui 0.0.9, fields show errors once touched
A form no longer opens with its required fields painted red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 20:34:48 +02:00
f4e40ffabc fix(web): forms open as new, not touched from the last time
Opening a drawer form refilled its model only, so a field touched the
time before came back showing its errors. They reset the form state
now (touched and dirty) along with the values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 20:25:52 +02:00
a32b669c8b fix(api): ship web-push in the API image
The image installs only the dependencies listed in apps/api/package.json,
and web-push was added to the root one alone with push notifications, so
the API died at start with «Cannot find module 'web-push'» and its
healthcheck never passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:49:39 +02:00
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
21bc598590 feat(web): dependencies between epics on the roadmap
Arrows from an epic to the ones that wait for it («bloquea» / «depende
de»), drawn over the bars; dashed red, plus a note in words, when one
starts before the epic blocking it ends. The next batch and the ideas
kept or dropped are written down in docs/10-futuro.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:00:14 +02:00
f7e850068b feat: custom fields per project
Text, number, date or option-list fields defined in the project settings
(archived, never removed; their type is fixed once saved). Tickets hold
them, edited in the side panel, listed under the title and filtered by
option in the list view; also over GraphQL, REST (?field=ID:value) and MCP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 09:31:12 +02:00
233c46e9fa feat: push notifications on each device (Web Push)
- What reaches the bell also reaches the devices where the person
  turned «Avisos en este dispositivo» on, with TaskStream closed: the
  Notifier sends each new notification encrypted (aes128gcm) and signed
  with VAPID, and forgets subscriptions the push service dropped
  (404/410). A failure never blocks the event stream.
- The API keeps one subscription per device (push_subscriptions) and
  exposes the VAPID public key in the notification settings; GraphQL
  subscribePush/unsubscribePush, REST /me/push-subscriptions. Without
  VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY push is off.
- The web registers /push-sw.js, a service worker that only shows the
  notification and opens its ticket on a tap (no caching, served
  without cache by nginx), and a manifest that makes it installable,
  which iPhones need for push.
- The bell's toggles show their text next to them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 08:38:07 +02:00
5ee627e689 chore(web): @pi/ui 0.0.8, ticked checkboxes follow the theme
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 23:41:24 +02:00
f45069a327 feat: a calendar to subscribe to, and the backlog's corners
- Each person can subscribe their dates in Google Calendar, Outlook or
  Apple Calendar: GET /api/calendar/{token}.ics (no session) brings
  their unfinished tickets with a due date (assigned or followed), the
  planned and running sprints and the planned releases of their
  projects, as all-day events linking to the web. The token is made,
  renewed or dropped under /me/calendar (and in GraphQL); in the web,
  from «Calendario» in «Mi trabajo».
- The backlog panel keeps the selected rows' background inside its
  rounded corners.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:48:33 +02:00
993f80b4b9 feat: move unfinished tickets between releases
- Bulk editing gets «Release…» (open releases or none), and the list
  view a release filter, also reachable from each release («Ver en la
  lista», ?release=).
- Archiving a release asks where its unfinished tickets go, as closing
  a sprint does: ReleaseArchivedEvent carries `moveTo` (an open release
  of the same project, or none) and the saga moves them there; finished
  ones stay. In GraphQL, REST and the MCP's archive_release.
- Chapter 10 lists the next features and the ideas to weigh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:55:45 +02:00
0a73eda9b2 chore(web): @pi/ui 0.0.7, empty checkboxes can be seen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:15:06 +02:00
8dfd35e611 fix(web): task lists in formatted text line up with the other items
The box takes the bullet's place instead of the whole item moving left,
and it is no longer `disabled` (browsers grey those out): it keeps the
brand colour, out of reach of clicks and the keyboard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:50:03 +02:00
1550332d41 refactor(api): buses only in services, atomic commands, @Prop schemas
- Mongo read models are @Schema/@Prop classes (SchemaFactory), still
  loose: the props declared are the indexed or shaped ones.
- CommandBus and QueryBus are used by the application services alone.
  Duplicating a ticket, bulk updates, CSV imports, proposals and
  emptying the trash chain several commands, so they are service
  methods now and their composite commands are gone; every command
  handler is atomic. No forwardRef is needed.
- The due-date and trash timers are entry points: they live in the
  presenters and call the services. The automations event handler has
  its own file and calls AutomationsService.
- The tracker's user directory asks IdentityService; the composition
  root hands the identity application module to the tracker
  infrastructure (withIdentity), with no global module.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:56:59 +02:00
2c501a50d0 feat: uuid ids everywhere, pickers for linked tickets, @pi/ui checkboxes
- Postgres: every table has a uuid `id` (gen_random_uuid()); the event
  store keeps its relay order in `seq`, and the lookup tables their
  uniqueness in constraints of their own.
- MongoDB: `_id` is Mongo's ObjectId; `id` is the domain id (the same
  uuid as the aggregate's stream in Postgres), unique, and every query
  goes by it. Production starts from empty databases.
- Web: linking tickets and hanging one from a parent are picked from a
  searchable select (links across projects, parents within the project)
  instead of typing a key; every checkbox is @pi/ui's pi-checkbox.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:16:43 +02:00
10c8f21ff4 chore(web): @pi/ui 0.0.6, the file upload zone keeps its own look
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:18:59 +02:00
836c6f63bc feat(web): write descriptions and comments with a visual editor
ts-rich-editor (TipTap) shows the formatting while writing: bold,
italic, strike, code, headings, lists, task lists, quotes, code blocks
and links, with @ suggesting the project's members and pasted
screenshots uploaded as attachments. It reads and writes the same
Markdown the API keeps (mentions as @[Name](id), images as
attachment:<id>, tables too), so nothing already written changes.

Used for comments, replies, editing a comment or a description, a new
ticket from the board and the public proposal form; the Markdown hint
and the paste directive are gone. List markers sat on a line of their
own, and task lists showed a stray bullet, in the editor and when read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:00:41 +02:00
9892443eda chore(web): @pi/ui 0.0.5, the sprint dates sit centred in their field
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:33:58 +02:00
1383e15f3f fix(api): the API failed to start after moving the proposal limit to Redis
ProposalLimiter, in the presenters, asked for the RateLimitStore port,
which only the application layer sees, so Nest could not build it and
the container never became healthy. The count now lives in an
application service, ProposalQuota; the presenter only answers 429.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:01:38 +02:00
3d8800e58c chore(web): @pi/ui 0.0.4, initial bundle down to 1.88 MB
The library no longer registers Chart.js on import nor depends on lodash,
so the size warning is gone. Chapter 10 updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:55:01 +02:00
2030fe0cd9 docs: what was done in the landing and in @pi/ui (chapter 10)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:21:38 +02:00
c91df110f8 feat: steadier live updates and notices for public proposals
- The web pings the live connection, reconnects when it goes quiet and
  reloads what each screen shows after reconnecting.
- Dropping a card on a column it cannot go to puts it back.
- Owners and admins get a notice when a proposal arrives.
- The proposal limit lives in Redis, so it holds across instances.
- REST tells whether I voted; pasted images leave the attachment list.
- Deleting a project removes its Git integration; no lint warnings left.
- Reactions and the blocked badge are components of their own.
- Docs: what is done and what is left (chapter 10), Word and PDF.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:57:21 +02:00
74903f7842 docs: what is left (chapter 10)
What it takes to call the product done in production, the known bugs and
debt, the next features in order and what is set aside for now. The note
on chat notifications moves here from the docs index. Word and PDF
regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:42:54 +02:00
f2001aca88 feat: formatted text, a list view, ticket details, public proposals and votes
Formatted text
- Descriptions, comments, replies and release notes render Markdown,
  sanitised with DOMPurify (tested against scripts, handlers and
  javascript: links). A pasted image is uploaded and written as
  ![name](attachment:<id>); the web fetches it with the reader's session,
  so it works on private projects. Every text box has a preview. The
  attachment upload now answers its attachmentId.

List view
- /p/:key/list: every ticket as rows, sortable by any column, filtered by
  text, open/closed, type, priority and assignee; status, priority and
  assignee change in place; bulk edit (the backlog's bar, now shared).

Ticket details
- Reactions on comments (GitHub's eight), for members and viewers.
- Duplicate a ticket: what it says, not where it stands.
- blockedBy: open tickets it waits for (BLOCKS / DEPENDS_ON), worked out
  when reading with one query per board or page; shown on cards, list
  rows and the ticket.

Public proposals
- A public project can take bug reports and ideas from anyone
  (acceptsProposals): POST /api/explore/projects/{project}/proposals and
  the form at /p/:key/proponer. They become tickets labelled «propuesta»,
  opened by TaskStream; a honeypot field and 5 per address every 15
  minutes keep spam out. No e-mail is asked: it would be public.

Votes
- Anyone signed in votes on a public project's tickets, members on a
  private one (voteIssue, PUT/DELETE /api/issues/{issue}/vote): votes and
  voted on each ticket, buttons on the ticket and the roadmap, a column
  in the list. The showcase carries votes and the open proposals.

MCP: vote_issue, duplicate_issue, react_to_comment; listings show
blockers and votes. Docs, Word and PDF updated; chat notifications
(Discord, Slack, Telegram) noted for later.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:36:11 +02:00
298766ad1a fix(web): cards move between board columns again
Since the swimlanes, each column's list is stamped from a template
declared outside the cdkDropListGroup, and a template only sees the
drop groups around where it is declared: every column was on its own and
no card could leave its column. The lists of each row are now connected
by id, with and without swimlanes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:53:03 +02:00
3c7c1f7f0e feat(web): mention people with @ when replying to a comment
The reply box suggests the project's members as the main comment box
does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:29:28 +02:00
f986f17968 feat: whoever opens or gets a ticket follows it
Like a commenter, whoever opens a ticket and whoever it is assigned to
(also on reassigning) follow it from then on, so they hear of its
priority, due date and sprint changes and find it under "Que sigo".
Existing tickets are left as they are. Docs, Word and PDF updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:29:28 +02:00
5ae1d05d30 fix(api): accept Pireon webhooks from the password client too
Pireon signs each webhook with the secret of the client that registered
it, and the API has two clients (the web app's and the password
grant's). Only PIREON_WEBHOOK_SECRET was accepted, so a token revoked
for someone signed in with a password went unheard. A delivery now
verifies with PIREON_WEBHOOK_SECRET or PIREON_PASSWORD_WEBHOOK_SECRET;
the same event arriving from both clients changes nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:29:27 +02:00
66b09ecf4d fix(web): the ticket page shows its own changes at once, and a tidier header
The ticket page only refreshed when the live update arrived; a dropped or
slow socket left an edit, a new subtask or a comment unseen until the
page was reloaded. After each change made there, it now asks for the
ticket, its comments and its subtasks again (twice, since the API
projects its read model a moment after answering).

"Seguir" and "Editar" are two matching buttons, side by side and level
with the title's first line (under it on narrow screens), instead of a
large button with a stray arrow and a loose grey pencil.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:07:04 +02:00
f6417248c7 feat: tickets I follow, and more news for followers
"Que sigo" on the home page lists the open tickets I follow, whoever
works on them; `watching` filters my issues (REST, GraphQL, MCP) and a
project's search (none without a session).

Followers now also hear when a ticket's priority or due date changes,
and when it enters or leaves a sprint. Smaller edits (title, labels…)
still do not notify.

Docs (with the REPLIED row the replies change missed), Word and PDF
updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:29:17 +02:00
ea4ef82239 feat: replies to comments
A comment can answer another one (replyTo), one level deep: answering a
reply joins the same thread, and it must be on the same ticket. Whoever
wrote the comment gets a new REPLIED notification, and the activity says
"respondió a un comentario".

A comment deleted while it has replies stays, without its text
(deleted: true), so the thread still reads; it goes with its last reply
and does not count in commentCount.

The ticket page shows threads with their replies indented and a
"Responder" box; REST, GraphQL and the MCP (add_comment, get_issue) take
and show replyTo. Existing comments are unchanged. Docs, Word and PDF
updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:14:37 +02:00
c2928d2385 feat: several Git repositories per project
A project keeps a list of repositories (api, web, admin…), each with its
own secret, instead of a single webhook secret. They all call the same
URL; the secret that verifies the signature tells which one it was, and
every commit, branch or pull request is linked with its repository's
name (shown on the ticket and in the MCP). Rotating or removing one
leaves the others alone, and what it linked stays.

A project that had turned the single webhook on keeps its secret, as a
repository named "principal".

API: the enable/disable endpoints and mutations give way to adding a
repository, rotating its secret and removing it (REST and GraphQL). The
web settings card lists the repositories. Docs, Word and PDF updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:39:59 +02:00
e6ac02d328 feat(mcp): sprints, epics, backlog, trash, checklists and release notes
Thirty new tools (54 in all) for what the API gained since the MCP:
create a project and read its public showcase; sprints (list, progress
and burndown, plan, change, start, close with carry-over, delete, move a
ticket in or out); epics and the backlog (list epics with progress, set
a ticket's parent, rank); bulk edit of up to 100 tickets by key, saying
which refused; the trash; checklists; following a ticket, removing a
relation, editing and deleting comments; editing and archiving releases,
drafting and saving their notes; notifications.

Searching filters by epic, sprint and dates and sorts by backlog order;
get_issue shows parent, checklist, commits and followers; create and
update take parent and start date.

API: `unreadOnly` and `dryRun` were documented as objects in the
OpenAPI; they are booleans now. Chapter 9 lists every tool; Word and PDF
regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:17:10 +02:00
58fe5e8301 fix: settings cards with the page's padding and compact buttons
The Git, automations, webhooks and import cards had no inner padding and
oversized buttons, because the settings page's styles do not reach child
components; they now share a stylesheet with the same pills. The template
button gets its own column instead of squeezing the type's name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:23:20 +02:00
62fac8ef45 fix: ship nodemailer in the API image
E-mail copies of notifications need nodemailer at runtime, but the image
installs only the dependencies listed in apps/api/package.json, so the API
crashed on start with "Cannot find module 'nodemailer'" and the container
never became healthy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 19:58:06 +02:00
8c998d7423 feat: a sprint calendar
A Calendario tab lays the project's sprints out month by month as bands,
with the releases and the tickets due on each day. Owners and admins plan
a sprint by clicking the day it starts, and the whole plan downloads as an
iCalendar file. Tickets can be searched by due-date range.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 19:20:28 +02:00
feea58d0e8 feat: a public showcase of each project
A public endpoint sums up a public project for websites: epics with their
dates and progress, the latest releases with the notes of shipped ones,
the sprint under way and open tickets labelled good-first-issue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 19:13:56 +02:00
bec39da46d feat: a trash for tickets
Deleting a ticket now sends it to the trash, for whoever opened it or an
admin: it disappears from boards, searches and statistics, cannot change,
and comes back from the ticket page or the trash page for 30 days. After
that it is deleted for good with the usual cascade.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:47:30 +02:00
71b807b94b feat: due-date reminders
Every hour the API reminds whoever has a ticket, and whoever follows it,
the day before its due date, on the day and once when it is overdue, in
the app and by e-mail for those who asked. Cards on the board flag what
is due soon or late.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:36:19 +02:00
d6a434ca1d feat: global search and keyboard shortcuts
Ctrl/⌘ K (or /) opens a search over the projects and tickets of all my
projects, driven by the keyboard. g plus a letter moves between project
pages, c creates a ticket on the board, e edits the open ticket and ?
lists every shortcut. My work can now be searched by text through the API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:32:07 +02:00
4148335457 feat: bulk edit tickets
Select several tickets in the backlog and change their status, assignee,
sprint, priority or labels at once. Each ticket goes through the normal
rules and may refuse on its own; the result says which did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:26:53 +02:00
5863d773be feat: templates per ticket type
Each ticket type can carry a starting description and a checklist, edited
in the project settings. New tickets of that type get the description
when they come without one and always get the checklist; the new ticket
form says what the template will add.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:58:37 +02:00
8994590b54 feat: automation rules
Owners and admins can set rules of the kind «when a ticket is created,
moves, gets assigned, gets a comment or its pull request merges; if it is
of this type, priority, area or label; then move it, assign it, label it,
change its priority, comment or put it in the active sprint». Rules act as
TaskStream within the workflow, run once per event and trigger no others.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:53:31 +02:00
1076205acc feat: import and export tickets as CSV
Every ticket of a project can be downloaded as CSV, and a CSV exported
from TaskStream or from Jira can be imported: types, statuses, priorities
and people are matched by id or name, the rest falls back with a warning,
and parents are rebuilt. The settings page previews an import before it
creates anything.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:43:02 +02:00
d43fc620e9 feat: outgoing webhooks
Owners and admins can point a project at other systems: a signed JSON
POST (HMAC-SHA256 of the body) when tickets, comments, releases or sprints
change, filtered by event or family. Deliveries are retried a couple of
times, the last one is shown, and a ping can be sent from the settings.
Private addresses are refused unless WEBHOOKS_ALLOW_PRIVATE is set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:36:19 +02:00
046d77c813 feat: link commits, branches and pull requests to tickets
A project can turn on a webhook for GitHub, Gitea or GitLab. Pushes and
pull or merge requests that name a ticket key show on that ticket under
Desarrollo, with the state of each pull request; the secret is shown and
rotated from the project settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:28:12 +02:00
24b37b6628 feat: release notes
Each release can carry notes in Markdown, written before or after it
ships. A draft is generated from its finished tickets grouped by type, to
edit and copy from the releases page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:19:23 +02:00
45a3dc1e16 feat: a roadmap of epics over time
Tickets get a start date next to their due date, both editable on the
ticket page. The Hoja de ruta tab lays the epics out over the months as
bars filled with their progress, with sprints and releases as landmarks
and a line for today.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:14:32 +02:00
09bd8c19a3 feat: work-in-progress limits and swimlanes on the board
Each status can carry a WIP limit in the project settings; the board shows
it next to the count, marks a column that goes over it and says so when a
card is dropped there. The board can also be grouped in rows by person,
epic, priority or type, remembered per browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:09:21 +02:00
6aec774c92 feat: estimate tickets and see the team's velocity
Story points can be set on the ticket page and when creating a ticket,
and show on cards, column headers, subtasks and the backlog. The Sprints
tab charts the points finished per closed sprint and warns when a planned
sprint holds more than the recent average.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:04:11 +02:00
50d3ffd1dd feat: sprints with burndown and carry-over
Plan sprints with dates and a goal, run one at a time per project, and
put tickets in them from the ticket page. The Sprints tab follows the one
under way with its progress and a burndown in tickets or points against
the ideal line. Closing a sprint sends what was left to the next one or
back to the backlog; the board can be filtered by sprint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:59:01 +02:00
d2e31789a7 fix: keep the user menu and the bell in the header slot
With two elements in the signed-in branch, neither was projected into the
shell's user slot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:58:27 +02:00
298383f19f feat: saved board views and richer board filters
The board now filters by text, type, priority and unassigned tickets as
well. Any combination can be saved as a named view, private or shared with
the project, and opened again with one click.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:14:38 +02:00
0606f53026 feat: an ordered backlog
A Backlog tab lists the tickets still to start in the order the team will
take them; drag them, or use the arrows, to reorder. Moving a ticket only
rewrites that ticket's rank, read from the event store, and board columns
follow the same order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:01:44 +02:00
d4da33d4e8 feat: epics, subtasks and checklists
Tickets can hang from another one of the same project, three levels deep
at most (epic → ticket → subtask). The ticket page lists its subtasks with
their progress, creates new ones and moves the ticket under an epic; a new
Épicas tab shows every epic and how far it has got. Each ticket also gets a
checklist for the small steps that don't deserve a ticket.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:52:43 +02:00
75323115fb fix: make the follow button on a ticket work
The store never sent the follow and unfollow requests to the API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:06:48 +02:00
88928b402f feat: mentions and following tickets
Mention project members in comments with @, follow and unfollow tickets,
and get told when something changes on the tickets you follow. Commenting
on a ticket starts following it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:41:32 +02:00
efa06a0723 feat: notifications in the app and, optionally, by e-mail
Derived from events and kept per person: whoever a ticket is assigned to,
whoever reports or works on it when it moves, fails QA or gets a comment,
and whoever joins a project. Never about what you did yourself, once per
event. They arrive live through a subscription to a bell in the header,
and are copied by e-mail to whoever turns it on when SMTP_URL is set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:19:59 +02:00
0b65c1c912 feat: attachments on tickets and comments
Files are uploaded over REST, stored on disk (a volume in Docker) and
recorded on the ticket as events; the bytes are written first, so no
attachment ever points at a missing file. Only images, video, audio, PDF
and plain text are shown inline, everything with nosniff and a sandbox CSP.
The web shows thumbnails, uploads by drag and drop or by pasting a
screenshot, and attaches files to new comments.

Also adds `nx run api:contracts`, which writes schema.gql and
openapi.json without starting the API or any database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:35:33 +02:00
684 changed files with 114957 additions and 5773 deletions

View File

@@ -16,8 +16,11 @@ EVENTS_BRIDGE_ENABLED=true
# Pireon identity server (https://oauth.pireon.dev/api/documentation) # Pireon identity server (https://oauth.pireon.dev/api/documentation)
PIREON_BASE_URL=https://oauth.pireon.dev PIREON_BASE_URL=https://oauth.pireon.dev
# Secret returned when registering the webhook (POST /api/clients/{client}/webhooks) # Secret returned when registering the webhook (POST /api/clients/{client}/webhooks).
# Pireon signs with the secret of the client that registered it: one per client.
PIREON_WEBHOOK_SECRET= PIREON_WEBHOOK_SECRET=
# The password client's webhook, if it has its own
PIREON_PASSWORD_WEBHOOK_SECRET=
# OAuth clients of task-stream in Pireon (POST /api/clients). Each Pireon client allows ONE grant type: # OAuth clients of task-stream in Pireon (POST /api/clients). Each Pireon client allows ONE grant type:
# authorization_code → "Sign in with Pireon" and Swagger UI. Redirect URIs to register: # authorization_code → "Sign in with Pireon" and Swagger UI. Redirect URIs to register:
# http://localhost:4200/auth/callback, http://localhost:3000/api/docs/oauth2-redirect.html # http://localhost:4200/auth/callback, http://localhost:3000/api/docs/oauth2-redirect.html
@@ -36,6 +39,29 @@ GRAPHQL_PLAYGROUND=true
SWAGGER_ENABLED=true SWAGGER_ENABLED=true
OPENAPI_FILE=apps/api/openapi.json OPENAPI_FILE=apps/api/openapi.json
# Files attached to tickets: folder (a volume in Docker) and size limit per file.
ATTACHMENTS_DIR=data/attachments
ATTACHMENTS_MAX_MB=25
# Optional e-mail copies of notifications. Without SMTP_URL nothing is sent.
SMTP_URL=
MAIL_FROM=
WEB_URL=http://localhost:4200
# Optional push notifications (Web Push). Without both keys nothing is sent.
# Make them once with `npx web-push generate-vapid-keys` (changing them drops
# every device's subscription). VAPID_SUBJECT: a mailto: or https: contact.
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_SUBJECT=mailto:no-reply@pireon.dev
# Outgoing webhooks refuse hosts on this machine or its private network unless true.
WEBHOOKS_ALLOW_PRIVATE=false
# Due-date reminders (hourly). The day changes at midnight in this time zone.
REMINDERS_ENABLED=true
REMINDERS_TIMEZONE=Europe/Madrid
# Docker builds only: token of Pireon's Nexus for @pi/ui (build secret, never stored in an image). # Docker builds only: token of Pireon's Nexus for @pi/ui (build secret, never stored in an image).
# Locally: export NEXUS_TOKEN=$(grep '^//nexus.pireon.dev/repository/npm-releases/:_authToken=' ~/.npmrc | cut -d= -f2-) # Locally: export NEXUS_TOKEN=$(grep '^//nexus.pireon.dev/repository/npm-releases/:_authToken=' ~/.npmrc | cut -d= -f2-)
NEXUS_TOKEN= NEXUS_TOKEN=

3
.gitignore vendored
View File

@@ -53,3 +53,6 @@ Thumbs.db
__screenshots__/ __screenshots__/
vitest.config.*.timestamp*!apps/web/.env vitest.config.*.timestamp*!apps/web/.env
# Attached files in development (ATTACHMENTS_DIR)
/data

View File

@@ -30,6 +30,9 @@ ENV NODE_ENV=production PORT=3000
COPY --from=build-api /workspace/dist/apps/api/package.json /workspace/dist/apps/api/package-lock.json ./ COPY --from=build-api /workspace/dist/apps/api/package.json /workspace/dist/apps/api/package-lock.json ./
RUN npm ci --omit=dev --ignore-scripts --no-audit --no-fund && npm cache clean --force RUN npm ci --omit=dev --ignore-scripts --no-audit --no-fund && npm cache clean --force
COPY --from=build-api /workspace/dist/apps/api/main.js ./ COPY --from=build-api /workspace/dist/apps/api/main.js ./
# Attached files (a volume in docker-compose), writable by the user that runs the API.
RUN mkdir -p /data/attachments && chown node:node /data/attachments
ENV ATTACHMENTS_DIR=/data/attachments
USER node USER node
EXPOSE 3000 EXPOSE 3000
HEALTHCHECK --interval=10s --timeout=3s --start-period=20s --retries=5 \ HEALTHCHECK --interval=10s --timeout=3s --start-period=20s --retries=5 \

View File

@@ -55,7 +55,7 @@ describe('task-stream (e2e)', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test reads arbitrary GraphQL shapes // eslint-disable-next-line @typescript-eslint/no-explicit-any -- test reads arbitrary GraphQL shapes
async function gql<T = Record<string, any>>( async function gql<T = Record<string, any>>(
token: string, token: string | null,
query: string, query: string,
variables?: object, variables?: object,
): Promise<T> { ): Promise<T> {
@@ -63,7 +63,7 @@ describe('task-stream (e2e)', () => {
method: 'POST', method: 'POST',
headers: { headers: {
'content-type': 'application/json', 'content-type': 'application/json',
authorization: `Bearer ${token}`, ...(token ? { authorization: `Bearer ${token}` } : {}),
}, },
body: JSON.stringify({ query, variables }), body: JSON.stringify({ query, variables }),
}); });
@@ -1168,4 +1168,949 @@ describe('task-stream (e2e)', () => {
history.body.map((h: { actor: { name: string } }) => h.actor.name), history.body.map((h: { actor: { name: string } }) => h.actor.name),
).toContain('TaskStream'); ).toContain('TaskStream');
}); });
it('adds custom fields to a project and lets tickets hold and filter by them', async () => {
const key = 'C' + Date.now().toString(36).slice(-5).toUpperCase();
await rest('token-ana', 'POST', '/projects', { key, name: key });
const project = await eventually(
() => rest('token-ana', 'GET', `/projects/${key}`),
(r) => r.status === 200,
);
const settings = project.body['settings'];
expect(settings.customFields).toEqual([]);
// REST: define them (a client that leaves them out keeps them).
const fields = [
{
id: 'BROWSER',
name: 'Navegador',
type: 'SELECT',
options: ['Chrome', 'Safari'],
},
{ id: 'COST', name: 'Coste', type: 'NUMBER' },
];
expect(
(
await rest('token-ana', 'PUT', `/projects/${key}/settings`, {
...settings,
customFields: fields,
})
).status,
).toBe(200);
await eventually(
() => rest('token-ana', 'GET', `/projects/${key}`),
(r) => r.body['settings'].customFields.length === 2,
);
const { customFields: _left, ...older } = settings;
expect(
(await rest('token-ana', 'PUT', `/projects/${key}/settings`, older))
.status,
).toBe(200);
// REST: a ticket with values; a wrong one is refused.
const created = await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'BUG',
title: 'Pago roto',
customFields: { BROWSER: 'Safari', COST: '1,5' },
});
expect(created.status).toBe(201);
const ticket = created.body['key'] as string;
const issue = await eventually(
() => rest('token-ana', 'GET', `/issues/${ticket}`),
(r) => r.status === 200,
);
expect(issue.body['customFields']).toEqual({
BROWSER: 'Safari',
COST: '1.5',
});
expect(
(
await rest('token-ana', 'PATCH', `/issues/${ticket}`, {
customFields: { BROWSER: 'Edge' },
})
).status,
).toBe(400);
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'BUG',
title: 'Otro',
customFields: { BROWSER: 'Chrome' },
});
// GraphQL: change one, clear another; filter by value.
await gql(
'token-ana',
`mutation($i: UpdateIssueInput!) { updateIssue(input: $i) { version } }`,
{
i: {
issueId: issue.body['id'],
customFields: [
{ fieldId: 'BROWSER', value: 'Chrome' },
{ fieldId: 'COST', value: null },
],
},
},
);
const filtered = await eventually(
() =>
gql(
'token-ana',
`query($f: IssueFilterInput!) { issues(filter: $f) { total items { key customFields { fieldId value } } } }`,
{
f: {
projectId: project.body['id'],
customFields: [{ fieldId: 'BROWSER', value: 'Chrome' }],
},
},
),
(d) => d['issues'].total === 2,
);
expect(
filtered['issues'].items.find((i: { key: string }) => i.key === ticket)
.customFields,
).toEqual([{ fieldId: 'BROWSER', value: 'Chrome' }]);
const bySafari = await rest(
'token-ana',
'GET',
`/projects/${key}/issues?field=BROWSER:Safari`,
);
expect(bySafari.body['total']).toBe(0);
const history = await rest('token-ana', 'GET', `/issues/${ticket}/history`);
expect(
history.body.map((h: { summary: string }) => h.summary).join(' | '),
).toContain('los campos personalizados');
});
it('starts a project from another one: its configuration and rules', async () => {
const stamp = Date.now().toString(36).slice(-4).toUpperCase();
const source = `T${stamp}`;
await rest('token-ana', 'POST', '/projects', { key: source, name: source });
const created = await eventually(
() => rest('token-ana', 'GET', `/projects/${source}`),
(r) => r.status === 200,
);
const sourceId = created.body['id'];
await rest('token-ana', 'POST', `/projects/${source}/members`, {
userId: '102',
});
const settings = created.body['settings'];
settings.areas.push({ id: 'DATA', name: 'Datos', archived: false });
expect(
(
await rest('token-ana', 'PUT', `/projects/${source}/settings`, {
...settings,
customFields: [
{
id: 'CLIENT',
name: 'Cliente',
type: 'SELECT',
options: ['Acme'],
},
],
})
).status,
).toBe(200);
const rule = (name: string, kind: string, value: string) =>
gql(
'token-ana',
`mutation($i: CreateAutomationInput!) { createAutomation(input: $i) { id } }`,
{
i: {
projectId: sourceId,
name,
trigger: { kind: 'ISSUE_CREATED' },
action: { kind, value },
},
},
);
const paused = (await rule('Etiquetar', 'ADD_LABEL', 'nuevo'))[
'createAutomation'
].id;
await gql(
'token-ana',
`mutation($a: ID!) { updateAutomation(automationId: $a, active: false) { id } }`,
{ a: paused },
);
// Bob is in the source project only: this one stays behind.
await rule('Para Bob', 'ASSIGN', '102');
// Only who manages the source can copy it.
expect(
(
await rest('token-bob', 'POST', '/projects', {
key: `B${stamp}`,
name: 'Copia',
template: source,
})
).status,
).toBe(403);
const copy = `C${stamp}`;
const { createProject } = await gql(
'token-ana',
`mutation($i: CreateProjectInput!) { createProject(input: $i) { id key } }`,
{ i: { key: copy, name: 'Copia', templateProjectId: sourceId } },
);
expect(createProject.key).toBe(copy);
const copied = await eventually(
() => rest('token-ana', 'GET', `/projects/${copy}`),
(r) => r.status === 200 && r.body['settings'].customFields.length === 1,
);
expect(
copied.body['settings'].areas.map((a: { id: string }) => a.id),
).toContain('DATA');
expect(copied.body['settings'].customFields[0].options).toEqual(['Acme']);
const { automations } = await gql(
'token-ana',
`query($p: ID!) { automations(projectId: $p) { name active } }`,
{ p: createProject.id },
);
expect(automations).toEqual([{ name: 'Etiquetar', active: false }]);
});
it('reads the dashboard: sprint under way, risks, aging and bottlenecks', async () => {
const key = 'D' + Date.now().toString(36).slice(-5).toUpperCase();
await rest('token-ana', 'POST', '/projects', { key, name: key });
await eventually(
() => rest('token-ana', 'GET', `/projects/${key}`),
(r) => r.status === 200,
);
const late = (
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'TASK',
title: 'Late',
dueDate: '2020-01-01',
})
).body['key'];
const waits = (
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'TASK',
title: 'Waits',
})
).body['key'];
await eventually(
() => rest('token-ana', 'GET', `/issues/${waits}`),
(r) => r.status === 200,
);
await rest('token-ana', 'POST', `/issues/${late}/links`, {
type: 'BLOCKS',
targetIssue: waits,
});
const insights = await eventually(
() => rest('token-ana', 'GET', `/projects/${key}/insights`),
(r) => r.status === 200 && r.body['risks'].blockedCount === 1,
);
expect(insights.body['sprint']).toBeNull();
expect(insights.body['risks'].overdue[0]).toMatchObject({
key: late,
dueDate: '2020-01-01',
});
expect(insights.body['risks'].blocked[0].key).toBe(waits);
expect(
insights.body['bottlenecks'].map((b: { status: string }) => b.status),
).toContain('IN_PROGRESS');
const { project } = await gql(
'token-ana',
`query($k: String!) { project(idOrKey: $k) { insights { risks { overdueCount } aging { thresholdDays } } } }`,
{ k: key },
);
expect(project.insights.risks.overdueCount).toBe(1);
// Activity per day (today's creations among it), the journey, no proposals.
const today = new Date().toISOString().slice(0, 10);
expect(insights.body['heatmap']).toHaveLength(84);
expect(
insights.body['heatmap'].find((d: { day: string }) => d.day === today)
?.count,
).toBeGreaterThan(0);
expect(insights.body['journey']).toMatchObject({ samples: 0, steps: [] });
expect(insights.body['proposals']).toBeNull();
// A day of it: only what happened between after and before.
const tomorrow = new Date(Date.parse(`${today}T00:00:00Z`) + 86_400_000);
const ofToday = await rest(
'token-ana',
'GET',
`/projects/${key}/activity?after=${today}T00:00:00.000Z&before=${tomorrow.toISOString()}&limit=200`,
);
expect(ofToday.body.length).toBeGreaterThan(0);
expect(
ofToday.body.every((a: { occurredAt: string }) =>
a.occurredAt.startsWith(today),
),
).toBe(true);
const ofYesterday = await rest(
'token-ana',
'GET',
`/projects/${key}/activity?after=2020-01-01T00:00:00.000Z&before=2020-01-02T00:00:00.000Z`,
);
expect(ofYesterday.body).toEqual([]);
// What changed since a moment, and what the backlog lacks.
const since = new Date(Date.now() - 60_000).toISOString();
const later = await rest(
'token-ana',
'GET',
`/projects/${key}/insights?since=${encodeURIComponent(since)}`,
);
expect(later.body['changes']).toMatchObject({ since, createdCount: 2 });
expect(later.body['backlog']).toMatchObject({
open: 2,
unestimated: 2,
unassigned: 2,
});
expect(
later.body['team'].map((t: { user: { id: string } }) => t.user.id),
).toContain('101');
const unestimated = await rest(
'token-ana',
'GET',
`/projects/${key}/issues?noEstimate=true&noArea=true`,
);
expect(unestimated.body['total']).toBe(2);
expect(later.body['quality'].openBugsByPriority).toHaveLength(5);
expect(later.body['quality'].bugWeeks).toHaveLength(8);
expect(later.body['fields']).toEqual([]);
});
it('keeps my own views of my work, across my projects', async () => {
const key = 'V' + Date.now().toString(36).slice(-5).toUpperCase();
await rest('token-ana', 'POST', '/projects', { key, name: key });
const project = await eventually(
() => rest('token-ana', 'GET', `/projects/${key}`),
(r) => r.status === 200,
);
const bug = (
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'BUG',
title: 'Late bug',
priority: 'CRITICAL',
dueDate: '2020-01-01',
})
).body['key'];
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'TASK',
title: 'Late task',
priority: 'CRITICAL',
dueDate: '2020-01-01',
});
await eventually(
() => rest('token-ana', 'GET', `/issues/${key}-2`),
(r) => r.status === 200,
);
const criteria = {
projectIds: [project.body['id']],
priorities: ['CRITICAL'],
bugsOnly: true,
overdue: true,
};
const saved = await rest('token-ana', 'POST', '/me/views', {
name: ' Bugs críticos vencidos ',
criteria,
});
expect(saved.status).toBe(201);
const views = await rest('token-ana', 'GET', '/me/views');
expect(views.body).toEqual([
expect.objectContaining({ name: 'Bugs críticos vencidos', criteria }),
]);
expect((await rest('token-bob', 'GET', '/me/views')).body).toEqual([]);
// Running it: the same criteria on my work.
const page = await rest(
'token-ana',
'GET',
`/me/issues?assignedToMe=false&project=${project.body['id']}&priority=CRITICAL&bugsOnly=true&overdue=true`,
);
expect(page.body['items'].map((i: { key: string }) => i.key)).toEqual([
bug,
]);
const { myIssues } = await gql(
'token-ana',
`query($p: [ID!]) { myIssues(assignedToMe: false, projectIds: $p, overdue: true) { total } }`,
{ p: [project.body['id']] },
);
expect(myIssues.total).toBe(2);
// Only its owner deletes it.
const id = saved.body['id'];
expect((await rest('token-bob', 'DELETE', `/me/views/${id}`)).status).toBe(
404,
);
expect((await rest('token-ana', 'DELETE', `/me/views/${id}`)).status).toBe(
204,
);
expect((await rest('token-ana', 'GET', '/me/views')).body).toEqual([]);
});
it('keeps unfinished checklists out of DONE when the project asks', async () => {
const key = 'H' + Date.now().toString(36).slice(-5).toUpperCase();
await rest('token-ana', 'POST', '/projects', { key, name: key });
const project = await eventually(
() => rest('token-ana', 'GET', `/projects/${key}`),
(r) => r.status === 200,
);
expect(project.body['settings'].checklistBeforeDone).toBe(false);
await rest('token-ana', 'POST', `/projects/${key}/members`, {
userId: '102',
});
expect(
(
await rest('token-ana', 'PUT', `/projects/${key}/settings`, {
...project.body['settings'],
checklistBeforeDone: true,
})
).status,
).toBe(200);
const issue = (
await rest('token-bob', 'POST', `/projects/${key}/issues`, {
type: 'TASK',
title: 'Con pasos',
})
).body['key'];
await eventually(
() => rest('token-bob', 'GET', `/issues/${issue}`),
(r) => r.status === 200,
);
await rest('token-bob', 'POST', `/issues/${issue}/checklist`, {
text: 'Probar en Safari',
});
for (const status of [
'TODO',
'IN_PROGRESS',
'IN_REVIEW',
'READY_FOR_QA',
'IN_QA',
])
await rest('token-bob', 'PUT', `/issues/${issue}/status`, { status });
const refused = await rest('token-bob', 'PUT', `/issues/${issue}/status`, {
status: 'DONE',
});
expect(refused.status).toBe(422);
expect(refused.body['message']).toMatch(/queda 1 paso/);
// The owner may close it anyway.
expect(
(
await rest('token-ana', 'PUT', `/issues/${issue}/status`, {
status: 'DONE',
})
).status,
).toBe(200);
});
it('archives a project: readable, read-only, back on demand', async () => {
const key = 'A' + Date.now().toString(36).slice(-5).toUpperCase();
await rest('token-ana', 'POST', '/projects', { key, name: key });
await eventually(
() => rest('token-ana', 'GET', `/projects/${key}`),
(r) => r.status === 200,
);
const added = await rest('token-ana', 'POST', `/projects/${key}/members`, {
userId: '102',
});
expect(added.status).toBe(201);
await eventually(
() => rest('token-bob', 'GET', `/projects/${key}`),
(r) => r.status === 200,
);
const created = await rest('token-bob', 'POST', `/projects/${key}/issues`, {
type: 'TASK',
title: 'Antes de archivar',
});
expect(created.body).toMatchObject({ key: `${key}-1` });
const ticket = created.body['key'] as string;
await eventually(
() => rest('token-bob', 'GET', `/issues/${ticket}`),
(r) => r.status === 200,
);
expect(
(await rest('token-bob', 'POST', `/projects/${key}/archive`)).status,
).toBe(403);
expect(
(await rest('token-ana', 'POST', `/projects/${key}/archive`)).status,
).toBe(200);
const archived = await eventually(
() => rest('token-bob', 'GET', `/projects/${key}`),
(r) => !!r.body['archivedAt'],
);
expect(archived.status).toBe(200);
// Still readable; nothing changes.
expect((await rest('token-bob', 'GET', `/issues/${ticket}`)).status).toBe(
200,
);
const refused = await rest('token-bob', 'PATCH', `/issues/${ticket}`, {
title: 'Después',
});
expect(refused.status).toBe(422);
expect(refused.body['message']).toMatch(/archivado/);
expect(
(
await rest('token-bob', 'POST', `/projects/${key}/issues`, {
type: 'TASK',
title: 'Nuevo',
})
).status,
).toBe(422);
const { unarchiveProject } = await gql(
'token-ana',
`mutation($p: ID!) { unarchiveProject(projectId: $p) { version } }`,
{ p: archived.body['id'] },
);
expect(unarchiveProject.version).toBeGreaterThan(0);
await eventually(
() => rest('token-bob', 'GET', `/projects/${key}`),
(r) => r.body['archivedAt'] === null,
);
expect(
(
await rest('token-bob', 'PATCH', `/issues/${ticket}`, {
title: 'Después',
})
).status,
).toBe(200);
});
it('groups members in teams: tickets go to them and @team tells them', async () => {
const key = 'T' + Date.now().toString(36).slice(-5).toUpperCase();
await rest('token-ana', 'POST', '/projects', { key, name: key });
const project = await eventually(
() => rest('token-ana', 'GET', `/projects/${key}`),
(r) => r.status === 200,
);
await rest('token-ana', 'POST', `/projects/${key}/members`, {
userId: '102',
});
expect(
(
await rest('token-ana', 'PUT', `/projects/${key}/settings`, {
...project.body['settings'],
teams: [
{ id: 'FRONT', name: 'Frontend', memberIds: ['102', 'nobody'] },
],
})
).status,
).toBe(200);
const settings = await eventually(
() => rest('token-ana', 'GET', `/projects/${key}`),
(r) => r.body['settings'].teams.length === 1,
);
expect(settings.body['settings'].teams[0].memberIds).toEqual(['102']);
const created = await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'TASK',
title: 'Para el equipo',
team: 'FRONT',
});
const ticket = created.body['key'];
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'TASK',
title: 'Sin equipo',
});
const mine = await eventually(
() => rest('token-ana', 'GET', `/projects/${key}/issues?team=FRONT`),
(r) => r.body?.['total'] === 1,
);
expect(mine.body['items'][0]).toMatchObject({ key: ticket, team: 'FRONT' });
await rest('token-ana', 'POST', `/issues/${ticket}/comments`, {
body: '¿Lo miráis, @[Frontend](team-FRONT)?',
});
const told = await eventually(
() => rest('token-bob', 'GET', '/me/notifications'),
(r) =>
r.body?.items?.some(
(n: { kind: string; issueKey: string }) =>
n.kind === 'MENTIONED' && n.issueKey === ticket,
),
);
expect(
told.body.items.find((n: { kind: string }) => n.kind === 'MENTIONED')
.summary,
).toMatch(/tu equipo Frontend/);
});
it('tells who else is on a ticket, and who is editing it', async () => {
const key = 'P' + Date.now().toString(36).slice(-5).toUpperCase();
await rest('token-ana', 'POST', '/projects', { key, name: key });
await eventually(
() => rest('token-ana', 'GET', `/projects/${key}`),
(r) => r.status === 200,
);
await rest('token-ana', 'POST', `/projects/${key}/members`, {
userId: '102',
});
const created = await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'TASK',
title: 'Aquí estamos',
});
const issue = await eventually(
() => rest('token-bob', 'GET', `/issues/${created.body['key']}`),
(r) => r.status === 200,
);
const id = issue.body['id'];
expect(
(
await rest('token-ana', 'PUT', `/issues/${id}/presence`, {
editing: false,
})
).body,
).toEqual([]);
const { touchPresence } = await gql(
'token-bob',
`mutation($i: ID!) { touchPresence(issueId: $i, editing: true) { user { id name } editing } }`,
{ i: id },
);
expect(touchPresence).toEqual([
{ user: { id: '101', name: 'Ana (lead)' }, editing: false },
]);
const seen = await rest('token-ana', 'PUT', `/issues/${id}/presence`, {});
expect(seen.body).toEqual([
expect.objectContaining({
user: { id: '102', name: 'Bob (frontend)' },
editing: true,
}),
]);
// Outsiders cannot even ask (a private ticket does not exist for them).
expect(
(await rest('token-eve', 'PUT', `/issues/${id}/presence`, {})).status,
).toBe(404);
expect(
(await rest('token-bob', 'DELETE', `/issues/${id}/presence`)).status,
).toBe(204);
expect(
(await rest('token-ana', 'PUT', `/issues/${id}/presence`, {})).body,
).toEqual([]);
});
it('lets a guest see and talk about only their own tickets', async () => {
const key = 'G' + Date.now().toString(36).slice(-5).toUpperCase();
await rest('token-ana', 'POST', '/projects', { key, name: key });
await eventually(
() => rest('token-ana', 'GET', `/projects/${key}`),
(r) => r.status === 200,
);
// Quinn, a client, joins as a guest.
expect((await rest('token-qa', 'GET', '/users/me')).status).toBe(200);
expect(
(
await rest('token-ana', 'POST', `/projects/${key}/members`, {
userId: '103',
role: 'GUEST',
})
).status,
).toBe(201);
await eventually(
() => rest('token-qa', 'GET', `/projects/${key}`),
(r) => r.status === 200,
);
const internal = (
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'TASK',
title: 'Solo del equipo',
})
).body['key'];
// A guest opens a ticket, but does not plan it.
expect(
(
await rest('token-qa', 'POST', `/projects/${key}/issues`, {
type: 'BUG',
title: 'Con asignación',
assigneeId: '101',
})
).status,
).toBe(403);
const mine = (
await rest('token-qa', 'POST', `/projects/${key}/issues`, {
type: 'BUG',
title: 'No puedo pagar',
})
).body['key'];
await eventually(
() => rest('token-qa', 'GET', `/issues/${mine}`),
(r) => r.status === 200,
);
const list = await eventually(
() => rest('token-ana', 'GET', `/projects/${key}/issues`),
(r) => r.body?.['total'] === 2,
);
expect(list.body['total']).toBe(2);
const guestList = await rest('token-qa', 'GET', `/projects/${key}/issues`);
expect(guestList.body['items'].map((i: { key: string }) => i.key)).toEqual([
mine,
]);
expect((await rest('token-qa', 'GET', `/issues/${internal}`)).status).toBe(
404,
);
expect(
(await rest('token-qa', 'PUT', `/issues/${internal}/watch`)).status,
).toBe(404);
// Project-wide views are not theirs.
expect(
(await rest('token-qa', 'GET', `/projects/${key}/activity`)).status,
).toBe(403);
expect(
(await rest('token-qa', 'GET', `/projects/${key}/stats`)).status,
).toBe(403);
// They talk on their ticket, but do not move it.
expect(
(
await rest('token-qa', 'POST', `/issues/${mine}/comments`, {
body: '¿Hay novedades?',
})
).status,
).toBe(201);
expect(
(
await rest('token-qa', 'PUT', `/issues/${mine}/status`, {
status: 'TODO',
})
).status,
).toBe(403);
// Once the team adds them to a ticket, they see it.
await rest('token-ana', 'PUT', `/issues/${internal}/assignee`, {
assigneeId: '103',
});
await eventually(
() => rest('token-qa', 'GET', `/issues/${internal}`),
(r) => r.status === 200,
);
});
it('shows my active projects at a glance (the portfolio)', async () => {
const key = 'F' + Date.now().toString(36).slice(-5).toUpperCase();
await rest('token-ana', 'POST', '/projects', {
key,
name: `Cartera ${key}`,
});
await eventually(
() => rest('token-ana', 'GET', `/projects/${key}`),
(r) => r.status === 200,
);
const epic = (
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'FEATURE',
title: 'Pagos',
dueDate: '2030-01-01',
})
).body['key'];
await eventually(
() => rest('token-ana', 'GET', `/issues/${epic}`),
(r) => r.status === 200,
);
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'TASK',
title: 'Parte',
parent: epic,
dueDate: '2020-01-01',
});
await rest('token-ana', 'POST', `/projects/${key}/releases`, {
name: '1.0',
targetDate: '2030-06-01',
});
const mine = await eventually(
() => rest('token-ana', 'GET', '/me/portfolio'),
(r) =>
r.body?.find?.((p: { key: string }) => p.key === key)?.epics?.length ===
1 &&
r.body.find((p: { key: string }) => p.key === key).releases.length ===
1,
);
const row = mine.body.find((p: { key: string }) => p.key === key);
expect(row).toMatchObject({ total: 2, open: 2, overdue: 1, sprint: null });
expect(row.releases[0]).toMatchObject({ name: '1.0', total: 0 });
expect(row.epics[0].issue.key).toBe(epic);
// Archived projects leave it.
await rest('token-ana', 'POST', `/projects/${key}/archive`);
await eventually(
() => gql('token-ana', `query { portfolio { key } }`),
(d) => !d['portfolio'].some((p: { key: string }) => p.key === key),
);
});
it('shares a dashboard with clients through a secret link', async () => {
const key = 'K' + Date.now().toString(36).slice(-5).toUpperCase();
await rest('token-ana', 'POST', '/projects', {
key,
name: `Cliente ${key}`,
});
await eventually(
() => rest('token-ana', 'GET', `/projects/${key}`),
(r) => r.status === 200,
);
await rest('token-ana', 'POST', `/projects/${key}/members`, {
userId: '102',
role: 'MEMBER',
});
const epic = (
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'FEATURE',
title: 'Pagos',
})
).body['key'];
await eventually(
() => rest('token-ana', 'GET', `/issues/${epic}`),
(r) => r.status === 200,
);
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'TASK',
title: 'Parte',
parent: epic,
});
expect(
(await rest('token-ana', 'GET', `/projects/${key}/share`)).status,
).toBe(204);
// Only known blocks are kept; a member cannot share.
const made = await rest('token-ana', 'PUT', `/projects/${key}/share`, {
blocks: ['epics', 'flow', 'nope'],
});
expect(made.status).toBe(200);
expect(made.body['blocks']).toEqual(['flow', 'epics']);
const token = made.body['token'] as string;
expect(token.length).toBeGreaterThan(30);
expect(
(
await rest('token-bob', 'PUT', `/projects/${key}/share`, {
blocks: ['flow'],
})
).status,
).toBe(403);
// Anyone with the link sees the chosen blocks, nothing else.
const seen = await eventually(
() => rest(null, 'GET', `/shared/dashboards/${token}`),
(r) => r.status === 200 && r.body['epics']?.[0]?.total === 1,
);
expect(seen.body).toMatchObject({
projectKey: key,
sprint: null,
releases: null,
activity: null,
});
expect(
seen.body['flow'].reduce(
(n: number, f: { count: number }) => n + f.count,
0,
),
).toBe(2);
expect(seen.body['epics'][0]).toMatchObject({
key: epic,
title: 'Pagos',
done: 0,
});
const viaGql = await gql(
null,
`query($t: String!) { sharedDashboard(token: $t) { projectKey blocks } }`,
{ t: token },
);
expect(viaGql.sharedDashboard).toEqual({
projectKey: key,
blocks: ['flow', 'epics'],
});
// Changing blocks keeps the link; renewing it kills the old one.
const changed = await rest('token-ana', 'PUT', `/projects/${key}/share`, {
blocks: ['activity'],
});
expect(changed.body['token']).toBe(token);
const renewed = await rest('token-ana', 'PUT', `/projects/${key}/share`, {
blocks: ['activity'],
renew: true,
});
expect(renewed.body['token']).not.toBe(token);
expect(
(await rest(null, 'GET', `/shared/dashboards/${token}`)).status,
).toBe(404);
const fresh = await rest(
null,
'GET',
`/shared/dashboards/${renewed.body['token']}`,
);
expect(fresh.body['activity']).toHaveLength(84);
expect(fresh.body['flow']).toBeNull();
// Off: the link stops working.
expect(
(await rest('token-ana', 'DELETE', `/projects/${key}/share`)).status,
).toBe(204);
expect(
(await rest(null, 'GET', `/shared/dashboards/${renewed.body['token']}`))
.status,
).toBe(404);
});
it('fills the home page: agenda, activity, the week and who is here', async () => {
const key = 'H' + Date.now().toString(36).slice(-5).toUpperCase();
await rest('token-ana', 'POST', '/projects', { key, name: key });
await eventually(
() => rest('token-ana', 'GET', `/projects/${key}`),
(r) => r.status === 200,
);
await rest('token-ana', 'POST', `/projects/${key}/members`, {
userId: '102',
});
const today = new Date().toISOString().slice(0, 10);
const ticket = (
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'TASK',
title: 'Para hoy',
assigneeId: '101',
dueDate: today,
})
).body['key'];
const issue = await eventually(
() => rest('token-bob', 'GET', `/issues/${ticket}`),
(r) => r.status === 200,
);
// Bob is on it right now.
await rest('token-bob', 'PUT', `/issues/${issue.body['id']}/presence`, {
editing: true,
});
const home = await eventually(
() => rest('token-ana', 'GET', '/me/home'),
(r) =>
r.body?.agenda?.some(
(a: { issueKey: string }) => a.issueKey === ticket,
) &&
r.body.activity.some(
(a: { issueKey: string }) => a.issueKey === ticket,
),
);
expect(home.body.agenda).toContainEqual(
expect.objectContaining({ day: today, kind: 'ISSUE', issueKey: ticket }),
);
expect(home.body.online).toContainEqual(
expect.objectContaining({
user: { id: '102', name: 'Bob (frontend)' },
projectKey: key,
issueKey: ticket,
editing: true,
}),
);
expect(home.body.team.perDay).toHaveLength(14);
expect(home.body.firstSteps).toMatchObject({
project: true,
invited: true,
ticket: true,
});
const card = home.body.projects.find(
(p: { projectId: string }) => p.projectId === issue.body['projectId'],
);
expect(card).toMatchObject({ open: 1 });
expect(card.activity).toHaveLength(14);
const { myHome } = await gql(
'token-ana',
`query { myHome { me { thisWeek streak } suggestions { kind } } }`,
);
expect(myHome.me.streak).toBeGreaterThanOrEqual(0);
});
}); });

File diff suppressed because it is too large Load Diff

View File

@@ -25,11 +25,13 @@
"ioredis": "^5.11.1", "ioredis": "^5.11.1",
"joi": "^18.2.9", "joi": "^18.2.9",
"mongoose": "^8.24.4", "mongoose": "^8.24.4",
"nodemailer": "^7.0.13",
"pg": "^8.23.1", "pg": "^8.23.1",
"reflect-metadata": "^0.2.0", "reflect-metadata": "^0.2.0",
"rxjs": "^7.8.0", "rxjs": "^7.8.0",
"tslib": "^2.8.1", "tslib": "^2.8.1",
"typeorm": "^0.3.31", "typeorm": "^0.3.31",
"web-push": "3.6.7",
"ws": "^8.22.0" "ws": "^8.22.0"
} }
} }

View File

@@ -3,15 +3,10 @@
"$schema": "../../node_modules/nx/schemas/project-schema.json", "$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "apps/api/src", "sourceRoot": "apps/api/src",
"projectType": "application", "projectType": "application",
"tags": [ "tags": ["scope:api", "type:app"],
"scope:api",
"type:app"
],
"targets": { "targets": {
"prune-lockfile": { "prune-lockfile": {
"dependsOn": [ "dependsOn": ["build"],
"build"
],
"cache": true, "cache": true,
"executor": "@nx/js:prune-lockfile", "executor": "@nx/js:prune-lockfile",
"outputs": [ "outputs": [
@@ -23,32 +18,23 @@
} }
}, },
"copy-workspace-modules": { "copy-workspace-modules": {
"dependsOn": [ "dependsOn": ["build"],
"build"
],
"cache": true, "cache": true,
"outputs": [ "outputs": ["{workspaceRoot}/dist/apps/api/workspace_modules"],
"{workspaceRoot}/dist/apps/api/workspace_modules"
],
"executor": "@nx/js:copy-workspace-modules", "executor": "@nx/js:copy-workspace-modules",
"options": { "options": {
"buildTarget": "build" "buildTarget": "build"
} }
}, },
"prune": { "prune": {
"dependsOn": [ "dependsOn": ["prune-lockfile", "copy-workspace-modules"],
"prune-lockfile",
"copy-workspace-modules"
],
"executor": "nx:noop" "executor": "nx:noop"
}, },
"serve": { "serve": {
"continuous": true, "continuous": true,
"executor": "@nx/js:node", "executor": "@nx/js:node",
"defaultConfiguration": "development", "defaultConfiguration": "development",
"dependsOn": [ "dependsOn": ["build"],
"build"
],
"options": { "options": {
"buildTarget": "api:build", "buildTarget": "api:build",
"runBuildTargetDependencies": false "runBuildTargetDependencies": false
@@ -73,6 +59,13 @@
"command": "jest --config apps/api/jest.e2e.config.cts --runInBand" "command": "jest --config apps/api/jest.e2e.config.cts --runInBand"
}, },
"cache": false "cache": false
},
"contracts": {
"executor": "nx:run-commands",
"options": {
"command": "node -r ./apps/api/scripts/register.cjs apps/api/scripts/contracts.ts"
},
"cache": false
} }
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,117 @@
/**
* Writes apps/api/schema.gql and apps/api/openapi.json without starting the
* API or touching a database: `npx nx run api:contracts`.
*
* The running API writes the same files on start in development; this is for
* when no database is at hand — a CI job, a laptop without Docker running.
*/
import 'reflect-metadata';
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { InjectionToken, Module, Provider, Type } from '@nestjs/common';
import { MODULE_METADATA } from '@nestjs/common/constants';
import { NestFactory } from '@nestjs/core';
import {
GRAPHQL_SDL_FILE_HEADER,
GraphQLSchemaBuilderModule,
GraphQLSchemaFactory,
} from '@nestjs/graphql';
import { IdentityPresentersModule } from '@task-stream/api/identity/presenters';
import { TrackerPresentersModule } from '@task-stream/api/tracker/presenters';
import { lexicographicSortSchema, printSchema } from 'graphql';
import { AppModule } from '../src/app/app.module';
import { buildOpenApiDocument } from '../src/app/openapi';
/** The GraphQL resolvers a presenters module declares. */
function resolversOf(module: Type): Type[] {
const providers: unknown[] =
Reflect.getMetadata(MODULE_METADATA.PROVIDERS, module) ?? [];
return providers.filter(
(provider): provider is Type =>
typeof provider === 'function' &&
// `@Resolver()` without a type (subscriptions) sets no type, only a name.
Reflect.getMetadataKeys(provider).some((key) =>
String(key).startsWith('graphql:resolver'),
),
);
}
async function graphqlSchema(): Promise<string> {
const app = await NestFactory.create(GraphQLSchemaBuilderModule, {
logger: false,
});
await app.init();
const schema = await app
.get(GraphQLSchemaFactory)
.create([
...resolversOf(IdentityPresentersModule),
...resolversOf(TrackerPresentersModule),
]);
await app.close();
// Same output as GraphQLModule's autoSchemaFile with sortSchema.
return GRAPHQL_SDL_FILE_HEADER + printSchema(lexicographicSortSchema(schema));
}
/** The REST controllers a module declares. */
function controllersOf(module: Type): Type[] {
return Reflect.getMetadata(MODULE_METADATA.CONTROLLERS, module) ?? [];
}
/**
* Empty stand-ins for whatever the controllers' constructors ask for: Swagger
* reads the controllers' metadata and never calls them.
*/
function standIns(controllers: Type[]): Provider[] {
const tokens = new Set<unknown>();
for (const controller of controllers) {
const types: unknown[] =
Reflect.getMetadata('design:paramtypes', controller) ?? [];
const injected: { index: number; param: unknown }[] =
Reflect.getMetadata('self:paramtypes', controller) ?? [];
types.forEach((type, index) =>
tokens.add(injected.find((i) => i.index === index)?.param ?? type),
);
}
return [...tokens].map((token) => ({
provide: token as InjectionToken,
useValue: {},
}));
}
async function openApi(): Promise<string> {
// Only the controllers, with stand-ins for their dependencies: nothing
// connects to Postgres, Mongo, Redis or Pireon.
const controllers = [
...controllersOf(AppModule),
...controllersOf(IdentityPresentersModule),
...controllersOf(TrackerPresentersModule),
];
@Module({ controllers, providers: standIns(controllers) })
class ContractsModule {}
const app = await NestFactory.create(ContractsModule, { logger: false });
app.setGlobalPrefix('api');
const document = buildOpenApiDocument(app, {
pireonBaseUrl: process.env['PIREON_BASE_URL'] || 'https://oauth.pireon.dev',
scopes: (process.env['PIREON_SCOPES'] || 'users:read')
.split(/[\s,]+/)
.filter(Boolean),
passwordGrant: process.env['PIREON_PASSWORD_GRANT'] !== 'false',
});
return JSON.stringify(document, null, 2) + '\n';
}
async function main() {
const root = process.cwd();
writeFileSync(join(root, 'apps/api/schema.gql'), await graphqlSchema());
writeFileSync(join(root, 'apps/api/openapi.json'), await openApi());
}
main().then(
() => process.exit(0),
(error) => {
console.error(error);
process.exit(1);
},
);

View File

@@ -0,0 +1,21 @@
/*
* Lets Node run the TypeScript sources of the monorepo directly: compiles
* them on the fly (decorator metadata included, which Nest needs) and
* resolves the `@task-stream/*` aliases of tsconfig.base.json.
*/
const { join } = require('node:path');
const root = join(__dirname, '../../..');
require('ts-node').register({
project: join(root, 'apps/api/tsconfig.app.json'),
transpileOnly: true,
compilerOptions: {
module: 'commonjs',
moduleResolution: 'node10',
ignoreDeprecations: '6.0',
},
});
require('tsconfig-paths').register({
baseUrl: root,
paths: require(join(root, 'tsconfig.base.json')).compilerOptions.paths,
});

View File

@@ -24,11 +24,16 @@ import { HealthController } from './health.controller';
* Composition root. Each bounded context is assembled outside-in: * Composition root. Each bounded context is assembled outside-in:
* presenters (GraphQL/HTTP) → application (services, CQRS handlers) → infrastructure (adapters) * presenters (GraphQL/HTTP) → application (services, CQRS handlers) → infrastructure (adapters)
*/ */
const identity = IdentityPresentersModule.withApplication( const identityApplication = IdentityApplicationModule.withInfrastructure(
IdentityApplicationModule.withInfrastructure(IdentityInfrastructureModule), IdentityInfrastructureModule,
); );
const identity = IdentityPresentersModule.withApplication(identityApplication);
// The tracker's user directory asks the identity context (IdentityService):
// the same module, so one identity context serves both.
const tracker = TrackerPresentersModule.withApplication( const tracker = TrackerPresentersModule.withApplication(
TrackerApplicationModule.withInfrastructure(TrackerInfrastructureModule), TrackerApplicationModule.withInfrastructure(
TrackerInfrastructureModule.withIdentity(identityApplication),
),
); );
@Module({ @Module({
@@ -47,6 +52,8 @@ const tracker = TrackerPresentersModule.withApplication(
url: config.getOrThrow<string>('POSTGRES_URL'), url: config.getOrThrow<string>('POSTGRES_URL'),
autoLoadEntities: true, autoLoadEntities: true,
synchronize: config.get<boolean>('POSTGRES_SYNCHRONIZE'), synchronize: config.get<boolean>('POSTGRES_SYNCHRONIZE'),
// uuid ids come from gen_random_uuid() (core since PostgreSQL 13).
uuidExtension: 'pgcrypto',
}), }),
}), }),

View File

@@ -23,6 +23,8 @@ export const configSchema = Joi.object({
// Pireon identity server // Pireon identity server
PIREON_BASE_URL: Joi.string().uri().default('https://oauth.pireon.dev'), PIREON_BASE_URL: Joi.string().uri().default('https://oauth.pireon.dev'),
PIREON_WEBHOOK_SECRET: Joi.string().allow('').optional(), PIREON_WEBHOOK_SECRET: Joi.string().allow('').optional(),
// The password client registers its own webhook, signed with its own secret.
PIREON_PASSWORD_WEBHOOK_SECRET: Joi.string().allow('').optional(),
// OAuth clients of task-stream in Pireon (each Pireon client allows one grant type): // OAuth clients of task-stream in Pireon (each Pireon client allows one grant type):
// authorization_code for "Sign in with Pireon" / Swagger UI… // authorization_code for "Sign in with Pireon" / Swagger UI…
PIREON_CLIENT_ID: Joi.string().allow('').optional(), PIREON_CLIENT_ID: Joi.string().allow('').optional(),
@@ -42,4 +44,19 @@ export const configSchema = Joi.object({
SWAGGER_ENABLED: Joi.boolean().default(true), SWAGGER_ENABLED: Joi.boolean().default(true),
// Where to write the OpenAPI document on startup ('' = don't), like the GraphQL SDL. // Where to write the OpenAPI document on startup ('' = don't), like the GraphQL SDL.
OPENAPI_FILE: Joi.string().allow('').default('apps/api/openapi.json'), OPENAPI_FILE: Joi.string().allow('').default('apps/api/openapi.json'),
// Attached files: where they are kept (a volume in Docker) and how big one can be.
ATTACHMENTS_DIR: Joi.string().default('data/attachments'),
ATTACHMENTS_MAX_MB: Joi.number().min(1).max(500).default(25),
// E-mail copies of notifications (optional): without SMTP_URL nothing is sent.
SMTP_URL: Joi.string().allow('').optional(),
MAIL_FROM: Joi.string().allow('').optional(),
// Push notifications (optional): without both VAPID keys nothing is sent.
// Make them once with `npx web-push generate-vapid-keys`.
VAPID_PUBLIC_KEY: Joi.string().allow('').optional(),
VAPID_PRIVATE_KEY: Joi.string().allow('').optional(),
VAPID_SUBJECT: Joi.string().allow('').optional(),
// Public address of the web app, for links in e-mails.
WEB_URL: Joi.string().allow('').optional(),
}); });

View File

@@ -12,7 +12,11 @@ import {
ApiTags, ApiTags,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { Public } from '@task-stream/api/identity/presenters'; import { Public } from '@task-stream/api/identity/presenters';
import { REDIS_CLIENT } from '@task-stream/api/shared/infrastructure'; import {
EventPipelineReading,
EventPipelineStatus,
REDIS_CLIENT,
} from '@task-stream/api/shared/infrastructure';
import Redis from 'ioredis'; import Redis from 'ioredis';
import { Connection } from 'mongoose'; import { Connection } from 'mongoose';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
@@ -24,14 +28,31 @@ export class HealthController {
private readonly postgres: DataSource, private readonly postgres: DataSource,
@InjectConnection() private readonly mongo: Connection, @InjectConnection() private readonly mongo: Connection,
@Inject(REDIS_CLIENT) private readonly redis: Redis, @Inject(REDIS_CLIENT) private readonly redis: Redis,
private readonly events: EventPipelineStatus,
) {} ) {}
@Public() @Public()
@Get() @Get()
@ApiOperation({ summary: 'API and databases status' }) @ApiOperation({
summary: 'API, databases and the way from write to read',
description:
'503 when a database is down. With the databases up, `status` is `degraded` (still 200, so a lagging read side does not get the container restarted) when events wait in the outbox over a minute, 500 or more wait to be projected, or some were set aside after failing again and again (`task-stream:events:dead`); `events.problems` says which.',
})
@ApiOkResponse({ @ApiOkResponse({
schema: { schema: {
example: { status: 'ok', postgres: 'up', mongo: 'up', redis: 'up' }, example: {
status: 'ok',
postgres: 'up',
mongo: 'up',
redis: 'up',
events: {
unpublished: 0,
oldestUnpublishedSeconds: null,
pending: 0,
dead: 0,
problems: [],
},
},
}, },
}) })
@ApiServiceUnavailableResponse({ description: 'A database is down' }) @ApiServiceUnavailableResponse({ description: 'A database is down' })
@@ -44,10 +65,38 @@ export class HealthController {
const [postgres, mongo, redis] = checks.map((c) => const [postgres, mongo, redis] = checks.map((c) =>
c.status === 'fulfilled' ? 'up' : 'down', c.status === 'fulfilled' ? 'up' : 'down',
); );
const result = { status: 'ok', postgres, mongo, redis }; const events = await this.events.read().then(
(reading) => ({ ...reading, problems: problemsOf(reading) }),
() => null,
);
const result = {
status: events?.problems.length ? 'degraded' : 'ok',
postgres,
mongo,
redis,
events,
};
if (checks.some((c) => c.status === 'rejected')) { if (checks.some((c) => c.status === 'rejected')) {
throw new ServiceUnavailableException({ ...result, status: 'degraded' }); throw new ServiceUnavailableException({ ...result, status: 'degraded' });
} }
return result; return result;
} }
} }
/** What is wrong on the way from write to read, if anything. */
function problemsOf(reading: EventPipelineReading): string[] {
const problems: string[] = [];
if ((reading.oldestUnpublishedSeconds ?? 0) > 60)
problems.push(
`${reading.unpublished} events wait in the outbox (the oldest, ${reading.oldestUnpublishedSeconds} s): is Redis reachable?`,
);
if (reading.pending >= 500)
problems.push(
`${reading.pending} events wait to be projected: is Mongo reachable?`,
);
if (reading.dead > 0)
problems.push(
`${reading.dead} events were set aside after failing again and again (task-stream:events:dead)`,
);
return problems;
}

View File

@@ -1,6 +1,6 @@
import { INestApplication } from '@nestjs/common'; import { INestApplication } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { DocumentBuilder, OpenAPIObject, SwaggerModule } from '@nestjs/swagger';
import { import {
PIREON_BEARER, PIREON_BEARER,
PIREON_OAUTH, PIREON_OAUTH,
@@ -35,19 +35,19 @@ show the previous version (or 404 right after a create).
For live updates use the GraphQL subscriptions at \`/graphql\` (graphql-ws). For live updates use the GraphQL subscriptions at \`/graphql\` (graphql-ws).
`.trim(); `.trim();
/** export interface OpenApiSettings {
* Builds the OpenAPI document from the REST controllers, serves Swagger UI and pireonBaseUrl: string;
* (in development) writes it to OPENAPI_FILE so other apps can generate clients. scopes: string[];
*/ passwordGrant: boolean;
export function setupOpenApi(app: INestApplication): void { }
const config = app.get(ConfigService);
if (!config.get<boolean>('SWAGGER_ENABLED')) return;
const pireon = /** The OpenAPI document of the REST controllers. Needs no database. */
config.get<string>('PIREON_BASE_URL') ?? 'https://oauth.pireon.dev'; export function buildOpenApiDocument(
const scopes = (config.get<string>('PIREON_SCOPES') ?? '') app: INestApplication,
.split(/[\s,]+/) settings: OpenApiSettings,
.filter(Boolean); ): OpenAPIObject {
const pireon = settings.pireonBaseUrl;
const scopes = settings.scopes;
const scopeDocs = Object.fromEntries( const scopeDocs = Object.fromEntries(
scopes.map((scope) => [ scopes.map((scope) => [
scope, scope,
@@ -76,7 +76,7 @@ export function setupOpenApi(app: INestApplication): void {
refreshUrl: tokenUrl, refreshUrl: tokenUrl,
scopes: scopeDocs, scopes: scopeDocs,
}, },
...(config.get<boolean>('PIREON_PASSWORD_GRANT') ...(settings.passwordGrant
? { ? {
password: { password: {
tokenUrl, tokenUrl,
@@ -125,6 +125,27 @@ export function setupOpenApi(app: INestApplication): void {
} }
} }
return document;
}
/**
* Builds the OpenAPI document from the REST controllers, serves Swagger UI and
* (in development) writes it to OPENAPI_FILE so other apps can generate clients.
*/
export function setupOpenApi(app: INestApplication): void {
const config = app.get(ConfigService);
if (!config.get<boolean>('SWAGGER_ENABLED')) return;
const scopes = (config.get<string>('PIREON_SCOPES') ?? '')
.split(/[\s,]+/)
.filter(Boolean);
const document = buildOpenApiDocument(app, {
pireonBaseUrl:
config.get<string>('PIREON_BASE_URL') ?? 'https://oauth.pireon.dev',
scopes,
passwordGrant: !!config.get<boolean>('PIREON_PASSWORD_GRANT'),
});
const file = config.get<string>('OPENAPI_FILE'); const file = config.get<string>('OPENAPI_FILE');
if (file) { if (file) {
writeFileSync( writeFileSync(

File diff suppressed because it is too large Load Diff

View File

@@ -4,16 +4,21 @@ import { McpModule } from '@nestjs-mcp/server';
import { TASKSTREAM_API_URL, TaskStreamApi } from './api/taskstream-api'; import { TASKSTREAM_API_URL, TaskStreamApi } from './api/taskstream-api';
import { HealthController } from './health.controller'; import { HealthController } from './health.controller';
import { McpHostsMiddleware } from './mcp-hosts.middleware'; import { McpHostsMiddleware } from './mcp-hosts.middleware';
import { BacklogResolver } from './tools/backlog.resolver';
import { IssuesResolver } from './tools/issues.resolver'; import { IssuesResolver } from './tools/issues.resolver';
import { NotificationsResolver } from './tools/notifications.resolver';
import { ProjectsResolver } from './tools/projects.resolver'; import { ProjectsResolver } from './tools/projects.resolver';
import { ReleasesResolver } from './tools/releases.resolver'; import { ReleasesResolver } from './tools/releases.resolver';
import { SprintsResolver } from './tools/sprints.resolver';
import { UsersResolver } from './tools/users.resolver'; import { UsersResolver } from './tools/users.resolver';
const INSTRUCTIONS = `TaskStream: issue tracker (projects, tickets, QA, releases). const INSTRUCTIONS = `TaskStream: issue tracker (projects, tickets, epics, sprints, QA, releases).
Projects and tickets go by key (WEB, WEB-12). Statuses, ticket types and areas are per Projects and tickets go by key (WEB, WEB-12). Statuses, ticket types and areas are per
project: read their ids with get_project before creating, updating or moving tickets, and project: read their ids with get_project before creating, updating or moving tickets, and
use a ticket's allowedTransitions (get_issue) to move it. Tools act as the user whose Pireon use a ticket's allowedTransitions (get_issue) to move it. Sprints and releases go by id
token the client sends; without one, only public projects can be read.`; (list_sprints, list_releases); the backlog is search_issues with noSprint and sort RANK.
Tools act as the user whose Pireon token the client sends; without one, only public
projects can be read.`;
@Module({ @Module({
imports: [ imports: [
@@ -44,7 +49,10 @@ token the client sends; without one, only public projects can be read.`;
TaskStreamApi, TaskStreamApi,
ProjectsResolver, ProjectsResolver,
IssuesResolver, IssuesResolver,
BacklogResolver,
SprintsResolver,
ReleasesResolver, ReleasesResolver,
NotificationsResolver,
UsersResolver, UsersResolver,
], ],
}) })

View File

@@ -10,6 +10,9 @@ import { AddressInfo } from 'node:net';
import { TASKSTREAM_API_URL } from './api/taskstream-api'; import { TASKSTREAM_API_URL } from './api/taskstream-api';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
const ISSUE_1 = '11111111-1111-4111-8111-111111111111';
const ISSUE_2 = '22222222-2222-4222-8222-222222222222';
/** What the fake API received. */ /** What the fake API received. */
interface Received { interface Received {
method: string; method: string;
@@ -75,6 +78,38 @@ function fakeApi(received: Received[]): Server {
code: 'BUSINESS_RULE', code: 'BUSINESS_RULE',
message: 'Cannot move', message: 'Cannot move',
}); });
if (req.method === 'POST' && req.url === '/api/projects')
return reply(201, { id: 'p2', key: 'DOCS', version: 1 });
if (req.url === '/api/issues/WEB-1') return reply(200, { id: ISSUE_1 });
if (req.url === '/api/issues/WEB-2') return reply(200, { id: ISSUE_2 });
if (req.url === '/api/issues/bulk')
return reply(200, {
updated: 1,
results: [
{ issueId: ISSUE_1, ok: true, error: null },
{ issueId: ISSUE_2, ok: false, error: 'Cannot move WEB-2' },
],
});
if (req.url === '/api/sprints/s1/close')
return reply(201, { id: 's1', version: 3 });
if (req.url?.startsWith('/api/me/notifications?'))
return reply(200, {
unread: 1,
items: [
{
id: 'n1',
kind: 'MENTIONED',
projectId: 'p1',
projectKey: 'WEB',
issueId: ISSUE_1,
issueKey: 'WEB-1',
actor: { id: '101', name: 'Ana' },
summary: 'Ana te ha mencionado en WEB-1',
readAt: null,
createdAt: '2026-10-09T10:00:00Z',
},
],
});
return reply(404, { statusCode: 404, message: 'Not found' }); return reply(404, { statusCode: 404, message: 'Not found' });
}); });
} }
@@ -152,11 +187,24 @@ describe('TaskStream MCP', () => {
'change_issue_status', 'change_issue_status',
'get_release', 'get_release',
'whoami', 'whoami',
'create_project',
'list_epics',
'bulk_update_issues',
'delete_issue',
'list_sprints',
'get_sprint',
'close_sprint',
'draft_release_notes',
'add_checklist_item',
'my_notifications',
]), ]),
); );
const byName = new Map(tools.map((t) => [t.name, t])); const byName = new Map(tools.map((t) => [t.name, t]));
expect(byName.get('get_project')?.annotations?.readOnlyHint).toBe(true); expect(byName.get('get_project')?.annotations?.readOnlyHint).toBe(true);
expect(byName.get('create_issue')?.annotations?.readOnlyHint).toBe(false); expect(byName.get('create_issue')?.annotations?.readOnlyHint).toBe(false);
expect(byName.get('delete_issue')?.annotations).toMatchObject({
destructiveHint: true,
});
}); });
it("calls the API with the caller's own token and compacts the answer", async () => { it("calls the API with the caller's own token and compacts the answer", async () => {
@@ -217,6 +265,68 @@ describe('TaskStream MCP', () => {
expect(received).toHaveLength(0); expect(received).toHaveLength(0);
}); });
it('creates projects with the key in capitals', async () => {
const result = await call(
'create_project',
{ key: 'docs', name: 'Documentación', isPublic: true },
'pireon-token',
);
expect(result.isError).toBeUndefined();
expect(received[0]).toMatchObject({
method: 'POST',
url: '/api/projects',
body: { key: 'DOCS', name: 'Documentación', isPublic: true },
});
});
it('changes many tickets by key and says which refused', async () => {
const result = await call(
'bulk_update_issues',
{ issues: ['WEB-1', 'WEB-2'], status: 'DONE' },
'pireon-token',
);
expect(received.at(-1)).toMatchObject({
method: 'POST',
url: '/api/issues/bulk',
body: { issueIds: [ISSUE_1, ISSUE_2], status: 'DONE' },
});
expect(JSON.parse(result.content[0].text)).toEqual({
updated: 1,
refused: [{ issue: 'WEB-2', error: 'Cannot move WEB-2' }],
});
});
it('closes a sprint carrying the unfinished tickets over', async () => {
await call(
'close_sprint',
{ sprintId: 's1', carryOverTo: 's2' },
'pireon-token',
);
expect(received[0]).toMatchObject({
method: 'POST',
url: '/api/sprints/s1/close',
body: { carryOverTo: 's2' },
});
});
it('reads the unread notifications', async () => {
const result = await call('my_notifications', {}, 'pireon-token');
expect(received[0].url).toBe(
'/api/me/notifications?unreadOnly=true&limit=30',
);
expect(JSON.parse(result.content[0].text)).toEqual({
unread: 1,
items: [
expect.objectContaining({
kind: 'MENTIONED',
issue: 'WEB-1',
who: 'Ana',
read: false,
}),
],
});
});
/** A raw POST /mcp with chosen Host / Origin headers (fetch would not let us set Host). */ /** A raw POST /mcp with chosen Host / Origin headers (fetch would not let us set Host). */
function rawPost(headers: Record<string, string>): Promise<number> { function rawPost(headers: Record<string, string>): Promise<number> {
const { port } = new URL(url); const { port } = new URL(url);

View File

@@ -0,0 +1,243 @@
import { McpContext, Resolver, Tool } from '@nestjs-mcp/server';
import { z } from 'zod';
import { TaskStreamApi, type TaskStreamClient } from '../api/taskstream-api';
import { issueRef, projectRef } from './params';
import { issueLine } from './summaries';
import { toolResult } from './tool-result';
const READ = { readOnlyHint: true, openWorldHint: false } as const;
const WRITE = {
readOnlyHint: false,
destructiveHint: false,
openWorldHint: false,
} as const;
const PRIORITY = z.enum(['LOWEST', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL']);
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/**
* How the work is organised: epics and the tickets hanging from them, the
* backlog order, changes to many tickets at once and the trash.
*/
@Resolver()
export class BacklogResolver {
constructor(private readonly api: TaskStreamApi) {}
@Tool({
name: 'list_epics',
description:
'Epics of a project (top-level tickets that group others) with the progress of the tickets hanging from each one.',
paramsSchema: z.object({ project: projectRef }),
annotations: READ,
})
epics({ project }: { project: string }, ctx: McpContext) {
return toolResult(async () =>
(
await this.api.data(
this.api.as(ctx).GET('/api/projects/{project}/epics', {
params: { path: { project } },
}),
)
).map((epic) => ({
...issueLine(epic.issue),
startDate: epic.issue.startDate,
progress: epic.progress,
})),
);
}
@Tool({
name: 'set_issue_parent',
description:
'Place a ticket under an epic or another ticket of the same project (three levels at most: epic → ticket → subtask), or back to the top (null).',
paramsSchema: z.object({
issue: issueRef,
parent: z.string().nullable().describe('Epic or ticket key, or null'),
}),
annotations: { ...WRITE, idempotentHint: true },
})
setParent(
{ issue, parent }: { issue: string; parent: string | null },
ctx: McpContext,
) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).PUT('/api/issues/{issue}/parent', {
params: { path: { issue } },
body: { parent },
}),
),
);
}
@Tool({
name: 'rank_issue',
description:
'Move a ticket in the backlog (board columns follow the same order): say which ticket ends up right above it or right below it; above null puts it at the top, below null at the bottom.',
paramsSchema: z.object({
issue: issueRef,
above: z
.string()
.nullable()
.optional()
.describe('Ticket that ends up right above (null: to the top)'),
below: z
.string()
.nullable()
.optional()
.describe('Ticket that ends up right below (null: to the bottom)'),
}),
annotations: { ...WRITE, idempotentHint: true },
})
rank(
params: { issue: string; above?: string | null; below?: string | null },
ctx: McpContext,
) {
const { issue, ...body } = params;
return toolResult(() =>
this.api.data(
this.api.as(ctx).PUT('/api/issues/{issue}/rank', {
params: { path: { issue } },
body,
}),
),
);
}
@Tool({
name: 'bulk_update_issues',
description:
'The same change on up to 100 tickets: status, assignee, sprint, release, priority, extra labels. Each ticket follows the normal rules and may refuse on its own; the answer says which.',
paramsSchema: z.object({
issues: z.array(issueRef).min(1).max(100),
status: z.string().optional().describe('Status id'),
assigneeId: z.string().optional().describe('User id of a member'),
unassign: z.boolean().optional(),
sprintId: z.string().optional(),
noSprint: z.boolean().optional().describe('Back to the backlog'),
releaseId: z.string().optional(),
noRelease: z.boolean().optional().describe('Out of their release'),
priority: PRIORITY.optional(),
addLabels: z.array(z.string().max(40)).max(20).optional(),
}),
annotations: WRITE,
})
bulk(
params: {
issues: string[];
status?: string;
assigneeId?: string;
unassign?: boolean;
sprintId?: string;
noSprint?: boolean;
releaseId?: string;
noRelease?: boolean;
priority?: z.infer<typeof PRIORITY>;
addLabels?: string[];
},
ctx: McpContext,
) {
const { issues, ...change } = params;
return toolResult(async () => {
const client = this.api.as(ctx);
const refs = await this.ids(client, issues);
const answer = await this.api.data(
client.POST('/api/issues/bulk', {
body: { issueIds: refs.map((r) => r.id), ...change },
}),
);
const keyOf = new Map(refs.map((r) => [r.id, r.ref]));
return {
updated: answer.updated,
refused: answer.results
.filter((r) => !r.ok)
.map((r) => ({ issue: keyOf.get(r.issueId), error: r.error })),
};
});
}
@Tool({
name: 'delete_issue',
description:
'Move a ticket to the trash (whoever opened it, or an owner/admin). It disappears everywhere; restore_issue brings it back within 30 days, then it is deleted for good.',
paramsSchema: z.object({ issue: issueRef }),
annotations: { readOnlyHint: false, destructiveHint: true },
})
trash({ issue }: { issue: string }, ctx: McpContext) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).DELETE('/api/issues/{issue}', {
params: { path: { issue } },
}),
),
);
}
@Tool({
name: 'restore_issue',
description: 'Bring a ticket back from the trash.',
paramsSchema: z.object({ issue: issueRef }),
annotations: { ...WRITE, idempotentHint: true },
})
restore({ issue }: { issue: string }, ctx: McpContext) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).POST('/api/issues/{issue}/restore', {
params: { path: { issue } },
}),
),
);
}
@Tool({
name: 'list_trash',
description: "A project's tickets in the trash, newest first.",
paramsSchema: z.object({ project: projectRef }),
annotations: READ,
})
listTrash({ project }: { project: string }, ctx: McpContext) {
return toolResult(async () =>
(
await this.api.data(
this.api.as(ctx).GET('/api/projects/{project}/trash', {
params: { path: { project } },
}),
)
).map((issue) => ({ ...issueLine(issue), trashedAt: issue.trashedAt })),
);
}
/**
* The bulk endpoint takes ids; people and models talk in keys. Keys are
* looked up a few at a time, ids pass as they are.
*/
private async ids(
client: TaskStreamClient,
refs: string[],
): Promise<{ ref: string; id: string }[]> {
const found: { ref: string; id: string }[] = [];
for (let i = 0; i < refs.length; i += 10) {
const batch = refs.slice(i, i + 10);
found.push(
...(await Promise.all(
batch.map(async (ref) =>
UUID.test(ref)
? { ref, id: ref }
: {
ref,
id: (
await this.api.data(
client.GET('/api/issues/{issue}', {
params: { path: { issue: ref } },
}),
)
).id,
},
),
)),
);
}
return found;
}
}

View File

@@ -11,9 +11,15 @@ const WRITE = {
destructiveHint: false, destructiveHint: false,
openWorldHint: false, openWorldHint: false,
} as const; } as const;
const REMOVE = {
readOnlyHint: false,
destructiveHint: true,
openWorldHint: false,
} as const;
const PRIORITY = z.enum(['LOWEST', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL']); const PRIORITY = z.enum(['LOWEST', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL']);
const CATEGORY = z.enum(['TODO', 'IN_PROGRESS', 'QA', 'DONE', 'CANCELLED']); const CATEGORY = z.enum(['TODO', 'IN_PROGRESS', 'QA', 'DONE', 'CANCELLED']);
const REACTION = z.enum(['👍', '👎', '😄', '🎉', '😕', '❤️', '🚀', '👀']);
const LINK_TYPE = z.enum([ const LINK_TYPE = z.enum([
'BLOCKS', 'BLOCKS',
'DEPENDS_ON', 'DEPENDS_ON',
@@ -31,21 +37,38 @@ 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)'),
storyPoints: z.number().int().min(0).max(100).optional(), storyPoints: z.number().int().min(0).max(100).optional(),
startDate: z
.string()
.optional()
.describe('ISO date when work should start (roadmap)'),
dueDate: z.string().optional().describe('ISO date, e.g. 2026-11-14'), dueDate: z.string().optional().describe('ISO date, e.g. 2026-11-14'),
customFields: z
.record(z.string(), z.string().nullable())
.optional()
.describe(
'Custom field id → value (see get_project settings.customFields): SELECT one of its options, DATE YYYY-MM-DD, NUMBER a number. Only the fields to change; null clears one',
),
}; };
type IssueFields = { 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;
storyPoints?: number; storyPoints?: number;
startDate?: string;
dueDate?: string; dueDate?: string;
customFields?: Record<string, string | null>;
}; };
@Resolver() @Resolver()
@@ -57,13 +80,34 @@ export class IssuesResolver {
@Tool({ @Tool({
name: 'my_issues', name: 'my_issues',
description: description:
"Tickets across all the user's projects: assigned to them (default) or anyone's, filtered by status category (e.g. QA to see what waits for testing).", "Tickets across all the user's projects: assigned to them (default) or anyone's, filtered by status category (e.g. QA to see what waits for testing); watching: only the ones they follow.",
paramsSchema: z.object({ paramsSchema: z.object({
assignedToMe: z.boolean().default(true), assignedToMe: z.boolean().default(true),
watching: z
.boolean()
.default(false)
.describe(
'Only the tickets the user follows (use with assignedToMe false)',
),
reviewing: z
.boolean()
.default(false)
.describe(
'Only the tickets the user was asked to review and has not answered (use with assignedToMe false)',
),
categories: z categories: z
.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,
@@ -71,7 +115,12 @@ export class IssuesResolver {
mine( mine(
params: { params: {
assignedToMe: boolean; assignedToMe: boolean;
watching: boolean;
reviewing: 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,
@@ -82,7 +131,12 @@ export class IssuesResolver {
params: { params: {
query: { query: {
assignedToMe: params.assignedToMe, assignedToMe: params.assignedToMe,
watching: params.watching,
reviewing: params.reviewing,
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,
}, },
}, },
@@ -95,7 +149,7 @@ export class IssuesResolver {
@Tool({ @Tool({
name: 'search_issues', name: 'search_issues',
description: description:
'Search the tickets of a project by text (key, title, description) and filters.', 'Search the tickets of a project by text (key, title, description) and filters. The backlog: noSprint with sort RANK.',
paramsSchema: z.object({ paramsSchema: z.object({
project: projectRef, project: projectRef,
search: z.string().max(200).optional(), search: z.string().max(200).optional(),
@@ -103,11 +157,37 @@ 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(),
releaseId: z.string().optional(), releaseId: z.string().optional(),
labels: z.array(z.string()).optional(), labels: z.array(z.string()).optional(),
parent: z
.string()
.optional()
.describe('Only the tickets hanging from this epic or ticket (key)'),
sprintId: z.string().optional().describe('Only this sprint'),
noSprint: z
.boolean()
.optional()
.describe('Only tickets in no sprint (the backlog)'),
watching: z
.boolean()
.optional()
.describe('Only the tickets the user follows'),
dueFrom: z.string().optional().describe('Due on or after (ISO date)'),
dueTo: z.string().optional().describe('Due on or before (ISO date)'),
customFields: z
.record(z.string(), z.string())
.optional()
.describe(
'Only tickets holding these custom values (field id → value)',
),
sort: z
.enum(['RECENT', 'RANK'])
.default('RECENT')
.describe('RECENT: last updated first. RANK: backlog order'),
limit: limit(200, 30), limit: limit(200, 30),
offset: z.number().int().min(0).default(0), offset: z.number().int().min(0).default(0),
}), }),
@@ -121,11 +201,20 @@ 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;
releaseId?: string; releaseId?: string;
labels?: string[]; labels?: string[];
parent?: string;
sprintId?: string;
noSprint?: boolean;
watching?: boolean;
dueFrom?: string;
dueTo?: string;
customFields?: Record<string, string>;
sort: 'RECENT' | 'RANK';
limit: number; limit: number;
offset: number; offset: number;
}, },
@@ -142,11 +231,24 @@ 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,
releaseId: params.releaseId, releaseId: params.releaseId,
label: params.labels, label: params.labels,
parent: params.parent,
sprintId: params.sprintId,
noSprint: params.noSprint,
watching: params.watching,
dueFrom: params.dueFrom,
dueTo: params.dueTo,
field: params.customFields
? Object.entries(params.customFields).map(
([id, value]) => `${id}:${value}`,
)
: undefined,
sort: params.sort,
limit: params.limit, limit: params.limit,
offset: params.offset, offset: params.offset,
}, },
@@ -160,7 +262,7 @@ export class IssuesResolver {
@Tool({ @Tool({
name: 'get_issue', name: 'get_issue',
description: description:
'A ticket in full: description, links, allowed next statuses, and optionally its comments and history.', 'A ticket in full: description, parent, checklist (with item ids), links, commits and pull requests, followers, allowed next statuses, the comments marked as decisions, and optionally its comments and history.',
paramsSchema: z.object({ paramsSchema: z.object({
issue: issueRef, issue: issueRef,
withComments: z.boolean().default(true), withComments: z.boolean().default(true),
@@ -187,11 +289,40 @@ export class IssuesResolver {
) )
).items.map((c) => ({ ).items.map((c) => ({
id: c.id, id: c.id,
author: c.author.name, replyTo: c.replyTo,
author: c.deleted ? null : c.author.name,
at: c.createdAt, at: c.createdAt,
body: c.body, body: c.deleted ? '(deleted; kept for its replies)' : c.body,
reactions: c.reactions.map((r) => ({
emoji: r.emoji,
by: r.users.map((u) => u.name),
})),
decision: c.decision || undefined,
resolved: c.resolved || undefined,
poll: c.poll
? {
options: c.poll.options.map((o, index) => ({
index,
text: o.text,
votes: o.voters.length,
})),
closed: c.poll.closed,
winner: c.poll.winner,
}
: undefined,
})) }))
: undefined; : undefined;
const decisions = (
await this.api.data(
client.GET('/api/issues/{issue}/decisions', { params: { path } }),
)
).map((c) => ({
commentId: c.id,
by: c.author.name,
at: c.createdAt,
markedBy: c.decidedBy?.name ?? null,
body: c.body,
}));
const history = params.withHistory const history = params.withHistory
? ( ? (
await this.api.data( await this.api.data(
@@ -206,11 +337,32 @@ export class IssuesResolver {
description: issue.description, description: issue.description,
reporter: issue.reporter.name, reporter: issue.reporter.name,
figmaUrl: issue.figmaUrl, figmaUrl: issue.figmaUrl,
storyPoints: issue.storyPoints, startDate: issue.startDate,
dueDate: issue.dueDate,
allowedTransitions: issue.allowedTransitions, allowedTransitions: issue.allowedTransitions,
parent: issue.parent,
checklist: issue.checklist,
links: issue.links, links: issue.links,
devLinks: issue.devLinks.map((d) => ({
kind: d.kind,
repository: d.repository,
ref: d.ref,
title: d.title,
state: d.state,
url: d.url,
})),
watchers: issue.watchers.map((w) => w.name),
reviews: issue.reviews.map((r) => ({
id: r.id,
requester: r.requester.name,
reviewer: r.reviewer.name,
note: r.note,
verdict: r.verdict,
answer: r.answer,
})),
attachments: issue.attachments.length,
trashedAt: issue.trashedAt,
createdAt: issue.createdAt, createdAt: issue.createdAt,
decisions,
comments, comments,
history, history,
}; };
@@ -229,6 +381,10 @@ export class IssuesResolver {
title: z.string().min(1).max(255), title: z.string().min(1).max(255),
assigneeId: z.string().optional().describe('User id (a project member)'), assigneeId: z.string().optional().describe('User id (a project member)'),
releaseId: z.string().optional(), releaseId: z.string().optional(),
parent: z
.string()
.optional()
.describe('Epic or ticket of the same project it breaks down (key)'),
...issueFields, ...issueFields,
}), }),
annotations: WRITE, annotations: WRITE,
@@ -240,6 +396,7 @@ export class IssuesResolver {
title: string; title: string;
assigneeId?: string; assigneeId?: string;
releaseId?: string; releaseId?: string;
parent?: string;
}, },
ctx: McpContext, ctx: McpContext,
) { ) {
@@ -357,19 +514,117 @@ export class IssuesResolver {
@Tool({ @Tool({
name: 'add_comment', name: 'add_comment',
description: 'Comment on a ticket (decisions, explanations, steps).', description:
'Comment on a ticket (decisions, explanations, steps), or answer a comment with replyTo (one level: answering a reply joins its thread).',
paramsSchema: z.object({ paramsSchema: z.object({
issue: issueRef, issue: issueRef,
body: z.string().min(1).max(20000), body: z.string().min(1).max(20000),
replyTo: z
.string()
.optional()
.describe('Id of the comment it answers (see get_issue)'),
poll: z
.array(z.string().min(1).max(80))
.min(2)
.max(6)
.optional()
.describe('A quick poll in the comment: 2 to 6 options'),
}), }),
annotations: WRITE, annotations: WRITE,
}) })
comment({ issue, body }: { issue: string; body: string }, ctx: McpContext) { comment(
{
issue,
body,
replyTo,
poll,
}: { issue: string; body: string; replyTo?: string; poll?: string[] },
ctx: McpContext,
) {
return toolResult(() => return toolResult(() =>
this.api.data( this.api.data(
this.api.as(ctx).POST('/api/issues/{issue}/comments', { this.api.as(ctx).POST('/api/issues/{issue}/comments', {
params: { path: { issue } }, params: { path: { issue } },
body: { body }, body: { body, replyTo, poll },
}),
),
);
}
@Tool({
name: 'vote_poll',
description:
"Vote in a comment's poll (option index from get_issue), or take the vote back (option null).",
paramsSchema: z.object({
commentId: z.string().min(1),
option: z.number().int().min(0).nullable(),
}),
annotations: { ...WRITE, idempotentHint: true },
})
votePoll(
{ commentId, option }: { commentId: string; option: number | null },
ctx: McpContext,
) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).POST('/api/comments/{commentId}/poll/vote', {
params: { path: { commentId } },
body: { option },
}),
),
);
}
@Tool({
name: 'close_poll',
description:
'Close a poll the user started (or as admin); with decide (default), the winner stays as a decision of the ticket.',
paramsSchema: z.object({
commentId: z.string().min(1),
decide: z.boolean().default(true),
}),
annotations: WRITE,
})
closePoll(
{ commentId, decide }: { commentId: string; decide: boolean },
ctx: McpContext,
) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).POST('/api/comments/{commentId}/poll/close', {
params: { path: { commentId } },
body: { decide },
}),
),
);
}
@Tool({
name: 'turn_comment_into_issue',
description:
"Make a ticket of a comment that turned out to be work of its own: its text becomes the description, as a subtask of the comment's ticket (asSubtask) or linked to it. Read type ids with get_project. Answers the new ticket's key.",
paramsSchema: z.object({
commentId: z.string().min(1),
title: z.string().min(1).max(200),
type: z.string().min(1).describe("One of the project's ticket type ids"),
asSubtask: z.boolean().default(false),
}),
annotations: WRITE,
})
turnCommentIntoIssue(
{
commentId,
title,
type,
asSubtask,
}: { commentId: string; title: string; type: string; asSubtask: boolean },
ctx: McpContext,
) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).POST('/api/comments/{commentId}/issue', {
params: { path: { commentId } },
body: { title, type, asSubtask },
}), }),
), ),
); );
@@ -399,4 +654,470 @@ export class IssuesResolver {
), ),
); );
} }
@Tool({
name: 'unlink_issues',
description:
'Remove a relation between two tickets (as link_issues made it).',
paramsSchema: z.object({
issue: issueRef,
type: LINK_TYPE,
target: issueRef,
}),
annotations: REMOVE,
})
unlink(
params: { issue: string; type: z.infer<typeof LINK_TYPE>; target: string },
ctx: McpContext,
) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).DELETE('/api/issues/{issue}/links/{type}/{target}', {
params: { path: params },
}),
),
);
}
@Tool({
name: 'watch_issue',
description:
'Follow a ticket (notifications of its changes and comments) or stop following it. With tree: true, an epic (or any ticket with subtasks) with everything under it, also tickets added later.',
paramsSchema: z.object({
issue: issueRef,
follow: z.boolean().default(true),
tree: z.boolean().default(false),
}),
annotations: { ...WRITE, idempotentHint: true },
})
watch(
{ issue, follow, tree }: { issue: string; follow: boolean; tree: boolean },
ctx: McpContext,
) {
const client = this.api.as(ctx);
const options = { params: { path: { issue } } };
if (tree)
return toolResult(async () => {
await this.api.data(
follow
? client.PUT('/api/issues/{issue}/follow-tree', options)
: client.DELETE('/api/issues/{issue}/follow-tree', options),
);
return { issue, followingTree: follow };
});
return toolResult(() =>
this.api.data(
follow
? client.PUT('/api/issues/{issue}/watch', options)
: client.DELETE('/api/issues/{issue}/watch', options),
),
);
}
@Tool({
name: 'edit_comment',
description:
'Reword a comment (only its author). Comment ids come with get_issue.',
paramsSchema: z.object({
commentId: z.string().min(1),
body: z.string().min(1).max(20000),
}),
annotations: { ...WRITE, idempotentHint: true },
})
editComment(
{ commentId, body }: { commentId: string; body: string },
ctx: McpContext,
) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).PATCH('/api/comments/{commentId}', {
params: { path: { commentId } },
body: { body },
}),
),
);
}
@Tool({
name: 'delete_comment',
description: 'Delete a comment (its author or a project admin).',
paramsSchema: z.object({ commentId: z.string().min(1) }),
annotations: REMOVE,
})
deleteComment({ commentId }: { commentId: string }, ctx: McpContext) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).DELETE('/api/comments/{commentId}', {
params: { path: { commentId } },
}),
),
);
}
/* ---- Checklist ---- */
@Tool({
name: 'add_checklist_item',
description: "Add a step to a ticket's checklist.",
paramsSchema: z.object({
issue: issueRef,
text: z.string().min(1).max(300),
}),
annotations: WRITE,
})
addChecklistItem(
{ issue, text }: { issue: string; text: string },
ctx: McpContext,
) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).POST('/api/issues/{issue}/checklist', {
params: { path: { issue } },
body: { text },
}),
),
);
}
@Tool({
name: 'update_checklist_item',
description:
'Tick, untick or reword a checklist step. Item ids come with get_issue.',
paramsSchema: z.object({
issue: issueRef,
item: z.string().min(1).describe('Checklist item id'),
done: z.boolean().optional(),
text: z.string().min(1).max(300).optional(),
}),
annotations: { ...WRITE, idempotentHint: true },
})
updateChecklistItem(
params: { issue: string; item: string; done?: boolean; text?: string },
ctx: McpContext,
) {
const { issue, item, ...body } = params;
return toolResult(() =>
this.api.data(
this.api.as(ctx).PATCH('/api/issues/{issue}/checklist/{item}', {
params: { path: { issue, item } },
body,
}),
),
);
}
@Tool({
name: 'remove_checklist_item',
description: "Remove a step from a ticket's checklist.",
paramsSchema: z.object({
issue: issueRef,
item: z.string().min(1).describe('Checklist item id'),
}),
annotations: REMOVE,
})
removeChecklistItem(
{ issue, item }: { issue: string; item: string },
ctx: McpContext,
) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).DELETE('/api/issues/{issue}/checklist/{item}', {
params: { path: { issue, item } },
}),
),
);
}
@Tool({
name: 'duplicate_issue',
description:
"A new ticket like this one: what it says (type, title, description, area, priority, labels, Figma, estimate, dates, parent, checklist steps unticked), not where it stands (first status, unassigned, no sprint, release, comments or files). Answers the copy's key.",
paramsSchema: z.object({ issue: issueRef }),
annotations: WRITE,
})
duplicate({ issue }: { issue: string }, ctx: McpContext) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).POST('/api/issues/{issue}/duplicate', {
params: { path: { issue } },
}),
),
);
}
@Tool({
name: 'react_to_comment',
description:
'React to a comment (👍 👎 😄 🎉 😕 ❤️ 🚀 👀) or take the reaction back (on: false). Comment ids come with get_issue.',
paramsSchema: z.object({
commentId: z.string().min(1),
emoji: REACTION,
on: z.boolean().default(true),
}),
annotations: { ...WRITE, idempotentHint: true },
})
react(
params: {
commentId: string;
emoji: z.infer<typeof REACTION>;
on: boolean;
},
ctx: McpContext,
) {
const { commentId, ...body } = params;
return toolResult(() =>
this.api.data(
this.api.as(ctx).POST('/api/comments/{commentId}/reactions', {
params: { path: { commentId } },
body,
}),
),
);
}
@Tool({
name: 'mark_decision',
description:
'Mark a comment as a decision of its ticket (what was agreed), or unmark it (on: false). Whoever can change the ticket. Comment ids come with get_issue.',
paramsSchema: z.object({
commentId: z.string().min(1),
on: z.boolean().default(true),
}),
annotations: { ...WRITE, idempotentHint: true },
})
markDecision(
{ commentId, on }: { commentId: string; on: boolean },
ctx: McpContext,
) {
const client = this.api.as(ctx);
const options = { params: { path: { commentId } } };
return toolResult(() =>
this.api.data(
on
? client.PUT('/api/comments/{commentId}/decision', options)
: client.DELETE('/api/comments/{commentId}/decision', options),
),
);
}
@Tool({
name: 'request_review',
description:
'Ask a member of the project to review a ticket (with an optional note): they are told and see it in my_issues with reviewing. reviewer is a user id (get_project lists members).',
paramsSchema: z.object({
issue: issueRef,
reviewer: z.string().min(1),
note: z.string().max(1000).optional(),
}),
annotations: WRITE,
})
requestReview(
params: { issue: string; reviewer: string; note?: string },
ctx: McpContext,
) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).POST('/api/issues/{issue}/reviews', {
params: { path: { issue: params.issue } },
body: { reviewer: params.reviewer, note: params.note },
}),
),
);
}
@Tool({
name: 'answer_review',
description:
'Answer a review the user was asked for: APPROVED (all good) or CHANGES, with an optional answer. Review ids come with get_issue.',
paramsSchema: z.object({
issue: issueRef,
reviewId: z.string().min(1),
verdict: z.enum(['APPROVED', 'CHANGES']),
answer: z.string().max(1000).optional(),
}),
annotations: WRITE,
})
answerReview(
params: {
issue: string;
reviewId: string;
verdict: 'APPROVED' | 'CHANGES';
answer?: string;
},
ctx: McpContext,
) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).POST('/api/issues/{issue}/reviews/{review}/answer', {
params: { path: { issue: params.issue, review: params.reviewId } },
body: { verdict: params.verdict, answer: params.answer },
}),
),
);
}
@Tool({
name: 'my_day',
description:
'«Mi día»: the tickets I picked for today (done ones marked), and what was left unfinished on my last day. Optionally add or remove one first. day is today in my time zone, YYYY-MM-DD.',
paramsSchema: z.object({
day: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
add: issueRef.optional(),
remove: issueRef.optional(),
}),
annotations: WRITE,
})
myDay(
params: { day: string; add?: string; remove?: string },
ctx: McpContext,
) {
const client = this.api.as(ctx);
const query = { day: params.day };
return toolResult(async () => {
if (params.add)
await this.api.data(
client.PUT('/api/me/day/{issue}', {
params: { path: { issue: params.add }, query },
}),
);
if (params.remove)
await this.api.data(
client.DELETE('/api/me/day/{issue}', {
params: { path: { issue: params.remove }, query },
}),
);
const myDay = await this.api.data(
client.GET('/api/me/day', { params: { query } }),
);
return {
day: myDay.day,
issues: myDay.issues.map(issueLine),
leftover: myDay.leftover && {
day: myDay.leftover.day,
issues: myDay.leftover.issues.map(issueLine),
},
};
});
}
@Tool({
name: 'remind_me',
description:
'Remind me of a ticket at a date and time (a notification), with an optional note; one per ticket, it replaces the waiting one. clear: true drops it. Times are ISO with offset, e.g. 2026-10-23T09:00:00+02:00.',
paramsSchema: z.object({
issue: issueRef,
at: z.string().optional(),
note: z.string().max(280).optional(),
clear: z.boolean().default(false),
}),
annotations: { ...WRITE, idempotentHint: true },
})
remindMe(
params: { issue: string; at?: string; note?: string; clear: boolean },
ctx: McpContext,
) {
const client = this.api.as(ctx);
const path = { issue: params.issue };
return toolResult(async () => {
if (params.clear) {
await this.api.data(
client.DELETE('/api/issues/{issue}/reminder', { params: { path } }),
);
return { issue: params.issue, reminder: null };
}
if (!params.at) throw new Error('at is required unless clear is true');
return this.api.data(
client.PUT('/api/issues/{issue}/reminder', {
params: { path },
body: { at: params.at, note: params.note },
}),
);
});
}
@Tool({
name: 'resolve_thread',
description:
'Settle a conversation (a top-level comment and its replies), optionally with a conclusion that is added as a reply marked as a decision; or reopen it (resolved: false). Whoever can change the ticket. Comment ids come with get_issue.',
paramsSchema: z.object({
commentId: z.string().min(1),
resolved: z.boolean().default(true),
conclusion: z.string().max(20000).optional(),
}),
annotations: { ...WRITE, idempotentHint: true },
})
resolveThread(
params: { commentId: string; resolved: boolean; conclusion?: string },
ctx: McpContext,
) {
const client = this.api.as(ctx);
const path = { commentId: params.commentId };
return toolResult(() =>
this.api.data(
params.resolved
? client.PUT('/api/comments/{commentId}/resolution', {
params: { path },
body: { conclusion: params.conclusion },
})
: client.DELETE('/api/comments/{commentId}/resolution', {
params: { path },
}),
),
);
}
@Tool({
name: 'search_decisions',
description:
'What was decided, by words in the decision: comments marked as decisions, most recent first, with their ticket. Of one project, or of all my projects without one.',
paramsSchema: z.object({
project: projectRef.optional(),
search: z.string().max(200).optional(),
limit: limit(100, 20),
}),
annotations: READ,
})
searchDecisions(
params: { project?: string; search?: string; limit: number },
ctx: McpContext,
) {
return toolResult(async () => {
const client = this.api.as(ctx);
const query = { search: params.search, limit: params.limit };
const found = await this.api.data(
params.project
? client.GET('/api/projects/{project}/decisions', {
params: { path: { project: params.project }, query },
})
: client.GET('/api/me/decisions', { params: { query } }),
);
return found.map((d) => ({
issue: d.issue.key,
title: d.issue.title,
commentId: d.comment.id,
by: d.comment.author.name,
at: d.comment.createdAt,
body: d.comment.body,
}));
});
}
@Tool({
name: 'vote_issue',
description:
'Vote for a ticket (on) or take the vote back: anyone signed in on a public project, members on a private one. Votes say what people want done.',
paramsSchema: z.object({ issue: issueRef, on: z.boolean().default(true) }),
annotations: { ...WRITE, idempotentHint: true },
})
vote({ issue, on }: { issue: string; on: boolean }, ctx: McpContext) {
const client = this.api.as(ctx);
const options = { params: { path: { issue } } };
return toolResult(() =>
this.api.data(
on
? client.PUT('/api/issues/{issue}/vote', options)
: client.DELETE('/api/issues/{issue}/vote', options),
),
);
}
} }

View File

@@ -0,0 +1,70 @@
import { McpContext, Resolver, Tool } from '@nestjs-mcp/server';
import { z } from 'zod';
import { TaskStreamApi } from '../api/taskstream-api';
import { limit } from './params';
import { toolResult } from './tool-result';
@Resolver()
export class NotificationsResolver {
constructor(private readonly api: TaskStreamApi) {}
@Tool({
name: 'my_notifications',
description:
"The user's notifications, newest first: assignments, status changes, failed QA, comments, mentions, followed tickets, reminders.",
paramsSchema: z.object({
unreadOnly: z.boolean().default(true),
limit: limit(100, 30),
}),
annotations: { readOnlyHint: true, openWorldHint: false },
})
mine(
{ unreadOnly, limit }: { unreadOnly: boolean; limit: number },
ctx: McpContext,
) {
return toolResult(async () => {
const page = await this.api.data(
this.api.as(ctx).GET('/api/me/notifications', {
params: { query: { unreadOnly, limit } },
}),
);
return {
unread: page.unread,
items: page.items.map((n) => ({
id: n.id,
kind: n.kind,
project: n.projectKey,
issue: n.issueKey,
who: n.actor?.name ?? null,
summary: n.summary,
read: n.readAt !== null,
at: n.createdAt,
})),
};
});
}
@Tool({
name: 'mark_notifications_read',
description: 'Mark notifications as read: these ids, or all of them.',
paramsSchema: z.object({
ids: z
.array(z.string())
.optional()
.describe('Notification ids; absent: all'),
}),
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
})
markRead({ ids }: { ids?: string[] }, ctx: McpContext) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).POST('/api/me/notifications/read', { body: { ids } }),
),
);
}
}

View File

@@ -13,6 +13,11 @@ export const issueRef = z
export const releaseId = z.string().min(1).describe('Release id'); export const releaseId = z.string().min(1).describe('Release id');
export const sprintId = z
.string()
.min(1)
.describe('Sprint id (see list_sprints)');
export const limit = (max: number, fallback: number) => export const limit = (max: number, fallback: number) =>
z z
.number() .number()

View File

@@ -37,6 +37,66 @@ export class ProjectsResolver {
); );
} }
@Tool({
name: 'create_project',
description:
'Create a project; the user becomes its owner. The key prefixes its tickets (WEB → WEB-1) and cannot change later. With template, it starts with the statuses, transitions, types, areas, custom fields and automation rules of that project.',
paramsSchema: z.object({
key: z
.string()
.regex(/^[A-Za-z][A-Za-z0-9]{1,9}$/)
.describe('2-10 letters or digits, starting with a letter; unique'),
name: z.string().min(1).max(120),
description: z.string().max(5000).optional(),
isPublic: z
.boolean()
.default(false)
.describe('Anyone can read it, also without signing in'),
template: z
.string()
.optional()
.describe(
'Key of a project the user owns or administers to copy its configuration and automation rules from',
),
}),
annotations: { readOnlyHint: false, destructiveHint: false },
})
create(
params: {
key: string;
name: string;
description?: string;
isPublic: boolean;
template?: string;
},
ctx: McpContext,
) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).POST('/api/projects', {
body: { ...params, key: params.key.toUpperCase() },
}),
),
);
}
@Tool({
name: 'get_project_showcase',
description:
'The public summary of a public project: epics on the roadmap with dates and progress, latest releases (notes of shipped ones), the sprint under way and open good-first-issue tickets. Paths point into the web app.',
paramsSchema: z.object({ project: projectRef }),
annotations: READ,
})
showcase({ project }: { project: string }, ctx: McpContext) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).GET('/api/explore/projects/{project}/showcase', {
params: { path: { project } },
}),
),
);
}
@Tool({ @Tool({
name: 'get_project', name: 'get_project',
description: description:
@@ -72,6 +132,7 @@ export class ProjectsResolver {
assigneeId: z.string().optional().describe('Only this person (user id)'), assigneeId: z.string().optional().describe('Only this person (user id)'),
releaseId: z.string().optional().describe('Only this release'), releaseId: z.string().optional().describe('Only this release'),
area: z.string().optional().describe('Only this area id'), area: z.string().optional().describe('Only this area id'),
sprintId: z.string().optional().describe('Only this sprint'),
perColumn: limit(200, 25), perColumn: limit(200, 25),
}), }),
annotations: READ, annotations: READ,
@@ -82,6 +143,7 @@ export class ProjectsResolver {
assigneeId?: string; assigneeId?: string;
releaseId?: string; releaseId?: string;
area?: string; area?: string;
sprintId?: string;
perColumn: number; perColumn: number;
}, },
ctx: McpContext, ctx: McpContext,
@@ -142,21 +204,112 @@ export class ProjectsResolver {
); );
} }
@Tool({
name: 'get_my_week',
description:
"The user's coming week and how the last one went: tickets of theirs (assigned or watched) due in the next 7 days, sprint ends and planned releases; tickets finished by them and by their teams this week and last, and their streak; sprints under way; and, when nothing is theirs, what they could pick up (unassigned, waiting for QA, proposals to review).",
paramsSchema: z.object({}),
annotations: READ,
})
myWeek(_params: Record<string, never>, ctx: McpContext) {
return toolResult(async () => {
const home = await this.api.data(this.api.as(ctx).GET('/api/me/home'));
// Compact: the page's activity feed, presence and first steps stay out.
return {
agenda: home.agenda,
me: home.me,
team: { thisWeek: home.team.thisWeek, lastWeek: home.team.lastWeek },
sprints: home.sprints,
suggestions: home.suggestions,
};
});
}
@Tool({
name: 'get_portfolio',
description:
"All the user's active projects at a glance (not archived, not as a guest): open, overdue, blocked and stuck tickets, the sprint under way and whether it gets there, planned releases with their forecast, and open epics with their progress.",
paramsSchema: z.object({}),
annotations: READ,
})
portfolio(_params: Record<string, never>, ctx: McpContext) {
return toolResult(async () => {
const projects = await this.api.data(
this.api.as(ctx).GET('/api/me/portfolio'),
);
// Compact: epics as key, title and progress.
return projects.map(({ epics, ...p }) => ({
...p,
epics: epics.map((e) => ({
key: e.issue.key,
title: e.issue.title,
dueDate: e.issue.dueDate,
percent: e.progress.percent,
})),
}));
});
}
@Tool({
name: 'get_project_insights',
description:
"The project's dashboard: the sprint under way (pace against time, behind or not), overdue, due-soon, blocked and stuck tickets, how long the work under way has been where it is (against the p85 cycle time), average days in each in-progress and QA status with WIP limits, when each planned release would be finished at the real pace (Monte Carlo), each person's open points against what they close per sprint, open tickets missing an estimate, an area or an assignee, quality (open bugs per priority, bugs created and closed per week, QA returns per area, bugs reported after each release) open tickets per option of each list custom field, activity per day over the last 12 weeks, where the time of the tickets done lately went from created to done (days and share per status, and the part spent at work: flow efficiency) and, if the public has proposed anything, the proposals funnel (received, waiting, under way, done, discarded, most voted waiting). With since, also what changed after it.",
paramsSchema: z.object({
project: projectRef,
since: z
.string()
.optional()
.describe(
'ISO instant: also tell what was created, finished or sent back from QA after it',
),
}),
annotations: READ,
})
insights(
{ project, since }: { project: string; since?: string },
ctx: McpContext,
) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).GET('/api/projects/{project}/insights', {
params: { path: { project }, query: { since } },
}),
),
);
}
@Tool({ @Tool({
name: 'get_project_activity', name: 'get_project_activity',
description: 'Latest activity of a project (newest first).', description:
paramsSchema: z.object({ project: projectRef, limit: limit(200, 30) }), 'Latest activity of a project (newest first), or that of one day (UTC) with day.',
paramsSchema: z.object({
project: projectRef,
limit: limit(200, 30),
day: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional()
.describe('YYYY-MM-DD: only what happened that day (UTC)'),
}),
annotations: READ, annotations: READ,
}) })
activity( activity(
{ project, limit }: { project: string; limit: number }, { project, limit, day }: { project: string; limit: number; day?: string },
ctx: McpContext, ctx: McpContext,
) { ) {
const window = day
? {
after: `${day}T00:00:00.000Z`,
before: new Date(
Date.parse(`${day}T00:00:00Z`) + 86_400_000,
).toISOString(),
}
: {};
return toolResult(async () => return toolResult(async () =>
( (
await this.api.data( await this.api.data(
this.api.as(ctx).GET('/api/projects/{project}/activity', { this.api.as(ctx).GET('/api/projects/{project}/activity', {
params: { path: { project }, query: { limit } }, params: { path: { project }, query: { limit, ...window } },
}), }),
) )
).map((a) => ({ ).map((a) => ({

View File

@@ -54,7 +54,7 @@ export class ReleasesResolver {
@Tool({ @Tool({
name: 'get_release', name: 'get_release',
description: description:
'A release with its progress (finished out of total), its tickets and, optionally, the day-by-day burnup.', 'A release with its progress (finished out of total), its notes, its tickets and, optionally, the day-by-day burnup.',
paramsSchema: z.object({ paramsSchema: z.object({
releaseId, releaseId,
withBurnup: z.boolean().default(false), withBurnup: z.boolean().default(false),
@@ -90,6 +90,7 @@ export class ReleasesResolver {
return { return {
...releaseLine(release), ...releaseLine(release),
description: release.description, description: release.description,
notes: release.notes,
progress, progress,
issues: issues.items.map(issueLine), issues: issues.items.map(issueLine),
burnup, burnup,
@@ -148,4 +149,115 @@ export class ReleasesResolver {
), ),
); );
} }
@Tool({
name: 'update_release',
description: 'Rename a release, describe it or move its target date.',
paramsSchema: z.object({
releaseId,
name: z.string().min(1).max(60).optional(),
description: z.string().max(5000).nullable().optional(),
targetDate: z.string().nullable().optional().describe('ISO date'),
}),
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
update(
params: {
releaseId: string;
name?: string;
description?: string | null;
targetDate?: string | null;
},
ctx: McpContext,
) {
const { releaseId, ...body } = params;
return toolResult(() =>
this.api.data(
this.api.as(ctx).PATCH('/api/releases/{releaseId}', {
params: { path: { releaseId } },
body,
}),
),
);
}
@Tool({
name: 'archive_release',
description:
'Archive a planned release that will not ship. Its unfinished tickets move to moveTo (another open release of the project, from list_releases) or, without it, leave it.',
paramsSchema: z.object({
releaseId,
moveTo: z
.string()
.uuid()
.optional()
.describe('Open release that takes the unfinished tickets'),
}),
annotations: { readOnlyHint: false, destructiveHint: true },
})
archive(
{ releaseId, moveTo }: { releaseId: string; moveTo?: string },
ctx: McpContext,
) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).POST('/api/releases/{releaseId}/archive', {
params: { path: { releaseId } },
body: { moveTo: moveTo ?? null },
}),
),
);
}
@Tool({
name: 'draft_release_notes',
description:
"A Markdown draft of a release's notes from its finished tickets, grouped by type. Nothing is saved: polish it and save it with set_release_notes.",
paramsSchema: z.object({ releaseId }),
annotations: READ,
})
draftNotes({ releaseId }: { releaseId: string }, ctx: McpContext) {
return toolResult(
async () =>
(
await this.api.data(
this.api.as(ctx).GET('/api/releases/{releaseId}/notes/draft', {
params: { path: { releaseId } },
}),
)
).markdown,
);
}
@Tool({
name: 'set_release_notes',
description:
'Write the notes of a release (Markdown, for people: what shipped). null clears them.',
paramsSchema: z.object({
releaseId,
notes: z.string().max(20000).nullable(),
}),
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
setNotes(
{ releaseId, notes }: { releaseId: string; notes: string | null },
ctx: McpContext,
) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).PUT('/api/releases/{releaseId}/notes', {
params: { path: { releaseId } },
body: { notes },
}),
),
);
}
} }

View File

@@ -0,0 +1,240 @@
import { McpContext, Resolver, Tool } from '@nestjs-mcp/server';
import { z } from 'zod';
import { TaskStreamApi } from '../api/taskstream-api';
import { issueRef, limit, projectRef, sprintId } from './params';
import { issueLine, sprintLine } from './summaries';
import { toolResult } from './tool-result';
const READ = { readOnlyHint: true, openWorldHint: false } as const;
const WRITE = {
readOnlyHint: false,
destructiveHint: false,
openWorldHint: false,
} as const;
const date = (what: string) => z.string().describe(`${what} (ISO date)`);
@Resolver()
export class SprintsResolver {
constructor(private readonly api: TaskStreamApi) {}
@Tool({
name: 'list_sprints',
description:
"A project's sprints (planned, active and closed) with their dates and goal.",
paramsSchema: z.object({ project: projectRef }),
annotations: READ,
})
list({ project }: { project: string }, ctx: McpContext) {
return toolResult(async () =>
(
await this.api.data(
this.api.as(ctx).GET('/api/projects/{project}/sprints', {
params: { path: { project } },
}),
)
).map(sprintLine),
);
}
@Tool({
name: 'get_sprint',
description:
'A sprint with its progress (tickets and story points finished), its tickets in backlog order and, optionally, the day-by-day burndown.',
paramsSchema: z.object({
sprintId,
withBurndown: z.boolean().default(false),
limit: limit(200, 100),
}),
annotations: READ,
})
get(
params: { sprintId: string; withBurndown: boolean; limit: number },
ctx: McpContext,
) {
return toolResult(async () => {
const client = this.api.as(ctx);
const path = { sprint: params.sprintId };
const [sprint, progress, issues] = await Promise.all([
this.api.data(
client.GET('/api/sprints/{sprint}', { params: { path } }),
),
this.api.data(
client.GET('/api/sprints/{sprint}/progress', { params: { path } }),
),
this.api.data(
client.GET('/api/sprints/{sprint}/issues', {
params: { path, query: { limit: params.limit } },
}),
),
]);
const burndown = params.withBurndown
? await this.api.data(
client.GET('/api/sprints/{sprint}/burndown', { params: { path } }),
)
: undefined;
return {
...sprintLine(sprint),
progress,
total: issues.total,
issues: issues.items.map(issueLine),
burndown,
};
});
}
@Tool({
name: 'create_sprint',
description: 'Plan a sprint in a project (owners and admins).',
paramsSchema: z.object({
project: projectRef,
name: z.string().min(1).max(80).describe('e.g. Sprint 14'),
goal: z.string().max(500).optional(),
startDate: date('First day'),
endDate: date('Last day'),
}),
annotations: WRITE,
})
create(
params: {
project: string;
name: string;
goal?: string;
startDate: string;
endDate: string;
},
ctx: McpContext,
) {
const { project, ...body } = params;
return toolResult(() =>
this.api.data(
this.api.as(ctx).POST('/api/projects/{project}/sprints', {
params: { path: { project } },
body,
}),
),
);
}
@Tool({
name: 'update_sprint',
description:
'Rename a sprint, change its goal or its dates (not once closed).',
paramsSchema: z.object({
sprintId,
name: z.string().min(1).max(80).optional(),
goal: z.string().max(500).nullable().optional(),
startDate: date('First day').optional(),
endDate: date('Last day').optional(),
}),
annotations: { ...WRITE, idempotentHint: true },
})
update(
params: {
sprintId: string;
name?: string;
goal?: string | null;
startDate?: string;
endDate?: string;
},
ctx: McpContext,
) {
const { sprintId, ...body } = params;
return toolResult(() =>
this.api.data(
this.api.as(ctx).PATCH('/api/sprints/{sprint}', {
params: { path: { sprint: sprintId } },
body,
}),
),
);
}
@Tool({
name: 'start_sprint',
description:
'Start a planned sprint (only one can be under way per project).',
paramsSchema: z.object({ sprintId }),
annotations: WRITE,
})
start({ sprintId }: { sprintId: string }, ctx: McpContext) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).POST('/api/sprints/{sprint}/start', {
params: { path: { sprint: sprintId } },
}),
),
);
}
@Tool({
name: 'close_sprint',
description:
'Close the sprint under way. Unfinished tickets move to carryOverTo (another open sprint of the project) or back to the backlog; finished ones stay as its record.',
paramsSchema: z.object({
sprintId,
carryOverTo: z
.string()
.nullable()
.optional()
.describe('Sprint id for the unfinished tickets; absent: the backlog'),
}),
annotations: WRITE,
})
close(
{
sprintId,
carryOverTo,
}: { sprintId: string; carryOverTo?: string | null },
ctx: McpContext,
) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).POST('/api/sprints/{sprint}/close', {
params: { path: { sprint: sprintId } },
body: { carryOverTo },
}),
),
);
}
@Tool({
name: 'delete_sprint',
description: 'Delete a sprint that never started.',
paramsSchema: z.object({ sprintId }),
annotations: { readOnlyHint: false, destructiveHint: true },
})
delete({ sprintId }: { sprintId: string }, ctx: McpContext) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).DELETE('/api/sprints/{sprint}', {
params: { path: { sprint: sprintId } },
}),
),
);
}
@Tool({
name: 'set_issue_sprint',
description:
'Put a ticket in an open sprint of its project, or back to the backlog (null). For many at once: bulk_update_issues.',
paramsSchema: z.object({
issue: issueRef,
sprintId: z.string().nullable().describe('Sprint id, or null'),
}),
annotations: { ...WRITE, idempotentHint: true },
})
setSprint(
{ issue, sprintId }: { issue: string; sprintId: string | null },
ctx: McpContext,
) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).PUT('/api/issues/{issue}/sprint', {
params: { path: { issue } },
body: { sprintId },
}),
),
);
}
}

View File

@@ -8,6 +8,7 @@ import type { Schemas } from '../api/taskstream-api';
type Issue = Schemas['IssueDto']; type Issue = Schemas['IssueDto'];
type Project = Schemas['ProjectDto']; type Project = Schemas['ProjectDto'];
type Release = Schemas['ReleaseDto']; type Release = Schemas['ReleaseDto'];
type Sprint = Schemas['SprintDto'];
export const issueLine = (issue: Issue) => ({ export const issueLine = (issue: Issue) => ({
key: issue.key, key: issue.key,
@@ -19,6 +20,14 @@ export const issueLine = (issue: Issue) => ({
area: issue.area, area: issue.area,
assignee: issue.assignee?.name ?? null, assignee: issue.assignee?.name ?? null,
release: issue.release?.name ?? null, release: issue.release?.name ?? null,
sprint: issue.sprint?.name ?? null,
parent: issue.parent?.key ?? null,
/** Open tickets it waits for. */
blockedBy: issue.blockedBy.map((b) => b.key),
votes: issue.votes,
storyPoints: issue.storyPoints,
dueDate: issue.dueDate,
customFields: issue.customFields,
labels: issue.labels, labels: issue.labels,
updatedAt: issue.updatedAt, updatedAt: issue.updatedAt,
}); });
@@ -61,3 +70,12 @@ export const releaseLine = (release: Release) => ({
targetDate: release.targetDate, targetDate: release.targetDate,
releasedAt: release.releasedAt, releasedAt: release.releasedAt,
}); });
export const sprintLine = (sprint: Sprint) => ({
id: sprint.id,
name: sprint.name,
goal: sprint.goal,
status: sprint.status,
startDate: sprint.startDate,
endDate: sprint.endDate,
});

View File

@@ -11,6 +11,8 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
location /api/ { location /api/ {
# Attachments: the API takes files up to ATTACHMENTS_MAX_MB (25 MB by default).
client_max_body_size 30m;
proxy_pass http://task-stream-api:3000; proxy_pass http://task-stream-api:3000;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -26,6 +28,19 @@ server {
proxy_read_timeout 1h; proxy_read_timeout 1h;
} }
# The service worker and the manifest have no hash in their name: never
# cached, or browsers would keep an old one (exact matches win over the
# regex below).
location = /push-sw.js {
add_header Cache-Control "no-cache";
try_files $uri =404;
}
location = /manifest.webmanifest {
add_header Cache-Control "no-cache";
default_type application/manifest+json;
try_files $uri =404;
}
# Hashed bundles can be cached forever; index.html never. # Hashed bundles can be cached forever; index.html never.
location ~* \.(js|css|woff2?|svg|png|ico)$ { location ~* \.(js|css|woff2?|svg|png|ico)$ {
expires 1y; expires 1y;

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

View File

@@ -0,0 +1,25 @@
{
"name": "TaskStream",
"short_name": "TaskStream",
"description": "Gestión de proyectos y tickets en tiempo real",
"lang": "es",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#f7f7fa",
"theme_color": "#a347e1",
"icons": [
{
"src": "/logo-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/logo-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
}
]
}

View File

@@ -0,0 +1,52 @@
/*
* TaskStream's service worker: push notifications only. It caches nothing,
* so a new deploy never leaves an old version of the app behind.
*
* The API sends `{ title, body, url, tag }` (see Notifier's payloadOf).
*/
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (event) =>
event.waitUntil(self.clients.claim()),
);
self.addEventListener('push', (event) => {
let data = {};
try {
data = event.data ? event.data.json() : {};
} catch {
data = { body: event.data ? event.data.text() : '' };
}
event.waitUntil(
self.registration.showNotification(data.title || 'TaskStream', {
body: data.body || '',
tag: data.tag,
icon: '/logo-192.png',
badge: '/logo-96.png',
data: { url: data.url || '/' },
}),
);
});
// A tap opens the ticket: in a TaskStream tab already open, or in a new one.
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const target = new URL(
event.notification.data?.url || '/',
self.location.origin,
).href;
event.waitUntil(
self.clients
.matchAll({ type: 'window', includeUncontrolled: true })
.then((tabs) => {
for (const tab of tabs) {
if (
new URL(tab.url).origin === self.location.origin &&
'focus' in tab
) {
return tab.focus().then(() => tab.navigate(target));
}
}
return self.clients.openWindow(target);
}),
);
});

View File

@@ -16,7 +16,7 @@ import {
} from '@task-stream/web/shared/infrastructure'; } from '@task-stream/web/shared/infrastructure';
import { providePiUi, ToastService } from '@task-stream/web/shared/ui'; import { providePiUi, ToastService } from '@task-stream/web/shared/ui';
import { Notifications } from '@task-stream/web/tracker/application'; import { Notifications } from '@task-stream/web/tracker/application';
import { provideTrackerInfrastructure } from '@task-stream/web/tracker/infrastructure'; import { provideTrackerShellInfrastructure } from '@task-stream/web/tracker/infrastructure';
import { appRoutes } from './app.routes'; import { appRoutes } from './app.routes';
/* /*
@@ -39,9 +39,9 @@ export const appConfig: ApplicationConfig = {
provideApiConfig(apiConfigFromLocation()), provideApiConfig(apiConfigFromLocation()),
provideGraphql(), provideGraphql(),
// Ports → adapters // Ports → adapters: the shell's here; the pages' with their lazy routes (tracker.routes.ts).
provideIdentityInfrastructure(), provideIdentityInfrastructure(),
provideTrackerInfrastructure(), provideTrackerShellInfrastructure(),
{ provide: Notifications, useExisting: ToastService }, { provide: Notifications, useExisting: ToastService },
], ],
}; };

View File

@@ -1,34 +1,72 @@
import { Route } from '@angular/router';
import { authGuard } from '@task-stream/web/identity/presenters'; import { authGuard } from '@task-stream/web/identity/presenters';
import { appRoutes } from './app.routes'; import { appRoutes } from './app.routes';
/** The tracker's routes, behind the lazy `''` route (and its providers' parent route). */
async function trackerChildren(): Promise<Route[]> {
const lazy = appRoutes.find(
(route) => route.path === '' && route.loadChildren,
);
const loaded = (await lazy?.loadChildren?.()) as Route[];
expect(loaded[0].providers?.length).toBeGreaterThan(0);
return loaded[0].children ?? [];
}
describe('appRoutes', () => { describe('appRoutes', () => {
it('keeps home behind the auth guard, and public projects and «Explorar» open', () => { it('keeps home behind the auth guard, and public projects and «Explorar» open', async () => {
const paths = appRoutes.map((route) => route.path); const paths = appRoutes.map((route) => route.path);
expect(paths).toEqual( expect(paths).toEqual(
expect.arrayContaining(['login', 'auth/callback', '', '**']), expect.arrayContaining(['login', 'auth/callback', '', '**']),
); );
const app = appRoutes.find((route) => route.path === '' && route.children); const children = await trackerChildren();
const children = app?.children ?? [];
expect(children.map((route) => route.path)).toEqual([ expect(children.map((route) => route.path)).toEqual([
'', '',
'portfolio',
'explore',
'share/:token',
'notes/:token',
'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();
// The pages for clients need no session: their link is the key.
expect(children[3].canActivate).toBeUndefined();
expect(children[4].canActivate).toBeUndefined();
expect(children[7].canActivate).toBeUndefined();
// The Spanish paths of shared links lead to the English ones.
expect(children[5].redirectTo).toBe('portfolio');
expect(children[6].redirectTo).toBe('explore');
}); });
it('exposes every project section', () => { it('gives each page its own state, not the root', async () => {
const project = appRoutes const children = await trackerChildren();
.find((route) => route.path === '') expect(children[0].providers?.length).toBe(4);
?.children?.find((route) => route.path === 'p/:key'); expect(children[2].providers?.length).toBe(1);
});
it('exposes every project section', async () => {
const project = (await trackerChildren()).find(
(route) => route.path === 'p/:key',
);
expect(project?.children?.map((route) => route.path)).toEqual([ expect(project?.children?.map((route) => route.path)).toEqual([
'', '',
'proponer',
'board', 'board',
'propose',
'list',
'issues/:issueKey', 'issues/:issueKey',
'backlog',
'sprints',
'calendar',
'roadmap',
'epics',
'trash',
'releases', 'releases',
'report/:kind/:id',
'dashboard', 'dashboard',
'analytics', 'analytics',
'settings', 'settings',

View File

@@ -1,10 +1,12 @@
import { Route } from '@angular/router'; import { Route } from '@angular/router';
import { identityRoutes } from '@task-stream/web/identity/presenters'; import { identityRoutes } from '@task-stream/web/identity/presenters';
import { trackerRoutes } from '@task-stream/web/tracker/presenters';
export const appRoutes: Route[] = [ export const appRoutes: Route[] = [
...identityRoutes, ...identityRoutes,
// Not all behind the guard: public projects are readable without a session. // Not all behind the guard: public projects are readable without a session.
{ path: '', children: trackerRoutes }, {
path: '',
loadChildren: () => import('./tracker.routes').then((m) => m.routes),
},
{ path: '**', redirectTo: '' }, { path: '**', redirectTo: '' },
]; ];

View File

@@ -10,7 +10,12 @@ import {
import { filter, map } from 'rxjs'; import { filter, map } from 'rxjs';
import { AuthFacade } from '@task-stream/web/identity/application'; import { AuthFacade } from '@task-stream/web/identity/application';
import { UserMenuComponent } from '@task-stream/web/identity/presenters'; import { UserMenuComponent } from '@task-stream/web/identity/presenters';
import { PiIconButtonComponent } from '@pi/ui';
import { AppShellComponent } from '@task-stream/web/shared/ui'; import { AppShellComponent } from '@task-stream/web/shared/ui';
import {
CommandPalette,
NotificationsBell,
} from '@task-stream/web/tracker/presenters';
@Component({ @Component({
selector: 'ts-root', selector: 'ts-root',
@@ -21,7 +26,26 @@ import { AppShellComponent } from '@task-stream/web/shared/ui';
RouterLinkActive, RouterLinkActive,
AppShellComponent, AppShellComponent,
UserMenuComponent, UserMenuComponent,
NotificationsBell,
CommandPalette,
PiIconButtonComponent,
], ],
styles: `
/* The palette's button, still: same box, so nothing moves when it loads. */
.ts-shell-search {
display: inline-flex;
gap: 0.375rem;
align-items: center;
border: 0;
background: none;
font: inherit;
cursor: pointer;
kbd {
font-size: 0.6875rem;
opacity: 0.7;
}
}
`,
template: ` template: `
<ts-app-shell> <ts-app-shell>
@if (auth.isAuthenticated()) { @if (auth.isAuthenticated()) {
@@ -35,16 +59,53 @@ import { AppShellComponent } from '@task-stream/web/shared/ui';
Proyectos Proyectos
</a> </a>
} }
@if (auth.isAuthenticated()) {
<a <a
shellNav shellNav
class="ts-nav-link" class="ts-nav-link"
routerLink="/explorar" routerLink="/portfolio"
routerLinkActive="is-active"
>
Cartera
</a>
}
<a
shellNav
class="ts-nav-link"
routerLink="/explore"
routerLinkActive="is-active" routerLinkActive="is-active"
> >
Explorar Explorar
</a> </a>
@if (auth.isAuthenticated()) { @if (auth.isAuthenticated()) {
<ts-user-menu shellUser /> <!-- One projectable node per @if branch, or neither reaches the slot. -->
<ng-container shellUser>
<!-- Palette and bell load once the page is up: their code and their
queries stay out of the start. Meanwhile, a still copy. -->
@defer (on idle; on interaction) {
<ts-command-palette />
} @placeholder {
<button
type="button"
class="ts-nav-link ts-shell-search"
tabindex="-1"
aria-hidden="true"
>
Buscar <kbd>⌘K</kbd>
</button>
}
@defer (on idle; on interaction) {
<ts-notifications-bell />
} @placeholder {
<pi-icon-button
icon="bell"
color="grey"
appearance="ghost"
aria-label="Notificaciones"
/>
}
<ts-user-menu />
</ng-container>
} @else if (auth.settled() && !onLogin()) { } @else if (auth.settled() && !onLogin()) {
<a shellUser class="ts-nav-link ts-nav-link--cta" routerLink="/login" <a shellUser class="ts-nav-link ts-nav-link--cta" routerLink="/login"
>Iniciar sesión</a >Iniciar sesión</a

View File

@@ -0,0 +1,15 @@
import { Route } from '@angular/router';
import { provideTrackerPagesInfrastructure } from '@task-stream/web/tracker/infrastructure';
import { trackerRoutes } from '@task-stream/web/tracker/presenters';
/**
* The tracker, loaded lazily: its pages, and the adapters only they use,
* come in this chunk instead of the one that boots the app.
*/
export const routes: Route[] = [
{
path: '',
providers: [provideTrackerPagesInfrastructure()],
children: trackerRoutes,
},
];

View File

@@ -12,6 +12,8 @@
<link rel="icon" type="image/x-icon" href="favicon.ico" /> <link rel="icon" type="image/x-icon" href="favicon.ico" />
<link rel="icon" type="image/png" sizes="96x96" href="logo-96.png" /> <link rel="icon" type="image/png" sizes="96x96" href="logo-96.png" />
<link rel="apple-touch-icon" href="logo.png" /> <link rel="apple-touch-icon" href="logo.png" />
<!-- Installable (on iPhone, push only reaches an installed web app) -->
<link rel="manifest" href="manifest.webmanifest" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<!-- Inter, like pireon.dev · Geist Mono only for data (ticket keys, counters, versions) --> <!-- Inter, like pireon.dev · Geist Mono only for data (ticket keys, counters, versions) -->

View File

@@ -406,6 +406,13 @@ h3 {
.ts-form { .ts-form {
display: grid; display: grid;
gap: 1rem; gap: 1rem;
// A field's label floats above its box; other fields leave room for it
// below them (their hint line), a segmented control does not.
> pi-segmented-control,
> p:has(+ pi-text-field, + pi-textarea, + pi-select-field, + pi-date-field) {
margin-bottom: 0.75rem;
}
} }
.ts-form__row { .ts-form__row {
@@ -618,3 +625,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

@@ -45,6 +45,7 @@ services:
EVENTS_BRIDGE_ENABLED: 'true' EVENTS_BRIDGE_ENABLED: 'true'
PIREON_BASE_URL: https://oauth.pireon.dev PIREON_BASE_URL: https://oauth.pireon.dev
PIREON_WEBHOOK_SECRET: ${PIREON_WEBHOOK_SECRET:-} PIREON_WEBHOOK_SECRET: ${PIREON_WEBHOOK_SECRET:-}
PIREON_PASSWORD_WEBHOOK_SECRET: ${PIREON_PASSWORD_WEBHOOK_SECRET:-}
PIREON_CLIENT_ID: ${PIREON_CLIENT_ID:-} PIREON_CLIENT_ID: ${PIREON_CLIENT_ID:-}
PIREON_CLIENT_SECRET: ${PIREON_CLIENT_SECRET:-} PIREON_CLIENT_SECRET: ${PIREON_CLIENT_SECRET:-}
# Pireon clients allow one grant type: the one above is authorization_code, # Pireon clients allow one grant type: the one above is authorization_code,
@@ -56,6 +57,24 @@ services:
# document is still served at /api/docs-json. # document is still served at /api/docs-json.
GRAPHQL_SCHEMA_FILE: '' GRAPHQL_SCHEMA_FILE: ''
OPENAPI_FILE: '' OPENAPI_FILE: ''
ATTACHMENTS_DIR: /data/attachments
ATTACHMENTS_MAX_MB: ${ATTACHMENTS_MAX_MB:-25}
# E-mail copies of notifications (optional).
SMTP_URL: ${SMTP_URL:-}
MAIL_FROM: ${MAIL_FROM:-}
WEB_URL: ${WEB_URL:-}
# Push notifications (optional): `npx web-push generate-vapid-keys`.
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
VAPID_SUBJECT: ${VAPID_SUBJECT:-}
# Outgoing webhooks to private addresses (off: they could reach internal services).
WEBHOOKS_ALLOW_PRIVATE: ${WEBHOOKS_ALLOW_PRIVATE:-false}
# Due-date reminders, checked every hour.
REMINDERS_ENABLED: ${REMINDERS_ENABLED:-true}
REMINDERS_TIMEZONE: ${REMINDERS_TIMEZONE:-Europe/Madrid}
volumes:
# Files attached to tickets: lose this volume and the tickets point at nothing.
- attachments-data:/data/attachments
depends_on: depends_on:
postgres: { condition: service_healthy } postgres: { condition: service_healthy }
mongo: { condition: service_healthy } mongo: { condition: service_healthy }
@@ -125,6 +144,7 @@ services:
retries: 10 retries: 10
volumes: volumes:
attachments-data:
postgres-data: postgres-data:
mongo-data: mongo-data:
redis-data: redis-data:

View File

@@ -3,7 +3,7 @@
## Qué resuelve ## Qué resuelve
| Necesidad | Cómo se resuelve | | Necesidad | Cómo se resuelve |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas | | 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas |
| 🌍 Públicos | Un proyecto público se puede leer sin iniciar sesión (web, GraphQL y REST): tablero, tickets, comentarios, releases, actividad y estadísticas; «Explorar» los lista | | 🌍 Públicos | Un proyecto público se puede leer sin iniciar sesión (web, GraphQL y REST): tablero, tickets, comentarios, releases, actividad y estadísticas; «Explorar» los lista |
| 🗑️ Borrar | El propietario borra un proyecto confirmando su clave; tickets, comentarios, releases y enlaces desde otros proyectos se borran en cascada (sagas) y la clave queda libre | | 🗑️ Borrar | El propietario borra un proyecto confirmando su clave; tickets, comentarios, releases y enlaces desde otros proyectos se borran en cascada (sagas) y la clave queda libre |
@@ -12,9 +12,37 @@
| ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo | | ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo |
| 🧪 QA | Por defecto, `READY_FOR_QA → IN_QA → DONE / QA_FAILED`; fallar QA exige un motivo | | 🧪 QA | Por defecto, `READY_FOR_QA → IN_QA → DONE / QA_FAILED`; fallar QA exige un motivo |
| 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) | | 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) |
| 📝 Notas de versión | Borrador en Markdown con los tickets terminados por tipo, editable y copiable, también tras publicar; y una página pública por release para los clientes, con enlace secreto |
| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona | | 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona |
| 💬 Comentarios | Decisiones y explicaciones dentro del ticket; el autor edita, el admin modera | | 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con formato (editor visual, guardado en Markdown), capturas pegadas, respuestas, reacciones (👍 🎉…), decisiones (un comentario marcado sale encima del ticket y se encuentra al buscar), conversaciones que se resuelven con su conclusión, convertir un comentario en ticket, lo nuevo desde tu última visita marcado, encuestas rápidas que acaban en decisión, «está escribiendo…» en vivo y `@menciones`; el autor edita, el admin modera |
| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA) | | 🔎 Vistas | El tablero se filtra por texto, persona (o sin asignar), tipo, prioridad, área y release; los filtros se guardan como vistas propias o compartidas con el proyecto |
| 📋 Lista | Todos los tickets en filas: ordenar por cualquier columna, filtrar, cambiar estado, prioridad o persona sin abrirlos, editar en bloque y duplicar |
| ↩️ Deshacer | Mover, reasignar o mandar a la papelera ofrece «Deshacer» unos segundos (también Ctrl/⌘ Z), con la orden contraria |
| ☀ Mi día | Cada mañana eliges los tickets de hoy; se tachan al terminar y lo que quede se pasa al día siguiente; y «Recordármelo» en cualquier ticket |
| 🚦 Salud | Un semáforo por proyecto (en su panel y en la cartera) que dice qué va mal y por qué: atascados, vencidos, bugs urgentes, QA, releases tarde |
| 🚦 WIP y carriles | Límite de trabajo en curso por estado (la columna se marca al pasarse) y tablero agrupable por persona, épica, prioridad o tipo |
| 🎲 Estimación | Puntos por ticket (escala 0-1-2-3-5-8-13-21) en la ficha, las tarjetas, las columnas y el backlog; velocidad por sprint y aviso si un sprint planificado supera la media |
| 🏃 Sprints | Iteraciones con fechas y objetivo, una en curso por proyecto, con burndown (tickets o puntos) y traspaso de lo pendiente al cerrar |
| 📅 Calendario | Los sprints mes a mes como franjas, con las releases y los vencimientos de cada día; se planifica un sprint pulsando su primer día y todo se descarga en .ics |
| 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden |
| 🗑️ Papelera | Borrar un ticket lo manda a la papelera (quien lo abrió o un admin); se recupera durante 30 días y luego se borra del todo |
| ☑️ En bloque | Selecciona varios tickets del backlog y cámbiales a la vez estado, persona, sprint, prioridad o etiqueta |
| ⌨️ Atajos y búsqueda | Ctrl/⌘ K o / busca tickets, decisiones y proyectos de todos tus proyectos (antes de escribir, tus tickets fijados); `g b`, `g k`, `g s`, `g r` navegan, `c` crea, en el tablero las flechas recorren las tarjetas y Shift+←/→ las cambia de columna, `e` edita, `?` los lista |
| 🗺️ Hoja de ruta | Las épicas en el tiempo (fecha de inicio y límite), con los sprints y las releases como referencias y flechas entre las que dependen unas de otras |
| 🗳️ Propuestas y votos | Un proyecto público puede abrir un formulario para que cualquiera, sin cuenta, reporte un error o proponga una idea; quien tenga sesión vota lo que quiere |
| 🌐 Web del colectivo | La página de cada proyecto en pireon.dev enseña su hoja de ruta, versiones, sprint y tickets para recién llegados (`good-first-issue`) |
| 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños |
| 👀 Seguir | Cualquier miembro sigue los tickets que le interesan y los ve juntos en «Que sigo»; quien lo abre, lo tiene asignado o comenta, lo sigue solo |
| 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios, respuestas), en vivo y opcionalmente por correo |
| ⏰ Recordatorios | Aviso (y correo, si se quiere) el día antes, el mismo día y al vencer la fecha límite, a quien lo tiene y a quien lo sigue; las tarjetas marcan lo que vence |
| 📎 Adjuntos | Capturas, logs y vídeos en el ticket o bajo un comentario; arrastrar, elegir o pegar con Ctrl+V; miniaturas de las imágenes; grabar la pantalla o la voz desde el comentario y verlo ahí mismo |
| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA); lo que espera a otro ticket abierto sale marcado como «Bloqueado» |
| 🌿 Git | Webhook para GitHub, Gitea o GitLab: commits, ramas y pull requests que nombran un ticket (WEB-12) aparecen en su ficha |
| 🚨 Alertas → bugs | Sentry, Uptime Kuma, Grafana o cualquier herramienta abren bugs por una URL secreta; el mismo problema repetido va al bug abierto, y «resuelta» se dice en él |
| 🧾 Plantillas | Cada tipo de ticket puede traer una descripción inicial y una checklist que se aplican al crearlo |
| 🤖 Automatizaciones | Reglas «cuando… si… entonces…»: al crear, mover, asignar, comentar o fusionar una PR, mover, asignar, etiquetar, priorizar, comentar o meter en el sprint |
| 📡 Webhooks | Un POST firmado (HMAC-SHA256) a otro sistema cuando pasa algo: tickets, comentarios, releases, sprints; con prueba y último envío |
| 📦 Importar/exportar | Todos los tickets a CSV; importar un CSV propio o una exportación de Jira, con vista previa de lo que se va a crear |
| 🏠 Inicio | «Mi trabajo»: mis tickets en todos los proyectos, por categoría (QA fallidos primero), lo que espera QA, próximas releases e «Ir a…» por clave | | 🏠 Inicio | «Mi trabajo»: mis tickets en todos los proyectos, por categoría (QA fallidos primero), lo que espera QA, próximas releases e «Ir a…» por clave |
| 📊 Seguimiento | Panel con contadores, flujo por estado, el equipo con sus tickets abiertos (enlaza a su tablero), feed de actividad e historial por ticket | | 📊 Seguimiento | Panel con contadores, flujo por estado, el equipo con sus tickets abiertos (enlaza a su tablero), feed de actividad e historial por ticket |
| 📈 Estadísticas | Creados vs. resueltos, flujo acumulado, burnup de releases, tiempos de ciclo y entrega, tasa de fallo en QA y resueltos por persona | | 📈 Estadísticas | Creados vs. resueltos, flujo acumulado, burnup de releases, tiempos de ciclo y entrega, tasa de fallo en QA y resueltos por persona |

View File

@@ -23,14 +23,24 @@ flowchart LR
| `domain` | Modelo y reglas de negocio; puertos que necesita el dominio | `domain` | | `domain` | Modelo y reglas de negocio; puertos que necesita el dominio | `domain` |
| `application` | Casos de uso: services, commands/queries + handlers, proyecciones (API) o facades + stores (web), puertos de salida | `domain`, `application` | | `application` | Casos de uso: services, commands/queries + handlers, proyecciones (API) o facades + stores (web), puertos de salida | `domain`, `application` |
| `infrastructure` | Adaptadores **de salida** que implementan los puertos (bases de datos, Redis, Pireon, GraphQL) | `domain`, `application`, `infrastructure` | | `infrastructure` | Adaptadores **de salida** que implementan los puertos (bases de datos, Redis, Pireon, GraphQL) | `domain`, `application`, `infrastructure` |
| `presenters` | Adaptadores **de entrada** (resolvers GraphQL, controllers HTTP, guards, páginas Angular) | `domain`, `application`, `presenters` | | `presenters` | Adaptadores **de entrada** (resolvers GraphQL, controllers HTTP, guards, temporizadores, páginas Angular) | `domain`, `application`, `presenters` |
Las reglas no son una convención: las comprueba `@nx/enforce-module-boundaries` en `eslint.config.mjs` (`npx nx run-many -t lint`). Las reglas no son una convención: las comprueba `@nx/enforce-module-boundaries` en `eslint.config.mjs` (`npx nx run-many -t lint`).
- `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…).
- `presenters` nunca importa `infrastructure` ni usa los buses CQRS: habla con los `*.service.ts` (API) o con las facades (web). - Los buses CQRS solo los usan los `*.service.ts` de `application` (los comandos, a través del `CommandRunner`, que
los reintenta si chocan; las consultas, con el `QueryBus`); todo lo demás pasa por
un servicio: resolvers, controllers, temporizadores (recordatorios, papelera; por eso viven en `presenters`), el
handler de eventos de las automatizaciones y el directorio de usuarios del tracker (`IdentityService`). La única
excepción es el `EventsBridge`, que ejecuta los comandos que devuelven las sagas.
- Los comandos son atómicos: un handler no ejecuta otros comandos. Lo que encadena varios (duplicar un ticket, la
edición en bloque, importar un CSV, las propuestas, vaciar la papelera) es un método del servicio, que ejecuta los
comandos uno a uno con su bus. Así no hay ciclos entre servicios y handlers, ni `forwardRef`.
- Sin módulos globales entre contextos: el tracker recibe el módulo de aplicación de identidad en la raíz de composición
(`TrackerInfrastructureModule.withIdentity(...)`), el mismo con el que se monta ese contexto.
- `presenters` nunca importa `infrastructure`: habla con los `*.service.ts` (API) o con las facades (web).
- `infrastructure` nunca importa `presenters`. - `infrastructure` nunca importa `presenters`.
- Los contextos también están acotados: `tracker` puede usar `identity` y `shared`; `shared` no depende de nadie; la web - Los contextos también están acotados: `tracker` puede usar `identity` y `shared`; `shared` no depende de nadie; la web
no depende de la API (y viceversa). no depende de la API (y viceversa).
@@ -49,8 +59,11 @@ TrackerPresentersModule.withApplication(
```ts ```ts
// apps/web/src/app/app.config.ts // apps/web/src/app/app.config.ts
provideTrackerInfrastructure(); // puertos → adaptadores GraphQL provideTrackerShellInfrastructure(); // puertos de la cabecera → adaptadores GraphQL
{ provide: Notifications, useExisting: ToastService } // puerto de la aplicación → toasts de @pi/ui { provide: Notifications, useExisting: ToastService } // puerto de la aplicación → toasts de @pi/ui
// apps/web/src/app/tracker.routes.ts (en diferido)
{ path: '', providers: [provideTrackerPagesInfrastructure()], children: trackerRoutes }
``` ```
## 2.2 Flujo completo de una escritura ## 2.2 Flujo completo de una escritura
@@ -100,18 +113,38 @@ 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
restricción aparte (la clave, el proyecto, proyecto + nombre). En `events`, el orden global con el que se reenvían los
eventos va en `seq` (`bigserial`), porque un uuid no se ordena; `stream_id` es el id del agregado.
### De escritura a lectura: Redis ### De escritura a lectura: Redis
- La tabla `events` es la **bandeja de salida (outbox)**: cada evento entra en la misma transacción que la escritura,
con `published_at` vacío. Si Postgres falla, la escritura falla entera y no queda nada a medias.
- El **`EventStoreRelay`** copia los eventos nuevos (`published_at IS NULL`) al Redis Stream `task-stream:events`, - El **`EventStoreRelay`** copia los eventos nuevos (`published_at IS NULL`) al Redis Stream `task-stream:events`,
en orden y con `FOR UPDATE SKIP LOCKED` (varias instancias no duplican). La entrega es _at-least-once_. en orden y con `FOR UPDATE SKIP LOCKED` (varias instancias no duplican). La entrega es _at-least-once_. Si Redis
falla, los eventos esperan en la tabla y salen al volver.
- El **`EventsBridge`** consume el stream con un _consumer group_, deserializa cada evento (`EventClsRegistry` + - El **`EventsBridge`** consume el stream con un _consumer group_, deserializa cada evento (`EventClsRegistry` +
`@AutowiredEvent`) y lo entrega a los `@EventsHandler` registrados para su clase. A diferencia de `@AutowiredEvent`) y lo entrega a los `@EventsHandler` registrados para su clase. A diferencia de
`eventBus.subject$.next()`, **espera** a cada handler y procesa los eventos en orden; solo hace `XACK` cuando todos `eventBus.subject$.next()`, **espera** a cada handler y procesa los eventos en orden; solo hace `XACK` cuando todos
terminan. Si uno falla, el evento queda pendiente y se reintenta (y `XAUTOCLAIM` recupera los de instancias caídas). terminan. El grupo de consumidores hace de **bandeja de entrada (inbox)**, y la idempotencia la ponen las
proyecciones (versión, abajo).
- **Si un evento falla, se reintenta en su sitio** (`EventStreamConsumer`), esperando cada vez más (hasta 30 s), y el
siguiente espera: saltárselo aplicaría antes un cambio posterior y la comprobación de versión descartaría después el
anterior para siempre. Si el fallo es de la infraestructura (Mongo, Redis o la red caídos, ver `isTransientError`),
espera lo que haga falta y se pone al día solo al volver, sin reiniciar. Si es del propio evento (un fallo del
código), tras 5 intentos va al stream `task-stream:events:dead` con el error, para no bloquear el resto. Lo que
queda pendiente al parar se reintenta al arrancar (y `XAUTOCLAIM` recupera lo de instancias caídas).
- `GET /api/health` dice en `events` cuántos eventos esperan en la bandeja de salida, cuántos están pendientes de
proyectar y cuántos se apartaron; con alguno de más, `status: degraded` (pero 200: un retraso de la lectura no
reinicia el contenedor). El stream se recorta a `EVENT_STREAM_MAX_LENGTH` (un millón): solo un corte larguísimo
podría recortar algo pendiente, y Postgres siempre permite reconstruir la lectura.
- Después de los handlers, el bridge ejecuta también las **sagas** (ver abajo). - Después de los handlers, el bridge ejecuta también las **sagas** (ver abajo).
### Lectura: MongoDB ### Lectura: MongoDB
@@ -122,6 +155,13 @@ sequenceDiagram
que reentregar un evento no tiene efecto. que reentregar un evento no tiene efecto.
- Las queries leen por los puertos `Find*Repository`. Los read models (`ProjectReadModel`, `IssueReadModel`…) están en - Las queries leen por los puertos `Find*Repository`. Los read models (`ProjectReadModel`, `IssueReadModel`…) están en
`domain/read-models`. `domain/read-models`.
- **Esquemas**: clases con `@Schema()` y `@Prop()` de `@nestjs/mongoose` (`read-model.schemas.ts`,
`user.schema.ts`), en `strict: false`: cada documento guarda el read model entero y las props declaradas son las que
tienen índice o una forma que conviene fijar.
- **Ids**: cada documento tiene `_id` (el `ObjectId` de Mongo, que no sale de la base de datos) e `id` con índice
único, que es el id del dominio: el mismo uuid que el `stream_id` del agregado en Postgres. Todas las consultas van
por `id`. En usuarios es el id de Pireon; en actividad, historial y avisos, uno compuesto que hace idempotente la
proyección.
### Tiempo real ### Tiempo real
@@ -138,10 +178,13 @@ evento, tras las proyecciones, pasa ese evento solo (`of(event)`) por cada saga
uno y esperando. Así heredan las garantías del bridge: orden, reintento y nada se pierde si la API se cae a mitad. uno y esperando. Así heredan las garantías del bridge: orden, reintento y nada se pierde si la API se cae a mitad.
| Saga | Evento | Comandos | | Saga | Evento | Comandos |
| --------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | | ---------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ProjectDeletionSaga` | `ProjectDeletedEvent` | `DeleteIssueCommand` por cada ticket y `DeleteReleaseCommand` por cada release del proyecto | | `ProjectDeletionSaga` | `ProjectDeletedEvent` | `DeleteIssueCommand` por cada ticket, `DeleteReleaseCommand` por cada release y `DeleteSprintCommand` por cada sprint del proyecto |
| | `IssueDeletedEvent` | `PurgeCommentCommand` por cada comentario y `DropLinksToIssueCommand` en los tickets que lo enlazaban | | | `IssueDeletedEvent` | `PurgeCommentCommand` por cada comentario, `DropLinksToIssueCommand` en los tickets que lo enlazaban y `DetachFromParentCommand` en los que colgaban de él |
| `ReleaseArchivedSaga` | `ReleaseArchivedEvent` | `DetachIssueFromReleaseCommand` para sus tickets sin terminar (los terminados se quedan) | | `ReleaseArchivedSaga` | `ReleaseArchivedEvent` | `DetachIssueFromReleaseCommand` para sus tickets sin terminar, a la release elegida al archivar o a ninguna (los terminados se quedan) |
| `SprintCarryOverSaga` | `SprintClosedEvent` / `SprintDeletedEvent` | `CarryOverIssueCommand`: lo pendiente pasa al sprint elegido o al backlog; al borrar, todos vuelven al backlog |
| `CommentersFollowSaga` | `CommentAddedEvent` | `WatchIssueCommand`: quien comenta pasa a seguir el ticket |
| `PeopleFollowSaga` | `IssueCreatedEvent`, `IssueAssignedEvent` | `WatchIssueCommand`: quien abre el ticket y quien lo tiene asignado pasan a seguirlo |
| `UserDeactivatedSaga` | `UserDeactivatedEvent` (contexto identity) | `UnassignIssueCommand` para sus tickets abiertos en todos los proyectos, como actor `system` («TaskStream») | | `UserDeactivatedSaga` | `UserDeactivatedEvent` (contexto identity) | `UnassignIssueCommand` para sus tickets abiertos en todos los proyectos, como actor `system` («TaskStream») |
Reglas que cumplen: Reglas que cumplen:

View File

@@ -39,11 +39,19 @@ 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.
@@ -56,6 +64,18 @@ historial, releases (progreso y burnup), actividad, seguimiento y estadísticas.
miembros según su rol (sin token: 401; con token pero sin ser miembro: 403). Un usuario con sesión que no es miembro miembros según su rol (sin token: 401; con token pero sin ser miembro: 403). Un usuario con sesión que no es miembro
lee igual que un anónimo. Las subscriptions exigen sesión; con ella, también se pueden seguir proyectos públicos. lee igual que un anónimo. Las subscriptions exigen sesión; con ella, también se pueden seguir proyectos públicos.
**Propuestas del público**: un proyecto público puede aceptar errores e ideas de cualquiera, sin cuenta
(`acceptsProposals`, en `updateProject` / `PATCH`). `POST /api/explore/projects/{project}/proposals` (`kind`: `BUG` o
`IDEA`, `title`, `description`, `author` opcional) crea un ticket con la etiqueta `propuesta`, a nombre de TaskStream,
en el primer estado, de tipo Bug o Feature (o el primer tipo activo). No se pide correo, porque quedaría a la vista. Contra
el spam, el campo `website` es una trampa (si llega relleno se responde como si nada y no se guarda) y cada dirección
envía como mucho 5 cada 15 minutos (429); el contador vive en Redis (`RateLimitStore`), así que vale igual con
varias instancias. Los propietarios y admins del proyecto reciben un aviso (`PROPOSED`). La web tiene el formulario en `/p/{CLAVE}/propose`, enlazado en la cabecera del proyecto.
**Votos**: quien tenga sesión vota los tickets de un proyecto público, y los miembros los de uno privado
(`voteIssue(issueId, on)`, `PUT/DELETE /api/issues/{issue}/vote`). Cada ticket da `votes` y `voted` (si he votado
yo; en REST, solo con token); la lista de quién votó no se publica.
`publicProjects` (GraphQL) y `GET /api/explore/projects` (REST, sin token) los listan. Al volver a privado, quien no es `publicProjects` (GraphQL) y `GET /api/explore/projects` (REST, sin token) los listan. Al volver a privado, quien no es
miembro vuelve a recibir _not found_. Los enlaces a tickets de otros proyectos solo se muestran si el visitante puede miembro vuelve a recibir _not found_. Los enlaces a tickets de otros proyectos solo se muestran si el visitante puede
leer esos proyectos. leer esos proyectos.
@@ -68,9 +88,19 @@ sus tickets (y sus comentarios), sus releases y los enlaces que tickets de otros
[Arquitectura](02-arquitectura.md)). La clave queda libre para un proyecto nuevo. Los eventos se quedan en el event [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** y **áreas** (`Project.settings`). Todos Cada proyecto tiene sus **estados**, **transiciones**, **tipos de ticket**, **áreas** y **campos personalizados**
(`Project.settings`). Todos
empiezan con los valores por defecto de abajo, y el propietario o un admin los cambia con `updateProjectSettings` (o empiezan con los valores por defecto de abajo, y el propietario o un admin los cambia con `updateProjectSettings` (o
`PUT /api/projects/{project}/settings`); el cambio es un evento más (`ProjectSettingsChangedEvent`). `PUT /api/projects/{project}/settings`); el cambio es un evento más (`ProjectSettingsChangedEvent`).
@@ -94,12 +124,63 @@ Reglas:
- Hace falta al menos un estado activo de categoría `TODO` (los tickets nuevos entran en el primero), uno `DONE` y un - Hace falta al menos un estado activo de categoría `TODO` (los tickets nuevos entran en el primero), uno `DONE` y un
tipo activo. tipo activo.
- Las transiciones solo pueden apuntar a estados que existen y nunca de un estado a sí mismo. - Las transiciones solo pueden apuntar a estados que existen y nunca de un estado a sí mismo.
- Colores: `slate`, `stone`, `violet`, `blue`, `sky`, `teal`, `green`, `amber`, `orange`, `red`, `pink`. Iconos: - Colores: uno de la paleta (`slate`, `stone`, `violet`, `blue`, `sky`, `teal`, `green`, `amber`, `orange`, `red`,
`pink`), que tiene su versión para modo oscuro, o cualquier `#rrggbb`. Iconos:
`bug`, `feature`, `palette`, `task`, `qa`, `flag`, `star-fill`, `rocket`, `document`, `light-bulb`, `key`, `support`. `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
Datos propios del proyecto en cada ticket («Navegador», «Cliente», «Coste»…), en `settings.customFields`: `id`, `name`,
`type` y `archived`, más `options` si es una lista. Un proyecto tiene como mucho 30, y empieza sin ninguno.
| Tipo | Valor |
| -------- | ---------------------------------------------------------------- |
| `TEXT` | Hasta 500 caracteres |
| `NUMBER` | Un número (se guarda en forma canónica: `1,5` → `1.5`) |
| `DATE` | `YYYY-MM-DD` |
| `SELECT` | Una de sus `options` (de 1 a 50, de hasta 60 caracteres, únicas) |
- Como el resto de la configuración, un campo no se borra: se archiva. Su **tipo no cambia** una vez guardado, para que
los valores que ya hay sigan teniendo sentido. Un campo archivado se puede vaciar en un ticket, pero no rellenar.
- Al guardar la configuración, si `customFields` no viene se quedan como estaban (los clientes que no los conocen no
los pierden).
- En el ticket: GraphQL `Issue.customFields` es una lista `{ fieldId, value }` (solo los que tienen valor) y se cambia
con `customFields: [{ fieldId, value }]` en `createIssue` / `updateIssue`; por REST es un objeto
`{ "BROWSER": "Safari" }`. En ambos se mandan solo los campos que cambian, y `null` o `""` vacía uno.
- Filtrar: `issues(filter: { customFields: [{ fieldId, value }] })` o `GET /api/projects/{project}/issues?field=BROWSER:Safari`
(se puede repetir).
- `IssueUpdatedEvent` lleva el mapa entero de valores ya validado, así la proyección solo lo copia.
### Workflow por defecto ### Workflow por defecto
```mermaid ```mermaid
@@ -142,8 +223,257 @@ stateDiagram-v2
- **Relaciones**: nunca consigo mismo ni duplicadas; pueden cruzar proyectos si el actor puede ver el destino. Se - **Relaciones**: nunca consigo mismo ni duplicadas; pueden cruzar proyectos si el actor puede ver el destino. Se
muestran en los dos tickets (`OUTWARD` en el origen, `INWARD` en el destino). muestran en los dos tickets (`OUTWARD` en el origen, `INWARD` en el destino).
- **Releases**: solo las `PLANNED` aceptan o pierden tickets; publicar con tickets sin terminar exige `force: true`. - **Releases**: solo las `PLANNED` aceptan o pierden tickets; publicar con tickets sin terminar exige `force: true`.
- **Comentarios**: solo el autor edita; borra el autor o un admin del proyecto. Las notas de versión (`notes`, Markdown) se escriben también después de publicar (no una vez archivada);
`notesDraft` / `GET /api/releases/{id}/notes/draft` propone un borrador con los tickets hechos agrupados por tipo
(los de QA quedan fuera).
- **Comentarios**: solo el autor edita; borra el autor o un admin del proyecto. Se puede responder a un comentario
(`replyTo`), con un solo nivel de respuestas: responder a una respuesta la suma al mismo hilo, y la respuesta tiene
que ser del mismo ticket. Quien escribió el comentario recibe un aviso (`REPLIED`). Un comentario borrado que tiene
respuestas se queda, sin texto (`deleted: true`), para que el hilo se siga entendiendo, y desaparece con su última
respuesta; no cuenta en `commentCount`.
- **Formato**: descripciones, comentarios y notas de versión se guardan en Markdown; la API los guarda tal cual y la web
los muestra limpiando el HTML (DOMPurify). En la web se escriben con un editor visual que lee y escribe ese mismo
Markdown, así que un cliente de la API (o el MCP) sigue mandando Markdown. Una imagen pegada se sube como adjunto y queda en el texto como
`![nombre](attachment:<id>)`: la subida responde `attachmentId`, y la web la descarga con la sesión de quien lee, así
que sirve también en proyectos privados.
- **Reacciones**: 👍 👎 😄 🎉 😕 ❤️ 🚀 👀 en los comentarios (`reactToComment(commentId, emoji, on)`,
`POST /api/comments/{id}/reactions`); los miembros, también los lectores. Cada comentario da `reactions` con quién
eligió cada una. No generan actividad ni avisos.
- **Encuestas**: un comentario puede llevar una encuesta de 2 a 6 opciones (`addComment(…, poll)`, `poll` en
`POST …/comments`). Los miembros, también los lectores, votan una opción y pueden cambiarla o retirarla
(`votePoll(commentId, option)`, `POST /api/comments/{id}/poll/vote`; `CommentPollVotedEvent`). Quien la abrió, o un
admin, la cierra (`closePoll(commentId, decide)`, `POST …/poll/close`; `CommentPollClosedEvent`): gana la más votada
(con empate, ninguna) y, con `decide`, se añade una respuesta «Decidido: …» marcada como decisión. El comentario da
`poll { options { text voters } closed winner }`.
- **Convertir en ticket**: un comentario que resulta ser trabajo propio se convierte en ticket
(`turnCommentIntoIssue(commentId, title, type, asSubtask)`, `POST /api/comments/{id}/issue`), como subtarea del ticket
del comentario o enlazado a él (`RELATES_TO`), por quien puede cambiar tickets. El texto del comentario es la
descripción (sin la plantilla del tipo), con una línea de dónde sale; el comentario lo recuerda
(`CommentTurnedIntoIssueEvent`) y da `spawned { id key }`. Devuelve el ticket nuevo.
- **Conversaciones**: un comentario raíz y sus respuestas se resuelven (`resolveThread(commentId, resolved, conclusion)`,
`PUT/DELETE /api/comments/{id}/resolution`; evento `CommentThreadResolvedEvent`) por quien puede cambiar el ticket;
una respuesta no se resuelve sola. Si trae `conclusion`, antes de cerrarla se añade como respuesta marcada como
decisión. El comentario da `resolved`, `resolvedBy` y `resolvedAt`. **Escribiendo**: `typing(issueId, threadId)`
(mutation, cada pocos segundos mientras se escribe) avisa al momento por la subscription `typing(issueId)` a quien
tenga el ticket abierto; no se guarda (canal de Redis `tracker.typing.<ticket>`), y cada cliente descarta sus propios
avisos.
- **Decisiones**: quien puede cambiar el ticket marca un comentario como decisión (`markDecision(commentId, on)`,
`PUT/DELETE /api/comments/{id}/decision`; evento `CommentDecisionMarkedEvent`). El comentario da `decision`,
`decidedBy` y `decidedAt`; el ticket, `decisions` (más antiguas primero, `GET /api/issues/{issue}/decisions`), que la
web muestra encima de la descripción. Se buscan por su texto: `decisions(projectId?, search, limit)`,
`GET /api/projects/{project}/decisions` y, de todos mis proyectos, `GET /api/me/decisions` (más recientes primero; sin
las de la papelera y, para un invitado, solo las de sus tickets).
- **Bloqueos**: un ticket espera a otro si ese otro lo bloquea (`BLOCKS`) o si él depende de ese otro (`DEPENDS_ON`),
mientras el otro no esté hecho ni cancelado. `blockedBy` lo calcula al leer (los enlaces no guardan el estado del
otro ticket), con una sola consulta por tablero o página.
- **Duplicar**: `duplicateIssue` / `POST /api/issues/{issue}/duplicate` copia lo que dice el ticket (tipo, título con
«(copia)», descripción, área, prioridad, etiquetas, Figma, estimación, fechas, épica y pasos de la checklist sin
marcar), no dónde está: la copia empieza en el primer estado, sin asignar, sin sprint, release, comentarios ni
adjuntos.
- **Cambios de UI**: `figmaUrl` debe ser un enlace `https://…figma.com/…`. - **Cambios de UI**: `figmaUrl` debe ser un enlace `https://…figma.com/…`.
- **Adjuntos**: los suben los miembros (no los lectores), hasta `ATTACHMENTS_MAX_MB` (25 MB) por archivo y 50 por
ticket; pueden ir en el ticket o bajo uno de sus comentarios (`commentId`). Quita uno quien lo subió o un admin.
Los bytes se guardan primero y el evento después, así que nunca queda un adjunto apuntando a un archivo que no
existe; si el evento no se guarda, el archivo se borra. Al borrar un ticket se borran sus archivos. Las imágenes,
los PDF, el texto y el vídeo o audio que reproduce el navegador (mp4, webm, mp3, ogg, wav) se sirven para verse en
la página; el resto, como descarga.
- **Épicas y subtareas**: un ticket puede colgar de otro del mismo proyecto (`parent`), hasta tres niveles
(épica → ticket → subtarea) y sin bucles; se comprueba sobre el event store, no sobre el read model. Una «épica» es
cualquier ticket de primer nivel del que cuelgan otros (`epics(projectId)` da su progreso: terminados = hechos o
cancelados, y puntos). Si se borra el padre, sus hijos vuelven al primer nivel.
- **Orden del backlog**: cada ticket tiene un `rank` (texto que se compara carácter a carácter). Mover uno
(`rankIssue`, `PUT /api/issues/{issue}/rank`) solo reescribe ese ticket: recibe un rango entre el de quien queda
encima y el de quien queda debajo, leídos del event store; si entretanto el orden cambió, la API lo rechaza y la web
recarga. Un ticket que nunca se movió ocupa su orden de creación (`IssueRankedEvent` no existe para él). Las
columnas del tablero y `issues(filter: { sort: RANK })` siguen este orden.
- **Alertas → bugs**: un owner o admin activa en Integraciones una URL secreta por proyecto,
`POST /api/webhooks/alerts/{proyecto}/{token}` (el token es el secreto: Sentry o Uptime Kuma solo dejan pegar una
URL). Entiende Sentry (integración interna, cabecera `sentry-hook-resource`, o el plugin de webhooks), Uptime Kuma,
Prometheus Alertmanager y Grafana; cualquier otra herramienta manda JSON con `title` y, si quiere, `fingerprint`,
`description`, `url` y `resolved`. Cada aviso tiene una huella (el id del problema en Sentry, el monitor en Uptime
Kuma, el `groupKey` en Alertmanager o `fingerprint`): la primera vez TaskStream abre un bug (actor `system`, etiqueta
`alerta`, el tipo y la prioridad elegidos; sin tipo, el primero con icono de bug); mientras ese bug siga abierto, las
repeticiones van a él (un comentario como mucho cada 30 minutos, con cuántas van) y «resuelta» se dice en él; si ya
está cerrado, abre otro que nombra el anterior. Responde `{ action: created | repeated | resolved | ignored, issueKey
}`; proyecto desconocido, alertas apagadas o token malo responden igual: 403. Las alertas de un proyecto se atienden
de una en una, así que dos iguales a la vez abren un solo bug. Configuración: `alertIntegration`,
`saveAlertIntegration(input: { typeId, priority })`, `rotateAlertToken`, `disableAlertIntegration`;
`GET/PUT/DELETE /api/projects/{project}/alert-integration` y `POST …/token`. Vive en MongoDB (`alert_integrations`,
y `alert_traces` con qué bug abrió cada huella), no en el event store.
- **Integración con Git**: un owner o admin añade en la configuración del proyecto sus repositorios (`api`, `web`,
`admin`…), cada uno con su propio secreto. Todos llaman a la misma URL, `POST /api/webhooks/git/{proyecto}`, con los
eventos _push_ y _pull/merge request_; GitHub y Gitea firman el cuerpo con el secreto (HMAC-SHA256) y GitLab lo envía
como `X-Gitlab-Token`, y el secreto que valida la firma dice de qué repositorio viene. Cada commit, rama o PR que
nombre una clave del proyecto (`WEB-12`, también en minúsculas) queda en `devLinks` del ticket con el nombre de su
repositorio (`IssueDevLinkRecordedEvent`, actor `system`); el mismo enlace solo vuelve a contar si cambia (una PR
fusionada). Proyecto desconocido, sin repositorios o firma mala responden igual: 403. Cambiar el secreto de un
repositorio o quitarlo no afecta a los demás, y lo ya enlazado se queda. Los repositorios viven en MongoDB
(`git_integrations`, un documento por proyecto), no en el event store; un proyecto que activó el webhook único de
antes conserva su secreto como repositorio «principal».
- **Escaparate público**: `GET /api/explore/projects/{project}/showcase` resume un proyecto público para webs de
fuera (la del colectivo lo enseña en la página de cada proyecto): épicas con fechas y progreso, las últimas
versiones (con sus notas si ya salieron), el sprint en curso y los tickets abiertos con la etiqueta
`good-first-issue`. Sin token, con `Cache-Control: public, max-age=300`; un proyecto privado responde 404.
- **Papelera**: `trashIssue` / `DELETE /api/issues/{issue}` manda un ticket a la papelera (quien lo abrió o un owner/
admin; `IssueTrashedEvent`): desaparece del tablero, las búsquedas, las estadísticas y el progreso, y no se puede
cambiar hasta recuperarlo (`restoreIssue` / `POST …/restore`, `IssueRestoredEvent`). `trash(projectId)` /
`GET /api/projects/{project}/trash` los lista. A los `TRASH_DAYS` días (30) se borra del todo, con la cascada de
siempre: comentarios, enlaces y subtareas de vuelta al primer nivel.
- **Edición en bloque**: `bulkUpdateIssues` / `POST /api/issues/bulk` aplica el mismo cambio (estado, persona o
`unassign`, sprint o `noSprint`, release o `noRelease`, prioridad, etiquetas que añadir) a hasta 100 tickets. Cada
uno pasa por los comandos normales (permisos, flujo, sprints y releases abiertos) y puede negarse sin parar al resto;
la respuesta dice cuáles. La web parte selecciones mayores (la Lista enseña hasta 200) en tandas de 100, una tras
otra, y suma el resultado.
- **Notas públicas de una release**: quien gestiona el proyecto abre un enlace secreto con las notas de una release,
para clientes y sin cuenta (también en proyectos privados): `shareReleaseNotes(releaseId, renew)`,
`unshareReleaseNotes`, `releaseNotesShare`; `GET/PUT/DELETE /api/releases/{id}/public-notes`. La página
(`publicReleaseNotes(token)`, `GET /api/shared/release-notes/{token}`, la web en `/notes/{token}`) enseña las notas
escritas o, mientras no las haya, el borrador de sus tickets terminados (`written: false`). Renovar hace un enlace
nuevo; retirarlo, o borrar la release, lo deja sin efecto. Vive en MongoDB (`release_note_shares`).
- **Archivar una release**: `archiveRelease(releaseId, moveTo)` / `POST /api/releases/{id}/archive` (`{ moveTo }`).
Sus tickets sin terminar pasan a `moveTo` (otra release abierta del mismo proyecto) o, sin él, se quedan sin release;
los terminados se quedan, como registro de lo que salió. Igual que al cerrar un sprint con `carryOverTo`.
- **Plantillas por tipo**: en la configuración, cada tipo de ticket puede llevar `template` con una descripción
(hasta 5000 caracteres) y una checklist (hasta 20 pasos). Al crear un ticket de ese tipo, la descripción se usa si
llega vacía y los pasos se añaden siempre (las importaciones no aplican plantillas).
- **Automatizaciones**: owners y admins crean reglas (hasta 50 por proyecto, por GraphQL: `automations`,
`createAutomation`, `updateAutomation`, `deleteAutomation`). Disparador: ticket creado, cambio de estado (opcionalmente
a uno concreto), asignación, comentario o PR fusionada (integración Git). Condiciones: tipo, prioridad, área o
etiqueta. Acción: mover a un estado, asignar (a alguien o a quien lo abrió), añadir una etiqueta, poner una
prioridad, comentar o meter en el sprint en curso. Actúan como `system` («TaskStream») y respetando el flujo: si la
transición no está permitida, la regla no hace nada y lo deja en `lastError`. Lo que hace una regla no dispara
otras (no hay cadenas ni bucles) y cada regla corre una sola vez por evento aunque se reentregue
(`automation_runs`). Viven en MongoDB (`automations`), fuera del event store.
- **Webhooks salientes**: owners y admins dan de alta URLs (hasta 20 por proyecto) suscritas a eventos
(`issue.created`, `issue.status_changed`, `comment.added`, `release.shipped`, `sprint.closed`… — la lista en
`webhookEvents` / `GET /api/webhook-events`), a familias (`issue.*`) o a todo (`*`). Cada envío es un `POST` JSON con
`event`, `deliveryId`, `occurredAt`, `actorId`, `project`, `issue` (si va de un ticket) y `data` (los campos del evento),
y las cabeceras `X-TaskStream-Event`, `X-TaskStream-Delivery` y `X-TaskStream-Signature: sha256=<HMAC del cuerpo con el
secreto>`. El secreto solo se muestra al crearlo. Se reintenta a los 2 y 10 s (no ante un 4xx), con 5 s de espera
máxima; se guarda el último envío. Salen en segundo plano y no son duraderos: si la API se reinicia a mitad, ese
envío se pierde. No llaman a direcciones privadas salvo con `WEBHOOKS_ALLOW_PRIVATE=true`.
- **Importar y exportar**: `GET /api/projects/{project}/export.csv` da todos los tickets (UTF-8 con BOM; las celdas
que una hoja de cálculo ejecutaría como fórmula empiezan por `'`). `POST /api/projects/{project}/import` (owners y
admins, multipart `file`, hasta 5 MB y 2000 filas) lee ese mismo CSV o una exportación CSV de Jira: tipos, estados y
personas se buscan por id o por nombre (los de Jira por su significado: _Story_ → Feature, _Highest_ → crítica,
categoría _In Progress_ → primer estado en curso…), lo que no encaja se queda con un valor por defecto y un aviso, y
los padres se reconstruyen al final. Con `dryRun=true` solo se cuenta lo que se crearía. Cada ticket importado es una
creación normal (con sus eventos), en el estado que traía si está activo.
- **Límite WIP**: cada estado puede tener `wipLimit` (1-999) en la configuración del proyecto. Es un aviso, no un
bloqueo: la API acepta pasarse y el tablero marca la columna.
- **Sprints**: los planifica y gestiona quien administra el proyecto (owner o admin); mete tickets cualquier miembro.
Un sprint va de `PLANNED` a `ACTIVE` (uno solo en curso por proyecto) y a `CLOSED`; dura como mucho 90 días y un
sprint en curso conserva su fecha de inicio. Un sprint cerrado ya no acepta ni pierde tickets. Al cerrarlo se elige
adónde van los tickets sin terminar: a otro sprint abierto del proyecto o al backlog; los terminados se quedan como
registro. Solo se borran sprints que no han empezado (sus tickets vuelven al backlog).
- **Vistas guardadas**: filtros del tablero con nombre. Las guardan los miembros del proyecto (los lectores, solo para
sí); compartir una la hace visible a todo el proyecto. Borra una su autor, o un admin si es compartida; como mucho
50 por persona y proyecto. Son preferencias, no historia: viven en MongoDB (`saved_filters`), fuera del event store,
y se borran con el proyecto.
- **Checklist**: hasta 100 pasos de hasta 300 caracteres por ticket; los cambia quien puede editar el ticket.
### Calendario (iCalendar)
Cada persona puede suscribir sus fechas en Google Calendar, Outlook o Apple Calendar con una URL secreta:
`GET /api/calendar/{token}.ics` (sin sesión: las aplicaciones de calendario no pueden iniciarla). Trae, como eventos de
día completo:
- sus tickets sin terminar con fecha límite, asignados o que sigue;
- los sprints planificados o en curso de sus proyectos;
- las releases planificadas con fecha objetivo.
Cada evento enlaza a la web (`WEB_URL`). El token (32 bytes aleatorios) se crea, renueva o retira con `GET`, `POST` y
`DELETE /api/me/calendar` (GraphQL: `myCalendarFeed`, `createCalendarFeed`, `revokeCalendarFeed`); renovarlo deja sin
servicio la URL anterior. Se guarda tal cual en `calendar_feeds` para poder volver a copiar la URL: solo da lectura de
fechas y títulos que ya están en el read model.
### Panel para clientes
Cada proyecto puede enseñar su estado a sus clientes con un enlace de solo lectura, sin cuenta: `/share/{token}` en la
web, que lee `GET /api/shared/dashboards/{token}` (GraphQL: `sharedDashboard(token)`), ambos sin sesión. Solo trae los
bloques elegidos, y los demás llegan a `null`:
| Bloque | Qué da |
| ---------- | -------------------------------------------------------------------------- |
| `flow` | Tickets en cada estado activo, con su nombre y color |
| `sprint` | El sprint en curso, como en el panel (¿llega?) |
| `releases` | Las releases planificadas con su previsión al ritmo real |
| `epics` | Las épicas abiertas (8, por fecha): título, fecha, terminados y porcentaje |
| `activity` | Movimientos por día de las últimas 12 semanas |
No enseña personas ni tickets sueltos (salvo las épicas). Propietarios y admins lo activan, cambian sus bloques o lo
renuevan con `GET`, `PUT` y `DELETE /api/projects/{project}/share` (`{ blocks, renew }`; GraphQL: `dashboardShare`,
`shareDashboard`, `unshareDashboard`). Renovarlo deja sin servicio el enlace anterior. El token (32 bytes aleatorios)
se guarda tal cual en `dashboard_shares` para poder volver a copiar el enlace, y se borra con el proyecto.
### Notificaciones
Se derivan de los eventos (`NotificationsEventHandler`) y se guardan **por persona** en MongoDB: no son estado de
ningún agregado, solo cada uno lee y descarta las suyas.
| Cuándo | A quién | Tipo |
| ---------------------------------------- | ---------------------------------------- | ------------------ |
| Se crea un ticket asignado o se asigna | La persona asignada | `ASSIGNED` |
| Un ticket cambia de estado | Quien lo tiene asignado y quien lo abrió | `STATUS_CHANGED` |
| Un ticket vuelve de QA sin pasar a hecho | Los mismos, con el motivo | `QA_FAILED` |
| Alguien comenta | Quien lo tiene asignado y quien lo abrió | `COMMENTED` |
| Alguien responde a un comentario | Quien escribió el comentario | `REPLIED` |
| Te añaden a un proyecto | La persona añadida | `ADDED_TO_PROJECT` |
| Llega una propuesta del público | Propietarios y admins del proyecto | `PROPOSED` |
**Push** (Web Push): además de en la campana y, si se pidió, por correo, cada aviso llega a los dispositivos donde la
persona activó «Avisos en este dispositivo», con TaskStream cerrado. El navegador se suscribe con la clave pública
VAPID (`notificationSettings.pushPublicKey`, null si no hay claves) y la API guarda la suscripción
(`subscribePush` / `POST /api/me/push-subscriptions`; se retira con `unsubscribePush` / `DELETE`). El mensaje va
cifrado (`aes128gcm`) y firmado con VAPID, con `{ title, body, url, tag }`; si el servicio push responde 404 o 410, la
suscripción se olvida. Como el correo, un fallo de envío no frena los eventos.
**Recordatorios** (`REMINDER`): cada hora la API mira los tickets sin terminar con fecha límite y avisa a quien lo
tiene asignado y a quien lo sigue el día antes, el mismo día y una vez cuando ya ha vencido (el día cambia en
`REMINDERS_TIMEZONE`). Cada aviso tiene id `reminder:<ticket>:<fecha>:<fase>:<persona>`, así que no se repite aunque
varias instancias lo comprueben a la vez.
Nadie recibe aviso de lo que hizo él mismo, y cada persona recibe uno por evento: la primera razón de la tabla gana
(una mención antes que «comentó en tu ticket», y eso antes que «un ticket que sigues»).
**Menciones**: se escriben `@[Nombre](id-de-usuario)` (la web las inserta al escribir `@`); el id dice a quién avisar y
el nombre es lo que se ve, así que renombrar a alguien no rompe comentarios antiguos. Solo avisan a miembros del
proyecto. **Seguir**: cualquier miembro, también los lectores, puede seguir un ticket (`watchIssue`/`unwatchIssue`,
eventos `IssueWatchedEvent`/`IssueUnwatchedEvent`), y quien lo abre, quien lo tiene asignado y quien comenta pasan a seguirlo (`PeopleFollowSaga`,
`CommentersFollowSaga`). Quien
sigue un ticket (`WATCHED`) sabe cuándo cambia de estado, se asigna, se comenta, cambia su prioridad o su fecha límite,
o entra o sale de un sprint; las ediciones menores (título, etiquetas…) no avisan. «Los que sigo»: `watching` en
`myIssues` / `GET /api/me/issues` y en la búsqueda de un proyecto (`issues(filter: { watching: true })`,
`?watching=true`; sin sesión, ninguno). **Revisiones**: quien puede cambiar un ticket pide a un miembro que lo revise, con nota opcional
(`requestReview(issueId, reviewerId, note)`, `POST /api/issues/{issue}/reviews`; evento `IssueReviewRequestedEvent`).
Esa persona, aunque sea lectora, responde «todo bien» o «cambios» con su respuesta (`answerReview`, `POST
…/reviews/{id}/answer`, `IssueReviewAnsweredEvent`); quien la pidió (o un admin) puede retirarla (`cancelReview`, `DELETE
…/reviews/{id}`, `IssueReviewCancelledEvent`). No a uno mismo ni dos veces a la misma persona con una pendiente. Avisos
`REVIEW_REQUESTED` y `REVIEW_ANSWERED`; `Issue.reviews`; «Te piden revisión» es `myIssues(reviewing: true)` /
`GET /api/me/issues?reviewing=true`. **Mi día**: los tickets que cada persona elige para hoy (`myDay(day)`, `addToMyDay(day, issueIds)`,
`removeFromMyDay(day, issueId)`; `GET /api/me/day?day=`, `PUT/DELETE /api/me/day/{issue}?day=`), hasta 10. `day` es
hoy en la zona horaria de quien pregunta (`YYYY-MM-DD`, a dos días como mucho de la del servidor). La respuesta trae
también lo que quedó sin terminar el último día anterior (si fue en la última semana), para pasarlo a hoy. Privado y sin
historial (`my_days`). **Recordármelo**: cada persona pone en un ticket un recordatorio con fecha y hora y nota opcional
(`setReminder(issueId, at, note)`, `clearReminder`, `Issue.myReminder`, `myReminders`; `PUT/DELETE
/api/issues/{issue}/reminder`, `GET /api/me/reminders`): uno por ticket, en el futuro y como mucho a un año. Cada minuto
`IssueReminderScheduler` avisa de los que tocan (`REMINDER`, por la campana, el navegador y el correo; una vez, por su
id) y olvida los de tickets en la papelera o fuera de alcance. Privados y sin historial (`issue_reminders`);
`REMINDERS_ENABLED=false` los apaga junto con los de fecha límite. **Lo nuevo desde tu última visita**: al abrir un
ticket, `visitIssue(issueId)` (`PUT /api/issues/{issue}/visit`) apunta la visita y devuelve la anterior (null la
primera vez); con ella el cliente marca lo que otros han hecho desde entonces. Personal y sin historial (colección
`issue_visits`). **Seguir todo**: seguir un ticket con todo lo que cuelga de él (una épica, un ticket con subtareas):
`followTree`/`unfollowTree`, `Issue.followingTree`; `PUT/DELETE /api/issues/{issue}/follow-tree`. Quien lo hace oye lo
mismo que un seguidor de cada ticket de debajo, a cualquier profundidad, también de los que se añadan, y además cuándo
uno entra (`IssueParentChangedEvent`). Personal y sin historial (colección `tree_follows`); los invitados de un proyecto
privado no oyen los tickets que no son suyos. **Fijar**: cada persona fija hasta 20 tickets para tenerlos a mano en el
inicio y en la búsqueda (`pinIssue`/`unpinIssue`, `myPins`, `Issue.pinned`; `GET /api/me/pins`,
`PUT/DELETE /api/issues/{issue}/pin`). Son privados y sin historial (colección `pinned_issues`, como las vistas de «Mi
trabajo»); los que están en la papelera o fuera de su alcance no se listan, y al llegar a 20 se olvidan primero los
que ya no puede leer. El id es
`<agregado>:<versión>:<persona>`, así que reentregar un evento no duplica avisos. Llegan en vivo por la subscription
`notificationAdded` y, si la instalación tiene `SMTP_URL`, también por correo a quien lo active (`email`, desactivado
por defecto); un correo que falla se registra y no bloquea el stream.
### Eventos de dominio ### Eventos de dominio
@@ -151,11 +481,12 @@ Todo cambio es un evento guardado en el event store (stream = id del agregado).
`aggregateId`, `projectId` y `aggregateVersion` (la posición en el stream). `aggregateId`, `projectId` y `aggregateVersion` (la posición en el stream).
| Agregado | Eventos | | Agregado | Eventos |
| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Project | `ProjectCreatedEvent`, `ProjectUpdatedEvent`, `ProjectSettingsChangedEvent`, `ProjectMemberAddedEvent`, `ProjectMemberRoleChangedEvent`, `ProjectMemberRemovedEvent`, `ProjectDeletedEvent` | | Project | `ProjectCreatedEvent`, `ProjectUpdatedEvent`, `ProjectSettingsChangedEvent`, `ProjectMemberAddedEvent`, `ProjectMemberRoleChangedEvent`, `ProjectMemberRemovedEvent`, `ProjectDeletedEvent` |
| Issue | `IssueCreatedEvent`, `IssueUpdatedEvent`, `IssueStatusChangedEvent`, `IssueAssignedEvent`, `IssueLinkedEvent`, `IssueUnlinkedEvent`, `IssueReleaseChangedEvent`, `IssueDeletedEvent` | | Issue | `IssueCreatedEvent`, `IssueUpdatedEvent`, `IssueStatusChangedEvent`, `IssueAssignedEvent`, `IssueLinkedEvent`, `IssueUnlinkedEvent`, `IssueReleaseChangedEvent`, `IssueAttachmentAddedEvent`, `IssueAttachmentRemovedEvent`, `IssueWatchedEvent`, `IssueUnwatchedEvent`, `IssueParentChangedEvent`, `IssueRankedEvent`, `IssueSprintChangedEvent`, `IssueDevLinkRecordedEvent`, `IssueChecklistItemAddedEvent`, `IssueChecklistItemUpdatedEvent`, `IssueChecklistItemRemovedEvent`, `IssueDeletedEvent` |
| Comment | `CommentAddedEvent`, `CommentEditedEvent`, `CommentDeletedEvent` | | Comment | `CommentAddedEvent`, `CommentEditedEvent`, `CommentDeletedEvent` |
| Release | `ReleaseCreatedEvent`, `ReleaseUpdatedEvent`, `ReleaseShippedEvent`, `ReleaseArchivedEvent`, `ReleaseDeletedEvent` | | Sprint | `SprintCreatedEvent`, `SprintUpdatedEvent`, `SprintStartedEvent`, `SprintClosedEvent`, `SprintDeletedEvent` |
| Release | `ReleaseCreatedEvent`, `ReleaseUpdatedEvent`, `ReleaseShippedEvent`, `ReleaseNotesWrittenEvent`, `ReleaseArchivedEvent`, `ReleaseDeletedEvent` |
`IssueStatusChangedEvent` lleva además `meta` con el nombre y la categoría de los dos estados en el momento del `IssueStatusChangedEvent` lleva además `meta` con el nombre y la categoría de los dos estados en el momento del
cambio, para que el historial y las estadísticas no cambien si luego se renombra o recategoriza un estado. cambio, para que el historial y las estadísticas no cambien si luego se renombra o recategoriza un estado.
@@ -174,11 +505,50 @@ puras (`application/src/lib/analytics`):
| Tiempo de entrega | De la creación hasta `DONE`; media y percentil 85 | | Tiempo de entrega | De la creación hasta `DONE`; media y percentil 85 |
| Throughput | Tickets resueltos por semana y por la persona que los tenía asignados | | Throughput | Tickets resueltos por semana y por la persona que los tenía asignados |
| QA | Salidas de un estado `QA`: a `DONE` cuenta como superado; a cualquier otro sitio salvo `CANCELLED`, como fallido | | QA | Salidas de un estado `QA`: a `DONE` cuenta como superado; a cualquier otro sitio salvo `CANCELLED`, como fallido |
| Burndown de un sprint | Por día del sprint: tickets en él (sin cancelados), cuántos siguen abiertos y sus puntos; línea ideal hasta cero |
| Burnup de una release | Por día, desde que se creó hasta que se publica (o hoy, máx. 180 días): tickets en ella (sin cancelados) y hechos | | Burnup de una release | Por día, desde que se creó hasta que se publica (o hoy, máx. 180 días): tickets en ella (sin cancelados) y hechos |
Los tickets anteriores a esta proyección solo tienen su creación y su estado actual; para tener su historia completa, 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 |
| Actividad | Movimientos por día (todo lo de la actividad) de las últimas 12 semanas, desde un lunes: 84 días, en `heatmap` |
| Recorrido | De los tickets terminados en 90 días: días de media de creado a hecho, días y parte del total en cada estado por el que pasaron (también la espera en Backlog o Por hacer) y `flowEfficiency`, la parte en un estado «en curso» |
| Propuestas | Si el público ha propuesto algo (tickets con la etiqueta «propuesta»): recibidas (y en 30 días), esperando (sin empezar ni planificar), en marcha, hechas y descartadas, y las 5 más votadas que esperan; si no, `null` |
## 3.2 GraphQL ## 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
@@ -188,39 +558,51 @@ que funcionan también sin token.
### Queries ### Queries
| Query | Devuelve | | Query | Devuelve |
| ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `me` | El usuario autenticado, con sus roles en Pireon | | `me` | El usuario autenticado, con sus roles en Pireon |
| `users(search, limit)` | Usuarios de Pireon (para asignar o añadir miembros) | | `users(search, limit)` | Usuarios de Pireon (para asignar o añadir miembros) |
| `user(id)` | Un usuario | | `user(id)` | Un usuario |
| `myProjects` | Proyectos de los que soy miembro | | `myProjects` | Proyectos de los que soy miembro |
| `myIssues(assignedToMe, statuses, categories, limit, offset)` | «Mi trabajo»: tickets de todos mis proyectos, asignados a mí (por defecto) o de cualquiera (p. ej. `categories: [QA]`) | | `myIssues(assignedToMe, statuses, categories, search, limit, offset)` | «Mi trabajo»: tickets de todos mis proyectos, asignados a mí (por defecto) o de cualquiera (p. ej. `categories: [QA]`) |
| `upcomingReleases(limit)` | Releases planificadas de mis proyectos, por fecha objetivo | | `upcomingReleases(limit)` | Releases planificadas de mis proyectos, por fecha objetivo |
| `publicProjects` | Proyectos públicos (sin token) | | `publicProjects` | Proyectos públicos (sin token) |
| `project(idOrKey)` | Un proyecto (mío, o público) con su `settings`. Campos calculados: `board(releaseId, assigneeId, area, perColumn)`, `stats`, `analytics(days)`, `releases` | | `project(idOrKey)` | Un proyecto (mío, o público) con su `settings`. Campos calculados: `board(releaseId, assigneeId, unassigned, area, types, priorities, labels, search, perColumn)`, `stats`, `analytics(days)`, `releases` |
| `issues(filter, limit, offset)` | Búsqueda de tickets (estado, tipo, área, prioridad, persona, release, etiquetas, texto) | | `issues(filter, limit, offset)` | Búsqueda de tickets (estado, tipo, área, prioridad, persona, release, etiquetas, ticket padre, fecha límite entre `dueFrom` y `dueTo`, texto); `sort: RANK` para el orden del backlog |
| `webhooks(projectId)`, `webhookEvents` | Webhooks salientes del proyecto (owners y admins) y los eventos a los que se pueden suscribir |
| `gitIntegration(projectId)` | Si el webhook de Git está activo, su ruta y (para owners y admins) el secreto |
| `sprints(projectId)`, `sprint(id)` | Sprints del proyecto (en curso, planificados, cerrados). Campos calculados: `progress`, `burndown`, `issues` |
| `savedFilters(projectId)` | Mis vistas guardadas del tablero y las compartidas con el proyecto |
| `epics(projectId)` | Épicas del proyecto (tickets de primer nivel de los que cuelgan otros) con el progreso de sus hijos |
| `issue(idOrKey)` | Un ticket. Campos calculados: `allowedTransitions`, `linkedIssues`, `comments`, `history` | | `issue(idOrKey)` | Un ticket. Campos calculados: `allowedTransitions`, `linkedIssues`, `comments`, `history` |
| `release(id)` | Una release. Campos calculados: `progress`, `burnup`, `issues` | | `release(id)` | Una release. Campos calculados: `progress`, `burnup`, `issues` |
| `activity(projectId, issueId?, limit, before?)` | Feed de actividad, del más reciente al más antiguo | | `activity(projectId, issueId?, limit, before?, after?)` | Feed de actividad, del más reciente al más antiguo; con `after` y `before`, una ventana (un día, por ejemplo). Por REST, los mismos parámetros en `…/activity` |
### Mutations ### Mutations
Todas devuelven `CommandResult { id, key, version }`. Todas devuelven `CommandResult { id, key, version }`.
| Ámbito | Mutations | | Ámbito | Mutations |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` | | Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease` | | Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `requestReview`, `answerReview`, `cancelReview`, `pinIssue`, `unpinIssue`, `followTree`, `unfollowTree`, `visitIssue`, `clearReminder`, `addToMyDay`, `removeFromMyDay` (devuelven `true`), `setReminder`, `removeAttachment` (subir: por REST), `duplicateIssue`, `voteIssue` |
| Comentarios | `addComment`, `editComment`, `deleteComment` | | Comentarios | `addComment(replyTo, poll)`, `votePoll`, `closePoll`, `editComment`, `deleteComment`, `reactToComment`, `markDecision(commentId, on)`, `resolveThread(commentId, resolved, conclusion)`, `turnCommentIntoIssue(commentId, title, type, asSubtask)`, `typing(issueId, threadId)`; consulta `decisions(projectId, search)` |
| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `archiveRelease` | | Webhooks | `createWebhook` (devuelve el secreto), `updateWebhook`, `deleteWebhook`, `testWebhook` |
| Git | `addGitRepository` (devuelve su secreto), `rotateGitRepositorySecret`, `removeGitRepository` |
| Sprints | `createSprint`, `updateSprint`, `startSprint`, `closeSprint(carryOverToSprintId)`, `deleteSprint`, `setIssueSprint` |
| Vistas | `saveFilter(input)` (devuelve el id), `deleteSavedFilter(id)`, `GET/POST /api/me/views`, `DELETE /api/me/views/{id}` (mis vistas de «Mi trabajo») |
| Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` |
| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `writeReleaseNotes`, `archiveRelease` |
### Subscriptions ### Subscriptions
| Subscription | Se emite cuando | | Subscription | Se emite cuando |
| --------------------------- | ---------------------------------------------------------------------------------------------------------- | | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `issueChanged(projectId)` | Se crea o cambia cualquier ticket del proyecto (incluye el ticket completo y `action` = nombre del evento) | | `issueChanged(projectId)` | Se crea o cambia cualquier ticket del proyecto (incluye el ticket completo y `action` = nombre del evento) |
| `commentChanged(issueId)` | Se añade, edita o borra un comentario (`comment` es `null` al borrar) | | `commentChanged(issueId)` | Se añade, edita o borra un comentario (`comment` es `null` al borrar, o el que queda sin texto si tenía respuestas) |
| `typing(issueId)` | Alguien escribe en el ticket (`threadId`: la conversación que responde; `null`, un comentario nuevo). Llegan también los propios |
| `activityAdded(projectId)` | Hay una nueva entrada en el feed | | `activityAdded(projectId)` | Hay una nueva entrada en el feed |
| `releaseChanged(projectId)` | Cambia una release | | `releaseChanged(projectId)` | Cambia una release |
| `sprintChanged(projectId)` | Se planifica, cambia, empieza, cierra o borra un sprint (`sprint` es `null` al borrar) |
| `projectChanged(projectId)` | Cambian los datos, la configuración o los miembros del proyecto | | `projectChanged(projectId)` | Cambian los datos, la configuración o los miembros del proyecto |
| `myProjectsChanged` | Me añaden a un proyecto o me quitan de uno | | `myProjectsChanged` | Me añaden a un proyecto o me quitan de uno |
@@ -408,15 +790,30 @@ 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`, `…/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`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history` | | Tickets | `GET /api/me/home` (portada), `GET /api/me/portfolio` (cartera), `GET /api/me/issues` (mi trabajo; filtros `project`, `priority`, `bugsOnly`, `overdue`), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `POST …/reviews`, `POST …/reviews/{id}/answer`, `DELETE …/reviews/{id}`, `PUT/DELETE …/follow-tree`, `PUT/DELETE …/reminder`, `GET /api/me/reminders`, `GET /api/me/day`, `PUT/DELETE /api/me/day/{issue}`, `GET /api/me/pins` y `PUT/DELETE …/pin` (fijados), `PUT/DELETE …/presence`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history`, `POST /api/issues/{issue}/duplicate`, `PUT/DELETE …/vote` |
| Comentarios | `PATCH/DELETE /api/comments/{commentId}` | | Comentarios | `PATCH/DELETE /api/comments/{commentId}`, `POST …/reactions`, `PUT/DELETE …/decision`, `PUT/DELETE …/resolution`, `POST …/poll/vote`, `POST …/poll/close`, `POST …/issue` (convertir en ticket); `GET /api/issues/{issue}/decisions`, `GET /api/projects/{project}/decisions`, `GET /api/me/decisions` |
| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` | | Sprints | `GET/POST /api/projects/{project}/sprints`, `GET/PATCH/DELETE /api/sprints/{sprint}`, `POST …/start`, `POST …/close`, `GET …/progress`, `…/burndown`, `…/issues`, `PUT /api/issues/{issue}/sprint` |
| Vistas | `GET/POST /api/projects/{project}/filters`, `DELETE /api/filters/{id}` |
| Avisos | `GET /api/me/notifications`, `POST /api/me/notifications/read`, `GET/PUT /api/me/notification-settings` |
| Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` |
| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `PUT …/notes`, `GET …/notes/draft`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` |
| Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) | | Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) |
| Compartir | `GET/PUT/DELETE /api/projects/{project}/share` (enlace para clientes), `GET /api/shared/dashboards/{token}` (lo que enseña, sin token) |
| Importar | `GET /api/projects/{project}/export.csv`, `POST /api/projects/{project}/import?dryRun=` (multipart `file`) |
| Webhooks | `GET /api/webhook-events`, `GET/POST /api/projects/{project}/webhooks`, `PATCH/DELETE /api/outgoing-webhooks/{id}`, `POST …/test` |
| Git | `GET /api/projects/{project}/git-integration`, `POST …/git-integration/repositories`, `POST …/repositories/{id}/secret`, `DELETE …/repositories/{id}`, `POST /api/webhooks/git/{project}` (lo llama el host de Git) |
| Alertas | `GET/PUT/DELETE /api/projects/{project}/alert-integration`, `POST …/alert-integration/token`, `POST /api/webhooks/alerts/{project}/{token}` |
| Explorar | `GET /api/explore/projects/{project}/showcase` (sin token, proyectos públicos): hoja de ruta con votos, versiones, sprint en curso, tickets `good-first-issue` y propuestas abiertas; `POST …/proposals` (sin token) |
| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` | | Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` |
Los archivos van por REST porque GraphQL no está hecho para bytes; la lista de adjuntos de un ticket llega con el
ticket (`attachments`, en GraphQL y en REST). La descarga sirve en línea solo imágenes, vídeo, audio, PDF y texto
plano, y todo con `X-Content-Type-Options: nosniff` y una CSP `sandbox`: un HTML o un SVG subido se descarga,
nunca se ejecuta en el origen de la API.
Los filtros de tickets aceptan `status` (ids del proyecto) y `category` (`?category=QA`), repetidos o separados por Los filtros de tickets aceptan `status` (ids del proyecto) y `category` (`?category=QA`), repetidos o separados por
comas. Las escrituras responden `{ id, key?, version }` en cuanto el evento se guarda; las lecturas salen del read model, que comas. Las escrituras responden `{ id, key?, version }` en cuanto el evento se guarda; las lecturas salen del read model, que
se pone al día en milisegundos (un `GET` justo después puede devolver la versión anterior, o 404 tras crear). Para se pone al día en milisegundos (un `GET` justo después puede devolver la versión anterior, o 404 tras crear). Para
@@ -425,7 +822,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

@@ -6,9 +6,9 @@ API repartida en `libs/web/*`.
## 4.1 Librerías ## 4.1 Librerías
| Librería | Contenido | | Librería | Contenido |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `web/shared/infrastructure` | Cliente Apollo (`provideGraphql`: HTTP + `graphql-ws`), `API_CONFIG`, puerto `AccessTokenSource` | | `web/shared/infrastructure` | Cliente Apollo (`provideGraphql`: HTTP + `graphql-ws`), `API_CONFIG`, puerto `AccessTokenSource` |
| `web/shared/ui` | Integración de `@pi/ui` (`providePiUi`), modo oscuro, iconos, `ToastService`, `DrawerService`, `AppShellComponent` | | `web/shared/ui` | Integración de `@pi/ui` (`providePiUi`), modo oscuro, iconos, `ToastService` (con `undoable`: aviso con «Deshacer», `UndoToastComponent`), `DrawerService`, `AppShellComponent` |
| `web/identity/domain` | `Session`, `CurrentUser`, `AuthConfig` y puertos `AuthGateway`, `ProfileGateway`, `SessionStorage`, `AuthorizationFlow` | | `web/identity/domain` | `Session`, `CurrentUser`, `AuthConfig` y puertos `AuthGateway`, `ProfileGateway`, `SessionStorage`, `AuthorizationFlow` |
| `web/identity/application` | `AuthFacade` (pública); por dentro `AuthStore` y eventos `authPageEvents`, `authApiEvents`, `sessionEvents` | | `web/identity/application` | `AuthFacade` (pública); por dentro `AuthStore` y eventos `authPageEvents`, `authApiEvents`, `sessionEvents` |
| `web/identity/infrastructure` | `/api/auth/*` (HTTP), `me` (GraphQL), `localStorage`, PKCE con Web Crypto; enlaza `AccessTokenSource` con la `AuthFacade` | | `web/identity/infrastructure` | `/api/auth/*` (HTTP), `me` (GraphQL), `localStorage`, PKCE con Web Crypto; enlaza `AccessTokenSource` con la `AuthFacade` |
@@ -16,7 +16,7 @@ API repartida en `libs/web/*`.
| `web/tracker/domain` | Modelos, enums, workflow y puertos (`ProjectsGateway`, `IssuesGateway`, `TrackerRealtimeGateway`…) | | `web/tracker/domain` | Modelos, enums, workflow y puertos (`ProjectsGateway`, `IssuesGateway`, `TrackerRealtimeGateway`…) |
| `web/tracker/application` | Facades `ProjectsFacade`, `ProjectFacade`, `BoardFacade`, `IssueFacade` y puerto `Notifications`; por dentro, stores y eventos | | `web/tracker/application` | Facades `ProjectsFacade`, `ProjectFacade`, `BoardFacade`, `IssueFacade` y puerto `Notifications`; por dentro, stores y eventos |
| `web/tracker/infrastructure` | Adaptadores GraphQL con operaciones tipadas (codegen) | | `web/tracker/infrastructure` | Adaptadores GraphQL con operaciones tipadas (codegen) |
| `web/tracker/presenters` | Páginas: proyectos, tablero, ticket, releases, seguimiento, actividad y miembros | | `web/tracker/presenters` | Páginas: proyectos, tablero, lista, ticket, propuestas, releases, seguimiento, actividad y miembros; `ts-markdown` (texto con formato, saneado con DOMPurify), `ts-rich-editor`, `ts-bulk-bar`, `ts-vote` |
## 4.2 Estado: NgRx Signals con eventos ## 4.2 Estado: NgRx Signals con eventos
@@ -110,22 +110,72 @@ Qué gana la página:
- **Tests sin plantilla**: la facade se prueba con gateways falsos (`facades.spec.ts`). - **Tests sin plantilla**: la facade se prueba con gateways falsos (`facades.spec.ts`).
| Facade | Store | Ámbito | Responsabilidad | | Facade | Store | Ámbito | Responsabilidad |
| ---------------- | --------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---------------- | --------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `AuthFacade` | `AuthStore` | raíz | Sesión, usuario, login (PKCE o email y contraseña de Pireon), renovación del token, restaurar al arrancar | | `AuthFacade` | `AuthStore` | raíz | Sesión, usuario, login (PKCE o email y contraseña de Pireon), renovación del token, restaurar al arrancar |
| `ProjectsFacade` | `ProjectsStore` | raíz | Mis proyectos; se actualiza cuando me añaden o quitan (`myProjectsChanged`) | | `ProjectsFacade` | `ProjectsStore` | raíz | Mis proyectos; se actualiza cuando me añaden o quitan (`myProjectsChanged`) |
| `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` | ruta `/` (portada) | «Mi trabajo» en la portada: mis tickets abiertos por categoría (QA fallidos primero), esperando QA y próximas releases; se recarga al volver a la pestaña |
| `ExploreFacade` | `ExploreStore` | raíz | Proyectos públicos («Explorar»), también sin sesión | | `MyHomeFacade` | `MyHomeStore` | ruta `/` (portada) | El resto de la portada (`myHome`): agenda, actividad, cifras de la semana, sprints, tarjetas, sugerencias, primeros pasos y quién está; se recarga cada minuto y al volver a la pestaña |
| `MyViewsFacade` | `MyViewsStore` | ruta `/` (portada) | Mis vistas guardadas de «Mi trabajo» (entre proyectos: proyectos, asignados a mí, que sigo, estados, prioridades, solo bugs, vencidos, texto) y los tickets de la elegida |
| `ExploreFacade` | `ExploreStore` | ruta `explore` | Proyectos públicos («Explorar»), también sin sesión |
| `ProjectFacade` | `ProjectStore` | ruta `/p/:key` | Proyecto, miembros, stats, releases, actividad en vivo, búsqueda de usuarios, configuración (`workflow`, `saveSettings`) y estadísticas (`loadAnalytics`, `loadBurnup`) | | `ProjectFacade` | `ProjectStore` | ruta `/p/:key` | Proyecto, miembros, stats, releases, actividad en vivo, búsqueda de usuarios, configuración (`workflow`, `saveSettings`) y estadísticas (`loadAnalytics`, `loadBurnup`) |
| `BoardFacade` | `BoardStore` | ruta `board` | Tablero kanban, filtros, movimiento optimista, nuevo ticket, cambios en vivo | | `BoardFacade` | `BoardStore` | ruta `board` | Tablero kanban, filtros, movimiento optimista (con «Deshacer»), prioridad, estimación y persona cambiadas desde la tarjeta, nuevo ticket, cambios en vivo |
| `ListFacade` | `ListStore` | ruta `list` | Todos los tickets del proyecto en filas: filtros (enviados a la API), cambios de estado, prioridad y persona en la fila, edición en bloque; se recarga tras cada cambio propio y con los de otros |
| `IssueFacade` | `IssueStore` | ruta `issues/:issueKey` | Ticket, comentarios en vivo, historial y todas sus acciones | | `IssueFacade` | `IssueStore` | ruta `issues/:issueKey` | Ticket, comentarios en vivo, historial y todas sus acciones |
Las de ámbito de ruta se registran con `provideProjectFacade()`, `provideBoardFacade()` o `provideIssueFacade()` en los En la raíz solo queda lo que usa la cabecera: la sesión (`AuthFacade`), mis proyectos para la paleta de comandos
`providers` del componente de la página (store + facade) y desaparecen al salir (con sus subscriptions). Las páginas (`ProjectsFacade`) y la campana (`InboxFacade`, `PushFacade`). La paleta y la campana se cargan con `@defer (on idle;
hijas de `/p/:key` reciben la misma `ProjectFacade`. on interaction)`: su código y sus consultas no están en el arranque, y mientras tanto la cabecera muestra una copia
quieta con la misma caja, así que nada se mueve al llegar. Lo demás es de su página:
- Las de una página que se vuelve a visitar a menudo (portada, Explorar, el formulario de propuestas) van en los
`providers` de su ruta (`provideHomeFacade()`, `provideMyHomeFacade()`, `provideMyViewsFacade()`,
`provideCalendarFacade()`, `provideExploreFacade()`, `provideProposeFacade()`, `provideSharedDashboardFacade()`): cargan con la página y el inyector de
la ruta las conserva, así que al volver se ve lo último al instante mientras se recarga.
- `SharingFacade` (el enlace para clientes en Configuración) va en los `providers` de su propio componente.
- Las de un proyecto o un ticket se registran con `provideProjectFacade()`, `provideBoardFacade()` o
`provideIssueFacade()` en los `providers` del componente de la página (store + facade) y desaparecen al salir (con sus
subscriptions). Las páginas hijas de `/p/:key` reciben la misma `ProjectFacade`.
Los adaptadores siguen el mismo reparto: `provideTrackerShellInfrastructure()` (proyectos, tickets, avisos, push y tiempo
real) va en la raíz, y `provideTrackerPagesInfrastructure()` (el resto) en la ruta del tracker, que se carga en diferido
(`apps/web/src/app/tracker.routes.ts`). Las librerías web declaran `"sideEffects": false` (`libs/web/package.json`), para
que el empaquetador deje en cada trozo solo lo que usa y no todo lo que exporta un `index.ts`.
Otros contextos también usan la facade: los stores del tracker leen el usuario de `AuthFacade`, y la infraestructura Otros contextos también usan la facade: los stores del tracker leen el usuario de `AuthFacade`, y la infraestructura
de identidad (`SessionTokenSource`) le pide el token para el cliente GraphQL. de identidad (`SessionTokenSource`) le pide el token para el cliente GraphQL.
### Notificaciones push
En la campana, «Avisos en este dispositivo» (si la API tiene claves VAPID y el navegador admite push) registra
`/push-sw.js`, pide permiso, suscribe el navegador y manda la suscripción a la API (`PushFacade` + el puerto `PushDevice`,
con `BrowserPushDevice` como adaptador). El _service worker_ solo hace push: muestra el aviso y, al pulsarlo, enfoca una
pestaña de TaskStream y la lleva al ticket, o abre una. No guarda nada en caché, así que un despliegue nunca deja una
versión vieja; nginx sirve `push-sw.js` y `manifest.webmanifest` sin caché. Con el manifiesto la web se puede instalar,
que en iPhone es condición para recibir push (iOS 16.4+).
### Calendario
«Mi trabajo» tiene un botón «Calendario» que abre `ts-calendar-feed`: crea la URL secreta, la copia, la renueva o la
desactiva (`CalendarFacade`, sin store). La URL es absoluta, compuesta con `restUrl` y el origen de la página, porque es
lo que se pega en la aplicación de calendario.
### Texto con formato
Descripciones y comentarios se escriben con `ts-rich-editor`, un editor visual sobre
[TipTap](https://tiptap.dev) (ProseMirror) que **lee y escribe Markdown**, que es lo que guarda la API:
- Barra con negrita, cursiva, tachado, código, título, listas, tareas, cita, bloque de código y enlace; Ctrl/Cmd+Enter
envía.
- `@` sugiere a los miembros del proyecto y escribe la mención como siempre, `@[Nombre](id)` (un lector propio en
`rich-text.ts`; la de TipTap usa otro formato).
- Pegar una captura la sube como adjunto y la deja como `![nombre](attachment:<id>)`; dentro del editor se descarga con
la sesión, reintentando unos instantes porque la API tarda en listar el adjunto nuevo.
- Entiende también tablas, para que editar un texto antiguo no las pierda (`rich-text.spec.ts` comprueba la ida y
vuelta).
- Funciona con `[(value)]` y con `[formField]` de los formularios de señales.
- Va en un trozo aparte (unos 150 kB comprimido) que solo se descarga en el ticket, el tablero y el formulario de
propuestas.
### Tiempo real y consistencia ### Tiempo real y consistencia
- Cada store se suscribe solo a lo que muestra (`issueChanges(projectId)`, `commentChanges(issueId)`…). - Cada store se suscribe solo a lo que muestra (`issueChanges(projectId)`, `commentChanges(issueId)`…).
@@ -140,6 +190,10 @@ de identidad (`SessionTokenSource`) le pide el token para el cliente GraphQL.
fuente de verdad. fuente de verdad.
- Las subscriptions van por `graphql-ws` con el token en `connectionParams`; si la API responde `UNAUTHENTICATED`, la - Las subscriptions van por `graphql-ws` con el token en `connectionParams`; si la API responde `UNAUTHENTICATED`, la
sesión se cierra. sesión se cierra.
- El cliente manda un _ping_ cada 10 s y, si no llega el _pong_ en 5 s, cierra el socket y reconecta (una conexión
medio muerta, p. ej. tras suspender el portátil o pasar por un proxy, no se nota de otro modo). Al reconectar,
`RealtimeConnection.reconnected$` avisa y cada store vuelve a pedir lo que muestra, porque lo que pasó mientras
tanto no llega por las subscriptions.
- Las operaciones están en `libs/web/*/infrastructure/src/lib/graphql/*.graphql`. Tras cambiar la API o una operación: - Las operaciones están en `libs/web/*/infrastructure/src/lib/graphql/*.graphql`. Tras cambiar la API o una operación:
```sh ```sh
@@ -168,7 +222,7 @@ La librería de componentes de Pireon se instala desde su Nexus (scope `@pi`, co
| Drawers y modales se abren con `MatDialog` | `DrawerService.open(título, template)` con `getDrawerConfigs` de la librería | | Drawers y modales se abren con `MatDialog` | `DrawerService.open(título, template)` con `getDrawerConfigs` de la librería |
| `pi-search-field` es un buscador plegable (un icono) | Para un campo de búsqueda normal se usa `pi-text-field` | | `pi-search-field` es un buscador plegable (un icono) | Para un campo de búsqueda normal se usa `pi-text-field` |
| `button` globales: `min-width`, padding y radio con selectores muy específicos | Los botones propios de la app usan `.ts-icon-button`; `pi-action-button size="w100"` necesita `box-sizing` (en `styles.scss`) | | `button` globales: `min-width`, padding y radio con selectores muy específicos | Los botones propios de la app usan `.ts-icon-button`, o anulan esas reglas con `!important` (votos, reacciones); `pi-action-button size="w100"` necesita `box-sizing` (en `styles.scss`) |
| Los campos pintan cualquier error que reciben, aunque no se hayan tocado | En el login, los errores se pasan solo tras salir del campo o intentar entrar (`(touch)`) | | Los campos pintan cualquier error que reciben, aunque no se hayan tocado | En el login, los errores se pasan solo tras salir del campo o intentar entrar (`(touch)`) |
Componentes en uso: `pi-action-button`, `pi-icon-button`, `pi-icon`, `pi-note`, `pi-text-field`, `pi-email-field`, Componentes en uso: `pi-action-button`, `pi-icon-button`, `pi-icon`, `pi-note`, `pi-text-field`, `pi-email-field`,
@@ -201,13 +255,21 @@ globales como `.ts-pill` o `.ts-key` leen esa variable. Las animaciones se desac
Estados, tipos y áreas son de cada proyecto, así que la UI nunca los tiene fijos: `ProjectFacade.workflow()` envuelve Estados, tipos y áreas son de cada proyecto, así que la UI nunca los tiene fijos: `ProjectFacade.workflow()` envuelve
`project.settings` (`Workflow` en el dominio web) y da nombre, color, icono, categoría, transiciones permitidas y si un `project.settings` (`Workflow` en el dominio web) y da nombre, color, icono, categoría, transiciones permitidas y si un
estado pide motivo. `statusLook`/`typeLook` de `ui/copy.ts` lo convierten en etiqueta y tono (`--ts-c-<color>`, con su estado pide motivo. `statusLook`/`typeLook` de `ui/copy.ts` lo convierten en etiqueta y tono: `--ts-c-<color>` para
versión oscura). Un id que ya no está configurado sigue mostrándose (`IN_REVIEW` → «In review»). «Mi trabajo» mezcla la paleta, con su versión oscura hecha a mano, y para un `#rrggbb` propio, `light-dark()` con una variante por tema
que `ui/colors.ts` calcula con la librería `color` (se acerca al blanco o al negro hasta contrastar 3:1 con la
superficie). En Configuración, cada estado y tipo elige su color con el selector nativo (`<input type="color">`), con
la paleta como sugerencias; si se elige un color de la paleta, se guarda su nombre. Un id que ya no está configurado sigue mostrándose (`IN_REVIEW` → «In review»). «Mi trabajo» mezcla
proyectos: cada fila usa el workflow de su proyecto y los grupos son por categoría. proyectos: cada fila usa el workflow de su proyecto y los grupos son por categoría.
En el tablero, un ticket solo se puede soltar en las columnas a las que su workflow le deja pasar, y los tipos En el tablero, un ticket solo se puede soltar en las columnas a las que su workflow le deja pasar, y los tipos
archivados no se ofrecen al crear. Los tipos con icono `palette` piden un frame de Figma. archivados no se ofrecen al crear. Los tipos con icono `palette` piden un frame de Figma.
Los campos personalizados se definen en Configuración (el tipo se elige al crearlos y luego queda fijo; las opciones
de una lista, una por línea). En la ficha, `ui/custom-field.ts` pone el control de cada tipo: las listas y las fechas
se guardan al elegir; el texto y los números, al salir del campo. En la lista, cada campo de opciones añade un filtro y
los valores del ticket se ven bajo su título.
### Gráficas ### Gráficas
Las de Estadísticas son SVG propios (`ui/series-chart.ts`), no `pi-line-chart`: hacen falta áreas apiladas y una Las de Estadísticas son SVG propios (`ui/series-chart.ts`), no `pi-line-chart`: hacen falta áreas apiladas y una
@@ -226,33 +288,40 @@ visualización de la plataforma:
### Proyectos públicos y visitantes sin sesión ### Proyectos públicos y visitantes sin sesión
- Solo la portada (`/`) exige sesión. `/explorar` y las páginas de proyecto (`/p/:key/…`) no: la API decide qué ve - Solo la portada (`/`) exige sesión. `/explore` y las páginas de proyecto (`/p/:key/…`) no: la API decide qué ve
cada visitante, y las páginas ya ocultan lo que no puede cambiar (`canContribute`, `canManage`). cada visitante, y las páginas ya ocultan lo que no puede cambiar (`canContribute`, `canManage`).
- Sin sesión, la cabecera muestra «Explorar» e «Iniciar sesión»; dentro de un proyecto público aparece un aviso de modo - Sin sesión, la cabecera muestra «Explorar» e «Iniciar sesión»; dentro de un proyecto público aparece un aviso de modo
lectura, y en uno privado (o inexistente), el enlace para iniciar sesión volviendo a esa página (`returnUrl`). lectura, y en uno privado (o inexistente), el enlace para iniciar sesión volviendo a esa página (`returnUrl`).
- El tiempo real necesita sesión: `GraphqlRealtimeGateway` no abre subscriptions sin token (si lo hiciera, la API - El tiempo real necesita sesión: `GraphqlRealtimeGateway` no abre subscriptions sin token (si lo hiciera, la API
cerraría el WebSocket y el cliente reintentaría sin fin). Los visitantes anónimos ven los datos al cargar la página. cerraría el WebSocket y el cliente reintentaría sin fin). Los visitantes anónimos ven los datos al cargar la página.
- Configuración tiene el interruptor «Público» (propietario y admins) y, solo para el propietario, «Eliminar el - Configuración tiene en General la casilla «Proyecto público» (propietario y admins) y, en Zona peligrosa y solo para el propietario, «Eliminar el
proyecto», que exige escribir la clave. Al borrarlo se vuelve a la portada; si otro lo borra mientras lo tienes proyecto», que exige escribir la clave. Al borrarlo se vuelve a la portada; si otro lo borra mientras lo tienes
abierto, la subscription avisa (`ProjectChanged.deleted`) y también se vuelve a la portada. abierto, la subscription avisa (`ProjectChanged.deleted`) y también se vuelve a la portada.
## 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, mis proyectos con su actividad (lo primero, para llegar antes), «Mi día» (`ts-my-day`: los tickets que elijo para hoy, tachados al terminar, mis abiertos para añadir y lo que quedó del último día), mis fijados (`ts-pinned`, una franja que se quita con ×; solo si hay), cuatro cifras (`ts-home-kpis`: abiertos míos, esperando QA, terminados por mí y por el equipo esta semana), la semana (`ts-week-agenda`), mis vistas guardadas y dos columnas: mis tickets y lo último en mis proyectos; sprints en curso, esperando QA, que sigo, próximas releases, quién está ahora y «Retomar» |
| `/explorar` | Proyectos públicos (sin sesión) | | `/portfolio` | Cartera (con sesión): una tarjeta por proyecto activo con lo abierto y en riesgo, el sprint en curso (¿llega?), las próximas releases con su previsión y las épicas abiertas; en directo: se vuelve a leer sola cuando cambia un ticket, sprint o release de esos proyectos. Cada proyecto lleva su semáforo de salud (`portfolioHealth`), con las causas al pasar por encima |
| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros) | | `/explore` | Proyectos públicos (sin sesión) |
| `/p/:key/issues/:issueKey` | Ticket | | `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros). Quien puede contribuir cambia prioridad, estimación y persona desde la propia tarjeta (selectores nativos sobre sus distintivos; el título es el enlace). Mover una tarjeta o cambiar su persona ofrece «Deshacer» unos segundos (o Ctrl/⌘ Z): la orden contraria, que el flujo puede rechazar. Con el teclado: Tab entra en las tarjetas, las flechas se mueven entre ellas, Shift+←/→ pasa la tarjeta de columna (mismas reglas que arrastrar), `p`/`e`/`a` abren su prioridad, estimación o persona y Enter la abre |
| `/p/:key/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). A la vista, «Votar», «Seguir» y «Editar»; el resto (pedir revisión, mi día, recordármelo, seguir todo, fijar y duplicar) está en el menú «Más» (menú del CDK: se recorre con las flechas y Escape lo cierra). «Fijar» lo deja a mano en el inicio y en la paleta de búsqueda. Arriba, sus decisiones (comentarios marcados con la bandera por quien puede cambiar el ticket); `?comment=<id>` lleva a un comentario y lo señala. Cambiar el estado o la persona y mandarlo a la papelera (sin confirmación) ofrecen «Deshacer». Cada conversación con respuestas se resuelve («✓ Resolver», con una conclusión opcional que queda como decisión) y se pliega en una línea; la cabecera cuenta las abiertas y terminar el ticket con alguna pregunta antes. Se ve quién está escribiendo en cada conversación («Ana está escribiendo…», `PresenceFacade.typists`), y en las respuestas Enter envía (Shift+Enter, otra línea). Si tiene subtareas, «Seguir todo» avisa de ella y de todo lo que cuelga, también lo que se añada (también desde Épicas). «Recordármelo» pone un aviso solo para mí (dentro de 1 hora, esta tarde, mañana o el lunes a las 9, en una semana o cuando diga), con nota opcional. «☀ Mi día» lo añade a los de hoy. «Pedir revisión» a alguien del equipo: lo ve en «Mi trabajo» («Te piden revisión») y responde «Todo bien» o «Pedir cambios» desde el recuadro Revisiones. Un comentario puede llevar una encuesta («Encuesta»; con su pregunta y de 2 a 6 opciones; el botón pasa a «Publicar encuesta» y el texto del comentario es opcional): se vota pulsando una opción y, al cerrarla, la ganadora queda como decisión. «🎥 Grabar pantalla» (con la voz, si se da permiso al micrófono) y «🎙 Grabar audio» graban en el navegador hasta 3 minutos y lo adjuntan al comentario, que puede ir sin texto; los vídeos y audios adjuntos se reproducen ahí mismo. Al abrirlo, lo que otros han hecho desde tu última visita: un aviso arriba («Desde tu última visita, hace 2 días: 3 comentarios y 1 cambio de Ana y Bob», con «Ver lo nuevo»), los comentarios nuevos marcados («Nuevo», también en una conversación plegada) y los cambios del historial resaltados. Cada comentario se puede «Convertir en ticket» (subtarea de este o enlazado, con el título sacado de su primera línea): el comentario enlaza al ticket nuevo («→ Convertido en WEB-45») |
| `/p/:key/dashboard` | Seguimiento: cifras, flujo por estado, equipo (cada persona enlaza a su tablero) y tipos | | `/p/:key/releases` | Releases. «Página pública» (quien gestiona): un enlace secreto con las notas para clientes, que se copia, renueva o retira |
| `/p/:key/report/:kind/:id` | Informe para imprimir de un sprint (`sprint`) o una release (`release`): datos, totales, notas y tickets por etapa. «Imprimir o guardar en PDF» abre el diálogo del navegador; al imprimir se quitan la cabecera, las pestañas y los fondos (`styles.scss`) |
| `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea. Los bloqueos con tickets de otros proyectos (en los dos sentidos, `externalDependencies`) salen en «De otros proyectos» con barra discontinua, sus flechas y el mismo aviso si las fechas no encajan; los que no tienen fechas o no se pueden leer se nombran debajo |
| `/p/:key/dashboard` | Arriba, la salud del proyecto (`ts-project-health`): un semáforo (bien, atención, en riesgo) con cada causa y sus tickets, calculado en la web (`projectHealth`) con los mismos datos: atascados, vencidos, bloqueados, bugs críticos o altos, lo que devuelve QA, releases que no llegan y columnas sobre su WIP. Seguimiento: cifras, flujo por estado, lo que ha cambiado desde tu última visita (guardada en este navegador), sprint en curso, tickets en riesgo, previsión de las próximas releases, antigüedad de lo que está en curso, cuellos de botella, salud del backlog (cada número abre la lista filtrada: `?missing=estimate`, `area`, `assignee` o `?staleDays=90`), calidad (bugs por prioridad y por semana, QA por área, bugs tras cada release), abiertos por un campo de lista a elegir, el recorrido de un ticket (dónde se va el tiempo de creado a hecho y qué parte es trabajo), la actividad de 12 semanas en un mapa de calor (pulsar un día abre lo que pasó, ticket a ticket), el embudo de propuestas del público (si las hay), equipo con sus puntos abiertos y tipos. Se recarga solo con cada cambio. «Modo pantalla» lo pone a pantalla completa para una tele del equipo: un bloque cada vez, ampliado, que cambia cada 15 s (← → para moverse, espacio para pausar, Esc para salir), con la hora y en directo |
| `/p/:key/analytics` | Estadísticas: creados vs. resueltos, flujo acumulado, burnup de una release, tiempos de ciclo y entrega, QA y resueltos por persona (30 días a 1 año) | | `/p/:key/analytics` | Estadísticas: creados vs. resueltos, flujo acumulado, burnup de una release, tiempos de ciclo y entrega, QA y resueltos por persona (30 días a 1 año) |
| `/p/:key/settings` | Configuración (propietario y admins; pestaña oculta para el resto): estados (orden, categoría, color, motivo), transiciones, tipos y áreas; archivar en lugar de borrar | | `/p/:key/settings` | Configuración (propietario y admins; pestaña oculta para el resto), con un menú de secciones (`?section=`): General (visibilidad, propuestas, panel para clientes), Flujo de trabajo (estados, transiciones, definición de «Hecho»), Tickets (tipos, áreas, campos), Equipos, Automatizaciones, Integraciones (Git, alertas → bugs con su URL secreta y cómo conectar Sentry, Uptime Kuma o Grafana, webhooks), Importar y exportar y Zona peligrosa; archivar en lugar de borrar, y un punto en el menú donde hay cambios sin guardar |
| `/p/:key/activity` | Actividad en vivo | | `/p/:key/activity` | Actividad en vivo |
| `/p/:key/members` | Miembros | | `/p/:key/propose` | Formulario público para reportar un error o proponer una idea sin cuenta (si el proyecto lo tiene abierto); llegan como tickets con la etiqueta «propuesta» |
| `/share/:token` | Panel para clientes, sin sesión: los bloques que eligió el proyecto (flujo, sprint, releases, épicas, actividad), sin enlaces hacia dentro de la aplicación; se vuelve a leer cada minuto. Si el enlace se renovó o retiró, «Este enlace ya no funciona» |
| `/notes/:token` | Notas de una release para sus clientes, sin cuenta: las escritas o las de sus tickets terminados |
| `/p/:key/members` | Miembros, con su rol (propietario, admin, miembro, lector o invitado). Un invitado de un proyecto privado solo tiene Tablero, Lista y Miembros, ve solo sus tickets y abre tickets sin asignarlos ni planificarlos |
Solo `/` está protegida por `authGuard` (redirige a `/login?returnUrl=…`); `/login` y `/auth/callback` son para quien 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
los componentes (`withComponentInputBinding`). los componentes (`withComponentInputBinding`). Las rutas van en inglés; las antiguas en castellano (`/cartera`,
`/explorar`, `/p/:key/proponer`) redirigen a las nuevas, para que sigan valiendo los enlaces ya compartidos.

View File

@@ -122,11 +122,16 @@ caduque la caché):
1. Registra `https://<tu-api>/api/webhooks/pireon` con los eventos `user.deactivated` y `token.revoked` 1. Registra `https://<tu-api>/api/webhooks/pireon` con los eventos `user.deactivated` y `token.revoked`
(`POST /api/clients/{client}/webhooks`). (`POST /api/clients/{client}/webhooks`).
2. Pon el secret que devuelve en `PIREON_WEBHOOK_SECRET`. 2. Pon el secret que devuelve en `PIREON_WEBHOOK_SECRET`.
3. Si usas un cliente aparte para el acceso con contraseña (`PIREON_PASSWORD_CLIENT_ID`), registra el webhook también
en ese cliente y pon su secret en `PIREON_PASSWORD_WEBHOOK_SECRET`. Pireon firma cada aviso con el secret del
cliente que lo registró, y la revocación de un token solo la avisa el cliente que lo emitió: sin este paso, los
tokens de quien entra con contraseña no se olvidan al revocarlos.
La API verifica la firma `X-Webhook-Signature` (HMAC-SHA256 del cuerpo), marca al usuario como inactivo y borra sus La API verifica la firma `X-Webhook-Signature` (HMAC-SHA256 del cuerpo, válida con cualquiera de los dos secrets),
marca al usuario como inactivo y borra sus
sesiones cacheadas. Además guarda `UserDeactivatedEvent` en el event store, y una saga del tracker deja sin asignar sus sesiones cacheadas. Además guarda `UserDeactivatedEvent` en el event store, y una saga del tracker deja sin asignar sus
tickets abiertos en todos los proyectos (ver [Arquitectura](02-arquitectura.md)). Sin `PIREON_WEBHOOK_SECRET`, el tickets abiertos en todos los proyectos (ver [Arquitectura](02-arquitectura.md)). Repetir un aviso (llega uno por
endpoint rechaza todas las llamadas. cliente) no tiene efecto. Sin ningún secret, el endpoint rechaza todas las llamadas.
## 5.4 Usuarios en TaskStream ## 5.4 Usuarios en TaskStream

View File

@@ -32,6 +32,7 @@ la web y la API comparten origen igual que en Docker.
| `npx nx e2e api` | Flujo completo de la API contra Docker, con Pireon simulado | | `npx nx e2e api` | Flujo completo de la API contra Docker, con Pireon simulado |
| `npx nx run-many -t build` | Builds de la API y la web | | `npx nx run-many -t build` | Builds de la API y la web |
| `npx nx run web:codegen` | Regenera los tipos GraphQL del cliente desde `apps/api/schema.gql` | | `npx nx run web:codegen` | Regenera los tipos GraphQL del cliente desde `apps/api/schema.gql` |
| `npx nx run api:contracts` | Escribe `schema.gql` y `openapi.json` sin arrancar la API ni bases de datos |
| `npx nx serve mcp` | Servidor MCP en `http://localhost:3010/mcp`, contra la API local ([MCP](09-mcp.md)) | | `npx nx serve mcp` | Servidor MCP en `http://localhost:3010/mcp`, contra la API local ([MCP](09-mcp.md)) |
| `npx nx run mcp:openapi-types` | Regenera los tipos REST del MCP desde `apps/api/openapi.json` | | `npx nx run mcp:openapi-types` | Regenera los tipos REST del MCP desde `apps/api/openapi.json` |
| `npx nx run api:prune` | Build de la API con `package.json` y lockfile podados (lo usa Docker) | | `npx nx run api:prune` | Build de la API con `package.json` y lockfile podados (lo usa Docker) |
@@ -70,7 +71,8 @@ Ejemplo: **marcar un ticket como bloqueado con un motivo**. El recorrido es siem
por `ReadModelNotifier`. Añadir el evento al `ActivityEventHandler` y una frase en `activity-summary.ts`. por `ReadModelNotifier`. Añadir el evento al `ActivityEventHandler` y una frase en `activity-summary.ts`.
- Si el read model cambia, añadir el campo a `IssueReadModel` (dominio) y al esquema Mongo (infraestructura). - Si el read model cambia, añadir el campo a `IssueReadModel` (dominio) y al esquema Mongo (infraestructura).
3. **Presenters** (`libs/api/tracker/presenters`): input y mutation `blockIssue` en `IssueResolver`, que llama al service. 3. **Presenters** (`libs/api/tracker/presenters`): input y mutation `blockIssue` en `IssueResolver`, que llama al service.
4. Arrancar la API para regenerar `apps/api/schema.gql`. 4. Regenerar `apps/api/schema.gql` y `apps/api/openapi.json`: arrancando la API, o sin bases de datos con
`npx nx run api:contracts`.
### Web ### Web

View File

@@ -36,7 +36,10 @@ genéricos, el DNS de Docker devuelve también los suyos y nginx reparte las pet
de las llamadas a `/api/*` responden `404 Cannot GET …` y el login dice «no configurado»). Si añades un servicio, de las llamadas a `/api/*` responden `404 Cannot GET …` y el login dice «no configurado»). Si añades un servicio,
dale su alias `task-stream-*` y úsalo en las URLs. dale su alias `task-stream-*` y úsalo en las URLs.
Todos tienen healthcheck; `web` espera a que `api` esté sana, y `api` a las tres bases de datos. Todos tienen healthcheck; `web` espera a que `api` esté sana, y `api` a las tres bases de datos. `GET /api/health` responde 503 si cae una base de datos; con
las tres en pie, `status: degraded` (200) avisa de eventos que no llegan a la lectura (`events.problems`). Los eventos
apartados por fallar una y otra vez se ven con `redis-cli XRANGE task-stream:events:dead - +`; arreglado el fallo, se
reconstruye la lectura (abajo) o se vuelven a publicar.
### Imágenes ### Imágenes
@@ -71,7 +74,7 @@ donde los lee Compose.
## 7.2 Variables de entorno de la API ## 7.2 Variables de entorno de la API
| Variable | Por defecto | Descripción | | Variable | Por defecto | Descripción |
| ------------------------------------------- | -------------------------- | ------------------------------------------------------------------------------------- | | ------------------------------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `PORT` | `3000` | Puerto HTTP | | `PORT` | `3000` | Puerto HTTP |
| `NODE_ENV` | `development` | En `production` no se devuelven stacktraces | | `NODE_ENV` | `development` | En `production` no se devuelven stacktraces |
| `POSTGRES_URL` | — (obligatoria) | Event store y lookups | | `POSTGRES_URL` | — (obligatoria) | Event store y lookups |
@@ -87,19 +90,32 @@ donde los lee Compose.
| `PIREON_SCOPES` | `users:read` | Scopes que se piden al iniciar sesión | | `PIREON_SCOPES` | `users:read` | Scopes que se piden al iniciar sesión |
| `PIREON_PASSWORD_GRANT` | `true` | Permite el login con email y contraseña | | `PIREON_PASSWORD_GRANT` | `true` | Permite el login con email y contraseña |
| `PIREON_WEBHOOK_SECRET` | — | Activa y firma los webhooks de Pireon | | `PIREON_WEBHOOK_SECRET` | — | Activa y firma los webhooks de Pireon |
| `PIREON_PASSWORD_WEBHOOK_SECRET` | — | El del webhook del cliente de contraseña, si es otro cliente |
| `PIREON_TIMEOUT_MS` | `5000` | Timeout de las llamadas a Pireon | | `PIREON_TIMEOUT_MS` | `5000` | Timeout de las llamadas a Pireon |
| `AUTH_SESSION_TTL_SECONDS` | `60` | Cuánto se confía en un token validado antes de volver a preguntar a Pireon | | `AUTH_SESSION_TTL_SECONDS` | `60` | Cuánto se confía en un token validado antes de volver a preguntar a Pireon |
| `GRAPHQL_PLAYGROUND` | `true` | GraphiQL e introspección | | `GRAPHQL_PLAYGROUND` | `true` | GraphiQL e introspección |
| `GRAPHQL_SCHEMA_FILE` | `apps/api/schema.gql` | Dónde escribir el SDL (vacío = no escribir; así está en Docker) | | `GRAPHQL_SCHEMA_FILE` | `apps/api/schema.gql` | Dónde escribir el SDL (vacío = no escribir; así está en Docker) |
| `SWAGGER_ENABLED` | `true` | API REST documentada: Swagger UI en `/api/docs`, OpenAPI en `/api/docs-json` | | `SWAGGER_ENABLED` | `true` | API REST documentada: Swagger UI en `/api/docs`, OpenAPI en `/api/docs-json` |
| `OPENAPI_FILE` | `apps/api/openapi.json` | Dónde escribir el documento OpenAPI al arrancar (vacío = no escribir; así en Docker) | | `OPENAPI_FILE` | `apps/api/openapi.json` | Dónde escribir el documento OpenAPI al arrancar (vacío = no escribir; así en Docker) |
| `ATTACHMENTS_DIR` | `data/attachments` | Carpeta de los adjuntos de los tickets; en Docker, el volumen `attachments-data` |
| `SMTP_URL` | — | Servidor de correo para las copias de las notificaciones (`smtps://user:pass@host`); sin él no se envía nada |
| `MAIL_FROM` | `TaskStream <no-reply@pireon.dev>` | Remitente de esos correos |
| `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` | — | Notificaciones push (Web Push). Sin las dos no se envía nada. Se crean una vez con `npx web-push generate-vapid-keys`; cambiarlas anula las suscripciones |
| `VAPID_SUBJECT` | `mailto:no-reply@pireon.dev` | Contacto (`mailto:` o `https:`) que los servicios push pueden usar si algo va mal |
| `WEB_URL` | — | Dirección pública de la web, para los enlaces de los correos |
| `ATTACHMENTS_MAX_MB` | `25` | Tamaño máximo de un adjunto (nginx admite hasta 30 MB por petición) |
| `WEBHOOKS_ALLOW_PRIVATE` | `false` | Deja que los webhooks salientes llamen a direcciones de esta máquina o de su red privada (apagado, un webhook podría alcanzar servicios internos) |
| `REMINDERS_ENABLED` | `true` | Avisa cada hora de los tickets que vencen mañana, hoy o ya han vencido |
| `REMINDERS_TIMEZONE` | `Europe/Madrid` | Zona horaria en la que cambia el día para los recordatorios |
| `TRASH_DAYS` | `30` | Días que un ticket pasa en la papelera antes de borrarse del todo |
La web no tiene configuración de entorno: llama a la API en su mismo origen (`/api`, `/graphql`). La web no tiene configuración de entorno: llama a la API en su mismo origen (`/api`, `/graphql`).
## 7.3 Escalado ## 7.3 Escalado
- **API**: es _stateless_ (sesiones en Redis, subscriptions por Redis pub/sub), así que admite varias réplicas detrás - **API**: es _stateless_ (sesiones en Redis, subscriptions por Redis pub/sub), así que admite varias réplicas detrás
del balanceador. El WebSocket necesita que el balanceador permita `Upgrade`. del balanceador. El WebSocket necesita que el balanceador permita `Upgrade`. La excepción son los **adjuntos**: se
guardan en disco (`ATTACHMENTS_DIR`), así que varias réplicas tienen que compartir ese volumen.
- **Relay**: cada instancia publica eventos al stream; `FOR UPDATE SKIP LOCKED` evita duplicados. - **Relay**: cada instancia publica eventos al stream; `FOR UPDATE SKIP LOCKED` evita duplicados.
- **Proyecciones**: el `EventsBridge` procesa los eventos en orden. Lo más sencillo es dejarlo activo en **una sola** - **Proyecciones**: el `EventsBridge` procesa los eventos en orden. Lo más sencillo es dejarlo activo en **una sola**
instancia (`EVENTS_BRIDGE_ENABLED=true`) y desactivarlo en las demás. Varias instancias en el mismo consumer group se instancia (`EVENTS_BRIDGE_ENABLED=true`) y desactivarlo en las demás. Varias instancias en el mismo consumer group se
@@ -111,7 +127,8 @@ La web no tiene configuración de entorno: llama a la API en su mismo origen (`/
- Registrar el cliente OAuth y las redirect URIs de producción en Pireon. - Registrar el cliente OAuth y las redirect URIs de producción en Pireon.
- Servir por HTTPS (la web usa `wss://` automáticamente cuando la página va por HTTPS). - Servir por HTTPS (la web usa `wss://` automáticamente cuando la página va por HTTPS).
- Revisar `GRAPHQL_PLAYGROUND` (introspección) según el entorno. - Revisar `GRAPHQL_PLAYGROUND` (introspección) según el entorno.
- Copias de seguridad de PostgreSQL: es la fuente de verdad. - Copias de seguridad de PostgreSQL: es la fuente de verdad. Y del volumen de adjuntos (`attachments-data`): los
eventos dicen qué archivos hay, pero los bytes solo están ahí.
## 7.5 Reconstruir el read model ## 7.5 Reconstruir el read model

View File

@@ -71,3 +71,16 @@ presentación se prueba sin plantillas. **Coste**: una clase más por store, cas
El `package.json` raíz incluye Angular y `@pi/ui` (del Nexus privado). La imagen de la API usa El `package.json` raíz incluye Angular y `@pi/ui` (del Nexus privado). La imagen de la API usa
`apps/api/package.json`, así que ni lleva el frontend ni necesita el token del Nexus en ejecución. `apps/api/package.json`, así que ni lleva el frontend ni necesita el token del Nexus en ejecución.
### Adjuntos en disco, notificaciones fuera del event store
Los **adjuntos** se registran como eventos del ticket, pero los bytes van a un puerto `AttachmentStorage` (disco,
`ATTACHMENTS_DIR`): un event store no es sitio para megabytes. Se escribe el archivo antes que el evento, y si el
evento falla se borra el archivo, así que nunca hay un adjunto apuntando a nada. **Coste**: el volumen hay que
copiarlo y compartirlo entre réplicas.
Las **notificaciones** sí salen de eventos, pero no son eventos: si un aviso está leído, o si alguien quiere copia por
correo, solo le importa a esa persona y no necesita historia. Se guardan en MongoDB por destinatario y se marcan como
leídas sin pasar por un agregado. Si se reconstruye el read model, se pueden regenerar desde los eventos (quedarían
como no leídas). **Alternativa descartada**: un agregado «bandeja» por persona, con eventos `NotificationRead`; mucha
ceremonia para un estado que nadie audita.

View File

@@ -32,35 +32,72 @@ flowchart LR
## 9.2 Herramientas ## 9.2 Herramientas
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) |
| | `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), 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_board` | Tablero por columnas (filtros por persona, release, área) | | | `get_project_showcase` | El escaparate público: hoja de ruta, releases, sprint en curso y tickets para empezar |
| | `get_board` | Tablero por columnas (filtros por persona, release, área, sprint) |
| | `get_project_stats` | Cifras de seguimiento | | | `get_project_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: p. ej. lo que espera QA) | | | `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 | | | `get_project_insights` | El panel: sprint, riesgo, antigüedad, cuellos de botella, releases, equipo, backlog, calidad, campos, actividad por día, recorrido de un ticket, propuestas y, con `since`, los cambios |
| | `get_issue` | Detalle, transiciones permitidas, enlaces, comentarios e historial | | | `get_project_activity` | Actividad reciente, o la de un día (`day`) |
| | `create_issue` | Crear un ticket | | Tickets | `my_issues` | Mi trabajo en todos los proyectos (o de cualquiera, por categoría; o los que sigo); también por prioridad, solo bugs o vencidos; `reviewing`: los que me piden revisar |
| | `update_issue` | Cambiar título, descripción, tipo, área, prioridad, etiquetas… | | | `search_issues` | Buscar por texto y filtros (épica, sprint, fechas, campos…); el backlog: `noSprint` y orden `RANK` |
| | `get_issue` | Detalle: épica, lista de comprobación, relaciones, commits, seguidores, transiciones, decisiones y comentarios |
| | `create_issue` | Crear un ticket (también colgado de una épica) |
| | `update_issue` | Cambiar título, descripción, tipo, área, prioridad, etiquetas, fechas, campos personalizados… |
| | `change_issue_status` | Mover de estado (con motivo cuando el estado lo pide) | | | `change_issue_status` | Mover de estado (con motivo cuando el estado lo pide) |
| | `assign_issue` | Asignar o desasignar | | | `assign_issue` | Asignar o desasignar |
| | `set_issue_release` | Meter en una release o sacar | | | `set_issue_release` | Meter en una release o sacar |
| | `add_comment` | Comentar | | | `add_comment` / `edit_comment` / `delete_comment` | Comentar (o responder a un comentario), corregir o borrar |
| | `link_issues` | Relacionar tickets (`DEPENDS_ON`, `BLOCKS`, `TESTS`…) | | | `link_issues` / `unlink_issues` | Relacionar tickets (`DEPENDS_ON`, `BLOCKS`, `TESTS`…) o quitar la relación |
| | `watch_issue` | Seguir un ticket o dejar de seguirlo; con `tree`, una épica con todo lo que cuelga de ella |
| | `vote_issue` | Votar un ticket o quitar el voto |
| | `duplicate_issue` | Crear otro ticket con lo mismo (sin estado, persona ni comentarios) |
| | `react_to_comment` | Reaccionar a un comentario (👍 🎉…) o quitar la reacción |
| | `mark_decision` | Marcar un comentario como decisión del ticket, o quitarla |
| | `resolve_thread` | Resolver una conversación (con conclusión opcional, que queda como decisión) o reabrirla |
| | `remind_me` | Recordármelo: un aviso solo para mí sobre un ticket, a una fecha y hora, con nota (o quitarlo) |
| | `my_day` | Mi día: los tickets elegidos para hoy (y lo que quedó del último día); añadir o quitar uno |
| | `request_review` | Pedir a alguien del proyecto que revise un ticket |
| | `answer_review` | Responder una revisión: todo bien o cambios |
| | `vote_poll` | Votar en la encuesta de un comentario (o retirar el voto) |
| | `close_poll` | Cerrar una encuesta; la ganadora queda como decisión |
| | `turn_comment_into_issue` | Convertir un comentario en ticket: subtarea del suyo o enlazado; su texto es la descripción |
| | `search_decisions` | Qué se decidió: decisiones por su texto, de un proyecto o de todos los míos |
| | `add_checklist_item` / `update_checklist_item` / `remove_checklist_item` | La lista de comprobación: añadir, marcar o reescribir, quitar |
| Organización | `list_epics` | Épicas con el progreso de lo que cuelga de cada una |
| | `set_issue_parent` | Colgar un ticket de una épica u otro ticket, o soltarlo |
| | `rank_issue` | Mover un ticket en el backlog |
| | `bulk_update_issues` | El mismo cambio en hasta 100 tickets (por clave); dice cuáles no se dejaron |
| | `delete_issue` / `restore_issue` / `list_trash` | Papelera: borrar (30 días para recuperar), recuperar y ver |
| Sprints | `list_sprints` | Sprints de un proyecto |
| | `get_sprint` | Progreso, tickets y burndown |
| | `create_sprint` / `update_sprint` / `delete_sprint` | Planificar, cambiar o borrar (si no empezó) un sprint |
| | `start_sprint` / `close_sprint` | Empezarlo; cerrarlo pasando lo pendiente a otro sprint o al backlog |
| | `set_issue_sprint` | Meter un ticket en un sprint o devolverlo al backlog |
| Releases | `list_releases` | Releases de un proyecto | | Releases | `list_releases` | Releases de un proyecto |
| | `upcoming_releases` | Próximas releases de mis proyectos | | | `upcoming_releases` | Próximas releases de mis proyectos |
| | `get_release` | Progreso, tickets y burnup | | | `get_release` | Progreso, notas, tickets y burnup |
| | `create_release` | Planificar una release | | | `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) | | | `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. Lo que es de Las de lectura van marcadas como `readOnlyHint`, para que el cliente no pida confirmación al usarlas, y las que borran o
administración (miembros, configuración, visibilidad, borrar proyectos) no se expone: se hace desde la web. archivan, como `destructiveHint`, para que la pida. Lo que es de administración (miembros, configuración, visibilidad,
borrar proyectos, webhooks, integración con git, importar y exportar CSV) no se expone: se hace desde la web.
## 9.3 Conectar un cliente ## 9.3 Conectar un cliente

137
docs/10-futuro.md Normal file
View File

@@ -0,0 +1,137 @@
# 10. Lo que queda
Lo pendiente, ordenado de más a menos urgente: primero lo que hace falta para dar el producto por terminado en
producción, después los fallos y la deuda conocidos y al final las ideas. Cada punto dice qué es, por qué importa y,
cuando hay pistas, por dónde empezar.
## 10.1 Para dar el producto por terminado
| Qué | Por qué | Por dónde empezar |
| ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Comprobar en producción que el tiempo real ya no se pierde | Ya hay _ping_ cada 10 s, reconexión si no hay respuesta y recarga de cada pantalla al reconectar, pero no se ha probado allí | Dejar una pestaña abierta más que la vida del token y tras suspender el portátil, y mover tickets desde otra. Si aún falla, mirar el token de `connectionParams`, que solo se manda al conectar |
| Pruebas de navegador en la integración continua | Los fallos del tablero (no se podía arrastrar entre columnas) y de la ficha (no se refrescaba) solo se vieron usándolo | Ya existe la receta: API real con usuarios simulados (`IdentityProvider` sustituido), `nx serve web` y Playwright con la sesión en `localStorage` (`task-stream.session`). Convertirla en `apps/web-e2e` |
| Copias de seguridad de Postgres y Mongo | Postgres guarda el event store, la fuente de verdad; Mongo se puede reconstruir desde él, Postgres no | Copias programadas en Dokploy y **una restauración de prueba** |
| Limpiar los datos de prueba de producción | DEMO, TEST y los tickets de las pruebas del MCP | Borrar los proyectos desde Configuración (borrado definitivo con su clave) |
| Registrar el webhook del cliente de contraseña en Pireon | Sin él, revocar un token de quien entra con contraseña no se nota hasta que caduca la caché | Solo si `PIREON_PASSWORD_CLIENT_ID` tiene valor: webhook en ese cliente y su secret en `PIREON_PASSWORD_WEBHOOK_SECRET` (ver [Autenticación](05-autenticacion.md)) |
| Una versión 1.0 | Un punto fijo al que volver | Desplegar todo, etiquetar `v1.0.0` y redactar la lista de cambios (las notas de versión de TaskStream dan el borrador) |
| Revisión de seguridad | Hay rutas sin sesión (proyectos públicos, escaparate, propuestas, panel para clientes) y un MCP abierto a Internet | Permisos por rol, qué ve un anónimo, límites de peticiones (solo las propuestas tienen), CORS, cabeceras |
| Registro de errores en producción | Hoy un fallo solo se conoce si alguien avisa | Sentry (o similar) en la API, la web y el MCP |
## 10.2 Fallos y deuda conocidos
- **Error al crear subtareas en producción**: lo reportó el usuario y no se reprodujo en local; con el arreglo de la
ficha la lista ya se refresca, pero falta el mensaje exacto si vuelve a salir.
- **Imágenes pegadas**: ya no salen en la lista de adjuntos, pero si se borran del texto el adjunto sigue guardado.
- **Seguimiento**: los tickets que ya existían no se apuntan a quien los creó o los tiene asignados (solo los nuevos).
- **El MCP no hace el flujo OAuth del protocolo**: el cliente tiene que llevar un token de Pireon en la cabecera y
renovarlo a mano cuando caduca.
## 10.3 Siguientes funcionalidades
### En curso
Nada: la tanda está hecha (ver 10.5).
### Aceptadas, para después de esta tanda
- **Sincronizar con issues de Gitea o GitHub**: que un issue del repositorio cree o actualice un ticket, y al revés.
- **Búsqueda con lenguaje de consulta**: `estado:abierto asignado:yo navegador:Safari prioridad>=alta`, guardable como
vista.
- **Entornos de despliegue**: en qué entorno está cada ticket (pruebas, producción), desde los avisos de la
integración continua.
- **Página pública de novedades**: las notas de versión publicadas en una página por proyecto, con RSS.
### Otras ideas apuntadas, sin decidir
- **Resumen diario por correo**: lo asignado, lo que vence hoy y lo que ha cambiado en lo que sigues, en un solo correo.
- **Tickets recurrentes**: «revisar copias cada lunes», creados solos con la plantilla del tipo.
- **Crear tickets por correo**: una dirección por proyecto; lo que llega es un ticket (con adjuntos) y las respuestas,
comentarios. Necesita un buzón de entrada.
- **Posponer un ticket**: «recuérdamelo el jueves».
- **Reunión diaria**: qué cerró cada uno ayer, qué tiene hoy y qué le bloquea, sacado del historial.
- **Requisitos al cambiar de estado**: campos obligatorios para entrar en un estado (estimación para «En curso»…).
- **Plazos por prioridad**: «lo crítico, en 24 h», con aviso antes de vencer.
- **Matriz impacto/esfuerzo** para ordenar el backlog.
- **Planning poker** y **retrospectiva del sprint** (las acciones se convierten en tickets).
- **Avisar a quien propuso** cuando su propuesta se hace o se descarta.
## 10.4 Descartado por ahora
Mucho trabajo para lo que aportan hoy; se pueden recuperar si alguien los necesita:
- **Registro de horas** dedicadas a cada ticket.
- **Objetivos u OKR** por encima de las épicas.
- **App móvil**: la web ya se adapta a pantallas estrechas.
- **Asistente en la web** (resumir tickets, sugerir tipo, prioridad y área, redactar notas de versión): el MCP ya deja
hacerlo desde un cliente de IA.
- **Avisos a Discord, Slack o Telegram** con mensajes legibles: los webhooks salientes siguen ahí para quien los
necesite.
- **Conector de claude.ai** (OAuth del protocolo en el MCP y registro dinámico de clientes en Pireon).
## 10.5 Hecho desde la primera versión de esta lista
- Tiempo real: _ping_ y reconexión del WebSocket, y recarga de lo que se ve al reconectar.
- Arrastrar a una columna prohibida devuelve la tarjeta a su sitio.
- Reacciones y «Bloqueado» en componentes propios (los estilos de la ficha vuelven a caber en 8 kB).
- Límite de propuestas en Redis, válido con varias instancias.
- Aviso `PROPOSED` a propietarios y admins cuando llega una propuesta.
- `voted` también por REST.
- Borrar un proyecto borra su integración con Git.
- Sin avisos de lint.
- La web pública (landing-web) enseña las propuestas más votadas, el enlace al formulario y los votos de la hoja de
ruta.
- Un proyecto de la landing creado sin tablero puede abrirlo después: «Crear en TaskStream» en su ficha o
`taskstream: true` en un `PATCH`.
- Paquete inicial de la web de 2,13 MB a 1,88 MB con `@pi/ui` 0.0.4, que ya no registra Chart.js al importarse ni
depende de `lodash`.
- Descripciones y comentarios con editor visual (guardado en Markdown), con menciones y capturas pegadas.
- Ids: `uuid` en todas las tablas de Postgres (el orden de los eventos en `seq`) y, en Mongo, `id` con el uuid del
dominio más el `_id` `ObjectId` propio.
- Enlazar tickets y colgar de un padre eligiendo en un selector con búsqueda; casillas de `@pi/ui` en toda la web.
- Mover tickets entre releases: «Release…» en la edición en bloque, filtro por release en la lista (también desde cada
release, «Ver en la lista»), y al archivar una release, elegir adónde va lo pendiente.
- Calendario suscribible (iCalendar) desde «Mi trabajo»: tickets con fecha, sprints y releases, con URL secreta
renovable.
- Notificaciones push (Web Push) en cada dispositivo, con la web instalable.
- Campos personalizados por proyecto (texto, número, fecha o lista de opciones): se definen en Configuración, se
editan en la ficha, se ven bajo el título en la lista y se filtran por los de opciones (también por GraphQL, REST y
MCP).
- Dependencias en la hoja de ruta: flechas entre las épicas que se bloquean o dependen unas de otras, y aviso cuando
una empieza antes de que acabe la que la bloquea.
- Plantillas de proyecto: al crear uno, «Empezar con la configuración de» otro que administras copia sus estados,
transiciones, tipos, áreas, campos personalizados y automatizaciones. (El burndown y la velocidad de sprint ya
existían, en Sprints.)
- Informe para imprimir (o guardar en PDF) de un sprint o una release, desde Sprints y Releases.
- Panel del proyecto, primera parte: sprint en curso (¿llega a tiempo?), tickets en riesgo (vencidos, que vencen esta
semana, bloqueados y atascados), antigüedad de lo que está en curso y cuellos de botella. También por REST, GraphQL
y MCP (`get_project_insights`).
- Panel del proyecto, segunda parte: lo que ha cambiado desde tu última visita, previsión de las próximas releases al
ritmo real (Monte Carlo), carga del equipo en puntos y salud del backlog con enlaces a la lista filtrada.
- Panel del proyecto, tercera parte: calidad (bugs abiertos por prioridad, nuevos y cerrados por semana, QA por área,
bugs tras cada release) y abiertos por opción de un campo de lista.
- Escrituras a la vez en un ticket: la API repite sola la orden que choca con otra (por ejemplo, con una saga), en
lugar de devolver un 409.
- Vistas guardadas en «Mi trabajo»: filtros propios entre proyectos (proyectos, asignados a mí, que sigo, estados,
prioridades, solo bugs, vencidos, texto), en una barra encima de «Mi trabajo».
- Definición de «Hecho» por proyecto: con ella activada, un ticket no se cierra con la checklist a medias (el
propietario y los admins sí pueden).
- Archivar un proyecto: queda en solo lectura (se ve todo, no cambia nada) hasta que su propietario lo desarchiva.
- Equipos dentro del proyecto: grupos de miembros en Configuración; los tickets se asignan a uno, la lista filtra por
equipo y «@equipo» avisa a todos sus miembros.
- Presencia: en cada ticket se ve quién más lo está viendo o editando en ese momento.
- Rol «invitado»: para clientes o gente de fuera; en un proyecto privado solo ve los tickets en los que participa,
abre tickets y comenta en los suyos.
- Vista de cartera (`/portfolio`): todos tus proyectos activos de un vistazo, con su riesgo, sprint, releases y épicas.
- Una portada con vida: saludo y resumen del día, la semana que viene, lo último en tus proyectos, quién está ahora,
la semana en cifras (tuya y del equipo, con racha), sprints en curso, «Retomar», tarjetas de proyecto con actividad,
qué coger cuando no tienes nada y primeros pasos para quien empieza. También por MCP (`get_my_week`).
- Panel del proyecto, cuarta parte: actividad de 12 semanas en un mapa de calor, el recorrido de un ticket (dónde se
va el tiempo de creado a hecho, espera incluida, y la parte que es trabajo) y el embudo de propuestas del público
con las más votadas sin respuesta.
- Modo pantalla del panel: a pantalla completa para una tele del equipo, un bloque cada vez que cambia solo, en
directo y con la hora.
- Panel para clientes: un enlace de solo lectura, sin cuenta, con los bloques que elijan el propietario y los admins
(flujo, sprint, releases, épicas, actividad); se renueva o se retira desde Configuración.
- De escritura a lectura: si un evento no se puede proyectar, se reintenta en su sitio (el siguiente espera) hasta
que Mongo vuelve, sin reiniciar ni perder cambios; los que fallan por sí mismos se apartan a
`task-stream:events:dead`, y `GET /api/health` avisa de eventos atascados o apartados.

View File

@@ -4,7 +4,7 @@ TaskStream es una alternativa a Jira para equipos de desarrollo: proyectos, tick
tareas, QA), releases, comentarios y relaciones entre tickets, todo en tiempo real. tareas, QA), releases, comentarios y relaciones entre tickets, todo en tiempo real.
| Documento | Para qué | | Documento | Para qué |
| ----------------------------------------- | ------------------------------------------------------------------------- | | ----------------------------------------- | ----------------------------------------------------------------------------------- |
| [1. Visión general](01-vision-general.md) | Qué hace el producto, stack y estructura del monorepo | | [1. Visión general](01-vision-general.md) | Qué hace el producto, stack y estructura del monorepo |
| [2. Arquitectura](02-arquitectura.md) | Hexagonal, CQRS, event sourcing, flujo escritura → lectura y tiempo real | | [2. Arquitectura](02-arquitectura.md) | Hexagonal, CQRS, event sourcing, flujo escritura → lectura y tiempo real |
| [3. API](03-api.md) | Dominio (agregados, workflow, eventos) y la API GraphQL | | [3. API](03-api.md) | Dominio (agregados, workflow, eventos) y la API GraphQL |
@@ -14,6 +14,7 @@ tareas, QA), releases, comentarios y relaciones entre tickets, todo en tiempo re
| [7. Despliegue](07-despliegue.md) | Docker, variables de entorno y escalado | | [7. Despliegue](07-despliegue.md) | Docker, variables de entorno y escalado |
| [8. Decisiones](08-decisiones.md) | Por qué está hecho así (y qué alternativas se descartaron) | | [8. Decisiones](08-decisiones.md) | Por qué está hecho así (y qué alternativas se descartaron) |
| [9. Servidor MCP](09-mcp.md) | Usar TaskStream desde asistentes de IA (Claude…): herramientas y conexión | | [9. Servidor MCP](09-mcp.md) | Usar TaskStream desde asistentes de IA (Claude…): herramientas y conexión |
| [10. Lo que queda](10-futuro.md) | Pendiente para cerrar, fallos conocidos, siguientes funcionalidades y lo descartado |
Cada librería de `libs/` tiene además su propio `README.md` con su responsabilidad y su contenido. Cada librería de `libs/` tiene además su propio `README.md` con su responsabilidad y su contenido.

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

@@ -16,20 +16,20 @@ export class MongoUserRegistry extends UserRegistry {
async upsert(user: User): Promise<void> { async upsert(user: User): Promise<void> {
await this.users.updateOne( await this.users.updateOne(
{ _id: user.id }, { id: user.id },
{ $set: { name: user.name, email: user.email, isActive: user.isActive } }, { $set: { name: user.name, email: user.email, isActive: user.isActive } },
{ upsert: true }, { upsert: true },
); );
} }
async findById(id: string): Promise<User | null> { async findById(id: string): Promise<User | null> {
const doc = await this.users.findById(id).lean(); const doc = await this.users.findOne({ id }).lean();
return doc ? this.toUser(doc) : null; return doc ? this.toUser(doc) : null;
} }
async findByIds(ids: string[]): Promise<User[]> { async findByIds(ids: string[]): Promise<User[]> {
if (ids.length === 0) return []; if (ids.length === 0) return [];
const docs = await this.users.find({ _id: { $in: ids } }).lean(); const docs = await this.users.find({ id: { $in: ids } }).lean();
return docs.map((d) => this.toUser(d)); return docs.map((d) => this.toUser(d));
} }
@@ -48,7 +48,7 @@ export class MongoUserRegistry extends UserRegistry {
private toUser(doc: UserDocument): User { private toUser(doc: UserDocument): User {
return this.factory.restore({ return this.factory.restore({
id: doc._id, id: doc.id,
name: doc.name, name: doc.name,
email: doc.email, email: doc.email,
isActive: doc.isActive, isActive: doc.isActive,

View File

@@ -2,8 +2,9 @@ import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
@Schema({ collection: 'users', timestamps: true, versionKey: false }) @Schema({ collection: 'users', timestamps: true, versionKey: false })
export class UserDocument { export class UserDocument {
@Prop({ type: String, required: true }) /** The user's id at Pireon; `_id` is Mongo's own ObjectId. */
_id!: string; @Prop({ type: String, required: true, unique: true })
id!: string;
@Prop({ required: true }) @Prop({ required: true })
name!: string; name!: string;

View File

@@ -0,0 +1,48 @@
import { UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { IdentityService } from '@task-stream/api/identity/application';
import { createHmac } from 'node:crypto';
import { PireonWebhookController } from './pireon-webhook.controller';
describe('PireonWebhookController', () => {
const handled: string[] = [];
const controller = (secrets: Record<string, string>) =>
new PireonWebhookController(
{
handleWebhook: async (event: string) => {
handled.push(event);
},
} as unknown as IdentityService,
{ get: (key: string) => secrets[key] } as unknown as ConfigService,
);
const body = { event: 'token.revoked', data: { token_id: 't-1' } };
const raw = Buffer.from(JSON.stringify(body));
const signed = (secret: string) =>
createHmac('sha256', secret).update(raw).digest('hex');
const request = { rawBody: raw, body } as never;
beforeEach(() => (handled.length = 0));
it("accepts a delivery signed with either Pireon client's secret", async () => {
const both = controller({
PIREON_WEBHOOK_SECRET: 'web-secret',
PIREON_PASSWORD_WEBHOOK_SECRET: 'password-secret',
});
await both.receive(request, signed('web-secret'));
await both.receive(request, `sha256=${signed('password-secret')}`);
expect(handled).toEqual(['token.revoked', 'token.revoked']);
});
it('refuses any other signature, and everything when no secret is set', async () => {
await expect(
controller({ PIREON_WEBHOOK_SECRET: 'web-secret' }).receive(
request,
signed('password-secret'),
),
).rejects.toThrow(UnauthorizedException);
await expect(
controller({}).receive(request, signed('web-secret')),
).rejects.toThrow(/disabled/);
expect(handled).toEqual([]);
});
});

View File

@@ -21,7 +21,13 @@ import { Request } from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto'; import { createHmac, timingSafeEqual } from 'node:crypto';
import { Public } from '../auth/auth.decorators'; import { Public } from '../auth/auth.decorators';
/** Receives `user.deactivated` / `token.revoked` from Pireon. */ /**
* Receives `user.deactivated` / `token.revoked` from Pireon.
*
* Pireon signs each webhook with the secret of the client that registered
* it, and this API has two (the web app's and the password grant's): a
* delivery is good if it verifies with either.
*/
@ApiTags('Webhooks') @ApiTags('Webhooks')
@Controller('webhooks/pireon') @Controller('webhooks/pireon')
export class PireonWebhookController { export class PireonWebhookController {
@@ -37,11 +43,12 @@ export class PireonWebhookController {
summary: 'Pireon events (called by Pireon, not by apps)', summary: 'Pireon events (called by Pireon, not by apps)',
description: description:
"`user.deactivated` closes the user's sessions; `token.revoked` forgets the token. " + "`user.deactivated` closes the user's sessions; `token.revoked` forgets the token. " +
'Disabled unless PIREON_WEBHOOK_SECRET is set.', 'Disabled unless PIREON_WEBHOOK_SECRET or PIREON_PASSWORD_WEBHOOK_SECRET is set.',
}) })
@ApiHeader({ @ApiHeader({
name: 'X-Webhook-Signature', name: 'X-Webhook-Signature',
description: 'HMAC-SHA256 of the raw body with PIREON_WEBHOOK_SECRET (hex)', description:
'HMAC-SHA256 of the raw body with the secret of the Pireon client that registered the webhook (hex)',
required: true, required: true,
}) })
@ApiNoContentResponse({ description: 'Processed' }) @ApiNoContentResponse({ description: 'Processed' })
@@ -63,13 +70,16 @@ export class PireonWebhookController {
await this.identity.handleWebhook(event, data); await this.identity.handleWebhook(event, data);
} }
/** X-Webhook-Signature = HMAC-SHA256(raw body, PIREON_WEBHOOK_SECRET). */ /** X-Webhook-Signature = HMAC-SHA256(raw body, the client's webhook secret). */
private verifySignature( private verifySignature(
rawBody: Buffer | undefined, rawBody: Buffer | undefined,
signature: string | undefined, signature: string | undefined,
): void { ): void {
const secret = this.config.get<string>('PIREON_WEBHOOK_SECRET'); const secrets = [
if (!secret) { this.config.get<string>('PIREON_WEBHOOK_SECRET'),
this.config.get<string>('PIREON_PASSWORD_WEBHOOK_SECRET'),
].filter((secret): secret is string => !!secret);
if (!secrets.length) {
throw new UnauthorizedException( throw new UnauthorizedException(
'Webhooks are disabled (PIREON_WEBHOOK_SECRET not set)', 'Webhooks are disabled (PIREON_WEBHOOK_SECRET not set)',
); );
@@ -77,11 +87,16 @@ export class PireonWebhookController {
if (!rawBody || !signature) if (!rawBody || !signature)
throw new UnauthorizedException('Missing signature'); throw new UnauthorizedException('Missing signature');
const expected = createHmac('sha256', secret).update(rawBody).digest('hex'); const received = Buffer.from(signature.replace(/^sha256=/, ''));
const received = signature.replace(/^sha256=/, ''); const valid = secrets.some((secret) => {
const valid = const expected = Buffer.from(
createHmac('sha256', secret).update(rawBody).digest('hex'),
);
return (
received.length === expected.length && received.length === expected.length &&
timingSafeEqual(Buffer.from(received), Buffer.from(expected)); timingSafeEqual(received, expected)
);
});
if (!valid) throw new UnauthorizedException('Invalid signature'); if (!valid) throw new UnauthorizedException('Invalid signature');
} }
} }

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

@@ -12,6 +12,9 @@ Event store en PostgreSQL y el camino de los eventos hasta la lectura.
- `EventStoreRelay`: copia los eventos nuevos al Redis Stream (`FOR UPDATE SKIP LOCKED`). - `EventStoreRelay`: copia los eventos nuevos al Redis Stream (`FOR UPDATE SKIP LOCKED`).
- `EventsBridge`: consume el stream y entrega cada evento a sus `@EventsHandler`, en orden y esperando (ACK al final); - `EventsBridge`: consume el stream y entrega cada evento a sus `@EventsHandler`, en orden y esperando (ACK al final);
después ejecuta los `@Saga()` (el `EventBus` en memoria nunca ve los eventos guardados) y sus comandos, uno a uno. después ejecuta los `@Saga()` (el `EventBus` en memoria nunca ve los eventos guardados) y sus comandos, uno a uno.
- `EventStreamConsumer`: reintenta en su sitio el evento que falla (el siguiente espera); los fallos de
infraestructura (`isTransientError`) sin límite, los del propio evento 5 veces y luego a `<stream>:dead`.
- `EventPipelineStatus`: bandeja de salida, pendientes y apartados, para `GET /api/health`.
- Conexiones Redis (`REDIS_CLIENT`, `GRAPHQL_PUB_SUB`) y `SharedInfrastructureModule.forRootAsync`. - Conexiones Redis (`REDIS_CLIENT`, `GRAPHQL_PUB_SUB`) y `SharedInfrastructureModule.forRootAsync`.
Flujo completo: [Arquitectura](../../../../docs/02-arquitectura.md#22-flujo-completo-de-una-escritura). Flujo completo: [Arquitectura](../../../../docs/02-arquitectura.md#22-flujo-completo-de-una-escritura).

View File

@@ -6,3 +6,4 @@ export * from './lib/event-stream/event-stream.consumer';
export * from './lib/event-store/entities/event.entity'; export * from './lib/event-store/entities/event.entity';
export * from './lib/event-store/deserializers/event.deserializer'; export * from './lib/event-store/deserializers/event.deserializer';
export * from './lib/event-store/serializers/event.serializer'; export * from './lib/event-store/serializers/event.serializer';
export * from './lib/event-store/event-pipeline.status';

View File

@@ -2,6 +2,7 @@ import {
Column, Column,
CreateDateColumn, CreateDateColumn,
Entity, Entity,
Generated,
Index, Index,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
Unique, Unique,
@@ -13,12 +14,21 @@ import {
*/ */
@Entity('events') @Entity('events')
@Unique('uq_events_stream_position', ['streamId', 'position']) @Unique('uq_events_stream_position', ['streamId', 'position'])
@Index('idx_events_unpublished', ['id'], { where: '"published_at" IS NULL' }) @Index('idx_events_unpublished', ['seq'], { where: '"published_at" IS NULL' })
export class EventEntity { export class EventEntity {
/** Global order of the store, used to relay events in sequence. */ /** The event's own id. */
@PrimaryGeneratedColumn('increment', { type: 'bigint' }) @PrimaryGeneratedColumn('uuid')
id!: string; id!: string;
/**
* Global order of the store, used to relay events in sequence. A uuid has
* no reliable order, so it is kept apart.
*/
@Index('uq_events_seq', { unique: true })
@Column({ type: 'bigint' })
@Generated('increment')
seq!: string;
@Column({ name: 'stream_id', length: 64 }) @Column({ name: 'stream_id', length: 64 })
streamId!: string; streamId!: string;

View File

@@ -0,0 +1,66 @@
import { Inject, Injectable } from '@nestjs/common';
import Redis from 'ioredis';
import { DataSource } from 'typeorm';
import { deadLetterKey } from '../event-stream/event-stream.consumer';
import {
EVENT_STREAM_OPTIONS,
EventStreamOptions,
} from '../event-stream/event-stream.options';
import { REDIS_CLIENT } from '../redis/redis.tokens';
import { EVENTS_BRIDGE_GROUP } from './events-bridge';
/** How write reaches read, for the health check. */
export interface EventPipelineReading {
/** Events in Postgres not yet copied to the Redis stream (the outbox). */
unpublished: number;
/** Seconds the oldest of them has waited (null: none). */
oldestUnpublishedSeconds: number | null;
/** Entries handed to the read side and not yet projected (the inbox). */
pending: number;
/** Events set aside after failing again and again. */
dead: number;
}
/**
* Reads the backlog at each step from the event store to the read model:
* a growing one means the read side is falling behind.
*/
@Injectable()
export class EventPipelineStatus {
constructor(
private readonly dataSource: DataSource,
@Inject(REDIS_CLIENT) private readonly redis: Redis,
@Inject(EVENT_STREAM_OPTIONS) private readonly stream: EventStreamOptions,
) {}
async read(): Promise<EventPipelineReading> {
const [outbox, pending, dead] = await Promise.all([
this.dataSource.query(
`SELECT count(*)::int AS count,
EXTRACT(EPOCH FROM now() - min(created_at))::int AS age
FROM events WHERE published_at IS NULL`,
) as Promise<{ count: number; age: number | null }[]>,
this.pending(),
this.redis.xlen(deadLetterKey(this.stream.streamKey)),
]);
return {
unpublished: outbox[0]?.count ?? 0,
oldestUnpublishedSeconds: outbox[0]?.age ?? null,
pending,
dead,
};
}
/** XPENDING's summary; no group yet (nothing consumed) means none. */
private async pending(): Promise<number> {
try {
const summary = (await this.redis.xpending(
this.stream.streamKey,
EVENTS_BRIDGE_GROUP,
)) as [number, ...unknown[]];
return Number(summary[0] ?? 0);
} catch {
return 0;
}
}
}

View File

@@ -73,7 +73,7 @@ export class EventStoreRelay
.getRepository(EventEntity) .getRepository(EventEntity)
.createQueryBuilder('e') .createQueryBuilder('e')
.where('e.published_at IS NULL') .where('e.published_at IS NULL')
.orderBy('e.id', 'ASC') .orderBy('e.seq', 'ASC')
.limit(BATCH_SIZE) .limit(BATCH_SIZE)
.setLock('pessimistic_write') .setLock('pessimistic_write')
.setOnLocked('skip_locked') .setOnLocked('skip_locked')

View File

@@ -30,6 +30,8 @@ import { RedisConnectionFactory } from '../redis/redis-connection.factory';
import { EventDeserializer } from './deserializers/event.deserializer'; import { EventDeserializer } from './deserializers/event.deserializer';
export const EVENTS_BRIDGE_ENABLED = Symbol('EVENTS_BRIDGE_ENABLED'); export const EVENTS_BRIDGE_ENABLED = Symbol('EVENTS_BRIDGE_ENABLED');
/** The Redis consumer group of the read side. */
export const EVENTS_BRIDGE_GROUP = 'events-bridge';
/** Metadata written by @EventsHandler(...events) and @Saga() (@nestjs/cqrs). */ /** Metadata written by @EventsHandler(...events) and @Saga() (@nestjs/cqrs). */
const EVENTS_HANDLER_METADATA = '__eventsHandler__'; const EVENTS_HANDLER_METADATA = '__eventsHandler__';
@@ -84,7 +86,7 @@ export class EventsBridge
this.connections.create('events-bridge'), this.connections.create('events-bridge'),
{ {
streamKey: this.stream.streamKey, streamKey: this.stream.streamKey,
group: 'events-bridge', group: EVENTS_BRIDGE_GROUP,
consumer: hostname(), consumer: hostname(),
}, },
(event) => this.handleEvent(event), (event) => this.handleEvent(event),

View File

@@ -0,0 +1,178 @@
import { SerializableEvent } from '@task-stream/api/shared/domain';
import Redis from 'ioredis';
import { deadLetterKey, EventStreamConsumer } from './event-stream.consumer';
import { isTransientError } from './transient-error';
const KEY = 'test:events';
/** Just enough of a Redis stream with a consumer group. */
class FakeRedis {
acked: string[] = [];
dead: Record<string, string>[] = [];
private handedOut = false;
constructor(private readonly entries: [string, string[]][]) {}
async xgroup() {
return 'OK';
}
async xautoclaim() {
return ['0-0', []];
}
async xreadgroup(...args: string[]) {
const cursor = args[args.length - 1];
if (cursor === '>' && !this.handedOut) {
this.handedOut = true;
return [[KEY, this.entries]];
}
await new Promise((resolve) => setTimeout(resolve, 2));
return null;
}
async xack(_key: string, _group: string, id: string) {
this.acked.push(id);
return 1;
}
async xadd(key: string, _id: string, ...fields: string[]) {
const entry: Record<string, string> = { key };
for (let i = 0; i < fields.length; i += 2) entry[fields[i]] = fields[i + 1];
this.dead.push(entry);
return '1-0';
}
disconnect() {
return undefined;
}
}
const entry = (id: string, position: number): [string, string[]] => [
id,
[
'event',
JSON.stringify({ streamId: 's', type: 'Moved', position, data: {} }),
],
];
async function until(done: () => boolean) {
const deadline = Date.now() + 2000;
while (!done()) {
if (Date.now() > deadline) throw new Error('timed out');
await new Promise((resolve) => setTimeout(resolve, 2));
}
}
function consumer(
redis: FakeRedis,
handle: (event: SerializableEvent) => Promise<void>,
) {
return new EventStreamConsumer(
redis as unknown as Redis,
{
streamKey: KEY,
group: 'g',
consumer: 'c',
retryBaseMs: 1,
maxRetryMs: 2,
maxAttempts: 3,
},
handle,
);
}
const mongoDown = () =>
Object.assign(new Error('connection refused'), {
name: 'MongoNetworkError',
});
describe('EventStreamConsumer', () => {
it('retries a failing event in place: the next one waits for it', async () => {
const redis = new FakeRedis([entry('1-0', 1), entry('2-0', 2)]);
const handled: number[] = [];
let failures = 2;
const c = consumer(redis, async (event) => {
if (event.position === 1 && failures-- > 0) throw mongoDown();
handled.push(event.position);
});
await c.start();
await until(() => redis.acked.length === 2);
await c.stop();
expect(handled).toEqual([1, 2]);
expect(redis.acked).toEqual(['1-0', '2-0']);
expect(redis.dead).toEqual([]);
});
it('waits out the infrastructure however long: it never sets it aside', async () => {
const redis = new FakeRedis([entry('1-0', 1)]);
let failures = 10; // well past maxAttempts
const c = consumer(redis, async () => {
if (failures-- > 0) throw mongoDown();
});
await c.start();
await until(() => redis.acked.length === 1);
await c.stop();
expect(redis.dead).toEqual([]);
});
it('sets aside an event that keeps failing on its own, and goes on', async () => {
const redis = new FakeRedis([entry('1-0', 1), entry('2-0', 2)]);
const handled: number[] = [];
let calls = 0;
const c = consumer(redis, async (event) => {
if (event.position === 1) {
calls++;
throw new Error('Cannot read properties of undefined');
}
handled.push(event.position);
});
await c.start();
await until(() => redis.acked.length === 2);
await c.stop();
expect(calls).toBe(3);
expect(handled).toEqual([2]);
expect(redis.dead).toHaveLength(1);
expect(redis.dead[0]).toMatchObject({
key: deadLetterKey(KEY),
attempts: '3',
sourceId: '1-0',
error: 'Cannot read properties of undefined',
});
expect(JSON.parse(redis.dead[0]['event'])).toMatchObject({ position: 1 });
});
it('stopping while it retries leaves the event pending', async () => {
const redis = new FakeRedis([entry('1-0', 1)]);
let calls = 0;
const c = consumer(redis, async () => {
calls++;
throw mongoDown();
});
await c.start();
await until(() => calls >= 2);
await c.stop();
expect(redis.acked).toEqual([]);
expect(redis.dead).toEqual([]);
});
});
describe('isTransientError', () => {
it('tells the infrastructure being down from a fault of its own', () => {
expect(isTransientError(mongoDown())).toBe(true);
expect(
isTransientError(Object.assign(new Error('x'), { code: 'ECONNRESET' })),
).toBe(true);
expect(
isTransientError(
Object.assign(new Error('write failed'), {
cause: Object.assign(new Error('y'), {
name: 'MongoServerSelectionError',
}),
}),
),
).toBe(true);
expect(isTransientError(new Error('Connection is closed.'))).toBe(true);
expect(isTransientError(new TypeError('x is undefined'))).toBe(false);
expect(isTransientError(new Error('E11000 duplicate key'))).toBe(false);
});
});

View File

@@ -1,6 +1,7 @@
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import Redis from 'ioredis'; import Redis from 'ioredis';
import { SerializableEvent } from '@task-stream/api/shared/domain'; import { SerializableEvent } from '@task-stream/api/shared/domain';
import { isTransientError } from './transient-error';
type StreamEntry = [id: string, fields: string[]]; type StreamEntry = [id: string, fields: string[]];
type StreamReply = [stream: string, entries: StreamEntry[]][] | null; type StreamReply = [stream: string, entries: StreamEntry[]][] | null;
@@ -11,17 +12,35 @@ export interface EventStreamConsumerOptions {
consumer: string; consumer: string;
batchSize?: number; batchSize?: number;
blockMs?: number; blockMs?: number;
/** First wait before retrying an event; it doubles up to `maxRetryMs`. */
retryBaseMs?: number;
maxRetryMs?: number;
/** Attempts of an event that keeps failing for a reason of its own. */
maxAttempts?: number;
} }
/** Where events that keep failing go, next to the stream: `<key>:dead`. */
export const deadLetterKey = (streamKey: string) => `${streamKey}:dead`;
/** /**
* Reads the event stream with a Redis consumer group. Entries are ACKed only * Reads the event stream with a Redis consumer group (the read side's inbox).
* after the handler succeeds; failed ones stay pending and are retried on * Entries are ACKed only after the handler succeeds.
*
* An event that fails is retried in place, waiting longer each time, and the
* next one waits for it: skipping ahead would apply a later change first, and
* the version check of the projections would then drop the earlier one for
* good. When the infrastructure is down (Mongo, Redis, the network) it waits
* as long as it takes; when the event itself keeps failing, after
* `maxAttempts` it goes to the dead-letter stream (`<key>:dead`) so that it
* does not hold up the rest. Entries still pending at a stop are retried on
* the next start (or by another consumer of the group). * the next start (or by another consumer of the group).
*/ */
export class EventStreamConsumer { export class EventStreamConsumer {
private readonly logger: Logger; private readonly logger: Logger;
private running = false; private running = false;
private loop?: Promise<void>; private loop?: Promise<void>;
/** Ends the current wait between retries (on stop). */
private wake?: () => void;
constructor( constructor(
private readonly redis: Redis, private readonly redis: Redis,
@@ -39,6 +58,7 @@ export class EventStreamConsumer {
async stop(): Promise<void> { async stop(): Promise<void> {
this.running = false; this.running = false;
this.wake?.();
this.redis.disconnect(); this.redis.disconnect();
await this.loop?.catch(() => undefined); await this.loop?.catch(() => undefined);
} }
@@ -108,15 +128,98 @@ export class EventStreamConsumer {
return; return;
} }
const event = JSON.parse(fields[index + 1]) as SerializableEvent; const event = JSON.parse(fields[index + 1]) as SerializableEvent;
const name = `${event.type} (${event.streamId}#${event.position})`;
const maxAttempts = this.options.maxAttempts ?? 5;
for (let attempt = 1; this.running; attempt++) {
try { try {
await this.handle(event); await this.handle(event);
await this.redis.xack(this.options.streamKey, this.options.group, id); await this.redis.xack(this.options.streamKey, this.options.group, id);
if (attempt > 1)
this.logger.log(`${name} handled on attempt ${attempt}`);
return;
} catch (error) { } catch (error) {
// Left pending: it will be retried after a restart. const transient = isTransientError(error);
if (!transient && attempt >= maxAttempts) {
if (
await this.deadLetter(id, fields[index + 1], name, error, attempt)
)
return;
}
const wait = this.backoff(attempt);
const reason = (error as Error).message;
if (attempt === 1)
this.logger.error( this.logger.error(
`Failed to handle ${event.type} (${event.streamId}#${event.position}): ${(error as Error).message}`, `Failed to handle ${name}: ${reason}; retrying in ${wait} ms`,
(error as Error).stack, (error as Error).stack,
); );
else
this.logger.warn(
`${name} still failing (attempt ${attempt}${transient ? ', infrastructure' : `/${maxAttempts}`}): ${reason}; retrying in ${wait} ms`,
);
await this.sleep(wait);
} }
} }
// Stopped while retrying: it stays pending for the next start.
}
/**
* Moves an event that keeps failing aside, with why, and lets the stream
* go on. False if even that failed (Redis down): it is then retried.
*/
private async deadLetter(
id: string,
payload: string,
name: string,
error: unknown,
attempts: number,
): Promise<boolean> {
try {
await this.redis.xadd(
deadLetterKey(this.options.streamKey),
'*',
'event',
payload,
'error',
(error as Error).message ?? String(error),
'attempts',
String(attempts),
'group',
this.options.group,
'sourceId',
id,
'failedAt',
new Date().toISOString(),
);
await this.redis.xack(this.options.streamKey, this.options.group, id);
this.logger.error(
`${name} failed ${attempts} times and went to ${deadLetterKey(this.options.streamKey)}: ${(error as Error).message}`,
);
return true;
} catch (failure) {
this.logger.error(
`Could not set ${name} aside: ${(failure as Error).message}`,
);
return false;
}
}
private backoff(attempt: number): number {
const base = this.options.retryBaseMs ?? 500;
return Math.min(
this.options.maxRetryMs ?? 30_000,
base * 2 ** (attempt - 1),
);
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
const timer = setTimeout(done, ms);
function done() {
clearTimeout(timer);
resolve();
}
this.wake = done;
});
}
} }

View File

@@ -0,0 +1,36 @@
/** Errors of the infrastructure itself: waiting is the cure, not skipping. */
const TRANSIENT_NAMES =
/^(Mongo(Network|NetworkTimeout|ServerSelection|NotConnected|TopologyClosed|PoolCleared)Error|MaxRetriesPerRequestError)$/;
const TRANSIENT_CODES = new Set([
'ECONNREFUSED',
'ECONNRESET',
'ETIMEDOUT',
'EPIPE',
'EHOSTUNREACH',
'ENETUNREACH',
'ENOTFOUND',
'EAI_AGAIN',
]);
const TRANSIENT_MESSAGES =
/connection (is )?closed|connection refused|timed out|getaddrinfo|socket hang up/i;
/**
* Whether an error comes from a database or the network being down (and
* will go away by itself) rather than from the event or the code that
* handles it. Follows `cause`, as drivers wrap the original error.
*/
export function isTransientError(error: unknown): boolean {
for (let current = error, depth = 0; current && depth < 5; depth++) {
const e = current as {
name?: string;
code?: string;
message?: string;
cause?: unknown;
};
if (e.name && TRANSIENT_NAMES.test(e.name)) return true;
if (e.code && TRANSIENT_CODES.has(e.code)) return true;
if (e.message && TRANSIENT_MESSAGES.test(e.message)) return true;
current = e.cause;
}
return false;
}

View File

@@ -6,6 +6,7 @@ import { EventStore } from '@task-stream/api/shared/application';
import { RedisPubSub } from 'graphql-redis-subscriptions'; import { RedisPubSub } from 'graphql-redis-subscriptions';
import { EventDeserializer } from './event-store/deserializers/event.deserializer'; import { EventDeserializer } from './event-store/deserializers/event.deserializer';
import { EventEntity } from './event-store/entities/event.entity'; import { EventEntity } from './event-store/entities/event.entity';
import { EventPipelineStatus } from './event-store/event-pipeline.status';
import { EventStoreRelay } from './event-store/event-store.relay'; import { EventStoreRelay } from './event-store/event-store.relay';
import { EventStoreSignal } from './event-store/event-store.signal'; import { EventStoreSignal } from './event-store/event-store.signal';
import { import {
@@ -103,6 +104,7 @@ export class SharedInfrastructureModule {
EventStreamPublisher, EventStreamPublisher,
EventStoreRelay, EventStoreRelay,
EventsBridge, EventsBridge,
EventPipelineStatus,
], ],
exports: [ exports: [
EventStore, EventStore,
@@ -111,6 +113,7 @@ export class SharedInfrastructureModule {
REDIS_CLIENT, REDIS_CLIENT,
GRAPHQL_PUB_SUB, GRAPHQL_PUB_SUB,
EVENT_STREAM_OPTIONS, EVENT_STREAM_OPTIONS,
EventPipelineStatus,
], ],
}; };
} }

View File

@@ -7,4 +7,56 @@ export * from './lib/comments.service';
export * from './lib/releases.service'; export * from './lib/releases.service';
export * from './lib/changes.service'; export * from './lib/changes.service';
export * from './lib/workflows.service'; export * from './lib/workflows.service';
export * from './lib/blockers.service';
export * from './lib/bulk-change';
export * from './lib/proposals';
export * from './lib/proposal-quota.service';
export * from './lib/notifications.service';
export * from './lib/notifications';
export * from './lib/saved-filters.service';
export * from './lib/sprints.service';
export * from './lib/git.service';
export * from './lib/webhooks.service';
export * from './lib/transfer.service';
export * from './lib/automations.service';
export * from './lib/calendar.service';
export * from './lib/calendar/calendar';
export * from './lib/sharing.service';
export * from './lib/sharing/dashboard-shares';
export * from './lib/automations/automations';
export * from './lib/automations/automation-rules';
export * from './lib/transfer/import-export';
export * from './lib/webhooks/webhooks';
export * from './lib/webhooks/webhook-events';
export * from './lib/git/git';
export * from './lib/git/git-payloads';
export * from './lib/saved-filters/saved-filters';
export * from './lib/saved-filters/my-views';
export * from './lib/ports/my-views.repository';
export * from './lib/saved-filters/pins';
export * from './lib/ports/pins.repository';
export * from './lib/pins.service';
export * from './lib/alerts/alert-payloads';
export * from './lib/alerts/alerts';
export * from './lib/ports/alert-integrations.repository';
export * from './lib/alerts.service';
export * from './lib/follows/tree-follows';
export * from './lib/ports/tree-follows.repository';
export * from './lib/tree-follows.service';
export * from './lib/visits/issue-visits';
export * from './lib/ports/issue-visits.repository';
export * from './lib/issue-visits.service';
export * from './lib/reminders/issue-reminders';
export * from './lib/ports/issue-reminders.repository';
export * from './lib/issue-reminders.service';
export * from './lib/my-day/my-day';
export * from './lib/ports/my-days.repository';
export * from './lib/my-day.service';
export * from './lib/commands/reviews';
export * from './lib/commands/polls';
export * from './lib/commands/turn-comment-into-issue';
export * from './lib/sharing/release-notes-shares';
export * from './lib/ports/release-note-shares.repository';
export * from './lib/tracker-application.module'; export * from './lib/tracker-application.module';
export * from './lib/ports/presence.store';
export * from './lib/presence.service';

View File

@@ -0,0 +1,86 @@
import { timingSafeEqual } from 'node:crypto';
import { CommandRunner } from '@task-stream/api/shared/application';
import { Injectable } from '@nestjs/common';
import { QueryBus } from '@nestjs/cqrs';
import { ForbiddenError } from '@task-stream/api/shared/domain';
import { alertOf } from './alerts/alert-payloads';
import {
AlertIntegration,
AlertOutcome,
AlertSettings,
DisableAlertIntegrationCommand,
GetAlertIntegrationQuery,
ReceiveAlertCommand,
RotateAlertTokenCommand,
SaveAlertIntegrationCommand,
} from './alerts/alerts';
import { AlertIntegrationsRepository } from './ports/alert-integrations.repository';
import { FindProjectsRepository } from './ports/find-projects.repository';
/** What a monitoring tool sent to a project's alert URL. */
export interface AlertDelivery {
/** Project id or key, from the URL. */
project: string;
token: string;
body: unknown;
headers: Record<string, string | undefined>;
}
/** Alerts → bugs: the project's secret URL, and what arrives through it. */
@Injectable()
export class AlertsService {
constructor(
private readonly commands: CommandRunner,
private readonly queryBus: QueryBus,
private readonly findProjects: FindProjectsRepository,
private readonly integrations: AlertIntegrationsRepository,
) {}
get(viewerId: string, projectId: string): Promise<AlertIntegration> {
return this.queryBus.execute(
new GetAlertIntegrationQuery(viewerId, projectId),
);
}
save(
actorId: string,
projectId: string,
settings: AlertSettings,
): Promise<AlertIntegration> {
return this.commands.execute(
new SaveAlertIntegrationCommand(actorId, projectId, settings),
);
}
rotateToken(actorId: string, projectId: string): Promise<AlertIntegration> {
return this.commands.execute(
new RotateAlertTokenCommand(actorId, projectId),
);
}
disable(actorId: string, projectId: string): Promise<void> {
return this.commands.execute(
new DisableAlertIntegrationCommand(actorId, projectId),
);
}
/** Lets an alert in only with the project's current token. */
async receive(delivery: AlertDelivery): Promise<AlertOutcome> {
const project =
(await this.findProjects.findById(delivery.project)) ??
(await this.findProjects.findByKey(delivery.project.toUpperCase()));
const config = project ? await this.integrations.find(project.id) : null;
// One answer for an unknown project, the integration off or a bad token.
if (!project || !config || !sameToken(config.token, delivery.token))
throw new ForbiddenError('Unknown project, alerts off or bad token');
const alert = alertOf(delivery.body, delivery.headers);
if (!alert) return { action: 'ignored', issueKey: null };
return this.commands.execute(new ReceiveAlertCommand(project.id, alert));
}
}
function sameToken(expected: string, given: string): boolean {
const a = Buffer.from(expected);
const b = Buffer.from(given ?? '');
return a.length === b.length && timingSafeEqual(a, b);
}

View File

@@ -0,0 +1,112 @@
import { alertOf } from './alert-payloads';
describe('alertOf', () => {
it('reads a Sentry issue (internal integration), resolved too', () => {
const body = {
action: 'created',
data: {
issue: {
id: '42',
title: 'TypeError: x is undefined',
culprit: 'app/checkout.ts',
permalink: 'https://sentry.io/organizations/acme/issues/42/',
level: 'error',
},
},
};
const alert = alertOf(body, { 'sentry-hook-resource': 'issue' });
expect(alert).toMatchObject({
source: 'sentry',
fingerprint: 'sentry:42',
title: 'TypeError: x is undefined',
url: 'https://sentry.io/organizations/acme/issues/42/',
resolved: false,
});
expect(alert?.details).toContain('app/checkout.ts');
expect(
alertOf(
{ ...body, action: 'resolved' },
{ 'sentry-hook-resource': 'issue' },
)?.resolved,
).toBe(true);
});
it('reads a Sentry issue alert and the legacy webhook', () => {
expect(
alertOf(
{
action: 'triggered',
data: {
event: { issue_id: '7', title: 'Boom', web_url: 'https://s.io/7' },
triggered_rule: 'Errores nuevos',
},
},
{ 'sentry-hook-resource': 'event_alert' },
),
).toMatchObject({ fingerprint: 'sentry:7', title: 'Boom' });
expect(
alertOf({
id: '99',
project_name: 'web',
message: 'Boom',
url: 'https://s.io/99',
event: { title: 'ReferenceError: y' },
}),
).toMatchObject({ fingerprint: 'sentry:99', title: 'ReferenceError: y' });
});
it('reads Uptime Kuma: down opens, up resolves', () => {
const down = {
heartbeat: { status: 0, msg: 'timeout', time: '2026-10-17 10:00' },
monitor: { id: 3, name: 'API', url: 'https://api.acme.dev' },
msg: '[API] [🔴 Down] timeout',
};
expect(alertOf(down)).toMatchObject({
source: 'uptime-kuma',
fingerprint: 'uptime-kuma:3',
title: 'API no responde',
resolved: false,
});
expect(alertOf({ ...down, heartbeat: { status: 1 } })?.resolved).toBe(true);
});
it('reads Alertmanager and Grafana', () => {
expect(
alertOf({
status: 'firing',
groupKey: '{}:{alertname="HighLatency"}',
alerts: [
{
labels: { alertname: 'HighLatency', service: 'api' },
annotations: { summary: 'p95 por encima de 2 s' },
generatorURL: 'https://grafana.acme.dev/alerting/1',
},
],
}),
).toMatchObject({
source: 'alertmanager',
fingerprint: 'alertmanager:{}:{alertname="HighLatency"}',
title: 'p95 por encima de 2 s',
resolved: false,
});
});
it('takes a plain title, and nothing without one', () => {
expect(
alertOf({ title: 'Cola atascada', fingerprint: 'queue', resolved: true }),
).toMatchObject({
source: 'generic',
fingerprint: 'generic:queue',
resolved: true,
});
expect(alertOf({ title: 'Cola atascada' })?.fingerprint).toBe(
'generic:cola atascada',
);
expect(alertOf({ ping: true })).toBeNull();
expect(alertOf('nonsense')).toBeNull();
});
it('only links http(s) addresses', () => {
expect(alertOf({ title: 'x', url: 'javascript:alert(1)' })?.url).toBeNull();
});
});

View File

@@ -0,0 +1,199 @@
/*
* What monitoring tools send when something breaks, read into one shape.
* Each tool has its own payload; none of them is trusted beyond being text.
*/
export type AlertSource = 'sentry' | 'uptime-kuma' | 'alertmanager' | 'generic';
export interface Alert {
source: AlertSource;
/**
* The same problem gives the same fingerprint every time: repeats go to
* the ticket it opened instead of opening another.
*/
fingerprint: string;
title: string;
/** Markdown for the ticket's description. */
details: string;
url: string | null;
/** The tool says it is over (back up, resolved). */
resolved: boolean;
}
const MAX_TITLE = 200;
const MAX_DETAILS = 4000;
type Json = Record<string, unknown>;
const obj = (value: unknown): Json =>
value && typeof value === 'object' && !Array.isArray(value)
? (value as Json)
: {};
const str = (value: unknown): string =>
typeof value === 'string'
? value.trim()
: typeof value === 'number'
? String(value)
: '';
/** An http(s) link, or null: anything else is not offered as a link. */
const link = (value: unknown): string | null => {
const text = str(value);
return /^https?:\/\/\S+$/i.test(text) ? text.slice(0, 500) : null;
};
/** The alert in a payload, or null when it is not one (a test ping…). */
export function alertOf(
body: unknown,
headers: Record<string, string | undefined> = {},
): Alert | null {
const payload = obj(body);
const alert =
sentry(payload, headers) ??
uptimeKuma(payload) ??
alertmanager(payload) ??
generic(payload);
if (!alert?.title || !alert.fingerprint) return null;
return {
...alert,
title: alert.title.replace(/\s+/g, ' ').slice(0, MAX_TITLE),
details: alert.details.slice(0, MAX_DETAILS),
fingerprint: alert.fingerprint.slice(0, 300),
};
}
/**
* Sentry: an internal integration (header `sentry-hook-resource`: `issue`
* or `event_alert`) or the legacy webhooks plugin (`project_name`, `event`).
*/
function sentry(
payload: Json,
headers: Record<string, string | undefined>,
): Alert | null {
const resource = str(headers['sentry-hook-resource']);
const data = obj(payload['data']);
if (resource === 'issue') {
const issue = obj(data['issue']);
const id = str(issue['id']);
if (!id) return null;
const action = str(payload['action']);
return {
source: 'sentry',
fingerprint: `sentry:${id}`,
title: str(issue['title']),
details: lines([
str(issue['culprit']) && `En \`${str(issue['culprit'])}\``,
str(issue['level']) && `Nivel: ${str(issue['level'])}`,
]),
url: link(issue['permalink'] ?? issue['web_url']),
resolved: action === 'resolved',
};
}
if (resource === 'event_alert') {
const event = obj(data['event']);
const id = str(event['issue_id']) || str(event['event_id']);
if (!id) return null;
return {
source: 'sentry',
fingerprint: `sentry:${id}`,
title: str(event['title']) || str(event['message']),
details: lines([
str(event['culprit']) && `En \`${str(event['culprit'])}\``,
str(event['level']) && `Nivel: ${str(event['level'])}`,
str(data['triggered_rule']) && `Regla: ${str(data['triggered_rule'])}`,
]),
url: link(event['web_url'] ?? event['url']),
resolved: false,
};
}
if ('project_name' in payload && 'event' in payload) {
const event = obj(payload['event']);
const id = str(payload['id']) || str(event['event_id']);
if (!id) return null;
return {
source: 'sentry',
fingerprint: `sentry:${id}`,
title: str(event['title']) || str(payload['message']),
details: lines([
str(payload['culprit']) && `En \`${str(payload['culprit'])}\``,
str(payload['level']) && `Nivel: ${str(payload['level'])}`,
str(payload['project_name']) &&
`Proyecto de Sentry: ${str(payload['project_name'])}`,
]),
url: link(payload['url']),
resolved: false,
};
}
return null;
}
/** Uptime Kuma's webhook: `monitor` and `heartbeat` (status 0 down, 1 up). */
function uptimeKuma(payload: Json): Alert | null {
const monitor = obj(payload['monitor']);
const heartbeat = obj(payload['heartbeat']);
if (!('monitor' in payload) || !('heartbeat' in payload)) return null;
const name = str(monitor['name']);
const id = str(monitor['id']) || name;
if (!id) return null;
const up = heartbeat['status'] === 1;
return {
source: 'uptime-kuma',
fingerprint: `uptime-kuma:${id}`,
title: `${name || 'Un monitor'} no responde`,
details: lines([
str(heartbeat['msg']) && `Respuesta: ${str(heartbeat['msg'])}`,
str(monitor['url']) && `Vigila: ${str(monitor['url'])}`,
str(heartbeat['time']) && `Desde: ${str(heartbeat['time'])}`,
]),
url: link(monitor['url']),
resolved: up,
};
}
/** Prometheus Alertmanager and Grafana: `status` and `alerts[]`. */
function alertmanager(payload: Json): Alert | null {
if (!Array.isArray(payload['alerts'])) return null;
const first = obj(payload['alerts'][0]);
const labels = obj(first['labels']);
const annotations = obj(first['annotations']);
const key = str(payload['groupKey']) || str(first['fingerprint']);
if (!key) return null;
return {
source: 'alertmanager',
fingerprint: `alertmanager:${key}`,
title:
str(annotations['summary']) ||
str(labels['alertname']) ||
str(payload['title']),
details: lines([
str(annotations['description']) || str(payload['message']),
Object.keys(labels).length > 0 &&
`Etiquetas: ${Object.entries(labels)
.map(([k, v]) => `\`${k}=${str(v)}\``)
.join(' ')}`,
]),
url: link(first['generatorURL'] ?? payload['externalURL']),
resolved: str(payload['status']) === 'resolved',
};
}
/**
* Anything else that says what broke: `title` (or `message`), and
* optionally `fingerprint`, `description`, `url` and `resolved`.
*/
function generic(payload: Json): Alert | null {
const title = str(payload['title']) || str(payload['message']);
if (!title) return null;
return {
source: 'generic',
fingerprint: `generic:${str(payload['fingerprint']) || title.toLowerCase()}`,
title,
details: str(payload['description']),
url: link(payload['url']),
resolved:
payload['resolved'] === true || str(payload['status']) === 'resolved',
};
}
function lines(parts: (string | false | undefined)[]): string {
return parts.filter((part): part is string => !!part).join('\n\n');
}

View File

@@ -0,0 +1,220 @@
import {
CommandBus,
CommandHandler,
CqrsModule,
ICommandHandler,
} from '@nestjs/cqrs';
import { Test } from '@nestjs/testing';
import { CommandRunner } from '@task-stream/api/shared/application';
import {
IssuePriority,
IssueReadModel,
StatusCategory,
SYSTEM_ACTOR_ID,
} from '@task-stream/api/tracker/domain';
import { AddCommentCommand } from '../commands/add-comment.command';
import { CreateIssueCommand } from '../commands/create-issue.command';
import { WriteAccess } from '../commands/write-access';
import {
AlertIntegrationConfig,
AlertIntegrationsRepository,
AlertTrace,
} from '../ports/alert-integrations.repository';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { ReadAccess } from '../queries/read-access';
import { Alert } from './alert-payloads';
import { ALERT_HANDLERS, ReceiveAlertCommand, REPEAT_QUIET_MS } from './alerts';
class InMemoryAlerts extends AlertIntegrationsRepository {
configs = new Map<string, AlertIntegrationConfig>();
traces = new Map<string, AlertTrace>();
async find(projectId: string) {
return this.configs.get(projectId) ?? null;
}
async save(config: AlertIntegrationConfig) {
this.configs.set(config.projectId, config);
}
async remove(projectId: string) {
this.configs.delete(projectId);
}
async trace(projectId: string, fingerprint: string) {
return this.traces.get(`${projectId}:${fingerprint}`) ?? null;
}
async saveTrace(trace: AlertTrace) {
this.traces.set(`${trace.projectId}:${trace.fingerprint}`, trace);
}
}
/** The tickets alerts open, and what TaskStream says on them. */
const issues = new Map<string, IssueReadModel>();
const created: CreateIssueCommand[] = [];
const comments: AddCommentCommand[] = [];
@CommandHandler(CreateIssueCommand)
class FakeCreate implements ICommandHandler<CreateIssueCommand> {
async execute(command: CreateIssueCommand) {
created.push(command);
const id = `i${created.length}`;
const key = `WEB-${created.length}`;
issues.set(id, {
id,
key,
statusCategory: StatusCategory.TODO,
trashedAt: null,
} as IssueReadModel);
return { id, key, version: 1 };
}
}
@CommandHandler(AddCommentCommand)
class FakeComment implements ICommandHandler<AddCommentCommand> {
async execute(command: AddCommentCommand) {
comments.push(command);
return { id: 'c', version: 1 };
}
}
const alert = (over: Partial<Alert> = {}): Alert => ({
source: 'uptime-kuma',
fingerprint: 'uptime-kuma:3',
title: 'API no responde',
details: 'timeout',
url: 'https://api.acme.dev',
resolved: false,
...over,
});
async function setup() {
issues.clear();
created.length = 0;
comments.length = 0;
const repo = new InMemoryAlerts();
await repo.save({
projectId: 'p1',
token: 't',
typeId: null,
priority: IssuePriority.HIGH,
createdAt: '2026-10-01T00:00:00Z',
});
const moduleRef = await Test.createTestingModule({
imports: [CqrsModule.forRoot()],
providers: [
...ALERT_HANDLERS,
FakeCreate,
FakeComment,
CommandRunner,
{ provide: AlertIntegrationsRepository, useValue: repo },
{
provide: FindProjectsRepository,
useValue: {
findById: async () => ({
id: 'p1',
settings: {
issueTypes: [
{ id: 'task', icon: 'task', archived: false },
{ id: 'bug', icon: 'bug', archived: false },
],
},
}),
},
},
{
provide: FindIssuesRepository,
useValue: { findById: async (id: string) => issues.get(id) ?? null },
},
{ provide: WriteAccess, useValue: {} },
{ provide: ReadAccess, useValue: {} },
],
}).compile();
await moduleRef.init();
const bus = moduleRef.get(CommandBus);
return {
repo,
receive: (a: Alert) => bus.execute(new ReceiveAlertCommand('p1', a)),
};
}
describe('alerts → bugs', () => {
it('opens a bug as TaskStream, with the bug type and the priority set', async () => {
const { receive } = await setup();
expect(await receive(alert())).toEqual({
action: 'created',
issueKey: 'WEB-1',
});
const [command] = created;
expect(command.actorId).toBe(SYSTEM_ACTOR_ID);
expect(command.type).toBe('bug');
expect(command.details.priority).toBe(IssuePriority.HIGH);
expect(command.details.labels).toEqual(['alerta']);
expect(command.details.description).toContain('Uptime Kuma');
});
it('sends repeats to the open bug, telling it at most every half hour', async () => {
const { receive, repo } = await setup();
await receive(alert());
expect(await receive(alert())).toEqual({
action: 'repeated',
issueKey: 'WEB-1',
});
// Just told when it opened: this repeat stays quiet.
expect(comments).toHaveLength(0);
const trace = await repo.trace('p1', 'uptime-kuma:3');
if (!trace) throw new Error('no trace');
expect(trace.count).toBe(2);
await repo.saveTrace({
...trace,
notedAt: new Date(Date.now() - REPEAT_QUIET_MS - 1000).toISOString(),
});
await receive(alert());
expect(comments).toHaveLength(1);
expect(comments[0].body).toContain('3 veces');
expect(created).toHaveLength(1);
});
it('says on the bug when it is resolved, and ignores one with nothing open', async () => {
const { receive } = await setup();
expect(await receive(alert({ resolved: true }))).toEqual({
action: 'ignored',
issueKey: null,
});
await receive(alert());
expect(await receive(alert({ resolved: true }))).toEqual({
action: 'resolved',
issueKey: 'WEB-1',
});
expect(comments[0].body).toContain('se ha resuelto');
});
it('opens a new bug when the old one is closed, naming it', async () => {
const { receive } = await setup();
await receive(alert());
issues.set('i1', {
...(issues.get('i1') as IssueReadModel),
statusCategory: StatusCategory.DONE,
});
expect(await receive(alert())).toEqual({
action: 'created',
issueKey: 'WEB-2',
});
expect(created[1].details.description).toContain('Ya pasó antes: WEB-1');
});
it('sends a repeat to a bug not yet in the read model', async () => {
const { receive } = await setup();
await receive(alert());
issues.clear();
expect(await receive(alert())).toEqual({
action: 'repeated',
issueKey: 'WEB-1',
});
expect(created).toHaveLength(1);
});
it('opens one bug when the same alert arrives twice at once', async () => {
const { receive } = await setup();
const both = await Promise.all([receive(alert()), receive(alert())]);
expect(both.map((o) => o.action).sort()).toEqual(['created', 'repeated']);
expect(created).toHaveLength(1);
});
});

View File

@@ -0,0 +1,357 @@
import { randomBytes } from 'node:crypto';
import { CommandRunner } from '@task-stream/api/shared/application';
import {
Command,
CommandHandler,
ICommandHandler,
IQueryHandler,
Query,
QueryHandler,
} from '@nestjs/cqrs';
import { assertThat, NotFoundError } from '@task-stream/api/shared/domain';
import {
IssuePriority,
ProjectRole,
StatusCategory,
SYSTEM_ACTOR_ID,
} from '@task-stream/api/tracker/domain';
import { AddCommentCommand } from '../commands/add-comment.command';
import { CommandResult } from '../commands/command-result';
import { CreateIssueCommand } from '../commands/create-issue.command';
import { WriteAccess } from '../commands/write-access';
import {
AlertIntegrationConfig,
AlertIntegrationsRepository,
} from '../ports/alert-integrations.repository';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { ReadAccess } from '../queries/read-access';
import { Alert, AlertSource } from './alert-payloads';
/*
* Alerts → bugs: monitoring tools (Sentry, Uptime Kuma, Grafana…) call a
* project's secret URL; a new problem opens a bug, a repeat goes to the bug
* it opened while that one is open, and «resolved» is said on it. Nobody's
* ticket: TaskStream opens and comments them.
*/
export interface AlertIntegration {
enabled: boolean;
/** Only for whoever manages the project. */
token: string | null;
typeId: string | null;
priority: IssuePriority;
}
export interface AlertSettings {
typeId?: string | null;
priority?: IssuePriority;
}
export type AlertOutcome =
| { action: 'created' | 'repeated' | 'resolved'; issueKey: string }
| { action: 'ignored'; issueKey: null };
/** Repeats tell the ticket at most this often. */
export const REPEAT_QUIET_MS = 30 * 60_000;
const SOURCE_NAME: Record<AlertSource, string> = {
sentry: 'Sentry',
'uptime-kuma': 'Uptime Kuma',
alertmanager: 'Alertmanager',
generic: 'la alerta',
};
const OFF: AlertIntegration = {
enabled: false,
token: null,
typeId: null,
priority: IssuePriority.HIGH,
};
const newToken = () => randomBytes(24).toString('hex');
/** Turns it on, or changes what it opens (the token stays). */
export class SaveAlertIntegrationCommand extends Command<AlertIntegration> {
constructor(
public readonly actorId: string,
public readonly projectId: string,
public readonly settings: AlertSettings,
) {
super();
}
}
/** A new URL; the old one stops working at once. */
export class RotateAlertTokenCommand extends Command<AlertIntegration> {
constructor(
public readonly actorId: string,
public readonly projectId: string,
) {
super();
}
}
export class DisableAlertIntegrationCommand extends Command<void> {
constructor(
public readonly actorId: string,
public readonly projectId: string,
) {
super();
}
}
/** An alert already let in (its token checked by the caller). */
export class ReceiveAlertCommand extends Command<AlertOutcome> {
constructor(
public readonly projectId: string,
public readonly alert: Alert,
) {
super();
}
}
export class GetAlertIntegrationQuery extends Query<AlertIntegration> {
constructor(
public readonly viewerId: string,
public readonly projectId: string,
) {
super();
}
}
/** What owners and admins see: the token included. */
const view = (config: AlertIntegrationConfig): AlertIntegration => ({
enabled: true,
token: config.token,
typeId: config.typeId,
priority: config.priority,
});
@CommandHandler(SaveAlertIntegrationCommand)
export class SaveAlertIntegrationCommandHandler
implements ICommandHandler<SaveAlertIntegrationCommand>
{
constructor(
private readonly access: WriteAccess,
private readonly findProjects: FindProjectsRepository,
private readonly integrations: AlertIntegrationsRepository,
) {}
async execute(
command: SaveAlertIntegrationCommand,
): Promise<AlertIntegration> {
(await this.access.project(command.projectId)).assertCanManage(
command.actorId,
);
const { typeId, priority } = command.settings;
if (typeId) {
const project = await this.findProjects.findById(command.projectId);
assertThat(
!!project?.settings.issueTypes.some(
(t) => t.id === typeId && !t.archived,
),
`Unknown ticket type: ${typeId}`,
);
}
const current = await this.integrations.find(command.projectId);
const config: AlertIntegrationConfig = {
projectId: command.projectId,
token: current?.token ?? newToken(),
typeId: typeId === undefined ? (current?.typeId ?? null) : typeId,
priority: priority ?? current?.priority ?? IssuePriority.HIGH,
createdAt: current?.createdAt ?? new Date().toISOString(),
};
await this.integrations.save(config);
return view(config);
}
}
@CommandHandler(RotateAlertTokenCommand)
export class RotateAlertTokenCommandHandler
implements ICommandHandler<RotateAlertTokenCommand>
{
constructor(
private readonly access: WriteAccess,
private readonly integrations: AlertIntegrationsRepository,
) {}
async execute(command: RotateAlertTokenCommand): Promise<AlertIntegration> {
(await this.access.project(command.projectId)).assertCanManage(
command.actorId,
);
const current = await this.integrations.find(command.projectId);
if (!current)
throw new NotFoundError('Alert integration', command.projectId);
const rotated = { ...current, token: newToken() };
await this.integrations.save(rotated);
return view(rotated);
}
}
@CommandHandler(DisableAlertIntegrationCommand)
export class DisableAlertIntegrationCommandHandler
implements ICommandHandler<DisableAlertIntegrationCommand>
{
constructor(
private readonly access: WriteAccess,
private readonly integrations: AlertIntegrationsRepository,
) {}
async execute(command: DisableAlertIntegrationCommand): Promise<void> {
(await this.access.project(command.projectId)).assertCanManage(
command.actorId,
);
await this.integrations.remove(command.projectId);
}
}
@CommandHandler(ReceiveAlertCommand)
export class ReceiveAlertCommandHandler
implements ICommandHandler<ReceiveAlertCommand>
{
/** One alert of a project after another: two at once open one ticket. */
private readonly queues = new Map<string, Promise<unknown>>();
constructor(
private readonly integrations: AlertIntegrationsRepository,
private readonly findProjects: FindProjectsRepository,
private readonly findIssues: FindIssuesRepository,
private readonly commands: CommandRunner,
) {}
execute(command: ReceiveAlertCommand): Promise<AlertOutcome> {
const previous = this.queues.get(command.projectId) ?? Promise.resolve();
const next = previous.catch(() => null).then(() => this.receive(command));
this.queues.set(command.projectId, next);
void next.finally(() => {
if (this.queues.get(command.projectId) === next)
this.queues.delete(command.projectId);
});
return next;
}
private async receive({
projectId,
alert,
}: ReceiveAlertCommand): Promise<AlertOutcome> {
const config = await this.integrations.find(projectId);
if (!config) throw new NotFoundError('Alert integration', projectId);
const now = new Date().toISOString();
const trace = await this.integrations.trace(projectId, alert.fingerprint);
const opened = trace ? await this.findIssues.findById(trace.issueId) : null;
// Not in the read model yet: it was opened a moment ago, so it is open.
const open = !trace
? null
: !opened
? { id: trace.issueId, key: trace.issueKey }
: !opened.trashedAt &&
opened.statusCategory !== StatusCategory.DONE &&
opened.statusCategory !== StatusCategory.CANCELLED
? opened
: null;
const source = SOURCE_NAME[alert.source];
if (trace && open) {
if (alert.resolved) {
await this.comment(open.id, `✅ Según ${source}, se ha resuelto.`);
await this.integrations.saveTrace({ ...trace, notedAt: now });
return { action: 'resolved', issueKey: open.key };
}
const count = trace.count + 1;
const quiet = Date.now() - Date.parse(trace.notedAt) < REPEAT_QUIET_MS;
if (!quiet)
await this.comment(
open.id,
`🔁 Ha vuelto a pasar: ${count} veces desde ${trace.firstAt.slice(0, 16).replace('T', ' ')} (UTC).`,
);
await this.integrations.saveTrace({
...trace,
count,
lastAt: now,
notedAt: quiet ? trace.notedAt : now,
});
return { action: 'repeated', issueKey: open.key };
}
// Over before anyone looked, or about a ticket already closed.
if (alert.resolved) return { action: 'ignored', issueKey: null };
const project = await this.findProjects.findById(projectId);
const types = (project?.settings.issueTypes ?? []).filter(
(t) => !t.archived,
);
const type =
types.find((t) => t.id === config.typeId) ??
types.find((t) => t.icon === 'bug') ??
types[0];
if (!type) throw new NotFoundError('Ticket type', config.typeId ?? 'bug');
const description = [
alert.details,
alert.url && `[Ver en ${source}](${alert.url})`,
opened && `Ya pasó antes: ${opened.key}.`,
`_Abierto por una alerta de ${source}._`,
]
.filter(Boolean)
.join('\n\n');
const result: CommandResult = await this.commands.execute(
new CreateIssueCommand(
SYSTEM_ACTOR_ID,
projectId,
type.id,
alert.title,
{ description, priority: config.priority, labels: ['alerta'] },
null,
null,
null,
null,
false,
),
);
await this.integrations.saveTrace({
projectId,
fingerprint: alert.fingerprint,
issueId: result.id,
issueKey: result.key ?? result.id,
count: 1,
firstAt: now,
lastAt: now,
notedAt: now,
});
return { action: 'created', issueKey: result.key ?? result.id };
}
private comment(issueId: string, body: string): Promise<CommandResult> {
return this.commands.execute(
new AddCommentCommand(SYSTEM_ACTOR_ID, issueId, body),
);
}
}
@QueryHandler(GetAlertIntegrationQuery)
export class GetAlertIntegrationQueryHandler
implements IQueryHandler<GetAlertIntegrationQuery, AlertIntegration>
{
constructor(
private readonly access: ReadAccess,
private readonly integrations: AlertIntegrationsRepository,
) {}
async execute(query: GetAlertIntegrationQuery): Promise<AlertIntegration> {
const role = await this.access.assertSeesWholeProject(
query.projectId,
query.viewerId,
);
const config = await this.integrations.find(query.projectId);
if (!config) return OFF;
const manages = role === ProjectRole.OWNER || role === ProjectRole.ADMIN;
return { ...view(config), token: manages ? config.token : null };
}
}
export const ALERT_HANDLERS = [
SaveAlertIntegrationCommandHandler,
RotateAlertTokenCommandHandler,
DisableAlertIntegrationCommandHandler,
ReceiveAlertCommandHandler,
GetAlertIntegrationQueryHandler,
];

View File

@@ -4,10 +4,13 @@ import {
ReleaseReadModel, ReleaseReadModel,
ReleaseStatus, ReleaseStatus,
StatusCategory as C, StatusCategory as C,
SprintReadModel,
SprintStatus,
} from '@task-stream/api/tracker/domain'; } from '@task-stream/api/tracker/domain';
import { import {
computeProjectAnalytics, computeProjectAnalytics,
computeReleaseBurnup, computeReleaseBurnup,
computeSprintBurndown,
weekOf, weekOf,
} from './analytics'; } from './analytics';
@@ -182,3 +185,62 @@ describe('release burnup', () => {
expect(at(burnup.done, '2026-10-14')).toBe(2); expect(at(burnup.done, '2026-10-14')).toBe(2);
}); });
}); });
describe('sprint burndown', () => {
const sprint = {
id: 's1',
projectId: 'p',
startDate: '2026-10-12',
endDate: '2026-10-16',
status: SprintStatus.ACTIVE,
closedAt: null,
} as SprintReadModel;
const into = (issueId: string, at: string): IssueTimelineEntry => ({
id: `${issueId}:${++seq}`,
issueId,
projectId: 'p',
at,
kind: 'sprint',
sprintId: 's1',
});
// X (3 points) and Y (5) committed on Monday; Z (2) added on Tuesday.
// X done on Tuesday, Y cancelled on Wednesday. Today is Wednesday noon.
const issues = [
{ ...issue('x', '2026-10-01T09:00:00Z', 'DONE', C.DONE), storyPoints: 3 },
{
...issue('y', '2026-10-01T09:00:00Z', 'CANCELLED', C.CANCELLED),
storyPoints: 5,
},
{ ...issue('z', '2026-10-01T09:00:00Z', 'TODO', C.TODO), storyPoints: 2 },
] as IssueReadModel[];
const timeline = [
step('x', '2026-10-01T09:00:00Z', 'TODO', C.TODO),
step('y', '2026-10-01T09:00:00Z', 'TODO', C.TODO),
step('z', '2026-10-01T09:00:00Z', 'TODO', C.TODO),
into('x', '2026-10-12T08:00:00Z'),
into('y', '2026-10-12T08:00:00Z'),
into('z', '2026-10-13T10:00:00Z'),
step('x', '2026-10-13T16:00:00Z', 'DONE', C.DONE),
step('y', '2026-10-14T09:00:00Z', 'CANCELLED', C.CANCELLED),
];
it('counts open tickets and points per day, and leaves the future blank', () => {
const burndown = computeSprintBurndown({
sprint,
issues,
timeline,
now: new Date('2026-10-14T12:00:00Z'),
});
expect(burndown.dates).toEqual([
'2026-10-12',
'2026-10-13',
'2026-10-14',
'2026-10-15',
'2026-10-16',
]);
expect(burndown.scope).toEqual([2, 3, 2, null, null]);
expect(burndown.remaining).toEqual([2, 2, 1, null, null]);
expect(burndown.remainingPoints).toEqual([8, 7, 2, null, null]);
expect(burndown.ideal).toEqual([2, 1.5, 1, 0.5, 0]);
});
});

View File

@@ -5,6 +5,8 @@ import {
ProjectAnalyticsReadModel, ProjectAnalyticsReadModel,
ReleaseBurnupReadModel, ReleaseBurnupReadModel,
ReleaseReadModel, ReleaseReadModel,
SprintBurndownReadModel,
SprintReadModel,
StatusCategory, StatusCategory,
UserRefReadModel, UserRefReadModel,
} from '@task-stream/api/tracker/domain'; } from '@task-stream/api/tracker/domain';
@@ -17,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;
@@ -61,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[]> {
@@ -313,3 +315,85 @@ export function computeReleaseBurnup(
done, done,
}; };
} }
export interface SprintBurndownInput {
sprint: SprintReadModel;
issues: IssueReadModel[];
timeline: IssueTimelineEntry[];
now: Date;
}
/**
* Per day of the sprint (start to end date, UTC): tickets in it, how many
* are still open and their points, and the ideal line. Points are today's
* estimates (their history is not kept). Days after today, or after the
* sprint closed, are null.
*/
export function computeSprintBurndown(
input: SprintBurndownInput,
): SprintBurndownReadModel {
const { sprint } = input;
const steps = stepsByIssue(input.issues, input.timeline);
const points = new Map(input.issues.map((i) => [i.id, i.storyPoints ?? 0]));
const membership = new Map<
string,
{ at: number; sprintId: string | null }[]
>();
for (const entry of input.timeline) {
if (entry.kind !== 'sprint') continue;
const list = membership.get(entry.issueId) ?? [];
list.push({ at: Date.parse(entry.at), sprintId: entry.sprintId ?? null });
membership.set(entry.issueId, list);
}
for (const list of membership.values()) list.sort((a, b) => a.at - b.at);
const start = Date.parse(`${sprint.startDate}T00:00:00Z`);
const end = Date.parse(`${sprint.endDate}T00:00:00Z`);
const last = Math.min(
sprint.closedAt ? Date.parse(sprint.closedAt) : input.now.getTime(),
end + DAY - 1,
);
const dayEnds = days(start, end + DAY - 1);
const scope: (number | null)[] = [];
const remaining: (number | null)[] = [];
const remainingPoints: (number | null)[] = [];
for (const time of dayEnds) {
// The day under way counts up to now; the ones to come are unknown.
const at = Math.min(time, last);
if (time - DAY >= last) {
scope.push(null);
remaining.push(null);
remainingPoints.push(null);
continue;
}
let inScope = 0;
let open = 0;
let openPoints = 0;
for (const [issueId, list] of membership) {
const current = list.filter((m) => m.at <= at).at(-1);
if (current?.sprintId !== sprint.id) continue;
const step = stepAt(steps.get(issueId) ?? [], at);
if (!step || step.category === StatusCategory.CANCELLED) continue;
inScope++;
if (step.category !== StatusCategory.DONE) {
open++;
openPoints += points.get(issueId) ?? 0;
}
}
scope.push(inScope);
remaining.push(open);
remainingPoints.push(openPoints);
}
const first = remaining[0] ?? 0;
const span = Math.max(dayEnds.length - 1, 1);
return {
sprintId: sprint.id,
dates: dayEnds.map(dayOf),
scope,
remaining,
remainingPoints,
ideal: dayEnds.map(
(_, i) => Math.round((first * (span - i) * 100) / span) / 100,
),
};
}

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

View File

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

View File

@@ -0,0 +1,56 @@
import { CommandRunner } from '@task-stream/api/shared/application';
import { Injectable } from '@nestjs/common';
import { QueryBus } from '@nestjs/cqrs';
import {
AutomationInput,
CreateAutomationCommand,
DeleteAutomationCommand,
GetAutomationsQuery,
RunAutomationCommand,
UpdateAutomationCommand,
} from './automations/automations';
import { Automation } from './ports/automations.repository';
/** The project's automation rules (owners and admins). */
@Injectable()
export class AutomationsService {
constructor(
private readonly commands: CommandRunner,
private readonly queryBus: QueryBus,
) {}
list(viewerId: string, projectId: string): Promise<Automation[]> {
return this.queryBus.execute(new GetAutomationsQuery(viewerId, projectId));
}
create(
actorId: string,
projectId: string,
rule: AutomationInput,
): Promise<Automation> {
return this.commands.execute(
new CreateAutomationCommand(actorId, projectId, rule),
);
}
update(
actorId: string,
automationId: string,
changes: { name?: string; active?: boolean },
): Promise<Automation> {
return this.commands.execute(
new UpdateAutomationCommand(actorId, automationId, changes),
);
}
/** Runs a rule on a ticket; the error, if any, as text. */
run(automation: Automation, issueId: string): Promise<string | null> {
return this.commands.execute(new RunAutomationCommand(automation, issueId));
}
delete(actorId: string, automationId: string): Promise<void> {
return this.commands.execute(
new DeleteAutomationCommand(actorId, automationId),
);
}
}

View File

@@ -0,0 +1,113 @@
import {
defaultProjectSettings,
DevLinkKind,
DevLinkState,
IssueDevLinkRecordedEvent,
IssuePriority,
IssueStatusChangedEvent,
ProjectWorkflow,
SYSTEM_ACTOR_ID,
} from '@task-stream/api/tracker/domain';
import {
AutomationActionKind,
AutomationTriggerKind,
normalizeRule,
ruleApplies,
triggerOf,
} from './automation-rules';
const workflow = new ProjectWorkflow(defaultProjectSettings());
const bug = {
type: 'BUG',
priority: IssuePriority.HIGH,
area: 'FRONTEND',
labels: ['ui'],
};
describe('automation rules', () => {
it('reads triggers from events, merged pull requests included', () => {
expect(
triggerOf(
new IssueStatusChangedEvent('u', 'i1', 'p', 'TODO', 'IN_QA', null),
),
).toEqual({
kind: AutomationTriggerKind.STATUS_CHANGED,
issueId: 'i1',
status: 'IN_QA',
});
const merged = new IssueDevLinkRecordedEvent(SYSTEM_ACTOR_ID, 'i2', 'p', {
kind: DevLinkKind.PULL_REQUEST,
url: 'https://x/pr/1',
title: 'x',
ref: '#1',
state: DevLinkState.MERGED,
provider: 'github',
repository: null,
author: null,
at: '',
});
expect(triggerOf(merged)?.kind).toBe(
AutomationTriggerKind.PULL_REQUEST_MERGED,
);
});
it('checks the trigger, its status and every condition', () => {
const rule = {
trigger: { kind: AutomationTriggerKind.STATUS_CHANGED, status: 'IN_QA' },
conditions: { types: ['BUG'], label: 'ui' },
};
const into = (status: string) => ({
kind: AutomationTriggerKind.STATUS_CHANGED,
status,
});
expect(ruleApplies(rule, into('IN_QA'), bug)).toBe(true);
expect(ruleApplies(rule, into('DONE'), bug)).toBe(false);
expect(ruleApplies(rule, into('IN_QA'), { ...bug, labels: [] })).toBe(
false,
);
expect(ruleApplies(rule, into('IN_QA'), { ...bug, type: 'TASK' })).toBe(
false,
);
});
it('refuses actions without a usable value', () => {
const rule = (kind: AutomationActionKind, value: string | null) => ({
name: 'R',
trigger: { kind: AutomationTriggerKind.ISSUE_CREATED },
conditions: {},
action: { kind, value },
});
const member = (id: string) => id === 'u-ana';
expect(
normalizeRule(
rule(AutomationActionKind.ADD_LABEL, ' Triage '),
workflow,
member,
).action,
).toEqual({
kind: AutomationActionKind.ADD_LABEL,
value: 'triage',
});
expect(() =>
normalizeRule(
rule(AutomationActionKind.SET_STATUS, 'NOPE'),
workflow,
member,
),
).toThrow();
expect(() =>
normalizeRule(
rule(AutomationActionKind.ASSIGN, 'u-bob'),
workflow,
member,
),
).toThrow();
expect(
normalizeRule(
rule(AutomationActionKind.ASSIGN_REPORTER, 'x'),
workflow,
member,
).action.value,
).toBeNull();
});
});

View File

@@ -0,0 +1,188 @@
import {
CommentAddedEvent,
DevLinkKind,
DevLinkState,
Issue,
IssueAssignedEvent,
IssueCreatedEvent,
IssueDevLinkRecordedEvent,
IssuePriority,
IssueStatusChangedEvent,
ProjectWorkflow,
TrackerEvent,
} from '@task-stream/api/tracker/domain';
import { assertThat } from '@task-stream/api/shared/domain';
export enum AutomationTriggerKind {
ISSUE_CREATED = 'ISSUE_CREATED',
/** Optionally only into `status`. */
STATUS_CHANGED = 'STATUS_CHANGED',
ISSUE_ASSIGNED = 'ISSUE_ASSIGNED',
COMMENT_ADDED = 'COMMENT_ADDED',
PULL_REQUEST_MERGED = 'PULL_REQUEST_MERGED',
}
export enum AutomationActionKind {
/** `value`: a status id. */
SET_STATUS = 'SET_STATUS',
/** `value`: a member's id. */
ASSIGN = 'ASSIGN',
ASSIGN_REPORTER = 'ASSIGN_REPORTER',
/** `value`: the label. */
ADD_LABEL = 'ADD_LABEL',
/** `value`: a priority. */
SET_PRIORITY = 'SET_PRIORITY',
/** `value`: the text. */
ADD_COMMENT = 'ADD_COMMENT',
ADD_TO_ACTIVE_SPRINT = 'ADD_TO_ACTIVE_SPRINT',
}
export interface AutomationTrigger {
kind: AutomationTriggerKind;
/** STATUS_CHANGED only: the status entered (null: any). */
status?: string | null;
}
/** All of them must hold; an empty list means "any". */
export interface AutomationConditions {
types?: string[];
priorities?: IssuePriority[];
areas?: string[];
/** The ticket carries this label. */
label?: string | null;
}
export interface AutomationAction {
kind: AutomationActionKind;
value?: string | null;
}
/** What an event means for automations, if anything. */
export function triggerOf(
event: TrackerEvent,
): { kind: AutomationTriggerKind; issueId: string; status?: string } | null {
if (event instanceof IssueCreatedEvent)
return {
kind: AutomationTriggerKind.ISSUE_CREATED,
issueId: event.aggregateId,
};
if (event instanceof IssueStatusChangedEvent)
return {
kind: AutomationTriggerKind.STATUS_CHANGED,
issueId: event.aggregateId,
status: event.to,
};
if (event instanceof IssueAssignedEvent && event.assigneeId)
return {
kind: AutomationTriggerKind.ISSUE_ASSIGNED,
issueId: event.aggregateId,
};
if (event instanceof CommentAddedEvent)
return {
kind: AutomationTriggerKind.COMMENT_ADDED,
issueId: event.comment.issueId,
};
if (
event instanceof IssueDevLinkRecordedEvent &&
event.link.kind === DevLinkKind.PULL_REQUEST &&
event.link.state === DevLinkState.MERGED
)
return {
kind: AutomationTriggerKind.PULL_REQUEST_MERGED,
issueId: event.aggregateId,
};
return null;
}
/** Whether a rule wants this trigger on this ticket. */
export function ruleApplies(
rule: { trigger: AutomationTrigger; conditions: AutomationConditions },
trigger: { kind: AutomationTriggerKind; status?: string },
issue: Pick<Issue, 'type' | 'priority' | 'area' | 'labels'>,
): boolean {
if (rule.trigger.kind !== trigger.kind) return false;
if (
rule.trigger.kind === AutomationTriggerKind.STATUS_CHANGED &&
rule.trigger.status &&
rule.trigger.status !== trigger.status
)
return false;
const { types, priorities, areas, label } = rule.conditions;
if (types?.length && !types.includes(issue.type)) return false;
if (priorities?.length && !priorities.includes(issue.priority)) return false;
if (areas?.length && !(issue.area && areas.includes(issue.area)))
return false;
if (label && !issue.labels.includes(label.toLowerCase())) return false;
return true;
}
/** Checks a rule against the project's workflow; returns it tidied. */
export function normalizeRule(
rule: {
name: string;
trigger: AutomationTrigger;
conditions: AutomationConditions;
action: AutomationAction;
},
workflow: ProjectWorkflow,
isMember: (userId: string) => boolean,
) {
const name = rule.name.trim();
assertThat(
name.length > 0 && name.length <= 80,
'A rule needs a name (up to 80 characters)',
);
assertThat(
Object.values(AutomationTriggerKind).includes(rule.trigger.kind),
'Unknown trigger',
);
const status = rule.trigger.status || null;
if (status) assertThat(!!workflow.status(status), `Unknown status ${status}`);
const { kind, value } = rule.action;
assertThat(
Object.values(AutomationActionKind).includes(kind),
'Unknown action',
);
const needs: Partial<Record<AutomationActionKind, (v: string) => boolean>> = {
[AutomationActionKind.SET_STATUS]: (v) => !!workflow.status(v),
[AutomationActionKind.ASSIGN]: (v) => isMember(v),
[AutomationActionKind.ADD_LABEL]: (v) =>
v.trim().length > 0 && v.length <= 40,
[AutomationActionKind.SET_PRIORITY]: (v) =>
(Object.values(IssuePriority) as string[]).includes(v),
[AutomationActionKind.ADD_COMMENT]: (v) =>
v.trim().length > 0 && v.length <= 2000,
};
const check = needs[kind];
if (check)
assertThat(
!!value && check(value),
`The action ${kind} needs a valid value`,
);
const list = <T>(values?: T[] | null) =>
values?.length ? [...new Set(values)] : undefined;
return {
name,
trigger: {
kind: rule.trigger.kind,
...(rule.trigger.kind === AutomationTriggerKind.STATUS_CHANGED
? { status }
: {}),
},
conditions: {
types: list(rule.conditions.types),
priorities: list(rule.conditions.priorities),
areas: list(rule.conditions.areas),
label: rule.conditions.label?.trim().toLowerCase() || undefined,
},
action: {
kind,
// `check` passed, so there is a value whenever one is needed.
value: check
? kind === AutomationActionKind.ADD_LABEL
? (value ?? '').trim().toLowerCase()
: value
: null,
},
};
}

View File

@@ -0,0 +1,66 @@
import { Logger } from '@nestjs/common';
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import {
CommentAddedEvent,
Issue,
IssueAssignedEvent,
IssueCreatedEvent,
IssueDevLinkRecordedEvent,
IssueStatusChangedEvent,
SYSTEM_ACTOR_ID,
TrackerEvent,
} from '@task-stream/api/tracker/domain';
import { AutomationsService } from '../automations.service';
import { WriteAccess } from '../commands/write-access';
import { AutomationsRepository } from '../ports/automations.repository';
import { ruleApplies, triggerOf } from './automation-rules';
/**
* Runs the project's rules on what people do. What rules themselves do (as
* TaskStream) triggers nothing more: no chains, no loops. Each rule runs once
* per event, also when the event is delivered again.
*/
@EventsHandler(
IssueCreatedEvent,
IssueStatusChangedEvent,
IssueAssignedEvent,
CommentAddedEvent,
IssueDevLinkRecordedEvent,
)
export class AutomationsEventHandler implements IEventHandler<TrackerEvent> {
private readonly logger = new Logger(AutomationsEventHandler.name);
constructor(
private readonly automations: AutomationsRepository,
private readonly access: WriteAccess,
private readonly service: AutomationsService,
) {}
async handle(event: TrackerEvent) {
// Git reports come as the system too, and a merge is worth reacting to.
if (
event.actorId === SYSTEM_ACTOR_ID &&
!(event instanceof IssueDevLinkRecordedEvent)
)
return;
const trigger = triggerOf(event);
if (!trigger) return;
const rules = await this.automations.findActive(event.projectId);
if (!rules.length) return;
const issue = await this.access.live(trigger.issueId, Issue);
if (!issue) return;
for (const rule of rules) {
if (!ruleApplies(rule, trigger, issue)) continue;
const key = `${event.aggregateId}:${event.aggregateVersion ?? event.occurredAt}`;
if (!(await this.automations.claimRun(rule.id, key))) continue;
let error: string | null;
try {
error = await this.service.run(rule, trigger.issueId);
} catch (failure) {
error = failure instanceof Error ? failure.message : String(failure);
this.logger.warn(`Rule ${rule.id} on ${trigger.issueId}: ${error}`);
}
await this.automations.recordRun(rule.id, error);
}
}
}

View File

@@ -0,0 +1,315 @@
import { randomUUID } from 'node:crypto';
import {
Command,
CommandHandler,
EventPublisher,
ICommandHandler,
IQueryHandler,
Query,
QueryHandler,
} from '@nestjs/cqrs';
import {
BusinessRuleError,
NotFoundError,
} from '@task-stream/api/shared/domain';
import {
CommentFactory,
Issue,
IssuePriority,
Project,
SYSTEM_ACTOR_ID,
} from '@task-stream/api/tracker/domain';
import { WriteAccess } from '../commands/write-access';
import {
Automation,
AutomationsRepository,
} from '../ports/automations.repository';
import { FindSprintsRepository } from '../ports/find-sprints.repository';
import { UserDirectory } from '../ports/user-directory';
import {
AutomationAction,
AutomationActionKind,
AutomationConditions,
AutomationTrigger,
normalizeRule,
} from './automation-rules';
/** A project keeps at most this many rules. */
export const MAX_AUTOMATIONS = 50;
export interface AutomationInput {
name: string;
trigger: AutomationTrigger;
conditions: AutomationConditions;
action: AutomationAction;
}
export class CreateAutomationCommand extends Command<Automation> {
constructor(
public readonly actorId: string,
public readonly projectId: string,
public readonly rule: AutomationInput,
) {
super();
}
}
export class UpdateAutomationCommand extends Command<Automation> {
constructor(
public readonly actorId: string,
public readonly automationId: string,
public readonly changes: { name?: string; active?: boolean },
) {
super();
}
}
export class DeleteAutomationCommand extends Command<void> {
constructor(
public readonly actorId: string,
public readonly automationId: string,
) {
super();
}
}
/** Event handler only: one rule's action on one ticket, as TaskStream. */
export class RunAutomationCommand extends Command<string | null> {
constructor(
public readonly automation: Automation,
public readonly issueId: string,
) {
super();
}
}
export class GetAutomationsQuery extends Query<Automation[]> {
constructor(
public readonly viewerId: string,
public readonly projectId: string,
) {
super();
}
}
async function managedProject(
access: WriteAccess,
projectId: string,
actorId: string,
/** Only to read the rules: an archived project still shows them. */
reading = false,
): Promise<Project> {
const project = await access.project(projectId);
if (reading) project.assertManages(actorId);
else project.assertCanManage(actorId);
return project;
}
async function managedAutomation(
automations: AutomationsRepository,
access: WriteAccess,
automationId: string,
actorId: string,
): Promise<Automation> {
const automation = await automations.findById(automationId);
if (!automation) throw new NotFoundError('Automation', automationId);
await managedProject(access, automation.projectId, actorId);
return automation;
}
@CommandHandler(CreateAutomationCommand)
export class CreateAutomationCommandHandler
implements ICommandHandler<CreateAutomationCommand>
{
constructor(
private readonly automations: AutomationsRepository,
private readonly access: WriteAccess,
private readonly users: UserDirectory,
) {}
async execute(command: CreateAutomationCommand): Promise<Automation> {
const project = await managedProject(
this.access,
command.projectId,
command.actorId,
);
if (
(await this.automations.findByProject(project.id)).length >=
MAX_AUTOMATIONS
)
throw new BusinessRuleError(
`A project keeps at most ${MAX_AUTOMATIONS} rules`,
);
const rule = normalizeRule(command.rule, project.workflow, (id) =>
project.isMember(id),
);
const users = await this.users.refs([command.actorId]);
const automation: Automation = {
id: randomUUID(),
projectId: project.id,
...rule,
active: true,
createdBy: users.of(command.actorId),
createdAt: new Date().toISOString(),
runs: 0,
lastRunAt: null,
lastError: null,
};
await this.automations.insert(automation);
return automation;
}
}
@CommandHandler(UpdateAutomationCommand)
export class UpdateAutomationCommandHandler
implements ICommandHandler<UpdateAutomationCommand>
{
constructor(
private readonly automations: AutomationsRepository,
private readonly access: WriteAccess,
) {}
async execute(command: UpdateAutomationCommand): Promise<Automation> {
const automation = await managedAutomation(
this.automations,
this.access,
command.automationId,
command.actorId,
);
const changes: { name?: string; active?: boolean } = {};
if (command.changes.name !== undefined) {
const name = command.changes.name.trim();
if (!name || name.length > 80)
throw new BusinessRuleError(
'A rule needs a name (up to 80 characters)',
);
changes.name = name;
}
if (command.changes.active !== undefined)
changes.active = command.changes.active;
await this.automations.update(automation.id, changes);
return { ...automation, ...changes };
}
}
@CommandHandler(DeleteAutomationCommand)
export class DeleteAutomationCommandHandler
implements ICommandHandler<DeleteAutomationCommand>
{
constructor(
private readonly automations: AutomationsRepository,
private readonly access: WriteAccess,
) {}
async execute(command: DeleteAutomationCommand): Promise<void> {
const automation = await managedAutomation(
this.automations,
this.access,
command.automationId,
command.actorId,
);
await this.automations.delete(automation.id);
}
}
/**
* Applies the action straight on the aggregates, as TaskStream (the system
* actor): the rule was set up by someone who manages the project, and the
* workflow still has the last word. Returns why nothing happened, if so.
*/
@CommandHandler(RunAutomationCommand)
export class RunAutomationCommandHandler
implements ICommandHandler<RunAutomationCommand>
{
constructor(
private readonly access: WriteAccess,
private readonly findSprints: FindSprintsRepository,
private readonly commentFactory: CommentFactory,
private readonly eventPublisher: EventPublisher,
) {}
async execute(command: RunAutomationCommand): Promise<string | null> {
const issue = await this.access.live(command.issueId, Issue);
const project = issue
? await this.access.live(issue.projectId, Project)
: null;
if (!issue || !project) return 'The ticket is gone';
if (project.archivedAt) return 'The project is archived';
const { kind, value } = command.automation.action;
const actor = SYSTEM_ACTOR_ID;
switch (kind) {
case AutomationActionKind.SET_STATUS:
if (!value || issue.status === value) return null;
issue.changeStatus(actor, value, 'Automatización', project.workflow);
break;
case AutomationActionKind.ASSIGN:
case AutomationActionKind.ASSIGN_REPORTER: {
const assignee =
kind === AutomationActionKind.ASSIGN
? (value ?? null)
: issue.reporterId;
if (!assignee || !project.isMember(assignee))
return 'That person is no longer a member';
issue.assign(actor, assignee);
break;
}
case AutomationActionKind.ADD_LABEL:
if (!value || issue.labels.includes(value)) return null;
issue.updateDetails(
actor,
{ labels: [...issue.labels, value] },
project.workflow,
);
break;
case AutomationActionKind.SET_PRIORITY:
issue.updateDetails(
actor,
{ priority: value as IssuePriority },
project.workflow,
);
break;
case AutomationActionKind.ADD_TO_ACTIVE_SPRINT: {
const active = await this.findSprints.findActive(project.id);
if (!active) return 'No sprint is under way';
issue.setSprint(actor, active.id);
break;
}
case AutomationActionKind.ADD_COMMENT: {
const comment = this.commentFactory.create({
issueId: issue.id,
projectId: issue.projectId,
authorId: actor,
body: value ?? '',
});
this.eventPublisher.mergeObjectContext(comment);
await comment.commitAndWait();
return null;
}
}
await issue.commitAndWait();
return null;
}
}
@QueryHandler(GetAutomationsQuery)
export class GetAutomationsQueryHandler
implements IQueryHandler<GetAutomationsQuery, Automation[]>
{
constructor(
private readonly automations: AutomationsRepository,
private readonly access: WriteAccess,
) {}
async execute(query: GetAutomationsQuery): Promise<Automation[]> {
await managedProject(this.access, query.projectId, query.viewerId, true);
return this.automations.findByProject(query.projectId);
}
}
export const AUTOMATION_HANDLERS = [
CreateAutomationCommandHandler,
UpdateAutomationCommandHandler,
DeleteAutomationCommandHandler,
RunAutomationCommandHandler,
GetAutomationsQueryHandler,
];

View File

@@ -0,0 +1,82 @@
import {
IssueLinkType,
IssueReadModel,
StatusCategory,
} from '@task-stream/api/tracker/domain';
import { ReadAccess } from './queries/read-access';
import { BlockersService } from './blockers.service';
import { FindIssuesRepository } from './ports/find-issues.repository';
const issue = (
id: string,
statusCategory = StatusCategory.TODO,
links: IssueReadModel['links'] = [],
) =>
({
id,
key: `WEB-${id}`,
title: `Ticket ${id}`,
statusCategory,
trashedAt: null,
links,
}) as unknown as IssueReadModel;
describe('BlockersService', () => {
const store = new Map(
[
issue('2'),
issue('3', StatusCategory.DONE),
issue('4', StatusCategory.IN_PROGRESS),
issue('5'),
].map((i) => [i.id, i]),
);
const queries: string[][] = [];
const service = new BlockersService(
{
findByIds: async (ids: string[]) => {
queries.push(ids);
return ids.map((id) => store.get(id)).filter(Boolean);
},
} as unknown as FindIssuesRepository,
{ participantScope: async () => null } as unknown as ReadAccess,
);
const link = (
type: IssueLinkType,
direction: 'INWARD' | 'OUTWARD',
id: string,
) => ({ type, direction, issueId: id, issueKey: `WEB-${id}` });
beforeEach(() => (queries.length = 0));
it('is waiting on what blocks it and what it depends on, while open', async () => {
const one = issue('1', StatusCategory.TODO, [
link(IssueLinkType.BLOCKS, 'INWARD', '2'), // 2 blocks 1
link(IssueLinkType.DEPENDS_ON, 'OUTWARD', '3'), // 1 depends on 3 (done)
link(IssueLinkType.DEPENDS_ON, 'OUTWARD', '4'), // 1 depends on 4
link(IssueLinkType.BLOCKS, 'OUTWARD', '5'), // 1 blocks 5: not a blocker
link(IssueLinkType.RELATES_TO, 'OUTWARD', '2'),
]);
expect((await service.blockedBy(one)).map((b) => b.key)).toEqual([
'WEB-2',
'WEB-4',
]);
});
it('asks once for the tickets of one moment (a board)', async () => {
const card = (id: string, blocker: string) =>
issue(id, StatusCategory.TODO, [
link(IssueLinkType.DEPENDS_ON, 'OUTWARD', blocker),
]);
const results = await Promise.all([
service.blockedBy(card('10', '2')),
service.blockedBy(card('11', '4')),
service.blockedBy(issue('12')),
]);
expect(results.map((r) => r.map((b) => b.key))).toEqual([
['WEB-2'],
['WEB-4'],
[],
]);
expect(queries).toEqual([['2', '4']]);
});
});

View File

@@ -0,0 +1,96 @@
import { involves, ReadAccess } from './queries/read-access';
import { Injectable } from '@nestjs/common';
import {
IssueLinkType,
IssueReadModel,
StatusCategory,
} from '@task-stream/api/tracker/domain';
import { FindIssuesRepository } from './ports/find-issues.repository';
/** A ticket another one waits for. */
export interface Blocker {
id: string;
key: string;
title: string;
}
const FINISHED = new Set<StatusCategory>([
StatusCategory.DONE,
StatusCategory.CANCELLED,
]);
/**
* What a ticket waits for: the tickets that block it and the ones it
* depends on, while they are open. Links keep no status, so it is worked
* out when reading; the lookups of one moment (a board, a page of a list)
* go to the read model as one query, not one per ticket.
*/
@Injectable()
export class BlockersService {
private batch: {
ids: Set<string>;
result: Promise<Map<string, IssueReadModel>>;
} | null = null;
constructor(
private readonly findIssues: FindIssuesRepository,
private readonly access: ReadAccess,
) {}
/** For `viewerId`: a guest only learns of blockers they take part in. */
async blockedBy(
issue: IssueReadModel,
viewerId: string | null = null,
): Promise<Blocker[]> {
const ids = blockerIdsOf(issue);
if (!ids.length) return [];
const found = await this.load(ids);
const scope = await this.access.participantScope(issue.projectId, viewerId);
return ids
.map((id) => found.get(id))
.filter(
(blocker): blocker is IssueReadModel =>
!!blocker &&
!blocker.trashedAt &&
!FINISHED.has(blocker.statusCategory) &&
(!scope || involves(blocker, scope)),
)
.map(({ id, key, title }) => ({ id, key, title }));
}
private load(ids: string[]): Promise<Map<string, IssueReadModel>> {
if (!this.batch) {
const pending = new Set<string>();
const result = new Promise<Map<string, IssueReadModel>>(
(resolve, reject) =>
setImmediate(() => {
this.batch = null;
this.findIssues
.findByIds([...pending])
.then((issues) => resolve(new Map(issues.map((i) => [i.id, i]))))
.catch(reject);
}),
);
this.batch = { ids: pending, result };
}
for (const id of ids) this.batch.ids.add(id);
return this.batch.result;
}
}
/** "X blocks this" and "this depends on X": X is what it waits for. */
export function blockerIdsOf(issue: IssueReadModel): string[] {
return [
...new Set(
(issue.links ?? [])
.filter(
(link) =>
(link.type === IssueLinkType.BLOCKS &&
link.direction === 'INWARD') ||
(link.type === IssueLinkType.DEPENDS_ON &&
link.direction === 'OUTWARD'),
)
.map((link) => link.issueId),
),
];
}

View File

@@ -0,0 +1,23 @@
import { IssuePriority } from '@task-stream/api/tracker/domain';
/** At most this many tickets per bulk change. */
export const MAX_BULK = 100;
/** What to change on every ticket; absent fields stay as they are. */
export interface BulkChange {
status?: string;
/** null unassigns. */
assigneeId?: string | null;
/** null: back to the backlog. */
sprintId?: string | null;
/** null: out of its release. */
releaseId?: string | null;
priority?: IssuePriority;
/** Added to the ticket's labels. */
addLabels?: string[];
}
export interface BulkResult {
updated: number;
results: { issueId: string; ok: boolean; error: string | null }[];
}

View File

@@ -0,0 +1,37 @@
import { CommandRunner } from '@task-stream/api/shared/application';
import { Injectable } from '@nestjs/common';
import { QueryBus } from '@nestjs/cqrs';
import {
CreateCalendarFeedCommand,
GetCalendarIcsQuery,
GetMyCalendarFeedQuery,
RevokeCalendarFeedCommand,
} from './calendar/calendar';
import { CalendarFeed } from './ports/calendar-feeds.repository';
/** A person's calendar subscription (iCalendar), behind a secret URL. */
@Injectable()
export class CalendarService {
constructor(
private readonly commands: CommandRunner,
private readonly queryBus: QueryBus,
) {}
mine(userId: string): Promise<CalendarFeed | null> {
return this.queryBus.execute(new GetMyCalendarFeedQuery(userId));
}
/** A new secret URL; the previous one, if any, stops working. */
create(userId: string): Promise<CalendarFeed> {
return this.commands.execute(new CreateCalendarFeedCommand(userId));
}
revoke(userId: string): Promise<void> {
return this.commands.execute(new RevokeCalendarFeedCommand(userId));
}
/** The calendar behind `token`, or null if no feed has it. */
ics(token: string, webUrl: string): Promise<string | null> {
return this.queryBus.execute(new GetCalendarIcsQuery(token, webUrl));
}
}

View File

@@ -0,0 +1,198 @@
import { guestOnly } from '../queries/read-access';
import { randomBytes } from 'node:crypto';
import {
Command,
CommandHandler,
ICommandHandler,
IQueryHandler,
Query,
QueryHandler,
} from '@nestjs/cqrs';
import {
IssueReadModel,
ProjectReadModel,
ReleaseStatus,
SprintStatus,
} from '@task-stream/api/tracker/domain';
import {
CalendarFeed,
CalendarFeedsRepository,
} from '../ports/calendar-feeds.repository';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { FindReleasesRepository } from '../ports/find-releases.repository';
import { FindSprintsRepository } from '../ports/find-sprints.repository';
import { UserDirectory } from '../ports/user-directory';
import { CalendarEntry, buildIcs } from './ics';
/** A new secret URL for the person's calendar; the old one stops working. */
export class CreateCalendarFeedCommand extends Command<CalendarFeed> {
constructor(public readonly userId: string) {
super();
}
}
/** The person's calendar URL stops working. */
export class RevokeCalendarFeedCommand extends Command<void> {
constructor(public readonly userId: string) {
super();
}
}
export class GetMyCalendarFeedQuery extends Query<CalendarFeed | null> {
constructor(public readonly userId: string) {
super();
}
}
/** The calendar behind a token, as iCalendar text; null for an unknown one. */
export class GetCalendarIcsQuery extends Query<string | null> {
constructor(
public readonly token: string,
/** Where tickets open (`WEB_URL`), for the events' links; may be empty. */
public readonly webUrl: string,
) {
super();
}
}
@CommandHandler(CreateCalendarFeedCommand)
export class CreateCalendarFeedCommandHandler
implements ICommandHandler<CreateCalendarFeedCommand>
{
constructor(private readonly feeds: CalendarFeedsRepository) {}
async execute(command: CreateCalendarFeedCommand): Promise<CalendarFeed> {
const feed: CalendarFeed = {
userId: command.userId,
// 32 random bytes: nobody guesses it.
token: randomBytes(32).toString('base64url'),
createdAt: new Date().toISOString(),
};
await this.feeds.save(feed);
return feed;
}
}
@CommandHandler(RevokeCalendarFeedCommand)
export class RevokeCalendarFeedCommandHandler
implements ICommandHandler<RevokeCalendarFeedCommand>
{
constructor(private readonly feeds: CalendarFeedsRepository) {}
async execute(command: RevokeCalendarFeedCommand): Promise<void> {
await this.feeds.remove(command.userId);
}
}
@QueryHandler(GetMyCalendarFeedQuery)
export class GetMyCalendarFeedQueryHandler
implements IQueryHandler<GetMyCalendarFeedQuery, CalendarFeed | null>
{
constructor(private readonly feeds: CalendarFeedsRepository) {}
execute(query: GetMyCalendarFeedQuery): Promise<CalendarFeed | null> {
return this.feeds.findByUser(query.userId);
}
}
/**
* What the person has to keep an eye on, as all-day events: their unfinished
* tickets with a due date (assigned or followed), the planned and running
* sprints of their projects and their planned releases with a date. Read at
* every download, so it is as fresh as the calendar app asks.
*/
@QueryHandler(GetCalendarIcsQuery)
export class GetCalendarIcsQueryHandler
implements IQueryHandler<GetCalendarIcsQuery, string | null>
{
constructor(
private readonly feeds: CalendarFeedsRepository,
private readonly findProjects: FindProjectsRepository,
private readonly findIssues: FindIssuesRepository,
private readonly findSprints: FindSprintsRepository,
private readonly findReleases: FindReleasesRepository,
private readonly users: UserDirectory,
) {}
async execute(query: GetCalendarIcsQuery): Promise<string | null> {
const feed = await this.feeds.findByToken(query.token);
if (!feed) return null;
const projects = await this.findProjects.findByMember(feed.userId);
const byId = new Map(projects.map((p) => [p.id, p]));
const ids = projects.map((p) => p.id);
// A guest's calendar has their tickets, not the project's plans.
const planned = projects
.filter((p) => !guestOnly(p, feed.userId))
.map((p) => p.id);
const [issues, sprints, releases, me] = await Promise.all([
this.findIssues.findDatedFor(feed.userId, ids),
Promise.all(planned.map((id) => this.findSprints.findByProject(id))),
Promise.all(planned.map((id) => this.findReleases.findByProject(id))),
this.users.findById(feed.userId),
]);
const link = linkTo(query.webUrl);
const entries: CalendarEntry[] = [
...issues.flatMap((issue) => {
const project = byId.get(issue.projectId);
return project ? [issueEntry(issue, project, link)] : [];
}),
...sprints
.flat()
.filter(
(s) =>
s.status === SprintStatus.PLANNED ||
s.status === SprintStatus.ACTIVE,
)
.map((s) => ({
uid: `sprint-${s.id}@taskstream`,
start: s.startDate,
end: s.endDate,
summary: `${s.name} · ${byId.get(s.projectId)?.key ?? ''}`.trim(),
description: s.goal ?? undefined,
url: link(`/p/${byId.get(s.projectId)?.key}/sprints`),
})),
...releases
.flat()
.filter((r) => r.status === ReleaseStatus.PLANNED && r.targetDate)
.map((r) => ({
uid: `release-${r.id}@taskstream`,
start: r.targetDate as string,
summary:
`Release ${r.name} · ${byId.get(r.projectId)?.key ?? ''}`.trim(),
description: r.description ?? undefined,
url: link(`/p/${byId.get(r.projectId)?.key}/releases`),
})),
];
return buildIcs(me ? `TaskStream · ${me.name}` : 'TaskStream', entries);
}
}
function issueEntry(
issue: IssueReadModel,
project: ProjectReadModel,
link: (path: string) => string | null,
): CalendarEntry {
const status =
project.settings?.statuses.find((s) => s.id === issue.status)?.name ??
issue.status;
return {
uid: `issue-${issue.id}@taskstream`,
start: issue.dueDate as string,
summary: `${issue.key} · ${issue.title}`,
description: `${project.name} · ${status}`,
url: link(`/p/${project.key}/issues/${issue.key}`),
};
}
function linkTo(webUrl: string): (path: string) => string | null {
const base = webUrl.replace(/\/+$/, '');
return (path) => (base ? `${base}${path}` : null);
}
export const CALENDAR_HANDLERS = [
CreateCalendarFeedCommandHandler,
RevokeCalendarFeedCommandHandler,
GetMyCalendarFeedQueryHandler,
GetCalendarIcsQueryHandler,
];

View File

@@ -0,0 +1,62 @@
import { buildIcs } from './ics';
const NOW = new Date('2026-10-14T09:30:00.000Z');
describe('buildIcs', () => {
it('writes all-day events with an exclusive end', () => {
const ics = buildIcs(
'TaskStream',
[
{
uid: 'issue-1@taskstream',
start: '2026-10-20',
summary: 'WEB-1 · Login',
},
{
uid: 'sprint-1@taskstream',
start: '2026-10-05',
end: '2026-10-19',
summary: 'Sprint 4',
},
],
NOW,
);
expect(ics.startsWith('BEGIN:VCALENDAR\r\nVERSION:2.0\r\n')).toBe(true);
expect(ics.endsWith('END:VCALENDAR\r\n')).toBe(true);
expect(ics).toContain('DTSTAMP:20261014T093000Z');
expect(ics).toContain(
'DTSTART;VALUE=DATE:20261020\r\nDTEND;VALUE=DATE:20261021',
);
expect(ics).toContain(
'DTSTART;VALUE=DATE:20261005\r\nDTEND;VALUE=DATE:20261020',
);
expect(ics.split('BEGIN:VEVENT')).toHaveLength(3);
});
it('escapes text and skips what has no date', () => {
const ics = buildIcs(
'Ana; López, QA',
[
{ uid: 'a', start: '2026-10-20', summary: 'Pago; roto, en\nSafari' },
{ uid: 'b', start: '', summary: 'sin fecha' },
],
NOW,
);
expect(ics).toContain('X-WR-CALNAME:Ana\\; López\\, QA');
expect(ics).toContain('SUMMARY:Pago\\; roto\\, en\\nSafari');
expect(ics).not.toContain('sin fecha');
});
it('folds long lines at 75 octets without splitting a character', () => {
const summary = 'Revisión de la exportación á'.repeat(5);
const ics = buildIcs(
'x',
[{ uid: 'u', start: '2026-10-20', summary }],
NOW,
);
for (const line of ics.split('\r\n'))
expect(Buffer.byteLength(line, 'utf8')).toBeLessThanOrEqual(75);
// Unfolding gives the text back.
expect(ics.replace(/\r\n /g, '')).toContain(`SUMMARY:${summary}`);
});
});

View File

@@ -0,0 +1,97 @@
/** One all-day event of the calendar feed. */
export interface CalendarEntry {
/** Stable across downloads, so calendars update the event, not duplicate it. */
uid: string;
/** `YYYY-MM-DD`. */
start: string;
/** `YYYY-MM-DD`, inclusive; the same day when absent. */
end?: string;
summary: string;
description?: string;
url?: string | null;
}
const DAY = /^\d{4}-\d{2}-\d{2}/;
/**
* An iCalendar (RFC 5545) document of all-day events: what Google Calendar,
* Outlook and Apple Calendar subscribe to. All-day dates carry no time
* zone, so a deadline falls on its day wherever the reader is.
*/
export function buildIcs(
name: string,
entries: CalendarEntry[],
now = new Date(),
): string {
const stamp = now.toISOString().replace(/[-:]/g, '').replace(/\.\d+/, '');
const lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Pireon//TaskStream//ES',
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
`X-WR-CALNAME:${text(name)}`,
// How often to look again: calendars mostly decide for themselves.
'REFRESH-INTERVAL;VALUE=DURATION:PT1H',
'X-PUBLISHED-TTL:PT1H',
];
for (const entry of entries) {
const start = DAY.exec(entry.start)?.[0];
if (!start) continue;
const last = DAY.exec(entry.end ?? '')?.[0] ?? start;
lines.push(
'BEGIN:VEVENT',
`UID:${text(entry.uid)}`,
`DTSTAMP:${stamp}`,
`DTSTART;VALUE=DATE:${compact(start)}`,
// DTEND is exclusive: the day after the last one.
`DTEND;VALUE=DATE:${compact(nextDay(last < start ? start : last))}`,
`SUMMARY:${text(entry.summary)}`,
...(entry.description ? [`DESCRIPTION:${text(entry.description)}`] : []),
...(entry.url ? [`URL:${entry.url}`] : []),
'TRANSP:TRANSPARENT',
'END:VEVENT',
);
}
lines.push('END:VCALENDAR');
return lines.map(fold).join('\r\n') + '\r\n';
}
/** TEXT values escape backslashes, semicolons, commas and line breaks. */
function text(value: string): string {
return value
.replace(/\\/g, '\\\\')
.replace(/;/g, '\\;')
.replace(/,/g, '\\,')
.replace(/\r?\n/g, '\\n');
}
const compact = (day: string) => day.replace(/-/g, '');
function nextDay(day: string): string {
const date = new Date(`${day}T00:00:00Z`);
date.setUTCDate(date.getUTCDate() + 1);
return date.toISOString().slice(0, 10);
}
/** Lines of more than 75 octets go on in the next one, after a space. */
function fold(line: string): string {
const bytes = Buffer.from(line, 'utf8');
if (bytes.length <= 75) return line;
const parts: string[] = [];
let current = '';
let size = 0;
for (const char of line) {
const length = Buffer.byteLength(char, 'utf8');
// The first line holds 75 octets; the next ones 74 after their space.
if (size + length > (parts.length ? 74 : 75)) {
parts.push(current);
current = '';
size = 0;
}
current += char;
size += length;
}
parts.push(current);
return parts.join('\r\n ');
}

View File

@@ -8,9 +8,22 @@ import {
IssueChange, IssueChange,
ProjectChange, ProjectChange,
ReleaseChange, ReleaseChange,
SprintChange,
Typing,
} 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()
@@ -25,7 +38,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(
@@ -36,11 +54,20 @@ export class ChangesService {
return this.changeFeed.comments(issueId); return this.changeFeed.comments(issueId);
} }
/** Who is writing on the ticket (the client leaves itself out). */
async watchTyping(
viewerId: string,
issueId: string,
): Promise<AsyncIterableIterator<Typing>> {
await this.queryBus.execute(new GetIssueQuery(viewerId, issueId)); // access check
return this.changeFeed.typing(issueId);
}
async watchActivity( async watchActivity(
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);
} }
@@ -48,10 +75,18 @@ 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);
} }
async watchSprints(
viewerId: string,
projectId: string,
): Promise<AsyncIterableIterator<SprintChange>> {
await this.assertCanView(viewerId, projectId, true);
return this.changeFeed.sprints(projectId);
}
async watchProject( async watchProject(
viewerId: string, viewerId: string,
projectId: string, projectId: string,
@@ -68,9 +103,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

@@ -1,5 +1,9 @@
import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs'; import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs';
import { CommentFactory } from '@task-stream/api/tracker/domain'; import { BusinessRuleError } from '@task-stream/api/shared/domain';
import {
CommentFactory,
SYSTEM_ACTOR_ID,
} from '@task-stream/api/tracker/domain';
import { AddCommentCommand } from './add-comment.command'; import { AddCommentCommand } from './add-comment.command';
import { CommandResult, ack } from './command-result'; import { CommandResult, ack } from './command-result';
import { WriteAccess } from './write-access'; import { WriteAccess } from './write-access';
@@ -15,19 +19,41 @@ export class AddCommentCommandHandler
) {} ) {}
async execute(command: AddCommentCommand): Promise<CommandResult> { async execute(command: AddCommentCommand): Promise<CommandResult> {
const { issue } = await this.access.issueForContributor( // TaskStream itself (alerts that repeat) is no member; only internal
command.issueId, // commands act as it, never a request from outside.
command.actorId, const issue =
command.actorId === SYSTEM_ACTOR_ID
? await this.access.issue(command.issueId)
: (await this.access.issueForReporter(command.issueId, command.actorId))
.issue;
if (issue.trashedAt)
throw new BusinessRuleError(
`${issue.key} is in the trash: restore it to change it`,
); );
const comment = this.commentFactory.create({ const comment = this.commentFactory.create({
issueId: issue.id, issueId: issue.id,
projectId: issue.projectId, projectId: issue.projectId,
authorId: command.actorId, authorId: command.actorId,
body: command.body, body: command.body,
replyTo: command.replyTo
? await this.threadOf(command.replyTo, issue.id)
: null,
poll: command.poll,
}); });
this.eventPublisher.mergeObjectContext(comment); this.eventPublisher.mergeObjectContext(comment);
await comment.commitAndWait(); await comment.commitAndWait();
return ack(comment); return ack(comment);
} }
/**
* The top-level comment a reply hangs from: one level only, so answering
* a reply joins its thread.
*/
private async threadOf(commentId: string, issueId: string): Promise<string> {
const parent = await this.access.comment(commentId);
if (parent.issueId !== issueId)
throw new BusinessRuleError('A reply must be on the same ticket');
return parent.replyTo ?? parent.id;
}
} }

View File

@@ -6,6 +6,10 @@ export class AddCommentCommand extends Command<CommandResult> {
public readonly actorId: string, public readonly actorId: string,
public readonly issueId: string, public readonly issueId: string,
public readonly body: string, public readonly body: string,
/** Comment it answers; a reply to a reply joins the same thread. */
public readonly replyTo: string | null = null,
/** A quick poll's options (2–6), if any. */
public readonly poll: string[] | null = null,
) { ) {
super(); super();
} }

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

@@ -11,10 +11,12 @@ export class ArchiveReleaseCommandHandler
async execute(command: ArchiveReleaseCommand): Promise<CommandResult> { async execute(command: ArchiveReleaseCommand): Promise<CommandResult> {
const release = await this.access.release(command.releaseId); const release = await this.access.release(command.releaseId);
(await this.access.project(release.projectId)).assertCanManage( const project = await this.access.project(release.projectId);
command.actorId, project.assertCanManage(command.actorId);
); // The tickets can only go to an open release of the same project.
release.archive(command.actorId); if (command.moveTo && command.moveTo !== release.id)
await this.access.openReleaseOf(project, command.moveTo);
release.archive(command.actorId, command.moveTo);
await release.commitAndWait(); await release.commitAndWait();
return ack(release); return ack(release);
} }

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