Compare commits
41 Commits
master
...
c2928d2385
| Author | SHA1 | Date | |
|---|---|---|---|
| c2928d2385 | |||
| e6ac02d328 | |||
| 58fe5e8301 | |||
| 62fac8ef45 | |||
| 8c998d7423 | |||
| feea58d0e8 | |||
| bec39da46d | |||
| 71b807b94b | |||
| d6a434ca1d | |||
| 4148335457 | |||
| 5863d773be | |||
| 8994590b54 | |||
| 1076205acc | |||
| d43fc620e9 | |||
| 046d77c813 | |||
| 24b37b6628 | |||
| 45a3dc1e16 | |||
| 09bd8c19a3 | |||
| 6aec774c92 | |||
| 50d3ffd1dd | |||
| d2e31789a7 | |||
| 298383f19f | |||
| 0606f53026 | |||
| d4da33d4e8 | |||
| 75323115fb | |||
| 88928b402f | |||
| efa06a0723 | |||
| 0b65c1c912 | |||
| 80d9bee6e0 | |||
| 9236cb73c6 | |||
| 71dc2d6f2d | |||
| e656222b54 | |||
| 0e301ad545 | |||
| 240ea9a539 | |||
| 6c3550ae9d | |||
| b1002e6599 | |||
| cbaad17fc7 | |||
| 7532938b4e | |||
| 341241a52f | |||
| b2c9be72c3 | |||
| bf0294a188 |
40
.env.example
40
.env.example
@@ -18,12 +18,48 @@ 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
|
||||||
|
|
||||||
|
# Files attached to tickets: folder (a volume in Docker) and size limit per file.
|
||||||
|
ATTACHMENTS_DIR=data/attachments
|
||||||
|
ATTACHMENTS_MAX_MB=25
|
||||||
|
|
||||||
|
# Optional e-mail copies of notifications. Without SMTP_URL nothing is sent.
|
||||||
|
SMTP_URL=
|
||||||
|
MAIL_FROM=
|
||||||
|
WEB_URL=http://localhost:4200
|
||||||
|
|
||||||
|
# Outgoing webhooks refuse hosts on this machine or its private network unless true.
|
||||||
|
WEBHOOKS_ALLOW_PRIVATE=false
|
||||||
|
|
||||||
|
# Due-date reminders (hourly). The day changes at midnight in this time zone.
|
||||||
|
REMINDERS_ENABLED=true
|
||||||
|
REMINDERS_TIMEZONE=Europe/Madrid
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -53,3 +53,6 @@ Thumbs.db
|
|||||||
__screenshots__/
|
__screenshots__/
|
||||||
|
|
||||||
vitest.config.*.timestamp*!apps/web/.env
|
vitest.config.*.timestamp*!apps/web/.env
|
||||||
|
|
||||||
|
# Attached files in development (ATTACHMENTS_DIR)
|
||||||
|
/data
|
||||||
|
|||||||
@@ -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/**"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
32
Dockerfile
32
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
|
||||||
@@ -25,12 +30,33 @@ ENV NODE_ENV=production PORT=3000
|
|||||||
COPY --from=build-api /workspace/dist/apps/api/package.json /workspace/dist/apps/api/package-lock.json ./
|
COPY --from=build-api /workspace/dist/apps/api/package.json /workspace/dist/apps/api/package-lock.json ./
|
||||||
RUN npm ci --omit=dev --ignore-scripts --no-audit --no-fund && npm cache clean --force
|
RUN npm ci --omit=dev --ignore-scripts --no-audit --no-fund && npm cache clean --force
|
||||||
COPY --from=build-api /workspace/dist/apps/api/main.js ./
|
COPY --from=build-api /workspace/dist/apps/api/main.js ./
|
||||||
|
# Attached files (a volume in docker-compose), writable by the user that runs the API.
|
||||||
|
RUN mkdir -p /data/attachments && chown node:node /data/attachments
|
||||||
|
ENV ATTACHMENTS_DIR=/data/attachments
|
||||||
USER node
|
USER node
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
HEALTHCHECK --interval=10s --timeout=3s --start-period=20s --retries=5 \
|
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);
|
|
||||||
});
|
|
||||||
12769
apps/api/openapi.json
Normal file
12769
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",
|
||||||
@@ -24,6 +25,7 @@
|
|||||||
"ioredis": "^5.11.1",
|
"ioredis": "^5.11.1",
|
||||||
"joi": "^18.2.9",
|
"joi": "^18.2.9",
|
||||||
"mongoose": "^8.24.4",
|
"mongoose": "^8.24.4",
|
||||||
|
"nodemailer": "^7.0.13",
|
||||||
"pg": "^8.23.1",
|
"pg": "^8.23.1",
|
||||||
"reflect-metadata": "^0.2.0",
|
"reflect-metadata": "^0.2.0",
|
||||||
"rxjs": "^7.8.0",
|
"rxjs": "^7.8.0",
|
||||||
|
|||||||
@@ -3,15 +3,10 @@
|
|||||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||||
"sourceRoot": "apps/api/src",
|
"sourceRoot": "apps/api/src",
|
||||||
"projectType": "application",
|
"projectType": "application",
|
||||||
"tags": [
|
"tags": ["scope:api", "type:app"],
|
||||||
"scope:api",
|
|
||||||
"type:app"
|
|
||||||
],
|
|
||||||
"targets": {
|
"targets": {
|
||||||
"prune-lockfile": {
|
"prune-lockfile": {
|
||||||
"dependsOn": [
|
"dependsOn": ["build"],
|
||||||
"build"
|
|
||||||
],
|
|
||||||
"cache": true,
|
"cache": true,
|
||||||
"executor": "@nx/js:prune-lockfile",
|
"executor": "@nx/js:prune-lockfile",
|
||||||
"outputs": [
|
"outputs": [
|
||||||
@@ -23,32 +18,23 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"copy-workspace-modules": {
|
"copy-workspace-modules": {
|
||||||
"dependsOn": [
|
"dependsOn": ["build"],
|
||||||
"build"
|
|
||||||
],
|
|
||||||
"cache": true,
|
"cache": true,
|
||||||
"outputs": [
|
"outputs": ["{workspaceRoot}/dist/apps/api/workspace_modules"],
|
||||||
"{workspaceRoot}/dist/apps/api/workspace_modules"
|
|
||||||
],
|
|
||||||
"executor": "@nx/js:copy-workspace-modules",
|
"executor": "@nx/js:copy-workspace-modules",
|
||||||
"options": {
|
"options": {
|
||||||
"buildTarget": "build"
|
"buildTarget": "build"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"prune": {
|
"prune": {
|
||||||
"dependsOn": [
|
"dependsOn": ["prune-lockfile", "copy-workspace-modules"],
|
||||||
"prune-lockfile",
|
|
||||||
"copy-workspace-modules"
|
|
||||||
],
|
|
||||||
"executor": "nx:noop"
|
"executor": "nx:noop"
|
||||||
},
|
},
|
||||||
"serve": {
|
"serve": {
|
||||||
"continuous": true,
|
"continuous": true,
|
||||||
"executor": "@nx/js:node",
|
"executor": "@nx/js:node",
|
||||||
"defaultConfiguration": "development",
|
"defaultConfiguration": "development",
|
||||||
"dependsOn": [
|
"dependsOn": ["build"],
|
||||||
"build"
|
|
||||||
],
|
|
||||||
"options": {
|
"options": {
|
||||||
"buildTarget": "api:build",
|
"buildTarget": "api:build",
|
||||||
"runBuildTargetDependencies": false
|
"runBuildTargetDependencies": false
|
||||||
@@ -73,6 +59,13 @@
|
|||||||
"command": "jest --config apps/api/jest.e2e.config.cts --runInBand"
|
"command": "jest --config apps/api/jest.e2e.config.cts --runInBand"
|
||||||
},
|
},
|
||||||
"cache": false
|
"cache": false
|
||||||
|
},
|
||||||
|
"contracts": {
|
||||||
|
"executor": "nx:run-commands",
|
||||||
|
"options": {
|
||||||
|
"command": "node -r ./apps/api/scripts/register.cjs apps/api/scripts/contracts.ts"
|
||||||
|
},
|
||||||
|
"cache": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1086
apps/api/schema.gql
1086
apps/api/schema.gql
File diff suppressed because it is too large
Load Diff
117
apps/api/scripts/contracts.ts
Normal file
117
apps/api/scripts/contracts.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* Writes apps/api/schema.gql and apps/api/openapi.json without starting the
|
||||||
|
* API or touching a database: `npx nx run api:contracts`.
|
||||||
|
*
|
||||||
|
* The running API writes the same files on start in development; this is for
|
||||||
|
* when no database is at hand — a CI job, a laptop without Docker running.
|
||||||
|
*/
|
||||||
|
import 'reflect-metadata';
|
||||||
|
import { writeFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { InjectionToken, Module, Provider, Type } from '@nestjs/common';
|
||||||
|
import { MODULE_METADATA } from '@nestjs/common/constants';
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import {
|
||||||
|
GRAPHQL_SDL_FILE_HEADER,
|
||||||
|
GraphQLSchemaBuilderModule,
|
||||||
|
GraphQLSchemaFactory,
|
||||||
|
} from '@nestjs/graphql';
|
||||||
|
import { IdentityPresentersModule } from '@task-stream/api/identity/presenters';
|
||||||
|
import { TrackerPresentersModule } from '@task-stream/api/tracker/presenters';
|
||||||
|
import { lexicographicSortSchema, printSchema } from 'graphql';
|
||||||
|
import { AppModule } from '../src/app/app.module';
|
||||||
|
import { buildOpenApiDocument } from '../src/app/openapi';
|
||||||
|
|
||||||
|
/** The GraphQL resolvers a presenters module declares. */
|
||||||
|
function resolversOf(module: Type): Type[] {
|
||||||
|
const providers: unknown[] =
|
||||||
|
Reflect.getMetadata(MODULE_METADATA.PROVIDERS, module) ?? [];
|
||||||
|
return providers.filter(
|
||||||
|
(provider): provider is Type =>
|
||||||
|
typeof provider === 'function' &&
|
||||||
|
// `@Resolver()` without a type (subscriptions) sets no type, only a name.
|
||||||
|
Reflect.getMetadataKeys(provider).some((key) =>
|
||||||
|
String(key).startsWith('graphql:resolver'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function graphqlSchema(): Promise<string> {
|
||||||
|
const app = await NestFactory.create(GraphQLSchemaBuilderModule, {
|
||||||
|
logger: false,
|
||||||
|
});
|
||||||
|
await app.init();
|
||||||
|
const schema = await app
|
||||||
|
.get(GraphQLSchemaFactory)
|
||||||
|
.create([
|
||||||
|
...resolversOf(IdentityPresentersModule),
|
||||||
|
...resolversOf(TrackerPresentersModule),
|
||||||
|
]);
|
||||||
|
await app.close();
|
||||||
|
// Same output as GraphQLModule's autoSchemaFile with sortSchema.
|
||||||
|
return GRAPHQL_SDL_FILE_HEADER + printSchema(lexicographicSortSchema(schema));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The REST controllers a module declares. */
|
||||||
|
function controllersOf(module: Type): Type[] {
|
||||||
|
return Reflect.getMetadata(MODULE_METADATA.CONTROLLERS, module) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empty stand-ins for whatever the controllers' constructors ask for: Swagger
|
||||||
|
* reads the controllers' metadata and never calls them.
|
||||||
|
*/
|
||||||
|
function standIns(controllers: Type[]): Provider[] {
|
||||||
|
const tokens = new Set<unknown>();
|
||||||
|
for (const controller of controllers) {
|
||||||
|
const types: unknown[] =
|
||||||
|
Reflect.getMetadata('design:paramtypes', controller) ?? [];
|
||||||
|
const injected: { index: number; param: unknown }[] =
|
||||||
|
Reflect.getMetadata('self:paramtypes', controller) ?? [];
|
||||||
|
types.forEach((type, index) =>
|
||||||
|
tokens.add(injected.find((i) => i.index === index)?.param ?? type),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return [...tokens].map((token) => ({
|
||||||
|
provide: token as InjectionToken,
|
||||||
|
useValue: {},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openApi(): Promise<string> {
|
||||||
|
// Only the controllers, with stand-ins for their dependencies: nothing
|
||||||
|
// connects to Postgres, Mongo, Redis or Pireon.
|
||||||
|
const controllers = [
|
||||||
|
...controllersOf(AppModule),
|
||||||
|
...controllersOf(IdentityPresentersModule),
|
||||||
|
...controllersOf(TrackerPresentersModule),
|
||||||
|
];
|
||||||
|
|
||||||
|
@Module({ controllers, providers: standIns(controllers) })
|
||||||
|
class ContractsModule {}
|
||||||
|
|
||||||
|
const app = await NestFactory.create(ContractsModule, { logger: false });
|
||||||
|
app.setGlobalPrefix('api');
|
||||||
|
const document = buildOpenApiDocument(app, {
|
||||||
|
pireonBaseUrl: process.env['PIREON_BASE_URL'] || 'https://oauth.pireon.dev',
|
||||||
|
scopes: (process.env['PIREON_SCOPES'] || 'users:read')
|
||||||
|
.split(/[\s,]+/)
|
||||||
|
.filter(Boolean),
|
||||||
|
passwordGrant: process.env['PIREON_PASSWORD_GRANT'] !== 'false',
|
||||||
|
});
|
||||||
|
return JSON.stringify(document, null, 2) + '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const root = process.cwd();
|
||||||
|
writeFileSync(join(root, 'apps/api/schema.gql'), await graphqlSchema());
|
||||||
|
writeFileSync(join(root, 'apps/api/openapi.json'), await openApi());
|
||||||
|
}
|
||||||
|
|
||||||
|
main().then(
|
||||||
|
() => process.exit(0),
|
||||||
|
(error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
},
|
||||||
|
);
|
||||||
21
apps/api/scripts/register.cjs
Normal file
21
apps/api/scripts/register.cjs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
/*
|
||||||
|
* Lets Node run the TypeScript sources of the monorepo directly: compiles
|
||||||
|
* them on the fly (decorator metadata included, which Nest needs) and
|
||||||
|
* resolves the `@task-stream/*` aliases of tsconfig.base.json.
|
||||||
|
*/
|
||||||
|
const { join } = require('node:path');
|
||||||
|
const root = join(__dirname, '../../..');
|
||||||
|
|
||||||
|
require('ts-node').register({
|
||||||
|
project: join(root, 'apps/api/tsconfig.app.json'),
|
||||||
|
transpileOnly: true,
|
||||||
|
compilerOptions: {
|
||||||
|
module: 'commonjs',
|
||||||
|
moduleResolution: 'node10',
|
||||||
|
ignoreDeprecations: '6.0',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
require('tsconfig-paths').register({
|
||||||
|
baseUrl: root,
|
||||||
|
paths: require(join(root, 'tsconfig.base.json')).compilerOptions.paths,
|
||||||
|
});
|
||||||
@@ -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,19 @@ 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'),
|
||||||
|
|
||||||
|
// Attached files: where they are kept (a volume in Docker) and how big one can be.
|
||||||
|
ATTACHMENTS_DIR: Joi.string().default('data/attachments'),
|
||||||
|
ATTACHMENTS_MAX_MB: Joi.number().min(1).max(500).default(25),
|
||||||
|
|
||||||
|
// E-mail copies of notifications (optional): without SMTP_URL nothing is sent.
|
||||||
|
SMTP_URL: Joi.string().allow('').optional(),
|
||||||
|
MAIL_FROM: Joi.string().allow('').optional(),
|
||||||
|
// Public address of the web app, for links in e-mails.
|
||||||
|
WEB_URL: Joi.string().allow('').optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
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'),
|
||||||
|
|||||||
179
apps/api/src/app/openapi.ts
Normal file
179
apps/api/src/app/openapi.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { DocumentBuilder, OpenAPIObject, 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();
|
||||||
|
|
||||||
|
export interface OpenApiSettings {
|
||||||
|
pireonBaseUrl: string;
|
||||||
|
scopes: string[];
|
||||||
|
passwordGrant: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The OpenAPI document of the REST controllers. Needs no database. */
|
||||||
|
export function buildOpenApiDocument(
|
||||||
|
app: INestApplication,
|
||||||
|
settings: OpenApiSettings,
|
||||||
|
): OpenAPIObject {
|
||||||
|
const pireon = settings.pireonBaseUrl;
|
||||||
|
const scopes = settings.scopes;
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
...(settings.passwordGrant
|
||||||
|
? {
|
||||||
|
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 = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return document;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 scopes = (config.get<string>('PIREON_SCOPES') ?? '')
|
||||||
|
.split(/[\s,]+/)
|
||||||
|
.filter(Boolean);
|
||||||
|
const document = buildOpenApiDocument(app, {
|
||||||
|
pireonBaseUrl:
|
||||||
|
config.get<string>('PIREON_BASE_URL') ?? 'https://oauth.pireon.dev',
|
||||||
|
scopes,
|
||||||
|
passwordGrant: !!config.get<boolean>('PIREON_PASSWORD_GRANT'),
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
8924
apps/mcp/src/app/api/taskstream.schema.ts
Normal file
8924
apps/mcp/src/app/api/taskstream.schema.ts
Normal file
File diff suppressed because it is too large
Load Diff
63
apps/mcp/src/app/app.module.ts
Normal file
63
apps/mcp/src/app/app.module.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
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 { BacklogResolver } from './tools/backlog.resolver';
|
||||||
|
import { IssuesResolver } from './tools/issues.resolver';
|
||||||
|
import { NotificationsResolver } from './tools/notifications.resolver';
|
||||||
|
import { ProjectsResolver } from './tools/projects.resolver';
|
||||||
|
import { ReleasesResolver } from './tools/releases.resolver';
|
||||||
|
import { SprintsResolver } from './tools/sprints.resolver';
|
||||||
|
import { UsersResolver } from './tools/users.resolver';
|
||||||
|
|
||||||
|
const INSTRUCTIONS = `TaskStream: issue tracker (projects, tickets, epics, sprints, 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. Sprints and releases go by id
|
||||||
|
(list_sprints, list_releases); the backlog is search_issues with noSprint and sort RANK.
|
||||||
|
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,
|
||||||
|
BacklogResolver,
|
||||||
|
SprintsResolver,
|
||||||
|
ReleasesResolver,
|
||||||
|
NotificationsResolver,
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
375
apps/mcp/src/app/mcp.spec.ts
Normal file
375
apps/mcp/src/app/mcp.spec.ts
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
const ISSUE_1 = '11111111-1111-4111-8111-111111111111';
|
||||||
|
const ISSUE_2 = '22222222-2222-4222-8222-222222222222';
|
||||||
|
|
||||||
|
/** 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',
|
||||||
|
});
|
||||||
|
if (req.method === 'POST' && req.url === '/api/projects')
|
||||||
|
return reply(201, { id: 'p2', key: 'DOCS', version: 1 });
|
||||||
|
if (req.url === '/api/issues/WEB-1') return reply(200, { id: ISSUE_1 });
|
||||||
|
if (req.url === '/api/issues/WEB-2') return reply(200, { id: ISSUE_2 });
|
||||||
|
if (req.url === '/api/issues/bulk')
|
||||||
|
return reply(200, {
|
||||||
|
updated: 1,
|
||||||
|
results: [
|
||||||
|
{ issueId: ISSUE_1, ok: true, error: null },
|
||||||
|
{ issueId: ISSUE_2, ok: false, error: 'Cannot move WEB-2' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (req.url === '/api/sprints/s1/close')
|
||||||
|
return reply(201, { id: 's1', version: 3 });
|
||||||
|
if (req.url?.startsWith('/api/me/notifications?'))
|
||||||
|
return reply(200, {
|
||||||
|
unread: 1,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'n1',
|
||||||
|
kind: 'MENTIONED',
|
||||||
|
projectId: 'p1',
|
||||||
|
projectKey: 'WEB',
|
||||||
|
issueId: ISSUE_1,
|
||||||
|
issueKey: 'WEB-1',
|
||||||
|
actor: { id: '101', name: 'Ana' },
|
||||||
|
summary: 'Ana te ha mencionado en WEB-1',
|
||||||
|
readAt: null,
|
||||||
|
createdAt: '2026-10-09T10:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
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',
|
||||||
|
'create_project',
|
||||||
|
'list_epics',
|
||||||
|
'bulk_update_issues',
|
||||||
|
'delete_issue',
|
||||||
|
'list_sprints',
|
||||||
|
'get_sprint',
|
||||||
|
'close_sprint',
|
||||||
|
'draft_release_notes',
|
||||||
|
'add_checklist_item',
|
||||||
|
'my_notifications',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
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);
|
||||||
|
expect(byName.get('delete_issue')?.annotations).toMatchObject({
|
||||||
|
destructiveHint: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates projects with the key in capitals', async () => {
|
||||||
|
const result = await call(
|
||||||
|
'create_project',
|
||||||
|
{ key: 'docs', name: 'Documentación', isPublic: true },
|
||||||
|
'pireon-token',
|
||||||
|
);
|
||||||
|
expect(result.isError).toBeUndefined();
|
||||||
|
expect(received[0]).toMatchObject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/projects',
|
||||||
|
body: { key: 'DOCS', name: 'Documentación', isPublic: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changes many tickets by key and says which refused', async () => {
|
||||||
|
const result = await call(
|
||||||
|
'bulk_update_issues',
|
||||||
|
{ issues: ['WEB-1', 'WEB-2'], status: 'DONE' },
|
||||||
|
'pireon-token',
|
||||||
|
);
|
||||||
|
expect(received.at(-1)).toMatchObject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/issues/bulk',
|
||||||
|
body: { issueIds: [ISSUE_1, ISSUE_2], status: 'DONE' },
|
||||||
|
});
|
||||||
|
expect(JSON.parse(result.content[0].text)).toEqual({
|
||||||
|
updated: 1,
|
||||||
|
refused: [{ issue: 'WEB-2', error: 'Cannot move WEB-2' }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes a sprint carrying the unfinished tickets over', async () => {
|
||||||
|
await call(
|
||||||
|
'close_sprint',
|
||||||
|
{ sprintId: 's1', carryOverTo: 's2' },
|
||||||
|
'pireon-token',
|
||||||
|
);
|
||||||
|
expect(received[0]).toMatchObject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/sprints/s1/close',
|
||||||
|
body: { carryOverTo: 's2' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads the unread notifications', async () => {
|
||||||
|
const result = await call('my_notifications', {}, 'pireon-token');
|
||||||
|
expect(received[0].url).toBe(
|
||||||
|
'/api/me/notifications?unreadOnly=true&limit=30',
|
||||||
|
);
|
||||||
|
expect(JSON.parse(result.content[0].text)).toEqual({
|
||||||
|
unread: 1,
|
||||||
|
items: [
|
||||||
|
expect.objectContaining({
|
||||||
|
kind: 'MENTIONED',
|
||||||
|
issue: 'WEB-1',
|
||||||
|
who: 'Ana',
|
||||||
|
read: false,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
243
apps/mcp/src/app/tools/backlog.resolver.ts
Normal file
243
apps/mcp/src/app/tools/backlog.resolver.ts
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
import { McpContext, Resolver, Tool } from '@nestjs-mcp/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { TaskStreamApi, type TaskStreamClient } from '../api/taskstream-api';
|
||||||
|
import { issueRef, 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 UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How the work is organised: epics and the tickets hanging from them, the
|
||||||
|
* backlog order, changes to many tickets at once and the trash.
|
||||||
|
*/
|
||||||
|
@Resolver()
|
||||||
|
export class BacklogResolver {
|
||||||
|
constructor(private readonly api: TaskStreamApi) {}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'list_epics',
|
||||||
|
description:
|
||||||
|
'Epics of a project (top-level tickets that group others) with the progress of the tickets hanging from each one.',
|
||||||
|
paramsSchema: z.object({ project: projectRef }),
|
||||||
|
annotations: READ,
|
||||||
|
})
|
||||||
|
epics({ project }: { project: string }, ctx: McpContext) {
|
||||||
|
return toolResult(async () =>
|
||||||
|
(
|
||||||
|
await this.api.data(
|
||||||
|
this.api.as(ctx).GET('/api/projects/{project}/epics', {
|
||||||
|
params: { path: { project } },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
).map((epic) => ({
|
||||||
|
...issueLine(epic.issue),
|
||||||
|
startDate: epic.issue.startDate,
|
||||||
|
progress: epic.progress,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'set_issue_parent',
|
||||||
|
description:
|
||||||
|
'Place a ticket under an epic or another ticket of the same project (three levels at most: epic → ticket → subtask), or back to the top (null).',
|
||||||
|
paramsSchema: z.object({
|
||||||
|
issue: issueRef,
|
||||||
|
parent: z.string().nullable().describe('Epic or ticket key, or null'),
|
||||||
|
}),
|
||||||
|
annotations: { ...WRITE, idempotentHint: true },
|
||||||
|
})
|
||||||
|
setParent(
|
||||||
|
{ issue, parent }: { issue: string; parent: string | null },
|
||||||
|
ctx: McpContext,
|
||||||
|
) {
|
||||||
|
return toolResult(() =>
|
||||||
|
this.api.data(
|
||||||
|
this.api.as(ctx).PUT('/api/issues/{issue}/parent', {
|
||||||
|
params: { path: { issue } },
|
||||||
|
body: { parent },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'rank_issue',
|
||||||
|
description:
|
||||||
|
'Move a ticket in the backlog (board columns follow the same order): say which ticket ends up right above it or right below it; above null puts it at the top, below null at the bottom.',
|
||||||
|
paramsSchema: z.object({
|
||||||
|
issue: issueRef,
|
||||||
|
above: z
|
||||||
|
.string()
|
||||||
|
.nullable()
|
||||||
|
.optional()
|
||||||
|
.describe('Ticket that ends up right above (null: to the top)'),
|
||||||
|
below: z
|
||||||
|
.string()
|
||||||
|
.nullable()
|
||||||
|
.optional()
|
||||||
|
.describe('Ticket that ends up right below (null: to the bottom)'),
|
||||||
|
}),
|
||||||
|
annotations: { ...WRITE, idempotentHint: true },
|
||||||
|
})
|
||||||
|
rank(
|
||||||
|
params: { issue: string; above?: string | null; below?: string | null },
|
||||||
|
ctx: McpContext,
|
||||||
|
) {
|
||||||
|
const { issue, ...body } = params;
|
||||||
|
return toolResult(() =>
|
||||||
|
this.api.data(
|
||||||
|
this.api.as(ctx).PUT('/api/issues/{issue}/rank', {
|
||||||
|
params: { path: { issue } },
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'bulk_update_issues',
|
||||||
|
description:
|
||||||
|
'The same change on up to 100 tickets: status, assignee, sprint, release, priority, extra labels. Each ticket follows the normal rules and may refuse on its own; the answer says which.',
|
||||||
|
paramsSchema: z.object({
|
||||||
|
issues: z.array(issueRef).min(1).max(100),
|
||||||
|
status: z.string().optional().describe('Status id'),
|
||||||
|
assigneeId: z.string().optional().describe('User id of a member'),
|
||||||
|
unassign: z.boolean().optional(),
|
||||||
|
sprintId: z.string().optional(),
|
||||||
|
noSprint: z.boolean().optional().describe('Back to the backlog'),
|
||||||
|
releaseId: z.string().optional(),
|
||||||
|
noRelease: z.boolean().optional().describe('Out of their release'),
|
||||||
|
priority: PRIORITY.optional(),
|
||||||
|
addLabels: z.array(z.string().max(40)).max(20).optional(),
|
||||||
|
}),
|
||||||
|
annotations: WRITE,
|
||||||
|
})
|
||||||
|
bulk(
|
||||||
|
params: {
|
||||||
|
issues: string[];
|
||||||
|
status?: string;
|
||||||
|
assigneeId?: string;
|
||||||
|
unassign?: boolean;
|
||||||
|
sprintId?: string;
|
||||||
|
noSprint?: boolean;
|
||||||
|
releaseId?: string;
|
||||||
|
noRelease?: boolean;
|
||||||
|
priority?: z.infer<typeof PRIORITY>;
|
||||||
|
addLabels?: string[];
|
||||||
|
},
|
||||||
|
ctx: McpContext,
|
||||||
|
) {
|
||||||
|
const { issues, ...change } = params;
|
||||||
|
return toolResult(async () => {
|
||||||
|
const client = this.api.as(ctx);
|
||||||
|
const refs = await this.ids(client, issues);
|
||||||
|
const answer = await this.api.data(
|
||||||
|
client.POST('/api/issues/bulk', {
|
||||||
|
body: { issueIds: refs.map((r) => r.id), ...change },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const keyOf = new Map(refs.map((r) => [r.id, r.ref]));
|
||||||
|
return {
|
||||||
|
updated: answer.updated,
|
||||||
|
refused: answer.results
|
||||||
|
.filter((r) => !r.ok)
|
||||||
|
.map((r) => ({ issue: keyOf.get(r.issueId), error: r.error })),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'delete_issue',
|
||||||
|
description:
|
||||||
|
'Move a ticket to the trash (whoever opened it, or an owner/admin). It disappears everywhere; restore_issue brings it back within 30 days, then it is deleted for good.',
|
||||||
|
paramsSchema: z.object({ issue: issueRef }),
|
||||||
|
annotations: { readOnlyHint: false, destructiveHint: true },
|
||||||
|
})
|
||||||
|
trash({ issue }: { issue: string }, ctx: McpContext) {
|
||||||
|
return toolResult(() =>
|
||||||
|
this.api.data(
|
||||||
|
this.api.as(ctx).DELETE('/api/issues/{issue}', {
|
||||||
|
params: { path: { issue } },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'restore_issue',
|
||||||
|
description: 'Bring a ticket back from the trash.',
|
||||||
|
paramsSchema: z.object({ issue: issueRef }),
|
||||||
|
annotations: { ...WRITE, idempotentHint: true },
|
||||||
|
})
|
||||||
|
restore({ issue }: { issue: string }, ctx: McpContext) {
|
||||||
|
return toolResult(() =>
|
||||||
|
this.api.data(
|
||||||
|
this.api.as(ctx).POST('/api/issues/{issue}/restore', {
|
||||||
|
params: { path: { issue } },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'list_trash',
|
||||||
|
description: "A project's tickets in the trash, newest first.",
|
||||||
|
paramsSchema: z.object({ project: projectRef }),
|
||||||
|
annotations: READ,
|
||||||
|
})
|
||||||
|
listTrash({ project }: { project: string }, ctx: McpContext) {
|
||||||
|
return toolResult(async () =>
|
||||||
|
(
|
||||||
|
await this.api.data(
|
||||||
|
this.api.as(ctx).GET('/api/projects/{project}/trash', {
|
||||||
|
params: { path: { project } },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
).map((issue) => ({ ...issueLine(issue), trashedAt: issue.trashedAt })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bulk endpoint takes ids; people and models talk in keys. Keys are
|
||||||
|
* looked up a few at a time, ids pass as they are.
|
||||||
|
*/
|
||||||
|
private async ids(
|
||||||
|
client: TaskStreamClient,
|
||||||
|
refs: string[],
|
||||||
|
): Promise<{ ref: string; id: string }[]> {
|
||||||
|
const found: { ref: string; id: string }[] = [];
|
||||||
|
for (let i = 0; i < refs.length; i += 10) {
|
||||||
|
const batch = refs.slice(i, i + 10);
|
||||||
|
found.push(
|
||||||
|
...(await Promise.all(
|
||||||
|
batch.map(async (ref) =>
|
||||||
|
UUID.test(ref)
|
||||||
|
? { ref, id: ref }
|
||||||
|
: {
|
||||||
|
ref,
|
||||||
|
id: (
|
||||||
|
await this.api.data(
|
||||||
|
client.GET('/api/issues/{issue}', {
|
||||||
|
params: { path: { issue: ref } },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
).id,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
619
apps/mcp/src/app/tools/issues.resolver.ts
Normal file
619
apps/mcp/src/app/tools/issues.resolver.ts
Normal file
@@ -0,0 +1,619 @@
|
|||||||
|
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 REMOVE = {
|
||||||
|
readOnlyHint: false,
|
||||||
|
destructiveHint: true,
|
||||||
|
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(),
|
||||||
|
startDate: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('ISO date when work should start (roadmap)'),
|
||||||
|
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;
|
||||||
|
startDate?: string;
|
||||||
|
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. The backlog: noSprint with sort RANK.',
|
||||||
|
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(),
|
||||||
|
parent: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Only the tickets hanging from this epic or ticket (key)'),
|
||||||
|
sprintId: z.string().optional().describe('Only this sprint'),
|
||||||
|
noSprint: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.describe('Only tickets in no sprint (the backlog)'),
|
||||||
|
dueFrom: z.string().optional().describe('Due on or after (ISO date)'),
|
||||||
|
dueTo: z.string().optional().describe('Due on or before (ISO date)'),
|
||||||
|
sort: z
|
||||||
|
.enum(['RECENT', 'RANK'])
|
||||||
|
.default('RECENT')
|
||||||
|
.describe('RECENT: last updated first. RANK: backlog order'),
|
||||||
|
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[];
|
||||||
|
parent?: string;
|
||||||
|
sprintId?: string;
|
||||||
|
noSprint?: boolean;
|
||||||
|
dueFrom?: string;
|
||||||
|
dueTo?: string;
|
||||||
|
sort: 'RECENT' | 'RANK';
|
||||||
|
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,
|
||||||
|
parent: params.parent,
|
||||||
|
sprintId: params.sprintId,
|
||||||
|
noSprint: params.noSprint,
|
||||||
|
dueFrom: params.dueFrom,
|
||||||
|
dueTo: params.dueTo,
|
||||||
|
sort: params.sort,
|
||||||
|
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, parent, checklist (with item ids), links, commits and pull requests, followers, 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,
|
||||||
|
startDate: issue.startDate,
|
||||||
|
allowedTransitions: issue.allowedTransitions,
|
||||||
|
parent: issue.parent,
|
||||||
|
checklist: issue.checklist,
|
||||||
|
links: issue.links,
|
||||||
|
devLinks: issue.devLinks.map((d) => ({
|
||||||
|
kind: d.kind,
|
||||||
|
repository: d.repository,
|
||||||
|
ref: d.ref,
|
||||||
|
title: d.title,
|
||||||
|
state: d.state,
|
||||||
|
url: d.url,
|
||||||
|
})),
|
||||||
|
watchers: issue.watchers.map((w) => w.name),
|
||||||
|
attachments: issue.attachments.length,
|
||||||
|
trashedAt: issue.trashedAt,
|
||||||
|
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(),
|
||||||
|
parent: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Epic or ticket of the same project it breaks down (key)'),
|
||||||
|
...issueFields,
|
||||||
|
}),
|
||||||
|
annotations: WRITE,
|
||||||
|
})
|
||||||
|
create(
|
||||||
|
params: IssueFields & {
|
||||||
|
project: string;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
assigneeId?: string;
|
||||||
|
releaseId?: string;
|
||||||
|
parent?: 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 },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'unlink_issues',
|
||||||
|
description:
|
||||||
|
'Remove a relation between two tickets (as link_issues made it).',
|
||||||
|
paramsSchema: z.object({
|
||||||
|
issue: issueRef,
|
||||||
|
type: LINK_TYPE,
|
||||||
|
target: issueRef,
|
||||||
|
}),
|
||||||
|
annotations: REMOVE,
|
||||||
|
})
|
||||||
|
unlink(
|
||||||
|
params: { issue: string; type: z.infer<typeof LINK_TYPE>; target: string },
|
||||||
|
ctx: McpContext,
|
||||||
|
) {
|
||||||
|
return toolResult(() =>
|
||||||
|
this.api.data(
|
||||||
|
this.api.as(ctx).DELETE('/api/issues/{issue}/links/{type}/{target}', {
|
||||||
|
params: { path: params },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'watch_issue',
|
||||||
|
description:
|
||||||
|
'Follow a ticket (notifications of its changes and comments) or stop following it.',
|
||||||
|
paramsSchema: z.object({
|
||||||
|
issue: issueRef,
|
||||||
|
follow: z.boolean().default(true),
|
||||||
|
}),
|
||||||
|
annotations: { ...WRITE, idempotentHint: true },
|
||||||
|
})
|
||||||
|
watch(
|
||||||
|
{ issue, follow }: { issue: string; follow: boolean },
|
||||||
|
ctx: McpContext,
|
||||||
|
) {
|
||||||
|
const client = this.api.as(ctx);
|
||||||
|
const options = { params: { path: { issue } } };
|
||||||
|
return toolResult(() =>
|
||||||
|
this.api.data(
|
||||||
|
follow
|
||||||
|
? client.PUT('/api/issues/{issue}/watch', options)
|
||||||
|
: client.DELETE('/api/issues/{issue}/watch', options),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'edit_comment',
|
||||||
|
description:
|
||||||
|
'Reword a comment (only its author). Comment ids come with get_issue.',
|
||||||
|
paramsSchema: z.object({
|
||||||
|
commentId: z.string().min(1),
|
||||||
|
body: z.string().min(1).max(20000),
|
||||||
|
}),
|
||||||
|
annotations: { ...WRITE, idempotentHint: true },
|
||||||
|
})
|
||||||
|
editComment(
|
||||||
|
{ commentId, body }: { commentId: string; body: string },
|
||||||
|
ctx: McpContext,
|
||||||
|
) {
|
||||||
|
return toolResult(() =>
|
||||||
|
this.api.data(
|
||||||
|
this.api.as(ctx).PATCH('/api/comments/{commentId}', {
|
||||||
|
params: { path: { commentId } },
|
||||||
|
body: { body },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'delete_comment',
|
||||||
|
description: 'Delete a comment (its author or a project admin).',
|
||||||
|
paramsSchema: z.object({ commentId: z.string().min(1) }),
|
||||||
|
annotations: REMOVE,
|
||||||
|
})
|
||||||
|
deleteComment({ commentId }: { commentId: string }, ctx: McpContext) {
|
||||||
|
return toolResult(() =>
|
||||||
|
this.api.data(
|
||||||
|
this.api.as(ctx).DELETE('/api/comments/{commentId}', {
|
||||||
|
params: { path: { commentId } },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Checklist ---- */
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'add_checklist_item',
|
||||||
|
description: "Add a step to a ticket's checklist.",
|
||||||
|
paramsSchema: z.object({
|
||||||
|
issue: issueRef,
|
||||||
|
text: z.string().min(1).max(300),
|
||||||
|
}),
|
||||||
|
annotations: WRITE,
|
||||||
|
})
|
||||||
|
addChecklistItem(
|
||||||
|
{ issue, text }: { issue: string; text: string },
|
||||||
|
ctx: McpContext,
|
||||||
|
) {
|
||||||
|
return toolResult(() =>
|
||||||
|
this.api.data(
|
||||||
|
this.api.as(ctx).POST('/api/issues/{issue}/checklist', {
|
||||||
|
params: { path: { issue } },
|
||||||
|
body: { text },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'update_checklist_item',
|
||||||
|
description:
|
||||||
|
'Tick, untick or reword a checklist step. Item ids come with get_issue.',
|
||||||
|
paramsSchema: z.object({
|
||||||
|
issue: issueRef,
|
||||||
|
item: z.string().min(1).describe('Checklist item id'),
|
||||||
|
done: z.boolean().optional(),
|
||||||
|
text: z.string().min(1).max(300).optional(),
|
||||||
|
}),
|
||||||
|
annotations: { ...WRITE, idempotentHint: true },
|
||||||
|
})
|
||||||
|
updateChecklistItem(
|
||||||
|
params: { issue: string; item: string; done?: boolean; text?: string },
|
||||||
|
ctx: McpContext,
|
||||||
|
) {
|
||||||
|
const { issue, item, ...body } = params;
|
||||||
|
return toolResult(() =>
|
||||||
|
this.api.data(
|
||||||
|
this.api.as(ctx).PATCH('/api/issues/{issue}/checklist/{item}', {
|
||||||
|
params: { path: { issue, item } },
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'remove_checklist_item',
|
||||||
|
description: "Remove a step from a ticket's checklist.",
|
||||||
|
paramsSchema: z.object({
|
||||||
|
issue: issueRef,
|
||||||
|
item: z.string().min(1).describe('Checklist item id'),
|
||||||
|
}),
|
||||||
|
annotations: REMOVE,
|
||||||
|
})
|
||||||
|
removeChecklistItem(
|
||||||
|
{ issue, item }: { issue: string; item: string },
|
||||||
|
ctx: McpContext,
|
||||||
|
) {
|
||||||
|
return toolResult(() =>
|
||||||
|
this.api.data(
|
||||||
|
this.api.as(ctx).DELETE('/api/issues/{issue}/checklist/{item}', {
|
||||||
|
params: { path: { issue, item } },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
70
apps/mcp/src/app/tools/notifications.resolver.ts
Normal file
70
apps/mcp/src/app/tools/notifications.resolver.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
@Resolver()
|
||||||
|
export class NotificationsResolver {
|
||||||
|
constructor(private readonly api: TaskStreamApi) {}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'my_notifications',
|
||||||
|
description:
|
||||||
|
"The user's notifications, newest first: assignments, status changes, failed QA, comments, mentions, followed tickets, reminders.",
|
||||||
|
paramsSchema: z.object({
|
||||||
|
unreadOnly: z.boolean().default(true),
|
||||||
|
limit: limit(100, 30),
|
||||||
|
}),
|
||||||
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
||||||
|
})
|
||||||
|
mine(
|
||||||
|
{ unreadOnly, limit }: { unreadOnly: boolean; limit: number },
|
||||||
|
ctx: McpContext,
|
||||||
|
) {
|
||||||
|
return toolResult(async () => {
|
||||||
|
const page = await this.api.data(
|
||||||
|
this.api.as(ctx).GET('/api/me/notifications', {
|
||||||
|
params: { query: { unreadOnly, limit } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
unread: page.unread,
|
||||||
|
items: page.items.map((n) => ({
|
||||||
|
id: n.id,
|
||||||
|
kind: n.kind,
|
||||||
|
project: n.projectKey,
|
||||||
|
issue: n.issueKey,
|
||||||
|
who: n.actor?.name ?? null,
|
||||||
|
summary: n.summary,
|
||||||
|
read: n.readAt !== null,
|
||||||
|
at: n.createdAt,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'mark_notifications_read',
|
||||||
|
description: 'Mark notifications as read: these ids, or all of them.',
|
||||||
|
paramsSchema: z.object({
|
||||||
|
ids: z
|
||||||
|
.array(z.string())
|
||||||
|
.optional()
|
||||||
|
.describe('Notification ids; absent: all'),
|
||||||
|
}),
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: false,
|
||||||
|
destructiveHint: false,
|
||||||
|
idempotentHint: true,
|
||||||
|
openWorldHint: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
markRead({ ids }: { ids?: string[] }, ctx: McpContext) {
|
||||||
|
return toolResult(() =>
|
||||||
|
this.api.data(
|
||||||
|
this.api.as(ctx).POST('/api/me/notifications/read', { body: { ids } }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
28
apps/mcp/src/app/tools/params.ts
Normal file
28
apps/mcp/src/app/tools/params.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
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 sprintId = z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.describe('Sprint id (see list_sprints)');
|
||||||
|
|
||||||
|
export const limit = (max: number, fallback: number) =>
|
||||||
|
z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(max)
|
||||||
|
.default(fallback)
|
||||||
|
.describe(`How many (1-${max})`);
|
||||||
225
apps/mcp/src/app/tools/projects.resolver.ts
Normal file
225
apps/mcp/src/app/tools/projects.resolver.ts
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
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: 'create_project',
|
||||||
|
description:
|
||||||
|
'Create a project; the user becomes its owner. The key prefixes its tickets (WEB → WEB-1) and cannot change later.',
|
||||||
|
paramsSchema: z.object({
|
||||||
|
key: z
|
||||||
|
.string()
|
||||||
|
.regex(/^[A-Za-z][A-Za-z0-9]{1,9}$/)
|
||||||
|
.describe('2-10 letters or digits, starting with a letter; unique'),
|
||||||
|
name: z.string().min(1).max(120),
|
||||||
|
description: z.string().max(5000).optional(),
|
||||||
|
isPublic: z
|
||||||
|
.boolean()
|
||||||
|
.default(false)
|
||||||
|
.describe('Anyone can read it, also without signing in'),
|
||||||
|
}),
|
||||||
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
||||||
|
})
|
||||||
|
create(
|
||||||
|
params: {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
isPublic: boolean;
|
||||||
|
},
|
||||||
|
ctx: McpContext,
|
||||||
|
) {
|
||||||
|
return toolResult(() =>
|
||||||
|
this.api.data(
|
||||||
|
this.api.as(ctx).POST('/api/projects', {
|
||||||
|
body: { ...params, key: params.key.toUpperCase() },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'get_project_showcase',
|
||||||
|
description:
|
||||||
|
'The public summary of a public project: epics on the roadmap with dates and progress, latest releases (notes of shipped ones), the sprint under way and open good-first-issue tickets. Paths point into the web app.',
|
||||||
|
paramsSchema: z.object({ project: projectRef }),
|
||||||
|
annotations: READ,
|
||||||
|
})
|
||||||
|
showcase({ project }: { project: string }, ctx: McpContext) {
|
||||||
|
return toolResult(() =>
|
||||||
|
this.api.data(
|
||||||
|
this.api.as(ctx).GET('/api/explore/projects/{project}/showcase', {
|
||||||
|
params: { path: { project } },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@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'),
|
||||||
|
sprintId: z.string().optional().describe('Only this sprint'),
|
||||||
|
perColumn: limit(200, 25),
|
||||||
|
}),
|
||||||
|
annotations: READ,
|
||||||
|
})
|
||||||
|
board(
|
||||||
|
params: {
|
||||||
|
project: string;
|
||||||
|
assigneeId?: string;
|
||||||
|
releaseId?: string;
|
||||||
|
area?: string;
|
||||||
|
sprintId?: 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,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
252
apps/mcp/src/app/tools/releases.resolver.ts
Normal file
252
apps/mcp/src/app/tools/releases.resolver.ts
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
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 notes, 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,
|
||||||
|
notes: release.notes,
|
||||||
|
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 },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'update_release',
|
||||||
|
description: 'Rename a release, describe it or move its target date.',
|
||||||
|
paramsSchema: z.object({
|
||||||
|
releaseId,
|
||||||
|
name: z.string().min(1).max(60).optional(),
|
||||||
|
description: z.string().max(5000).nullable().optional(),
|
||||||
|
targetDate: z.string().nullable().optional().describe('ISO date'),
|
||||||
|
}),
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: false,
|
||||||
|
destructiveHint: false,
|
||||||
|
idempotentHint: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
update(
|
||||||
|
params: {
|
||||||
|
releaseId: string;
|
||||||
|
name?: string;
|
||||||
|
description?: string | null;
|
||||||
|
targetDate?: string | null;
|
||||||
|
},
|
||||||
|
ctx: McpContext,
|
||||||
|
) {
|
||||||
|
const { releaseId, ...body } = params;
|
||||||
|
return toolResult(() =>
|
||||||
|
this.api.data(
|
||||||
|
this.api.as(ctx).PATCH('/api/releases/{releaseId}', {
|
||||||
|
params: { path: { releaseId } },
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'archive_release',
|
||||||
|
description:
|
||||||
|
'Archive a planned release that will not ship; its tickets leave it.',
|
||||||
|
paramsSchema: z.object({ releaseId }),
|
||||||
|
annotations: { readOnlyHint: false, destructiveHint: true },
|
||||||
|
})
|
||||||
|
archive({ releaseId }: { releaseId: string }, ctx: McpContext) {
|
||||||
|
return toolResult(() =>
|
||||||
|
this.api.data(
|
||||||
|
this.api.as(ctx).POST('/api/releases/{releaseId}/archive', {
|
||||||
|
params: { path: { releaseId } },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'draft_release_notes',
|
||||||
|
description:
|
||||||
|
"A Markdown draft of a release's notes from its finished tickets, grouped by type. Nothing is saved: polish it and save it with set_release_notes.",
|
||||||
|
paramsSchema: z.object({ releaseId }),
|
||||||
|
annotations: READ,
|
||||||
|
})
|
||||||
|
draftNotes({ releaseId }: { releaseId: string }, ctx: McpContext) {
|
||||||
|
return toolResult(
|
||||||
|
async () =>
|
||||||
|
(
|
||||||
|
await this.api.data(
|
||||||
|
this.api.as(ctx).GET('/api/releases/{releaseId}/notes/draft', {
|
||||||
|
params: { path: { releaseId } },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
).markdown,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'set_release_notes',
|
||||||
|
description:
|
||||||
|
'Write the notes of a release (Markdown, for people: what shipped). null clears them.',
|
||||||
|
paramsSchema: z.object({
|
||||||
|
releaseId,
|
||||||
|
notes: z.string().max(20000).nullable(),
|
||||||
|
}),
|
||||||
|
annotations: {
|
||||||
|
readOnlyHint: false,
|
||||||
|
destructiveHint: false,
|
||||||
|
idempotentHint: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
setNotes(
|
||||||
|
{ releaseId, notes }: { releaseId: string; notes: string | null },
|
||||||
|
ctx: McpContext,
|
||||||
|
) {
|
||||||
|
return toolResult(() =>
|
||||||
|
this.api.data(
|
||||||
|
this.api.as(ctx).PUT('/api/releases/{releaseId}/notes', {
|
||||||
|
params: { path: { releaseId } },
|
||||||
|
body: { notes },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
240
apps/mcp/src/app/tools/sprints.resolver.ts
Normal file
240
apps/mcp/src/app/tools/sprints.resolver.ts
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
import { McpContext, Resolver, Tool } from '@nestjs-mcp/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { TaskStreamApi } from '../api/taskstream-api';
|
||||||
|
import { issueRef, limit, projectRef, sprintId } from './params';
|
||||||
|
import { issueLine, sprintLine } 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 date = (what: string) => z.string().describe(`${what} (ISO date)`);
|
||||||
|
|
||||||
|
@Resolver()
|
||||||
|
export class SprintsResolver {
|
||||||
|
constructor(private readonly api: TaskStreamApi) {}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'list_sprints',
|
||||||
|
description:
|
||||||
|
"A project's sprints (planned, active and closed) with their dates and goal.",
|
||||||
|
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}/sprints', {
|
||||||
|
params: { path: { project } },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
).map(sprintLine),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'get_sprint',
|
||||||
|
description:
|
||||||
|
'A sprint with its progress (tickets and story points finished), its tickets in backlog order and, optionally, the day-by-day burndown.',
|
||||||
|
paramsSchema: z.object({
|
||||||
|
sprintId,
|
||||||
|
withBurndown: z.boolean().default(false),
|
||||||
|
limit: limit(200, 100),
|
||||||
|
}),
|
||||||
|
annotations: READ,
|
||||||
|
})
|
||||||
|
get(
|
||||||
|
params: { sprintId: string; withBurndown: boolean; limit: number },
|
||||||
|
ctx: McpContext,
|
||||||
|
) {
|
||||||
|
return toolResult(async () => {
|
||||||
|
const client = this.api.as(ctx);
|
||||||
|
const path = { sprint: params.sprintId };
|
||||||
|
const [sprint, progress, issues] = await Promise.all([
|
||||||
|
this.api.data(
|
||||||
|
client.GET('/api/sprints/{sprint}', { params: { path } }),
|
||||||
|
),
|
||||||
|
this.api.data(
|
||||||
|
client.GET('/api/sprints/{sprint}/progress', { params: { path } }),
|
||||||
|
),
|
||||||
|
this.api.data(
|
||||||
|
client.GET('/api/sprints/{sprint}/issues', {
|
||||||
|
params: { path, query: { limit: params.limit } },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
const burndown = params.withBurndown
|
||||||
|
? await this.api.data(
|
||||||
|
client.GET('/api/sprints/{sprint}/burndown', { params: { path } }),
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
return {
|
||||||
|
...sprintLine(sprint),
|
||||||
|
progress,
|
||||||
|
total: issues.total,
|
||||||
|
issues: issues.items.map(issueLine),
|
||||||
|
burndown,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'create_sprint',
|
||||||
|
description: 'Plan a sprint in a project (owners and admins).',
|
||||||
|
paramsSchema: z.object({
|
||||||
|
project: projectRef,
|
||||||
|
name: z.string().min(1).max(80).describe('e.g. Sprint 14'),
|
||||||
|
goal: z.string().max(500).optional(),
|
||||||
|
startDate: date('First day'),
|
||||||
|
endDate: date('Last day'),
|
||||||
|
}),
|
||||||
|
annotations: WRITE,
|
||||||
|
})
|
||||||
|
create(
|
||||||
|
params: {
|
||||||
|
project: string;
|
||||||
|
name: string;
|
||||||
|
goal?: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
},
|
||||||
|
ctx: McpContext,
|
||||||
|
) {
|
||||||
|
const { project, ...body } = params;
|
||||||
|
return toolResult(() =>
|
||||||
|
this.api.data(
|
||||||
|
this.api.as(ctx).POST('/api/projects/{project}/sprints', {
|
||||||
|
params: { path: { project } },
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'update_sprint',
|
||||||
|
description:
|
||||||
|
'Rename a sprint, change its goal or its dates (not once closed).',
|
||||||
|
paramsSchema: z.object({
|
||||||
|
sprintId,
|
||||||
|
name: z.string().min(1).max(80).optional(),
|
||||||
|
goal: z.string().max(500).nullable().optional(),
|
||||||
|
startDate: date('First day').optional(),
|
||||||
|
endDate: date('Last day').optional(),
|
||||||
|
}),
|
||||||
|
annotations: { ...WRITE, idempotentHint: true },
|
||||||
|
})
|
||||||
|
update(
|
||||||
|
params: {
|
||||||
|
sprintId: string;
|
||||||
|
name?: string;
|
||||||
|
goal?: string | null;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
},
|
||||||
|
ctx: McpContext,
|
||||||
|
) {
|
||||||
|
const { sprintId, ...body } = params;
|
||||||
|
return toolResult(() =>
|
||||||
|
this.api.data(
|
||||||
|
this.api.as(ctx).PATCH('/api/sprints/{sprint}', {
|
||||||
|
params: { path: { sprint: sprintId } },
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'start_sprint',
|
||||||
|
description:
|
||||||
|
'Start a planned sprint (only one can be under way per project).',
|
||||||
|
paramsSchema: z.object({ sprintId }),
|
||||||
|
annotations: WRITE,
|
||||||
|
})
|
||||||
|
start({ sprintId }: { sprintId: string }, ctx: McpContext) {
|
||||||
|
return toolResult(() =>
|
||||||
|
this.api.data(
|
||||||
|
this.api.as(ctx).POST('/api/sprints/{sprint}/start', {
|
||||||
|
params: { path: { sprint: sprintId } },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'close_sprint',
|
||||||
|
description:
|
||||||
|
'Close the sprint under way. Unfinished tickets move to carryOverTo (another open sprint of the project) or back to the backlog; finished ones stay as its record.',
|
||||||
|
paramsSchema: z.object({
|
||||||
|
sprintId,
|
||||||
|
carryOverTo: z
|
||||||
|
.string()
|
||||||
|
.nullable()
|
||||||
|
.optional()
|
||||||
|
.describe('Sprint id for the unfinished tickets; absent: the backlog'),
|
||||||
|
}),
|
||||||
|
annotations: WRITE,
|
||||||
|
})
|
||||||
|
close(
|
||||||
|
{
|
||||||
|
sprintId,
|
||||||
|
carryOverTo,
|
||||||
|
}: { sprintId: string; carryOverTo?: string | null },
|
||||||
|
ctx: McpContext,
|
||||||
|
) {
|
||||||
|
return toolResult(() =>
|
||||||
|
this.api.data(
|
||||||
|
this.api.as(ctx).POST('/api/sprints/{sprint}/close', {
|
||||||
|
params: { path: { sprint: sprintId } },
|
||||||
|
body: { carryOverTo },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'delete_sprint',
|
||||||
|
description: 'Delete a sprint that never started.',
|
||||||
|
paramsSchema: z.object({ sprintId }),
|
||||||
|
annotations: { readOnlyHint: false, destructiveHint: true },
|
||||||
|
})
|
||||||
|
delete({ sprintId }: { sprintId: string }, ctx: McpContext) {
|
||||||
|
return toolResult(() =>
|
||||||
|
this.api.data(
|
||||||
|
this.api.as(ctx).DELETE('/api/sprints/{sprint}', {
|
||||||
|
params: { path: { sprint: sprintId } },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'set_issue_sprint',
|
||||||
|
description:
|
||||||
|
'Put a ticket in an open sprint of its project, or back to the backlog (null). For many at once: bulk_update_issues.',
|
||||||
|
paramsSchema: z.object({
|
||||||
|
issue: issueRef,
|
||||||
|
sprintId: z.string().nullable().describe('Sprint id, or null'),
|
||||||
|
}),
|
||||||
|
annotations: { ...WRITE, idempotentHint: true },
|
||||||
|
})
|
||||||
|
setSprint(
|
||||||
|
{ issue, sprintId }: { issue: string; sprintId: string | null },
|
||||||
|
ctx: McpContext,
|
||||||
|
) {
|
||||||
|
return toolResult(() =>
|
||||||
|
this.api.data(
|
||||||
|
this.api.as(ctx).PUT('/api/issues/{issue}/sprint', {
|
||||||
|
params: { path: { issue } },
|
||||||
|
body: { sprintId },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
77
apps/mcp/src/app/tools/summaries.ts
Normal file
77
apps/mcp/src/app/tools/summaries.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
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'];
|
||||||
|
type Sprint = Schemas['SprintDto'];
|
||||||
|
|
||||||
|
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,
|
||||||
|
sprint: issue.sprint?.name ?? null,
|
||||||
|
parent: issue.parent?.key ?? null,
|
||||||
|
storyPoints: issue.storyPoints,
|
||||||
|
dueDate: issue.dueDate,
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const sprintLine = (sprint: Sprint) => ({
|
||||||
|
id: sprint.id,
|
||||||
|
name: sprint.name,
|
||||||
|
goal: sprint.goal,
|
||||||
|
status: sprint.status,
|
||||||
|
startDate: sprint.startDate,
|
||||||
|
endDate: sprint.endDate,
|
||||||
|
});
|
||||||
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,15 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://api:3000;
|
# Attachments: the API takes files up to ATTACHMENTS_MAX_MB (25 MB by default).
|
||||||
|
client_max_body_size 30m;
|
||||||
|
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,42 @@ 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',
|
||||||
|
'backlog',
|
||||||
|
'sprints',
|
||||||
|
'calendar',
|
||||||
|
'roadmap',
|
||||||
|
'epics',
|
||||||
|
'trash',
|
||||||
'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,8 +1,20 @@
|
|||||||
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';
|
||||||
|
import {
|
||||||
|
CommandPalette,
|
||||||
|
NotificationsBell,
|
||||||
|
} from '@task-stream/web/tracker/presenters';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'ts-root',
|
selector: 'ts-root',
|
||||||
@@ -13,6 +25,8 @@ import { AppShellComponent } from '@task-stream/web/shared/ui';
|
|||||||
RouterLinkActive,
|
RouterLinkActive,
|
||||||
AppShellComponent,
|
AppShellComponent,
|
||||||
UserMenuComponent,
|
UserMenuComponent,
|
||||||
|
NotificationsBell,
|
||||||
|
CommandPalette,
|
||||||
],
|
],
|
||||||
template: `
|
template: `
|
||||||
<ts-app-shell>
|
<ts-app-shell>
|
||||||
@@ -27,8 +41,25 @@ 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 />
|
<!-- One projectable node per @if branch, or neither reaches the slot. -->
|
||||||
|
<ng-container shellUser>
|
||||||
|
<ts-command-palette />
|
||||||
|
<ts-notifications-bell />
|
||||||
|
<ts-user-menu />
|
||||||
|
</ng-container>
|
||||||
|
} @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 +67,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,86 @@ 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: ''
|
||||||
|
ATTACHMENTS_DIR: /data/attachments
|
||||||
|
ATTACHMENTS_MAX_MB: ${ATTACHMENTS_MAX_MB:-25}
|
||||||
|
# E-mail copies of notifications (optional).
|
||||||
|
SMTP_URL: ${SMTP_URL:-}
|
||||||
|
MAIL_FROM: ${MAIL_FROM:-}
|
||||||
|
WEB_URL: ${WEB_URL:-}
|
||||||
|
# Outgoing webhooks to private addresses (off: they could reach internal services).
|
||||||
|
WEBHOOKS_ALLOW_PRIVATE: ${WEBHOOKS_ALLOW_PRIVATE:-false}
|
||||||
|
# Due-date reminders, checked every hour.
|
||||||
|
REMINDERS_ENABLED: ${REMINDERS_ENABLED:-true}
|
||||||
|
REMINDERS_TIMEZONE: ${REMINDERS_TIMEZONE:-Europe/Madrid}
|
||||||
|
volumes:
|
||||||
|
# Files attached to tickets: lose this volume and the tickets point at nothing.
|
||||||
|
- attachments-data:/data/attachments
|
||||||
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 +117,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 +128,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']
|
||||||
@@ -76,10 +139,11 @@ services:
|
|||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
attachments-data:
|
||||||
postgres-data:
|
postgres-data:
|
||||||
mongo-data:
|
mongo-data:
|
||||||
redis-data:
|
redis-data:
|
||||||
|
|
||||||
secrets:
|
secrets:
|
||||||
npmrc:
|
nexus_token:
|
||||||
file: ${HOME}/.npmrc
|
environment: NEXUS_TOKEN
|
||||||
|
|||||||
@@ -3,16 +3,43 @@
|
|||||||
## 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 |
|
||||||
|
| 🌍 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 |
|
||||||
|
| 🗑️ 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 |
|
||||||
|
| 🧩 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 |
|
||||||
| 🎨 Cambios de UI | Tipo `UI_CHANGE` con enlace al frame de Figma (validado contra `figma.com`) |
|
| 🎨 Cambios de UI | Tipo `UI_CHANGE` con enlace al frame de Figma (validado contra `figma.com`) |
|
||||||
| ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo |
|
| ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo |
|
||||||
| 🧪 QA | Estados `READY_FOR_QA → IN_QA → DONE / QA_FAILED`; fallar QA exige un motivo |
|
| 🧪 QA | Por defecto, `READY_FOR_QA → IN_QA → DONE / QA_FAILED`; fallar QA exige un motivo |
|
||||||
| 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) |
|
| 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) |
|
||||||
|
| 📝 Notas de versión | Borrador en Markdown con los tickets terminados por tipo, editable y copiable, también tras publicar |
|
||||||
| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona |
|
| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona |
|
||||||
| 💬 Comentarios | Decisiones y explicaciones dentro del ticket; el autor edita, el admin modera |
|
| 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con `@menciones`; el autor edita, el admin modera |
|
||||||
|
| 🔎 Vistas | El tablero se filtra por texto, persona (o sin asignar), tipo, prioridad, área y release; los filtros se guardan como vistas propias o compartidas con el proyecto |
|
||||||
|
| 🚦 WIP y carriles | Límite de trabajo en curso por estado (la columna se marca al pasarse) y tablero agrupable por persona, épica, prioridad o tipo |
|
||||||
|
| 🎲 Estimación | Puntos por ticket (escala 0-1-2-3-5-8-13-21) en la ficha, las tarjetas, las columnas y el backlog; velocidad por sprint y aviso si un sprint planificado supera la media |
|
||||||
|
| 🏃 Sprints | Iteraciones con fechas y objetivo, una en curso por proyecto, con burndown (tickets o puntos) y traspaso de lo pendiente al cerrar |
|
||||||
|
| 📅 Calendario | Los sprints mes a mes como franjas, con las releases y los vencimientos de cada día; se planifica un sprint pulsando su primer día y todo se descarga en .ics |
|
||||||
|
| 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden |
|
||||||
|
| 🗑️ Papelera | Borrar un ticket lo manda a la papelera (quien lo abrió o un admin); se recupera durante 30 días y luego se borra del todo |
|
||||||
|
| ☑️ En bloque | Selecciona varios tickets del backlog y cámbiales a la vez estado, persona, sprint, prioridad o etiqueta |
|
||||||
|
| ⌨️ Atajos y búsqueda | Ctrl/⌘ K o / busca tickets y proyectos de todos tus proyectos; `g b`, `g k`, `g s`, `g r` navegan, `c` crea, `e` edita, `?` los lista |
|
||||||
|
| 🗺️ Hoja de ruta | Las épicas en el tiempo (fecha de inicio y límite), con los sprints y las releases como referencias |
|
||||||
|
| 🌐 Web del colectivo | La página de cada proyecto en pireon.dev enseña su hoja de ruta, versiones, sprint y tickets para recién llegados (`good-first-issue`) |
|
||||||
|
| 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños |
|
||||||
|
| 👀 Seguir | Cualquier miembro sigue los tickets que le interesan; quien comenta, los sigue solo |
|
||||||
|
| 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios), en vivo y opcionalmente por correo |
|
||||||
|
| ⏰ Recordatorios | Aviso (y correo, si se quiere) el día antes, el mismo día y al vencer la fecha límite, a quien lo tiene y a quien lo sigue; las tarjetas marcan lo que vence |
|
||||||
|
| 📎 Adjuntos | Capturas, logs y vídeos en el ticket o bajo un comentario; arrastrar, elegir o pegar con Ctrl+V; miniaturas de las imágenes |
|
||||||
| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA) |
|
| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA) |
|
||||||
| 📊 Seguimiento | Panel con contadores, gráfico por estado, carga por persona, feed de actividad e historial por ticket |
|
| 🌿 Git | Webhook para GitHub, Gitea o GitLab: commits, ramas y pull requests que nombran un ticket (WEB-12) aparecen en su ficha |
|
||||||
|
| 🧾 Plantillas | Cada tipo de ticket puede traer una descripción inicial y una checklist que se aplican al crearlo |
|
||||||
|
| 🤖 Automatizaciones | Reglas «cuando… si… entonces…»: al crear, mover, asignar, comentar o fusionar una PR, mover, asignar, etiquetar, priorizar, comentar o meter en el sprint |
|
||||||
|
| 📡 Webhooks | Un POST firmado (HMAC-SHA256) a otro sistema cuando pasa algo: tickets, comentarios, releases, sprints; con prueba y último envío |
|
||||||
|
| 📦 Importar/exportar | Todos los tickets a CSV; importar un CSV propio o una exportación de Jira, con vista previa de lo que se va a crear |
|
||||||
|
| 🏠 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) |
|
| ⚡ 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**
|
||||||
@@ -39,12 +66,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,36 @@ 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, `DeleteReleaseCommand` por cada release y `DeleteSprintCommand` por cada sprint del proyecto |
|
||||||
|
| | `IssueDeletedEvent` | `PurgeCommentCommand` por cada comentario, `DropLinksToIssueCommand` en los tickets que lo enlazaban y `DetachFromParentCommand` en los que colgaban de él |
|
||||||
|
| `ReleaseArchivedSaga` | `ReleaseArchivedEvent` | `DetachIssueFromReleaseCommand` para sus tickets sin terminar (los terminados se quedan) |
|
||||||
|
| `SprintCarryOverSaga` | `SprintClosedEvent` / `SprintDeletedEvent` | `CarryOverIssueCommand`: lo pendiente pasa al sprint elegido o al backlog; al borrar, todos vuelven al backlog |
|
||||||
|
| `CommentersFollowSaga` | `CommentAddedEvent` | `WatchIssueCommand`: quien comenta pasa a seguir el ticket |
|
||||||
|
| `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
|
||||||
|
|||||||
349
docs/03-api.md
349
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
|
||||||
|
|
||||||
@@ -88,8 +142,114 @@ stateDiagram-v2
|
|||||||
- **Relaciones**: nunca consigo mismo ni duplicadas; pueden cruzar proyectos si el actor puede ver el destino. Se
|
- **Relaciones**: nunca consigo mismo ni duplicadas; pueden cruzar proyectos si el actor puede ver el destino. Se
|
||||||
muestran en los dos tickets (`OUTWARD` en el origen, `INWARD` en el destino).
|
muestran en los dos tickets (`OUTWARD` en el origen, `INWARD` en el destino).
|
||||||
- **Releases**: solo las `PLANNED` aceptan o pierden tickets; publicar con tickets sin terminar exige `force: true`.
|
- **Releases**: solo las `PLANNED` aceptan o pierden tickets; publicar con tickets sin terminar exige `force: true`.
|
||||||
|
Las notas de versión (`notes`, Markdown) se escriben también después de publicar (no una vez archivada);
|
||||||
|
`notesDraft` / `GET /api/releases/{id}/notes/draft` propone un borrador con los tickets hechos agrupados por tipo
|
||||||
|
(los de QA quedan fuera).
|
||||||
- **Comentarios**: solo el autor edita; borra el autor o un admin del proyecto.
|
- **Comentarios**: solo el autor edita; borra el autor o un admin del proyecto.
|
||||||
- **Cambios de UI**: `figmaUrl` debe ser un enlace `https://…figma.com/…`.
|
- **Cambios de UI**: `figmaUrl` debe ser un enlace `https://…figma.com/…`.
|
||||||
|
- **Adjuntos**: los suben los miembros (no los lectores), hasta `ATTACHMENTS_MAX_MB` (25 MB) por archivo y 50 por
|
||||||
|
ticket; pueden ir en el ticket o bajo uno de sus comentarios (`commentId`). Quita uno quien lo subió o un admin.
|
||||||
|
Los bytes se guardan primero y el evento después, así que nunca queda un adjunto apuntando a un archivo que no
|
||||||
|
existe; si el evento no se guarda, el archivo se borra. Al borrar un ticket se borran sus archivos.
|
||||||
|
- **Épicas y subtareas**: un ticket puede colgar de otro del mismo proyecto (`parent`), hasta tres niveles
|
||||||
|
(épica → ticket → subtarea) y sin bucles; se comprueba sobre el event store, no sobre el read model. Una «épica» es
|
||||||
|
cualquier ticket de primer nivel del que cuelgan otros (`epics(projectId)` da su progreso: terminados = hechos o
|
||||||
|
cancelados, y puntos). Si se borra el padre, sus hijos vuelven al primer nivel.
|
||||||
|
- **Orden del backlog**: cada ticket tiene un `rank` (texto que se compara carácter a carácter). Mover uno
|
||||||
|
(`rankIssue`, `PUT /api/issues/{issue}/rank`) solo reescribe ese ticket: recibe un rango entre el de quien queda
|
||||||
|
encima y el de quien queda debajo, leídos del event store; si entretanto el orden cambió, la API lo rechaza y la web
|
||||||
|
recarga. Un ticket que nunca se movió ocupa su orden de creación (`IssueRankedEvent` no existe para él). Las
|
||||||
|
columnas del tablero y `issues(filter: { sort: RANK })` siguen este orden.
|
||||||
|
- **Integración con Git**: un owner o admin añade en la configuración del proyecto sus repositorios (`api`, `web`,
|
||||||
|
`admin`…), cada uno con su propio secreto. Todos llaman a la misma URL, `POST /api/webhooks/git/{proyecto}`, con los
|
||||||
|
eventos _push_ y _pull/merge request_; GitHub y Gitea firman el cuerpo con el secreto (HMAC-SHA256) y GitLab lo envía
|
||||||
|
como `X-Gitlab-Token`, y el secreto que valida la firma dice de qué repositorio viene. Cada commit, rama o PR que
|
||||||
|
nombre una clave del proyecto (`WEB-12`, también en minúsculas) queda en `devLinks` del ticket con el nombre de su
|
||||||
|
repositorio (`IssueDevLinkRecordedEvent`, actor `system`); el mismo enlace solo vuelve a contar si cambia (una PR
|
||||||
|
fusionada). Proyecto desconocido, sin repositorios o firma mala responden igual: 403. Cambiar el secreto de un
|
||||||
|
repositorio o quitarlo no afecta a los demás, y lo ya enlazado se queda. Los repositorios viven en MongoDB
|
||||||
|
(`git_integrations`, un documento por proyecto), no en el event store; un proyecto que activó el webhook único de
|
||||||
|
antes conserva su secreto como repositorio «principal».
|
||||||
|
- **Escaparate público**: `GET /api/explore/projects/{project}/showcase` resume un proyecto público para webs de
|
||||||
|
fuera (la del colectivo lo enseña en la página de cada proyecto): épicas con fechas y progreso, las últimas
|
||||||
|
versiones (con sus notas si ya salieron), el sprint en curso y los tickets abiertos con la etiqueta
|
||||||
|
`good-first-issue`. Sin token, con `Cache-Control: public, max-age=300`; un proyecto privado responde 404.
|
||||||
|
- **Papelera**: `trashIssue` / `DELETE /api/issues/{issue}` manda un ticket a la papelera (quien lo abrió o un owner/
|
||||||
|
admin; `IssueTrashedEvent`): desaparece del tablero, las búsquedas, las estadísticas y el progreso, y no se puede
|
||||||
|
cambiar hasta recuperarlo (`restoreIssue` / `POST …/restore`, `IssueRestoredEvent`). `trash(projectId)` /
|
||||||
|
`GET /api/projects/{project}/trash` los lista. A los `TRASH_DAYS` días (30) se borra del todo, con la cascada de
|
||||||
|
siempre: comentarios, enlaces y subtareas de vuelta al primer nivel.
|
||||||
|
- **Edición en bloque**: `bulkUpdateIssues` / `POST /api/issues/bulk` aplica el mismo cambio (estado, persona o
|
||||||
|
`unassign`, sprint o `noSprint`, release o `noRelease`, prioridad, etiquetas que añadir) a hasta 100 tickets. Cada
|
||||||
|
uno pasa por los comandos normales (permisos, flujo, sprints y releases abiertos) y puede negarse sin parar al resto;
|
||||||
|
la respuesta dice cuáles.
|
||||||
|
- **Plantillas por tipo**: en la configuración, cada tipo de ticket puede llevar `template` con una descripción
|
||||||
|
(hasta 5000 caracteres) y una checklist (hasta 20 pasos). Al crear un ticket de ese tipo, la descripción se usa si
|
||||||
|
llega vacía y los pasos se añaden siempre (las importaciones no aplican plantillas).
|
||||||
|
- **Automatizaciones**: owners y admins crean reglas (hasta 50 por proyecto, por GraphQL: `automations`,
|
||||||
|
`createAutomation`, `updateAutomation`, `deleteAutomation`). Disparador: ticket creado, cambio de estado (opcionalmente
|
||||||
|
a uno concreto), asignación, comentario o PR fusionada (integración Git). Condiciones: tipo, prioridad, área o
|
||||||
|
etiqueta. Acción: mover a un estado, asignar (a alguien o a quien lo abrió), añadir una etiqueta, poner una
|
||||||
|
prioridad, comentar o meter en el sprint en curso. Actúan como `system` («TaskStream») y respetando el flujo: si la
|
||||||
|
transición no está permitida, la regla no hace nada y lo deja en `lastError`. Lo que hace una regla no dispara
|
||||||
|
otras (no hay cadenas ni bucles) y cada regla corre una sola vez por evento aunque se reentregue
|
||||||
|
(`automation_runs`). Viven en MongoDB (`automations`), fuera del event store.
|
||||||
|
- **Webhooks salientes**: owners y admins dan de alta URLs (hasta 20 por proyecto) suscritas a eventos
|
||||||
|
(`issue.created`, `issue.status_changed`, `comment.added`, `release.shipped`, `sprint.closed`… — la lista en
|
||||||
|
`webhookEvents` / `GET /api/webhook-events`), a familias (`issue.*`) o a todo (`*`). Cada envío es un `POST` JSON con
|
||||||
|
`event`, `deliveryId`, `occurredAt`, `actorId`, `project`, `issue` (si va de un ticket) y `data` (los campos del evento),
|
||||||
|
y las cabeceras `X-TaskStream-Event`, `X-TaskStream-Delivery` y `X-TaskStream-Signature: sha256=<HMAC del cuerpo con el
|
||||||
|
secreto>`. El secreto solo se muestra al crearlo. Se reintenta a los 2 y 10 s (no ante un 4xx), con 5 s de espera
|
||||||
|
máxima; se guarda el último envío. Salen en segundo plano y no son duraderos: si la API se reinicia a mitad, ese
|
||||||
|
envío se pierde. No llaman a direcciones privadas salvo con `WEBHOOKS_ALLOW_PRIVATE=true`.
|
||||||
|
- **Importar y exportar**: `GET /api/projects/{project}/export.csv` da todos los tickets (UTF-8 con BOM; las celdas
|
||||||
|
que una hoja de cálculo ejecutaría como fórmula empiezan por `'`). `POST /api/projects/{project}/import` (owners y
|
||||||
|
admins, multipart `file`, hasta 5 MB y 2000 filas) lee ese mismo CSV o una exportación CSV de Jira: tipos, estados y
|
||||||
|
personas se buscan por id o por nombre (los de Jira por su significado: _Story_ → Feature, _Highest_ → crítica,
|
||||||
|
categoría _In Progress_ → primer estado en curso…), lo que no encaja se queda con un valor por defecto y un aviso, y
|
||||||
|
los padres se reconstruyen al final. Con `dryRun=true` solo se cuenta lo que se crearía. Cada ticket importado es una
|
||||||
|
creación normal (con sus eventos), en el estado que traía si está activo.
|
||||||
|
- **Límite WIP**: cada estado puede tener `wipLimit` (1-999) en la configuración del proyecto. Es un aviso, no un
|
||||||
|
bloqueo: la API acepta pasarse y el tablero marca la columna.
|
||||||
|
- **Sprints**: los planifica y gestiona quien administra el proyecto (owner o admin); mete tickets cualquier miembro.
|
||||||
|
Un sprint va de `PLANNED` a `ACTIVE` (uno solo en curso por proyecto) y a `CLOSED`; dura como mucho 90 días y un
|
||||||
|
sprint en curso conserva su fecha de inicio. Un sprint cerrado ya no acepta ni pierde tickets. Al cerrarlo se elige
|
||||||
|
adónde van los tickets sin terminar: a otro sprint abierto del proyecto o al backlog; los terminados se quedan como
|
||||||
|
registro. Solo se borran sprints que no han empezado (sus tickets vuelven al backlog).
|
||||||
|
- **Vistas guardadas**: filtros del tablero con nombre. Las guardan los miembros del proyecto (los lectores, solo para
|
||||||
|
sí); compartir una la hace visible a todo el proyecto. Borra una su autor, o un admin si es compartida; como mucho
|
||||||
|
50 por persona y proyecto. Son preferencias, no historia: viven en MongoDB (`saved_filters`), fuera del event store,
|
||||||
|
y se borran con el proyecto.
|
||||||
|
- **Checklist**: hasta 100 pasos de hasta 300 caracteres por ticket; los cambia quien puede editar el ticket.
|
||||||
|
|
||||||
|
### Notificaciones
|
||||||
|
|
||||||
|
Se derivan de los eventos (`NotificationsEventHandler`) y se guardan **por persona** en MongoDB: no son estado de
|
||||||
|
ningún agregado, solo cada uno lee y descarta las suyas.
|
||||||
|
|
||||||
|
| Cuándo | A quién | Tipo |
|
||||||
|
| ---------------------------------------- | ---------------------------------------- | ------------------ |
|
||||||
|
| Se crea un ticket asignado o se asigna | La persona asignada | `ASSIGNED` |
|
||||||
|
| Un ticket cambia de estado | Quien lo tiene asignado y quien lo abrió | `STATUS_CHANGED` |
|
||||||
|
| Un ticket vuelve de QA sin pasar a hecho | Los mismos, con el motivo | `QA_FAILED` |
|
||||||
|
| Alguien comenta | Quien lo tiene asignado y quien lo abrió | `COMMENTED` |
|
||||||
|
| Te añaden a un proyecto | La persona añadida | `ADDED_TO_PROJECT` |
|
||||||
|
|
||||||
|
**Recordatorios** (`REMINDER`): cada hora la API mira los tickets sin terminar con fecha límite y avisa a quien lo
|
||||||
|
tiene asignado y a quien lo sigue el día antes, el mismo día y una vez cuando ya ha vencido (el día cambia en
|
||||||
|
`REMINDERS_TIMEZONE`). Cada aviso tiene id `reminder:<ticket>:<fecha>:<fase>:<persona>`, así que no se repite aunque
|
||||||
|
varias instancias lo comprueben a la vez.
|
||||||
|
|
||||||
|
Nadie recibe aviso de lo que hizo él mismo, y cada persona recibe uno por evento: la primera razón de la tabla gana
|
||||||
|
(una mención antes que «comentó en tu ticket», y eso antes que «un ticket que sigues»).
|
||||||
|
|
||||||
|
**Menciones**: se escriben `@[Nombre](id-de-usuario)` (la web las inserta al escribir `@`); el id dice a quién avisar y
|
||||||
|
el nombre es lo que se ve, así que renombrar a alguien no rompe comentarios antiguos. Solo avisan a miembros del
|
||||||
|
proyecto. **Seguir**: cualquier miembro, también los lectores, puede seguir un ticket (`watchIssue`/`unwatchIssue`,
|
||||||
|
eventos `IssueWatchedEvent`/`IssueUnwatchedEvent`), y quien comenta pasa a seguirlo (`CommentersFollowSaga`). El id es
|
||||||
|
`<agregado>:<versión>:<persona>`, así que reentregar un evento no duplica avisos. Llegan en vivo por la subscription
|
||||||
|
`notificationAdded` y, si la instalación tiene `SMTP_URL`, también por correo a quien lo active (`email`, desactivado
|
||||||
|
por defecto); un correo que falla se registra y no bloquea el stream.
|
||||||
|
|
||||||
### Eventos de dominio
|
### Eventos de dominio
|
||||||
|
|
||||||
@@ -97,28 +257,62 @@ Todo cambio es un evento guardado en el event store (stream = id del agregado).
|
|||||||
`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`, `IssueAttachmentAddedEvent`, `IssueAttachmentRemovedEvent`, `IssueWatchedEvent`, `IssueUnwatchedEvent`, `IssueParentChangedEvent`, `IssueRankedEvent`, `IssueSprintChangedEvent`, `IssueDevLinkRecordedEvent`, `IssueChecklistItemAddedEvent`, `IssueChecklistItemUpdatedEvent`, `IssueChecklistItemRemovedEvent`, `IssueDeletedEvent` |
|
||||||
| Comment | `CommentAddedEvent`, `CommentEditedEvent`, `CommentDeletedEvent` |
|
| Comment | `CommentAddedEvent`, `CommentEditedEvent`, `CommentDeletedEvent` |
|
||||||
| Release | `ReleaseCreatedEvent`, `ReleaseUpdatedEvent`, `ReleaseShippedEvent`, `ReleaseArchivedEvent` |
|
| Sprint | `SprintCreatedEvent`, `SprintUpdatedEvent`, `SprintStartedEvent`, `SprintClosedEvent`, `SprintDeletedEvent` |
|
||||||
|
| Release | `ReleaseCreatedEvent`, `ReleaseUpdatedEvent`, `ReleaseShippedEvent`, `ReleaseNotesWrittenEvent`, `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 |
|
||||||
|
| Burndown de un sprint | Por día del sprint: tickets en él (sin cancelados), cuántos siguen abiertos y sus puntos; línea ideal hasta cero |
|
||||||
|
| 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, search, 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 |
|
||||||
|
| `publicProjects` | Proyectos públicos (sin token) |
|
||||||
|
| `project(idOrKey)` | Un proyecto (mío, o público) con su `settings`. Campos calculados: `board(releaseId, assigneeId, unassigned, area, types, priorities, labels, search, perColumn)`, `stats`, `analytics(days)`, `releases` |
|
||||||
|
| `issues(filter, limit, offset)` | Búsqueda de tickets (estado, tipo, área, prioridad, persona, release, etiquetas, ticket padre, fecha límite entre `dueFrom` y `dueTo`, texto); `sort: RANK` para el orden del backlog |
|
||||||
|
| `webhooks(projectId)`, `webhookEvents` | Webhooks salientes del proyecto (owners y admins) y los eventos a los que se pueden suscribir |
|
||||||
|
| `gitIntegration(projectId)` | Si el webhook de Git está activo, su ruta y (para owners y admins) el secreto |
|
||||||
|
| `sprints(projectId)`, `sprint(id)` | Sprints del proyecto (en curso, planificados, cerrados). Campos calculados: `progress`, `burndown`, `issues` |
|
||||||
|
| `savedFilters(projectId)` | Mis vistas guardadas del tablero y las compartidas con el proyecto |
|
||||||
|
| `epics(projectId)` | Épicas del proyecto (tickets de primer nivel de los que cuelgan otros) con el progreso de sus hijos |
|
||||||
| `issue(idOrKey)` | Un ticket. Campos calculados: `allowedTransitions`, `linkedIssues`, `comments`, `history` |
|
| `issue(idOrKey)` | Un ticket. Campos calculados: `allowedTransitions`, `linkedIssues`, `comments`, `history` |
|
||||||
| `release(id)` | Una release. Campos calculados: `progress`, `issues` |
|
| `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 |
|
| `activity(projectId, issueId?, limit, before?)` | Feed de actividad, del más reciente al más antiguo |
|
||||||
|
|
||||||
### Mutations
|
### Mutations
|
||||||
@@ -126,11 +320,16 @@ Todas las operaciones requieren `Authorization: Bearer <token de Pireon>` (en su
|
|||||||
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`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `removeAttachment` (subir: por REST) |
|
||||||
| Comentarios | `addComment`, `editComment`, `deleteComment` |
|
| Comentarios | `addComment`, `editComment`, `deleteComment` |
|
||||||
| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `archiveRelease` |
|
| Webhooks | `createWebhook` (devuelve el secreto), `updateWebhook`, `deleteWebhook`, `testWebhook` |
|
||||||
|
| Git | `addGitRepository` (devuelve su secreto), `rotateGitRepositorySecret`, `removeGitRepository` |
|
||||||
|
| Sprints | `createSprint`, `updateSprint`, `startSprint`, `closeSprint(carryOverToSprintId)`, `deleteSprint`, `setIssueSprint` |
|
||||||
|
| Vistas | `saveFilter(input)` (devuelve el id), `deleteSavedFilter(id)` |
|
||||||
|
| Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` |
|
||||||
|
| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `writeReleaseNotes`, `archiveRelease` |
|
||||||
|
|
||||||
### Subscriptions
|
### Subscriptions
|
||||||
|
|
||||||
@@ -140,14 +339,24 @@ 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 |
|
| `sprintChanged(projectId)` | Se planifica, cambia, empieza, cierra o borra un sprint (`sprint` es `null` al borrar) |
|
||||||
|
| `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 +366,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 +374,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 +419,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 +495,76 @@ 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`, `…/epics`, `…/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`, `PUT/DELETE …/watch`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history` |
|
||||||
|
| Comentarios | `PATCH/DELETE /api/comments/{commentId}` |
|
||||||
|
| Sprints | `GET/POST /api/projects/{project}/sprints`, `GET/PATCH/DELETE /api/sprints/{sprint}`, `POST …/start`, `POST …/close`, `GET …/progress`, `…/burndown`, `…/issues`, `PUT /api/issues/{issue}/sprint` |
|
||||||
|
| Vistas | `GET/POST /api/projects/{project}/filters`, `DELETE /api/filters/{id}` |
|
||||||
|
| Avisos | `GET /api/me/notifications`, `POST /api/me/notifications/read`, `GET/PUT /api/me/notification-settings` |
|
||||||
|
| Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` |
|
||||||
|
| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `PUT …/notes`, `GET …/notes/draft`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` |
|
||||||
|
| Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) |
|
||||||
|
| Importar | `GET /api/projects/{project}/export.csv`, `POST /api/projects/{project}/import?dryRun=` (multipart `file`) |
|
||||||
|
| Webhooks | `GET /api/webhook-events`, `GET/POST /api/projects/{project}/webhooks`, `PATCH/DELETE /api/outgoing-webhooks/{id}`, `POST …/test` |
|
||||||
|
| Git | `GET /api/projects/{project}/git-integration`, `POST …/git-integration/repositories`, `POST …/repositories/{id}/secret`, `DELETE …/repositories/{id}`, `POST /api/webhooks/git/{project}` (lo llama el host de Git) |
|
||||||
|
| Explorar | `GET /api/explore/projects/{project}/showcase` (sin token, proyectos públicos): hoja de ruta, versiones, sprint en curso y tickets `good-first-issue` |
|
||||||
|
| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` |
|
||||||
|
|
||||||
|
Los archivos van por REST porque GraphQL no está hecho para bytes; la lista de adjuntos de un ticket llega con el
|
||||||
|
ticket (`attachments`, en GraphQL y en REST). La descarga sirve en línea solo imágenes, vídeo, audio, PDF y texto
|
||||||
|
plano, y todo con `X-Content-Type-Options: nosniff` y una CSP `sandbox`: un HTML o un SVG subido se descarga,
|
||||||
|
nunca se ejecuta en el origen de la API.
|
||||||
|
|
||||||
|
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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,10 +110,12 @@ Qué gana la página:
|
|||||||
- **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 |
|
||||||
|
| `ExploreFacade` | `ExploreStore` | raíz | Proyectos públicos («Explorar»), también sin sesión |
|
||||||
|
| `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 |
|
| `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 |
|
| `IssueFacade` | `IssueStore` | ruta `issues/:issueKey` | Ticket, comentarios en vivo, historial y todas sus acciones |
|
||||||
|
|
||||||
@@ -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/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros) |
|
||||||
| `/p/:key/issues/:issueKey` | Ticket |
|
| `/p/:key/issues/:issueKey` | Ticket |
|
||||||
| `/p/:key/releases` | Releases |
|
| `/p/:key/releases` | Releases |
|
||||||
| `/p/:key/dashboard` | Seguimiento |
|
| `/p/:key/dashboard` | Seguimiento: cifras, flujo por estado, equipo (cada persona enlaza a su tablero) y tipos |
|
||||||
|
| `/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/activity` | Actividad en vivo |
|
||||||
| `/p/:key/members` | Miembros |
|
| `/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
|
||||||
|
|
||||||
|
|||||||
@@ -26,12 +26,15 @@ 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:contracts` | Escribe `schema.gql` y `openapi.json` sin arrancar la API ni bases de datos |
|
||||||
|
| `npx nx serve mcp` | Servidor MCP en `http://localhost:3010/mcp`, contra la API local ([MCP](09-mcp.md)) |
|
||||||
|
| `npx nx run mcp:openapi-types` | Regenera los tipos REST del MCP desde `apps/api/openapi.json` |
|
||||||
| `npx nx run api:prune` | Build de la API con `package.json` y lockfile podados (lo usa Docker) |
|
| `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 graph` | Grafo de dependencias entre proyectos |
|
||||||
| `npx nx test api-tracker-domain` | Tests de una librería concreta (`api-<contexto>-<capa>`, `web-<contexto>-<capa>`) |
|
| `npx nx test api-tracker-domain` | Tests de una librería concreta (`api-<contexto>-<capa>`, `web-<contexto>-<capa>`) |
|
||||||
@@ -68,7 +71,8 @@ Ejemplo: **marcar un ticket como bloqueado con un motivo**. El recorrido es siem
|
|||||||
por `ReadModelNotifier`. Añadir el evento al `ActivityEventHandler` y una frase en `activity-summary.ts`.
|
por `ReadModelNotifier`. Añadir el evento al `ActivityEventHandler` y una frase en `activity-summary.ts`.
|
||||||
- Si el read model cambia, añadir el campo a `IssueReadModel` (dominio) y al esquema Mongo (infraestructura).
|
- Si el read model cambia, añadir el campo a `IssueReadModel` (dominio) y al esquema Mongo (infraestructura).
|
||||||
3. **Presenters** (`libs/api/tracker/presenters`): input y mutation `blockIssue` en `IssueResolver`, que llama al service.
|
3. **Presenters** (`libs/api/tracker/presenters`): input y mutation `blockIssue` en `IssueResolver`, que llama al service.
|
||||||
4. Arrancar la API para regenerar `apps/api/schema.gql`.
|
4. Regenerar `apps/api/schema.gql` y `apps/api/openapi.json`: arrancando la API, o sin bases de datos con
|
||||||
|
`npx nx run api:contracts`.
|
||||||
|
|
||||||
### Web
|
### Web
|
||||||
|
|
||||||
@@ -86,8 +90,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,32 +9,69 @@ 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 |
|
||||||
| ------------------------------------------- | -------------------------- | ------------------------------------------------------------------------------------- |
|
| ------------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `PORT` | `3000` | Puerto HTTP |
|
| `PORT` | `3000` | Puerto HTTP |
|
||||||
| `NODE_ENV` | `development` | En `production` no se devuelven stacktraces |
|
| `NODE_ENV` | `development` | En `production` no se devuelven stacktraces |
|
||||||
| `POSTGRES_URL` | — (obligatoria) | Event store y lookups |
|
| `POSTGRES_URL` | — (obligatoria) | Event store y lookups |
|
||||||
@@ -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,13 +91,25 @@ 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) |
|
||||||
|
| `ATTACHMENTS_DIR` | `data/attachments` | Carpeta de los adjuntos de los tickets; en Docker, el volumen `attachments-data` |
|
||||||
|
| `SMTP_URL` | — | Servidor de correo para las copias de las notificaciones (`smtps://user:pass@host`); sin él no se envía nada |
|
||||||
|
| `MAIL_FROM` | `TaskStream <no-reply@pireon.dev>` | Remitente de esos correos |
|
||||||
|
| `WEB_URL` | — | Dirección pública de la web, para los enlaces de los correos |
|
||||||
|
| `ATTACHMENTS_MAX_MB` | `25` | Tamaño máximo de un adjunto (nginx admite hasta 30 MB por petición) |
|
||||||
|
| `WEBHOOKS_ALLOW_PRIVATE` | `false` | Deja que los webhooks salientes llamen a direcciones de esta máquina o de su red privada (apagado, un webhook podría alcanzar servicios internos) |
|
||||||
|
| `REMINDERS_ENABLED` | `true` | Avisa cada hora de los tickets que vencen mañana, hoy o ya han vencido |
|
||||||
|
| `REMINDERS_TIMEZONE` | `Europe/Madrid` | Zona horaria en la que cambia el día para los recordatorios |
|
||||||
|
| `TRASH_DAYS` | `30` | Días que un ticket pasa en la papelera antes de borrarse del todo |
|
||||||
|
|
||||||
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`).
|
||||||
|
|
||||||
## 7.3 Escalado
|
## 7.3 Escalado
|
||||||
|
|
||||||
- **API**: es _stateless_ (sesiones en Redis, subscriptions por Redis pub/sub), así que admite varias réplicas detrás
|
- **API**: es _stateless_ (sesiones en Redis, subscriptions por Redis pub/sub), así que admite varias réplicas detrás
|
||||||
del balanceador. El WebSocket necesita que el balanceador permita `Upgrade`.
|
del balanceador. El WebSocket necesita que el balanceador permita `Upgrade`. La excepción son los **adjuntos**: se
|
||||||
|
guardan en disco (`ATTACHMENTS_DIR`), así que varias réplicas tienen que compartir ese volumen.
|
||||||
- **Relay**: cada instancia publica eventos al stream; `FOR UPDATE SKIP LOCKED` evita duplicados.
|
- **Relay**: cada instancia publica eventos al stream; `FOR UPDATE SKIP LOCKED` evita duplicados.
|
||||||
- **Proyecciones**: el `EventsBridge` procesa los eventos en orden. Lo más sencillo es dejarlo activo en **una sola**
|
- **Proyecciones**: el `EventsBridge` procesa los eventos en orden. Lo más sencillo es dejarlo activo en **una sola**
|
||||||
instancia (`EVENTS_BRIDGE_ENABLED=true`) y desactivarlo en las demás. Varias instancias en el mismo consumer group se
|
instancia (`EVENTS_BRIDGE_ENABLED=true`) y desactivarlo en las demás. Varias instancias en el mismo consumer group se
|
||||||
@@ -71,7 +121,8 @@ La web no tiene configuración de entorno: llama a la API en su mismo origen (`/
|
|||||||
- Registrar el cliente OAuth y las redirect URIs de producción en Pireon.
|
- Registrar el cliente OAuth y las redirect URIs de producción en Pireon.
|
||||||
- Servir por HTTPS (la web usa `wss://` automáticamente cuando la página va por HTTPS).
|
- Servir por HTTPS (la web usa `wss://` automáticamente cuando la página va por HTTPS).
|
||||||
- Revisar `GRAPHQL_PLAYGROUND` (introspección) según el entorno.
|
- Revisar `GRAPHQL_PLAYGROUND` (introspección) según el entorno.
|
||||||
- Copias de seguridad de PostgreSQL: es la fuente de verdad.
|
- Copias de seguridad de PostgreSQL: es la fuente de verdad. Y del volumen de adjuntos (`attachments-data`): los
|
||||||
|
eventos dicen qué archivos hay, pero los bytes solo están ahí.
|
||||||
|
|
||||||
## 7.5 Reconstruir el read model
|
## 7.5 Reconstruir el read model
|
||||||
|
|
||||||
@@ -82,3 +133,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.
|
||||||
|
|||||||
@@ -71,3 +71,16 @@ presentación se prueba sin plantillas. **Coste**: una clase más por store, cas
|
|||||||
|
|
||||||
El `package.json` raíz incluye Angular y `@pi/ui` (del Nexus privado). La imagen de la API usa
|
El `package.json` raíz incluye Angular y `@pi/ui` (del Nexus privado). La imagen de la API usa
|
||||||
`apps/api/package.json`, así que ni lleva el frontend ni necesita el token del Nexus en ejecución.
|
`apps/api/package.json`, así que ni lleva el frontend ni necesita el token del Nexus en ejecución.
|
||||||
|
|
||||||
|
### Adjuntos en disco, notificaciones fuera del event store
|
||||||
|
|
||||||
|
Los **adjuntos** se registran como eventos del ticket, pero los bytes van a un puerto `AttachmentStorage` (disco,
|
||||||
|
`ATTACHMENTS_DIR`): un event store no es sitio para megabytes. Se escribe el archivo antes que el evento, y si el
|
||||||
|
evento falla se borra el archivo, así que nunca hay un adjunto apuntando a nada. **Coste**: el volumen hay que
|
||||||
|
copiarlo y compartirlo entre réplicas.
|
||||||
|
|
||||||
|
Las **notificaciones** sí salen de eventos, pero no son eventos: si un aviso está leído, o si alguien quiere copia por
|
||||||
|
correo, solo le importa a esa persona y no necesita historia. Se guardan en MongoDB por destinatario y se marcan como
|
||||||
|
leídas sin pasar por un agregado. Si se reconstruye el read model, se pueden regenerar desde los eventos (quedarían
|
||||||
|
como no leídas). **Alternativa descartada**: un agregado «bandeja» por persona, con eventos `NotificationRead`; mucha
|
||||||
|
ceremonia para un estado que nadie audita.
|
||||||
|
|||||||
159
docs/09-mcp.md
Normal file
159
docs/09-mcp.md
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
54 herramientas, agrupadas por ámbito:
|
||||||
|
|
||||||
|
| Ámbito | Herramienta | Qué hace |
|
||||||
|
| ------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- |
|
||||||
|
| Usuario | `whoami` | El usuario del token |
|
||||||
|
| | `search_users` | Buscar personas de Pireon (para asignar) |
|
||||||
|
| | `my_notifications` | Mis notificaciones (por defecto, las no leídas) |
|
||||||
|
| | `mark_notifications_read` | Marcar como leídas unas o todas |
|
||||||
|
| Proyectos | `list_my_projects` | Mis proyectos |
|
||||||
|
| | `list_public_projects` | Proyectos públicos |
|
||||||
|
| | `create_project` | Crear un proyecto (quien lo crea es su dueño) |
|
||||||
|
| | `get_project` | Equipo y configuración: ids de estados, tipos y áreas que aceptan las demás herramientas |
|
||||||
|
| | `get_project_showcase` | El escaparate público: hoja de ruta, releases, sprint en curso y tickets para empezar |
|
||||||
|
| | `get_board` | Tablero por columnas (filtros por persona, release, área, sprint) |
|
||||||
|
| | `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 (épica, sprint, fechas…); el backlog: `noSprint` y orden `RANK` |
|
||||||
|
| | `get_issue` | Detalle: épica, lista de comprobación, relaciones, commits, seguidores, transiciones, comentarios |
|
||||||
|
| | `create_issue` | Crear un ticket (también colgado de una épica) |
|
||||||
|
| | `update_issue` | Cambiar título, descripción, tipo, área, prioridad, etiquetas, fechas… |
|
||||||
|
| | `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` / `edit_comment` / `delete_comment` | Comentar, corregir o borrar un comentario |
|
||||||
|
| | `link_issues` / `unlink_issues` | Relacionar tickets (`DEPENDS_ON`, `BLOCKS`, `TESTS`…) o quitar la relación |
|
||||||
|
| | `watch_issue` | Seguir un ticket o dejar de seguirlo |
|
||||||
|
| | `add_checklist_item` / `update_checklist_item` / `remove_checklist_item` | La lista de comprobación: añadir, marcar o reescribir, quitar |
|
||||||
|
| Organización | `list_epics` | Épicas con el progreso de lo que cuelga de cada una |
|
||||||
|
| | `set_issue_parent` | Colgar un ticket de una épica u otro ticket, o soltarlo |
|
||||||
|
| | `rank_issue` | Mover un ticket en el backlog |
|
||||||
|
| | `bulk_update_issues` | El mismo cambio en hasta 100 tickets (por clave); dice cuáles no se dejaron |
|
||||||
|
| | `delete_issue` / `restore_issue` / `list_trash` | Papelera: borrar (30 días para recuperar), recuperar y ver |
|
||||||
|
| Sprints | `list_sprints` | Sprints de un proyecto |
|
||||||
|
| | `get_sprint` | Progreso, tickets y burndown |
|
||||||
|
| | `create_sprint` / `update_sprint` / `delete_sprint` | Planificar, cambiar o borrar (si no empezó) un sprint |
|
||||||
|
| | `start_sprint` / `close_sprint` | Empezarlo; cerrarlo pasando lo pendiente a otro sprint o al backlog |
|
||||||
|
| | `set_issue_sprint` | Meter un ticket en un sprint o devolverlo al backlog |
|
||||||
|
| Releases | `list_releases` | Releases de un proyecto |
|
||||||
|
| | `upcoming_releases` | Próximas releases de mis proyectos |
|
||||||
|
| | `get_release` | Progreso, notas, tickets y burnup |
|
||||||
|
| | `create_release` / `update_release` | Planificar una release o cambiar su nombre, descripción o fecha |
|
||||||
|
| | `draft_release_notes` / `set_release_notes` | Borrador de las notas desde los tickets terminados, y guardarlas |
|
||||||
|
| | `publish_release` | Publicar (con `force` si quedan tickets sin terminar) |
|
||||||
|
| | `archive_release` | Archivar una release que no saldrá (sus tickets la dejan) |
|
||||||
|
|
||||||
|
Las de lectura van marcadas como `readOnlyHint`, para que el cliente no pida confirmación al usarlas, y las que borran o
|
||||||
|
archivan, como `destructiveHint`, para que la pida. Lo que es de administración (miembros, configuración, visibilidad,
|
||||||
|
borrar proyectos, webhooks, integración con git, importar y exportar CSV) 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.
|
||||||
@@ -4,7 +4,7 @@ TaskStream es una alternativa a Jira para equipos de desarrollo: proyectos, tick
|
|||||||
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 |
|
||||||
@@ -13,6 +13,7 @@ tareas, QA), releases, comentarios y relaciones entre tickets, todo en tiempo re
|
|||||||
| [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
|
||||||
|
// know which one that was: try each until one accepts it.
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(
|
throw new ServiceUnavailableException(
|
||||||
'Sign-in is not configured (PIREON_CLIENT_ID)',
|
'Sign-in is not configured (PIREON_CLIENT_ID)',
|
||||||
);
|
);
|
||||||
|
return candidates.filter(
|
||||||
|
(client, i) => candidates.findIndex((c) => c.id === client.id) === i,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const body = new URLSearchParams({ client_id: clientId });
|
private async requestToken(
|
||||||
if (clientSecret) body.set('client_secret', clientSecret);
|
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,21 @@ 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/notifications.service';
|
||||||
|
export * from './lib/notifications';
|
||||||
|
export * from './lib/saved-filters.service';
|
||||||
|
export * from './lib/sprints.service';
|
||||||
|
export * from './lib/git.service';
|
||||||
|
export * from './lib/webhooks.service';
|
||||||
|
export * from './lib/transfer.service';
|
||||||
|
export * from './lib/automations.service';
|
||||||
|
export * from './lib/automations/automations';
|
||||||
|
export * from './lib/automations/automation-rules';
|
||||||
|
export * from './lib/transfer/import-export';
|
||||||
|
export * from './lib/webhooks/webhooks';
|
||||||
|
export * from './lib/webhooks/webhook-events';
|
||||||
|
export * from './lib/git/git';
|
||||||
|
export * from './lib/git/git-payloads';
|
||||||
|
export * from './lib/saved-filters/saved-filters';
|
||||||
export * from './lib/tracker-application.module';
|
export * from './lib/tracker-application.module';
|
||||||
|
|||||||
246
libs/api/tracker/application/src/lib/analytics/analytics.spec.ts
Normal file
246
libs/api/tracker/application/src/lib/analytics/analytics.spec.ts
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
import {
|
||||||
|
IssueReadModel,
|
||||||
|
IssueTimelineEntry,
|
||||||
|
ReleaseReadModel,
|
||||||
|
ReleaseStatus,
|
||||||
|
StatusCategory as C,
|
||||||
|
SprintReadModel,
|
||||||
|
SprintStatus,
|
||||||
|
} from '@task-stream/api/tracker/domain';
|
||||||
|
import {
|
||||||
|
computeProjectAnalytics,
|
||||||
|
computeReleaseBurnup,
|
||||||
|
computeSprintBurndown,
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sprint burndown', () => {
|
||||||
|
const sprint = {
|
||||||
|
id: 's1',
|
||||||
|
projectId: 'p',
|
||||||
|
startDate: '2026-10-12',
|
||||||
|
endDate: '2026-10-16',
|
||||||
|
status: SprintStatus.ACTIVE,
|
||||||
|
closedAt: null,
|
||||||
|
} as SprintReadModel;
|
||||||
|
const into = (issueId: string, at: string): IssueTimelineEntry => ({
|
||||||
|
id: `${issueId}:${++seq}`,
|
||||||
|
issueId,
|
||||||
|
projectId: 'p',
|
||||||
|
at,
|
||||||
|
kind: 'sprint',
|
||||||
|
sprintId: 's1',
|
||||||
|
});
|
||||||
|
// X (3 points) and Y (5) committed on Monday; Z (2) added on Tuesday.
|
||||||
|
// X done on Tuesday, Y cancelled on Wednesday. Today is Wednesday noon.
|
||||||
|
const issues = [
|
||||||
|
{ ...issue('x', '2026-10-01T09:00:00Z', 'DONE', C.DONE), storyPoints: 3 },
|
||||||
|
{
|
||||||
|
...issue('y', '2026-10-01T09:00:00Z', 'CANCELLED', C.CANCELLED),
|
||||||
|
storyPoints: 5,
|
||||||
|
},
|
||||||
|
{ ...issue('z', '2026-10-01T09:00:00Z', 'TODO', C.TODO), storyPoints: 2 },
|
||||||
|
] as IssueReadModel[];
|
||||||
|
const timeline = [
|
||||||
|
step('x', '2026-10-01T09:00:00Z', 'TODO', C.TODO),
|
||||||
|
step('y', '2026-10-01T09:00:00Z', 'TODO', C.TODO),
|
||||||
|
step('z', '2026-10-01T09:00:00Z', 'TODO', C.TODO),
|
||||||
|
into('x', '2026-10-12T08:00:00Z'),
|
||||||
|
into('y', '2026-10-12T08:00:00Z'),
|
||||||
|
into('z', '2026-10-13T10:00:00Z'),
|
||||||
|
step('x', '2026-10-13T16:00:00Z', 'DONE', C.DONE),
|
||||||
|
step('y', '2026-10-14T09:00:00Z', 'CANCELLED', C.CANCELLED),
|
||||||
|
];
|
||||||
|
|
||||||
|
it('counts open tickets and points per day, and leaves the future blank', () => {
|
||||||
|
const burndown = computeSprintBurndown({
|
||||||
|
sprint,
|
||||||
|
issues,
|
||||||
|
timeline,
|
||||||
|
now: new Date('2026-10-14T12:00:00Z'),
|
||||||
|
});
|
||||||
|
expect(burndown.dates).toEqual([
|
||||||
|
'2026-10-12',
|
||||||
|
'2026-10-13',
|
||||||
|
'2026-10-14',
|
||||||
|
'2026-10-15',
|
||||||
|
'2026-10-16',
|
||||||
|
]);
|
||||||
|
expect(burndown.scope).toEqual([2, 3, 2, null, null]);
|
||||||
|
expect(burndown.remaining).toEqual([2, 2, 1, null, null]);
|
||||||
|
expect(burndown.remainingPoints).toEqual([8, 7, 2, null, null]);
|
||||||
|
expect(burndown.ideal).toEqual([2, 1.5, 1, 0.5, 0]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user