first commit
This commit is contained in:
60
docs/01-vision-general.md
Normal file
60
docs/01-vision-general.md
Normal 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
168
docs/02-arquitectura.md
Normal 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
277
docs/03-api.md
Normal 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
211
docs/04-cliente-web.md
Normal 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
88
docs/05-autenticacion.md
Normal 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
93
docs/06-desarrollo.md
Normal 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
84
docs/07-despliegue.md
Normal 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
73
docs/08-decisiones.md
Normal 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
25
docs/README.md
Normal 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)).
|
||||
BIN
docs/TaskStream-Documentacion.docx
Normal file
BIN
docs/TaskStream-Documentacion.docx
Normal file
Binary file not shown.
BIN
docs/TaskStream-Documentacion.pdf
Normal file
BIN
docs/TaskStream-Documentacion.pdf
Normal file
Binary file not shown.
Reference in New Issue
Block a user