feat: deleteIfNoRelations

This commit is contained in:
Ahmed Bouhuolia
2025-06-28 22:35:29 +02:00
parent 0ca98c7ae4
commit fa5c3bd955
11 changed files with 229 additions and 143 deletions

View File

@@ -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;
}
}

View File

@@ -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<Response>();
const status = HttpStatus.CONFLICT;
response.status(status).json({
errors: [
{
statusCode: status,
type: exception.type || 'MODEL_HAS_RELATIONS',
message: exception.message,
},
],
});
}
}

View File

@@ -0,0 +1,7 @@
import { QueryBuilder, Model } from 'objection';
declare module 'objection' {
interface QueryBuilder<M extends Model, R = M[]> {
deleteIfNoRelations(this: QueryBuilder<M, R>, ...args: any[]): Promise<any>;
}
}

View File

@@ -5,6 +5,7 @@ import * as path from 'path';
import './utils/moment-mysql'; import './utils/moment-mysql';
import { AppModule } from './modules/App/App.module'; import { AppModule } from './modules/App/App.module';
import { ServiceErrorFilter } from './common/filters/service-error.filter'; 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 { ValidationPipe } from './common/pipes/ClassValidation.pipe';
import { ToJsonInterceptor } from './common/interceptors/to-json.interceptor'; import { ToJsonInterceptor } from './common/interceptors/to-json.interceptor';
@@ -36,6 +37,7 @@ async function bootstrap() {
SwaggerModule.setup('swagger', app, documentFactory); SwaggerModule.setup('swagger', app, documentFactory);
app.useGlobalFilters(new ServiceErrorFilter()); app.useGlobalFilters(new ServiceErrorFilter());
app.useGlobalFilters(new ModelHasRelationsFilter());
await app.listen(process.env.PORT ?? 3000); await app.listen(process.env.PORT ?? 3000);
} }

View File

