diff --git a/backend/.gitignore b/backend/.gitignore index 4b56acf..d526bb3 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -37,10 +37,8 @@ lerna-debug.log* # dotenv environment variable files .env -.env.development.local -.env.test.local -.env.production.local -.env.local +.env.* +!.env.example # temp directory .temp diff --git a/backend/src/database/base/base.model-action.ts b/backend/src/database/base/base.model-action.ts index e3f5104..20ab334 100644 --- a/backend/src/database/base/base.model-action.ts +++ b/backend/src/database/base/base.model-action.ts @@ -5,6 +5,8 @@ import { FindOptionsWhere, ObjectLiteral, Repository, + ILike, + Between, } from 'typeorm'; import { computePaginationMeta, PaginationMeta } from '~/helpers/query.helper'; import ListGenericRecord from '~/types/generic/list-record.type'; @@ -15,12 +17,18 @@ import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity export abstract class AbstractModelAction { model: EntityTarget; + protected readonly partialSearchFields: string[] = []; + protected readonly rangeFields: string[] = []; constructor( protected readonly repository: Repository, model: EntityTarget, + partialSearchFields: readonly string[] = [], + rangeFields: readonly string[] = [], ) { this.model = model; + this.partialSearchFields = [...partialSearchFields]; + this.rangeFields = [...rangeFields]; } async create( @@ -115,11 +123,53 @@ export abstract class AbstractModelAction { sort?: 'ASC' | 'DESC'; } & Record; + // Build where clause with partial search and range support + const whereClause: FindOptionsWhere = {}; + const processedRangeFields = new Set(); + + Object.entries(filter).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + // Handle range fields (lat/lng bounds) + if (this.isRangeField(key)) { + const baseField = this.getBaseFieldFromRangeKey(key); + if (!processedRangeFields.has(baseField)) { + const rangeValue = this.buildRangeQuery(baseField, filter); + if (rangeValue) { + (whereClause as Record)[baseField] = rangeValue; + processedRangeFields.add(baseField); + } + } + } + // Handle partial search fields + else if ( + this.partialSearchFields.includes(key) && + typeof value === 'string' + ) { + (whereClause as Record)[key] = ILike(`%${value}%`); + } + // Handle exact match fields + else if (!this.isRangeKey(key)) { + (whereClause as Record)[key] = value; + } + } + }); + const orderBy = {} as FindOptionsOrder; if (sort) { Object.keys(filter).forEach((key) => { - (orderBy as unknown as Record)[key] = sort; + if ( + key !== 'sort' && + !this.partialSearchFields.includes(key) && + !this.isRangeKey(key) + ) { + (orderBy as unknown as Record)[key] = sort; + } }); + + if (Object.keys(orderBy as object).length === 0) { + (orderBy as unknown as Record).createdAt = + 'DESC'; + } } else { (orderBy as unknown as Record).createdAt = 'DESC'; } @@ -127,7 +177,7 @@ export abstract class AbstractModelAction { if (paginationPayload) { const { limit, page } = paginationPayload; const query = await this.repository.find({ - where: filter as FindOptionsWhere, + where: whereClause, relations, take: +limit, skip: +limit * (+page - 1), @@ -135,7 +185,7 @@ export abstract class AbstractModelAction { }); const total = await this.repository.count({ - where: filter as FindOptionsWhere, + where: whereClause, }); return { @@ -145,7 +195,7 @@ export abstract class AbstractModelAction { } const query = await this.repository.find({ - where: filter as FindOptionsWhere, + where: whereClause, relations, order: orderBy, }); @@ -154,4 +204,71 @@ export abstract class AbstractModelAction { paginationMeta: computePaginationMeta(query.length, query.length, 1), }; } + + private isRangeField(key: string): boolean { + if (!this.isRangeKey(key)) return false; + const baseField = this.getBaseFieldFromRangeKey(key); + return this.rangeFields.includes(baseField); + } + + private isRangeKey(key: string): boolean { + return key.startsWith('min') || key.startsWith('max'); + } + + private getBaseFieldFromRangeKey(key: string): string { + if (key.startsWith('min') || key.startsWith('max')) { + // Convert minLat -> latitude, maxLng -> longitude, etc. + const baseName = key.substring(3); // Remove 'min' or 'max' + if (baseName === 'Lat') return 'latitude'; + if (baseName === 'Lng') return 'longitude'; + return baseName.toLowerCase(); + } + return key; + } + + private buildRangeQuery( + baseField: string, + filter: Record, + ): ReturnType | null { + let minKey: string, maxKey: string; + + // Map base field to min/max keys + if (baseField === 'latitude') { + minKey = 'minLat'; + maxKey = 'maxLat'; + } else if (baseField === 'longitude') { + minKey = 'minLng'; + maxKey = 'maxLng'; + } else { + minKey = `min${baseField.charAt(0).toUpperCase() + baseField.slice(1)}`; + maxKey = `max${baseField.charAt(0).toUpperCase() + baseField.slice(1)}`; + } + + const minValue = filter[minKey]; + const maxValue = filter[maxKey]; + + // Both min and max values are required for range query + if ( + minValue !== undefined && + maxValue !== undefined && + minValue !== null && + maxValue !== null + ) { + const min = + typeof minValue === 'string' ? parseFloat(minValue) : Number(minValue); + const max = + typeof maxValue === 'string' ? parseFloat(maxValue) : Number(maxValue); + + if ( + !isNaN(min) && + !isNaN(max) && + typeof min === 'number' && + typeof max === 'number' + ) { + return Between(min, max); + } + } + + return null; + } } diff --git a/backend/src/modules/admin/admin.controller.ts b/backend/src/modules/admin/admin.controller.ts index d819abe..9758418 100644 --- a/backend/src/modules/admin/admin.controller.ts +++ b/backend/src/modules/admin/admin.controller.ts @@ -18,6 +18,7 @@ import { UseGuards, } from '@nestjs/common'; import { AdminService } from './admin.service'; +import { SkipThrottle } from '@nestjs/throttler'; import { StoreQueryValidator } from '../store/dto/store.dto'; import { AssignLocationDto, UserQueryValidator } from '../user/dto/user.dto'; @@ -63,6 +64,7 @@ export class AdminController { } @Get('stores') + @SkipThrottle() async getStores(@Query() queryOptions: StoreQueryValidator) { return this.adminService.listStores(queryOptions); } diff --git a/backend/src/modules/store/dto/store.dto.ts b/backend/src/modules/store/dto/store.dto.ts index a096274..0c70868 100644 --- a/backend/src/modules/store/dto/store.dto.ts +++ b/backend/src/modules/store/dto/store.dto.ts @@ -1,5 +1,12 @@ +import { + IsString, + IsNotEmpty, + IsNumber, + IsOptional, + IsLatitude, + IsLongitude, +} from 'class-validator'; import { QueryValidator } from '~/helpers/query.helper'; -import { IsString, IsNotEmpty, IsNumber, IsOptional } from 'class-validator'; export class StoreDto { @IsString() @@ -74,4 +81,20 @@ export class StoreQueryValidator extends QueryValidator { @IsString() @IsOptional() districtId?: string; + + @IsOptional() + @IsLatitude() + minLat?: `${number}`; + + @IsOptional() + @IsLatitude() + maxLat?: `${number}`; + + @IsOptional() + @IsLongitude() + minLng?: `${number}`; + + @IsOptional() + @IsLongitude() + maxLng?: `${number}`; } diff --git a/backend/src/modules/store/store.controller.ts b/backend/src/modules/store/store.controller.ts index 20c4445..b3b710a 100644 --- a/backend/src/modules/store/store.controller.ts +++ b/backend/src/modules/store/store.controller.ts @@ -13,6 +13,7 @@ import { } from '@nestjs/common'; import { Request, Response } from 'express'; import { StoreService } from './store.service'; +import { SkipThrottle } from '@nestjs/throttler'; import { QueryValidator } from '~/helpers/query.helper'; import { StoreDto, StoreQueryValidator } from './dto/store.dto'; @@ -39,6 +40,7 @@ export class StoreController { } @Get() + @SkipThrottle() @HttpCode(HttpStatus.OK) async listStores( @Req() req: Request & { user: { sub: string } }, diff --git a/backend/src/modules/store/store.model-action.ts b/backend/src/modules/store/store.model-action.ts index c449fef..f03809b 100644 --- a/backend/src/modules/store/store.model-action.ts +++ b/backend/src/modules/store/store.model-action.ts @@ -4,9 +4,17 @@ import { Store } from './entities/store.entity'; import { InjectRepository } from '@nestjs/typeorm'; import { AbstractModelAction } from '~/database/base/base.model-action'; +const SEARCHABLE_STORE_COLUMNS: (keyof Store)[] = ['name']; +const FILTERABLE_STORE_COLUMNS: (keyof Store)[] = ['latitude', 'longitude']; + @Injectable() export class StoreModelAction extends AbstractModelAction { constructor(@InjectRepository(Store) repository: Repository) { - super(repository, Store); + super( + repository, + Store, + SEARCHABLE_STORE_COLUMNS, + FILTERABLE_STORE_COLUMNS, + ); } } diff --git a/backend/src/modules/store/store.service.spec.ts b/backend/src/modules/store/store.service.spec.ts index 25301cf..4fa39d0 100644 --- a/backend/src/modules/store/store.service.spec.ts +++ b/backend/src/modules/store/store.service.spec.ts @@ -6,17 +6,25 @@ import { Response } from 'express'; import { NullishValueError } from '~/helpers/try-safe'; import { CustomHttpException } from '~/helpers/custom.exception'; import { HttpStatus } from '@nestjs/common'; -import { EntityMetadata, EntityPropertyNotFoundError } from 'typeorm'; +import { + EntityMetadata, + EntityPropertyNotFoundError, + Repository, + ILike, + Between, +} from 'typeorm'; import { Store } from './entities/store.entity'; import { StoreQueryOptions } from './types/list-store.type'; import { UserRole, UserStatus } from '../user/constants/user.constant'; import { AuthProvider } from '../auth/constants/auth.constant'; import * as SYS_MSG from '~/helpers/system-messages'; import { ExportType } from '~/helpers/query.helper'; +import { getRepositoryToken } from '@nestjs/typeorm'; describe('StoreService', () => { let service: StoreService; let storeModelAction: StoreModelAction; + let mockRepository: jest.Mocked>; const mockResponse = { setHeader: jest.fn(), @@ -99,6 +107,15 @@ describe('StoreService', () => { list: jest.fn(), }; + const mockRepositoryMethods = { + find: jest.fn(), + count: jest.fn(), + save: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + findOne: jest.fn(), + }; + const module: TestingModule = await Test.createTestingModule({ providers: [ StoreService, @@ -106,11 +123,18 @@ describe('StoreService', () => { provide: StoreModelAction, useValue: mockStoreModelAction, }, + { + provide: getRepositoryToken(Store), + useValue: mockRepositoryMethods, + }, ], }).compile(); service = module.get(StoreService); storeModelAction = module.get(StoreModelAction); + mockRepository = module.get>( + getRepositoryToken(Store), + ) as jest.Mocked>; }); it('should be defined', () => { @@ -484,4 +508,441 @@ describe('StoreService', () => { ); }); }); + + describe('StoreModelAction - Partial Search Tests', () => { + let modelAction: StoreModelAction; + + beforeEach(() => { + modelAction = new StoreModelAction(mockRepository); + }); + + describe('list - partial search functionality', () => { + it('should perform exact match for non-name fields', async () => { + const stores = [mockStore]; + mockRepository.find.mockResolvedValue(stores); + mockRepository.count.mockResolvedValue(1); + + const listOptions = { + paginationPayload: { page: 1, limit: 10 }, + filterRecordOptions: { + stateId: 'state1', + enumeratorId: 'user1', + }, + }; + + await modelAction.list(listOptions); + + expect(mockRepository.find).toHaveBeenCalledWith({ + where: { + stateId: 'state1', + enumeratorId: 'user1', + }, + relations: undefined, + take: 10, + skip: 0, + order: { createdAt: 'DESC' }, + }); + }); + + it('should perform case-insensitive partial search for name field', async () => { + const stores = [mockStore]; + mockRepository.find.mockResolvedValue(stores); + mockRepository.count.mockResolvedValue(1); + + const listOptions = { + paginationPayload: { page: 1, limit: 10 }, + filterRecordOptions: { + name: 'Test', + stateId: 'state1', + }, + }; + + await modelAction.list(listOptions); + + expect(mockRepository.find).toHaveBeenCalledWith({ + where: { + name: ILike('%Test%'), + stateId: 'state1', + }, + relations: undefined, + take: 10, + skip: 0, + order: { createdAt: 'DESC' }, + }); + + expect(mockRepository.count).toHaveBeenCalledWith({ + where: { + name: ILike('%Test%'), + stateId: 'state1', + }, + }); + }); + + it('should handle partial name search with special characters', async () => { + const stores = [mockStore]; + mockRepository.find.mockResolvedValue(stores); + mockRepository.count.mockResolvedValue(1); + + const listOptions = { + paginationPayload: { page: 1, limit: 10 }, + filterRecordOptions: { + name: 'Store & Shop', + }, + }; + + await modelAction.list(listOptions); + + expect(mockRepository.find).toHaveBeenCalledWith({ + where: { + name: ILike('%Store & Shop%'), + }, + relations: undefined, + take: 10, + skip: 0, + order: { createdAt: 'DESC' }, + }); + }); + + it('should ignore undefined and null values in filter', async () => { + const stores = [mockStore]; + mockRepository.find.mockResolvedValue(stores); + mockRepository.count.mockResolvedValue(1); + + const listOptions = { + paginationPayload: { page: 1, limit: 10 }, + filterRecordOptions: { + name: 'Test', + stateId: undefined, + localGovernmentId: null, + }, + }; + + await modelAction.list(listOptions); + + expect(mockRepository.find).toHaveBeenCalledWith({ + where: { + name: ILike('%Test%'), + }, + relations: undefined, + take: 10, + skip: 0, + order: { createdAt: 'DESC' }, + }); + }); + + it('should work without pagination', async () => { + const stores = [mockStore]; + mockRepository.find.mockResolvedValue(stores); + + const listOptions = { + filterRecordOptions: { + name: 'Test Store', + }, + }; + + const result = await modelAction.list(listOptions); + + expect(mockRepository.find).toHaveBeenCalledWith({ + where: { + name: ILike('%Test Store%'), + }, + relations: undefined, + order: { createdAt: 'DESC' }, + }); + + expect(result).toEqual({ + payload: stores, + paginationMeta: { + total: 1, + page: 1, + limit: 1, + totalPages: 1, + hasNext: false, + hasPrevious: false, + }, + }); + }); + + it('should work with empty name string', async () => { + const stores = [mockStore]; + mockRepository.find.mockResolvedValue(stores); + mockRepository.count.mockResolvedValue(1); + + const listOptions = { + paginationPayload: { page: 1, limit: 10 }, + filterRecordOptions: { + name: '', + stateId: 'state1', + }, + }; + + await modelAction.list(listOptions); + + expect(mockRepository.find).toHaveBeenCalledWith({ + where: { + name: ILike('%%'), + stateId: 'state1', + }, + relations: undefined, + take: 10, + skip: 0, + order: { createdAt: 'DESC' }, + }); + }); + + it('should handle sorting with partial search', async () => { + const stores = [mockStore]; + mockRepository.find.mockResolvedValue(stores); + mockRepository.count.mockResolvedValue(1); + + const listOptions = { + paginationPayload: { page: 1, limit: 10 }, + filterRecordOptions: { + name: 'Test', + stateId: 'state1', + sort: 'ASC' as const, + }, + }; + + await modelAction.list(listOptions); + + expect(mockRepository.find).toHaveBeenCalledWith({ + where: { + name: ILike('%Test%'), + stateId: 'state1', + }, + relations: undefined, + take: 10, + skip: 0, + order: { stateId: 'ASC' }, + }); + }); + + it('should fall back to parent implementation when no filter options', async () => { + const stores = [mockStore]; + mockRepository.find.mockResolvedValue(stores); + mockRepository.count.mockResolvedValue(1); + + const listOptions = { + paginationPayload: { page: 1, limit: 10 }, + filterRecordOptions: undefined, + }; + + await modelAction.list(listOptions); + + expect(mockRepository.find).toHaveBeenCalledWith({ + relations: undefined, + take: 10, + skip: 0, + order: { createdAt: 'DESC' }, + }); + }); + + it('should handle geographic bounds filtering for lat/lng viewport', async () => { + const stores = [mockStore]; + mockRepository.find.mockResolvedValue(stores); + mockRepository.count.mockResolvedValue(1); + + const listOptions = { + paginationPayload: { page: 1, limit: 10 }, + filterRecordOptions: { + minLat: '9.070992170693003', + maxLat: '9.089723037902536', + minLng: '7.464008331298829', + maxLng: '7.497739791870118', + }, + }; + + await modelAction.list(listOptions); + + expect(mockRepository.find).toHaveBeenCalledWith({ + where: { + latitude: Between(9.070992170693003, 9.089723037902536), + longitude: Between(7.464008331298829, 7.497739791870118), + }, + relations: undefined, + take: 10, + skip: 0, + order: { createdAt: 'DESC' }, + }); + }); + + it('should handle combined name search and geographic bounds filtering', async () => { + const stores = [mockStore]; + mockRepository.find.mockResolvedValue(stores); + mockRepository.count.mockResolvedValue(1); + + const listOptions = { + paginationPayload: { page: 1, limit: 10 }, + filterRecordOptions: { + name: 'Market', + stateId: 'state1', + minLat: '9.0', + maxLat: '9.1', + minLng: '7.4', + maxLng: '7.5', + }, + }; + + await modelAction.list(listOptions); + + expect(mockRepository.find).toHaveBeenCalledWith({ + where: { + name: ILike('%Market%'), + stateId: 'state1', + latitude: Between(9.0, 9.1), + longitude: Between(7.4, 7.5), + }, + relations: undefined, + take: 10, + skip: 0, + order: { createdAt: 'DESC' }, + }); + }); + + it('should ignore incomplete bounds (only min or max provided)', async () => { + const stores = [mockStore]; + mockRepository.find.mockResolvedValue(stores); + mockRepository.count.mockResolvedValue(1); + + const listOptions = { + paginationPayload: { page: 1, limit: 10 }, + filterRecordOptions: { + name: 'Test', + minLat: '9.0', // Only min provided, no max + maxLng: '7.5', // Only max provided, no min + }, + }; + + await modelAction.list(listOptions); + + expect(mockRepository.find).toHaveBeenCalledWith({ + where: { + name: ILike('%Test%'), + }, + relations: undefined, + take: 10, + skip: 0, + order: { createdAt: 'DESC' }, + }); + }); + + it('should handle invalid numeric values in bounds', async () => { + const stores = [mockStore]; + mockRepository.find.mockResolvedValue(stores); + mockRepository.count.mockResolvedValue(1); + + const listOptions = { + paginationPayload: { page: 1, limit: 10 }, + filterRecordOptions: { + name: 'Test', + minLat: 'invalid', + maxLat: 'alsoinvalid', + minLng: '7.4', + maxLng: '7.5', + }, + }; + + await modelAction.list(listOptions); + + expect(mockRepository.find).toHaveBeenCalledWith({ + where: { + name: ILike('%Test%'), + longitude: Between(7.4, 7.5), + }, + relations: undefined, + take: 10, + skip: 0, + order: { createdAt: 'DESC' }, + }); + }); + + it('should work with geographic bounds only (no other filters)', async () => { + const stores = [mockStore]; + mockRepository.find.mockResolvedValue(stores); + mockRepository.count.mockResolvedValue(1); + + const listOptions = { + paginationPayload: { page: 1, limit: 10 }, + filterRecordOptions: { + minLat: '8.5', + maxLat: '9.5', + minLng: '7.0', + maxLng: '8.0', + }, + }; + + await modelAction.list(listOptions); + + expect(mockRepository.find).toHaveBeenCalledWith({ + where: { + latitude: Between(8.5, 9.5), + longitude: Between(7.0, 8.0), + }, + relations: undefined, + take: 10, + skip: 0, + order: { createdAt: 'DESC' }, + }); + }); + + it('should handle zero values in bounds', async () => { + const stores = [mockStore]; + mockRepository.find.mockResolvedValue(stores); + mockRepository.count.mockResolvedValue(1); + + const listOptions = { + paginationPayload: { page: 1, limit: 10 }, + filterRecordOptions: { + minLat: '0', + maxLat: '1', + minLng: '0', + maxLng: '1', + }, + }; + + await modelAction.list(listOptions); + + expect(mockRepository.find).toHaveBeenCalledWith({ + where: { + latitude: Between(0, 1), + longitude: Between(0, 1), + }, + relations: undefined, + take: 10, + skip: 0, + order: { createdAt: 'DESC' }, + }); + }); + + it('should handle negative coordinates in bounds', async () => { + const stores = [mockStore]; + mockRepository.find.mockResolvedValue(stores); + mockRepository.count.mockResolvedValue(1); + + const listOptions = { + paginationPayload: { page: 1, limit: 10 }, + filterRecordOptions: { + minLat: '-90', + maxLat: '-80', + minLng: '-180', + maxLng: '-170', + }, + }; + + await modelAction.list(listOptions); + + expect(mockRepository.find).toHaveBeenCalledWith({ + where: { + latitude: Between(-90, -80), + longitude: Between(-180, -170), + }, + relations: undefined, + take: 10, + skip: 0, + order: { createdAt: 'DESC' }, + }); + }); + }); + }); }); diff --git a/backend/src/modules/store/types/list-store.type.ts b/backend/src/modules/store/types/list-store.type.ts index 64acd1c..a9520ec 100644 --- a/backend/src/modules/store/types/list-store.type.ts +++ b/backend/src/modules/store/types/list-store.type.ts @@ -8,6 +8,10 @@ interface StoreFilterOptions extends FilterOptions { localGovernmentId?: string; phaseId?: string; districtId?: string; + minLat?: `${number}`; + maxLat?: `${number}`; + minLng?: `${number}`; + maxLng?: `${number}`; } interface StoreQueryOptions extends StoreFilterOptions, PaginationOptions {}