diff --git a/apps/server/package.json b/apps/server/package.json index b1fef837..0d4722e7 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -21,6 +21,7 @@ "@biomejs/biome": "1.9.4", "@repo/typescript-config": "workspace:*", "@types/bcrypt": "^5.0.2", + "@types/cors": "^2.8.19", "@types/dinero.js": "^1.9.4", "@types/express": "^4.17.21", "@types/glob": "^8.1.0", diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index d7fde656..6e8f3626 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -1,4 +1,5 @@ //import { initPackages } from "@/core/package-loader"; +import cors from "cors"; import dotenv from "dotenv"; import express, { Application } from "express"; import helmet from "helmet"; @@ -21,6 +22,27 @@ export function createApp(): Application { app.use(responseTime()); // set up the response-time middleware + // enable CORS - Cross Origin Resource Sharing + app.use( + cors({ + origin: process.env.FRONTEND_URL || "http://localhost:5173", + methods: "GET,POST,PUT,DELETE,OPTIONS", + credentials: true, + + exposedHeaders: [ + "Access-Control-Allow-Headers", + "Access-Control-Allow-Origin", + "Content-Disposition", + "Content-Type", + "Content-Length", + "X-Total-Count", + "Pagination-Count", + "Pagination-Page", + "Pagination-Limit", + ], + }) + ); + // secure apps by setting various HTTP headers app.use(helmet()); diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx index 57833a17..48732025 100644 --- a/apps/web/src/app.tsx +++ b/apps/web/src/app.tsx @@ -24,7 +24,7 @@ export const App = () => { }); const axiosInstance = createAxiosInstance({ - baseURL: import.meta.env.VITE_API_URL, + baseURL: import.meta.env.VITE_API_SERVER_URL, getAccessToken, onAuthError: () => { clearAccessToken(); @@ -32,8 +32,6 @@ export const App = () => { }, }); - console.log(axiosInstance.defaults.env); - const dataSource = createAxiosDataSource(axiosInstance); return ( diff --git a/docs/prompt-auth.md b/docs/prompt-auth.md new file mode 100644 index 00000000..3b648112 --- /dev/null +++ b/docs/prompt-auth.md @@ -0,0 +1,83 @@ +📌 CONTEXTO DEL PROYECTO: +Estoy desarrollando una API en **Node.js** con **TypeScript**, **Express.js**, **Sequelize**, y **Arquitectura Hexagonal (Ports & Adapters)** bajo los principios de **DDD (Domain-Driven Design)** y **SOLID**. +La API maneja **autenticación con PassportJS**, soporte para **JWT y LocalStrategy**, gestión segura de contraseñas con **bcrypt**, y transformación de respuestas con **DTOs (Data Transfer Objects)**. + +📌 PRINCIPIOS Y PATRONES A APLICAR: +✅ **DDD (Domain-Driven Design):** + - Los **agregados** encapsulan su estado y exponen solo lo necesario. + - Cada **agregado** tiene su propio **mapper** para conversión entre persistencia y dominio. + - **Repositorios** manejan **agregados** y deben desacoplarse de Sequelize. + +✅ **SOLID (Principios de Diseño):** + - **SRP:** Cada clase tiene una única responsabilidad. + - **OCP:** El código debe ser fácil de extender sin modificarlo. + - **LSP:** Los objetos derivados deben poder sustituir a sus clases base. + - **ISP:** Usar interfaces específicas en lugar de dependencias directas. + - **DIP:** Usar **interfaces** (`IAuthProvider`, `ITransactionManager`) en lugar de acoplarse a implementaciones concretas. + +📌 GESTIÓN DE DEPENDENCIAS: +✅ **Factory Pattern (`createAuthService`)** + - `AuthService` debe crearse con un **Factory** (`createAuthService()`), permitiendo cambiar su implementación sin afectar la API. + - `AuthService` depende de `IAuthenticatedUserRepository`, `ITransactionManager` y `IAuthProvider`. + +📌 AUTENTICACIÓN CON `PassportJS`: +✅ **Soporte para JWT y LocalStrategy** + - `PassportAuthProvider` maneja **JWTStrategy** para autenticación con tokens y **LocalStrategy** para validación con email y contraseña. + - Se debe usar `bcrypt` para cifrar contraseñas antes de almacenarlas. + - `validateUser()` en `PassportAuthProvider` verifica credenciales con `LocalStrategy`. + +📌 GESTIÓN SEGURA DE CONTRASEÑAS: +✅ **Uso de `bcrypt` para cifrado de contraseñas** + - `PasswordHash.create()` valida la fuerza de la contraseña antes de cifrarla. + - `bcrypt.compare()` se usa para validar la contraseña al hacer login. + - Las contraseñas **nunca deben almacenarse en texto plano en la base de datos**. + +📌 GESTIÓN DE ERRORES: +✅ **Uso de `ApiError` para respuestas de error estructuradas** + - `ExpressController` debe devolver errores con `ApiError` (`status`, `title`, `detail`, `timestamp`). + - `RegisterController` y `LoginController` deben usar `ApiError` en caso de errores (`409 Conflict`, `401 Unauthorized`, `500 Internal Server Error`). + - Se deben capturar errores de **Sequelize** en `BaseRepository` (`UniqueConstraintError`, `ConnectionError`, `TimeoutError`). + - **Errores de unicidad (`SequelizeUniqueConstraintError`) deben ser personalizados según el contexto usando `errorMapper` en los repositorios.** + +📌 FLUJO DE LA API `/register`: +1️⃣ **Request llega a `RegisterController`**. +2️⃣ **Valida `email`, `username`, `password` como Value Objects**. +3️⃣ **Llama a `AuthService.registerUser()`**. +4️⃣ **`AuthService` usa `validateUser()` para evitar usuarios duplicados**. +5️⃣ **Si el usuario es nuevo, se cifra la contraseña con `bcrypt.hash()`**. +6️⃣ **Se almacena en la BD con `AuthenticatedUserRepository.create()`**. +7️⃣ **Se generan `accessToken` y `refreshToken` con `PassportAuthProvider`**. +8️⃣ **Se responde con los tokens y el `userId`**. + +📌 FLUJO DE LA API `/login`: +1️⃣ **Request llega a `LoginController`**. +2️⃣ **Llama a `AuthService.loginUser()` con `email` y `password`**. +3️⃣ **`AuthService` busca el usuario en `AuthenticatedUserRepository.findUserByEmail()`**. +4️⃣ **Si el usuario existe, compara contraseñas con `bcrypt.compare()`**. +5️⃣ **Si la contraseña es correcta, genera `accessToken` y `refreshToken`**. +6️⃣ **Devuelve los tokens en la respuesta**. + +📌 TRANSFORMACIÓN DE RESPUESTAS: +✅ **Los Controladores son responsables de la transformación de datos antes de enviarlos.** +✅ **Se usan `Presenters` (`AuthResponsePresenter`) para mapear datos del dominio a DTOs (`AuthResponseDTO`).** +✅ **Se inyectan `Presenters` en los controladores para desacoplar la lógica de transformación.** + +📌 SEGURIDAD Y MEJORES PRÁCTICAS: +✅ **Usar `TransactionManager.complete()` para manejar transacciones.** +✅ **Evitar acoplamientos directos entre servicios y repositorios.** +✅ **Usar `bcrypt` con `SALT_ROUNDS=12` configurables en `.env`.** +✅ **Si ocurre un error en la BD, `rollback()` debe ejecutarse automáticamente.** +✅ **Devolver códigos HTTP adecuados (`401 Unauthorized`, `409 Conflict`, `500 Internal Server Error`).** + +📌 SOLICITUDES QUE PUEDO HACERTE: +- **Generar código** cumpliendo con estas reglas. +- **Revisar código y detectar violaciones de SOLID o DDD**. +- **Optimizar `AuthService` para mejorar la seguridad y escalabilidad**. +- **Implementar `/refresh` para renovar `accessTokens`**. +- **Escribir pruebas unitarias y de integración para `registerUser()` y `loginUser()`**. +- **Optimizar `AuthResponsePresenter` para soportar más formatos (`XML`, `CSV`).** + +⚠️ **IMPORTANTE:** +- **NO generes código que acople dependencias directamente**. +- **NO uses Sequelize directamente en los servicios**. +- **SIEMPRE usa interfaces (`IAuthProvider`, `ITransactionManager`) en lugar de instancias concretas**. diff --git a/docs/prompt-customer-invoices.md b/docs/prompt-customer-invoices.md new file mode 100644 index 00000000..040faa03 --- /dev/null +++ b/docs/prompt-customer-invoices.md @@ -0,0 +1,93 @@ +📌 CONTEXTO DEL PROYECTO: +Estoy desarrollando una API en **Node.js** con **TypeScript**, **Express.js**, **Sequelize**, y **Arquitectura Hexagonal (Ports & Adapters)** bajo los principios de **DDD (Domain-Driven Design)** y **SOLID**. +Este módulo es para **facturas de cliente (Customer Invoices)** y debe cumplir con los estándares definidos previamente para el contexto de autenticación, adaptados a los datos, reglas y operaciones de las facturas. + +📌 PRINCIPIOS Y PATRONES A APLICAR: +✅ **DDD (Domain-Driven Design)** + - Las **facturas** serán agregados raíz (`CustomerInvoice`) con propiedades como `id`, `customerId`, `invoiceNumber`, `date`, `lines`, `totals`, etc. + - Las **líneas de factura** (`InvoiceLine`) se modelan como entidades o value objects dentro del agregado. + - Se usará un `Mapper` para convertir entre dominio y persistencia. + - Repositorios (`ICustomerInvoiceRepository`) solo manejan agregados. + - Operaciones como `createInvoice`, `updateInvoice`, `getInvoiceById` serán gestionadas en `CustomerInvoiceService`. + +✅ **SOLID** + - Usar SRP: cada clase con una responsabilidad clara. + - Usar interfaces como `ICustomerInvoiceRepository`, `ITransactionManager`, `IInvoicePresenter`. + - Evitar acoplamientos entre capa de aplicación y capa de infraestructura. + - Controladores deben transformar los datos en DTOs antes de enviarlos. + +📌 ESTRUCTURA DE DATOS: + +La entidad `CustomerInvoice` tendrá: +- `id`: UUID (ValueObject `UniqueID`) +- `status`: string (ValueObject) (`Draft`, `Emitted`, `Sent`, `Rejected`) +- `invoiceNumber`: string (ValueObject ) +- `invoiceSeries`: string (ValueObject ) +- `customerId`: UUID (ValueObject) +- `issueDate`: UtcDate (ValueObject) +- `operationDate`: UtcDate (ValueObject) +- `lines`: array de `InvoiceLine`, cada una con: + - `productId`: (Optional) UUID (ValueObject `UniqueID`) + - `description`: string (ValueObject) + - `quantity`: Amount (number) and scale (number) (ValueObject Quantity) + - `unitPrice`: Amount (number), scale (number) and currency (string) (ValueObject MoneyValue) + - `totalLineAmount`: Amount (number), scale (number) and currency (string) (ValueObject MoneyValue) +- `subtotalPrice`: Amount (number), scale (number) and currency (string) (ValueObject MoneyValue) +- `discount`: Amount (number) and scale (number) (ValueObject Percentage) +- `tax`: Amount (number) and scale (number) (ValueObject Percentage) +- `totalAmount`: Amount (number), scale (number) and currency (string) (ValueObject MoneyValue) + + +📌 OPERACIONES PRINCIPALES: +- `createInvoice(data: CreateCustomerInvoiceDTO)` +- `getInvoiceById(id: string)` +- `updateInvoice(id: string, changes: UpdateCustomerInvoiceDTO)` +- `listInvoices(filter?)` +- `deleteInvoice(id: string)` +- `markAsPaid(id: string)` + +📌 VALIDACIONES: +✅ Usar `Zod` para validaciones en Value Objects (`InvoiceNumber`, `InvoiceDate`, `InvoiceLine`). +✅ No se deben lanzar excepciones para errores de validación. Usar `Result` como en autenticación. + +📌 PRESENTACIÓN: +✅ Los controladores convierten el agregado `CustomerInvoice` a `CustomerInvoiceDTO`. +✅ Se usan `Presenters` (`CustomerInvoicePresenter`) para construir los DTOs. +✅ DTOs solo exponen campos necesarios: nada sensible, nada interno. +✅ Se puede extender con `CsvInvoicePresenter`, `PdfInvoicePresenter`, etc. + +📌 AUTORIZACIÓN Y ACCESO: +✅ Se protegerán rutas con middleware JWT (`authenticateJWT`). +✅ Se validará que el usuario autenticado pueda acceder/modificar la factura (p. ej. pertenece a su empresa). + +📌 PERSISTENCIA: +✅ `SequelizeCustomerInvoiceRepository` no accede directamente al modelo de Sequelize, lo hace a través de `Mapper`. +✅ La clase `CustomerInvoiceMapper` mapea de/agregado <-> persistencia. +✅ Soporta transacciones Sequelize (`TransactionManager.complete(...)`). +✅ Los nombres de los campos y las tablas en la base de datos seguirán la notación "snake_case". +✅ Los nombres de los campos en los modelos Sequelize seguirán la notación "snake_case". +✅ Los modelos Sequelize de los agregados tienen campos 'timestamp' con los nombres "created_at", "updated_at" y "deleted_at". + + +📌 ERRORES: +✅ Usar `ApiError` para errores en la API. +✅ Los repositorios deben capturar errores de Sequelize (`UniqueConstraintError`, etc.) y convertirlos a errores de dominio con mensajes claros y específicos (mediante `errorMapper`). + +📌 TESTING: +✅ Los servicios (`CustomerInvoiceService`) serán testeados con mocks de repositorio. +✅ Las rutas serán testeadas con `supertest`. +✅ Las validaciones de ValueObjects tendrán pruebas unitarias. + +📌 NOMENCLATURA: +- Agregado: `CustomerInvoice` +- Línea: `CustomerInvoiceLine` +- DTOs: `CustomerInvoiceDTO`, `InvoiceLineDTO` +- Presentadores: `CustomerInvoicePresenter` +- Repositorio: `ICustomerInvoiceRepository`, `SequelizeCustomerInvoiceRepository` +- ValueObjects: `CustomerInvoiceNumber`, `CustomerInvoiceDate`, `CustomerInvoiceTotal`, `CustomerInvoiceItemQuantity`, etc. + +⚠️ IMPORTANTE: +- NO incluir `passwords`, `tokens`, o datos sensibles en respuestas. +- NO usar Sequelize directamente en servicios ni controladores. +- NO devolver el agregado como respuesta bruta. Usar DTOs. +- TODO debe estar tipado, incluso los métodos de los servicios (`Promise>`). diff --git a/modules/core/src/web/lib/data-source/axios/create-axios-data-source.ts b/modules/core/src/web/lib/data-source/axios/create-axios-data-source.ts index 0d096652..ece0b7ab 100644 --- a/modules/core/src/web/lib/data-source/axios/create-axios-data-source.ts +++ b/modules/core/src/web/lib/data-source/axios/create-axios-data-source.ts @@ -2,53 +2,78 @@ import { AxiosInstance } from "axios"; import { ICustomParams, IDataSource } from "../datasource.interface"; import { defaultAxiosRequestConfig } from "./create-axios-instance"; +/** + * Crea un DataSource basado en Axios. + * @param client Instancia de Axios que se utilizará para las peticiones HTTP. + * @returns Un objeto que implementa la interfaz IDataSource. + * @throws Error si la instancia de Axios no es válida o no tiene la configuración necesaria. + * @example + * const axiosInstance = createAxiosInstance({ + * baseURL: "https://api.example.com", + * getAccessToken: () => localStorage.getItem("accessToken"), + * onAuthError: () => { + * console.error("Error de autenticación"); + * // Manejar el error de autenticación, por ejemplo, redirigir al login + * }, + * }); + * const dataSource = createAxiosDataSource(axiosInstance); + * + */ + export const createAxiosDataSource = (client: AxiosInstance): IDataSource => { - function getBaseUrlOrFail(): string { - const baseUrl = client.getUri(); + // Validaciones de la instancia de Axios + if (!client) { + throw new Error("[Axios] Se esperaba una instancia de Axios."); + } - if (!baseUrl) { - throw new Error("[Axios] baseURL no está definido en esta instancia."); - } + if (!(client as AxiosInstance).getUri) { + throw new Error("[Axios] La instancia proporcionada no es una instancia de Axios válida."); + } - return baseUrl; + if (typeof (client as AxiosInstance).getUri !== "function") { + throw new Error("[Axios] La instancia proporcionada no tiene el método getUri."); + } + + if (typeof (client as AxiosInstance).getUri() !== "string") { + throw new Error("[Axios] baseURL no está definido en esta instancia."); } return { - getBaseUrl: getBaseUrlOrFail, + getBaseUrl: () => (client as AxiosInstance).getUri(), getList: async (resource: string, params?: Record) => { - const res = await client.get(resource, params); + const res = await (client as AxiosInstance).get(resource, params); return res.data; }, getOne: async (resource: string, id: string | number) => { - const res = await client.get(`${resource}/${id}`); + const res = await (client as AxiosInstance).get(`${resource}/${id}`); return res.data; }, getMany: async (resource: string, ids: Array) => { - const res = await client.get(`${resource}`, { params: { ids } }); + const res = await (client as AxiosInstance).get(`${resource}`, { params: { ids } }); return res.data; }, createOne: async (resource: string, data: Partial) => { - const res = await client.post(resource, data); + const res = await (client as AxiosInstance).post(resource, data); return res.data; }, updateOne: async (resource: string, id: string | number, data: Partial) => { - const res = await client.put(`${resource}/${id}`, data); + const res = await (client as AxiosInstance).put(`${resource}/${id}`, data); return res.data; }, deleteOne: async (resource: string, id: string | number) => { - await client.delete(`${resource}/${id}`); + await (client as AxiosInstance).delete(`${resource}/${id}`); }, custom: async (customParams: ICustomParams) => { const { url, path, method, responseType, headers, signal, data, ...payload } = customParams; - const requestUrl = path ? `${getBaseUrlOrFail()}/${path}` : url; + const requestUrl = path ? `${(client as AxiosInstance).getUri()}/${path}` : url; if (!requestUrl) throw new Error('"url" or "path" param is missing'); const config = { @@ -67,14 +92,14 @@ export const createAxiosDataSource = (client: AxiosInstance): IDataSource => { case "put": case "post": case "patch": - customResponse = await client.request({ + customResponse = await (client as AxiosInstance).request({ ...config, data, }); break; case "delete": - customResponse = await client.delete(requestUrl, { + customResponse = await (client as AxiosInstance).delete(requestUrl, { responseType, headers, ...payload, @@ -82,7 +107,7 @@ export const createAxiosDataSource = (client: AxiosInstance): IDataSource => { break; default: - customResponse = await client.get(requestUrl, { + customResponse = await (client as AxiosInstance).get(requestUrl, { responseType, signal, headers, diff --git a/modules/core/src/web/lib/data-source/axios/create-axios-instance.ts b/modules/core/src/web/lib/data-source/axios/create-axios-instance.ts index f3fd1cd4..7b47a58b 100644 --- a/modules/core/src/web/lib/data-source/axios/create-axios-instance.ts +++ b/modules/core/src/web/lib/data-source/axios/create-axios-instance.ts @@ -1,4 +1,4 @@ -import axios, { AxiosInstance } from "axios"; +import axios, { AxiosInstance, CreateAxiosDefaults } from "axios"; import { setupInterceptors } from "./setup-interceptors"; /** @@ -20,7 +20,7 @@ export interface AxiosFactoryConfig { onAuthError?: () => void; } -export const defaultAxiosRequestConfig = { +export const defaultAxiosRequestConfig: CreateAxiosDefaults = { headers: { Accept: "application/json", "Content-Type": "application/json; charset=utf-8", @@ -40,10 +40,11 @@ export const createAxiosInstance = ({ getAccessToken, onAuthError, }: AxiosFactoryConfig): AxiosInstance => { - const instance = axios.create({ - baseURL, - ...defaultAxiosRequestConfig, - }); + console.log({ baseURL, getAccessToken, onAuthError }); + + const instance = axios.create(defaultAxiosRequestConfig); + + instance.defaults.baseURL = baseURL; setupInterceptors(instance, getAccessToken, onAuthError); diff --git a/modules/customer-invoices/src/web/components/customer-invoices-grid.tsx b/modules/customer-invoices/src/web/components/customer-invoices-grid.tsx index a80ec0b7..fca94db7 100644 --- a/modules/customer-invoices/src/web/components/customer-invoices-grid.tsx +++ b/modules/customer-invoices/src/web/components/customer-invoices-grid.tsx @@ -87,6 +87,8 @@ export const CustomerInvoicesGrid = () => { }; }, []); + console.log(isError, error); + // Container: Defines the grid's theme & dimensions. return (
{ return useQuery({ queryKey: keys().data().resource("invoices").action("list").params(params).get(), queryFn: (context) => { - console.log(context); + console.log(dataSource.getBaseUrl()); const { signal } = context; return dataSource.getList("customer-invoices", { signal, diff --git a/modules/customer-invoices/src/web/pages/list.tsx b/modules/customer-invoices/src/web/pages/list.tsx index 13b38f14..7aff6a39 100644 --- a/modules/customer-invoices/src/web/pages/list.tsx +++ b/modules/customer-invoices/src/web/pages/list.tsx @@ -12,7 +12,7 @@ export const CustomerInvoicesList = () => { const navigate = useNavigate(); const [status, setStatus] = useState("all"); - const CustomerInvoiceStatuses = [ + /*const CustomerInvoiceStatuses = [ { value: "all", label: t("customerInvoices.list.tabs.all") }, { value: "draft", label: t("customerInvoices.list.tabs.draft") }, { value: "ready", label: t("customerInvoices.list.tabs.ready") }, @@ -20,7 +20,7 @@ export const CustomerInvoicesList = () => { { value: "accepted", label: t("customerInvoices.list.tabs.accepted") }, { value: "rejected", label: t("customerInvoices.list.tabs.rejected") }, { value: "archived", label: t("customerInvoices.list.tabs.archived") }, - ]; + ];*/ return ( <> @@ -34,7 +34,7 @@ export const CustomerInvoicesList = () => {

{t("customerInvoices.list.description")}

- diff --git a/packages/rdx-ui/src/index.ts b/packages/rdx-ui/src/index.ts index a29b9fab..49130918 100644 --- a/packages/rdx-ui/src/index.ts +++ b/packages/rdx-ui/src/index.ts @@ -1,3 +1,3 @@ "use client"; -export * from "./components"; +export * from "./components/index.tsx"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cf29c3d1..7745bb23 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -132,6 +132,9 @@ importers: '@types/bcrypt': specifier: ^5.0.2 version: 5.0.2 + '@types/cors': + specifier: ^2.8.19 + version: 2.8.19 '@types/dinero.js': specifier: ^1.9.4 version: 1.9.4 @@ -2621,6 +2624,9 @@ packages: '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/cors@2.8.19': + resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} + '@types/d3-array@3.2.1': resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==} @@ -7961,6 +7967,10 @@ snapshots: dependencies: '@types/node': 22.15.24 + '@types/cors@2.8.19': + dependencies: + '@types/node': 22.15.24 + '@types/d3-array@3.2.1': {} '@types/d3-color@3.1.3': {}