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