This commit is contained in:
Ahmed Bouhuolia
2025-11-20 17:41:16 +02:00
parent d90b6ffbe7
commit 56e00d254b
71 changed files with 1167 additions and 185 deletions

View File

@@ -0,0 +1,34 @@
import { Injectable } from '@nestjs/common';
import { castArray, uniq } from 'lodash';
import { PromisePool } from '@supercharge/promise-pool';
import { DeleteCustomer } from './commands/DeleteCustomer.service';
@Injectable()
export class BulkDeleteCustomersService {
constructor(private readonly deleteCustomerService: DeleteCustomer) {}
public async bulkDeleteCustomers(
customerIds: number[],
options?: { skipUndeletable?: boolean },
): Promise<void> {
const { skipUndeletable = false } = options ?? {};
const ids = uniq(castArray(customerIds));
const results = await PromisePool.withConcurrency(1)
.for(ids)
.process(async (customerId: number) => {
try {
await this.deleteCustomerService.deleteCustomer(customerId);
} catch (error) {
if (!skipUndeletable) {
throw error;
}
}
});
if (!skipUndeletable && results.errors && results.errors.length > 0) {
throw results.errors[0].raw ?? results.errors[0];
}
}
}

View File

@@ -24,6 +24,10 @@ import { CreateCustomerDto } from './dtos/CreateCustomer.dto';
import { EditCustomerDto } from './dtos/EditCustomer.dto';
import { CustomerResponseDto } from './dtos/CustomerResponse.dto';
import { GetCustomersQueryDto } from './dtos/GetCustomersQuery.dto';
import {
BulkDeleteCustomersDto,
ValidateBulkDeleteCustomersResponseDto,
} from './dtos/BulkDeleteCustomers.dto';
import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
@Controller('customers')
@@ -31,7 +35,7 @@ import { ApiCommonHeaders } from '@/common/decorators/ApiCommonHeaders';
@ApiExtraModels(CustomerResponseDto)
@ApiCommonHeaders()
export class CustomersController {
constructor(private customersApplication: CustomersApplication) {}
constructor(private customersApplication: CustomersApplication) { }
@Get(':id')
@ApiOperation({ summary: 'Retrieves the customer details.' })
@@ -109,4 +113,37 @@ export class CustomersController {
openingBalanceDTO,
);
}
@Post('validate-bulk-delete')
@ApiOperation({
summary:
'Validates which customers can be deleted and returns counts of deletable and non-deletable customers.',
})
@ApiResponse({
status: 200,
description:
'Validation completed. Returns counts and IDs of deletable and non-deletable customers.',
schema: { $ref: getSchemaPath(ValidateBulkDeleteCustomersResponseDto) },
})
validateBulkDeleteCustomers(
@Body() bulkDeleteDto: BulkDeleteCustomersDto,
): Promise<ValidateBulkDeleteCustomersResponseDto> {
return this.customersApplication.validateBulkDeleteCustomers(
bulkDeleteDto.ids,
);
}
@Post('bulk-delete')
@ApiOperation({ summary: 'Deletes multiple customers in bulk.' })
@ApiResponse({
status: 200,
description: 'The customers have been successfully deleted.',
})
async bulkDeleteCustomers(
@Body() bulkDeleteDto: BulkDeleteCustomersDto,
): Promise<void> {
return this.customersApplication.bulkDeleteCustomers(bulkDeleteDto.ids, {
skipUndeletable: bulkDeleteDto.skipUndeletable ?? false,
});
}
}

View File

@@ -16,6 +16,8 @@ import { CustomersExportable } from './CustomersExportable';
import { CustomersImportable } from './CustomersImportable';
import { GetCustomers } from './queries/GetCustomers.service';
import { DynamicListModule } from '../DynamicListing/DynamicList.module';
import { BulkDeleteCustomersService } from './BulkDeleteCustomers.service';
import { ValidateBulkDeleteCustomersService } from './ValidateBulkDeleteCustomers.service';
@Module({
imports: [TenancyDatabaseModule, DynamicListModule],
@@ -37,6 +39,8 @@ import { DynamicListModule } from '../DynamicListing/DynamicList.module';
CustomersExportable,
CustomersImportable,
GetCustomers,
BulkDeleteCustomersService,
ValidateBulkDeleteCustomersService,
],
})
export class CustomersModule {}

View File

