Compare commits

..

10 Commits

Author SHA1 Message Date
e656222b54 feat: public projects, project deletion and sagas
Public projects:
- Project.isPublic (create/update, owners and admins). Anyone can read a
  public project without a token: project, board, tickets, comments,
  history, releases, activity, tracking and statistics, over GraphQL and
  REST. Writes stay for members (401 anonymous, 403 outsiders).
- @OptionalAuth() / @ApiOptionalPireonAuth(): anonymous callers allowed,
  invalid tokens still rejected. ReadAccess lets anonymous viewers read
  public projects only; linked tickets are filtered by visibility.
- publicProjects (GraphQL) and GET /api/explore/projects (REST).
- Web: «Explorar» page, project pages open without a session (read-only
  notice, sign-in hints), public badge, visibility switch in settings,
  no realtime subscriptions without a session.

Project deletion:
- Owner only, confirming the key (deleteProject / DELETE
  /api/projects/{project}?confirm=KEY). The key is freed; the read model
  drops the project and members are notified (web goes home).

Sagas (run by the EventsBridge after projections, durable and ordered):
- ProjectDeletionSaga: project deleted → delete its tickets and releases;
  ticket deleted → purge its comments and drop links from other tickets.
- ReleaseArchivedSaga: unfinished tickets leave an archived release.
- UserDeactivatedSaga: the Pireon webhook records UserDeactivatedEvent in
  the event store (EventRecorder); the tracker unassigns that user's open
  tickets as the system actor.
- Internal idempotent commands; domain refusals are logged and skipped,
  concurrency conflicts retried. Affected streams come from the event store
  (TrackerStreamsRepository).

Tests: domain lifecycle specs, saga specs run like the bridge, facade
tests, e2e for public access, cascade deletion, archived releases and
deactivation webhook. Docs, READMEs, schema.gql/openapi.json and Word/PDF
updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 07:04:20 +02:00
0e301ad545 feat: per-project workflow settings, statistics and "my work" home
Per-project settings (owners/admins, «Configuración» tab):
- Statuses with category (TODO, IN_PROGRESS, QA, DONE, CANCELLED), colour and
  "requires reason"; configurable transitions; ticket types (icon, colour) and
  areas. Defaults are the previous fixed values; projects created before keep
  them without migration.
- Values are archived, never deleted: tickets, history and statistics keep
  referring to them. Validated in the domain (ProjectSettingsChangedEvent).
- Releases, board, tracking and "my work" use categories, so custom statuses
  keep working. Status changes carry name/category metadata for history.
- GraphQL: status/type/area are strings (breaking for clients sending enum
  literals); Project.settings, updateProjectSettings, statusCategory,
  myIssues(categories). REST: PUT /projects/{project}/settings, category filters.
- Read model: statusCategory on issues (backfilled at startup).

Statistics («Estadísticas» tab):
- New issue_timeline projection; pure functions compute created vs resolved,
  cumulative flow, cycle/lead time (avg + p85), throughput per person, QA
  pass/fail and release burnup. GraphQL Project.analytics(days),
  Release.burnup; REST /projects/{project}/analytics, /releases/{id}/burnup.
- Web: SVG charts with validated light/dark palette, crosshair tooltip,
  keyboard navigation, legend and data table.

Home "Mi trabajo": my open tickets across projects grouped by category (QA
failures first), tickets waiting for QA, upcoming releases and "Ir a…".

Tests: domain/application specs, e2e scenario for settings and analytics,
facade tests. Docs, READMEs, schema.gql/openapi.json and Word/PDF updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:53:45 +02:00
240ea9a539 feat(web): team list on the tracking dashboard
"¿Quién está con qué?" only listed people with open tickets. The tracking
page now shows the whole team: every member with their role and open
tickets (0 included), busiest first, plus former members who still have
tickets and the unassigned count. Each person links to the board filtered
by them: the board accepts ?assignee= like ?release=.

Role colours move to ui/copy.ts (shared with the members page). Docs and
Word/PDF updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 16:14:46 +02:00
6c3550ae9d docs(auth): two Pireon clients, production URLs and sign-in troubleshooting
- Registering clients: the authorization_code client is confidential and needs
  its secret; Pireon shows it once (rotate-secret if lost); containers must be
  recreated to read new environment variables.
- Production URLs (taskstream.pireon.dev, api.taskstream.pireon.dev) and their
  redirect URIs.
- "Si el login falla": each error seen while deploying (not configured,
  unauthorized_client, missing client_secret, invalid_client, redirect URI,
  expired code, wrong credentials, intermittent 404s) with cause and fix.
- READMEs mention both clients; Word/PDF regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 16:01:33 +02:00
b1002e6599 fix(auth): keep Pireon's OAuth error hint in token errors and logs
Passport answers token failures with a generic `invalid_request` description
and puts the actual cause in `hint` (invalid redirect URI, expired or revoked
code, code issued to another client…). The proxy dropped it, so sign-in
showed only "The request is missing a required parameter…". It is now
appended to the error message and logged with the grant type and client id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:52:16 +02:00
cbaad17fc7 fix(auth): separate Pireon clients for authorization code and password
Pireon OAuth clients allow a single grant type. With one PIREON_CLIENT_ID of
type password, email + password worked but "Sign in with Pireon" failed with
`unauthorized_client`.

- PIREON_CLIENT_ID/SECRET: authorization_code client (Sign in with Pireon,
  Swagger UI). New PIREON_PASSWORD_CLIENT_ID/SECRET: password client, falling
  back to the former so existing setups keep working.
- Refresh tries both clients: a refresh token only works with its issuer.
- PIREON_PASSWORD_GRANT=false now also rejects password grants in the API
  (before it only hid the form).
- /api/auth/config returns clientId: null when there is no authorization_code
  client, and the web hides "Sign in with Pireon".
- docker-compose passes the new variables; docs 05/07, .env.example,
  openapi.json; unit tests for the client selection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:41:50 +02:00
7532938b4e fix(docker): reach services by unique aliases, not bare service names
In Dokploy, services with a domain join the shared dokploy-network, where
another project also has an `api`. nginx proxied /api to `api:3000`, so
Docker DNS returned both containers and half of the requests went to the
other app: 404 "Cannot POST /api/auth/token" on sign-in and "sign-in not
configured" when /api/auth/config was answered by it.

Services now get task-stream-* aliases on the project network and nginx and
the API use them (task-stream-api, -postgres, -mongo, -redis). The API's
OPENAPI_FILE is empty in the container; docs 07 explain the aliases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:26:47 +02:00
341241a52f feat(api): REST API with OpenAPI for other apps, behind Pireon OAuth
A REST adapter next to GraphQL (presenters/http) that calls the same
application services, so validation, permissions and domain errors are the
same: projects (members, board, stats, activity), issues (search, workflow,
assignee, release, links, comments, history), comments, releases and users.
Projects and issues can be addressed by id or key (WEB, WEB-12).

- Swagger UI at /api/docs, document at /api/docs-json and apps/api/openapi.json
  (rewritten by `nx serve api`, like schema.gql) for client generation.
- Every route but health/auth/webhook needs a Pireon token: the document
  declares Pireon OAuth2 (authorization code + PKCE, password) through the
  /api/auth/token proxy, and a bearer scheme for apps with their own client.
- configureApp() shares main.ts setup with the e2e, which now also covers REST.
- SWAGGER_ENABLED / OPENAPI_FILE config; docs 03/05 and library READMEs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:26:47 +02:00
b2c9be72c3 chore(docker): publish ports only in development
docker-compose.yml is the production definition (deployed by Dokploy) and no
longer publishes any port: postgres, mongo, redis and the API are reachable
only on the compose network, and the web goes out through the platform's
proxy (domain → service web, port 80). The ports moved to
docker-compose.override.yml, which Compose loads automatically locally and
Dokploy (-f docker-compose.yml) does not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 14:16:50 +02:00
bf0294a188 fix(docker): read the Nexus token from NEXUS_TOKEN instead of ~/.npmrc
Dokploy builds failed with "failed to stat /root/.npmrc": the compose build
secret pointed at ${HOME}/.npmrc, which only exists on a developer machine.
The token now comes from the NEXUS_TOKEN environment variable (Dokploy's
Environment tab or .env), is written to a temporary npmrc and removed in the
same RUN, so it never lands in an image layer.

Also removes a temporary UI dev-server spec that was committed by mistake,
and documents the variable (.env.example, docs 06/07, Word/PDF export).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 14:11:01 +02:00
273 changed files with 21906 additions and 1432 deletions

View File

@@ -18,12 +18,24 @@ EVENTS_BRIDGE_ENABLED=true
PIREON_BASE_URL=https://oauth.pireon.dev PIREON_BASE_URL=https://oauth.pireon.dev
# Secret returned when registering the webhook (POST /api/clients/{client}/webhooks) # Secret returned when registering the webhook (POST /api/clients/{client}/webhooks)
PIREON_WEBHOOK_SECRET= PIREON_WEBHOOK_SECRET=
# OAuth client registered for task-stream (POST /api/clients). Needed by the web app to sign in. # OAuth clients of task-stream in Pireon (POST /api/clients). Each Pireon client allows ONE grant type:
# Redirect URI to register: http://localhost:4200/auth/callback # authorization_code → "Sign in with Pireon" and Swagger UI. Redirect URIs to register:
# http://localhost:4200/auth/callback, http://localhost:3000/api/docs/oauth2-redirect.html
PIREON_CLIENT_ID= PIREON_CLIENT_ID=
PIREON_CLIENT_SECRET= PIREON_CLIENT_SECRET=
# password → email + password sign-in (defaults to the client above when empty)
PIREON_PASSWORD_CLIENT_ID=
PIREON_PASSWORD_CLIENT_SECRET=
PIREON_SCOPES=users:read PIREON_SCOPES=users:read
PIREON_PASSWORD_GRANT=true PIREON_PASSWORD_GRANT=true
AUTH_SESSION_TTL_SECONDS=60 AUTH_SESSION_TTL_SECONDS=60
GRAPHQL_PLAYGROUND=true GRAPHQL_PLAYGROUND=true
# REST + OpenAPI for other apps (Swagger UI at /api/docs). The document is also written to OPENAPI_FILE.
SWAGGER_ENABLED=true
OPENAPI_FILE=apps/api/openapi.json
# Docker builds only: token of Pireon's Nexus for @pi/ui (build secret, never stored in an image).
# Locally: export NEXUS_TOKEN=$(grep '^//nexus.pireon.dev/repository/npm-releases/:_authToken=' ~/.npmrc | cut -d= -f2-)
NEXUS_TOKEN=

View File

@@ -5,3 +5,7 @@
/.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

View File

@@ -3,14 +3,18 @@
# docker compose up -d --build → api + web + infrastructure # docker compose up -d --build → api + web + infrastructure
# #
# @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

View File

@@ -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 |

View File

@@ -1,12 +1,12 @@
import { INestApplication, ValidationPipe } from '@nestjs/common'; import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing'; import { Test } from '@nestjs/testing';
import { import {
IdentityProvider, IdentityProvider,
InvalidAccessTokenError, InvalidAccessTokenError,
} from '@task-stream/api/identity/application'; } from '@task-stream/api/identity/application';
import { User, UserFactory } from '@task-stream/api/identity/domain'; import { User, UserFactory } from '@task-stream/api/identity/domain';
import { DomainErrorFilter } from '@task-stream/api/shared/presenters';
import { Client, createClient } from 'graphql-ws'; import { Client, createClient } from 'graphql-ws';
import { createHmac } from 'node:crypto';
import WebSocket from 'ws'; import WebSocket from 'ws';
process.env['POSTGRES_URL'] ??= process.env['POSTGRES_URL'] ??=
@@ -16,9 +16,12 @@ process.env['MONGO_URL'] ??= 'mongodb://localhost:27017/taskstream_e2e';
process.env['REDIS_URL'] ??= 'redis://localhost:6379'; process.env['REDIS_URL'] ??= 'redis://localhost:6379';
process.env['EVENT_STREAM_KEY'] = `task-stream:e2e:${Date.now()}`; process.env['EVENT_STREAM_KEY'] = `task-stream:e2e:${Date.now()}`;
process.env['GRAPHQL_SCHEMA_FILE'] = ''; process.env['GRAPHQL_SCHEMA_FILE'] = '';
process.env['OPENAPI_FILE'] = '';
process.env['AUTH_SESSION_TTL_SECONDS'] = '0'; process.env['AUTH_SESSION_TTL_SECONDS'] = '0';
process.env['PIREON_WEBHOOK_SECRET'] = 'e2e-webhook-secret';
const { AppModule } = require('../src/app/app.module'); const { AppModule } = require('../src/app/app.module');
const { configureApp } = require('../src/app/configure');
/** Pireon is replaced by fixed tokens: the rest of the stack is real. */ /** Pireon is replaced by fixed tokens: the rest of the stack is real. */
const users = new UserFactory(); const users = new UserFactory();
@@ -29,6 +32,7 @@ const people: Record<string, User> = {
'token-bob': person('102', 'Bob (frontend)', 'bob@x.dev'), 'token-bob': person('102', 'Bob (frontend)', 'bob@x.dev'),
'token-qa': person('103', 'Quinn (QA)', 'qa@x.dev'), 'token-qa': person('103', 'Quinn (QA)', 'qa@x.dev'),
'token-eve': person('104', 'Eve (outsider)', 'eve@x.dev'), 'token-eve': person('104', 'Eve (outsider)', 'eve@x.dev'),
'token-dan': person('105', 'Dan (leaving)', 'dan@x.dev'),
}; };
const byId = (id: string) => const byId = (id: string) =>
Object.values(people).find((p) => p.id === id) ?? null; Object.values(people).find((p) => p.id === id) ?? null;
@@ -71,6 +75,25 @@ describe('task-stream (e2e)', () => {
return body.data; return body.data;
} }
/** REST call under /api; returns status and parsed body. */
async function rest<T = Record<string, any>>( // eslint-disable-line @typescript-eslint/no-explicit-any
token: string | null,
method: string,
path: string,
body?: object,
): Promise<{ status: number; body: T }> {
const response = await fetch(`${url}/api${path}`, {
method,
headers: {
'content-type': 'application/json',
...(token ? { authorization: `Bearer ${token}` } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const text = await response.text();
return { status: response.status, body: text ? JSON.parse(text) : null };
}
/** The read model is eventually consistent: poll until it catches up. */ /** The read model is eventually consistent: poll until it catches up. */
async function eventually<T>( async function eventually<T>(
read: () => Promise<T>, read: () => Promise<T>,
@@ -123,10 +146,7 @@ describe('task-stream (e2e)', () => {
.useValue(fakePireon) .useValue(fakePireon)
.compile(); .compile();
app = moduleRef.createNestApplication({ rawBody: true }); app = moduleRef.createNestApplication({ rawBody: true });
app.useGlobalPipes( configureApp(app);
new ValidationPipe({ transform: true, whitelist: true }),
);
app.useGlobalFilters(new DomainErrorFilter());
await app.listen(0); await app.listen(0);
url = await app.getUrl(); url = await app.getUrl();
url = url.replace('[::1]', 'localhost'); url = url.replace('[::1]', 'localhost');
@@ -405,4 +425,740 @@ describe('task-stream (e2e)', () => {
expect(anonymous.received).toHaveLength(0); expect(anonymous.received).toHaveLength(0);
expect(anonymous.errors).toHaveLength(1); expect(anonymous.errors).toHaveLength(1);
}); });
it('exposes the same use cases over REST, documented with OpenAPI', async () => {
// --- The contract other apps generate clients from ---
const docs = await rest(null, 'GET', '/docs-json');
expect(docs.status).toBe(200);
expect(Object.keys(docs.body['paths'])).toEqual(
expect.arrayContaining([
'/api/projects',
'/api/projects/{project}/issues',
'/api/issues/{issue}/status',
'/api/releases/{releaseId}/publish',
'/api/projects/{project}/settings',
'/api/projects/{project}/analytics',
'/api/releases/{releaseId}/burnup',
'/api/users/me',
]),
);
expect(Object.keys(docs.body['components']['securitySchemes'])).toEqual(
expect.arrayContaining(['pireon-oauth', 'pireon']),
);
// --- Every consumer goes through Pireon's OAuth ---
expect((await rest(null, 'GET', '/projects')).status).toBe(401);
expect((await rest('not-a-token', 'GET', '/projects')).status).toBe(401);
expect((await rest('token-ana', 'GET', '/users/me')).body).toMatchObject({
id: '101',
name: 'Ana (lead)',
});
// --- Project and members, addressed by key ---
const key = 'R' + Date.now().toString(36).slice(-5).toUpperCase();
const created = await rest('token-ana', 'POST', '/projects', {
key,
name: 'REST project',
});
expect(created.status).toBe(201);
await eventually(
() => rest('token-ana', 'GET', `/projects/${key}`),
(r) => r.status === 200,
);
expect(
(
await rest('token-ana', 'POST', `/projects/${key}/members`, {
userId: '103',
role: 'MEMBER',
})
).status,
).toBe(201);
// --- Tickets: create, workflow, relations, comments ---
const invalid = await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'NOPE',
title: '',
});
expect(invalid.status).toBe(400);
const be = await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'FEATURE',
title: 'Endpoint de cupones',
area: 'BACKEND',
});
const qa = await rest('token-qa', 'POST', `/projects/${key}/issues`, {
type: 'QA',
title: 'Probar cupones',
});
expect(be.body).toMatchObject({ key: `${key}-1`, version: 1 });
const beKey = be.body['key'] as string;
const qaKey = qa.body['key'] as string;
await eventually(
() => rest('token-ana', 'GET', `/issues/${beKey}`),
(r) => r.status === 200,
);
const skip = await rest('token-ana', 'PUT', `/issues/${beKey}/status`, {
status: 'DONE',
});
expect(skip.status).toBe(422);
expect(skip.body).toMatchObject({ code: 'BUSINESS_RULE' });
expect(
(
await rest('token-ana', 'PUT', `/issues/${beKey}/status`, {
status: 'TODO',
})
).status,
).toBe(200);
expect(
(
await rest('token-ana', 'PUT', `/issues/${beKey}/assignee`, {
assigneeId: '103',
})
).status,
).toBe(200);
expect(
(
await rest('token-qa', 'POST', `/issues/${qaKey}/links`, {
type: 'TESTS',
targetIssue: beKey,
})
).status,
).toBe(201);
expect(
(
await rest('token-qa', 'POST', `/issues/${beKey}/comments`, {
body: 'Lo pruebo en cuanto esté en QA',
})
).status,
).toBe(201);
const issue = await eventually(
() => rest('token-ana', 'GET', `/issues/${beKey}`),
(r) => r.body?.['status'] === 'TODO' && r.body?.['links']?.length === 1,
);
expect(issue.body).toMatchObject({
key: beKey,
assignee: { id: '103' },
links: [{ type: 'TESTS', direction: 'INWARD', issueKey: qaKey }],
allowedTransitions: expect.arrayContaining(['IN_PROGRESS']),
});
const search = await rest(
'token-ana',
'GET',
`/projects/${key}/issues?status=TODO,BACKLOG&type=FEATURE`,
);
expect(search.body).toMatchObject({ total: 1, items: [{ key: beKey }] });
const comments = await eventually(
() => rest('token-ana', 'GET', `/issues/${beKey}/comments`),
(r) => r.body?.['total'] === 1,
);
expect(comments.body['items'][0]).toMatchObject({ author: { id: '103' } });
// --- My work across projects ---
const mine = await eventually(
() => rest('token-qa', 'GET', '/me/issues'),
(r) => r.body?.['items']?.some((i: { key: string }) => i.key === beKey),
);
// Created by Quinn but assigned to nobody: not in "assigned to me".
expect(mine.body['items'].map((i: { key: string }) => i.key)).not.toContain(
qaKey,
);
const todo = await rest(
'token-ana',
'GET',
'/me/issues?assignedToMe=false&status=TODO',
);
expect(todo.body['items'].map((i: { key: string }) => i.key)).toContain(
beKey,
);
const viaGraphql = await gql(
'token-qa',
`{ myIssues { total items { key assignee { id } } } }`,
);
expect(viaGraphql['myIssues'].items).toEqual(
expect.arrayContaining([{ key: beKey, assignee: { id: '103' } }]),
);
await rest('token-ana', 'POST', `/projects/${key}/releases`, {
name: 'v9.9.9',
targetDate: '2099-01-01',
});
await eventually(
() => rest('token-ana', 'GET', '/me/releases?limit=50'),
(r) =>
Array.isArray(r.body) &&
r.body.some((rel: { name: string }) => rel.name === 'v9.9.9'),
);
// Not a member of the project: none of its tickets.
const outsider = await rest('token-eve', 'GET', '/me/issues');
expect(
outsider.body['items'].map((i: { key: string }) => i.key),
).not.toContain(beKey);
// --- Same authorisation as GraphQL ---
expect((await rest('token-eve', 'GET', `/issues/${beKey}`)).status).toBe(
404,
);
});
it('lets each project customise its workflow, types and areas, and measures it', async () => {
const key = 'S' + Date.now().toString(36).slice(-5).toUpperCase();
const projectId = (
await gql(
'token-ana',
`mutation($i: CreateProjectInput!) { createProject(input: $i) { id } }`,
{ i: { key, name: 'Custom workflow' } },
)
).createProject.id;
await eventually(
() =>
gql('token-ana', `query($k: String!) { project(idOrKey: $k) { id } }`, {
k: key,
}),
(d) => !!d.project,
);
await gql(
'token-ana',
`mutation($i: ProjectMemberInput!) { addProjectMember(input: $i) { version } }`,
{ i: { projectId, userId: '102', role: 'MEMBER' } },
);
// --- Every project starts with the default settings ---
const settingsQuery = `query($k: String!) { project(idOrKey: $k) { settings {
statuses { id name category color requiresReason archived }
transitions { from to }
issueTypes { id name icon color archived }
areas { id name archived }
} } }`;
const { settings } = (await gql('token-ana', settingsQuery, { k: key }))
.project;
expect(settings.statuses[0]).toEqual({
id: 'BACKLOG',
name: 'Backlog',
category: 'TODO',
color: 'stone',
requiresReason: false,
archived: false,
});
expect(settings.areas.map((a: { id: string }) => a.id)).toContain('MOBILE');
// --- A staging step, a Spike type, a Data area; Mobile archived ---
type Item = { id: string; archived?: boolean };
const reviewAt = settings.statuses.findIndex(
(s: Item) => s.id === 'IN_REVIEW',
);
settings.statuses.splice(reviewAt + 1, 0, {
id: 'STAGING',
name: 'En staging',
category: 'QA',
color: 'teal',
requiresReason: false,
archived: false,
});
settings.transitions = settings.transitions.map(
(t: { from: string; to: string[] }) =>
t.from === 'IN_REVIEW'
? { from: 'IN_REVIEW', to: ['STAGING', 'IN_PROGRESS'] }
: t,
);
settings.transitions.push({ from: 'STAGING', to: ['DONE', 'IN_PROGRESS'] });
settings.issueTypes.push({
id: 'SPIKE',
name: 'Spike',
icon: 'light-bulb',
color: 'teal',
archived: false,
});
settings.areas = settings.areas.map((a: Item) =>
a.id === 'MOBILE' ? { ...a, archived: true } : a,
);
settings.areas.push({ id: 'DATA', name: 'Datos', archived: false });
const save = (token: string, next: object) =>
gql(
token,
`mutation($p: ID!, $s: ProjectSettingsInput!) { updateProjectSettings(projectId: $p, settings: $s) { version } }`,
{ p: projectId, s: next },
);
await expect(save('token-bob', settings)).rejects.toThrow(/owner|admin/i);
await expect(
save('token-ana', {
...settings,
areas: settings.areas.filter((a: Item) => a.id !== 'QA'),
}),
).rejects.toThrow(/archívalo/);
await save('token-ana', settings);
await eventually(
() => gql('token-ana', settingsQuery, { k: key }),
(d) => d.project.settings.statuses.some((s: Item) => s.id === 'STAGING'),
);
// --- Tickets follow the project's settings ---
const create = `mutation($i: CreateIssueInput!) { createIssue(input: $i) { id key } }`;
await expect(
gql('token-bob', create, {
i: { projectId, type: 'EPIC', title: 'Unknown type' },
}),
).rejects.toThrow(/Unknown ticket type/);
await expect(
gql('token-bob', create, {
i: { projectId, type: 'TASK', title: 'Old area', area: 'MOBILE' },
}),
).rejects.toThrow(/archived/);
const spike = (
await gql('token-bob', create, {
i: {
projectId,
type: 'SPIKE',
title: 'Evaluate a column store',
area: 'DATA',
assigneeId: '102',
},
})
).createIssue;
const release = (
await gql(
'token-ana',
`mutation($i: CreateReleaseInput!) { createRelease(input: $i) { id } }`,
{ i: { projectId, name: 'v1.0.0' } },
)
).createRelease;
await eventually(
() =>
gql('token-ana', `query($id: ID!) { release(id: $id) { id } }`, {
id: release.id,
}),
(d) => !!d.release,
);
await gql(
'token-ana',
`mutation($i: ID!, $r: ID) { setIssueRelease(issueId: $i, releaseId: $r) { version } }`,
{ i: spike.id, r: release.id },
);
const move = (status: string) =>
gql(
'token-bob',
`mutation($i: ChangeIssueStatusInput!) { changeIssueStatus(input: $i) { version } }`,
{ i: { issueId: spike.id, status } },
);
for (const status of ['TODO', 'IN_PROGRESS', 'IN_REVIEW'])
await move(status);
const inReview = await eventually(
() =>
gql(
'token-bob',
`query($k: String!) { issue(idOrKey: $k) { status statusCategory allowedTransitions } }`,
{ k: spike.key },
),
(d) => d.issue.status === 'IN_REVIEW',
);
expect(inReview.issue).toEqual({
status: 'IN_REVIEW',
statusCategory: 'IN_PROGRESS',
allowedTransitions: ['STAGING', 'IN_PROGRESS'],
});
await expect(move('READY_FOR_QA')).rejects.toThrow(/Cannot move/);
await move('STAGING');
await move('DONE');
// --- Board, history, releases and statistics use the categories ---
const { project } = await eventually(
() =>
gql(
'token-ana',
`query($k: String!) { project(idOrKey: $k) {
board { status total }
stats { total open }
releases { progress { finished percent } burnup { scope done } }
analytics(days: 30) {
createdVsResolved { created resolved }
cumulativeFlow { series { status counts } }
cycleTime { count }
leadTime { count }
throughput { user { id } count }
qa { passed failed failureRate }
}
} }`,
{ k: key },
),
(d) => d.project.analytics.cycleTime.count === 1,
);
const columns = project.board.map((c: { status: string }) => c.status);
expect(columns.indexOf('STAGING')).toBe(columns.indexOf('IN_REVIEW') + 1);
expect(columns).not.toContain('CANCELLED');
expect(project.stats).toEqual({ total: 1, open: 0 });
expect(project.releases[0].progress).toEqual({ finished: 1, percent: 100 });
expect(project.releases[0].burnup.scope.at(-1)).toBe(1);
expect(project.releases[0].burnup.done.at(-1)).toBe(1);
const analytics = project.analytics;
const sum = (
rows: { created: number; resolved: number }[],
f: 'created' | 'resolved',
) => rows.reduce((total, row) => total + row[f], 0);
expect(sum(analytics.createdVsResolved, 'created')).toBe(1);
expect(sum(analytics.createdVsResolved, 'resolved')).toBe(1);
expect(analytics.cumulativeFlow.series).toEqual([
expect.objectContaining({ status: 'DONE' }),
]);
expect(analytics.leadTime.count).toBe(1);
expect(analytics.throughput).toEqual([{ user: { id: '102' }, count: 1 }]);
// STAGING is a QA-category step: leaving it to DONE is a QA pass.
expect(analytics.qa).toEqual({ passed: 1, failed: 0, failureRate: 0 });
const history = await gql(
'token-ana',
`query($k: String!) { issue(idOrKey: $k) { history { summary } } }`,
{ k: spike.key },
);
expect(
history.issue.history.map((h: { summary: string }) => h.summary),
).toContain(
`Bob (frontend) movió ${spike.key} de En revisión a En staging`,
);
const done = await gql(
'token-bob',
`{ myIssues(categories: [DONE]) { items { key } } }`,
);
expect(done.myIssues.items).toEqual(
expect.arrayContaining([{ key: spike.key }]),
);
// --- Same over REST ---
const viaRest = await rest('token-ana', 'GET', `/projects/${key}`);
expect(viaRest.body['settings']['transitions']).toEqual(
expect.arrayContaining([
{ from: 'STAGING', to: ['DONE', 'IN_PROGRESS'] },
]),
);
const renamed = viaRest.body['settings'];
renamed.statuses = renamed.statuses.map((s: Item & { name: string }) =>
s.id === 'STAGING' ? { ...s, name: 'Preproducción' } : s,
);
expect(
(await rest('token-ana', 'PUT', `/projects/${key}/settings`, renamed))
.status,
).toBe(200);
const removed = await rest(
'token-ana',
'PUT',
`/projects/${key}/settings`,
{ ...renamed, statuses: [] },
);
expect(removed.status).toBe(422);
expect(removed.body['message']).toMatch(/archívalo/);
const neon = await rest('token-ana', 'PUT', `/projects/${key}/settings`, {
...renamed,
areas: [...renamed.areas, { id: 'X', name: 'X', color: 'neon' }],
issueTypes: [
...renamed.issueTypes,
{ id: 'Y', name: 'Y', icon: 'bug', color: 'neon' },
],
});
expect(neon.status).toBe(400);
const stats = await rest(
'token-ana',
'GET',
`/projects/${key}/analytics?days=30`,
);
expect(stats.status).toBe(200);
expect(stats.body['cycleTime']['count']).toBe(1);
const burnup = await rest(
'token-ana',
'GET',
`/releases/${release.id}/burnup`,
);
expect(burnup.body).toMatchObject({ releaseId: release.id });
expect(
(await rest('token-eve', 'GET', `/projects/${key}/analytics`)).status,
).toBe(404);
});
it('opens public projects to anyone, read-only', async () => {
const key = 'P' + Date.now().toString(36).slice(-5).toUpperCase();
const created = await rest('token-ana', 'POST', '/projects', {
key,
name: 'Open roadmap',
isPublic: true,
});
expect(created.status).toBe(201);
const projectId = created.body['id'] as string;
await eventually(
() => rest(null, 'GET', `/projects/${key}`),
(r) => r.status === 200,
);
const issue = (
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'FEATURE',
title: 'Dark mode',
})
).body['key'] as string;
await eventually(
() => rest('token-ana', 'GET', `/issues/${issue}`),
(r) => r.status === 200,
);
await rest('token-ana', 'POST', `/issues/${issue}/comments`, {
body: 'Planned for Q4',
});
// REST without a token: project, tickets, comments, statistics, discovery.
const anonymous = await eventually(
() => rest(null, 'GET', `/issues/${issue}/comments`),
(r) => r.body?.['total'] === 1,
);
expect(anonymous.status).toBe(200);
expect((await rest(null, 'GET', `/projects/${key}`)).body).toMatchObject({
key,
isPublic: true,
});
expect((await rest(null, 'GET', `/issues/${issue}`)).status).toBe(200);
expect((await rest(null, 'GET', `/projects/${key}/analytics`)).status).toBe(
200,
);
const explore = await rest(null, 'GET', '/explore/projects');
expect(explore.body.map((p: { key: string }) => p.key)).toContain(key);
// GraphQL without a token, nested fields included.
const viaGraphql = await fetch(`${url}/graphql`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
query: `query($k: String!) {
publicProjects { key }
project(idOrKey: $k) { key isPublic board { status total } stats { total } releases { name } }
issues(filter: { projectId: "${projectId}" }) { total items { key comments { total } linkedIssues { key } } }
}`,
variables: { k: key },
}),
}).then((r) => r.json());
expect(viaGraphql.errors).toBeUndefined();
expect(viaGraphql.data.project).toMatchObject({
key,
isPublic: true,
stats: { total: 1 },
});
expect(viaGraphql.data.issues.items[0]).toMatchObject({
key: issue,
comments: { total: 1 },
});
// Reading is open, writing is not: anonymous → 401, outsiders → 403.
expect(
(
await rest(null, 'POST', `/projects/${key}/issues`, {
type: 'BUG',
title: 'x',
})
).status,
).toBe(401);
expect(
(
await rest('token-eve', 'POST', `/projects/${key}/issues`, {
type: 'BUG',
title: 'x',
})
).status,
).toBe(403);
expect((await rest('token-eve', 'GET', `/issues/${issue}`)).status).toBe(
200,
);
// A bad token is still rejected, even where none is needed.
expect((await rest('bad-token', 'GET', `/projects/${key}`)).status).toBe(
401,
);
// Back to private: anonymous readers get 404 again.
await rest('token-ana', 'PATCH', `/projects/${key}`, { isPublic: false });
await eventually(
() => rest(null, 'GET', `/projects/${key}`),
(r) => r.status === 404,
);
expect((await rest(null, 'GET', `/issues/${issue}`)).status).toBe(404);
});
it('deletes a project with everything in it (cascade saga) and frees its key', async () => {
const key = 'D' + Date.now().toString(36).slice(-5).toUpperCase();
const other = 'O' + Date.now().toString(36).slice(-5).toUpperCase();
for (const k of [key, other])
await rest('token-ana', 'POST', '/projects', { key: k, name: k });
await eventually(
() => rest('token-ana', 'GET', `/projects/${other}`),
(r) => r.status === 200,
);
await eventually(
() => rest('token-ana', 'GET', `/projects/${key}`),
(r) => r.status === 200,
);
await rest('token-ana', 'POST', `/projects/${key}/members`, {
userId: '102',
role: 'ADMIN',
});
const doomed = (
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'BUG',
title: 'Doomed',
})
).body['key'] as string;
const survivor = (
await rest('token-ana', 'POST', `/projects/${other}/issues`, {
type: 'TASK',
title: 'Survivor',
})
).body['key'] as string;
await eventually(
() => rest('token-ana', 'GET', `/issues/${survivor}`),
(r) => r.status === 200,
);
await eventually(
() => rest('token-ana', 'GET', `/issues/${doomed}`),
(r) => r.status === 200,
);
await rest('token-ana', 'POST', `/issues/${doomed}/comments`, {
body: 'bye',
});
await rest('token-ana', 'POST', `/projects/${key}/releases`, {
name: 'v1.0.0',
});
await rest('token-ana', 'POST', `/issues/${survivor}/links`, {
type: 'DEPENDS_ON',
targetIssue: doomed,
});
await eventually(
() => rest('token-ana', 'GET', `/issues/${survivor}`),
(r) => r.body?.['links']?.length === 1,
);
// Owner only, confirming the key.
expect(
(await rest('token-bob', 'DELETE', `/projects/${key}?confirm=${key}`))
.status,
).toBe(403);
expect(
(await rest('token-ana', 'DELETE', `/projects/${key}?confirm=NOPE`))
.status,
).toBe(422);
const deleted = await gql(
'token-ana',
`mutation($p: ID!, $k: String!) { deleteProject(projectId: $p, confirmKey: $k) { version } }`,
{
p: (await rest('token-ana', 'GET', `/projects/${key}`)).body['id'],
k: key.toLowerCase(),
},
);
expect(deleted.deleteProject.version).toBeGreaterThan(1);
await eventually(
() => rest('token-ana', 'GET', `/projects/${key}`),
(r) => r.status === 404,
);
// The saga deletes the tickets and the link from the other project.
await eventually(
() => rest('token-ana', 'GET', `/issues/${doomed}`),
(r) => r.status === 404,
);
await eventually(
() => rest('token-ana', 'GET', `/issues/${survivor}`),
(r) => r.body?.['links']?.length === 0,
);
const mine = await rest('token-bob', 'GET', '/projects');
expect(mine.body.map((p: { key: string }) => p.key)).not.toContain(key);
// The key can be used again.
expect(
(await rest('token-ana', 'POST', '/projects', { key, name: 'Reborn' }))
.status,
).toBe(201);
});
it('archiving a release lets its unfinished tickets go (saga)', async () => {
const key = 'A' + Date.now().toString(36).slice(-5).toUpperCase();
await rest('token-ana', 'POST', '/projects', { key, name: key });
await eventually(
() => rest('token-ana', 'GET', `/projects/${key}`),
(r) => r.status === 200,
);
const release = (
await rest('token-ana', 'POST', `/projects/${key}/releases`, {
name: 'v2.0.0',
})
).body['id'] as string;
await eventually(
() => rest('token-ana', 'GET', `/releases/${release}`),
(r) => r.status === 200,
);
const open = (
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'TASK',
title: 'Still open',
releaseId: release,
})
).body['key'] as string;
await eventually(
() => rest('token-ana', 'GET', `/issues/${open}`),
(r) => r.body?.['release']?.id === release,
);
expect(
(await rest('token-ana', 'POST', `/releases/${release}/archive`)).status,
).toBe(200);
const released = await eventually(
() => rest('token-ana', 'GET', `/issues/${open}`),
(r) => r.body?.['release'] === null,
);
expect(released.body['release']).toBeNull();
});
it('unassigns the open tickets of a user Pireon deactivates (saga)', async () => {
const key = 'U' + Date.now().toString(36).slice(-5).toUpperCase();
await rest('token-ana', 'POST', '/projects', { key, name: key });
await eventually(
() => rest('token-ana', 'GET', `/projects/${key}`),
(r) => r.status === 200,
);
// Dan signs in once (so the identity context knows him) and joins.
expect((await rest('token-dan', 'GET', '/users/me')).status).toBe(200);
await rest('token-ana', 'POST', `/projects/${key}/members`, {
userId: '105',
});
const ticket = (
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'TASK',
title: 'Dan has it',
assigneeId: '105',
})
).body['key'] as string;
await eventually(
() => rest('token-ana', 'GET', `/issues/${ticket}`),
(r) => r.body?.['assignee']?.id === '105',
);
const body = JSON.stringify({
event: 'user.deactivated',
data: { user_id: 105 },
});
const webhook = await fetch(`${url}/api/webhooks/pireon`, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-webhook-signature': createHmac('sha256', 'e2e-webhook-secret')
.update(body)
.digest('hex'),
},
body,
});
expect(webhook.status).toBe(204);
const unassigned = await eventually(
() => rest('token-ana', 'GET', `/issues/${ticket}`),
(r) => r.status === 200 && r.body['assignee'] === null,
);
expect(unassigned.body['assignee']).toBeNull();
const history = await rest('token-ana', 'GET', `/issues/${ticket}/history`);
expect(
history.body.map((h: { actor: { name: string } }) => h.actor.name),
).toContain('TaskStream');
});
}); });

