This commit is contained in:
David Arranz 2025-02-20 19:55:24 +01:00
parent cf38a2ad9d
commit 085a390aa6
81 changed files with 1876 additions and 239 deletions

View File

@ -9,7 +9,6 @@
"prettier.useEditorConfig": false,
"prettier.useTabs": false,
"prettier.configPath": ".prettierrc",
"asciidoc.antora.enableAntoraSupport": true,
// other vscode settings
"tailwindCSS.rootFontSize": 16,

View File

@ -0,0 +1,7 @@
/** @type {import('ts-jest').JestConfigWithTsJest} **/
module.exports = {
testEnvironment: "node",
transform: {
"^.+.tsx?$": ["ts-jest",{}],
},
};

View File

@ -59,6 +59,7 @@
"http": "0.0.1-security",
"http-status": "^2.1.0",
"jsonwebtoken": "^9.0.2",
"libphonenumber-js": "^1.11.20",
"luxon": "^3.5.0",
"mariadb": "^3.4.0",
"module-alias": "^2.2.3",

View File

@ -1,12 +1,12 @@
import { UniqueID } from "./value-objects/unique-id";
export abstract class DomainEntity<T extends object> {
protected readonly _id: UniqueID;
protected readonly _props: T;
protected readonly props: T;
public readonly id: UniqueID;
protected constructor(props: T, id?: UniqueID) {
this._id = id ? id : UniqueID.generateNewID().data;
this._props = props;
this.id = id ? id : UniqueID.generateNewID().data;
this.props = props;
}
protected _flattenProps(props: T): { [s: string]: any } {
@ -18,19 +18,15 @@ export abstract class DomainEntity<T extends object> {
}, {});
}
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);
const flattenProps = this._flattenProps(this.props);
return {
id: this._id.toString(),
id: this.id.toString(),
...flattenProps.map((prop: any) => String(prop)),
};
}

View File

@ -2,5 +2,4 @@ export * from "./aggregate-root";
export * from "./aggregate-root-repository.interface";
export * from "./domain-entity";
export * from "./events/domain-event.interface";
export * from "./result";
export * from "./value-objects";

View File

@ -1,45 +0,0 @@
import { Result } from "./result";
describe("Result", () => {
it("should create a successful result", () => {
const result = Result.ok("Success Data");
expect(result.isSuccess).toBe(true);
expect(result.isSuccess).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.isSuccess).toBe(false);
expect(result.isSuccess).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");
});
});

View File

@ -16,7 +16,7 @@ describe("EmailAddress Value Object", () => {
});
it("should allow null email", () => {
const result = EmailAddress.create(null);
const result = EmailAddress.createNullable();
expect(result.isSuccess).toBe(true);
expect(result.data.getValue()).toBe(null);
@ -48,16 +48,16 @@ describe("EmailAddress Value Object", () => {
});
it("should detect empty email correctly", () => {
const email = EmailAddress.create(null);
const email = EmailAddress.createNullable();
expect(email.isSuccess).toBe(true);
expect(email.data.isEmpty()).toBe(true);
expect(email.data.isSome()).toBe(false);
});
it("should detect non-empty email correctly", () => {
const email = EmailAddress.create("test@example.com");
expect(email.isSuccess).toBe(true);
expect(email.data.isEmpty()).toBe(false);
expect(email.data.getValue()).toBe(true);
});
});

View File

@ -0,0 +1,50 @@
import { Maybe, Result, ValueObject } from "@common/domain";
import { z } from "zod";
interface EmailAddressProps {
value: string;
}
export class EmailAddress extends ValueObject<EmailAddressProps> {
static create(value: string): Result<EmailAddress, Error> {
const valueIsValid = EmailAddress.validate(value);
if (!valueIsValid.success) {
Result.fail(new Error(valueIsValid.error.errors[0].message));
}
return Result.ok(new EmailAddress({ value: valueIsValid.data! }));
}
static createNullable(value?: string): Result<Maybe<EmailAddress>, Error> {
if (!value || value.trim() === "") {
return Result.ok(Maybe.None<EmailAddress>());
}
return EmailAddress.create(value!).map((value) => Maybe.Some(value));
}
private static validate(value: string) {
const schema = z.string().email({ message: "Invalid email format" });
return schema.safeParse(value);
}
getLocalPart(): string {
return this.props.value.split("@")[0];
}
getDomain(): string {
return this.props.value.split("@")[1];
}
getDomainExtension(): string {
return this.getDomain().split(".")[1];
}
getDomainName(): string {
return this.getDomain().split(".")[0];
}
getValue(): string {
return this.props.value;
}
}

View File

@ -1,2 +1,8 @@
export * from "./email-address";
export * from "./name";
export * from "./phone-number";
export * from "./postal-address";
export * from "./slug";
export * from "./tin-number";
export * from "./unique-id";
export * from "./value-object";

View File

@ -0,0 +1,41 @@
import { Name } from "./name";
describe("Name Value Object", () => {
test("Debe crear un Name válido", () => {
const nameResult = Name.create("John Doe");
expect(nameResult.isSuccess).toBe(true);
expect(nameResult.data.getValue()).toBe("John Doe");
});
test("Debe fallar si el nombre excede los 255 caracteres", () => {
const longName = "A".repeat(256);
const nameResult = Name.create(longName);
expect(nameResult.isSuccess).toBe(false);
expect(nameResult.error).toBeInstanceOf(Error);
});
test("Debe permitir un Name nullable vacío", () => {
const nullableNameResult = Name.createNullable("");
expect(nullableNameResult.isSuccess).toBe(true);
expect(nullableNameResult.data.isSome()).toBe(false);
});
test("Debe permitir un Name nullable con un valor válido", () => {
const nullableNameResult = Name.createNullable("Alice");
expect(nullableNameResult.isSuccess).toBe(true);
expect(nullableNameResult.data.isSome()).toBe(true);
expect(nullableNameResult.data.getValue()).toBe("Alice");
});
test("Debe generar acrónimos correctamente", () => {
expect(Name.generateAcronym("John Doe")).toBe("JDXX");
expect(Name.generateAcronym("Alice Bob Charlie")).toBe("ABCX");
expect(Name.generateAcronym("A B C D E")).toBe("ABCD");
expect(Name.generateAcronym("SingleWord")).toBe("SXXX");
});
test("Debe obtener el acrónimo de una instancia de Name", () => {
const name = Name.create("John Doe").data;
expect(name.getAcronym()).toBe("JDXX");
});
});

View File

@ -0,0 +1,61 @@
import { Maybe, Result, ValueObject } from "@common/domain";
import { z } from "zod";
interface NameProps {
value: string;
}
export class Name extends ValueObject<NameProps> {
private static readonly MAX_LENGTH = 255;
protected static validate(value: string) {
const schema = z
.string()
.trim()
.max(Name.MAX_LENGTH, { message: `Name must be at most ${Name.MAX_LENGTH} characters long` });
return schema.safeParse(value);
}
static create(value: string) {
const valueIsValid = Name.validate(value);
if (!valueIsValid.success) {
Result.fail(new Error(valueIsValid.error.errors[0].message));
}
return Result.ok(new Name({ value: valueIsValid.data! }));
}
static createNullable(value?: string): Result<Maybe<Name>, Error> {
if (!value || value.trim() === "") {
return Result.ok(Maybe.None<Name>());
}
return Name.create(value!).map((value) => Maybe.Some(value));
}
static generateAcronym(name: string): string {
const words = name.split(" ").map((word) => word[0].toUpperCase());
let acronym = words.join("");
// Asegurarse de que tenga 4 caracteres, recortando o añadiendo letras
if (acronym.length > 4) {
acronym = acronym.slice(0, 4);
} else if (acronym.length < 4) {
acronym = acronym.padEnd(4, "X"); // Se completa con 'X' si es necesario
}
return acronym;
}
getAcronym(): string {
return Name.generateAcronym(this.toString());
}
getValue(): string {
return this.props.value;
}
toString(): string {
return this.getValue();
}
}

View File

@ -0,0 +1,63 @@
import { isValidPhoneNumber, parsePhoneNumberWithError } from "libphonenumber-js";
import { z } from "zod";
import { Maybe } from "../../helpers/maybe";
import { Result } from "../../helpers/result";
import { ValueObject } from "./value-object";
interface PhoneNumberProps {
value: string;
}
export class PhoneNumber extends ValueObject<PhoneNumberProps> {
static create(value: string): Result<PhoneNumber> {
const valueIsValid = PhoneNumber.validate(value);
if (!valueIsValid.success) {
Result.fail(new Error(valueIsValid.error.errors[0].message));
}
return Result.ok(new PhoneNumber({ value: valueIsValid.data! }));
}
static createNullable(value?: string): Result<Maybe<PhoneNumber>, Error> {
if (!value || value.trim() === "") {
return Result.ok(Maybe.None<PhoneNumber>());
}
return PhoneNumber.create(value!).map((value) => Maybe.Some(value));
}
static validate(value: string) {
const schema = z
.string()
.refine(
isValidPhoneNumber,
"Please specify a valid phone number (include the international prefix)."
)
.transform((value: string) => parsePhoneNumberWithError(value).number.toString());
return schema.safeParse(value);
}
getValue(): string {
return this.props.value;
}
toString(): string {
return this.getValue();
}
getCountryCode(): string | undefined {
return parsePhoneNumberWithError(this.props.value).country;
}
getNationalNumber(): string {
return parsePhoneNumberWithError(this.props.value).nationalNumber;
}
getNumber(): string {
return parsePhoneNumberWithError(this.props.value).number.toString();
}
getExtension(): string | undefined {
return parsePhoneNumberWithError(this.props.value).ext;
}
}

