Skip to content

[API] Pagination, text search, and proximity filtering middleware #186

Description

@zakkiyyat

Overview

Implement a reusable pagination and filtering layer in `apps/api/src/shared/` that can be applied to any list endpoint. The restaurant list and food item list endpoints (issues #180 and #181) should adopt it immediately. This middleware pattern will also be used by the discovery feed (Sprint 2).


File Location

```
apps/api/src/shared/
query/
paginate.ts # Cursor/offset pagination helper
text-search.ts # MongoDB $text search helper
geo-filter.ts # $geoNear / $near proximity filter
query.types.ts # Shared query param types
```


Pagination

Support offset-based pagination via query params:

```
GET /api/restaurants?page=1&limit=20
```

Param Default Max
`page` 1
`limit` 20 100

Response envelope (wrap all paginated list endpoints in this shape):

```json
{
"data": [...],
"pagination": {
"page": 1,
"limit": 20,
"total": 143,
"totalPages": 8,
"hasNextPage": true,
"hasPrevPage": false
}
}
```

The `paginate(model, filter, options)` helper should return this envelope from a single call.


Text Search

```
GET /api/restaurants?q=sushi
```

  • Add a MongoDB text index on `restaurants.name` and `restaurants.description`
  • When `q` is present, use MongoDB `$text: { $search: q }` and sort by `{ score: { $meta: 'textScore' } }`
  • Text search and pagination must work together

Cuisine Tag Filter

```
GET /api/restaurants?cuisine=italian,pizza
```

  • Accept a comma-separated list of `CuisineTag` values
  • Filter using `{ cuisineTags: { $in: [...] } }`

Proximity Filter

```
GET /api/restaurants?lat=51.5074&lng=-0.1278&radius=5000
```

  • `radius` is in metres, default 10 000 m (10 km), max 50 000 m
  • Requires a 2dsphere index on `address.coordinates`
  • Use MongoDB `$geoNear` aggregation or `$near` query operator
  • When coordinates are not provided, skip this filter entirely (no error)

Acceptance Criteria

  • `paginate()` helper is generic — it accepts any Mongoose model and filter object
  • `page` and `limit` params are validated with Zod; invalid values default to safe values (not 500 errors)
  • Text index is created in a Mongoose `createIndexes()` call within the restaurant model file (not a migration script)
  • 2dsphere index is created on `address.coordinates` in the restaurant model
  • Proximity filter is silently skipped when `lat`/`lng` are absent — never throws when they are omitted
  • All three filters (text, cuisine, proximity) can be combined in a single request
  • `GET /api/restaurants` and `GET /api/restaurants/:id/food-items` both use the `paginate()` helper
  • The response envelope type is exported from `packages/shared/src/types/` for use on the web and mobile clients

Testing Requirements

  • Pagination returns correct `totalPages` and `hasNextPage` values
  • Requesting `page` beyond total returns empty `data` array (not 404)
  • Text search returns restaurants matching the query
  • Cuisine filter returns only matching restaurants
  • Proximity filter returns restaurants within the radius when coordinates are provided
  • All filters combined return the intersection correctly

Out of Scope

  • Cursor-based pagination (offset is sufficient for this phase)
  • Price range filtering on food items (Sprint 2 filter sheet)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

BACKENDBACKENDGrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardOfficial CampaignCampaign: Official Campaignintermediate~2–3 day effortsprint-1Sprint 1: Restaurant & Menu Foundation

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions