first commit

This commit is contained in:
2026-10-03 13:40:26 +02:00
parent 732a5b5a16
commit 566f415fa7
555 changed files with 62704 additions and 0 deletions

60
docs/01-vision-general.md Normal file
View File

@@ -0,0 +1,60 @@
# 1. Visión general
## Qué resuelve
| Necesidad | Cómo se resuelve |
| ---------------- | ----------------------------------------------------------------------------------------------------- |
| 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas |
| 🎨 Cambios de UI | Tipo `UI_CHANGE` con enlace al frame de Figma (validado contra `figma.com`) |
| ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo |
| 🧪 QA | Estados `READY_FOR_QA → IN_QA → DONE / QA_FAILED`; fallar QA exige un motivo |
| 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) |
| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona |
| 💬 Comentarios | Decisiones y explicaciones dentro del ticket; el autor edita, el admin modera |
| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA) |
| 📊 Seguimiento | Panel con contadores, gráfico por estado, carga por persona, feed de actividad e historial por ticket |
| ⚡ Tiempo real | Tablero, comentarios, actividad, releases y membresías se actualizan solos (GraphQL subscriptions) |
Los **usuarios no se gestionan aquí**: vienen del servidor de identidad **Pireon**
([Swagger](https://oauth.pireon.dev/api/documentation)). TaskStream gestiona proyectos, membresías y roles por
proyecto (`OWNER`, `ADMIN`, `MEMBER`, `VIEWER`).
## Stack
| Pieza | Tecnología |
| ------------------- | ----------------------------------------------------------------------------------------------------------- |
| Monorepo | Nx 23 |
| API | NestJS 11, `@nestjs/cqrs`, GraphQL (Apollo Server 5, code-first) con subscriptions `graphql-ws` |
| Escritura | PostgreSQL 17: _event store_ + tablas de lookup (TypeORM) |
| Escritura → lectura | Redis 7: Redis Stream (eventos) y pub/sub (tiempo real); también caché de sesiones |
| Lectura | MongoDB 8: vistas desnormalizadas (Mongoose) |
| Identidad | Pireon (OAuth2 / Laravel Passport) |
| Cliente | Angular 22 (standalone, zoneless, Signal Forms), NgRx Signals (plugin de eventos), Apollo Angular, `@pi/ui` |
| Tests | Jest (API), Vitest (web), e2e de la API contra Docker |
| Despliegue | Docker: imagen Node de la API y nginx para la web |
## Estructura del monorepo
```
apps/
api/ composición de la API (NestJS): config, GraphQL, conexiones, e2e
web/ composición del cliente (Angular): rutas, providers, estilos, nginx
libs/
api/{shared,identity,tracker}/{domain,application,infrastructure,presenters}
web/{shared,identity,tracker}/{domain,application,infrastructure,presenters} (+ web/shared/ui)
docs/ esta documentación
codegen.ts tipos GraphQL del cliente generados desde apps/api/schema.gql
Dockerfile dos destinos: api y web
docker-compose.yml
```
API y web comparten la misma estructura: **contexto** (`shared`, `identity`, `tracker`) × **capa** (`domain`,
`application`, `infrastructure`, `presenters`). Los imports siguen el mismo patrón:
`@task-stream/api/tracker/domain`, `@task-stream/web/tracker/application`…
### Contextos
- **shared**: piezas comunes. En la API, event store, Redis, `VersionedAggregateRoot`, errores de dominio. En la web, el
cliente GraphQL y la integración de `@pi/ui`.
- **identity**: quién es el usuario (Pireon), sesiones, inicio de sesión y autorización global.
- **tracker**: el producto (proyectos, tickets, comentarios, releases, actividad).

168
docs/02-arquitectura.md Normal file
View File

@@ -0,0 +1,168 @@
# 2. Arquitectura
TaskStream combina tres ideas:
1. **Arquitectura hexagonal** (puertos y adaptadores), aplicada igual en la API y en la web.
2. **CQRS**: las escrituras (commands) y las lecturas (queries) van por caminos y bases de datos distintas.
3. **Event sourcing** en la API: lo que se guarda no es el estado de los agregados sino sus eventos.
## 2.1 Capas hexagonales
Cada contexto se divide en cuatro capas, cada una en su propia librería Nx:
```mermaid
flowchart LR
P[presenters<br/>GraphQL · HTTP · páginas] --> A[application<br/>services · commands · queries · event handlers · stores]
A --> D[domain<br/>agregados · eventos · reglas · read models · puertos]
I[infrastructure<br/>Postgres · Mongo · Redis · Pireon · Apollo] -. implementa puertos .-> A
I -.-> D
```
| Capa | Contiene | Puede depender de |
| ---------------- | ------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- |
| `domain` | Modelo y reglas de negocio; puertos que necesita el dominio | `domain` |
| `application` | Casos de uso: services, commands/queries + handlers, proyecciones (API) o facades + stores (web), puertos de salida | `domain`, `application` |
| `infrastructure` | Adaptadores **de salida** que implementan los puertos (bases de datos, Redis, Pireon, GraphQL) | `domain`, `application`, `infrastructure` |
| `presenters` | Adaptadores **de entrada** (resolvers GraphQL, controllers HTTP, guards, páginas Angular) | `domain`, `application`, `presenters` |
Las reglas no son una convención: las comprueba `@nx/enforce-module-boundaries` en `eslint.config.mjs` (`npx nx run-many -t lint`).
- `domain` solo puede importar `@nestjs/common` (por `@Injectable()` de las factories) y `@nestjs/cqrs` (por `AggregateRoot`).
Nada de TypeORM, Mongoose, Redis, GraphQL, Angular, NgRx ni Apollo.
- `application` no puede importar adaptadores (TypeORM, Mongoose, Redis, Apollo…).
- `presenters` nunca importa `infrastructure` ni usa los buses CQRS: habla con los `*.service.ts` (API) o con las facades (web).
- `infrastructure` nunca importa `presenters`.
- Los contextos también están acotados: `tracker` puede usar `identity` y `shared`; `shared` no depende de nadie; la web
no depende de la API (y viceversa).
### Composición
Las apps son la _composition root_: montan cada contexto de fuera hacia dentro.
```ts
// apps/api/src/app/app.module.ts
SharedModule.withInfrastructure(SharedInfrastructureModule.forRootAsync({ … }));
TrackerPresentersModule.withApplication(
TrackerApplicationModule.withInfrastructure(TrackerInfrastructureModule),
);
```
```ts
// apps/web/src/app/app.config.ts
provideTrackerInfrastructure(); // puertos → adaptadores GraphQL
{ provide: Notifications, useExisting: ToastService } // puerto de la aplicación → toasts de @pi/ui
```
## 2.2 Flujo completo de una escritura
```mermaid
sequenceDiagram
autonumber
participant UI as Web (store)
participant R as Resolver (presenters)
participant S as IssuesService
participant H as CommandHandler
participant AR as AggregateRehydrator
participant ES as Event store (Postgres)
participant RL as EventStoreRelay
participant RS as Redis Stream
participant EB as EventsBridge
participant EH as @EventsHandler (proyecciones)
participant M as MongoDB
participant PS as Redis pub/sub
UI->>R: mutation changeIssueStatus
R->>S: changeStatus(actor, issue, status)
S->>H: CommandBus.execute(ChangeIssueStatusCommand)
H->>AR: rehydrate(issueId, Issue)
AR->>ES: getEventsByStreamId
ES-->>AR: eventos → loadFromHistory
H->>H: issue.changeStatus() → apply(IssueStatusChangedEvent)
H->>ES: commitAndWait() → INSERT (stream_id, position)
H-->>UI: CommandResult { id, key, version }
RL->>ES: eventos con published_at IS NULL
RL->>RS: XADD
EB->>RS: XREADGROUP (consumer group)
EB->>EH: handle(evento) en orden, esperando
EH->>M: UpsertMaterialized*Repository (si version es mayor)
EH->>PS: ChangeFeed.publish*
PS-->>UI: subscription issueChanged
EB->>RS: XACK
```
### Escritura: event sourcing en PostgreSQL
- Los agregados (`Project`, `Issue`, `Comment`, `Release`) extienden `VersionedAggregateRoot`, que extiende el
`AggregateRoot` de `@nestjs/cqrs` y añade la versión.
- **Crear**: una factory `@Injectable()` construye el agregado y aplica el evento de creación (`skipHandler`).
- **Modificar**: el `AggregateRehydrator` carga los eventos del stream y los reproduce (`loadFromHistory`); los métodos
del agregado validan y hacen `apply(new XEvent(…))`; el estado solo cambia en los handlers `onXEvent`.
- **Guardar**: el `EventStorePublisher` sustituye al publisher del `EventBus`, así que `commit()` escribe en la tabla
`events`. `commitAndWait()` espera a que la escritura termine (el `commit()` de Nest no devuelve la promesa).
- **Concurrencia optimista**: `UNIQUE(stream_id, position)`. Si dos peticiones modifican el mismo agregado a la vez, la
segunda falla con `ConcurrencyError` (código GraphQL `CONFLICT`).
- **Lookups**: lo que no se puede comprobar dentro de un agregado vive en tablas aparte: claves de proyecto únicas
(`project_keys`), numeración de tickets (`issue_sequences`) y nombres de release únicos (`release_names`).
### De escritura a lectura: Redis
- El **`EventStoreRelay`** copia los eventos nuevos (`published_at IS NULL`) al Redis Stream `task-stream:events`,
en orden y con `FOR UPDATE SKIP LOCKED` (varias instancias no duplican). La entrega es _at-least-once_.
- El **`EventsBridge`** consume el stream con un _consumer group_, deserializa cada evento (`EventClsRegistry` +
`@AutowiredEvent`) y lo entrega a los `@EventsHandler` registrados para su clase. A diferencia de
`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).
### Lectura: MongoDB
- Las proyecciones son `@EventsHandler` de la capa `application/event-handlers`, uno por evento (más el de actividad,
que escucha todos). Escriben a través de los puertos `UpsertMaterialized*Repository`.
- Cada documento guarda la `version` del último evento aplicado; el upsert solo se aplica si la versión es mayor, así
que reentregar un evento no tiene efecto.
- Las queries leen por los puertos `Find*Repository`. Los read models (`ProjectReadModel`, `IssueReadModel`…) están en
`domain/read-models`.
### Tiempo real
Tras actualizar Mongo, el handler publica en Redis pub/sub (`ChangeFeed`). Las subscriptions GraphQL escuchan esos
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.
### Consistencia eventual
Las mutations devuelven `CommandResult { id, key, version }` en cuanto los eventos están guardados; Mongo se actualiza
milisegundos después. Consecuencias:
- El cliente reintenta las queries que devuelven _not found_ justo después de crear algo.
- Si necesitas el dato actualizado, espera un evento de subscription con `version >= result.version`.
- Las reglas que necesitan datos exactos **no** leen Mongo: por ejemplo, "no publicar una release con tickets sin
terminar" reconstruye esos tickets desde el event store (`ReleaseIssuesRepository`).
## 2.3 El cliente web
El cliente replica las capas (ver [Cliente web](04-cliente-web.md)):
```mermaid
flowchart LR
Page[Página Angular<br/>presenters] -- métodos --> F[Facade<br/>application]
F -- injectDispatch --> Ev((Eventos))
Ev --> Red[withReducer<br/>estado del store]
Ev --> Eff[withEventHandlers<br/>efectos]
Eff -- puertos --> GW[Gateways GraphQL<br/>infrastructure]
GW -- respuesta --> Eff
Eff -- emite eventos api / realtime --> Ev
Red -- señales --> F
F -- señales --> Page
```
## 2.4 Diagrama de despliegue
```mermaid
flowchart LR
B[Navegador] -->|:8080| N[nginx<br/>web]
N -->|/api · /graphql · WS| A[API NestJS]
A --> PG[(PostgreSQL<br/>event store)]
A --> R[(Redis<br/>stream · pub/sub · caché)]
A --> MG[(MongoDB<br/>read model)]
A -->|/api/user · /oauth/token| P[Pireon]
```

277
docs/03-api.md Normal file
View File

@@ -0,0 +1,277 @@
# 3. API
API GraphQL en `/graphql` (HTTP para queries y mutations, WebSocket `graphql-ws` para subscriptions) más unos pocos
endpoints REST bajo `/api`. El esquema completo se genera en `apps/api/schema.gql`.
## 3.1 Modelo de dominio (contexto `tracker`)
```mermaid
classDiagram
class Project {
key: "TS"
name
members: (userId, role)[]
addMember() changeMemberRole() removeMember() updateDetails()
}
class Issue {
key: "TS-42"
type · area · priority · status
assigneeId · releaseId · labels · figmaUrl
links: (type, target)[]
changeStatus() assign() linkTo() unlink() setRelease() updateDetails()
}
class Comment {
body · authorId
edit() delete()
}
class Release {
name · status · targetDate
update() ship() archive()
}
Project "1" --> "*" Issue
Issue "1" --> "*" Comment
Project "1" --> "*" Release
Release "0..1" <-- "*" Issue
Issue "*" --> "*" Issue : links
```
### Roles por proyecto
| Rol | Ver | Crear/modificar tickets, comentar | Gestionar miembros y releases |
| -------------------------------- | --- | --------------------------------- | -------------------------------------------- |
| `OWNER` (quien crea el proyecto) | ✅ | ✅ | ✅ (no se le puede quitar ni cambiar el rol) |
| `ADMIN` | ✅ | ✅ | ✅ |
| `MEMBER` | ✅ | ✅ | ❌ |
| `VIEWER` | ✅ | ❌ | ❌ |
Cualquiera puede salir de un proyecto (salvo el propietario). Los no miembros reciben _not found_, para no revelar que
el proyecto existe.
### Workflow de un ticket
```mermaid
stateDiagram-v2
[*] --> BACKLOG
BACKLOG --> TODO
BACKLOG --> IN_PROGRESS
BACKLOG --> CANCELLED
TODO --> BACKLOG
TODO --> IN_PROGRESS
TODO --> CANCELLED
IN_PROGRESS --> TODO
IN_PROGRESS --> IN_REVIEW
IN_PROGRESS --> READY_FOR_QA
IN_PROGRESS --> CANCELLED
IN_REVIEW --> IN_PROGRESS
IN_REVIEW --> READY_FOR_QA
IN_REVIEW --> DONE
READY_FOR_QA --> IN_QA
READY_FOR_QA --> IN_PROGRESS
IN_QA --> DONE
IN_QA --> QA_FAILED
QA_FAILED --> IN_PROGRESS
QA_FAILED --> TODO
DONE --> TODO
DONE --> IN_PROGRESS
CANCELLED --> BACKLOG
```
- `QA_FAILED` y `CANCELLED` exigen un **motivo**, que queda en el historial.
- No se puede pasar a `DONE` saltándose QA salvo desde `IN_REVIEW` (cambios que no requieren QA).
- `Issue.allowedTransitions` en GraphQL devuelve los estados permitidos desde el actual.
### Otras reglas
- **Proyecto**: clave de 2 a 10 caracteres alfanuméricos que empieza por letra y es única (los tickets se numeran
`CLAVE-1`, `CLAVE-2`…).
- **Asignación**: solo a miembros del proyecto. Para añadir miembros, el usuario debe existir y estar activo en Pireon.
- **Relaciones**: nunca consigo mismo ni duplicadas; pueden cruzar proyectos si el actor puede ver el destino. Se
muestran en los dos tickets (`OUTWARD` en el origen, `INWARD` en el destino).
- **Releases**: solo las `PLANNED` aceptan o pierden tickets; publicar con tickets sin terminar exige `force: true`.
- **Comentarios**: solo el autor edita; borra el autor o un admin del proyecto.
- **Cambios de UI**: `figmaUrl` debe ser un enlace `https://…figma.com/…`.
### Eventos de dominio
Todo cambio es un evento guardado en el event store (stream = id del agregado). Llevan `actorId`, `occurredAt`,
`aggregateId`, `projectId` y `aggregateVersion` (la posición en el stream).
| Agregado | Eventos |
| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Project | `ProjectCreatedEvent`, `ProjectUpdatedEvent`, `ProjectMemberAddedEvent`, `ProjectMemberRoleChangedEvent`, `ProjectMemberRemovedEvent` |
| Issue | `IssueCreatedEvent`, `IssueUpdatedEvent`, `IssueStatusChangedEvent`, `IssueAssignedEvent`, `IssueLinkedEvent`, `IssueUnlinkedEvent`, `IssueReleaseChangedEvent` |
| Comment | `CommentAddedEvent`, `CommentEditedEvent`, `CommentDeletedEvent` |
| Release | `ReleaseCreatedEvent`, `ReleaseUpdatedEvent`, `ReleaseShippedEvent`, `ReleaseArchivedEvent` |
## 3.2 GraphQL
Todas las operaciones requieren `Authorization: Bearer <token de Pireon>` (en subscriptions, en el `connection_init`).
### Queries
| Query | Devuelve |
| ----------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| `me` | El usuario autenticado, con sus roles en Pireon |
| `users(search, limit)` | Usuarios de Pireon (para asignar o añadir miembros) |
| `user(id)` | Un usuario |
| `myProjects` | Proyectos de los que soy miembro |
| `project(idOrKey)` | Un proyecto. Campos calculados: `board(releaseId, assigneeId, area, perColumn)`, `stats`, `releases` |
| `issues(filter, limit, offset)` | Búsqueda de tickets (estado, tipo, área, prioridad, persona, release, etiquetas, texto) |
| `issue(idOrKey)` | Un ticket. Campos calculados: `allowedTransitions`, `linkedIssues`, `comments`, `history` |
| `release(id)` | Una release. Campos calculados: `progress`, `issues` |
| `activity(projectId, issueId?, limit, before?)` | Feed de actividad, del más reciente al más antiguo |
### Mutations
Todas devuelven `CommandResult { id, key, version }`.
| Ámbito | Mutations |
| ----------- | ----------------------------------------------------------------------------------------------------------------- |
| Proyectos | `createProject`, `updateProject`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease` |
| Comentarios | `addComment`, `editComment`, `deleteComment` |
| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `archiveRelease` |
### Subscriptions
| Subscription | Se emite cuando |
| --------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `issueChanged(projectId)` | Se crea o cambia cualquier ticket del proyecto (incluye el ticket completo y `action` = nombre del evento) |
| `commentChanged(issueId)` | Se añade, edita o borra un comentario (`comment` es `null` al borrar) |
| `activityAdded(projectId)` | Hay una nueva entrada en el feed |
| `releaseChanged(projectId)` | Cambia una release |
| `projectChanged(projectId)` | Cambian los datos o los miembros del proyecto |
| `myProjectsChanged` | Me añaden a un proyecto o me quitan de uno |
Solo los miembros pueden suscribirse a un proyecto; las conexiones sin token válido se cierran con el código 4403.
### Ejemplos
```graphql
mutation {
createProject(input: { key: "SHOP", name: "Checkout revamp" }) {
id
key
version
}
}
mutation {
createIssue(input: { projectId: "…", type: BUG, title: "El botón de pagar no responde en Safari", area: FRONTEND, priority: CRITICAL, assigneeId: "102", labels: ["safari"] }) {
id
key
version
}
}
mutation {
changeIssueStatus(input: { issueId: "…", status: QA_FAILED, reason: "Sigue fallando en Safari 17" }) {
version
}
}
mutation {
linkIssues(input: { issueId: "<FE>", type: DEPENDS_ON, targetIssueId: "<BE>" }) {
version
}
}
query {
project(idOrKey: "SHOP") {
stats {
total
open
byStatus {
key
count
}
byAssignee {
user {
name
}
count
}
}
board {
status
total
issues {
key
title
assignee {
name
}
}
}
releases {
name
status
progress {
percent
}
}
}
issue(idOrKey: "SHOP-1") {
status
allowedTransitions
links {
type
direction
issueKey
}
comments {
items {
author {
name
}
body
}
}
history {
summary
occurredAt
}
}
}
```
```ts
import { createClient } from 'graphql-ws';
const client = createClient({
url: 'ws://localhost:3000/graphql',
connectionParams: { authorization: `Bearer ${token}` },
});
client.subscribe(
{
query: 'subscription($p: ID!) { issueChanged(projectId: $p) { action version issue { key status } } }',
variables: { p: projectId },
},
{ next: console.log, error: console.error, complete: () => {} },
);
```
### Errores
Los errores de dominio llegan como errores GraphQL con `extensions.code`:
| Código | Significado |
| ----------------- | -------------------------------------------------------------- |
| `UNAUTHENTICATED` | Falta el token o Pireon lo rechaza |
| `NOT_FOUND` | No existe o no eres miembro |
| `FORBIDDEN` | No tienes permiso (p. ej. un `VIEWER` modificando) |
| `VALIDATION` | Datos inválidos (clave de proyecto, URL de Figma…) |
| `BUSINESS_RULE` | Regla de negocio (transición no permitida, release bloqueada…) |
| `CONFLICT` | Otro cambio llegó antes (concurrencia optimista): reintenta |
## 3.3 REST
| Endpoint | Uso |
| --------------------------- | ----------------------------------------------------------------------------------------------- |
| `GET /api/health` | Estado de PostgreSQL, MongoDB y Redis (público) |
| `GET /api/auth/config` | Configuración para iniciar sesión con Pireon desde el navegador (público) |
| `POST /api/auth/token` | Proxy de `/oauth/token` de Pireon: `authorization_code`, `password` y `refresh_token` (público) |
| `POST /api/webhooks/pireon` | Webhooks de Pireon firmados con HMAC (`user.deactivated`, `token.revoked`) |
Ver [Autenticación](05-autenticacion.md).

211
docs/04-cliente-web.md Normal file
View File

@@ -0,0 +1,211 @@
# 4. Cliente web
Angular 22 (componentes standalone, zoneless, Signal Forms) en `apps/web`, con la misma arquitectura hexagonal que la
API repartida en `libs/web/*`.
## 4.1 Librerías
| Librería | Contenido |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `web/shared/infrastructure` | Cliente Apollo (`provideGraphql`: HTTP + `graphql-ws`), `API_CONFIG`, puerto `AccessTokenSource` |
| `web/shared/ui` | Integración de `@pi/ui` (`providePiUi`), modo oscuro, iconos, `ToastService`, `DrawerService`, `AppShellComponent` |
| `web/identity/domain` | `Session`, `CurrentUser`, `AuthConfig` y puertos `AuthGateway`, `ProfileGateway`, `SessionStorage`, `AuthorizationFlow` |
| `web/identity/application` | `AuthFacade` (pública); por dentro `AuthStore` y eventos `authPageEvents`, `authApiEvents`, `sessionEvents` |
| `web/identity/infrastructure` | `/api/auth/*` (HTTP), `me` (GraphQL), `localStorage`, PKCE con Web Crypto; enlaza `AccessTokenSource` con la `AuthFacade` |
| `web/identity/presenters` | Página de login, callback de Pireon, `authGuard` / `guestGuard`, menú de usuario |
| `web/tracker/domain` | Modelos, enums, workflow y puertos (`ProjectsGateway`, `IssuesGateway`, `TrackerRealtimeGateway`…) |
| `web/tracker/application` | Facades `ProjectsFacade`, `ProjectFacade`, `BoardFacade`, `IssueFacade` y puerto `Notifications`; por dentro, stores y eventos |
| `web/tracker/infrastructure` | Adaptadores GraphQL con operaciones tipadas (codegen) |
| `web/tracker/presenters` | Páginas: proyectos, tablero, ticket, releases, seguimiento, actividad y miembros |
## 4.2 Estado: NgRx Signals con eventos
Las páginas **no llaman a servicios ni a los stores**: hablan con una **facade**, que lee el estado como señales y
traduce cada intención en un evento. Los stores reaccionan con reducers (estado) y con handlers (efectos), y los efectos
emiten nuevos eventos con el resultado.
Cada store agrupa sus eventos por origen:
- `*PageEvents`: lo que hace el usuario (`opened`, `issueMoved`, `issueCreateRequested`…).
- `*ApiEvents`: lo que responde la API (`loaded`, `loadFailed`, `commandSucceeded`…).
- `*RealtimeEvents`: lo que llega por subscription (`issueChanged`, `activityAdded`…).
Ejemplo real (`BoardStore`, simplificado):
```ts
export const boardPageEvents = eventGroup({
source: 'Board Page',
events: {
opened: type<BoardFilter>(),
issueMoved: type<{ issueId: string; to: IssueStatus; reason?: string | null }>(),
},
});
export const BoardStore = signalStore(
withState<BoardState>({ filter: null, columns: [], loading: false, … }),
withReducer(
on(boardPageEvents.opened, ({ payload }) => ({ filter: payload, loading: true })),
on(boardApiEvents.loaded, ({ payload }) => ({ columns: payload, loading: false })),
// Optimista: la tarjeta se mueve al instante…
on(boardPageEvents.issueMoved, ({ payload }, s) => ({ columns: moveIssue(s.columns, payload.issueId, payload.to) })),
on(boardRealtimeEvents.issueChanged, ({ payload }, s) => ({ columns: applyIssueChange(s.columns, payload.issue, s.filter) })),
),
withEventHandlers((store, events = inject(Events), issues = inject(IssuesGateway)) => ({
load$: events.on(boardPageEvents.opened).pipe(switchMap(({ payload }) => issues.board(payload).pipe(
mapResponse({ next: boardApiEvents.loaded, error: (e) => boardApiEvents.loadFailed(describeError(e)) }),
))),
// …y vuelve a su sitio si la API rechaza el cambio.
move$: events.on(boardPageEvents.issueMoved).pipe(mergeMap(({ payload }) =>
issues.changeStatus({ issueId: payload.issueId, status: payload.to, reason: payload.reason }).pipe(
mapResponse({ next: () => null, error: (e) => boardApiEvents.issueMoveFailed(describeError(e)) }),
))),
})),
);
```
### Facades
Cada store tiene su facade en `application`, y es lo **único** que exportan esas librerías (junto al puerto
`Notifications`): los stores y los eventos quedan como detalle interno.
```ts
@Injectable()
export class BoardFacade {
private readonly store = inject(BoardStore);
private readonly dispatch = injectDispatch(boardPageEvents);
readonly columns = this.store.columns; // estado: señales de solo lectura
readonly loading = this.store.loading;
/** Abre el tablero si es otro proyecto; si es el mismo, solo cambia el filtro. */
show(filter: BoardFilter): void {
const same = this.store.filter()?.projectId === filter.projectId;
if (same) this.dispatch.filterChanged(filter);
else this.dispatch.opened(filter);
}
moveIssue(issueId: string, to: IssueStatus, reason?: string | null): void {
this.dispatch.issueMoved({ issueId, to, reason });
}
}
export const provideBoardFacade = () => [BoardStore, BoardFacade];
```
```ts
// En la página
@Component({ providers: [provideBoardFacade()], … })
export class BoardPage {
protected readonly board = inject(BoardFacade);
drop(event) { this.board.moveIssue(issueId, to); }
}
```
Qué gana la página:
- **Intenciones, no eventos**: `board.moveIssue(…)` en lugar de conocer `boardPageEvents` y la forma de su payload.
- **Contexto implícito**: `ProjectFacade` e `IssueFacade` ponen el id del proyecto o del ticket abierto
(`project.addMember(userId)`, `issue.addComment(body)`); si aún no ha cargado, el comando se ignora.
- **Un sitio para la lógica de presentación**: abrir o refiltrar el tablero, buscar usuarios a partir de 2 caracteres…
- **Tests sin plantilla**: la facade se prueba con gateways falsos (`facades.spec.ts`).
| Facade | Store | Ámbito | Responsabilidad |
| ---------------- | --------------- | ----------------------- | --------------------------------------------------------------------------------------------------------- |
| `AuthFacade` | `AuthStore` | raíz | Sesión, usuario, login (PKCE o email y contraseña de Pireon), renovación del token, restaurar al arrancar |
| `ProjectsFacade` | `ProjectsStore` | raíz | Mis proyectos; se actualiza cuando me añaden o quitan (`myProjectsChanged`) |
| `ProjectFacade` | `ProjectStore` | ruta `/p/:key` | Proyecto, miembros, stats, releases, actividad en vivo y búsqueda de usuarios |
| `BoardFacade` | `BoardStore` | ruta `board` | Tablero kanban, filtros, movimiento optimista, nuevo ticket, cambios en vivo |
| `IssueFacade` | `IssueStore` | ruta `issues/:issueKey` | Ticket, comentarios en vivo, historial y todas sus acciones |
Las de ámbito de ruta se registran con `provideProjectFacade()`, `provideBoardFacade()` o `provideIssueFacade()` en los
`providers` del componente de la página (store + facade) y desaparecen al salir (con sus subscriptions). Las páginas
hijas de `/p/:key` reciben la misma `ProjectFacade`.
Otros contextos también usan la facade: los stores del tracker leen el usuario de `AuthFacade`, y la infraestructura
de identidad (`SessionTokenSource`) le pide el token para el cliente GraphQL.
### Tiempo real y consistencia
- Cada store se suscribe solo a lo que muestra (`issueChanges(projectId)`, `commentChanges(issueId)`…).
- Los cambios se fusionan por `version`: si llega un evento más antiguo que lo que hay en pantalla, se ignora.
- Tras crear algo, la query puede devolver _not found_ unos milisegundos (el read model es eventualmente consistente);
los stores lo reintentan hasta 5 veces.
- Los resultados de los comandos se comunican con el puerto `Notifications` (en la app, toasts de `pi-snack-bar`).
## 4.3 GraphQL
- **Apollo** (`apollo-angular` + `@apollo/client` 4) con la caché **desactivada**: los stores de NgRx son la única
fuente de verdad.
- Las subscriptions van por `graphql-ws` con el token en `connectionParams`; si la API responde `UNAUTHENTICATED`, la
sesión se cierra.
- Las operaciones están en `libs/web/*/infrastructure/src/lib/graphql/*.graphql`. Tras cambiar la API o una operación:
```sh
npx nx serve api # reescribe apps/api/schema.gql
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`).
## 4.4 `@pi/ui`
La librería de componentes de Pireon se instala desde su Nexus (scope `@pi`, configurado en `.npmrc`; el token va en
`~/.npmrc`). Se integra igual que en `landing-web`, y conviene conocer sus particularidades:
| Particularidad | Cómo se resuelve |
| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
| `styles/common/index.scss` no compila con Material ≥ 19 | `apps/web/src/styles.scss` importa sus partes una a una y declara el tema con `mat.theme` |
| No trae modo oscuro | Redefinimos sus tokens `--pi-color-*` bajo `html.dark-mode .pi-shell` (paleta Pireon, igual que la landing) |
| `PiThemeService` persiste mal el modo oscuro (`"false"` se lee como verdadero) | `AppearanceService` guarda su propia preferencia |
| No trae SVGs: `pi-icon` busca en el registro de Material | `shared/ui/src/lib/icons.ts` registra los iconos en línea, incluidos los que usa la propia librería (`eye`, `eye-off`, `add`…) |
| Sus textos (errores de campos, "Cerrar") van por ngx-translate | `providePiStrings()` los registra al arrancar, sin peticiones HTTP |
| Varios inputs son obligatorios (`input.required`) | `pi-note` necesita `gap`, `status`, `title` y `text` |
| Los campos usan Signal Forms | `[formField]="form.campo"` para formularios; `[(value)]` para controles sueltos |
| Drawers y modales se abren con `MatDialog` | `DrawerService.open(título, template)` con `getDrawerConfigs` de la librería |
| `pi-search-field` es un buscador plegable (un icono) | Para un campo de búsqueda normal se usa `pi-text-field` |
| `button` globales: `min-width`, padding y radio con selectores muy específicos | Los botones propios de la app usan `.ts-icon-button`; `pi-action-button size="w100"` necesita `box-sizing` (en `styles.scss`) |
| Los campos pintan cualquier error que reciben, aunque no se hayan tocado | En el login, los errores se pasan solo tras salir del campo o intentar entrar (`(touch)`) |
Componentes en uso: `pi-action-button`, `pi-icon-button`, `pi-icon`, `pi-note`, `pi-text-field`, `pi-email-field`,
`pi-password-field`, `pi-textarea`, `pi-select-field`, `pi-segmented-control`, `pi-skeleton`, `pi-spinner`,
`pi-empty-state`, `pi-divider`, `pi-drawer` y `pi-snack-bar`.
## 4.5 Diseño
TaskStream es un producto de la plataforma Pireon y se ve como tal: comparte con pireon.dev (`landing-web`) el logo,
la paleta, la tipografía, la cabecera y las superficies. `@pi/ui` aporta los controles; lo propio vive en
`apps/web/src/styles.scss` como tokens CSS, con su versión oscura bajo `html.dark-mode .pi-shell`.
| De pireon.dev | En TaskStream |
| --------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| Logo (`logo.png`, `favicon.ico`) | `apps/web/public`, pintado por `LogoComponent` |
| Rampas `primary` (#a347e1) y `accent` (#1879bf) sacadas del logo | Las mismas `brand-ramp` / `dark-ramp` |
| Inter; fondo `#f7f7fa` (oscuro `#0f1116`) | Igual (`--ts-font-body`, `--ts-bg`) |
| Cabecera translúcida con desenfoque, borde y sombra al hacer scroll | `AppShellComponent`, con «Pireon \| TaskStream» |
| Luces difusas violeta y azul arriba de cada página | `.ts-main::before/::after` |
| _Eyebrow_ azul en mayúsculas y titulares con un tramo en degradado | `.ts-eyebrow` y `.ts-gradient-text` |
| Tarjeta con lavado violeta→azul y borde en degradado (`landing-card`) | `.ts-panel` (paneles) y `.ts-card-link` (tarjetas que navegan, con elevación) |
| Cifras con `pi-stat-card` | Resumen de Seguimiento |
Encima, el lenguaje del producto: `--ts-font-mono` (Geist Mono) solo para datos (claves `WEB-12`, contadores,
versiones) y un color por estado (`--ts-s-*`) y por tipo de ticket (`--ts-t-*`), el mismo en tablero, tarjetas,
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`.
## 4.6 Rutas
| Ruta | Página |
| -------------------------- | ----------------------------------------------- |
| `/login` | Inicio de sesión (solo sin sesión) |
| `/auth/callback` | Retorno de Pireon (PKCE) |
| `/` | Mis proyectos |
| `/p/:key/board` | Tablero kanban (`?release=` filtra por release) |
| `/p/:key/issues/:issueKey` | Ticket |
| `/p/:key/releases` | Releases |
| `/p/:key/dashboard` | Seguimiento |
| `/p/:key/activity` | Actividad en vivo |
| `/p/:key/members` | Miembros |
Todo salvo `/login` y `/auth/callback` está protegido por `authGuard`. Los parámetros de ruta llegan como inputs de
los componentes (`withComponentInputBinding`).

88
docs/05-autenticacion.md Normal file
View File

@@ -0,0 +1,88 @@
# 5. Autenticación (Pireon)
Los usuarios pertenecen a **Pireon** (`https://oauth.pireon.dev`, OAuth2 con Laravel Passport). TaskStream no guarda
contraseñas: solo valida tokens y guarda una copia local de nombre y email para mostrarlos.
## 5.1 Cómo valida la API un token
```mermaid
sequenceDiagram
participant C as Cliente
participant G as AuthGuard (presenters)
participant Q as ResolveSessionQuery
participant R as Redis (caché)
participant P as Pireon
C->>G: Authorization: Bearer <token>
G->>Q: resolveSession(token)
Q->>R: GET auth:session:sha256(token)
alt en caché
R-->>Q: usuario
else no está
Q->>P: GET /api/user
P-->>Q: usuario + roles
Q->>R: SET (AUTH_SESSION_TTL_SECONDS)
end
Q-->>G: User (o 401)
```
- Se cachea el **hash SHA-256** del token, nunca el token en claro.
- Si Pireon marca al usuario como inactivo, el token se rechaza.
- El guard es global: todo requiere token salvo lo marcado con `@Public()` (health, configuración de login, proxy de
tokens y webhook).
- 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).
## 5.2 Iniciar sesión desde el navegador
Pireon **no permite CORS** en `/oauth/token` y el `client_secret` no debe llegar al navegador, así que la web pasa por
la API: `POST /api/auth/token` hace de proxy hacia Pireon.
| Método | Necesita | Flujo |
| ------------------------------------------------- | ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| **Entrar con Pireon** (Authorization Code + PKCE) | `PIREON_CLIENT_ID`, `PIREON_CLIENT_SECRET` y la redirect URI registrada | Web → `/oauth/authorize` de Pireon → `/auth/callback` → `POST /api/auth/token` (`authorization_code` + `code_verifier`) |
| **Email y contraseña** | El mismo cliente, con `PIREON_PASSWORD_GRANT=true` | `POST /api/auth/token` (`password`) |
Las dos opciones necesitan el cliente OAuth: sin `PIREON_CLIENT_ID` la API responde `503` y el login avisa de que no
está configurado. El email y la contraseña son los de la cuenta de Pireon; TaskStream no los guarda, solo los reenvía.
(El login con token de acceso personal se ha retirado de la web por ahora; la API sigue aceptando esos tokens como
`Bearer`.)
### Registrar TaskStream como cliente en Pireon
1. Crea el cliente: `POST https://oauth.pireon.dev/api/clients` (con un token tuyo de Pireon, scope `account:write`).
2. Registra como redirect URIs:
- `http://localhost:4200/auth/callback` (desarrollo)
- `http://localhost:8080/auth/callback` (Docker)
- la de producción
3. Pon `PIREON_CLIENT_ID` y `PIREON_CLIENT_SECRET` en `.env` (o en el entorno del contenedor).
`GET /api/auth/config` devuelve a la web lo necesario para iniciar el flujo (`authorizeUrl`, `clientId`, `scopes`,
`passwordGrant`), o `null` si no hay cliente configurado; en ese caso el login muestra el aviso y no deja entrar.
### Sesión en la web
- La sesión (`accessToken`, `refreshToken`, caducidad) se guarda en `localStorage` y se restaura al arrancar.
- Un minuto antes de caducar se renueva con `refresh_token`. Si llegan varias peticiones a la vez, se renueva una sola vez.
- Si la API responde `UNAUTHENTICATED`, se cierra la sesión y se vuelve al login.
- El parámetro `state` del flujo PKCE protege contra CSRF; el `code_verifier` vive en `sessionStorage` solo durante la redirección.
## 5.3 Webhooks de Pireon (opcional)
Para que la desactivación de un usuario o la revocación de un token surtan efecto al instante (sin esperar a que
caduque la caché):
1. Registra `https://<tu-api>/api/webhooks/pireon` con los eventos `user.deactivated` y `token.revoked`
(`POST /api/clients/{client}/webhooks`).
2. Pon el secret que devuelve en `PIREON_WEBHOOK_SECRET`.
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.
## 5.4 Usuarios en TaskStream
- Al validar un token, el usuario se guarda (nombre, email, activo) en la colección `users` de MongoDB.
- `users(search)` sincroniza antes el directorio de Pireon con el token del usuario, así que se puede añadir a gente
que aún no ha entrado en TaskStream (requiere el scope `users:read`).
- Los read models guardan el nombre de los usuarios desnormalizado: si alguien cambia su nombre en Pireon, las entradas
antiguas mantienen el nombre anterior.

93
docs/06-desarrollo.md Normal file
View File

@@ -0,0 +1,93 @@
# 6. Desarrollo
## 6.1 Requisitos
- Node 22 y npm 10.
- Docker (PostgreSQL, MongoDB y Redis).
- Acceso al Nexus de Pireon para `@pi/ui`. El scope está en `.npmrc` (sin token); el token va en tu `~/.npmrc`:
```sh
npm config set //nexus.pireon.dev/repository/npm-releases/:_authToken "<token>"
```
## 6.2 Puesta en marcha
```sh
npm install
docker compose up -d postgres mongo redis
cp .env.example .env # ajusta PIREON_*
npx nx serve api # http://localhost:3000 (GraphiQL en /graphql)
npx nx serve web # http://localhost:4200
```
`nx serve web` usa `apps/web/proxy.conf.json` para enviar `/api` y `/graphql` (también el WebSocket) a la API, así que
la web y la API comparten origen igual que en Docker.
## 6.3 Comandos
| Comando | Qué hace |
| -------------------------------- | --------------------------------------------------------------------------------- |
| `npx nx run-many -t lint` | Lint de todo, incluidas las reglas de capas y contextos |
| `npx nx run-many -t test` | Tests unitarios (dominio y aplicación, API y web) |
| `npx nx e2e api` | Flujo completo de la API contra Docker, con Pireon simulado |
| `npx nx run-many -t build` | Builds de la API y la web |
| `npx nx run web:codegen` | Regenera los tipos GraphQL del cliente desde `apps/api/schema.gql` |
| `npx nx run api:prune` | Build de la API con `package.json` y lockfile podados (lo usa Docker) |
| `npx nx graph` | Grafo de dependencias entre proyectos |
| `npx nx test api-tracker-domain` | Tests de una librería concreta (`api-<contexto>-<capa>`, `web-<contexto>-<capa>`) |
### Tests
| Dónde | Qué se prueba | Herramienta |
| ----------------------------------- | ---------------------------------------------------------------------- | ------------------------ |
| `api/*/domain` | Reglas de los agregados y reconstrucción desde el historial de eventos | Jest |
| `api/*/application` | Commands contra un event store en memoria (incluida la concurrencia) | Jest + `@nestjs/testing` |
| `apps/api/e2e` | Todo el flujo: GraphQL → Postgres → Redis → Mongo → subscriptions | Jest contra Docker |
| `web/*/domain`, `web/*/application` | Sesión y reducers del tablero | Jest / Vitest |
| `apps/web` | Rutas y guards | Vitest |
El e2e sustituye a Pireon por usuarios fijos (`token-ana`, `token-bob`…) con `overrideProvider(IdentityProvider)`; el
resto de la pila es real.
## 6.4 Cómo añadir una funcionalidad
Ejemplo: **marcar un ticket como bloqueado con un motivo**. El recorrido es siempre el mismo, de dentro hacia fuera.
### API
1. **Dominio** (`libs/api/tracker/domain`)
- Evento `issue-blocked.event.ts` con `@AutowiredEvent`, que extiende `TrackerEvent`.
- En `Issue`, el método `block(actorId, reason)` valida y hace `this.apply(new IssueBlockedEvent(…))`; el handler
`onIssueBlockedEvent(event)` cambia el estado.
- Test en `issue.spec.ts`, incluida la reconstrucción desde el historial (`replay`).
2. **Aplicación** (`libs/api/tracker/application`)
- `commands/block-issue.command.ts` y `block-issue.command-handler.ts`: rehidratar (`WriteAccess`), llamar al
dominio y `commitAndWait()`. Registrar el handler en `command-handlers.ts`.
- Método en `IssuesService`.
- Proyección `event-handlers/issue-blocked.event-handler.ts` (`@EventsHandler`) que actualiza el read model y avisa
por `ReadModelNotifier`. Añadir el evento al `ActivityEventHandler` y una frase en `activity-summary.ts`.
- Si el read model cambia, añadir el campo a `IssueReadModel` (dominio) y al esquema Mongo (infraestructura).
3. **Presenters** (`libs/api/tracker/presenters`): input y mutation `blockIssue` en `IssueResolver`, que llama al service.
4. Arrancar la API para regenerar `apps/api/schema.gql`.
### Web
5. `npx nx run web:codegen` tras añadir la mutation a `issues.graphql`.
6. **Dominio**: método en el puerto `IssuesGateway`. **Infraestructura**: implementarlo en `GraphqlIssuesGateway`.
7. **Aplicación**: evento `blockRequested` en `issuePageEvents`, su efecto en `IssueStore` (`runCommand`) y el método
`block(reason)` en `IssueFacade`.
8. **Presenters**: botón y formulario en la página del ticket que llaman a `facade.block(reason)`.
El lint impide atajos: si un resolver intenta usar un repositorio o una página intenta usar Apollo, falla. Y como `application` solo
exporta facades, una página tampoco puede tocar un store ni despachar eventos por su cuenta.
## 6.5 Problemas conocidos
| Síntoma | Causa y solución |
| ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `npm install` falla con `Cannot read properties of null (reading 'edgesOut')` | Bug de npm con un peer opcional de Vite que arrastra vitest 5. El `overrides` de `package.json` fija vitest; no lo quites |
| `401` al instalar `@pi/ui` | Falta el token del Nexus en `~/.npmrc` |
| `nx serve web` arranca en el puerto 3000 | Nx carga el `.env` raíz (`PORT=3000`) en todas las tareas; `apps/web/.env` lo fija en 4200 |
| La API no ve un evento nuevo al deserializar | La clase del evento no tiene `@AutowiredEvent` o no se importa |
| Un componente de `@pi/ui` lanza `Unable to find icon` | Falta registrar ese icono en `libs/web/shared/ui/src/lib/icons.ts` |
| Tablas antiguas (`projects`, `issues`, `outbox`) en tu Postgres local | Restos de una versión anterior sin event sourcing; `docker compose down -v` empieza de cero |

84
docs/07-despliegue.md Normal file
View File

@@ -0,0 +1,84 @@
# 7. Despliegue
## 7.1 Docker
```sh
docker compose up -d --build # todo: web en http://localhost:8080
docker compose up -d postgres mongo redis # solo infraestructura
docker compose logs -f api
docker compose down # parar (-v borra los datos)
```
| Servicio | Imagen | Puerto | Función |
| ---------- | --------------------------- | ------ | ----------------------------------------------------------------------- |
| `web` | `task-stream-web` (nginx) | 8080 | SPA y proxy de `/api` y `/graphql` (incluido el WebSocket) hacia la API |
| `api` | `task-stream-api` (Node 22) | 3000 | API NestJS |
| `postgres` | postgres:17-alpine | 5432 | Event store y lookups |
| `mongo` | mongo:8 | 27017 | Read model |
| `redis` | redis:7-alpine (AOF) | 6379 | Stream de eventos, pub/sub y caché de sesiones |
Todos tienen healthcheck; `web` espera a que `api` esté sana, y `api` a las tres bases de datos.
### Imágenes
El `Dockerfile` tiene dos destinos que comparten la instalación de dependencias:
- **Instalación**: `npm ci` del workspace. `@pi/ui` viene del Nexus privado; el token se pasa como **build secret**
(`secrets.npmrc` → `~/.npmrc` en `docker-compose.yml`) y nunca queda en una capa.
- **`api`**: `nx run api:prune` genera el bundle con un `package.json` y un lockfile podados a partir de
`apps/api/package.json`. La imagen final solo instala esas dependencias (sin Angular ni paquetes privados).
Si la API empieza a usar un paquete nuevo **en tiempo de ejecución**, hay que añadirlo a `apps/api/package.json`.
- **`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`.
## 7.2 Variables de entorno de la API
| Variable | Por defecto | Descripción |
| ------------------------------------------- | -------------------------- | ------------------------------------------------------------------------------------- |
| `PORT` | `3000` | Puerto HTTP |
| `NODE_ENV` | `development` | En `production` no se devuelven stacktraces |
| `POSTGRES_URL` | — (obligatoria) | Event store y lookups |
| `POSTGRES_SYNCHRONIZE` | `false` | Crea las tablas desde las entidades. Cómodo en desarrollo; en producción, migraciones |
| `MONGO_URL` | — (obligatoria) | Read model |
| `REDIS_URL` | — (obligatoria) | Stream, pub/sub y caché |
| `EVENT_STREAM_KEY` | `task-stream:events` | Clave del Redis Stream |
| `EVENT_STREAM_MAX_LENGTH` | `1000000` | Longitud aproximada que se conserva en el stream |
| `EVENTS_BRIDGE_ENABLED` | `true` | Si esta instancia proyecta eventos hacia Mongo (ver escalado) |
| `PIREON_BASE_URL` | `https://oauth.pireon.dev` | Servidor de identidad |
| `PIREON_CLIENT_ID` / `PIREON_CLIENT_SECRET` | — | Cliente OAuth para el login desde la web |
| `PIREON_SCOPES` | `users:read` | Scopes que se piden al iniciar sesión |
| `PIREON_PASSWORD_GRANT` | `true` | Permite el login con email y contraseña |
| `PIREON_WEBHOOK_SECRET` | — | Activa y firma los webhooks de Pireon |
| `PIREON_TIMEOUT_MS` | `5000` | Timeout de las llamadas a Pireon |
| `AUTH_SESSION_TTL_SECONDS` | `60` | Cuánto se confía en un token validado antes de volver a preguntar a Pireon |
| `GRAPHQL_PLAYGROUND` | `true` | GraphiQL e introspección |
| `GRAPHQL_SCHEMA_FILE` | `apps/api/schema.gql` | Dónde escribir el SDL (vacío = no escribir; así está en Docker) |
La web no tiene configuración de entorno: llama a la API en su mismo origen (`/api`, `/graphql`).
## 7.3 Escalado
- **API**: es _stateless_ (sesiones en Redis, subscriptions por Redis pub/sub), así que admite varias réplicas detrás
del balanceador. El WebSocket necesita que el balanceador permita `Upgrade`.
- **Relay**: cada instancia publica eventos al stream; `FOR UPDATE SKIP LOCKED` evita duplicados.
- **Proyecciones**: el `EventsBridge` procesa los eventos en orden. Lo más sencillo es dejarlo activo en **una sola**
instancia (`EVENTS_BRIDGE_ENABLED=true`) y desactivarlo en las demás. Varias instancias en el mismo consumer group se
reparten los eventos y pierden el orden entre ellos.
## 7.4 Antes de producción
- Sustituir `POSTGRES_SYNCHRONIZE` por migraciones de TypeORM.
- Registrar el cliente OAuth y las redirect URIs de producción en Pireon.
- Servir por HTTPS (la web usa `wss://` automáticamente cuando la página va por HTTPS).
- Revisar `GRAPHQL_PLAYGROUND` (introspección) según el entorno.
- Copias de seguridad de PostgreSQL: es la fuente de verdad.
## 7.5 Reconstruir el read model
MongoDB solo contiene proyecciones, así que se puede regenerar desde el event store:
1. Para la API (o desactiva el bridge en todas las instancias).
2. Borra la base de lectura (`taskstream_read`) salvo la colección `users`, que no sale de los eventos sino de Pireon.
3. Marca los eventos como no publicados: `UPDATE events SET published_at = NULL;`
4. Arranca la API: el relay vuelve a enviar todos los eventos, en orden, y las proyecciones reconstruyen las vistas.
Las notificaciones en tiempo real se reenvían también, sin efecto si no hay clientes conectados.

73
docs/08-decisiones.md Normal file
View File

@@ -0,0 +1,73 @@
# 8. Decisiones de diseño
Registro breve de las decisiones que más condicionan el código, con su motivo y su coste.
### Arquitectura hexagonal con una librería Nx por capa
Las reglas de dependencia las comprueba el lint, no la disciplina del equipo. **Coste**: más librerías y más ficheros
(un command y un handler por fichero, como en el patrón de referencia del equipo).
### La misma estructura en API y web
`libs/api/<contexto>/<capa>` y `libs/web/<contexto>/<capa>`: quien conoce un lado se orienta en el otro.
### Event sourcing en PostgreSQL (y no en MongoDB)
El requisito era escritura en PostgreSQL y lectura en MongoDB. El event store (`events`) implementa la misma API que
el `MongoEventStore` del patrón de referencia (`persist`, `getEventsByStreamId`). **Diferencia**: un conflicto de
versión lanza `ConcurrencyError` en lugar de solo registrarse, para no perder cambios en silencio.
### `commitAndWait()`
El `commit()` de `@nestjs/cqrs` no devuelve la promesa del publisher: el command respondería OK aunque el event store
fallara. `VersionedAggregateRoot.commitAndWait()` espera la escritura y avanza la versión del agregado.
### Redis entre escritura y lectura, y un bridge que espera
El requisito era que Redis llevase los datos de escritura a lectura. El `EventsBridge` hace el papel del _change stream_
del patrón de referencia, pero no usa `eventBus.subject$.next()`: así los handlers se ejecutarían en paralelo
(`mergeMap`), un `IssueStatusChanged` podría proyectarse antes que su `IssueCreated` y un fallo se perdería. El bridge
espera a cada `@EventsHandler` en orden y confirma en Redis (`XACK`) al final.
### `EventClsRegistry` y `@AutowiredEvent` en el dominio
En el patrón de referencia están en infraestructura, pero los eventos de dominio los usan; ponerlos allí rompería la
regla de capas.
### Lookups fuera de los agregados
La unicidad de claves de proyecto, la numeración de tickets y los nombres de release no caben en un agregado.
Se resuelven con tablas pequeñas en PostgreSQL. **Coste**: no forman parte de la misma transacción que los eventos
(una clave reservada puede quedar huérfana si el guardado falla después).
### Reglas críticas contra el event store, no contra Mongo
"No publicar una release con tickets sin terminar" se comprobaba primero contra el read model y fallaba si alguien
metía un ticket y publicaba justo después. Ahora reconstruye esos tickets desde los eventos.
### Proxy de tokens en la API
Pireon no permite CORS en `/oauth/token` y el `client_secret` no debe llegar al navegador.
`POST /api/auth/token` hace de intermediario, igual que la API de administración de la landing.
### Caché de Apollo desactivada en la web
El estado vive en los stores de NgRx (alimentados por queries y subscriptions). Tener dos fuentes de verdad
(Apollo cache y stores) solo añadiría inconsistencias.
### Estado web con el plugin de eventos de NgRx Signals
Los efectos se declaran con `withEventHandlers` (nombre en v22 de lo que antes era `withEffects`), y lo que emiten se
despacha automáticamente.
### Facades delante de los stores
Las páginas usan una facade por store (`BoardFacade`, `IssueFacade`…) y no conocen ni los stores ni los eventos, que
ya no se exportan. La facade expone el estado como señales y las intenciones como métodos, y pone el contexto (id del
proyecto o del ticket abierto). Así se puede cambiar cómo se gestiona el estado sin tocar las páginas, y la lógica de
presentación se prueba sin plantillas. **Coste**: una clase más por store, casi toda de reenvío.
### Imagen de la API con dependencias propias
El `package.json` raíz incluye Angular y `@pi/ui` (del Nexus privado). La imagen de la API usa
`apps/api/package.json`, así que ni lleva el frontend ni necesita el token del Nexus en ejecución.

25
docs/README.md Normal file
View File

@@ -0,0 +1,25 @@
# Documentación de TaskStream
TaskStream es una alternativa a Jira para equipos de desarrollo: proyectos, tickets (bugs, features, cambios de UI,
tareas, QA), releases, comentarios y relaciones entre tickets, todo en tiempo real.
| Documento | Para qué |
| ----------------------------------------- | ------------------------------------------------------------------------ |
| [1. Visión general](01-vision-general.md) | Qué hace el producto, stack y estructura del monorepo |
| [2. Arquitectura](02-arquitectura.md) | Hexagonal, CQRS, event sourcing, flujo escritura → lectura y tiempo real |
| [3. API](03-api.md) | Dominio (agregados, workflow, eventos) y la API GraphQL |
| [4. Cliente web](04-cliente-web.md) | Angular, NgRx Signals con eventos, GraphQL y `@pi/ui` |
| [5. Autenticación](05-autenticacion.md) | Pireon: tokens, login en el navegador, sesiones y webhooks |
| [6. Desarrollo](06-desarrollo.md) | Puesta en marcha, comandos, tests y cómo añadir una funcionalidad |
| [7. Despliegue](07-despliegue.md) | Docker, variables de entorno y escalado |
| [8. Decisiones](08-decisiones.md) | Por qué está hecho así (y qué alternativas se descartaron) |
Cada librería de `libs/` tiene además su propio `README.md` con su responsabilidad y su contenido.
**Empezar rápido**
```sh
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)).

Binary file not shown.

Binary file not shown.