Compare commits
13 Commits
master
...
80d9bee6e0
| Author | SHA1 | Date | |
|---|---|---|---|
| 80d9bee6e0 | |||
| 9236cb73c6 | |||
| 71dc2d6f2d | |||
| e656222b54 | |||
| 0e301ad545 | |||
| 240ea9a539 | |||
| 6c3550ae9d | |||
| b1002e6599 | |||
| cbaad17fc7 | |||
| 7532938b4e | |||
| 341241a52f | |||
| b2c9be72c3 | |||
| bf0294a188 |
24
.env.example
24
.env.example
@@ -18,12 +18,32 @@ EVENTS_BRIDGE_ENABLED=true
|
|||||||
PIREON_BASE_URL=https://oauth.pireon.dev
|
PIREON_BASE_URL=https://oauth.pireon.dev
|
||||||
# Secret returned when registering the webhook (POST /api/clients/{client}/webhooks)
|
# Secret returned when registering the webhook (POST /api/clients/{client}/webhooks)
|
||||||
PIREON_WEBHOOK_SECRET=
|
PIREON_WEBHOOK_SECRET=
|
||||||
# OAuth client registered for task-stream (POST /api/clients). Needed by the web app to sign in.
|
# OAuth clients of task-stream in Pireon (POST /api/clients). Each Pireon client allows ONE grant type:
|
||||||
# Redirect URI to register: http://localhost:4200/auth/callback
|
# authorization_code → "Sign in with Pireon" and Swagger UI. Redirect URIs to register:
|
||||||
|
# http://localhost:4200/auth/callback, http://localhost:3000/api/docs/oauth2-redirect.html
|
||||||
PIREON_CLIENT_ID=
|
PIREON_CLIENT_ID=
|
||||||
PIREON_CLIENT_SECRET=
|
PIREON_CLIENT_SECRET=
|
||||||
|
# password → email + password sign-in (defaults to the client above when empty)
|
||||||
|
PIREON_PASSWORD_CLIENT_ID=
|
||||||
|
PIREON_PASSWORD_CLIENT_SECRET=
|
||||||
PIREON_SCOPES=users:read
|
PIREON_SCOPES=users:read
|
||||||
PIREON_PASSWORD_GRANT=true
|
PIREON_PASSWORD_GRANT=true
|
||||||
AUTH_SESSION_TTL_SECONDS=60
|
AUTH_SESSION_TTL_SECONDS=60
|
||||||
|
|
||||||
GRAPHQL_PLAYGROUND=true
|
GRAPHQL_PLAYGROUND=true
|
||||||
|
|
||||||
|
# REST + OpenAPI for other apps (Swagger UI at /api/docs). The document is also written to OPENAPI_FILE.
|
||||||
|
SWAGGER_ENABLED=true
|
||||||
|
OPENAPI_FILE=apps/api/openapi.json
|
||||||
|
|
||||||
|
# Docker builds only: token of Pireon's Nexus for @pi/ui (build secret, never stored in an image).
|
||||||
|
# Locally: export NEXUS_TOKEN=$(grep '^//nexus.pireon.dev/repository/npm-releases/:_authToken=' ~/.npmrc | cut -d= -f2-)
|
||||||
|
NEXUS_TOKEN=
|
||||||
|
|
||||||
|
# MCP server (apps/mcp, docs/09-mcp.md). Port: not PORT, which is the API's.
|
||||||
|
MCP_PORT=3010
|
||||||
|
TASKSTREAM_API_URL=http://localhost:3000
|
||||||
|
# Domains it answers on, comma-separated (DNS rebinding protection). Empty: localhost only.
|
||||||
|
MCP_ALLOWED_HOSTS=
|
||||||
|
# error | warn | log | debug | verbose (warn: the library logs every tool on every call otherwise)
|
||||||
|
MCP_LOG_LEVEL=warn
|
||||||
|
|||||||
@@ -5,3 +5,10 @@
|
|||||||
/.nx/workspace-data
|
/.nx/workspace-data
|
||||||
package-lock.json
|
package-lock.json
|
||||||
.angular
|
.angular
|
||||||
|
|
||||||
|
# Generated by the API on startup (nx serve api): keep them byte-for-byte as written
|
||||||
|
apps/api/openapi.json
|
||||||
|
apps/api/schema.gql
|
||||||
|
|
||||||
|
# Generated from apps/api/openapi.json (nx run mcp:openapi-types)
|
||||||
|
apps/mcp/src/app/api/taskstream.schema.ts
|
||||||
|
|||||||
28
.vscode/launch.json
vendored
28
.vscode/launch.json
vendored
@@ -6,24 +6,36 @@
|
|||||||
"request": "launch",
|
"request": "launch",
|
||||||
"name": "Debug api with Nx",
|
"name": "Debug api with Nx",
|
||||||
"runtimeExecutable": "npx",
|
"runtimeExecutable": "npx",
|
||||||
"runtimeArgs": [
|
"runtimeArgs": ["nx", "serve", "api"],
|
||||||
"nx",
|
|
||||||
"serve",
|
|
||||||
"api"
|
|
||||||
],
|
|
||||||
"env": {
|
"env": {
|
||||||
"NODE_OPTIONS": "--inspect=9229"
|
"NODE_OPTIONS": "--inspect=9229"
|
||||||
},
|
},
|
||||||
"console": "integratedTerminal",
|
"console": "integratedTerminal",
|
||||||
"internalConsoleOptions": "neverOpen",
|
"internalConsoleOptions": "neverOpen",
|
||||||
"skipFiles": [
|
"skipFiles": ["<node_internals>/**"],
|
||||||
"<node_internals>/**"
|
|
||||||
],
|
|
||||||
"sourceMaps": true,
|
"sourceMaps": true,
|
||||||
"outFiles": [
|
"outFiles": [
|
||||||
"${workspaceFolder}/apps/api/dist/**/*.(m|c|)js",
|
"${workspaceFolder}/apps/api/dist/**/*.(m|c|)js",
|
||||||
"!**/node_modules/**"
|
"!**/node_modules/**"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "node",
|
||||||
|
"request": "launch",
|
||||||
|
"name": "Debug mcp with Nx",
|
||||||
|
"runtimeExecutable": "npx",
|
||||||
|
"runtimeArgs": ["nx", "serve", "mcp"],
|
||||||
|
"env": {
|
||||||
|
"NODE_OPTIONS": "--inspect=9230"
|
||||||
|
},
|
||||||
|
"console": "integratedTerminal",
|
||||||
|
"internalConsoleOptions": "neverOpen",
|
||||||
|
"skipFiles": ["<node_internals>/**"],
|
||||||
|
"sourceMaps": true,
|
||||||
|
"outFiles": [
|
||||||
|
"${workspaceFolder}/apps/mcp/dist/**/*.(m|c|)js",
|
||||||
|
"!**/node_modules/**"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
31
Dockerfile
31
Dockerfile
@@ -1,16 +1,21 @@
|
|||||||
# syntax=docker/dockerfile:1.7
|
# syntax=docker/dockerfile:1.7
|
||||||
# TaskStream images (one Dockerfile, two targets):
|
# TaskStream images (one Dockerfile, three targets):
|
||||||
# docker compose up -d --build → api + web + infrastructure
|
# docker compose up -d --build → api + web + infrastructure
|
||||||
|
# docker compose --profile mcp up -d --build → plus the MCP server
|
||||||
#
|
#
|
||||||
# @pi/ui comes from Pireon's private Nexus. Its token is passed as a build
|
# @pi/ui comes from Pireon's private Nexus. Its token is passed as a build
|
||||||
# secret (compose: `secrets.npmrc` → ~/.npmrc) and never lands in a layer.
|
# secret (compose: `secrets.nexus_token` ← $NEXUS_TOKEN) and never lands in a
|
||||||
|
# layer: it is written to a temporary npmrc that the same RUN deletes.
|
||||||
|
|
||||||
# ---- Workspace dependencies (shared by both builds) --------------------
|
# ---- Workspace dependencies (shared by both builds) --------------------
|
||||||
FROM node:22-alpine AS deps
|
FROM node:22-alpine AS deps
|
||||||
WORKDIR /workspace
|
WORKDIR /workspace
|
||||||
ENV NX_DAEMON=false NX_NO_CLOUD=true NX_SKIP_NX_CACHE=true
|
ENV NX_DAEMON=false NX_NO_CLOUD=true NX_SKIP_NX_CACHE=true
|
||||||
COPY package.json package-lock.json .npmrc ./
|
COPY package.json package-lock.json .npmrc ./
|
||||||
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci --no-audit --no-fund
|
RUN --mount=type=secret,id=nexus_token,required=true \
|
||||||
|
printf '//nexus.pireon.dev/repository/npm-releases/:_authToken=%s\n' "$(cat /run/secrets/nexus_token)" > /tmp/auth.npmrc \
|
||||||
|
&& npm ci --no-audit --no-fund --userconfig /tmp/auth.npmrc \
|
||||||
|
&& rm -f /tmp/auth.npmrc
|
||||||
|
|
||||||
# ---- API: Nest bundle + pruned package.json / lockfile -----------------
|
# ---- API: Nest bundle + pruned package.json / lockfile -----------------
|
||||||
FROM deps AS build-api
|
FROM deps AS build-api
|
||||||
@@ -31,6 +36,24 @@ HEALTHCHECK --interval=10s --timeout=3s --start-period=20s --retries=5 \
|
|||||||
CMD wget -qO- http://127.0.0.1:3000/api/health > /dev/null || exit 1
|
CMD wget -qO- http://127.0.0.1:3000/api/health > /dev/null || exit 1
|
||||||
CMD ["node", "main.js"]
|
CMD ["node", "main.js"]
|
||||||
|
|
||||||
|
# ---- MCP: server for AI clients, consumes the API over REST ------------
|
||||||
|
FROM deps AS build-mcp
|
||||||
|
COPY . .
|
||||||
|
RUN npx nx run mcp:prune
|
||||||
|
|
||||||
|
# @nestjs-mcp/server 2 targets Node 24.
|
||||||
|
FROM node:24-alpine AS mcp
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production MCP_PORT=3010
|
||||||
|
COPY --from=build-mcp /workspace/dist/apps/mcp/package.json /workspace/dist/apps/mcp/package-lock.json ./
|
||||||
|
RUN npm ci --omit=dev --ignore-scripts --no-audit --no-fund && npm cache clean --force
|
||||||
|
COPY --from=build-mcp /workspace/dist/apps/mcp/main.js ./
|
||||||
|
USER node
|
||||||
|
EXPOSE 3010
|
||||||
|
HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=5 \
|
||||||
|
CMD wget -qO- http://127.0.0.1:3010/health > /dev/null || exit 1
|
||||||
|
CMD ["node", "main.js"]
|
||||||
|
|
||||||
# ---- Web: Angular SPA behind nginx (proxies /api and /graphql) ---------
|
# ---- Web: Angular SPA behind nginx (proxies /api and /graphql) ---------
|
||||||
FROM deps AS build-web
|
FROM deps AS build-web
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
11
README.md
11
README.md
@@ -3,7 +3,7 @@
|
|||||||
Alternativa a Jira para equipos de desarrollo: proyectos, tickets (bugs, features, cambios de UI, tareas y QA),
|
Alternativa a Jira para equipos de desarrollo: proyectos, tickets (bugs, features, cambios de UI, tareas y QA),
|
||||||
releases, comentarios y relaciones entre tickets, **en tiempo real**.
|
releases, comentarios y relaciones entre tickets, **en tiempo real**.
|
||||||
|
|
||||||
- **API**: NestJS + GraphQL, arquitectura hexagonal, CQRS y event sourcing (escritura en PostgreSQL, lectura en MongoDB,
|
- **API**: NestJS + GraphQL y REST con OpenAPI (Swagger, para otras apps), arquitectura hexagonal, CQRS y event sourcing (escritura en PostgreSQL, lectura en MongoDB,
|
||||||
Redis entre ambas).
|
Redis entre ambas).
|
||||||
- **Web**: Angular 22 + NgRx Signals (patrón de eventos) + `@pi/ui`, con la misma arquitectura.
|
- **Web**: Angular 22 + NgRx Signals (patrón de eventos) + `@pi/ui`, con la misma arquitectura.
|
||||||
- **Usuarios**: los gestiona [Pireon](https://oauth.pireon.dev/api/documentation).
|
- **Usuarios**: los gestiona [Pireon](https://oauth.pireon.dev/api/documentation).
|
||||||
@@ -14,14 +14,15 @@ releases, comentarios y relaciones entre tickets, **en tiempo real**.
|
|||||||
docker compose up -d --build # web en http://localhost:8080
|
docker compose up -d --build # web en http://localhost:8080
|
||||||
```
|
```
|
||||||
|
|
||||||
Para entrar hace falta un cliente OAuth de Pireon en `.env` (`PIREON_CLIENT_ID` y `PIREON_CLIENT_SECRET`, ver
|
Para entrar hacen falta los clientes OAuth de Pireon en `.env`: uno `authorization_code` (`PIREON_CLIENT_ID` /
|
||||||
[Autenticación](docs/05-autenticacion.md#registrar-taskstream-como-cliente-en-pireon)). Para desarrollar:
|
`_SECRET`, «Entrar con Pireon») y uno `password` (`PIREON_PASSWORD_CLIENT_ID` / `_SECRET`, email y contraseña). Ver
|
||||||
|
[Autenticación](docs/05-autenticacion.md#registrar-taskstream-como-cliente-en-pireon). Para desarrollar:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm install # @pi/ui necesita el token del Nexus de Pireon en ~/.npmrc
|
npm install # @pi/ui necesita el token del Nexus de Pireon en ~/.npmrc
|
||||||
docker compose up -d postgres mongo redis
|
docker compose up -d postgres mongo redis
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
npx nx serve api # http://localhost:3000/graphql
|
npx nx serve api # http://localhost:3000/graphql · REST: /api/docs
|
||||||
npx nx serve web # http://localhost:4200
|
npx nx serve web # http://localhost:4200
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -31,7 +32,7 @@ npx nx serve web # http://localhost:4200
|
|||||||
| ------------------------------------------- | -------------------------------------------------------------------- |
|
| ------------------------------------------- | -------------------------------------------------------------------- |
|
||||||
| [Visión general](docs/01-vision-general.md) | Funcionalidades, stack y estructura del monorepo |
|
| [Visión general](docs/01-vision-general.md) | Funcionalidades, stack y estructura del monorepo |
|
||||||
| [Arquitectura](docs/02-arquitectura.md) | Capas, CQRS, event sourcing, flujo escritura → lectura y tiempo real |
|
| [Arquitectura](docs/02-arquitectura.md) | Capas, CQRS, event sourcing, flujo escritura → lectura y tiempo real |
|
||||||
| [API](docs/03-api.md) | Dominio, workflow de tickets, eventos y operaciones GraphQL |
|
| [API](docs/03-api.md) | Dominio, workflow de tickets, eventos, GraphQL y REST (OpenAPI) |
|
||||||
| [Cliente web](docs/04-cliente-web.md) | Stores con eventos, GraphQL y `@pi/ui` |
|
| [Cliente web](docs/04-cliente-web.md) | Stores con eventos, GraphQL y `@pi/ui` |
|
||||||
| [Autenticación](docs/05-autenticacion.md) | Tokens de Pireon, login en el navegador y webhooks |
|
| [Autenticación](docs/05-autenticacion.md) | Tokens de Pireon, login en el navegador y webhooks |
|
||||||
| [Desarrollo](docs/06-desarrollo.md) | Comandos, tests, cómo añadir una funcionalidad y problemas conocidos |
|
| [Desarrollo](docs/06-desarrollo.md) | Comandos, tests, cómo añadir una funcionalidad y problemas conocidos |
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
import { INestApplication } from '@nestjs/common';
|
||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
import {
|
import {
|
||||||
IdentityProvider,
|
IdentityProvider,
|
||||||
InvalidAccessTokenError,
|
InvalidAccessTokenError,
|
||||||
} from '@task-stream/api/identity/application';
|
} from '@task-stream/api/identity/application';
|
||||||
import { User, UserFactory } from '@task-stream/api/identity/domain';
|
import { User, UserFactory } from '@task-stream/api/identity/domain';
|
||||||
import { DomainErrorFilter } from '@task-stream/api/shared/presenters';
|
|
||||||
import { Client, createClient } from 'graphql-ws';
|
import { Client, createClient } from 'graphql-ws';
|
||||||
|
import { createHmac } from 'node:crypto';
|
||||||
import WebSocket from 'ws';
|
import WebSocket from 'ws';
|
||||||
|
|
||||||
process.env['POSTGRES_URL'] ??=
|
process.env['POSTGRES_URL'] ??=
|
||||||
@@ -16,9 +16,12 @@ process.env['MONGO_URL'] ??= 'mongodb://localhost:27017/taskstream_e2e';
|
|||||||
process.env['REDIS_URL'] ??= 'redis://localhost:6379';
|
process.env['REDIS_URL'] ??= 'redis://localhost:6379';
|
||||||
process.env['EVENT_STREAM_KEY'] = `task-stream:e2e:${Date.now()}`;
|
process.env['EVENT_STREAM_KEY'] = `task-stream:e2e:${Date.now()}`;
|
||||||
process.env['GRAPHQL_SCHEMA_FILE'] = '';
|
process.env['GRAPHQL_SCHEMA_FILE'] = '';
|
||||||
|
process.env['OPENAPI_FILE'] = '';
|
||||||
process.env['AUTH_SESSION_TTL_SECONDS'] = '0';
|
process.env['AUTH_SESSION_TTL_SECONDS'] = '0';
|
||||||
|
process.env['PIREON_WEBHOOK_SECRET'] = 'e2e-webhook-secret';
|
||||||
|
|
||||||
const { AppModule } = require('../src/app/app.module');
|
const { AppModule } = require('../src/app/app.module');
|
||||||
|
const { configureApp } = require('../src/app/configure');
|
||||||
|
|
||||||
/** Pireon is replaced by fixed tokens: the rest of the stack is real. */
|
/** Pireon is replaced by fixed tokens: the rest of the stack is real. */
|
||||||
const users = new UserFactory();
|
const users = new UserFactory();
|
||||||
@@ -29,6 +32,7 @@ const people: Record<string, User> = {
|
|||||||
'token-bob': person('102', 'Bob (frontend)', 'bob@x.dev'),
|
'token-bob': person('102', 'Bob (frontend)', 'bob@x.dev'),
|
||||||
'token-qa': person('103', 'Quinn (QA)', 'qa@x.dev'),
|
'token-qa': person('103', 'Quinn (QA)', 'qa@x.dev'),
|
||||||
'token-eve': person('104', 'Eve (outsider)', 'eve@x.dev'),
|
'token-eve': person('104', 'Eve (outsider)', 'eve@x.dev'),
|
||||||
|
'token-dan': person('105', 'Dan (leaving)', 'dan@x.dev'),
|
||||||
};
|
};
|
||||||
const byId = (id: string) =>
|
const byId = (id: string) =>
|
||||||
Object.values(people).find((p) => p.id === id) ?? null;
|
Object.values(people).find((p) => p.id === id) ?? null;
|
||||||
@@ -71,6 +75,25 @@ describe('task-stream (e2e)', () => {
|
|||||||
return body.data;
|
return body.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** REST call under /api; returns status and parsed body. */
|
||||||
|
async function rest<T = Record<string, any>>( // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||||
|
token: string | null,
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: object,
|
||||||
|
): Promise<{ status: number; body: T }> {
|
||||||
|
const response = await fetch(`${url}/api${path}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
return { status: response.status, body: text ? JSON.parse(text) : null };
|
||||||
|
}
|
||||||
|
|
||||||
/** The read model is eventually consistent: poll until it catches up. */
|
/** The read model is eventually consistent: poll until it catches up. */
|
||||||
async function eventually<T>(
|
async function eventually<T>(
|
||||||
read: () => Promise<T>,
|
read: () => Promise<T>,
|
||||||
@@ -123,10 +146,7 @@ describe('task-stream (e2e)', () => {
|
|||||||
.useValue(fakePireon)
|
.useValue(fakePireon)
|
||||||
.compile();
|
.compile();
|
||||||
app = moduleRef.createNestApplication({ rawBody: true });
|
app = moduleRef.createNestApplication({ rawBody: true });
|
||||||
app.useGlobalPipes(
|
configureApp(app);
|
||||||
new ValidationPipe({ transform: true, whitelist: true }),
|
|
||||||
);
|
|
||||||
app.useGlobalFilters(new DomainErrorFilter());
|
|
||||||
await app.listen(0);
|
await app.listen(0);
|
||||||
url = await app.getUrl();
|
url = await app.getUrl();
|
||||||
url = url.replace('[::1]', 'localhost');
|
url = url.replace('[::1]', 'localhost');
|
||||||
@@ -405,4 +425,747 @@ describe('task-stream (e2e)', () => {
|
|||||||
expect(anonymous.received).toHaveLength(0);
|
expect(anonymous.received).toHaveLength(0);
|
||||||
expect(anonymous.errors).toHaveLength(1);
|
expect(anonymous.errors).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('exposes the same use cases over REST, documented with OpenAPI', async () => {
|
||||||
|
// --- The contract other apps generate clients from ---
|
||||||
|
const docs = await rest(null, 'GET', '/docs-json');
|
||||||
|
expect(docs.status).toBe(200);
|
||||||
|
expect(Object.keys(docs.body['paths'])).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
'/api/projects',
|
||||||
|
'/api/projects/{project}/issues',
|
||||||
|
'/api/issues/{issue}/status',
|
||||||
|
'/api/releases/{releaseId}/publish',
|
||||||
|
'/api/projects/{project}/settings',
|
||||||
|
'/api/projects/{project}/analytics',
|
||||||
|
'/api/releases/{releaseId}/burnup',
|
||||||
|
'/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' } });
|
||||||
|
|
||||||
|
// --- My work across projects ---
|
||||||
|
const mine = await eventually(
|
||||||
|
() => rest('token-qa', 'GET', '/me/issues'),
|
||||||
|
(r) => r.body?.['items']?.some((i: { key: string }) => i.key === beKey),
|
||||||
|
);
|
||||||
|
// Created by Quinn but assigned to nobody: not in "assigned to me".
|
||||||
|
expect(mine.body['items'].map((i: { key: string }) => i.key)).not.toContain(
|
||||||
|
qaKey,
|
||||||
|
);
|
||||||
|
const todo = await rest(
|
||||||
|
'token-ana',
|
||||||
|
'GET',
|
||||||
|
'/me/issues?assignedToMe=false&status=TODO',
|
||||||
|
);
|
||||||
|
expect(todo.body['items'].map((i: { key: string }) => i.key)).toContain(
|
||||||
|
beKey,
|
||||||
|
);
|
||||||
|
const viaGraphql = await gql(
|
||||||
|
'token-qa',
|
||||||
|
`{ myIssues { total items { key assignee { id } } } }`,
|
||||||
|
);
|
||||||
|
expect(viaGraphql['myIssues'].items).toEqual(
|
||||||
|
expect.arrayContaining([{ key: beKey, assignee: { id: '103' } }]),
|
||||||
|
);
|
||||||
|
await rest('token-ana', 'POST', `/projects/${key}/releases`, {
|
||||||
|
name: 'v9.9.9',
|
||||||
|
targetDate: '2099-01-01',
|
||||||
|
});
|
||||||
|
await eventually(
|
||||||
|
() => rest('token-ana', 'GET', '/me/releases?limit=50'),
|
||||||
|
(r) =>
|
||||||
|
Array.isArray(r.body) &&
|
||||||
|
r.body.some((rel: { name: string }) => rel.name === 'v9.9.9'),
|
||||||
|
);
|
||||||
|
// Not a member of the project: none of its tickets.
|
||||||
|
const outsider = await rest('token-eve', 'GET', '/me/issues');
|
||||||
|
expect(
|
||||||
|
outsider.body['items'].map((i: { key: string }) => i.key),
|
||||||
|
).not.toContain(beKey);
|
||||||
|
|
||||||
|
// --- Same authorisation as GraphQL ---
|
||||||
|
expect((await rest('token-eve', 'GET', `/issues/${beKey}`)).status).toBe(
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets each project customise its workflow, types and areas, and measures it', async () => {
|
||||||
|
const key = 'S' + Date.now().toString(36).slice(-5).toUpperCase();
|
||||||
|
const projectId = (
|
||||||
|
await gql(
|
||||||
|
'token-ana',
|
||||||
|
`mutation($i: CreateProjectInput!) { createProject(input: $i) { id } }`,
|
||||||
|
{ i: { key, name: 'Custom workflow' } },
|
||||||
|
)
|
||||||
|
).createProject.id;
|
||||||
|
await eventually(
|
||||||
|
() =>
|
||||||
|
gql('token-ana', `query($k: String!) { project(idOrKey: $k) { id } }`, {
|
||||||
|
k: key,
|
||||||
|
}),
|
||||||
|
(d) => !!d.project,
|
||||||
|
);
|
||||||
|
await gql(
|
||||||
|
'token-ana',
|
||||||
|
`mutation($i: ProjectMemberInput!) { addProjectMember(input: $i) { version } }`,
|
||||||
|
{ i: { projectId, userId: '102', role: 'MEMBER' } },
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Every project starts with the default settings ---
|
||||||
|
const settingsQuery = `query($k: String!) { project(idOrKey: $k) { settings {
|
||||||
|
statuses { id name category color requiresReason archived }
|
||||||
|
transitions { from to }
|
||||||
|
issueTypes { id name icon color archived }
|
||||||
|
areas { id name archived }
|
||||||
|
} } }`;
|
||||||
|
const { settings } = (await gql('token-ana', settingsQuery, { k: key }))
|
||||||
|
.project;
|
||||||
|
expect(settings.statuses[0]).toEqual({
|
||||||
|
id: 'BACKLOG',
|
||||||
|
name: 'Backlog',
|
||||||
|
category: 'TODO',
|
||||||
|
color: 'stone',
|
||||||
|
requiresReason: false,
|
||||||
|
archived: false,
|
||||||
|
});
|
||||||
|
expect(settings.areas.map((a: { id: string }) => a.id)).toContain('MOBILE');
|
||||||
|
|
||||||
|
// --- A staging step, a Spike type, a Data area; Mobile archived ---
|
||||||
|
type Item = { id: string; archived?: boolean };
|
||||||
|
const reviewAt = settings.statuses.findIndex(
|
||||||
|
(s: Item) => s.id === 'IN_REVIEW',
|
||||||
|
);
|
||||||
|
settings.statuses.splice(reviewAt + 1, 0, {
|
||||||
|
id: 'STAGING',
|
||||||
|
name: 'En staging',
|
||||||
|
category: 'QA',
|
||||||
|
color: 'teal',
|
||||||
|
requiresReason: false,
|
||||||
|
archived: false,
|
||||||
|
});
|
||||||
|
settings.transitions = settings.transitions.map(
|
||||||
|
(t: { from: string; to: string[] }) =>
|
||||||
|
t.from === 'IN_REVIEW'
|
||||||
|
? { from: 'IN_REVIEW', to: ['STAGING', 'IN_PROGRESS'] }
|
||||||
|
: t,
|
||||||
|
);
|
||||||
|
settings.transitions.push({ from: 'STAGING', to: ['DONE', 'IN_PROGRESS'] });
|
||||||
|
settings.issueTypes.push({
|
||||||
|
id: 'SPIKE',
|
||||||
|
name: 'Spike',
|
||||||
|
icon: 'light-bulb',
|
||||||
|
color: 'teal',
|
||||||
|
archived: false,
|
||||||
|
});
|
||||||
|
settings.areas = settings.areas.map((a: Item) =>
|
||||||
|
a.id === 'MOBILE' ? { ...a, archived: true } : a,
|
||||||
|
);
|
||||||
|
settings.areas.push({ id: 'DATA', name: 'Datos', archived: false });
|
||||||
|
|
||||||
|
const save = (token: string, next: object) =>
|
||||||
|
gql(
|
||||||
|
token,
|
||||||
|
`mutation($p: ID!, $s: ProjectSettingsInput!) { updateProjectSettings(projectId: $p, settings: $s) { version } }`,
|
||||||
|
{ p: projectId, s: next },
|
||||||
|
);
|
||||||
|
await expect(save('token-bob', settings)).rejects.toThrow(/owner|admin/i);
|
||||||
|
await expect(
|
||||||
|
save('token-ana', {
|
||||||
|
...settings,
|
||||||
|
areas: settings.areas.filter((a: Item) => a.id !== 'QA'),
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/archívalo/);
|
||||||
|
await save('token-ana', settings);
|
||||||
|
await eventually(
|
||||||
|
() => gql('token-ana', settingsQuery, { k: key }),
|
||||||
|
(d) => d.project.settings.statuses.some((s: Item) => s.id === 'STAGING'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Tickets follow the project's settings ---
|
||||||
|
const create = `mutation($i: CreateIssueInput!) { createIssue(input: $i) { id key } }`;
|
||||||
|
await expect(
|
||||||
|
gql('token-bob', create, {
|
||||||
|
i: { projectId, type: 'EPIC', title: 'Unknown type' },
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/Unknown ticket type/);
|
||||||
|
await expect(
|
||||||
|
gql('token-bob', create, {
|
||||||
|
i: { projectId, type: 'TASK', title: 'Old area', area: 'MOBILE' },
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/archived/);
|
||||||
|
const spike = (
|
||||||
|
await gql('token-bob', create, {
|
||||||
|
i: {
|
||||||
|
projectId,
|
||||||
|
type: 'SPIKE',
|
||||||
|
title: 'Evaluate a column store',
|
||||||
|
area: 'DATA',
|
||||||
|
assigneeId: '102',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).createIssue;
|
||||||
|
|
||||||
|
const release = (
|
||||||
|
await gql(
|
||||||
|
'token-ana',
|
||||||
|
`mutation($i: CreateReleaseInput!) { createRelease(input: $i) { id } }`,
|
||||||
|
{ i: { projectId, name: 'v1.0.0' } },
|
||||||
|
)
|
||||||
|
).createRelease;
|
||||||
|
await eventually(
|
||||||
|
() =>
|
||||||
|
gql('token-ana', `query($id: ID!) { release(id: $id) { id } }`, {
|
||||||
|
id: release.id,
|
||||||
|
}),
|
||||||
|
(d) => !!d.release,
|
||||||
|
);
|
||||||
|
await gql(
|
||||||
|
'token-ana',
|
||||||
|
`mutation($i: ID!, $r: ID) { setIssueRelease(issueId: $i, releaseId: $r) { version } }`,
|
||||||
|
{ i: spike.id, r: release.id },
|
||||||
|
);
|
||||||
|
|
||||||
|
const move = (status: string) =>
|
||||||
|
gql(
|
||||||
|
'token-bob',
|
||||||
|
`mutation($i: ChangeIssueStatusInput!) { changeIssueStatus(input: $i) { version } }`,
|
||||||
|
{ i: { issueId: spike.id, status } },
|
||||||
|
);
|
||||||
|
for (const status of ['TODO', 'IN_PROGRESS', 'IN_REVIEW'])
|
||||||
|
await move(status);
|
||||||
|
const inReview = await eventually(
|
||||||
|
() =>
|
||||||
|
gql(
|
||||||
|
'token-bob',
|
||||||
|
`query($k: String!) { issue(idOrKey: $k) { status statusCategory allowedTransitions } }`,
|
||||||
|
{ k: spike.key },
|
||||||
|
),
|
||||||
|
(d) => d.issue.status === 'IN_REVIEW',
|
||||||
|
);
|
||||||
|
expect(inReview.issue).toEqual({
|
||||||
|
status: 'IN_REVIEW',
|
||||||
|
statusCategory: 'IN_PROGRESS',
|
||||||
|
allowedTransitions: ['STAGING', 'IN_PROGRESS'],
|
||||||
|
});
|
||||||
|
await expect(move('READY_FOR_QA')).rejects.toThrow(/Cannot move/);
|
||||||
|
await move('STAGING');
|
||||||
|
await move('DONE');
|
||||||
|
|
||||||
|
// --- Board, history, releases and statistics use the categories ---
|
||||||
|
const { project } = await eventually(
|
||||||
|
() =>
|
||||||
|
gql(
|
||||||
|
'token-ana',
|
||||||
|
`query($k: String!) { project(idOrKey: $k) {
|
||||||
|
board { status total }
|
||||||
|
stats { total open }
|
||||||
|
releases { progress { finished percent } burnup { scope done } }
|
||||||
|
analytics(days: 30) {
|
||||||
|
createdVsResolved { created resolved }
|
||||||
|
cumulativeFlow { series { status counts } }
|
||||||
|
cycleTime { count }
|
||||||
|
leadTime { count }
|
||||||
|
throughput { user { id } count }
|
||||||
|
qa { passed failed failureRate }
|
||||||
|
}
|
||||||
|
} }`,
|
||||||
|
{ k: key },
|
||||||
|
),
|
||||||
|
(d) => d.project.analytics.cycleTime.count === 1,
|
||||||
|
);
|
||||||
|
const columns = project.board.map((c: { status: string }) => c.status);
|
||||||
|
expect(columns.indexOf('STAGING')).toBe(columns.indexOf('IN_REVIEW') + 1);
|
||||||
|
expect(columns).not.toContain('CANCELLED');
|
||||||
|
expect(project.stats).toEqual({ total: 1, open: 0 });
|
||||||
|
expect(project.releases[0].progress).toEqual({ finished: 1, percent: 100 });
|
||||||
|
expect(project.releases[0].burnup.scope.at(-1)).toBe(1);
|
||||||
|
expect(project.releases[0].burnup.done.at(-1)).toBe(1);
|
||||||
|
const analytics = project.analytics;
|
||||||
|
const sum = (
|
||||||
|
rows: { created: number; resolved: number }[],
|
||||||
|
f: 'created' | 'resolved',
|
||||||
|
) => rows.reduce((total, row) => total + row[f], 0);
|
||||||
|
expect(sum(analytics.createdVsResolved, 'created')).toBe(1);
|
||||||
|
expect(sum(analytics.createdVsResolved, 'resolved')).toBe(1);
|
||||||
|
expect(analytics.cumulativeFlow.series).toEqual([
|
||||||
|
expect.objectContaining({ status: 'DONE' }),
|
||||||
|
]);
|
||||||
|
expect(analytics.leadTime.count).toBe(1);
|
||||||
|
expect(analytics.throughput).toEqual([{ user: { id: '102' }, count: 1 }]);
|
||||||
|
// STAGING is a QA-category step: leaving it to DONE is a QA pass.
|
||||||
|
expect(analytics.qa).toEqual({ passed: 1, failed: 0, failureRate: 0 });
|
||||||
|
|
||||||
|
const history = await gql(
|
||||||
|
'token-ana',
|
||||||
|
`query($k: String!) { issue(idOrKey: $k) { history { summary } } }`,
|
||||||
|
{ k: spike.key },
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
history.issue.history.map((h: { summary: string }) => h.summary),
|
||||||
|
).toContain(
|
||||||
|
`Bob (frontend) movió ${spike.key} de En revisión a En staging`,
|
||||||
|
);
|
||||||
|
const done = await gql(
|
||||||
|
'token-bob',
|
||||||
|
`{ myIssues(categories: [DONE]) { items { key } } }`,
|
||||||
|
);
|
||||||
|
expect(done.myIssues.items).toEqual(
|
||||||
|
expect.arrayContaining([{ key: spike.key }]),
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Same over REST ---
|
||||||
|
const viaRest = await rest('token-ana', 'GET', `/projects/${key}`);
|
||||||
|
expect(viaRest.body['settings']['transitions']).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
{ from: 'STAGING', to: ['DONE', 'IN_PROGRESS'] },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const renamed = viaRest.body['settings'];
|
||||||
|
renamed.statuses = renamed.statuses.map((s: Item & { name: string }) =>
|
||||||
|
s.id === 'STAGING' ? { ...s, name: 'Preproducción' } : s,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(await rest('token-ana', 'PUT', `/projects/${key}/settings`, renamed))
|
||||||
|
.status,
|
||||||
|
).toBe(200);
|
||||||
|
const removed = await rest(
|
||||||
|
'token-ana',
|
||||||
|
'PUT',
|
||||||
|
`/projects/${key}/settings`,
|
||||||
|
{ ...renamed, statuses: [] },
|
||||||
|
);
|
||||||
|
expect(removed.status).toBe(422);
|
||||||
|
expect(removed.body['message']).toMatch(/archívalo/);
|
||||||
|
const neon = await rest('token-ana', 'PUT', `/projects/${key}/settings`, {
|
||||||
|
...renamed,
|
||||||
|
areas: [...renamed.areas, { id: 'X', name: 'X', color: 'neon' }],
|
||||||
|
issueTypes: [
|
||||||
|
...renamed.issueTypes,
|
||||||
|
{ id: 'Y', name: 'Y', icon: 'bug', color: 'neon' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(neon.status).toBe(400);
|
||||||
|
const stats = await rest(
|
||||||
|
'token-ana',
|
||||||
|
'GET',
|
||||||
|
`/projects/${key}/analytics?days=30`,
|
||||||
|
);
|
||||||
|
expect(stats.status).toBe(200);
|
||||||
|
expect(stats.body['cycleTime']['count']).toBe(1);
|
||||||
|
const burnup = await rest(
|
||||||
|
'token-ana',
|
||||||
|
'GET',
|
||||||
|
`/releases/${release.id}/burnup`,
|
||||||
|
);
|
||||||
|
expect(burnup.body).toMatchObject({ releaseId: release.id });
|
||||||
|
expect(
|
||||||
|
(await rest('token-eve', 'GET', `/projects/${key}/analytics`)).status,
|
||||||
|
).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens public projects to anyone, read-only', async () => {
|
||||||
|
const key = 'P' + Date.now().toString(36).slice(-5).toUpperCase();
|
||||||
|
const created = await rest('token-ana', 'POST', '/projects', {
|
||||||
|
key,
|
||||||
|
name: 'Open roadmap',
|
||||||
|
isPublic: true,
|
||||||
|
});
|
||||||
|
expect(created.status).toBe(201);
|
||||||
|
const projectId = created.body['id'] as string;
|
||||||
|
await eventually(
|
||||||
|
() => rest(null, 'GET', `/projects/${key}`),
|
||||||
|
(r) => r.status === 200,
|
||||||
|
);
|
||||||
|
const issue = (
|
||||||
|
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
|
||||||
|
type: 'FEATURE',
|
||||||
|
title: 'Dark mode',
|
||||||
|
})
|
||||||
|
).body['key'] as string;
|
||||||
|
await eventually(
|
||||||
|
() => rest('token-ana', 'GET', `/issues/${issue}`),
|
||||||
|
(r) => r.status === 200,
|
||||||
|
);
|
||||||
|
await rest('token-ana', 'POST', `/issues/${issue}/comments`, {
|
||||||
|
body: 'Planned for Q4',
|
||||||
|
});
|
||||||
|
|
||||||
|
// REST without a token: project, tickets, comments, statistics, discovery.
|
||||||
|
const anonymous = await eventually(
|
||||||
|
() => rest(null, 'GET', `/issues/${issue}/comments`),
|
||||||
|
(r) => r.body?.['total'] === 1,
|
||||||
|
);
|
||||||
|
expect(anonymous.status).toBe(200);
|
||||||
|
expect((await rest(null, 'GET', `/projects/${key}`)).body).toMatchObject({
|
||||||
|
key,
|
||||||
|
isPublic: true,
|
||||||
|
});
|
||||||
|
expect((await rest(null, 'GET', `/issues/${issue}`)).status).toBe(200);
|
||||||
|
expect((await rest(null, 'GET', `/projects/${key}/analytics`)).status).toBe(
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
const explore = await rest(null, 'GET', '/explore/projects');
|
||||||
|
expect(explore.body.map((p: { key: string }) => p.key)).toContain(key);
|
||||||
|
|
||||||
|
// GraphQL without a token, nested fields included.
|
||||||
|
const viaGraphql = await fetch(`${url}/graphql`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
query: `query($k: String!) {
|
||||||
|
publicProjects { key }
|
||||||
|
project(idOrKey: $k) { key isPublic board { status total } stats { total } releases { name } }
|
||||||
|
issues(filter: { projectId: "${projectId}" }) { total items { key comments { total } linkedIssues { key } } }
|
||||||
|
}`,
|
||||||
|
variables: { k: key },
|
||||||
|
}),
|
||||||
|
}).then((r) => r.json());
|
||||||
|
expect(viaGraphql.errors).toBeUndefined();
|
||||||
|
expect(viaGraphql.data.project).toMatchObject({
|
||||||
|
key,
|
||||||
|
isPublic: true,
|
||||||
|
stats: { total: 1 },
|
||||||
|
});
|
||||||
|
expect(viaGraphql.data.issues.items[0]).toMatchObject({
|
||||||
|
key: issue,
|
||||||
|
comments: { total: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reading is open, writing is not: anonymous → 401, outsiders → 403.
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await rest(null, 'POST', `/projects/${key}/issues`, {
|
||||||
|
type: 'BUG',
|
||||||
|
title: 'x',
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
).toBe(401);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await rest('token-eve', 'POST', `/projects/${key}/issues`, {
|
||||||
|
type: 'BUG',
|
||||||
|
title: 'x',
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
).toBe(403);
|
||||||
|
expect((await rest('token-eve', 'GET', `/issues/${issue}`)).status).toBe(
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
// A bad token is still rejected, even where none is needed.
|
||||||
|
expect((await rest('bad-token', 'GET', `/projects/${key}`)).status).toBe(
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Back to private: anonymous readers get 404 again.
|
||||||
|
await rest('token-ana', 'PATCH', `/projects/${key}`, { isPublic: false });
|
||||||
|
await eventually(
|
||||||
|
() => rest(null, 'GET', `/projects/${key}`),
|
||||||
|
(r) => r.status === 404,
|
||||||
|
);
|
||||||
|
const hidden = await rest(null, 'GET', `/issues/${issue}`);
|
||||||
|
expect(hidden.status).toBe(404);
|
||||||
|
// The answer names what was asked, not the private project behind it.
|
||||||
|
expect(hidden.body['message']).toContain(issue);
|
||||||
|
expect(hidden.body['message']).not.toContain(projectId);
|
||||||
|
const project = await rest(null, 'GET', `/projects/${key}`);
|
||||||
|
expect(project.body['message']).toContain(key);
|
||||||
|
expect(project.body['message']).not.toContain(projectId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes a project with everything in it (cascade saga) and frees its key', async () => {
|
||||||
|
const key = 'D' + Date.now().toString(36).slice(-5).toUpperCase();
|
||||||
|
const other = 'O' + Date.now().toString(36).slice(-5).toUpperCase();
|
||||||
|
for (const k of [key, other])
|
||||||
|
await rest('token-ana', 'POST', '/projects', { key: k, name: k });
|
||||||
|
await eventually(
|
||||||
|
() => rest('token-ana', 'GET', `/projects/${other}`),
|
||||||
|
(r) => r.status === 200,
|
||||||
|
);
|
||||||
|
await eventually(
|
||||||
|
() => rest('token-ana', 'GET', `/projects/${key}`),
|
||||||
|
(r) => r.status === 200,
|
||||||
|
);
|
||||||
|
await rest('token-ana', 'POST', `/projects/${key}/members`, {
|
||||||
|
userId: '102',
|
||||||
|
role: 'ADMIN',
|
||||||
|
});
|
||||||
|
const doomed = (
|
||||||
|
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
|
||||||
|
type: 'BUG',
|
||||||
|
title: 'Doomed',
|
||||||
|
})
|
||||||
|
).body['key'] as string;
|
||||||
|
const survivor = (
|
||||||
|
await rest('token-ana', 'POST', `/projects/${other}/issues`, {
|
||||||
|
type: 'TASK',
|
||||||
|
title: 'Survivor',
|
||||||
|
})
|
||||||
|
).body['key'] as string;
|
||||||
|
await eventually(
|
||||||
|
() => rest('token-ana', 'GET', `/issues/${survivor}`),
|
||||||
|
(r) => r.status === 200,
|
||||||
|
);
|
||||||
|
await eventually(
|
||||||
|
() => rest('token-ana', 'GET', `/issues/${doomed}`),
|
||||||
|
(r) => r.status === 200,
|
||||||
|
);
|
||||||
|
await rest('token-ana', 'POST', `/issues/${doomed}/comments`, {
|
||||||
|
body: 'bye',
|
||||||
|
});
|
||||||
|
await rest('token-ana', 'POST', `/projects/${key}/releases`, {
|
||||||
|
name: 'v1.0.0',
|
||||||
|
});
|
||||||
|
await rest('token-ana', 'POST', `/issues/${survivor}/links`, {
|
||||||
|
type: 'DEPENDS_ON',
|
||||||
|
targetIssue: doomed,
|
||||||
|
});
|
||||||
|
await eventually(
|
||||||
|
() => rest('token-ana', 'GET', `/issues/${survivor}`),
|
||||||
|
(r) => r.body?.['links']?.length === 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Owner only, confirming the key.
|
||||||
|
expect(
|
||||||
|
(await rest('token-bob', 'DELETE', `/projects/${key}?confirm=${key}`))
|
||||||
|
.status,
|
||||||
|
).toBe(403);
|
||||||
|
expect(
|
||||||
|
(await rest('token-ana', 'DELETE', `/projects/${key}?confirm=NOPE`))
|
||||||
|
.status,
|
||||||
|
).toBe(422);
|
||||||
|
const deleted = await gql(
|
||||||
|
'token-ana',
|
||||||
|
`mutation($p: ID!, $k: String!) { deleteProject(projectId: $p, confirmKey: $k) { version } }`,
|
||||||
|
{
|
||||||
|
p: (await rest('token-ana', 'GET', `/projects/${key}`)).body['id'],
|
||||||
|
k: key.toLowerCase(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(deleted.deleteProject.version).toBeGreaterThan(1);
|
||||||
|
|
||||||
|
await eventually(
|
||||||
|
() => rest('token-ana', 'GET', `/projects/${key}`),
|
||||||
|
(r) => r.status === 404,
|
||||||
|
);
|
||||||
|
// The saga deletes the tickets and the link from the other project.
|
||||||
|
await eventually(
|
||||||
|
() => rest('token-ana', 'GET', `/issues/${doomed}`),
|
||||||
|
(r) => r.status === 404,
|
||||||
|
);
|
||||||
|
await eventually(
|
||||||
|
() => rest('token-ana', 'GET', `/issues/${survivor}`),
|
||||||
|
(r) => r.body?.['links']?.length === 0,
|
||||||
|
);
|
||||||
|
const mine = await rest('token-bob', 'GET', '/projects');
|
||||||
|
expect(mine.body.map((p: { key: string }) => p.key)).not.toContain(key);
|
||||||
|
// The key can be used again.
|
||||||
|
expect(
|
||||||
|
(await rest('token-ana', 'POST', '/projects', { key, name: 'Reborn' }))
|
||||||
|
.status,
|
||||||
|
).toBe(201);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('archiving a release lets its unfinished tickets go (saga)', async () => {
|
||||||
|
const key = 'A' + Date.now().toString(36).slice(-5).toUpperCase();
|
||||||
|
await rest('token-ana', 'POST', '/projects', { key, name: key });
|
||||||
|
await eventually(
|
||||||
|
() => rest('token-ana', 'GET', `/projects/${key}`),
|
||||||
|
(r) => r.status === 200,
|
||||||
|
);
|
||||||
|
const release = (
|
||||||
|
await rest('token-ana', 'POST', `/projects/${key}/releases`, {
|
||||||
|
name: 'v2.0.0',
|
||||||
|
})
|
||||||
|
).body['id'] as string;
|
||||||
|
await eventually(
|
||||||
|
() => rest('token-ana', 'GET', `/releases/${release}`),
|
||||||
|
(r) => r.status === 200,
|
||||||
|
);
|
||||||
|
const open = (
|
||||||
|
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
|
||||||
|
type: 'TASK',
|
||||||
|
title: 'Still open',
|
||||||
|
releaseId: release,
|
||||||
|
})
|
||||||
|
).body['key'] as string;
|
||||||
|
await eventually(
|
||||||
|
() => rest('token-ana', 'GET', `/issues/${open}`),
|
||||||
|
(r) => r.body?.['release']?.id === release,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(await rest('token-ana', 'POST', `/releases/${release}/archive`)).status,
|
||||||
|
).toBe(200);
|
||||||
|
const released = await eventually(
|
||||||
|
() => rest('token-ana', 'GET', `/issues/${open}`),
|
||||||
|
(r) => r.body?.['release'] === null,
|
||||||
|
);
|
||||||
|
expect(released.body['release']).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unassigns the open tickets of a user Pireon deactivates (saga)', async () => {
|
||||||
|
const key = 'U' + Date.now().toString(36).slice(-5).toUpperCase();
|
||||||
|
await rest('token-ana', 'POST', '/projects', { key, name: key });
|
||||||
|
await eventually(
|
||||||
|
() => rest('token-ana', 'GET', `/projects/${key}`),
|
||||||
|
(r) => r.status === 200,
|
||||||
|
);
|
||||||
|
// Dan signs in once (so the identity context knows him) and joins.
|
||||||
|
expect((await rest('token-dan', 'GET', '/users/me')).status).toBe(200);
|
||||||
|
await rest('token-ana', 'POST', `/projects/${key}/members`, {
|
||||||
|
userId: '105',
|
||||||
|
});
|
||||||
|
const ticket = (
|
||||||
|
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
|
||||||
|
type: 'TASK',
|
||||||
|
title: 'Dan has it',
|
||||||
|
assigneeId: '105',
|
||||||
|
})
|
||||||
|
).body['key'] as string;
|
||||||
|
await eventually(
|
||||||
|
() => rest('token-ana', 'GET', `/issues/${ticket}`),
|
||||||
|
(r) => r.body?.['assignee']?.id === '105',
|
||||||
|
);
|
||||||
|
|
||||||
|
const body = JSON.stringify({
|
||||||
|
event: 'user.deactivated',
|
||||||
|
data: { user_id: 105 },
|
||||||
|
});
|
||||||
|
const webhook = await fetch(`${url}/api/webhooks/pireon`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'x-webhook-signature': createHmac('sha256', 'e2e-webhook-secret')
|
||||||
|
.update(body)
|
||||||
|
.digest('hex'),
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
expect(webhook.status).toBe(204);
|
||||||
|
|
||||||
|
const unassigned = await eventually(
|
||||||
|
() => rest('token-ana', 'GET', `/issues/${ticket}`),
|
||||||
|
(r) => r.status === 200 && r.body['assignee'] === null,
|
||||||
|
);
|
||||||
|
expect(unassigned.body['assignee']).toBeNull();
|
||||||
|
const history = await rest('token-ana', 'GET', `/issues/${ticket}/history`);
|
||||||
|
expect(
|
||||||
|
history.body.map((h: { actor: { name: string } }) => h.actor.name),
|
||||||
|
).toContain('TaskStream');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,123 +0,0 @@
|
|||||||
// TEMPORARY (UI work): API on :3001 with fake Pireon users and seeded data. Not part of the repo.
|
|
||||||
import { ValidationPipe } from '@nestjs/common';
|
|
||||||
import { Test } from '@nestjs/testing';
|
|
||||||
import {
|
|
||||||
IdentityProvider,
|
|
||||||
InvalidAccessTokenError,
|
|
||||||
InvalidCredentialsError,
|
|
||||||
} from '@task-stream/api/identity/application';
|
|
||||||
import { User, UserFactory } from '@task-stream/api/identity/domain';
|
|
||||||
import { DomainErrorFilter } from '@task-stream/api/shared/presenters';
|
|
||||||
|
|
||||||
process.env['POSTGRES_URL'] ??= 'postgres://taskstream:taskstream@localhost:5432/taskstream';
|
|
||||||
process.env['POSTGRES_SYNCHRONIZE'] = 'true';
|
|
||||||
process.env['MONGO_URL'] = `mongodb://localhost:27017/taskstream_ui_${Date.now()}`;
|
|
||||||
process.env['REDIS_URL'] ??= 'redis://localhost:6379';
|
|
||||||
process.env['EVENT_STREAM_KEY'] = `task-stream:ui:${Date.now()}`;
|
|
||||||
process.env['GRAPHQL_SCHEMA_FILE'] = '';
|
|
||||||
process.env['AUTH_SESSION_TTL_SECONDS'] = '0';
|
|
||||||
|
|
||||||
const { AppModule } = require('../src/app/app.module');
|
|
||||||
|
|
||||||
const users = new UserFactory();
|
|
||||||
const person = (id: string, name: string, email: string) => users.create({ id, name, email, isActive: true });
|
|
||||||
const people: Record<string, User> = {
|
|
||||||
'token-ana': person('101', 'Ana García', 'ana@pireon.dev'),
|
|
||||||
'token-bob': person('102', 'Bob Martín', 'bob@pireon.dev'),
|
|
||||||
'token-qa': person('103', 'Quinn Ruiz', 'quinn@pireon.dev'),
|
|
||||||
'token-dan': person('104', 'Dan Ortega', 'dan@pireon.dev'),
|
|
||||||
};
|
|
||||||
const byId = (id: string) => Object.values(people).find((p) => p.id === id) ?? null;
|
|
||||||
|
|
||||||
const PATH: Record<string, string[]> = {
|
|
||||||
BACKLOG: [], TODO: ['TODO'], IN_PROGRESS: ['TODO', 'IN_PROGRESS'],
|
|
||||||
IN_REVIEW: ['TODO', 'IN_PROGRESS', 'IN_REVIEW'],
|
|
||||||
READY_FOR_QA: ['TODO', 'IN_PROGRESS', 'IN_REVIEW', 'READY_FOR_QA'],
|
|
||||||
IN_QA: ['TODO', 'IN_PROGRESS', 'IN_REVIEW', 'READY_FOR_QA', 'IN_QA'],
|
|
||||||
QA_FAILED: ['TODO', 'IN_PROGRESS', 'IN_REVIEW', 'READY_FOR_QA', 'IN_QA', 'QA_FAILED'],
|
|
||||||
DONE: ['TODO', 'IN_PROGRESS', 'IN_REVIEW', 'DONE'], CANCELLED: ['CANCELLED'],
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('ui dev server', () => {
|
|
||||||
it('serves until stopped', async () => {
|
|
||||||
const moduleRef = await Test.createTestingModule({ imports: [AppModule] })
|
|
||||||
.overrideProvider(IdentityProvider)
|
|
||||||
.useValue({
|
|
||||||
whoAmI: async (token: string) => { const u = people[token]; if (!u) throw new InvalidAccessTokenError(); return u; },
|
|
||||||
findUser: async (id: string) => byId(id),
|
|
||||||
listUsers: async () => Object.values(people),
|
|
||||||
// Email + any password "pireon" → that user's token.
|
|
||||||
issueToken: async (grant: { type: string; username?: string; password?: string }) => {
|
|
||||||
const entry = Object.entries(people).find(([, u]) => u.email === grant.username);
|
|
||||||
if (grant.type !== 'password' || !entry || grant.password !== 'pireon') throw new InvalidCredentialsError('Email o contraseña incorrectos');
|
|
||||||
return { accessToken: entry[0], refreshToken: null, expiresIn: 86400, tokenType: 'Bearer' };
|
|
||||||
},
|
|
||||||
authorizationConfig: () => ({ authorizeUrl: 'https://oauth.pireon.dev/oauth/authorize', clientId: 'ui-dev', scopes: ['users:read'], passwordGrant: true }),
|
|
||||||
})
|
|
||||||
.compile();
|
|
||||||
const app = moduleRef.createNestApplication({ rawBody: true });
|
|
||||||
app.setGlobalPrefix('api');
|
|
||||||
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
|
|
||||||
app.useGlobalFilters(new DomainErrorFilter());
|
|
||||||
await app.listen(3001);
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const gql = async (token: string, query: string, variables?: object): Promise<any> => {
|
|
||||||
const r = await fetch('http://localhost:3001/graphql', { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, body: JSON.stringify({ query, variables }) });
|
|
||||||
const body = await r.json();
|
|
||||||
if (body.errors) throw new Error(JSON.stringify(body.errors));
|
|
||||||
return body.data;
|
|
||||||
};
|
|
||||||
const ana = 'token-ana';
|
|
||||||
const project = async (key: string, name: string, description: string) => (await gql(ana, `mutation($i: CreateProjectInput!) { createProject(input: $i) { id } }`, { i: { key, name, description } })).createProject.id as string;
|
|
||||||
const member = (projectId: string, userId: string, role: string) => gql(ana, `mutation($i: ProjectMemberInput!) { addProjectMember(input: $i) { version } }`, { i: { projectId, userId, role } });
|
|
||||||
const release = async (projectId: string, name: string, targetDate: string, description: string) => (await gql(ana, `mutation($i: CreateReleaseInput!) { createRelease(input: $i) { id } }`, { i: { projectId, name, targetDate, description } })).createRelease.id as string;
|
|
||||||
const move = async (issueId: string, status: string, reason?: string) => {
|
|
||||||
for (const step of PATH[status]) await gql(ana, `mutation($i: ChangeIssueStatusInput!) { changeIssueStatus(input: $i) { version } }`, { i: { issueId, status: step, reason: step === 'QA_FAILED' || step === 'CANCELLED' ? reason : undefined } });
|
|
||||||
};
|
|
||||||
const issue = async (projectId: string, i: Record<string, unknown>, status = 'BACKLOG', reason?: string) => {
|
|
||||||
const id = (await gql(ana, `mutation($i: CreateIssueInput!) { createIssue(input: $i) { id } }`, { i: { projectId, ...i } })).createIssue.id as string;
|
|
||||||
await move(id, status, reason);
|
|
||||||
return id;
|
|
||||||
};
|
|
||||||
const comment = (token: string, issueId: string, body: string) => gql(token, `mutation($i: ID!, $b: String!) { addComment(issueId: $i, body: $b) { id } }`, { i: issueId, b: body });
|
|
||||||
const link = (issueId: string, targetIssueId: string, type: string) => gql(ana, `mutation($i: LinkIssuesInput!) { linkIssues(input: $i) { version } }`, { i: { issueId, targetIssueId, type } });
|
|
||||||
|
|
||||||
const web = await project('WEB', 'Checkout revamp', 'Rediseño del checkout: pago en un paso, cupones y QA en cada release.');
|
|
||||||
for (const [id, role] of [['102', 'MEMBER'], ['103', 'MEMBER'], ['104', 'ADMIN']]) await member(web, id, role);
|
|
||||||
const v132 = await release(web, 'v1.3.2', '2026-09-26', 'Tarjetas guardadas y webhooks v2.');
|
|
||||||
const v140 = await release(web, 'v1.4.0', '2026-10-17', 'Checkout en un paso.');
|
|
||||||
await release(web, 'v1.5.0', '2026-11-14', 'Nuevos métodos de pago.');
|
|
||||||
const pay = await issue(web, { type: 'BUG', priority: 'CRITICAL', area: 'FRONTEND', assigneeId: '102', releaseId: v140, labels: ['safari', 'checkout'], title: 'El botón de pagar no responde en Safari 17', description: '1. Abrir el checkout en Safari 17\n2. Rellenar la tarjeta\n3. Pulsar «Pagar»: no pasa nada y no hay error en consola.' }, 'IN_PROGRESS');
|
|
||||||
const cart = await issue(web, { type: 'FEATURE', priority: 'HIGH', area: 'BACKEND', assigneeId: '104', releaseId: v140, labels: ['carrito'], title: 'Guardar el carrito entre dispositivos', description: 'El carrito se asocia al usuario y se fusiona al iniciar sesión.' }, 'IN_REVIEW');
|
|
||||||
const summary = await issue(web, { type: 'UI_CHANGE', priority: 'MEDIUM', area: 'DESIGN', assigneeId: '102', releaseId: v140, figmaUrl: 'https://www.figma.com/design/abc123/Checkout?node-id=12-345', title: 'Nuevo resumen del pedido según Figma' }, 'READY_FOR_QA');
|
|
||||||
const regression = await issue(web, { type: 'QA', priority: 'HIGH', area: 'QA', assigneeId: '103', releaseId: v140, title: 'Regresión del checkout en iOS y Android' }, 'IN_QA');
|
|
||||||
const coupon = await issue(web, { type: 'BUG', priority: 'HIGH', area: 'BACKEND', assigneeId: '104', releaseId: v140, labels: ['cupones'], title: 'El cupón se aplica dos veces si se recarga la página' }, 'QA_FAILED', 'Sigue duplicándose si se recarga durante el pago.');
|
|
||||||
await issue(web, { type: 'TASK', priority: 'LOW', area: 'DEVOPS', assigneeId: '104', title: 'Alertas de latencia del servicio de pagos' }, 'TODO');
|
|
||||||
await issue(web, { type: 'FEATURE', priority: 'MEDIUM', area: 'FRONTEND', title: 'Pago con Bizum' });
|
|
||||||
await issue(web, { type: 'BUG', priority: 'LOW', area: 'FRONTEND', assigneeId: '102', title: 'El foco se pierde al cerrar el modal de dirección' }, 'TODO');
|
|
||||||
await issue(web, { type: 'UI_CHANGE', priority: 'LOW', area: 'DESIGN', title: 'Estados vacíos del historial de pedidos' });
|
|
||||||
await issue(web, { type: 'TASK', priority: 'MEDIUM', area: 'BACKEND', assigneeId: '104', releaseId: v132, title: 'Migrar webhooks de Stripe a la v2' }, 'DONE');
|
|
||||||
await issue(web, { type: 'FEATURE', priority: 'HIGH', area: 'FRONTEND', assigneeId: '102', releaseId: v132, title: 'Guardar tarjeta para próximas compras' }, 'DONE');
|
|
||||||
await issue(web, { type: 'QA', priority: 'MEDIUM', area: 'QA', assigneeId: '103', title: 'Pruebas exploratorias de devoluciones' }, 'TODO');
|
|
||||||
await issue(web, { type: 'BUG', priority: 'MEDIUM', area: 'MOBILE', title: 'La app se cierra al girar en el paso de envío' });
|
|
||||||
await issue(web, { type: 'TASK', priority: 'LOWEST', area: 'DEVOPS', title: 'Limpiar feature flags del checkout antiguo' }, 'CANCELLED', 'Se hará con la limpieza general de flags.');
|
|
||||||
await gql(ana, `mutation($r: ID!) { publishRelease(releaseId: $r) { version } }`, { r: v132 });
|
|
||||||
await link(regression, summary, 'TESTS');
|
|
||||||
await link(summary, cart, 'DEPENDS_ON');
|
|
||||||
await link(coupon, regression, 'BLOCKS');
|
|
||||||
await comment('token-bob', pay, 'Reproducido: el `onClick` llega, pero el iframe de pago no termina de cargar en Safari.');
|
|
||||||
await comment('token-qa', pay, 'En Safari 16 funciona. Adjunto vídeo en el ticket de regresión.');
|
|
||||||
await comment(ana, pay, 'Decisión: usar redirect en lugar de popup para el 3-D Secure.');
|
|
||||||
const api = await project('API', 'Plataforma API', 'Servicios compartidos: identidad, pagos y notificaciones.');
|
|
||||||
await member(api, '104', 'ADMIN');
|
|
||||||
await issue(api, { type: 'FEATURE', priority: 'HIGH', area: 'BACKEND', assigneeId: '104', title: 'Rate limiting por cliente' }, 'IN_PROGRESS');
|
|
||||||
await issue(api, { type: 'BUG', priority: 'MEDIUM', area: 'BACKEND', title: 'Timeout en /payments/refund con importes altos' }, 'TODO');
|
|
||||||
const mob = await project('MOB', 'App móvil', 'iOS y Android: compra rápida y notificaciones.');
|
|
||||||
await member(mob, '102', 'MEMBER');
|
|
||||||
await issue(mob, { type: 'UI_CHANGE', priority: 'MEDIUM', area: 'MOBILE', title: 'Nueva navegación inferior' }, 'IN_REVIEW');
|
|
||||||
|
|
||||||
console.log('UI DEV API READY on :3001');
|
|
||||||
await new Promise(() => undefined);
|
|
||||||
}, 24 * 60 * 60 * 1000);
|
|
||||||
});
|
|
||||||
6349
apps/api/openapi.json
Normal file
6349
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/graphql": "^13.4.5",
|
||||||
"@nestjs/mongoose": "^11.0.4",
|
"@nestjs/mongoose": "^11.0.4",
|
||||||
"@nestjs/platform-express": "^11.0.0",
|
"@nestjs/platform-express": "^11.0.0",
|
||||||
|
"@nestjs/swagger": "^11.4.7",
|
||||||
"@nestjs/typeorm": "^11.0.3",
|
"@nestjs/typeorm": "^11.0.3",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
|
|||||||
@@ -13,20 +13,32 @@ type Activity {
|
|||||||
type: String!
|
type: String!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AreaOption {
|
||||||
|
archived: Boolean!
|
||||||
|
id: String!
|
||||||
|
name: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
input AreaOptionInput {
|
||||||
|
archived: Boolean! = false
|
||||||
|
id: String!
|
||||||
|
name: String!
|
||||||
|
}
|
||||||
|
|
||||||
type BoardColumn {
|
type BoardColumn {
|
||||||
issues: [Issue!]!
|
issues: [Issue!]!
|
||||||
status: IssueStatus!
|
status: String!
|
||||||
total: Int!
|
total: Int!
|
||||||
}
|
}
|
||||||
|
|
||||||
input ChangeIssueStatusInput {
|
input ChangeIssueStatusInput {
|
||||||
issueId: ID!
|
issueId: ID!
|
||||||
|
|
||||||
"""
|
"""Required by statuses with requiresReason"""
|
||||||
Required for QA_FAILED and CANCELLED
|
|
||||||
"""
|
|
||||||
reason: String
|
reason: String
|
||||||
status: IssueStatus!
|
|
||||||
|
"""A status id"""
|
||||||
|
status: String!
|
||||||
}
|
}
|
||||||
|
|
||||||
"""
|
"""
|
||||||
@@ -52,9 +64,7 @@ type CommentChange {
|
|||||||
action: String!
|
action: String!
|
||||||
actorId: ID!
|
actorId: ID!
|
||||||
|
|
||||||
"""
|
"""Null when deleted"""
|
||||||
Null when deleted
|
|
||||||
"""
|
|
||||||
comment: Comment
|
comment: Comment
|
||||||
commentId: ID!
|
commentId: ID!
|
||||||
issueId: ID!
|
issueId: ID!
|
||||||
@@ -69,9 +79,7 @@ type CommentPage {
|
|||||||
type CountByAssignee {
|
type CountByAssignee {
|
||||||
count: Int!
|
count: Int!
|
||||||
|
|
||||||
"""
|
"""Null = unassigned"""
|
||||||
Null = unassigned
|
|
||||||
"""
|
|
||||||
user: UserRef
|
user: UserRef
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,18 +90,19 @@ type CountByKey {
|
|||||||
|
|
||||||
type CountByStatus {
|
type CountByStatus {
|
||||||
count: Int!
|
count: Int!
|
||||||
key: IssueStatus!
|
|
||||||
|
"""Status id"""
|
||||||
|
key: String!
|
||||||
}
|
}
|
||||||
|
|
||||||
input CreateIssueInput {
|
input CreateIssueInput {
|
||||||
area: IssueArea
|
"""An area id"""
|
||||||
|
area: String
|
||||||
assigneeId: ID
|
assigneeId: ID
|
||||||
description: String
|
description: String
|
||||||
dueDate: String
|
dueDate: String
|
||||||
|
|
||||||
"""
|
"""Figma frame for UI changes"""
|
||||||
Figma frame for UI changes
|
|
||||||
"""
|
|
||||||
figmaUrl: String
|
figmaUrl: String
|
||||||
labels: [String!]
|
labels: [String!]
|
||||||
priority: IssuePriority
|
priority: IssuePriority
|
||||||
@@ -101,15 +110,18 @@ input CreateIssueInput {
|
|||||||
releaseId: ID
|
releaseId: ID
|
||||||
storyPoints: Int
|
storyPoints: Int
|
||||||
title: String!
|
title: String!
|
||||||
type: IssueType!
|
|
||||||
|
"""A ticket type id"""
|
||||||
|
type: String!
|
||||||
}
|
}
|
||||||
|
|
||||||
input CreateProjectInput {
|
input CreateProjectInput {
|
||||||
description: String
|
description: String
|
||||||
|
|
||||||
"""
|
"""Readable by anyone, also without signing in"""
|
||||||
2-10 chars, e.g. "TS" → tickets TS-1, TS-2...
|
isPublic: Boolean
|
||||||
"""
|
|
||||||
|
"""2-10 chars, e.g. "TS" → tickets TS-1, TS-2..."""
|
||||||
key: String!
|
key: String!
|
||||||
name: String!
|
name: String!
|
||||||
}
|
}
|
||||||
@@ -117,20 +129,44 @@ input CreateProjectInput {
|
|||||||
input CreateReleaseInput {
|
input CreateReleaseInput {
|
||||||
description: String
|
description: String
|
||||||
|
|
||||||
"""
|
"""e.g. v1.4.0"""
|
||||||
e.g. v1.4.0
|
|
||||||
"""
|
|
||||||
name: String!
|
name: String!
|
||||||
projectId: ID!
|
projectId: ID!
|
||||||
targetDate: String
|
targetDate: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CreatedResolved {
|
||||||
|
created: Int!
|
||||||
|
resolved: Int!
|
||||||
|
|
||||||
|
"""Monday of the week (YYYY-MM-DD)"""
|
||||||
|
week: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type CumulativeFlow {
|
||||||
|
dates: [String!]!
|
||||||
|
series: [FlowSeries!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
type DurationStats {
|
||||||
|
averageDays: Float
|
||||||
|
count: Int!
|
||||||
|
|
||||||
|
"""85% of tickets take at most this long"""
|
||||||
|
p85Days: Float
|
||||||
|
}
|
||||||
|
|
||||||
|
type FlowSeries {
|
||||||
|
counts: [Int!]!
|
||||||
|
status: String!
|
||||||
|
}
|
||||||
|
|
||||||
type Issue {
|
type Issue {
|
||||||
"""
|
"""Statuses this ticket can move to next (project's workflow)"""
|
||||||
Statuses this ticket can move to next
|
allowedTransitions: [String!]!
|
||||||
"""
|
|
||||||
allowedTransitions: [IssueStatus!]!
|
"""An area id"""
|
||||||
area: IssueArea
|
area: String
|
||||||
assignee: UserRef
|
assignee: UserRef
|
||||||
commentCount: Int!
|
commentCount: Int!
|
||||||
comments(limit: Int! = 50, offset: Int! = 0): CommentPage!
|
comments(limit: Int! = 50, offset: Int! = 0): CommentPage!
|
||||||
@@ -139,21 +175,15 @@ type Issue {
|
|||||||
dueDate: String
|
dueDate: String
|
||||||
figmaUrl: String
|
figmaUrl: String
|
||||||
|
|
||||||
"""
|
"""History of this ticket"""
|
||||||
History of this ticket
|
|
||||||
"""
|
|
||||||
history(limit: Int! = 50): [Activity!]!
|
history(limit: Int! = 50): [Activity!]!
|
||||||
id: ID!
|
id: ID!
|
||||||
|
|
||||||
"""
|
"""Human key, e.g. TS-42"""
|
||||||
Human key, e.g. TS-42
|
|
||||||
"""
|
|
||||||
key: String!
|
key: String!
|
||||||
labels: [String!]!
|
labels: [String!]!
|
||||||
|
|
||||||
"""
|
"""Linked tickets I can see"""
|
||||||
Linked tickets I can see
|
|
||||||
"""
|
|
||||||
linkedIssues: [Issue!]!
|
linkedIssues: [Issue!]!
|
||||||
links: [IssueLink!]!
|
links: [IssueLink!]!
|
||||||
number: Int!
|
number: Int!
|
||||||
@@ -162,26 +192,19 @@ type Issue {
|
|||||||
projectKey: String!
|
projectKey: String!
|
||||||
release: ReleaseRef
|
release: ReleaseRef
|
||||||
reporter: UserRef!
|
reporter: UserRef!
|
||||||
status: IssueStatus!
|
|
||||||
|
"""A status id of the project's settings"""
|
||||||
|
status: String!
|
||||||
|
statusCategory: StatusCategory!
|
||||||
storyPoints: Int
|
storyPoints: Int
|
||||||
title: String!
|
title: String!
|
||||||
type: IssueType!
|
|
||||||
|
"""A ticket type id of the project's settings"""
|
||||||
|
type: String!
|
||||||
updatedAt: String!
|
updatedAt: String!
|
||||||
version: Int!
|
version: Int!
|
||||||
}
|
}
|
||||||
|
|
||||||
"""
|
|
||||||
Team/layer: links FE ↔ BE ↔ QA work
|
|
||||||
"""
|
|
||||||
enum IssueArea {
|
|
||||||
BACKEND
|
|
||||||
DESIGN
|
|
||||||
DEVOPS
|
|
||||||
FRONTEND
|
|
||||||
MOBILE
|
|
||||||
QA
|
|
||||||
}
|
|
||||||
|
|
||||||
type IssueChange {
|
type IssueChange {
|
||||||
action: String!
|
action: String!
|
||||||
actorId: ID!
|
actorId: ID!
|
||||||
@@ -190,26 +213,23 @@ type IssueChange {
|
|||||||
}
|
}
|
||||||
|
|
||||||
input IssueFilterInput {
|
input IssueFilterInput {
|
||||||
areas: [IssueArea!]
|
areas: [String!]
|
||||||
assigneeId: ID
|
assigneeId: ID
|
||||||
|
categories: [StatusCategory!]
|
||||||
labels: [String!]
|
labels: [String!]
|
||||||
priorities: [IssuePriority!]
|
priorities: [IssuePriority!]
|
||||||
projectId: ID!
|
projectId: ID!
|
||||||
releaseId: ID
|
releaseId: ID
|
||||||
|
|
||||||
"""
|
"""Matches key, title or description"""
|
||||||
Matches key, title or description
|
|
||||||
"""
|
|
||||||
search: String
|
search: String
|
||||||
statuses: [IssueStatus!]
|
statuses: [String!]
|
||||||
types: [IssueType!]
|
types: [String!]
|
||||||
unassigned: Boolean
|
unassigned: Boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type IssueLink {
|
type IssueLink {
|
||||||
"""
|
"""OUTWARD: this issue → other. INWARD: other → this issue."""
|
||||||
OUTWARD: this issue → other. INWARD: other → this issue.
|
|
||||||
"""
|
|
||||||
direction: LinkDirection!
|
direction: LinkDirection!
|
||||||
issueId: ID!
|
issueId: ID!
|
||||||
issueKey: String!
|
issueKey: String!
|
||||||
@@ -237,24 +257,20 @@ enum IssuePriority {
|
|||||||
MEDIUM
|
MEDIUM
|
||||||
}
|
}
|
||||||
|
|
||||||
enum IssueStatus {
|
type IssueTypeOption {
|
||||||
BACKLOG
|
archived: Boolean!
|
||||||
CANCELLED
|
color: String!
|
||||||
DONE
|
icon: String!
|
||||||
IN_PROGRESS
|
id: String!
|
||||||
IN_QA
|
name: String!
|
||||||
IN_REVIEW
|
|
||||||
QA_FAILED
|
|
||||||
READY_FOR_QA
|
|
||||||
TODO
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum IssueType {
|
input IssueTypeOptionInput {
|
||||||
BUG
|
archived: Boolean! = false
|
||||||
FEATURE
|
color: String!
|
||||||
QA
|
icon: String!
|
||||||
TASK
|
id: String!
|
||||||
UI_CHANGE
|
name: String!
|
||||||
}
|
}
|
||||||
|
|
||||||
enum LinkDirection {
|
enum LinkDirection {
|
||||||
@@ -269,53 +285,44 @@ input LinkIssuesInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Me {
|
type Me {
|
||||||
"""
|
"""Roles inside the task-stream OAuth client"""
|
||||||
Roles inside the task-stream OAuth client
|
|
||||||
"""
|
|
||||||
appRoles: [String!]!
|
appRoles: [String!]!
|
||||||
email: String!
|
email: String!
|
||||||
id: ID!
|
id: ID!
|
||||||
isActive: Boolean!
|
isActive: Boolean!
|
||||||
name: String!
|
name: String!
|
||||||
|
|
||||||
"""
|
"""Global roles in Pireon"""
|
||||||
Global roles in Pireon
|
|
||||||
"""
|
|
||||||
roles: [String!]!
|
roles: [String!]!
|
||||||
}
|
}
|
||||||
|
|
||||||
type Mutation {
|
type Mutation {
|
||||||
addComment(body: String!, issueId: ID!): CommandResult!
|
addComment(body: String!, issueId: ID!): CommandResult!
|
||||||
|
|
||||||
"""
|
"""Add a Pireon user to the project"""
|
||||||
Add a Pireon user to the project
|
|
||||||
"""
|
|
||||||
addProjectMember(input: ProjectMemberInput!): CommandResult!
|
addProjectMember(input: ProjectMemberInput!): CommandResult!
|
||||||
archiveRelease(releaseId: ID!): CommandResult!
|
archiveRelease(releaseId: ID!): CommandResult!
|
||||||
|
|
||||||
"""
|
"""Assign (or unassign with null) a project member"""
|
||||||
Assign (or unassign with null) a project member
|
|
||||||
"""
|
|
||||||
assignIssue(assigneeId: ID, issueId: ID!): CommandResult!
|
assignIssue(assigneeId: ID, issueId: ID!): CommandResult!
|
||||||
|
|
||||||
"""
|
"""Move a ticket through the workflow (incl. QA)"""
|
||||||
Move a ticket through the workflow (incl. QA)
|
|
||||||
"""
|
|
||||||
changeIssueStatus(input: ChangeIssueStatusInput!): CommandResult!
|
changeIssueStatus(input: ChangeIssueStatusInput!): CommandResult!
|
||||||
changeProjectMemberRole(input: ProjectMemberInput!): CommandResult!
|
changeProjectMemberRole(input: ProjectMemberInput!): CommandResult!
|
||||||
createIssue(input: CreateIssueInput!): CommandResult!
|
createIssue(input: CreateIssueInput!): CommandResult!
|
||||||
createProject(input: CreateProjectInput!): CommandResult!
|
createProject(input: CreateProjectInput!): CommandResult!
|
||||||
createRelease(input: CreateReleaseInput!): CommandResult!
|
createRelease(input: CreateReleaseInput!): CommandResult!
|
||||||
|
|
||||||
"""
|
"""Author or project admin"""
|
||||||
Author or project admin
|
|
||||||
"""
|
|
||||||
deleteComment(commentId: ID!): CommandResult!
|
deleteComment(commentId: ID!): CommandResult!
|
||||||
editComment(body: String!, commentId: ID!): CommandResult!
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Relate tickets, e.g. FE DEPENDS_ON BE, QA TESTS FE
|
Delete the project (owner only), with its tickets, comments and releases. `confirmKey` must be the project key.
|
||||||
"""
|
"""
|
||||||
|
deleteProject(confirmKey: String!, projectId: ID!): CommandResult!
|
||||||
|
editComment(body: String!, commentId: ID!): CommandResult!
|
||||||
|
|
||||||
|
"""Relate tickets, e.g. FE DEPENDS_ON BE, QA TESTS FE"""
|
||||||
linkIssues(input: LinkIssuesInput!): CommandResult!
|
linkIssues(input: LinkIssuesInput!): CommandResult!
|
||||||
|
|
||||||
"""
|
"""
|
||||||
@@ -324,43 +331,65 @@ type Mutation {
|
|||||||
publishRelease(force: Boolean! = false, releaseId: ID!): CommandResult!
|
publishRelease(force: Boolean! = false, releaseId: ID!): CommandResult!
|
||||||
removeProjectMember(projectId: ID!, userId: ID!): CommandResult!
|
removeProjectMember(projectId: ID!, userId: ID!): CommandResult!
|
||||||
|
|
||||||
"""
|
"""Put a ticket in a release (null removes it)"""
|
||||||
Put a ticket in a release (null removes it)
|
|
||||||
"""
|
|
||||||
setIssueRelease(issueId: ID!, releaseId: ID): CommandResult!
|
setIssueRelease(issueId: ID!, releaseId: ID): CommandResult!
|
||||||
unlinkIssues(input: LinkIssuesInput!): CommandResult!
|
unlinkIssues(input: LinkIssuesInput!): CommandResult!
|
||||||
updateIssue(input: UpdateIssueInput!): CommandResult!
|
updateIssue(input: UpdateIssueInput!): CommandResult!
|
||||||
updateProject(input: UpdateProjectInput!): CommandResult!
|
updateProject(input: UpdateProjectInput!): CommandResult!
|
||||||
|
|
||||||
|
"""
|
||||||
|
Replace statuses, transitions, ticket types and areas (owners and admins). Values can be archived, not deleted.
|
||||||
|
"""
|
||||||
|
updateProjectSettings(projectId: ID!, settings: ProjectSettingsInput!): CommandResult!
|
||||||
updateRelease(input: UpdateReleaseInput!): CommandResult!
|
updateRelease(input: UpdateReleaseInput!): CommandResult!
|
||||||
}
|
}
|
||||||
|
|
||||||
type Project {
|
type Project {
|
||||||
"""
|
"""
|
||||||
Kanban board, one column per status
|
Statistics of the last `days` days (7-365): created vs resolved, cumulative flow, cycle/lead time, throughput and QA
|
||||||
"""
|
"""
|
||||||
board(
|
analytics(days: Int! = 90): ProjectAnalytics!
|
||||||
area: IssueArea
|
|
||||||
assigneeId: ID
|
"""Kanban board, one column per status"""
|
||||||
perColumn: Int! = 50
|
board(area: String, assigneeId: ID, perColumn: Int! = 50, releaseId: ID): [BoardColumn!]!
|
||||||
releaseId: ID
|
|
||||||
): [BoardColumn!]!
|
|
||||||
createdAt: String!
|
createdAt: String!
|
||||||
description: String
|
description: String
|
||||||
id: ID!
|
id: ID!
|
||||||
|
|
||||||
|
"""Anyone can read it, also without signing in"""
|
||||||
|
isPublic: Boolean!
|
||||||
key: String!
|
key: String!
|
||||||
members: [ProjectMember!]!
|
members: [ProjectMember!]!
|
||||||
name: String!
|
name: String!
|
||||||
owner: UserRef!
|
owner: UserRef!
|
||||||
releases: [Release!]!
|
releases: [Release!]!
|
||||||
|
settings: ProjectSettings!
|
||||||
|
|
||||||
"""
|
"""Project tracking dashboard"""
|
||||||
Project tracking dashboard
|
|
||||||
"""
|
|
||||||
stats: ProjectStats!
|
stats: ProjectStats!
|
||||||
updatedAt: String!
|
updatedAt: String!
|
||||||
version: Int!
|
version: Int!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ProjectAnalytics {
|
||||||
|
createdVsResolved: [CreatedResolved!]!
|
||||||
|
|
||||||
|
"""Tickets in each status at the end of every day"""
|
||||||
|
cumulativeFlow: CumulativeFlow!
|
||||||
|
|
||||||
|
"""Start of work → done"""
|
||||||
|
cycleTime: DurationStats!
|
||||||
|
from: String!
|
||||||
|
|
||||||
|
"""Creation → done"""
|
||||||
|
leadTime: DurationStats!
|
||||||
|
qa: QaStats!
|
||||||
|
|
||||||
|
"""Finished per person and week"""
|
||||||
|
throughput: [Throughput!]!
|
||||||
|
to: String!
|
||||||
|
}
|
||||||
|
|
||||||
type ProjectChange {
|
type ProjectChange {
|
||||||
action: String!
|
action: String!
|
||||||
actorId: ID!
|
actorId: ID!
|
||||||
@@ -377,9 +406,7 @@ input ProjectMemberInput {
|
|||||||
projectId: ID!
|
projectId: ID!
|
||||||
role: ProjectRole! = MEMBER
|
role: ProjectRole! = MEMBER
|
||||||
|
|
||||||
"""
|
"""Pireon user id"""
|
||||||
Pireon user id
|
|
||||||
"""
|
|
||||||
userId: ID!
|
userId: ID!
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,12 +417,28 @@ enum ProjectRole {
|
|||||||
VIEWER
|
VIEWER
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"""Statuses (board order), transitions, ticket types and areas"""
|
||||||
|
type ProjectSettings {
|
||||||
|
areas: [AreaOption!]!
|
||||||
|
issueTypes: [IssueTypeOption!]!
|
||||||
|
statuses: [WorkflowStatus!]!
|
||||||
|
transitions: [StatusTransitions!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
"""
|
||||||
|
The whole configuration (replaces the previous one). Statuses go in board order.
|
||||||
|
"""
|
||||||
|
input ProjectSettingsInput {
|
||||||
|
areas: [AreaOptionInput!]!
|
||||||
|
issueTypes: [IssueTypeOptionInput!]!
|
||||||
|
statuses: [WorkflowStatusInput!]!
|
||||||
|
transitions: [StatusTransitionsInput!]!
|
||||||
|
}
|
||||||
|
|
||||||
type ProjectStats {
|
type ProjectStats {
|
||||||
byArea: [CountByKey!]!
|
byArea: [CountByKey!]!
|
||||||
|
|
||||||
"""
|
"""Open tickets per person"""
|
||||||
Open tickets per person
|
|
||||||
"""
|
|
||||||
byAssignee: [CountByAssignee!]!
|
byAssignee: [CountByAssignee!]!
|
||||||
byPriority: [CountByKey!]!
|
byPriority: [CountByKey!]!
|
||||||
byStatus: [CountByStatus!]!
|
byStatus: [CountByStatus!]!
|
||||||
@@ -404,63 +447,64 @@ type ProjectStats {
|
|||||||
total: Int!
|
total: Int!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type QaStats {
|
||||||
|
failed: Int!
|
||||||
|
failureRate: Float
|
||||||
|
passed: Int!
|
||||||
|
}
|
||||||
|
|
||||||
type Query {
|
type Query {
|
||||||
"""
|
"""Project activity feed (newest first)"""
|
||||||
Project activity feed (newest first)
|
|
||||||
"""
|
|
||||||
activity(
|
activity(
|
||||||
"""
|
"""ISO date cursor"""
|
||||||
ISO date cursor
|
|
||||||
"""
|
|
||||||
before: String
|
before: String
|
||||||
issueId: ID
|
issueId: ID
|
||||||
limit: Int! = 50
|
limit: Int! = 50
|
||||||
projectId: ID!
|
projectId: ID!
|
||||||
): [Activity!]!
|
): [Activity!]!
|
||||||
issue(
|
issue(
|
||||||
"""
|
"""Issue id or key (e.g. "TS-42")"""
|
||||||
Issue id or key (e.g. "TS-42")
|
|
||||||
"""
|
|
||||||
idOrKey: String!
|
idOrKey: String!
|
||||||
): Issue!
|
): Issue!
|
||||||
issues(
|
issues(filter: IssueFilterInput!, limit: Int! = 50, offset: Int! = 0): IssuePage!
|
||||||
filter: IssueFilterInput!
|
|
||||||
limit: Int! = 50
|
|
||||||
offset: Int! = 0
|
|
||||||
): IssuePage!
|
|
||||||
|
|
||||||
"""
|
"""The authenticated user"""
|
||||||
The authenticated user
|
|
||||||
"""
|
|
||||||
me: Me!
|
me: Me!
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Projects where I am a member
|
Tickets across all my projects: assigned to me (default) or any (e.g. categories [QA])
|
||||||
"""
|
"""
|
||||||
|
myIssues(assignedToMe: Boolean! = true, categories: [StatusCategory!], limit: Int! = 50, offset: Int! = 0, statuses: [String!]): IssuePage!
|
||||||
|
|
||||||
|
"""Projects where I am a member"""
|
||||||
myProjects: [Project!]!
|
myProjects: [Project!]!
|
||||||
|
|
||||||
|
"""A project of mine, or any public one (no sign-in needed)"""
|
||||||
project(
|
project(
|
||||||
"""
|
"""Project id or key (e.g. "TS")"""
|
||||||
Project id or key (e.g. "TS")
|
|
||||||
"""
|
|
||||||
idOrKey: String!
|
idOrKey: String!
|
||||||
): Project!
|
): Project!
|
||||||
|
|
||||||
|
"""Projects anyone can read, also without signing in"""
|
||||||
|
publicProjects: [Project!]!
|
||||||
release(id: ID!): Release!
|
release(id: ID!): Release!
|
||||||
|
|
||||||
|
"""Planned releases of all my projects, soonest target date first"""
|
||||||
|
upcomingReleases(limit: Int! = 10): [Release!]!
|
||||||
user(id: String!): User
|
user(id: String!): User
|
||||||
|
|
||||||
"""
|
"""Search users by name or email (to assign / add members)"""
|
||||||
Search users by name or email (to assign / add members)
|
|
||||||
"""
|
|
||||||
users(limit: Int! = 20, search: String! = ""): [User!]!
|
users(limit: Int! = 20, search: String! = ""): [User!]!
|
||||||
}
|
}
|
||||||
|
|
||||||
type Release {
|
type Release {
|
||||||
|
"""Scope vs finished tickets, day by day"""
|
||||||
|
burnup: ReleaseBurnup!
|
||||||
createdAt: String!
|
createdAt: String!
|
||||||
description: String
|
description: String
|
||||||
id: ID!
|
id: ID!
|
||||||
|
|
||||||
"""
|
"""Tickets included in this release"""
|
||||||
Tickets included in this release
|
|
||||||
"""
|
|
||||||
issues(limit: Int! = 100, offset: Int! = 0): IssuePage!
|
issues(limit: Int! = 100, offset: Int! = 0): IssuePage!
|
||||||
name: String!
|
name: String!
|
||||||
progress: ReleaseProgress!
|
progress: ReleaseProgress!
|
||||||
@@ -471,6 +515,16 @@ type Release {
|
|||||||
version: Int!
|
version: Int!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ReleaseBurnup {
|
||||||
|
dates: [String!]!
|
||||||
|
done: [Int!]!
|
||||||
|
releaseId: ID!
|
||||||
|
|
||||||
|
"""Tickets in the release (not cancelled)"""
|
||||||
|
scope: [Int!]!
|
||||||
|
targetDate: String
|
||||||
|
}
|
||||||
|
|
||||||
type ReleaseChange {
|
type ReleaseChange {
|
||||||
action: String!
|
action: String!
|
||||||
actorId: ID!
|
actorId: ID!
|
||||||
@@ -496,45 +550,66 @@ enum ReleaseStatus {
|
|||||||
RELEASED
|
RELEASED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"""
|
||||||
|
What a status means, whatever each project calls it. DONE and CANCELLED are finished.
|
||||||
|
"""
|
||||||
|
enum StatusCategory {
|
||||||
|
CANCELLED
|
||||||
|
DONE
|
||||||
|
IN_PROGRESS
|
||||||
|
QA
|
||||||
|
TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
type StatusTransitions {
|
||||||
|
from: String!
|
||||||
|
to: [String!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
input StatusTransitionsInput {
|
||||||
|
from: String!
|
||||||
|
to: [String!]!
|
||||||
|
}
|
||||||
|
|
||||||
type Subscription {
|
type Subscription {
|
||||||
"""
|
"""Live activity feed"""
|
||||||
Live activity feed
|
|
||||||
"""
|
|
||||||
activityAdded(projectId: ID!): Activity!
|
activityAdded(projectId: ID!): Activity!
|
||||||
commentChanged(issueId: ID!): CommentChange!
|
commentChanged(issueId: ID!): CommentChange!
|
||||||
|
|
||||||
"""
|
"""Tickets created/updated/moved in a project (board in realtime)"""
|
||||||
Tickets created/updated/moved in a project (board in realtime)
|
|
||||||
"""
|
|
||||||
issueChanged(projectId: ID!): IssueChange!
|
issueChanged(projectId: ID!): IssueChange!
|
||||||
|
|
||||||
"""
|
"""Projects I am added to / removed from"""
|
||||||
Projects I am added to / removed from
|
|
||||||
"""
|
|
||||||
myProjectsChanged: ProjectChange!
|
myProjectsChanged: ProjectChange!
|
||||||
projectChanged(projectId: ID!): ProjectChange!
|
projectChanged(projectId: ID!): ProjectChange!
|
||||||
releaseChanged(projectId: ID!): ReleaseChange!
|
releaseChanged(projectId: ID!): ReleaseChange!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Throughput {
|
||||||
|
count: Int!
|
||||||
|
user: UserRef
|
||||||
|
week: String!
|
||||||
|
}
|
||||||
|
|
||||||
input UpdateIssueInput {
|
input UpdateIssueInput {
|
||||||
area: IssueArea
|
"""An area id"""
|
||||||
|
area: String
|
||||||
description: String
|
description: String
|
||||||
dueDate: String
|
dueDate: String
|
||||||
|
|
||||||
"""
|
"""Figma frame for UI changes"""
|
||||||
Figma frame for UI changes
|
|
||||||
"""
|
|
||||||
figmaUrl: String
|
figmaUrl: String
|
||||||
issueId: ID!
|
issueId: ID!
|
||||||
labels: [String!]
|
labels: [String!]
|
||||||
priority: IssuePriority
|
priority: IssuePriority
|
||||||
storyPoints: Int
|
storyPoints: Int
|
||||||
title: String
|
title: String
|
||||||
type: IssueType
|
type: String
|
||||||
}
|
}
|
||||||
|
|
||||||
input UpdateProjectInput {
|
input UpdateProjectInput {
|
||||||
description: String
|
description: String
|
||||||
|
isPublic: Boolean
|
||||||
name: String
|
name: String
|
||||||
projectId: ID!
|
projectId: ID!
|
||||||
}
|
}
|
||||||
@@ -546,9 +621,7 @@ input UpdateReleaseInput {
|
|||||||
targetDate: String
|
targetDate: String
|
||||||
}
|
}
|
||||||
|
|
||||||
"""
|
"""User managed by the Pireon identity server"""
|
||||||
User managed by the Pireon identity server
|
|
||||||
"""
|
|
||||||
type User {
|
type User {
|
||||||
email: String!
|
email: String!
|
||||||
id: ID!
|
id: ID!
|
||||||
@@ -560,3 +633,28 @@ type UserRef {
|
|||||||
id: ID!
|
id: ID!
|
||||||
name: String!
|
name: String!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WorkflowStatus {
|
||||||
|
"""No new tickets or transitions into it"""
|
||||||
|
archived: Boolean!
|
||||||
|
category: StatusCategory!
|
||||||
|
|
||||||
|
"""Colour token: slate, violet, green…"""
|
||||||
|
color: String!
|
||||||
|
|
||||||
|
"""Stable id stored on tickets, e.g. IN_REVIEW"""
|
||||||
|
id: String!
|
||||||
|
name: String!
|
||||||
|
|
||||||
|
"""Entering it needs a reason"""
|
||||||
|
requiresReason: Boolean!
|
||||||
|
}
|
||||||
|
|
||||||
|
input WorkflowStatusInput {
|
||||||
|
archived: Boolean! = false
|
||||||
|
category: StatusCategory!
|
||||||
|
color: String!
|
||||||
|
id: String!
|
||||||
|
name: String!
|
||||||
|
requiresReason: Boolean! = false
|
||||||
|
}
|
||||||
@@ -23,9 +23,13 @@ export const configSchema = Joi.object({
|
|||||||
// Pireon identity server
|
// Pireon identity server
|
||||||
PIREON_BASE_URL: Joi.string().uri().default('https://oauth.pireon.dev'),
|
PIREON_BASE_URL: Joi.string().uri().default('https://oauth.pireon.dev'),
|
||||||
PIREON_WEBHOOK_SECRET: Joi.string().allow('').optional(),
|
PIREON_WEBHOOK_SECRET: Joi.string().allow('').optional(),
|
||||||
// OAuth client of task-stream in Pireon, used by the web app to sign in.
|
// OAuth clients of task-stream in Pireon (each Pireon client allows one grant type):
|
||||||
|
// authorization_code for "Sign in with Pireon" / Swagger UI…
|
||||||
PIREON_CLIENT_ID: Joi.string().allow('').optional(),
|
PIREON_CLIENT_ID: Joi.string().allow('').optional(),
|
||||||
PIREON_CLIENT_SECRET: Joi.string().allow('').optional(),
|
PIREON_CLIENT_SECRET: Joi.string().allow('').optional(),
|
||||||
|
// …and password for email + password (defaults to the client above).
|
||||||
|
PIREON_PASSWORD_CLIENT_ID: Joi.string().allow('').optional(),
|
||||||
|
PIREON_PASSWORD_CLIENT_SECRET: Joi.string().allow('').optional(),
|
||||||
PIREON_SCOPES: Joi.string().default('users:read'),
|
PIREON_SCOPES: Joi.string().default('users:read'),
|
||||||
PIREON_PASSWORD_GRANT: Joi.boolean().default(true),
|
PIREON_PASSWORD_GRANT: Joi.boolean().default(true),
|
||||||
PIREON_TIMEOUT_MS: Joi.number().default(5000),
|
PIREON_TIMEOUT_MS: Joi.number().default(5000),
|
||||||
@@ -33,4 +37,9 @@ export const configSchema = Joi.object({
|
|||||||
|
|
||||||
GRAPHQL_PLAYGROUND: Joi.boolean().default(true),
|
GRAPHQL_PLAYGROUND: Joi.boolean().default(true),
|
||||||
GRAPHQL_SCHEMA_FILE: Joi.string().allow('').default('apps/api/schema.gql'),
|
GRAPHQL_SCHEMA_FILE: Joi.string().allow('').default('apps/api/schema.gql'),
|
||||||
|
|
||||||
|
// REST + OpenAPI for other apps: Swagger UI at /api/docs, JSON at /api/docs-json.
|
||||||
|
SWAGGER_ENABLED: Joi.boolean().default(true),
|
||||||
|
// Where to write the OpenAPI document on startup ('' = don't), like the GraphQL SDL.
|
||||||
|
OPENAPI_FILE: Joi.string().allow('').default('apps/api/openapi.json'),
|
||||||
});
|
});
|
||||||
|
|||||||
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,
|
ServiceUnavailableException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectConnection } from '@nestjs/mongoose';
|
import { InjectConnection } from '@nestjs/mongoose';
|
||||||
|
import {
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiOperation,
|
||||||
|
ApiServiceUnavailableResponse,
|
||||||
|
ApiTags,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
import { Public } from '@task-stream/api/identity/presenters';
|
import { Public } from '@task-stream/api/identity/presenters';
|
||||||
import { REDIS_CLIENT } from '@task-stream/api/shared/infrastructure';
|
import { REDIS_CLIENT } from '@task-stream/api/shared/infrastructure';
|
||||||
import Redis from 'ioredis';
|
import Redis from 'ioredis';
|
||||||
import { Connection } from 'mongoose';
|
import { Connection } from 'mongoose';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
@ApiTags('Health')
|
||||||
@Controller('health')
|
@Controller('health')
|
||||||
export class HealthController {
|
export class HealthController {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -21,6 +28,13 @@ export class HealthController {
|
|||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Get()
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'API and databases status' })
|
||||||
|
@ApiOkResponse({
|
||||||
|
schema: {
|
||||||
|
example: { status: 'ok', postgres: 'up', mongo: 'up', redis: 'up' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiServiceUnavailableResponse({ description: 'A database is down' })
|
||||||
async check() {
|
async check() {
|
||||||
const checks = await Promise.allSettled([
|
const checks = await Promise.allSettled([
|
||||||
this.postgres.query('SELECT 1'),
|
this.postgres.query('SELECT 1'),
|
||||||
|
|||||||
158
apps/api/src/app/openapi.ts
Normal file
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 { NestFactory } from '@nestjs/core';
|
||||||
import { DomainErrorFilter } from '@task-stream/api/shared/presenters';
|
|
||||||
import { AppModule } from './app/app.module';
|
import { AppModule } from './app/app.module';
|
||||||
|
import { configureApp } from './app/configure';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
// rawBody: needed to verify Pireon webhook signatures.
|
// rawBody: needed to verify Pireon webhook signatures.
|
||||||
const app = await NestFactory.create(AppModule, { rawBody: true });
|
const app = await NestFactory.create(AppModule, { rawBody: true });
|
||||||
app.setGlobalPrefix('api');
|
configureApp(app);
|
||||||
app.enableCors();
|
|
||||||
app.enableShutdownHooks();
|
|
||||||
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
|
|
||||||
app.useGlobalFilters(new DomainErrorFilter());
|
|
||||||
|
|
||||||
const port = process.env['PORT'] || 3000;
|
const port = process.env['PORT'] || 3000;
|
||||||
await app.listen(port);
|
await app.listen(port);
|
||||||
Logger.log(
|
Logger.log(
|
||||||
`🚀 GraphQL: http://localhost:${port}/graphql (ws for subscriptions)`,
|
`🚀 GraphQL: http://localhost:${port}/graphql (ws for subscriptions)`,
|
||||||
);
|
);
|
||||||
|
Logger.log(`📘 REST: http://localhost:${port}/api/docs (OpenAPI)`);
|
||||||
Logger.log(`❤️ Health: http://localhost:${port}/api/health`);
|
Logger.log(`❤️ Health: http://localhost:${port}/api/health`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
7
apps/mcp/eslint.config.mjs
Normal file
7
apps/mcp/eslint.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import baseConfig from '../../eslint.config.mjs';
|
||||||
|
|
||||||
|
export default [
|
||||||
|
...baseConfig,
|
||||||
|
// Generated from apps/api/openapi.json (nx run mcp:openapi-types).
|
||||||
|
{ ignores: ['src/app/api/taskstream.schema.ts'] },
|
||||||
|
];
|
||||||
10
apps/mcp/jest.config.cts
Normal file
10
apps/mcp/jest.config.cts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
module.exports = {
|
||||||
|
displayName: 'mcp',
|
||||||
|
preset: '../../jest.preset.js',
|
||||||
|
testEnvironment: 'node',
|
||||||
|
transform: {
|
||||||
|
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
|
||||||
|
},
|
||||||
|
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||||
|
coverageDirectory: '../../coverage/apps/mcp',
|
||||||
|
};
|
||||||
21
apps/mcp/package.json
Normal file
21
apps/mcp/package.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "mcp",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"description": "Runtime dependencies of the MCP image. Nx uses this list for dist/apps/mcp/package.json (pruned lockfile for Docker).",
|
||||||
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/server": "^2.3.1",
|
||||||
|
"@nestjs-mcp/server": "^2.0.0",
|
||||||
|
"@nestjs/common": "^11.0.0",
|
||||||
|
"@nestjs/config": "^4.0.4",
|
||||||
|
"@nestjs/core": "^11.0.0",
|
||||||
|
"@nestjs/platform-express": "^11.0.0",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.15.1",
|
||||||
|
"openapi-fetch": "^0.17.0",
|
||||||
|
"reflect-metadata": "^0.2.0",
|
||||||
|
"rxjs": "^7.8.0",
|
||||||
|
"tslib": "^2.8.1",
|
||||||
|
"zod": "^4.6.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
66
apps/mcp/project.json
Normal file
66
apps/mcp/project.json
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
{
|
||||||
|
"name": "mcp",
|
||||||
|
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||||
|
"sourceRoot": "apps/mcp/src",
|
||||||
|
"projectType": "application",
|
||||||
|
"tags": ["scope:mcp", "type:app"],
|
||||||
|
"targets": {
|
||||||
|
"prune-lockfile": {
|
||||||
|
"dependsOn": ["build"],
|
||||||
|
"cache": true,
|
||||||
|
"executor": "@nx/js:prune-lockfile",
|
||||||
|
"outputs": [
|
||||||
|
"{workspaceRoot}/dist/apps/mcp/package.json",
|
||||||
|
"{workspaceRoot}/dist/apps/mcp/package-lock.json"
|
||||||
|
],
|
||||||
|
"options": {
|
||||||
|
"buildTarget": "build"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"copy-workspace-modules": {
|
||||||
|
"dependsOn": ["build"],
|
||||||
|
"cache": true,
|
||||||
|
"outputs": ["{workspaceRoot}/dist/apps/mcp/workspace_modules"],
|
||||||
|
"executor": "@nx/js:copy-workspace-modules",
|
||||||
|
"options": {
|
||||||
|
"buildTarget": "build"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"prune": {
|
||||||
|
"dependsOn": ["prune-lockfile", "copy-workspace-modules"],
|
||||||
|
"executor": "nx:noop"
|
||||||
|
},
|
||||||
|
"serve": {
|
||||||
|
"continuous": true,
|
||||||
|
"executor": "@nx/js:node",
|
||||||
|
"defaultConfiguration": "development",
|
||||||
|
"dependsOn": ["build"],
|
||||||
|
"options": {
|
||||||
|
"buildTarget": "mcp:build",
|
||||||
|
"runBuildTargetDependencies": false
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"development": {
|
||||||
|
"buildTarget": "mcp:build:development"
|
||||||
|
},
|
||||||
|
"production": {
|
||||||
|
"buildTarget": "mcp:build:production"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"options": {
|
||||||
|
"passWithNoTests": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"openapi-types": {
|
||||||
|
"executor": "nx:run-commands",
|
||||||
|
"inputs": ["{workspaceRoot}/apps/api/openapi.json"],
|
||||||
|
"outputs": ["{projectRoot}/src/app/api/taskstream.schema.ts"],
|
||||||
|
"cache": true,
|
||||||
|
"options": {
|
||||||
|
"command": "npx -y openapi-typescript@7.13.0 apps/api/openapi.json -o apps/mcp/src/app/api/taskstream.schema.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
58
apps/mcp/src/app/api/taskstream-api.ts
Normal file
58
apps/mcp/src/app/api/taskstream-api.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import type { McpContext } from '@nestjs-mcp/server';
|
||||||
|
import createClient, { type Client } from 'openapi-fetch';
|
||||||
|
import type { components, paths } from './taskstream.schema';
|
||||||
|
|
||||||
|
export type Schemas = components['schemas'];
|
||||||
|
export type TaskStreamClient = Client<paths>;
|
||||||
|
|
||||||
|
export const TASKSTREAM_API_URL = Symbol('TASKSTREAM_API_URL');
|
||||||
|
|
||||||
|
/** An answer of the API other than 2xx, with its message for the model. */
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly status: number,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The task-stream REST API (typed from apps/api/openapi.json). Each MCP call
|
||||||
|
* is made with the caller's own Pireon token, forwarded from its
|
||||||
|
* `Authorization` header: the MCP keeps no credentials, and every user sees
|
||||||
|
* exactly what the API lets them see (public projects only, without a token).
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class TaskStreamApi {
|
||||||
|
constructor(@Inject(TASKSTREAM_API_URL) private readonly baseUrl: string) {}
|
||||||
|
|
||||||
|
/** A client acting as the caller of this MCP request. */
|
||||||
|
as(ctx: McpContext): TaskStreamClient {
|
||||||
|
const authorization = ctx.headers.authorization;
|
||||||
|
return createClient<paths>({
|
||||||
|
baseUrl: this.baseUrl,
|
||||||
|
headers: authorization ? { authorization } : {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The body of a successful answer; otherwise an ApiError with the API's message. */
|
||||||
|
async data<T>(
|
||||||
|
request: Promise<{ data?: T; error?: unknown; response: Response }>,
|
||||||
|
): Promise<T> {
|
||||||
|
const { data, error, response } = await request;
|
||||||
|
if (response.ok) return data as T;
|
||||||
|
throw new ApiError(response.status, describe(response.status, error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function describe(status: number, error: unknown): string {
|
||||||
|
const body = (error ?? {}) as { message?: unknown; code?: unknown };
|
||||||
|
const message = Array.isArray(body.message)
|
||||||
|
? body.message.join('; ')
|
||||||
|
: String(body.message ?? `HTTP ${status}`);
|
||||||
|
if (status === 401)
|
||||||
|
return `${message}. Este MCP usa el token de Pireon del cliente: configúralo con el header "Authorization: Bearer <token>" (sin token solo se ven proyectos públicos).`;
|
||||||
|
return body.code ? `${message} (${String(body.code)})` : message;
|
||||||
|
}
|
||||||
4292
apps/mcp/src/app/api/taskstream.schema.ts
Normal file
4292
apps/mcp/src/app/api/taskstream.schema.ts
Normal file
File diff suppressed because it is too large
Load Diff
55
apps/mcp/src/app/app.module.ts
Normal file
55
apps/mcp/src/app/app.module.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { McpModule } from '@nestjs-mcp/server';
|
||||||
|
import { TASKSTREAM_API_URL, TaskStreamApi } from './api/taskstream-api';
|
||||||
|
import { HealthController } from './health.controller';
|
||||||
|
import { McpHostsMiddleware } from './mcp-hosts.middleware';
|
||||||
|
import { IssuesResolver } from './tools/issues.resolver';
|
||||||
|
import { ProjectsResolver } from './tools/projects.resolver';
|
||||||
|
import { ReleasesResolver } from './tools/releases.resolver';
|
||||||
|
import { UsersResolver } from './tools/users.resolver';
|
||||||
|
|
||||||
|
const INSTRUCTIONS = `TaskStream: issue tracker (projects, tickets, QA, releases).
|
||||||
|
Projects and tickets go by key (WEB, WEB-12). Statuses, ticket types and areas are per
|
||||||
|
project: read their ids with get_project before creating, updating or moving tickets, and
|
||||||
|
use a ticket's allowedTransitions (get_issue) to move it. Tools act as the user whose Pireon
|
||||||
|
token the client sends; without one, only public projects can be read.`;
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule.forRoot({ isGlobal: true }),
|
||||||
|
McpModule.forRootAsync({
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (config: ConfigService) => ({
|
||||||
|
name: 'taskstream',
|
||||||
|
version: '1.0.0',
|
||||||
|
instructions: INSTRUCTIONS,
|
||||||
|
// The library logs every tool on every request (it builds a server per call):
|
||||||
|
// only warnings and errors unless asked for more.
|
||||||
|
logging: { level: config.get('MCP_LOG_LEVEL', 'warn') },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
controllers: [HealthController],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: TASKSTREAM_API_URL,
|
||||||
|
inject: [ConfigService],
|
||||||
|
// Origin of the API (its routes already start with /api).
|
||||||
|
useFactory: (config: ConfigService) =>
|
||||||
|
config
|
||||||
|
.get<string>('TASKSTREAM_API_URL', 'http://localhost:3000')
|
||||||
|
.replace(/\/+$/, ''),
|
||||||
|
},
|
||||||
|
TaskStreamApi,
|
||||||
|
ProjectsResolver,
|
||||||
|
IssuesResolver,
|
||||||
|
ReleasesResolver,
|
||||||
|
UsersResolver,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule implements NestModule {
|
||||||
|
configure(consumer: MiddlewareConsumer): void {
|
||||||
|
consumer.apply(McpHostsMiddleware).forRoutes('mcp');
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/mcp/src/app/health.controller.ts
Normal file
10
apps/mcp/src/app/health.controller.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
|
||||||
|
/** For Docker / Dokploy healthchecks. */
|
||||||
|
@Controller('health')
|
||||||
|
export class HealthController {
|
||||||
|
@Get()
|
||||||
|
check() {
|
||||||
|
return { status: 'ok' };
|
||||||
|
}
|
||||||
|
}
|
||||||
46
apps/mcp/src/app/mcp-hosts.middleware.ts
Normal file
46
apps/mcp/src/app/mcp-hosts.middleware.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import {
|
||||||
|
localhostAllowedHostnames,
|
||||||
|
validateHostHeader,
|
||||||
|
validateOriginHeader,
|
||||||
|
} from '@modelcontextprotocol/server';
|
||||||
|
import type { NextFunction, Request, Response } from 'express';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Protection against DNS rebinding on /mcp (what the MCP spec asks of HTTP
|
||||||
|
* servers): the Host header must be one of MCP_ALLOWED_HOSTS (default: only
|
||||||
|
* localhost), and a browser's Origin, when sent, too. AI clients calling from
|
||||||
|
* a server send no Origin, so they only need the right domain.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class McpHostsMiddleware implements NestMiddleware {
|
||||||
|
private readonly hosts: string[];
|
||||||
|
|
||||||
|
constructor(config: ConfigService) {
|
||||||
|
const configured = (config.get<string>('MCP_ALLOWED_HOSTS') ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((host) => host.trim().toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
this.hosts = configured.length ? configured : localhostAllowedHostnames();
|
||||||
|
}
|
||||||
|
|
||||||
|
use(req: Request, res: Response, next: NextFunction): void {
|
||||||
|
const error = this.rejection(req);
|
||||||
|
if (!error) return next();
|
||||||
|
res.status(403).json({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: null,
|
||||||
|
error: { code: -32000, message: error },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Why the request is refused, or null when its Host (and Origin, if any) are allowed. */
|
||||||
|
private rejection(req: Request): string | null {
|
||||||
|
const host = validateHostHeader(req.headers.host, this.hosts);
|
||||||
|
if ('message' in host) return host.message;
|
||||||
|
if (!req.headers.origin) return null;
|
||||||
|
const origin = validateOriginHeader(req.headers.origin, this.hosts);
|
||||||
|
return 'message' in origin ? origin.message : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
265
apps/mcp/src/app/mcp.spec.ts
Normal file
265
apps/mcp/src/app/mcp.spec.ts
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import {
|
||||||
|
createServer,
|
||||||
|
IncomingMessage,
|
||||||
|
request as httpRequest,
|
||||||
|
Server,
|
||||||
|
} from 'node:http';
|
||||||
|
import { AddressInfo } from 'node:net';
|
||||||
|
import { TASKSTREAM_API_URL } from './api/taskstream-api';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
|
/** What the fake API received. */
|
||||||
|
interface Received {
|
||||||
|
method: string;
|
||||||
|
url: string;
|
||||||
|
authorization?: string;
|
||||||
|
body: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = {
|
||||||
|
id: 'p1',
|
||||||
|
key: 'WEB',
|
||||||
|
name: 'Web',
|
||||||
|
description: null,
|
||||||
|
isPublic: false,
|
||||||
|
owner: { id: '101', name: 'Ana' },
|
||||||
|
members: [{ user: { id: '101', name: 'Ana' }, role: 'OWNER' }],
|
||||||
|
settings: {
|
||||||
|
statuses: [
|
||||||
|
{
|
||||||
|
id: 'TODO',
|
||||||
|
name: 'Por hacer',
|
||||||
|
category: 'TODO',
|
||||||
|
color: 'slate',
|
||||||
|
requiresReason: false,
|
||||||
|
archived: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
transitions: [],
|
||||||
|
issueTypes: [
|
||||||
|
{ id: 'BUG', name: 'Bug', icon: 'bug', color: 'red', archived: false },
|
||||||
|
],
|
||||||
|
areas: [],
|
||||||
|
},
|
||||||
|
createdAt: '2026-10-01T00:00:00Z',
|
||||||
|
updatedAt: '2026-10-02T00:00:00Z',
|
||||||
|
version: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A stand-in for the task-stream API: answers a few routes and records every call. */
|
||||||
|
function fakeApi(received: Received[]): Server {
|
||||||
|
return createServer(async (req: IncomingMessage, res) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const chunk of req) chunks.push(chunk as Buffer);
|
||||||
|
const text = Buffer.concat(chunks).toString();
|
||||||
|
received.push({
|
||||||
|
method: req.method ?? '',
|
||||||
|
url: req.url ?? '',
|
||||||
|
authorization: req.headers.authorization,
|
||||||
|
body: text ? JSON.parse(text) : undefined,
|
||||||
|
});
|
||||||
|
const reply = (status: number, body: unknown) => {
|
||||||
|
res.writeHead(status, { 'content-type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(body));
|
||||||
|
};
|
||||||
|
if (req.url === '/api/projects/WEB') return reply(200, project);
|
||||||
|
if (req.url === '/api/projects/SECRET')
|
||||||
|
return reply(401, { statusCode: 401, message: 'Missing bearer token' });
|
||||||
|
if (req.url === '/api/issues/WEB-1/status')
|
||||||
|
return reply(200, { id: 'i1', key: 'WEB-1', version: 4 });
|
||||||
|
if (req.url === '/api/issues/WEB-1/status-denied')
|
||||||
|
return reply(422, {
|
||||||
|
statusCode: 422,
|
||||||
|
code: 'BUSINESS_RULE',
|
||||||
|
message: 'Cannot move',
|
||||||
|
});
|
||||||
|
return reply(404, { statusCode: 404, message: 'Not found' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TaskStream MCP', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let api: Server;
|
||||||
|
let url: string;
|
||||||
|
const received: Received[] = [];
|
||||||
|
|
||||||
|
/** One JSON-RPC call over Streamable HTTP (answers come as an SSE event). */
|
||||||
|
async function rpc(
|
||||||
|
method: string,
|
||||||
|
params: object,
|
||||||
|
token?: string,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const response = await fetch(`${url}/mcp`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
accept: 'application/json, text/event-stream',
|
||||||
|
'mcp-protocol-version': '2025-06-18',
|
||||||
|
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
const data = text.startsWith('{')
|
||||||
|
? text
|
||||||
|
: (text.split('\n').find((line) => line.startsWith('data:')) ?? '').slice(
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
return JSON.parse(data).result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const call = (name: string, args: object, token?: string) =>
|
||||||
|
rpc('tools/call', { name, arguments: args }, token) as Promise<{
|
||||||
|
isError?: boolean;
|
||||||
|
content: { type: string; text: string }[];
|
||||||
|
}>;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
api = fakeApi(received);
|
||||||
|
await new Promise<void>((resolve) => api.listen(0, resolve));
|
||||||
|
const apiUrl = `http://127.0.0.1:${(api.address() as AddressInfo).port}`;
|
||||||
|
const moduleRef = await Test.createTestingModule({ imports: [AppModule] })
|
||||||
|
.overrideProvider(TASKSTREAM_API_URL)
|
||||||
|
.useValue(apiUrl)
|
||||||
|
.compile();
|
||||||
|
app = moduleRef.createNestApplication({ logger: false });
|
||||||
|
await app.listen(0);
|
||||||
|
url = (await app.getUrl()).replace('[::1]', '127.0.0.1');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app?.close();
|
||||||
|
await new Promise((resolve) => api?.close(resolve));
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => (received.length = 0));
|
||||||
|
|
||||||
|
it('lists the tools, read-only ones marked as such', async () => {
|
||||||
|
const { tools } = (await rpc('tools/list', {})) as {
|
||||||
|
tools: { name: string; annotations?: { readOnlyHint?: boolean } }[];
|
||||||
|
};
|
||||||
|
const names = tools.map((t) => t.name);
|
||||||
|
expect(names).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
'list_my_projects',
|
||||||
|
'get_project',
|
||||||
|
'get_board',
|
||||||
|
'search_issues',
|
||||||
|
'get_issue',
|
||||||
|
'create_issue',
|
||||||
|
'change_issue_status',
|
||||||
|
'get_release',
|
||||||
|
'whoami',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const byName = new Map(tools.map((t) => [t.name, t]));
|
||||||
|
expect(byName.get('get_project')?.annotations?.readOnlyHint).toBe(true);
|
||||||
|
expect(byName.get('create_issue')?.annotations?.readOnlyHint).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls the API with the caller's own token and compacts the answer", async () => {
|
||||||
|
const result = await call(
|
||||||
|
'get_project',
|
||||||
|
{ project: 'WEB' },
|
||||||
|
'pireon-token',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(received).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/projects/WEB',
|
||||||
|
authorization: 'Bearer pireon-token',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(JSON.parse(result.content[0].text)).toMatchObject({
|
||||||
|
key: 'WEB',
|
||||||
|
owner: 'Ana',
|
||||||
|
members: [{ id: '101', name: 'Ana', role: 'OWNER' }],
|
||||||
|
statuses: [{ id: 'TODO', category: 'TODO' }],
|
||||||
|
issueTypes: [{ id: 'BUG', name: 'Bug' }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends no credentials when the client has none (public projects only)', async () => {
|
||||||
|
await call('get_project', { project: 'WEB' });
|
||||||
|
expect(received[0].authorization).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns API refusals into tool errors the model can read', async () => {
|
||||||
|
const result = await call('get_project', { project: 'SECRET' });
|
||||||
|
expect(result.isError).toBe(true);
|
||||||
|
expect(result.content[0].text).toMatch(/Error 401: Missing bearer token/);
|
||||||
|
expect(result.content[0].text).toMatch(/Authorization: Bearer/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes through the REST API', async () => {
|
||||||
|
const result = await call(
|
||||||
|
'change_issue_status',
|
||||||
|
{ issue: 'WEB-1', status: 'QA_FAILED', reason: 'Falla en Safari' },
|
||||||
|
'pireon-token',
|
||||||
|
);
|
||||||
|
expect(result.isError).toBeUndefined();
|
||||||
|
expect(received[0]).toMatchObject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/issues/WEB-1/status',
|
||||||
|
body: { status: 'QA_FAILED', reason: 'Falla en Safari' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates the arguments before calling the API', async () => {
|
||||||
|
const result = await call('get_project_analytics', {
|
||||||
|
project: 'WEB',
|
||||||
|
days: 2,
|
||||||
|
});
|
||||||
|
expect(result.isError).toBe(true);
|
||||||
|
expect(received).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A raw POST /mcp with chosen Host / Origin headers (fetch would not let us set Host). */
|
||||||
|
function rawPost(headers: Record<string, string>): Promise<number> {
|
||||||
|
const { port } = new URL(url);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = httpRequest(
|
||||||
|
{
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port,
|
||||||
|
path: '/mcp',
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
accept: 'application/json, text/event-stream',
|
||||||
|
'mcp-protocol-version': '2025-06-18',
|
||||||
|
...headers,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
res.resume();
|
||||||
|
resolve(res.statusCode ?? 0);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
req.on('error', reject);
|
||||||
|
req.end(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('only answers on its own domain (DNS rebinding protection)', async () => {
|
||||||
|
// Default allowlist: localhost only (MCP_ALLOWED_HOSTS sets the real domain).
|
||||||
|
expect(await rawPost({ host: `localhost:${new URL(url).port}` })).toBe(200);
|
||||||
|
expect(await rawPost({ host: 'evil.example' })).toBe(403);
|
||||||
|
expect(
|
||||||
|
await rawPost({
|
||||||
|
host: `localhost:${new URL(url).port}`,
|
||||||
|
origin: 'https://evil.example',
|
||||||
|
}),
|
||||||
|
).toBe(403);
|
||||||
|
expect(
|
||||||
|
await rawPost({
|
||||||
|
host: `localhost:${new URL(url).port}`,
|
||||||
|
origin: 'http://localhost:6274',
|
||||||
|
}),
|
||||||
|
).toBe(200);
|
||||||
|
expect((await fetch(`${url}/health`)).status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
402
apps/mcp/src/app/tools/issues.resolver.ts
Normal file
402
apps/mcp/src/app/tools/issues.resolver.ts
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
import { McpContext, Resolver, Tool } from '@nestjs-mcp/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { TaskStreamApi } from '../api/taskstream-api';
|
||||||
|
import { issueRef, limit, projectRef } from './params';
|
||||||
|
import { issueLine } from './summaries';
|
||||||
|
import { toolResult } from './tool-result';
|
||||||
|
|
||||||
|
const READ = { readOnlyHint: true, openWorldHint: false } as const;
|
||||||
|
const WRITE = {
|
||||||
|
readOnlyHint: false,
|
||||||
|
destructiveHint: false,
|
||||||
|
openWorldHint: false,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const PRIORITY = z.enum(['LOWEST', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL']);
|
||||||
|
const CATEGORY = z.enum(['TODO', 'IN_PROGRESS', 'QA', 'DONE', 'CANCELLED']);
|
||||||
|
const LINK_TYPE = z.enum([
|
||||||
|
'BLOCKS',
|
||||||
|
'DEPENDS_ON',
|
||||||
|
'TESTS',
|
||||||
|
'DUPLICATES',
|
||||||
|
'RELATES_TO',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ids = (what: string) =>
|
||||||
|
z
|
||||||
|
.array(z.string())
|
||||||
|
.optional()
|
||||||
|
.describe(`${what} ids of the project's settings (see get_project)`);
|
||||||
|
|
||||||
|
const issueFields = {
|
||||||
|
description: z.string().max(50000).optional(),
|
||||||
|
area: z.string().optional().describe('Area id (see get_project)'),
|
||||||
|
priority: PRIORITY.optional(),
|
||||||
|
labels: z.array(z.string().max(40)).max(20).optional(),
|
||||||
|
figmaUrl: z.string().url().optional().describe('Figma frame (https)'),
|
||||||
|
storyPoints: z.number().int().min(0).max(100).optional(),
|
||||||
|
dueDate: z.string().optional().describe('ISO date, e.g. 2026-11-14'),
|
||||||
|
};
|
||||||
|
|
||||||
|
type IssueFields = {
|
||||||
|
description?: string;
|
||||||
|
area?: string;
|
||||||
|
priority?: z.infer<typeof PRIORITY>;
|
||||||
|
labels?: string[];
|
||||||
|
figmaUrl?: string;
|
||||||
|
storyPoints?: number;
|
||||||
|
dueDate?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Resolver()
|
||||||
|
export class IssuesResolver {
|
||||||
|
constructor(private readonly api: TaskStreamApi) {}
|
||||||
|
|
||||||
|
/* ---- Reading ---- */
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'my_issues',
|
||||||
|
description:
|
||||||
|
"Tickets across all the user's projects: assigned to them (default) or anyone's, filtered by status category (e.g. QA to see what waits for testing).",
|
||||||
|
paramsSchema: z.object({
|
||||||
|
assignedToMe: z.boolean().default(true),
|
||||||
|
categories: z
|
||||||
|
.array(CATEGORY)
|
||||||
|
.optional()
|
||||||
|
.describe('Default: open ones (TODO, IN_PROGRESS, QA)'),
|
||||||
|
limit: limit(200, 50),
|
||||||
|
}),
|
||||||
|
annotations: READ,
|
||||||
|
})
|
||||||
|
mine(
|
||||||
|
params: {
|
||||||
|
assignedToMe: boolean;
|
||||||
|
categories?: z.infer<typeof CATEGORY>[];
|
||||||
|
limit: number;
|
||||||
|
},
|
||||||
|
ctx: McpContext,
|
||||||
|
) {
|
||||||
|
return toolResult(async () => {
|
||||||
|
const page = await this.api.data(
|
||||||
|
this.api.as(ctx).GET('/api/me/issues', {
|
||||||
|
params: {
|
||||||
|
query: {
|
||||||
|
assignedToMe: params.assignedToMe,
|
||||||
|
category: params.categories ?? ['TODO', 'IN_PROGRESS', 'QA'],
|
||||||
|
limit: params.limit,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return { total: page.total, items: page.items.map(issueLine) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'search_issues',
|
||||||
|
description:
|
||||||
|
'Search the tickets of a project by text (key, title, description) and filters.',
|
||||||
|
paramsSchema: z.object({
|
||||||
|
project: projectRef,
|
||||||
|
search: z.string().max(200).optional(),
|
||||||
|
statuses: ids('Status'),
|
||||||
|
categories: z.array(CATEGORY).optional(),
|
||||||
|
types: ids('Ticket type'),
|
||||||
|
areas: ids('Area'),
|
||||||
|
priorities: z.array(PRIORITY).optional(),
|
||||||
|
assigneeId: z.string().optional().describe('User id'),
|
||||||
|
unassigned: z.boolean().optional(),
|
||||||
|
releaseId: z.string().optional(),
|
||||||
|
labels: z.array(z.string()).optional(),
|
||||||
|
limit: limit(200, 30),
|
||||||
|
offset: z.number().int().min(0).default(0),
|
||||||
|
}),
|
||||||
|
annotations: READ,
|
||||||
|
})
|
||||||
|
search(
|
||||||
|
params: {
|
||||||
|
project: string;
|
||||||
|
search?: string;
|
||||||
|
statuses?: string[];
|
||||||
|
categories?: z.infer<typeof CATEGORY>[];
|
||||||
|
types?: string[];
|
||||||
|
areas?: string[];
|
||||||
|
priorities?: z.infer<typeof PRIORITY>[];
|
||||||
|
assigneeId?: string;
|
||||||
|
unassigned?: boolean;
|
||||||
|
releaseId?: string;
|
||||||
|
labels?: string[];
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
},
|
||||||
|
ctx: McpContext,
|
||||||
|
) {
|
||||||
|
return toolResult(async () => {
|
||||||
|
const page = await this.api.data(
|
||||||
|
this.api.as(ctx).GET('/api/projects/{project}/issues', {
|
||||||
|
params: {
|
||||||
|
path: { project: params.project },
|
||||||
|
query: {
|
||||||
|
search: params.search,
|
||||||
|
status: params.statuses,
|
||||||
|
category: params.categories,
|
||||||
|
type: params.types,
|
||||||
|
area: params.areas,
|
||||||
|
priority: params.priorities,
|
||||||
|
assigneeId: params.assigneeId,
|
||||||
|
unassigned: params.unassigned,
|
||||||
|
releaseId: params.releaseId,
|
||||||
|
label: params.labels,
|
||||||
|
limit: params.limit,
|
||||||
|
offset: params.offset,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return { total: page.total, items: page.items.map(issueLine) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'get_issue',
|
||||||
|
description:
|
||||||
|
'A ticket in full: description, links, allowed next statuses, and optionally its comments and history.',
|
||||||
|
paramsSchema: z.object({
|
||||||
|
issue: issueRef,
|
||||||
|
withComments: z.boolean().default(true),
|
||||||
|
withHistory: z.boolean().default(false),
|
||||||
|
}),
|
||||||
|
annotations: READ,
|
||||||
|
})
|
||||||
|
getIssue(
|
||||||
|
params: { issue: string; withComments: boolean; withHistory: boolean },
|
||||||
|
ctx: McpContext,
|
||||||
|
) {
|
||||||
|
return toolResult(async () => {
|
||||||
|
const client = this.api.as(ctx);
|
||||||
|
const path = { issue: params.issue };
|
||||||
|
const issue = await this.api.data(
|
||||||
|
client.GET('/api/issues/{issue}', { params: { path } }),
|
||||||
|
);
|
||||||
|
const comments = params.withComments
|
||||||
|
? (
|
||||||
|
await this.api.data(
|
||||||
|
client.GET('/api/issues/{issue}/comments', {
|
||||||
|
params: { path, query: { limit: 100 } },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
).items.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
author: c.author.name,
|
||||||
|
at: c.createdAt,
|
||||||
|
body: c.body,
|
||||||
|
}))
|
||||||
|
: undefined;
|
||||||
|
const history = params.withHistory
|
||||||
|
? (
|
||||||
|
await this.api.data(
|
||||||
|
client.GET('/api/issues/{issue}/history', {
|
||||||
|
params: { path, query: { limit: 100 } },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
).map((a) => ({ at: a.occurredAt, summary: a.summary }))
|
||||||
|
: undefined;
|
||||||
|
return {
|
||||||
|
...issueLine(issue),
|
||||||
|
description: issue.description,
|
||||||
|
reporter: issue.reporter.name,
|
||||||
|
figmaUrl: issue.figmaUrl,
|
||||||
|
storyPoints: issue.storyPoints,
|
||||||
|
dueDate: issue.dueDate,
|
||||||
|
allowedTransitions: issue.allowedTransitions,
|
||||||
|
links: issue.links,
|
||||||
|
createdAt: issue.createdAt,
|
||||||
|
comments,
|
||||||
|
history,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Writing ---- */
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'create_issue',
|
||||||
|
description:
|
||||||
|
'Create a ticket. Type and area are ids of the project settings (see get_project); it starts in the first pending status.',
|
||||||
|
paramsSchema: z.object({
|
||||||
|
project: projectRef,
|
||||||
|
type: z.string().describe('Ticket type id, e.g. BUG'),
|
||||||
|
title: z.string().min(1).max(255),
|
||||||
|
assigneeId: z.string().optional().describe('User id (a project member)'),
|
||||||
|
releaseId: z.string().optional(),
|
||||||
|
...issueFields,
|
||||||
|
}),
|
||||||
|
annotations: WRITE,
|
||||||
|
})
|
||||||
|
create(
|
||||||
|
params: IssueFields & {
|
||||||
|
project: string;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
assigneeId?: string;
|
||||||
|
releaseId?: string;
|
||||||
|
},
|
||||||
|
ctx: McpContext,
|
||||||
|
) {
|
||||||
|
const { project, ...body } = params;
|
||||||
|
return toolResult(() =>
|
||||||
|
this.api.data(
|
||||||
|
this.api.as(ctx).POST('/api/projects/{project}/issues', {
|
||||||
|
params: { path: { project } },
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'update_issue',
|
||||||
|
description: 'Change the title, description, type, area, priority, labels…',
|
||||||
|
paramsSchema: z.object({
|
||||||
|
issue: issueRef,
|
||||||
|
title: z.string().min(1).max(255).optional(),
|
||||||
|
type: z.string().optional().describe('Ticket type id'),
|
||||||
|
...issueFields,
|
||||||
|
}),
|
||||||
|
annotations: { ...WRITE, idempotentHint: true },
|
||||||
|
})
|
||||||
|
update(
|
||||||
|
params: IssueFields & { issue: string; title?: string; type?: string },
|
||||||
|
ctx: McpContext,
|
||||||
|
) {
|
||||||
|
const { issue, ...body } = params;
|
||||||
|
return toolResult(() =>
|
||||||
|
this.api.data(
|
||||||
|
this.api.as(ctx).PATCH('/api/issues/{issue}', {
|
||||||
|
params: { path: { issue } },
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'change_issue_status',
|
||||||
|
description:
|
||||||
|
"Move a ticket to another status. It must be one of the ticket's allowedTransitions (see get_issue); some statuses need a reason.",
|
||||||
|
paramsSchema: z.object({
|
||||||
|
issue: issueRef,
|
||||||
|
status: z.string().describe('Status id, e.g. IN_PROGRESS'),
|
||||||
|
reason: z.string().max(5000).optional(),
|
||||||
|
}),
|
||||||
|
annotations: WRITE,
|
||||||
|
})
|
||||||
|
changeStatus(
|
||||||
|
{
|
||||||
|
issue,
|
||||||
|
status,
|
||||||
|
reason,
|
||||||
|
}: { issue: string; status: string; reason?: string },
|
||||||
|
ctx: McpContext,
|
||||||
|
) {
|
||||||
|
return toolResult(() =>
|
||||||
|
this.api.data(
|
||||||
|
this.api.as(ctx).PUT('/api/issues/{issue}/status', {
|
||||||
|
params: { path: { issue } },
|
||||||
|
body: { status, reason },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'assign_issue',
|
||||||
|
description: 'Assign a ticket to a project member, or unassign it (null).',
|
||||||
|
paramsSchema: z.object({
|
||||||
|
issue: issueRef,
|
||||||
|
assigneeId: z.string().nullable().describe('User id, or null'),
|
||||||
|
}),
|
||||||
|
annotations: { ...WRITE, idempotentHint: true },
|
||||||
|
})
|
||||||
|
assign(
|
||||||
|
{ issue, assigneeId }: { issue: string; assigneeId: string | null },
|
||||||
|
ctx: McpContext,
|
||||||
|
) {
|
||||||
|
return toolResult(() =>
|
||||||
|
this.api.data(
|
||||||
|
this.api.as(ctx).PUT('/api/issues/{issue}/assignee', {
|
||||||
|
params: { path: { issue } },
|
||||||
|
body: { assigneeId },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'set_issue_release',
|
||||||
|
description: 'Put a ticket in a planned release, or take it out (null).',
|
||||||
|
paramsSchema: z.object({
|
||||||
|
issue: issueRef,
|
||||||
|
releaseId: z.string().nullable(),
|
||||||
|
}),
|
||||||
|
annotations: { ...WRITE, idempotentHint: true },
|
||||||
|
})
|
||||||
|
setRelease(
|
||||||
|
{ issue, releaseId }: { issue: string; releaseId: string | null },
|
||||||
|
ctx: McpContext,
|
||||||
|
) {
|
||||||
|
return toolResult(() =>
|
||||||
|
this.api.data(
|
||||||
|
this.api.as(ctx).PUT('/api/issues/{issue}/release', {
|
||||||
|
params: { path: { issue } },
|
||||||
|
body: { releaseId },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'add_comment',
|
||||||
|
description: 'Comment on a ticket (decisions, explanations, steps).',
|
||||||
|
paramsSchema: z.object({
|
||||||
|
issue: issueRef,
|
||||||
|
body: z.string().min(1).max(20000),
|
||||||
|
}),
|
||||||
|
annotations: WRITE,
|
||||||
|
})
|
||||||
|
comment({ issue, body }: { issue: string; body: string }, ctx: McpContext) {
|
||||||
|
return toolResult(() =>
|
||||||
|
this.api.data(
|
||||||
|
this.api.as(ctx).POST('/api/issues/{issue}/comments', {
|
||||||
|
params: { path: { issue } },
|
||||||
|
body: { body },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'link_issues',
|
||||||
|
description:
|
||||||
|
'Relate two tickets, e.g. WEB-3 DEPENDS_ON API-7 (they may be in different projects).',
|
||||||
|
paramsSchema: z.object({
|
||||||
|
issue: issueRef,
|
||||||
|
type: LINK_TYPE,
|
||||||
|
target: issueRef,
|
||||||
|
}),
|
||||||
|
annotations: WRITE,
|
||||||
|
})
|
||||||
|
link(
|
||||||
|
params: { issue: string; type: z.infer<typeof LINK_TYPE>; target: string },
|
||||||
|
ctx: McpContext,
|
||||||
|
) {
|
||||||
|
return toolResult(() =>
|
||||||
|
this.api.data(
|
||||||
|
this.api.as(ctx).POST('/api/issues/{issue}/links', {
|
||||||
|
params: { path: { issue: params.issue } },
|
||||||
|
body: { type: params.type, targetIssue: params.target },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
apps/mcp/src/app/tools/params.ts
Normal file
23
apps/mcp/src/app/tools/params.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
/** Shared parameter schemas (descriptions are what the model reads). */
|
||||||
|
export const projectRef = z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.describe('Project key (e.g. "WEB") or id');
|
||||||
|
|
||||||
|
export const issueRef = z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.describe('Ticket key (e.g. "WEB-12") or id');
|
||||||
|
|
||||||
|
export const releaseId = z.string().min(1).describe('Release id');
|
||||||
|
|
||||||
|
export const limit = (max: number, fallback: number) =>
|
||||||
|
z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(max)
|
||||||
|
.default(fallback)
|
||||||
|
.describe(`How many (1-${max})`);
|
||||||
170
apps/mcp/src/app/tools/projects.resolver.ts
Normal file
170
apps/mcp/src/app/tools/projects.resolver.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import { McpContext, Resolver, Tool } from '@nestjs-mcp/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { TaskStreamApi } from '../api/taskstream-api';
|
||||||
|
import { limit, projectRef } from './params';
|
||||||
|
import { issueLine, projectLine, projectSettings } from './summaries';
|
||||||
|
import { toolResult } from './tool-result';
|
||||||
|
|
||||||
|
const READ = { readOnlyHint: true, openWorldHint: false } as const;
|
||||||
|
|
||||||
|
@Resolver()
|
||||||
|
export class ProjectsResolver {
|
||||||
|
constructor(private readonly api: TaskStreamApi) {}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'list_my_projects',
|
||||||
|
description: 'Projects where the signed-in user is a member.',
|
||||||
|
annotations: READ,
|
||||||
|
})
|
||||||
|
listMine(ctx: McpContext) {
|
||||||
|
return toolResult(async () =>
|
||||||
|
(await this.api.data(this.api.as(ctx).GET('/api/projects'))).map(
|
||||||
|
projectLine,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'list_public_projects',
|
||||||
|
description: 'Public projects: anyone can read them, also without a token.',
|
||||||
|
annotations: READ,
|
||||||
|
})
|
||||||
|
listPublic(ctx: McpContext) {
|
||||||
|
return toolResult(async () =>
|
||||||
|
(await this.api.data(this.api.as(ctx).GET('/api/explore/projects'))).map(
|
||||||
|
projectLine,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'get_project',
|
||||||
|
description:
|
||||||
|
'A project with its team and settings: the status, ticket type and area ids that the other tools accept, and which statuses need a reason.',
|
||||||
|
paramsSchema: z.object({ project: projectRef }),
|
||||||
|
annotations: READ,
|
||||||
|
})
|
||||||
|
getProject({ project }: { project: string }, ctx: McpContext) {
|
||||||
|
return toolResult(async () => {
|
||||||
|
const found = await this.api.data(
|
||||||
|
this.api
|
||||||
|
.as(ctx)
|
||||||
|
.GET('/api/projects/{project}', { params: { path: { project } } }),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...projectLine(found),
|
||||||
|
members: found.members.map((m) => ({
|
||||||
|
id: m.user.id,
|
||||||
|
name: m.user.name,
|
||||||
|
role: m.role,
|
||||||
|
})),
|
||||||
|
...projectSettings(found),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'get_board',
|
||||||
|
description:
|
||||||
|
'Kanban board: one column per status (in board order) with its tickets.',
|
||||||
|
paramsSchema: z.object({
|
||||||
|
project: projectRef,
|
||||||
|
assigneeId: z.string().optional().describe('Only this person (user id)'),
|
||||||
|
releaseId: z.string().optional().describe('Only this release'),
|
||||||
|
area: z.string().optional().describe('Only this area id'),
|
||||||
|
perColumn: limit(200, 25),
|
||||||
|
}),
|
||||||
|
annotations: READ,
|
||||||
|
})
|
||||||
|
board(
|
||||||
|
params: {
|
||||||
|
project: string;
|
||||||
|
assigneeId?: string;
|
||||||
|
releaseId?: string;
|
||||||
|
area?: string;
|
||||||
|
perColumn: number;
|
||||||
|
},
|
||||||
|
ctx: McpContext,
|
||||||
|
) {
|
||||||
|
const { project, ...query } = params;
|
||||||
|
return toolResult(async () =>
|
||||||
|
(
|
||||||
|
await this.api.data(
|
||||||
|
this.api.as(ctx).GET('/api/projects/{project}/board', {
|
||||||
|
params: { path: { project }, query },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
).map((column) => ({
|
||||||
|
status: column.status,
|
||||||
|
total: column.total,
|
||||||
|
issues: column.issues.map(issueLine),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'get_project_stats',
|
||||||
|
description:
|
||||||
|
'Tracking numbers: total and open tickets, by status, type, priority, area and open tickets per person.',
|
||||||
|
paramsSchema: z.object({ project: projectRef }),
|
||||||
|
annotations: READ,
|
||||||
|
})
|
||||||
|
stats({ project }: { project: string }, ctx: McpContext) {
|
||||||
|
return toolResult(() =>
|
||||||
|
this.api.data(
|
||||||
|
this.api.as(ctx).GET('/api/projects/{project}/stats', {
|
||||||
|
params: { path: { project } },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'get_project_analytics',
|
||||||
|
description:
|
||||||
|
'Statistics over the last days: created vs resolved per week, cumulative flow, cycle and lead time (average and p85), throughput per person and QA pass/fail rate.',
|
||||||
|
paramsSchema: z.object({
|
||||||
|
project: projectRef,
|
||||||
|
days: z.number().int().min(7).max(365).default(90),
|
||||||
|
}),
|
||||||
|
annotations: READ,
|
||||||
|
})
|
||||||
|
analytics(
|
||||||
|
{ project, days }: { project: string; days: number },
|
||||||
|
ctx: McpContext,
|
||||||
|
) {
|
||||||
|
return toolResult(() =>
|
||||||
|
this.api.data(
|
||||||
|
this.api.as(ctx).GET('/api/projects/{project}/analytics', {
|
||||||
|
params: { path: { project }, query: { days } },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'get_project_activity',
|
||||||
|
description: 'Latest activity of a project (newest first).',
|
||||||
|
paramsSchema: z.object({ project: projectRef, limit: limit(200, 30) }),
|
||||||
|
annotations: READ,
|
||||||
|
})
|
||||||
|
activity(
|
||||||
|
{ project, limit }: { project: string; limit: number },
|
||||||
|
ctx: McpContext,
|
||||||
|
) {
|
||||||
|
return toolResult(async () =>
|
||||||
|
(
|
||||||
|
await this.api.data(
|
||||||
|
this.api.as(ctx).GET('/api/projects/{project}/activity', {
|
||||||
|
params: { path: { project }, query: { limit } },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
).map((a) => ({
|
||||||
|
at: a.occurredAt,
|
||||||
|
who: a.actor.name,
|
||||||
|
summary: a.summary,
|
||||||
|
issue: a.issueKey,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
151
apps/mcp/src/app/tools/releases.resolver.ts
Normal file
151
apps/mcp/src/app/tools/releases.resolver.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { McpContext, Resolver, Tool } from '@nestjs-mcp/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { TaskStreamApi } from '../api/taskstream-api';
|
||||||
|
import { limit, projectRef, releaseId } from './params';
|
||||||
|
import { issueLine, releaseLine } from './summaries';
|
||||||
|
import { toolResult } from './tool-result';
|
||||||
|
|
||||||
|
const READ = { readOnlyHint: true, openWorldHint: false } as const;
|
||||||
|
|
||||||
|
@Resolver()
|
||||||
|
export class ReleasesResolver {
|
||||||
|
constructor(private readonly api: TaskStreamApi) {}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'list_releases',
|
||||||
|
description: 'Releases of a project (planned, released and archived).',
|
||||||
|
paramsSchema: z.object({ project: projectRef }),
|
||||||
|
annotations: READ,
|
||||||
|
})
|
||||||
|
list({ project }: { project: string }, ctx: McpContext) {
|
||||||
|
return toolResult(async () =>
|
||||||
|
(
|
||||||
|
await this.api.data(
|
||||||
|
this.api.as(ctx).GET('/api/projects/{project}/releases', {
|
||||||
|
params: { path: { project } },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
).map(releaseLine),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'upcoming_releases',
|
||||||
|
description:
|
||||||
|
"Planned releases of all the user's projects, soonest target date first.",
|
||||||
|
paramsSchema: z.object({ limit: limit(50, 10) }),
|
||||||
|
annotations: READ,
|
||||||
|
})
|
||||||
|
upcoming({ limit }: { limit: number }, ctx: McpContext) {
|
||||||
|
return toolResult(async () =>
|
||||||
|
(
|
||||||
|
await this.api.data(
|
||||||
|
this.api
|
||||||
|
.as(ctx)
|
||||||
|
.GET('/api/me/releases', { params: { query: { limit } } }),
|
||||||
|
)
|
||||||
|
).map((release) => ({
|
||||||
|
...releaseLine(release),
|
||||||
|
projectId: release.projectId,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'get_release',
|
||||||
|
description:
|
||||||
|
'A release with its progress (finished out of total), its tickets and, optionally, the day-by-day burnup.',
|
||||||
|
paramsSchema: z.object({
|
||||||
|
releaseId,
|
||||||
|
withBurnup: z.boolean().default(false),
|
||||||
|
}),
|
||||||
|
annotations: READ,
|
||||||
|
})
|
||||||
|
get(params: { releaseId: string; withBurnup: boolean }, ctx: McpContext) {
|
||||||
|
return toolResult(async () => {
|
||||||
|
const client = this.api.as(ctx);
|
||||||
|
const path = { releaseId: params.releaseId };
|
||||||
|
const [release, progress, issues] = await Promise.all([
|
||||||
|
this.api.data(
|
||||||
|
client.GET('/api/releases/{releaseId}', { params: { path } }),
|
||||||
|
),
|
||||||
|
this.api.data(
|
||||||
|
client.GET('/api/releases/{releaseId}/progress', {
|
||||||
|
params: { path },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
this.api.data(
|
||||||
|
client.GET('/api/releases/{releaseId}/issues', {
|
||||||
|
params: { path, query: { limit: 200 } },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
const burnup = params.withBurnup
|
||||||
|
? await this.api.data(
|
||||||
|
client.GET('/api/releases/{releaseId}/burnup', {
|
||||||
|
params: { path },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
return {
|
||||||
|
...releaseLine(release),
|
||||||
|
description: release.description,
|
||||||
|
progress,
|
||||||
|
issues: issues.items.map(issueLine),
|
||||||
|
burnup,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'create_release',
|
||||||
|
description: 'Plan a release in a project (owners and admins).',
|
||||||
|
paramsSchema: z.object({
|
||||||
|
project: projectRef,
|
||||||
|
name: z.string().min(1).max(60).describe('e.g. v1.4.0'),
|
||||||
|
description: z.string().max(5000).optional(),
|
||||||
|
targetDate: z.string().optional().describe('ISO date'),
|
||||||
|
}),
|
||||||
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
||||||
|
})
|
||||||
|
create(
|
||||||
|
params: {
|
||||||
|
project: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
targetDate?: string;
|
||||||
|
},
|
||||||
|
ctx: McpContext,
|
||||||
|
) {
|
||||||
|
const { project, ...body } = params;
|
||||||
|
return toolResult(() =>
|
||||||
|
this.api.data(
|
||||||
|
this.api.as(ctx).POST('/api/projects/{project}/releases', {
|
||||||
|
params: { path: { project } },
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'publish_release',
|
||||||
|
description:
|
||||||
|
'Ship a planned release. Fails while it has unfinished tickets unless force is true.',
|
||||||
|
paramsSchema: z.object({ releaseId, force: z.boolean().default(false) }),
|
||||||
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
||||||
|
})
|
||||||
|
publish(
|
||||||
|
{ releaseId, force }: { releaseId: string; force: boolean },
|
||||||
|
ctx: McpContext,
|
||||||
|
) {
|
||||||
|
return toolResult(() =>
|
||||||
|
this.api.data(
|
||||||
|
this.api.as(ctx).POST('/api/releases/{releaseId}/publish', {
|
||||||
|
params: { path: { releaseId } },
|
||||||
|
body: { force },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
63
apps/mcp/src/app/tools/summaries.ts
Normal file
63
apps/mcp/src/app/tools/summaries.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import type { Schemas } from '../api/taskstream-api';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Compact views for listings: what the model needs to reason, without ids
|
||||||
|
* and nested objects it rarely uses (full detail through the get_* tools).
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Issue = Schemas['IssueDto'];
|
||||||
|
type Project = Schemas['ProjectDto'];
|
||||||
|
type Release = Schemas['ReleaseDto'];
|
||||||
|
|
||||||
|
export const issueLine = (issue: Issue) => ({
|
||||||
|
key: issue.key,
|
||||||
|
title: issue.title,
|
||||||
|
type: issue.type,
|
||||||
|
status: issue.status,
|
||||||
|
statusCategory: issue.statusCategory,
|
||||||
|
priority: issue.priority,
|
||||||
|
area: issue.area,
|
||||||
|
assignee: issue.assignee?.name ?? null,
|
||||||
|
release: issue.release?.name ?? null,
|
||||||
|
labels: issue.labels,
|
||||||
|
updatedAt: issue.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const projectLine = (project: Project) => ({
|
||||||
|
key: project.key,
|
||||||
|
name: project.name,
|
||||||
|
description: project.description,
|
||||||
|
isPublic: project.isPublic,
|
||||||
|
owner: project.owner.name,
|
||||||
|
members: project.members.length,
|
||||||
|
updatedAt: project.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Statuses, types and areas by id: what create/update/move tools accept. */
|
||||||
|
export const projectSettings = (project: Project) => ({
|
||||||
|
statuses: project.settings.statuses.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
category: s.category,
|
||||||
|
requiresReason: s.requiresReason,
|
||||||
|
archived: s.archived,
|
||||||
|
})),
|
||||||
|
issueTypes: project.settings.issueTypes.map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
name: t.name,
|
||||||
|
archived: t.archived,
|
||||||
|
})),
|
||||||
|
areas: project.settings.areas.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
name: a.name,
|
||||||
|
archived: a.archived,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const releaseLine = (release: Release) => ({
|
||||||
|
id: release.id,
|
||||||
|
name: release.name,
|
||||||
|
status: release.status,
|
||||||
|
targetDate: release.targetDate,
|
||||||
|
releasedAt: release.releasedAt,
|
||||||
|
});
|
||||||
34
apps/mcp/src/app/tools/tool-result.ts
Normal file
34
apps/mcp/src/app/tools/tool-result.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import type { CallToolResult } from '@modelcontextprotocol/server';
|
||||||
|
import { ApiError } from '../api/taskstream-api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs a tool and answers with its result as JSON text, or, if the API
|
||||||
|
* refuses it, with an error result the model can read and act on (instead of
|
||||||
|
* a protocol error that aborts the call).
|
||||||
|
*/
|
||||||
|
export async function toolResult(
|
||||||
|
run: () => Promise<unknown>,
|
||||||
|
): Promise<CallToolResult> {
|
||||||
|
try {
|
||||||
|
const result = await run();
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text:
|
||||||
|
typeof result === 'string'
|
||||||
|
? result
|
||||||
|
: JSON.stringify(result ?? { ok: true }, null, 2),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof ApiError)) throw error;
|
||||||
|
return {
|
||||||
|
isError: true,
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: `Error ${error.status}: ${error.message}` },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
49
apps/mcp/src/app/tools/users.resolver.ts
Normal file
49
apps/mcp/src/app/tools/users.resolver.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { McpContext, Resolver, Tool } from '@nestjs-mcp/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { TaskStreamApi } from '../api/taskstream-api';
|
||||||
|
import { limit } from './params';
|
||||||
|
import { toolResult } from './tool-result';
|
||||||
|
|
||||||
|
const READ = { readOnlyHint: true, openWorldHint: false } as const;
|
||||||
|
|
||||||
|
@Resolver()
|
||||||
|
export class UsersResolver {
|
||||||
|
constructor(private readonly api: TaskStreamApi) {}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'whoami',
|
||||||
|
description:
|
||||||
|
'The user this MCP acts as (from the Pireon token the client sends).',
|
||||||
|
annotations: READ,
|
||||||
|
})
|
||||||
|
whoami(ctx: McpContext) {
|
||||||
|
return toolResult(() =>
|
||||||
|
this.api.data(this.api.as(ctx).GET('/api/users/me')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'search_users',
|
||||||
|
description:
|
||||||
|
'Find Pireon users by name or email (to assign tickets: use their id).',
|
||||||
|
paramsSchema: z.object({
|
||||||
|
search: z.string().min(2),
|
||||||
|
limit: limit(50, 10),
|
||||||
|
}),
|
||||||
|
annotations: READ,
|
||||||
|
})
|
||||||
|
search(
|
||||||
|
{ search, limit }: { search: string; limit: number },
|
||||||
|
ctx: McpContext,
|
||||||
|
) {
|
||||||
|
return toolResult(async () =>
|
||||||
|
(
|
||||||
|
await this.api.data(
|
||||||
|
this.api
|
||||||
|
.as(ctx)
|
||||||
|
.GET('/api/users', { params: { query: { search, limit } } }),
|
||||||
|
)
|
||||||
|
).map((u) => ({ id: u.id, name: u.name, email: u.email })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
0
apps/mcp/src/assets/.gitkeep
Normal file
0
apps/mcp/src/assets/.gitkeep
Normal file
15
apps/mcp/src/main.ts
Normal file
15
apps/mcp/src/main.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { AppModule } from './app/app.module';
|
||||||
|
|
||||||
|
/** MCP server of TaskStream over Streamable HTTP: `POST /mcp`. */
|
||||||
|
async function bootstrap() {
|
||||||
|
const app = await NestFactory.create(AppModule);
|
||||||
|
app.enableShutdownHooks();
|
||||||
|
// Not PORT: Nx loads the workspace .env into every task, and there PORT is the API's.
|
||||||
|
const port = Number(process.env['MCP_PORT'] ?? 3010);
|
||||||
|
await app.listen(port);
|
||||||
|
Logger.log(`🔌 MCP: http://localhost:${port}/mcp`, 'Bootstrap');
|
||||||
|
}
|
||||||
|
|
||||||
|
void bootstrap();
|
||||||
24
apps/mcp/tsconfig.app.json
Normal file
24
apps/mcp/tsconfig.app.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../../dist/out-tsc",
|
||||||
|
"module": "commonjs",
|
||||||
|
"types": ["node"],
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"target": "es2021",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"strictBindCallApply": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": [
|
||||||
|
"jest.config.ts",
|
||||||
|
"jest.config.cts",
|
||||||
|
"src/**/*.spec.ts",
|
||||||
|
"src/**/*.test.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
16
apps/mcp/tsconfig.json
Normal file
16
apps/mcp/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"files": [],
|
||||||
|
"include": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.app.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.spec.json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"esModuleInterop": true
|
||||||
|
}
|
||||||
|
}
|
||||||
16
apps/mcp/tsconfig.spec.json
Normal file
16
apps/mcp/tsconfig.spec.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../../dist/out-tsc",
|
||||||
|
"module": "commonjs",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"types": ["jest", "node"]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"jest.config.ts",
|
||||||
|
"jest.config.cts",
|
||||||
|
"src/**/*.test.ts",
|
||||||
|
"src/**/*.spec.ts",
|
||||||
|
"src/**/*.d.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
25
apps/mcp/webpack.config.js
Normal file
25
apps/mcp/webpack.config.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
const { NxAppWebpackPlugin } = require('@nx/webpack/app-plugin');
|
||||||
|
const { join } = require('path');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
output: {
|
||||||
|
path: join(__dirname, '../../dist/apps/mcp'),
|
||||||
|
clean: true,
|
||||||
|
...(process.env.NODE_ENV !== 'production' && {
|
||||||
|
devtoolModuleFilenameTemplate: '[absolute-resource-path]',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
new NxAppWebpackPlugin({
|
||||||
|
target: 'node',
|
||||||
|
compiler: 'tsc',
|
||||||
|
main: './src/main.ts',
|
||||||
|
tsConfig: './tsconfig.app.json',
|
||||||
|
assets: ['./src/assets'],
|
||||||
|
optimization: false,
|
||||||
|
outputHashing: 'none',
|
||||||
|
generatePackageJson: true,
|
||||||
|
sourceMap: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
# Same-origin API for the SPA: REST under /api, GraphQL (HTTP + WebSocket) under /graphql.
|
# Same-origin API for the SPA: REST under /api, GraphQL (HTTP + WebSocket) under /graphql.
|
||||||
|
# The API is reached by its unique alias (task-stream-api), not by the service name `api`:
|
||||||
|
# on a shared network (Dokploy) another project's `api` would get half of the requests.
|
||||||
map $http_upgrade $connection_upgrade {
|
map $http_upgrade $connection_upgrade {
|
||||||
default upgrade;
|
default upgrade;
|
||||||
'' close;
|
'' close;
|
||||||
@@ -9,13 +11,13 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://api:3000;
|
proxy_pass http://task-stream-api:3000;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /graphql {
|
location /graphql {
|
||||||
proxy_pass http://api:3000;
|
proxy_pass http://task-stream-api:3000;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection $connection_upgrade;
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
|||||||
@@ -2,23 +2,36 @@ import { authGuard } from '@task-stream/web/identity/presenters';
|
|||||||
import { appRoutes } from './app.routes';
|
import { appRoutes } from './app.routes';
|
||||||
|
|
||||||
describe('appRoutes', () => {
|
describe('appRoutes', () => {
|
||||||
it('keeps sign-in public and everything else behind the auth guard', () => {
|
it('keeps home behind the auth guard, and public projects and «Explorar» open', () => {
|
||||||
const paths = appRoutes.map((route) => route.path);
|
const paths = appRoutes.map((route) => route.path);
|
||||||
expect(paths).toEqual(expect.arrayContaining(['login', 'auth/callback', '', '**']));
|
expect(paths).toEqual(
|
||||||
|
expect.arrayContaining(['login', 'auth/callback', '', '**']),
|
||||||
|
);
|
||||||
|
|
||||||
const app = appRoutes.find((route) => route.path === '' && route.children);
|
const app = appRoutes.find((route) => route.path === '' && route.children);
|
||||||
expect(app?.canActivate).toContain(authGuard);
|
const children = app?.children ?? [];
|
||||||
expect(app?.children?.map((route) => route.path)).toEqual(['', 'p/:key']);
|
expect(children.map((route) => route.path)).toEqual([
|
||||||
|
'',
|
||||||
|
'explorar',
|
||||||
|
'p/:key',
|
||||||
|
]);
|
||||||
|
expect(children[0].canActivate).toContain(authGuard);
|
||||||
|
expect(children[1].canActivate).toBeUndefined();
|
||||||
|
expect(children[2].canActivate).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('exposes every project section', () => {
|
it('exposes every project section', () => {
|
||||||
const project = appRoutes.find((route) => route.path === '')?.children?.find((route) => route.path === 'p/:key');
|
const project = appRoutes
|
||||||
|
.find((route) => route.path === '')
|
||||||
|
?.children?.find((route) => route.path === 'p/:key');
|
||||||
expect(project?.children?.map((route) => route.path)).toEqual([
|
expect(project?.children?.map((route) => route.path)).toEqual([
|
||||||
'',
|
'',
|
||||||
'board',
|
'board',
|
||||||
'issues/:issueKey',
|
'issues/:issueKey',
|
||||||
'releases',
|
'releases',
|
||||||
'dashboard',
|
'dashboard',
|
||||||
|
'analytics',
|
||||||
|
'settings',
|
||||||
'activity',
|
'activity',
|
||||||
'members',
|
'members',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { Route } from '@angular/router';
|
import { Route } from '@angular/router';
|
||||||
import {
|
import { identityRoutes } from '@task-stream/web/identity/presenters';
|
||||||
authGuard,
|
|
||||||
identityRoutes,
|
|
||||||
} from '@task-stream/web/identity/presenters';
|
|
||||||
import { trackerRoutes } from '@task-stream/web/tracker/presenters';
|
import { trackerRoutes } from '@task-stream/web/tracker/presenters';
|
||||||
|
|
||||||
export const appRoutes: Route[] = [
|
export const appRoutes: Route[] = [
|
||||||
...identityRoutes,
|
...identityRoutes,
|
||||||
{ path: '', canActivate: [authGuard], children: trackerRoutes },
|
// Not all behind the guard: public projects are readable without a session.
|
||||||
|
{ path: '', children: trackerRoutes },
|
||||||
{ path: '**', redirectTo: '' },
|
{ path: '**', redirectTo: '' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||||
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
import { toSignal } from '@angular/core/rxjs-interop';
|
||||||
|
import {
|
||||||
|
NavigationEnd,
|
||||||
|
Router,
|
||||||
|
RouterLink,
|
||||||
|
RouterLinkActive,
|
||||||
|
RouterOutlet,
|
||||||
|
} from '@angular/router';
|
||||||
|
import { filter, map } from 'rxjs';
|
||||||
import { AuthFacade } from '@task-stream/web/identity/application';
|
import { AuthFacade } from '@task-stream/web/identity/application';
|
||||||
import { UserMenuComponent } from '@task-stream/web/identity/presenters';
|
import { UserMenuComponent } from '@task-stream/web/identity/presenters';
|
||||||
import { AppShellComponent } from '@task-stream/web/shared/ui';
|
import { AppShellComponent } from '@task-stream/web/shared/ui';
|
||||||
@@ -27,8 +35,20 @@ import { AppShellComponent } from '@task-stream/web/shared/ui';
|
|||||||
Proyectos
|
Proyectos
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
|
<a
|
||||||
|
shellNav
|
||||||
|
class="ts-nav-link"
|
||||||
|
routerLink="/explorar"
|
||||||
|
routerLinkActive="is-active"
|
||||||
|
>
|
||||||
|
Explorar
|
||||||
|
</a>
|
||||||
@if (auth.isAuthenticated()) {
|
@if (auth.isAuthenticated()) {
|
||||||
<ts-user-menu shellUser />
|
<ts-user-menu shellUser />
|
||||||
|
} @else if (auth.settled() && !onLogin()) {
|
||||||
|
<a shellUser class="ts-nav-link ts-nav-link--cta" routerLink="/login"
|
||||||
|
>Iniciar sesión</a
|
||||||
|
>
|
||||||
}
|
}
|
||||||
<router-outlet />
|
<router-outlet />
|
||||||
</ts-app-shell>
|
</ts-app-shell>
|
||||||
@@ -36,4 +56,13 @@ import { AppShellComponent } from '@task-stream/web/shared/ui';
|
|||||||
})
|
})
|
||||||
export class App {
|
export class App {
|
||||||
protected readonly auth = inject(AuthFacade);
|
protected readonly auth = inject(AuthFacade);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
/** The login page has its own form: no "Iniciar sesión" button there. */
|
||||||
|
protected readonly onLogin = toSignal(
|
||||||
|
this.router.events.pipe(
|
||||||
|
filter((event) => event instanceof NavigationEnd),
|
||||||
|
map(() => this.router.url.startsWith('/login')),
|
||||||
|
),
|
||||||
|
{ initialValue: false },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,6 +114,31 @@ html {
|
|||||||
--ts-t-ui: #d6409f;
|
--ts-t-ui: #d6409f;
|
||||||
--ts-t-task: #64748b;
|
--ts-t-task: #64748b;
|
||||||
--ts-t-qa: #d97706;
|
--ts-t-qa: #d97706;
|
||||||
|
|
||||||
|
// Palette of the per-project settings (statuses and ticket types pick one).
|
||||||
|
--ts-c-slate: #64748b;
|
||||||
|
--ts-c-stone: #8b8b9a;
|
||||||
|
--ts-c-violet: #a347e1;
|
||||||
|
--ts-c-blue: #1879bf;
|
||||||
|
--ts-c-sky: #0284c7;
|
||||||
|
--ts-c-teal: #0f766e;
|
||||||
|
--ts-c-green: #15803d;
|
||||||
|
--ts-c-amber: #b7791f;
|
||||||
|
--ts-c-orange: #ea580c;
|
||||||
|
--ts-c-red: #dc2626;
|
||||||
|
--ts-c-pink: #d6409f;
|
||||||
|
|
||||||
|
// Charts (validated with the dataviz checks against --ts-surface, light and
|
||||||
|
// dark): two categorical slots, an ordinal ramp for flow stages and the
|
||||||
|
// reserved good/critical status colours.
|
||||||
|
--ts-viz-1: #2a78d6;
|
||||||
|
--ts-viz-2: #eb6834;
|
||||||
|
--ts-viz-ramp-1: #86b6ef;
|
||||||
|
--ts-viz-ramp-2: #5598e7;
|
||||||
|
--ts-viz-ramp-3: #256abf;
|
||||||
|
--ts-viz-ramp-4: #104281;
|
||||||
|
--ts-viz-good: #0ca30c;
|
||||||
|
--ts-viz-critical: #d03b3b;
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
@@ -316,6 +341,19 @@ h3 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Text for screen readers only (same helper as pireon.dev). */
|
||||||
|
.visually-hidden {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip-path: inset(50%);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.ts-prewrap {
|
.ts-prewrap {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
@@ -444,6 +482,18 @@ h3 {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
background: var(--pi-color-primary-50);
|
background: var(--pi-color-primary-50);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "Iniciar sesión" for visitors of public projects.
|
||||||
|
&--cta {
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--ts-brand);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #fff;
|
||||||
|
filter: brightness(1.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
@@ -501,6 +551,25 @@ html.dark-mode .pi-shell {
|
|||||||
--ts-t-task: #94a3b8;
|
--ts-t-task: #94a3b8;
|
||||||
--ts-t-qa: #fbbf24;
|
--ts-t-qa: #fbbf24;
|
||||||
|
|
||||||
|
--ts-c-slate: #94a3b8;
|
||||||
|
--ts-c-stone: #8a91a4;
|
||||||
|
--ts-c-violet: #ae5de5;
|
||||||
|
--ts-c-blue: #339be6;
|
||||||
|
--ts-c-sky: #38bdf8;
|
||||||
|
--ts-c-teal: #2dd4bf;
|
||||||
|
--ts-c-green: #34c98a;
|
||||||
|
--ts-c-amber: #eab308;
|
||||||
|
--ts-c-orange: #fb923c;
|
||||||
|
--ts-c-red: #f07070;
|
||||||
|
--ts-c-pink: #f472b6;
|
||||||
|
|
||||||
|
--ts-viz-1: #3987e5;
|
||||||
|
--ts-viz-2: #d95926;
|
||||||
|
--ts-viz-ramp-1: #184f95;
|
||||||
|
--ts-viz-ramp-2: #2a78d6;
|
||||||
|
--ts-viz-ramp-3: #6da7ec;
|
||||||
|
--ts-viz-ramp-4: #b7d3f6;
|
||||||
|
|
||||||
--pi-color-grey-10: #12141a;
|
--pi-color-grey-10: #12141a;
|
||||||
--pi-color-grey-25: #161922;
|
--pi-color-grey-25: #161922;
|
||||||
--pi-color-grey-50: #1a1e28;
|
--pi-color-grey-50: #1a1e28;
|
||||||
|
|||||||
16
docker-compose.override.yml
Normal file
16
docker-compose.override.yml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Development only: published ports, so `npx nx serve api` / `npx nx serve web` and your tools
|
||||||
|
# can reach the containers. Compose loads this file automatically with docker-compose.yml;
|
||||||
|
# production (Dokploy) runs `-f docker-compose.yml` alone and publishes nothing.
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
ports: ['8080:80']
|
||||||
|
api:
|
||||||
|
ports: ['3000:3000']
|
||||||
|
mcp:
|
||||||
|
ports: ['3010:3010']
|
||||||
|
postgres:
|
||||||
|
ports: ['5432:5432']
|
||||||
|
mongo:
|
||||||
|
ports: ['27017:27017']
|
||||||
|
redis:
|
||||||
|
ports: ['6379:6379']
|
||||||
@@ -1,16 +1,26 @@
|
|||||||
# TaskStream: web + API + infrastructure.
|
# TaskStream: web + API + infrastructure — production definition (Dokploy deploys this file).
|
||||||
|
# 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 --build → everything: web on http://localhost:8080
|
||||||
# docker compose up -d postgres mongo redis → only infrastructure (then `npx nx serve api` / `npx nx serve web`)
|
# docker compose up -d postgres mongo redis → only infrastructure (then `npx nx serve api` / `npx nx serve web`)
|
||||||
#
|
#
|
||||||
# Building needs access to Pireon's Nexus (@pi/ui): the token is read from ~/.npmrc as a build secret.
|
# Building needs access to Pireon's Nexus (@pi/ui): set NEXUS_TOKEN in the environment or in .env
|
||||||
|
# (Dokploy: Environment tab). It reaches the build as a secret and never lands in an image layer.
|
||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
target: web
|
target: web
|
||||||
secrets: [npmrc]
|
secrets: [nexus_token]
|
||||||
image: task-stream-web
|
image: task-stream-web
|
||||||
ports: ['8080:80']
|
expose: ['80']
|
||||||
|
networks: [default]
|
||||||
depends_on:
|
depends_on:
|
||||||
api: { condition: service_healthy }
|
api: { condition: service_healthy }
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -19,37 +29,72 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
target: api
|
target: api
|
||||||
secrets: [npmrc]
|
secrets: [nexus_token]
|
||||||
image: task-stream-api
|
image: task-stream-api
|
||||||
ports: ['3000:3000']
|
expose: ['3000']
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
aliases: [task-stream-api]
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_URL: postgres://taskstream:taskstream@postgres:5432/taskstream
|
POSTGRES_URL: postgres://taskstream:taskstream@task-stream-postgres:5432/taskstream
|
||||||
# No migrations yet: TypeORM creates the event store and lookup tables.
|
# No migrations yet: TypeORM creates the event store and lookup tables.
|
||||||
POSTGRES_SYNCHRONIZE: 'true'
|
POSTGRES_SYNCHRONIZE: 'true'
|
||||||
MONGO_URL: mongodb://mongo:27017/taskstream_read
|
MONGO_URL: mongodb://task-stream-mongo:27017/taskstream_read
|
||||||
REDIS_URL: redis://redis:6379
|
REDIS_URL: redis://task-stream-redis:6379
|
||||||
EVENT_STREAM_KEY: task-stream:events
|
EVENT_STREAM_KEY: task-stream:events
|
||||||
EVENTS_BRIDGE_ENABLED: 'true'
|
EVENTS_BRIDGE_ENABLED: 'true'
|
||||||
PIREON_BASE_URL: https://oauth.pireon.dev
|
PIREON_BASE_URL: https://oauth.pireon.dev
|
||||||
PIREON_WEBHOOK_SECRET: ${PIREON_WEBHOOK_SECRET:-}
|
PIREON_WEBHOOK_SECRET: ${PIREON_WEBHOOK_SECRET:-}
|
||||||
PIREON_CLIENT_ID: ${PIREON_CLIENT_ID:-}
|
PIREON_CLIENT_ID: ${PIREON_CLIENT_ID:-}
|
||||||
PIREON_CLIENT_SECRET: ${PIREON_CLIENT_SECRET:-}
|
PIREON_CLIENT_SECRET: ${PIREON_CLIENT_SECRET:-}
|
||||||
|
# Pireon clients allow one grant type: the one above is authorization_code,
|
||||||
|
# this one password (email + password sign-in). Optional.
|
||||||
|
PIREON_PASSWORD_CLIENT_ID: ${PIREON_PASSWORD_CLIENT_ID:-}
|
||||||
|
PIREON_PASSWORD_CLIENT_SECRET: ${PIREON_PASSWORD_CLIENT_SECRET:-}
|
||||||
GRAPHQL_PLAYGROUND: 'true'
|
GRAPHQL_PLAYGROUND: 'true'
|
||||||
# The SDL file is only useful in development (codegen).
|
# The SDL / OpenAPI files are only useful in development (codegen); the
|
||||||
|
# document is still served at /api/docs-json.
|
||||||
GRAPHQL_SCHEMA_FILE: ''
|
GRAPHQL_SCHEMA_FILE: ''
|
||||||
|
OPENAPI_FILE: ''
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres: { condition: service_healthy }
|
postgres: { condition: service_healthy }
|
||||||
mongo: { condition: service_healthy }
|
mongo: { condition: service_healthy }
|
||||||
redis: { condition: service_healthy }
|
redis: { condition: service_healthy }
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# MCP server for AI clients (Claude, Cursor…): Streamable HTTP at /mcp. It consumes the API
|
||||||
|
# with each caller's own Pireon token (Authorization header), so it holds no credentials.
|
||||||
|
# Optional: it only starts with the `mcp` profile (`docker compose --profile mcp up -d`; in
|
||||||
|
# Dokploy, COMPOSE_PROFILES=mcp) and needs a domain → service `mcp`, port 3010.
|
||||||
|
mcp:
|
||||||
|
profiles: [mcp]
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
target: mcp
|
||||||
|
secrets: [nexus_token]
|
||||||
|
image: task-stream-mcp
|
||||||
|
expose: ['3010']
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
aliases: [task-stream-mcp]
|
||||||
|
environment:
|
||||||
|
TASKSTREAM_API_URL: http://task-stream-api:3000
|
||||||
|
# Domains it answers on (protection against DNS rebinding): the one Dokploy gives it.
|
||||||
|
MCP_ALLOWED_HOSTS: ${MCP_ALLOWED_HOSTS:-mcp.taskstream.pireon.dev,localhost}
|
||||||
|
MCP_LOG_LEVEL: ${MCP_LOG_LEVEL:-warn}
|
||||||
|
depends_on:
|
||||||
|
api: { condition: service_healthy }
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:17-alpine
|
image: postgres:17-alpine
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
aliases: [task-stream-postgres]
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: taskstream
|
POSTGRES_USER: taskstream
|
||||||
POSTGRES_PASSWORD: taskstream
|
POSTGRES_PASSWORD: taskstream
|
||||||
POSTGRES_DB: taskstream
|
POSTGRES_DB: taskstream
|
||||||
ports: ['5432:5432']
|
|
||||||
volumes: [postgres-data:/var/lib/postgresql/data]
|
volumes: [postgres-data:/var/lib/postgresql/data]
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ['CMD-SHELL', 'pg_isready -U taskstream']
|
test: ['CMD-SHELL', 'pg_isready -U taskstream']
|
||||||
@@ -58,7 +103,9 @@ services:
|
|||||||
|
|
||||||
mongo:
|
mongo:
|
||||||
image: mongo:8
|
image: mongo:8
|
||||||
ports: ['27017:27017']
|
networks:
|
||||||
|
default:
|
||||||
|
aliases: [task-stream-mongo]
|
||||||
volumes: [mongo-data:/data/db]
|
volumes: [mongo-data:/data/db]
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ['CMD', 'mongosh', '--quiet', '--eval', 'db.adminCommand("ping")']
|
test: ['CMD', 'mongosh', '--quiet', '--eval', 'db.adminCommand("ping")']
|
||||||
@@ -67,8 +114,10 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
aliases: [task-stream-redis]
|
||||||
command: ['redis-server', '--appendonly', 'yes']
|
command: ['redis-server', '--appendonly', 'yes']
|
||||||
ports: ['6379:6379']
|
|
||||||
volumes: [redis-data:/data]
|
volumes: [redis-data:/data]
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ['CMD', 'redis-cli', 'ping']
|
test: ['CMD', 'redis-cli', 'ping']
|
||||||
@@ -81,5 +130,5 @@ volumes:
|
|||||||
redis-data:
|
redis-data:
|
||||||
|
|
||||||
secrets:
|
secrets:
|
||||||
npmrc:
|
nexus_token:
|
||||||
file: ${HOME}/.npmrc
|
environment: NEXUS_TOKEN
|
||||||
|
|||||||
@@ -2,18 +2,23 @@
|
|||||||
|
|
||||||
## Qué resuelve
|
## Qué resuelve
|
||||||
|
|
||||||
| Necesidad | Cómo se resuelve |
|
| Necesidad | Cómo se resuelve |
|
||||||
| ---------------- | ----------------------------------------------------------------------------------------------------- |
|
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas |
|
| 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas |
|
||||||
| 🎨 Cambios de UI | Tipo `UI_CHANGE` con enlace al frame de Figma (validado contra `figma.com`) |
|
| 🌍 Públicos | Un proyecto público se puede leer sin iniciar sesión (web, GraphQL y REST): tablero, tickets, comentarios, releases, actividad y estadísticas; «Explorar» los lista |
|
||||||
| ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo |
|
| 🗑️ Borrar | El propietario borra un proyecto confirmando su clave; tickets, comentarios, releases y enlaces desde otros proyectos se borran en cascada (sagas) y la clave queda libre |
|
||||||
| 🧪 QA | Estados `READY_FOR_QA → IN_QA → DONE / QA_FAILED`; fallar QA exige un motivo |
|
| 🧩 Configuración | Cada proyecto tiene sus estados (con categoría, color y motivo obligatorio), transiciones, tipos de ticket y áreas; lo usado se archiva, no se borra |
|
||||||
| 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) |
|
| 🎨 Cambios de UI | Tipo `UI_CHANGE` con enlace al frame de Figma (validado contra `figma.com`) |
|
||||||
| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona |
|
| ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo |
|
||||||
| 💬 Comentarios | Decisiones y explicaciones dentro del ticket; el autor edita, el admin modera |
|
| 🧪 QA | Por defecto, `READY_FOR_QA → IN_QA → DONE / QA_FAILED`; fallar QA exige un motivo |
|
||||||
| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA) |
|
| 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) |
|
||||||
| 📊 Seguimiento | Panel con contadores, gráfico por estado, carga por persona, feed de actividad e historial por ticket |
|
| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona |
|
||||||
| ⚡ Tiempo real | Tablero, comentarios, actividad, releases y membresías se actualizan solos (GraphQL subscriptions) |
|
| 💬 Comentarios | Decisiones y explicaciones dentro del ticket; el autor edita, el admin modera |
|
||||||
|
| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA) |
|
||||||
|
| 🏠 Inicio | «Mi trabajo»: mis tickets en todos los proyectos, por categoría (QA fallidos primero), lo que espera QA, próximas releases e «Ir a…» por clave |
|
||||||
|
| 📊 Seguimiento | Panel con contadores, flujo por estado, el equipo con sus tickets abiertos (enlaza a su tablero), feed de actividad e historial por ticket |
|
||||||
|
| 📈 Estadísticas | Creados vs. resueltos, flujo acumulado, burnup de releases, tiempos de ciclo y entrega, tasa de fallo en QA y resueltos por persona |
|
||||||
|
| ⚡ Tiempo real | Tablero, comentarios, actividad, releases y membresías se actualizan solos (GraphQL subscriptions) |
|
||||||
|
|
||||||
Los **usuarios no se gestionan aquí**: vienen del servidor de identidad **Pireon**
|
Los **usuarios no se gestionan aquí**: vienen del servidor de identidad **Pireon**
|
||||||
([Swagger](https://oauth.pireon.dev/api/documentation)). TaskStream gestiona proyectos, membresías y roles por
|
([Swagger](https://oauth.pireon.dev/api/documentation)). TaskStream gestiona proyectos, membresías y roles por
|
||||||
@@ -39,12 +44,13 @@ proyecto (`OWNER`, `ADMIN`, `MEMBER`, `VIEWER`).
|
|||||||
apps/
|
apps/
|
||||||
api/ composición de la API (NestJS): config, GraphQL, conexiones, e2e
|
api/ composición de la API (NestJS): config, GraphQL, conexiones, e2e
|
||||||
web/ composición del cliente (Angular): rutas, providers, estilos, nginx
|
web/ composición del cliente (Angular): rutas, providers, estilos, nginx
|
||||||
|
mcp/ servidor MCP para asistentes de IA (NestJS), consume la API REST
|
||||||
libs/
|
libs/
|
||||||
api/{shared,identity,tracker}/{domain,application,infrastructure,presenters}
|
api/{shared,identity,tracker}/{domain,application,infrastructure,presenters}
|
||||||
web/{shared,identity,tracker}/{domain,application,infrastructure,presenters} (+ web/shared/ui)
|
web/{shared,identity,tracker}/{domain,application,infrastructure,presenters} (+ web/shared/ui)
|
||||||
docs/ esta documentación
|
docs/ esta documentación
|
||||||
codegen.ts tipos GraphQL del cliente generados desde apps/api/schema.gql
|
codegen.ts tipos GraphQL del cliente generados desde apps/api/schema.gql
|
||||||
Dockerfile dos destinos: api y web
|
Dockerfile tres destinos: api, web y mcp
|
||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ sequenceDiagram
|
|||||||
`@AutowiredEvent`) y lo entrega a los `@EventsHandler` registrados para su clase. A diferencia de
|
`@AutowiredEvent`) y lo entrega a los `@EventsHandler` registrados para su clase. A diferencia de
|
||||||
`eventBus.subject$.next()`, **espera** a cada handler y procesa los eventos en orden; solo hace `XACK` cuando todos
|
`eventBus.subject$.next()`, **espera** a cada handler y procesa los eventos en orden; solo hace `XACK` cuando todos
|
||||||
terminan. Si uno falla, el evento queda pendiente y se reintenta (y `XAUTOCLAIM` recupera los de instancias caídas).
|
terminan. Si uno falla, el evento queda pendiente y se reintenta (y `XAUTOCLAIM` recupera los de instancias caídas).
|
||||||
|
- Después de los handlers, el bridge ejecuta también las **sagas** (ver abajo).
|
||||||
|
|
||||||
### Lectura: MongoDB
|
### Lectura: MongoDB
|
||||||
|
|
||||||
@@ -128,6 +129,34 @@ Tras actualizar Mongo, el handler publica en Redis pub/sub (`ChangeFeed`). Las s
|
|||||||
canales (uno por proyecto, ticket o usuario), así que funcionan con varias instancias de la API. Como se publica
|
canales (uno por proyecto, ticket o usuario), así que funcionan con varias instancias de la API. Como se publica
|
||||||
**después** de proyectar, quien recibe el aviso siempre lee el estado nuevo.
|
**después** de proyectar, quien recibe el aviso siempre lee el estado nuevo.
|
||||||
|
|
||||||
|
### Sagas: reacciones entre agregados
|
||||||
|
|
||||||
|
Cuando un evento de un agregado debe cambiar otros agregados, lo hace una saga (`application/sagas`), con el `@Saga()`
|
||||||
|
estándar de `@nestjs/cqrs`: un observable que filtra eventos (`ofType`) y emite comandos. Como el `EventBus` en memoria
|
||||||
|
nunca ve los eventos guardados (su publisher es el event store), las sagas las ejecuta el **`EventsBridge`**: a cada
|
||||||
|
evento, tras las proyecciones, pasa ese evento solo (`of(event)`) por cada saga y ejecuta los comandos que emite, uno a
|
||||||
|
uno y esperando. Así heredan las garantías del bridge: orden, reintento y nada se pierde si la API se cae a mitad.
|
||||||
|
|
||||||
|
| Saga | Evento | Comandos |
|
||||||
|
| --------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `ProjectDeletionSaga` | `ProjectDeletedEvent` | `DeleteIssueCommand` por cada ticket y `DeleteReleaseCommand` por cada release del proyecto |
|
||||||
|
| | `IssueDeletedEvent` | `PurgeCommentCommand` por cada comentario y `DropLinksToIssueCommand` en los tickets que lo enlazaban |
|
||||||
|
| `ReleaseArchivedSaga` | `ReleaseArchivedEvent` | `DetachIssueFromReleaseCommand` para sus tickets sin terminar (los terminados se quedan) |
|
||||||
|
| `UserDeactivatedSaga` | `UserDeactivatedEvent` (contexto identity) | `UnassignIssueCommand` para sus tickets abiertos en todos los proyectos, como actor `system` («TaskStream») |
|
||||||
|
|
||||||
|
Reglas que cumplen:
|
||||||
|
|
||||||
|
- **A quién afecta sale del event store**, no del read model (`TrackerStreamsRepository`, `ReleaseIssuesRepository`),
|
||||||
|
salvo la saga de usuarios, que busca tickets asignados ahora mismo en Mongo.
|
||||||
|
- **Comandos idempotentes**: un evento puede procesarse dos veces, así que cada comando comprueba si aún hay algo que
|
||||||
|
hacer (`WriteAccess.live()` devuelve `null` si el agregado no existe o ya está borrado; desasignar solo si sigue
|
||||||
|
asignado a esa persona…). Son comandos internos: ningún presenter los expone.
|
||||||
|
- **Nada bloquea el stream**: si el dominio rechaza un comando (no encontrado, regla de negocio…), el bridge lo registra
|
||||||
|
y sigue; los conflictos de concurrencia se reintentan hasta 5 veces y, si persisten, el evento queda pendiente.
|
||||||
|
- **Autoría**: los cambios se atribuyen a quien provocó el evento (quien borró el proyecto o archivó la release).
|
||||||
|
- **Entre contextos**: el webhook de Pireon guarda `UserDeactivatedEvent` en el event store (`EventRecorder`, stream
|
||||||
|
`identity-user-<id>`), y la saga del tracker reacciona a él sin que los contextos se llamen entre sí.
|
||||||
|
|
||||||
### Consistencia eventual
|
### Consistencia eventual
|
||||||
|
|
||||||
Las mutations devuelven `CommandResult { id, key, version }` en cuanto los eventos están guardados; Mongo se actualiza
|
Las mutations devuelven `CommandResult { id, key, version }` en cuanto los eventos están guardados; Mongo se actualiza
|
||||||
|
|||||||
239
docs/03-api.md
239
docs/03-api.md
@@ -11,7 +11,8 @@ classDiagram
|
|||||||
key: "TS"
|
key: "TS"
|
||||||
name
|
name
|
||||||
members: (userId, role)[]
|
members: (userId, role)[]
|
||||||
addMember() changeMemberRole() removeMember() updateDetails()
|
settings: estados · transiciones · tipos · áreas
|
||||||
|
addMember() changeMemberRole() removeMember() updateDetails() updateSettings()
|
||||||
}
|
}
|
||||||
class Issue {
|
class Issue {
|
||||||
key: "TS-42"
|
key: "TS-42"
|
||||||
@@ -47,7 +48,59 @@ classDiagram
|
|||||||
Cualquiera puede salir de un proyecto (salvo el propietario). Los no miembros reciben _not found_, para no revelar que
|
Cualquiera puede salir de un proyecto (salvo el propietario). Los no miembros reciben _not found_, para no revelar que
|
||||||
el proyecto existe.
|
el proyecto existe.
|
||||||
|
|
||||||
### Workflow de un ticket
|
### Proyectos públicos
|
||||||
|
|
||||||
|
Con `isPublic` (al crear, o después con `updateProject` / `PATCH /api/projects/{project}`, propietario o admin),
|
||||||
|
**cualquiera puede leer** el proyecto, también sin token: el proyecto y sus miembros, tablero, tickets, comentarios,
|
||||||
|
historial, releases (progreso y burnup), actividad, seguimiento y estadísticas. Escribir sigue siendo cosa de los
|
||||||
|
miembros según su rol (sin token: 401; con token pero sin ser miembro: 403). Un usuario con sesión que no es miembro
|
||||||
|
lee igual que un anónimo. Las subscriptions exigen sesión; con ella, también se pueden seguir proyectos públicos.
|
||||||
|
|
||||||
|
`publicProjects` (GraphQL) y `GET /api/explore/projects` (REST, sin token) los listan. Al volver a privado, quien no es
|
||||||
|
miembro vuelve a recibir _not found_. Los enlaces a tickets de otros proyectos solo se muestran si el visitante puede
|
||||||
|
leer esos proyectos.
|
||||||
|
|
||||||
|
### Borrar un proyecto
|
||||||
|
|
||||||
|
Solo el **propietario**, escribiendo la clave del proyecto para confirmar (`deleteProject(projectId, confirmKey)` o
|
||||||
|
`DELETE /api/projects/{project}?confirm=CLAVE`). El proyecto desaparece al momento y, a continuación, una saga borra
|
||||||
|
sus tickets (y sus comentarios), sus releases y los enlaces que tickets de otros proyectos tenían hacia los suyos (ver
|
||||||
|
[Arquitectura](02-arquitectura.md)). La clave queda libre para un proyecto nuevo. Los eventos se quedan en el event
|
||||||
|
store como auditoría, pero desde la API no se puede deshacer.
|
||||||
|
|
||||||
|
### Configuración por proyecto
|
||||||
|
|
||||||
|
Cada proyecto tiene sus **estados**, **transiciones**, **tipos de ticket** y **áreas** (`Project.settings`). Todos
|
||||||
|
empiezan con los valores por defecto de abajo, y el propietario o un admin los cambia con `updateProjectSettings` (o
|
||||||
|
`PUT /api/projects/{project}/settings`); el cambio es un evento más (`ProjectSettingsChangedEvent`).
|
||||||
|
|
||||||
|
| Valor | Campos | Por defecto |
|
||||||
|
| ------ | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Estado | `id`, `name`, `category`, `color`, `requiresReason`, `archived` (el orden es el del tablero) | `BACKLOG`, `TODO`, `IN_PROGRESS`, `IN_REVIEW`, `READY_FOR_QA`, `IN_QA`, `QA_FAILED`, `DONE`, `CANCELLED` |
|
||||||
|
| Tipo | `id`, `name`, `icon`, `color`, `archived` | `BUG`, `FEATURE`, `UI_CHANGE`, `TASK`, `QA` |
|
||||||
|
| Área | `id`, `name`, `archived` | `FRONTEND`, `BACKEND`, `MOBILE`, `QA`, `DESIGN`, `DEVOPS` |
|
||||||
|
|
||||||
|
La **categoría** de un estado dice qué significa, se llame como se llame: `TODO` (pendiente), `IN_PROGRESS`, `QA`,
|
||||||
|
`DONE` y `CANCELLED`. Releases («todo terminado» = `DONE` o `CANCELLED`), «Mi trabajo», el seguimiento y las
|
||||||
|
estadísticas trabajan con categorías, así que siguen funcionando con estados propios. Los tickets guardan el id del
|
||||||
|
estado y, en el read model, su categoría (`Issue.statusCategory`).
|
||||||
|
|
||||||
|
Reglas:
|
||||||
|
|
||||||
|
- **No se borra lo que ya existe: se archiva.** Tickets, historial y estadísticas siguen apuntando a ese id. Un valor
|
||||||
|
archivado no se ofrece para tickets nuevos ni recibe transiciones; los tickets que ya lo tienen lo conservan.
|
||||||
|
- Los ids son estables (`A-Z`, `0-9`, `_`, hasta 30, empezando por letra); los nombres, de 1 a 40 caracteres, se
|
||||||
|
pueden cambiar cuando se quiera.
|
||||||
|
- Hace falta al menos un estado activo de categoría `TODO` (los tickets nuevos entran en el primero), uno `DONE` y un
|
||||||
|
tipo activo.
|
||||||
|
- Las transiciones solo pueden apuntar a estados que existen y nunca de un estado a sí mismo.
|
||||||
|
- Colores: `slate`, `stone`, `violet`, `blue`, `sky`, `teal`, `green`, `amber`, `orange`, `red`, `pink`. Iconos:
|
||||||
|
`bug`, `feature`, `palette`, `task`, `qa`, `flag`, `star-fill`, `rocket`, `document`, `light-bulb`, `key`, `support`.
|
||||||
|
|
||||||
|
Los proyectos creados antes de que existiera la configuración usan los valores por defecto (sus eventos no la
|
||||||
|
llevan). Al arrancar, la API rellena la categoría de los tickets del read model que no la tienen.
|
||||||
|
|
||||||
|
### Workflow por defecto
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
stateDiagram-v2
|
stateDiagram-v2
|
||||||
@@ -76,9 +129,10 @@ stateDiagram-v2
|
|||||||
CANCELLED --> BACKLOG
|
CANCELLED --> BACKLOG
|
||||||
```
|
```
|
||||||
|
|
||||||
- `QA_FAILED` y `CANCELLED` exigen un **motivo**, que queda en el historial.
|
- `QA_FAILED` y `CANCELLED` exigen un **motivo** (`requiresReason`), que queda en el historial.
|
||||||
- No se puede pasar a `DONE` saltándose QA salvo desde `IN_REVIEW` (cambios que no requieren QA).
|
- No se puede pasar a `DONE` saltándose QA salvo desde `IN_REVIEW` (cambios que no requieren QA).
|
||||||
- `Issue.allowedTransitions` en GraphQL devuelve los estados permitidos desde el actual.
|
- `Issue.allowedTransitions` devuelve los estados activos a los que puede pasar el ticket según el workflow de su
|
||||||
|
proyecto.
|
||||||
|
|
||||||
### Otras reglas
|
### Otras reglas
|
||||||
|
|
||||||
@@ -96,41 +150,68 @@ stateDiagram-v2
|
|||||||
Todo cambio es un evento guardado en el event store (stream = id del agregado). Llevan `actorId`, `occurredAt`,
|
Todo cambio es un evento guardado en el event store (stream = id del agregado). Llevan `actorId`, `occurredAt`,
|
||||||
`aggregateId`, `projectId` y `aggregateVersion` (la posición en el stream).
|
`aggregateId`, `projectId` y `aggregateVersion` (la posición en el stream).
|
||||||
|
|
||||||
| Agregado | Eventos |
|
| Agregado | Eventos |
|
||||||
| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Project | `ProjectCreatedEvent`, `ProjectUpdatedEvent`, `ProjectMemberAddedEvent`, `ProjectMemberRoleChangedEvent`, `ProjectMemberRemovedEvent` |
|
| Project | `ProjectCreatedEvent`, `ProjectUpdatedEvent`, `ProjectSettingsChangedEvent`, `ProjectMemberAddedEvent`, `ProjectMemberRoleChangedEvent`, `ProjectMemberRemovedEvent`, `ProjectDeletedEvent` |
|
||||||
| Issue | `IssueCreatedEvent`, `IssueUpdatedEvent`, `IssueStatusChangedEvent`, `IssueAssignedEvent`, `IssueLinkedEvent`, `IssueUnlinkedEvent`, `IssueReleaseChangedEvent` |
|
| Issue | `IssueCreatedEvent`, `IssueUpdatedEvent`, `IssueStatusChangedEvent`, `IssueAssignedEvent`, `IssueLinkedEvent`, `IssueUnlinkedEvent`, `IssueReleaseChangedEvent`, `IssueDeletedEvent` |
|
||||||
| Comment | `CommentAddedEvent`, `CommentEditedEvent`, `CommentDeletedEvent` |
|
| Comment | `CommentAddedEvent`, `CommentEditedEvent`, `CommentDeletedEvent` |
|
||||||
| Release | `ReleaseCreatedEvent`, `ReleaseUpdatedEvent`, `ReleaseShippedEvent`, `ReleaseArchivedEvent` |
|
| Release | `ReleaseCreatedEvent`, `ReleaseUpdatedEvent`, `ReleaseShippedEvent`, `ReleaseArchivedEvent`, `ReleaseDeletedEvent` |
|
||||||
|
|
||||||
|
`IssueStatusChangedEvent` lleva además `meta` con el nombre y la categoría de los dos estados en el momento del
|
||||||
|
cambio, para que el historial y las estadísticas no cambien si luego se renombra o recategoriza un estado.
|
||||||
|
|
||||||
|
### Estadísticas
|
||||||
|
|
||||||
|
Una proyección más (`issue_timeline` en MongoDB) guarda cada paso de cada ticket: a qué estado pasó, con qué categoría,
|
||||||
|
quién lo tenía asignado, y cuándo entró o salió de una release. Las estadísticas se calculan sobre ella con funciones
|
||||||
|
puras (`application/src/lib/analytics`):
|
||||||
|
|
||||||
|
| Métrica | Definición |
|
||||||
|
| --------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Creados vs. resueltos | Por semana (lunes, UTC): tickets creados y tickets que entraron en una categoría `DONE` |
|
||||||
|
| Flujo acumulado | Tickets en cada estado al final de cada día |
|
||||||
|
| Tiempo de ciclo | Del primer paso a `IN_PROGRESS` o `QA` hasta `DONE`; media y percentil 85 |
|
||||||
|
| Tiempo de entrega | De la creación hasta `DONE`; media y percentil 85 |
|
||||||
|
| Throughput | Tickets resueltos por semana y por la persona que los tenía asignados |
|
||||||
|
| QA | Salidas de un estado `QA`: a `DONE` cuenta como superado; a cualquier otro sitio salvo `CANCELLED`, como fallido |
|
||||||
|
| Burnup de una release | Por día, desde que se creó hasta que se publica (o hoy, máx. 180 días): tickets en ella (sin cancelados) y hechos |
|
||||||
|
|
||||||
|
Los tickets anteriores a esta proyección solo tienen su creación y su estado actual; para tener su historia completa,
|
||||||
|
reconstruye el read model (ver [Despliegue](07-despliegue.md)).
|
||||||
|
|
||||||
## 3.2 GraphQL
|
## 3.2 GraphQL
|
||||||
|
|
||||||
Todas las operaciones requieren `Authorization: Bearer <token de Pireon>` (en subscriptions, en el `connection_init`).
|
Las operaciones requieren `Authorization: Bearer <token de Pireon>` (en subscriptions, en el `connection_init`), salvo
|
||||||
|
las lecturas de proyectos públicos (`project`, `issue`, `issues`, `release`, `activity`, `publicProjects` y sus campos),
|
||||||
|
que funcionan también sin token.
|
||||||
|
|
||||||
### Queries
|
### Queries
|
||||||
|
|
||||||
| Query | Devuelve |
|
| Query | Devuelve |
|
||||||
| ----------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
|
| ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `me` | El usuario autenticado, con sus roles en Pireon |
|
| `me` | El usuario autenticado, con sus roles en Pireon |
|
||||||
| `users(search, limit)` | Usuarios de Pireon (para asignar o añadir miembros) |
|
| `users(search, limit)` | Usuarios de Pireon (para asignar o añadir miembros) |
|
||||||
| `user(id)` | Un usuario |
|
| `user(id)` | Un usuario |
|
||||||
| `myProjects` | Proyectos de los que soy miembro |
|
| `myProjects` | Proyectos de los que soy miembro |
|
||||||
| `project(idOrKey)` | Un proyecto. Campos calculados: `board(releaseId, assigneeId, area, perColumn)`, `stats`, `releases` |
|
| `myIssues(assignedToMe, statuses, categories, limit, offset)` | «Mi trabajo»: tickets de todos mis proyectos, asignados a mí (por defecto) o de cualquiera (p. ej. `categories: [QA]`) |
|
||||||
| `issues(filter, limit, offset)` | Búsqueda de tickets (estado, tipo, área, prioridad, persona, release, etiquetas, texto) |
|
| `upcomingReleases(limit)` | Releases planificadas de mis proyectos, por fecha objetivo |
|
||||||
| `issue(idOrKey)` | Un ticket. Campos calculados: `allowedTransitions`, `linkedIssues`, `comments`, `history` |
|
| `publicProjects` | Proyectos públicos (sin token) |
|
||||||
| `release(id)` | Una release. Campos calculados: `progress`, `issues` |
|
| `project(idOrKey)` | Un proyecto (mío, o público) con su `settings`. Campos calculados: `board(releaseId, assigneeId, area, perColumn)`, `stats`, `analytics(days)`, `releases` |
|
||||||
| `activity(projectId, issueId?, limit, before?)` | Feed de actividad, del más reciente al más antiguo |
|
| `issues(filter, limit, offset)` | Búsqueda de tickets (estado, tipo, área, prioridad, persona, release, etiquetas, texto) |
|
||||||
|
| `issue(idOrKey)` | Un ticket. Campos calculados: `allowedTransitions`, `linkedIssues`, `comments`, `history` |
|
||||||
|
| `release(id)` | Una release. Campos calculados: `progress`, `burnup`, `issues` |
|
||||||
|
| `activity(projectId, issueId?, limit, before?)` | Feed de actividad, del más reciente al más antiguo |
|
||||||
|
|
||||||
### Mutations
|
### Mutations
|
||||||
|
|
||||||
Todas devuelven `CommandResult { id, key, version }`.
|
Todas devuelven `CommandResult { id, key, version }`.
|
||||||
|
|
||||||
| Ámbito | Mutations |
|
| Ámbito | Mutations |
|
||||||
| ----------- | ----------------------------------------------------------------------------------------------------------------- |
|
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| Proyectos | `createProject`, `updateProject`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
|
| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
|
||||||
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease` |
|
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease` |
|
||||||
| Comentarios | `addComment`, `editComment`, `deleteComment` |
|
| Comentarios | `addComment`, `editComment`, `deleteComment` |
|
||||||
| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `archiveRelease` |
|
| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `archiveRelease` |
|
||||||
|
|
||||||
### Subscriptions
|
### Subscriptions
|
||||||
|
|
||||||
@@ -140,14 +221,23 @@ Todas devuelven `CommandResult { id, key, version }`.
|
|||||||
| `commentChanged(issueId)` | Se añade, edita o borra un comentario (`comment` es `null` al borrar) |
|
| `commentChanged(issueId)` | Se añade, edita o borra un comentario (`comment` es `null` al borrar) |
|
||||||
| `activityAdded(projectId)` | Hay una nueva entrada en el feed |
|
| `activityAdded(projectId)` | Hay una nueva entrada en el feed |
|
||||||
| `releaseChanged(projectId)` | Cambia una release |
|
| `releaseChanged(projectId)` | Cambia una release |
|
||||||
| `projectChanged(projectId)` | Cambian los datos o los miembros del proyecto |
|
| `projectChanged(projectId)` | Cambian los datos, la configuración o los miembros del proyecto |
|
||||||
| `myProjectsChanged` | Me añaden a un proyecto o me quitan de uno |
|
| `myProjectsChanged` | Me añaden a un proyecto o me quitan de uno |
|
||||||
|
|
||||||
Solo los miembros pueden suscribirse a un proyecto; las conexiones sin token válido se cierran con el código 4403.
|
Solo los miembros pueden suscribirse a un proyecto; las conexiones sin token válido se cierran con el código 4403.
|
||||||
|
|
||||||
### Ejemplos
|
### Ejemplos
|
||||||
|
|
||||||
|
Estados, tipos y áreas son texto (ids de la configuración del proyecto), no enums: van entre comillas.
|
||||||
|
|
||||||
```graphql
|
```graphql
|
||||||
|
mutation ($settings: ProjectSettingsInput!) {
|
||||||
|
# La configuración entera (la que devuelve project.settings, cambiada).
|
||||||
|
updateProjectSettings(projectId: "…", settings: $settings) {
|
||||||
|
version
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
mutation {
|
mutation {
|
||||||
createProject(input: { key: "SHOP", name: "Checkout revamp" }) {
|
createProject(input: { key: "SHOP", name: "Checkout revamp" }) {
|
||||||
id
|
id
|
||||||
@@ -157,7 +247,7 @@ mutation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mutation {
|
mutation {
|
||||||
createIssue(input: { projectId: "…", type: BUG, title: "El botón de pagar no responde en Safari", area: FRONTEND, priority: CRITICAL, assigneeId: "102", labels: ["safari"] }) {
|
createIssue(input: { projectId: "…", type: "BUG", title: "El botón de pagar no responde en Safari", area: "FRONTEND", priority: CRITICAL, assigneeId: "102", labels: ["safari"] }) {
|
||||||
id
|
id
|
||||||
key
|
key
|
||||||
version
|
version
|
||||||
@@ -165,7 +255,7 @@ mutation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mutation {
|
mutation {
|
||||||
changeIssueStatus(input: { issueId: "…", status: QA_FAILED, reason: "Sigue fallando en Safari 17" }) {
|
changeIssueStatus(input: { issueId: "…", status: "QA_FAILED", reason: "Sigue fallando en Safari 17" }) {
|
||||||
version
|
version
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -210,6 +300,27 @@ query {
|
|||||||
percent
|
percent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
analytics(days: 90) {
|
||||||
|
createdVsResolved {
|
||||||
|
week
|
||||||
|
created
|
||||||
|
resolved
|
||||||
|
}
|
||||||
|
cycleTime {
|
||||||
|
averageDays
|
||||||
|
p85Days
|
||||||
|
}
|
||||||
|
qa {
|
||||||
|
failureRate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
release(id: "…") {
|
||||||
|
burnup {
|
||||||
|
dates
|
||||||
|
scope
|
||||||
|
done
|
||||||
|
}
|
||||||
}
|
}
|
||||||
issue(idOrKey: "SHOP-1") {
|
issue(idOrKey: "SHOP-1") {
|
||||||
status
|
status
|
||||||
@@ -265,13 +376,63 @@ Los errores de dominio llegan como errores GraphQL con `extensions.code`:
|
|||||||
| `BUSINESS_RULE` | Regla de negocio (transición no permitida, release bloqueada…) |
|
| `BUSINESS_RULE` | Regla de negocio (transición no permitida, release bloqueada…) |
|
||||||
| `CONFLICT` | Otro cambio llegó antes (concurrencia optimista): reintenta |
|
| `CONFLICT` | Otro cambio llegó antes (concurrencia optimista): reintenta |
|
||||||
|
|
||||||
## 3.3 REST
|
## 3.3 REST con OpenAPI (para otras apps)
|
||||||
|
|
||||||
| Endpoint | Uso |
|
Los mismos casos de uso que GraphQL, como API REST documentada con OpenAPI 3.0, para que otras apps de la plataforma
|
||||||
| --------------------------- | ----------------------------------------------------------------------------------------------- |
|
la consuman o generen un cliente. Son otro adaptador de entrada (`presenters/http`) que llama a los mismos
|
||||||
| `GET /api/health` | Estado de PostgreSQL, MongoDB y Redis (público) |
|
`*.service.ts`: misma validación, permisos y errores.
|
||||||
| `GET /api/auth/config` | Configuración para iniciar sesión con Pireon desde el navegador (público) |
|
|
||||||
| `POST /api/auth/token` | Proxy de `/oauth/token` de Pireon: `authorization_code`, `password` y `refresh_token` (público) |
|
|
||||||
| `POST /api/webhooks/pireon` | Webhooks de Pireon firmados con HMAC (`user.deactivated`, `token.revoked`) |
|
|
||||||
|
|
||||||
Ver [Autenticación](05-autenticacion.md).
|
| | |
|
||||||
|
| ----------------- | ----------------------------------------------------------------------------------- |
|
||||||
|
| Swagger UI | `/api/docs` (con «Authorize» contra Pireon) |
|
||||||
|
| Documento OpenAPI | `/api/docs-json` (y `/api/docs-yaml`) |
|
||||||
|
| Fichero exportado | `apps/api/openapi.json`: lo reescribe `nx serve api` al arrancar, como `schema.gql` |
|
||||||
|
|
||||||
|
### Autenticación: siempre a través de Pireon
|
||||||
|
|
||||||
|
Cualquier app o usuario que la consuma pasa por el OAuth de Pireon: todas las rutas, salvo `health`, `auth`, el
|
||||||
|
webhook, `explore` y las lecturas de proyectos públicos, exigen `Authorization: Bearer <token de Pireon>` (el
|
||||||
|
`AuthGuard` global, igual que en GraphQL). En el documento OpenAPI, las rutas donde el token es opcional lo indican con
|
||||||
|
un requisito de seguridad vacío (`{}`). Declara dos esquemas:
|
||||||
|
|
||||||
|
- **`pireon-oauth`** (OAuth2): _Authorization Code + PKCE_ contra `oauth.pireon.dev/oauth/authorize` y, si
|
||||||
|
`PIREON_PASSWORD_GRANT=true`, _password_. El intercambio de tokens va por `POST /api/auth/token` (Pireon no tiene
|
||||||
|
CORS en `/oauth/token` y el `client_secret` se queda en el servidor). Para usar «Authorize» en Swagger UI, registra
|
||||||
|
`<api>/api/docs/oauth2-redirect.html` como redirect URI del cliente en Pireon.
|
||||||
|
- **`pireon`** (Bearer): para una app que ya tiene un token de Pireon, por ejemplo con su propio cliente OAuth.
|
||||||
|
|
||||||
|
Lo que cada usuario puede ver o cambiar depende de su rol en cada proyecto, como en la web.
|
||||||
|
|
||||||
|
### Recursos
|
||||||
|
|
||||||
|
Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
|
||||||
|
|
||||||
|
| Recurso | Rutas |
|
||||||
|
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` |
|
||||||
|
| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `PUT …/settings`, `…/board`, `…/stats`, `…/analytics?days=`, `…/activity`, `…/members` (POST, PATCH, DELETE) |
|
||||||
|
| Tickets | `GET /api/me/issues` (mi trabajo), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history` |
|
||||||
|
| Comentarios | `PATCH/DELETE /api/comments/{commentId}` |
|
||||||
|
| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` |
|
||||||
|
| Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) |
|
||||||
|
| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` |
|
||||||
|
|
||||||
|
Los filtros de tickets aceptan `status` (ids del proyecto) y `category` (`?category=QA`), repetidos o separados por
|
||||||
|
comas. Las escrituras responden `{ id, key?, version }` en cuanto el evento se guarda; las lecturas salen del read model, que
|
||||||
|
se pone al día en milisegundos (un `GET` justo después puede devolver la versión anterior, o 404 tras crear). Para
|
||||||
|
tiempo real, las subscriptions de GraphQL.
|
||||||
|
|
||||||
|
### Errores
|
||||||
|
|
||||||
|
`{ statusCode, code?, message }`: `400` entrada no válida (`message` es una lista), `401` sin token o token rechazado
|
||||||
|
por Pireon, `403`/`404` sin acceso, `409` cambio concurrente (reintentar) y `422` regla de negocio (p. ej. una
|
||||||
|
transición que el workflow no permite).
|
||||||
|
|
||||||
|
### Generar un cliente en otra app
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npx openapi-typescript apps/api/openapi.json -o src/task-stream-api.d.ts # tipos
|
||||||
|
# o un cliente completo: openapi-generator, orval, ng-openapi-gen…
|
||||||
|
```
|
||||||
|
|
||||||
|
Ver también [Autenticación](05-autenticacion.md).
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export const boardPageEvents = eventGroup({
|
|||||||
source: 'Board Page',
|
source: 'Board Page',
|
||||||
events: {
|
events: {
|
||||||
opened: type<BoardFilter>(),
|
opened: type<BoardFilter>(),
|
||||||
issueMoved: type<{ issueId: string; to: IssueStatus; reason?: string | null }>(),
|
issueMoved: type<{ issueId: string; to: string; reason?: string | null }>(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ export class BoardFacade {
|
|||||||
else this.dispatch.opened(filter);
|
else this.dispatch.opened(filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
moveIssue(issueId: string, to: IssueStatus, reason?: string | null): void {
|
moveIssue(issueId: string, to: string, reason?: string | null): void {
|
||||||
this.dispatch.issueMoved({ issueId, to, reason });
|
this.dispatch.issueMoved({ issueId, to, reason });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,13 +109,15 @@ Qué gana la página:
|
|||||||
- **Un sitio para la lógica de presentación**: abrir o refiltrar el tablero, buscar usuarios a partir de 2 caracteres…
|
- **Un sitio para la lógica de presentación**: abrir o refiltrar el tablero, buscar usuarios a partir de 2 caracteres…
|
||||||
- **Tests sin plantilla**: la facade se prueba con gateways falsos (`facades.spec.ts`).
|
- **Tests sin plantilla**: la facade se prueba con gateways falsos (`facades.spec.ts`).
|
||||||
|
|
||||||
| Facade | Store | Ámbito | Responsabilidad |
|
| Facade | Store | Ámbito | Responsabilidad |
|
||||||
| ---------------- | --------------- | ----------------------- | --------------------------------------------------------------------------------------------------------- |
|
| ---------------- | --------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `AuthFacade` | `AuthStore` | raíz | Sesión, usuario, login (PKCE o email y contraseña de Pireon), renovación del token, restaurar al arrancar |
|
| `AuthFacade` | `AuthStore` | raíz | Sesión, usuario, login (PKCE o email y contraseña de Pireon), renovación del token, restaurar al arrancar |
|
||||||
| `ProjectsFacade` | `ProjectsStore` | raíz | Mis proyectos; se actualiza cuando me añaden o quitan (`myProjectsChanged`) |
|
| `ProjectsFacade` | `ProjectsStore` | raíz | Mis proyectos; se actualiza cuando me añaden o quitan (`myProjectsChanged`) |
|
||||||
| `ProjectFacade` | `ProjectStore` | ruta `/p/:key` | Proyecto, miembros, stats, releases, actividad en vivo y búsqueda de usuarios |
|
| `HomeFacade` | `HomeStore` | raíz | «Mi trabajo» en la portada: mis tickets abiertos por categoría (QA fallidos primero), esperando QA y próximas releases; se recarga al volver a la pestaña |
|
||||||
| `BoardFacade` | `BoardStore` | ruta `board` | Tablero kanban, filtros, movimiento optimista, nuevo ticket, cambios en vivo |
|
| `ExploreFacade` | `ExploreStore` | raíz | Proyectos públicos («Explorar»), también sin sesión |
|
||||||
| `IssueFacade` | `IssueStore` | ruta `issues/:issueKey` | Ticket, comentarios en vivo, historial y todas sus acciones |
|
| `ProjectFacade` | `ProjectStore` | ruta `/p/:key` | Proyecto, miembros, stats, releases, actividad en vivo, búsqueda de usuarios, configuración (`workflow`, `saveSettings`) y estadísticas (`loadAnalytics`, `loadBurnup`) |
|
||||||
|
| `BoardFacade` | `BoardStore` | ruta `board` | Tablero kanban, filtros, movimiento optimista, nuevo ticket, cambios en vivo |
|
||||||
|
| `IssueFacade` | `IssueStore` | ruta `issues/:issueKey` | Ticket, comentarios en vivo, historial y todas sus acciones |
|
||||||
|
|
||||||
Las de ámbito de ruta se registran con `provideProjectFacade()`, `provideBoardFacade()` o `provideIssueFacade()` en los
|
Las de ámbito de ruta se registran con `provideProjectFacade()`, `provideBoardFacade()` o `provideIssueFacade()` en los
|
||||||
`providers` del componente de la página (store + facade) y desaparecen al salir (con sus subscriptions). Las páginas
|
`providers` del componente de la página (store + facade) y desaparecen al salir (con sus subscriptions). Las páginas
|
||||||
@@ -145,7 +147,9 @@ de identidad (`SessionTokenSource`) le pide el token para el cliente GraphQL.
|
|||||||
npx nx run web:codegen # regenera generated.ts (TypedDocumentNode)
|
npx nx run web:codegen # regenera generated.ts (TypedDocumentNode)
|
||||||
```
|
```
|
||||||
|
|
||||||
- Los adaptadores convierten los tipos generados en modelos de dominio en un único sitio (`adapters/mappers.ts`).
|
- Los adaptadores convierten los tipos generados en modelos de dominio en un único sitio (`adapters/mappers.ts`). Lo
|
||||||
|
que se lee y luego se reenvía como input (la configuración del proyecto) se copia campo a campo: Apollo añade
|
||||||
|
`__typename` a cada objeto y los input types de GraphQL lo rechazan.
|
||||||
|
|
||||||
## 4.4 `@pi/ui`
|
## 4.4 `@pi/ui`
|
||||||
|
|
||||||
@@ -193,19 +197,62 @@ versiones) y un color por estado (`--ts-s-*`) y por tipo de ticket (`--ts-t-*`),
|
|||||||
gráficas e historial. Salen de `tone` en `ui/copy.ts` y las plantillas los aplican con `[style.--tone]`; utilidades
|
gráficas e historial. Salen de `tone` en `ui/copy.ts` y las plantillas los aplican con `[style.--tone]`; utilidades
|
||||||
globales como `.ts-pill` o `.ts-key` leen esa variable. Las animaciones se desactivan con `prefers-reduced-motion`.
|
globales como `.ts-pill` o `.ts-key` leen esa variable. Las animaciones se desactivan con `prefers-reduced-motion`.
|
||||||
|
|
||||||
|
### Configuración del proyecto en la UI
|
||||||
|
|
||||||
|
Estados, tipos y áreas son de cada proyecto, así que la UI nunca los tiene fijos: `ProjectFacade.workflow()` envuelve
|
||||||
|
`project.settings` (`Workflow` en el dominio web) y da nombre, color, icono, categoría, transiciones permitidas y si un
|
||||||
|
estado pide motivo. `statusLook`/`typeLook` de `ui/copy.ts` lo convierten en etiqueta y tono (`--ts-c-<color>`, con su
|
||||||
|
versión oscura). Un id que ya no está configurado sigue mostrándose (`IN_REVIEW` → «In review»). «Mi trabajo» mezcla
|
||||||
|
proyectos: cada fila usa el workflow de su proyecto y los grupos son por categoría.
|
||||||
|
|
||||||
|
En el tablero, un ticket solo se puede soltar en las columnas a las que su workflow le deja pasar, y los tipos
|
||||||
|
archivados no se ofrecen al crear. Los tipos con icono `palette` piden un frame de Figma.
|
||||||
|
|
||||||
|
### Gráficas
|
||||||
|
|
||||||
|
Las de Estadísticas son SVG propios (`ui/series-chart.ts`), no `pi-line-chart`: hacen falta áreas apiladas y una
|
||||||
|
paleta con versión oscura, que los gráficos de `@pi/ui` (Chart.js con colores fijos) no traen. Siguen las reglas de
|
||||||
|
visualización de la plataforma:
|
||||||
|
|
||||||
|
- Colores `--ts-viz-*` en `styles.scss`, validados contra la superficie clara y la oscura (separación para daltonismo,
|
||||||
|
contraste): dos series categóricas (azul y naranja), una rampa ordinal de un solo tono para las etapas del flujo y
|
||||||
|
bueno/crítico reservados para QA superado/fallido, siempre con icono y texto.
|
||||||
|
- Líneas de 2 px, rejilla de 1 px continua, separación de 2 px entre bandas apiladas, una sola escala por gráfica.
|
||||||
|
- Cruceta que se ajusta al día o semana más cercano y un único tooltip con todas las series; con teclado, ← → Inicio
|
||||||
|
Fin sobre la gráfica.
|
||||||
|
- Leyenda con dos o más series, etiqueta de valor al final de cada línea solo si no se solapan, y «Ver los datos» con
|
||||||
|
la tabla, para que nada dependa del color ni del ratón.
|
||||||
|
- Mientras se recalcula un periodo, se queda la gráfica anterior atenuada.
|
||||||
|
|
||||||
|
### Proyectos públicos y visitantes sin sesión
|
||||||
|
|
||||||
|
- Solo la portada (`/`) exige sesión. `/explorar` y las páginas de proyecto (`/p/:key/…`) no: la API decide qué ve
|
||||||
|
cada visitante, y las páginas ya ocultan lo que no puede cambiar (`canContribute`, `canManage`).
|
||||||
|
- Sin sesión, la cabecera muestra «Explorar» e «Iniciar sesión»; dentro de un proyecto público aparece un aviso de modo
|
||||||
|
lectura, y en uno privado (o inexistente), el enlace para iniciar sesión volviendo a esa página (`returnUrl`).
|
||||||
|
- El tiempo real necesita sesión: `GraphqlRealtimeGateway` no abre subscriptions sin token (si lo hiciera, la API
|
||||||
|
cerraría el WebSocket y el cliente reintentaría sin fin). Los visitantes anónimos ven los datos al cargar la página.
|
||||||
|
- Configuración tiene el interruptor «Público» (propietario y admins) y, solo para el propietario, «Eliminar el
|
||||||
|
proyecto», que exige escribir la clave. Al borrarlo se vuelve a la portada; si otro lo borra mientras lo tienes
|
||||||
|
abierto, la subscription avisa (`ProjectChanged.deleted`) y también se vuelve a la portada.
|
||||||
|
|
||||||
## 4.6 Rutas
|
## 4.6 Rutas
|
||||||
|
|
||||||
| Ruta | Página |
|
| Ruta | Página |
|
||||||
| -------------------------- | ----------------------------------------------- |
|
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `/login` | Inicio de sesión (solo sin sesión) |
|
| `/login` | Inicio de sesión (solo sin sesión) |
|
||||||
| `/auth/callback` | Retorno de Pireon (PKCE) |
|
| `/auth/callback` | Retorno de Pireon (PKCE) |
|
||||||
| `/` | Mis proyectos |
|
| `/` | Inicio: «Ir a…» (`WEB-12`), mi trabajo (`ts-my-work`) y mis proyectos |
|
||||||
| `/p/:key/board` | Tablero kanban (`?release=` filtra por release) |
|
| `/explorar` | Proyectos públicos (sin sesión) |
|
||||||
| `/p/:key/issues/:issueKey` | Ticket |
|
| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros) |
|
||||||
| `/p/:key/releases` | Releases |
|
| `/p/:key/issues/:issueKey` | Ticket |
|
||||||
| `/p/:key/dashboard` | Seguimiento |
|
| `/p/:key/releases` | Releases |
|
||||||
| `/p/:key/activity` | Actividad en vivo |
|
| `/p/:key/dashboard` | Seguimiento: cifras, flujo por estado, equipo (cada persona enlaza a su tablero) y tipos |
|
||||||
| `/p/:key/members` | Miembros |
|
| `/p/:key/analytics` | Estadísticas: creados vs. resueltos, flujo acumulado, burnup de una release, tiempos de ciclo y entrega, QA y resueltos por persona (30 días a 1 año) |
|
||||||
|
| `/p/:key/settings` | Configuración (propietario y admins; pestaña oculta para el resto): estados (orden, categoría, color, motivo), transiciones, tipos y áreas; archivar en lugar de borrar |
|
||||||
|
| `/p/:key/activity` | Actividad en vivo |
|
||||||
|
| `/p/:key/members` | Miembros |
|
||||||
|
|
||||||
Todo salvo `/login` y `/auth/callback` está protegido por `authGuard`. Los parámetros de ruta llegan como inputs de
|
Solo `/` está protegida por `authGuard` (redirige a `/login?returnUrl=…`); `/login` y `/auth/callback` son para quien
|
||||||
|
no tiene sesión, y el resto se puede abrir sin ella si el proyecto es público. Los parámetros de ruta llegan como inputs de
|
||||||
los componentes (`withComponentInputBinding`).
|
los componentes (`withComponentInputBinding`).
|
||||||
|
|||||||
@@ -29,7 +29,11 @@ sequenceDiagram
|
|||||||
- Se cachea el **hash SHA-256** del token, nunca el token en claro.
|
- Se cachea el **hash SHA-256** del token, nunca el token en claro.
|
||||||
- Si Pireon marca al usuario como inactivo, el token se rechaza.
|
- Si Pireon marca al usuario como inactivo, el token se rechaza.
|
||||||
- El guard es global: todo requiere token salvo lo marcado con `@Public()` (health, configuración de login, proxy de
|
- El guard es global: todo requiere token salvo lo marcado con `@Public()` (health, configuración de login, proxy de
|
||||||
tokens y webhook).
|
tokens, webhook y `GET /api/explore/projects`).
|
||||||
|
- `@OptionalAuth()` (o `@ApiOptionalPireonAuth()` en REST) deja pasar sin token: es para las lecturas que permiten los
|
||||||
|
proyectos públicos (proyecto, tablero, tickets, comentarios, releases, actividad, estadísticas). Sin token, el
|
||||||
|
usuario es `undefined` y el visitante es anónimo; un token presente pero inválido sigue dando 401. Las escrituras, las
|
||||||
|
subscriptions y lo «mío» (`myProjects`, `myIssues`, `/api/me/…`) siguen exigiendo sesión.
|
||||||
- En subscriptions, el token se valida una vez al abrir el WebSocket (`connection_init`).
|
- En subscriptions, el token se valida una vez al abrir el WebSocket (`connection_init`).
|
||||||
- `@RequireAppRoles('…')` permite exigir roles del cliente OAuth en Pireon (los admins globales de Pireon los tienen todos).
|
- `@RequireAppRoles('…')` permite exigir roles del cliente OAuth en Pireon (los admins globales de Pireon los tienen todos).
|
||||||
|
|
||||||
@@ -38,27 +42,70 @@ sequenceDiagram
|
|||||||
Pireon **no permite CORS** en `/oauth/token` y el `client_secret` no debe llegar al navegador, así que la web pasa por
|
Pireon **no permite CORS** en `/oauth/token` y el `client_secret` no debe llegar al navegador, así que la web pasa por
|
||||||
la API: `POST /api/auth/token` hace de proxy hacia Pireon.
|
la API: `POST /api/auth/token` hace de proxy hacia Pireon.
|
||||||
|
|
||||||
| Método | Necesita | Flujo |
|
Cada cliente OAuth de Pireon admite **un único tipo de grant**, así que cada forma de entrar usa su cliente:
|
||||||
| ------------------------------------------------- | ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| **Entrar con Pireon** (Authorization Code + PKCE) | `PIREON_CLIENT_ID`, `PIREON_CLIENT_SECRET` y la redirect URI registrada | Web → `/oauth/authorize` de Pireon → `/auth/callback` → `POST /api/auth/token` (`authorization_code` + `code_verifier`) |
|
|
||||||
| **Email y contraseña** | El mismo cliente, con `PIREON_PASSWORD_GRANT=true` | `POST /api/auth/token` (`password`) |
|
|
||||||
|
|
||||||
Las dos opciones necesitan el cliente OAuth: sin `PIREON_CLIENT_ID` la API responde `503` y el login avisa de que no
|
| Método | Cliente de Pireon (`grant_type`) | Variables | Flujo |
|
||||||
está configurado. El email y la contraseña son los de la cuenta de Pireon; TaskStream no los guarda, solo los reenvía.
|
| ------------------------------------------------- | -------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------- |
|
||||||
(El login con token de acceso personal se ha retirado de la web por ahora; la API sigue aceptando esos tokens como
|
| **Entrar con Pireon** (Authorization Code + PKCE) | `authorization_code` | `PIREON_CLIENT_ID`, `PIREON_CLIENT_SECRET` | Web → `/oauth/authorize` de Pireon → `/auth/callback` → `POST /api/auth/token` (`authorization_code` + `code_verifier`) |
|
||||||
`Bearer`.)
|
| **Email y contraseña** | `password` | `PIREON_PASSWORD_CLIENT_ID`, `PIREON_PASSWORD_CLIENT_SECRET` | `POST /api/auth/token` (`password`); se desactiva con `PIREON_PASSWORD_GRANT=false` |
|
||||||
|
|
||||||
|
Si no se definen las variables `PIREON_PASSWORD_CLIENT_*`, el login con contraseña usa `PIREON_CLIENT_ID` (sirve si
|
||||||
|
solo hay un cliente de tipo `password`, pero entonces «Entrar con Pireon» falla con `unauthorized_client`). Al renovar
|
||||||
|
la sesión, la API prueba los dos clientes: un `refresh_token` solo vale con el cliente que lo emitió.
|
||||||
|
|
||||||
|
El email y la contraseña son los de la cuenta de Pireon; TaskStream no los guarda, solo los reenvía. (El login con
|
||||||
|
token de acceso personal se ha retirado de la web por ahora; la API sigue aceptando esos tokens como `Bearer`.)
|
||||||
|
|
||||||
### Registrar TaskStream como cliente en Pireon
|
### Registrar TaskStream como cliente en Pireon
|
||||||
|
|
||||||
1. Crea el cliente: `POST https://oauth.pireon.dev/api/clients` (con un token tuyo de Pireon, scope `account:write`).
|
Con un token tuyo de Pireon (scope `account:write`), `POST https://oauth.pireon.dev/api/clients`:
|
||||||
2. Registra como redirect URIs:
|
|
||||||
- `http://localhost:4200/auth/callback` (desarrollo)
|
|
||||||
- `http://localhost:8080/auth/callback` (Docker)
|
|
||||||
- la de producción
|
|
||||||
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`,
|
1. **Cliente para «Entrar con Pireon»**: `{"name": "TaskStream", "grant_type": "authorization_code", "confidential": true,
|
||||||
`passwordGrant`), o `null` si no hay cliente configurado; en ese caso el login muestra el aviso y no deja entrar.
|
"redirect_uris": [...]}` con estas redirect URIs:
|
||||||
|
- `http://localhost:4200/auth/callback` (desarrollo) y `http://localhost:8080/auth/callback` (Docker)
|
||||||
|
- `https://taskstream.pireon.dev/auth/callback` (producción)
|
||||||
|
- para el «Authorize» de Swagger UI: `http://localhost:3000/api/docs/oauth2-redirect.html` y
|
||||||
|
`https://api.taskstream.pireon.dev/api/docs/oauth2-redirect.html` (ver
|
||||||
|
[API REST](03-api.md#33-rest-con-openapi-para-otras-apps))
|
||||||
|
|
||||||
|
→ `PIREON_CLIENT_ID` y `PIREON_CLIENT_SECRET`. Al ser confidencial, **el secret es obligatorio**: sin él Pireon
|
||||||
|
rechaza el canje del código (ver la tabla de errores más abajo).
|
||||||
|
|
||||||
|
2. **Cliente para email y contraseña**: `{"name": "TaskStream (password)", "grant_type": "password"}`
|
||||||
|
→ `PIREON_PASSWORD_CLIENT_ID` y `PIREON_PASSWORD_CLIENT_SECRET`.
|
||||||
|
|
||||||
|
Pireon muestra el secret (`plain_secret`) **una sola vez**, en la respuesta de alta. Si se pierde, se genera otro con
|
||||||
|
`POST https://oauth.pireon.dev/api/clients/{client}/rotate-secret` (el anterior sigue valiendo 48 h).
|
||||||
|
|
||||||
|
Ponlos en `.env` (o en el entorno del contenedor; en Dokploy, en _Environment_) y reinicia la API; en Docker/Dokploy,
|
||||||
|
**recrea** el contenedor (redesplegar), porque un reinicio no relee el entorno.
|
||||||
|
|
||||||
|
En producción:
|
||||||
|
|
||||||
|
| Servicio | URL | Redirect URIs registradas en el cliente `authorization_code` |
|
||||||
|
| ---------- | ----------------------------------- | ----------------------------------------------------------------- |
|
||||||
|
| Web | `https://taskstream.pireon.dev` | `https://taskstream.pireon.dev/auth/callback` |
|
||||||
|
| API (REST) | `https://api.taskstream.pireon.dev` | `https://api.taskstream.pireon.dev/api/docs/oauth2-redirect.html` |
|
||||||
|
|
||||||
|
`GET /api/auth/config` le dice a la web qué formas de entrar hay: `clientId` del cliente `authorization_code` (o `null`:
|
||||||
|
la web oculta «Entrar con Pireon») y `passwordGrant`. Devuelve `null` si no hay ningún cliente; en ese caso el login
|
||||||
|
muestra el aviso y no deja entrar.
|
||||||
|
|
||||||
|
### Si el login falla
|
||||||
|
|
||||||
|
La API reenvía el error de Pireon con su `hint` entre paréntesis (lo que de verdad falla) y lo registra en su log como
|
||||||
|
`Pireon rejected <grant> for client <id>: … (hint: …)`.
|
||||||
|
|
||||||
|
| Error | Causa | Solución |
|
||||||
|
| ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| El login dice «Inicio de sesión no configurado» | `GET /api/auth/config` devuelve `null`: no hay ningún cliente en el entorno de la API | Define `PIREON_CLIENT_ID` y/o `PIREON_PASSWORD_CLIENT_ID` (con sus secrets) y recrea el contenedor |
|
||||||
|
| `unauthorized_client`: _not authorized to use this authorization grant type_ | El cliente no es del tipo que pide el flujo (p. ej. uno `password` en `PIREON_CLIENT_ID`) | Un cliente `authorization_code` en `PIREON_CLIENT_ID` y uno `password` en `PIREON_PASSWORD_CLIENT_ID` |
|
||||||
|
| `invalid_request` … _(Check the `client_secret` parameter)_ | El cliente es confidencial y la API no envía secret: la variable está vacía o no llega | Pon el secret en `PIREON_CLIENT_SECRET` (o `PIREON_PASSWORD_CLIENT_SECRET`) y redespliega |
|
||||||
|
| `invalid_client`: _Client authentication failed_ | El secret llega pero no es el de ese cliente | Revisa que id y secret sean del mismo cliente; si no lo tienes, rótalo |
|
||||||
|
| `invalid_request` … _(Invalid redirect URI)_ | La redirect URI del canje no es la de la autorización | Regístrala exactamente igual en el cliente (esquema, dominio y ruta, sin barra final) |
|
||||||
|
| `invalid_grant`: _Authorization code has expired / has been revoked_ | El código caducó o ya se usó (p. ej. al recargar `/auth/callback`) | Vuelve a «Entrar con Pireon» |
|
||||||
|
| `The user credentials were incorrect.` | Email o contraseña de Pireon incorrectos | — |
|
||||||
|
| Errores `404 Cannot GET/POST /api/…` intermitentes a través de la web | Otro `api` en la red compartida de Dokploy recibe parte de las peticiones | Ya resuelto con alias únicos (`task-stream-api`…), ver [Despliegue](07-despliegue.md#nombres-únicos-en-la-red) |
|
||||||
|
|
||||||
### Sesión en la web
|
### Sesión en la web
|
||||||
|
|
||||||
@@ -77,7 +124,9 @@ caduque la caché):
|
|||||||
2. Pon el secret que devuelve en `PIREON_WEBHOOK_SECRET`.
|
2. Pon el secret que devuelve en `PIREON_WEBHOOK_SECRET`.
|
||||||
|
|
||||||
La API verifica la firma `X-Webhook-Signature` (HMAC-SHA256 del cuerpo), marca al usuario como inactivo y borra sus
|
La API verifica la firma `X-Webhook-Signature` (HMAC-SHA256 del cuerpo), marca al usuario como inactivo y borra sus
|
||||||
sesiones cacheadas. Sin `PIREON_WEBHOOK_SECRET`, el endpoint rechaza todas las llamadas.
|
sesiones cacheadas. Además guarda `UserDeactivatedEvent` en el event store, y una saga del tracker deja sin asignar sus
|
||||||
|
tickets abiertos en todos los proyectos (ver [Arquitectura](02-arquitectura.md)). Sin `PIREON_WEBHOOK_SECRET`, el
|
||||||
|
endpoint rechaza todas las llamadas.
|
||||||
|
|
||||||
## 5.4 Usuarios en TaskStream
|
## 5.4 Usuarios en TaskStream
|
||||||
|
|
||||||
|
|||||||
@@ -25,16 +25,18 @@ la web y la API comparten origen igual que en Docker.
|
|||||||
|
|
||||||
## 6.3 Comandos
|
## 6.3 Comandos
|
||||||
|
|
||||||
| Comando | Qué hace |
|
| Comando | Qué hace |
|
||||||
| -------------------------------- | --------------------------------------------------------------------------------- |
|
| -------------------------------- | ----------------------------------------------------------------------------------- |
|
||||||
| `npx nx run-many -t lint` | Lint de todo, incluidas las reglas de capas y contextos |
|
| `npx nx run-many -t lint` | Lint de todo, incluidas las reglas de capas y contextos |
|
||||||
| `npx nx run-many -t test` | Tests unitarios (dominio y aplicación, API y web) |
|
| `npx nx run-many -t test` | Tests unitarios (dominio y aplicación, API y web) |
|
||||||
| `npx nx e2e api` | Flujo completo de la API contra Docker, con Pireon simulado |
|
| `npx nx e2e api` | Flujo completo de la API contra Docker, con Pireon simulado |
|
||||||
| `npx nx run-many -t build` | Builds de la API y la web |
|
| `npx nx run-many -t build` | Builds de la API y la web |
|
||||||
| `npx nx run web:codegen` | Regenera los tipos GraphQL del cliente desde `apps/api/schema.gql` |
|
| `npx nx run web:codegen` | Regenera los tipos GraphQL del cliente desde `apps/api/schema.gql` |
|
||||||
| `npx nx run api:prune` | Build de la API con `package.json` y lockfile podados (lo usa Docker) |
|
| `npx nx serve mcp` | Servidor MCP en `http://localhost:3010/mcp`, contra la API local ([MCP](09-mcp.md)) |
|
||||||
| `npx nx graph` | Grafo de dependencias entre proyectos |
|
| `npx nx run mcp:openapi-types` | Regenera los tipos REST del MCP desde `apps/api/openapi.json` |
|
||||||
| `npx nx test api-tracker-domain` | Tests de una librería concreta (`api-<contexto>-<capa>`, `web-<contexto>-<capa>`) |
|
| `npx nx run api:prune` | Build de la API con `package.json` y lockfile podados (lo usa Docker) |
|
||||||
|
| `npx nx graph` | Grafo de dependencias entre proyectos |
|
||||||
|
| `npx nx test api-tracker-domain` | Tests de una librería concreta (`api-<contexto>-<capa>`, `web-<contexto>-<capa>`) |
|
||||||
|
|
||||||
### Tests
|
### Tests
|
||||||
|
|
||||||
@@ -86,8 +88,10 @@ exporta facades, una página tampoco puede tocar un store ni despachar eventos p
|
|||||||
| Síntoma | Causa y solución |
|
| Síntoma | Causa y solución |
|
||||||
| ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
| ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `npm install` falla con `Cannot read properties of null (reading 'edgesOut')` | Bug de npm con un peer opcional de Vite que arrastra vitest 5. El `overrides` de `package.json` fija vitest; no lo quites |
|
| `npm install` falla con `Cannot read properties of null (reading 'edgesOut')` | Bug de npm con un peer opcional de Vite que arrastra vitest 5. El `overrides` de `package.json` fija vitest; no lo quites |
|
||||||
| `401` al instalar `@pi/ui` | Falta el token del Nexus en `~/.npmrc` |
|
| `401` al instalar `@pi/ui` | Falta el token del Nexus en `~/.npmrc` (o `NEXUS_TOKEN` al construir con Docker) |
|
||||||
|
| `failed to stat /root/.npmrc` al desplegar | Versión antigua del `docker-compose.yml`: ahora el token llega por `NEXUS_TOKEN` (ver [Despliegue](07-despliegue.md)) |
|
||||||
| `nx serve web` arranca en el puerto 3000 | Nx carga el `.env` raíz (`PORT=3000`) en todas las tareas; `apps/web/.env` lo fija en 4200 |
|
| `nx serve web` arranca en el puerto 3000 | Nx carga el `.env` raíz (`PORT=3000`) en todas las tareas; `apps/web/.env` lo fija en 4200 |
|
||||||
| La API no ve un evento nuevo al deserializar | La clase del evento no tiene `@AutowiredEvent` o no se importa |
|
| La API no ve un evento nuevo al deserializar | La clase del evento no tiene `@AutowiredEvent` o no se importa |
|
||||||
| Un componente de `@pi/ui` lanza `Unable to find icon` | Falta registrar ese icono en `libs/web/shared/ui/src/lib/icons.ts` |
|
| Un componente de `@pi/ui` lanza `Unable to find icon` | Falta registrar ese icono en `libs/web/shared/ui/src/lib/icons.ts` |
|
||||||
| Tablas antiguas (`projects`, `issues`, `outbox`) en tu Postgres local | Restos de una versión anterior sin event sourcing; `docker compose down -v` empieza de cero |
|
| Tablas antiguas (`projects`, `issues`, `outbox`) en tu Postgres local | Restos de una versión anterior sin event sourcing; `docker compose down -v` empieza de cero |
|
||||||
|
| El login con Pireon falla (`unauthorized_client`, `client_secret`…) | Configuración de los clientes OAuth: ver [Si el login falla](05-autenticacion.md#si-el-login-falla) |
|
||||||
|
|||||||
@@ -9,28 +9,65 @@ docker compose logs -f api
|
|||||||
docker compose down # parar (-v borra los datos)
|
docker compose down # parar (-v borra los datos)
|
||||||
```
|
```
|
||||||
|
|
||||||
| Servicio | Imagen | Puerto | Función |
|
| Servicio | Imagen | Puerto en local | Función |
|
||||||
| ---------- | --------------------------- | ------ | ----------------------------------------------------------------------- |
|
| ---------- | --------------------------- | --------------- | ----------------------------------------------------------------------- |
|
||||||
| `web` | `task-stream-web` (nginx) | 8080 | SPA y proxy de `/api` y `/graphql` (incluido el WebSocket) hacia la API |
|
| `web` | `task-stream-web` (nginx) | 8080 | SPA y proxy de `/api` y `/graphql` (incluido el WebSocket) hacia la API |
|
||||||
| `api` | `task-stream-api` (Node 22) | 3000 | API NestJS |
|
| `api` | `task-stream-api` (Node 22) | 3000 | API NestJS |
|
||||||
| `postgres` | postgres:17-alpine | 5432 | Event store y lookups |
|
| `postgres` | postgres:17-alpine | 5432 | Event store y lookups |
|
||||||
| `mongo` | mongo:8 | 27017 | Read model |
|
| `mongo` | mongo:8 | 27017 | Read model |
|
||||||
| `redis` | redis:7-alpine (AOF) | 6379 | Stream de eventos, pub/sub y caché de sesiones |
|
| `redis` | redis:7-alpine (AOF) | 6379 | Stream de eventos, pub/sub y caché de sesiones |
|
||||||
|
|
||||||
|
### Producción y desarrollo
|
||||||
|
|
||||||
|
- **`docker-compose.yml`** es la definición de producción (la que despliega Dokploy) y **no publica puertos**: los
|
||||||
|
servicios solo se ven entre sí por la red de Compose. Hacia fuera solo sale la web, por el proxy de la plataforma
|
||||||
|
(en Dokploy: dominio → servicio `web`, puerto `80`). `web` y `api` declaran `expose` para documentarlo.
|
||||||
|
- **`docker-compose.override.yml`** publica los puertos de la tabla. Compose lo carga solo cuando se ejecuta sin `-f`,
|
||||||
|
es decir, en local; Dokploy usa `-f docker-compose.yml` y no lo lee.
|
||||||
|
|
||||||
|
Para probar en local exactamente lo de producción: `docker compose -f docker-compose.yml up -d --build`.
|
||||||
|
|
||||||
|
### Nombres únicos en la red
|
||||||
|
|
||||||
|
Los servicios se llaman entre sí por **alias únicos** (`task-stream-api`, `task-stream-postgres`, `task-stream-mongo`,
|
||||||
|
`task-stream-redis`), no por el nombre del servicio. Al dar dominio a un servicio, Dokploy lo conecta a la red
|
||||||
|
compartida `dokploy-network`, donde otros proyectos también tienen un `api`, un `postgres` o un `redis`: con nombres
|
||||||
|
genéricos, el DNS de Docker devuelve también los suyos y nginx reparte las peticiones entre ambos (síntoma: la mitad
|
||||||
|
de las llamadas a `/api/*` responden `404 Cannot GET …` y el login dice «no configurado»). Si añades un servicio,
|
||||||
|
dale su alias `task-stream-*` y úsalo en las URLs.
|
||||||
|
|
||||||
Todos tienen healthcheck; `web` espera a que `api` esté sana, y `api` a las tres bases de datos.
|
Todos tienen healthcheck; `web` espera a que `api` esté sana, y `api` a las tres bases de datos.
|
||||||
|
|
||||||
### Imágenes
|
### Imágenes
|
||||||
|
|
||||||
El `Dockerfile` tiene dos destinos que comparten la instalación de dependencias:
|
El `Dockerfile` tiene tres destinos que comparten la instalación de dependencias:
|
||||||
|
|
||||||
- **Instalación**: `npm ci` del workspace. `@pi/ui` viene del Nexus privado; el token se pasa como **build secret**
|
- **Instalación**: `npm ci` del workspace. `@pi/ui` viene del Nexus privado; el token llega como **build secret**
|
||||||
(`secrets.npmrc` → `~/.npmrc` en `docker-compose.yml`) y nunca queda en una capa.
|
desde la variable `NEXUS_TOKEN` (`secrets.nexus_token` en `docker-compose.yml`), se escribe en un `npmrc` temporal y
|
||||||
|
se borra en el mismo paso, así que nunca queda en una capa. Sin `NEXUS_TOKEN` la build falla en ese paso.
|
||||||
- **`api`**: `nx run api:prune` genera el bundle con un `package.json` y un lockfile podados a partir de
|
- **`api`**: `nx run api:prune` genera el bundle con un `package.json` y un lockfile podados a partir de
|
||||||
`apps/api/package.json`. La imagen final solo instala esas dependencias (sin Angular ni paquetes privados).
|
`apps/api/package.json`. La imagen final solo instala esas dependencias (sin Angular ni paquetes privados).
|
||||||
Si la API empieza a usar un paquete nuevo **en tiempo de ejecución**, hay que añadirlo a `apps/api/package.json`.
|
Si la API empieza a usar un paquete nuevo **en tiempo de ejecución**, hay que añadirlo a `apps/api/package.json`.
|
||||||
|
- **`mcp`**: el [servidor MCP](09-mcp.md), podado igual que la API (`nx run mcp:prune`), sobre Node 24. Su servicio
|
||||||
|
en `docker-compose.yml` va en el perfil `mcp`: solo arranca con `--profile mcp` (en Dokploy, `COMPOSE_PROFILES=mcp`).
|
||||||
- **`web`**: build de producción de Angular servido por nginx (`apps/web/nginx.conf`): fallback de rutas a
|
- **`web`**: build de producción de Angular servido por nginx (`apps/web/nginx.conf`): fallback de rutas a
|
||||||
`index.html`, caché larga para los bundles con hash y sin caché para `index.html`.
|
`index.html`, caché larga para los bundles con hash y sin caché para `index.html`.
|
||||||
|
|
||||||
|
### Construir
|
||||||
|
|
||||||
|
`NEXUS_TOKEN` tiene que estar en el entorno de quien construye, o en el `.env` junto al `docker-compose.yml`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Local: reutiliza el token de tu ~/.npmrc
|
||||||
|
export NEXUS_TOKEN=$(grep '^//nexus.pireon.dev/repository/npm-releases/:_authToken=' ~/.npmrc | cut -d= -f2-)
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
En **Dokploy** (despliegue con Docker Compose): pestaña _Environment_ del servicio → `NEXUS_TOKEN=<token>`, junto a
|
||||||
|
`PIREON_CLIENT_ID`, `PIREON_CLIENT_SECRET`, `PIREON_PASSWORD_CLIENT_ID`, `PIREON_PASSWORD_CLIENT_SECRET` y
|
||||||
|
`PIREON_WEBHOOK_SECRET`. Dokploy los escribe en el `.env` del proyecto, de
|
||||||
|
donde los lee Compose.
|
||||||
|
|
||||||
## 7.2 Variables de entorno de la API
|
## 7.2 Variables de entorno de la API
|
||||||
|
|
||||||
| Variable | Por defecto | Descripción |
|
| Variable | Por defecto | Descripción |
|
||||||
@@ -45,7 +82,8 @@ El `Dockerfile` tiene dos destinos que comparten la instalación de dependencias
|
|||||||
| `EVENT_STREAM_MAX_LENGTH` | `1000000` | Longitud aproximada que se conserva en el stream |
|
| `EVENT_STREAM_MAX_LENGTH` | `1000000` | Longitud aproximada que se conserva en el stream |
|
||||||
| `EVENTS_BRIDGE_ENABLED` | `true` | Si esta instancia proyecta eventos hacia Mongo (ver escalado) |
|
| `EVENTS_BRIDGE_ENABLED` | `true` | Si esta instancia proyecta eventos hacia Mongo (ver escalado) |
|
||||||
| `PIREON_BASE_URL` | `https://oauth.pireon.dev` | Servidor de identidad |
|
| `PIREON_BASE_URL` | `https://oauth.pireon.dev` | Servidor de identidad |
|
||||||
| `PIREON_CLIENT_ID` / `PIREON_CLIENT_SECRET` | — | Cliente OAuth para el login desde la web |
|
| `PIREON_CLIENT_ID` / `PIREON_CLIENT_SECRET` | — | Cliente OAuth `authorization_code`: «Entrar con Pireon» y Swagger UI |
|
||||||
|
| `PIREON_PASSWORD_CLIENT_ID` / `_SECRET` | el anterior | Cliente OAuth `password`: login con email y contraseña |
|
||||||
| `PIREON_SCOPES` | `users:read` | Scopes que se piden al iniciar sesión |
|
| `PIREON_SCOPES` | `users:read` | Scopes que se piden al iniciar sesión |
|
||||||
| `PIREON_PASSWORD_GRANT` | `true` | Permite el login con email y contraseña |
|
| `PIREON_PASSWORD_GRANT` | `true` | Permite el login con email y contraseña |
|
||||||
| `PIREON_WEBHOOK_SECRET` | — | Activa y firma los webhooks de Pireon |
|
| `PIREON_WEBHOOK_SECRET` | — | Activa y firma los webhooks de Pireon |
|
||||||
@@ -53,6 +91,8 @@ El `Dockerfile` tiene dos destinos que comparten la instalación de dependencias
|
|||||||
| `AUTH_SESSION_TTL_SECONDS` | `60` | Cuánto se confía en un token validado antes de volver a preguntar a Pireon |
|
| `AUTH_SESSION_TTL_SECONDS` | `60` | Cuánto se confía en un token validado antes de volver a preguntar a Pireon |
|
||||||
| `GRAPHQL_PLAYGROUND` | `true` | GraphiQL e introspección |
|
| `GRAPHQL_PLAYGROUND` | `true` | GraphiQL e introspección |
|
||||||
| `GRAPHQL_SCHEMA_FILE` | `apps/api/schema.gql` | Dónde escribir el SDL (vacío = no escribir; así está en Docker) |
|
| `GRAPHQL_SCHEMA_FILE` | `apps/api/schema.gql` | Dónde escribir el SDL (vacío = no escribir; así está en Docker) |
|
||||||
|
| `SWAGGER_ENABLED` | `true` | API REST documentada: Swagger UI en `/api/docs`, OpenAPI en `/api/docs-json` |
|
||||||
|
| `OPENAPI_FILE` | `apps/api/openapi.json` | Dónde escribir el documento OpenAPI al arrancar (vacío = no escribir; así en Docker) |
|
||||||
|
|
||||||
La web no tiene configuración de entorno: llama a la API en su mismo origen (`/api`, `/graphql`).
|
La web no tiene configuración de entorno: llama a la API en su mismo origen (`/api`, `/graphql`).
|
||||||
|
|
||||||
@@ -82,3 +122,8 @@ MongoDB solo contiene proyecciones, así que se puede regenerar desde el event s
|
|||||||
3. Marca los eventos como no publicados: `UPDATE events SET published_at = NULL;`
|
3. Marca los eventos como no publicados: `UPDATE events SET published_at = NULL;`
|
||||||
4. Arranca la API: el relay vuelve a enviar todos los eventos, en orden, y las proyecciones reconstruyen las vistas.
|
4. Arranca la API: el relay vuelve a enviar todos los eventos, en orden, y las proyecciones reconstruyen las vistas.
|
||||||
Las notificaciones en tiempo real se reenvían también, sin efecto si no hay clientes conectados.
|
Las notificaciones en tiempo real se reenvían también, sin efecto si no hay clientes conectados.
|
||||||
|
|
||||||
|
Hazlo también una vez tras desplegar la configuración por proyecto y las estadísticas si quieres la historia completa
|
||||||
|
de los tickets anteriores: sin reconstruir, la línea temporal (`issue_timeline`) solo empieza con los cambios nuevos y
|
||||||
|
las estadísticas usan, para lo anterior, la creación y el estado actual de cada ticket. La categoría de los estados no
|
||||||
|
lo necesita: la API la rellena al arrancar en los tickets que no la tienen.
|
||||||
|
|||||||
138
docs/09-mcp.md
Normal file
138
docs/09-mcp.md
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
# 9. Servidor MCP
|
||||||
|
|
||||||
|
`apps/mcp` es un servidor [MCP](https://modelcontextprotocol.io) (Model Context Protocol) para que asistentes de IA
|
||||||
|
(Claude Code, Claude Desktop, Cursor…) trabajen con TaskStream: consultar tableros y tickets, crear y mover tickets,
|
||||||
|
comentar, gestionar releases o leer las estadísticas. Está hecho con NestJS y
|
||||||
|
[`@nestjs-mcp/server`](https://www.npmjs.com/package/@nestjs-mcp/server) 2.x, y consume la **API REST** como cualquier
|
||||||
|
otra app: con tipos generados desde `apps/api/openapi.json`.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
C["Cliente MCP<br/>(Claude…)"] -- "POST /mcp<br/>Authorization: Bearer token" --> M["apps/mcp<br/>(NestJS)"]
|
||||||
|
M -- "REST /api/…<br/>mismo token" --> A["apps/api"]
|
||||||
|
A -- "valida el token" --> P["Pireon"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9.1 Cómo funciona
|
||||||
|
|
||||||
|
- **Un endpoint**: `POST /mcp`, transporte _Streamable HTTP_. Sin estado (la versión 2026-07-28 del protocolo retiró
|
||||||
|
las sesiones): cualquier instancia atiende cualquier llamada.
|
||||||
|
- **Sin credenciales propias**: cada llamada lleva su `Authorization: Bearer <token de Pireon>`, y el MCP la reenvía
|
||||||
|
tal cual a la API. Cada persona ve y puede cambiar exactamente lo que la API le permite (su rol en cada proyecto);
|
||||||
|
**sin token**, solo se leen los proyectos públicos.
|
||||||
|
- **Errores legibles**: si la API rechaza algo (permiso, regla de negocio, transición no permitida…), la herramienta
|
||||||
|
responde con un error que el modelo puede leer y corregir («Cannot move WEB-16 from Backlog to Hecho. Allowed: Por
|
||||||
|
hacer, En curso, Cancelado»), en vez de cortar la llamada.
|
||||||
|
- **Validación**: los parámetros de cada herramienta se validan con zod antes de llamar a la API.
|
||||||
|
- **Solo en su dominio**: `/mcp` rechaza (403) las peticiones cuyo `Host` no esté en `MCP_ALLOWED_HOSTS`, y las de un
|
||||||
|
navegador cuyo `Origin` tampoco lo esté. Es la protección contra _DNS rebinding_ que pide la especificación de MCP;
|
||||||
|
los clientes que llaman desde un servidor no envían `Origin` y solo necesitan el dominio correcto.
|
||||||
|
- **Respuestas compactas**: los listados devuelven lo que el modelo necesita (clave, título, estado, persona…); el
|
||||||
|
detalle completo, con `get_issue` o `get_project`.
|
||||||
|
|
||||||
|
## 9.2 Herramientas
|
||||||
|
|
||||||
|
| Ámbito | Herramienta | Qué hace |
|
||||||
|
| --------- | ----------------------- | ------------------------------------------------------------------------------------------- |
|
||||||
|
| Usuario | `whoami` | El usuario del token |
|
||||||
|
| | `search_users` | Buscar personas de Pireon (para asignar) |
|
||||||
|
| Proyectos | `list_my_projects` | Mis proyectos |
|
||||||
|
| | `list_public_projects` | Proyectos públicos |
|
||||||
|
| | `get_project` | Equipo y configuración: ids de estados, tipos y áreas que aceptan las demás herramientas |
|
||||||
|
| | `get_board` | Tablero por columnas (filtros por persona, release, área) |
|
||||||
|
| | `get_project_stats` | Cifras de seguimiento |
|
||||||
|
| | `get_project_analytics` | Estadísticas de los últimos días (creados/resueltos, flujo, tiempos, throughput, QA) |
|
||||||
|
| | `get_project_activity` | Actividad reciente |
|
||||||
|
| Tickets | `my_issues` | Mi trabajo en todos los proyectos (o de cualquiera, por categoría: p. ej. lo que espera QA) |
|
||||||
|
| | `search_issues` | Buscar por texto y filtros |
|
||||||
|
| | `get_issue` | Detalle, transiciones permitidas, enlaces, comentarios e historial |
|
||||||
|
| | `create_issue` | Crear un ticket |
|
||||||
|
| | `update_issue` | Cambiar título, descripción, tipo, área, prioridad, etiquetas… |
|
||||||
|
| | `change_issue_status` | Mover de estado (con motivo cuando el estado lo pide) |
|
||||||
|
| | `assign_issue` | Asignar o desasignar |
|
||||||
|
| | `set_issue_release` | Meter en una release o sacar |
|
||||||
|
| | `add_comment` | Comentar |
|
||||||
|
| | `link_issues` | Relacionar tickets (`DEPENDS_ON`, `BLOCKS`, `TESTS`…) |
|
||||||
|
| Releases | `list_releases` | Releases de un proyecto |
|
||||||
|
| | `upcoming_releases` | Próximas releases de mis proyectos |
|
||||||
|
| | `get_release` | Progreso, tickets y burnup |
|
||||||
|
| | `create_release` | Planificar una release |
|
||||||
|
| | `publish_release` | Publicar (con `force` si quedan tickets sin terminar) |
|
||||||
|
|
||||||
|
Las de lectura van marcadas como `readOnlyHint`, para que el cliente no pida confirmación al usarlas. Lo que es de
|
||||||
|
administración (miembros, configuración, visibilidad, borrar proyectos) no se expone: se hace desde la web.
|
||||||
|
|
||||||
|
## 9.3 Conectar un cliente
|
||||||
|
|
||||||
|
Hace falta un token de Pireon. Con email y contraseña (si la API tiene configurado el cliente `password`):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -s https://api.taskstream.pireon.dev/api/auth/token \
|
||||||
|
-H 'content-type: application/json' \
|
||||||
|
-d '{"grant_type":"password","username":"tu@email.com","password":"…"}'
|
||||||
|
# → { "access_token": "…", "refresh_token": "…", "expires_in": … }
|
||||||
|
```
|
||||||
|
|
||||||
|
Claude Code:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
claude mcp add --transport http taskstream https://mcp.taskstream.pireon.dev/mcp \
|
||||||
|
--header "Authorization: Bearer $TASKSTREAM_TOKEN"
|
||||||
|
# en local: http://localhost:3010/mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
Otros clientes (JSON de configuración):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"taskstream": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://mcp.taskstream.pireon.dev/mcp",
|
||||||
|
"headers": { "Authorization": "Bearer <token de Pireon>" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
El token caduca como cualquier sesión de Pireon: cuando la herramienta responda «Error 401», hay que pedir otro (o
|
||||||
|
renovarlo con `grant_type: refresh_token`). El MCP todavía no implementa el flujo de autorización OAuth del propio
|
||||||
|
protocolo MCP, así que el cliente no lo obtiene solo.
|
||||||
|
|
||||||
|
## 9.4 Desarrollo
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npx nx serve api # la API en :3000
|
||||||
|
npx nx serve mcp # el MCP en http://localhost:3010/mcp (TASKSTREAM_API_URL=http://localhost:3000)
|
||||||
|
npx @modelcontextprotocol/inspector # probar las herramientas en el navegador
|
||||||
|
npx nx test mcp # MCP real contra una API simulada: token reenviado, errores, validación
|
||||||
|
npx nx run mcp:openapi-types # regenerar los tipos tras cambiar la API (apps/api/openapi.json)
|
||||||
|
```
|
||||||
|
|
||||||
|
| Variable | Por defecto | Qué es |
|
||||||
|
| -------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| `MCP_PORT` | `3010` | Puerto del MCP (no `PORT`: Nx carga el `.env` del workspace, donde `PORT` es el de la API) |
|
||||||
|
| `TASKSTREAM_API_URL` | `http://localhost:3000` | Origen de la API (sus rutas ya llevan `/api`) |
|
||||||
|
| `MCP_ALLOWED_HOSTS` | (solo `localhost`) | Dominios en los que responde, separados por comas |
|
||||||
|
| `MCP_LOG_LEVEL` | `warn` | `error`, `warn`, `log`, `debug` o `verbose`. Por encima de `warn`, la librería registra cada herramienta en cada llamada |
|
||||||
|
|
||||||
|
Añadir una herramienta: un método con `@Tool({ name, description, paramsSchema: z.object(…), annotations })` en el
|
||||||
|
resolver de su ámbito (`apps/mcp/src/app/tools`), que llame a la API con `this.api.as(ctx)` (cliente tipado con el
|
||||||
|
token de quien llama) y devuelva `toolResult(…)`.
|
||||||
|
|
||||||
|
La imagen solo instala lo que lista `apps/mcp/package.json`: si el MCP empieza a usar un paquete nuevo en tiempo de
|
||||||
|
ejecución, hay que añadirlo ahí (igual que en la API).
|
||||||
|
|
||||||
|
## 9.5 Despliegue
|
||||||
|
|
||||||
|
El servicio `mcp` de `docker-compose.yml` va en el perfil `mcp`, así que no arranca salvo que se pida:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose --profile mcp up -d --build # local: http://localhost:3010/mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
En Dokploy: `COMPOSE_PROFILES=mcp` en las variables del proyecto y un dominio (p. ej. `mcp.taskstream.pireon.dev`) →
|
||||||
|
servicio `mcp`, puerto 3010. Si el dominio es otro, ponlo en `MCP_ALLOWED_HOSTS` (por defecto
|
||||||
|
`mcp.taskstream.pireon.dev,localhost`); si no, el MCP responderá 403. Habla con la API por la red interna
|
||||||
|
(`http://task-stream-api:3000`). La imagen usa Node 24,
|
||||||
|
que es lo que pide `@nestjs-mcp/server` 2.
|
||||||
@@ -3,16 +3,17 @@
|
|||||||
TaskStream es una alternativa a Jira para equipos de desarrollo: proyectos, tickets (bugs, features, cambios de UI,
|
TaskStream es una alternativa a Jira para equipos de desarrollo: proyectos, tickets (bugs, features, cambios de UI,
|
||||||
tareas, QA), releases, comentarios y relaciones entre tickets, todo en tiempo real.
|
tareas, QA), releases, comentarios y relaciones entre tickets, todo en tiempo real.
|
||||||
|
|
||||||
| Documento | Para qué |
|
| Documento | Para qué |
|
||||||
| ----------------------------------------- | ------------------------------------------------------------------------ |
|
| ----------------------------------------- | ------------------------------------------------------------------------- |
|
||||||
| [1. Visión general](01-vision-general.md) | Qué hace el producto, stack y estructura del monorepo |
|
| [1. Visión general](01-vision-general.md) | Qué hace el producto, stack y estructura del monorepo |
|
||||||
| [2. Arquitectura](02-arquitectura.md) | Hexagonal, CQRS, event sourcing, flujo escritura → lectura y tiempo real |
|
| [2. Arquitectura](02-arquitectura.md) | Hexagonal, CQRS, event sourcing, flujo escritura → lectura y tiempo real |
|
||||||
| [3. API](03-api.md) | Dominio (agregados, workflow, eventos) y la API GraphQL |
|
| [3. API](03-api.md) | Dominio (agregados, workflow, eventos) y la API GraphQL |
|
||||||
| [4. Cliente web](04-cliente-web.md) | Angular, NgRx Signals con eventos, GraphQL y `@pi/ui` |
|
| [4. Cliente web](04-cliente-web.md) | Angular, NgRx Signals con eventos, GraphQL y `@pi/ui` |
|
||||||
| [5. Autenticación](05-autenticacion.md) | Pireon: tokens, login en el navegador, sesiones y webhooks |
|
| [5. Autenticación](05-autenticacion.md) | Pireon: tokens, login en el navegador, sesiones y webhooks |
|
||||||
| [6. Desarrollo](06-desarrollo.md) | Puesta en marcha, comandos, tests y cómo añadir una funcionalidad |
|
| [6. Desarrollo](06-desarrollo.md) | Puesta en marcha, comandos, tests y cómo añadir una funcionalidad |
|
||||||
| [7. Despliegue](07-despliegue.md) | Docker, variables de entorno y escalado |
|
| [7. Despliegue](07-despliegue.md) | Docker, variables de entorno y escalado |
|
||||||
| [8. Decisiones](08-decisiones.md) | Por qué está hecho así (y qué alternativas se descartaron) |
|
| [8. Decisiones](08-decisiones.md) | Por qué está hecho así (y qué alternativas se descartaron) |
|
||||||
|
| [9. Servidor MCP](09-mcp.md) | Usar TaskStream desde asistentes de IA (Claude…): herramientas y conexión |
|
||||||
|
|
||||||
Cada librería de `libs/` tiene además su propio `README.md` con su responsabilidad y su contenido.
|
Cada librería de `libs/` tiene además su propio `README.md` con su responsabilidad y su contenido.
|
||||||
|
|
||||||
@@ -22,4 +23,5 @@ Cada librería de `libs/` tiene además su propio `README.md` con su responsabil
|
|||||||
docker compose up -d --build # web en http://localhost:8080
|
docker compose up -d --build # web en http://localhost:8080
|
||||||
```
|
```
|
||||||
|
|
||||||
Para entrar hace falta un cliente OAuth de Pireon en `.env` (ver [Autenticación](05-autenticacion.md#registrar-taskstream-como-cliente-en-pireon)).
|
Para entrar hacen falta dos clientes OAuth de Pireon en `.env` (`authorization_code` y `password`, ver
|
||||||
|
[Autenticación](05-autenticacion.md#registrar-taskstream-como-cliente-en-pireon)).
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -9,7 +9,8 @@ Casos de uso de identidad.
|
|||||||
|
|
||||||
- `IdentityService`: punto de entrada para los presenters.
|
- `IdentityService`: punto de entrada para los presenters.
|
||||||
- Queries: `ResolveSessionQuery` (token → usuario, con caché), `FindUserQuery`, `FindUsersByIdsQuery`, `SearchUsersQuery`, `GetAuthorizationConfigQuery`.
|
- Queries: `ResolveSessionQuery` (token → usuario, con caché), `FindUserQuery`, `FindUsersByIdsQuery`, `SearchUsersQuery`, `GetAuthorizationConfigQuery`.
|
||||||
- Commands: `IssueTokenCommand` (login / refresh vía Pireon), `HandleIdentityWebhookCommand`.
|
- Commands: `IssueTokenCommand` (login / refresh vía Pireon), `HandleIdentityWebhookCommand` (al desactivar a un
|
||||||
|
usuario, guarda `UserDeactivatedEvent` en el event store para que el tracker reaccione).
|
||||||
- Puertos: `IdentityProvider`, `SessionCache`, `UserRegistry`.
|
- Puertos: `IdentityProvider`, `SessionCache`, `UserRegistry`.
|
||||||
|
|
||||||
Ver [Autenticación](../../../../docs/05-autenticacion.md).
|
Ver [Autenticación](../../../../docs/05-autenticacion.md).
|
||||||
|
|||||||
@@ -3,10 +3,7 @@
|
|||||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||||
"sourceRoot": "libs/api/identity/application/src",
|
"sourceRoot": "libs/api/identity/application/src",
|
||||||
"projectType": "library",
|
"projectType": "library",
|
||||||
"tags": [
|
"tags": ["scope:api-identity", "type:application"],
|
||||||
"scope:api-identity",
|
|
||||||
"type:application"
|
|
||||||
],
|
|
||||||
"// targets": "to see all targets run: nx show project api-identity-application --web",
|
"// targets": "to see all targets run: nx show project api-identity-application --web",
|
||||||
"targets": {}
|
"targets": {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
import { Command, CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
import { Command, CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||||
|
import { EventRecorder } from '@task-stream/api/shared/application';
|
||||||
|
import { UserDeactivatedEvent } from '@task-stream/api/identity/domain';
|
||||||
import { SessionCache } from '../ports/session-cache.port';
|
import { SessionCache } from '../ports/session-cache.port';
|
||||||
import { UserRegistry } from '../ports/user-registry.port';
|
import { UserRegistry } from '../ports/user-registry.port';
|
||||||
|
|
||||||
@@ -22,6 +24,7 @@ export class HandleIdentityWebhookHandler
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly cache: SessionCache,
|
private readonly cache: SessionCache,
|
||||||
private readonly registry: UserRegistry,
|
private readonly registry: UserRegistry,
|
||||||
|
private readonly events: EventRecorder,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute({ event, data }: HandleIdentityWebhookCommand): Promise<void> {
|
async execute({ event, data }: HandleIdentityWebhookCommand): Promise<void> {
|
||||||
@@ -50,8 +53,14 @@ export class HandleIdentityWebhookHandler
|
|||||||
|
|
||||||
private async deactivate(userId: string): Promise<void> {
|
private async deactivate(userId: string): Promise<void> {
|
||||||
const user = await this.registry.findById(userId);
|
const user = await this.registry.findById(userId);
|
||||||
if (!user) return;
|
if (!user?.isActive) return;
|
||||||
user.deactivate();
|
user.deactivate();
|
||||||
await this.registry.upsert(user);
|
await this.registry.upsert(user);
|
||||||
|
// Stored so other contexts react (the tracker unassigns their open tickets).
|
||||||
|
for (const event of user.getUncommittedEvents()) {
|
||||||
|
if (event instanceof UserDeactivatedEvent)
|
||||||
|
await this.events.record(`identity-user-${userId}`, event);
|
||||||
|
}
|
||||||
|
user.uncommit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,7 @@
|
|||||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||||
"sourceRoot": "libs/api/identity/domain/src",
|
"sourceRoot": "libs/api/identity/domain/src",
|
||||||
"projectType": "library",
|
"projectType": "library",
|
||||||
"tags": [
|
"tags": ["scope:api-identity", "type:domain"],
|
||||||
"scope:api-identity",
|
|
||||||
"type:domain"
|
|
||||||
],
|
|
||||||
"// targets": "to see all targets run: nx show project api-identity-domain --web",
|
"// targets": "to see all targets run: nx show project api-identity-domain --web",
|
||||||
"targets": {}
|
"targets": {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,11 +17,15 @@ export interface TokenSet {
|
|||||||
tokenType: string;
|
tokenType: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What a browser needs to start the Authorization Code + PKCE flow. */
|
/** How a browser can sign in. */
|
||||||
export interface AuthorizationConfig {
|
export interface AuthorizationConfig {
|
||||||
authorizeUrl: string;
|
authorizeUrl: string;
|
||||||
clientId: string;
|
/**
|
||||||
|
* Client for Authorization Code + PKCE ("Sign in with Pireon"). Null when
|
||||||
|
* only email + password is configured: Pireon clients allow a single grant.
|
||||||
|
*/
|
||||||
|
clientId: string | null;
|
||||||
scopes: string[];
|
scopes: string[];
|
||||||
/** Password grant can be disabled on the identity server. */
|
/** Whether email + password sign-in is available. */
|
||||||
passwordGrant: boolean;
|
passwordGrant: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,7 @@
|
|||||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||||
"sourceRoot": "libs/api/identity/infrastructure/src",
|
"sourceRoot": "libs/api/identity/infrastructure/src",
|
||||||
"projectType": "library",
|
"projectType": "library",
|
||||||
"tags": [
|
"tags": ["scope:api-identity", "type:infrastructure"],
|
||||||
"scope:api-identity",
|
|
||||||
"type:infrastructure"
|
|
||||||
],
|
|
||||||
"// targets": "to see all targets run: nx show project api-identity-infrastructure --web",
|
"// targets": "to see all targets run: nx show project api-identity-infrastructure --web",
|
||||||
"targets": {}
|
"targets": {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ import { RedisSessionCache } from './redis/redis-session.cache';
|
|||||||
requestTimeoutMs: Number(config.get('PIREON_TIMEOUT_MS', 5000)),
|
requestTimeoutMs: Number(config.get('PIREON_TIMEOUT_MS', 5000)),
|
||||||
clientId: config.get<string>('PIREON_CLIENT_ID') || undefined,
|
clientId: config.get<string>('PIREON_CLIENT_ID') || undefined,
|
||||||
clientSecret: config.get<string>('PIREON_CLIENT_SECRET') || undefined,
|
clientSecret: config.get<string>('PIREON_CLIENT_SECRET') || undefined,
|
||||||
|
passwordClientId:
|
||||||
|
config.get<string>('PIREON_PASSWORD_CLIENT_ID') || undefined,
|
||||||
|
passwordClientSecret:
|
||||||
|
config.get<string>('PIREON_PASSWORD_CLIENT_SECRET') || undefined,
|
||||||
scopes: String(config.get('PIREON_SCOPES', 'users:read'))
|
scopes: String(config.get('PIREON_SCOPES', 'users:read'))
|
||||||
.split(/\s+/)
|
.split(/\s+/)
|
||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import {
|
||||||
|
ForbiddenException,
|
||||||
|
ServiceUnavailableException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { InvalidCredentialsError } from '@task-stream/api/identity/application';
|
||||||
|
import { UserFactory } from '@task-stream/api/identity/domain';
|
||||||
|
import { PireonConfig } from './pireon.config';
|
||||||
|
import { PireonIdentityProvider } from './pireon-identity.provider';
|
||||||
|
|
||||||
|
const TOKEN = {
|
||||||
|
access_token: 'access',
|
||||||
|
refresh_token: 'refresh',
|
||||||
|
expires_in: 3600,
|
||||||
|
token_type: 'Bearer',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Pireon clients allow a single grant type: one client for code, one for password. */
|
||||||
|
function setup(overrides: Partial<PireonConfig> = {}) {
|
||||||
|
const config: PireonConfig = {
|
||||||
|
baseUrl: 'https://oauth.pireon.dev',
|
||||||
|
sessionTtlSeconds: 60,
|
||||||
|
requestTimeoutMs: 1000,
|
||||||
|
clientId: 'code-client',
|
||||||
|
clientSecret: 'code-secret',
|
||||||
|
passwordClientId: 'password-client',
|
||||||
|
passwordClientSecret: 'password-secret',
|
||||||
|
scopes: ['users:read'],
|
||||||
|
passwordGrant: true,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
const calls: URLSearchParams[] = [];
|
||||||
|
/** Answers 401 to the clients in `rejects`, a token otherwise. */
|
||||||
|
const respond = (rejects: string[] = []) =>
|
||||||
|
jest.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
|
||||||
|
const body = init?.body as URLSearchParams;
|
||||||
|
calls.push(body);
|
||||||
|
return rejects.includes(body.get('client_id') ?? '')
|
||||||
|
? new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'invalid_request',
|
||||||
|
error_description: 'The refresh token is invalid.',
|
||||||
|
}),
|
||||||
|
{ status: 401 },
|
||||||
|
)
|
||||||
|
: new Response(JSON.stringify(TOKEN), { status: 200 });
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
provider: new PireonIdentityProvider(config, new UserFactory()),
|
||||||
|
calls,
|
||||||
|
respond,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const password = {
|
||||||
|
type: 'password',
|
||||||
|
username: 'ana@pireon.dev',
|
||||||
|
password: 'x',
|
||||||
|
} as const;
|
||||||
|
const code = {
|
||||||
|
type: 'authorization_code',
|
||||||
|
code: 'c',
|
||||||
|
codeVerifier: 'v',
|
||||||
|
redirectUri: 'https://taskstream.pireon.dev/auth/callback',
|
||||||
|
} as const;
|
||||||
|
const refresh = { type: 'refresh_token', refreshToken: 'r' } as const;
|
||||||
|
|
||||||
|
describe('PireonIdentityProvider tokens', () => {
|
||||||
|
afterEach(() => jest.restoreAllMocks());
|
||||||
|
|
||||||
|
it('uses the password client for email + password', async () => {
|
||||||
|
const { provider, calls, respond } = setup();
|
||||||
|
respond();
|
||||||
|
await provider.issueToken(password);
|
||||||
|
expect(calls[0].get('client_id')).toBe('password-client');
|
||||||
|
expect(calls[0].get('client_secret')).toBe('password-secret');
|
||||||
|
expect(calls[0].get('grant_type')).toBe('password');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the authorization_code client for "Sign in with Pireon"', async () => {
|
||||||
|
const { provider, calls, respond } = setup();
|
||||||
|
respond();
|
||||||
|
await provider.issueToken(code);
|
||||||
|
expect(calls[0].get('client_id')).toBe('code-client');
|
||||||
|
expect(calls[0].get('code_verifier')).toBe('v');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the main client when there is no password client', async () => {
|
||||||
|
const { provider, calls, respond } = setup({
|
||||||
|
passwordClientId: undefined,
|
||||||
|
passwordClientSecret: undefined,
|
||||||
|
});
|
||||||
|
respond();
|
||||||
|
await provider.issueToken(password);
|
||||||
|
expect(calls[0].get('client_id')).toBe('code-client');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshes with whichever client issued the token', async () => {
|
||||||
|
const { provider, calls, respond } = setup();
|
||||||
|
respond(['password-client']);
|
||||||
|
await expect(provider.issueToken(refresh)).resolves.toMatchObject({
|
||||||
|
accessToken: 'access',
|
||||||
|
});
|
||||||
|
expect(calls.map((c) => c.get('client_id'))).toEqual([
|
||||||
|
'password-client',
|
||||||
|
'code-client',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps Pireon's hint, which says what was wrong", async () => {
|
||||||
|
const { provider } = setup();
|
||||||
|
jest.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'invalid_request',
|
||||||
|
error_description:
|
||||||
|
'The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed.',
|
||||||
|
hint: 'Invalid redirect URI',
|
||||||
|
}),
|
||||||
|
{ status: 400 },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await expect(provider.issueToken(code)).rejects.toThrow(
|
||||||
|
/otherwise malformed\. \(Invalid redirect URI\)/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects the refresh when no client accepts it', async () => {
|
||||||
|
const { provider, respond } = setup();
|
||||||
|
respond(['password-client', 'code-client']);
|
||||||
|
await expect(provider.issueToken(refresh)).rejects.toBeInstanceOf(
|
||||||
|
InvalidCredentialsError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses email + password when PIREON_PASSWORD_GRANT=false', async () => {
|
||||||
|
const { provider, calls, respond } = setup({ passwordGrant: false });
|
||||||
|
respond();
|
||||||
|
await expect(provider.issueToken(password)).rejects.toBeInstanceOf(
|
||||||
|
ForbiddenException,
|
||||||
|
);
|
||||||
|
expect(calls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says what is missing when a flow is not configured', async () => {
|
||||||
|
const { provider } = setup({
|
||||||
|
clientId: undefined,
|
||||||
|
clientSecret: undefined,
|
||||||
|
});
|
||||||
|
await expect(provider.issueToken(code)).rejects.toBeInstanceOf(
|
||||||
|
ServiceUnavailableException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tells the browser which flows exist', () => {
|
||||||
|
expect(setup().provider.authorizationConfig()).toMatchObject({
|
||||||
|
clientId: 'code-client',
|
||||||
|
passwordGrant: true,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
setup({
|
||||||
|
clientId: undefined,
|
||||||
|
clientSecret: undefined,
|
||||||
|
}).provider.authorizationConfig(),
|
||||||
|
).toMatchObject({ clientId: null, passwordGrant: true });
|
||||||
|
expect(
|
||||||
|
setup({
|
||||||
|
clientId: undefined,
|
||||||
|
passwordClientId: undefined,
|
||||||
|
}).provider.authorizationConfig(),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
ForbiddenException,
|
||||||
Inject,
|
Inject,
|
||||||
Injectable,
|
Injectable,
|
||||||
Logger,
|
Logger,
|
||||||
@@ -28,6 +29,11 @@ interface PireonUser {
|
|||||||
app_roles?: string[];
|
app_roles?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface OAuthClient {
|
||||||
|
id: string;
|
||||||
|
secret: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/** Driven adapter for the Pireon OAuth2 identity server (Laravel Passport). */
|
/** Driven adapter for the Pireon OAuth2 identity server (Laravel Passport). */
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PireonIdentityProvider extends IdentityProvider {
|
export class PireonIdentityProvider extends IdentityProvider {
|
||||||
@@ -69,14 +75,78 @@ export class PireonIdentityProvider extends IdentityProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async issueToken(grant: TokenGrant): Promise<TokenSet> {
|
async issueToken(grant: TokenGrant): Promise<TokenSet> {
|
||||||
const { clientId, clientSecret, scopes } = this.config;
|
const clients = this.clientsFor(grant);
|
||||||
if (!clientId)
|
// A refresh token only works with the client that issued it, and we don't
|
||||||
throw new ServiceUnavailableException(
|
// know which one that was: try each until one accepts it.
|
||||||
'Sign-in is not configured (PIREON_CLIENT_ID)',
|
let rejected: InvalidCredentialsError | undefined;
|
||||||
);
|
for (const client of clients) {
|
||||||
|
try {
|
||||||
|
return await this.requestToken(client, grant);
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
grant.type !== 'refresh_token' ||
|
||||||
|
!(error instanceof InvalidCredentialsError)
|
||||||
|
)
|
||||||
|
throw error;
|
||||||
|
rejected = error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw rejected ?? new InvalidCredentialsError();
|
||||||
|
}
|
||||||
|
|
||||||
const body = new URLSearchParams({ client_id: clientId });
|
/**
|
||||||
if (clientSecret) body.set('client_secret', clientSecret);
|
* Pireon clients allow a single grant type: authorization_code uses
|
||||||
|
* PIREON_CLIENT_ID, password PIREON_PASSWORD_CLIENT_ID (or the former).
|
||||||
|
*/
|
||||||
|
private clientsFor(grant: TokenGrant): OAuthClient[] {
|
||||||
|
const { clientId, clientSecret, passwordClientId, passwordClientSecret } =
|
||||||
|
this.config;
|
||||||
|
const code: OAuthClient | undefined = clientId
|
||||||
|
? { id: clientId, secret: clientSecret }
|
||||||
|
: undefined;
|
||||||
|
const password: OAuthClient | undefined = passwordClientId
|
||||||
|
? { id: passwordClientId, secret: passwordClientSecret }
|
||||||
|
: code;
|
||||||
|
|
||||||
|
switch (grant.type) {
|
||||||
|
case 'authorization_code':
|
||||||
|
if (!code)
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
'Sign-in with Pireon is not configured (PIREON_CLIENT_ID)',
|
||||||
|
);
|
||||||
|
return [code];
|
||||||
|
case 'password':
|
||||||
|
if (!this.config.passwordGrant)
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Email and password sign-in is disabled',
|
||||||
|
);
|
||||||
|
if (!password)
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
'Email and password sign-in is not configured (PIREON_PASSWORD_CLIENT_ID)',
|
||||||
|
);
|
||||||
|
return [password];
|
||||||
|
case 'refresh_token': {
|
||||||
|
const candidates = [password, code].filter(
|
||||||
|
(client): client is OAuthClient => !!client,
|
||||||
|
);
|
||||||
|
if (!candidates.length)
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
'Sign-in is not configured (PIREON_CLIENT_ID)',
|
||||||
|
);
|
||||||
|
return candidates.filter(
|
||||||
|
(client, i) => candidates.findIndex((c) => c.id === client.id) === i,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requestToken(
|
||||||
|
client: OAuthClient,
|
||||||
|
grant: TokenGrant,
|
||||||
|
): Promise<TokenSet> {
|
||||||
|
const { scopes } = this.config;
|
||||||
|
const body = new URLSearchParams({ client_id: client.id });
|
||||||
|
if (client.secret) body.set('client_secret', client.secret);
|
||||||
switch (grant.type) {
|
switch (grant.type) {
|
||||||
case 'authorization_code':
|
case 'authorization_code':
|
||||||
body.set('grant_type', 'authorization_code');
|
body.set('grant_type', 'authorization_code');
|
||||||
@@ -114,12 +184,23 @@ export class PireonIdentityProvider extends IdentityProvider {
|
|||||||
throw new ServiceUnavailableException('Identity server unavailable');
|
throw new ServiceUnavailableException('Identity server unavailable');
|
||||||
}
|
}
|
||||||
if (response.status === 400 || response.status === 401) {
|
if (response.status === 400 || response.status === 401) {
|
||||||
|
// OAuth2 error (RFC 6749 §5.2). Passport puts the useful part in `hint`
|
||||||
|
// (which parameter was wrong), so keep it in the message and the log.
|
||||||
const error = (await response.json().catch(() => ({}))) as {
|
const error = (await response.json().catch(() => ({}))) as {
|
||||||
|
error?: string;
|
||||||
error_description?: string;
|
error_description?: string;
|
||||||
|
hint?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
};
|
};
|
||||||
|
const description =
|
||||||
|
error.error_description ?? error.message ?? 'Invalid credentials';
|
||||||
|
this.logger.warn(
|
||||||
|
`Pireon rejected ${grant.type} for client ${client.id}: ` +
|
||||||
|
`${error.error ?? response.status} — ${description}` +
|
||||||
|
(error.hint ? ` (hint: ${error.hint})` : ''),
|
||||||
|
);
|
||||||
throw new InvalidCredentialsError(
|
throw new InvalidCredentialsError(
|
||||||
error.error_description ?? error.message ?? 'Invalid credentials',
|
error.hint ? `${description} (${error.hint})` : description,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.assertOk(response, 'POST /oauth/token');
|
this.assertOk(response, 'POST /oauth/token');
|
||||||
@@ -138,12 +219,14 @@ export class PireonIdentityProvider extends IdentityProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
authorizationConfig(): AuthorizationConfig | null {
|
authorizationConfig(): AuthorizationConfig | null {
|
||||||
if (!this.config.clientId) return null;
|
const { clientId, passwordClientId, passwordGrant } = this.config;
|
||||||
|
const password = passwordGrant && !!(passwordClientId ?? clientId);
|
||||||
|
if (!clientId && !password) return null;
|
||||||
return {
|
return {
|
||||||
authorizeUrl: new URL('/oauth/authorize', this.config.baseUrl).toString(),
|
authorizeUrl: new URL('/oauth/authorize', this.config.baseUrl).toString(),
|
||||||
clientId: this.config.clientId,
|
clientId: clientId ?? null,
|
||||||
scopes: this.config.scopes,
|
scopes: this.config.scopes,
|
||||||
passwordGrant: this.config.passwordGrant,
|
passwordGrant: password,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,17 @@ export interface PireonConfig {
|
|||||||
/** Seconds a resolved token is trusted before asking Pireon again. */
|
/** Seconds a resolved token is trusted before asking Pireon again. */
|
||||||
sessionTtlSeconds: number;
|
sessionTtlSeconds: number;
|
||||||
requestTimeoutMs: number;
|
requestTimeoutMs: number;
|
||||||
/** OAuth client of task-stream in Pireon (Authorization Code + PKCE / password grant). */
|
/**
|
||||||
|
* Pireon OAuth client with grant type `authorization_code` ("Sign in with
|
||||||
|
* Pireon", Swagger UI). Pireon clients allow a single grant type, so email +
|
||||||
|
* password uses its own client below.
|
||||||
|
*/
|
||||||
clientId?: string;
|
clientId?: string;
|
||||||
/** Kept server-side: browsers sign in through /api/auth/token. */
|
/** Kept server-side: browsers sign in through /api/auth/token. */
|
||||||
clientSecret?: string;
|
clientSecret?: string;
|
||||||
|
/** Pireon OAuth client with grant type `password`; defaults to the one above. */
|
||||||
|
passwordClientId?: string;
|
||||||
|
passwordClientSecret?: string;
|
||||||
scopes: string[];
|
scopes: string[];
|
||||||
passwordGrant: boolean;
|
passwordGrant: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,11 @@ Adaptadores de entrada de identidad.
|
|||||||
|
|
||||||
## Contenido
|
## Contenido
|
||||||
|
|
||||||
- `auth/`: `AuthGuard` global, `@Public()`, `@CurrentUser()`, `@AccessToken()`, `@RequireAppRoles()` y `WsAuthenticator` (subscriptions).
|
- `auth/`: `AuthGuard` global, `@Public()`, `@OptionalAuth()` / `@ApiOptionalPireonAuth()` (lecturas de proyectos
|
||||||
- `http/`: `AuthController` (`GET /api/auth/config`, `POST /api/auth/token`) y `PireonWebhookController` (firma HMAC).
|
públicos: sin token, el usuario es `undefined`), `@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`.
|
- `graphql/`: queries `me`, `user` y `users`.
|
||||||
|
|
||||||
## Comandos
|
## Comandos
|
||||||
|
|||||||
@@ -3,10 +3,7 @@
|
|||||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||||
"sourceRoot": "libs/api/identity/presenters/src",
|
"sourceRoot": "libs/api/identity/presenters/src",
|
||||||
"projectType": "library",
|
"projectType": "library",
|
||||||
"tags": [
|
"tags": ["scope:api-identity", "type:presenters"],
|
||||||
"scope:api-identity",
|
|
||||||
"type:presenters"
|
|
||||||
],
|
|
||||||
"// targets": "to see all targets run: nx show project api-identity-presenters --web",
|
"// targets": "to see all targets run: nx show project api-identity-presenters --web",
|
||||||
"targets": {}
|
"targets": {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,30 @@
|
|||||||
import {
|
import {
|
||||||
ExecutionContext,
|
ExecutionContext,
|
||||||
SetMetadata,
|
SetMetadata,
|
||||||
|
applyDecorators,
|
||||||
createParamDecorator,
|
createParamDecorator,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOAuth2, ApiSecurity } from '@nestjs/swagger';
|
||||||
import { getAuthRequest } from './auth-request';
|
import { getAuthRequest } from './auth-request';
|
||||||
|
|
||||||
export const IS_PUBLIC = 'identity:isPublic';
|
export const IS_PUBLIC = 'identity:isPublic';
|
||||||
|
export const IS_OPTIONAL_AUTH = 'identity:optionalAuth';
|
||||||
export const REQUIRED_APP_ROLES = 'identity:appRoles';
|
export const REQUIRED_APP_ROLES = 'identity:appRoles';
|
||||||
|
|
||||||
/** Skips authentication (health checks, webhooks...). */
|
/** Skips authentication (health checks, webhooks...). */
|
||||||
export const Public = () => SetMetadata(IS_PUBLIC, true);
|
export const Public = () => SetMetadata(IS_PUBLIC, true);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anonymous callers are let through (`@CurrentUser()` is undefined); a token,
|
||||||
|
* if sent, must still be valid. For reads that public projects allow.
|
||||||
|
*/
|
||||||
|
export const OptionalAuth = () => SetMetadata(IS_OPTIONAL_AUTH, true);
|
||||||
|
|
||||||
/** Requires one of these roles in the task-stream OAuth client (Pireon app roles). */
|
/** Requires one of these roles in the task-stream OAuth client (Pireon app roles). */
|
||||||
export const RequireAppRoles = (...roles: string[]) =>
|
export const RequireAppRoles = (...roles: string[]) =>
|
||||||
SetMetadata(REQUIRED_APP_ROLES, roles);
|
SetMetadata(REQUIRED_APP_ROLES, roles);
|
||||||
|
|
||||||
/** The authenticated caller (User). */
|
/** The authenticated caller (User); undefined on anonymous `@OptionalAuth()` calls. */
|
||||||
export const CurrentUser = createParamDecorator(
|
export const CurrentUser = createParamDecorator(
|
||||||
(_: unknown, context: ExecutionContext) => getAuthRequest(context).user,
|
(_: unknown, context: ExecutionContext) => getAuthRequest(context).user,
|
||||||
);
|
);
|
||||||
@@ -25,3 +34,22 @@ export const AccessToken = createParamDecorator(
|
|||||||
(_: unknown, context: ExecutionContext) =>
|
(_: unknown, context: ExecutionContext) =>
|
||||||
getAuthRequest(context).accessToken,
|
getAuthRequest(context).accessToken,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** OpenAPI security schemes (declared in apps/api/src/app/openapi.ts). */
|
||||||
|
export const PIREON_OAUTH = 'pireon-oauth';
|
||||||
|
export const PIREON_BEARER = 'pireon';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Documents that the endpoints need a Pireon access token: obtained through
|
||||||
|
* Pireon's OAuth (authorization code + PKCE, or password) or sent as a bearer
|
||||||
|
* token by an app that already has one. Enforced by the global AuthGuard.
|
||||||
|
*/
|
||||||
|
export const ApiPireonAuth = () =>
|
||||||
|
applyDecorators(ApiOAuth2([], PIREON_OAUTH), ApiBearerAuth(PIREON_BEARER));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `@OptionalAuth()` + OpenAPI: adds "no token" to the security requirements
|
||||||
|
* the controller declares with `@ApiPireonAuth()`.
|
||||||
|
*/
|
||||||
|
export const ApiOptionalPireonAuth = () =>
|
||||||
|
applyDecorators(OptionalAuth(), ApiSecurity({}));
|
||||||
|
|||||||
@@ -11,9 +11,16 @@ import {
|
|||||||
InvalidAccessTokenError,
|
InvalidAccessTokenError,
|
||||||
} from '@task-stream/api/identity/application';
|
} from '@task-stream/api/identity/application';
|
||||||
import { extractBearerToken, getAuthRequest } from './auth-request';
|
import { extractBearerToken, getAuthRequest } from './auth-request';
|
||||||
import { IS_PUBLIC, REQUIRED_APP_ROLES } from './auth.decorators';
|
import {
|
||||||
|
IS_OPTIONAL_AUTH,
|
||||||
|
IS_PUBLIC,
|
||||||
|
REQUIRED_APP_ROLES,
|
||||||
|
} from './auth.decorators';
|
||||||
|
|
||||||
/** Global guard: every HTTP/GraphQL operation needs a valid Pireon token unless @Public(). */
|
/**
|
||||||
|
* Global guard: every HTTP/GraphQL operation needs a valid Pireon token unless
|
||||||
|
* @Public(); with @OptionalAuth() the token may be missing (but not invalid).
|
||||||
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthGuard implements CanActivate {
|
export class AuthGuard implements CanActivate {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -30,7 +37,13 @@ export class AuthGuard implements CanActivate {
|
|||||||
// WebSocket subscriptions are authenticated once in connection_init.
|
// WebSocket subscriptions are authenticated once in connection_init.
|
||||||
if (!req.user) {
|
if (!req.user) {
|
||||||
const token = extractBearerToken(req.headers['authorization']);
|
const token = extractBearerToken(req.headers['authorization']);
|
||||||
if (!token) throw new UnauthorizedException('Missing bearer token');
|
if (!token) {
|
||||||
|
if (
|
||||||
|
this.reflector.getAllAndOverride<boolean>(IS_OPTIONAL_AUTH, targets)
|
||||||
|
)
|
||||||
|
return true;
|
||||||
|
throw new UnauthorizedException('Missing bearer token');
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
req.user = await this.identity.resolveSession(token);
|
req.user = await this.identity.resolveSession(token);
|
||||||
req.accessToken = token;
|
req.accessToken = token;
|
||||||
|
|||||||
@@ -6,6 +6,15 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiBody,
|
||||||
|
ApiConsumes,
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiOperation,
|
||||||
|
ApiProperty,
|
||||||
|
ApiTags,
|
||||||
|
ApiUnauthorizedResponse,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
import {
|
import {
|
||||||
IdentityService,
|
IdentityService,
|
||||||
InvalidCredentialsError,
|
InvalidCredentialsError,
|
||||||
@@ -18,10 +27,34 @@ import {
|
|||||||
import { Public } from '../auth/auth.decorators';
|
import { Public } from '../auth/auth.decorators';
|
||||||
import { IssueTokenDto } from './dto/issue-token.dto';
|
import { IssueTokenDto } from './dto/issue-token.dto';
|
||||||
|
|
||||||
|
export class AuthorizationConfigDto {
|
||||||
|
@ApiProperty({ example: 'https://oauth.pireon.dev/oauth/authorize' })
|
||||||
|
authorizeUrl!: string;
|
||||||
|
@ApiProperty({
|
||||||
|
type: String,
|
||||||
|
nullable: true,
|
||||||
|
description:
|
||||||
|
'Public id of the authorization_code client ("Sign in with Pireon"); null when only email + password is configured',
|
||||||
|
})
|
||||||
|
clientId!: string | null;
|
||||||
|
@ApiProperty({ type: [String], example: ['users:read'] })
|
||||||
|
scopes!: string[];
|
||||||
|
@ApiProperty({ description: 'Whether email + password sign-in is allowed' })
|
||||||
|
passwordGrant!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TokenResponseDto {
|
||||||
|
@ApiProperty() access_token!: string;
|
||||||
|
@ApiProperty({ type: String, nullable: true }) refresh_token!: string | null;
|
||||||
|
@ApiProperty({ description: 'Seconds' }) expires_in!: number;
|
||||||
|
@ApiProperty({ example: 'Bearer' }) token_type!: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sign-in for browsers. Pireon's /oauth/token has no CORS and the client
|
* Sign-in for browsers. Pireon's /oauth/token has no CORS and the client
|
||||||
* secret must stay server-side, so the web app goes through here.
|
* secret must stay server-side, so the web app goes through here.
|
||||||
*/
|
*/
|
||||||
|
@ApiTags('Auth')
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(private readonly identity: IdentityService) {}
|
constructor(private readonly identity: IdentityService) {}
|
||||||
@@ -29,6 +62,10 @@ export class AuthController {
|
|||||||
/** What the web app needs to start Authorization Code + PKCE (null = not configured). */
|
/** What the web app needs to start Authorization Code + PKCE (null = not configured). */
|
||||||
@Public()
|
@Public()
|
||||||
@Get('config')
|
@Get('config')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'How to sign in with Pireon (null when not configured)',
|
||||||
|
})
|
||||||
|
@ApiOkResponse({ type: AuthorizationConfigDto })
|
||||||
config(): Promise<AuthorizationConfig | null> {
|
config(): Promise<AuthorizationConfig | null> {
|
||||||
return this.identity.authorizationConfig();
|
return this.identity.authorizationConfig();
|
||||||
}
|
}
|
||||||
@@ -36,6 +73,18 @@ export class AuthController {
|
|||||||
@Public()
|
@Public()
|
||||||
@Post('token')
|
@Post('token')
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
'Exchange a grant for Pireon tokens (proxy of Pireon /oauth/token)',
|
||||||
|
description:
|
||||||
|
'For browsers (Pireon has no CORS) and Swagger UI: adds the client secret ' +
|
||||||
|
'server-side. Apps with their own Pireon OAuth client can get tokens from ' +
|
||||||
|
'Pireon directly; any valid Pireon access token is accepted by this API.',
|
||||||
|
})
|
||||||
|
@ApiConsumes('application/json', 'application/x-www-form-urlencoded')
|
||||||
|
@ApiBody({ type: IssueTokenDto })
|
||||||
|
@ApiOkResponse({ type: TokenResponseDto })
|
||||||
|
@ApiUnauthorizedResponse({ description: 'Pireon rejected the grant' })
|
||||||
async token(@Body() dto: IssueTokenDto) {
|
async token(@Body() dto: IssueTokenDto) {
|
||||||
try {
|
try {
|
||||||
return toResponse(await this.identity.issueToken(toGrant(dto)));
|
return toResponse(await this.identity.issueToken(toGrant(dto)));
|
||||||
|
|||||||
@@ -1,35 +1,48 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsIn, IsNotEmpty, IsString, IsUrl, ValidateIf } from 'class-validator';
|
import { IsIn, IsNotEmpty, IsString, IsUrl, ValidateIf } from 'class-validator';
|
||||||
|
|
||||||
|
/** OAuth2 token request (RFC 6749), as JSON or application/x-www-form-urlencoded. */
|
||||||
export class IssueTokenDto {
|
export class IssueTokenDto {
|
||||||
|
@ApiProperty({ enum: ['authorization_code', 'password', 'refresh_token'] })
|
||||||
@IsIn(['authorization_code', 'password', 'refresh_token'])
|
@IsIn(['authorization_code', 'password', 'refresh_token'])
|
||||||
grant_type!: 'authorization_code' | 'password' | 'refresh_token';
|
grant_type!: 'authorization_code' | 'password' | 'refresh_token';
|
||||||
|
|
||||||
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'authorization_code')
|
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'authorization_code')
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'authorization_code: code returned by Pireon',
|
||||||
|
})
|
||||||
code?: string;
|
code?: string;
|
||||||
|
|
||||||
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'authorization_code')
|
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'authorization_code')
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
@ApiPropertyOptional({ description: 'authorization_code: PKCE verifier' })
|
||||||
code_verifier?: string;
|
code_verifier?: string;
|
||||||
|
|
||||||
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'authorization_code')
|
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'authorization_code')
|
||||||
@IsUrl({ require_tld: false })
|
@IsUrl({ require_tld: false })
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'authorization_code: redirect URI used to authorize',
|
||||||
|
})
|
||||||
redirect_uri?: string;
|
redirect_uri?: string;
|
||||||
|
|
||||||
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'password')
|
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'password')
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
@ApiPropertyOptional({ description: 'password: Pireon email' })
|
||||||
username?: string;
|
username?: string;
|
||||||
|
|
||||||
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'password')
|
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'password')
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
@ApiPropertyOptional({ description: 'password: Pireon password' })
|
||||||
password?: string;
|
password?: string;
|
||||||
|
|
||||||
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'refresh_token')
|
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'refresh_token')
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
@ApiPropertyOptional({ description: 'refresh_token' })
|
||||||
refresh_token?: string;
|
refresh_token?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,12 +9,20 @@ import {
|
|||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import {
|
||||||
|
ApiHeader,
|
||||||
|
ApiNoContentResponse,
|
||||||
|
ApiOperation,
|
||||||
|
ApiTags,
|
||||||
|
ApiUnauthorizedResponse,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
import { IdentityService } from '@task-stream/api/identity/application';
|
import { IdentityService } from '@task-stream/api/identity/application';
|
||||||
import { Request } from 'express';
|
import { Request } from 'express';
|
||||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||||
import { Public } from '../auth/auth.decorators';
|
import { Public } from '../auth/auth.decorators';
|
||||||
|
|
||||||
/** Receives `user.deactivated` / `token.revoked` from Pireon. */
|
/** Receives `user.deactivated` / `token.revoked` from Pireon. */
|
||||||
|
@ApiTags('Webhooks')
|
||||||
@Controller('webhooks/pireon')
|
@Controller('webhooks/pireon')
|
||||||
export class PireonWebhookController {
|
export class PireonWebhookController {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -25,6 +33,19 @@ export class PireonWebhookController {
|
|||||||
@Public()
|
@Public()
|
||||||
@Post()
|
@Post()
|
||||||
@HttpCode(204)
|
@HttpCode(204)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Pireon events (called by Pireon, not by apps)',
|
||||||
|
description:
|
||||||
|
"`user.deactivated` closes the user's sessions; `token.revoked` forgets the token. " +
|
||||||
|
'Disabled unless PIREON_WEBHOOK_SECRET is set.',
|
||||||
|
})
|
||||||
|
@ApiHeader({
|
||||||
|
name: 'X-Webhook-Signature',
|
||||||
|
description: 'HMAC-SHA256 of the raw body with PIREON_WEBHOOK_SECRET (hex)',
|
||||||
|
required: true,
|
||||||
|
})
|
||||||
|
@ApiNoContentResponse({ description: 'Processed' })
|
||||||
|
@ApiUnauthorizedResponse({ description: 'Missing or invalid signature' })
|
||||||
async receive(
|
async receive(
|
||||||
@Req() req: RawBodyRequest<Request>,
|
@Req() req: RawBodyRequest<Request>,
|
||||||
@Headers('x-webhook-signature') signature?: string,
|
@Headers('x-webhook-signature') signature?: string,
|
||||||
|
|||||||
105
libs/api/identity/presenters/src/lib/http/users.controller.ts
Normal file
105
libs/api/identity/presenters/src/lib/http/users.controller.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
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({
|
||||||
|
type: String,
|
||||||
|
description: 'Name or email',
|
||||||
|
default: '',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
search = '';
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: Number, 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 { UserResolver } from './graphql/user.resolver';
|
||||||
import { AuthController } from './http/auth.controller';
|
import { AuthController } from './http/auth.controller';
|
||||||
import { PireonWebhookController } from './http/pireon-webhook.controller';
|
import { PireonWebhookController } from './http/pireon-webhook.controller';
|
||||||
|
import { UsersController } from './http/users.controller';
|
||||||
|
|
||||||
/** Driving adapters of the identity context: global auth guard, webhook, GraphQL. */
|
/** Driving adapters of the identity context: global auth guard, REST (auth, users, webhook), GraphQL. */
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [AuthController, PireonWebhookController],
|
controllers: [AuthController, UsersController, PireonWebhookController],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: APP_GUARD, useClass: AuthGuard },
|
{ provide: APP_GUARD, useClass: AuthGuard },
|
||||||
WsAuthenticator,
|
WsAuthenticator,
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ Contrato del event store y reconstrucción de agregados.
|
|||||||
|
|
||||||
- `EventStore` (puerto): `persist` y `getEventsByStreamId`.
|
- `EventStore` (puerto): `persist` y `getEventsByStreamId`.
|
||||||
- `AggregateRehydrator`: carga los eventos de un stream y devuelve el agregado reconstruido, ya enlazado con el `EventPublisher`.
|
- `AggregateRehydrator`: carga los eventos de un stream y devuelve el agregado reconstruido, ya enlazado con el `EventPublisher`.
|
||||||
- `SharedModule.withInfrastructure(...)`: módulo global que expone el event store y el rehydrator a todos los contextos.
|
- `EventRecorder`: guarda un evento en un stream que no es de ningún agregado propio (p. ej. un usuario de Pireon), en
|
||||||
|
la siguiente posición, para que otros contextos reaccionen a él.
|
||||||
|
- `SharedModule.withInfrastructure(...)`: módulo global que expone el event store, el rehydrator y el recorder a todos
|
||||||
|
los contextos.
|
||||||
|
|
||||||
## Comandos
|
## Comandos
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,7 @@
|
|||||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||||
"sourceRoot": "libs/api/shared/application/src",
|
"sourceRoot": "libs/api/shared/application/src",
|
||||||
"projectType": "library",
|
"projectType": "library",
|
||||||
"tags": [
|
"tags": ["scope:api-shared", "type:application"],
|
||||||
"scope:api-shared",
|
|
||||||
"type:application"
|
|
||||||
],
|
|
||||||
"// targets": "to see all targets run: nx show project api-shared-application --web",
|
"// targets": "to see all targets run: nx show project api-shared-application --web",
|
||||||
"targets": {}
|
"targets": {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from './lib/ports/event-store';
|
export * from './lib/ports/event-store';
|
||||||
export * from './lib/aggregate-rehydrator';
|
export * from './lib/aggregate-rehydrator';
|
||||||
export * from './lib/shared.module';
|
export * from './lib/shared.module';
|
||||||
|
export * from './lib/event-recorder';
|
||||||
|
|||||||
35
libs/api/shared/application/src/lib/event-recorder.ts
Normal file
35
libs/api/shared/application/src/lib/event-recorder.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ConcurrencyError, DomainEvent } from '@task-stream/api/shared/domain';
|
||||||
|
import { EventStore } from './ports/event-store';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appends a domain event to a stream that no aggregate of ours owns (e.g. a
|
||||||
|
* user, owned by Pireon), at the next position. Like any stored event, it
|
||||||
|
* reaches the event handlers and sagas of every context through the events
|
||||||
|
* bridge.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class EventRecorder {
|
||||||
|
constructor(private readonly eventStore: EventStore) {}
|
||||||
|
|
||||||
|
async record(streamId: string, event: DomainEvent): Promise<void> {
|
||||||
|
for (let attempt = 1; ; attempt++) {
|
||||||
|
const position =
|
||||||
|
(await this.eventStore.getEventsByStreamId(streamId)).length + 1;
|
||||||
|
try {
|
||||||
|
await this.eventStore.persist({
|
||||||
|
streamId,
|
||||||
|
position,
|
||||||
|
type: event.constructor.name,
|
||||||
|
data: JSON.parse(
|
||||||
|
JSON.stringify({ ...event, aggregateVersion: position }),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
// Someone appended meanwhile: take the next position.
|
||||||
|
if (!(error instanceof ConcurrencyError) || attempt === 3) throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { DynamicModule, Global, Module, Type } from '@nestjs/common';
|
import { DynamicModule, Global, Module, Type } from '@nestjs/common';
|
||||||
import { CqrsModule } from '@nestjs/cqrs';
|
import { CqrsModule } from '@nestjs/cqrs';
|
||||||
import { AggregateRehydrator } from './aggregate-rehydrator';
|
import { AggregateRehydrator } from './aggregate-rehydrator';
|
||||||
|
import { EventRecorder } from './event-recorder';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared building blocks for every bounded context: the event store (from
|
* Shared building blocks for every bounded context: the event store (from
|
||||||
* the infrastructure module plugged in by the composition root) and the
|
* the infrastructure module plugged in by the composition root), the
|
||||||
* AggregateRehydrator.
|
* AggregateRehydrator and the EventRecorder.
|
||||||
*/
|
*/
|
||||||
@Global()
|
@Global()
|
||||||
@Module({})
|
@Module({})
|
||||||
@@ -16,8 +17,8 @@ export class SharedModule {
|
|||||||
return {
|
return {
|
||||||
module: SharedModule,
|
module: SharedModule,
|
||||||
imports: [CqrsModule, infrastructureModule],
|
imports: [CqrsModule, infrastructureModule],
|
||||||
providers: [AggregateRehydrator],
|
providers: [AggregateRehydrator, EventRecorder],
|
||||||
exports: [infrastructureModule, AggregateRehydrator],
|
exports: [infrastructureModule, AggregateRehydrator, EventRecorder],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,7 @@
|
|||||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||||
"sourceRoot": "libs/api/shared/domain/src",
|
"sourceRoot": "libs/api/shared/domain/src",
|
||||||
"projectType": "library",
|
"projectType": "library",
|
||||||
"tags": [
|
"tags": ["scope:api-shared", "type:domain"],
|
||||||
"scope:api-shared",
|
|
||||||
"type:domain"
|
|
||||||
],
|
|
||||||
"// targets": "to see all targets run: nx show project api-shared-domain --web",
|
"// targets": "to see all targets run: nx show project api-shared-domain --web",
|
||||||
"targets": {}
|
"targets": {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ Event store en PostgreSQL y el camino de los eventos hasta la lectura.
|
|||||||
- `PostgresEventStore` (tabla `events`, `UNIQUE(stream_id, position)` → `ConcurrencyError`).
|
- `PostgresEventStore` (tabla `events`, `UNIQUE(stream_id, position)` → `ConcurrencyError`).
|
||||||
- `EventSerializer` / `EventDeserializer` y `EventStorePublisher` (sustituye al publisher del `EventBus`: `commit()` persiste).
|
- `EventSerializer` / `EventDeserializer` y `EventStorePublisher` (sustituye al publisher del `EventBus`: `commit()` persiste).
|
||||||
- `EventStoreRelay`: copia los eventos nuevos al Redis Stream (`FOR UPDATE SKIP LOCKED`).
|
- `EventStoreRelay`: copia los eventos nuevos al Redis Stream (`FOR UPDATE SKIP LOCKED`).
|
||||||
- `EventsBridge`: consume el stream y entrega cada evento a sus `@EventsHandler`, en orden y esperando (ACK al final).
|
- `EventsBridge`: consume el stream y entrega cada evento a sus `@EventsHandler`, en orden y esperando (ACK al final);
|
||||||
|
después ejecuta los `@Saga()` (el `EventBus` en memoria nunca ve los eventos guardados) y sus comandos, uno a uno.
|
||||||
- Conexiones Redis (`REDIS_CLIENT`, `GRAPHQL_PUB_SUB`) y `SharedInfrastructureModule.forRootAsync`.
|
- Conexiones Redis (`REDIS_CLIENT`, `GRAPHQL_PUB_SUB`) y `SharedInfrastructureModule.forRootAsync`.
|
||||||
|
|
||||||
Flujo completo: [Arquitectura](../../../../docs/02-arquitectura.md#22-flujo-completo-de-una-escritura).
|
Flujo completo: [Arquitectura](../../../../docs/02-arquitectura.md#22-flujo-completo-de-una-escritura).
|
||||||
|
|||||||
@@ -3,10 +3,7 @@
|
|||||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||||
"sourceRoot": "libs/api/shared/infrastructure/src",
|
"sourceRoot": "libs/api/shared/infrastructure/src",
|
||||||
"projectType": "library",
|
"projectType": "library",
|
||||||
"tags": [
|
"tags": ["scope:api-shared", "type:infrastructure"],
|
||||||
"scope:api-shared",
|
|
||||||
"type:infrastructure"
|
|
||||||
],
|
|
||||||
"// targets": "to see all targets run: nx show project api-shared-infrastructure --web",
|
"// targets": "to see all targets run: nx show project api-shared-infrastructure --web",
|
||||||
"targets": {}
|
"targets": {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,20 @@ import {
|
|||||||
Type,
|
Type,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { DiscoveryService } from '@nestjs/core';
|
import { DiscoveryService } from '@nestjs/core';
|
||||||
import { IEventHandler } from '@nestjs/cqrs';
|
import {
|
||||||
import { SerializableEvent } from '@task-stream/api/shared/domain';
|
CommandBus,
|
||||||
|
ICommand,
|
||||||
|
IEvent,
|
||||||
|
IEventHandler,
|
||||||
|
ISaga,
|
||||||
|
} from '@nestjs/cqrs';
|
||||||
|
import {
|
||||||
|
ConcurrencyError,
|
||||||
|
DomainError,
|
||||||
|
SerializableEvent,
|
||||||
|
} from '@task-stream/api/shared/domain';
|
||||||
import { hostname } from 'node:os';
|
import { hostname } from 'node:os';
|
||||||
|
import { lastValueFrom, of, toArray } from 'rxjs';
|
||||||
import { EventStreamConsumer } from '../event-stream/event-stream.consumer';
|
import { EventStreamConsumer } from '../event-stream/event-stream.consumer';
|
||||||
import {
|
import {
|
||||||
EVENT_STREAM_OPTIONS,
|
EVENT_STREAM_OPTIONS,
|
||||||
@@ -20,8 +31,12 @@ import { EventDeserializer } from './deserializers/event.deserializer';
|
|||||||
|
|
||||||
export const EVENTS_BRIDGE_ENABLED = Symbol('EVENTS_BRIDGE_ENABLED');
|
export const EVENTS_BRIDGE_ENABLED = Symbol('EVENTS_BRIDGE_ENABLED');
|
||||||
|
|
||||||
/** Metadata written by @EventsHandler(...events) (@nestjs/cqrs). */
|
/** Metadata written by @EventsHandler(...events) and @Saga() (@nestjs/cqrs). */
|
||||||
const EVENTS_HANDLER_METADATA = '__eventsHandler__';
|
const EVENTS_HANDLER_METADATA = '__eventsHandler__';
|
||||||
|
const SAGA_METADATA = '__saga__';
|
||||||
|
|
||||||
|
/** Attempts of a saga command that keeps hitting concurrent writes. */
|
||||||
|
const CONCURRENCY_ATTEMPTS = 5;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Redis stream → @EventsHandler. Plays the role of the event-store change
|
* Redis stream → @EventsHandler. Plays the role of the event-store change
|
||||||
@@ -32,6 +47,13 @@ const EVENTS_HANDLER_METADATA = '__eventsHandler__';
|
|||||||
* time, so events of an aggregate are projected in order, and the entry is
|
* time, so events of an aggregate are projected in order, and the entry is
|
||||||
* acknowledged in Redis only once every handler succeeded (otherwise it
|
* acknowledged in Redis only once every handler succeeded (otherwise it
|
||||||
* stays pending and is retried).
|
* stays pending and is retried).
|
||||||
|
*
|
||||||
|
* It also runs the `@Saga()`s (the in-memory EventBus never sees stored
|
||||||
|
* events, so it would not): after the handlers, each saga gets the event
|
||||||
|
* alone (`of(event)`), and the commands it emits are executed one by one,
|
||||||
|
* awaited. Commands must be idempotent (an event may be handled twice).
|
||||||
|
* A command refused by the domain (not found, business rule...) is logged
|
||||||
|
* and skipped, so it cannot block the stream; concurrent writes are retried.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EventsBridge
|
export class EventsBridge
|
||||||
@@ -40,6 +62,7 @@ export class EventsBridge
|
|||||||
private readonly logger = new Logger(EventsBridge.name);
|
private readonly logger = new Logger(EventsBridge.name);
|
||||||
private consumer?: EventStreamConsumer;
|
private consumer?: EventStreamConsumer;
|
||||||
private readonly handlers = new Map<Type, IEventHandler[]>();
|
private readonly handlers = new Map<Type, IEventHandler[]>();
|
||||||
|
private readonly sagas: { name: string; saga: ISaga }[] = [];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly connections: RedisConnectionFactory,
|
private readonly connections: RedisConnectionFactory,
|
||||||
@@ -47,6 +70,7 @@ export class EventsBridge
|
|||||||
@Inject(EVENTS_BRIDGE_ENABLED) private readonly enabled: boolean,
|
@Inject(EVENTS_BRIDGE_ENABLED) private readonly enabled: boolean,
|
||||||
private readonly eventDeserializer: EventDeserializer,
|
private readonly eventDeserializer: EventDeserializer,
|
||||||
private readonly discovery: DiscoveryService,
|
private readonly discovery: DiscoveryService,
|
||||||
|
private readonly commandBus: CommandBus,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async onApplicationBootstrap(): Promise<void> {
|
async onApplicationBootstrap(): Promise<void> {
|
||||||
@@ -55,6 +79,7 @@ export class EventsBridge
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.registerEventHandlers();
|
this.registerEventHandlers();
|
||||||
|
this.registerSagas();
|
||||||
this.consumer = new EventStreamConsumer(
|
this.consumer = new EventStreamConsumer(
|
||||||
this.connections.create('events-bridge'),
|
this.connections.create('events-bridge'),
|
||||||
{
|
{
|
||||||
@@ -66,7 +91,7 @@ export class EventsBridge
|
|||||||
);
|
);
|
||||||
await this.consumer.start();
|
await this.consumer.start();
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Bridging ${this.stream.streamKey} to ${this.handlers.size} event type(s)`,
|
`Bridging ${this.stream.streamKey} to ${this.handlers.size} event type(s) and ${this.sagas.length} saga(s)`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,11 +115,66 @@ export class EventsBridge
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private registerSagas(): void {
|
||||||
|
for (const wrapper of this.discovery.getProviders()) {
|
||||||
|
if (!wrapper.metatype || !wrapper.instance) continue;
|
||||||
|
const keys: string[] =
|
||||||
|
Reflect.getMetadata(SAGA_METADATA, wrapper.metatype) ?? [];
|
||||||
|
const instance = wrapper.instance as Record<string, ISaga>;
|
||||||
|
for (const key of keys) {
|
||||||
|
this.sagas.push({
|
||||||
|
name: `${wrapper.metatype.name}.${key}`,
|
||||||
|
saga: instance[key].bind(instance),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async handleEvent(stored: SerializableEvent): Promise<void> {
|
private async handleEvent(stored: SerializableEvent): Promise<void> {
|
||||||
const { data: event } = this.eventDeserializer.deserialize(stored);
|
const { data: event } = this.eventDeserializer.deserialize(stored);
|
||||||
const eventCls = Object.getPrototypeOf(event).constructor as Type;
|
const eventCls = Object.getPrototypeOf(event).constructor as Type;
|
||||||
|
// Projections first: sagas may read the read model they just updated.
|
||||||
for (const handler of this.handlers.get(eventCls) ?? []) {
|
for (const handler of this.handlers.get(eventCls) ?? []) {
|
||||||
await handler.handle(event);
|
await handler.handle(event);
|
||||||
}
|
}
|
||||||
|
for (const { name, saga } of this.sagas) {
|
||||||
|
const commands = await lastValueFrom(
|
||||||
|
saga(of(event as IEvent)).pipe(toArray()),
|
||||||
|
);
|
||||||
|
for (const command of commands) {
|
||||||
|
await this.execute(name, event as IEvent, command);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async execute(
|
||||||
|
saga: string,
|
||||||
|
event: IEvent,
|
||||||
|
command: ICommand,
|
||||||
|
): Promise<void> {
|
||||||
|
for (let attempt = 1; ; attempt++) {
|
||||||
|
try {
|
||||||
|
await this.commandBus.execute(command);
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error instanceof ConcurrencyError &&
|
||||||
|
attempt < CONCURRENCY_ATTEMPTS
|
||||||
|
) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50 * attempt));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
error instanceof DomainError &&
|
||||||
|
!(error instanceof ConcurrencyError)
|
||||||
|
) {
|
||||||
|
this.logger.warn(
|
||||||
|
`${saga}: ${command.constructor.name} after ${event.constructor.name} skipped: ${error.message}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Adaptadores de entrada comunes.
|
|||||||
## Contenido
|
## Contenido
|
||||||
|
|
||||||
- `DomainErrorFilter`: convierte los errores de dominio en errores GraphQL (`extensions.code`) o respuestas HTTP.
|
- `DomainErrorFilter`: convierte los errores de dominio en errores GraphQL (`extensions.code`) o respuestas HTTP.
|
||||||
|
- `ApiErrorDto`, `@ApiReadErrors()` y `@ApiCommandErrors()`: el formato de error de la API REST en OpenAPI.
|
||||||
|
|
||||||
## Comandos
|
## Comandos
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,7 @@
|
|||||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||||
"sourceRoot": "libs/api/shared/presenters/src",
|
"sourceRoot": "libs/api/shared/presenters/src",
|
||||||
"projectType": "library",
|
"projectType": "library",
|
||||||
"tags": [
|
"tags": ["scope:api-shared", "type:presenters"],
|
||||||
"scope:api-shared",
|
|
||||||
"type:presenters"
|
|
||||||
],
|
|
||||||
"// targets": "to see all targets run: nx show project api-shared-presenters --web",
|
"// targets": "to see all targets run: nx show project api-shared-presenters --web",
|
||||||
"targets": {}
|
"targets": {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export * from './lib/filters/domain-error.filter';
|
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)',
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -7,10 +7,18 @@ Casos de uso del tracker (CQRS).
|
|||||||
|
|
||||||
## Contenido
|
## Contenido
|
||||||
|
|
||||||
- Services: `ProjectsService`, `IssuesService`, `CommentsService`, `ReleasesService`, `ChangesService` (subscriptions).
|
- Services: `ProjectsService`, `IssuesService`, `CommentsService`, `ReleasesService`, `ChangesService` (subscriptions),
|
||||||
|
`WorkflowsService` (workflow de cada proyecto para presentar tickets, con una caché de 2 s).
|
||||||
- `commands/`: un fichero por command y otro por handler; cargan con `WriteAccess` (rehydrator) y guardan con `commitAndWait()`.
|
- `commands/`: un fichero por command y otro por handler; cargan con `WriteAccess` (rehydrator) y guardan con `commitAndWait()`.
|
||||||
- `queries/`: un fichero por query y otro por handler; leen de los puertos `Find*Repository` y comprueban la membresía (`ReadAccess`).
|
- `queries/`: un fichero por query y otro por handler; leen de los puertos `Find*Repository` y comprueban el acceso
|
||||||
- `event-handlers/`: proyecciones (`@EventsHandler`) hacia el read model, notificaciones en tiempo real y feed de actividad.
|
(`ReadAccess`: miembros, o cualquiera si el proyecto es público; `viewerId` null = anónimo);
|
||||||
|
`SearchMyIssuesQuery` y `GetUpcomingReleasesQuery` cruzan todos los proyectos del usuario («Mi trabajo»).
|
||||||
|
- `event-handlers/`: proyecciones (`@EventsHandler`) hacia el read model (incluida la línea temporal de cada ticket),
|
||||||
|
notificaciones en tiempo real y feed de actividad.
|
||||||
|
- `sagas/`: `ProjectDeletionSaga` (cascada al borrar un proyecto y sus tickets), `ReleaseArchivedSaga` (los tickets sin
|
||||||
|
terminar salen de una release archivada) y `UserDeactivatedSaga` (desasigna los tickets abiertos de un usuario que
|
||||||
|
Pireon desactiva). Las ejecuta el `EventsBridge`; sus comandos son internos e idempotentes. Tests en `sagas.spec.ts`.
|
||||||
|
- `analytics/`: estadísticas y burnup calculados con funciones puras sobre la línea temporal (con tests).
|
||||||
- `ports/`: lookups de escritura, `Find*`, `UpsertMaterialized*`, `ReleaseIssuesRepository`, `ChangeFeed`, `UserDirectory`.
|
- `ports/`: lookups de escritura, `Find*`, `UpsertMaterialized*`, `ReleaseIssuesRepository`, `ChangeFeed`, `UserDirectory`.
|
||||||
- `TrackerApplicationModule.withInfrastructure(...)`.
|
- `TrackerApplicationModule.withInfrastructure(...)`.
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,7 @@
|
|||||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||||
"sourceRoot": "libs/api/tracker/application/src",
|
"sourceRoot": "libs/api/tracker/application/src",
|
||||||
"projectType": "library",
|
"projectType": "library",
|
||||||
"tags": [
|
"tags": ["scope:api-tracker", "type:application"],
|
||||||
"scope:api-tracker",
|
|
||||||
"type:application"
|
|
||||||
],
|
|
||||||
"// targets": "to see all targets run: nx show project api-tracker-application --web",
|
"// targets": "to see all targets run: nx show project api-tracker-application --web",
|
||||||
"targets": {}
|
"targets": {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,4 +6,5 @@ export * from './lib/issues.service';
|
|||||||
export * from './lib/comments.service';
|
export * from './lib/comments.service';
|
||||||
export * from './lib/releases.service';
|
export * from './lib/releases.service';
|
||||||
export * from './lib/changes.service';
|
export * from './lib/changes.service';
|
||||||
|
export * from './lib/workflows.service';
|
||||||
export * from './lib/tracker-application.module';
|
export * from './lib/tracker-application.module';
|
||||||
|
|||||||
184
libs/api/tracker/application/src/lib/analytics/analytics.spec.ts
Normal file
184
libs/api/tracker/application/src/lib/analytics/analytics.spec.ts
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import {
|
||||||
|
IssueReadModel,
|
||||||
|
IssueTimelineEntry,
|
||||||
|
ReleaseReadModel,
|
||||||
|
ReleaseStatus,
|
||||||
|
StatusCategory as C,
|
||||||
|
} from '@task-stream/api/tracker/domain';
|
||||||
|
import {
|
||||||
|
computeProjectAnalytics,
|
||||||
|
computeReleaseBurnup,
|
||||||
|
weekOf,
|
||||||
|
} from './analytics';
|
||||||
|
|
||||||
|
const ana = { id: 'ana', name: 'Ana' };
|
||||||
|
const bob = { id: 'bob', name: 'Bob' };
|
||||||
|
|
||||||
|
const issue = (
|
||||||
|
id: string,
|
||||||
|
createdAt: string,
|
||||||
|
status: string,
|
||||||
|
category: C,
|
||||||
|
releaseId: string | null = null,
|
||||||
|
): IssueReadModel =>
|
||||||
|
({
|
||||||
|
id,
|
||||||
|
key: id,
|
||||||
|
createdAt,
|
||||||
|
status,
|
||||||
|
statusCategory: category,
|
||||||
|
reporter: ana,
|
||||||
|
assignee: bob,
|
||||||
|
release: releaseId ? { id: releaseId, name: 'v1' } : null,
|
||||||
|
}) as IssueReadModel;
|
||||||
|
|
||||||
|
let seq = 0;
|
||||||
|
const step = (
|
||||||
|
issueId: string,
|
||||||
|
at: string,
|
||||||
|
status: string,
|
||||||
|
category: C,
|
||||||
|
assigneeId: string | null = 'bob',
|
||||||
|
): IssueTimelineEntry => ({
|
||||||
|
id: `${issueId}:${++seq}`,
|
||||||
|
issueId,
|
||||||
|
projectId: 'p',
|
||||||
|
at,
|
||||||
|
kind: 'status',
|
||||||
|
status,
|
||||||
|
category,
|
||||||
|
assigneeId,
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A: created Mon 5 Oct, started Tue 6, to QA Wed 7, QA failed Thu 8, back to QA and done Fri 9.
|
||||||
|
* B: created Mon 5 Oct, started Mon 12, QA Tue 13, done Wed 14 (by Ana).
|
||||||
|
* C: created Tue 13 Oct, still in backlog.
|
||||||
|
*/
|
||||||
|
const issues = [
|
||||||
|
issue('A', '2026-10-05T09:00:00Z', 'DONE', C.DONE, 'rel'),
|
||||||
|
issue('B', '2026-10-05T10:00:00Z', 'DONE', C.DONE, 'rel'),
|
||||||
|
issue('C', '2026-10-13T10:00:00Z', 'BACKLOG', C.TODO),
|
||||||
|
];
|
||||||
|
const timeline: IssueTimelineEntry[] = [
|
||||||
|
step('A', '2026-10-05T09:00:00Z', 'BACKLOG', C.TODO),
|
||||||
|
{
|
||||||
|
id: 'A:r',
|
||||||
|
issueId: 'A',
|
||||||
|
projectId: 'p',
|
||||||
|
at: '2026-10-05T09:00:01Z',
|
||||||
|
kind: 'release',
|
||||||
|
releaseId: 'rel',
|
||||||
|
},
|
||||||
|
step('A', '2026-10-06T09:00:00Z', 'IN_PROGRESS', C.IN_PROGRESS),
|
||||||
|
step('A', '2026-10-07T09:00:00Z', 'IN_QA', C.QA),
|
||||||
|
step('A', '2026-10-08T09:00:00Z', 'QA_FAILED', C.IN_PROGRESS),
|
||||||
|
step('A', '2026-10-09T08:00:00Z', 'IN_QA', C.QA),
|
||||||
|
step('A', '2026-10-09T09:00:00Z', 'DONE', C.DONE),
|
||||||
|
step('B', '2026-10-05T10:00:00Z', 'BACKLOG', C.TODO),
|
||||||
|
{
|
||||||
|
id: 'B:r',
|
||||||
|
issueId: 'B',
|
||||||
|
projectId: 'p',
|
||||||
|
at: '2026-10-12T08:00:00Z',
|
||||||
|
kind: 'release',
|
||||||
|
releaseId: 'rel',
|
||||||
|
},
|
||||||
|
step('B', '2026-10-12T10:00:00Z', 'IN_PROGRESS', C.IN_PROGRESS),
|
||||||
|
step('B', '2026-10-13T10:00:00Z', 'IN_QA', C.QA),
|
||||||
|
step('B', '2026-10-14T10:00:00Z', 'DONE', C.DONE, 'ana'),
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('project analytics', () => {
|
||||||
|
const stats = computeProjectAnalytics({
|
||||||
|
issues,
|
||||||
|
timeline,
|
||||||
|
statusOrder: ['BACKLOG', 'IN_PROGRESS', 'IN_QA', 'QA_FAILED', 'DONE'],
|
||||||
|
from: new Date('2026-10-05T00:00:00Z'),
|
||||||
|
to: new Date('2026-10-14T23:59:59Z'),
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts created and resolved tickets per week (Monday)', () => {
|
||||||
|
expect(weekOf(Date.parse('2026-10-11T12:00:00Z'))).toBe('2026-10-05');
|
||||||
|
expect(stats.createdVsResolved).toEqual([
|
||||||
|
{ week: '2026-10-05', created: 2, resolved: 1 },
|
||||||
|
{ week: '2026-10-12', created: 1, resolved: 1 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('measures cycle time (start → done) and lead time (created → done)', () => {
|
||||||
|
// A: 3 days of cycle, 4 of lead. B: 2 and 9.
|
||||||
|
expect(stats.cycleTime).toEqual({ count: 2, averageDays: 2.5, p85Days: 3 });
|
||||||
|
expect(stats.leadTime).toEqual({ count: 2, averageDays: 6.5, p85Days: 9 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts QA passes and failures', () => {
|
||||||
|
expect(stats.qa).toEqual({ passed: 2, failed: 1, failureRate: 0.333 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('attributes throughput to who had the ticket', () => {
|
||||||
|
expect(stats.throughput).toEqual([
|
||||||
|
{ week: '2026-10-05', user: bob, count: 1 },
|
||||||
|
{ week: '2026-10-12', user: ana, count: 1 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds the cumulative flow day by day, in the workflow order', () => {
|
||||||
|
const flow = stats.cumulativeFlow;
|
||||||
|
expect(flow.dates).toHaveLength(10);
|
||||||
|
expect(flow.series.map((s) => s.status)).toEqual([
|
||||||
|
'BACKLOG',
|
||||||
|
'IN_PROGRESS',
|
||||||
|
'IN_QA',
|
||||||
|
'QA_FAILED',
|
||||||
|
'DONE',
|
||||||
|
]);
|
||||||
|
const at = (status: string, day: string) =>
|
||||||
|
flow.series.find((s) => s.status === status)?.counts[
|
||||||
|
flow.dates.indexOf(day)
|
||||||
|
];
|
||||||
|
expect(at('BACKLOG', '2026-10-05')).toBe(2);
|
||||||
|
expect(at('QA_FAILED', '2026-10-08')).toBe(1);
|
||||||
|
expect(at('DONE', '2026-10-14')).toBe(2);
|
||||||
|
expect(at('BACKLOG', '2026-10-14')).toBe(1); // C
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to creation + current status for tickets without timeline', () => {
|
||||||
|
const old = computeProjectAnalytics({
|
||||||
|
issues: [issue('OLD', '2026-10-01T00:00:00Z', 'TODO', C.TODO)],
|
||||||
|
timeline: [],
|
||||||
|
statusOrder: [],
|
||||||
|
from: new Date('2026-10-01T00:00:00Z'),
|
||||||
|
to: new Date('2026-10-02T23:00:00Z'),
|
||||||
|
});
|
||||||
|
expect(old.cumulativeFlow.series).toEqual([
|
||||||
|
{ status: 'TODO', counts: [1, 1] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('release burnup', () => {
|
||||||
|
it('tracks scope and done tickets of the release per day', () => {
|
||||||
|
const release = {
|
||||||
|
id: 'rel',
|
||||||
|
createdAt: '2026-10-05T08:00:00Z',
|
||||||
|
releasedAt: '2026-10-14T18:00:00Z',
|
||||||
|
targetDate: '2026-10-15',
|
||||||
|
status: ReleaseStatus.RELEASED,
|
||||||
|
} as ReleaseReadModel;
|
||||||
|
const burnup = computeReleaseBurnup({
|
||||||
|
release,
|
||||||
|
issues,
|
||||||
|
timeline,
|
||||||
|
now: new Date('2026-10-20T00:00:00Z'),
|
||||||
|
});
|
||||||
|
const at = (series: number[], day: string) =>
|
||||||
|
series[burnup.dates.indexOf(day)];
|
||||||
|
expect(burnup.dates[0]).toBe('2026-10-05');
|
||||||
|
expect(burnup.dates.at(-1)).toBe('2026-10-14');
|
||||||
|
expect(at(burnup.scope, '2026-10-05')).toBe(1); // A
|
||||||
|
expect(at(burnup.scope, '2026-10-12')).toBe(2); // + B
|
||||||
|
expect(at(burnup.done, '2026-10-09')).toBe(1);
|
||||||
|
expect(at(burnup.done, '2026-10-14')).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
315
libs/api/tracker/application/src/lib/analytics/analytics.ts
Normal file
315
libs/api/tracker/application/src/lib/analytics/analytics.ts
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
import {
|
||||||
|
DurationStats,
|
||||||
|
IssueReadModel,
|
||||||
|
IssueTimelineEntry,
|
||||||
|
ProjectAnalyticsReadModel,
|
||||||
|
ReleaseBurnupReadModel,
|
||||||
|
ReleaseReadModel,
|
||||||
|
StatusCategory,
|
||||||
|
UserRefReadModel,
|
||||||
|
} from '@task-stream/api/tracker/domain';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Statistics are computed from each ticket's timeline (status and release
|
||||||
|
* steps). Pure functions: no Nest, no database, easy to test.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DAY = 86_400_000;
|
||||||
|
const STARTED = [StatusCategory.IN_PROGRESS, StatusCategory.QA];
|
||||||
|
|
||||||
|
interface Step {
|
||||||
|
at: number;
|
||||||
|
status: string;
|
||||||
|
category: StatusCategory;
|
||||||
|
assigneeId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "2026-10-06" for a timestamp (UTC). */
|
||||||
|
export const dayOf = (time: number) =>
|
||||||
|
new Date(time).toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
/** Monday of the ISO week of a timestamp (UTC), as "YYYY-MM-DD". */
|
||||||
|
export function weekOf(time: number): string {
|
||||||
|
const date = new Date(time);
|
||||||
|
const shift = (date.getUTCDay() + 6) % 7;
|
||||||
|
return dayOf(
|
||||||
|
Date.UTC(
|
||||||
|
date.getUTCFullYear(),
|
||||||
|
date.getUTCMonth(),
|
||||||
|
date.getUTCDate() - shift,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** End of each UTC day between two instants (inclusive). */
|
||||||
|
function days(from: number, to: number): number[] {
|
||||||
|
const out: number[] = [];
|
||||||
|
const first = Date.UTC(
|
||||||
|
new Date(from).getUTCFullYear(),
|
||||||
|
new Date(from).getUTCMonth(),
|
||||||
|
new Date(from).getUTCDate(),
|
||||||
|
);
|
||||||
|
for (let day = first; day <= to; day += DAY) out.push(day + DAY - 1);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function weeks(from: number, to: number): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
for (let day = Date.parse(weekOf(from)); day <= to; day += 7 * DAY)
|
||||||
|
out.push(dayOf(day));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Status steps per ticket; tickets with no timeline get one from their creation. */
|
||||||
|
function stepsByIssue(
|
||||||
|
issues: IssueReadModel[],
|
||||||
|
timeline: IssueTimelineEntry[],
|
||||||
|
): Map<string, Step[]> {
|
||||||
|
const steps = new Map<string, Step[]>();
|
||||||
|
for (const entry of timeline) {
|
||||||
|
if (entry.kind !== 'status' || !entry.status || !entry.category) continue;
|
||||||
|
const list = steps.get(entry.issueId) ?? [];
|
||||||
|
list.push({
|
||||||
|
at: Date.parse(entry.at),
|
||||||
|
status: entry.status,
|
||||||
|
category: entry.category,
|
||||||
|
assigneeId: entry.assigneeId ?? null,
|
||||||
|
});
|
||||||
|
steps.set(entry.issueId, list);
|
||||||
|
}
|
||||||
|
for (const issue of issues) {
|
||||||
|
if (!steps.has(issue.id)) {
|
||||||
|
steps.set(issue.id, [
|
||||||
|
{
|
||||||
|
at: Date.parse(issue.createdAt),
|
||||||
|
status: issue.status,
|
||||||
|
category: issue.statusCategory,
|
||||||
|
assigneeId: issue.assignee?.id ?? null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
steps.get(issue.id)?.sort((a, b) => a.at - b.at);
|
||||||
|
}
|
||||||
|
return steps;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Last step at or before `time`. */
|
||||||
|
function stepAt(steps: Step[], time: number): Step | null {
|
||||||
|
let current: Step | null = null;
|
||||||
|
for (const step of steps) {
|
||||||
|
if (step.at > time) break;
|
||||||
|
current = step;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function durations(values: number[]): DurationStats {
|
||||||
|
if (values.length === 0)
|
||||||
|
return { count: 0, averageDays: null, p85Days: null };
|
||||||
|
const sorted = [...values].sort((a, b) => a - b);
|
||||||
|
const average = values.reduce((sum, v) => sum + v, 0) / values.length;
|
||||||
|
const p85 = sorted[Math.max(0, Math.ceil(0.85 * sorted.length) - 1)];
|
||||||
|
const round = (ms: number) => Math.round((ms / DAY) * 10) / 10;
|
||||||
|
return {
|
||||||
|
count: values.length,
|
||||||
|
averageDays: round(average),
|
||||||
|
p85Days: round(p85),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsInput {
|
||||||
|
issues: IssueReadModel[];
|
||||||
|
timeline: IssueTimelineEntry[];
|
||||||
|
/** Status ids in the project's order (series follow it). */
|
||||||
|
statusOrder: string[];
|
||||||
|
from: Date;
|
||||||
|
to: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeProjectAnalytics(
|
||||||
|
input: AnalyticsInput,
|
||||||
|
): ProjectAnalyticsReadModel {
|
||||||
|
const from = input.from.getTime();
|
||||||
|
const to = input.to.getTime();
|
||||||
|
const inRange = (time: number) => time >= from && time <= to;
|
||||||
|
const steps = stepsByIssue(input.issues, input.timeline);
|
||||||
|
const byId = new Map(input.issues.map((issue) => [issue.id, issue]));
|
||||||
|
const people = new Map<string, UserRefReadModel>();
|
||||||
|
for (const issue of input.issues) {
|
||||||
|
if (issue.assignee) people.set(issue.assignee.id, issue.assignee);
|
||||||
|
people.set(issue.reporter.id, issue.reporter);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Created vs resolved, per week.
|
||||||
|
const weekKeys = weeks(from, to);
|
||||||
|
const created = new Map(weekKeys.map((week) => [week, 0]));
|
||||||
|
const resolved = new Map(weekKeys.map((week) => [week, 0]));
|
||||||
|
for (const issue of input.issues) {
|
||||||
|
const at = Date.parse(issue.createdAt);
|
||||||
|
if (inRange(at))
|
||||||
|
created.set(weekOf(at), (created.get(weekOf(at)) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk every transition once: resolutions, cycle/lead time, throughput, QA.
|
||||||
|
const cycle: number[] = [];
|
||||||
|
const lead: number[] = [];
|
||||||
|
const throughput = new Map<
|
||||||
|
string,
|
||||||
|
{ week: string; user: string | null; count: number }
|
||||||
|
>();
|
||||||
|
let passed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
for (const [issueId, list] of steps) {
|
||||||
|
const issue = byId.get(issueId);
|
||||||
|
for (let i = 1; i < list.length; i++) {
|
||||||
|
const previous = list[i - 1];
|
||||||
|
const step = list[i];
|
||||||
|
if (!inRange(step.at)) continue;
|
||||||
|
if (
|
||||||
|
previous.category === StatusCategory.QA &&
|
||||||
|
step.category !== StatusCategory.QA
|
||||||
|
) {
|
||||||
|
if (step.category === StatusCategory.DONE) passed++;
|
||||||
|
else if (step.category !== StatusCategory.CANCELLED) failed++;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
step.category !== StatusCategory.DONE ||
|
||||||
|
previous.category === StatusCategory.DONE
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
const week = weekOf(step.at);
|
||||||
|
resolved.set(week, (resolved.get(week) ?? 0) + 1);
|
||||||
|
if (issue) lead.push(step.at - Date.parse(issue.createdAt));
|
||||||
|
const start = list.slice(0, i).find((s) => STARTED.includes(s.category));
|
||||||
|
if (start) cycle.push(step.at - start.at);
|
||||||
|
const key = `${week}|${step.assigneeId ?? ''}`;
|
||||||
|
const row = throughput.get(key) ?? {
|
||||||
|
week,
|
||||||
|
user: step.assigneeId,
|
||||||
|
count: 0,
|
||||||
|
};
|
||||||
|
row.count++;
|
||||||
|
throughput.set(key, row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cumulative flow: tickets per status at the end of each day.
|
||||||
|
const dayEnds = days(from, to);
|
||||||
|
const counts = new Map<string, number[]>();
|
||||||
|
for (const list of steps.values()) {
|
||||||
|
dayEnds.forEach((end, index) => {
|
||||||
|
const step = stepAt(list, end);
|
||||||
|
if (!step) return;
|
||||||
|
const series =
|
||||||
|
counts.get(step.status) ?? new Array(dayEnds.length).fill(0);
|
||||||
|
series[index]++;
|
||||||
|
counts.set(step.status, series);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const order = (status: string) => {
|
||||||
|
const index = input.statusOrder.indexOf(status);
|
||||||
|
return index === -1 ? Number.MAX_SAFE_INTEGER : index;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
from: input.from.toISOString(),
|
||||||
|
to: input.to.toISOString(),
|
||||||
|
createdVsResolved: weekKeys.map((week) => ({
|
||||||
|
week,
|
||||||
|
created: created.get(week) ?? 0,
|
||||||
|
resolved: resolved.get(week) ?? 0,
|
||||||
|
})),
|
||||||
|
cumulativeFlow: {
|
||||||
|
dates: dayEnds.map(dayOf),
|
||||||
|
series: [...counts.entries()]
|
||||||
|
.sort(([a], [b]) => order(a) - order(b))
|
||||||
|
.map(([status, values]) => ({ status, counts: values })),
|
||||||
|
},
|
||||||
|
cycleTime: durations(cycle),
|
||||||
|
leadTime: durations(lead),
|
||||||
|
throughput: [...throughput.values()]
|
||||||
|
.sort((a, b) => a.week.localeCompare(b.week) || b.count - a.count)
|
||||||
|
.map((row) => ({
|
||||||
|
week: row.week,
|
||||||
|
user: row.user
|
||||||
|
? (people.get(row.user) ?? { id: row.user, name: '?' })
|
||||||
|
: null,
|
||||||
|
count: row.count,
|
||||||
|
})),
|
||||||
|
qa: {
|
||||||
|
passed,
|
||||||
|
failed,
|
||||||
|
failureRate:
|
||||||
|
passed + failed
|
||||||
|
? Math.round((failed / (passed + failed)) * 1000) / 1000
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BurnupInput {
|
||||||
|
release: ReleaseReadModel;
|
||||||
|
issues: IssueReadModel[];
|
||||||
|
timeline: IssueTimelineEntry[];
|
||||||
|
now: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per day, from the release's creation until it ships (or today): tickets in
|
||||||
|
* it (scope, cancelled ones aside) and how many of those are done.
|
||||||
|
*/
|
||||||
|
export function computeReleaseBurnup(
|
||||||
|
input: BurnupInput,
|
||||||
|
): ReleaseBurnupReadModel {
|
||||||
|
const { release } = input;
|
||||||
|
const steps = stepsByIssue(input.issues, input.timeline);
|
||||||
|
const membership = new Map<
|
||||||
|
string,
|
||||||
|
{ at: number; releaseId: string | null }[]
|
||||||
|
>();
|
||||||
|
for (const entry of input.timeline) {
|
||||||
|
if (entry.kind !== 'release') continue;
|
||||||
|
const list = membership.get(entry.issueId) ?? [];
|
||||||
|
list.push({ at: Date.parse(entry.at), releaseId: entry.releaseId ?? null });
|
||||||
|
membership.set(entry.issueId, list);
|
||||||
|
}
|
||||||
|
for (const issue of input.issues) {
|
||||||
|
if (!membership.has(issue.id) && issue.release) {
|
||||||
|
membership.set(issue.id, [
|
||||||
|
{ at: Date.parse(issue.createdAt), releaseId: issue.release.id },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
membership.get(issue.id)?.sort((a, b) => a.at - b.at);
|
||||||
|
}
|
||||||
|
|
||||||
|
const end = release.releasedAt
|
||||||
|
? Date.parse(release.releasedAt)
|
||||||
|
: input.now.getTime();
|
||||||
|
// At most the last 180 days, so a long-lived release stays readable.
|
||||||
|
const start = Math.max(Date.parse(release.createdAt), end - 179 * DAY);
|
||||||
|
const dayEnds = days(start, end);
|
||||||
|
const scope: number[] = [];
|
||||||
|
const done: number[] = [];
|
||||||
|
for (const time of dayEnds) {
|
||||||
|
let inScope = 0;
|
||||||
|
let finished = 0;
|
||||||
|
for (const [issueId, list] of membership) {
|
||||||
|
const last = list.filter((m) => m.at <= time).at(-1);
|
||||||
|
if (last?.releaseId !== release.id) continue;
|
||||||
|
const step = stepAt(steps.get(issueId) ?? [], time);
|
||||||
|
if (!step || step.category === StatusCategory.CANCELLED) continue;
|
||||||
|
inScope++;
|
||||||
|
if (step.category === StatusCategory.DONE) finished++;
|
||||||
|
}
|
||||||
|
scope.push(inScope);
|
||||||
|
done.push(finished);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
releaseId: release.id,
|
||||||
|
targetDate: release.targetDate,
|
||||||
|
dates: dayEnds.map(dayOf),
|
||||||
|
scope,
|
||||||
|
done,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -10,11 +10,16 @@ export class ChangeIssueStatusCommandHandler
|
|||||||
constructor(private readonly access: WriteAccess) {}
|
constructor(private readonly access: WriteAccess) {}
|
||||||
|
|
||||||
async execute(command: ChangeIssueStatusCommand): Promise<CommandResult> {
|
async execute(command: ChangeIssueStatusCommand): Promise<CommandResult> {
|
||||||
const { issue } = await this.access.issueForContributor(
|
const { issue, project } = await this.access.issueForContributor(
|
||||||
command.issueId,
|
command.issueId,
|
||||||
command.actorId,
|
command.actorId,
|
||||||
);
|
);
|
||||||
issue.changeStatus(command.actorId, command.status, command.reason);
|
issue.changeStatus(
|
||||||
|
command.actorId,
|
||||||
|
command.status,
|
||||||
|
command.reason,
|
||||||
|
project.workflow,
|
||||||
|
);
|
||||||
await issue.commitAndWait();
|
await issue.commitAndWait();
|
||||||
return ack(issue);
|
return ack(issue);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { Command } from '@nestjs/cqrs';
|
import { Command } from '@nestjs/cqrs';
|
||||||
import { IssueStatus } from '@task-stream/api/tracker/domain';
|
|
||||||
import { CommandResult } from './command-result';
|
import { CommandResult } from './command-result';
|
||||||
|
|
||||||
export class ChangeIssueStatusCommand extends Command<CommandResult> {
|
export class ChangeIssueStatusCommand extends Command<CommandResult> {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly actorId: string,
|
public readonly actorId: string,
|
||||||
public readonly issueId: string,
|
public readonly issueId: string,
|
||||||
public readonly status: IssueStatus,
|
/** Status id of the project's workflow. */
|
||||||
|
public readonly status: string,
|
||||||
/** Required for QA_FAILED and CANCELLED. */
|
/** Required for QA_FAILED and CANCELLED. */
|
||||||
public readonly reason?: string | null,
|
public readonly reason?: string | null,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { CreateProjectCommandHandler } from './create-project.command-handler';
|
import { CreateProjectCommandHandler } from './create-project.command-handler';
|
||||||
import { UpdateProjectCommandHandler } from './update-project.command-handler';
|
import { UpdateProjectCommandHandler } from './update-project.command-handler';
|
||||||
|
import { UpdateProjectSettingsCommandHandler } from './update-project-settings.command-handler';
|
||||||
import { AddProjectMemberCommandHandler } from './add-project-member.command-handler';
|
import { AddProjectMemberCommandHandler } from './add-project-member.command-handler';
|
||||||
import { ChangeProjectMemberRoleCommandHandler } from './change-project-member-role.command-handler';
|
import { ChangeProjectMemberRoleCommandHandler } from './change-project-member-role.command-handler';
|
||||||
import { RemoveProjectMemberCommandHandler } from './remove-project-member.command-handler';
|
import { RemoveProjectMemberCommandHandler } from './remove-project-member.command-handler';
|
||||||
@@ -17,10 +18,18 @@ import { CreateReleaseCommandHandler } from './create-release.command-handler';
|
|||||||
import { UpdateReleaseCommandHandler } from './update-release.command-handler';
|
import { UpdateReleaseCommandHandler } from './update-release.command-handler';
|
||||||
import { PublishReleaseCommandHandler } from './publish-release.command-handler';
|
import { PublishReleaseCommandHandler } from './publish-release.command-handler';
|
||||||
import { ArchiveReleaseCommandHandler } from './archive-release.command-handler';
|
import { ArchiveReleaseCommandHandler } from './archive-release.command-handler';
|
||||||
|
import { DeleteProjectCommandHandler } from './delete-project.command-handler';
|
||||||
|
import { DeleteIssueCommandHandler } from './delete-issue.command-handler';
|
||||||
|
import { DeleteReleaseCommandHandler } from './delete-release.command-handler';
|
||||||
|
import { PurgeCommentCommandHandler } from './purge-comment.command-handler';
|
||||||
|
import { DropLinksToIssueCommandHandler } from './drop-links-to-issue.command-handler';
|
||||||
|
import { DetachIssueFromReleaseCommandHandler } from './detach-issue-from-release.command-handler';
|
||||||
|
import { UnassignIssueCommandHandler } from './unassign-issue.command-handler';
|
||||||
|
|
||||||
export const COMMAND_HANDLERS = [
|
export const COMMAND_HANDLERS = [
|
||||||
CreateProjectCommandHandler,
|
CreateProjectCommandHandler,
|
||||||
UpdateProjectCommandHandler,
|
UpdateProjectCommandHandler,
|
||||||
|
UpdateProjectSettingsCommandHandler,
|
||||||
AddProjectMemberCommandHandler,
|
AddProjectMemberCommandHandler,
|
||||||
ChangeProjectMemberRoleCommandHandler,
|
ChangeProjectMemberRoleCommandHandler,
|
||||||
RemoveProjectMemberCommandHandler,
|
RemoveProjectMemberCommandHandler,
|
||||||
@@ -38,4 +47,12 @@ export const COMMAND_HANDLERS = [
|
|||||||
UpdateReleaseCommandHandler,
|
UpdateReleaseCommandHandler,
|
||||||
PublishReleaseCommandHandler,
|
PublishReleaseCommandHandler,
|
||||||
ArchiveReleaseCommandHandler,
|
ArchiveReleaseCommandHandler,
|
||||||
|
DeleteProjectCommandHandler,
|
||||||
|
// Emitted by sagas only (cascades and reactions to other aggregates).
|
||||||
|
DeleteIssueCommandHandler,
|
||||||
|
DeleteReleaseCommandHandler,
|
||||||
|
PurgeCommentCommandHandler,
|
||||||
|
DropLinksToIssueCommandHandler,
|
||||||
|
DetachIssueFromReleaseCommandHandler,
|
||||||
|
UnassignIssueCommandHandler,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -19,21 +19,28 @@ export class CreateIssueCommandHandler
|
|||||||
async execute(command: CreateIssueCommand): Promise<CommandResult> {
|
async execute(command: CreateIssueCommand): Promise<CommandResult> {
|
||||||
const project = await this.access.project(command.projectId);
|
const project = await this.access.project(command.projectId);
|
||||||
project.assertCanContribute(command.actorId);
|
project.assertCanContribute(command.actorId);
|
||||||
|
// Before taking a number: an invalid type or area must not burn one.
|
||||||
|
const workflow = project.workflow;
|
||||||
|
workflow.assertActiveType(command.type);
|
||||||
|
workflow.assertActiveArea(command.details.area);
|
||||||
if (command.assigneeId)
|
if (command.assigneeId)
|
||||||
this.access.assertAssignable(project, command.assigneeId);
|
this.access.assertAssignable(project, command.assigneeId);
|
||||||
if (command.releaseId)
|
if (command.releaseId)
|
||||||
await this.access.openReleaseOf(project, command.releaseId);
|
await this.access.openReleaseOf(project, command.releaseId);
|
||||||
|
|
||||||
const issue = this.issueFactory.create({
|
const issue = this.issueFactory.create(
|
||||||
...command.details,
|
{
|
||||||
projectId: project.id,
|
...command.details,
|
||||||
projectKey: project.key,
|
projectId: project.id,
|
||||||
number: await this.issueNumbers.next(project.id),
|
projectKey: project.key,
|
||||||
type: command.type,
|
number: await this.issueNumbers.next(project.id),
|
||||||
title: command.title,
|
type: command.type,
|
||||||
reporterId: command.actorId,
|
title: command.title,
|
||||||
assigneeId: command.assigneeId,
|
reporterId: command.actorId,
|
||||||
});
|
assigneeId: command.assigneeId,
|
||||||
|
},
|
||||||
|
workflow,
|
||||||
|
);
|
||||||
if (command.releaseId) issue.setRelease(command.actorId, command.releaseId);
|
if (command.releaseId) issue.setRelease(command.actorId, command.releaseId);
|
||||||
|
|
||||||
this.eventPublisher.mergeObjectContext(issue);
|
this.eventPublisher.mergeObjectContext(issue);
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { Command } from '@nestjs/cqrs';
|
import { Command } from '@nestjs/cqrs';
|
||||||
import { IssueDetails, IssueType } from '@task-stream/api/tracker/domain';
|
import { IssueDetails } from '@task-stream/api/tracker/domain';
|
||||||
import { CommandResult } from './command-result';
|
import { CommandResult } from './command-result';
|
||||||
|
|
||||||
export class CreateIssueCommand extends Command<CommandResult> {
|
export class CreateIssueCommand extends Command<CommandResult> {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly actorId: string,
|
public readonly actorId: string,
|
||||||
public readonly projectId: string,
|
public readonly projectId: string,
|
||||||
public readonly type: IssueType,
|
/** Ticket type id of the project's settings. */
|
||||||
|
public readonly type: string,
|
||||||
public readonly title: string,
|
public readonly title: string,
|
||||||
public readonly details: Omit<IssueDetails, 'title' | 'type'>,
|
public readonly details: Omit<IssueDetails, 'title' | 'type'>,
|
||||||
public readonly assigneeId?: string | null,
|
public readonly assigneeId?: string | null,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export class CreateProjectCommandHandler
|
|||||||
key: command.key,
|
key: command.key,
|
||||||
name: command.name,
|
name: command.name,
|
||||||
description: command.description,
|
description: command.description,
|
||||||
|
isPublic: command.isPublic,
|
||||||
ownerId: command.actorId,
|
ownerId: command.actorId,
|
||||||
});
|
});
|
||||||
await this.projectKeys.reserve(project.key, project.id);
|
await this.projectKeys.reserve(project.key, project.id);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export class CreateProjectCommand extends Command<CommandResult> {
|
|||||||
public readonly key: string,
|
public readonly key: string,
|
||||||
public readonly name: string,
|
public readonly name: string,
|
||||||
public readonly description?: string | null,
|
public readonly description?: string | null,
|
||||||
|
public readonly isPublic = false,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user