# 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)[] addMember() changeMemberRole() removeMember() updateDetails() } 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. ### Workflow de un ticket ```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**, 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. ### 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`. - **Comentarios**: solo el autor edita; borra el autor o un admin del proyecto. - **Cambios de UI**: `figmaUrl` debe ser un enlace `https://…figma.com/…`. ### 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`, `ProjectMemberAddedEvent`, `ProjectMemberRoleChangedEvent`, `ProjectMemberRemovedEvent` | | Issue | `IssueCreatedEvent`, `IssueUpdatedEvent`, `IssueStatusChangedEvent`, `IssueAssignedEvent`, `IssueLinkedEvent`, `IssueUnlinkedEvent`, `IssueReleaseChangedEvent` | | Comment | `CommentAddedEvent`, `CommentEditedEvent`, `CommentDeletedEvent` | | Release | `ReleaseCreatedEvent`, `ReleaseUpdatedEvent`, `ReleaseShippedEvent`, `ReleaseArchivedEvent` | ## 3.2 GraphQL Todas las operaciones requieren `Authorization: Bearer ` (en subscriptions, en el `connection_init`). ### 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 | ### 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` | ### 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 | | `projectChanged(projectId)` | Cambian los datos 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 ```graphql 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 } } } 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` y el webhook, exigen `Authorization: Bearer ` (el `AuthGuard` global, igual que en GraphQL). El documento 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 /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` | 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).