From 0a112c565550163b3a7e1ad089e34820da941889 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Sun, 15 Dec 2024 13:04:41 +0200 Subject: [PATCH] refactor: items services to Nestjs --- .../src/modules/Items/ActivateItem.service.ts | 41 +++ .../src/modules/Items/CreateItem.service.ts | 9 +- .../src/modules/Items/EditItem.service.ts | 11 +- .../src/modules/Items/GetItem.service.ts | 46 ++++ .../src/modules/Items/GetItems.service.ts | 0 .../modules/Items/InactivateItem.service.ts | 40 +++ .../src/modules/Items/Item.controller.ts | 66 ++++- .../src/modules/Items/Item.schema.ts | 3 +- .../src/modules/Items/Item.transformer.ts | 61 +++++ .../src/modules/Items/Items.module.ts | 8 + .../modules/Items/ItemsApplication.service.ts | 84 ++++++ .../System/models/TenantMetadataModel.ts | 87 ++++++ .../src/modules/System/models/TenantModel.ts | 25 ++ .../modules/Tenancy/TenancyContext.service.ts | 13 +- .../Tenancy/TenancyModels/Tenancy.module.ts | 6 +- .../src/modules/Transformer/Transformer.ts | 247 ++++++++++++++++++ .../modules/Transformer/Transformer.types.ts | 8 + .../TransformerInjectable.service.ts | 59 +++++ .../server-nest/src/utils/format-number.ts | 54 ++++ packages/server-nest/test/items.e2e-spec.ts | 47 ++++ 20 files changed, 897 insertions(+), 18 deletions(-) create mode 100644 packages/server-nest/src/modules/Items/ActivateItem.service.ts create mode 100644 packages/server-nest/src/modules/Items/GetItem.service.ts create mode 100644 packages/server-nest/src/modules/Items/GetItems.service.ts create mode 100644 packages/server-nest/src/modules/Items/InactivateItem.service.ts create mode 100644 packages/server-nest/src/modules/Items/Item.transformer.ts create mode 100644 packages/server-nest/src/modules/Items/ItemsApplication.service.ts create mode 100644 packages/server-nest/src/modules/System/models/TenantMetadataModel.ts create mode 100644 packages/server-nest/src/modules/Transformer/Transformer.ts create mode 100644 packages/server-nest/src/modules/Transformer/Transformer.types.ts create mode 100644 packages/server-nest/src/modules/Transformer/TransformerInjectable.service.ts create mode 100644 packages/server-nest/src/utils/format-number.ts diff --git a/packages/server-nest/src/modules/Items/ActivateItem.service.ts b/packages/server-nest/src/modules/Items/ActivateItem.service.ts new file mode 100644 index 000000000..10d3d78fc --- /dev/null +++ b/packages/server-nest/src/modules/Items/ActivateItem.service.ts @@ -0,0 +1,41 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Item } from './models/Item'; +import { UnitOfWork } from '../Tenancy/TenancyDB/UnitOfWork.service'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { events } from '@/common/events/events'; +import { Knex } from 'knex'; + +@Injectable() +export class ActivateItemService { + constructor( + @Inject(Item.name) + private readonly itemModel: typeof Item, + private readonly eventEmitter: EventEmitter2, + private readonly uow: UnitOfWork, + ) {} + + /** + * Activates the given item on the storage. + * @param {number} itemId - + * @return {Promise} + */ + public async activateItem( + itemId: number, + trx?: Knex.Transaction, + ): Promise { + // Retrieves the given item or throw not found error. + const oldItem = await this.itemModel + .query() + .findById(itemId) + .throwIfNotFound(); + + // Activate the given item with associated transactions under unit-of-work environment. + return this.uow.withTransaction(async (trx) => { + // Mutate item on the storage. + await this.itemModel.query(trx).findById(itemId).patch({ active: true }); + + // Triggers `onItemActivated` event. + await this.eventEmitter.emitAsync(events.item.onActivated, {}); + }, trx); + } +} diff --git a/packages/server-nest/src/modules/Items/CreateItem.service.ts b/packages/server-nest/src/modules/Items/CreateItem.service.ts index cc86d7f54..3a22613b3 100644 --- a/packages/server-nest/src/modules/Items/CreateItem.service.ts +++ b/packages/server-nest/src/modules/Items/CreateItem.service.ts @@ -7,7 +7,6 @@ import { events } from '@/common/events/events'; import { ItemsValidators } from './ItemValidator.service'; import { Item } from './models/Item'; import { UnitOfWork } from '../Tenancy/TenancyDB/UnitOfWork.service'; -import { TenancyContext } from '../Tenancy/TenancyContext.service'; @Injectable({ scope: Scope.REQUEST }) export class CreateItemService { @@ -90,17 +89,17 @@ export class CreateItemService { /** * Creates a new item. * @param {IItemDTO} itemDTO - * @return {Promise} + * @return {Promise} - The created item id. */ public async createItem( itemDTO: IItemDTO, trx?: Knex.Transaction, - ): Promise { + ): Promise { // Authorize the item before creating. await this.authorize(itemDTO); // Creates a new item with associated transactions under unit-of-work envirement. - return this.uow.withTransaction(async (trx: Knex.Transaction) => { + return this.uow.withTransaction(async (trx: Knex.Transaction) => { const itemInsert = this.transformNewItemDTOToModel(itemDTO); // Inserts a new item and fetch the created item. @@ -114,7 +113,7 @@ export class CreateItemService { trx, } as IItemEventCreatedPayload); - return item; + return item.id; }, trx); } } diff --git a/packages/server-nest/src/modules/Items/EditItem.service.ts b/packages/server-nest/src/modules/Items/EditItem.service.ts index 197fdeedc..661998bf0 100644 --- a/packages/server-nest/src/modules/Items/EditItem.service.ts +++ b/packages/server-nest/src/modules/Items/EditItem.service.ts @@ -100,14 +100,15 @@ export class EditItemService { /** * Edits the item metadata. - * @param {number} itemId - * @param {IItemDTO} itemDTO + * @param {number} itemId - The item id. + * @param {IItemDTO} itemDTO - The item DTO. + * @return {Promise} - The updated item id. */ public async editItem( itemId: number, itemDTO: IItemDTO, trx?: Knex.Transaction, - ): Promise { + ): Promise { // Validates the given item existance on the storage. const oldItem = await this.itemModel .query() @@ -121,7 +122,7 @@ export class EditItemService { const itemModel = this.transformEditItemDTOToModel(itemDTO, oldItem); // Edits the item with associated transactions under unit-of-work environment. - return this.uow.withTransaction(async (trx: Knex.Transaction) => { + return this.uow.withTransaction(async (trx: Knex.Transaction) => { // Updates the item on the storage and fetches the updated one. const newItem = await this.itemModel .query(trx) @@ -137,7 +138,7 @@ export class EditItemService { // Triggers `onItemEdited` event. await this.eventEmitter.emitAsync(events.item.onEdited, eventPayload); - return newItem; + return newItem.id; }, trx); } } diff --git a/packages/server-nest/src/modules/Items/GetItem.service.ts b/packages/server-nest/src/modules/Items/GetItem.service.ts new file mode 100644 index 000000000..b990ef0eb --- /dev/null +++ b/packages/server-nest/src/modules/Items/GetItem.service.ts @@ -0,0 +1,46 @@ +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { Inject, Injectable } from '@nestjs/common'; +import { Item } from './models/Item'; +import { events } from '@/common/events/events'; +import { TransformerInjectable } from '../Transformer/TransformerInjectable.service'; +import { ItemTransformer } from './Item.transformer'; + +@Injectable() +export class GetItemService { + constructor( + @Inject(Item) + private itemModel: typeof Item, + private eventEmitter2: EventEmitter2, + private transformerInjectable: TransformerInjectable, + ) {} + + /** + * Retrieve the item details of the given id with associated details. + * @param {number} tenantId - The tenant id. + * @param {number} itemId - The item id. + */ + public async getItem(itemId: number): Promise { + const item = await this.itemModel + .query() + .findById(itemId) + .withGraphFetched('sellAccount') + .withGraphFetched('inventoryAccount') + .withGraphFetched('category') + .withGraphFetched('costAccount') + .withGraphFetched('itemWarehouses.warehouse') + .withGraphFetched('sellTaxRate') + .withGraphFetched('purchaseTaxRate') + .throwIfNotFound(); + + const transformed = await this.transformerInjectable.transform( + item, + new ItemTransformer(), + ); + const eventPayload = { itemId }; + + // Triggers the `onItemViewed` event. + await this.eventEmitter2.emitAsync(events.item.onViewed, eventPayload); + + return transformed; + } +} diff --git a/packages/server-nest/src/modules/Items/GetItems.service.ts b/packages/server-nest/src/modules/Items/GetItems.service.ts new file mode 100644 index 000000000..e69de29bb diff --git a/packages/server-nest/src/modules/Items/InactivateItem.service.ts b/packages/server-nest/src/modules/Items/InactivateItem.service.ts new file mode 100644 index 000000000..8bf9bd4a0 --- /dev/null +++ b/packages/server-nest/src/modules/Items/InactivateItem.service.ts @@ -0,0 +1,40 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Knex } from 'knex'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { Item } from './models/Item'; +import { events } from '@/common/events/events'; +import { UnitOfWork } from '../Tenancy/TenancyDB/UnitOfWork.service'; + +@Injectable() +export class InactivateItem { + constructor( + @Inject(Item.name) private itemModel: typeof Item, + private readonly eventEmitter: EventEmitter2, + private readonly uow: UnitOfWork, + ) {} + + /** + * Inactivates the given item on the storage. + * @param {number} itemId + * @return {Promise} + */ + public async inactivateItem( + itemId: number, + trx?: Knex.Transaction, + ): Promise { + // Retrieves the item or throw not found error. + const oldItem = await this.itemModel + .query() + .findById(itemId) + .throwIfNotFound(); + + // Inactivate item under unit-of-work environment. + return this.uow.withTransaction(async (trx: Knex.Transaction) => { + // Inactivate item on the storage. + await this.itemModel.query(trx).findById(itemId).patch({ active: false }); + + // Triggers `onItemInactivated` event. + await this.eventEmitter.emitAsync(events.item.onInactivated, { trx }); + }, trx); + } +} diff --git a/packages/server-nest/src/modules/Items/Item.controller.ts b/packages/server-nest/src/modules/Items/Item.controller.ts index aae81ddeb..4f9324f22 100644 --- a/packages/server-nest/src/modules/Items/Item.controller.ts +++ b/packages/server-nest/src/modules/Items/Item.controller.ts @@ -6,16 +6,18 @@ import { Post, UsePipes, UseGuards, + Patch, + Get, } from '@nestjs/common'; import { ZodValidationPipe } from '@/common/pipes/ZodValidation.pipe'; import { createItemSchema } from './Item.schema'; import { CreateItemService } from './CreateItem.service'; -import { Item } from './models/Item'; import { DeleteItemService } from './DeleteItem.service'; import { TenantController } from '../Tenancy/Tenant.controller'; import { SubscriptionGuard } from '../Subscription/interceptors/Subscription.guard'; -import { ClsService } from 'nestjs-cls'; import { PublicRoute } from '../Auth/Jwt.guard'; +import { EditItemService } from './EditItem.service'; +import { ItemsApplicationService } from './ItemsApplication.service'; @Controller('/items') @UseGuards(SubscriptionGuard) @@ -24,20 +26,76 @@ export class ItemsController extends TenantController { constructor( private readonly createItemService: CreateItemService, private readonly deleteItemService: DeleteItemService, - private readonly cls: ClsService, + private readonly editItemService: EditItemService, + private readonly itemsApplication: ItemsApplicationService, ) { super(); } + /** + * Edit item. + * @param id - The item id. + * @param editItemDto - The item DTO. + * @returns The updated item id. + */ + @Post(':id') + @UsePipes(new ZodValidationPipe(createItemSchema)) + async editItem( + @Param('id') id: string, + @Body() editItemDto: any, + ): Promise { + const itemId = parseInt(id, 10); + return this.editItemService.editItem(itemId, editItemDto); + } + + /** + * Create item. + * @param createItemDto - The item DTO. + * @returns The created item id. + */ @Post() @UsePipes(new ZodValidationPipe(createItemSchema)) - async createItem(@Body() createItemDto: any): Promise { + async createItem(@Body() createItemDto: any): Promise { return this.createItemService.createItem(createItemDto); } + /** + * Delete item. + * @param id - The item id. + */ @Delete(':id') async deleteItem(@Param('id') id: string): Promise { const itemId = parseInt(id, 10); return this.deleteItemService.deleteItem(itemId); } + + /** + * Inactivate item. + * @param id - The item id. + */ + @Patch(':id/inactivate') + async inactivateItem(@Param('id') id: string): Promise { + const itemId = parseInt(id, 10); + return this.itemsApplication.inactivateItem(itemId); + } + + /** + * Activate item. + * @param id - The item id. + */ + @Patch(':id/activate') + async activateItem(@Param('id') id: string): Promise { + const itemId = parseInt(id, 10); + return this.itemsApplication.activateItem(itemId); + } + + /** + * Get item. + * @param id - The item id. + */ + @Get(':id') + async getItem(@Param('id') id: string): Promise { + const itemId = parseInt(id, 10); + return this.itemsApplication.getItem(itemId); + } } diff --git a/packages/server-nest/src/modules/Items/Item.schema.ts b/packages/server-nest/src/modules/Items/Item.schema.ts index 0191be81b..87f2fcd5a 100644 --- a/packages/server-nest/src/modules/Items/Item.schema.ts +++ b/packages/server-nest/src/modules/Items/Item.schema.ts @@ -108,4 +108,5 @@ export const createItemSchema = z ); -export type createItemDTO = z.infer; \ No newline at end of file +export type createItemDTO = z.infer; +export type editItemDTOSchema = z.infer; \ No newline at end of file diff --git a/packages/server-nest/src/modules/Items/Item.transformer.ts b/packages/server-nest/src/modules/Items/Item.transformer.ts new file mode 100644 index 000000000..312041772 --- /dev/null +++ b/packages/server-nest/src/modules/Items/Item.transformer.ts @@ -0,0 +1,61 @@ +import { Transformer } from '../Transformer/Transformer'; +// import { GetItemWarehouseTransformer } from '@/services/Warehouses/Items/GettItemWarehouseTransformer'; + +export class ItemTransformer extends Transformer { + /** + * Include these attributes to sale invoice object. + * @returns {Array} + */ + public includeAttributes = (): string[] => { + return [ + 'typeFormatted', + 'sellPriceFormatted', + 'costPriceFormatted', + 'itemWarehouses', + ]; + }; + + /** + * Formatted item type. + * @param {IItem} item + * @returns {string} + */ + public typeFormatted(item): string { + return this.context.i18n.t(`item.field.type.${item.type}`); + } + + /** + * Formatted sell price. + * @param item + * @returns {string} + */ + public sellPriceFormatted(item): string { + return this.formatNumber(item.sellPrice, { + currencyCode: this.context.organization.baseCurrency, + }); + } + + /** + * Formatted cost price. + * @param item + * @returns {string} + */ + public costPriceFormatted(item): string { + return this.formatNumber(item.costPrice, { + currencyCode: this.context.organization.baseCurrency, + }); + } + + /** + * Associate the item warehouses quantity. + * @param item + * @returns + */ + // public itemWarehouses = (item) => { + // return this.item( + // item.itemWarehouses, + // new GetItemWarehouseTransformer(), + // {}, + // ); + // }; +} diff --git a/packages/server-nest/src/modules/Items/Items.module.ts b/packages/server-nest/src/modules/Items/Items.module.ts index 68713d6fb..ebbf3dc64 100644 --- a/packages/server-nest/src/modules/Items/Items.module.ts +++ b/packages/server-nest/src/modules/Items/Items.module.ts @@ -5,6 +5,10 @@ import { TenancyDatabaseModule } from '../Tenancy/TenancyDB/TenancyDB.module'; import { ItemsValidators } from './ItemValidator.service'; import { DeleteItemService } from './DeleteItem.service'; import { TenancyContext } from '../Tenancy/TenancyContext.service'; +import { EditItemService } from './EditItem.service'; +import { InactivateItem } from './InactivateItem.service'; +import { ActivateItemService } from './ActivateItem.service'; +import { ItemsApplicationService } from './ItemsApplication.service'; @Module({ imports: [TenancyDatabaseModule], @@ -12,7 +16,11 @@ import { TenancyContext } from '../Tenancy/TenancyContext.service'; providers: [ ItemsValidators, CreateItemService, + EditItemService, + InactivateItem, + ActivateItemService, DeleteItemService, + ItemsApplicationService, TenancyContext, ], }) diff --git a/packages/server-nest/src/modules/Items/ItemsApplication.service.ts b/packages/server-nest/src/modules/Items/ItemsApplication.service.ts new file mode 100644 index 000000000..5509cb7b9 --- /dev/null +++ b/packages/server-nest/src/modules/Items/ItemsApplication.service.ts @@ -0,0 +1,84 @@ +import { Item } from './models/Item'; +import { CreateItemService } from './CreateItem.service'; +import { DeleteItemService } from './DeleteItem.service'; +import { EditItemService } from './EditItem.service'; +import { IItem, IItemDTO } from '@/interfaces/Item'; +import { Knex } from 'knex'; +import { InactivateItem } from './InactivateItem.service'; +import { ActivateItemService } from './ActivateItem.service'; +import { GetItemService } from './GetItem.service'; + +export class ItemsApplicationService { + constructor( + private readonly createItemService: CreateItemService, + private readonly editItemService: EditItemService, + private readonly deleteItemService: DeleteItemService, + private readonly activateItemService: ActivateItemService, + private readonly inactivateItemService: InactivateItem, + private readonly getItemService: GetItemService, + ) {} + + /** + * Creates a new item. + * @param {IItemDTO} createItemDto - The item DTO. + * @param {Knex.Transaction} [trx] - The transaction. + * @return {Promise} - The created item id. + */ + async createItem( + createItemDto: IItemDTO, + trx?: Knex.Transaction, + ): Promise { + return this.createItemService.createItem(createItemDto); + } + + /** + * Edits an existing item. + * @param {number} itemId - The item id. + * @param {IItemDTO} editItemDto - The item DTO. + * @param {Knex.Transaction} [trx] - The transaction. + * @return {Promise} - The updated item id. + */ + async editItem( + itemId: number, + editItemDto: IItemDTO, + trx?: Knex.Transaction, + ): Promise { + return this.editItemService.editItem(itemId, editItemDto, trx); + } + + /** + * Deletes an existing item. + * @param {number} itemId - The item id. + * @return {Promise} + */ + async deleteItem(itemId: number): Promise { + return this.deleteItemService.deleteItem(itemId); + } + + /** + * Activates an item. + * @param {number} itemId - The item id. + * @returns {Promise} + */ + async activateItem(itemId: number): Promise { + return this.activateItemService.activateItem(itemId); + } + + /** + * Inactivates an item. + * @param {number} itemId - The item id. + * @returns {Promise} + */ + async inactivateItem(itemId: number): Promise { + return this.inactivateItemService.inactivateItem(itemId); + } + + /** + * Retrieves the item details of the given id with associated details. + * @param {number} itemId - The item id. + * @returns {Promise} - The item details. + */ + async getItem(itemId: number): Promise { + return this.getItemService.getItem(itemId); + } +} diff --git a/packages/server-nest/src/modules/System/models/TenantMetadataModel.ts b/packages/server-nest/src/modules/System/models/TenantMetadataModel.ts new file mode 100644 index 000000000..190e3d29c --- /dev/null +++ b/packages/server-nest/src/modules/System/models/TenantMetadataModel.ts @@ -0,0 +1,87 @@ +// import { +// defaultOrganizationAddressFormat, +// organizationAddressTextFormat, +// } from '@/utils/address-text-format'; +import { BaseModel } from '@/models/Model'; +// import { findByIsoCountryCode } from '@bigcapital/utils'; +// import { getUploadedObjectUri } from '../../services/Attachments/utils'; + +export class TenantMetadata extends BaseModel { + baseCurrency!: string; + name!: string; + tenantId!: number; + industry!: string; + location!: string; + language!: string; + timezone!: string; + dateFormat!: string; + fiscalYear!: string; + primaryColor!: string; + logoKey!: string; + address!: Record; + + /** + * Json schema. + */ + static get jsonSchema() { + return { + type: 'object', + required: ['tenantId', 'name', 'baseCurrency'], + properties: { + tenantId: { type: 'integer' }, + name: { type: 'string', maxLength: 255 }, + industry: { type: 'string', maxLength: 255 }, + location: { type: 'string', maxLength: 255 }, + baseCurrency: { type: 'string', maxLength: 3 }, + language: { type: 'string', maxLength: 255 }, + timezone: { type: 'string', maxLength: 255 }, + dateFormat: { type: 'string', maxLength: 255 }, + fiscalYear: { type: 'string', maxLength: 255 }, + primaryColor: { type: 'string', maxLength: 7 }, // Assuming hex color code + logoKey: { type: 'string', maxLength: 255 }, + address: { type: 'object' }, + }, + }; + } + + /** + * Table name. + */ + static get tableName() { + return 'tenants_metadata'; + } + + /** + * Virtual attributes. + */ + static get virtualAttributes() { + return ['logoUri']; + } + + // /** + // * Organization logo url. + // * @returns {string | null} + // */ + // public get logoUri() { + // return this.logoKey ? getUploadedObjectUri(this.logoKey) : null; + // } + + // /** + // * Retrieves the organization address formatted text. + // * @returns {string} + // */ + // public get addressTextFormatted() { + // const addressCountry = findByIsoCountryCode(this.location); + + // return organizationAddressTextFormat(defaultOrganizationAddressFormat, { + // organizationName: this.name, + // address1: this.address?.address1, + // address2: this.address?.address2, + // state: this.address?.stateProvince, + // city: this.address?.city, + // postalCode: this.address?.postalCode, + // phone: this.address?.phone, + // country: addressCountry?.name ?? '', + // }); + // } +} diff --git a/packages/server-nest/src/modules/System/models/TenantModel.ts b/packages/server-nest/src/modules/System/models/TenantModel.ts index db23753de..5483b01f1 100644 --- a/packages/server-nest/src/modules/System/models/TenantModel.ts +++ b/packages/server-nest/src/modules/System/models/TenantModel.ts @@ -1,12 +1,37 @@ import { BaseModel } from '@/models/Model'; +import { Model } from 'objection'; +import { TenantMetadata } from './TenantMetadataModel'; export class TenantModel extends BaseModel { public readonly organizationId: string; public readonly initializedAt: string; public readonly seededAt: boolean; public readonly builtAt: string; + public readonly metadata: TenantMetadata; + /** + * Table name. + */ static get tableName() { return 'tenants'; } + + /** + * Relations mappings. + */ + static get relationMappings() { + // const PlanSubscription = require('./Subscriptions/PlanSubscription'); + const { TenantMetadata } = require('./TenantMetadataModel'); + + return { + metadata: { + relation: Model.HasOneRelation, + modelClass: TenantMetadata, + join: { + from: 'tenants.id', + to: 'tenants_metadata.tenantId', + }, + }, + }; + } } diff --git a/packages/server-nest/src/modules/Tenancy/TenancyContext.service.ts b/packages/server-nest/src/modules/Tenancy/TenancyContext.service.ts index 91a5e987e..f2890b6b8 100644 --- a/packages/server-nest/src/modules/Tenancy/TenancyContext.service.ts +++ b/packages/server-nest/src/modules/Tenancy/TenancyContext.service.ts @@ -17,13 +17,22 @@ export class TenancyContext { /** * Get the current tenant. + * @param {boolean} withMetadata - If true, the tenant metadata will be fetched. * @returns */ - getTenant() { + getTenant(withMetadata: boolean = false) { // Get the tenant from the request headers. const organizationId = this.cls.get('organizationId'); - return this.systemTenantModel.query().findOne({ organizationId }); + if (!organizationId) { + throw new Error('Tenant not found'); + } + const query = this.systemTenantModel.query().findOne({ organizationId }); + + if (withMetadata) { + query.withGraphFetched('metadata'); + } + return query; } /** diff --git a/packages/server-nest/src/modules/Tenancy/TenancyModels/Tenancy.module.ts b/packages/server-nest/src/modules/Tenancy/TenancyModels/Tenancy.module.ts index 6cad22635..97711d63b 100644 --- a/packages/server-nest/src/modules/Tenancy/TenancyModels/Tenancy.module.ts +++ b/packages/server-nest/src/modules/Tenancy/TenancyModels/Tenancy.module.ts @@ -1,10 +1,14 @@ import { Knex } from 'knex'; import { Global, Module, Scope } from '@nestjs/common'; import { TENANCY_DB_CONNECTION } from '../TenancyDB/TenancyDB.constants'; + import { Item } from '../../../modules/Items/models/Item'; import { Account } from '@/modules/Accounts/models/Account'; +import { TenantModel } from '@/modules/System/models/TenantModel'; +import { TenantMetadata } from '@/modules/System/models/TenantMetadataModel'; + +const models = [Item, Account, TenantModel, TenantMetadata]; -const models = [Item, Account]; const modelProviders = models.map((model) => { return { provide: model.name, diff --git a/packages/server-nest/src/modules/Transformer/Transformer.ts b/packages/server-nest/src/modules/Transformer/Transformer.ts new file mode 100644 index 000000000..510de5280 --- /dev/null +++ b/packages/server-nest/src/modules/Transformer/Transformer.ts @@ -0,0 +1,247 @@ +import moment from 'moment'; +import * as R from 'ramda'; +import { includes, isFunction, isObject, isUndefined, omit } from 'lodash'; +// import { EXPORT_DTE_FORMAT } from '@/services/Export/constants'; +import { formatNumber } from '@/utils/format-number'; +import { TransformerContext } from './Transformer.types'; + +const EXPORT_DTE_FORMAT = 'YYYY-MM-DD'; + +export class Transformer { + public context: TransformerContext; + public options: Record; + + /** + * Includeded attributes. + * @returns {string[]} + */ + public includeAttributes = (): string[] => { + return []; + }; + + /** + * Exclude attributes. + * @returns {string[]} + */ + public excludeAttributes = (): string[] => { + return []; + }; + + /** + * Detarmines whether to exclude all attributes except the include attributes. + * @returns {boolean} + */ + public isExcludeAllAttributes = () => { + return includes(this.excludeAttributes(), '*'); + }; + + /** + * + * @param object + */ + transform = (object: any) => { + return object; + }; + + /** + * + * @param object + * @returns + */ + protected preCollectionTransform = (object: any) => { + return object; + }; + + /** + * + * @param object + * @returns + */ + protected postCollectionTransform = (object: any) => { + return object; + }; + + /** + * + */ + public work = (object: any) => { + if (Array.isArray(object)) { + const preTransformed = this.preCollectionTransform(object); + const transformed = preTransformed.map(this.getTransformation); + + return this.postCollectionTransform(transformed); + } else if (isObject(object)) { + return this.getTransformation(object); + } + return object; + }; + + /** + * Transformes the given item to desired output. + * @param item + * @returns + */ + protected getTransformation = (item) => { + const normlizedItem = this.normalizeModelItem(item); + + return R.compose( + // sortObjectKeysAlphabetically, + this.transform, + R.when(this.hasExcludeAttributes, this.excludeAttributesTransformed), + this.includeAttributesTransformed + )(normlizedItem); + }; + + /** + * + * @param item + * @returns + */ + protected normalizeModelItem = (item) => { + return !isUndefined(item.toJSON) ? item.toJSON() : item; + }; + + /** + * Exclude attributes from the given item. + */ + protected excludeAttributesTransformed = (item) => { + const exclude = this.excludeAttributes(); + + return omit(item, exclude); + }; + + /** + * Incldues virtual attributes. + */ + protected getIncludeAttributesTransformed = (item) => { + const attributes = this.includeAttributes(); + + return attributes + .filter( + (attribute) => + isFunction(this[attribute]) || !isUndefined(item[attribute]) + ) + .reduce((acc, attribute: string) => { + acc[attribute] = isFunction(this[attribute]) + ? this[attribute](item) + : item[attribute]; + + return acc; + }, {}); + }; + + /** + * + * @param item + * @returns + */ + protected includeAttributesTransformed = (item) => { + const excludeAll = this.isExcludeAllAttributes(); + const virtualAttrs = this.getIncludeAttributesTransformed(item); + + return { + ...(!excludeAll ? item : {}), + ...virtualAttrs, + }; + }; + + /** + * + * @returns + */ + private hasExcludeAttributes = () => { + return this.excludeAttributes().length > 0; + }; + + private dateFormat = 'YYYY MMM DD'; + + setDateFormat(format: string) { + this.dateFormat = format; + } + + /** + * Format date. + * @param {string} date + * @param {string} format + * @returns {string} + */ + protected formatDate(date: string, format?: string) { + // Use the export date format if the async operation is in exporting, + // otherwise use the given or default format. + const _format = this.context.exportAls.isExport + ? EXPORT_DTE_FORMAT + : format || this.dateFormat; + + return date ? moment(date).format(_format) : ''; + } + + /** + * Format date from now. + * @param {string} date + * @returns {string} + */ + protected formatDateFromNow(date: string) { + return date ? moment(date).fromNow(true) : ''; + } + + /** + * Format number. + * @param {number | string} number + * @param {any} props + * @returns {string} + */ + protected formatNumber(number: number | string, props?) { + return formatNumber(number, { money: false, ...props }); + } + + /** + * + * @param money + * @param options + * @returns {} + */ + protected formatMoney(money, options?) { + return formatNumber(money, { + currencyCode: this.context.organization.baseCurrency, + ...options, + }); + } + + /** + * + * @param obj + * @param transformer + * @param options + */ + public item( + obj: Record, + transformer: Transformer, + options?: any + ) { + transformer.setOptions(options); + transformer.setContext(this.context); + transformer.setDateFormat(this.dateFormat); + + return transformer.work(obj); + } + + /** + * Sets custom options to the application. + * @param {} options + * @returns {Transformer} + */ + public setOptions(options) { + this.options = options; + return this; + } + + /** + * Sets the application context to the application. + * @param {} context + * @returns {Transformer} + */ + public setContext(context) { + this.context = context; + return this; + } +} diff --git a/packages/server-nest/src/modules/Transformer/Transformer.types.ts b/packages/server-nest/src/modules/Transformer/Transformer.types.ts new file mode 100644 index 000000000..dc07b27b7 --- /dev/null +++ b/packages/server-nest/src/modules/Transformer/Transformer.types.ts @@ -0,0 +1,8 @@ +import { I18nService } from 'nestjs-i18n'; +import { TenantMetadata } from '../System/models/TenantMetadataModel'; + +export interface TransformerContext { + organization: TenantMetadata; + i18n: I18nService; + exportAls: Record; +} diff --git a/packages/server-nest/src/modules/Transformer/TransformerInjectable.service.ts b/packages/server-nest/src/modules/Transformer/TransformerInjectable.service.ts new file mode 100644 index 000000000..2fa649dbc --- /dev/null +++ b/packages/server-nest/src/modules/Transformer/TransformerInjectable.service.ts @@ -0,0 +1,59 @@ +import { I18nService } from 'nestjs-i18n'; +import { Transformer } from './Transformer'; +import { Injectable } from '@nestjs/common'; +import { TenancyContext } from '../Tenancy/TenancyContext.service'; +import { TransformerContext } from './Transformer.types'; + +@Injectable() +export class TransformerInjectable { + constructor( + private readonly tenancyContext: TenancyContext, + private readonly i18n: I18nService, + ) {} + + /** + * Retrieves the application context of all tenant transformers. + * @returns {TransformerContext} + */ + async getApplicationContext(): Promise { + const tenant = await this.tenancyContext.getTenant(true); + const organization = tenant.metadata; + + return { + organization, + i18n: this.i18n, + exportAls: {}, + }; + } + + /** + * Retrieves the given tenatn date format. + * @returns {string} + */ + async getTenantDateFormat() { + const tenant = await this.tenancyContext.getTenant(true); + return tenant.metadata.dateFormat; + } + + /** + * Transformes the given transformer after inject the tenant context. + * @param {Record | Record[]} object + * @param {Transformer} transformer + * @param {Record} options + * @returns {Record} + */ + async transform( + object: Record | Record[], + transformer: Transformer, + options?: Record, + ) { + const context = await this.getApplicationContext(); + transformer.setContext(context); + + const dateFormat = await this.getTenantDateFormat(); + transformer.setDateFormat(dateFormat); + transformer.setOptions(options); + + return transformer.work(object); + } +} diff --git a/packages/server-nest/src/utils/format-number.ts b/packages/server-nest/src/utils/format-number.ts new file mode 100644 index 000000000..539da998e --- /dev/null +++ b/packages/server-nest/src/utils/format-number.ts @@ -0,0 +1,54 @@ +import _ from 'lodash'; +import accounting from 'accounting'; +import Currencies from 'js-money/lib/currency'; + +const getNegativeFormat = (formatName) => { + switch (formatName) { + case 'parentheses': + return '(%s%v)'; + case 'mines': + return '-%s%v'; + } +}; + +const getCurrencySign = (currencyCode) => { + return _.get(Currencies, `${currencyCode}.symbol`); +}; + +export const formatNumber = ( + balance, + { + precision = 2, + divideOn1000 = false, + excerptZero = false, + negativeFormat = 'mines', + thousand = ',', + decimal = '.', + zeroSign = '', + money = true, + currencyCode, + symbol = '', + }, +) => { + const formattedSymbol = getCurrencySign(currencyCode); + const negForamt = getNegativeFormat(negativeFormat); + const format = '%s%v'; + + let formattedBalance = parseFloat(balance); + + if (divideOn1000) { + formattedBalance /= 1000; + } + return accounting.formatMoney( + formattedBalance, + money ? formattedSymbol : symbol ? symbol : '', + precision, + thousand, + decimal, + { + pos: format, + neg: negForamt, + zero: excerptZero ? zeroSign : format, + }, + ); +}; diff --git a/packages/server-nest/test/items.e2e-spec.ts b/packages/server-nest/test/items.e2e-spec.ts index dd3a1a6da..4d0060304 100644 --- a/packages/server-nest/test/items.e2e-spec.ts +++ b/packages/server-nest/test/items.e2e-spec.ts @@ -16,4 +16,51 @@ describe('Items (e2e)', () => { }) .expect(201); }); + + it('/items/:id (POST)', async () => { + const item = { + name: faker.commerce.productName(), + type: 'service', + }; + return request(app.getHttpServer()) + .post('/items') + .set('organization-id', '4064541lv40nhca') + .send({ + name: faker.commerce.productName(), + type: 'service', + }) + .expect(201); + }); + + it('/items/:id/inactivate (PATCH)', async () => { + const response = await request(app.getHttpServer()) + .post('/items') + .set('organization-id', '4064541lv40nhca') + .send({ + name: faker.commerce.productName(), + type: 'service', + }); + const itemId = response.body.id; + + return request(app.getHttpServer()) + .patch(`/items/${itemId}/inactivate`) + .set('organization-id', '4064541lv40nhca') + .expect(200); + }); + + it('/items/:id/activate (PATCH)', async () => { + const response = await request(app.getHttpServer()) + .post('/items') + .set('organization-id', '4064541lv40nhca') + .send({ + name: faker.commerce.productName(), + type: 'service', + }); + const itemId = response.body.id; + + return request(app.getHttpServer()) + .patch(`/items/${itemId}/activate`) + .set('organization-id', '4064541lv40nhca') + .expect(200); + }); });