Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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 @@ -40,3 +40,21 @@ Health endpoint:
```bash
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
49 changes: 49 additions & 0 deletions backend/src/models/food-item.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { InferSchemaType, Schema, Types, model } from "mongoose"

const foodItemSchema = new Schema(
{
restaurant_id: {
type: Types.ObjectId,
ref: "Restaurant",
required: true,
index: true,
},
owner_user_id: {
type: Types.ObjectId,
required: true,
index: true,
},
name: {
type: String,
required: true,
trim: true,
},
description: {
type: String,
required: true,
trim: true,
},
price: {
type: Number,
required: true,
min: 0,
},
image_url: {
type: String,
required: true,
trim: true,
},
is_active: {
type: Boolean,
default: true,
index: true,
},
},
{
timestamps: true,
versionKey: false,
},
)

export type FoodItemDocument = InferSchemaType<typeof foodItemSchema>
export const FoodItemModel = model<FoodItemDocument>("FoodItem", foodItemSchema)
2 changes: 2 additions & 0 deletions backend/src/models/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { RestaurantModel, type RestaurantDocument } from "./restaurant.model.js"
export { FoodItemModel, type FoodItemDocument } from "./food-item.model.js"
57 changes: 57 additions & 0 deletions backend/src/models/restaurant.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { InferSchemaType, Schema, Types, model } from "mongoose"

const geoPointSchema = new Schema(
{
type: {
type: String,
enum: ["Point"],
default: "Point",
required: true,
},
coordinates: {
type: [Number],
required: true,
validate: {
validator: (value: number[]) => value.length === 2,
message: "coordinates must be [longitude, latitude]",
},
},
},
{ _id: false },
)

const restaurantSchema = new Schema(
{
owner_user_id: {
type: Types.ObjectId,
required: true,
index: true,
},
name: {
type: String,
required: true,
trim: true,
},
location: {
type: geoPointSchema,
required: true,
},
stellar_wallet: {
type: String,
default: null,
},
is_active: {
type: Boolean,
default: true,
},
},
{
timestamps: true,
versionKey: false,
},
)

restaurantSchema.index({ location: "2dsphere" })

export type RestaurantDocument = InferSchemaType<typeof restaurantSchema>
export const RestaurantModel = model<RestaurantDocument>("Restaurant", restaurantSchema)
130 changes: 130 additions & 0 deletions backend/src/routes/foods.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { Router } from "express"
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) {
return 0
}
return parsed
} catch {
return 0
}
}

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)
const skip = decodeCursor(query.cursor)

const pipeline = [
{
$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,
},
},
{ $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,6 +1,8 @@
import { Router } from "express"
import { foodsRouter } from "./foods.routes.js"
import { healthRouter } from "./health.routes.js"

export const apiRouter = Router()

apiRouter.use("/health", healthRouter)
apiRouter.use("/foods", foodsRouter)
67 changes: 67 additions & 0 deletions backend/test/foods-discover.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
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

;(RestaurantModel.aggregate as unknown as (...args: unknown[]) => Promise<unknown>) = async () => [
{
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)

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
})
Loading