View File

@@ -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);
});

6421
apps/api/openapi.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -13,20 +13,32 @@ type Activity {
type: String! type: String!
} }
type AreaOption {
archived: Boolean!
id: String!
name: String!
}
input AreaOptionInput {
archived: Boolean! = false
id: String!
name: String!
}
type BoardColumn { type BoardColumn {
issues: [Issue!]! issues: [Issue!]!
status: IssueStatus! status: String!
total: Int! total: Int!
} }
input ChangeIssueStatusInput { input ChangeIssueStatusInput {
issueId: ID! issueId: ID!
""" """Required by statuses with requiresReason"""
Required for QA_FAILED and CANCELLED
"""
reason: String reason: String
status: IssueStatus!
"""A status id"""
status: String!
} }
""" """
@@ -52,9 +64,7 @@ type CommentChange {
action: String! action: String!
actorId: ID! actorId: ID!
""" """Null when deleted"""
Null when deleted
"""
comment: Comment comment: Comment
commentId: ID! commentId: ID!
issueId: ID! issueId: ID!
@@ -69,9 +79,7 @@ type CommentPage {
type CountByAssignee { type CountByAssignee {
count: Int! count: Int!
""" """Null = unassigned"""
Null = unassigned
"""
user: UserRef user: UserRef
} }
@@ -82,18 +90,19 @@ type CountByKey {
type CountByStatus { type CountByStatus {
count: Int! count: Int!
key: IssueStatus!
"""Status id"""
key: String!
} }
input CreateIssueInput { input CreateIssueInput {
area: IssueArea """An area id"""
area: String
assigneeId: ID assigneeId: ID
description: String description: String
dueDate: String dueDate: String
""" """Figma frame for UI changes"""
Figma frame for UI changes
"""
figmaUrl: String figmaUrl: String
labels: [String!] labels: [String!]
priority: IssuePriority priority: IssuePriority
@@ -101,15 +110,18 @@ input CreateIssueInput {
releaseId: ID releaseId: ID
storyPoints: Int storyPoints: Int
title: String! title: String!
type: IssueType!
"""A ticket type id"""
type: String!
} }
input CreateProjectInput { input CreateProjectInput {
description: String description: String
""" """Readable by anyone, also without signing in"""
2-10 chars, e.g. "TS" → tickets TS-1, TS-2... isPublic: Boolean
"""
"""2-10 chars, e.g. "TS" → tickets TS-1, TS-2..."""
key: String! key: String!
name: String! name: String!
} }
@@ -117,20 +129,44 @@ input CreateProjectInput {
input CreateReleaseInput { input CreateReleaseInput {
description: String description: String
""" """e.g. v1.4.0"""
e.g. v1.4.0
"""
name: String! name: String!
projectId: ID! projectId: ID!
targetDate: String targetDate: String
} }
type CreatedResolved {
created: Int!
resolved: Int!
"""Monday of the week (YYYY-MM-DD)"""
week: String!
}
type CumulativeFlow {
dates: [String!]!
series: [FlowSeries!]!
}
type DurationStats {
averageDays: Float
count: Int!
"""85% of tickets take at most this long"""
p85Days: Float
}
type FlowSeries {
counts: [Int!]!
status: String!
}
type Issue { type Issue {
""" """Statuses this ticket can move to next (project's workflow)"""
Statuses this ticket can move to next allowedTransitions: [String!]!
"""
allowedTransitions: [IssueStatus!]! """An area id"""
area: IssueArea area: String
assignee: UserRef assignee: UserRef
commentCount: Int! commentCount: Int!
comments(limit: Int! = 50, offset: Int! = 0): CommentPage! comments(limit: Int! = 50, offset: Int! = 0): CommentPage!
@@ -139,21 +175,15 @@ type Issue {
dueDate: String dueDate: String
figmaUrl: String figmaUrl: String
""" """History of this ticket"""
History of this ticket
"""
history(limit: Int! = 50): [Activity!]! history(limit: Int! = 50): [Activity!]!
id: ID! id: ID!
""" """Human key, e.g. TS-42"""
Human key, e.g. TS-42
"""
key: String! key: String!
labels: [String!]! labels: [String!]!
""" """Linked tickets I can see"""
Linked tickets I can see
"""
linkedIssues: [Issue!]! linkedIssues: [Issue!]!
links: [IssueLink!]! links: [IssueLink!]!
number: Int! number: Int!
@@ -162,26 +192,19 @@ type Issue {
projectKey: String! projectKey: String!
release: ReleaseRef release: ReleaseRef
reporter: UserRef! reporter: UserRef!
status: IssueStatus!
"""A status id of the project's settings"""
status: String!
statusCategory: StatusCategory!
storyPoints: Int storyPoints: Int
title: String! title: String!
type: IssueType!
"""A ticket type id of the project's settings"""
type: String!
updatedAt: String! updatedAt: String!
version: Int! version: Int!
} }
"""
Team/layer: links FE ↔ BE ↔ QA work
"""
enum IssueArea {
BACKEND
DESIGN
DEVOPS
FRONTEND
MOBILE
QA
}
type IssueChange { type IssueChange {
action: String! action: String!
actorId: ID! actorId: ID!
@@ -190,26 +213,23 @@ type IssueChange {
} }
input IssueFilterInput { input IssueFilterInput {
areas: [IssueArea!] areas: [String!]
assigneeId: ID assigneeId: ID
categories: [StatusCategory!]
labels: [String!] labels: [String!]
priorities: [IssuePriority!] priorities: [IssuePriority!]
projectId: ID! projectId: ID!
releaseId: ID releaseId: ID
""" """Matches key, title or description"""
Matches key, title or description
"""
search: String search: String
statuses: [IssueStatus!] statuses: [String!]
types: [IssueType!] types: [String!]
unassigned: Boolean unassigned: Boolean
} }
type IssueLink { type IssueLink {
""" """OUTWARD: this issue → other. INWARD: other → this issue."""
OUTWARD: this issue → other. INWARD: other → this issue.
"""
direction: LinkDirection! direction: LinkDirection!
issueId: ID! issueId: ID!
issueKey: String! issueKey: String!
@@ -237,24 +257,20 @@ enum IssuePriority {
MEDIUM MEDIUM
} }
enum IssueStatus { type IssueTypeOption {
BACKLOG archived: Boolean!
CANCELLED color: String!
DONE icon: String!
IN_PROGRESS id: String!
IN_QA name: String!
IN_REVIEW
QA_FAILED
READY_FOR_QA
TODO
} }
enum IssueType { input IssueTypeOptionInput {
BUG archived: Boolean! = false
FEATURE color: String!
QA icon: String!
TASK id: String!
UI_CHANGE name: String!
} }
enum LinkDirection { enum LinkDirection {
@@ -269,53 +285,44 @@ input LinkIssuesInput {
} }
type Me { type Me {
""" """Roles inside the task-stream OAuth client"""
Roles inside the task-stream OAuth client
"""
appRoles: [String!]! appRoles: [String!]!
email: String! email: String!
id: ID! id: ID!
isActive: Boolean! isActive: Boolean!
name: String! name: String!
""" """Global roles in Pireon"""
Global roles in Pireon
"""
roles: [String!]! roles: [String!]!
} }
type Mutation { type Mutation {
addComment(body: String!, issueId: ID!): CommandResult! addComment(body: String!, issueId: ID!): CommandResult!
""" """Add a Pireon user to the project"""
Add a Pireon user to the project
"""
addProjectMember(input: ProjectMemberInput!): CommandResult! addProjectMember(input: ProjectMemberInput!): CommandResult!
archiveRelease(releaseId: ID!): CommandResult! archiveRelease(releaseId: ID!): CommandResult!
""" """Assign (or unassign with null) a project member"""
Assign (or unassign with null) a project member
"""
assignIssue(assigneeId: ID, issueId: ID!): CommandResult! assignIssue(assigneeId: ID, issueId: ID!): CommandResult!
""" """Move a ticket through the workflow (incl. QA)"""
Move a ticket through the workflow (incl. QA)
"""
changeIssueStatus(input: ChangeIssueStatusInput!): CommandResult! changeIssueStatus(input: ChangeIssueStatusInput!): CommandResult!
changeProjectMemberRole(input: ProjectMemberInput!): CommandResult! changeProjectMemberRole(input: ProjectMemberInput!): CommandResult!
createIssue(input: CreateIssueInput!): CommandResult! createIssue(input: CreateIssueInput!): CommandResult!
createProject(input: CreateProjectInput!): CommandResult! createProject(input: CreateProjectInput!): CommandResult!
createRelease(input: CreateReleaseInput!): CommandResult! createRelease(input: CreateReleaseInput!): CommandResult!
""" """Author or project admin"""
Author or project admin
"""
deleteComment(commentId: ID!): CommandResult! deleteComment(commentId: ID!): CommandResult!
editComment(body: String!, commentId: ID!): CommandResult!
""" """
Relate tickets, e.g. FE DEPENDS_ON BE, QA TESTS FE Delete the project (owner only), with its tickets, comments and releases. `confirmKey` must be the project key.
""" """
deleteProject(confirmKey: String!, projectId: ID!): CommandResult!
editComment(body: String!, commentId: ID!): CommandResult!
"""Relate tickets, e.g. FE DEPENDS_ON BE, QA TESTS FE"""
linkIssues(input: LinkIssuesInput!): CommandResult! linkIssues(input: LinkIssuesInput!): CommandResult!
""" """
@@ -324,43 +331,65 @@ type Mutation {
publishRelease(force: Boolean! = false, releaseId: ID!): CommandResult! publishRelease(force: Boolean! = false, releaseId: ID!): CommandResult!
removeProjectMember(projectId: ID!, userId: ID!): CommandResult! removeProjectMember(projectId: ID!, userId: ID!): CommandResult!
""" """Put a ticket in a release (null removes it)"""
Put a ticket in a release (null removes it)
"""
setIssueRelease(issueId: ID!, releaseId: ID): CommandResult! setIssueRelease(issueId: ID!, releaseId: ID): CommandResult!
unlinkIssues(input: LinkIssuesInput!): CommandResult! unlinkIssues(input: LinkIssuesInput!): CommandResult!
updateIssue(input: UpdateIssueInput!): CommandResult! updateIssue(input: UpdateIssueInput!): CommandResult!
updateProject(input: UpdateProjectInput!): CommandResult! updateProject(input: UpdateProjectInput!): CommandResult!
"""
Replace statuses, transitions, ticket types and areas (owners and admins). Values can be archived, not deleted.
"""
updateProjectSettings(projectId: ID!, settings: ProjectSettingsInput!): CommandResult!
updateRelease(input: UpdateReleaseInput!): CommandResult! updateRelease(input: UpdateReleaseInput!): CommandResult!
} }
type Project { type Project {
""" """
Kanban board, one column per status Statistics of the last `days` days (7-365): created vs resolved, cumulative flow, cycle/lead time, throughput and QA
""" """
board( analytics(days: Int! = 90): ProjectAnalytics!
area: IssueArea
assigneeId: ID """Kanban board, one column per status"""
perColumn: Int! = 50 board(area: String, assigneeId: ID, perColumn: Int! = 50, releaseId: ID): [BoardColumn!]!
releaseId: ID
): [BoardColumn!]!
createdAt: String! createdAt: String!
description: String description: String
id: ID! id: ID!
"""Anyone can read it, also without signing in"""
isPublic: Boolean!
key: String! key: String!
members: [ProjectMember!]! members: [ProjectMember!]!
name: String! name: String!
owner: UserRef! owner: UserRef!
releases: [Release!]! releases: [Release!]!
settings: ProjectSettings!
""" """Project tracking dashboard"""
Project tracking dashboard
"""
stats: ProjectStats! stats: ProjectStats!
updatedAt: String! updatedAt: String!
version: Int! version: Int!
} }
type ProjectAnalytics {
createdVsResolved: [CreatedResolved!]!
"""Tickets in each status at the end of every day"""
cumulativeFlow: CumulativeFlow!
"""Start of work → done"""
cycleTime: DurationStats!
from: String!
"""Creation → done"""
leadTime: DurationStats!
qa: QaStats!
"""Finished per person and week"""
throughput: [Throughput!]!
to: String!
}
type ProjectChange { type ProjectChange {
action: String! action: String!
actorId: ID! actorId: ID!
@@ -377,9 +406,7 @@ input ProjectMemberInput {
projectId: ID! projectId: ID!
role: ProjectRole! = MEMBER role: ProjectRole! = MEMBER
""" """Pireon user id"""
Pireon user id
"""
userId: ID! userId: ID!
} }
@@ -390,12 +417,28 @@ enum ProjectRole {
VIEWER VIEWER
} }
"""Statuses (board order), transitions, ticket types and areas"""
type ProjectSettings {
areas: [AreaOption!]!
issueTypes: [IssueTypeOption!]!
statuses: [WorkflowStatus!]!
transitions: [StatusTransitions!]!
}
"""
The whole configuration (replaces the previous one). Statuses go in board order.
"""
input ProjectSettingsInput {
areas: [AreaOptionInput!]!
issueTypes: [IssueTypeOptionInput!]!
statuses: [WorkflowStatusInput!]!
transitions: [StatusTransitionsInput!]!
}
type ProjectStats { type ProjectStats {
byArea: [CountByKey!]! byArea: [CountByKey!]!
""" """Open tickets per person"""
Open tickets per person
"""
byAssignee: [CountByAssignee!]! byAssignee: [CountByAssignee!]!
byPriority: [CountByKey!]! byPriority: [CountByKey!]!
byStatus: [CountByStatus!]! byStatus: [CountByStatus!]!
@@ -404,63 +447,64 @@ type ProjectStats {
total: Int! total: Int!
} }
type QaStats {
failed: Int!
failureRate: Float
passed: Int!
}
type Query { type Query {
""" """Project activity feed (newest first)"""
Project activity feed (newest first)
"""
activity( activity(
""" """ISO date cursor"""
ISO date cursor
"""
before: String before: String
issueId: ID issueId: ID
limit: Int! = 50 limit: Int! = 50
projectId: ID! projectId: ID!
): [Activity!]! ): [Activity!]!
issue( issue(
""" """Issue id or key (e.g. "TS-42")"""
Issue id or key (e.g. "TS-42")
"""
idOrKey: String! idOrKey: String!
): Issue! ): Issue!
issues( issues(filter: IssueFilterInput!, limit: Int! = 50, offset: Int! = 0): IssuePage!
filter: IssueFilterInput!
limit: Int! = 50
offset: Int! = 0
): IssuePage!
""" """The authenticated user"""
The authenticated user
"""
me: Me! me: Me!
""" """
Projects where I am a member Tickets across all my projects: assigned to me (default) or any (e.g. categories [QA])
""" """
myIssues(assignedToMe: Boolean! = true, categories: [StatusCategory!], limit: Int! = 50, offset: Int! = 0, statuses: [String!]): IssuePage!
"""Projects where I am a member"""
myProjects: [Project!]! myProjects: [Project!]!
"""A project of mine, or any public one (no sign-in needed)"""
project( project(
""" """Project id or key (e.g. "TS")"""
Project id or key (e.g. "TS")
"""
idOrKey: String! idOrKey: String!
): Project! ): Project!
"""Projects anyone can read, also without signing in"""
publicProjects: [Project!]!
release(id: ID!): Release! release(id: ID!): Release!
"""Planned releases of all my projects, soonest target date first"""
upcomingReleases(limit: Int! = 10): [Release!]!
user(id: String!): User user(id: String!): User
""" """Search users by name or email (to assign / add members)"""
Search users by name or email (to assign / add members)
"""
users(limit: Int! = 20, search: String! = ""): [User!]! users(limit: Int! = 20, search: String! = ""): [User!]!
} }
type Release { type Release {
"""Scope vs finished tickets, day by day"""
burnup: ReleaseBurnup!
createdAt: String! createdAt: String!
description: String description: String
id: ID! id: ID!
""" """Tickets included in this release"""
Tickets included in this release
"""
issues(limit: Int! = 100, offset: Int! = 0): IssuePage! issues(limit: Int! = 100, offset: Int! = 0): IssuePage!
name: String! name: String!
progress: ReleaseProgress! progress: ReleaseProgress!
@@ -471,6 +515,16 @@ type Release {
version: Int! version: Int!
} }
type ReleaseBurnup {
dates: [String!]!
done: [Int!]!
releaseId: ID!
"""Tickets in the release (not cancelled)"""
scope: [Int!]!
targetDate: String
}
type ReleaseChange { type ReleaseChange {
action: String! action: String!
actorId: ID! actorId: ID!
@@ -496,45 +550,66 @@ enum ReleaseStatus {
RELEASED RELEASED
} }
"""
What a status means, whatever each project calls it. DONE and CANCELLED are finished.
"""
enum StatusCategory {
CANCELLED
DONE
IN_PROGRESS
QA
TODO
}
type StatusTransitions {
from: String!
to: [String!]!
}
input StatusTransitionsInput {
from: String!
to: [String!]!
}
type Subscription { type Subscription {
""" """Live activity feed"""
Live activity feed
"""
activityAdded(projectId: ID!): Activity! activityAdded(projectId: ID!): Activity!
commentChanged(issueId: ID!): CommentChange! commentChanged(issueId: ID!): CommentChange!
""" """Tickets created/updated/moved in a project (board in realtime)"""
Tickets created/updated/moved in a project (board in realtime)
"""
issueChanged(projectId: ID!): IssueChange! issueChanged(projectId: ID!): IssueChange!
""" """Projects I am added to / removed from"""
Projects I am added to / removed from
"""
myProjectsChanged: ProjectChange! myProjectsChanged: ProjectChange!
projectChanged(projectId: ID!): ProjectChange! projectChanged(projectId: ID!): ProjectChange!
releaseChanged(projectId: ID!): ReleaseChange! releaseChanged(projectId: ID!): ReleaseChange!
} }
type Throughput {
count: Int!
user: UserRef
week: String!
}
input UpdateIssueInput { input UpdateIssueInput {
area: IssueArea """An area id"""
area: String
description: String description: String
dueDate: String dueDate: String
""" """Figma frame for UI changes"""
Figma frame for UI changes
"""
figmaUrl: String figmaUrl: String
issueId: ID! issueId: ID!
labels: [String!] labels: [String!]
priority: IssuePriority priority: IssuePriority
storyPoints: Int storyPoints: Int
title: String title: String
type: IssueType type: String
} }
input UpdateProjectInput { input UpdateProjectInput {
description: String description: String
isPublic: Boolean
name: String name: String
projectId: ID! projectId: ID!
} }
@@ -546,9 +621,7 @@ input UpdateReleaseInput {
targetDate: String targetDate: String
} }
""" """User managed by the Pireon identity server"""
User managed by the Pireon identity server
"""
type User { type User {
email: String! email: String!
id: ID! id: ID!
@@ -560,3 +633,28 @@ type UserRef {
id: ID! id: ID!
name: String! name: String!
} }
type WorkflowStatus {
"""No new tickets or transitions into it"""
archived: Boolean!
category: StatusCategory!
"""Colour token: slate, violet, green…"""
color: String!
"""Stable id stored on tickets, e.g. IN_REVIEW"""
id: String!
name: String!
"""Entering it needs a reason"""
requiresReason: Boolean!
}
input WorkflowStatusInput {
archived: Boolean! = false
category: StatusCategory!
color: String!
id: String!
name: String!
requiresReason: Boolean! = false
}

View File

@@ -23,9 +23,13 @@ export const configSchema = Joi.object({
// Pireon identity server // Pireon identity server
PIREON_BASE_URL: Joi.string().uri().default('https://oauth.pireon.dev'), PIREON_BASE_URL: Joi.string().uri().default('https://oauth.pireon.dev'),
PIREON_WEBHOOK_SECRET: Joi.string().allow('').optional(), PIREON_WEBHOOK_SECRET: Joi.string().allow('').optional(),
// OAuth client of task-stream in Pireon, used by the web app to sign in. // OAuth clients of task-stream in Pireon (each Pireon client allows one grant type):
// authorization_code for "Sign in with Pireon" / Swagger UI…
PIREON_CLIENT_ID: Joi.string().allow('').optional(), PIREON_CLIENT_ID: Joi.string().allow('').optional(),
PIREON_CLIENT_SECRET: Joi.string().allow('').optional(), PIREON_CLIENT_SECRET: Joi.string().allow('').optional(),
// …and password for email + password (defaults to the client above).
PIREON_PASSWORD_CLIENT_ID: Joi.string().allow('').optional(),
PIREON_PASSWORD_CLIENT_SECRET: Joi.string().allow('').optional(),
PIREON_SCOPES: Joi.string().default('users:read'), PIREON_SCOPES: Joi.string().default('users:read'),
PIREON_PASSWORD_GRANT: Joi.boolean().default(true), PIREON_PASSWORD_GRANT: Joi.boolean().default(true),
PIREON_TIMEOUT_MS: Joi.number().default(5000), PIREON_TIMEOUT_MS: Joi.number().default(5000),
@@ -33,4 +37,9 @@ export const configSchema = Joi.object({
GRAPHQL_PLAYGROUND: Joi.boolean().default(true), GRAPHQL_PLAYGROUND: Joi.boolean().default(true),
GRAPHQL_SCHEMA_FILE: Joi.string().allow('').default('apps/api/schema.gql'), GRAPHQL_SCHEMA_FILE: Joi.string().allow('').default('apps/api/schema.gql'),
// REST + OpenAPI for other apps: Swagger UI at /api/docs, JSON at /api/docs-json.
SWAGGER_ENABLED: Joi.boolean().default(true),
// Where to write the OpenAPI document on startup ('' = don't), like the GraphQL SDL.
OPENAPI_FILE: Joi.string().allow('').default('apps/api/openapi.json'),
}); });

View 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);
}

View File

@@ -5,12 +5,19 @@ import {
ServiceUnavailableException, ServiceUnavailableException,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectConnection } from '@nestjs/mongoose'; import { InjectConnection } from '@nestjs/mongoose';
import {
ApiOkResponse,
ApiOperation,
ApiServiceUnavailableResponse,
ApiTags,
} from '@nestjs/swagger';
import { Public } from '@task-stream/api/identity/presenters'; import { Public } from '@task-stream/api/identity/presenters';
import { REDIS_CLIENT } from '@task-stream/api/shared/infrastructure'; import { REDIS_CLIENT } from '@task-stream/api/shared/infrastructure';
import Redis from 'ioredis'; import Redis from 'ioredis';
import { Connection } from 'mongoose'; import { Connection } from 'mongoose';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
@ApiTags('Health')
@Controller('health') @Controller('health')
export class HealthController { export class HealthController {
constructor( constructor(
@@ -21,6 +28,13 @@ export class HealthController {
@Public() @Public()
@Get() @Get()
@ApiOperation({ summary: 'API and databases status' })
@ApiOkResponse({
schema: {
example: { status: 'ok', postgres: 'up', mongo: 'up', redis: 'up' },
},
})
@ApiServiceUnavailableResponse({ description: 'A database is down' })
async check() { async check() {
const checks = await Promise.allSettled([ const checks = await Promise.allSettled([
this.postgres.query('SELECT 1'), this.postgres.query('SELECT 1'),

158
apps/api/src/app/openapi.ts Normal file
View File

@@ -0,0 +1,158 @@
import { INestApplication } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import {
PIREON_BEARER,
PIREON_OAUTH,
} from '@task-stream/api/identity/presenters';
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
export const API_VERSION = '1.0.0';
const DESCRIPTION = `
REST API of **Pireon TaskStream** for other apps: projects, tickets (bugs, features,
UI changes, tasks and QA), workflow, comments, relations and releases.
### Authentication
Every endpoint (except \`/auth\`, \`/health\` and the webhook) needs a **Pireon access
token**, so any app or user that consumes it goes through Pireon's OAuth:
- **Authorization Code + PKCE** against \`oauth.pireon.dev/oauth/authorize\`
(the "Authorize" button here uses it; register \`<api>/api/docs/oauth2-redirect.html\`
as a redirect URI of the client).
- An app with its own Pireon OAuth client can get tokens from Pireon directly and
send them as \`Authorization: Bearer <token>\`.
What you can see and change depends on your role in each project.
### Writes are eventually consistent
Commands answer \`{ id, key?, version }\` as soon as the event is stored. Reads come from
a read model that catches up in milliseconds: a \`GET\` right after a write may still
show the previous version (or 404 right after a create).
### Real time
For live updates use the GraphQL subscriptions at \`/graphql\` (graphql-ws).
`.trim();
/**
* Builds the OpenAPI document from the REST controllers, serves Swagger UI and
* (in development) writes it to OPENAPI_FILE so other apps can generate clients.
*/
export function setupOpenApi(app: INestApplication): void {
const config = app.get(ConfigService);
if (!config.get<boolean>('SWAGGER_ENABLED')) return;
const pireon =
config.get<string>('PIREON_BASE_URL') ?? 'https://oauth.pireon.dev';
const scopes = (config.get<string>('PIREON_SCOPES') ?? '')
.split(/[\s,]+/)
.filter(Boolean);
const scopeDocs = Object.fromEntries(
scopes.map((scope) => [
scope,
scope === 'users:read' ? 'Search Pireon users' : scope,
]),
);
// Token requests go through the API's proxy: Pireon's /oauth/token has no
// CORS and the client secret stays on the server.
const tokenUrl = '/api/auth/token';
const document = SwaggerModule.createDocument(
app,
new DocumentBuilder()
.setTitle('Pireon TaskStream API')
.setDescription(DESCRIPTION)
.setVersion(API_VERSION)
.addServer('/', 'This server (the paths already include /api)')
.addOAuth2(
{
type: 'oauth2',
description: 'Pireon OAuth2 (Laravel Passport)',
flows: {
authorizationCode: {
authorizationUrl: `${pireon}/oauth/authorize`,
tokenUrl,
refreshUrl: tokenUrl,
scopes: scopeDocs,
},
...(config.get<boolean>('PIREON_PASSWORD_GRANT')
? {
password: {
tokenUrl,
refreshUrl: tokenUrl,
scopes: scopeDocs,
},
}
: {}),
},
},
PIREON_OAUTH,
)
.addBearerAuth(
{
type: 'http',
scheme: 'bearer',
description: 'A Pireon access token obtained elsewhere',
},
PIREON_BEARER,
)
.addTag('Projects', 'Projects, members, board, stats and activity')
.addTag(
'Issues',
'Tickets: search, create, workflow, relations, comments',
)
.addTag('Comments', 'Edit and delete comments')
.addTag('Releases', 'Versions that group tickets')
.addTag('Users', 'The signed-in user and the Pireon directory')
.addTag('Auth', 'Pireon OAuth token proxy')
.addTag('Health', 'Liveness of the API and its databases')
.addTag('Webhooks', 'Called by Pireon')
.build(),
);
// Operations without a security requirement are the public ones (@Public():
// health, auth, webhook). Say so explicitly instead of leaving it undefined.
for (const path of Object.values(document.paths)) {
for (const operation of [
path.get,
path.post,
path.put,
path.patch,
path.delete,
]) {
if (operation && !operation.security) operation.security = [];
}
}
const file = config.get<string>('OPENAPI_FILE');
if (file) {
writeFileSync(
join(process.cwd(), file),
JSON.stringify(document, null, 2) + '\n',
);
}
SwaggerModule.setup('api/docs', app, document, {
jsonDocumentUrl: 'api/docs-json',
yamlDocumentUrl: 'api/docs-yaml',
customSiteTitle: 'Pireon TaskStream API',
swaggerOptions: {
persistAuthorization: true,
initOAuth: {
clientId: config.get<string>('PIREON_CLIENT_ID') || undefined,
scopes,
usePkceWithAuthorizationCodeGrant: true,
},
},
// The Pireon look, as in the web app.
customCss: `
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap');
.swagger-ui, .swagger-ui .info .title, .swagger-ui .opblock-tag { font-family: Inter, system-ui, sans-serif; }
.swagger-ui .topbar { display: none; }
.swagger-ui .info .title { background: linear-gradient(100deg, #a347e1, #1879bf); -webkit-background-clip: text; background-clip: text; color: transparent; }
.swagger-ui .btn.authorize { color: #a347e1; border-color: #a347e1; }
.swagger-ui .btn.authorize svg { fill: #a347e1; }
`,
});
}

View File

@@ -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`);
} }

View File

@@ -1,4 +1,6 @@
# Same-origin API for the SPA: REST under /api, GraphQL (HTTP + WebSocket) under /graphql. # Same-origin API for the SPA: REST under /api, GraphQL (HTTP + WebSocket) under /graphql.
# The API is reached by its unique alias (task-stream-api), not by the service name `api`:
# on a shared network (Dokploy) another project's `api` would get half of the requests.
map $http_upgrade $connection_upgrade { map $http_upgrade $connection_upgrade {
default upgrade; default upgrade;
'' close; '' close;
@@ -9,13 +11,13 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
location /api/ { location /api/ {
proxy_pass http://api:3000; proxy_pass http://task-stream-api:3000;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
} }
location /graphql { location /graphql {
proxy_pass http://api:3000; proxy_pass http://task-stream-api:3000;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade; proxy_set_header Connection $connection_upgrade;

View File

@@ -2,23 +2,36 @@ import { authGuard } from '@task-stream/web/identity/presenters';
import { appRoutes } from './app.routes'; import { appRoutes } from './app.routes';
describe('appRoutes', () => { describe('appRoutes', () => {
it('keeps sign-in public and everything else behind the auth guard', () => { it('keeps home behind the auth guard, and public projects and «Explorar» open', () => {
const paths = appRoutes.map((route) => route.path); const paths = appRoutes.map((route) => route.path);
expect(paths).toEqual(expect.arrayContaining(['login', 'auth/callback', '', '**'])); expect(paths).toEqual(
expect.arrayContaining(['login', 'auth/callback', '', '**']),
);
const app = appRoutes.find((route) => route.path === '' && route.children); const app = appRoutes.find((route) => route.path === '' && route.children);
expect(app?.canActivate).toContain(authGuard); const children = app?.children ?? [];
expect(app?.children?.map((route) => route.path)).toEqual(['', 'p/:key']); expect(children.map((route) => route.path)).toEqual([
'',
'explorar',
'p/:key',
]);
expect(children[0].canActivate).toContain(authGuard);
expect(children[1].canActivate).toBeUndefined();
expect(children[2].canActivate).toBeUndefined();
}); });
it('exposes every project section', () => { it('exposes every project section', () => {
const project = appRoutes.find((route) => route.path === '')?.children?.find((route) => route.path === 'p/:key'); const project = appRoutes
.find((route) => route.path === '')
?.children?.find((route) => route.path === 'p/:key');
expect(project?.children?.map((route) => route.path)).toEqual([ expect(project?.children?.map((route) => route.path)).toEqual([
'', '',
'board', 'board',
'issues/:issueKey', 'issues/:issueKey',
'releases', 'releases',
'dashboard', 'dashboard',
'analytics',
'settings',
'activity', 'activity',
'members', 'members',
]); ]);

View File

@@ -1,12 +1,10 @@
import { Route } from '@angular/router'; import { Route } from '@angular/router';
import { import { identityRoutes } from '@task-stream/web/identity/presenters';
authGuard,
identityRoutes,
} from '@task-stream/web/identity/presenters';
import { trackerRoutes } from '@task-stream/web/tracker/presenters'; import { trackerRoutes } from '@task-stream/web/tracker/presenters';
export const appRoutes: Route[] = [ export const appRoutes: Route[] = [
...identityRoutes, ...identityRoutes,
{ path: '', canActivate: [authGuard], children: trackerRoutes }, // Not all behind the guard: public projects are readable without a session.
{ path: '', children: trackerRoutes },
{ path: '**', redirectTo: '' }, { path: '**', redirectTo: '' },
]; ];

View File

@@ -1,5 +1,13 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; import { toSignal } from '@angular/core/rxjs-interop';
import {
NavigationEnd,
Router,
RouterLink,
RouterLinkActive,
RouterOutlet,
} from '@angular/router';
import { filter, map } from 'rxjs';
import { AuthFacade } from '@task-stream/web/identity/application'; import { AuthFacade } from '@task-stream/web/identity/application';
import { UserMenuComponent } from '@task-stream/web/identity/presenters'; import { UserMenuComponent } from '@task-stream/web/identity/presenters';
import { AppShellComponent } from '@task-stream/web/shared/ui'; import { AppShellComponent } from '@task-stream/web/shared/ui';
@@ -27,8 +35,20 @@ import { AppShellComponent } from '@task-stream/web/shared/ui';
Proyectos Proyectos
</a> </a>
} }
<a
shellNav
class="ts-nav-link"
routerLink="/explorar"
routerLinkActive="is-active"
>
Explorar
</a>
@if (auth.isAuthenticated()) { @if (auth.isAuthenticated()) {
<ts-user-menu shellUser /> <ts-user-menu shellUser />
} @else if (auth.settled() && !onLogin()) {
<a shellUser class="ts-nav-link ts-nav-link--cta" routerLink="/login"
>Iniciar sesión</a
>
} }
<router-outlet /> <router-outlet />
</ts-app-shell> </ts-app-shell>
@@ -36,4 +56,13 @@ import { AppShellComponent } from '@task-stream/web/shared/ui';
}) })
export class App { export class App {
protected readonly auth = inject(AuthFacade); protected readonly auth = inject(AuthFacade);
private readonly router = inject(Router);
/** The login page has its own form: no "Iniciar sesión" button there. */
protected readonly onLogin = toSignal(
this.router.events.pipe(
filter((event) => event instanceof NavigationEnd),
map(() => this.router.url.startsWith('/login')),
),
{ initialValue: false },
);
} }

View File

@@ -114,6 +114,31 @@ html {
--ts-t-ui: #d6409f; --ts-t-ui: #d6409f;
--ts-t-task: #64748b; --ts-t-task: #64748b;
--ts-t-qa: #d97706; --ts-t-qa: #d97706;
// Palette of the per-project settings (statuses and ticket types pick one).
--ts-c-slate: #64748b;
--ts-c-stone: #8b8b9a;
--ts-c-violet: #a347e1;
--ts-c-blue: #1879bf;
--ts-c-sky: #0284c7;
--ts-c-teal: #0f766e;
--ts-c-green: #15803d;
--ts-c-amber: #b7791f;
--ts-c-orange: #ea580c;
--ts-c-red: #dc2626;
--ts-c-pink: #d6409f;
// Charts (validated with the dataviz checks against --ts-surface, light and
// dark): two categorical slots, an ordinal ramp for flow stages and the
// reserved good/critical status colours.
--ts-viz-1: #2a78d6;
--ts-viz-2: #eb6834;
--ts-viz-ramp-1: #86b6ef;
--ts-viz-ramp-2: #5598e7;
--ts-viz-ramp-3: #256abf;
--ts-viz-ramp-4: #104281;
--ts-viz-good: #0ca30c;
--ts-viz-critical: #d03b3b;
} }
html, html,
@@ -316,6 +341,19 @@ h3 {
} }
} }
/* Text for screen readers only (same helper as pireon.dev). */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
.ts-prewrap { .ts-prewrap {
white-space: pre-wrap; white-space: pre-wrap;
} }
@@ -444,6 +482,18 @@ h3 {
font-weight: 600; font-weight: 600;
background: var(--pi-color-primary-50); background: var(--pi-color-primary-50);
} }
// "Iniciar sesión" for visitors of public projects.
&--cta {
color: #fff;
font-weight: 600;
background: var(--ts-brand);
&:hover {
color: #fff;
filter: brightness(1.08);
}
}
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
@@ -501,6 +551,25 @@ html.dark-mode .pi-shell {
--ts-t-task: #94a3b8; --ts-t-task: #94a3b8;
--ts-t-qa: #fbbf24; --ts-t-qa: #fbbf24;
--ts-c-slate: #94a3b8;
--ts-c-stone: #8a91a4;
--ts-c-violet: #ae5de5;
--ts-c-blue: #339be6;
--ts-c-sky: #38bdf8;
--ts-c-teal: #2dd4bf;
--ts-c-green: #34c98a;
--ts-c-amber: #eab308;
--ts-c-orange: #fb923c;
--ts-c-red: #f07070;
--ts-c-pink: #f472b6;
--ts-viz-1: #3987e5;
--ts-viz-2: #d95926;
--ts-viz-ramp-1: #184f95;
--ts-viz-ramp-2: #2a78d6;
--ts-viz-ramp-3: #6da7ec;
--ts-viz-ramp-4: #b7d3f6;
--pi-color-grey-10: #12141a; --pi-color-grey-10: #12141a;
--pi-color-grey-25: #161922; --pi-color-grey-25: #161922;
--pi-color-grey-50: #1a1e28; --pi-color-grey-50: #1a1e28;

View File

@@ -0,0 +1,14 @@
# 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']
postgres:
ports: ['5432:5432']
mongo:
ports: ['27017:27017']
redis:
ports: ['6379:6379']

View File

@@ -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,24 +29,33 @@ services:
build: build:
context: . context: .
target: api target: api
secrets: [npmrc] secrets: [nexus_token]
image: task-stream-api image: task-stream-api
ports: ['3000:3000'] expose: ['3000']
networks:
default:
aliases: [task-stream-api]
environment: environment:
POSTGRES_URL: postgres://taskstream:taskstream@postgres:5432/taskstream POSTGRES_URL: postgres://taskstream:taskstream@task-stream-postgres:5432/taskstream
# No migrations yet: TypeORM creates the event store and lookup tables. # No migrations yet: TypeORM creates the event store and lookup tables.
POSTGRES_SYNCHRONIZE: 'true' POSTGRES_SYNCHRONIZE: 'true'
MONGO_URL: mongodb://mongo:27017/taskstream_read MONGO_URL: mongodb://task-stream-mongo:27017/taskstream_read
REDIS_URL: redis://redis:6379 REDIS_URL: redis://task-stream-redis:6379
EVENT_STREAM_KEY: task-stream:events EVENT_STREAM_KEY: task-stream:events
EVENTS_BRIDGE_ENABLED: 'true' EVENTS_BRIDGE_ENABLED: 'true'
PIREON_BASE_URL: https://oauth.pireon.dev PIREON_BASE_URL: https://oauth.pireon.dev
PIREON_WEBHOOK_SECRET: ${PIREON_WEBHOOK_SECRET:-} PIREON_WEBHOOK_SECRET: ${PIREON_WEBHOOK_SECRET:-}
PIREON_CLIENT_ID: ${PIREON_CLIENT_ID:-} PIREON_CLIENT_ID: ${PIREON_CLIENT_ID:-}
PIREON_CLIENT_SECRET: ${PIREON_CLIENT_SECRET:-} PIREON_CLIENT_SECRET: ${PIREON_CLIENT_SECRET:-}
# Pireon clients allow one grant type: the one above is authorization_code,
# this one password (email + password sign-in). Optional.
PIREON_PASSWORD_CLIENT_ID: ${PIREON_PASSWORD_CLIENT_ID:-}
PIREON_PASSWORD_CLIENT_SECRET: ${PIREON_PASSWORD_CLIENT_SECRET:-}
GRAPHQL_PLAYGROUND: 'true' GRAPHQL_PLAYGROUND: 'true'
# The SDL file is only useful in development (codegen). # The SDL / OpenAPI files are only useful in development (codegen); the
# document is still served at /api/docs-json.
GRAPHQL_SCHEMA_FILE: '' GRAPHQL_SCHEMA_FILE: ''
OPENAPI_FILE: ''
depends_on: depends_on:
postgres: { condition: service_healthy } postgres: { condition: service_healthy }
mongo: { condition: service_healthy } mongo: { condition: service_healthy }
@@ -45,11 +64,13 @@ services:
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 +79,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 +90,10 @@ services:
redis: redis:
image: redis:7-alpine image: redis:7-alpine
networks:
default:
aliases: [task-stream-redis]
command: ['redis-server', '--appendonly', 'yes'] command: ['redis-server', '--appendonly', 'yes']
ports: ['6379:6379']
volumes: [redis-data:/data] volumes: [redis-data:/data]
healthcheck: healthcheck:
test: ['CMD', 'redis-cli', 'ping'] test: ['CMD', 'redis-cli', 'ping']
@@ -81,5 +106,5 @@ volumes:
redis-data: redis-data:
secrets: secrets:
npmrc: nexus_token:
file: ${HOME}/.npmrc environment: NEXUS_TOKEN

View File

@@ -3,16 +3,21 @@
## Qué resuelve ## Qué resuelve
| Necesidad | Cómo se resuelve | | Necesidad | Cómo se resuelve |
| ---------------- | ----------------------------------------------------------------------------------------------------- | | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas | | 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas |
| 🌍 Públicos | Un proyecto público se puede leer sin iniciar sesión (web, GraphQL y REST): tablero, tickets, comentarios, releases, actividad y estadísticas; «Explorar» los lista |
| 🗑️ Borrar | El propietario borra un proyecto confirmando su clave; tickets, comentarios, releases y enlaces desde otros proyectos se borran en cascada (sagas) y la clave queda libre |
| 🧩 Configuración | Cada proyecto tiene sus estados (con categoría, color y motivo obligatorio), transiciones, tipos de ticket y áreas; lo usado se archiva, no se borra |
| 🎨 Cambios de UI | Tipo `UI_CHANGE` con enlace al frame de Figma (validado contra `figma.com`) | | 🎨 Cambios de UI | Tipo `UI_CHANGE` con enlace al frame de Figma (validado contra `figma.com`) |
| ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo | | ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo |
| 🧪 QA | Estados `READY_FOR_QA → IN_QA → DONE / QA_FAILED`; fallar QA exige un motivo | | 🧪 QA | Por defecto, `READY_FOR_QA → IN_QA → DONE / QA_FAILED`; fallar QA exige un motivo |
| 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) | | 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) |
| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona | | 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona |
| 💬 Comentarios | Decisiones y explicaciones dentro del ticket; el autor edita, el admin modera | | 💬 Comentarios | Decisiones y explicaciones dentro del ticket; el autor edita, el admin modera |
| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA) | | 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA) |
| 📊 Seguimiento | Panel con contadores, gráfico por estado, carga por persona, feed de actividad e historial por ticket | | 🏠 Inicio | «Mi trabajo»: mis tickets en todos los proyectos, por categoría (QA fallidos primero), lo que espera QA, próximas releases e «Ir a…» por clave |
| 📊 Seguimiento | Panel con contadores, flujo por estado, el equipo con sus tickets abiertos (enlaza a su tablero), feed de actividad e historial por ticket |
| 📈 Estadísticas | Creados vs. resueltos, flujo acumulado, burnup de releases, tiempos de ciclo y entrega, tasa de fallo en QA y resueltos por persona |
| ⚡ Tiempo real | Tablero, comentarios, actividad, releases y membresías se actualizan solos (GraphQL subscriptions) | | ⚡ Tiempo real | Tablero, comentarios, actividad, releases y membresías se actualizan solos (GraphQL subscriptions) |
Los **usuarios no se gestionan aquí**: vienen del servidor de identidad **Pireon** Los **usuarios no se gestionan aquí**: vienen del servidor de identidad **Pireon**

View File

@@ -112,6 +112,7 @@ sequenceDiagram
`@AutowiredEvent`) y lo entrega a los `@EventsHandler` registrados para su clase. A diferencia de `@AutowiredEvent`) y lo entrega a los `@EventsHandler` registrados para su clase. A diferencia de
`eventBus.subject$.next()`, **espera** a cada handler y procesa los eventos en orden; solo hace `XACK` cuando todos `eventBus.subject$.next()`, **espera** a cada handler y procesa los eventos en orden; solo hace `XACK` cuando todos
terminan. Si uno falla, el evento queda pendiente y se reintenta (y `XAUTOCLAIM` recupera los de instancias caídas). terminan. Si uno falla, el evento queda pendiente y se reintenta (y `XAUTOCLAIM` recupera los de instancias caídas).
- Después de los handlers, el bridge ejecuta también las **sagas** (ver abajo).
### Lectura: MongoDB ### Lectura: MongoDB
@@ -128,6 +129,34 @@ Tras actualizar Mongo, el handler publica en Redis pub/sub (`ChangeFeed`). Las s
canales (uno por proyecto, ticket o usuario), así que funcionan con varias instancias de la API. Como se publica canales (uno por proyecto, ticket o usuario), así que funcionan con varias instancias de la API. Como se publica
**después** de proyectar, quien recibe el aviso siempre lee el estado nuevo. **después** de proyectar, quien recibe el aviso siempre lee el estado nuevo.
### Sagas: reacciones entre agregados
Cuando un evento de un agregado debe cambiar otros agregados, lo hace una saga (`application/sagas`), con el `@Saga()`
estándar de `@nestjs/cqrs`: un observable que filtra eventos (`ofType`) y emite comandos. Como el `EventBus` en memoria
nunca ve los eventos guardados (su publisher es el event store), las sagas las ejecuta el **`EventsBridge`**: a cada
evento, tras las proyecciones, pasa ese evento solo (`of(event)`) por cada saga y ejecuta los comandos que emite, uno a
uno y esperando. Así heredan las garantías del bridge: orden, reintento y nada se pierde si la API se cae a mitad.
| Saga | Evento | Comandos |
| --------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------- |
| `ProjectDeletionSaga` | `ProjectDeletedEvent` | `DeleteIssueCommand` por cada ticket y `DeleteReleaseCommand` por cada release del proyecto |
| | `IssueDeletedEvent` | `PurgeCommentCommand` por cada comentario y `DropLinksToIssueCommand` en los tickets que lo enlazaban |
| `ReleaseArchivedSaga` | `ReleaseArchivedEvent` | `DetachIssueFromReleaseCommand` para sus tickets sin terminar (los terminados se quedan) |
| `UserDeactivatedSaga` | `UserDeactivatedEvent` (contexto identity) | `UnassignIssueCommand` para sus tickets abiertos en todos los proyectos, como actor `system` («TaskStream») |
Reglas que cumplen:
- **A quién afecta sale del event store**, no del read model (`TrackerStreamsRepository`, `ReleaseIssuesRepository`),
salvo la saga de usuarios, que busca tickets asignados ahora mismo en Mongo.
- **Comandos idempotentes**: un evento puede procesarse dos veces, así que cada comando comprueba si aún hay algo que
hacer (`WriteAccess.live()` devuelve `null` si el agregado no existe o ya está borrado; desasignar solo si sigue
asignado a esa persona…). Son comandos internos: ningún presenter los expone.
- **Nada bloquea el stream**: si el dominio rechaza un comando (no encontrado, regla de negocio…), el bridge lo registra
y sigue; los conflictos de concurrencia se reintentan hasta 5 veces y, si persisten, el evento queda pendiente.
- **Autoría**: los cambios se atribuyen a quien provocó el evento (quien borró el proyecto o archivó la release).
- **Entre contextos**: el webhook de Pireon guarda `UserDeactivatedEvent` en el event store (`EventRecorder`, stream
`identity-user-<id>`), y la saga del tracker reacciona a él sin que los contextos se llamen entre sí.
### Consistencia eventual ### Consistencia eventual
Las mutations devuelven `CommandResult { id, key, version }` en cuanto los eventos están guardados; Mongo se actualiza Las mutations devuelven `CommandResult { id, key, version }` en cuanto los eventos están guardados; Mongo se actualiza

View File

@@ -11,7 +11,8 @@ classDiagram
key: "TS" key: "TS"
name name
members: (userId, role)[] members: (userId, role)[]
addMember() changeMemberRole() removeMember() updateDetails() settings: estados · transiciones · tipos · áreas
addMember() changeMemberRole() removeMember() updateDetails() updateSettings()
} }
class Issue { class Issue {
key: "TS-42" key: "TS-42"
@@ -47,7 +48,59 @@ classDiagram
Cualquiera puede salir de un proyecto (salvo el propietario). Los no miembros reciben _not found_, para no revelar que Cualquiera puede salir de un proyecto (salvo el propietario). Los no miembros reciben _not found_, para no revelar que
el proyecto existe. el proyecto existe.
### Workflow de un ticket ### Proyectos públicos
Con `isPublic` (al crear, o después con `updateProject` / `PATCH /api/projects/{project}`, propietario o admin),
**cualquiera puede leer** el proyecto, también sin token: el proyecto y sus miembros, tablero, tickets, comentarios,
historial, releases (progreso y burnup), actividad, seguimiento y estadísticas. Escribir sigue siendo cosa de los
miembros según su rol (sin token: 401; con token pero sin ser miembro: 403). Un usuario con sesión que no es miembro
lee igual que un anónimo. Las subscriptions exigen sesión; con ella, también se pueden seguir proyectos públicos.
`publicProjects` (GraphQL) y `GET /api/explore/projects` (REST, sin token) los listan. Al volver a privado, quien no es
miembro vuelve a recibir _not found_. Los enlaces a tickets de otros proyectos solo se muestran si el visitante puede
leer esos proyectos.
### Borrar un proyecto
Solo el **propietario**, escribiendo la clave del proyecto para confirmar (`deleteProject(projectId, confirmKey)` o
`DELETE /api/projects/{project}?confirm=CLAVE`). El proyecto desaparece al momento y, a continuación, una saga borra
sus tickets (y sus comentarios), sus releases y los enlaces que tickets de otros proyectos tenían hacia los suyos (ver
[Arquitectura](02-arquitectura.md)). La clave queda libre para un proyecto nuevo. Los eventos se quedan en el event
store como auditoría, pero desde la API no se puede deshacer.
### Configuración por proyecto
Cada proyecto tiene sus **estados**, **transiciones**, **tipos de ticket** y **áreas** (`Project.settings`). Todos
empiezan con los valores por defecto de abajo, y el propietario o un admin los cambia con `updateProjectSettings` (o
`PUT /api/projects/{project}/settings`); el cambio es un evento más (`ProjectSettingsChangedEvent`).
| Valor | Campos | Por defecto |
| ------ | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| Estado | `id`, `name`, `category`, `color`, `requiresReason`, `archived` (el orden es el del tablero) | `BACKLOG`, `TODO`, `IN_PROGRESS`, `IN_REVIEW`, `READY_FOR_QA`, `IN_QA`, `QA_FAILED`, `DONE`, `CANCELLED` |
| Tipo | `id`, `name`, `icon`, `color`, `archived` | `BUG`, `FEATURE`, `UI_CHANGE`, `TASK`, `QA` |
| Área | `id`, `name`, `archived` | `FRONTEND`, `BACKEND`, `MOBILE`, `QA`, `DESIGN`, `DEVOPS` |
La **categoría** de un estado dice qué significa, se llame como se llame: `TODO` (pendiente), `IN_PROGRESS`, `QA`,
`DONE` y `CANCELLED`. Releases («todo terminado» = `DONE` o `CANCELLED`), «Mi trabajo», el seguimiento y las
estadísticas trabajan con categorías, así que siguen funcionando con estados propios. Los tickets guardan el id del
estado y, en el read model, su categoría (`Issue.statusCategory`).
Reglas:
- **No se borra lo que ya existe: se archiva.** Tickets, historial y estadísticas siguen apuntando a ese id. Un valor
archivado no se ofrece para tickets nuevos ni recibe transiciones; los tickets que ya lo tienen lo conservan.
- Los ids son estables (`A-Z`, `0-9`, `_`, hasta 30, empezando por letra); los nombres, de 1 a 40 caracteres, se
pueden cambiar cuando se quiera.
- Hace falta al menos un estado activo de categoría `TODO` (los tickets nuevos entran en el primero), uno `DONE` y un
tipo activo.
- Las transiciones solo pueden apuntar a estados que existen y nunca de un estado a sí mismo.
- Colores: `slate`, `stone`, `violet`, `blue`, `sky`, `teal`, `green`, `amber`, `orange`, `red`, `pink`. Iconos:
`bug`, `feature`, `palette`, `task`, `qa`, `flag`, `star-fill`, `rocket`, `document`, `light-bulb`, `key`, `support`.
Los proyectos creados antes de que existiera la configuración usan los valores por defecto (sus eventos no la
llevan). Al arrancar, la API rellena la categoría de los tickets del read model que no la tienen.
### Workflow por defecto
```mermaid ```mermaid
stateDiagram-v2 stateDiagram-v2
@@ -76,9 +129,10 @@ stateDiagram-v2
CANCELLED --> BACKLOG CANCELLED --> BACKLOG
``` ```
- `QA_FAILED` y `CANCELLED` exigen un **motivo**, que queda en el historial. - `QA_FAILED` y `CANCELLED` exigen un **motivo** (`requiresReason`), que queda en el historial.
- No se puede pasar a `DONE` saltándose QA salvo desde `IN_REVIEW` (cambios que no requieren QA). - No se puede pasar a `DONE` saltándose QA salvo desde `IN_REVIEW` (cambios que no requieren QA).
- `Issue.allowedTransitions` en GraphQL devuelve los estados permitidos desde el actual. - `Issue.allowedTransitions` devuelve los estados activos a los que puede pasar el ticket según el workflow de su
proyecto.
### Otras reglas ### Otras reglas
@@ -97,28 +151,55 @@ Todo cambio es un evento guardado en el event store (stream = id del agregado).
`aggregateId`, `projectId` y `aggregateVersion` (la posición en el stream). `aggregateId`, `projectId` y `aggregateVersion` (la posición en el stream).
| Agregado | Eventos | | Agregado | Eventos |
| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Project | `ProjectCreatedEvent`, `ProjectUpdatedEvent`, `ProjectMemberAddedEvent`, `ProjectMemberRoleChangedEvent`, `ProjectMemberRemovedEvent` | | Project | `ProjectCreatedEvent`, `ProjectUpdatedEvent`, `ProjectSettingsChangedEvent`, `ProjectMemberAddedEvent`, `ProjectMemberRoleChangedEvent`, `ProjectMemberRemovedEvent`, `ProjectDeletedEvent` |
| Issue | `IssueCreatedEvent`, `IssueUpdatedEvent`, `IssueStatusChangedEvent`, `IssueAssignedEvent`, `IssueLinkedEvent`, `IssueUnlinkedEvent`, `IssueReleaseChangedEvent` | | Issue | `IssueCreatedEvent`, `IssueUpdatedEvent`, `IssueStatusChangedEvent`, `IssueAssignedEvent`, `IssueLinkedEvent`, `IssueUnlinkedEvent`, `IssueReleaseChangedEvent`, `IssueDeletedEvent` |
| Comment | `CommentAddedEvent`, `CommentEditedEvent`, `CommentDeletedEvent` | | Comment | `CommentAddedEvent`, `CommentEditedEvent`, `CommentDeletedEvent` |
| Release | `ReleaseCreatedEvent`, `ReleaseUpdatedEvent`, `ReleaseShippedEvent`, `ReleaseArchivedEvent` | | Release | `ReleaseCreatedEvent`, `ReleaseUpdatedEvent`, `ReleaseShippedEvent`, `ReleaseArchivedEvent`, `ReleaseDeletedEvent` |
`IssueStatusChangedEvent` lleva además `meta` con el nombre y la categoría de los dos estados en el momento del
cambio, para que el historial y las estadísticas no cambien si luego se renombra o recategoriza un estado.
### Estadísticas
Una proyección más (`issue_timeline` en MongoDB) guarda cada paso de cada ticket: a qué estado pasó, con qué categoría,
quién lo tenía asignado, y cuándo entró o salió de una release. Las estadísticas se calculan sobre ella con funciones
puras (`application/src/lib/analytics`):
| Métrica | Definición |
| --------------------- | ----------------------------------------------------------------------------------------------------------------- |
| Creados vs. resueltos | Por semana (lunes, UTC): tickets creados y tickets que entraron en una categoría `DONE` |
| Flujo acumulado | Tickets en cada estado al final de cada día |
| Tiempo de ciclo | Del primer paso a `IN_PROGRESS` o `QA` hasta `DONE`; media y percentil 85 |
| Tiempo de entrega | De la creación hasta `DONE`; media y percentil 85 |
| Throughput | Tickets resueltos por semana y por la persona que los tenía asignados |
| QA | Salidas de un estado `QA`: a `DONE` cuenta como superado; a cualquier otro sitio salvo `CANCELLED`, como fallido |
| Burnup de una release | Por día, desde que se creó hasta que se publica (o hoy, máx. 180 días): tickets en ella (sin cancelados) y hechos |
Los tickets anteriores a esta proyección solo tienen su creación y su estado actual; para tener su historia completa,
reconstruye el read model (ver [Despliegue](07-despliegue.md)).
## 3.2 GraphQL ## 3.2 GraphQL
Todas las operaciones requieren `Authorization: Bearer <token de Pireon>` (en subscriptions, en el `connection_init`). Las operaciones requieren `Authorization: Bearer <token de Pireon>` (en subscriptions, en el `connection_init`), salvo
las lecturas de proyectos públicos (`project`, `issue`, `issues`, `release`, `activity`, `publicProjects` y sus campos),
que funcionan también sin token.
### Queries ### Queries
| Query | Devuelve | | Query | Devuelve |
| ----------------------------------------------- | ---------------------------------------------------------------------------------------------------- | | ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `me` | El usuario autenticado, con sus roles en Pireon | | `me` | El usuario autenticado, con sus roles en Pireon |
| `users(search, limit)` | Usuarios de Pireon (para asignar o añadir miembros) | | `users(search, limit)` | Usuarios de Pireon (para asignar o añadir miembros) |
| `user(id)` | Un usuario | | `user(id)` | Un usuario |
| `myProjects` | Proyectos de los que soy miembro | | `myProjects` | Proyectos de los que soy miembro |
| `project(idOrKey)` | Un proyecto. Campos calculados: `board(releaseId, assigneeId, area, perColumn)`, `stats`, `releases` | | `myIssues(assignedToMe, statuses, categories, limit, offset)` | «Mi trabajo»: tickets de todos mis proyectos, asignados a mí (por defecto) o de cualquiera (p. ej. `categories: [QA]`) |
| `upcomingReleases(limit)` | Releases planificadas de mis proyectos, por fecha objetivo |
| `publicProjects` | Proyectos públicos (sin token) |
| `project(idOrKey)` | Un proyecto (mío, o público) con su `settings`. Campos calculados: `board(releaseId, assigneeId, area, perColumn)`, `stats`, `analytics(days)`, `releases` |
| `issues(filter, limit, offset)` | Búsqueda de tickets (estado, tipo, área, prioridad, persona, release, etiquetas, texto) | | `issues(filter, limit, offset)` | Búsqueda de tickets (estado, tipo, área, prioridad, persona, release, etiquetas, texto) |
| `issue(idOrKey)` | Un ticket. Campos calculados: `allowedTransitions`, `linkedIssues`, `comments`, `history` | | `issue(idOrKey)` | Un ticket. Campos calculados: `allowedTransitions`, `linkedIssues`, `comments`, `history` |
| `release(id)` | Una release. Campos calculados: `progress`, `issues` | | `release(id)` | Una release. Campos calculados: `progress`, `burnup`, `issues` |
| `activity(projectId, issueId?, limit, before?)` | Feed de actividad, del más reciente al más antiguo | | `activity(projectId, issueId?, limit, before?)` | Feed de actividad, del más reciente al más antiguo |
### Mutations ### Mutations
@@ -126,8 +207,8 @@ Todas las operaciones requieren `Authorization: Bearer <token de Pireon>` (en su
Todas devuelven `CommandResult { id, key, version }`. Todas devuelven `CommandResult { id, key, version }`.
| Ámbito | Mutations | | Ámbito | Mutations |
| ----------- | ----------------------------------------------------------------------------------------------------------------- | | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Proyectos | `createProject`, `updateProject`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` | | Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease` | | Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease` |
| Comentarios | `addComment`, `editComment`, `deleteComment` | | Comentarios | `addComment`, `editComment`, `deleteComment` |
| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `archiveRelease` | | Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `archiveRelease` |
@@ -140,14 +221,23 @@ Todas devuelven `CommandResult { id, key, version }`.
| `commentChanged(issueId)` | Se añade, edita o borra un comentario (`comment` es `null` al borrar) | | `commentChanged(issueId)` | Se añade, edita o borra un comentario (`comment` es `null` al borrar) |
| `activityAdded(projectId)` | Hay una nueva entrada en el feed | | `activityAdded(projectId)` | Hay una nueva entrada en el feed |
| `releaseChanged(projectId)` | Cambia una release | | `releaseChanged(projectId)` | Cambia una release |
| `projectChanged(projectId)` | Cambian los datos o los miembros del proyecto | | `projectChanged(projectId)` | Cambian los datos, la configuración o los miembros del proyecto |
| `myProjectsChanged` | Me añaden a un proyecto o me quitan de uno | | `myProjectsChanged` | Me añaden a un proyecto o me quitan de uno |
Solo los miembros pueden suscribirse a un proyecto; las conexiones sin token válido se cierran con el código 4403. Solo los miembros pueden suscribirse a un proyecto; las conexiones sin token válido se cierran con el código 4403.
### Ejemplos ### Ejemplos
Estados, tipos y áreas son texto (ids de la configuración del proyecto), no enums: van entre comillas.
```graphql ```graphql
mutation ($settings: ProjectSettingsInput!) {
# La configuración entera (la que devuelve project.settings, cambiada).
updateProjectSettings(projectId: "…", settings: $settings) {
version
}
}
mutation { mutation {
createProject(input: { key: "SHOP", name: "Checkout revamp" }) { createProject(input: { key: "SHOP", name: "Checkout revamp" }) {
id id
@@ -157,7 +247,7 @@ mutation {
} }
mutation { mutation {
createIssue(input: { projectId: "…", type: BUG, title: "El botón de pagar no responde en Safari", area: FRONTEND, priority: CRITICAL, assigneeId: "102", labels: ["safari"] }) { createIssue(input: { projectId: "…", type: "BUG", title: "El botón de pagar no responde en Safari", area: "FRONTEND", priority: CRITICAL, assigneeId: "102", labels: ["safari"] }) {
id id
key key
version version
@@ -165,7 +255,7 @@ mutation {
} }
mutation { mutation {
changeIssueStatus(input: { issueId: "…", status: QA_FAILED, reason: "Sigue fallando en Safari 17" }) { changeIssueStatus(input: { issueId: "…", status: "QA_FAILED", reason: "Sigue fallando en Safari 17" }) {
version version
} }
} }
@@ -210,6 +300,27 @@ query {
percent percent
} }
} }
analytics(days: 90) {
createdVsResolved {
week
created
resolved
}
cycleTime {
averageDays
p85Days
}
qa {
failureRate
}
}
}
release(id: "…") {
burnup {
dates
scope
done
}
} }
issue(idOrKey: "SHOP-1") { issue(idOrKey: "SHOP-1") {
status status
@@ -265,13 +376,63 @@ Los errores de dominio llegan como errores GraphQL con `extensions.code`:
| `BUSINESS_RULE` | Regla de negocio (transición no permitida, release bloqueada…) | | `BUSINESS_RULE` | Regla de negocio (transición no permitida, release bloqueada…) |
| `CONFLICT` | Otro cambio llegó antes (concurrencia optimista): reintenta | | `CONFLICT` | Otro cambio llegó antes (concurrencia optimista): reintenta |
## 3.3 REST ## 3.3 REST con OpenAPI (para otras apps)
| Endpoint | Uso | Los mismos casos de uso que GraphQL, como API REST documentada con OpenAPI 3.0, para que otras apps de la plataforma
| --------------------------- | ----------------------------------------------------------------------------------------------- | la consuman o generen un cliente. Son otro adaptador de entrada (`presenters/http`) que llama a los mismos
| `GET /api/health` | Estado de PostgreSQL, MongoDB y Redis (público) | `*.service.ts`: misma validación, permisos y errores.
| `GET /api/auth/config` | Configuración para iniciar sesión con Pireon desde el navegador (público) |
| `POST /api/auth/token` | Proxy de `/oauth/token` de Pireon: `authorization_code`, `password` y `refresh_token` (público) |
| `POST /api/webhooks/pireon` | Webhooks de Pireon firmados con HMAC (`user.deactivated`, `token.revoked`) |
Ver [Autenticación](05-autenticacion.md). | | |
| ----------------- | ----------------------------------------------------------------------------------- |
| Swagger UI | `/api/docs` (con «Authorize» contra Pireon) |
| Documento OpenAPI | `/api/docs-json` (y `/api/docs-yaml`) |
| Fichero exportado | `apps/api/openapi.json`: lo reescribe `nx serve api` al arrancar, como `schema.gql` |
### Autenticación: siempre a través de Pireon
Cualquier app o usuario que la consuma pasa por el OAuth de Pireon: todas las rutas, salvo `health`, `auth`, el
webhook, `explore` y las lecturas de proyectos públicos, exigen `Authorization: Bearer <token de Pireon>` (el
`AuthGuard` global, igual que en GraphQL). En el documento OpenAPI, las rutas donde el token es opcional lo indican con
un requisito de seguridad vacío (`{}`). Declara dos esquemas:
- **`pireon-oauth`** (OAuth2): _Authorization Code + PKCE_ contra `oauth.pireon.dev/oauth/authorize` y, si
`PIREON_PASSWORD_GRANT=true`, _password_. El intercambio de tokens va por `POST /api/auth/token` (Pireon no tiene
CORS en `/oauth/token` y el `client_secret` se queda en el servidor). Para usar «Authorize» en Swagger UI, registra
`<api>/api/docs/oauth2-redirect.html` como redirect URI del cliente en Pireon.
- **`pireon`** (Bearer): para una app que ya tiene un token de Pireon, por ejemplo con su propio cliente OAuth.
Lo que cada usuario puede ver o cambiar depende de su rol en cada proyecto, como en la web.
### Recursos
Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
| Recurso | Rutas |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` |
| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `PUT …/settings`, `…/board`, `…/stats`, `…/analytics?days=`, `…/activity`, `…/members` (POST, PATCH, DELETE) |
| Tickets | `GET /api/me/issues` (mi trabajo), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history` |
| Comentarios | `PATCH/DELETE /api/comments/{commentId}` |
| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` |
| Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) |
| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` |
Los filtros de tickets aceptan `status` (ids del proyecto) y `category` (`?category=QA`), repetidos o separados por
comas. Las escrituras responden `{ id, key?, version }` en cuanto el evento se guarda; las lecturas salen del read model, que
se pone al día en milisegundos (un `GET` justo después puede devolver la versión anterior, o 404 tras crear). Para
tiempo real, las subscriptions de GraphQL.
### Errores
`{ statusCode, code?, message }`: `400` entrada no válida (`message` es una lista), `401` sin token o token rechazado
por Pireon, `403`/`404` sin acceso, `409` cambio concurrente (reintentar) y `422` regla de negocio (p. ej. una
transición que el workflow no permite).
### Generar un cliente en otra app
```sh
npx openapi-typescript apps/api/openapi.json -o src/task-stream-api.d.ts # tipos
# o un cliente completo: openapi-generator, orval, ng-openapi-gen…
```
Ver también [Autenticación](05-autenticacion.md).

View File

@@ -37,7 +37,7 @@ export const boardPageEvents = eventGroup({
source: 'Board Page', source: 'Board Page',
events: { events: {
opened: type<BoardFilter>(), opened: type<BoardFilter>(),
issueMoved: type<{ issueId: string; to: IssueStatus; reason?: string | null }>(), issueMoved: type<{ issueId: string; to: string; reason?: string | null }>(),
}, },
}); });
@@ -84,7 +84,7 @@ export class BoardFacade {
else this.dispatch.opened(filter); else this.dispatch.opened(filter);
} }
moveIssue(issueId: string, to: IssueStatus, reason?: string | null): void { moveIssue(issueId: string, to: string, reason?: string | null): void {
this.dispatch.issueMoved({ issueId, to, reason }); this.dispatch.issueMoved({ issueId, to, reason });
} }
} }
@@ -110,10 +110,12 @@ Qué gana la página:
- **Tests sin plantilla**: la facade se prueba con gateways falsos (`facades.spec.ts`). - **Tests sin plantilla**: la facade se prueba con gateways falsos (`facades.spec.ts`).
| Facade | Store | Ámbito | Responsabilidad | | Facade | Store | Ámbito | Responsabilidad |
| ---------------- | --------------- | ----------------------- | --------------------------------------------------------------------------------------------------------- | | ---------------- | --------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `AuthFacade` | `AuthStore` | raíz | Sesión, usuario, login (PKCE o email y contraseña de Pireon), renovación del token, restaurar al arrancar | | `AuthFacade` | `AuthStore` | raíz | Sesión, usuario, login (PKCE o email y contraseña de Pireon), renovación del token, restaurar al arrancar |
| `ProjectsFacade` | `ProjectsStore` | raíz | Mis proyectos; se actualiza cuando me añaden o quitan (`myProjectsChanged`) | | `ProjectsFacade` | `ProjectsStore` | raíz | Mis proyectos; se actualiza cuando me añaden o quitan (`myProjectsChanged`) |
| `ProjectFacade` | `ProjectStore` | ruta `/p/:key` | Proyecto, miembros, stats, releases, actividad en vivo y búsqueda de usuarios | | `HomeFacade` | `HomeStore` | raíz | «Mi trabajo» en la portada: mis tickets abiertos por categoría (QA fallidos primero), esperando QA y próximas releases; se recarga al volver a la pestaña |
| `ExploreFacade` | `ExploreStore` | raíz | Proyectos públicos («Explorar»), también sin sesión |
| `ProjectFacade` | `ProjectStore` | ruta `/p/:key` | Proyecto, miembros, stats, releases, actividad en vivo, búsqueda de usuarios, configuración (`workflow`, `saveSettings`) y estadísticas (`loadAnalytics`, `loadBurnup`) |
| `BoardFacade` | `BoardStore` | ruta `board` | Tablero kanban, filtros, movimiento optimista, nuevo ticket, cambios en vivo | | `BoardFacade` | `BoardStore` | ruta `board` | Tablero kanban, filtros, movimiento optimista, nuevo ticket, cambios en vivo |
| `IssueFacade` | `IssueStore` | ruta `issues/:issueKey` | Ticket, comentarios en vivo, historial y todas sus acciones | | `IssueFacade` | `IssueStore` | ruta `issues/:issueKey` | Ticket, comentarios en vivo, historial y todas sus acciones |
@@ -145,7 +147,9 @@ de identidad (`SessionTokenSource`) le pide el token para el cliente GraphQL.
npx nx run web:codegen # regenera generated.ts (TypedDocumentNode) npx nx run web:codegen # regenera generated.ts (TypedDocumentNode)
``` ```
- Los adaptadores convierten los tipos generados en modelos de dominio en un único sitio (`adapters/mappers.ts`). - Los adaptadores convierten los tipos generados en modelos de dominio en un único sitio (`adapters/mappers.ts`). Lo
que se lee y luego se reenvía como input (la configuración del proyecto) se copia campo a campo: Apollo añade
`__typename` a cada objeto y los input types de GraphQL lo rechazan.
## 4.4 `@pi/ui` ## 4.4 `@pi/ui`
@@ -193,19 +197,62 @@ versiones) y un color por estado (`--ts-s-*`) y por tipo de ticket (`--ts-t-*`),
gráficas e historial. Salen de `tone` en `ui/copy.ts` y las plantillas los aplican con `[style.--tone]`; utilidades gráficas e historial. Salen de `tone` en `ui/copy.ts` y las plantillas los aplican con `[style.--tone]`; utilidades
globales como `.ts-pill` o `.ts-key` leen esa variable. Las animaciones se desactivan con `prefers-reduced-motion`. globales como `.ts-pill` o `.ts-key` leen esa variable. Las animaciones se desactivan con `prefers-reduced-motion`.
### Configuración del proyecto en la UI
Estados, tipos y áreas son de cada proyecto, así que la UI nunca los tiene fijos: `ProjectFacade.workflow()` envuelve
`project.settings` (`Workflow` en el dominio web) y da nombre, color, icono, categoría, transiciones permitidas y si un
estado pide motivo. `statusLook`/`typeLook` de `ui/copy.ts` lo convierten en etiqueta y tono (`--ts-c-<color>`, con su
versión oscura). Un id que ya no está configurado sigue mostrándose (`IN_REVIEW` → «In review»). «Mi trabajo» mezcla
proyectos: cada fila usa el workflow de su proyecto y los grupos son por categoría.
En el tablero, un ticket solo se puede soltar en las columnas a las que su workflow le deja pasar, y los tipos
archivados no se ofrecen al crear. Los tipos con icono `palette` piden un frame de Figma.
### Gráficas
Las de Estadísticas son SVG propios (`ui/series-chart.ts`), no `pi-line-chart`: hacen falta áreas apiladas y una
paleta con versión oscura, que los gráficos de `@pi/ui` (Chart.js con colores fijos) no traen. Siguen las reglas de
visualización de la plataforma:
- Colores `--ts-viz-*` en `styles.scss`, validados contra la superficie clara y la oscura (separación para daltonismo,
contraste): dos series categóricas (azul y naranja), una rampa ordinal de un solo tono para las etapas del flujo y
bueno/crítico reservados para QA superado/fallido, siempre con icono y texto.
- Líneas de 2 px, rejilla de 1 px continua, separación de 2 px entre bandas apiladas, una sola escala por gráfica.
- Cruceta que se ajusta al día o semana más cercano y un único tooltip con todas las series; con teclado, ← → Inicio
Fin sobre la gráfica.
- Leyenda con dos o más series, etiqueta de valor al final de cada línea solo si no se solapan, y «Ver los datos» con
la tabla, para que nada dependa del color ni del ratón.
- Mientras se recalcula un periodo, se queda la gráfica anterior atenuada.
### Proyectos públicos y visitantes sin sesión
- Solo la portada (`/`) exige sesión. `/explorar` y las páginas de proyecto (`/p/:key/…`) no: la API decide qué ve
cada visitante, y las páginas ya ocultan lo que no puede cambiar (`canContribute`, `canManage`).
- Sin sesión, la cabecera muestra «Explorar» e «Iniciar sesión»; dentro de un proyecto público aparece un aviso de modo
lectura, y en uno privado (o inexistente), el enlace para iniciar sesión volviendo a esa página (`returnUrl`).
- El tiempo real necesita sesión: `GraphqlRealtimeGateway` no abre subscriptions sin token (si lo hiciera, la API
cerraría el WebSocket y el cliente reintentaría sin fin). Los visitantes anónimos ven los datos al cargar la página.
- Configuración tiene el interruptor «Público» (propietario y admins) y, solo para el propietario, «Eliminar el
proyecto», que exige escribir la clave. Al borrarlo se vuelve a la portada; si otro lo borra mientras lo tienes
abierto, la subscription avisa (`ProjectChanged.deleted`) y también se vuelve a la portada.
## 4.6 Rutas ## 4.6 Rutas
| Ruta | Página | | Ruta | Página |
| -------------------------- | ----------------------------------------------- | | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/login` | Inicio de sesión (solo sin sesión) | | `/login` | Inicio de sesión (solo sin sesión) |
| `/auth/callback` | Retorno de Pireon (PKCE) | | `/auth/callback` | Retorno de Pireon (PKCE) |
| `/` | Mis proyectos | | `/` | Inicio: «Ir a…» (`WEB-12`), mi trabajo (`ts-my-work`) y mis proyectos |
| `/p/:key/board` | Tablero kanban (`?release=` filtra por release) | | `/explorar` | Proyectos públicos (sin sesión) |
| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros) |
| `/p/:key/issues/:issueKey` | Ticket | | `/p/:key/issues/:issueKey` | Ticket |
| `/p/:key/releases` | Releases | | `/p/:key/releases` | Releases |
| `/p/:key/dashboard` | Seguimiento | | `/p/:key/dashboard` | Seguimiento: cifras, flujo por estado, equipo (cada persona enlaza a su tablero) y tipos |
| `/p/:key/analytics` | Estadísticas: creados vs. resueltos, flujo acumulado, burnup de una release, tiempos de ciclo y entrega, QA y resueltos por persona (30 días a 1 año) |
| `/p/:key/settings` | Configuración (propietario y admins; pestaña oculta para el resto): estados (orden, categoría, color, motivo), transiciones, tipos y áreas; archivar en lugar de borrar |
| `/p/:key/activity` | Actividad en vivo | | `/p/:key/activity` | Actividad en vivo |
| `/p/:key/members` | Miembros | | `/p/:key/members` | Miembros |
Todo salvo `/login` y `/auth/callback` está protegido por `authGuard`. Los parámetros de ruta llegan como inputs de Solo `/` está protegida por `authGuard` (redirige a `/login?returnUrl=…`); `/login` y `/auth/callback` son para quien
no tiene sesión, y el resto se puede abrir sin ella si el proyecto es público. Los parámetros de ruta llegan como inputs de
los componentes (`withComponentInputBinding`). los componentes (`withComponentInputBinding`).

View File

@@ -29,7 +29,11 @@ sequenceDiagram
- Se cachea el **hash SHA-256** del token, nunca el token en claro. - Se cachea el **hash SHA-256** del token, nunca el token en claro.
- Si Pireon marca al usuario como inactivo, el token se rechaza. - Si Pireon marca al usuario como inactivo, el token se rechaza.
- El guard es global: todo requiere token salvo lo marcado con `@Public()` (health, configuración de login, proxy de - El guard es global: todo requiere token salvo lo marcado con `@Public()` (health, configuración de login, proxy de
tokens y webhook). tokens, webhook y `GET /api/explore/projects`).
- `@OptionalAuth()` (o `@ApiOptionalPireonAuth()` en REST) deja pasar sin token: es para las lecturas que permiten los
proyectos públicos (proyecto, tablero, tickets, comentarios, releases, actividad, estadísticas). Sin token, el
usuario es `undefined` y el visitante es anónimo; un token presente pero inválido sigue dando 401. Las escrituras, las
subscriptions y lo «mío» (`myProjects`, `myIssues`, `/api/me/…`) siguen exigiendo sesión.
- En subscriptions, el token se valida una vez al abrir el WebSocket (`connection_init`). - En subscriptions, el token se valida una vez al abrir el WebSocket (`connection_init`).
- `@RequireAppRoles('…')` permite exigir roles del cliente OAuth en Pireon (los admins globales de Pireon los tienen todos). - `@RequireAppRoles('…')` permite exigir roles del cliente OAuth en Pireon (los admins globales de Pireon los tienen todos).
@@ -38,27 +42,70 @@ sequenceDiagram
Pireon **no permite CORS** en `/oauth/token` y el `client_secret` no debe llegar al navegador, así que la web pasa por Pireon **no permite CORS** en `/oauth/token` y el `client_secret` no debe llegar al navegador, así que la web pasa por
la API: `POST /api/auth/token` hace de proxy hacia Pireon. la API: `POST /api/auth/token` hace de proxy hacia Pireon.
| Método | Necesita | Flujo | Cada cliente OAuth de Pireon admite **un único tipo de grant**, así que cada forma de entrar usa su cliente:
| ------------------------------------------------- | ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| **Entrar con Pireon** (Authorization Code + PKCE) | `PIREON_CLIENT_ID`, `PIREON_CLIENT_SECRET` y la redirect URI registrada | Web → `/oauth/authorize` de Pireon → `/auth/callback` → `POST /api/auth/token` (`authorization_code` + `code_verifier`) |
| **Email y contraseña** | El mismo cliente, con `PIREON_PASSWORD_GRANT=true` | `POST /api/auth/token` (`password`) |
Las dos opciones necesitan el cliente OAuth: sin `PIREON_CLIENT_ID` la API responde `503` y el login avisa de que no | Método | Cliente de Pireon (`grant_type`) | Variables | Flujo |
está configurado. El email y la contraseña son los de la cuenta de Pireon; TaskStream no los guarda, solo los reenvía. | ------------------------------------------------- | -------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------- |
(El login con token de acceso personal se ha retirado de la web por ahora; la API sigue aceptando esos tokens como | **Entrar con Pireon** (Authorization Code + PKCE) | `authorization_code` | `PIREON_CLIENT_ID`, `PIREON_CLIENT_SECRET` | Web → `/oauth/authorize` de Pireon → `/auth/callback` → `POST /api/auth/token` (`authorization_code` + `code_verifier`) |
`Bearer`.) | **Email y contraseña** | `password` | `PIREON_PASSWORD_CLIENT_ID`, `PIREON_PASSWORD_CLIENT_SECRET` | `POST /api/auth/token` (`password`); se desactiva con `PIREON_PASSWORD_GRANT=false` |
Si no se definen las variables `PIREON_PASSWORD_CLIENT_*`, el login con contraseña usa `PIREON_CLIENT_ID` (sirve si
solo hay un cliente de tipo `password`, pero entonces «Entrar con Pireon» falla con `unauthorized_client`). Al renovar
la sesión, la API prueba los dos clientes: un `refresh_token` solo vale con el cliente que lo emitió.
El email y la contraseña son los de la cuenta de Pireon; TaskStream no los guarda, solo los reenvía. (El login con
token de acceso personal se ha retirado de la web por ahora; la API sigue aceptando esos tokens como `Bearer`.)
### Registrar TaskStream como cliente en Pireon ### Registrar TaskStream como cliente en Pireon
1. Crea el cliente: `POST https://oauth.pireon.dev/api/clients` (con un token tuyo de Pireon, scope `account:write`). Con un token tuyo de Pireon (scope `account:write`), `POST https://oauth.pireon.dev/api/clients`:
2. Registra como redirect URIs:
- `http://localhost:4200/auth/callback` (desarrollo)
- `http://localhost:8080/auth/callback` (Docker)
- la de producción
3. Pon `PIREON_CLIENT_ID` y `PIREON_CLIENT_SECRET` en `.env` (o en el entorno del contenedor).
`GET /api/auth/config` devuelve a la web lo necesario para iniciar el flujo (`authorizeUrl`, `clientId`, `scopes`, 1. **Cliente para «Entrar con Pireon»**: `{"name": "TaskStream", "grant_type": "authorization_code", "confidential": true,
`passwordGrant`), o `null` si no hay cliente configurado; en ese caso el login muestra el aviso y no deja entrar. "redirect_uris": [...]}` con estas redirect URIs:
- `http://localhost:4200/auth/callback` (desarrollo) y `http://localhost:8080/auth/callback` (Docker)
- `https://taskstream.pireon.dev/auth/callback` (producción)
- para el «Authorize» de Swagger UI: `http://localhost:3000/api/docs/oauth2-redirect.html` y
`https://api.taskstream.pireon.dev/api/docs/oauth2-redirect.html` (ver
[API REST](03-api.md#33-rest-con-openapi-para-otras-apps))
→ `PIREON_CLIENT_ID` y `PIREON_CLIENT_SECRET`. Al ser confidencial, **el secret es obligatorio**: sin él Pireon
rechaza el canje del código (ver la tabla de errores más abajo).
2. **Cliente para email y contraseña**: `{"name": "TaskStream (password)", "grant_type": "password"}`
→ `PIREON_PASSWORD_CLIENT_ID` y `PIREON_PASSWORD_CLIENT_SECRET`.
Pireon muestra el secret (`plain_secret`) **una sola vez**, en la respuesta de alta. Si se pierde, se genera otro con
`POST https://oauth.pireon.dev/api/clients/{client}/rotate-secret` (el anterior sigue valiendo 48 h).
Ponlos en `.env` (o en el entorno del contenedor; en Dokploy, en _Environment_) y reinicia la API; en Docker/Dokploy,
**recrea** el contenedor (redesplegar), porque un reinicio no relee el entorno.
En producción:
| Servicio | URL | Redirect URIs registradas en el cliente `authorization_code` |
| ---------- | ----------------------------------- | ----------------------------------------------------------------- |
| Web | `https://taskstream.pireon.dev` | `https://taskstream.pireon.dev/auth/callback` |
| API (REST) | `https://api.taskstream.pireon.dev` | `https://api.taskstream.pireon.dev/api/docs/oauth2-redirect.html` |
`GET /api/auth/config` le dice a la web qué formas de entrar hay: `clientId` del cliente `authorization_code` (o `null`:
la web oculta «Entrar con Pireon») y `passwordGrant`. Devuelve `null` si no hay ningún cliente; en ese caso el login
muestra el aviso y no deja entrar.
### Si el login falla
La API reenvía el error de Pireon con su `hint` entre paréntesis (lo que de verdad falla) y lo registra en su log como
`Pireon rejected <grant> for client <id>: … (hint: …)`.
| Error | Causa | Solución |
| ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| El login dice «Inicio de sesión no configurado» | `GET /api/auth/config` devuelve `null`: no hay ningún cliente en el entorno de la API | Define `PIREON_CLIENT_ID` y/o `PIREON_PASSWORD_CLIENT_ID` (con sus secrets) y recrea el contenedor |
| `unauthorized_client`: _not authorized to use this authorization grant type_ | El cliente no es del tipo que pide el flujo (p. ej. uno `password` en `PIREON_CLIENT_ID`) | Un cliente `authorization_code` en `PIREON_CLIENT_ID` y uno `password` en `PIREON_PASSWORD_CLIENT_ID` |
| `invalid_request` … _(Check the `client_secret` parameter)_ | El cliente es confidencial y la API no envía secret: la variable está vacía o no llega | Pon el secret en `PIREON_CLIENT_SECRET` (o `PIREON_PASSWORD_CLIENT_SECRET`) y redespliega |
| `invalid_client`: _Client authentication failed_ | El secret llega pero no es el de ese cliente | Revisa que id y secret sean del mismo cliente; si no lo tienes, rótalo |
| `invalid_request` … _(Invalid redirect URI)_ | La redirect URI del canje no es la de la autorización | Regístrala exactamente igual en el cliente (esquema, dominio y ruta, sin barra final) |
| `invalid_grant`: _Authorization code has expired / has been revoked_ | El código caducó o ya se usó (p. ej. al recargar `/auth/callback`) | Vuelve a «Entrar con Pireon» |
| `The user credentials were incorrect.` | Email o contraseña de Pireon incorrectos | — |
| Errores `404 Cannot GET/POST /api/…` intermitentes a través de la web | Otro `api` en la red compartida de Dokploy recibe parte de las peticiones | Ya resuelto con alias únicos (`task-stream-api`…), ver [Despliegue](07-despliegue.md#nombres-únicos-en-la-red) |
### Sesión en la web ### Sesión en la web
@@ -77,7 +124,9 @@ caduque la caché):
2. Pon el secret que devuelve en `PIREON_WEBHOOK_SECRET`. 2. Pon el secret que devuelve en `PIREON_WEBHOOK_SECRET`.
La API verifica la firma `X-Webhook-Signature` (HMAC-SHA256 del cuerpo), marca al usuario como inactivo y borra sus La API verifica la firma `X-Webhook-Signature` (HMAC-SHA256 del cuerpo), marca al usuario como inactivo y borra sus
sesiones cacheadas. Sin `PIREON_WEBHOOK_SECRET`, el endpoint rechaza todas las llamadas. sesiones cacheadas. Además guarda `UserDeactivatedEvent` en el event store, y una saga del tracker deja sin asignar sus
tickets abiertos en todos los proyectos (ver [Arquitectura](02-arquitectura.md)). Sin `PIREON_WEBHOOK_SECRET`, el
endpoint rechaza todas las llamadas.
## 5.4 Usuarios en TaskStream ## 5.4 Usuarios en TaskStream

View File

@@ -86,8 +86,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) |

View File

@@ -9,28 +9,63 @@ docker compose logs -f api
docker compose down # parar (-v borra los datos) docker compose down # parar (-v borra los datos)
``` ```
| Servicio | Imagen | Puerto | Función | | Servicio | Imagen | Puerto en local | Función |
| ---------- | --------------------------- | ------ | ----------------------------------------------------------------------- | | ---------- | --------------------------- | --------------- | ----------------------------------------------------------------------- |
| `web` | `task-stream-web` (nginx) | 8080 | SPA y proxy de `/api` y `/graphql` (incluido el WebSocket) hacia la API | | `web` | `task-stream-web` (nginx) | 8080 | SPA y proxy de `/api` y `/graphql` (incluido el WebSocket) hacia la API |
| `api` | `task-stream-api` (Node 22) | 3000 | API NestJS | | `api` | `task-stream-api` (Node 22) | 3000 | API NestJS |
| `postgres` | postgres:17-alpine | 5432 | Event store y lookups | | `postgres` | postgres:17-alpine | 5432 | Event store y lookups |
| `mongo` | mongo:8 | 27017 | Read model | | `mongo` | mongo:8 | 27017 | Read model |
| `redis` | redis:7-alpine (AOF) | 6379 | Stream de eventos, pub/sub y caché de sesiones | | `redis` | redis:7-alpine (AOF) | 6379 | Stream de eventos, pub/sub y caché de sesiones |
### Producción y desarrollo
- **`docker-compose.yml`** es la definición de producción (la que despliega Dokploy) y **no publica puertos**: los
servicios solo se ven entre sí por la red de Compose. Hacia fuera solo sale la web, por el proxy de la plataforma
(en Dokploy: dominio → servicio `web`, puerto `80`). `web` y `api` declaran `expose` para documentarlo.
- **`docker-compose.override.yml`** publica los puertos de la tabla. Compose lo carga solo cuando se ejecuta sin `-f`,
es decir, en local; Dokploy usa `-f docker-compose.yml` y no lo lee.
Para probar en local exactamente lo de producción: `docker compose -f docker-compose.yml up -d --build`.
### Nombres únicos en la red
Los servicios se llaman entre sí por **alias únicos** (`task-stream-api`, `task-stream-postgres`, `task-stream-mongo`,
`task-stream-redis`), no por el nombre del servicio. Al dar dominio a un servicio, Dokploy lo conecta a la red
compartida `dokploy-network`, donde otros proyectos también tienen un `api`, un `postgres` o un `redis`: con nombres
genéricos, el DNS de Docker devuelve también los suyos y nginx reparte las peticiones entre ambos (síntoma: la mitad
de las llamadas a `/api/*` responden `404 Cannot GET …` y el login dice «no configurado»). Si añades un servicio,
dale su alias `task-stream-*` y úsalo en las URLs.
Todos tienen healthcheck; `web` espera a que `api` esté sana, y `api` a las tres bases de datos. Todos tienen healthcheck; `web` espera a que `api` esté sana, y `api` a las tres bases de datos.
### Imágenes ### Imágenes
El `Dockerfile` tiene dos destinos que comparten la instalación de dependencias: El `Dockerfile` tiene dos 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`.
- **`web`**: build de producción de Angular servido por nginx (`apps/web/nginx.conf`): fallback de rutas a - **`web`**: build de producción de Angular servido por nginx (`apps/web/nginx.conf`): fallback de rutas a
`index.html`, caché larga para los bundles con hash y sin caché para `index.html`. `index.html`, caché larga para los bundles con hash y sin caché para `index.html`.
### Construir
`NEXUS_TOKEN` tiene que estar en el entorno de quien construye, o en el `.env` junto al `docker-compose.yml`:
```sh
# Local: reutiliza el token de tu ~/.npmrc
export NEXUS_TOKEN=$(grep '^//nexus.pireon.dev/repository/npm-releases/:_authToken=' ~/.npmrc | cut -d= -f2-)
docker compose up -d --build
```
En **Dokploy** (despliegue con Docker Compose): pestaña _Environment_ del servicio → `NEXUS_TOKEN=<token>`, junto a
`PIREON_CLIENT_ID`, `PIREON_CLIENT_SECRET`, `PIREON_PASSWORD_CLIENT_ID`, `PIREON_PASSWORD_CLIENT_SECRET` y
`PIREON_WEBHOOK_SECRET`. Dokploy los escribe en el `.env` del proyecto, de
donde los lee Compose.
## 7.2 Variables de entorno de la API ## 7.2 Variables de entorno de la API
| Variable | Por defecto | Descripción | | Variable | Por defecto | Descripción |
@@ -45,7 +80,8 @@ El `Dockerfile` tiene dos destinos que comparten la instalación de dependencias
| `EVENT_STREAM_MAX_LENGTH` | `1000000` | Longitud aproximada que se conserva en el stream | | `EVENT_STREAM_MAX_LENGTH` | `1000000` | Longitud aproximada que se conserva en el stream |
| `EVENTS_BRIDGE_ENABLED` | `true` | Si esta instancia proyecta eventos hacia Mongo (ver escalado) | | `EVENTS_BRIDGE_ENABLED` | `true` | Si esta instancia proyecta eventos hacia Mongo (ver escalado) |
| `PIREON_BASE_URL` | `https://oauth.pireon.dev` | Servidor de identidad | | `PIREON_BASE_URL` | `https://oauth.pireon.dev` | Servidor de identidad |
| `PIREON_CLIENT_ID` / `PIREON_CLIENT_SECRET` | — | Cliente OAuth para el login desde la web | | `PIREON_CLIENT_ID` / `PIREON_CLIENT_SECRET` | — | Cliente OAuth `authorization_code`: «Entrar con Pireon» y Swagger UI |
| `PIREON_PASSWORD_CLIENT_ID` / `_SECRET` | el anterior | Cliente OAuth `password`: login con email y contraseña |
| `PIREON_SCOPES` | `users:read` | Scopes que se piden al iniciar sesión | | `PIREON_SCOPES` | `users:read` | Scopes que se piden al iniciar sesión |
| `PIREON_PASSWORD_GRANT` | `true` | Permite el login con email y contraseña | | `PIREON_PASSWORD_GRANT` | `true` | Permite el login con email y contraseña |
| `PIREON_WEBHOOK_SECRET` | — | Activa y firma los webhooks de Pireon | | `PIREON_WEBHOOK_SECRET` | — | Activa y firma los webhooks de Pireon |
@@ -53,6 +89,8 @@ El `Dockerfile` tiene dos destinos que comparten la instalación de dependencias
| `AUTH_SESSION_TTL_SECONDS` | `60` | Cuánto se confía en un token validado antes de volver a preguntar a Pireon | | `AUTH_SESSION_TTL_SECONDS` | `60` | Cuánto se confía en un token validado antes de volver a preguntar a Pireon |
| `GRAPHQL_PLAYGROUND` | `true` | GraphiQL e introspección | | `GRAPHQL_PLAYGROUND` | `true` | GraphiQL e introspección |
| `GRAPHQL_SCHEMA_FILE` | `apps/api/schema.gql` | Dónde escribir el SDL (vacío = no escribir; así está en Docker) | | `GRAPHQL_SCHEMA_FILE` | `apps/api/schema.gql` | Dónde escribir el SDL (vacío = no escribir; así está en Docker) |
| `SWAGGER_ENABLED` | `true` | API REST documentada: Swagger UI en `/api/docs`, OpenAPI en `/api/docs-json` |
| `OPENAPI_FILE` | `apps/api/openapi.json` | Dónde escribir el documento OpenAPI al arrancar (vacío = no escribir; así en Docker) |
La web no tiene configuración de entorno: llama a la API en su mismo origen (`/api`, `/graphql`). La web no tiene configuración de entorno: llama a la API en su mismo origen (`/api`, `/graphql`).
@@ -82,3 +120,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.

View File

@@ -22,4 +22,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.

View File

@@ -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).

View File

@@ -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": {}
} }

View File

@@ -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();
} }
} }

View File

@@ -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": {}
} }

View File

@@ -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;
} }

View File

@@ -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": {}
} }

View File

@@ -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),

View File

@@ -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();
});
});

View File

@@ -1,4 +1,5 @@
import { import {
ForbiddenException,
Inject, Inject,
Injectable, Injectable,
Logger, Logger,
@@ -28,6 +29,11 @@ interface PireonUser {
app_roles?: string[]; app_roles?: string[];
} }
interface OAuthClient {
id: string;
secret: string | undefined;
}
/** Driven adapter for the Pireon OAuth2 identity server (Laravel Passport). */ /** Driven adapter for the Pireon OAuth2 identity server (Laravel Passport). */
@Injectable() @Injectable()
export class PireonIdentityProvider extends IdentityProvider { export class PireonIdentityProvider extends IdentityProvider {
@@ -69,14 +75,78 @@ export class PireonIdentityProvider extends IdentityProvider {
} }
async issueToken(grant: TokenGrant): Promise<TokenSet> { async issueToken(grant: TokenGrant): Promise<TokenSet> {
const { clientId, clientSecret, scopes } = this.config; const clients = this.clientsFor(grant);
if (!clientId) // A refresh token only works with the client that issued it, and we don't
// know which one that was: try each until one accepts it.
let rejected: InvalidCredentialsError | undefined;
for (const client of clients) {
try {
return await this.requestToken(client, grant);
} catch (error) {
if (
grant.type !== 'refresh_token' ||
!(error instanceof InvalidCredentialsError)
)
throw error;
rejected = error;
}
}
throw rejected ?? new InvalidCredentialsError();
}
/**
* Pireon clients allow a single grant type: authorization_code uses
* PIREON_CLIENT_ID, password PIREON_PASSWORD_CLIENT_ID (or the former).
*/
private clientsFor(grant: TokenGrant): OAuthClient[] {
const { clientId, clientSecret, passwordClientId, passwordClientSecret } =
this.config;
const code: OAuthClient | undefined = clientId
? { id: clientId, secret: clientSecret }
: undefined;
const password: OAuthClient | undefined = passwordClientId
? { id: passwordClientId, secret: passwordClientSecret }
: code;
switch (grant.type) {
case 'authorization_code':
if (!code)
throw new ServiceUnavailableException(
'Sign-in with Pireon is not configured (PIREON_CLIENT_ID)',
);
return [code];
case 'password':
if (!this.config.passwordGrant)
throw new ForbiddenException(
'Email and password sign-in is disabled',
);
if (!password)
throw new ServiceUnavailableException(
'Email and password sign-in is not configured (PIREON_PASSWORD_CLIENT_ID)',
);
return [password];
case 'refresh_token': {
const candidates = [password, code].filter(
(client): client is OAuthClient => !!client,
);
if (!candidates.length)
throw new ServiceUnavailableException( throw new ServiceUnavailableException(
'Sign-in is not configured (PIREON_CLIENT_ID)', 'Sign-in is not configured (PIREON_CLIENT_ID)',
); );
return candidates.filter(
(client, i) => candidates.findIndex((c) => c.id === client.id) === i,
);
}
}
}
const body = new URLSearchParams({ client_id: clientId }); private async requestToken(
if (clientSecret) body.set('client_secret', clientSecret); client: OAuthClient,
grant: TokenGrant,
): Promise<TokenSet> {
const { scopes } = this.config;
const body = new URLSearchParams({ client_id: client.id });
if (client.secret) body.set('client_secret', client.secret);
switch (grant.type) { switch (grant.type) {
case 'authorization_code': case 'authorization_code':
body.set('grant_type', 'authorization_code'); body.set('grant_type', 'authorization_code');
@@ -114,12 +184,23 @@ export class PireonIdentityProvider extends IdentityProvider {
throw new ServiceUnavailableException('Identity server unavailable'); throw new ServiceUnavailableException('Identity server unavailable');
} }
if (response.status === 400 || response.status === 401) { if (response.status === 400 || response.status === 401) {
// OAuth2 error (RFC 6749 §5.2). Passport puts the useful part in `hint`
// (which parameter was wrong), so keep it in the message and the log.
const error = (await response.json().catch(() => ({}))) as { const error = (await response.json().catch(() => ({}))) as {
error?: string;
error_description?: string; error_description?: string;
hint?: string;
message?: string; message?: string;
}; };
const description =
error.error_description ?? error.message ?? 'Invalid credentials';
this.logger.warn(
`Pireon rejected ${grant.type} for client ${client.id}: ` +
`${error.error ?? response.status} — ${description}` +
(error.hint ? ` (hint: ${error.hint})` : ''),
);
throw new InvalidCredentialsError( throw new InvalidCredentialsError(
error.error_description ?? error.message ?? 'Invalid credentials', error.hint ? `${description} (${error.hint})` : description,
); );
} }
this.assertOk(response, 'POST /oauth/token'); this.assertOk(response, 'POST /oauth/token');
@@ -138,12 +219,14 @@ export class PireonIdentityProvider extends IdentityProvider {
} }
authorizationConfig(): AuthorizationConfig | null { authorizationConfig(): AuthorizationConfig | null {
if (!this.config.clientId) return null; const { clientId, passwordClientId, passwordGrant } = this.config;
const password = passwordGrant && !!(passwordClientId ?? clientId);
if (!clientId && !password) return null;
return { return {
authorizeUrl: new URL('/oauth/authorize', this.config.baseUrl).toString(), authorizeUrl: new URL('/oauth/authorize', this.config.baseUrl).toString(),
clientId: this.config.clientId, clientId: clientId ?? null,
scopes: this.config.scopes, scopes: this.config.scopes,
passwordGrant: this.config.passwordGrant, passwordGrant: password,
}; };
} }

View File

@@ -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;
} }

View File

@@ -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

View File

@@ -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": {}
} }

View File

@@ -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({}));

View File

@@ -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;

View File

@@ -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)));

