From e3f92fa534f310b1ef74af3aead6e5fb6a8f3899 Mon Sep 17 00:00:00 2001 From: David Date: Sat, 3 Oct 2026 12:36:03 +0200 Subject: [PATCH] feat: initialize pnpm workspace and add configuration files - Added pnpm-workspace.yaml to define package structure. - Created prepare-local-env.py script for local environment setup. - Introduced tsconfig.json for TypeScript configuration. - Added turbo.json for task management with Turbo. --- .env.example | 10 + .gitattributes | 1 + .gitignore | 8 + .node-version | 1 + AGENTS.md | 93 + README.md | 13 + apps/server/package.json | 31 + apps/server/src/app.test.ts | 46 + apps/server/src/app.ts | 63 + apps/server/src/config/database.ts | 29 + apps/server/src/config/env-schema.test.ts | 17 + apps/server/src/config/env-schema.ts | 14 + apps/server/src/identity.test.ts | 431 ++ apps/server/src/index.ts | 42 + .../src/infrastructure/http/http-logger.ts | 13 + .../infrastructure/http/identity-runtime.ts | 44 + apps/server/tsconfig.json | 4 + apps/server/tsup.config.ts | 16 + apps/web/components.json | 22 + apps/web/index.html | 13 + apps/web/package.json | 38 + apps/web/src/app.test.tsx | 82 + apps/web/src/app.tsx | 82 + apps/web/src/global.css | 9 + apps/web/src/i18n.ts | 20 + apps/web/src/main.tsx | 33 + apps/web/tsconfig.json | 9 + apps/web/vite.config.ts | 14 + biome.json | 337 + compose.yaml | 21 + docker/mariadb/01-test-database.sql | 3 + docs/arquitectura/capas-del-servidor.md | 57 + docs/arquitectura/composicion-de-modulos.md | 18 + docs/arquitectura/contratos-http.md | 19 + docs/arquitectura/flujo-http.md | 32 + .../modelo-ddd-e-identificadores.md | 48 + docs/arquitectura/pruebas.md | 18 + docs/companias-contactos-y-sedes.md | 46 + docs/componentes-de-interfaz.md | 56 + docs/plan-de-desarrollo.md | 48 + docs/puesta-en-marcha.md | 46 + docs/usuarios-y-acceso.md | 37 + modules/companies/package.json | 40 + .../api/application/company-input.mapper.ts | 51 + .../application/company-response.builder.ts | 26 + .../src/api/application/company.repository.ts | 15 + .../src/api/application/company.use-cases.ts | 88 + .../companies/src/api/domain/company-error.ts | 10 + .../src/api/domain/company.aggregate.test.ts | 107 + .../src/api/domain/company.aggregate.ts | 122 + .../src/api/domain/contact.entity.ts | 59 + .../companies/src/api/domain/site.entity.ts | 40 + .../api/domain/value-objects/phone-number.ts | 16 + .../api/domain/value-objects/site-address.ts | 11 + .../api/infrastructure/http/company.routes.ts | 106 + .../persistence/company-persistence.mapper.ts | 73 + .../persistence/company.model.ts | 61 + .../persistence/migrate-companies.ts | 43 + .../sequelize-company.repository.ts | 105 + modules/companies/src/api/module.ts | 23 + .../common/dto/request/company.request.dto.ts | 13 + .../dto/response/company.response.dto.ts | 17 + .../src/common/dto/shared/company.dto.ts | 33 + modules/companies/src/common/index.ts | 3 + modules/companies/src/web/api/company-api.ts | 60 + .../web/components/companies-page.test.tsx | 104 + .../src/web/components/companies-page.tsx | 143 + .../src/web/components/company-detail.tsx | 262 + .../src/web/components/company-feedback.tsx | 34 + .../src/web/components/company-form.tsx | 80 + .../src/web/components/contact-form.tsx | 110 + .../src/web/components/site-form.tsx | 81 + modules/companies/src/web/i18n.ts | 106 + modules/companies/src/web/index.ts | 3 + modules/companies/tsconfig.json | 9 + modules/core/package.json | 35 + .../api/infrastructure/http/core.routes.ts | 24 + modules/core/src/api/module.ts | 1 + .../dto/request/get-health.request.dto.ts | 4 + .../response/get-health.response.dto.test.ts | 28 + .../dto/response/get-health.response.dto.ts | 7 + .../dto/response/http-error.response.dto.ts | 7 + modules/core/src/common/index.ts | 6 + modules/core/src/common/locales/en.json | 16 + modules/core/src/common/locales/es.json | 16 + modules/core/src/web/api/core.api.ts | 10 + modules/core/src/web/index.ts | 4 + modules/core/src/web/pages/home-page.tsx | 76 + .../core/src/web/queries/core.query-keys.ts | 3 + modules/core/tsconfig.json | 8 + modules/identity/package.json | 44 + .../src/api/application/identity-ports.ts | 22 + .../src/api/application/identity.use-cases.ts | 124 + .../src/api/application/user-input.mapper.ts | 44 + .../api/application/user-response.builder.ts | 12 + .../identity/src/api/domain/identity-error.ts | 14 + .../src/api/domain/user.aggregate.test.ts | 65 + .../identity/src/api/domain/user.aggregate.ts | 84 + .../api/domain/value-objects/password-hash.ts | 11 + .../infrastructure/http/identity.routes.ts | 251 + .../http/require-authenticated.ts | 24 + .../infrastructure/http/session-types.d.ts | 10 + .../persistence/migrate-identity.ts | 56 + .../persistence/sequelize-user.repository.ts | 98 + .../user-persistence.mapper.test.ts | 37 + .../persistence/user-persistence.mapper.ts | 57 + .../infrastructure/persistence/user.model.ts | 43 + .../security/scrypt-password-hasher.test.ts | 17 + .../security/scrypt-password-hasher.ts | 32 + modules/identity/src/api/module.ts | 31 + .../dto/request/identity.request.dto.ts | 36 + .../dto/response/identity.response.dto.ts | 30 + .../src/common/dto/shared/user.dto.ts | 10 + modules/identity/src/common/index.ts | 3 + modules/identity/src/web/api/identity-api.ts | 53 + .../src/web/components/credential-form.tsx | 95 + .../src/web/components/form-feedback.tsx | 26 + .../src/web/components/identity-workspace.tsx | 179 + .../src/web/components/password-form.tsx | 101 + .../identity/src/web/components/user-form.tsx | 140 + .../src/web/components/user-management.tsx | 144 + modules/identity/src/web/i18n.ts | 109 + modules/identity/src/web/index.ts | 3 + modules/identity/tsconfig.json | 8 + package.json | 26 + packages/rdx-ddd/package.json | 23 + packages/rdx-ddd/src/aggregate-root.ts | 4 + packages/rdx-ddd/src/domain-entity.ts | 19 + packages/rdx-ddd/src/domain-model.test.ts | 60 + packages/rdx-ddd/src/index.ts | 6 + .../src/value-objects/email-address.ts | 14 + packages/rdx-ddd/src/value-objects/name.ts | 13 + .../rdx-ddd/src/value-objects/unique-id.ts | 18 + .../rdx-ddd/src/value-objects/value-object.ts | 22 + packages/rdx-ddd/tsconfig.json | 7 + packages/rdx-utils/package.json | 15 + packages/rdx-utils/src/index.ts | 25 + packages/rdx-utils/tsconfig.json | 4 + packages/shadcn-ui/components.json | 22 + packages/shadcn-ui/package.json | 36 + packages/shadcn-ui/src/components/button.tsx | 58 + packages/shadcn-ui/src/components/card.tsx | 91 + .../shadcn-ui/src/components/checkbox.tsx | 25 + packages/shadcn-ui/src/components/dialog.tsx | 132 + .../shadcn-ui/src/components/form-dialog.tsx | 42 + packages/shadcn-ui/src/components/input.tsx | 19 + packages/shadcn-ui/src/components/label.tsx | 20 + packages/shadcn-ui/src/components/select.tsx | 187 + packages/shadcn-ui/src/components/table.tsx | 88 + .../shadcn-ui/src/components/textarea.tsx | 17 + packages/shadcn-ui/src/lib/utils.ts | 1 + packages/shadcn-ui/src/styles/globals.css | 82 + packages/shadcn-ui/tsconfig.json | 8 + packages/typescript-config/base.json | 16 + packages/typescript-config/buildless.json | 8 + packages/typescript-config/package.json | 9 + pnpm-lock.yaml | 5507 +++++++++++++++++ pnpm-workspace.yaml | 8 + scripts/prepare-local-env.py | 18 + tsconfig.json | 8 + turbo.json | 20 + 161 files changed, 12974 insertions(+) create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .node-version create mode 100644 AGENTS.md create mode 100644 README.md create mode 100644 apps/server/package.json create mode 100644 apps/server/src/app.test.ts create mode 100644 apps/server/src/app.ts create mode 100644 apps/server/src/config/database.ts create mode 100644 apps/server/src/config/env-schema.test.ts create mode 100644 apps/server/src/config/env-schema.ts create mode 100644 apps/server/src/identity.test.ts create mode 100644 apps/server/src/index.ts create mode 100644 apps/server/src/infrastructure/http/http-logger.ts create mode 100644 apps/server/src/infrastructure/http/identity-runtime.ts create mode 100644 apps/server/tsconfig.json create mode 100644 apps/server/tsup.config.ts create mode 100644 apps/web/components.json create mode 100644 apps/web/index.html create mode 100644 apps/web/package.json create mode 100644 apps/web/src/app.test.tsx create mode 100644 apps/web/src/app.tsx create mode 100644 apps/web/src/global.css create mode 100644 apps/web/src/i18n.ts create mode 100644 apps/web/src/main.tsx create mode 100644 apps/web/tsconfig.json create mode 100644 apps/web/vite.config.ts create mode 100644 biome.json create mode 100644 compose.yaml create mode 100644 docker/mariadb/01-test-database.sql create mode 100644 docs/arquitectura/capas-del-servidor.md create mode 100644 docs/arquitectura/composicion-de-modulos.md create mode 100644 docs/arquitectura/contratos-http.md create mode 100644 docs/arquitectura/flujo-http.md create mode 100644 docs/arquitectura/modelo-ddd-e-identificadores.md create mode 100644 docs/arquitectura/pruebas.md create mode 100644 docs/companias-contactos-y-sedes.md create mode 100644 docs/componentes-de-interfaz.md create mode 100644 docs/plan-de-desarrollo.md create mode 100644 docs/puesta-en-marcha.md create mode 100644 docs/usuarios-y-acceso.md create mode 100644 modules/companies/package.json create mode 100644 modules/companies/src/api/application/company-input.mapper.ts create mode 100644 modules/companies/src/api/application/company-response.builder.ts create mode 100644 modules/companies/src/api/application/company.repository.ts create mode 100644 modules/companies/src/api/application/company.use-cases.ts create mode 100644 modules/companies/src/api/domain/company-error.ts create mode 100644 modules/companies/src/api/domain/company.aggregate.test.ts create mode 100644 modules/companies/src/api/domain/company.aggregate.ts create mode 100644 modules/companies/src/api/domain/contact.entity.ts create mode 100644 modules/companies/src/api/domain/site.entity.ts create mode 100644 modules/companies/src/api/domain/value-objects/phone-number.ts create mode 100644 modules/companies/src/api/domain/value-objects/site-address.ts create mode 100644 modules/companies/src/api/infrastructure/http/company.routes.ts create mode 100644 modules/companies/src/api/infrastructure/persistence/company-persistence.mapper.ts create mode 100644 modules/companies/src/api/infrastructure/persistence/company.model.ts create mode 100644 modules/companies/src/api/infrastructure/persistence/migrate-companies.ts create mode 100644 modules/companies/src/api/infrastructure/persistence/sequelize-company.repository.ts create mode 100644 modules/companies/src/api/module.ts create mode 100644 modules/companies/src/common/dto/request/company.request.dto.ts create mode 100644 modules/companies/src/common/dto/response/company.response.dto.ts create mode 100644 modules/companies/src/common/dto/shared/company.dto.ts create mode 100644 modules/companies/src/common/index.ts create mode 100644 modules/companies/src/web/api/company-api.ts create mode 100644 modules/companies/src/web/components/companies-page.test.tsx create mode 100644 modules/companies/src/web/components/companies-page.tsx create mode 100644 modules/companies/src/web/components/company-detail.tsx create mode 100644 modules/companies/src/web/components/company-feedback.tsx create mode 100644 modules/companies/src/web/components/company-form.tsx create mode 100644 modules/companies/src/web/components/contact-form.tsx create mode 100644 modules/companies/src/web/components/site-form.tsx create mode 100644 modules/companies/src/web/i18n.ts create mode 100644 modules/companies/src/web/index.ts create mode 100644 modules/companies/tsconfig.json create mode 100644 modules/core/package.json create mode 100644 modules/core/src/api/infrastructure/http/core.routes.ts create mode 100644 modules/core/src/api/module.ts create mode 100644 modules/core/src/common/dto/request/get-health.request.dto.ts create mode 100644 modules/core/src/common/dto/response/get-health.response.dto.test.ts create mode 100644 modules/core/src/common/dto/response/get-health.response.dto.ts create mode 100644 modules/core/src/common/dto/response/http-error.response.dto.ts create mode 100644 modules/core/src/common/index.ts create mode 100644 modules/core/src/common/locales/en.json create mode 100644 modules/core/src/common/locales/es.json create mode 100644 modules/core/src/web/api/core.api.ts create mode 100644 modules/core/src/web/index.ts create mode 100644 modules/core/src/web/pages/home-page.tsx create mode 100644 modules/core/src/web/queries/core.query-keys.ts create mode 100644 modules/core/tsconfig.json create mode 100644 modules/identity/package.json create mode 100644 modules/identity/src/api/application/identity-ports.ts create mode 100644 modules/identity/src/api/application/identity.use-cases.ts create mode 100644 modules/identity/src/api/application/user-input.mapper.ts create mode 100644 modules/identity/src/api/application/user-response.builder.ts create mode 100644 modules/identity/src/api/domain/identity-error.ts create mode 100644 modules/identity/src/api/domain/user.aggregate.test.ts create mode 100644 modules/identity/src/api/domain/user.aggregate.ts create mode 100644 modules/identity/src/api/domain/value-objects/password-hash.ts create mode 100644 modules/identity/src/api/infrastructure/http/identity.routes.ts create mode 100644 modules/identity/src/api/infrastructure/http/require-authenticated.ts create mode 100644 modules/identity/src/api/infrastructure/http/session-types.d.ts create mode 100644 modules/identity/src/api/infrastructure/persistence/migrate-identity.ts create mode 100644 modules/identity/src/api/infrastructure/persistence/sequelize-user.repository.ts create mode 100644 modules/identity/src/api/infrastructure/persistence/user-persistence.mapper.test.ts create mode 100644 modules/identity/src/api/infrastructure/persistence/user-persistence.mapper.ts create mode 100644 modules/identity/src/api/infrastructure/persistence/user.model.ts create mode 100644 modules/identity/src/api/infrastructure/security/scrypt-password-hasher.test.ts create mode 100644 modules/identity/src/api/infrastructure/security/scrypt-password-hasher.ts create mode 100644 modules/identity/src/api/module.ts create mode 100644 modules/identity/src/common/dto/request/identity.request.dto.ts create mode 100644 modules/identity/src/common/dto/response/identity.response.dto.ts create mode 100644 modules/identity/src/common/dto/shared/user.dto.ts create mode 100644 modules/identity/src/common/index.ts create mode 100644 modules/identity/src/web/api/identity-api.ts create mode 100644 modules/identity/src/web/components/credential-form.tsx create mode 100644 modules/identity/src/web/components/form-feedback.tsx create mode 100644 modules/identity/src/web/components/identity-workspace.tsx create mode 100644 modules/identity/src/web/components/password-form.tsx create mode 100644 modules/identity/src/web/components/user-form.tsx create mode 100644 modules/identity/src/web/components/user-management.tsx create mode 100644 modules/identity/src/web/i18n.ts create mode 100644 modules/identity/src/web/index.ts create mode 100644 modules/identity/tsconfig.json create mode 100644 package.json create mode 100644 packages/rdx-ddd/package.json create mode 100644 packages/rdx-ddd/src/aggregate-root.ts create mode 100644 packages/rdx-ddd/src/domain-entity.ts create mode 100644 packages/rdx-ddd/src/domain-model.test.ts create mode 100644 packages/rdx-ddd/src/index.ts create mode 100644 packages/rdx-ddd/src/value-objects/email-address.ts create mode 100644 packages/rdx-ddd/src/value-objects/name.ts create mode 100644 packages/rdx-ddd/src/value-objects/unique-id.ts create mode 100644 packages/rdx-ddd/src/value-objects/value-object.ts create mode 100644 packages/rdx-ddd/tsconfig.json create mode 100644 packages/rdx-utils/package.json create mode 100644 packages/rdx-utils/src/index.ts create mode 100644 packages/rdx-utils/tsconfig.json create mode 100644 packages/shadcn-ui/components.json create mode 100644 packages/shadcn-ui/package.json create mode 100644 packages/shadcn-ui/src/components/button.tsx create mode 100644 packages/shadcn-ui/src/components/card.tsx create mode 100644 packages/shadcn-ui/src/components/checkbox.tsx create mode 100644 packages/shadcn-ui/src/components/dialog.tsx create mode 100644 packages/shadcn-ui/src/components/form-dialog.tsx create mode 100644 packages/shadcn-ui/src/components/input.tsx create mode 100644 packages/shadcn-ui/src/components/label.tsx create mode 100644 packages/shadcn-ui/src/components/select.tsx create mode 100644 packages/shadcn-ui/src/components/table.tsx create mode 100644 packages/shadcn-ui/src/components/textarea.tsx create mode 100644 packages/shadcn-ui/src/lib/utils.ts create mode 100644 packages/shadcn-ui/src/styles/globals.css create mode 100644 packages/shadcn-ui/tsconfig.json create mode 100644 packages/typescript-config/base.json create mode 100644 packages/typescript-config/buildless.json create mode 100644 packages/typescript-config/package.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 scripts/prepare-local-env.py create mode 100644 tsconfig.json create mode 100644 turbo.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a1d7ee3 --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +PORT=3001 +APP_ORIGIN=http://localhost:5173 +DB_HOST=127.0.0.1 +DB_PORT=3307 +DB_NAME=metis_desk +DB_USER=metis +DB_PASSWORD=replace-with-a-random-value +DB_ROOT_PASSWORD=replace-with-another-random-value +SESSION_SECRET=replace-with-at-least-32-random-characters +NODE_ENV=development diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e598063 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +.turbo/ +coverage/ +.env +.env.* +!.env.example +*.tsbuildinfo diff --git a/.node-version b/.node-version new file mode 100644 index 0000000..b7179ae --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +24.20.0 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..60fdd93 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,93 @@ +# Instrucciones de Metis Desk + +## Alcance y entorno + +Proyecto independiente de factuges-erp. Se conserva su estilo, no sus funcionalidades ni dependencias locales. +El repositorio vive en `/home/david/dev/metis-desk`, en WSL2 Ubuntu-24.04. +Ejecutar las herramientas del proyecto dentro de WSL con bash, nunca desde Windows, rutas UNC o /mnt/c. +Antes de modificar código: comprobar pwd, git status --short, node --version y pnpm --version. +Usar Node.js 24.x y la versión de PNPM fijada en package.json. +No imprimir, copiar ni versionar .env locales o secretos. + +## Convenciones + +- Código e identificadores en inglés; comentarios, TSDoc y documentación en castellano. +- Ficheros y directorios en kebab-case. Clases, tipos e interfaces en PascalCase. +- Variables, funciones y métodos en camelCase; constantes globales en UPPER_SNAKE_CASE. +- Interfaces con prefijo I cuando el área ya siga esa convención. +- Documentación en /docs, con nombres de ficheros en castellano. +- Comentarios que expliquen decisiones, riesgos o contratos, nunca que repitan el código. +- Mantener es y en para textos de interfaz; no insertar literales en áreas con i18n. + +## Disciplina + +Aplicar SOLID, Clean Code, DRY, KISS, YAGNI, SSOT, fail fast y programación defensiva. +Priorizar claridad y explicitud frente a abstracción prematura. +Cambios incrementales y acotados. No renombrar, reformatear o mover código ajeno a la tarea. +No crear capas, factories, genéricos, eventos, contenedores DI ni repositorios base sin necesidad real. +Justificar cada nueva dependencia. Preferir APIs nativas cuando basten. +No añadir supresiones de TypeScript o Biome sin justificación. +No eliminar legado sin buscar y clasificar sus referencias en todo el monorepo. + +## TypeScript y asincronía + +- Importar tipos explícitamente con import type en la cabecera. +- No usar import("...").Tipo en código ordinario. +- Evitar any, assertions y non-null assertions salvo garantías verificables. +- Inferir DTOs de schemas Zod; no duplicarlos mediante interfaces. +- Usar unions para conjuntos cerrados y mantener switches exhaustivos. +- No usar Partial si el contrato ya representa la opcionalidad. +- No usar el operador void para descartar promesas. +- Toda promesa se espera, se devuelve o gestiona su error expresamente. +- No dejar promesas flotantes ni capturas que oculten fallos. +- Comprobar el contrato del callback antes de convertirlo a async. +- Usar Result para fallos de negocio y Maybe para ausencia semántica cuando se implementen esas operaciones. +- Collection solo cuando el conjunto necesite comportamiento de dominio; no introducir estos patrones sin consumidores. + +## Arquitectura y contratos + +Las fuentes normativas se consultan, no se duplican: + +- [Capas del servidor](docs/arquitectura/capas-del-servidor.md) +- [Composición de módulos](docs/arquitectura/composicion-de-modulos.md) +- [Flujo HTTP](docs/arquitectura/flujo-http.md) +- [Contratos HTTP](docs/arquitectura/contratos-http.md) +- [Pruebas](docs/arquitectura/pruebas.md) +- [Modelo DDD e identificadores](docs/arquitectura/modelo-ddd-e-identificadores.md) + +## Seguridad y persistencia + +- Menor privilegio y validación de toda entrada externa. +- Autenticación y autorización antes de acceder a datos de clientes. +- El cliente atendido no es el tenant de la empresa que presta soporte. +- Verificar siempre permisos y pertenencia; no confiar en companyId enviado por el navegador. +- No exponer SQL, stacks, datos sensibles o credenciales en respuestas ni logs. +- Registrar identificadores de petición y resultados suficientes para diagnosticar fallos. +- MariaDB y Sequelize v6 para persistencia; ORM exclusivamente en Infrastructure. +- Consultas parametrizadas, transacciones para cambios atómicos y restricciones en base de datos. +- Migraciones explícitas; no usar sync({ alter: true }) como sustituto. +- Toda entidad de negocio, incluidas las entidades internas de un agregado, tiene identidad UniqueID (UUID v7 para nuevas entidades), siguiendo factuges-erp. +- Declarar Value Objects, DomainEntity y AggregateRoot según docs/arquitectura/modelo-ddd-e-identificadores.md. +- Mantener semántica de create, rehydrate y update según la arquitectura. +- Comparar cambios de dominio semánticamente, no mediante JSON.stringify. +- Añadir idempotencia donde una repetición pueda producir efectos duplicados. + +## Frontend + +React, TanStack Query, Tailwind y shadcn/ui (Base UI, estilo Vega). Los componentes compartidos viven en @repo/shadcn-ui. Incorporar TanStack Table y React Hook Form cuando haya consumidores. +Estado remoto en TanStack Query; claves centralizadas e invalidación explícita después de mutaciones. +No duplicar estado remoto ni usar efectos para derivar valores calculables durante render. +Mantener accesibilidad y estados de carga, error, vacío y acciones deshabilitadas. +Separar componentes por responsabilidades reales, sin fragmentar JSX trivial. +Los listados no muestran formularios de alta ni edición junto a los datos. El alta se inicia con un botón en la cabecera, a la derecha; formularios sencillos en modal shadcn/ui y formularios complejos o extensos en una página propia. La edición se abre mediante una acción explícita, siguiendo el mismo criterio. Mantener foco, cierre por teclado, cancelación y errores dentro del formulario. + +## Validación y entrega + +Ejecutar pnpm typecheck, pnpm lint, pnpm test, pnpm check y pnpm build en cambios transversales. +Preferir comprobaciones dirigidas al paquete cuando sean suficientes. +Añadir pruebas de comportamiento observable: válidos, inválidos, límites e idempotencia cuando corresponda. +Tests deterministas, sin esperas arbitrarias ni desactivaciones. +Informar cambios, archivos principales, comandos ejecutados, resultados y pendientes reales. +No afirmar validaciones no ejecutadas ni ocultar fallos conocidos. + +Precedencia: instrucciones actuales del usuario, AGENTS.md más cercano, este archivo y convenciones existentes. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9cb4a91 --- /dev/null +++ b/README.md @@ -0,0 +1,13 @@ +# Metis Desk + +Aplicación interna para soporte técnico a pequeñas empresas. + +- [Preparación y ejecución](docs/puesta-en-marcha.md) +- [Modelo DDD e identificadores](docs/arquitectura/modelo-ddd-e-identificadores.md) +- [Organización del código](docs/arquitectura/capas-del-servidor.md) +- [Usuarios y acceso](docs/usuarios-y-acceso.md) +- [Compañías, contactos y sedes](docs/companias-contactos-y-sedes.md) +- [Componentes de interfaz](docs/componentes-de-interfaz.md) +- [Alcance y siguientes entregas](docs/plan-de-desarrollo.md) + +Incluye acceso, sesiones persistentes, gestión de usuarios y compañías clientes con contactos y sedes. Inventario y tickets se incorporarán progresivamente. diff --git a/apps/server/package.json b/apps/server/package.json new file mode 100644 index 0000000..b18f994 --- /dev/null +++ b/apps/server/package.json @@ -0,0 +1,31 @@ +{ + "name": "@metis/server", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "node --env-file=../../.env --import tsx --watch src/index.ts", + "typecheck": "tsc -p tsconfig.json", + "test": "node --import tsx --test src/app.test.ts src/config/env-schema.test.ts && DB_NAME=metis_desk_test NODE_ENV=test node --env-file=../../.env --import tsx --test src/identity.test.ts", + "build": "tsup --config tsup.config.ts", + "start": "node --env-file=../../.env dist/index.js" + }, + "dependencies": { + "@metis/core": "workspace:*", + "express": "4.22.1", + "helmet": "8.1.0", + "zod": "4.3.6", + "@metis/identity": "workspace:*", + "sequelize": "6.37.8", + "mariadb": "3.5.4", + "express-session": "1.19.0", + "connect-session-sequelize": "8.0.6", + "@metis/companies": "workspace:*" + }, + "devDependencies": { + "@repo/typescript-config": "workspace:*", + "@types/express": "4.17.21", + "tsup": "8.5.1", + "@types/express-session": "^1.18.2" + } +} diff --git a/apps/server/src/app.test.ts b/apps/server/src/app.test.ts new file mode 100644 index 0000000..59a7c57 --- /dev/null +++ b/apps/server/src/app.test.ts @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import { once } from "node:events"; +import { test } from "node:test"; + +import { GetHealthResponseSchema, HttpErrorResponseSchema } from "@metis/core/common"; + +import { createApp } from "./app"; +import type { HttpLogEntry } from "./infrastructure/http/http-logger"; + +test("HTTP real: estado, contrato, errores y trazas sin datos sensibles", async () => { + const entries: HttpLogEntry[] = []; + const server = createApp((entry) => entries.push(entry)).listen(0, "127.0.0.1"); + try { + await once(server, "listening"); + const address = server.address(); + assert.ok(address && typeof address !== "string"); + const baseUrl = `http://127.0.0.1:${address.port}`; + const response = await fetch(`${baseUrl}/api/__health`); + assert.equal(response.status, 200); + const health = GetHealthResponseSchema.parse(await response.json()); + assert.equal(health.status, "ok"); + assert.equal(response.headers.get("x-powered-by"), null); + assert.equal(response.headers.get("cache-control"), "no-store"); + assert.ok(response.headers.get("x-content-type-options")); + + for (const [path, status, code] of [ + ["/api/__health?token=secret-test-value", 400, "INVALID_REQUEST"], + ["/api/customers", 404, "NOT_FOUND"], + ] as const) { + const failure = await fetch(`${baseUrl}${path}`); + assert.equal(failure.status, status); + const body = HttpErrorResponseSchema.parse(await failure.json()); + assert.equal(body.code, code); + assert.equal(body.requestId, failure.headers.get("x-request-id")); + } + assert.equal(entries.length, 3); + assert.equal(new Set(entries.map((entry) => entry.requestId)).size, 3); + assert.equal(JSON.stringify(entries).includes("secret-test-value"), false); + assert.ok(entries.every((entry) => entry.durationMs >= 0)); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + server.closeAllConnections(); + }); + } +}); diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts new file mode 100644 index 0000000..fea8dd4 --- /dev/null +++ b/apps/server/src/app.ts @@ -0,0 +1,63 @@ +import { randomUUID } from "node:crypto"; + +import type { HttpErrorResponseDTO } from "@metis/core/common"; +import { registerCoreRoutes } from "@metis/core/module"; +import type { Application, ErrorRequestHandler, RequestHandler } from "express"; +import express from "express"; +import helmet from "helmet"; + +import type { HttpLogger } from "./infrastructure/http/http-logger"; +import { logHttpRequest } from "./infrastructure/http/http-logger"; + +export function createApp( + logger: HttpLogger = logHttpRequest, + identity?: { middleware: RequestHandler; register: (app: Application) => void } +) { + const app = express(); + app.disable("x-powered-by"); + app.use(helmet()); + app.use((request, response, next) => { + const requestId = randomUUID(); + const startedAt = performance.now(); + response.setHeader("x-request-id", requestId); + response.setHeader("cache-control", "no-store"); + response.once("finish", () => { + logger({ + event: "http_request", + requestId, + method: request.method, + statusCode: response.statusCode, + durationMs: Math.round((performance.now() - startedAt) * 100) / 100, + }); + }); + next(); + }); + + app.use(express.json({ limit: "64kb" })); + if (identity) { + app.use(identity.middleware); + identity.register(app); + } + registerCoreRoutes(app); + + app.use((_request, response) => { + const error: HttpErrorResponseDTO = { + code: "NOT_FOUND", + requestId: response.getHeader("x-request-id")?.toString() ?? "", + }; + response.status(404).json(error); + }); + const errorHandler: ErrorRequestHandler = (_error: unknown, _request, response, next) => { + if (response.headersSent) { + next(_error); + return; + } + const error: HttpErrorResponseDTO = { + code: "INTERNAL_ERROR", + requestId: response.getHeader("x-request-id")?.toString() ?? "", + }; + response.status(500).json(error); + }; + app.use(errorHandler); + return app; +} diff --git a/apps/server/src/config/database.ts b/apps/server/src/config/database.ts new file mode 100644 index 0000000..5da3b5a --- /dev/null +++ b/apps/server/src/config/database.ts @@ -0,0 +1,29 @@ +import { Sequelize } from "sequelize"; +import { z } from "zod/v4"; + +const DatabaseEnvironmentSchema = z.object({ + DB_HOST: z.string().min(1), + DB_PORT: z.coerce.number().int().positive(), + DB_NAME: z.string().regex(/^[a-z_]+$/), + DB_USER: z.string().min(1), + DB_PASSWORD: z.string().min(1), + SESSION_SECRET: z.string().min(32), + APP_ORIGIN: z.url(), + NODE_ENV: z.enum(["development", "test", "production"]).default("development"), +}); +export function readDatabaseEnvironment(source: NodeJS.ProcessEnv) { + const parsed = DatabaseEnvironmentSchema.safeParse(source); + if (!parsed.success) { + throw new Error("Configuración de persistencia o sesión incompleta."); + } + return parsed.data; +} +export function createDatabase(environment: ReturnType) { + return new Sequelize(environment.DB_NAME, environment.DB_USER, environment.DB_PASSWORD, { + dialect: "mariadb", + host: environment.DB_HOST, + port: environment.DB_PORT, + logging: false, + pool: { max: 5, min: 0, idle: 10000 }, + }); +} diff --git a/apps/server/src/config/env-schema.test.ts b/apps/server/src/config/env-schema.test.ts new file mode 100644 index 0000000..1f53391 --- /dev/null +++ b/apps/server/src/config/env-schema.test.ts @@ -0,0 +1,17 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { readServerEnvironment } from "./env-schema"; + +test("el puerto usa un valor local por defecto y acepta los límites válidos", () => { + assert.equal(readServerEnvironment({}).PORT, 3001); + assert.equal(readServerEnvironment({ PORT: "1" }).PORT, 1); + assert.equal(readServerEnvironment({ PORT: "65535" }).PORT, 65535); +}); +test("el arranque rechaza puertos inválidos sin revelar el valor recibido", () => { + for (const port of ["", "0", "-1", "65536", "1.5", "secret-value"]) { + assert.throws(() => readServerEnvironment({ PORT: port }), { + message: "Invalid server configuration: PORT must be between 1 and 65535", + }); + } +}); diff --git a/apps/server/src/config/env-schema.ts b/apps/server/src/config/env-schema.ts new file mode 100644 index 0000000..ffef78f --- /dev/null +++ b/apps/server/src/config/env-schema.ts @@ -0,0 +1,14 @@ +import { z } from "zod/v4"; + +const ServerEnvironmentSchema = z.object({ + PORT: z.coerce.number().int().min(1).max(65535), +}); + +/** El arranque rechaza una configuración inválida antes de abrir el puerto. */ +export function readServerEnvironment(environment: NodeJS.ProcessEnv) { + const result = ServerEnvironmentSchema.safeParse({ PORT: environment.PORT ?? "3001" }); + if (!result.success) { + throw new Error("Invalid server configuration: PORT must be between 1 and 65535"); + } + return result.data; +} diff --git a/apps/server/src/identity.test.ts b/apps/server/src/identity.test.ts new file mode 100644 index 0000000..f080a8c --- /dev/null +++ b/apps/server/src/identity.test.ts @@ -0,0 +1,431 @@ +import assert from "node:assert/strict"; +import { once } from "node:events"; +import { test } from "node:test"; + +import { + CompaniesResponseSchema, + CompanyErrorResponseSchema, + CompanyResponseSchema, +} from "@metis/companies/common"; +import { + IdentityErrorResponseSchema, + SessionResponseSchema, + UserResponseSchema, + UsersResponseSchema, +} from "@metis/identity/common"; +import { QueryTypes } from "sequelize"; + +import { createApp } from "./app"; +import { createDatabase, readDatabaseEnvironment } from "./config/database"; +import { createIdentityRuntime } from "./infrastructure/http/identity-runtime"; + +test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección del administrador", async () => { + const environment = readDatabaseEnvironment(process.env); + // Este proceso solo puede limpiar la base de pruebas dedicada. + assert.equal(environment.DB_NAME, "metis_desk_test"); + const database = createDatabase(environment); + const identity = await createIdentityRuntime(database, environment); + const server = createApp(() => undefined, identity).listen(0, "127.0.0.1"); + try { + await once(server, "listening"); + await database.query("UPDATE identity_state SET initialAdminId = NULL WHERE id = 1"); + await database.query("DELETE FROM http_sessions"); + await database.query("DELETE FROM users"); + await database.query("DELETE FROM company_contacts"); + await database.query("DELETE FROM company_sites"); + await database.query("DELETE FROM companies"); + const address = server.address(); + assert.ok(address && typeof address !== "string"); + const baseUrl = `http://127.0.0.1:${address.port}/api`; + function browser() { + let cookie = ""; + let csrf = ""; + return { + async request( + path: string, + body?: unknown, + origin = environment.APP_ORIGIN, + token?: string + ) { + const response = await fetch(`${baseUrl}${path}`, { + method: body === undefined ? "GET" : "POST", + headers: { + cookie, + origin, + "content-type": "application/json", + "x-csrf-token": token ?? csrf, + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + const newCookie = response.headers.get("set-cookie"); + if (newCookie) { + cookie = newCookie.split(";")[0] ?? ""; + } + return response; + }, + async session() { + const response = await this.request("/auth/session"); + assert.equal(response.status, 200); + const data = SessionResponseSchema.parse(await response.json()); + csrf = data.csrfToken; + return data; + }, + cookie: () => cookie, + }; + } + async function expectError(response: Response, status: number, code: string) { + assert.equal(response.status, status); + assert.equal(IdentityErrorResponseSchema.parse(await response.json()).code, code); + } + const admin = browser(); + const competitor = browser(); + assert.equal((await admin.session()).setupRequired, true); + await competitor.session(); + await expectError(await admin.request("/users"), 401, "UNAUTHENTICATED"); + const first = { + name: "Admin test", + email: "ADMIN@example.test", + password: "Admin123", + }; + await expectError( + await admin.request("/auth/bootstrap", first, "https://other.example"), + 403, + "FORBIDDEN" + ); + await expectError( + await admin.request("/auth/bootstrap", first, environment.APP_ORIGIN, "á".repeat(64)), + 403, + "FORBIDDEN" + ); + await expectError( + await admin.request("/auth/bootstrap", { ...first, password: "1234567" }), + 400, + "INVALID_REQUEST" + ); + const beforeCookie = admin.cookie(); + const attempts = await Promise.all([ + admin.request("/auth/bootstrap", first), + competitor.request("/auth/bootstrap", { ...first, email: "other@example.test" }), + ]); + assert.deepEqual(attempts.map((response) => response.status).sort(), [201, 409]); + const winnerIndex = attempts.findIndex((response) => response.status === 201); + const administrator = winnerIndex === 0 ? admin : competitor; + const winnerResponse = attempts[winnerIndex]; + assert.ok(winnerResponse); + const initial = UserResponseSchema.parse(await winnerResponse.json()).user; + assert.equal(initial.isInitialAdmin, true); + assert.equal(initial.id.charAt(14), "7"); + if (winnerIndex === 0) { + assert.notEqual(admin.cookie(), beforeCookie); + } + assert.equal((await administrator.session()).setupRequired, false); + await expectError(await administrator.request("/auth/bootstrap", first), 409, "SETUP_CLOSED"); + await expectError( + await administrator.request(`/users/${initial.id}/update`, { + name: initial.name, + email: initial.email, + active: false, + }), + 409, + "ADMIN_PROTECTED" + ); + await expectError( + await administrator.request("/users", { + name: "Intruso", + email: "intruder@example.test", + password: first.password, + isInitialAdmin: true, + }), + 400, + "INVALID_REQUEST" + ); + await expectError( + await administrator.request(`/users/${initial.id.toUpperCase()}/reset-password`, { + password: "Admin123", + }), + 409, + "ADMIN_PROTECTED" + ); + const person = { + name: "Operador test", + email: "operator@example.test", + password: "User1234", + }; + const created = await administrator.request("/users", person); + assert.equal(created.status, 201); + const user = UserResponseSchema.parse(await created.json()).user; + assert.equal(user.isInitialAdmin, false); + await expectError( + await administrator.request("/users", { ...person, email: "OPERATOR@example.test" }), + 409, + "EMAIL_EXISTS" + ); + const list = UsersResponseSchema.parse(await (await administrator.request("/users")).json()); + assert.equal(list.users.length, 2); + assert.equal(JSON.stringify(list).includes("passwordHash"), false); + const operator = browser(); + await operator.session(); + await expectError( + await operator.request("/auth/login", { email: person.email, password: "incorrect" }), + 401, + "INVALID_CREDENTIALS" + ); + assert.equal( + (await operator.request("/auth/login", { email: person.email, password: person.password })) + .status, + 200 + ); + await operator.session(); + await expectError(await operator.request("/users"), 403, "FORBIDDEN"); + await expectError( + await operator.request("/auth/change-password", { + currentPassword: "incorrect", + password: "Nuevo123", + }), + 401, + "INVALID_CREDENTIALS" + ); + const oldSession = browser(); + await oldSession.session(); + assert.equal( + (await oldSession.request("/auth/login", { email: person.email, password: person.password })) + .status, + 200 + ); + await oldSession.session(); + const changedPassword = "Nuevo123"; + assert.equal( + ( + await operator.request("/auth/change-password", { + currentPassword: person.password, + password: changedPassword, + }) + ).status, + 200 + ); + assert.equal((await operator.session()).user?.id, user.id); + assert.equal((await oldSession.session()).user, null); + const resetPassword = "Reset123"; + assert.equal( + (await administrator.request(`/users/${user.id}/reset-password`, { password: resetPassword })) + .status, + 200 + ); + assert.equal((await operator.session()).user, null); + await expectError( + await operator.request("/auth/login", { email: person.email, password: changedPassword }), + 401, + "INVALID_CREDENTIALS" + ); + assert.equal( + (await operator.request("/auth/login", { email: person.email, password: resetPassword })) + .status, + 200 + ); + await operator.session(); + assert.equal( + ( + await administrator.request(`/users/${user.id}/update`, { + name: "Operador editado", + email: person.email, + active: false, + }) + ).status, + 200 + ); + assert.equal((await operator.session()).user, null); + await expectError( + await operator.request("/auth/login", { email: person.email, password: resetPassword }), + 401, + "INVALID_CREDENTIALS" + ); + assert.equal( + ( + await administrator.request(`/users/${user.id}/update`, { + name: person.name, + email: person.email, + active: true, + }) + ).status, + 200 + ); + assert.equal( + (await operator.request("/auth/login", { email: person.email, password: resetPassword })) + .status, + 200 + ); + await operator.session(); + const anonymousCompanyBrowser = browser(); + await anonymousCompanyBrowser.session(); + await expectError(await anonymousCompanyBrowser.request("/companies"), 401, "UNAUTHENTICATED"); + const companyCreated = await operator.request("/companies", { + name: "Compañía de pruebas", + active: true, + }); + assert.equal(companyCreated.status, 201); + const customer = CompanyResponseSchema.parse(await companyCreated.json()).company; + assert.equal(customer.id.charAt(14), "7"); + const secondCreated = await operator.request("/companies", { + name: "Segunda compañía", + active: true, + }); + const secondCompany = CompanyResponseSchema.parse(await secondCreated.json()).company; + const personContact = { + name: "Ana Test", + position: "Responsable IT", + email: "ANA@example.test", + phone: "+34 900 123 456", + active: true, + }; + await expectError( + await operator.request( + `/companies/${customer.id}/contacts`, + personContact, + "https://other.example" + ), + 403, + "FORBIDDEN" + ); + await expectError( + await operator.request(`/companies/${customer.id}/contacts`, { + ...personContact, + phone: "invalid", + }), + 400, + "INVALID_REQUEST" + ); + const addedContact = await operator.request( + `/companies/${customer.id}/contacts`, + personContact + ); + assert.equal(addedContact.status, 201); + const afterContact = CompanyResponseSchema.parse(await addedContact.json()).company; + const contact = afterContact.contacts[0]; + assert.ok(contact); + assert.equal(contact.companyId, customer.id); + assert.equal(contact.id.charAt(14), "7"); + assert.equal(contact.email, "ana@example.test"); + const crossContact = await operator.request( + `/companies/${secondCompany.id}/contacts/${contact.id}/update`, + personContact + ); + assert.equal(crossContact.status, 404); + assert.equal( + CompanyErrorResponseSchema.parse(await crossContact.json()).code, + "CONTACT_NOT_FOUND" + ); + const changedContact = await operator.request( + `/companies/${customer.id}/contacts/${contact.id}/update`, + { ...personContact, name: "Ana Actualizada", active: false } + ); + assert.equal(changedContact.status, 200); + assert.equal( + CompanyResponseSchema.parse(await changedContact.json()).company.contacts[0]?.active, + false + ); + const noAddressSite = await operator.request(`/companies/${customer.id}/sites`, { + name: "Almacén", + }); + assert.equal(noAddressSite.status, 201); + const emptySite = CompanyResponseSchema.parse(await noAddressSite.json()).company.sites[0]; + assert.ok(emptySite); + assert.equal(emptySite.address, null); + const siteAddress = "Calle Mayor, 10\n28001 Madrid"; + const changedSite = await operator.request( + `/companies/${customer.id}/sites/${emptySite.id}/update`, + { name: "Sede central", address: siteAddress } + ); + assert.equal(changedSite.status, 200); + assert.equal( + CompanyResponseSchema.parse(await changedSite.json()).company.sites[0]?.address, + siteAddress + ); + const crossSite = await operator.request( + `/companies/${secondCompany.id}/sites/${emptySite.id}/update`, + { name: "Ajena", address: null } + ); + assert.equal(crossSite.status, 404); + const largeAddress = "Á".repeat(10000); + assert.equal( + ( + await operator.request(`/companies/${customer.id}/sites`, { + name: "Oficina", + address: largeAddress, + }) + ).status, + 201 + ); + assert.equal( + ( + await operator.request(`/companies/${customer.id}/sites`, { + name: "Demasiado", + address: "a".repeat(10001), + }) + ).status, + 400 + ); + const editedCompany = await operator.request(`/companies/${customer.id}/update`, { + name: "Compañía actualizada", + active: false, + }); + assert.equal(editedCompany.status, 200); + const details = CompanyResponseSchema.parse( + await (await operator.request(`/companies/${customer.id}`)).json() + ).company; + assert.equal(details.id, customer.id); + assert.equal(details.active, false); + assert.equal(details.contacts[0]?.id, contact.id); + assert.equal(details.sites.length, 2); + const companiesList = CompaniesResponseSchema.parse( + await (await operator.request("/companies")).json() + ); + assert.equal(companiesList.companies.length, 2); + await assert.rejects( + database.query("DELETE FROM companies WHERE id = :id", { replacements: { id: customer.id } }) + ); + const clearedSite = await operator.request( + `/companies/${customer.id}/sites/${emptySite.id}/update`, + { name: "Sede central", address: null } + ); + assert.equal(clearedSite.status, 200); + assert.equal((await operator.request("/auth/logout", {})).status, 200); + assert.equal((await operator.session()).user, null); + await expectError(await operator.request("/users"), 401, "UNAUTHENTICATED"); + await expectError(await operator.request("/companies"), 401, "UNAUTHENTICATED"); + await assert.rejects( + database.query("UPDATE users SET active = 0 WHERE id = :id", { + replacements: { id: initial.id }, + }) + ); + await assert.rejects( + database.query("DELETE FROM users WHERE id = :id", { replacements: { id: initial.id } }) + ); + const stored = await database.query<{ passwordHash: string }>( + "SELECT passwordHash FROM users WHERE id = :id", + { replacements: { id: user.id }, type: QueryTypes.SELECT } + ); + assert.ok(stored[0]?.passwordHash.startsWith("scrypt-v1$")); + assert.equal(JSON.stringify(stored).includes(resetPassword), false); + // Al acumular fallos se rechazan los siguientes intentos. + let limited = false; + for (let index = 0; index < 12; index += 1) { + const response = await operator.request("/auth/login", { + email: "missing@example.test", + password: "wrong", + }); + if (response.status === 429) { + limited = true; + break; + } + assert.equal(response.status, 401); + } + assert.equal(limited, true); + } finally { + identity.stop(); + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + server.closeAllConnections(); + }); + await database.close(); + } +}); diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts new file mode 100644 index 0000000..ae94276 --- /dev/null +++ b/apps/server/src/index.ts @@ -0,0 +1,42 @@ +import { createApp } from "./app"; +import { createDatabase, readDatabaseEnvironment } from "./config/database"; +import { readServerEnvironment } from "./config/env-schema"; +import { createIdentityRuntime } from "./infrastructure/http/identity-runtime"; + +async function start(): Promise { + const environment = readServerEnvironment(process.env); + const databaseEnvironment = readDatabaseEnvironment(process.env); + const database = createDatabase(databaseEnvironment); + try { + const identity = await createIdentityRuntime(database, databaseEnvironment); + const server = createApp(undefined, identity).listen(environment.PORT, "127.0.0.1", () => { + console.info(JSON.stringify({ event: "server_started", port: environment.PORT })); + }); + server.on("error", () => { + identity.stop(); + database + .close() + .catch(() => console.error(JSON.stringify({ event: "database_close_failed" }))); + console.error(JSON.stringify({ event: "server_start_failed" })); + process.exitCode = 1; + }); + const shutdown = () => { + identity.stop(); + server.close(() => { + database.close().catch(() => { + console.error(JSON.stringify({ event: "database_close_failed" })); + process.exitCode = 1; + }); + }); + }; + process.once("SIGINT", shutdown); + process.once("SIGTERM", shutdown); + } catch (error) { + await database.close(); + throw error; + } +} +start().catch(() => { + console.error(JSON.stringify({ event: "server_start_failed" })); + process.exitCode = 1; +}); diff --git a/apps/server/src/infrastructure/http/http-logger.ts b/apps/server/src/infrastructure/http/http-logger.ts new file mode 100644 index 0000000..0b1844d --- /dev/null +++ b/apps/server/src/infrastructure/http/http-logger.ts @@ -0,0 +1,13 @@ +export type HttpLogEntry = Readonly<{ + event: "http_request"; + requestId: string; + method: string; + statusCode: number; + durationMs: number; +}>; +export type HttpLogger = (entry: HttpLogEntry) => void; + +// No se registran URL, cabeceras ni cuerpos: pueden contener datos de clientes o credenciales. +export const logHttpRequest: HttpLogger = (entry) => { + console.info(JSON.stringify(entry)); +}; diff --git a/apps/server/src/infrastructure/http/identity-runtime.ts b/apps/server/src/infrastructure/http/identity-runtime.ts new file mode 100644 index 0000000..1253cfc --- /dev/null +++ b/apps/server/src/infrastructure/http/identity-runtime.ts @@ -0,0 +1,44 @@ +import { createCompanyModule } from "@metis/companies/module"; +import { createIdentityModule, migrateIdentity } from "@metis/identity/module"; +import connectSequelize from "connect-session-sequelize"; +import session from "express-session"; +import type { Sequelize } from "sequelize"; + +import type { readDatabaseEnvironment } from "../../config/database"; + +export async function createIdentityRuntime( + database: Sequelize, + environment: ReturnType +) { + await database.authenticate(); + await migrateIdentity(database); + const identity = await createIdentityModule(database, environment.APP_ORIGIN); + const companies = await createCompanyModule(database, identity.authenticate); + const SequelizeStore = connectSequelize(session.Store); + const store = new SequelizeStore({ + db: database, + tableName: "http_sessions", + expiration: 8 * 60 * 60 * 1000, + }); + const middleware = session({ + name: "metis.sid", + secret: environment.SESSION_SECRET, + store, + resave: false, + saveUninitialized: false, + cookie: { + httpOnly: true, + sameSite: "strict", + secure: environment.NODE_ENV === "production", + maxAge: 8 * 60 * 60 * 1000, + }, + }); + return { + register: (app: Parameters[0]) => { + identity.register(app); + companies.register(app); + }, + middleware, + stop: () => store.stopExpiringSessions(), + }; +} diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json new file mode 100644 index 0000000..6ae9714 --- /dev/null +++ b/apps/server/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "@repo/typescript-config/buildless.json", + "include": ["src", "tsup.config.ts"] +} diff --git a/apps/server/tsup.config.ts b/apps/server/tsup.config.ts new file mode 100644 index 0000000..98f961d --- /dev/null +++ b/apps/server/tsup.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm"], + target: "node24", + platform: "node", + bundle: true, + noExternal: [/^@metis\//, "@repo/rdx-utils", "@repo/rdx-ddd"], + // El código CommonJS de dependencias empaquetadas necesita require en la salida ESM. + banner: { + js: 'import { createRequire } from "node:module"; const require = createRequire(import.meta.url);', + }, + clean: true, + sourcemap: true, +}); diff --git a/apps/web/components.json b/apps/web/components.json new file mode 100644 index 0000000..8b761f3 --- /dev/null +++ b/apps/web/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-vega", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "../../packages/shadcn-ui/src/styles/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "#components", + "ui": "@repo/shadcn-ui/components", + "utils": "@repo/shadcn-ui/lib/utils", + "lib": "#lib", + "hooks": "#hooks" + }, + "rtl": false +} diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..5807410 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,13 @@ + + + + + + + Metis Desk + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..f913751 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,38 @@ +{ + "name": "@metis/web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host 127.0.0.1", + "typecheck": "tsc -p tsconfig.json", + "test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/app.test.tsx", + "build": "vite build", + "preview": "vite preview --host 127.0.0.1" + }, + "dependencies": { + "@metis/core": "workspace:*", + "@tanstack/react-query": "5.98.0", + "i18next": "26.0.4", + "react": "19.2.5", + "react-dom": "19.2.5", + "react-i18next": "17.0.2", + "tailwindcss": "4.2.2", + "@repo/shadcn-ui": "workspace:*", + "@metis/identity": "workspace:*", + "@metis/companies": "workspace:*" + }, + "devDependencies": { + "@repo/typescript-config": "workspace:*", + "@tailwindcss/vite": "4.2.2", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", + "@vitejs/plugin-react": "6.0.1", + "vite": "8.3.0" + }, + "imports": { + "#components/*": "./src/components/*.tsx", + "#lib/*": "./src/lib/*.ts", + "#hooks/*": "./src/hooks/*.ts" + } +} diff --git a/apps/web/src/app.test.tsx b/apps/web/src/app.test.tsx new file mode 100644 index 0000000..2d484ed --- /dev/null +++ b/apps/web/src/app.test.tsx @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { identityKeys } from "@metis/identity/client"; +import type { SessionResponseDTO, UserDTO } from "@metis/identity/common"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderToStaticMarkup } from "react-dom/server"; +import { I18nextProvider } from "react-i18next"; + +import { App } from "./app"; +import { createAppI18n } from "./i18n"; + +const user: UserDTO = { + id: "c90b4d83-b06a-4d27-a402-e64899a2dfc2", + name: "Usuario de prueba", + email: "user@example.test", + active: true, + isInitialAdmin: false, +}; +async function renderPage(language: string, session?: SessionResponseDTO) { + const { instance, initialization } = createAppI18n(language); + await initialization; + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Number.POSITIVE_INFINITY } }, + }); + if (session) { + client.setQueryData(identityKeys.session, session); + } + try { + return renderToStaticMarkup( + + + + + + ); + } finally { + client.clear(); + } +} +test("el acceso protege el contenido y traduce la carga de sesión", async () => { + for (const [language, pending] of [ + ["es", "Comprobando acceso"], + ["en", "Checking access"], + ] as const) { + const markup = await renderPage(language); + assert.equal(markup.includes("Tu espacio de soporte"), false); + assert.equal(markup.includes("Your support workspace"), false); + assert.match(markup, /role="combobox"/); + assert.ok(markup.includes(language === "es" ? "Español" : "English")); + assert.ok(markup.includes(pending)); + assert.ok(markup.includes('role="status"')); + } +}); +test("la configuración única y el login presentan sus formularios traducidos sin acceso al contenido", async () => { + for (const [language, setupTitle, loginTitle] of [ + ["es", "Configura Metis Desk", "Accede a Metis Desk"], + ["en", "Set up Metis Desk", "Sign in to Metis Desk"], + ] as const) { + const snapshot = { user: null, csrfToken: "a".repeat(64), setupRequired: true }; + const setup = await renderPage(language, snapshot); + assert.ok(setup.includes(setupTitle)); + assert.match(setup, /autoComplete="new-password"/); + assert.match(setup, /minLength="8"/); + assert.equal(setup.includes("Tu espacio de soporte"), false); + const login = await renderPage(language, { ...snapshot, setupRequired: false }); + assert.ok(login.includes(loginTitle)); + assert.match(login, /autoComplete="current-password"/); + assert.match(login, /minLength="1"/); + assert.equal(login.includes('autoComplete="name"'), false); + } +}); +test("solo el administrador inicial ve la navegación de gestión de usuarios", async () => { + const snapshot = { user, csrfToken: "a".repeat(64), setupRequired: false }; + const normal = await renderPage("es", snapshot); + assert.ok(normal.includes("Tu espacio de soporte")); + assert.ok(normal.includes("Mi contraseña")); + assert.ok(normal.includes("Salir")); + assert.equal(normal.includes(">Usuarios"), false); + const admin = await renderPage("es", { ...snapshot, user: { ...user, isInitialAdmin: true } }); + assert.ok(admin.includes(">Usuarios")); +}); diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx new file mode 100644 index 0000000..918a788 --- /dev/null +++ b/apps/web/src/app.tsx @@ -0,0 +1,82 @@ +import { CompaniesPage } from "@metis/companies/client"; +import { HomePage } from "@metis/core/client"; +import { IdentityWorkspace } from "@metis/identity/client"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@repo/shadcn-ui/components/select"; +import { useId, useState } from "react"; +import { useTranslation } from "react-i18next"; + +const LANGUAGE_OPTIONS = [ + { value: "es", label: "Español" }, + { value: "en", label: "English" }, +]; + +export function App() { + const { t: companyTranslation } = useTranslation("companies"); + const { t, i18n } = useTranslation("core"); + const [languageError, setLanguageError] = useState(false); + const languageLabelId = useId(); + + return ( +
+
+
+ + Metis Desk. + +
+ {t("language")} + +
+
+ {languageError && ( +

+ {t("languageError")} +

+ )} +
+ , + }, + ]} + > + + +
+ ); +} diff --git a/apps/web/src/global.css b/apps/web/src/global.css new file mode 100644 index 0000000..ed1f63d --- /dev/null +++ b/apps/web/src/global.css @@ -0,0 +1,9 @@ +@import "@repo/shadcn-ui/globals.css"; + +@source "../../../modules"; +@source "../../../packages/shadcn-ui/src/components"; + +body { + margin: 0; + font-family: ui-sans-serif, system-ui, sans-serif; +} diff --git a/apps/web/src/i18n.ts b/apps/web/src/i18n.ts new file mode 100644 index 0000000..1bfa02e --- /dev/null +++ b/apps/web/src/i18n.ts @@ -0,0 +1,20 @@ +import { companiesEnglish, companiesSpanish } from "@metis/companies/client"; +import { coreEnglish, coreSpanish } from "@metis/core/client"; +import { identityEnglish, identitySpanish } from "@metis/identity/client"; +import { createInstance } from "i18next"; + +export function createAppI18n(language = "es") { + const instance = createInstance(); + const initialization = instance.init({ + lng: language, + fallbackLng: "es", + supportedLngs: ["es", "en"], + defaultNS: "core", + resources: { + es: { core: coreSpanish, identity: identitySpanish, companies: companiesSpanish }, + en: { core: coreEnglish, identity: identityEnglish, companies: companiesEnglish }, + }, + interpolation: { escapeValue: false }, + }); + return { instance, initialization }; +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx new file mode 100644 index 0000000..9b4a679 --- /dev/null +++ b/apps/web/src/main.tsx @@ -0,0 +1,33 @@ +import { coreSpanish } from "@metis/core/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { I18nextProvider } from "react-i18next"; + +import { App } from "./app"; +import { createAppI18n } from "./i18n"; +import "./global.css"; + +const rootElement = document.getElementById("root"); +if (!rootElement) { + throw new Error("Missing application root"); +} +const { instance, initialization } = createAppI18n(); +const queryClient = new QueryClient(); + +initialization + .then(() => { + createRoot(rootElement).render( + + + + + + + + ); + }) + .catch(() => { + rootElement.setAttribute("role", "alert"); + rootElement.textContent = coreSpanish.startupError; + }); diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 0000000..bdfe0b5 --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@repo/typescript-config/buildless.json", + "compilerOptions": { + "jsx": "react-jsx", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["vite/client"] + }, + "include": ["src", "vite.config.ts"] +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts new file mode 100644 index 0000000..6a75e26 --- /dev/null +++ b/apps/web/vite.config.ts @@ -0,0 +1,14 @@ +import tailwindcss from "@tailwindcss/vite"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [react(), tailwindcss()], + server: { + port: 5173, + strictPort: true, + proxy: { "/api": process.env.API_PROXY_TARGET ?? "http://127.0.0.1:3001" }, + fs: { strict: true }, + }, + resolve: { dedupe: ["react", "react-dom"] }, +}); diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..ab9d679 --- /dev/null +++ b/biome.json @@ -0,0 +1,337 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.11/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true, + "defaultBranch": "main" + }, + "files": { + "ignoreUnknown": true, + "includes": [ + "**", + "!!**/node_modules", + "!!**/.next", + "!!**/dist", + "!!**/build", + "!!**/coverage", + "!!**/.turbo", + "!!**/out", + "!!**/storybook-static", + "!!**/.vercel", + "!**/.env*", + "!**/public", + "!**/*.d.ts" + ] + }, + "formatter": { + "enabled": true, + "formatWithErrors": false, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100, + "lineEnding": "lf", + "attributePosition": "auto", + "bracketSpacing": true, + "bracketSameLine": true, + "expand": "auto" + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "style": { + "noDefaultExport": "off", + "noImplicitBoolean": "off", + "noInferrableTypes": "error", + "noNamespace": "error", + "noNegationElse": "warn", + "noNonNullAssertion": "error", + "noParameterAssign": "error", + "noUnusedTemplateLiteral": "error", + "noUselessElse": "warn", + "useBlockStatements": "error", + "useCollapsedElseIf": "error", + "useConst": "error", + "useDefaultParameterLast": "error", + "useEnumInitializers": "error", + "useExportType": "error", + "useFilenamingConvention": { + "level": "error", + "options": { + "strictCase": false, + "requireAscii": true, + "filenameCases": ["kebab-case"] + } + }, + "useForOf": "error", + "useFragmentSyntax": "error", + "useImportType": "error", + "useNamingConvention": { + "level": "off", + "options": { + "strictCase": false, + "conventions": [ + { + "selector": { + "kind": "function" + }, + "formats": ["camelCase", "PascalCase"] + }, + { + "selector": { + "kind": "variable" + }, + "formats": ["camelCase", "PascalCase", "CONSTANT_CASE"] + }, + { + "selector": { + "kind": "typeLike" + }, + "formats": ["PascalCase"] + } + ] + } + }, + "useNodejsImportProtocol": "error", + "useNumberNamespace": "error", + "useSelfClosingElements": "error", + "useShorthandAssign": "error", + "useShorthandFunctionType": "error", + "useSingleVarDeclarator": "error", + "useTemplate": "error", + "useThrowOnlyError": "error" + }, + "suspicious": { + "noExplicitAny": "error", + "noDebugger": "error", + "noDuplicateJsxProps": "error", + "noDuplicateObjectKeys": "error", + "noDuplicateParameters": "error", + "noShadowRestrictedNames": "error", + "noSparseArray": "error", + "noUnsafeNegation": "error", + "noArrayIndexKey": "warn", + "noAssignInExpressions": "error", + "noCatchAssign": "error", + "noClassAssign": "error", + "noCommentText": "error", + "noCompareNegZero": "error", + "noConsole": "off", + "noConstEnum": "error", + "noControlCharactersInRegex": "error", + "noDoubleEquals": "error", + "noDuplicateCase": "error", + "noEmptyBlockStatements": "off", + "noFallthroughSwitchClause": "error", + "noFunctionAssign": "error", + "noGlobalAssign": "error", + "noLabelVar": "error", + "noMisleadingCharacterClass": "error", + "noPrototypeBuiltins": "error", + "noRedeclare": "error", + "noSelfCompare": "error", + "noUnknownAtRules": "off" + }, + "correctness": { + "noConstAssign": "error", + "noConstructorReturn": "error", + "noEmptyPattern": "error", + "noInvalidConstructorSuper": "error", + "noInvalidUseBeforeDeclaration": "error", + "noSelfAssign": "error", + "noSetterReturn": "error", + "noSwitchDeclarations": "error", + "noUnreachable": "error", + "noUnreachableSuper": "error", + "noUnsafeFinally": "error", + "noUnsafeOptionalChaining": "error", + "noUnusedFunctionParameters": "warn", + "noUnusedLabels": "error", + "noUnusedVariables": "warn", + "useExhaustiveDependencies": "info", + "useHookAtTopLevel": "error", + "useIsNan": "error", + "useJsxKeyInIterable": "error", + "useValidForDirection": "error", + "useYield": "error" + }, + "complexity": { + "noBannedTypes": "off", + "noExcessiveCognitiveComplexity": { + "level": "warn", + "options": { + "maxAllowedComplexity": 15 + } + }, + "noForEach": "warn", + "noStaticOnlyClass": "off", + "noThisInStatic": "error", + "noUselessCatch": "error", + "noUselessConstructor": "error", + "noUselessFragments": "off", + "noUselessLabel": "error", + "noUselessRename": "error", + "noUselessSwitchCase": "warn", + "noUselessTernary": "error", + "noUselessTypeConstraint": "error", + "noVoid": "error", + "useFlatMap": "error", + "useLiteralKeys": "error", + "useOptionalChain": "off", + "useSimpleNumberKeys": "error", + "useSimplifiedLogicExpression": "info" + }, + "security": { + "noDangerouslySetInnerHtml": "error", + "noDangerouslySetInnerHtmlWithChildren": "error", + "noGlobalEval": "error" + }, + "a11y": { + "noAccessKey": "error", + "noAriaHiddenOnFocusable": "error", + "noAriaUnsupportedElements": "error", + "noAutofocus": "error", + "noDistractingElements": "error", + "noHeaderScope": "error", + "noInteractiveElementToNoninteractiveRole": "error", + "noNoninteractiveElementToInteractiveRole": "error", + "noNoninteractiveTabindex": "error", + "noPositiveTabindex": "error", + "noRedundantAlt": "error", + "noRedundantRoles": "error", + "useFocusableInteractive": "error", + "useIframeTitle": "error", + "useKeyWithClickEvents": "error", + "useKeyWithMouseEvents": "error", + "useMediaCaption": "error", + "useSemanticElements": "info", + "useValidAnchor": "info", + "useValidAriaProps": "error", + "useValidAriaValues": "error", + "useValidLang": "error", + "useButtonType": "info" + }, + "performance": { + "noAccumulatingSpread": "warn", + "noDelete": "error" + } + }, + "domains": { + "next": "all", + "react": "recommended" + } + }, + "assist": { + "actions": { + "source": { + "organizeImports": { + "level": "on", + "options": { + "groups": [ + ":URL:", + ":BLANK_LINE:", + [":BUN:", ":NODE:"], + ":BLANK_LINE:", + ":PACKAGE_WITH_PROTOCOL:", + ":BLANK_LINE:", + [":PACKAGE:"], + ":BLANK_LINE:", + ["!@/**", "!#*", "!~*", "!$*", "!%*"], + ":BLANK_LINE:", + ["@/**", "#*", "~*", "$*", "%*"], + ":BLANK_LINE:", + [":PATH:", "!./**", "!../**"], + ":BLANK_LINE:", + ["../**"], + ":BLANK_LINE:", + ["./**"] + ], + "identifierOrder": "lexicographic" + } + }, + "useSortedAttributes": "on" + } + } + }, + "javascript": { + "formatter": { + "arrowParentheses": "always", + "bracketSameLine": false, + "bracketSpacing": true, + "jsxQuoteStyle": "double", + "quoteProperties": "asNeeded", + "quoteStyle": "double", + "semicolons": "always", + "trailingCommas": "es5" + }, + "globals": ["console", "process", "__dirname", "__filename"] + }, + "json": { + "formatter": { + "trailingCommas": "none", + "indentStyle": "space", + "indentWidth": 2 + } + }, + "css": { + "parser": { + "tailwindDirectives": true + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100, + "quoteStyle": "double" + }, + "linter": { + "enabled": true + } + }, + "html": { + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + } + }, + "overrides": [ + { + "includes": ["**/*.d.ts", "**/lib/env/*.ts"], + "linter": { + "rules": { + "style": { + "noNamespace": "off", + "useNamingConvention": { + "level": "error", + "options": { + "strictCase": false, + "conventions": [ + { + "selector": { + "kind": "objectLiteralProperty" + }, + "formats": ["CONSTANT_CASE", "camelCase"] + } + ] + } + } + }, + "suspicious": { + "noExplicitAny": "info" + }, + "complexity": { + "noExcessiveCognitiveComplexity": { + "level": "error", + "options": { + "maxAllowedComplexity": 12 + } + } + } + } + } + } + ] +} diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..c520da4 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,21 @@ +name: metis-desk +services: + database: + image: mariadb:11.8 + ports: + - "127.0.0.1:3307:3306" + environment: + MARIADB_DATABASE: ${DB_NAME} + MARIADB_USER: ${DB_USER} + MARIADB_PASSWORD: ${DB_PASSWORD} + MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} + volumes: + - database-data:/var/lib/mysql + - ./docker/mariadb/01-test-database.sql:/docker-entrypoint-initdb.d/01-test-database.sql:ro + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 3s + timeout: 3s + retries: 30 +volumes: + database-data: diff --git a/docker/mariadb/01-test-database.sql b/docker/mariadb/01-test-database.sql new file mode 100644 index 0000000..e7e9256 --- /dev/null +++ b/docker/mariadb/01-test-database.sql @@ -0,0 +1,3 @@ +-- Las pruebas usan un esquema separado y nunca vacían los datos de la aplicación. +CREATE DATABASE IF NOT EXISTS metis_desk_test CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +GRANT ALL PRIVILEGES ON metis_desk_test.* TO 'metis'@'%'; diff --git a/docs/arquitectura/capas-del-servidor.md b/docs/arquitectura/capas-del-servidor.md new file mode 100644 index 0000000..3863c8c --- /dev/null +++ b/docs/arquitectura/capas-del-servidor.md @@ -0,0 +1,57 @@ +# Capas del servidor + +Esta es la fuente normativa de la arquitectura de Metis Desk. + +## Organización + +```text +apps/ + server/ arranque, configuración y middleware HTTP común + web/ arranque React, proveedores y composición de páginas +modules/ + core/ + src/ + common/ contratos Zod y traducciones compartidos + api/ código exclusivo del servidor + web/ páginas, consultas y acceso HTTP del navegador +packages/ + typescript-config/ configuración compartida del compilador +``` + +Los módulos funcionales seguirán esta estructura. Dentro de api, crear domain, application e infrastructure cuando existan responsabilidades que lo requieran. + +La primera entrega solo comprueba la vida del proceso. No hay reglas de negocio, agregados, repositorios ni casos de uso artificiales para esta comprobación técnica. + +## Dependencias + +Infrastructure → Application → Domain. + +Domain no conoce Express, Zod de transporte, Sequelize, Application ni Infrastructure. +Application conoce Domain, contratos y puertos necesarios; no importa Express, modelos ORM ni Infrastructure. +Infrastructure implementa HTTP y persistencia. Los puertos de repositorio se declaran en Application; sus implementaciones concretas viven en Infrastructure. + +common puede ser consumido por servidor y navegador. Nunca exporta modelos ORM ni infraestructura. +La aplicación web importa únicamente contratos comunes y la API pública web de los módulos. + +Cada módulo mantiene sus propios repositorios. La colaboración entre módulos se realiza mediante servicios públicos explícitos, únicamente cuando existan consumidores reales. + +## Creación y persistencia + +create valida invariantes de creación y devuelve Result. Application lo usa para nuevas entidades. +rehydrate reconstruye entidades persistidas sin aplicar reglas de creación ni emitir eventos. Infrastructure lo usa al leer. +update aplica sus propias invariantes; no reutiliza create ni rehydrate. + +Los Value Objects se reconstruyen mediante create. Un fallo al leer datos persistidos se trata como inconsistencia de persistencia, no se oculta. + +Cada agregado persistido tendrá un mapper bidireccional con toPersistence y toDomain. +Sequelize permanece en Infrastructure. El módulo de identidad ya usa MariaDB y Sequelize v6. + +Toda entidad de negocio lleva un UniqueID. Las declaraciones DDD y reglas de UUID se definen en [Modelo DDD e identificadores](modelo-ddd-e-identificadores.md). + +## Alcance de las abstracciones + +Result, Maybe y Collection seguirán el estilo del ERP cuando tengan consumidores reales. +No se copia rdx-utils completo ni se depende del filesystem de factuges-erp. +No se generan módulos vacíos para funciones futuras. + +Consultar también [composición](composicion-de-modulos.md), [flujo HTTP](flujo-http.md), [contratos](contratos-http.md) y [pruebas](pruebas.md). diff --git a/docs/arquitectura/composicion-de-modulos.md b/docs/arquitectura/composicion-de-modulos.md new file mode 100644 index 0000000..c853e0a --- /dev/null +++ b/docs/arquitectura/composicion-de-modulos.md @@ -0,0 +1,18 @@ +# Composición de módulos + +Cada módulo funcional tendrá un composition root explícito: construye sus dependencias y devuelve únicamente las APIs necesarias para sus consumidores. + +Usar constructor injection. No usar Service Locator, contenedores DI, decoradores ni resolución implícita. +No copiar el registro dinámico del ERP: esta aplicación todavía no necesita esa capacidad. + +Las rutas reciben dependencias ya construidas. No construyen repositorios, servicios de Application ni mappers de dominio. + +Cuando sea necesario distinguir superficies: + +- services: servicios públicos consumidos por otros módulos. +- internal: dependencias privadas necesarias para las rutas del propio módulo. + +No exportar repositorios ni modelos ORM a otros módulos. No crear contratos públicos sin consumidores. + +En esta entrega core expone registerCoreRoutes mediante ./module, una frontera de composición del servidor. +./common expone contratos y ./client expone la página y recursos de traducción. No hay dependencias locales del ERP. diff --git a/docs/arquitectura/contratos-http.md b/docs/arquitectura/contratos-http.md new file mode 100644 index 0000000..b120125 --- /dev/null +++ b/docs/arquitectura/contratos-http.md @@ -0,0 +1,19 @@ +# Contratos HTTP + +Zod v4 es la fuente de verdad de los DTOs de entrada y salida. +Inferir todos sus tipos mediante z.infer; no escribir interfaces equivalentes. + +Ubicación: modules//src/common/dto/request y response. +Crear shared solo cuando haya subcontratos estables usados por varias operaciones. + +Los nombres expresan recurso y acción: GetHealthRequestSchema, GetHealthRequestDTO, GetHealthResponseSchema y GetHealthResponseDTO. + +Distinguir campo omitido, null, cadena vacía y colección vacía. +Evitar default y transform en contratos de negocio salvo una necesidad justificada. +Usar schemas semánticos para UUID y timestamps ISO. Los decimales exactos viajan como cadenas. + +La route valida la entrada una vez; el controller no repite Zod. +El navegador valida la respuesta externa antes de consumirla. + +Los builders de respuesta se tipan con el DTO inferido exacto; no duplican interfaces Snapshot. +Son deterministas y no contienen lógica de negocio ni acceso a repositorios. diff --git a/docs/arquitectura/flujo-http.md b/docs/arquitectura/flujo-http.md new file mode 100644 index 0000000..73a02cc --- /dev/null +++ b/docs/arquitectura/flujo-http.md @@ -0,0 +1,32 @@ +# Flujo HTTP + +Para operaciones de negocio, usar: + +```text +RequestSchema → validación en route → Controller +→ UseCase({ dto, context }) → InputMapper → Value Objects / Props +→ servicios de Application / Domain +``` + +Las rutas declaran validación y guards de autenticación y autorización. +El controller obtiene el contexto autenticado, invoca el caso de uso y traduce Result a HTTP. +Application no conoce Express y no construye agregados directamente desde DTOs HTTP. + +El InputMapper adapta primitivas y contexto, crea Value Objects y devuelve Result. +Solo crear un RequestMapper adicional cuando exista adaptación HTTP real, como combinar body, params y query. + +Los identificadores de la organización y del actor se obtienen del contexto autenticado. +customerId puede identificar al cliente atendido, pero siempre se comprueba que el actor tenga acceso antes de consultar o modificar sus datos. + +## Entrega inicial + +GET /api/__health devuelve status y time. Rechaza parámetros de consulta desconocidos. +Comprueba únicamente que el proceso responde; no demuestra disponibilidad de base de datos ni autorización. +No requiere un caso de uso porque no contiene comportamiento de negocio. + +Cada petición obtiene un UUID generado por el servidor, devuelto como x-request-id. +Los errores HTTP tienen code y requestId. No devuelven detalles internos. +El middleware registra método, estado, duración e identificador; no registra URL, query, cabeceras ni cuerpo. + +El servidor escucha en 127.0.0.1. La interfaz de desarrollo usa el proxy de Vite para /api. +No hay acceso a datos de clientes ni autenticación en esta entrega. Incorporar ambos de forma conjunta antes de habilitar esos datos. diff --git a/docs/arquitectura/modelo-ddd-e-identificadores.md b/docs/arquitectura/modelo-ddd-e-identificadores.md new file mode 100644 index 0000000..568cd59 --- /dev/null +++ b/docs/arquitectura/modelo-ddd-e-identificadores.md @@ -0,0 +1,48 @@ +# Modelo DDD e identificadores + +## Regla de identidad + +Toda entidad de negocio de Metis Desk, tanto raíz de agregado como entidad interna, tiene un identificador global único UniqueID. Se genera como UUID v7, igual que en factuges-erp. Clientes, contactos, sedes/ubicaciones, documentos, tickets, intervenciones, assets, contratos y las demás entidades seguirán esta regla. + +La identidad no depende de nombres, correos, referencias visibles ni claves numéricas incrementales. No cambia al actualizar, activar o archivar. Las relaciones entre entidades usan el UniqueID de la entidad referenciada. + +Los UUID anteriores válidos, incluidos los v4 de usuarios ya creados, se conservan al leer y escribir. No se reasignan identificadores ni se reescriben relaciones. UniqueID.create valida un identificador recibido; UniqueID.generateNewID genera uno nuevo. + +Los identificadores viajan como cadenas UUID en DTOs y se validan con z.uuid. Dentro del dominio y de los puertos de repositorio se usa UniqueID, no string. Infrastructure convierte a la representación persistida. La clave primaria y las claves foráneas se declaran explícitamente en las migraciones. + +El marcador técnico de configuración y el identificador opaco de cookie de express-session no representan entidades de negocio y mantienen sus contratos de infraestructura. + +## Declaraciones + +packages/rdx-ddd proporciona la base compartida, independiente del filesystem del ERP: + +- ValueObject: valor escalar inmutable e igualdad semántica por valor y tipo. Los Value Objects no tienen identidad. +- UniqueID, Name y EmailAddress: Value Objects compartidos con create, getProps, toPrimitive, toString y equals. +- DomainEntity: identidad UniqueID y propiedades de dominio. La igualdad se basa en identidad y tipo, independientemente de los atributos. +- AggregateRoot: entidad que delimita una unidad de consistencia y es persistida por su repositorio. + +La dependencia uuid 13 se usa para generar UUID v7, como en el ERP; se evita implementar un generador propio. + +Se conserva la organización y los nombres del ERP, con tipos estrictos y sin copiar sus dependencias o utilidades no utilizadas. Esta base escalar no introduce comparación genérica de objetos compuestos ni un bus de eventos sin consumidores. + +Las raíces se declaran en archivos *.aggregate.ts; las entidades internas en *.entity.ts. Los Value Objects específicos de un módulo viven en domain/value-objects. Domain permanece libre de Express, DTOs de transporte y Sequelize. La validación propia de los Value Objects puede usar Zod, igual que en el ERP. + +## Ciclo de vida y fronteras + +create valida invariantes de creación y devuelve Result. La nueva raíz genera su UniqueID cuando no recibe uno explícito. rehydrate exige el ID persistido, no genera otro y no aplica reglas de alta ni emite eventos. update aplica invariantes de actualización y compara valores con equals. + +En esta entrega los agregados devuelven una instancia nueva al cambiar; una actualización semánticamente idéntica devuelve la misma instancia. Todas conservan el UniqueID original. + +Application adapta DTOs a Value Objects mediante InputMapper. Los errores de entrada esperados se devuelven como Result. Los repositorios aceptan identificadores y valores de dominio. + +Infrastructure reconstruye los Value Objects mediante create antes de llamar a rehydrate. Un valor persistido inválido provoca un error de consistencia sin incluir datos sensibles; no se oculta ni se reemplaza por un valor generado. toPersistence serializa primitivas. Los builders HTTP también serializan primitivas y solo exponen los campos previstos por el DTO. + +## Aplicación actual y siguientes módulos + +User es la raíz del agregado de identidad. Sus Value Objects son UniqueID, Name, EmailAddress y PasswordHash. Conserva las invariantes del administrador inicial, la activación y la invalidación de sesiones. + +Company es la raíz del agregado de compañías clientes. Contact y Site son entidades internas con UniqueID propio y referencia UniqueID a su compañía. Name, EmailAddress, PhoneNumber y SiteAddress representan sus valores; la dirección opcional usa Maybe. El repositorio carga y guarda el agregado completo dentro de una transacción con bloqueo de la compañía. + +Los módulos de documentos, tickets y assets todavía no existen. Se declararán como agregados o entidades al implementar sus operaciones y reglas de consistencia. La decisión de pertenencia a un agregado se toma por sus invariantes y transacciones; no convierte automáticamente cada tabla en una raíz ni fuerza todo el cliente y sus tickets dentro de un solo agregado. + +Cada entidad que se incorpore deberá tener UniqueID desde su primera implementación, mapper de persistencia y pruebas de identidad, invariantes, rehidratación y actualización. diff --git a/docs/arquitectura/pruebas.md b/docs/arquitectura/pruebas.md new file mode 100644 index 0000000..81bc943 --- /dev/null +++ b/docs/arquitectura/pruebas.md @@ -0,0 +1,18 @@ +# Pruebas + +Las pruebas verifican comportamiento observable, no detalles internos. + +- DTO: casos válidos, inválidos, límites, claves desconocidas y semántica de ausencia. +- InputMapper: adaptación a Value Objects y errores de dominio, sin repetir Zod. +- UseCase: colaboración, transacciones, propagación de Result e idempotencia cuando corresponda. +- HTTP: middleware, entrada, contratos de salida, estados y autorización. +- Frontend: textos traducidos, estados y acciones observables. + +Usar el runner nativo de Node.js con tsx. La primera prueba HTTP abre Express en un puerto efímero y usa fetch real. No necesita una base de datos ni espera tiempos arbitrarios. +La prueba web inicial usa renderizado React en servidor para verificar es/en y el estado pendiente. +No sustituye una prueba de interacción en navegador. + +Comprobaciones transversales: pnpm typecheck, pnpm lint, pnpm test, pnpm check y pnpm build. +Si una no se ejecuta o falla, documentar exactamente cuál y por qué. + +La prueba web fija el tsconfig raíz mediante TSX_TSCONFIG_PATH para que el runtime JSX automático también se aplique al código importado desde módulos del workspace. diff --git a/docs/companias-contactos-y-sedes.md b/docs/companias-contactos-y-sedes.md new file mode 100644 index 0000000..bbe7431 --- /dev/null +++ b/docs/companias-contactos-y-sedes.md @@ -0,0 +1,46 @@ +# Compañías, contactos y sedes + +## Uso + +Tras iniciar sesión, abrir Compañías. El listado permite buscar por nombre, crear compañías y abrir su ficha. El botón Nueva compañía está en la cabecera, a la derecha, y abre un modal. Dentro de cada ficha se puede editar la compañía y gestionar sus contactos y sedes mediante modales de alta y edición. Los listados no muestran formularios integrados. + +- Compañía: nombre obligatorio (100 caracteres como máximo) y estado activo/inactivo. +- Contacto: nombre, puesto, email y teléfono obligatorios; estado activo/inactivo. Nombre y puesto admiten 100 caracteres. El email se normaliza a minúsculas; el teléfono admite prefijo internacional, espacios, paréntesis, guiones y extensión. +- Sede: nombre obligatorio y dirección opcional, multilínea, de hasta 10.000 caracteres. Una dirección omitida, vacía o con solo espacios se guarda como ausencia. + +Las compañías representan empresas clientes atendidas, no la organización que presta soporte ni usuarios con acceso. Todos los usuarios autenticados activos pueden gestionarlas. El administrador inicial conserva su autorización exclusiva sobre la gestión de usuarios. + +Desactivar una compañía no desactiva sus contactos ni oculta sus sedes. Los estados se editan por separado. No se han añadido eliminación ni un estado de activación a las sedes. + +## Dominio y persistencia + +El módulo companies sigue Domain, Application e Infrastructure. Company es raíz de agregado; Contact y Site son entidades internas, cada una con su UniqueID. Los identificadores nuevos son UUID v7; rehydrate y update conservan los persistidos. + +Name, EmailAddress, PhoneNumber y SiteAddress encapsulan sus invariantes. Maybe representa la dirección ausente. Result expresa errores esperados. Los mappers convierten DTOs a valores de dominio y reconstruyen el agregado desde la persistencia; un registro inconsistente falla sin exponer sus datos. + +MariaDB almacena companies, company_contacts y company_sites. Las claves foráneas relacionan cada contacto y sede con su compañía, con restricción de borrado. Las migraciones explícitas se aplican al arrancar y no borran datos existentes. + +Las modificaciones bloquean la compañía dentro de una transacción antes de cargar y guardar el agregado. No se permite trasladar una entidad a otra compañía mediante una edición ni editar un contacto o sede usando el identificador de otra compañía. + +## API + +Todas las rutas requieren sesión válida. Las escrituras requieren el origen configurado y el token CSRF de la sesión. + +| Método | Ruta | Operación | +| --- | --- | --- | +| GET | /api/companies | Listado | +| GET | /api/companies/:companyId | Ficha con contactos y sedes | +| POST | /api/companies | Crear compañía | +| POST | /api/companies/:companyId/update | Editar compañía | +| POST | /api/companies/:companyId/contacts | Crear contacto | +| POST | /api/companies/:companyId/contacts/:id/update | Editar contacto | +| POST | /api/companies/:companyId/sites | Crear sede | +| POST | /api/companies/:companyId/sites/:id/update | Editar sede | + +Los contratos Zod se declaran en common/dto/request, response y shared. Las mutaciones devuelven la ficha completa para actualizar la caché de TanStack Query. TanStack Table filtra el listado; React Hook Form valida los formularios. Los controles proceden del paquete compartido shadcn/ui. + +## Validación + +Las pruebas cubren identidad UUID, invariantes, pertenencia al agregado, actualizaciones, dirección opcional y límites. Las pruebas HTTP usan exclusivamente metis_desk_test y comprueban autenticación, CSRF/origen, persistencia, relaciones y direcciones de 10.000 caracteres Unicode. + +También se ha probado en el navegador la búsqueda, la creación de compañías y contactos, y la creación y edición de sedes con direcciones multilínea, usando datos ficticios en el entorno de pruebas aislado. diff --git a/docs/componentes-de-interfaz.md b/docs/componentes-de-interfaz.md new file mode 100644 index 0000000..f2a5861 --- /dev/null +++ b/docs/componentes-de-interfaz.md @@ -0,0 +1,56 @@ +# Componentes de interfaz + +Metis Desk usa shadcn/ui, con Base UI y el estilo base-vega, siguiendo la organización de factuges-erp. +Los componentes viven en packages/shadcn-ui y se consumen mediante el paquete @repo/shadcn-ui. +El código se genera desde el registro oficial y queda versionado en este repositorio, sin depender del ERP. + +## Componentes actuales + +- Button: volver a comprobar la conexión. +- Card: bloques de información y estado. +- Select: cambio entre castellano e inglés, con navegación por teclado. +- Input y Label: credenciales y datos de usuario. +- Checkbox: estado activo de usuarios, compañías y contactos. +- Table: listados de usuarios, compañías y contactos. +- Textarea: dirección opcional de las sedes, con varias líneas. +- Dialog: formularios sencillos de alta y edición, con foco y cierre por teclado. FormDialog comparte el título y la cancelación traducidos. + +## Convención de navegación + +Los listados presentan datos, búsqueda y acciones. El botón de alta se coloca en la cabecera, a la derecha. Los formularios sencillos se abren en un modal; los complejos o con muchos campos se presentan en una página propia. No mostrar formularios junto al listado. Las ediciones se abren mediante una acción explícita. Compañías, contactos, sedes y usuarios siguen este criterio. + +TanStack Table filtra el listado de compañías por nombre; React Hook Form gestiona los formularios con los contratos Zod compartidos. + +El paquete exporta componentes individuales. Importar solo el componente necesario: + +```tsx +import { Button } from "@repo/shadcn-ui/components/button"; +``` + +El tema se define una sola vez en packages/shadcn-ui/src/styles/globals.css. +La aplicación importa ese CSS y declara las fuentes de Tailwind de los módulos y del paquete compartido. +Usar tokens semánticos como bg-background, text-foreground y border-border en vez de repetir colores por pantalla. + +## Añadir componentes + +Desde la raíz del proyecto, dentro de WSL: + +```bash +pnpm --filter @repo/shadcn-ui ui:add input +``` + +El CLI está fijado en package.json. Revisar los archivos y dependencias añadidos antes de validar. +No usar --overwrite sin revisar las adaptaciones locales. +apps/web/components.json también permite al CLI localizar el paquete compartido desde la aplicación. + +Después de generar código, separar los imports de tipos con import type y ejecutar las comprobaciones del proyecto. +No añadir todo el catálogo ni crear envoltorios sin una necesidad concreta. + +## Dependencias + +Base UI proporciona interacción accesible y gestión del foco. +class-variance-authority define variantes del botón; cn combina clases de Tailwind. +lucide-react aporta los iconos del selector y tw-animate-css sus transiciones. +shadcn es una herramienta de desarrollo; su hoja de estilos común se incorpora al compilar. + +Guías oficiales: [monorepos](https://ui.shadcn.com/docs/monorepo) e [instalación manual](https://ui.shadcn.com/docs/installation/manual). diff --git a/docs/plan-de-desarrollo.md b/docs/plan-de-desarrollo.md new file mode 100644 index 0000000..a1fa2b9 --- /dev/null +++ b/docs/plan-de-desarrollo.md @@ -0,0 +1,48 @@ +# Plan de desarrollo + +Metis Desk es una aplicación interna para técnicos que dan soporte a pequeñas empresas. +El proyecto es independiente de factuges-erp y comparte sus convenciones de programación. + +## Entrega 0: base técnica + +Incluye monorepo PNPM y Turborepo, TypeScript estricto, Biome, Express, React, Vite, Tailwind, TanStack Query, shadcn/ui con Base UI, Zod y traducciones es/en. +La página inicial muestra la conexión real con la API. No contiene datos ficticios de clientes. + +## Acceso y usuarios: implementado + +MariaDB, Sequelize v6, migraciones iniciales, configuración única del administrador, login, sesiones y gestión de usuarios sin catálogo de roles. React Hook Form y componentes shadcn/ui para estos formularios. + +## Entrega 1: compañías clientes, implementado + +Crear, consultar, editar y buscar compañías; activar o desactivar compañías y contactos; crear y editar sedes con dirección opcional. Formularios con React Hook Form, listado con TanStack Table y componentes shadcn/ui. Consultar [Compañías, contactos y sedes](companias-contactos-y-sedes.md). + +Distinguir: + +- Organización que presta el soporte: contexto de acceso. +- Cliente atendido: empresa sobre la que se registran datos. +- Técnico: usuario autenticado de Metis Desk. +- Contacto: persona del cliente. +- Usuario de un equipo: persona asignada a un puesto; no implica acceso a la aplicación. + +La adopción de una sola organización o de varias se decidirá antes del modelo persistente. +No crear un sistema multiempresa complejo sin necesidad, companyId identifica a la compañía cliente; no se utiliza como identificador de tenant. + +## Entrega 2: inventario + +Infraestructura por sede: routers, firewalls, switches, puntos de acceso, NAS y servidores. +Puestos de trabajo y asignaciones: equipos, monitores, impresoras y usuarios. +Definir identificación, estado e historial de movimientos. + +## Entrega 3: tickets e intervenciones + +Cliente, sede, activo afectado, técnico, fecha, tiempo empleado, diagnóstico, trabajo realizado y resolución. +Un ticket puede tener varias intervenciones. No confundir la solicitud de soporte con cada trabajo realizado. + +## Entrega 4: servicios y documentación + +Software, licencias, Microsoft 365, ERP, antivirus, cloud, dominios, DNS, certificados, contratos y documentación. +Las credenciales se resolverán con una solución específica; no almacenarlas como texto libre. + +## Decisiones pendientes + +Permisos por técnico si llegan a necesitarse, despliegue, backups, recuperación administrativa y modelo de organización. El acceso y los usuarios ya están implementados; la base de compañías clientes, contactos y sedes está implementada. Inventario y tickets siguen pendientes. diff --git a/docs/puesta-en-marcha.md b/docs/puesta-en-marcha.md new file mode 100644 index 0000000..19798d7 --- /dev/null +++ b/docs/puesta-en-marcha.md @@ -0,0 +1,46 @@ +# Puesta en marcha + +## Entorno + +El repositorio está en /home/david/dev/metis-desk, dentro de WSL2 Ubuntu-24.04. Necesita Node.js 24.x, PNPM 11.25.0 y Docker disponible desde WSL. No depende de paquetes locales de factuges-erp. + +Desde una terminal Ubuntu-24.04: + +```bash +cd /home/david/dev/metis-desk +pnpm install --frozen-lockfile +pnpm db:up +pnpm dev +``` + +pnpm db:up genera .env con secretos locales si no existe e inicia MariaDB en un volumen persistente. No crea usuarios de la aplicación. El servidor aplica las migraciones al arrancar. + +Abrir http://localhost:5173. La primera apertura permite crear el administrador inicial; las siguientes muestran el acceso. La API escucha en 127.0.0.1:3001 y la base de datos en 127.0.0.1:3307. + +Consultar [Usuarios y acceso](usuarios-y-acceso.md) para la configuración inicial y protección de cuentas. No borrar el volumen para actualizar o reiniciar: contiene usuarios, compañías, contactos y sedes. + +Si se cambia PORT, configurar API_PROXY_TARGET al arrancar Vite (por defecto http://127.0.0.1:3001). Si cambia el origen del navegador, actualizar APP_ORIGIN en .env. + +## Verificaciones + +```bash +pnpm typecheck +pnpm lint +pnpm test +pnpm check +pnpm build +``` + +La base local debe estar encendida para las pruebas de integración. Estas solo usan metis_desk_test. La compilación genera apps/server/dist y apps/web/dist. + +Para ejecutar la API compilada, parar primero el servidor de desarrollo: + +```bash +pnpm --filter @metis/server start +``` + +## Alcance + +Hay acceso, sesiones persistentes, gestión de usuarios, compañías clientes con contactos y sedes y una página inicial que comprueba la conexión. Inventario y tickets siguen pendientes. Las tablas nuevas se crean al arrancar la API mediante migraciones explícitas. + +La API compilada todavía no sirve la interfaz estática. El despliegue, proxy HTTPS, backups y recuperación de acceso se definirán en una entrega posterior. diff --git a/docs/usuarios-y-acceso.md b/docs/usuarios-y-acceso.md new file mode 100644 index 0000000..8521343 --- /dev/null +++ b/docs/usuarios-y-acceso.md @@ -0,0 +1,37 @@ +# Usuarios y acceso + +## Primera apertura + +La instalación empieza sin cuentas ni contraseñas predeterminadas. En la primera visita a http://localhost:5173 aparece «Configura Metis Desk». Introducir nombre, correo y contraseña de al menos 8 caracteres para crear el administrador inicial. + +Esa operación crea la cuenta y el marcador de instalación en una misma transacción. La configuración se cierra para siempre al completarla; dos peticiones simultáneas no pueden crear dos administradores. La cuenta inicial no se puede desactivar ni borrar. Su correo, nombre y contraseña sí se pueden cambiar. + +Por ahora no existe un catálogo de roles. La cuenta inicial tiene una condición especial e inmutable y es la única que puede gestionar usuarios. El resto accede al espacio de soporte y puede cambiar su propia contraseña. + +## Gestión + +Desde «Usuarios» el administrador puede crear cuentas, editar nombre y correo, activar/desactivar usuarios y restablecer contraseñas. No se borran cuentas: la desactivación conserva su identidad para las futuras relaciones con tickets e intervenciones. + +El administrador cambia su propia contraseña en «Mi contraseña», proporcionando la contraseña actual. El restablecimiento de otro usuario invalida sus sesiones. Su nueva contraseña debe comunicarse por un canal privado; no hay envío automático de correos. + +Los correos se normalizan a minúsculas y son únicos. El nombre se recorta en Application. Los DTOs estrictos no admiten campos para otorgar privilegios. + +## Persistencia y sesiones + +MariaDB persiste usuarios, el marcador de configuración y las sesiones. Las migraciones iniciales son aditivas e idempotentes y no recrean tablas ni reinician el marcador. No se utiliza sync para modificar el esquema. + +Las contraseñas se guardan con scrypt y una sal aleatoria, nunca como texto. Las sesiones usan una cookie HttpOnly, SameSite=Strict y duración de ocho horas. El identificador de sesión se renueva al entrar y al cambiar la contraseña propia. No se guarda ningún token en localStorage. + +Cada operación comprueba el estado y la versión de autenticación del usuario. Cambiar contraseñas o activar/desactivar cuentas invalida las sesiones previas. Los POST requieren el origen configurado y un token CSRF asociado a la sesión. Los intentos fallidos de acceso están limitados por dirección IP; el contador se mantiene en memoria en esta entrega local. + +## Entorno local + +Docker Compose inicia una MariaDB independiente en 127.0.0.1:3307. Su volumen conserva los datos al reiniciar. El archivo .env se genera localmente con secretos aleatorios y no se versiona. SESSION_SECRET debe conservarse entre arranques para mantener las sesiones. + +APP_ORIGIN debe coincidir exactamente con el origen usado en el navegador; el valor local es http://localhost:5173. Usar 127.0.0.1 en el navegador requiere cambiar esta configuración. En un futuro despliegue con HTTPS, NODE_ENV=production activa Secure en la cookie; el proxy, backups y recuperación administrativa siguen pendientes de diseño. + +## Pruebas + +pnpm db:up prepara el entorno. pnpm test ejecuta pruebas de dominio, hashes, interfaz y HTTP con MariaDB. La prueba de integración solo puede limpiar metis_desk_test y comprueba ese nombre antes de hacerlo. Nunca limpia metis_desk. + +Cubre configuración concurrente, cierre del bootstrap, protección de administrador en HTTP y SQL, contratos estrictos, correos duplicados, login, autorización, CSRF, rotación de sesión, cambio y restablecimiento de contraseña, desactivación/reactivación, cierre de sesión y límite de intentos. diff --git a/modules/companies/package.json b/modules/companies/package.json new file mode 100644 index 0000000..c684a6e --- /dev/null +++ b/modules/companies/package.json @@ -0,0 +1,40 @@ +{ + "name": "@metis/companies", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + "./common": "./src/common/index.ts", + "./module": "./src/api/module.ts", + "./client": "./src/web/index.ts" + }, + "scripts": { + "typecheck": "tsc -p tsconfig.json", + "test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/company.aggregate.test.ts src/web/components/companies-page.test.tsx" + }, + "dependencies": { + "@repo/rdx-ddd": "workspace:*", + "@repo/rdx-utils": "workspace:*", + "@repo/shadcn-ui": "workspace:*", + "sequelize": "6.37.8", + "zod": "4.3.6", + "react-hook-form": "7.72.1", + "@hookform/resolvers": "5.2.2", + "@tanstack/react-table": "8.21.3" + }, + "devDependencies": { + "@repo/typescript-config": "workspace:*", + "@types/node": "25.6.0", + "@types/express": "4.17.21", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3" + }, + "peerDependencies": { + "express": "4.22.1", + "react": "19.2.5", + "react-dom": "19.2.5", + "i18next": "26.0.4", + "react-i18next": "17.0.2", + "@tanstack/react-query": "5.98.0" + } +} diff --git a/modules/companies/src/api/application/company-input.mapper.ts b/modules/companies/src/api/application/company-input.mapper.ts new file mode 100644 index 0000000..8877ae9 --- /dev/null +++ b/modules/companies/src/api/application/company-input.mapper.ts @@ -0,0 +1,51 @@ +import { EmailAddress, Name, UniqueID } from "@repo/rdx-ddd"; +import { Maybe, Result } from "@repo/rdx-utils"; + +import type { CompanyRequestDTO, ContactRequestDTO, SiteRequestDTO } from "../../common"; +import { CompanyError } from "../domain/company-error"; +import { PhoneNumber } from "../domain/value-objects/phone-number"; +import { SiteAddress } from "../domain/value-objects/site-address"; + +export class CompanyInputMapper { + id(value: string): Result { + const result = UniqueID.create(value); + return result.isSuccess + ? Result.ok(result.data) + : Result.fail(new CompanyError("INVALID_REQUEST")); + } + company(dto: CompanyRequestDTO) { + const name = Name.create(dto.name, 100); + return name.isSuccess + ? Result.ok({ name: name.data, active: dto.active }) + : Result.fail(new CompanyError("INVALID_REQUEST")); + } + contact(dto: ContactRequestDTO) { + const name = Name.create(dto.name, 100); + const position = Name.create(dto.position, 100); + const email = EmailAddress.create(dto.email); + const phone = PhoneNumber.create(dto.phone); + if (!(name.isSuccess && position.isSuccess && email.isSuccess && phone.isSuccess)) { + return Result.fail(new CompanyError("INVALID_REQUEST")); + } + return Result.ok({ + name: name.data, + position: position.data, + email: email.data, + phone: phone.data, + active: dto.active, + }); + } + site(dto: SiteRequestDTO) { + const name = Name.create(dto.name, 100); + if (!name.isSuccess) { + return Result.fail(new CompanyError("INVALID_REQUEST")); + } + if (dto.address === null || dto.address === undefined || !dto.address.trim()) { + return Result.ok({ name: name.data, address: Maybe.none() }); + } + const address = SiteAddress.create(dto.address); + return address.isSuccess + ? Result.ok({ name: name.data, address: Maybe.some(address.data) }) + : Result.fail(new CompanyError("INVALID_REQUEST")); + } +} diff --git a/modules/companies/src/api/application/company-response.builder.ts b/modules/companies/src/api/application/company-response.builder.ts new file mode 100644 index 0000000..da727dd --- /dev/null +++ b/modules/companies/src/api/application/company-response.builder.ts @@ -0,0 +1,26 @@ +import type { CompanyDTO, CompanySummaryDTO } from "../../common"; +import type { Company } from "../domain/company.aggregate"; + +export function buildCompanySummary(company: Company): CompanySummaryDTO { + return { id: company.id.toPrimitive(), name: company.name.toPrimitive(), active: company.active }; +} +export function buildCompanyResponse(company: Company): CompanyDTO { + return { + ...buildCompanySummary(company), + contacts: company.contacts.map((contact) => ({ + id: contact.id.toPrimitive(), + companyId: contact.companyId.toPrimitive(), + name: contact.name.toPrimitive(), + position: contact.position.toPrimitive(), + email: contact.email.toPrimitive(), + phone: contact.phone.toPrimitive(), + active: contact.active, + })), + sites: company.sites.map((site) => ({ + id: site.id.toPrimitive(), + companyId: site.companyId.toPrimitive(), + name: site.name.toPrimitive(), + address: site.address.getOrUndefined()?.toPrimitive() ?? null, + })), + }; +} diff --git a/modules/companies/src/api/application/company.repository.ts b/modules/companies/src/api/application/company.repository.ts new file mode 100644 index 0000000..9894a5a --- /dev/null +++ b/modules/companies/src/api/application/company.repository.ts @@ -0,0 +1,15 @@ +import type { UniqueID } from "@repo/rdx-ddd"; +import type { Maybe, Result } from "@repo/rdx-utils"; + +import type { Company } from "../domain/company.aggregate"; +import type { CompanyError } from "../domain/company-error"; + +export interface ICompanyRepository { + list(): Promise; + findById(id: UniqueID): Promise>; + create(company: Company): Promise; + update( + id: UniqueID, + change: (company: Company) => Result + ): Promise>; +} diff --git a/modules/companies/src/api/application/company.use-cases.ts b/modules/companies/src/api/application/company.use-cases.ts new file mode 100644 index 0000000..edd8a39 --- /dev/null +++ b/modules/companies/src/api/application/company.use-cases.ts @@ -0,0 +1,88 @@ +import { Result } from "@repo/rdx-utils"; + +import type { CompanyRequestDTO, ContactRequestDTO, SiteRequestDTO } from "../../common"; +import { Company } from "../domain/company.aggregate"; +import { CompanyError } from "../domain/company-error"; +import { Contact } from "../domain/contact.entity"; +import { Site } from "../domain/site.entity"; + +import type { ICompanyRepository } from "./company.repository"; +import type { CompanyInputMapper } from "./company-input.mapper"; + +export class CompanyUseCases { + constructor( + private readonly companies: ICompanyRepository, + private readonly input: CompanyInputMapper + ) {} + list() { + return this.companies.list(); + } + async get(id: string) { + const parsed = this.input.id(id); + if (!parsed.isSuccess) { + return parsed; + } + const company = (await this.companies.findById(parsed.data)).getOrUndefined(); + return company ? Result.ok(company) : Result.fail(new CompanyError("COMPANY_NOT_FOUND")); + } + async create(dto: CompanyRequestDTO) { + const props = this.input.company(dto); + if (!props.isSuccess) { + return props; + } + const created = Company.create(props.data); + return created.isSuccess ? Result.ok(await this.companies.create(created.data)) : created; + } + async update(id: string, dto: CompanyRequestDTO) { + const parsed = this.input.id(id); + const props = this.input.company(dto); + if (!(parsed.isSuccess && props.isSuccess)) { + return Result.fail(new CompanyError("INVALID_REQUEST")); + } + return this.companies.update(parsed.data, (company) => company.update(props.data)); + } + async addContact(id: string, dto: ContactRequestDTO) { + const parsed = this.input.id(id); + const props = this.input.contact(dto); + if (!(parsed.isSuccess && props.isSuccess)) { + return Result.fail(new CompanyError("INVALID_REQUEST")); + } + return this.companies.update(parsed.data, (company) => { + const contact = Contact.create({ ...props.data, companyId: company.id }); + return contact.isSuccess ? company.addContact(contact.data) : contact; + }); + } + async updateContact(companyId: string, id: string, dto: ContactRequestDTO) { + const parent = this.input.id(companyId); + const child = this.input.id(id); + const props = this.input.contact(dto); + if (!(parent.isSuccess && child.isSuccess && props.isSuccess)) { + return Result.fail(new CompanyError("INVALID_REQUEST")); + } + return this.companies.update(parent.data, (company) => + company.updateContact(child.data, props.data) + ); + } + async addSite(id: string, dto: SiteRequestDTO) { + const parsed = this.input.id(id); + const props = this.input.site(dto); + if (!(parsed.isSuccess && props.isSuccess)) { + return Result.fail(new CompanyError("INVALID_REQUEST")); + } + return this.companies.update(parsed.data, (company) => { + const site = Site.create({ ...props.data, companyId: company.id }); + return site.isSuccess ? company.addSite(site.data) : site; + }); + } + async updateSite(companyId: string, id: string, dto: SiteRequestDTO) { + const parent = this.input.id(companyId); + const child = this.input.id(id); + const props = this.input.site(dto); + if (!(parent.isSuccess && child.isSuccess && props.isSuccess)) { + return Result.fail(new CompanyError("INVALID_REQUEST")); + } + return this.companies.update(parent.data, (company) => + company.updateSite(child.data, props.data) + ); + } +} diff --git a/modules/companies/src/api/domain/company-error.ts b/modules/companies/src/api/domain/company-error.ts new file mode 100644 index 0000000..2be10fe --- /dev/null +++ b/modules/companies/src/api/domain/company-error.ts @@ -0,0 +1,10 @@ +export type CompanyErrorCode = + | "INVALID_REQUEST" + | "COMPANY_NOT_FOUND" + | "CONTACT_NOT_FOUND" + | "SITE_NOT_FOUND"; +export class CompanyError extends Error { + constructor(public readonly code: CompanyErrorCode) { + super(code); + } +} diff --git a/modules/companies/src/api/domain/company.aggregate.test.ts b/modules/companies/src/api/domain/company.aggregate.test.ts new file mode 100644 index 0000000..bc5ffd2 --- /dev/null +++ b/modules/companies/src/api/domain/company.aggregate.test.ts @@ -0,0 +1,107 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { UniqueID } from "@repo/rdx-ddd"; + +import { CompanyInputMapper } from "../application/company-input.mapper"; + +import { Company } from "./company.aggregate"; +import { Contact } from "./contact.entity"; +import { Site } from "./site.entity"; + +const input = new CompanyInputMapper(); +function companyFixture(name = "Empresa test") { + const props = input.company({ name, active: true }); + assert.ok(props.isSuccess); + const created = Company.create(props.data); + assert.ok(created.isSuccess); + return created.data; +} +test("compañía, contacto y sede tienen UUID v7 propios y se conserva la identidad al editar", () => { + const company = companyFixture(); + const props = input.contact({ + name: "Ana", + position: "Responsable", + email: "ANA@example.test", + phone: "+34 900 123 456", + active: true, + }); + assert.ok(props.isSuccess); + const contact = Contact.create({ ...props.data, companyId: company.id }); + assert.ok(contact.isSuccess); + const added = company.addContact(contact.data); + assert.ok(added.isSuccess); + assert.notEqual(company.id.toPrimitive(), contact.data.id.toPrimitive()); + assert.equal(contact.data.id.toPrimitive().charAt(14), "7"); + const changed = added.data.updateContact(contact.data.id, { ...props.data, active: false }); + assert.ok(changed.isSuccess); + assert.ok(changed.data.id.equals(company.id)); + assert.ok(changed.data.contacts[0]?.id.equals(contact.data.id)); + assert.equal(changed.data.contacts[0]?.active, false); + assert.equal(added.data.contacts[0]?.active, true); + const repeated = changed.data.updateContact(contact.data.id, { ...props.data, active: false }); + assert.ok(repeated.isSuccess); + assert.equal(repeated.data, changed.data); +}); +test("la pertenencia impide asociar o editar entidades de otra compañía", () => { + const company = companyFixture(); + const other = companyFixture("Otra"); + const props = input.site({ name: "Central", address: null }); + assert.ok(props.isSuccess); + const site = Site.create({ ...props.data, companyId: other.id }); + assert.ok(site.isSuccess); + assert.equal(company.addSite(site.data).isSuccess, false); + const missing = company.updateSite(site.data.id, props.data); + assert.equal(missing.isSuccess, false); + const contact = company.updateContact(UniqueID.generateNewID(), { + ...inputContact(), + active: false, + }); + assert.equal(contact.isSuccess, false); +}); +function inputContact() { + const props = input.contact({ + name: "Ana", + position: "Responsable", + email: "ana@example.test", + phone: "900123456", + active: true, + }); + assert.ok(props.isSuccess); + return props.data; +} +test("la dirección es optativa, admite varias líneas y se puede borrar", () => { + const company = companyFixture(); + const empty = input.site({ name: "Central", address: null }); + const full = input.site({ name: "Central", address: "Calle Mayor, 10\n28001 Madrid" }); + assert.ok(empty.isSuccess && full.isSuccess); + const site = Site.create({ ...full.data, companyId: company.id }); + assert.ok(site.isSuccess); + assert.equal(site.data.id.toPrimitive().charAt(14), "7"); + const added = company.addSite(site.data); + assert.ok(added.isSuccess); + const cleared = added.data.updateSite(site.data.id, empty.data); + assert.ok(cleared.isSuccess); + assert.equal(cleared.data.sites[0]?.address.getOrUndefined(), undefined); + assert.ok(cleared.data.sites[0]?.id.equals(site.data.id)); +}); +test("nombres, email, teléfono y longitud de dirección se validan en los Value Objects", () => { + assert.equal(input.company({ name: " ", active: true }).isSuccess, false); + assert.equal( + input.contact({ name: "Ana", position: "", email: "invalid", phone: "abc", active: true }) + .isSuccess, + false + ); + assert.equal( + input.contact({ + name: "Ana", + position: "Responsable", + email: "ana@example.test", + phone: "abc", + active: true, + }).isSuccess, + false + ); + assert.equal(input.site({ name: "Central", address: "x".repeat(10001) }).isSuccess, false); + assert.equal(input.site({ name: "Central", address: "x".repeat(10000) }).isSuccess, true); +}); diff --git a/modules/companies/src/api/domain/company.aggregate.ts b/modules/companies/src/api/domain/company.aggregate.ts new file mode 100644 index 0000000..0cfbb4f --- /dev/null +++ b/modules/companies/src/api/domain/company.aggregate.ts @@ -0,0 +1,122 @@ +import type { Name, UniqueID } from "@repo/rdx-ddd"; +import { AggregateRoot } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import { CompanyError } from "./company-error"; +import type { Contact, ContactProps } from "./contact.entity"; +import type { Site, SiteProps } from "./site.entity"; + +export type CompanyProps = Readonly<{ + name: Name; + active: boolean; + contacts: readonly Contact[]; + sites: readonly Site[]; +}>; +export class Company extends AggregateRoot { + protected constructor(props: CompanyProps, id?: UniqueID) { + super( + { + ...props, + contacts: Object.freeze([...props.contacts]), + sites: Object.freeze([...props.sites]), + }, + id + ); + } + static create(props: Pick): Result { + if (props.name.toPrimitive().length > 100) { + return Result.fail(new CompanyError("INVALID_REQUEST")); + } + return Result.ok(new Company({ ...props, contacts: [], sites: [] })); + } + static rehydrate(props: CompanyProps, id: UniqueID): Company { + return new Company(props, id); + } + get name() { + return this.props.name; + } + get active() { + return this.props.active; + } + get contacts() { + return this.props.contacts; + } + get sites() { + return this.props.sites; + } + update(values: Pick): Result { + if (values.name.toPrimitive().length > 100) { + return Result.fail(new CompanyError("INVALID_REQUEST")); + } + return this.name.equals(values.name) && this.active === values.active + ? Result.ok(this) + : Result.ok(new Company({ ...this.props, ...values }, this.id)); + } + addContact(contact: Contact): Result { + if (!contact.companyId.equals(this.id)) { + return Result.fail(new CompanyError("INVALID_REQUEST")); + } + if (this.contacts.some((existing) => existing.id.equals(contact.id))) { + return Result.ok(this); + } + return Result.ok( + new Company({ ...this.props, contacts: [...this.contacts, contact] }, this.id) + ); + } + updateContact( + id: UniqueID, + values: Omit + ): Result { + const current = this.contacts.find((contact) => contact.id.equals(id)); + if (!current) { + return Result.fail(new CompanyError("CONTACT_NOT_FOUND")); + } + const updated = current.update(values); + if (!updated.isSuccess) { + return updated; + } + return updated.data === current + ? Result.ok(this) + : Result.ok( + new Company( + { + ...this.props, + contacts: this.contacts.map((contact) => + contact.id.equals(id) ? updated.data : contact + ), + }, + this.id + ) + ); + } + addSite(site: Site): Result { + if (!site.companyId.equals(this.id)) { + return Result.fail(new CompanyError("INVALID_REQUEST")); + } + if (this.sites.some((existing) => existing.id.equals(site.id))) { + return Result.ok(this); + } + return Result.ok(new Company({ ...this.props, sites: [...this.sites, site] }, this.id)); + } + updateSite(id: UniqueID, values: Omit): Result { + const current = this.sites.find((site) => site.id.equals(id)); + if (!current) { + return Result.fail(new CompanyError("SITE_NOT_FOUND")); + } + const updated = current.update(values); + if (!updated.isSuccess) { + return updated; + } + return updated.data === current + ? Result.ok(this) + : Result.ok( + new Company( + { + ...this.props, + sites: this.sites.map((site) => (site.id.equals(id) ? updated.data : site)), + }, + this.id + ) + ); + } +} diff --git a/modules/companies/src/api/domain/contact.entity.ts b/modules/companies/src/api/domain/contact.entity.ts new file mode 100644 index 0000000..e9fd945 --- /dev/null +++ b/modules/companies/src/api/domain/contact.entity.ts @@ -0,0 +1,59 @@ +import type { EmailAddress, Name, UniqueID } from "@repo/rdx-ddd"; +import { DomainEntity } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import { CompanyError } from "./company-error"; +import type { PhoneNumber } from "./value-objects/phone-number"; + +export type ContactProps = Readonly<{ + companyId: UniqueID; + name: Name; + position: Name; + email: EmailAddress; + phone: PhoneNumber; + active: boolean; +}>; +export class Contact extends DomainEntity { + static create(props: ContactProps): Result { + if (props.name.toPrimitive().length > 100 || props.position.toPrimitive().length > 100) { + return Result.fail(new CompanyError("INVALID_REQUEST")); + } + return Result.ok(new Contact(props)); + } + static rehydrate(props: ContactProps, id: UniqueID): Contact { + return new Contact(props, id); + } + get companyId() { + return this.props.companyId; + } + get name() { + return this.props.name; + } + get position() { + return this.props.position; + } + get email() { + return this.props.email; + } + get phone() { + return this.props.phone; + } + get active() { + return this.props.active; + } + update(values: Omit): Result { + if (values.name.toPrimitive().length > 100 || values.position.toPrimitive().length > 100) { + return Result.fail(new CompanyError("INVALID_REQUEST")); + } + if ( + this.name.equals(values.name) && + this.position.equals(values.position) && + this.email.equals(values.email) && + this.phone.equals(values.phone) && + this.active === values.active + ) { + return Result.ok(this); + } + return Result.ok(new Contact({ ...values, companyId: this.companyId }, this.id)); + } +} diff --git a/modules/companies/src/api/domain/site.entity.ts b/modules/companies/src/api/domain/site.entity.ts new file mode 100644 index 0000000..2f16933 --- /dev/null +++ b/modules/companies/src/api/domain/site.entity.ts @@ -0,0 +1,40 @@ +import type { Name, UniqueID } from "@repo/rdx-ddd"; +import { DomainEntity } from "@repo/rdx-ddd"; +import type { Maybe } from "@repo/rdx-utils"; +import { Result } from "@repo/rdx-utils"; + +import { CompanyError } from "./company-error"; +import type { SiteAddress } from "./value-objects/site-address"; + +export type SiteProps = Readonly<{ companyId: UniqueID; name: Name; address: Maybe }>; +export class Site extends DomainEntity { + static create(props: SiteProps): Result { + if (props.name.toPrimitive().length > 100) { + return Result.fail(new CompanyError("INVALID_REQUEST")); + } + return Result.ok(new Site(props)); + } + static rehydrate(props: SiteProps, id: UniqueID): Site { + return new Site(props, id); + } + get companyId() { + return this.props.companyId; + } + get name() { + return this.props.name; + } + get address() { + return this.props.address; + } + update(values: Omit): Result { + if (values.name.toPrimitive().length > 100) { + return Result.fail(new CompanyError("INVALID_REQUEST")); + } + const current = this.address.getOrUndefined(); + const next = values.address.getOrUndefined(); + const sameAddress = current ? current.equals(next) : next === undefined; + return this.name.equals(values.name) && sameAddress + ? Result.ok(this) + : Result.ok(new Site({ ...values, companyId: this.companyId }, this.id)); + } +} diff --git a/modules/companies/src/api/domain/value-objects/phone-number.ts b/modules/companies/src/api/domain/value-objects/phone-number.ts new file mode 100644 index 0000000..172819a --- /dev/null +++ b/modules/companies/src/api/domain/value-objects/phone-number.ts @@ -0,0 +1,16 @@ +import { ValueObject } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +export class PhoneNumber extends ValueObject { + static create(value: string): Result { + const normalized = value.trim(); + if ( + normalized.length > 50 || + !/^\+?[0-9 ().-]{3,50}(?: ?(?:x|ext\.?) ?[0-9]{1,8})?$/.test(normalized) || + !/[0-9]/.test(normalized) + ) { + return Result.fail(new Error("Teléfono inválido.")); + } + return Result.ok(new PhoneNumber(normalized)); + } +} diff --git a/modules/companies/src/api/domain/value-objects/site-address.ts b/modules/companies/src/api/domain/value-objects/site-address.ts new file mode 100644 index 0000000..63d4d3d --- /dev/null +++ b/modules/companies/src/api/domain/value-objects/site-address.ts @@ -0,0 +1,11 @@ +import { ValueObject } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +export class SiteAddress extends ValueObject { + static create(value: string): Result { + const normalized = value.trim(); + return normalized && normalized.length <= 10000 + ? Result.ok(new SiteAddress(normalized)) + : Result.fail(new Error("Dirección inválida.")); + } +} diff --git a/modules/companies/src/api/infrastructure/http/company.routes.ts b/modules/companies/src/api/infrastructure/http/company.routes.ts new file mode 100644 index 0000000..638f6dc --- /dev/null +++ b/modules/companies/src/api/infrastructure/http/company.routes.ts @@ -0,0 +1,106 @@ +import { randomUUID } from "node:crypto"; + +import type { Result } from "@repo/rdx-utils"; +import type { Application, RequestHandler, Response } from "express"; +import { Router } from "express"; +import { z } from "zod/v4"; + +import { + ChildParamsSchema, + CompanyParamsSchema, + CompanyRequestSchema, + ContactRequestSchema, + SiteRequestSchema, +} from "../../../common"; +import type { CompanyUseCases } from "../../application/company.use-cases"; +import { + buildCompanyResponse, + buildCompanySummary, +} from "../../application/company-response.builder"; +import type { Company } from "../../domain/company.aggregate"; +import type { CompanyError } from "../../domain/company-error"; + +function sendError(response: Response, code: string, status: number): void { + response + .status(status) + .json({ code, requestId: response.getHeader("x-request-id")?.toString() ?? randomUUID() }); +} +function sendResult(response: Response, result: Result, status = 200): void { + if (!result.isSuccess) { + sendError(response, result.error.code, result.error.code === "INVALID_REQUEST" ? 400 : 404); + return; + } + response.status(status).json({ company: buildCompanyResponse(result.data) }); +} +export function registerCompanyRoutes( + app: Application, + useCases: CompanyUseCases, + authenticate: RequestHandler +): void { + const router = Router(); + router.get("/", (request, response, next) => { + if (!z.strictObject({}).safeParse(request.query).success) { + sendError(response, "INVALID_REQUEST", 400); + return; + } + useCases + .list() + .then((companies) => response.json({ companies: companies.map(buildCompanySummary) })) + .catch(next); + }); + router.get("/:companyId", (request, response, next) => { + const parsed = CompanyParamsSchema.safeParse(request.params); + if (!parsed.success) { + sendError(response, "INVALID_REQUEST", 400); + return; + } + useCases + .get(parsed.data.companyId) + .then((result) => sendResult(response, result)) + .catch(next); + }); + function post( + path: string, + body: z.ZodType, + params: z.ZodType

