.
This commit is contained in:
parent
6649374ad8
commit
3568e7e438
1
apps/server/src/config/index.ts
Normal file
1
apps/server/src/config/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./database";
|
||||
114
apps/server/src/contexts/auth/application/auth.service.ts
Normal file
114
apps/server/src/contexts/auth/application/auth.service.ts
Normal file
@ -0,0 +1,114 @@
|
||||
import { EmailAddress, PasswordHash, Username } from "contexts/auth/domain";
|
||||
import { authUserRepository } from "contexts/auth/infraestructure/sequelize";
|
||||
import { Result, UniqueID } from "contexts/common/domain";
|
||||
|
||||
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) => {
|
||||
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
|
||||
);
|
||||
if (userExists) {
|
||||
return Result.fail(new Error("Email is already registered"));
|
||||
}
|
||||
|
||||
const user = await authUserRepository.createUser(
|
||||
{
|
||||
id: userIdResult.data.getValue(),
|
||||
username: usernameResult.data.getValue(),
|
||||
email: emailResult.data.getValue(),
|
||||
password: passwordResult.data.getValue(),
|
||||
isActive: true,
|
||||
},
|
||||
transaction
|
||||
);
|
||||
|
||||
return Result.ok({ userId: user.id });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔹 `login`
|
||||
* Autentica un usuario y genera un token JWT bajo transacción.
|
||||
*/
|
||||
static async login(
|
||||
email: string,
|
||||
password: string
|
||||
): Promise<Result<{ token: string; userId: string }, Error>> {
|
||||
return await authUserRepository.executeTransaction(async (transaction) => {
|
||||
const emailResult = EmailAddress.create(email);
|
||||
if (emailResult.isError()) {
|
||||
return Result.fail(emailResult.error);
|
||||
}
|
||||
|
||||
const user = await authUserRepository.findByEmail(emailResult.data.getValue(), transaction);
|
||||
if (user.isError()) {
|
||||
return Result.fail(new Error("Invalid email or password"));
|
||||
}
|
||||
|
||||
const isValidPassword = await user.data.validatePassword(password);
|
||||
if (!isValidPassword) {
|
||||
return Result.fail(new Error("Invalid email or password"));
|
||||
}
|
||||
|
||||
const token = JwtHelper.generateToken({ userId: user.data.getUserID() });
|
||||
|
||||
return Result.ok({ token, userId: user.data.getUserID() });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔹 `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.isError()) {
|
||||
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" });
|
||||
}
|
||||
}
|
||||
1
apps/server/src/contexts/auth/application/index.ts
Normal file
1
apps/server/src/contexts/auth/application/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./auth.service";
|
||||
@ -1,4 +1,4 @@
|
||||
import { Result } from "@common/domain";
|
||||
import { Result } from "contexts/common/domain";
|
||||
import { EmailAddress, PasswordHash, Username, UserRoles } from "./value-objects";
|
||||
|
||||
export class AuthUser {
|
||||
2
apps/server/src/contexts/auth/domain/index.ts
Normal file
2
apps/server/src/contexts/auth/domain/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./auth-user.model";
|
||||
export * from "./value-objects";
|
||||
@ -1,4 +1,4 @@
|
||||
import { Result, ValueObject } from "@common/domain";
|
||||
import { Result, ValueObject } from "contexts/common/domain";
|
||||
import { z } from "zod";
|
||||
|
||||
const RoleSchema = z.enum(["Admin", "User", "Manager", "Editor"]);
|
||||
@ -1,4 +1,4 @@
|
||||
import { Result, ValueObject } from "@common/domain";
|
||||
import { Result, ValueObject } from "contexts/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 "@common/domain";
|
||||
import { Result, ValueObject } from "contexts/common/domain";
|
||||
import { z } from "zod";
|
||||
|
||||
export class Username extends ValueObject<string> {
|
||||
@ -0,0 +1,72 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,62 @@
|
||||
import { DataTypes, InferAttributes, InferCreationAttributes, Model } from "sequelize";
|
||||
import { sequelize } from "../../../../config/database";
|
||||
|
||||
export type AuthUserCreationAttributes = InferCreationAttributes<AuthUserModel>;
|
||||
|
||||
class AuthUserModel extends Model<
|
||||
InferAttributes<AuthUserModel>,
|
||||
InferCreationAttributes<AuthUserModel>
|
||||
> {
|
||||
public id!: string;
|
||||
public username!: string;
|
||||
public email!: string;
|
||||
public password!: string;
|
||||
public roles!: string[];
|
||||
public isActive!: boolean;
|
||||
}
|
||||
|
||||
AuthUserModel.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
primaryKey: true,
|
||||
allowNull: false,
|
||||
},
|
||||
username: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
email: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
},
|
||||
password: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
roles: {
|
||||
type: DataTypes.ARRAY(DataTypes.STRING),
|
||||
allowNull: false,
|
||||
defaultValue: [],
|
||||
},
|
||||
isActive: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
tableName: "users",
|
||||
paranoid: true, // softs deletes
|
||||
timestamps: true,
|
||||
|
||||
createdAt: "created_at",
|
||||
updatedAt: "updated_at",
|
||||
deletedAt: "deleted_at",
|
||||
|
||||
indexes: [{ name: "email_idx", fields: ["email"], unique: true }],
|
||||
}
|
||||
);
|
||||
|
||||
export { AuthUserModel };
|
||||
@ -0,0 +1,42 @@
|
||||
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,2 @@
|
||||
export * from "./auth-user.model";
|
||||
export * from "./auth-user.repository";
|
||||
@ -1,2 +1,3 @@
|
||||
export * from "./result";
|
||||
export * from "./unique-id";
|
||||
export * from "./value-object";
|
||||
45
apps/server/src/contexts/common/domain/result.spec.ts
Normal file
45
apps/server/src/contexts/common/domain/result.spec.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { Result } from "./result";
|
||||
|
||||
describe("Result", () => {
|
||||
it("should create a successful result", () => {
|
||||
const result = Result.ok("Success Data");
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(result.isError()).toBe(false);
|
||||
expect(result.data).toBe("Success Data");
|
||||
});
|
||||
|
||||
it("should create a failed result", () => {
|
||||
const error = new Error("Test error");
|
||||
const result = Result.fail(error);
|
||||
|
||||
expect(result.isOk()).toBe(false);
|
||||
expect(result.isError()).toBe(true);
|
||||
expect(result.error).toBe(error);
|
||||
});
|
||||
|
||||
it("should getOrElse return default value if result is a failure", () => {
|
||||
const error = new Error("Test error");
|
||||
const result = Result.fail(error);
|
||||
|
||||
expect(result.getOrElse("Default")).toBe("Default");
|
||||
});
|
||||
|
||||
it("should match execute correct function based on success or failure", () => {
|
||||
const successResult = Result.ok("Success");
|
||||
const failureResult = Result.fail(new Error("Failure"));
|
||||
|
||||
expect(
|
||||
successResult.match(
|
||||
(data) => `OK: ${data}`,
|
||||
(error) => null
|
||||
)
|
||||
).toBe("OK: Success");
|
||||
expect(
|
||||
failureResult.match(
|
||||
(data) => null,
|
||||
(error) => `ERROR: ${error.message}`
|
||||
)
|
||||
).toBe("ERROR: Failure");
|
||||
});
|
||||
});
|
||||
@ -64,7 +64,7 @@ export class Result<T, E extends Error = Error> {
|
||||
* 🔹 `getOrElse(defaultValue: T): T`
|
||||
* Si el `Result` es un `ok`, devuelve `data`, de lo contrario, devuelve `defaultValue`.
|
||||
*/
|
||||
getOrElse(defaultValue: T): T {
|
||||
getOrElse(defaultValue: any): T | any {
|
||||
return this.isSuccess ? this.data : defaultValue;
|
||||
}
|
||||
|
||||
35
apps/server/src/contexts/common/domain/unique-id.spec.ts
Normal file
35
apps/server/src/contexts/common/domain/unique-id.spec.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { UniqueID } from "./unique-id";
|
||||
|
||||
describe("UniqueID Value Object", () => {
|
||||
it("should generate a new UUID using generateNewID()", () => {
|
||||
const result = UniqueID.generateNewID();
|
||||
|
||||
expect(result.isOk()).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
|
||||
);
|
||||
});
|
||||
|
||||
it("should return an error for an invalid UUID", () => {
|
||||
const result = UniqueID.create("invalid-uuid");
|
||||
|
||||
expect(result.isError()).toBe(true);
|
||||
expect(result.error.message).toBe("Invalid UUID format");
|
||||
});
|
||||
|
||||
it("should create a valid UniqueID from an existing UUID", () => {
|
||||
const validUUID = "550e8400-e29b-41d4-a716-446655440000";
|
||||
const result = UniqueID.create(validUUID);
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(result.data.getValue()).toBe(validUUID);
|
||||
});
|
||||
|
||||
it("should correctly convert UniqueID to string", () => {
|
||||
const validUUID = "550e8400-e29b-41d4-a716-446655440000";
|
||||
const result = UniqueID.create(validUUID);
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(result.data.toString()).toBe(validUUID);
|
||||
});
|
||||
});
|
||||
1
apps/server/src/contexts/common/infraestructure/index.ts
Normal file
1
apps/server/src/contexts/common/infraestructure/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./sequelize";
|
||||
@ -0,0 +1 @@
|
||||
export * from "./sequelize-repository";
|
||||
@ -0,0 +1,68 @@
|
||||
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 "./config/app"
|
||||
import { connectToDatabase } from "./config/database";
|
||||
import { createApp } from "./infrastructure/app";
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import express, { Application } from "express";
|
||||
import responseTime from "response-time";
|
||||
import helmet from "helmet";
|
||||
import dotenv from "dotenv";
|
||||
import express, { Application } from "express";
|
||||
import helmet from "helmet";
|
||||
import responseTime from "response-time";
|
||||
import { authRoutes } from "./express";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
@ -22,10 +23,8 @@ export function createApp(): Application {
|
||||
|
||||
app.set("port", process.env.PORT ?? 3002);
|
||||
|
||||
// Rutas (placeholder para bounded contexts)
|
||||
app.get("/", (req, res) => {
|
||||
res.json({ message: "¡Servidor funcionando!" });
|
||||
});
|
||||
// Registrar rutas del módulo de autenticación
|
||||
app.use("/api/auth", authRoutes);
|
||||
|
||||
return app;
|
||||
}
|
||||
13
apps/server/src/infrastructure/express/auth.routes.ts
Normal file
13
apps/server/src/infrastructure/express/auth.routes.ts
Normal file
@ -0,0 +1,13 @@
|
||||
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
apps/server/src/infrastructure/express/index.ts
Normal file
1
apps/server/src/infrastructure/express/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./auth.routes";
|
||||
1
apps/server/src/infrastructure/index.ts
Normal file
1
apps/server/src/infrastructure/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./app";
|
||||
@ -8,8 +8,9 @@
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"baseUrl": "src",
|
||||
"paths": {
|
||||
"@shared/*": ["shared/*"],
|
||||
"@common/*": ["common/*"]
|
||||
"@shared/*": ["../../packages/shared/*"],
|
||||
"@common/*": ["common/*"],
|
||||
"@config/*": ["config/*"]
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user