# 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`) ```mermaid 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. `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](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. ### 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 [*] --> 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_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` devuelve 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 (`OUTWARD` en el origen, `INWARD` en el destino). - **Releases**: solo las `PLANNED` aceptan o pierden tickets; publicar con tickets sin terminar exige `force: 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/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. - **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. - **É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. - **Integración con Git**: un owner o admin la activa en la configuración del proyecto y obtiene un secreto. El host llama a `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`. Cada commit, rama o PR que nombre una clave del proyecto (`WEB-12`, también en minúsculas) queda en `devLinks` del ticket (`IssueDevLinkRecordedEvent`, actor `system`); el mismo enlace solo vuelve a contar si cambia (una PR fusionada). Proyecto desconocido, integración apagada o firma mala responden igual: 403. El secreto vive en MongoDB (`git_integrations`), no en el 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=`. 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. ### 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` | | Te añaden a un proyecto | La persona añadida | `ADDED_TO_PROJECT` | 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 comenta pasa a seguirlo (`CommentersFollowSaga`). El id es `::`, 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](07-despliegue.md)). ## 3.2 GraphQL Las operaciones requieren `Authorization: Bearer ` (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, 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, 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) | | Comentarios | `addComment`, `editComment`, `deleteComment` | | Webhooks | `createWebhook` (devuelve el secreto), `updateWebhook`, `deleteWebhook`, `testWebhook` | | Git | `enableGitIntegration` (también para cambiar el secreto), `disableGitIntegration` | | Sprints | `createSprint`, `updateSprint`, `startSprint`, `closeSprint(carryOverToSprintId)`, `deleteSprint`, `setIssueSprint` | | Vistas | `saveFilter(input)` (devuelve el id), `deleteSavedFilter(id)` | | Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` | | Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `writeReleaseNotes`, `archiveRelease` | ### 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) | | `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. ```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 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: "", type: DEPENDS_ON, targetIssueId: "" }) { 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 } } } ``` ```ts 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 ` (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_ contra `oauth.pireon.dev/oauth/authorize` y, si `PIREON_PASSWORD_GRANT=true`, _password_. El intercambio de tokens va por `POST /api/auth/token` (Pireon no tiene CORS en `/oauth/token` y el `client_secret` se queda en el servidor). Para usar «Authorize» en Swagger UI, registra `/api/docs/oauth2-redirect.html` como 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`), `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/activity`, `…/members` (POST, PATCH, DELETE) | | Tickets | `GET /api/me/issues` (mi trabajo), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history` | | Comentarios | `PATCH/DELETE /api/comments/{commentId}` | | 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/POST/DELETE /api/projects/{project}/git-integration`, `POST /api/webhooks/git/{project}` (lo llama el host de Git) | | 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 (reintentar) y `422` regla de negocio (p. ej. una transición que el workflow no permite). ### Generar un cliente en otra app ```sh 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](05-autenticacion.md).