architecture refactoring

This commit is contained in:
Cesar
2026-05-22 19:26:38 -06:00
parent 34dbfd051b
commit c2e53eb21b
20 changed files with 949 additions and 222 deletions

13
backend/.env.example Normal file
View File

@@ -0,0 +1,13 @@
# Server
PORT=3000
NODE_ENV=development
# Database
DATABASE_URL=postgresql://user:password@localhost:5432/optihack
# JWT
JWT_SEED=your_super_secret_jwt_seed_here_change_in_production
JWT_EXPIRES_IN=7d
# Bcrypt
BCRYPT_ROUNDS=10

209
backend/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,209 @@
# Clean Architecture - Backend OptiHack
## Estructura del Proyecto
```
src/
├── config/ # Configuración e integración con librerías
│ ├── env.ts # Variables de entorno
│ ├── jwt.ts # JwtAdapter - genera y valida tokens
│ └── bcrypt.ts # BcryptAdapter - hashea y compara contraseñas
├── domain/ # Lógica de negocio (sin dependencias externas)
│ ├── dtos/ # Data Transfer Objects con validación
│ │ ├── auth/
│ │ │ ├── register-user.dto.ts
│ │ │ └── login-user.dto.ts
│ │ └── index.ts
│ │
│ ├── repositories/ # Interfaces de repositorios (contratos)
│ │ └── auth.repository.ts
│ │
│ ├── use-cases/ # Lógica de negocio (casos de uso)
│ │ └── auth/
│ │ ├── register-user.use-case.ts
│ │ ├── login-user.use-case.ts
│ │ └── get-me.use-case.ts
│ │
│ └── errors/ # Errores personalizados
│ └── custom.error.ts
├── data/ # Implementación de datos (Prisma, BD)
│ ├── postgres/ # Cliente de Prisma
│ │ └── index.ts
│ └── repositories/ # Implementación de repositorios
│ └── auth.repository.impl.ts
├── presentation/ # HTTP (Express, controladores, middlewares)
│ ├── auth/
│ │ ├── controller.ts
│ │ └── routes.ts
│ ├── middlewares/
│ │ └── auth.middleware.ts
│ ├── routes.ts # Rutas principales
│ └── server.ts # Instancia de Express
├── app.ts # Punto de entrada
└── ...
```
## Flujo de Datos (Regla Principal)
```
HTTP Request
Controller recibe los datos
Controller llama al UseCase
UseCase hace la lógica de negocio
UseCase llama al Repository
Repository implementación habla con Prisma
Prisma ejecuta queries en la BD
Response va hacia arriba
HTTP Response
```
### Ejemplo: Register
1. **Cliente** envía POST `/api/auth/register` con `{ name, email, password }`
2. **Controller** recibe la request
3. **Controller** llama a `RegisterUserUseCase.execute()`
4. **UseCase** valida el DTO
5. **UseCase** verifica si el email existe (llama al Repository)
6. **Repository** busca en la BD
7. **UseCase** hashea la contraseña
8. **UseCase** crea el usuario (llama al Repository)
9. **Repository** inserta en la BD
10. **UseCase** genera el JWT
11. **Controller** responde con user + token
## Responsabilidades por Capa
### **Config**
- Variables de entorno
- Adapters (JWT, Bcrypt)
- Configuraciones globales
### **Domain** (Sin dependencias externas)
- DTOs con validación
- Interfaces de repositorios (contratos)
- Casos de uso (lógica de negocio)
- Errores personalizados
### **Data**
- Implementación de repositorios
- Cliente de Prisma
- Queries a la base de datos
### **Presentation**
- Controladores (Express handlers)
- Rutas
- Middlewares
- Validación HTTP
## Endpoints de Autenticación
### 1. Register
```
POST /api/auth/register
Content-Type: application/json
{
"name": "John Doe",
"email": "john@example.com",
"password": "password123"
}
Response 201:
{
"user": {
"id": 1,
"email": "john@example.com",
"name": "John Doe"
},
"token": "eyJhbGc..."
}
```
### 2. Login
```
POST /api/auth/login
Content-Type: application/json
{
"email": "john@example.com",
"password": "password123"
}
Response 200:
{
"user": {
"id": 1,
"email": "john@example.com",
"name": "John Doe"
},
"token": "eyJhbGc..."
}
```
### 3. GetMe (Protegida)
```
GET /api/auth/me
Authorization: Bearer eyJhbGc...
Response 200:
{
"id": 1,
"email": "john@example.com",
"name": "John Doe",
"role": "USER"
}
```
## Validaciones
### DTOs
- **name**: string no vacío
- **email**: email válido (regex)
- **password**: mínimo 6 caracteres
### Errores
- **400 Bad Request**: Datos inválidos
- **401 Unauthorized**: Token inválido, usuario no autenticado
- **404 Not Found**: Usuario no encontrado
- **409 Conflict**: Email ya en uso
- **500 Internal Server Error**: Error del servidor
## Variables de Entorno (.env)
```
PORT=3000 # Puerto del servidor
NODE_ENV=development # Entorno
DATABASE_URL=postgresql://user:pass@host:5432/db # URL de BD
JWT_SEED=tu_secreto_super_seguro # Seed para JWT
JWT_EXPIRES_IN=7d # Expiración del token
BCRYPT_ROUNDS=10 # Rondas de bcrypt
```
## Cómo Agregar un Nuevo UseCase
1. **Crear el UseCase** en `domain/use-cases/auth/new-feature.use-case.ts`
2. **Agregar método al Repository** en `domain/repositories/auth.repository.ts`
3. **Implementar en** `data/repositories/auth.repository.impl.ts`
4. **Crear método en Controller** en `presentation/auth/controller.ts`
5. **Agregar ruta** en `presentation/auth/routes.ts`
## Ventajas de Esta Arquitectura
**Separación de responsabilidades**: Cada capa hace una cosa
**Fácil de testear**: UseCase no conoce HTTP ni BD
**Mantenible**: Cambios en BD no afectan lógica de negocio
**Escalable**: Fácil agregar nuevos módulos
**Simple**: No over-engineered, ideal para MVP/Hackathon
**Limpio**: Código organizado y fácil de entender

