Compare commits

...

2 Commits

Author SHA1 Message Date
7470b15cfe Revisión general 2025-09-16 13:29:45 +02:00
2df2ce7083 Facturas de cliente 2025-09-16 11:17:29 +02:00
59 changed files with 372 additions and 320 deletions

View File

@ -2,6 +2,7 @@ import { logger } from "@/lib/logger";
import { DateTime } from "luxon";
import http from "node:http";
import os from "node:os";
import * as z from "zod/v4";
import { createApp } from "./app";
import { ENV } from "./config";
import { tryConnectToDatabase } from "./config/database";
@ -12,6 +13,8 @@ import { registerModules } from "./register-modules";
const API_BASE_PATH = "/api/v1";
z.config(z.locales.es());
// Guardamos información del estado del servidor
export const currentState = {
launchedAt: DateTime.now(),

View File

@ -1,4 +1,4 @@
import { DomainError } from "./domain-error";
import { DomainError } from "@repo/rdx-ddd";
export class DuplicateEntityError extends DomainError {
constructor(entity: string, field: string, value: string, options?: ErrorOptions) {

View File

@ -1,4 +1,4 @@
import { DomainError } from "./domain-error";
import { DomainError } from "@repo/rdx-ddd";
export class EntityNotFoundError extends DomainError {
constructor(entity: string, field: string, value: any, options?: ErrorOptions) {

View File

@ -1,5 +1,2 @@
export * from "./domain-error";
export * from "./domain-validation-error";
export * from "./duplicate-entity-error";
export * from "./entity-not-found-error";
export * from "./validation-error-collection";

View File

@ -1 +1 @@
export * from "./extract-or-push-error";

View File

@ -78,7 +78,7 @@ export abstract class TransactionManager implements ITransactionManager {
await this.rollback();
const error = err as Error;
this.logger.error(`❌ Transaction rolled back due to error: ${error.message}`, {
stack: error.stack,
//stack: error.stack,
label: "TransactionManager.start",
});
throw error;

View File

@ -13,13 +13,16 @@
import {
DomainValidationError,
DuplicateEntityError,
EntityNotFoundError,
ValidationErrorCollection,
isDomainValidationError,
isValidationErrorCollection,
} from "@repo/rdx-ddd";
import {
DuplicateEntityError,
EntityNotFoundError,
isDuplicateEntityError,
isEntityNotFoundError,
isValidationErrorCollection,
} from "../../domain";
import { isInfrastructureRepositoryError, isInfrastructureUnavailableError } from "../errors";
import {

View File

@ -61,9 +61,7 @@ export abstract class ExpressController {
await this.executeImpl();
} catch (error: unknown) {
const err = error as Error;
console.debug("❌ Unhandled error executing controller:", err.message);
this.handleError(new InternalApiError(err.message));
this.handleError(error as Error);
}
}

View File

@ -1,111 +0,0 @@
import { Collection, Result } from "@repo/rdx-utils";
import { Model } from "sequelize";
export type MapperParamsType = Record<string, unknown>;
interface IDomainMapper<TModel extends Model, TEntity> {
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> {
mapToPersistence(source: TEntity, params?: MapperParamsType): TModelAttributes;
mapCollectionToPersistence(
source: Collection<TEntity>,
params?: MapperParamsType
): TModelAttributes[];
}
export interface ISequelizeMapper<TModel extends Model, TModelAttributes, TEntity>
extends IDomainMapper<TModel, TEntity>,
IPersistenceMapper<TModelAttributes, TEntity> {}
export abstract class SequelizeMapper<TModel extends Model, TModelAttributes, TEntity>
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> {
const items = source ?? [];
return this.mapArrayAndCountToDomain(items, items.length, params);
}
public mapArrayAndCountToDomain(
source: TModel[],
totalCount: number,
params?: MapperParamsType
): Result<Collection<TEntity>, Error> {
const _source = source ?? [];
try {
if (_source.length === 0) {
return Result.ok(new Collection([], totalCount));
}
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): TModelAttributes;
public mapCollectionToPersistence(
source: Collection<TEntity>,
params?: MapperParamsType
): TModelAttributes[] {
return source.map((value, index) => this.mapToPersistence(value, { index, ...params }));
}
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

@ -20,9 +20,13 @@ export abstract class SequelizeQueryMapper<TModel extends Model, TEntity>
return Result.ok(new Collection([], totalCount));
}
const items = _source.map(
(value, index) => this.mapToDTO(value as TModel, { index, ...params }).data
);
const items = _source.map((value, index) => {
const result = this.mapToDTO(value as TModel, { index, ...params });
if (result.isFailure) {
throw result.error;
}
return result.data;
});
return Result.ok(new Collection(items, totalCount));
} catch (error) {
return Result.fail(error as Error);

View File

@ -1,3 +1,4 @@
import { DomainValidationError, ValidationErrorCollection } from "@repo/rdx-ddd";
import {
ConnectionError,
DatabaseError,
@ -5,12 +6,7 @@ import {
ValidationError as SequelizeValidationError,
UniqueConstraintError,
} from "sequelize";
import {
DomainValidationError,
DuplicateEntityError,
EntityNotFoundError,
ValidationErrorCollection,
} from "../../domain";
import { DuplicateEntityError, EntityNotFoundError } from "../../domain";
import { InfrastructureRepositoryError } from "../errors/infrastructure-repository-error";
import { InfrastructureUnavailableError } from "../errors/infrastructure-unavailable-error";

View File

@ -69,7 +69,7 @@ export class SequelizeTransactionManager extends TransactionManager {
} catch (err) {
const error = err as Error;
this.logger.error(`❌ Transaction rolled back due to error: ${error.message}`, {
stack: error.stack,
//stack: error.stack,
label: "SequelizeTransactionManager.complete",
});
throw error;

View File

@ -28,6 +28,6 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"],
"include": ["src", "../../packages/rdx-ddd/src/helpers/extract-or-push-error.ts"],
"exclude": ["node_modules"]
}

View File

@ -1,45 +0,0 @@
import { DomainValidationError, ValidationErrorDetail } from "@erp/core/api";
import { Result } from "@repo/rdx-utils";
/**
* Extrae un valor de un Result si es válido.
* Si es un fallo, agrega un ValidationErrorDetail al array proporcionado.
* @param result - El resultado a evaluar.
* @param path - La ruta del error para el detalle de validación.
* @param errors - El array donde se agregarán los errores de validación.
* @returns El valor extraído si el resultado es exitoso, o undefined si es un fallo.
* @template T - El tipo de dato esperado en el resultado exitoso.
* @throws {Error} Si el resultado es un fallo y no es una instancia de DomainValidationError.
* @example
* const result = Result.ok(42);
* const value = extractOrPushError(result, 'some.path', []);
* console.log(value); // 42
* const errorResult = Result.fail(new Error('Something went wrong'));
* const value = extractOrPushError(errorResult, 'some.path', []);
* console.log(value); // undefined
* // errors will contain [{ path: 'some.path', message: 'Something went wrong' }]
*
* @see Result
* @see DomainValidationError
* @see ValidationErrorDetail
*/
export function extractOrPushError<T>(
result: Result<T, Error>,
path: string,
errors: ValidationErrorDetail[]
): T | undefined {
if (result.isFailure) {
const error = result.error;
if (error instanceof DomainValidationError) {
errors.push({ path, message: error.detail });
} else {
errors.push({ path, message: error.message });
}
return undefined;
}
return result.data;
}

View File

@ -1,10 +1,8 @@
import { ValidationErrorCollection, ValidationErrorDetail } from "@erp/core/api";
import { CreateCustomerInvoiceCommandDTO } from "@erp/customer-invoices/common/dto";
import { ValidationErrorCollection, ValidationErrorDetail } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import {
CustomerInvoiceItem,
CustomerInvoiceItemDescription,
CustomerInvoiceItemQuantity,
ItemAmount,
ItemDiscount,
} from "../../domain";

View File

@ -1,19 +1,16 @@
import { JsonTaxCatalogProvider } from "@erp/core";
import {
DomainError,
Tax,
Taxes,
ValidationErrorCollection,
ValidationErrorDetail,
extractOrPushError,
} from "@erp/core/api";
import { Tax, Taxes } from "@erp/core/api";
import {
CurrencyCode,
DomainError,
LanguageCode,
Percentage,
TextValue,
UniqueID,
UtcDate,
ValidationErrorCollection,
ValidationErrorDetail,
extractOrPushError,
maybeFromNullableVO,
} from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";

View File

@ -1,7 +1,7 @@
// Ejemplo: regla específica para Billing → InvoiceIdAlreadyExistsError
// (si defines un error más ubicuo dentro del BC con su propia clase)
import { DomainError } from "@erp/core/api";
import { DomainError } from "@repo/rdx-ddd";
// Suponemos que existe esta clase en tu dominio de Billing:
export class CustomerInvoiceIdAlreadyExistsError extends DomainError {

View File

@ -1,12 +1,11 @@
import { ISequelizeDomainMapper, MapperParamsType, SequelizeDomainMapper } from "@erp/core/api";
import {
ISequelizeDomainMapper,
MapperParamsType,
SequelizeDomainMapper,
UniqueID,
ValidationErrorCollection,
ValidationErrorDetail,
extractOrPushError,
} from "@erp/core/api";
import { UniqueID, maybeFromNullableVO } from "@repo/rdx-ddd";
maybeFromNullableVO,
} from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { InferCreationAttributes } from "sequelize";
import {

View File

@ -1,11 +1,4 @@
import {
ISequelizeDomainMapper,
MapperParamsType,
SequelizeDomainMapper,
ValidationErrorCollection,
ValidationErrorDetail,
extractOrPushError,
} from "@erp/core/api";
import { ISequelizeDomainMapper, MapperParamsType, SequelizeDomainMapper } from "@erp/core/api";
import {
CurrencyCode,
LanguageCode,
@ -13,6 +6,9 @@ import {
TextValue,
UniqueID,
UtcDate,
ValidationErrorCollection,
ValidationErrorDetail,
extractOrPushError,
maybeFromNullableVO,
} from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";

View File

@ -6,15 +6,13 @@ import {
Province,
Street,
TINNumber,
maybeFromNullableVO,
} from "@repo/rdx-ddd";
import {
MapperParamsType,
ValidationErrorCollection,
ValidationErrorDetail,
extractOrPushError,
} from "@erp/core/api";
maybeFromNullableVO,
} from "@repo/rdx-ddd";
import { MapperParamsType } from "@erp/core/api";
import { Maybe, Result } from "@repo/rdx-utils";
import { CustomerInvoiceProps, InvoiceRecipient } from "../../../domain";
import { CustomerInvoiceModel } from "../../sequelize";

View File

@ -1,12 +1,10 @@
import { JsonTaxCatalogProvider } from "@erp/core";
import { MapperParamsType, SequelizeDomainMapper, Tax } from "@erp/core/api";
import {
MapperParamsType,
SequelizeDomainMapper,
Tax,
ValidationErrorCollection,
ValidationErrorDetail,
extractOrPushError,
} from "@erp/core/api";
} from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { ItemTax } from "../../../domain";
import {

View File

@ -1,12 +1,11 @@
import { JsonTaxCatalogProvider } from "@erp/core";
import { MapperParamsType, SequelizeDomainMapper, Tax } from "@erp/core/api";
import {
MapperParamsType,
SequelizeDomainMapper,
Tax,
ValidationErrorCollection,
ValidationErrorDetail,
extractOrPushError,
} from "@erp/core/api";
} from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { CustomerInvoiceProps } from "../../../domain";
import { InvoiceTax } from "../../../domain/entities/invoice-taxes";

View File

@ -1,11 +1,11 @@
import { ISequelizeQueryMapper, MapperParamsType, SequelizeQueryMapper } from "@erp/core/api";
import {
ISequelizeQueryMapper,
MapperParamsType,
SequelizeQueryMapper,
ValidationErrorCollection,
ValidationErrorDetail,
extractOrPushError,
} from "@erp/core/api";
} from "@repo/rdx-ddd";
import {
CurrencyCode,
LanguageCode,

View File

@ -27,8 +27,13 @@ export class CustomerFullPresenter extends Presenter<Customer, GetCustomerByIdRe
postal_code: toEmptyString(address.postalCode, (value) => value.toPrimitive()),
country: toEmptyString(address.country, (value) => value.toPrimitive()),
email: toEmptyString(customer.email, (value) => value.toPrimitive()),
phone: toEmptyString(customer.phone, (value) => value.toPrimitive()),
email_primary: toEmptyString(customer.emailPrimary, (value) => value.toPrimitive()),
email_secondary: toEmptyString(customer.emailSecondary, (value) => value.toPrimitive()),
phone_primary: toEmptyString(customer.phonePrimary, (value) => value.toPrimitive()),
phone_secondary: toEmptyString(customer.phoneSecondary, (value) => value.toPrimitive()),
mobile_primary: toEmptyString(customer.mobilePrimary, (value) => value.toPrimitive()),
mobile_secondary: toEmptyString(customer.mobileSecondary, (value) => value.toPrimitive()),
fax: toEmptyString(customer.fax, (value) => value.toPrimitive()),
website: toEmptyString(customer.website, (value) => value.toPrimitive()),

View File

@ -29,8 +29,13 @@ export class ListCustomersPresenter extends Presenter {
postal_code: toEmptyString(address.postalCode, (value) => value.toPrimitive()),
country: toEmptyString(address.country, (value) => value.toPrimitive()),
email: toEmptyString(customer.email, (value) => value.toPrimitive()),
phone: toEmptyString(customer.phone, (value) => value.toPrimitive()),
email_primary: toEmptyString(customer.emailPrimary, (value) => value.toPrimitive()),
email_secondary: toEmptyString(customer.emailSecondary, (value) => value.toPrimitive()),
phone_primary: toEmptyString(customer.phonePrimary, (value) => value.toPrimitive()),
phone_secondary: toEmptyString(customer.phoneSecondary, (value) => value.toPrimitive()),
mobile_primary: toEmptyString(customer.mobilePrimary, (value) => value.toPrimitive()),
mobile_secondary: toEmptyString(customer.mobileSecondary, (value) => value.toPrimitive()),
fax: toEmptyString(customer.fax, (value) => value.toPrimitive()),
website: toEmptyString(customer.website, (value) => value.toPrimitive()),

View File

@ -1,13 +1,8 @@
import {
DomainError,
ValidationErrorCollection,
ValidationErrorDetail,
extractOrPushError,
} from "@erp/core/api";
import {
City,
Country,
CurrencyCode,
DomainError,
EmailAddress,
LanguageCode,
Name,
@ -21,6 +16,9 @@ import {
TextValue,
URLAddress,
UniqueID,
ValidationErrorCollection,
ValidationErrorDetail,
extractOrPushError,
maybeFromNullableVO,
} from "@repo/rdx-ddd";
import { Collection, Result, isNullishOrEmpty } from "@repo/rdx-utils";

View File

@ -1,9 +1,5 @@
import {
DomainError,
ValidationErrorCollection,
ValidationErrorDetail,
extractOrPushError,
} from "@erp/core/api";
import { DomainError, ValidationErrorCollection, ValidationErrorDetail } from "@repo/rdx-ddd";
import {
City,
Country,
@ -23,8 +19,6 @@ import {
maybeFromNullableVO,
} from "@repo/rdx-ddd";
import { Collection, Result, isNullishOrEmpty, toPatchField } from "@repo/rdx-utils";
import { UpdateCustomerRequestDTO } from "../../../common/dto";
import { CustomerPatchProps } from "../../domain";
/**
* mapDTOToUpdateCustomerPatchProps

View File

@ -28,8 +28,12 @@ export interface CustomerProps {
address: PostalAddress;
email: Maybe<EmailAddress>;
phone: Maybe<PhoneNumber>;
emailPrimary: Maybe<EmailAddress>;
emailSecondary: Maybe<EmailAddress>;
phonePrimary: Maybe<PhoneNumber>;
phoneSecondary: Maybe<PhoneNumber>;
mobilePrimary: Maybe<PhoneNumber>;
mobileSecondary: Maybe<PhoneNumber>;
fax: Maybe<PhoneNumber>;
website: Maybe<URLAddress>;
@ -114,12 +118,28 @@ export class Customer extends AggregateRoot<CustomerProps> {
return this.props.address;
}
public get email(): Maybe<EmailAddress> {
return this.props.email;
public get emailPrimary(): Maybe<EmailAddress> {
return this.props.emailPrimary;
}
public get phone(): Maybe<PhoneNumber> {
return this.props.phone;
public get emailSecondary(): Maybe<EmailAddress> {
return this.props.emailSecondary;
}
public get phonePrimary(): Maybe<PhoneNumber> {
return this.props.phonePrimary;
}
public get phoneSecondary(): Maybe<PhoneNumber> {
return this.props.phoneSecondary;
}
public get mobilePrimary(): Maybe<PhoneNumber> {
return this.props.mobilePrimary;
}
public get mobileSecondary(): Maybe<PhoneNumber> {
return this.props.mobileSecondary;
}
public get fax(): Maybe<PhoneNumber> {

View File

@ -1,11 +1,6 @@
import {
ISequelizeDomainMapper,
MapperParamsType,
SequelizeDomainMapper,
ValidationErrorCollection,
ValidationErrorDetail,
extractOrPushError,
} from "@erp/core/api";
import { ValidationErrorCollection, ValidationErrorDetail } from "@repo/rdx-ddd";
import { ISequelizeDomainMapper, MapperParamsType, SequelizeDomainMapper } from "@erp/core/api";
import {
City,
Country,
@ -23,6 +18,7 @@ import {
TextValue,
URLAddress,
UniqueID,
extractOrPushError,
maybeFromNullableVO,
toNullable,
} from "@repo/rdx-ddd";
@ -106,15 +102,39 @@ export class CustomerDomainMapper
errors
);
const emailAddress = extractOrPushError(
maybeFromNullableVO(source.email, (value) => EmailAddress.create(value)),
"email",
const emailPrimaryAddress = extractOrPushError(
maybeFromNullableVO(source.email_primary, (value) => EmailAddress.create(value)),
"email_primary",
errors
);
const phoneNumber = extractOrPushError(
maybeFromNullableVO(source.phone, (value) => PhoneNumber.create(value)),
"phone",
const emailSecondaryAddress = extractOrPushError(
maybeFromNullableVO(source.email_secondary, (value) => EmailAddress.create(value)),
"email_secondary",
errors
);
const phonePrimaryNumber = extractOrPushError(
maybeFromNullableVO(source.phone_primary, (value) => PhoneNumber.create(value)),
"phone_primary",
errors
);
const phoneSecondaryNumber = extractOrPushError(
maybeFromNullableVO(source.phone_secondary, (value) => PhoneNumber.create(value)),
"phone_secondary",
errors
);
const mobilePrimaryNumber = extractOrPushError(
maybeFromNullableVO(source.mobile_primary, (value) => PhoneNumber.create(value)),
"mobile_primary",
errors
);
const mobileSecondaryNumber = extractOrPushError(
maybeFromNullableVO(source.mobile_secondary, (value) => PhoneNumber.create(value)),
"mobile_secondary",
errors
);
@ -192,8 +212,12 @@ export class CustomerDomainMapper
address: postalAddress!,
email: emailAddress!,
phone: phoneNumber!,
emailPrimary: emailPrimaryAddress!,
emailSecondary: emailSecondaryAddress!,
phonePrimary: phonePrimaryNumber!,
phoneSecondary: phoneSecondaryNumber!,
mobilePrimary: mobilePrimaryNumber!,
mobileSecondary: mobileSecondaryNumber!,
fax: faxNumber!,
website: website!,
@ -209,7 +233,10 @@ export class CustomerDomainMapper
}
}
public mapToPersistence(source: Customer, params?: MapperParamsType): CustomerCreationAttributes {
public mapToPersistence(
source: Customer,
params?: MapperParamsType
): Result<CustomerCreationAttributes, Error> {
const customerValues: Partial<CustomerCreationAttributes> = {
id: source.id.toPrimitive(),
company_id: source.companyId.toPrimitive(),
@ -220,8 +247,12 @@ export class CustomerDomainMapper
trade_name: toNullable(source.tradeName, (tradeName) => tradeName.toPrimitive()),
tin: toNullable(source.tin, (tin) => tin.toPrimitive()),
email: toNullable(source.email, (email) => email.toPrimitive()),
phone: toNullable(source.phone, (phone) => phone.toPrimitive()),
email_primary: toNullable(source.emailPrimary, (email) => email.toPrimitive()),
email_secondary: toNullable(source.emailSecondary, (email) => email.toPrimitive()),
phone_primary: toNullable(source.phonePrimary, (phone) => phone.toPrimitive()),
phone_secondary: toNullable(source.phoneSecondary, (phone) => phone.toPrimitive()),
mobile_primary: toNullable(source.mobilePrimary, (mobile) => mobile.toPrimitive()),
mobile_secondary: toNullable(source.mobileSecondary, (mobile) => mobile.toPrimitive()),
fax: toNullable(source.fax, (fax) => fax.toPrimitive()),
website: toNullable(source.website, (website) => website.toPrimitive()),
@ -246,6 +277,6 @@ export class CustomerDomainMapper
});
}
return customerValues as CustomerCreationAttributes;
return Result.ok<CustomerCreationAttributes>(customerValues as CustomerCreationAttributes);
}
}

View File

@ -1,3 +1,5 @@
import { ValidationErrorCollection, ValidationErrorDetail } from "@repo/rdx-ddd";
import {
City,
Country,
@ -14,17 +16,11 @@ import {
TextValue,
URLAddress,
UniqueID,
extractOrPushError,
maybeFromNullableVO,
} from "@repo/rdx-ddd";
import {
ISequelizeQueryMapper,
MapperParamsType,
SequelizeQueryMapper,
ValidationErrorCollection,
ValidationErrorDetail,
extractOrPushError,
} from "@erp/core/api";
import { ISequelizeQueryMapper, MapperParamsType, SequelizeQueryMapper } from "@erp/core/api";
import { Maybe, Result } from "@repo/rdx-utils";
import { CustomerStatus } from "../../../domain";
@ -43,8 +39,13 @@ export type CustomerListDTO = {
address: PostalAddress;
email: Maybe<EmailAddress>;
phone: Maybe<PhoneNumber>;
email_primary: Maybe<EmailAddress>;
email_secondary: Maybe<EmailAddress>;
phone_primary: Maybe<PhoneNumber>;
phone_secondary: Maybe<PhoneNumber>;
mobile_primary: Maybe<PhoneNumber>;
mobile_secondary: Maybe<PhoneNumber>;
fax: Maybe<PhoneNumber>;
website: Maybe<URLAddress>;
@ -124,15 +125,39 @@ export class CustomerListMapper
errors
);
const emailAddress = extractOrPushError(
maybeFromNullableVO(raw.email, (value) => EmailAddress.create(value)),
"email",
const emailPrimaryAddress = extractOrPushError(
maybeFromNullableVO(raw.email_primary, (value) => EmailAddress.create(value)),
"email_primary",
errors
);
const phoneNumber = extractOrPushError(
maybeFromNullableVO(raw.phone, (value) => PhoneNumber.create(value)),
"phone",
const emailSecondaryAddress = extractOrPushError(
maybeFromNullableVO(raw.email_secondary, (value) => EmailAddress.create(value)),
"email_secondary",
errors
);
const phonePrimaryNumber = extractOrPushError(
maybeFromNullableVO(raw.phone_primary, (value) => PhoneNumber.create(value)),
"phone_primary",
errors
);
const phoneSecondaryNumber = extractOrPushError(
maybeFromNullableVO(raw.phone_secondary, (value) => PhoneNumber.create(value)),
"phone_secondary",
errors
);
const mobilePrimaryNumber = extractOrPushError(
maybeFromNullableVO(raw.mobile_primary, (value) => PhoneNumber.create(value)),
"mobile_primary",
errors
);
const mobileSecondaryNumber = extractOrPushError(
maybeFromNullableVO(raw.mobile_secondary, (value) => PhoneNumber.create(value)),
"mobile_secondary",
errors
);
@ -200,8 +225,12 @@ export class CustomerListMapper
address: postalAddress!,
email: emailAddress!,
phone: phoneNumber!,
email_primary: emailPrimaryAddress!,
email_secondary: emailSecondaryAddress!,
phone_primary: phonePrimaryNumber!,
phone_secondary: phoneSecondaryNumber!,
mobile_primary: mobilePrimaryNumber!,
mobile_secondary: mobileSecondaryNumber!,
fax: faxNumber!,
website: website!,

View File

@ -27,8 +27,18 @@ export class CustomerModel extends Model<
declare postal_code: string;
declare country: string;
declare email: string;
declare phone: string;
// Correos electrónicos
declare email_primary: string;
declare email_secondary: string;
// Teléfonos fijos
declare phone_primary: string;
declare phone_secondary: string;
// Móviles
declare mobile_primary: string;
declare mobile_secondary: string;
declare fax: string;
declare website: string;
@ -113,7 +123,7 @@ export default (database: Sequelize) => {
defaultValue: null,
},
email: {
email_primary: {
type: DataTypes.STRING,
allowNull: true,
defaultValue: null,
@ -121,11 +131,37 @@ export default (database: Sequelize) => {
isEmail: true,
},
},
phone: {
email_secondary: {
type: DataTypes.STRING,
allowNull: true,
defaultValue: null,
validate: {
isEmail: true,
},
},
phone_primary: {
type: DataTypes.STRING,
allowNull: true,
defaultValue: null,
},
phone_secondary: {
type: DataTypes.STRING,
allowNull: true,
defaultValue: null,
},
mobile_primary: {
type: DataTypes.STRING,
allowNull: true,
defaultValue: null,
},
mobile_secondary: {
type: DataTypes.STRING,
allowNull: true,
defaultValue: null,
},
fax: {
type: DataTypes.STRING,
allowNull: true,

View File

@ -18,8 +18,13 @@ export const GetCustomerByIdResponseSchema = z.object({
postal_code: z.string(),
country: z.string(),
email: z.string(),
phone: z.string(),
email_primary: z.string(),
email_secondary: z.string(),
phone_primary: z.string(),
phone_secondary: z.string(),
mobile_primary: z.string(),
mobile_secondary: z.string(),
fax: z.string(),
website: z.string(),

View File

@ -28,6 +28,6 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src", "../core/src/api/helpers/extract-or-push-error.ts"],
"include": ["src", "../../packages/rdx-ddd/src/helpers/extract-or-push-error.ts"],
"exclude": ["node_modules"]
}

View File

@ -35,12 +35,20 @@ export class DomainValidationError extends DomainError {
}
// Constructores rápidos
static required(field: string, options?: ErrorOptions) {
return new DomainValidationError("REQUIRED", field, "cannot be empty", options);
static requiredValue(field: string, options?: ErrorOptions) {
return new DomainValidationError("REQUIRED_VALUE", field, "cannot be empty", options);
}
static invalidFormat(field: string, detail = "invalid format", options?: ErrorOptions) {
return new DomainValidationError("INVALID_FORMAT", field, detail, options);
}
static invalidValue(
field: string,
value: unknown,
detail = "invalid value",
options?: ErrorOptions
) {
return new DomainValidationError("INVALID_VALUE", field, detail, { ...options, cause: value });
}
// Proyección útil para Problem+JSON o colecciones
toDetail() {

View File

@ -0,0 +1,3 @@
export * from "./domain-error";
export * from "./domain-validation-error";
export * from "./validation-error-collection";

View File

@ -18,8 +18,9 @@
import { DomainError } from "./domain-error";
export interface ValidationErrorDetail {
path: string; // ejemplo: "lines[1].unitPrice.amount"
path?: string; // ejemplo: "lines[1].unitPrice.amount"
message: string; // ejemplo: "Amount must be a positive number",
value?: unknown; // valor inválido opcional
}
/**

View File

@ -1,5 +1,10 @@
import { DomainValidationError, ValidationErrorDetail } from "@erp/core/api";
import { Result } from "@repo/rdx-utils";
import {
DomainValidationError,
ValidationErrorDetail,
isDomainValidationError,
isValidationErrorCollection,
} from "../errors";
/**
* Extrae un valor de un Result si es válido.
@ -32,8 +37,11 @@ export function extractOrPushError<T>(
if (result.isFailure) {
const error = result.error;
if (error instanceof DomainValidationError) {
errors.push({ path, message: error.detail });
if (isValidationErrorCollection(error)) {
// Agrega todos los detalles de error al array proporcionado
errors.push(...error.details);
} else if (isDomainValidationError(error)) {
errors.push({ path, message: error.detail, value: error.cause?.toString() });
} else {
errors.push({ path, message: error.message });
}

View File

@ -1 +1,3 @@
export * from "./extract-or-push-error";
export * from "./normalizers";
export * from "./zod-validator-error-traslator";

View File

@ -0,0 +1,18 @@
import { ZodError } from "zod/v4";
import { ValidationErrorCollection, ValidationErrorDetail } from "../errors";
export function translateZodValidationError<T>(
message: string,
zodError: ZodError<T>,
errorValue?: unknown
) {
const errors: ValidationErrorDetail[] = [];
for (const issue of zodError.issues) {
errors.push({
message: issue.message,
path: issue.path.join("."),
value: errorValue ?? issue.input,
});
}
return new ValidationErrorCollection(message, errors);
}

View File

@ -1,6 +1,7 @@
export * from "./aggregate-root";
export * from "./aggregate-root-repository.interface";
export * from "./domain-entity";
export * from "./errors";
export * from "./events/domain-event.interface";
export * from "./helpers";
export * from "./specification";

View File

@ -1,5 +1,6 @@
import { Result } from "@repo/rdx-utils";
import * as z from "zod/v4";
import { translateZodValidationError } from "../helpers";
import { ValueObject } from "./value-object";
interface CityProps {
@ -23,7 +24,7 @@ export class City extends ValueObject<CityProps> {
const valueIsValid = City.validate(value);
if (!valueIsValid.success) {
return Result.fail(new Error(valueIsValid.error.issues[0].message));
return Result.fail(translateZodValidationError("City creation failed", valueIsValid.error));
}
return Result.ok(new City({ value }));
}

View File

@ -1,5 +1,6 @@
import { Result } from "@repo/rdx-utils";
import * as z from "zod/v4";
import { translateZodValidationError } from "../helpers";
import { ValueObject } from "./value-object";
interface CountryProps {
@ -23,7 +24,9 @@ export class Country extends ValueObject<CountryProps> {
const valueIsValid = Country.validate(value);
if (!valueIsValid.success) {
return Result.fail(new Error(valueIsValid.error.issues[0].message));
return Result.fail(
translateZodValidationError("Country creation failed", valueIsValid.error)
);
}
return Result.ok(new Country({ value }));
}

View File

@ -1,5 +1,6 @@
import { Result } from "@repo/rdx-utils";
import * as z from "zod/v4";
import { translateZodValidationError } from "../helpers";
import { ValueObject } from "./value-object";
interface CurrencyCodeProps {
@ -33,7 +34,9 @@ export class CurrencyCode extends ValueObject<CurrencyCodeProps> {
const valueIsValid = CurrencyCode.validate(value);
if (!valueIsValid.success) {
return Result.fail(new Error(valueIsValid.error.issues[0].message));
return Result.fail(
translateZodValidationError("CurrencyCode creation failed", valueIsValid.error)
);
}
return Result.ok(new CurrencyCode({ value: valueIsValid.data }));
}

View File

@ -1,5 +1,6 @@
import { Result } from "@repo/rdx-utils";
import * as z from "zod/v4";
import { translateZodValidationError } from "../helpers";
import { ValueObject } from "./value-object";
interface EmailAddressProps {
@ -11,7 +12,9 @@ export class EmailAddress extends ValueObject<EmailAddressProps> {
const valueIsValid = EmailAddress.validate(value);
if (!valueIsValid.success) {
return Result.fail(new Error(valueIsValid.error.issues[0].message));
return Result.fail(
translateZodValidationError("EmailAddress creation failed", valueIsValid.error)
);
}
return Result.ok(new EmailAddress({ value: valueIsValid.data }));

View File

@ -1,5 +1,6 @@
import { Result } from "@repo/rdx-utils";
import * as z from "zod/v4";
import { translateZodValidationError } from "../helpers";
import { ValueObject } from "./value-object";
interface LanguageCodeProps {
@ -33,7 +34,9 @@ export class LanguageCode extends ValueObject<LanguageCodeProps> {
const valueIsValid = LanguageCode.validate(value);
if (!valueIsValid.success) {
return Result.fail(new Error(valueIsValid.error.issues[0].message));
return Result.fail(
translateZodValidationError("LanguageCode creation failed", valueIsValid.error)
);
}
return Result.ok(new LanguageCode({ value: valueIsValid.data }));
}

View File

@ -1,5 +1,6 @@
import { Result } from "@repo/rdx-utils";
import * as z from "zod/v4";
import { translateZodValidationError } from "../helpers";
import { ValueObject } from "./value-object";
interface NameProps {
@ -21,7 +22,7 @@ export class Name extends ValueObject<NameProps> {
const valueIsValid = Name.validate(value);
if (!valueIsValid.success) {
return Result.fail(new Error(valueIsValid.error.issues[0].message));
return Result.fail(translateZodValidationError("Name creation failed", valueIsValid.error));
}
return Result.ok(new Name({ value }));
}

View File

@ -1,5 +1,6 @@
import { Result } from "@repo/rdx-utils";
import * as z from "zod/v4";
import { translateZodValidationError } from "../helpers";
import { ValueObject } from "./value-object";
const DEFAULT_SCALE = 2;
@ -42,9 +43,12 @@ export class Percentage extends ValueObject<PercentageProps> {
static create(props: { value: number; scale?: number }): Result<Percentage> {
const { value, scale = Percentage.DEFAULT_SCALE } = props;
const validationResult = Percentage.validate({ value, scale });
if (!validationResult.success) {
return Result.fail(new Error(validationResult.error.issues.map((e) => e.message).join(", ")));
const valueIsValid = Percentage.validate({ value, scale });
if (!valueIsValid.success) {
return Result.fail(
translateZodValidationError("Percentage creation failed", valueIsValid.error)
);
}
// Cálculo del valor real del porcentaje

View File

@ -1,6 +1,7 @@
import { Result } from "@repo/rdx-utils";
import { isPossiblePhoneNumber, parsePhoneNumberWithError } from "libphonenumber-js";
import * as z from "zod/v4";
import { translateZodValidationError } from "../helpers";
import { ValueObject } from "./value-object";
interface PhoneNumberProps {
@ -38,14 +39,18 @@ export class PhoneNumber extends ValueObject<PhoneNumberProps> {
}
})*/
return schema.safeParse(value);
return schema.safeParse(value, {
reportInput: true,
});
}
static create(value: string): Result<PhoneNumber> {
const valueIsValid = PhoneNumber.validate(value);
if (!valueIsValid.success) {
return Result.fail(new Error(valueIsValid.error.issues[0].message));
return Result.fail(
translateZodValidationError("PhoneNumber creation failed", valueIsValid.error)
);
}
return Result.ok(new PhoneNumber({ value: valueIsValid.data }));

View File

@ -1,5 +1,6 @@
import { Result } from "@repo/rdx-utils";
import * as z from "zod/v4";
import { translateZodValidationError } from "../helpers";
import { ValueObject } from "./value-object";
interface PostalCodeProps {
@ -31,7 +32,9 @@ export class PostalCode extends ValueObject<PostalCodeProps> {
const valueIsValid = PostalCode.validate(value);
if (!valueIsValid.success) {
return Result.fail(new Error(valueIsValid.error.issues[0].message));
return Result.fail(
translateZodValidationError("PostalCode creation failed", valueIsValid.error)
);
}
return Result.ok(new PostalCode({ value: valueIsValid.data }));
}

View File

@ -1,5 +1,6 @@
import { Result } from "@repo/rdx-utils";
import * as z from "zod/v4";
import { translateZodValidationError } from "../helpers";
import { ValueObject } from "./value-object";
interface ProvinceProps {
@ -23,7 +24,9 @@ export class Province extends ValueObject<ProvinceProps> {
const valueIsValid = Province.validate(value);
if (!valueIsValid.success) {
return Result.fail(new Error(valueIsValid.error.issues[0].message));
return Result.fail(
translateZodValidationError("Province creation failed", valueIsValid.error)
);
}
return Result.ok(new Province({ value }));
}

View File

@ -1,5 +1,6 @@
import { Result } from "@repo/rdx-utils";
import * as z from "zod/v4";
import { translateZodValidationError } from "../helpers";
import { ValueObject } from "./value-object";
const DEFAULT_SCALE = 2;
@ -33,7 +34,7 @@ export class Quantity extends ValueObject<QuantityProps> {
const checkProps = Quantity.validate(props);
if (!checkProps.success) {
return Result.fail(new Error(checkProps.error.issues[0].message));
return Result.fail(translateZodValidationError("Quantity creation failed", checkProps.error));
}
return Result.ok(new Quantity({ ...(checkProps.data as QuantityProps) }));
}

View File

@ -1,5 +1,6 @@
import { Result } from "@repo/rdx-utils";
import * as z from "zod/v4";
import { translateZodValidationError } from "../helpers";
import { ValueObject } from "./value-object";
interface SlugProps {
@ -26,7 +27,7 @@ export class Slug extends ValueObject<SlugProps> {
const valueIsValid = Slug.validate(value);
if (!valueIsValid.success) {
return Result.fail(new Error(valueIsValid.error.issues[0].message));
return Result.fail(translateZodValidationError("Slug creation failed", valueIsValid.error));
}
// biome-ignore lint/style/noNonNullAssertion: <explanation>
return Result.ok(new Slug({ value: valueIsValid.data! }));

View File

@ -1,5 +1,6 @@
import { Result } from "@repo/rdx-utils";
import * as z from "zod/v4";
import { translateZodValidationError } from "../helpers";
import { ValueObject } from "./value-object";
interface StreetProps {
@ -23,7 +24,7 @@ export class Street extends ValueObject<StreetProps> {
const valueIsValid = Street.validate(value);
if (!valueIsValid.success) {
return Result.fail(new Error(valueIsValid.error.issues[0].message));
return Result.fail(translateZodValidationError("Street creation failed", valueIsValid.error));
}
return Result.ok(new Street({ value }));
}

View File

@ -1,5 +1,6 @@
import { Result } from "@repo/rdx-utils";
import * as z from "zod/v4";
import { translateZodValidationError } from "../helpers";
import { ValueObject } from "./value-object";
interface TaxCodeProps {
@ -30,7 +31,9 @@ export class TaxCode extends ValueObject<TaxCodeProps> {
const valueIsValid = TaxCode.validate(value);
if (!valueIsValid.success) {
return Result.fail(new Error(valueIsValid.error.issues[0].message));
return Result.fail(
translateZodValidationError("TaxCode creation failed", valueIsValid.error)
);
}
// biome-ignore lint/style/noNonNullAssertion: <explanation>
return Result.ok(new TaxCode({ value: valueIsValid.data! }));

View File

@ -1,5 +1,6 @@
import { Result } from "@repo/rdx-utils";
import * as z from "zod/v4";
import { translateZodValidationError } from "../helpers";
import { ValueObject } from "./value-object";
interface TextValueProps {
@ -24,7 +25,9 @@ export class TextValue extends ValueObject<TextValueProps> {
const valueIsValid = TextValue.validate(value);
if (!valueIsValid.success) {
return Result.fail(new Error(valueIsValid.error.issues[0].message));
return Result.fail(
translateZodValidationError("TextValue creation failed", valueIsValid.error)
);
}
return Result.ok(new TextValue({ value }));
}

View File

@ -1,5 +1,6 @@
import { Result } from "@repo/rdx-utils";
import * as z from "zod/v4";
import { translateZodValidationError } from "../helpers";
import { ValueObject } from "./value-object";
interface TINNumberProps {
@ -28,7 +29,9 @@ export class TINNumber extends ValueObject<TINNumberProps> {
const valueIsValid = TINNumber.validate(value);
if (!valueIsValid.success) {
return Result.fail(new Error(valueIsValid.error.issues[0].message));
return Result.fail(
translateZodValidationError("TINNumber creation failed", valueIsValid.error)
);
}
return Result.ok(new TINNumber({ value: valueIsValid.data }));
}

View File

@ -1,5 +1,6 @@
import { Result } from "@repo/rdx-utils";
import * as z from "zod/v4";
import { translateZodValidationError } from "../helpers";
import { ValueObject } from "./value-object";
interface URLAddressProps {
@ -11,14 +12,28 @@ export class URLAddress extends ValueObject<URLAddressProps> {
const valueIsValid = URLAddress.validate(value);
if (!valueIsValid.success) {
return Result.fail(new Error(valueIsValid.error.issues[0].message));
return Result.fail(
translateZodValidationError("URLAddress creation failed", valueIsValid.error)
);
}
return Result.ok(new URLAddress({ value: valueIsValid.data }));
}
private static validate(value: string) {
const schema = z.url({ message: "Invalid URL format" });
const schema = z.string().refine((value) => {
const urlPattern = new RegExp(
"^(https?:\\/\\/)?" + // protocolo opcional
"((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|" + // nombre de dominio
"((\\d{1,3}\\.){3}\\d{1,3}))" + // o dirección IP (v4)
"(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*" + // puerto y ruta
"(\\?[;&a-z\\d%_.~+=-]*)?" + // query string
"(\\#[-a-z\\d_]*)?$",
"i" // fragment locator
);
return urlPattern.test(value);
}, "Invalid URL format");
return schema.safeParse(value);
}

View File

@ -1,5 +1,6 @@
import { Result } from "@repo/rdx-utils";
import * as z from "zod/v4";
import { translateZodValidationError } from "../helpers";
import { ValueObject } from "./value-object";
interface UtcDateProps {
@ -33,8 +34,9 @@ export class UtcDate extends ValueObject<UtcDateProps> {
*/
static createFromISO(isoDateString: string): Result<UtcDate, Error> {
const dateIsValid = UtcDate.validate(isoDateString);
if (!dateIsValid.success) {
return Result.fail(new Error(`Invalid UTC date format: ${isoDateString}`));
return Result.fail(translateZodValidationError("UtcDate creation failed", dateIsValid.error));
}
return Result.ok(new UtcDate({ value: isoDateString }));