Compare commits

...

2 Commits

Author SHA1 Message Date
7532938b4e fix(docker): reach services by unique aliases, not bare service names
In Dokploy, services with a domain join the shared dokploy-network, where
another project also has an `api`. nginx proxied /api to `api:3000`, so
Docker DNS returned both containers and half of the requests went to the
other app: 404 "Cannot POST /api/auth/token" on sign-in and "sign-in not
configured" when /api/auth/config was answered by it.

Services now get task-stream-* aliases on the project network and nginx and
the API use them (task-stream-api, -postgres, -mongo, -redis). The API's
OPENAPI_FILE is empty in the container; docs 07 explain the aliases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:26:47 +02:00
341241a52f feat(api): REST API with OpenAPI for other apps, behind Pireon OAuth
A REST adapter next to GraphQL (presenters/http) that calls the same
application services, so validation, permissions and domain errors are the
same: projects (members, board, stats, activity), issues (search, workflow,
assignee, release, links, comments, history), comments, releases and users.
Projects and issues can be addressed by id or key (WEB, WEB-12).

- Swagger UI at /api/docs, document at /api/docs-json and apps/api/openapi.json
  (rewritten by `nx serve api`, like schema.gql) for client generation.
- Every route but health/auth/webhook needs a Pireon token: the document
  declares Pireon OAuth2 (authorization code + PKCE, password) through the
  /api/auth/token proxy, and a bearer scheme for apps with their own client.
- configureApp() shares main.ts setup with the e2e, which now also covers REST.
- SWAGGER_ENABLED / OPENAPI_FILE config; docs 03/05 and library READMEs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:26:47 +02:00
41 changed files with 7424 additions and 48 deletions

View File

@@ -28,6 +28,10 @@ AUTH_SESSION_TTL_SECONDS=60
GRAPHQL_PLAYGROUND=true 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). # 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-) # Locally: export NEXUS_TOKEN=$(grep '^//nexus.pireon.dev/repository/npm-releases/:_authToken=' ~/.npmrc | cut -d= -f2-)
NEXUS_TOKEN= NEXUS_TOKEN=

View File

@@ -5,3 +5,7 @@
/.nx/workspace-data /.nx/workspace-data
package-lock.json package-lock.json
.angular .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

View File

