This commit is contained in:
David Arranz 2025-02-04 19:25:10 +01:00
parent a8c72b1d64
commit 8ead5a62da
18 changed files with 250 additions and 164 deletions

View File

@ -46,19 +46,27 @@ export class Result<T, E extends Error = Error> {
}
get data(): T {
if (!this._isSuccess) {
throw new Error("Cannot get value data from a failed result.");
}
return this._data as T;
return this.getData();
}
get error(): E {
return this.getError();
}
getError(): E {
if (this._isSuccess) {
throw new Error("Cannot get error from a successful result.");
}
return this._error as E;
}
getData(): T {
if (!this._isSuccess) {
throw new Error("Cannot get value data from a failed result.");
}
return this._data as T;
}
/**
* 🔹 `getOrElse(defaultValue: T): T`
* Si el `Result` es un `ok`, devuelve `data`, de lo contrario, devuelve `defaultValue`.

View File

@ -1,35 +1,54 @@
import { UniqueID } from "./value-objects/unique-id";
import { UniqueID } from "./unique-id";
describe("UniqueID Value Object", () => {
it("should generate a new UUID using generateNewID()", () => {
const result = UniqueID.generateNewID();
// Mock UUID generation to ensure predictable tests
jest.mock("uuid", () => ({ v4: () => "123e4567-e89b-12d3-a456-426614174000" }));
describe("UniqueID", () => {
test("should create a UniqueID with a valid UUID", () => {
const id = "123e4567-e89b-12d3-a456-426614174000";
const result = UniqueID.create(id);
expect(result.isSuccess).toBe(true);
expect(result.data.getValue()).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
);
expect(result.data?.isDefined()).toBe(true);
});
it("should return an error for an invalid UUID", () => {
test("should fail to create UniqueID with an invalid UUID", () => {
const result = UniqueID.create("invalid-uuid");
expect(result.isSuccess).toBe(true);
expect(result.error.message).toBe("Invalid UUID format");
expect(result.isFailure).toBe(true);
});
it("should create a valid UniqueID from an existing UUID", () => {
const validUUID = "550e8400-e29b-41d4-a716-446655440000";
const result = UniqueID.create(validUUID);
test("should create an undefined UniqueID when id is undefined and generateOnEmpty is false", () => {
const result = UniqueID.create(undefined, false);
expect(result.isSuccess).toBe(true);
expect(result.data.getValue()).toBe(validUUID);
expect(result.data?.isDefined()).toBe(false);
});
it("should correctly convert UniqueID to string", () => {
const validUUID = "550e8400-e29b-41d4-a716-446655440000";
const result = UniqueID.create(validUUID);
test("should generate a new UUID when id is undefined and generateOnEmpty is true", () => {
const result = UniqueID.create(undefined, true);
expect(result.isSuccess).toBe(true);
expect(result.data.toString()).toBe(validUUID);
expect(result.data?.isDefined()).toBe(true);
});
test("should fail when id is null", () => {
const result = UniqueID.create(null as any);
expect(result.isFailure).toBe(true);
});
test("should create a UniqueID when id is an empty string and generateOnEmpty is true", () => {
const result = UniqueID.create(" ", true);
expect(result.isSuccess).toBe(true);
expect(result.data?.isDefined()).toBe(true);
});
test("should create an undefined UniqueID when id is an empty string and generateOnEmpty is false", () => {
const result = UniqueID.create(" ", false);
expect(result.isSuccess).toBe(true);
expect(result.data?.isDefined()).toBe(false);
});
});

View File

@ -3,17 +3,27 @@ import { z } from "zod";
import { Result } from "../result";
import { ValueObject } from "./value-object";
const UUIDSchema = z.string().uuid({ message: "Invalid UUID format" });
export const UNDEFINED_ID = undefined;
export class UniqueID extends ValueObject<string | undefined> {
protected readonly _hasId!: boolean;
protected constructor(id?: string) {
super(id);
this._hasId = id != UNDEFINED_ID;
}
export class UniqueID extends ValueObject<string> {
static create(id?: string, generateOnEmpty: boolean = false): Result<UniqueID, Error> {
if (!id) {
return generateOnEmpty
? UniqueID.generateNewID()
: Result.fail(new Error("ID is null or empty"));
if (id === null) {
return Result.fail(new Error("ID cannot be null"));
}
const result = UniqueID.validate(id.trim());
const trimmedId = id?.trim();
if (!trimmedId) {
return generateOnEmpty ? UniqueID.generateNewID() : Result.ok(new UniqueID(UNDEFINED_ID));
}
const result = UniqueID.validate(trimmedId);
return result.success
? Result.ok(new UniqueID(result.data))
@ -32,4 +42,12 @@ export class UniqueID extends ValueObject<string> {
static generateNewID(): Result<UniqueID, never> {
return Result.ok(new UniqueID(uuidv4()));
}
static generateUndefinedID(): Result<UniqueID, never> {
return Result.ok(new UniqueID(UNDEFINED_ID));
}
isDefined(): boolean {
return this._hasId;
}
}

View File

@ -5,8 +5,6 @@ export abstract class ValueObject<T> {
protected constructor(value: T) {
this._value = typeof value === "object" && value !== null ? Object.freeze(value) : value;
Object.freeze(this);
}
equals(other: ValueObject<T>): boolean {

View File

@ -1,4 +1,4 @@
import { Result } from "@common/domain";
import { Result, UniqueID } from "@common/domain";
import { AuthenticatedUser, EmailAddress, PasswordHash, Username } from "../domain";
export interface IAuthService {
@ -8,8 +8,16 @@ export interface IAuthService {
passwordHash: PasswordHash;
}): Promise<Result<AuthenticatedUser, Error>>;
loginUser(params: {
email: EmailAddress;
passwordHash: PasswordHash;
}): Promise<Result<AuthenticatedUser, Error>>;
loginUser(params: { email: EmailAddress; passwordHash: PasswordHash; tabId: UniqueID }): Promise<
Result<
{
user: AuthenticatedUser;
tokens: {
accessToken: string;
refreshToken: string;
};
},
Error
>
>;
}

View File

@ -5,22 +5,27 @@ import {
EmailAddress,
IAuthenticatedUserRepository,
PasswordHash,
TabContext,
Username,
} from "@contexts/auth/domain";
} from "../domain";
import { ITabContextRepository } from "../domain/repositories/tab-context-repository.interface";
import { IAuthProvider } from "./auth-provider.interface";
import { IAuthService } from "./auth-service.interface";
export class AuthService implements IAuthService {
private readonly _respository!: IAuthenticatedUserRepository;
private readonly _userRepo!: IAuthenticatedUserRepository;
private readonly _tabContactRepo!: ITabContextRepository;
private readonly _transactionManager!: ITransactionManager;
private readonly _authProvider: IAuthProvider;
constructor(
repository: IAuthenticatedUserRepository,
userRepo: IAuthenticatedUserRepository,
tabContextRepo: ITabContextRepository,
transactionManager: ITransactionManager,
authProvider: IAuthProvider
) {
this._respository = repository;
this._userRepo = userRepo;
this._tabContactRepo = tabContextRepo;
this._transactionManager = transactionManager;
this._authProvider = authProvider;
}
@ -39,7 +44,7 @@ export class AuthService implements IAuthService {
const { username, email, passwordHash } = params;
// Verificar si el usuario ya existe
const userExists = await this._respository.findUserByEmail(email, transaction);
const userExists = await this._userRepo.findUserByEmail(email, transaction);
if (userExists.isSuccess && userExists.data) {
return Result.fail(new Error("Email is already registered"));
}
@ -64,7 +69,7 @@ export class AuthService implements IAuthService {
return Result.fail(userOrError.error);
}
const createdResult = await this._respository.createUser(userOrError.data, transaction);
const createdResult = await this._userRepo.createUser(userOrError.data, transaction);
if (createdResult.isFailure) {
return Result.fail(createdResult.error);
@ -84,21 +89,35 @@ export class AuthService implements IAuthService {
async loginUser(params: {
email: EmailAddress;
passwordHash: PasswordHash;
}): Promise<Result<AuthenticatedUser, Error>> {
tabId: UniqueID;
}): Promise<
Result<
{
user: AuthenticatedUser;
tokens: {
accessToken: string;
refreshToken: string;
};
},
Error
>
> {
try {
return await this._transactionManager.complete(async (transaction) => {
const { email, passwordHash } = params;
const { email, passwordHash, tabId } = params;
// Verificar que el tab ID está definido
if (!tabId.isDefined()) {
return Result.fail(new Error("Invalid tab id"));
}
// 🔹 Verificar si el usuario existe en la base de datos
const userResult = await this._respository.findUserByEmail(email, transaction);
const userResult = await this._userRepo.findUserByEmail(email, transaction);
if (userResult.isFailure) {
return Result.fail(new Error("Invalid email or password"));
}
const user = userResult.data;
if (!user) {
return Result.fail(new Error("Invalid email or password"));
}
// 🔹 Verificar que la contraseña sea correcta
const isValidPassword = await user.comparePassword(passwordHash);
@ -106,56 +125,42 @@ export class AuthService implements IAuthService {
return Result.fail(new Error("Invalid email or password"));
}
// Registrar o actualizar el contexto de ese tab ID
const contextOrError = TabContext.create({
userId: user.id,
tabId: tabId,
companyId: UniqueID.generateUndefinedID().data,
branchId: UniqueID.generateUndefinedID().data,
});
if (contextOrError.isFailure) {
return Result.fail(new Error("Error creating user context"));
}
await this._tabContactRepo.registerContext(contextOrError.data, transaction);
// 🔹 Generar Access Token y Refresh Token
user.accessToken = this._authProvider.generateAccessToken({
const accessToken = this._authProvider.generateAccessToken({
userId: user.id.toString(),
email: email.toString(),
tabId: tabId.toString(),
roles: ["USER"],
});
user.refreshToken = this._authProvider.generateRefreshToken({
const refreshToken = this._authProvider.generateRefreshToken({
userId: user.id.toString(),
});
return Result.ok(user);
return Result.ok({
user,
tokens: {
accessToken,
refreshToken,
},
});
});
} catch (error: unknown) {
return Result.fail(error as Error);
}
}
/**
* 🔹 `selectCompany`
* Permite a un usuario seleccionar una empresa activa en la sesión bajo transacción.
*/
/*static async selectCompany(
userId: string,
companyId: string
): Promise<Result<{ message: string }, Error>> {
return await authUserRepository.executeTransaction(async (transaction) => {
const user = await authUserRepository.findById(userId, transaction);
if (user.isFailure) {
return Result.fail(new Error("User not found"));
}
const isAssociated = await authUserRepository.isUserAssociatedWithCompany(
userId,
companyId,
transaction
);
if (!isAssociated) {
return Result.fail(new Error("User does not have access to this company"));
}
return Result.ok({ message: "Company selected successfully" });
});
}*/
/**
* 🔹 `logout`
* Simula el cierre de sesión de un usuario. No requiere transacción.
*/
/*static logout(): Result<{ message: string }, never> {
return Result.ok({ message: "Logged out successfully" });
}*/
}