View File

@@ -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;
} }

View File

@@ -9,12 +9,20 @@ import {
UnauthorizedException, UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import {
ApiHeader,
ApiNoContentResponse,
ApiOperation,
ApiTags,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
import { IdentityService } from '@task-stream/api/identity/application'; import { IdentityService } from '@task-stream/api/identity/application';
import { Request } from 'express'; import { Request } from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto'; import { createHmac, timingSafeEqual } from 'node:crypto';
import { Public } from '../auth/auth.decorators'; import { Public } from '../auth/auth.decorators';
/** Receives `user.deactivated` / `token.revoked` from Pireon. */ /** Receives `user.deactivated` / `token.revoked` from Pireon. */
@ApiTags('Webhooks')
@Controller('webhooks/pireon') @Controller('webhooks/pireon')
export class PireonWebhookController { export class PireonWebhookController {
constructor( constructor(
@@ -25,6 +33,19 @@ export class PireonWebhookController {
@Public() @Public()
@Post() @Post()
@HttpCode(204) @HttpCode(204)
@ApiOperation({
summary: 'Pireon events (called by Pireon, not by apps)',
description:
"`user.deactivated` closes the user's sessions; `token.revoked` forgets the token. " +
'Disabled unless PIREON_WEBHOOK_SECRET is set.',
})
@ApiHeader({
name: 'X-Webhook-Signature',
description: 'HMAC-SHA256 of the raw body with PIREON_WEBHOOK_SECRET (hex)',
required: true,
})
@ApiNoContentResponse({ description: 'Processed' })
@ApiUnauthorizedResponse({ description: 'Missing or invalid signature' })
async receive( async receive(
@Req() req: RawBodyRequest<Request>, @Req() req: RawBodyRequest<Request>,
@Headers('x-webhook-signature') signature?: string, @Headers('x-webhook-signature') signature?: string,

View File

@@ -0,0 +1,101 @@
import {
Controller,
Get,
NotFoundException,
Param,
Query,
} from '@nestjs/common';
import {
ApiOkResponse,
ApiOperation,
ApiProperty,
ApiPropertyOptional,
ApiTags,
} from '@nestjs/swagger';
import { IdentityService } from '@task-stream/api/identity/application';
import { User } from '@task-stream/api/identity/domain';
import { ApiReadErrors } from '@task-stream/api/shared/presenters';
import { Type } from 'class-transformer';
import {
IsInt,
IsOptional,
IsString,
Max,
MaxLength,
Min,
} from 'class-validator';
import {
AccessToken,
ApiPireonAuth,
CurrentUser,
} from '../auth/auth.decorators';
export class UserDto {
@ApiProperty({ description: 'Pireon user id' }) id!: string;
@ApiProperty() name!: string;
@ApiProperty() email!: string;
@ApiProperty() isActive!: boolean;
}
export class MeDto extends UserDto {
@ApiProperty({ type: [String], description: 'Global roles in Pireon' })
roles!: string[];
@ApiProperty({
type: [String],
description: 'Roles inside the task-stream OAuth client',
})
appRoles!: string[];
}
export class UserSearchQueryDto {
@ApiPropertyOptional({ description: 'Name or email', default: '' })
@IsOptional()
@IsString()
@MaxLength(100)
search = '';
@ApiPropertyOptional({ default: 20, minimum: 1, maximum: 100 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit = 20;
}
/** REST adapter of identity: who I am and Pireon's user directory. */
@ApiTags('Users')
@ApiPireonAuth()
@Controller('users')
export class UsersController {
constructor(private readonly identity: IdentityService) {}
@Get('me')
@ApiOperation({ summary: 'The user behind the access token' })
@ApiOkResponse({ type: MeDto })
@ApiReadErrors()
me(@CurrentUser() user: User): MeDto {
return user.toSnapshot();
}
@Get()
@ApiOperation({
summary: 'Search Pireon users (to assign tickets or add members)',
description: 'Needs the `users:read` scope in the access token.',
})
@ApiOkResponse({ type: [UserDto] })
@ApiReadErrors()
search(@Query() query: UserSearchQueryDto, @AccessToken() token: string) {
return this.identity.searchUsers(query.search, token, query.limit);
}
@Get(':id')
@ApiOperation({ summary: 'A Pireon user' })
@ApiOkResponse({ type: UserDto })
@ApiReadErrors()
async findOne(@Param('id') id: string, @AccessToken() token: string) {
const user = await this.identity.findUser(id, token);
if (!user) throw new NotFoundException('User not found');
return user;
}
}

View File

@@ -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,

View File

@@ -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

View File

@@ -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": {}
} }

View File

@@ -1,3 +1,4 @@
export * from './lib/ports/event-store'; export * from './lib/ports/event-store';
export * from './lib/aggregate-rehydrator'; export * from './lib/aggregate-rehydrator';
export * from './lib/shared.module'; export * from './lib/shared.module';
export * from './lib/event-recorder';

View File

@@ -0,0 +1,35 @@
import { Injectable } from '@nestjs/common';
import { ConcurrencyError, DomainEvent } from '@task-stream/api/shared/domain';
import { EventStore } from './ports/event-store';
/**
* Appends a domain event to a stream that no aggregate of ours owns (e.g. a
* user, owned by Pireon), at the next position. Like any stored event, it
* reaches the event handlers and sagas of every context through the events
* bridge.
*/
@Injectable()
export class EventRecorder {
constructor(private readonly eventStore: EventStore) {}
async record(streamId: string, event: DomainEvent): Promise<void> {
for (let attempt = 1; ; attempt++) {
const position =
(await this.eventStore.getEventsByStreamId(streamId)).length + 1;
try {
await this.eventStore.persist({
streamId,
position,
type: event.constructor.name,
data: JSON.parse(
JSON.stringify({ ...event, aggregateVersion: position }),
),
});
return;
} catch (error) {
// Someone appended meanwhile: take the next position.
if (!(error instanceof ConcurrencyError) || attempt === 3) throw error;
}
}
}
}

View File

@@ -1,11 +1,12 @@
import { DynamicModule, Global, Module, Type } from '@nestjs/common'; import { DynamicModule, Global, Module, Type } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs'; import { CqrsModule } from '@nestjs/cqrs';
import { AggregateRehydrator } from './aggregate-rehydrator'; import { AggregateRehydrator } from './aggregate-rehydrator';
import { EventRecorder } from './event-recorder';
/** /**
* Shared building blocks for every bounded context: the event store (from * Shared building blocks for every bounded context: the event store (from
* the infrastructure module plugged in by the composition root) and the * the infrastructure module plugged in by the composition root), the
* AggregateRehydrator. * AggregateRehydrator and the EventRecorder.
*/ */
@Global() @Global()
@Module({}) @Module({})
@@ -16,8 +17,8 @@ export class SharedModule {
return { return {
module: SharedModule, module: SharedModule,
imports: [CqrsModule, infrastructureModule], imports: [CqrsModule, infrastructureModule],
providers: [AggregateRehydrator], providers: [AggregateRehydrator, EventRecorder],
exports: [infrastructureModule, AggregateRehydrator], exports: [infrastructureModule, AggregateRehydrator, EventRecorder],
}; };
} }
} }

View File

@@ -3,10 +3,7 @@
"$schema": "../../../../node_modules/nx/schemas/project-schema.json", "$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/api/shared/domain/src", "sourceRoot": "libs/api/shared/domain/src",
"projectType": "library", "projectType": "library",
"tags": [ "tags": ["scope:api-shared", "type:domain"],
"scope:api-shared",
"type:domain"
],
"// targets": "to see all targets run: nx show project api-shared-domain --web", "// targets": "to see all targets run: nx show project api-shared-domain --web",
"targets": {} "targets": {}
} }

View File

@@ -10,7 +10,8 @@ Event store en PostgreSQL y el camino de los eventos hasta la lectura.
- `PostgresEventStore` (tabla `events`, `UNIQUE(stream_id, position)` → `ConcurrencyError`). - `PostgresEventStore` (tabla `events`, `UNIQUE(stream_id, position)` → `ConcurrencyError`).
- `EventSerializer` / `EventDeserializer` y `EventStorePublisher` (sustituye al publisher del `EventBus`: `commit()` persiste). - `EventSerializer` / `EventDeserializer` y `EventStorePublisher` (sustituye al publisher del `EventBus`: `commit()` persiste).
- `EventStoreRelay`: copia los eventos nuevos al Redis Stream (`FOR UPDATE SKIP LOCKED`). - `EventStoreRelay`: copia los eventos nuevos al Redis Stream (`FOR UPDATE SKIP LOCKED`).
- `EventsBridge`: consume el stream y entrega cada evento a sus `@EventsHandler`, en orden y esperando (ACK al final). - `EventsBridge`: consume el stream y entrega cada evento a sus `@EventsHandler`, en orden y esperando (ACK al final);
después ejecuta los `@Saga()` (el `EventBus` en memoria nunca ve los eventos guardados) y sus comandos, uno a uno.
- Conexiones Redis (`REDIS_CLIENT`, `GRAPHQL_PUB_SUB`) y `SharedInfrastructureModule.forRootAsync`. - Conexiones Redis (`REDIS_CLIENT`, `GRAPHQL_PUB_SUB`) y `SharedInfrastructureModule.forRootAsync`.
Flujo completo: [Arquitectura](../../../../docs/02-arquitectura.md#22-flujo-completo-de-una-escritura). Flujo completo: [Arquitectura](../../../../docs/02-arquitectura.md#22-flujo-completo-de-una-escritura).

View File

@@ -3,10 +3,7 @@
"$schema": "../../../../node_modules/nx/schemas/project-schema.json", "$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/api/shared/infrastructure/src", "sourceRoot": "libs/api/shared/infrastructure/src",
"projectType": "library", "projectType": "library",
"tags": [ "tags": ["scope:api-shared", "type:infrastructure"],
"scope:api-shared",
"type:infrastructure"
],
"// targets": "to see all targets run: nx show project api-shared-infrastructure --web", "// targets": "to see all targets run: nx show project api-shared-infrastructure --web",
"targets": {} "targets": {}
} }

View File

@@ -7,9 +7,20 @@ import {
Type, Type,
} from '@nestjs/common'; } from '@nestjs/common';
import { DiscoveryService } from '@nestjs/core'; import { DiscoveryService } from '@nestjs/core';
import { IEventHandler } from '@nestjs/cqrs'; import {
import { SerializableEvent } from '@task-stream/api/shared/domain'; CommandBus,
ICommand,
IEvent,
IEventHandler,
ISaga,
} from '@nestjs/cqrs';
import {
ConcurrencyError,
DomainError,
SerializableEvent,
} from '@task-stream/api/shared/domain';
import { hostname } from 'node:os'; import { hostname } from 'node:os';
import { lastValueFrom, of, toArray } from 'rxjs';
import { EventStreamConsumer } from '../event-stream/event-stream.consumer'; import { EventStreamConsumer } from '../event-stream/event-stream.consumer';
import { import {
EVENT_STREAM_OPTIONS, EVENT_STREAM_OPTIONS,
@@ -20,8 +31,12 @@ import { EventDeserializer } from './deserializers/event.deserializer';
export const EVENTS_BRIDGE_ENABLED = Symbol('EVENTS_BRIDGE_ENABLED'); export const EVENTS_BRIDGE_ENABLED = Symbol('EVENTS_BRIDGE_ENABLED');
/** Metadata written by @EventsHandler(...events) (@nestjs/cqrs). */ /** Metadata written by @EventsHandler(...events) and @Saga() (@nestjs/cqrs). */
const EVENTS_HANDLER_METADATA = '__eventsHandler__'; const EVENTS_HANDLER_METADATA = '__eventsHandler__';
const SAGA_METADATA = '__saga__';
/** Attempts of a saga command that keeps hitting concurrent writes. */
const CONCURRENCY_ATTEMPTS = 5;
/** /**
* Redis stream → @EventsHandler. Plays the role of the event-store change * Redis stream → @EventsHandler. Plays the role of the event-store change
@@ -32,6 +47,13 @@ const EVENTS_HANDLER_METADATA = '__eventsHandler__';
* time, so events of an aggregate are projected in order, and the entry is * time, so events of an aggregate are projected in order, and the entry is
* acknowledged in Redis only once every handler succeeded (otherwise it * acknowledged in Redis only once every handler succeeded (otherwise it
* stays pending and is retried). * stays pending and is retried).
*
* It also runs the `@Saga()`s (the in-memory EventBus never sees stored
* events, so it would not): after the handlers, each saga gets the event
* alone (`of(event)`), and the commands it emits are executed one by one,
* awaited. Commands must be idempotent (an event may be handled twice).
* A command refused by the domain (not found, business rule...) is logged
* and skipped, so it cannot block the stream; concurrent writes are retried.
*/ */
@Injectable() @Injectable()
export class EventsBridge export class EventsBridge
@@ -40,6 +62,7 @@ export class EventsBridge
private readonly logger = new Logger(EventsBridge.name); private readonly logger = new Logger(EventsBridge.name);
private consumer?: EventStreamConsumer; private consumer?: EventStreamConsumer;
private readonly handlers = new Map<Type, IEventHandler[]>(); private readonly handlers = new Map<Type, IEventHandler[]>();
private readonly sagas: { name: string; saga: ISaga }[] = [];
constructor( constructor(
private readonly connections: RedisConnectionFactory, private readonly connections: RedisConnectionFactory,
@@ -47,6 +70,7 @@ export class EventsBridge
@Inject(EVENTS_BRIDGE_ENABLED) private readonly enabled: boolean, @Inject(EVENTS_BRIDGE_ENABLED) private readonly enabled: boolean,
private readonly eventDeserializer: EventDeserializer, private readonly eventDeserializer: EventDeserializer,
private readonly discovery: DiscoveryService, private readonly discovery: DiscoveryService,
private readonly commandBus: CommandBus,
) {} ) {}
async onApplicationBootstrap(): Promise<void> { async onApplicationBootstrap(): Promise<void> {
@@ -55,6 +79,7 @@ export class EventsBridge
return; return;
} }
this.registerEventHandlers(); this.registerEventHandlers();
this.registerSagas();
this.consumer = new EventStreamConsumer( this.consumer = new EventStreamConsumer(
this.connections.create('events-bridge'), this.connections.create('events-bridge'),
{ {
@@ -66,7 +91,7 @@ export class EventsBridge
); );
await this.consumer.start(); await this.consumer.start();
this.logger.log( this.logger.log(
`Bridging ${this.stream.streamKey} to ${this.handlers.size} event type(s)`, `Bridging ${this.stream.streamKey} to ${this.handlers.size} event type(s) and ${this.sagas.length} saga(s)`,
); );
} }
@@ -90,11 +115,66 @@ export class EventsBridge
} }
} }
private registerSagas(): void {
for (const wrapper of this.discovery.getProviders()) {
if (!wrapper.metatype || !wrapper.instance) continue;
const keys: string[] =
Reflect.getMetadata(SAGA_METADATA, wrapper.metatype) ?? [];
const instance = wrapper.instance as Record<string, ISaga>;
for (const key of keys) {
this.sagas.push({
name: `${wrapper.metatype.name}.${key}`,
saga: instance[key].bind(instance),
});
}
}
}
private async handleEvent(stored: SerializableEvent): Promise<void> { private async handleEvent(stored: SerializableEvent): Promise<void> {
const { data: event } = this.eventDeserializer.deserialize(stored); const { data: event } = this.eventDeserializer.deserialize(stored);
const eventCls = Object.getPrototypeOf(event).constructor as Type; const eventCls = Object.getPrototypeOf(event).constructor as Type;
// Projections first: sagas may read the read model they just updated.
for (const handler of this.handlers.get(eventCls) ?? []) { for (const handler of this.handlers.get(eventCls) ?? []) {
await handler.handle(event); await handler.handle(event);
} }
for (const { name, saga } of this.sagas) {
const commands = await lastValueFrom(
saga(of(event as IEvent)).pipe(toArray()),
);
for (const command of commands) {
await this.execute(name, event as IEvent, command);
}
}
}
private async execute(
saga: string,
event: IEvent,
command: ICommand,
): Promise<void> {
for (let attempt = 1; ; attempt++) {
try {
await this.commandBus.execute(command);
return;
} catch (error) {
if (
error instanceof ConcurrencyError &&
attempt < CONCURRENCY_ATTEMPTS
) {
await new Promise((resolve) => setTimeout(resolve, 50 * attempt));
continue;
}
if (
error instanceof DomainError &&
!(error instanceof ConcurrencyError)
) {
this.logger.warn(
`${saga}: ${command.constructor.name} after ${event.constructor.name} skipped: ${error.message}`,
);
return;
}
throw error;
}
}
} }
} }

