app
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
|
||||||
@@ -10,6 +10,6 @@ export default defineConfig({
|
|||||||
path: "prisma/migrations",
|
path: "prisma/migrations",
|
||||||
},
|
},
|
||||||
datasource: {
|
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 { env } from "./config/env.js";
|
||||||
import { AppRoutes } from "./presentation/routes.js";
|
import { AppRoutes } from "./presentation/routes.js";
|
||||||
import { Server } from "./presentation/server.js";
|
import { Server } from "./presentation/server.js";
|
||||||
|
import { prisma } from "./data/postgres/index.js";
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const server = new Server( {
|
try {
|
||||||
port : env.PORT,
|
// Verificar conexión a la base de datos
|
||||||
routes : AppRoutes.routes,
|
await prisma.$connect();
|
||||||
});
|
console.log("✓ Database connected");
|
||||||
await server.start();
|
|
||||||
|
|
||||||
|
// 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();
|
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 = {
|
export const env = {
|
||||||
PORT: envVar.get('PORT').required().asPortNumber(),
|
// Server
|
||||||
PUBLIC_PATH: envVar.get('PUBLIC_PATH').default('public').asString(),
|
PORT: Number(getEnvVar("PORT", "3000")),
|
||||||
POSTGRES_URL: envVar.get('POSTGRES_URL').required().asString()
|
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';
|
export interface JwtPayload {
|
||||||
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN ?? '2h';
|
id: number;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class JwtAdapter {
|
export class JwtAdapter {
|
||||||
static generateToken(payload: object): string {
|
static generate(payload: JwtPayload): string {
|
||||||
return jwt.sign(payload, JWT_SECRET, {
|
const options: SignOptions = {
|
||||||
expiresIn: JWT_EXPIRES_IN,
|
expiresIn: env.JWT_EXPIRES_IN as NonNullable<SignOptions["expiresIn"]>,
|
||||||
} as SignOptions);
|
};
|
||||||
|
|
||||||
|
return jwt.sign(payload, env.JWT_SEED, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
static validateToken<T>(token: string): T | null {
|
static validate(token: string): JwtPayload | null {
|
||||||
try {
|
try {
|
||||||
return jwt.verify(token, JWT_SECRET) as T;
|
return jwt.verify(token, env.JWT_SEED) as JwtPayload;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
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";
|
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",
|
||||||
|
});
|
||||||
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(
|
* login-user.dto.ts
|
||||||
public readonly email: string,
|
* DTO para iniciar sesión.
|
||||||
public readonly password: string,
|
* Define qué datos esperamos al hacer login.
|
||||||
) {}
|
*/
|
||||||
|
|
||||||
static create(props: { [key: string]: any }): [string | undefined, LoginUserDto?] {
|
export interface LoginUserDto {
|
||||||
const { email, password } = props;
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
if (!email) return ['email is required'];
|
export class LoginUserDtoValidator {
|
||||||
if (!password) return ['password is required'];
|
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(
|
* register-user.dto.ts
|
||||||
public readonly name: string,
|
* DTO para registrar un usuario.
|
||||||
public readonly email: string,
|
* Define qué datos esperamos al registrar.
|
||||||
public readonly password: string,
|
*/
|
||||||
) {}
|
|
||||||
|
|
||||||
static create(props: { [key: string]: any }): [string | undefined, RegisterUserDto?] {
|
export interface RegisterUserDto {
|
||||||
const { name, email, password } = props;
|
name: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
if (!name) return ['name is required'];
|
export class RegisterUserDtoValidator {
|
||||||
if (!email) return ['email is required'];
|
static validate(data: unknown): RegisterUserDto {
|
||||||
if (!password) return ['password is required'];
|
if (typeof data !== "object" || data === null) {
|
||||||
|
throw new Error("Invalid data");
|
||||||
if (password.length < 6) {
|
|
||||||
return ['password must be at least 6 characters'];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 { Response } from "express";
|
||||||
import { JwtAdapter } from "../../config/jwt.js";
|
|
||||||
import { LoginUserDto, RegisterUserDto } from "../../domain/dtos/index.js";
|
|
||||||
import { AuthRequest } from "../middlewares/auth.middleware.js";
|
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 {
|
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
|
register = async (req: AuthRequest, res: Response) => {
|
||||||
constructor() {}
|
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) {
|
const result = await this.getMeUseCase.execute(req.user.id);
|
||||||
return res.status(400).json({ error });
|
res.status(200).json(result);
|
||||||
}
|
} catch (error) {
|
||||||
|
this.handleError(error, res);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
private handleError(error: unknown, res: Response): void {
|
||||||
const userExists = await prisma.user.findUnique({
|
if (error instanceof CustomError) {
|
||||||
where: {
|
res.status(error.statusCode).json({ error: error.message });
|
||||||
email: registerUserDto!.email,
|
return;
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
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'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public loginUser = async (req: Request, res: Response) => {
|
if (error instanceof Error) {
|
||||||
|
res.status(400).json({ error: error.message });
|
||||||
const [error, loginUserDto] = LoginUserDto.create(req.body);
|
return;
|
||||||
|
|
||||||
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'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public getMe = async (req: AuthRequest, res: Response) => {
|
res.status(500).json({ error: "Internal server error" });
|
||||||
|
}
|
||||||
if (!req.user) {
|
|
||||||
return res.status(401).json({
|
|
||||||
error: 'User not authenticated'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.json({
|
|
||||||
user: req.user
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,20 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* routes.ts
|
||||||
|
* Rutas de autenticación.
|
||||||
|
* Define los endpoints de register, login y getMe.
|
||||||
|
*/
|
||||||
|
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { AuthController } from "./controller.js";
|
import { AuthController } from "./controller.js";
|
||||||
import { AuthMiddleware } from "../middlewares/auth.middleware.js";
|
import { AuthMiddleware } from "../middlewares/auth.middleware.js";
|
||||||
|
|
||||||
export class AuthRoutes {
|
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();
|
return 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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 {
|
import { Request, Response, NextFunction } from "express";
|
||||||
id: number;
|
import { JwtAdapter, JwtPayload } from "../../config/jwt.js";
|
||||||
email: string;
|
import { AuthRepositoryImpl } from "../../data/repositories/auth.repository.impl.js";
|
||||||
role: string;
|
import { CustomError } from "../../domain/errors/custom.error.js";
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuthRequest extends Request {
|
export interface AuthRequest extends Request {
|
||||||
user?: TokenPayload;
|
user?: JwtPayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AuthMiddleware {
|
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) {
|
// Verificar que el usuario existe en BD
|
||||||
return res.status(401).json({
|
const repository = new AuthRepositoryImpl();
|
||||||
error: 'No token provided'
|
const user = await repository.findById(payload.id);
|
||||||
});
|
if (!user) {
|
||||||
}
|
throw CustomError.unauthorized("User not found");
|
||||||
|
}
|
||||||
|
|
||||||
if (!authorization.startsWith('Bearer ')) {
|
// Agregar el usuario al request
|
||||||
return res.status(401).json({
|
req.user = payload;
|
||||||
error: 'Invalid Bearer token'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = authorization.split(' ').at(1) ?? '';
|
next();
|
||||||
|
} catch (error) {
|
||||||
const payload = JwtAdapter.validateToken<TokenPayload>(token);
|
if (error instanceof CustomError) {
|
||||||
|
return res.status(error.statusCode).json({ error: error.message });
|
||||||
if (!payload) {
|
}
|
||||||
return res.status(401).json({
|
res.status(500).json({ error: "Internal server error" });
|
||||||
error: 'Invalid token'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
req.user = payload;
|
|
||||||
|
|
||||||
next();
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -11,12 +11,45 @@ export default function Layout() {
|
|||||||
tabBarInactiveTintColor: "#9CA3AF",
|
tabBarInactiveTintColor: "#9CA3AF",
|
||||||
|
|
||||||
tabBarStyle: {
|
tabBarStyle: {
|
||||||
height: 70,
|
position: "absolute",
|
||||||
paddingBottom: 10,
|
bottom: 5,
|
||||||
paddingTop: 10,
|
left: 20,
|
||||||
|
right: 20,
|
||||||
|
|
||||||
|
height: 82,
|
||||||
|
|
||||||
|
borderRadius: 25,
|
||||||
|
|
||||||
backgroundColor: "#FFFFFF",
|
backgroundColor: "#FFFFFF",
|
||||||
|
|
||||||
borderTopWidth: 0,
|
borderTopWidth: 0,
|
||||||
elevation: 8,
|
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: {
|
||||||
|
width: 0,
|
||||||
|
height: 10,
|
||||||
|
},
|
||||||
|
|
||||||
|
shadowOpacity: 0.08,
|
||||||
|
shadowRadius: 10,
|
||||||
|
|
||||||
|
elevation: 10,
|
||||||
|
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
},
|
||||||
|
|
||||||
|
tabBarLabelStyle: {
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: "400",
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
|
||||||
|
tabBarItemStyle: {
|
||||||
|
paddingTop: 6,
|
||||||
|
paddingBottom: 4,
|
||||||
|
borderRadius: 18,
|
||||||
|
marginVertical: 6,
|
||||||
|
marginHorizontal: 4,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -24,8 +57,12 @@ export default function Layout() {
|
|||||||
name="index"
|
name="index"
|
||||||
options={{
|
options={{
|
||||||
title: "Inicio",
|
title: "Inicio",
|
||||||
tabBarIcon: ({ color, size }) => (
|
tabBarIcon: ({ focused, color }) => (
|
||||||
<Ionicons name="home-outline" size={size} color={color} />
|
<Ionicons
|
||||||
|
name={focused ? "home" : "home-outline"}
|
||||||
|
size={focused ? 28 : 24}
|
||||||
|
color={color}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -34,8 +71,12 @@ export default function Layout() {
|
|||||||
name="alerts"
|
name="alerts"
|
||||||
options={{
|
options={{
|
||||||
title: "Alertas",
|
title: "Alertas",
|
||||||
tabBarIcon: ({ color, size }) => (
|
tabBarIcon: ({ focused, color }) => (
|
||||||
<Ionicons name="notifications-outline" size={size} color={color} />
|
<Ionicons
|
||||||
|
name={focused ? "notifications" : "notifications-outline"}
|
||||||
|
size={focused ? 28 : 24}
|
||||||
|
color={color}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -44,8 +85,12 @@ export default function Layout() {
|
|||||||
name="guide"
|
name="guide"
|
||||||
options={{
|
options={{
|
||||||
title: "Guía",
|
title: "Guía",
|
||||||
tabBarIcon: ({ color, size }) => (
|
tabBarIcon: ({ focused, color }) => (
|
||||||
<Ionicons name="leaf-outline" size={size} color={color} />
|
<Ionicons
|
||||||
|
name={focused ? "leaf" : "leaf-outline"}
|
||||||
|
size={focused ? 28 : 24}
|
||||||
|
color={color}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -54,8 +99,12 @@ export default function Layout() {
|
|||||||
name="profile"
|
name="profile"
|
||||||
options={{
|
options={{
|
||||||
title: "Perfil",
|
title: "Perfil",
|
||||||
tabBarIcon: ({ color, size }) => (
|
tabBarIcon: ({ focused, color }) => (
|
||||||
<Ionicons name="person-outline" size={size} color={color} />
|
<Ionicons
|
||||||
|
name={focused ? "person" : "person-outline"}
|
||||||
|
size={focused ? 28 : 24}
|
||||||
|
color={color}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export default function GuideScreen() {
|
|||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text>Guía</Text>
|
<Text>Guía</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/*import { View, Text, ScrollView } from "react-native";
|
import { View, Text } from "react-native";
|
||||||
|
|
||||||
export default function HomeScreen() {
|
export default function HomeScreen() {
|
||||||
return (
|
return (
|
||||||
@@ -12,91 +12,4 @@ export default function HomeScreen() {
|
|||||||
<Text>Inicio</Text>
|
<Text>Inicio</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}*/
|
|
||||||
|
|
||||||
import { View, ScrollView } from 'react-native';
|
|
||||||
|
|
||||||
import EtaCard from '../components/EtaCard';
|
|
||||||
import QuickAction from '../components/QuickAction';
|
|
||||||
import SectionTitle from '../components/SectionTitle';
|
|
||||||
import AlertItem from '../components/Alertltem';
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
return (
|
|
||||||
<ScrollView
|
|
||||||
style={{ flex: 1, backgroundColor: '#ffffff' }}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
>
|
|
||||||
|
|
||||||
{/*ETA*/}
|
|
||||||
<EtaCard
|
|
||||||
minutes={12}
|
|
||||||
status="Camión en ruta 🚛"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/*ACCIONES RÁPIDAS*/}
|
|
||||||
<SectionTitle title="Acciones rápidas" />
|
|
||||||
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'space-around',
|
|
||||||
paddingHorizontal: 10,
|
|
||||||
marginTop: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<QuickAction title="Mapa" icon="🗺️" />
|
|
||||||
<QuickAction title="Reportar" icon="🚨" />
|
|
||||||
<QuickAction title="Horarios" icon="🕒" />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/*IMPACTO SEMANAL*/}
|
|
||||||
<SectionTitle title="Impacto semanal" />
|
|
||||||
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
marginHorizontal: 15,
|
|
||||||
backgroundColor: '#F0F0F3',
|
|
||||||
padding: 20,
|
|
||||||
borderRadius: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AlertItem
|
|
||||||
message="🚛 5 recolecciones completadas"
|
|
||||||
type="success"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AlertItem
|
|
||||||
message="⏱ Sin retrasos esta semana"
|
|
||||||
type="success"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AlertItem
|
|
||||||
message="📍 Cobertura completa en tu zona"
|
|
||||||
type="success"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 🔔 ALERTAS RECIENTES */}
|
|
||||||
<SectionTitle title="Alertas recientes" />
|
|
||||||
|
|
||||||
<View style={{ marginBottom: 40 }}>
|
|
||||||
<AlertItem
|
|
||||||
message="🚛 Ruta iniciada"
|
|
||||||
type="success"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AlertItem
|
|
||||||
message="📍 Camión cerca de tu zona"
|
|
||||||
type="warning"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AlertItem
|
|
||||||
message="⏳ Retraso detectado"
|
|
||||||
type="danger"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -4,12 +4,12 @@ export default function ProfileScreen() {
|
|||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text>Perfil</Text>
|
<Text>Perfil</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user