View File

@ -0,0 +1,95 @@
import { z } from "zod";
import { Maybe, Result } from "../../helpers";
import { ValueObject } from "./value-object";
// 📌 Validaciones usando `zod`
const postalCodeSchema = z
.string()
.min(4)
.max(10)
.regex(/^\d{4,10}$/, {
message: "Invalid postal code format",
});
const countrySchema = z.string().min(2).max(56);
const provinceSchema = z.string().min(2).max(50);
const citySchema = z.string().min(2).max(50);
const streetSchema = z.string().min(2).max(255);
interface IPostalAddressProps {
street: string;
city: string;
postalCode: string;
state: string;
country: string;
}
export interface IPostalAddressPrimitives extends IPostalAddressProps {
street: string;
city: string;
postal_code: string;
state: string;
country: string;
}
export class PostalAddress extends ValueObject<IPostalAddressProps> {
protected static validate(values: IPostalAddressProps) {
return z
.object({
street: streetSchema,
city: citySchema,
postalCode: postalCodeSchema,
state: provinceSchema,
country: countrySchema,
})
.safeParse(values);
}
static create(values: IPostalAddressProps): Result<PostalAddress, Error> {
const valueIsValid = PostalAddress.validate(values);
if (!valueIsValid.success) {
Result.fail(new Error(valueIsValid.error.errors[0].message));
}
return Result.ok(new PostalAddress(valueIsValid.data!));
}
static createNullable(values?: IPostalAddressProps): Result<Maybe<PostalAddress>, Error> {
if (!values || Object.values(values).every((value) => value.trim() === "")) {
return Result.ok(Maybe.None<PostalAddress>());
}
return PostalAddress.create(values!).map((value) => Maybe.Some(value));
}
get street(): string {
return this.props.street;
}
get city(): string {
return this.props.city;
}
get postalCode(): string {
return this.props.postalCode;
}
get state(): string {
return this.props.state;
}
get country(): string {
return this.props.country;
}
getValue(): IPostalAddressProps {
return this.props;
}
toString(): string {
return `${this.props.street}, ${this.props.city}, ${this.props.postalCode}, ${this.props.state}, ${this.props.country}`;
}
}

View File

@ -0,0 +1,41 @@
import { Slug } from "./slug"; // Ajusta la ruta según corresponda
describe("Slug Value Object", () => {
test("Debe crear un Slug válido", () => {
const slugResult = Slug.create("valid-slug-123");
expect(slugResult.isSuccess).toBe(true);
expect(slugResult.data.getValue()).toBe("valid-slug-123");
});
test("Debe fallar si el Slug contiene caracteres inválidos", () => {
const slugResult = Slug.create("Invalid_Slug!");
expect(slugResult.isSuccess).toBe(false);
expect(slugResult.error).toBeInstanceOf(Error);
});
test("Debe fallar si el Slug tiene menos de 2 caracteres", () => {
const slugResult = Slug.create("a");
expect(slugResult.isSuccess).toBe(false);
expect(slugResult.error).toBeInstanceOf(Error);
});
test("Debe fallar si el Slug tiene más de 100 caracteres", () => {
const longSlug = "a".repeat(101);
const slugResult = Slug.create(longSlug);
expect(slugResult.isSuccess).toBe(false);
expect(slugResult.error).toBeInstanceOf(Error);
});
test("Debe permitir un Slug nullable vacío", () => {
const nullableSlugResult = Slug.createNullable("");
expect(nullableSlugResult.isSuccess).toBe(true);
expect(nullableSlugResult.data.isSome()).toBe(false);
});
test("Debe permitir un Slug nullable con un valor válido", () => {
const nullableSlugResult = Slug.createNullable("my-slug");
expect(nullableSlugResult.isSuccess).toBe(true);
expect(nullableSlugResult.data.isSome()).toBe(true);
expect(nullableSlugResult.data.getValue()).toBe("my-slug");
});
});

View File

@ -0,0 +1,48 @@
import { Maybe, Result, ValueObject } from "@common/domain";
import { z } from "zod";
interface SlugProps {
value: string;
}
export class Slug extends ValueObject<SlugProps> {
protected static readonly MIN_LENGTH = 2;
protected static readonly MAX_LENGTH = 100;
protected static validate(value: string) {
const schema = z
.string()
.trim()
.regex(/^[a-z0-9-]+$/, {
message: "Slug must contain only lowercase letters, numbers, and hyphens",
})
.min(Slug.MIN_LENGTH, { message: `Slug must be at least ${Slug.MIN_LENGTH} characters long` })
.max(Slug.MAX_LENGTH, { message: `Slug must be at most ${Slug.MAX_LENGTH} characters long` });
return schema.safeParse(value);
}
static create(value: string) {
const valueIsValid = Slug.validate(value);
if (!valueIsValid.success) {
Result.fail(new Error(valueIsValid.error.errors[0].message));
}
return Result.ok(new Slug({ value: valueIsValid.data! }));
}
static createNullable(value?: string): Result<Maybe<Slug>, Error> {
if (!value || value.trim() === "") {
return Result.ok(Maybe.None<Slug>());
}
return Slug.create(value!).map((value) => Maybe.Some(value));
}
getValue(): string {
return this.props.value;
}
toString(): string {
return this.getValue();
}
}

View File

@ -0,0 +1,51 @@
import { z } from "zod";
import { Maybe, Result } from "../../helpers";
import { ValueObject } from "./value-object";
interface TINNumberProps {
value: string;
}
export class TINNumber extends ValueObject<TINNumberProps> {
private static readonly MIN_LENGTH = 2;
private static readonly MAX_LENGTH = 10;
protected static validate(value: string) {
const schema = z
.string()
.trim()
.min(TINNumber.MIN_LENGTH, {
message: `TIN must be at least ${TINNumber.MIN_LENGTH} characters long`,
})
.max(TINNumber.MAX_LENGTH, {
message: `TIN must be at most ${TINNumber.MAX_LENGTH} characters long`,
});
return schema.safeParse(value);
}
static create(value: string): Result<TINNumber, Error> {
const valueIsValid = TINNumber.validate(value);
if (!valueIsValid.success) {
Result.fail(new Error(valueIsValid.error.errors[0].message));
}
return Result.ok(new TINNumber({ value: valueIsValid.data! }));
}
static createNullable(value?: string): Result<Maybe<TINNumber>, Error> {
if (!value || value.trim() === "") {
return Result.ok(Maybe.None<TINNumber>());
}
return TINNumber.create(value!).map((value) => Maybe.Some(value));
}
getValue(): string {
return this.props.value;
}
toString(): string {
return this.props !== null && this.props !== undefined ? String(this.props) : "";
}
}

View File

