From 2194ed14200b019fc9bab03878d82fbe3417eacc Mon Sep 17 00:00:00 2001 From: David Arranz Date: Tue, 23 Apr 2024 17:29:38 +0200 Subject: [PATCH] . --- .gitignore | 22 + .prettierc.json | 10 + .vscode/launch.json | 37 + .vscode/settings.json | 8 + package.json | 32 + server/.eslintrc.json | 55 + server/package.json | 83 ++ server/src/config/environments/development.ts | 40 + server/src/config/environments/production.ts | 21 + server/src/config/index.ts | 21 + .../application/ListProductsUseCase.ts | 79 ++ .../src/contexts/catalog/application/index.ts | 1 + .../catalog/domain/entities/Product.ts | 75 ++ .../contexts/catalog/domain/entities/index.ts | 1 + server/src/contexts/catalog/domain/index.ts | 2 + .../repository/CatalogRepository.interface.ts | 7 + .../catalog/domain/repository/index.ts | 1 + .../infrastructure/Catalog.repository.ts | 59 + .../infrastructure/express/catalogRoutes.ts | 40 + .../listProducts/ListProductsController.ts | 88 ++ .../express/controllers/listProducts/index.ts | 34 + .../presenter/ListProducts.presenter.ts | 71 ++ .../listProducts/presenter/index.ts | 1 + .../infrastructure/firebird/firebird.model.ts | 1 + .../catalog/infrastructure/firebird/index.ts | 1 + .../infrastructure/firebird/product.model.ts | 11 + .../contexts/catalog/infrastructure/index.ts | 9 + .../catalog/infrastructure/mappers/index.ts | 0 .../infrastructure/mappers/product.mapper.ts | 47 + .../src/contexts/common/application/index.ts | 3 + .../services/ApplicationService.ts | 4 + .../services/ApplicationServiceError.ts | 23 + .../services/QueryCriteriaService.ts | 98 ++ .../common/application/services/index.ts | 1 + .../application/useCases/UseCase.interface.ts | 6 + .../application/useCases/UseCaseError.ts | 81 ++ .../common/application/useCases/index.ts | 2 + .../common/domain/Mapper.interface.ts | 24 + .../contexts/common/domain/Specification.ts | 163 +++ .../common/domain/errors/ServerError.ts | 68 ++ .../contexts/common/domain/errors/index.ts | 1 + server/src/contexts/common/domain/index.ts | 3 + .../domain/repositories/Adapter.interface.ts | 5 + .../BusinessTransaction.interface.ts | 5 + .../repositories/Repository.interface.ts | 2 + .../domain/repositories/RepositoryBuilder.ts | 1 + .../domain/repositories/RepositoryManager.ts | 49 + .../RepositoryQueryBuilder.interface.ts | 5 + .../common/domain/repositories/index.ts | 6 + .../contexts/common/domain/services/index.ts | 0 .../common/infrastructure/ContextFactory.ts | 36 + .../infrastructure/InfrastructureError.ts | 57 + .../express/ExpressController.ts | 232 ++++ .../infrastructure/express/HttpStatusCodes.ts | 42 + .../infrastructure/express/ProblemDocument.ts | 106 ++ .../common/infrastructure/express/index.ts | 1 + .../firebird/FirebirdAdapter.ts | 107 ++ .../firebird/FirebirdBusinessTransaction.ts | 55 + .../firebird/FirebirdRepository.ts | 97 ++ .../common/infrastructure/firebird/index.ts | 12 + .../queryBuilder/FirebirdQueryBuilder.ts | 12 + .../firebird/queryBuilder/index.ts | 5 + .../contexts/common/infrastructure/index.ts | 3 + .../infrastructure/mappers/FirebirdMapper.ts | 175 +++ .../infrastructure/mappers/SequelizeMapper.ts | 211 ++++ .../common/infrastructure/mappers/index.ts | 1 + .../sequelize/SequelizeAdapter.ts | 189 +++ .../sequelize/SequelizeBusinessTransaction.ts | 64 + .../sequelize/SequelizeModel.interface.ts | 18 + .../sequelize/SequelizeRepository.ts | 266 ++++ .../common/infrastructure/sequelize/index.ts | 12 + .../queryBuilder/SequelizeParseFilter.ts | 123 ++ .../queryBuilder/SequelizeParseOrder.ts | 26 + .../queryBuilder/SequelizeQueryBuilder.ts | 154 +++ .../sequelize/queryBuilder/index.ts | 5 + server/src/index.ts | 2 + server/src/infrastructure/express/api/v1.ts | 9 + server/src/infrastructure/express/app.ts | 57 + server/src/infrastructure/http/server.ts | 144 +++ server/src/infrastructure/logger/index.ts | 85 ++ server/tsconfig.eslint.json | 12 + server/tsconfig.json | 81 ++ shared/.eslintrc.json | 21 + shared/.prettierc.json | 10 + .../IListProducts_Response.dto.ts | 11 + .../dto/IListProducts.dto/index.ts | 1 + .../contexts/catalog/application/dto/index.ts | 1 + .../lib/contexts/catalog/application/index.ts | 1 + shared/lib/contexts/catalog/index.ts | 1 + .../application/dto/IError_Response.dto.ts | 20 + .../common/application/dto/IMoney.dto.ts | 7 + .../common/application/dto/IPercentage.dto.ts | 6 + .../common/application/dto/ITaxType.dto.ts | 11 + .../contexts/common/application/dto/index.ts | 4 + .../lib/contexts/common/application/index.ts | 1 + .../lib/contexts/common/domain/EntityError.ts | 6 + .../common/domain/IListResponse.dto.ts | 25 + .../contexts/common/domain/RuleValidator.ts | 66 + .../domain/entities/Address/AddressTitle.ts | 51 + .../domain/entities/Address/AddressType.ts | 59 + .../common/domain/entities/Address/City.ts | 49 + .../common/domain/entities/Address/Country.ts | 51 + .../domain/entities/Address/GenericAddress.ts | 116 ++ .../domain/entities/Address/PostalCode.ts | 52 + .../domain/entities/Address/Province.ts | 51 + .../common/domain/entities/Address/Street.ts | 51 + .../common/domain/entities/Address/index.ts | 9 + .../common/domain/entities/AggregateRoot.ts | 39 + .../common/domain/entities/Collection.ts | 113 ++ .../domain/entities/Currency/Currency.ts | 79 ++ .../domain/entities/Currency/currencies.ts | 1082 +++++++++++++++++ .../common/domain/entities/Currency/index.ts | 1 + .../common/domain/entities/Description.ts | 40 + .../contexts/common/domain/entities/Email.ts | 53 + .../contexts/common/domain/entities/Entity.ts | 70 ++ .../domain/entities/Language/Language.ts | 98 ++ .../common/domain/entities/Language/index.ts | 1 + .../entities/Language/languages_data.ts | 738 +++++++++++ .../common/domain/entities/Measure.ts | 38 + .../common/domain/entities/MoneyValue.test.ts | 109 ++ .../common/domain/entities/MoneyValue.ts | 357 ++++++ .../contexts/common/domain/entities/Name.ts | 49 + .../contexts/common/domain/entities/Note.ts | 53 + .../domain/entities/NullableValueObject.ts | 11 + .../common/domain/entities/Percentage.ts | 57 + .../contexts/common/domain/entities/Phone.ts | 63 + .../common/domain/entities/Quantity.test.ts | 80 ++ .../common/domain/entities/Quantity.ts | 97 ++ .../QueryCriteria/Field/FieldCriteria.ts | 59 + .../entities/QueryCriteria/Field/index.ts | 0 .../entities/QueryCriteria/Filters/Filter.ts | 78 ++ .../QueryCriteria/Filters/FilterCollection.ts | 40 + .../QueryCriteria/Filters/FilterCriteria.ts | 145 +++ .../entities/QueryCriteria/Filters/index.ts | 3 + .../entities/QueryCriteria/Order/Order.ts | 111 ++ .../QueryCriteria/Order/OrderCollection.ts | 39 + .../QueryCriteria/Order/OrderCriteria.ts | 135 ++ .../entities/QueryCriteria/Order/OrderRoot.ts | 109 ++ .../Order/__test__/Order.test.ts | 52 + .../Order/__test__/OrderCriteria.test.ts | 49 + .../entities/QueryCriteria/Order/index.ts | 4 + .../QueryCriteria/Pagination/OffsetPaging.ts | 145 +++ .../__test__/PaginatedResult.test.ts.bak | 81 ++ .../__test__/PagingStrategy.test.ts.bak | 84 ++ .../QueryCriteria/Pagination/index.ts | 1 + .../entities/QueryCriteria/QueryCriteria.ts | 75 ++ .../QuickSearch/QuickSearchCriteria.ts | 66 + .../QueryCriteria/QuickSearch/index.ts | 1 + .../domain/entities/QueryCriteria/index.ts | 5 + .../contexts/common/domain/entities/Result.ts | 63 + .../domain/entities/ResultCollection.ts | 49 + .../contexts/common/domain/entities/Slug.ts | 90 ++ .../domain/entities/StringValueObject.ts | 22 + .../common/domain/entities/TINNumber.ts | 58 + .../domain/entities/UTCDateValue.test.ts | 55 + .../common/domain/entities/UTCDateValue.ts | 78 ++ .../common/domain/entities/UniqueID.ts | 87 ++ .../common/domain/entities/UnitPrice.ts | 28 + .../common/domain/entities/ValueObject.ts | 39 + .../contexts/common/domain/entities/index.ts | 27 + .../common/domain/errors/DomainError.ts | 39 + .../common/domain/errors/GenericError.ts | 20 + .../contexts/common/domain/errors/index.ts | 2 + .../domain/events/DomainEventInterface.ts | 6 + .../common/domain/events/DomainEvents.ts | 95 ++ .../common/domain/events/HandleInterface.ts | 3 + .../contexts/common/domain/events/index.ts | 3 + .../xxx__tests__xxxx/domainEvents.txst.ts | 139 +++ .../mocks/domain/mockJobAggregateRoot.ts | 26 + .../mocks/domain/mockJobAggregateRootID.ts | 4 + .../mocks/events/mockJobCreatedEvent.ts | 17 + .../mocks/events/mockJobDeletedEvent.ts | 17 + .../mocks/services/mockPostToSocial.ts | 33 + shared/lib/contexts/common/domain/index.ts | 5 + .../common/domain/spanish-joi-messages.json | 93 ++ shared/lib/contexts/common/index.ts | 2 + shared/lib/contexts/index.ts | 2 + shared/lib/index.ts | 1 + shared/lib/utilities/index.ts | 3 + shared/package.json | 29 + shared/tsconfig.json | 10 + tsconfig.json | 13 + 182 files changed, 10155 insertions(+) create mode 100644 .gitignore create mode 100644 .prettierc.json create mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json create mode 100644 package.json create mode 100644 server/.eslintrc.json create mode 100644 server/package.json create mode 100644 server/src/config/environments/development.ts create mode 100644 server/src/config/environments/production.ts create mode 100644 server/src/config/index.ts create mode 100644 server/src/contexts/catalog/application/ListProductsUseCase.ts create mode 100644 server/src/contexts/catalog/application/index.ts create mode 100644 server/src/contexts/catalog/domain/entities/Product.ts create mode 100644 server/src/contexts/catalog/domain/entities/index.ts create mode 100644 server/src/contexts/catalog/domain/index.ts create mode 100644 server/src/contexts/catalog/domain/repository/CatalogRepository.interface.ts create mode 100644 server/src/contexts/catalog/domain/repository/index.ts create mode 100644 server/src/contexts/catalog/infrastructure/Catalog.repository.ts create mode 100644 server/src/contexts/catalog/infrastructure/express/catalogRoutes.ts create mode 100644 server/src/contexts/catalog/infrastructure/express/controllers/listProducts/ListProductsController.ts create mode 100644 server/src/contexts/catalog/infrastructure/express/controllers/listProducts/index.ts create mode 100644 server/src/contexts/catalog/infrastructure/express/controllers/listProducts/presenter/ListProducts.presenter.ts create mode 100644 server/src/contexts/catalog/infrastructure/express/controllers/listProducts/presenter/index.ts create mode 100644 server/src/contexts/catalog/infrastructure/firebird/firebird.model.ts create mode 100644 server/src/contexts/catalog/infrastructure/firebird/index.ts create mode 100644 server/src/contexts/catalog/infrastructure/firebird/product.model.ts create mode 100644 server/src/contexts/catalog/infrastructure/index.ts create mode 100644 server/src/contexts/catalog/infrastructure/mappers/index.ts create mode 100644 server/src/contexts/catalog/infrastructure/mappers/product.mapper.ts create mode 100644 server/src/contexts/common/application/index.ts create mode 100644 server/src/contexts/common/application/services/ApplicationService.ts create mode 100644 server/src/contexts/common/application/services/ApplicationServiceError.ts create mode 100644 server/src/contexts/common/application/services/QueryCriteriaService.ts create mode 100644 server/src/contexts/common/application/services/index.ts create mode 100644 server/src/contexts/common/application/useCases/UseCase.interface.ts create mode 100755 server/src/contexts/common/application/useCases/UseCaseError.ts create mode 100644 server/src/contexts/common/application/useCases/index.ts create mode 100644 server/src/contexts/common/domain/Mapper.interface.ts create mode 100644 server/src/contexts/common/domain/Specification.ts create mode 100644 server/src/contexts/common/domain/errors/ServerError.ts create mode 100644 server/src/contexts/common/domain/errors/index.ts create mode 100644 server/src/contexts/common/domain/index.ts create mode 100644 server/src/contexts/common/domain/repositories/Adapter.interface.ts create mode 100644 server/src/contexts/common/domain/repositories/BusinessTransaction.interface.ts create mode 100644 server/src/contexts/common/domain/repositories/Repository.interface.ts create mode 100644 server/src/contexts/common/domain/repositories/RepositoryBuilder.ts create mode 100644 server/src/contexts/common/domain/repositories/RepositoryManager.ts create mode 100644 server/src/contexts/common/domain/repositories/RepositoryQueryBuilder.interface.ts create mode 100644 server/src/contexts/common/domain/repositories/index.ts create mode 100644 server/src/contexts/common/domain/services/index.ts create mode 100644 server/src/contexts/common/infrastructure/ContextFactory.ts create mode 100755 server/src/contexts/common/infrastructure/InfrastructureError.ts create mode 100644 server/src/contexts/common/infrastructure/express/ExpressController.ts create mode 100644 server/src/contexts/common/infrastructure/express/HttpStatusCodes.ts create mode 100644 server/src/contexts/common/infrastructure/express/ProblemDocument.ts create mode 100644 server/src/contexts/common/infrastructure/express/index.ts create mode 100644 server/src/contexts/common/infrastructure/firebird/FirebirdAdapter.ts create mode 100644 server/src/contexts/common/infrastructure/firebird/FirebirdBusinessTransaction.ts create mode 100644 server/src/contexts/common/infrastructure/firebird/FirebirdRepository.ts create mode 100644 server/src/contexts/common/infrastructure/firebird/index.ts create mode 100644 server/src/contexts/common/infrastructure/firebird/queryBuilder/FirebirdQueryBuilder.ts create mode 100644 server/src/contexts/common/infrastructure/firebird/queryBuilder/index.ts create mode 100644 server/src/contexts/common/infrastructure/index.ts create mode 100644 server/src/contexts/common/infrastructure/mappers/FirebirdMapper.ts create mode 100644 server/src/contexts/common/infrastructure/mappers/SequelizeMapper.ts create mode 100644 server/src/contexts/common/infrastructure/mappers/index.ts create mode 100644 server/src/contexts/common/infrastructure/sequelize/SequelizeAdapter.ts create mode 100644 server/src/contexts/common/infrastructure/sequelize/SequelizeBusinessTransaction.ts create mode 100644 server/src/contexts/common/infrastructure/sequelize/SequelizeModel.interface.ts create mode 100644 server/src/contexts/common/infrastructure/sequelize/SequelizeRepository.ts create mode 100644 server/src/contexts/common/infrastructure/sequelize/index.ts create mode 100644 server/src/contexts/common/infrastructure/sequelize/queryBuilder/SequelizeParseFilter.ts create mode 100644 server/src/contexts/common/infrastructure/sequelize/queryBuilder/SequelizeParseOrder.ts create mode 100644 server/src/contexts/common/infrastructure/sequelize/queryBuilder/SequelizeQueryBuilder.ts create mode 100644 server/src/contexts/common/infrastructure/sequelize/queryBuilder/index.ts create mode 100644 server/src/index.ts create mode 100644 server/src/infrastructure/express/api/v1.ts create mode 100644 server/src/infrastructure/express/app.ts create mode 100644 server/src/infrastructure/http/server.ts create mode 100644 server/src/infrastructure/logger/index.ts create mode 100644 server/tsconfig.eslint.json create mode 100644 server/tsconfig.json create mode 100644 shared/.eslintrc.json create mode 100644 shared/.prettierc.json create mode 100644 shared/lib/contexts/catalog/application/dto/IListProducts.dto/IListProducts_Response.dto.ts create mode 100644 shared/lib/contexts/catalog/application/dto/IListProducts.dto/index.ts create mode 100644 shared/lib/contexts/catalog/application/dto/index.ts create mode 100644 shared/lib/contexts/catalog/application/index.ts create mode 100644 shared/lib/contexts/catalog/index.ts create mode 100644 shared/lib/contexts/common/application/dto/IError_Response.dto.ts create mode 100644 shared/lib/contexts/common/application/dto/IMoney.dto.ts create mode 100644 shared/lib/contexts/common/application/dto/IPercentage.dto.ts create mode 100644 shared/lib/contexts/common/application/dto/ITaxType.dto.ts create mode 100644 shared/lib/contexts/common/application/dto/index.ts create mode 100644 shared/lib/contexts/common/application/index.ts create mode 100644 shared/lib/contexts/common/domain/EntityError.ts create mode 100644 shared/lib/contexts/common/domain/IListResponse.dto.ts create mode 100644 shared/lib/contexts/common/domain/RuleValidator.ts create mode 100644 shared/lib/contexts/common/domain/entities/Address/AddressTitle.ts create mode 100644 shared/lib/contexts/common/domain/entities/Address/AddressType.ts create mode 100644 shared/lib/contexts/common/domain/entities/Address/City.ts create mode 100644 shared/lib/contexts/common/domain/entities/Address/Country.ts create mode 100644 shared/lib/contexts/common/domain/entities/Address/GenericAddress.ts create mode 100644 shared/lib/contexts/common/domain/entities/Address/PostalCode.ts create mode 100644 shared/lib/contexts/common/domain/entities/Address/Province.ts create mode 100644 shared/lib/contexts/common/domain/entities/Address/Street.ts create mode 100644 shared/lib/contexts/common/domain/entities/Address/index.ts create mode 100644 shared/lib/contexts/common/domain/entities/AggregateRoot.ts create mode 100644 shared/lib/contexts/common/domain/entities/Collection.ts create mode 100644 shared/lib/contexts/common/domain/entities/Currency/Currency.ts create mode 100644 shared/lib/contexts/common/domain/entities/Currency/currencies.ts create mode 100644 shared/lib/contexts/common/domain/entities/Currency/index.ts create mode 100644 shared/lib/contexts/common/domain/entities/Description.ts create mode 100644 shared/lib/contexts/common/domain/entities/Email.ts create mode 100644 shared/lib/contexts/common/domain/entities/Entity.ts create mode 100644 shared/lib/contexts/common/domain/entities/Language/Language.ts create mode 100644 shared/lib/contexts/common/domain/entities/Language/index.ts create mode 100644 shared/lib/contexts/common/domain/entities/Language/languages_data.ts create mode 100644 shared/lib/contexts/common/domain/entities/Measure.ts create mode 100644 shared/lib/contexts/common/domain/entities/MoneyValue.test.ts create mode 100644 shared/lib/contexts/common/domain/entities/MoneyValue.ts create mode 100644 shared/lib/contexts/common/domain/entities/Name.ts create mode 100644 shared/lib/contexts/common/domain/entities/Note.ts create mode 100644 shared/lib/contexts/common/domain/entities/NullableValueObject.ts create mode 100644 shared/lib/contexts/common/domain/entities/Percentage.ts create mode 100644 shared/lib/contexts/common/domain/entities/Phone.ts create mode 100644 shared/lib/contexts/common/domain/entities/Quantity.test.ts create mode 100644 shared/lib/contexts/common/domain/entities/Quantity.ts create mode 100644 shared/lib/contexts/common/domain/entities/QueryCriteria/Field/FieldCriteria.ts create mode 100644 shared/lib/contexts/common/domain/entities/QueryCriteria/Field/index.ts create mode 100644 shared/lib/contexts/common/domain/entities/QueryCriteria/Filters/Filter.ts create mode 100644 shared/lib/contexts/common/domain/entities/QueryCriteria/Filters/FilterCollection.ts create mode 100644 shared/lib/contexts/common/domain/entities/QueryCriteria/Filters/FilterCriteria.ts create mode 100644 shared/lib/contexts/common/domain/entities/QueryCriteria/Filters/index.ts create mode 100644 shared/lib/contexts/common/domain/entities/QueryCriteria/Order/Order.ts create mode 100644 shared/lib/contexts/common/domain/entities/QueryCriteria/Order/OrderCollection.ts create mode 100644 shared/lib/contexts/common/domain/entities/QueryCriteria/Order/OrderCriteria.ts create mode 100644 shared/lib/contexts/common/domain/entities/QueryCriteria/Order/OrderRoot.ts create mode 100644 shared/lib/contexts/common/domain/entities/QueryCriteria/Order/__test__/Order.test.ts create mode 100644 shared/lib/contexts/common/domain/entities/QueryCriteria/Order/__test__/OrderCriteria.test.ts create mode 100644 shared/lib/contexts/common/domain/entities/QueryCriteria/Order/index.ts create mode 100644 shared/lib/contexts/common/domain/entities/QueryCriteria/Pagination/OffsetPaging.ts create mode 100644 shared/lib/contexts/common/domain/entities/QueryCriteria/Pagination/__test__/PaginatedResult.test.ts.bak create mode 100644 shared/lib/contexts/common/domain/entities/QueryCriteria/Pagination/__test__/PagingStrategy.test.ts.bak create mode 100644 shared/lib/contexts/common/domain/entities/QueryCriteria/Pagination/index.ts create mode 100644 shared/lib/contexts/common/domain/entities/QueryCriteria/QueryCriteria.ts create mode 100644 shared/lib/contexts/common/domain/entities/QueryCriteria/QuickSearch/QuickSearchCriteria.ts create mode 100644 shared/lib/contexts/common/domain/entities/QueryCriteria/QuickSearch/index.ts create mode 100644 shared/lib/contexts/common/domain/entities/QueryCriteria/index.ts create mode 100644 shared/lib/contexts/common/domain/entities/Result.ts create mode 100644 shared/lib/contexts/common/domain/entities/ResultCollection.ts create mode 100644 shared/lib/contexts/common/domain/entities/Slug.ts create mode 100644 shared/lib/contexts/common/domain/entities/StringValueObject.ts create mode 100644 shared/lib/contexts/common/domain/entities/TINNumber.ts create mode 100644 shared/lib/contexts/common/domain/entities/UTCDateValue.test.ts create mode 100644 shared/lib/contexts/common/domain/entities/UTCDateValue.ts create mode 100644 shared/lib/contexts/common/domain/entities/UniqueID.ts create mode 100644 shared/lib/contexts/common/domain/entities/UnitPrice.ts create mode 100644 shared/lib/contexts/common/domain/entities/ValueObject.ts create mode 100644 shared/lib/contexts/common/domain/entities/index.ts create mode 100755 shared/lib/contexts/common/domain/errors/DomainError.ts create mode 100644 shared/lib/contexts/common/domain/errors/GenericError.ts create mode 100644 shared/lib/contexts/common/domain/errors/index.ts create mode 100644 shared/lib/contexts/common/domain/events/DomainEventInterface.ts create mode 100644 shared/lib/contexts/common/domain/events/DomainEvents.ts create mode 100644 shared/lib/contexts/common/domain/events/HandleInterface.ts create mode 100644 shared/lib/contexts/common/domain/events/index.ts create mode 100755 shared/lib/contexts/common/domain/events/xxx__tests__xxxx/domainEvents.txst.ts create mode 100755 shared/lib/contexts/common/domain/events/xxx__tests__xxxx/mocks/domain/mockJobAggregateRoot.ts create mode 100755 shared/lib/contexts/common/domain/events/xxx__tests__xxxx/mocks/domain/mockJobAggregateRootID.ts create mode 100755 shared/lib/contexts/common/domain/events/xxx__tests__xxxx/mocks/events/mockJobCreatedEvent.ts create mode 100755 shared/lib/contexts/common/domain/events/xxx__tests__xxxx/mocks/events/mockJobDeletedEvent.ts create mode 100755 shared/lib/contexts/common/domain/events/xxx__tests__xxxx/mocks/services/mockPostToSocial.ts create mode 100644 shared/lib/contexts/common/domain/index.ts create mode 100644 shared/lib/contexts/common/domain/spanish-joi-messages.json create mode 100644 shared/lib/contexts/common/index.ts create mode 100644 shared/lib/contexts/index.ts create mode 100644 shared/lib/index.ts create mode 100644 shared/lib/utilities/index.ts create mode 100644 shared/package.json create mode 100644 shared/tsconfig.json create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..17d78a3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +node_modules/ +bundle.js +npm-debug.log + +/.idea +/public + +.env +.passport.js +.DS_Store + +build/ +dist/ +client/.parcel-cache +yarn-debug.log* +yarn-error.log* +yarn.lock +debug*.log* +error*.log* +.*-audit.json + + diff --git a/.prettierc.json b/.prettierc.json new file mode 100644 index 0000000..e98db94 --- /dev/null +++ b/.prettierc.json @@ -0,0 +1,10 @@ +{ + "semi": true, + "printWidth": 80, + "useTabs": false, + "endOfLine": "auto", + + "trailingComma": "all", + "singleQuote": false, + "bracketSpacing": true +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..709b87b --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,37 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Launch firefox localhost", + "type": "firefox", + "request": "launch", + "reAttach": true, + "url": "http://localhost:5173", + "webRoot": "${workspaceFolder}/client" + }, + + { + "type": "msedge", + "request": "launch", + "name": "CLIENT: Launch Edge against localhost", + "url": "http://localhost:5173", + "webRoot": "${workspaceFolder}/client" + }, + { + "type": "node", + "request": "attach", + "name": "SERVER: Attach to dev:debug", + "port": 4321, + "restart": true, + "cwd": "${workspaceRoot}" + }, + { + "name": "Launch via YARN", + "request": "launch", + "runtimeArgs": ["run", "server"], + "runtimeExecutable": "yarn", + "skipFiles": ["/**", "client/**", "dist/**", "doc/**"], + "type": "node" + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..6c15390 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + //"typescript.surveys.enabled": false, + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit", + "source.fixAll.eslint": "explicit" + }, + "editor.formatOnSave": true +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..e8c24a9 --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "uecko-presupuestador", + "version": "1.0.0", + "author": "Rodax Software ", + "license": "ISC", + "private": true, + "workspaces": [ + "shared" + ], + "scripts": { + "test": "jest --verbose", + "client": "cd client; yarn run dev:debug", + "server": "cd server; yarn run dev:debug", + "start": "concurrently --kill-others-on-fail \"yarn server\" \"yarn client\"", + "clean": "concurrently --kill-others-on-fail \"cd server; yarn run clean\" \"cd shared; yarn run clean\" \"cd client; yarn run clean\" \"rm -rf node_modules\"" + }, + "engines": { + "node": ">=18.18.0", + "yarn": ">=1.22" + }, + "devDependencies": { + "concurrently": "4.1.0" + }, + "dependencies": { + "concurrently": "4.1.0", + "@types/jest": "^29.5.6", + "eslint-plugin-jest": "^27.4.2", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "typescript": "^5.2.2" + } +} diff --git a/server/.eslintrc.json b/server/.eslintrc.json new file mode 100644 index 0000000..2bbd728 --- /dev/null +++ b/server/.eslintrc.json @@ -0,0 +1,55 @@ +{ + "root": true, + "env": { + "browser": false, + "es6": true, + "node": true + }, + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:jest/recommended", + "prettier" + ], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "project": "tsconfig.eslint.json", + "tsconfigRootDir": "./server", + "sourceType": "module" + }, + "plugins": ["@typescript-eslint", "sort-class-members"], + "rules": { + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-inferrable-types": "off", + "@typescript-eslint/no-empty-interface": "off", + "@typescript-eslint/recommended-requiring-type-checking": "off", + "@typescript-eslint/no-unused-vars": "warn", + "lines-between-class-members": [ + "error", + "always", + { "exceptAfterSingleLine": true } + ], + + "sort-class-members/sort-class-members": [ + 2, + { + "order": [ + "[static-properties]", + "[static-methods]", + "[conventional-private-properties]", + "[properties]", + "constructor", + "[methods]", + "[conventional-private-methods]" + ], + "accessorPairPositioning": "getThenSet" + } + ] + }, + "overrides": [ + { + "files": ["**/*.test.ts"], + "env": { "jest": true, "node": true } + } + ] +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..7897832 --- /dev/null +++ b/server/package.json @@ -0,0 +1,83 @@ +{ + "name": "@uecko-presupuestador/server", + "private": false, + "version": "1.0.0", + "main": "./src/index.ts", + "scripts": { + "start": "node -r ts-node/register/transpile-only -r tsconfig-paths/register ../dist/src/index.js", + "dev": "ts-node-dev -r tsconfig-paths/register ./src/index.ts", + "dev:debug": "ts-node-dev --transpile-only --respawn --inspect=4321 -r tsconfig-paths/register ./src/index.ts", + "build": "tsc", + "lint": "eslint --ignore-path .gitignore . --ext .ts", + "lint:fix": "npm run lint -- --fix", + "test": "jest --verbose", + "clean": "rm -rf node_modules" + }, + "author": "Rodax Software ", + "license": "ISC", + "devDependencies": { + "@types/cors": "^2.8.13", + "@types/dinero.js": "^1.9.1", + "@types/express": "^4.17.13", + "@types/glob": "^8.1.0", + "@types/jest": "^29.5.6", + "@types/luxon": "^3.3.1", + "@types/module-alias": "^2.0.1", + "@types/morgan": "^1.9.4", + "@types/node": "^20.4.9", + "@types/response-time": "^2.3.5", + "@types/supertest": "^2.0.11", + "@types/validator": "^13.11.1", + "@typescript-eslint/eslint-plugin": "^6.8.0", + "@typescript-eslint/parser": "^6.8.0", + "eslint": "^8.52.0", + "eslint-config-prettier": "^9.0.0", + "eslint-plugin-hexagonal-architecture": "^1.0.3", + "eslint-plugin-import": "^2.28.0", + "eslint-plugin-jest": "^27.4.2", + "eslint-plugin-prettier": "^5.0.0", + "eslint-plugin-simple-import-sort": "^10.0.0", + "eslint-plugin-sort-class-members": "^1.19.0", + "eslint-plugin-unused-imports": "^3.0.0", + "jest": "^29.7.0", + "module-alias": "^2.2.3", + "prettier": "3.0.1", + "supertest": "^6.2.2", + "ts-jest": "^29.1.1", + "ts-node-dev": "^2.0.0", + "typescript": "^5.2.2" + }, + "dependencies": { + "@joi/date": "^2.1.0", + "@reis/joi-luxon": "^3.0.0", + "cls-rtracer": "^2.6.3", + "cors": "^2.8.5", + "cross-env": "5.0.5", + "dotenv": "^16.3.1", + "express": "^4.18.2", + "express-openapi-validator": "^5.0.4", + "helmet": "^7.0.0", + "joi": "^17.12.3", + "joi-phone-number": "^5.1.1", + "lodash": "^4.17.21", + "luxon": "^3.4.0", + "moment": "^2.29.4", + "morgan": "^1.10.0", + "mysql2": "^3.6.0", + "node-firebird": "^1.1.8", + "path": "^0.12.7", + "remove": "^0.1.5", + "response-time": "^2.3.2", + "sequelize": "^6.33.0", + "sequelize-revision": "^6.0.0", + "sequelize-typescript": "^2.1.5", + "shallow-equal-object": "^1.1.1", + "ts-node": "^10.9.1", + "tsconfig-paths": "^4.2.0", + "winston": "^3.10.0", + "winston-daily-rotate-file": "^4.7.1" + }, + "engines": { + "node": ">=18" + } +} diff --git a/server/src/config/environments/development.ts b/server/src/config/environments/development.ts new file mode 100644 index 0000000..b839828 --- /dev/null +++ b/server/src/config/environments/development.ts @@ -0,0 +1,40 @@ +module.exports = { + database: { + username: "rodax", + password: "rodax", + database: "uecko", + host: process.env.HOSTNAME || "localhost", + port: 3306, + dialect: "mysql", + }, + + firebird: { + host: process.env.HOSTNAME || "192.168.0.133", + port: 3050, + database: "C:/Codigo/Output/Debug/Database/FACTUGES.FDB", + user: "SYSDBA", + password: "masterkey", + lowercase_keys: false, // set to true to lowercase keys + role: null, // default + pageSize: 4096, // default when creating database + retryConnectionInterval: 1000, // reconnect interval in case of connection drop + blobAsText: false, // set to true to get blob as text, only affects blob subtype 1 + encoding: "UTF-8", // default encoding for connection is UTF-8 }, + poolCount: 5, // opened sockets + }, + + server: { + hostname: process.env.HOSTNAME || "127.0.0.1", + port: process.env.PORT || 4001, + public_url: "", + }, + + uploads: { + imports: + process.env.UPLOAD_PATH || + "/home/rodax/Documentos/BBDD/server/uploads/imports", + documents: + process.env.UPLOAD_PATH || + "/home/rodax/Documentos/BBDD/server/uploads/documents", + }, +}; diff --git a/server/src/config/environments/production.ts b/server/src/config/environments/production.ts new file mode 100644 index 0000000..bd67a85 --- /dev/null +++ b/server/src/config/environments/production.ts @@ -0,0 +1,21 @@ +module.exports = { + database: { + username: "uecko", + password: "", + database: "uecko2", + host: process.env.HOSTNAME || "localhost", + port: 3306, + dialect: "mysql", + }, + + server: { + hostname: process.env.HOSTNAME || "127.0.0.1", + port: process.env.PORT || 17777, + public_url: "https://...", + }, + + uploads: { + imports: process.env.UPLOAD_PATH || "/opt/bbdd/imports", + documents: process.env.UPLOAD_PATH || "/opt/bbdd/documents", + }, +}; diff --git a/server/src/config/index.ts b/server/src/config/index.ts new file mode 100644 index 0000000..2f58c41 --- /dev/null +++ b/server/src/config/index.ts @@ -0,0 +1,21 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +import path from 'path'; + +const enviroment = process.env.NODE_ENV || 'development'; +const isProduction = enviroment === 'production'; +const isDevelopment = enviroment === 'development'; + +const enviromentConfig = require(path.resolve( + __dirname, + 'environments', + enviroment + '.ts' +)); + +export const config = Object.assign( + { + enviroment, + isProduction, + isDevelopment, + }, + enviromentConfig +); diff --git a/server/src/contexts/catalog/application/ListProductsUseCase.ts b/server/src/contexts/catalog/application/ListProductsUseCase.ts new file mode 100644 index 0000000..0f0a7e8 --- /dev/null +++ b/server/src/contexts/catalog/application/ListProductsUseCase.ts @@ -0,0 +1,79 @@ +import { + IUseCase, + IUseCaseError, + UseCaseError, + handleUseCaseError, +} from "@/contexts/common/application/useCases"; +import { IRepositoryManager } from "@/contexts/common/domain"; +import { + Collection, + ICollection, + IQueryCriteria, + Result, +} from "@shared/contexts"; + +import { IInfrastructureError } from "@/contexts/common/infrastructure"; +import { IFirebirdAdapter } from "@/contexts/common/infrastructure/firebird"; +import { ICatalogRepository, Product } from "../domain"; + +export interface IListProductsParams { + queryCriteria: IQueryCriteria; +} + +export type ListProductsResult = + | Result // Misc errors (value objects) + | Result, never>; // Success! + +export class ListProductsUseCase + implements IUseCase> +{ + private _adapter: IFirebirdAdapter; + private _repositoryManager: IRepositoryManager; + + constructor(props: { + adapter: IFirebirdAdapter; + repositoryManager: IRepositoryManager; + }) { + this._adapter = props.adapter; + this._repositoryManager = props.repositoryManager; + } + + private getRepositoryByName(name: string) { + return this._repositoryManager.getRepository(name); + } + + async execute( + params: Partial + ): Promise { + const { queryCriteria } = params; + + return this.findProducts(queryCriteria); + } + + private async findProducts(queryCriteria) { + const transaction = this._adapter.startTransaction(); + const productRepoBuilder = + this.getRepositoryByName("Product"); + + let products: ICollection = new Collection(); + + try { + await transaction.complete(async (t) => { + products = await productRepoBuilder({ transaction: t }).findAll( + queryCriteria + ); + }); + + return Result.ok(products); + } catch (error: unknown) { + const _error = error as IInfrastructureError; + return Result.fail( + handleUseCaseError( + UseCaseError.REPOSITORY_ERROR, + "Error al listar el catálogo", + _error + ) + ); + } + } +} diff --git a/server/src/contexts/catalog/application/index.ts b/server/src/contexts/catalog/application/index.ts new file mode 100644 index 0000000..9013b30 --- /dev/null +++ b/server/src/contexts/catalog/application/index.ts @@ -0,0 +1 @@ +export * from "./ListProductsUseCase"; diff --git a/server/src/contexts/catalog/domain/entities/Product.ts b/server/src/contexts/catalog/domain/entities/Product.ts new file mode 100644 index 0000000..3b12038 --- /dev/null +++ b/server/src/contexts/catalog/domain/entities/Product.ts @@ -0,0 +1,75 @@ +import { + AggregateRoot, + Description, + IDomainError, + MoneyValueObject, + Result, + StringValueObject, + UniqueID, + ValueObject, +} from "@shared/contexts"; + +export interface IProductProps { + reference: StringValueObject; + family: StringValueObject; + subfamily: StringValueObject; + description: StringValueObject; + points: ValueObject; + pvp: MoneyValueObject; +} + +export interface IProduct { + id: UniqueID; + reference: Description; + family: Description; + subfamily: Description; + description: Description; + points: ValueObject; + pvp: MoneyValueObject; +} + +export class Product extends AggregateRoot implements IProduct { + public static create( + props: IProductProps, + id?: UniqueID + ): Result { + //const isNew = !!id === false; + + // Se hace en el constructor de la Entidad + /* if (isNew) { + id = UniqueEntityID.create(); + }*/ + + const product = new Product(props, id); + + return Result.ok(product); + } + + private constructor(props: IProductProps, id?: UniqueID) { + super(props, id); + } + + get reference(): Description { + return this.props.reference; + } + + get family(): Description { + return this.props.family; + } + + get subfamily(): Description { + return this.props.subfamily; + } + + get description(): Description { + return this.props.description; + } + + get points(): ValueObject { + return this.props.points; + } + + get pvp(): MoneyValueObject { + return this.props.pvp; + } +} diff --git a/server/src/contexts/catalog/domain/entities/index.ts b/server/src/contexts/catalog/domain/entities/index.ts new file mode 100644 index 0000000..b405045 --- /dev/null +++ b/server/src/contexts/catalog/domain/entities/index.ts @@ -0,0 +1 @@ +export * from "./Product"; diff --git a/server/src/contexts/catalog/domain/index.ts b/server/src/contexts/catalog/domain/index.ts new file mode 100644 index 0000000..6347a2b --- /dev/null +++ b/server/src/contexts/catalog/domain/index.ts @@ -0,0 +1,2 @@ +export * from "./entities"; +export * from "./repository"; diff --git a/server/src/contexts/catalog/domain/repository/CatalogRepository.interface.ts b/server/src/contexts/catalog/domain/repository/CatalogRepository.interface.ts new file mode 100644 index 0000000..4b57844 --- /dev/null +++ b/server/src/contexts/catalog/domain/repository/CatalogRepository.interface.ts @@ -0,0 +1,7 @@ +import { IRepository } from "@/contexts/common/domain"; +import { ICollection, IQueryCriteria } from "@shared/contexts"; +import { Product } from "../entities"; + +export interface ICatalogRepository extends IRepository { + findAll(queryCriteria?: IQueryCriteria): Promise>; +} diff --git a/server/src/contexts/catalog/domain/repository/index.ts b/server/src/contexts/catalog/domain/repository/index.ts new file mode 100644 index 0000000..0d459c1 --- /dev/null +++ b/server/src/contexts/catalog/domain/repository/index.ts @@ -0,0 +1 @@ +export * from "./CatalogRepository.interface"; diff --git a/server/src/contexts/catalog/infrastructure/Catalog.repository.ts b/server/src/contexts/catalog/infrastructure/Catalog.repository.ts new file mode 100644 index 0000000..c9f5afd --- /dev/null +++ b/server/src/contexts/catalog/infrastructure/Catalog.repository.ts @@ -0,0 +1,59 @@ +import { + FirebirdRepository, + IFirebirdAdapter, +} from "@/contexts/common/infrastructure/firebird"; +import { ICollection, IQueryCriteria, UniqueID } from "@shared/contexts"; +import Firebird from "node-firebird"; +import { Product } from "../domain/entities"; +import { ICatalogRepository } from "../domain/repository/CatalogRepository.interface"; +import { Product_Model } from "./firebird"; +import { IProductMapper } from "./mappers/product.mapper"; + +export type QueryParams = { + pagination: Record; + filters: Record; +}; + +export class CatalogRepository + extends FirebirdRepository + implements ICatalogRepository +{ + protected mapper: IProductMapper; + + public constructor(props: { + mapper: IProductMapper; + adapter: IFirebirdAdapter; + transaction: Firebird.Transaction; + }) { + const { adapter, mapper, transaction } = props; + super({ adapter, transaction }); + this.mapper = mapper; + } + + public async getById(id: UniqueID): Promise { + const rawProduct: Product_Model = await this.adapter.execute( + "SELECT * FROM TABLE WHERE ID=?", + [id.toString()] + ); + + return this.mapper.mapToDomain(rawProduct); + } + + public async findAll( + queryCriteria?: IQueryCriteria + ): Promise> { + let rows: Product_Model[] = []; + const count = await this.adapter.execute( + "SELECT count(*) FROM TABLE", + [] + ); + if (count) { + rows = await this.adapter.execute( + "SELECT * FROM TABLE", + [] + ); + } + + return this.mapper.mapArrayAndCountToDomain(rows, count); + } +} diff --git a/server/src/contexts/catalog/infrastructure/express/catalogRoutes.ts b/server/src/contexts/catalog/infrastructure/express/catalogRoutes.ts new file mode 100644 index 0000000..79b0e87 --- /dev/null +++ b/server/src/contexts/catalog/infrastructure/express/catalogRoutes.ts @@ -0,0 +1,40 @@ +import express, { NextFunction, Request, Response, Router } from "express"; + +import { RepositoryManager } from "@/contexts/common/domain"; +import { createSequelizeAdapter } from "@/contexts/common/infrastructure/sequelize"; +import { createListProductsController } from "./controllers/listProducts"; + +const catalogRouter: Router = express.Router({ mergeParams: true }); + +const logMiddleware = (req, res, next) => { + console.log( + `[${new Date().toLocaleTimeString()}] Incoming request to ${req.path}` + ); + next(); +}; + +catalogRouter.use(logMiddleware); + +const contextMiddleware = (req: Request, res: Response, next: NextFunction) => { + res.locals["context"] = { + adapter: createSequelizeAdapter(), + repositoryManager: RepositoryManager.getInstance(), + services: {}, + }; + + return next(); +}; + +catalogRouter.use(contextMiddleware); + +catalogRouter.get("/", (req: Request, res: Response, next: NextFunction) => + createListProductsController(res.locals["context"]).execute(req, res, next) +); + +/*catalogRouter.get( + "/:articleId", + (req: Request, res: Response, next: NextFunction) => + createGetCustomerController(res.locals["context"]).execute(req, res, next) +);*/ + +export { catalogRouter }; diff --git a/server/src/contexts/catalog/infrastructure/express/controllers/listProducts/ListProductsController.ts b/server/src/contexts/catalog/infrastructure/express/controllers/listProducts/ListProductsController.ts new file mode 100644 index 0000000..fc8901b --- /dev/null +++ b/server/src/contexts/catalog/infrastructure/express/controllers/listProducts/ListProductsController.ts @@ -0,0 +1,88 @@ +import Joi from "joi"; + +import { + ListProductsResult, + ListProductsUseCase, +} from "@/contexts/catalog/application"; +import { Product } from "@/contexts/catalog/domain"; +import { QueryCriteriaService } from "@/contexts/common/application/services"; +import { IServerError } from "@/contexts/common/domain/errors"; +import { ExpressController } from "@/contexts/common/infrastructure/express"; +import { + ICollection, + IListProducts_Response_DTO, + IListResponse_DTO, + IQueryCriteria, + Result, + RuleValidator, +} from "@shared/contexts"; +import { ICatalogContext } from "../../.."; +import { IListProductsPresenter } from "./presenter"; + +export class ListProductsController extends ExpressController { + private useCase: ListProductsUseCase; + private presenter: IListProductsPresenter; + private context: ICatalogContext; + + constructor( + props: { + useCase: ListProductsUseCase; + presenter: IListProductsPresenter; + }, + context: ICatalogContext + ) { + super(); + + const { useCase, presenter } = props; + this.useCase = useCase; + this.presenter = presenter; + this.context = context; + } + + protected validateQuery(query): Result { + const schema = Joi.object({ + page: Joi.number().optional(), + limit: Joi.number().optional(), + $sort_by: Joi.string().optional(), + $filters: Joi.string().optional(), + q: Joi.string().optional(), + }).optional(); + + return RuleValidator.validate(schema, query); + } + + async executeImpl() { + const queryOrError = this.validateQuery(this.req.query); + if (queryOrError.isFailure) { + return this.clientError(queryOrError.error.message); + } + + const queryParams = queryOrError.object; + + try { + const queryCriteria: IQueryCriteria = + QueryCriteriaService.parse(queryParams); + + console.log(queryCriteria); + + const result: ListProductsResult = await this.useCase.execute({ + queryCriteria, + }); + + if (result.isFailure) { + return this.clientError(result.error.message); + } + + const customers = >result.object; + + return this.ok>( + this.presenter.mapArray(customers, this.context, { + page: queryCriteria.pagination.offset, + limit: queryCriteria.pagination.limit, + }) + ); + } catch (e: unknown) { + return this.fail(e as IServerError); + } + } +} diff --git a/server/src/contexts/catalog/infrastructure/express/controllers/listProducts/index.ts b/server/src/contexts/catalog/infrastructure/express/controllers/listProducts/index.ts new file mode 100644 index 0000000..d2864db --- /dev/null +++ b/server/src/contexts/catalog/infrastructure/express/controllers/listProducts/index.ts @@ -0,0 +1,34 @@ +import { ListProductsUseCase } from "@/contexts/catalog/application"; +import { ICatalogContext } from "../../.."; +import { CatalogRepository } from "../../../Catalog.repository"; +import { createProductMapper } from "../../../mappers/product.mapper"; +import { ListProductsController } from "./ListProductsController"; +import { listProductsPresenter } from "./presenter"; + +export const createListProductsController = (context: ICatalogContext) => { + const adapter = context.adapter; + const repoManager = context.repositoryManager; + + repoManager.registerRepository( + "Product", + (params = { transaction: null }) => { + const { transaction } = params; + + return new CatalogRepository({ + transaction, + adapter, + mapper: createProductMapper(context), + }); + } + ); + + const listProductsUseCase = new ListProductsUseCase(context); + + return new ListProductsController( + { + useCase: listProductsUseCase, + presenter: listProductsPresenter, + }, + context + ); +}; diff --git a/server/src/contexts/catalog/infrastructure/express/controllers/listProducts/presenter/ListProducts.presenter.ts b/server/src/contexts/catalog/infrastructure/express/controllers/listProducts/presenter/ListProducts.presenter.ts new file mode 100644 index 0000000..dd6447f --- /dev/null +++ b/server/src/contexts/catalog/infrastructure/express/controllers/listProducts/presenter/ListProducts.presenter.ts @@ -0,0 +1,71 @@ +import { Product } from "@/contexts/catalog/domain"; +import { ICatalogContext } from "@/contexts/catalog/infrastructure"; +import { + ICollection, + IListProducts_Response_DTO, + IListResponse_DTO, +} from "@shared/contexts"; + +export interface IListProductsPresenter { + map: ( + product: Product, + context: ICatalogContext + ) => IListProducts_Response_DTO; + + mapArray: ( + products: ICollection, + context: ICatalogContext, + params: { + page: number; + limit: number; + } + ) => IListResponse_DTO; +} + +export const listProductsPresenter: IListProductsPresenter = { + map: ( + product: Product, + context: ICatalogContext + ): IListProducts_Response_DTO => { + console.time("listProductsPresenter.map"); + + const result: IListProducts_Response_DTO = { + id: product.id.toString(), + reference: product.reference.toString(), + }; + + console.timeEnd("listProductsPresenter.map"); + + return result; + }, + + mapArray: ( + products: ICollection, + context: ICatalogContext, + params: { + page: number; + limit: number; + } + ): IListResponse_DTO => { + console.time("listProductsPresenter.mapArray"); + + const { page, limit } = params; + + const totalCount = products.totalCount ?? 0; + const items = products.items.map((product: Product) => + listProductsPresenter.map(product, context) + ); + + const result = { + page, + per_page: limit, + total_pages: Math.ceil(totalCount / limit), + total_items: totalCount, + items, + }; + + console.timeEnd("listProductsPresenter.mapArray"); + + return result; + }, +}; diff --git a/server/src/contexts/catalog/infrastructure/express/controllers/listProducts/presenter/index.ts b/server/src/contexts/catalog/infrastructure/express/controllers/listProducts/presenter/index.ts new file mode 100644 index 0000000..1fef814 --- /dev/null +++ b/server/src/contexts/catalog/infrastructure/express/controllers/listProducts/presenter/index.ts @@ -0,0 +1 @@ +export * from "./ListProducts.presenter"; diff --git a/server/src/contexts/catalog/infrastructure/firebird/firebird.model.ts b/server/src/contexts/catalog/infrastructure/firebird/firebird.model.ts new file mode 100644 index 0000000..d73a8f8 --- /dev/null +++ b/server/src/contexts/catalog/infrastructure/firebird/firebird.model.ts @@ -0,0 +1 @@ +export type FirebirdModel = Record; diff --git a/server/src/contexts/catalog/infrastructure/firebird/index.ts b/server/src/contexts/catalog/infrastructure/firebird/index.ts new file mode 100644 index 0000000..5768017 --- /dev/null +++ b/server/src/contexts/catalog/infrastructure/firebird/index.ts @@ -0,0 +1 @@ +export * from "./product.model"; diff --git a/server/src/contexts/catalog/infrastructure/firebird/product.model.ts b/server/src/contexts/catalog/infrastructure/firebird/product.model.ts new file mode 100644 index 0000000..dd638ee --- /dev/null +++ b/server/src/contexts/catalog/infrastructure/firebird/product.model.ts @@ -0,0 +1,11 @@ +import { FirebirdModel } from "./firebird.model"; + +export type Product_Model = FirebirdModel & { + id: string; + reference: string; + family: string; + subfamiliy: string; + description: string; + points: number; + pvp: number; +}; diff --git a/server/src/contexts/catalog/infrastructure/index.ts b/server/src/contexts/catalog/infrastructure/index.ts new file mode 100644 index 0000000..c361d65 --- /dev/null +++ b/server/src/contexts/catalog/infrastructure/index.ts @@ -0,0 +1,9 @@ +import { IApplicationService } from "@/contexts/common/application/services/ApplicationService"; +import { IRepositoryManager } from "@/contexts/common/domain"; +import { IFirebirdAdapter } from "@/contexts/common/infrastructure/firebird"; + +export interface ICatalogContext { + adapter: IFirebirdAdapter; + repositoryManager: IRepositoryManager; + services: IApplicationService; +} diff --git a/server/src/contexts/catalog/infrastructure/mappers/index.ts b/server/src/contexts/catalog/infrastructure/mappers/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/server/src/contexts/catalog/infrastructure/mappers/product.mapper.ts b/server/src/contexts/catalog/infrastructure/mappers/product.mapper.ts new file mode 100644 index 0000000..1899076 --- /dev/null +++ b/server/src/contexts/catalog/infrastructure/mappers/product.mapper.ts @@ -0,0 +1,47 @@ +import { + FirebirdMapper, + IFirebirdMapper, +} from "@/contexts/common/infrastructure/mappers/FirebirdMapper"; +import { Description, MoneyValue, UniqueID } from "@shared/contexts"; +import { ICatalogContext } from ".."; +import { IProductProps, Product } from "../../domain/entities"; +import { Product_Model } from "../firebird"; + +export interface IProductMapper + extends IFirebirdMapper {} + +class ProductMapper + extends FirebirdMapper + implements IProductMapper +{ + public constructor(props: { context: ICatalogContext }) { + super(props); + } + + protected toDomainMappingImpl(source: Product_Model, params: any): Product { + const props: IProductProps = { + reference: this.mapsValue(source, "reference", Description.create), + family: this.mapsValue(source, "family", Description.create), + subfamily: this.mapsValue(source, "subfamily", Description.create), + description: this.mapsValue(source, "description", Description.create), + points: this.mapsValue(source, "points", Description.create), + pvp: this.mapsValue(source, "pvp", (value: any) => + MoneyValue.create({ amount: value }) + ), + }; + + const id = this.mapsValue(source, "id", UniqueID.create); + const productOrError = Product.create(props, id); + + if (productOrError.isFailure) { + throw productOrError.error; + } + + return productOrError.object; + } +} + +export const createProductMapper = (context: ICatalogContext): IProductMapper => + new ProductMapper({ + context, + }); diff --git a/server/src/contexts/common/application/index.ts b/server/src/contexts/common/application/index.ts new file mode 100644 index 0000000..19428b9 --- /dev/null +++ b/server/src/contexts/common/application/index.ts @@ -0,0 +1,3 @@ +export * from "./services"; +export * from "./useCases"; + diff --git a/server/src/contexts/common/application/services/ApplicationService.ts b/server/src/contexts/common/application/services/ApplicationService.ts new file mode 100644 index 0000000..ff3aed9 --- /dev/null +++ b/server/src/contexts/common/application/services/ApplicationService.ts @@ -0,0 +1,4 @@ +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface IApplicationService {} + +export abstract class ApplicationService implements IApplicationService {} diff --git a/server/src/contexts/common/application/services/ApplicationServiceError.ts b/server/src/contexts/common/application/services/ApplicationServiceError.ts new file mode 100644 index 0000000..060b9ba --- /dev/null +++ b/server/src/contexts/common/application/services/ApplicationServiceError.ts @@ -0,0 +1,23 @@ +import { IServerError, ServerError } from "../../domain/errors"; + +export interface IApplicationServiceError extends IServerError {} + +export class ApplicationServiceError + extends ServerError + implements IApplicationServiceError +{ + public static readonly INVALID_REQUEST_PARAM = "INVALID_REQUEST_PARAM"; + public static readonly INVALID_INPUT_DATA = "INVALID_INPUT_DATA"; + public static readonly UNEXCEPTED_ERROR = "UNEXCEPTED_ERROR"; + public static readonly REPOSITORY_ERROR = "REPOSITORY_ERROR"; + public static readonly NOT_FOUND_ERROR = "NOT_FOUND_ERROR"; + public static readonly RESOURCE_ALREADY_EXITS = "RESOURCE_ALREADY_EXITS"; + + public static create( + code: string, + message: string, + details?: Record + ): ApplicationServiceError { + return new ApplicationServiceError(code, message, details); + } +} diff --git a/server/src/contexts/common/application/services/QueryCriteriaService.ts b/server/src/contexts/common/application/services/QueryCriteriaService.ts new file mode 100644 index 0000000..0e2f10b --- /dev/null +++ b/server/src/contexts/common/application/services/QueryCriteriaService.ts @@ -0,0 +1,98 @@ +import { + FilterCriteria, + IQueryCriteria, + OffsetPaging, + OrderCriteria, + QueryCriteria, + QuickSearchCriteria, +} from "@shared/contexts"; +import { ApplicationService } from "./ApplicationService"; + +export interface IQueryCriteriaServiceProps { + page: string; + limit: string; + + sort_by: string; + fields: string; + filters: string; + quick_search: string; +} + +export class QueryCriteriaService extends ApplicationService { + public static parse( + params: Partial + ): IQueryCriteria { + const { + page = undefined, + limit = undefined, + sort_by = undefined, + quick_search = undefined, + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + fields = null, // fields / select + //scopes: + } = params || {}; + + const filters = params["$filters"]; + + // Pagination + const _pagination = QueryCriteriaService.parsePagination(page, limit); + + const _filters: FilterCriteria = QueryCriteriaService.parseFilter(filters); + + const _order: OrderCriteria = QueryCriteriaService.parseOrder(sort_by); + + const _quickSearch: QuickSearchCriteria = + QueryCriteriaService.parseQuickSearch(quick_search); + + return QueryCriteria.create({ + pagination: _pagination, + quickSearch: _quickSearch, + filters: _filters, + order: _order, + }).object; + } + + protected static parsePagination(page?: string, limit?: string) { + if (!page && !limit) { + return OffsetPaging.createWithDefaultValues().object; + } + + const paginationOrError = OffsetPaging.create({ + offset: String(page), + limit: String(limit), + }); + if (paginationOrError.isFailure) { + throw paginationOrError.error; + } + + return paginationOrError.object; + } + + protected static parseFilter(filter?: string): FilterCriteria { + const filterOrError = FilterCriteria.create(filter); + if (filterOrError.isFailure) { + throw filterOrError.error; + } + + return filterOrError.object; + } + + protected static parseOrder(sort_by?: string): OrderCriteria { + const orderOrError = OrderCriteria.create(sort_by); + if (orderOrError.isFailure) { + throw orderOrError.error; + } + + return orderOrError.object; + } + + protected static parseQuickSearch(quickSearch?: string): QuickSearchCriteria { + const quickSearchOrError = QuickSearchCriteria.create(quickSearch); + if (quickSearchOrError.isFailure) { + throw quickSearchOrError.error; + } + + return quickSearchOrError.object; + } +} diff --git a/server/src/contexts/common/application/services/index.ts b/server/src/contexts/common/application/services/index.ts new file mode 100644 index 0000000..d0508d6 --- /dev/null +++ b/server/src/contexts/common/application/services/index.ts @@ -0,0 +1 @@ +export * from './QueryCriteriaService'; diff --git a/server/src/contexts/common/application/useCases/UseCase.interface.ts b/server/src/contexts/common/application/useCases/UseCase.interface.ts new file mode 100644 index 0000000..0470661 --- /dev/null +++ b/server/src/contexts/common/application/useCases/UseCase.interface.ts @@ -0,0 +1,6 @@ +export interface IUseCaseRequest {} +export interface IUseCaseResponse {} + +export interface IUseCase { + execute(useCaseRequest: IUseCaseRequest): IUseCaseResponse; +} diff --git a/server/src/contexts/common/application/useCases/UseCaseError.ts b/server/src/contexts/common/application/useCases/UseCaseError.ts new file mode 100755 index 0000000..cae8bce --- /dev/null +++ b/server/src/contexts/common/application/useCases/UseCaseError.ts @@ -0,0 +1,81 @@ +import { IServerError, ServerError } from "../../domain/errors"; + +export interface IUseCaseError extends IServerError {} + +export class UseCaseError extends ServerError implements IUseCaseError { + public static readonly INVALID_REQUEST_PARAM = "INVALID_REQUEST_PARAM"; + public static readonly INVALID_INPUT_DATA = "INVALID_INPUT_DATA"; + public static readonly UNEXCEPTED_ERROR = "UNEXCEPTED_ERROR"; + public static readonly REPOSITORY_ERROR = "REPOSITORY_ERROR"; + public static readonly NOT_FOUND_ERROR = "NOT_FOUND_ERROR"; + public static readonly RESOURCE_ALREADY_EXITS = "RESOURCE_ALREADY_EXITS"; + + public static create( + code: string, + message: string, + details?: Record + ): UseCaseError { + return new UseCaseError(code, message, details); + } +} + +export function handleUseCaseError( + code: string, + message: string, + payload?: Record +): IUseCaseError { + return UseCaseError.create(code, message, payload); +} + +/*export function handleNotFoundError( + message: string, + validationError: Error, + details?: Record +): Result { + return handleUseCaseError( + UseCaseError.NOT_FOUND_ERROR, + message, + validationError, + details + ); +} + +export function handleInvalidInputDataError( + message: string, + validationError: Error, + details?: Record +): Result { + return handleUseCaseError( + UseCaseError.INVALID_INPUT_DATA, + message, + validationError, + details + ); +} + +export function handleResourceAlreadyExitsError( + message: string, + validationError: Error, + details?: Record +): Result { + return handleUseCaseError( + UseCaseError.RESOURCE_ALREADY_EXITS, + message, + validationError, + details + ); +} + +export function handleRepositoryError( + message: string, + repositoryError: Error, + details?: Record +): Result { + return handleUseCaseError( + UseCaseError.REPOSITORY_ERROR, + message, + repositoryError, + details + ); +} +*/ diff --git a/server/src/contexts/common/application/useCases/index.ts b/server/src/contexts/common/application/useCases/index.ts new file mode 100644 index 0000000..9e42d64 --- /dev/null +++ b/server/src/contexts/common/application/useCases/index.ts @@ -0,0 +1,2 @@ +export * from "./UseCase.interface"; +export * from "./UseCaseError"; diff --git a/server/src/contexts/common/domain/Mapper.interface.ts b/server/src/contexts/common/domain/Mapper.interface.ts new file mode 100644 index 0000000..ffa6454 --- /dev/null +++ b/server/src/contexts/common/domain/Mapper.interface.ts @@ -0,0 +1,24 @@ +import { ICollection, IListResponse_DTO } from "@shared/contexts"; + +interface IGenericMapper { + map?: (source: S) => D; + mapArray?: (sourceArray: M) => N; +} + +export interface IMapper + extends IGenericMapper, ICollection> {} + +export interface IDTOMapper { + map: (source: S) => D; + mapArray: ( + sourceArray: ICollection, + params: { + page: number; + limit: number; + }, + ) => IListResponse_DTO; +} + +export interface IDomainMapper { + map: (source: S) => D; +} diff --git a/server/src/contexts/common/domain/Specification.ts b/server/src/contexts/common/domain/Specification.ts new file mode 100644 index 0000000..fab7f14 --- /dev/null +++ b/server/src/contexts/common/domain/Specification.ts @@ -0,0 +1,163 @@ +interface IBaseSpecification { + isSatisfiedBy(candidate: T): boolean; +} + +export interface ICompositeSpecification extends IBaseSpecification { + and(other: ICompositeSpecification): ICompositeSpecification; + andNot(other: ICompositeSpecification): ICompositeSpecification; + or(other: ICompositeSpecification): ICompositeSpecification; + orNot(other: ICompositeSpecification): ICompositeSpecification; + not(): ICompositeSpecification; +} + +export abstract class CompositeSpecification + implements ICompositeSpecification +{ + abstract isSatisfiedBy(candidate: T): boolean; + + public and(other: ICompositeSpecification): ICompositeSpecification { + return new AndSpecification(this, other); + } + + public andNot(other: ICompositeSpecification): ICompositeSpecification { + return new AndNotSpecification(this, other); + } + + public or(other: ICompositeSpecification): ICompositeSpecification { + return new OrSpecification(this, other); + } + + public orNot(other: ICompositeSpecification): ICompositeSpecification { + return new OrNotSpecification(this, other); + } + + public not(): ICompositeSpecification { + return new NotSpecification(this); + } +} + +class AndSpecification extends CompositeSpecification { + public left: ICompositeSpecification; + public right: ICompositeSpecification; + + constructor( + left: ICompositeSpecification, + right: ICompositeSpecification, + ) { + super(); + this.left = left; + this.right = right; + } + + public isSatisfiedBy(candidate: T): boolean { + return ( + this.left.isSatisfiedBy(candidate) && this.right.isSatisfiedBy(candidate) + ); + } + + toString(): string { + return `(${this.left.toString()} and ${this.right.toString()})`; + } +} + +class AndNotSpecification extends AndSpecification { + isSatisfiedBy(candidate: T): boolean { + return super.isSatisfiedBy(candidate) !== true; + } + + toString(): string { + return `not ${super.toString()}`; + } +} + +class OrSpecification extends CompositeSpecification { + public left: ICompositeSpecification; + public right: ICompositeSpecification; + + constructor( + left: ICompositeSpecification, + right: ICompositeSpecification, + ) { + super(); + this.left = left; + this.right = right; + } + + public isSatisfiedBy(candidate: T): boolean { + return ( + this.left.isSatisfiedBy(candidate) || this.right.isSatisfiedBy(candidate) + ); + } + + toString(): string { + return `(${this.left.toString()} or ${this.right.toString()})`; + } +} + +class OrNotSpecification extends OrSpecification { + isSatisfiedBy(candidate: T): boolean { + return super.isSatisfiedBy(candidate) !== true; + } + + toString(): string { + return `not ${super.toString()}`; + } +} + +export class NotSpecification extends CompositeSpecification { + public spec: ICompositeSpecification; + + constructor(spec: ICompositeSpecification) { + super(); + this.spec = spec; + } + + public isSatisfiedBy(candidate: T): boolean { + return !this.spec.isSatisfiedBy(candidate); + } + + toString(): string { + return `(not ${this.spec.toString()})`; + } +} + +export class RangeSpecification extends CompositeSpecification { + private a: T; + private b: T; + + constructor(a: T, b: T) { + super(); + this.a = a; + this.b = b; + } + + isSatisfiedBy(candidate: T): boolean { + return candidate >= this.a && candidate <= this.b; + } + + toString(): string { + return `range (${this.a}, ${this.b})`; + } +} + +/* +export class OrSpecification2 { + private first: ISpecification; + private second: ISpecification; + + public OrSpecification(first: ISpecification, second: ISpecification) { + this.first = first; + this.second = second; + } + + public IsSatisfiedBy(entity: T): IResult { + const result: ResultCollection = new ResultCollection() + .add(this.first.IsSatisfiedBy(entity)) + .add(this.second.IsSatisfiedBy(entity)); + + return result.hasSomeFaultyResult() + ? result.getFirstFaultyResult() + : Result.ok(true); + } +} +*/ diff --git a/server/src/contexts/common/domain/errors/ServerError.ts b/server/src/contexts/common/domain/errors/ServerError.ts new file mode 100644 index 0000000..54997c9 --- /dev/null +++ b/server/src/contexts/common/domain/errors/ServerError.ts @@ -0,0 +1,68 @@ +import { GenericError, IGenericError } from "@shared/contexts"; +import { BaseError } from "sequelize"; + +export interface IServerError extends IGenericError {} + +export class ServerError extends GenericError implements IServerError {} + +export class InternalServerError extends ServerError { + public static create(code: string, message: string) { + return new InternalServerError(code, message); + } +} + +export class RepositoryError extends ServerError { + public static isRepositoryError = (error: unknown) => { + return error instanceof BaseError; + }; + + public static create(error: unknown): ServerError { + const _error = error as BaseError; + + return new GenericError("", _error.message, { + name: _error.name, + }); + } +} + +export class NotFoundError extends ServerError { + public static create(id: string, resource: string): NotFoundError { + return new NotFoundError(`${resource} not found`, id, resource); + } + + public readonly id: string; + public readonly resource: string; + + constructor(message: string, id: string, resource: string) { + super(message, `${resource} with id '${id}' not found`); + this.id = id; + this.resource = resource; + } +} + +export class RequiredFieldMissingError extends ServerError { + public static field( + fieldName: string, + message?: string + ): RequiredFieldMissingError { + return new RequiredFieldMissingError( + "", + `Required field '${fieldName}' is missing`, + message + ); + } +} + +export class FieldValueError extends ServerError { + public static field( + fieldName: string, + value: any, + message?: string + ): FieldValueError { + return new RequiredFieldMissingError( + "", + `Incorrect value '${String(value)}' for field '${fieldName}'`, + message + ); + } +} diff --git a/server/src/contexts/common/domain/errors/index.ts b/server/src/contexts/common/domain/errors/index.ts new file mode 100644 index 0000000..a4b0c31 --- /dev/null +++ b/server/src/contexts/common/domain/errors/index.ts @@ -0,0 +1 @@ +export * from "./ServerError"; diff --git a/server/src/contexts/common/domain/index.ts b/server/src/contexts/common/domain/index.ts new file mode 100644 index 0000000..42289fe --- /dev/null +++ b/server/src/contexts/common/domain/index.ts @@ -0,0 +1,3 @@ +export * from "./Mapper.interface"; +export * from "./Specification"; +export * from "./repositories"; diff --git a/server/src/contexts/common/domain/repositories/Adapter.interface.ts b/server/src/contexts/common/domain/repositories/Adapter.interface.ts new file mode 100644 index 0000000..17d10be --- /dev/null +++ b/server/src/contexts/common/domain/repositories/Adapter.interface.ts @@ -0,0 +1,5 @@ +import { TBusinessTransaction } from "./BusinessTransaction.interface"; + +export interface IAdapter { + startTransaction: () => TBusinessTransaction; +} diff --git a/server/src/contexts/common/domain/repositories/BusinessTransaction.interface.ts b/server/src/contexts/common/domain/repositories/BusinessTransaction.interface.ts new file mode 100644 index 0000000..d7dcb75 --- /dev/null +++ b/server/src/contexts/common/domain/repositories/BusinessTransaction.interface.ts @@ -0,0 +1,5 @@ +type TUnitOfWork = { + start(): unknown; +}; + +export type TBusinessTransaction = TUnitOfWork; diff --git a/server/src/contexts/common/domain/repositories/Repository.interface.ts b/server/src/contexts/common/domain/repositories/Repository.interface.ts new file mode 100644 index 0000000..ac9f05d --- /dev/null +++ b/server/src/contexts/common/domain/repositories/Repository.interface.ts @@ -0,0 +1,2 @@ +/* eslint-disable no-unused-vars */ +export interface IRepository {} diff --git a/server/src/contexts/common/domain/repositories/RepositoryBuilder.ts b/server/src/contexts/common/domain/repositories/RepositoryBuilder.ts new file mode 100644 index 0000000..17b5f0e --- /dev/null +++ b/server/src/contexts/common/domain/repositories/RepositoryBuilder.ts @@ -0,0 +1 @@ +export type RepositoryBuilder = (params?: any) => T; diff --git a/server/src/contexts/common/domain/repositories/RepositoryManager.ts b/server/src/contexts/common/domain/repositories/RepositoryManager.ts new file mode 100644 index 0000000..1b8fda7 --- /dev/null +++ b/server/src/contexts/common/domain/repositories/RepositoryManager.ts @@ -0,0 +1,49 @@ +import { InfrastructureError } from "../../infrastructure/InfrastructureError"; +import { RepositoryBuilder } from "./RepositoryBuilder"; + +export interface IRepositoryManager { + getRepository: (name: string) => RepositoryBuilder; + registerRepository: ( + name: string, + repository: RepositoryBuilder, + ) => void; +} + +export class RepositoryManager implements IRepositoryManager { + private static instance: RepositoryManager | null = null; + public static getInstance(): RepositoryManager { + if (!RepositoryManager.instance) { + RepositoryManager.instance = new RepositoryManager(); + } + + return RepositoryManager.instance; + } + + private repositories: Map>; + + private constructor() { + this.repositories = new Map(); + } + + public registerRepository( + name: string, + repository: RepositoryBuilder, + ): void { + if (!this.repositories.has(name)) { + this.repositories.set(name, repository); + } + } + + public getRepository(name: string): RepositoryBuilder { + const repository = this.repositories.get(name) as RepositoryBuilder; + + if (!repository) { + throw InfrastructureError.create( + InfrastructureError.RESOURCE_NOT_FOUND_ERROR, + `Repository "${name}" not found.`, + ); + } + + return repository; + } +} diff --git a/server/src/contexts/common/domain/repositories/RepositoryQueryBuilder.interface.ts b/server/src/contexts/common/domain/repositories/RepositoryQueryBuilder.interface.ts new file mode 100644 index 0000000..e66fff2 --- /dev/null +++ b/server/src/contexts/common/domain/repositories/RepositoryQueryBuilder.interface.ts @@ -0,0 +1,5 @@ +export interface IRepositoryQueryOptions { + query: any; +} + +export interface IRepositoryQueryBuilder {} diff --git a/server/src/contexts/common/domain/repositories/index.ts b/server/src/contexts/common/domain/repositories/index.ts new file mode 100644 index 0000000..f1e3f1e --- /dev/null +++ b/server/src/contexts/common/domain/repositories/index.ts @@ -0,0 +1,6 @@ +export * from "./Adapter.interface"; +export * from "./BusinessTransaction.interface"; +export * from "./Repository.interface"; +export * from "./RepositoryBuilder"; +export * from "./RepositoryManager"; +export * from "./RepositoryQueryBuilder.interface"; diff --git a/server/src/contexts/common/domain/services/index.ts b/server/src/contexts/common/domain/services/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/server/src/contexts/common/infrastructure/ContextFactory.ts b/server/src/contexts/common/infrastructure/ContextFactory.ts new file mode 100644 index 0000000..0830b61 --- /dev/null +++ b/server/src/contexts/common/infrastructure/ContextFactory.ts @@ -0,0 +1,36 @@ +import { IRepositoryManager, RepositoryManager } from "../domain"; +import { ISequelizeAdapter, createSequelizeAdapter } from "./sequelize"; + +// ContextFactory.ts +export interface IContext { + adapter: ISequelizeAdapter; + repositoryManager: IRepositoryManager; + services: T; +} + +export class ContextFactory { + private static instances: Map> = new Map(); + + public static getInstance(constructor: new () => T): ContextFactory { + const key = constructor.name; + if (!ContextFactory.instances.has(key)) { + ContextFactory.instances.set(key, new ContextFactory(constructor)); + } + + return ContextFactory.instances.get(key)! as ContextFactory; + } + + private context: IContext; + + private constructor(constructor: new () => T) { + this.context = { + adapter: createSequelizeAdapter(), + repositoryManager: RepositoryManager.getInstance(), + services: new constructor(), + }; + } + + public getContext(): IContext { + return this.context; + } +} diff --git a/server/src/contexts/common/infrastructure/InfrastructureError.ts b/server/src/contexts/common/infrastructure/InfrastructureError.ts new file mode 100755 index 0000000..8ca50a1 --- /dev/null +++ b/server/src/contexts/common/infrastructure/InfrastructureError.ts @@ -0,0 +1,57 @@ +import { ValidationError } from "joi"; +import { UseCaseError } from "../application"; +import { IServerError, ServerError } from "../domain/errors"; + +export interface IInfrastructureError extends IServerError {} + +export class InfrastructureError + extends ServerError + implements IInfrastructureError +{ + public static readonly UNEXCEPTED_ERROR = "UNEXCEPTED_ERROR"; + public static readonly INVALID_INPUT_DATA = "INVALID_INPUT_DATA"; + public static readonly RESOURCE_NOT_READY = "RESOURCE_NOT_READY"; + public static readonly RESOURCE_NOT_FOUND_ERROR = "RESOURCE_NOT_FOUND_ERROR"; + public static readonly RESOURCE_ALREADY_REGISTERED = + "RESOURCE_ALREADY_REGISTERED"; + + public static create( + code: string, + message: string, + payload?: Record + ): InfrastructureError { + return new InfrastructureError(code, message, payload); + } +} + +function _isJoiError(error: Error) { + return error.name === "ValidationError"; +} + +export function handleInfrastructureError( + code: string, + message: string, + error: Error // UseCaseError | ValidationError +): IInfrastructureError { + let payload = {}; + + if (_isJoiError(error)) { + //Joi => error.details + payload = (error).details; + } else { + // UseCaseError + /*const useCaseError = error; + if (useCaseError.payload.path) { + const errorItem = {}; + errorItem[`${useCaseError.payload.path}`] = useCaseError.message; + payload = {+ + errors: [errorItem], + }; + }*/ + payload = (error).payload; + } + + console.log(payload); + + return InfrastructureError.create(code, message, payload); +} diff --git a/server/src/contexts/common/infrastructure/express/ExpressController.ts b/server/src/contexts/common/infrastructure/express/ExpressController.ts new file mode 100644 index 0000000..50b9317 --- /dev/null +++ b/server/src/contexts/common/infrastructure/express/ExpressController.ts @@ -0,0 +1,232 @@ +import * as express from "express"; +import { URL } from "url"; + +import { + IErrorExtra_Response_DTO, + IError_Response_DTO, +} from "@shared/contexts"; +import { UseCaseError } from "../../application"; +import { IServerError } from "../../domain/errors"; +import { InfrastructureError } from "../InfrastructureError"; +import { ProblemDocument, ProblemDocumentExtension } from "./ProblemDocument"; + +export interface IController {} + +export abstract class ExpressController implements IController { + protected req: express.Request; + protected res: express.Response; + protected next: express.NextFunction; + + protected serverURL: string = ""; + protected file: any; + + protected abstract executeImpl(): Promise; + + public execute( + req: express.Request, + res: express.Response, + next: express.NextFunction + ): void { + this.req = req; + this.res = res; + this.next = next; + + this.serverURL = `${ + new URL( + `${this.req.protocol}://${this.req.get("host")}${this.req.originalUrl}` + ).origin + }/api/v1`; + + this.file = this.req && this.req["file"]; // <-- ???? + + this.executeImpl(); + } + + public ok(dto?: T) { + if (dto) { + return this._jsonResponse(200, dto); + } + + return this.res.status(200).send(); + } + + public fail(error: IServerError) { + console.group("ExpressController FAIL RESPONSE ===================="); + console.log(error); + console.trace("Show me"); + console.groupEnd(); + + return this._errorResponse(500, error ? error.toString() : "Fail"); + } + + public created(dto?: T) { + if (dto) { + return this.res.status(201).json(dto).send(); + } + + return this.res.status(201).send(); + } + + public noContent() { + return this.res.status(204).send(); + } + + public download(filepath: string, filename: string, done?: any) { + return this.res.download(filepath, filename, done); + } + + public clientError(message?: string) { + return this._errorResponse(400, message); + } + + public unauthorizedError(message?: string) { + return this._errorResponse(401, message); + } + + public paymentRequiredError(message?: string) { + return this._errorResponse(402, message); + } + + public forbiddenError(message?: string) { + return this._errorResponse(403, message); + } + + public notFoundError(message: string, error?: IServerError) { + return this._errorResponse(404, message, error); + } + + public conflictError(message: string, error?: IServerError) { + return this._errorResponse(409, message, error); + } + + public invalidInputError(message?: string, error?: InfrastructureError) { + return this._errorResponse(422, message, error); + } + + public tooManyError(message: string, error?: Error) { + return this._errorResponse(429, message, error); + } + + public internalServerError(message?: string, error?: IServerError) { + return this._errorResponse(500, message, error); + } + + public todoError(message?: string) { + return this._errorResponse(501, message); + } + + public unavailableError(message?: string) { + return this._errorResponse(503, message); + } + + private _jsonResponse( + statusCode: number, + jsonPayload: any + ): express.Response { + return this.res.status(statusCode).json(jsonPayload).send(); + } + + private _errorResponse( + statusCode: number, + message?: string, + error?: Error | InfrastructureError + ): express.Response { + const context = {}; + + if (Object.keys(this.res.locals).length) { + if ("user" in this.res.locals) { + context["user"] = this.res.locals.user; + } + } + + if (Object.keys(this.req.params).length) { + context["params"] = this.req.params; + } + + if (Object.keys(this.req.query).length) { + context["query"] = this.req.query; + } + + if (Object.keys(this.req.body).length) { + context["body"] = this.req.body; + } + + const extension = new ProblemDocumentExtension({ + context, + extra: error ? { ...this._processError(error) } : {}, + }); + + return this._jsonResponse( + statusCode, + new ProblemDocument( + { + status: statusCode, + detail: message, + instance: this.req.baseUrl, + }, + extension + ) + ); + } + + private _processError( + error: Error | InfrastructureError + ): IErrorExtra_Response_DTO { + /** + * + * + * + { + code: "INVALID_INPUT_DATA", + payload: { + label: "tin", + path: "tin", // [{path: "first_name"}, {path: "last_name"}] + }, + name: "UseCaseError", + } + + + { + code: "INVALID_INPUT_DATA", + payload: [ + { + tin: "{tin} is not allowed to be empty", + }, + { + first_name: "{first_name} is not allowed to be empty", + }, + { + last_name: "{last_name} is not allowed to be empty", + }, + { + company_name: "{company_name} is not allowed to be empty", + }, + ], + name: "InfrastructureError", + } + + */ + + const useCaseError = error; + + const payload = !Array.isArray(useCaseError.payload) + ? Array(useCaseError.payload) + : useCaseError.payload; + + const errors = payload.map((item) => { + if (item.path) { + return item.path + ? { + [String(item.path)]: useCaseError.message, + } + : {}; + } else { + return item; + } + }); + + return { + errors, + }; + } +} diff --git a/server/src/contexts/common/infrastructure/express/HttpStatusCodes.ts b/server/src/contexts/common/infrastructure/express/HttpStatusCodes.ts new file mode 100644 index 0000000..0284edf --- /dev/null +++ b/server/src/contexts/common/infrastructure/express/HttpStatusCodes.ts @@ -0,0 +1,42 @@ +// https://raw.githubusercontent.com/PDMLab/http-problem-details/master/src/StatusCodes.ts + +export const httpStatusCodes = { + 400: "Bad Request", + 401: "Unauthorized", + 402: "Payment Required", + 403: "Forbidden", + 404: "Not Found", + 405: "Method Not Allowed", + 406: "Not Acceptable", + 407: "Proxy Authentication Required", + 408: "Request Timeout", + 409: "Conflict", + 410: "Gone", + 411: "Length Required", + 412: "Precondition Failed", + 413: "Payload Too Large", + 414: "URI Too Long", + 415: "Unsupported Media Type", + 416: "Range Not Satisfiable", + 417: "Expectation Failed", + 421: "Misdirected Request", + 422: "Unprocessable Entity", + 423: "Locked", + 424: "Failed Dependency", + 426: "Upgrade Required", + 428: "Precondition Required", + 429: "Too Many Requests", + 431: "Request Header Fields Too Large", + 451: "Unavailable For Legal Reasons", + 500: "Internal Server Error", + 501: "Not Implemented", + 502: "Bad Gateway", + 503: "Service Unavailable", + 504: "Gateway Timeout", + 505: "HTTP Version Not Supported", + 506: "Variant Also Negotiates", + 507: "Insufficient Storage", + 508: "Loop Detected", + 510: "Not Extended", + 511: "Network Authentication Required", +}; diff --git a/server/src/contexts/common/infrastructure/express/ProblemDocument.ts b/server/src/contexts/common/infrastructure/express/ProblemDocument.ts new file mode 100644 index 0000000..f3eb16a --- /dev/null +++ b/server/src/contexts/common/infrastructure/express/ProblemDocument.ts @@ -0,0 +1,106 @@ +// RFC 7807 - Problem Details for HTTP APIs +// https://datatracker.ietf.org/doc/html/rfc7807 +// https://raw.githubusercontent.com/PDMLab/http-problem-details/master/src/ProblemDocument.ts + +import { httpStatusCodes } from "./HttpStatusCodes"; + +/** + * A problem details object can have the following members: + + o "type" (string) - A URI reference [RFC3986] that identifies the + problem type. This specification encourages that, when + dereferenced, it provide human-readable documentation for the + problem type (e.g., using HTML [W3C.REC-html5-20141028]). When + this member is not present, its value is assumed to be + "about:blank". + + o "title" (string) - A short, human-readable summary of the problem + type. It SHOULD NOT change from occurrence to occurrence of the + problem, except for purposes of localization (e.g., using + proactive content negotiation; see [RFC7231], Section 3.4). + + o "status" (number) - The HTTP status code ([RFC7231], Section 6) + generated by the origin server for this occurrence of the problem. + + o "detail" (string) - A human-readable explanation specific to this + occurrence of the problem. + + o "instance" (string) - A URI reference that identifies the specific + occurrence of the problem. It may or may not yield further + information if dereferenced. + + */ + +export class ProblemDocument { + public detail?: string; + public instance?: string; + public status: number; + public title: string; + public type?: string; + //public status_text: string; + + public constructor( + options: ProblemDocumentOptions, + extension?: ProblemDocumentExtension | Record + ) { + const { title, detail, instance, status } = options; + let { type } = options; + + if (status && !type) { + type = "about:blank"; + } + + /*if (instance) { + // eslint-disable-next-line node/no-deprecated-api + url.parse(instance); + }*/ + + /*if (type) { + // eslint-disable-next-line node/no-deprecated-api + url.parse(type); + // eslint-disable-next-line node/no-deprecated-api + }*/ + + // const result = { + this.type = type; + + //if (detail) { + this.detail = detail; + //} + + this.instance = instance; + this.status = Number(status); + this.title = title ? String(title) : httpStatusCodes[this.status]; + //this.status_text = status ? httpStatusCodes[status] : ""; + // }; + + if (extension) { + const extensionProperties = + extension instanceof ProblemDocumentExtension + ? extension.extensionProperties + : extension; + + for (const propertyName in extensionProperties) { + if (propertyName in extensionProperties) { + this[propertyName] = extensionProperties[propertyName]; + } + } + } + } +} + +export class ProblemDocumentOptions { + public detail?: string; + public instance?: string; + public type?: string; + public title?: string; + public status?: number; +} + +export class ProblemDocumentExtension { + public extensionProperties: Record; + + public constructor(extensionProperties: Record) { + this.extensionProperties = extensionProperties; + } +} diff --git a/server/src/contexts/common/infrastructure/express/index.ts b/server/src/contexts/common/infrastructure/express/index.ts new file mode 100644 index 0000000..8e594da --- /dev/null +++ b/server/src/contexts/common/infrastructure/express/index.ts @@ -0,0 +1 @@ +export * from './ExpressController'; diff --git a/server/src/contexts/common/infrastructure/firebird/FirebirdAdapter.ts b/server/src/contexts/common/infrastructure/firebird/FirebirdAdapter.ts new file mode 100644 index 0000000..00a9878 --- /dev/null +++ b/server/src/contexts/common/infrastructure/firebird/FirebirdAdapter.ts @@ -0,0 +1,107 @@ +import { config } from "@/config"; +import { initLogger } from "@/infrastructure/logger"; +import rTracer from "cls-rtracer"; +import Firebird from "node-firebird"; +import { IAdapter, IRepositoryQueryBuilder } from "../../domain"; +import { FirebirdBusinessTransaction } from "./FirebirdBusinessTransaction"; + +export interface IFirebirdAdapter extends IAdapter { + queryBuilder: IRepositoryQueryBuilder; + + disconnect: () => void; + execute: (query: string, params: any[]) => Promise; + sync: () => void; +} + +export class FirebirdAdapter implements IFirebirdAdapter { + // eslint-disable-next-line no-use-before-define + private static instance: FirebirdAdapter; + + public static getInstance(params: { + queryBuilder: IRepositoryQueryBuilder; + }): FirebirdAdapter { + if (!FirebirdAdapter.instance) { + FirebirdAdapter.instance = FirebirdAdapter.create(params); + } + + return FirebirdAdapter.instance; + } + + private static create(params: { queryBuilder: IRepositoryQueryBuilder }) { + const { queryBuilder } = params; + const connection = initConnection(); + + return new FirebirdAdapter(connection, queryBuilder); + } + + private _connection: Firebird.ConnectionPool; + private _queryBuilder: IRepositoryQueryBuilder; + + protected constructor( + connection: Firebird.ConnectionPool, + queryBuilder: IRepositoryQueryBuilder + ) { + this._connection = connection; + this._queryBuilder = queryBuilder; + } + + get queryBuilder(): IRepositoryQueryBuilder { + return this._queryBuilder; + } + + public startTransaction(): FirebirdBusinessTransaction { + return new FirebirdBusinessTransaction(this._connection).start(); + } + + public disconnect() { + if (this._connection) { + this._connection.destroy((err) => { + if (err) { + throw err; + } + + endConnection(); + }); + } + } + + public async execute(query: string, params: any[] = []): Promise { + return new Promise((resolve, reject) => { + this._connection.get((err, db) => { + if (err) { + return reject(err); + } + db.query(query, params, (err, result) => { + if (err) { + return reject(err); + } + db.detach(); + resolve(result); + }); + }); + }); + } + + public sync() { + return new Promise((resolve, reject) => { + this.execute("select current_connection from rdb$database") + .then(() => resolve(this)) + .catch((error) => reject(error)); + }); + } +} + +function initConnection() { + const { poolCount, ...firebirdOptions } = config.firebird; + const logger = initLogger(rTracer); + + logger.debug("=========================> CONECTO A FIREBIRD"); + + return Firebird.pool(poolCount, firebirdOptions); +} + +function endConnection() { + const logger = initLogger(rTracer); + + logger.debug("<========================= DESCONECTO DE FIREBIRD"); +} diff --git a/server/src/contexts/common/infrastructure/firebird/FirebirdBusinessTransaction.ts b/server/src/contexts/common/infrastructure/firebird/FirebirdBusinessTransaction.ts new file mode 100644 index 0000000..2e30757 --- /dev/null +++ b/server/src/contexts/common/infrastructure/firebird/FirebirdBusinessTransaction.ts @@ -0,0 +1,55 @@ +import Firebird from "node-firebird"; +import { TBusinessTransaction } from "../../domain/repositories"; +import { InfrastructureError } from "../InfrastructureError"; + +export type FirebirdBusinessTransactionType = TBusinessTransaction & { + start: (a: unknown) => unknown; + complete( + work: (t: Firebird.Transaction, db: Firebird.Database) => unknown + ): void; +}; + +export class FirebirdBusinessTransaction + implements FirebirdBusinessTransactionType +{ + private _connection: Firebird.ConnectionPool; + + constructor(connection: Firebird.ConnectionPool) { + this._connection = connection; + } + + public start() { + return this; + } + + public complete( + work: (t: Firebird.Transaction, db: Firebird.Database) => unknown + ): void { + this._connection.get((err, db: Firebird.Database) => { + if (err) { + InfrastructureError.create(InfrastructureError.UNEXCEPTED_ERROR, err); + } + + db.transaction( + Firebird.ISOLATION_READ_COMMITTED, + (err, transaction: Firebird.Transaction) => { + if (err) { + InfrastructureError.create( + InfrastructureError.UNEXCEPTED_ERROR, + err + ); + } + + try { + work(transaction, db); + transaction.commit(); + } catch (e: unknown) { + transaction.rollback(); + } finally { + db.detach(); + } + } + ); + }); + } +} diff --git a/server/src/contexts/common/infrastructure/firebird/FirebirdRepository.ts b/server/src/contexts/common/infrastructure/firebird/FirebirdRepository.ts new file mode 100644 index 0000000..db3b371 --- /dev/null +++ b/server/src/contexts/common/infrastructure/firebird/FirebirdRepository.ts @@ -0,0 +1,97 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { ICollection, IQueryCriteria, UniqueID } from "@shared/contexts"; +import Firebird from "node-firebird"; + +import { + IRepository, + IRepositoryQueryBuilder, +} from "../../domain/repositories"; +import { IFirebirdAdapter } from "./FirebirdAdapter"; + +export abstract class FirebirdRepository implements IRepository { + protected queryBuilder: IRepositoryQueryBuilder; + protected transaction: Firebird.Transaction; + protected adapter: IFirebirdAdapter; + + public constructor(props: { + adapter: IFirebirdAdapter; + transaction: Firebird.Transaction; + }) { + this.adapter = props.adapter; + this.transaction = props.transaction; + this.queryBuilder = this.adapter.queryBuilder; + } + + protected getById(id: UniqueID): Promise { + throw new Error("[FirebirdRepository] getById not implemented!"); + } + + protected findAll(queryCriteria?: IQueryCriteria): Promise> { + throw new Error("[FirebirdRepository] findAll not implemented!"); + } + + protected save(t: T): Promise { + throw new Error("[FirebirdRepository] save not implemented!"); + } + + /* protected remove(t: T): Promise { + throw new Error('[FirebirdRepository] remove not implemented!'); + }*/ + + protected removeById(id: UniqueID): Promise { + throw new Error("[FirebirdRepository] removeById not implemented!"); + } + + protected async _getBy( + modelName: string, + field: string, + value: any, + params: any = {} + ): Promise { + throw new Error("[FirebirdRepository] _getBy not implemented!"); + } + + protected async _getById( + modelName: string, + id: UniqueID | string, + params: any = {} + ): Promise { + throw new Error("[FirebirdRepository] _getById not implemented!"); + } + + protected async _findAll( + modelName: string, + queryCriteria?: IQueryCriteria, + params: any = {} + ): Promise<{ rows: any[]; count: number }> { + console.time("_findAll"); + throw new Error("[FirebirdRepository] _findAll not implemented!"); + } + + protected async _exists( + modelName: string, + field: string, + value: any, + params: any = {} + ): Promise { + throw new Error("[FirebirdRepository] _exists not implemented!"); + } + + protected async _save( + modelName: string, + id: UniqueID, + data: any, + params: any = {} + ): Promise { + throw new Error("[FirebirdRepository] _save not implemented!"); + } + + protected async _removeById( + modelName: string, + id: UniqueID, + force: boolean = false, + params: any = {} + ): Promise { + throw new Error("[FirebirdRepository] _removeById not implemented!"); + } +} diff --git a/server/src/contexts/common/infrastructure/firebird/index.ts b/server/src/contexts/common/infrastructure/firebird/index.ts new file mode 100644 index 0000000..1fea5f7 --- /dev/null +++ b/server/src/contexts/common/infrastructure/firebird/index.ts @@ -0,0 +1,12 @@ +import { FirebirdAdapter, IFirebirdAdapter } from "./FirebirdAdapter"; +import { createFirebirdQueryBuilder } from "./queryBuilder"; + +const createFirebirdAdapter = () => { + return FirebirdAdapter.getInstance({ + queryBuilder: createFirebirdQueryBuilder(), + }); +}; + +export { IFirebirdAdapter, createFirebirdAdapter }; + +export * from "./FirebirdRepository"; diff --git a/server/src/contexts/common/infrastructure/firebird/queryBuilder/FirebirdQueryBuilder.ts b/server/src/contexts/common/infrastructure/firebird/queryBuilder/FirebirdQueryBuilder.ts new file mode 100644 index 0000000..6bf2120 --- /dev/null +++ b/server/src/contexts/common/infrastructure/firebird/queryBuilder/FirebirdQueryBuilder.ts @@ -0,0 +1,12 @@ +import { + IRepositoryQueryBuilder, + IRepositoryQueryOptions, +} from "@/contexts/common/domain"; + +export interface ISequelizeQueryOptions extends IRepositoryQueryOptions {} + +export class FirebirdQueryBuilder implements IRepositoryQueryBuilder { + public static create() { + return new FirebirdQueryBuilder(); + } +} diff --git a/server/src/contexts/common/infrastructure/firebird/queryBuilder/index.ts b/server/src/contexts/common/infrastructure/firebird/queryBuilder/index.ts new file mode 100644 index 0000000..5f3c219 --- /dev/null +++ b/server/src/contexts/common/infrastructure/firebird/queryBuilder/index.ts @@ -0,0 +1,5 @@ +import { FirebirdQueryBuilder } from "./FirebirdQueryBuilder"; + +const createFirebirdQueryBuilder = () => FirebirdQueryBuilder.create(); + +export { createFirebirdQueryBuilder }; diff --git a/server/src/contexts/common/infrastructure/index.ts b/server/src/contexts/common/infrastructure/index.ts new file mode 100644 index 0000000..6bcfbf9 --- /dev/null +++ b/server/src/contexts/common/infrastructure/index.ts @@ -0,0 +1,3 @@ +export * from "./ContextFactory"; +export * from "./InfrastructureError"; +export * from "./mappers"; diff --git a/server/src/contexts/common/infrastructure/mappers/FirebirdMapper.ts b/server/src/contexts/common/infrastructure/mappers/FirebirdMapper.ts new file mode 100644 index 0000000..9580ccb --- /dev/null +++ b/server/src/contexts/common/infrastructure/mappers/FirebirdMapper.ts @@ -0,0 +1,175 @@ +import { FirebirdModel } from "@/contexts/catalog/infrastructure/firebird/firebird.model"; +import { Collection, Entity, Result } from "@shared/contexts"; +import { ValidationError } from "sequelize"; +import { + FieldValueError, + RequiredFieldMissingError, +} from "../../domain/errors"; +import { InfrastructureError } from "../InfrastructureError"; + +export interface IFirebirdMapper< + TModel extends FirebirdModel, + TEntity extends Entity, +> { + mapToDomain(source: TModel, params?: Record): TEntity; + + mapArrayToDomain( + source: TModel[], + params?: Record + ): Collection; + + mapArrayAndCountToDomain( + source: TModel[], + totalCount: number, + params?: Record + ): Collection; +} + +export abstract class FirebirdMapper< + TModel extends FirebirdModel = any, + TModelAttributes = any, + TEntity extends Entity = any, +> implements IFirebirdMapper +{ + public constructor(protected props: any) {} + + public mapToDomain(source: TModel, params?: Record): TEntity { + return this.toDomainMappingImpl(source, params); + } + + public mapArrayToDomain( + source: TModel[], + params?: Record + ): Collection { + return this.mapArrayAndCountToDomain( + source, + source ? source.length : 0, + params + ); + } + + public mapArrayAndCountToDomain( + source: TModel[], + totalCount: number, + params?: Record + ): Collection { + const items = source + ? source.map((value, index: number) => + this.toDomainMappingImpl!(value, { index, ...params }) + ) + : []; + return new Collection(items, totalCount); + } + + protected toDomainMappingImpl( + source: TModel, + params?: Record + ): TEntity { + throw InfrastructureError.create( + InfrastructureError.UNEXCEPTED_ERROR, + 'Method "toDomainMappingImpl" not implemented!' + ); + } + + protected handleRequiredFieldError(key: string, error: Error) { + throw RequiredFieldMissingError.field(key, error.message); + } + + protected handleInvalidFieldError(key: string, error: ValidationError) { + throw FieldValueError.field(key, error.message); + } + + protected mapsValue( + row: TModel, + key: string, + customMapFn: ( + value: any, + params: Record + ) => Result, + params: Record = { + defaultValue: null, + } + ) { + let value = params.defaultValue; + + if (!row || typeof row !== "object") { + console.debug( + `Data row has not keys! Key ${key} not exists in data row!` + ); + } else if (!Object.hasOwn(row.dataValues, key)) { + console.debug(`Key ${key} not exists in data row!`); + } else { + value = row.getDataValue(key); + } + + const valueOrError = customMapFn(value, params); + + if (valueOrError.isFailure) { + this.handleFailure(valueOrError.error, key); + } + return valueOrError.object; + } + + protected mapsAssociation( + row: TModel, + associationName: string, + customMapper: any, + params: Record = {} + ) { + if (!customMapper) { + throw InfrastructureError.create( + InfrastructureError.UNEXCEPTED_ERROR, + 'Custom mapper undefined at "mapsAssociation"!' + ); + } + + const { filter, ...otherParams } = params; + + let associationRows = []; + + if (Object.keys(row).length === 0) { + console.debug( + `Data row has not keys! Association ${associationName} not exists in data row!` + ); + } else if (!Object.hasOwn(row.dataValues, associationName)) { + console.debug(`Association ${associationName} not exists in data row!`); + } else { + associationRows = row.getDataValue(associationName); + } + + const customMapFn = + Array.isArray(associationRows) && associationRows.length > 0 + ? customMapper.mapArrayToDomain + : customMapper.mapToDomain; + + if (filter) { + associationRows = Array.isArray(associationRows) + ? associationRows.filter(filter) + : filter(associationRows); + } + + if (!customMapFn) { + throw InfrastructureError.create( + InfrastructureError.UNEXCEPTED_ERROR, + 'Custom mapper function undefined at "mapsAssociation"!' + ); + } + const associatedDataOrError = customMapFn(associationRows, otherParams); + + if (associatedDataOrError.isFailure) { + this.handleFailure(associatedDataOrError.error, associationName); + } + return associatedDataOrError.object; + + //const associatedData = row[association.accessors.get](); + //return associatedData; + } + + private handleFailure(error: Error, key: string) { + if (error instanceof ValidationError) { + this.handleInvalidFieldError(key, error); + } else { + this.handleRequiredFieldError(key, error); + } + } +} diff --git a/server/src/contexts/common/infrastructure/mappers/SequelizeMapper.ts b/server/src/contexts/common/infrastructure/mappers/SequelizeMapper.ts new file mode 100644 index 0000000..ee4a492 --- /dev/null +++ b/server/src/contexts/common/infrastructure/mappers/SequelizeMapper.ts @@ -0,0 +1,211 @@ +import { Collection, Entity, ICollection, Result } from "@shared/contexts"; +import { Model, ValidationError } from "sequelize"; +import { + FieldValueError, + RequiredFieldMissingError, +} from "../../domain/errors"; +import { InfrastructureError } from "../InfrastructureError"; + +export interface ISequelizeMapper< + TModel extends Model, + TModelAttributes, + TEntity extends Entity, +> { + mapToDomain(source: TModel, params?: Record): TEntity; + + mapArrayToDomain( + source: TModel[], + params?: Record + ): Collection; + + mapArrayAndCountToDomain( + source: TModel[], + totalCount: number, + params?: Record + ): Collection; + + mapToPersistence( + source: TEntity, + params?: Record + ): TModelAttributes; + + mapCollectionToPersistence( + source: ICollection, + params?: Record + ): TModelAttributes[]; +} + +export abstract class SequelizeMapper< + TModel extends Model = any, + TModelAttributes = any, + TEntity extends Entity = any, +> implements ISequelizeMapper +{ + public constructor(protected props: any) {} + + public mapToDomain(source: TModel, params?: Record): TEntity { + return this.toDomainMappingImpl(source, params); + } + + public mapArrayToDomain( + source: TModel[], + params?: Record + ): Collection { + return this.mapArrayAndCountToDomain( + source, + source ? source.length : 0, + params + ); + } + + public mapArrayAndCountToDomain( + source: TModel[], + totalCount: number, + params?: Record + ): Collection { + const items = source + ? source.map((value, index: number) => + this.toDomainMappingImpl!(value, { index, ...params }) + ) + : []; + return new Collection(items, totalCount); + } + + public mapToPersistence( + source: TEntity, + params?: Record + ): TModelAttributes { + return this.toPersistenceMappingImpl(source, params); + } + + public mapCollectionToPersistence( + source: ICollection, + params?: Record + ): TModelAttributes[] { + return source.items.map((value: TEntity, index: number) => + this.toPersistenceMappingImpl!(value, { index, ...params }) + ); + } + + protected toDomainMappingImpl( + source: TModel, + params?: Record + ): TEntity { + throw InfrastructureError.create( + InfrastructureError.UNEXCEPTED_ERROR, + 'Method "toDomainMappingImpl" not implemented!' + ); + } + + protected toPersistenceMappingImpl( + source: TEntity, + params?: Record + ): TModelAttributes { + throw InfrastructureError.create( + InfrastructureError.UNEXCEPTED_ERROR, + 'Method "toPersistenceMappingImpl" not implemented!' + ); + } + + protected handleRequiredFieldError(key: string, error: Error) { + throw RequiredFieldMissingError.field(key, error.message); + } + + protected handleInvalidFieldError(key: string, error: ValidationError) { + throw FieldValueError.field(key, error.message); + } + + protected mapsValue( + row: TModel, + key: string, + customMapFn: ( + value: any, + params: Record + ) => Result, + params: Record = { + defaultValue: null, + } + ) { + let value = params.defaultValue; + + if (!row || typeof row !== "object") { + console.debug( + `Data row has not keys! Key ${key} not exists in data row!` + ); + } else if (!Object.hasOwn(row.dataValues, key)) { + console.debug(`Key ${key} not exists in data row!`); + } else { + value = row.getDataValue(key); + } + + const valueOrError = customMapFn(value, params); + + if (valueOrError.isFailure) { + this.handleFailure(valueOrError.error, key); + } + return valueOrError.object; + } + + protected mapsAssociation( + row: TModel, + associationName: string, + customMapper: any, + params: Record = {} + ) { + if (!customMapper) { + throw InfrastructureError.create( + InfrastructureError.UNEXCEPTED_ERROR, + 'Custom mapper undefined at "mapsAssociation"!' + ); + } + + const { filter, ...otherParams } = params; + + let associationRows = []; + + if (Object.keys(row).length === 0) { + console.debug( + `Data row has not keys! Association ${associationName} not exists in data row!` + ); + } else if (!Object.hasOwn(row.dataValues, associationName)) { + console.debug(`Association ${associationName} not exists in data row!`); + } else { + associationRows = row.getDataValue(associationName); + } + + const customMapFn = + Array.isArray(associationRows) && associationRows.length > 0 + ? customMapper.mapArrayToDomain + : customMapper.mapToDomain; + + if (filter) { + associationRows = Array.isArray(associationRows) + ? associationRows.filter(filter) + : filter(associationRows); + } + + if (!customMapFn) { + throw InfrastructureError.create( + InfrastructureError.UNEXCEPTED_ERROR, + 'Custom mapper function undefined at "mapsAssociation"!' + ); + } + const associatedDataOrError = customMapFn(associationRows, otherParams); + + if (associatedDataOrError.isFailure) { + this.handleFailure(associatedDataOrError.error, associationName); + } + return associatedDataOrError.object; + + //const associatedData = row[association.accessors.get](); + //return associatedData; + } + + private handleFailure(error: Error, key: string) { + if (error instanceof ValidationError) { + this.handleInvalidFieldError(key, error); + } else { + this.handleRequiredFieldError(key, error); + } + } +} diff --git a/server/src/contexts/common/infrastructure/mappers/index.ts b/server/src/contexts/common/infrastructure/mappers/index.ts new file mode 100644 index 0000000..8c14bf9 --- /dev/null +++ b/server/src/contexts/common/infrastructure/mappers/index.ts @@ -0,0 +1 @@ +export * from "./SequelizeMapper"; diff --git a/server/src/contexts/common/infrastructure/sequelize/SequelizeAdapter.ts b/server/src/contexts/common/infrastructure/sequelize/SequelizeAdapter.ts new file mode 100644 index 0000000..ae69430 --- /dev/null +++ b/server/src/contexts/common/infrastructure/sequelize/SequelizeAdapter.ts @@ -0,0 +1,189 @@ +import { config } from "@/config"; +import rTracer from "cls-rtracer"; +import { DataTypes, Sequelize } from "sequelize"; +import { SequelizeRevision } from "sequelize-revision"; + +import { initLogger } from "@/infrastructure/logger"; +import * as glob from "glob"; +import * as path from "path"; +import { IAdapter } from "../../domain"; +import { InfrastructureError } from "../InfrastructureError"; +import { + SequelizeBusinessTransaction, + SequelizeBusinessTransactionType, +} from "./SequelizeBusinessTransaction"; +import { ISequelizeModels } from "./SequelizeModel.interface"; +import { ISequelizeQueryBuilder } from "./queryBuilder/SequelizeQueryBuilder"; + +//import * as dotenv from "dotenv"; +//dotenv.config(); + +export interface ISequelizeAdapter extends IAdapter { + queryBuilder: ISequelizeQueryBuilder; + + getModel: (modelName: string) => any; + hasModel: (modelName: string) => boolean; +} + +export class SequelizeAdapter implements ISequelizeAdapter { + // eslint-disable-next-line no-use-before-define + private static instance: SequelizeAdapter; + + public static getInstance(params: { + queryBuilder: ISequelizeQueryBuilder; + }): SequelizeAdapter { + if (!SequelizeAdapter.instance) { + SequelizeAdapter.instance = SequelizeAdapter.create(params); + } + + return SequelizeAdapter.instance; + } + + private static create(params: { queryBuilder: ISequelizeQueryBuilder }) { + const { queryBuilder } = params; + + const connection = initConnection(); + const sequelizeRevision = new SequelizeRevision(connection, { + UUID: true, + tableName: "revisions", + //changeTableName: "", + underscored: true, + underscoredAttributes: true, + }); + const models = registerModels(connection, sequelizeRevision); + + return new SequelizeAdapter( + connection, + models, + queryBuilder, + sequelizeRevision + ); + } + + private _connection: Sequelize; + private _models: ISequelizeModels; + private _queryBuilder: ISequelizeQueryBuilder; + private _revisions: SequelizeRevision; + + protected constructor( + connection: Sequelize, + models: ISequelizeModels, + queryBuilder: ISequelizeQueryBuilder, + revisions: SequelizeRevision + ) { + this._connection = connection; + this._models = models; + this._queryBuilder = queryBuilder; + this._revisions = revisions; + } + + get queryBuilder(): ISequelizeQueryBuilder { + return this._queryBuilder; + } + + public startTransaction(): SequelizeBusinessTransactionType { + return new SequelizeBusinessTransaction(this._connection); + } + + public sync(params) { + return this._connection.sync(params); + } + + public getModel(modelName: string) { + if (this.hasModel(modelName)) { + return this._models[modelName]; + } + throw InfrastructureError.create( + InfrastructureError.RESOURCE_NOT_FOUND_ERROR, + `[SequelizeAdapter] ${modelName} sequelize model not exists!` + ); + } + + public hasModel(modelName: string): boolean { + return !!this._models[modelName]; + } +} + +function initConnection(): Sequelize { + const { username, password, database, host, dialect, port } = config.database; + const logger = initLogger(rTracer); + + logger.debug("=========================> CONECTO A SEQUELIZE"); + + return new Sequelize(database, username, password, { + host, + dialect, + port, + dialectOptions: { + multipleStatements: true, + dateStrings: true, + typeCast: true, + //timezone: "Z", + }, + pool: { + max: 5, + min: 0, + acquire: 60000, + idle: 10000, + }, + logQueryParameters: true, + logging: (sql, timing) => console.debug(sql), //logger.debug(sql, timing), + + define: { + charset: "utf8mb4", + collate: "utf8mb4_unicode_ci", + //freezeTableName: true, + underscored: true, + timestamps: true, + }, + }); +} + +function registerModels( + connection: Sequelize, + sequelizeRevision: SequelizeRevision +): ISequelizeModels { + const cwd = path.resolve(`${__dirname}/../../../`); + const models: ISequelizeModels = {}; + + // Get all models + const globOptions = { + cwd, + nocase: true, + nodir: true, + absolute: true, + }; + + glob.sync("**/*.model.{js,ts}", globOptions).forEach(function (file) { + console.log(`>> ${file}`); + + // eslint-disable-next-line @typescript-eslint/no-var-requires + const modelDef = require(path.join(file)).default; + const model = + typeof modelDef === "function" ? modelDef(connection, DataTypes) : false; + if (model) models[model.name] = model; + }); + + // Register revisions models + const [Revision, RevisionChanges] = sequelizeRevision.defineModels(); + //models[Revision.name] = Revision; + //models[RevisionChanges.name] = RevisionChanges; + + for (const modelName in models) { + const model = models[modelName]; + + if (model.trackRevision) { + model.trackRevision(connection, sequelizeRevision); + } + + if (model.associate) { + model.associate(connection, models); + } + + if (model.hooks) { + model.hooks(connection); + } + } + + return models; +} diff --git a/server/src/contexts/common/infrastructure/sequelize/SequelizeBusinessTransaction.ts b/server/src/contexts/common/infrastructure/sequelize/SequelizeBusinessTransaction.ts new file mode 100644 index 0000000..d9f0a79 --- /dev/null +++ b/server/src/contexts/common/infrastructure/sequelize/SequelizeBusinessTransaction.ts @@ -0,0 +1,64 @@ +import { Sequelize, Transaction } from "sequelize"; +import { TBusinessTransaction } from "../../domain/repositories"; +import { InfrastructureError } from "../InfrastructureError"; + +export type SequelizeBusinessTransactionType = TBusinessTransaction & { + start(): void; + complete(work: (t: Transaction) => Promise): Promise; +}; + +export class SequelizeBusinessTransaction + implements SequelizeBusinessTransactionType +{ + private _connection: Sequelize; + + constructor(connection: Sequelize) { + this._connection = connection; + } + + public start(): void { + return; + } + + public async complete(work: (t: Transaction) => Promise): Promise { + try { + return await this._connection.transaction(work); + } catch (error: unknown) { + //error instanceof BaseError; + + /* + { + name: "SequelizeValidationError", + errors: [ + { + message: "Customer.entity_type cannot be null", + type: "notNull Violation", + path: "entity_type", + value: null, + origin: "CORE", + instance: { + dataValues: { + id: "85ac4089-6ad7-4058-a16a-adf7fbbfe388", + created_at: "2023-08-02T10:42:49.248Z", + }, + ... + ... + }, + isNewRecord: true, + }, + validatorKey: "is_null", + validatorName: null, + validatorArgs: [ + ], + }, + ], + } + */ + + throw InfrastructureError.create( + InfrastructureError.UNEXCEPTED_ERROR, + (error as Error).message + ); + } + } +} diff --git a/server/src/contexts/common/infrastructure/sequelize/SequelizeModel.interface.ts b/server/src/contexts/common/infrastructure/sequelize/SequelizeModel.interface.ts new file mode 100644 index 0000000..3c5bad1 --- /dev/null +++ b/server/src/contexts/common/infrastructure/sequelize/SequelizeModel.interface.ts @@ -0,0 +1,18 @@ +import { Model, Sequelize } from "sequelize"; +import { SequelizeRevision } from "sequelize-revision"; + +interface ISequelizeModel extends Model {} + +interface ISequelizeModels { + [prop: string]: ISequelizeModel; +} +interface ISequelizeModel extends Model { + associate?: (connection: Sequelize, models?: ISequelizeModels) => void; + hooks?: (connection: Sequelize) => void; + trackRevision?: ( + connection: Sequelize, + sequelizeRevision: SequelizeRevision + ) => void; +} + +export { ISequelizeModel, ISequelizeModels }; diff --git a/server/src/contexts/common/infrastructure/sequelize/SequelizeRepository.ts b/server/src/contexts/common/infrastructure/sequelize/SequelizeRepository.ts new file mode 100644 index 0000000..b53eb82 --- /dev/null +++ b/server/src/contexts/common/infrastructure/sequelize/SequelizeRepository.ts @@ -0,0 +1,266 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { ICollection, IQueryCriteria, UniqueID } from "@shared/contexts"; +import { ModelDefined, Transaction } from "sequelize"; + +import { IRepository } from "../../domain/repositories"; +import { ISequelizeAdapter } from "./SequelizeAdapter"; +import { ISequelizeQueryBuilder } from "./queryBuilder/SequelizeQueryBuilder"; + +export abstract class SequelizeRepository implements IRepository { + protected queryBuilder: ISequelizeQueryBuilder; + protected transaction: Transaction; + protected adapter: ISequelizeAdapter; + public constructor(props: { + adapter: ISequelizeAdapter; + transaction: Transaction; + }) { + this.adapter = props.adapter; + this.transaction = props.transaction; + this.queryBuilder = this.adapter.queryBuilder; + } + + protected getById(id: UniqueID): Promise { + throw new Error("[SequelizeRepository] getById not implemented!"); + } + + /*protected getBy(field: string, value: any): Promise { + throw new Error('[SequelizeRepository] getBy not implemented!'); + }*/ + + protected findAll(queryCriteria?: IQueryCriteria): Promise> { + throw new Error("[SequelizeRepository] findAll not implemented!"); + } + + /*protected totalCount(queryCriteria?: IQueryCriteria): Promise { + throw new Error('[SequelizeRepository] totalCount not implemented!'); + }*/ + + protected save(t: T): Promise { + throw new Error("[SequelizeRepository] save not implemented!"); + } + + /* protected remove(t: T): Promise { + throw new Error('[SequelizeRepository] remove not implemented!'); + }*/ + + protected removeById(id: UniqueID): Promise { + throw new Error("[SequelizeRepository] removeById not implemented!"); + } + + protected async _getBy( + modelName: string, + field: string, + value: any, + params: any = {} + ): Promise { + const _model = this.adapter.getModel(modelName); + const where: { [key: string]: any } = {}; + + where[field] = value; + + return _model.findOne({ + where, + transaction: this.transaction, + ...params, + }); + } + + protected async _getById( + modelName: string, + id: UniqueID | string, + params: any = {} + ): Promise { + const _model = this.adapter.getModel(modelName); + return _model.findByPk(id.toString(), params); + } + + protected async _findAll( + modelName: string, + queryCriteria?: IQueryCriteria, + params: any = {} + ): Promise<{ rows: any[]; count: number }> { + console.time("_findAll"); + + const { model: _model, query } = this.queryBuilder.generateQuery({ + model: this.adapter.getModel(modelName), + queryCriteria, + }); + + if (!_model) { + throw new Error(`[SequelizeRepository] Model ${modelName} not found!`); + } + + const args = { + ...query, + distinct: true, + transaction: this.transaction, + ...params, + }; + + const result = _model.findAndCountAll(args); + + console.timeEnd("_findAll"); + + return result; + } + + protected async _exists( + modelName: string, + field: string, + value: any, + params: any = {} + ): Promise { + const _model = this.adapter.getModel(modelName); + const where = {}; + where[field] = value; + + const count: number = await _model.count({ + where, + transaction: this.transaction, + }); + + return Promise.resolve(Boolean(count !== 0)); + } + + protected async _save( + modelName: string, + id: UniqueID, + data: any, + params: any = {} + ): Promise { + const _model = this.adapter.getModel(modelName); + + if (await this._exists(modelName, "id", id.toString())) { + await _model.update( + { + ...data, + id: undefined, + }, + { + where: { id: id.toString() }, + transaction: this.transaction, + ...params, + } + ); + } else { + await _model.create( + { + ...data, + id: id.toString(), + }, + { + include: [{ all: true }], + transaction: this.transaction, + ...params, + } + ); + } + } + + protected async _removeById( + modelName: string, + id: UniqueID, + force: boolean = false, + params: any = {} + ): Promise { + const model: ModelDefined = this.adapter.getModel(modelName); + + await model.destroy({ + where: { + id: id.toString(), + }, + transaction: this.transaction, + force, + logging: console.log, + }); + } + + /*protected _totalCount( + modelName: string, + queryCriteria?: IQueryCriteria + ): Promise { + const { model: _model, query } = this.queryBuilder.generateQuery({ + model: this.adapter.getModel(modelName), + queryCriteria, + }); + + return _model.count({ + distinct: true, + ...query, + }); + } + + + + protected async _removeByIds( + modelName: string, + ids: UniqueID[] | string[] + ): Promise { + const _ids = ids.map((id: UniqueID | string) => id.toString()); + + const destroyedRows: Promise = await this.adapter.models[ + modelName + ].destroy({ + where: { + id: { + [Op.in]: _ids, + }, + }, + }); + + return !!destroyedRows; + } + + */ + + /* + protected _debugModelInfo(model) { + if (!model.name) + return; + + console.log("\n\n----------------------------------\n", + model.name, + "\n----------------------------------"); + + console.log("\nAttributes"); + console.log("\n----------------------------------\n"); + if (model._options.attributes) { + model._options.attributes.forEach(attr => console.log(model.name + '.' + attr)); + } + + console.log("\nAssociations"); + console.log("\n----------------------------------\n"); + if (model._options.includeNames) { + const names: [string] = model._options.includeNames; + const map: [] = model._options.includeMap; + + names.forEach((name: string) => { + console.log('\nas: ', map[name].association.as, 'type: ', map[name].association.associationType); + console.log("----------------------------------\n"); + const accessors = map[name].association.accessors; + for (const accessor of Object.keys(accessors)) { + console.log(accessor, ' => ', map[name].association.accessors[accessor]); + //console.log(model.name + '.' + model.associations[assoc].accessors[accessor] + '()'); + } + }); + } + + + if (model.Instance && model.Instance.super_) { + console.log("\nCommon"); + for (const func of Object.keys(model.Instance.super_.prototype)) { + if (func === 'constructor' || func === 'sequelize') + continue; + console.log(model.name + '.' + func + '()'); + } + } + + console.log("\n\n----------------------------------\n", + "END", + "\n----------------------------------"); + + + return; + } + */ +} diff --git a/server/src/contexts/common/infrastructure/sequelize/index.ts b/server/src/contexts/common/infrastructure/sequelize/index.ts new file mode 100644 index 0000000..d3c3708 --- /dev/null +++ b/server/src/contexts/common/infrastructure/sequelize/index.ts @@ -0,0 +1,12 @@ +import { ISequelizeAdapter, SequelizeAdapter } from "./SequelizeAdapter"; +import { createSequelizeQueryBuilder } from "./queryBuilder"; + +const createSequelizeAdapter = () => { + return SequelizeAdapter.getInstance({ + queryBuilder: createSequelizeQueryBuilder(), + }); +}; + +export { ISequelizeAdapter, createSequelizeAdapter }; + +export * from "./SequelizeRepository"; diff --git a/server/src/contexts/common/infrastructure/sequelize/queryBuilder/SequelizeParseFilter.ts b/server/src/contexts/common/infrastructure/sequelize/queryBuilder/SequelizeParseFilter.ts new file mode 100644 index 0000000..662512a --- /dev/null +++ b/server/src/contexts/common/infrastructure/sequelize/queryBuilder/SequelizeParseFilter.ts @@ -0,0 +1,123 @@ +import Sequelize = require("sequelize"); + +// https://github.com/Hodor9898/sequelize-query-builder/blob/master/index.ts + +const Op = Sequelize.Op; + +export enum CONNECTING_OPERATORS { + OR = "OR", + AND = "AND", +} + +export enum OPERATORS { + EQ = "EQ", + NOT = "NOT", + IN = "IN", + NOTIN = "NOTIN", + BETWEEN = "BETWEEN", + NOTBETWEEN = "NOTBETWEEN", + LT = "LT", + LTE = "LTE", + GT = "GT", + GTE = "GTE", + NULL = "NULL", + LIKE = "LIKE", + NOTLIKE = "NOTLIKE", +} + +export enum FUNCTIONS { + INCLUDES = "INCLUDES", +} + +const SEQUELIZE_OP_MAP: { + [key: string]: any; +} = { + [CONNECTING_OPERATORS.OR]: Op.or, + [CONNECTING_OPERATORS.AND]: Op.and, + [OPERATORS.EQ]: Op.eq, + [OPERATORS.NOT]: Op.not, + [OPERATORS.IN]: Op.in, + [OPERATORS.NOTIN]: Op.notIn, + [OPERATORS.BETWEEN]: Op.between, + [OPERATORS.NOTBETWEEN]: Op.notBetween, + [OPERATORS.LT]: Op.lt, + [OPERATORS.LTE]: Op.lte, + [OPERATORS.GT]: Op.gt, + [OPERATORS.GTE]: Op.gte, + [OPERATORS.NULL]: Op.is, + [OPERATORS.LIKE]: Op.like, + [OPERATORS.NOTLIKE]: Op.notLike, +}; + +const SEQUELIZE_FN_MAP: { + [key: string]: any; +} = { + [FUNCTIONS.INCLUDES]: (field: string, val: string) => + Sequelize.where( + Sequelize.fn( + "FIND_IN_SET", + Sequelize.literal(`'${val}'`), + Sequelize.col(field), + ), + SEQUELIZE_OP_MAP[OPERATORS.GT], + 0, + ), +}; + +const OPERATOR_VALUE_TRANSFORMER: { + [key: string]: any; +} = { + [OPERATORS.LIKE]: (val: string) => `%${val}%`, + [OPERATORS.NOTLIKE]: (val: string) => `%${val}%`, + [OPERATORS.IN]: (val: string) => val.split("-."), + [OPERATORS.BETWEEN]: (val: string) => val, +}; + +export type FilterObject = { + operator: string; + field: string; + value: any; +}; + +export class SequelizeParseFilter { + static parseFilter(filterRoot: Partial): any { + if (filterRoot === null) { + return null; + } + + const { operator = null, field = null, value = null } = filterRoot; + + if (operator === null) { + return null; + } + + const _op: any = + CONNECTING_OPERATORS[operator as keyof typeof CONNECTING_OPERATORS] || + OPERATORS[operator as keyof typeof OPERATORS] || + FUNCTIONS[operator as keyof typeof FUNCTIONS]; + + if (Object.values(CONNECTING_OPERATORS).includes(_op)) { + return { + [SEQUELIZE_OP_MAP[operator]]: value.map((val: any) => ({ + ...SequelizeParseFilter.parseFilter(val), + })), + }; + } + + if (Object.values(FUNCTIONS).includes(_op) && field !== null) { + return [SEQUELIZE_FN_MAP[_op](field, value)]; + } + + if (Object.values(OPERATORS).includes(_op) && field !== null) { + return { + [field]: { + [SEQUELIZE_OP_MAP[operator]]: OPERATOR_VALUE_TRANSFORMER[operator] + ? OPERATOR_VALUE_TRANSFORMER[operator](value) + : value, + }, + }; + } + + throw new Error(`Filter operator ${operator} is invalid!`); + } +} diff --git a/server/src/contexts/common/infrastructure/sequelize/queryBuilder/SequelizeParseOrder.ts b/server/src/contexts/common/infrastructure/sequelize/queryBuilder/SequelizeParseOrder.ts new file mode 100644 index 0000000..259c1a4 --- /dev/null +++ b/server/src/contexts/common/infrastructure/sequelize/queryBuilder/SequelizeParseOrder.ts @@ -0,0 +1,26 @@ +// https://github.com/Hodor9898/sequelize-query-builder/blob/master/index.ts + +import { IOrder, IOrderCollection } from "@shared/contexts"; + +export class SequelizeParseOrder { + // eslint-disable-next-line unused-imports/no-unused-vars, @typescript-eslint/no-unused-vars + static parseOrder(orderCollection: IOrderCollection): any { + if (orderCollection.totalCount === 0) { + return null; + } + + return orderCollection.items.map((item: IOrder) => { + if (!item.field) { + return []; + } + + const [model, field] = String(item.field).split("."); + + return [ + //field ? model : undefined, + field ? field : model, + item.type === "-" ? "DESC" : "ASC", + ]; + }); + } +} diff --git a/server/src/contexts/common/infrastructure/sequelize/queryBuilder/SequelizeQueryBuilder.ts b/server/src/contexts/common/infrastructure/sequelize/queryBuilder/SequelizeQueryBuilder.ts new file mode 100644 index 0000000..073d683 --- /dev/null +++ b/server/src/contexts/common/infrastructure/sequelize/queryBuilder/SequelizeQueryBuilder.ts @@ -0,0 +1,154 @@ +import { ModelDefined } from "sequelize"; + +import { + IRepositoryQueryBuilder, + IRepositoryQueryOptions, +} from "@/contexts/common/domain/repositories"; + +import { + FilterCriteria, + IQueryCriteria, + OffsetPaging, + OrderCriteria, + QuickSearchCriteria, +} from "@shared/contexts"; +import { SequelizeParseFilter } from "./SequelizeParseFilter"; +import { SequelizeParseOrder } from "./SequelizeParseOrder"; + +export interface ISequelizeQueryOptions extends IRepositoryQueryOptions { + model: ModelDefined; +} + +export interface ISequelizeQueryBuilder extends IRepositoryQueryBuilder { + generateQuery: (props: { + model: any; + queryCriteria?: IQueryCriteria; + }) => ISequelizeQueryOptions; +} + +export class SequelizeQueryBuilder implements ISequelizeQueryBuilder { + public static create() { + return new SequelizeQueryBuilder(); + } + + private applyPagination(pagination: OffsetPaging): any { + const limit = pagination.limit; + const offset = pagination.offset * limit; + + return { + offset, + limit, + subQuery: false, // <- https://selleo.com/til/posts/ddesmudzmi-offset-pagination-with-subquery-in-sequelize- + }; + } + + private applyQuickSearch( + model: ModelDefined, + quickSearchCriteria: QuickSearchCriteria + ): any { + let _model = model; + if (!quickSearchCriteria.isEmpty()) { + if ( + _model && + _model.options.scopes && + _model.options.scopes["quickSearch"] + ) { + _model = _model.scope({ + method: ["quickSearch", quickSearchCriteria.value], + }); + } + } + + return _model; + } + + private applyFilters(filterCriteria: FilterCriteria): any { + let where = undefined; + + if (!filterCriteria.isEmpty()) { + const filterRoot = filterCriteria.getFilterRoot(); + + where = SequelizeParseFilter.parseFilter(filterRoot); + } + + return { + where, + }; + } + + private applyOrder(orderCriteria: OrderCriteria): any { + let order = []; + + if (!orderCriteria.isEmpty()) { + const orderCollection = orderCriteria.getOrderCollection(); + + order = SequelizeParseOrder.parseOrder(orderCollection); + } + + return { + order, + }; + } + + public generateQuery(props: { + model: ModelDefined; + queryCriteria?: IQueryCriteria; + }): ISequelizeQueryOptions { + const { model, queryCriteria } = props; + + let _model = model; + + const defaultOptions: any = { + include: [ + { + all: true, + + // Ejecutar consultas de forma separada para poder paginar + separate: true, + + // Poder referenciar cualquier campo de los joins. P.e.: %emailAddresses.value% + // Al activar esto sale el error: "Only HasMany associations support include.separate" + // nested: true, + + duplicating: false, + }, + ], + }; + + let paginateOptions = {}; + let whereOptions = {}; + let orderOptions = {}; + + if (queryCriteria) { + // Paginate + if (queryCriteria.pagination) { + paginateOptions = this.applyPagination(queryCriteria.pagination); + } + + // QuickSearch + if (queryCriteria.quickSearch) { + _model = this.applyQuickSearch(_model, queryCriteria.quickSearch); + } + + // Filters + if (queryCriteria.filters) { + whereOptions = this.applyFilters(queryCriteria.filters); + } + + // Order + if (queryCriteria.order) { + orderOptions = this.applyOrder(queryCriteria.order); + } + } + + return { + model: _model, + query: { + ...defaultOptions, + ...paginateOptions, + ...whereOptions, + ...orderOptions, + }, + }; + } +} diff --git a/server/src/contexts/common/infrastructure/sequelize/queryBuilder/index.ts b/server/src/contexts/common/infrastructure/sequelize/queryBuilder/index.ts new file mode 100644 index 0000000..2f93ae2 --- /dev/null +++ b/server/src/contexts/common/infrastructure/sequelize/queryBuilder/index.ts @@ -0,0 +1,5 @@ +import { SequelizeQueryBuilder } from "./SequelizeQueryBuilder"; + +const createSequelizeQueryBuilder = () => SequelizeQueryBuilder.create(); + +export { createSequelizeQueryBuilder }; diff --git a/server/src/index.ts b/server/src/index.ts new file mode 100644 index 0000000..6555398 --- /dev/null +++ b/server/src/index.ts @@ -0,0 +1,2 @@ +// Infra +import "./infrastructure/http/server"; diff --git a/server/src/infrastructure/express/api/v1.ts b/server/src/infrastructure/express/api/v1.ts new file mode 100644 index 0000000..f9214e7 --- /dev/null +++ b/server/src/infrastructure/express/api/v1.ts @@ -0,0 +1,9 @@ +import express from "express"; + +const v1Router = express.Router({ mergeParams: true }); + +v1Router.get("/hello", (req, res) => { + res.send("Hello world!"); +}); + +export { v1Router }; diff --git a/server/src/infrastructure/express/app.ts b/server/src/infrastructure/express/app.ts new file mode 100644 index 0000000..1d5b936 --- /dev/null +++ b/server/src/infrastructure/express/app.ts @@ -0,0 +1,57 @@ +import rTracer from "cls-rtracer"; +import cors from "cors"; +import express from "express"; +import helmet from "helmet"; +import morgan from "morgan"; +import responseTime from "response-time"; + +import { initLogger } from "../logger"; +import { v1Router } from "./api/v1"; + +const logger = initLogger(rTracer); + +// Create Express server +const app = express(); + +app.use(rTracer.expressMiddleware()); +app.disable("x-powered-by"); +app.use(express.json()); +app.use(express.text()); +app.use(express.urlencoded({ extended: true })); + +// set up the response-time middleware +app.use(responseTime()); + +// enable CORS - Cross Origin Resource Sharing +app.use( + cors({ + origin: "http://localhost:5173", + credentials: true, + + exposedHeaders: [ + "Access-Control-Allow-Headers", + "Access-Control-Allow-Origin", + "Content-Disposition", + "Content-Type", + "Content-Length", + "X-Total-Count", + "Pagination-Count", + "Pagination-Page", + "Pagination-Limit", + ], + }) +); + +// secure apps by setting various HTTP headers +app.use(helmet()); + +// request logging. dev: console | production: file +//app.use(morgan('common')); +app.use(morgan("dev")); + +// Express configuration +app.set("port", process.env.PORT ?? 3000); + +app.use("/api/v1", v1Router); + +export default app; diff --git a/server/src/infrastructure/http/server.ts b/server/src/infrastructure/http/server.ts new file mode 100644 index 0000000..58e1400 --- /dev/null +++ b/server/src/infrastructure/http/server.ts @@ -0,0 +1,144 @@ +/* eslint-disable no-use-before-define */ +import rTracer from "cls-rtracer"; +import http from "http"; +import { assign } from "lodash"; +import { DateTime, Settings } from "luxon"; + +import { createFirebirdAdapter } from "@/contexts/common/infrastructure/firebird"; +import { createSequelizeAdapter } from "@/contexts/common/infrastructure/sequelize"; +import { trace } from "console"; +import { config } from "../../config"; +import app from "../express/app"; +import { initLogger } from "../logger"; + +process.env.TZ = "UTC"; +Settings.defaultLocale = "es-ES"; +Settings.defaultZone = "utc"; + +const logger = initLogger(rTracer); + +export const currentState = assign( + { + launchedAt: DateTime.now(), + appPath: process.cwd(), + //host: process.env.HOST || process.env.HOSTNAME || 'localhost', + //port: process.env.PORT || 18888, + environment: config.enviroment, + connections: {}, + }, + config +); + +const serverStop = (server: http.Server) => { + const forceTimeout = 30000; + + return new Promise((resolve, reject) => { + logger.warn("⚡️ Shutting down server"); + + setTimeout(() => { + logger.error( + "Could not close connections in time, forcefully shutting down" + ); + resolve(); + }, forceTimeout).unref(); + + server.close((err) => { + if (err) { + return reject(err); + } + + logger.info("Closed out remaining connections."); + logger.info("Bye!"); + resolve(); + }); + }); + + /*const now = DateTime.now(); + // Destroy server and available connections. + logger.info(`Time: ${now.toLocaleString()}`); + logger.info('Shutting down at: ' + new Date()); + if (server) { + server.close(); + } + + logger.info('Bye!'); + process.exit(1);*/ +}; + +const serverError = (error: any) => { + if (error.code === "EADDRINUSE") { + logger.debug(`⛔️ Server wasn't able to start properly.`); + logger.error( + `The port ${error.port} is already used by another application.` + ); + } else { + logger.debug(`⛔️ Server wasn't able to start properly.`); + logger.error(error); + trace(error); + } + + serverStop(server); + + return; +}; + +const serverConnection = (conn: any) => { + const key = `${conn.remoteAddress}:${conn.remotePort}`; + currentState.connections[key] = conn; + + logger.debug(currentState.connections); + + conn.on("close", () => { + delete currentState.connections[key]; + }); +}; + +const sequelizeConn = createSequelizeAdapter(); +const firebirdConn = createFirebirdAdapter(); + +const server: http.Server = http + .createServer(app) + .once("listening", () => + process.on("SIGINT", () => { + firebirdConn.disconnect(); + serverStop(server); + }) + ) + .on("close", () => + logger.info( + `Shut down at: ${DateTime.now().toLocaleString(DateTime.DATETIME_FULL)}` + ) + ) + .on("connection", serverConnection) + .on("error", serverError); + +try { + firebirdConn.sync().then(() => { + sequelizeConn.sync({ force: false, alter: true }).then(() => { + // Launch server + server.listen(currentState.server.port, () => { + const now = DateTime.now(); + logger.info( + `Time: ${now.toLocaleString(DateTime.DATETIME_FULL)} ${now.zoneName}` + ); + logger.info( + `Launched in: ${now.diff(currentState.launchedAt).toMillis()} ms` + ); + logger.info(`Environment: ${currentState.environment}`); + logger.info(`Process PID: ${process.pid}`); + logger.info("To shut down your server, press + C at any time"); + logger.info( + `⚡️ Server: http://${currentState.server.hostname}:${currentState.server.port}` + ); + }); + }); + }); +} catch (error) { + serverError(error); +} + +process.on("uncaughtException", (error: any) => { + logger.error(`${new Date().toUTCString()} uncaughtException:`, error.message); + logger.error(error.stack); + //process.exit(1); +}); diff --git a/server/src/infrastructure/logger/index.ts b/server/src/infrastructure/logger/index.ts new file mode 100644 index 0000000..b4ebdad --- /dev/null +++ b/server/src/infrastructure/logger/index.ts @@ -0,0 +1,85 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import path from "path"; +import { createLogger, format, transports } from "winston"; +import DailyRotateFile from "winston-daily-rotate-file"; +import { config } from "../../config"; + +function initLogger(rTracer) { + // a custom format that outputs request id + + const consoleFormat = format.combine( + format.colorize(), + format.timestamp(), + format.align(), + format.splat(), + format.printf((info) => { + const rid = rTracer.id(); + + let out = + config.isProduction && rid + ? `${info.timestamp} [request-id:${rid}] - ${info.level}: [${info.label}]: ${info.message}` + : `${info.timestamp} - ${info.level}: [${info.label}]: ${info.message}`; + + if (info.metadata?.error) { + out = `${out} ${info.metadata.error}`; + if (info.metadata?.error?.stack) { + out = `${out} ${info.metadata.error.stack}`; + } + } + + return out; + }), + ); + + const fileFormat = format.combine( + format.timestamp(), + format.splat(), + format.label({ label: path.basename(String(require.main?.filename)) }), + //format.metadata(), + format.metadata({ fillExcept: ["message", "level", "timestamp", "label"] }), + format.simple(), + format.json(), + ); + + const logger = createLogger({ + level: process.env.NODE_ENV === "production" ? "info" : "debug", + + format: fileFormat, + + transports: [ + new DailyRotateFile({ + filename: "error-%DATE%.log", + datePattern: "YYYY-MM-DD", + utc: true, + level: "error", + maxSize: "5m", + maxFiles: "1d", + }), + new DailyRotateFile({ + filename: "debug-%DATE%.log", + datePattern: "YYYY-MM-DD", + utc: true, + level: "debug", + maxSize: "5m", + maxFiles: "1d", + }), + ], + }); + + // + // If we're not in production then log to the `console` with the format: + // `${info.level}: ${info.message} JSON.stringify({ ...rest }) ` + // + if (!config.isProduction) { + logger.add( + new transports.Console({ + format: consoleFormat, + level: "debug", + }), + ); + } + + return logger; +} + +export { initLogger }; diff --git a/server/tsconfig.eslint.json b/server/tsconfig.eslint.json new file mode 100644 index 0000000..bc6937c --- /dev/null +++ b/server/tsconfig.eslint.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["jest"], + "baseUrl": "./src", + "paths": { + "@/*": ["./src/*"], + "@shared/*": ["../shared/lib/*"] + } + }, + "include": ["src", "tests"] +} diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 0000000..9cf7f9c --- /dev/null +++ b/server/tsconfig.json @@ -0,0 +1,81 @@ +{ + "compilerOptions": { + /* Basic Options */ + "target": "ES2022" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */, + "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */, + "lib": [ + "ES2022", + "dom" + ] /* Specify library files to be included in the compilation. */, + + "allowJs": false /* Allow javascript files to be compiled. */, + "pretty": true, + // "checkJs": true, /* Report errors in .js files. */ + "jsx": "preserve" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */, + // "declaration": true, /* Generates corresponding '.d.ts' file. */ + // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ + "sourceMap": true /* Generates corresponding '.map' file. */, + // "outFile": "./", /* Concatenate and emit output to single file. */ + "outDir": "../dist/" /* Redirect output structure to the directory. */, + //"rootDir": "./src" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */, + // "composite": true, /* Enable project compilation */ + "removeComments": true /* Do not emit comments to output. */, + // "noEmit": true, /* Do not emit outputs. */ + // "importHelpers": true, /* Import emit helpers from 'tslib'. */ + // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ + // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ + + /* Strict Type-Checking Options */ + "skipLibCheck": false /* Skip type checking of declaration files. */, + "strict": true /* Enable all strict type-checking options. */, + "noImplicitAny": false /* Raise error on expressions and declarations with an implied 'any' type. */, + "strictNullChecks": true /* Enable strict null checks. */, + "strictFunctionTypes": true /* Enable strict checking of function types. */, + "strictPropertyInitialization": false /* Enable strict checking of property initialization in classes. */, + "noImplicitThis": true /* Raise error on 'this' expressions with an implied 'any' type. */, + // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ + + /* Additional Checks */ + "noUnusedLocals": false /* Report errors on unused locals. */, + "noUnusedParameters": false /* Report errors on unused parameters. */, + "noImplicitReturns": true /* Report error when not all code paths in function return a value. */, + "noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */, + + /* Module Resolution Options */ + "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, + //"baseUrl": "./" /* Base directory to resolve non-absolute module names. */, + "paths": { + /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ + "@/*": ["./src/*"], + "@shared/*": ["../shared/lib/*"] + }, + + // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ + // "typeRoots": [] /* List of folders to include type definitions from. */, + // "types": [], /* Type declaration files to be included in compilation. */ + "allowSyntheticDefaultImports": true /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */, + "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, + "forceConsistentCasingInFileNames": true, + // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ + + /* Source Map Options */ + // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ + // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ + + /* Experimental Options */ + "experimentalDecorators": true /* Enables experimental support for ES7 decorators. */, + "emitDecoratorMetadata": true /* Enables experimental support for emitting type metadata for decorators. */, + + /* Advanced Options */ + "resolveJsonModule": true /* Include modules imported with '.json' extension */, + "suppressImplicitAnyIndexErrors": false + }, + "exclude": [ + "src/**/__tests__/*", + "src/**/*.mock.*", + "src/**/*.test.*", + "node_modules" + ] +} diff --git a/shared/.eslintrc.json b/shared/.eslintrc.json new file mode 100644 index 0000000..47ff4d9 --- /dev/null +++ b/shared/.eslintrc.json @@ -0,0 +1,21 @@ +{ + "root": true, + "extends": [ + "eslint:recommended", + "prettier", + "plugin:prettier/recommended", + "plugin:@typescript-eslint/recommended", + "plugin:jest/recommended", + "plugin:import/errors", + "plugin:import/warnings", + "plugin:import/typescript" + ], + "overrides": [ + { + "files": ["*.ts", "*.tsx"], + "parserOptions": { + "project": ["./tsconfig.json"] + } + } + ] +} \ No newline at end of file diff --git a/shared/.prettierc.json b/shared/.prettierc.json new file mode 100644 index 0000000..392a44c --- /dev/null +++ b/shared/.prettierc.json @@ -0,0 +1,10 @@ +{ + "semi": true, + "printWidth": 80, + "useTabs": false, + "endOfLine": "auto", + + "trailingComma": "all", + "singleQuote": false, + "bracketSpacing": true + } \ No newline at end of file diff --git a/shared/lib/contexts/catalog/application/dto/IListProducts.dto/IListProducts_Response.dto.ts b/shared/lib/contexts/catalog/application/dto/IListProducts.dto/IListProducts_Response.dto.ts new file mode 100644 index 0000000..e858b4b --- /dev/null +++ b/shared/lib/contexts/catalog/application/dto/IListProducts.dto/IListProducts_Response.dto.ts @@ -0,0 +1,11 @@ +import { IMoney_Response_DTO } from "../../../../common"; + +export interface IListProducts_Response_DTO { + id: string; + reference: string; + family: string; + subfamily: string; + description: string; + points: number; + pvp: IMoney_Response_DTO; +} diff --git a/shared/lib/contexts/catalog/application/dto/IListProducts.dto/index.ts b/shared/lib/contexts/catalog/application/dto/IListProducts.dto/index.ts new file mode 100644 index 0000000..bdcf554 --- /dev/null +++ b/shared/lib/contexts/catalog/application/dto/IListProducts.dto/index.ts @@ -0,0 +1 @@ +export * from "./IListProducts_Response.dto"; diff --git a/shared/lib/contexts/catalog/application/dto/index.ts b/shared/lib/contexts/catalog/application/dto/index.ts new file mode 100644 index 0000000..5e658d9 --- /dev/null +++ b/shared/lib/contexts/catalog/application/dto/index.ts @@ -0,0 +1 @@ +export * from "./IListProducts.dto"; diff --git a/shared/lib/contexts/catalog/application/index.ts b/shared/lib/contexts/catalog/application/index.ts new file mode 100644 index 0000000..0392b1b --- /dev/null +++ b/shared/lib/contexts/catalog/application/index.ts @@ -0,0 +1 @@ +export * from "./dto"; diff --git a/shared/lib/contexts/catalog/index.ts b/shared/lib/contexts/catalog/index.ts new file mode 100644 index 0000000..f4fe054 --- /dev/null +++ b/shared/lib/contexts/catalog/index.ts @@ -0,0 +1 @@ +export * from "./application"; diff --git a/shared/lib/contexts/common/application/dto/IError_Response.dto.ts b/shared/lib/contexts/common/application/dto/IError_Response.dto.ts new file mode 100644 index 0000000..0619abd --- /dev/null +++ b/shared/lib/contexts/common/application/dto/IError_Response.dto.ts @@ -0,0 +1,20 @@ +export interface IError_Response_DTO { + detail?: string; + instance?: string; + status: number; + title: string; + type?: string; + context: IErrorContext_Response_DTO; + extra: IErrorExtra_Response_DTO; +} + +export interface IErrorContext_Response_DTO { + user?: unknown; + params?: Record; + query?: Record; + body?: Record; +} + +export interface IErrorExtra_Response_DTO { + errors: Record[]; +} diff --git a/shared/lib/contexts/common/application/dto/IMoney.dto.ts b/shared/lib/contexts/common/application/dto/IMoney.dto.ts new file mode 100644 index 0000000..a78728d --- /dev/null +++ b/shared/lib/contexts/common/application/dto/IMoney.dto.ts @@ -0,0 +1,7 @@ +export interface IMoney_DTO { + amount: number; + precision: number; + currency: string; +} + +export interface IMoney_Response_DTO extends IMoney_DTO {} diff --git a/shared/lib/contexts/common/application/dto/IPercentage.dto.ts b/shared/lib/contexts/common/application/dto/IPercentage.dto.ts new file mode 100644 index 0000000..3ff7d88 --- /dev/null +++ b/shared/lib/contexts/common/application/dto/IPercentage.dto.ts @@ -0,0 +1,6 @@ +export interface IPercentage_DTO { + amount: number; + precision: number; +} + +export interface IPercentage_Response_DTO extends IPercentage_DTO {} diff --git a/shared/lib/contexts/common/application/dto/ITaxType.dto.ts b/shared/lib/contexts/common/application/dto/ITaxType.dto.ts new file mode 100644 index 0000000..1dce758 --- /dev/null +++ b/shared/lib/contexts/common/application/dto/ITaxType.dto.ts @@ -0,0 +1,11 @@ +import { IPercentage_DTO } from "./IPercentage.dto"; + +export interface ITaxType_DTO { + id: string; + type_code: string, + tax_slug: string, + tax_rate: IPercentage_DTO, + equivalence_surcharge: IPercentage_DTO, +} + +export interface ITaxType_Response_DTO extends ITaxType_DTO {} diff --git a/shared/lib/contexts/common/application/dto/index.ts b/shared/lib/contexts/common/application/dto/index.ts new file mode 100644 index 0000000..9db54ca --- /dev/null +++ b/shared/lib/contexts/common/application/dto/index.ts @@ -0,0 +1,4 @@ +export * from "./IError_Response.dto"; +export * from "./IMoney.dto"; +export * from "./IPercentage.dto"; +export * from "./ITaxType.dto"; diff --git a/shared/lib/contexts/common/application/index.ts b/shared/lib/contexts/common/application/index.ts new file mode 100644 index 0000000..0392b1b --- /dev/null +++ b/shared/lib/contexts/common/application/index.ts @@ -0,0 +1 @@ +export * from "./dto"; diff --git a/shared/lib/contexts/common/domain/EntityError.ts b/shared/lib/contexts/common/domain/EntityError.ts new file mode 100644 index 0000000..69333ed --- /dev/null +++ b/shared/lib/contexts/common/domain/EntityError.ts @@ -0,0 +1,6 @@ +export class EntityError extends Error { + constructor(field: string, message?: string) { + super(message); + this.name = this.constructor.name; + } +} diff --git a/shared/lib/contexts/common/domain/IListResponse.dto.ts b/shared/lib/contexts/common/domain/IListResponse.dto.ts new file mode 100644 index 0000000..be96591 --- /dev/null +++ b/shared/lib/contexts/common/domain/IListResponse.dto.ts @@ -0,0 +1,25 @@ +export interface IListResponse_DTO { + page: number; + per_page: number; + total_pages: number; + total_items: number; + items: T[]; +} + +export const IsResponseAListDTO = ( + response: any +): response is IListResponse_DTO => { + return ( + typeof response === "object" && + response !== null && + response.hasOwnProperty("total_items") + ); +}; + +export const existsMoreReponsePages = ( + response: any +): response is IListResponse_DTO => { + return ( + IsResponseAListDTO(response) && response.page + 1 < response.total_pages + ); +}; diff --git a/shared/lib/contexts/common/domain/RuleValidator.ts b/shared/lib/contexts/common/domain/RuleValidator.ts new file mode 100644 index 0000000..01dcffe --- /dev/null +++ b/shared/lib/contexts/common/domain/RuleValidator.ts @@ -0,0 +1,66 @@ +import Joi, { ValidationError } from "joi"; +import { Result } from "./entities/Result"; + +export type TRuleValidatorResult = Result; + +export class RuleValidator { + public static readonly RULE_NOT_NULL_OR_UNDEFINED = Joi.any() + .required() // <- undefined + .invalid(null); // <- null + + public static readonly RULE_ALLOW_NULL_OR_UNDEFINED = Joi.any() + .optional() // <- undefined + .valid(null); // <- null + + public static readonly RULE_ALLOW_NULL = Joi.any().valid(null); // <- null + + public static readonly RULE_ALLOW_EMPTY = Joi.any() + .optional() // <- undefined + .valid(null, ""); // + + public static readonly RULE_IS_TYPE_STRING = Joi.string(); + public static readonly RULE_IS_TYPE_NUMBER = Joi.number(); + + public static validate( + rule: Joi.AnySchema | Joi.AnySchema[], + value: any, + options: Joi.ValidationOptions = {} + ): TRuleValidatorResult { + if (!Joi.isSchema(rule)) { + throw new RuleValidator_Error("Rule provided is not a valid Joi schema!"); + } + + const _options: Joi.ValidationOptions = { + abortEarly: false, + errors: { + wrap: { + label: "{}", + }, + }, + //messages: SpanishJoiMessages, + ...options, + }; + + const validationResult = rule.validate(value, _options); + + if (validationResult.error) { + return Result.fail(validationResult.error); + } + + return Result.ok(validationResult.value); + } + + public static validateFnc(ruleFnc: (value: any) => any) { + return (value: any, helpers) => { + const result = ruleFnc(value); + console.log(value); + return result.isSuccess + ? value + : helpers.message({ + custom: result.error.message, + }); + }; + } +} + +export class RuleValidator_Error extends Error {} diff --git a/shared/lib/contexts/common/domain/entities/Address/AddressTitle.ts b/shared/lib/contexts/common/domain/entities/Address/AddressTitle.ts new file mode 100644 index 0000000..3eed1ef --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/Address/AddressTitle.ts @@ -0,0 +1,51 @@ +import Joi from "joi"; + +import { UndefinedOr } from "../../../../../utilities"; + +import { RuleValidator } from "../../RuleValidator"; +import { DomainError, handleDomainError } from "../../errors"; +import { Result } from "../Result"; +import { + IStringValueObjectOptions, + StringValueObject, +} from "../StringValueObject"; + +export class AddressTitle extends StringValueObject { + protected static validate( + value: UndefinedOr, + options: IStringValueObjectOptions + ) { + const rule = Joi.string() + .allow(null) + .allow("") + .default("") + .trim() + .label(options.label ? options.label : "value"); + + return RuleValidator.validate(rule, value); + } + + public static create( + value: UndefinedOr, + options: IStringValueObjectOptions = {} + ) { + const _options = { + label: "title", + ...options, + }; + + const validationResult = AddressTitle.validate(value, _options); + + if (validationResult.isFailure) { + return Result.fail( + handleDomainError( + DomainError.INVALID_INPUT_DATA, + validationResult.error + ) + ); + } + return Result.ok(new AddressTitle(validationResult.object)); + } +} + +export class AddressTitle_ValidationError extends Joi.ValidationError {} diff --git a/shared/lib/contexts/common/domain/entities/Address/AddressType.ts b/shared/lib/contexts/common/domain/entities/Address/AddressType.ts new file mode 100644 index 0000000..447c4df --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/Address/AddressType.ts @@ -0,0 +1,59 @@ +import Joi from "joi"; + +import { UndefinedOr } from "../../../../../utilities"; + +import { RuleValidator } from "../../RuleValidator"; +import { DomainError, handleDomainError } from "../../errors"; +import { Result } from "../Result"; +import { + IStringValueObjectOptions, + StringValueObject, +} from "../StringValueObject"; + +export class AddressType extends StringValueObject { + protected static validate( + value: UndefinedOr, + options: IStringValueObjectOptions + ) { + const rule = Joi.string() + .allow(null) + .allow("") + .default("") + .trim() + .label(options.label ? options.label : "value"); + + return RuleValidator.validate(rule, value); + } + + public static create( + value: UndefinedOr, + options: IStringValueObjectOptions = {} + ) { + const _options = { + label: "type", + ...options, + }; + + const validationResult = AddressType.validate(value, _options); + + if (validationResult.isFailure) { + return Result.fail( + handleDomainError( + DomainError.INVALID_INPUT_DATA, + validationResult.error + ) + ); + } + return Result.ok(new AddressType(validationResult.object)); + } + + public isBilling(): Boolean { + return this.toString() === "billing"; + } + + public isShipping(): Boolean { + return this.toString() === "shipping"; + } +} + +export class AddressType_ValidationError extends Joi.ValidationError {} diff --git a/shared/lib/contexts/common/domain/entities/Address/City.ts b/shared/lib/contexts/common/domain/entities/Address/City.ts new file mode 100644 index 0000000..b8f850c --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/Address/City.ts @@ -0,0 +1,49 @@ +import Joi from "joi"; +import { UndefinedOr } from "../../../../../utilities"; +import { RuleValidator } from "../../RuleValidator"; +import { DomainError, handleDomainError } from "../../errors"; +import { Result } from "../Result"; +import { + IStringValueObjectOptions, + StringValueObject, +} from "../StringValueObject"; + +export class City extends StringValueObject { + protected static validate( + value: UndefinedOr, + options: IStringValueObjectOptions + ) { + const rule = Joi.string() + .allow(null) + .allow("") + .default("") + .trim() + .label(options.label ? options.label : "value"); + + return RuleValidator.validate(rule, value); + } + + public static create( + value: UndefinedOr, + options: IStringValueObjectOptions = {} + ) { + const _options = { + label: "city", + ...options, + }; + + const validationResult = City.validate(value, _options); + + if (validationResult.isFailure) { + return Result.fail( + handleDomainError( + DomainError.INVALID_INPUT_DATA, + validationResult.error + ) + ); + } + return Result.ok(new City(validationResult.object)); + } +} + +export class City_ValidationError extends Joi.ValidationError {} diff --git a/shared/lib/contexts/common/domain/entities/Address/Country.ts b/shared/lib/contexts/common/domain/entities/Address/Country.ts new file mode 100644 index 0000000..f44c31b --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/Address/Country.ts @@ -0,0 +1,51 @@ +import Joi from "joi"; + +import { UndefinedOr } from "../../../../../utilities"; + +import { RuleValidator } from "../../RuleValidator"; +import { DomainError, handleDomainError } from "../../errors"; +import { Result } from "../Result"; +import { + IStringValueObjectOptions, + StringValueObject, +} from "../StringValueObject"; + +export class Country extends StringValueObject { + protected static validate( + value: UndefinedOr, + options: IStringValueObjectOptions + ) { + const rule = Joi.string() + .allow(null) + .allow("") + .default("") + .trim() + .label(options.label ? options.label : "value"); + + return RuleValidator.validate(rule, value); + } + + public static create( + value: UndefinedOr, + options: IStringValueObjectOptions = {} + ) { + const _options = { + label: "country", + ...options, + }; + + const validationResult = Country.validate(value, _options); + + if (validationResult.isFailure) { + return Result.fail( + handleDomainError( + DomainError.INVALID_INPUT_DATA, + validationResult.error + ) + ); + } + return Result.ok(new Country(validationResult.object)); + } +} + +export class Country_ValidationError extends Joi.ValidationError {} diff --git a/shared/lib/contexts/common/domain/entities/Address/GenericAddress.ts b/shared/lib/contexts/common/domain/entities/Address/GenericAddress.ts new file mode 100644 index 0000000..efd0d5e --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/Address/GenericAddress.ts @@ -0,0 +1,116 @@ +import { Email } from "../Email"; +import { Entity } from "../Entity"; +import { Note } from "../Note"; +import { Phone } from "../Phone"; +import { Result } from "../Result"; +import { UniqueID } from "../UniqueID"; +import { AddressTitle } from "./AddressTitle"; +import { AddressType } from "./AddressType"; +import { City } from "./City"; +import { Country } from "./Country"; +import { PostalCode } from "./PostalCode"; +import { Province } from "./Province"; +import { Street } from "./Street"; + +export interface IGenericAddressProps { + type: AddressType; + title: AddressTitle; + street: Street; + city: City; + province: Province; + postalCode: PostalCode; + country: Country; + email: Email; + phone: Phone; + notes: Note; +} + +export interface IGenericAddress { + type: AddressType; + + id: UniqueID; + title: AddressTitle; + street: Street; + city: City; + province: Province; + postalCode: PostalCode; + country: Country; + email: Email; + phone: Phone; + notes: Note; +} + +export class GenericAddress + extends Entity + implements IGenericAddress +{ + get title(): AddressTitle { + return this.props.title; + } + + get street(): Street { + return this.props.street; + } + + get city(): City { + return this.props.city; + } + + get province(): Province { + return this.props.province; + } + + get postalCode(): PostalCode { + return this.props.postalCode; + } + + get country(): Country { + return this.props.country; + } + + get email(): Email { + return this.props.email; + } + + get phone(): Phone { + return this.props.phone; + } + + get notes(): Note { + return this.props.notes; + } + + get type(): AddressType { + return this.props.type; + } + + public toString(): { + type: string; + title: string; + street: string; + city: string; + province: string; + postal_code: string; + country: string; + email: string; + phone: string; + notes: string; + } { + return { + type: this.type.toString(), + title: this.title.toString(), + street: this.street.toString(), + city: this.city.toString(), + province: this.province.toString(), + postal_code: this.postalCode.toString(), + country: this.country.toString(), + email: this.email.toString(), + phone: this.phone.toString(), + notes: this.notes.toString(), + }; + } + + public static create(props: IGenericAddressProps, id?: UniqueID) { + return Result.ok(new this(props, id)); + } +} diff --git a/shared/lib/contexts/common/domain/entities/Address/PostalCode.ts b/shared/lib/contexts/common/domain/entities/Address/PostalCode.ts new file mode 100644 index 0000000..d0d1941 --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/Address/PostalCode.ts @@ -0,0 +1,52 @@ +import Joi from "joi"; + +import { UndefinedOr } from "../../../../../utilities"; +import { RuleValidator } from "../../RuleValidator"; +import { DomainError, handleDomainError } from "../../errors"; +import { Result } from "../Result"; +import { + IStringValueObjectOptions, + StringValueObject, +} from "../StringValueObject"; + +export class PostalCode extends StringValueObject { + private static readonly LENGTH = 5; + + protected static validate( + value: UndefinedOr, + options: IStringValueObjectOptions + ) { + const rule = Joi.string() + .allow(null) + .allow("") + .default("") + .trim() + .label(options.label ? options.label : "value"); + + return RuleValidator.validate(rule, value); + } + + public static create( + value: UndefinedOr, + options: IStringValueObjectOptions = {} + ) { + const _options = { + label: "postal_code", + ...options, + }; + + const validationResult = PostalCode.validate(value, _options); + + if (validationResult.isFailure) { + return Result.fail( + handleDomainError( + DomainError.INVALID_INPUT_DATA, + validationResult.error + ) + ); + } + return Result.ok(new PostalCode(validationResult.object)); + } +} + +export class PostalCode_ValidationError extends Joi.ValidationError {} diff --git a/shared/lib/contexts/common/domain/entities/Address/Province.ts b/shared/lib/contexts/common/domain/entities/Address/Province.ts new file mode 100644 index 0000000..17fc0b7 --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/Address/Province.ts @@ -0,0 +1,51 @@ +import Joi from "joi"; + +import { UndefinedOr } from "../../../../../utilities"; + +import { RuleValidator } from "../../RuleValidator"; +import { DomainError, handleDomainError } from "../../errors"; +import { Result } from "../Result"; +import { + IStringValueObjectOptions, + StringValueObject, +} from "../StringValueObject"; + +export class Province extends StringValueObject { + protected static validate( + value: UndefinedOr, + options: IStringValueObjectOptions + ) { + const rule = Joi.string() + .allow(null) + .allow("") + .default("") + .trim() + .label(options.label ? options.label : "value"); + + return RuleValidator.validate(rule, value); + } + + public static create( + value: UndefinedOr, + options: IStringValueObjectOptions = {} + ) { + const _options = { + label: "province", + ...options, + }; + + const validationResult = Province.validate(value, _options); + + if (validationResult.isFailure) { + return Result.fail( + handleDomainError( + DomainError.INVALID_INPUT_DATA, + validationResult.error + ) + ); + } + return Result.ok(new Province(validationResult.object)); + } +} + +export class Province_ValidationError extends Joi.ValidationError {} diff --git a/shared/lib/contexts/common/domain/entities/Address/Street.ts b/shared/lib/contexts/common/domain/entities/Address/Street.ts new file mode 100644 index 0000000..7a6a6df --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/Address/Street.ts @@ -0,0 +1,51 @@ +import Joi from "joi"; + +import { UndefinedOr } from "../../../../../utilities"; + +import { RuleValidator } from "../../RuleValidator"; +import { DomainError, handleDomainError } from "../../errors"; +import { Result } from "../Result"; +import { + IStringValueObjectOptions, + StringValueObject, +} from "../StringValueObject"; + +export class Street extends StringValueObject { + protected static validate( + value: UndefinedOr, + options: IStringValueObjectOptions + ) { + const rule = Joi.string() + .allow(null) + .allow("") + .default("") + .trim() + .label(options.label ? options.label : "value"); + + return RuleValidator.validate(rule, value); + } + + public static create( + value: UndefinedOr, + options: IStringValueObjectOptions = {} + ) { + const _options = { + label: "street", + ...options, + }; + + const validationResult = Street.validate(value, _options); + + if (validationResult.isFailure) { + return Result.fail( + handleDomainError( + DomainError.INVALID_INPUT_DATA, + validationResult.error + ) + ); + } + return Result.ok(new Street(validationResult.object)); + } +} + +export class Street_ValidationError extends Joi.ValidationError {} diff --git a/shared/lib/contexts/common/domain/entities/Address/index.ts b/shared/lib/contexts/common/domain/entities/Address/index.ts new file mode 100644 index 0000000..5349ddd --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/Address/index.ts @@ -0,0 +1,9 @@ +export * from "./City"; +export * from "./Country"; +export * from "./GenericAddress"; +export * from "./PostalCode"; +export * from "./Province"; +export * from "./Street"; + +export * from "./AddressTitle"; +export * from "./AddressType"; diff --git a/shared/lib/contexts/common/domain/entities/AggregateRoot.ts b/shared/lib/contexts/common/domain/entities/AggregateRoot.ts new file mode 100644 index 0000000..9660b7a --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/AggregateRoot.ts @@ -0,0 +1,39 @@ +import { DomainEvents, IDomainEvent } from "../events"; +import { Entity } from "./Entity"; + +export abstract class AggregateRoot< + T extends { [s: string]: any }, +> extends Entity { + private _domainEvents: IDomainEvent[] = []; + + get domainEvents(): IDomainEvent[] { + return this._domainEvents; + } + + protected addDomainEvent(domainEvent: IDomainEvent): void { + // Add the domain event to this aggregate's list of domain events + this._domainEvents.push(domainEvent); + + // Add this aggregate instance to the domain event's list of aggregates who's + // events it eventually needs to dispatch. + DomainEvents.markAggregateForDispatch(this); + + // Log the domain event + this.logDomainEventAdded(domainEvent); + } + + public clearEvents(): void { + this._domainEvents.splice(0, this._domainEvents.length); + } + + private logDomainEventAdded(domainEvent: IDomainEvent): void { + const thisClass = Reflect.getPrototypeOf(this); + const domainEventClass = Reflect.getPrototypeOf(domainEvent); + console.info( + `[Domain Event Created]:`, + thisClass?.constructor.name, + "==>", + domainEventClass?.constructor.name, + ); + } +} diff --git a/shared/lib/contexts/common/domain/entities/Collection.ts b/shared/lib/contexts/common/domain/entities/Collection.ts new file mode 100644 index 0000000..fe8e9c2 --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/Collection.ts @@ -0,0 +1,113 @@ +export interface ICollection { + items: T[]; + totalCount: number; +} + +export class Collection implements ICollection { + protected _items: Map; + protected _totalCount: number | undefined = undefined; + protected _totalCountIsProvided = false; + + get totalCount(): number { + if (this._totalCountIsProvided) { + return Number(this._totalCount); + } + + return Array.from(this._items.values()).reduce( + (total, item) => (item !== undefined ? total + 1 : total), + 0, + ); + } + + get items(): T[] { + return Array.from(this._items.values()).filter((item) => item); + } + + constructor(initialValues?: T[], totalCount?: number) { + this._items = new Map( + initialValues + ? initialValues.map( + (value: any, index: number) => [index, value] as [number, T], + ) + : [], + ); + + this._totalCountIsProvided = typeof totalCount === "number"; + if (this._totalCountIsProvided) { + this._totalCount = Number(totalCount); + } + } + + private incTotalCount() { + if (this._totalCountIsProvided) { + this._totalCount = Number(this._totalCount) + 1; + } + } + + private decTotalCount() { + if (this._totalCountIsProvided) { + this._totalCount = Number(this._totalCount) - 1; + } + } + + private resetTotalCount() { + this._totalCount = 0; + } + + /*public static createEmpty() { + return new Collection([]); + } + + public static create(initialValues: any[], totalCount?: number) { + return new Collection(initialValues, totalCount); + }*/ + + public reset() { + this._items.clear(); + this.resetTotalCount(); + } + + public addItem(item: T) { + this._items.set(this._items.size, item); + this.incTotalCount(); + } + + public replaceItem(index: number, item: T) { + this._items.set(index, item); + } + + public removeByIndex(index: number) { + this._items.delete(index); // <--- this._items.set(index, undefined); + this.decTotalCount(); + } + + public addCollection(collection: ICollection) { + if (collection) { + collection.items.forEach((item: T) => { + this.addItem(item); + }); + } + } + + public find( + predicate: (value: T, index: number, obj: T[]) => unknown, + ): T | undefined { + return Array.from(this._items.values()).find(predicate); + } + + public toArray(): T[] { + return this.items; + } + + public toString(): string { + return this.items + .map((item) => (item !== undefined ? JSON.stringify(item) : undefined)) + .toString(); + } + + public toStringArray(): string[] { + return Array.from(this._items.values(), (element) => + JSON.stringify(element), + ).filter((element) => element.length > 0); + } +} diff --git a/shared/lib/contexts/common/domain/entities/Currency/Currency.ts b/shared/lib/contexts/common/domain/entities/Currency/Currency.ts new file mode 100644 index 0000000..f3e8bd7 --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/Currency/Currency.ts @@ -0,0 +1,79 @@ +import Joi from "joi"; +import { RuleValidator } from "../../RuleValidator"; +import { + INullableValueObjectOptions, + NullableValueObject, +} from "../NullableValueObject"; +import { Result } from "../Result"; +import { Currencies } from "./currencies"; + +export interface ICurrency { + symbol: string; + name: string; + symbol_native: string; + decimal_digits: number; + rounding: number; + code: string; + name_plural: string; +} + +export interface ICurrencyOptions extends INullableValueObjectOptions {} + +export class Currency extends NullableValueObject { + public static readonly DEFAULT_CURRENCY_CODE = "EUR"; + public static readonly CURRENCIES = Currencies; + + get symbol(): string { + return this.props ? String(this.props.symbol_native) : ""; + } + + get code(): string { + return this.props ? String(this.props.code) : ""; + } + + protected static validate(value: string, options: ICurrencyOptions) { + const rule = Joi.alternatives( + RuleValidator.RULE_ALLOW_EMPTY.default(""), + Joi.string() + .uppercase() + .valid(...Object.keys(Currencies)) + .label(String(options.label)), + ); + + return RuleValidator.validate(rule, value); + } + + public static createFromCode( + currencyCode: string, + options: ICurrencyOptions = {}, + ) { + const _options = { + ...options, + label: options.label ? options.label : "current_code", + }; + + const validationResult = Currency.validate(currencyCode, _options); + + if (validationResult.isFailure) { + return Result.fail(validationResult.error); + } + + return Result.ok(new Currency(Currencies[validationResult.object])); + } + + public static createDefaultCode() { + return Currency.createFromCode(Currency.DEFAULT_CURRENCY_CODE); + } + + public isEmpty(): boolean { + return this.isNull() || this.props === undefined; + } + + public toString = (): string => { + return this.code; + }; + + public toPrimitive(): string { + return this.toString(); + } +} diff --git a/shared/lib/contexts/common/domain/entities/Currency/currencies.ts b/shared/lib/contexts/common/domain/entities/Currency/currencies.ts new file mode 100644 index 0000000..01fd2c3 --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/Currency/currencies.ts @@ -0,0 +1,1082 @@ +export const Currencies = { + USD: { + symbol: "$", + name: "US Dollar", + symbol_native: "$", + decimal_digits: 2, + rounding: 0, + code: "USD", + name_plural: "US dollars", + }, + CAD: { + symbol: "CA$", + name: "Canadian Dollar", + symbol_native: "$", + decimal_digits: 2, + rounding: 0, + code: "CAD", + name_plural: "Canadian dollars", + }, + EUR: { + symbol: "€", + name: "Euro", + symbol_native: "€", + decimal_digits: 2, + rounding: 0, + code: "EUR", + name_plural: "euros", + }, + BTC: { + symbol: "BTC", + name: "Bitcoin", + symbol_native: "฿", + decimal_digits: 8, + rounding: 0, + code: "BTC", + name_plural: "Bitcoins", + }, + AED: { + symbol: "AED", + name: "United Arab Emirates Dirham", + symbol_native: "د.إ.‏", + decimal_digits: 2, + rounding: 0, + code: "AED", + name_plural: "UAE dirhams", + }, + AFN: { + symbol: "Af", + name: "Afghan Afghani", + symbol_native: "؋", + decimal_digits: 2, + rounding: 0, + code: "AFN", + name_plural: "Afghan Afghanis", + }, + ALL: { + symbol: "ALL", + name: "Albanian Lek", + symbol_native: "Lek", + decimal_digits: 2, + rounding: 0, + code: "ALL", + name_plural: "Albanian lekë", + }, + AMD: { + symbol: "AMD", + name: "Armenian Dram", + symbol_native: "դր.", + decimal_digits: 2, + rounding: 0, + code: "AMD", + name_plural: "Armenian drams", + }, + ARS: { + symbol: "AR$", + name: "Argentine Peso", + symbol_native: "$", + decimal_digits: 2, + rounding: 0, + code: "ARS", + name_plural: "Argentine pesos", + }, + AUD: { + symbol: "AU$", + name: "Australian Dollar", + symbol_native: "$", + decimal_digits: 2, + rounding: 0, + code: "AUD", + name_plural: "Australian dollars", + }, + AZN: { + symbol: "man.", + name: "Azerbaijani Manat", + symbol_native: "ман.", + decimal_digits: 2, + rounding: 0, + code: "AZN", + name_plural: "Azerbaijani manats", + }, + BAM: { + symbol: "KM", + name: "Bosnia-Herzegovina Convertible Mark", + symbol_native: "KM", + decimal_digits: 2, + rounding: 0, + code: "BAM", + name_plural: "Bosnia-Herzegovina convertible marks", + }, + BDT: { + symbol: "Tk", + name: "Bangladeshi Taka", + symbol_native: "৳", + decimal_digits: 2, + rounding: 0, + code: "BDT", + name_plural: "Bangladeshi takas", + }, + BGN: { + symbol: "BGN", + name: "Bulgarian Lev", + symbol_native: "лв.", + decimal_digits: 2, + rounding: 0, + code: "BGN", + name_plural: "Bulgarian leva", + }, + BHD: { + symbol: "BD", + name: "Bahraini Dinar", + symbol_native: "د.ب.‏", + decimal_digits: 3, + rounding: 0, + code: "BHD", + name_plural: "Bahraini dinars", + }, + BIF: { + symbol: "FBu", + name: "Burundian Franc", + symbol_native: "FBu", + decimal_digits: 0, + rounding: 0, + code: "BIF", + name_plural: "Burundian francs", + }, + BND: { + symbol: "BN$", + name: "Brunei Dollar", + symbol_native: "$", + decimal_digits: 2, + rounding: 0, + code: "BND", + name_plural: "Brunei dollars", + }, + BOB: { + symbol: "Bs", + name: "Bolivian Boliviano", + symbol_native: "Bs", + decimal_digits: 2, + rounding: 0, + code: "BOB", + name_plural: "Bolivian bolivianos", + }, + BRL: { + symbol: "R$", + name: "Brazilian Real", + symbol_native: "R$", + decimal_digits: 2, + rounding: 0, + code: "BRL", + name_plural: "Brazilian reals", + }, + BWP: { + symbol: "BWP", + name: "Botswanan Pula", + symbol_native: "P", + decimal_digits: 2, + rounding: 0, + code: "BWP", + name_plural: "Botswanan pulas", + }, + BYR: { + symbol: "BYR", + name: "Belarusian Ruble", + symbol_native: "BYR", + decimal_digits: 0, + rounding: 0, + code: "BYR", + name_plural: "Belarusian rubles", + }, + BZD: { + symbol: "BZ$", + name: "Belize Dollar", + symbol_native: "$", + decimal_digits: 2, + rounding: 0, + code: "BZD", + name_plural: "Belize dollars", + }, + CDF: { + symbol: "CDF", + name: "Congolese Franc", + symbol_native: "FrCD", + decimal_digits: 2, + rounding: 0, + code: "CDF", + name_plural: "Congolese francs", + }, + CHF: { + symbol: "CHF", + name: "Swiss Franc", + symbol_native: "CHF", + decimal_digits: 2, + rounding: 0.05, + code: "CHF", + name_plural: "Swiss francs", + }, + CLP: { + symbol: "CL$", + name: "Chilean Peso", + symbol_native: "$", + decimal_digits: 0, + rounding: 0, + code: "CLP", + name_plural: "Chilean pesos", + }, + CNY: { + symbol: "CN¥", + name: "Chinese Yuan", + symbol_native: "CN¥", + decimal_digits: 2, + rounding: 0, + code: "CNY", + name_plural: "Chinese yuan", + }, + COP: { + symbol: "CO$", + name: "Colombian Peso", + symbol_native: "$", + decimal_digits: 2, + rounding: 0, + code: "COP", + name_plural: "Colombian pesos", + }, + CRC: { + symbol: "₡", + name: "Costa Rican Colón", + symbol_native: "₡", + decimal_digits: 2, + rounding: 0, + code: "CRC", + name_plural: "Costa Rican colóns", + }, + CVE: { + symbol: "CV$", + name: "Cape Verdean Escudo", + symbol_native: "CV$", + decimal_digits: 2, + rounding: 0, + code: "CVE", + name_plural: "Cape Verdean escudos", + }, + CZK: { + symbol: "Kč", + name: "Czech Republic Koruna", + symbol_native: "Kč", + decimal_digits: 2, + rounding: 0, + code: "CZK", + name_plural: "Czech Republic korunas", + }, + DJF: { + symbol: "Fdj", + name: "Djiboutian Franc", + symbol_native: "Fdj", + decimal_digits: 0, + rounding: 0, + code: "DJF", + name_plural: "Djiboutian francs", + }, + DKK: { + symbol: "Dkr", + name: "Danish Krone", + symbol_native: "kr", + decimal_digits: 2, + rounding: 0, + code: "DKK", + name_plural: "Danish kroner", + }, + DOP: { + symbol: "RD$", + name: "Dominican Peso", + symbol_native: "RD$", + decimal_digits: 2, + rounding: 0, + code: "DOP", + name_plural: "Dominican pesos", + }, + DZD: { + symbol: "DA", + name: "Algerian Dinar", + symbol_native: "د.ج.‏", + decimal_digits: 2, + rounding: 0, + code: "DZD", + name_plural: "Algerian dinars", + }, + EEK: { + symbol: "Ekr", + name: "Estonian Kroon", + symbol_native: "kr", + decimal_digits: 2, + rounding: 0, + code: "EEK", + name_plural: "Estonian kroons", + }, + EGP: { + symbol: "EGP", + name: "Egyptian Pound", + symbol_native: "ج.م.‏", + decimal_digits: 2, + rounding: 0, + code: "EGP", + name_plural: "Egyptian pounds", + }, + ERN: { + symbol: "Nfk", + name: "Eritrean Nakfa", + symbol_native: "Nfk", + decimal_digits: 2, + rounding: 0, + code: "ERN", + name_plural: "Eritrean nakfas", + }, + ETB: { + symbol: "Br", + name: "Ethiopian Birr", + symbol_native: "Br", + decimal_digits: 2, + rounding: 0, + code: "ETB", + name_plural: "Ethiopian birrs", + }, + GBP: { + symbol: "£", + name: "British Pound Sterling", + symbol_native: "£", + decimal_digits: 2, + rounding: 0, + code: "GBP", + name_plural: "British pounds sterling", + }, + GEL: { + symbol: "GEL", + name: "Georgian Lari", + symbol_native: "GEL", + decimal_digits: 2, + rounding: 0, + code: "GEL", + name_plural: "Georgian laris", + }, + GHS: { + symbol: "GH₵", + name: "Ghanaian Cedi", + symbol_native: "GH₵", + decimal_digits: 2, + rounding: 0, + code: "GHS", + name_plural: "Ghanaian cedis", + }, + GNF: { + symbol: "FG", + name: "Guinean Franc", + symbol_native: "FG", + decimal_digits: 0, + rounding: 0, + code: "GNF", + name_plural: "Guinean francs", + }, + GTQ: { + symbol: "GTQ", + name: "Guatemalan Quetzal", + symbol_native: "Q", + decimal_digits: 2, + rounding: 0, + code: "GTQ", + name_plural: "Guatemalan quetzals", + }, + HKD: { + symbol: "HK$", + name: "Hong Kong Dollar", + symbol_native: "$", + decimal_digits: 2, + rounding: 0, + code: "HKD", + name_plural: "Hong Kong dollars", + }, + HNL: { + symbol: "HNL", + name: "Honduran Lempira", + symbol_native: "L", + decimal_digits: 2, + rounding: 0, + code: "HNL", + name_plural: "Honduran lempiras", + }, + HRK: { + symbol: "kn", + name: "Croatian Kuna", + symbol_native: "kn", + decimal_digits: 2, + rounding: 0, + code: "HRK", + name_plural: "Croatian kunas", + }, + HUF: { + symbol: "Ft", + name: "Hungarian Forint", + symbol_native: "Ft", + decimal_digits: 2, + rounding: 0, + code: "HUF", + name_plural: "Hungarian forints", + }, + IDR: { + symbol: "Rp", + name: "Indonesian Rupiah", + symbol_native: "Rp", + decimal_digits: 2, + rounding: 0, + code: "IDR", + name_plural: "Indonesian rupiahs", + }, + ILS: { + symbol: "₪", + name: "Israeli New Sheqel", + symbol_native: "₪", + decimal_digits: 2, + rounding: 0, + code: "ILS", + name_plural: "Israeli new sheqels", + }, + INR: { + symbol: "Rs", + name: "Indian Rupee", + symbol_native: "টকা", + decimal_digits: 2, + rounding: 0, + code: "INR", + name_plural: "Indian rupees", + }, + IQD: { + symbol: "IQD", + name: "Iraqi Dinar", + symbol_native: "د.ع.‏", + decimal_digits: 3, + rounding: 0, + code: "IQD", + name_plural: "Iraqi dinars", + }, + IRR: { + symbol: "IRR", + name: "Iranian Rial", + symbol_native: "﷼", + decimal_digits: 2, + rounding: 0, + code: "IRR", + name_plural: "Iranian rials", + }, + ISK: { + symbol: "Ikr", + name: "Icelandic Króna", + symbol_native: "kr", + decimal_digits: 0, + rounding: 0, + code: "ISK", + name_plural: "Icelandic krónur", + }, + JMD: { + symbol: "J$", + name: "Jamaican Dollar", + symbol_native: "$", + decimal_digits: 2, + rounding: 0, + code: "JMD", + name_plural: "Jamaican dollars", + }, + JOD: { + symbol: "JD", + name: "Jordanian Dinar", + symbol_native: "د.أ.‏", + decimal_digits: 3, + rounding: 0, + code: "JOD", + name_plural: "Jordanian dinars", + }, + JPY: { + symbol: "¥", + name: "Japanese Yen", + symbol_native: "¥", + decimal_digits: 0, + rounding: 0, + code: "JPY", + name_plural: "Japanese yen", + }, + KES: { + symbol: "Ksh", + name: "Kenyan Shilling", + symbol_native: "Ksh", + decimal_digits: 2, + rounding: 0, + code: "KES", + name_plural: "Kenyan shillings", + }, + KHR: { + symbol: "KHR", + name: "Cambodian Riel", + symbol_native: "៛", + decimal_digits: 2, + rounding: 0, + code: "KHR", + name_plural: "Cambodian riels", + }, + KMF: { + symbol: "CF", + name: "Comorian Franc", + symbol_native: "FC", + decimal_digits: 0, + rounding: 0, + code: "KMF", + name_plural: "Comorian francs", + }, + KRW: { + symbol: "₩", + name: "South Korean Won", + symbol_native: "₩", + decimal_digits: 0, + rounding: 0, + code: "KRW", + name_plural: "South Korean won", + }, + KWD: { + symbol: "KD", + name: "Kuwaiti Dinar", + symbol_native: "د.ك.‏", + decimal_digits: 3, + rounding: 0, + code: "KWD", + name_plural: "Kuwaiti dinars", + }, + KZT: { + symbol: "KZT", + name: "Kazakhstani Tenge", + symbol_native: "тңг.", + decimal_digits: 2, + rounding: 0, + code: "KZT", + name_plural: "Kazakhstani tenges", + }, + LAK: { + symbol: "₭", + name: "Lao kip", + symbol_native: "ກີບ", + decimal_digits: 2, + rounding: 0, + code: "LAK", + name_plural: "Lao kips", + }, + LBP: { + symbol: "LB£", + name: "Lebanese Pound", + symbol_native: "ل.ل.‏", + decimal_digits: 2, + rounding: 0, + code: "LBP", + name_plural: "Lebanese pounds", + }, + LKR: { + symbol: "SLRs", + name: "Sri Lankan Rupee", + symbol_native: "SL Re", + decimal_digits: 2, + rounding: 0, + code: "LKR", + name_plural: "Sri Lankan rupees", + }, + LTL: { + symbol: "Lt", + name: "Lithuanian Litas", + symbol_native: "Lt", + decimal_digits: 2, + rounding: 0, + code: "LTL", + name_plural: "Lithuanian litai", + }, + LVL: { + symbol: "Ls", + name: "Latvian Lats", + symbol_native: "Ls", + decimal_digits: 2, + rounding: 0, + code: "LVL", + name_plural: "Latvian lati", + }, + LYD: { + symbol: "LD", + name: "Libyan Dinar", + symbol_native: "د.ل.‏", + decimal_digits: 3, + rounding: 0, + code: "LYD", + name_plural: "Libyan dinars", + }, + MAD: { + symbol: "MAD", + name: "Moroccan Dirham", + symbol_native: "د.م.‏", + decimal_digits: 2, + rounding: 0, + code: "MAD", + name_plural: "Moroccan dirhams", + }, + MDL: { + symbol: "MDL", + name: "Moldovan Leu", + symbol_native: "MDL", + decimal_digits: 2, + rounding: 0, + code: "MDL", + name_plural: "Moldovan lei", + }, + MGA: { + symbol: "MGA", + name: "Malagasy Ariary", + symbol_native: "MGA", + decimal_digits: 2, + rounding: 0, + code: "MGA", + name_plural: "Malagasy Ariaries", + }, + MKD: { + symbol: "MKD", + name: "Macedonian Denar", + symbol_native: "MKD", + decimal_digits: 2, + rounding: 0, + code: "MKD", + name_plural: "Macedonian denari", + }, + MMK: { + symbol: "MMK", + name: "Myanma Kyat", + symbol_native: "K", + decimal_digits: 2, + rounding: 0, + code: "MMK", + name_plural: "Myanma kyats", + }, + MOP: { + symbol: "MOP$", + name: "Macanese Pataca", + symbol_native: "MOP$", + decimal_digits: 2, + rounding: 0, + code: "MOP", + name_plural: "Macanese patacas", + }, + MUR: { + symbol: "MURs", + name: "Mauritian Rupee", + symbol_native: "MURs", + decimal_digits: 2, + rounding: 0, + code: "MUR", + name_plural: "Mauritian rupees", + }, + MXN: { + symbol: "MX$", + name: "Mexican Peso", + symbol_native: "$", + decimal_digits: 2, + rounding: 0, + code: "MXN", + name_plural: "Mexican pesos", + }, + MYR: { + symbol: "RM", + name: "Malaysian Ringgit", + symbol_native: "RM", + decimal_digits: 2, + rounding: 0, + code: "MYR", + name_plural: "Malaysian ringgits", + }, + MZN: { + symbol: "MTn", + name: "Mozambican Metical", + symbol_native: "MTn", + decimal_digits: 2, + rounding: 0, + code: "MZN", + name_plural: "Mozambican meticals", + }, + NAD: { + symbol: "N$", + name: "Namibian Dollar", + symbol_native: "N$", + decimal_digits: 2, + rounding: 0, + code: "NAD", + name_plural: "Namibian dollars", + }, + NGN: { + symbol: "₦", + name: "Nigerian Naira", + symbol_native: "₦", + decimal_digits: 2, + rounding: 0, + code: "NGN", + name_plural: "Nigerian nairas", + }, + NIO: { + symbol: "C$", + name: "Nicaraguan Córdoba", + symbol_native: "C$", + decimal_digits: 2, + rounding: 0, + code: "NIO", + name_plural: "Nicaraguan córdobas", + }, + NOK: { + symbol: "Nkr", + name: "Norwegian Krone", + symbol_native: "kr", + decimal_digits: 2, + rounding: 0, + code: "NOK", + name_plural: "Norwegian kroner", + }, + NPR: { + symbol: "NPRs", + name: "Nepalese Rupee", + symbol_native: "नेरू", + decimal_digits: 2, + rounding: 0, + code: "NPR", + name_plural: "Nepalese rupees", + }, + NZD: { + symbol: "NZ$", + name: "New Zealand Dollar", + symbol_native: "$", + decimal_digits: 2, + rounding: 0, + code: "NZD", + name_plural: "New Zealand dollars", + }, + OMR: { + symbol: "OMR", + name: "Omani Rial", + symbol_native: "ر.ع.‏", + decimal_digits: 3, + rounding: 0, + code: "OMR", + name_plural: "Omani rials", + }, + PAB: { + symbol: "B/.", + name: "Panamanian Balboa", + symbol_native: "B/.", + decimal_digits: 2, + rounding: 0, + code: "PAB", + name_plural: "Panamanian balboas", + }, + PEN: { + symbol: "S/.", + name: "Peruvian Nuevo Sol", + symbol_native: "S/.", + decimal_digits: 2, + rounding: 0, + code: "PEN", + name_plural: "Peruvian nuevos soles", + }, + PHP: { + symbol: "₱", + name: "Philippine Peso", + symbol_native: "₱", + decimal_digits: 2, + rounding: 0, + code: "PHP", + name_plural: "Philippine pesos", + }, + PKR: { + symbol: "PKRs", + name: "Pakistani Rupee", + symbol_native: "₨", + decimal_digits: 2, + rounding: 0, + code: "PKR", + name_plural: "Pakistani rupees", + }, + PLN: { + symbol: "zł", + name: "Polish Zloty", + symbol_native: "zł", + decimal_digits: 2, + rounding: 0, + code: "PLN", + name_plural: "Polish zlotys", + }, + PYG: { + symbol: "₲", + name: "Paraguayan Guarani", + symbol_native: "₲", + decimal_digits: 0, + rounding: 0, + code: "PYG", + name_plural: "Paraguayan guaranis", + }, + QAR: { + symbol: "QR", + name: "Qatari Rial", + symbol_native: "ر.ق.‏", + decimal_digits: 2, + rounding: 0, + code: "QAR", + name_plural: "Qatari rials", + }, + RON: { + symbol: "RON", + name: "Romanian Leu", + symbol_native: "RON", + decimal_digits: 2, + rounding: 0, + code: "RON", + name_plural: "Romanian lei", + }, + RSD: { + symbol: "din.", + name: "Serbian Dinar", + symbol_native: "дин.", + decimal_digits: 2, + rounding: 0, + code: "RSD", + name_plural: "Serbian dinars", + }, + RUB: { + symbol: "RUB", + name: "Russian Ruble", + symbol_native: "₽", + decimal_digits: 2, + rounding: 0, + code: "RUB", + name_plural: "Russian rubles", + }, + RWF: { + symbol: "RWF", + name: "Rwandan Franc", + symbol_native: "FR", + decimal_digits: 0, + rounding: 0, + code: "RWF", + name_plural: "Rwandan francs", + }, + SAR: { + symbol: "SR", + name: "Saudi Riyal", + symbol_native: "ر.س.‏", + decimal_digits: 2, + rounding: 0, + code: "SAR", + name_plural: "Saudi riyals", + }, + SDG: { + symbol: "SDG", + name: "Sudanese Pound", + symbol_native: "SDG", + decimal_digits: 2, + rounding: 0, + code: "SDG", + name_plural: "Sudanese pounds", + }, + SEK: { + symbol: "Skr", + name: "Swedish Krona", + symbol_native: "kr", + decimal_digits: 2, + rounding: 0, + code: "SEK", + name_plural: "Swedish kronor", + }, + SGD: { + symbol: "S$", + name: "Singapore Dollar", + symbol_native: "$", + decimal_digits: 2, + rounding: 0, + code: "SGD", + name_plural: "Singapore dollars", + }, + SOS: { + symbol: "Ssh", + name: "Somali Shilling", + symbol_native: "Ssh", + decimal_digits: 2, + rounding: 0, + code: "SOS", + name_plural: "Somali shillings", + }, + SYP: { + symbol: "SY£", + name: "Syrian Pound", + symbol_native: "ل.س.‏", + decimal_digits: 2, + rounding: 0, + code: "SYP", + name_plural: "Syrian pounds", + }, + THB: { + symbol: "฿", + name: "Thai Baht", + symbol_native: "฿", + decimal_digits: 2, + rounding: 0, + code: "THB", + name_plural: "Thai baht", + }, + TND: { + symbol: "DT", + name: "Tunisian Dinar", + symbol_native: "د.ت.‏", + decimal_digits: 3, + rounding: 0, + code: "TND", + name_plural: "Tunisian dinars", + }, + TOP: { + symbol: "T$", + name: "Tongan Paʻanga", + symbol_native: "T$", + decimal_digits: 2, + rounding: 0, + code: "TOP", + name_plural: "Tongan paʻanga", + }, + TRY: { + symbol: "TL", + name: "Turkish Lira", + symbol_native: "TL", + decimal_digits: 2, + rounding: 0, + code: "TRY", + name_plural: "Turkish Lira", + }, + TTD: { + symbol: "TT$", + name: "Trinidad and Tobago Dollar", + symbol_native: "$", + decimal_digits: 2, + rounding: 0, + code: "TTD", + name_plural: "Trinidad and Tobago dollars", + }, + TWD: { + symbol: "NT$", + name: "New Taiwan Dollar", + symbol_native: "NT$", + decimal_digits: 2, + rounding: 0, + code: "TWD", + name_plural: "New Taiwan dollars", + }, + TZS: { + symbol: "TSh", + name: "Tanzanian Shilling", + symbol_native: "TSh", + decimal_digits: 2, + rounding: 0, + code: "TZS", + name_plural: "Tanzanian shillings", + }, + UAH: { + symbol: "₴", + name: "Ukrainian Hryvnia", + symbol_native: "₴", + decimal_digits: 2, + rounding: 0, + code: "UAH", + name_plural: "Ukrainian hryvnias", + }, + UGX: { + symbol: "USh", + name: "Ugandan Shilling", + symbol_native: "USh", + decimal_digits: 0, + rounding: 0, + code: "UGX", + name_plural: "Ugandan shillings", + }, + UYU: { + symbol: "$U", + name: "Uruguayan Peso", + symbol_native: "$", + decimal_digits: 2, + rounding: 0, + code: "UYU", + name_plural: "Uruguayan pesos", + }, + UZS: { + symbol: "UZS", + name: "Uzbekistan Som", + symbol_native: "UZS", + decimal_digits: 2, + rounding: 0, + code: "UZS", + name_plural: "Uzbekistan som", + }, + VEF: { + symbol: "Bs.F.", + name: "Venezuelan Bolívar", + symbol_native: "Bs.F.", + decimal_digits: 2, + rounding: 0, + code: "VEF", + name_plural: "Venezuelan bolívars", + }, + VND: { + symbol: "₫", + name: "Vietnamese Dong", + symbol_native: "₫", + decimal_digits: 0, + rounding: 0, + code: "VND", + name_plural: "Vietnamese dong", + }, + XAF: { + symbol: "FCFA", + name: "CFA Franc BEAC", + symbol_native: "FCFA", + decimal_digits: 0, + rounding: 0, + code: "XAF", + name_plural: "CFA francs BEAC", + }, + XOF: { + symbol: "CFA", + name: "CFA Franc BCEAO", + symbol_native: "CFA", + decimal_digits: 0, + rounding: 0, + code: "XOF", + name_plural: "CFA francs BCEAO", + }, + YER: { + symbol: "YR", + name: "Yemeni Rial", + symbol_native: "ر.ي.‏", + decimal_digits: 2, + rounding: 0, + code: "YER", + name_plural: "Yemeni rials", + }, + ZAR: { + symbol: "R", + name: "South African Rand", + symbol_native: "R", + decimal_digits: 2, + rounding: 0, + code: "ZAR", + name_plural: "South African rand", + }, + ZMK: { + symbol: "ZK", + name: "Zambian Kwacha", + symbol_native: "ZK", + decimal_digits: 0, + rounding: 0, + code: "ZMK", + name_plural: "Zambian kwachas", + }, +}; diff --git a/shared/lib/contexts/common/domain/entities/Currency/index.ts b/shared/lib/contexts/common/domain/entities/Currency/index.ts new file mode 100644 index 0000000..0748ff3 --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/Currency/index.ts @@ -0,0 +1 @@ +export * from "./Currency"; diff --git a/shared/lib/contexts/common/domain/entities/Description.ts b/shared/lib/contexts/common/domain/entities/Description.ts new file mode 100644 index 0000000..263cf2d --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/Description.ts @@ -0,0 +1,40 @@ +import Joi from "joi"; +import { UndefinedOr } from "../../../../utilities"; +import { RuleValidator } from "../RuleValidator"; +import { Result } from "./Result"; +import { + IStringValueObjectOptions, + StringValueObject, +} from "./StringValueObject"; + +export class Description extends StringValueObject { + protected static validate( + value: UndefinedOr, + options: IStringValueObjectOptions + ) { + const ruleIsEmpty = Joi.string() + .optional() + .default("") + .label(String(options.label)); + + return RuleValidator.validate(ruleIsEmpty, value); + } + + public static create( + value: UndefinedOr, + options: IStringValueObjectOptions = {} + ) { + const _options = { + label: "description", + ...options, + }; + + const validationResult = Description.validate(value, _options); + + if (validationResult.isFailure) { + return Result.fail(validationResult.error); + } + + return Result.ok(new Description(validationResult.object)); + } +} diff --git a/shared/lib/contexts/common/domain/entities/Email.ts b/shared/lib/contexts/common/domain/entities/Email.ts new file mode 100644 index 0000000..6114052 --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/Email.ts @@ -0,0 +1,53 @@ +import Joi from "joi"; + +import { UndefinedOr } from "../../../../utilities"; + +import { RuleValidator } from "../RuleValidator"; +import { DomainError, handleDomainError } from "../errors"; +import { Result } from "./Result"; +import { + IStringValueObjectOptions, + StringValueObject, +} from "./StringValueObject"; + +export class Email extends StringValueObject { + protected static validate( + value: UndefinedOr, + options: IStringValueObjectOptions + ) { + const rule = Joi.string() + .allow(null) + .allow("") + .default("") + .email({ + tlds: { allow: false }, + }) + .label(options.label ? options.label : "value"); + + return RuleValidator.validate(rule, value); + } + + public static create( + value: UndefinedOr, + options: IStringValueObjectOptions = {} + ) { + const _options = { + label: "email", + ...options, + }; + + const validationResult = Email.validate(value, _options); + + if (validationResult.isFailure) { + return Result.fail( + handleDomainError( + DomainError.INVALID_INPUT_DATA, + validationResult.error + ) + ); + } + return Result.ok(new Email(validationResult.object)); + } +} + +export class Email_ValidationError extends Joi.ValidationError {} diff --git a/shared/lib/contexts/common/domain/entities/Entity.ts b/shared/lib/contexts/common/domain/entities/Entity.ts new file mode 100644 index 0000000..3c1f2dc --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/Entity.ts @@ -0,0 +1,70 @@ +import { UniqueID } from "./UniqueID"; + +const isEntity = (v: any): v is Entity => { + return v instanceof Entity; +}; + +export interface IEntityProps { + [s: string]: any; +} + +export abstract class Entity { + protected readonly _id: UniqueID; + protected readonly props: T; + + public get id(): UniqueID { + return this._id; + } + + constructor(props: T, id?: UniqueID) { + this._id = id ? id : UniqueID.generateNewID().object; + this.props = props; + } + + public equals(object?: Entity): boolean { + if (object === null || object === undefined) { + return false; + } + + if (this === object) { + return true; + } + + if (!isEntity(object)) { + return false; + } + + return this._id.equals(object.id); + } + + public toString(): { [s: string]: string } { + const flattenProps = this._flattenProps(this.props); + + console.log(flattenProps); + + return { + id: this._id.toString(), + ...flattenProps.map((prop: any) => String(prop)), + }; + } + + public toPrimitives(): { [s: string]: any } { + const flattenProps = this._flattenProps(this.props); + + console.log(flattenProps); + + return { + id: this._id.value, + ...flattenProps, + }; + } + + protected _flattenProps(props: T): { [s: string]: any } { + return Object.entries(props).reduce((result, [key, valueObject]) => { + console.log(key, valueObject.value); + result[key] = valueObject.value; + + return result; + }, {}); + } +} diff --git a/shared/lib/contexts/common/domain/entities/Language/Language.ts b/shared/lib/contexts/common/domain/entities/Language/Language.ts new file mode 100644 index 0000000..64b1077 --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/Language/Language.ts @@ -0,0 +1,98 @@ +import { RuleValidator } from "../../RuleValidator"; +import { Result } from "../Result"; + +import Joi from "joi"; + +import { UndefinedOr } from "../../../../../utilities"; +import { + INullableValueObjectOptions, + NullableValueObject, +} from "../NullableValueObject"; +import { LANGUAGES_LIST } from "./languages_data"; + +export interface ILanguage { + code: string; + name: string; + nativeName: string; +} + +export interface ILanguageOptions extends INullableValueObjectOptions {} + +export class Language extends NullableValueObject { + public static readonly DEFAULT_LANGUAGE_CODE = "es"; + public static readonly LANGUAGES = LANGUAGES_LIST; + + protected static validate( + value: UndefinedOr, + options: INullableValueObjectOptions, + ) { + const rule = Joi.alternatives( + RuleValidator.RULE_ALLOW_EMPTY.default(""), + Joi.string() + .lowercase() + .valid(...Object.keys(LANGUAGES_LIST)) + .label(String(options.label)), + ); + + return RuleValidator.validate(rule, value); + } + + private static sanitize(value: UndefinedOr) { + return value ? String(value).toLowerCase() : undefined; + } + + public static createFromCode( + languageCode: string, + options: ILanguageOptions = {}, + ) { + const _options = { + ...options, + label: options.label ? options.label : "language_code", + }; + + const validationResult = Language.validate(languageCode, _options); + + if (validationResult.isFailure) { + return Result.fail(validationResult.error); + } + + const code = Language.sanitize(validationResult.object); + + const props = code + ? { + ...LANGUAGES_LIST[validationResult.object], + code, + } + : undefined; + + return Result.ok(new Language(props)); + } + + public static createDefaultCode() { + return Language.createFromCode(this.DEFAULT_LANGUAGE_CODE); + } + + get name(): string { + return this.props ? String(this.props.name) : ""; + } + + get nativeName(): string { + return this.props ? String(this.props.nativeName) : ""; + } + + get code(): string { + return this.props ? String(this.props.code) : ""; + } + + public isEmpty(): boolean { + return this.isNull() || this.props === undefined; + } + + public toString = (): string => { + return this.code; + }; + + public toPrimitive(): string { + return this.toString(); + } +} diff --git a/shared/lib/contexts/common/domain/entities/Language/index.ts b/shared/lib/contexts/common/domain/entities/Language/index.ts new file mode 100644 index 0000000..52ed721 --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/Language/index.ts @@ -0,0 +1 @@ +export * from "./Language"; diff --git a/shared/lib/contexts/common/domain/entities/Language/languages_data.ts b/shared/lib/contexts/common/domain/entities/Language/languages_data.ts new file mode 100644 index 0000000..2e296d0 --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/Language/languages_data.ts @@ -0,0 +1,738 @@ +// https://github.com/meikidd/iso-639-1/ + +const LANGUAGES_LIST = { + aa: { + name: "Afar", + nativeName: "Afaraf", + }, + ab: { + name: "Abkhaz", + nativeName: "аҧсуа бызшәа", + }, + ae: { + name: "Avestan", + nativeName: "avesta", + }, + af: { + name: "Afrikaans", + nativeName: "Afrikaans", + }, + ak: { + name: "Akan", + nativeName: "Akan", + }, + am: { + name: "Amharic", + nativeName: "አማርኛ", + }, + an: { + name: "Aragonese", + nativeName: "aragonés", + }, + ar: { + name: "Arabic", + nativeName: "اَلْعَرَبِيَّةُ", + }, + as: { + name: "Assamese", + nativeName: "অসমীয়া", + }, + av: { + name: "Avaric", + nativeName: "авар мацӀ", + }, + ay: { + name: "Aymara", + nativeName: "aymar aru", + }, + az: { + name: "Azerbaijani", + nativeName: "azərbaycan dili", + }, + ba: { + name: "Bashkir", + nativeName: "башҡорт теле", + }, + be: { + name: "Belarusian", + nativeName: "беларуская мова", + }, + bg: { + name: "Bulgarian", + nativeName: "български език", + }, + bi: { + name: "Bislama", + nativeName: "Bislama", + }, + bm: { + name: "Bambara", + nativeName: "bamanankan", + }, + bn: { + name: "Bengali", + nativeName: "বাংলা", + }, + bo: { + name: "Tibetan", + nativeName: "བོད་ཡིག", + }, + br: { + name: "Breton", + nativeName: "brezhoneg", + }, + bs: { + name: "Bosnian", + nativeName: "bosanski jezik", + }, + ca: { + name: "Catalan", + nativeName: "Català", + }, + ce: { + name: "Chechen", + nativeName: "нохчийн мотт", + }, + ch: { + name: "Chamorro", + nativeName: "Chamoru", + }, + co: { + name: "Corsican", + nativeName: "corsu", + }, + cr: { + name: "Cree", + nativeName: "ᓀᐦᐃᔭᐍᐏᐣ", + }, + cs: { + name: "Czech", + nativeName: "čeština", + }, + cu: { + name: "Old Church Slavonic", + nativeName: "ѩзыкъ словѣньскъ", + }, + cv: { + name: "Chuvash", + nativeName: "чӑваш чӗлхи", + }, + cy: { + name: "Welsh", + nativeName: "Cymraeg", + }, + da: { + name: "Danish", + nativeName: "dansk", + }, + de: { + name: "German", + nativeName: "Deutsch", + }, + dv: { + name: "Divehi", + nativeName: "ދިވެހި", + }, + dz: { + name: "Dzongkha", + nativeName: "རྫོང་ཁ", + }, + ee: { + name: "Ewe", + nativeName: "Eʋegbe", + }, + el: { + name: "Greek", + nativeName: "Ελληνικά", + }, + en: { + name: "English", + nativeName: "English", + }, + eo: { + name: "Esperanto", + nativeName: "Esperanto", + }, + es: { + name: "Spanish", + nativeName: "Español", + }, + et: { + name: "Estonian", + nativeName: "eesti", + }, + eu: { + name: "Basque", + nativeName: "euskara", + }, + fa: { + name: "Persian", + nativeName: "فارسی", + }, + ff: { + name: "Fula", + nativeName: "Fulfulde", + }, + fi: { + name: "Finnish", + nativeName: "suomi", + }, + fj: { + name: "Fijian", + nativeName: "vosa Vakaviti", + }, + fo: { + name: "Faroese", + nativeName: "føroyskt", + }, + fr: { + name: "French", + nativeName: "Français", + }, + fy: { + name: "Western Frisian", + nativeName: "Frysk", + }, + ga: { + name: "Irish", + nativeName: "Gaeilge", + }, + gd: { + name: "Scottish Gaelic", + nativeName: "Gàidhlig", + }, + gl: { + name: "Galician", + nativeName: "galego", + }, + gn: { + name: "Guaraní", + nativeName: "Avañe'ẽ", + }, + gu: { + name: "Gujarati", + nativeName: "ગુજરાતી", + }, + gv: { + name: "Manx", + nativeName: "Gaelg", + }, + ha: { + name: "Hausa", + nativeName: "هَوُسَ", + }, + he: { + name: "Hebrew", + nativeName: "עברית", + }, + hi: { + name: "Hindi", + nativeName: "हिन्दी", + }, + ho: { + name: "Hiri Motu", + nativeName: "Hiri Motu", + }, + hr: { + name: "Croatian", + nativeName: "Hrvatski", + }, + ht: { + name: "Haitian", + nativeName: "Kreyòl ayisyen", + }, + hu: { + name: "Hungarian", + nativeName: "magyar", + }, + hy: { + name: "Armenian", + nativeName: "Հայերեն", + }, + hz: { + name: "Herero", + nativeName: "Otjiherero", + }, + ia: { + name: "Interlingua", + nativeName: "Interlingua", + }, + id: { + name: "Indonesian", + nativeName: "Bahasa Indonesia", + }, + ie: { + name: "Interlingue", + nativeName: "Interlingue", + }, + ig: { + name: "Igbo", + nativeName: "Asụsụ Igbo", + }, + ii: { + name: "Nuosu", + nativeName: "ꆈꌠ꒿ Nuosuhxop", + }, + ik: { + name: "Inupiaq", + nativeName: "Iñupiaq", + }, + io: { + name: "Ido", + nativeName: "Ido", + }, + is: { + name: "Icelandic", + nativeName: "Íslenska", + }, + it: { + name: "Italian", + nativeName: "Italiano", + }, + iu: { + name: "Inuktitut", + nativeName: "ᐃᓄᒃᑎᑐᑦ", + }, + ja: { + name: "Japanese", + nativeName: "日本語", + }, + jv: { + name: "Javanese", + nativeName: "basa Jawa", + }, + ka: { + name: "Georgian", + nativeName: "ქართული", + }, + kg: { + name: "Kongo", + nativeName: "Kikongo", + }, + ki: { + name: "Kikuyu", + nativeName: "Gĩkũyũ", + }, + kj: { + name: "Kwanyama", + nativeName: "Kuanyama", + }, + kk: { + name: "Kazakh", + nativeName: "қазақ тілі", + }, + kl: { + name: "Kalaallisut", + nativeName: "kalaallisut", + }, + km: { + name: "Khmer", + nativeName: "ខេមរភាសា", + }, + kn: { + name: "Kannada", + nativeName: "ಕನ್ನಡ", + }, + ko: { + name: "Korean", + nativeName: "한국어", + }, + kr: { + name: "Kanuri", + nativeName: "Kanuri", + }, + ks: { + name: "Kashmiri", + nativeName: "कश्मीरी", + }, + ku: { + name: "Kurdish", + nativeName: "Kurdî", + }, + kv: { + name: "Komi", + nativeName: "коми кыв", + }, + kw: { + name: "Cornish", + nativeName: "Kernewek", + }, + ky: { + name: "Kyrgyz", + nativeName: "Кыргызча", + }, + la: { + name: "Latin", + nativeName: "latine", + }, + lb: { + name: "Luxembourgish", + nativeName: "Lëtzebuergesch", + }, + lg: { + name: "Ganda", + nativeName: "Luganda", + }, + li: { + name: "Limburgish", + nativeName: "Limburgs", + }, + ln: { + name: "Lingala", + nativeName: "Lingála", + }, + lo: { + name: "Lao", + nativeName: "ພາສາລາວ", + }, + lt: { + name: "Lithuanian", + nativeName: "lietuvių kalba", + }, + lu: { + name: "Luba-Katanga", + nativeName: "Kiluba", + }, + lv: { + name: "Latvian", + nativeName: "latviešu valoda", + }, + mg: { + name: "Malagasy", + nativeName: "fiteny malagasy", + }, + mh: { + name: "Marshallese", + nativeName: "Kajin M̧ajeļ", + }, + mi: { + name: "Māori", + nativeName: "te reo Māori", + }, + mk: { + name: "Macedonian", + nativeName: "македонски јазик", + }, + ml: { + name: "Malayalam", + nativeName: "മലയാളം", + }, + mn: { + name: "Mongolian", + nativeName: "Монгол хэл", + }, + mr: { + name: "Marathi", + nativeName: "मराठी", + }, + ms: { + name: "Malay", + nativeName: "Bahasa Melayu", + }, + mt: { + name: "Maltese", + nativeName: "Malti", + }, + my: { + name: "Burmese", + nativeName: "ဗမာစာ", + }, + na: { + name: "Nauru", + nativeName: "Dorerin Naoero", + }, + nb: { + name: "Norwegian Bokmål", + nativeName: "Norsk bokmål", + }, + nd: { + name: "Northern Ndebele", + nativeName: "isiNdebele", + }, + ne: { + name: "Nepali", + nativeName: "नेपाली", + }, + ng: { + name: "Ndonga", + nativeName: "Owambo", + }, + nl: { + name: "Dutch", + nativeName: "Nederlands", + }, + nn: { + name: "Norwegian Nynorsk", + nativeName: "Norsk nynorsk", + }, + no: { + name: "Norwegian", + nativeName: "Norsk", + }, + nr: { + name: "Southern Ndebele", + nativeName: "isiNdebele", + }, + nv: { + name: "Navajo", + nativeName: "Diné bizaad", + }, + ny: { + name: "Chichewa", + nativeName: "chiCheŵa", + }, + oc: { + name: "Occitan", + nativeName: "occitan", + }, + oj: { + name: "Ojibwe", + nativeName: "ᐊᓂᔑᓈᐯᒧᐎᓐ", + }, + om: { + name: "Oromo", + nativeName: "Afaan Oromoo", + }, + or: { + name: "Oriya", + nativeName: "ଓଡ଼ିଆ", + }, + os: { + name: "Ossetian", + nativeName: "ирон æвзаг", + }, + pa: { + name: "Panjabi", + nativeName: "ਪੰਜਾਬੀ", + }, + pi: { + name: "Pāli", + nativeName: "पाऴि", + }, + pl: { + name: "Polish", + nativeName: "Polski", + }, + ps: { + name: "Pashto", + nativeName: "پښتو", + }, + pt: { + name: "Portuguese", + nativeName: "Português", + }, + qu: { + name: "Quechua", + nativeName: "Runa Simi", + }, + rm: { + name: "Romansh", + nativeName: "rumantsch grischun", + }, + rn: { + name: "Kirundi", + nativeName: "Ikirundi", + }, + ro: { + name: "Romanian", + nativeName: "Română", + }, + ru: { + name: "Russian", + nativeName: "Русский", + }, + rw: { + name: "Kinyarwanda", + nativeName: "Ikinyarwanda", + }, + sa: { + name: "Sanskrit", + nativeName: "संस्कृतम्", + }, + sc: { + name: "Sardinian", + nativeName: "sardu", + }, + sd: { + name: "Sindhi", + nativeName: "सिन्धी", + }, + se: { + name: "Northern Sami", + nativeName: "Davvisámegiella", + }, + sg: { + name: "Sango", + nativeName: "yângâ tî sängö", + }, + si: { + name: "Sinhala", + nativeName: "සිංහල", + }, + sk: { + name: "Slovak", + nativeName: "slovenčina", + }, + sl: { + name: "Slovenian", + nativeName: "slovenščina", + }, + sm: { + name: "Samoan", + nativeName: "gagana fa'a Samoa", + }, + sn: { + name: "Shona", + nativeName: "chiShona", + }, + so: { + name: "Somali", + nativeName: "Soomaaliga", + }, + sq: { + name: "Albanian", + nativeName: "Shqip", + }, + sr: { + name: "Serbian", + nativeName: "српски језик", + }, + ss: { + name: "Swati", + nativeName: "SiSwati", + }, + st: { + name: "Southern Sotho", + nativeName: "Sesotho", + }, + su: { + name: "Sundanese", + nativeName: "Basa Sunda", + }, + sv: { + name: "Swedish", + nativeName: "Svenska", + }, + sw: { + name: "Swahili", + nativeName: "Kiswahili", + }, + ta: { + name: "Tamil", + nativeName: "தமிழ்", + }, + te: { + name: "Telugu", + nativeName: "తెలుగు", + }, + tg: { + name: "Tajik", + nativeName: "тоҷикӣ", + }, + th: { + name: "Thai", + nativeName: "ไทย", + }, + ti: { + name: "Tigrinya", + nativeName: "ትግርኛ", + }, + tk: { + name: "Turkmen", + nativeName: "Türkmençe", + }, + tl: { + name: "Tagalog", + nativeName: "Wikang Tagalog", + }, + tn: { + name: "Tswana", + nativeName: "Setswana", + }, + to: { + name: "Tonga", + nativeName: "faka Tonga", + }, + tr: { + name: "Turkish", + nativeName: "Türkçe", + }, + ts: { + name: "Tsonga", + nativeName: "Xitsonga", + }, + tt: { + name: "Tatar", + nativeName: "татар теле", + }, + tw: { + name: "Twi", + nativeName: "Twi", + }, + ty: { + name: "Tahitian", + nativeName: "Reo Tahiti", + }, + ug: { + name: "Uyghur", + nativeName: "ئۇيغۇرچە‎", + }, + uk: { + name: "Ukrainian", + nativeName: "Українська", + }, + ur: { + name: "Urdu", + nativeName: "اردو", + }, + uz: { + name: "Uzbek", + nativeName: "Ўзбек", + }, + ve: { + name: "Venda", + nativeName: "Tshivenḓa", + }, + vi: { + name: "Vietnamese", + nativeName: "Tiếng Việt", + }, + vo: { + name: "Volapük", + nativeName: "Volapük", + }, + wa: { + name: "Walloon", + nativeName: "walon", + }, + wo: { + name: "Wolof", + nativeName: "Wollof", + }, + xh: { + name: "Xhosa", + nativeName: "isiXhosa", + }, + yi: { + name: "Yiddish", + nativeName: "ייִדיש", + }, + yo: { + name: "Yoruba", + nativeName: "Yorùbá", + }, + za: { + name: "Zhuang", + nativeName: "Saɯ cueŋƅ", + }, + zh: { + name: "Chinese", + nativeName: "中文", + }, + zu: { + name: "Zulu", + nativeName: "isiZulu", + }, +}; + +export { LANGUAGES_LIST }; diff --git a/shared/lib/contexts/common/domain/entities/Measure.ts b/shared/lib/contexts/common/domain/entities/Measure.ts new file mode 100644 index 0000000..df34d00 --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/Measure.ts @@ -0,0 +1,38 @@ +import { UndefinedOr } from "../../../../utilities"; +import { RuleValidator } from "../RuleValidator"; +import { Result } from "./Result"; +import { + IStringValueObjectOptions, + StringValueObject, +} from "./StringValueObject"; + +export class Measure extends StringValueObject { + protected static validate( + value: UndefinedOr, + options: IStringValueObjectOptions, + ) { + const ruleIsEmpty = RuleValidator.RULE_ALLOW_EMPTY.default("").label( + String(options.label), + ); + + return RuleValidator.validate(ruleIsEmpty, value); + } + + public static create( + value: UndefinedOr, + options: IStringValueObjectOptions = {}, + ) { + const _options = { + label: "description", + ...options, + }; + + const validationResult = Measure.validate(value, _options); + + if (validationResult.isFailure) { + return Result.fail(validationResult.error); + } + + return Result.ok(new Measure(validationResult.object)); + } +} diff --git a/shared/lib/contexts/common/domain/entities/MoneyValue.test.ts b/shared/lib/contexts/common/domain/entities/MoneyValue.test.ts new file mode 100644 index 0000000..a29970d --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/MoneyValue.test.ts @@ -0,0 +1,109 @@ +import { MoneyValue } from "./MoneyValue"; // Asegúrate de importar correctamente la clase MoneyValue. + +describe("MoneyValue Value Object", () => { + // Prueba la creación de un valor monetario válido. + it("Should create a valid money value (number)", () => { + const validMoneyValue = MoneyValue.create({ + amount: 10055, + }); + + expect(validMoneyValue.isSuccess).toBe(true); + expect(validMoneyValue.object.toUnit()).toBe(100.55); + }); + + // Prueba la creación de un valor monetario nulo. + it("Should create a valid null money value", () => { + const nullMoneyValue = MoneyValue.create({ + amount: null + }); + + expect(nullMoneyValue.isSuccess).toBe(true); + expect(nullMoneyValue.object.isEmpty()).toBe(true); + }); + + // Prueba la creación de un valor monetario válido a partir de una cadena. + it("Should create a valid money value from string and format it", () => { + const validMoneyValueFromString = MoneyValue.create({ + amount: "5075", + }); + + expect(validMoneyValueFromString.isSuccess).toBe(true); + expect(validMoneyValueFromString.object.toString()).toEqual("50,75 €"); + }); + + // Prueba la creación de un valor monetario con una cadena no válida. + it("Should fail to create money value from invalid string", () => { + const invalidMoneyValueFromString = MoneyValue.create({ + amount: "invalid", + }); + + expect(invalidMoneyValueFromString.isFailure).toBe(true); + }); + + it("should create MoneyValue from number and currency", () => { + const result = MoneyValue.create({ + amount: 100, + precision: 3, + currencyCode: 'EUR' + }); + expect(result.isSuccess).toBe(true); + + const moneyValue = result.object; + + expect(moneyValue.getAmount()).toBe(100); + expect(moneyValue.getCurrency().code).toBe("EUR"); + expect(moneyValue.getPrecision()).toBe(3); + }); + + it("should create MoneyValue from string and currency", () => { + const result = MoneyValue.create({ + amount: "12345", + precision: 2, + currencyCode: 'USD' + }); + expect(result.isSuccess).toBe(true); + + const moneyValue = result.object; + + expect(moneyValue.getAmount()).toBe(12345); + expect(moneyValue.getCurrency().code).toBe("USD"); + expect(moneyValue.getPrecision()).toBe(2); + }); + + it("should fail to create MoneyValue with invalid amount", () => { + const result = MoneyValue.create({ + amount: "invalid", + precision: 2, + currencyCode: 'USD' + }); + expect(result.isFailure).toBe(true); + }); + + // Prueba la conversión a cadena. + it("Should convert to string", () => { + const moneyValue = MoneyValue.create({ + amount: 7525, + }).object; + const result = moneyValue.toString(); + + expect(result).toBe("7525"); + }); + + // Prueba la verificación de valor nulo. + it("Should check if value is null", () => { + expect(() => MoneyValue.create(null)).toThrowError(); + }); + + // Prueba la verificación de valor cero. + it("Should check if value is zero", () => { + const zeroMoneyValue = MoneyValue.create(({ + amount: 0, + })).object; + const nonZeroMoneyValue = MoneyValue.create(({ + amount: 50, + })).object; + + expect(zeroMoneyValue.isZero()).toBe(true); + expect(nonZeroMoneyValue.isZero()).toBe(false); + }); +}); diff --git a/shared/lib/contexts/common/domain/entities/MoneyValue.ts b/shared/lib/contexts/common/domain/entities/MoneyValue.ts new file mode 100644 index 0000000..6517052 --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/MoneyValue.ts @@ -0,0 +1,357 @@ +/* eslint-disable no-use-before-define */ +import DineroFactory, { Dinero } from "dinero.js"; + +import { Currency } from "./Currency"; + +import Joi from "joi"; +import { isNull } from "lodash"; +import { NullOr } from "../../../../utilities"; +import { RuleValidator } from "../RuleValidator"; +import { Result } from "./Result"; +import { IValueObjectOptions, ValueObject } from "./ValueObject"; + +export interface IMoneyValueOptions extends IValueObjectOptions { + locale: string; +} + +export const defaultMoneyValueOptions: IMoneyValueOptions = { + locale: "es-ES", +}; + +export interface MoneyValueObject { + amount: number; + precision: number; + currency: string; +} + +type RoundingMode = + | "HALF_ODD" + | "HALF_EVEN" + | "HALF_UP" + | "HALF_DOWN" + | "HALF_TOWARDS_ZERO" + | "HALF_AWAY_FROM_ZERO" + | "DOWN"; + +export { RoundingMode }; + +export interface IMoneyValueProps { + amount: NullOr; + currencyCode?: string; + precision?: number; +} + +const defaultMoneyValueProps = { + amount: 0, + currencyCode: Currency.DEFAULT_CURRENCY_CODE, + precision: 2, +}; + +interface IMoneyValue { + toPrimitive(): number; + toPrimitives(): MoneyValueObject; + isEmpty(): boolean; + toString(): string; + toJSON(): MoneyValueObject | {}; + isNull(): boolean; + + getAmount(): number; + getCurrency(): Currency; + getLocale(): string; + getPrecision(): number; + convertPrecision( + newPrecision: number, + roundingMode?: RoundingMode, + ): MoneyValue; + + add(addend: MoneyValue): MoneyValue; + subtract(subtrahend: MoneyValue): MoneyValue; + multiply(multiplier: number, roundingMode?: RoundingMode): MoneyValue; + divide(divisor: number, roundingMode?: RoundingMode): MoneyValue; + percentage(percentage: number, roundingMode?: RoundingMode): MoneyValue; + allocate(ratios: ReadonlyArray): MoneyValue[]; + + equalsTo(comparator: MoneyValue): boolean; + lessThan(comparator: MoneyValue): boolean; + lessThanOrEqual(comparator: MoneyValue): boolean; + greaterThan(comparator: MoneyValue): boolean; + greaterThanOrEqual(comparator: MoneyValue): boolean; + isZero(): boolean; + isPositive(): boolean; + isNegative(): boolean; + + hasSameCurrency(comparator: MoneyValue): boolean; + hasSameAmount(comparator: MoneyValue): boolean; + toFormat(format?: string, roundingMode?: RoundingMode): string; + toUnit(): number; + toRoundedUnit(digits: number, roundingMode?: RoundingMode): number; + toObject(): MoneyValueObject; + toNumber(): number; +} + +export class MoneyValue extends ValueObject implements IMoneyValue { + private static readonly MIN_VALUE = Number.MIN_VALUE; + private static readonly MAX_VALUE = Number.MAX_VALUE; + + private readonly _isNull: boolean; + private readonly _options: IMoneyValueOptions; + + protected static validate( + amount: NullOr, + options: IMoneyValueOptions, + ) { + const ruleNull = Joi.any() + .optional() // <- undefined + .valid(null); // <- null + + const ruleNumber = Joi.number() + .optional() + .default(0) + .min(-1000) + .max(this.MAX_VALUE) + .label(options.label ? options.label : "amount"); + + const rules = Joi.alternatives(ruleNull, ruleNumber); + + return RuleValidator.validate>(rules, amount); + } + + protected static getMonetaryValueInfo(amount: string): [string, number] { + // Divide la cadena de entrada en dos partes: valor y precisión + const [valuePart, precisionPart] = amount.split("."); + + // Calcula la precisión utilizada + const precision = precisionPart ? precisionPart.length : 0; + + // Elimina cualquier carácter no numérico de la parte del valor y concaténalo + const sanitizedValue = (valuePart + precisionPart).replace(/[^0-9]/g, ""); + + return [sanitizedValue, precision]; + } + + public static create( + props: IMoneyValueProps = defaultMoneyValueProps, + options = defaultMoneyValueOptions, + ) { + if (props === null) { + throw new Error(`InvalidParams: props params is missing`); + } + + const { + amount = defaultMoneyValueProps.amount, + currencyCode = defaultMoneyValueProps.currencyCode, + precision = defaultMoneyValueProps.precision, + } = props; + + const validationResult = MoneyValue.validate(amount, options); + + if (validationResult.isFailure) { + return Result.fail(validationResult.error); + } + + const _amount: NullOr = MoneyValue.sanitize( + validationResult.object, + ); + + const prop = DineroFactory({ + amount: !isNull(_amount) ? _amount : 0, + currency: Currency.DEFAULT_CURRENCY_CODE, + precision, + }).setLocale(options.locale); + + return Result.ok(new this(prop, isNull(_amount), options)); + } + + private static sanitize(amount: NullOr): NullOr { + let _amount: NullOr = null; + + if (typeof amount === "string") { + _amount = parseFloat(amount); + } else { + _amount = amount; + } + + return _amount; + } + + protected static createFromDinero(dinero: Dinero) { + return Result.ok( + new MoneyValue(dinero, false, defaultMoneyValueOptions), + ); + } + + public static normalizePrecision( + objects: ReadonlyArray, + ): MoneyValue[] { + return DineroFactory.normalizePrecision( + objects.map((object) => object.props), + ).map((dinero) => MoneyValue.createFromDinero(dinero).object); + } + + public static minimum(objects: ReadonlyArray): MoneyValue { + return MoneyValue.createFromDinero( + DineroFactory.minimum(objects.map((object) => object.props)), + ).object; + } + + public static maximum(objects: ReadonlyArray): MoneyValue { + return MoneyValue.createFromDinero( + DineroFactory.maximum(objects.map((object) => object.props)), + ).object; + } + + constructor(value: Dinero, isNull: boolean, options: IMoneyValueOptions) { + super(value); + this._isNull = Object.freeze(isNull); + this._options = Object.freeze(options); + } + + public isEmpty = (): boolean => { + return this.isNull(); + }; + + public toString(): string { + return this._isNull ? "" : String(this.props?.getAmount()); + } + + public toJSON() { + return this._isNull ? {} : this.props?.toJSON(); + } + + public toPrimitive(): number { + return this.toUnit(); + } + + public toPrimitives(): MoneyValueObject { + return this.toObject(); + } + + public isNull = (): boolean => { + return this._isNull; + }; + + public getAmount(): number { + return this.props.getAmount(); + } + + public getPrecision(): number { + return this.props.getPrecision(); + } + + public convertPrecision( + newPrecision: number, + roundingMode?: RoundingMode, + ): MoneyValue { + return MoneyValue.createFromDinero( + this.props.convertPrecision(newPrecision, roundingMode), + ).object; + } + + public getCurrency(): Currency { + return Currency.createFromCode(this.props.getCurrency()).object; + } + + public getLocale(): string { + return this.props.getLocale(); + } + + public add(addend: MoneyValue): MoneyValue { + return MoneyValue.createFromDinero(this.props.add(addend.props)).object; + } + + public subtract(subtrahend: MoneyValue): MoneyValue { + return MoneyValue.createFromDinero(this.props.subtract(subtrahend.props)) + .object; + } + + public multiply(multiplier: number, roundingMode?: RoundingMode): MoneyValue { + return MoneyValue.createFromDinero( + this.props.multiply(multiplier, roundingMode), + ).object; + } + + public divide(divisor: number, roundingMode?: RoundingMode): MoneyValue { + return MoneyValue.createFromDinero(this.props.divide(divisor, roundingMode)) + .object; + } + + public percentage( + percentage: number, + roundingMode?: RoundingMode, + ): MoneyValue { + return MoneyValue.createFromDinero( + this.props.percentage(percentage, roundingMode), + ).object; + } + + public allocate(ratios: ReadonlyArray): MoneyValue[] { + return this.props + .allocate(ratios) + .map((dinero) => MoneyValue.createFromDinero(dinero).object); + } + + public equalsTo(comparator: MoneyValue): boolean { + return this.props.equalsTo(comparator.props); + } + + public lessThan(comparator: MoneyValue): boolean { + return this.props.lessThan(comparator.props); + } + + public lessThanOrEqual(comparator: MoneyValue): boolean { + return this.props.lessThanOrEqual(comparator.props); + } + + public greaterThan(comparator: MoneyValue): boolean { + return this.props.greaterThan(comparator.props); + } + + public greaterThanOrEqual(comparator: MoneyValue): boolean { + return this.props.greaterThanOrEqual(comparator.props); + } + + public isZero(): boolean { + return this.props.isZero(); + } + + public isPositive(): boolean { + return this.props.isPositive(); + } + + public isNegative(): boolean { + return this.props.isNegative(); + } + + public hasSameCurrency(comparator: MoneyValue): boolean { + return this.props.hasSameCurrency(comparator.props); + } + + public hasSameAmount(comparator: MoneyValue): boolean { + return this.props.hasSameAmount(comparator.props); + } + + public toFormat(format?: string, roundingMode?: RoundingMode): string { + return this.props.toFormat(format, roundingMode); + } + + public toUnit(): number { + return this.props.toUnit(); + } + + public toRoundedUnit(digits: number, roundingMode?: RoundingMode): number { + return this.props.toRoundedUnit(digits, roundingMode); + } + + public toObject(): MoneyValueObject { + const obj = this.props.toObject(); + return { + amount: obj.amount, + precision: obj.precision, + currency: String(obj.currency), + }; + } + + public toNumber(): number { + return this.toUnit(); + } +} diff --git a/shared/lib/contexts/common/domain/entities/Name.ts b/shared/lib/contexts/common/domain/entities/Name.ts new file mode 100644 index 0000000..5e53d95 --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/Name.ts @@ -0,0 +1,49 @@ +import Joi from "joi"; +import { UndefinedOr } from "../../../../utilities"; +import { RuleValidator } from "../RuleValidator"; +import { DomainError, handleDomainError } from "../errors"; +import { Result } from "./Result"; +import { + IStringValueObjectOptions, + StringValueObject, +} from "./StringValueObject"; + +export interface INameOptions extends IStringValueObjectOptions {} + +export class Name extends StringValueObject { + private static readonly MIN_LENGTH = 2; + private static readonly MAX_LENGTH = 100; + + protected static validate(value: UndefinedOr, options: INameOptions) { + const rule = Joi.string() + .allow(null) + .allow("") + .default("") + .trim() + .min(Name.MIN_LENGTH) + .max(Name.MAX_LENGTH) + .label(options.label ? options.label : "value"); + + return RuleValidator.validate(rule, value); + } + + public static create(value: UndefinedOr, options: INameOptions = {}) { + const _options = { + label: "name", + ...options, + }; + + const validationResult = Name.validate(value, _options); + + if (validationResult.isFailure) { + return Result.fail( + handleDomainError( + DomainError.INVALID_INPUT_DATA, + validationResult.error + ) + ); + } + + return Result.ok(new Name(validationResult.object)); + } +} diff --git a/shared/lib/contexts/common/domain/entities/Note.ts b/shared/lib/contexts/common/domain/entities/Note.ts new file mode 100644 index 0000000..6bb5c38 --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/Note.ts @@ -0,0 +1,53 @@ +import Joi from "joi"; + +import { DomainError, Result, RuleValidator, handleDomainError } from ".."; +import { UndefinedOr } from "../../../../utilities"; +import { + IStringValueObjectOptions, + StringValueObject, +} from "./StringValueObject"; + +export class Note extends StringValueObject { + protected static readonly MIN_LENGTH = 0; + protected static readonly MAX_LENGTH = 255; + + protected static validate( + value: UndefinedOr, + options: IStringValueObjectOptions + ) { + const rule = Joi.string() + .allow(null) + .allow("") + .default("") + .trim() + .min(Note.MIN_LENGTH) + .max(Note.MAX_LENGTH) + .label(options.label ? options.label : "value"); + + return RuleValidator.validate(rule, value); + } + + public static create( + value: UndefinedOr, + options: IStringValueObjectOptions = {} + ) { + const _options = { + label: "note", + ...options, + }; + + const validationResult = Note.validate(value, _options); + + if (validationResult.isFailure) { + return Result.fail( + handleDomainError( + DomainError.INVALID_INPUT_DATA, + validationResult.error + ) + ); + } + return Result.ok(new Note(validationResult.object)); + } +} + +export class Note_ValidationError extends Joi.ValidationError {} diff --git a/shared/lib/contexts/common/domain/entities/NullableValueObject.ts b/shared/lib/contexts/common/domain/entities/NullableValueObject.ts new file mode 100644 index 0000000..9458c69 --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/NullableValueObject.ts @@ -0,0 +1,11 @@ +import { NullOr } from "../../../../utilities"; + +import { IValueObjectOptions, ValueObject } from "./ValueObject"; + +export interface INullableValueObjectOptions extends IValueObjectOptions {} + +export abstract class NullableValueObject extends ValueObject> { + public isNull = (): boolean => { + return this.props === null; + }; +} diff --git a/shared/lib/contexts/common/domain/entities/Percentage.ts b/shared/lib/contexts/common/domain/entities/Percentage.ts new file mode 100644 index 0000000..be8e8ae --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/Percentage.ts @@ -0,0 +1,57 @@ +import Joi from "joi"; +import { RuleValidator } from "../RuleValidator"; +import { + INullableValueObjectOptions, + NullableValueObject, +} from "./NullableValueObject"; +import { Result } from "./Result"; + +export class Percentage extends NullableValueObject { + private static readonly MIN_VALUE = 0; + private static readonly MAX_VALUE = 100; + + protected static validate( + value: number, + options: INullableValueObjectOptions, + ) { + const rule = Joi.number() + .min(Percentage.MIN_VALUE) + .max(Percentage.MAX_VALUE) + + .label(options.label ? options.label : "value"); + + return RuleValidator.validate(rule, value); + } + + public static create( + value: number, + options: INullableValueObjectOptions = {}, + ) { + const _options = { + label: "percentage", + ...options, + }; + + const validationResult = Percentage.validate(value, _options); + + if (validationResult.isFailure) { + return Result.fail(validationResult.error); + } + + return Result.ok(new Percentage(value)); + } + + public toNumber(): number { + return this.isNull() ? 0 : Number(this.value); + } + + public toString(): string { + return this.isNull() ? "" : String(this.value); + } + + public toPrimitive(): number { + return this.toNumber(); + } +} + +export class InvalidPercentageError extends Error {} diff --git a/shared/lib/contexts/common/domain/entities/Phone.ts b/shared/lib/contexts/common/domain/entities/Phone.ts new file mode 100644 index 0000000..058f8bb --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/Phone.ts @@ -0,0 +1,63 @@ +import Joi from "joi"; + +//import JoiPhoneNumber from "joi-phone-number"; +import { UndefinedOr } from "../../../../utilities"; +import { RuleValidator } from "../RuleValidator"; +import { DomainError, handleDomainError } from "../errors"; +import { Result } from "./Result"; +import { + IStringValueObjectOptions, + StringValueObject, +} from "./StringValueObject"; + +export class Phone extends StringValueObject { + protected static validate( + value: UndefinedOr, + options: IStringValueObjectOptions + ) { + const rule = Joi.string() //.extend(JoiPhoneNumber) + .allow(null) + .allow("") + .trim() + //.phoneNumber(/*{ defaultCountry: 'ES', format: 'national' }*/) + .label(options.label ? options.label : "value"); + + return RuleValidator.validate(rule, value); + } + + public static create( + value: UndefinedOr, + options: IStringValueObjectOptions = {} + ) { + const _options = { + label: "phone", + ...options, + }; + + const validationResult = Phone.validate(value, _options); + + if (validationResult.isFailure) { + return Result.fail( + handleDomainError( + DomainError.INVALID_INPUT_DATA, + validationResult.error + ) + ); + } + return Result.ok(new Phone(validationResult.object)); + } + + public format(countryCode: string) { + const rule = Joi /*.extend(JoiPhoneNumber)*/.string(); + /*.phoneNumber({ + defaultCountry: countryCode, + format: "international", + })*/ const validationResult = RuleValidator.validate(rule, this.value); + + return validationResult.isSuccess + ? validationResult.object + : validationResult.error.message; + } +} + +export class Phone_ValidationError extends Joi.ValidationError {} diff --git a/shared/lib/contexts/common/domain/entities/Quantity.test.ts b/shared/lib/contexts/common/domain/entities/Quantity.test.ts new file mode 100644 index 0000000..8f7eb6f --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/Quantity.test.ts @@ -0,0 +1,80 @@ +import { Quantity } from "./Quantity"; // Asegúrate de importar correctamente la clase Quantity. + +describe("Quantity Value Object", () => { + // Prueba la creación de una cantidad válida. + it("Should create a valid quantity (number)", () => { + const validQuantity = Quantity.create(5); + + expect(validQuantity.isSuccess).toBe(true); + expect(validQuantity.object.toNumber()).toBe(5); + }); + + it("Should create a valid quantity (string)", () => { + const validQuantity = Quantity.create("99"); + + expect(validQuantity.isSuccess).toBe(true); + expect(validQuantity.object.toNumber()).toBe(99); + }); + + it("Should create a valid quantity (null)", () => { + const validQuantity = Quantity.create(null); + + expect(validQuantity.isSuccess).toBe(true); + expect(validQuantity.object.isNull).toBeTruthy(); + }); + + // Prueba la creación de una cantidad nula. + it("Should create a valid null quantity", () => { + const nullQuantity = Quantity.create(null); + + expect(nullQuantity.isSuccess).toBe(true); + expect(nullQuantity.object.isNull()).toBe(true); + }); + + // Prueba la creación de una cantidad válida a partir de una cadena. + it("Should create a valid quantity from string", () => { + const validQuantityFromString = Quantity.create("10"); + + expect(validQuantityFromString.isSuccess).toBe(true); + expect(validQuantityFromString.object.toNumber()).toBe(10); + }); + + // Prueba la creación de una cantidad con una cadena no válida. + it("Should fail to create quantity from invalid string", () => { + const invalidQuantityFromString = Quantity.create("invalid"); + + expect(invalidQuantityFromString.isFailure).toBe(true); + }); + + // Prueba la conversión a número. + it("Should convert to number", () => { + const quantity = Quantity.create(7).object; + const result = quantity.toNumber(); + + expect(result).toBe(7); + }); + + // Prueba la conversión a cadena. + it("Should convert to string", () => { + const quantity = Quantity.create(15).object; + const result = quantity.toString(); + + expect(result).toBe("15"); + }); + + // Prueba la operación de incremento. + it("Should increment quantity", () => { + const quantity = Quantity.create(5).object; + const incrementedQuantity = quantity.increment(3).object; + + expect(incrementedQuantity.toNumber()).toBe(8); + }); + + // Prueba la operación de decremento. + it("Should decrement quantity", () => { + const quantity = Quantity.create(10).object; + const decrementedQuantity = quantity.decrement(4).object; + + expect(decrementedQuantity.toNumber()).toBe(6); + }); +}); diff --git a/shared/lib/contexts/common/domain/entities/Quantity.ts b/shared/lib/contexts/common/domain/entities/Quantity.ts new file mode 100644 index 0000000..2b65c88 --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/Quantity.ts @@ -0,0 +1,97 @@ +import Joi from "joi"; +import { NullOr } from "../../../../utilities"; +import { RuleValidator } from "../RuleValidator"; +import { + INullableValueObjectOptions, + NullableValueObject, +} from "./NullableValueObject"; +import { Result } from "./Result"; + +export interface IQuantityOptions extends INullableValueObjectOptions {} + +export class Quantity extends NullableValueObject { + protected static validate( + value: NullOr, + options: IQuantityOptions = {}, + ) { + const ruleNull = RuleValidator.RULE_ALLOW_NULL_OR_UNDEFINED.default(null); + + const ruleNumber = RuleValidator.RULE_IS_TYPE_NUMBER.label( + options.label ? options.label : "quantity", + ); + + const ruleString = RuleValidator.RULE_IS_TYPE_STRING.regex( + /^[-]?\d+$/, + ).label(options.label ? options.label : "quantity"); + + const rules = Joi.alternatives(ruleNull, ruleNumber, ruleString); + + return RuleValidator.validate>(rules, value); + } + + public static create( + value: NullOr, + options: IQuantityOptions = {}, + ) { + const _options = { + label: "quantity", + ...options, + }; + + const validationResult = Quantity.validate(value, _options); + + if (validationResult.isFailure) { + return Result.fail(validationResult.error); + } + + let _value: NullOr = null; + + if (typeof validationResult.object === "string") { + _value = parseInt(validationResult.object, 10); + } else { + _value = validationResult.object; + } + + return Result.ok(new Quantity(_value)); + } + + public toNumber(): number { + return this.isNull() ? 0 : Number(this.value); + } + + public toString(): string { + return this.isNull() ? "" : String(this.value); + } + + public toPrimitive(): number { + return this.toNumber(); + } + + public increment(amount: number = 1) { + const validationResult = Quantity.validate(amount); + + if (validationResult.isFailure) { + return Result.fail(validationResult.error); + } + + if (this.value === null) { + return Quantity.create(amount); + } + + return Quantity.create(this.value + amount); + } + + public decrement(amount: number = 1) { + const validationResult = Quantity.validate(amount); + + if (validationResult.isFailure) { + return Result.fail(validationResult.error); + } + + if (this.value === null) { + return Quantity.create(amount); + } + + return Quantity.create(this.value - amount); + } +} diff --git a/shared/lib/contexts/common/domain/entities/QueryCriteria/Field/FieldCriteria.ts b/shared/lib/contexts/common/domain/entities/QueryCriteria/Field/FieldCriteria.ts new file mode 100644 index 0000000..e032b17 --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/QueryCriteria/Field/FieldCriteria.ts @@ -0,0 +1,59 @@ +import { UndefinedOr } from "../../../../../../utilities"; +import { RuleValidator } from "../../../RuleValidator"; +import { Result } from "../../Result"; +import { ResultCollection } from "../../ResultCollection"; +import { StringValueObject } from "../../StringValueObject"; + +export interface IFieldCriteria { + toJSON(): string; + toString(): string; + toObject(): Record; +} + +export class FieldCriteria extends StringValueObject implements IFieldCriteria { + public static create(value: UndefinedOr) { + const validatedProps = this.validate(value); + + if (validatedProps.isFailure) { + return Result.fail(validatedProps.error); + } + + return Result.ok(new FieldCriteria(String(value))); + } + + protected static validate(value: UndefinedOr) { + if ( + RuleValidator.validate(RuleValidator.RULE_NOT_NULL_OR_UNDEFINED, value) + .isSuccess + ) { + const stringOrError = RuleValidator.validate( + RuleValidator.RULE_IS_TYPE_STRING, + value + ); + + if (stringOrError.isFailure) { + return stringOrError; + } + } + + const fieldString = String(value); + + const fieldsOrErrors = new ResultCollection(); + + this.parseFieldString(fieldString).forEach((token: string[]) => { + const fieldOrError = Field.create({ + field: token[2], + operator: String(token[3]).toUpperCase(), + value: token[4], + }); + + fieldsOrErrors.add(fieldOrError); + }); + + if (fieldsOrErrors.hasSomeFaultyResult()) { + return fieldsOrErrors.getFirstFaultyResult() as Result; + } + + return Result.ok(fieldString); + } +} diff --git a/shared/lib/contexts/common/domain/entities/QueryCriteria/Field/index.ts b/shared/lib/contexts/common/domain/entities/QueryCriteria/Field/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/shared/lib/contexts/common/domain/entities/QueryCriteria/Filters/Filter.ts b/shared/lib/contexts/common/domain/entities/QueryCriteria/Filters/Filter.ts new file mode 100644 index 0000000..82c7894 --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/QueryCriteria/Filters/Filter.ts @@ -0,0 +1,78 @@ +import { Result } from "../../Result"; +import { ValueObject } from "../../ValueObject"; + +export interface IFilterProps { + operator: string; + field?: string; + value: any; +} + +export interface IFilter { + operator: string; + field?: string; + value: any; + + toString(): string; + toObject(): Record; +} + +export class Filter extends ValueObject implements IFilter { + public static create(filterProps: IFilterProps): Result { + // Validación de props + const valid = this.validate(filterProps); + if (valid.isFailure) { + return Result.fail(valid.error); + } + + return Result.ok( + new Filter({ + ...filterProps, + operator: this.sanitizeOperator(filterProps.operator), + }), + ); + } + + protected static validate(filter: any): Result { + return Result.ok(filter); + } + + protected static sanitizeOperator(operator: string): string { + return operator.toUpperCase().trim(); + } + + protected constructor(props: IFilterProps) { + super(props); + } + + get field(): string | undefined { + return this.props.field; + } + + get operator(): string { + return this.props.operator; + } + + get value(): any { + return this.props.value; + } + + public toString(): string { + return `${this.field} [${this.operator}] ${this.value}`; + } + + public toObject(): Record { + return { + operator: String(this.operator), + field: String(this.field), + value: this.value, + }; + } + + public toJSON(): string { + return this.toString(); + } + + public toPrimitive(): string { + return this.toString(); + } +} diff --git a/shared/lib/contexts/common/domain/entities/QueryCriteria/Filters/FilterCollection.ts b/shared/lib/contexts/common/domain/entities/QueryCriteria/Filters/FilterCollection.ts new file mode 100644 index 0000000..dc59e02 --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/QueryCriteria/Filters/FilterCollection.ts @@ -0,0 +1,40 @@ +import { Collection, ICollection } from "../../Collection"; +import { Result } from "../../Result"; +import { Filter } from "./Filter"; + +export interface IFilterCollection extends ICollection { + findByField(field: string): Filter | undefined; + removeByField(field: string): void; + toObject(): Record; +} + +export class FilterCollection + extends Collection + implements IFilterCollection +{ + static create(filters?: Filter[]): Result { + return Result.ok(new FilterCollection(filters)); + } + + protected constructor(initialValues?: Filter[]) { + super(initialValues, undefined); + } + + public findByField(field: string): Filter | undefined { + return this.find((filter) => filter.field === field); + } + + public removeByField(field: string): void { + let indexFound = -1; + this.find((filter, index) => + filter.field === field ? (indexFound = index) : null, + ); + if (indexFound > -1) { + this.removeByIndex(indexFound); + } + } + + public toObject(): Record { + return this.items.map((filter: Filter) => filter.toObject()); + } +} diff --git a/shared/lib/contexts/common/domain/entities/QueryCriteria/Filters/FilterCriteria.ts b/shared/lib/contexts/common/domain/entities/QueryCriteria/Filters/FilterCriteria.ts new file mode 100644 index 0000000..e58ac33 --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/QueryCriteria/Filters/FilterCriteria.ts @@ -0,0 +1,145 @@ +import { UndefinedOr } from "../../../../../../utilities"; +import { RuleValidator } from "../../../RuleValidator"; +import { Result } from "../../Result"; +import { ResultCollection } from "../../ResultCollection"; +import { StringValueObject } from "../../StringValueObject"; +import { Filter, IFilter } from "./Filter"; + +export interface IFilterCriteria { + getFilterRoot(): any; + toJSON(): string; + toString(): string; + toObject(): Record; +} + +export class FilterCriteria + extends StringValueObject + implements IFilterCriteria +{ + protected static parseFilterString = (filterString: string): string[][] => { + // eslint-disable-next-line no-useless-escape + const regex = /(?:\|([^\]]+)(?:\|))*([^\[|]+)\[([^\]]+)\]([^\[|]+)/gi; + const result: any[] = []; + let matches: any; + + while ((matches = regex.exec(filterString)) !== null) { + result.push([...matches]); + } + + return result; + }; + + public static create(value: UndefinedOr) { + const validatedProps = this.validate(value); + + if (validatedProps.isFailure) { + return Result.fail(validatedProps.error); + } + + return Result.ok(new FilterCriteria(String(value))); + } + + protected static validate(value: UndefinedOr) { + if ( + RuleValidator.validate(RuleValidator.RULE_NOT_NULL_OR_UNDEFINED, value) + .isSuccess + ) { + const stringOrError = RuleValidator.validate( + RuleValidator.RULE_IS_TYPE_STRING, + value + ); + + if (stringOrError.isFailure) { + return stringOrError; + } + } + + const filterString = String(value); + + const filtersOrErrors = new ResultCollection(); + + this.parseFilterString(filterString).forEach((token: string[]) => { + const filterOrError = Filter.create({ + field: token[2], + operator: String(token[3]).toUpperCase(), + value: token[4], + }); + + filtersOrErrors.add(filterOrError); + }); + + if (filtersOrErrors.hasSomeFaultyResult()) { + return filtersOrErrors.getFirstFaultyResult() as Result; + } + + return Result.ok(filterString); + } + + public getFilterRoot(): any { + return this.buildFilterRoot(); + } + + public toJSON(): string { + return JSON.stringify(this.toObject()); + } + + public toPrimitive(): string { + throw new Error("NOT IMPLEMENT FilterCriteria.toPrimitive()"); + } + + public toObject(): Record { + return this.getFilterRoot(); + } + + protected buildFilterRoot(): any { + const __processNodes: any = (nodes: any[], prevFilter?: IFilter) => { + const _node: any = nodes.shift(); + + if (!_node) { + return prevFilter; + } + + if (!_node.connection) { + return __processNodes(nodes, _node.filter); + } + + return { + operator: _node.connection, + value: [prevFilter, __processNodes(nodes, _node.filter)], + }; + }; + + const filterString = String(this.props); + + const filterNodes = FilterCriteria.parseFilterString(filterString).map( + (token: string[]) => { + /** TOKEN + * [1] => and / or (opcional) + * [2] => field + * [3] => operator + * [4] => value + */ + const connection = token[1] + ? String(token[1]).toUpperCase() + : undefined; + + const filterOrError = Filter.create({ + field: token[2], + operator: String(token[3]).toUpperCase(), + value: token[4], + }); + + if (filterOrError.isFailure) { + throw new Error(`Filter '${token.join()}' is not valid`); + } + + return { + connection, + filter: filterOrError.object, + }; + } + ); + + return __processNodes(filterNodes, null); + } +} diff --git a/shared/lib/contexts/common/domain/entities/QueryCriteria/Filters/index.ts b/shared/lib/contexts/common/domain/entities/QueryCriteria/Filters/index.ts new file mode 100644 index 0000000..79a4161 --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/QueryCriteria/Filters/index.ts @@ -0,0 +1,3 @@ +export * from './FilterCollection'; +export * from './Filter'; +export * from './FilterCriteria'; \ No newline at end of file diff --git a/shared/lib/contexts/common/domain/entities/QueryCriteria/Order/Order.ts b/shared/lib/contexts/common/domain/entities/QueryCriteria/Order/Order.ts new file mode 100644 index 0000000..781cca7 --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/QueryCriteria/Order/Order.ts @@ -0,0 +1,111 @@ +import Joi from "joi"; +import { RuleValidator } from "../../../RuleValidator"; +import { Result } from "../../Result"; +import { ValueObject } from "../../ValueObject"; + +export interface IOrderProps { + type: string; + field: string; +} + +export interface IOrder { + type: string; + field: string; + + toString(): string; + toObject(): Record; +} + +export class Order extends ValueObject implements IOrder { + public static create(orderProps: IOrderProps) { + // Validación de props + const valid = this.validate(orderProps); + if (valid.isFailure) { + return Result.fail(valid.error); + } + + return Result.ok( + new Order({ + field: this.sanitize(orderProps.field), + type: this.sanitize(orderProps.type), + }), + ); + } + + protected static validate(orderProps: IOrderProps) { + const { type, field } = orderProps; + + // type + if ( + RuleValidator.validate(RuleValidator.RULE_NOT_NULL_OR_UNDEFINED, type) + .isSuccess + ) { + let typeOrError = RuleValidator.validate( + RuleValidator.RULE_IS_TYPE_STRING, + type, + ); + + if (typeOrError.isFailure) { + return typeOrError; + } + + typeOrError = RuleValidator.validate(Joi.any().valid("+", "-"), type); + + if (typeOrError.isFailure) { + return typeOrError; + } + } + + // field + if ( + RuleValidator.validate(RuleValidator.RULE_NOT_NULL_OR_UNDEFINED, type) + .isSuccess + ) { + const fieldOrError = RuleValidator.validate( + RuleValidator.RULE_IS_TYPE_STRING, + field, + ); + + if (fieldOrError.isFailure) { + return fieldOrError; + } + } + + return Result.ok(); + } + + protected static sanitize(value: string): string { + return String(Joi.string().trim().validate(value).value); + } + + protected constructor(props: IOrderProps) { + super(props); + } + + get field(): string { + return this.props.field; + } + + get type(): string { + return this.props.type; + } + + public toString(): string { + return `${this.field} [${this.type}]`; + } + + public toPrimitive(): string { + return this.toString(); + } + + public toObject(): Record { + return { + type: this.type, + field: this.field, + }; + } + + public toJSON(): string { + return this.toString(); + } +} diff --git a/shared/lib/contexts/common/domain/entities/QueryCriteria/Order/OrderCollection.ts b/shared/lib/contexts/common/domain/entities/QueryCriteria/Order/OrderCollection.ts new file mode 100644 index 0000000..06dcd6f --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/QueryCriteria/Order/OrderCollection.ts @@ -0,0 +1,39 @@ +import { Collection, ICollection } from "../../Collection"; +import { Order } from "./Order"; + +export interface IOrderCollection extends ICollection { + findByField(field: string): Order | undefined; + removeByField(field: string): void; + toObject(): Record; +} + +export class OrderCollection + extends Collection + implements IOrderCollection +{ + public static createEmpty() { + return new OrderCollection(); + } + + public static create(initialValues: Order[], totalCount?: number) { + return new OrderCollection(initialValues, totalCount); + } + + public findByField(field: string): Order | undefined { + return this.find((order) => order.field === field); + } + + public removeByField(field: string): void { + let indexFound = -1; + this.find((order, index) => + order.field === field ? (indexFound = index) : null, + ); + if (indexFound > -1) { + this.removeByIndex(indexFound); + } + } + + public toObject(): Record { + return this.items.map((order: Order) => order.toObject()); + } +} diff --git a/shared/lib/contexts/common/domain/entities/QueryCriteria/Order/OrderCriteria.ts b/shared/lib/contexts/common/domain/entities/QueryCriteria/Order/OrderCriteria.ts new file mode 100644 index 0000000..8383181 --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/QueryCriteria/Order/OrderCriteria.ts @@ -0,0 +1,135 @@ +import { IOrderCollection, IOrderProps, Order, OrderCollection } from "."; +import { UndefinedOr } from "../../../../../../utilities"; +import { RuleValidator } from "../../../RuleValidator"; +import { Result } from "../../Result"; +import { ResultCollection } from "../../ResultCollection"; +import { StringValueObject } from "../../StringValueObject"; + +export interface IOrderCriteria { + getOrderCollection(): IOrderCollection; + toJSON(): string; + toString(): string; + toObject(): Record; +} + +export class OrderCriteria extends StringValueObject implements IOrderCriteria { + private static parseOrderString = ( + orderString: UndefinedOr + ): string[][] => { + // eslint-disable-next-line no-useless-escape + //return OrderString.match(/(.+)(([\[])([\w]+)([\]]))([\w\W]+)*/i); + + const result: any[] = []; + + if (orderString) { + const regex = /([+-]?)([\w_.]+)/gi; + let matches: any; + while ((matches = regex.exec(orderString)) !== null) { + result.push([...matches]); + } + } + + return result; + }; + + protected static validate(value: UndefinedOr) { + let orderString = value; + + if ( + RuleValidator.validate( + RuleValidator.RULE_NOT_NULL_OR_UNDEFINED, + orderString + ).isFailure + ) { + return Result.ok(value); + } + + const stringOrError = RuleValidator.validate( + RuleValidator.RULE_IS_TYPE_STRING, + orderString + ); + + if (stringOrError.isFailure) { + return stringOrError; + } + + orderString = String(value); + + const orderOrErrors = new ResultCollection(); + + this.parseOrderString(orderString).forEach((token: string[]) => { + const orderOrError = Order.create({ + type: token[1] || "+", + field: String(token[2]), + }); + + orderOrErrors.add(orderOrError); + }); + + if (orderOrErrors.hasSomeFaultyResult()) { + return orderOrErrors.getFirstFaultyResult() as Result; + } + + return Result.ok(orderString); + } + + public static create(value: UndefinedOr) { + const validatedProps = this.validate(value); + + if (validatedProps.isFailure) { + return Result.fail(validatedProps.error); + } + + return Result.ok(new OrderCriteria(validatedProps.object)); + } + + public getOrderCollection(): IOrderCollection { + return this.buildOrderCollection(); + } + + public toJSON(): string { + return JSON.stringify(this.toObject()); + } + + public toPrimitive(): string { + throw new Error("NOT IMPLEMENT OrderCriteria.toPrimitive()"); + } + + public toObject(): Record { + return this.getOrderCollection().toObject(); + } + + protected buildOrderCollection(): IOrderCollection { + let _orders: IOrderProps[] | undefined = undefined; + + if (!this.isEmpty()) { + _orders = OrderCriteria.parseOrderString(this.props).map( + (token: string[]) => { + /** TOKEN + * [1] => type + / - (opcional) + * [2] => field / model.field + */ + + const type = token[1] ? String(token[1]) : "+"; + + const orderOrError = Order.create({ + type, + field: String(token[2]), + }); + + if (orderOrError.isFailure) { + throw new Error(`Order '${token.join()}' is not valid`); + } + + return orderOrError.object; + } + ); + } + + return ( + _orders === undefined + ? OrderCollection.createEmpty() + : OrderCollection.create(_orders as Order[]) + ) as IOrderCollection; + } +} diff --git a/shared/lib/contexts/common/domain/entities/QueryCriteria/Order/OrderRoot.ts b/shared/lib/contexts/common/domain/entities/QueryCriteria/Order/OrderRoot.ts new file mode 100644 index 0000000..f8c4169 --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/QueryCriteria/Order/OrderRoot.ts @@ -0,0 +1,109 @@ +import { Result, ValueObject } from "@shared/contexts"; +import { DomainError } from "../../../errors"; +import { Order } from "./Order"; +import { OrderCollection } from "./OrderCollection"; + +interface IOrderRootProps { + type: string; + items: OrderCollection; +} + +export interface IOrderRoot { + type: string; + items: OrderCollection; + + toString(): string; + toObject(): Record; +} + +export class OrderRoot + extends ValueObject + implements IOrderRoot +{ + public static createASC(value: Order[]): Result { + return this.create({ + type: "asc", + value, + }); + } + + public static createDESC(value: Order[]): Result { + return this.create({ + type: "desc", + value, + }); + } + + public static create(orderRootProps: any): Result { + // Validación de props + const valid = this.validate(orderRootProps); + if (valid.isFailure) { + return Result.fail(valid.error); + } + + return Result.ok( + new OrderRoot({ + type: this.sanitizeOperator(orderRootProps.type), + items: OrderCollection.create(orderRootProps.value), + }) + ); + } + + protected static validate(OrderRootRoot: IOrderRootProps): Result { + throw DomainError.create("NOT IMPLEMENT", { + function: "OrderRoot.validate()", + }); + + /*return Validator.isOneOf( + { + value: OrderRootRoot.type, + valueName: 'OrderRoot root type', + }, + ['asc', 'desc'] + );*/ + } + + protected static sanitizeOperator(type: string): string { + return type.toUpperCase().trim(); + } + + protected _type: string; + protected _items: OrderCollection; + + protected constructor(props: IOrderRootProps) { + super(props); + this._type = props.type; + this._items = props.items; + } + + get type(): string { + return this._type; + } + + get items(): OrderCollection { + return this._items; + } + + public toString(): string { + return `${this.type} ${this.value.toString()}`; + } + + public toObject(): Record { + return { + type: this.type, + items: this.items.toObject(), + }; + } + + public toPrimitive(): string { + return this.toString(); + } + + public toJSON(): string { + return this.toString(); + } + + public transform(transformerFunction: any, params: any): any { + return transformerFunction(this.toObject(), params); + } +} diff --git a/shared/lib/contexts/common/domain/entities/QueryCriteria/Order/__test__/Order.test.ts b/shared/lib/contexts/common/domain/entities/QueryCriteria/Order/__test__/Order.test.ts new file mode 100644 index 0000000..fc3ff39 --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/QueryCriteria/Order/__test__/Order.test.ts @@ -0,0 +1,52 @@ +import { Order } from "../Order"; + +describe("Order", () => { + it("Campo con orden ascendente +", () => { + const type = "+"; + const field = "name"; + const order = Order.create({ type, field }); + expect(order.isSuccess).toBeTruthy(); + }); + + it("Campo con orden descendente -", () => { + const type = "-"; + const field = "date"; + const order = Order.create({ type, field }); + expect(order.isSuccess).toBeTruthy(); + }); + + it("Error si el campo no indica orden", () => { + const type = ""; + const field = "id"; + const order = Order.create({ type, field }); + expect(order.isFailure).toBeTruthy(); + }); + + it("Error si el campo de orden tiene un valor diferente de + ó -", () => { + const type = "*"; + const field = "id"; + const order = Order.create({ type, field }); + expect(order.isFailure).toBeTruthy(); + }); + + it("Error si no hay valor para el campo (undefined)", () => { + const type = "-"; + const field = undefined; + const order = Order.create({ type, field }); + expect(order.isFailure).toBeTruthy(); + }); + + it("Error si no hay valor para el campo (null)", () => { + const type = "-"; + const field = null; + const order = Order.create({ type, field }); + expect(order.isFailure).toBeTruthy(); + }); + + it('Error si no hay valor para el campo ("")', () => { + const type = "-"; + const field = ""; + const order = Order.create({ type, field }); + expect(order.isFailure).toBeTruthy(); + }); +}); diff --git a/shared/lib/contexts/common/domain/entities/QueryCriteria/Order/__test__/OrderCriteria.test.ts b/shared/lib/contexts/common/domain/entities/QueryCriteria/Order/__test__/OrderCriteria.test.ts new file mode 100644 index 0000000..ead116f --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/QueryCriteria/Order/__test__/OrderCriteria.test.ts @@ -0,0 +1,49 @@ +import { OrderCriteria } from "../OrderCriteria"; + +describe("OrderCriteria", () => { + it("Cadena con orden ascendente", () => { + const value = "name"; + const order = OrderCriteria.create(value); + expect(order.isSuccess).toBeTruthy(); + }); + + it("Cadena con orden ascendente +", () => { + const value = "+name"; + const order = OrderCriteria.create(value); + expect(order.isSuccess).toBeTruthy(); + }); + + it("Cadena con orden descendente -", () => { + const value = "-date"; + const order = OrderCriteria.create(value); + expect(order.isSuccess).toBeTruthy(); + }); + + it("Criterio vacio si no hay valor para el campo (undefined)", () => { + const value = undefined; + const order = OrderCriteria.create(value); + expect(order.isSuccess).toBeTruthy(); + expect(order.object.isEmpty()).toBeTruthy(); + }); + + it("Criterio vacio si no hay valor para el campo (null)", () => { + const value = null; + const order = OrderCriteria.create(value); + expect(order.isSuccess).toBeTruthy(); + expect(order.object.isEmpty()).toBeTruthy(); + }); + + it('Criterio vacio si no hay valor para el campo ("")', () => { + const value = ""; + const order = OrderCriteria.create(value); + expect(order.isSuccess).toBeTruthy(); + expect(order.object.isEmpty()).toBeTruthy(); + }); + + it("Analizar cadena de criterios (+date-)", () => { + const value = ""; + const order = OrderCriteria.create(value); + expect(order.isSuccess).toBeTruthy(); + expect(order.object.isEmpty()).toBeTruthy(); + }); +}); diff --git a/shared/lib/contexts/common/domain/entities/QueryCriteria/Order/index.ts b/shared/lib/contexts/common/domain/entities/QueryCriteria/Order/index.ts new file mode 100644 index 0000000..72d206f --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/QueryCriteria/Order/index.ts @@ -0,0 +1,4 @@ +//export * from './OrderRoot'; +export * from "./Order"; +export * from "./OrderCriteria"; +export * from "./OrderCollection"; diff --git a/shared/lib/contexts/common/domain/entities/QueryCriteria/Pagination/OffsetPaging.ts b/shared/lib/contexts/common/domain/entities/QueryCriteria/Pagination/OffsetPaging.ts new file mode 100644 index 0000000..57e31e9 --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/QueryCriteria/Pagination/OffsetPaging.ts @@ -0,0 +1,145 @@ +import Joi from "joi"; +import { RuleValidator } from "../../../RuleValidator"; +import { Result } from "../../Result"; +import { ValueObject } from "../../ValueObject"; + +export interface IOffsetPagingProps { + offset: number | string; + limit: number | string; +} + +export interface IOffsetPaging { + offset: number; + limit: number; +} + +export class OffsetPaging extends ValueObject { + public static readonly LIMIT_DEFAULT_VALUE: number = 10; + public static readonly LIMIT_MINIMAL_VALUE: number = 1; + public static readonly LIMIT_MAXIMAL_VALUE: number = 100; + + public static readonly OFFSET_DEFAULT_VALUE: number = 0; + public static readonly OFFSET_MINIMAL_VALUE: number = 0; + public static readonly OFFSET_MAXIMAL_VALUE: number = Number.MAX_SAFE_INTEGER; + + public static createWithMaxLimit(): Result { + return OffsetPaging.create({ + offset: OffsetPaging.OFFSET_DEFAULT_VALUE, + limit: OffsetPaging.LIMIT_MAXIMAL_VALUE, + }); + } + + public static createWithDefaultValues(): Result { + return OffsetPaging.create({ + offset: OffsetPaging.OFFSET_DEFAULT_VALUE, + limit: OffsetPaging.LIMIT_DEFAULT_VALUE, + }); + } + + public static create(paginationProps: IOffsetPagingProps) { + const offset = paginationProps.offset || this.OFFSET_DEFAULT_VALUE; + const limit = paginationProps.limit || this.LIMIT_DEFAULT_VALUE; + + const validatedProps = this.validate(offset, limit); + + if (validatedProps.isFailure) { + return Result.fail(validatedProps.error); + } + + const paging: IOffsetPaging = validatedProps.object as IOffsetPaging; + + return Result.ok(new OffsetPaging(paging)); + } + + private static validate(offset: string | number, limit: string | number) { + const numberOrError = RuleValidator.validate( + RuleValidator.RULE_IS_TYPE_NUMBER, + offset, + ); + + if (numberOrError.isFailure) { + return numberOrError; + } + + const _offset = typeof offset === "string" ? parseInt(offset, 10) : offset; + + const offsetValidate = RuleValidator.validate( + Joi.number() + .min(OffsetPaging.OFFSET_MINIMAL_VALUE) + .max(OffsetPaging.OFFSET_MAXIMAL_VALUE), + offset, + ); + + if (offsetValidate.isFailure) { + return Result.fail( + new Error( + `Page need to be larger than or equal to ${OffsetPaging.OFFSET_MINIMAL_VALUE}.`, + ), + ); + } + + // limit + const limitNumberOrError = RuleValidator.validate( + RuleValidator.RULE_IS_TYPE_NUMBER, + limit, + ); + + if (limitNumberOrError.isFailure) { + return limitNumberOrError; + } + + const _limit = typeof limit === "string" ? parseInt(limit, 10) : limit; + + const limitValidate = RuleValidator.validate( + Joi.number().min(0).max(OffsetPaging.LIMIT_MAXIMAL_VALUE), + offset, + ); + + if (limitValidate.isFailure) { + return Result.fail( + new Error( + `Page size need to be smaller than ${OffsetPaging.LIMIT_MAXIMAL_VALUE}`, + ), + ); + } + + return Result.ok({ + offset: _offset, + limit: _limit, + }); + } + + private constructor(props: IOffsetPaging) { + super(props); + } + + get offset(): number { + return this.props.offset; + } + + get limit(): number { + return this.props.limit; + } + + public next(): OffsetPaging { + return new OffsetPaging({ + offset: this.props.offset + this.props.limit, + limit: this.props.limit, + }); + } + + public toJSON(): string { + return JSON.stringify(this.toObject()); + } + + public toPrimitive(): string { + throw new Error("NOT IMPLEMENT OffsetPaging.toPrimitive()"); + } + + public toObject(): Record { + return { + limit: this.props.limit, + offset: this.props.offset, + }; + } +} diff --git a/shared/lib/contexts/common/domain/entities/QueryCriteria/Pagination/__test__/PaginatedResult.test.ts.bak b/shared/lib/contexts/common/domain/entities/QueryCriteria/Pagination/__test__/PaginatedResult.test.ts.bak new file mode 100644 index 0000000..766e16a --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/QueryCriteria/Pagination/__test__/PaginatedResult.test.ts.bak @@ -0,0 +1,81 @@ +import { TFetchingRequest } from '../PaginatedResult'; +import { OffsetPaginatedResult } from "../OffsetPaginatedResult"; +import { OffsetPaging } from "../OffsetPaging"; + +const DATASOURCE_SIZE: number = 100; +let dataSource: string[] = []; + +const populateDataSource = () => { + dataSource = []; + for (let index = 0; index < DATASOURCE_SIZE; index++) { + dataSource.push(`Linea #${index}`); + } +}; + +const fetchingDataMock = jest.fn((request: TFetchingRequest) => { + const { paging } = request; + return dataSource.slice(paging.start, paging.start + paging.limit); +}); + +describe.skip('OffsetPaginatedResult', () => { + beforeEach(() => { + populateDataSource(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }) + + test('se recupera la primera página', () => { + let result: OffsetPaginatedResult = OffsetPaginatedResult.create({ + fetchingFn: fetchingDataMock, + fetchingParams: null, + totalCount: (DATASOURCE_SIZE), + }); + + result = result.start(OffsetPaging.create(0, 25), null); + result.getData(); + expect(fetchingDataMock.mock.calls.length).toBe(1); + }); + + test('se recuperan todos las páginas', () => { + let offset = 0; + let limit = 25; + let offsetPaging = OffsetPaging.create(offset, limit); + + let result: OffsetPaginatedResult = OffsetPaginatedResult.create({ + fetchingFn: fetchingDataMock, + fetchingParams: null, + totalCount: DATASOURCE_SIZE, + }); + + result = result.start(offsetPaging, null); + do { + result.getData(); + } while (result.next()); + + expect(fetchingDataMock.mock.calls.length).toBe(DATASOURCE_SIZE / limit); + }); + + test('se recuperan los datos correctos en cada página', () => { + let offset = 0; + let limit = 25; + let data: string[] = undefined; + let offsetPaging = OffsetPaging.create(offset, limit); + + let result: OffsetPaginatedResult = OffsetPaginatedResult.create({ + fetchingFn: fetchingDataMock, + fetchingParams: null, + totalCount: DATASOURCE_SIZE, + }); + + result = result.start(offsetPaging, null); + do { + data = result.getData(); + expect(data).toStrictEqual(dataSource.slice(offset, offset + limit)); + offset = offset + limit; + } while (result.next()); + + expect(fetchingDataMock.mock.calls.length).toBe(DATASOURCE_SIZE / limit); + }); +}); diff --git a/shared/lib/contexts/common/domain/entities/QueryCriteria/Pagination/__test__/PagingStrategy.test.ts.bak b/shared/lib/contexts/common/domain/entities/QueryCriteria/Pagination/__test__/PagingStrategy.test.ts.bak new file mode 100644 index 0000000..76e3896 --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/QueryCriteria/Pagination/__test__/PagingStrategy.test.ts.bak @@ -0,0 +1,84 @@ +/*import { CursorPaging } from "../CursorPaging";*/ +import { OffsetPaging } from "../OffsetPaging"; + +describe.skip('OffsetPaging', () => { + beforeAll(() => { }); + + beforeEach(() => { }); + + test('comprobar que se crea correctamente', () => { + const offset: number = 0; + const limit: number = 15; + + const offsetPaging = OffsetPaging.create(offset, limit); + + expect(offsetPaging.start).toEqual(offset); + expect(offsetPaging.limit).toEqual(limit); + }); + + test('comprobar que se calcula la siguiente página correctamente', () => { + const offset: number = 0; + const limit: number = 15; + const nextStart: number = offset + limit; + + const nextPaging = OffsetPaging.create(offset, limit).next(); + expect(nextPaging.start).toEqual(nextStart); + }); + + test('comprobar que el offset es obligatorio', () => { + const offset: number = undefined; + + try { + const offsetPaging = OffsetPaging.create(offset, 10); + } catch (error) { + expect(error).toBeInstanceOf(EvalError); + } + }); + + test('comprobar que el offset >= 0', () => { + const offset: number = -1; + + try { + const offsetPaging = OffsetPaging.create(offset, 100); + } catch (error) { + expect(error).toBeInstanceOf(RangeError); + } + }); + + test('comprobar que el offset <= MAXIMAL_LIMIT', () => { + const offset: number = 0; + const excedeMaximo: number = OffsetPaging.LIMIT_MAXIMAL_VALUE + 1; + + try { + const offsetPaging = OffsetPaging.create(offset, excedeMaximo); + } catch (error) { + expect(error).toBeInstanceOf(RangeError); + } + }); +}); + +describe.skip('CursorPaging', () => { + beforeAll(() => { }); + + beforeEach(() => { }); + + test('comprobar que se crea correctamente', () => { + const cursor: string = 'cursor'; + const limit: number = 10; + + const cursorPaging = CursorPaging.create(cursor, limit); + + expect(cursorPaging.start).toEqual(cursor); + expect(cursorPaging.limit).toEqual(limit); + }); + + test('comprobar que el cursor es obligatorio', () => { + const cursor: string = undefined; + + try { + const cursorPaging = CursorPaging.create(cursor, 10); + } catch (error) { + expect(error).toBeInstanceOf(EvalError); + } + }); +}); \ No newline at end of file diff --git a/shared/lib/contexts/common/domain/entities/QueryCriteria/Pagination/index.ts b/shared/lib/contexts/common/domain/entities/QueryCriteria/Pagination/index.ts new file mode 100644 index 0000000..1d3ce99 --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/QueryCriteria/Pagination/index.ts @@ -0,0 +1 @@ +export * from './OffsetPaging'; \ No newline at end of file diff --git a/shared/lib/contexts/common/domain/entities/QueryCriteria/QueryCriteria.ts b/shared/lib/contexts/common/domain/entities/QueryCriteria/QueryCriteria.ts new file mode 100644 index 0000000..e48337c --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/QueryCriteria/QueryCriteria.ts @@ -0,0 +1,75 @@ +import { Result } from "../Result"; +import { ValueObject } from "../ValueObject"; +import { FilterCriteria } from "./Filters"; +import { OrderCriteria } from "./Order"; +import { OffsetPaging } from "./Pagination"; +import { QuickSearchCriteria } from "./QuickSearch"; + +export interface IQueryCriteriaProps { + pagination: OffsetPaging; + order: OrderCriteria; + //fields: FieldCriteria; // fields=first_name,last_name,tin + filters: FilterCriteria; + quickSearch: QuickSearchCriteria; +} + +export interface IQueryCriteria { + pagination: OffsetPaging; + order: OrderCriteria; + //fields: string; + filters: FilterCriteria; + quickSearch: QuickSearchCriteria; + + toJSON(): string; + toObject(): Record; +} + +export class QueryCriteria + extends ValueObject + implements IQueryCriteria +{ + public static create(props: IQueryCriteriaProps): Result { + return Result.ok( + new QueryCriteria({ + pagination: props.pagination, + //fields: props.fields, + order: props.order, + filters: props.filters, + quickSearch: props.quickSearch, + }) + ); + } + + public get pagination(): OffsetPaging { + return this.props.pagination; + } + + public get order(): OrderCriteria { + return this.props.order; + } + + public get filters(): FilterCriteria { + return this.props.filters; + } + + public get quickSearch(): QuickSearchCriteria { + return this.props.quickSearch; + } + + public toObject(): Record { + return { + pagination: this.pagination?.toObject(), + order: this.order?.toObject(), + filters: this.filters?.toObject(), + quickSearch: this.quickSearch?.toString(), + }; + } + + public toJSON(): string { + return JSON.stringify(this.toObject()); + } + + public toPrimitive(): string { + throw new Error("NOT IMPLEMENT QueryCriteria.toPrimitive()"); + } +} diff --git a/shared/lib/contexts/common/domain/entities/QueryCriteria/QuickSearch/QuickSearchCriteria.ts b/shared/lib/contexts/common/domain/entities/QueryCriteria/QuickSearch/QuickSearchCriteria.ts new file mode 100644 index 0000000..106a048 --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/QueryCriteria/QuickSearch/QuickSearchCriteria.ts @@ -0,0 +1,66 @@ +import Joi from "joi"; +import { UndefinedOr } from "../../../../../../utilities"; +import { RuleValidator } from "../../../RuleValidator"; +import { Result } from "../../Result"; +import { StringValueObject } from "../../StringValueObject"; + +export interface IQuickSearchCriteria { + searchTerm: string; + toJSON(): string; + toString(): string; +} + +export class QuickSearchCriteria + extends StringValueObject + implements IQuickSearchCriteria +{ + protected static validate(value: UndefinedOr) { + const searchString = value; + + if ( + RuleValidator.validate( + RuleValidator.RULE_NOT_NULL_OR_UNDEFINED, + searchString, + ).isSuccess + ) { + const stringOrError = RuleValidator.validate( + RuleValidator.RULE_IS_TYPE_STRING, + searchString, + ); + + if (stringOrError.isFailure) { + return stringOrError; + } + } + + return Result.ok(String(searchString)); + } + + public static create(value: UndefinedOr) { + const stringOrError = this.validate(value); + + if (stringOrError.isFailure) { + return Result.fail(stringOrError.error); + } + + const _term = QuickSearchCriteria.sanitize(stringOrError.object); + + return Result.ok(new QuickSearchCriteria(_term)); + } + + private static sanitize(searchTerm: UndefinedOr): string { + return String(Joi.string().trim().validate(searchTerm).value); + } + + get searchTerm(): string { + return this.toString(); + } + + public toJSON(): string { + return JSON.stringify(this.toString()); + } + + public toPrimitive(): string { + return this.toString(); + } +} diff --git a/shared/lib/contexts/common/domain/entities/QueryCriteria/QuickSearch/index.ts b/shared/lib/contexts/common/domain/entities/QueryCriteria/QuickSearch/index.ts new file mode 100644 index 0000000..1ca0ddc --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/QueryCriteria/QuickSearch/index.ts @@ -0,0 +1 @@ +export * from './QuickSearchCriteria'; \ No newline at end of file diff --git a/shared/lib/contexts/common/domain/entities/QueryCriteria/index.ts b/shared/lib/contexts/common/domain/entities/QueryCriteria/index.ts new file mode 100644 index 0000000..d28a751 --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/QueryCriteria/index.ts @@ -0,0 +1,5 @@ +export * from './QueryCriteria'; +export * from './Filters'; +export * from './Order'; +export * from './Pagination'; +export * from './QuickSearch'; \ No newline at end of file diff --git a/shared/lib/contexts/common/domain/entities/Result.ts b/shared/lib/contexts/common/domain/entities/Result.ts new file mode 100644 index 0000000..cacd802 --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/Result.ts @@ -0,0 +1,63 @@ +export class Result { + protected readonly _object?: T; + protected readonly _error?: E; + + public readonly isSuccess: boolean; + public readonly isFailure: boolean; + + protected constructor(props: { isSuccess: boolean; error?: E; object?: T }) { + const { isSuccess, error, object } = props; + if (isSuccess && error) { + throw new Error( + `InvalidOperation: A result cannot be successful and contain an error`, + ); + } + if (!isSuccess && !error) { + throw new Error( + `InvalidOperation: A failing result needs to contain an error message`, + ); + } + + this.isSuccess = isSuccess; + this.isFailure = !isSuccess; + this._error = error; + this._object = object; + + Object.freeze(this); + } + + public get object(): T { + if (this.isFailure) { + throw new Error(`Result is not successful`); + } + + return this._object as T; + } + + public get error(): E { + if (this.isSuccess) { + throw new Error(`Result is not error`); + } + + return this._error as E; + } + + // Constructores públicos + public static ok(object?: U): Result { + return new Result({ isSuccess: true, object }); + } + + public static fail(error?: E): Result { + return new Result({ isSuccess: false, error }); + } + + public static combine(results: Result[]): Result { + for (const result of results) { + if (result.isFailure) { + return result; + } + } + + return Result.ok(); + } +} diff --git a/shared/lib/contexts/common/domain/entities/ResultCollection.ts b/shared/lib/contexts/common/domain/entities/ResultCollection.ts new file mode 100644 index 0000000..0e852b2 --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/ResultCollection.ts @@ -0,0 +1,49 @@ +import { Result } from "./Result"; + +export interface IResultCollection { + add(result: Result): void; + hasSomeFaultyResult(): boolean; + getFirstFaultyResult(): Result; +} + +export class ResultCollection + implements IResultCollection +{ + private _collection: Result[] = []; + + constructor(results?: Result[]) { + this._collection = results ?? []; + } + + public add(result: Result): void { + this._collection.push(result); + } + + public reset(): void { + this._collection = []; + } + + public hasSomeFaultyResult(): boolean { + return this._collection.some((result) => result.isFailure); + } + + public getFirstFaultyResult(): Result { + return this._collection.find((result) => result.isFailure)!; + } + + public getAllFaultyResults(): Result[] { + return this._collection.filter((result) => result.isFailure); + } + + public get objects(): T[] { + return this._collection + .filter((result) => result.isSuccess) + .map((result) => result.object); + } + + public get errors(): E[] { + return this._collection + .filter((result) => result.isFailure) + .map((result) => result.error); + } +} diff --git a/shared/lib/contexts/common/domain/entities/Slug.ts b/shared/lib/contexts/common/domain/entities/Slug.ts new file mode 100644 index 0000000..6170ff1 --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/Slug.ts @@ -0,0 +1,90 @@ +import Joi from "joi"; +import { UndefinedOr } from "../../../../utilities"; + +import { RuleValidator } from "../RuleValidator"; +import { Result } from "./Result"; +import { + IStringValueObjectOptions, + StringValueObject, +} from "./StringValueObject"; + +export class Slug extends StringValueObject { + protected static readonly MIN_LENGTH = 2; + protected static readonly MAX_LENGTH = 100; + + protected static validate( + value: UndefinedOr, + options: IStringValueObjectOptions, + ) { + const rule = Joi.string() + .allow(null) + .allow("") + .default("") + .trim() + .regex(/^[a-z0-9-]+$/) + .min(Slug.MIN_LENGTH) + .max(Slug.MAX_LENGTH) + .label(options.label ? options.label : "value"); + + return RuleValidator.validate(rule, value); + /*if (slug === undefined) { + return Result.ok(""); + } + + const _slug = String(slug); + + if (!_slug || _slug.trim().length < this.MIN_LENGTH) { + return Result.fail( + new TooShortSlugError( + `Slug must be at least ${this.MIN_LENGTH} characters long.`, + ), + ); + } + + if (_slug.trim().length > this.MAX_LENGTH) { + return Result.fail( + new TooLongSlugError( + `Slug must be at most ${this.MAX_LENGTH} characters long.`, + ), + ); + } + + // Implement your slug validation logic here + // For example, you can check if it contains only lowercase letters, numbers, and hyphens + const slugRegex = /^[a-z0-9-]+$/; + if (!slugRegex.test(_slug)) { + return Result.fail( + new InvalidSlugError(`Invalid slug value`), + ); + } + + return Result.ok(_slug); + */ + } + + private static sanitize(slug: string): string { + return slug ? slug.trim() : ""; + } + + public static create( + value: UndefinedOr, + options: IStringValueObjectOptions = {}, + ) { + const _options = { + label: "slug", + ...options, + }; + + const validationResult = Slug.validate(value, _options); + + if (validationResult.isFailure) { + return Result.fail(validationResult.error); + } + + return Result.ok(new Slug(this.sanitize(validationResult.object))); + } +} + +export class InvalidSlugError extends Error {} +export class TooShortSlugError extends Error {} +export class TooLongSlugError extends Error {} diff --git a/shared/lib/contexts/common/domain/entities/StringValueObject.ts b/shared/lib/contexts/common/domain/entities/StringValueObject.ts new file mode 100644 index 0000000..abc2fa4 --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/StringValueObject.ts @@ -0,0 +1,22 @@ +import { UndefinedOr } from "../../../../utilities"; +import { IValueObjectOptions, ValueObject } from "./ValueObject"; + +export interface IStringValueObjectOptions extends IValueObjectOptions {} + +export class StringValueObject extends ValueObject> { + public isEmpty = (): boolean => { + return this.toString().length === 0; + }; + + public toString = (): string => { + return this.props ? String(this.props) : ""; + }; + + public toPrimitive(): string { + return this.toString(); + } + + get value(): UndefinedOr { + return !this.isEmpty() ? this.props : undefined; + } +} diff --git a/shared/lib/contexts/common/domain/entities/TINNumber.ts b/shared/lib/contexts/common/domain/entities/TINNumber.ts new file mode 100644 index 0000000..d385623 --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/TINNumber.ts @@ -0,0 +1,58 @@ +import Joi from "joi"; +import { UndefinedOr } from "../../../../utilities"; +import { RuleValidator } from "../RuleValidator"; +import { DomainError, handleDomainError } from "../errors"; +import { Result } from "./Result"; +import { + IStringValueObjectOptions, + StringValueObject, +} from "./StringValueObject"; + +export interface ITINNumberOptions extends IStringValueObjectOptions {} + +export class TINNumber extends StringValueObject { + private static readonly MIN_LENGTH = 2; + private static readonly MAX_LENGTH = 10; + + protected static validate( + value: UndefinedOr, + options: ITINNumberOptions + ) { + const rule = Joi.string() + .allow(null) + .allow("null") + .allow("") + .default("") + .trim() + .min(TINNumber.MIN_LENGTH) + .max(TINNumber.MAX_LENGTH) + + .label(options.label ? options.label : "value"); + + return RuleValidator.validate(rule, value); + } + + public static create( + value: UndefinedOr, + options: ITINNumberOptions = {} + ) { + const _options = { + label: "TIN", + ...options, + }; + + const validationResult = TINNumber.validate(value, _options); + + if (validationResult.isFailure) { + return Result.fail( + handleDomainError( + DomainError.INVALID_INPUT_DATA, + validationResult.error.message, + _options + ) + ); + } + + return Result.ok(new TINNumber(validationResult.object)); + } +} diff --git a/shared/lib/contexts/common/domain/entities/UTCDateValue.test.ts b/shared/lib/contexts/common/domain/entities/UTCDateValue.test.ts new file mode 100644 index 0000000..f74771d --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/UTCDateValue.test.ts @@ -0,0 +1,55 @@ +import { UTCDateValue } from "./UTCDateValue"; // Asegúrate de importar la clase adecuadamente + +describe("UTCDateValue Value Object", () => { + it("should create an instance with the current date", () => { + const result = UTCDateValue.createCurrentDate(); + expect(result.isSuccess).toBe(true); + + const currentDate = result.object; + + expect(currentDate.isEmpty()).toBe(false); + + expect(currentDate.toString()).toMatch(/^\d{4}-\d{2}-\d{2}$/); // Verifica el formato 'YYYY-MM-DD' + }); + + it("should create an instance from a valid date string", () => { + const validDateString = "1999-12-31"; + + const result = UTCDateValue.create(validDateString); + expect(result.isSuccess).toBe(true); + + const validDate = result.object; + + expect(validDate.isEmpty()).toBe(false); + expect(validDate.toString()).toBe(validDateString); + }); + + it("should create an instance with an empty date string", () => { + const result = UTCDateValue.create(null); + + expect(result.isSuccess).toBe(true); + + const emptyDate = result.object; + + expect(emptyDate.isEmpty()).toBe(true); + expect(emptyDate.toString()).toBe(""); + }); + + it("should create an instance with an empty date string", () => { + const result = UTCDateValue.create(null); + + expect(result.isSuccess).toBe(true); + + const emptyDate = result.object; + + expect(emptyDate.isEmpty()).toBe(true); + expect(emptyDate.value).toBe(null); + }); + + + it("should be failure for an invalid date string (result)", () => { + const invalidDateString = "2023-13-45"; // Fecha inválida + const result = UTCDateValue.create(invalidDateString); + expect(result.isFailure).toBe(true); + }); +}); diff --git a/shared/lib/contexts/common/domain/entities/UTCDateValue.ts b/shared/lib/contexts/common/domain/entities/UTCDateValue.ts new file mode 100644 index 0000000..68e33f7 --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/UTCDateValue.ts @@ -0,0 +1,78 @@ +import Joi from "joi"; +import { EmptyOr } from "../../../../utilities"; +import { RuleValidator } from "../RuleValidator"; +import { Result } from "./Result"; +import { IValueObjectOptions, ValueObject } from "./ValueObject"; + +export interface IDateValueOptions extends IValueObjectOptions { + dateFormat?: string; +} + +export class UTCDateValue extends ValueObject { + protected static validate( + value: EmptyOr, + options: IDateValueOptions, + ) { + const ruleIsEmpty = RuleValidator.RULE_ALLOW_EMPTY.default(0); + const rulesIsDate = Joi.date() + //.format(String(options.dateFormat)) + .label(String(options.label)); + + const rules = Joi.alternatives(ruleIsEmpty, rulesIsDate); + + return RuleValidator.validate(rules, value); + } + + public static createCurrentDate() { + return Result.ok(new UTCDateValue(new Date())); + } + + public static create( + value: EmptyOr, + options: IDateValueOptions = {}, + ) { + const _options = { + ...options, + dateFormat: options.dateFormat ? options.dateFormat : "YYYY-MM-DD", + label: options.label ? options.label : "date", + }; + + const validationResult = UTCDateValue.validate(value, _options); + + if (validationResult.isFailure) { + return Result.fail(validationResult.error); + } + + return Result.ok( + new UTCDateValue(new Date(validationResult.object)), + ); + } + + public isValid = (): boolean => { + return !isNaN(this.props.valueOf()) && this.props.valueOf() !== 0; + }; + + public isEmpty = (): boolean => { + return !this.isValid(); + }; + + public toISO8601 = (): string => { + return this.isValid() ? this.props.toISOString() : ""; + }; + + public toString(): string { + if (!this.isEmpty()) { + const year = this.props.getFullYear(); + const month = String(this.props.getMonth() + 1).padStart(2, "0"); + const day = String(this.props.getDate()).padStart(2, "0"); + + return String(`${year}-${month}-${day}`); + } + + return String(""); + } + + public toPrimitive(): string { + return this.toISO8601(); + } +} diff --git a/shared/lib/contexts/common/domain/entities/UniqueID.ts b/shared/lib/contexts/common/domain/entities/UniqueID.ts new file mode 100644 index 0000000..c709a44 --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/UniqueID.ts @@ -0,0 +1,87 @@ +import { v4 as uuidv4 } from "uuid"; + +import Joi from "joi"; +import { NullOr, UndefinedOr } from "../../../../utilities"; +import { RuleValidator } from "../RuleValidator"; +import { DomainError, handleDomainError } from "../errors"; +import { + INullableValueObjectOptions, + NullableValueObject, +} from "./NullableValueObject"; +import { Result } from "./Result"; + +export interface IUniqueIDOptions extends INullableValueObjectOptions { + generateOnEmpty?: boolean; +} + +export class UniqueID extends NullableValueObject { + protected static validate( + value: UndefinedOr, + options: IUniqueIDOptions + ) { + const ruleIsEmpty = RuleValidator.RULE_ALLOW_EMPTY.default(""); + + const ruleIsGuid = Joi.string() + .guid({ + version: ["uuidv4"], + }) + .label(options.label ? options.label : "id"); + + const rules = Joi.alternatives(ruleIsEmpty, ruleIsGuid); + + return RuleValidator.validate(rules, value); + } + + private static sanitize(id: string): string { + return id.trim(); + } + + public static create(value: NullOr, options: IUniqueIDOptions = {}) { + const _options: IUniqueIDOptions = { + label: "id", + generateOnEmpty: false, + ...options, + }; + + if (value) { + const validationResult = UniqueID.validate(value, _options); + + if (validationResult.isFailure) { + return Result.fail( + handleDomainError( + DomainError.INVALID_INPUT_DATA, + validationResult.error + ) + ); + } + + return Result.ok( + new UniqueID(UniqueID.sanitize(validationResult.object)) + ); + } + + if (_options.generateOnEmpty) { + return UniqueID.generateNewID(); + } + + return Result.ok(new UniqueID(null)); + } + + public static generateNewID(): Result { + return Result.ok(new UniqueID(uuidv4())); + } + + get value(): string { + return String(this.props); + } + + public toString(): string { + return String(this.props); + } + + public toPrimitive(): string { + return this.toString(); + } +} + +export class InvalidUniqueIDError extends Error {} diff --git a/shared/lib/contexts/common/domain/entities/UnitPrice.ts b/shared/lib/contexts/common/domain/entities/UnitPrice.ts new file mode 100644 index 0000000..13889ff --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/UnitPrice.ts @@ -0,0 +1,28 @@ +import { NullOr } from '../../../../utilities'; +import { MoneyValue } from './MoneyValue'; +import { Result } from './Result'; + +export interface IUnitPriceProps { + amount: NullOr; + currencyCode?: string; + precision: number; +} + +export class UnitPrice extends MoneyValue { + public static create(props: IUnitPriceProps) { + const {amount, currencyCode, precision = 4} = props; + + const _unitPriceOrError = MoneyValue.create({ + amount, + currencyCode, + precision, + }); + if (_unitPriceOrError.isFailure) { + return _unitPriceOrError; + } + + const _unitPrice = _unitPriceOrError.object.convertPrecision(4); + + return Result.ok(_unitPrice); + } +} diff --git a/shared/lib/contexts/common/domain/entities/ValueObject.ts b/shared/lib/contexts/common/domain/entities/ValueObject.ts new file mode 100644 index 0000000..a84c483 --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/ValueObject.ts @@ -0,0 +1,39 @@ +import { shallowEqual } from "shallow-equal-object"; + +type Primitive = string | boolean | number; + +export interface IValueObjectOptions { + label?: string; + path?: string; +} + +export abstract class ValueObject { + readonly props: T; + + constructor(value: T) { + this.props = typeof value === "object" ? Object.freeze(value) : value; + } + + get value(): T { + return this.props; + } + + public abstract toPrimitive(): Primitive; + + public equals(vo: ValueObject): boolean { + if (vo === null || vo === undefined) { + return false; + } + + if (vo.props === undefined) { + return false; + } + + return shallowEqual(this.props, vo.props); + } +} + +/*export type TComposedValueObject = { + [key: string]: ValueObject; +}; +*/ diff --git a/shared/lib/contexts/common/domain/entities/index.ts b/shared/lib/contexts/common/domain/entities/index.ts new file mode 100644 index 0000000..569c43e --- /dev/null +++ b/shared/lib/contexts/common/domain/entities/index.ts @@ -0,0 +1,27 @@ +export * from "./Address"; +export * from "./AggregateRoot"; +export * from "./Collection"; +export * from "./Currency"; +export * from "./Description"; +export * from "./Email"; +export * from "./Entity"; +export * from "./Language"; +export * from "./Measure"; +export * from "./MoneyValue"; +export * from "./Name"; +export * from "./Note"; +export * from "./NullableValueObject"; +export * from "./Percentage"; +export * from "./Phone"; +export * from "./Quantity"; +export * from "./Result"; +export * from "./ResultCollection"; +export * from "./Slug"; +export * from "./StringValueObject"; +export * from "./TINNumber"; +export * from "./UTCDateValue"; +export * from "./UniqueID"; +export * from "./UnitPrice"; +export * from "./ValueObject"; + +export * from "./QueryCriteria"; diff --git a/shared/lib/contexts/common/domain/errors/DomainError.ts b/shared/lib/contexts/common/domain/errors/DomainError.ts new file mode 100755 index 0000000..47d375b --- /dev/null +++ b/shared/lib/contexts/common/domain/errors/DomainError.ts @@ -0,0 +1,39 @@ +import { GenericError, IGenericError } from "./GenericError"; + +export interface IDomainError extends IGenericError {} + +function _isJoiError(error: Error) { + return error.name === "ValidationError"; +} + +export class DomainError extends GenericError implements IDomainError { + public static readonly INVALID_INPUT_DATA = "INVALID_INPUT_DATA"; + + public static create( + code: string, + message: string, + payload?: Record + ): DomainError { + return new DomainError(code, message, payload); + } +} + +export function handleDomainError2( + code: string, + error: Error, + payload?: Record +): DomainError { + if (_isJoiError(error)) { + // ?? + } + + return DomainError.create(code, error.message, payload); +} + +export function handleDomainError( + code: string, + message?: string, + payload?: Record +): DomainError { + return DomainError.create(code, message, payload); +} diff --git a/shared/lib/contexts/common/domain/errors/GenericError.ts b/shared/lib/contexts/common/domain/errors/GenericError.ts new file mode 100644 index 0000000..47a0d08 --- /dev/null +++ b/shared/lib/contexts/common/domain/errors/GenericError.ts @@ -0,0 +1,20 @@ +export interface IGenericError extends Error { + code: string; + payload: Record; +} + +export class GenericError extends Error implements IGenericError { + public readonly code: string; + public readonly payload: Record = {}; + + protected constructor(code: string, message: string, payload = {}) { + super(message); + + this.name = this.constructor.name; + this.code = code; + this.payload = payload; + + // 👇️ because we are extending a built-in class + Object.setPrototypeOf(this, GenericError.prototype); + } +} diff --git a/shared/lib/contexts/common/domain/errors/index.ts b/shared/lib/contexts/common/domain/errors/index.ts new file mode 100644 index 0000000..c5adab6 --- /dev/null +++ b/shared/lib/contexts/common/domain/errors/index.ts @@ -0,0 +1,2 @@ +export * from "./DomainError"; +export * from "./GenericError"; diff --git a/shared/lib/contexts/common/domain/events/DomainEventInterface.ts b/shared/lib/contexts/common/domain/events/DomainEventInterface.ts new file mode 100644 index 0000000..87960cb --- /dev/null +++ b/shared/lib/contexts/common/domain/events/DomainEventInterface.ts @@ -0,0 +1,6 @@ +import { UniqueID } from "../entities"; + +export interface IDomainEvent { + occurredOn: Date; + getAggregateId(): UniqueID; +} diff --git a/shared/lib/contexts/common/domain/events/DomainEvents.ts b/shared/lib/contexts/common/domain/events/DomainEvents.ts new file mode 100644 index 0000000..9ca6ee6 --- /dev/null +++ b/shared/lib/contexts/common/domain/events/DomainEvents.ts @@ -0,0 +1,95 @@ +import { AggregateRoot, UniqueID } from '../entities'; +import { IDomainEvent } from './DomainEventInterface'; + +export class DomainEvents { + private static handlersMap: { [key: string]: any } = {}; + private static markedAggregates: AggregateRoot[] = []; + + /** + * @method markAggregateForDispatch + * @static + * @desc Called by aggregate root objects that have created domain + * events to eventually be dispatched when the infrastructure commits + * the unit of work. + */ + + public static markAggregateForDispatch( + aggregate: AggregateRoot + ): void { + const aggregateFound = !!this.findMarkedAggregateByID(aggregate.id); + + if (!aggregateFound) { + this.markedAggregates.push(aggregate); + } + } + + private static dispatchAggregateEvents( + aggregate: AggregateRoot + ): void { + aggregate.domainEvents.forEach((event: IDomainEvent) => + this.dispatch(event) + ); + } + + private static removeAggregateFromMarkedDispatchList( + aggregate: AggregateRoot + ): void { + const index = this.markedAggregates.findIndex((a) => + a.equals(aggregate) + ); + this.markedAggregates.splice(index, 1); + } + + private static findMarkedAggregateByID( + id: UniqueID + ): AggregateRoot | undefined { + let found: AggregateRoot | undefined = undefined; + + for (const aggregate of this.markedAggregates) { + if (aggregate.id.equals(id)) { + found = aggregate; + } + } + + return found; + } + + public static dispatchEventsForAggregate(id: UniqueID): void { + const aggregate = this.findMarkedAggregateByID(id); + + if (aggregate) { + this.dispatchAggregateEvents(aggregate); + aggregate.clearEvents(); + this.removeAggregateFromMarkedDispatchList(aggregate); + } + } + + public static register( + callback: (event: IDomainEvent) => void, + eventClassName: string + ): void { + if (!this.handlersMap.hasOwnProperty(eventClassName)) { + this.handlersMap[eventClassName] = []; + } + this.handlersMap[eventClassName].push(callback); + } + + public static clearHandlers(): void { + this.handlersMap = {}; + } + + public static clearMarkedAggregates(): void { + this.markedAggregates = []; + } + + private static dispatch(event: IDomainEvent): void { + const eventClassName: string = event.constructor.name; + + if (this.handlersMap.hasOwnProperty(eventClassName)) { + const handlers: any[] = this.handlersMap[eventClassName]; + for (const handler of handlers) { + handler(event); + } + } + } +} diff --git a/shared/lib/contexts/common/domain/events/HandleInterface.ts b/shared/lib/contexts/common/domain/events/HandleInterface.ts new file mode 100644 index 0000000..c19181e --- /dev/null +++ b/shared/lib/contexts/common/domain/events/HandleInterface.ts @@ -0,0 +1,3 @@ +export interface IHandleEvent { + setupSubscriptions(): void; +} diff --git a/shared/lib/contexts/common/domain/events/index.ts b/shared/lib/contexts/common/domain/events/index.ts new file mode 100644 index 0000000..67f52de --- /dev/null +++ b/shared/lib/contexts/common/domain/events/index.ts @@ -0,0 +1,3 @@ +export * from './DomainEventInterface'; +export * from './DomainEvents'; +export * from './HandleInterface'; diff --git a/shared/lib/contexts/common/domain/events/xxx__tests__xxxx/domainEvents.txst.ts b/shared/lib/contexts/common/domain/events/xxx__tests__xxxx/domainEvents.txst.ts new file mode 100755 index 0000000..23b5858 --- /dev/null +++ b/shared/lib/contexts/common/domain/events/xxx__tests__xxxx/domainEvents.txst.ts @@ -0,0 +1,139 @@ + +import * as sinon from 'sinon' +import { DomainEvents } from '../domainEvents'; +import { MockJobCreatedEvent } from './mocks/events/mockJobCreatedEvent' +import { MockJobDeletedEvent } from './mocks/events/mockJobDeletedEvent' +import { MockJobAggregateRoot } from './mocks/domain/mockJobAggregateRoot' +import { MockPostToSocial } from './mocks/services/mockPostToSocial' +import { MockJobAggregateRootID } from './mocks/domain/mockJobAggregateRootID'; +import { UniqueEntityID } from '../../entities/UniqueEntityID'; + +let social: MockPostToSocial; +let job: MockJobAggregateRoot; +let spy; + +describe('Domain Events', () => { + + beforeEach(() => { + social = null; + DomainEvents.clearHandlers(); + DomainEvents.clearMarkedAggregates(); + spy = null; + job = null; + }) + + describe('Given a JobCreatedEvent, JobDeletedEvent and a PostToSocial handler class', () => { + it('Should be able to setup event subscriptions', () => { + social = new MockPostToSocial(); + social.setupSubscriptions(); + + expect(Object.keys(DomainEvents['handlersMap']).length).toBe(2); + + expect(DomainEvents['handlersMap'][MockJobCreatedEvent.name].length).toBe(1); + expect(DomainEvents['handlersMap'][MockJobDeletedEvent.name].length).toBe(1); + }) + + it('There should be exactly one handler subscribed to the JobCreatedEvent', () => { + social = new MockPostToSocial(); + social.setupSubscriptions(); + + expect(DomainEvents['handlersMap'][MockJobCreatedEvent.name].length).toBe(1); + }) + + it('There should be exactly one handler subscribed to the JobDeletedEvent', () => { + social = new MockPostToSocial(); + social.setupSubscriptions(); + + expect(DomainEvents['handlersMap'][MockJobCreatedEvent.name].length).toBe(1); + }) + + it('Should add the event to the DomainEvents list when the event is created', () => { + job = MockJobAggregateRoot.createJob({}, MockJobAggregateRootID); + social = new MockPostToSocial(); + social.setupSubscriptions(); + + var domainEventsAggregateSpy = sinon.spy(DomainEvents, "markAggregateForDispatch"); + + // setTimeout(() => { + // expect(domainEventsAggregateSpy.calledOnce).toBeTruthy(); + // expect(domainEventsAggregateSpy.callCount).toBe(0) + // expect(DomainEvents['markedAggregates'][0]['length']).toBe(1); + // }, 1000); + }); + + it('Should call the handlers when the event is dispatched after marking the aggregate root', () => { + + social = new MockPostToSocial(); + social.setupSubscriptions(); + + var jobCreatedEventSpy = sinon.spy(social, "handleJobCreatedEvent"); + var jobDeletedEventSpy = sinon.spy(social, "handleDeletedEvent"); + + // Create the event, mark the aggregate + job = MockJobAggregateRoot.createJob({}, MockJobAggregateRootID); + + // Dispatch the events now + DomainEvents.dispatchEventsForAggregate(MockJobAggregateRootID); + + // setTimeout(() => { + // expect(jobCreatedEventSpy.calledOnce).toBeFalsy(); + // expect(jobDeletedEventSpy.calledOnce).toBeTruthy(); + // }, 1000); + }); + + it('Should remove the marked aggregate from the marked aggregates list after it gets dispatched', () => { + social = new MockPostToSocial(); + social.setupSubscriptions(); + + // Create the event, mark the aggregate + job = MockJobAggregateRoot.createJob({}, MockJobAggregateRootID); + + // Dispatch the events now + DomainEvents.dispatchEventsForAggregate(MockJobAggregateRootID); + + // setTimeout(() => { + // expect(DomainEvents['markedAggregates']['length']).toBe(0); + // }, 1000); + }); + + it('Should only add the domain event to the ', () => { + social = new MockPostToSocial(); + social.setupSubscriptions(); + + // Create the event, mark the aggregate + MockJobAggregateRoot.createJob({}, new UniqueEntityID('99')); + expect(DomainEvents['markedAggregates']['length']).toBe(1); + + // Create a new job, it should also get marked + job = MockJobAggregateRoot.createJob({}, new UniqueEntityID('12')); + expect(DomainEvents['markedAggregates']['length']).toBe(2); + + // Dispatch another action from the second job created + job.deleteJob(); + + // The number of aggregates should be the same + expect(DomainEvents['markedAggregates']['length']).toBe(2); + + // However, the second aggregate should have two events now + expect(DomainEvents['markedAggregates'][1].domainEvents.length).toBe(2); + + // And the first aggregate should have one event + expect(DomainEvents['markedAggregates'][0].domainEvents.length).toBe(1); + + // Dispatch the event for the first job + DomainEvents.dispatchEventsForAggregate(new UniqueEntityID('99')); + expect(DomainEvents['markedAggregates']['length']).toBe(1); + + // The job with two events should still be there + expect(DomainEvents['markedAggregates'][0].domainEvents.length).toBe(2); + + // Dispatch the event for the second job + DomainEvents.dispatchEventsForAggregate(new UniqueEntityID('12')); + + // There should be no more domain events in the list + expect(DomainEvents['markedAggregates']['length']).toBe(0); + + + }) + }); +}); diff --git a/shared/lib/contexts/common/domain/events/xxx__tests__xxxx/mocks/domain/mockJobAggregateRoot.ts b/shared/lib/contexts/common/domain/events/xxx__tests__xxxx/mocks/domain/mockJobAggregateRoot.ts new file mode 100755 index 0000000..0cc2071 --- /dev/null +++ b/shared/lib/contexts/common/domain/events/xxx__tests__xxxx/mocks/domain/mockJobAggregateRoot.ts @@ -0,0 +1,26 @@ + +import { AggregateRoot } from "../../../../entities/AggregateRoot"; +import { MockJobCreatedEvent } from '../events/mockJobCreatedEvent' +import { UniqueEntityID } from "../../../../entities/UniqueEntityID"; +import { MockJobDeletedEvent } from "../events/mockJobDeletedEvent"; + +export interface IMockJobProps { + +} + +export class MockJobAggregateRoot extends AggregateRoot { + private constructor (props: IMockJobProps, id?: UniqueEntityID) { + super(props, id); + } + + public static createJob (props: IMockJobProps, id?: UniqueEntityID): MockJobAggregateRoot { + const job = new this(props, id); + job.addDomainEvent(new MockJobCreatedEvent(job.id)); + return job; + } + + public deleteJob (): void { + this.addDomainEvent(new MockJobDeletedEvent(this.id)) + } + +} \ No newline at end of file diff --git a/shared/lib/contexts/common/domain/events/xxx__tests__xxxx/mocks/domain/mockJobAggregateRootID.ts b/shared/lib/contexts/common/domain/events/xxx__tests__xxxx/mocks/domain/mockJobAggregateRootID.ts new file mode 100755 index 0000000..3987ab5 --- /dev/null +++ b/shared/lib/contexts/common/domain/events/xxx__tests__xxxx/mocks/domain/mockJobAggregateRootID.ts @@ -0,0 +1,4 @@ + +import { UniqueEntityID } from "../../../../entities/UniqueEntityID"; + +export const MockJobAggregateRootID = new UniqueEntityID('999'); \ No newline at end of file diff --git a/shared/lib/contexts/common/domain/events/xxx__tests__xxxx/mocks/events/mockJobCreatedEvent.ts b/shared/lib/contexts/common/domain/events/xxx__tests__xxxx/mocks/events/mockJobCreatedEvent.ts new file mode 100755 index 0000000..1338132 --- /dev/null +++ b/shared/lib/contexts/common/domain/events/xxx__tests__xxxx/mocks/events/mockJobCreatedEvent.ts @@ -0,0 +1,17 @@ + +import { IDomainEvent } from "../../../DomainEventInterface"; +import { UniqueEntityID } from "../../../../entities/UniqueEntityID"; + +export class MockJobCreatedEvent implements IDomainEvent { + occurredOn: Date; + id: UniqueEntityID; + + constructor (id: UniqueEntityID) { + this.id = id; + this.occurredOn = new Date(); + } + + getAggregateId (): UniqueEntityID { + return this.id; + } +} \ No newline at end of file diff --git a/shared/lib/contexts/common/domain/events/xxx__tests__xxxx/mocks/events/mockJobDeletedEvent.ts b/shared/lib/contexts/common/domain/events/xxx__tests__xxxx/mocks/events/mockJobDeletedEvent.ts new file mode 100755 index 0000000..7b9e1f6 --- /dev/null +++ b/shared/lib/contexts/common/domain/events/xxx__tests__xxxx/mocks/events/mockJobDeletedEvent.ts @@ -0,0 +1,17 @@ + +import { IDomainEvent } from "../../../DomainEventInterface"; +import { UniqueEntityID } from "../../../../entities/UniqueEntityID"; + +export class MockJobDeletedEvent implements IDomainEvent { + occurredOn: Date; + id: UniqueEntityID; + + constructor (id: UniqueEntityID) { + this.occurredOn = new Date(); + this.id = id; + } + + getAggregateId (): UniqueEntityID { + return this.id; + } +} \ No newline at end of file diff --git a/shared/lib/contexts/common/domain/events/xxx__tests__xxxx/mocks/services/mockPostToSocial.ts b/shared/lib/contexts/common/domain/events/xxx__tests__xxxx/mocks/services/mockPostToSocial.ts new file mode 100755 index 0000000..252c944 --- /dev/null +++ b/shared/lib/contexts/common/domain/events/xxx__tests__xxxx/mocks/services/mockPostToSocial.ts @@ -0,0 +1,33 @@ +import { DomainEvents } from "../../../DomainEvents"; +import { IHandleEvent } from "../../../HandleInterface"; +import { MockJobCreatedEvent } from "../events/mockJobCreatedEvent"; +import { MockJobDeletedEvent } from "../events/mockJobDeletedEvent"; + +export class MockPostToSocial + implements + IHandleEvent, + IHandleEvent +{ + constructor() {} + + /** + * This is how we may setup subscriptions to domain events. + */ + + setupSubscriptions(): void { + DomainEvents.register(this.handleJobCreatedEvent, MockJobCreatedEvent.name); + DomainEvents.register(this.handleDeletedEvent, MockJobDeletedEvent.name); + } + + /** + * These are examples of how we define the handlers for domain events. + */ + + handleJobCreatedEvent(event: MockJobCreatedEvent): void { + console.log("A job was created!!!"); + } + + handleDeletedEvent(event: MockJobDeletedEvent): void { + console.log("A job was deleted!!!"); + } +} diff --git a/shared/lib/contexts/common/domain/index.ts b/shared/lib/contexts/common/domain/index.ts new file mode 100644 index 0000000..aa6c71c --- /dev/null +++ b/shared/lib/contexts/common/domain/index.ts @@ -0,0 +1,5 @@ +export * from "./IListResponse.dto"; +export * from "./RuleValidator"; +export * from "./entities"; +export * from "./errors"; +export * from "./events"; diff --git a/shared/lib/contexts/common/domain/spanish-joi-messages.json b/shared/lib/contexts/common/domain/spanish-joi-messages.json new file mode 100644 index 0000000..673659d --- /dev/null +++ b/shared/lib/contexts/common/domain/spanish-joi-messages.json @@ -0,0 +1,93 @@ +{ + "any.unknown": "{{#label}}: no está permitido", + "any.invalid": "{{#label}}: contiene un valor invalido", + "any.empty": "{{#label}}: no está permitido que sea vacío", + "any.required": "{{#label}}: es un campo requerido", + "any.allowOnly": "{{#label}}: debería ser uno de las siguientes variantes: {{valids}}", + "any.default": "emitió un error cuando se ejecutó el metodo default", + "alternatives.base": "{{#label}}: no coincide con ninguna de las alternativas permitidas", + "array.base": "{{#label}}: debe ser un array", + "array.includes": "{{#label}}: en la posición {{pos}} no coincide con ninguno de los tipos permitidos", + "array.includesSingle": "{{#label}}: el valor de \"{{!key}}\" no coincide con ninguno de los tipos permitidos", + "array.includesOne": "{{#label}}: en la posición {{pos}} falló porque {{reason}}", + "array.includesOneSingle": "{{#label}}: el valor \"{{!key}}\" falló porque {{reason}}", + "array.includesRequiredUnknowns": "{{#label}}: no contiene valor/es requerido/s: {{unknownMisses}} ", + "array.includesRequiredKnowns": "{{#label}}: no contiene: {{knownMisses}}", + "array.includesRequiredBoth": "{{#label}}: no contiene {{knownMisses}} y {{unknownMisses}} otros valores requeridos", + "array.excludes": "{{#label}}: en la posición {{pos}} contiene un valor excluído", + "array.excludesSingle": "{{#label}}: el valor \"{{!key}}\" contiene un valor excluído", + "array.min": "{{#label}}: debe contener al menos {{limit}} items", + "array.max": "{{#label}}: debe contener máximo {{limit}} items", + "array.length": "{{#label}}: debe contener exactamente {{limit}} items", + "array.ordered": "{{#label}}: en la posición {{pos}} falló porque {{reason}}", + "array.orderedLength": "{{#label}}: en la posición {{pos}} falló porque el array debre contener como máximo {{limit}} items", + "array.sparse": "{{#label}}: no debe ser un array esparcido", + "array.unique": "{{#label}}: posición {{pos}} contiene un valor duplicado", + "boolean.base": "{{#label}}: debe ser un valor verdadero/falso o si/no", + "binary.base": "{{#label}}: debe ser un buffer o un string", + "binary.min": "{{#label}}: debe ser como mínimo de {{limit}} bytes", + "binary.max": "{{#label}}: debe ser como máximo de {{limit}} bytes", + "binary.length": "{{#label}}: debe tener exactamente {{limit}} bytes", + "date.base": "{{#label}}: debe ser una cantidad de milisegundos o una fecha en cadena de texto válida", + "date.min": "{{#label}}: debe ser mayor o igual a \"{{limit}}\"", + "date.max": "{{#label}}: debe ser menor o igual que \"{{limit}}\"", + "date.isoDate": "{{#label}}: debe ser una fecha en formato ISO 8601", + "date.ref": "referencia a \"{{ref}}\", que no es una fecha válida", + "function.base": "{{#label}}: debe ser una función", + "object.base": "{{#label}}: debe ser un objeto", + "object.child": "hijo \"{{!key}}\" falló porque {{reason}}", + "object.min": "{{#label}}: debe tener como mínimo {{limit}} hijo", + "object.max": "{{#label}}: debe tener menos o a lo sumo {{limit}} hijo", + "object.length": "{{#label}}: debe tener máximo {{limit}} hijo/s", + "object.allowUnknown": "no está permitido", + "object.with": "peer faltante: \"{{peer}}\"", + "object.without": "conflicto con peer prohibido: \"{{peer}}\"", + "object.missing": "{{#label}}: debe contener al menos uno de: {{peers}}", + "object.xor": "{{#label}}: contiene un conflicto con alguno de: {{peers}}", + "object.or": "{{#label}}: debe contener al menos uno de: {{peers}}", + "object.and": "contiene {{present}} sin el requerido: {{missing}}", + "object.nand": "!!\"{{main}}\" no debe existir simultáneamente con {{peers}}", + "object.assert": "!!\"{{ref}}\" falló validacion porque \"{{ref}}\" falló a {{message}}", + "object.rename.multiple": "{{#label}}: no se puede renombrar el hijo \"{{from}}\" porque múltiples re-nombramientos estan deshabilitados y otra clave fue renombrada a \"{{to}}\"", + "object.rename.override": "{{#label}}: no se puede renombrar el hijo \"{{from}}\" porque la sobre escritura esta deshabilitada y el target \"{{to}}\" existe", + "object.type": "{{#label}}: debe ser una instancia de \"{{type}}\"", + "number.base": "{{#label}}: debe ser un número", + "number.min": "{{#label}}: debe ser mayor o igual que {{limit}}", + "number.max": "{{#label}}: debe ser menor o igual que {{limit}}", + "number.less": "{{#label}}: debe ser menor a {{limit}}", + "number.greater": "{{#label}}: debe ser mayor a {{limit}}", + "number.float": "{{#label}}: debe ser un numero flotante", + "number.integer": "{{#label}}: debe ser un número entero", + "number.negative": "{{#label}}: debe ser un número negativo", + "number.positive": "{{#label}}: debe ser un número positivo", + "number.precision": "{{#label}}: no debe tener mas de {{limit}} decimales", + "number.ref": "{{#label}}: referencia a \"{{ref}}\" que no es un número", + "number.multiple": "{{#label}}: debe ser un múltiplo de {{multiple}}", + "string.base": "{{#label}}: debe ser una cadena de texto", + "string.min": "{{#label}}: debe ser mínimo de {{limit}} caracteres de largo", + "string.max": "{{#label}}: debe ser de máximo {{limit}} caracteres de largo", + "string.length": "{{#label}}: debe ser exactamente de {{limit}} caracteres de largo", + "string.alphanum": "{{#label}}: debe contener solo letras y números", + "string.token": "{{#label}}: debe contener solo letras, números y guines bajos", + "string.regex.base": "{{#label}}: el valor \"{{!value}}\" no coincide con el pattern requerido: {{pattern}}", + "string.regex.name": "{{#label}}: el valor \"{{!value}}\" no coincide con el nombre de pattern {{name}}", + "string.email": "{{#label}}: debe ser un email válido", + "string.uri": "{{#label}}: debe sre una uri válida", + "string.uriCustomScheme": "{{#label}}: debe ser una uri válida con el esquema concidiente con el patrón {{scheme}}", + "string.isoDate": "{{#label}}: debe ser una fecha en formato ISO 8601 válida", + "string.guid": "{{#label}}: debe ser un GUID valido", + "string.hex": "{{#label}}: debe contener solo caracteres hexadecimales", + "string.hostname": "{{#label}}: deber ser un hostname válido", + "string.lowercase": "{{#label}}: solo debe contener minúsculas", + "string.uppercase": "{{#label}}: solo debe contener mayúsculas", + "string.trim": "{{#label}}: no debe tener espacios en blanco delante o atrás", + "string.creditCard": "{{#label}}: debe ser una tarjeta de crédito", + "string.ref": "Referencia \"{{ref}}\" que no es un número", + "string.ip": "{{#label}}: debe ser una dirección ip válida con un CDIR {{cidr}}", + "string.ipVersion": "{{#label}}: debe ser una dirección ip válida de una de las siguientes versiones {{version}} con un CDIR {{cidr}}", + "object.unknown": "{{#label}}: es un campo no es permitido", + "luxon.lt": "{{#label}}: must be before {{#date}}", + "luxon.gt": "{{#label}}: must be after {{#date}}", + "luxon.lte": "{{#label}}: must be same as or before {{#date}}", + "luxon.gte": "{{#label}}: must be same as or after {{#date}}" +} diff --git a/shared/lib/contexts/common/index.ts b/shared/lib/contexts/common/index.ts new file mode 100644 index 0000000..ec3ecbc --- /dev/null +++ b/shared/lib/contexts/common/index.ts @@ -0,0 +1,2 @@ +export * from "./application"; +export * from "./domain"; diff --git a/shared/lib/contexts/index.ts b/shared/lib/contexts/index.ts new file mode 100644 index 0000000..c57efc9 --- /dev/null +++ b/shared/lib/contexts/index.ts @@ -0,0 +1,2 @@ +export * from "./catalog"; +export * from "./common"; diff --git a/shared/lib/index.ts b/shared/lib/index.ts new file mode 100644 index 0000000..315ff97 --- /dev/null +++ b/shared/lib/index.ts @@ -0,0 +1 @@ +export * from "./contexts"; diff --git a/shared/lib/utilities/index.ts b/shared/lib/utilities/index.ts new file mode 100644 index 0000000..317759d --- /dev/null +++ b/shared/lib/utilities/index.ts @@ -0,0 +1,3 @@ +export type NullOr = T | null; +export type UndefinedOr = T | undefined; +export type EmptyOr = NullOr>; diff --git a/shared/package.json b/shared/package.json new file mode 100644 index 0000000..7b01abb --- /dev/null +++ b/shared/package.json @@ -0,0 +1,29 @@ +{ + "name": "@uecko-presupuestador/shared", + "private": false, + "version": "1.0.0", + "main": "./index.ts", + "author": "Rodax Software ", + "license": "ISC", + "scripts": { + "clean": "rm -rf node_modules" + }, + "dependencies": { + "dinero.js": "^1.9.1", + "joi": "^17.12.3", + "joi-phone-number": "^5.1.1", + "shallow-equal-object": "^1.1.1", + "uuid": "^9.0.1" + }, + "devDependencies": { + "@types/dinero.js": "^1.9.2", + "@types/jest": "^29.5.6", + "@types/joi-phone-number": "^5.0.7", + "@types/lodash": "^4.14.200", + "@types/uuid": "^9.0.5", + "eslint-plugin-jest": "^27.4.2", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "typescript": "^5.2.2" + } +} diff --git a/shared/tsconfig.json b/shared/tsconfig.json new file mode 100644 index 0000000..1bd71bc --- /dev/null +++ b/shared/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "baseUrl": "./lib/*" /* Base directory to resolve non-absolute module names. */, + "allowSyntheticDefaultImports": true /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */, + "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..892ca43 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "baseUrl": ".", + /*"paths": { + "@factuges/shared": ["shared"] + },*/ + "esModuleInterop": true, + "resolveJsonModule": true + }, + "references": [{ "path": "./shared/tsconfig.json" }], + "include": ["server/**/*.ts", "client/**/*.ts", "shared/**/*.ts"], + "exclude": ["**/node_modules"] +}