This commit is contained in:
David Arranz 2025-02-04 15:58:33 +01:00
parent ad12c3a67a
commit a8c72b1d64
25 changed files with 637 additions and 24 deletions

View File

@ -9,6 +9,11 @@
"date": 1738578708264,
"name": "debug-2025-02-03.log",
"hash": "48ca17f819e391cb5ae1909a6ee0fa4d9c8cdb6667ef893547acb004f4a79737"
},
{
"date": 1738664864746,
"name": "debug-2025-02-04.log",
"hash": "7f1ecce0e9a97fbb99865ac9bfc3591897975a2fd9562164c9be52aabc47f47f"
}
],
"hashType": "sha256"

View File

@ -9,6 +9,11 @@
"date": 1738578708262,
"name": "error-2025-02-03.log",
"hash": "a21f154f5c386a75eee98a35c2b100da7df1b8002cf99851b90bd12810f1fe8a"
},
{
"date": 1738664864743,
"name": "error-2025-02-04.log",
"hash": "dfb19c1e5b9c2039572425939e77f4d4ab3285df0fcded1edfba3e7c4cc2a94d"
}
],
"hashType": "sha256"

View File

@ -15,6 +15,36 @@ export abstract class SequelizeRepository<T> implements IAggregateRootRepository
});
}
protected async _getBy(
model: ModelDefined<any, any>,
field: string,
value: any,
params: any = {},
transaction?: Transaction
): Promise<any> {
const where: { [key: string]: any } = {};
where[field] = value;
return model.findOne({
where,
transaction,
...params,
});
}
protected async _getById(
model: ModelDefined<any, any>,
id: UniqueID | string,
params: any = {},
transaction?: Transaction
): Promise<any> {
return model.findByPk(id.toString(), {
transaction,
...params,
});
}
protected async _exists(
model: ModelDefined<any, any>,
field: string,

View File

@ -51,8 +51,9 @@ export abstract class ExpressController {
return ExpressController.errorResponse(
new ApiError({
status: 401,
title: "Unauthorized",
detail: message ?? "You are not authorized to access this resource.",
title: httpStatus["401"],
name: httpStatus["401_NAME"],
detail: message ?? httpStatus["401_MESSAGE"],
}),
this.res
);

View File

@ -0,0 +1,9 @@
import { Result, UniqueID } from "@common/domain";
import { TabContext } from "../domain";
export interface ITabContextService {
getByTabId(tabId: UniqueID): Promise<Result<TabContext, Error>>;
createContext(tabId: UniqueID, userId: UniqueID): Promise<Result<TabContext, Error>>;
assignCompany(tabId: UniqueID, companyId: UniqueID): Promise<Result<void, Error>>;
removeContext(tabId: UniqueID): Promise<Result<void, Error>>;
}

View File

@ -0,0 +1,108 @@
import { Result, UniqueID } from "@common/domain";
import { ITransactionManager } from "@common/infrastructure/database";
import { TabContext } from "../domain";
import { ITabContextRepository } from "../domain/repositories/tab-context-repository.interface";
import { ITabContextService } from "./tab-context-service.interface";
export class TabContextService implements ITabContextService {
private readonly _respository!: ITabContextRepository;
private readonly _transactionManager!: ITransactionManager;
constructor(repository: ITabContextRepository, transactionManager: ITransactionManager) {
this._respository = repository;
this._transactionManager = transactionManager;
}
/**
* Obtiene el contexto de una pestaña por su ID
*/
async getByTabId(tabId: UniqueID): Promise<Result<TabContext, Error>> {
try {
return await this._transactionManager.complete(async (transaction) => {
// Verificar si la pestaña existe
const tabContextOrError = await this._respository.getContextByTabId(tabId, transaction);
if (tabContextOrError.isSuccess && !tabContextOrError.data) {
return Result.fail(new Error("Invalid or expired Tab ID"));
}
if (tabContextOrError.isFailure) {
return Result.fail(tabContextOrError.error);
}
return Result.ok(tabContextOrError.data);
});
} catch (error: unknown) {
return Result.fail(error as Error);
}
}
/**
* Registra un nuevo contexto de pestaña para un usuario
*/
async createContext(tabId: UniqueID, userId: UniqueID): Promise<Result<TabContext, Error>> {
if (!userId || !tabId) {
return Result.fail(new Error("User ID and Tab ID are required"));
}
try {
return await this._transactionManager.complete(async (transaction) => {
const contextOrError = TabContext.create(
{
userId,
tabId,
},
UniqueID.generateNewID().data
);
if (contextOrError.isFailure) {
return Result.fail(contextOrError.error);
}
await this._respository.createContext(contextOrError.data, transaction);
return Result.ok(contextOrError.data);
});
} catch (error: unknown) {
return Result.fail(error as Error);
}
}
/**
* Asigna una empresa activa a un contexto de pestaña
*/
async assignCompany(tabId: UniqueID, companyId: UniqueID): Promise<Result<void, Error>> {
if (!companyId || !tabId) {
return Result.fail(new Error("Tab ID and Company ID are required"));
}
try {
return await this._transactionManager.complete(async (transaction) => {
// Verificar si la pestaña existe
const tabContextOrError = await this._respository.contextExists(tabId, transaction);
if (tabContextOrError.isFailure || !tabContextOrError.data) {
return Result.fail(new Error("Invalid or expired Tab ID"));
}
return await this._respository.updateCompanyByTabId(tabId, companyId, transaction);
});
} catch (error: unknown) {
return Result.fail(error as Error);
}
}
/**
* Elimina un contexto de pestaña por su ID
*/
async removeContext(tabId: UniqueID): Promise<Result<void, Error>> {
if (!tabId) {
return Result.fail(new Error("Tab ID is required"));
}
try {
return await this._transactionManager.complete(async (transaction) => {
return await this._respository.deleteContextByTabId(tabId, transaction);
});
} catch (error: unknown) {
return Result.fail(error as Error);
}
}
}

View File

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

View File

@ -0,0 +1,81 @@
import { DomainEntity, Result, UniqueID } from "@common/domain";
export interface ITabContextProps {
tabId: UniqueID;
userId: UniqueID;
companyId?: UniqueID;
branchId?: UniqueID;
}
export interface ITabContext {
tabId: UniqueID;
userId: UniqueID;
companyId?: UniqueID;
branchId?: UniqueID;
assignCompany(companyId: UniqueID): void;
assignBranch(branchId: UniqueID): void;
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;
}
get userId(): UniqueID {
return this._props.userId;
}
get companyId(): UniqueID | undefined {
return this._companyId;
}
get branchId(): UniqueID | undefined {
return this._branchId;
}
hasCompanyAssigned(): boolean {
return this._companyId !== undefined;
}
assignCompany(companyId: UniqueID): void {
this._companyId = companyId;
}
hasBranchAssigned(): boolean {
return this._branchId !== undefined;
}
assignBranch(branchId: UniqueID): void {
this._branchId = branchId;
}
/**
* 🔹 Devuelve una representación lista para persistencia
*/
toPersistenceData(): any {
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,
};
}
}