@@ -12,6 +12,8 @@ import { CreateCustomerDto } from './dtos/CreateCustomer.dto';
import { EditCustomerDto } from './dtos/EditCustomer.dto';
import { GetCustomers } from './queries/GetCustomers.service';
import { GetCustomersQueryDto } from './dtos/GetCustomersQuery.dto';
import { BulkDeleteCustomersService } from './BulkDeleteCustomers.service';
import { ValidateBulkDeleteCustomersService } from './ValidateBulkDeleteCustomers.service';
@Injectable()
export class CustomersApplication {
@@ -22,6 +24,8 @@ export class CustomersApplication {
private deleteCustomerService: DeleteCustomer,
private editOpeningBalanceService: EditOpeningBalanceCustomer,
private getCustomersService: GetCustomers,
private readonly bulkDeleteCustomersService: BulkDeleteCustomersService,
private readonly validateBulkDeleteCustomersService: ValidateBulkDeleteCustomersService,
) {}
/**
@@ -83,4 +87,20 @@ export class CustomersApplication {
public getCustomers = (filterDTO: GetCustomersQueryDto) => {
return this.getCustomersService.getCustomersList(filterDTO);
};
public bulkDeleteCustomers = (
customerIds: number[],
options?: { skipUndeletable?: boolean },
) => {
return this.bulkDeleteCustomersService.bulkDeleteCustomers(
customerIds,
options,
);
};
public validateBulkDeleteCustomers = (customerIds: number[]) => {
return this.validateBulkDeleteCustomersService.validateBulkDeleteCustomers(
customerIds,
);
};
}

View File

@@ -0,0 +1,61 @@
import { Inject, Injectable } from '@nestjs/common';
import { Knex } from 'knex';
import { TENANCY_DB_CONNECTION } from '../Tenancy/TenancyDB/TenancyDB.constants';
import { DeleteCustomer } from './commands/DeleteCustomer.service';
import { ModelHasRelationsError } from '@/common/exceptions/ModelHasRelations.exception';
import { ServiceError } from '@/modules/Items/ServiceError';
@Injectable()
export class ValidateBulkDeleteCustomersService {
constructor(
private readonly deleteCustomerService: DeleteCustomer,
@Inject(TENANCY_DB_CONNECTION)
private readonly tenantKnex: () => Knex,
) {}
public async validateBulkDeleteCustomers(customerIds: number[]): Promise<{
deletableCount: number;
nonDeletableCount: number;
deletableIds: number[];
nonDeletableIds: number[];
}> {
const trx = await this.tenantKnex().transaction({
isolationLevel: 'read uncommitted',
});
try {
const deletableIds: number[] = [];
const nonDeletableIds: number[] = [];
for (const customerId of customerIds) {
try {
await this.deleteCustomerService.deleteCustomer(customerId, trx);
deletableIds.push(customerId);
} catch (error) {
if (
error instanceof ModelHasRelationsError ||
(error instanceof ServiceError &&
error.errorType === 'CUSTOMER_HAS_TRANSACTIONS')
) {
nonDeletableIds.push(customerId);
} else {
nonDeletableIds.push(customerId);
}
}
}
await trx.rollback();
return {
deletableCount: deletableIds.length,
nonDeletableCount: nonDeletableIds.length,
deletableIds,
nonDeletableIds,
};
} catch (error) {
await trx.rollback();
throw error;
}
}
}

View File

@@ -31,12 +31,13 @@ export class DeleteCustomer {
* @param {number} customerId - Customer ID.
* @return {Promise<void>}
*/
public async deleteCustomer(customerId: number): Promise<void> {
public async deleteCustomer(
customerId: number,
trx?: Knex.Transaction,
): Promise<void> {
// Retrieve the customer or throw not found service error.
const oldCustomer = await this.customerModel()
.query()
.findById(customerId)
.throwIfNotFound();
const query = this.customerModel().query(trx);
const oldCustomer = await query.findById(customerId).throwIfNotFound();
// Triggers `onCustomerDeleting` event.
await this.eventPublisher.emitAsync(events.customers.onDeleting, {
@@ -45,10 +46,10 @@ export class DeleteCustomer {
} as ICustomerDeletingPayload);
// Deletes the customer and associated entities under UOW transaction.
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
return this.uow.withTransaction(async (transaction: Knex.Transaction) => {
// Delete the customer from the storage.
await this.customerModel()
.query(trx)
.query(transaction)
.findById(customerId)
.deleteIfNoRelations({
type: ERRORS.CUSTOMER_HAS_TRANSACTIONS,
@@ -58,8 +59,8 @@ export class DeleteCustomer {
await this.eventPublisher.emitAsync(events.customers.onDeleted, {
customerId,
oldCustomer,
trx,
trx: transaction,
} as ICustomerEventDeletedPayload);
});
}, trx);
}
}

View File

@@ -0,0 +1,62 @@
import {
ArrayNotEmpty,
IsArray,
IsInt,
IsOptional,
IsBoolean,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { parseBoolean } from '@/utils/parse-boolean';
export class BulkDeleteCustomersDto {
@IsArray()
@ArrayNotEmpty()
@IsInt({ each: true })
@ApiProperty({
description: 'Array of customer IDs to delete',
type: [Number],
example: [1, 2, 3],
})
ids: number[];
@IsOptional()
@IsBoolean()
@Transform(({ value, obj }) => parseBoolean(value ?? obj?.skip_undeletable, false))
@ApiPropertyOptional({
description:
'When true, undeletable customers will be skipped and only deletable ones removed.',
type: Boolean,
default: false,
})
skipUndeletable?: boolean;
}
export class ValidateBulkDeleteCustomersResponseDto {
@ApiProperty({
description: 'Number of customers that can be deleted',
example: 2,
})
deletableCount: number;
@ApiProperty({
description: 'Number of customers that cannot be deleted',
example: 1,
})
nonDeletableCount: number;
@ApiProperty({
description: 'IDs of customers that can be deleted',
type: [Number],
example: [1, 2],
})
deletableIds: number[];
@ApiProperty({
description: 'IDs of customers that cannot be deleted',
type: [Number],
example: [3],
})
nonDeletableIds: number[];
}