, + action: (dto: T, identifiers: P) => Promise>, + status = 200 + ): void { + router.post(path, (request, response, next) => { + const dto = body.safeParse(request.body); + const identifiers = params.safeParse(request.params); + if (!(dto.success && identifiers.success)) { + sendError(response, "INVALID_REQUEST", 400); + return; + } + action(dto.data, identifiers.data) + .then((result) => sendResult(response, result, status)) + .catch(next); + }); + } + post("/", CompanyRequestSchema, z.strictObject({}), (dto) => useCases.create(dto), 201); + post("/:companyId/update", CompanyRequestSchema, CompanyParamsSchema, (dto, params) => + useCases.update(params.companyId, dto) + ); + post( + "/:companyId/contacts", + ContactRequestSchema, + CompanyParamsSchema, + (dto, params) => useCases.addContact(params.companyId, dto), + 201 + ); + post("/:companyId/contacts/:id/update", ContactRequestSchema, ChildParamsSchema, (dto, params) => + useCases.updateContact(params.companyId, params.id, dto) + ); + post( + "/:companyId/sites", + SiteRequestSchema, + CompanyParamsSchema, + (dto, params) => useCases.addSite(params.companyId, dto), + 201 + ); + post("/:companyId/sites/:id/update", SiteRequestSchema, ChildParamsSchema, (dto, params) => + useCases.updateSite(params.companyId, params.id, dto) + ); + app.use("/api/companies", authenticate, router); +} diff --git a/modules/companies/src/api/infrastructure/persistence/company-persistence.mapper.ts b/modules/companies/src/api/infrastructure/persistence/company-persistence.mapper.ts new file mode 100644 index 0000000..5eeade7 --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/company-persistence.mapper.ts @@ -0,0 +1,73 @@ +import type { Result as DomainResult } from "@repo/rdx-utils"; +import type { InferAttributes } from "sequelize"; + +import { CompanyInputMapper } from "../../application/company-input.mapper"; +import { Company } from "../../domain/company.aggregate"; +import { Contact } from "../../domain/contact.entity"; +import { Site } from "../../domain/site.entity"; + +import type { CompanyModel, ContactModel, SiteModel } from "./company.model"; + +export type CompanyRecord = { + company: InferAttributes; + contacts: InferAttributes[]; + sites: InferAttributes[]; +}; +function persisted(result: DomainResult): T { + if (!result.isSuccess) { + throw new Error("Persistencia de compañía inconsistente."); + } + return result.data; +} +export class CompanyPersistenceMapper { + private readonly input = new CompanyInputMapper(); + toDomain(record: CompanyRecord): Company { + const id = persisted(this.input.id(record.company.id)); + const props = persisted(this.input.company(record.company)); + const contacts = record.contacts.map((row) => { + const companyId = persisted(this.input.id(row.companyId)); + if (!companyId.equals(id)) { + throw new Error("Persistencia de contacto inconsistente."); + } + return Contact.rehydrate( + { ...persisted(this.input.contact(row)), companyId }, + persisted(this.input.id(row.id)) + ); + }); + const sites = record.sites.map((row) => { + const companyId = persisted(this.input.id(row.companyId)); + if (!companyId.equals(id)) { + throw new Error("Persistencia de sede inconsistente."); + } + return Site.rehydrate( + { ...persisted(this.input.site(row)), companyId }, + persisted(this.input.id(row.id)) + ); + }); + return Company.rehydrate({ ...props, contacts, sites }, id); + } + toPersistence(company: Company): CompanyRecord { + return { + company: { + id: company.id.toPrimitive(), + name: company.name.toPrimitive(), + active: company.active, + }, + contacts: company.contacts.map((contact) => ({ + id: contact.id.toPrimitive(), + companyId: contact.companyId.toPrimitive(), + name: contact.name.toPrimitive(), + position: contact.position.toPrimitive(), + email: contact.email.toPrimitive(), + phone: contact.phone.toPrimitive(), + active: contact.active, + })), + sites: company.sites.map((site) => ({ + id: site.id.toPrimitive(), + companyId: site.companyId.toPrimitive(), + name: site.name.toPrimitive(), + address: site.address.getOrUndefined()?.toPrimitive() ?? null, + })), + }; + } +} diff --git a/modules/companies/src/api/infrastructure/persistence/company.model.ts b/modules/companies/src/api/infrastructure/persistence/company.model.ts new file mode 100644 index 0000000..98e2008 --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/company.model.ts @@ -0,0 +1,61 @@ +import type { InferAttributes, InferCreationAttributes, Sequelize } from "sequelize"; +import { DataTypes, Model } from "sequelize"; + +export class CompanyModel extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: string; + declare name: string; + declare active: boolean; +} +export class ContactModel extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: string; + declare companyId: string; + declare name: string; + declare position: string; + declare email: string; + declare phone: string; + declare active: boolean; +} +export class SiteModel extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: string; + declare companyId: string; + declare name: string; + declare address: string | null; +} +export function initializeCompanyModels(database: Sequelize): void { + CompanyModel.init( + { + id: { type: DataTypes.UUID, primaryKey: true }, + name: { type: DataTypes.STRING(100), allowNull: false }, + active: { type: DataTypes.BOOLEAN, allowNull: false }, + }, + { sequelize: database, tableName: "companies", timestamps: false } + ); + const childFields = { + id: { type: DataTypes.UUID, primaryKey: true }, + companyId: { type: DataTypes.UUID, allowNull: false }, + name: { type: DataTypes.STRING(100), allowNull: false }, + }; + ContactModel.init( + { + ...childFields, + position: { type: DataTypes.STRING(100), allowNull: false }, + email: { type: DataTypes.STRING(254), allowNull: false }, + phone: { type: DataTypes.STRING(50), allowNull: false }, + active: { type: DataTypes.BOOLEAN, allowNull: false }, + }, + { sequelize: database, tableName: "company_contacts", timestamps: false } + ); + SiteModel.init( + { ...childFields, address: { type: DataTypes.TEXT, allowNull: true } }, + { sequelize: database, tableName: "company_sites", timestamps: false } + ); +} diff --git a/modules/companies/src/api/infrastructure/persistence/migrate-companies.ts b/modules/companies/src/api/infrastructure/persistence/migrate-companies.ts new file mode 100644 index 0000000..5147713 --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/migrate-companies.ts @@ -0,0 +1,43 @@ +import type { Sequelize } from "sequelize"; +import { DataTypes, QueryTypes } from "sequelize"; + +export async function migrateCompanies(database: Sequelize): Promise { + const query = database.getQueryInterface(); + const rows = await database.query<{ name: string }>( + "SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE()", + { type: QueryTypes.SELECT } + ); + const tables = rows.map((row) => row.name); + if (!tables.includes("companies")) { + await query.createTable("companies", { + id: { type: DataTypes.UUID, primaryKey: true }, + name: { type: DataTypes.STRING(100), allowNull: false }, + active: { type: DataTypes.BOOLEAN, allowNull: false }, + }); + } + const childFields = { + id: { type: DataTypes.UUID, primaryKey: true }, + companyId: { + type: DataTypes.UUID, + allowNull: false, + references: { model: "companies", key: "id" }, + onDelete: "RESTRICT", + }, + name: { type: DataTypes.STRING(100), allowNull: false }, + }; + if (!tables.includes("company_contacts")) { + await query.createTable("company_contacts", { + ...childFields, + position: { type: DataTypes.STRING(100), allowNull: false }, + email: { type: DataTypes.STRING(254), allowNull: false }, + phone: { type: DataTypes.STRING(50), allowNull: false }, + active: { type: DataTypes.BOOLEAN, allowNull: false }, + }); + } + if (!tables.includes("company_sites")) { + await query.createTable("company_sites", { + ...childFields, + address: { type: DataTypes.TEXT, allowNull: true }, + }); + } +} diff --git a/modules/companies/src/api/infrastructure/persistence/sequelize-company.repository.ts b/modules/companies/src/api/infrastructure/persistence/sequelize-company.repository.ts new file mode 100644 index 0000000..8506dc2 --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/sequelize-company.repository.ts @@ -0,0 +1,105 @@ +import type { UniqueID } from "@repo/rdx-ddd"; +import type { Result as DomainResult } from "@repo/rdx-utils"; +import { Maybe, Result } from "@repo/rdx-utils"; +import type { Sequelize, Transaction } from "sequelize"; +import { Op } from "sequelize"; + +import type { ICompanyRepository } from "../../application/company.repository"; +import type { Company } from "../../domain/company.aggregate"; +import { CompanyError } from "../../domain/company-error"; + +import { CompanyModel, ContactModel, SiteModel } from "./company.model"; +import type { CompanyPersistenceMapper } from "./company-persistence.mapper"; + +export class SequelizeCompanyRepository implements ICompanyRepository { + constructor( + private readonly database: Sequelize, + private readonly mapper: CompanyPersistenceMapper + ) {} + async list(): Promise { + const companies = await CompanyModel.findAll({ + order: [ + ["name", "ASC"], + ["id", "ASC"], + ], + }); + if (companies.length === 0) { + return []; + } + const ids = companies.map((company) => company.id); + const [contacts, sites] = await Promise.all([ + ContactModel.findAll({ + where: { companyId: { [Op.in]: ids } }, + order: [ + ["name", "ASC"], + ["id", "ASC"], + ], + }), + SiteModel.findAll({ + where: { companyId: { [Op.in]: ids } }, + order: [ + ["name", "ASC"], + ["id", "ASC"], + ], + }), + ]); + return companies.map((company) => + this.mapper.toDomain({ + company, + contacts: contacts.filter((contact) => contact.companyId === company.id), + sites: sites.filter((site) => site.companyId === company.id), + }) + ); + } + private async load(id: UniqueID, transaction?: Transaction): Promise> { + const options = transaction ? { transaction, lock: transaction.LOCK.UPDATE } : {}; + const company = await CompanyModel.findByPk(id.toPrimitive(), options); + if (!company) { + return Maybe.none(); + } + const childOptions = { + where: { companyId: company.id }, + ...(transaction ? { transaction } : {}), + }; + const contacts = await ContactModel.findAll({ ...childOptions, order: [["name", "ASC"]] }); + const sites = await SiteModel.findAll({ ...childOptions, order: [["name", "ASC"]] }); + return Maybe.some(this.mapper.toDomain({ company, contacts, sites })); + } + findById(id: UniqueID) { + return this.load(id); + } + async create(company: Company): Promise { + await CompanyModel.create(this.mapper.toPersistence(company).company); + return company; + } + async update( + id: UniqueID, + change: (company: Company) => DomainResult + ): Promise> { + return this.database.transaction(async (transaction) => { + const current = (await this.load(id, transaction)).getOrUndefined(); + if (!current) { + return Result.fail(new CompanyError("COMPANY_NOT_FOUND")); + } + const changed = change(current); + if (!changed.isSuccess || changed.data === current) { + return changed; + } + const record = this.mapper.toPersistence(changed.data); + await CompanyModel.update(record.company, { where: { id: id.toPrimitive() }, transaction }); + if (record.contacts.length > 0) { + await ContactModel.bulkCreate(record.contacts, { + transaction, + updateOnDuplicate: ["name", "position", "email", "phone", "active"], + }); + } + if (record.sites.length > 0) { + await SiteModel.bulkCreate(record.sites, { + transaction, + updateOnDuplicate: ["name", "address"], + }); + } + return changed; + }); + } +} diff --git a/modules/companies/src/api/module.ts b/modules/companies/src/api/module.ts new file mode 100644 index 0000000..0d2185b --- /dev/null +++ b/modules/companies/src/api/module.ts @@ -0,0 +1,23 @@ +import type { RequestHandler } from "express"; +import type { Sequelize } from "sequelize"; + +import { CompanyUseCases } from "./application/company.use-cases"; +import { CompanyInputMapper } from "./application/company-input.mapper"; +import { registerCompanyRoutes } from "./infrastructure/http/company.routes"; +import { initializeCompanyModels } from "./infrastructure/persistence/company.model"; +import { CompanyPersistenceMapper } from "./infrastructure/persistence/company-persistence.mapper"; +import { migrateCompanies } from "./infrastructure/persistence/migrate-companies"; +import { SequelizeCompanyRepository } from "./infrastructure/persistence/sequelize-company.repository"; + +export async function createCompanyModule(database: Sequelize, authenticate: RequestHandler) { + await migrateCompanies(database); + initializeCompanyModels(database); + const useCases = new CompanyUseCases( + new SequelizeCompanyRepository(database, new CompanyPersistenceMapper()), + new CompanyInputMapper() + ); + return { + register: (app: Parameters[0]) => + registerCompanyRoutes(app, useCases, authenticate), + }; +} diff --git a/modules/companies/src/common/dto/request/company.request.dto.ts b/modules/companies/src/common/dto/request/company.request.dto.ts new file mode 100644 index 0000000..705753b --- /dev/null +++ b/modules/companies/src/common/dto/request/company.request.dto.ts @@ -0,0 +1,13 @@ +import { z } from "zod/v4"; + +import { CompanyFieldsSchema, ContactFieldsSchema, SiteFieldsSchema } from "../shared/company.dto"; +export const CompanyRequestSchema = CompanyFieldsSchema; +export type CompanyRequestDTO = z.infer; +export const ContactRequestSchema = ContactFieldsSchema; +export type ContactRequestDTO = z.infer; +export const SiteRequestSchema = SiteFieldsSchema.extend({ + address: SiteFieldsSchema.shape.address.optional(), +}); +export type SiteRequestDTO = z.infer; +export const CompanyParamsSchema = z.strictObject({ companyId: z.uuid() }); +export const ChildParamsSchema = CompanyParamsSchema.extend({ id: z.uuid() }); diff --git a/modules/companies/src/common/dto/response/company.response.dto.ts b/modules/companies/src/common/dto/response/company.response.dto.ts new file mode 100644 index 0000000..96c0a4f --- /dev/null +++ b/modules/companies/src/common/dto/response/company.response.dto.ts @@ -0,0 +1,17 @@ +import { z } from "zod/v4"; + +import { CompanySchema, CompanySummarySchema } from "../shared/company.dto"; +export const CompaniesResponseSchema = z.strictObject({ companies: z.array(CompanySummarySchema) }); +export const CompanyResponseSchema = z.strictObject({ company: CompanySchema }); +export const CompanyErrorResponseSchema = z.strictObject({ + code: z.enum([ + "INVALID_REQUEST", + "COMPANY_NOT_FOUND", + "CONTACT_NOT_FOUND", + "SITE_NOT_FOUND", + "UNAUTHENTICATED", + "FORBIDDEN", + "INTERNAL_ERROR", + ]), + requestId: z.uuid(), +}); diff --git a/modules/companies/src/common/dto/shared/company.dto.ts b/modules/companies/src/common/dto/shared/company.dto.ts new file mode 100644 index 0000000..8ba2160 --- /dev/null +++ b/modules/companies/src/common/dto/shared/company.dto.ts @@ -0,0 +1,33 @@ +import { z } from "zod/v4"; + +const RequiredTextSchema = z + .string() + .min(1) + .max(100) + .refine((value) => value.trim().length > 0); +export const CompanyFieldsSchema = z.strictObject({ + name: RequiredTextSchema, + active: z.boolean(), +}); +export const ContactFieldsSchema = z.strictObject({ + name: RequiredTextSchema, + position: RequiredTextSchema, + email: z.email().max(254), + phone: z.string().min(3).max(50), + active: z.boolean(), +}); +export const SiteFieldsSchema = z.strictObject({ + name: RequiredTextSchema, + address: z.string().max(10000).nullable(), +}); +export const CompanySummarySchema = CompanyFieldsSchema.extend({ id: z.uuid() }); +export type CompanySummaryDTO = z.infer; +export const ContactSchema = ContactFieldsSchema.extend({ id: z.uuid(), companyId: z.uuid() }); +export type ContactDTO = z.infer; +export const SiteSchema = SiteFieldsSchema.extend({ id: z.uuid(), companyId: z.uuid() }); +export type SiteDTO = z.infer; +export const CompanySchema = CompanySummarySchema.extend({ + contacts: z.array(ContactSchema), + sites: z.array(SiteSchema), +}); +export type CompanyDTO = z.infer; diff --git a/modules/companies/src/common/index.ts b/modules/companies/src/common/index.ts new file mode 100644 index 0000000..39be667 --- /dev/null +++ b/modules/companies/src/common/index.ts @@ -0,0 +1,3 @@ +export * from "./dto/request/company.request.dto"; +export * from "./dto/response/company.response.dto"; +export * from "./dto/shared/company.dto"; diff --git a/modules/companies/src/web/api/company-api.ts b/modules/companies/src/web/api/company-api.ts new file mode 100644 index 0000000..578b8de --- /dev/null +++ b/modules/companies/src/web/api/company-api.ts @@ -0,0 +1,60 @@ +import type { z } from "zod/v4"; + +import { + CompaniesResponseSchema, + CompanyErrorResponseSchema, + CompanyResponseSchema, +} from "../../common"; + +export const companyKeys = { + all: ["companies"], + list: ["companies", "list"], + detail: (id: string) => ["companies", "detail", id], +}; +export class CompanyApiError extends Error { + constructor(public readonly code: string) { + super(code); + } +} +export async function companyRequest( + path: string, + schema: z.ZodType, + body?: unknown, + csrfToken?: string +): Promise { + const response = await fetch(`/api/companies${path}`, { + method: body === undefined ? "GET" : "POST", + credentials: "same-origin", + headers: + body === undefined + ? {} + : { "content-type": "application/json", "x-csrf-token": csrfToken ?? "" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + const json: unknown = await response.json(); + if (!response.ok) { + const error = CompanyErrorResponseSchema.safeParse(json); + throw new CompanyApiError(error.success ? error.data.code : "INTERNAL_ERROR"); + } + return schema.parse(json); +} +export const companyApi = { + list: () => companyRequest("", CompaniesResponseSchema), + get: (id: string) => companyRequest(`/${id}`, CompanyResponseSchema), + save: (id: string | undefined, body: unknown, token: string) => + companyRequest(id ? `/${id}/update` : "", CompanyResponseSchema, body, token), + contact: (companyId: string, id: string | undefined, body: unknown, token: string) => + companyRequest( + `/${companyId}/contacts${id ? `/${id}/update` : ""}`, + CompanyResponseSchema, + body, + token + ), + site: (companyId: string, id: string | undefined, body: unknown, token: string) => + companyRequest( + `/${companyId}/sites${id ? `/${id}/update` : ""}`, + CompanyResponseSchema, + body, + token + ), +}; diff --git a/modules/companies/src/web/components/companies-page.test.tsx b/modules/companies/src/web/components/companies-page.test.tsx new file mode 100644 index 0000000..b115c93 --- /dev/null +++ b/modules/companies/src/web/components/companies-page.test.tsx @@ -0,0 +1,104 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { createInstance } from "i18next"; +import type { ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { I18nextProvider } from "react-i18next"; + +import { companyKeys } from "../api/company-api"; +import { companiesEnglish, companiesSpanish } from "../i18n"; + +import { CompaniesPage } from "./companies-page"; +import { CompanyDetail } from "./company-detail"; +import { SiteForm } from "./site-form"; + +const companyId = "e0420eb9-2aab-4368-96a0-2ce83dd23eb4"; +async function renderPage( + language: string, + children: ReactNode, + seed: (client: QueryClient) => void +) { + const i18n = createInstance(); + await i18n.init({ + lng: language, + fallbackLng: "es", + resources: { es: { companies: companiesSpanish }, en: { companies: companiesEnglish } }, + interpolation: { escapeValue: false }, + }); + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Number.POSITIVE_INFINITY } }, + }); + seed(client); + try { + return renderToStaticMarkup( + + {children} + + ); + } finally { + client.clear(); + } +} +test("el listado presenta estado vacío y acceso al alta sin formularios visibles", async () => { + for (const [language, title, empty] of [ + ["es", "Compañías", "Todavía no hay compañías"], + ["en", "Companies", "No companies yet"], + ] as const) { + const markup = await renderPage(language, , (client) => + client.setQueryData(companyKeys.list, { companies: [] }) + ); + assert.ok(markup.includes(title)); + assert.ok(markup.includes(empty)); + assert.ok(markup.includes(language === "es" ? "Nueva compañía" : "New company")); + assert.equal(markup.includes(" { + const markup = await renderPage( + "es", + undefined} />, + (client) => + client.setQueryData(companyKeys.detail(companyId), { + company: { + id: companyId, + name: "Empresa test", + active: true, + contacts: [ + { + id: companyId, + companyId, + name: "Ana", + position: "Responsable IT", + email: "ana@example.test", + phone: "900123456", + active: false, + }, + ], + sites: [], + }, + }) + ); + assert.ok(markup.includes("Empresa test")); + assert.ok(markup.includes("Responsable IT")); + assert.ok(markup.includes("ana@example.test")); + assert.ok(markup.includes("Sedes (0)")); + assert.ok(markup.includes("Nuevo contacto")); + assert.ok(markup.includes("Editar compañía")); + assert.equal(markup.includes(" { + const markup = await renderPage( + "es", + , + () => undefined + ); + assert.ok(markup.includes("Dirección (opcional)")); + assert.match(markup, /]*rows="6"/); + assert.match(markup, /]*name="address"/); + const textarea = markup.match(/]*>/)?.[0]; + assert.ok(textarea); + assert.equal(textarea.includes("required"), false); +}); diff --git a/modules/companies/src/web/components/companies-page.tsx b/modules/companies/src/web/components/companies-page.tsx new file mode 100644 index 0000000..e0c488b --- /dev/null +++ b/modules/companies/src/web/components/companies-page.tsx @@ -0,0 +1,143 @@ +import { Button } from "@repo/shadcn-ui/components/button"; +import { Card, CardContent } from "@repo/shadcn-ui/components/card"; +import { FormDialog } from "@repo/shadcn-ui/components/form-dialog"; +import { Input } from "@repo/shadcn-ui/components/input"; +import { Label } from "@repo/shadcn-ui/components/label"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@repo/shadcn-ui/components/table"; +import { useQuery } from "@tanstack/react-query"; +import type { ColumnDef } from "@tanstack/react-table"; +import { getCoreRowModel, getFilteredRowModel, useReactTable } from "@tanstack/react-table"; +import { useId, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import type { CompanySummaryDTO } from "../../common"; +import { companyApi, companyKeys } from "../api/company-api"; + +import { CompanyDetail } from "./company-detail"; +import { CompanyFeedback } from "./company-feedback"; +import { CompanyForm } from "./company-form"; + +const EMPTY_COMPANIES: CompanySummaryDTO[] = []; +const COMPANY_COLUMNS: ColumnDef[] = [ + { accessorKey: "name" }, + { accessorKey: "active", enableGlobalFilter: false }, +]; + +export function CompaniesPage({ csrfToken }: { csrfToken: string }) { + const { t } = useTranslation("companies"); + const query = useQuery({ queryKey: companyKeys.list, queryFn: companyApi.list }); + const [selectedId, setSelectedId] = useState(); + const [createOpen, setCreateOpen] = useState(false); + const [search, setSearch] = useState(""); + const id = useId(); + const table = useReactTable({ + data: query.data?.companies ?? EMPTY_COMPANIES, + columns: COMPANY_COLUMNS, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + state: { globalFilter: search.trim() }, + globalFilterFn: "includesString", + }); + if (selectedId) { + return ( + setSelectedId(undefined)} + /> + ); + } + const companies = table.getRowModel().rows.map((row) => row.original); + return ( +

+
+
+

{t("title")}

+

{t("help")}

+
+ +
+
+ + setSearch(event.target.value)} + placeholder={t("searchPlaceholder")} + value={search} + /> +
+ {query.isPending &&

{t("loading")}

} + + {query.isError && ( + + )} + {query.data && ( + + + {companies.length === 0 ? ( +

{t(search ? "noResults" : "empty")}

+ ) : ( + + + + {t("name")} + {t("status")} + {t("actions")} + + + + {companies.map((company) => ( + + {company.name} + {t(company.active ? "active" : "inactive")} + + + + + ))} + +
+ )} +
+
+ )} + + { + setCreateOpen(false); + setSelectedId(companyId); + }} + showTitle={false} + /> + +
+ ); +} diff --git a/modules/companies/src/web/components/company-detail.tsx b/modules/companies/src/web/components/company-detail.tsx new file mode 100644 index 0000000..43fe8ca --- /dev/null +++ b/modules/companies/src/web/components/company-detail.tsx @@ -0,0 +1,262 @@ +import { Button } from "@repo/shadcn-ui/components/button"; +import { Card, CardContent } from "@repo/shadcn-ui/components/card"; +import { FormDialog } from "@repo/shadcn-ui/components/form-dialog"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@repo/shadcn-ui/components/table"; +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import type { CompanyDTO } from "../../common"; +import { companyApi, companyKeys } from "../api/company-api"; + +import { CompanyFeedback } from "./company-feedback"; +import { CompanyForm } from "./company-form"; +import { ContactForm } from "./contact-form"; +import { SiteForm } from "./site-form"; + +function ContactSection({ company, csrfToken }: { company: CompanyDTO; csrfToken: string }) { + const { t } = useTranslation("companies"); + const [createOpen, setCreateOpen] = useState(false); + const [selectedId, setSelectedId] = useState(); + const selected = company.contacts.find((contact) => contact.id === selectedId); + return ( +
+ + +
+