View File

@ -1,4 +1,5 @@
export * from "./aggregates/authenticated-user";
export * from "./events/user-authenticated.event";
export * from "./aggregates";
export * from "./entities";
export * from "./events";
export * from "./repositories";
export * from "./value-objects";

View File

@ -6,7 +6,7 @@ export interface IAuthenticatedUserRepository {
findUserByEmail(
email: EmailAddress,
transaction?: any
): Promise<Result<AuthenticatedUser | null, Error>>;
): Promise<Result<AuthenticatedUser, Error>>;
userExists(email: EmailAddress, transaction?: any): Promise<Result<boolean, Error>>;
createUser(user: AuthenticatedUser, transaction?: any): Promise<Result<void, Error>>;
}

View File

@ -0,0 +1,15 @@
import { Result, UniqueID } from "@common/domain";
import { Transaction } from "sequelize";
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>>;
contextExists(tabId: UniqueID, transaction?: any): Promise<Result<boolean, Error>>;
updateCompanyByTabId(
tabId: UniqueID,
companyId: UniqueID,
transaction?: Transaction
): Promise<Result<void, Error>>;
deleteContextByTabId(tabId: UniqueID, transaction?: any): Promise<Result<void, Error>>;
}

View File

@ -2,13 +2,6 @@ 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

