.
This commit is contained in:
parent
3568e7e438
commit
350b8a8422
46
apps/server/.eslintrc.json
Normal file
46
apps/server/.eslintrc.json
Normal 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 }
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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>>;
|
||||
}
|
||||
41
apps/server/src/common/domain/aggregate-root.ts
Normal file
41
apps/server/src/common/domain/aggregate-root.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
37
apps/server/src/common/domain/domain-entity.ts
Normal file
37
apps/server/src/common/domain/domain-entity.ts
Normal 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)),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
export interface IHandle<IDomainEvent> {
|
||||
setupSubscriptions(): void;
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
135
apps/server/src/common/domain/events/domain-event.ts
Normal file
135
apps/server/src/common/domain/events/domain-event.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
apps/server/src/common/domain/events/index.ts
Normal file
2
apps/server/src/common/domain/events/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./domain-event";
|
||||
export * from "./domain-event.interface";
|
||||
6
apps/server/src/common/domain/index.ts
Normal file
6
apps/server/src/common/domain/index.ts
Normal 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";
|
||||
@ -1,3 +1,2 @@
|
||||
export * from "./result";
|
||||
export * from "./unique-id";
|
||||
export * from "./value-object";
|
||||
@ -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()", () => {
|
||||
@ -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" });
|
||||
2
apps/server/src/common/infrastructure/database/index.ts
Normal file
2
apps/server/src/common/infrastructure/database/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./transaction-manager";
|
||||
export * from "./transaction-manager.interface";
|
||||
@ -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>;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
1
apps/server/src/common/infrastructure/passport/index.ts
Normal file
1
apps/server/src/common/infrastructure/passport/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./passport";
|
||||
27
apps/server/src/common/infrastructure/passport/passport.ts
Normal file
27
apps/server/src/common/infrastructure/passport/passport.ts
Normal 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;
|
||||
@ -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>;
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
37
apps/server/src/common/presentation/express/api-error.ts
Normal file
37
apps/server/src/common/presentation/express/api-error.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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 });
|
||||
}
|
||||
}
|
||||
4
apps/server/src/common/presentation/express/index.ts
Normal file
4
apps/server/src/common/presentation/express/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from "./api-error";
|
||||
export * from "./express-controller";
|
||||
export * from "./middlewares";
|
||||
export * from "./validate-request";
|
||||
@ -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,
|
||||
});
|
||||
};
|
||||
@ -0,0 +1 @@
|
||||
export * from "./error-handler";
|
||||
@ -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();
|
||||
};
|
||||
1
apps/server/src/common/presentation/index.ts
Normal file
1
apps/server/src/common/presentation/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./express";
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>>;
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
1
apps/server/src/contexts/auth/domain/aggregates/index.ts
Normal file
1
apps/server/src/contexts/auth/domain/aggregates/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./authenticated-user";
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
1
apps/server/src/contexts/auth/domain/events/index.ts
Normal file
1
apps/server/src/contexts/auth/domain/events/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./user-authenticated.event";
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -0,0 +1 @@
|
||||
export interface IAuthenticatedUserRepository {}
|
||||
@ -0,0 +1 @@
|
||||
export * from "./authenticated-user-repository.interface";
|
||||
@ -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"]);
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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> {
|
||||
|
||||
3
apps/server/src/contexts/auth/infraestructure/index.ts
Normal file
3
apps/server/src/contexts/auth/infraestructure/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from "./jwt.helper";
|
||||
export * from "./sequelize";
|
||||
12;
|
||||
13
apps/server/src/contexts/auth/infraestructure/jwt.helper.ts
Normal file
13
apps/server/src/contexts/auth/infraestructure/jwt.helper.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,2 @@
|
||||
export * from "./authenticated-user-mapper.interface";
|
||||
export * from "./authenticated-user.mapper";
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -1,2 +1,2 @@
|
||||
export * from "./auth-user.model";
|
||||
export * from "./auth-user.repository";
|
||||
export * from "./authenticated-user.repository";
|
||||
|
||||
@ -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();
|
||||
77
apps/server/src/contexts/auth/presentation/auth.routes.ts
Normal file
77
apps/server/src/contexts/auth/presentation/auth.routes.ts
Normal 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);
|
||||
};
|
||||
@ -0,0 +1 @@
|
||||
export * from "./register.controller";
|
||||
@ -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);
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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"),
|
||||
});
|
||||
3
apps/server/src/contexts/auth/presentation/dto/index.ts
Normal file
3
apps/server/src/contexts/auth/presentation/dto/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from "./auth.request.dto";
|
||||
export * from "./auth.response.dto";
|
||||
export * from "./auth.validation.dto";
|
||||
1
apps/server/src/contexts/auth/presentation/index.ts
Normal file
1
apps/server/src/contexts/auth/presentation/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./auth.controller";
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import { createApp } from "./app";
|
||||
import { connectToDatabase } from "./config/database";
|
||||
import { createApp } from "./infrastructure/app";
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
|
||||
@ -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;
|
||||
};
|
||||
@ -1 +0,0 @@
|
||||
export * from "./auth.routes";
|
||||
@ -1 +0,0 @@
|
||||
export * from "./app";
|
||||
1
apps/server/src/routes/index.ts
Normal file
1
apps/server/src/routes/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./v1.routes";
|
||||
21
apps/server/src/routes/v1.routes.ts
Normal file
21
apps/server/src/routes/v1.routes.ts
Normal 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;
|
||||
};
|
||||
@ -10,6 +10,7 @@
|
||||
"paths": {
|
||||
"@shared/*": ["../../packages/shared/*"],
|
||||
"@common/*": ["common/*"],
|
||||
"@contexts/*": ["contexts/*"],
|
||||
"@config/*": ["config/*"]
|
||||
}
|
||||
},
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user