diff --git a/backend/src/core/api/v1/campaign/campaign.resolver.js b/backend/src/core/api/v1/campaign/campaign.resolver.js index 67f796ff..684fc817 100644 --- a/backend/src/core/api/v1/campaign/campaign.resolver.js +++ b/backend/src/core/api/v1/campaign/campaign.resolver.js @@ -1,7 +1,7 @@ import { Module } from 'packages/handler/Module'; import { SearchCampaignInterceptor, CreateCampaignInterceptor } from 'core/modules/campaign/interceptor'; import { CampaignController } from './campaign.controller'; -import { orgCampaignId, campaignId, RecordId, NameQuery, LongitudeQuery, LatitudeQuery, userId, WardQuery, DistrictQuery, CityQuery, CampaignSortQuery } from '../../../common/swagger'; +import { orgCampaignId, campaignId, RecordId, NameQuery, LongitudeQuery, LatitudeQuery, userId, WardQuery, DistrictQuery, CityQuery, CampaignOrderQuery, CurrentLatitudeQuery, CurrentLongitudeQuery } from '../../../common/swagger'; import { RecordIdInterceptor } from '../../../modules/interceptor/recordId/record-id.interceptor'; import { FeedbackInterceptor } from '../../../modules/feedback'; import { MediaInterceptor } from 'core/modules/document'; @@ -62,7 +62,7 @@ export const CampaignResolver = Module.builder() { route: '/campaigns', method: 'get', - params: [NameQuery, LongitudeQuery, LatitudeQuery, WardQuery, DistrictQuery, CityQuery, CampaignSortQuery], + params: [NameQuery, LatitudeQuery, LongitudeQuery, WardQuery, DistrictQuery, CityQuery, CampaignOrderQuery, CurrentLatitudeQuery, CurrentLongitudeQuery], interceptors: [SearchCampaignInterceptor], controller: CampaignController.searchByQuery, }, diff --git a/backend/src/core/common/orders/and-then-order.js b/backend/src/core/common/orders/and-then-order.js new file mode 100644 index 00000000..7091fd37 --- /dev/null +++ b/backend/src/core/common/orders/and-then-order.js @@ -0,0 +1,17 @@ +import { Order } from './' + +export class ThenByOrder extends Order { + #firstOrder; + #secondOrder; + + constructor(firstOrder, secondOrder) { + super(); + this.#firstOrder = firstOrder; + this.#secondOrder = secondOrder; + } + + toSql() { + console.log(123,this.#firstOrder.toSql(), " ",this.#secondOrder.toSql()); + return `${this.#firstOrder.toSql()}, ${this.#secondOrder.toSql()}`; + } +} diff --git a/backend/src/core/common/orders/index.js b/backend/src/core/common/orders/index.js new file mode 100644 index 00000000..4e6b6712 --- /dev/null +++ b/backend/src/core/common/orders/index.js @@ -0,0 +1,2 @@ +export * from './order'; +export * from './and-then-order'; diff --git a/backend/src/core/common/orders/order.js b/backend/src/core/common/orders/order.js new file mode 100644 index 00000000..9ca1d6b7 --- /dev/null +++ b/backend/src/core/common/orders/order.js @@ -0,0 +1,22 @@ +import { ThenByOrder } from './'; + +export class Order { + constructor(direction = null) { + if (direction) { + this.validateDirection(direction); + } + this.direction = direction; + } + + validateDirection(direction) { + var direction = direction.toUpperCase(); + + if (direction !== 'ASC' && direction !== 'DESC') { + throw new Error('Invalid direction'); + } + } + + thenBy(order) { + return new ThenByOrder(this, order); + } +} diff --git a/backend/src/core/common/swagger/campaign-sort-query.js b/backend/src/core/common/swagger/campaign-sort-query.js index 1d338f08..fb61fbb7 100644 --- a/backend/src/core/common/swagger/campaign-sort-query.js +++ b/backend/src/core/common/swagger/campaign-sort-query.js @@ -1,9 +1,10 @@ +import { OrderFactory } from 'core/modules/campaign/orders'; import { SwaggerDocument } from '../../../packages/swagger'; -export const CampaignSortQuery = SwaggerDocument.ApiParams({ - name: 'isSortedByStatus', +export const CampaignOrderQuery = SwaggerDocument.ApiParams({ + name: 'orderBy', paramsIn: 'query', - type: 'bool', + type: 'string', required: false, - description: 'Sort campaigns by status as follows: UPCOMING, ENDED, IN PROGRESS', + description: `Syntax follows the pattern 'value + 'asc' or 'desc' (+ ', ') (e.g., 'value1 asc, value2 desc'). Allowed values include: ${OrderFactory.getAllOrderQueryValues()}.`, }); diff --git a/backend/src/core/common/swagger/current-latitude-query.js b/backend/src/core/common/swagger/current-latitude-query.js new file mode 100644 index 00000000..10dafa3e --- /dev/null +++ b/backend/src/core/common/swagger/current-latitude-query.js @@ -0,0 +1,11 @@ +import { SwaggerDocument } from '../../../packages/swagger'; + +export const CurrentLatitudeQuery = SwaggerDocument.ApiParams( + { + name: 'currentLat', + paramsIn: 'query', + type: 'string', + required: false, + description: "User's current latitude", + } +); \ No newline at end of file diff --git a/backend/src/core/common/swagger/current-longitude-query.js b/backend/src/core/common/swagger/current-longitude-query.js new file mode 100644 index 00000000..9ac8ef02 --- /dev/null +++ b/backend/src/core/common/swagger/current-longitude-query.js @@ -0,0 +1,11 @@ +import { SwaggerDocument } from '../../../packages/swagger'; + +export const CurrentLongitudeQuery = SwaggerDocument.ApiParams( + { + name: 'currentLng', + paramsIn: 'query', + type: 'string', + required: false, + description: "User's current longitude", + } +); diff --git a/backend/src/core/common/swagger/index.js b/backend/src/core/common/swagger/index.js index 8ef0c909..1678814e 100644 --- a/backend/src/core/common/swagger/index.js +++ b/backend/src/core/common/swagger/index.js @@ -15,3 +15,5 @@ export * from './ward-query'; export * from './district-query'; export * from './city-query'; export * from './campaign-sort-query'; +export * from './current-latitude-query'; +export * from './current-longitude-query'; diff --git a/backend/src/core/modules/campaign/campaign.repository.js b/backend/src/core/modules/campaign/campaign.repository.js index 2f1f2761..63c74ee7 100644 --- a/backend/src/core/modules/campaign/campaign.repository.js +++ b/backend/src/core/modules/campaign/campaign.repository.js @@ -331,15 +331,15 @@ class Repository extends DataRepository { return super.removeNameConstraint(); } - findByQuery(specification, isSortedByStatus = false) { - var sql = specification.toSql(); - var query = sql[0]; - var params = sql[1]; + findByQuery(specification, order) { + var whereSql = specification.toSql(); + var whereQuery = whereSql[0]; + var whereParams = whereSql[1]; var query = this.query() .join('organizations', 'campaigns.organization_id', '=', 'organizations.id') .whereNull('campaigns.deleted_at') - .whereRaw(query, params) + .whereRaw(whereQuery, whereParams) .select([ 'campaigns.id', 'campaigns.name', @@ -360,14 +360,8 @@ class Repository extends DataRepository { )`)} ]); - if (isSortedByStatus === 'true') { - query = query.orderByRaw(` - CASE - WHEN campaigns.start_date > NOW() AND campaigns.end_date > NOW() THEN 1 -- UPCOMING - WHEN campaigns.start_date < NOW() AND campaigns.end_date < NOW() THEN 2 -- PASSED - ELSE 2 -- IN PROGRESS - END - `); + if (order) { + query = query.orderByRaw(order.toSql()); } return query; diff --git a/backend/src/core/modules/campaign/interceptor/campaign-sort-interceptor.js b/backend/src/core/modules/campaign/interceptor/campaign-sort-interceptor.js deleted file mode 100644 index bba7cfb6..00000000 --- a/backend/src/core/modules/campaign/interceptor/campaign-sort-interceptor.js +++ /dev/null @@ -1,7 +0,0 @@ -import Joi from 'joi'; -import { DefaultValidatorInterceptor } from 'core/infrastructure/interceptor'; - -export const CampaignSortInterceptor = new DefaultValidatorInterceptor( - Joi.object({ - }), -); diff --git a/backend/src/core/modules/campaign/interceptor/coordinate-campaign.interceptor.js b/backend/src/core/modules/campaign/interceptor/coordinate-campaign.interceptor.js index 533ea95d..15d5734c 100644 --- a/backend/src/core/modules/campaign/interceptor/coordinate-campaign.interceptor.js +++ b/backend/src/core/modules/campaign/interceptor/coordinate-campaign.interceptor.js @@ -1,5 +1,6 @@ import Joi from 'joi'; import { DefaultValidatorInterceptor } from 'core/infrastructure/interceptor'; +import { JoiUtils } from '../../../utils'; export const SearchCampaignInterceptor = new DefaultValidatorInterceptor( Joi.object({ @@ -9,6 +10,8 @@ export const SearchCampaignInterceptor = new DefaultValidatorInterceptor( ward: Joi.string().optional(), district: Joi.string().optional(), city: Joi.string().optional(), - isSortedByStatus: Joi.boolean().optional(), + orderBy: JoiUtils.order().optional(), + currentLat: Joi.number().optional(), + currentLng: Joi.number().optional(), }), ); \ No newline at end of file diff --git a/backend/src/core/modules/campaign/orders/campaign-by-proximity-order.js b/backend/src/core/modules/campaign/orders/campaign-by-proximity-order.js new file mode 100644 index 00000000..7034d34e --- /dev/null +++ b/backend/src/core/modules/campaign/orders/campaign-by-proximity-order.js @@ -0,0 +1,19 @@ +import { Order } from 'core/common/orders'; + +export class CampaignByProximityOrder extends Order { + #lat; + #lng; + + constructor(lat, lng, direction = 'ASC') { + super(direction); + if (!lat || !lng) throw new Error('Latitude and longitude are required'); + this.#lat = parseFloat(lat); + this.#lng = parseFloat(lng); + } + + toSql() { + return `ST_Distance( + ST_MakePoint(cast(coordinate->>'lng' as float), cast(coordinate->>'lat' as float))::geography, + ST_MakePoint(${this.#lng}, ${this.#lat})::geography) ${this.direction}`; + } +} diff --git a/backend/src/core/modules/campaign/orders/campaign-status-order.js b/backend/src/core/modules/campaign/orders/campaign-status-order.js new file mode 100644 index 00000000..b005018b --- /dev/null +++ b/backend/src/core/modules/campaign/orders/campaign-status-order.js @@ -0,0 +1,24 @@ +import { Order } from 'core/common/orders'; + +export class CampaignStatusOrder extends Order { + #upComing; + #onGoing; + #passed; + + constructor(upComing, onGoing, passed, direction = 'ASC') { + super(direction); + this.#upComing = Number(upComing); + this.#onGoing = Number(onGoing); + this.#passed = Number(passed); + } + + toSql() { + return ` + CASE + WHEN campaigns.start_date > NOW() AND campaigns.end_date > NOW() THEN ${this.#upComing} -- UPCOMING + WHEN campaigns.start_date < NOW() AND campaigns.end_date < NOW() THEN ${this.#passed} -- PASSED + ELSE ${this.#onGoing} -- ONGOING + END ${this.direction} + `; + } +} diff --git a/backend/src/core/modules/campaign/orders/index.js b/backend/src/core/modules/campaign/orders/index.js new file mode 100644 index 00000000..8f6fcdef --- /dev/null +++ b/backend/src/core/modules/campaign/orders/index.js @@ -0,0 +1,3 @@ +export * from './campaign-by-proximity-order'; +export * from './campaign-status-order'; +export * from './order-factory'; \ No newline at end of file diff --git a/backend/src/core/modules/campaign/orders/order-factory.js b/backend/src/core/modules/campaign/orders/order-factory.js new file mode 100644 index 00000000..7af39314 --- /dev/null +++ b/backend/src/core/modules/campaign/orders/order-factory.js @@ -0,0 +1,28 @@ +import { CampaignByProximityOrder, CampaignStatusOrder } from '.' + +export class OrderFactory { + static OrderTypeEnum = { + PROXIMITY: 'proximity', + PASSED_ONGOING_UPCOMING: 'passed_ongoing_upcoming', + UPCOMING_PASSED_ONGOING: 'upcoming_passed_ongoing', + PASSED_UPCOMING_ONGOING: 'passed_upcoming_ongoing', + }; + static getOrder(param, lat, lng, direction) { + switch(param) { + case OrderTypeEnum.PROXIMITY: + return new CampaignByProximityOrder(lat, lng, direction); + case OrderTypeEnum.PASSED_ONGOING_UPCOMING: + return new CampaignStatusOrder(3, 2, 1, direction); + case OrderTypeEnum.UPCOMING_PASSED_ONGOING: + return new CampaignStatusOrder(1, 3, 2, direction); + case OrderTypeEnum.PASSED_UPCOMING_ONGOING: + return new CampaignStatusOrder(2, 3, 1, direction); + default: + throw new Error('Order not found'); + } + } + + static getAllOrderQueryValues() { + return Object.values(this.OrderTypeEnum).join(", "); + } +} diff --git a/backend/src/core/modules/campaign/services/campaign.service.js b/backend/src/core/modules/campaign/services/campaign.service.js index 16e9b54e..1f6b1cb2 100644 --- a/backend/src/core/modules/campaign/services/campaign.service.js +++ b/backend/src/core/modules/campaign/services/campaign.service.js @@ -8,8 +8,9 @@ import { UserCampaignRepository } from '../../user_campaign/user_campaign.reposi import { Status } from '../../../common/enum'; import { UserRepository } from '../../../modules/user/user.repository'; import { FileSystemService, MediaService } from 'core/modules/document'; -import { CampaignNamePartialMatchSpecification, CampaignWardSpecification, CampaignDistrictSpecification, CampaignCitySpecification, CampaignCoordinateSpecification } from '../specifications'; +import { CampaignNamePartialMatchSpecification, CampaignWardSpecification, CampaignDistrictSpecification, CampaignCitySpecification, CampaignCoordinateSpecification, CampaignLocationProximitySpecification } from '../specifications'; import { TrueSpecification } from 'core/common/specifications'; +import { OrderFactory } from '../orders'; class Service { constructor() { @@ -425,13 +426,40 @@ class Service { specification = specification.and(campaignCoordinateSpecification); } + var order = this.buildCampaignOrder(query.currentLat, query.currentLng, query.orderBy); + try { - return this.repository.findByQuery(specification, query.isSortedByStatus); + return this.repository.findByQuery(specification, order); } catch (error) { logger.error(error.message); throw new InternalServerException(); } } + + buildCampaignOrder(lat, lng, orderBy) { + if (!orderBy) { + return null; + } + + var orderStrategy = null; + + orderBy.split(', ').forEach(value => { + console.log(value) + var array = value.split(' '); + var param = array[0]; + var direction = array[1]; + + var order = OrderFactory.getOrder(param, lat, lng, direction); + + if (orderStrategy) { + orderStrategy = orderStrategy.thenBy(order); + } else { + orderStrategy = order; + } + }); + return orderStrategy; + } + }; export const CampaignService = new Service(); diff --git a/backend/src/core/modules/campaign/specifications/campaign-location-proximity-specification.js b/backend/src/core/modules/campaign/specifications/campaign-location-proximity-specification.js new file mode 100644 index 00000000..a3d10f6d --- /dev/null +++ b/backend/src/core/modules/campaign/specifications/campaign-location-proximity-specification.js @@ -0,0 +1,20 @@ +import { Specification } from 'core/common/specifications'; + +export class CampaignLocationProximitySpecification extends Specification { + #lat; + #lng; + #radius = 5000; + + constructor(lat, lng) { + super(); + this.#lat = lat; + this.#lng = lng; + } + + toSql() { + return [`ST_DWithin( + ST_MakePoint(cast(coordinate->>'lng' as float), cast(coordinate->>'lat' as float))::geography, + ST_MakePoint(:lng, :lat)::geography,:radius)`, + { lng: this.#lng, lat: this.#lat, radius: this.#radius }]; + } +} diff --git a/backend/src/core/modules/campaign/specifications/index.js b/backend/src/core/modules/campaign/specifications/index.js index f35c211e..7e8c4450 100644 --- a/backend/src/core/modules/campaign/specifications/index.js +++ b/backend/src/core/modules/campaign/specifications/index.js @@ -3,3 +3,4 @@ export * from './campaign-ward-specification'; export * from './campaign-district-specification'; export * from './campaign-city-specification'; export * from './campaign-coordinate-specification'; +export * from './campaign-location-proximity-specification' diff --git a/backend/src/core/utils/joi.util.js b/backend/src/core/utils/joi.util.js index 7965b329..5122f520 100644 --- a/backend/src/core/utils/joi.util.js +++ b/backend/src/core/utils/joi.util.js @@ -9,6 +9,9 @@ const PWD_FORMAT = /^[a-zA-Z0-9\d@$!%*?&]{8,30}$/; // Vietnam phone number validation const PHONE_NUMBER_FORMAT = /(((\+|)84)|0)(3|5|7|8|9)+([0-9]{8})\b/; + +const ORDER_FORMAT = /^(\w+\s(ASC|DESC|asc|desc))(,\s\w+\s(ASC|DESC|asc|desc))*$/; + export class JoiUtils { static objectId() { return Joi.string().regex(MONGOOSE_ID_OBJECT_FORMAT); @@ -70,4 +73,8 @@ export class JoiUtils { static phoneNumber() { return Joi.string().regex(PHONE_NUMBER_FORMAT); } + + static order() { + return Joi.string().regex(ORDER_FORMAT); + } }