@ -16,7 +16,7 @@ export class AuthenticatedUserMapper implements IAuthenticatedUserMapper {
const usernameResult = Username.create(entity.username);
const passwordHashResult = PasswordHash.create(entity.passwordHash);
const emailResult = EmailAddress.create(entity.email);
1;
// Validar que no haya errores en la creación de los Value Objects
const okOrError = Result.combine([
uniqueIdResult,
@ -35,7 +35,6 @@ export class AuthenticatedUserMapper implements IAuthenticatedUserMapper {
email: emailResult.data!,
passwordHash: passwordHashResult.data!,
roles: entity.roles || [],
token: entity.token,
},
uniqueIdResult.data!
);

View File

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

View File

@ -0,0 +1,7 @@
import { Result } from "@common/domain";
import { TabContext } from "@contexts/auth/domain";
export interface ITabContextMapper {
toDomain(entity: any): Result<TabContext, Error>;
toPersistence(aggregate: TabContext): any;
}

View File

@ -0,0 +1,46 @@
import { Result, UniqueID } from "@common/domain";
import { EmailAddress, PasswordHash, TabContext, Username } from "@contexts/auth/domain";
import { ITabContextMapper } from "./tab-context-mapper.interface";
export class TabContextMapper implements ITabContextMapper {
toDomain(entity: any): Result<TabContext, 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 userIdResult = Username.create(entity.user_id);
const companyIdResult = PasswordHash.create(entity.passwordHash);
const brachIdResult = EmailAddress.create(entity.email);
// Validar que no haya errores en la creación de los Value Objects
const okOrError = Result.combine([
uniqueIdResult,
usernameResult,
companyIdResult,
emailResult,
]);
if (okOrError.isFailure) {
return Result.fail(okOrError.error.message);
}
// Crear el agregado de dominio
return TabContext.create(
{
username: usernameResult.data!,
email: emailResult.data!,
passwordHash: companyIdResult.data!,
roles: entity.roles || [],
token: entity.token,
},
uniqueIdResult.data!
);
}
toPersistence(tabContext: TabContext): any {
return tabContext.toPersistenceData();
}
}
export const createTabContextMapper = (): ITabContextMapper => new TabContextMapper();

View File

@ -1,6 +1,19 @@
import { DataTypes, InferAttributes, InferCreationAttributes, Model, Sequelize } from "sequelize";
import {
DataTypes,
InferAttributes,
InferCreationAttributes,
Model,
NonAttribute,
Sequelize,
} from "sequelize";
import { TabContextCreationAttributes, TabContextModel } from "./tab-context.model";
export type AuthUserCreationAttributes = InferCreationAttributes<AuthUserModel>;
export type AuthUserCreationAttributes = InferCreationAttributes<
AuthUserModel,
{ omit: "contexts" }
> & {
contexts: TabContextCreationAttributes[];
};
export class AuthUserModel extends Model<
InferAttributes<AuthUserModel>,
@ -11,13 +24,23 @@ export class AuthUserModel extends Model<
return Promise.resolve();
}*/
static associate(connection: Sequelize) {}
static associate(connection: Sequelize) {
const { TabContextModel } = connection.models;
AuthUserModel.hasMany(TabContextModel, {
as: "contexts",
foreignKey: "user_id",
onDelete: "CASCADE",
});
}
declare id: string;
declare username: string;
declare email: string;
declare password: string;
declare roles: string[];
declare isActive: boolean;
declare contexts: NonAttribute<TabContextModel[]>;
}
export default (sequelize: Sequelize) => {
@ -68,6 +91,12 @@ export default (sequelize: Sequelize) => {
deletedAt: "deleted_at",
indexes: [{ name: "email_idx", fields: ["email"], unique: true }],
whereMergeStrategy: "and", // <- cómo tratar el merge de un scope
defaultScope: {},
scopes: {},
}
);
return AuthUserModel;

