From 0e38ce931ac70b7a830402b22ca52d050aae03bb Mon Sep 17 00:00:00 2001 From: Didier Palma Date: Fri, 22 May 2026 18:22:58 -0600 Subject: [PATCH 1/4] EtaCard --- frontend/package-lock.json | 12 ------------ frontend/src/constants/EtaCard.tsx | 0 2 files changed, 12 deletions(-) create mode 100644 frontend/src/constants/EtaCard.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9a37061..aa6b065 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -5196,9 +5196,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5219,9 +5216,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5242,9 +5236,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5265,9 +5256,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/frontend/src/constants/EtaCard.tsx b/frontend/src/constants/EtaCard.tsx new file mode 100644 index 0000000..e69de29 From a2626486c39263ee52fe515a6c12ee40eb5e634c Mon Sep 17 00:00:00 2001 From: Didier Palma Date: Fri, 22 May 2026 18:56:19 -0600 Subject: [PATCH 2/4] moved EtaCard --- frontend/src/components/EtaCard.tsx | 54 +++++++++++++++++++++++++++++ frontend/src/constants/EtaCard.tsx | 0 2 files changed, 54 insertions(+) create mode 100644 frontend/src/components/EtaCard.tsx delete mode 100644 frontend/src/constants/EtaCard.tsx diff --git a/frontend/src/components/EtaCard.tsx b/frontend/src/components/EtaCard.tsx new file mode 100644 index 0000000..3087258 --- /dev/null +++ b/frontend/src/components/EtaCard.tsx @@ -0,0 +1,54 @@ +import { View, Text, StyleSheet } from 'react-native'; +import { Colors } from '../constants/theme'; + +type EtaCardProps = { + minutes?: number; + status?: string; +}; + +export default function EtaCard({ + minutes = 12, + status = 'En Camino', +}: EtaCardProps) { + + const theme = Colors.light; + + return ( + + + + ETA estimado + + + + {minutes} min + + + + {status} + + + + ); +} + +const styles = StyleSheet.create({ + card: { + margin: 15, + padding: 20, + borderRadius: 16, + }, + label: { + fontSize: 12, + marginBottom: 6, + }, + time: { + fontSize: 32, + fontWeight: 'bold', + marginBottom: 6, + }, + status: { + fontSize: 14, + fontWeight: '600', + }, +}); diff --git a/frontend/src/constants/EtaCard.tsx b/frontend/src/constants/EtaCard.tsx deleted file mode 100644 index e69de29..0000000 From 1dadefb5d1a8653e54df1b666dc815cb3e2db7bc Mon Sep 17 00:00:00 2001 From: Didier Palma Date: Fri, 22 May 2026 19:14:46 -0600 Subject: [PATCH 3/4] components --- frontend/src/components/Alertltem.tsx | 0 frontend/src/components/InputField.tsx | 0 frontend/src/components/PrimaryButton.tsx | 0 frontend/src/components/QuickAction.tsx | 0 frontend/src/components/SectionTitle.tsx | 0 frontend/src/components/StatusBadge.tsx | 0 6 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 frontend/src/components/Alertltem.tsx create mode 100644 frontend/src/components/InputField.tsx create mode 100644 frontend/src/components/PrimaryButton.tsx create mode 100644 frontend/src/components/QuickAction.tsx create mode 100644 frontend/src/components/SectionTitle.tsx create mode 100644 frontend/src/components/StatusBadge.tsx diff --git a/frontend/src/components/Alertltem.tsx b/frontend/src/components/Alertltem.tsx new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/components/InputField.tsx b/frontend/src/components/InputField.tsx new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/components/PrimaryButton.tsx b/frontend/src/components/PrimaryButton.tsx new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/components/QuickAction.tsx b/frontend/src/components/QuickAction.tsx new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/components/SectionTitle.tsx b/frontend/src/components/SectionTitle.tsx new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/components/StatusBadge.tsx b/frontend/src/components/StatusBadge.tsx new file mode 100644 index 0000000..e69de29 From c2e53eb21b07013a55e364a3ddd1a03b33c2e030 Mon Sep 17 00:00:00 2001 From: Cesar Date: Fri, 22 May 2026 19:26:38 -0600 Subject: [PATCH 4/4] architecture refactoring --- backend/.env.example | 13 ++ backend/ARCHITECTURE.md | 209 ++++++++++++++++++ backend/CHANGES_SUMMARY.md | 202 +++++++++++++++++ backend/prisma.config.ts | 2 +- backend/src/app.ts | 31 ++- backend/src/config/bcrypt.ts | 18 ++ backend/src/config/env.ts | 41 +++- backend/src/config/jwt.ts | 23 +- backend/src/data/postgres/index.ts | 19 +- .../data/repositories/auth.repository.impl.ts | 45 ++++ .../src/domain/dtos/auth/login-user.dto.ts | 43 +++- .../src/domain/dtos/auth/register-user.dto.ts | 53 +++-- backend/src/domain/errors/custom.error.ts | 35 +++ .../domain/repositories/auth.repository.ts | 11 + .../domain/use-cases/auth/get-me.use-case.ts | 30 +++ .../use-cases/auth/login-user.use-case.ts | 59 +++++ .../use-cases/auth/register-user.use-case.ts | 60 +++++ backend/src/presentation/auth/controller.ts | 177 +++++---------- backend/src/presentation/auth/routes.ts | 26 ++- .../middlewares/auth.middleware.ts | 74 ++++--- 20 files changed, 949 insertions(+), 222 deletions(-) create mode 100644 backend/.env.example create mode 100644 backend/ARCHITECTURE.md create mode 100644 backend/CHANGES_SUMMARY.md create mode 100644 backend/src/config/bcrypt.ts create mode 100644 backend/src/data/repositories/auth.repository.impl.ts create mode 100644 backend/src/domain/errors/custom.error.ts create mode 100644 backend/src/domain/repositories/auth.repository.ts create mode 100644 backend/src/domain/use-cases/auth/get-me.use-case.ts create mode 100644 backend/src/domain/use-cases/auth/login-user.use-case.ts create mode 100644 backend/src/domain/use-cases/auth/register-user.use-case.ts diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..c743b02 --- /dev/null +++ b/backend/.env.example @@ -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 diff --git a/backend/ARCHITECTURE.md b/backend/ARCHITECTURE.md new file mode 100644 index 0000000..ec54cdc --- /dev/null +++ b/backend/ARCHITECTURE.md @@ -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 diff --git a/backend/CHANGES_SUMMARY.md b/backend/CHANGES_SUMMARY.md new file mode 100644 index 0000000..c588441 --- /dev/null +++ b/backend/CHANGES_SUMMARY.md @@ -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 " +``` + +--- + +## 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 diff --git a/backend/prisma.config.ts b/backend/prisma.config.ts index 3c45216..60d6fe9 100644 --- a/backend/prisma.config.ts +++ b/backend/prisma.config.ts @@ -10,6 +10,6 @@ export default defineConfig({ path: "prisma/migrations", }, datasource: { - url: env("POSTGRES_URL"), + url: env("DATABASE_URL"), }, }); diff --git a/backend/src/app.ts b/backend/src/app.ts index 071cf34..40fd2ec 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -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(); \ No newline at end of file diff --git a/backend/src/config/bcrypt.ts b/backend/src/config/bcrypt.ts new file mode 100644 index 0000000..bcc6e62 --- /dev/null +++ b/backend/src/config/bcrypt.ts @@ -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 { + return bcrypt.hash(password, env.BCRYPT_ROUNDS); + } + + static async compare(password: string, hash: string): Promise { + return bcrypt.compare(password, hash); + } +} diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index a631686..b599db4 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -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; diff --git a/backend/src/config/jwt.ts b/backend/src/config/jwt.ts index 1b1ff37..73ec18e 100644 --- a/backend/src/config/jwt.ts +++ b/backend/src/config/jwt.ts @@ -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, + }; + + return jwt.sign(payload, env.JWT_SEED, options); } - static validateToken(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; } diff --git a/backend/src/data/postgres/index.ts b/backend/src/data/postgres/index.ts index 5d90ca3..a14c73d 100644 --- a/backend/src/data/postgres/index.ts +++ b/backend/src/data/postgres/index.ts @@ -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 }); \ No newline at end of file +import { PrismaPg } from "@prisma/adapter-pg"; + +const adapter = new PrismaPg({ + connectionString: process.env.DATABASE_URL!, +}); + +export const prisma = new PrismaClient({ + adapter, + errorFormat: "pretty", +}); \ No newline at end of file diff --git a/backend/src/data/repositories/auth.repository.impl.ts b/backend/src/data/repositories/auth.repository.impl.ts new file mode 100644 index 0000000..3fec4d7 --- /dev/null +++ b/backend/src/data/repositories/auth.repository.impl.ts @@ -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, + }, + }); + } +} \ No newline at end of file diff --git a/backend/src/domain/dtos/auth/login-user.dto.ts b/backend/src/domain/dtos/auth/login-user.dto.ts index aec887b..9965c85 100644 --- a/backend/src/domain/dtos/auth/login-user.dto.ts +++ b/backend/src/domain/dtos/auth/login-user.dto.ts @@ -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; + + 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); } } \ No newline at end of file diff --git a/backend/src/domain/dtos/auth/register-user.dto.ts b/backend/src/domain/dtos/auth/register-user.dto.ts index 59f623a..3266f8b 100644 --- a/backend/src/domain/dtos/auth/register-user.dto.ts +++ b/backend/src/domain/dtos/auth/register-user.dto.ts @@ -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; + + 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); } } \ No newline at end of file diff --git a/backend/src/domain/errors/custom.error.ts b/backend/src/domain/errors/custom.error.ts new file mode 100644 index 0000000..4315215 --- /dev/null +++ b/backend/src/domain/errors/custom.error.ts @@ -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); + } +} diff --git a/backend/src/domain/repositories/auth.repository.ts b/backend/src/domain/repositories/auth.repository.ts new file mode 100644 index 0000000..b89b37d --- /dev/null +++ b/backend/src/domain/repositories/auth.repository.ts @@ -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>; +} \ No newline at end of file diff --git a/backend/src/domain/use-cases/auth/get-me.use-case.ts b/backend/src/domain/use-cases/auth/get-me.use-case.ts new file mode 100644 index 0000000..dbc73d0 --- /dev/null +++ b/backend/src/domain/use-cases/auth/get-me.use-case.ts @@ -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 { + const user = await this.repository.findById(userId); + + if (!user) { + throw CustomError.notFound("User not found"); + } + + return user as GetMeResponse; + } +} \ No newline at end of file diff --git a/backend/src/domain/use-cases/auth/login-user.use-case.ts b/backend/src/domain/use-cases/auth/login-user.use-case.ts new file mode 100644 index 0000000..1cd838c --- /dev/null +++ b/backend/src/domain/use-cases/auth/login-user.use-case.ts @@ -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 { + // 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, + }; + } +} \ No newline at end of file diff --git a/backend/src/domain/use-cases/auth/register-user.use-case.ts b/backend/src/domain/use-cases/auth/register-user.use-case.ts new file mode 100644 index 0000000..cdd649c --- /dev/null +++ b/backend/src/domain/use-cases/auth/register-user.use-case.ts @@ -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 { + // 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, + }; + } +} diff --git a/backend/src/presentation/auth/controller.ts b/backend/src/presentation/auth/controller.ts index b9c7b2b..3f9c941 100644 --- a/backend/src/presentation/auth/controller.ts +++ b/backend/src/presentation/auth/controller.ts @@ -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" }); + } } \ No newline at end of file diff --git a/backend/src/presentation/auth/routes.ts b/backend/src/presentation/auth/routes.ts index 2913c41..e8808d4 100644 --- a/backend/src/presentation/auth/routes.ts +++ b/backend/src/presentation/auth/routes.ts @@ -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; + } } \ No newline at end of file diff --git a/backend/src/presentation/middlewares/auth.middleware.ts b/backend/src/presentation/middlewares/auth.middleware.ts index c1daaa4..a605845 100644 --- a/backend/src/presentation/middlewares/auth.middleware.ts +++ b/backend/src/presentation/middlewares/auth.middleware.ts @@ -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(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" }); } + } } \ No newline at end of file