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
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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<string, unknown> = { restaurantId };
if (!includeUnavailable) filter['isAvailable'] = true;

const result = await paginate(FoodItemModel, filter, { page, limit });
res.status(200).json(result);
} catch (error) {
next(error);
}
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/modules/food-items/tests/food-item.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

Expand Down
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -19,9 +23,26 @@ export const restaurantController = {
async list(req: Request, res: Response, next: NextFunction): Promise<void> {
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<string, unknown> = { 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);
}
Expand Down
30 changes: 30 additions & 0 deletions apps/api/src/modules/restaurants/repositories/restaurant.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
);
Expand All @@ -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<string, unknown> | null;
if (!update) return;
const coords = (update.$set as Record<string, unknown> | undefined)?.['address.coordinates'] ??
(update as Record<string, unknown>)['address.coordinates'] ??
(update.$set as Record<string, { coordinates?: { lat: number; lng: number } }> | 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<typeof restaurantSchema>;
export type RestaurantDocument = HydratedDocument<RestaurantAttributes>;

Expand Down
6 changes: 3 additions & 3 deletions apps/api/src/modules/restaurants/tests/restaurant.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand Down
26 changes: 26 additions & 0 deletions apps/api/src/shared/query/geo-filter.ts
Original file line number Diff line number Diff line change
@@ -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<T>(filter: FilterQuery<T>, params: GeoParams): FilterQuery<T> {
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,
},
},
};
}
45 changes: 45 additions & 0 deletions apps/api/src/shared/query/paginate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { FilterQuery, Model, SortOrder } from 'mongoose';
import type { PaginatedResponse, PaginationParams } from './query.types.js';

interface PaginateOptions {
sort?: Record<string, SortOrder>;
scoreSort?: boolean;
}

export async function paginate<T>(
model: Model<T>,
filter: FilterQuery<T>,
params: PaginationParams,
options: PaginateOptions = {},
): Promise<PaginatedResponse<T>> {
const { page, limit } = params;
const skip = (page - 1) * limit;

let sort: Record<string, SortOrder | { $meta: string }> = 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<string, SortOrder>)
.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,
},
};
}
18 changes: 18 additions & 0 deletions apps/api/src/shared/query/query.types.ts
Original file line number Diff line number Diff line change
@@ -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<T> {
data: T[];
pagination: PaginationMeta;
}
11 changes: 11 additions & 0 deletions apps/api/src/shared/query/text-search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { FilterQuery } from 'mongoose';

export function applyTextSearch<T>(filter: FilterQuery<T>, query?: string): { filter: FilterQuery<T>; scoreSort: boolean } {
if (!query || !query.trim()) {
return { filter, scoreSort: false };
}
return {
filter: { ...filter, $text: { $search: query.trim() } },
scoreSort: true,
};
}
1 change: 1 addition & 0 deletions packages/shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
13 changes: 13 additions & 0 deletions packages/shared/src/types/pagination.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export interface PaginationMeta {
page: number;
limit: number;
total: number;
totalPages: number;
hasNextPage: boolean;
hasPrevPage: boolean;
}

export interface PaginatedResponse<T> {
data: T[];
pagination: PaginationMeta;
}
Loading