diff --git a/packages/server/src/common/exceptions/ModelHasRelations.exception.ts b/packages/server/src/common/exceptions/ModelHasRelations.exception.ts new file mode 100644 index 000000000..4ceb53d76 --- /dev/null +++ b/packages/server/src/common/exceptions/ModelHasRelations.exception.ts @@ -0,0 +1,9 @@ +export class ModelHasRelationsError extends Error { + type: string; + + constructor(type: string = 'ModelHasRelations', message?: string) { + message = message || `Entity has relations`; + super(message); + this.type = type; + } +} diff --git a/packages/server/src/common/filters/model-has-relations.filter.ts b/packages/server/src/common/filters/model-has-relations.filter.ts new file mode 100644 index 000000000..f1e29e0fe --- /dev/null +++ b/packages/server/src/common/filters/model-has-relations.filter.ts @@ -0,0 +1,27 @@ +import { + ExceptionFilter, + Catch, + ArgumentsHost, + HttpStatus, +} from '@nestjs/common'; +import { Response } from 'express'; +import { ModelHasRelationsError } from '../exceptions/ModelHasRelations.exception'; + +@Catch(ModelHasRelationsError) +export class ModelHasRelationsFilter implements ExceptionFilter { + catch(exception: ModelHasRelationsError, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const status = HttpStatus.CONFLICT; + + response.status(status).json({ + errors: [ + { + statusCode: status, + type: exception.type || 'MODEL_HAS_RELATIONS', + message: exception.message, + }, + ], + }); + } +} diff --git a/packages/server/src/common/types/Objection.d.ts b/packages/server/src/common/types/Objection.d.ts new file mode 100644 index 000000000..042ed6d45 --- /dev/null +++ b/packages/server/src/common/types/Objection.d.ts @@ -0,0 +1,7 @@ +import { QueryBuilder, Model } from 'objection'; + +declare module 'objection' { + interface QueryBuilder { + deleteIfNoRelations(this: QueryBuilder, ...args: any[]): Promise; + } +} diff --git a/packages/server/src/main.ts b/packages/server/src/main.ts index e9b404860..13178dcf3 100644 --- a/packages/server/src/main.ts +++ b/packages/server/src/main.ts @@ -5,6 +5,7 @@ import * as path from 'path'; import './utils/moment-mysql'; import { AppModule } from './modules/App/App.module'; import { ServiceErrorFilter } from './common/filters/service-error.filter'; +import { ModelHasRelationsFilter } from './common/filters/model-has-relations.filter'; import { ValidationPipe } from './common/pipes/ClassValidation.pipe'; import { ToJsonInterceptor } from './common/interceptors/to-json.interceptor'; @@ -36,6 +37,7 @@ async function bootstrap() { SwaggerModule.setup('swagger', app, documentFactory); app.useGlobalFilters(new ServiceErrorFilter()); + app.useGlobalFilters(new ModelHasRelationsFilter()); await app.listen(process.env.PORT ?? 3000); } diff --git a/packages/server/src/models/Model.ts b/packages/server/src/models/Model.ts index a5f7ebbb5..16dcd02cd 100644 --- a/packages/server/src/models/Model.ts +++ b/packages/server/src/models/Model.ts @@ -1,4 +1,5 @@ import { QueryBuilder, Model } from 'objection'; +import { ModelHasRelationsError } from '@/common/exceptions/ModelHasRelations.exception'; interface PaginationResult { results: M[]; @@ -32,15 +33,44 @@ export class PaginationQueryBuilder< }; }) as unknown as PaginationQueryBuilderType; } + + async deleteIfNoRelations({ + type, + message, + }: { + type?: string; + message?: string; + }) { + const relationMappings = this.modelClass().relationMappings; + const relationNames = Object.keys(relationMappings || {}); + + if (relationNames.length === 0) { + // No relations defined + return this.delete(); + } + const recordQuery = this.clone(); + + relationNames.forEach((relationName: string) => { + recordQuery.withGraphFetched(relationName); + }); + const record = await recordQuery; + + const hasRelations = relationNames.some((name) => { + const val = record[name]; + return Array.isArray(val) ? val.length > 0 : val != null; + }); + if (!hasRelations) { + return this.clone().delete(); + } else { + throw new ModelHasRelationsError(type, message); + } + } } -// New BaseQueryBuilder extending PaginationQueryBuilder export class BaseQueryBuilder< M extends Model, R = M[], > extends PaginationQueryBuilder { - // You can add more shared query methods here in the future - changeAmount(whereAttributes, attribute, amount) { const changeMethod = amount > 0 ? 'increment' : 'decrement'; diff --git a/packages/server/src/modules/Branches/commands/DeleteBranch.service.ts b/packages/server/src/modules/Branches/commands/DeleteBranch.service.ts index fc27bd178..b145a5021 100644 --- a/packages/server/src/modules/Branches/commands/DeleteBranch.service.ts +++ b/packages/server/src/modules/Branches/commands/DeleteBranch.service.ts @@ -39,9 +39,6 @@ export class DeleteBranchService { .query() .findById(branchId) .throwIfNotFound(); - // .queryAndThrowIfHasRelations({ - // type: ERRORS.BRANCH_HAS_ASSOCIATED_TRANSACTIONS, - // }); // Authorize the branch before deleting. await this.authorize(branchId); @@ -54,8 +51,10 @@ export class DeleteBranchService { trx, } as IBranchDeletePayload); - await this.branchModel().query().findById(branchId).delete(); - + await this.branchModel().query().findById(branchId).deleteIfNoRelations({ + type: ERRORS.BRANCH_HAS_ASSOCIATED_TRANSACTIONS, + message: 'Branch has associated transactions', + }); // Triggers `onBranchCreate` event. await this.eventPublisher.emitAsync(events.warehouse.onEdited, { oldBranch, diff --git a/packages/server/src/modules/Customers/commands/DeleteCustomer.service.ts b/packages/server/src/modules/Customers/commands/DeleteCustomer.service.ts index 8e54509de..9b1475e3f 100644 --- a/packages/server/src/modules/Customers/commands/DeleteCustomer.service.ts +++ b/packages/server/src/modules/Customers/commands/DeleteCustomer.service.ts @@ -9,6 +9,7 @@ import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service'; import { Customer } from '../models/Customer'; import { events } from '@/common/events/events'; import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; +import { ERRORS } from '../constants'; @Injectable() export class DeleteCustomer { @@ -36,9 +37,6 @@ export class DeleteCustomer { .query() .findById(customerId) .throwIfNotFound(); - // .queryAndThrowIfHasRelations({ - // type: ERRORS.CUSTOMER_HAS_TRANSACTIONS, - // }); // Triggers `onCustomerDeleting` event. await this.eventPublisher.emitAsync(events.customers.onDeleting, { @@ -49,8 +47,13 @@ export class DeleteCustomer { // Deletes the customer and associated entities under UOW transaction. return this.uow.withTransaction(async (trx: Knex.Transaction) => { // Delete the customer from the storage. - await this.customerModel().query(trx).findById(customerId).delete(); - + await this.customerModel() + .query(trx) + .findById(customerId) + .deleteIfNoRelations({ + type: ERRORS.CUSTOMER_HAS_TRANSACTIONS, + message: 'Customer has associated transactions', + }); // Throws `onCustomerDeleted` event. await this.eventPublisher.emitAsync(events.customers.onDeleted, { customerId, diff --git a/packages/server/src/modules/Items/DeleteItem.service.ts b/packages/server/src/modules/Items/DeleteItem.service.ts index fbb5d66e1..c2fef45df 100644 --- a/packages/server/src/modules/Items/DeleteItem.service.ts +++ b/packages/server/src/modules/Items/DeleteItem.service.ts @@ -39,11 +39,8 @@ export class DeleteItemService { // Retrieve the given item or throw not found service error. const oldItem = await this.itemModel() .query() - .findById(itemId) - .throwIfNotFound(); - // .queryAndThrowIfHasRelations({ - // type: ERRORS.ITEM_HAS_ASSOCIATED_TRANSACTIONS, - // }); + .findOne('id', itemId) + .deleteIfNoRelations(); // Delete item in unit of work. return this.uow.withTransaction(async (trx: Knex.Transaction) => { diff --git a/packages/server/src/modules/Vendors/commands/DeleteVendor.service.ts b/packages/server/src/modules/Vendors/commands/DeleteVendor.service.ts index ea47ebe93..598299b95 100644 --- a/packages/server/src/modules/Vendors/commands/DeleteVendor.service.ts +++ b/packages/server/src/modules/Vendors/commands/DeleteVendor.service.ts @@ -9,6 +9,7 @@ import { IVendorEventDeletingPayload, } from '../types/Vendors.types'; import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; +import { ERRORS } from '../constants'; @Injectable() export class DeleteVendorService { @@ -34,9 +35,6 @@ export class DeleteVendorService { .query() .findById(vendorId) .throwIfNotFound(); - // .queryAndThrowIfHasRelations({ - // type: ERRORS.VENDOR_HAS_TRANSACTIONS, - // }); // Triggers `onVendorDeleting` event. await this.eventPublisher.emitAsync(events.vendors.onDeleting, { @@ -47,8 +45,13 @@ export class DeleteVendorService { // Deletes vendor contact under unit-of-work. return this.uow.withTransaction(async (trx: Knex.Transaction) => { // Deletes the vendor contact from the storage. - await this.vendorModel().query(trx).findById(vendorId).delete(); - + await this.vendorModel() + .query(trx) + .findById(vendorId) + .deleteIfNoRelations({ + type: ERRORS.VENDOR_HAS_TRANSACTIONS, + message: 'Vendor has associated transactions', + }); // Triggers `onVendorDeleted` event. await this.eventPublisher.emitAsync(events.vendors.onDeleted, { vendorId, diff --git a/packages/server/src/modules/Warehouses/commands/DeleteWarehouse.service.ts b/packages/server/src/modules/Warehouses/commands/DeleteWarehouse.service.ts index fc55c4189..9ecdc76db 100644 --- a/packages/server/src/modules/Warehouses/commands/DeleteWarehouse.service.ts +++ b/packages/server/src/modules/Warehouses/commands/DeleteWarehouse.service.ts @@ -49,9 +49,6 @@ export class DeleteWarehouseService { .query() .findById(warehouseId) .throwIfNotFound(); - // .queryAndThrowIfHasRelations({ - // type: ERRORS.WAREHOUSE_HAS_ASSOCIATED_TRANSACTIONS, - // }); // Validates the given warehouse before deleting. await this.authorize(warehouseId); @@ -70,8 +67,13 @@ export class DeleteWarehouseService { eventPayload, ); // Deletes the given warehouse from the storage. - await this.warehouseModel().query().findById(warehouseId).delete(); - + await this.warehouseModel() + .query() + .findById(warehouseId) + .deleteIfNoRelations({ + type: ERRORS.WAREHOUSE_HAS_ASSOCIATED_TRANSACTIONS, + message: 'Warehouse has associated transactions', + }); // Triggers `onWarehouseCreated`. await this.eventPublisher.emitAsync( events.warehouse.onDeleted, diff --git a/packages/server/src/modules/Warehouses/models/Warehouse.model.ts b/packages/server/src/modules/Warehouses/models/Warehouse.model.ts index 94c078c72..b0b9683bf 100644 --- a/packages/server/src/modules/Warehouses/models/Warehouse.model.ts +++ b/packages/server/src/modules/Warehouses/models/Warehouse.model.ts @@ -1,6 +1,7 @@ // import { Model } from 'objection'; -import { BaseModel } from '@/models/Model'; +import { BaseModel, BaseQueryBuilder } from '@/models/Model'; import { Item } from '@/modules/Items/models/Item'; +import { Model } from 'objection'; export class Warehouse extends BaseModel { name!: string; @@ -45,128 +46,134 @@ export class Warehouse extends BaseModel { /** * Relationship mapping. */ - // static get relationMappings() { - // const SaleInvoice = require('models/SaleInvoice'); - // const SaleEstimate = require('models/SaleEstimate'); - // const SaleReceipt = require('models/SaleReceipt'); - // const Bill = require('models/Bill'); - // const VendorCredit = require('models/VendorCredit'); - // const CreditNote = require('models/CreditNote'); - // const InventoryTransaction = require('models/InventoryTransaction'); - // const WarehouseTransfer = require('models/WarehouseTransfer'); - // const InventoryAdjustment = require('models/InventoryAdjustment'); + static get relationMappings() { + const { SaleInvoice } = require('../../SaleInvoices/models/SaleInvoice'); + const { SaleEstimate } = require('../../SaleEstimates/models/SaleEstimate'); + const { SaleReceipt } = require('../../SaleReceipts/models/SaleReceipt'); + const { Bill } = require('../../Bills/models/Bill'); + const { VendorCredit } = require('../../VendorCredit/models/VendorCredit'); + const { CreditNote } = require('../../CreditNotes/models/CreditNote'); + const { + InventoryTransaction, + } = require('../../InventoryCost/models/InventoryTransaction'); + const { + WarehouseTransfer, + } = require('../../WarehousesTransfers/models/WarehouseTransfer'); + const { + InventoryAdjustment, + } = require('../../InventoryAdjutments/models/InventoryAdjustment'); - // return { - // /** - // * Warehouse may belongs to associated sale invoices. - // */ - // invoices: { - // relation: Model.HasManyRelation, - // modelClass: SaleInvoice.default, - // join: { - // from: 'warehouses.id', - // to: 'sales_invoices.warehouseId', - // }, - // }, + return { + /** + * Warehouse may belongs to associated sale invoices. + */ + invoices: { + relation: Model.HasManyRelation, + modelClass: SaleInvoice, + join: { + from: 'warehouses.id', + to: 'sales_invoices.warehouseId', + }, + }, - // /** - // * Warehouse may belongs to associated sale estimates. - // */ - // estimates: { - // relation: Model.HasManyRelation, - // modelClass: SaleEstimate.default, - // join: { - // from: 'warehouses.id', - // to: 'sales_estimates.warehouseId', - // }, - // }, + /** + * Warehouse may belongs to associated sale estimates. + */ + estimates: { + relation: Model.HasManyRelation, + modelClass: SaleEstimate, + join: { + from: 'warehouses.id', + to: 'sales_estimates.warehouseId', + }, + }, - // /** - // * Warehouse may belongs to associated sale receipts. - // */ - // receipts: { - // relation: Model.HasManyRelation, - // modelClass: SaleReceipt.default, - // join: { - // from: 'warehouses.id', - // to: 'sales_receipts.warehouseId', - // }, - // }, + /** + * Warehouse may belongs to associated sale receipts. + */ + receipts: { + relation: Model.HasManyRelation, + modelClass: SaleReceipt, + join: { + from: 'warehouses.id', + to: 'sales_receipts.warehouseId', + }, + }, - // /** - // * Warehouse may belongs to associated bills. - // */ - // bills: { - // relation: Model.HasManyRelation, - // modelClass: Bill.default, - // join: { - // from: 'warehouses.id', - // to: 'bills.warehouseId', - // }, - // }, + /** + * Warehouse may belongs to associated bills. + */ + bills: { + relation: Model.HasManyRelation, + modelClass: Bill, + join: { + from: 'warehouses.id', + to: 'bills.warehouseId', + }, + }, - // /** - // * Warehouse may belongs to associated credit notes. - // */ - // creditNotes: { - // relation: Model.HasManyRelation, - // modelClass: CreditNote.default, - // join: { - // from: 'warehouses.id', - // to: 'credit_notes.warehouseId', - // }, - // }, + /** + * Warehouse may belongs to associated credit notes. + */ + creditNotes: { + relation: Model.HasManyRelation, + modelClass: CreditNote, + join: { + from: 'warehouses.id', + to: 'credit_notes.warehouseId', + }, + }, - // /** - // * Warehouse may belongs to associated to vendor credits. - // */ - // vendorCredit: { - // relation: Model.HasManyRelation, - // modelClass: VendorCredit.default, - // join: { - // from: 'warehouses.id', - // to: 'vendor_credits.warehouseId', - // }, - // }, + /** + * Warehouse may belongs to associated to vendor credits. + */ + vendorCredit: { + relation: Model.HasManyRelation, + modelClass: VendorCredit, + join: { + from: 'warehouses.id', + to: 'vendor_credits.warehouseId', + }, + }, - // /** - // * Warehouse may belongs to associated to inventory transactions. - // */ - // inventoryTransactions: { - // relation: Model.HasManyRelation, - // modelClass: InventoryTransaction.default, - // join: { - // from: 'warehouses.id', - // to: 'inventory_transactions.warehouseId', - // }, - // }, + /** + * Warehouse may belongs to associated to inventory transactions. + */ + inventoryTransactions: { + relation: Model.HasManyRelation, + modelClass: InventoryTransaction, + join: { + from: 'warehouses.id', + to: 'inventory_transactions.warehouseId', + }, + }, - // warehouseTransferTo: { - // relation: Model.HasManyRelation, - // modelClass: WarehouseTransfer.default, - // join: { - // from: 'warehouses.id', - // to: 'warehouses_transfers.toWarehouseId', - // }, - // }, + warehouseTransferTo: { + relation: Model.HasManyRelation, + modelClass: WarehouseTransfer, + join: { + from: 'warehouses.id', + to: 'warehouses_transfers.toWarehouseId', + }, + }, - // warehouseTransferFrom: { - // relation: Model.HasManyRelation, - // modelClass: WarehouseTransfer.default, - // join: { - // from: 'warehouses.id', - // to: 'warehouses_transfers.fromWarehouseId', - // }, - // }, + warehouseTransferFrom: { + relation: Model.HasManyRelation, + modelClass: WarehouseTransfer, + join: { + from: 'warehouses.id', + to: 'warehouses_transfers.fromWarehouseId', + }, + }, - // inventoryAdjustment: { - // relation: Model.HasManyRelation, - // modelClass: InventoryAdjustment.default, - // join: { - // from: 'warehouses.id', - // to: 'inventory_adjustments.warehouseId', - // }, - // }, - // }; - // } + inventoryAdjustment: { + relation: Model.HasManyRelation, + modelClass: InventoryAdjustment, + join: { + from: 'warehouses.id', + to: 'inventory_adjustments.warehouseId', + }, + }, + }; + } }