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>
63 KiB
3. API
API GraphQL en /graphql (HTTP para queries y mutations, WebSocket graphql-ws para subscriptions) más unos pocos
endpoints REST bajo /api. El esquema completo se genera en apps/api/schema.gql.
3.1 Modelo de dominio (contexto tracker)
classDiagram
class Project {
key: "TS"
name
members: (userId, role)[]
settings: estados · transiciones · tipos · áreas
addMember() changeMemberRole() removeMember() updateDetails() updateSettings()
}
class Issue {
key: "TS-42"
type · area · priority · status
assigneeId · releaseId · labels · figmaUrl
links: (type, target)[]
changeStatus() assign() linkTo() unlink() setRelease() updateDetails()
}
class Comment {
body · authorId
edit() delete()
}
class Release {
name · status · targetDate
update() ship() archive()
}
Project "1" --> "*" Issue
Issue "1" --> "*" Comment
Project "1" --> "*" Release
Release "0..1" <-- "*" Issue
Issue "*" --> "*" Issue : links
Roles por proyecto
| Rol | Ver | Crear/modificar tickets, comentar | Gestionar miembros y releases |
|---|---|---|---|
OWNER (quien crea el proyecto) |
✅ | ✅ | ✅ (no se le puede quitar ni cambiar el rol) |
ADMIN |
✅ | ✅ | ✅ |
MEMBER |
✅ | ✅ | ❌ |
VIEWER |
✅ | ❌ | ❌ |
Cualquiera puede salir de un proyecto (salvo el propietario). Los no miembros reciben not found, para no revelar que el proyecto existe.
Proyectos públicos
Con isPublic (al crear, o después con updateProject / PATCH /api/projects/{project}, propietario o admin),
cualquiera puede leer el proyecto, también sin token: el proyecto y sus miembros, tablero, tickets, comentarios,
historial, releases (progreso y burnup), actividad, seguimiento y estadísticas. Escribir sigue siendo cosa de los
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.
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}/proponer, 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
miembro vuelve a recibir not found. Los enlaces a tickets de otros proyectos solo se muestran si el visitante puede
leer esos proyectos.
Borrar un proyecto
Solo el propietario, escribiendo la clave del proyecto para confirmar (deleteProject(projectId, confirmKey) o
DELETE /api/projects/{project}?confirm=CLAVE). El proyecto desaparece al momento y, a continuación, una saga borra
sus tickets (y sus comentarios), sus releases y los enlaces que tickets de otros proyectos tenían hacia los suyos (ver
Arquitectura). La clave queda libre para un proyecto nuevo. Los eventos se quedan en el event
store como auditoría, pero desde la API no se puede deshacer.
Archivar un proyecto
El propietario archiva un proyecto terminado (archiveProject / POST /api/projects/{project}/archive) en lugar de
borrarlo: se sigue viendo todo (tablero, tickets, historia, estadísticas, y quien lo administra sigue viendo sus
reglas y webhooks), pero ninguna orden lo cambia (422, «está archivado»): ni tickets, ni comentarios, ni votos,
ni ajustes, ni miembros; las automatizaciones no se ejecutan y el formulario público no acepta propuestas. Seguir un
ticket sí se puede, porque solo atañe a quien lo sigue. unarchiveProject / POST …/unarchive lo devuelve.
Project.archivedAt dice desde cuándo.
Configuración por proyecto
Cada proyecto tiene sus estados, transiciones, tipos de ticket, áreas y campos personalizados
(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), unoDONEy 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.
Un proyecto nuevo puede empezar con la configuración de otro que administres: templateProjectId en
createProject o template (id o clave) en POST /api/projects. Se copian una vez sus estados, transiciones, tipos,
áreas, campos personalizados y automatizaciones (las pausadas, pausadas; las que asignan a alguien que no está en el
nuevo proyecto se quedan fuera). Las etiquetas no tienen catálogo, así que no hay nada que copiar.
Los proyectos creados antes de que existiera la configuración usan los valores por defecto (sus eventos no la llevan). Al arrancar, la API rellena la categoría de los tickets del read model que no la tienen.
Equipos
settings.teams: grupos de miembros (id, name, memberIds, archived), como mucho 30. Al guardar, quien no es
miembro del proyecto se quita del equipo; un equipo no se borra, se archiva; y si teams no viene, se quedan como
estaban. Un ticket va a un equipo con team (en createIssue / updateIssue y por REST), y la lista se filtra con
teams (?team=FRONT). En un comentario, @[Frontend](team-FRONT) avisa a cada miembro del equipo («mencionó a tu
equipo…»).
Presencia
Quién más está en un ticket ahora. La página de un ticket late cada 10 s con touchPresence(issueId, editing) (o
PUT /api/issues/{issue}/presence con { editing }), y la respuesta es quién más está, primero los que editan. Se
guarda en Redis (un hash por ticket, task-stream:presence:<id>) y una entrada sin latido en 30 s desaparece sola;
leavePresence (DELETE …/presence) la quita al salir. No es historia: no pasa por el event store. Solo para quien
puede leer el ticket.
Definición de «Hecho»
settings.checklistBeforeDone (desactivada por defecto): con ella, un ticket no pasa a un estado de categoría DONE
mientras le quede algún paso de la checklist sin marcar (422, «quedan N pasos…»). El propietario y los admins sí
pueden cerrarlo; las automatizaciones, no. Al guardar la configuración, si no viene, se queda como estaba.
Campos personalizados
Datos propios del proyecto en cada ticket («Navegador», «Cliente», «Coste»…), en settings.customFields: id, name,
type y archived, más options si es una lista. Un proyecto tiene como mucho 30, y empieza sin ninguno.
| Tipo | Valor |
|---|---|
TEXT |
Hasta 500 caracteres |
NUMBER |
Un número (se guarda en forma canónica: 1,5 → 1.5) |
DATE |
YYYY-MM-DD |
SELECT |
Una de sus options (de 1 a 50, de hasta 60 caracteres, únicas) |
- Como el resto de la configuración, un campo no se borra: se archiva. Su tipo no cambia una vez guardado, para que los valores que ya hay sigan teniendo sentido. Un campo archivado se puede vaciar en un ticket, pero no rellenar.
- Al guardar la configuración, si
customFieldsno viene se quedan como estaban (los clientes que no los conocen no los pierden). - En el ticket: GraphQL
Issue.customFieldses una lista{ fieldId, value }(solo los que tienen valor) y se cambia concustomFields: [{ fieldId, value }]encreateIssue/updateIssue; por REST es un objeto{ "BROWSER": "Safari" }. En ambos se mandan solo los campos que cambian, ynullo""vacía uno. - Filtrar:
issues(filter: { customFields: [{ fieldId, value }] })oGET /api/projects/{project}/issues?field=BROWSER:Safari(se puede repetir). IssueUpdatedEventlleva el mapa entero de valores ya validado, así la proyección solo lo copia.
Workflow por defecto
stateDiagram-v2
[*] --> BACKLOG
BACKLOG --> TODO
BACKLOG --> IN_PROGRESS
BACKLOG --> CANCELLED
TODO --> BACKLOG
TODO --> IN_PROGRESS
TODO --> CANCELLED
IN_PROGRESS --> TODO
IN_PROGRESS --> IN_REVIEW
IN_PROGRESS --> READY_FOR_QA
IN_PROGRESS --> CANCELLED
IN_REVIEW --> IN_PROGRESS
IN_REVIEW --> READY_FOR_QA
IN_REVIEW --> DONE
READY_FOR_QA --> IN_QA
READY_FOR_QA --> IN_PROGRESS
IN_QA --> DONE
IN_QA --> QA_FAILED
QA_FAILED --> IN_PROGRESS
QA_FAILED --> TODO
DONE --> TODO
DONE --> IN_PROGRESS
CANCELLED --> BACKLOG
QA_FAILEDyCANCELLEDexigen un motivo (requiresReason), que queda en el historial.- No se puede pasar a
DONEsaltándose QA salvo desdeIN_REVIEW(cambios que no requieren QA). Issue.allowedTransitionsdevuelve los estados activos a los que puede pasar el ticket según el workflow de su proyecto.
Otras reglas
- Proyecto: clave de 2 a 10 caracteres alfanuméricos que empieza por letra y es única (los tickets se numeran
CLAVE-1,CLAVE-2…). - Asignación: solo a miembros del proyecto. Para añadir miembros, el usuario debe existir y estar activo en Pireon.
- Relaciones: nunca consigo mismo ni duplicadas; pueden cruzar proyectos si el actor puede ver el destino. Se
muestran en los dos tickets (
OUTWARDen el origen,INWARDen el destino). - Releases: solo las
PLANNEDaceptan o pierden tickets; publicar con tickets sin terminar exigeforce: true. 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/draftpropone 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 encommentCount. - 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
: la subida respondeattachmentId, 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 dareactionscon quién eligió cada una. No generan actividad ni avisos. - 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.blockedBylo 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}/duplicatecopia 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:
figmaUrldebe ser un enlacehttps://…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. - É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 (IssueRankedEventno existe para él). Las columnas del tablero yissues(filter: { sort: RANK })siguen este orden. - 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 comoX-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 endevLinksdel ticket con el nombre de su repositorio (IssueDevLinkRecordedEvent, actorsystem); 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}/showcaseresume 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 etiquetagood-first-issue. Sin token, conCache-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}/trashlos lista. A losTRASH_DAYSdí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/bulkaplica el mismo cambio (estado, persona ounassign, sprint onoSprint, release onoRelease, 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. - Archivar una release:
archiveRelease(releaseId, moveTo)/POST /api/releases/{id}/archive({ moveTo }). Sus tickets sin terminar pasan amoveTo(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 concarryOverTo. - Plantillas por tipo: en la configuración, cada tipo de ticket puede llevar
templatecon 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 comosystem(«TaskStream») y respetando el flujo: si la transición no está permitida, la regla no hace nada y lo deja enlastError. 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 enwebhookEvents/GET /api/webhook-events), a familias (issue.*) o a todo (*). Cada envío es unPOSTJSON conevent,deliveryId,occurredAt,actorId,project,issue(si va de un ticket) ydata(los campos del evento), y las cabecerasX-TaskStream-Event,X-TaskStream-DeliveryyX-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 conWEBHOOKS_ALLOW_PRIVATE=true. - Importar y exportar:
GET /api/projects/{project}/export.csvda 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, multipartfile, 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. CondryRun=truesolo 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
PLANNEDaACTIVE(uno solo en curso por proyecto) y aCLOSED; 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.
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). 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
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, ProjectSettingsChangedEvent, ProjectMemberAddedEvent, ProjectMemberRoleChangedEvent, ProjectMemberRemovedEvent, ProjectDeletedEvent |
| Issue | IssueCreatedEvent, IssueUpdatedEvent, IssueStatusChangedEvent, IssueAssignedEvent, IssueLinkedEvent, IssueUnlinkedEvent, IssueReleaseChangedEvent, IssueAttachmentAddedEvent, IssueAttachmentRemovedEvent, IssueWatchedEvent, IssueUnwatchedEvent, IssueParentChangedEvent, IssueRankedEvent, IssueSprintChangedEvent, IssueDevLinkRecordedEvent, IssueChecklistItemAddedEvent, IssueChecklistItemUpdatedEvent, IssueChecklistItemRemovedEvent, IssueDeletedEvent |
| Comment | CommentAddedEvent, CommentEditedEvent, CommentDeletedEvent |
| 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
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 |
| 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 |
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).
El panel del proyecto (Project.insights en GraphQL, GET /api/projects/{project}/insights) lee lo mismo para
decir qué mirar ahora (analytics/insights.ts; acepta since, un instante ISO):
| Bloque | Qué da |
|---|---|
| Sprint en curso | Días que quedan, tickets y puntos hechos, ritmo (puntos al día) y el que haría falta; behind si no llega |
| Riesgo | Abiertos vencidos, que vencen en 7 días, bloqueados (por un ticket abierto) y atascados (7 días o más sin moverse) |
| Antigüedad | Lo que está en curso o en QA, por días en su estado, con el percentil 85 del tiempo de ciclo (90 días) como umbral |
| Cuellos de botella | Días de media en cada estado en curso o de QA (estancias que terminaron en 90 días), cuántos hay y su límite WIP |
| Próximas releases | Para cada release planificada (por fecha objetivo): fecha probable (p50) y segura (p85) simulando 500 futuros con lo que se cerró cada día de las últimas 6 semanas (Monte Carlo); cada release cuenta lo que queda de las anteriores. ON_TRACK, AT_RISK, LATE, NO_DATE, UNKNOWN (sin ritmo aún) o DONE |
| Cambios | Con since (por ejemplo, la última visita): tickets creados, terminados y vueltos de QA desde entonces, y mis menciones en el proyecto |
| Equipo | Por persona: tickets y puntos abiertos, bloqueados, puntos cerrados por sprint (últimos 3 cerrados) y overloaded si tiene abiertos más de los que suele cerrar |
| Salud del backlog | Abiertos sin estimar, sin área, sin asignar y sin tocar en 90 días |
| Calidad | Bugs (tipos con icono bug): abiertos por prioridad, nuevos y cerrados por semana (8 semanas), lo que QA devolvió y dejó pasar por área (90 días) y bugs abiertos en los 14 días tras cada una de las 3 últimas releases |
| Campos | Para cada campo personalizado de lista: tickets abiertos por opción y sin valor |
3.2 GraphQL
Las operaciones requieren Authorization: Bearer <token de Pireon> (en subscriptions, en el connection_init), salvo
las lecturas de proyectos públicos (project, issue, issues, release, activity, publicProjects y sus campos),
que funcionan también sin token.
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 |
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 |
publicProjects |
Proyectos públicos (sin token) |
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, 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 |
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, updateProjectSettings, deleteProject(confirmKey), addProjectMember, changeProjectMemberRole, removeProjectMember |
| Tickets | createIssue, updateIssue, changeIssueStatus, assignIssue, linkIssues, unlinkIssues, setIssueRelease, setIssueParent, rankIssue, addChecklistItem, updateChecklistItem, removeChecklistItem, watchIssue, unwatchIssue, removeAttachment (subir: por REST), duplicateIssue, voteIssue |
| Comentarios | addComment(replyTo), editComment, deleteComment, reactToComment |
| Webhooks | createWebhook (devuelve el secreto), updateWebhook, deleteWebhook, testWebhook |
| Git | addGitRepository (devuelve su secreto), rotateGitRepositorySecret, removeGitRepository |
| Sprints | createSprint, updateSprint, startSprint, closeSprint(carryOverToSprintId), deleteSprint, setIssueSprint |
| Vistas | saveFilter(input) (devuelve el id), deleteSavedFilter(id), GET/POST /api/me/views, DELETE /api/me/views/{id} (mis vistas de «Mi trabajo») |
| Avisos | markNotificationsRead(ids), updateNotificationSettings(email) |
| Releases | createRelease, updateRelease, publishRelease(force), writeReleaseNotes, archiveRelease |
Subscriptions
| Subscription | Se emite cuando |
|---|---|
issueChanged(projectId) |
Se crea o cambia cualquier ticket del proyecto (incluye el ticket completo y action = nombre del evento) |
commentChanged(issueId) |
Se añade, edita o borra un comentario (comment es null al borrar, o el que queda sin texto si tenía respuestas) |
activityAdded(projectId) |
Hay una nueva entrada en el feed |
releaseChanged(projectId) |
Cambia una release |
sprintChanged(projectId) |
Se planifica, cambia, empieza, cierra o borra un sprint (sprint es null al borrar) |
projectChanged(projectId) |
Cambian los datos, la configuración o los miembros del proyecto |
myProjectsChanged |
Me añaden a un proyecto o me quitan de uno |
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.
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
key
version
}
}
mutation {
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
}
}
mutation {
changeIssueStatus(input: { issueId: "…", status: "QA_FAILED", reason: "Sigue fallando en Safari 17" }) {
version
}
}
mutation {
linkIssues(input: { issueId: "<FE>", type: DEPENDS_ON, targetIssueId: "<BE>" }) {
version
}
}
query {
project(idOrKey: "SHOP") {
stats {
total
open
byStatus {
key
count
}
byAssignee {
user {
name
}
count
}
}
board {
status
total
issues {
key
title
assignee {
name
}
}
}
releases {
name
status
progress {
percent
}
}
analytics(days: 90) {
createdVsResolved {
week
created
resolved
}
cycleTime {
averageDays
p85Days
}
qa {
failureRate
}
}
}
release(id: "…") {
burnup {
dates
scope
done
}
}
issue(idOrKey: "SHOP-1") {
status
allowedTransitions
links {
type
direction
issueKey
}
comments {
items {
author {
name
}
body
}
}
history {
summary
occurredAt
}
}
}
import { createClient } from 'graphql-ws';
const client = createClient({
url: 'ws://localhost:3000/graphql',
connectionParams: { authorization: `Bearer ${token}` },
});
client.subscribe(
{
query: 'subscription($p: ID!) { issueChanged(projectId: $p) { action version issue { key status } } }',
variables: { p: projectId },
},
{ next: console.log, error: console.error, complete: () => {} },
);
Errores
Los errores de dominio llegan como errores GraphQL con extensions.code:
| Código | Significado |
|---|---|
UNAUTHENTICATED |
Falta el token o Pireon lo rechaza |
NOT_FOUND |
No existe o no eres miembro |
FORBIDDEN |
No tienes permiso (p. ej. un VIEWER modificando) |
VALIDATION |
Datos inválidos (clave de proyecto, URL de Figma…) |
BUSINESS_RULE |
Regla de negocio (transición no permitida, release bloqueada…) |
CONFLICT |
Otro cambio llegó antes (concurrencia optimista): reintenta |
3.3 REST con OpenAPI (para otras apps)
Los mismos casos de uso que GraphQL, como API REST documentada con OpenAPI 3.0, para que otras apps de la plataforma
la consuman o generen un cliente. Son otro adaptador de entrada (presenters/http) que llama a los mismos
*.service.ts: misma validación, permisos y errores.
| Swagger UI | /api/docs (con «Authorize» contra Pireon) |
| Documento OpenAPI | /api/docs-json (y /api/docs-yaml) |
| Fichero exportado | apps/api/openapi.json: lo reescribe nx serve api al arrancar, como schema.gql |
Autenticación: siempre a través de Pireon
Cualquier app o usuario que la consuma pasa por el OAuth de Pireon: todas las rutas, salvo health, auth, el
webhook, explore y las lecturas de proyectos públicos, exigen Authorization: Bearer <token de Pireon> (el
AuthGuard global, igual que en GraphQL). En el documento OpenAPI, las rutas donde el token es opcional lo indican con
un requisito de seguridad vacío ({}). Declara dos esquemas:
pireon-oauth(OAuth2): Authorization Code + PKCE contraoauth.pireon.dev/oauth/authorizey, siPIREON_PASSWORD_GRANT=true, password. El intercambio de tokens va porPOST /api/auth/token(Pireon no tiene CORS en/oauth/tokeny elclient_secretse queda en el servidor). Para usar «Authorize» en Swagger UI, registra<api>/api/docs/oauth2-redirect.htmlcomo redirect URI del cliente en Pireon.pireon(Bearer): para una app que ya tiene un token de Pireon, por ejemplo con su propio cliente OAuth.
Lo que cada usuario puede ver o cambiar depende de su rol en cada proyecto, como en la web.
Recursos
Proyectos y tickets se pueden indicar por id o por clave (WEB, WEB-12).
| Recurso | Rutas |
|---|---|
| Usuarios | GET /api/users/me, GET /api/users?search=, GET /api/users/{id} |
| Proyectos | GET/POST /api/projects, GET/PATCH/DELETE /api/projects/{project} (DELETE …?confirm=CLAVE), POST …/archive, POST …/unarchive, PUT …/settings, …/board, …/epics, …/stats, …/analytics?days=, …/insights, …/activity, …/members (POST, PATCH, DELETE) |
| Tickets | GET /api/me/issues (mi trabajo; filtros project, priority, bugsOnly, overdue), GET/POST /api/projects/{project}/issues, GET/PATCH /api/issues/{issue}, PUT …/status, PUT …/assignee, PUT …/release, PUT/DELETE …/watch, PUT/DELETE …/presence, PUT …/parent, PUT …/rank, POST …/checklist, PATCH/DELETE …/checklist/{item}, POST/DELETE …/links, GET/POST …/comments, GET …/history, POST /api/issues/{issue}/duplicate, PUT/DELETE …/vote |
| Comentarios | PATCH/DELETE /api/comments/{commentId}, POST …/reactions |
| Sprints | GET/POST /api/projects/{project}/sprints, GET/PATCH/DELETE /api/sprints/{sprint}, POST …/start, POST …/close, GET …/progress, …/burndown, …/issues, PUT /api/issues/{issue}/sprint |
| Vistas | GET/POST /api/projects/{project}/filters, DELETE /api/filters/{id} |
| Avisos | GET /api/me/notifications, POST /api/me/notifications/read, GET/PUT /api/me/notification-settings |
| Adjuntos | POST /api/issues/{issue}/attachments (multipart: file y opcional commentId), GET/DELETE /api/issues/{issue}/attachments/{id} |
| Releases | GET /api/me/releases (próximas), GET/POST /api/projects/{project}/releases, GET/PATCH /api/releases/{id}, PUT …/notes, GET …/notes/draft, …/progress, …/burnup, …/issues, POST …/publish, POST …/archive |
| Explorar | GET /api/explore/projects (proyectos públicos, sin token) |
| Importar | GET /api/projects/{project}/export.csv, POST /api/projects/{project}/import?dryRun= (multipart file) |
| Webhooks | GET /api/webhook-events, GET/POST /api/projects/{project}/webhooks, PATCH/DELETE /api/outgoing-webhooks/{id}, POST …/test |
| Git | GET /api/projects/{project}/git-integration, POST …/git-integration/repositories, POST …/repositories/{id}/secret, DELETE …/repositories/{id}, POST /api/webhooks/git/{project} (lo llama el host de Git) |
| Explorar | GET /api/explore/projects/{project}/showcase (sin token, proyectos públicos): hoja de ruta con votos, versiones, sprint en curso, tickets good-first-issue y propuestas abiertas; POST …/proposals (sin token) |
| Sistema | GET /api/health, GET /api/auth/config, POST /api/auth/token, POST /api/webhooks/pireon |
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
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.
Errores
{ statusCode, code?, message }: 400 entrada no válida (message es una lista), 401 sin token o token rechazado
por Pireon, 403/404 sin acceso, 409 cambio concurrente (la API ya lo ha reintentado 5 veces; raro) y 422 regla de negocio (p. ej. una
transición que el workflow no permite).
Generar un cliente en otra app
npx openapi-typescript apps/api/openapi.json -o src/task-stream-api.d.ts # tipos
# o un cliente completo: openapi-generator, orval, ng-openapi-gen…
Ver también Autenticación.