{t("contacts")}

+ +
+ {company.contacts.length === 0 ? ( +

{t("noContacts")}

+ ) : ( + + + + {t("name")} + {t("position")} + {t("email")} + {t("phone")} + {t("status")} + {t("actions")} + + + + {company.contacts.map((contact) => ( + + {contact.name} + {contact.position} + {contact.email} + {contact.phone} + {t(contact.active ? "active" : "inactive")} + + + + + ))} + +
+ )} +
+
+ + setCreateOpen(false)} + showTitle={false} + /> + + { + if (!open) { + setSelectedId(undefined); + } + }} + open={selected !== undefined} + title={t("editContact")} + > + {selected && ( + setSelectedId(undefined)} + showTitle={false} + /> + )} + +
+ ); +} +function SiteSection({ company, csrfToken }: { company: CompanyDTO; csrfToken: string }) { + const { t } = useTranslation("companies"); + const [createOpen, setCreateOpen] = useState(false); + const [selectedId, setSelectedId] = useState(); + const selected = company.sites.find((site) => site.id === selectedId); + return ( +
+ + +
+

{t("sites")}

+ +
+ {company.sites.length === 0 ? ( +

{t("noSites")}

+ ) : ( +
+ {company.sites.map((site) => ( +
+
+

{site.name}

+

+ {site.address ?? t("noAddress")} +

+
+ +
+ ))} +
+ )} +
+
+ + setCreateOpen(false)} + showTitle={false} + /> + + { + if (!open) { + setSelectedId(undefined); + } + }} + open={selected !== undefined} + title={t("editSite")} + > + {selected && ( + setSelectedId(undefined)} + showTitle={false} + site={selected} + /> + )} + +
+ ); +} +export function CompanyDetail({ + id, + csrfToken, + onBack, +}: { + id: string; + csrfToken: string; + onBack: () => void; +}) { + const { t } = useTranslation("companies"); + const query = useQuery({ queryKey: companyKeys.detail(id), queryFn: () => companyApi.get(id) }); + const [editOpen, setEditOpen] = useState(false); + const [tab, setTab] = useState<"contacts" | "sites">("contacts"); + const company = query.data?.company; + return ( +
+ + {query.isPending &&

{t("loading")}

} + + {query.isError && ( + + )} + {company && ( + <> +
+
+

{company.name}

+

+ {t(company.active ? "activeCompany" : "inactiveCompany")} +

+
+ +
+ + setEditOpen(false)} + showTitle={false} + /> + + + {tab === "contacts" ? ( + + ) : ( + + )} + + )} +
+ ); +} diff --git a/modules/companies/src/web/components/company-feedback.tsx b/modules/companies/src/web/components/company-feedback.tsx new file mode 100644 index 0000000..d322df9 --- /dev/null +++ b/modules/companies/src/web/components/company-feedback.tsx @@ -0,0 +1,34 @@ +import { useTranslation } from "react-i18next"; + +import { CompanyApiError } from "../api/company-api"; + +export function CompanyFeedback({ + error, + success = false, + invalid = false, +}: { + error: Error | null; + success?: boolean; + invalid?: boolean; +}) { + const { t } = useTranslation("companies"); + return ( + <> + {invalid && ( +

+ {t("invalidForm")} +

+ )} + {error && ( +

+ {t(`errors.${error instanceof CompanyApiError ? error.code : "INTERNAL_ERROR"}`)} +

+ )} + {success && ( +

+ {t("saved")} +

+ )} + + ); +} diff --git a/modules/companies/src/web/components/company-form.tsx b/modules/companies/src/web/components/company-form.tsx new file mode 100644 index 0000000..cfb4f91 --- /dev/null +++ b/modules/companies/src/web/components/company-form.tsx @@ -0,0 +1,80 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { Button } from "@repo/shadcn-ui/components/button"; +import { Checkbox } from "@repo/shadcn-ui/components/checkbox"; +import { Input } from "@repo/shadcn-ui/components/input"; +import { Label } from "@repo/shadcn-ui/components/label"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useId } from "react"; +import { useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; + +import type { CompanyRequestDTO, CompanySummaryDTO } from "../../common"; +import { CompanyRequestSchema } from "../../common"; +import { companyApi, companyKeys } from "../api/company-api"; + +import { CompanyFeedback } from "./company-feedback"; + +export function CompanyForm({ + company, + csrfToken, + onCreated, + onSaved, + showTitle = true, +}: { + company?: CompanySummaryDTO; + csrfToken: string; + onCreated?: (id: string) => void; + onSaved?: () => void; + showTitle?: boolean; +}) { + const { t } = useTranslation("companies"); + const id = useId(); + const client = useQueryClient(); + const form = useForm({ + resolver: zodResolver(CompanyRequestSchema), + defaultValues: { name: company?.name ?? "", active: company?.active ?? true }, + }); + const mutation = useMutation({ + mutationFn: (values: CompanyRequestDTO) => companyApi.save(company?.id, values, csrfToken), + onSuccess: async (response) => { + client.setQueryData(companyKeys.detail(response.company.id), response); + await client.invalidateQueries({ queryKey: companyKeys.list }); + if (company) { + form.reset({ name: response.company.name, active: response.company.active }); + } else { + form.reset(); + onCreated?.(response.company.id); + } + onSaved?.(); + }, + }); + return ( +
mutation.mutate(values))}> + {showTitle && ( +

{t(company ? "editCompany" : "newCompany")}

+ )} +
+ + +
+
+ + form.setValue("active", checked === true, { shouldDirty: true }) + } + /> + +
+ 0} + success={mutation.isSuccess} + /> + + + ); +} diff --git a/modules/companies/src/web/components/contact-form.tsx b/modules/companies/src/web/components/contact-form.tsx new file mode 100644 index 0000000..8ace18b --- /dev/null +++ b/modules/companies/src/web/components/contact-form.tsx @@ -0,0 +1,110 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { Button } from "@repo/shadcn-ui/components/button"; +import { Checkbox } from "@repo/shadcn-ui/components/checkbox"; +import { Input } from "@repo/shadcn-ui/components/input"; +import { Label } from "@repo/shadcn-ui/components/label"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useId } from "react"; +import { useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; + +import type { ContactDTO, ContactRequestDTO } from "../../common"; +import { ContactRequestSchema } from "../../common"; +import { companyApi, companyKeys } from "../api/company-api"; + +import { CompanyFeedback } from "./company-feedback"; + +export function ContactForm({ + contact, + companyId, + csrfToken, + onSaved, + showTitle = true, +}: { + contact?: ContactDTO; + companyId: string; + csrfToken: string; + onSaved?: () => void; + showTitle?: boolean; +}) { + const { t } = useTranslation("companies"); + const id = useId(); + const client = useQueryClient(); + const form = useForm({ + resolver: zodResolver(ContactRequestSchema), + defaultValues: { + name: contact?.name ?? "", + position: contact?.position ?? "", + email: contact?.email ?? "", + phone: contact?.phone ?? "", + active: contact?.active ?? true, + }, + }); + const mutation = useMutation({ + mutationFn: (values: ContactRequestDTO) => + companyApi.contact(companyId, contact?.id, values, csrfToken), + onSuccess: (response) => { + client.setQueryData(companyKeys.detail(companyId), response); + if (!contact) { + form.reset(); + } + onSaved?.(); + }, + }); + return ( +
mutation.mutate(values))}> + {showTitle && ( +

{t(contact ? "editContact" : "newContact")}

+ )} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + form.setValue("active", checked === true, { shouldDirty: true }) + } + /> + +
+ 0} + success={mutation.isSuccess} + /> + + + ); +} diff --git a/modules/companies/src/web/components/site-form.tsx b/modules/companies/src/web/components/site-form.tsx new file mode 100644 index 0000000..afe4402 --- /dev/null +++ b/modules/companies/src/web/components/site-form.tsx @@ -0,0 +1,81 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { Button } from "@repo/shadcn-ui/components/button"; +import { Input } from "@repo/shadcn-ui/components/input"; +import { Label } from "@repo/shadcn-ui/components/label"; +import { Textarea } from "@repo/shadcn-ui/components/textarea"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useId } from "react"; +import { useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; + +import type { SiteDTO, SiteRequestDTO } from "../../common"; +import { SiteRequestSchema } from "../../common"; +import { companyApi, companyKeys } from "../api/company-api"; + +import { CompanyFeedback } from "./company-feedback"; + +export function SiteForm({ + site, + companyId, + csrfToken, + onSaved, + showTitle = true, +}: { + site?: SiteDTO; + companyId: string; + csrfToken: string; + onSaved?: () => void; + showTitle?: boolean; +}) { + const { t } = useTranslation("companies"); + const id = useId(); + const client = useQueryClient(); + const form = useForm({ + resolver: zodResolver(SiteRequestSchema), + defaultValues: { name: site?.name ?? "", address: site?.address ?? "" }, + }); + const mutation = useMutation({ + mutationFn: (values: SiteRequestDTO) => + companyApi.site( + companyId, + site?.id, + { ...values, address: values.address?.trim() ? values.address : null }, + csrfToken + ), + onSuccess: (response) => { + client.setQueryData(companyKeys.detail(companyId), response); + if (!site) { + form.reset(); + } + onSaved?.(); + }, + }); + return ( +
mutation.mutate(values))}> + {showTitle &&

{t(site ? "editSite" : "newSite")}

} +
+ + +
+
+ +