This commit is contained in:
David Arranz 2025-02-01 22:48:13 +01:00
parent 3568e7e438
commit 350b8a8422
76 changed files with 1224 additions and 293 deletions

View File

@ -0,0 +1,46 @@
{
"root": true,
"env": {
"browser": false,
"es6": true,
"node": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:jest/recommended",
"prettier"
],
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint", "sort-class-members"],
"rules": {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-inferrable-types": "off",
"@typescript-eslint/no-empty-interface": "off",
"@typescript-eslint/recommended-requiring-type-checking": "off",
"@typescript-eslint/no-unused-vars": "warn",
"lines-between-class-members": ["error", "always", { "exceptAfterSingleLine": true }],
"sort-class-members/sort-class-members": [
2,
{
"order": [
"[static-properties]",
"[static-methods]",
"[conventional-private-properties]",
"[properties]",
"constructor",
"[methods]",
"[conventional-private-methods]"
],
"accessorPairPositioning": "getThenSet"
}
]
},
"overrides": [
{
"files": ["**/*.test.ts"],
"env": { "jest": true, "node": true }
}
]
}

View File

@ -23,6 +23,7 @@
"@types/jest": "^29.5.14",
"@types/jsonwebtoken": "^9.0.8",
"@types/node": "^22.10.7",
"@types/passport": "^1.0.17",
"@types/passport-jwt": "^4.0.1",
"@types/response-time": "^2.3.8",
"@typescript-eslint/eslint-plugin": "^8.22.0",
@ -42,6 +43,7 @@
"esbuild": "^0.24.0",
"express": "^4.21.2",
"helmet": "^8.0.0",
"http-status": "^2.1.0",
"jsonwebtoken": "^9.0.2",
"mariadb": "^3.4.0",
"module-alias": "^2.2.3",

View File