@@ -3,7 +3,7 @@
Alternativa a Jira para equipos de desarrollo: proyectos, tickets (bugs, features, cambios de UI, tareas y QA), 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**. 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). Redis entre ambas).
- **Web**: Angular 22 + NgRx Signals (patrón de eventos) + `@pi/ui`, con la misma arquitectura. - **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). - **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 npm install # @pi/ui necesita el token del Nexus de Pireon en ~/.npmrc
docker compose up -d postgres mongo redis docker compose up -d postgres mongo redis
cp .env.example .env 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 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 | | [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 | | [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` | | [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 | | [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 | | [Desarrollo](docs/06-desarrollo.md) | Comandos, tests, cómo añadir una funcionalidad y problemas conocidos |

View File

@@ -1,11 +1,10 @@
import { INestApplication, ValidationPipe } from '@nestjs/common'; import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing'; import { Test } from '@nestjs/testing';
import { import {
IdentityProvider, IdentityProvider,
InvalidAccessTokenError, InvalidAccessTokenError,
} from '@task-stream/api/identity/application'; } from '@task-stream/api/identity/application';
import { User, UserFactory } from '@task-stream/api/identity/domain'; import { User, UserFactory } from '@task-stream/api/identity/domain';
import { DomainErrorFilter } from '@task-stream/api/shared/presenters';
import { Client, createClient } from 'graphql-ws'; import { Client, createClient } from 'graphql-ws';
import WebSocket from '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['REDIS_URL'] ??= 'redis://localhost:6379';
process.env['EVENT_STREAM_KEY'] = `task-stream:e2e:${Date.now()}`; process.env['EVENT_STREAM_KEY'] = `task-stream:e2e:${Date.now()}`;
process.env['GRAPHQL_SCHEMA_FILE'] = ''; process.env['GRAPHQL_SCHEMA_FILE'] = '';
process.env['OPENAPI_FILE'] = '';
process.env['AUTH_SESSION_TTL_SECONDS'] = '0'; process.env['AUTH_SESSION_TTL_SECONDS'] = '0';
const { AppModule } = require('../src/app/app.module'); 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. */ /** Pireon is replaced by fixed tokens: the rest of the stack is real. */
const users = new UserFactory(); const users = new UserFactory();
@@ -71,6 +72,25 @@ describe('task-stream (e2e)', () => {
return body.data; return body.data;
} }
/** REST call under /api; returns status and parsed body. */
async function rest<T = Record<string, any>>( // 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. */ /** The read model is eventually consistent: poll until it catches up. */
async function eventually<T>( async function eventually<T>(
read: () => Promise<T>, read: () => Promise<T>,
@@ -123,10 +143,7 @@ describe('task-stream (e2e)', () => {
.useValue(fakePireon) .useValue(fakePireon)
.compile(); .compile();
app = moduleRef.createNestApplication({ rawBody: true }); app = moduleRef.createNestApplication({ rawBody: true });
app.useGlobalPipes( configureApp(app);
new ValidationPipe({ transform: true, whitelist: true }),
);
app.useGlobalFilters(new DomainErrorFilter());
await app.listen(0); await app.listen(0);
url = await app.getUrl(); url = await app.getUrl();
url = url.replace('[::1]', 'localhost'); url = url.replace('[::1]', 'localhost');
@@ -405,4 +422,139 @@ describe('task-stream (e2e)', () => {
expect(anonymous.received).toHaveLength(0); expect(anonymous.received).toHaveLength(0);
expect(anonymous.errors).toHaveLength(1); 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,
);
});
}); });

5176
apps/api/openapi.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -14,6 +14,7 @@
"@nestjs/graphql": "^13.4.5", "@nestjs/graphql": "^13.4.5",
"@nestjs/mongoose": "^11.0.4", "@nestjs/mongoose": "^11.0.4",
"@nestjs/platform-express": "^11.0.0", "@nestjs/platform-express": "^11.0.0",
"@nestjs/swagger": "^11.4.7",
"@nestjs/typeorm": "^11.0.3", "@nestjs/typeorm": "^11.0.3",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.15.1", "class-validator": "^0.15.1",

View File

@@ -33,4 +33,9 @@ export const configSchema = Joi.object({
GRAPHQL_PLAYGROUND: Joi.boolean().default(true), GRAPHQL_PLAYGROUND: Joi.boolean().default(true),
GRAPHQL_SCHEMA_FILE: Joi.string().allow('').default('apps/api/schema.gql'), 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'),
}); });

View File

@@ -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);
}

View File

@@ -5,12 +5,19 @@ import {
ServiceUnavailableException, ServiceUnavailableException,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectConnection } from '@nestjs/mongoose'; import { InjectConnection } from '@nestjs/mongoose';
import {
ApiOkResponse,
ApiOperation,
ApiServiceUnavailableResponse,
ApiTags,
} from '@nestjs/swagger';
import { Public } from '@task-stream/api/identity/presenters'; import { Public } from '@task-stream/api/identity/presenters';
import { REDIS_CLIENT } from '@task-stream/api/shared/infrastructure'; import { REDIS_CLIENT } from '@task-stream/api/shared/infrastructure';
import Redis from 'ioredis'; import Redis from 'ioredis';
import { Connection } from 'mongoose'; import { Connection } from 'mongoose';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
@ApiTags('Health')
@Controller('health') @Controller('health')
export class HealthController { export class HealthController {
constructor( constructor(
@@ -21,6 +28,13 @@ export class HealthController {
@Public() @Public()
@Get() @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() { async check() {
const checks = await Promise.allSettled([ const checks = await Promise.allSettled([
this.postgres.query('SELECT 1'), this.postgres.query('SELECT 1'),

158
apps/api/src/app/openapi.ts Normal file
View File

@@ -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>/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 <token>\`.
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<boolean>('SWAGGER_ENABLED')) return;
const pireon =
config.get<string>('PIREON_BASE_URL') ?? 'https://oauth.pireon.dev';
const scopes = (config.get<string>('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<boolean>('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<string>('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<string>('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; }
`,
});
}