View File

@@ -8,6 +8,7 @@ Adaptadores de entrada comunes.
## Contenido ## Contenido
- `DomainErrorFilter`: convierte los errores de dominio en errores GraphQL (`extensions.code`) o respuestas HTTP. - `DomainErrorFilter`: convierte los errores de dominio en errores GraphQL (`extensions.code`) o respuestas HTTP.
- `ApiErrorDto`, `@ApiReadErrors()` y `@ApiCommandErrors()`: el formato de error de la API REST en OpenAPI.
## Comandos ## Comandos

View File

@@ -3,10 +3,7 @@
"$schema": "../../../../node_modules/nx/schemas/project-schema.json", "$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/api/shared/presenters/src", "sourceRoot": "libs/api/shared/presenters/src",
"projectType": "library", "projectType": "library",
"tags": [ "tags": ["scope:api-shared", "type:presenters"],
"scope:api-shared",
"type:presenters"
],
"// targets": "to see all targets run: nx show project api-shared-presenters --web", "// targets": "to see all targets run: nx show project api-shared-presenters --web",
"targets": {} "targets": {}
} }

View File

@@ -1 +1,2 @@
export * from './lib/filters/domain-error.filter'; export * from './lib/filters/domain-error.filter';
export * from './lib/http/api-errors';