View File

@ -1,6 +1,6 @@
import { createSequelizeTransactionManager } from "@common/infrastructure";
import { createAuthenticatedUserRepository } from "../infraestructure";
import { createAuthenticatedUserRepository, createTabContextRepository } from "../infraestructure";
import { createPassportAuthProvider } from "../infraestructure/passport/passport-auth-provider";
import { IAuthProvider } from "./auth-provider.interface";
import { IAuthService } from "./auth-service.interface";
@ -12,10 +12,16 @@ export * from "./auth-service.interface";
export const createAuthService = (): IAuthService => {
const transactionManager = createSequelizeTransactionManager();
const authenticatedUserRepository = createAuthenticatedUserRepository();
const tabContextRepository = createTabContextRepository();
const authProvider: IAuthProvider = createPassportAuthProvider(
authenticatedUserRepository,
transactionManager
);
return new AuthService(authenticatedUserRepository, transactionManager, authProvider);
return new AuthService(
authenticatedUserRepository,
tabContextRepository,
transactionManager,
authProvider
);
};

View File

@ -3,7 +3,12 @@ import { TabContext } from "../domain";
export interface ITabContextService {
getByTabId(tabId: UniqueID): Promise<Result<TabContext, Error>>;
createContext(tabId: UniqueID, userId: UniqueID): Promise<Result<TabContext, Error>>;
createContext(params: {
tabId: UniqueID;
userId: UniqueID;
companyId: UniqueID;
branchId: UniqueID;
}): Promise<Result<TabContext, Error>>;
assignCompany(tabId: UniqueID, companyId: UniqueID): Promise<Result<void, Error>>;
removeContext(tabId: UniqueID): Promise<Result<void, Error>>;
}

