Compare commits
2 Commits
b2c9be72c3
...
7532938b4e
| Author | SHA1 | Date | |
|---|---|---|---|
| 7532938b4e | |||
| 341241a52f |
@@ -28,6 +28,10 @@ AUTH_SESSION_TTL_SECONDS=60
|
||||
|
||||
GRAPHQL_PLAYGROUND=true
|
||||
|
||||
# REST + OpenAPI for other apps (Swagger UI at /api/docs). The document is also written to OPENAPI_FILE.
|
||||
SWAGGER_ENABLED=true
|
||||
OPENAPI_FILE=apps/api/openapi.json
|
||||
|
||||
# Docker builds only: token of Pireon's Nexus for @pi/ui (build secret, never stored in an image).
|
||||
# Locally: export NEXUS_TOKEN=$(grep '^//nexus.pireon.dev/repository/npm-releases/:_authToken=' ~/.npmrc | cut -d= -f2-)
|
||||
NEXUS_TOKEN=
|
||||
|
||||
@@ -5,3 +5,7 @@
|
||||
/.nx/workspace-data
|
||||
package-lock.json
|
||||
.angular
|
||||
|
||||
# Generated by the API on startup (nx serve api): keep them byte-for-byte as written
|
||||
apps/api/openapi.json
|
||||
apps/api/schema.gql
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Alternativa a Jira para equipos de desarrollo: proyectos, tickets (bugs, features, cambios de UI, tareas y QA),
|
||||
releases, comentarios y relaciones entre tickets, **en tiempo real**.
|
||||
|
||||
- **API**: NestJS + GraphQL, arquitectura hexagonal, CQRS y event sourcing (escritura en PostgreSQL, lectura en MongoDB,
|
||||
- **API**: NestJS + GraphQL y REST con OpenAPI (Swagger, para otras apps), arquitectura hexagonal, CQRS y event sourcing (escritura en PostgreSQL, lectura en MongoDB,
|
||||
Redis entre ambas).
|
||||
- **Web**: Angular 22 + NgRx Signals (patrón de eventos) + `@pi/ui`, con la misma arquitectura.
|
||||
- **Usuarios**: los gestiona [Pireon](https://oauth.pireon.dev/api/documentation).
|
||||
@@ -21,7 +21,7 @@ Para entrar hace falta un cliente OAuth de Pireon en `.env` (`PIREON_CLIENT_ID`
|
||||
npm install # @pi/ui necesita el token del Nexus de Pireon en ~/.npmrc
|
||||
docker compose up -d postgres mongo redis
|
||||
cp .env.example .env
|
||||
npx nx serve api # http://localhost:3000/graphql
|
||||
npx nx serve api # http://localhost:3000/graphql · REST: /api/docs
|
||||
npx nx serve web # http://localhost:4200
|
||||
```
|
||||
|
||||
@@ -31,7 +31,7 @@ npx nx serve web # http://localhost:4200
|
||||
| ------------------------------------------- | -------------------------------------------------------------------- |
|
||||
| [Visión general](docs/01-vision-general.md) | Funcionalidades, stack y estructura del monorepo |
|
||||
| [Arquitectura](docs/02-arquitectura.md) | Capas, CQRS, event sourcing, flujo escritura → lectura y tiempo real |
|
||||
| [API](docs/03-api.md) | Dominio, workflow de tickets, eventos y operaciones GraphQL |
|
||||
| [API](docs/03-api.md) | Dominio, workflow de tickets, eventos, GraphQL y REST (OpenAPI) |
|
||||
| [Cliente web](docs/04-cliente-web.md) | Stores con eventos, GraphQL y `@pi/ui` |
|
||||
| [Autenticación](docs/05-autenticacion.md) | Tokens de Pireon, login en el navegador y webhooks |
|
||||
| [Desarrollo](docs/06-desarrollo.md) | Comandos, tests, cómo añadir una funcionalidad y problemas conocidos |
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import {
|
||||
IdentityProvider,
|
||||
InvalidAccessTokenError,
|
||||
} from '@task-stream/api/identity/application';
|
||||
import { User, UserFactory } from '@task-stream/api/identity/domain';
|
||||
import { DomainErrorFilter } from '@task-stream/api/shared/presenters';
|
||||
import { Client, createClient } from 'graphql-ws';
|
||||
import WebSocket from 'ws';
|
||||
|
||||
@@ -16,9 +15,11 @@ process.env['MONGO_URL'] ??= 'mongodb://localhost:27017/taskstream_e2e';
|
||||
process.env['REDIS_URL'] ??= 'redis://localhost:6379';
|
||||
process.env['EVENT_STREAM_KEY'] = `task-stream:e2e:${Date.now()}`;
|
||||
process.env['GRAPHQL_SCHEMA_FILE'] = '';
|
||||
process.env['OPENAPI_FILE'] = '';
|
||||
process.env['AUTH_SESSION_TTL_SECONDS'] = '0';
|
||||
|
||||
const { AppModule } = require('../src/app/app.module');
|
||||
const { configureApp } = require('../src/app/configure');
|
||||
|
||||
/** Pireon is replaced by fixed tokens: the rest of the stack is real. */
|
||||
const users = new UserFactory();
|
||||
@@ -71,6 +72,25 @@ describe('task-stream (e2e)', () => {
|
||||
return body.data;
|
||||
}
|
||||
|
||||
/** REST call under /api; returns status and parsed body. */
|
||||
async function rest<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. */
|
||||
async function eventually<T>(
|
||||
read: () => Promise<T>,
|
||||
@@ -123,10 +143,7 @@ describe('task-stream (e2e)', () => {
|
||||
.useValue(fakePireon)
|
||||
.compile();
|
||||
app = moduleRef.createNestApplication({ rawBody: true });
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({ transform: true, whitelist: true }),
|
||||
);
|
||||
app.useGlobalFilters(new DomainErrorFilter());
|
||||
configureApp(app);
|
||||
await app.listen(0);
|
||||
url = await app.getUrl();
|
||||
url = url.replace('[::1]', 'localhost');
|
||||
@@ -405,4 +422,139 @@ describe('task-stream (e2e)', () => {
|
||||
expect(anonymous.received).toHaveLength(0);
|
||||
expect(anonymous.errors).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('exposes the same use cases over REST, documented with OpenAPI', async () => {
|
||||
// --- The contract other apps generate clients from ---
|
||||
const docs = await rest(null, 'GET', '/docs-json');
|
||||
expect(docs.status).toBe(200);
|
||||
expect(Object.keys(docs.body['paths'])).toEqual(
|
||||
expect.arrayContaining([
|
||||
'/api/projects',
|
||||
'/api/projects/{project}/issues',
|
||||
'/api/issues/{issue}/status',
|
||||
'/api/releases/{releaseId}/publish',
|
||||
'/api/users/me',
|
||||
]),
|
||||
);
|
||||
expect(Object.keys(docs.body['components']['securitySchemes'])).toEqual(
|
||||
expect.arrayContaining(['pireon-oauth', 'pireon']),
|
||||
);
|
||||
|
||||
// --- Every consumer goes through Pireon's OAuth ---
|
||||
expect((await rest(null, 'GET', '/projects')).status).toBe(401);
|
||||
expect((await rest('not-a-token', 'GET', '/projects')).status).toBe(401);
|
||||
expect((await rest('token-ana', 'GET', '/users/me')).body).toMatchObject({
|
||||
id: '101',
|
||||
name: 'Ana (lead)',
|
||||
});
|
||||
|
||||
// --- Project and members, addressed by key ---
|
||||
const key = 'R' + Date.now().toString(36).slice(-5).toUpperCase();
|
||||
const created = await rest('token-ana', 'POST', '/projects', {
|
||||
key,
|
||||
name: 'REST project',
|
||||
});
|
||||
expect(created.status).toBe(201);
|
||||
await eventually(
|
||||
() => rest('token-ana', 'GET', `/projects/${key}`),
|
||||
(r) => r.status === 200,
|
||||
);
|
||||
expect(
|
||||
(
|
||||
await rest('token-ana', 'POST', `/projects/${key}/members`, {
|
||||
userId: '103',
|
||||
role: 'MEMBER',
|
||||
})
|
||||
).status,
|
||||
).toBe(201);
|
||||
|
||||
// --- Tickets: create, workflow, relations, comments ---
|
||||
const invalid = await rest('token-ana', 'POST', `/projects/${key}/issues`, {
|
||||
type: 'NOPE',
|
||||
title: '',
|
||||
});
|
||||
expect(invalid.status).toBe(400);
|
||||
|
||||
const be = await rest('token-ana', 'POST', `/projects/${key}/issues`, {
|
||||
type: 'FEATURE',
|
||||
title: 'Endpoint de cupones',
|
||||
area: 'BACKEND',
|
||||
});
|
||||
const qa = await rest('token-qa', 'POST', `/projects/${key}/issues`, {
|
||||
type: 'QA',
|
||||
title: 'Probar cupones',
|
||||
});
|
||||
expect(be.body).toMatchObject({ key: `${key}-1`, version: 1 });
|
||||
const beKey = be.body['key'] as string;
|
||||
const qaKey = qa.body['key'] as string;
|
||||
await eventually(
|
||||
() => rest('token-ana', 'GET', `/issues/${beKey}`),
|
||||
(r) => r.status === 200,
|
||||
);
|
||||
|
||||
const skip = await rest('token-ana', 'PUT', `/issues/${beKey}/status`, {
|
||||
status: 'DONE',
|
||||
});
|
||||
expect(skip.status).toBe(422);
|
||||
expect(skip.body).toMatchObject({ code: 'BUSINESS_RULE' });
|
||||
|
||||
expect(
|
||||
(
|
||||
await rest('token-ana', 'PUT', `/issues/${beKey}/status`, {
|
||||
status: 'TODO',
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
expect(
|
||||
(
|
||||
await rest('token-ana', 'PUT', `/issues/${beKey}/assignee`, {
|
||||
assigneeId: '103',
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
expect(
|
||||
(
|
||||
await rest('token-qa', 'POST', `/issues/${qaKey}/links`, {
|
||||
type: 'TESTS',
|
||||
targetIssue: beKey,
|
||||
})
|
||||
).status,
|
||||
).toBe(201);
|
||||
expect(
|
||||
(
|
||||
await rest('token-qa', 'POST', `/issues/${beKey}/comments`, {
|
||||
body: 'Lo pruebo en cuanto esté en QA',
|
||||
})
|
||||
).status,
|
||||
).toBe(201);
|
||||
|
||||
const issue = await eventually(
|
||||
() => rest('token-ana', 'GET', `/issues/${beKey}`),
|
||||
(r) => r.body?.['status'] === 'TODO' && r.body?.['links']?.length === 1,
|
||||
);
|
||||
expect(issue.body).toMatchObject({
|
||||
key: beKey,
|
||||
assignee: { id: '103' },
|
||||
links: [{ type: 'TESTS', direction: 'INWARD', issueKey: qaKey }],
|
||||
allowedTransitions: expect.arrayContaining(['IN_PROGRESS']),
|
||||
});
|
||||
|
||||
const search = await rest(
|
||||
'token-ana',
|
||||
'GET',
|
||||
`/projects/${key}/issues?status=TODO,BACKLOG&type=FEATURE`,
|
||||
);
|
||||
expect(search.body).toMatchObject({ total: 1, items: [{ key: beKey }] });
|
||||
|
||||
const comments = await eventually(
|
||||
() => rest('token-ana', 'GET', `/issues/${beKey}/comments`),
|
||||
(r) => r.body?.['total'] === 1,
|
||||
);
|
||||
expect(comments.body['items'][0]).toMatchObject({ author: { id: '103' } });
|
||||
|
||||
// --- Same authorisation as GraphQL ---
|
||||
expect((await rest('token-eve', 'GET', `/issues/${beKey}`)).status).toBe(
|
||||
404,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
5176
apps/api/openapi.json
Normal file
5176
apps/api/openapi.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@
|
||||
"@nestjs/graphql": "^13.4.5",
|
||||
"@nestjs/mongoose": "^11.0.4",
|
||||
"@nestjs/platform-express": "^11.0.0",
|
||||
"@nestjs/swagger": "^11.4.7",
|
||||
"@nestjs/typeorm": "^11.0.3",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
|
||||
@@ -33,4 +33,9 @@ export const configSchema = Joi.object({
|
||||
|
||||
GRAPHQL_PLAYGROUND: Joi.boolean().default(true),
|
||||
GRAPHQL_SCHEMA_FILE: Joi.string().allow('').default('apps/api/schema.gql'),
|
||||
|
||||
// REST + OpenAPI for other apps: Swagger UI at /api/docs, JSON at /api/docs-json.
|
||||
SWAGGER_ENABLED: Joi.boolean().default(true),
|
||||
// Where to write the OpenAPI document on startup ('' = don't), like the GraphQL SDL.
|
||||
OPENAPI_FILE: Joi.string().allow('').default('apps/api/openapi.json'),
|
||||
});
|
||||
|
||||
13
apps/api/src/app/configure.ts
Normal file
13
apps/api/src/app/configure.ts
Normal 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);
|
||||
}
|
||||
@@ -5,12 +5,19 @@ import {
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectConnection } from '@nestjs/mongoose';
|
||||
import {
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiServiceUnavailableResponse,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { Public } from '@task-stream/api/identity/presenters';
|
||||
import { REDIS_CLIENT } from '@task-stream/api/shared/infrastructure';
|
||||
import Redis from 'ioredis';
|
||||
import { Connection } from 'mongoose';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
@ApiTags('Health')
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
constructor(
|
||||
@@ -21,6 +28,13 @@ export class HealthController {
|
||||
|
||||
@Public()
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'API and databases status' })
|
||||
@ApiOkResponse({
|
||||
schema: {
|
||||
example: { status: 'ok', postgres: 'up', mongo: 'up', redis: 'up' },
|
||||
},
|
||||
})
|
||||
@ApiServiceUnavailableResponse({ description: 'A database is down' })
|
||||
async check() {
|
||||
const checks = await Promise.allSettled([
|
||||
this.postgres.query('SELECT 1'),
|
||||
|
||||
158
apps/api/src/app/openapi.ts
Normal file
158
apps/api/src/app/openapi.ts
Normal 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; }
|
||||
`,
|
||||
});
|
||||
}
|
||||
@@ -1,22 +1,19 @@
|
||||
import { Logger, ValidationPipe } from '@nestjs/common';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { DomainErrorFilter } from '@task-stream/api/shared/presenters';
|
||||
import { AppModule } from './app/app.module';
|
||||
import { configureApp } from './app/configure';
|
||||
|
||||
async function bootstrap() {
|
||||
// rawBody: needed to verify Pireon webhook signatures.
|
||||
const app = await NestFactory.create(AppModule, { rawBody: true });
|
||||
app.setGlobalPrefix('api');
|
||||
app.enableCors();
|
||||
app.enableShutdownHooks();
|
||||
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
|
||||
app.useGlobalFilters(new DomainErrorFilter());
|
||||
configureApp(app);
|
||||
|
||||
const port = process.env['PORT'] || 3000;
|
||||
await app.listen(port);
|
||||
Logger.log(
|
||||
`🚀 GraphQL: http://localhost:${port}/graphql (ws for subscriptions)`,
|
||||
);
|
||||
Logger.log(`📘 REST: http://localhost:${port}/api/docs (OpenAPI)`);
|
||||
Logger.log(`❤️ Health: http://localhost:${port}/api/health`);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
# 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 {
|
||||
default upgrade;
|
||||
'' close;
|
||||
@@ -9,13 +11,13 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://api:3000;
|
||||
proxy_pass http://task-stream-api:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
location /graphql {
|
||||
proxy_pass http://api:3000;
|
||||
proxy_pass http://task-stream-api:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
# 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).
|
||||
#
|
||||
# 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:
|
||||
# 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`)
|
||||
@@ -16,6 +20,7 @@ services:
|
||||
secrets: [nexus_token]
|
||||
image: task-stream-web
|
||||
expose: ['80']
|
||||
networks: [default]
|
||||
depends_on:
|
||||
api: { condition: service_healthy }
|
||||
restart: unless-stopped
|
||||
@@ -27,12 +32,15 @@ services:
|
||||
secrets: [nexus_token]
|
||||
image: task-stream-api
|
||||
expose: ['3000']
|
||||
networks:
|
||||
default:
|
||||
aliases: [task-stream-api]
|
||||
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.
|
||||
POSTGRES_SYNCHRONIZE: 'true'
|
||||
MONGO_URL: mongodb://mongo:27017/taskstream_read
|
||||
REDIS_URL: redis://redis:6379
|
||||
MONGO_URL: mongodb://task-stream-mongo:27017/taskstream_read
|
||||
REDIS_URL: redis://task-stream-redis:6379
|
||||
EVENT_STREAM_KEY: task-stream:events
|
||||
EVENTS_BRIDGE_ENABLED: 'true'
|
||||
PIREON_BASE_URL: https://oauth.pireon.dev
|
||||
@@ -40,8 +48,10 @@ services:
|
||||
PIREON_CLIENT_ID: ${PIREON_CLIENT_ID:-}
|
||||
PIREON_CLIENT_SECRET: ${PIREON_CLIENT_SECRET:-}
|
||||
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: ''
|
||||
OPENAPI_FILE: ''
|
||||
depends_on:
|
||||
postgres: { condition: service_healthy }
|
||||
mongo: { condition: service_healthy }
|
||||
@@ -50,6 +60,9 @@ services:
|
||||
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
networks:
|
||||
default:
|
||||
aliases: [task-stream-postgres]
|
||||
environment:
|
||||
POSTGRES_USER: taskstream
|
||||
POSTGRES_PASSWORD: taskstream
|
||||
@@ -62,6 +75,9 @@ services:
|
||||
|
||||
mongo:
|
||||
image: mongo:8
|
||||
networks:
|
||||
default:
|
||||
aliases: [task-stream-mongo]
|
||||
volumes: [mongo-data:/data/db]
|
||||
healthcheck:
|
||||
test: ['CMD', 'mongosh', '--quiet', '--eval', 'db.adminCommand("ping")']
|
||||
@@ -70,6 +86,9 @@ services:
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
networks:
|
||||
default:
|
||||
aliases: [task-stream-redis]
|
||||
command: ['redis-server', '--appendonly', 'yes']
|
||||
volumes: [redis-data:/data]
|
||||
healthcheck:
|
||||
|
||||
@@ -265,13 +265,60 @@ Los errores de dominio llegan como errores GraphQL con `extensions.code`:
|
||||
| `BUSINESS_RULE` | Regla de negocio (transición no permitida, release bloqueada…) |
|
||||
| `CONFLICT` | Otro cambio llegó antes (concurrencia optimista): reintenta |
|
||||
|
||||
## 3.3 REST
|
||||
## 3.3 REST con OpenAPI (para otras apps)
|
||||
|
||||
| Endpoint | Uso |
|
||||
| --------------------------- | ----------------------------------------------------------------------------------------------- |
|
||||
| `GET /api/health` | Estado de PostgreSQL, MongoDB y Redis (público) |
|
||||
| `GET /api/auth/config` | Configuración para iniciar sesión con Pireon desde el navegador (público) |
|
||||
| `POST /api/auth/token` | Proxy de `/oauth/token` de Pireon: `authorization_code`, `password` y `refresh_token` (público) |
|
||||
| `POST /api/webhooks/pireon` | Webhooks de Pireon firmados con HMAC (`user.deactivated`, `token.revoked`) |
|
||||
Los mismos casos de uso que GraphQL, como API REST documentada con OpenAPI 3.0, para que otras apps de la plataforma
|
||||
la consuman o generen un cliente. Son otro adaptador de entrada (`presenters/http`) que llama a los mismos
|
||||
`*.service.ts`: misma validación, permisos y errores.
|
||||
|
||||
Ver [Autenticación](05-autenticacion.md).
|
||||
| | |
|
||||
| ----------------- | ----------------------------------------------------------------------------------- |
|
||||
| Swagger UI | `/api/docs` (con «Authorize» contra Pireon) |
|
||||
| Documento OpenAPI | `/api/docs-json` (y `/api/docs-yaml`) |
|
||||
| Fichero exportado | `apps/api/openapi.json`: lo reescribe `nx serve api` al arrancar, como `schema.gql` |
|
||||
|
||||
### Autenticación: siempre a través de Pireon
|
||||
|
||||
Cualquier app o usuario que la consuma pasa por el OAuth de Pireon: todas las rutas, salvo `health`, `auth` y el
|
||||
webhook, exigen `Authorization: Bearer <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).
|
||||
|
||||
@@ -55,6 +55,8 @@ está configurado. El email y la contraseña son los de la cuenta de Pireon; Tas
|
||||
- `http://localhost:4200/auth/callback` (desarrollo)
|
||||
- `http://localhost:8080/auth/callback` (Docker)
|
||||
- la de producción
|
||||
- para el botón «Authorize» de Swagger UI: `http://localhost:3000/api/docs/oauth2-redirect.html` y la de
|
||||
producción (ver [API REST](03-api.md#33-rest-con-openapi-para-otras-apps))
|
||||
3. Pon `PIREON_CLIENT_ID` y `PIREON_CLIENT_SECRET` en `.env` (o en el entorno del contenedor).
|
||||
|
||||
`GET /api/auth/config` devuelve a la web lo necesario para iniciar el flujo (`authorizeUrl`, `clientId`, `scopes`,
|
||||
|
||||
@@ -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`.
|
||||
|
||||
### 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.
|
||||
|
||||
### 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 |
|
||||
| `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) |
|
||||
| `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`).
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -7,8 +7,10 @@ Adaptadores de entrada de identidad.
|
||||
|
||||
## Contenido
|
||||
|
||||
- `auth/`: `AuthGuard` global, `@Public()`, `@CurrentUser()`, `@AccessToken()`, `@RequireAppRoles()` y `WsAuthenticator` (subscriptions).
|
||||
- `http/`: `AuthController` (`GET /api/auth/config`, `POST /api/auth/token`) y `PireonWebhookController` (firma HMAC).
|
||||
- `auth/`: `AuthGuard` global, `@Public()`, `@CurrentUser()`, `@AccessToken()`, `@RequireAppRoles()`, `WsAuthenticator`
|
||||
(subscriptions) y `@ApiPireonAuth()` (esquemas OAuth2 / Bearer de Pireon en OpenAPI).
|
||||
- `http/`: `AuthController` (`GET /api/auth/config`, `POST /api/auth/token`), `UsersController` (`/api/users/me`,
|
||||
directorio de Pireon) y `PireonWebhookController` (firma HMAC). Documentados con `@nestjs/swagger`.
|
||||
- `graphql/`: queries `me`, `user` y `users`.
|
||||
|
||||
## Comandos
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
ExecutionContext,
|
||||
SetMetadata,
|
||||
applyDecorators,
|
||||
createParamDecorator,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOAuth2 } from '@nestjs/swagger';
|
||||
import { getAuthRequest } from './auth-request';
|
||||
|
||||
export const IS_PUBLIC = 'identity:isPublic';
|
||||
@@ -25,3 +27,15 @@ export const AccessToken = createParamDecorator(
|
||||
(_: unknown, context: ExecutionContext) =>
|
||||
getAuthRequest(context).accessToken,
|
||||
);
|
||||
|
||||
/** OpenAPI security schemes (declared in apps/api/src/app/openapi.ts). */
|
||||
export const PIREON_OAUTH = 'pireon-oauth';
|
||||
export const PIREON_BEARER = 'pireon';
|
||||
|
||||
/**
|
||||
* Documents that the endpoints need a Pireon access token: obtained through
|
||||
* Pireon's OAuth (authorization code + PKCE, or password) or sent as a bearer
|
||||
* token by an app that already has one. Enforced by the global AuthGuard.
|
||||
*/
|
||||
export const ApiPireonAuth = () =>
|
||||
applyDecorators(ApiOAuth2([], PIREON_OAUTH), ApiBearerAuth(PIREON_BEARER));
|
||||
|
||||
@@ -6,6 +6,15 @@ import {
|
||||
Post,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBody,
|
||||
ApiConsumes,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiProperty,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import {
|
||||
IdentityService,
|
||||
InvalidCredentialsError,
|
||||
@@ -18,10 +27,29 @@ import {
|
||||
import { Public } from '../auth/auth.decorators';
|
||||
import { IssueTokenDto } from './dto/issue-token.dto';
|
||||
|
||||
export class AuthorizationConfigDto {
|
||||
@ApiProperty({ example: 'https://oauth.pireon.dev/oauth/authorize' })
|
||||
authorizeUrl!: string;
|
||||
@ApiProperty({ description: 'Public id of the task-stream OAuth client' })
|
||||
clientId!: string;
|
||||
@ApiProperty({ type: [String], example: ['users:read'] })
|
||||
scopes!: string[];
|
||||
@ApiProperty({ description: 'Whether email + password sign-in is allowed' })
|
||||
passwordGrant!: boolean;
|
||||
}
|
||||
|
||||
export class TokenResponseDto {
|
||||
@ApiProperty() access_token!: string;
|
||||
@ApiProperty({ type: String, nullable: true }) refresh_token!: string | null;
|
||||
@ApiProperty({ description: 'Seconds' }) expires_in!: number;
|
||||
@ApiProperty({ example: 'Bearer' }) token_type!: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign-in for browsers. Pireon's /oauth/token has no CORS and the client
|
||||
* secret must stay server-side, so the web app goes through here.
|
||||
*/
|
||||
@ApiTags('Auth')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly identity: IdentityService) {}
|
||||
@@ -29,6 +57,10 @@ export class AuthController {
|
||||
/** What the web app needs to start Authorization Code + PKCE (null = not configured). */
|
||||
@Public()
|
||||
@Get('config')
|
||||
@ApiOperation({
|
||||
summary: 'How to sign in with Pireon (null when not configured)',
|
||||
})
|
||||
@ApiOkResponse({ type: AuthorizationConfigDto })
|
||||
config(): Promise<AuthorizationConfig | null> {
|
||||
return this.identity.authorizationConfig();
|
||||
}
|
||||
@@ -36,6 +68,18 @@ export class AuthController {
|
||||
@Public()
|
||||
@Post('token')
|
||||
@HttpCode(200)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Exchange a grant for Pireon tokens (proxy of Pireon /oauth/token)',
|
||||
description:
|
||||
'For browsers (Pireon has no CORS) and Swagger UI: adds the client secret ' +
|
||||
'server-side. Apps with their own Pireon OAuth client can get tokens from ' +
|
||||
'Pireon directly; any valid Pireon access token is accepted by this API.',
|
||||
})
|
||||
@ApiConsumes('application/json', 'application/x-www-form-urlencoded')
|
||||
@ApiBody({ type: IssueTokenDto })
|
||||
@ApiOkResponse({ type: TokenResponseDto })
|
||||
@ApiUnauthorizedResponse({ description: 'Pireon rejected the grant' })
|
||||
async token(@Body() dto: IssueTokenDto) {
|
||||
try {
|
||||
return toResponse(await this.identity.issueToken(toGrant(dto)));
|
||||
|
||||
@@ -1,35 +1,48 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsIn, IsNotEmpty, IsString, IsUrl, ValidateIf } from 'class-validator';
|
||||
|
||||
/** OAuth2 token request (RFC 6749), as JSON or application/x-www-form-urlencoded. */
|
||||
export class IssueTokenDto {
|
||||
@ApiProperty({ enum: ['authorization_code', 'password', 'refresh_token'] })
|
||||
@IsIn(['authorization_code', 'password', 'refresh_token'])
|
||||
grant_type!: 'authorization_code' | 'password' | 'refresh_token';
|
||||
|
||||
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'authorization_code')
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@ApiPropertyOptional({
|
||||
description: 'authorization_code: code returned by Pireon',
|
||||
})
|
||||
code?: string;
|
||||
|
||||
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'authorization_code')
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@ApiPropertyOptional({ description: 'authorization_code: PKCE verifier' })
|
||||
code_verifier?: string;
|
||||
|
||||
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'authorization_code')
|
||||
@IsUrl({ require_tld: false })
|
||||
@ApiPropertyOptional({
|
||||
description: 'authorization_code: redirect URI used to authorize',
|
||||
})
|
||||
redirect_uri?: string;
|
||||
|
||||
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'password')
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@ApiPropertyOptional({ description: 'password: Pireon email' })
|
||||
username?: string;
|
||||
|
||||
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'password')
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@ApiPropertyOptional({ description: 'password: Pireon password' })
|
||||
password?: string;
|
||||
|
||||
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'refresh_token')
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@ApiPropertyOptional({ description: 'refresh_token' })
|
||||
refresh_token?: string;
|
||||
}
|
||||
|
||||
@@ -9,12 +9,20 @@ import {
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
ApiHeader,
|
||||
ApiNoContentResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { IdentityService } from '@task-stream/api/identity/application';
|
||||
import { Request } from 'express';
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
import { Public } from '../auth/auth.decorators';
|
||||
|
||||
/** Receives `user.deactivated` / `token.revoked` from Pireon. */
|
||||
@ApiTags('Webhooks')
|
||||
@Controller('webhooks/pireon')
|
||||
export class PireonWebhookController {
|
||||
constructor(
|
||||
@@ -25,6 +33,19 @@ export class PireonWebhookController {
|
||||
@Public()
|
||||
@Post()
|
||||
@HttpCode(204)
|
||||
@ApiOperation({
|
||||
summary: 'Pireon events (called by Pireon, not by apps)',
|
||||
description:
|
||||
"`user.deactivated` closes the user's sessions; `token.revoked` forgets the token. " +
|
||||
'Disabled unless PIREON_WEBHOOK_SECRET is set.',
|
||||
})
|
||||
@ApiHeader({
|
||||
name: 'X-Webhook-Signature',
|
||||
description: 'HMAC-SHA256 of the raw body with PIREON_WEBHOOK_SECRET (hex)',
|
||||
required: true,
|
||||
})
|
||||
@ApiNoContentResponse({ description: 'Processed' })
|
||||
@ApiUnauthorizedResponse({ description: 'Missing or invalid signature' })
|
||||
async receive(
|
||||
@Req() req: RawBodyRequest<Request>,
|
||||
@Headers('x-webhook-signature') signature?: string,
|
||||
|
||||
101
libs/api/identity/presenters/src/lib/http/users.controller.ts
Normal file
101
libs/api/identity/presenters/src/lib/http/users.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,11 @@ import { WsAuthenticator } from './auth/ws-authenticator';
|
||||
import { UserResolver } from './graphql/user.resolver';
|
||||
import { AuthController } from './http/auth.controller';
|
||||
import { PireonWebhookController } from './http/pireon-webhook.controller';
|
||||
import { UsersController } from './http/users.controller';
|
||||
|
||||
/** Driving adapters of the identity context: global auth guard, webhook, GraphQL. */
|
||||
/** Driving adapters of the identity context: global auth guard, REST (auth, users, webhook), GraphQL. */
|
||||
@Module({
|
||||
controllers: [AuthController, PireonWebhookController],
|
||||
controllers: [AuthController, UsersController, PireonWebhookController],
|
||||
providers: [
|
||||
{ provide: APP_GUARD, useClass: AuthGuard },
|
||||
WsAuthenticator,
|
||||
|
||||
@@ -8,6 +8,7 @@ Adaptadores de entrada comunes.
|
||||
## Contenido
|
||||
|
||||
- `DomainErrorFilter`: convierte los errores de dominio en errores GraphQL (`extensions.code`) o respuestas HTTP.
|
||||
- `ApiErrorDto`, `@ApiReadErrors()` y `@ApiCommandErrors()`: el formato de error de la API REST en OpenAPI.
|
||||
|
||||
## Comandos
|
||||
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from './lib/filters/domain-error.filter';
|
||||
export * from './lib/http/api-errors';
|
||||
|
||||
59
libs/api/shared/presenters/src/lib/http/api-errors.ts
Normal file
59
libs/api/shared/presenters/src/lib/http/api-errors.ts
Normal 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)',
|
||||
}),
|
||||
);
|
||||
@@ -1,6 +1,6 @@
|
||||
# api-tracker-presenters
|
||||
|
||||
API GraphQL del tracker.
|
||||
API GraphQL y REST (OpenAPI) del tracker.
|
||||
|
||||
- **Import**: `@task-stream/api/tracker/presenters`
|
||||
- **Capa**: `presenters` · **Tags**: `scope:api-tracker`, `type:presenters`
|
||||
@@ -9,8 +9,11 @@ API GraphQL del tracker.
|
||||
|
||||
- Resolvers `ProjectResolver`, `IssueResolver`, `CommentResolver`, `ReleaseResolver` y `SubscriptionResolver`; solo llaman a services.
|
||||
- Tipos, inputs (con `class-validator`) y enums de GraphQL (code-first).
|
||||
- `http/`: controladores REST `ProjectsController`, `IssuesController`, `CommentsController`, `ReleasesController`
|
||||
(mismos services), DTOs con `@nestjs/swagger` y `Refs` (proyectos y tickets por id o clave).
|
||||
|
||||
Operaciones disponibles: [API](../../../../docs/03-api.md#32-graphql).
|
||||
Operaciones disponibles: [GraphQL](../../../../docs/03-api.md#32-graphql) y
|
||||
[REST](../../../../docs/03-api.md#33-rest-con-openapi-para-otras-apps).
|
||||
|
||||
## Comandos
|
||||
|
||||
|
||||
10
libs/api/tracker/presenters/src/lib/common/issue-input.ts
Normal file
10
libs/api/tracker/presenters/src/lib/common/issue-input.ts
Normal 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;
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
IssuesService,
|
||||
} from '@task-stream/api/tracker/application';
|
||||
import {
|
||||
IssueDetails,
|
||||
IssueReadModel,
|
||||
IssueStatus,
|
||||
IssueWorkflow,
|
||||
@@ -27,6 +26,7 @@ import {
|
||||
LinkIssuesInput,
|
||||
UpdateIssueInput,
|
||||
} from './inputs';
|
||||
import { withoutNulls } from '../common/issue-input';
|
||||
import {
|
||||
ActivityGql,
|
||||
CommandResultGql,
|
||||
@@ -35,15 +35,6 @@ import {
|
||||
IssuePageGql,
|
||||
} from './types';
|
||||
|
||||
/** GraphQL sends explicit nulls; the domain only accepts them on nullable fields. */
|
||||
function withoutNulls<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)
|
||||
export class IssueResolver {
|
||||
constructor(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
434
libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts
Normal file
434
libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts
Normal 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;
|
||||
}
|
||||
215
libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts
Normal file
215
libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts
Normal 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;
|
||||
}
|
||||
304
libs/api/tracker/presenters/src/lib/http/issues.controller.ts
Normal file
304
libs/api/tracker/presenters/src/lib/http/issues.controller.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
221
libs/api/tracker/presenters/src/lib/http/projects.controller.ts
Normal file
221
libs/api/tracker/presenters/src/lib/http/projects.controller.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
39
libs/api/tracker/presenters/src/lib/http/refs.ts
Normal file
39
libs/api/tracker/presenters/src/lib/http/refs.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
164
libs/api/tracker/presenters/src/lib/http/releases.controller.ts
Normal file
164
libs/api/tracker/presenters/src/lib/http/releases.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,26 @@ import { IssueResolver } from './graphql/issue.resolver';
|
||||
import { ProjectResolver } from './graphql/project.resolver';
|
||||
import { ReleaseResolver } from './graphql/release.resolver';
|
||||
import { SubscriptionResolver } from './graphql/subscription.resolver';
|
||||
import { CommentsController } from './http/comments.controller';
|
||||
import { IssuesController } from './http/issues.controller';
|
||||
import { ProjectsController } from './http/projects.controller';
|
||||
import { Refs } from './http/refs';
|
||||
import { ReleasesController } from './http/releases.controller';
|
||||
|
||||
/** Driving adapters of the tracker context (GraphQL queries, mutations, subscriptions). */
|
||||
/**
|
||||
* Driving adapters of the tracker context: GraphQL (queries, mutations,
|
||||
* subscriptions) and REST with OpenAPI for other apps. Both call the same
|
||||
* application services.
|
||||
*/
|
||||
@Module({
|
||||
controllers: [
|
||||
ProjectsController,
|
||||
IssuesController,
|
||||
CommentsController,
|
||||
ReleasesController,
|
||||
],
|
||||
providers: [
|
||||
Refs,
|
||||
ProjectResolver,
|
||||
IssueResolver,
|
||||
CommentResolver,
|
||||
|
||||
79
package-lock.json
generated
79
package-lock.json
generated
@@ -30,6 +30,7 @@
|
||||
"@nestjs/graphql": "^13.4.5",
|
||||
"@nestjs/mongoose": "^11.0.4",
|
||||
"@nestjs/platform-express": "^11.0.0",
|
||||
"@nestjs/swagger": "^11.4.7",
|
||||
"@nestjs/typeorm": "^11.0.3",
|
||||
"@ngrx/operators": "^22.0.1",
|
||||
"@ngrx/signals": "^22.0.1",
|
||||
@@ -10670,6 +10671,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@microsoft/tsdoc": {
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.16.0.tgz",
|
||||
"integrity": "sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk": {
|
||||
"version": "1.30.0",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz",
|
||||
@@ -11650,6 +11657,61 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/swagger": {
|
||||
"version": "11.4.7",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.4.7.tgz",
|
||||
"integrity": "sha512-QyDYnmfP4IRucgmtQxMqzgRBdWtjFoDp8eFvvgf92+3wdLCL+Q0xOFO1948j/ntW/Wi7qT2dyck6ka8ADzPWQQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@microsoft/tsdoc": "0.16.0",
|
||||
"@nestjs/mapped-types": "2.1.1",
|
||||
"js-yaml": "5.3.0",
|
||||
"lodash": "4.18.1",
|
||||
"path-to-regexp": "8.4.2",
|
||||
"swagger-ui-dist": "5.32.13"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@fastify/static": "^8.0.0 || ^9.0.0 || ^10.0.0",
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"class-transformer": "*",
|
||||
"class-validator": "*",
|
||||
"reflect-metadata": "^0.1.12 || ^0.2.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@fastify/static": {
|
||||
"optional": true
|
||||
},
|
||||
"class-transformer": {
|
||||
"optional": true
|
||||
},
|
||||
"class-validator": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/swagger/node_modules/js-yaml": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.3.0.tgz",
|
||||
"integrity": "sha512-muutsYr+e2+d3rTgUGslq5rxbBlUy3cJ61IsHag2QNDQV+7zXWjkUpmALIajhrlLlrgRUiymj6U3zUr/TMK84Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/puzrin"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodeca"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/testing": {
|
||||
"version": "11.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.2.7.tgz",
|
||||
@@ -13792,6 +13854,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@scarf/scarf": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz",
|
||||
"integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@schematics/angular": {
|
||||
"version": "22.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-22.1.9.tgz",
|
||||
@@ -15312,7 +15381,6 @@
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "2.0.1",
|
||||
"dev": true,
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/aria-query": {
|
||||
@@ -29064,6 +29132,15 @@
|
||||
"url": "https://opencollective.com/svgo"
|
||||
}
|
||||
},
|
||||
"node_modules/swagger-ui-dist": {
|
||||
"version": "5.32.13",
|
||||
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.13.tgz",
|
||||
"integrity": "sha512-qQobzb3DeC2LeK0j3E8812Ef4aIq1y9flJxvZkimkqUC/w4u7wS+yCc+VakqGJLweUUBrI24effhwo8OsAvNAw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@scarf/scarf": "=1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/swap-case": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/swap-case/-/swap-case-3.0.3.tgz",
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"@nestjs/graphql": "^13.4.5",
|
||||
"@nestjs/mongoose": "^11.0.4",
|
||||
"@nestjs/platform-express": "^11.0.0",
|
||||
"@nestjs/swagger": "^11.4.7",
|
||||
"@nestjs/typeorm": "^11.0.3",
|
||||
"@ngrx/operators": "^22.0.1",
|
||||
"@ngrx/signals": "^22.0.1",
|
||||
|
||||
Reference in New Issue
Block a user