View File

@@ -0,0 +1,59 @@
import { applyDecorators } from '@nestjs/common';
import {
ApiBadRequestResponse,
ApiConflictResponse,
ApiForbiddenResponse,
ApiNotFoundResponse,
ApiProperty,
ApiPropertyOptional,
ApiUnauthorizedResponse,
ApiUnprocessableEntityResponse,
} from '@nestjs/swagger';
/** Error body of the REST API (`DomainErrorFilter`, guards and validation). */
export class ApiErrorDto {
@ApiProperty({ example: 404 })
statusCode!: number;
@ApiPropertyOptional({
description: 'Domain error code',
enum: ['NOT_FOUND', 'FORBIDDEN', 'VALIDATION', 'CONFLICT', 'BUSINESS_RULE'],
})
code?: string;
@ApiProperty({
description: 'Human message (a list of messages for invalid input)',
oneOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }],
example: 'Issue not found',
})
message!: string | string[];
}
/** 400 / 401 / 403 / 404: what any authenticated read can answer. */
export const ApiReadErrors = () =>
applyDecorators(
ApiBadRequestResponse({ type: ApiErrorDto, description: 'Invalid input' }),
ApiUnauthorizedResponse({
type: ApiErrorDto,
description: 'Missing or rejected Pireon token',
}),
ApiForbiddenResponse({
type: ApiErrorDto,
description: 'Not a member of the project (or not allowed)',
}),
ApiNotFoundResponse({ type: ApiErrorDto, description: 'Not found' }),
);
/** Reads + 409 (concurrent change) and 422 (business rule) for commands. */
export const ApiCommandErrors = () =>
applyDecorators(
ApiReadErrors(),
ApiConflictResponse({
type: ApiErrorDto,
description: 'Someone changed it at the same time: retry',
}),
ApiUnprocessableEntityResponse({
type: ApiErrorDto,
description: 'Business rule (e.g. a transition the workflow forbids)',
}),
);