View File

@@ -1,22 +1,19 @@
import { Logger, ValidationPipe } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { DomainErrorFilter } from '@task-stream/api/shared/presenters';
import { AppModule } from './app/app.module'; import { AppModule } from './app/app.module';
import { configureApp } from './app/configure';
async function bootstrap() { async function bootstrap() {
// rawBody: needed to verify Pireon webhook signatures. // rawBody: needed to verify Pireon webhook signatures.
const app = await NestFactory.create(AppModule, { rawBody: true }); const app = await NestFactory.create(AppModule, { rawBody: true });
app.setGlobalPrefix('api'); configureApp(app);
app.enableCors();
app.enableShutdownHooks();
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
app.useGlobalFilters(new DomainErrorFilter());
const port = process.env['PORT'] || 3000; const port = process.env['PORT'] || 3000;
await app.listen(port); await app.listen(port);
Logger.log( Logger.log(
`🚀 GraphQL: http://localhost:${port}/graphql (ws for subscriptions)`, `🚀 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`); Logger.log(`❤️ Health: http://localhost:${port}/api/health`);
} }

View File

@@ -1,4 +1,6 @@
# Same-origin API for the SPA: REST under /api, GraphQL (HTTP + WebSocket) under /graphql. # Same-origin API for the SPA: REST under /api, GraphQL (HTTP + WebSocket) under /graphql.
# The API is reached by its unique alias (task-stream-api), not by the service name `api`:
# on a shared network (Dokploy) another project's `api` would get half of the requests.
map $http_upgrade $connection_upgrade { map $http_upgrade $connection_upgrade {
default upgrade; default upgrade;
'' close; '' close;
@@ -9,13 +11,13 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
location /api/ { location /api/ {
proxy_pass http://api:3000; proxy_pass http://task-stream-api:3000;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
} }
location /graphql { location /graphql {
proxy_pass http://api:3000; proxy_pass http://task-stream-api:3000;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade; proxy_set_header Connection $connection_upgrade;

View File

@@ -2,6 +2,10 @@
# No ports are published here: services only talk over the compose network, and the web is # No ports are published here: services only talk over the compose network, and the web is
# exposed through the platform's proxy (Dokploy domain → service `web`, port 80). # exposed through the platform's proxy (Dokploy domain → service `web`, port 80).
# #
# Services talk to each other by unique aliases (task-stream-*), never by the bare service
# name: on a shared network (Dokploy's dokploy-network) other projects also have an `api`,
# `postgres` or `redis`, and Docker DNS would hand out theirs too.
#
# Locally, `docker compose` also loads docker-compose.override.yml, which publishes the ports: # Locally, `docker compose` also loads docker-compose.override.yml, which publishes the ports:
# docker compose up -d --build → everything: web on http://localhost:8080 # docker compose up -d --build → everything: web on http://localhost:8080
# docker compose up -d postgres mongo redis → only infrastructure (then `npx nx serve api` / `npx nx serve web`) # docker compose up -d postgres mongo redis → only infrastructure (then `npx nx serve api` / `npx nx serve web`)
@@ -16,6 +20,7 @@ services:
secrets: [nexus_token] secrets: [nexus_token]
image: task-stream-web image: task-stream-web
expose: ['80'] expose: ['80']
networks: [default]
depends_on: depends_on:
api: { condition: service_healthy } api: { condition: service_healthy }
restart: unless-stopped restart: unless-stopped
@@ -27,12 +32,15 @@ services:
secrets: [nexus_token] secrets: [nexus_token]
image: task-stream-api image: task-stream-api
expose: ['3000'] expose: ['3000']
networks:
default:
aliases: [task-stream-api]
environment: environment:
POSTGRES_URL: postgres://taskstream:taskstream@postgres:5432/taskstream POSTGRES_URL: postgres://taskstream:taskstream@task-stream-postgres:5432/taskstream
# No migrations yet: TypeORM creates the event store and lookup tables. # No migrations yet: TypeORM creates the event store and lookup tables.
POSTGRES_SYNCHRONIZE: 'true' POSTGRES_SYNCHRONIZE: 'true'
MONGO_URL: mongodb://mongo:27017/taskstream_read MONGO_URL: mongodb://task-stream-mongo:27017/taskstream_read
REDIS_URL: redis://redis:6379 REDIS_URL: redis://task-stream-redis:6379
EVENT_STREAM_KEY: task-stream:events EVENT_STREAM_KEY: task-stream:events
EVENTS_BRIDGE_ENABLED: 'true' EVENTS_BRIDGE_ENABLED: 'true'
PIREON_BASE_URL: https://oauth.pireon.dev PIREON_BASE_URL: https://oauth.pireon.dev
@@ -40,8 +48,10 @@ services:
PIREON_CLIENT_ID: ${PIREON_CLIENT_ID:-} PIREON_CLIENT_ID: ${PIREON_CLIENT_ID:-}
PIREON_CLIENT_SECRET: ${PIREON_CLIENT_SECRET:-} PIREON_CLIENT_SECRET: ${PIREON_CLIENT_SECRET:-}
GRAPHQL_PLAYGROUND: 'true' GRAPHQL_PLAYGROUND: 'true'
# The SDL file is only useful in development (codegen). # The SDL / OpenAPI files are only useful in development (codegen); the
# document is still served at /api/docs-json.
GRAPHQL_SCHEMA_FILE: '' GRAPHQL_SCHEMA_FILE: ''
OPENAPI_FILE: ''
depends_on: depends_on:
postgres: { condition: service_healthy } postgres: { condition: service_healthy }
mongo: { condition: service_healthy } mongo: { condition: service_healthy }
@@ -50,6 +60,9 @@ services:
postgres: postgres:
image: postgres:17-alpine image: postgres:17-alpine
networks:
default:
aliases: [task-stream-postgres]
environment: environment:
POSTGRES_USER: taskstream POSTGRES_USER: taskstream
POSTGRES_PASSWORD: taskstream POSTGRES_PASSWORD: taskstream
@@ -62,6 +75,9 @@ services:
mongo: mongo:
image: mongo:8 image: mongo:8
networks:
default:
aliases: [task-stream-mongo]
volumes: [mongo-data:/data/db] volumes: [mongo-data:/data/db]
healthcheck: healthcheck:
test: ['CMD', 'mongosh', '--quiet', '--eval', 'db.adminCommand("ping")'] test: ['CMD', 'mongosh', '--quiet', '--eval', 'db.adminCommand("ping")']
@@ -70,6 +86,9 @@ services:
redis: redis:
image: redis:7-alpine image: redis:7-alpine
networks:
default:
aliases: [task-stream-redis]
command: ['redis-server', '--appendonly', 'yes'] command: ['redis-server', '--appendonly', 'yes']
volumes: [redis-data:/data] volumes: [redis-data:/data]
healthcheck: healthcheck:

View File

@@ -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…) | | `BUSINESS_RULE` | Regla de negocio (transición no permitida, release bloqueada…) |
| `CONFLICT` | Otro cambio llegó antes (concurrencia optimista): reintenta | | `CONFLICT` | Otro cambio llegó antes (concurrencia optimista): reintenta |
## 3.3 REST ## 3.3 REST con OpenAPI (para otras apps)
| Endpoint | Uso | 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
| `GET /api/health` | Estado de PostgreSQL, MongoDB y Redis (público) | `*.service.ts`: misma validación, permisos y errores.
| `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`) |
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 <token de Pireon>` (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>/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).

View File

@@ -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:4200/auth/callback` (desarrollo)
- `http://localhost:8080/auth/callback` (Docker) - `http://localhost:8080/auth/callback` (Docker)
- la de producción - 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). 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`, `GET /api/auth/config` devuelve a la web lo necesario para iniciar el flujo (`authorizeUrl`, `clientId`, `scopes`,

View File

@@ -27,6 +27,15 @@ docker compose down # parar (-v borra los datos)
Para probar en local exactamente lo de producción: `docker compose -f docker-compose.yml up -d --build`. Para probar en local exactamente lo de producción: `docker compose -f docker-compose.yml up -d --build`.
### Nombres únicos en la red
Los servicios se llaman entre sí por **alias únicos** (`task-stream-api`, `task-stream-postgres`, `task-stream-mongo`,
`task-stream-redis`), no por el nombre del servicio. Al dar dominio a un servicio, Dokploy lo conecta a la red
compartida `dokploy-network`, donde otros proyectos también tienen un `api`, un `postgres` o un `redis`: con nombres
genéricos, el DNS de Docker devuelve también los suyos y nginx reparte las peticiones entre ambos (síntoma: la mitad
de las llamadas a `/api/*` responden `404 Cannot GET …` y el login dice «no configurado»). Si añades un servicio,
dale su alias `task-stream-*` y úsalo en las URLs.
Todos tienen healthcheck; `web` espera a que `api` esté sana, y `api` a las tres bases de datos. Todos tienen healthcheck; `web` espera a que `api` esté sana, y `api` a las tres bases de datos.
### Imágenes ### Imágenes
@@ -78,6 +87,8 @@ donde los lee Compose.
| `AUTH_SESSION_TTL_SECONDS` | `60` | Cuánto se confía en un token validado antes de volver a preguntar a Pireon | | `AUTH_SESSION_TTL_SECONDS` | `60` | Cuánto se confía en un token validado antes de volver a preguntar a Pireon |
| `GRAPHQL_PLAYGROUND` | `true` | GraphiQL e introspección | | `GRAPHQL_PLAYGROUND` | `true` | GraphiQL e introspección |
| `GRAPHQL_SCHEMA_FILE` | `apps/api/schema.gql` | Dónde escribir el SDL (vacío = no escribir; así está en Docker) | | `GRAPHQL_SCHEMA_FILE` | `apps/api/schema.gql` | Dónde escribir el SDL (vacío = no escribir; así está en Docker) |
| `SWAGGER_ENABLED` | `true` | API REST documentada: Swagger UI en `/api/docs`, OpenAPI en `/api/docs-json` |
| `OPENAPI_FILE` | `apps/api/openapi.json` | Dónde escribir el documento OpenAPI al arrancar (vacío = no escribir; así en Docker) |
La web no tiene configuración de entorno: llama a la API en su mismo origen (`/api`, `/graphql`). La web no tiene configuración de entorno: llama a la API en su mismo origen (`/api`, `/graphql`).

Binary file not shown.

Binary file not shown.

View File

@@ -7,8 +7,10 @@ Adaptadores de entrada de identidad.
## Contenido ## Contenido
- `auth/`: `AuthGuard` global, `@Public()`, `@CurrentUser()`, `@AccessToken()`, `@RequireAppRoles()` y `WsAuthenticator` (subscriptions). - `auth/`: `AuthGuard` global, `@Public()`, `@CurrentUser()`, `@AccessToken()`, `@RequireAppRoles()`, `WsAuthenticator`
- `http/`: `AuthController` (`GET /api/auth/config`, `POST /api/auth/token`) y `PireonWebhookController` (firma HMAC). (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`. - `graphql/`: queries `me`, `user` y `users`.
## Comandos ## Comandos

View File

@@ -1,8 +1,10 @@
import { import {
ExecutionContext, ExecutionContext,
SetMetadata, SetMetadata,
applyDecorators,
createParamDecorator, createParamDecorator,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiBearerAuth, ApiOAuth2 } from '@nestjs/swagger';
import { getAuthRequest } from './auth-request'; import { getAuthRequest } from './auth-request';
export const IS_PUBLIC = 'identity:isPublic'; export const IS_PUBLIC = 'identity:isPublic';
@@ -25,3 +27,15 @@ export const AccessToken = createParamDecorator(
(_: unknown, context: ExecutionContext) => (_: unknown, context: ExecutionContext) =>
getAuthRequest(context).accessToken, 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));

View File

@@ -6,6 +6,15 @@ import {
Post, Post,
UnauthorizedException, UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
import {
ApiBody,
ApiConsumes,
ApiOkResponse,
ApiOperation,
ApiProperty,
ApiTags,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
import { import {
IdentityService, IdentityService,
InvalidCredentialsError, InvalidCredentialsError,
@@ -18,10 +27,29 @@ import {
import { Public } from '../auth/auth.decorators'; import { Public } from '../auth/auth.decorators';
import { IssueTokenDto } from './dto/issue-token.dto'; 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 * 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. * secret must stay server-side, so the web app goes through here.
*/ */
@ApiTags('Auth')
@Controller('auth') @Controller('auth')
export class AuthController { export class AuthController {
constructor(private readonly identity: IdentityService) {} 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). */ /** What the web app needs to start Authorization Code + PKCE (null = not configured). */
@Public() @Public()
@Get('config') @Get('config')
@ApiOperation({
summary: 'How to sign in with Pireon (null when not configured)',
})
@ApiOkResponse({ type: AuthorizationConfigDto })
config(): Promise<AuthorizationConfig | null> { config(): Promise<AuthorizationConfig | null> {
return this.identity.authorizationConfig(); return this.identity.authorizationConfig();
} }
@@ -36,6 +68,18 @@ export class AuthController {
@Public() @Public()
@Post('token') @Post('token')
@HttpCode(200) @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) { async token(@Body() dto: IssueTokenDto) {
try { try {
return toResponse(await this.identity.issueToken(toGrant(dto))); return toResponse(await this.identity.issueToken(toGrant(dto)));

View File

@@ -1,35 +1,48 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsNotEmpty, IsString, IsUrl, ValidateIf } from 'class-validator'; 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 { export class IssueTokenDto {
@ApiProperty({ enum: ['authorization_code', 'password', 'refresh_token'] })
@IsIn(['authorization_code', 'password', 'refresh_token']) @IsIn(['authorization_code', 'password', 'refresh_token'])
grant_type!: 'authorization_code' | 'password' | 'refresh_token'; grant_type!: 'authorization_code' | 'password' | 'refresh_token';
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'authorization_code') @ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'authorization_code')
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@ApiPropertyOptional({
description: 'authorization_code: code returned by Pireon',
})
code?: string; code?: string;
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'authorization_code') @ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'authorization_code')
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@ApiPropertyOptional({ description: 'authorization_code: PKCE verifier' })
code_verifier?: string; code_verifier?: string;
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'authorization_code') @ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'authorization_code')
@IsUrl({ require_tld: false }) @IsUrl({ require_tld: false })
@ApiPropertyOptional({
description: 'authorization_code: redirect URI used to authorize',
})
redirect_uri?: string; redirect_uri?: string;
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'password') @ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'password')
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@ApiPropertyOptional({ description: 'password: Pireon email' })
username?: string; username?: string;
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'password') @ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'password')
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@ApiPropertyOptional({ description: 'password: Pireon password' })
password?: string; password?: string;
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'refresh_token') @ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'refresh_token')
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@ApiPropertyOptional({ description: 'refresh_token' })
refresh_token?: string; refresh_token?: string;
} }

