Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions backend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
125 changes: 121 additions & 4 deletions backend/src/database/base/base.model-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -15,12 +17,18 @@ import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity

export abstract class AbstractModelAction<T extends ObjectLiteral> {
model: EntityTarget<T>;
protected readonly partialSearchFields: string[] = [];
protected readonly rangeFields: string[] = [];

constructor(
protected readonly repository: Repository<T>,
model: EntityTarget<T>,
partialSearchFields: readonly string[] = [],
rangeFields: readonly string[] = [],
) {
this.model = model;
this.partialSearchFields = [...partialSearchFields];
this.rangeFields = [...rangeFields];
}

async create(
Expand Down Expand Up @@ -115,27 +123,69 @@ export abstract class AbstractModelAction<T extends ObjectLiteral> {
sort?: 'ASC' | 'DESC';
} & Record<string, unknown>;

// Build where clause with partial search and range support
const whereClause: FindOptionsWhere<T> = {};
const processedRangeFields = new Set<string>();

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<string, unknown>)[baseField] = rangeValue;
processedRangeFields.add(baseField);
}
}
}
// Handle partial search fields
else if (
this.partialSearchFields.includes(key) &&
typeof value === 'string'
) {
(whereClause as Record<string, unknown>)[key] = ILike(`%${value}%`);
}
// Handle exact match fields
else if (!this.isRangeKey(key)) {
(whereClause as Record<string, unknown>)[key] = value;
}
}
});

const orderBy = {} as FindOptionsOrder<T>;
if (sort) {
Object.keys(filter).forEach((key) => {
(orderBy as unknown as Record<string, 'ASC' | 'DESC'>)[key] = sort;
if (
key !== 'sort' &&
!this.partialSearchFields.includes(key) &&
!this.isRangeKey(key)
) {
(orderBy as unknown as Record<string, 'ASC' | 'DESC'>)[key] = sort;
}
});

if (Object.keys(orderBy as object).length === 0) {
(orderBy as unknown as Record<string, 'ASC' | 'DESC'>).createdAt =
'DESC';
}
} else {
(orderBy as unknown as Record<string, 'ASC' | 'DESC'>).createdAt = 'DESC';
}

if (paginationPayload) {
const { limit, page } = paginationPayload;
const query = await this.repository.find({
where: filter as FindOptionsWhere<T>,
where: whereClause,
relations,
take: +limit,
skip: +limit * (+page - 1),
order: orderBy,
});

const total = await this.repository.count({
where: filter as FindOptionsWhere<T>,
where: whereClause,
});

return {
Expand All @@ -145,7 +195,7 @@ export abstract class AbstractModelAction<T extends ObjectLiteral> {
}

const query = await this.repository.find({
where: filter as FindOptionsWhere<T>,
where: whereClause,
relations,
order: orderBy,
});
Expand All @@ -154,4 +204,71 @@ export abstract class AbstractModelAction<T extends ObjectLiteral> {
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<string, unknown>,
): ReturnType<typeof Between> | 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;
}
}
2 changes: 2 additions & 0 deletions backend/src/modules/admin/admin.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -63,6 +64,7 @@ export class AdminController {
}

@Get('stores')
@SkipThrottle()
async getStores(@Query() queryOptions: StoreQueryValidator) {
return this.adminService.listStores(queryOptions);
}
Expand Down
25 changes: 24 additions & 1 deletion backend/src/modules/store/dto/store.dto.ts
Original file line number Diff line number Diff line change
@@ -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()
Expand Down Expand Up @@ -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}`;
}
2 changes: 2 additions & 0 deletions backend/src/modules/store/store.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -39,6 +40,7 @@ export class StoreController {
}

@Get()
@SkipThrottle()
@HttpCode(HttpStatus.OK)
async listStores(
@Req() req: Request & { user: { sub: string } },
Expand Down
10 changes: 9 additions & 1 deletion backend/src/modules/store/store.model-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Store> {
constructor(@InjectRepository(Store) repository: Repository<Store>) {
super(repository, Store);
super(
repository,
Store,
SEARCHABLE_STORE_COLUMNS,
FILTERABLE_STORE_COLUMNS,
);
}
}
Loading