View File

@@ -7,10 +7,18 @@ Casos de uso del tracker (CQRS).
## Contenido ## Contenido
- Services: `ProjectsService`, `IssuesService`, `CommentsService`, `ReleasesService`, `ChangesService` (subscriptions). - Services: `ProjectsService`, `IssuesService`, `CommentsService`, `ReleasesService`, `ChangesService` (subscriptions),
`WorkflowsService` (workflow de cada proyecto para presentar tickets, con una caché de 2 s).
- `commands/`: un fichero por command y otro por handler; cargan con `WriteAccess` (rehydrator) y guardan con `commitAndWait()`. - `commands/`: un fichero por command y otro por handler; cargan con `WriteAccess` (rehydrator) y guardan con `commitAndWait()`.
- `queries/`: un fichero por query y otro por handler; leen de los puertos `Find*Repository` y comprueban la membresía (`ReadAccess`). - `queries/`: un fichero por query y otro por handler; leen de los puertos `Find*Repository` y comprueban el acceso
- `event-handlers/`: proyecciones (`@EventsHandler`) hacia el read model, notificaciones en tiempo real y feed de actividad. (`ReadAccess`: miembros, o cualquiera si el proyecto es público; `viewerId` null = anónimo);
`SearchMyIssuesQuery` y `GetUpcomingReleasesQuery` cruzan todos los proyectos del usuario («Mi trabajo»).
- `event-handlers/`: proyecciones (`@EventsHandler`) hacia el read model (incluida la línea temporal de cada ticket),
notificaciones en tiempo real y feed de actividad.
- `sagas/`: `ProjectDeletionSaga` (cascada al borrar un proyecto y sus tickets), `ReleaseArchivedSaga` (los tickets sin
terminar salen de una release archivada) y `UserDeactivatedSaga` (desasigna los tickets abiertos de un usuario que
Pireon desactiva). Las ejecuta el `EventsBridge`; sus comandos son internos e idempotentes. Tests en `sagas.spec.ts`.
- `analytics/`: estadísticas y burnup calculados con funciones puras sobre la línea temporal (con tests).
- `ports/`: lookups de escritura, `Find*`, `UpsertMaterialized*`, `ReleaseIssuesRepository`, `ChangeFeed`, `UserDirectory`. - `ports/`: lookups de escritura, `Find*`, `UpsertMaterialized*`, `ReleaseIssuesRepository`, `ChangeFeed`, `UserDirectory`.
- `TrackerApplicationModule.withInfrastructure(...)`. - `TrackerApplicationModule.withInfrastructure(...)`.

