diff --git a/apps/api/src/modules/food-items/controllers/food-item.controller.ts b/apps/api/src/modules/food-items/controllers/food-item.controller.ts index 18af715..8225844 100644 --- a/apps/api/src/modules/food-items/controllers/food-item.controller.ts +++ b/apps/api/src/modules/food-items/controllers/food-item.controller.ts @@ -1,6 +1,8 @@ import type { NextFunction, Request, Response } from 'express'; import { AppError } from '../../../shared/errors/app-error.js'; +import { paginate } from '../../../shared/query/paginate.js'; import type { AuthenticatedRequest } from '../../../shared/types/express.js'; +import { FoodItemModel } from '../repositories/food-item.model.js'; import { foodItemService } from '../services/food-item.service.js'; import { createFoodItemSchema, updateFoodItemSchema } from '../validators/food-item.validators.js'; @@ -21,9 +23,13 @@ export const foodItemController = { const restaurantId = req.params['restaurantId']!; const includeUnavailable = req.query['includeUnavailable'] === 'true'; const page = Math.max(1, Number(req.query['page']) || 1); - const limit = Math.min(50, Math.max(1, Number(req.query['limit']) || 20)); - const { docs, total } = await foodItemService.list(restaurantId, includeUnavailable, page, limit); - res.status(200).json({ data: docs, meta: { page, limit, total } }); + const limit = Math.min(100, Math.max(1, Number(req.query['limit']) || 20)); + + const filter: Record = { restaurantId }; + if (!includeUnavailable) filter['isAvailable'] = true; + + const result = await paginate(FoodItemModel, filter, { page, limit }); + res.status(200).json(result); } catch (error) { next(error); } diff --git a/apps/api/src/modules/food-items/tests/food-item.test.ts b/apps/api/src/modules/food-items/tests/food-item.test.ts index 5bf9264..98dc6fa 100644 --- a/apps/api/src/modules/food-items/tests/food-item.test.ts +++ b/apps/api/src/modules/food-items/tests/food-item.test.ts @@ -146,7 +146,7 @@ describe('GET /api/restaurants/:restaurantId/food-items', () => { expect(res.status).toBe(200); expect(res.body.data).toHaveLength(1); - expect(res.body.meta.total).toBe(2); + expect(res.body.pagination.total).toBe(2); }); }); diff --git a/apps/api/src/modules/restaurants/controllers/restaurant.controller.ts b/apps/api/src/modules/restaurants/controllers/restaurant.controller.ts index fe4e2ea..79ff7eb 100644 --- a/apps/api/src/modules/restaurants/controllers/restaurant.controller.ts +++ b/apps/api/src/modules/restaurants/controllers/restaurant.controller.ts @@ -1,6 +1,10 @@ import type { NextFunction, Request, Response } from 'express'; import { AppError } from '../../../shared/errors/app-error.js'; +import { applyGeoFilter } from '../../../shared/query/geo-filter.js'; +import { paginate } from '../../../shared/query/paginate.js'; +import { applyTextSearch } from '../../../shared/query/text-search.js'; import type { AuthenticatedRequest } from '../../../shared/types/express.js'; +import { RestaurantModel } from '../repositories/restaurant.model.js'; import { restaurantService } from '../services/restaurant.service.js'; import { createRestaurantSchema, updateRestaurantSchema } from '../validators/restaurant.validators.js'; @@ -19,9 +23,26 @@ export const restaurantController = { async list(req: Request, res: Response, next: NextFunction): Promise { try { const page = Math.max(1, Number(req.query['page']) || 1); - const limit = Math.min(50, Math.max(1, Number(req.query['limit']) || 20)); - const { docs, total } = await restaurantService.list(page, limit); - res.status(200).json({ data: docs, meta: { page, limit, total } }); + const limit = Math.min(100, Math.max(1, Number(req.query['limit']) || 20)); + + let filter: Record = { isActive: true }; + + const cuisine = req.query['cuisine'] as string | undefined; + if (cuisine) { + const tags = cuisine.split(',').map((t) => t.trim()).filter(Boolean); + if (tags.length) filter['cuisineTags'] = { $in: tags }; + } + + const { filter: searchFilter, scoreSort } = applyTextSearch(filter, req.query['q'] as string | undefined); + filter = searchFilter; + + const lat = req.query['lat'] ? Number(req.query['lat']) : undefined; + const lng = req.query['lng'] ? Number(req.query['lng']) : undefined; + const radius = req.query['radius'] ? Number(req.query['radius']) : undefined; + filter = applyGeoFilter(filter, { lat, lng, radius }); + + const result = await paginate(RestaurantModel, filter, { page, limit }, { scoreSort }); + res.status(200).json(result); } catch (error) { next(error); } diff --git a/apps/api/src/modules/restaurants/repositories/restaurant.model.ts b/apps/api/src/modules/restaurants/repositories/restaurant.model.ts index 5f38247..a0c4aea 100644 --- a/apps/api/src/modules/restaurants/repositories/restaurant.model.ts +++ b/apps/api/src/modules/restaurants/repositories/restaurant.model.ts @@ -14,6 +14,10 @@ const addressSchema = new Schema( city: { type: String, required: true, trim: true }, country: { type: String, required: true, trim: true }, coordinates: { type: coordinatesSchema }, + location: { + type: { type: String, enum: ['Point'] }, + coordinates: { type: [Number] }, + }, }, { _id: false }, ); @@ -32,6 +36,32 @@ const restaurantSchema = new Schema( { timestamps: true }, ); +restaurantSchema.index({ name: 'text', description: 'text' }); +restaurantSchema.index({ 'address.location': '2dsphere' }); + +restaurantSchema.pre('save', function () { + if (this.address?.coordinates?.lat != null && this.address?.coordinates?.lng != null) { + this.address.location = { + type: 'Point', + coordinates: [this.address.coordinates.lng, this.address.coordinates.lat], + }; + } else if (this.address) { + this.address.location = undefined; + } +}); + +restaurantSchema.pre('findOneAndUpdate', function () { + const update = this.getUpdate() as Record | null; + if (!update) return; + const coords = (update.$set as Record | undefined)?.['address.coordinates'] ?? + (update as Record)['address.coordinates'] ?? + (update.$set as Record | undefined)?.address?.coordinates; + if (coords && typeof coords === 'object' && 'lat' in coords && 'lng' in coords) { + const c = coords as { lat: number; lng: number }; + this.set('address.location', { type: 'Point', coordinates: [c.lng, c.lat] }); + } +}); + export type RestaurantAttributes = InferSchemaType; export type RestaurantDocument = HydratedDocument; diff --git a/apps/api/src/modules/restaurants/tests/restaurant.test.ts b/apps/api/src/modules/restaurants/tests/restaurant.test.ts index 6a8f64e..68ba36d 100644 --- a/apps/api/src/modules/restaurants/tests/restaurant.test.ts +++ b/apps/api/src/modules/restaurants/tests/restaurant.test.ts @@ -99,7 +99,7 @@ describe('GET /api/restaurants', () => { expect(res.status).toBe(200); expect(res.body.data).toHaveLength(1); - expect(res.body.meta.total).toBe(1); + expect(res.body.pagination.total).toBe(1); }); it('excludes soft-deleted restaurants', async () => { @@ -127,8 +127,8 @@ describe('GET /api/restaurants', () => { expect(res.status).toBe(200); expect(res.body.data).toHaveLength(1); - expect(res.body.meta.total).toBe(2); - expect(res.body.meta.limit).toBe(1); + expect(res.body.pagination.total).toBe(2); + expect(res.body.pagination.limit).toBe(1); }); it('returns empty array when no restaurants match', async () => { diff --git a/apps/api/src/shared/query/geo-filter.ts b/apps/api/src/shared/query/geo-filter.ts new file mode 100644 index 0000000..3cd3956 --- /dev/null +++ b/apps/api/src/shared/query/geo-filter.ts @@ -0,0 +1,26 @@ +import type { FilterQuery } from 'mongoose'; + +const DEFAULT_RADIUS = 10_000; +const MAX_RADIUS = 50_000; + +interface GeoParams { + lat?: number; + lng?: number; + radius?: number; +} + +export function applyGeoFilter(filter: FilterQuery, params: GeoParams): FilterQuery { + if (params.lat == null || params.lng == null) return filter; + + const radius = Math.min(Math.max(params.radius ?? DEFAULT_RADIUS, 1), MAX_RADIUS); + + return { + ...filter, + 'address.location': { + $near: { + $geometry: { type: 'Point', coordinates: [params.lng, params.lat] }, + $maxDistance: radius, + }, + }, + }; +} diff --git a/apps/api/src/shared/query/paginate.ts b/apps/api/src/shared/query/paginate.ts new file mode 100644 index 0000000..bacee73 --- /dev/null +++ b/apps/api/src/shared/query/paginate.ts @@ -0,0 +1,45 @@ +import type { FilterQuery, Model, SortOrder } from 'mongoose'; +import type { PaginatedResponse, PaginationParams } from './query.types.js'; + +interface PaginateOptions { + sort?: Record; + scoreSort?: boolean; +} + +export async function paginate( + model: Model, + filter: FilterQuery, + params: PaginationParams, + options: PaginateOptions = {}, +): Promise> { + const { page, limit } = params; + const skip = (page - 1) * limit; + + let sort: Record = options.sort ?? { createdAt: -1 }; + if (options.scoreSort) { + sort = { score: { $meta: 'textScore' }, ...sort }; + } + + const [docs, total] = await Promise.all([ + model.find(filter, options.scoreSort ? { score: { $meta: 'textScore' } } : undefined) + .sort(sort as Record) + .skip(skip) + .limit(limit) + .lean(), + model.countDocuments(filter), + ]); + + const totalPages = Math.ceil(total / limit) || 1; + + return { + data: docs as T[], + pagination: { + page, + limit, + total, + totalPages, + hasNextPage: page < totalPages, + hasPrevPage: page > 1, + }, + }; +} diff --git a/apps/api/src/shared/query/query.types.ts b/apps/api/src/shared/query/query.types.ts new file mode 100644 index 0000000..df0b2d5 --- /dev/null +++ b/apps/api/src/shared/query/query.types.ts @@ -0,0 +1,18 @@ +export interface PaginationParams { + page: number; + limit: number; +} + +export interface PaginationMeta { + page: number; + limit: number; + total: number; + totalPages: number; + hasNextPage: boolean; + hasPrevPage: boolean; +} + +export interface PaginatedResponse { + data: T[]; + pagination: PaginationMeta; +} diff --git a/apps/api/src/shared/query/text-search.ts b/apps/api/src/shared/query/text-search.ts new file mode 100644 index 0000000..1d34ddb --- /dev/null +++ b/apps/api/src/shared/query/text-search.ts @@ -0,0 +1,11 @@ +import type { FilterQuery } from 'mongoose'; + +export function applyTextSearch(filter: FilterQuery, query?: string): { filter: FilterQuery; scoreSort: boolean } { + if (!query || !query.trim()) { + return { filter, scoreSort: false }; + } + return { + filter: { ...filter, $text: { $search: query.trim() } }, + scoreSort: true, + }; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index cf8f814..480819f 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2,3 +2,4 @@ export * from './types/auth.js'; export * from './validation/auth.js'; export * from './types/tags.js'; export * from './validation/tags.js'; +export * from './types/pagination.js'; diff --git a/packages/shared/src/types/pagination.ts b/packages/shared/src/types/pagination.ts new file mode 100644 index 0000000..65b28d2 --- /dev/null +++ b/packages/shared/src/types/pagination.ts @@ -0,0 +1,13 @@ +export interface PaginationMeta { + page: number; + limit: number; + total: number; + totalPages: number; + hasNextPage: boolean; + hasPrevPage: boolean; +} + +export interface PaginatedResponse { + data: T[]; + pagination: PaginationMeta; +}