@@ -1,4 +1,5 @@
import { QueryBuilder, Model } from 'objection'; import { QueryBuilder, Model } from 'objection';
import { ModelHasRelationsError } from '@/common/exceptions/ModelHasRelations.exception';
interface PaginationResult<M extends Model> { interface PaginationResult<M extends Model> {
results: M[]; results: M[];
@@ -32,15 +33,44 @@ export class PaginationQueryBuilder<
}; };
}) as unknown as PaginationQueryBuilderType<M>; }) as unknown as PaginationQueryBuilderType<M>;
} }
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< export class BaseQueryBuilder<
M extends Model, M extends Model,
R = M[], R = M[],
> extends PaginationQueryBuilder<M, R> { > extends PaginationQueryBuilder<M, R> {
// You can add more shared query methods here in the future
changeAmount(whereAttributes, attribute, amount) { changeAmount(whereAttributes, attribute, amount) {
const changeMethod = amount > 0 ? 'increment' : 'decrement'; const changeMethod = amount > 0 ? 'increment' : 'decrement';

View File

@@ -39,9 +39,6 @@ export class DeleteBranchService {
.query() .query()
.findById(branchId) .findById(branchId)
.throwIfNotFound(); .throwIfNotFound();
// .queryAndThrowIfHasRelations({
// type: ERRORS.BRANCH_HAS_ASSOCIATED_TRANSACTIONS,
// });
// Authorize the branch before deleting. // Authorize the branch before deleting.
await this.authorize(branchId); await this.authorize(branchId);
@@ -54,8 +51,10 @@ export class DeleteBranchService {
trx, trx,
} as IBranchDeletePayload); } 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. // Triggers `onBranchCreate` event.
await this.eventPublisher.emitAsync(events.warehouse.onEdited, { await this.eventPublisher.emitAsync(events.warehouse.onEdited, {
oldBranch, oldBranch,

View File

@@ -9,6 +9,7 @@ import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service';
import { Customer } from '../models/Customer'; import { Customer } from '../models/Customer';
import { events } from '@/common/events/events'; import { events } from '@/common/events/events';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { ERRORS } from '../constants';
@Injectable() @Injectable()
export class DeleteCustomer { export class DeleteCustomer {
@@ -36,9 +37,6 @@ export class DeleteCustomer {
.query() .query()
.findById(customerId) .findById(customerId)
.throwIfNotFound(); .throwIfNotFound();
// .queryAndThrowIfHasRelations({
// type: ERRORS.CUSTOMER_HAS_TRANSACTIONS,
// });
// Triggers `onCustomerDeleting` event. // Triggers `onCustomerDeleting` event.
await this.eventPublisher.emitAsync(events.customers.onDeleting, { await this.eventPublisher.emitAsync(events.customers.onDeleting, {
@@ -49,8 +47,13 @@ export class DeleteCustomer {
// Deletes the customer and associated entities under UOW transaction. // Deletes the customer and associated entities under UOW transaction.
return this.uow.withTransaction(async (trx: Knex.Transaction) => { return this.uow.withTransaction(async (trx: Knex.Transaction) => {
// Delete the customer from the storage. // 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. // Throws `onCustomerDeleted` event.
await this.eventPublisher.emitAsync(events.customers.onDeleted, { await this.eventPublisher.emitAsync(events.customers.onDeleted, {
customerId, customerId,

View File

@@ -39,11 +39,8 @@ export class DeleteItemService {
// Retrieve the given item or throw not found service error. // Retrieve the given item or throw not found service error.
const oldItem = await this.itemModel() const oldItem = await this.itemModel()
.query() .query()
.findById(itemId) .findOne('id', itemId)
.throwIfNotFound(); .deleteIfNoRelations();
// .queryAndThrowIfHasRelations({
// type: ERRORS.ITEM_HAS_ASSOCIATED_TRANSACTIONS,
// });
// Delete item in unit of work. // Delete item in unit of work.
return this.uow.withTransaction(async (trx: Knex.Transaction) => { return this.uow.withTransaction(async (trx: Knex.Transaction) => {

View File

@@ -9,6 +9,7 @@ import {
IVendorEventDeletingPayload, IVendorEventDeletingPayload,
} from '../types/Vendors.types'; } from '../types/Vendors.types';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { ERRORS } from '../constants';
@Injectable() @Injectable()
export class DeleteVendorService { export class DeleteVendorService {
@@ -34,9 +35,6 @@ export class DeleteVendorService {
.query() .query()
.findById(vendorId) .findById(vendorId)
.throwIfNotFound(); .throwIfNotFound();
// .queryAndThrowIfHasRelations({
// type: ERRORS.VENDOR_HAS_TRANSACTIONS,
// });
// Triggers `onVendorDeleting` event. // Triggers `onVendorDeleting` event.
await this.eventPublisher.emitAsync(events.vendors.onDeleting, { await this.eventPublisher.emitAsync(events.vendors.onDeleting, {
@@ -47,8 +45,13 @@ export class DeleteVendorService {
// Deletes vendor contact under unit-of-work. // Deletes vendor contact under unit-of-work.
return this.uow.withTransaction(async (trx: Knex.Transaction) => { return this.uow.withTransaction(async (trx: Knex.Transaction) => {
// Deletes the vendor contact from the storage. // 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. // Triggers `onVendorDeleted` event.
await this.eventPublisher.emitAsync(events.vendors.onDeleted, { await this.eventPublisher.emitAsync(events.vendors.onDeleted, {
vendorId, vendorId,

View File

@@ -49,9 +49,6 @@ export class DeleteWarehouseService {
.query() .query()
.findById(warehouseId) .findById(warehouseId)
.throwIfNotFound(); .throwIfNotFound();
// .queryAndThrowIfHasRelations({
// type: ERRORS.WAREHOUSE_HAS_ASSOCIATED_TRANSACTIONS,
// });
// Validates the given warehouse before deleting. // Validates the given warehouse before deleting.
await this.authorize(warehouseId); await this.authorize(warehouseId);
@@ -70,8 +67,13 @@ export class DeleteWarehouseService {
eventPayload, eventPayload,
); );
// Deletes the given warehouse from the storage. // 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`. // Triggers `onWarehouseCreated`.
await this.eventPublisher.emitAsync( await this.eventPublisher.emitAsync(
events.warehouse.onDeleted, events.warehouse.onDeleted,

View File

@@ -1,6 +1,7 @@
// import { Model } from 'objection'; // import { Model } from 'objection';
import { BaseModel } from '@/models/Model'; import { BaseModel, BaseQueryBuilder } from '@/models/Model';
import { Item } from '@/modules/Items/models/Item'; import { Item } from '@/modules/Items/models/Item';
import { Model } from 'objection';
export class Warehouse extends BaseModel { export class Warehouse extends BaseModel {
name!: string; name!: string;
@@ -45,128 +46,134 @@ export class Warehouse extends BaseModel {
/** /**
* Relationship mapping. * Relationship mapping.
*/ */
// static get relationMappings() { static get relationMappings() {
// const SaleInvoice = require('models/SaleInvoice'); const { SaleInvoice } = require('../../SaleInvoices/models/SaleInvoice');
// const SaleEstimate = require('models/SaleEstimate'); const { SaleEstimate } = require('../../SaleEstimates/models/SaleEstimate');
// const SaleReceipt = require('models/SaleReceipt'); const { SaleReceipt } = require('../../SaleReceipts/models/SaleReceipt');
// const Bill = require('models/Bill'); const { Bill } = require('../../Bills/models/Bill');
// const VendorCredit = require('models/VendorCredit'); const { VendorCredit } = require('../../VendorCredit/models/VendorCredit');
// const CreditNote = require('models/CreditNote'); const { CreditNote } = require('../../CreditNotes/models/CreditNote');
// const InventoryTransaction = require('models/InventoryTransaction'); const {
// const WarehouseTransfer = require('models/WarehouseTransfer'); InventoryTransaction,
// const InventoryAdjustment = require('models/InventoryAdjustment'); } = require('../../InventoryCost/models/InventoryTransaction');
const {
WarehouseTransfer,
} = require('../../WarehousesTransfers/models/WarehouseTransfer');
const {
InventoryAdjustment,
} = require('../../InventoryAdjutments/models/InventoryAdjustment');
// return { return {
// /** /**
// * Warehouse may belongs to associated sale invoices. * Warehouse may belongs to associated sale invoices.
// */ */
// invoices: { invoices: {
// relation: Model.HasManyRelation, relation: Model.HasManyRelation,
// modelClass: SaleInvoice.default, modelClass: SaleInvoice,
// join: { join: {
// from: 'warehouses.id', from: 'warehouses.id',
// to: 'sales_invoices.warehouseId', to: 'sales_invoices.warehouseId',
// }, },
// }, },
// /** /**
// * Warehouse may belongs to associated sale estimates. * Warehouse may belongs to associated sale estimates.
// */ */
// estimates: { estimates: {
// relation: Model.HasManyRelation, relation: Model.HasManyRelation,
// modelClass: SaleEstimate.default, modelClass: SaleEstimate,
// join: { join: {
// from: 'warehouses.id', from: 'warehouses.id',
// to: 'sales_estimates.warehouseId', to: 'sales_estimates.warehouseId',
// }, },
// }, },
// /** /**
// * Warehouse may belongs to associated sale receipts. * Warehouse may belongs to associated sale receipts.
// */ */
// receipts: { receipts: {
// relation: Model.HasManyRelation, relation: Model.HasManyRelation,
// modelClass: SaleReceipt.default, modelClass: SaleReceipt,
// join: { join: {
// from: 'warehouses.id', from: 'warehouses.id',
// to: 'sales_receipts.warehouseId', to: 'sales_receipts.warehouseId',
// }, },
// }, },
// /** /**
// * Warehouse may belongs to associated bills. * Warehouse may belongs to associated bills.
// */ */
// bills: { bills: {
// relation: Model.HasManyRelation, relation: Model.HasManyRelation,
// modelClass: Bill.default, modelClass: Bill,
// join: { join: {
// from: 'warehouses.id', from: 'warehouses.id',
// to: 'bills.warehouseId', to: 'bills.warehouseId',
// }, },
// }, },
// /** /**
// * Warehouse may belongs to associated credit notes. * Warehouse may belongs to associated credit notes.
// */ */
// creditNotes: { creditNotes: {
// relation: Model.HasManyRelation, relation: Model.HasManyRelation,
// modelClass: CreditNote.default, modelClass: CreditNote,
// join: { join: {
// from: 'warehouses.id', from: 'warehouses.id',
// to: 'credit_notes.warehouseId', to: 'credit_notes.warehouseId',
// }, },
// }, },
// /** /**
// * Warehouse may belongs to associated to vendor credits. * Warehouse may belongs to associated to vendor credits.
// */ */
// vendorCredit: { vendorCredit: {
// relation: Model.HasManyRelation, relation: Model.HasManyRelation,
// modelClass: VendorCredit.default, modelClass: VendorCredit,
// join: { join: {
// from: 'warehouses.id', from: 'warehouses.id',
// to: 'vendor_credits.warehouseId', to: 'vendor_credits.warehouseId',
// }, },
// }, },
// /** /**
// * Warehouse may belongs to associated to inventory transactions. * Warehouse may belongs to associated to inventory transactions.
// */ */
// inventoryTransactions: { inventoryTransactions: {
// relation: Model.HasManyRelation, relation: Model.HasManyRelation,
// modelClass: InventoryTransaction.default, modelClass: InventoryTransaction,
// join: { join: {
// from: 'warehouses.id', from: 'warehouses.id',
// to: 'inventory_transactions.warehouseId', to: 'inventory_transactions.warehouseId',
// }, },
// }, },
// warehouseTransferTo: { warehouseTransferTo: {
// relation: Model.HasManyRelation, relation: Model.HasManyRelation,
// modelClass: WarehouseTransfer.default, modelClass: WarehouseTransfer,
// join: { join: {
// from: 'warehouses.id', from: 'warehouses.id',
// to: 'warehouses_transfers.toWarehouseId', to: 'warehouses_transfers.toWarehouseId',
// }, },
// }, },
// warehouseTransferFrom: { warehouseTransferFrom: {
// relation: Model.HasManyRelation, relation: Model.HasManyRelation,
// modelClass: WarehouseTransfer.default, modelClass: WarehouseTransfer,
// join: { join: {
// from: 'warehouses.id', from: 'warehouses.id',
// to: 'warehouses_transfers.fromWarehouseId', to: 'warehouses_transfers.fromWarehouseId',
// }, },
// }, },
// inventoryAdjustment: { inventoryAdjustment: {
// relation: Model.HasManyRelation, relation: Model.HasManyRelation,
// modelClass: InventoryAdjustment.default, modelClass: InventoryAdjustment,
// join: { join: {
// from: 'warehouses.id', from: 'warehouses.id',
// to: 'inventory_adjustments.warehouseId', to: 'inventory_adjustments.warehouseId',
// }, },
// }, },
// }; };
// } }
} }