View File

@@ -3,10 +3,7 @@
"$schema": "../../../../node_modules/nx/schemas/project-schema.json", "$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/api/tracker/application/src", "sourceRoot": "libs/api/tracker/application/src",
"projectType": "library", "projectType": "library",
"tags": [ "tags": ["scope:api-tracker", "type:application"],
"scope:api-tracker",
"type:application"
],
"// targets": "to see all targets run: nx show project api-tracker-application --web", "// targets": "to see all targets run: nx show project api-tracker-application --web",
"targets": {} "targets": {}
} }

View File

@@ -6,4 +6,5 @@ export * from './lib/issues.service';
export * from './lib/comments.service'; export * from './lib/comments.service';
export * from './lib/releases.service'; export * from './lib/releases.service';
export * from './lib/changes.service'; export * from './lib/changes.service';
export * from './lib/workflows.service';
export * from './lib/tracker-application.module'; export * from './lib/tracker-application.module';

View File

@@ -0,0 +1,184 @@
import {
IssueReadModel,
IssueTimelineEntry,
ReleaseReadModel,
ReleaseStatus,
StatusCategory as C,
} from '@task-stream/api/tracker/domain';
import {
computeProjectAnalytics,
computeReleaseBurnup,
weekOf,
} from './analytics';
const ana = { id: 'ana', name: 'Ana' };
const bob = { id: 'bob', name: 'Bob' };
const issue = (
id: string,
createdAt: string,
status: string,
category: C,
releaseId: string | null = null,
): IssueReadModel =>
({
id,
key: id,
createdAt,
status,
statusCategory: category,
reporter: ana,
assignee: bob,
release: releaseId ? { id: releaseId, name: 'v1' } : null,
}) as IssueReadModel;
let seq = 0;
const step = (
issueId: string,
at: string,
status: string,
category: C,
assigneeId: string | null = 'bob',
): IssueTimelineEntry => ({
id: `${issueId}:${++seq}`,
issueId,
projectId: 'p',
at,
kind: 'status',
status,
category,
assigneeId,
});
/*
* A: created Mon 5 Oct, started Tue 6, to QA Wed 7, QA failed Thu 8, back to QA and done Fri 9.
* B: created Mon 5 Oct, started Mon 12, QA Tue 13, done Wed 14 (by Ana).
* C: created Tue 13 Oct, still in backlog.
*/
const issues = [
issue('A', '2026-10-05T09:00:00Z', 'DONE', C.DONE, 'rel'),
issue('B', '2026-10-05T10:00:00Z', 'DONE', C.DONE, 'rel'),
issue('C', '2026-10-13T10:00:00Z', 'BACKLOG', C.TODO),
];
const timeline: IssueTimelineEntry[] = [
step('A', '2026-10-05T09:00:00Z', 'BACKLOG', C.TODO),
{
id: 'A:r',
issueId: 'A',
projectId: 'p',
at: '2026-10-05T09:00:01Z',
kind: 'release',
releaseId: 'rel',
},
step('A', '2026-10-06T09:00:00Z', 'IN_PROGRESS', C.IN_PROGRESS),
step('A', '2026-10-07T09:00:00Z', 'IN_QA', C.QA),
step('A', '2026-10-08T09:00:00Z', 'QA_FAILED', C.IN_PROGRESS),
step('A', '2026-10-09T08:00:00Z', 'IN_QA', C.QA),
step('A', '2026-10-09T09:00:00Z', 'DONE', C.DONE),
step('B', '2026-10-05T10:00:00Z', 'BACKLOG', C.TODO),
{
id: 'B:r',
issueId: 'B',
projectId: 'p',
at: '2026-10-12T08:00:00Z',
kind: 'release',
releaseId: 'rel',
},
step('B', '2026-10-12T10:00:00Z', 'IN_PROGRESS', C.IN_PROGRESS),
step('B', '2026-10-13T10:00:00Z', 'IN_QA', C.QA),
step('B', '2026-10-14T10:00:00Z', 'DONE', C.DONE, 'ana'),
];
describe('project analytics', () => {
const stats = computeProjectAnalytics({
issues,
timeline,
statusOrder: ['BACKLOG', 'IN_PROGRESS', 'IN_QA', 'QA_FAILED', 'DONE'],
from: new Date('2026-10-05T00:00:00Z'),
to: new Date('2026-10-14T23:59:59Z'),
});
it('counts created and resolved tickets per week (Monday)', () => {
expect(weekOf(Date.parse('2026-10-11T12:00:00Z'))).toBe('2026-10-05');
expect(stats.createdVsResolved).toEqual([
{ week: '2026-10-05', created: 2, resolved: 1 },
{ week: '2026-10-12', created: 1, resolved: 1 },
]);
});
it('measures cycle time (start → done) and lead time (created → done)', () => {
// A: 3 days of cycle, 4 of lead. B: 2 and 9.
expect(stats.cycleTime).toEqual({ count: 2, averageDays: 2.5, p85Days: 3 });
expect(stats.leadTime).toEqual({ count: 2, averageDays: 6.5, p85Days: 9 });
});
it('counts QA passes and failures', () => {
expect(stats.qa).toEqual({ passed: 2, failed: 1, failureRate: 0.333 });
});
it('attributes throughput to who had the ticket', () => {
expect(stats.throughput).toEqual([
{ week: '2026-10-05', user: bob, count: 1 },
{ week: '2026-10-12', user: ana, count: 1 },
]);
});
it('builds the cumulative flow day by day, in the workflow order', () => {
const flow = stats.cumulativeFlow;
expect(flow.dates).toHaveLength(10);
expect(flow.series.map((s) => s.status)).toEqual([
'BACKLOG',
'IN_PROGRESS',
'IN_QA',
'QA_FAILED',
'DONE',
]);
const at = (status: string, day: string) =>
flow.series.find((s) => s.status === status)?.counts[
flow.dates.indexOf(day)
];
expect(at('BACKLOG', '2026-10-05')).toBe(2);
expect(at('QA_FAILED', '2026-10-08')).toBe(1);
expect(at('DONE', '2026-10-14')).toBe(2);
expect(at('BACKLOG', '2026-10-14')).toBe(1); // C
});
it('falls back to creation + current status for tickets without timeline', () => {
const old = computeProjectAnalytics({
issues: [issue('OLD', '2026-10-01T00:00:00Z', 'TODO', C.TODO)],
timeline: [],
statusOrder: [],
from: new Date('2026-10-01T00:00:00Z'),
to: new Date('2026-10-02T23:00:00Z'),
});
expect(old.cumulativeFlow.series).toEqual([
{ status: 'TODO', counts: [1, 1] },
]);
});
});
describe('release burnup', () => {
it('tracks scope and done tickets of the release per day', () => {
const release = {
id: 'rel',
createdAt: '2026-10-05T08:00:00Z',
releasedAt: '2026-10-14T18:00:00Z',
targetDate: '2026-10-15',
status: ReleaseStatus.RELEASED,
} as ReleaseReadModel;
const burnup = computeReleaseBurnup({
release,
issues,
timeline,
now: new Date('2026-10-20T00:00:00Z'),
});
const at = (series: number[], day: string) =>
series[burnup.dates.indexOf(day)];
expect(burnup.dates[0]).toBe('2026-10-05');
expect(burnup.dates.at(-1)).toBe('2026-10-14');
expect(at(burnup.scope, '2026-10-05')).toBe(1); // A
expect(at(burnup.scope, '2026-10-12')).toBe(2); // + B
expect(at(burnup.done, '2026-10-09')).toBe(1);
expect(at(burnup.done, '2026-10-14')).toBe(2);
});
});

View File

@@ -0,0 +1,315 @@
import {
DurationStats,
IssueReadModel,
IssueTimelineEntry,
ProjectAnalyticsReadModel,
ReleaseBurnupReadModel,
ReleaseReadModel,
StatusCategory,
UserRefReadModel,
} from '@task-stream/api/tracker/domain';
/*
* Statistics are computed from each ticket's timeline (status and release
* steps). Pure functions: no Nest, no database, easy to test.
*/
const DAY = 86_400_000;
const STARTED = [StatusCategory.IN_PROGRESS, StatusCategory.QA];
interface Step {
at: number;
status: string;
category: StatusCategory;
assigneeId: string | null;
}
/** "2026-10-06" for a timestamp (UTC). */
export const dayOf = (time: number) =>
new Date(time).toISOString().slice(0, 10);
/** Monday of the ISO week of a timestamp (UTC), as "YYYY-MM-DD". */
export function weekOf(time: number): string {
const date = new Date(time);
const shift = (date.getUTCDay() + 6) % 7;
return dayOf(
Date.UTC(
date.getUTCFullYear(),
date.getUTCMonth(),
date.getUTCDate() - shift,
),
);
}
/** End of each UTC day between two instants (inclusive). */
function days(from: number, to: number): number[] {
const out: number[] = [];
const first = Date.UTC(
new Date(from).getUTCFullYear(),
new Date(from).getUTCMonth(),
new Date(from).getUTCDate(),
);
for (let day = first; day <= to; day += DAY) out.push(day + DAY - 1);
return out;
}
function weeks(from: number, to: number): string[] {
const out: string[] = [];
for (let day = Date.parse(weekOf(from)); day <= to; day += 7 * DAY)
out.push(dayOf(day));
return out;
}
/** Status steps per ticket; tickets with no timeline get one from their creation. */
function stepsByIssue(
issues: IssueReadModel[],
timeline: IssueTimelineEntry[],
): Map<string, Step[]> {
const steps = new Map<string, Step[]>();
for (const entry of timeline) {
if (entry.kind !== 'status' || !entry.status || !entry.category) continue;
const list = steps.get(entry.issueId) ?? [];
list.push({
at: Date.parse(entry.at),
status: entry.status,
category: entry.category,
assigneeId: entry.assigneeId ?? null,
});
steps.set(entry.issueId, list);
}
for (const issue of issues) {
if (!steps.has(issue.id)) {
steps.set(issue.id, [
{
at: Date.parse(issue.createdAt),
status: issue.status,
category: issue.statusCategory,
assigneeId: issue.assignee?.id ?? null,
},
]);
}
steps.get(issue.id)?.sort((a, b) => a.at - b.at);
}
return steps;
}
/** Last step at or before `time`. */
function stepAt(steps: Step[], time: number): Step | null {
let current: Step | null = null;
for (const step of steps) {
if (step.at > time) break;
current = step;
}
return current;
}
function durations(values: number[]): DurationStats {
if (values.length === 0)
return { count: 0, averageDays: null, p85Days: null };
const sorted = [...values].sort((a, b) => a - b);
const average = values.reduce((sum, v) => sum + v, 0) / values.length;
const p85 = sorted[Math.max(0, Math.ceil(0.85 * sorted.length) - 1)];
const round = (ms: number) => Math.round((ms / DAY) * 10) / 10;
return {
count: values.length,
averageDays: round(average),
p85Days: round(p85),
};
}
export interface AnalyticsInput {
issues: IssueReadModel[];
timeline: IssueTimelineEntry[];
/** Status ids in the project's order (series follow it). */
statusOrder: string[];
from: Date;
to: Date;
}
export function computeProjectAnalytics(
input: AnalyticsInput,
): ProjectAnalyticsReadModel {
const from = input.from.getTime();
const to = input.to.getTime();
const inRange = (time: number) => time >= from && time <= to;
const steps = stepsByIssue(input.issues, input.timeline);
const byId = new Map(input.issues.map((issue) => [issue.id, issue]));
const people = new Map<string, UserRefReadModel>();
for (const issue of input.issues) {
if (issue.assignee) people.set(issue.assignee.id, issue.assignee);
people.set(issue.reporter.id, issue.reporter);
}
// Created vs resolved, per week.
const weekKeys = weeks(from, to);
const created = new Map(weekKeys.map((week) => [week, 0]));
const resolved = new Map(weekKeys.map((week) => [week, 0]));
for (const issue of input.issues) {
const at = Date.parse(issue.createdAt);
if (inRange(at))
created.set(weekOf(at), (created.get(weekOf(at)) ?? 0) + 1);
}
// Walk every transition once: resolutions, cycle/lead time, throughput, QA.
const cycle: number[] = [];
const lead: number[] = [];
const throughput = new Map<
string,
{ week: string; user: string | null; count: number }
>();
let passed = 0;
let failed = 0;
for (const [issueId, list] of steps) {
const issue = byId.get(issueId);
for (let i = 1; i < list.length; i++) {
const previous = list[i - 1];
const step = list[i];
if (!inRange(step.at)) continue;
if (
previous.category === StatusCategory.QA &&
step.category !== StatusCategory.QA
) {
if (step.category === StatusCategory.DONE) passed++;
else if (step.category !== StatusCategory.CANCELLED) failed++;
}
if (
step.category !== StatusCategory.DONE ||
previous.category === StatusCategory.DONE
)
continue;
const week = weekOf(step.at);
resolved.set(week, (resolved.get(week) ?? 0) + 1);
if (issue) lead.push(step.at - Date.parse(issue.createdAt));
const start = list.slice(0, i).find((s) => STARTED.includes(s.category));
if (start) cycle.push(step.at - start.at);
const key = `${week}|${step.assigneeId ?? ''}`;
const row = throughput.get(key) ?? {
week,
user: step.assigneeId,
count: 0,
};
row.count++;
throughput.set(key, row);
}
}
// Cumulative flow: tickets per status at the end of each day.
const dayEnds = days(from, to);
const counts = new Map<string, number[]>();
for (const list of steps.values()) {
dayEnds.forEach((end, index) => {
const step = stepAt(list, end);
if (!step) return;
const series =
counts.get(step.status) ?? new Array(dayEnds.length).fill(0);
series[index]++;
counts.set(step.status, series);
});
}
const order = (status: string) => {
const index = input.statusOrder.indexOf(status);
return index === -1 ? Number.MAX_SAFE_INTEGER : index;
};
return {
from: input.from.toISOString(),
to: input.to.toISOString(),
createdVsResolved: weekKeys.map((week) => ({
week,
created: created.get(week) ?? 0,
resolved: resolved.get(week) ?? 0,
})),
cumulativeFlow: {
dates: dayEnds.map(dayOf),
series: [...counts.entries()]
.sort(([a], [b]) => order(a) - order(b))
.map(([status, values]) => ({ status, counts: values })),
},
cycleTime: durations(cycle),
leadTime: durations(lead),
throughput: [...throughput.values()]
.sort((a, b) => a.week.localeCompare(b.week) || b.count - a.count)
.map((row) => ({
week: row.week,
user: row.user
? (people.get(row.user) ?? { id: row.user, name: '?' })
: null,
count: row.count,
})),
qa: {
passed,
failed,
failureRate:
passed + failed
? Math.round((failed / (passed + failed)) * 1000) / 1000
: null,
},
};
}
export interface BurnupInput {
release: ReleaseReadModel;
issues: IssueReadModel[];
timeline: IssueTimelineEntry[];
now: Date;
}
/**
* Per day, from the release's creation until it ships (or today): tickets in
* it (scope, cancelled ones aside) and how many of those are done.
*/
export function computeReleaseBurnup(
input: BurnupInput,
): ReleaseBurnupReadModel {
const { release } = input;
const steps = stepsByIssue(input.issues, input.timeline);
const membership = new Map<
string,
{ at: number; releaseId: string | null }[]
>();
for (const entry of input.timeline) {
if (entry.kind !== 'release') continue;
const list = membership.get(entry.issueId) ?? [];
list.push({ at: Date.parse(entry.at), releaseId: entry.releaseId ?? null });
membership.set(entry.issueId, list);
}
for (const issue of input.issues) {
if (!membership.has(issue.id) && issue.release) {
membership.set(issue.id, [
{ at: Date.parse(issue.createdAt), releaseId: issue.release.id },
]);
}
membership.get(issue.id)?.sort((a, b) => a.at - b.at);
}
const end = release.releasedAt
? Date.parse(release.releasedAt)
: input.now.getTime();
// At most the last 180 days, so a long-lived release stays readable.
const start = Math.max(Date.parse(release.createdAt), end - 179 * DAY);
const dayEnds = days(start, end);
const scope: number[] = [];
const done: number[] = [];
for (const time of dayEnds) {
let inScope = 0;
let finished = 0;
for (const [issueId, list] of membership) {
const last = list.filter((m) => m.at <= time).at(-1);
if (last?.releaseId !== release.id) continue;
const step = stepAt(steps.get(issueId) ?? [], time);
if (!step || step.category === StatusCategory.CANCELLED) continue;
inScope++;
if (step.category === StatusCategory.DONE) finished++;
}
scope.push(inScope);
done.push(finished);
}
return {
releaseId: release.id,
targetDate: release.targetDate,
dates: dayEnds.map(dayOf),
scope,
done,
};
}

View File

@@ -10,11 +10,16 @@ export class ChangeIssueStatusCommandHandler
constructor(private readonly access: WriteAccess) {} constructor(private readonly access: WriteAccess) {}
async execute(command: ChangeIssueStatusCommand): Promise<CommandResult> { async execute(command: ChangeIssueStatusCommand): Promise<CommandResult> {
const { issue } = await this.access.issueForContributor( const { issue, project } = await this.access.issueForContributor(
command.issueId, command.issueId,
command.actorId, command.actorId,
); );
issue.changeStatus(command.actorId, command.status, command.reason); issue.changeStatus(
command.actorId,
command.status,
command.reason,
project.workflow,
);
await issue.commitAndWait(); await issue.commitAndWait();
return ack(issue); return ack(issue);
} }

View File