View File

@ -52,7 +52,7 @@ export class AuthenticatedUserRepository
async findUserByEmail(
email: EmailAddress,
transaction?: Transaction
): Promise<Result<AuthenticatedUser | null, Error>> {
): Promise<Result<AuthenticatedUser, Error>> {
try {
const rawUser: any = await this._findById(
AuthUserModel,
@ -62,7 +62,7 @@ export class AuthenticatedUserRepository
);
if (!rawUser === true) {
return Result.ok(null);
return Result.fail(new Error("User with email not exists"));
}
return this._mapper.toDomain(rawUser);

View File

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

View File

@ -0,0 +1,87 @@
import {
DataTypes,
InferAttributes,
InferCreationAttributes,
Model,
NonAttribute,
Sequelize,
} from "sequelize";
import { AuthUserModel } from "./auth-user.model";
export type TabContextCreationAttributes = InferCreationAttributes<
TabContextModel,
{ omit: "user" }
>;
export class TabContextModel extends Model<
InferAttributes<TabContextModel, { omit: "user" }>,
InferCreationAttributes<TabContextModel, { omit: "user" }>
> {
// To avoid table creation
/*static async sync(): Promise<any> {
return Promise.resolve();
}*/
static associate(connection: Sequelize) {
const { AuthUserModel } = connection.models;
TabContextModel.belongsTo(AuthUserModel, {
as: "user",
foreignKey: "user_id",
onDelete: "CASCADE",
});
}
declare id: string;
declare tab_id: string;
declare user_id: string;
declare company_id: string;
declare branch_id: string;
declare user: NonAttribute<AuthUserModel>;
}
export default (sequelize: Sequelize) => {
TabContextModel.init(
{
id: {
type: DataTypes.UUID,
primaryKey: true,
},
user_id: {
type: DataTypes.UUID,
allowNull: false,
},
tab_id: {
type: DataTypes.UUID,
allowNull: false,
},
company_id: {
type: DataTypes.UUID,
allowNull: false,
},
branch_id: {
type: DataTypes.UUID,
allowNull: false,
},
},
{
sequelize,
tableName: "user_tab_contexts",
paranoid: true, // softs deletes
timestamps: true,
createdAt: "created_at",
updatedAt: "updated_at",
deletedAt: "deleted_at",
indexes: [{ name: "tab_id_idx", fields: ["tab_id"], unique: true }],
whereMergeStrategy: "and", // <- cómo tratar el merge de un scope
defaultScope: {},
scopes: {},
}
);
return TabContextModel;
};

View File

@ -0,0 +1,128 @@
import { Result, UniqueID } from "@common/domain";
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 { TabContextModel } from "./tab-context.model";
export class TabContextRepository
extends SequelizeRepository<TabContext>
implements ITabContextRepository
{
private readonly _mapper!: ITabContextMapper;
/**
* 🔹 Función personalizada para mapear errores de unicidad en autenticación
*/
private _customErrorMapper(error: Error): string | null {
if (error.name === "SequelizeUniqueConstraintError") {
return "Tab context already exists";
}
return null;
}
constructor(mapper: ITabContextMapper) {
super();
this._mapper = mapper;
}
async getContextByTabId(
tabId: UniqueID,
transaction?: Transaction
): Promise<Result<TabContext, Error>> {
try {
const rawContext = await this._getBy(
TabContextModel,
"tab_id",
tabId.toString(),
{},
transaction
);
if (!rawContext === true) {
return Result.fail(new Error("Tab context not exists"));
}
return this._mapper.toDomain(rawContext);
} catch (error: any) {
return this._handleDatabaseError(error, this._customErrorMapper);
}
}
async contextExists(tabId: UniqueID, transaction?: any): Promise<Result<boolean, Error>> {
try {
const result: any = await this._exists(
TabContextModel,
"tab_id",
tabId.toString(),
transaction
);
return Result.ok(Boolean(result));
} catch (error: any) {
return this._handleDatabaseError(error, this._customErrorMapper);
}
}
async createContext(
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,
}
);
return Result.ok();
} catch (error: any) {
return this._handleDatabaseError(error, this._customErrorMapper);
}
}
async updateCompanyByTabId(
tabId: UniqueID,
companyId: UniqueID,
transaction?: Transaction
): Promise<Result<void, Error>> {
try {
await TabContextModel.update(
{ company_id: companyId.toString() },
{
where: {
tab_id: tabId.toString(),
},
transaction,
}
);
return Result.ok();
} catch (error: any) {
return this._handleDatabaseError(error, this._customErrorMapper);
}
}
async deleteContextByTabId(tabId: UniqueID, transaction?: any): Promise<Result<void, Error>> {
try {
await TabContextModel.destroy({
where: {
tab_id: tabId.toString(),
},
transaction,
force: false,
});
return Result.ok();
} catch (error: any) {
return this._handleDatabaseError(error, this._customErrorMapper);
}
}
}

