diff --git a/backend/README.md b/backend/README.md index c4cdd75..c999365 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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` diff --git a/backend/src/routes/foods.routes.ts b/backend/src/routes/foods.routes.ts new file mode 100644 index 0000000..9082aed --- /dev/null +++ b/backend/src/routes/foods.routes.ts @@ -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(), +}) + +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) + } +}) diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts index 22ab552..31ca136 100644 --- a/backend/src/routes/index.ts +++ b/backend/src/routes/index.ts @@ -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" @@ -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) diff --git a/backend/test/foods-discover.test.ts b/backend/test/foods-discover.test.ts new file mode 100644 index 0000000..9ec9bad --- /dev/null +++ b/backend/test/foods-discover.test.ts @@ -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[] = [] + + ;(RestaurantModel.aggregate as unknown as (...args: unknown[]) => Promise) = async ( + pipeline: Record[], + ) => { + 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 | 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) = 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") +})