@@ -1,12 +1,12 @@
import { Command } from '@nestjs/cqrs'; import { Command } from '@nestjs/cqrs';
import { IssueStatus } from '@task-stream/api/tracker/domain';
import { CommandResult } from './command-result'; import { CommandResult } from './command-result';
export class ChangeIssueStatusCommand extends Command<CommandResult> { export class ChangeIssueStatusCommand extends Command<CommandResult> {
constructor( constructor(
public readonly actorId: string, public readonly actorId: string,
public readonly issueId: string, public readonly issueId: string,
public readonly status: IssueStatus, /** Status id of the project's workflow. */
public readonly status: string,
/** Required for QA_FAILED and CANCELLED. */ /** Required for QA_FAILED and CANCELLED. */
public readonly reason?: string | null, public readonly reason?: string | null,
) { ) {

View File

@@ -1,5 +1,6 @@
import { CreateProjectCommandHandler } from './create-project.command-handler'; import { CreateProjectCommandHandler } from './create-project.command-handler';
import { UpdateProjectCommandHandler } from './update-project.command-handler'; import { UpdateProjectCommandHandler } from './update-project.command-handler';
import { UpdateProjectSettingsCommandHandler } from './update-project-settings.command-handler';
import { AddProjectMemberCommandHandler } from './add-project-member.command-handler'; import { AddProjectMemberCommandHandler } from './add-project-member.command-handler';
import { ChangeProjectMemberRoleCommandHandler } from './change-project-member-role.command-handler'; import { ChangeProjectMemberRoleCommandHandler } from './change-project-member-role.command-handler';
import { RemoveProjectMemberCommandHandler } from './remove-project-member.command-handler'; import { RemoveProjectMemberCommandHandler } from './remove-project-member.command-handler';
@@ -17,10 +18,18 @@ import { CreateReleaseCommandHandler } from './create-release.command-handler';
import { UpdateReleaseCommandHandler } from './update-release.command-handler'; import { UpdateReleaseCommandHandler } from './update-release.command-handler';
import { PublishReleaseCommandHandler } from './publish-release.command-handler'; import { PublishReleaseCommandHandler } from './publish-release.command-handler';
import { ArchiveReleaseCommandHandler } from './archive-release.command-handler'; import { ArchiveReleaseCommandHandler } from './archive-release.command-handler';
import { DeleteProjectCommandHandler } from './delete-project.command-handler';
import { DeleteIssueCommandHandler } from './delete-issue.command-handler';
import { DeleteReleaseCommandHandler } from './delete-release.command-handler';
import { PurgeCommentCommandHandler } from './purge-comment.command-handler';
import { DropLinksToIssueCommandHandler } from './drop-links-to-issue.command-handler';
import { DetachIssueFromReleaseCommandHandler } from './detach-issue-from-release.command-handler';
import { UnassignIssueCommandHandler } from './unassign-issue.command-handler';
export const COMMAND_HANDLERS = [ export const COMMAND_HANDLERS = [
CreateProjectCommandHandler, CreateProjectCommandHandler,
UpdateProjectCommandHandler, UpdateProjectCommandHandler,
UpdateProjectSettingsCommandHandler,
AddProjectMemberCommandHandler, AddProjectMemberCommandHandler,
ChangeProjectMemberRoleCommandHandler, ChangeProjectMemberRoleCommandHandler,
RemoveProjectMemberCommandHandler, RemoveProjectMemberCommandHandler,
@@ -38,4 +47,12 @@ export const COMMAND_HANDLERS = [
UpdateReleaseCommandHandler, UpdateReleaseCommandHandler,
PublishReleaseCommandHandler, PublishReleaseCommandHandler,
ArchiveReleaseCommandHandler, ArchiveReleaseCommandHandler,
DeleteProjectCommandHandler,
// Emitted by sagas only (cascades and reactions to other aggregates).
DeleteIssueCommandHandler,
DeleteReleaseCommandHandler,
PurgeCommentCommandHandler,
DropLinksToIssueCommandHandler,
DetachIssueFromReleaseCommandHandler,
UnassignIssueCommandHandler,
]; ];

View File

@@ -19,12 +19,17 @@ export class CreateIssueCommandHandler
async execute(command: CreateIssueCommand): Promise<CommandResult> { async execute(command: CreateIssueCommand): Promise<CommandResult> {
const project = await this.access.project(command.projectId); const project = await this.access.project(command.projectId);
project.assertCanContribute(command.actorId); project.assertCanContribute(command.actorId);
// Before taking a number: an invalid type or area must not burn one.
const workflow = project.workflow;
workflow.assertActiveType(command.type);
workflow.assertActiveArea(command.details.area);
if (command.assigneeId) if (command.assigneeId)
this.access.assertAssignable(project, command.assigneeId); this.access.assertAssignable(project, command.assigneeId);
if (command.releaseId) if (command.releaseId)
await this.access.openReleaseOf(project, command.releaseId); await this.access.openReleaseOf(project, command.releaseId);
const issue = this.issueFactory.create({ const issue = this.issueFactory.create(
{
...command.details, ...command.details,
projectId: project.id, projectId: project.id,
projectKey: project.key, projectKey: project.key,
@@ -33,7 +38,9 @@ export class CreateIssueCommandHandler
title: command.title, title: command.title,
reporterId: command.actorId, reporterId: command.actorId,
assigneeId: command.assigneeId, assigneeId: command.assigneeId,
}); },
workflow,
);
if (command.releaseId) issue.setRelease(command.actorId, command.releaseId); if (command.releaseId) issue.setRelease(command.actorId, command.releaseId);
this.eventPublisher.mergeObjectContext(issue); this.eventPublisher.mergeObjectContext(issue);

View File

@@ -1,12 +1,13 @@
import { Command } from '@nestjs/cqrs'; import { Command } from '@nestjs/cqrs';
import { IssueDetails, IssueType } from '@task-stream/api/tracker/domain'; import { IssueDetails } from '@task-stream/api/tracker/domain';
import { CommandResult } from './command-result'; import { CommandResult } from './command-result';
export class CreateIssueCommand extends Command<CommandResult> { export class CreateIssueCommand extends Command<CommandResult> {
constructor( constructor(
public readonly actorId: string, public readonly actorId: string,
public readonly projectId: string, public readonly projectId: string,
public readonly type: IssueType, /** Ticket type id of the project's settings. */
public readonly type: string,
public readonly title: string, public readonly title: string,
public readonly details: Omit<IssueDetails, 'title' | 'type'>, public readonly details: Omit<IssueDetails, 'title' | 'type'>,
public readonly assigneeId?: string | null, public readonly assigneeId?: string | null,

View File

@@ -19,6 +19,7 @@ export class CreateProjectCommandHandler
key: command.key, key: command.key,
name: command.name, name: command.name,
description: command.description, description: command.description,
isPublic: command.isPublic,
ownerId: command.actorId, ownerId: command.actorId,
}); });
await this.projectKeys.reserve(project.key, project.id); await this.projectKeys.reserve(project.key, project.id);

View File

@@ -7,6 +7,7 @@ export class CreateProjectCommand extends Command<CommandResult> {
public readonly key: string, public readonly key: string,
public readonly name: string, public readonly name: string,
public readonly description?: string | null, public readonly description?: string | null,
public readonly isPublic = false,
) { ) {
super(); super();
} }

View File

@@ -0,0 +1,18 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { Issue } from '@task-stream/api/tracker/domain';
import { DeleteIssueCommand } from './delete-issue.command';
import { WriteAccess } from './write-access';
@CommandHandler(DeleteIssueCommand)
export class DeleteIssueCommandHandler
implements ICommandHandler<DeleteIssueCommand>
{
constructor(private readonly access: WriteAccess) {}
async execute(command: DeleteIssueCommand): Promise<void> {
const issue = await this.access.live(command.issueId, Issue);
if (!issue) return;
issue.delete(command.actorId);
await issue.commitAndWait();
}
}

View File

@@ -0,0 +1,11 @@
import { Command } from '@nestjs/cqrs';
/** Saga only: deletes a ticket of a deleted project. */
export class DeleteIssueCommand extends Command<void> {
constructor(
public readonly actorId: string,
public readonly issueId: string,
) {
super();
}
}

View File

@@ -0,0 +1,25 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { ProjectKeysRepository } from '../ports/project-keys.repository';
import { CommandResult, ack } from './command-result';
import { DeleteProjectCommand } from './delete-project.command';
import { WriteAccess } from './write-access';
/** Tickets, comments and releases follow through ProjectDeletionSaga. */
@CommandHandler(DeleteProjectCommand)
export class DeleteProjectCommandHandler
implements ICommandHandler<DeleteProjectCommand>
{
constructor(
private readonly access: WriteAccess,
private readonly projectKeys: ProjectKeysRepository,
) {}
async execute(command: DeleteProjectCommand): Promise<CommandResult> {
const project = await this.access.project(command.projectId);
project.delete(command.actorId, command.confirmKey);
await project.commitAndWait();
// The key can be used again by a new project.
await this.projectKeys.release(project.key, project.id);
return ack(project);
}
}

View File

@@ -0,0 +1,13 @@
import { Command } from '@nestjs/cqrs';
import { CommandResult } from './command-result';
/** The owner deletes a project, typing its key to confirm. */
export class DeleteProjectCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly projectId: string,
public readonly confirmKey: string,
) {
super();
}
}

View File

@@ -0,0 +1,18 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { Release } from '@task-stream/api/tracker/domain';
import { DeleteReleaseCommand } from './delete-release.command';
import { WriteAccess } from './write-access';
@CommandHandler(DeleteReleaseCommand)
export class DeleteReleaseCommandHandler
implements ICommandHandler<DeleteReleaseCommand>
{
constructor(private readonly access: WriteAccess) {}
async execute(command: DeleteReleaseCommand): Promise<void> {
const release = await this.access.live(command.releaseId, Release);
if (!release) return;
release.delete(command.actorId);
await release.commitAndWait();
}
}

View File

@@ -0,0 +1,11 @@
import { Command } from '@nestjs/cqrs';
/** Saga only: deletes a release of a deleted project. */
export class DeleteReleaseCommand extends Command<void> {
constructor(
public readonly actorId: string,
public readonly releaseId: string,
) {
super();
}
}

View File

@@ -0,0 +1,21 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { Issue, Project } from '@task-stream/api/tracker/domain';
import { DetachIssueFromReleaseCommand } from './detach-issue-from-release.command';
import { WriteAccess } from './write-access';
@CommandHandler(DetachIssueFromReleaseCommand)
export class DetachIssueFromReleaseCommandHandler
implements ICommandHandler<DetachIssueFromReleaseCommand>
{
constructor(private readonly access: WriteAccess) {}
async execute(command: DetachIssueFromReleaseCommand): Promise<void> {
const issue = await this.access.live(command.issueId, Issue);
if (!issue || issue.releaseId !== command.releaseId) return;
const project = await this.access.live(issue.projectId, Project);
// Finished tickets stay: they are what the release shipped.
if (!project || project.workflow.isFinished(issue.status)) return;
issue.setRelease(command.actorId, null);
await issue.commitAndWait();
}
}

View File

@@ -0,0 +1,12 @@
import { Command } from '@nestjs/cqrs';
/** Saga only: takes an unfinished ticket out of an archived release. */
export class DetachIssueFromReleaseCommand extends Command<void> {
constructor(
public readonly actorId: string,
public readonly issueId: string,
public readonly releaseId: string,
) {
super();
}
}

View File

@@ -0,0 +1,19 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { Issue, Project } from '@task-stream/api/tracker/domain';
import { DropLinksToIssueCommand } from './drop-links-to-issue.command';
import { WriteAccess } from './write-access';
@CommandHandler(DropLinksToIssueCommand)
export class DropLinksToIssueCommandHandler
implements ICommandHandler<DropLinksToIssueCommand>
{
constructor(private readonly access: WriteAccess) {}
async execute(command: DropLinksToIssueCommand): Promise<void> {
const issue = await this.access.live(command.issueId, Issue);
// Gone, or about to be (its project was deleted too): nothing to tidy.
if (!issue || !(await this.access.live(issue.projectId, Project))) return;
issue.unlinkAllTo(command.actorId, command.targetIssueId);
await issue.commitAndWait();
}
}

View File

@@ -0,0 +1,12 @@
import { Command } from '@nestjs/cqrs';
/** Saga only: removes the links of `issueId` that point to a deleted ticket. */
export class DropLinksToIssueCommand extends Command<void> {
constructor(
public readonly actorId: string,
public readonly issueId: string,
public readonly targetIssueId: string,
) {
super();
}
}

View File

@@ -17,4 +17,12 @@ export * from './set-issue-release.command';
export * from './unlink-issues.command'; export * from './unlink-issues.command';
export * from './update-issue.command'; export * from './update-issue.command';
export * from './update-project.command'; export * from './update-project.command';
export * from './update-project-settings.command';
export * from './update-release.command'; export * from './update-release.command';
export * from './delete-project.command';
export * from './delete-issue.command';
export * from './delete-release.command';
export * from './purge-comment.command';
export * from './drop-links-to-issue.command';
export * from './detach-issue-from-release.command';
export * from './unassign-issue.command';

View File

@@ -1,5 +1,4 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { IssueWorkflow } from '@task-stream/api/tracker/domain';
import { ReleaseIssuesRepository } from '../ports/release-issues.repository'; import { ReleaseIssuesRepository } from '../ports/release-issues.repository';
import { CommandResult, ack } from './command-result'; import { CommandResult, ack } from './command-result';
import { PublishReleaseCommand } from './publish-release.command'; import { PublishReleaseCommand } from './publish-release.command';
@@ -16,9 +15,9 @@ export class PublishReleaseCommandHandler
async execute(command: PublishReleaseCommand): Promise<CommandResult> { async execute(command: PublishReleaseCommand): Promise<CommandResult> {
const release = await this.access.release(command.releaseId); const release = await this.access.release(command.releaseId);
(await this.access.project(release.projectId)).assertCanManage( const project = await this.access.project(release.projectId);
command.actorId, project.assertCanManage(command.actorId);
); const workflow = project.workflow;
// Rebuilt from the event store (source of truth), not from the read model. // Rebuilt from the event store (source of truth), not from the read model.
const candidates = await this.releaseIssues.findIssueIds(release.id); const candidates = await this.releaseIssues.findIssueIds(release.id);
@@ -27,8 +26,7 @@ export class PublishReleaseCommandHandler
); );
const unfinished = issues.filter( const unfinished = issues.filter(
(issue) => (issue) =>
issue.releaseId === release.id && issue.releaseId === release.id && !workflow.isFinished(issue.status),
!IssueWorkflow.isFinished(issue.status),
).length; ).length;
release.ship(command.actorId, unfinished, command.force); release.ship(command.actorId, unfinished, command.force);

View File

@@ -0,0 +1,18 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { Comment } from '@task-stream/api/tracker/domain';
import { PurgeCommentCommand } from './purge-comment.command';
import { WriteAccess } from './write-access';
@CommandHandler(PurgeCommentCommand)
export class PurgeCommentCommandHandler
implements ICommandHandler<PurgeCommentCommand>
{
constructor(private readonly access: WriteAccess) {}
async execute(command: PurgeCommentCommand): Promise<void> {
const comment = await this.access.live(command.commentId, Comment);
if (!comment || comment.deleted) return;
comment.delete(command.actorId, true);
await comment.commitAndWait();
}
}

View File

@@ -0,0 +1,11 @@
import { Command } from '@nestjs/cqrs';
/** Saga only: deletes a comment of a deleted ticket. */
export class PurgeCommentCommand extends Command<void> {
constructor(
public readonly actorId: string,
public readonly commentId: string,
) {
super();
}
}

View File

@@ -0,0 +1,19 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { Issue } from '@task-stream/api/tracker/domain';
import { UnassignIssueCommand } from './unassign-issue.command';
import { WriteAccess } from './write-access';
@CommandHandler(UnassignIssueCommand)
export class UnassignIssueCommandHandler
implements ICommandHandler<UnassignIssueCommand>
{
constructor(private readonly access: WriteAccess) {}
async execute(command: UnassignIssueCommand): Promise<void> {
const issue = await this.access.live(command.issueId, Issue);
// Someone may have reassigned it meanwhile: only undo our user's assignment.
if (!issue || issue.assigneeId !== command.userId) return;
issue.assign(command.actorId, null);
await issue.commitAndWait();
}
}

View File

@@ -0,0 +1,12 @@
import { Command } from '@nestjs/cqrs';
/** Saga only: unassigns a ticket if it is still assigned to `userId` (deactivated in Pireon). */
export class UnassignIssueCommand extends Command<void> {
constructor(
public readonly actorId: string,
public readonly issueId: string,
public readonly userId: string,
) {
super();
}
}

View File

@@ -10,11 +10,11 @@ export class UpdateIssueCommandHandler
constructor(private readonly access: WriteAccess) {} constructor(private readonly access: WriteAccess) {}
async execute(command: UpdateIssueCommand): Promise<CommandResult> { async execute(command: UpdateIssueCommand): Promise<CommandResult> {
const { issue } = await this.access.issueForContributor( const { issue, project } = await this.access.issueForContributor(
command.issueId, command.issueId,
command.actorId, command.actorId,
); );
issue.updateDetails(command.actorId, command.changes); issue.updateDetails(command.actorId, command.changes, project.workflow);
await issue.commitAndWait(); await issue.commitAndWait();
return ack(issue); return ack(issue);
} }

View File

@@ -0,0 +1,18 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { CommandResult, ack } from './command-result';
import { UpdateProjectSettingsCommand } from './update-project-settings.command';
import { WriteAccess } from './write-access';
@CommandHandler(UpdateProjectSettingsCommand)
export class UpdateProjectSettingsCommandHandler
implements ICommandHandler<UpdateProjectSettingsCommand>
{
constructor(private readonly access: WriteAccess) {}
async execute(command: UpdateProjectSettingsCommand): Promise<CommandResult> {
const project = await this.access.project(command.projectId);
project.updateSettings(command.actorId, command.settings);
await project.commitAndWait();
return ack(project);
}
}

View File

@@ -0,0 +1,14 @@
import { Command } from '@nestjs/cqrs';
import { ProjectSettings } from '@task-stream/api/tracker/domain';
import { CommandResult } from './command-result';
/** Replaces the statuses, transitions, ticket types and areas of a project. */
export class UpdateProjectSettingsCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly projectId: string,
public readonly settings: ProjectSettings,
) {
super();
}
}

View File

@@ -5,7 +5,11 @@ export class UpdateProjectCommand extends Command<CommandResult> {
constructor( constructor(
public readonly actorId: string, public readonly actorId: string,
public readonly projectId: string, public readonly projectId: string,
public readonly changes: { name?: string; description?: string | null }, public readonly changes: {
name?: string;
description?: string | null;
isPublic?: boolean;
},
) { ) {
super(); super();
} }

View File

@@ -13,6 +13,11 @@ import {
} from '@task-stream/api/tracker/domain'; } from '@task-stream/api/tracker/domain';
import { UserDirectory } from '../ports/user-directory'; import { UserDirectory } from '../ports/user-directory';
type Aggregate = VersionedAggregateRoot & {
createdAt?: string;
deleted?: boolean;
};
/** Loads aggregates from the event store, failing with domain errors. */ /** Loads aggregates from the event store, failing with domain errors. */
@Injectable() @Injectable()
export class WriteAccess { export class WriteAccess {
@@ -78,13 +83,31 @@ export class WriteAccess {
} }
} }
private async load<T extends VersionedAggregateRoot & { createdAt?: string }>( /**
* For saga commands, which must be idempotent: the aggregate, or null when
* it does not exist or was deleted (nothing left to do).
*/
async live<T extends Aggregate>(
id: string,
AggregateCls: Type<T>,
): Promise<T | null> {
try {
return await this.load(id, AggregateCls);
} catch (error) {
if (error instanceof NotFoundError) return null;
throw error;
}
}
private async load<T extends Aggregate>(
id: string, id: string,
AggregateCls: Type<T>, AggregateCls: Type<T>,
): Promise<T> { ): Promise<T> {
const aggregate = await this.rehydrator.rehydrate(id, AggregateCls); const aggregate = await this.rehydrator.rehydrate(id, AggregateCls);
// The stream exists but belongs to another kind of aggregate. // The stream exists but belongs to another kind of aggregate, or the
if (!aggregate.createdAt) throw new NotFoundError(AggregateCls.name, id); // aggregate was deleted: same answer, it is not there.
if (!aggregate.createdAt || aggregate.deleted)
throw new NotFoundError(AggregateCls.name, id);
return aggregate; return aggregate;
} }
} }

View File

@@ -38,7 +38,7 @@ export class CommentsService {
} }
list( list(
viewerId: string, viewerId: string | null,
issueId: string, issueId: string,
limit?: number, limit?: number,
offset?: number, offset?: number,

View File

@@ -7,9 +7,7 @@ import {
IssueLinkedEvent, IssueLinkedEvent,
IssueLinkType, IssueLinkType,
IssueReleaseChangedEvent, IssueReleaseChangedEvent,
IssueStatus,
IssueStatusChangedEvent, IssueStatusChangedEvent,
IssueType,
IssueUnlinkedEvent, IssueUnlinkedEvent,
IssueUpdatedEvent, IssueUpdatedEvent,
ProjectCreatedEvent, ProjectCreatedEvent,
@@ -17,7 +15,9 @@ import {
ProjectMemberRemovedEvent, ProjectMemberRemovedEvent,
ProjectMemberRoleChangedEvent, ProjectMemberRoleChangedEvent,
ProjectRole, ProjectRole,
ProjectSettingsChangedEvent,
ProjectUpdatedEvent, ProjectUpdatedEvent,
ProjectWorkflow,
ReleaseArchivedEvent, ReleaseArchivedEvent,
ReleaseCreatedEvent, ReleaseCreatedEvent,
ReleaseShippedEvent, ReleaseShippedEvent,
@@ -31,28 +31,19 @@ export interface SummaryContext {
issueKey: string; issueKey: string;
projectKey: string; projectKey: string;
releaseName: string; releaseName: string;
/** The project's workflow: names of statuses and types. */
workflow: ProjectWorkflow;
} }
/* The activity feed is read by people: Spanish, with the same labels as the web app. */ /* The activity feed is read by people: Spanish, with the same labels as the web app. */
const STATUS: Record<IssueStatus, string> = { /** Default types keep their lowercase wording in sentences ("creó WEB-3 (tarea)"). */
[IssueStatus.BACKLOG]: 'Backlog', const DEFAULT_TYPE_WORDING: Record<string, string> = {
[IssueStatus.TODO]: 'Por hacer', BUG: 'bug',
[IssueStatus.IN_PROGRESS]: 'En curso', FEATURE: 'feature',
[IssueStatus.IN_REVIEW]: 'En revisión', UI_CHANGE: 'cambio de UI',
[IssueStatus.READY_FOR_QA]: 'Listo para QA', TASK: 'tarea',
[IssueStatus.IN_QA]: 'En QA', QA: 'QA',
[IssueStatus.QA_FAILED]: 'QA fallido',
[IssueStatus.DONE]: 'Hecho',
[IssueStatus.CANCELLED]: 'Cancelado',
};
const TYPE: Record<IssueType, string> = {
[IssueType.BUG]: 'bug',
[IssueType.FEATURE]: 'feature',
[IssueType.UI_CHANGE]: 'cambio de UI',
[IssueType.TASK]: 'tarea',
[IssueType.QA]: 'QA',
}; };
const LINK: Record<IssueLinkType, string> = { const LINK: Record<IssueLinkType, string> = {
@@ -98,6 +89,8 @@ export function describeEvent(
return `${actor} creó el proyecto ${event.project.key}`; return `${actor} creó el proyecto ${event.project.key}`;
if (event instanceof ProjectUpdatedEvent) if (event instanceof ProjectUpdatedEvent)
return `${actor} actualizó el proyecto ${ctx.projectKey}`; return `${actor} actualizó el proyecto ${ctx.projectKey}`;
if (event instanceof ProjectSettingsChangedEvent)
return `${actor} cambió los estados, tipos o áreas del proyecto ${ctx.projectKey}`;
if (event instanceof ProjectMemberAddedEvent) { if (event instanceof ProjectMemberAddedEvent) {
return `${actor} añadió a ${ctx.user(event.userId)} como ${ROLE[event.role]}`; return `${actor} añadió a ${ctx.user(event.userId)} como ${ROLE[event.role]}`;
} }
@@ -111,13 +104,19 @@ export function describeEvent(
} }
if (event instanceof IssueCreatedEvent) { if (event instanceof IssueCreatedEvent) {
return `${actor} creó ${event.issue.key} (${TYPE[event.issue.type]}): ${event.issue.title}`; const type =
DEFAULT_TYPE_WORDING[event.issue.type] ??
ctx.workflow.typeName(event.issue.type);
return `${actor} creó ${event.issue.key} (${type}): ${event.issue.title}`;
} }
if (event instanceof IssueUpdatedEvent) { if (event instanceof IssueUpdatedEvent) {
return `${actor} cambió ${list(Object.keys(event.changes).map((f) => FIELD[f] ?? f))} de ${issueKey}`; return `${actor} cambió ${list(Object.keys(event.changes).map((f) => FIELD[f] ?? f))} de ${issueKey}`;
} }
if (event instanceof IssueStatusChangedEvent) { if (event instanceof IssueStatusChangedEvent) {
return `${actor} movió ${issueKey} de ${STATUS[event.from]} a ${STATUS[event.to]}${event.reason ? `: ${event.reason}` : ''}`; // Names as they were then (old events: as configured now).
const from = event.meta?.fromName ?? ctx.workflow.statusName(event.from);
const to = event.meta?.toName ?? ctx.workflow.statusName(event.to);
return `${actor} movió ${issueKey} de ${from} a ${to}${event.reason ? `: ${event.reason}` : ''}`;
} }
if (event instanceof IssueAssignedEvent) { if (event instanceof IssueAssignedEvent) {
return event.assigneeId return event.assigneeId

View File

@@ -21,6 +21,11 @@ import {
ReleaseShippedEvent, ReleaseShippedEvent,
ReleaseUpdatedEvent, ReleaseUpdatedEvent,
TrackerEvent, TrackerEvent,
DEFAULT_PROJECT_SETTINGS,
SYSTEM_ACTOR_ID,
SYSTEM_ACTOR_NAME,
ProjectSettingsChangedEvent,
ProjectWorkflow,
} from '@task-stream/api/tracker/domain'; } from '@task-stream/api/tracker/domain';
import { ChangeFeed } from '../ports/change-feed'; import { ChangeFeed } from '../ports/change-feed';
import { FindIssuesRepository } from '../ports/find-issues.repository'; import { FindIssuesRepository } from '../ports/find-issues.repository';
@@ -35,6 +40,7 @@ import { versionOf } from './read-model-notifier';
@EventsHandler( @EventsHandler(
ProjectCreatedEvent, ProjectCreatedEvent,
ProjectUpdatedEvent, ProjectUpdatedEvent,
ProjectSettingsChangedEvent,
ProjectMemberAddedEvent, ProjectMemberAddedEvent,
ProjectMemberRemovedEvent, ProjectMemberRemovedEvent,
ProjectMemberRoleChangedEvent, ProjectMemberRoleChangedEvent,
@@ -76,12 +82,16 @@ export class ActivityEventHandler implements IEventHandler<TrackerEvent> {
? await this.findReleases.findById(releaseId) ? await this.findReleases.findById(releaseId)
: null; : null;
const project = await this.findProjects.findById(event.projectId); const project = await this.findProjects.findById(event.projectId);
// The project was deleted (cascade in progress): no feed to write to.
if (!project) return;
const mentioned = [ const mentioned = [
event.actorId, event.actorId,
(event as { userId?: string }).userId, (event as { userId?: string }).userId,
(event as { assigneeId?: string }).assigneeId, (event as { assigneeId?: string }).assigneeId,
]; ];
const users = await this.users.refs(mentioned); const users = await this.users.refs(mentioned);
const actorOf = (id: string) =>
id === SYSTEM_ACTOR_ID ? { id, name: SYSTEM_ACTOR_NAME } : users.of(id);
const activity: ActivityReadModel = { const activity: ActivityReadModel = {
id: `${event.aggregateId}:${versionOf(event)}`, id: `${event.aggregateId}:${versionOf(event)}`,
@@ -89,13 +99,16 @@ export class ActivityEventHandler implements IEventHandler<TrackerEvent> {
projectId: event.projectId, projectId: event.projectId,
issueId, issueId,
issueKey, issueKey,
actor: users.of(event.actorId), actor: actorOf(event.actorId),
summary: describeEvent(event, { summary: describeEvent(event, {
actor: users.of(event.actorId).name, actor: actorOf(event.actorId).name,
user: (id) => users.ofNullable(id)?.name ?? '?', user: (id) => users.ofNullable(id)?.name ?? '?',
issueKey: issueKey ?? '', issueKey: issueKey ?? '',
projectKey: project?.key ?? '', projectKey: project?.key ?? '',
releaseName: release?.name ?? '', releaseName: release?.name ?? '',
workflow: new ProjectWorkflow(
project?.settings ?? DEFAULT_PROJECT_SETTINGS,
),
}), }),
occurredAt: event.occurredAt, occurredAt: event.occurredAt,
}; };

View File

@@ -19,10 +19,18 @@ import { ReleaseCreatedEventHandler } from './release-created.event-handler';
import { ReleaseShippedEventHandler } from './release-shipped.event-handler'; import { ReleaseShippedEventHandler } from './release-shipped.event-handler';
import { ReleaseUpdatedEventHandler } from './release-updated.event-handler'; import { ReleaseUpdatedEventHandler } from './release-updated.event-handler';
import { ReadModelNotifier } from './read-model-notifier'; import { ReadModelNotifier } from './read-model-notifier';
import { IssueTimelineEventHandler } from './issue-timeline.event-handler';
import { ProjectSettingsChangedEventHandler } from './project-settings-changed.event-handler';
import { ProjectDeletedEventHandler } from './project-deleted.event-handler';
import { IssueDeletedEventHandler } from './issue-deleted.event-handler';
import { ReleaseDeletedEventHandler } from './release-deleted.event-handler';
/** Read-model projections (MongoDB) + realtime, fed by the EventsBridge. */ /** Read-model projections (MongoDB) + realtime, fed by the EventsBridge. */
export const EVENT_HANDLERS = [ export const EVENT_HANDLERS = [
ActivityEventHandler, ActivityEventHandler,
ProjectDeletedEventHandler,
IssueDeletedEventHandler,
ReleaseDeletedEventHandler,
CommentAddedEventHandler, CommentAddedEventHandler,
CommentDeletedEventHandler, CommentDeletedEventHandler,
CommentEditedEventHandler, CommentEditedEventHandler,
@@ -31,6 +39,7 @@ export const EVENT_HANDLERS = [
IssueLinkedEventHandler, IssueLinkedEventHandler,
IssueReleaseChangedEventHandler, IssueReleaseChangedEventHandler,
IssueStatusChangedEventHandler, IssueStatusChangedEventHandler,
IssueTimelineEventHandler,
IssueUnlinkedEventHandler, IssueUnlinkedEventHandler,
IssueUpdatedEventHandler, IssueUpdatedEventHandler,
ProjectCreatedEventHandler, ProjectCreatedEventHandler,
@@ -38,6 +47,7 @@ export const EVENT_HANDLERS = [
ProjectMemberRemovedEventHandler, ProjectMemberRemovedEventHandler,
ProjectMemberRoleChangedEventHandler, ProjectMemberRoleChangedEventHandler,
ProjectUpdatedEventHandler, ProjectUpdatedEventHandler,
ProjectSettingsChangedEventHandler,
ReleaseArchivedEventHandler, ReleaseArchivedEventHandler,
ReleaseCreatedEventHandler, ReleaseCreatedEventHandler,
ReleaseShippedEventHandler, ReleaseShippedEventHandler,

Some files were not shown because too many files have changed in this diff Show More