@ -1,13 +1,15 @@
import { errorHandler } from "@common/presentation";
import dotenv from "dotenv";
import express, { Application } from "express";
import helmet from "helmet";
import responseTime from "response-time";
import { authRoutes } from "./express";
import { v1Routes } from "./routes";
dotenv.config();
export function createApp(): Application {
const app = express();
app.set("port", process.env.PORT ?? 3002);
// secure apps by setting various HTTP headers
app.use(helmet());
@ -18,13 +20,16 @@ export function createApp(): Application {
app.use(express.text());
app.use(express.urlencoded({ extended: true }));
// set up the response-time middleware
app.use(responseTime());
app.use(responseTime()); // set up the response-time middleware
app.set("port", process.env.PORT ?? 3002);
// Inicializar Passport
app.use(passport.initialize());
// Registrar rutas del módulo de autenticación
app.use("/api/auth", authRoutes);
// Registrar rutas de la API
app.use("/api/v1", v1Routes());
// Gestión global de errores
app.use(errorHandler);
return app;
}

View File

@ -0,0 +1,8 @@
import { Result } from "./result";
export interface IAggregateRootRepository<T> {
findById(id: string): Promise<Result<T, Error>>;
create(entity: T): Promise<Result<void, Error>>;
update(entity: T): Promise<Result<void, Error>>;
delete(id: string): Promise<Result<void, Error>>;
}

View File

@ -0,0 +1,41 @@
import { DomainEntity } from "./domain-entity";
import { IDomainEvent } from "./events";
export abstract class AggregateRoot<T extends object> extends DomainEntity<T> {
private _domainEvents: IDomainEvent[] = [];
private logDomainEventAdded(event: IDomainEvent): void {
const thisClass = Reflect.getPrototypeOf(this);
const domainEventClass = Reflect.getPrototypeOf(event);
console.info(
`[Domain Event Created]:`,
thisClass?.constructor.name,
"==>",
domainEventClass?.constructor.name
);
}
/**
* 🔹 Agregar un evento de dominio al agregado
*/
protected addDomainEvent(event: IDomainEvent): void {
this._domainEvents.push(event);
// Log the domain event
this.logDomainEventAdded(event);
}
/**
* 🔹 Obtener los eventos de dominio pendientes
*/
get domainEvents(): IDomainEvent[] {
return this._domainEvents;
}
/**
* 🔹 Limpiar la lista de eventos después de procesarlos
*/
public clearDomainEvents(): void {
this._domainEvents.splice(0, this._domainEvents.length);
}
}

View File

@ -0,0 +1,37 @@
import { UniqueID } from "./value-objects/unique-id";
export abstract class DomainEntity<T extends object> {
protected readonly _id: UniqueID;
protected readonly _props: T;
protected constructor(props: T, id?: UniqueID) {
this._id = id ? id : UniqueID.generateNewID().data;
this._props = props;
}
protected _flattenProps(props: T): { [s: string]: any } {
return Object.entries(props).reduce((result: any, [key, valueObject]) => {
console.log(key, valueObject.value);
result[key] = valueObject.value;
return result;
}, {});
}
get id(): UniqueID {
return this._id;
}
equals(other: DomainEntity<T>): boolean {
return other instanceof DomainEntity && this.id.equals(other.id);
}
toString(): { [s: string]: string } {
const flattenProps = this._flattenProps(this._props);
return {
id: this._id.toString(),
...flattenProps.map((prop: any) => String(prop)),
};
}
}

View File

@ -0,0 +1,3 @@
export interface IHandle<IDomainEvent> {
setupSubscriptions(): void;
}

View File

@ -0,0 +1,7 @@
import { UniqueID } from "../value-objects/unique-id";
export interface IDomainEvent {
eventName: string; // Nombre del evento
aggregateId: UniqueID; // ID del agregado que generó el evento
occurredAt: Date; // Fecha y hora del evento
}

View File

@ -0,0 +1,135 @@
// https://khalilstemmler.com/articles/typescript-domain-driven-design/chain-business-logic-domain-events/
import { AggregateRoot } from "../aggregate-root";
import { UniqueID } from "../value-objects/unique-id";
import { IDomainEvent } from "./domain-event.interface";
export class DomainEvents {
private static handlersMap: { [key: string]: Array<(event: IDomainEvent) => void> } = {};
private static markedAggregates: AggregateRoot<any>[] = [];
/**
* @method markAggregateForDispatch
* @static
* @desc Called by aggregate root objects that have created domain
* events to eventually be dispatched when the infrastructure commits
* the unit of work.
*/
public static markAggregateForDispatch(aggregate: AggregateRoot<any>): void {
const aggregateFound = !!this.findMarkedAggregateByID(aggregate.id);
if (!aggregateFound) {
this.markedAggregates.push(aggregate);
}
}
/**
* @method dispatchAggregateEvents
* @static
* @private
* @desc Call all of the handlers for any domain events on this aggregate.
*/
private static dispatchAggregateEvents(aggregate: AggregateRoot<any>): void {
aggregate.domainEvents.forEach((event: IDomainEvent) => this.dispatch(event));
}
/**
* @method removeAggregateFromMarkedDispatchList
* @static
* @desc Removes an aggregate from the marked list.
*/
private static removeAggregateFromMarkedDispatchList(aggregate: AggregateRoot<any>): void {
const index = this.markedAggregates.findIndex((a) => a.equals(aggregate));
this.markedAggregates.splice(index, 1);
}
/**
* @method findMarkedAggregateByID
* @static
* @desc Finds an aggregate within the list of marked aggregates.
*/
private static findMarkedAggregateByID(id: UniqueID): AggregateRoot<any> {
let found!: AggregateRoot<any>;
for (let aggregate of this.markedAggregates) {
if (aggregate.id.equals(id)) {
found = aggregate;
}
}
return found;
}
/**
* @method dispatchEventsForAggregate
* @static
* @desc When all we know is the ID of the aggregate, call this
* in order to dispatch any handlers subscribed to events on the
* aggregate.
*/
public static dispatchEventsForAggregate(id: UniqueID): void {
const aggregate = this.findMarkedAggregateByID(id);
if (aggregate) {
this.dispatchAggregateEvents(aggregate);
aggregate.clearDomainEvents();
this.removeAggregateFromMarkedDispatchList(aggregate);
}
}
/**
* @method register
* @static
* @desc Register a handler to a domain event.
*/
public static register(callback: (event: IDomainEvent) => void, eventClassName: string): void {
if (!this.handlersMap.hasOwnProperty(eventClassName)) {
this.handlersMap[eventClassName] = [];
}
this.handlersMap[eventClassName].push(callback);
}
/**
* @method clearHandlers
* @static
* @desc Useful for testing.
*/
public static clearHandlers(): void {
this.handlersMap = {};
}
/**
* @method clearMarkedAggregates
* @static
* @desc Useful for testing.
*/
public static clearMarkedAggregates(): void {
this.markedAggregates = [];
}
/**
* @method dispatch
* @static
* @desc Invokes all of the subscribers to a particular domain event.
*/
private static dispatch(event: IDomainEvent): void {
const eventClassName: string = event.constructor.name;
if (this.handlersMap.hasOwnProperty(eventClassName)) {
const handlers: any[] = this.handlersMap[eventClassName];
for (let handler of handlers) {
handler(event);
}
}
}
}

View File

@ -0,0 +1,2 @@
export * from "./domain-event";
export * from "./domain-event.interface";

View File

@ -0,0 +1,6 @@
export * from "./aggregate-root";
export * from "./aggregate-root-repository.interface";
export * from "./domain-entity";
export * from "./events/domain-event.interface";
export * from "./result";
export * from "./value-objects";

View File

@ -1,3 +1,2 @@
export * from "./result";
export * from "./unique-id";
export * from "./value-object";

View File

@ -1,4 +1,4 @@
import { UniqueID } from "./unique-id";
import { UniqueID } from "./value-objects/unique-id";
describe("UniqueID Value Object", () => {
it("should generate a new UUID using generateNewID()", () => {

View File

@ -1,6 +1,6 @@
import { v4 as uuidv4 } from "uuid";
import { z } from "zod";
import { Result } from "./result";
import { Result } from "../result";
import { ValueObject } from "./value-object";
const UUIDSchema = z.string().uuid({ message: "Invalid UUID format" });

View File

@ -0,0 +1,2 @@
export * from "./transaction-manager";
export * from "./transaction-manager.interface";

View File

@ -0,0 +1,27 @@
export interface ITransactionManager {
/**
* 🔹 Inicia una transacción
*/
start(): Promise<void>;
/**
* 🔹 Obtiene la transacción activa
*/
getTransaction(): any;
/**
* 🔹 Ejecuta un bloque de código dentro de una transacción
* Si algo falla, se hace rollback automáticamente.
*/
execute<T>(work: (transaction: any) => Promise<T>): Promise<T>;
/**
* 🔹 Confirma la transacción
*/
commit(): Promise<void>;
/**
* 🔹 Revierte la transacción
*/
rollback(): Promise<void>;
}

View File

@ -0,0 +1,60 @@
import { ITransactionManager } from "./transaction-manager.interface";
export abstract class TransactionManager implements ITransactionManager {
protected _transaction: any | null = null;
/**
* 🔹 Inicia una transacción si no hay una activa
*/
async start(): Promise<void> {
if (!this._transaction) {
this._transaction = await this._startTransaction();
}
}
/**
* 🔹 Devuelve la transacción activa
*/
getTransaction(): any {
if (!this._transaction) {
throw new Error("No active transaction. Call start() first.");
}
return this._transaction;
}
/**
* 🔹 Ejecuta una función dentro de una transacción
*/
async execute<T>(work: (transaction: any) => Promise<T>): Promise<T> {
await this.start();
try {
const result = await work(this.getTransaction());
await this.commit();
return result;
} catch (error) {
await this.rollback();
throw error;
}
}
/**
* 🔹 Métodos abstractos para manejar transacciones
*/
protected abstract _startTransaction(): Promise<any>;
protected abstract _commitTransaction(): Promise<void>;
protected abstract _rollbackTransaction(): Promise<void>;
async commit(): Promise<void> {
if (this._transaction) {
await this._commitTransaction();
this._transaction = null;
}
}
async rollback(): Promise<void> {
if (this._transaction) {
await this._rollbackTransaction();
this._transaction = null;
}
}
}

View File

@ -0,0 +1 @@
export * from "./passport";

View File

@ -0,0 +1,27 @@
import { authUserRepository } from "@contexts/auth/infraestructure";
import passport from "passport";
import { ExtractJwt, Strategy as JwtStrategy } from "passport-jwt";
const SECRET_KEY = process.env.JWT_SECRET || "supersecretkey";
// Configuración de la estrategia JWT
const jwtOptions = {
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: SECRET_KEY,
};
passport.use(
new JwtStrategy(jwtOptions, async (payload, done) => {
try {
const userResult = await authUserRepository.findById(payload.userId);
if (userResult.isError()) {
return done(null, false);
}
return done(null, userResult.data);
} catch (error) {
return done(error, false);
}
})
);
export default passport;

View File

@ -0,0 +1,66 @@
import { IAggregateRootRepository, Result } from "@common/domain";
import { Transaction } from "sequelize";
export abstract class SequelizeRepository<T> implements IAggregateRootRepository<T> {
/**
* 🔹 Convertir un modelo de Sequelize en un agregado del dominio
* Cada repositorio concreto debe implementar este método.
*/
protected abstract toDomain(entity: any): Result<T, Error>;
/**
* 🔹 Convertir un agregado del dominio en datos listos para persistir
*/
protected abstract toPersistence(aggregate: T): any;
/**
* 🔹 Buscar por ID y devolver el agregado
*/
async findById(id: string, transaction?: Transaction): Promise<Result<T, Error>> {
const entity = await this._findById(id, transaction);
if (!entity) {
return Result.fail(new Error("Entity not found"));
}
return this.toDomain(entity);
}
/**
* 🔹 Crear un nuevo agregado en la BD
*/
async create(aggregate: T, transaction?: Transaction): Promise<Result<void, Error>> {
const data = this.toPersistence(aggregate);
await this._create(data, transaction);
return Result.ok();
}
/**
* 🔹 Actualizar un agregado en la BD
*/
async update(aggregate: T, transaction?: Transaction): Promise<Result<void, Error>> {
const data = this.toPersistence(aggregate);
const updated = await this._update(data.id, data, transaction);
if (!updated) {
return Result.fail(new Error("Failed to update entity"));
}
return Result.ok();
}
/**
* 🔹 Eliminar un agregado de la BD
*/
async delete(id: string, transaction?: Transaction): Promise<Result<void, Error>> {
const deleted = await this._delete(id, transaction);
if (!deleted) {
return Result.fail(new Error("Failed to delete entity"));
}
return Result.ok();
}
/**
* 🔹 Métodos privados que deben ser implementados en la infraestructura
*/
protected abstract _findById(id: string, transaction?: Transaction): Promise<any>;
protected abstract _create(data: any, transaction?: Transaction): Promise<void>;
protected abstract _update(id: string, data: any, transaction?: Transaction): Promise<boolean>;
protected abstract _delete(id: string, transaction?: Transaction): Promise<boolean>;
}

View File

@ -0,0 +1,21 @@
import { sequelize } from "@config/database";
import { Transaction } from "sequelize";
import { TransactionManager } from "../database";
export class SequelizeTransactionManager extends TransactionManager {
protected async _startTransaction(): Promise<Transaction> {
return await sequelize.transaction();
}
protected async _commitTransaction(): Promise<void> {
if (this._transaction) {
await this._transaction.commit();
}
}
protected async _rollbackTransaction(): Promise<void> {
if (this._transaction) {
await this._transaction.rollback();
}
}
}

View File

@ -0,0 +1,37 @@
interface IApiErrorOptions {
status: number;
title: string;
detail: string;
type?: string;
instance?: string;
errors?: any[];
[key: string]: any; // Para permitir añadir campos extra
}
export class ApiError extends Error {
public status: number;
public title: string;
public detail: string;
public type: string;
public instance?: string;
public errors?: any[];
public timestamp: string;
constructor(options: IApiErrorOptions) {
super(options.title);
// Asegura que la instancia sea reconocida correctamente como ApiError
Object.setPrototypeOf(this, ApiError.prototype);
// Campos obligatorios
this.status = options.status;
this.title = options.title;
this.detail = options.detail;
this.timestamp = new Date().toISOString();
// Campos opcionales con valores por defecto
this.type = options.type ?? "about:blank";
this.instance = options.instance;
this.errors = options.errors;
}
}

View File

@ -0,0 +1,63 @@
import { NextFunction, Request, Response } from "express";
import httpStatus from "http-status";
export abstract class ExpressController {
protected req!: Request;
protected res!: Response;
protected next!: NextFunction;
protected abstract executeImpl(): Promise<void | any>;
public execute(req: Request, res: Response, next: NextFunction): void {
this.req = req;
this.res = res;
this.next = next;
this.executeImpl();
}
protected ok<T>(dto?: T) {
return dto ? this.res.status(httpStatus.OK).json(dto) : this.res.status(httpStatus.OK).send();
}
protected fail(error: string | Error) {
console.error("ExpressController FAIL:", error);
return this.res
.status(httpStatus.INTERNAL_SERVER_ERROR)
.json({ message: error instanceof Error ? error.message : error });
}
protected created<T>(dto?: T) {
return dto
? this.res.status(httpStatus.CREATED).json(dto)
: this.res.status(httpStatus.CREATED).send();
}
protected noContent() {
return this.res.status(httpStatus.NO_CONTENT).send();
}
protected clientError(message?: string) {
return this.res.status(httpStatus.BAD_REQUEST).json({ message });
}
protected unauthorizedError(message?: string) {
return this.res.status(httpStatus.UNAUTHORIZED).json({ message });
}
protected forbiddenError(message?: string) {
return this.res.status(httpStatus.FORBIDDEN).json({ message });
}
protected notFoundError(message?: string) {
return this.res.status(httpStatus.NOT_FOUND).json({ message });
}
protected conflictError(message?: string) {
return this.res.status(httpStatus.CONFLICT).json({ message });
}
protected invalidInputError(message?: string) {
return this.res.status(httpStatus.UNPROCESSABLE_ENTITY).json({ message });
}
}

View File

@ -0,0 +1,4 @@
export * from "./api-error";
export * from "./express-controller";
export * from "./middlewares";
export * from "./validate-request";

View File

@ -0,0 +1,31 @@
import { NextFunction, Request, Response } from "express";
import { ApiError } from "../api-error";
export const errorHandler = (err: any, req: Request, res: Response, next: NextFunction) => {
// Si ya se envió una respuesta, delegamos al siguiente error handler
if (res.headersSent) {
return next(err);
}
// Verifica si el error es una instancia de ApiError
if (err instanceof ApiError) {
// Respuesta con formato RFC 7807
return res.status(err.status).json({
type: err.type,
title: err.title,
status: err.status,
detail: err.detail,
instance: err.instance ?? req.originalUrl,
errors: err.errors ?? [], // Aquí puedes almacenar validaciones, etc.
});
}
// Si no es un ApiError, lo tratamos como un error interno (500)
return res.status(500).json({
type: "https://example.com/probs/internal-server-error",
title: "Internal Server Error",
status: 500,
detail: err.message || "Ha ocurrido un error inesperado.",
instance: req.originalUrl,
});
};

View File

@ -0,0 +1 @@
export * from "./error-handler";

View File

@ -0,0 +1,29 @@
import { NextFunction, Request, Response } from "express";
import httpStatus from "http-status";
import { ZodSchema } from "zod";
import { ApiError } from "./api-error";
export const validateRequest =
(schema: ZodSchema) => (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.body);
if (!result.success) {
// Construye errores detallados
const validationErrors = result.error.errors.map((err) => ({
field: err.path.join("."),
message: err.message,
}));
throw new ApiError({
status: httpStatus.BAD_REQUEST, //400
title: "Validation Error",
detail: "Algunos campos no cumplen con los criterios de validación.",
type: "https://example.com/probs/validation-error",
instance: req.originalUrl,
errors: validationErrors,
});
}
// Si pasa la validación, opcionalmente reescribe req.body
req.body = result.data;
next();
};

View File

@ -0,0 +1 @@
export * from "./express";

View File

@ -1,5 +1,5 @@
import { Sequelize } from "sequelize";
import dotenv from "dotenv";
import { Sequelize } from "sequelize";
dotenv.config();
@ -11,16 +11,22 @@ export const sequelize = new Sequelize(
host: process.env.DB_HOST as string,
dialect: "mariadb",
port: parseInt(process.env.DB_PORT || "3306", 10),
logging: false,
},
logging: process.env.DB_LOGGING === "true" ? console.log : false,
pool: {
max: 10,
min: 0,
acquire: 30000,
idle: 10000,
},
}
);
export async function connectToDatabase(): Promise<void> {
try {
await sequelize.authenticate();
console.log("Conexión a MariaDB establecida correctamente.");
console.log("✅ Database connection established successfully.");
} catch (error) {
console.error("Error al conectar a la base de datos:", error);
console.error("❌ Unable to connect to the database:", error);
process.exit(1);
}
}

View File

@ -0,0 +1,14 @@
import { Result } from "@common/domain";
import { AuthenticatedUser, EmailAddress, PasswordHash, Username } from "../domain";
export interface IAuthService {
/**
* 🔹 Registra un nuevo usuario en el sistema.
* Si el email ya existe, devuelve un error.
*/
registerUser(
username: Username,
email: EmailAddress,
password: PasswordHash
): Promise<Result<AuthenticatedUser, Error>>;
}

View File

@ -1,43 +1,47 @@
import { EmailAddress, PasswordHash, Username } from "contexts/auth/domain";
import { authUserRepository } from "contexts/auth/infraestructure/sequelize";
import { Result, UniqueID } from "contexts/common/domain";
import { Result, UniqueID } from "@common/domain";
import { ITransactionManager } from "@common/infrastructure/database";
import {
EmailAddress,
IAuthenticatedUserRepository,
PasswordHash,
Username,
} from "@contexts/auth/domain";
import { IAuthService } from "./auth-service.interface";
export class AuthService implements IAuthService {
private _respository!: IAuthenticatedUserRepository;
private readonly _transactionManager!: ITransactionManager;
constructor(repository: IAuthenticatedUserRepository, transactionManager: ITransactionManager) {
this._respository = repository;
this._transactionManager = transactionManager;
}
export class AuthService {
/**
* 🔹 `registerUser`
* Registra un nuevo usuario en la base de datos bajo transacción.
*/
static async registerUser(
username: string,
email: string,
password: string
): Promise<Result<{ userId: string }, Error>> {
return await authUserRepository.executeTransaction(async (transaction) => {
async registerUser(params: {
username: Username;
email: EmailAddress;
password: PasswordHash;
}): Promise<Result<{ userId: string }, Error>> {
return await this._transactionManager.execute(async (transaction) => {
const { username, email, password } = params;
const userIdResult = UniqueID.generateNewID();
const usernameResult = Username.create(username);
const emailResult = EmailAddress.create(email);
const passwordResult = await PasswordHash.create(password);
const combined = Result.combine([userIdResult, usernameResult, emailResult, passwordResult]);
if (combined.isError()) {
return Result.fail(combined.error);
}
// Verificar si el usuario ya existe
const userExists = await authUserRepository.userExists(
emailResult.data.getValue(),
transaction
);
const userExists = await this._respository.userExists(email.toString(), transaction);
if (userExists) {
return Result.fail(new Error("Email is already registered"));
}
const user = await authUserRepository.createUser(
const user = await this._respository.createUser(
{
id: userIdResult.data.getValue(),
username: usernameResult.data.getValue(),
email: emailResult.data.getValue(),
password: passwordResult.data.getValue(),
id: userIdResult,
username: username,
email: email,
password: password,
isActive: true,
},
transaction

View File

@ -1 +1,11 @@
export * from "./auth.service";
export * from "./auth-service.interface";
import { ITransactionManager } from "@common/infrastructure/database";
import { SequelizeTransactionManager } from "@common/infrastructure/sequelize/sequelize-transaction-manager";
import { AuthenticatedUserRepository } from "../infraestructure";
import { AuthService } from "./auth.service";
const transactionManager: ITransactionManager = new SequelizeTransactionManager();
const authenticatedUserRepository = new AuthenticatedUserRepository();
const authService = new AuthService(authenticatedUserRepository, transactionManager);
export { authService };

View File

@ -0,0 +1,40 @@
import { AggregateRoot, Result, UniqueID } from "@common/domain";
import { UserAuthenticatedEvent } from "../events";
import { EmailAddress, Username } from "../value-objects";
export interface IAuthenticatedUserProps {
username: Username;
email: EmailAddress;
roles: string[];
token: string;
}
export class AuthenticatedUser extends AggregateRoot<IAuthenticatedUserProps> {
static create(props: IAuthenticatedUserProps, id?: UniqueID): Result<AuthenticatedUser, Error> {
const { username, email, roles, token } = props;
if (!id || !username || !email || !token) {
return Result.fail(new Error("Invalid authenticated user data"));
}
const user = new AuthenticatedUser({ username, email, roles, token }, id);
// 🔹 Disparar evento de dominio "UserAuthenticatedEvent"
user.addDomainEvent(new UserAuthenticatedEvent(id, email.toString()));
return Result.ok(user);
}
/**
* 🔹 Devuelve una representación lista para persistencia
*/
public toPersistenceData(): any {
return {
id: this._id.toString(),
username: this._props.username.toString(),
email: this._props.email.toString(),
roles: JSON.stringify(this._props.roles),
token: this._props.token,
};
}
}

View File

@ -0,0 +1 @@
export * from "./authenticated-user";

View File

@ -1,47 +0,0 @@
import { Result } from "contexts/common/domain";
import { EmailAddress, PasswordHash, Username, UserRoles } from "./value-objects";
export class AuthUser {
private constructor(
public readonly id: number | null,
public readonly username: Username,
public readonly email: EmailAddress,
private password: PasswordHash,
public readonly roles: UserRoles,
public readonly isActive: boolean
) {}
static async create(
id: number | null,
username: string,
email: string | null,
plainPassword: string,
roles: string[],
isActive: boolean
): Promise<Result<AuthUser, Error>> {
const usernameResult = Username.create(username);
const emailResult = EmailAddress.create(email);
const passwordResult = await PasswordHash.create(plainPassword);
const rolesResult = UserRoles.create(roles);
const combined = Result.combine([usernameResult, emailResult, passwordResult, rolesResult]);
if (combined.isError()) {
return Result.fail(combined.error);
}
return Result.ok(
new AuthUser(
id,
usernameResult.data,
emailResult.data,
passwordResult.data,
rolesResult.data,
isActive
)
);
}
async validatePassword(plainPassword: string): Promise<boolean> {
return await this.password.compare(plainPassword);
}
}

View File

@ -0,0 +1 @@
export * from "./user-authenticated.event";

View File

@ -0,0 +1,13 @@
import { IDomainEvent, UniqueID } from "@common/domain";
export class UserAuthenticatedEvent implements IDomainEvent {
public readonly eventName = "UserAuthenticated";
public readonly occurredAt: Date;
constructor(
public readonly aggregateId: UniqueID,
public readonly email: string // Email en formato string
) {
this.occurredAt = new Date();
}
}

View File

@ -1,2 +1,5 @@
export * from "./auth-user.model";
export * from "./aggregates/authenticated-user";
export * from "./auth-user.entity";
export * from "./events/user-authenticated.event";
export * from "./repositories";
export * from "./value-objects";

View File

@ -0,0 +1 @@
export interface IAuthenticatedUserRepository {}

View File

@ -0,0 +1 @@
export * from "./authenticated-user-repository.interface";

View File

@ -1,4 +1,4 @@
import { Result, ValueObject } from "contexts/common/domain";
import { Result, ValueObject } from "@common/domain";
import { z } from "zod";
const RoleSchema = z.enum(["Admin", "User", "Manager", "Editor"]);

View File

@ -1,4 +1,4 @@
import { Result, ValueObject } from "contexts/common/domain";
import { Result, ValueObject } from "@common/domain";
import { z } from "zod";
export class EmailAddress extends ValueObject<string | null> {

View File

@ -1,5 +1,5 @@
import { Result, ValueObject } from "@common/domain";
import bcrypt from "bcrypt";
import { Result, ValueObject } from "contexts/common/domain";
import { z } from "zod";
const PasswordSchema = z

View File

@ -1,4 +1,4 @@
import { Result, ValueObject } from "contexts/common/domain";
import { Result, ValueObject } from "@common/domain";
import { z } from "zod";
export class Username extends ValueObject<string> {

View File

@ -0,0 +1,3 @@
export * from "./jwt.helper";
export * from "./sequelize";
12;

View File

@ -0,0 +1,13 @@
import jwt from "jsonwebtoken";
const SECRET_KEY = process.env.JWT_SECRET || "supersecretkey";
export class JwtHelper {
static generateToken(payload: object, expiresIn = "1h"): string {
return jwt.sign(payload, SECRET_KEY, { expiresIn });
}
static verifyToken(token: string): any {
return jwt.verify(token, SECRET_KEY);
}
}

View File

@ -0,0 +1,14 @@
import { Result } from "@common/domain";
import { AuthenticatedUser } from "@contexts/auth/domain";
export interface IAuthenticatedUserMapper {
/**
* 🔹 Convierte una entidad de la base de datos en un agregado de dominio `AuthenticatedUser`
*/
toDomain(entity: any): Result<AuthenticatedUser, Error>;
/**
* 🔹 Convierte un agregado `AuthenticatedUser` en un objeto listo para persistencia
*/
toPersistence(aggregate: AuthenticatedUser): any;
}

View File

@ -0,0 +1,42 @@
import { Result, UniqueID } from "@common/domain";
import { AuthenticatedUser, EmailAddress, Username } from "@contexts/auth/domain";
export class AuthenticatedUserMapper {
/**
* 🔹 Convierte una entidad de la base de datos en un agregado de dominio `AuthenticatedUser`
*/
static toDomain(entity: any): Result<AuthenticatedUser, Error> {
if (!entity) {
return Result.fail(new Error("Entity not found"));
}
// Crear Value Objects asegurando que sean válidos
const uniqueIdResult = UniqueID.create(entity.id);
const usernameResult = Username.create(entity.username);
const emailResult = EmailAddress.create(entity.email);
// Validar que no haya errores en la creación de los Value Objects
const okOrError = Result.combine([uniqueIdResult, usernameResult, emailResult]);
if (okOrError.isError()) {
return okOrError;
}
// Crear el agregado de dominio
return AuthenticatedUser.create(
{
username: usernameResult.data!,
email: emailResult.data!,
roles: entity.roles || [],
token: entity.token,
},
uniqueIdResult.data!
);
}
/**
* 🔹 Convierte un agregado `AuthenticatedUser` en un objeto listo para persistencia
*/
static toPersistence(authenticatedUser: AuthenticatedUser): any {
return authenticatedUser.toPersistenceData();
}
}

View File

@ -0,0 +1,2 @@
export * from "./authenticated-user-mapper.interface";
export * from "./authenticated-user.mapper";

View File

@ -1,72 +0,0 @@
import { authUserRepository } from "./auth-user.repository";
describe("authUserRepository", () => {
beforeEach(() => {
// Resetear la base de datos antes de cada prueba
});
it("should create a user successfully", async () => {
const result = await authUserRepository.createUser({
id: "user-uuid",
username: "testUser",
email: "user@example.com",
password: "hashed-password",
isActive: true,
});
expect(result).toHaveProperty("id");
expect(result.email).toBe("user@example.com");
});
/*it("should find a user by email", async () => {
await authUserRepository.createUser({
id: "user-uuid",
username: "testUser",
email: "user@example.com",
password: "hashed-password",
isActive: true,
});
const user = await authUserRepository.findByEmail("user@example.com");
expect(user.isOk()).toBe(true);
expect(user.data?.getUserID()).toBe("user-uuid");
});
it("should return an error when user is not found", async () => {
const user = await authUserRepository.findByEmail("notfound@example.com");
expect(user.isError()).toBe(true);
expect(user.error.message).toBe("User not found");
});*/
it("should check if a user exists", async () => {
await authUserRepository.createUser({
id: "user-uuid",
username: "testUser",
email: "exists@example.com",
password: "hashed-password",
isActive: true,
});
expect(await authUserRepository.userExists("exists@example.com")).toBe(true);
expect(await authUserRepository.userExists("notfound@example.com")).toBe(false);
});
it("should count active users", async () => {
await authUserRepository.createUser({
id: "1",
username: "user1",
email: "user1@example.com",
password: "pass",
isActive: true,
});
await authUserRepository.createUser({
id: "2",
username: "user2",
email: "user2@example.com",
password: "pass",
isActive: false,
});
expect(await authUserRepository.countActiveUsers()).toBe(1);
});
});

View File

@ -1,42 +0,0 @@
import { SequelizeRepository } from "contexts/common/infraestructure";
import { Transaction } from "sequelize";
import { AuthUserModel } from "./auth-user.model";
class AuthUserRepository extends SequelizeRepository<AuthUserModel> {
constructor() {
super(AuthUserModel);
}
async createUser(
data: { id: string; username: string; email: string; password: string; isActive: boolean },
transaction?: Transaction
): Promise<AuthUserModel> {
return await this.create(data, transaction);
}
async findAllUsers(): Promise<AuthUserModel[]> {
return await this.findAll();
}
async isUserAssociatedWithCompany(
userId: string,
companyId: string,
transaction?: Transaction
): Promise<boolean> {
const association = await AuthUserModel.findOne({
where: { id: userId, companyId },
transaction,
});
return !!association;
}
async userExists(email: string, transaction?: Transaction): Promise<boolean> {
return await this.exists("email", email, transaction);
}
async countActiveUsers(transaction?: Transaction): Promise<number> {
return await this.count({ where: { isActive: true }, transaction });
}
}
export const authUserRepository = new AuthUserRepository();

View File

@ -0,0 +1,76 @@
import { Result, UniqueID } from "@common/domain";
import { SequelizeRepository } from "@common/infrastructure";
import {
AuthenticatedUser,
EmailAddress,
IAuthenticatedUserRepository,
Username,
} from "@contexts/auth/domain";
import { Transaction } from "sequelize";
import { IAuthenticatedUserMapper } from "../mappers";
import { AuthUserModel } from "./auth-user.model";
export class AuthenticatedUserRepository
extends SequelizeRepository<AuthenticatedUser>
implements IAuthenticatedUserRepository
{
private readonly _mapper!: IAuthenticatedUserMapper;
constructor(mapper: IAuthenticatedUserMapper) {
super();
this._mapper = mapper;
}
protected async _findById(id: string, transaction?: Transaction): Promise<any> {
return await AuthUserModel.findByPk(id, { transaction });
}
async create(user: AuthenticatedUser, transaction?: Transaction): Promise<Result<void, Error>> {
const persistenceData = this._mapper.toPersistence(user);
await AuthUserModel.create(persistenceData, { transaction });
return Result.ok();
}
protected async _update(id: string, data: any, transaction?: Transaction): Promise<boolean> {
const [updated] = await AuthUserModel.update(data, { where: { id }, transaction });
return updated > 0;
}
protected async _delete(id: string, transaction?: Transaction): Promise<boolean> {
const deleted = await AuthUserModel.destroy({ where: { id }, transaction });
return deleted > 0;
}
protected toDomain(entity: any): Result<AuthenticatedUser, Error> {
if (!entity) {
return Result.fail(new Error("Entity not found"));
}
// 🔹 Crear los Value Objects manejando errores correctamente
const idOrError = UniqueID.create(entity.id);
const usernameOrError = Username.create(entity.username);
const emailOrError = EmailAddress.create(entity.email);
// 🔹 Si algún Value Object es inválido, devolver el error inmediatamente
const combinedResults = [idOrError, usernameOrError, emailOrError];
for (const result of combinedResults) {
if (result.isError()) {
return Result.fail(result.error);
}
}
// 🔹 Crear las propiedades validadas del agregado
const props = {
username: usernameOrError.data!,
email: emailOrError.data!,
roles: entity.roles || [],
token: entity.token,
};
// 🔹 Crear el agregado manejando errores
return AuthenticatedUser.create(props, idOrError.data!);
}
protected toPersistence(authenticatedUser: AuthenticatedUser): any {
this._mapper.toPersistence(authenticatedUser);
}
}

View File

@ -1,2 +1,2 @@
export * from "./auth-user.model";
export * from "./auth-user.repository";
export * from "./authenticated-user.repository";

View File

@ -0,0 +1,57 @@
import { ExpressController } from "@common/presentation/express/express-controller";
import { Request, Response } from "express";
import { AuthService } from "../application";
import { EmailAddress, PasswordHash, Username } from "../domain";
import { IRegisterUserRequestDTO } from "./dto";
class AuthController extends ExpressController {
protected async executeImpl(): Promise<void> {
this.clientError("Method not implemented");
}
async register(req: Request, res: Response) {
const { username, email, password }: IRegisterUserRequestDTO = req.body;
const emailVO = EmailAddress.create(email);
const usernameVO = Username.create(username);
const passwordVO = await PasswordHash.create(password);
const combined = [emailVO, usernameVO, passwordVO].every((r) => r.isOk());
if (!combined) {
return this.clientError("Invalid input data");
}
const result = await AuthService.registerUser({
username: usernameVO.data,
email: emailVO.data,
password: passwordVO.data,
});
return result.isError()
? this.clientError(result.error.message)
: this.created({ userId: result.data.userId });
}
async login(req: Request, res: Response) {
const { email, password } = req.body;
const result = await AuthService.login(email, password);
return result.isError() ? this.unauthorizedError(result.error.message) : this.ok(result.data);
}
async selectCompany(req: Request, res: Response) {
const userId = (req as any).user.userId;
const { companyId } = req.body;
const result = await AuthService.selectCompany(userId, companyId);
return result.isError() ? this.forbiddenError(result.error.message) : this.ok(result.data);
}
async logout(req: Request, res: Response) {
return this.ok(AuthService.logout());
}
}
export const authController = new AuthController();

View File

@ -0,0 +1,77 @@
import { authController } from "./auth.controller";
import { validateRequest } from "@common/presentation";
import { NextFunction, Request, Response, Router } from "express";
import { registerController } from "./controllers";
import { RegisterUserSchema } from "./dto";
const loggerMiddleware = () => (req: Request, res: Response, next: NextFunction) => {
console.log(`${req.method} ${req.path}`);
next();
};
export const authRouter = (appRouter: Router) => {
const authRoutes: Router = Router({ mergeParams: true });
/**
* @api {post} /api/auth/register Register a new user
* @apiName RegisterUser
* @apiGroup Authentication
* @apiVersion 1.0.0
*
* @apiBody {String} username User's unique username.
* @apiBody {String} email User's email address.
* @apiBody {String} password User's password (minimum 8 characters).
*
* @apiSuccess (201) {String} userId The unique ID of the created user.
*
* @apiError (400) {String} message Error message.
*/
authRoutes.post("/register", validateRequest(RegisterUserSchema), registerController.execute);
/**
* @api {post} /api/auth/login Authenticate a user
* @apiName LoginUser
* @apiGroup Authentication
* @apiVersion 1.0.0
*
* @apiBody {String} email User's email address.
* @apiBody {String} password User's password.
*
* @apiSuccess (200) {String} token JWT authentication token.
* @apiSuccess (200) {String} userId The unique ID of the authenticated user.
*
* @apiError (401) {String} message Invalid email or password.
*/
authRoutes.post("/login", authController.login);
/**
* @api {post} /api/auth/select-company Select an active company
* @apiName SelectCompany
* @apiGroup Authentication
* @apiVersion 1.0.0
*
* @apiHeader {String} Authorization Bearer token.
*
* @apiBody {String} companyId The ID of the company to select.
*
* @apiSuccess (200) {String} message Success message.
*
* @apiError (403) {String} message Unauthorized or invalid company selection.
*/
authRoutes.post("/select-company", authMiddleware, authController.selectCompany);
/**
* @api {post} /api/auth/logout Logout user
* @apiName LogoutUser
* @apiGroup Authentication
* @apiVersion 1.0.0
*
* @apiHeader {String} Authorization Bearer token.
*
* @apiSuccess (200) {String} message Success message.
*/
authRoutes.post("/logout", authMiddleware, authController.logout);
appRouter.use("/auth", authRoutes);
};

View File

@ -0,0 +1 @@
export * from "./register.controller";

View File

@ -0,0 +1,35 @@
import { ExpressController } from "@common/presentation";
import { authService } from "@contexts/auth/application";
import { AuthService } from "@contexts/auth/application/auth.service";
import { EmailAddress, PasswordHash, Username } from "@contexts/auth/domain";
class RegisterController extends ExpressController {
private readonly _authService!: AuthService;
constructor(authService: AuthService) {
super();
this._authService = authService;
}
async executeImpl() {
const emailVO = EmailAddress.create(this.req.body.email);
const usernameVO = Username.create(this.req.body.username);
const passwordVO = await PasswordHash.create(this.req.body.password);
if ([emailVO, usernameVO, passwordVO].some((r) => r.isError())) {
return this.clientError("Invalid input data");
}
const result = await this._authService.registerUser({
username: usernameVO.data,
email: emailVO.data,
password: passwordVO.data,
});
return result.isError()
? this.clientError(result.error.message)
: this.created({ userId: result.data.userId });
}
}
export const registerController = new RegisterController(authService);

View File

@ -0,0 +1,14 @@
export interface IRegisterUserRequestDTO {
username: string;
email: string;
password: string;
}
export interface ILoginUserRequestDTO {
email: string;
password: string;
}
export interface ISelectCompanyRequestDTO {
companyId: string;
}

View File

@ -0,0 +1,31 @@
export interface IRegisterUserDTO {
username: string;
email: string;
password: string;
}
export interface ILoginUserDTO {
email: string;
password: string;
}
export interface ISelectCompanyDTO {
companyId: string;
}
export interface IRegisterUserResponseDTO {
userId: string;
}
export interface ILoginUserResponseDTO {
token: string;
userId: string;
}
export interface ISelectCompanyResponseDTO {
message: string;
}
export interface ILogoutResponseDTO {
message: string;
}

View File

@ -0,0 +1,16 @@
import { z } from "zod";
export const RegisterUserSchema = z.object({
username: z.string().min(3, "Username must be at least 3 characters long"),
email: z.string().email("Invalid email format"),
password: z.string().min(8, "Password must be at least 8 characters long"),
});
export const LoginUserSchema = z.object({
email: z.string().email("Invalid email format"),
password: z.string().min(8, "Password must be at least 8 characters long"),
});
export const SelectCompanySchema = z.object({
companyId: z.string().min(1, "Company ID is required"),
});

View File

@ -0,0 +1,3 @@
export * from "./auth.request.dto";
export * from "./auth.response.dto";
export * from "./auth.validation.dto";

View File

@ -0,0 +1 @@
export * from "./auth.controller";

View File

@ -1,68 +0,0 @@
import { sequelize } from "@config/database";
import { FindOptions, Model, ModelDefined, Transaction } from "sequelize";
export abstract class SequelizeRepository<T extends Model> {
protected readonly model: ModelDefined<T>;
protected constructor(model: ModelDefined<T>) {
this.model = model;
}
async findById(id: string, transaction?: Transaction): Promise<T | null> {
return await this.model.findByPk(id, { transaction });
}
async findOneByField(field: string, value: any, transaction?: Transaction): Promise<T | null> {
return await this.model.findOne({ where: { [field]: value }, transaction });
}
async findAll(filter?: FindOptions, transaction?: Transaction): Promise<T[]> {
return await this.model.findAll({ ...filter, transaction });
}
async create(data: Partial<T>, transaction?: Transaction): Promise<T> {
return await this.model.create(data as any, { transaction });
}
async update(id: string, data: Partial<T>, transaction?: Transaction): Promise<[number, T[]]> {
return await this.model.update(data as any, { where: { id }, returning: true, transaction });
}
async delete(id: string, transaction?: Transaction): Promise<boolean> {
const deleted = await this.model.destroy({ where: { id }, transaction });
return deleted > 0;
}
/**
* 🔹 `exists`
* Verifica si un registro existe en la base de datos basado en un campo y valor.
*/
async exists(field: string, value: any, transaction?: Transaction): Promise<boolean> {
const count = await this.model.count({ where: { [field]: value }, transaction });
return count > 0;
}
/**
* 🔹 `count`
* Cuenta el número de registros que cumplen con una condición.
*/
async count(filter?: FindOptions, transaction?: Transaction): Promise<number> {
return await this.model.count({ ...filter, transaction });
}
/**
* 🔹 `executeTransaction`
* Ejecuta una función dentro de una transacción de Sequelize.
*/
async executeTransaction<R>(operation: (transaction: Transaction) => Promise<R>): Promise<R> {
const transaction = await sequelize.transaction();
try {
const result = await operation(transaction);
await transaction.commit();
return result;
} catch (error) {
await transaction.rollback();
throw error;
}
}
}

View File

@ -1,5 +1,5 @@
import { createApp } from "./app";
import { connectToDatabase } from "./config/database";
import { createApp } from "./infrastructure/app";
const PORT = process.env.PORT || 3000;

View File

@ -1,13 +0,0 @@
import { Router } from "express";
import * as authController from "../../application/auth/auth.controller";
export const authRoutes = () => {
const router = Router();
router.post("/register", authController.register);
router.post("/login", authController.login);
router.post("/select-company", authController.selectCompany);
router.post("/logout", authController.logout);
return router;
};

View File

@ -1 +0,0 @@
export * from "./auth.routes";

View File

@ -1 +0,0 @@
export * from "./app";

View File

@ -0,0 +1 @@
export * from "./v1.routes";

View File

@ -0,0 +1,21 @@
import { Router } from "express";
import { authRouter } from "../contexts/auth/presentation/auth.routes";
export const v1Routes = () => {
const routes = Router({ mergeParams: true });
routes.get("/hello", (req, res) => {
res.send("Hello world!");
});
routes.use((req, res, next) => {
console.log(
`[${new Date().toLocaleTimeString()}] Incoming request ${req.method} to ${req.path}`
);
next();
});
authRouter(routes);
return routes;
};

View File

@ -10,6 +10,7 @@
"paths": {
"@shared/*": ["../../packages/shared/*"],
"@common/*": ["common/*"],
"@contexts/*": ["contexts/*"],
"@config/*": ["config/*"]
}
},

View File

@ -38,6 +38,9 @@ importers:
helmet:
specifier: ^8.0.0
version: 8.0.0
http-status:
specifier: ^2.1.0
version: 2.1.0
jsonwebtoken:
specifier: ^9.0.2
version: 9.0.2
@ -96,6 +99,9 @@ importers:
'@types/node':
specifier: ^22.10.7
version: 22.12.0
'@types/passport':
specifier: ^1.0.17
version: 1.0.17
'@types/passport-jwt':
specifier: ^4.0.1
version: 4.0.1
@ -2092,6 +2098,10 @@ packages:
resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
engines: {node: '>= 0.8'}
http-status@2.1.0:
resolution: {integrity: sha512-O5kPr7AW7wYd/BBiOezTwnVAnmSNFY+J7hlZD2X5IOxVBetjcHAiTXhzj0gMrnojQlwy+UT1/Y3H3vJ3UlmvLA==}
engines: {node: '>= 0.4.0'}
https-proxy-agent@5.0.1:
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
engines: {node: '>= 6'}
@ -5860,6 +5870,8 @@ snapshots:
statuses: 2.0.1
toidentifier: 1.0.1
http-status@2.1.0: {}
https-proxy-agent@5.0.1:
dependencies:
agent-base: 6.0.2