View File

@ -39,7 +39,14 @@ export class TabContextService implements ITabContextService {
/**
* Registra un nuevo contexto de pestaña para un usuario
*/
async createContext(tabId: UniqueID, userId: UniqueID): Promise<Result<TabContext, Error>> {
async createContext(params: {
tabId: UniqueID;
userId: UniqueID;
companyId: UniqueID;
branchId: UniqueID;
}): Promise<Result<TabContext, Error>> {
const { tabId, userId, companyId, branchId } = params;
if (!userId || !tabId) {
return Result.fail(new Error("User ID and Tab ID are required"));
}
@ -50,6 +57,8 @@ export class TabContextService implements ITabContextService {
{
userId,
tabId,
companyId,
branchId,
},
UniqueID.generateNewID().data
);
@ -58,7 +67,7 @@ export class TabContextService implements ITabContextService {
return Result.fail(contextOrError.error);
}
await this._respository.createContext(contextOrError.data, transaction);
await this._respository.registerContext(contextOrError.data, transaction);
return Result.ok(contextOrError.data);
});

View File

@ -3,38 +3,27 @@ import { DomainEntity, Result, UniqueID } from "@common/domain";
export interface ITabContextProps {
tabId: UniqueID;
userId: UniqueID;
companyId?: UniqueID;
branchId?: UniqueID;
companyId: UniqueID;
branchId: UniqueID;
}
export interface ITabContext {
tabId: UniqueID;
userId: UniqueID;
companyId?: UniqueID;
branchId?: UniqueID;
companyId: UniqueID;
branchId: UniqueID;
assignCompany(companyId: UniqueID): void;
assignBranch(branchId: UniqueID): void;
hasCompanyAssigned(): boolean;
hasBranchAssigned(): boolean;
toPersistenceData(): any;
}
export class TabContext extends DomainEntity<ITabContextProps> implements ITabContext {
private _companyId: UniqueID | undefined;
private _branchId: UniqueID | undefined;
static create(props: ITabContextProps, id?: UniqueID): Result<TabContext, Error> {
return Result.ok(new this(props, id));
}
protected constructor(props: ITabContextProps, id?: UniqueID) {
const { tabId, userId, companyId, branchId } = props;
super({ tabId, userId }, id);
this._companyId = companyId;
this._branchId = branchId;
}
get tabId(): UniqueID {
return this._props.tabId;
}
@ -43,28 +32,20 @@ export class TabContext extends DomainEntity<ITabContextProps> implements ITabCo
return this._props.userId;
}
get companyId(): UniqueID | undefined {
return this._companyId;
get companyId(): UniqueID {
return this._props.companyId;
}
get branchId(): UniqueID | undefined {
return this._branchId;
get branchId(): UniqueID {
return this._props.branchId;
}
hasCompanyAssigned(): boolean {
return this._companyId !== undefined;
}
assignCompany(companyId: UniqueID): void {
this._companyId = companyId;
return this._props.companyId.isDefined();
}
hasBranchAssigned(): boolean {
return this._branchId !== undefined;
}
assignBranch(branchId: UniqueID): void {
this._branchId = branchId;
return this._props.branchId.isDefined();
}
/**
@ -74,8 +55,8 @@ export class TabContext extends DomainEntity<ITabContextProps> implements ITabCo
return {
id: this._id.toString(),
user_id: this.userId.toString(),
company_id: this._companyId ? this._companyId.toString() : undefined,
branchId: this._branchId ? this._branchId.toString() : undefined,
company_id: this.companyId.toString(),
branchId: this.branchId.toString(),
};
}
}

View File

@ -4,7 +4,7 @@ import { TabContext } from "../entities";
export interface ITabContextRepository {
getContextByTabId(tabId: UniqueID, transaction?: any): Promise<Result<TabContext, Error>>;
createContext(context: TabContext, transaction?: Transaction): Promise<Result<void, Error>>;
registerContext(context: TabContext, transaction?: Transaction): Promise<Result<void, Error>>;
contextExists(tabId: UniqueID, transaction?: any): Promise<Result<boolean, Error>>;
updateCompanyByTabId(
tabId: UniqueID,

View File

@ -1,5 +1,5 @@
import { Result, UniqueID } from "@common/domain";
import { EmailAddress, PasswordHash, TabContext, Username } from "@contexts/auth/domain";
import { TabContext } from "@contexts/auth/domain";
import { ITabContextMapper } from "./tab-context-mapper.interface";
export class TabContextMapper implements ITabContextMapper {
@ -10,16 +10,18 @@ export class TabContextMapper implements ITabContextMapper {
// Crear Value Objects asegurando que sean válidos
const uniqueIdResult = UniqueID.create(entity.id);
const userIdResult = Username.create(entity.user_id);
const companyIdResult = PasswordHash.create(entity.passwordHash);
const brachIdResult = EmailAddress.create(entity.email);
const tabIdResult = UniqueID.create(entity.tab_id);
const userIdResult = UniqueID.create(entity.user_id);
const companyIdResult = UniqueID.create(entity.company_id, false);
const brachIdResult = UniqueID.create(entity.branch_id, false);
// Validar que no haya errores en la creación de los Value Objects
const okOrError = Result.combine([
uniqueIdResult,
usernameResult,
tabIdResult,
userIdResult,
companyIdResult,
emailResult,
brachIdResult,
]);
if (okOrError.isFailure) {
return Result.fail(okOrError.error.message);
@ -28,11 +30,10 @@ export class TabContextMapper implements ITabContextMapper {
// Crear el agregado de dominio
return TabContext.create(
{
username: usernameResult.data!,
email: emailResult.data!,
passwordHash: companyIdResult.data!,
roles: entity.roles || [],
token: entity.token,
tabId: tabIdResult.data!,
userId: userIdResult.data!,
companyId: companyIdResult.data,
branchId: brachIdResult.data,
},
uniqueIdResult.data!
);

View File

@ -3,7 +3,7 @@ import { SequelizeRepository } from "@common/infrastructure";
import { TabContext } from "@contexts/auth/domain/";
import { ITabContextRepository } from "@contexts/auth/domain/repositories/tab-context-repository.interface";
import { Transaction } from "sequelize";
import { ITabContextMapper } from "../mappers";
import { createTabContextMapper, ITabContextMapper } from "../mappers";
import { TabContextModel } from "./tab-context.model";
export class TabContextRepository
@ -66,23 +66,22 @@ export class TabContextRepository
}
}
async createContext(
/**
* Crea un contexto para un tab id o actualiza si ya existe
* @param context
* @param transaction
* @returns
*/
async registerContext(
context: TabContext,
transaction?: Transaction
): Promise<Result<void, Error>> {
try {
const { id } = context;
const persistenceData = this._mapper.toPersistence(context);
await TabContextModel.create(
{
...persistenceData,
id: id.toString(),
},
{
include: [{ all: true }],
transaction,
}
);
await this._save(TabContextModel, id, persistenceData, {}, transaction);
return Result.ok();
} catch (error: any) {
return this._handleDatabaseError(error, this._customErrorMapper);
@ -126,3 +125,8 @@ export class TabContextRepository
}
}
}
export const createTabContextRepository = (): ITabContextRepository => {
const tabContextMapper = createTabContextMapper();
return new TabContextRepository(tabContextMapper);
};

View File

@ -1,3 +1,4 @@
import { UniqueID } from "@common/domain";
import { ExpressController } from "@common/presentation";
import { createAuthService, IAuthService } from "@contexts/auth/application";
import { EmailAddress, PasswordHash } from "@contexts/auth/domain";
@ -14,16 +15,19 @@ class LoginController extends ExpressController {
}
async executeImpl() {
const tabId = this.req.headers["x-tab-id"];
const emailVO = EmailAddress.create(this.req.body.email);
const passwordHashVO = PasswordHash.create(this.req.body.password);
const tabIdVO = UniqueID.create(String(tabId));
if ([emailVO, passwordHashVO].some((r) => r.isFailure)) {
if ([emailVO, passwordHashVO, tabIdVO].some((r) => r.isFailure)) {
return this.clientError("Invalid input data");
}
const userOrError = await this._authService.loginUser({
email: emailVO.data,
passwordHash: passwordHashVO.data,
tabId: tabIdVO.data,
});
if (userOrError.isFailure) {

View File

@ -2,20 +2,40 @@ import { AuthenticatedUser } from "@contexts/auth/domain";
import { ILoginUserResponseDTO } from "../../dto";
export interface ILoginPresenter {
map: (user: AuthenticatedUser) => ILoginUserResponseDTO;
map: (data: {
user: AuthenticatedUser;
tokens: {
accessToken: string;
refreshToken: string;
};
}) => ILoginUserResponseDTO;
}
export const LoginPresenter: ILoginPresenter = {
map: (user: AuthenticatedUser): ILoginUserResponseDTO => {
//const { user, token, refreshToken } = loginUser;
//const roles = user.getRoles()?.map((rol) => rol.toString()) || [];
map: (data: {
user: AuthenticatedUser;
tokens: {
accessToken: string;
refreshToken: string;
};
}): ILoginUserResponseDTO => {
const {
user,
tokens: { accessToken, refreshToken },
} = data;
const userData = user.toPersistenceData();
return {
user_id: userData,
access_token: userData.accessToken,
refresh_token: userData.refreshToken,
user: {
id: userData.id,
email: userData.email,
username: userData.username,
},
tokens: {
access_token: accessToken,
refresh_token: refreshToken,
},
};
},
};

View File

@ -5,14 +5,16 @@ export interface IRegisterUserResponseDTO {
}
export interface ILoginUserResponseDTO {
access_token: string;
refresh_token: string;
user: {
id: string;
username: string;
email: string;
};
tab_id: string;
tokens: {
access_token: string;
refresh_token: string;
};
//tab_id: string;
}
export interface ILogoutResponseDTO {

View File

@ -32,7 +32,7 @@ export const validateTabContext = async (req: Request, res: Response, next: Next
);
}
const contextOrError = await TabContextRepository.getByTabId(tabId);
const contextOrError = await TabContextRepository.getContextByTabId(tabId);
if (contextOrError.isFailure) {
return ExpressController.errorResponse(
new ApiError({

View File

@ -1,9 +1,7 @@
import { validateRequest } from "@common/presentation";
import { validateTabHeader } from "@contexts/auth/presentation";
import {
createLoginController,
createRegisterController,
} from "@contexts/auth/presentation/controllers";
import { createLoginController } from "@contexts/auth/presentation/controllers";
import { createRegisterController } from "@contexts/auth/presentation/controllers/register/register.controller";
import { LoginUserSchema, RegisterUserSchema } from "@contexts/auth/presentation/dto";
import { Router } from "express";