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
18 changes: 18 additions & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,24 @@ Health endpoint:
curl http://localhost:5000/api/health
```

## Discovery Endpoint

`GET /api/foods/discover`

Query params:

- `longitude` (number, required)
- `latitude` (number, required)
- `cursor` (string, optional)

Behavior:

- Uses MongoDB geospatial query with a 10km radius
- Sorts results by nearest restaurant first
- Returns up to 10 items per page
- Includes `distanceMeters` on each item
- Returns next `cursor` when additional items exist

## Swipe Endpoint

`POST /api/swipe`
Expand Down
141 changes: 141 additions & 0 deletions backend/src/routes/foods.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { Router } from "express"
import type { PipelineStage } from "mongoose"
import { z } from "zod"
import { RestaurantModel } from "../models/index.js"

const querySchema = z.object({
longitude: z.coerce.number(),
latitude: z.coerce.number(),
cursor: z.string().optional(),
})
Comment on lines +6 to +10
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add strict longitude/latitude range checks ([-180,180], [-90,90]) from query validation, focus on:

  • coordinate validation boundaries and malformed query handling
  • $geoNear pipeline correctness and distance sorting
  • cursor pagination behavior (stability and safety)
  • response contract consistency (distanceMeters, cursor nullability)


const PAGE_SIZE = 10
const DISCOVERY_RADIUS_METERS = 10_000

type DiscoveryRow = {
food_id: unknown
restaurant_id: unknown
food_name: string
description: string
price: number
image_url: string
restaurant_name: string
distance_meters: number
}

function decodeCursor(cursor?: string): number {
if (!cursor) {
return 0
}

try {
const raw = Buffer.from(cursor, "base64").toString("utf8")
const parsed = Number(raw)
if (!Number.isFinite(parsed) || parsed < 0) {
throw new Error("invalid-cursor")
}
return parsed
} catch {
throw new Error("invalid-cursor")
}
}

function encodeCursor(offset: number): string {
return Buffer.from(String(offset), "utf8").toString("base64")
}

export const foodsRouter = Router()

foodsRouter.get("/discover", async (req, res, next) => {
try {
const query = querySchema.parse(req.query)
let skip = 0
try {
skip = decodeCursor(query.cursor)
} catch {
res.status(400).json({
error: "Bad Request",
message: "Invalid cursor",
})
return
}

const pipeline: PipelineStage[] = [
{
$geoNear: {
near: {
type: "Point",
coordinates: [query.longitude, query.latitude],
},
distanceField: "distance_meters",
spherical: true,
maxDistance: DISCOVERY_RADIUS_METERS,
query: { is_active: true },
},
},
{
$lookup: {
from: "fooditems",
localField: "_id",
foreignField: "restaurant_id",
as: "foods",
},
},
{
$unwind: "$foods",
},
{
$match: {
"foods.is_active": true,
},
},
{
$project: {
food_id: "$foods._id",
restaurant_id: "$_id",
food_name: "$foods.name",
description: "$foods.description",
price: "$foods.price",
image_url: "$foods.image_url",
restaurant_name: "$name",
distance_meters: 1,
},
},
{ $sort: { distance_meters: 1, food_id: 1 } },
{ $skip: skip },
{ $limit: PAGE_SIZE + 1 },
]

const rows = (await RestaurantModel.aggregate(pipeline)) as DiscoveryRow[]
const hasMore = rows.length > PAGE_SIZE
const items = rows.slice(0, PAGE_SIZE).map((row) => ({
id: String(row.food_id),
restaurantId: String(row.restaurant_id),
name: row.food_name,
description: row.description,
price: row.price,
imageUrl: row.image_url,
restaurantName: row.restaurant_name,
distanceMeters: row.distance_meters,
}))

res.status(200).json({
items,
cursor: hasMore ? encodeCursor(skip + PAGE_SIZE) : null,
})
} catch (error) {
if (error instanceof z.ZodError) {
res.status(400).json({
error: "Bad Request",
message: "Invalid query parameters",
details: error.issues.map((issue) => ({
path: issue.path.join("."),
message: issue.message,
code: issue.code,
})),
})
return
}
next(error)
}
})
2 changes: 2 additions & 0 deletions backend/src/routes/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Router } from "express"
import { foodsRouter } from "./foods.routes.js"
import { authRouter } from "./auth.routes.js"
import { healthRouter } from "./health.routes.js"
import { uploadRouter } from "./upload.routes.js"
Expand All @@ -9,6 +10,7 @@ import { pingRouter } from "./ping.routes.js"
export const apiRouter = Router()

apiRouter.use("/health", healthRouter)
apiRouter.use("/foods", foodsRouter)
apiRouter.use("/upload", uploadRouter)
apiRouter.use("/swipe", swipeRouter)
apiRouter.use("/restaurant/foods", restaurantFoodsRouter)
Expand Down
91 changes: 91 additions & 0 deletions backend/test/foods-discover.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import assert from "node:assert/strict"
import test from "node:test"
import request from "supertest"
import { RestaurantModel } from "../src/models/index.js"
import { createApp } from "../src/app.js"

test("GET /api/foods/discover returns 400 when coordinates are missing", async () => {
const app = createApp()

const response = await request(app).get("/api/foods/discover")

assert.equal(response.status, 400)
assert.equal(response.body.error, "Bad Request")
})

test("GET /api/foods/discover returns paginated data with distance", async () => {
const app = createApp()
const originalAggregate = RestaurantModel.aggregate
let capturedPipeline: Record<string, unknown>[] = []

;(RestaurantModel.aggregate as unknown as (...args: unknown[]) => Promise<unknown>) = async (
pipeline: Record<string, unknown>[],
) => {
capturedPipeline = pipeline
return [
{
food_id: "660000000000000000000101",
restaurant_id: "660000000000000000000201",
food_name: "Spicy Bowl",
description: "House special",
price: 12.5,
image_url: "https://example.com/a.png",
restaurant_name: "Demo Restaurant",
distance_meters: 128.4,
},
]
}

const response = await request(app).get("/api/foods/discover?longitude=-73.99&latitude=40.73")

assert.equal(response.status, 200)
assert.equal(response.body.items.length, 1)
assert.equal(response.body.items[0].name, "Spicy Bowl")
assert.equal(response.body.items[0].distanceMeters, 128.4)
assert.equal(response.body.cursor, null)
assert.ok(
capturedPipeline.some((stage) => {
const sort = stage.$sort as Record<string, unknown> | undefined
return sort?.distance_meters === 1
}),
)

RestaurantModel.aggregate = originalAggregate
})

test("GET /api/foods/discover returns next cursor when more than one page", async () => {
const app = createApp()
const originalAggregate = RestaurantModel.aggregate

;(RestaurantModel.aggregate as unknown as (...args: unknown[]) => Promise<unknown>) = async () =>
Array.from({ length: 11 }, (_, index) => ({
food_id: `660000000000000000000${index + 100}`,
restaurant_id: "660000000000000000000200",
food_name: `Dish ${index + 1}`,
description: "desc",
price: 10 + index,
image_url: "https://example.com/x.png",
restaurant_name: "Demo Restaurant",
distance_meters: 25 + index,
}))

const response = await request(app).get("/api/foods/discover?longitude=-73.99&latitude=40.73")

assert.equal(response.status, 200)
assert.equal(response.body.items.length, 10)
assert.ok(typeof response.body.cursor === "string")

RestaurantModel.aggregate = originalAggregate
})

test("GET /api/foods/discover returns 400 for invalid cursor", async () => {
const app = createApp()

const response = await request(app).get(
"/api/foods/discover?longitude=-73.99&latitude=40.73&cursor=not-base64",
)

assert.equal(response.status, 400)
assert.equal(response.body.error, "Bad Request")
assert.equal(response.body.message, "Invalid cursor")
})