This commit is contained in:
David Arranz 2025-02-25 18:47:42 +01:00
parent 7d1c441d34
commit 5a9e7261f9
70 changed files with 926 additions and 239 deletions

View File

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

View File

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

View File

@ -0,0 +1,17 @@
import { Collection, Result } from "@common/helpers";
import { ITransactionManager } from "@common/infrastructure/database";
import { Account } from "@contexts/accounts/domain";
import { IAccountService } from "@contexts/accounts/domain/services/account-service.interface";
export class ListAccountsUseCase {
constructor(
private readonly accountService: IAccountService,
private readonly transactionManager: ITransactionManager
) {}
public execute(): Promise<Result<Collection<Account>, Error>> {
return this.transactionManager.complete((transaction) => {
return this.accountService.findAccounts(transaction);
});
}
}

View File

@ -8,7 +8,7 @@ import {
} from "@common/domain"; } from "@common/domain";
import { Maybe, Result } from "@common/helpers"; import { Maybe, Result } from "@common/helpers";
export interface ICompanyProps { export interface IAccountProps {
isFreelancer: boolean; isFreelancer: boolean;
name: string; name: string;
tin: TINNumber; tin: TINNumber;
@ -27,7 +27,7 @@ export interface ICompanyProps {
logo: Maybe<string>; logo: Maybe<string>;
} }
export interface ICompany { export interface IAccount {
id: UniqueID; id: UniqueID;
name: string; name: string;
tin: TINNumber; tin: TINNumber;
@ -44,24 +44,24 @@ export interface ICompany {
website: Maybe<string>; website: Maybe<string>;
logo: Maybe<string>; logo: Maybe<string>;
isCompany: boolean; isAccount: boolean;
isFreelancer: boolean; isFreelancer: boolean;
isActive: boolean; isActive: boolean;
} }
export class Company extends AggregateRoot<ICompanyProps> implements ICompany { export class Account extends AggregateRoot<IAccountProps> implements IAccount {
static create(props: ICompanyProps, id?: UniqueID): Result<Company, Error> { static create(props: IAccountProps, id?: UniqueID): Result<Account, Error> {
const company = new Company(props, id); const account = new Account(props, id);
// Reglas de negocio / validaciones // Reglas de negocio / validaciones
// ... // ...
// ... // ...
// 🔹 Disparar evento de dominio "CompanyAuthenticatedEvent" // 🔹 Disparar evento de dominio "AccountAuthenticatedEvent"
//const { company } = props; //const { account } = props;
//user.addDomainEvent(new CompanyAuthenticatedEvent(id, company.toString())); //user.addDomainEvent(new AccountAuthenticatedEvent(id, account.toString()));
return Result.ok(company); return Result.ok(account);
} }
get name() { get name() {
@ -116,7 +116,7 @@ export class Company extends AggregateRoot<ICompanyProps> implements ICompany {
return this.props.logo; return this.props.logo;
} }
get isCompany(): boolean { get isAccount(): boolean {
return !this.props.isFreelancer; return !this.props.isFreelancer;
} }

View File

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

View File

@ -0,0 +1,3 @@
export * from "./aggregates";
export * from "./repositories";

View File

@ -0,0 +1,9 @@
import { EmailAddress, UniqueID } from "@common/domain";
import { Collection, Result } from "@common/helpers";
import { Account } from "../aggregates";
export interface IAccountRepository {
findAll(transaction?: any): Promise<Result<Collection<Account>, Error>>;
findById(id: UniqueID, transaction?: any): Promise<Result<Account, Error>>;
findByEmail(email: EmailAddress, transaction?: any): Promise<Result<Account, Error>>;
}

View File

@ -0,0 +1 @@
export * from "./account-repository.interface";

View File

@ -0,0 +1,8 @@
import { UniqueID } from "@common/domain";
import { Collection, Result } from "@common/helpers";
import { Account } from "../aggregates";
export interface IAccountService {
findAccounts(transaction?: any): Promise<Result<Collection<Account>, Error>>;
findAccountById(userId: UniqueID, transaction?: any): Promise<Result<Account>>;
}

View File

@ -0,0 +1,23 @@
import { UniqueID } from "@common/domain";
import { Collection, Result } from "@common/helpers";
import { Account, IAccountRepository } from "..";
import { IAccountService } from "./account-service.interface";
export class AccountService implements IAccountService {
constructor(private readonly accountRepository: IAccountRepository) {}
async findAccounts(transaction?: any): Promise<Result<Collection<Account>, Error>> {
const accountsOrError = await this.accountRepository.findAll(transaction);
if (accountsOrError.isFailure) {
return Result.fail(accountsOrError.error);
}
// Solo devolver usuarios activos
const activeAccounts = accountsOrError.data.filter((account) => account.isActive);
return Result.ok(new Collection(activeAccounts));
}
async findAccountById(accountId: UniqueID, transaction?: any): Promise<Result<Account>> {
return await this.accountRepository.findById(accountId, transaction);
}
}

View File

@ -5,17 +5,17 @@ import {
MapperParamsType, MapperParamsType,
SequelizeMapper, SequelizeMapper,
} from "@common/infrastructure/sequelize/sequelize-mapper"; } from "@common/infrastructure/sequelize/sequelize-mapper";
import { Company } from "@contexts/companies/domain/aggregates/company"; import { Account } from "@contexts/accounts/domain/";
import { CompanyCreationAttributes, CompanyModel } from "../sequelize/company.model"; import { AccountCreationAttributes, AccountModel } from "../sequelize/account.model";
export interface ICompanyMapper export interface IAccountMapper
extends ISequelizeMapper<CompanyModel, CompanyCreationAttributes, Company> {} extends ISequelizeMapper<AccountModel, AccountCreationAttributes, Account> {}
export class CompanyMapper export class AccountMapper
extends SequelizeMapper<CompanyModel, CompanyCreationAttributes, Company> extends SequelizeMapper<AccountModel, AccountCreationAttributes, Account>
implements ICompanyMapper implements IAccountMapper
{ {
public mapToDomain(source: CompanyModel, params?: MapperParamsType): Result<Company, Error> { public mapToDomain(source: AccountModel, params?: MapperParamsType): Result<Account, Error> {
const idOrError = UniqueID.create(source.id); const idOrError = UniqueID.create(source.id);
const tinOrError = TINNumber.create(source.tin); const tinOrError = TINNumber.create(source.tin);
const emailOrError = EmailAddress.create(source.email); const emailOrError = EmailAddress.create(source.email);
@ -42,7 +42,7 @@ export class CompanyMapper
return Result.fail(result.error); return Result.fail(result.error);
} }
return Company.create( return Account.create(
{ {
isFreelancer: source.is_freelancer, isFreelancer: source.is_freelancer,
name: source.name, name: source.name,
@ -65,9 +65,9 @@ export class CompanyMapper
} }
public mapToPersistence( public mapToPersistence(
source: Company, source: Account,
params?: MapperParamsType params?: MapperParamsType
): Result<CompanyCreationAttributes, Error> { ): Result<AccountCreationAttributes, Error> {
return Result.ok({ return Result.ok({
id: source.id.toString(), id: source.id.toString(),
is_freelancer: source.isFreelancer, is_freelancer: source.isFreelancer,
@ -96,5 +96,5 @@ export class CompanyMapper
} }
} }
const companyMapper: CompanyMapper = new CompanyMapper(); const accountMapper: AccountMapper = new AccountMapper();
export { companyMapper }; export { accountMapper };

View File

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

View File

@ -7,11 +7,11 @@ import {
Sequelize, Sequelize,
} from "sequelize"; } from "sequelize";
export type CompanyCreationAttributes = InferCreationAttributes<CompanyModel, {}> & {}; export type AccountCreationAttributes = InferCreationAttributes<AccountModel, {}> & {};
export class CompanyModel extends Model< export class AccountModel extends Model<
InferAttributes<CompanyModel>, InferAttributes<AccountModel>,
InferCreationAttributes<CompanyModel> InferCreationAttributes<AccountModel>
> { > {
// To avoid table creation // To avoid table creation
/*static async sync(): Promise<any> { /*static async sync(): Promise<any> {
@ -46,7 +46,7 @@ export class CompanyModel extends Model<
} }
export default (sequelize: Sequelize) => { export default (sequelize: Sequelize) => {
CompanyModel.init( AccountModel.init(
{ {
id: { id: {
type: DataTypes.UUID, type: DataTypes.UUID,
@ -148,7 +148,7 @@ export default (sequelize: Sequelize) => {
}, },
{ {
sequelize, sequelize,
tableName: "companies", tableName: "accounts",
paranoid: true, // softs deletes paranoid: true, // softs deletes
timestamps: true, timestamps: true,
@ -166,5 +166,5 @@ export default (sequelize: Sequelize) => {
scopes: {}, scopes: {},
} }
); );
return CompanyModel; return AccountModel;
}; };

View File

@ -0,0 +1,82 @@
import { EmailAddress, UniqueID } from "@common/domain";
import { Collection, Result } from "@common/helpers";
import { SequelizeRepository } from "@common/infrastructure";
import { Account } from "@contexts/accounts/domain";
import { IAccountRepository } from "@contexts/accounts/domain/repositories/company-repository.interface";
import { Transaction } from "sequelize";
import { accountMapper, IAccountMapper } from "../mappers/account.mapper";
import { AccountModel } from "./account.model";
class AccountRepository extends SequelizeRepository<Account> implements IAccountRepository {
private readonly _mapper!: IAccountMapper;
/**
* 🔹 Función personalizada para mapear errores de unicidad en autenticación
*/
private _customErrorMapper(error: Error): string | null {
if (error.name === "SequelizeUniqueConstraintError") {
return "Account with this email already exists";
}
return null;
}
constructor(mapper: IAccountMapper) {
super();
this._mapper = mapper;
}
async findAll(transaction?: Transaction): Promise<Result<Collection<Account>, Error>> {
try {
const rawAccounts: any = await this._findAll(AccountModel, {}, transaction);
if (!rawAccounts === true) {
return Result.fail(new Error("Account with email not exists"));
}
return this._mapper.mapArrayToDomain(rawAccounts);
} catch (error: any) {
return this._handleDatabaseError(error, this._customErrorMapper);
}
}
async findById(id: UniqueID, transaction?: Transaction): Promise<Result<Account, Error>> {
try {
const rawAccount: any = await this._getById(AccountModel, id, {}, transaction);
if (!rawAccount === true) {
return Result.fail(new Error(`Account with id ${id.toString()} not exists`));
}
return this._mapper.mapToDomain(rawAccount);
} catch (error: any) {
return this._handleDatabaseError(error, this._customErrorMapper);
}
}
async findByEmail(
email: EmailAddress,
transaction?: Transaction
): Promise<Result<Account, Error>> {
try {
const rawAccount: any = await this._getBy(
AccountModel,
"email",
email.toString(),
{},
transaction
);
if (!rawAccount === true) {
return Result.fail(new Error(`Account with email ${email.toString()} not exists`));
}
return this._mapper.mapToDomain(rawAccount);
} catch (error: any) {
return this._handleDatabaseError(error, this._customErrorMapper);
}
}
}
const accountRepository = new AccountRepository(accountMapper);
export { accountRepository };

View File

@ -0,0 +1,8 @@
import { IAccountRepository } from "@contexts/accounts/domain/repositories/company-repository.interface";
import { accountRepository } from "./account.repository";
export * from "./account.model";
export const createAccountRepository = (): IAccountRepository => {
return accountRepository;
};

View File

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

View File

@ -0,0 +1,16 @@
import { SequelizeTransactionManager } from "@common/infrastructure";
import { ListAccountsUseCase } from "@contexts/accounts/application/list-accounts/list-accounts.use-case";
import { AccountService } from "@contexts/accounts/domain/services/company.service";
import { accountRepository } from "@contexts/accounts/infraestructure/sequelize/account.repository";
import { ListAccountsController } from "./list-accounts.controller";
import { listAccountsPresenter } from "./list-accounts.presenter";
export const listAccountsController = () => {
const transactionManager = new SequelizeTransactionManager();
const accountService = new AccountService(accountRepository);
const useCase = new ListAccountsUseCase(accountService, transactionManager);
const presenter = listAccountsPresenter;
return new ListAccountsController(useCase, presenter);
};

View File

@ -0,0 +1,37 @@
import { ExpressController } from "@common/presentation";
import { ListAccountsUseCase } from "@contexts/accounts/application/list-accounts/list-accounts.use-case";
import { IListAccountsPresenter } from "./list-accounts.presenter";
export class ListAccountsController extends ExpressController {
public constructor(
private readonly listAccounts: ListAccountsUseCase,
private readonly presenter: IListAccountsPresenter
) {
super();
}
protected async executeImpl() {
const accountsOrError = await this.listAccounts.execute();
if (accountsOrError.isFailure) {
return this.handleError(accountsOrError.error);
}
return this.ok(this.presenter.toDTO(accountsOrError.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, ensureBoolean, ensureNumber, ensureString } from "@common/helpers";
import { Account } from "@contexts/accounts/domain";
import { IListAccountsResponseDTO } from "../../dto";
export interface IListAccountsPresenter {
toDTO: (accounts: Collection<Account>) => IListAccountsResponseDTO[];
}
export const listAccountsPresenter: IListAccountsPresenter = {
toDTO: (accounts: Collection<Account>): IListAccountsResponseDTO[] =>
accounts.map((account) => ({
id: ensureString(account.id.toString()),
is_freelancer: ensureBoolean(account.isFreelancer),
name: ensureString(account.name),
trade_name: ensureString(account.tradeName.getOrUndefined()),
tin: ensureString(account.tin.toString()),
street: ensureString(account.address.street),
city: ensureString(account.address.city),
state: ensureString(account.address.state),
postal_code: ensureString(account.address.postalCode),
country: ensureString(account.address.country),
email: ensureString(account.email.toString()),
phone: ensureString(account.phone.toString()),
fax: ensureString(account.fax.getOrUndefined()?.toString()),
website: ensureString(account.website.getOrUndefined()),
legal_record: ensureString(account.legalRecord),
default_tax: ensureNumber(account.defaultTax),
status: ensureString(account.isActive ? "active" : "inactive"),
lang_code: ensureString(account.langCode),
currency_code: ensureString(account.currencyCode),
logo: ensureString(account.logo.getOrUndefined()),
})),
};

View File

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

View File

@ -1,4 +1,4 @@
export interface IListCompaniesResponseDTO { export interface IListAccountsResponseDTO {
id: string; id: string;
is_freelancer: boolean; is_freelancer: boolean;

View File

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

View File

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

View File

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

View File

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

View File

@ -1,17 +0,0 @@
import { Collection, Result } from "@common/helpers";
import { ITransactionManager } from "@common/infrastructure/database";
import { Company } from "@contexts/companies/domain";
import { ICompanyService } from "@contexts/companies/domain/services/company-service.interface";
export class ListCompaniesUseCase {
constructor(
private readonly companyService: ICompanyService,
private readonly transactionManager: ITransactionManager
) {}
public execute(): Promise<Result<Collection<Company>, Error>> {
return this.transactionManager.complete((transaction) => {
return this.companyService.findCompanies(transaction);
});
}
}

View File

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

View File

@ -1,5 +0,0 @@
export * from "./aggregates";
export * from "./entities";
export * from "./events";
export * from "./repositories";
export * from "./value-objects";

View File

@ -1,9 +0,0 @@
import { EmailAddress, UniqueID } from "@common/domain";
import { Collection, Result } from "@common/helpers";
import { Company } from "../aggregates";
export interface ICompanyRepository {
findAll(transaction?: any): Promise<Result<Collection<Company>, Error>>;
findById(id: UniqueID, transaction?: any): Promise<Result<Company, Error>>;
findByEmail(email: EmailAddress, transaction?: any): Promise<Result<Company, Error>>;
}

View File

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

View File

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

View File

@ -1,23 +0,0 @@
import { UniqueID } from "@common/domain";
import { Collection, Result } from "@common/helpers";
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<Collection<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(new Collection(activeCompanies));
}
async findCompanyById(companyId: UniqueID, transaction?: any): Promise<Result<Company>> {
return await this.companyRepository.findById(companyId, transaction);
}
}

View File

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

View File

@ -1,9 +0,0 @@
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

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

View File

@ -1,16 +0,0 @@
import { SequelizeTransactionManager } from "@common/infrastructure";
import { ListCompaniesUseCase } from "@contexts/companies/application/list-companies/list-companies.use-case";
import { CompanyService } from "@contexts/companies/domain/services/company.service";
import { companyRepository } from "@contexts/companies/infraestructure";
import { ListCompaniesController } from "./list-companies.controller";
import { listCompaniesPresenter } from "./list-companies.presenter";
export const listCompaniesController = () => {
const transactionManager = new SequelizeTransactionManager();
const companyService = new CompanyService(companyRepository);
const useCase = new ListCompaniesUseCase(companyService, transactionManager);
const presenter = listCompaniesPresenter;
return new ListCompaniesController(useCase, presenter);
};

View File

@ -1,37 +0,0 @@
import { ExpressController } from "@common/presentation";
import { ListCompaniesUseCase } from "@contexts/companies/application/list-companies/list-companies.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

@ -1,38 +0,0 @@
import { Collection, ensureBoolean, ensureNumber, 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.getOrUndefined()),
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.getOrUndefined()?.toString()),
website: ensureString(company.website.getOrUndefined()),
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.getOrUndefined()),
})),
};

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,16 @@
import { Collection, Result } from "@common/helpers";
import { ITransactionManager } from "@common/infrastructure/database";
import { Contact, IContactService } from "../domain";
export class ListContactsUseCase {
constructor(
private readonly contactService: IContactService,
private readonly transactionManager: ITransactionManager
) {}
public execute(): Promise<Result<Collection<Contact>, Error>> {
return this.transactionManager.complete((transaction) => {
return this.contactService.findContact(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 IContactProps {
reference: string;
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>;
}
export interface IContact {
id: UniqueID;
reference: string;
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>;
isContact: boolean;
isFreelancer: boolean;
isActive: boolean;
}
export class Contact extends AggregateRoot<IContactProps> implements IContact {
static create(props: IContactProps, id?: UniqueID): Result<Contact, Error> {
const contact = new Contact(props, id);
// Reglas de negocio / validaciones
// ...
// ...
// 🔹 Disparar evento de dominio "ContactAuthenticatedEvent"
//const { contact } = props;
//user.addDomainEvent(new ContactAuthenticatedEvent(id, contact.toString()));
return Result.ok(contact);
}
get reference() {
return this.props.reference;
}
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 isContact(): 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 "./contact";

View File

@ -0,0 +1,3 @@
export * from "./aggregates";
export * from "./repositories";
export * from "./services";

View File

@ -0,0 +1,9 @@
import { EmailAddress, UniqueID } from "@common/domain";
import { Collection, Result } from "@common/helpers";
import { Contact } from "../aggregates";
export interface IContactRepository {
findAll(transaction?: any): Promise<Result<Collection<Contact>, Error>>;
findById(id: UniqueID, transaction?: any): Promise<Result<Contact, Error>>;
findByEmail(email: EmailAddress, transaction?: any): Promise<Result<Contact, Error>>;
}

View File

@ -0,0 +1 @@
export * from "./contact-repository.interface";

View File

@ -0,0 +1,8 @@
import { UniqueID } from "@common/domain";
import { Collection, Result } from "@common/helpers";
import { Contact } from "../aggregates";
export interface IContactService {
findContact(transaction?: any): Promise<Result<Collection<Contact>, Error>>;
findContactById(contactId: UniqueID, transaction?: any): Promise<Result<Contact>>;
}

View File

@ -0,0 +1,24 @@
import { UniqueID } from "@common/domain";
import { Collection, Result } from "@common/helpers";
import { Contact } from "../aggregates";
import { IContactRepository } from "../repositories";
import { IContactService } from "./contact-service.interface";
export class ContactService implements IContactService {
constructor(private readonly contactRepository: IContactRepository) {}
async findContact(transaction?: any): Promise<Result<Collection<Contact>, Error>> {
const contactsOrError = await this.contactRepository.findAll(transaction);
if (contactsOrError.isFailure) {
return Result.fail(contactsOrError.error);
}
// Solo devolver usuarios activos
const activeContacts = contactsOrError.data.filter((contact) => contact.isActive);
return Result.ok(new Collection(activeContacts));
}
async findContactById(contactId: UniqueID, transaction?: any): Promise<Result<Contact>> {
return await this.contactRepository.findById(contactId, transaction);
}
}

View File

@ -0,0 +1,2 @@
export * from "./contact-service.interface";
export * from "./contact.service";

View File

@ -0,0 +1,2 @@
export * from "./mappers";
export * from "./sequelize";

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 { Contact } from "../../domain";
import { ContactCreationAttributes, ContactModel } from "../sequelize/contact.model";
export interface IContactMapper
extends ISequelizeMapper<ContactModel, ContactCreationAttributes, Contact> {}
export class ContactMapper
extends SequelizeMapper<ContactModel, ContactCreationAttributes, Contact>
implements IContactMapper
{
public mapToDomain(source: ContactModel, params?: MapperParamsType): Result<Contact, 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 Contact.create(
{
isFreelancer: source.is_freelancer,
reference: source.reference,
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,
},
idOrError.data
);
}
public mapToPersistence(
source: Contact,
params?: MapperParamsType
): Result<ContactCreationAttributes, Error> {
return Result.ok({
id: source.id.toString(),
reference: source.reference,
is_freelancer: source.isFreelancer,
name: source.name,
trade_name: source.tradeName.getOrUndefined(),
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.getOrUndefined()?.toString() : undefined,
website: source.website.getOrUndefined(),
legal_record: source.legalRecord,
default_tax: source.defaultTax,
status: source.isActive ? "active" : "inactive",
lang_code: source.langCode,
currency_code: source.currencyCode,
});
}
}
const contactMapper: ContactMapper = new ContactMapper();
export { contactMapper };

View File

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

View File

@ -0,0 +1,172 @@
import {
CreationOptional,
DataTypes,
InferAttributes,
InferCreationAttributes,
Model,
Sequelize,
} from "sequelize";
export type ContactCreationAttributes = InferCreationAttributes<ContactModel, {}> & {};
export class ContactModel extends Model<
InferAttributes<ContactModel>,
InferCreationAttributes<ContactModel>
> {
// To avoid table creation
/*static async sync(): Promise<any> {
return Promise.resolve();
}*/
declare id: string;
declare reference: CreationOptional<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;
}
export default (sequelize: Sequelize) => {
ContactModel.init(
{
id: {
type: DataTypes.UUID,
primaryKey: true,
},
reference: {
type: DataTypes.STRING,
allowNull: false,
},
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,
},
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: "contacts",
paranoid: true, // softs deletes
timestamps: true,
createdAt: "created_at",
updatedAt: "updated_at",
deletedAt: "deleted_at",
indexes: [
{ name: "email_idx", fields: ["email"], unique: true },
{ name: "reference_idx", fields: ["reference"], unique: true },
],
whereMergeStrategy: "and", // <- cómo tratar el merge de un scope
defaultScope: {},
scopes: {},
}
);
return ContactModel;
};

View File

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

View File

@ -0,0 +1,9 @@
import { IContactRepository } from "../../domain";
import { contactRepository } from "./contact.repository";
export * from "./contact.model";
export * from "./contact.repository";
export const createContactRepository = (): IContactRepository => {
return contactRepository;
};

View File

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

View File

@ -0,0 +1,16 @@
import { SequelizeTransactionManager } from "@common/infrastructure";
import { ListContactsUseCase } from "../../../application";
import { ContactService } from "../../../domain";
import { contactRepository } from "../../../infraestructure";
import { ListContactsController } from "./list-contacts.controller";
import { listContactsPresenter } from "./list-contacts.presenter";
export const listContactsController = () => {
const transactionManager = new SequelizeTransactionManager();
const contactService = new ContactService(contactRepository);
const useCase = new ListContactsUseCase(contactService, transactionManager);
const presenter = listContactsPresenter;
return new ListContactsController(useCase, presenter);
};

View File

@ -0,0 +1,37 @@
import { ExpressController } from "@common/presentation";
import { ListContactsUseCase } from "../../../application";
import { IListContactsPresenter } from "./list-contacts.presenter";
export class ListContactsController extends ExpressController {
public constructor(
private readonly listContacts: ListContactsUseCase,
private readonly presenter: IListContactsPresenter
) {
super();
}
protected async executeImpl() {
const contactsOrError = await this.listContacts.execute();
if (contactsOrError.isFailure) {
return this.handleError(contactsOrError.error);
}
return this.ok(this.presenter.toDTO(contactsOrError.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, ensureBoolean, ensureNumber, ensureString } from "@common/helpers";
import { Contact } from "../../../domain";
import { IListContactsResponseDTO } from "../../dto";
export interface IListContactsPresenter {
toDTO: (contacts: Collection<Contact>) => IListContactsResponseDTO[];
}
export const listContactsPresenter: IListContactsPresenter = {
toDTO: (contacts: Collection<Contact>): IListContactsResponseDTO[] =>
contacts.map((contact) => ({
id: ensureString(contact.id.toString()),
reference: ensureString(contact.reference),
is_freelancer: ensureBoolean(contact.isFreelancer),
name: ensureString(contact.name),
trade_name: ensureString(contact.tradeName.getOrUndefined()),
tin: ensureString(contact.tin.toString()),
street: ensureString(contact.address.street),
city: ensureString(contact.address.city),
state: ensureString(contact.address.state),
postal_code: ensureString(contact.address.postalCode),
country: ensureString(contact.address.country),
email: ensureString(contact.email.toString()),
phone: ensureString(contact.phone.toString()),
fax: ensureString(contact.fax.getOrUndefined()?.toString()),
website: ensureString(contact.website.getOrUndefined()),
legal_record: ensureString(contact.legalRecord),
default_tax: ensureNumber(contact.defaultTax),
status: ensureString(contact.isActive ? "active" : "inactive"),
lang_code: ensureString(contact.langCode),
currency_code: ensureString(contact.currencyCode),
})),
};

View File

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

View File

@ -0,0 +1,27 @@
export interface IListContactsResponseDTO {
id: string;
reference: 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;
}

View File

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

View File

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

View File

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

View File

@ -1,20 +1,21 @@
import { validateRequestDTO } from "@common/presentation"; import { validateRequestDTO } from "@common/presentation";
import { ListAccountsSchema } from "@contexts/accounts/presentation";
import { listAccountsController } from "@contexts/accounts/presentation/controllers/list-accounts";
import { checkTabContext } from "@contexts/auth/infraestructure"; import { checkTabContext } from "@contexts/auth/infraestructure";
import { listCompaniesController, ListCompaniesSchema } from "@contexts/companies/presentation";
import { NextFunction, Request, Response, Router } from "express"; import { NextFunction, Request, Response, Router } from "express";
export const companiesRouter = (appRouter: Router) => { export const accountsRouter = (appRouter: Router) => {
const routes: Router = Router({ mergeParams: true }); const routes: Router = Router({ mergeParams: true });
routes.get( routes.get(
"/", "/",
validateRequestDTO(ListCompaniesSchema), validateRequestDTO(ListAccountsSchema),
checkTabContext, checkTabContext,
//checkUser, //checkUser,
(req: Request, res: Response, next: NextFunction) => { (req: Request, res: Response, next: NextFunction) => {
listCompaniesController().execute(req, res, next); listAccountsController().execute(req, res, next);
} }
); );
appRouter.use("/companies", routes); appRouter.use("/accounts", routes);
}; };

View File

@ -1,6 +1,6 @@
import { Router } from "express"; import { Router } from "express";
import { accountsRouter } from "./accounts.routes";
import { authRouter } from "./auth.routes"; import { authRouter } from "./auth.routes";
import { companiesRouter } from "./companies.routes";
import { customersRouter } from "./customers.routes"; import { customersRouter } from "./customers.routes";
import { usersRouter } from "./users.routes"; import { usersRouter } from "./users.routes";
@ -13,7 +13,7 @@ export const v1Routes = () => {
authRouter(routes); authRouter(routes);
usersRouter(routes); usersRouter(routes);
companiesRouter(routes); accountsRouter(routes);
// Sales // Sales
customersRouter(routes); customersRouter(routes);