diff --git a/.env.example b/.env.example index 54aea6a..b55e727 100644 --- a/.env.example +++ b/.env.example @@ -28,6 +28,10 @@ AUTH_SESSION_TTL_SECONDS=60 GRAPHQL_PLAYGROUND=true +# REST + OpenAPI for other apps (Swagger UI at /api/docs). The document is also written to OPENAPI_FILE. +SWAGGER_ENABLED=true +OPENAPI_FILE=apps/api/openapi.json + # Docker builds only: token of Pireon's Nexus for @pi/ui (build secret, never stored in an image). # Locally: export NEXUS_TOKEN=$(grep '^//nexus.pireon.dev/repository/npm-releases/:_authToken=' ~/.npmrc | cut -d= -f2-) NEXUS_TOKEN= diff --git a/.prettierignore b/.prettierignore index a1d1bd1..516a5d1 100644 --- a/.prettierignore +++ b/.prettierignore @@ -5,3 +5,7 @@ /.nx/workspace-data package-lock.json .angular + +# Generated by the API on startup (nx serve api): keep them byte-for-byte as written +apps/api/openapi.json +apps/api/schema.gql diff --git a/README.md b/README.md index 1a7f837..74917c8 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ Alternativa a Jira para equipos de desarrollo: proyectos, tickets (bugs, features, cambios de UI, tareas y QA), releases, comentarios y relaciones entre tickets, **en tiempo real**. -- **API**: NestJS + GraphQL, arquitectura hexagonal, CQRS y event sourcing (escritura en PostgreSQL, lectura en MongoDB, +- **API**: NestJS + GraphQL y REST con OpenAPI (Swagger, para otras apps), arquitectura hexagonal, CQRS y event sourcing (escritura en PostgreSQL, lectura en MongoDB, Redis entre ambas). - **Web**: Angular 22 + NgRx Signals (patrón de eventos) + `@pi/ui`, con la misma arquitectura. - **Usuarios**: los gestiona [Pireon](https://oauth.pireon.dev/api/documentation). @@ -21,7 +21,7 @@ Para entrar hace falta un cliente OAuth de Pireon en `.env` (`PIREON_CLIENT_ID` npm install # @pi/ui necesita el token del Nexus de Pireon en ~/.npmrc docker compose up -d postgres mongo redis cp .env.example .env -npx nx serve api # http://localhost:3000/graphql +npx nx serve api # http://localhost:3000/graphql · REST: /api/docs npx nx serve web # http://localhost:4200 ``` @@ -31,7 +31,7 @@ npx nx serve web # http://localhost:4200 | ------------------------------------------- | -------------------------------------------------------------------- | | [Visión general](docs/01-vision-general.md) | Funcionalidades, stack y estructura del monorepo | | [Arquitectura](docs/02-arquitectura.md) | Capas, CQRS, event sourcing, flujo escritura → lectura y tiempo real | -| [API](docs/03-api.md) | Dominio, workflow de tickets, eventos y operaciones GraphQL | +| [API](docs/03-api.md) | Dominio, workflow de tickets, eventos, GraphQL y REST (OpenAPI) | | [Cliente web](docs/04-cliente-web.md) | Stores con eventos, GraphQL y `@pi/ui` | | [Autenticación](docs/05-autenticacion.md) | Tokens de Pireon, login en el navegador y webhooks | | [Desarrollo](docs/06-desarrollo.md) | Comandos, tests, cómo añadir una funcionalidad y problemas conocidos | diff --git a/apps/api/e2e/tracker.e2e-spec.ts b/apps/api/e2e/tracker.e2e-spec.ts index 9593485..689eaad 100644 --- a/apps/api/e2e/tracker.e2e-spec.ts +++ b/apps/api/e2e/tracker.e2e-spec.ts @@ -1,11 +1,10 @@ -import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { IdentityProvider, InvalidAccessTokenError, } from '@task-stream/api/identity/application'; import { User, UserFactory } from '@task-stream/api/identity/domain'; -import { DomainErrorFilter } from '@task-stream/api/shared/presenters'; import { Client, createClient } from 'graphql-ws'; import WebSocket from 'ws'; @@ -16,9 +15,11 @@ process.env['MONGO_URL'] ??= 'mongodb://localhost:27017/taskstream_e2e'; process.env['REDIS_URL'] ??= 'redis://localhost:6379'; process.env['EVENT_STREAM_KEY'] = `task-stream:e2e:${Date.now()}`; process.env['GRAPHQL_SCHEMA_FILE'] = ''; +process.env['OPENAPI_FILE'] = ''; process.env['AUTH_SESSION_TTL_SECONDS'] = '0'; const { AppModule } = require('../src/app/app.module'); +const { configureApp } = require('../src/app/configure'); /** Pireon is replaced by fixed tokens: the rest of the stack is real. */ const users = new UserFactory(); @@ -71,6 +72,25 @@ describe('task-stream (e2e)', () => { return body.data; } + /** REST call under /api; returns status and parsed body. */ + async function rest>( // eslint-disable-line @typescript-eslint/no-explicit-any + token: string | null, + method: string, + path: string, + body?: object, + ): Promise<{ status: number; body: T }> { + const response = await fetch(`${url}/api${path}`, { + method, + headers: { + 'content-type': 'application/json', + ...(token ? { authorization: `Bearer ${token}` } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + const text = await response.text(); + return { status: response.status, body: text ? JSON.parse(text) : null }; + } + /** The read model is eventually consistent: poll until it catches up. */ async function eventually( read: () => Promise, @@ -123,10 +143,7 @@ describe('task-stream (e2e)', () => { .useValue(fakePireon) .compile(); app = moduleRef.createNestApplication({ rawBody: true }); - app.useGlobalPipes( - new ValidationPipe({ transform: true, whitelist: true }), - ); - app.useGlobalFilters(new DomainErrorFilter()); + configureApp(app); await app.listen(0); url = await app.getUrl(); url = url.replace('[::1]', 'localhost'); @@ -405,4 +422,139 @@ describe('task-stream (e2e)', () => { expect(anonymous.received).toHaveLength(0); expect(anonymous.errors).toHaveLength(1); }); + + it('exposes the same use cases over REST, documented with OpenAPI', async () => { + // --- The contract other apps generate clients from --- + const docs = await rest(null, 'GET', '/docs-json'); + expect(docs.status).toBe(200); + expect(Object.keys(docs.body['paths'])).toEqual( + expect.arrayContaining([ + '/api/projects', + '/api/projects/{project}/issues', + '/api/issues/{issue}/status', + '/api/releases/{releaseId}/publish', + '/api/users/me', + ]), + ); + expect(Object.keys(docs.body['components']['securitySchemes'])).toEqual( + expect.arrayContaining(['pireon-oauth', 'pireon']), + ); + + // --- Every consumer goes through Pireon's OAuth --- + expect((await rest(null, 'GET', '/projects')).status).toBe(401); + expect((await rest('not-a-token', 'GET', '/projects')).status).toBe(401); + expect((await rest('token-ana', 'GET', '/users/me')).body).toMatchObject({ + id: '101', + name: 'Ana (lead)', + }); + + // --- Project and members, addressed by key --- + const key = 'R' + Date.now().toString(36).slice(-5).toUpperCase(); + const created = await rest('token-ana', 'POST', '/projects', { + key, + name: 'REST project', + }); + expect(created.status).toBe(201); + await eventually( + () => rest('token-ana', 'GET', `/projects/${key}`), + (r) => r.status === 200, + ); + expect( + ( + await rest('token-ana', 'POST', `/projects/${key}/members`, { + userId: '103', + role: 'MEMBER', + }) + ).status, + ).toBe(201); + + // --- Tickets: create, workflow, relations, comments --- + const invalid = await rest('token-ana', 'POST', `/projects/${key}/issues`, { + type: 'NOPE', + title: '', + }); + expect(invalid.status).toBe(400); + + const be = await rest('token-ana', 'POST', `/projects/${key}/issues`, { + type: 'FEATURE', + title: 'Endpoint de cupones', + area: 'BACKEND', + }); + const qa = await rest('token-qa', 'POST', `/projects/${key}/issues`, { + type: 'QA', + title: 'Probar cupones', + }); + expect(be.body).toMatchObject({ key: `${key}-1`, version: 1 }); + const beKey = be.body['key'] as string; + const qaKey = qa.body['key'] as string; + await eventually( + () => rest('token-ana', 'GET', `/issues/${beKey}`), + (r) => r.status === 200, + ); + + const skip = await rest('token-ana', 'PUT', `/issues/${beKey}/status`, { + status: 'DONE', + }); + expect(skip.status).toBe(422); + expect(skip.body).toMatchObject({ code: 'BUSINESS_RULE' }); + + expect( + ( + await rest('token-ana', 'PUT', `/issues/${beKey}/status`, { + status: 'TODO', + }) + ).status, + ).toBe(200); + expect( + ( + await rest('token-ana', 'PUT', `/issues/${beKey}/assignee`, { + assigneeId: '103', + }) + ).status, + ).toBe(200); + expect( + ( + await rest('token-qa', 'POST', `/issues/${qaKey}/links`, { + type: 'TESTS', + targetIssue: beKey, + }) + ).status, + ).toBe(201); + expect( + ( + await rest('token-qa', 'POST', `/issues/${beKey}/comments`, { + body: 'Lo pruebo en cuanto esté en QA', + }) + ).status, + ).toBe(201); + + const issue = await eventually( + () => rest('token-ana', 'GET', `/issues/${beKey}`), + (r) => r.body?.['status'] === 'TODO' && r.body?.['links']?.length === 1, + ); + expect(issue.body).toMatchObject({ + key: beKey, + assignee: { id: '103' }, + links: [{ type: 'TESTS', direction: 'INWARD', issueKey: qaKey }], + allowedTransitions: expect.arrayContaining(['IN_PROGRESS']), + }); + + const search = await rest( + 'token-ana', + 'GET', + `/projects/${key}/issues?status=TODO,BACKLOG&type=FEATURE`, + ); + expect(search.body).toMatchObject({ total: 1, items: [{ key: beKey }] }); + + const comments = await eventually( + () => rest('token-ana', 'GET', `/issues/${beKey}/comments`), + (r) => r.body?.['total'] === 1, + ); + expect(comments.body['items'][0]).toMatchObject({ author: { id: '103' } }); + + // --- Same authorisation as GraphQL --- + expect((await rest('token-eve', 'GET', `/issues/${beKey}`)).status).toBe( + 404, + ); + }); }); diff --git a/apps/api/openapi.json b/apps/api/openapi.json new file mode 100644 index 0000000..a9a2eb5 --- /dev/null +++ b/apps/api/openapi.json @@ -0,0 +1,5176 @@ +{ + "openapi": "3.0.0", + "paths": { + "/api/health": { + "get": { + "operationId": "HealthController_check", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "example": { + "status": "ok", + "postgres": "up", + "mongo": "up", + "redis": "up" + } + } + } + } + }, + "503": { + "description": "A database is down" + } + }, + "summary": "API and databases status", + "tags": [ + "Health" + ], + "security": [] + } + }, + "/api/auth/config": { + "get": { + "operationId": "AuthController_config", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthorizationConfigDto" + } + } + } + } + }, + "summary": "How to sign in with Pireon (null when not configured)", + "tags": [ + "Auth" + ], + "security": [] + } + }, + "/api/auth/token": { + "post": { + "description": "For browsers (Pireon has no CORS) and Swagger UI: adds the client secret server-side. Apps with their own Pireon OAuth client can get tokens from Pireon directly; any valid Pireon access token is accepted by this API.", + "operationId": "AuthController_token", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IssueTokenDto" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/IssueTokenDto" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenResponseDto" + } + } + } + }, + "401": { + "description": "Pireon rejected the grant" + } + }, + "summary": "Exchange a grant for Pireon tokens (proxy of Pireon /oauth/token)", + "tags": [ + "Auth" + ], + "security": [] + } + }, + "/api/users/me": { + "get": { + "operationId": "UsersController_me", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MeDto" + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "The user behind the access token", + "tags": [ + "Users" + ] + } + }, + "/api/users": { + "get": { + "description": "Needs the `users:read` scope in the access token.", + "operationId": "UsersController_search", + "parameters": [ + { + "name": "search", + "required": false, + "in": "query", + "description": "Name or email", + "schema": { + "default": "", + "allOf": [ + { + "$ref": "#/components/schemas/Object" + } + ] + } + }, + { + "name": "limit", + "required": false, + "in": "query", + "schema": { + "minimum": 1, + "maximum": 100, + "default": 20, + "allOf": [ + { + "$ref": "#/components/schemas/Object" + } + ] + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserDto" + } + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "Search Pireon users (to assign tickets or add members)", + "tags": [ + "Users" + ] + } + }, + "/api/users/{id}": { + "get": { + "operationId": "UsersController_findOne", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserDto" + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "A Pireon user", + "tags": [ + "Users" + ] + } + }, + "/api/webhooks/pireon": { + "post": { + "description": "`user.deactivated` closes the user's sessions; `token.revoked` forgets the token. Disabled unless PIREON_WEBHOOK_SECRET is set.", + "operationId": "PireonWebhookController_receive", + "parameters": [ + { + "name": "x-webhook-signature", + "required": true, + "in": "header", + "schema": { + "type": "string" + } + }, + { + "name": "X-Webhook-Signature", + "in": "header", + "description": "HMAC-SHA256 of the raw body with PIREON_WEBHOOK_SECRET (hex)", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Processed" + }, + "401": { + "description": "Missing or invalid signature" + } + }, + "summary": "Pireon events (called by Pireon, not by apps)", + "tags": [ + "Webhooks" + ], + "security": [] + } + }, + "/api/projects": { + "get": { + "operationId": "ProjectsController_mine", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProjectDto" + } + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "Projects where I am a member", + "tags": [ + "Projects" + ] + }, + "post": { + "operationId": "ProjectsController_create", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateProjectDto" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommandResultDto" + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "409": { + "description": "Someone changed it at the same time: retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "422": { + "description": "Business rule (e.g. a transition the workflow forbids)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "Create a project (you become its owner)", + "tags": [ + "Projects" + ] + } + }, + "/api/projects/{project}": { + "get": { + "operationId": "ProjectsController_findOne", + "parameters": [ + { + "name": "project", + "required": true, + "in": "path", + "description": "Project id or key", + "schema": { + "example": "WEB", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectDto" + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "A project with its members", + "tags": [ + "Projects" + ] + }, + "patch": { + "operationId": "ProjectsController_update", + "parameters": [ + { + "name": "project", + "required": true, + "in": "path", + "description": "Project id or key", + "schema": { + "example": "WEB", + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateProjectDto" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommandResultDto" + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "409": { + "description": "Someone changed it at the same time: retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "422": { + "description": "Business rule (e.g. a transition the workflow forbids)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "Rename or describe a project (owner/admin)", + "tags": [ + "Projects" + ] + } + }, + "/api/projects/{project}/board": { + "get": { + "operationId": "ProjectsController_board", + "parameters": [ + { + "name": "project", + "required": true, + "in": "path", + "description": "Project id or key", + "schema": { + "example": "WEB", + "type": "string" + } + }, + { + "name": "releaseId", + "required": false, + "in": "query", + "description": "Only this release", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "assigneeId", + "required": false, + "in": "query", + "description": "Only this person (Pireon user id)", + "schema": { + "type": "string" + } + }, + { + "name": "area", + "required": false, + "in": "query", + "schema": { + "$ref": "#/components/schemas/IssueArea" + } + }, + { + "name": "perColumn", + "required": false, + "in": "query", + "schema": { + "minimum": 1, + "maximum": 200, + "default": 50, + "allOf": [ + { + "$ref": "#/components/schemas/Object" + } + ] + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BoardColumnDto" + } + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "Kanban board: one column per status", + "tags": [ + "Projects" + ] + } + }, + "/api/projects/{project}/stats": { + "get": { + "operationId": "ProjectsController_stats", + "parameters": [ + { + "name": "project", + "required": true, + "in": "path", + "description": "Project id or key", + "schema": { + "example": "WEB", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectStatsDto" + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "Tracking dashboard: counts by status, type, person…", + "tags": [ + "Projects" + ] + } + }, + "/api/projects/{project}/activity": { + "get": { + "operationId": "ProjectsController_activity", + "parameters": [ + { + "name": "project", + "required": true, + "in": "path", + "description": "Project id or key", + "schema": { + "example": "WEB", + "type": "string" + } + }, + { + "name": "issueId", + "required": false, + "in": "query", + "description": "Only this ticket", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "limit", + "required": false, + "in": "query", + "schema": { + "minimum": 1, + "maximum": 200, + "default": 50, + "allOf": [ + { + "$ref": "#/components/schemas/Object" + } + ] + } + }, + { + "name": "before", + "required": false, + "in": "query", + "description": "Cursor: entries older than this date", + "schema": { + "format": "date-time", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ActivityDto" + } + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "Activity feed (newest first)", + "tags": [ + "Projects" + ] + } + }, + "/api/projects/{project}/members": { + "post": { + "description": "The user must exist and be active in Pireon.", + "operationId": "ProjectsController_addMember", + "parameters": [ + { + "name": "project", + "required": true, + "in": "path", + "description": "Project id or key", + "schema": { + "example": "WEB", + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddMemberDto" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommandResultDto" + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "409": { + "description": "Someone changed it at the same time: retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "422": { + "description": "Business rule (e.g. a transition the workflow forbids)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "Add a Pireon user to the project (owner/admin)", + "tags": [ + "Projects" + ] + } + }, + "/api/projects/{project}/members/{userId}": { + "patch": { + "operationId": "ProjectsController_changeMemberRole", + "parameters": [ + { + "name": "project", + "required": true, + "in": "path", + "description": "Project id or key", + "schema": { + "example": "WEB", + "type": "string" + } + }, + { + "name": "userId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChangeMemberRoleDto" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommandResultDto" + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "409": { + "description": "Someone changed it at the same time: retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "422": { + "description": "Business rule (e.g. a transition the workflow forbids)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "Change a member's role (owner/admin)", + "tags": [ + "Projects" + ] + }, + "delete": { + "operationId": "ProjectsController_removeMember", + "parameters": [ + { + "name": "project", + "required": true, + "in": "path", + "description": "Project id or key", + "schema": { + "example": "WEB", + "type": "string" + } + }, + { + "name": "userId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommandResultDto" + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "409": { + "description": "Someone changed it at the same time: retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "422": { + "description": "Business rule (e.g. a transition the workflow forbids)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "Remove a member (owner/admin, or yourself to leave)", + "tags": [ + "Projects" + ] + } + }, + "/api/projects/{project}/issues": { + "get": { + "operationId": "IssuesController_search", + "parameters": [ + { + "name": "project", + "required": true, + "in": "path", + "description": "Project id or key", + "schema": { + "example": "WEB", + "type": "string" + } + }, + { + "name": "limit", + "required": false, + "in": "query", + "schema": { + "minimum": 1, + "maximum": 200, + "default": 50, + "allOf": [ + { + "$ref": "#/components/schemas/Object" + } + ] + } + }, + { + "name": "offset", + "required": false, + "in": "query", + "schema": { + "minimum": 0, + "default": 0, + "allOf": [ + { + "$ref": "#/components/schemas/Object" + } + ] + } + }, + { + "name": "status", + "required": false, + "in": "query", + "description": "Repeat the parameter or separate with commas", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IssueStatus" + } + } + }, + { + "name": "type", + "required": false, + "in": "query", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IssueType" + } + } + }, + { + "name": "area", + "required": false, + "in": "query", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IssueArea" + } + } + }, + { + "name": "priority", + "required": false, + "in": "query", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IssuePriority" + } + } + }, + { + "name": "assigneeId", + "required": false, + "in": "query", + "description": "Pireon user id", + "schema": { + "type": "string" + } + }, + { + "name": "unassigned", + "required": false, + "in": "query", + "description": "Only tickets without assignee", + "schema": { + "type": "boolean" + } + }, + { + "name": "releaseId", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "label", + "required": false, + "in": "query", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "search", + "required": false, + "in": "query", + "description": "Matches key, title or description", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IssuePageDto" + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "Search the tickets of a project", + "tags": [ + "Issues" + ] + }, + "post": { + "description": "Starts in BACKLOG. The response carries its key (e.g. WEB-13).", + "operationId": "IssuesController_create", + "parameters": [ + { + "name": "project", + "required": true, + "in": "path", + "description": "Project id or key", + "schema": { + "example": "WEB", + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateIssueDto" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommandResultDto" + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "409": { + "description": "Someone changed it at the same time: retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "422": { + "description": "Business rule (e.g. a transition the workflow forbids)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "Create a ticket", + "tags": [ + "Issues" + ] + } + }, + "/api/issues/{issue}": { + "get": { + "operationId": "IssuesController_findOne", + "parameters": [ + { + "name": "issue", + "required": true, + "in": "path", + "description": "Ticket id or key", + "schema": { + "example": "WEB-12", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IssueDto" + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "A ticket", + "tags": [ + "Issues" + ] + }, + "patch": { + "operationId": "IssuesController_update", + "parameters": [ + { + "name": "issue", + "required": true, + "in": "path", + "description": "Ticket id or key", + "schema": { + "example": "WEB-12", + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateIssueDto" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommandResultDto" + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "409": { + "description": "Someone changed it at the same time: retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "422": { + "description": "Business rule (e.g. a transition the workflow forbids)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "Edit title, description, type, priority, area, labels…", + "tags": [ + "Issues" + ] + } + }, + "/api/issues/{issue}/status": { + "put": { + "description": "Only the transitions in `allowedTransitions` are accepted (422 otherwise). QA_FAILED and CANCELLED need a reason.", + "operationId": "IssuesController_changeStatus", + "parameters": [ + { + "name": "issue", + "required": true, + "in": "path", + "description": "Ticket id or key", + "schema": { + "example": "WEB-12", + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChangeStatusDto" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommandResultDto" + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "409": { + "description": "Someone changed it at the same time: retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "422": { + "description": "Business rule (e.g. a transition the workflow forbids)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "Move the ticket through the workflow (incl. QA)", + "tags": [ + "Issues" + ] + } + }, + "/api/issues/{issue}/assignee": { + "put": { + "operationId": "IssuesController_assign", + "parameters": [ + { + "name": "issue", + "required": true, + "in": "path", + "description": "Ticket id or key", + "schema": { + "example": "WEB-12", + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssignIssueDto" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommandResultDto" + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "409": { + "description": "Someone changed it at the same time: retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "422": { + "description": "Business rule (e.g. a transition the workflow forbids)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "Assign to a project member (null to unassign)", + "tags": [ + "Issues" + ] + } + }, + "/api/issues/{issue}/release": { + "put": { + "operationId": "IssuesController_setRelease", + "parameters": [ + { + "name": "issue", + "required": true, + "in": "path", + "description": "Ticket id or key", + "schema": { + "example": "WEB-12", + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetIssueReleaseDto" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommandResultDto" + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "409": { + "description": "Someone changed it at the same time: retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "422": { + "description": "Business rule (e.g. a transition the workflow forbids)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "Put the ticket in a planned release (null takes it out)", + "tags": [ + "Issues" + ] + } + }, + "/api/issues/{issue}/links": { + "post": { + "description": "e.g. WEB-3 DEPENDS_ON API-7, WEB-9 TESTS WEB-3. May cross projects.", + "operationId": "IssuesController_link", + "parameters": [ + { + "name": "issue", + "required": true, + "in": "path", + "description": "Ticket id or key", + "schema": { + "example": "WEB-12", + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LinkIssueDto" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommandResultDto" + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "409": { + "description": "Someone changed it at the same time: retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "422": { + "description": "Business rule (e.g. a transition the workflow forbids)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "Relate tickets (FE ↔ BE ↔ QA)", + "tags": [ + "Issues" + ] + } + }, + "/api/issues/{issue}/links/{type}/{target}": { + "delete": { + "operationId": "IssuesController_unlink", + "parameters": [ + { + "name": "issue", + "required": true, + "in": "path", + "description": "Ticket id or key", + "schema": { + "example": "WEB-12", + "type": "string" + } + }, + { + "name": "type", + "required": true, + "in": "path", + "schema": { + "$ref": "#/components/schemas/IssueLinkType" + } + }, + { + "name": "target", + "required": true, + "in": "path", + "description": "Other ticket: id or key", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommandResultDto" + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "409": { + "description": "Someone changed it at the same time: retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "422": { + "description": "Business rule (e.g. a transition the workflow forbids)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "Remove a relation", + "tags": [ + "Issues" + ] + } + }, + "/api/issues/{issue}/comments": { + "get": { + "operationId": "IssuesController_listComments", + "parameters": [ + { + "name": "issue", + "required": true, + "in": "path", + "description": "Ticket id or key", + "schema": { + "example": "WEB-12", + "type": "string" + } + }, + { + "name": "limit", + "required": false, + "in": "query", + "schema": { + "minimum": 1, + "maximum": 200, + "default": 50, + "allOf": [ + { + "$ref": "#/components/schemas/Object" + } + ] + } + }, + { + "name": "offset", + "required": false, + "in": "query", + "schema": { + "minimum": 0, + "default": 0, + "allOf": [ + { + "$ref": "#/components/schemas/Object" + } + ] + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommentPageDto" + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "Comments of the ticket (oldest first)", + "tags": [ + "Issues" + ] + }, + "post": { + "operationId": "IssuesController_addComment", + "parameters": [ + { + "name": "issue", + "required": true, + "in": "path", + "description": "Ticket id or key", + "schema": { + "example": "WEB-12", + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommentBodyDto" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommandResultDto" + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "409": { + "description": "Someone changed it at the same time: retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "422": { + "description": "Business rule (e.g. a transition the workflow forbids)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "Comment on the ticket", + "tags": [ + "Issues" + ] + } + }, + "/api/issues/{issue}/history": { + "get": { + "operationId": "IssuesController_history", + "parameters": [ + { + "name": "issue", + "required": true, + "in": "path", + "description": "Ticket id or key", + "schema": { + "example": "WEB-12", + "type": "string" + } + }, + { + "name": "limit", + "required": false, + "in": "query", + "schema": { + "minimum": 1, + "maximum": 200, + "default": 50, + "allOf": [ + { + "$ref": "#/components/schemas/Object" + } + ] + } + }, + { + "name": "offset", + "required": false, + "in": "query", + "schema": { + "minimum": 0, + "default": 0, + "allOf": [ + { + "$ref": "#/components/schemas/Object" + } + ] + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ActivityDto" + } + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "History of the ticket (newest first)", + "tags": [ + "Issues" + ] + } + }, + "/api/comments/{commentId}": { + "patch": { + "operationId": "CommentsController_edit", + "parameters": [ + { + "name": "commentId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommentBodyDto" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommandResultDto" + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "409": { + "description": "Someone changed it at the same time: retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "422": { + "description": "Business rule (e.g. a transition the workflow forbids)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "Edit a comment (author only)", + "tags": [ + "Comments" + ] + }, + "delete": { + "operationId": "CommentsController_delete", + "parameters": [ + { + "name": "commentId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommandResultDto" + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "409": { + "description": "Someone changed it at the same time: retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "422": { + "description": "Business rule (e.g. a transition the workflow forbids)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "Delete a comment (author or project admin)", + "tags": [ + "Comments" + ] + } + }, + "/api/projects/{project}/releases": { + "get": { + "operationId": "ReleasesController_list", + "parameters": [ + { + "name": "project", + "required": true, + "in": "path", + "description": "Project id or key", + "schema": { + "example": "WEB", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReleaseDto" + } + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "Releases of a project", + "tags": [ + "Releases" + ] + }, + "post": { + "operationId": "ReleasesController_create", + "parameters": [ + { + "name": "project", + "required": true, + "in": "path", + "description": "Project id or key", + "schema": { + "example": "WEB", + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateReleaseDto" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommandResultDto" + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "409": { + "description": "Someone changed it at the same time: retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "422": { + "description": "Business rule (e.g. a transition the workflow forbids)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "Plan a release (owner/admin)", + "tags": [ + "Releases" + ] + } + }, + "/api/releases/{releaseId}": { + "get": { + "operationId": "ReleasesController_findOne", + "parameters": [ + { + "name": "releaseId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReleaseDto" + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "A release", + "tags": [ + "Releases" + ] + }, + "patch": { + "operationId": "ReleasesController_update", + "parameters": [ + { + "name": "releaseId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateReleaseDto" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommandResultDto" + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "409": { + "description": "Someone changed it at the same time: retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "422": { + "description": "Business rule (e.g. a transition the workflow forbids)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "Rename, describe or move the target date", + "tags": [ + "Releases" + ] + } + }, + "/api/releases/{releaseId}/progress": { + "get": { + "operationId": "ReleasesController_progress", + "parameters": [ + { + "name": "releaseId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReleaseProgressDto" + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "Finished tickets out of the total", + "tags": [ + "Releases" + ] + } + }, + "/api/releases/{releaseId}/issues": { + "get": { + "operationId": "ReleasesController_issuesOf", + "parameters": [ + { + "name": "releaseId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "required": false, + "in": "query", + "schema": { + "minimum": 1, + "maximum": 200, + "default": 50, + "allOf": [ + { + "$ref": "#/components/schemas/Object" + } + ] + } + }, + { + "name": "offset", + "required": false, + "in": "query", + "schema": { + "minimum": 0, + "default": 0, + "allOf": [ + { + "$ref": "#/components/schemas/Object" + } + ] + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IssuePageDto" + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "Tickets included in the release", + "tags": [ + "Releases" + ] + } + }, + "/api/releases/{releaseId}/publish": { + "post": { + "description": "Fails (422) while it has unfinished tickets unless `force` is true.", + "operationId": "ReleasesController_publish", + "parameters": [ + { + "name": "releaseId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublishReleaseDto" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommandResultDto" + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "409": { + "description": "Someone changed it at the same time: retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "422": { + "description": "Business rule (e.g. a transition the workflow forbids)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "Ship the release", + "tags": [ + "Releases" + ] + } + }, + "/api/releases/{releaseId}/archive": { + "post": { + "operationId": "ReleasesController_archive", + "parameters": [ + { + "name": "releaseId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommandResultDto" + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "409": { + "description": "Someone changed it at the same time: retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "422": { + "description": "Business rule (e.g. a transition the workflow forbids)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "Archive a planned release (its tickets leave it)", + "tags": [ + "Releases" + ] + } + } + }, + "info": { + "title": "Pireon TaskStream API", + "description": "REST API of **Pireon TaskStream** for other apps: projects, tickets (bugs, features,\nUI changes, tasks and QA), workflow, comments, relations and releases.\n\n### Authentication\nEvery endpoint (except `/auth`, `/health` and the webhook) needs a **Pireon access\ntoken**, so any app or user that consumes it goes through Pireon's OAuth:\n\n- **Authorization Code + PKCE** against `oauth.pireon.dev/oauth/authorize`\n (the \"Authorize\" button here uses it; register `/api/docs/oauth2-redirect.html`\n as a redirect URI of the client).\n- An app with its own Pireon OAuth client can get tokens from Pireon directly and\n send them as `Authorization: Bearer `.\n\nWhat you can see and change depends on your role in each project.\n\n### Writes are eventually consistent\nCommands answer `{ id, key?, version }` as soon as the event is stored. Reads come from\na read model that catches up in milliseconds: a `GET` right after a write may still\nshow the previous version (or 404 right after a create).\n\n### Real time\nFor live updates use the GraphQL subscriptions at `/graphql` (graphql-ws).", + "version": "1.0.0", + "contact": {} + }, + "tags": [ + { + "name": "Projects", + "description": "Projects, members, board, stats and activity" + }, + { + "name": "Issues", + "description": "Tickets: search, create, workflow, relations, comments" + }, + { + "name": "Comments", + "description": "Edit and delete comments" + }, + { + "name": "Releases", + "description": "Versions that group tickets" + }, + { + "name": "Users", + "description": "The signed-in user and the Pireon directory" + }, + { + "name": "Auth", + "description": "Pireon OAuth token proxy" + }, + { + "name": "Health", + "description": "Liveness of the API and its databases" + }, + { + "name": "Webhooks", + "description": "Called by Pireon" + } + ], + "servers": [ + { + "url": "/", + "description": "This server (the paths already include /api)" + } + ], + "components": { + "securitySchemes": { + "pireon-oauth": { + "type": "oauth2", + "flows": { + "authorizationCode": { + "authorizationUrl": "https://oauth.pireon.dev/oauth/authorize", + "tokenUrl": "/api/auth/token", + "refreshUrl": "/api/auth/token", + "scopes": { + "users:read": "Search Pireon users" + } + }, + "password": { + "tokenUrl": "/api/auth/token", + "refreshUrl": "/api/auth/token", + "scopes": { + "users:read": "Search Pireon users" + } + } + }, + "description": "Pireon OAuth2 (Laravel Passport)" + }, + "pireon": { + "scheme": "bearer", + "bearerFormat": "JWT", + "type": "http", + "description": "A Pireon access token obtained elsewhere" + } + }, + "schemas": { + "AuthorizationConfigDto": { + "type": "object", + "properties": { + "authorizeUrl": { + "type": "string", + "example": "https://oauth.pireon.dev/oauth/authorize" + }, + "clientId": { + "type": "string", + "description": "Public id of the task-stream OAuth client" + }, + "scopes": { + "example": [ + "users:read" + ], + "type": "array", + "items": { + "type": "string" + } + }, + "passwordGrant": { + "type": "boolean", + "description": "Whether email + password sign-in is allowed" + } + }, + "required": [ + "authorizeUrl", + "clientId", + "scopes", + "passwordGrant" + ] + }, + "IssueTokenDto": { + "type": "object", + "properties": { + "grant_type": { + "type": "string", + "enum": [ + "authorization_code", + "password", + "refresh_token" + ] + }, + "code": { + "type": "string", + "description": "authorization_code: code returned by Pireon" + }, + "code_verifier": { + "type": "string", + "description": "authorization_code: PKCE verifier" + }, + "redirect_uri": { + "type": "string", + "description": "authorization_code: redirect URI used to authorize" + }, + "username": { + "type": "string", + "description": "password: Pireon email" + }, + "password": { + "type": "string", + "description": "password: Pireon password" + }, + "refresh_token": { + "type": "string", + "description": "refresh_token" + } + }, + "required": [ + "grant_type" + ] + }, + "TokenResponseDto": { + "type": "object", + "properties": { + "access_token": { + "type": "string" + }, + "refresh_token": { + "type": "string", + "nullable": true + }, + "expires_in": { + "type": "number", + "description": "Seconds" + }, + "token_type": { + "type": "string", + "example": "Bearer" + } + }, + "required": [ + "access_token", + "refresh_token", + "expires_in", + "token_type" + ] + }, + "MeDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Pireon user id" + }, + "name": { + "type": "string" + }, + "email": { + "type": "string" + }, + "isActive": { + "type": "boolean" + }, + "roles": { + "description": "Global roles in Pireon", + "type": "array", + "items": { + "type": "string" + } + }, + "appRoles": { + "description": "Roles inside the task-stream OAuth client", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "id", + "name", + "email", + "isActive", + "roles", + "appRoles" + ] + }, + "ApiErrorDto": { + "type": "object", + "properties": { + "statusCode": { + "type": "number", + "example": 404 + }, + "code": { + "type": "string", + "description": "Domain error code", + "enum": [ + "NOT_FOUND", + "FORBIDDEN", + "VALIDATION", + "CONFLICT", + "BUSINESS_RULE" + ] + }, + "message": { + "description": "Human message (a list of messages for invalid input)", + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ], + "example": "Issue not found" + } + }, + "required": [ + "statusCode", + "message" + ] + }, + "Object": { + "type": "object", + "properties": {} + }, + "UserDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Pireon user id" + }, + "name": { + "type": "string" + }, + "email": { + "type": "string" + }, + "isActive": { + "type": "boolean" + } + }, + "required": [ + "id", + "name", + "email", + "isActive" + ] + }, + "UserRefDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Pireon user id" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + }, + "ProjectRole": { + "type": "string", + "enum": [ + "OWNER", + "ADMIN", + "MEMBER", + "VIEWER" + ] + }, + "ProjectMemberDto": { + "type": "object", + "properties": { + "user": { + "$ref": "#/components/schemas/UserRefDto" + }, + "role": { + "allOf": [ + { + "$ref": "#/components/schemas/ProjectRole" + } + ] + } + }, + "required": [ + "user", + "role" + ] + }, + "ProjectDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "key": { + "type": "string", + "example": "WEB", + "description": "Prefix of the ticket keys" + }, + "name": { + "type": "string", + "example": "Checkout revamp" + }, + "description": { + "type": "string", + "nullable": true + }, + "owner": { + "$ref": "#/components/schemas/UserRefDto" + }, + "members": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProjectMemberDto" + } + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "version": { + "type": "number", + "description": "Version of the aggregate (event count)" + } + }, + "required": [ + "id", + "key", + "name", + "description", + "owner", + "members", + "createdAt", + "updatedAt", + "version" + ] + }, + "CreateProjectDto": { + "type": "object", + "properties": { + "key": { + "type": "string", + "example": "WEB", + "description": "2-10 letters/digits starting with a letter → tickets WEB-1, WEB-2…" + }, + "name": { + "type": "string", + "example": "Checkout revamp", + "maxLength": 120 + }, + "description": { + "type": "string", + "nullable": true, + "maxLength": 5000 + } + }, + "required": [ + "key", + "name" + ] + }, + "CommandResultDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Id of the aggregate written" + }, + "key": { + "type": "string", + "description": "Human key when one was assigned (project or issue)", + "example": "WEB-13" + }, + "version": { + "type": "number", + "description": "Version after the write. Writes are acknowledged at once and the read model catches up asynchronously: a GET may briefly return the previous version (or 404 right after a create)." + } + }, + "required": [ + "id", + "version" + ] + }, + "UpdateProjectDto": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "Checkout revamp", + "maxLength": 120 + }, + "description": { + "type": "string", + "nullable": true, + "maxLength": 5000 + } + } + }, + "IssueArea": { + "type": "string", + "enum": [ + "FRONTEND", + "BACKEND", + "MOBILE", + "QA", + "DESIGN", + "DEVOPS" + ] + }, + "IssueStatus": { + "type": "string", + "enum": [ + "BACKLOG", + "TODO", + "IN_PROGRESS", + "IN_REVIEW", + "READY_FOR_QA", + "IN_QA", + "QA_FAILED", + "DONE", + "CANCELLED" + ] + }, + "IssueType": { + "type": "string", + "enum": [ + "BUG", + "FEATURE", + "UI_CHANGE", + "TASK", + "QA" + ] + }, + "IssuePriority": { + "type": "string", + "enum": [ + "LOWEST", + "LOW", + "MEDIUM", + "HIGH", + "CRITICAL" + ] + }, + "ReleaseRefDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string", + "example": "v1.4.0" + } + }, + "required": [ + "id", + "name" + ] + }, + "IssueLinkType": { + "type": "string", + "enum": [ + "BLOCKS", + "DEPENDS_ON", + "TESTS", + "DUPLICATES", + "RELATES_TO" + ] + }, + "LinkDirection": { + "type": "string", + "enum": [ + "OUTWARD", + "INWARD" + ], + "description": "OUTWARD: this issue → other. INWARD: other → this issue." + }, + "IssueLinkDto": { + "type": "object", + "properties": { + "type": { + "allOf": [ + { + "$ref": "#/components/schemas/IssueLinkType" + } + ] + }, + "direction": { + "description": "OUTWARD: this issue → other. INWARD: other → this issue.", + "allOf": [ + { + "$ref": "#/components/schemas/LinkDirection" + } + ] + }, + "issueId": { + "type": "string", + "format": "uuid" + }, + "issueKey": { + "type": "string", + "example": "WEB-3" + } + }, + "required": [ + "type", + "direction", + "issueId", + "issueKey" + ] + }, + "IssueDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "projectId": { + "type": "string", + "format": "uuid" + }, + "projectKey": { + "type": "string", + "example": "WEB" + }, + "number": { + "type": "number", + "example": 12 + }, + "key": { + "type": "string", + "example": "WEB-12", + "description": "Human key" + }, + "type": { + "allOf": [ + { + "$ref": "#/components/schemas/IssueType" + } + ] + }, + "area": { + "type": "string", + "enum": [ + "FRONTEND", + "BACKEND", + "MOBILE", + "QA", + "DESIGN", + "DEVOPS" + ], + "nullable": true + }, + "title": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/IssueStatus" + } + ] + }, + "allowedTransitions": { + "type": "array", + "description": "Statuses this ticket can move to next", + "items": { + "$ref": "#/components/schemas/IssueStatus" + } + }, + "priority": { + "allOf": [ + { + "$ref": "#/components/schemas/IssuePriority" + } + ] + }, + "reporter": { + "$ref": "#/components/schemas/UserRefDto" + }, + "assignee": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/UserRefDto" + } + ] + }, + "release": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/ReleaseRefDto" + } + ] + }, + "labels": { + "type": "array", + "items": { + "type": "string" + } + }, + "figmaUrl": { + "type": "string", + "nullable": true, + "description": "Figma frame" + }, + "storyPoints": { + "type": "number", + "nullable": true + }, + "dueDate": { + "type": "string", + "nullable": true, + "format": "date" + }, + "links": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IssueLinkDto" + } + }, + "commentCount": { + "type": "number" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "version": { + "type": "number" + } + }, + "required": [ + "id", + "projectId", + "projectKey", + "number", + "key", + "type", + "area", + "title", + "description", + "status", + "allowedTransitions", + "priority", + "reporter", + "assignee", + "release", + "labels", + "figmaUrl", + "storyPoints", + "dueDate", + "links", + "commentCount", + "createdAt", + "updatedAt", + "version" + ] + }, + "BoardColumnDto": { + "type": "object", + "properties": { + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/IssueStatus" + } + ] + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IssueDto" + } + }, + "total": { + "type": "number", + "description": "Tickets in the column (may exceed `issues`)" + } + }, + "required": [ + "status", + "issues", + "total" + ] + }, + "CountByStatusDto": { + "type": "object", + "properties": { + "key": { + "allOf": [ + { + "$ref": "#/components/schemas/IssueStatus" + } + ] + }, + "count": { + "type": "number" + } + }, + "required": [ + "key", + "count" + ] + }, + "CountByKeyDto": { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "Type, priority or area (\"NONE\" = unset)" + }, + "count": { + "type": "number" + } + }, + "required": [ + "key", + "count" + ] + }, + "CountByAssigneeDto": { + "type": "object", + "properties": { + "user": { + "nullable": true, + "description": "Null = unassigned", + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/UserRefDto" + } + ] + }, + "count": { + "type": "number" + } + }, + "required": [ + "user", + "count" + ] + }, + "ProjectStatsDto": { + "type": "object", + "properties": { + "total": { + "type": "number" + }, + "open": { + "type": "number", + "description": "Not done nor cancelled" + }, + "byStatus": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CountByStatusDto" + } + }, + "byType": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CountByKeyDto" + } + }, + "byPriority": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CountByKeyDto" + } + }, + "byArea": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CountByKeyDto" + } + }, + "byAssignee": { + "description": "Open tickets per person", + "type": "array", + "items": { + "$ref": "#/components/schemas/CountByAssigneeDto" + } + } + }, + "required": [ + "total", + "open", + "byStatus", + "byType", + "byPriority", + "byArea", + "byAssignee" + ] + }, + "ActivityDto": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "example": "IssueStatusChanged" + }, + "projectId": { + "type": "string", + "format": "uuid" + }, + "issueId": { + "type": "string", + "nullable": true + }, + "issueKey": { + "type": "string", + "nullable": true, + "example": "WEB-12" + }, + "actor": { + "$ref": "#/components/schemas/UserRefDto" + }, + "summary": { + "type": "string", + "example": "Ana movió WEB-12 de En curso a En revisión" + }, + "occurredAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "id", + "type", + "projectId", + "issueId", + "issueKey", + "actor", + "summary", + "occurredAt" + ] + }, + "AddMemberDto": { + "type": "object", + "properties": { + "userId": { + "type": "string", + "description": "Pireon user id (see GET /users)" + }, + "role": { + "default": "MEMBER", + "allOf": [ + { + "$ref": "#/components/schemas/ProjectRole" + } + ] + } + }, + "required": [ + "userId" + ] + }, + "ChangeMemberRoleDto": { + "type": "object", + "properties": { + "role": { + "allOf": [ + { + "$ref": "#/components/schemas/ProjectRole" + } + ] + } + }, + "required": [ + "role" + ] + }, + "IssuePageDto": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IssueDto" + } + }, + "total": { + "type": "number", + "description": "Total matches (for pagination)" + } + }, + "required": [ + "items", + "total" + ] + }, + "CreateIssueDto": { + "type": "object", + "properties": { + "description": { + "type": "string", + "nullable": true, + "maxLength": 50000 + }, + "area": { + "type": "string", + "enum": [ + "FRONTEND", + "BACKEND", + "MOBILE", + "QA", + "DESIGN", + "DEVOPS" + ], + "nullable": true + }, + "priority": { + "allOf": [ + { + "$ref": "#/components/schemas/IssuePriority" + } + ] + }, + "labels": { + "maxItems": 20, + "type": "array", + "items": { + "type": "string" + } + }, + "figmaUrl": { + "type": "string", + "nullable": true, + "description": "Figma frame (https) for UI changes" + }, + "storyPoints": { + "type": "number", + "nullable": true, + "minimum": 0, + "maximum": 100 + }, + "dueDate": { + "type": "string", + "nullable": true, + "format": "date" + }, + "type": { + "allOf": [ + { + "$ref": "#/components/schemas/IssueType" + } + ] + }, + "title": { + "type": "string", + "example": "El botón de pagar no responde en Safari", + "maxLength": 255 + }, + "assigneeId": { + "type": "string", + "nullable": true, + "description": "Pireon user id" + }, + "releaseId": { + "type": "string", + "nullable": true, + "format": "uuid" + } + }, + "required": [ + "type", + "title" + ] + }, + "UpdateIssueDto": { + "type": "object", + "properties": { + "description": { + "type": "string", + "nullable": true, + "maxLength": 50000 + }, + "area": { + "type": "string", + "enum": [ + "FRONTEND", + "BACKEND", + "MOBILE", + "QA", + "DESIGN", + "DEVOPS" + ], + "nullable": true + }, + "priority": { + "allOf": [ + { + "$ref": "#/components/schemas/IssuePriority" + } + ] + }, + "labels": { + "maxItems": 20, + "type": "array", + "items": { + "type": "string" + } + }, + "figmaUrl": { + "type": "string", + "nullable": true, + "description": "Figma frame (https) for UI changes" + }, + "storyPoints": { + "type": "number", + "nullable": true, + "minimum": 0, + "maximum": 100 + }, + "dueDate": { + "type": "string", + "nullable": true, + "format": "date" + }, + "type": { + "allOf": [ + { + "$ref": "#/components/schemas/IssueType" + } + ] + }, + "title": { + "type": "string", + "maxLength": 255 + } + } + }, + "ChangeStatusDto": { + "type": "object", + "properties": { + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/IssueStatus" + } + ] + }, + "reason": { + "type": "string", + "nullable": true, + "description": "Required for QA_FAILED and CANCELLED" + } + }, + "required": [ + "status" + ] + }, + "AssignIssueDto": { + "type": "object", + "properties": { + "assigneeId": { + "type": "string", + "nullable": true, + "description": "Pireon user id of a project member, or null to unassign" + } + }, + "required": [ + "assigneeId" + ] + }, + "SetIssueReleaseDto": { + "type": "object", + "properties": { + "releaseId": { + "type": "string", + "nullable": true, + "format": "uuid", + "description": "Planned release, or null to take the ticket out" + } + }, + "required": [ + "releaseId" + ] + }, + "LinkIssueDto": { + "type": "object", + "properties": { + "type": { + "allOf": [ + { + "$ref": "#/components/schemas/IssueLinkType" + } + ] + }, + "targetIssue": { + "type": "string", + "example": "API-3", + "description": "Other ticket: id or key" + } + }, + "required": [ + "type", + "targetIssue" + ] + }, + "CommentDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "issueId": { + "type": "string", + "format": "uuid" + }, + "author": { + "$ref": "#/components/schemas/UserRefDto" + }, + "body": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "editedAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "version": { + "type": "number" + } + }, + "required": [ + "id", + "issueId", + "author", + "body", + "createdAt", + "editedAt", + "version" + ] + }, + "CommentPageDto": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CommentDto" + } + }, + "total": { + "type": "number" + } + }, + "required": [ + "items", + "total" + ] + }, + "CommentBodyDto": { + "type": "object", + "properties": { + "body": { + "type": "string", + "example": "Reproducido en Safari 17.", + "maxLength": 20000 + } + }, + "required": [ + "body" + ] + }, + "ReleaseStatus": { + "type": "string", + "enum": [ + "PLANNED", + "RELEASED", + "ARCHIVED" + ] + }, + "ReleaseDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "projectId": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string", + "example": "v1.4.0" + }, + "description": { + "type": "string", + "nullable": true + }, + "targetDate": { + "type": "string", + "nullable": true, + "format": "date" + }, + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/ReleaseStatus" + } + ] + }, + "releasedAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "version": { + "type": "number" + } + }, + "required": [ + "id", + "projectId", + "name", + "description", + "targetDate", + "status", + "releasedAt", + "createdAt", + "version" + ] + }, + "CreateReleaseDto": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "v1.4.0", + "maxLength": 60 + }, + "description": { + "type": "string", + "nullable": true, + "maxLength": 5000 + }, + "targetDate": { + "type": "string", + "nullable": true, + "format": "date", + "example": "2026-11-14" + } + }, + "required": [ + "name" + ] + }, + "UpdateReleaseDto": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "v1.4.0", + "maxLength": 60 + }, + "description": { + "type": "string", + "nullable": true, + "maxLength": 5000 + }, + "targetDate": { + "type": "string", + "nullable": true, + "format": "date", + "example": "2026-11-14" + } + } + }, + "ReleaseProgressDto": { + "type": "object", + "properties": { + "total": { + "type": "number" + }, + "finished": { + "type": "number" + }, + "percent": { + "type": "number", + "minimum": 0, + "maximum": 100 + }, + "byStatus": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CountByStatusDto" + } + } + }, + "required": [ + "total", + "finished", + "percent", + "byStatus" + ] + }, + "PublishReleaseDto": { + "type": "object", + "properties": { + "force": { + "type": "object", + "default": false, + "description": "Publish even with unfinished tickets" + } + } + } + } + } +} diff --git a/apps/api/package.json b/apps/api/package.json index dee4262..19129bc 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -14,6 +14,7 @@ "@nestjs/graphql": "^13.4.5", "@nestjs/mongoose": "^11.0.4", "@nestjs/platform-express": "^11.0.0", + "@nestjs/swagger": "^11.4.7", "@nestjs/typeorm": "^11.0.3", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", diff --git a/apps/api/src/app/config.schema.ts b/apps/api/src/app/config.schema.ts index 49e6a8e..afd0695 100644 --- a/apps/api/src/app/config.schema.ts +++ b/apps/api/src/app/config.schema.ts @@ -33,4 +33,9 @@ export const configSchema = Joi.object({ GRAPHQL_PLAYGROUND: Joi.boolean().default(true), GRAPHQL_SCHEMA_FILE: Joi.string().allow('').default('apps/api/schema.gql'), + + // REST + OpenAPI for other apps: Swagger UI at /api/docs, JSON at /api/docs-json. + SWAGGER_ENABLED: Joi.boolean().default(true), + // Where to write the OpenAPI document on startup ('' = don't), like the GraphQL SDL. + OPENAPI_FILE: Joi.string().allow('').default('apps/api/openapi.json'), }); diff --git a/apps/api/src/app/configure.ts b/apps/api/src/app/configure.ts new file mode 100644 index 0000000..20f94b4 --- /dev/null +++ b/apps/api/src/app/configure.ts @@ -0,0 +1,13 @@ +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { DomainErrorFilter } from '@task-stream/api/shared/presenters'; +import { setupOpenApi } from './openapi'; + +/** Everything main.ts sets on the app, shared with the e2e tests. */ +export function configureApp(app: INestApplication): void { + app.setGlobalPrefix('api'); + app.enableCors(); + app.enableShutdownHooks(); + app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true })); + app.useGlobalFilters(new DomainErrorFilter()); + setupOpenApi(app); +} diff --git a/apps/api/src/app/health.controller.ts b/apps/api/src/app/health.controller.ts index 9d7e941..d2c50b3 100644 --- a/apps/api/src/app/health.controller.ts +++ b/apps/api/src/app/health.controller.ts @@ -5,12 +5,19 @@ import { ServiceUnavailableException, } from '@nestjs/common'; import { InjectConnection } from '@nestjs/mongoose'; +import { + ApiOkResponse, + ApiOperation, + ApiServiceUnavailableResponse, + ApiTags, +} from '@nestjs/swagger'; import { Public } from '@task-stream/api/identity/presenters'; import { REDIS_CLIENT } from '@task-stream/api/shared/infrastructure'; import Redis from 'ioredis'; import { Connection } from 'mongoose'; import { DataSource } from 'typeorm'; +@ApiTags('Health') @Controller('health') export class HealthController { constructor( @@ -21,6 +28,13 @@ export class HealthController { @Public() @Get() + @ApiOperation({ summary: 'API and databases status' }) + @ApiOkResponse({ + schema: { + example: { status: 'ok', postgres: 'up', mongo: 'up', redis: 'up' }, + }, + }) + @ApiServiceUnavailableResponse({ description: 'A database is down' }) async check() { const checks = await Promise.allSettled([ this.postgres.query('SELECT 1'), diff --git a/apps/api/src/app/openapi.ts b/apps/api/src/app/openapi.ts new file mode 100644 index 0000000..1b465e5 --- /dev/null +++ b/apps/api/src/app/openapi.ts @@ -0,0 +1,158 @@ +import { INestApplication } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import { + PIREON_BEARER, + PIREON_OAUTH, +} from '@task-stream/api/identity/presenters'; +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +export const API_VERSION = '1.0.0'; + +const DESCRIPTION = ` +REST API of **Pireon TaskStream** for other apps: projects, tickets (bugs, features, +UI changes, tasks and QA), workflow, comments, relations and releases. + +### Authentication +Every endpoint (except \`/auth\`, \`/health\` and the webhook) needs a **Pireon access +token**, so any app or user that consumes it goes through Pireon's OAuth: + +- **Authorization Code + PKCE** against \`oauth.pireon.dev/oauth/authorize\` + (the "Authorize" button here uses it; register \`/api/docs/oauth2-redirect.html\` + as a redirect URI of the client). +- An app with its own Pireon OAuth client can get tokens from Pireon directly and + send them as \`Authorization: Bearer \`. + +What you can see and change depends on your role in each project. + +### Writes are eventually consistent +Commands answer \`{ id, key?, version }\` as soon as the event is stored. Reads come from +a read model that catches up in milliseconds: a \`GET\` right after a write may still +show the previous version (or 404 right after a create). + +### Real time +For live updates use the GraphQL subscriptions at \`/graphql\` (graphql-ws). +`.trim(); + +/** + * Builds the OpenAPI document from the REST controllers, serves Swagger UI and + * (in development) writes it to OPENAPI_FILE so other apps can generate clients. + */ +export function setupOpenApi(app: INestApplication): void { + const config = app.get(ConfigService); + if (!config.get('SWAGGER_ENABLED')) return; + + const pireon = + config.get('PIREON_BASE_URL') ?? 'https://oauth.pireon.dev'; + const scopes = (config.get('PIREON_SCOPES') ?? '') + .split(/[\s,]+/) + .filter(Boolean); + const scopeDocs = Object.fromEntries( + scopes.map((scope) => [ + scope, + scope === 'users:read' ? 'Search Pireon users' : scope, + ]), + ); + // Token requests go through the API's proxy: Pireon's /oauth/token has no + // CORS and the client secret stays on the server. + const tokenUrl = '/api/auth/token'; + + const document = SwaggerModule.createDocument( + app, + new DocumentBuilder() + .setTitle('Pireon TaskStream API') + .setDescription(DESCRIPTION) + .setVersion(API_VERSION) + .addServer('/', 'This server (the paths already include /api)') + .addOAuth2( + { + type: 'oauth2', + description: 'Pireon OAuth2 (Laravel Passport)', + flows: { + authorizationCode: { + authorizationUrl: `${pireon}/oauth/authorize`, + tokenUrl, + refreshUrl: tokenUrl, + scopes: scopeDocs, + }, + ...(config.get('PIREON_PASSWORD_GRANT') + ? { + password: { + tokenUrl, + refreshUrl: tokenUrl, + scopes: scopeDocs, + }, + } + : {}), + }, + }, + PIREON_OAUTH, + ) + .addBearerAuth( + { + type: 'http', + scheme: 'bearer', + description: 'A Pireon access token obtained elsewhere', + }, + PIREON_BEARER, + ) + .addTag('Projects', 'Projects, members, board, stats and activity') + .addTag( + 'Issues', + 'Tickets: search, create, workflow, relations, comments', + ) + .addTag('Comments', 'Edit and delete comments') + .addTag('Releases', 'Versions that group tickets') + .addTag('Users', 'The signed-in user and the Pireon directory') + .addTag('Auth', 'Pireon OAuth token proxy') + .addTag('Health', 'Liveness of the API and its databases') + .addTag('Webhooks', 'Called by Pireon') + .build(), + ); + + // Operations without a security requirement are the public ones (@Public(): + // health, auth, webhook). Say so explicitly instead of leaving it undefined. + for (const path of Object.values(document.paths)) { + for (const operation of [ + path.get, + path.post, + path.put, + path.patch, + path.delete, + ]) { + if (operation && !operation.security) operation.security = []; + } + } + + const file = config.get('OPENAPI_FILE'); + if (file) { + writeFileSync( + join(process.cwd(), file), + JSON.stringify(document, null, 2) + '\n', + ); + } + + SwaggerModule.setup('api/docs', app, document, { + jsonDocumentUrl: 'api/docs-json', + yamlDocumentUrl: 'api/docs-yaml', + customSiteTitle: 'Pireon TaskStream API', + swaggerOptions: { + persistAuthorization: true, + initOAuth: { + clientId: config.get('PIREON_CLIENT_ID') || undefined, + scopes, + usePkceWithAuthorizationCodeGrant: true, + }, + }, + // The Pireon look, as in the web app. + customCss: ` + @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap'); + .swagger-ui, .swagger-ui .info .title, .swagger-ui .opblock-tag { font-family: Inter, system-ui, sans-serif; } + .swagger-ui .topbar { display: none; } + .swagger-ui .info .title { background: linear-gradient(100deg, #a347e1, #1879bf); -webkit-background-clip: text; background-clip: text; color: transparent; } + .swagger-ui .btn.authorize { color: #a347e1; border-color: #a347e1; } + .swagger-ui .btn.authorize svg { fill: #a347e1; } + `, + }); +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 6ba5230..235a073 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,22 +1,19 @@ -import { Logger, ValidationPipe } from '@nestjs/common'; +import { Logger } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; -import { DomainErrorFilter } from '@task-stream/api/shared/presenters'; import { AppModule } from './app/app.module'; +import { configureApp } from './app/configure'; async function bootstrap() { // rawBody: needed to verify Pireon webhook signatures. const app = await NestFactory.create(AppModule, { rawBody: true }); - app.setGlobalPrefix('api'); - app.enableCors(); - app.enableShutdownHooks(); - app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true })); - app.useGlobalFilters(new DomainErrorFilter()); + configureApp(app); const port = process.env['PORT'] || 3000; await app.listen(port); Logger.log( `🚀 GraphQL: http://localhost:${port}/graphql (ws for subscriptions)`, ); + Logger.log(`📘 REST: http://localhost:${port}/api/docs (OpenAPI)`); Logger.log(`❤️ Health: http://localhost:${port}/api/health`); } diff --git a/docs/03-api.md b/docs/03-api.md index da3d9a1..a8e14df 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -265,13 +265,60 @@ Los errores de dominio llegan como errores GraphQL con `extensions.code`: | `BUSINESS_RULE` | Regla de negocio (transición no permitida, release bloqueada…) | | `CONFLICT` | Otro cambio llegó antes (concurrencia optimista): reintenta | -## 3.3 REST +## 3.3 REST con OpenAPI (para otras apps) -| Endpoint | Uso | -| --------------------------- | ----------------------------------------------------------------------------------------------- | -| `GET /api/health` | Estado de PostgreSQL, MongoDB y Redis (público) | -| `GET /api/auth/config` | Configuración para iniciar sesión con Pireon desde el navegador (público) | -| `POST /api/auth/token` | Proxy de `/oauth/token` de Pireon: `authorization_code`, `password` y `refresh_token` (público) | -| `POST /api/webhooks/pireon` | Webhooks de Pireon firmados con HMAC (`user.deactivated`, `token.revoked`) | +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. -Ver [Autenticación](05-autenticacion.md). +| | | +| ----------------- | ----------------------------------------------------------------------------------- | +| 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). diff --git a/docs/05-autenticacion.md b/docs/05-autenticacion.md index ccae14d..a331587 100644 --- a/docs/05-autenticacion.md +++ b/docs/05-autenticacion.md @@ -55,6 +55,8 @@ está configurado. El email y la contraseña son los de la cuenta de Pireon; Tas - `http://localhost:4200/auth/callback` (desarrollo) - `http://localhost:8080/auth/callback` (Docker) - la de producción + - para el botón «Authorize» de Swagger UI: `http://localhost:3000/api/docs/oauth2-redirect.html` y la de + producción (ver [API REST](03-api.md#33-rest-con-openapi-para-otras-apps)) 3. Pon `PIREON_CLIENT_ID` y `PIREON_CLIENT_SECRET` en `.env` (o en el entorno del contenedor). `GET /api/auth/config` devuelve a la web lo necesario para iniciar el flujo (`authorizeUrl`, `clientId`, `scopes`, diff --git a/libs/api/identity/presenters/README.md b/libs/api/identity/presenters/README.md index b1d6af0..05afdec 100644 --- a/libs/api/identity/presenters/README.md +++ b/libs/api/identity/presenters/README.md @@ -7,8 +7,10 @@ Adaptadores de entrada de identidad. ## Contenido -- `auth/`: `AuthGuard` global, `@Public()`, `@CurrentUser()`, `@AccessToken()`, `@RequireAppRoles()` y `WsAuthenticator` (subscriptions). -- `http/`: `AuthController` (`GET /api/auth/config`, `POST /api/auth/token`) y `PireonWebhookController` (firma HMAC). +- `auth/`: `AuthGuard` global, `@Public()`, `@CurrentUser()`, `@AccessToken()`, `@RequireAppRoles()`, `WsAuthenticator` + (subscriptions) y `@ApiPireonAuth()` (esquemas OAuth2 / Bearer de Pireon en OpenAPI). +- `http/`: `AuthController` (`GET /api/auth/config`, `POST /api/auth/token`), `UsersController` (`/api/users/me`, + directorio de Pireon) y `PireonWebhookController` (firma HMAC). Documentados con `@nestjs/swagger`. - `graphql/`: queries `me`, `user` y `users`. ## Comandos diff --git a/libs/api/identity/presenters/src/lib/auth/auth.decorators.ts b/libs/api/identity/presenters/src/lib/auth/auth.decorators.ts index ccf364d..690afe6 100644 --- a/libs/api/identity/presenters/src/lib/auth/auth.decorators.ts +++ b/libs/api/identity/presenters/src/lib/auth/auth.decorators.ts @@ -1,8 +1,10 @@ import { ExecutionContext, SetMetadata, + applyDecorators, createParamDecorator, } from '@nestjs/common'; +import { ApiBearerAuth, ApiOAuth2 } from '@nestjs/swagger'; import { getAuthRequest } from './auth-request'; export const IS_PUBLIC = 'identity:isPublic'; @@ -25,3 +27,15 @@ export const AccessToken = createParamDecorator( (_: unknown, context: ExecutionContext) => getAuthRequest(context).accessToken, ); + +/** OpenAPI security schemes (declared in apps/api/src/app/openapi.ts). */ +export const PIREON_OAUTH = 'pireon-oauth'; +export const PIREON_BEARER = 'pireon'; + +/** + * Documents that the endpoints need a Pireon access token: obtained through + * Pireon's OAuth (authorization code + PKCE, or password) or sent as a bearer + * token by an app that already has one. Enforced by the global AuthGuard. + */ +export const ApiPireonAuth = () => + applyDecorators(ApiOAuth2([], PIREON_OAUTH), ApiBearerAuth(PIREON_BEARER)); diff --git a/libs/api/identity/presenters/src/lib/http/auth.controller.ts b/libs/api/identity/presenters/src/lib/http/auth.controller.ts index dfb6fad..c7b6ed9 100644 --- a/libs/api/identity/presenters/src/lib/http/auth.controller.ts +++ b/libs/api/identity/presenters/src/lib/http/auth.controller.ts @@ -6,6 +6,15 @@ import { Post, UnauthorizedException, } from '@nestjs/common'; +import { + ApiBody, + ApiConsumes, + ApiOkResponse, + ApiOperation, + ApiProperty, + ApiTags, + ApiUnauthorizedResponse, +} from '@nestjs/swagger'; import { IdentityService, InvalidCredentialsError, @@ -18,10 +27,29 @@ import { import { Public } from '../auth/auth.decorators'; import { IssueTokenDto } from './dto/issue-token.dto'; +export class AuthorizationConfigDto { + @ApiProperty({ example: 'https://oauth.pireon.dev/oauth/authorize' }) + authorizeUrl!: string; + @ApiProperty({ description: 'Public id of the task-stream OAuth client' }) + clientId!: string; + @ApiProperty({ type: [String], example: ['users:read'] }) + scopes!: string[]; + @ApiProperty({ description: 'Whether email + password sign-in is allowed' }) + passwordGrant!: boolean; +} + +export class TokenResponseDto { + @ApiProperty() access_token!: string; + @ApiProperty({ type: String, nullable: true }) refresh_token!: string | null; + @ApiProperty({ description: 'Seconds' }) expires_in!: number; + @ApiProperty({ example: 'Bearer' }) token_type!: string; +} + /** * Sign-in for browsers. Pireon's /oauth/token has no CORS and the client * secret must stay server-side, so the web app goes through here. */ +@ApiTags('Auth') @Controller('auth') export class AuthController { constructor(private readonly identity: IdentityService) {} @@ -29,6 +57,10 @@ export class AuthController { /** What the web app needs to start Authorization Code + PKCE (null = not configured). */ @Public() @Get('config') + @ApiOperation({ + summary: 'How to sign in with Pireon (null when not configured)', + }) + @ApiOkResponse({ type: AuthorizationConfigDto }) config(): Promise { return this.identity.authorizationConfig(); } @@ -36,6 +68,18 @@ export class AuthController { @Public() @Post('token') @HttpCode(200) + @ApiOperation({ + summary: + 'Exchange a grant for Pireon tokens (proxy of Pireon /oauth/token)', + description: + 'For browsers (Pireon has no CORS) and Swagger UI: adds the client secret ' + + 'server-side. Apps with their own Pireon OAuth client can get tokens from ' + + 'Pireon directly; any valid Pireon access token is accepted by this API.', + }) + @ApiConsumes('application/json', 'application/x-www-form-urlencoded') + @ApiBody({ type: IssueTokenDto }) + @ApiOkResponse({ type: TokenResponseDto }) + @ApiUnauthorizedResponse({ description: 'Pireon rejected the grant' }) async token(@Body() dto: IssueTokenDto) { try { return toResponse(await this.identity.issueToken(toGrant(dto))); diff --git a/libs/api/identity/presenters/src/lib/http/dto/issue-token.dto.ts b/libs/api/identity/presenters/src/lib/http/dto/issue-token.dto.ts index 6909e8f..9430546 100644 --- a/libs/api/identity/presenters/src/lib/http/dto/issue-token.dto.ts +++ b/libs/api/identity/presenters/src/lib/http/dto/issue-token.dto.ts @@ -1,35 +1,48 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsIn, IsNotEmpty, IsString, IsUrl, ValidateIf } from 'class-validator'; +/** OAuth2 token request (RFC 6749), as JSON or application/x-www-form-urlencoded. */ export class IssueTokenDto { + @ApiProperty({ enum: ['authorization_code', 'password', 'refresh_token'] }) @IsIn(['authorization_code', 'password', 'refresh_token']) grant_type!: 'authorization_code' | 'password' | 'refresh_token'; @ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'authorization_code') @IsString() @IsNotEmpty() + @ApiPropertyOptional({ + description: 'authorization_code: code returned by Pireon', + }) code?: string; @ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'authorization_code') @IsString() @IsNotEmpty() + @ApiPropertyOptional({ description: 'authorization_code: PKCE verifier' }) code_verifier?: string; @ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'authorization_code') @IsUrl({ require_tld: false }) + @ApiPropertyOptional({ + description: 'authorization_code: redirect URI used to authorize', + }) redirect_uri?: string; @ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'password') @IsString() @IsNotEmpty() + @ApiPropertyOptional({ description: 'password: Pireon email' }) username?: string; @ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'password') @IsString() @IsNotEmpty() + @ApiPropertyOptional({ description: 'password: Pireon password' }) password?: string; @ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'refresh_token') @IsString() @IsNotEmpty() + @ApiPropertyOptional({ description: 'refresh_token' }) refresh_token?: string; } diff --git a/libs/api/identity/presenters/src/lib/http/pireon-webhook.controller.ts b/libs/api/identity/presenters/src/lib/http/pireon-webhook.controller.ts index 4d59ba3..cda43e4 100644 --- a/libs/api/identity/presenters/src/lib/http/pireon-webhook.controller.ts +++ b/libs/api/identity/presenters/src/lib/http/pireon-webhook.controller.ts @@ -9,12 +9,20 @@ import { UnauthorizedException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { + ApiHeader, + ApiNoContentResponse, + ApiOperation, + ApiTags, + ApiUnauthorizedResponse, +} from '@nestjs/swagger'; import { IdentityService } from '@task-stream/api/identity/application'; import { Request } from 'express'; import { createHmac, timingSafeEqual } from 'node:crypto'; import { Public } from '../auth/auth.decorators'; /** Receives `user.deactivated` / `token.revoked` from Pireon. */ +@ApiTags('Webhooks') @Controller('webhooks/pireon') export class PireonWebhookController { constructor( @@ -25,6 +33,19 @@ export class PireonWebhookController { @Public() @Post() @HttpCode(204) + @ApiOperation({ + summary: 'Pireon events (called by Pireon, not by apps)', + description: + "`user.deactivated` closes the user's sessions; `token.revoked` forgets the token. " + + 'Disabled unless PIREON_WEBHOOK_SECRET is set.', + }) + @ApiHeader({ + name: 'X-Webhook-Signature', + description: 'HMAC-SHA256 of the raw body with PIREON_WEBHOOK_SECRET (hex)', + required: true, + }) + @ApiNoContentResponse({ description: 'Processed' }) + @ApiUnauthorizedResponse({ description: 'Missing or invalid signature' }) async receive( @Req() req: RawBodyRequest, @Headers('x-webhook-signature') signature?: string, diff --git a/libs/api/identity/presenters/src/lib/http/users.controller.ts b/libs/api/identity/presenters/src/lib/http/users.controller.ts new file mode 100644 index 0000000..4dbdfa3 --- /dev/null +++ b/libs/api/identity/presenters/src/lib/http/users.controller.ts @@ -0,0 +1,101 @@ +import { + Controller, + Get, + NotFoundException, + Param, + Query, +} from '@nestjs/common'; +import { + ApiOkResponse, + ApiOperation, + ApiProperty, + ApiPropertyOptional, + ApiTags, +} from '@nestjs/swagger'; +import { IdentityService } from '@task-stream/api/identity/application'; +import { User } from '@task-stream/api/identity/domain'; +import { ApiReadErrors } from '@task-stream/api/shared/presenters'; +import { Type } from 'class-transformer'; +import { + IsInt, + IsOptional, + IsString, + Max, + MaxLength, + Min, +} from 'class-validator'; +import { + AccessToken, + ApiPireonAuth, + CurrentUser, +} from '../auth/auth.decorators'; + +export class UserDto { + @ApiProperty({ description: 'Pireon user id' }) id!: string; + @ApiProperty() name!: string; + @ApiProperty() email!: string; + @ApiProperty() isActive!: boolean; +} + +export class MeDto extends UserDto { + @ApiProperty({ type: [String], description: 'Global roles in Pireon' }) + roles!: string[]; + @ApiProperty({ + type: [String], + description: 'Roles inside the task-stream OAuth client', + }) + appRoles!: string[]; +} + +export class UserSearchQueryDto { + @ApiPropertyOptional({ description: 'Name or email', default: '' }) + @IsOptional() + @IsString() + @MaxLength(100) + search = ''; + + @ApiPropertyOptional({ default: 20, minimum: 1, maximum: 100 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit = 20; +} + +/** REST adapter of identity: who I am and Pireon's user directory. */ +@ApiTags('Users') +@ApiPireonAuth() +@Controller('users') +export class UsersController { + constructor(private readonly identity: IdentityService) {} + + @Get('me') + @ApiOperation({ summary: 'The user behind the access token' }) + @ApiOkResponse({ type: MeDto }) + @ApiReadErrors() + me(@CurrentUser() user: User): MeDto { + return user.toSnapshot(); + } + + @Get() + @ApiOperation({ + summary: 'Search Pireon users (to assign tickets or add members)', + description: 'Needs the `users:read` scope in the access token.', + }) + @ApiOkResponse({ type: [UserDto] }) + @ApiReadErrors() + search(@Query() query: UserSearchQueryDto, @AccessToken() token: string) { + return this.identity.searchUsers(query.search, token, query.limit); + } + + @Get(':id') + @ApiOperation({ summary: 'A Pireon user' }) + @ApiOkResponse({ type: UserDto }) + @ApiReadErrors() + async findOne(@Param('id') id: string, @AccessToken() token: string) { + const user = await this.identity.findUser(id, token); + if (!user) throw new NotFoundException('User not found'); + return user; + } +} diff --git a/libs/api/identity/presenters/src/lib/identity-presenters.module.ts b/libs/api/identity/presenters/src/lib/identity-presenters.module.ts index 5f77952..20f8fdc 100644 --- a/libs/api/identity/presenters/src/lib/identity-presenters.module.ts +++ b/libs/api/identity/presenters/src/lib/identity-presenters.module.ts @@ -5,10 +5,11 @@ import { WsAuthenticator } from './auth/ws-authenticator'; import { UserResolver } from './graphql/user.resolver'; import { AuthController } from './http/auth.controller'; import { PireonWebhookController } from './http/pireon-webhook.controller'; +import { UsersController } from './http/users.controller'; -/** Driving adapters of the identity context: global auth guard, webhook, GraphQL. */ +/** Driving adapters of the identity context: global auth guard, REST (auth, users, webhook), GraphQL. */ @Module({ - controllers: [AuthController, PireonWebhookController], + controllers: [AuthController, UsersController, PireonWebhookController], providers: [ { provide: APP_GUARD, useClass: AuthGuard }, WsAuthenticator, diff --git a/libs/api/shared/presenters/README.md b/libs/api/shared/presenters/README.md index 9829bed..5f7f7b3 100644 --- a/libs/api/shared/presenters/README.md +++ b/libs/api/shared/presenters/README.md @@ -8,6 +8,7 @@ Adaptadores de entrada comunes. ## Contenido - `DomainErrorFilter`: convierte los errores de dominio en errores GraphQL (`extensions.code`) o respuestas HTTP. +- `ApiErrorDto`, `@ApiReadErrors()` y `@ApiCommandErrors()`: el formato de error de la API REST en OpenAPI. ## Comandos diff --git a/libs/api/shared/presenters/src/index.ts b/libs/api/shared/presenters/src/index.ts index 2c16129..59c8bb5 100644 --- a/libs/api/shared/presenters/src/index.ts +++ b/libs/api/shared/presenters/src/index.ts @@ -1 +1,2 @@ export * from './lib/filters/domain-error.filter'; +export * from './lib/http/api-errors'; diff --git a/libs/api/shared/presenters/src/lib/http/api-errors.ts b/libs/api/shared/presenters/src/lib/http/api-errors.ts new file mode 100644 index 0000000..09233cd --- /dev/null +++ b/libs/api/shared/presenters/src/lib/http/api-errors.ts @@ -0,0 +1,59 @@ +import { applyDecorators } from '@nestjs/common'; +import { + ApiBadRequestResponse, + ApiConflictResponse, + ApiForbiddenResponse, + ApiNotFoundResponse, + ApiProperty, + ApiPropertyOptional, + ApiUnauthorizedResponse, + ApiUnprocessableEntityResponse, +} from '@nestjs/swagger'; + +/** Error body of the REST API (`DomainErrorFilter`, guards and validation). */ +export class ApiErrorDto { + @ApiProperty({ example: 404 }) + statusCode!: number; + + @ApiPropertyOptional({ + description: 'Domain error code', + enum: ['NOT_FOUND', 'FORBIDDEN', 'VALIDATION', 'CONFLICT', 'BUSINESS_RULE'], + }) + code?: string; + + @ApiProperty({ + description: 'Human message (a list of messages for invalid input)', + oneOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }], + example: 'Issue not found', + }) + message!: string | string[]; +} + +/** 400 / 401 / 403 / 404: what any authenticated read can answer. */ +export const ApiReadErrors = () => + applyDecorators( + ApiBadRequestResponse({ type: ApiErrorDto, description: 'Invalid input' }), + ApiUnauthorizedResponse({ + type: ApiErrorDto, + description: 'Missing or rejected Pireon token', + }), + ApiForbiddenResponse({ + type: ApiErrorDto, + description: 'Not a member of the project (or not allowed)', + }), + ApiNotFoundResponse({ type: ApiErrorDto, description: 'Not found' }), + ); + +/** Reads + 409 (concurrent change) and 422 (business rule) for commands. */ +export const ApiCommandErrors = () => + applyDecorators( + ApiReadErrors(), + ApiConflictResponse({ + type: ApiErrorDto, + description: 'Someone changed it at the same time: retry', + }), + ApiUnprocessableEntityResponse({ + type: ApiErrorDto, + description: 'Business rule (e.g. a transition the workflow forbids)', + }), + ); diff --git a/libs/api/tracker/presenters/README.md b/libs/api/tracker/presenters/README.md index b12ef9a..271034b 100644 --- a/libs/api/tracker/presenters/README.md +++ b/libs/api/tracker/presenters/README.md @@ -1,6 +1,6 @@ # api-tracker-presenters -API GraphQL del tracker. +API GraphQL y REST (OpenAPI) del tracker. - **Import**: `@task-stream/api/tracker/presenters` - **Capa**: `presenters` · **Tags**: `scope:api-tracker`, `type:presenters` @@ -9,8 +9,11 @@ API GraphQL del tracker. - Resolvers `ProjectResolver`, `IssueResolver`, `CommentResolver`, `ReleaseResolver` y `SubscriptionResolver`; solo llaman a services. - Tipos, inputs (con `class-validator`) y enums de GraphQL (code-first). +- `http/`: controladores REST `ProjectsController`, `IssuesController`, `CommentsController`, `ReleasesController` + (mismos services), DTOs con `@nestjs/swagger` y `Refs` (proyectos y tickets por id o clave). -Operaciones disponibles: [API](../../../../docs/03-api.md#32-graphql). +Operaciones disponibles: [GraphQL](../../../../docs/03-api.md#32-graphql) y +[REST](../../../../docs/03-api.md#33-rest-con-openapi-para-otras-apps). ## Comandos diff --git a/libs/api/tracker/presenters/src/lib/common/issue-input.ts b/libs/api/tracker/presenters/src/lib/common/issue-input.ts new file mode 100644 index 0000000..52befc7 --- /dev/null +++ b/libs/api/tracker/presenters/src/lib/common/issue-input.ts @@ -0,0 +1,10 @@ +import { IssueDetails } from '@task-stream/api/tracker/domain'; + +/** Clients may send explicit nulls; the domain only accepts them on nullable fields. */ +export function withoutNulls>(input: T): T { + const details = { ...input }; + for (const field of ['title', 'type', 'priority', 'labels'] as const) { + if (details[field] === null) delete details[field]; + } + return details; +} diff --git a/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts b/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts index 2c93ef3..80d10bb 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts @@ -15,7 +15,6 @@ import { IssuesService, } from '@task-stream/api/tracker/application'; import { - IssueDetails, IssueReadModel, IssueStatus, IssueWorkflow, @@ -27,6 +26,7 @@ import { LinkIssuesInput, UpdateIssueInput, } from './inputs'; +import { withoutNulls } from '../common/issue-input'; import { ActivityGql, CommandResultGql, @@ -35,15 +35,6 @@ import { IssuePageGql, } from './types'; -/** GraphQL sends explicit nulls; the domain only accepts them on nullable fields. */ -function withoutNulls>(input: T): T { - const details = { ...input }; - for (const field of ['title', 'type', 'priority', 'labels'] as const) { - if (details[field] === null) delete details[field]; - } - return details; -} - @Resolver(() => IssueGql) export class IssueResolver { constructor( diff --git a/libs/api/tracker/presenters/src/lib/http/comments.controller.ts b/libs/api/tracker/presenters/src/lib/http/comments.controller.ts new file mode 100644 index 0000000..a9565d1 --- /dev/null +++ b/libs/api/tracker/presenters/src/lib/http/comments.controller.ts @@ -0,0 +1,39 @@ +import { Body, Controller, Delete, Param, Patch } from '@nestjs/common'; +import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { User } from '@task-stream/api/identity/domain'; +import { + ApiPireonAuth, + CurrentUser, +} from '@task-stream/api/identity/presenters'; +import { ApiCommandErrors } from '@task-stream/api/shared/presenters'; +import { CommentsService } from '@task-stream/api/tracker/application'; +import { CommentBodyDto } from './dto/requests.dto'; +import { CommandResultDto } from './dto/responses.dto'; + +/** REST adapter of the comment use cases (listing and adding live under /issues). */ +@ApiTags('Comments') +@ApiPireonAuth() +@Controller('comments') +export class CommentsController { + constructor(private readonly comments: CommentsService) {} + + @Patch(':commentId') + @ApiOperation({ summary: 'Edit a comment (author only)' }) + @ApiOkResponse({ type: CommandResultDto }) + @ApiCommandErrors() + edit( + @CurrentUser() me: User, + @Param('commentId') commentId: string, + @Body() body: CommentBodyDto, + ) { + return this.comments.edit(me.id, commentId, body.body); + } + + @Delete(':commentId') + @ApiOperation({ summary: 'Delete a comment (author or project admin)' }) + @ApiOkResponse({ type: CommandResultDto }) + @ApiCommandErrors() + delete(@CurrentUser() me: User, @Param('commentId') commentId: string) { + return this.comments.delete(me.id, commentId); + } +} diff --git a/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts b/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts new file mode 100644 index 0000000..c2ba9c6 --- /dev/null +++ b/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts @@ -0,0 +1,434 @@ +import { + ApiProperty, + ApiPropertyOptional, + OmitType, + PartialType, +} from '@nestjs/swagger'; +import { + IssueArea, + IssueLinkType, + IssuePriority, + IssueStatus, + IssueType, + ProjectRole, +} from '@task-stream/api/tracker/domain'; +import { Transform, Type } from 'class-transformer'; +import { + ArrayMaxSize, + IsBoolean, + IsEnum, + IsIn, + IsInt, + IsISO8601, + IsOptional, + IsString, + IsUrl, + Matches, + Max, + MaxLength, + Min, + MinLength, + ValidateIf, +} from 'class-validator'; + +/* REST request bodies and query strings, validated like the GraphQL inputs. */ + +/** `?status=TODO&status=DONE` or `?status=TODO,DONE` → ['TODO', 'DONE']. */ +const toList = ({ value }: { value: unknown }) => + value === undefined || value === '' + ? undefined + : ([] as unknown[]) + .concat(value) + .flatMap((v) => String(v).split(',')) + .map((v) => v.trim()) + .filter(Boolean); + +const toBoolean = ({ value }: { value: unknown }) => + value === undefined ? undefined : value === true || value === 'true'; + +// ---- Projects --------------------------------------------------------------- + +export class CreateProjectDto { + @ApiProperty({ + example: 'WEB', + description: + '2-10 letters/digits starting with a letter → tickets WEB-1, WEB-2…', + }) + @Matches(/^[A-Za-z][A-Za-z0-9]{1,9}$/) + key!: string; + + @ApiProperty({ example: 'Checkout revamp', maxLength: 120 }) + @IsString() + @MinLength(1) + @MaxLength(120) + name!: string; + + @ApiPropertyOptional({ type: String, nullable: true, maxLength: 5000 }) + @IsOptional() + @IsString() + @MaxLength(5000) + description?: string | null; +} + +export class UpdateProjectDto extends PartialType( + OmitType(CreateProjectDto, ['key'] as const), +) {} + +export class AddMemberDto { + @ApiProperty({ description: 'Pireon user id (see GET /users)' }) + @IsString() + userId!: string; + + @ApiPropertyOptional({ + enum: ProjectRole, + enumName: 'ProjectRole', + default: ProjectRole.MEMBER, + }) + @IsOptional() + @IsEnum(ProjectRole) + role: ProjectRole = ProjectRole.MEMBER; +} + +export class ChangeMemberRoleDto { + @ApiProperty({ enum: ProjectRole, enumName: 'ProjectRole' }) + @IsEnum(ProjectRole) + role!: ProjectRole; +} + +export class BoardQueryDto { + @ApiPropertyOptional({ format: 'uuid', description: 'Only this release' }) + @IsOptional() + @IsString() + releaseId?: string; + + @ApiPropertyOptional({ description: 'Only this person (Pireon user id)' }) + @IsOptional() + @IsString() + assigneeId?: string; + + @ApiPropertyOptional({ enum: IssueArea, enumName: 'IssueArea' }) + @IsOptional() + @IsEnum(IssueArea) + area?: IssueArea; + + @ApiPropertyOptional({ default: 50, minimum: 1, maximum: 200 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(200) + perColumn = 50; +} + +export class ActivityQueryDto { + @ApiPropertyOptional({ format: 'uuid', description: 'Only this ticket' }) + @IsOptional() + @IsString() + issueId?: string; + + @ApiPropertyOptional({ default: 50, minimum: 1, maximum: 200 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(200) + limit = 50; + + @ApiPropertyOptional({ + format: 'date-time', + description: 'Cursor: entries older than this date', + }) + @IsOptional() + @IsISO8601() + before?: string; +} + +export class PageQueryDto { + @ApiPropertyOptional({ default: 50, minimum: 1, maximum: 200 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(200) + limit = 50; + + @ApiPropertyOptional({ default: 0, minimum: 0 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + offset = 0; +} + +// ---- Issues ----------------------------------------------------------------- + +export class IssueSearchQueryDto extends PageQueryDto { + @ApiPropertyOptional({ + enum: IssueStatus, + enumName: 'IssueStatus', + isArray: true, + description: 'Repeat the parameter or separate with commas', + }) + @IsOptional() + @Transform(toList) + @IsEnum(IssueStatus, { each: true }) + status?: IssueStatus[]; + + @ApiPropertyOptional({ + enum: IssueType, + enumName: 'IssueType', + isArray: true, + }) + @IsOptional() + @Transform(toList) + @IsEnum(IssueType, { each: true }) + type?: IssueType[]; + + @ApiPropertyOptional({ + enum: IssueArea, + enumName: 'IssueArea', + isArray: true, + }) + @IsOptional() + @Transform(toList) + @IsEnum(IssueArea, { each: true }) + area?: IssueArea[]; + + @ApiPropertyOptional({ + enum: IssuePriority, + enumName: 'IssuePriority', + isArray: true, + }) + @IsOptional() + @Transform(toList) + @IsEnum(IssuePriority, { each: true }) + priority?: IssuePriority[]; + + @ApiPropertyOptional({ description: 'Pireon user id' }) + @IsOptional() + @IsString() + assigneeId?: string; + + @ApiPropertyOptional({ description: 'Only tickets without assignee' }) + @IsOptional() + @Transform(toBoolean) + @IsBoolean() + unassigned?: boolean; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsString() + releaseId?: string; + + @ApiPropertyOptional({ type: [String] }) + @IsOptional() + @Transform(toList) + @IsString({ each: true }) + label?: string[]; + + @ApiPropertyOptional({ description: 'Matches key, title or description' }) + @IsOptional() + @IsString() + @MaxLength(200) + search?: string; +} + +class IssueFieldsDto { + @ApiPropertyOptional({ type: String, nullable: true, maxLength: 50000 }) + @IsOptional() + @IsString() + @MaxLength(50000) + description?: string | null; + + @ApiPropertyOptional({ + enum: IssueArea, + type: String, + nullable: true, + }) + @IsOptional() + @IsEnum(IssueArea) + area?: IssueArea | null; + + @ApiPropertyOptional({ enum: IssuePriority, enumName: 'IssuePriority' }) + @IsOptional() + @IsEnum(IssuePriority) + priority?: IssuePriority; + + @ApiPropertyOptional({ type: [String], maxItems: 20 }) + @IsOptional() + @ArrayMaxSize(20) + @IsString({ each: true }) + @MaxLength(40, { each: true }) + labels?: string[]; + + @ApiPropertyOptional({ + type: String, + nullable: true, + description: 'Figma frame (https) for UI changes', + }) + @IsOptional() + @IsUrl({ protocols: ['https'] }) + figmaUrl?: string | null; + + @ApiPropertyOptional({ + type: Number, + nullable: true, + minimum: 0, + maximum: 100, + }) + @IsOptional() + @IsInt() + @Min(0) + @Max(100) + storyPoints?: number | null; + + @ApiPropertyOptional({ type: String, nullable: true, format: 'date' }) + @IsOptional() + @IsISO8601() + dueDate?: string | null; +} + +export class CreateIssueDto extends IssueFieldsDto { + @ApiProperty({ enum: IssueType, enumName: 'IssueType' }) + @IsEnum(IssueType) + type!: IssueType; + + @ApiProperty({ + example: 'El botón de pagar no responde en Safari', + maxLength: 255, + }) + @IsString() + @MinLength(1) + @MaxLength(255) + title!: string; + + @ApiPropertyOptional({ + type: String, + nullable: true, + description: 'Pireon user id', + }) + @IsOptional() + @IsString() + assigneeId?: string | null; + + @ApiPropertyOptional({ type: String, nullable: true, format: 'uuid' }) + @IsOptional() + @IsString() + releaseId?: string | null; +} + +export class UpdateIssueDto extends IssueFieldsDto { + @ApiPropertyOptional({ enum: IssueType, enumName: 'IssueType' }) + @IsOptional() + @IsEnum(IssueType) + type?: IssueType; + + @ApiPropertyOptional({ maxLength: 255 }) + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(255) + title?: string; +} + +export class ChangeStatusDto { + @ApiProperty({ enum: IssueStatus, enumName: 'IssueStatus' }) + @IsEnum(IssueStatus) + status!: IssueStatus; + + @ApiPropertyOptional({ + type: String, + nullable: true, + description: 'Required for QA_FAILED and CANCELLED', + }) + @IsOptional() + @IsString() + @MaxLength(5000) + reason?: string | null; +} + +export class AssignIssueDto { + @ApiProperty({ + type: String, + nullable: true, + description: 'Pireon user id of a project member, or null to unassign', + }) + @ValidateIf((dto: AssignIssueDto) => dto.assigneeId !== null) + @IsString() + assigneeId!: string | null; +} + +export class SetIssueReleaseDto { + @ApiProperty({ + type: String, + nullable: true, + format: 'uuid', + description: 'Planned release, or null to take the ticket out', + }) + @ValidateIf((dto: SetIssueReleaseDto) => dto.releaseId !== null) + @IsString() + releaseId!: string | null; +} + +export class LinkIssueDto { + @ApiProperty({ enum: IssueLinkType, enumName: 'IssueLinkType' }) + @IsEnum(IssueLinkType) + type!: IssueLinkType; + + @ApiProperty({ example: 'API-3', description: 'Other ticket: id or key' }) + @IsString() + @MinLength(1) + targetIssue!: string; +} + +export class LinkTypeParamDto { + @ApiProperty({ enum: IssueLinkType, enumName: 'IssueLinkType' }) + @IsIn(Object.values(IssueLinkType)) + type!: IssueLinkType; +} + +export class CommentBodyDto { + @ApiProperty({ example: 'Reproducido en Safari 17.', maxLength: 20000 }) + @IsString() + @MinLength(1) + @MaxLength(20000) + body!: string; +} + +// ---- Releases --------------------------------------------------------------- + +export class CreateReleaseDto { + @ApiProperty({ example: 'v1.4.0', maxLength: 60 }) + @IsString() + @MinLength(1) + @MaxLength(60) + name!: string; + + @ApiPropertyOptional({ type: String, nullable: true, maxLength: 5000 }) + @IsOptional() + @IsString() + @MaxLength(5000) + description?: string | null; + + @ApiPropertyOptional({ + type: String, + nullable: true, + format: 'date', + example: '2026-11-14', + }) + @IsOptional() + @IsISO8601() + targetDate?: string | null; +} + +export class UpdateReleaseDto extends PartialType(CreateReleaseDto) {} + +export class PublishReleaseDto { + @ApiPropertyOptional({ + default: false, + description: 'Publish even with unfinished tickets', + }) + @IsOptional() + @IsBoolean() + force = false; +} diff --git a/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts b/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts new file mode 100644 index 0000000..36d5a62 --- /dev/null +++ b/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts @@ -0,0 +1,215 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IssueArea, + IssueLinkType, + IssuePriority, + IssueStatus, + IssueType, + ProjectRole, + ReleaseStatus, +} from '@task-stream/api/tracker/domain'; + +/* + * REST response bodies. Same shapes as the GraphQL types (both are views of + * the read models); here they only document the contract for OpenAPI. + */ + +export class UserRefDto { + @ApiProperty({ description: 'Pireon user id' }) id!: string; + @ApiProperty() name!: string; +} + +export class ProjectMemberDto { + @ApiProperty({ type: UserRefDto }) user!: UserRefDto; + @ApiProperty({ enum: ProjectRole, enumName: 'ProjectRole' }) + role!: ProjectRole; +} + +export class ProjectDto { + @ApiProperty({ format: 'uuid' }) id!: string; + @ApiProperty({ example: 'WEB', description: 'Prefix of the ticket keys' }) + key!: string; + @ApiProperty({ example: 'Checkout revamp' }) name!: string; + @ApiProperty({ type: String, nullable: true }) description!: string | null; + @ApiProperty({ type: UserRefDto }) owner!: UserRefDto; + @ApiProperty({ type: [ProjectMemberDto] }) members!: ProjectMemberDto[]; + @ApiProperty({ format: 'date-time' }) createdAt!: string; + @ApiProperty({ format: 'date-time' }) updatedAt!: string; + @ApiProperty({ description: 'Version of the aggregate (event count)' }) + version!: number; +} + +export class IssueLinkDto { + @ApiProperty({ enum: IssueLinkType, enumName: 'IssueLinkType' }) + type!: IssueLinkType; + @ApiProperty({ + enum: ['OUTWARD', 'INWARD'], + enumName: 'LinkDirection', + description: 'OUTWARD: this issue → other. INWARD: other → this issue.', + }) + direction!: 'OUTWARD' | 'INWARD'; + @ApiProperty({ format: 'uuid' }) issueId!: string; + @ApiProperty({ example: 'WEB-3' }) issueKey!: string; +} + +export class ReleaseRefDto { + @ApiProperty({ format: 'uuid' }) id!: string; + @ApiProperty({ example: 'v1.4.0' }) name!: string; +} + +export class IssueDto { + @ApiProperty({ format: 'uuid' }) id!: string; + @ApiProperty({ format: 'uuid' }) projectId!: string; + @ApiProperty({ example: 'WEB' }) projectKey!: string; + @ApiProperty({ example: 12 }) number!: number; + @ApiProperty({ example: 'WEB-12', description: 'Human key' }) key!: string; + @ApiProperty({ enum: IssueType, enumName: 'IssueType' }) type!: IssueType; + @ApiProperty({ enum: IssueArea, type: String, nullable: true }) + area!: IssueArea | null; + @ApiProperty() title!: string; + @ApiProperty({ type: String, nullable: true }) description!: string | null; + @ApiProperty({ enum: IssueStatus, enumName: 'IssueStatus' }) + status!: IssueStatus; + @ApiProperty({ + enum: IssueStatus, + enumName: 'IssueStatus', + isArray: true, + description: 'Statuses this ticket can move to next', + }) + allowedTransitions!: IssueStatus[]; + @ApiProperty({ enum: IssuePriority, enumName: 'IssuePriority' }) + priority!: IssuePriority; + @ApiProperty({ type: UserRefDto }) reporter!: UserRefDto; + @ApiProperty({ type: UserRefDto, nullable: true }) + assignee!: UserRefDto | null; + @ApiProperty({ type: ReleaseRefDto, nullable: true }) + release!: ReleaseRefDto | null; + @ApiProperty({ type: [String] }) labels!: string[]; + @ApiProperty({ type: String, nullable: true, description: 'Figma frame' }) + figmaUrl!: string | null; + @ApiProperty({ type: Number, nullable: true }) storyPoints!: number | null; + @ApiProperty({ type: String, nullable: true, format: 'date' }) + dueDate!: string | null; + @ApiProperty({ type: [IssueLinkDto] }) links!: IssueLinkDto[]; + @ApiProperty() commentCount!: number; + @ApiProperty({ format: 'date-time' }) createdAt!: string; + @ApiProperty({ format: 'date-time' }) updatedAt!: string; + @ApiProperty() version!: number; +} + +export class IssuePageDto { + @ApiProperty({ type: [IssueDto] }) items!: IssueDto[]; + @ApiProperty({ description: 'Total matches (for pagination)' }) + total!: number; +} + +export class BoardColumnDto { + @ApiProperty({ enum: IssueStatus, enumName: 'IssueStatus' }) + status!: IssueStatus; + @ApiProperty({ type: [IssueDto] }) issues!: IssueDto[]; + @ApiProperty({ description: 'Tickets in the column (may exceed `issues`)' }) + total!: number; +} + +export class CommentDto { + @ApiProperty({ format: 'uuid' }) id!: string; + @ApiProperty({ format: 'uuid' }) issueId!: string; + @ApiProperty({ type: UserRefDto }) author!: UserRefDto; + @ApiProperty() body!: string; + @ApiProperty({ format: 'date-time' }) createdAt!: string; + @ApiProperty({ type: String, nullable: true, format: 'date-time' }) + editedAt!: string | null; + @ApiProperty() version!: number; +} + +export class CommentPageDto { + @ApiProperty({ type: [CommentDto] }) items!: CommentDto[]; + @ApiProperty() total!: number; +} + +export class ReleaseDto { + @ApiProperty({ format: 'uuid' }) id!: string; + @ApiProperty({ format: 'uuid' }) projectId!: string; + @ApiProperty({ example: 'v1.4.0' }) name!: string; + @ApiProperty({ type: String, nullable: true }) description!: string | null; + @ApiProperty({ type: String, nullable: true, format: 'date' }) + targetDate!: string | null; + @ApiProperty({ enum: ReleaseStatus, enumName: 'ReleaseStatus' }) + status!: ReleaseStatus; + @ApiProperty({ type: String, nullable: true, format: 'date-time' }) + releasedAt!: string | null; + @ApiProperty({ format: 'date-time' }) createdAt!: string; + @ApiProperty() version!: number; +} + +export class CountByStatusDto { + @ApiProperty({ enum: IssueStatus, enumName: 'IssueStatus' }) + key!: IssueStatus; + @ApiProperty() count!: number; +} + +export class CountByKeyDto { + @ApiProperty({ description: 'Type, priority or area ("NONE" = unset)' }) + key!: string; + @ApiProperty() count!: number; +} + +export class CountByAssigneeDto { + @ApiProperty({ + type: UserRefDto, + nullable: true, + description: 'Null = unassigned', + }) + user!: UserRefDto | null; + @ApiProperty() count!: number; +} + +export class ReleaseProgressDto { + @ApiProperty() total!: number; + @ApiProperty() finished!: number; + @ApiProperty({ minimum: 0, maximum: 100 }) percent!: number; + @ApiProperty({ type: [CountByStatusDto] }) byStatus!: CountByStatusDto[]; +} + +export class ProjectStatsDto { + @ApiProperty() total!: number; + @ApiProperty({ description: 'Not done nor cancelled' }) open!: number; + @ApiProperty({ type: [CountByStatusDto] }) byStatus!: CountByStatusDto[]; + @ApiProperty({ type: [CountByKeyDto] }) byType!: CountByKeyDto[]; + @ApiProperty({ type: [CountByKeyDto] }) byPriority!: CountByKeyDto[]; + @ApiProperty({ type: [CountByKeyDto] }) byArea!: CountByKeyDto[]; + @ApiProperty({ + type: [CountByAssigneeDto], + description: 'Open tickets per person', + }) + byAssignee!: CountByAssigneeDto[]; +} + +export class ActivityDto { + @ApiProperty() id!: string; + @ApiProperty({ example: 'IssueStatusChanged' }) type!: string; + @ApiProperty({ format: 'uuid' }) projectId!: string; + @ApiProperty({ type: String, nullable: true }) issueId!: string | null; + @ApiProperty({ type: String, nullable: true, example: 'WEB-12' }) + issueKey!: string | null; + @ApiProperty({ type: UserRefDto }) actor!: UserRefDto; + @ApiProperty({ example: 'Ana movió WEB-12 de En curso a En revisión' }) + summary!: string; + @ApiProperty({ format: 'date-time' }) occurredAt!: string; +} + +export class CommandResultDto { + @ApiProperty({ description: 'Id of the aggregate written' }) id!: string; + @ApiPropertyOptional({ + description: 'Human key when one was assigned (project or issue)', + example: 'WEB-13', + }) + key?: string; + @ApiProperty({ + description: + 'Version after the write. Writes are acknowledged at once and the read ' + + 'model catches up asynchronously: a GET may briefly return the previous ' + + 'version (or 404 right after a create).', + }) + version!: number; +} diff --git a/libs/api/tracker/presenters/src/lib/http/issues.controller.ts b/libs/api/tracker/presenters/src/lib/http/issues.controller.ts new file mode 100644 index 0000000..ec71e94 --- /dev/null +++ b/libs/api/tracker/presenters/src/lib/http/issues.controller.ts @@ -0,0 +1,304 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Put, + Query, +} from '@nestjs/common'; +import { + ApiCreatedResponse, + ApiOkResponse, + ApiOperation, + ApiParam, + ApiTags, +} from '@nestjs/swagger'; +import { User } from '@task-stream/api/identity/domain'; +import { + ApiPireonAuth, + CurrentUser, +} from '@task-stream/api/identity/presenters'; +import { + ApiCommandErrors, + ApiReadErrors, +} from '@task-stream/api/shared/presenters'; +import { + CommentsService, + IssuesService, +} from '@task-stream/api/tracker/application'; +import { IssueLinkType } from '@task-stream/api/tracker/domain'; +import { withoutNulls } from '../common/issue-input'; +import { + AssignIssueDto, + ChangeStatusDto, + CommentBodyDto, + CreateIssueDto, + IssueSearchQueryDto, + LinkIssueDto, + LinkTypeParamDto, + PageQueryDto, + SetIssueReleaseDto, + UpdateIssueDto, +} from './dto/requests.dto'; +import { + ActivityDto, + CommandResultDto, + CommentPageDto, + IssueDto, + IssuePageDto, +} from './dto/responses.dto'; +import { Refs, toIssueDto } from './refs'; + +const PROJECT = ApiParam({ + name: 'project', + description: 'Project id or key', + example: 'WEB', +}); +const ISSUE = ApiParam({ + name: 'issue', + description: 'Ticket id or key', + example: 'WEB-12', +}); + +/** REST adapter of the ticket use cases: bugs, features, UI changes, tasks and QA. */ +@ApiTags('Issues') +@ApiPireonAuth() +@Controller() +export class IssuesController { + constructor( + private readonly issues: IssuesService, + private readonly comments: CommentsService, + private readonly refs: Refs, + ) {} + + @Get('projects/:project/issues') + @PROJECT + @ApiOperation({ summary: 'Search the tickets of a project' }) + @ApiOkResponse({ type: IssuePageDto }) + @ApiReadErrors() + async search( + @CurrentUser() me: User, + @Param('project') project: string, + @Query() query: IssueSearchQueryDto, + ) { + const page = await this.issues.search(me.id, { + projectId: await this.refs.projectId(me.id, project), + statuses: query.status, + types: query.type, + areas: query.area, + priorities: query.priority, + assigneeId: query.assigneeId, + unassigned: query.unassigned, + releaseId: query.releaseId, + labels: query.label, + search: query.search, + limit: query.limit, + offset: query.offset, + }); + return { ...page, items: page.items.map(toIssueDto) }; + } + + @Post('projects/:project/issues') + @PROJECT + @ApiOperation({ + summary: 'Create a ticket', + description: + 'Starts in BACKLOG. The response carries its key (e.g. WEB-13).', + }) + @ApiCreatedResponse({ type: CommandResultDto }) + @ApiCommandErrors() + async create( + @CurrentUser() me: User, + @Param('project') project: string, + @Body() body: CreateIssueDto, + ) { + return this.issues.create(me.id, { + ...withoutNulls(body), + projectId: await this.refs.projectId(me.id, project), + }); + } + + @Get('issues/:issue') + @ISSUE + @ApiOperation({ summary: 'A ticket' }) + @ApiOkResponse({ type: IssueDto }) + @ApiReadErrors() + async findOne(@CurrentUser() me: User, @Param('issue') issue: string) { + return toIssueDto(await this.issues.findOne(me.id, issue)); + } + + @Patch('issues/:issue') + @ISSUE + @ApiOperation({ + summary: 'Edit title, description, type, priority, area, labels…', + }) + @ApiOkResponse({ type: CommandResultDto }) + @ApiCommandErrors() + async update( + @CurrentUser() me: User, + @Param('issue') issue: string, + @Body() body: UpdateIssueDto, + ) { + return this.issues.update( + me.id, + await this.refs.issueId(me.id, issue), + withoutNulls(body), + ); + } + + @Put('issues/:issue/status') + @ISSUE + @ApiOperation({ + summary: 'Move the ticket through the workflow (incl. QA)', + description: + 'Only the transitions in `allowedTransitions` are accepted (422 otherwise). ' + + 'QA_FAILED and CANCELLED need a reason.', + }) + @ApiOkResponse({ type: CommandResultDto }) + @ApiCommandErrors() + async changeStatus( + @CurrentUser() me: User, + @Param('issue') issue: string, + @Body() body: ChangeStatusDto, + ) { + return this.issues.changeStatus( + me.id, + await this.refs.issueId(me.id, issue), + body.status, + body.reason, + ); + } + + @Put('issues/:issue/assignee') + @ISSUE + @ApiOperation({ summary: 'Assign to a project member (null to unassign)' }) + @ApiOkResponse({ type: CommandResultDto }) + @ApiCommandErrors() + async assign( + @CurrentUser() me: User, + @Param('issue') issue: string, + @Body() body: AssignIssueDto, + ) { + return this.issues.assign( + me.id, + await this.refs.issueId(me.id, issue), + body.assigneeId, + ); + } + + @Put('issues/:issue/release') + @ISSUE + @ApiOperation({ + summary: 'Put the ticket in a planned release (null takes it out)', + }) + @ApiOkResponse({ type: CommandResultDto }) + @ApiCommandErrors() + async setRelease( + @CurrentUser() me: User, + @Param('issue') issue: string, + @Body() body: SetIssueReleaseDto, + ) { + return this.issues.setRelease( + me.id, + await this.refs.issueId(me.id, issue), + body.releaseId, + ); + } + + @Post('issues/:issue/links') + @ISSUE + @ApiOperation({ + summary: 'Relate tickets (FE ↔ BE ↔ QA)', + description: + 'e.g. WEB-3 DEPENDS_ON API-7, WEB-9 TESTS WEB-3. May cross projects.', + }) + @ApiCreatedResponse({ type: CommandResultDto }) + @ApiCommandErrors() + async link( + @CurrentUser() me: User, + @Param('issue') issue: string, + @Body() body: LinkIssueDto, + ) { + return this.issues.link(me.id, { + issueId: await this.refs.issueId(me.id, issue), + type: body.type, + targetIssueId: await this.refs.issueId(me.id, body.targetIssue), + }); + } + + @Delete('issues/:issue/links/:type/:target') + @ISSUE + @ApiParam({ name: 'type', enum: IssueLinkType, enumName: 'IssueLinkType' }) + @ApiParam({ name: 'target', description: 'Other ticket: id or key' }) + @ApiOperation({ summary: 'Remove a relation' }) + @ApiOkResponse({ type: CommandResultDto }) + @ApiCommandErrors() + async unlink( + @CurrentUser() me: User, + @Param('issue') issue: string, + @Param() params: LinkTypeParamDto, + @Param('target') target: string, + ) { + return this.issues.unlink(me.id, { + issueId: await this.refs.issueId(me.id, issue), + type: params.type, + targetIssueId: await this.refs.issueId(me.id, target), + }); + } + + @Get('issues/:issue/comments') + @ISSUE + @ApiOperation({ summary: 'Comments of the ticket (oldest first)' }) + @ApiOkResponse({ type: CommentPageDto }) + @ApiReadErrors() + async listComments( + @CurrentUser() me: User, + @Param('issue') issue: string, + @Query() page: PageQueryDto, + ) { + return this.comments.list( + me.id, + await this.refs.issueId(me.id, issue), + page.limit, + page.offset, + ); + } + + @Post('issues/:issue/comments') + @ISSUE + @ApiOperation({ summary: 'Comment on the ticket' }) + @ApiCreatedResponse({ type: CommandResultDto }) + @ApiCommandErrors() + async addComment( + @CurrentUser() me: User, + @Param('issue') issue: string, + @Body() body: CommentBodyDto, + ) { + return this.comments.add( + me.id, + await this.refs.issueId(me.id, issue), + body.body, + ); + } + + @Get('issues/:issue/history') + @ISSUE + @ApiOperation({ summary: 'History of the ticket (newest first)' }) + @ApiOkResponse({ type: [ActivityDto] }) + @ApiReadErrors() + async history( + @CurrentUser() me: User, + @Param('issue') issue: string, + @Query() page: PageQueryDto, + ) { + const found = await this.issues.findOne(me.id, issue); + return this.issues.activity(me.id, { + projectId: found.projectId, + issueId: found.id, + limit: page.limit, + }); + } +} diff --git a/libs/api/tracker/presenters/src/lib/http/projects.controller.ts b/libs/api/tracker/presenters/src/lib/http/projects.controller.ts new file mode 100644 index 0000000..165cc7c --- /dev/null +++ b/libs/api/tracker/presenters/src/lib/http/projects.controller.ts @@ -0,0 +1,221 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, +} from '@nestjs/common'; +import { + ApiCreatedResponse, + ApiOkResponse, + ApiOperation, + ApiParam, + ApiTags, +} from '@nestjs/swagger'; +import { User } from '@task-stream/api/identity/domain'; +import { + AccessToken, + ApiPireonAuth, + CurrentUser, +} from '@task-stream/api/identity/presenters'; +import { + ApiCommandErrors, + ApiReadErrors, +} from '@task-stream/api/shared/presenters'; +import { + IssuesService, + ProjectsService, +} from '@task-stream/api/tracker/application'; +import { + ActivityQueryDto, + AddMemberDto, + BoardQueryDto, + ChangeMemberRoleDto, + CreateProjectDto, + UpdateProjectDto, +} from './dto/requests.dto'; +import { + ActivityDto, + BoardColumnDto, + CommandResultDto, + ProjectDto, + ProjectStatsDto, +} from './dto/responses.dto'; +import { Refs, toIssueDto } from './refs'; + +const PROJECT = ApiParam({ + name: 'project', + description: 'Project id or key', + example: 'WEB', +}); + +/** REST adapter of the project use cases (same services as GraphQL). */ +@ApiTags('Projects') +@ApiPireonAuth() +@Controller('projects') +export class ProjectsController { + constructor( + private readonly projects: ProjectsService, + private readonly issues: IssuesService, + private readonly refs: Refs, + ) {} + + @Get() + @ApiOperation({ summary: 'Projects where I am a member' }) + @ApiOkResponse({ type: [ProjectDto] }) + @ApiReadErrors() + mine(@CurrentUser() me: User) { + return this.projects.findMine(me.id); + } + + @Post() + @ApiOperation({ summary: 'Create a project (you become its owner)' }) + @ApiCreatedResponse({ type: CommandResultDto }) + @ApiCommandErrors() + create(@CurrentUser() me: User, @Body() body: CreateProjectDto) { + return this.projects.create(me.id, body); + } + + @Get(':project') + @PROJECT + @ApiOperation({ summary: 'A project with its members' }) + @ApiOkResponse({ type: ProjectDto }) + @ApiReadErrors() + findOne(@CurrentUser() me: User, @Param('project') project: string) { + return this.projects.findOne(me.id, project); + } + + @Patch(':project') + @PROJECT + @ApiOperation({ summary: 'Rename or describe a project (owner/admin)' }) + @ApiOkResponse({ type: CommandResultDto }) + @ApiCommandErrors() + async update( + @CurrentUser() me: User, + @Param('project') project: string, + @Body() body: UpdateProjectDto, + ) { + return this.projects.update( + me.id, + await this.refs.projectId(me.id, project), + body, + ); + } + + @Get(':project/board') + @PROJECT + @ApiOperation({ summary: 'Kanban board: one column per status' }) + @ApiOkResponse({ type: [BoardColumnDto] }) + @ApiReadErrors() + async board( + @CurrentUser() me: User, + @Param('project') project: string, + @Query() query: BoardQueryDto, + ) { + const columns = await this.projects.board( + me.id, + { + projectId: await this.refs.projectId(me.id, project), + releaseId: query.releaseId, + assigneeId: query.assigneeId, + areas: query.area ? [query.area] : undefined, + }, + query.perColumn, + ); + return columns.map((column) => ({ + ...column, + issues: column.issues.map(toIssueDto), + })); + } + + @Get(':project/stats') + @PROJECT + @ApiOperation({ + summary: 'Tracking dashboard: counts by status, type, person…', + }) + @ApiOkResponse({ type: ProjectStatsDto }) + @ApiReadErrors() + async stats(@CurrentUser() me: User, @Param('project') project: string) { + return this.projects.stats( + me.id, + await this.refs.projectId(me.id, project), + ); + } + + @Get(':project/activity') + @PROJECT + @ApiOperation({ summary: 'Activity feed (newest first)' }) + @ApiOkResponse({ type: [ActivityDto] }) + @ApiReadErrors() + async activity( + @CurrentUser() me: User, + @Param('project') project: string, + @Query() query: ActivityQueryDto, + ) { + return this.issues.activity(me.id, { + projectId: await this.refs.projectId(me.id, project), + issueId: query.issueId, + limit: query.limit, + before: query.before, + }); + } + + @Post(':project/members') + @PROJECT + @ApiOperation({ + summary: 'Add a Pireon user to the project (owner/admin)', + description: 'The user must exist and be active in Pireon.', + }) + @ApiCreatedResponse({ type: CommandResultDto }) + @ApiCommandErrors() + async addMember( + @CurrentUser() me: User, + @AccessToken() token: string, + @Param('project') project: string, + @Body() body: AddMemberDto, + ) { + const projectId = await this.refs.projectId(me.id, project); + return this.projects.addMember(me.id, { projectId, ...body }, token); + } + + @Patch(':project/members/:userId') + @PROJECT + @ApiOperation({ summary: "Change a member's role (owner/admin)" }) + @ApiOkResponse({ type: CommandResultDto }) + @ApiCommandErrors() + async changeMemberRole( + @CurrentUser() me: User, + @Param('project') project: string, + @Param('userId') userId: string, + @Body() body: ChangeMemberRoleDto, + ) { + const projectId = await this.refs.projectId(me.id, project); + return this.projects.changeMemberRole(me.id, { + projectId, + userId, + role: body.role, + }); + } + + @Delete(':project/members/:userId') + @PROJECT + @ApiOperation({ + summary: 'Remove a member (owner/admin, or yourself to leave)', + }) + @ApiOkResponse({ type: CommandResultDto }) + @ApiCommandErrors() + async removeMember( + @CurrentUser() me: User, + @Param('project') project: string, + @Param('userId') userId: string, + ) { + return this.projects.removeMember( + me.id, + await this.refs.projectId(me.id, project), + userId, + ); + } +} diff --git a/libs/api/tracker/presenters/src/lib/http/refs.ts b/libs/api/tracker/presenters/src/lib/http/refs.ts new file mode 100644 index 0000000..2a8664c --- /dev/null +++ b/libs/api/tracker/presenters/src/lib/http/refs.ts @@ -0,0 +1,39 @@ +import { Injectable } from '@nestjs/common'; +import { + IssuesService, + ProjectsService, +} from '@task-stream/api/tracker/application'; +import { IssueReadModel, IssueWorkflow } from '@task-stream/api/tracker/domain'; + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * REST routes accept a project or ticket by id or by key (`WEB`, `WEB-12`), + * which is what other apps usually have. Keys are looked up in the read model; + * ids go straight to the command (the service still checks membership). + */ +@Injectable() +export class Refs { + constructor( + private readonly projects: ProjectsService, + private readonly issues: IssuesService, + ) {} + + async projectId(viewerId: string, ref: string): Promise { + if (UUID.test(ref)) return ref; + return (await this.projects.findOne(viewerId, ref)).id; + } + + async issueId(viewerId: string, ref: string): Promise { + if (UUID.test(ref)) return ref; + return (await this.issues.findOne(viewerId, ref)).id; + } +} + +/** The ticket as the REST API returns it: read model + allowed transitions. */ +export function toIssueDto(issue: IssueReadModel) { + return { + ...issue, + allowedTransitions: IssueWorkflow.allowedFrom(issue.status), + }; +} diff --git a/libs/api/tracker/presenters/src/lib/http/releases.controller.ts b/libs/api/tracker/presenters/src/lib/http/releases.controller.ts new file mode 100644 index 0000000..1825fdd --- /dev/null +++ b/libs/api/tracker/presenters/src/lib/http/releases.controller.ts @@ -0,0 +1,164 @@ +import { + Body, + Controller, + Get, + HttpCode, + Param, + Patch, + Post, + Query, +} from '@nestjs/common'; +import { + ApiCreatedResponse, + ApiOkResponse, + ApiOperation, + ApiParam, + ApiTags, +} from '@nestjs/swagger'; +import { User } from '@task-stream/api/identity/domain'; +import { + ApiPireonAuth, + CurrentUser, +} from '@task-stream/api/identity/presenters'; +import { + ApiCommandErrors, + ApiReadErrors, +} from '@task-stream/api/shared/presenters'; +import { + IssuesService, + ProjectsService, + ReleasesService, +} from '@task-stream/api/tracker/application'; +import { + CreateReleaseDto, + PageQueryDto, + PublishReleaseDto, + UpdateReleaseDto, +} from './dto/requests.dto'; +import { + CommandResultDto, + IssuePageDto, + ReleaseDto, + ReleaseProgressDto, +} from './dto/responses.dto'; +import { Refs, toIssueDto } from './refs'; + +const PROJECT = ApiParam({ + name: 'project', + description: 'Project id or key', + example: 'WEB', +}); + +/** REST adapter of the release use cases: versions that group tickets. */ +@ApiTags('Releases') +@ApiPireonAuth() +@Controller() +export class ReleasesController { + constructor( + private readonly releases: ReleasesService, + private readonly projects: ProjectsService, + private readonly issues: IssuesService, + private readonly refs: Refs, + ) {} + + @Get('projects/:project/releases') + @PROJECT + @ApiOperation({ summary: 'Releases of a project' }) + @ApiOkResponse({ type: [ReleaseDto] }) + @ApiReadErrors() + async list(@CurrentUser() me: User, @Param('project') project: string) { + return this.projects.releases( + me.id, + await this.refs.projectId(me.id, project), + ); + } + + @Post('projects/:project/releases') + @PROJECT + @ApiOperation({ summary: 'Plan a release (owner/admin)' }) + @ApiCreatedResponse({ type: CommandResultDto }) + @ApiCommandErrors() + async create( + @CurrentUser() me: User, + @Param('project') project: string, + @Body() body: CreateReleaseDto, + ) { + return this.releases.create(me.id, { + ...body, + projectId: await this.refs.projectId(me.id, project), + }); + } + + @Get('releases/:releaseId') + @ApiOperation({ summary: 'A release' }) + @ApiOkResponse({ type: ReleaseDto }) + @ApiReadErrors() + findOne(@CurrentUser() me: User, @Param('releaseId') releaseId: string) { + return this.releases.findOne(me.id, releaseId); + } + + @Patch('releases/:releaseId') + @ApiOperation({ summary: 'Rename, describe or move the target date' }) + @ApiOkResponse({ type: CommandResultDto }) + @ApiCommandErrors() + update( + @CurrentUser() me: User, + @Param('releaseId') releaseId: string, + @Body() body: UpdateReleaseDto, + ) { + return this.releases.update(me.id, releaseId, body); + } + + @Get('releases/:releaseId/progress') + @ApiOperation({ summary: 'Finished tickets out of the total' }) + @ApiOkResponse({ type: ReleaseProgressDto }) + @ApiReadErrors() + progress(@CurrentUser() me: User, @Param('releaseId') releaseId: string) { + return this.releases.progress(me.id, releaseId); + } + + @Get('releases/:releaseId/issues') + @ApiOperation({ summary: 'Tickets included in the release' }) + @ApiOkResponse({ type: IssuePageDto }) + @ApiReadErrors() + async issuesOf( + @CurrentUser() me: User, + @Param('releaseId') releaseId: string, + @Query() page: PageQueryDto, + ) { + const release = await this.releases.findOne(me.id, releaseId); + const found = await this.issues.search(me.id, { + projectId: release.projectId, + releaseId: release.id, + limit: page.limit, + offset: page.offset, + }); + return { ...found, items: found.items.map(toIssueDto) }; + } + + @Post('releases/:releaseId/publish') + @HttpCode(200) + @ApiOperation({ + summary: 'Ship the release', + description: + 'Fails (422) while it has unfinished tickets unless `force` is true.', + }) + @ApiOkResponse({ type: CommandResultDto }) + @ApiCommandErrors() + publish( + @CurrentUser() me: User, + @Param('releaseId') releaseId: string, + @Body() body: PublishReleaseDto, + ) { + return this.releases.publish(me.id, releaseId, body.force); + } + + @Post('releases/:releaseId/archive') + @HttpCode(200) + @ApiOperation({ summary: 'Archive a planned release (its tickets leave it)' }) + @ApiOkResponse({ type: CommandResultDto }) + @ApiCommandErrors() + archive(@CurrentUser() me: User, @Param('releaseId') releaseId: string) { + return this.releases.archive(me.id, releaseId); + } +} diff --git a/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts b/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts index 868826d..db0e385 100644 --- a/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts +++ b/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts @@ -5,10 +5,26 @@ import { IssueResolver } from './graphql/issue.resolver'; import { ProjectResolver } from './graphql/project.resolver'; import { ReleaseResolver } from './graphql/release.resolver'; import { SubscriptionResolver } from './graphql/subscription.resolver'; +import { CommentsController } from './http/comments.controller'; +import { IssuesController } from './http/issues.controller'; +import { ProjectsController } from './http/projects.controller'; +import { Refs } from './http/refs'; +import { ReleasesController } from './http/releases.controller'; -/** Driving adapters of the tracker context (GraphQL queries, mutations, subscriptions). */ +/** + * Driving adapters of the tracker context: GraphQL (queries, mutations, + * subscriptions) and REST with OpenAPI for other apps. Both call the same + * application services. + */ @Module({ + controllers: [ + ProjectsController, + IssuesController, + CommentsController, + ReleasesController, + ], providers: [ + Refs, ProjectResolver, IssueResolver, CommentResolver, diff --git a/package-lock.json b/package-lock.json index 1a8334b..4496cf8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "@nestjs/graphql": "^13.4.5", "@nestjs/mongoose": "^11.0.4", "@nestjs/platform-express": "^11.0.0", + "@nestjs/swagger": "^11.4.7", "@nestjs/typeorm": "^11.0.3", "@ngrx/operators": "^22.0.1", "@ngrx/signals": "^22.0.1", @@ -10670,6 +10671,12 @@ "node": ">=8" } }, + "node_modules/@microsoft/tsdoc": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.16.0.tgz", + "integrity": "sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==", + "license": "MIT" + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", @@ -11650,6 +11657,61 @@ "node": ">= 8" } }, + "node_modules/@nestjs/swagger": { + "version": "11.4.7", + "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.4.7.tgz", + "integrity": "sha512-QyDYnmfP4IRucgmtQxMqzgRBdWtjFoDp8eFvvgf92+3wdLCL+Q0xOFO1948j/ntW/Wi7qT2dyck6ka8ADzPWQQ==", + "license": "MIT", + "dependencies": { + "@microsoft/tsdoc": "0.16.0", + "@nestjs/mapped-types": "2.1.1", + "js-yaml": "5.3.0", + "lodash": "4.18.1", + "path-to-regexp": "8.4.2", + "swagger-ui-dist": "5.32.13" + }, + "peerDependencies": { + "@fastify/static": "^8.0.0 || ^9.0.0 || ^10.0.0", + "@nestjs/common": "^11.0.1", + "@nestjs/core": "^11.0.1", + "class-transformer": "*", + "class-validator": "*", + "reflect-metadata": "^0.1.12 || ^0.2.0" + }, + "peerDependenciesMeta": { + "@fastify/static": { + "optional": true + }, + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, + "node_modules/@nestjs/swagger/node_modules/js-yaml": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.3.0.tgz", + "integrity": "sha512-muutsYr+e2+d3rTgUGslq5rxbBlUy3cJ61IsHag2QNDQV+7zXWjkUpmALIajhrlLlrgRUiymj6U3zUr/TMK84Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.mjs" + } + }, "node_modules/@nestjs/testing": { "version": "11.2.7", "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.2.7.tgz", @@ -13792,6 +13854,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, "node_modules/@schematics/angular": { "version": "22.1.9", "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-22.1.9.tgz", @@ -15312,7 +15381,6 @@ }, "node_modules/argparse": { "version": "2.0.1", - "dev": true, "license": "Python-2.0" }, "node_modules/aria-query": { @@ -29064,6 +29132,15 @@ "url": "https://opencollective.com/svgo" } }, + "node_modules/swagger-ui-dist": { + "version": "5.32.13", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.13.tgz", + "integrity": "sha512-qQobzb3DeC2LeK0j3E8812Ef4aIq1y9flJxvZkimkqUC/w4u7wS+yCc+VakqGJLweUUBrI24effhwo8OsAvNAw==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, "node_modules/swap-case": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/swap-case/-/swap-case-3.0.3.tgz", diff --git a/package.json b/package.json index 47aa742..60a1351 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@nestjs/graphql": "^13.4.5", "@nestjs/mongoose": "^11.0.4", "@nestjs/platform-express": "^11.0.0", + "@nestjs/swagger": "^11.4.7", "@nestjs/typeorm": "^11.0.3", "@ngrx/operators": "^22.0.1", "@ngrx/signals": "^22.0.1",