This commit is contained in:
David Arranz 2025-06-17 18:18:25 +02:00
parent 3a881c554f
commit f47a97bfa0
12 changed files with 266 additions and 31 deletions

View File

@ -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",

View File

@ -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());

View File

@ -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 (

83
docs/prompt-auth.md Normal file
View File

@ -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**.

View File

@ -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<T, E>` 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<Result<T, Error>>`).

View File

@ -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 <T>(resource: string, params?: Record<string, any>) => {
const res = await client.get<T[]>(resource, params);
const res = await (client as AxiosInstance).get<T[]>(resource, params);
return res.data;
},
getOne: async <T>(resource: string, id: string | number) => {
const res = await client.get<T>(`${resource}/${id}`);
const res = await (client as AxiosInstance).get<T>(`${resource}/${id}`);
return res.data;
},
getMany: async <T>(resource: string, ids: Array<string | number>) => {
const res = await client.get<T[]>(`${resource}`, { params: { ids } });
const res = await (client as AxiosInstance).get<T[]>(`${resource}`, { params: { ids } });
return res.data;
},
createOne: async <T>(resource: string, data: Partial<T>) => {
const res = await client.post<T>(resource, data);
const res = await (client as AxiosInstance).post<T>(resource, data);
return res.data;
},
updateOne: async <T>(resource: string, id: string | number, data: Partial<T>) => {
const res = await client.put<T>(`${resource}/${id}`, data);
const res = await (client as AxiosInstance).put<T>(`${resource}/${id}`, data);
return res.data;
},
deleteOne: async <T>(resource: string, id: string | number) => {
await client.delete(`${resource}/${id}`);
await (client as AxiosInstance).delete(`${resource}/${id}`);
},
custom: async <T>(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<T>({
customResponse = await (client as AxiosInstance).request<T>({
...config,
data,
});
break;
case "delete":
customResponse = await client.delete<T>(requestUrl, {
customResponse = await (client as AxiosInstance).delete<T>(requestUrl, {
responseType,
headers,
...payload,
@ -82,7 +107,7 @@ export const createAxiosDataSource = (client: AxiosInstance): IDataSource => {
break;
default:
customResponse = await client.get<T>(requestUrl, {
customResponse = await (client as AxiosInstance).get<T>(requestUrl, {
responseType,
signal,
headers,

View File

@ -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);

View File

@ -87,6 +87,8 @@ export const CustomerInvoicesGrid = () => {
};
}, []);
console.log(isError, error);
// Container: Defines the grid's theme & dimensions.
return (
<div

View File

@ -9,7 +9,7 @@ export const useCustomerInvoices = (params: any) => {
return useQuery<any[]>({
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,

View File

@ -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 = () => {
<p className='text-muted-foreground'>{t("customerInvoices.list.description")}</p>
</div>
<div className='flex items-center space-x-2'>
<Button onClick={() => navigate("/CustomerInvoices/add")}>
<Button onClick={() => navigate("/customer-invoices/add")}>
<PlusIcon className='w-4 h-4 mr-2' />
{t("customerInvoices.create.title")}
</Button>

View File

@ -1,3 +1,3 @@
"use client";
export * from "./components";
export * from "./components/index.tsx";

View File

@ -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': {}