Compare commits
47 Commits
master
...
3c7c1f7f0e
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c7c1f7f0e | |||
| f986f17968 | |||
| 5ae1d05d30 | |||
| 66b09ecf4d | |||
| f6417248c7 | |||
| ea4ef82239 | |||
| 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 |
45
.env.example
45
.env.example
@@ -16,14 +16,53 @@ EVENTS_BRIDGE_ENABLED=true
|
||||
|
||||
# Pireon identity server (https://oauth.pireon.dev/api/documentation)
|
||||
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 signs with the secret of the client that registered it: one per client.
|
||||
PIREON_WEBHOOK_SECRET=
|
||||
# OAuth client registered for task-stream (POST /api/clients). Needed by the web app to sign in.
|
||||
# Redirect URI to register: http://localhost:4200/auth/callback
|
||||
# The password client's webhook, if it has its own
|
||||
PIREON_PASSWORD_WEBHOOK_SECRET=
|
||||
# OAuth clients of task-stream in Pireon (POST /api/clients). Each Pireon client allows ONE grant type:
|
||||
# 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_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_PASSWORD_GRANT=true
|
||||
AUTH_SESSION_TTL_SECONDS=60
|
||||
|
||||
GRAPHQL_PLAYGROUND=true
|
||||
|
||||
# REST + OpenAPI for other apps (Swagger UI at /api/docs). The document is also written to OPENAPI_FILE.
|
||||
SWAGGER_ENABLED=true
|
||||
OPENAPI_FILE=apps/api/openapi.json
|
||||
|
||||
# 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__/
|
||||
|
||||
vitest.config.*.timestamp*!apps/web/.env
|
||||
|
||||
# Attached files in development (ATTACHMENTS_DIR)
|
||||
/data
|
||||
|
||||
@@ -5,3 +5,10 @@
|
||||
/.nx/workspace-data
|
||||
package-lock.json
|
||||
.angular
|
||||
|
||||
# Generated by the API on startup (nx serve api): keep them byte-for-byte as written
|
||||
apps/api/openapi.json
|
||||
apps/api/schema.gql
|
||||
|
||||
# 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",
|
||||
"name": "Debug api with Nx",
|
||||
"runtimeExecutable": "npx",
|
||||
"runtimeArgs": [
|
||||
"nx",
|
||||
"serve",
|
||||
"api"
|
||||
],
|
||||
"runtimeArgs": ["nx", "serve", "api"],
|
||||
"env": {
|
||||
"NODE_OPTIONS": "--inspect=9229"
|
||||
},
|
||||
"console": "integratedTerminal",
|
||||
"internalConsoleOptions": "neverOpen",
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
],
|
||||
"skipFiles": ["<node_internals>/**"],
|
||||
"sourceMaps": true,
|
||||
"outFiles": [
|
||||
"${workspaceFolder}/apps/api/dist/**/*.(m|c|)js",
|
||||
"!**/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/**"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
34
Dockerfile
34
Dockerfile
@@ -1,16 +1,21 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
# TaskStream images (one Dockerfile, two targets):
|
||||
# docker compose up -d --build → api + web + infrastructure
|
||||
# TaskStream images (one Dockerfile, three targets):
|
||||
# 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
|
||||
# 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) --------------------
|
||||
FROM node:22-alpine AS deps
|
||||
WORKDIR /workspace
|
||||
ENV NX_DAEMON=false NX_NO_CLOUD=true NX_SKIP_NX_CACHE=true
|
||||
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 -----------------
|
||||
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 ./
|
||||
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 ./
|
||||
# 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
|
||||
EXPOSE 3000
|
||||
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 ["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) ---------
|
||||
FROM deps AS build-web
|
||||
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),
|
||||
releases, comentarios y relaciones entre tickets, **en tiempo real**.
|
||||
|
||||
- **API**: NestJS + GraphQL, arquitectura hexagonal, CQRS y event sourcing (escritura en PostgreSQL, lectura en MongoDB,
|
||||
- **API**: NestJS + GraphQL y REST con OpenAPI (Swagger, para otras apps), arquitectura hexagonal, CQRS y event sourcing (escritura en PostgreSQL, lectura en MongoDB,
|
||||
Redis entre ambas).
|
||||
- **Web**: Angular 22 + NgRx Signals (patrón de eventos) + `@pi/ui`, con la misma arquitectura.
|
||||
- **Usuarios**: los gestiona [Pireon](https://oauth.pireon.dev/api/documentation).
|
||||
@@ -14,14 +14,15 @@ releases, comentarios y relaciones entre tickets, **en tiempo real**.
|
||||
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
|
||||
[Autenticación](docs/05-autenticacion.md#registrar-taskstream-como-cliente-en-pireon)). Para desarrollar:
|
||||
Para entrar hacen falta los clientes OAuth de Pireon en `.env`: uno `authorization_code` (`PIREON_CLIENT_ID` /
|
||||
`_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
|
||||
npm install # @pi/ui necesita el token del Nexus de Pireon en ~/.npmrc
|
||||
docker compose up -d postgres mongo redis
|
||||
cp .env.example .env
|
||||
npx nx serve api # http://localhost:3000/graphql
|
||||
npx nx serve api # http://localhost:3000/graphql · REST: /api/docs
|
||||
npx nx serve web # http://localhost:4200
|
||||
```
|
||||
|
||||
@@ -31,7 +32,7 @@ npx nx serve web # http://localhost:4200
|
||||
| ------------------------------------------- | -------------------------------------------------------------------- |
|
||||
| [Visión general](docs/01-vision-general.md) | Funcionalidades, stack y estructura del monorepo |
|
||||
| [Arquitectura](docs/02-arquitectura.md) | Capas, CQRS, event sourcing, flujo escritura → lectura y tiempo real |
|
||||
| [API](docs/03-api.md) | Dominio, workflow de tickets, eventos y operaciones GraphQL |
|
||||
| [API](docs/03-api.md) | Dominio, workflow de tickets, eventos, GraphQL y REST (OpenAPI) |
|
||||
| [Cliente web](docs/04-cliente-web.md) | Stores con eventos, GraphQL y `@pi/ui` |
|
||||
| [Autenticación](docs/05-autenticacion.md) | Tokens de Pireon, login en el navegador y webhooks |
|
||||
| [Desarrollo](docs/06-desarrollo.md) | Comandos, tests, cómo añadir una funcionalidad y problemas conocidos |
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import {
|
||||
IdentityProvider,
|
||||
InvalidAccessTokenError,
|
||||
} from '@task-stream/api/identity/application';
|
||||
import { User, UserFactory } from '@task-stream/api/identity/domain';
|
||||
import { DomainErrorFilter } from '@task-stream/api/shared/presenters';
|
||||
import { Client, createClient } from 'graphql-ws';
|
||||
import { createHmac } from 'node:crypto';
|
||||
import WebSocket from 'ws';
|
||||
|
||||
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['EVENT_STREAM_KEY'] = `task-stream:e2e:${Date.now()}`;
|
||||
process.env['GRAPHQL_SCHEMA_FILE'] = '';
|
||||
process.env['OPENAPI_FILE'] = '';
|
||||
process.env['AUTH_SESSION_TTL_SECONDS'] = '0';
|
||||
process.env['PIREON_WEBHOOK_SECRET'] = 'e2e-webhook-secret';
|
||||
|
||||
const { AppModule } = require('../src/app/app.module');
|
||||
const { configureApp } = require('../src/app/configure');
|
||||
|
||||
/** Pireon is replaced by fixed tokens: the rest of the stack is real. */
|
||||
const users = new UserFactory();
|
||||
@@ -29,6 +32,7 @@ const people: Record<string, User> = {
|
||||
'token-bob': person('102', 'Bob (frontend)', 'bob@x.dev'),
|
||||
'token-qa': person('103', 'Quinn (QA)', 'qa@x.dev'),
|
||||
'token-eve': person('104', 'Eve (outsider)', 'eve@x.dev'),
|
||||
'token-dan': person('105', 'Dan (leaving)', 'dan@x.dev'),
|
||||
};
|
||||
const byId = (id: string) =>
|
||||
Object.values(people).find((p) => p.id === id) ?? null;
|
||||
@@ -71,6 +75,25 @@ describe('task-stream (e2e)', () => {
|
||||
return body.data;
|
||||
}
|
||||
|
||||
/** REST call under /api; returns status and parsed body. */
|
||||
async function rest<T = Record<string, any>>( // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
token: string | null,
|
||||
method: string,
|
||||
path: string,
|
||||
body?: object,
|
||||
): Promise<{ status: number; body: T }> {
|
||||
const response = await fetch(`${url}/api${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await response.text();
|
||||
return { status: response.status, body: text ? JSON.parse(text) : null };
|
||||
}
|
||||
|
||||
/** The read model is eventually consistent: poll until it catches up. */
|
||||
async function eventually<T>(
|
||||
read: () => Promise<T>,
|
||||
@@ -123,10 +146,7 @@ describe('task-stream (e2e)', () => {
|
||||
.useValue(fakePireon)
|
||||
.compile();
|
||||
app = moduleRef.createNestApplication({ rawBody: true });
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({ transform: true, whitelist: true }),
|
||||
);
|
||||
app.useGlobalFilters(new DomainErrorFilter());
|
||||
configureApp(app);
|
||||
await app.listen(0);
|
||||
url = await app.getUrl();
|
||||
url = url.replace('[::1]', 'localhost');
|
||||
@@ -405,4 +425,747 @@ describe('task-stream (e2e)', () => {
|
||||
expect(anonymous.received).toHaveLength(0);
|
||||
expect(anonymous.errors).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('exposes the same use cases over REST, documented with OpenAPI', async () => {
|
||||
// --- The contract other apps generate clients from ---
|
||||
const docs = await rest(null, 'GET', '/docs-json');
|
||||
expect(docs.status).toBe(200);
|
||||
expect(Object.keys(docs.body['paths'])).toEqual(
|
||||
expect.arrayContaining([
|
||||
'/api/projects',
|
||||
'/api/projects/{project}/issues',
|
||||
'/api/issues/{issue}/status',
|
||||
'/api/releases/{releaseId}/publish',
|
||||
'/api/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);
|
||||
});
|
||||
12819
apps/api/openapi.json
Normal file
12819
apps/api/openapi.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@
|
||||
"@nestjs/graphql": "^13.4.5",
|
||||
"@nestjs/mongoose": "^11.0.4",
|
||||
"@nestjs/platform-express": "^11.0.0",
|
||||
"@nestjs/swagger": "^11.4.7",
|
||||
"@nestjs/typeorm": "^11.0.3",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
@@ -24,6 +25,7 @@
|
||||
"ioredis": "^5.11.1",
|
||||
"joi": "^18.2.9",
|
||||
"mongoose": "^8.24.4",
|
||||
"nodemailer": "^7.0.13",
|
||||
"pg": "^8.23.1",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"rxjs": "^7.8.0",
|
||||
|
||||
@@ -3,15 +3,10 @@
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "apps/api/src",
|
||||
"projectType": "application",
|
||||
"tags": [
|
||||
"scope:api",
|
||||
"type:app"
|
||||
],
|
||||
"tags": ["scope:api", "type:app"],
|
||||
"targets": {
|
||||
"prune-lockfile": {
|
||||
"dependsOn": [
|
||||
"build"
|
||||
],
|
||||
"dependsOn": ["build"],
|
||||
"cache": true,
|
||||
"executor": "@nx/js:prune-lockfile",
|
||||
"outputs": [
|
||||
@@ -23,32 +18,23 @@
|
||||
}
|
||||
},
|
||||
"copy-workspace-modules": {
|
||||
"dependsOn": [
|
||||
"build"
|
||||
],
|
||||
"dependsOn": ["build"],
|
||||
"cache": true,
|
||||
"outputs": [
|
||||
"{workspaceRoot}/dist/apps/api/workspace_modules"
|
||||
],
|
||||
"outputs": ["{workspaceRoot}/dist/apps/api/workspace_modules"],
|
||||
"executor": "@nx/js:copy-workspace-modules",
|
||||
"options": {
|
||||
"buildTarget": "build"
|
||||
}
|
||||
},
|
||||
"prune": {
|
||||
"dependsOn": [
|
||||
"prune-lockfile",
|
||||
"copy-workspace-modules"
|
||||
],
|
||||
"dependsOn": ["prune-lockfile", "copy-workspace-modules"],
|
||||
"executor": "nx:noop"
|
||||
},
|
||||
"serve": {
|
||||
"continuous": true,
|
||||
"executor": "@nx/js:node",
|
||||
"defaultConfiguration": "development",
|
||||
"dependsOn": [
|
||||
"build"
|
||||
],
|
||||
"dependsOn": ["build"],
|
||||
"options": {
|
||||
"buildTarget": "api:build",
|
||||
"runBuildTargetDependencies": false
|
||||
@@ -73,6 +59,13 @@
|
||||
"command": "jest --config apps/api/jest.e2e.config.cts --runInBand"
|
||||
},
|
||||
"cache": false
|
||||
},
|
||||
"contracts": {
|
||||
"executor": "nx:run-commands",
|
||||
"options": {
|
||||
"command": "node -r ./apps/api/scripts/register.cjs apps/api/scripts/contracts.ts"
|
||||
},
|
||||
"cache": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1107
apps/api/schema.gql
1107
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,15 @@ export const configSchema = Joi.object({
|
||||
// Pireon identity server
|
||||
PIREON_BASE_URL: Joi.string().uri().default('https://oauth.pireon.dev'),
|
||||
PIREON_WEBHOOK_SECRET: Joi.string().allow('').optional(),
|
||||
// OAuth client of task-stream in Pireon, used by the web app to sign in.
|
||||
// The password client registers its own webhook, signed with its own secret.
|
||||
PIREON_PASSWORD_WEBHOOK_SECRET: Joi.string().allow('').optional(),
|
||||
// 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_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_PASSWORD_GRANT: Joi.boolean().default(true),
|
||||
PIREON_TIMEOUT_MS: Joi.number().default(5000),
|
||||
@@ -33,4 +39,19 @@ export const configSchema = Joi.object({
|
||||
|
||||
GRAPHQL_PLAYGROUND: Joi.boolean().default(true),
|
||||
GRAPHQL_SCHEMA_FILE: Joi.string().allow('').default('apps/api/schema.gql'),
|
||||
|
||||
// REST + OpenAPI for other apps: Swagger UI at /api/docs, JSON at /api/docs-json.
|
||||
SWAGGER_ENABLED: Joi.boolean().default(true),
|
||||
// Where to write the OpenAPI document on startup ('' = don't), like the GraphQL SDL.
|
||||
OPENAPI_FILE: Joi.string().allow('').default('apps/api/openapi.json'),
|
||||
|
||||
// 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,
|
||||
} from '@nestjs/common';
|
||||
import { InjectConnection } from '@nestjs/mongoose';
|
||||
import {
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiServiceUnavailableResponse,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { Public } from '@task-stream/api/identity/presenters';
|
||||
import { REDIS_CLIENT } from '@task-stream/api/shared/infrastructure';
|
||||
import Redis from 'ioredis';
|
||||
import { Connection } from 'mongoose';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
@ApiTags('Health')
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
constructor(
|
||||
@@ -21,6 +28,13 @@ export class HealthController {
|
||||
|
||||
@Public()
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'API and databases status' })
|
||||
@ApiOkResponse({
|
||||
schema: {
|
||||
example: { status: 'ok', postgres: 'up', mongo: 'up', redis: 'up' },
|
||||
},
|
||||
})
|
||||
@ApiServiceUnavailableResponse({ description: 'A database is down' })
|
||||
async check() {
|
||||
const checks = await Promise.allSettled([
|
||||
this.postgres.query('SELECT 1'),
|
||||
|
||||
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 { DomainErrorFilter } from '@task-stream/api/shared/presenters';
|
||||
import { AppModule } from './app/app.module';
|
||||
import { configureApp } from './app/configure';
|
||||
|
||||
async function bootstrap() {
|
||||
// rawBody: needed to verify Pireon webhook signatures.
|
||||
const app = await NestFactory.create(AppModule, { rawBody: true });
|
||||
app.setGlobalPrefix('api');
|
||||
app.enableCors();
|
||||
app.enableShutdownHooks();
|
||||
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
|
||||
app.useGlobalFilters(new DomainErrorFilter());
|
||||
configureApp(app);
|
||||
|
||||
const port = process.env['PORT'] || 3000;
|
||||
await app.listen(port);
|
||||
Logger.log(
|
||||
`🚀 GraphQL: http://localhost:${port}/graphql (ws for subscriptions)`,
|
||||
);
|
||||
Logger.log(`📘 REST: http://localhost:${port}/api/docs (OpenAPI)`);
|
||||
Logger.log(`❤️ Health: http://localhost:${port}/api/health`);
|
||||
}
|
||||
|
||||
|
||||
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;
|
||||
}
|
||||
8945
apps/mcp/src/app/api/taskstream.schema.ts
Normal file
8945
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;
|
||||
}
|
||||
}
|
||||
642
apps/mcp/src/app/tools/issues.resolver.ts
Normal file
642
apps/mcp/src/app/tools/issues.resolver.ts
Normal file
@@ -0,0 +1,642 @@
|
||||
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); watching: only the ones they follow.",
|
||||
paramsSchema: z.object({
|
||||
assignedToMe: z.boolean().default(true),
|
||||
watching: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe(
|
||||
'Only the tickets the user follows (use with assignedToMe false)',
|
||||
),
|
||||
categories: z
|
||||
.array(CATEGORY)
|
||||
.optional()
|
||||
.describe('Default: open ones (TODO, IN_PROGRESS, QA)'),
|
||||
limit: limit(200, 50),
|
||||
}),
|
||||
annotations: READ,
|
||||
})
|
||||
mine(
|
||||
params: {
|
||||
assignedToMe: boolean;
|
||||
watching: 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,
|
||||
watching: params.watching,
|
||||
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)'),
|
||||
watching: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Only the tickets the user follows'),
|
||||
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;
|
||||
watching?: 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,
|
||||
watching: params.watching,
|
||||
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,
|
||||
replyTo: c.replyTo,
|
||||
author: c.deleted ? null : c.author.name,
|
||||
at: c.createdAt,
|
||||
body: c.deleted ? '(deleted; kept for its replies)' : 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), or answer a comment with replyTo (one level: answering a reply joins its thread).',
|
||||
paramsSchema: z.object({
|
||||
issue: issueRef,
|
||||
body: z.string().min(1).max(20000),
|
||||
replyTo: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Id of the comment it answers (see get_issue)'),
|
||||
}),
|
||||
annotations: WRITE,
|
||||
})
|
||||
comment(
|
||||
{ issue, body, replyTo }: { issue: string; body: string; replyTo?: string },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
return toolResult(() =>
|
||||
this.api.data(
|
||||
this.api.as(ctx).POST('/api/issues/{issue}/comments', {
|
||||
params: { path: { issue } },
|
||||
body: { body, replyTo },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@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.
|
||||
# The API is reached by its unique alias (task-stream-api), not by the service name `api`:
|
||||
# on a shared network (Dokploy) another project's `api` would get half of the requests.
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
@@ -9,13 +11,15 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
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 X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
location /graphql {
|
||||
proxy_pass http://api:3000;
|
||||
proxy_pass http://task-stream-api:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
|
||||
@@ -2,23 +2,42 @@ import { authGuard } from '@task-stream/web/identity/presenters';
|
||||
import { appRoutes } from './app.routes';
|
||||
|
||||
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);
|
||||
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);
|
||||
expect(app?.canActivate).toContain(authGuard);
|
||||
expect(app?.children?.map((route) => route.path)).toEqual(['', 'p/:key']);
|
||||
const children = app?.children ?? [];
|
||||
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', () => {
|
||||
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([
|
||||
'',
|
||||
'board',
|
||||
'issues/:issueKey',
|
||||
'backlog',
|
||||
'sprints',
|
||||
'calendar',
|
||||
'roadmap',
|
||||
'epics',
|
||||
'trash',
|
||||
'releases',
|
||||
'dashboard',
|
||||
'analytics',
|
||||
'settings',
|
||||
'activity',
|
||||
'members',
|
||||
]);
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { Route } from '@angular/router';
|
||||
import {
|
||||
authGuard,
|
||||
identityRoutes,
|
||||
} from '@task-stream/web/identity/presenters';
|
||||
import { identityRoutes } from '@task-stream/web/identity/presenters';
|
||||
import { trackerRoutes } from '@task-stream/web/tracker/presenters';
|
||||
|
||||
export const appRoutes: Route[] = [
|
||||
...identityRoutes,
|
||||
{ path: '', canActivate: [authGuard], children: trackerRoutes },
|
||||
// Not all behind the guard: public projects are readable without a session.
|
||||
{ path: '', children: trackerRoutes },
|
||||
{ path: '**', redirectTo: '' },
|
||||
];
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
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 { UserMenuComponent } from '@task-stream/web/identity/presenters';
|
||||
import { AppShellComponent } from '@task-stream/web/shared/ui';
|
||||
import {
|
||||
CommandPalette,
|
||||
NotificationsBell,
|
||||
} from '@task-stream/web/tracker/presenters';
|
||||
|
||||
@Component({
|
||||
selector: 'ts-root',
|
||||
@@ -13,6 +25,8 @@ import { AppShellComponent } from '@task-stream/web/shared/ui';
|
||||
RouterLinkActive,
|
||||
AppShellComponent,
|
||||
UserMenuComponent,
|
||||
NotificationsBell,
|
||||
CommandPalette,
|
||||
],
|
||||
template: `
|
||||
<ts-app-shell>
|
||||
@@ -27,8 +41,25 @@ import { AppShellComponent } from '@task-stream/web/shared/ui';
|
||||
Proyectos
|
||||
</a>
|
||||
}
|
||||
<a
|
||||
shellNav
|
||||
class="ts-nav-link"
|
||||
routerLink="/explorar"
|
||||
routerLinkActive="is-active"
|
||||
>
|
||||
Explorar
|
||||
</a>
|
||||
@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 />
|
||||
</ts-app-shell>
|
||||
@@ -36,4 +67,13 @@ import { AppShellComponent } from '@task-stream/web/shared/ui';
|
||||
})
|
||||
export class App {
|
||||
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-task: #64748b;
|
||||
--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,
|
||||
@@ -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 {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
@@ -444,6 +482,18 @@ h3 {
|
||||
font-weight: 600;
|
||||
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) {
|
||||
@@ -501,6 +551,25 @@ html.dark-mode .pi-shell {
|
||||
--ts-t-task: #94a3b8;
|
||||
--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-25: #161922;
|
||||
--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 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:
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
target: web
|
||||
secrets: [npmrc]
|
||||
secrets: [nexus_token]
|
||||
image: task-stream-web
|
||||
ports: ['8080:80']
|
||||
expose: ['80']
|
||||
networks: [default]
|
||||
depends_on:
|
||||
api: { condition: service_healthy }
|
||||
restart: unless-stopped
|
||||
@@ -19,37 +29,87 @@ services:
|
||||
build:
|
||||
context: .
|
||||
target: api
|
||||
secrets: [npmrc]
|
||||
secrets: [nexus_token]
|
||||
image: task-stream-api
|
||||
ports: ['3000:3000']
|
||||
expose: ['3000']
|
||||
networks:
|
||||
default:
|
||||
aliases: [task-stream-api]
|
||||
environment:
|
||||
POSTGRES_URL: postgres://taskstream:taskstream@postgres:5432/taskstream
|
||||
POSTGRES_URL: postgres://taskstream:taskstream@task-stream-postgres:5432/taskstream
|
||||
# No migrations yet: TypeORM creates the event store and lookup tables.
|
||||
POSTGRES_SYNCHRONIZE: 'true'
|
||||
MONGO_URL: mongodb://mongo:27017/taskstream_read
|
||||
REDIS_URL: redis://redis:6379
|
||||
MONGO_URL: mongodb://task-stream-mongo:27017/taskstream_read
|
||||
REDIS_URL: redis://task-stream-redis:6379
|
||||
EVENT_STREAM_KEY: task-stream:events
|
||||
EVENTS_BRIDGE_ENABLED: 'true'
|
||||
PIREON_BASE_URL: https://oauth.pireon.dev
|
||||
PIREON_WEBHOOK_SECRET: ${PIREON_WEBHOOK_SECRET:-}
|
||||
PIREON_PASSWORD_WEBHOOK_SECRET: ${PIREON_PASSWORD_WEBHOOK_SECRET:-}
|
||||
PIREON_CLIENT_ID: ${PIREON_CLIENT_ID:-}
|
||||
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'
|
||||
# The SDL file is only useful in development (codegen).
|
||||
# The SDL / OpenAPI files are only useful in development (codegen); the
|
||||
# document is still served at /api/docs-json.
|
||||
GRAPHQL_SCHEMA_FILE: ''
|
||||
OPENAPI_FILE: ''
|
||||
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:
|
||||
postgres: { condition: service_healthy }
|
||||
mongo: { condition: service_healthy }
|
||||
redis: { condition: service_healthy }
|
||||
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:
|
||||
image: postgres:17-alpine
|
||||
networks:
|
||||
default:
|
||||
aliases: [task-stream-postgres]
|
||||
environment:
|
||||
POSTGRES_USER: taskstream
|
||||
POSTGRES_PASSWORD: taskstream
|
||||
POSTGRES_DB: taskstream
|
||||
ports: ['5432:5432']
|
||||
volumes: [postgres-data:/var/lib/postgresql/data]
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U taskstream']
|
||||
@@ -58,7 +118,9 @@ services:
|
||||
|
||||
mongo:
|
||||
image: mongo:8
|
||||
ports: ['27017:27017']
|
||||
networks:
|
||||
default:
|
||||
aliases: [task-stream-mongo]
|
||||
volumes: [mongo-data:/data/db]
|
||||
healthcheck:
|
||||
test: ['CMD', 'mongosh', '--quiet', '--eval', 'db.adminCommand("ping")']
|
||||
@@ -67,8 +129,10 @@ services:
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
networks:
|
||||
default:
|
||||
aliases: [task-stream-redis]
|
||||
command: ['redis-server', '--appendonly', 'yes']
|
||||
ports: ['6379:6379']
|
||||
volumes: [redis-data:/data]
|
||||
healthcheck:
|
||||
test: ['CMD', 'redis-cli', 'ping']
|
||||
@@ -76,10 +140,11 @@ services:
|
||||
retries: 10
|
||||
|
||||
volumes:
|
||||
attachments-data:
|
||||
postgres-data:
|
||||
mongo-data:
|
||||
redis-data:
|
||||
|
||||
secrets:
|
||||
npmrc:
|
||||
file: ${HOME}/.npmrc
|
||||
nexus_token:
|
||||
environment: NEXUS_TOKEN
|
||||
|
||||
@@ -2,18 +2,45 @@
|
||||
|
||||
## Qué resuelve
|
||||
|
||||
| Necesidad | Cómo se resuelve |
|
||||
| ---------------- | ----------------------------------------------------------------------------------------------------- |
|
||||
| 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas |
|
||||
| 🎨 Cambios de UI | Tipo `UI_CHANGE` con enlace al frame de Figma (validado contra `figma.com`) |
|
||||
| ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo |
|
||||
| 🧪 QA | Estados `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) |
|
||||
| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona |
|
||||
| 💬 Comentarios | Decisiones y explicaciones dentro del ticket; el autor edita, el admin modera |
|
||||
| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA) |
|
||||
| 📊 Seguimiento | Panel con contadores, gráfico por estado, carga por persona, feed de actividad e historial por ticket |
|
||||
| ⚡ Tiempo real | Tablero, comentarios, actividad, releases y membresías se actualizan solos (GraphQL subscriptions) |
|
||||
| Necesidad | Cómo se resuelve |
|
||||
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🐛 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`) |
|
||||
| ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo |
|
||||
| 🧪 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) |
|
||||
| 📝 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 |
|
||||
| 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con respuestas y `@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 y los ve juntos en «Que sigo»; quien lo abre, lo tiene asignado o comenta, lo sigue solo |
|
||||
| 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios, respuestas), 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) |
|
||||
| 🌿 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) |
|
||||
|
||||
Los **usuarios no se gestionan aquí**: vienen del servidor de identidad **Pireon**
|
||||
([Swagger](https://oauth.pireon.dev/api/documentation)). TaskStream gestiona proyectos, membresías y roles por
|
||||
@@ -39,12 +66,13 @@ proyecto (`OWNER`, `ADMIN`, `MEMBER`, `VIEWER`).
|
||||
apps/
|
||||
api/ composición de la API (NestJS): config, GraphQL, conexiones, e2e
|
||||
web/ composición del cliente (Angular): rutas, providers, estilos, nginx
|
||||
mcp/ servidor MCP para asistentes de IA (NestJS), consume la API REST
|
||||
libs/
|
||||
api/{shared,identity,tracker}/{domain,application,infrastructure,presenters}
|
||||
web/{shared,identity,tracker}/{domain,application,infrastructure,presenters} (+ web/shared/ui)
|
||||
docs/ esta documentación
|
||||
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
|
||||
```
|
||||
|
||||
|
||||
@@ -112,6 +112,7 @@ sequenceDiagram
|
||||
`@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
|
||||
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
|
||||
|
||||
@@ -128,6 +129,37 @@ 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
|
||||
**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 |
|
||||
| `PeopleFollowSaga` | `IssueCreatedEvent`, `IssueAssignedEvent` | `WatchIssueCommand`: quien abre el ticket y quien lo tiene asignado pasan a seguirlo |
|
||||
| `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
|
||||
|
||||
Las mutations devuelven `CommandResult { id, key, version }` en cuanto los eventos están guardados; Mongo se actualiza
|
||||
|
||||
397
docs/03-api.md
397
docs/03-api.md
@@ -11,7 +11,8 @@ classDiagram
|
||||
key: "TS"
|
||||
name
|
||||
members: (userId, role)[]
|
||||
addMember() changeMemberRole() removeMember() updateDetails()
|
||||
settings: estados · transiciones · tipos · áreas
|
||||
addMember() changeMemberRole() removeMember() updateDetails() updateSettings()
|
||||
}
|
||||
class Issue {
|
||||
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
|
||||
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
|
||||
stateDiagram-v2
|
||||
@@ -76,9 +129,10 @@ stateDiagram-v2
|
||||
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).
|
||||
- `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
|
||||
|
||||
@@ -88,66 +142,231 @@ stateDiagram-v2
|
||||
- **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).
|
||||
- **Releases**: solo las `PLANNED` aceptan o pierden tickets; publicar con tickets sin terminar exige `force: true`.
|
||||
- **Comentarios**: solo el autor edita; borra el autor o un admin del proyecto.
|
||||
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. Se puede responder a un comentario
|
||||
(`replyTo`), con un solo nivel de respuestas: responder a una respuesta la suma al mismo hilo, y la respuesta tiene
|
||||
que ser del mismo ticket. Quien escribió el comentario recibe un aviso (`REPLIED`). Un comentario borrado que tiene
|
||||
respuestas se queda, sin texto (`deleted: true`), para que el hilo se siga entendiendo, y desaparece con su última
|
||||
respuesta; no cuenta en `commentCount`.
|
||||
- **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` |
|
||||
| Alguien responde a un comentario | Quien escribió el comentario | `REPLIED` |
|
||||
| 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 lo abre, quien lo tiene asignado y quien comenta pasan a seguirlo (`PeopleFollowSaga`,
|
||||
`CommentersFollowSaga`). Quien
|
||||
sigue un ticket (`WATCHED`) sabe cuándo cambia de estado, se asigna, se comenta, cambia su prioridad o su fecha límite,
|
||||
o entra o sale de un sprint; las ediciones menores (título, etiquetas…) no avisan. «Los que sigo»: `watching` en
|
||||
`myIssues` / `GET /api/me/issues` y en la búsqueda de un proyecto (`issues(filter: { watching: true })`,
|
||||
`?watching=true`; sin sesión, ninguno). 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
|
||||
|
||||
Todo cambio es un evento guardado en el event store (stream = id del agregado). Llevan `actorId`, `occurredAt`,
|
||||
`aggregateId`, `projectId` y `aggregateVersion` (la posición en el stream).
|
||||
|
||||
| Agregado | Eventos |
|
||||
| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Project | `ProjectCreatedEvent`, `ProjectUpdatedEvent`, `ProjectMemberAddedEvent`, `ProjectMemberRoleChangedEvent`, `ProjectMemberRemovedEvent` |
|
||||
| Issue | `IssueCreatedEvent`, `IssueUpdatedEvent`, `IssueStatusChangedEvent`, `IssueAssignedEvent`, `IssueLinkedEvent`, `IssueUnlinkedEvent`, `IssueReleaseChangedEvent` |
|
||||
| Comment | `CommentAddedEvent`, `CommentEditedEvent`, `CommentDeletedEvent` |
|
||||
| Release | `ReleaseCreatedEvent`, `ReleaseUpdatedEvent`, `ReleaseShippedEvent`, `ReleaseArchivedEvent` |
|
||||
| Agregado | Eventos |
|
||||
| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Project | `ProjectCreatedEvent`, `ProjectUpdatedEvent`, `ProjectSettingsChangedEvent`, `ProjectMemberAddedEvent`, `ProjectMemberRoleChangedEvent`, `ProjectMemberRemovedEvent`, `ProjectDeletedEvent` |
|
||||
| Issue | `IssueCreatedEvent`, `IssueUpdatedEvent`, `IssueStatusChangedEvent`, `IssueAssignedEvent`, `IssueLinkedEvent`, `IssueUnlinkedEvent`, `IssueReleaseChangedEvent`, `IssueAttachmentAddedEvent`, `IssueAttachmentRemovedEvent`, `IssueWatchedEvent`, `IssueUnwatchedEvent`, `IssueParentChangedEvent`, `IssueRankedEvent`, `IssueSprintChangedEvent`, `IssueDevLinkRecordedEvent`, `IssueChecklistItemAddedEvent`, `IssueChecklistItemUpdatedEvent`, `IssueChecklistItemRemovedEvent`, `IssueDeletedEvent` |
|
||||
| Comment | `CommentAddedEvent`, `CommentEditedEvent`, `CommentDeletedEvent` |
|
||||
| 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
|
||||
|
||||
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
|
||||
|
||||
| Query | Devuelve |
|
||||
| ----------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| `me` | El usuario autenticado, con sus roles en Pireon |
|
||||
| `users(search, limit)` | Usuarios de Pireon (para asignar o añadir miembros) |
|
||||
| `user(id)` | Un usuario |
|
||||
| `myProjects` | Proyectos de los que soy miembro |
|
||||
| `project(idOrKey)` | Un proyecto. Campos calculados: `board(releaseId, assigneeId, area, perColumn)`, `stats`, `releases` |
|
||||
| `issues(filter, limit, offset)` | Búsqueda de tickets (estado, tipo, área, prioridad, persona, release, etiquetas, texto) |
|
||||
| `issue(idOrKey)` | Un ticket. Campos calculados: `allowedTransitions`, `linkedIssues`, `comments`, `history` |
|
||||
| `release(id)` | Una release. Campos calculados: `progress`, `issues` |
|
||||
| `activity(projectId, issueId?, limit, before?)` | Feed de actividad, del más reciente al más antiguo |
|
||||
| Query | Devuelve |
|
||||
| --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `me` | El usuario autenticado, con sus roles en Pireon |
|
||||
| `users(search, limit)` | Usuarios de Pireon (para asignar o añadir miembros) |
|
||||
| `user(id)` | Un usuario |
|
||||
| `myProjects` | Proyectos de los que soy miembro |
|
||||
| `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]`) |
|
||||
| `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` |
|
||||
| `release(id)` | Una release. Campos calculados: `progress`, `burnup`, `issues` |
|
||||
| `activity(projectId, issueId?, limit, before?)` | Feed de actividad, del más reciente al más antiguo |
|
||||
|
||||
### Mutations
|
||||
|
||||
Todas devuelven `CommandResult { id, key, version }`.
|
||||
|
||||
| Ámbito | Mutations |
|
||||
| ----------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| Proyectos | `createProject`, `updateProject`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
|
||||
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease` |
|
||||
| Comentarios | `addComment`, `editComment`, `deleteComment` |
|
||||
| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `archiveRelease` |
|
||||
| Ámbito | Mutations |
|
||||
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
|
||||
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `removeAttachment` (subir: por REST) |
|
||||
| Comentarios | `addComment`, `editComment`, `deleteComment` |
|
||||
| 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
|
||||
|
||||
| Subscription | Se emite cuando |
|
||||
| --------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| `issueChanged(projectId)` | Se crea o cambia cualquier ticket del proyecto (incluye el ticket completo y `action` = nombre del evento) |
|
||||
| `commentChanged(issueId)` | Se añade, edita o borra un comentario (`comment` es `null` al borrar) |
|
||||
| `activityAdded(projectId)` | Hay una nueva entrada en el feed |
|
||||
| `releaseChanged(projectId)` | Cambia una release |
|
||||
| `projectChanged(projectId)` | Cambian los datos o los miembros del proyecto |
|
||||
| `myProjectsChanged` | Me añaden a un proyecto o me quitan de uno |
|
||||
| Subscription | Se emite cuando |
|
||||
| --------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||
| `issueChanged(projectId)` | Se crea o cambia cualquier ticket del proyecto (incluye el ticket completo y `action` = nombre del evento) |
|
||||
| `commentChanged(issueId)` | Se añade, edita o borra un comentario (`comment` es `null` al borrar, o el que queda sin texto si tenía respuestas) |
|
||||
| `activityAdded(projectId)` | Hay una nueva entrada en el feed |
|
||||
| `releaseChanged(projectId)` | Cambia una release |
|
||||
| `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 |
|
||||
|
||||
Solo los miembros pueden suscribirse a un proyecto; las conexiones sin token válido se cierran con el código 4403.
|
||||
|
||||
### Ejemplos
|
||||
|
||||
Estados, tipos y áreas son texto (ids de la configuración del proyecto), no enums: van entre comillas.
|
||||
|
||||
```graphql
|
||||
mutation ($settings: ProjectSettingsInput!) {
|
||||
# La configuración entera (la que devuelve project.settings, cambiada).
|
||||
updateProjectSettings(projectId: "…", settings: $settings) {
|
||||
version
|
||||
}
|
||||
}
|
||||
|
||||
mutation {
|
||||
createProject(input: { key: "SHOP", name: "Checkout revamp" }) {
|
||||
id
|
||||
@@ -157,7 +376,7 @@ 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
|
||||
key
|
||||
version
|
||||
@@ -165,7 +384,7 @@ 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
|
||||
}
|
||||
}
|
||||
@@ -210,6 +429,27 @@ query {
|
||||
percent
|
||||
}
|
||||
}
|
||||
analytics(days: 90) {
|
||||
createdVsResolved {
|
||||
week
|
||||
created
|
||||
resolved
|
||||
}
|
||||
cycleTime {
|
||||
averageDays
|
||||
p85Days
|
||||
}
|
||||
qa {
|
||||
failureRate
|
||||
}
|
||||
}
|
||||
}
|
||||
release(id: "…") {
|
||||
burnup {
|
||||
dates
|
||||
scope
|
||||
done
|
||||
}
|
||||
}
|
||||
issue(idOrKey: "SHOP-1") {
|
||||
status
|
||||
@@ -265,13 +505,76 @@ Los errores de dominio llegan como errores GraphQL con `extensions.code`:
|
||||
| `BUSINESS_RULE` | Regla de negocio (transición no permitida, release bloqueada…) |
|
||||
| `CONFLICT` | Otro cambio llegó antes (concurrencia optimista): reintenta |
|
||||
|
||||
## 3.3 REST
|
||||
## 3.3 REST con OpenAPI (para otras apps)
|
||||
|
||||
| Endpoint | Uso |
|
||||
| --------------------------- | ----------------------------------------------------------------------------------------------- |
|
||||
| `GET /api/health` | Estado de PostgreSQL, MongoDB y Redis (público) |
|
||||
| `GET /api/auth/config` | Configuración para iniciar sesión con Pireon desde el navegador (público) |
|
||||
| `POST /api/auth/token` | Proxy de `/oauth/token` de Pireon: `authorization_code`, `password` y `refresh_token` (público) |
|
||||
| `POST /api/webhooks/pireon` | Webhooks de Pireon firmados con HMAC (`user.deactivated`, `token.revoked`) |
|
||||
Los mismos casos de uso que GraphQL, como API REST documentada con OpenAPI 3.0, para que otras apps de la plataforma
|
||||
la consuman o generen un cliente. Son otro adaptador de entrada (`presenters/http`) que llama a los mismos
|
||||
`*.service.ts`: misma validación, permisos y errores.
|
||||
|
||||
Ver [Autenticación](05-autenticacion.md).
|
||||
| | |
|
||||
| ----------------- | ----------------------------------------------------------------------------------- |
|
||||
| Swagger UI | `/api/docs` (con «Authorize» contra Pireon) |
|
||||
| Documento OpenAPI | `/api/docs-json` (y `/api/docs-yaml`) |
|
||||
| Fichero exportado | `apps/api/openapi.json`: lo reescribe `nx serve api` al arrancar, como `schema.gql` |
|
||||
|
||||
### Autenticación: siempre a través de Pireon
|
||||
|
||||
Cualquier app o usuario que la consuma pasa por el OAuth de Pireon: todas las rutas, salvo `health`, `auth`, 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',
|
||||
events: {
|
||||
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);
|
||||
}
|
||||
|
||||
moveIssue(issueId: string, to: IssueStatus, reason?: string | null): void {
|
||||
moveIssue(issueId: string, to: string, reason?: string | null): void {
|
||||
this.dispatch.issueMoved({ issueId, to, reason });
|
||||
}
|
||||
}
|
||||
@@ -109,13 +109,15 @@ Qué gana la página:
|
||||
- **Un sitio para la lógica de presentación**: abrir o refiltrar el tablero, buscar usuarios a partir de 2 caracteres…
|
||||
- **Tests sin plantilla**: la facade se prueba con gateways falsos (`facades.spec.ts`).
|
||||
|
||||
| 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 |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| 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 |
|
||||
| `ProjectsFacade` | `ProjectsStore` | raíz | Mis proyectos; se actualiza cuando me añaden o quitan (`myProjectsChanged`) |
|
||||
| `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 |
|
||||
| `IssueFacade` | `IssueStore` | ruta `issues/:issueKey` | Ticket, comentarios en vivo, historial y todas sus acciones |
|
||||
|
||||
Las de ámbito de ruta se registran con `provideProjectFacade()`, `provideBoardFacade()` o `provideIssueFacade()` en los
|
||||
`providers` del componente de la página (store + facade) y desaparecen al salir (con sus subscriptions). Las páginas
|
||||
@@ -145,7 +147,9 @@ de identidad (`SessionTokenSource`) le pide el token para el cliente GraphQL.
|
||||
npx nx run web:codegen # regenera generated.ts (TypedDocumentNode)
|
||||
```
|
||||
|
||||
- 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`
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
| Ruta | Página |
|
||||
| -------------------------- | ----------------------------------------------- |
|
||||
| `/login` | Inicio de sesión (solo sin sesión) |
|
||||
| `/auth/callback` | Retorno de Pireon (PKCE) |
|
||||
| `/` | Mis proyectos |
|
||||
| `/p/:key/board` | Tablero kanban (`?release=` filtra por release) |
|
||||
| `/p/:key/issues/:issueKey` | Ticket |
|
||||
| `/p/:key/releases` | Releases |
|
||||
| `/p/:key/dashboard` | Seguimiento |
|
||||
| `/p/:key/activity` | Actividad en vivo |
|
||||
| `/p/:key/members` | Miembros |
|
||||
| Ruta | Página |
|
||||
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `/login` | Inicio de sesión (solo sin sesión) |
|
||||
| `/auth/callback` | Retorno de Pireon (PKCE) |
|
||||
| `/` | Inicio: «Ir a…» (`WEB-12`), mi trabajo (`ts-my-work`) y mis proyectos |
|
||||
| `/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/releases` | Releases |
|
||||
| `/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/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`).
|
||||
|
||||
@@ -29,7 +29,11 @@ sequenceDiagram
|
||||
- Se cachea el **hash SHA-256** del token, nunca el token en claro.
|
||||
- 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
|
||||
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`).
|
||||
- `@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
|
||||
la API: `POST /api/auth/token` hace de proxy hacia Pireon.
|
||||
|
||||
| Método | Necesita | Flujo |
|
||||
| ------------------------------------------------- | ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
||||
| **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`) |
|
||||
Cada cliente OAuth de Pireon admite **un único tipo de grant**, así que cada forma de entrar usa su cliente:
|
||||
|
||||
Las dos opciones necesitan el cliente OAuth: sin `PIREON_CLIENT_ID` la API responde `503` y el login avisa de que no
|
||||
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
|
||||
`Bearer`.)
|
||||
| Método | Cliente de Pireon (`grant_type`) | Variables | Flujo |
|
||||
| ------------------------------------------------- | -------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------- |
|
||||
| **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`) |
|
||||
| **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
|
||||
|
||||
1. Crea el cliente: `POST https://oauth.pireon.dev/api/clients` (con un token tuyo de Pireon, scope `account:write`).
|
||||
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).
|
||||
Con un token tuyo de Pireon (scope `account:write`), `POST https://oauth.pireon.dev/api/clients`:
|
||||
|
||||
`GET /api/auth/config` devuelve a la web lo necesario para iniciar el flujo (`authorizeUrl`, `clientId`, `scopes`,
|
||||
`passwordGrant`), o `null` si no hay cliente configurado; en ese caso el login muestra el aviso y no deja entrar.
|
||||
1. **Cliente para «Entrar con Pireon»**: `{"name": "TaskStream", "grant_type": "authorization_code", "confidential": true,
|
||||
"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
|
||||
|
||||
@@ -75,9 +122,16 @@ caduque la caché):
|
||||
1. Registra `https://<tu-api>/api/webhooks/pireon` con los eventos `user.deactivated` y `token.revoked`
|
||||
(`POST /api/clients/{client}/webhooks`).
|
||||
2. Pon el secret que devuelve en `PIREON_WEBHOOK_SECRET`.
|
||||
3. Si usas un cliente aparte para el acceso con contraseña (`PIREON_PASSWORD_CLIENT_ID`), registra el webhook también
|
||||
en ese cliente y pon su secret en `PIREON_PASSWORD_WEBHOOK_SECRET`. Pireon firma cada aviso con el secret del
|
||||
cliente que lo registró, y la revocación de un token solo la avisa el cliente que lo emitió: sin este paso, los
|
||||
tokens de quien entra con contraseña no se olvidan al revocarlos.
|
||||
|
||||
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.
|
||||
La API verifica la firma `X-Webhook-Signature` (HMAC-SHA256 del cuerpo, válida con cualquiera de los dos secrets),
|
||||
marca al usuario como inactivo y borra sus
|
||||
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)). Repetir un aviso (llega uno por
|
||||
cliente) no tiene efecto. Sin ningún secret, el endpoint rechaza todas las llamadas.
|
||||
|
||||
## 5.4 Usuarios en TaskStream
|
||||
|
||||
|
||||
@@ -25,16 +25,19 @@ la web y la API comparten origen igual que en Docker.
|
||||
|
||||
## 6.3 Comandos
|
||||
|
||||
| Comando | Qué hace |
|
||||
| -------------------------------- | --------------------------------------------------------------------------------- |
|
||||
| `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 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 web:codegen` | Regenera los tipos GraphQL del cliente desde `apps/api/schema.gql` |
|
||||
| `npx nx run api:prune` | Build de la API con `package.json` y lockfile podados (lo usa Docker) |
|
||||
| `npx nx graph` | Grafo de dependencias entre proyectos |
|
||||
| `npx nx test api-tracker-domain` | Tests de una librería concreta (`api-<contexto>-<capa>`, `web-<contexto>-<capa>`) |
|
||||
| Comando | Qué hace |
|
||||
| -------------------------------- | ----------------------------------------------------------------------------------- |
|
||||
| `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 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 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 graph` | Grafo de dependencias entre proyectos |
|
||||
| `npx nx test api-tracker-domain` | Tests de una librería concreta (`api-<contexto>-<capa>`, `web-<contexto>-<capa>`) |
|
||||
|
||||
### Tests
|
||||
|
||||
@@ -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`.
|
||||
- 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.
|
||||
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
|
||||
|
||||
@@ -86,8 +90,10 @@ exporta facades, una página tampoco puede tocar un store ni despachar eventos p
|
||||
| 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 |
|
||||
| `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 |
|
||||
| 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` |
|
||||
| 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,57 +9,108 @@ docker compose logs -f api
|
||||
docker compose down # parar (-v borra los datos)
|
||||
```
|
||||
|
||||
| Servicio | Imagen | Puerto | Función |
|
||||
| ---------- | --------------------------- | ------ | ----------------------------------------------------------------------- |
|
||||
| `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 |
|
||||
| `postgres` | postgres:17-alpine | 5432 | Event store y lookups |
|
||||
| `mongo` | mongo:8 | 27017 | Read model |
|
||||
| `redis` | redis:7-alpine (AOF) | 6379 | Stream de eventos, pub/sub y caché de sesiones |
|
||||
| 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 |
|
||||
| `api` | `task-stream-api` (Node 22) | 3000 | API NestJS |
|
||||
| `postgres` | postgres:17-alpine | 5432 | Event store y lookups |
|
||||
| `mongo` | mongo:8 | 27017 | Read model |
|
||||
| `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.
|
||||
|
||||
### 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**
|
||||
(`secrets.npmrc` → `~/.npmrc` en `docker-compose.yml`) y nunca queda en una capa.
|
||||
- **Instalación**: `npm ci` del workspace. `@pi/ui` viene del Nexus privado; el token llega como **build secret**
|
||||
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
|
||||
`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`.
|
||||
- **`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
|
||||
`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
|
||||
|
||||
| Variable | Por defecto | Descripción |
|
||||
| ------------------------------------------- | -------------------------- | ------------------------------------------------------------------------------------- |
|
||||
| `PORT` | `3000` | Puerto HTTP |
|
||||
| `NODE_ENV` | `development` | En `production` no se devuelven stacktraces |
|
||||
| `POSTGRES_URL` | — (obligatoria) | Event store y lookups |
|
||||
| `POSTGRES_SYNCHRONIZE` | `false` | Crea las tablas desde las entidades. Cómodo en desarrollo; en producción, migraciones |
|
||||
| `MONGO_URL` | — (obligatoria) | Read model |
|
||||
| `REDIS_URL` | — (obligatoria) | Stream, pub/sub y caché |
|
||||
| `EVENT_STREAM_KEY` | `task-stream:events` | Clave del Redis 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) |
|
||||
| `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_SCOPES` | `users:read` | Scopes que se piden al iniciar sesión |
|
||||
| `PIREON_PASSWORD_GRANT` | `true` | Permite el login con email y contraseña |
|
||||
| `PIREON_WEBHOOK_SECRET` | — | Activa y firma los webhooks de Pireon |
|
||||
| `PIREON_TIMEOUT_MS` | `5000` | Timeout de las llamadas 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_SCHEMA_FILE` | `apps/api/schema.gql` | Dónde escribir el SDL (vacío = no escribir; así está en Docker) |
|
||||
| Variable | Por defecto | Descripción |
|
||||
| ------------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `PORT` | `3000` | Puerto HTTP |
|
||||
| `NODE_ENV` | `development` | En `production` no se devuelven stacktraces |
|
||||
| `POSTGRES_URL` | — (obligatoria) | Event store y lookups |
|
||||
| `POSTGRES_SYNCHRONIZE` | `false` | Crea las tablas desde las entidades. Cómodo en desarrollo; en producción, migraciones |
|
||||
| `MONGO_URL` | — (obligatoria) | Read model |
|
||||
| `REDIS_URL` | — (obligatoria) | Stream, pub/sub y caché |
|
||||
| `EVENT_STREAM_KEY` | `task-stream:events` | Clave del Redis 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) |
|
||||
| `PIREON_BASE_URL` | `https://oauth.pireon.dev` | Servidor de identidad |
|
||||
| `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_PASSWORD_GRANT` | `true` | Permite el login con email y contraseña |
|
||||
| `PIREON_WEBHOOK_SECRET` | — | Activa y firma los webhooks de Pireon |
|
||||
| `PIREON_PASSWORD_WEBHOOK_SECRET` | — | El del webhook del cliente de contraseña, si es otro cliente |
|
||||
| `PIREON_TIMEOUT_MS` | `5000` | Timeout de las llamadas 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_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`).
|
||||
|
||||
## 7.3 Escalado
|
||||
|
||||
- **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.
|
||||
- **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
|
||||
@@ -71,7 +122,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.
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
@@ -82,3 +134,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;`
|
||||
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.
|
||||
|
||||
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
|
||||
`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; o los que sigo) |
|
||||
| | `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 (o responder a un comentario), corregir o borrar |
|
||||
| | `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.
|
||||
@@ -3,16 +3,17 @@
|
||||
TaskStream es una alternativa a Jira para equipos de desarrollo: proyectos, tickets (bugs, features, cambios de UI,
|
||||
tareas, QA), releases, comentarios y relaciones entre tickets, todo en tiempo real.
|
||||
|
||||
| Documento | Para qué |
|
||||
| ----------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| [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 |
|
||||
| [3. API](03-api.md) | Dominio (agregados, workflow, eventos) y la API GraphQL |
|
||||
| [4. Cliente web](04-cliente-web.md) | Angular, NgRx Signals con eventos, GraphQL y `@pi/ui` |
|
||||
| [5. Autenticación](05-autenticacion.md) | Pireon: tokens, login en el navegador, sesiones y webhooks |
|
||||
| [6. Desarrollo](06-desarrollo.md) | Puesta en marcha, comandos, tests y cómo añadir una funcionalidad |
|
||||
| [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) |
|
||||
| Documento | Para qué |
|
||||
| ----------------------------------------- | ------------------------------------------------------------------------- |
|
||||
| [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 |
|
||||
| [3. API](03-api.md) | Dominio (agregados, workflow, eventos) y la API GraphQL |
|
||||
| [4. Cliente web](04-cliente-web.md) | Angular, NgRx Signals con eventos, GraphQL y `@pi/ui` |
|
||||
| [5. Autenticación](05-autenticacion.md) | Pireon: tokens, login en el navegador, sesiones y webhooks |
|
||||
| [6. Desarrollo](06-desarrollo.md) | Puesta en marcha, comandos, tests y cómo añadir una funcionalidad |
|
||||
| [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) |
|
||||
| [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.
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
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.
|
||||
- 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`.
|
||||
|
||||
Ver [Autenticación](../../../../docs/05-autenticacion.md).
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/api/identity/application/src",
|
||||
"projectType": "library",
|
||||
"tags": [
|
||||
"scope:api-identity",
|
||||
"type:application"
|
||||
],
|
||||
"tags": ["scope:api-identity", "type:application"],
|
||||
"// targets": "to see all targets run: nx show project api-identity-application --web",
|
||||
"targets": {}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
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 { UserRegistry } from '../ports/user-registry.port';
|
||||
|
||||
@@ -22,6 +24,7 @@ export class HandleIdentityWebhookHandler
|
||||
constructor(
|
||||
private readonly cache: SessionCache,
|
||||
private readonly registry: UserRegistry,
|
||||
private readonly events: EventRecorder,
|
||||
) {}
|
||||
|
||||
async execute({ event, data }: HandleIdentityWebhookCommand): Promise<void> {
|
||||
@@ -50,8 +53,14 @@ export class HandleIdentityWebhookHandler
|
||||
|
||||
private async deactivate(userId: string): Promise<void> {
|
||||
const user = await this.registry.findById(userId);
|
||||
if (!user) return;
|
||||
if (!user?.isActive) return;
|
||||
user.deactivate();
|
||||
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",
|
||||
"sourceRoot": "libs/api/identity/domain/src",
|
||||
"projectType": "library",
|
||||
"tags": [
|
||||
"scope:api-identity",
|
||||
"type:domain"
|
||||
],
|
||||
"tags": ["scope:api-identity", "type:domain"],
|
||||
"// targets": "to see all targets run: nx show project api-identity-domain --web",
|
||||
"targets": {}
|
||||
}
|
||||
|
||||
@@ -17,11 +17,15 @@ export interface TokenSet {
|
||||
tokenType: string;
|
||||
}
|
||||
|
||||
/** What a browser needs to start the Authorization Code + PKCE flow. */
|
||||
/** How a browser can sign in. */
|
||||
export interface AuthorizationConfig {
|
||||
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[];
|
||||
/** Password grant can be disabled on the identity server. */
|
||||
/** Whether email + password sign-in is available. */
|
||||
passwordGrant: boolean;
|
||||
}
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/api/identity/infrastructure/src",
|
||||
"projectType": "library",
|
||||
"tags": [
|
||||
"scope:api-identity",
|
||||
"type:infrastructure"
|
||||
],
|
||||
"tags": ["scope:api-identity", "type:infrastructure"],
|
||||
"// targets": "to see all targets run: nx show project api-identity-infrastructure --web",
|
||||
"targets": {}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,10 @@ import { RedisSessionCache } from './redis/redis-session.cache';
|
||||
requestTimeoutMs: Number(config.get('PIREON_TIMEOUT_MS', 5000)),
|
||||
clientId: config.get<string>('PIREON_CLIENT_ID') || 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'))
|
||||
.split(/\s+/)
|
||||
.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 {
|
||||
ForbiddenException,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
@@ -28,6 +29,11 @@ interface PireonUser {
|
||||
app_roles?: string[];
|
||||
}
|
||||
|
||||
interface OAuthClient {
|
||||
id: string;
|
||||
secret: string | undefined;
|
||||
}
|
||||
|
||||
/** Driven adapter for the Pireon OAuth2 identity server (Laravel Passport). */
|
||||
@Injectable()
|
||||
export class PireonIdentityProvider extends IdentityProvider {
|
||||
@@ -69,14 +75,78 @@ export class PireonIdentityProvider extends IdentityProvider {
|
||||
}
|
||||
|
||||
async issueToken(grant: TokenGrant): Promise<TokenSet> {
|
||||
const { clientId, clientSecret, scopes } = this.config;
|
||||
if (!clientId)
|
||||
throw new ServiceUnavailableException(
|
||||
'Sign-in is not configured (PIREON_CLIENT_ID)',
|
||||
);
|
||||
const clients = this.clientsFor(grant);
|
||||
// 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();
|
||||
}
|
||||
|
||||
const body = new URLSearchParams({ client_id: clientId });
|
||||
if (clientSecret) body.set('client_secret', clientSecret);
|
||||
/**
|
||||
* Pireon clients allow a single grant type: authorization_code uses
|
||||
* PIREON_CLIENT_ID, password PIREON_PASSWORD_CLIENT_ID (or the former).
|
||||
*/
|
||||
private clientsFor(grant: TokenGrant): OAuthClient[] {
|
||||
const { clientId, clientSecret, passwordClientId, passwordClientSecret } =
|
||||
this.config;
|
||||
const code: OAuthClient | undefined = clientId
|
||||
? { id: clientId, secret: clientSecret }
|
||||
: undefined;
|
||||
const password: OAuthClient | undefined = passwordClientId
|
||||
? { id: passwordClientId, secret: passwordClientSecret }
|
||||
: code;
|
||||
|
||||
switch (grant.type) {
|
||||
case 'authorization_code':
|
||||
if (!code)
|
||||
throw new ServiceUnavailableException(
|
||||
'Sign-in with Pireon is not configured (PIREON_CLIENT_ID)',
|
||||
);
|
||||
return [code];
|
||||
case 'password':
|
||||
if (!this.config.passwordGrant)
|
||||
throw new ForbiddenException(
|
||||
'Email and password sign-in is disabled',
|
||||
);
|
||||
if (!password)
|
||||
throw new ServiceUnavailableException(
|
||||
'Email and password sign-in is not configured (PIREON_PASSWORD_CLIENT_ID)',
|
||||
);
|
||||
return [password];
|
||||
case 'refresh_token': {
|
||||
const candidates = [password, code].filter(
|
||||
(client): client is OAuthClient => !!client,
|
||||
);
|
||||
if (!candidates.length)
|
||||
throw new ServiceUnavailableException(
|
||||
'Sign-in is not configured (PIREON_CLIENT_ID)',
|
||||
);
|
||||
return candidates.filter(
|
||||
(client, i) => candidates.findIndex((c) => c.id === client.id) === i,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async requestToken(
|
||||
client: OAuthClient,
|
||||
grant: TokenGrant,
|
||||
): Promise<TokenSet> {
|
||||
const { scopes } = this.config;
|
||||
const body = new URLSearchParams({ client_id: client.id });
|
||||
if (client.secret) body.set('client_secret', client.secret);
|
||||
switch (grant.type) {
|
||||
case 'authorization_code':
|
||||
body.set('grant_type', 'authorization_code');
|
||||
@@ -114,12 +184,23 @@ export class PireonIdentityProvider extends IdentityProvider {
|
||||
throw new ServiceUnavailableException('Identity server unavailable');
|
||||
}
|
||||
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 {
|
||||
error?: string;
|
||||
error_description?: string;
|
||||
hint?: 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(
|
||||
error.error_description ?? error.message ?? 'Invalid credentials',
|
||||
error.hint ? `${description} (${error.hint})` : description,
|
||||
);
|
||||
}
|
||||
this.assertOk(response, 'POST /oauth/token');
|
||||
@@ -138,12 +219,14 @@ export class PireonIdentityProvider extends IdentityProvider {
|
||||
}
|
||||
|
||||
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 {
|
||||
authorizeUrl: new URL('/oauth/authorize', this.config.baseUrl).toString(),
|
||||
clientId: this.config.clientId,
|
||||
clientId: clientId ?? null,
|
||||
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. */
|
||||
sessionTtlSeconds: 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;
|
||||
/** Kept server-side: browsers sign in through /api/auth/token. */
|
||||
clientSecret?: string;
|
||||
/** Pireon OAuth client with grant type `password`; defaults to the one above. */
|
||||
passwordClientId?: string;
|
||||
passwordClientSecret?: string;
|
||||
scopes: string[];
|
||||
passwordGrant: boolean;
|
||||
}
|
||||
|
||||
@@ -7,8 +7,11 @@ Adaptadores de entrada de identidad.
|
||||
|
||||
## Contenido
|
||||
|
||||
- `auth/`: `AuthGuard` global, `@Public()`, `@CurrentUser()`, `@AccessToken()`, `@RequireAppRoles()` y `WsAuthenticator` (subscriptions).
|
||||
- `http/`: `AuthController` (`GET /api/auth/config`, `POST /api/auth/token`) y `PireonWebhookController` (firma HMAC).
|
||||
- `auth/`: `AuthGuard` global, `@Public()`, `@OptionalAuth()` / `@ApiOptionalPireonAuth()` (lecturas de proyectos
|
||||
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`.
|
||||
|
||||
## Comandos
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/api/identity/presenters/src",
|
||||
"projectType": "library",
|
||||
"tags": [
|
||||
"scope:api-identity",
|
||||
"type:presenters"
|
||||
],
|
||||
"tags": ["scope:api-identity", "type:presenters"],
|
||||
"// targets": "to see all targets run: nx show project api-identity-presenters --web",
|
||||
"targets": {}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,30 @@
|
||||
import {
|
||||
ExecutionContext,
|
||||
SetMetadata,
|
||||
applyDecorators,
|
||||
createParamDecorator,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOAuth2, ApiSecurity } from '@nestjs/swagger';
|
||||
import { getAuthRequest } from './auth-request';
|
||||
|
||||
export const IS_PUBLIC = 'identity:isPublic';
|
||||
export const IS_OPTIONAL_AUTH = 'identity:optionalAuth';
|
||||
export const REQUIRED_APP_ROLES = 'identity:appRoles';
|
||||
|
||||
/** Skips authentication (health checks, webhooks...). */
|
||||
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). */
|
||||
export const RequireAppRoles = (...roles: string[]) =>
|
||||
SetMetadata(REQUIRED_APP_ROLES, roles);
|
||||
|
||||
/** The authenticated caller (User). */
|
||||
/** The authenticated caller (User); undefined on anonymous `@OptionalAuth()` calls. */
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(_: unknown, context: ExecutionContext) => getAuthRequest(context).user,
|
||||
);
|
||||
@@ -25,3 +34,22 @@ export const AccessToken = createParamDecorator(
|
||||
(_: unknown, context: ExecutionContext) =>
|
||||
getAuthRequest(context).accessToken,
|
||||
);
|
||||
|
||||
/** OpenAPI security schemes (declared in apps/api/src/app/openapi.ts). */
|
||||
export const PIREON_OAUTH = 'pireon-oauth';
|
||||
export const PIREON_BEARER = 'pireon';
|
||||
|
||||
/**
|
||||
* Documents that the endpoints need a Pireon access token: obtained through
|
||||
* Pireon's OAuth (authorization code + PKCE, or password) or sent as a bearer
|
||||
* token by an app that already has one. Enforced by the global AuthGuard.
|
||||
*/
|
||||
export const ApiPireonAuth = () =>
|
||||
applyDecorators(ApiOAuth2([], PIREON_OAUTH), ApiBearerAuth(PIREON_BEARER));
|
||||
|
||||
/**
|
||||
* `@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,
|
||||
} from '@task-stream/api/identity/application';
|
||||
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()
|
||||
export class AuthGuard implements CanActivate {
|
||||
constructor(
|
||||
@@ -30,7 +37,13 @@ export class AuthGuard implements CanActivate {
|
||||
// WebSocket subscriptions are authenticated once in connection_init.
|
||||
if (!req.user) {
|
||||
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 {
|
||||
req.user = await this.identity.resolveSession(token);
|
||||
req.accessToken = token;
|
||||
|
||||
@@ -6,6 +6,15 @@ import {
|
||||
Post,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBody,
|
||||
ApiConsumes,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiProperty,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import {
|
||||
IdentityService,
|
||||
InvalidCredentialsError,
|
||||
@@ -18,10 +27,34 @@ import {
|
||||
import { Public } from '../auth/auth.decorators';
|
||||
import { IssueTokenDto } from './dto/issue-token.dto';
|
||||
|
||||
export class AuthorizationConfigDto {
|
||||
@ApiProperty({ example: 'https://oauth.pireon.dev/oauth/authorize' })
|
||||
authorizeUrl!: string;
|
||||
@ApiProperty({
|
||||
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
|
||||
* secret must stay server-side, so the web app goes through here.
|
||||
*/
|
||||
@ApiTags('Auth')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly identity: IdentityService) {}
|
||||
@@ -29,6 +62,10 @@ export class AuthController {
|
||||
/** What the web app needs to start Authorization Code + PKCE (null = not configured). */
|
||||
@Public()
|
||||
@Get('config')
|
||||
@ApiOperation({
|
||||
summary: 'How to sign in with Pireon (null when not configured)',
|
||||
})
|
||||
@ApiOkResponse({ type: AuthorizationConfigDto })
|
||||
config(): Promise<AuthorizationConfig | null> {
|
||||
return this.identity.authorizationConfig();
|
||||
}
|
||||
@@ -36,6 +73,18 @@ export class AuthController {
|
||||
@Public()
|
||||
@Post('token')
|
||||
@HttpCode(200)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Exchange a grant for Pireon tokens (proxy of Pireon /oauth/token)',
|
||||
description:
|
||||
'For browsers (Pireon has no CORS) and Swagger UI: adds the client secret ' +
|
||||
'server-side. Apps with their own Pireon OAuth client can get tokens from ' +
|
||||
'Pireon directly; any valid Pireon access token is accepted by this API.',
|
||||
})
|
||||
@ApiConsumes('application/json', 'application/x-www-form-urlencoded')
|
||||
@ApiBody({ type: IssueTokenDto })
|
||||
@ApiOkResponse({ type: TokenResponseDto })
|
||||
@ApiUnauthorizedResponse({ description: 'Pireon rejected the grant' })
|
||||
async token(@Body() dto: IssueTokenDto) {
|
||||
try {
|
||||
return toResponse(await this.identity.issueToken(toGrant(dto)));
|
||||
|
||||
@@ -1,35 +1,48 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsIn, IsNotEmpty, IsString, IsUrl, ValidateIf } from 'class-validator';
|
||||
|
||||
/** OAuth2 token request (RFC 6749), as JSON or application/x-www-form-urlencoded. */
|
||||
export class IssueTokenDto {
|
||||
@ApiProperty({ enum: ['authorization_code', 'password', 'refresh_token'] })
|
||||
@IsIn(['authorization_code', 'password', 'refresh_token'])
|
||||
grant_type!: 'authorization_code' | 'password' | 'refresh_token';
|
||||
|
||||
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'authorization_code')
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@ApiPropertyOptional({
|
||||
description: 'authorization_code: code returned by Pireon',
|
||||
})
|
||||
code?: string;
|
||||
|
||||
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'authorization_code')
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@ApiPropertyOptional({ description: 'authorization_code: PKCE verifier' })
|
||||
code_verifier?: string;
|
||||
|
||||
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'authorization_code')
|
||||
@IsUrl({ require_tld: false })
|
||||
@ApiPropertyOptional({
|
||||
description: 'authorization_code: redirect URI used to authorize',
|
||||
})
|
||||
redirect_uri?: string;
|
||||
|
||||
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'password')
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@ApiPropertyOptional({ description: 'password: Pireon email' })
|
||||
username?: string;
|
||||
|
||||
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'password')
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@ApiPropertyOptional({ description: 'password: Pireon password' })
|
||||
password?: string;
|
||||
|
||||
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'refresh_token')
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@ApiPropertyOptional({ description: 'refresh_token' })
|
||||
refresh_token?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { IdentityService } from '@task-stream/api/identity/application';
|
||||
import { createHmac } from 'node:crypto';
|
||||
import { PireonWebhookController } from './pireon-webhook.controller';
|
||||
|
||||
describe('PireonWebhookController', () => {
|
||||
const handled: string[] = [];
|
||||
const controller = (secrets: Record<string, string>) =>
|
||||
new PireonWebhookController(
|
||||
{
|
||||
handleWebhook: async (event: string) => {
|
||||
handled.push(event);
|
||||
},
|
||||
} as unknown as IdentityService,
|
||||
{ get: (key: string) => secrets[key] } as unknown as ConfigService,
|
||||
);
|
||||
const body = { event: 'token.revoked', data: { token_id: 't-1' } };
|
||||
const raw = Buffer.from(JSON.stringify(body));
|
||||
const signed = (secret: string) =>
|
||||
createHmac('sha256', secret).update(raw).digest('hex');
|
||||
const request = { rawBody: raw, body } as never;
|
||||
|
||||
beforeEach(() => (handled.length = 0));
|
||||
|
||||
it("accepts a delivery signed with either Pireon client's secret", async () => {
|
||||
const both = controller({
|
||||
PIREON_WEBHOOK_SECRET: 'web-secret',
|
||||
PIREON_PASSWORD_WEBHOOK_SECRET: 'password-secret',
|
||||
});
|
||||
await both.receive(request, signed('web-secret'));
|
||||
await both.receive(request, `sha256=${signed('password-secret')}`);
|
||||
expect(handled).toEqual(['token.revoked', 'token.revoked']);
|
||||
});
|
||||
|
||||
it('refuses any other signature, and everything when no secret is set', async () => {
|
||||
await expect(
|
||||
controller({ PIREON_WEBHOOK_SECRET: 'web-secret' }).receive(
|
||||
request,
|
||||
signed('password-secret'),
|
||||
),
|
||||
).rejects.toThrow(UnauthorizedException);
|
||||
await expect(
|
||||
controller({}).receive(request, signed('web-secret')),
|
||||
).rejects.toThrow(/disabled/);
|
||||
expect(handled).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -9,12 +9,26 @@ import {
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
ApiHeader,
|
||||
ApiNoContentResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { IdentityService } from '@task-stream/api/identity/application';
|
||||
import { Request } from 'express';
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
import { Public } from '../auth/auth.decorators';
|
||||
|
||||
/** Receives `user.deactivated` / `token.revoked` from Pireon. */
|
||||
/**
|
||||
* Receives `user.deactivated` / `token.revoked` from Pireon.
|
||||
*
|
||||
* Pireon signs each webhook with the secret of the client that registered
|
||||
* it, and this API has two (the web app's and the password grant's): a
|
||||
* delivery is good if it verifies with either.
|
||||
*/
|
||||
@ApiTags('Webhooks')
|
||||
@Controller('webhooks/pireon')
|
||||
export class PireonWebhookController {
|
||||
constructor(
|
||||
@@ -25,6 +39,20 @@ export class PireonWebhookController {
|
||||
@Public()
|
||||
@Post()
|
||||
@HttpCode(204)
|
||||
@ApiOperation({
|
||||
summary: 'Pireon events (called by Pireon, not by apps)',
|
||||
description:
|
||||
"`user.deactivated` closes the user's sessions; `token.revoked` forgets the token. " +
|
||||
'Disabled unless PIREON_WEBHOOK_SECRET or PIREON_PASSWORD_WEBHOOK_SECRET is set.',
|
||||
})
|
||||
@ApiHeader({
|
||||
name: 'X-Webhook-Signature',
|
||||
description:
|
||||
'HMAC-SHA256 of the raw body with the secret of the Pireon client that registered the webhook (hex)',
|
||||
required: true,
|
||||
})
|
||||
@ApiNoContentResponse({ description: 'Processed' })
|
||||
@ApiUnauthorizedResponse({ description: 'Missing or invalid signature' })
|
||||
async receive(
|
||||
@Req() req: RawBodyRequest<Request>,
|
||||
@Headers('x-webhook-signature') signature?: string,
|
||||
@@ -42,13 +70,16 @@ export class PireonWebhookController {
|
||||
await this.identity.handleWebhook(event, data);
|
||||
}
|
||||
|
||||
/** X-Webhook-Signature = HMAC-SHA256(raw body, PIREON_WEBHOOK_SECRET). */
|
||||
/** X-Webhook-Signature = HMAC-SHA256(raw body, the client's webhook secret). */
|
||||
private verifySignature(
|
||||
rawBody: Buffer | undefined,
|
||||
signature: string | undefined,
|
||||
): void {
|
||||
const secret = this.config.get<string>('PIREON_WEBHOOK_SECRET');
|
||||
if (!secret) {
|
||||
const secrets = [
|
||||
this.config.get<string>('PIREON_WEBHOOK_SECRET'),
|
||||
this.config.get<string>('PIREON_PASSWORD_WEBHOOK_SECRET'),
|
||||
].filter((secret): secret is string => !!secret);
|
||||
if (!secrets.length) {
|
||||
throw new UnauthorizedException(
|
||||
'Webhooks are disabled (PIREON_WEBHOOK_SECRET not set)',
|
||||
);
|
||||
@@ -56,11 +87,16 @@ export class PireonWebhookController {
|
||||
if (!rawBody || !signature)
|
||||
throw new UnauthorizedException('Missing signature');
|
||||
|
||||
const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
|
||||
const received = signature.replace(/^sha256=/, '');
|
||||
const valid =
|
||||
received.length === expected.length &&
|
||||
timingSafeEqual(Buffer.from(received), Buffer.from(expected));
|
||||
const received = Buffer.from(signature.replace(/^sha256=/, ''));
|
||||
const valid = secrets.some((secret) => {
|
||||
const expected = Buffer.from(
|
||||
createHmac('sha256', secret).update(rawBody).digest('hex'),
|
||||
);
|
||||
return (
|
||||
received.length === expected.length &&
|
||||
timingSafeEqual(received, expected)
|
||||
);
|
||||
});
|
||||
if (!valid) throw new UnauthorizedException('Invalid signature');
|
||||
}
|
||||
}
|
||||
|
||||
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 { AuthController } from './http/auth.controller';
|
||||
import { PireonWebhookController } from './http/pireon-webhook.controller';
|
||||
import { UsersController } from './http/users.controller';
|
||||
|
||||
/** Driving adapters of the identity context: global auth guard, webhook, GraphQL. */
|
||||
/** Driving adapters of the identity context: global auth guard, REST (auth, users, webhook), GraphQL. */
|
||||
@Module({
|
||||
controllers: [AuthController, PireonWebhookController],
|
||||
controllers: [AuthController, UsersController, PireonWebhookController],
|
||||
providers: [
|
||||
{ provide: APP_GUARD, useClass: AuthGuard },
|
||||
WsAuthenticator,
|
||||
|
||||
@@ -9,7 +9,10 @@ Contrato del event store y reconstrucción de agregados.
|
||||
|
||||
- `EventStore` (puerto): `persist` y `getEventsByStreamId`.
|
||||
- `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
|
||||
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/api/shared/application/src",
|
||||
"projectType": "library",
|
||||
"tags": [
|
||||
"scope:api-shared",
|
||||
"type:application"
|
||||
],
|
||||
"tags": ["scope:api-shared", "type:application"],
|
||||
"// targets": "to see all targets run: nx show project api-shared-application --web",
|
||||
"targets": {}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './lib/ports/event-store';
|
||||
export * from './lib/aggregate-rehydrator';
|
||||
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 { CqrsModule } from '@nestjs/cqrs';
|
||||
import { AggregateRehydrator } from './aggregate-rehydrator';
|
||||
import { EventRecorder } from './event-recorder';
|
||||
|
||||
/**
|
||||
* Shared building blocks for every bounded context: the event store (from
|
||||
* the infrastructure module plugged in by the composition root) and the
|
||||
* AggregateRehydrator.
|
||||
* the infrastructure module plugged in by the composition root), the
|
||||
* AggregateRehydrator and the EventRecorder.
|
||||
*/
|
||||
@Global()
|
||||
@Module({})
|
||||
@@ -16,8 +17,8 @@ export class SharedModule {
|
||||
return {
|
||||
module: SharedModule,
|
||||
imports: [CqrsModule, infrastructureModule],
|
||||
providers: [AggregateRehydrator],
|
||||
exports: [infrastructureModule, AggregateRehydrator],
|
||||
providers: [AggregateRehydrator, EventRecorder],
|
||||
exports: [infrastructureModule, AggregateRehydrator, EventRecorder],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/api/shared/domain/src",
|
||||
"projectType": "library",
|
||||
"tags": [
|
||||
"scope:api-shared",
|
||||
"type:domain"
|
||||
],
|
||||
"tags": ["scope:api-shared", "type:domain"],
|
||||
"// targets": "to see all targets run: nx show project api-shared-domain --web",
|
||||
"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`).
|
||||
- `EventSerializer` / `EventDeserializer` y `EventStorePublisher` (sustituye al publisher del `EventBus`: `commit()` persiste).
|
||||
- `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`.
|
||||
|
||||
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",
|
||||
"sourceRoot": "libs/api/shared/infrastructure/src",
|
||||
"projectType": "library",
|
||||
"tags": [
|
||||
"scope:api-shared",
|
||||
"type:infrastructure"
|
||||
],
|
||||
"tags": ["scope:api-shared", "type:infrastructure"],
|
||||
"// targets": "to see all targets run: nx show project api-shared-infrastructure --web",
|
||||
"targets": {}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,20 @@ import {
|
||||
Type,
|
||||
} from '@nestjs/common';
|
||||
import { DiscoveryService } from '@nestjs/core';
|
||||
import { IEventHandler } from '@nestjs/cqrs';
|
||||
import { SerializableEvent } from '@task-stream/api/shared/domain';
|
||||
import {
|
||||
CommandBus,
|
||||
ICommand,
|
||||
IEvent,
|
||||
IEventHandler,
|
||||
ISaga,
|
||||
} from '@nestjs/cqrs';
|
||||
import {
|
||||
ConcurrencyError,
|
||||
DomainError,
|
||||
SerializableEvent,
|
||||
} from '@task-stream/api/shared/domain';
|
||||
import { hostname } from 'node:os';
|
||||
import { lastValueFrom, of, toArray } from 'rxjs';
|
||||
import { EventStreamConsumer } from '../event-stream/event-stream.consumer';
|
||||
import {
|
||||
EVENT_STREAM_OPTIONS,
|
||||
@@ -20,8 +31,12 @@ import { EventDeserializer } from './deserializers/event.deserializer';
|
||||
|
||||
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 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
|
||||
@@ -32,6 +47,13 @@ const EVENTS_HANDLER_METADATA = '__eventsHandler__';
|
||||
* time, so events of an aggregate are projected in order, and the entry is
|
||||
* acknowledged in Redis only once every handler succeeded (otherwise it
|
||||
* 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()
|
||||
export class EventsBridge
|
||||
@@ -40,6 +62,7 @@ export class EventsBridge
|
||||
private readonly logger = new Logger(EventsBridge.name);
|
||||
private consumer?: EventStreamConsumer;
|
||||
private readonly handlers = new Map<Type, IEventHandler[]>();
|
||||
private readonly sagas: { name: string; saga: ISaga }[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly connections: RedisConnectionFactory,
|
||||
@@ -47,6 +70,7 @@ export class EventsBridge
|
||||
@Inject(EVENTS_BRIDGE_ENABLED) private readonly enabled: boolean,
|
||||
private readonly eventDeserializer: EventDeserializer,
|
||||
private readonly discovery: DiscoveryService,
|
||||
private readonly commandBus: CommandBus,
|
||||
) {}
|
||||
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
@@ -55,6 +79,7 @@ export class EventsBridge
|
||||
return;
|
||||
}
|
||||
this.registerEventHandlers();
|
||||
this.registerSagas();
|
||||
this.consumer = new EventStreamConsumer(
|
||||
this.connections.create('events-bridge'),
|
||||
{
|
||||
@@ -66,7 +91,7 @@ export class EventsBridge
|
||||
);
|
||||
await this.consumer.start();
|
||||
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> {
|
||||
const { data: event } = this.eventDeserializer.deserialize(stored);
|
||||
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) ?? []) {
|
||||
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
|
||||
|
||||
- `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
|
||||
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/api/shared/presenters/src",
|
||||
"projectType": "library",
|
||||
"tags": [
|
||||
"scope:api-shared",
|
||||
"type:presenters"
|
||||
],
|
||||
"tags": ["scope:api-shared", "type:presenters"],
|
||||
"// targets": "to see all targets run: nx show project api-shared-presenters --web",
|
||||
"targets": {}
|
||||
}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from './lib/filters/domain-error.filter';
|
||||
export * from './lib/http/api-errors';
|
||||
|
||||
59
libs/api/shared/presenters/src/lib/http/api-errors.ts
Normal file
59
libs/api/shared/presenters/src/lib/http/api-errors.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { applyDecorators } from '@nestjs/common';
|
||||
import {
|
||||
ApiBadRequestResponse,
|
||||
ApiConflictResponse,
|
||||
ApiForbiddenResponse,
|
||||
ApiNotFoundResponse,
|
||||
ApiProperty,
|
||||
ApiPropertyOptional,
|
||||
ApiUnauthorizedResponse,
|
||||
ApiUnprocessableEntityResponse,
|
||||
} from '@nestjs/swagger';
|
||||
|
||||
/** Error body of the REST API (`DomainErrorFilter`, guards and validation). */
|
||||
export class ApiErrorDto {
|
||||
@ApiProperty({ example: 404 })
|
||||
statusCode!: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Domain error code',
|
||||
enum: ['NOT_FOUND', 'FORBIDDEN', 'VALIDATION', 'CONFLICT', 'BUSINESS_RULE'],
|
||||
})
|
||||
code?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Human message (a list of messages for invalid input)',
|
||||
oneOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }],
|
||||
example: 'Issue not found',
|
||||
})
|
||||
message!: string | string[];
|
||||
}
|
||||
|
||||
/** 400 / 401 / 403 / 404: what any authenticated read can answer. */
|
||||
export const ApiReadErrors = () =>
|
||||
applyDecorators(
|
||||
ApiBadRequestResponse({ type: ApiErrorDto, description: 'Invalid input' }),
|
||||
ApiUnauthorizedResponse({
|
||||
type: ApiErrorDto,
|
||||
description: 'Missing or rejected Pireon token',
|
||||
}),
|
||||
ApiForbiddenResponse({
|
||||
type: ApiErrorDto,
|
||||
description: 'Not a member of the project (or not allowed)',
|
||||
}),
|
||||
ApiNotFoundResponse({ type: ApiErrorDto, description: 'Not found' }),
|
||||
);
|
||||
|
||||
/** Reads + 409 (concurrent change) and 422 (business rule) for commands. */
|
||||
export const ApiCommandErrors = () =>
|
||||
applyDecorators(
|
||||
ApiReadErrors(),
|
||||
ApiConflictResponse({
|
||||
type: ApiErrorDto,
|
||||
description: 'Someone changed it at the same time: retry',
|
||||
}),
|
||||
ApiUnprocessableEntityResponse({
|
||||
type: ApiErrorDto,
|
||||
description: 'Business rule (e.g. a transition the workflow forbids)',
|
||||
}),
|
||||
);
|
||||
@@ -7,10 +7,18 @@ Casos de uso del tracker (CQRS).
|
||||
|
||||
## 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()`.
|
||||
- `queries/`: un fichero por query y otro por handler; leen de los puertos `Find*Repository` y comprueban la membresía (`ReadAccess`).
|
||||
- `event-handlers/`: proyecciones (`@EventsHandler`) hacia el read model, notificaciones en tiempo real y feed de actividad.
|
||||
- `queries/`: un fichero por query y otro por handler; leen de los puertos `Find*Repository` y comprueban el acceso
|
||||
(`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`.
|
||||
- `TrackerApplicationModule.withInfrastructure(...)`.
|
||||
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/api/tracker/application/src",
|
||||
"projectType": "library",
|
||||
"tags": [
|
||||
"scope:api-tracker",
|
||||
"type:application"
|
||||
],
|
||||
"tags": ["scope:api-tracker", "type:application"],
|
||||
"// targets": "to see all targets run: nx show project api-tracker-application --web",
|
||||
"targets": {}
|
||||
}
|
||||
|
||||
@@ -6,4 +6,21 @@ export * from './lib/issues.service';
|
||||
export * from './lib/comments.service';
|
||||
export * from './lib/releases.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';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user