@ -1,29 +1,18 @@
import { v4 as uuidv4 } from "uuid";
import { z } from "zod";
import { Result } from "../result";
import { Result } from "../../helpers/result";
import { ValueObject } from "./value-object";
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 === null) {
return Result.fail(new Error("ID cannot be null"));
if (!id || id?.trim() === "") {
if (!generateOnEmpty) {
return Result.fail(new Error("ID cannot be undefined or null"));
}
UniqueID.generateNewID();
}
const trimmedId = id?.trim();
if (!trimmedId) {
return generateOnEmpty ? UniqueID.generateNewID() : Result.ok(new UniqueID(UNDEFINED_ID));
}
const result = UniqueID.validate(trimmedId);
const result = UniqueID.validate(id!);
return result.success
? Result.ok(new UniqueID(result.data))
@ -35,19 +24,19 @@ export class UniqueID extends ValueObject<string | undefined> {
}
static validate(id: string) {
const schema = z.string().uuid({ message: "Invalid UUID format" });
return schema.safeParse(id.trim());
const schema = z.string().trim().uuid({ message: "Invalid UUID format" });
return schema.safeParse(id);
}
static generateNewID(): Result<UniqueID, never> {
return Result.ok(new UniqueID(uuidv4()));
}
static generateUndefinedID(): Result<UniqueID, never> {
return Result.ok(new UniqueID(UNDEFINED_ID));
getValue(): string {
return this.props;
}
isDefined(): boolean {
return this._hasId;
toString(): string {
return this.props;
}
}

View File

@ -1,33 +1,31 @@
import { shallowEqual } from "shallow-equal-object";
export abstract class ValueObject<T> {
protected readonly _value: T;
protected readonly props: T;
protected constructor(value: T) {
this._value = typeof value === "object" && value !== null ? Object.freeze(value) : value;
protected constructor(props: T) {
this.props = Object.freeze(props); // 🔒 Garantiza inmutabilidad
}
abstract getValue(): any;
equals(other: ValueObject<T>): boolean {
if (!(other instanceof ValueObject)) {
return false;
}
if (other._value === undefined || other._value === null) {
if (other.props === undefined || other.props === null) {
return false;
}
return shallowEqual(this._value, other._value);
return shallowEqual(this.props, other.props);
}
getValue(): T {
return this._value;
}
/*isEmpty(): boolean {
return this.props === null || this.props === undefined;
}*/
isEmpty(): boolean {
return this._value === null || this._value === undefined;
}
toString(): string {
return this._value !== null && this._value !== undefined ? String(this._value) : "";
}
/*toString(): string {
return this.props !== null && this.props !== undefined ? String(this.props) : "";
}*/
}

View File

@ -0,0 +1,39 @@
import { ValueObject } from "./value-object";
class TestValueObject extends ValueObject<{ prop: string }> {
constructor(prop: string) {
super({ prop });
}
}
describe("ValueObject", () => {
test("debe considerar dos ValueObjects con los mismos valores como iguales", () => {
const vo1 = new TestValueObject("test");
const vo2 = new TestValueObject("test");
expect(vo1.equals(vo2)).toBe(true);
});
test("debe considerar dos ValueObjects con valores diferentes como distintos", () => {
const vo1 = new TestValueObject("test1");
const vo2 = new TestValueObject("test2");
expect(vo1.equals(vo2)).toBe(false);
});
test("debe devolver false si el objeto comparado no es una instancia de ValueObject", () => {
const vo1 = new TestValueObject("test");
expect(vo1.equals({ prop: "test" } as any)).toBe(false);
});
test("debe devolver false si el objeto comparado es null o undefined", () => {
const vo1 = new TestValueObject("test");
expect(vo1.equals(null as any)).toBe(false);
expect(vo1.equals(undefined as any)).toBe(false);
});
test("debe garantizar la inmutabilidad de las propiedades", () => {
const vo = new TestValueObject("immutable");
expect(() => {
(vo as any).props.prop = "mutated";
}).toThrow();
});
});

View File

@ -0,0 +1,40 @@
import { Collection } from "./collection";
describe("Collection<T>", () => {
let collection: Collection<number>;
beforeEach(() => {
collection = new Collection<number>([1, 2, 3], 5);
});
test("should initialize with given items", () => {
expect(collection.getAll()).toEqual([1, 2, 3]);
expect(collection.total()).toBe(5);
});
test("should add an item", () => {
collection.add(4);
expect(collection.getAll()).toEqual([1, 2, 3, 4]);
expect(collection.total()).toBe(6);
});
test("should remove an existing item", () => {
const removed = collection.remove(2);
expect(removed).toBe(true);
expect(collection.getAll()).toEqual([1, 3]);
expect(collection.total()).toBe(4);
});
test("should not remove a non-existing item", () => {
const removed = collection.remove(99);
expect(removed).toBe(false);
expect(collection.getAll()).toEqual([1, 2, 3]);
expect(collection.total()).toBe(5);
});
test("should reset the collection", () => {
collection.reset();
expect(collection.getAll()).toEqual([]);
expect(collection.total()).toBe(0);
});
});

View File

@ -0,0 +1,82 @@
// Interfaz para definir las operaciones básicas de una colección
/*interface ICollection<T> {
reset(): void;
add(item: T): void;
remove(item: T): boolean;
getAll(): T[];
size(): number;
total(): number | null;
}*/
export class Collection<T> /*implements ICollection<T>*/ {
private items: T[];
private totalItems: number | null;
constructor(items: T[] = [], totalItems: number | null = null) {
this.items = [...items];
this.totalItems = totalItems ?? items.length;
}
// Resetea la colección
reset(): void {
this.items = [];
this.totalItems = 0;
}
// Agrega un elemento a la colección
add(item: T): void {
this.items.push(item);
if (this.totalItems !== null) {
this.totalItems++;
}
}
// Elimina un elemento de la colección
remove(item: T): boolean {
const index = this.items.indexOf(item);
if (index !== -1) {
this.items.splice(index, 1);
if (this.totalItems !== null) {
this.totalItems--;
}
return true;
}
return false;
}
// Devuelve todos los elementos
getAll(): T[] {
return [...this.items];
}
// Devuelve el número de elementos en la colección
size(): number {
return this.items.length;
}
// Devuelve el total de elementos esperados en la colección
total(): number | null {
return this.totalItems;
}
// Implementación de operaciones nativas de array
map<U>(callback: (item: T, index: number, array: T[]) => U): U[] {
return this.items.map(callback);
}
filter(callback: (item: T, index: number, array: T[]) => boolean): T[] {
return this.items.filter(callback);
}
find(callback: (item: T, index: number, array: T[]) => boolean): T | undefined {
return this.items.find(callback);
}
some(callback: (item: T, index: number, array: T[]) => boolean): boolean {
return this.items.some(callback);
}
every(callback: (item: T, index: number, array: T[]) => boolean): boolean {
return this.items.every(callback);
}
}

View File

@ -0,0 +1,4 @@
export * from "./collection";
export * from "./maybe";
export * from "./result";
export * from "./utils";

View File

@ -0,0 +1,31 @@
import { Maybe } from "./maybe";
describe("Maybe", () => {
test("debe contener un valor cuando se usa Some", () => {
const maybeNumber = Maybe.Some(42);
expect(maybeNumber.isSome()).toBe(true);
expect(maybeNumber.getValue()).toBe(42);
});
test("debe estar vacío cuando se usa None", () => {
const maybeEmpty = Maybe.None<number>();
expect(maybeEmpty.isSome()).toBe(false);
expect(maybeEmpty.getValue()).toBeUndefined();
});
test("map debe transformar el valor si existe", () => {
const maybeNumber = Maybe.Some(10);
const maybeDoubled = maybeNumber.map((n) => n * 2);
expect(maybeDoubled.isSome()).toBe(true);
expect(maybeDoubled.getValue()).toBe(20);
});
test("map debe retornar None si el valor no existe", () => {
const maybeEmpty = Maybe.None<number>();
const maybeTransformed = maybeEmpty.map((n) => n * 2);
expect(maybeTransformed.isSome()).toBe(false);
expect(maybeTransformed.getValue()).toBeUndefined();
});
});

View File

@ -0,0 +1,34 @@
/**
* Uso:
*
* const maybeNumber = Maybe.Some(10);
* const doubled = maybeNumber.map(n => n * 2);
* console.log(doubled.getValue()); // 20
* const noValue = Maybe.None<number>();
* console.log(noValue.isSome()); // false
**/
export class Maybe<T> {
private constructor(private readonly value?: T) {}
static Some<T>(value: T): Maybe<T> {
return new Maybe<T>(value);
}
static None<T>(): Maybe<T> {
return new Maybe<T>();
}
isSome(): boolean {
return this.value !== undefined;
}
getValue(): T | undefined {
return this.value;
}
map<U>(fn: (value: T) => U): Maybe<U> {
return this.isSome() ? Maybe.Some(fn(this.value as T)) : Maybe.None();
}
}

View File

@ -0,0 +1,73 @@
import { Result } from "./result";
describe("Result", () => {
test("debe crear un resultado exitoso con datos", () => {
const result = Result.ok("Success data");
expect(result.isSuccess).toBe(true);
expect(result.isFailure).toBe(false);
expect(result.data).toBe("Success data");
});
test("debe crear un resultado fallido con un error", () => {
const error = new Error("Something went wrong");
const result = Result.fail(error);
expect(result.isSuccess).toBe(false);
expect(result.isFailure).toBe(true);
expect(result.error).toBe(error);
});
test("debe lanzar un error al intentar obtener data de un resultado fallido", () => {
const result = Result.fail(new Error("Failure"));
expect(() => result.getData()).toThrow("Cannot get value data from a failed result.");
});
test("debe lanzar un error al intentar obtener error de un resultado exitoso", () => {
const result = Result.ok("Success");
expect(() => result.getError()).toThrow("Cannot get error from a successful result.");
});
test("debe devolver el valor por defecto cuando se usa getOrElse en un resultado fallido", () => {
const result = Result.fail(new Error("Failure"));
expect(result.getOrElse("Default value")).toBe("Default value");
});
test("debe devolver el valor correcto cuando se usa getOrElse en un resultado exitoso", () => {
const result = Result.ok("Success");
expect(result.getOrElse("Default value")).toBe("Success");
});
test("debe ejecutar la función correcta en match dependiendo del resultado", () => {
const successResult = Result.ok("Success");
const failureResult = Result.fail(new Error("Failure"));
const successMatch = successResult.match(
(data) => `Success: ${data}`,
(error) => `Error: ${error}}`
);
expect(successMatch).toBe("Success: Success");
const failureMatch = failureResult.match(
(data) => `Success: ${data}`,
(error) => `Error: ${error.message}`
);
expect(failureMatch).toBe("Error: Failure");
});
test("combine debe devolver el primer resultado fallido si hay alguno", () => {
const success1 = Result.ok("Success 1");
const failure = Result.fail(new Error("Failure"));
const success2 = Result.ok("Success 2");
const combined = Result.combine([success1, failure, success2]);
expect(combined.isFailure).toBe(true);
expect(combined.error.message).toBe("Failure");
});
test("combine debe devolver un resultado exitoso si todos los resultados son exitosos", () => {
const success1 = Result.ok("Success 1");
const success2 = Result.ok("Success 2");
const combined = Result.combine([success1, success2]);
expect(combined.isSuccess).toBe(true);
});
});

View File

@ -67,6 +67,13 @@ export class Result<T, E extends Error = Error> {
return this._data as T;
}
map<U>(fn: (value: T) => U): Result<U, Error> {
if (this.isSuccess && this._data !== undefined) {
return Result.ok<U>(fn(this.data));
}
return Result.fail(this.error || new Error("Unknown error"));
}
/**
* 🔹 `getOrElse(defaultValue: T): T`
* Si el `Result` es un `ok`, devuelve `data`, de lo contrario, devuelve `defaultValue`.

View File

@ -0,0 +1,46 @@
// Función genérica para asegurar valores básicos
function ensure<T>(value: T | undefined | null, defaultValue: T): T {
return value ?? defaultValue;
}
// Implementaciones específicas para tipos básicos
const ensureString = (value?: string): string => ensure(value, "");
const ensureNumber = (value?: number): number => ensure(value, 0);
const ensureBoolean = (value?: boolean): boolean => ensure(value, false);
const ensureBigInt = (value?: bigint): bigint => ensure(value, BigInt(0));
const ensureSymbol = (value?: symbol, defaultSymbol = Symbol()): symbol =>
ensure(value, defaultSymbol);
describe("ensure functions", () => {
test("ensureString should return string value or default", () => {
expect(ensureString("Hola")).toBe("Hola");
expect(ensureString(undefined)).toBe("");
expect(ensureString(null as any)).toBe("");
});
test("ensureNumber should return number value or default", () => {
expect(ensureNumber(42)).toBe(42);
expect(ensureNumber(undefined)).toBe(0);
expect(ensureNumber(null as any)).toBe(0);
});
test("ensureBoolean should return boolean value or default", () => {
expect(ensureBoolean(true)).toBe(true);
expect(ensureBoolean(false)).toBe(false);
expect(ensureBoolean(undefined)).toBe(false);
expect(ensureBoolean(null as any)).toBe(false);
});
test("ensureBigInt should return bigint value or default", () => {
expect(ensureBigInt(BigInt(123))).toBe(BigInt(123));
expect(ensureBigInt(undefined)).toBe(BigInt(0));
expect(ensureBigInt(null as any)).toBe(BigInt(0));
});
test("ensureSymbol should return symbol value or default", () => {
const sym = Symbol("test");
expect(ensureSymbol(sym)).toBe(sym);
expect(typeof ensureSymbol(undefined)).toBe("symbol");
expect(typeof ensureSymbol(null as any)).toBe("symbol");
});
});

View File

@ -0,0 +1,12 @@
// Función genérica para asegurar valores básicos
function ensure<T>(value: T | undefined | null, defaultValue: T): T {
return value ?? defaultValue;
}
// Implementaciones específicas para tipos básicos
export const ensureString = (value?: string): string => ensure(value, "");
export const ensureNumber = (value?: number): number => ensure(value, 0);
export const ensureBoolean = (value?: boolean): boolean => ensure(value, false);
export const ensureBigInt = (value?: bigint): bigint => ensure(value, BigInt(0));
export const ensureSymbol = (value?: symbol, defaultSymbol = Symbol()): symbol =>
ensure(value, defaultSymbol);

View File

@ -1,2 +1,3 @@
export * from "./sequelize-mapper";
export * from "./sequelize-repository";
export * from "./sequelize-transaction-manager";

View File

@ -0,0 +1,67 @@
import { DomainEntity } from "@common/domain";
import { Collection, Result } from "@common/helpers";
import { Model } from "sequelize";
import { SequelizeMapper } from "./sequelize-mapper";
// Mock Entities
class MockEntity extends DomainEntity<any> {}
class MockModel extends Model {
dataValues: Record<string, any> = {};
}
// Mock Mapper
class MockSequelizeMapper extends SequelizeMapper<MockModel, Record<string, any>, MockEntity> {
public mapToDomain(source: MockModel, params?: Record<string, any>): Result<MockEntity, Error> {
return Result.ok(new MockEntity({ ...source.dataValues, ...params }));
}
public mapToPersistence(
source: MockEntity,
params?: Record<string, any>
): Result<Record<string, any>, Error> {
return Result.ok({ ...source.props, ...params });
}
}
describe("SequelizeMapper", () => {
let mapper: MockSequelizeMapper;
let model: MockModel;
beforeEach(() => {
mapper = new MockSequelizeMapper({});
model = new MockModel();
model.dataValues = { id: 1, name: "Test" };
});
test("should map a model to a domain entity", () => {
const result = mapper.mapToDomain(model);
expect(result.isSuccess).toBe(true);
expect(result.data).toBeInstanceOf(MockEntity);
expect(result.data.props.id).toBe(1);
expect(result.data.props.name).toBe("Test");
});
test("should map an entity to a persistence object", () => {
const entity = new MockEntity({ id: 1, name: "Test" });
const result = mapper.mapToPersistence(entity);
expect(result.isSuccess).toBe(true);
expect(result.data).toEqual({ id: 1, name: "Test" });
});
test("should map an array of models to a collection of domain entities", () => {
const models = [model, { ...model, dataValues: { id: 2, name: "Test2" } }];
const result = mapper.mapArrayToDomain(models);
expect(result.isSuccess).toBe(true);
expect(result.data).toBeInstanceOf(Collection);
expect(result.data.items.length).toBe(2);
});
test("should handle mapping failures gracefully", () => {
jest
.spyOn(mapper, "mapToDomain")
.mockImplementation(() => Result.fail(new Error("Mapping failed")));
const result = mapper.mapToDomain(model);
expect(result.isFailure).toBe(true);
expect(result.error?.message).toBe("Mapping failed");
});
});

View File

@ -0,0 +1,121 @@
import { DomainEntity } from "@common/domain";
import { Collection, Result } from "@common/helpers";
import { Model } from "sequelize";
export type MapperParamsType = Record<string, any>;
interface IDomainMapper<TModel extends Model, TEntity extends DomainEntity<any>> {
mapToDomain(source: TModel, params?: MapperParamsType): Result<TEntity, Error>;
mapArrayToDomain(source: TModel[], params?: MapperParamsType): Result<Collection<TEntity>, Error>;
mapArrayAndCountToDomain(
source: TModel[],
totalCount: number,
params?: MapperParamsType
): Result<Collection<TEntity>, Error>;
}
interface IPersistenceMapper<TModelAttributes, TEntity extends DomainEntity<any>> {
mapToPersistence(source: TEntity, params?: MapperParamsType): Result<TModelAttributes, Error>;
mapCollectionToPersistence(
source: Collection<TEntity>,
params?: MapperParamsType
): Result<TModelAttributes[], Error>;
}
export interface ISequelizeMapper<
TModel extends Model,
TModelAttributes,
TEntity extends DomainEntity<any>,
> extends IDomainMapper<TModel, TEntity>,
IPersistenceMapper<TModelAttributes, TEntity> {}
export abstract class SequelizeMapper<
TModel extends Model,
TModelAttributes,
TEntity extends DomainEntity<any>,
> implements ISequelizeMapper<TModel, TModelAttributes, TEntity>
{
public abstract mapToDomain(source: TModel, params?: MapperParamsType): Result<TEntity, Error>;
public mapArrayToDomain(
source: TModel[],
params?: MapperParamsType
): Result<Collection<TEntity>, Error> {
return this.mapArrayAndCountToDomain(source, source.length, params);
}
public mapArrayAndCountToDomain(
source: TModel[],
totalCount: number,
params?: MapperParamsType
): Result<Collection<TEntity>, Error> {
try {
const items = source.map(
(value, index) => this.mapToDomain(value, { index, ...params }).data
);
return Result.ok(new Collection(items, totalCount));
} catch (error) {
return Result.fail(error as Error);
}
}
public abstract mapToPersistence(
source: TEntity,
params?: MapperParamsType
): Result<TModelAttributes, Error>;
public mapCollectionToPersistence(
source: Collection<TEntity>,
params?: MapperParamsType
): Result<TModelAttributes[], Error> {
try {
const result = source.map(
(value, index) => this.mapToPersistence(value, { index, ...params }).data
);
return Result.ok(result);
} catch (error) {
return Result.fail(error as Error);
}
}
protected safeMap<T>(operation: () => T, key: string): Result<T, Error> {
try {
return Result.ok(operation());
} catch (error: unknown) {
return Result.fail(error as Error);
}
}
protected mapsValue(
row: TModel,
key: string,
customMapFn: (value: any, params: MapperParamsType) => Result<any, Error>,
params: MapperParamsType = { defaultValue: null }
): Result<any, Error> {
return customMapFn(row?.dataValues[key] ?? params.defaultValue, params);
}
protected mapsAssociation(
row: TModel,
associationName: string,
customMapper: IDomainMapper<any, any>,
params: MapperParamsType = {}
): Result<any, Error> {
if (!customMapper) {
Result.fail(Error(`Custom mapper undefined for ${associationName}`));
}
const { filter, ...otherParams } = params;
let associationRows = row?.dataValues[associationName] ?? [];
if (filter) {
associationRows = Array.isArray(associationRows)
? associationRows.filter(filter)
: filter(associationRows);
}
return Array.isArray(associationRows)
? customMapper.mapArrayToDomain(associationRows, otherParams)
: customMapper.mapToDomain(associationRows, otherParams);
}
}

View File

@ -1,4 +1,5 @@
import { IAggregateRootRepository, Result, UniqueID } from "@common/domain";
import { IAggregateRootRepository, UniqueID } from "@common/domain";
import { Result } from "@common/helpers";
import { ModelDefined, Transaction } from "sequelize";
import { logger } from "../logger";

View File

@ -44,15 +44,15 @@ export class AuthenticatedUser
}
verifyPassword(candidatePassword: PlainPassword): Promise<boolean> {
return this._props.hashPassword.verifyPassword(candidatePassword.toString());
return this.props.hashPassword.verifyPassword(candidatePassword.toString());
}
getRoles(): string[] {
return this._props.roles;
return this.props.roles;
}
hasRole(role: string): boolean {
return (this._props.roles || []).some((r) => r === role);
return (this.props.roles || []).some((r) => r === role);
}
hasRoles(roles: string[]): boolean {
@ -60,11 +60,11 @@ export class AuthenticatedUser
}
get username(): Username {
return this._props.username;
return this.props.username;
}
get email(): EmailAddress {
return this._props.email;
return this.props.email;
}
get isUser(): boolean {
@ -80,11 +80,11 @@ export class AuthenticatedUser
*/
toPersistenceData(): any {
return {
id: this._id.toString(),
username: this._props.username.toString(),
email: this._props.email.toString(),
hash_password: this._props.hashPassword.toString(),
roles: this._props.roles.map((role) => role.toString()),
id: this.id.toString(),
username: this.props.username.toString(),
email: this.props.email.toString(),
hash_password: this.props.hashPassword.toString(),
roles: this.props.roles.map((role) => role.toString()),
access_token: this.accessToken,
refresh_token: this.refreshToken,
};

View File

@ -1,6 +1,6 @@
import { AggregateRoot, Result, UniqueID } from "@common/domain";
import { AggregateRoot, EmailAddress, Result, UniqueID } from "@common/domain";
import { UserAuthenticatedEvent } from "../events";
import { EmailAddress, Username } from "../value-objects";
import { Username } from "../value-objects";
export interface IUserProps {
username: Username;
@ -33,11 +33,11 @@ export class User extends AggregateRoot<IUserProps> implements IUser {
}
getRoles(): string[] {
return this._props.roles;
return this.props.roles;
}
hasRole(role: string): boolean {
return (this._props.roles || []).some((r) => r === role);
return (this.props.roles || []).some((r) => r === role);
}
hasRoles(roles: string[]): boolean {
@ -45,11 +45,11 @@ export class User extends AggregateRoot<IUserProps> implements IUser {
}
get username(): Username {
return this._props.username;
return this.props.username;
}
get email(): EmailAddress {
return this._props.email;
return this.props.email;
}
get isUser(): boolean {
@ -65,10 +65,10 @@ export class User extends AggregateRoot<IUserProps> implements IUser {
*/
toPersistenceData(): any {
return {
id: this._id.toString(),
username: this._props.username.toString(),
email: this._props.email.toString(),
roles: this._props.roles.map((role) => role.toString()),
id: this.id.toString(),
username: this.props.username.toString(),
email: this.props.email.toString(),
roles: this.props.roles.map((role) => role.toString()),
};
}
}

View File

@ -54,15 +54,15 @@ export class JWTPayload extends DomainEntity<IJWTPayloadProps> implements IJWTPa
}
get tabId(): UniqueID {
return this._props.tabId;
return this.props.tabId;
}
get userId(): UniqueID {
return this._props.userId;
return this.props.userId;
}
get email(): EmailAddress {
return this._props.email;
return this.props.email;
}
toPersistenceData(): any {

View File

@ -48,14 +48,14 @@ export class LoginData extends DomainEntity<ILoginDataProps> implements ILoginDa
}
get email(): EmailAddress {
return this._props.email;
return this.props.email;
}
get plainPassword(): PlainPassword {
return this._props.plainPassword;
return this.props.plainPassword;
}
get tabId(): UniqueID {
return this._props.tabId;
return this.props.tabId;
}
}

View File

@ -43,10 +43,10 @@ export class LogoutData extends DomainEntity<ILogoutDataProps> implements ILogou
}
get email(): EmailAddress {
return this._props.email;
return this.props.email;
}
get tabId(): UniqueID {
return this._props.tabId;
return this.props.tabId;
}
}

View File

@ -48,14 +48,14 @@ export class RegisterData extends DomainEntity<IRegisterDataProps> implements IR
}
get username(): Username {
return this._props.username;
return this.props.username;
}
get email(): EmailAddress {
return this._props.email;
return this.props.email;
}
get hashPassword(): HashPassword {
return this._props.hashPassword;
return this.props.hashPassword;
}
}

View File

@ -41,16 +41,16 @@ export class TabContext extends DomainEntity<ITabContextProps> implements ITabCo
}
get tabId(): UniqueID {
return this._props.tabId;
return this.props.tabId;
}
get userId(): UniqueID {
return this._props.userId;
return this.props.userId;
}
toPersistenceData(): ITabContextPrimitives {
return {
id: this._id.toString(),
id: this.id.toString(),
tab_id: this.tabId.toString(),
user_id: this.userId.toString(),
};

View File

@ -17,6 +17,6 @@ export class UserRoles extends ValueObject<string[]> {
}
hasRole(role: string): boolean {
return this._value.includes(role);
return this.props.includes(role);
}
}

View File

@ -1,28 +0,0 @@
import { Result, ValueObject } from "@common/domain";
import { logger } from "@common/infrastructure/logger";
import { z } from "zod";
export const NULLED_EMAIL_ADDRESS = null;
export class EmailAddress extends ValueObject<string | null> {
static create(email: string | null): Result<EmailAddress, Error> {
logger.debug(`Creating EmailAddress from ${email}`);
const normalizedEmail =
email?.trim() === "" ? NULLED_EMAIL_ADDRESS : email?.toLowerCase() || NULLED_EMAIL_ADDRESS;
const result = EmailAddress.validate(normalizedEmail);
return result.success
? Result.ok(new EmailAddress(result.data))
: Result.fail(new Error(result.error.errors[0].message));
}
private static validate(email: string | null) {
const schema = z.string().email({ message: "Invalid email format" }).or(z.null());
return schema.safeParse(email);
}
isDefined(): boolean {
return !this.isEmpty();
}
}

View File

@ -1,33 +1,44 @@
import bcrypt from "bcrypt";
import { HashPassword } from "./hash-password";
describe("PasswordHash Value Object", () => {
it("should hash a valid password", async () => {
const result = HashPassword.create("StrongPass123");
describe("HashPassword", () => {
test("debe crear una instancia de HashPassword desde un texto plano válido", () => {
const result = HashPassword.createFromPlainText("securepassword");
expect(result.isSuccess).toBe(true);
expect(result.data.getValue()).not.toBe("StrongPass123"); // Should be hashed
expect(result.data).toBeInstanceOf(HashPassword);
});
it("should return an error for short password", async () => {
const result = HashPassword.create("12345");
expect(result.isSuccess).toBe(true);
test("debe fallar al crear una instancia con una contraseña demasiado corta", () => {
const result = HashPassword.createFromPlainText("123");
expect(result.isFailure).toBe(true);
expect(result.error.message).toBe("Password must be at least 6 characters long");
});
it("should validate password comparison correctly", async () => {
const result = HashPassword.create("SecurePass123");
test("debe crear una instancia de HashPassword desde un hash válido", () => {
const hashedPassword = bcrypt.hashSync("securepassword", 10);
const result = HashPassword.createFromHash(hashedPassword);
expect(result.isSuccess).toBe(true);
expect(result.data).toBeInstanceOf(HashPassword);
});
const isValid = await result.data.verifyPassword("SecurePass123");
test("debe verificar correctamente una contraseña válida", async () => {
const password = "securepassword";
const result = HashPassword.createFromPlainText(password);
expect(result.isSuccess).toBe(true);
const hashPasswordInstance = result.data;
const isValid = await hashPasswordInstance.verifyPassword(password);
expect(isValid).toBe(true);
});
it("should fail password comparison for incorrect passwords", async () => {
const result = HashPassword.create("SecurePass123");
test("debe fallar la verificación con una contraseña incorrecta", async () => {
const password = "securepassword";
const wrongPassword = "wrongpassword";
const result = HashPassword.createFromPlainText(password);
expect(result.isSuccess).toBe(true);
const hashPasswordInstance = result.data;
const isValid = await result.data.verifyPassword("WrongPassword");
const isValid = await hashPasswordInstance.verifyPassword(wrongPassword);
expect(isValid).toBe(false);
});
});

View File

@ -2,7 +2,11 @@ import { Result, ValueObject } from "@common/domain";
import bcrypt from "bcrypt";
import { z } from "zod";
export class HashPassword extends ValueObject<string> {
interface HashPasswordProps {
value: string;
}
export class HashPassword extends ValueObject<HashPasswordProps> {
private static readonly SALT_ROUNDS = 10;
static createFromPlainText(plainTextPassword: string): Result<HashPassword, Error> {
@ -13,7 +17,7 @@ export class HashPassword extends ValueObject<string> {
}
const hashed = bcrypt.hashSync(result.data, this.SALT_ROUNDS);
return Result.ok(new HashPassword(hashed));
return Result.ok(new HashPassword({ value: hashed }));
}
private static validate(password: string) {
@ -22,10 +26,18 @@ export class HashPassword extends ValueObject<string> {
}
static createFromHash(hashedPassword: string): Result<HashPassword, Error> {
return Result.ok(new HashPassword(hashedPassword));
return Result.ok(new HashPassword({ value: hashedPassword }));
}
async verifyPassword(plainTextPassword: string): Promise<boolean> {
return await bcrypt.compare(plainTextPassword, this._value);
return await bcrypt.compare(plainTextPassword, this.props.value);
}
getValue() {
return this.props.value;
}
toString() {
return this.props.value;
}
}

View File

@ -1,5 +1,4 @@
export * from "./auth-user-roles";
export * from "./email-address";
export * from "./hash-password";
export * from "./plain-password";
export * from "./token";

View File

@ -1,7 +1,11 @@
import { Result, ValueObject } from "@common/domain";
import { z } from "zod";
export class PlainPassword extends ValueObject<string> {
interface PlainPasswordProps {
value: string;
}
export class PlainPassword extends ValueObject<PlainPasswordProps> {
static create(plainTextPassword: string): Result<PlainPassword, Error> {
const result = PlainPassword.validate(plainTextPassword);
@ -9,11 +13,19 @@ export class PlainPassword extends ValueObject<string> {
return Result.fail(new Error(result.error.errors[0].message));
}
return Result.ok(new PlainPassword(result.data));
return Result.ok(new PlainPassword({ value: result.data }));
}
private static validate(password: string) {
const schema = z.string().min(6, { message: "Password must be at least 6 characters long" });
return schema.safeParse(password);
}
getValue() {
return this.props.value;
}
toString() {
return this.props.value;
}
}

View File

@ -1,7 +1,11 @@
import { Result, ValueObject } from "@common/domain";
import { z } from "zod";
export class Token extends ValueObject<string> {
interface TokenProps {
value: string;
}
export class Token extends ValueObject<TokenProps> {
static create(token: string): Result<Token, Error> {
const result = Token.validate(token);
@ -9,11 +13,19 @@ export class Token extends ValueObject<string> {
return Result.fail(new Error(result.error.errors[0].message));
}
return Result.ok(new Token(result.data));
return Result.ok(new Token({ value: result.data }));
}
private static validate(token: string) {
const schema = z.string().min(1, { message: "Invalid token string" });
return schema.safeParse(token);
}
getValue() {
return this.props.value;
}
toString() {
return this.props.value;
}
}

View File

@ -1,12 +1,16 @@
import { Result, ValueObject } from "@common/domain";
import { z } from "zod";
export class Username extends ValueObject<string> {
interface UsernameProps {
value: string;
}
export class Username extends ValueObject<UsernameProps> {
static create(username: string): Result<Username, Error> {
const result = Username.validate(username);
return result.success
? Result.ok(new Username(result.data))
? Result.ok(new Username({ value: result.data }))
: Result.fail(new Error(result.error.errors[0].message));
}
@ -21,4 +25,12 @@ export class Username extends ValueObject<string> {
return schema.safeParse(username);
}
getValue() {
return this.props.value;
}
toString() {
return this.props.value;
}
}

View File

@ -1,5 +1,5 @@
import { Result, UniqueID } from "@common/domain";
import { AuthenticatedUser, EmailAddress, HashPassword, Username } from "@contexts/auth/domain";
import { EmailAddress, Result, UniqueID } from "@common/domain";
import { AuthenticatedUser, HashPassword, Username } from "@contexts/auth/domain";
import { AuthUserModel } from "../sequelize";
export interface IAuthenticatedUserMapper {

View File

@ -1,5 +1,5 @@
import { Result, UniqueID } from "@common/domain";
import { EmailAddress, User, Username } from "@contexts/auth/domain";
import { EmailAddress, Result, UniqueID } from "@common/domain";
import { User, Username } from "@contexts/auth/domain";
import { UserModel } from "../sequelize";
export interface IUserMapper {

View File

@ -35,7 +35,7 @@ export const checkUser = [
// Verifica que el usuario sea administrador
export const checkUserIsAdmin = [
authProvider.authenticateJWT(),
_authorizeUser((user) => user.isAdmin),
_authorizeUser((user) => true /*user.isAdmin*/),
];
// Middleware para verificar que el usuario sea administrador o el dueño de los datos (self)

View File

@ -1,6 +1,6 @@
import { Result, UniqueID } from "@common/domain";
import { EmailAddress, Result, UniqueID } from "@common/domain";
import { SequelizeRepository } from "@common/infrastructure";
import { EmailAddress, IUserRepository, User } from "@contexts/auth/domain";
import { IUserRepository, User } from "@contexts/auth/domain";
import { Transaction } from "sequelize";
import { IUserMapper, userMapper } from "../mappers";
import { UserModel } from "./user.model";

View File

@ -10,7 +10,7 @@ export class ListUsersController extends ExpressController {
super();
}
protected async executeImpl(): Promise<void | any> {
protected async executeImpl() {
const usersOrError = await this.listUsers.execute();
if (usersOrError.isFailure) {

View File

@ -10,5 +10,6 @@ export const listUsersPresenter: IListUsersPresenter = {
users.map((user) => ({
id: user.id.toString(),
email: user.email.toString(),
username: user.username.toString(),
})),
};

View File

@ -1,6 +1,7 @@
import { ExpressController } from "@common/presentation";
import { LogoutUseCase } from "@contexts/auth/application/logout";
import { AuthenticatedUser, LogoutData, TabContext } from "@contexts/auth/domain";
import { TabContextRequest } from "@contexts/auth/infraestructure/express/types";
export class LogoutController extends ExpressController {
public constructor(private readonly logout: LogoutUseCase) {
@ -9,7 +10,7 @@ export class LogoutController extends ExpressController {
async executeImpl() {
const user = this.req.user as AuthenticatedUser;
const tabContext = this.req.tabContext as TabContext;
const tabContext = (this.req as TabContextRequest).tabContext as TabContext;
const logoutDataOrError = LogoutData.create({
email: user.email,

View File

@ -1,4 +1,5 @@
export interface IListUsersResponseDTO {
id: string;
username: string;
email: string;
}

View File

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

View File

@ -0,0 +1 @@
export * from "./list-companies.use-case";

View File

@ -0,0 +1,17 @@
import { Result } from "@common/domain";
import { ITransactionManager } from "@common/infrastructure/database";
import { User } from "@contexts/auth/domain";
import { IUserService } from "@contexts/auth/domain/services";
export class ListCompaniesUseCase {
constructor(
private readonly userService: IUserService,
private readonly transactionManager: ITransactionManager
) {}
public async execute(): Promise<Result<User[], Error>> {
return await this.transactionManager.complete(async (transaction) => {
return await this.userService.findCompanies(transaction);
});
}
}

View File

@ -0,0 +1,130 @@
import {
AggregateRoot,
EmailAddress,
PhoneNumber,
PostalAddress,
TINNumber,
UniqueID,
} from "@common/domain";
import { Maybe, Result } from "@common/helpers";
export interface ICompanyProps {
isFreelancer: boolean;
name: string;
tin: TINNumber;
address: PostalAddress;
email: EmailAddress;
phone: PhoneNumber;
legalRecord: string;
defaultTax: number;
status: string;
langCode: string;
currencyCode: string;
tradeName: Maybe<string>;
website: Maybe<string>;
fax: Maybe<PhoneNumber>;
logo: Maybe<string>;
}
export interface ICompany {
id: UniqueID;
name: string;
tin: TINNumber;
address: PostalAddress;
email: EmailAddress;
phone: PhoneNumber;
legalRecord: string;
defaultTax: number;
langCode: string;
currencyCode: string;
tradeName: Maybe<string>;
fax: Maybe<PhoneNumber>;
website: Maybe<string>;
logo: Maybe<string>;
isCompany: boolean;
isFreelancer: boolean;
isActive: boolean;
}
export class Company extends AggregateRoot<ICompanyProps> implements ICompany {
static create(props: ICompanyProps, id?: UniqueID): Result<Company, Error> {
const company = new Company(props, id);
// Reglas de negocio / validaciones
// ...
// ...
// 🔹 Disparar evento de dominio "CompanyAuthenticatedEvent"
//const { company } = props;
//user.addDomainEvent(new CompanyAuthenticatedEvent(id, company.toString()));
return Result.ok(company);
}
get name() {
return this.props.name;
}
get tradeName() {
return this.props.tradeName;
}
get tin(): TINNumber {
return this.props.tin;
}
get address(): PostalAddress {
return this.props.address;
}
get email(): EmailAddress {
return this.props.email;
}
get phone(): PhoneNumber {
return this.props.phone;
}
get fax(): Maybe<PhoneNumber> {
return this.props.fax;
}
get website() {
return this.props.website;
}
get legalRecord() {
return this.props.legalRecord;
}
get defaultTax() {
return this.props.defaultTax;
}
get langCode() {
return this.props.langCode;
}
get currencyCode() {
return this.props.currencyCode;
}
get logo() {
return this.props.logo;
}
get isCompany(): boolean {
return !this.props.isFreelancer;
}
get isFreelancer(): boolean {
return this.props.isFreelancer;
}
get isActive(): boolean {
return this.props.status === "active";
}
}

View File

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

View File

@ -0,0 +1,7 @@
import { Result, UniqueID } from "@common/domain";
import { Company } from "../aggregates";
export interface ICompanyService {
findCompanies(transaction?: any): Promise<Result<Company[], Error>>;
findCompanyById(userId: UniqueID, transaction?: any): Promise<Result<Company>>;
}

View File

@ -0,0 +1,22 @@
import { Result, UniqueID } from "@common/domain";
import { Company, ICompanyRepository } from "..";
import { ICompanyService } from "./company-service.interface";
export class CompanyService implements ICompanyService {
constructor(private readonly companyRepository: ICompanyRepository) {}
async findCompanies(transaction?: any): Promise<Result<Company[], Error>> {
const companysOrError = await this.companyRepository.findAll(transaction);
if (companysOrError.isFailure) {
return Result.fail(companysOrError.error);
}
// Solo devolver usuarios activos
const activeCompanies = companysOrError.data.filter((company) => company /*.isActive*/);
return Result.ok(activeCompanies);
}
async findCompanyById(companyId: UniqueID, transaction?: any): Promise<Result<Company>> {
return await this.companyRepository.findById(companyId, transaction);
}
}

View File

@ -0,0 +1,100 @@
import { EmailAddress, PhoneNumber, PostalAddress, TINNumber, UniqueID } from "@common/domain";
import { Maybe, Result } from "@common/helpers";
import {
ISequelizeMapper,
MapperParamsType,
SequelizeMapper,
} from "@common/infrastructure/sequelize/sequelize-mapper";
import { Company } from "@contexts/companies/domain/aggregates/company";
import { CompanyCreationAttributes, CompanyModel } from "../sequelize/company.model";
export interface ICompanyMapper
extends ISequelizeMapper<CompanyModel, CompanyCreationAttributes, Company> {}
export class CompanyMapper
extends SequelizeMapper<CompanyModel, CompanyCreationAttributes, Company>
implements ICompanyMapper
{
public mapToDomain(source: CompanyModel, params?: MapperParamsType): Result<Company, Error> {
const idOrError = UniqueID.create(source.id);
const tinOrError = TINNumber.create(source.tin);
const emailOrError = EmailAddress.create(source.email);
const phoneOrError = PhoneNumber.create(source.phone);
const faxOrError = PhoneNumber.createNullable(source.fax);
const postalAddressOrError = PostalAddress.create({
street: source.street,
city: source.city,
state: source.state,
postalCode: source.postal_code,
country: source.country,
});
const result = Result.combine([
idOrError,
tinOrError,
emailOrError,
phoneOrError,
faxOrError,
postalAddressOrError,
]);
if (result.isFailure) {
return Result.fail(result.error);
}
return Company.create(
{
isFreelancer: source.is_freelancer,
name: source.name,
tradeName: source.trade_name ? Maybe.Some(source.trade_name) : Maybe.None(),
tin: tinOrError.data,
address: postalAddressOrError.data,
email: emailOrError.data,
phone: phoneOrError.data,
fax: faxOrError.data,
website: source.website ? Maybe.Some(source.website) : Maybe.None(),
legalRecord: source.legal_record,
defaultTax: source.default_tax,
status: source.status,
langCode: source.lang_code,
currencyCode: source.currency_code,
logo: source.logo ? Maybe.Some(source.logo) : Maybe.None(),
},
idOrError.data
);
}
public mapToPersistence(
source: Company,
params?: MapperParamsType
): Result<CompanyCreationAttributes, Error> {
return Result.ok({
id: source.id.toString(),
is_freelancer: source.isFreelancer,
name: source.name,
trade_name: source.tradeName.isSome() ? source.tradeName.getValue() : undefined,
tin: source.tin.toString(),
street: source.address.street,
city: source.address.city,
state: source.address.state,
postal_code: source.address.postalCode,
country: source.address.country,
email: source.email.toString(),
phone: source.phone.toString(),
fax: source.fax.isSome() ? source.fax.getValue()?.toString() : undefined,
website: source.website.isSome() ? source.website.getValue() : undefined,
legal_record: source.legalRecord,
default_tax: source.defaultTax,
status: source.isActive ? "active" : "inactive",
lang_code: source.langCode,
currency_code: source.currencyCode,
logo: source.logo.isSome() ? source.logo.getValue() : undefined,
});
}
}
const companyMapper: CompanyMapper = new CompanyMapper();
export { companyMapper };

View File

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

View File

@ -0,0 +1,170 @@
import {
CreationOptional,
DataTypes,
InferAttributes,
InferCreationAttributes,
Model,
Sequelize,
} from "sequelize";
export type CompanyCreationAttributes = InferCreationAttributes<CompanyModel, {}> & {};
export class CompanyModel extends Model<
InferAttributes<CompanyModel>,
InferCreationAttributes<CompanyModel>
> {
// To avoid table creation
/*static async sync(): Promise<any> {
return Promise.resolve();
}*/
declare id: string;
declare is_freelancer: boolean;
declare name: string;
declare trade_name: CreationOptional<string>;
declare tin: string;
declare street: string;
declare city: string;
declare state: string;
declare postal_code: string;
declare country: string;
declare email: string;
declare phone: string;
declare fax: CreationOptional<string>;
declare website: CreationOptional<string>;
declare legal_record: string;
declare default_tax: number;
declare status: string;
declare lang_code: string;
declare currency_code: string;
declare logo: CreationOptional<string>;
}
export default (sequelize: Sequelize) => {
CompanyModel.init(
{
id: {
type: DataTypes.UUID,
primaryKey: true,
},
is_freelancer: {
type: DataTypes.BOOLEAN,
allowNull: false,
},
name: {
type: DataTypes.STRING,
allowNull: false,
},
trade_name: {
type: DataTypes.STRING,
allowNull: true,
},
tin: {
type: DataTypes.STRING,
allowNull: false,
},
street: {
type: DataTypes.STRING,
allowNull: false,
},
city: {
type: DataTypes.STRING,
allowNull: false,
},
state: {
type: DataTypes.STRING,
allowNull: false,
},
postal_code: {
type: DataTypes.STRING,
allowNull: false,
},
country: {
type: DataTypes.STRING,
allowNull: false,
},
email: {
type: DataTypes.STRING,
allowNull: false,
validate: {
isEmail: true,
},
},
phone: {
type: DataTypes.STRING,
allowNull: false,
},
fax: {
type: DataTypes.STRING,
allowNull: true,
},
website: {
type: DataTypes.STRING,
allowNull: true,
validate: {
isUrl: true,
},
},
legal_record: {
type: DataTypes.TEXT,
allowNull: false,
},
default_tax: {
type: new DataTypes.SMALLINT(),
allowNull: false,
defaultValue: 2100,
},
logo: {
type: DataTypes.STRING,
allowNull: true,
},
lang_code: {
type: DataTypes.STRING(2),
allowNull: false,
defaultValue: "es",
},
currency_code: {
type: new DataTypes.STRING(3),
allowNull: false,
defaultValue: "EUR",
},
status: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: "active",
},
},
{
sequelize,
tableName: "companies",
paranoid: true, // softs deletes
timestamps: true,
createdAt: "created_at",
updatedAt: "updated_at",
deletedAt: "deleted_at",
indexes: [{ name: "email_idx", fields: ["email"], unique: true }],
whereMergeStrategy: "and", // <- cómo tratar el merge de un scope
defaultScope: {},
scopes: {},
}
);
return CompanyModel;
};

View File

@ -0,0 +1,81 @@
import { EmailAddress, UniqueID } from "@common/domain";
import { Collection, Result } from "@common/helpers";
import { SequelizeRepository } from "@common/infrastructure";
import { Company, ICompanyRepository } from "@contexts/companies/domain";
import { Transaction } from "sequelize";
import { companyMapper, ICompanyMapper } from "../mappers";
import { CompanyModel } from "./company.model";
class CompanyRepository extends SequelizeRepository<Company> implements ICompanyRepository {
private readonly _mapper!: ICompanyMapper;
/**
* 🔹 Función personalizada para mapear errores de unicidad en autenticación
*/
private _customErrorMapper(error: Error): string | null {
if (error.name === "SequelizeUniqueConstraintError") {
return "Company with this email already exists";
}
return null;
}
constructor(mapper: ICompanyMapper) {
super();
this._mapper = mapper;
}
async findAll(transaction?: Transaction): Promise<Result<Collection<Company>, Error>> {
try {
const rawCompanys: any = await this._findAll(CompanyModel, {}, transaction);
if (!rawCompanys === true) {
return Result.fail(new Error("Company with email not exists"));
}
return this._mapper.mapArrayToDomain(rawCompanys);
} catch (error: any) {
return this._handleDatabaseError(error, this._customErrorMapper);
}
}
async findById(id: UniqueID, transaction?: Transaction): Promise<Result<Company, Error>> {
try {
const rawCompany: any = await this._getById(CompanyModel, id, {}, transaction);
if (!rawCompany === true) {
return Result.fail(new Error(`Company with id ${id.toString()} not exists`));
}
return this._mapper.mapToDomain(rawCompany);
} catch (error: any) {
return this._handleDatabaseError(error, this._customErrorMapper);
}
}
async findByEmail(
email: EmailAddress,
transaction?: Transaction
): Promise<Result<Company, Error>> {
try {
const rawCompany: any = await this._getBy(
CompanyModel,
"email",
email.toString(),
{},
transaction
);
if (!rawCompany === true) {
return Result.fail(new Error(`Company with email ${email.toString()} not exists`));
}
return this._mapper.mapToDomain(rawCompany);
} catch (error: any) {
return this._handleDatabaseError(error, this._customErrorMapper);
}
}
}
const companyRepository = new CompanyRepository(companyMapper);
export { companyRepository };

View File

@ -0,0 +1,9 @@
import { ICompanyRepository } from "@contexts/companies/domain";
import { companyRepository } from "./company.repository";
export * from "./company.model";
export * from "./company.repository";
export const createCompanyRepository = (): ICompanyRepository => {
return companyRepository;
};

View File

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

View File

@ -0,0 +1,15 @@
import { SequelizeTransactionManager } from "@common/infrastructure";
import { ListcompaniesUseCase } from "@contexts/auth/application/list-companies/list-companies.use-case";
import { userRepository } from "@contexts/auth/infraestructure";
import { ListcompaniesController } from "./list-companies.controller";
import { listcompaniesPresenter } from "./list-companies.presenter";
export const listcompaniesController = () => {
const transactionManager = new SequelizeTransactionManager();
const companieservice = new companieservice(userRepository);
const useCase = new ListcompaniesUseCase(companieservice, transactionManager);
const presenter = listcompaniesPresenter;
return new ListcompaniesController(useCase, presenter);
};

View File

@ -0,0 +1,37 @@
import { ExpressController } from "@common/presentation";
import { ListCompaniesUseCase } from "@contexts/companies/application/list-companies/list-users.use-case";
import { IListCompaniesPresenter } from "./list-companies.presenter";
export class ListcompaniesController extends ExpressController {
public constructor(
private readonly listcompanies: ListCompaniesUseCase,
private readonly presenter: IListCompaniesPresenter
) {
super();
}
protected async executeImpl() {
const companiesOrError = await this.listcompanies.execute();
if (companiesOrError.isFailure) {
return this.handleError(companiesOrError.error);
}
return this.ok(this.presenter.toDTO(companiesOrError.data));
}
private handleError(error: Error) {
const message = error.message;
if (
message.includes("Database connection lost") ||
message.includes("Database request timed out")
) {
return this.unavailableError(
"Database service is currently unavailable. Please try again later."
);
}
return this.conflictError(message);
}
}

View File

@ -0,0 +1,38 @@
import { Collection, ensureString } from "@common/helpers";
import { Company } from "@contexts/companies/domain";
import { IListCompaniesResponseDTO } from "../../dto";
export interface IListCompaniesPresenter {
toDTO: (companies: Collection<Company>) => IListCompaniesResponseDTO[];
}
export const listCompaniesPresenter: IListCompaniesPresenter = {
toDTO: (companies: Collection<Company>): IListCompaniesResponseDTO[] =>
companies.map((company) => ({
id: ensureString(company.id.toString()),
is_freelancer: ensureBoolean(company.isFreelancer),
name: ensureString(company.name),
trade_name: ensureString(company.tradeName.getValue()),
tin: ensureString(company.tin.toString()),
street: ensureString(company.address.street),
city: ensureString(company.address.city),
state: ensureString(company.address.state),
postal_code: ensureString(company.address.postalCode),
country: ensureString(company.address.country),
email: ensureString(company.email.toString()),
phone: ensureString(company.phone.toString()),
fax: ensureString(company.fax.getValue()?.toString()),
website: ensureString(company.website.getValue()),
legal_record: ensureString(company.legalRecord),
default_tax: ensureNumber(company.defaultTax),
status: ensureString(company.isActive ? "active" : "inactive"),
lang_code: ensureString(company.langCode),
currency_code: ensureString(company.currencyCode),
logo: ensureString(company.logo.getValue()),
})),
};

View File

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

View File

@ -0,0 +1,27 @@
export interface IListCompaniesResponseDTO {
id: string;
is_freelancer: boolean;
name: string;
trade_name: string;
tin: string;
street: string;
city: string;
state: string;
postal_code: string;
country: string;
email: string;
phone: string;
fax: string;
website: string;
legal_record: string;
default_tax: number;
status: string;
lang_code: string;
currency_code: string;
logo: string;
}

View File

@ -0,0 +1,3 @@
import { z } from "zod";
export const ListCompaniesSchema = z.object({});

View File

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

View File

@ -0,0 +1,2 @@
export * from "./controllers";
export * from "./dto";

View File

@ -1,47 +1,18 @@
import { validateRequestDTO } from "@common/presentation";
import { createAuthProvider } from "@contexts/company/infraestructure";
import { validateTabContextHeader } from "@contexts/company/presentation";
import { createLoginController } from "@contexts/company/presentation/controllers";
import { createLogoutController } from "@contexts/company/presentation/controllers/logout/logout.controller";
import { createRegisterController } from "@contexts/company/presentation/controllers/register/register.controller";
import { LoginUserSchema, RegisterUserSchema } from "@contexts/company/presentation/dto";
import { checkTabContext, checkUser } from "@contexts/auth/infraestructure";
import { ListCompaniesSchema } from "@contexts/companies/presentation";
import { NextFunction, Request, Response, Router } from "express";
export const companyRouter = (appRouter: Router) => {
const companyRoutes: Router = Router({ mergeParams: true });
const authProvider = createAuthProvider();
companyRoutes.get(
"/",
/*validateRequestDTO(ListCompaniesSchema),*/
validateTabContextHeader,
authProvider.companyenticateJWT(),
getDealerMiddleware,
handleRequest(listQuotesController)
);
companyRoutes.get("/:quoteId", checkUser, getDealerMiddleware, handleRequest(getQuoteController));
companyRoutes.post("/", checkUser, getDealerMiddleware, handleRequest(createQuoteController));
companyRoutes.post("/register", validateRequestDTO(RegisterUserSchema), (req, res, next) => {
createRegisterController().execute(req, res, next);
});
companyRoutes.post(
"/login",
validateRequestDTO(LoginUserSchema),
validateTabContextHeader,
validateRequestDTO(ListCompaniesSchema),
checkTabContext,
checkUser,
(req: Request, res: Response, next: NextFunction) => {
createLoginController().execute(req, res, next);
}
);
companyRoutes.post(
"/logout",
validateTabContextHeader,
authProvider.companyenticateJWT(),
(req: Request, res: Response, next: NextFunction) => {
createLogoutController().execute(req, res, next);
listCompaniesController().execute(req, res, next);
}
);

View File

@ -1,4 +1,5 @@
import { validateRequestDTO } from "@common/presentation";
import { checkTabContext, checkUserIsAdmin } from "@contexts/auth/infraestructure";
import { listUsersController, ListUsersSchema } from "@contexts/auth/presentation";
import { NextFunction, Request, Response, Router } from "express";
@ -8,10 +9,9 @@ export const userRouter = (appRouter: Router) => {
authRoutes.get(
"/",
validateRequestDTO(ListUsersSchema),
//validateTabContextHeader,
//authProvider.authenticateJWT(),
//authProvider.checkIsAdmin(),
async (req: Request, res: Response, next: NextFunction) => {
checkTabContext,
checkUserIsAdmin,
(req: Request, res: Response, next: NextFunction) => {
listUsersController().execute(req, res, next);
}
);

View File

@ -53,6 +53,9 @@ importers:
jsonwebtoken:
specifier: ^9.0.2
version: 9.0.2
libphonenumber-js:
specifier: ^1.11.20
version: 1.11.20
luxon:
specifier: ^3.5.0
version: 3.5.0
@ -3093,6 +3096,9 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
libphonenumber-js@1.11.20:
resolution: {integrity: sha512-/ipwAMvtSZRdiQBHqW1qxqeYiBMzncOQLVA+62MWYr7N4m7Q2jqpJ0WgT7zlOEOpyLRSqrMXidbJpC0J77AaKA==}
lilconfig@3.1.3:
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
engines: {node: '>=14'}
@ -8019,6 +8025,8 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
libphonenumber-js@1.11.20: {}
lilconfig@3.1.3: {}
lines-and-columns@1.2.4: {}