202
backend/CHANGES_SUMMARY.md Normal file
View File

@@ -0,0 +1,202 @@
# Resumen de Archivos - Clean Architecture
## Archivos Modificados/Creados
### 1. **config/env.ts** ✓
- Lee y valida variables de entorno
- Centraliza PORT, DATABASE_URL, JWT_SEED, BCRYPT_ROUNDS
- Se importa en toda la app para acceder a configuración
### 2. **config/jwt.ts** ✓
- JwtAdapter para generar y validar tokens
- Métodos estáticos: `generate()` y `validate()`
- Define JwtPayload interface con id y email
- Exporta los tokens y valida automáticamente expiración
### 3. **config/bcrypt.ts** ✓
- BcryptAdapter para hashear y comparar contraseñas
- Métodos estáticos: `hash()` y `compare()`
- Usa BCRYPT_ROUNDS de env para consistencia
### 4. **domain/errors/custom.error.ts** ✓
- CustomError clase base para errores personalizados
- Métodos estáticos: badRequest(), unauthorized(), notFound(), conflict(), internalServer()
- Cada error tiene statusCode para responder correctamente
### 5. **domain/dtos/auth/register-user.dto.ts** ✓
- RegisterUserDto interfaz
- RegisterUserDtoValidator con método validate()
- Valida name (no vacío), email (formato), password (mínimo 6 caracteres)
- Normaliza email a minúsculas
### 6. **domain/dtos/auth/login-user.dto.ts** ✓
- LoginUserDto interfaz
- LoginUserDtoValidator con método validate()
- Valida email (formato) y password (no vacío)
- Normaliza email a minúsculas
### 7. **domain/dtos/index.ts** ✓
- Exporta todos los DTOs en un solo lugar
- Facilita importar: `import * from '../../dtos'`
### 8. **domain/repositories/auth.repository.ts** ✓
- AuthRepository interfaz (contrato)
- Define métodos: findByEmail(), create(), findById()
- El UseCase no sabe cómo se implementa, solo el contrato
### 9. **domain/use-cases/auth/register-user.use-case.ts** ✓
- RegisterUserUseCase ejecuta el registro
- Valida DTO → Verifica email → Hashea → Crea usuario → Genera JWT
- Retorna { user, token }
- Lanza CustomError si email existe
### 10. **domain/use-cases/auth/login-user.use-case.ts** ✓
- LoginUserUseCase ejecuta el login
- Valida DTO → Busca usuario → Compara password → Genera JWT
- Retorna { user, token }
- Lanza CustomError si credenciales son inválidas
### 11. **domain/use-cases/auth/get-me.use-case.ts** ✓
- GetMeUseCase obtiene el usuario autenticado
- Recibe userId del JWT
- Busca en repositorio y retorna datos del usuario
- Lanza CustomError si no existe
### 12. **data/postgres/index.ts** ✓
- Cliente único de Prisma
- Se inicializa una vez y se reutiliza
- Se exporta para usarlo en repositorios
### 13. **data/repositories/auth.repository.impl.ts** ✓
- AuthRepositoryImpl implementa AuthRepository
- Habla directamente con Prisma (BD)
- Métodos: findByEmail(), create(), findById()
- Select solo los campos necesarios (no contraseña en getMe)
### 14. **presentation/middlewares/auth.middleware.ts** ✓
- AuthMiddleware valida JWT en rutas protegidas
- Extrae token del header: "Authorization: Bearer TOKEN"
- Valida token → Busca usuario en BD → Agrega a req.user
- Define AuthRequest extends Request con user opcional
- Retorna 401 si token inválido o usuario no existe
### 15. **presentation/auth/controller.ts** ✓
- AuthController maneja peticiones HTTP
- Métodos: register(), login(), getMe()
- Cada método recibe, llama al UseCase, responde
- handleError() maneja CustomError y error normal
### 16. **presentation/auth/routes.ts** ✓
- Define rutas de autenticación
- POST /api/auth/register → controller.register
- POST /api/auth/login → controller.login
- GET /api/auth/me → AuthMiddleware.validate → controller.getMe
### 17. **presentation/routes.ts** ✓
- Rutas principales de la app
- Integra AuthRoutes en /api/auth
### 18. **presentation/server.ts** (Sin cambios)
- Instancia Express
- Middlewares globales (json, urlencoded)
### 19. **app.ts** ✓
- Punto de entrada
- Conecta a Prisma
- Crea Server con env.PORT
- Maneja errores globales
### 20. **.env.example** ✓
- Ejemplo de variables de entorno
- El usuario debe copiar a .env y configurar valores
### 21. **ARCHITECTURE.md** ✓
- Documentación completa de la arquitectura
- Estructura de carpetas
- Flujo de datos
- Endpoints
- Variables de entorno
---
## Resumen de Cambios
### Lógica de Negocio (Domain)
- DTOs con validaciones robustas
- UseCase implementan la lógica de registrar, login, obtener usuario
- Repository interface define el contrato
- CustomError para manejo de errores consistente
### Implementación (Data)
- AuthRepositoryImpl usa Prisma para hablar con BD
- Select solo campos necesarios (seguridad)
- Reutiliza cliente de Prisma
### HTTP (Presentation)
- AuthController coordina peticiones HTTP
- AuthMiddleware valida JWT en rutas protegidas
- AuthRoutes define endpoints
### Configuración (Config)
- JwtAdapter y BcryptAdapter centralizados
- env.ts maneja todas las variables
---
## Cómo Usar
### 1. Copiar .env.example a .env
```bash
cp .env.example .env
```
### 2. Configurar variables de entorno
```bash
# .env
DATABASE_URL=postgresql://user:password@localhost:5432/optihack
JWT_SEED=mi_secreto_super_seguro
```
### 3. Instalar dependencias
```bash
npm install
```
### 4. Ejecutar migraciones de Prisma
```bash
npx prisma migrate dev
```
### 5. Iniciar el servidor
```bash
npm run dev
```
### 6. Probar endpoints
```bash
# Register
curl -X POST http://localhost:3000/api/auth/register \
-H "Content-Type: application/json" \
-d '{"name":"John","email":"john@example.com","password":"password123"}'
# Login
curl -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"john@example.com","password":"password123"}'
# GetMe (usa el token obtenido en login)
curl -X GET http://localhost:3000/api/auth/me \
-H "Authorization: Bearer <token>"
```
---
## Principios Aplicados
**Separation of Concerns**: Cada capa tiene una responsabilidad
**Dependency Inversion**: UseCase depende de interfaz, no implementación
**Single Responsibility**: Cada archivo hace una cosa bien
**Open/Closed**: Fácil agregar nuevos casos de uso
**Liskov Substitution**: AuthRepositoryImpl puede reemplazar AuthRepository
**Interface Segregation**: Interfaces pequeñas y específicas
**DRY**: No repitas código, centraliza lógica

