architecture refactoring
This commit is contained in:
13
backend/.env.example
Normal file
13
backend/.env.example
Normal 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
209
backend/ARCHITECTURE.md
Normal 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
202
backend/CHANGES_SUMMARY.md
Normal 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
|
||||
@@ -10,6 +10,6 @@ export default defineConfig({
|
||||
path: "prisma/migrations",
|
||||
},
|
||||
datasource: {
|
||||
url: env("POSTGRES_URL"),
|
||||
url: env("DATABASE_URL"),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
18
backend/src/config/bcrypt.ts
Normal file
18
backend/src/config/bcrypt.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
const connectionString = `${env.POSTGRES_URL}`;
|
||||
const adapter = new PrismaPg({ connectionString });
|
||||
|
||||
export const prisma = new PrismaClient({ adapter });
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
|
||||
const adapter = new PrismaPg({
|
||||
connectionString: process.env.DATABASE_URL!,
|
||||
});
|
||||
|
||||
export const prisma = new PrismaClient({
|
||||
adapter,
|
||||
errorFormat: "pretty",
|
||||
});
|
||||
45
backend/src/data/repositories/auth.repository.impl.ts
Normal file
45
backend/src/data/repositories/auth.repository.impl.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
35
backend/src/domain/errors/custom.error.ts
Normal file
35
backend/src/domain/errors/custom.error.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
11
backend/src/domain/repositories/auth.repository.ts
Normal file
11
backend/src/domain/repositories/auth.repository.ts
Normal 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>;
|
||||
}
|
||||
30
backend/src/domain/use-cases/auth/get-me.use-case.ts
Normal file
30
backend/src/domain/use-cases/auth/get-me.use-case.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
59
backend/src/domain/use-cases/auth/login-user.use-case.ts
Normal file
59
backend/src/domain/use-cases/auth/login-user.use-case.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
60
backend/src/domain/use-cases/auth/register-user.use-case.ts
Normal file
60
backend/src/domain/use-cases/auth/register-user.use-case.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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() {}
|
||||
register = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const result = await this.registerUseCase.execute(req.body);
|
||||
res.status(201).json(result);
|
||||
} catch (error) {
|
||||
this.handleError(error, res);
|
||||
}
|
||||
};
|
||||
|
||||
public registerUser = async (req: Request, res: Response) => {
|
||||
login = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const result = await this.loginUseCase.execute(req.body);
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
this.handleError(error, res);
|
||||
}
|
||||
};
|
||||
|
||||
const [error, registerUserDto] = RegisterUserDto.create(req.body);
|
||||
getMe = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw CustomError.unauthorized("User not authenticated");
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
const result = await this.getMeUseCase.execute(req.user.id);
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
this.handleError(error, res);
|
||||
}
|
||||
};
|
||||
|
||||
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
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
return res.status(500).json({
|
||||
error: 'Internal server error'
|
||||
});
|
||||
}
|
||||
private handleError(error: unknown, res: Response): void {
|
||||
if (error instanceof CustomError) {
|
||||
res.status(error.statusCode).json({ error: error.message });
|
||||
return;
|
||||
}
|
||||
|
||||
public loginUser = async (req: Request, res: Response) => {
|
||||
|
||||
const [error, loginUserDto] = LoginUserDto.create(req.body);
|
||||
|
||||
if (error) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
|
||||
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
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
return res.status(500).json({
|
||||
error: 'Internal server error'
|
||||
});
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
res.status(400).json({ error: error.message });
|
||||
return;
|
||||
}
|
||||
|
||||
public getMe = async (req: AuthRequest, res: Response) => {
|
||||
|
||||
if (!req.user) {
|
||||
return res.status(401).json({
|
||||
error: 'User not authenticated'
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
user: req.user
|
||||
});
|
||||
}
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,22 @@
|
||||
/**
|
||||
* 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();
|
||||
|
||||
static get routes(): Router {
|
||||
router.post("/register", controller.register);
|
||||
router.post("/login", controller.login);
|
||||
router.get("/me", AuthMiddleware.validate, controller.getMe);
|
||||
|
||||
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);
|
||||
|
||||
return router;
|
||||
}
|
||||
return router;
|
||||
}
|
||||
}
|
||||
@@ -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 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");
|
||||
}
|
||||
|
||||
static validateJwt = (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
const token = authHeader.substring(7); // Remover "Bearer "
|
||||
|
||||
const authorization = req.header('Authorization');
|
||||
// Validar el token
|
||||
const payload = JwtAdapter.validate(token);
|
||||
if (!payload) {
|
||||
throw CustomError.unauthorized("Invalid token");
|
||||
}
|
||||
|
||||
if (!authorization) {
|
||||
return res.status(401).json({
|
||||
error: 'No token provided'
|
||||
});
|
||||
}
|
||||
// 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");
|
||||
}
|
||||
|
||||
if (!authorization.startsWith('Bearer ')) {
|
||||
return res.status(401).json({
|
||||
error: 'Invalid Bearer token'
|
||||
});
|
||||
}
|
||||
// Agregar el usuario al request
|
||||
req.user = payload;
|
||||
|
||||
const token = authorization.split(' ').at(1) ?? '';
|
||||
|
||||
const payload = JwtAdapter.validateToken<TokenPayload>(token);
|
||||
|
||||
if (!payload) {
|
||||
return res.status(401).json({
|
||||
error: 'Invalid token'
|
||||
});
|
||||
}
|
||||
|
||||
req.user = payload;
|
||||
|
||||
next();
|
||||
next();
|
||||
} catch (error) {
|
||||
if (error instanceof CustomError) {
|
||||
return res.status(error.statusCode).json({ error: error.message });
|
||||
}
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user