View File

@ -7,7 +7,12 @@ export interface IRegisterUserResponseDTO {
export interface ILoginUserResponseDTO {
access_token: string;
refresh_token: string;
user_id: string;
user: {
id: string;
username: string;
email: string;
};
tab_id: string;
}
export interface ILogoutResponseDTO {

View File

@ -1 +1,2 @@
export * from "./passport-auth.middleware";
export * from "./tab-context.middleware";

View File

@ -0,0 +1,52 @@
import { ApiError, ExpressController } from "@common/presentation";
import { NextFunction, Request, Response } from "express";
import httpStatus from "http-status";
export const validateTabHeader = (req: Request, res: Response, next: NextFunction) => {
const tabId = req.headers["x-tab-id"];
if (!tabId) {
return ExpressController.errorResponse(
new ApiError({
status: 401,
title: httpStatus["401"],
name: httpStatus["401_NAME"],
detail: "Tab ID is required",
}),
res
);
}
next();
};
export const validateTabContext = async (req: Request, res: Response, next: NextFunction) => {
const tabId = req.headers["x-tab-id"];
if (!tabId) {
return ExpressController.errorResponse(
new ApiError({
status: 401,
title: httpStatus["401"],
name: httpStatus["401_NAME"],
detail: "Tab ID is required",
}),
res
);
}
const contextOrError = await TabContextRepository.getByTabId(tabId);
if (contextOrError.isFailure) {
return ExpressController.errorResponse(
new ApiError({
status: 401,
title: httpStatus["401"],
name: httpStatus["401_NAME"],
detail: "Invalid or expired Tab ID",
}),
res
);
}
const context = contextOrError.data;
req.user = { id: context.user_id, company_id: context.company_id };
next();
};

View File

@ -1,4 +1,5 @@
import { validateRequest } from "@common/presentation";
import { validateTabHeader } from "@contexts/auth/presentation";
import {
createLoginController,
createRegisterController,
@ -41,9 +42,14 @@ export const authRouter = (appRouter: Router) => {
*
* @apiError (401) {String} message Invalid email or password.
*/
authRoutes.post("/login", validateRequest(LoginUserSchema), (req, res, next) => {
createLoginController().execute(req, res, next);
});
authRoutes.post(
"/login",
validateRequest(LoginUserSchema),
validateTabHeader,
(req, res, next) => {
createLoginController().execute(req, res, next);
}
);
/**
* @api {post} /api/auth/select-company Select an active company