View File

@@ -10,6 +10,6 @@ export default defineConfig({
path: "prisma/migrations",
},
datasource: {
url: env("POSTGRES_URL"),
url: env("DATABASE_URL"),
},
});

View File

@@ -1,15 +1,34 @@
/**
* app.ts
* Punto de entrada de la aplicación.
* - Inicia el servidor
* - Conecta a la base de datos
* - Maneja errores globales
*/
import "dotenv/config";
import { env } from "./config/env.js";
import { AppRoutes } from "./presentation/routes.js";
import { Server } from "./presentation/server.js";
import { prisma } from "./data/postgres/index.js";
async function main() {
const server = new Server( {
port : env.PORT,
routes : AppRoutes.routes,
});
await server.start();
try {
// Verificar conexión a la base de datos
await prisma.$connect();
console.log("✓ Database connected");
// Crear y iniciar el servidor
const server = new Server({
port: env.PORT,
routes: AppRoutes.routes,
});
await server.start();
} catch (error) {
console.error("✗ Error starting application:", error);
process.exit(1);
}
}
main();

View File

@@ -0,0 +1,18 @@
/**
* bcrypt.ts
* BcryptAdapter: hashea y compara contraseñas.
* Encapsula toda la lógica de bcrypt en un lugar.
*/
import bcrypt from "bcryptjs";
import { env } from "./env.js";
export class BcryptAdapter {
static async hash(password: string): Promise<string> {
return bcrypt.hash(password, env.BCRYPT_ROUNDS);
}
static async compare(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}
}

