diff --git a/.env.example b/.env.example index 9e35480..54aea6a 100644 --- a/.env.example +++ b/.env.example @@ -27,3 +27,7 @@ PIREON_PASSWORD_GRANT=true AUTH_SESSION_TTL_SECONDS=60 GRAPHQL_PLAYGROUND=true + +# Docker builds only: token of Pireon's Nexus for @pi/ui (build secret, never stored in an image). +# Locally: export NEXUS_TOKEN=$(grep '^//nexus.pireon.dev/repository/npm-releases/:_authToken=' ~/.npmrc | cut -d= -f2-) +NEXUS_TOKEN= diff --git a/Dockerfile b/Dockerfile index 735da18..033703b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,14 +3,18 @@ # docker compose up -d --build → api + web + infrastructure # # @pi/ui comes from Pireon's private Nexus. Its token is passed as a build -# secret (compose: `secrets.npmrc` → ~/.npmrc) and never lands in a layer. +# secret (compose: `secrets.nexus_token` ← $NEXUS_TOKEN) and never lands in a +# layer: it is written to a temporary npmrc that the same RUN deletes. # ---- Workspace dependencies (shared by both builds) -------------------- FROM node:22-alpine AS deps WORKDIR /workspace ENV NX_DAEMON=false NX_NO_CLOUD=true NX_SKIP_NX_CACHE=true COPY package.json package-lock.json .npmrc ./ -RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci --no-audit --no-fund +RUN --mount=type=secret,id=nexus_token,required=true \ + printf '//nexus.pireon.dev/repository/npm-releases/:_authToken=%s\n' "$(cat /run/secrets/nexus_token)" > /tmp/auth.npmrc \ + && npm ci --no-audit --no-fund --userconfig /tmp/auth.npmrc \ + && rm -f /tmp/auth.npmrc # ---- API: Nest bundle + pruned package.json / lockfile ----------------- FROM deps AS build-api diff --git a/apps/api/e2e/zz-ui-dev-server.e2e-spec.ts b/apps/api/e2e/zz-ui-dev-server.e2e-spec.ts deleted file mode 100644 index 4de2d40..0000000 --- a/apps/api/e2e/zz-ui-dev-server.e2e-spec.ts +++ /dev/null @@ -1,123 +0,0 @@ -// TEMPORARY (UI work): API on :3001 with fake Pireon users and seeded data. Not part of the repo. -import { ValidationPipe } from '@nestjs/common'; -import { Test } from '@nestjs/testing'; -import { - IdentityProvider, - InvalidAccessTokenError, - InvalidCredentialsError, -} from '@task-stream/api/identity/application'; -import { User, UserFactory } from '@task-stream/api/identity/domain'; -import { DomainErrorFilter } from '@task-stream/api/shared/presenters'; - -process.env['POSTGRES_URL'] ??= 'postgres://taskstream:taskstream@localhost:5432/taskstream'; -process.env['POSTGRES_SYNCHRONIZE'] = 'true'; -process.env['MONGO_URL'] = `mongodb://localhost:27017/taskstream_ui_${Date.now()}`; -process.env['REDIS_URL'] ??= 'redis://localhost:6379'; -process.env['EVENT_STREAM_KEY'] = `task-stream:ui:${Date.now()}`; -process.env['GRAPHQL_SCHEMA_FILE'] = ''; -process.env['AUTH_SESSION_TTL_SECONDS'] = '0'; - -const { AppModule } = require('../src/app/app.module'); - -const users = new UserFactory(); -const person = (id: string, name: string, email: string) => users.create({ id, name, email, isActive: true }); -const people: Record = { - 'token-ana': person('101', 'Ana García', 'ana@pireon.dev'), - 'token-bob': person('102', 'Bob Martín', 'bob@pireon.dev'), - 'token-qa': person('103', 'Quinn Ruiz', 'quinn@pireon.dev'), - 'token-dan': person('104', 'Dan Ortega', 'dan@pireon.dev'), -}; -const byId = (id: string) => Object.values(people).find((p) => p.id === id) ?? null; - -const PATH: Record = { - BACKLOG: [], TODO: ['TODO'], IN_PROGRESS: ['TODO', 'IN_PROGRESS'], - IN_REVIEW: ['TODO', 'IN_PROGRESS', 'IN_REVIEW'], - READY_FOR_QA: ['TODO', 'IN_PROGRESS', 'IN_REVIEW', 'READY_FOR_QA'], - IN_QA: ['TODO', 'IN_PROGRESS', 'IN_REVIEW', 'READY_FOR_QA', 'IN_QA'], - QA_FAILED: ['TODO', 'IN_PROGRESS', 'IN_REVIEW', 'READY_FOR_QA', 'IN_QA', 'QA_FAILED'], - DONE: ['TODO', 'IN_PROGRESS', 'IN_REVIEW', 'DONE'], CANCELLED: ['CANCELLED'], -}; - -describe('ui dev server', () => { - it('serves until stopped', async () => { - const moduleRef = await Test.createTestingModule({ imports: [AppModule] }) - .overrideProvider(IdentityProvider) - .useValue({ - whoAmI: async (token: string) => { const u = people[token]; if (!u) throw new InvalidAccessTokenError(); return u; }, - findUser: async (id: string) => byId(id), - listUsers: async () => Object.values(people), - // Email + any password "pireon" → that user's token. - issueToken: async (grant: { type: string; username?: string; password?: string }) => { - const entry = Object.entries(people).find(([, u]) => u.email === grant.username); - if (grant.type !== 'password' || !entry || grant.password !== 'pireon') throw new InvalidCredentialsError('Email o contraseña incorrectos'); - return { accessToken: entry[0], refreshToken: null, expiresIn: 86400, tokenType: 'Bearer' }; - }, - authorizationConfig: () => ({ authorizeUrl: 'https://oauth.pireon.dev/oauth/authorize', clientId: 'ui-dev', scopes: ['users:read'], passwordGrant: true }), - }) - .compile(); - const app = moduleRef.createNestApplication({ rawBody: true }); - app.setGlobalPrefix('api'); - app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true })); - app.useGlobalFilters(new DomainErrorFilter()); - await app.listen(3001); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const gql = async (token: string, query: string, variables?: object): Promise => { - const r = await fetch('http://localhost:3001/graphql', { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, body: JSON.stringify({ query, variables }) }); - const body = await r.json(); - if (body.errors) throw new Error(JSON.stringify(body.errors)); - return body.data; - }; - const ana = 'token-ana'; - const project = async (key: string, name: string, description: string) => (await gql(ana, `mutation($i: CreateProjectInput!) { createProject(input: $i) { id } }`, { i: { key, name, description } })).createProject.id as string; - const member = (projectId: string, userId: string, role: string) => gql(ana, `mutation($i: ProjectMemberInput!) { addProjectMember(input: $i) { version } }`, { i: { projectId, userId, role } }); - const release = async (projectId: string, name: string, targetDate: string, description: string) => (await gql(ana, `mutation($i: CreateReleaseInput!) { createRelease(input: $i) { id } }`, { i: { projectId, name, targetDate, description } })).createRelease.id as string; - const move = async (issueId: string, status: string, reason?: string) => { - for (const step of PATH[status]) await gql(ana, `mutation($i: ChangeIssueStatusInput!) { changeIssueStatus(input: $i) { version } }`, { i: { issueId, status: step, reason: step === 'QA_FAILED' || step === 'CANCELLED' ? reason : undefined } }); - }; - const issue = async (projectId: string, i: Record, status = 'BACKLOG', reason?: string) => { - const id = (await gql(ana, `mutation($i: CreateIssueInput!) { createIssue(input: $i) { id } }`, { i: { projectId, ...i } })).createIssue.id as string; - await move(id, status, reason); - return id; - }; - const comment = (token: string, issueId: string, body: string) => gql(token, `mutation($i: ID!, $b: String!) { addComment(issueId: $i, body: $b) { id } }`, { i: issueId, b: body }); - const link = (issueId: string, targetIssueId: string, type: string) => gql(ana, `mutation($i: LinkIssuesInput!) { linkIssues(input: $i) { version } }`, { i: { issueId, targetIssueId, type } }); - - const web = await project('WEB', 'Checkout revamp', 'Rediseño del checkout: pago en un paso, cupones y QA en cada release.'); - for (const [id, role] of [['102', 'MEMBER'], ['103', 'MEMBER'], ['104', 'ADMIN']]) await member(web, id, role); - const v132 = await release(web, 'v1.3.2', '2026-09-26', 'Tarjetas guardadas y webhooks v2.'); - const v140 = await release(web, 'v1.4.0', '2026-10-17', 'Checkout en un paso.'); - await release(web, 'v1.5.0', '2026-11-14', 'Nuevos métodos de pago.'); - const pay = await issue(web, { type: 'BUG', priority: 'CRITICAL', area: 'FRONTEND', assigneeId: '102', releaseId: v140, labels: ['safari', 'checkout'], title: 'El botón de pagar no responde en Safari 17', description: '1. Abrir el checkout en Safari 17\n2. Rellenar la tarjeta\n3. Pulsar «Pagar»: no pasa nada y no hay error en consola.' }, 'IN_PROGRESS'); - const cart = await issue(web, { type: 'FEATURE', priority: 'HIGH', area: 'BACKEND', assigneeId: '104', releaseId: v140, labels: ['carrito'], title: 'Guardar el carrito entre dispositivos', description: 'El carrito se asocia al usuario y se fusiona al iniciar sesión.' }, 'IN_REVIEW'); - const summary = await issue(web, { type: 'UI_CHANGE', priority: 'MEDIUM', area: 'DESIGN', assigneeId: '102', releaseId: v140, figmaUrl: 'https://www.figma.com/design/abc123/Checkout?node-id=12-345', title: 'Nuevo resumen del pedido según Figma' }, 'READY_FOR_QA'); - const regression = await issue(web, { type: 'QA', priority: 'HIGH', area: 'QA', assigneeId: '103', releaseId: v140, title: 'Regresión del checkout en iOS y Android' }, 'IN_QA'); - const coupon = await issue(web, { type: 'BUG', priority: 'HIGH', area: 'BACKEND', assigneeId: '104', releaseId: v140, labels: ['cupones'], title: 'El cupón se aplica dos veces si se recarga la página' }, 'QA_FAILED', 'Sigue duplicándose si se recarga durante el pago.'); - await issue(web, { type: 'TASK', priority: 'LOW', area: 'DEVOPS', assigneeId: '104', title: 'Alertas de latencia del servicio de pagos' }, 'TODO'); - await issue(web, { type: 'FEATURE', priority: 'MEDIUM', area: 'FRONTEND', title: 'Pago con Bizum' }); - await issue(web, { type: 'BUG', priority: 'LOW', area: 'FRONTEND', assigneeId: '102', title: 'El foco se pierde al cerrar el modal de dirección' }, 'TODO'); - await issue(web, { type: 'UI_CHANGE', priority: 'LOW', area: 'DESIGN', title: 'Estados vacíos del historial de pedidos' }); - await issue(web, { type: 'TASK', priority: 'MEDIUM', area: 'BACKEND', assigneeId: '104', releaseId: v132, title: 'Migrar webhooks de Stripe a la v2' }, 'DONE'); - await issue(web, { type: 'FEATURE', priority: 'HIGH', area: 'FRONTEND', assigneeId: '102', releaseId: v132, title: 'Guardar tarjeta para próximas compras' }, 'DONE'); - await issue(web, { type: 'QA', priority: 'MEDIUM', area: 'QA', assigneeId: '103', title: 'Pruebas exploratorias de devoluciones' }, 'TODO'); - await issue(web, { type: 'BUG', priority: 'MEDIUM', area: 'MOBILE', title: 'La app se cierra al girar en el paso de envío' }); - await issue(web, { type: 'TASK', priority: 'LOWEST', area: 'DEVOPS', title: 'Limpiar feature flags del checkout antiguo' }, 'CANCELLED', 'Se hará con la limpieza general de flags.'); - await gql(ana, `mutation($r: ID!) { publishRelease(releaseId: $r) { version } }`, { r: v132 }); - await link(regression, summary, 'TESTS'); - await link(summary, cart, 'DEPENDS_ON'); - await link(coupon, regression, 'BLOCKS'); - await comment('token-bob', pay, 'Reproducido: el `onClick` llega, pero el iframe de pago no termina de cargar en Safari.'); - await comment('token-qa', pay, 'En Safari 16 funciona. Adjunto vídeo en el ticket de regresión.'); - await comment(ana, pay, 'Decisión: usar redirect en lugar de popup para el 3-D Secure.'); - const api = await project('API', 'Plataforma API', 'Servicios compartidos: identidad, pagos y notificaciones.'); - await member(api, '104', 'ADMIN'); - await issue(api, { type: 'FEATURE', priority: 'HIGH', area: 'BACKEND', assigneeId: '104', title: 'Rate limiting por cliente' }, 'IN_PROGRESS'); - await issue(api, { type: 'BUG', priority: 'MEDIUM', area: 'BACKEND', title: 'Timeout en /payments/refund con importes altos' }, 'TODO'); - const mob = await project('MOB', 'App móvil', 'iOS y Android: compra rápida y notificaciones.'); - await member(mob, '102', 'MEMBER'); - await issue(mob, { type: 'UI_CHANGE', priority: 'MEDIUM', area: 'MOBILE', title: 'Nueva navegación inferior' }, 'IN_REVIEW'); - - console.log('UI DEV API READY on :3001'); - await new Promise(() => undefined); - }, 24 * 60 * 60 * 1000); -}); diff --git a/docker-compose.yml b/docker-compose.yml index 40d5470..9212585 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,13 +2,14 @@ # docker compose up -d --build → everything: web on http://localhost:8080 # docker compose up -d postgres mongo redis → only infrastructure (then `npx nx serve api` / `npx nx serve web`) # -# Building needs access to Pireon's Nexus (@pi/ui): the token is read from ~/.npmrc as a build secret. +# Building needs access to Pireon's Nexus (@pi/ui): set NEXUS_TOKEN in the environment or in .env +# (Dokploy: Environment tab). It reaches the build as a secret and never lands in an image layer. services: web: build: context: . target: web - secrets: [npmrc] + secrets: [nexus_token] image: task-stream-web ports: ['8080:80'] depends_on: @@ -19,7 +20,7 @@ services: build: context: . target: api - secrets: [npmrc] + secrets: [nexus_token] image: task-stream-api ports: ['3000:3000'] environment: @@ -81,5 +82,5 @@ volumes: redis-data: secrets: - npmrc: - file: ${HOME}/.npmrc + nexus_token: + environment: NEXUS_TOKEN diff --git a/docs/06-desarrollo.md b/docs/06-desarrollo.md index a138229..4b71f41 100644 --- a/docs/06-desarrollo.md +++ b/docs/06-desarrollo.md @@ -86,7 +86,8 @@ exporta facades, una página tampoco puede tocar un store ni despachar eventos p | Síntoma | Causa y solución | | ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | | `npm install` falla con `Cannot read properties of null (reading 'edgesOut')` | Bug de npm con un peer opcional de Vite que arrastra vitest 5. El `overrides` de `package.json` fija vitest; no lo quites | -| `401` al instalar `@pi/ui` | Falta el token del Nexus en `~/.npmrc` | +| `401` al instalar `@pi/ui` | Falta el token del Nexus en `~/.npmrc` (o `NEXUS_TOKEN` al construir con Docker) | +| `failed to stat /root/.npmrc` al desplegar | Versión antigua del `docker-compose.yml`: ahora el token llega por `NEXUS_TOKEN` (ver [Despliegue](07-despliegue.md)) | | `nx serve web` arranca en el puerto 3000 | Nx carga el `.env` raíz (`PORT=3000`) en todas las tareas; `apps/web/.env` lo fija en 4200 | | La API no ve un evento nuevo al deserializar | La clase del evento no tiene `@AutowiredEvent` o no se importa | | Un componente de `@pi/ui` lanza `Unable to find icon` | Falta registrar ese icono en `libs/web/shared/ui/src/lib/icons.ts` | diff --git a/docs/07-despliegue.md b/docs/07-despliegue.md index 4a1f9f5..58dccf2 100644 --- a/docs/07-despliegue.md +++ b/docs/07-despliegue.md @@ -23,14 +23,29 @@ Todos tienen healthcheck; `web` espera a que `api` esté sana, y `api` a las tre 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. +- **Instalación**: `npm ci` del workspace. `@pi/ui` viene del Nexus privado; el token llega como **build secret** + desde la variable `NEXUS_TOKEN` (`secrets.nexus_token` en `docker-compose.yml`), se escribe en un `npmrc` temporal y + se borra en el mismo paso, así que nunca queda en una capa. Sin `NEXUS_TOKEN` la build falla en ese paso. - **`api`**: `nx run api:prune` genera el bundle con un `package.json` y un lockfile podados a partir de `apps/api/package.json`. La imagen final solo instala esas dependencias (sin Angular ni paquetes privados). Si la API empieza a usar un paquete nuevo **en tiempo de ejecución**, hay que añadirlo a `apps/api/package.json`. - **`web`**: build de producción de Angular servido por nginx (`apps/web/nginx.conf`): fallback de rutas a `index.html`, caché larga para los bundles con hash y sin caché para `index.html`. +### Construir + +`NEXUS_TOKEN` tiene que estar en el entorno de quien construye, o en el `.env` junto al `docker-compose.yml`: + +```sh +# Local: reutiliza el token de tu ~/.npmrc +export NEXUS_TOKEN=$(grep '^//nexus.pireon.dev/repository/npm-releases/:_authToken=' ~/.npmrc | cut -d= -f2-) +docker compose up -d --build +``` + +En **Dokploy** (despliegue con Docker Compose): pestaña _Environment_ del servicio → `NEXUS_TOKEN=`, junto a +`PIREON_CLIENT_ID`, `PIREON_CLIENT_SECRET` y `PIREON_WEBHOOK_SECRET`. Dokploy los escribe en el `.env` del proyecto, de +donde los lee Compose. + ## 7.2 Variables de entorno de la API | Variable | Por defecto | Descripción | diff --git a/docs/TaskStream-Documentacion.docx b/docs/TaskStream-Documentacion.docx index b115e83..0da8e4f 100644 Binary files a/docs/TaskStream-Documentacion.docx and b/docs/TaskStream-Documentacion.docx differ diff --git a/docs/TaskStream-Documentacion.pdf b/docs/TaskStream-Documentacion.pdf index 9d202f6..0d4b673 100644 Binary files a/docs/TaskStream-Documentacion.pdf and b/docs/TaskStream-Documentacion.pdf differ