View File

@@ -9,12 +9,20 @@ import {
UnauthorizedException, UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import {
ApiHeader,
ApiNoContentResponse,
ApiOperation,
ApiTags,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
import { IdentityService } from '@task-stream/api/identity/application'; import { IdentityService } from '@task-stream/api/identity/application';
import { Request } from 'express'; import { Request } from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto'; import { createHmac, timingSafeEqual } from 'node:crypto';
import { Public } from '../auth/auth.decorators'; import { Public } from '../auth/auth.decorators';
/** Receives `user.deactivated` / `token.revoked` from Pireon. */ /** Receives `user.deactivated` / `token.revoked` from Pireon. */
@ApiTags('Webhooks')
@Controller('webhooks/pireon') @Controller('webhooks/pireon')
export class PireonWebhookController { export class PireonWebhookController {
constructor( constructor(
@@ -25,6 +33,19 @@ export class PireonWebhookController {
@Public() @Public()
@Post() @Post()
@HttpCode(204) @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( async receive(
@Req() req: RawBodyRequest<Request>, @Req() req: RawBodyRequest<Request>,
@Headers('x-webhook-signature') signature?: string, @Headers('x-webhook-signature') signature?: string,

View File

@@ -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;
}
}

View File

@@ -5,10 +5,11 @@ import { WsAuthenticator } from './auth/ws-authenticator';
import { UserResolver } from './graphql/user.resolver'; import { UserResolver } from './graphql/user.resolver';
import { AuthController } from './http/auth.controller'; import { AuthController } from './http/auth.controller';
import { PireonWebhookController } from './http/pireon-webhook.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({ @Module({
controllers: [AuthController, PireonWebhookController], controllers: [AuthController, UsersController, PireonWebhookController],
providers: [ providers: [
{ provide: APP_GUARD, useClass: AuthGuard }, { provide: APP_GUARD, useClass: AuthGuard },
WsAuthenticator, WsAuthenticator,

View File

@@ -8,6 +8,7 @@ Adaptadores de entrada comunes.
## Contenido ## Contenido
- `DomainErrorFilter`: convierte los errores de dominio en errores GraphQL (`extensions.code`) o respuestas HTTP. - `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 ## Comandos

View File

@@ -1 +1,2 @@
export * from './lib/filters/domain-error.filter'; export * from './lib/filters/domain-error.filter';
export * from './lib/http/api-errors';

View File

@@ -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)',
}),
);

View File

@@ -1,6 +1,6 @@
# api-tracker-presenters # api-tracker-presenters
API GraphQL del tracker. API GraphQL y REST (OpenAPI) del tracker.
- **Import**: `@task-stream/api/tracker/presenters` - **Import**: `@task-stream/api/tracker/presenters`
- **Capa**: `presenters` · **Tags**: `scope:api-tracker`, `type: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. - Resolvers `ProjectResolver`, `IssueResolver`, `CommentResolver`, `ReleaseResolver` y `SubscriptionResolver`; solo llaman a services.
- Tipos, inputs (con `class-validator`) y enums de GraphQL (code-first). - 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 ## Comandos

View File

@@ -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<T extends Partial<IssueDetails>>(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;
}

View File

@@ -15,7 +15,6 @@ import {
IssuesService, IssuesService,
} from '@task-stream/api/tracker/application'; } from '@task-stream/api/tracker/application';
import { import {
IssueDetails,
IssueReadModel, IssueReadModel,
IssueStatus, IssueStatus,
IssueWorkflow, IssueWorkflow,
@@ -27,6 +26,7 @@ import {
LinkIssuesInput, LinkIssuesInput,
UpdateIssueInput, UpdateIssueInput,
} from './inputs'; } from './inputs';
import { withoutNulls } from '../common/issue-input';
import { import {
ActivityGql, ActivityGql,
CommandResultGql, CommandResultGql,
@@ -35,15 +35,6 @@ import {
IssuePageGql, IssuePageGql,
} from './types'; } from './types';
/** GraphQL sends explicit nulls; the domain only accepts them on nullable fields. */
function withoutNulls<T extends Partial<IssueDetails>>(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) @Resolver(() => IssueGql)
export class IssueResolver { export class IssueResolver {
constructor( constructor(

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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,
});
}
}

View File

@@ -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,
);
}
}

View File

@@ -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<string> {
if (UUID.test(ref)) return ref;
return (await this.projects.findOne(viewerId, ref)).id;
}
async issueId(viewerId: string, ref: string): Promise<string> {
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),
};
}

View File

@@ -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);
}
}

View File

@@ -5,10 +5,26 @@ import { IssueResolver } from './graphql/issue.resolver';
import { ProjectResolver } from './graphql/project.resolver'; import { ProjectResolver } from './graphql/project.resolver';
import { ReleaseResolver } from './graphql/release.resolver'; import { ReleaseResolver } from './graphql/release.resolver';
import { SubscriptionResolver } from './graphql/subscription.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({ @Module({
controllers: [
ProjectsController,
IssuesController,
CommentsController,
ReleasesController,
],
providers: [ providers: [
Refs,
ProjectResolver, ProjectResolver,
IssueResolver, IssueResolver,
CommentResolver, CommentResolver,

79
package-lock.json generated
View File

@@ -30,6 +30,7 @@
"@nestjs/graphql": "^13.4.5", "@nestjs/graphql": "^13.4.5",
"@nestjs/mongoose": "^11.0.4", "@nestjs/mongoose": "^11.0.4",
"@nestjs/platform-express": "^11.0.0", "@nestjs/platform-express": "^11.0.0",
"@nestjs/swagger": "^11.4.7",
"@nestjs/typeorm": "^11.0.3", "@nestjs/typeorm": "^11.0.3",
"@ngrx/operators": "^22.0.1", "@ngrx/operators": "^22.0.1",
"@ngrx/signals": "^22.0.1", "@ngrx/signals": "^22.0.1",
@@ -10670,6 +10671,12 @@
"node": ">=8" "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": { "node_modules/@modelcontextprotocol/sdk": {
"version": "1.30.0", "version": "1.30.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz",
@@ -11650,6 +11657,61 @@
"node": ">= 8" "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": { "node_modules/@nestjs/testing": {
"version": "11.2.7", "version": "11.2.7",
"resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.2.7.tgz", "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.2.7.tgz",
@@ -13792,6 +13854,13 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@schematics/angular": {
"version": "22.1.9", "version": "22.1.9",
"resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-22.1.9.tgz", "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-22.1.9.tgz",
@@ -15312,7 +15381,6 @@
}, },
"node_modules/argparse": { "node_modules/argparse": {
"version": "2.0.1", "version": "2.0.1",
"dev": true,
"license": "Python-2.0" "license": "Python-2.0"
}, },
"node_modules/aria-query": { "node_modules/aria-query": {
@@ -29064,6 +29132,15 @@
"url": "https://opencollective.com/svgo" "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": { "node_modules/swap-case": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/swap-case/-/swap-case-3.0.3.tgz", "resolved": "https://registry.npmjs.org/swap-case/-/swap-case-3.0.3.tgz",

View File

@@ -26,6 +26,7 @@
"@nestjs/graphql": "^13.4.5", "@nestjs/graphql": "^13.4.5",
"@nestjs/mongoose": "^11.0.4", "@nestjs/mongoose": "^11.0.4",
"@nestjs/platform-express": "^11.0.0", "@nestjs/platform-express": "^11.0.0",
"@nestjs/swagger": "^11.4.7",
"@nestjs/typeorm": "^11.0.3", "@nestjs/typeorm": "^11.0.3",
"@ngrx/operators": "^22.0.1", "@ngrx/operators": "^22.0.1",
"@ngrx/signals": "^22.0.1", "@ngrx/signals": "^22.0.1",