View File

@@ -1,9 +1,38 @@
import 'dotenv/config';
import envVar from 'env-var';
/**
* env.ts
* Lee y valida las variables de entorno.
* Centraliza toda la configuración que viene del .env
*/
const getEnvVar = (key: string, defaultValue?: string): string => {
const value = process.env[key];
if (!value && defaultValue === undefined) {
throw new Error(`Missing environment variable: ${key}`);
}
return (value as string) || (defaultValue as string);
};
const requiredEnvVar = (key: string): string => {
const value = process.env[key];
if (!value) {
throw new Error(`Missing required environment variable: ${key}`);
}
return value;
};
export const env = {
PORT: envVar.get('PORT').required().asPortNumber(),
PUBLIC_PATH: envVar.get('PUBLIC_PATH').default('public').asString(),
POSTGRES_URL: envVar.get('POSTGRES_URL').required().asString()
}
// Server
PORT: Number(getEnvVar("PORT", "3000")),
NODE_ENV: getEnvVar("NODE_ENV", "development"),
// Database
DATABASE_URL: requiredEnvVar("DATABASE_URL"),
// JWT
JWT_SEED: requiredEnvVar("JWT_SEED"),
JWT_EXPIRES_IN: getEnvVar("JWT_EXPIRES_IN", "7d"),
// Bcrypt
BCRYPT_ROUNDS: Number(getEnvVar("BCRYPT_ROUNDS", "10")),
} as const;

View File

