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 { 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);
}

View File

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

View File

@@ -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,

View File

@@ -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,

View File

@@ -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) => {

View File

@@ -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,

View File

@@ -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,

View File

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