Compare commits
122 Commits
master
...
429b52e6f5
| Author | SHA1 | Date | |
|---|---|---|---|
| 429b52e6f5 | |||
| 3c211f0e56 | |||
| 909edc9f4d | |||
| c27aa4b1d7 | |||
| 79b8094011 | |||
| 8ed16f133e | |||
| 3a46a236e4 | |||
| 93d028e870 | |||
| 172c53d21d | |||
| dfb5f973f4 | |||
| b88296c22b | |||
| d79ef418d3 | |||
| 179d6a5e94 | |||
| 199b60e4ce | |||
| c0b0417b6d | |||
| 33c593521e | |||
| 034704e46f | |||
| 42a21412d9 | |||
| 70e4fbd399 | |||
| 8f83fdefb6 | |||
| dfa42a8a1a | |||
| 423f82c1a3 | |||
| a32c937765 | |||
| d66b1f3ece | |||
| de9c8a7780 | |||
| 4969fb3f6d | |||
| e10c9b84bf | |||
| fc90c79f7f | |||
| 8fe581ff3e | |||
| 4dc673997d | |||
| 1101b6e926 | |||
| 9ccc3b5967 | |||
| f448207e88 | |||
| d0efaeba80 | |||
| 9044690348 | |||
| 0564810d36 | |||
| 2cbe6af878 | |||
| 2add1b6a31 | |||
| e006d245d7 | |||
| f4e40ffabc | |||
| a32b669c8b | |||
| 0946e9517d | |||
| affce615a7 | |||
| 8bf7d47b2e | |||
| f60d4c8346 | |||
| 869acd3f1f | |||
| 4acf629106 | |||
| e598f76e1c | |||
| 5b0396bc10 | |||
| e14f445e6b | |||
| 76312c079d | |||
| 0d158a2ed7 | |||
| fe03c5bda8 | |||
| 86e313efaa | |||
| e5f4e6c683 | |||
| 21bc598590 | |||
| f7e850068b | |||
| 233c46e9fa | |||
| 5ee627e689 | |||
| f45069a327 | |||
| 993f80b4b9 | |||
| 0a73eda9b2 | |||
| 8dfd35e611 | |||
| 1550332d41 | |||
| 2c501a50d0 | |||
| 10c8f21ff4 | |||
| 836c6f63bc | |||
| 9892443eda | |||
| 1383e15f3f | |||
| 3d8800e58c | |||
| 2030fe0cd9 | |||
| c91df110f8 | |||
| 74903f7842 | |||
| f2001aca88 | |||
| 298766ad1a | |||
| 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 |
52
.env.example
52
.env.example
@@ -16,14 +16,60 @@ EVENTS_BRIDGE_ENABLED=true
|
|||||||
|
|
||||||
# Pireon identity server (https://oauth.pireon.dev/api/documentation)
|
# Pireon identity server (https://oauth.pireon.dev/api/documentation)
|
||||||
PIREON_BASE_URL=https://oauth.pireon.dev
|
PIREON_BASE_URL=https://oauth.pireon.dev
|
||||||
# Secret returned when registering the webhook (POST /api/clients/{client}/webhooks)
|
# Secret returned when registering the webhook (POST /api/clients/{client}/webhooks).
|
||||||
|
# Pireon signs with the secret of the client that registered it: one per client.
|
||||||
PIREON_WEBHOOK_SECRET=
|
PIREON_WEBHOOK_SECRET=
|
||||||
# OAuth client registered for task-stream (POST /api/clients). Needed by the web app to sign in.
|
# The password client's webhook, if it has its own
|
||||||
# Redirect URI to register: http://localhost:4200/auth/callback
|
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_ID=
|
||||||
PIREON_CLIENT_SECRET=
|
PIREON_CLIENT_SECRET=
|
||||||
|
# password → email + password sign-in (defaults to the client above when empty)
|
||||||
|
PIREON_PASSWORD_CLIENT_ID=
|
||||||
|
PIREON_PASSWORD_CLIENT_SECRET=
|
||||||
PIREON_SCOPES=users:read
|
PIREON_SCOPES=users:read
|
||||||
PIREON_PASSWORD_GRANT=true
|
PIREON_PASSWORD_GRANT=true
|
||||||
AUTH_SESSION_TTL_SECONDS=60
|
AUTH_SESSION_TTL_SECONDS=60
|
||||||
|
|
||||||
GRAPHQL_PLAYGROUND=true
|
GRAPHQL_PLAYGROUND=true
|
||||||
|
|
||||||
|
# REST + OpenAPI for other apps (Swagger UI at /api/docs). The document is also written to OPENAPI_FILE.
|
||||||
|
SWAGGER_ENABLED=true
|
||||||
|
OPENAPI_FILE=apps/api/openapi.json
|
||||||
|
|
||||||
|
# Files attached to tickets: folder (a volume in Docker) and size limit per file.
|
||||||
|
ATTACHMENTS_DIR=data/attachments
|
||||||
|
ATTACHMENTS_MAX_MB=25
|
||||||
|
|
||||||
|
# Optional e-mail copies of notifications. Without SMTP_URL nothing is sent.
|
||||||
|
SMTP_URL=
|
||||||
|
MAIL_FROM=
|
||||||
|
WEB_URL=http://localhost:4200
|
||||||
|
|
||||||
|
# Optional push notifications (Web Push). Without both keys nothing is sent.
|
||||||
|
# Make them once with `npx web-push generate-vapid-keys` (changing them drops
|
||||||
|
# every device's subscription). VAPID_SUBJECT: a mailto: or https: contact.
|
||||||
|
VAPID_PUBLIC_KEY=
|
||||||
|
VAPID_PRIVATE_KEY=
|
||||||
|
VAPID_SUBJECT=mailto:no-reply@pireon.dev
|
||||||
|
|
||||||
|
# Outgoing webhooks refuse hosts on this machine or its private network unless true.
|
||||||
|
WEBHOOKS_ALLOW_PRIVATE=false
|
||||||
|
|
||||||
|
# Due-date reminders (hourly). The day changes at midnight in this time zone.
|
||||||
|
REMINDERS_ENABLED=true
|
||||||
|
REMINDERS_TIMEZONE=Europe/Madrid
|
||||||
|
|
||||||
|
# Docker builds only: token of Pireon's Nexus for @pi/ui (build secret, never stored in an image).
|
||||||
|
# Locally: export NEXUS_TOKEN=$(grep '^//nexus.pireon.dev/repository/npm-releases/:_authToken=' ~/.npmrc | cut -d= -f2-)
|
||||||
|
NEXUS_TOKEN=
|
||||||
|
|
||||||
|
# MCP server (apps/mcp, docs/09-mcp.md). Port: not PORT, which is the API's.
|
||||||
|
MCP_PORT=3010
|
||||||
|
TASKSTREAM_API_URL=http://localhost:3000
|
||||||
|
# Domains it answers on, comma-separated (DNS rebinding protection). Empty: localhost only.
|
||||||
|
MCP_ALLOWED_HOSTS=
|
||||||
|
# error | warn | log | debug | verbose (warn: the library logs every tool on every call otherwise)
|
||||||
|
MCP_LOG_LEVEL=warn
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -53,3 +53,6 @@ Thumbs.db
|
|||||||
__screenshots__/
|
__screenshots__/
|
||||||
|
|
||||||
vitest.config.*.timestamp*!apps/web/.env
|
vitest.config.*.timestamp*!apps/web/.env
|
||||||
|
|
||||||
|
# Attached files in development (ATTACHMENTS_DIR)
|
||||||
|
/data
|
||||||
|
|||||||
@@ -5,3 +5,10 @@
|
|||||||
/.nx/workspace-data
|
/.nx/workspace-data
|
||||||
package-lock.json
|
package-lock.json
|
||||||
.angular
|
.angular
|
||||||
|
|
||||||
|
# Generated by the API on startup (nx serve api): keep them byte-for-byte as written
|
||||||
|
apps/api/openapi.json
|
||||||
|
apps/api/schema.gql
|
||||||
|
|
||||||
|
# Generated from apps/api/openapi.json (nx run mcp:openapi-types)
|
||||||
|
apps/mcp/src/app/api/taskstream.schema.ts
|
||||||
|
|||||||
28
.vscode/launch.json
vendored
28
.vscode/launch.json
vendored
@@ -6,24 +6,36 @@
|
|||||||
"request": "launch",
|
"request": "launch",
|
||||||
"name": "Debug api with Nx",
|
"name": "Debug api with Nx",
|
||||||
"runtimeExecutable": "npx",
|
"runtimeExecutable": "npx",
|
||||||
"runtimeArgs": [
|
"runtimeArgs": ["nx", "serve", "api"],
|
||||||
"nx",
|
|
||||||
"serve",
|
|
||||||
"api"
|
|
||||||
],
|
|
||||||
"env": {
|
"env": {
|
||||||
"NODE_OPTIONS": "--inspect=9229"
|
"NODE_OPTIONS": "--inspect=9229"
|
||||||
},
|
},
|
||||||
"console": "integratedTerminal",
|
"console": "integratedTerminal",
|
||||||
"internalConsoleOptions": "neverOpen",
|
"internalConsoleOptions": "neverOpen",
|
||||||
"skipFiles": [
|
"skipFiles": ["<node_internals>/**"],
|
||||||
"<node_internals>/**"
|
|
||||||
],
|
|
||||||
"sourceMaps": true,
|
"sourceMaps": true,
|
||||||
"outFiles": [
|
"outFiles": [
|
||||||
"${workspaceFolder}/apps/api/dist/**/*.(m|c|)js",
|
"${workspaceFolder}/apps/api/dist/**/*.(m|c|)js",
|
||||||
"!**/node_modules/**"
|
"!**/node_modules/**"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "node",
|
||||||
|
"request": "launch",
|
||||||
|
"name": "Debug mcp with Nx",
|
||||||
|
"runtimeExecutable": "npx",
|
||||||
|
"runtimeArgs": ["nx", "serve", "mcp"],
|
||||||
|
"env": {
|
||||||
|
"NODE_OPTIONS": "--inspect=9230"
|
||||||
|
},
|
||||||
|
"console": "integratedTerminal",
|
||||||
|
"internalConsoleOptions": "neverOpen",
|
||||||
|
"skipFiles": ["<node_internals>/**"],
|
||||||
|
"sourceMaps": true,
|
||||||
|
"outFiles": [
|
||||||
|
"${workspaceFolder}/apps/mcp/dist/**/*.(m|c|)js",
|
||||||
|
"!**/node_modules/**"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
34
Dockerfile
34
Dockerfile
@@ -1,16 +1,21 @@
|
|||||||
# syntax=docker/dockerfile:1.7
|
# syntax=docker/dockerfile:1.7
|
||||||
# TaskStream images (one Dockerfile, two targets):
|
# TaskStream images (one Dockerfile, three targets):
|
||||||
# docker compose up -d --build → api + web + infrastructure
|
# docker compose up -d --build → api + web + infrastructure
|
||||||
|
# docker compose --profile mcp up -d --build → plus the MCP server
|
||||||
#
|
#
|
||||||
# @pi/ui comes from Pireon's private Nexus. Its token is passed as a build
|
# @pi/ui comes from Pireon's private Nexus. Its token is passed as a build
|
||||||
# secret (compose: `secrets.npmrc` → ~/.npmrc) and never lands in a layer.
|
# secret (compose: `secrets.nexus_token` ← $NEXUS_TOKEN) and never lands in a
|
||||||
|
# layer: it is written to a temporary npmrc that the same RUN deletes.
|
||||||
|
|
||||||
# ---- Workspace dependencies (shared by both builds) --------------------
|
# ---- Workspace dependencies (shared by both builds) --------------------
|
||||||
FROM node:22-alpine AS deps
|
FROM node:22-alpine AS deps
|
||||||
WORKDIR /workspace
|
WORKDIR /workspace
|
||||||
ENV NX_DAEMON=false NX_NO_CLOUD=true NX_SKIP_NX_CACHE=true
|
ENV NX_DAEMON=false NX_NO_CLOUD=true NX_SKIP_NX_CACHE=true
|
||||||
COPY package.json package-lock.json .npmrc ./
|
COPY package.json package-lock.json .npmrc ./
|
||||||
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci --no-audit --no-fund
|
RUN --mount=type=secret,id=nexus_token,required=true \
|
||||||
|
printf '//nexus.pireon.dev/repository/npm-releases/:_authToken=%s\n' "$(cat /run/secrets/nexus_token)" > /tmp/auth.npmrc \
|
||||||
|
&& npm ci --no-audit --no-fund --userconfig /tmp/auth.npmrc \
|
||||||
|
&& rm -f /tmp/auth.npmrc
|
||||||
|
|
||||||
# ---- API: Nest bundle + pruned package.json / lockfile -----------------
|
# ---- API: Nest bundle + pruned package.json / lockfile -----------------
|
||||||
FROM deps AS build-api
|
FROM deps AS build-api
|
||||||
@@ -25,12 +30,33 @@ ENV NODE_ENV=production PORT=3000
|
|||||||
COPY --from=build-api /workspace/dist/apps/api/package.json /workspace/dist/apps/api/package-lock.json ./
|
COPY --from=build-api /workspace/dist/apps/api/package.json /workspace/dist/apps/api/package-lock.json ./
|
||||||
RUN npm ci --omit=dev --ignore-scripts --no-audit --no-fund && npm cache clean --force
|
RUN npm ci --omit=dev --ignore-scripts --no-audit --no-fund && npm cache clean --force
|
||||||
COPY --from=build-api /workspace/dist/apps/api/main.js ./
|
COPY --from=build-api /workspace/dist/apps/api/main.js ./
|
||||||
|
# Attached files (a volume in docker-compose), writable by the user that runs the API.
|
||||||
|
RUN mkdir -p /data/attachments && chown node:node /data/attachments
|
||||||
|
ENV ATTACHMENTS_DIR=/data/attachments
|
||||||
USER node
|
USER node
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
HEALTHCHECK --interval=10s --timeout=3s --start-period=20s --retries=5 \
|
HEALTHCHECK --interval=10s --timeout=3s --start-period=20s --retries=5 \
|
||||||
CMD wget -qO- http://127.0.0.1:3000/api/health > /dev/null || exit 1
|
CMD wget -qO- http://127.0.0.1:3000/api/health > /dev/null || exit 1
|
||||||
CMD ["node", "main.js"]
|
CMD ["node", "main.js"]
|
||||||
|
|
||||||
|
# ---- MCP: server for AI clients, consumes the API over REST ------------
|
||||||
|
FROM deps AS build-mcp
|
||||||
|
COPY . .
|
||||||
|
RUN npx nx run mcp:prune
|
||||||
|
|
||||||
|
# @nestjs-mcp/server 2 targets Node 24.
|
||||||
|
FROM node:24-alpine AS mcp
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production MCP_PORT=3010
|
||||||
|
COPY --from=build-mcp /workspace/dist/apps/mcp/package.json /workspace/dist/apps/mcp/package-lock.json ./
|
||||||
|
RUN npm ci --omit=dev --ignore-scripts --no-audit --no-fund && npm cache clean --force
|
||||||
|
COPY --from=build-mcp /workspace/dist/apps/mcp/main.js ./
|
||||||
|
USER node
|
||||||
|
EXPOSE 3010
|
||||||
|
HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=5 \
|
||||||
|
CMD wget -qO- http://127.0.0.1:3010/health > /dev/null || exit 1
|
||||||
|
CMD ["node", "main.js"]
|
||||||
|
|
||||||
# ---- Web: Angular SPA behind nginx (proxies /api and /graphql) ---------
|
# ---- Web: Angular SPA behind nginx (proxies /api and /graphql) ---------
|
||||||
FROM deps AS build-web
|
FROM deps AS build-web
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
11
README.md
11
README.md
@@ -3,7 +3,7 @@
|
|||||||
Alternativa a Jira para equipos de desarrollo: proyectos, tickets (bugs, features, cambios de UI, tareas y QA),
|
Alternativa a Jira para equipos de desarrollo: proyectos, tickets (bugs, features, cambios de UI, tareas y QA),
|
||||||
releases, comentarios y relaciones entre tickets, **en tiempo real**.
|
releases, comentarios y relaciones entre tickets, **en tiempo real**.
|
||||||
|
|
||||||
- **API**: NestJS + GraphQL, arquitectura hexagonal, CQRS y event sourcing (escritura en PostgreSQL, lectura en MongoDB,
|
- **API**: NestJS + GraphQL y REST con OpenAPI (Swagger, para otras apps), arquitectura hexagonal, CQRS y event sourcing (escritura en PostgreSQL, lectura en MongoDB,
|
||||||
Redis entre ambas).
|
Redis entre ambas).
|
||||||
- **Web**: Angular 22 + NgRx Signals (patrón de eventos) + `@pi/ui`, con la misma arquitectura.
|
- **Web**: Angular 22 + NgRx Signals (patrón de eventos) + `@pi/ui`, con la misma arquitectura.
|
||||||
- **Usuarios**: los gestiona [Pireon](https://oauth.pireon.dev/api/documentation).
|
- **Usuarios**: los gestiona [Pireon](https://oauth.pireon.dev/api/documentation).
|
||||||
@@ -14,14 +14,15 @@ releases, comentarios y relaciones entre tickets, **en tiempo real**.
|
|||||||
docker compose up -d --build # web en http://localhost:8080
|
docker compose up -d --build # web en http://localhost:8080
|
||||||
```
|
```
|
||||||
|
|
||||||
Para entrar hace falta un cliente OAuth de Pireon en `.env` (`PIREON_CLIENT_ID` y `PIREON_CLIENT_SECRET`, ver
|
Para entrar hacen falta los clientes OAuth de Pireon en `.env`: uno `authorization_code` (`PIREON_CLIENT_ID` /
|
||||||
[Autenticación](docs/05-autenticacion.md#registrar-taskstream-como-cliente-en-pireon)). Para desarrollar:
|
`_SECRET`, «Entrar con Pireon») y uno `password` (`PIREON_PASSWORD_CLIENT_ID` / `_SECRET`, email y contraseña). Ver
|
||||||
|
[Autenticación](docs/05-autenticacion.md#registrar-taskstream-como-cliente-en-pireon). Para desarrollar:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm install # @pi/ui necesita el token del Nexus de Pireon en ~/.npmrc
|
npm install # @pi/ui necesita el token del Nexus de Pireon en ~/.npmrc
|
||||||
docker compose up -d postgres mongo redis
|
docker compose up -d postgres mongo redis
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
npx nx serve api # http://localhost:3000/graphql
|
npx nx serve api # http://localhost:3000/graphql · REST: /api/docs
|
||||||
npx nx serve web # http://localhost:4200
|
npx nx serve web # http://localhost:4200
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -31,7 +32,7 @@ npx nx serve web # http://localhost:4200
|
|||||||
| ------------------------------------------- | -------------------------------------------------------------------- |
|
| ------------------------------------------- | -------------------------------------------------------------------- |
|
||||||
| [Visión general](docs/01-vision-general.md) | Funcionalidades, stack y estructura del monorepo |
|
| [Visión general](docs/01-vision-general.md) | Funcionalidades, stack y estructura del monorepo |
|
||||||
| [Arquitectura](docs/02-arquitectura.md) | Capas, CQRS, event sourcing, flujo escritura → lectura y tiempo real |
|
| [Arquitectura](docs/02-arquitectura.md) | Capas, CQRS, event sourcing, flujo escritura → lectura y tiempo real |
|
||||||
| [API](docs/03-api.md) | Dominio, workflow de tickets, eventos y operaciones GraphQL |
|
| [API](docs/03-api.md) | Dominio, workflow de tickets, eventos, GraphQL y REST (OpenAPI) |
|
||||||
| [Cliente web](docs/04-cliente-web.md) | Stores con eventos, GraphQL y `@pi/ui` |
|
| [Cliente web](docs/04-cliente-web.md) | Stores con eventos, GraphQL y `@pi/ui` |
|
||||||
| [Autenticación](docs/05-autenticacion.md) | Tokens de Pireon, login en el navegador y webhooks |
|
| [Autenticación](docs/05-autenticacion.md) | Tokens de Pireon, login en el navegador y webhooks |
|
||||||
| [Desarrollo](docs/06-desarrollo.md) | Comandos, tests, cómo añadir una funcionalidad y problemas conocidos |
|
| [Desarrollo](docs/06-desarrollo.md) | Comandos, tests, cómo añadir una funcionalidad y problemas conocidos |
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
|
||||||
});
|
|
||||||
20390
apps/api/openapi.json
Normal file
20390
apps/api/openapi.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@
|
|||||||
"@nestjs/graphql": "^13.4.5",
|
"@nestjs/graphql": "^13.4.5",
|
||||||
"@nestjs/mongoose": "^11.0.4",
|
"@nestjs/mongoose": "^11.0.4",
|
||||||
"@nestjs/platform-express": "^11.0.0",
|
"@nestjs/platform-express": "^11.0.0",
|
||||||
|
"@nestjs/swagger": "^11.4.7",
|
||||||
"@nestjs/typeorm": "^11.0.3",
|
"@nestjs/typeorm": "^11.0.3",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
@@ -24,11 +25,13 @@
|
|||||||
"ioredis": "^5.11.1",
|
"ioredis": "^5.11.1",
|
||||||
"joi": "^18.2.9",
|
"joi": "^18.2.9",
|
||||||
"mongoose": "^8.24.4",
|
"mongoose": "^8.24.4",
|
||||||
|
"nodemailer": "^7.0.13",
|
||||||
"pg": "^8.23.1",
|
"pg": "^8.23.1",
|
||||||
"reflect-metadata": "^0.2.0",
|
"reflect-metadata": "^0.2.0",
|
||||||
"rxjs": "^7.8.0",
|
"rxjs": "^7.8.0",
|
||||||
"tslib": "^2.8.1",
|
"tslib": "^2.8.1",
|
||||||
"typeorm": "^0.3.31",
|
"typeorm": "^0.3.31",
|
||||||
|
"web-push": "3.6.7",
|
||||||
"ws": "^8.22.0"
|
"ws": "^8.22.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,15 +3,10 @@
|
|||||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||||
"sourceRoot": "apps/api/src",
|
"sourceRoot": "apps/api/src",
|
||||||
"projectType": "application",
|
"projectType": "application",
|
||||||
"tags": [
|
"tags": ["scope:api", "type:app"],
|
||||||
"scope:api",
|
|
||||||
"type:app"
|
|
||||||
],
|
|
||||||
"targets": {
|
"targets": {
|
||||||
"prune-lockfile": {
|
"prune-lockfile": {
|
||||||
"dependsOn": [
|
"dependsOn": ["build"],
|
||||||
"build"
|
|
||||||
],
|
|
||||||
"cache": true,
|
"cache": true,
|
||||||
"executor": "@nx/js:prune-lockfile",
|
"executor": "@nx/js:prune-lockfile",
|
||||||
"outputs": [
|
"outputs": [
|
||||||
@@ -23,32 +18,23 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"copy-workspace-modules": {
|
"copy-workspace-modules": {
|
||||||
"dependsOn": [
|
"dependsOn": ["build"],
|
||||||
"build"
|
|
||||||
],
|
|
||||||
"cache": true,
|
"cache": true,
|
||||||
"outputs": [
|
"outputs": ["{workspaceRoot}/dist/apps/api/workspace_modules"],
|
||||||
"{workspaceRoot}/dist/apps/api/workspace_modules"
|
|
||||||
],
|
|
||||||
"executor": "@nx/js:copy-workspace-modules",
|
"executor": "@nx/js:copy-workspace-modules",
|
||||||
"options": {
|
"options": {
|
||||||
"buildTarget": "build"
|
"buildTarget": "build"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"prune": {
|
"prune": {
|
||||||
"dependsOn": [
|
"dependsOn": ["prune-lockfile", "copy-workspace-modules"],
|
||||||
"prune-lockfile",
|
|
||||||
"copy-workspace-modules"
|
|
||||||
],
|
|
||||||
"executor": "nx:noop"
|
"executor": "nx:noop"
|
||||||
},
|
},
|
||||||
"serve": {
|
"serve": {
|
||||||
"continuous": true,
|
"continuous": true,
|
||||||
"executor": "@nx/js:node",
|
"executor": "@nx/js:node",
|
||||||
"defaultConfiguration": "development",
|
"defaultConfiguration": "development",
|
||||||
"dependsOn": [
|
"dependsOn": ["build"],
|
||||||
"build"
|
|
||||||
],
|
|
||||||
"options": {
|
"options": {
|
||||||
"buildTarget": "api:build",
|
"buildTarget": "api:build",
|
||||||
"runBuildTargetDependencies": false
|
"runBuildTargetDependencies": false
|
||||||
@@ -73,6 +59,13 @@
|
|||||||
"command": "jest --config apps/api/jest.e2e.config.cts --runInBand"
|
"command": "jest --config apps/api/jest.e2e.config.cts --runInBand"
|
||||||
},
|
},
|
||||||
"cache": false
|
"cache": false
|
||||||
|
},
|
||||||
|
"contracts": {
|
||||||
|
"executor": "nx:run-commands",
|
||||||
|
"options": {
|
||||||
|
"command": "node -r ./apps/api/scripts/register.cjs apps/api/scripts/contracts.ts"
|
||||||
|
},
|
||||||
|
"cache": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2197
apps/api/schema.gql
2197
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,
|
||||||
|
});
|
||||||
@@ -24,11 +24,16 @@ import { HealthController } from './health.controller';
|
|||||||
* Composition root. Each bounded context is assembled outside-in:
|
* Composition root. Each bounded context is assembled outside-in:
|
||||||
* presenters (GraphQL/HTTP) → application (services, CQRS handlers) → infrastructure (adapters)
|
* presenters (GraphQL/HTTP) → application (services, CQRS handlers) → infrastructure (adapters)
|
||||||
*/
|
*/
|
||||||
const identity = IdentityPresentersModule.withApplication(
|
const identityApplication = IdentityApplicationModule.withInfrastructure(
|
||||||
IdentityApplicationModule.withInfrastructure(IdentityInfrastructureModule),
|
IdentityInfrastructureModule,
|
||||||
);
|
);
|
||||||
|
const identity = IdentityPresentersModule.withApplication(identityApplication);
|
||||||
|
// The tracker's user directory asks the identity context (IdentityService):
|
||||||
|
// the same module, so one identity context serves both.
|
||||||
const tracker = TrackerPresentersModule.withApplication(
|
const tracker = TrackerPresentersModule.withApplication(
|
||||||
TrackerApplicationModule.withInfrastructure(TrackerInfrastructureModule),
|
TrackerApplicationModule.withInfrastructure(
|
||||||
|
TrackerInfrastructureModule.withIdentity(identityApplication),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -47,6 +52,8 @@ const tracker = TrackerPresentersModule.withApplication(
|
|||||||
url: config.getOrThrow<string>('POSTGRES_URL'),
|
url: config.getOrThrow<string>('POSTGRES_URL'),
|
||||||
autoLoadEntities: true,
|
autoLoadEntities: true,
|
||||||
synchronize: config.get<boolean>('POSTGRES_SYNCHRONIZE'),
|
synchronize: config.get<boolean>('POSTGRES_SYNCHRONIZE'),
|
||||||
|
// uuid ids come from gen_random_uuid() (core since PostgreSQL 13).
|
||||||
|
uuidExtension: 'pgcrypto',
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
@@ -23,9 +23,15 @@ export const configSchema = Joi.object({
|
|||||||
// Pireon identity server
|
// Pireon identity server
|
||||||
PIREON_BASE_URL: Joi.string().uri().default('https://oauth.pireon.dev'),
|
PIREON_BASE_URL: Joi.string().uri().default('https://oauth.pireon.dev'),
|
||||||
PIREON_WEBHOOK_SECRET: Joi.string().allow('').optional(),
|
PIREON_WEBHOOK_SECRET: Joi.string().allow('').optional(),
|
||||||
// OAuth client of task-stream in Pireon, used by the web app to sign in.
|
// 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_ID: Joi.string().allow('').optional(),
|
||||||
PIREON_CLIENT_SECRET: Joi.string().allow('').optional(),
|
PIREON_CLIENT_SECRET: Joi.string().allow('').optional(),
|
||||||
|
// …and password for email + password (defaults to the client above).
|
||||||
|
PIREON_PASSWORD_CLIENT_ID: Joi.string().allow('').optional(),
|
||||||
|
PIREON_PASSWORD_CLIENT_SECRET: Joi.string().allow('').optional(),
|
||||||
PIREON_SCOPES: Joi.string().default('users:read'),
|
PIREON_SCOPES: Joi.string().default('users:read'),
|
||||||
PIREON_PASSWORD_GRANT: Joi.boolean().default(true),
|
PIREON_PASSWORD_GRANT: Joi.boolean().default(true),
|
||||||
PIREON_TIMEOUT_MS: Joi.number().default(5000),
|
PIREON_TIMEOUT_MS: Joi.number().default(5000),
|
||||||
@@ -33,4 +39,24 @@ export const configSchema = Joi.object({
|
|||||||
|
|
||||||
GRAPHQL_PLAYGROUND: Joi.boolean().default(true),
|
GRAPHQL_PLAYGROUND: Joi.boolean().default(true),
|
||||||
GRAPHQL_SCHEMA_FILE: Joi.string().allow('').default('apps/api/schema.gql'),
|
GRAPHQL_SCHEMA_FILE: Joi.string().allow('').default('apps/api/schema.gql'),
|
||||||
|
|
||||||
|
// REST + OpenAPI for other apps: Swagger UI at /api/docs, JSON at /api/docs-json.
|
||||||
|
SWAGGER_ENABLED: Joi.boolean().default(true),
|
||||||
|
// Where to write the OpenAPI document on startup ('' = don't), like the GraphQL SDL.
|
||||||
|
OPENAPI_FILE: Joi.string().allow('').default('apps/api/openapi.json'),
|
||||||
|
|
||||||
|
// Attached files: where they are kept (a volume in Docker) and how big one can be.
|
||||||
|
ATTACHMENTS_DIR: Joi.string().default('data/attachments'),
|
||||||
|
ATTACHMENTS_MAX_MB: Joi.number().min(1).max(500).default(25),
|
||||||
|
|
||||||
|
// E-mail copies of notifications (optional): without SMTP_URL nothing is sent.
|
||||||
|
SMTP_URL: Joi.string().allow('').optional(),
|
||||||
|
MAIL_FROM: Joi.string().allow('').optional(),
|
||||||
|
// Push notifications (optional): without both VAPID keys nothing is sent.
|
||||||
|
// Make them once with `npx web-push generate-vapid-keys`.
|
||||||
|
VAPID_PUBLIC_KEY: Joi.string().allow('').optional(),
|
||||||
|
VAPID_PRIVATE_KEY: Joi.string().allow('').optional(),
|
||||||
|
VAPID_SUBJECT: 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,22 +5,57 @@ import {
|
|||||||
ServiceUnavailableException,
|
ServiceUnavailableException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectConnection } from '@nestjs/mongoose';
|
import { InjectConnection } from '@nestjs/mongoose';
|
||||||
|
import {
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiOperation,
|
||||||
|
ApiServiceUnavailableResponse,
|
||||||
|
ApiTags,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
import { Public } from '@task-stream/api/identity/presenters';
|
import { Public } from '@task-stream/api/identity/presenters';
|
||||||
import { REDIS_CLIENT } from '@task-stream/api/shared/infrastructure';
|
import {
|
||||||
|
EventPipelineReading,
|
||||||
|
EventPipelineStatus,
|
||||||
|
REDIS_CLIENT,
|
||||||
|
} from '@task-stream/api/shared/infrastructure';
|
||||||
import Redis from 'ioredis';
|
import Redis from 'ioredis';
|
||||||
import { Connection } from 'mongoose';
|
import { Connection } from 'mongoose';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
@ApiTags('Health')
|
||||||
@Controller('health')
|
@Controller('health')
|
||||||
export class HealthController {
|
export class HealthController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly postgres: DataSource,
|
private readonly postgres: DataSource,
|
||||||
@InjectConnection() private readonly mongo: Connection,
|
@InjectConnection() private readonly mongo: Connection,
|
||||||
@Inject(REDIS_CLIENT) private readonly redis: Redis,
|
@Inject(REDIS_CLIENT) private readonly redis: Redis,
|
||||||
|
private readonly events: EventPipelineStatus,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Get()
|
@Get()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'API, databases and the way from write to read',
|
||||||
|
description:
|
||||||
|
'503 when a database is down. With the databases up, `status` is `degraded` (still 200, so a lagging read side does not get the container restarted) when events wait in the outbox over a minute, 500 or more wait to be projected, or some were set aside after failing again and again (`task-stream:events:dead`); `events.problems` says which.',
|
||||||
|
})
|
||||||
|
@ApiOkResponse({
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
status: 'ok',
|
||||||
|
postgres: 'up',
|
||||||
|
mongo: 'up',
|
||||||
|
redis: 'up',
|
||||||
|
events: {
|
||||||
|
unpublished: 0,
|
||||||
|
oldestUnpublishedSeconds: null,
|
||||||
|
pending: 0,
|
||||||
|
dead: 0,
|
||||||
|
problems: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiServiceUnavailableResponse({ description: 'A database is down' })
|
||||||
async check() {
|
async check() {
|
||||||
const checks = await Promise.allSettled([
|
const checks = await Promise.allSettled([
|
||||||
this.postgres.query('SELECT 1'),
|
this.postgres.query('SELECT 1'),
|
||||||
@@ -30,10 +65,38 @@ export class HealthController {
|
|||||||
const [postgres, mongo, redis] = checks.map((c) =>
|
const [postgres, mongo, redis] = checks.map((c) =>
|
||||||
c.status === 'fulfilled' ? 'up' : 'down',
|
c.status === 'fulfilled' ? 'up' : 'down',
|
||||||
);
|
);
|
||||||
const result = { status: 'ok', postgres, mongo, redis };
|
const events = await this.events.read().then(
|
||||||
|
(reading) => ({ ...reading, problems: problemsOf(reading) }),
|
||||||
|
() => null,
|
||||||
|
);
|
||||||
|
const result = {
|
||||||
|
status: events?.problems.length ? 'degraded' : 'ok',
|
||||||
|
postgres,
|
||||||
|
mongo,
|
||||||
|
redis,
|
||||||
|
events,
|
||||||
|
};
|
||||||
if (checks.some((c) => c.status === 'rejected')) {
|
if (checks.some((c) => c.status === 'rejected')) {
|
||||||
throw new ServiceUnavailableException({ ...result, status: 'degraded' });
|
throw new ServiceUnavailableException({ ...result, status: 'degraded' });
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What is wrong on the way from write to read, if anything. */
|
||||||
|
function problemsOf(reading: EventPipelineReading): string[] {
|
||||||
|
const problems: string[] = [];
|
||||||
|
if ((reading.oldestUnpublishedSeconds ?? 0) > 60)
|
||||||
|
problems.push(
|
||||||
|
`${reading.unpublished} events wait in the outbox (the oldest, ${reading.oldestUnpublishedSeconds} s): is Redis reachable?`,
|
||||||
|
);
|
||||||
|
if (reading.pending >= 500)
|
||||||
|
problems.push(
|
||||||
|
`${reading.pending} events wait to be projected: is Mongo reachable?`,
|
||||||
|
);
|
||||||
|
if (reading.dead > 0)
|
||||||
|
problems.push(
|
||||||
|
`${reading.dead} events were set aside after failing again and again (task-stream:events:dead)`,
|
||||||
|
);
|
||||||
|
return problems;
|
||||||
|
}
|
||||||
|
|||||||
179
apps/api/src/app/openapi.ts
Normal file
179
apps/api/src/app/openapi.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { DocumentBuilder, OpenAPIObject, SwaggerModule } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
PIREON_BEARER,
|
||||||
|
PIREON_OAUTH,
|
||||||
|
} from '@task-stream/api/identity/presenters';
|
||||||
|
import { writeFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
export const API_VERSION = '1.0.0';
|
||||||
|
|
||||||
|
const DESCRIPTION = `
|
||||||
|
REST API of **Pireon TaskStream** for other apps: projects, tickets (bugs, features,
|
||||||
|
UI changes, tasks and QA), workflow, comments, relations and releases.
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
Every endpoint (except \`/auth\`, \`/health\` and the webhook) needs a **Pireon access
|
||||||
|
token**, so any app or user that consumes it goes through Pireon's OAuth:
|
||||||
|
|
||||||
|
- **Authorization Code + PKCE** against \`oauth.pireon.dev/oauth/authorize\`
|
||||||
|
(the "Authorize" button here uses it; register \`<api>/api/docs/oauth2-redirect.html\`
|
||||||
|
as a redirect URI of the client).
|
||||||
|
- An app with its own Pireon OAuth client can get tokens from Pireon directly and
|
||||||
|
send them as \`Authorization: Bearer <token>\`.
|
||||||
|
|
||||||
|
What you can see and change depends on your role in each project.
|
||||||
|
|
||||||
|
### Writes are eventually consistent
|
||||||
|
Commands answer \`{ id, key?, version }\` as soon as the event is stored. Reads come from
|
||||||
|
a read model that catches up in milliseconds: a \`GET\` right after a write may still
|
||||||
|
show the previous version (or 404 right after a create).
|
||||||
|
|
||||||
|
### Real time
|
||||||
|
For live updates use the GraphQL subscriptions at \`/graphql\` (graphql-ws).
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
export interface OpenApiSettings {
|
||||||
|
pireonBaseUrl: string;
|
||||||
|
scopes: string[];
|
||||||
|
passwordGrant: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The OpenAPI document of the REST controllers. Needs no database. */
|
||||||
|
export function buildOpenApiDocument(
|
||||||
|
app: INestApplication,
|
||||||
|
settings: OpenApiSettings,
|
||||||
|
): OpenAPIObject {
|
||||||
|
const pireon = settings.pireonBaseUrl;
|
||||||
|
const scopes = settings.scopes;
|
||||||
|
const scopeDocs = Object.fromEntries(
|
||||||
|
scopes.map((scope) => [
|
||||||
|
scope,
|
||||||
|
scope === 'users:read' ? 'Search Pireon users' : scope,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
// Token requests go through the API's proxy: Pireon's /oauth/token has no
|
||||||
|
// CORS and the client secret stays on the server.
|
||||||
|
const tokenUrl = '/api/auth/token';
|
||||||
|
|
||||||
|
const document = SwaggerModule.createDocument(
|
||||||
|
app,
|
||||||
|
new DocumentBuilder()
|
||||||
|
.setTitle('Pireon TaskStream API')
|
||||||
|
.setDescription(DESCRIPTION)
|
||||||
|
.setVersion(API_VERSION)
|
||||||
|
.addServer('/', 'This server (the paths already include /api)')
|
||||||
|
.addOAuth2(
|
||||||
|
{
|
||||||
|
type: 'oauth2',
|
||||||
|
description: 'Pireon OAuth2 (Laravel Passport)',
|
||||||
|
flows: {
|
||||||
|
authorizationCode: {
|
||||||
|
authorizationUrl: `${pireon}/oauth/authorize`,
|
||||||
|
tokenUrl,
|
||||||
|
refreshUrl: tokenUrl,
|
||||||
|
scopes: scopeDocs,
|
||||||
|
},
|
||||||
|
...(settings.passwordGrant
|
||||||
|
? {
|
||||||
|
password: {
|
||||||
|
tokenUrl,
|
||||||
|
refreshUrl: tokenUrl,
|
||||||
|
scopes: scopeDocs,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PIREON_OAUTH,
|
||||||
|
)
|
||||||
|
.addBearerAuth(
|
||||||
|
{
|
||||||
|
type: 'http',
|
||||||
|
scheme: 'bearer',
|
||||||
|
description: 'A Pireon access token obtained elsewhere',
|
||||||
|
},
|
||||||
|
PIREON_BEARER,
|
||||||
|
)
|
||||||
|
.addTag('Projects', 'Projects, members, board, stats and activity')
|
||||||
|
.addTag(
|
||||||
|
'Issues',
|
||||||
|
'Tickets: search, create, workflow, relations, comments',
|
||||||
|
)
|
||||||
|
.addTag('Comments', 'Edit and delete comments')
|
||||||
|
.addTag('Releases', 'Versions that group tickets')
|
||||||
|
.addTag('Users', 'The signed-in user and the Pireon directory')
|
||||||
|
.addTag('Auth', 'Pireon OAuth token proxy')
|
||||||
|
.addTag('Health', 'Liveness of the API and its databases')
|
||||||
|
.addTag('Webhooks', 'Called by Pireon')
|
||||||
|
.build(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Operations without a security requirement are the public ones (@Public():
|
||||||
|
// health, auth, webhook). Say so explicitly instead of leaving it undefined.
|
||||||
|
for (const path of Object.values(document.paths)) {
|
||||||
|
for (const operation of [
|
||||||
|
path.get,
|
||||||
|
path.post,
|
||||||
|
path.put,
|
||||||
|
path.patch,
|
||||||
|
path.delete,
|
||||||
|
]) {
|
||||||
|
if (operation && !operation.security) operation.security = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return document;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the OpenAPI document from the REST controllers, serves Swagger UI and
|
||||||
|
* (in development) writes it to OPENAPI_FILE so other apps can generate clients.
|
||||||
|
*/
|
||||||
|
export function setupOpenApi(app: INestApplication): void {
|
||||||
|
const config = app.get(ConfigService);
|
||||||
|
if (!config.get<boolean>('SWAGGER_ENABLED')) return;
|
||||||
|
|
||||||
|
const scopes = (config.get<string>('PIREON_SCOPES') ?? '')
|
||||||
|
.split(/[\s,]+/)
|
||||||
|
.filter(Boolean);
|
||||||
|
const document = buildOpenApiDocument(app, {
|
||||||
|
pireonBaseUrl:
|
||||||
|
config.get<string>('PIREON_BASE_URL') ?? 'https://oauth.pireon.dev',
|
||||||
|
scopes,
|
||||||
|
passwordGrant: !!config.get<boolean>('PIREON_PASSWORD_GRANT'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const file = config.get<string>('OPENAPI_FILE');
|
||||||
|
if (file) {
|
||||||
|
writeFileSync(
|
||||||
|
join(process.cwd(), file),
|
||||||
|
JSON.stringify(document, null, 2) + '\n',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
SwaggerModule.setup('api/docs', app, document, {
|
||||||
|
jsonDocumentUrl: 'api/docs-json',
|
||||||
|
yamlDocumentUrl: 'api/docs-yaml',
|
||||||
|
customSiteTitle: 'Pireon TaskStream API',
|
||||||
|
swaggerOptions: {
|
||||||
|
persistAuthorization: true,
|
||||||
|
initOAuth: {
|
||||||
|
clientId: config.get<string>('PIREON_CLIENT_ID') || undefined,
|
||||||
|
scopes,
|
||||||
|
usePkceWithAuthorizationCodeGrant: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// The Pireon look, as in the web app.
|
||||||
|
customCss: `
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap');
|
||||||
|
.swagger-ui, .swagger-ui .info .title, .swagger-ui .opblock-tag { font-family: Inter, system-ui, sans-serif; }
|
||||||
|
.swagger-ui .topbar { display: none; }
|
||||||
|
.swagger-ui .info .title { background: linear-gradient(100deg, #a347e1, #1879bf); -webkit-background-clip: text; background-clip: text; color: transparent; }
|
||||||
|
.swagger-ui .btn.authorize { color: #a347e1; border-color: #a347e1; }
|
||||||
|
.swagger-ui .btn.authorize svg { fill: #a347e1; }
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,22 +1,19 @@
|
|||||||
import { Logger, ValidationPipe } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { DomainErrorFilter } from '@task-stream/api/shared/presenters';
|
|
||||||
import { AppModule } from './app/app.module';
|
import { AppModule } from './app/app.module';
|
||||||
|
import { configureApp } from './app/configure';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
// rawBody: needed to verify Pireon webhook signatures.
|
// rawBody: needed to verify Pireon webhook signatures.
|
||||||
const app = await NestFactory.create(AppModule, { rawBody: true });
|
const app = await NestFactory.create(AppModule, { rawBody: true });
|
||||||
app.setGlobalPrefix('api');
|
configureApp(app);
|
||||||
app.enableCors();
|
|
||||||
app.enableShutdownHooks();
|
|
||||||
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
|
|
||||||
app.useGlobalFilters(new DomainErrorFilter());
|
|
||||||
|
|
||||||
const port = process.env['PORT'] || 3000;
|
const port = process.env['PORT'] || 3000;
|
||||||
await app.listen(port);
|
await app.listen(port);
|
||||||
Logger.log(
|
Logger.log(
|
||||||
`🚀 GraphQL: http://localhost:${port}/graphql (ws for subscriptions)`,
|
`🚀 GraphQL: http://localhost:${port}/graphql (ws for subscriptions)`,
|
||||||
);
|
);
|
||||||
|
Logger.log(`📘 REST: http://localhost:${port}/api/docs (OpenAPI)`);
|
||||||
Logger.log(`❤️ Health: http://localhost:${port}/api/health`);
|
Logger.log(`❤️ Health: http://localhost:${port}/api/health`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
7
apps/mcp/eslint.config.mjs
Normal file
7
apps/mcp/eslint.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import baseConfig from '../../eslint.config.mjs';
|
||||||
|
|
||||||
|
export default [
|
||||||
|
...baseConfig,
|
||||||
|
// Generated from apps/api/openapi.json (nx run mcp:openapi-types).
|
||||||
|
{ ignores: ['src/app/api/taskstream.schema.ts'] },
|
||||||
|
];
|
||||||
10
apps/mcp/jest.config.cts
Normal file
10
apps/mcp/jest.config.cts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
module.exports = {
|
||||||
|
displayName: 'mcp',
|
||||||
|
preset: '../../jest.preset.js',
|
||||||
|
testEnvironment: 'node',
|
||||||
|
transform: {
|
||||||
|
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
|
||||||
|
},
|
||||||
|
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||||
|
coverageDirectory: '../../coverage/apps/mcp',
|
||||||
|
};
|
||||||
21
apps/mcp/package.json
Normal file
21
apps/mcp/package.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "mcp",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"description": "Runtime dependencies of the MCP image. Nx uses this list for dist/apps/mcp/package.json (pruned lockfile for Docker).",
|
||||||
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/server": "^2.3.1",
|
||||||
|
"@nestjs-mcp/server": "^2.0.0",
|
||||||
|
"@nestjs/common": "^11.0.0",
|
||||||
|
"@nestjs/config": "^4.0.4",
|
||||||
|
"@nestjs/core": "^11.0.0",
|
||||||
|
"@nestjs/platform-express": "^11.0.0",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.15.1",
|
||||||
|
"openapi-fetch": "^0.17.0",
|
||||||
|
"reflect-metadata": "^0.2.0",
|
||||||
|
"rxjs": "^7.8.0",
|
||||||
|
"tslib": "^2.8.1",
|
||||||
|
"zod": "^4.6.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
66
apps/mcp/project.json
Normal file
66
apps/mcp/project.json
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
{
|
||||||
|
"name": "mcp",
|
||||||
|
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||||
|
"sourceRoot": "apps/mcp/src",
|
||||||
|
"projectType": "application",
|
||||||
|
"tags": ["scope:mcp", "type:app"],
|
||||||
|
"targets": {
|
||||||
|
"prune-lockfile": {
|
||||||
|
"dependsOn": ["build"],
|
||||||
|
"cache": true,
|
||||||
|
"executor": "@nx/js:prune-lockfile",
|
||||||
|
"outputs": [
|
||||||
|
"{workspaceRoot}/dist/apps/mcp/package.json",
|
||||||
|
"{workspaceRoot}/dist/apps/mcp/package-lock.json"
|
||||||
|
],
|
||||||
|
"options": {
|
||||||
|
"buildTarget": "build"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"copy-workspace-modules": {
|
||||||
|
"dependsOn": ["build"],
|
||||||
|
"cache": true,
|
||||||
|
"outputs": ["{workspaceRoot}/dist/apps/mcp/workspace_modules"],
|
||||||
|
"executor": "@nx/js:copy-workspace-modules",
|
||||||
|
"options": {
|
||||||
|
"buildTarget": "build"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"prune": {
|
||||||
|
"dependsOn": ["prune-lockfile", "copy-workspace-modules"],
|
||||||
|
"executor": "nx:noop"
|
||||||
|
},
|
||||||
|
"serve": {
|
||||||
|
"continuous": true,
|
||||||
|
"executor": "@nx/js:node",
|
||||||
|
"defaultConfiguration": "development",
|
||||||
|
"dependsOn": ["build"],
|
||||||
|
"options": {
|
||||||
|
"buildTarget": "mcp:build",
|
||||||
|
"runBuildTargetDependencies": false
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"development": {
|
||||||
|
"buildTarget": "mcp:build:development"
|
||||||
|
},
|
||||||
|
"production": {
|
||||||
|
"buildTarget": "mcp:build:production"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"options": {
|
||||||
|
"passWithNoTests": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"openapi-types": {
|
||||||
|
"executor": "nx:run-commands",
|
||||||
|
"inputs": ["{workspaceRoot}/apps/api/openapi.json"],
|
||||||
|
"outputs": ["{projectRoot}/src/app/api/taskstream.schema.ts"],
|
||||||
|
"cache": true,
|
||||||
|
"options": {
|
||||||
|
"command": "npx -y openapi-typescript@7.13.0 apps/api/openapi.json -o apps/mcp/src/app/api/taskstream.schema.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
58
apps/mcp/src/app/api/taskstream-api.ts
Normal file
58
apps/mcp/src/app/api/taskstream-api.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import type { McpContext } from '@nestjs-mcp/server';
|
||||||
|
import createClient, { type Client } from 'openapi-fetch';
|
||||||
|
import type { components, paths } from './taskstream.schema';
|
||||||
|
|
||||||
|
export type Schemas = components['schemas'];
|
||||||
|
export type TaskStreamClient = Client<paths>;
|
||||||
|
|
||||||
|
export const TASKSTREAM_API_URL = Symbol('TASKSTREAM_API_URL');
|
||||||
|
|
||||||
|
/** An answer of the API other than 2xx, with its message for the model. */
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly status: number,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The task-stream REST API (typed from apps/api/openapi.json). Each MCP call
|
||||||
|
* is made with the caller's own Pireon token, forwarded from its
|
||||||
|
* `Authorization` header: the MCP keeps no credentials, and every user sees
|
||||||
|
* exactly what the API lets them see (public projects only, without a token).
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class TaskStreamApi {
|
||||||
|
constructor(@Inject(TASKSTREAM_API_URL) private readonly baseUrl: string) {}
|
||||||
|
|
||||||
|
/** A client acting as the caller of this MCP request. */
|
||||||
|
as(ctx: McpContext): TaskStreamClient {
|
||||||
|
const authorization = ctx.headers.authorization;
|
||||||
|
return createClient<paths>({
|
||||||
|
baseUrl: this.baseUrl,
|
||||||
|
headers: authorization ? { authorization } : {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The body of a successful answer; otherwise an ApiError with the API's message. */
|
||||||
|
async data<T>(
|
||||||
|
request: Promise<{ data?: T; error?: unknown; response: Response }>,
|
||||||
|
): Promise<T> {
|
||||||
|
const { data, error, response } = await request;
|
||||||
|
if (response.ok) return data as T;
|
||||||
|
throw new ApiError(response.status, describe(response.status, error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function describe(status: number, error: unknown): string {
|
||||||
|
const body = (error ?? {}) as { message?: unknown; code?: unknown };
|
||||||
|
const message = Array.isArray(body.message)
|
||||||
|
? body.message.join('; ')
|
||||||
|
: String(body.message ?? `HTTP ${status}`);
|
||||||
|
if (status === 401)
|
||||||
|
return `${message}. Este MCP usa el token de Pireon del cliente: configúralo con el header "Authorization: Bearer <token>" (sin token solo se ven proyectos públicos).`;
|
||||||
|
return body.code ? `${message} (${String(body.code)})` : message;
|
||||||
|
}
|
||||||
14161
apps/mcp/src/app/api/taskstream.schema.ts
Normal file
14161
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
1092
apps/mcp/src/app/tools/issues.resolver.ts
Normal file
1092
apps/mcp/src/app/tools/issues.resolver.ts
Normal file
File diff suppressed because it is too large
Load Diff
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})`);
|
||||||
323
apps/mcp/src/app/tools/projects.resolver.ts
Normal file
323
apps/mcp/src/app/tools/projects.resolver.ts
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
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. With template, it starts with the statuses, transitions, types, areas, custom fields and automation rules of that project.',
|
||||||
|
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'),
|
||||||
|
template: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'Key of a project the user owns or administers to copy its configuration and automation rules from',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
||||||
|
})
|
||||||
|
create(
|
||||||
|
params: {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
isPublic: boolean;
|
||||||
|
template?: string;
|
||||||
|
},
|
||||||
|
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_my_week',
|
||||||
|
description:
|
||||||
|
"The user's coming week and how the last one went: tickets of theirs (assigned or watched) due in the next 7 days, sprint ends and planned releases; tickets finished by them and by their teams this week and last, and their streak; sprints under way; and, when nothing is theirs, what they could pick up (unassigned, waiting for QA, proposals to review).",
|
||||||
|
paramsSchema: z.object({}),
|
||||||
|
annotations: READ,
|
||||||
|
})
|
||||||
|
myWeek(_params: Record<string, never>, ctx: McpContext) {
|
||||||
|
return toolResult(async () => {
|
||||||
|
const home = await this.api.data(this.api.as(ctx).GET('/api/me/home'));
|
||||||
|
// Compact: the page's activity feed, presence and first steps stay out.
|
||||||
|
return {
|
||||||
|
agenda: home.agenda,
|
||||||
|
me: home.me,
|
||||||
|
team: { thisWeek: home.team.thisWeek, lastWeek: home.team.lastWeek },
|
||||||
|
sprints: home.sprints,
|
||||||
|
suggestions: home.suggestions,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'get_portfolio',
|
||||||
|
description:
|
||||||
|
"All the user's active projects at a glance (not archived, not as a guest): open, overdue, blocked and stuck tickets, the sprint under way and whether it gets there, planned releases with their forecast, and open epics with their progress.",
|
||||||
|
paramsSchema: z.object({}),
|
||||||
|
annotations: READ,
|
||||||
|
})
|
||||||
|
portfolio(_params: Record<string, never>, ctx: McpContext) {
|
||||||
|
return toolResult(async () => {
|
||||||
|
const projects = await this.api.data(
|
||||||
|
this.api.as(ctx).GET('/api/me/portfolio'),
|
||||||
|
);
|
||||||
|
// Compact: epics as key, title and progress.
|
||||||
|
return projects.map(({ epics, ...p }) => ({
|
||||||
|
...p,
|
||||||
|
epics: epics.map((e) => ({
|
||||||
|
key: e.issue.key,
|
||||||
|
title: e.issue.title,
|
||||||
|
dueDate: e.issue.dueDate,
|
||||||
|
percent: e.progress.percent,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'get_project_insights',
|
||||||
|
description:
|
||||||
|
"The project's dashboard: the sprint under way (pace against time, behind or not), overdue, due-soon, blocked and stuck tickets, how long the work under way has been where it is (against the p85 cycle time), average days in each in-progress and QA status with WIP limits, when each planned release would be finished at the real pace (Monte Carlo), each person's open points against what they close per sprint, open tickets missing an estimate, an area or an assignee, quality (open bugs per priority, bugs created and closed per week, QA returns per area, bugs reported after each release) open tickets per option of each list custom field, activity per day over the last 12 weeks, where the time of the tickets done lately went from created to done (days and share per status, and the part spent at work: flow efficiency) and, if the public has proposed anything, the proposals funnel (received, waiting, under way, done, discarded, most voted waiting). With since, also what changed after it.",
|
||||||
|
paramsSchema: z.object({
|
||||||
|
project: projectRef,
|
||||||
|
since: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'ISO instant: also tell what was created, finished or sent back from QA after it',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
annotations: READ,
|
||||||
|
})
|
||||||
|
insights(
|
||||||
|
{ project, since }: { project: string; since?: string },
|
||||||
|
ctx: McpContext,
|
||||||
|
) {
|
||||||
|
return toolResult(() =>
|
||||||
|
this.api.data(
|
||||||
|
this.api.as(ctx).GET('/api/projects/{project}/insights', {
|
||||||
|
params: { path: { project }, query: { since } },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'get_project_activity',
|
||||||
|
description:
|
||||||
|
'Latest activity of a project (newest first), or that of one day (UTC) with day.',
|
||||||
|
paramsSchema: z.object({
|
||||||
|
project: projectRef,
|
||||||
|
limit: limit(200, 30),
|
||||||
|
day: z
|
||||||
|
.string()
|
||||||
|
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||||
|
.optional()
|
||||||
|
.describe('YYYY-MM-DD: only what happened that day (UTC)'),
|
||||||
|
}),
|
||||||
|
annotations: READ,
|
||||||
|
})
|
||||||
|
activity(
|
||||||
|
{ project, limit, day }: { project: string; limit: number; day?: string },
|
||||||
|
ctx: McpContext,
|
||||||
|
) {
|
||||||
|
const window = day
|
||||||
|
? {
|
||||||
|
after: `${day}T00:00:00.000Z`,
|
||||||
|
before: new Date(
|
||||||
|
Date.parse(`${day}T00:00:00Z`) + 86_400_000,
|
||||||
|
).toISOString(),
|
||||||
|
}
|
||||||
|
: {};
|
||||||
|
return toolResult(async () =>
|
||||||
|
(
|
||||||
|
await this.api.data(
|
||||||
|
this.api.as(ctx).GET('/api/projects/{project}/activity', {
|
||||||
|
params: { path: { project }, query: { limit, ...window } },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
).map((a) => ({
|
||||||
|
at: a.occurredAt,
|
||||||
|
who: a.actor.name,
|
||||||
|
summary: a.summary,
|
||||||
|
issue: a.issueKey,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
263
apps/mcp/src/app/tools/releases.resolver.ts
Normal file
263
apps/mcp/src/app/tools/releases.resolver.ts
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
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 unfinished tickets move to moveTo (another open release of the project, from list_releases) or, without it, leave it.',
|
||||||
|
paramsSchema: z.object({
|
||||||
|
releaseId,
|
||||||
|
moveTo: z
|
||||||
|
.string()
|
||||||
|
.uuid()
|
||||||
|
.optional()
|
||||||
|
.describe('Open release that takes the unfinished tickets'),
|
||||||
|
}),
|
||||||
|
annotations: { readOnlyHint: false, destructiveHint: true },
|
||||||
|
})
|
||||||
|
archive(
|
||||||
|
{ releaseId, moveTo }: { releaseId: string; moveTo?: string },
|
||||||
|
ctx: McpContext,
|
||||||
|
) {
|
||||||
|
return toolResult(() =>
|
||||||
|
this.api.data(
|
||||||
|
this.api.as(ctx).POST('/api/releases/{releaseId}/archive', {
|
||||||
|
params: { path: { releaseId } },
|
||||||
|
body: { moveTo: moveTo ?? null },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
81
apps/mcp/src/app/tools/summaries.ts
Normal file
81
apps/mcp/src/app/tools/summaries.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
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,
|
||||||
|
/** Open tickets it waits for. */
|
||||||
|
blockedBy: issue.blockedBy.map((b) => b.key),
|
||||||
|
votes: issue.votes,
|
||||||
|
storyPoints: issue.storyPoints,
|
||||||
|
dueDate: issue.dueDate,
|
||||||
|
customFields: issue.customFields,
|
||||||
|
labels: issue.labels,
|
||||||
|
updatedAt: issue.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const projectLine = (project: Project) => ({
|
||||||
|
key: project.key,
|
||||||
|
name: project.name,
|
||||||
|
description: project.description,
|
||||||
|
isPublic: project.isPublic,
|
||||||
|
owner: project.owner.name,
|
||||||
|
members: project.members.length,
|
||||||
|
updatedAt: project.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Statuses, types and areas by id: what create/update/move tools accept. */
|
||||||
|
export const projectSettings = (project: Project) => ({
|
||||||
|
statuses: project.settings.statuses.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
category: s.category,
|
||||||
|
requiresReason: s.requiresReason,
|
||||||
|
archived: s.archived,
|
||||||
|
})),
|
||||||
|
issueTypes: project.settings.issueTypes.map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
name: t.name,
|
||||||
|
archived: t.archived,
|
||||||
|
})),
|
||||||
|
areas: project.settings.areas.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
name: a.name,
|
||||||
|
archived: a.archived,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const releaseLine = (release: Release) => ({
|
||||||
|
id: release.id,
|
||||||
|
name: release.name,
|
||||||
|
status: release.status,
|
||||||
|
targetDate: release.targetDate,
|
||||||
|
releasedAt: release.releasedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const sprintLine = (sprint: Sprint) => ({
|
||||||
|
id: sprint.id,
|
||||||
|
name: sprint.name,
|
||||||
|
goal: sprint.goal,
|
||||||
|
status: sprint.status,
|
||||||
|
startDate: sprint.startDate,
|
||||||
|
endDate: sprint.endDate,
|
||||||
|
});
|
||||||
34
apps/mcp/src/app/tools/tool-result.ts
Normal file
34
apps/mcp/src/app/tools/tool-result.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import type { CallToolResult } from '@modelcontextprotocol/server';
|
||||||
|
import { ApiError } from '../api/taskstream-api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs a tool and answers with its result as JSON text, or, if the API
|
||||||
|
* refuses it, with an error result the model can read and act on (instead of
|
||||||
|
* a protocol error that aborts the call).
|
||||||
|
*/
|
||||||
|
export async function toolResult(
|
||||||
|
run: () => Promise<unknown>,
|
||||||
|
): Promise<CallToolResult> {
|
||||||
|
try {
|
||||||
|
const result = await run();
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text:
|
||||||
|
typeof result === 'string'
|
||||||
|
? result
|
||||||
|
: JSON.stringify(result ?? { ok: true }, null, 2),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof ApiError)) throw error;
|
||||||
|
return {
|
||||||
|
isError: true,
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: `Error ${error.status}: ${error.message}` },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
49
apps/mcp/src/app/tools/users.resolver.ts
Normal file
49
apps/mcp/src/app/tools/users.resolver.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { McpContext, Resolver, Tool } from '@nestjs-mcp/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { TaskStreamApi } from '../api/taskstream-api';
|
||||||
|
import { limit } from './params';
|
||||||
|
import { toolResult } from './tool-result';
|
||||||
|
|
||||||
|
const READ = { readOnlyHint: true, openWorldHint: false } as const;
|
||||||
|
|
||||||
|
@Resolver()
|
||||||
|
export class UsersResolver {
|
||||||
|
constructor(private readonly api: TaskStreamApi) {}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'whoami',
|
||||||
|
description:
|
||||||
|
'The user this MCP acts as (from the Pireon token the client sends).',
|
||||||
|
annotations: READ,
|
||||||
|
})
|
||||||
|
whoami(ctx: McpContext) {
|
||||||
|
return toolResult(() =>
|
||||||
|
this.api.data(this.api.as(ctx).GET('/api/users/me')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool({
|
||||||
|
name: 'search_users',
|
||||||
|
description:
|
||||||
|
'Find Pireon users by name or email (to assign tickets: use their id).',
|
||||||
|
paramsSchema: z.object({
|
||||||
|
search: z.string().min(2),
|
||||||
|
limit: limit(50, 10),
|
||||||
|
}),
|
||||||
|
annotations: READ,
|
||||||
|
})
|
||||||
|
search(
|
||||||
|
{ search, limit }: { search: string; limit: number },
|
||||||
|
ctx: McpContext,
|
||||||
|
) {
|
||||||
|
return toolResult(async () =>
|
||||||
|
(
|
||||||
|
await this.api.data(
|
||||||
|
this.api
|
||||||
|
.as(ctx)
|
||||||
|
.GET('/api/users', { params: { query: { search, limit } } }),
|
||||||
|
)
|
||||||
|
).map((u) => ({ id: u.id, name: u.name, email: u.email })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
0
apps/mcp/src/assets/.gitkeep
Normal file
0
apps/mcp/src/assets/.gitkeep
Normal file
15
apps/mcp/src/main.ts
Normal file
15
apps/mcp/src/main.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { AppModule } from './app/app.module';
|
||||||
|
|
||||||
|
/** MCP server of TaskStream over Streamable HTTP: `POST /mcp`. */
|
||||||
|
async function bootstrap() {
|
||||||
|
const app = await NestFactory.create(AppModule);
|
||||||
|
app.enableShutdownHooks();
|
||||||
|
// Not PORT: Nx loads the workspace .env into every task, and there PORT is the API's.
|
||||||
|
const port = Number(process.env['MCP_PORT'] ?? 3010);
|
||||||
|
await app.listen(port);
|
||||||
|
Logger.log(`🔌 MCP: http://localhost:${port}/mcp`, 'Bootstrap');
|
||||||
|
}
|
||||||
|
|
||||||
|
void bootstrap();
|
||||||
24
apps/mcp/tsconfig.app.json
Normal file
24
apps/mcp/tsconfig.app.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../../dist/out-tsc",
|
||||||
|
"module": "commonjs",
|
||||||
|
"types": ["node"],
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"target": "es2021",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"strictBindCallApply": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": [
|
||||||
|
"jest.config.ts",
|
||||||
|
"jest.config.cts",
|
||||||
|
"src/**/*.spec.ts",
|
||||||
|
"src/**/*.test.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
16
apps/mcp/tsconfig.json
Normal file
16
apps/mcp/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"files": [],
|
||||||
|
"include": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.app.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.spec.json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"esModuleInterop": true
|
||||||
|
}
|
||||||
|
}
|
||||||
16
apps/mcp/tsconfig.spec.json
Normal file
16
apps/mcp/tsconfig.spec.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../../dist/out-tsc",
|
||||||
|
"module": "commonjs",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"types": ["jest", "node"]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"jest.config.ts",
|
||||||
|
"jest.config.cts",
|
||||||
|
"src/**/*.test.ts",
|
||||||
|
"src/**/*.spec.ts",
|
||||||
|
"src/**/*.d.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
25
apps/mcp/webpack.config.js
Normal file
25
apps/mcp/webpack.config.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
const { NxAppWebpackPlugin } = require('@nx/webpack/app-plugin');
|
||||||
|
const { join } = require('path');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
output: {
|
||||||
|
path: join(__dirname, '../../dist/apps/mcp'),
|
||||||
|
clean: true,
|
||||||
|
...(process.env.NODE_ENV !== 'production' && {
|
||||||
|
devtoolModuleFilenameTemplate: '[absolute-resource-path]',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
new NxAppWebpackPlugin({
|
||||||
|
target: 'node',
|
||||||
|
compiler: 'tsc',
|
||||||
|
main: './src/main.ts',
|
||||||
|
tsConfig: './tsconfig.app.json',
|
||||||
|
assets: ['./src/assets'],
|
||||||
|
optimization: false,
|
||||||
|
outputHashing: 'none',
|
||||||
|
generatePackageJson: true,
|
||||||
|
sourceMap: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
# Same-origin API for the SPA: REST under /api, GraphQL (HTTP + WebSocket) under /graphql.
|
# Same-origin API for the SPA: REST under /api, GraphQL (HTTP + WebSocket) under /graphql.
|
||||||
|
# The API is reached by its unique alias (task-stream-api), not by the service name `api`:
|
||||||
|
# on a shared network (Dokploy) another project's `api` would get half of the requests.
|
||||||
map $http_upgrade $connection_upgrade {
|
map $http_upgrade $connection_upgrade {
|
||||||
default upgrade;
|
default upgrade;
|
||||||
'' close;
|
'' close;
|
||||||
@@ -9,13 +11,15 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://api:3000;
|
# Attachments: the API takes files up to ATTACHMENTS_MAX_MB (25 MB by default).
|
||||||
|
client_max_body_size 30m;
|
||||||
|
proxy_pass http://task-stream-api:3000;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /graphql {
|
location /graphql {
|
||||||
proxy_pass http://api:3000;
|
proxy_pass http://task-stream-api:3000;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection $connection_upgrade;
|
proxy_set_header Connection $connection_upgrade;
|
||||||
@@ -24,6 +28,19 @@ server {
|
|||||||
proxy_read_timeout 1h;
|
proxy_read_timeout 1h;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# The service worker and the manifest have no hash in their name: never
|
||||||
|
# cached, or browsers would keep an old one (exact matches win over the
|
||||||
|
# regex below).
|
||||||
|
location = /push-sw.js {
|
||||||
|
add_header Cache-Control "no-cache";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
location = /manifest.webmanifest {
|
||||||
|
add_header Cache-Control "no-cache";
|
||||||
|
default_type application/manifest+json;
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
# Hashed bundles can be cached forever; index.html never.
|
# Hashed bundles can be cached forever; index.html never.
|
||||||
location ~* \.(js|css|woff2?|svg|png|ico)$ {
|
location ~* \.(js|css|woff2?|svg|png|ico)$ {
|
||||||
expires 1y;
|
expires 1y;
|
||||||
|
|||||||
BIN
apps/web/public/logo-192.png
Normal file
BIN
apps/web/public/logo-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
BIN
apps/web/public/logo-512.png
Normal file
BIN
apps/web/public/logo-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
25
apps/web/public/manifest.webmanifest
Normal file
25
apps/web/public/manifest.webmanifest
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "TaskStream",
|
||||||
|
"short_name": "TaskStream",
|
||||||
|
"description": "Gestión de proyectos y tickets en tiempo real",
|
||||||
|
"lang": "es",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#f7f7fa",
|
||||||
|
"theme_color": "#a347e1",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/logo-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/logo-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
52
apps/web/public/push-sw.js
Normal file
52
apps/web/public/push-sw.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/*
|
||||||
|
* TaskStream's service worker: push notifications only. It caches nothing,
|
||||||
|
* so a new deploy never leaves an old version of the app behind.
|
||||||
|
*
|
||||||
|
* The API sends `{ title, body, url, tag }` (see Notifier's payloadOf).
|
||||||
|
*/
|
||||||
|
self.addEventListener('install', () => self.skipWaiting());
|
||||||
|
self.addEventListener('activate', (event) =>
|
||||||
|
event.waitUntil(self.clients.claim()),
|
||||||
|
);
|
||||||
|
|
||||||
|
self.addEventListener('push', (event) => {
|
||||||
|
let data = {};
|
||||||
|
try {
|
||||||
|
data = event.data ? event.data.json() : {};
|
||||||
|
} catch {
|
||||||
|
data = { body: event.data ? event.data.text() : '' };
|
||||||
|
}
|
||||||
|
event.waitUntil(
|
||||||
|
self.registration.showNotification(data.title || 'TaskStream', {
|
||||||
|
body: data.body || '',
|
||||||
|
tag: data.tag,
|
||||||
|
icon: '/logo-192.png',
|
||||||
|
badge: '/logo-96.png',
|
||||||
|
data: { url: data.url || '/' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A tap opens the ticket: in a TaskStream tab already open, or in a new one.
|
||||||
|
self.addEventListener('notificationclick', (event) => {
|
||||||
|
event.notification.close();
|
||||||
|
const target = new URL(
|
||||||
|
event.notification.data?.url || '/',
|
||||||
|
self.location.origin,
|
||||||
|
).href;
|
||||||
|
event.waitUntil(
|
||||||
|
self.clients
|
||||||
|
.matchAll({ type: 'window', includeUncontrolled: true })
|
||||||
|
.then((tabs) => {
|
||||||
|
for (const tab of tabs) {
|
||||||
|
if (
|
||||||
|
new URL(tab.url).origin === self.location.origin &&
|
||||||
|
'focus' in tab
|
||||||
|
) {
|
||||||
|
return tab.focus().then(() => tab.navigate(target));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return self.clients.openWindow(target);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
} from '@task-stream/web/shared/infrastructure';
|
} from '@task-stream/web/shared/infrastructure';
|
||||||
import { providePiUi, ToastService } from '@task-stream/web/shared/ui';
|
import { providePiUi, ToastService } from '@task-stream/web/shared/ui';
|
||||||
import { Notifications } from '@task-stream/web/tracker/application';
|
import { Notifications } from '@task-stream/web/tracker/application';
|
||||||
import { provideTrackerInfrastructure } from '@task-stream/web/tracker/infrastructure';
|
import { provideTrackerShellInfrastructure } from '@task-stream/web/tracker/infrastructure';
|
||||||
import { appRoutes } from './app.routes';
|
import { appRoutes } from './app.routes';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -39,9 +39,9 @@ export const appConfig: ApplicationConfig = {
|
|||||||
provideApiConfig(apiConfigFromLocation()),
|
provideApiConfig(apiConfigFromLocation()),
|
||||||
provideGraphql(),
|
provideGraphql(),
|
||||||
|
|
||||||
// Ports → adapters
|
// Ports → adapters: the shell's here; the pages' with their lazy routes (tracker.routes.ts).
|
||||||
provideIdentityInfrastructure(),
|
provideIdentityInfrastructure(),
|
||||||
provideTrackerInfrastructure(),
|
provideTrackerShellInfrastructure(),
|
||||||
{ provide: Notifications, useExisting: ToastService },
|
{ provide: Notifications, useExisting: ToastService },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,24 +1,75 @@
|
|||||||
|
import { Route } from '@angular/router';
|
||||||
import { authGuard } from '@task-stream/web/identity/presenters';
|
import { authGuard } from '@task-stream/web/identity/presenters';
|
||||||
import { appRoutes } from './app.routes';
|
import { appRoutes } from './app.routes';
|
||||||
|
|
||||||
describe('appRoutes', () => {
|
/** The tracker's routes, behind the lazy `''` route (and its providers' parent route). */
|
||||||
it('keeps sign-in public and everything else behind the auth guard', () => {
|
async function trackerChildren(): Promise<Route[]> {
|
||||||
const paths = appRoutes.map((route) => route.path);
|
const lazy = appRoutes.find(
|
||||||
expect(paths).toEqual(expect.arrayContaining(['login', 'auth/callback', '', '**']));
|
(route) => route.path === '' && route.loadChildren,
|
||||||
|
);
|
||||||
|
const loaded = (await lazy?.loadChildren?.()) as Route[];
|
||||||
|
expect(loaded[0].providers?.length).toBeGreaterThan(0);
|
||||||
|
return loaded[0].children ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
const app = appRoutes.find((route) => route.path === '' && route.children);
|
describe('appRoutes', () => {
|
||||||
expect(app?.canActivate).toContain(authGuard);
|
it('keeps home behind the auth guard, and public projects and «Explorar» open', async () => {
|
||||||
expect(app?.children?.map((route) => route.path)).toEqual(['', 'p/:key']);
|
const paths = appRoutes.map((route) => route.path);
|
||||||
|
expect(paths).toEqual(
|
||||||
|
expect.arrayContaining(['login', 'auth/callback', '', '**']),
|
||||||
|
);
|
||||||
|
|
||||||
|
const children = await trackerChildren();
|
||||||
|
expect(children.map((route) => route.path)).toEqual([
|
||||||
|
'',
|
||||||
|
'portfolio',
|
||||||
|
'explore',
|
||||||
|
'share/:token',
|
||||||
|
'notes/:token',
|
||||||
|
'cartera',
|
||||||
|
'explorar',
|
||||||
|
'p/:key',
|
||||||
|
]);
|
||||||
|
expect(children[0].canActivate).toContain(authGuard);
|
||||||
|
expect(children[1].canActivate).toContain(authGuard);
|
||||||
|
expect(children[2].canActivate).toBeUndefined();
|
||||||
|
// The pages for clients need no session: their link is the key.
|
||||||
|
expect(children[3].canActivate).toBeUndefined();
|
||||||
|
expect(children[4].canActivate).toBeUndefined();
|
||||||
|
expect(children[7].canActivate).toBeUndefined();
|
||||||
|
// The Spanish paths of shared links lead to the English ones.
|
||||||
|
expect(children[5].redirectTo).toBe('portfolio');
|
||||||
|
expect(children[6].redirectTo).toBe('explore');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('exposes every project section', () => {
|
it('gives each page its own state, not the root', async () => {
|
||||||
const project = appRoutes.find((route) => route.path === '')?.children?.find((route) => route.path === 'p/:key');
|
const children = await trackerChildren();
|
||||||
|
expect(children[0].providers?.length).toBe(4);
|
||||||
|
expect(children[2].providers?.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes every project section', async () => {
|
||||||
|
const project = (await trackerChildren()).find(
|
||||||
|
(route) => route.path === 'p/:key',
|
||||||
|
);
|
||||||
expect(project?.children?.map((route) => route.path)).toEqual([
|
expect(project?.children?.map((route) => route.path)).toEqual([
|
||||||
'',
|
'',
|
||||||
|
'proponer',
|
||||||
'board',
|
'board',
|
||||||
|
'propose',
|
||||||
|
'list',
|
||||||
'issues/:issueKey',
|
'issues/:issueKey',
|
||||||
|
'backlog',
|
||||||
|
'sprints',
|
||||||
|
'calendar',
|
||||||
|
'roadmap',
|
||||||
|
'epics',
|
||||||
|
'trash',
|
||||||
'releases',
|
'releases',
|
||||||
|
'report/:kind/:id',
|
||||||
'dashboard',
|
'dashboard',
|
||||||
|
'analytics',
|
||||||
|
'settings',
|
||||||
'activity',
|
'activity',
|
||||||
'members',
|
'members',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { Route } from '@angular/router';
|
import { Route } from '@angular/router';
|
||||||
import {
|
import { identityRoutes } from '@task-stream/web/identity/presenters';
|
||||||
authGuard,
|
|
||||||
identityRoutes,
|
|
||||||
} from '@task-stream/web/identity/presenters';
|
|
||||||
import { trackerRoutes } from '@task-stream/web/tracker/presenters';
|
|
||||||
|
|
||||||
export const appRoutes: Route[] = [
|
export const appRoutes: Route[] = [
|
||||||
...identityRoutes,
|
...identityRoutes,
|
||||||
{ path: '', canActivate: [authGuard], children: trackerRoutes },
|
// Not all behind the guard: public projects are readable without a session.
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
loadChildren: () => import('./tracker.routes').then((m) => m.routes),
|
||||||
|
},
|
||||||
{ path: '**', redirectTo: '' },
|
{ path: '**', redirectTo: '' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,8 +1,21 @@
|
|||||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||||
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
import { toSignal } from '@angular/core/rxjs-interop';
|
||||||
|
import {
|
||||||
|
NavigationEnd,
|
||||||
|
Router,
|
||||||
|
RouterLink,
|
||||||
|
RouterLinkActive,
|
||||||
|
RouterOutlet,
|
||||||
|
} from '@angular/router';
|
||||||
|
import { filter, map } from 'rxjs';
|
||||||
import { AuthFacade } from '@task-stream/web/identity/application';
|
import { AuthFacade } from '@task-stream/web/identity/application';
|
||||||
import { UserMenuComponent } from '@task-stream/web/identity/presenters';
|
import { UserMenuComponent } from '@task-stream/web/identity/presenters';
|
||||||
|
import { PiIconButtonComponent } from '@pi/ui';
|
||||||
import { AppShellComponent } from '@task-stream/web/shared/ui';
|
import { AppShellComponent } from '@task-stream/web/shared/ui';
|
||||||
|
import {
|
||||||
|
CommandPalette,
|
||||||
|
NotificationsBell,
|
||||||
|
} from '@task-stream/web/tracker/presenters';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'ts-root',
|
selector: 'ts-root',
|
||||||
@@ -13,7 +26,26 @@ import { AppShellComponent } from '@task-stream/web/shared/ui';
|
|||||||
RouterLinkActive,
|
RouterLinkActive,
|
||||||
AppShellComponent,
|
AppShellComponent,
|
||||||
UserMenuComponent,
|
UserMenuComponent,
|
||||||
|
NotificationsBell,
|
||||||
|
CommandPalette,
|
||||||
|
PiIconButtonComponent,
|
||||||
],
|
],
|
||||||
|
styles: `
|
||||||
|
/* The palette's button, still: same box, so nothing moves when it loads. */
|
||||||
|
.ts-shell-search {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.375rem;
|
||||||
|
align-items: center;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
kbd {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
template: `
|
template: `
|
||||||
<ts-app-shell>
|
<ts-app-shell>
|
||||||
@if (auth.isAuthenticated()) {
|
@if (auth.isAuthenticated()) {
|
||||||
@@ -28,7 +60,56 @@ import { AppShellComponent } from '@task-stream/web/shared/ui';
|
|||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
@if (auth.isAuthenticated()) {
|
@if (auth.isAuthenticated()) {
|
||||||
<ts-user-menu shellUser />
|
<a
|
||||||
|
shellNav
|
||||||
|
class="ts-nav-link"
|
||||||
|
routerLink="/portfolio"
|
||||||
|
routerLinkActive="is-active"
|
||||||
|
>
|
||||||
|
Cartera
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
<a
|
||||||
|
shellNav
|
||||||
|
class="ts-nav-link"
|
||||||
|
routerLink="/explore"
|
||||||
|
routerLinkActive="is-active"
|
||||||
|
>
|
||||||
|
Explorar
|
||||||
|
</a>
|
||||||
|
@if (auth.isAuthenticated()) {
|
||||||
|
<!-- One projectable node per @if branch, or neither reaches the slot. -->
|
||||||
|
<ng-container shellUser>
|
||||||
|
<!-- Palette and bell load once the page is up: their code and their
|
||||||
|
queries stay out of the start. Meanwhile, a still copy. -->
|
||||||
|
@defer (on idle; on interaction) {
|
||||||
|
<ts-command-palette />
|
||||||
|
} @placeholder {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ts-nav-link ts-shell-search"
|
||||||
|
tabindex="-1"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
Buscar <kbd>⌘K</kbd>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@defer (on idle; on interaction) {
|
||||||
|
<ts-notifications-bell />
|
||||||
|
} @placeholder {
|
||||||
|
<pi-icon-button
|
||||||
|
icon="bell"
|
||||||
|
color="grey"
|
||||||
|
appearance="ghost"
|
||||||
|
aria-label="Notificaciones"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
<ts-user-menu />
|
||||||
|
</ng-container>
|
||||||
|
} @else if (auth.settled() && !onLogin()) {
|
||||||
|
<a shellUser class="ts-nav-link ts-nav-link--cta" routerLink="/login"
|
||||||
|
>Iniciar sesión</a
|
||||||
|
>
|
||||||
}
|
}
|
||||||
<router-outlet />
|
<router-outlet />
|
||||||
</ts-app-shell>
|
</ts-app-shell>
|
||||||
@@ -36,4 +117,13 @@ import { AppShellComponent } from '@task-stream/web/shared/ui';
|
|||||||
})
|
})
|
||||||
export class App {
|
export class App {
|
||||||
protected readonly auth = inject(AuthFacade);
|
protected readonly auth = inject(AuthFacade);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
/** The login page has its own form: no "Iniciar sesión" button there. */
|
||||||
|
protected readonly onLogin = toSignal(
|
||||||
|
this.router.events.pipe(
|
||||||
|
filter((event) => event instanceof NavigationEnd),
|
||||||
|
map(() => this.router.url.startsWith('/login')),
|
||||||
|
),
|
||||||
|
{ initialValue: false },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
15
apps/web/src/app/tracker.routes.ts
Normal file
15
apps/web/src/app/tracker.routes.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Route } from '@angular/router';
|
||||||
|
import { provideTrackerPagesInfrastructure } from '@task-stream/web/tracker/infrastructure';
|
||||||
|
import { trackerRoutes } from '@task-stream/web/tracker/presenters';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tracker, loaded lazily: its pages, and the adapters only they use,
|
||||||
|
* come in this chunk instead of the one that boots the app.
|
||||||
|
*/
|
||||||
|
export const routes: Route[] = [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
providers: [provideTrackerPagesInfrastructure()],
|
||||||
|
children: trackerRoutes,
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -12,6 +12,8 @@
|
|||||||
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||||
<link rel="icon" type="image/png" sizes="96x96" href="logo-96.png" />
|
<link rel="icon" type="image/png" sizes="96x96" href="logo-96.png" />
|
||||||
<link rel="apple-touch-icon" href="logo.png" />
|
<link rel="apple-touch-icon" href="logo.png" />
|
||||||
|
<!-- Installable (on iPhone, push only reaches an installed web app) -->
|
||||||
|
<link rel="manifest" href="manifest.webmanifest" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<!-- Inter, like pireon.dev · Geist Mono only for data (ticket keys, counters, versions) -->
|
<!-- Inter, like pireon.dev · Geist Mono only for data (ticket keys, counters, versions) -->
|
||||||
|
|||||||
@@ -114,6 +114,31 @@ html {
|
|||||||
--ts-t-ui: #d6409f;
|
--ts-t-ui: #d6409f;
|
||||||
--ts-t-task: #64748b;
|
--ts-t-task: #64748b;
|
||||||
--ts-t-qa: #d97706;
|
--ts-t-qa: #d97706;
|
||||||
|
|
||||||
|
// Palette of the per-project settings (statuses and ticket types pick one).
|
||||||
|
--ts-c-slate: #64748b;
|
||||||
|
--ts-c-stone: #8b8b9a;
|
||||||
|
--ts-c-violet: #a347e1;
|
||||||
|
--ts-c-blue: #1879bf;
|
||||||
|
--ts-c-sky: #0284c7;
|
||||||
|
--ts-c-teal: #0f766e;
|
||||||
|
--ts-c-green: #15803d;
|
||||||
|
--ts-c-amber: #b7791f;
|
||||||
|
--ts-c-orange: #ea580c;
|
||||||
|
--ts-c-red: #dc2626;
|
||||||
|
--ts-c-pink: #d6409f;
|
||||||
|
|
||||||
|
// Charts (validated with the dataviz checks against --ts-surface, light and
|
||||||
|
// dark): two categorical slots, an ordinal ramp for flow stages and the
|
||||||
|
// reserved good/critical status colours.
|
||||||
|
--ts-viz-1: #2a78d6;
|
||||||
|
--ts-viz-2: #eb6834;
|
||||||
|
--ts-viz-ramp-1: #86b6ef;
|
||||||
|
--ts-viz-ramp-2: #5598e7;
|
||||||
|
--ts-viz-ramp-3: #256abf;
|
||||||
|
--ts-viz-ramp-4: #104281;
|
||||||
|
--ts-viz-good: #0ca30c;
|
||||||
|
--ts-viz-critical: #d03b3b;
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
@@ -316,6 +341,19 @@ h3 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Text for screen readers only (same helper as pireon.dev). */
|
||||||
|
.visually-hidden {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip-path: inset(50%);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.ts-prewrap {
|
.ts-prewrap {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
@@ -368,6 +406,13 @@ h3 {
|
|||||||
.ts-form {
|
.ts-form {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
|
||||||
|
// A field's label floats above its box; other fields leave room for it
|
||||||
|
// below them (their hint line), a segmented control does not.
|
||||||
|
> pi-segmented-control,
|
||||||
|
> p:has(+ pi-text-field, + pi-textarea, + pi-select-field, + pi-date-field) {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ts-form__row {
|
.ts-form__row {
|
||||||
@@ -444,6 +489,18 @@ h3 {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
background: var(--pi-color-primary-50);
|
background: var(--pi-color-primary-50);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "Iniciar sesión" for visitors of public projects.
|
||||||
|
&--cta {
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--ts-brand);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #fff;
|
||||||
|
filter: brightness(1.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
@@ -501,6 +558,25 @@ html.dark-mode .pi-shell {
|
|||||||
--ts-t-task: #94a3b8;
|
--ts-t-task: #94a3b8;
|
||||||
--ts-t-qa: #fbbf24;
|
--ts-t-qa: #fbbf24;
|
||||||
|
|
||||||
|
--ts-c-slate: #94a3b8;
|
||||||
|
--ts-c-stone: #8a91a4;
|
||||||
|
--ts-c-violet: #ae5de5;
|
||||||
|
--ts-c-blue: #339be6;
|
||||||
|
--ts-c-sky: #38bdf8;
|
||||||
|
--ts-c-teal: #2dd4bf;
|
||||||
|
--ts-c-green: #34c98a;
|
||||||
|
--ts-c-amber: #eab308;
|
||||||
|
--ts-c-orange: #fb923c;
|
||||||
|
--ts-c-red: #f07070;
|
||||||
|
--ts-c-pink: #f472b6;
|
||||||
|
|
||||||
|
--ts-viz-1: #3987e5;
|
||||||
|
--ts-viz-2: #d95926;
|
||||||
|
--ts-viz-ramp-1: #184f95;
|
||||||
|
--ts-viz-ramp-2: #2a78d6;
|
||||||
|
--ts-viz-ramp-3: #6da7ec;
|
||||||
|
--ts-viz-ramp-4: #b7d3f6;
|
||||||
|
|
||||||
--pi-color-grey-10: #12141a;
|
--pi-color-grey-10: #12141a;
|
||||||
--pi-color-grey-25: #161922;
|
--pi-color-grey-25: #161922;
|
||||||
--pi-color-grey-50: #1a1e28;
|
--pi-color-grey-50: #1a1e28;
|
||||||
@@ -549,3 +625,34 @@ html.dark-mode .pi-shell {
|
|||||||
background-color: var(--ts-surface-raised) !important;
|
background-color: var(--ts-surface-raised) !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Printing a report (/p/:key/report/…): the report alone, on white paper.
|
||||||
|
@media print {
|
||||||
|
body:has(ts-report-page) {
|
||||||
|
background: #fff !important;
|
||||||
|
color: #000;
|
||||||
|
|
||||||
|
.ts-header,
|
||||||
|
.ts-project-header,
|
||||||
|
.ts-tabs,
|
||||||
|
.ts-main::before,
|
||||||
|
.ts-main::after {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-main {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No tinted panels or soft lights on paper.
|
||||||
|
.ts-panel {
|
||||||
|
background: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
|
||||||
|
&::before,
|
||||||
|
&::after {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
16
docker-compose.override.yml
Normal file
16
docker-compose.override.yml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Development only: published ports, so `npx nx serve api` / `npx nx serve web` and your tools
|
||||||
|
# can reach the containers. Compose loads this file automatically with docker-compose.yml;
|
||||||
|
# production (Dokploy) runs `-f docker-compose.yml` alone and publishes nothing.
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
ports: ['8080:80']
|
||||||
|
api:
|
||||||
|
ports: ['3000:3000']
|
||||||
|
mcp:
|
||||||
|
ports: ['3010:3010']
|
||||||
|
postgres:
|
||||||
|
ports: ['5432:5432']
|
||||||
|
mongo:
|
||||||
|
ports: ['27017:27017']
|
||||||
|
redis:
|
||||||
|
ports: ['6379:6379']
|
||||||
@@ -1,16 +1,26 @@
|
|||||||
# TaskStream: web + API + infrastructure.
|
# TaskStream: web + API + infrastructure — production definition (Dokploy deploys this file).
|
||||||
|
# No ports are published here: services only talk over the compose network, and the web is
|
||||||
|
# exposed through the platform's proxy (Dokploy domain → service `web`, port 80).
|
||||||
|
#
|
||||||
|
# Services talk to each other by unique aliases (task-stream-*), never by the bare service
|
||||||
|
# name: on a shared network (Dokploy's dokploy-network) other projects also have an `api`,
|
||||||
|
# `postgres` or `redis`, and Docker DNS would hand out theirs too.
|
||||||
|
#
|
||||||
|
# Locally, `docker compose` also loads docker-compose.override.yml, which publishes the ports:
|
||||||
# docker compose up -d --build → everything: web on http://localhost:8080
|
# docker compose up -d --build → everything: web on http://localhost:8080
|
||||||
# docker compose up -d postgres mongo redis → only infrastructure (then `npx nx serve api` / `npx nx serve web`)
|
# docker compose up -d postgres mongo redis → only infrastructure (then `npx nx serve api` / `npx nx serve web`)
|
||||||
#
|
#
|
||||||
# Building needs access to Pireon's Nexus (@pi/ui): the token is read from ~/.npmrc as a build secret.
|
# Building needs access to Pireon's Nexus (@pi/ui): set NEXUS_TOKEN in the environment or in .env
|
||||||
|
# (Dokploy: Environment tab). It reaches the build as a secret and never lands in an image layer.
|
||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
target: web
|
target: web
|
||||||
secrets: [npmrc]
|
secrets: [nexus_token]
|
||||||
image: task-stream-web
|
image: task-stream-web
|
||||||
ports: ['8080:80']
|
expose: ['80']
|
||||||
|
networks: [default]
|
||||||
depends_on:
|
depends_on:
|
||||||
api: { condition: service_healthy }
|
api: { condition: service_healthy }
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -19,37 +29,91 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
target: api
|
target: api
|
||||||
secrets: [npmrc]
|
secrets: [nexus_token]
|
||||||
image: task-stream-api
|
image: task-stream-api
|
||||||
ports: ['3000:3000']
|
expose: ['3000']
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
aliases: [task-stream-api]
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_URL: postgres://taskstream:taskstream@postgres:5432/taskstream
|
POSTGRES_URL: postgres://taskstream:taskstream@task-stream-postgres:5432/taskstream
|
||||||
# No migrations yet: TypeORM creates the event store and lookup tables.
|
# No migrations yet: TypeORM creates the event store and lookup tables.
|
||||||
POSTGRES_SYNCHRONIZE: 'true'
|
POSTGRES_SYNCHRONIZE: 'true'
|
||||||
MONGO_URL: mongodb://mongo:27017/taskstream_read
|
MONGO_URL: mongodb://task-stream-mongo:27017/taskstream_read
|
||||||
REDIS_URL: redis://redis:6379
|
REDIS_URL: redis://task-stream-redis:6379
|
||||||
EVENT_STREAM_KEY: task-stream:events
|
EVENT_STREAM_KEY: task-stream:events
|
||||||
EVENTS_BRIDGE_ENABLED: 'true'
|
EVENTS_BRIDGE_ENABLED: 'true'
|
||||||
PIREON_BASE_URL: https://oauth.pireon.dev
|
PIREON_BASE_URL: https://oauth.pireon.dev
|
||||||
PIREON_WEBHOOK_SECRET: ${PIREON_WEBHOOK_SECRET:-}
|
PIREON_WEBHOOK_SECRET: ${PIREON_WEBHOOK_SECRET:-}
|
||||||
|
PIREON_PASSWORD_WEBHOOK_SECRET: ${PIREON_PASSWORD_WEBHOOK_SECRET:-}
|
||||||
PIREON_CLIENT_ID: ${PIREON_CLIENT_ID:-}
|
PIREON_CLIENT_ID: ${PIREON_CLIENT_ID:-}
|
||||||
PIREON_CLIENT_SECRET: ${PIREON_CLIENT_SECRET:-}
|
PIREON_CLIENT_SECRET: ${PIREON_CLIENT_SECRET:-}
|
||||||
|
# Pireon clients allow one grant type: the one above is authorization_code,
|
||||||
|
# this one password (email + password sign-in). Optional.
|
||||||
|
PIREON_PASSWORD_CLIENT_ID: ${PIREON_PASSWORD_CLIENT_ID:-}
|
||||||
|
PIREON_PASSWORD_CLIENT_SECRET: ${PIREON_PASSWORD_CLIENT_SECRET:-}
|
||||||
GRAPHQL_PLAYGROUND: 'true'
|
GRAPHQL_PLAYGROUND: 'true'
|
||||||
# The SDL file is only useful in development (codegen).
|
# The SDL / OpenAPI files are only useful in development (codegen); the
|
||||||
|
# document is still served at /api/docs-json.
|
||||||
GRAPHQL_SCHEMA_FILE: ''
|
GRAPHQL_SCHEMA_FILE: ''
|
||||||
|
OPENAPI_FILE: ''
|
||||||
|
ATTACHMENTS_DIR: /data/attachments
|
||||||
|
ATTACHMENTS_MAX_MB: ${ATTACHMENTS_MAX_MB:-25}
|
||||||
|
# E-mail copies of notifications (optional).
|
||||||
|
SMTP_URL: ${SMTP_URL:-}
|
||||||
|
MAIL_FROM: ${MAIL_FROM:-}
|
||||||
|
WEB_URL: ${WEB_URL:-}
|
||||||
|
# Push notifications (optional): `npx web-push generate-vapid-keys`.
|
||||||
|
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
|
||||||
|
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
|
||||||
|
VAPID_SUBJECT: ${VAPID_SUBJECT:-}
|
||||||
|
# Outgoing webhooks to private addresses (off: they could reach internal services).
|
||||||
|
WEBHOOKS_ALLOW_PRIVATE: ${WEBHOOKS_ALLOW_PRIVATE:-false}
|
||||||
|
# Due-date reminders, checked every hour.
|
||||||
|
REMINDERS_ENABLED: ${REMINDERS_ENABLED:-true}
|
||||||
|
REMINDERS_TIMEZONE: ${REMINDERS_TIMEZONE:-Europe/Madrid}
|
||||||
|
volumes:
|
||||||
|
# Files attached to tickets: lose this volume and the tickets point at nothing.
|
||||||
|
- attachments-data:/data/attachments
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres: { condition: service_healthy }
|
postgres: { condition: service_healthy }
|
||||||
mongo: { condition: service_healthy }
|
mongo: { condition: service_healthy }
|
||||||
redis: { condition: service_healthy }
|
redis: { condition: service_healthy }
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# MCP server for AI clients (Claude, Cursor…): Streamable HTTP at /mcp. It consumes the API
|
||||||
|
# with each caller's own Pireon token (Authorization header), so it holds no credentials.
|
||||||
|
# Optional: it only starts with the `mcp` profile (`docker compose --profile mcp up -d`; in
|
||||||
|
# Dokploy, COMPOSE_PROFILES=mcp) and needs a domain → service `mcp`, port 3010.
|
||||||
|
mcp:
|
||||||
|
profiles: [mcp]
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
target: mcp
|
||||||
|
secrets: [nexus_token]
|
||||||
|
image: task-stream-mcp
|
||||||
|
expose: ['3010']
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
aliases: [task-stream-mcp]
|
||||||
|
environment:
|
||||||
|
TASKSTREAM_API_URL: http://task-stream-api:3000
|
||||||
|
# Domains it answers on (protection against DNS rebinding): the one Dokploy gives it.
|
||||||
|
MCP_ALLOWED_HOSTS: ${MCP_ALLOWED_HOSTS:-mcp.taskstream.pireon.dev,localhost}
|
||||||
|
MCP_LOG_LEVEL: ${MCP_LOG_LEVEL:-warn}
|
||||||
|
depends_on:
|
||||||
|
api: { condition: service_healthy }
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:17-alpine
|
image: postgres:17-alpine
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
aliases: [task-stream-postgres]
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: taskstream
|
POSTGRES_USER: taskstream
|
||||||
POSTGRES_PASSWORD: taskstream
|
POSTGRES_PASSWORD: taskstream
|
||||||
POSTGRES_DB: taskstream
|
POSTGRES_DB: taskstream
|
||||||
ports: ['5432:5432']
|
|
||||||
volumes: [postgres-data:/var/lib/postgresql/data]
|
volumes: [postgres-data:/var/lib/postgresql/data]
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ['CMD-SHELL', 'pg_isready -U taskstream']
|
test: ['CMD-SHELL', 'pg_isready -U taskstream']
|
||||||
@@ -58,7 +122,9 @@ services:
|
|||||||
|
|
||||||
mongo:
|
mongo:
|
||||||
image: mongo:8
|
image: mongo:8
|
||||||
ports: ['27017:27017']
|
networks:
|
||||||
|
default:
|
||||||
|
aliases: [task-stream-mongo]
|
||||||
volumes: [mongo-data:/data/db]
|
volumes: [mongo-data:/data/db]
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ['CMD', 'mongosh', '--quiet', '--eval', 'db.adminCommand("ping")']
|
test: ['CMD', 'mongosh', '--quiet', '--eval', 'db.adminCommand("ping")']
|
||||||
@@ -67,8 +133,10 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
aliases: [task-stream-redis]
|
||||||
command: ['redis-server', '--appendonly', 'yes']
|
command: ['redis-server', '--appendonly', 'yes']
|
||||||
ports: ['6379:6379']
|
|
||||||
volumes: [redis-data:/data]
|
volumes: [redis-data:/data]
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ['CMD', 'redis-cli', 'ping']
|
test: ['CMD', 'redis-cli', 'ping']
|
||||||
@@ -76,10 +144,11 @@ services:
|
|||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
attachments-data:
|
||||||
postgres-data:
|
postgres-data:
|
||||||
mongo-data:
|
mongo-data:
|
||||||
redis-data:
|
redis-data:
|
||||||
|
|
||||||
secrets:
|
secrets:
|
||||||
npmrc:
|
nexus_token:
|
||||||
file: ${HOME}/.npmrc
|
environment: NEXUS_TOKEN
|
||||||
|
|||||||
@@ -2,18 +2,51 @@
|
|||||||
|
|
||||||
## Qué resuelve
|
## Qué resuelve
|
||||||
|
|
||||||
| Necesidad | Cómo se resuelve |
|
| Necesidad | Cómo se resuelve |
|
||||||
| ---------------- | ----------------------------------------------------------------------------------------------------- |
|
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas |
|
| 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas |
|
||||||
| 🎨 Cambios de UI | Tipo `UI_CHANGE` con enlace al frame de Figma (validado contra `figma.com`) |
|
| 🌍 Públicos | Un proyecto público se puede leer sin iniciar sesión (web, GraphQL y REST): tablero, tickets, comentarios, releases, actividad y estadísticas; «Explorar» los lista |
|
||||||
| ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo |
|
| 🗑️ Borrar | El propietario borra un proyecto confirmando su clave; tickets, comentarios, releases y enlaces desde otros proyectos se borran en cascada (sagas) y la clave queda libre |
|
||||||
| 🧪 QA | Estados `READY_FOR_QA → IN_QA → DONE / QA_FAILED`; fallar QA exige un motivo |
|
| 🧩 Configuración | Cada proyecto tiene sus estados (con categoría, color y motivo obligatorio), transiciones, tipos de ticket y áreas; lo usado se archiva, no se borra |
|
||||||
| 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) |
|
| 🎨 Cambios de UI | Tipo `UI_CHANGE` con enlace al frame de Figma (validado contra `figma.com`) |
|
||||||
| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona |
|
| ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo |
|
||||||
| 💬 Comentarios | Decisiones y explicaciones dentro del ticket; el autor edita, el admin modera |
|
| 🧪 QA | Por defecto, `READY_FOR_QA → IN_QA → DONE / QA_FAILED`; fallar QA exige un motivo |
|
||||||
| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA) |
|
| 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) |
|
||||||
| 📊 Seguimiento | Panel con contadores, gráfico por estado, carga por persona, feed de actividad e historial por ticket |
|
| 📝 Notas de versión | Borrador en Markdown con los tickets terminados por tipo, editable y copiable, también tras publicar; y una página pública por release para los clientes, con enlace secreto |
|
||||||
| ⚡ Tiempo real | Tablero, comentarios, actividad, releases y membresías se actualizan solos (GraphQL subscriptions) |
|
| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona |
|
||||||
|
| 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con formato (editor visual, guardado en Markdown), capturas pegadas, respuestas, reacciones (👍 🎉…), decisiones (un comentario marcado sale encima del ticket y se encuentra al buscar), conversaciones que se resuelven con su conclusión, encuestas rápidas que acaban en decisión, «está escribiendo…» en vivo 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 |
|
||||||
|
| 📋 Lista | Todos los tickets en filas: ordenar por cualquier columna, filtrar, cambiar estado, prioridad o persona sin abrirlos, editar en bloque y duplicar |
|
||||||
|
| ↩️ Deshacer | Mover, reasignar o mandar a la papelera ofrece «Deshacer» unos segundos (también Ctrl/⌘ Z), con la orden contraria |
|
||||||
|
| ☀ Mi día | Cada mañana eliges los tickets de hoy; se tachan al terminar y lo que quede se pasa al día siguiente; y «Recordármelo» en cualquier ticket |
|
||||||
|
| 🚦 Salud | Un semáforo por proyecto (en su panel y en la cartera) que dice qué va mal y por qué: atascados, vencidos, bugs urgentes, QA, releases tarde |
|
||||||
|
| 🚦 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, decisiones y proyectos de todos tus proyectos (antes de escribir, tus tickets fijados); `g b`, `g k`, `g s`, `g r` navegan, `c` crea, en el tablero las flechas recorren las tarjetas y Shift+←/→ las cambia de columna, `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 y flechas entre las que dependen unas de otras |
|
||||||
|
| 🗳️ Propuestas y votos | Un proyecto público puede abrir un formulario para que cualquiera, sin cuenta, reporte un error o proponga una idea; quien tenga sesión vota lo que quiere |
|
||||||
|
| 🌐 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; grabar la pantalla o la voz desde el comentario y verlo ahí mismo |
|
||||||
|
| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA); lo que espera a otro ticket abierto sale marcado como «Bloqueado» |
|
||||||
|
| 🌿 Git | Webhook para GitHub, Gitea o GitLab: commits, ramas y pull requests que nombran un ticket (WEB-12) aparecen en su ficha |
|
||||||
|
| 🚨 Alertas → bugs | Sentry, Uptime Kuma, Grafana o cualquier herramienta abren bugs por una URL secreta; el mismo problema repetido va al bug abierto, y «resuelta» se dice en él |
|
||||||
|
| 🧾 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**
|
Los **usuarios no se gestionan aquí**: vienen del servidor de identidad **Pireon**
|
||||||
([Swagger](https://oauth.pireon.dev/api/documentation)). TaskStream gestiona proyectos, membresías y roles por
|
([Swagger](https://oauth.pireon.dev/api/documentation)). TaskStream gestiona proyectos, membresías y roles por
|
||||||
@@ -39,12 +72,13 @@ proyecto (`OWNER`, `ADMIN`, `MEMBER`, `VIEWER`).
|
|||||||
apps/
|
apps/
|
||||||
api/ composición de la API (NestJS): config, GraphQL, conexiones, e2e
|
api/ composición de la API (NestJS): config, GraphQL, conexiones, e2e
|
||||||
web/ composición del cliente (Angular): rutas, providers, estilos, nginx
|
web/ composición del cliente (Angular): rutas, providers, estilos, nginx
|
||||||
|
mcp/ servidor MCP para asistentes de IA (NestJS), consume la API REST
|
||||||
libs/
|
libs/
|
||||||
api/{shared,identity,tracker}/{domain,application,infrastructure,presenters}
|
api/{shared,identity,tracker}/{domain,application,infrastructure,presenters}
|
||||||
web/{shared,identity,tracker}/{domain,application,infrastructure,presenters} (+ web/shared/ui)
|
web/{shared,identity,tracker}/{domain,application,infrastructure,presenters} (+ web/shared/ui)
|
||||||
docs/ esta documentación
|
docs/ esta documentación
|
||||||
codegen.ts tipos GraphQL del cliente generados desde apps/api/schema.gql
|
codegen.ts tipos GraphQL del cliente generados desde apps/api/schema.gql
|
||||||
Dockerfile dos destinos: api y web
|
Dockerfile tres destinos: api, web y mcp
|
||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -23,14 +23,24 @@ flowchart LR
|
|||||||
| `domain` | Modelo y reglas de negocio; puertos que necesita el dominio | `domain` |
|
| `domain` | Modelo y reglas de negocio; puertos que necesita el dominio | `domain` |
|
||||||
| `application` | Casos de uso: services, commands/queries + handlers, proyecciones (API) o facades + stores (web), puertos de salida | `domain`, `application` |
|
| `application` | Casos de uso: services, commands/queries + handlers, proyecciones (API) o facades + stores (web), puertos de salida | `domain`, `application` |
|
||||||
| `infrastructure` | Adaptadores **de salida** que implementan los puertos (bases de datos, Redis, Pireon, GraphQL) | `domain`, `application`, `infrastructure` |
|
| `infrastructure` | Adaptadores **de salida** que implementan los puertos (bases de datos, Redis, Pireon, GraphQL) | `domain`, `application`, `infrastructure` |
|
||||||
| `presenters` | Adaptadores **de entrada** (resolvers GraphQL, controllers HTTP, guards, páginas Angular) | `domain`, `application`, `presenters` |
|
| `presenters` | Adaptadores **de entrada** (resolvers GraphQL, controllers HTTP, guards, temporizadores, páginas Angular) | `domain`, `application`, `presenters` |
|
||||||
|
|
||||||
Las reglas no son una convención: las comprueba `@nx/enforce-module-boundaries` en `eslint.config.mjs` (`npx nx run-many -t lint`).
|
Las reglas no son una convención: las comprueba `@nx/enforce-module-boundaries` en `eslint.config.mjs` (`npx nx run-many -t lint`).
|
||||||
|
|
||||||
- `domain` solo puede importar `@nestjs/common` (por `@Injectable()` de las factories) y `@nestjs/cqrs` (por `AggregateRoot`).
|
- `domain` solo puede importar `@nestjs/common` (por `@Injectable()` de las factories) y `@nestjs/cqrs` (por `AggregateRoot`).
|
||||||
Nada de TypeORM, Mongoose, Redis, GraphQL, Angular, NgRx ni Apollo.
|
Nada de TypeORM, Mongoose, Redis, GraphQL, Angular, NgRx ni Apollo.
|
||||||
- `application` no puede importar adaptadores (TypeORM, Mongoose, Redis, Apollo…).
|
- `application` no puede importar adaptadores (TypeORM, Mongoose, Redis, Apollo…).
|
||||||
- `presenters` nunca importa `infrastructure` ni usa los buses CQRS: habla con los `*.service.ts` (API) o con las facades (web).
|
- Los buses CQRS solo los usan los `*.service.ts` de `application` (los comandos, a través del `CommandRunner`, que
|
||||||
|
los reintenta si chocan; las consultas, con el `QueryBus`); todo lo demás pasa por
|
||||||
|
un servicio: resolvers, controllers, temporizadores (recordatorios, papelera; por eso viven en `presenters`), el
|
||||||
|
handler de eventos de las automatizaciones y el directorio de usuarios del tracker (`IdentityService`). La única
|
||||||
|
excepción es el `EventsBridge`, que ejecuta los comandos que devuelven las sagas.
|
||||||
|
- Los comandos son atómicos: un handler no ejecuta otros comandos. Lo que encadena varios (duplicar un ticket, la
|
||||||
|
edición en bloque, importar un CSV, las propuestas, vaciar la papelera) es un método del servicio, que ejecuta los
|
||||||
|
comandos uno a uno con su bus. Así no hay ciclos entre servicios y handlers, ni `forwardRef`.
|
||||||
|
- Sin módulos globales entre contextos: el tracker recibe el módulo de aplicación de identidad en la raíz de composición
|
||||||
|
(`TrackerInfrastructureModule.withIdentity(...)`), el mismo con el que se monta ese contexto.
|
||||||
|
- `presenters` nunca importa `infrastructure`: habla con los `*.service.ts` (API) o con las facades (web).
|
||||||
- `infrastructure` nunca importa `presenters`.
|
- `infrastructure` nunca importa `presenters`.
|
||||||
- Los contextos también están acotados: `tracker` puede usar `identity` y `shared`; `shared` no depende de nadie; la web
|
- Los contextos también están acotados: `tracker` puede usar `identity` y `shared`; `shared` no depende de nadie; la web
|
||||||
no depende de la API (y viceversa).
|
no depende de la API (y viceversa).
|
||||||
@@ -49,8 +59,11 @@ TrackerPresentersModule.withApplication(
|
|||||||
|
|
||||||
```ts
|
```ts
|
||||||
// apps/web/src/app/app.config.ts
|
// apps/web/src/app/app.config.ts
|
||||||
provideTrackerInfrastructure(); // puertos → adaptadores GraphQL
|
provideTrackerShellInfrastructure(); // puertos de la cabecera → adaptadores GraphQL
|
||||||
{ provide: Notifications, useExisting: ToastService } // puerto de la aplicación → toasts de @pi/ui
|
{ provide: Notifications, useExisting: ToastService } // puerto de la aplicación → toasts de @pi/ui
|
||||||
|
|
||||||
|
// apps/web/src/app/tracker.routes.ts (en diferido)
|
||||||
|
{ path: '', providers: [provideTrackerPagesInfrastructure()], children: trackerRoutes }
|
||||||
```
|
```
|
||||||
|
|
||||||
## 2.2 Flujo completo de una escritura
|
## 2.2 Flujo completo de una escritura
|
||||||
@@ -100,18 +113,39 @@ sequenceDiagram
|
|||||||
- **Guardar**: el `EventStorePublisher` sustituye al publisher del `EventBus`, así que `commit()` escribe en la tabla
|
- **Guardar**: el `EventStorePublisher` sustituye al publisher del `EventBus`, así que `commit()` escribe en la tabla
|
||||||
`events`. `commitAndWait()` espera a que la escritura termine (el `commit()` de Nest no devuelve la promesa).
|
`events`. `commitAndWait()` espera a que la escritura termine (el `commit()` de Nest no devuelve la promesa).
|
||||||
- **Concurrencia optimista**: `UNIQUE(stream_id, position)`. Si dos peticiones modifican el mismo agregado a la vez, la
|
- **Concurrencia optimista**: `UNIQUE(stream_id, position)`. Si dos peticiones modifican el mismo agregado a la vez, la
|
||||||
segunda falla con `ConcurrencyError` (código GraphQL `CONFLICT`).
|
segunda falla con `ConcurrencyError`. El `CommandRunner` (`shared/application`) la repite sobre el estado nuevo hasta
|
||||||
|
5 veces, con una pequeña espera creciente: cada comando carga su agregado de cero y guarda sus eventos en una sola
|
||||||
|
transacción, así que un choque significa que no se guardó nada. Solo si sigue chocando llega al cliente (código
|
||||||
|
GraphQL `CONFLICT`, HTTP `409`). Las sagas tienen su propio reintento en el `EventsBridge`.
|
||||||
- **Lookups**: lo que no se puede comprobar dentro de un agregado vive en tablas aparte: claves de proyecto únicas
|
- **Lookups**: lo que no se puede comprobar dentro de un agregado vive en tablas aparte: claves de proyecto únicas
|
||||||
(`project_keys`), numeración de tickets (`issue_sequences`) y nombres de release únicos (`release_names`).
|
(`project_keys`), numeración de tickets (`issue_sequences`) y nombres de release únicos (`release_names`).
|
||||||
|
- **Ids**: todas las tablas tienen `id uuid` (`gen_random_uuid()`); lo que hace única una fila de lookup es una
|
||||||
|
restricción aparte (la clave, el proyecto, proyecto + nombre). En `events`, el orden global con el que se reenvían los
|
||||||
|
eventos va en `seq` (`bigserial`), porque un uuid no se ordena; `stream_id` es el id del agregado.
|
||||||
|
|
||||||
### De escritura a lectura: Redis
|
### De escritura a lectura: Redis
|
||||||
|
|
||||||
|
- La tabla `events` es la **bandeja de salida (outbox)**: cada evento entra en la misma transacción que la escritura,
|
||||||
|
con `published_at` vacío. Si Postgres falla, la escritura falla entera y no queda nada a medias.
|
||||||
- El **`EventStoreRelay`** copia los eventos nuevos (`published_at IS NULL`) al Redis Stream `task-stream:events`,
|
- El **`EventStoreRelay`** copia los eventos nuevos (`published_at IS NULL`) al Redis Stream `task-stream:events`,
|
||||||
en orden y con `FOR UPDATE SKIP LOCKED` (varias instancias no duplican). La entrega es _at-least-once_.
|
en orden y con `FOR UPDATE SKIP LOCKED` (varias instancias no duplican). La entrega es _at-least-once_. Si Redis
|
||||||
|
falla, los eventos esperan en la tabla y salen al volver.
|
||||||
- El **`EventsBridge`** consume el stream con un _consumer group_, deserializa cada evento (`EventClsRegistry` +
|
- El **`EventsBridge`** consume el stream con un _consumer group_, deserializa cada evento (`EventClsRegistry` +
|
||||||
`@AutowiredEvent`) y lo entrega a los `@EventsHandler` registrados para su clase. A diferencia de
|
`@AutowiredEvent`) y lo entrega a los `@EventsHandler` registrados para su clase. A diferencia de
|
||||||
`eventBus.subject$.next()`, **espera** a cada handler y procesa los eventos en orden; solo hace `XACK` cuando todos
|
`eventBus.subject$.next()`, **espera** a cada handler y procesa los eventos en orden; solo hace `XACK` cuando todos
|
||||||
terminan. Si uno falla, el evento queda pendiente y se reintenta (y `XAUTOCLAIM` recupera los de instancias caídas).
|
terminan. El grupo de consumidores hace de **bandeja de entrada (inbox)**, y la idempotencia la ponen las
|
||||||
|
proyecciones (versión, abajo).
|
||||||
|
- **Si un evento falla, se reintenta en su sitio** (`EventStreamConsumer`), esperando cada vez más (hasta 30 s), y el
|
||||||
|
siguiente espera: saltárselo aplicaría antes un cambio posterior y la comprobación de versión descartaría después el
|
||||||
|
anterior para siempre. Si el fallo es de la infraestructura (Mongo, Redis o la red caídos, ver `isTransientError`),
|
||||||
|
espera lo que haga falta y se pone al día solo al volver, sin reiniciar. Si es del propio evento (un fallo del
|
||||||
|
código), tras 5 intentos va al stream `task-stream:events:dead` con el error, para no bloquear el resto. Lo que
|
||||||
|
queda pendiente al parar se reintenta al arrancar (y `XAUTOCLAIM` recupera lo de instancias caídas).
|
||||||
|
- `GET /api/health` dice en `events` cuántos eventos esperan en la bandeja de salida, cuántos están pendientes de
|
||||||
|
proyectar y cuántos se apartaron; con alguno de más, `status: degraded` (pero 200: un retraso de la lectura no
|
||||||
|
reinicia el contenedor). El stream se recorta a `EVENT_STREAM_MAX_LENGTH` (un millón): solo un corte larguísimo
|
||||||
|
podría recortar algo pendiente, y Postgres siempre permite reconstruir la lectura.
|
||||||
|
- Después de los handlers, el bridge ejecuta también las **sagas** (ver abajo).
|
||||||
|
|
||||||
### Lectura: MongoDB
|
### Lectura: MongoDB
|
||||||
|
|
||||||
@@ -121,6 +155,13 @@ sequenceDiagram
|
|||||||
que reentregar un evento no tiene efecto.
|
que reentregar un evento no tiene efecto.
|
||||||
- Las queries leen por los puertos `Find*Repository`. Los read models (`ProjectReadModel`, `IssueReadModel`…) están en
|
- Las queries leen por los puertos `Find*Repository`. Los read models (`ProjectReadModel`, `IssueReadModel`…) están en
|
||||||
`domain/read-models`.
|
`domain/read-models`.
|
||||||
|
- **Esquemas**: clases con `@Schema()` y `@Prop()` de `@nestjs/mongoose` (`read-model.schemas.ts`,
|
||||||
|
`user.schema.ts`), en `strict: false`: cada documento guarda el read model entero y las props declaradas son las que
|
||||||
|
tienen índice o una forma que conviene fijar.
|
||||||
|
- **Ids**: cada documento tiene `_id` (el `ObjectId` de Mongo, que no sale de la base de datos) e `id` con índice
|
||||||
|
único, que es el id del dominio: el mismo uuid que el `stream_id` del agregado en Postgres. Todas las consultas van
|
||||||
|
por `id`. En usuarios es el id de Pireon; en actividad, historial y avisos, uno compuesto que hace idempotente la
|
||||||
|
proyección.
|
||||||
|
|
||||||
### Tiempo real
|
### Tiempo real
|
||||||
|
|
||||||
@@ -128,6 +169,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
|
canales (uno por proyecto, ticket o usuario), así que funcionan con varias instancias de la API. Como se publica
|
||||||
**después** de proyectar, quien recibe el aviso siempre lee el estado nuevo.
|
**después** de proyectar, quien recibe el aviso siempre lee el estado nuevo.
|
||||||
|
|
||||||
|
### Sagas: reacciones entre agregados
|
||||||
|
|
||||||
|
Cuando un evento de un agregado debe cambiar otros agregados, lo hace una saga (`application/sagas`), con el `@Saga()`
|
||||||
|
estándar de `@nestjs/cqrs`: un observable que filtra eventos (`ofType`) y emite comandos. Como el `EventBus` en memoria
|
||||||
|
nunca ve los eventos guardados (su publisher es el event store), las sagas las ejecuta el **`EventsBridge`**: a cada
|
||||||
|
evento, tras las proyecciones, pasa ese evento solo (`of(event)`) por cada saga y ejecuta los comandos que emite, uno a
|
||||||
|
uno y esperando. Así heredan las garantías del bridge: orden, reintento y nada se pierde si la API se cae a mitad.
|
||||||
|
|
||||||
|
| Saga | Evento | Comandos |
|
||||||
|
| ---------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `ProjectDeletionSaga` | `ProjectDeletedEvent` | `DeleteIssueCommand` por cada ticket, `DeleteReleaseCommand` por cada release y `DeleteSprintCommand` por cada sprint del proyecto |
|
||||||
|
| | `IssueDeletedEvent` | `PurgeCommentCommand` por cada comentario, `DropLinksToIssueCommand` en los tickets que lo enlazaban y `DetachFromParentCommand` en los que colgaban de él |
|
||||||
|
| `ReleaseArchivedSaga` | `ReleaseArchivedEvent` | `DetachIssueFromReleaseCommand` para sus tickets sin terminar, a la release elegida al archivar o a ninguna (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
|
### Consistencia eventual
|
||||||
|
|
||||||
Las mutations devuelven `CommandResult { id, key, version }` en cuanto los eventos están guardados; Mongo se actualiza
|
Las mutations devuelven `CommandResult { id, key, version }` en cuanto los eventos están guardados; Mongo se actualiza
|
||||||
|
|||||||
656
docs/03-api.md
656
docs/03-api.md
@@ -11,7 +11,8 @@ classDiagram
|
|||||||
key: "TS"
|
key: "TS"
|
||||||
name
|
name
|
||||||
members: (userId, role)[]
|
members: (userId, role)[]
|
||||||
addMember() changeMemberRole() removeMember() updateDetails()
|
settings: estados · transiciones · tipos · áreas
|
||||||
|
addMember() changeMemberRole() removeMember() updateDetails() updateSettings()
|
||||||
}
|
}
|
||||||
class Issue {
|
class Issue {
|
||||||
key: "TS-42"
|
key: "TS-42"
|
||||||
@@ -37,17 +38,150 @@ classDiagram
|
|||||||
|
|
||||||
### Roles por proyecto
|
### Roles por proyecto
|
||||||
|
|
||||||
| Rol | Ver | Crear/modificar tickets, comentar | Gestionar miembros y releases |
|
| Rol | Ver | Crear/modificar tickets, comentar | Gestionar miembros y releases |
|
||||||
| -------------------------------- | --- | --------------------------------- | -------------------------------------------- |
|
| -------------------------------- | ---------------- | ----------------------------------------------- | -------------------------------------------- |
|
||||||
| `OWNER` (quien crea el proyecto) | ✅ | ✅ | ✅ (no se le puede quitar ni cambiar el rol) |
|
| `OWNER` (quien crea el proyecto) | ✅ | ✅ | ✅ (no se le puede quitar ni cambiar el rol) |
|
||||||
| `ADMIN` | ✅ | ✅ | ✅ |
|
| `ADMIN` | ✅ | ✅ | ✅ |
|
||||||
| `MEMBER` | ✅ | ✅ | ❌ |
|
| `MEMBER` | ✅ | ✅ | ❌ |
|
||||||
| `VIEWER` | ✅ | ❌ | ❌ |
|
| `VIEWER` | ✅ | ❌ | ❌ |
|
||||||
|
| `GUEST` | Solo sus tickets | Abrir tickets, comentar y adjuntar en los suyos | ❌ |
|
||||||
|
|
||||||
|
Un **invitado** (`GUEST`, para clientes o gente de fuera) de un proyecto privado solo ve los tickets en los que
|
||||||
|
participa: los que abrió, los que tiene asignados y los que sigue. El resto no existen para él (`404`, también en
|
||||||
|
«Mi trabajo», en las suscripciones en tiempo real, en «bloqueado por» y en los enlazados), y las vistas de todo el
|
||||||
|
proyecto (actividad, estadísticas, panel, releases, sprints, épicas, papelera) le responden `403`. Abre tickets sin
|
||||||
|
asignarlos ni planificarlos, comenta y adjunta en los suyos; no cambia su estado ni sus campos. En un proyecto público
|
||||||
|
ve lo mismo que cualquiera.
|
||||||
|
|
||||||
Cualquiera puede salir de un proyecto (salvo el propietario). Los no miembros reciben _not found_, para no revelar que
|
Cualquiera puede salir de un proyecto (salvo el propietario). Los no miembros reciben _not found_, para no revelar que
|
||||||
el proyecto existe.
|
el proyecto existe.
|
||||||
|
|
||||||
### Workflow de un ticket
|
### Proyectos públicos
|
||||||
|
|
||||||
|
Con `isPublic` (al crear, o después con `updateProject` / `PATCH /api/projects/{project}`, propietario o admin),
|
||||||
|
**cualquiera puede leer** el proyecto, también sin token: el proyecto y sus miembros, tablero, tickets, comentarios,
|
||||||
|
historial, releases (progreso y burnup), actividad, seguimiento y estadísticas. Escribir sigue siendo cosa de los
|
||||||
|
miembros según su rol (sin token: 401; con token pero sin ser miembro: 403). Un usuario con sesión que no es miembro
|
||||||
|
lee igual que un anónimo. Las subscriptions exigen sesión; con ella, también se pueden seguir proyectos públicos.
|
||||||
|
|
||||||
|
**Propuestas del público**: un proyecto público puede aceptar errores e ideas de cualquiera, sin cuenta
|
||||||
|
(`acceptsProposals`, en `updateProject` / `PATCH`). `POST /api/explore/projects/{project}/proposals` (`kind`: `BUG` o
|
||||||
|
`IDEA`, `title`, `description`, `author` opcional) crea un ticket con la etiqueta `propuesta`, a nombre de TaskStream,
|
||||||
|
en el primer estado, de tipo Bug o Feature (o el primer tipo activo). No se pide correo, porque quedaría a la vista. Contra
|
||||||
|
el spam, el campo `website` es una trampa (si llega relleno se responde como si nada y no se guarda) y cada dirección
|
||||||
|
envía como mucho 5 cada 15 minutos (429); el contador vive en Redis (`RateLimitStore`), así que vale igual con
|
||||||
|
varias instancias. Los propietarios y admins del proyecto reciben un aviso (`PROPOSED`). La web tiene el formulario en `/p/{CLAVE}/propose`, enlazado en la cabecera del proyecto.
|
||||||
|
|
||||||
|
**Votos**: quien tenga sesión vota los tickets de un proyecto público, y los miembros los de uno privado
|
||||||
|
(`voteIssue(issueId, on)`, `PUT/DELETE /api/issues/{issue}/vote`). Cada ticket da `votes` y `voted` (si he votado
|
||||||
|
yo; en REST, solo con token); la lista de quién votó no se publica.
|
||||||
|
|
||||||
|
`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.
|
||||||
|
|
||||||
|
### Archivar un proyecto
|
||||||
|
|
||||||
|
El propietario archiva un proyecto terminado (`archiveProject` / `POST /api/projects/{project}/archive`) en lugar de
|
||||||
|
borrarlo: se sigue viendo todo (tablero, tickets, historia, estadísticas, y quien lo administra sigue viendo sus
|
||||||
|
reglas y webhooks), pero ninguna orden lo cambia (`422`, «está archivado»): ni tickets, ni comentarios, ni votos,
|
||||||
|
ni ajustes, ni miembros; las automatizaciones no se ejecutan y el formulario público no acepta propuestas. Seguir un
|
||||||
|
ticket sí se puede, porque solo atañe a quien lo sigue. `unarchiveProject` / `POST …/unarchive` lo devuelve.
|
||||||
|
`Project.archivedAt` dice desde cuándo.
|
||||||
|
|
||||||
|
### Configuración por proyecto
|
||||||
|
|
||||||
|
Cada proyecto tiene sus **estados**, **transiciones**, **tipos de ticket**, **áreas** y **campos personalizados**
|
||||||
|
(`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: uno de la paleta (`slate`, `stone`, `violet`, `blue`, `sky`, `teal`, `green`, `amber`, `orange`, `red`,
|
||||||
|
`pink`), que tiene su versión para modo oscuro, o cualquier `#rrggbb`. Iconos:
|
||||||
|
`bug`, `feature`, `palette`, `task`, `qa`, `flag`, `star-fill`, `rocket`, `document`, `light-bulb`, `key`, `support`.
|
||||||
|
|
||||||
|
Un proyecto nuevo puede **empezar con la configuración de otro** que administres: `templateProjectId` en
|
||||||
|
`createProject` o `template` (id o clave) en `POST /api/projects`. Se copian una vez sus estados, transiciones, tipos,
|
||||||
|
áreas, campos personalizados y automatizaciones (las pausadas, pausadas; las que asignan a alguien que no está en el
|
||||||
|
nuevo proyecto se quedan fuera). Las etiquetas no tienen catálogo, así que no hay nada que copiar.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### Equipos
|
||||||
|
|
||||||
|
`settings.teams`: grupos de miembros (`id`, `name`, `memberIds`, `archived`), como mucho 30. Al guardar, quien no es
|
||||||
|
miembro del proyecto se quita del equipo; un equipo no se borra, se archiva; y si `teams` no viene, se quedan como
|
||||||
|
estaban. Un ticket va a un equipo con `team` (en `createIssue` / `updateIssue` y por REST), y la lista se filtra con
|
||||||
|
`teams` (`?team=FRONT`). En un comentario, `@[Frontend](team-FRONT)` avisa a cada miembro del equipo («mencionó a tu
|
||||||
|
equipo…»).
|
||||||
|
|
||||||
|
### Presencia
|
||||||
|
|
||||||
|
Quién más está en un ticket ahora. La página de un ticket late cada 10 s con `touchPresence(issueId, editing)` (o
|
||||||
|
`PUT /api/issues/{issue}/presence` con `{ editing }`), y la respuesta es quién más está, primero los que editan. Se
|
||||||
|
guarda en Redis (un hash por ticket, `task-stream:presence:<id>`) y una entrada sin latido en 30 s desaparece sola;
|
||||||
|
`leavePresence` (`DELETE …/presence`) la quita al salir. No es historia: no pasa por el event store. Solo para quien
|
||||||
|
puede leer el ticket.
|
||||||
|
|
||||||
|
### Definición de «Hecho»
|
||||||
|
|
||||||
|
`settings.checklistBeforeDone` (desactivada por defecto): con ella, un ticket no pasa a un estado de categoría `DONE`
|
||||||
|
mientras le quede algún paso de la checklist sin marcar (`422`, «quedan N pasos…»). El propietario y los admins sí
|
||||||
|
pueden cerrarlo; las automatizaciones, no. Al guardar la configuración, si no viene, se queda como estaba.
|
||||||
|
|
||||||
|
### Campos personalizados
|
||||||
|
|
||||||
|
Datos propios del proyecto en cada ticket («Navegador», «Cliente», «Coste»…), en `settings.customFields`: `id`, `name`,
|
||||||
|
`type` y `archived`, más `options` si es una lista. Un proyecto tiene como mucho 30, y empieza sin ninguno.
|
||||||
|
|
||||||
|
| Tipo | Valor |
|
||||||
|
| -------- | ---------------------------------------------------------------- |
|
||||||
|
| `TEXT` | Hasta 500 caracteres |
|
||||||
|
| `NUMBER` | Un número (se guarda en forma canónica: `1,5` → `1.5`) |
|
||||||
|
| `DATE` | `YYYY-MM-DD` |
|
||||||
|
| `SELECT` | Una de sus `options` (de 1 a 50, de hasta 60 caracteres, únicas) |
|
||||||
|
|
||||||
|
- Como el resto de la configuración, un campo no se borra: se archiva. Su **tipo no cambia** una vez guardado, para que
|
||||||
|
los valores que ya hay sigan teniendo sentido. Un campo archivado se puede vaciar en un ticket, pero no rellenar.
|
||||||
|
- Al guardar la configuración, si `customFields` no viene se quedan como estaban (los clientes que no los conocen no
|
||||||
|
los pierden).
|
||||||
|
- En el ticket: GraphQL `Issue.customFields` es una lista `{ fieldId, value }` (solo los que tienen valor) y se cambia
|
||||||
|
con `customFields: [{ fieldId, value }]` en `createIssue` / `updateIssue`; por REST es un objeto
|
||||||
|
`{ "BROWSER": "Safari" }`. En ambos se mandan solo los campos que cambian, y `null` o `""` vacía uno.
|
||||||
|
- Filtrar: `issues(filter: { customFields: [{ fieldId, value }] })` o `GET /api/projects/{project}/issues?field=BROWSER:Safari`
|
||||||
|
(se puede repetir).
|
||||||
|
- `IssueUpdatedEvent` lleva el mapa entero de valores ya validado, así la proyección solo lo copia.
|
||||||
|
|
||||||
|
### Workflow por defecto
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
stateDiagram-v2
|
stateDiagram-v2
|
||||||
@@ -76,9 +210,10 @@ stateDiagram-v2
|
|||||||
CANCELLED --> BACKLOG
|
CANCELLED --> BACKLOG
|
||||||
```
|
```
|
||||||
|
|
||||||
- `QA_FAILED` y `CANCELLED` exigen un **motivo**, que queda en el historial.
|
- `QA_FAILED` y `CANCELLED` exigen un **motivo** (`requiresReason`), que queda en el historial.
|
||||||
- No se puede pasar a `DONE` saltándose QA salvo desde `IN_REVIEW` (cambios que no requieren QA).
|
- No se puede pasar a `DONE` saltándose QA salvo desde `IN_REVIEW` (cambios que no requieren QA).
|
||||||
- `Issue.allowedTransitions` en GraphQL devuelve los estados permitidos desde el actual.
|
- `Issue.allowedTransitions` devuelve los estados activos a los que puede pasar el ticket según el workflow de su
|
||||||
|
proyecto.
|
||||||
|
|
||||||
### Otras reglas
|
### Otras reglas
|
||||||
|
|
||||||
@@ -88,66 +223,395 @@ stateDiagram-v2
|
|||||||
- **Relaciones**: nunca consigo mismo ni duplicadas; pueden cruzar proyectos si el actor puede ver el destino. Se
|
- **Relaciones**: nunca consigo mismo ni duplicadas; pueden cruzar proyectos si el actor puede ver el destino. Se
|
||||||
muestran en los dos tickets (`OUTWARD` en el origen, `INWARD` en el destino).
|
muestran en los dos tickets (`OUTWARD` en el origen, `INWARD` en el destino).
|
||||||
- **Releases**: solo las `PLANNED` aceptan o pierden tickets; publicar con tickets sin terminar exige `force: true`.
|
- **Releases**: solo las `PLANNED` aceptan o pierden tickets; publicar con tickets sin terminar exige `force: true`.
|
||||||
- **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`.
|
||||||
|
- **Formato**: descripciones, comentarios y notas de versión se guardan en Markdown; la API los guarda tal cual y la web
|
||||||
|
los muestra limpiando el HTML (DOMPurify). En la web se escriben con un editor visual que lee y escribe ese mismo
|
||||||
|
Markdown, así que un cliente de la API (o el MCP) sigue mandando Markdown. Una imagen pegada se sube como adjunto y queda en el texto como
|
||||||
|
``: la subida responde `attachmentId`, y la web la descarga con la sesión de quien lee, así
|
||||||
|
que sirve también en proyectos privados.
|
||||||
|
- **Reacciones**: 👍 👎 😄 🎉 😕 ❤️ 🚀 👀 en los comentarios (`reactToComment(commentId, emoji, on)`,
|
||||||
|
`POST /api/comments/{id}/reactions`); los miembros, también los lectores. Cada comentario da `reactions` con quién
|
||||||
|
eligió cada una. No generan actividad ni avisos.
|
||||||
|
- **Encuestas**: un comentario puede llevar una encuesta de 2 a 6 opciones (`addComment(…, poll)`, `poll` en
|
||||||
|
`POST …/comments`). Los miembros, también los lectores, votan una opción y pueden cambiarla o retirarla
|
||||||
|
(`votePoll(commentId, option)`, `POST /api/comments/{id}/poll/vote`; `CommentPollVotedEvent`). Quien la abrió, o un
|
||||||
|
admin, la cierra (`closePoll(commentId, decide)`, `POST …/poll/close`; `CommentPollClosedEvent`): gana la más votada
|
||||||
|
(con empate, ninguna) y, con `decide`, se añade una respuesta «Decidido: …» marcada como decisión. El comentario da
|
||||||
|
`poll { options { text voters } closed winner }`.
|
||||||
|
- **Conversaciones**: un comentario raíz y sus respuestas se resuelven (`resolveThread(commentId, resolved, conclusion)`,
|
||||||
|
`PUT/DELETE /api/comments/{id}/resolution`; evento `CommentThreadResolvedEvent`) por quien puede cambiar el ticket;
|
||||||
|
una respuesta no se resuelve sola. Si trae `conclusion`, antes de cerrarla se añade como respuesta marcada como
|
||||||
|
decisión. El comentario da `resolved`, `resolvedBy` y `resolvedAt`. **Escribiendo**: `typing(issueId, threadId)`
|
||||||
|
(mutation, cada pocos segundos mientras se escribe) avisa al momento por la subscription `typing(issueId)` a quien
|
||||||
|
tenga el ticket abierto; no se guarda (canal de Redis `tracker.typing.<ticket>`), y cada cliente descarta sus propios
|
||||||
|
avisos.
|
||||||
|
- **Decisiones**: quien puede cambiar el ticket marca un comentario como decisión (`markDecision(commentId, on)`,
|
||||||
|
`PUT/DELETE /api/comments/{id}/decision`; evento `CommentDecisionMarkedEvent`). El comentario da `decision`,
|
||||||
|
`decidedBy` y `decidedAt`; el ticket, `decisions` (más antiguas primero, `GET /api/issues/{issue}/decisions`), que la
|
||||||
|
web muestra encima de la descripción. Se buscan por su texto: `decisions(projectId?, search, limit)`,
|
||||||
|
`GET /api/projects/{project}/decisions` y, de todos mis proyectos, `GET /api/me/decisions` (más recientes primero; sin
|
||||||
|
las de la papelera y, para un invitado, solo las de sus tickets).
|
||||||
|
- **Bloqueos**: un ticket espera a otro si ese otro lo bloquea (`BLOCKS`) o si él depende de ese otro (`DEPENDS_ON`),
|
||||||
|
mientras el otro no esté hecho ni cancelado. `blockedBy` lo calcula al leer (los enlaces no guardan el estado del
|
||||||
|
otro ticket), con una sola consulta por tablero o página.
|
||||||
|
- **Duplicar**: `duplicateIssue` / `POST /api/issues/{issue}/duplicate` copia lo que dice el ticket (tipo, título con
|
||||||
|
«(copia)», descripción, área, prioridad, etiquetas, Figma, estimación, fechas, épica y pasos de la checklist sin
|
||||||
|
marcar), no dónde está: la copia empieza en el primer estado, sin asignar, sin sprint, release, comentarios ni
|
||||||
|
adjuntos.
|
||||||
- **Cambios de UI**: `figmaUrl` debe ser un enlace `https://…figma.com/…`.
|
- **Cambios de UI**: `figmaUrl` debe ser un enlace `https://…figma.com/…`.
|
||||||
|
- **Adjuntos**: los suben los miembros (no los lectores), hasta `ATTACHMENTS_MAX_MB` (25 MB) por archivo y 50 por
|
||||||
|
ticket; pueden ir en el ticket o bajo uno de sus comentarios (`commentId`). Quita uno quien lo subió o un admin.
|
||||||
|
Los bytes se guardan primero y el evento después, así que nunca queda un adjunto apuntando a un archivo que no
|
||||||
|
existe; si el evento no se guarda, el archivo se borra. Al borrar un ticket se borran sus archivos. Las imágenes,
|
||||||
|
los PDF, el texto y el vídeo o audio que reproduce el navegador (mp4, webm, mp3, ogg, wav) se sirven para verse en
|
||||||
|
la página; el resto, como descarga.
|
||||||
|
- **É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.
|
||||||
|
- **Alertas → bugs**: un owner o admin activa en Integraciones una URL secreta por proyecto,
|
||||||
|
`POST /api/webhooks/alerts/{proyecto}/{token}` (el token es el secreto: Sentry o Uptime Kuma solo dejan pegar una
|
||||||
|
URL). Entiende Sentry (integración interna, cabecera `sentry-hook-resource`, o el plugin de webhooks), Uptime Kuma,
|
||||||
|
Prometheus Alertmanager y Grafana; cualquier otra herramienta manda JSON con `title` y, si quiere, `fingerprint`,
|
||||||
|
`description`, `url` y `resolved`. Cada aviso tiene una huella (el id del problema en Sentry, el monitor en Uptime
|
||||||
|
Kuma, el `groupKey` en Alertmanager o `fingerprint`): la primera vez TaskStream abre un bug (actor `system`, etiqueta
|
||||||
|
`alerta`, el tipo y la prioridad elegidos; sin tipo, el primero con icono de bug); mientras ese bug siga abierto, las
|
||||||
|
repeticiones van a él (un comentario como mucho cada 30 minutos, con cuántas van) y «resuelta» se dice en él; si ya
|
||||||
|
está cerrado, abre otro que nombra el anterior. Responde `{ action: created | repeated | resolved | ignored, issueKey
|
||||||
|
}`; proyecto desconocido, alertas apagadas o token malo responden igual: 403. Las alertas de un proyecto se atienden
|
||||||
|
de una en una, así que dos iguales a la vez abren un solo bug. Configuración: `alertIntegration`,
|
||||||
|
`saveAlertIntegration(input: { typeId, priority })`, `rotateAlertToken`, `disableAlertIntegration`;
|
||||||
|
`GET/PUT/DELETE /api/projects/{project}/alert-integration` y `POST …/token`. Vive en MongoDB (`alert_integrations`,
|
||||||
|
y `alert_traces` con qué bug abrió cada huella), no en el event store.
|
||||||
|
- **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. La web parte selecciones mayores (la Lista enseña hasta 200) en tandas de 100, una tras
|
||||||
|
otra, y suma el resultado.
|
||||||
|
- **Notas públicas de una release**: quien gestiona el proyecto abre un enlace secreto con las notas de una release,
|
||||||
|
para clientes y sin cuenta (también en proyectos privados): `shareReleaseNotes(releaseId, renew)`,
|
||||||
|
`unshareReleaseNotes`, `releaseNotesShare`; `GET/PUT/DELETE /api/releases/{id}/public-notes`. La página
|
||||||
|
(`publicReleaseNotes(token)`, `GET /api/shared/release-notes/{token}`, la web en `/notes/{token}`) enseña las notas
|
||||||
|
escritas o, mientras no las haya, el borrador de sus tickets terminados (`written: false`). Renovar hace un enlace
|
||||||
|
nuevo; retirarlo, o borrar la release, lo deja sin efecto. Vive en MongoDB (`release_note_shares`).
|
||||||
|
- **Archivar una release**: `archiveRelease(releaseId, moveTo)` / `POST /api/releases/{id}/archive` (`{ moveTo }`).
|
||||||
|
Sus tickets sin terminar pasan a `moveTo` (otra release abierta del mismo proyecto) o, sin él, se quedan sin release;
|
||||||
|
los terminados se quedan, como registro de lo que salió. Igual que al cerrar un sprint con `carryOverTo`.
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
### Calendario (iCalendar)
|
||||||
|
|
||||||
|
Cada persona puede suscribir sus fechas en Google Calendar, Outlook o Apple Calendar con una URL secreta:
|
||||||
|
`GET /api/calendar/{token}.ics` (sin sesión: las aplicaciones de calendario no pueden iniciarla). Trae, como eventos de
|
||||||
|
día completo:
|
||||||
|
|
||||||
|
- sus tickets sin terminar con fecha límite, asignados o que sigue;
|
||||||
|
- los sprints planificados o en curso de sus proyectos;
|
||||||
|
- las releases planificadas con fecha objetivo.
|
||||||
|
|
||||||
|
Cada evento enlaza a la web (`WEB_URL`). El token (32 bytes aleatorios) se crea, renueva o retira con `GET`, `POST` y
|
||||||
|
`DELETE /api/me/calendar` (GraphQL: `myCalendarFeed`, `createCalendarFeed`, `revokeCalendarFeed`); renovarlo deja sin
|
||||||
|
servicio la URL anterior. Se guarda tal cual en `calendar_feeds` para poder volver a copiar la URL: solo da lectura de
|
||||||
|
fechas y títulos que ya están en el read model.
|
||||||
|
|
||||||
|
### Panel para clientes
|
||||||
|
|
||||||
|
Cada proyecto puede enseñar su estado a sus clientes con un enlace de solo lectura, sin cuenta: `/share/{token}` en la
|
||||||
|
web, que lee `GET /api/shared/dashboards/{token}` (GraphQL: `sharedDashboard(token)`), ambos sin sesión. Solo trae los
|
||||||
|
bloques elegidos, y los demás llegan a `null`:
|
||||||
|
|
||||||
|
| Bloque | Qué da |
|
||||||
|
| ---------- | -------------------------------------------------------------------------- |
|
||||||
|
| `flow` | Tickets en cada estado activo, con su nombre y color |
|
||||||
|
| `sprint` | El sprint en curso, como en el panel (¿llega?) |
|
||||||
|
| `releases` | Las releases planificadas con su previsión al ritmo real |
|
||||||
|
| `epics` | Las épicas abiertas (8, por fecha): título, fecha, terminados y porcentaje |
|
||||||
|
| `activity` | Movimientos por día de las últimas 12 semanas |
|
||||||
|
|
||||||
|
No enseña personas ni tickets sueltos (salvo las épicas). Propietarios y admins lo activan, cambian sus bloques o lo
|
||||||
|
renuevan con `GET`, `PUT` y `DELETE /api/projects/{project}/share` (`{ blocks, renew }`; GraphQL: `dashboardShare`,
|
||||||
|
`shareDashboard`, `unshareDashboard`). Renovarlo deja sin servicio el enlace anterior. El token (32 bytes aleatorios)
|
||||||
|
se guarda tal cual en `dashboard_shares` para poder volver a copiar el enlace, y se borra con el proyecto.
|
||||||
|
|
||||||
|
### 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` |
|
||||||
|
| Llega una propuesta del público | Propietarios y admins del proyecto | `PROPOSED` |
|
||||||
|
|
||||||
|
**Push** (Web Push): además de en la campana y, si se pidió, por correo, cada aviso llega a los dispositivos donde la
|
||||||
|
persona activó «Avisos en este dispositivo», con TaskStream cerrado. El navegador se suscribe con la clave pública
|
||||||
|
VAPID (`notificationSettings.pushPublicKey`, null si no hay claves) y la API guarda la suscripción
|
||||||
|
(`subscribePush` / `POST /api/me/push-subscriptions`; se retira con `unsubscribePush` / `DELETE`). El mensaje va
|
||||||
|
cifrado (`aes128gcm`) y firmado con VAPID, con `{ title, body, url, tag }`; si el servicio push responde 404 o 410, la
|
||||||
|
suscripción se olvida. Como el correo, un fallo de envío no frena los eventos.
|
||||||
|
|
||||||
|
**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). **Revisiones**: quien puede cambiar un ticket pide a un miembro que lo revise, con nota opcional
|
||||||
|
(`requestReview(issueId, reviewerId, note)`, `POST /api/issues/{issue}/reviews`; evento `IssueReviewRequestedEvent`).
|
||||||
|
Esa persona, aunque sea lectora, responde «todo bien» o «cambios» con su respuesta (`answerReview`, `POST
|
||||||
|
…/reviews/{id}/answer`, `IssueReviewAnsweredEvent`); quien la pidió (o un admin) puede retirarla (`cancelReview`, `DELETE
|
||||||
|
…/reviews/{id}`, `IssueReviewCancelledEvent`). No a uno mismo ni dos veces a la misma persona con una pendiente. Avisos
|
||||||
|
`REVIEW_REQUESTED` y `REVIEW_ANSWERED`; `Issue.reviews`; «Te piden revisión» es `myIssues(reviewing: true)` /
|
||||||
|
`GET /api/me/issues?reviewing=true`. **Mi día**: los tickets que cada persona elige para hoy (`myDay(day)`, `addToMyDay(day, issueIds)`,
|
||||||
|
`removeFromMyDay(day, issueId)`; `GET /api/me/day?day=`, `PUT/DELETE /api/me/day/{issue}?day=`), hasta 10. `day` es
|
||||||
|
hoy en la zona horaria de quien pregunta (`YYYY-MM-DD`, a dos días como mucho de la del servidor). La respuesta trae
|
||||||
|
también lo que quedó sin terminar el último día anterior (si fue en la última semana), para pasarlo a hoy. Privado y sin
|
||||||
|
historial (`my_days`). **Recordármelo**: cada persona pone en un ticket un recordatorio con fecha y hora y nota opcional
|
||||||
|
(`setReminder(issueId, at, note)`, `clearReminder`, `Issue.myReminder`, `myReminders`; `PUT/DELETE
|
||||||
|
/api/issues/{issue}/reminder`, `GET /api/me/reminders`): uno por ticket, en el futuro y como mucho a un año. Cada minuto
|
||||||
|
`IssueReminderScheduler` avisa de los que tocan (`REMINDER`, por la campana, el navegador y el correo; una vez, por su
|
||||||
|
id) y olvida los de tickets en la papelera o fuera de alcance. Privados y sin historial (`issue_reminders`);
|
||||||
|
`REMINDERS_ENABLED=false` los apaga junto con los de fecha límite. **Seguir todo**: seguir un ticket con todo lo que cuelga de él (una épica, un ticket con subtareas):
|
||||||
|
`followTree`/`unfollowTree`, `Issue.followingTree`; `PUT/DELETE /api/issues/{issue}/follow-tree`. Quien lo hace oye lo
|
||||||
|
mismo que un seguidor de cada ticket de debajo, a cualquier profundidad, también de los que se añadan, y además cuándo
|
||||||
|
uno entra (`IssueParentChangedEvent`). Personal y sin historial (colección `tree_follows`); los invitados de un proyecto
|
||||||
|
privado no oyen los tickets que no son suyos. **Fijar**: cada persona fija hasta 20 tickets para tenerlos a mano en el
|
||||||
|
inicio y en la búsqueda (`pinIssue`/`unpinIssue`, `myPins`, `Issue.pinned`; `GET /api/me/pins`,
|
||||||
|
`PUT/DELETE /api/issues/{issue}/pin`). Son privados y sin historial (colección `pinned_issues`, como las vistas de «Mi
|
||||||
|
trabajo»); los que están en la papelera o fuera de su alcance no se listan, y al llegar a 20 se olvidan primero los
|
||||||
|
que ya no puede leer. El id es
|
||||||
|
`<agregado>:<versión>:<persona>`, así que reentregar un evento no duplica avisos. Llegan en vivo por la subscription
|
||||||
|
`notificationAdded` y, si la instalación tiene `SMTP_URL`, también por correo a quien lo active (`email`, desactivado
|
||||||
|
por defecto); un correo que falla se registra y no bloquea el stream.
|
||||||
|
|
||||||
### Eventos de dominio
|
### Eventos de dominio
|
||||||
|
|
||||||
Todo cambio es un evento guardado en el event store (stream = id del agregado). Llevan `actorId`, `occurredAt`,
|
Todo cambio es un evento guardado en el event store (stream = id del agregado). Llevan `actorId`, `occurredAt`,
|
||||||
`aggregateId`, `projectId` y `aggregateVersion` (la posición en el stream).
|
`aggregateId`, `projectId` y `aggregateVersion` (la posición en el stream).
|
||||||
|
|
||||||
| Agregado | Eventos |
|
| Agregado | Eventos |
|
||||||
| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Project | `ProjectCreatedEvent`, `ProjectUpdatedEvent`, `ProjectMemberAddedEvent`, `ProjectMemberRoleChangedEvent`, `ProjectMemberRemovedEvent` |
|
| Project | `ProjectCreatedEvent`, `ProjectUpdatedEvent`, `ProjectSettingsChangedEvent`, `ProjectMemberAddedEvent`, `ProjectMemberRoleChangedEvent`, `ProjectMemberRemovedEvent`, `ProjectDeletedEvent` |
|
||||||
| Issue | `IssueCreatedEvent`, `IssueUpdatedEvent`, `IssueStatusChangedEvent`, `IssueAssignedEvent`, `IssueLinkedEvent`, `IssueUnlinkedEvent`, `IssueReleaseChangedEvent` |
|
| Issue | `IssueCreatedEvent`, `IssueUpdatedEvent`, `IssueStatusChangedEvent`, `IssueAssignedEvent`, `IssueLinkedEvent`, `IssueUnlinkedEvent`, `IssueReleaseChangedEvent`, `IssueAttachmentAddedEvent`, `IssueAttachmentRemovedEvent`, `IssueWatchedEvent`, `IssueUnwatchedEvent`, `IssueParentChangedEvent`, `IssueRankedEvent`, `IssueSprintChangedEvent`, `IssueDevLinkRecordedEvent`, `IssueChecklistItemAddedEvent`, `IssueChecklistItemUpdatedEvent`, `IssueChecklistItemRemovedEvent`, `IssueDeletedEvent` |
|
||||||
| Comment | `CommentAddedEvent`, `CommentEditedEvent`, `CommentDeletedEvent` |
|
| Comment | `CommentAddedEvent`, `CommentEditedEvent`, `CommentDeletedEvent` |
|
||||||
| Release | `ReleaseCreatedEvent`, `ReleaseUpdatedEvent`, `ReleaseShippedEvent`, `ReleaseArchivedEvent` |
|
| Sprint | `SprintCreatedEvent`, `SprintUpdatedEvent`, `SprintStartedEvent`, `SprintClosedEvent`, `SprintDeletedEvent` |
|
||||||
|
| Release | `ReleaseCreatedEvent`, `ReleaseUpdatedEvent`, `ReleaseShippedEvent`, `ReleaseNotesWrittenEvent`, `ReleaseArchivedEvent`, `ReleaseDeletedEvent` |
|
||||||
|
|
||||||
|
`IssueStatusChangedEvent` lleva además `meta` con el nombre y la categoría de los dos estados en el momento del
|
||||||
|
cambio, para que el historial y las estadísticas no cambien si luego se renombra o recategoriza un estado.
|
||||||
|
|
||||||
|
### Estadísticas
|
||||||
|
|
||||||
|
Una proyección más (`issue_timeline` en MongoDB) guarda cada paso de cada ticket: a qué estado pasó, con qué categoría,
|
||||||
|
quién lo tenía asignado, y cuándo entró o salió de una release. Las estadísticas se calculan sobre ella con funciones
|
||||||
|
puras (`application/src/lib/analytics`):
|
||||||
|
|
||||||
|
| Métrica | Definición |
|
||||||
|
| --------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Creados vs. resueltos | Por semana (lunes, UTC): tickets creados y tickets que entraron en una categoría `DONE` |
|
||||||
|
| Flujo acumulado | Tickets en cada estado al final de cada día |
|
||||||
|
| Tiempo de ciclo | Del primer paso a `IN_PROGRESS` o `QA` hasta `DONE`; media y percentil 85 |
|
||||||
|
| Tiempo de entrega | De la creación hasta `DONE`; media y percentil 85 |
|
||||||
|
| Throughput | Tickets resueltos por semana y por la persona que los tenía asignados |
|
||||||
|
| QA | Salidas de un estado `QA`: a `DONE` cuenta como superado; a cualquier otro sitio salvo `CANCELLED`, como fallido |
|
||||||
|
| Burndown de un sprint | Por día del sprint: tickets en él (sin cancelados), cuántos siguen abiertos y sus puntos; línea ideal hasta cero |
|
||||||
|
| Burnup de una release | Por día, desde que se creó hasta que se publica (o hoy, máx. 180 días): tickets en ella (sin cancelados) y hechos |
|
||||||
|
|
||||||
|
Los tickets anteriores a esta proyección solo tienen su creación y su estado actual; para tener su historia completa,
|
||||||
|
reconstruye el read model (ver [Despliegue](07-despliegue.md)).
|
||||||
|
|
||||||
|
La **cartera** (`portfolio` en GraphQL, `GET /api/me/portfolio`) junta, para cada proyecto activo de quien pregunta
|
||||||
|
(no archivado y en el que no es invitado), los tickets abiertos, vencidos, bloqueados y atascados, el sprint en curso,
|
||||||
|
la previsión de sus releases y sus épicas abiertas (las 6 con fecha límite más cercana). Se calcula con lo mismo que el
|
||||||
|
panel.
|
||||||
|
|
||||||
|
La **portada** (`myHome` en GraphQL, `GET /api/me/home`) junta lo demás que se ve en la página de inicio
|
||||||
|
(`analytics/home.ts`):
|
||||||
|
|
||||||
|
| Bloque | Qué da |
|
||||||
|
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `agenda` | Los próximos 7 días: mis tickets abiertos (asignados o que sigo) que vencen, fin de sprints en curso y releases planificadas |
|
||||||
|
| `activity` | Lo último en mis proyectos (30 entradas; sin los proyectos en los que soy invitado) |
|
||||||
|
| `team` / `me` | Tickets terminados por día en los últimos 14 días; esta semana y la anterior, de mis equipos y míos (por quien lo tenía asignado), y mi racha |
|
||||||
|
| `sprints` | Los sprints en curso, con el mismo pulso que el panel (¿llega?) |
|
||||||
|
| `projects` | Por proyecto: abiertos, vencidos, actividad por día (14 días) y el sprint en curso con su porcentaje |
|
||||||
|
| `suggestions` | Para cuando no tengo nada: sin asignar, esperando QA y propuestas por revisar (estas, solo a quien administra); las 5 mayores |
|
||||||
|
| `firstSteps` | Si ya creé un proyecto, invité a alguien, abrí un ticket y conecté el calendario |
|
||||||
|
| `online` | Quién está ahora en un ticket de mis proyectos (por la presencia, una entrada por persona) |
|
||||||
|
|
||||||
|
El **panel del proyecto** (`Project.insights` en GraphQL, `GET /api/projects/{project}/insights`) lee lo mismo para
|
||||||
|
decir qué mirar ahora (`analytics/insights.ts`; acepta `since`, un instante ISO):
|
||||||
|
|
||||||
|
| Bloque | Qué da |
|
||||||
|
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Sprint en curso | Días que quedan, tickets y puntos hechos, ritmo (puntos al día) y el que haría falta; `behind` si no llega |
|
||||||
|
| Riesgo | Abiertos vencidos, que vencen en 7 días, bloqueados (por un ticket abierto) y atascados (7 días o más sin moverse) |
|
||||||
|
| Antigüedad | Lo que está en curso o en QA, por días en su estado, con el percentil 85 del tiempo de ciclo (90 días) como umbral |
|
||||||
|
| Cuellos de botella | Días de media en cada estado en curso o de QA (estancias que terminaron en 90 días), cuántos hay y su límite WIP |
|
||||||
|
| Próximas releases | Para cada release planificada (por fecha objetivo): fecha probable (p50) y segura (p85) simulando 500 futuros con lo que se cerró cada día de las últimas 6 semanas (Monte Carlo); cada release cuenta lo que queda de las anteriores. `ON_TRACK`, `AT_RISK`, `LATE`, `NO_DATE`, `UNKNOWN` (sin ritmo aún) o `DONE` |
|
||||||
|
| Cambios | Con `since` (por ejemplo, la última visita): tickets creados, terminados y vueltos de QA desde entonces, y mis menciones en el proyecto |
|
||||||
|
| Equipo | Por persona: tickets y puntos abiertos, bloqueados, puntos cerrados por sprint (últimos 3 cerrados) y `overloaded` si tiene abiertos más de los que suele cerrar |
|
||||||
|
| Salud del backlog | Abiertos sin estimar, sin área, sin asignar y sin tocar en 90 días |
|
||||||
|
| Calidad | Bugs (tipos con icono `bug`): abiertos por prioridad, nuevos y cerrados por semana (8 semanas), lo que QA devolvió y dejó pasar por área (90 días) y bugs abiertos en los 14 días tras cada una de las 3 últimas releases |
|
||||||
|
| Campos | Para cada campo personalizado de lista: tickets abiertos por opción y sin valor |
|
||||||
|
| Actividad | Movimientos por día (todo lo de la actividad) de las últimas 12 semanas, desde un lunes: 84 días, en `heatmap` |
|
||||||
|
| Recorrido | De los tickets terminados en 90 días: días de media de creado a hecho, días y parte del total en cada estado por el que pasaron (también la espera en Backlog o Por hacer) y `flowEfficiency`, la parte en un estado «en curso» |
|
||||||
|
| Propuestas | Si el público ha propuesto algo (tickets con la etiqueta «propuesta»): recibidas (y en 30 días), esperando (sin empezar ni planificar), en marcha, hechas y descartadas, y las 5 más votadas que esperan; si no, `null` |
|
||||||
|
|
||||||
## 3.2 GraphQL
|
## 3.2 GraphQL
|
||||||
|
|
||||||
Todas las operaciones requieren `Authorization: Bearer <token de Pireon>` (en subscriptions, en el `connection_init`).
|
Las operaciones requieren `Authorization: Bearer <token de Pireon>` (en subscriptions, en el `connection_init`), salvo
|
||||||
|
las lecturas de proyectos públicos (`project`, `issue`, `issues`, `release`, `activity`, `publicProjects` y sus campos),
|
||||||
|
que funcionan también sin token.
|
||||||
|
|
||||||
### Queries
|
### Queries
|
||||||
|
|
||||||
| Query | Devuelve |
|
| Query | Devuelve |
|
||||||
| ----------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
|
| --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `me` | El usuario autenticado, con sus roles en Pireon |
|
| `me` | El usuario autenticado, con sus roles en Pireon |
|
||||||
| `users(search, limit)` | Usuarios de Pireon (para asignar o añadir miembros) |
|
| `users(search, limit)` | Usuarios de Pireon (para asignar o añadir miembros) |
|
||||||
| `user(id)` | Un usuario |
|
| `user(id)` | Un usuario |
|
||||||
| `myProjects` | Proyectos de los que soy miembro |
|
| `myProjects` | Proyectos de los que soy miembro |
|
||||||
| `project(idOrKey)` | Un proyecto. Campos calculados: `board(releaseId, assigneeId, area, perColumn)`, `stats`, `releases` |
|
| `myIssues(assignedToMe, statuses, categories, search, limit, offset)` | «Mi trabajo»: tickets de todos mis proyectos, asignados a mí (por defecto) o de cualquiera (p. ej. `categories: [QA]`) |
|
||||||
| `issues(filter, limit, offset)` | Búsqueda de tickets (estado, tipo, área, prioridad, persona, release, etiquetas, texto) |
|
| `upcomingReleases(limit)` | Releases planificadas de mis proyectos, por fecha objetivo |
|
||||||
| `issue(idOrKey)` | Un ticket. Campos calculados: `allowedTransitions`, `linkedIssues`, `comments`, `history` |
|
| `publicProjects` | Proyectos públicos (sin token) |
|
||||||
| `release(id)` | Una release. Campos calculados: `progress`, `issues` |
|
| `project(idOrKey)` | Un proyecto (mío, o público) con su `settings`. Campos calculados: `board(releaseId, assigneeId, unassigned, area, types, priorities, labels, search, perColumn)`, `stats`, `analytics(days)`, `releases` |
|
||||||
| `activity(projectId, issueId?, limit, before?)` | Feed de actividad, del más reciente al más antiguo |
|
| `issues(filter, limit, offset)` | Búsqueda de tickets (estado, tipo, área, prioridad, persona, release, etiquetas, 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?, after?)` | Feed de actividad, del más reciente al más antiguo; con `after` y `before`, una ventana (un día, por ejemplo). Por REST, los mismos parámetros en `…/activity` |
|
||||||
|
|
||||||
### Mutations
|
### Mutations
|
||||||
|
|
||||||
Todas devuelven `CommandResult { id, key, version }`.
|
Todas devuelven `CommandResult { id, key, version }`.
|
||||||
|
|
||||||
| Ámbito | Mutations |
|
| Ámbito | Mutations |
|
||||||
| ----------- | ----------------------------------------------------------------------------------------------------------------- |
|
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| Proyectos | `createProject`, `updateProject`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
|
| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
|
||||||
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease` |
|
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `requestReview`, `answerReview`, `cancelReview`, `pinIssue`, `unpinIssue`, `followTree`, `unfollowTree`, `clearReminder`, `addToMyDay`, `removeFromMyDay` (devuelven `true`), `setReminder`, `removeAttachment` (subir: por REST), `duplicateIssue`, `voteIssue` |
|
||||||
| Comentarios | `addComment`, `editComment`, `deleteComment` |
|
| Comentarios | `addComment(replyTo, poll)`, `votePoll`, `closePoll`, `editComment`, `deleteComment`, `reactToComment`, `markDecision(commentId, on)`, `resolveThread(commentId, resolved, conclusion)`, `typing(issueId, threadId)`; consulta `decisions(projectId, search)` |
|
||||||
| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `archiveRelease` |
|
| Webhooks | `createWebhook` (devuelve el secreto), `updateWebhook`, `deleteWebhook`, `testWebhook` |
|
||||||
|
| Git | `addGitRepository` (devuelve su secreto), `rotateGitRepositorySecret`, `removeGitRepository` |
|
||||||
|
| Sprints | `createSprint`, `updateSprint`, `startSprint`, `closeSprint(carryOverToSprintId)`, `deleteSprint`, `setIssueSprint` |
|
||||||
|
| Vistas | `saveFilter(input)` (devuelve el id), `deleteSavedFilter(id)`, `GET/POST /api/me/views`, `DELETE /api/me/views/{id}` (mis vistas de «Mi trabajo») |
|
||||||
|
| Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` |
|
||||||
|
| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `writeReleaseNotes`, `archiveRelease` |
|
||||||
|
|
||||||
### Subscriptions
|
### Subscriptions
|
||||||
|
|
||||||
| Subscription | Se emite cuando |
|
| Subscription | Se emite cuando |
|
||||||
| --------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `issueChanged(projectId)` | Se crea o cambia cualquier ticket del proyecto (incluye el ticket completo y `action` = nombre del evento) |
|
| `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) |
|
| `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 |
|
| `typing(issueId)` | Alguien escribe en el ticket (`threadId`: la conversación que responde; `null`, un comentario nuevo). Llegan también los propios |
|
||||||
| `releaseChanged(projectId)` | Cambia una release |
|
| `activityAdded(projectId)` | Hay una nueva entrada en el feed |
|
||||||
| `projectChanged(projectId)` | Cambian los datos o los miembros del proyecto |
|
| `releaseChanged(projectId)` | Cambia una release |
|
||||||
| `myProjectsChanged` | Me añaden a un proyecto o me quitan de uno |
|
| `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.
|
Solo los miembros pueden suscribirse a un proyecto; las conexiones sin token válido se cierran con el código 4403.
|
||||||
|
|
||||||
### Ejemplos
|
### Ejemplos
|
||||||
|
|
||||||
|
Estados, tipos y áreas son texto (ids de la configuración del proyecto), no enums: van entre comillas.
|
||||||
|
|
||||||
```graphql
|
```graphql
|
||||||
|
mutation ($settings: ProjectSettingsInput!) {
|
||||||
|
# La configuración entera (la que devuelve project.settings, cambiada).
|
||||||
|
updateProjectSettings(projectId: "…", settings: $settings) {
|
||||||
|
version
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
mutation {
|
mutation {
|
||||||
createProject(input: { key: "SHOP", name: "Checkout revamp" }) {
|
createProject(input: { key: "SHOP", name: "Checkout revamp" }) {
|
||||||
id
|
id
|
||||||
@@ -157,7 +621,7 @@ mutation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mutation {
|
mutation {
|
||||||
createIssue(input: { projectId: "…", type: BUG, title: "El botón de pagar no responde en Safari", area: FRONTEND, priority: CRITICAL, assigneeId: "102", labels: ["safari"] }) {
|
createIssue(input: { projectId: "…", type: "BUG", title: "El botón de pagar no responde en Safari", area: "FRONTEND", priority: CRITICAL, assigneeId: "102", labels: ["safari"] }) {
|
||||||
id
|
id
|
||||||
key
|
key
|
||||||
version
|
version
|
||||||
@@ -165,7 +629,7 @@ mutation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mutation {
|
mutation {
|
||||||
changeIssueStatus(input: { issueId: "…", status: QA_FAILED, reason: "Sigue fallando en Safari 17" }) {
|
changeIssueStatus(input: { issueId: "…", status: "QA_FAILED", reason: "Sigue fallando en Safari 17" }) {
|
||||||
version
|
version
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -210,6 +674,27 @@ query {
|
|||||||
percent
|
percent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
analytics(days: 90) {
|
||||||
|
createdVsResolved {
|
||||||
|
week
|
||||||
|
created
|
||||||
|
resolved
|
||||||
|
}
|
||||||
|
cycleTime {
|
||||||
|
averageDays
|
||||||
|
p85Days
|
||||||
|
}
|
||||||
|
qa {
|
||||||
|
failureRate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
release(id: "…") {
|
||||||
|
burnup {
|
||||||
|
dates
|
||||||
|
scope
|
||||||
|
done
|
||||||
|
}
|
||||||
}
|
}
|
||||||
issue(idOrKey: "SHOP-1") {
|
issue(idOrKey: "SHOP-1") {
|
||||||
status
|
status
|
||||||
@@ -265,13 +750,78 @@ Los errores de dominio llegan como errores GraphQL con `extensions.code`:
|
|||||||
| `BUSINESS_RULE` | Regla de negocio (transición no permitida, release bloqueada…) |
|
| `BUSINESS_RULE` | Regla de negocio (transición no permitida, release bloqueada…) |
|
||||||
| `CONFLICT` | Otro cambio llegó antes (concurrencia optimista): reintenta |
|
| `CONFLICT` | Otro cambio llegó antes (concurrencia optimista): reintenta |
|
||||||
|
|
||||||
## 3.3 REST
|
## 3.3 REST con OpenAPI (para otras apps)
|
||||||
|
|
||||||
| Endpoint | Uso |
|
Los mismos casos de uso que GraphQL, como API REST documentada con OpenAPI 3.0, para que otras apps de la plataforma
|
||||||
| --------------------------- | ----------------------------------------------------------------------------------------------- |
|
la consuman o generen un cliente. Son otro adaptador de entrada (`presenters/http`) que llama a los mismos
|
||||||
| `GET /api/health` | Estado de PostgreSQL, MongoDB y Redis (público) |
|
`*.service.ts`: misma validación, permisos y errores.
|
||||||
| `GET /api/auth/config` | Configuración para iniciar sesión con Pireon desde el navegador (público) |
|
|
||||||
| `POST /api/auth/token` | Proxy de `/oauth/token` de Pireon: `authorization_code`, `password` y `refresh_token` (público) |
|
|
||||||
| `POST /api/webhooks/pireon` | Webhooks de Pireon firmados con HMAC (`user.deactivated`, `token.revoked`) |
|
|
||||||
|
|
||||||
Ver [Autenticación](05-autenticacion.md).
|
| | |
|
||||||
|
| ----------------- | ----------------------------------------------------------------------------------- |
|
||||||
|
| Swagger UI | `/api/docs` (con «Authorize» contra Pireon) |
|
||||||
|
| Documento OpenAPI | `/api/docs-json` (y `/api/docs-yaml`) |
|
||||||
|
| Fichero exportado | `apps/api/openapi.json`: lo reescribe `nx serve api` al arrancar, como `schema.gql` |
|
||||||
|
|
||||||
|
### Autenticación: siempre a través de Pireon
|
||||||
|
|
||||||
|
Cualquier app o usuario que la consuma pasa por el OAuth de Pireon: todas las rutas, salvo `health`, `auth`, el
|
||||||
|
webhook, `explore` y las lecturas de proyectos públicos, exigen `Authorization: Bearer <token de Pireon>` (el
|
||||||
|
`AuthGuard` global, igual que en GraphQL). En el documento OpenAPI, las rutas donde el token es opcional lo indican con
|
||||||
|
un requisito de seguridad vacío (`{}`). Declara dos esquemas:
|
||||||
|
|
||||||
|
- **`pireon-oauth`** (OAuth2): _Authorization Code + PKCE_ contra `oauth.pireon.dev/oauth/authorize` y, si
|
||||||
|
`PIREON_PASSWORD_GRANT=true`, _password_. El intercambio de tokens va por `POST /api/auth/token` (Pireon no tiene
|
||||||
|
CORS en `/oauth/token` y el `client_secret` se queda en el servidor). Para usar «Authorize» en Swagger UI, registra
|
||||||
|
`<api>/api/docs/oauth2-redirect.html` como redirect URI del cliente en Pireon.
|
||||||
|
- **`pireon`** (Bearer): para una app que ya tiene un token de Pireon, por ejemplo con su propio cliente OAuth.
|
||||||
|
|
||||||
|
Lo que cada usuario puede ver o cambiar depende de su rol en cada proyecto, como en la web.
|
||||||
|
|
||||||
|
### Recursos
|
||||||
|
|
||||||
|
Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
|
||||||
|
|
||||||
|
| Recurso | Rutas |
|
||||||
|
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` |
|
||||||
|
| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `POST …/archive`, `POST …/unarchive`, `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/insights`, `…/activity`, `…/members` (POST, PATCH, DELETE) |
|
||||||
|
| Tickets | `GET /api/me/home` (portada), `GET /api/me/portfolio` (cartera), `GET /api/me/issues` (mi trabajo; filtros `project`, `priority`, `bugsOnly`, `overdue`), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `POST …/reviews`, `POST …/reviews/{id}/answer`, `DELETE …/reviews/{id}`, `PUT/DELETE …/follow-tree`, `PUT/DELETE …/reminder`, `GET /api/me/reminders`, `GET /api/me/day`, `PUT/DELETE /api/me/day/{issue}`, `GET /api/me/pins` y `PUT/DELETE …/pin` (fijados), `PUT/DELETE …/presence`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history`, `POST /api/issues/{issue}/duplicate`, `PUT/DELETE …/vote` |
|
||||||
|
| Comentarios | `PATCH/DELETE /api/comments/{commentId}`, `POST …/reactions`, `PUT/DELETE …/decision`, `PUT/DELETE …/resolution`, `POST …/poll/vote`, `POST …/poll/close`; `GET /api/issues/{issue}/decisions`, `GET /api/projects/{project}/decisions`, `GET /api/me/decisions` |
|
||||||
|
| 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) |
|
||||||
|
| Compartir | `GET/PUT/DELETE /api/projects/{project}/share` (enlace para clientes), `GET /api/shared/dashboards/{token}` (lo que enseña, 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) |
|
||||||
|
| Alertas | `GET/PUT/DELETE /api/projects/{project}/alert-integration`, `POST …/alert-integration/token`, `POST /api/webhooks/alerts/{project}/{token}` |
|
||||||
|
| Explorar | `GET /api/explore/projects/{project}/showcase` (sin token, proyectos públicos): hoja de ruta con votos, versiones, sprint en curso, tickets `good-first-issue` y propuestas abiertas; `POST …/proposals` (sin token) |
|
||||||
|
| 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 (la API ya lo ha reintentado 5 veces; raro) 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).
|
||||||
|
|||||||
@@ -5,18 +5,18 @@ API repartida en `libs/web/*`.
|
|||||||
|
|
||||||
## 4.1 Librerías
|
## 4.1 Librerías
|
||||||
|
|
||||||
| Librería | Contenido |
|
| Librería | Contenido |
|
||||||
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `web/shared/infrastructure` | Cliente Apollo (`provideGraphql`: HTTP + `graphql-ws`), `API_CONFIG`, puerto `AccessTokenSource` |
|
| `web/shared/infrastructure` | Cliente Apollo (`provideGraphql`: HTTP + `graphql-ws`), `API_CONFIG`, puerto `AccessTokenSource` |
|
||||||
| `web/shared/ui` | Integración de `@pi/ui` (`providePiUi`), modo oscuro, iconos, `ToastService`, `DrawerService`, `AppShellComponent` |
|
| `web/shared/ui` | Integración de `@pi/ui` (`providePiUi`), modo oscuro, iconos, `ToastService` (con `undoable`: aviso con «Deshacer», `UndoToastComponent`), `DrawerService`, `AppShellComponent` |
|
||||||
| `web/identity/domain` | `Session`, `CurrentUser`, `AuthConfig` y puertos `AuthGateway`, `ProfileGateway`, `SessionStorage`, `AuthorizationFlow` |
|
| `web/identity/domain` | `Session`, `CurrentUser`, `AuthConfig` y puertos `AuthGateway`, `ProfileGateway`, `SessionStorage`, `AuthorizationFlow` |
|
||||||
| `web/identity/application` | `AuthFacade` (pública); por dentro `AuthStore` y eventos `authPageEvents`, `authApiEvents`, `sessionEvents` |
|
| `web/identity/application` | `AuthFacade` (pública); por dentro `AuthStore` y eventos `authPageEvents`, `authApiEvents`, `sessionEvents` |
|
||||||
| `web/identity/infrastructure` | `/api/auth/*` (HTTP), `me` (GraphQL), `localStorage`, PKCE con Web Crypto; enlaza `AccessTokenSource` con la `AuthFacade` |
|
| `web/identity/infrastructure` | `/api/auth/*` (HTTP), `me` (GraphQL), `localStorage`, PKCE con Web Crypto; enlaza `AccessTokenSource` con la `AuthFacade` |
|
||||||
| `web/identity/presenters` | Página de login, callback de Pireon, `authGuard` / `guestGuard`, menú de usuario |
|
| `web/identity/presenters` | Página de login, callback de Pireon, `authGuard` / `guestGuard`, menú de usuario |
|
||||||
| `web/tracker/domain` | Modelos, enums, workflow y puertos (`ProjectsGateway`, `IssuesGateway`, `TrackerRealtimeGateway`…) |
|
| `web/tracker/domain` | Modelos, enums, workflow y puertos (`ProjectsGateway`, `IssuesGateway`, `TrackerRealtimeGateway`…) |
|
||||||
| `web/tracker/application` | Facades `ProjectsFacade`, `ProjectFacade`, `BoardFacade`, `IssueFacade` y puerto `Notifications`; por dentro, stores y eventos |
|
| `web/tracker/application` | Facades `ProjectsFacade`, `ProjectFacade`, `BoardFacade`, `IssueFacade` y puerto `Notifications`; por dentro, stores y eventos |
|
||||||
| `web/tracker/infrastructure` | Adaptadores GraphQL con operaciones tipadas (codegen) |
|
| `web/tracker/infrastructure` | Adaptadores GraphQL con operaciones tipadas (codegen) |
|
||||||
| `web/tracker/presenters` | Páginas: proyectos, tablero, ticket, releases, seguimiento, actividad y miembros |
|
| `web/tracker/presenters` | Páginas: proyectos, tablero, lista, ticket, propuestas, releases, seguimiento, actividad y miembros; `ts-markdown` (texto con formato, saneado con DOMPurify), `ts-rich-editor`, `ts-bulk-bar`, `ts-vote` |
|
||||||
|
|
||||||
## 4.2 Estado: NgRx Signals con eventos
|
## 4.2 Estado: NgRx Signals con eventos
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ export const boardPageEvents = eventGroup({
|
|||||||
source: 'Board Page',
|
source: 'Board Page',
|
||||||
events: {
|
events: {
|
||||||
opened: type<BoardFilter>(),
|
opened: type<BoardFilter>(),
|
||||||
issueMoved: type<{ issueId: string; to: IssueStatus; reason?: string | null }>(),
|
issueMoved: type<{ issueId: string; to: string; reason?: string | null }>(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ export class BoardFacade {
|
|||||||
else this.dispatch.opened(filter);
|
else this.dispatch.opened(filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
moveIssue(issueId: string, to: IssueStatus, reason?: string | null): void {
|
moveIssue(issueId: string, to: string, reason?: string | null): void {
|
||||||
this.dispatch.issueMoved({ issueId, to, reason });
|
this.dispatch.issueMoved({ issueId, to, reason });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,21 +109,73 @@ Qué gana la página:
|
|||||||
- **Un sitio para la lógica de presentación**: abrir o refiltrar el tablero, buscar usuarios a partir de 2 caracteres…
|
- **Un sitio para la lógica de presentación**: abrir o refiltrar el tablero, buscar usuarios a partir de 2 caracteres…
|
||||||
- **Tests sin plantilla**: la facade se prueba con gateways falsos (`facades.spec.ts`).
|
- **Tests sin plantilla**: la facade se prueba con gateways falsos (`facades.spec.ts`).
|
||||||
|
|
||||||
| Facade | Store | Ámbito | Responsabilidad |
|
| Facade | Store | Ámbito | Responsabilidad |
|
||||||
| ---------------- | --------------- | ----------------------- | --------------------------------------------------------------------------------------------------------- |
|
| ---------------- | --------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `AuthFacade` | `AuthStore` | raíz | Sesión, usuario, login (PKCE o email y contraseña de Pireon), renovación del token, restaurar al arrancar |
|
| `AuthFacade` | `AuthStore` | raíz | Sesión, usuario, login (PKCE o email y contraseña de Pireon), renovación del token, restaurar al arrancar |
|
||||||
| `ProjectsFacade` | `ProjectsStore` | raíz | Mis proyectos; se actualiza cuando me añaden o quitan (`myProjectsChanged`) |
|
| `ProjectsFacade` | `ProjectsStore` | raíz | Mis proyectos; se actualiza cuando me añaden o quitan (`myProjectsChanged`) |
|
||||||
| `ProjectFacade` | `ProjectStore` | ruta `/p/:key` | Proyecto, miembros, stats, releases, actividad en vivo y búsqueda de usuarios |
|
| `HomeFacade` | `HomeStore` | ruta `/` (portada) | «Mi trabajo» en la portada: mis tickets abiertos por categoría (QA fallidos primero), esperando QA y próximas releases; se recarga al volver a la pestaña |
|
||||||
| `BoardFacade` | `BoardStore` | ruta `board` | Tablero kanban, filtros, movimiento optimista, nuevo ticket, cambios en vivo |
|
| `MyHomeFacade` | `MyHomeStore` | ruta `/` (portada) | El resto de la portada (`myHome`): agenda, actividad, cifras de la semana, sprints, tarjetas, sugerencias, primeros pasos y quién está; se recarga cada minuto y al volver a la pestaña |
|
||||||
| `IssueFacade` | `IssueStore` | ruta `issues/:issueKey` | Ticket, comentarios en vivo, historial y todas sus acciones |
|
| `MyViewsFacade` | `MyViewsStore` | ruta `/` (portada) | Mis vistas guardadas de «Mi trabajo» (entre proyectos: proyectos, asignados a mí, que sigo, estados, prioridades, solo bugs, vencidos, texto) y los tickets de la elegida |
|
||||||
|
| `ExploreFacade` | `ExploreStore` | ruta `explore` | 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 (con «Deshacer»), prioridad, estimación y persona cambiadas desde la tarjeta, nuevo ticket, cambios en vivo |
|
||||||
|
| `ListFacade` | `ListStore` | ruta `list` | Todos los tickets del proyecto en filas: filtros (enviados a la API), cambios de estado, prioridad y persona en la fila, edición en bloque; se recarga tras cada cambio propio y con los de otros |
|
||||||
|
| `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
|
En la raíz solo queda lo que usa la cabecera: la sesión (`AuthFacade`), mis proyectos para la paleta de comandos
|
||||||
`providers` del componente de la página (store + facade) y desaparecen al salir (con sus subscriptions). Las páginas
|
(`ProjectsFacade`) y la campana (`InboxFacade`, `PushFacade`). La paleta y la campana se cargan con `@defer (on idle;
|
||||||
hijas de `/p/:key` reciben la misma `ProjectFacade`.
|
on interaction)`: su código y sus consultas no están en el arranque, y mientras tanto la cabecera muestra una copia
|
||||||
|
quieta con la misma caja, así que nada se mueve al llegar. Lo demás es de su página:
|
||||||
|
|
||||||
|
- Las de una página que se vuelve a visitar a menudo (portada, Explorar, el formulario de propuestas) van en los
|
||||||
|
`providers` de su ruta (`provideHomeFacade()`, `provideMyHomeFacade()`, `provideMyViewsFacade()`,
|
||||||
|
`provideCalendarFacade()`, `provideExploreFacade()`, `provideProposeFacade()`, `provideSharedDashboardFacade()`): cargan con la página y el inyector de
|
||||||
|
la ruta las conserva, así que al volver se ve lo último al instante mientras se recarga.
|
||||||
|
- `SharingFacade` (el enlace para clientes en Configuración) va en los `providers` de su propio componente.
|
||||||
|
- Las de un proyecto o un ticket 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 hijas de `/p/:key` reciben la misma `ProjectFacade`.
|
||||||
|
|
||||||
|
Los adaptadores siguen el mismo reparto: `provideTrackerShellInfrastructure()` (proyectos, tickets, avisos, push y tiempo
|
||||||
|
real) va en la raíz, y `provideTrackerPagesInfrastructure()` (el resto) en la ruta del tracker, que se carga en diferido
|
||||||
|
(`apps/web/src/app/tracker.routes.ts`). Las librerías web declaran `"sideEffects": false` (`libs/web/package.json`), para
|
||||||
|
que el empaquetador deje en cada trozo solo lo que usa y no todo lo que exporta un `index.ts`.
|
||||||
|
|
||||||
Otros contextos también usan la facade: los stores del tracker leen el usuario de `AuthFacade`, y la infraestructura
|
Otros contextos también usan la facade: los stores del tracker leen el usuario de `AuthFacade`, y la infraestructura
|
||||||
de identidad (`SessionTokenSource`) le pide el token para el cliente GraphQL.
|
de identidad (`SessionTokenSource`) le pide el token para el cliente GraphQL.
|
||||||
|
|
||||||
|
### Notificaciones push
|
||||||
|
|
||||||
|
En la campana, «Avisos en este dispositivo» (si la API tiene claves VAPID y el navegador admite push) registra
|
||||||
|
`/push-sw.js`, pide permiso, suscribe el navegador y manda la suscripción a la API (`PushFacade` + el puerto `PushDevice`,
|
||||||
|
con `BrowserPushDevice` como adaptador). El _service worker_ solo hace push: muestra el aviso y, al pulsarlo, enfoca una
|
||||||
|
pestaña de TaskStream y la lleva al ticket, o abre una. No guarda nada en caché, así que un despliegue nunca deja una
|
||||||
|
versión vieja; nginx sirve `push-sw.js` y `manifest.webmanifest` sin caché. Con el manifiesto la web se puede instalar,
|
||||||
|
que en iPhone es condición para recibir push (iOS 16.4+).
|
||||||
|
|
||||||
|
### Calendario
|
||||||
|
|
||||||
|
«Mi trabajo» tiene un botón «Calendario» que abre `ts-calendar-feed`: crea la URL secreta, la copia, la renueva o la
|
||||||
|
desactiva (`CalendarFacade`, sin store). La URL es absoluta, compuesta con `restUrl` y el origen de la página, porque es
|
||||||
|
lo que se pega en la aplicación de calendario.
|
||||||
|
|
||||||
|
### Texto con formato
|
||||||
|
|
||||||
|
Descripciones y comentarios se escriben con `ts-rich-editor`, un editor visual sobre
|
||||||
|
[TipTap](https://tiptap.dev) (ProseMirror) que **lee y escribe Markdown**, que es lo que guarda la API:
|
||||||
|
|
||||||
|
- Barra con negrita, cursiva, tachado, código, título, listas, tareas, cita, bloque de código y enlace; Ctrl/Cmd+Enter
|
||||||
|
envía.
|
||||||
|
- `@` sugiere a los miembros del proyecto y escribe la mención como siempre, `@[Nombre](id)` (un lector propio en
|
||||||
|
`rich-text.ts`; la de TipTap usa otro formato).
|
||||||
|
- Pegar una captura la sube como adjunto y la deja como ``; dentro del editor se descarga con
|
||||||
|
la sesión, reintentando unos instantes porque la API tarda en listar el adjunto nuevo.
|
||||||
|
- Entiende también tablas, para que editar un texto antiguo no las pierda (`rich-text.spec.ts` comprueba la ida y
|
||||||
|
vuelta).
|
||||||
|
- Funciona con `[(value)]` y con `[formField]` de los formularios de señales.
|
||||||
|
- Va en un trozo aparte (unos 150 kB comprimido) que solo se descarga en el ticket, el tablero y el formulario de
|
||||||
|
propuestas.
|
||||||
|
|
||||||
### Tiempo real y consistencia
|
### Tiempo real y consistencia
|
||||||
|
|
||||||
- Cada store se suscribe solo a lo que muestra (`issueChanges(projectId)`, `commentChanges(issueId)`…).
|
- Cada store se suscribe solo a lo que muestra (`issueChanges(projectId)`, `commentChanges(issueId)`…).
|
||||||
@@ -138,6 +190,10 @@ de identidad (`SessionTokenSource`) le pide el token para el cliente GraphQL.
|
|||||||
fuente de verdad.
|
fuente de verdad.
|
||||||
- Las subscriptions van por `graphql-ws` con el token en `connectionParams`; si la API responde `UNAUTHENTICATED`, la
|
- Las subscriptions van por `graphql-ws` con el token en `connectionParams`; si la API responde `UNAUTHENTICATED`, la
|
||||||
sesión se cierra.
|
sesión se cierra.
|
||||||
|
- El cliente manda un _ping_ cada 10 s y, si no llega el _pong_ en 5 s, cierra el socket y reconecta (una conexión
|
||||||
|
medio muerta, p. ej. tras suspender el portátil o pasar por un proxy, no se nota de otro modo). Al reconectar,
|
||||||
|
`RealtimeConnection.reconnected$` avisa y cada store vuelve a pedir lo que muestra, porque lo que pasó mientras
|
||||||
|
tanto no llega por las subscriptions.
|
||||||
- Las operaciones están en `libs/web/*/infrastructure/src/lib/graphql/*.graphql`. Tras cambiar la API o una operación:
|
- Las operaciones están en `libs/web/*/infrastructure/src/lib/graphql/*.graphql`. Tras cambiar la API o una operación:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -145,7 +201,9 @@ de identidad (`SessionTokenSource`) le pide el token para el cliente GraphQL.
|
|||||||
npx nx run web:codegen # regenera generated.ts (TypedDocumentNode)
|
npx nx run web:codegen # regenera generated.ts (TypedDocumentNode)
|
||||||
```
|
```
|
||||||
|
|
||||||
- Los adaptadores convierten los tipos generados en modelos de dominio en un único sitio (`adapters/mappers.ts`).
|
- Los adaptadores convierten los tipos generados en modelos de dominio en un único sitio (`adapters/mappers.ts`). Lo
|
||||||
|
que se lee y luego se reenvía como input (la configuración del proyecto) se copia campo a campo: Apollo añade
|
||||||
|
`__typename` a cada objeto y los input types de GraphQL lo rechazan.
|
||||||
|
|
||||||
## 4.4 `@pi/ui`
|
## 4.4 `@pi/ui`
|
||||||
|
|
||||||
@@ -164,7 +222,7 @@ La librería de componentes de Pireon se instala desde su Nexus (scope `@pi`, co
|
|||||||
| Drawers y modales se abren con `MatDialog` | `DrawerService.open(título, template)` con `getDrawerConfigs` de la librería |
|
| Drawers y modales se abren con `MatDialog` | `DrawerService.open(título, template)` con `getDrawerConfigs` de la librería |
|
||||||
| `pi-search-field` es un buscador plegable (un icono) | Para un campo de búsqueda normal se usa `pi-text-field` |
|
| `pi-search-field` es un buscador plegable (un icono) | Para un campo de búsqueda normal se usa `pi-text-field` |
|
||||||
|
|
||||||
| `button` globales: `min-width`, padding y radio con selectores muy específicos | Los botones propios de la app usan `.ts-icon-button`; `pi-action-button size="w100"` necesita `box-sizing` (en `styles.scss`) |
|
| `button` globales: `min-width`, padding y radio con selectores muy específicos | Los botones propios de la app usan `.ts-icon-button`, o anulan esas reglas con `!important` (votos, reacciones); `pi-action-button size="w100"` necesita `box-sizing` (en `styles.scss`) |
|
||||||
| Los campos pintan cualquier error que reciben, aunque no se hayan tocado | En el login, los errores se pasan solo tras salir del campo o intentar entrar (`(touch)`) |
|
| Los campos pintan cualquier error que reciben, aunque no se hayan tocado | En el login, los errores se pasan solo tras salir del campo o intentar entrar (`(touch)`) |
|
||||||
|
|
||||||
Componentes en uso: `pi-action-button`, `pi-icon-button`, `pi-icon`, `pi-note`, `pi-text-field`, `pi-email-field`,
|
Componentes en uso: `pi-action-button`, `pi-icon-button`, `pi-icon`, `pi-note`, `pi-text-field`, `pi-email-field`,
|
||||||
@@ -193,19 +251,77 @@ versiones) y un color por estado (`--ts-s-*`) y por tipo de ticket (`--ts-t-*`),
|
|||||||
gráficas e historial. Salen de `tone` en `ui/copy.ts` y las plantillas los aplican con `[style.--tone]`; utilidades
|
gráficas e historial. Salen de `tone` en `ui/copy.ts` y las plantillas los aplican con `[style.--tone]`; utilidades
|
||||||
globales como `.ts-pill` o `.ts-key` leen esa variable. Las animaciones se desactivan con `prefers-reduced-motion`.
|
globales como `.ts-pill` o `.ts-key` leen esa variable. Las animaciones se desactivan con `prefers-reduced-motion`.
|
||||||
|
|
||||||
|
### Configuración del proyecto en la UI
|
||||||
|
|
||||||
|
Estados, tipos y áreas son de cada proyecto, así que la UI nunca los tiene fijos: `ProjectFacade.workflow()` envuelve
|
||||||
|
`project.settings` (`Workflow` en el dominio web) y da nombre, color, icono, categoría, transiciones permitidas y si un
|
||||||
|
estado pide motivo. `statusLook`/`typeLook` de `ui/copy.ts` lo convierten en etiqueta y tono: `--ts-c-<color>` para
|
||||||
|
la paleta, con su versión oscura hecha a mano, y para un `#rrggbb` propio, `light-dark()` con una variante por tema
|
||||||
|
que `ui/colors.ts` calcula con la librería `color` (se acerca al blanco o al negro hasta contrastar 3:1 con la
|
||||||
|
superficie). En Configuración, cada estado y tipo elige su color con el selector nativo (`<input type="color">`), con
|
||||||
|
la paleta como sugerencias; si se elige un color de la paleta, se guarda su nombre. 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.
|
||||||
|
|
||||||
|
Los campos personalizados se definen en Configuración (el tipo se elige al crearlos y luego queda fijo; las opciones
|
||||||
|
de una lista, una por línea). En la ficha, `ui/custom-field.ts` pone el control de cada tipo: las listas y las fechas
|
||||||
|
se guardan al elegir; el texto y los números, al salir del campo. En la lista, cada campo de opciones añade un filtro y
|
||||||
|
los valores del ticket se ven bajo su título.
|
||||||
|
|
||||||
|
### 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. `/explore` 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 en General la casilla «Proyecto público» (propietario y admins) y, en Zona peligrosa y solo para el propietario, «Eliminar el
|
||||||
|
proyecto», que exige escribir la clave. Al borrarlo se vuelve a la portada; si otro lo borra mientras lo tienes
|
||||||
|
abierto, la subscription avisa (`ProjectChanged.deleted`) y también se vuelve a la portada.
|
||||||
|
|
||||||
## 4.6 Rutas
|
## 4.6 Rutas
|
||||||
|
|
||||||
| Ruta | Página |
|
| Ruta | Página |
|
||||||
| -------------------------- | ----------------------------------------------- |
|
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `/login` | Inicio de sesión (solo sin sesión) |
|
| `/login` | Inicio de sesión (solo sin sesión) |
|
||||||
| `/auth/callback` | Retorno de Pireon (PKCE) |
|
| `/auth/callback` | Retorno de Pireon (PKCE) |
|
||||||
| `/` | Mis proyectos |
|
| `/` | Inicio: saludo según la hora con el resumen del día, «Ir a…» (`WEB-12`), primeros pasos, mis proyectos con su actividad (lo primero, para llegar antes), «Mi día» (`ts-my-day`: los tickets que elijo para hoy, tachados al terminar, mis abiertos para añadir y lo que quedó del último día), mis fijados (`ts-pinned`, una franja que se quita con ×; solo si hay), cuatro cifras (`ts-home-kpis`: abiertos míos, esperando QA, terminados por mí y por el equipo esta semana), la semana (`ts-week-agenda`), mis vistas guardadas y dos columnas: mis tickets y lo último en mis proyectos; sprints en curso, esperando QA, que sigo, próximas releases, quién está ahora y «Retomar» |
|
||||||
| `/p/:key/board` | Tablero kanban (`?release=` filtra por release) |
|
| `/portfolio` | Cartera (con sesión): una tarjeta por proyecto activo con lo abierto y en riesgo, el sprint en curso (¿llega?), las próximas releases con su previsión y las épicas abiertas; en directo: se vuelve a leer sola cuando cambia un ticket, sprint o release de esos proyectos. Cada proyecto lleva su semáforo de salud (`portfolioHealth`), con las causas al pasar por encima |
|
||||||
| `/p/:key/issues/:issueKey` | Ticket |
|
| `/explore` | Proyectos públicos (sin sesión) |
|
||||||
| `/p/:key/releases` | Releases |
|
| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros). Quien puede contribuir cambia prioridad, estimación y persona desde la propia tarjeta (selectores nativos sobre sus distintivos; el título es el enlace). Mover una tarjeta o cambiar su persona ofrece «Deshacer» unos segundos (o Ctrl/⌘ Z): la orden contraria, que el flujo puede rechazar. Con el teclado: Tab entra en las tarjetas, las flechas se mueven entre ellas, Shift+←/→ pasa la tarjeta de columna (mismas reglas que arrastrar), `p`/`e`/`a` abren su prioridad, estimación o persona y Enter la abre |
|
||||||
| `/p/:key/dashboard` | Seguimiento |
|
| `/p/:key/issues/:issueKey` | Ticket. Encima del título, quién más lo está viendo o editando ahora (`PresenceFacade`: late cada 10 s; editar es tener abierto el formulario del ticket o de un comentario, o estar escribiendo uno). «Fijar» lo deja a mano en el inicio y en la paleta de búsqueda. Arriba, sus decisiones (comentarios marcados con la bandera por quien puede cambiar el ticket); `?comment=<id>` lleva a un comentario y lo señala. Cambiar el estado o la persona y mandarlo a la papelera (sin confirmación) ofrecen «Deshacer». Cada conversación con respuestas se resuelve («✓ Resolver», con una conclusión opcional que queda como decisión) y se pliega en una línea; la cabecera cuenta las abiertas y terminar el ticket con alguna pregunta antes. Se ve quién está escribiendo en cada conversación («Ana está escribiendo…», `PresenceFacade.typists`), y en las respuestas Enter envía (Shift+Enter, otra línea). Si tiene subtareas, «Seguir todo» avisa de ella y de todo lo que cuelga, también lo que se añada (también desde Épicas). «Recordármelo» pone un aviso solo para mí (dentro de 1 hora, esta tarde, mañana o el lunes a las 9, en una semana o cuando diga), con nota opcional. «☀ Mi día» lo añade a los de hoy. «Pedir revisión» a alguien del equipo: lo ve en «Mi trabajo» («Te piden revisión») y responde «Todo bien» o «Pedir cambios» desde el recuadro Revisiones. Un comentario puede llevar una encuesta («📊 Añadir una encuesta»): se vota pulsando una opción y, al cerrarla, la ganadora queda como decisión. «🎥 Grabar pantalla» (con la voz, si se da permiso al micrófono) y «🎙 Grabar audio» graban en el navegador hasta 3 minutos y lo adjuntan al comentario, que puede ir sin texto; los vídeos y audios adjuntos se reproducen ahí mismo |
|
||||||
| `/p/:key/activity` | Actividad en vivo |
|
| `/p/:key/releases` | Releases. «Página pública» (quien gestiona): un enlace secreto con las notas para clientes, que se copia, renueva o retira |
|
||||||
| `/p/:key/members` | Miembros |
|
| `/p/:key/report/:kind/:id` | Informe para imprimir de un sprint (`sprint`) o una release (`release`): datos, totales, notas y tickets por etapa. «Imprimir o guardar en PDF» abre el diálogo del navegador; al imprimir se quitan la cabecera, las pestañas y los fondos (`styles.scss`) |
|
||||||
|
| `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea. Los bloqueos con tickets de otros proyectos (en los dos sentidos, `externalDependencies`) salen en «De otros proyectos» con barra discontinua, sus flechas y el mismo aviso si las fechas no encajan; los que no tienen fechas o no se pueden leer se nombran debajo |
|
||||||
|
| `/p/:key/dashboard` | Arriba, la salud del proyecto (`ts-project-health`): un semáforo (bien, atención, en riesgo) con cada causa y sus tickets, calculado en la web (`projectHealth`) con los mismos datos: atascados, vencidos, bloqueados, bugs críticos o altos, lo que devuelve QA, releases que no llegan y columnas sobre su WIP. Seguimiento: cifras, flujo por estado, lo que ha cambiado desde tu última visita (guardada en este navegador), sprint en curso, tickets en riesgo, previsión de las próximas releases, antigüedad de lo que está en curso, cuellos de botella, salud del backlog (cada número abre la lista filtrada: `?missing=estimate`, `area`, `assignee` o `?staleDays=90`), calidad (bugs por prioridad y por semana, QA por área, bugs tras cada release), abiertos por un campo de lista a elegir, el recorrido de un ticket (dónde se va el tiempo de creado a hecho y qué parte es trabajo), la actividad de 12 semanas en un mapa de calor (pulsar un día abre lo que pasó, ticket a ticket), el embudo de propuestas del público (si las hay), equipo con sus puntos abiertos y tipos. Se recarga solo con cada cambio. «Modo pantalla» lo pone a pantalla completa para una tele del equipo: un bloque cada vez, ampliado, que cambia cada 15 s (← → para moverse, espacio para pausar, Esc para salir), con la hora y en directo |
|
||||||
|
| `/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), con un menú de secciones (`?section=`): General (visibilidad, propuestas, panel para clientes), Flujo de trabajo (estados, transiciones, definición de «Hecho»), Tickets (tipos, áreas, campos), Equipos, Automatizaciones, Integraciones (Git, alertas → bugs con su URL secreta y cómo conectar Sentry, Uptime Kuma o Grafana, webhooks), Importar y exportar y Zona peligrosa; archivar en lugar de borrar, y un punto en el menú donde hay cambios sin guardar |
|
||||||
|
| `/p/:key/activity` | Actividad en vivo |
|
||||||
|
| `/p/:key/propose` | Formulario público para reportar un error o proponer una idea sin cuenta (si el proyecto lo tiene abierto); llegan como tickets con la etiqueta «propuesta» |
|
||||||
|
| `/share/:token` | Panel para clientes, sin sesión: los bloques que eligió el proyecto (flujo, sprint, releases, épicas, actividad), sin enlaces hacia dentro de la aplicación; se vuelve a leer cada minuto. Si el enlace se renovó o retiró, «Este enlace ya no funciona» |
|
||||||
|
| `/notes/:token` | Notas de una release para sus clientes, sin cuenta: las escritas o las de sus tickets terminados |
|
||||||
|
| `/p/:key/members` | Miembros, con su rol (propietario, admin, miembro, lector o invitado). Un invitado de un proyecto privado solo tiene Tablero, Lista y Miembros, ve solo sus tickets y abre tickets sin asignarlos ni planificarlos |
|
||||||
|
|
||||||
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
|
||||||
los componentes (`withComponentInputBinding`).
|
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`). Las rutas van en inglés; las antiguas en castellano (`/cartera`,
|
||||||
|
`/explorar`, `/p/:key/proponer`) redirigen a las nuevas, para que sigan valiendo los enlaces ya compartidos.
|
||||||
|
|||||||
@@ -29,7 +29,11 @@ sequenceDiagram
|
|||||||
- Se cachea el **hash SHA-256** del token, nunca el token en claro.
|
- Se cachea el **hash SHA-256** del token, nunca el token en claro.
|
||||||
- Si Pireon marca al usuario como inactivo, el token se rechaza.
|
- Si Pireon marca al usuario como inactivo, el token se rechaza.
|
||||||
- El guard es global: todo requiere token salvo lo marcado con `@Public()` (health, configuración de login, proxy de
|
- El guard es global: todo requiere token salvo lo marcado con `@Public()` (health, configuración de login, proxy de
|
||||||
tokens y webhook).
|
tokens, webhook y `GET /api/explore/projects`).
|
||||||
|
- `@OptionalAuth()` (o `@ApiOptionalPireonAuth()` en REST) deja pasar sin token: es para las lecturas que permiten los
|
||||||
|
proyectos públicos (proyecto, tablero, tickets, comentarios, releases, actividad, estadísticas). Sin token, el
|
||||||
|
usuario es `undefined` y el visitante es anónimo; un token presente pero inválido sigue dando 401. Las escrituras, las
|
||||||
|
subscriptions y lo «mío» (`myProjects`, `myIssues`, `/api/me/…`) siguen exigiendo sesión.
|
||||||
- En subscriptions, el token se valida una vez al abrir el WebSocket (`connection_init`).
|
- En subscriptions, el token se valida una vez al abrir el WebSocket (`connection_init`).
|
||||||
- `@RequireAppRoles('…')` permite exigir roles del cliente OAuth en Pireon (los admins globales de Pireon los tienen todos).
|
- `@RequireAppRoles('…')` permite exigir roles del cliente OAuth en Pireon (los admins globales de Pireon los tienen todos).
|
||||||
|
|
||||||
@@ -38,27 +42,70 @@ sequenceDiagram
|
|||||||
Pireon **no permite CORS** en `/oauth/token` y el `client_secret` no debe llegar al navegador, así que la web pasa por
|
Pireon **no permite CORS** en `/oauth/token` y el `client_secret` no debe llegar al navegador, así que la web pasa por
|
||||||
la API: `POST /api/auth/token` hace de proxy hacia Pireon.
|
la API: `POST /api/auth/token` hace de proxy hacia Pireon.
|
||||||
|
|
||||||
| Método | Necesita | Flujo |
|
Cada cliente OAuth de Pireon admite **un único tipo de grant**, así que cada forma de entrar usa su cliente:
|
||||||
| ------------------------------------------------- | ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| **Entrar con Pireon** (Authorization Code + PKCE) | `PIREON_CLIENT_ID`, `PIREON_CLIENT_SECRET` y la redirect URI registrada | Web → `/oauth/authorize` de Pireon → `/auth/callback` → `POST /api/auth/token` (`authorization_code` + `code_verifier`) |
|
|
||||||
| **Email y contraseña** | El mismo cliente, con `PIREON_PASSWORD_GRANT=true` | `POST /api/auth/token` (`password`) |
|
|
||||||
|
|
||||||
Las dos opciones necesitan el cliente OAuth: sin `PIREON_CLIENT_ID` la API responde `503` y el login avisa de que no
|
| Método | Cliente de Pireon (`grant_type`) | Variables | Flujo |
|
||||||
está configurado. El email y la contraseña son los de la cuenta de Pireon; TaskStream no los guarda, solo los reenvía.
|
| ------------------------------------------------- | -------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------- |
|
||||||
(El login con token de acceso personal se ha retirado de la web por ahora; la API sigue aceptando esos tokens como
|
| **Entrar con Pireon** (Authorization Code + PKCE) | `authorization_code` | `PIREON_CLIENT_ID`, `PIREON_CLIENT_SECRET` | Web → `/oauth/authorize` de Pireon → `/auth/callback` → `POST /api/auth/token` (`authorization_code` + `code_verifier`) |
|
||||||
`Bearer`.)
|
| **Email y contraseña** | `password` | `PIREON_PASSWORD_CLIENT_ID`, `PIREON_PASSWORD_CLIENT_SECRET` | `POST /api/auth/token` (`password`); se desactiva con `PIREON_PASSWORD_GRANT=false` |
|
||||||
|
|
||||||
|
Si no se definen las variables `PIREON_PASSWORD_CLIENT_*`, el login con contraseña usa `PIREON_CLIENT_ID` (sirve si
|
||||||
|
solo hay un cliente de tipo `password`, pero entonces «Entrar con Pireon» falla con `unauthorized_client`). Al renovar
|
||||||
|
la sesión, la API prueba los dos clientes: un `refresh_token` solo vale con el cliente que lo emitió.
|
||||||
|
|
||||||
|
El email y la contraseña son los de la cuenta de Pireon; TaskStream no los guarda, solo los reenvía. (El login con
|
||||||
|
token de acceso personal se ha retirado de la web por ahora; la API sigue aceptando esos tokens como `Bearer`.)
|
||||||
|
|
||||||
### Registrar TaskStream como cliente en Pireon
|
### Registrar TaskStream como cliente en Pireon
|
||||||
|
|
||||||
1. Crea el cliente: `POST https://oauth.pireon.dev/api/clients` (con un token tuyo de Pireon, scope `account:write`).
|
Con un token tuyo de Pireon (scope `account:write`), `POST https://oauth.pireon.dev/api/clients`:
|
||||||
2. Registra como redirect URIs:
|
|
||||||
- `http://localhost:4200/auth/callback` (desarrollo)
|
|
||||||
- `http://localhost:8080/auth/callback` (Docker)
|
|
||||||
- la de producción
|
|
||||||
3. Pon `PIREON_CLIENT_ID` y `PIREON_CLIENT_SECRET` en `.env` (o en el entorno del contenedor).
|
|
||||||
|
|
||||||
`GET /api/auth/config` devuelve a la web lo necesario para iniciar el flujo (`authorizeUrl`, `clientId`, `scopes`,
|
1. **Cliente para «Entrar con Pireon»**: `{"name": "TaskStream", "grant_type": "authorization_code", "confidential": true,
|
||||||
`passwordGrant`), o `null` si no hay cliente configurado; en ese caso el login muestra el aviso y no deja entrar.
|
"redirect_uris": [...]}` con estas redirect URIs:
|
||||||
|
- `http://localhost:4200/auth/callback` (desarrollo) y `http://localhost:8080/auth/callback` (Docker)
|
||||||
|
- `https://taskstream.pireon.dev/auth/callback` (producción)
|
||||||
|
- para el «Authorize» de Swagger UI: `http://localhost:3000/api/docs/oauth2-redirect.html` y
|
||||||
|
`https://api.taskstream.pireon.dev/api/docs/oauth2-redirect.html` (ver
|
||||||
|
[API REST](03-api.md#33-rest-con-openapi-para-otras-apps))
|
||||||
|
|
||||||
|
→ `PIREON_CLIENT_ID` y `PIREON_CLIENT_SECRET`. Al ser confidencial, **el secret es obligatorio**: sin él Pireon
|
||||||
|
rechaza el canje del código (ver la tabla de errores más abajo).
|
||||||
|
|
||||||
|
2. **Cliente para email y contraseña**: `{"name": "TaskStream (password)", "grant_type": "password"}`
|
||||||
|
→ `PIREON_PASSWORD_CLIENT_ID` y `PIREON_PASSWORD_CLIENT_SECRET`.
|
||||||
|
|
||||||
|
Pireon muestra el secret (`plain_secret`) **una sola vez**, en la respuesta de alta. Si se pierde, se genera otro con
|
||||||
|
`POST https://oauth.pireon.dev/api/clients/{client}/rotate-secret` (el anterior sigue valiendo 48 h).
|
||||||
|
|
||||||
|
Ponlos en `.env` (o en el entorno del contenedor; en Dokploy, en _Environment_) y reinicia la API; en Docker/Dokploy,
|
||||||
|
**recrea** el contenedor (redesplegar), porque un reinicio no relee el entorno.
|
||||||
|
|
||||||
|
En producción:
|
||||||
|
|
||||||
|
| Servicio | URL | Redirect URIs registradas en el cliente `authorization_code` |
|
||||||
|
| ---------- | ----------------------------------- | ----------------------------------------------------------------- |
|
||||||
|
| Web | `https://taskstream.pireon.dev` | `https://taskstream.pireon.dev/auth/callback` |
|
||||||
|
| API (REST) | `https://api.taskstream.pireon.dev` | `https://api.taskstream.pireon.dev/api/docs/oauth2-redirect.html` |
|
||||||
|
|
||||||
|
`GET /api/auth/config` le dice a la web qué formas de entrar hay: `clientId` del cliente `authorization_code` (o `null`:
|
||||||
|
la web oculta «Entrar con Pireon») y `passwordGrant`. Devuelve `null` si no hay ningún cliente; en ese caso el login
|
||||||
|
muestra el aviso y no deja entrar.
|
||||||
|
|
||||||
|
### Si el login falla
|
||||||
|
|
||||||
|
La API reenvía el error de Pireon con su `hint` entre paréntesis (lo que de verdad falla) y lo registra en su log como
|
||||||
|
`Pireon rejected <grant> for client <id>: … (hint: …)`.
|
||||||
|
|
||||||
|
| Error | Causa | Solución |
|
||||||
|
| ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| El login dice «Inicio de sesión no configurado» | `GET /api/auth/config` devuelve `null`: no hay ningún cliente en el entorno de la API | Define `PIREON_CLIENT_ID` y/o `PIREON_PASSWORD_CLIENT_ID` (con sus secrets) y recrea el contenedor |
|
||||||
|
| `unauthorized_client`: _not authorized to use this authorization grant type_ | El cliente no es del tipo que pide el flujo (p. ej. uno `password` en `PIREON_CLIENT_ID`) | Un cliente `authorization_code` en `PIREON_CLIENT_ID` y uno `password` en `PIREON_PASSWORD_CLIENT_ID` |
|
||||||
|
| `invalid_request` … _(Check the `client_secret` parameter)_ | El cliente es confidencial y la API no envía secret: la variable está vacía o no llega | Pon el secret en `PIREON_CLIENT_SECRET` (o `PIREON_PASSWORD_CLIENT_SECRET`) y redespliega |
|
||||||
|
| `invalid_client`: _Client authentication failed_ | El secret llega pero no es el de ese cliente | Revisa que id y secret sean del mismo cliente; si no lo tienes, rótalo |
|
||||||
|
| `invalid_request` … _(Invalid redirect URI)_ | La redirect URI del canje no es la de la autorización | Regístrala exactamente igual en el cliente (esquema, dominio y ruta, sin barra final) |
|
||||||
|
| `invalid_grant`: _Authorization code has expired / has been revoked_ | El código caducó o ya se usó (p. ej. al recargar `/auth/callback`) | Vuelve a «Entrar con Pireon» |
|
||||||
|
| `The user credentials were incorrect.` | Email o contraseña de Pireon incorrectos | — |
|
||||||
|
| Errores `404 Cannot GET/POST /api/…` intermitentes a través de la web | Otro `api` en la red compartida de Dokploy recibe parte de las peticiones | Ya resuelto con alias únicos (`task-stream-api`…), ver [Despliegue](07-despliegue.md#nombres-únicos-en-la-red) |
|
||||||
|
|
||||||
### Sesión en la web
|
### Sesión en la web
|
||||||
|
|
||||||
@@ -75,9 +122,16 @@ caduque la caché):
|
|||||||
1. Registra `https://<tu-api>/api/webhooks/pireon` con los eventos `user.deactivated` y `token.revoked`
|
1. Registra `https://<tu-api>/api/webhooks/pireon` con los eventos `user.deactivated` y `token.revoked`
|
||||||
(`POST /api/clients/{client}/webhooks`).
|
(`POST /api/clients/{client}/webhooks`).
|
||||||
2. Pon el secret que devuelve en `PIREON_WEBHOOK_SECRET`.
|
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
|
La API verifica la firma `X-Webhook-Signature` (HMAC-SHA256 del cuerpo, válida con cualquiera de los dos secrets),
|
||||||
sesiones cacheadas. Sin `PIREON_WEBHOOK_SECRET`, el endpoint rechaza todas las llamadas.
|
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
|
## 5.4 Usuarios en TaskStream
|
||||||
|
|
||||||
|
|||||||
@@ -25,16 +25,19 @@ la web y la API comparten origen igual que en Docker.
|
|||||||
|
|
||||||
## 6.3 Comandos
|
## 6.3 Comandos
|
||||||
|
|
||||||
| Comando | Qué hace |
|
| Comando | Qué hace |
|
||||||
| -------------------------------- | --------------------------------------------------------------------------------- |
|
| -------------------------------- | ----------------------------------------------------------------------------------- |
|
||||||
| `npx nx run-many -t lint` | Lint de todo, incluidas las reglas de capas y contextos |
|
| `npx nx run-many -t lint` | Lint de todo, incluidas las reglas de capas y contextos |
|
||||||
| `npx nx run-many -t test` | Tests unitarios (dominio y aplicación, API y web) |
|
| `npx nx run-many -t test` | Tests unitarios (dominio y aplicación, API y web) |
|
||||||
| `npx nx e2e api` | Flujo completo de la API contra Docker, con Pireon simulado |
|
| `npx nx e2e api` | Flujo completo de la API contra Docker, con Pireon simulado |
|
||||||
| `npx nx run-many -t build` | Builds de la API y la web |
|
| `npx nx run-many -t build` | Builds de la API y la web |
|
||||||
| `npx nx run web:codegen` | Regenera los tipos GraphQL del cliente desde `apps/api/schema.gql` |
|
| `npx nx run web:codegen` | Regenera los tipos GraphQL del cliente desde `apps/api/schema.gql` |
|
||||||
| `npx nx run api:prune` | Build de la API con `package.json` y lockfile podados (lo usa Docker) |
|
| `npx nx run api:contracts` | Escribe `schema.gql` y `openapi.json` sin arrancar la API ni bases de datos |
|
||||||
| `npx nx graph` | Grafo de dependencias entre proyectos |
|
| `npx nx serve mcp` | Servidor MCP en `http://localhost:3010/mcp`, contra la API local ([MCP](09-mcp.md)) |
|
||||||
| `npx nx test api-tracker-domain` | Tests de una librería concreta (`api-<contexto>-<capa>`, `web-<contexto>-<capa>`) |
|
| `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
|
### 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`.
|
por `ReadModelNotifier`. Añadir el evento al `ActivityEventHandler` y una frase en `activity-summary.ts`.
|
||||||
- Si el read model cambia, añadir el campo a `IssueReadModel` (dominio) y al esquema Mongo (infraestructura).
|
- Si el read model cambia, añadir el campo a `IssueReadModel` (dominio) y al esquema Mongo (infraestructura).
|
||||||
3. **Presenters** (`libs/api/tracker/presenters`): input y mutation `blockIssue` en `IssueResolver`, que llama al service.
|
3. **Presenters** (`libs/api/tracker/presenters`): input y mutation `blockIssue` en `IssueResolver`, que llama al service.
|
||||||
4. Arrancar la API para regenerar `apps/api/schema.gql`.
|
4. Regenerar `apps/api/schema.gql` y `apps/api/openapi.json`: arrancando la API, o sin bases de datos con
|
||||||
|
`npx nx run api:contracts`.
|
||||||
|
|
||||||
### Web
|
### Web
|
||||||
|
|
||||||
@@ -86,8 +90,10 @@ exporta facades, una página tampoco puede tocar un store ni despachar eventos p
|
|||||||
| Síntoma | Causa y solución |
|
| Síntoma | Causa y solución |
|
||||||
| ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
| ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `npm install` falla con `Cannot read properties of null (reading 'edgesOut')` | Bug de npm con un peer opcional de Vite que arrastra vitest 5. El `overrides` de `package.json` fija vitest; no lo quites |
|
| `npm install` falla con `Cannot read properties of null (reading 'edgesOut')` | Bug de npm con un peer opcional de Vite que arrastra vitest 5. El `overrides` de `package.json` fija vitest; no lo quites |
|
||||||
| `401` al instalar `@pi/ui` | Falta el token del Nexus en `~/.npmrc` |
|
| `401` al instalar `@pi/ui` | Falta el token del Nexus en `~/.npmrc` (o `NEXUS_TOKEN` al construir con Docker) |
|
||||||
|
| `failed to stat /root/.npmrc` al desplegar | Versión antigua del `docker-compose.yml`: ahora el token llega por `NEXUS_TOKEN` (ver [Despliegue](07-despliegue.md)) |
|
||||||
| `nx serve web` arranca en el puerto 3000 | Nx carga el `.env` raíz (`PORT=3000`) en todas las tareas; `apps/web/.env` lo fija en 4200 |
|
| `nx serve web` arranca en el puerto 3000 | Nx carga el `.env` raíz (`PORT=3000`) en todas las tareas; `apps/web/.env` lo fija en 4200 |
|
||||||
| La API no ve un evento nuevo al deserializar | La clase del evento no tiene `@AutowiredEvent` o no se importa |
|
| La API no ve un evento nuevo al deserializar | La clase del evento no tiene `@AutowiredEvent` o no se importa |
|
||||||
| Un componente de `@pi/ui` lanza `Unable to find icon` | Falta registrar ese icono en `libs/web/shared/ui/src/lib/icons.ts` |
|
| Un componente de `@pi/ui` lanza `Unable to find icon` | Falta registrar ese icono en `libs/web/shared/ui/src/lib/icons.ts` |
|
||||||
| Tablas antiguas (`projects`, `issues`, `outbox`) en tu Postgres local | Restos de una versión anterior sin event sourcing; `docker compose down -v` empieza de cero |
|
| Tablas antiguas (`projects`, `issues`, `outbox`) en tu Postgres local | Restos de una versión anterior sin event sourcing; `docker compose down -v` empieza de cero |
|
||||||
|
| El login con Pireon falla (`unauthorized_client`, `client_secret`…) | Configuración de los clientes OAuth: ver [Si el login falla](05-autenticacion.md#si-el-login-falla) |
|
||||||
|
|||||||
@@ -9,57 +9,113 @@ docker compose logs -f api
|
|||||||
docker compose down # parar (-v borra los datos)
|
docker compose down # parar (-v borra los datos)
|
||||||
```
|
```
|
||||||
|
|
||||||
| Servicio | Imagen | Puerto | Función |
|
| Servicio | Imagen | Puerto en local | Función |
|
||||||
| ---------- | --------------------------- | ------ | ----------------------------------------------------------------------- |
|
| ---------- | --------------------------- | --------------- | ----------------------------------------------------------------------- |
|
||||||
| `web` | `task-stream-web` (nginx) | 8080 | SPA y proxy de `/api` y `/graphql` (incluido el WebSocket) hacia la API |
|
| `web` | `task-stream-web` (nginx) | 8080 | SPA y proxy de `/api` y `/graphql` (incluido el WebSocket) hacia la API |
|
||||||
| `api` | `task-stream-api` (Node 22) | 3000 | API NestJS |
|
| `api` | `task-stream-api` (Node 22) | 3000 | API NestJS |
|
||||||
| `postgres` | postgres:17-alpine | 5432 | Event store y lookups |
|
| `postgres` | postgres:17-alpine | 5432 | Event store y lookups |
|
||||||
| `mongo` | mongo:8 | 27017 | Read model |
|
| `mongo` | mongo:8 | 27017 | Read model |
|
||||||
| `redis` | redis:7-alpine (AOF) | 6379 | Stream de eventos, pub/sub y caché de sesiones |
|
| `redis` | redis:7-alpine (AOF) | 6379 | Stream de eventos, pub/sub y caché de sesiones |
|
||||||
|
|
||||||
Todos tienen healthcheck; `web` espera a que `api` esté sana, y `api` a las tres bases de datos.
|
### 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. `GET /api/health` responde 503 si cae una base de datos; con
|
||||||
|
las tres en pie, `status: degraded` (200) avisa de eventos que no llegan a la lectura (`events.problems`). Los eventos
|
||||||
|
apartados por fallar una y otra vez se ven con `redis-cli XRANGE task-stream:events:dead - +`; arreglado el fallo, se
|
||||||
|
reconstruye la lectura (abajo) o se vuelven a publicar.
|
||||||
|
|
||||||
### Imágenes
|
### Imágenes
|
||||||
|
|
||||||
El `Dockerfile` tiene dos destinos que comparten la instalación de dependencias:
|
El `Dockerfile` tiene tres destinos que comparten la instalación de dependencias:
|
||||||
|
|
||||||
- **Instalación**: `npm ci` del workspace. `@pi/ui` viene del Nexus privado; el token se pasa como **build secret**
|
- **Instalación**: `npm ci` del workspace. `@pi/ui` viene del Nexus privado; el token llega como **build secret**
|
||||||
(`secrets.npmrc` → `~/.npmrc` en `docker-compose.yml`) y nunca queda en una capa.
|
desde la variable `NEXUS_TOKEN` (`secrets.nexus_token` en `docker-compose.yml`), se escribe en un `npmrc` temporal y
|
||||||
|
se borra en el mismo paso, así que nunca queda en una capa. Sin `NEXUS_TOKEN` la build falla en ese paso.
|
||||||
- **`api`**: `nx run api:prune` genera el bundle con un `package.json` y un lockfile podados a partir de
|
- **`api`**: `nx run api:prune` genera el bundle con un `package.json` y un lockfile podados a partir de
|
||||||
`apps/api/package.json`. La imagen final solo instala esas dependencias (sin Angular ni paquetes privados).
|
`apps/api/package.json`. La imagen final solo instala esas dependencias (sin Angular ni paquetes privados).
|
||||||
Si la API empieza a usar un paquete nuevo **en tiempo de ejecución**, hay que añadirlo a `apps/api/package.json`.
|
Si la API empieza a usar un paquete nuevo **en tiempo de ejecución**, hay que añadirlo a `apps/api/package.json`.
|
||||||
|
- **`mcp`**: el [servidor MCP](09-mcp.md), podado igual que la API (`nx run mcp:prune`), sobre Node 24. Su servicio
|
||||||
|
en `docker-compose.yml` va en el perfil `mcp`: solo arranca con `--profile mcp` (en Dokploy, `COMPOSE_PROFILES=mcp`).
|
||||||
- **`web`**: build de producción de Angular servido por nginx (`apps/web/nginx.conf`): fallback de rutas a
|
- **`web`**: build de producción de Angular servido por nginx (`apps/web/nginx.conf`): fallback de rutas a
|
||||||
`index.html`, caché larga para los bundles con hash y sin caché para `index.html`.
|
`index.html`, caché larga para los bundles con hash y sin caché para `index.html`.
|
||||||
|
|
||||||
|
### Construir
|
||||||
|
|
||||||
|
`NEXUS_TOKEN` tiene que estar en el entorno de quien construye, o en el `.env` junto al `docker-compose.yml`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Local: reutiliza el token de tu ~/.npmrc
|
||||||
|
export NEXUS_TOKEN=$(grep '^//nexus.pireon.dev/repository/npm-releases/:_authToken=' ~/.npmrc | cut -d= -f2-)
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
En **Dokploy** (despliegue con Docker Compose): pestaña _Environment_ del servicio → `NEXUS_TOKEN=<token>`, junto a
|
||||||
|
`PIREON_CLIENT_ID`, `PIREON_CLIENT_SECRET`, `PIREON_PASSWORD_CLIENT_ID`, `PIREON_PASSWORD_CLIENT_SECRET` y
|
||||||
|
`PIREON_WEBHOOK_SECRET`. Dokploy los escribe en el `.env` del proyecto, de
|
||||||
|
donde los lee Compose.
|
||||||
|
|
||||||
## 7.2 Variables de entorno de la API
|
## 7.2 Variables de entorno de la API
|
||||||
|
|
||||||
| Variable | Por defecto | Descripción |
|
| Variable | Por defecto | Descripción |
|
||||||
| ------------------------------------------- | -------------------------- | ------------------------------------------------------------------------------------- |
|
| ------------------------------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `PORT` | `3000` | Puerto HTTP |
|
| `PORT` | `3000` | Puerto HTTP |
|
||||||
| `NODE_ENV` | `development` | En `production` no se devuelven stacktraces |
|
| `NODE_ENV` | `development` | En `production` no se devuelven stacktraces |
|
||||||
| `POSTGRES_URL` | — (obligatoria) | Event store y lookups |
|
| `POSTGRES_URL` | — (obligatoria) | Event store y lookups |
|
||||||
| `POSTGRES_SYNCHRONIZE` | `false` | Crea las tablas desde las entidades. Cómodo en desarrollo; en producción, migraciones |
|
| `POSTGRES_SYNCHRONIZE` | `false` | Crea las tablas desde las entidades. Cómodo en desarrollo; en producción, migraciones |
|
||||||
| `MONGO_URL` | — (obligatoria) | Read model |
|
| `MONGO_URL` | — (obligatoria) | Read model |
|
||||||
| `REDIS_URL` | — (obligatoria) | Stream, pub/sub y caché |
|
| `REDIS_URL` | — (obligatoria) | Stream, pub/sub y caché |
|
||||||
| `EVENT_STREAM_KEY` | `task-stream:events` | Clave del Redis Stream |
|
| `EVENT_STREAM_KEY` | `task-stream:events` | Clave del Redis Stream |
|
||||||
| `EVENT_STREAM_MAX_LENGTH` | `1000000` | Longitud aproximada que se conserva en el stream |
|
| `EVENT_STREAM_MAX_LENGTH` | `1000000` | Longitud aproximada que se conserva en el stream |
|
||||||
| `EVENTS_BRIDGE_ENABLED` | `true` | Si esta instancia proyecta eventos hacia Mongo (ver escalado) |
|
| `EVENTS_BRIDGE_ENABLED` | `true` | Si esta instancia proyecta eventos hacia Mongo (ver escalado) |
|
||||||
| `PIREON_BASE_URL` | `https://oauth.pireon.dev` | Servidor de identidad |
|
| `PIREON_BASE_URL` | `https://oauth.pireon.dev` | Servidor de identidad |
|
||||||
| `PIREON_CLIENT_ID` / `PIREON_CLIENT_SECRET` | — | Cliente OAuth para el login desde la web |
|
| `PIREON_CLIENT_ID` / `PIREON_CLIENT_SECRET` | — | Cliente OAuth `authorization_code`: «Entrar con Pireon» y Swagger UI |
|
||||||
| `PIREON_SCOPES` | `users:read` | Scopes que se piden al iniciar sesión |
|
| `PIREON_PASSWORD_CLIENT_ID` / `_SECRET` | el anterior | Cliente OAuth `password`: login con email y contraseña |
|
||||||
| `PIREON_PASSWORD_GRANT` | `true` | Permite el login con email y contraseña |
|
| `PIREON_SCOPES` | `users:read` | Scopes que se piden al iniciar sesión |
|
||||||
| `PIREON_WEBHOOK_SECRET` | — | Activa y firma los webhooks de Pireon |
|
| `PIREON_PASSWORD_GRANT` | `true` | Permite el login con email y contraseña |
|
||||||
| `PIREON_TIMEOUT_MS` | `5000` | Timeout de las llamadas a Pireon |
|
| `PIREON_WEBHOOK_SECRET` | — | Activa y firma los webhooks de Pireon |
|
||||||
| `AUTH_SESSION_TTL_SECONDS` | `60` | Cuánto se confía en un token validado antes de volver a preguntar a Pireon |
|
| `PIREON_PASSWORD_WEBHOOK_SECRET` | — | El del webhook del cliente de contraseña, si es otro cliente |
|
||||||
| `GRAPHQL_PLAYGROUND` | `true` | GraphiQL e introspección |
|
| `PIREON_TIMEOUT_MS` | `5000` | Timeout de las llamadas a Pireon |
|
||||||
| `GRAPHQL_SCHEMA_FILE` | `apps/api/schema.gql` | Dónde escribir el SDL (vacío = no escribir; así está en Docker) |
|
| `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 |
|
||||||
|
| `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` | — | Notificaciones push (Web Push). Sin las dos no se envía nada. Se crean una vez con `npx web-push generate-vapid-keys`; cambiarlas anula las suscripciones |
|
||||||
|
| `VAPID_SUBJECT` | `mailto:no-reply@pireon.dev` | Contacto (`mailto:` o `https:`) que los servicios push pueden usar si algo va mal |
|
||||||
|
| `WEB_URL` | — | Dirección pública de la web, para los enlaces de los correos |
|
||||||
|
| `ATTACHMENTS_MAX_MB` | `25` | Tamaño máximo de un adjunto (nginx admite hasta 30 MB por petición) |
|
||||||
|
| `WEBHOOKS_ALLOW_PRIVATE` | `false` | Deja que los webhooks salientes llamen a direcciones de esta máquina o de su red privada (apagado, un webhook podría alcanzar servicios internos) |
|
||||||
|
| `REMINDERS_ENABLED` | `true` | Avisa cada hora de los tickets que vencen mañana, hoy o ya han vencido |
|
||||||
|
| `REMINDERS_TIMEZONE` | `Europe/Madrid` | Zona horaria en la que cambia el día para los recordatorios |
|
||||||
|
| `TRASH_DAYS` | `30` | Días que un ticket pasa en la papelera antes de borrarse del todo |
|
||||||
|
|
||||||
La web no tiene configuración de entorno: llama a la API en su mismo origen (`/api`, `/graphql`).
|
La web no tiene configuración de entorno: llama a la API en su mismo origen (`/api`, `/graphql`).
|
||||||
|
|
||||||
## 7.3 Escalado
|
## 7.3 Escalado
|
||||||
|
|
||||||
- **API**: es _stateless_ (sesiones en Redis, subscriptions por Redis pub/sub), así que admite varias réplicas detrás
|
- **API**: es _stateless_ (sesiones en Redis, subscriptions por Redis pub/sub), así que admite varias réplicas detrás
|
||||||
del balanceador. El WebSocket necesita que el balanceador permita `Upgrade`.
|
del balanceador. El WebSocket necesita que el balanceador permita `Upgrade`. La excepción son los **adjuntos**: se
|
||||||
|
guardan en disco (`ATTACHMENTS_DIR`), así que varias réplicas tienen que compartir ese volumen.
|
||||||
- **Relay**: cada instancia publica eventos al stream; `FOR UPDATE SKIP LOCKED` evita duplicados.
|
- **Relay**: cada instancia publica eventos al stream; `FOR UPDATE SKIP LOCKED` evita duplicados.
|
||||||
- **Proyecciones**: el `EventsBridge` procesa los eventos en orden. Lo más sencillo es dejarlo activo en **una sola**
|
- **Proyecciones**: el `EventsBridge` procesa los eventos en orden. Lo más sencillo es dejarlo activo en **una sola**
|
||||||
instancia (`EVENTS_BRIDGE_ENABLED=true`) y desactivarlo en las demás. Varias instancias en el mismo consumer group se
|
instancia (`EVENTS_BRIDGE_ENABLED=true`) y desactivarlo en las demás. Varias instancias en el mismo consumer group se
|
||||||
@@ -71,7 +127,8 @@ La web no tiene configuración de entorno: llama a la API en su mismo origen (`/
|
|||||||
- Registrar el cliente OAuth y las redirect URIs de producción en Pireon.
|
- Registrar el cliente OAuth y las redirect URIs de producción en Pireon.
|
||||||
- Servir por HTTPS (la web usa `wss://` automáticamente cuando la página va por HTTPS).
|
- Servir por HTTPS (la web usa `wss://` automáticamente cuando la página va por HTTPS).
|
||||||
- Revisar `GRAPHQL_PLAYGROUND` (introspección) según el entorno.
|
- Revisar `GRAPHQL_PLAYGROUND` (introspección) según el entorno.
|
||||||
- Copias de seguridad de PostgreSQL: es la fuente de verdad.
|
- Copias de seguridad de PostgreSQL: es la fuente de verdad. Y del volumen de adjuntos (`attachments-data`): los
|
||||||
|
eventos dicen qué archivos hay, pero los bytes solo están ahí.
|
||||||
|
|
||||||
## 7.5 Reconstruir el read model
|
## 7.5 Reconstruir el read model
|
||||||
|
|
||||||
@@ -82,3 +139,8 @@ MongoDB solo contiene proyecciones, así que se puede regenerar desde el event s
|
|||||||
3. Marca los eventos como no publicados: `UPDATE events SET published_at = NULL;`
|
3. Marca los eventos como no publicados: `UPDATE events SET published_at = NULL;`
|
||||||
4. Arranca la API: el relay vuelve a enviar todos los eventos, en orden, y las proyecciones reconstruyen las vistas.
|
4. Arranca la API: el relay vuelve a enviar todos los eventos, en orden, y las proyecciones reconstruyen las vistas.
|
||||||
Las notificaciones en tiempo real se reenvían también, sin efecto si no hay clientes conectados.
|
Las notificaciones en tiempo real se reenvían también, sin efecto si no hay clientes conectados.
|
||||||
|
|
||||||
|
Hazlo también una vez tras desplegar la configuración por proyecto y las estadísticas si quieres la historia completa
|
||||||
|
de los tickets anteriores: sin reconstruir, la línea temporal (`issue_timeline`) solo empieza con los cambios nuevos y
|
||||||
|
las estadísticas usan, para lo anterior, la creación y el estado actual de cada ticket. La categoría de los estados no
|
||||||
|
lo necesita: la API la rellena al arrancar en los tickets que no la tienen.
|
||||||
|
|||||||
@@ -71,3 +71,16 @@ presentación se prueba sin plantillas. **Coste**: una clase más por store, cas
|
|||||||
|
|
||||||
El `package.json` raíz incluye Angular y `@pi/ui` (del Nexus privado). La imagen de la API usa
|
El `package.json` raíz incluye Angular y `@pi/ui` (del Nexus privado). La imagen de la API usa
|
||||||
`apps/api/package.json`, así que ni lleva el frontend ni necesita el token del Nexus en ejecución.
|
`apps/api/package.json`, así que ni lleva el frontend ni necesita el token del Nexus en ejecución.
|
||||||
|
|
||||||
|
### Adjuntos en disco, notificaciones fuera del event store
|
||||||
|
|
||||||
|
Los **adjuntos** se registran como eventos del ticket, pero los bytes van a un puerto `AttachmentStorage` (disco,
|
||||||
|
`ATTACHMENTS_DIR`): un event store no es sitio para megabytes. Se escribe el archivo antes que el evento, y si el
|
||||||
|
evento falla se borra el archivo, así que nunca hay un adjunto apuntando a nada. **Coste**: el volumen hay que
|
||||||
|
copiarlo y compartirlo entre réplicas.
|
||||||
|
|
||||||
|
Las **notificaciones** sí salen de eventos, pero no son eventos: si un aviso está leído, o si alguien quiere copia por
|
||||||
|
correo, solo le importa a esa persona y no necesita historia. Se guardan en MongoDB por destinatario y se marcan como
|
||||||
|
leídas sin pasar por un agregado. Si se reconstruye el read model, se pueden regenerar desde los eventos (quedarían
|
||||||
|
como no leídas). **Alternativa descartada**: un agregado «bandeja» por persona, con eventos `NotificationRead`; mucha
|
||||||
|
ceremonia para un estado que nadie audita.
|
||||||
|
|||||||
174
docs/09-mcp.md
Normal file
174
docs/09-mcp.md
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
60 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), también copiando la configuración de otro (`template`) |
|
||||||
|
| | `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_portfolio` | Todos mis proyectos activos de un vistazo: abiertos y en riesgo, sprint, previsión de releases y épicas |
|
||||||
|
| | `get_my_week` | Mi semana: lo mío que vence en 7 días, fin de sprints y releases; lo terminado por mí y por mis equipos, racha; sprints en curso y qué podría coger |
|
||||||
|
| | `get_project_insights` | El panel: sprint, riesgo, antigüedad, cuellos de botella, releases, equipo, backlog, calidad, campos, actividad por día, recorrido de un ticket, propuestas y, con `since`, los cambios |
|
||||||
|
| | `get_project_activity` | Actividad reciente, o la de un día (`day`) |
|
||||||
|
| Tickets | `my_issues` | Mi trabajo en todos los proyectos (o de cualquiera, por categoría; o los que sigo); también por prioridad, solo bugs o vencidos; `reviewing`: los que me piden revisar |
|
||||||
|
| | `search_issues` | Buscar por texto y filtros (épica, sprint, fechas, campos…); el backlog: `noSprint` y orden `RANK` |
|
||||||
|
| | `get_issue` | Detalle: épica, lista de comprobación, relaciones, commits, seguidores, transiciones, decisiones y comentarios |
|
||||||
|
| | `create_issue` | Crear un ticket (también colgado de una épica) |
|
||||||
|
| | `update_issue` | Cambiar título, descripción, tipo, área, prioridad, etiquetas, fechas, campos personalizados… |
|
||||||
|
| | `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; con `tree`, una épica con todo lo que cuelga de ella |
|
||||||
|
| | `vote_issue` | Votar un ticket o quitar el voto |
|
||||||
|
| | `duplicate_issue` | Crear otro ticket con lo mismo (sin estado, persona ni comentarios) |
|
||||||
|
| | `react_to_comment` | Reaccionar a un comentario (👍 🎉…) o quitar la reacción |
|
||||||
|
| | `mark_decision` | Marcar un comentario como decisión del ticket, o quitarla |
|
||||||
|
| | `resolve_thread` | Resolver una conversación (con conclusión opcional, que queda como decisión) o reabrirla |
|
||||||
|
| | `remind_me` | Recordármelo: un aviso solo para mí sobre un ticket, a una fecha y hora, con nota (o quitarlo) |
|
||||||
|
| | `my_day` | Mi día: los tickets elegidos para hoy (y lo que quedó del último día); añadir o quitar uno |
|
||||||
|
| | `request_review` | Pedir a alguien del proyecto que revise un ticket |
|
||||||
|
| | `answer_review` | Responder una revisión: todo bien o cambios |
|
||||||
|
| | `vote_poll` | Votar en la encuesta de un comentario (o retirar el voto) |
|
||||||
|
| | `close_poll` | Cerrar una encuesta; la ganadora queda como decisión |
|
||||||
|
| | `search_decisions` | Qué se decidió: decisiones por su texto, de un proyecto o de todos los míos |
|
||||||
|
| | `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á; lo pendiente pasa a `moveTo` (otra release abierta) o la deja |
|
||||||
|
|
||||||
|
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.
|
||||||
137
docs/10-futuro.md
Normal file
137
docs/10-futuro.md
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
# 10. Lo que queda
|
||||||
|
|
||||||
|
Lo pendiente, ordenado de más a menos urgente: primero lo que hace falta para dar el producto por terminado en
|
||||||
|
producción, después los fallos y la deuda conocidos y al final las ideas. Cada punto dice qué es, por qué importa y,
|
||||||
|
cuando hay pistas, por dónde empezar.
|
||||||
|
|
||||||
|
## 10.1 Para dar el producto por terminado
|
||||||
|
|
||||||
|
| Qué | Por qué | Por dónde empezar |
|
||||||
|
| ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Comprobar en producción que el tiempo real ya no se pierde | Ya hay _ping_ cada 10 s, reconexión si no hay respuesta y recarga de cada pantalla al reconectar, pero no se ha probado allí | Dejar una pestaña abierta más que la vida del token y tras suspender el portátil, y mover tickets desde otra. Si aún falla, mirar el token de `connectionParams`, que solo se manda al conectar |
|
||||||
|
| Pruebas de navegador en la integración continua | Los fallos del tablero (no se podía arrastrar entre columnas) y de la ficha (no se refrescaba) solo se vieron usándolo | Ya existe la receta: API real con usuarios simulados (`IdentityProvider` sustituido), `nx serve web` y Playwright con la sesión en `localStorage` (`task-stream.session`). Convertirla en `apps/web-e2e` |
|
||||||
|
| Copias de seguridad de Postgres y Mongo | Postgres guarda el event store, la fuente de verdad; Mongo se puede reconstruir desde él, Postgres no | Copias programadas en Dokploy y **una restauración de prueba** |
|
||||||
|
| Limpiar los datos de prueba de producción | DEMO, TEST y los tickets de las pruebas del MCP | Borrar los proyectos desde Configuración (borrado definitivo con su clave) |
|
||||||
|
| Registrar el webhook del cliente de contraseña en Pireon | Sin él, revocar un token de quien entra con contraseña no se nota hasta que caduca la caché | Solo si `PIREON_PASSWORD_CLIENT_ID` tiene valor: webhook en ese cliente y su secret en `PIREON_PASSWORD_WEBHOOK_SECRET` (ver [Autenticación](05-autenticacion.md)) |
|
||||||
|
| Una versión 1.0 | Un punto fijo al que volver | Desplegar todo, etiquetar `v1.0.0` y redactar la lista de cambios (las notas de versión de TaskStream dan el borrador) |
|
||||||
|
| Revisión de seguridad | Hay rutas sin sesión (proyectos públicos, escaparate, propuestas, panel para clientes) y un MCP abierto a Internet | Permisos por rol, qué ve un anónimo, límites de peticiones (solo las propuestas tienen), CORS, cabeceras |
|
||||||
|
| Registro de errores en producción | Hoy un fallo solo se conoce si alguien avisa | Sentry (o similar) en la API, la web y el MCP |
|
||||||
|
|
||||||
|
## 10.2 Fallos y deuda conocidos
|
||||||
|
|
||||||
|
- **Error al crear subtareas en producción**: lo reportó el usuario y no se reprodujo en local; con el arreglo de la
|
||||||
|
ficha la lista ya se refresca, pero falta el mensaje exacto si vuelve a salir.
|
||||||
|
- **Imágenes pegadas**: ya no salen en la lista de adjuntos, pero si se borran del texto el adjunto sigue guardado.
|
||||||
|
- **Seguimiento**: los tickets que ya existían no se apuntan a quien los creó o los tiene asignados (solo los nuevos).
|
||||||
|
- **El MCP no hace el flujo OAuth del protocolo**: el cliente tiene que llevar un token de Pireon en la cabecera y
|
||||||
|
renovarlo a mano cuando caduca.
|
||||||
|
|
||||||
|
## 10.3 Siguientes funcionalidades
|
||||||
|
|
||||||
|
### En curso
|
||||||
|
|
||||||
|
Nada: la tanda está hecha (ver 10.5).
|
||||||
|
|
||||||
|
### Aceptadas, para después de esta tanda
|
||||||
|
|
||||||
|
- **Sincronizar con issues de Gitea o GitHub**: que un issue del repositorio cree o actualice un ticket, y al revés.
|
||||||
|
- **Búsqueda con lenguaje de consulta**: `estado:abierto asignado:yo navegador:Safari prioridad>=alta`, guardable como
|
||||||
|
vista.
|
||||||
|
- **Entornos de despliegue**: en qué entorno está cada ticket (pruebas, producción), desde los avisos de la
|
||||||
|
integración continua.
|
||||||
|
- **Página pública de novedades**: las notas de versión publicadas en una página por proyecto, con RSS.
|
||||||
|
|
||||||
|
### Otras ideas apuntadas, sin decidir
|
||||||
|
|
||||||
|
- **Resumen diario por correo**: lo asignado, lo que vence hoy y lo que ha cambiado en lo que sigues, en un solo correo.
|
||||||
|
- **Tickets recurrentes**: «revisar copias cada lunes», creados solos con la plantilla del tipo.
|
||||||
|
- **Crear tickets por correo**: una dirección por proyecto; lo que llega es un ticket (con adjuntos) y las respuestas,
|
||||||
|
comentarios. Necesita un buzón de entrada.
|
||||||
|
- **Posponer un ticket**: «recuérdamelo el jueves».
|
||||||
|
- **Reunión diaria**: qué cerró cada uno ayer, qué tiene hoy y qué le bloquea, sacado del historial.
|
||||||
|
- **Requisitos al cambiar de estado**: campos obligatorios para entrar en un estado (estimación para «En curso»…).
|
||||||
|
- **Plazos por prioridad**: «lo crítico, en 24 h», con aviso antes de vencer.
|
||||||
|
- **Matriz impacto/esfuerzo** para ordenar el backlog.
|
||||||
|
- **Planning poker** y **retrospectiva del sprint** (las acciones se convierten en tickets).
|
||||||
|
- **Avisar a quien propuso** cuando su propuesta se hace o se descarta.
|
||||||
|
|
||||||
|
## 10.4 Descartado por ahora
|
||||||
|
|
||||||
|
Mucho trabajo para lo que aportan hoy; se pueden recuperar si alguien los necesita:
|
||||||
|
|
||||||
|
- **Registro de horas** dedicadas a cada ticket.
|
||||||
|
- **Objetivos u OKR** por encima de las épicas.
|
||||||
|
- **App móvil**: la web ya se adapta a pantallas estrechas.
|
||||||
|
- **Asistente en la web** (resumir tickets, sugerir tipo, prioridad y área, redactar notas de versión): el MCP ya deja
|
||||||
|
hacerlo desde un cliente de IA.
|
||||||
|
- **Avisos a Discord, Slack o Telegram** con mensajes legibles: los webhooks salientes siguen ahí para quien los
|
||||||
|
necesite.
|
||||||
|
- **Conector de claude.ai** (OAuth del protocolo en el MCP y registro dinámico de clientes en Pireon).
|
||||||
|
|
||||||
|
## 10.5 Hecho desde la primera versión de esta lista
|
||||||
|
|
||||||
|
- Tiempo real: _ping_ y reconexión del WebSocket, y recarga de lo que se ve al reconectar.
|
||||||
|
- Arrastrar a una columna prohibida devuelve la tarjeta a su sitio.
|
||||||
|
- Reacciones y «Bloqueado» en componentes propios (los estilos de la ficha vuelven a caber en 8 kB).
|
||||||
|
- Límite de propuestas en Redis, válido con varias instancias.
|
||||||
|
- Aviso `PROPOSED` a propietarios y admins cuando llega una propuesta.
|
||||||
|
- `voted` también por REST.
|
||||||
|
- Borrar un proyecto borra su integración con Git.
|
||||||
|
- Sin avisos de lint.
|
||||||
|
- La web pública (landing-web) enseña las propuestas más votadas, el enlace al formulario y los votos de la hoja de
|
||||||
|
ruta.
|
||||||
|
- Un proyecto de la landing creado sin tablero puede abrirlo después: «Crear en TaskStream» en su ficha o
|
||||||
|
`taskstream: true` en un `PATCH`.
|
||||||
|
- Paquete inicial de la web de 2,13 MB a 1,88 MB con `@pi/ui` 0.0.4, que ya no registra Chart.js al importarse ni
|
||||||
|
depende de `lodash`.
|
||||||
|
- Descripciones y comentarios con editor visual (guardado en Markdown), con menciones y capturas pegadas.
|
||||||
|
- Ids: `uuid` en todas las tablas de Postgres (el orden de los eventos en `seq`) y, en Mongo, `id` con el uuid del
|
||||||
|
dominio más el `_id` `ObjectId` propio.
|
||||||
|
- Enlazar tickets y colgar de un padre eligiendo en un selector con búsqueda; casillas de `@pi/ui` en toda la web.
|
||||||
|
- Mover tickets entre releases: «Release…» en la edición en bloque, filtro por release en la lista (también desde cada
|
||||||
|
release, «Ver en la lista»), y al archivar una release, elegir adónde va lo pendiente.
|
||||||
|
- Calendario suscribible (iCalendar) desde «Mi trabajo»: tickets con fecha, sprints y releases, con URL secreta
|
||||||
|
renovable.
|
||||||
|
- Notificaciones push (Web Push) en cada dispositivo, con la web instalable.
|
||||||
|
- Campos personalizados por proyecto (texto, número, fecha o lista de opciones): se definen en Configuración, se
|
||||||
|
editan en la ficha, se ven bajo el título en la lista y se filtran por los de opciones (también por GraphQL, REST y
|
||||||
|
MCP).
|
||||||
|
- Dependencias en la hoja de ruta: flechas entre las épicas que se bloquean o dependen unas de otras, y aviso cuando
|
||||||
|
una empieza antes de que acabe la que la bloquea.
|
||||||
|
- Plantillas de proyecto: al crear uno, «Empezar con la configuración de» otro que administras copia sus estados,
|
||||||
|
transiciones, tipos, áreas, campos personalizados y automatizaciones. (El burndown y la velocidad de sprint ya
|
||||||
|
existían, en Sprints.)
|
||||||
|
- Informe para imprimir (o guardar en PDF) de un sprint o una release, desde Sprints y Releases.
|
||||||
|
- Panel del proyecto, primera parte: sprint en curso (¿llega a tiempo?), tickets en riesgo (vencidos, que vencen esta
|
||||||
|
semana, bloqueados y atascados), antigüedad de lo que está en curso y cuellos de botella. También por REST, GraphQL
|
||||||
|
y MCP (`get_project_insights`).
|
||||||
|
- Panel del proyecto, segunda parte: lo que ha cambiado desde tu última visita, previsión de las próximas releases al
|
||||||
|
ritmo real (Monte Carlo), carga del equipo en puntos y salud del backlog con enlaces a la lista filtrada.
|
||||||
|
- Panel del proyecto, tercera parte: calidad (bugs abiertos por prioridad, nuevos y cerrados por semana, QA por área,
|
||||||
|
bugs tras cada release) y abiertos por opción de un campo de lista.
|
||||||
|
- Escrituras a la vez en un ticket: la API repite sola la orden que choca con otra (por ejemplo, con una saga), en
|
||||||
|
lugar de devolver un 409.
|
||||||
|
- Vistas guardadas en «Mi trabajo»: filtros propios entre proyectos (proyectos, asignados a mí, que sigo, estados,
|
||||||
|
prioridades, solo bugs, vencidos, texto), en una barra encima de «Mi trabajo».
|
||||||
|
- Definición de «Hecho» por proyecto: con ella activada, un ticket no se cierra con la checklist a medias (el
|
||||||
|
propietario y los admins sí pueden).
|
||||||
|
- Archivar un proyecto: queda en solo lectura (se ve todo, no cambia nada) hasta que su propietario lo desarchiva.
|
||||||
|
- Equipos dentro del proyecto: grupos de miembros en Configuración; los tickets se asignan a uno, la lista filtra por
|
||||||
|
equipo y «@equipo» avisa a todos sus miembros.
|
||||||
|
- Presencia: en cada ticket se ve quién más lo está viendo o editando en ese momento.
|
||||||
|
- Rol «invitado»: para clientes o gente de fuera; en un proyecto privado solo ve los tickets en los que participa,
|
||||||
|
abre tickets y comenta en los suyos.
|
||||||
|
- Vista de cartera (`/portfolio`): todos tus proyectos activos de un vistazo, con su riesgo, sprint, releases y épicas.
|
||||||
|
- Una portada con vida: saludo y resumen del día, la semana que viene, lo último en tus proyectos, quién está ahora,
|
||||||
|
la semana en cifras (tuya y del equipo, con racha), sprints en curso, «Retomar», tarjetas de proyecto con actividad,
|
||||||
|
qué coger cuando no tienes nada y primeros pasos para quien empieza. También por MCP (`get_my_week`).
|
||||||
|
- Panel del proyecto, cuarta parte: actividad de 12 semanas en un mapa de calor, el recorrido de un ticket (dónde se
|
||||||
|
va el tiempo de creado a hecho, espera incluida, y la parte que es trabajo) y el embudo de propuestas del público
|
||||||
|
con las más votadas sin respuesta.
|
||||||
|
- Modo pantalla del panel: a pantalla completa para una tele del equipo, un bloque cada vez que cambia solo, en
|
||||||
|
directo y con la hora.
|
||||||
|
- Panel para clientes: un enlace de solo lectura, sin cuenta, con los bloques que elijan el propietario y los admins
|
||||||
|
(flujo, sprint, releases, épicas, actividad); se renueva o se retira desde Configuración.
|
||||||
|
- De escritura a lectura: si un evento no se puede proyectar, se reintenta en su sitio (el siguiente espera) hasta
|
||||||
|
que Mongo vuelve, sin reiniciar ni perder cambios; los que fallan por sí mismos se apartan a
|
||||||
|
`task-stream:events:dead`, y `GET /api/health` avisa de eventos atascados o apartados.
|
||||||
@@ -3,16 +3,18 @@
|
|||||||
TaskStream es una alternativa a Jira para equipos de desarrollo: proyectos, tickets (bugs, features, cambios de UI,
|
TaskStream es una alternativa a Jira para equipos de desarrollo: proyectos, tickets (bugs, features, cambios de UI,
|
||||||
tareas, QA), releases, comentarios y relaciones entre tickets, todo en tiempo real.
|
tareas, QA), releases, comentarios y relaciones entre tickets, todo en tiempo real.
|
||||||
|
|
||||||
| Documento | Para qué |
|
| Documento | Para qué |
|
||||||
| ----------------------------------------- | ------------------------------------------------------------------------ |
|
| ----------------------------------------- | ----------------------------------------------------------------------------------- |
|
||||||
| [1. Visión general](01-vision-general.md) | Qué hace el producto, stack y estructura del monorepo |
|
| [1. Visión general](01-vision-general.md) | Qué hace el producto, stack y estructura del monorepo |
|
||||||
| [2. Arquitectura](02-arquitectura.md) | Hexagonal, CQRS, event sourcing, flujo escritura → lectura y tiempo real |
|
| [2. Arquitectura](02-arquitectura.md) | Hexagonal, CQRS, event sourcing, flujo escritura → lectura y tiempo real |
|
||||||
| [3. API](03-api.md) | Dominio (agregados, workflow, eventos) y la API GraphQL |
|
| [3. API](03-api.md) | Dominio (agregados, workflow, eventos) y la API GraphQL |
|
||||||
| [4. Cliente web](04-cliente-web.md) | Angular, NgRx Signals con eventos, GraphQL y `@pi/ui` |
|
| [4. Cliente web](04-cliente-web.md) | Angular, NgRx Signals con eventos, GraphQL y `@pi/ui` |
|
||||||
| [5. Autenticación](05-autenticacion.md) | Pireon: tokens, login en el navegador, sesiones y webhooks |
|
| [5. Autenticación](05-autenticacion.md) | Pireon: tokens, login en el navegador, sesiones y webhooks |
|
||||||
| [6. Desarrollo](06-desarrollo.md) | Puesta en marcha, comandos, tests y cómo añadir una funcionalidad |
|
| [6. Desarrollo](06-desarrollo.md) | Puesta en marcha, comandos, tests y cómo añadir una funcionalidad |
|
||||||
| [7. Despliegue](07-despliegue.md) | Docker, variables de entorno y escalado |
|
| [7. Despliegue](07-despliegue.md) | Docker, variables de entorno y escalado |
|
||||||
| [8. Decisiones](08-decisiones.md) | Por qué está hecho así (y qué alternativas se descartaron) |
|
| [8. Decisiones](08-decisiones.md) | Por qué está hecho así (y qué alternativas se descartaron) |
|
||||||
|
| [9. Servidor MCP](09-mcp.md) | Usar TaskStream desde asistentes de IA (Claude…): herramientas y conexión |
|
||||||
|
| [10. Lo que queda](10-futuro.md) | Pendiente para cerrar, fallos conocidos, siguientes funcionalidades y lo descartado |
|
||||||
|
|
||||||
Cada librería de `libs/` tiene además su propio `README.md` con su responsabilidad y su contenido.
|
Cada librería de `libs/` tiene además su propio `README.md` con su responsabilidad y su contenido.
|
||||||
|
|
||||||
@@ -22,4 +24,5 @@ Cada librería de `libs/` tiene además su propio `README.md` con su responsabil
|
|||||||
docker compose up -d --build # web en http://localhost:8080
|
docker compose up -d --build # web en http://localhost:8080
|
||||||
```
|
```
|
||||||
|
|
||||||
Para entrar hace falta un cliente OAuth de Pireon en `.env` (ver [Autenticación](05-autenticacion.md#registrar-taskstream-como-cliente-en-pireon)).
|
Para entrar hacen falta dos clientes OAuth de Pireon en `.env` (`authorization_code` y `password`, ver
|
||||||
|
[Autenticación](05-autenticacion.md#registrar-taskstream-como-cliente-en-pireon)).
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -9,7 +9,8 @@ Casos de uso de identidad.
|
|||||||
|
|
||||||
- `IdentityService`: punto de entrada para los presenters.
|
- `IdentityService`: punto de entrada para los presenters.
|
||||||
- Queries: `ResolveSessionQuery` (token → usuario, con caché), `FindUserQuery`, `FindUsersByIdsQuery`, `SearchUsersQuery`, `GetAuthorizationConfigQuery`.
|
- Queries: `ResolveSessionQuery` (token → usuario, con caché), `FindUserQuery`, `FindUsersByIdsQuery`, `SearchUsersQuery`, `GetAuthorizationConfigQuery`.
|
||||||
- Commands: `IssueTokenCommand` (login / refresh vía Pireon), `HandleIdentityWebhookCommand`.
|
- Commands: `IssueTokenCommand` (login / refresh vía Pireon), `HandleIdentityWebhookCommand` (al desactivar a un
|
||||||
|
usuario, guarda `UserDeactivatedEvent` en el event store para que el tracker reaccione).
|
||||||
- Puertos: `IdentityProvider`, `SessionCache`, `UserRegistry`.
|
- Puertos: `IdentityProvider`, `SessionCache`, `UserRegistry`.
|
||||||
|
|
||||||
Ver [Autenticación](../../../../docs/05-autenticacion.md).
|
Ver [Autenticación](../../../../docs/05-autenticacion.md).
|
||||||
|
|||||||
@@ -3,10 +3,7 @@
|
|||||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||||
"sourceRoot": "libs/api/identity/application/src",
|
"sourceRoot": "libs/api/identity/application/src",
|
||||||
"projectType": "library",
|
"projectType": "library",
|
||||||
"tags": [
|
"tags": ["scope:api-identity", "type:application"],
|
||||||
"scope:api-identity",
|
|
||||||
"type:application"
|
|
||||||
],
|
|
||||||
"// targets": "to see all targets run: nx show project api-identity-application --web",
|
"// targets": "to see all targets run: nx show project api-identity-application --web",
|
||||||
"targets": {}
|
"targets": {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
import { Command, CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
import { Command, CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||||
|
import { EventRecorder } from '@task-stream/api/shared/application';
|
||||||
|
import { UserDeactivatedEvent } from '@task-stream/api/identity/domain';
|
||||||
import { SessionCache } from '../ports/session-cache.port';
|
import { SessionCache } from '../ports/session-cache.port';
|
||||||
import { UserRegistry } from '../ports/user-registry.port';
|
import { UserRegistry } from '../ports/user-registry.port';
|
||||||
|
|
||||||
@@ -22,6 +24,7 @@ export class HandleIdentityWebhookHandler
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly cache: SessionCache,
|
private readonly cache: SessionCache,
|
||||||
private readonly registry: UserRegistry,
|
private readonly registry: UserRegistry,
|
||||||
|
private readonly events: EventRecorder,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute({ event, data }: HandleIdentityWebhookCommand): Promise<void> {
|
async execute({ event, data }: HandleIdentityWebhookCommand): Promise<void> {
|
||||||
@@ -50,8 +53,14 @@ export class HandleIdentityWebhookHandler
|
|||||||
|
|
||||||
private async deactivate(userId: string): Promise<void> {
|
private async deactivate(userId: string): Promise<void> {
|
||||||
const user = await this.registry.findById(userId);
|
const user = await this.registry.findById(userId);
|
||||||
if (!user) return;
|
if (!user?.isActive) return;
|
||||||
user.deactivate();
|
user.deactivate();
|
||||||
await this.registry.upsert(user);
|
await this.registry.upsert(user);
|
||||||
|
// Stored so other contexts react (the tracker unassigns their open tickets).
|
||||||
|
for (const event of user.getUncommittedEvents()) {
|
||||||
|
if (event instanceof UserDeactivatedEvent)
|
||||||
|
await this.events.record(`identity-user-${userId}`, event);
|
||||||
|
}
|
||||||
|
user.uncommit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import { CommandRunner } from '@task-stream/api/shared/application';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
import { QueryBus } from '@nestjs/cqrs';
|
||||||
import {
|
import {
|
||||||
AuthorizationConfig,
|
AuthorizationConfig,
|
||||||
TokenGrant,
|
TokenGrant,
|
||||||
@@ -20,7 +21,7 @@ import {
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class IdentityService {
|
export class IdentityService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly commandBus: CommandBus,
|
private readonly commands: CommandRunner,
|
||||||
private readonly queryBus: QueryBus,
|
private readonly queryBus: QueryBus,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -44,14 +45,12 @@ export class IdentityService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleWebhook(event: string, data: Record<string, unknown>): Promise<void> {
|
handleWebhook(event: string, data: Record<string, unknown>): Promise<void> {
|
||||||
return this.commandBus.execute(
|
return this.commands.execute(new HandleIdentityWebhookCommand(event, data));
|
||||||
new HandleIdentityWebhookCommand(event, data),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sign in / refresh through the identity server (the browser cannot call it: no CORS). */
|
/** Sign in / refresh through the identity server (the browser cannot call it: no CORS). */
|
||||||
issueToken(grant: TokenGrant): Promise<TokenSet> {
|
issueToken(grant: TokenGrant): Promise<TokenSet> {
|
||||||
return this.commandBus.execute(new IssueTokenCommand(grant));
|
return this.commands.execute(new IssueTokenCommand(grant));
|
||||||
}
|
}
|
||||||
|
|
||||||
authorizationConfig(): Promise<AuthorizationConfig | null> {
|
authorizationConfig(): Promise<AuthorizationConfig | null> {
|
||||||
|
|||||||
@@ -3,10 +3,7 @@
|
|||||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||||
"sourceRoot": "libs/api/identity/domain/src",
|
"sourceRoot": "libs/api/identity/domain/src",
|
||||||
"projectType": "library",
|
"projectType": "library",
|
||||||
"tags": [
|
"tags": ["scope:api-identity", "type:domain"],
|
||||||
"scope:api-identity",
|
|
||||||
"type:domain"
|
|
||||||
],
|
|
||||||
"// targets": "to see all targets run: nx show project api-identity-domain --web",
|
"// targets": "to see all targets run: nx show project api-identity-domain --web",
|
||||||
"targets": {}
|
"targets": {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,11 +17,15 @@ export interface TokenSet {
|
|||||||
tokenType: string;
|
tokenType: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What a browser needs to start the Authorization Code + PKCE flow. */
|
/** How a browser can sign in. */
|
||||||
export interface AuthorizationConfig {
|
export interface AuthorizationConfig {
|
||||||
authorizeUrl: string;
|
authorizeUrl: string;
|
||||||
clientId: string;
|
/**
|
||||||
|
* Client for Authorization Code + PKCE ("Sign in with Pireon"). Null when
|
||||||
|
* only email + password is configured: Pireon clients allow a single grant.
|
||||||
|
*/
|
||||||
|
clientId: string | null;
|
||||||
scopes: string[];
|
scopes: string[];
|
||||||
/** Password grant can be disabled on the identity server. */
|
/** Whether email + password sign-in is available. */
|
||||||
passwordGrant: boolean;
|
passwordGrant: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,7 @@
|
|||||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||||
"sourceRoot": "libs/api/identity/infrastructure/src",
|
"sourceRoot": "libs/api/identity/infrastructure/src",
|
||||||
"projectType": "library",
|
"projectType": "library",
|
||||||
"tags": [
|
"tags": ["scope:api-identity", "type:infrastructure"],
|
||||||
"scope:api-identity",
|
|
||||||
"type:infrastructure"
|
|
||||||
],
|
|
||||||
"// targets": "to see all targets run: nx show project api-identity-infrastructure --web",
|
"// targets": "to see all targets run: nx show project api-identity-infrastructure --web",
|
||||||
"targets": {}
|
"targets": {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ import { RedisSessionCache } from './redis/redis-session.cache';
|
|||||||
requestTimeoutMs: Number(config.get('PIREON_TIMEOUT_MS', 5000)),
|
requestTimeoutMs: Number(config.get('PIREON_TIMEOUT_MS', 5000)),
|
||||||
clientId: config.get<string>('PIREON_CLIENT_ID') || undefined,
|
clientId: config.get<string>('PIREON_CLIENT_ID') || undefined,
|
||||||
clientSecret: config.get<string>('PIREON_CLIENT_SECRET') || undefined,
|
clientSecret: config.get<string>('PIREON_CLIENT_SECRET') || undefined,
|
||||||
|
passwordClientId:
|
||||||
|
config.get<string>('PIREON_PASSWORD_CLIENT_ID') || undefined,
|
||||||
|
passwordClientSecret:
|
||||||
|
config.get<string>('PIREON_PASSWORD_CLIENT_SECRET') || undefined,
|
||||||
scopes: String(config.get('PIREON_SCOPES', 'users:read'))
|
scopes: String(config.get('PIREON_SCOPES', 'users:read'))
|
||||||
.split(/\s+/)
|
.split(/\s+/)
|
||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
|
|||||||
@@ -16,20 +16,20 @@ export class MongoUserRegistry extends UserRegistry {
|
|||||||
|
|
||||||
async upsert(user: User): Promise<void> {
|
async upsert(user: User): Promise<void> {
|
||||||
await this.users.updateOne(
|
await this.users.updateOne(
|
||||||
{ _id: user.id },
|
{ id: user.id },
|
||||||
{ $set: { name: user.name, email: user.email, isActive: user.isActive } },
|
{ $set: { name: user.name, email: user.email, isActive: user.isActive } },
|
||||||
{ upsert: true },
|
{ upsert: true },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async findById(id: string): Promise<User | null> {
|
async findById(id: string): Promise<User | null> {
|
||||||
const doc = await this.users.findById(id).lean();
|
const doc = await this.users.findOne({ id }).lean();
|
||||||
return doc ? this.toUser(doc) : null;
|
return doc ? this.toUser(doc) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByIds(ids: string[]): Promise<User[]> {
|
async findByIds(ids: string[]): Promise<User[]> {
|
||||||
if (ids.length === 0) return [];
|
if (ids.length === 0) return [];
|
||||||
const docs = await this.users.find({ _id: { $in: ids } }).lean();
|
const docs = await this.users.find({ id: { $in: ids } }).lean();
|
||||||
return docs.map((d) => this.toUser(d));
|
return docs.map((d) => this.toUser(d));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ export class MongoUserRegistry extends UserRegistry {
|
|||||||
|
|
||||||
private toUser(doc: UserDocument): User {
|
private toUser(doc: UserDocument): User {
|
||||||
return this.factory.restore({
|
return this.factory.restore({
|
||||||
id: doc._id,
|
id: doc.id,
|
||||||
name: doc.name,
|
name: doc.name,
|
||||||
email: doc.email,
|
email: doc.email,
|
||||||
isActive: doc.isActive,
|
isActive: doc.isActive,
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
|||||||
|
|
||||||
@Schema({ collection: 'users', timestamps: true, versionKey: false })
|
@Schema({ collection: 'users', timestamps: true, versionKey: false })
|
||||||
export class UserDocument {
|
export class UserDocument {
|
||||||
@Prop({ type: String, required: true })
|
/** The user's id at Pireon; `_id` is Mongo's own ObjectId. */
|
||||||
_id!: string;
|
@Prop({ type: String, required: true, unique: true })
|
||||||
|
id!: string;
|
||||||
|
|
||||||
@Prop({ required: true })
|
@Prop({ required: true })
|
||||||
name!: string;
|
name!: string;
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import {
|
||||||
|
ForbiddenException,
|
||||||
|
ServiceUnavailableException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { InvalidCredentialsError } from '@task-stream/api/identity/application';
|
||||||
|
import { UserFactory } from '@task-stream/api/identity/domain';
|
||||||
|
import { PireonConfig } from './pireon.config';
|
||||||
|
import { PireonIdentityProvider } from './pireon-identity.provider';
|
||||||
|
|
||||||
|
const TOKEN = {
|
||||||
|
access_token: 'access',
|
||||||
|
refresh_token: 'refresh',
|
||||||
|
expires_in: 3600,
|
||||||
|
token_type: 'Bearer',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Pireon clients allow a single grant type: one client for code, one for password. */
|
||||||
|
function setup(overrides: Partial<PireonConfig> = {}) {
|
||||||
|
const config: PireonConfig = {
|
||||||
|
baseUrl: 'https://oauth.pireon.dev',
|
||||||
|
sessionTtlSeconds: 60,
|
||||||
|
requestTimeoutMs: 1000,
|
||||||
|
clientId: 'code-client',
|
||||||
|
clientSecret: 'code-secret',
|
||||||
|
passwordClientId: 'password-client',
|
||||||
|
passwordClientSecret: 'password-secret',
|
||||||
|
scopes: ['users:read'],
|
||||||
|
passwordGrant: true,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
const calls: URLSearchParams[] = [];
|
||||||
|
/** Answers 401 to the clients in `rejects`, a token otherwise. */
|
||||||
|
const respond = (rejects: string[] = []) =>
|
||||||
|
jest.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
|
||||||
|
const body = init?.body as URLSearchParams;
|
||||||
|
calls.push(body);
|
||||||
|
return rejects.includes(body.get('client_id') ?? '')
|
||||||
|
? new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'invalid_request',
|
||||||
|
error_description: 'The refresh token is invalid.',
|
||||||
|
}),
|
||||||
|
{ status: 401 },
|
||||||
|
)
|
||||||
|
: new Response(JSON.stringify(TOKEN), { status: 200 });
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
provider: new PireonIdentityProvider(config, new UserFactory()),
|
||||||
|
calls,
|
||||||
|
respond,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const password = {
|
||||||
|
type: 'password',
|
||||||
|
username: 'ana@pireon.dev',
|
||||||
|
password: 'x',
|
||||||
|
} as const;
|
||||||
|
const code = {
|
||||||
|
type: 'authorization_code',
|
||||||
|
code: 'c',
|
||||||
|
codeVerifier: 'v',
|
||||||
|
redirectUri: 'https://taskstream.pireon.dev/auth/callback',
|
||||||
|
} as const;
|
||||||
|
const refresh = { type: 'refresh_token', refreshToken: 'r' } as const;
|
||||||
|
|
||||||
|
describe('PireonIdentityProvider tokens', () => {
|
||||||
|
afterEach(() => jest.restoreAllMocks());
|
||||||
|
|
||||||
|
it('uses the password client for email + password', async () => {
|
||||||
|
const { provider, calls, respond } = setup();
|
||||||
|
respond();
|
||||||
|
await provider.issueToken(password);
|
||||||
|
expect(calls[0].get('client_id')).toBe('password-client');
|
||||||
|
expect(calls[0].get('client_secret')).toBe('password-secret');
|
||||||
|
expect(calls[0].get('grant_type')).toBe('password');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the authorization_code client for "Sign in with Pireon"', async () => {
|
||||||
|
const { provider, calls, respond } = setup();
|
||||||
|
respond();
|
||||||
|
await provider.issueToken(code);
|
||||||
|
expect(calls[0].get('client_id')).toBe('code-client');
|
||||||
|
expect(calls[0].get('code_verifier')).toBe('v');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the main client when there is no password client', async () => {
|
||||||
|
const { provider, calls, respond } = setup({
|
||||||
|
passwordClientId: undefined,
|
||||||
|
passwordClientSecret: undefined,
|
||||||
|
});
|
||||||
|
respond();
|
||||||
|
await provider.issueToken(password);
|
||||||
|
expect(calls[0].get('client_id')).toBe('code-client');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshes with whichever client issued the token', async () => {
|
||||||
|
const { provider, calls, respond } = setup();
|
||||||
|
respond(['password-client']);
|
||||||
|
await expect(provider.issueToken(refresh)).resolves.toMatchObject({
|
||||||
|
accessToken: 'access',
|
||||||
|
});
|
||||||
|
expect(calls.map((c) => c.get('client_id'))).toEqual([
|
||||||
|
'password-client',
|
||||||
|
'code-client',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps Pireon's hint, which says what was wrong", async () => {
|
||||||
|
const { provider } = setup();
|
||||||
|
jest.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'invalid_request',
|
||||||
|
error_description:
|
||||||
|
'The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed.',
|
||||||
|
hint: 'Invalid redirect URI',
|
||||||
|
}),
|
||||||
|
{ status: 400 },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await expect(provider.issueToken(code)).rejects.toThrow(
|
||||||
|
/otherwise malformed\. \(Invalid redirect URI\)/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects the refresh when no client accepts it', async () => {
|
||||||
|
const { provider, respond } = setup();
|
||||||
|
respond(['password-client', 'code-client']);
|
||||||
|
await expect(provider.issueToken(refresh)).rejects.toBeInstanceOf(
|
||||||
|
InvalidCredentialsError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses email + password when PIREON_PASSWORD_GRANT=false', async () => {
|
||||||
|
const { provider, calls, respond } = setup({ passwordGrant: false });
|
||||||
|
respond();
|
||||||
|
await expect(provider.issueToken(password)).rejects.toBeInstanceOf(
|
||||||
|
ForbiddenException,
|
||||||
|
);
|
||||||
|
expect(calls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says what is missing when a flow is not configured', async () => {
|
||||||
|
const { provider } = setup({
|
||||||
|
clientId: undefined,
|
||||||
|
clientSecret: undefined,
|
||||||
|
});
|
||||||
|
await expect(provider.issueToken(code)).rejects.toBeInstanceOf(
|
||||||
|
ServiceUnavailableException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tells the browser which flows exist', () => {
|
||||||
|
expect(setup().provider.authorizationConfig()).toMatchObject({
|
||||||
|
clientId: 'code-client',
|
||||||
|
passwordGrant: true,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
setup({
|
||||||
|
clientId: undefined,
|
||||||
|
clientSecret: undefined,
|
||||||
|
}).provider.authorizationConfig(),
|
||||||
|
).toMatchObject({ clientId: null, passwordGrant: true });
|
||||||
|
expect(
|
||||||
|
setup({
|
||||||
|
clientId: undefined,
|
||||||
|
passwordClientId: undefined,
|
||||||
|
}).provider.authorizationConfig(),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
ForbiddenException,
|
||||||
Inject,
|
Inject,
|
||||||
Injectable,
|
Injectable,
|
||||||
Logger,
|
Logger,
|
||||||
@@ -28,6 +29,11 @@ interface PireonUser {
|
|||||||
app_roles?: string[];
|
app_roles?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface OAuthClient {
|
||||||
|
id: string;
|
||||||
|
secret: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/** Driven adapter for the Pireon OAuth2 identity server (Laravel Passport). */
|
/** Driven adapter for the Pireon OAuth2 identity server (Laravel Passport). */
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PireonIdentityProvider extends IdentityProvider {
|
export class PireonIdentityProvider extends IdentityProvider {
|
||||||
@@ -69,14 +75,78 @@ export class PireonIdentityProvider extends IdentityProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async issueToken(grant: TokenGrant): Promise<TokenSet> {
|
async issueToken(grant: TokenGrant): Promise<TokenSet> {
|
||||||
const { clientId, clientSecret, scopes } = this.config;
|
const clients = this.clientsFor(grant);
|
||||||
if (!clientId)
|
// A refresh token only works with the client that issued it, and we don't
|
||||||
throw new ServiceUnavailableException(
|
// know which one that was: try each until one accepts it.
|
||||||
'Sign-in is not configured (PIREON_CLIENT_ID)',
|
let rejected: InvalidCredentialsError | undefined;
|
||||||
);
|
for (const client of clients) {
|
||||||
|
try {
|
||||||
|
return await this.requestToken(client, grant);
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
grant.type !== 'refresh_token' ||
|
||||||
|
!(error instanceof InvalidCredentialsError)
|
||||||
|
)
|
||||||
|
throw error;
|
||||||
|
rejected = error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw rejected ?? new InvalidCredentialsError();
|
||||||
|
}
|
||||||
|
|
||||||
const body = new URLSearchParams({ client_id: clientId });
|
/**
|
||||||
if (clientSecret) body.set('client_secret', clientSecret);
|
* Pireon clients allow a single grant type: authorization_code uses
|
||||||
|
* PIREON_CLIENT_ID, password PIREON_PASSWORD_CLIENT_ID (or the former).
|
||||||
|
*/
|
||||||
|
private clientsFor(grant: TokenGrant): OAuthClient[] {
|
||||||
|
const { clientId, clientSecret, passwordClientId, passwordClientSecret } =
|
||||||
|
this.config;
|
||||||
|
const code: OAuthClient | undefined = clientId
|
||||||
|
? { id: clientId, secret: clientSecret }
|
||||||
|
: undefined;
|
||||||
|
const password: OAuthClient | undefined = passwordClientId
|
||||||
|
? { id: passwordClientId, secret: passwordClientSecret }
|
||||||
|
: code;
|
||||||
|
|
||||||
|
switch (grant.type) {
|
||||||
|
case 'authorization_code':
|
||||||
|
if (!code)
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
'Sign-in with Pireon is not configured (PIREON_CLIENT_ID)',
|
||||||
|
);
|
||||||
|
return [code];
|
||||||
|
case 'password':
|
||||||
|
if (!this.config.passwordGrant)
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Email and password sign-in is disabled',
|
||||||
|
);
|
||||||
|
if (!password)
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
'Email and password sign-in is not configured (PIREON_PASSWORD_CLIENT_ID)',
|
||||||
|
);
|
||||||
|
return [password];
|
||||||
|
case 'refresh_token': {
|
||||||
|
const candidates = [password, code].filter(
|
||||||
|
(client): client is OAuthClient => !!client,
|
||||||
|
);
|
||||||
|
if (!candidates.length)
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
'Sign-in is not configured (PIREON_CLIENT_ID)',
|
||||||
|
);
|
||||||
|
return candidates.filter(
|
||||||
|
(client, i) => candidates.findIndex((c) => c.id === client.id) === i,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requestToken(
|
||||||
|
client: OAuthClient,
|
||||||
|
grant: TokenGrant,
|
||||||
|
): Promise<TokenSet> {
|
||||||
|
const { scopes } = this.config;
|
||||||
|
const body = new URLSearchParams({ client_id: client.id });
|
||||||
|
if (client.secret) body.set('client_secret', client.secret);
|
||||||
switch (grant.type) {
|
switch (grant.type) {
|
||||||
case 'authorization_code':
|
case 'authorization_code':
|
||||||
body.set('grant_type', 'authorization_code');
|
body.set('grant_type', 'authorization_code');
|
||||||
@@ -114,12 +184,23 @@ export class PireonIdentityProvider extends IdentityProvider {
|
|||||||
throw new ServiceUnavailableException('Identity server unavailable');
|
throw new ServiceUnavailableException('Identity server unavailable');
|
||||||
}
|
}
|
||||||
if (response.status === 400 || response.status === 401) {
|
if (response.status === 400 || response.status === 401) {
|
||||||
|
// OAuth2 error (RFC 6749 §5.2). Passport puts the useful part in `hint`
|
||||||
|
// (which parameter was wrong), so keep it in the message and the log.
|
||||||
const error = (await response.json().catch(() => ({}))) as {
|
const error = (await response.json().catch(() => ({}))) as {
|
||||||
|
error?: string;
|
||||||
error_description?: string;
|
error_description?: string;
|
||||||
|
hint?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
};
|
};
|
||||||
|
const description =
|
||||||
|
error.error_description ?? error.message ?? 'Invalid credentials';
|
||||||
|
this.logger.warn(
|
||||||
|
`Pireon rejected ${grant.type} for client ${client.id}: ` +
|
||||||
|
`${error.error ?? response.status} — ${description}` +
|
||||||
|
(error.hint ? ` (hint: ${error.hint})` : ''),
|
||||||
|
);
|
||||||
throw new InvalidCredentialsError(
|
throw new InvalidCredentialsError(
|
||||||
error.error_description ?? error.message ?? 'Invalid credentials',
|
error.hint ? `${description} (${error.hint})` : description,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.assertOk(response, 'POST /oauth/token');
|
this.assertOk(response, 'POST /oauth/token');
|
||||||
@@ -138,12 +219,14 @@ export class PireonIdentityProvider extends IdentityProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
authorizationConfig(): AuthorizationConfig | null {
|
authorizationConfig(): AuthorizationConfig | null {
|
||||||
if (!this.config.clientId) return null;
|
const { clientId, passwordClientId, passwordGrant } = this.config;
|
||||||
|
const password = passwordGrant && !!(passwordClientId ?? clientId);
|
||||||
|
if (!clientId && !password) return null;
|
||||||
return {
|
return {
|
||||||
authorizeUrl: new URL('/oauth/authorize', this.config.baseUrl).toString(),
|
authorizeUrl: new URL('/oauth/authorize', this.config.baseUrl).toString(),
|
||||||
clientId: this.config.clientId,
|
clientId: clientId ?? null,
|
||||||
scopes: this.config.scopes,
|
scopes: this.config.scopes,
|
||||||
passwordGrant: this.config.passwordGrant,
|
passwordGrant: password,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,17 @@ export interface PireonConfig {
|
|||||||
/** Seconds a resolved token is trusted before asking Pireon again. */
|
/** Seconds a resolved token is trusted before asking Pireon again. */
|
||||||
sessionTtlSeconds: number;
|
sessionTtlSeconds: number;
|
||||||
requestTimeoutMs: number;
|
requestTimeoutMs: number;
|
||||||
/** OAuth client of task-stream in Pireon (Authorization Code + PKCE / password grant). */
|
/**
|
||||||
|
* Pireon OAuth client with grant type `authorization_code` ("Sign in with
|
||||||
|
* Pireon", Swagger UI). Pireon clients allow a single grant type, so email +
|
||||||
|
* password uses its own client below.
|
||||||
|
*/
|
||||||
clientId?: string;
|
clientId?: string;
|
||||||
/** Kept server-side: browsers sign in through /api/auth/token. */
|
/** Kept server-side: browsers sign in through /api/auth/token. */
|
||||||
clientSecret?: string;
|
clientSecret?: string;
|
||||||
|
/** Pireon OAuth client with grant type `password`; defaults to the one above. */
|
||||||
|
passwordClientId?: string;
|
||||||
|
passwordClientSecret?: string;
|
||||||
scopes: string[];
|
scopes: string[];
|
||||||
passwordGrant: boolean;
|
passwordGrant: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,11 @@ Adaptadores de entrada de identidad.
|
|||||||
|
|
||||||
## Contenido
|
## Contenido
|
||||||
|
|
||||||
- `auth/`: `AuthGuard` global, `@Public()`, `@CurrentUser()`, `@AccessToken()`, `@RequireAppRoles()` y `WsAuthenticator` (subscriptions).
|
- `auth/`: `AuthGuard` global, `@Public()`, `@OptionalAuth()` / `@ApiOptionalPireonAuth()` (lecturas de proyectos
|
||||||
- `http/`: `AuthController` (`GET /api/auth/config`, `POST /api/auth/token`) y `PireonWebhookController` (firma HMAC).
|
públicos: sin token, el usuario es `undefined`), `@CurrentUser()`, `@AccessToken()`, `@RequireAppRoles()`, `WsAuthenticator`
|
||||||
|
(subscriptions) y `@ApiPireonAuth()` (esquemas OAuth2 / Bearer de Pireon en OpenAPI).
|
||||||
|
- `http/`: `AuthController` (`GET /api/auth/config`, `POST /api/auth/token`), `UsersController` (`/api/users/me`,
|
||||||
|
directorio de Pireon) y `PireonWebhookController` (firma HMAC). Documentados con `@nestjs/swagger`.
|
||||||
- `graphql/`: queries `me`, `user` y `users`.
|
- `graphql/`: queries `me`, `user` y `users`.
|
||||||
|
|
||||||
## Comandos
|
## Comandos
|
||||||
|
|||||||
@@ -3,10 +3,7 @@
|
|||||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||||
"sourceRoot": "libs/api/identity/presenters/src",
|
"sourceRoot": "libs/api/identity/presenters/src",
|
||||||
"projectType": "library",
|
"projectType": "library",
|
||||||
"tags": [
|
"tags": ["scope:api-identity", "type:presenters"],
|
||||||
"scope:api-identity",
|
|
||||||
"type:presenters"
|
|
||||||
],
|
|
||||||
"// targets": "to see all targets run: nx show project api-identity-presenters --web",
|
"// targets": "to see all targets run: nx show project api-identity-presenters --web",
|
||||||
"targets": {}
|
"targets": {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,30 @@
|
|||||||
import {
|
import {
|
||||||
ExecutionContext,
|
ExecutionContext,
|
||||||
SetMetadata,
|
SetMetadata,
|
||||||
|
applyDecorators,
|
||||||
createParamDecorator,
|
createParamDecorator,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOAuth2, ApiSecurity } from '@nestjs/swagger';
|
||||||
import { getAuthRequest } from './auth-request';
|
import { getAuthRequest } from './auth-request';
|
||||||
|
|
||||||
export const IS_PUBLIC = 'identity:isPublic';
|
export const IS_PUBLIC = 'identity:isPublic';
|
||||||
|
export const IS_OPTIONAL_AUTH = 'identity:optionalAuth';
|
||||||
export const REQUIRED_APP_ROLES = 'identity:appRoles';
|
export const REQUIRED_APP_ROLES = 'identity:appRoles';
|
||||||
|
|
||||||
/** Skips authentication (health checks, webhooks...). */
|
/** Skips authentication (health checks, webhooks...). */
|
||||||
export const Public = () => SetMetadata(IS_PUBLIC, true);
|
export const Public = () => SetMetadata(IS_PUBLIC, true);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anonymous callers are let through (`@CurrentUser()` is undefined); a token,
|
||||||
|
* if sent, must still be valid. For reads that public projects allow.
|
||||||
|
*/
|
||||||
|
export const OptionalAuth = () => SetMetadata(IS_OPTIONAL_AUTH, true);
|
||||||
|
|
||||||
/** Requires one of these roles in the task-stream OAuth client (Pireon app roles). */
|
/** Requires one of these roles in the task-stream OAuth client (Pireon app roles). */
|
||||||
export const RequireAppRoles = (...roles: string[]) =>
|
export const RequireAppRoles = (...roles: string[]) =>
|
||||||
SetMetadata(REQUIRED_APP_ROLES, roles);
|
SetMetadata(REQUIRED_APP_ROLES, roles);
|
||||||
|
|
||||||
/** The authenticated caller (User). */
|
/** The authenticated caller (User); undefined on anonymous `@OptionalAuth()` calls. */
|
||||||
export const CurrentUser = createParamDecorator(
|
export const CurrentUser = createParamDecorator(
|
||||||
(_: unknown, context: ExecutionContext) => getAuthRequest(context).user,
|
(_: unknown, context: ExecutionContext) => getAuthRequest(context).user,
|
||||||
);
|
);
|
||||||
@@ -25,3 +34,22 @@ export const AccessToken = createParamDecorator(
|
|||||||
(_: unknown, context: ExecutionContext) =>
|
(_: unknown, context: ExecutionContext) =>
|
||||||
getAuthRequest(context).accessToken,
|
getAuthRequest(context).accessToken,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** OpenAPI security schemes (declared in apps/api/src/app/openapi.ts). */
|
||||||
|
export const PIREON_OAUTH = 'pireon-oauth';
|
||||||
|
export const PIREON_BEARER = 'pireon';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Documents that the endpoints need a Pireon access token: obtained through
|
||||||
|
* Pireon's OAuth (authorization code + PKCE, or password) or sent as a bearer
|
||||||
|
* token by an app that already has one. Enforced by the global AuthGuard.
|
||||||
|
*/
|
||||||
|
export const ApiPireonAuth = () =>
|
||||||
|
applyDecorators(ApiOAuth2([], PIREON_OAUTH), ApiBearerAuth(PIREON_BEARER));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `@OptionalAuth()` + OpenAPI: adds "no token" to the security requirements
|
||||||
|
* the controller declares with `@ApiPireonAuth()`.
|
||||||
|
*/
|
||||||
|
export const ApiOptionalPireonAuth = () =>
|
||||||
|
applyDecorators(OptionalAuth(), ApiSecurity({}));
|
||||||
|
|||||||
@@ -11,9 +11,16 @@ import {
|
|||||||
InvalidAccessTokenError,
|
InvalidAccessTokenError,
|
||||||
} from '@task-stream/api/identity/application';
|
} from '@task-stream/api/identity/application';
|
||||||
import { extractBearerToken, getAuthRequest } from './auth-request';
|
import { extractBearerToken, getAuthRequest } from './auth-request';
|
||||||
import { IS_PUBLIC, REQUIRED_APP_ROLES } from './auth.decorators';
|
import {
|
||||||
|
IS_OPTIONAL_AUTH,
|
||||||
|
IS_PUBLIC,
|
||||||
|
REQUIRED_APP_ROLES,
|
||||||
|
} from './auth.decorators';
|
||||||
|
|
||||||
/** Global guard: every HTTP/GraphQL operation needs a valid Pireon token unless @Public(). */
|
/**
|
||||||
|
* Global guard: every HTTP/GraphQL operation needs a valid Pireon token unless
|
||||||
|
* @Public(); with @OptionalAuth() the token may be missing (but not invalid).
|
||||||
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthGuard implements CanActivate {
|
export class AuthGuard implements CanActivate {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -30,7 +37,13 @@ export class AuthGuard implements CanActivate {
|
|||||||
// WebSocket subscriptions are authenticated once in connection_init.
|
// WebSocket subscriptions are authenticated once in connection_init.
|
||||||
if (!req.user) {
|
if (!req.user) {
|
||||||
const token = extractBearerToken(req.headers['authorization']);
|
const token = extractBearerToken(req.headers['authorization']);
|
||||||
if (!token) throw new UnauthorizedException('Missing bearer token');
|
if (!token) {
|
||||||
|
if (
|
||||||
|
this.reflector.getAllAndOverride<boolean>(IS_OPTIONAL_AUTH, targets)
|
||||||
|
)
|
||||||
|
return true;
|
||||||
|
throw new UnauthorizedException('Missing bearer token');
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
req.user = await this.identity.resolveSession(token);
|
req.user = await this.identity.resolveSession(token);
|
||||||
req.accessToken = token;
|
req.accessToken = token;
|
||||||
|
|||||||
@@ -6,6 +6,15 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiBody,
|
||||||
|
ApiConsumes,
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiOperation,
|
||||||
|
ApiProperty,
|
||||||
|
ApiTags,
|
||||||
|
ApiUnauthorizedResponse,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
import {
|
import {
|
||||||
IdentityService,
|
IdentityService,
|
||||||
InvalidCredentialsError,
|
InvalidCredentialsError,
|
||||||
@@ -18,10 +27,34 @@ import {
|
|||||||
import { Public } from '../auth/auth.decorators';
|
import { Public } from '../auth/auth.decorators';
|
||||||
import { IssueTokenDto } from './dto/issue-token.dto';
|
import { IssueTokenDto } from './dto/issue-token.dto';
|
||||||
|
|
||||||
|
export class AuthorizationConfigDto {
|
||||||
|
@ApiProperty({ example: 'https://oauth.pireon.dev/oauth/authorize' })
|
||||||
|
authorizeUrl!: string;
|
||||||
|
@ApiProperty({
|
||||||
|
type: String,
|
||||||
|
nullable: true,
|
||||||
|
description:
|
||||||
|
'Public id of the authorization_code client ("Sign in with Pireon"); null when only email + password is configured',
|
||||||
|
})
|
||||||
|
clientId!: string | null;
|
||||||
|
@ApiProperty({ type: [String], example: ['users:read'] })
|
||||||
|
scopes!: string[];
|
||||||
|
@ApiProperty({ description: 'Whether email + password sign-in is allowed' })
|
||||||
|
passwordGrant!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TokenResponseDto {
|
||||||
|
@ApiProperty() access_token!: string;
|
||||||
|
@ApiProperty({ type: String, nullable: true }) refresh_token!: string | null;
|
||||||
|
@ApiProperty({ description: 'Seconds' }) expires_in!: number;
|
||||||
|
@ApiProperty({ example: 'Bearer' }) token_type!: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sign-in for browsers. Pireon's /oauth/token has no CORS and the client
|
* Sign-in for browsers. Pireon's /oauth/token has no CORS and the client
|
||||||
* secret must stay server-side, so the web app goes through here.
|
* secret must stay server-side, so the web app goes through here.
|
||||||
*/
|
*/
|
||||||
|
@ApiTags('Auth')
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(private readonly identity: IdentityService) {}
|
constructor(private readonly identity: IdentityService) {}
|
||||||
@@ -29,6 +62,10 @@ export class AuthController {
|
|||||||
/** What the web app needs to start Authorization Code + PKCE (null = not configured). */
|
/** What the web app needs to start Authorization Code + PKCE (null = not configured). */
|
||||||
@Public()
|
@Public()
|
||||||
@Get('config')
|
@Get('config')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'How to sign in with Pireon (null when not configured)',
|
||||||
|
})
|
||||||
|
@ApiOkResponse({ type: AuthorizationConfigDto })
|
||||||
config(): Promise<AuthorizationConfig | null> {
|
config(): Promise<AuthorizationConfig | null> {
|
||||||
return this.identity.authorizationConfig();
|
return this.identity.authorizationConfig();
|
||||||
}
|
}
|
||||||
@@ -36,6 +73,18 @@ export class AuthController {
|
|||||||
@Public()
|
@Public()
|
||||||
@Post('token')
|
@Post('token')
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
'Exchange a grant for Pireon tokens (proxy of Pireon /oauth/token)',
|
||||||
|
description:
|
||||||
|
'For browsers (Pireon has no CORS) and Swagger UI: adds the client secret ' +
|
||||||
|
'server-side. Apps with their own Pireon OAuth client can get tokens from ' +
|
||||||
|
'Pireon directly; any valid Pireon access token is accepted by this API.',
|
||||||
|
})
|
||||||
|
@ApiConsumes('application/json', 'application/x-www-form-urlencoded')
|
||||||
|
@ApiBody({ type: IssueTokenDto })
|
||||||
|
@ApiOkResponse({ type: TokenResponseDto })
|
||||||
|
@ApiUnauthorizedResponse({ description: 'Pireon rejected the grant' })
|
||||||
async token(@Body() dto: IssueTokenDto) {
|
async token(@Body() dto: IssueTokenDto) {
|
||||||
try {
|
try {
|
||||||
return toResponse(await this.identity.issueToken(toGrant(dto)));
|
return toResponse(await this.identity.issueToken(toGrant(dto)));
|
||||||
|
|||||||
@@ -1,35 +1,48 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsIn, IsNotEmpty, IsString, IsUrl, ValidateIf } from 'class-validator';
|
import { IsIn, IsNotEmpty, IsString, IsUrl, ValidateIf } from 'class-validator';
|
||||||
|
|
||||||
|
/** OAuth2 token request (RFC 6749), as JSON or application/x-www-form-urlencoded. */
|
||||||
export class IssueTokenDto {
|
export class IssueTokenDto {
|
||||||
|
@ApiProperty({ enum: ['authorization_code', 'password', 'refresh_token'] })
|
||||||
@IsIn(['authorization_code', 'password', 'refresh_token'])
|
@IsIn(['authorization_code', 'password', 'refresh_token'])
|
||||||
grant_type!: 'authorization_code' | 'password' | 'refresh_token';
|
grant_type!: 'authorization_code' | 'password' | 'refresh_token';
|
||||||
|
|
||||||
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'authorization_code')
|
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'authorization_code')
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'authorization_code: code returned by Pireon',
|
||||||
|
})
|
||||||
code?: string;
|
code?: string;
|
||||||
|
|
||||||
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'authorization_code')
|
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'authorization_code')
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
@ApiPropertyOptional({ description: 'authorization_code: PKCE verifier' })
|
||||||
code_verifier?: string;
|
code_verifier?: string;
|
||||||
|
|
||||||
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'authorization_code')
|
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'authorization_code')
|
||||||
@IsUrl({ require_tld: false })
|
@IsUrl({ require_tld: false })
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'authorization_code: redirect URI used to authorize',
|
||||||
|
})
|
||||||
redirect_uri?: string;
|
redirect_uri?: string;
|
||||||
|
|
||||||
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'password')
|
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'password')
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
@ApiPropertyOptional({ description: 'password: Pireon email' })
|
||||||
username?: string;
|
username?: string;
|
||||||
|
|
||||||
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'password')
|
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'password')
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
@ApiPropertyOptional({ description: 'password: Pireon password' })
|
||||||
password?: string;
|
password?: string;
|
||||||
|
|
||||||
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'refresh_token')
|
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'refresh_token')
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
@ApiPropertyOptional({ description: 'refresh_token' })
|
||||||
refresh_token?: string;
|
refresh_token?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import {
|
||||||
|
ApiHeader,
|
||||||
|
ApiNoContentResponse,
|
||||||
|
ApiOperation,
|
||||||
|
ApiTags,
|
||||||
|
ApiUnauthorizedResponse,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
import { IdentityService } from '@task-stream/api/identity/application';
|
import { IdentityService } from '@task-stream/api/identity/application';
|
||||||
import { Request } from 'express';
|
import { Request } from 'express';
|
||||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||||
import { Public } from '../auth/auth.decorators';
|
import { Public } from '../auth/auth.decorators';
|
||||||
|
|
||||||
/** Receives `user.deactivated` / `token.revoked` from Pireon. */
|
/**
|
||||||
|
* Receives `user.deactivated` / `token.revoked` from Pireon.
|
||||||
|
*
|
||||||
|
* 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')
|
@Controller('webhooks/pireon')
|
||||||
export class PireonWebhookController {
|
export class PireonWebhookController {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -25,6 +39,20 @@ export class PireonWebhookController {
|
|||||||
@Public()
|
@Public()
|
||||||
@Post()
|
@Post()
|
||||||
@HttpCode(204)
|
@HttpCode(204)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Pireon events (called by Pireon, not by apps)',
|
||||||
|
description:
|
||||||
|
"`user.deactivated` closes the user's sessions; `token.revoked` forgets the token. " +
|
||||||
|
'Disabled unless PIREON_WEBHOOK_SECRET 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(
|
async receive(
|
||||||
@Req() req: RawBodyRequest<Request>,
|
@Req() req: RawBodyRequest<Request>,
|
||||||
@Headers('x-webhook-signature') signature?: string,
|
@Headers('x-webhook-signature') signature?: string,
|
||||||
@@ -42,13 +70,16 @@ export class PireonWebhookController {
|
|||||||
await this.identity.handleWebhook(event, data);
|
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(
|
private verifySignature(
|
||||||
rawBody: Buffer | undefined,
|
rawBody: Buffer | undefined,
|
||||||
signature: string | undefined,
|
signature: string | undefined,
|
||||||
): void {
|
): void {
|
||||||
const secret = this.config.get<string>('PIREON_WEBHOOK_SECRET');
|
const secrets = [
|
||||||
if (!secret) {
|
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(
|
throw new UnauthorizedException(
|
||||||
'Webhooks are disabled (PIREON_WEBHOOK_SECRET not set)',
|
'Webhooks are disabled (PIREON_WEBHOOK_SECRET not set)',
|
||||||
);
|
);
|
||||||
@@ -56,11 +87,16 @@ export class PireonWebhookController {
|
|||||||
if (!rawBody || !signature)
|
if (!rawBody || !signature)
|
||||||
throw new UnauthorizedException('Missing signature');
|
throw new UnauthorizedException('Missing signature');
|
||||||
|
|
||||||
const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
|
const received = Buffer.from(signature.replace(/^sha256=/, ''));
|
||||||
const received = signature.replace(/^sha256=/, '');
|
const valid = secrets.some((secret) => {
|
||||||
const valid =
|
const expected = Buffer.from(
|
||||||
received.length === expected.length &&
|
createHmac('sha256', secret).update(rawBody).digest('hex'),
|
||||||
timingSafeEqual(Buffer.from(received), Buffer.from(expected));
|
);
|
||||||
|
return (
|
||||||
|
received.length === expected.length &&
|
||||||
|
timingSafeEqual(received, expected)
|
||||||
|
);
|
||||||
|
});
|
||||||
if (!valid) throw new UnauthorizedException('Invalid signature');
|
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 { UserResolver } from './graphql/user.resolver';
|
||||||
import { AuthController } from './http/auth.controller';
|
import { AuthController } from './http/auth.controller';
|
||||||
import { PireonWebhookController } from './http/pireon-webhook.controller';
|
import { PireonWebhookController } from './http/pireon-webhook.controller';
|
||||||
|
import { UsersController } from './http/users.controller';
|
||||||
|
|
||||||
/** Driving adapters of the identity context: global auth guard, webhook, GraphQL. */
|
/** Driving adapters of the identity context: global auth guard, REST (auth, users, webhook), GraphQL. */
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [AuthController, PireonWebhookController],
|
controllers: [AuthController, UsersController, PireonWebhookController],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: APP_GUARD, useClass: AuthGuard },
|
{ provide: APP_GUARD, useClass: AuthGuard },
|
||||||
WsAuthenticator,
|
WsAuthenticator,
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ Contrato del event store y reconstrucción de agregados.
|
|||||||
|
|
||||||
- `EventStore` (puerto): `persist` y `getEventsByStreamId`.
|
- `EventStore` (puerto): `persist` y `getEventsByStreamId`.
|
||||||
- `AggregateRehydrator`: carga los eventos de un stream y devuelve el agregado reconstruido, ya enlazado con el `EventPublisher`.
|
- `AggregateRehydrator`: carga los eventos de un stream y devuelve el agregado reconstruido, ya enlazado con el `EventPublisher`.
|
||||||
- `SharedModule.withInfrastructure(...)`: módulo global que expone el event store y el rehydrator a todos los contextos.
|
- `EventRecorder`: guarda un evento en un stream que no es de ningún agregado propio (p. ej. un usuario de Pireon), en
|
||||||
|
la siguiente posición, para que otros contextos reaccionen a él.
|
||||||
|
- `SharedModule.withInfrastructure(...)`: módulo global que expone el event store, el rehydrator y el recorder a todos
|
||||||
|
los contextos.
|
||||||
|
|
||||||
## Comandos
|
## Comandos
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,7 @@
|
|||||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||||
"sourceRoot": "libs/api/shared/application/src",
|
"sourceRoot": "libs/api/shared/application/src",
|
||||||
"projectType": "library",
|
"projectType": "library",
|
||||||
"tags": [
|
"tags": ["scope:api-shared", "type:application"],
|
||||||
"scope:api-shared",
|
|
||||||
"type:application"
|
|
||||||
],
|
|
||||||
"// targets": "to see all targets run: nx show project api-shared-application --web",
|
"// targets": "to see all targets run: nx show project api-shared-application --web",
|
||||||
"targets": {}
|
"targets": {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
export * from './lib/ports/event-store';
|
export * from './lib/ports/event-store';
|
||||||
export * from './lib/aggregate-rehydrator';
|
export * from './lib/aggregate-rehydrator';
|
||||||
export * from './lib/shared.module';
|
export * from './lib/shared.module';
|
||||||
|
export * from './lib/event-recorder';
|
||||||
|
export * from './lib/command-runner';
|
||||||
|
|||||||
48
libs/api/shared/application/src/lib/command-runner.spec.ts
Normal file
48
libs/api/shared/application/src/lib/command-runner.spec.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { Command, CommandBus } from '@nestjs/cqrs';
|
||||||
|
import {
|
||||||
|
BusinessRuleError,
|
||||||
|
ConcurrencyError,
|
||||||
|
} from '@task-stream/api/shared/domain';
|
||||||
|
import { COMMAND_ATTEMPTS, CommandRunner } from './command-runner';
|
||||||
|
|
||||||
|
class Rename extends Command<string> {}
|
||||||
|
|
||||||
|
const busFailing = (errors: Error[]) => {
|
||||||
|
const execute = jest.fn(async () => {
|
||||||
|
const error = errors.shift();
|
||||||
|
if (error) throw error;
|
||||||
|
return 'done';
|
||||||
|
});
|
||||||
|
return { bus: { execute } as unknown as CommandBus, execute };
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('CommandRunner', () => {
|
||||||
|
it('runs a command again while its aggregate changed underneath', async () => {
|
||||||
|
const { bus, execute } = busFailing([
|
||||||
|
new ConcurrencyError('Aggregate', 'a'),
|
||||||
|
new ConcurrencyError('Aggregate', 'a'),
|
||||||
|
]);
|
||||||
|
await expect(new CommandRunner(bus).execute(new Rename())).resolves.toBe(
|
||||||
|
'done',
|
||||||
|
);
|
||||||
|
expect(execute).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives up after a few conflicts, and never retries other errors', async () => {
|
||||||
|
const conflicts = Array.from(
|
||||||
|
{ length: COMMAND_ATTEMPTS },
|
||||||
|
() => new ConcurrencyError('Aggregate', 'a'),
|
||||||
|
);
|
||||||
|
const stubborn = busFailing(conflicts);
|
||||||
|
await expect(
|
||||||
|
new CommandRunner(stubborn.bus).execute(new Rename()),
|
||||||
|
).rejects.toBeInstanceOf(ConcurrencyError);
|
||||||
|
expect(stubborn.execute).toHaveBeenCalledTimes(COMMAND_ATTEMPTS);
|
||||||
|
|
||||||
|
const refused = busFailing([new BusinessRuleError('No')]);
|
||||||
|
await expect(
|
||||||
|
new CommandRunner(refused.bus).execute(new Rename()),
|
||||||
|
).rejects.toBeInstanceOf(BusinessRuleError);
|
||||||
|
expect(refused.execute).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user