@@ -1,18 +1,23 @@
import jwt, { SignOptions } from 'jsonwebtoken';
import jwt, { SignOptions } from "jsonwebtoken";
import { env } from "./env.js";
const JWT_SECRET = process.env.JWT_SECRET ?? 'dev_secret';
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN ?? '2h';
export interface JwtPayload {
id: number;
email: string;
}
export class JwtAdapter {
static generateToken(payload: object): string {
return jwt.sign(payload, JWT_SECRET, {
expiresIn: JWT_EXPIRES_IN,
} as SignOptions);
static generate(payload: JwtPayload): string {
const options: SignOptions = {
expiresIn: env.JWT_EXPIRES_IN as NonNullable<SignOptions["expiresIn"]>,
};
return jwt.sign(payload, env.JWT_SEED, options);
}
static validateToken<T>(token: string): T | null {
static validate(token: string): JwtPayload | null {
try {
return jwt.verify(token, JWT_SECRET) as T;
return jwt.verify(token, env.JWT_SEED) as JwtPayload;
} catch {
return null;
}

View File

@@ -1,9 +1,14 @@
import { env } from "../../config/env.js";
import { PrismaPg } from "@prisma/adapter-pg";
import "dotenv/config";
import { PrismaClient } from "../../generated/prisma/client.js";
import { PrismaPg } from "@prisma/adapter-pg";
const connectionString = `${env.POSTGRES_URL}`;
const adapter = new PrismaPg({ connectionString });
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL!,
});
export const prisma = new PrismaClient({ adapter });
export const prisma = new PrismaClient({
adapter,
errorFormat: "pretty",
});

View File

@@ -0,0 +1,45 @@
/**
* auth.repository.impl.ts
* AuthRepositoryImpl: implementación real del AuthRepository.
* Aquí es donde hablamos con la base de datos usando Prisma.
*/
import { AuthRepository } from "../../domain/repositories/auth.repository.js";
import { prisma } from "../postgres/index.js";
export class AuthRepositoryImpl implements AuthRepository {
async findByEmail(email: string) {
return prisma.user.findUnique({
where: { email },
select: {
id: true,
email: true,
password: true,
name: true,
},
});
}
async create(data: { name: string; email: string; password: string }) {
return prisma.user.create({
data,
select: {
id: true,
email: true,
name: true,
},
});
}
async findById(id: number) {
return prisma.user.findUnique({
where: { id },
select: {
id: true,
email: true,
name: true,
role: true,
},
});
}
}

View File

@@ -1,15 +1,38 @@
export class LoginUserDto {
private constructor(
public readonly email: string,
public readonly password: string,
) {}
/**
* login-user.dto.ts
* DTO para iniciar sesión.
* Define qué datos esperamos al hacer login.
*/
static create(props: { [key: string]: any }): [string | undefined, LoginUserDto?] {
const { email, password } = props;
export interface LoginUserDto {
email: string;
password: string;
}
if (!email) return ['email is required'];
if (!password) return ['password is required'];
export class LoginUserDtoValidator {
static validate(data: unknown): LoginUserDto {
if (typeof data !== "object" || data === null) {
throw new Error("Invalid data");
}
return [undefined, new LoginUserDto(email, password)];
const obj = data as Record<string, unknown>;
if (typeof obj.email !== "string" || !this.isValidEmail(obj.email)) {
throw new Error("Email is required and must be valid");
}
if (typeof obj.password !== "string" || obj.password.length === 0) {
throw new Error("Password is required");
}
return {
email: obj.email.trim().toLowerCase(),
password: obj.password,
};
}
private static isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
}

View File

@@ -1,21 +1,44 @@
export class RegisterUserDto {
private constructor(
public readonly name: string,
public readonly email: string,
public readonly password: string,
) {}
/**
* register-user.dto.ts
* DTO para registrar un usuario.
* Define qué datos esperamos al registrar.
*/
static create(props: { [key: string]: any }): [string | undefined, RegisterUserDto?] {
const { name, email, password } = props;
export interface RegisterUserDto {
name: string;
email: string;
password: string;
}
if (!name) return ['name is required'];
if (!email) return ['email is required'];
if (!password) return ['password is required'];
if (password.length < 6) {
return ['password must be at least 6 characters'];
export class RegisterUserDtoValidator {
static validate(data: unknown): RegisterUserDto {
if (typeof data !== "object" || data === null) {
throw new Error("Invalid data");
}
return [undefined, new RegisterUserDto(name, email, password)];
const obj = data as Record<string, unknown>;
if (typeof obj.name !== "string" || obj.name.trim().length === 0) {
throw new Error("Name is required and must be a non-empty string");
}
if (typeof obj.email !== "string" || !this.isValidEmail(obj.email)) {
throw new Error("Email is required and must be valid");
}
if (typeof obj.password !== "string" || obj.password.length < 6) {
throw new Error("Password is required and must be at least 6 characters");
}
return {
name: obj.name.trim(),
email: obj.email.trim().toLowerCase(),
password: obj.password,
};
}
private static isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
}

View File

@@ -0,0 +1,35 @@
/**
* custom.error.ts
* CustomError: clase base para errores personalizados.
* Facilita el manejo de errores en toda la aplicación.
*/
export class CustomError extends Error {
constructor(
public message: string,
public statusCode: number,
) {
super(message);
this.name = "CustomError";
}
static badRequest(message: string): CustomError {
return new CustomError(message, 400);
}
static unauthorized(message: string = "Unauthorized"): CustomError {
return new CustomError(message, 401);
}
static notFound(message: string): CustomError {
return new CustomError(message, 404);
}
static conflict(message: string): CustomError {
return new CustomError(message, 409);
}
static internalServer(message: string = "Internal Server Error"): CustomError {
return new CustomError(message, 500);
}
}

View File

@@ -0,0 +1,11 @@
/**
* auth.repository.ts
* AuthRepository (interfaz): define qué métodos debe tener el repositorio de auth.
* El use-case usa esta interfaz sin saber cómo se implementa.
*/
export interface AuthRepository {
findByEmail(email: string): Promise<{ id: number; email: string; password: string; name: string } | null>;
create(data: { name: string; email: string; password: string }): Promise<{ id: number; email: string; name: string }>;
findById(id: number): Promise<{ id: number; email: string; name: string; role: string } | null>;
}

View File

@@ -0,0 +1,30 @@
/**
* get-me.use-case.ts
* GetMeUseCase: obtiene los datos del usuario autenticado.
* - Busca el usuario por ID (que viene del JWT)
* - Retorna los datos del usuario
*/
import { AuthRepository } from "../../repositories/auth.repository.js";
import { CustomError } from "../../errors/custom.error.js";
export interface GetMeResponse {
id: number;
email: string;
name: string;
role: string;
}
export class GetMeUseCase {
constructor(private repository: AuthRepository) {}
async execute(userId: number): Promise<GetMeResponse> {
const user = await this.repository.findById(userId);
if (!user) {
throw CustomError.notFound("User not found");
}
return user as GetMeResponse;
}
}

View File

@@ -0,0 +1,59 @@
/**
* login-user.use-case.ts
* LoginUserUseCase: lógica para iniciar sesión.
* - Valida datos
* - Busca el usuario por email
* - Compara las contraseñas
* - Genera el JWT
*/
import { LoginUserDto, LoginUserDtoValidator } from "../../dtos/index.js";
import { AuthRepository } from "../../repositories/auth.repository.js";
import { CustomError } from "../../errors/custom.error.js";
import { BcryptAdapter } from "../../../config/bcrypt.js";
import { JwtAdapter } from "../../../config/jwt.js";
export interface LoginUserResponse {
user: {
id: number;
email: string;
name: string;
};
token: string;
}
export class LoginUserUseCase {
constructor(private repository: AuthRepository) {}
async execute(data: unknown): Promise<LoginUserResponse> {
// Validar DTO
const dto = LoginUserDtoValidator.validate(data);
// Buscar usuario por email
const user = await this.repository.findByEmail(dto.email);
if (!user) {
throw CustomError.unauthorized("Invalid credentials");
}
// Comparar contraseñas
const isPasswordValid = await BcryptAdapter.compare(dto.password, user.password);
if (!isPasswordValid) {
throw CustomError.unauthorized("Invalid credentials");
}
// Generar JWT
const token = JwtAdapter.generate({
id: user.id,
email: user.email,
});
return {
user: {
id: user.id,
email: user.email,
name: user.name,
},
token,
};
}
}

View File

@@ -0,0 +1,60 @@
/**
* register-user.use-case.ts
* RegisterUserUseCase: lógica para registrar un usuario.
* - Valida datos
* - Verifica si el email ya existe
* - Hashea la contraseña
* - Crea el usuario
* - Genera el JWT
*/
import { RegisterUserDto, RegisterUserDtoValidator } from "../../dtos/index.js";
import { AuthRepository } from "../../repositories/auth.repository.js";
import { CustomError } from "../../errors/custom.error.js";
import { BcryptAdapter } from "../../../config/bcrypt.js";
import { JwtAdapter } from "../../../config/jwt.js";
export interface RegisterUserResponse {
user: {
id: number;
email: string;
name: string;
};
token: string;
}
export class RegisterUserUseCase {
constructor(private repository: AuthRepository) {}
async execute(data: unknown): Promise<RegisterUserResponse> {
// Validar DTO
const dto = RegisterUserDtoValidator.validate(data);
// Verificar si el email ya existe
const existingUser = await this.repository.findByEmail(dto.email);
if (existingUser) {
throw CustomError.conflict("Email already in use");
}
// Hashear la contraseña
const hashedPassword = await BcryptAdapter.hash(dto.password);
// Crear el usuario
const user = await this.repository.create({
name: dto.name,
email: dto.email,
password: hashedPassword,
});
// Generar JWT
const token = JwtAdapter.generate({
id: user.id,
email: user.email,
});
return {
user,
token,
};
}
}

View File

@@ -1,137 +1,68 @@
import { Request, Response } from "express";
import bcrypt from "bcryptjs";
/**
* controller.ts
* AuthController: maneja las peticiones HTTP de autenticación.
* - Recibe los datos
* - Llama al use-case correspondiente
* - Responde al cliente
* - Maneja los errores
*/
import { prisma } from "../../data/postgres/index.js";
import { JwtAdapter } from "../../config/jwt.js";
import { LoginUserDto, RegisterUserDto } from "../../domain/dtos/index.js";
import { Response } from "express";
import { AuthRequest } from "../middlewares/auth.middleware.js";
import { CustomError } from "../../domain/errors/custom.error.js";
import { AuthRepositoryImpl } from "../../data/repositories/auth.repository.impl.js";
import { RegisterUserUseCase } from "../../domain/use-cases/auth/register-user.use-case.js";
import { LoginUserUseCase } from "../../domain/use-cases/auth/login-user.use-case.js";
import { GetMeUseCase } from "../../domain/use-cases/auth/get-me.use-case.js";
export class AuthController {
private repository = new AuthRepositoryImpl();
private registerUseCase = new RegisterUserUseCase(this.repository);
private loginUseCase = new LoginUserUseCase(this.repository);
private getMeUseCase = new GetMeUseCase(this.repository);
// SIN metodos estaticos para hacer DI
constructor() {}
public registerUser = async (req: Request, res: Response) => {
const [error, registerUserDto] = RegisterUserDto.create(req.body);
if (error) {
return res.status(400).json({ error });
}
register = async (req: AuthRequest, res: Response) => {
try {
const userExists = await prisma.user.findUnique({
where: {
email: registerUserDto!.email,
}
});
if (userExists) {
return res.status(400).json({
error: 'Email already exists'
});
}
const hashedPassword = bcrypt.hashSync(registerUserDto!.password, 10);
const user = await prisma.user.create({
data: {
name: registerUserDto!.name,
email: registerUserDto!.email,
password: hashedPassword,
}
});
const token = JwtAdapter.generateToken({
id: user.id,
email: user.email,
role: user.role,
});
return res.status(201).json({
user: {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
},
token
});
const result = await this.registerUseCase.execute(req.body);
res.status(201).json(result);
} catch (error) {
console.log(error);
return res.status(500).json({
error: 'Internal server error'
});
}
}
public loginUser = async (req: Request, res: Response) => {
const [error, loginUserDto] = LoginUserDto.create(req.body);
if (error) {
return res.status(400).json({ error });
this.handleError(error, res);
}
};
login = async (req: AuthRequest, res: Response) => {
try {
const user = await prisma.user.findUnique({
where: {
email: loginUserDto!.email,
}
});
if (!user) {
return res.status(400).json({
error: 'Invalid email or password'
});
}
const isPasswordValid = bcrypt.compareSync(
loginUserDto!.password,
user.password
);
if (!isPasswordValid) {
return res.status(400).json({
error: 'Invalid email or password'
});
}
const token = JwtAdapter.generateToken({
id: user.id,
email: user.email,
role: user.role,
});
return res.json({
user: {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
},
token
});
const result = await this.loginUseCase.execute(req.body);
res.status(200).json(result);
} catch (error) {
console.log(error);
return res.status(500).json({
error: 'Internal server error'
});
}
this.handleError(error, res);
}
};
public getMe = async (req: AuthRequest, res: Response) => {
getMe = async (req: AuthRequest, res: Response) => {
try {
if (!req.user) {
return res.status(401).json({
error: 'User not authenticated'
});
throw CustomError.unauthorized("User not authenticated");
}
return res.json({
user: req.user
});
const result = await this.getMeUseCase.execute(req.user.id);
res.status(200).json(result);
} catch (error) {
this.handleError(error, res);
}
};
private handleError(error: unknown, res: Response): void {
if (error instanceof CustomError) {
res.status(error.statusCode).json({ error: error.message });
return;
}
if (error instanceof Error) {
res.status(400).json({ error: error.message });
return;
}
res.status(500).json({ error: "Internal server error" });
}
}

View File

@@ -1,19 +1,21 @@
/**
* routes.ts
* Rutas de autenticación.
* Define los endpoints de register, login y getMe.
*/
import { Router } from "express";
import { AuthController } from "./controller.js";
import { AuthMiddleware } from "../middlewares/auth.middleware.js";
export class AuthRoutes {
static get routes(): Router {
const router = Router();
const controller = new AuthController();
router.post('/register', controller.registerUser);
router.post('/login', controller.loginUser);
// Ruta protegida para probar JWT
router.get('/me', AuthMiddleware.validateJwt, controller.getMe);
router.post("/register", controller.register);
router.post("/login", controller.login);
router.get("/me", AuthMiddleware.validate, controller.getMe);
return router;
}

View File

@@ -1,46 +1,54 @@
import { Request, Response, NextFunction } from "express";
import { JwtAdapter } from "../../config/jwt.js";
/**
* auth.middleware.ts
* Middleware para validar el JWT.
* - Lee el token del header Authorization: Bearer TOKEN
* - Valida el token
* - Busca el usuario en base de datos
* - Agrega el usuario a req.user
*/
interface TokenPayload {
id: number;
email: string;
role: string;
}
import { Request, Response, NextFunction } from "express";
import { JwtAdapter, JwtPayload } from "../../config/jwt.js";
import { AuthRepositoryImpl } from "../../data/repositories/auth.repository.impl.js";
import { CustomError } from "../../domain/errors/custom.error.js";
export interface AuthRequest extends Request {
user?: TokenPayload;
user?: JwtPayload;
}
export class AuthMiddleware {
static validateJwt = (req: AuthRequest, res: Response, next: NextFunction) => {
const authorization = req.header('Authorization');
if (!authorization) {
return res.status(401).json({
error: 'No token provided'
});
static async validate(req: AuthRequest, res: Response, next: NextFunction) {
try {
// Obtener el token del header
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
throw CustomError.unauthorized("Missing or invalid Authorization header");
}
if (!authorization.startsWith('Bearer ')) {
return res.status(401).json({
error: 'Invalid Bearer token'
});
}
const token = authorization.split(' ').at(1) ?? '';
const payload = JwtAdapter.validateToken<TokenPayload>(token);
const token = authHeader.substring(7); // Remover "Bearer "
// Validar el token
const payload = JwtAdapter.validate(token);
if (!payload) {
return res.status(401).json({
error: 'Invalid token'
});
throw CustomError.unauthorized("Invalid token");
}
// Verificar que el usuario existe en BD
const repository = new AuthRepositoryImpl();
const user = await repository.findById(payload.id);
if (!user) {
throw CustomError.unauthorized("User not found");
}
// Agregar el usuario al request
req.user = payload;
next();
} catch (error) {
if (error instanceof CustomError) {
return res.status(error.statusCode).json({ error: error.message });
}
res.status(500).json({ error: "Internal server error" });
}
}
}