From 5bb461343bcc7307368c58312042fb9c43bc5e38 Mon Sep 17 00:00:00 2001 From: prudentbird Date: Fri, 24 Oct 2025 19:52:27 +0100 Subject: [PATCH 1/2] feat: add store type description and validation for store creation and updates - Introduced a new migration to add a 'store_type_description' column to the 'stores' table. - Updated the StoreDto to include validation for 'storeTypeDescription' when the store type is 'SHOP' or 'OTHER'. - Enhanced the StoreService to enforce the requirement of a description for these store types during creation and updates. - Modified relevant tests to cover new validation rules and ensure proper handling of store types and descriptions. --- .../1761330903480-AddStoreTypeDescription.ts | 62 ++++ .../src/modules/admin/admin.service.spec.ts | 2 +- backend/src/modules/store/dto/store.dto.ts | 41 ++- .../modules/store/entities/store.entity.ts | 6 +- .../modules/store/store.controller.spec.ts | 8 +- .../src/modules/store/store.service.spec.ts | 305 +++++++++++++++++- backend/src/modules/store/store.service.ts | 23 ++ .../modules/store/types/list-store.type.ts | 2 + .../modules/store/types/store.interface.ts | 16 +- 9 files changed, 453 insertions(+), 12 deletions(-) create mode 100644 backend/src/database/migrations/1761330903480-AddStoreTypeDescription.ts diff --git a/backend/src/database/migrations/1761330903480-AddStoreTypeDescription.ts b/backend/src/database/migrations/1761330903480-AddStoreTypeDescription.ts new file mode 100644 index 0000000..4071f81 --- /dev/null +++ b/backend/src/database/migrations/1761330903480-AddStoreTypeDescription.ts @@ -0,0 +1,62 @@ +import { + MigrationInterface, + QueryRunner, + TableColumn, + TableIndex, +} from 'typeorm'; + +export class AddStoreTypeDescription1761330903480 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.addColumn( + 'stores', + new TableColumn({ + name: 'store_type_description', + type: 'text', + isNullable: true, + }), + ); + + await queryRunner.query(` + UPDATE stores + SET store_type_description = store_type + WHERE store_type IS NOT NULL + AND store_type != '' + AND store_type != 'OTHER' + `); + + await queryRunner.query(` + UPDATE stores + SET store_type = 'OTHER' + WHERE store_type IS NOT NULL + `); + + await queryRunner.query(` + UPDATE stores + SET store_type = 'OTHER', + store_type_description = 'Legacy store type - migrated from previous system' + WHERE store_type IS NULL OR store_type = '' + `); + + await queryRunner.createIndex( + 'stores', + new TableIndex({ + columnNames: ['store_type'], + name: 'IDX_stores_store_type', + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropIndex('stores', 'IDX_stores_store_type'); + + await queryRunner.query(` + UPDATE stores + SET store_type = store_type_description + WHERE store_type_description IS NOT NULL AND store_type_description != '' + `); + + await queryRunner.dropColumn('stores', 'store_type_description'); + } +} diff --git a/backend/src/modules/admin/admin.service.spec.ts b/backend/src/modules/admin/admin.service.spec.ts index f1591ec..73af9bb 100644 --- a/backend/src/modules/admin/admin.service.spec.ts +++ b/backend/src/modules/admin/admin.service.spec.ts @@ -36,7 +36,7 @@ describe('AdminService', () => { id: '1', name: 'Test Store', address: '123 Main St', - storeType: 'Retail', + storeType: 'SHOP' as const, latitude: 123.456, longitude: 78.901, localGovernment: { diff --git a/backend/src/modules/store/dto/store.dto.ts b/backend/src/modules/store/dto/store.dto.ts index 0c70868..cb7e720 100644 --- a/backend/src/modules/store/dto/store.dto.ts +++ b/backend/src/modules/store/dto/store.dto.ts @@ -5,8 +5,11 @@ import { IsOptional, IsLatitude, IsLongitude, + IsIn, + ValidateIf, } from 'class-validator'; import { QueryValidator } from '~/helpers/query.helper'; +import { StoreType } from '../types/store.interface'; export class StoreDto { @IsString() @@ -27,7 +30,26 @@ export class StoreDto { @IsString() @IsNotEmpty() - storeType: string; + @IsIn([ + 'SHOP', + 'REFUSE_SITE', + 'SCHOOL', + 'HOSPITAL', + 'BAR_RESTAURANT', + 'FUELING_STATION', + 'HOTEL', + 'RECREATION_PARK', + 'FINANCIAL_INSTITUTION', + 'RELIGIOUS', + 'OTHER', + ]) + storeType: StoreType; + + @IsString() + @IsOptional() + @ValidateIf((o) => o.storeType === 'SHOP' || o.storeType === 'OTHER') + @IsNotEmpty() + storeTypeDescription?: string; @IsString() @IsOptional() @@ -82,6 +104,23 @@ export class StoreQueryValidator extends QueryValidator { @IsOptional() districtId?: string; + @IsString() + @IsOptional() + @IsIn([ + 'SHOP', + 'REFUSE_SITE', + 'SCHOOL', + 'HOSPITAL', + 'BAR_RESTAURANT', + 'FUELING_STATION', + 'HOTEL', + 'RECREATION_PARK', + 'FINANCIAL_INSTITUTION', + 'RELIGIOUS', + 'OTHER', + ]) + storeType?: StoreType; + @IsOptional() @IsLatitude() minLat?: `${number}`; diff --git a/backend/src/modules/store/entities/store.entity.ts b/backend/src/modules/store/entities/store.entity.ts index 3891efe..887ea41 100644 --- a/backend/src/modules/store/entities/store.entity.ts +++ b/backend/src/modules/store/entities/store.entity.ts @@ -1,3 +1,4 @@ +import { StoreType } from '../types/store.interface'; import { User } from '~/modules/user/entities/user.entity'; import { State } from '~/modules/state/entities/state.entity'; import { Phase } from '~/modules/phase/entities/phase.entity'; @@ -31,7 +32,10 @@ export class Store extends AbstractBaseEntity { address: string; @Column() - storeType: string; + storeType: StoreType; + + @Column({ type: 'text', nullable: true }) + storeTypeDescription?: string; @Column({ type: 'text', nullable: true }) landmarks?: string; diff --git a/backend/src/modules/store/store.controller.spec.ts b/backend/src/modules/store/store.controller.spec.ts index 7aadb4e..060b65f 100644 --- a/backend/src/modules/store/store.controller.spec.ts +++ b/backend/src/modules/store/store.controller.spec.ts @@ -14,7 +14,8 @@ describe('StoreController', () => { id: '1', name: 'Test Store', address: '123 Main St', - storeType: 'Retail', + storeType: 'SHOP' as const, + storeTypeDescription: 'Electronics store', latitude: 123.456, longitude: 78.901, localGovernmentId: '1', @@ -58,7 +59,8 @@ describe('StoreController', () => { const storeDto: Omit = { name: 'Test Store', address: '123 Main St', - storeType: 'Retail', + storeType: 'SHOP', + storeTypeDescription: 'Electronics store', latitude: 123.456, longitude: 78.901, localGovernmentId: '1', @@ -85,7 +87,7 @@ describe('StoreController', () => { const storeDto: Omit = { name: 'Test Store', address: '123 Main St', - storeType: 'Retail', + storeType: 'HOSPITAL', latitude: 123.456, longitude: 78.901, localGovernmentId: '1', diff --git a/backend/src/modules/store/store.service.spec.ts b/backend/src/modules/store/store.service.spec.ts index 0ad2e61..26cf35d 100644 --- a/backend/src/modules/store/store.service.spec.ts +++ b/backend/src/modules/store/store.service.spec.ts @@ -45,7 +45,8 @@ describe('StoreService', () => { id: '1', name: 'Test Store', address: '123 Main St', - storeType: 'Retail', + storeType: 'SHOP' as const, + storeTypeDescription: 'Electronics store', latitude: 123.456, longitude: 78.901, localGovernment: { @@ -146,7 +147,8 @@ describe('StoreService', () => { const storeDto: Omit = { name: 'Test Store', address: '123 Main St', - storeType: 'Retail', + storeType: 'SHOP', + storeTypeDescription: 'Electronics store', latitude: 123.456, longitude: 78.901, localGovernmentId: '1', @@ -172,7 +174,7 @@ describe('StoreService', () => { const storeDto: Omit = { name: 'Test Store', address: '123 Main St', - storeType: 'Retail', + storeType: 'HOSPITAL', latitude: 123.456, longitude: 78.901, localGovernmentId: '1', @@ -194,6 +196,184 @@ describe('StoreService', () => { message: SYS_MSG.RESOURCE_CREATION_FAILED('Store'), }); }); + + it('should throw error when SHOP store type is provided without description', async () => { + const storeDto: Omit = { + name: 'Test Store', + address: '123 Main St', + storeType: 'SHOP', + latitude: 123.456, + longitude: 78.901, + localGovernmentId: '1', + stateId: '1', + }; + const enumeratorId = '1'; + + await expect(service.createStore(enumeratorId, storeDto)).rejects.toThrow( + CustomHttpException, + ); + await expect( + service.createStore(enumeratorId, storeDto), + ).rejects.toMatchObject({ + status: HttpStatus.BAD_REQUEST, + message: + 'Store type description is required when store type is SHOP or OTHER', + }); + }); + + it('should throw error when OTHER store type is provided without description', async () => { + const storeDto: Omit = { + name: 'Test Store', + address: '123 Main St', + storeType: 'OTHER', + latitude: 123.456, + longitude: 78.901, + localGovernmentId: '1', + stateId: '1', + }; + const enumeratorId = '1'; + + await expect(service.createStore(enumeratorId, storeDto)).rejects.toThrow( + CustomHttpException, + ); + await expect( + service.createStore(enumeratorId, storeDto), + ).rejects.toMatchObject({ + status: HttpStatus.BAD_REQUEST, + message: + 'Store type description is required when store type is SHOP or OTHER', + }); + }); + + it('should throw error when SHOP store type has empty string description', async () => { + const storeDto: Omit = { + name: 'Test Store', + address: '123 Main St', + storeType: 'SHOP', + storeTypeDescription: ' ', // whitespace only + latitude: 123.456, + longitude: 78.901, + localGovernmentId: '1', + stateId: '1', + }; + const enumeratorId = '1'; + + await expect(service.createStore(enumeratorId, storeDto)).rejects.toThrow( + CustomHttpException, + ); + await expect( + service.createStore(enumeratorId, storeDto), + ).rejects.toMatchObject({ + status: HttpStatus.BAD_REQUEST, + message: + 'Store type description is required when store type is SHOP or OTHER', + }); + }); + + it('should throw error when OTHER store type has empty string description', async () => { + const storeDto: Omit = { + name: 'Test Store', + address: '123 Main St', + storeType: 'OTHER', + storeTypeDescription: '', // empty string + latitude: 123.456, + longitude: 78.901, + localGovernmentId: '1', + stateId: '1', + }; + const enumeratorId = '1'; + + await expect(service.createStore(enumeratorId, storeDto)).rejects.toThrow( + CustomHttpException, + ); + await expect( + service.createStore(enumeratorId, storeDto), + ).rejects.toMatchObject({ + status: HttpStatus.BAD_REQUEST, + message: + 'Store type description is required when store type is SHOP or OTHER', + }); + }); + + it('should create store successfully when SHOP store type has description', async () => { + const storeDto: Omit = { + name: 'Test Store', + address: '123 Main St', + storeType: 'SHOP', + storeTypeDescription: 'Electronics store', + latitude: 123.456, + longitude: 78.901, + localGovernmentId: '1', + stateId: '1', + }; + const enumeratorId = '1'; + + jest.spyOn(storeModelAction, 'create').mockResolvedValue(mockStore); + + const result = await service.createStore(enumeratorId, storeDto); + + expect(storeModelAction.create).toHaveBeenCalledWith({ + createPayload: { ...storeDto, enumeratorId }, + transactionOptions: { useTransaction: false }, + }); + expect(result).toEqual({ + data: mockStore, + message: SYS_MSG.RESOURCE_CREATED_SUCCESSFULLY('Store'), + }); + }); + + it('should create store successfully when OTHER store type has description', async () => { + const storeDto: Omit = { + name: 'Test Store', + address: '123 Main St', + storeType: 'OTHER', + storeTypeDescription: 'Custom business type', + latitude: 123.456, + longitude: 78.901, + localGovernmentId: '1', + stateId: '1', + }; + const enumeratorId = '1'; + + jest.spyOn(storeModelAction, 'create').mockResolvedValue(mockStore); + + const result = await service.createStore(enumeratorId, storeDto); + + expect(storeModelAction.create).toHaveBeenCalledWith({ + createPayload: { ...storeDto, enumeratorId }, + transactionOptions: { useTransaction: false }, + }); + expect(result).toEqual({ + data: mockStore, + message: SYS_MSG.RESOURCE_CREATED_SUCCESSFULLY('Store'), + }); + }); + + it('should create store successfully when non-SHOP/OTHER store type is provided without description', async () => { + const storeDto: Omit = { + name: 'Test Store', + address: '123 Main St', + storeType: 'HOSPITAL', + latitude: 123.456, + longitude: 78.901, + localGovernmentId: '1', + stateId: '1', + }; + const enumeratorId = '1'; + + jest.spyOn(storeModelAction, 'create').mockResolvedValue(mockStore); + + const result = await service.createStore(enumeratorId, storeDto); + + expect(storeModelAction.create).toHaveBeenCalledWith({ + createPayload: { ...storeDto, enumeratorId }, + transactionOptions: { useTransaction: false }, + }); + expect(result).toEqual({ + data: mockStore, + message: SYS_MSG.RESOURCE_CREATED_SUCCESSFULLY('Store'), + }); + }); }); describe('getStoreById', () => { @@ -347,7 +527,7 @@ describe('StoreService', () => { const storeDto: Partial = { name: 'Updated Store', address: '456 New St', - storeType: 'Retail', + storeType: 'HOSPITAL', latitude: 123.456, longitude: 78.901, localGovernmentId: '1', @@ -378,7 +558,7 @@ describe('StoreService', () => { const storeDto: Partial = { name: 'Updated Store', address: '456 New St', - storeType: 'Retail', + storeType: 'HOSPITAL', latitude: 123.456, longitude: 78.901, localGovernmentId: '1', @@ -399,6 +579,121 @@ describe('StoreService', () => { message: SYS_MSG.RESOURCE_UPDATE_FAILED('Store'), }); }); + + it('should throw error when updating to SHOP store type without description', async () => { + const storeId = '1'; + const userId = '1'; + const storeDto: Partial = { + storeType: 'SHOP', + }; + + await expect( + service.updateStore(storeId, userId, storeDto), + ).rejects.toThrow(CustomHttpException); + await expect( + service.updateStore(storeId, userId, storeDto), + ).rejects.toMatchObject({ + status: HttpStatus.BAD_REQUEST, + message: + 'Store type description is required when store type is SHOP or OTHER', + }); + }); + + it('should throw error when updating to OTHER store type without description', async () => { + const storeId = '1'; + const userId = '1'; + const storeDto: Partial = { + storeType: 'OTHER', + }; + + await expect( + service.updateStore(storeId, userId, storeDto), + ).rejects.toThrow(CustomHttpException); + await expect( + service.updateStore(storeId, userId, storeDto), + ).rejects.toMatchObject({ + status: HttpStatus.BAD_REQUEST, + message: + 'Store type description is required when store type is SHOP or OTHER', + }); + }); + + it('should update store successfully when SHOP store type has description', async () => { + const storeId = '1'; + const userId = '1'; + const storeDto: Partial = { + storeType: 'SHOP', + storeTypeDescription: 'Electronics store', + }; + + jest.spyOn(storeModelAction, 'update').mockResolvedValue({ + ...mockStore, + ...storeDto, + }); + + const result = await service.updateStore(storeId, userId, storeDto); + + expect(storeModelAction.update).toHaveBeenCalledWith({ + identifierOptions: { id: storeId, enumeratorId: userId }, + updatePayload: storeDto, + transactionOptions: { useTransaction: false }, + }); + expect(result).toEqual({ + data: { ...mockStore, ...storeDto }, + message: SYS_MSG.RESOURCE_UPDATED_SUCCESSFULLY('Store'), + }); + }); + + it('should update store successfully when OTHER store type has description', async () => { + const storeId = '1'; + const userId = '1'; + const storeDto: Partial = { + storeType: 'OTHER', + storeTypeDescription: 'Custom business type', + }; + + jest.spyOn(storeModelAction, 'update').mockResolvedValue({ + ...mockStore, + ...storeDto, + }); + + const result = await service.updateStore(storeId, userId, storeDto); + + expect(storeModelAction.update).toHaveBeenCalledWith({ + identifierOptions: { id: storeId, enumeratorId: userId }, + updatePayload: storeDto, + transactionOptions: { useTransaction: false }, + }); + expect(result).toEqual({ + data: { ...mockStore, ...storeDto }, + message: SYS_MSG.RESOURCE_UPDATED_SUCCESSFULLY('Store'), + }); + }); + + it('should update store successfully when non-SHOP/OTHER store type is provided without description', async () => { + const storeId = '1'; + const userId = '1'; + const storeDto: Partial = { + storeType: 'HOSPITAL', + }; + + jest.spyOn(storeModelAction, 'update').mockResolvedValue({ + ...mockStore, + ...storeDto, + }); + + const result = await service.updateStore(storeId, userId, storeDto); + + expect(storeModelAction.update).toHaveBeenCalledWith({ + identifierOptions: { id: storeId, enumeratorId: userId }, + updatePayload: storeDto, + transactionOptions: { useTransaction: false }, + }); + expect(result).toEqual({ + data: { ...mockStore, ...storeDto }, + message: SYS_MSG.RESOURCE_UPDATED_SUCCESSFULLY('Store'), + }); + }); }); describe('exportStores', () => { diff --git a/backend/src/modules/store/store.service.ts b/backend/src/modules/store/store.service.ts index e37e64d..8a62d5f 100644 --- a/backend/src/modules/store/store.service.ts +++ b/backend/src/modules/store/store.service.ts @@ -27,6 +27,17 @@ export class StoreService { enumeratorId: string, storeDto: Omit, ): Promise> { + if ( + (storeDto.storeType === 'SHOP' || storeDto.storeType === 'OTHER') && + (!storeDto.storeTypeDescription || + storeDto.storeTypeDescription.trim() === '') + ) { + throw new CustomHttpException( + 'Store type description is required when store type is SHOP or OTHER', + HttpStatus.BAD_REQUEST, + ); + } + const createStorePayload: CreateStoreRecordOptions = { createPayload: { ...storeDto, enumeratorId }, transactionOptions: { useTransaction: false }, @@ -144,6 +155,18 @@ export class StoreService { userId: string, storeDto: Partial, ): Promise> { + if ( + storeDto.storeType && + (storeDto.storeType === 'SHOP' || storeDto.storeType === 'OTHER') && + (!storeDto.storeTypeDescription || + storeDto.storeTypeDescription.trim() === '') + ) { + throw new CustomHttpException( + 'Store type description is required when store type is SHOP or OTHER', + HttpStatus.BAD_REQUEST, + ); + } + const updateStorePayload: UpdateStoreRecordOptions = { identifierOptions: { id, enumeratorId: userId }, updatePayload: storeDto, diff --git a/backend/src/modules/store/types/list-store.type.ts b/backend/src/modules/store/types/list-store.type.ts index a9520ec..be1297c 100644 --- a/backend/src/modules/store/types/list-store.type.ts +++ b/backend/src/modules/store/types/list-store.type.ts @@ -1,3 +1,4 @@ +import { StoreType } from './store.interface'; import ListGenericRecord from '~/types/generic/list-record.type'; import { FilterOptions, PaginationOptions } from '~/helpers/query.helper'; @@ -8,6 +9,7 @@ interface StoreFilterOptions extends FilterOptions { localGovernmentId?: string; phaseId?: string; districtId?: string; + storeType?: StoreType; minLat?: `${number}`; maxLat?: `${number}`; minLng?: `${number}`; diff --git a/backend/src/modules/store/types/store.interface.ts b/backend/src/modules/store/types/store.interface.ts index 0e5cdc8..a1e3dfb 100644 --- a/backend/src/modules/store/types/store.interface.ts +++ b/backend/src/modules/store/types/store.interface.ts @@ -1,11 +1,25 @@ import { AbstractBaseInterface } from '~/database/base/base.interface'; +export type StoreType = + | 'SHOP' + | 'REFUSE_SITE' + | 'SCHOOL' + | 'HOSPITAL' + | 'BAR_RESTAURANT' + | 'FUELING_STATION' + | 'HOTEL' + | 'RECREATION_PARK' + | 'FINANCIAL_INSTITUTION' + | 'RELIGIOUS' + | 'OTHER'; + export interface StoreInterface extends AbstractBaseInterface { name: string; localGovernmentId?: string; stateId?: string; address: string; - storeType: string; + storeType: StoreType; + storeTypeDescription?: string; landmarks?: string; photos?: string[]; latitude: number; From 982d9e0d635e9fb3b4274bc543dc4cf6234b5b25 Mon Sep 17 00:00:00 2001 From: prudentbird Date: Fri, 24 Oct 2025 20:11:38 +0100 Subject: [PATCH 2/2] feat: add length validation for store type description in StoreDto - Added Length validation to the storeTypeDescription field in StoreDto to ensure it meets the character requirements of 1 to 500 characters. - Updated validation message for clarity when the description does not meet the specified length. --- backend/src/modules/store/dto/store.dto.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/src/modules/store/dto/store.dto.ts b/backend/src/modules/store/dto/store.dto.ts index cb7e720..ecdfff3 100644 --- a/backend/src/modules/store/dto/store.dto.ts +++ b/backend/src/modules/store/dto/store.dto.ts @@ -7,6 +7,7 @@ import { IsLongitude, IsIn, ValidateIf, + Length, } from 'class-validator'; import { QueryValidator } from '~/helpers/query.helper'; import { StoreType } from '../types/store.interface'; @@ -47,6 +48,7 @@ export class StoreDto { @IsString() @IsOptional() + @Length(1, 500, { message: 'Store type description must be between 1 and 500 characters' }) @ValidateIf((o) => o.storeType === 'SHOP' || o.storeType === 'OTHER') @IsNotEmpty() storeTypeDescription?: string;