From 19774917242cacd8ba5170d0b4c7dc92fae4cbaf Mon Sep 17 00:00:00 2001 From: S-Mubarak Date: Thu, 5 Mar 2026 13:21:28 +0100 Subject: [PATCH 1/3] feat(data): add restaurant and food item models for geospatial discovery --- backend/src/models/food-item.model.ts | 49 ++++++++++++++++++++++ backend/src/models/index.ts | 2 + backend/src/models/restaurant.model.ts | 57 ++++++++++++++++++++++++++ 3 files changed, 108 insertions(+) create mode 100644 backend/src/models/food-item.model.ts create mode 100644 backend/src/models/index.ts create mode 100644 backend/src/models/restaurant.model.ts diff --git a/backend/src/models/food-item.model.ts b/backend/src/models/food-item.model.ts new file mode 100644 index 0000000..e9928cc --- /dev/null +++ b/backend/src/models/food-item.model.ts @@ -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 +export const FoodItemModel = model("FoodItem", foodItemSchema) diff --git a/backend/src/models/index.ts b/backend/src/models/index.ts new file mode 100644 index 0000000..aa7de55 --- /dev/null +++ b/backend/src/models/index.ts @@ -0,0 +1,2 @@ +export { RestaurantModel, type RestaurantDocument } from "./restaurant.model.js" +export { FoodItemModel, type FoodItemDocument } from "./food-item.model.js" diff --git a/backend/src/models/restaurant.model.ts b/backend/src/models/restaurant.model.ts new file mode 100644 index 0000000..aa97bb4 --- /dev/null +++ b/backend/src/models/restaurant.model.ts @@ -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 +export const RestaurantModel = model("Restaurant", restaurantSchema) From 12557fc7feb53c7b62080ab9b11622aac0d0aa0f Mon Sep 17 00:00:00 2001 From: S-Mubarak Date: Fri, 6 Mar 2026 12:47:05 +0100 Subject: [PATCH 2/3] feat(api): add geospatial foods discovery endpoint with cursor pagination --- backend/src/routes/foods.routes.ts | 130 +++++++++++++++++++++++++++++ backend/src/routes/index.ts | 2 + 2 files changed, 132 insertions(+) create mode 100644 backend/src/routes/foods.routes.ts diff --git a/backend/src/routes/foods.routes.ts b/backend/src/routes/foods.routes.ts new file mode 100644 index 0000000..1a092e7 --- /dev/null +++ b/backend/src/routes/foods.routes.ts @@ -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(), +}) + +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) + } +}) diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts index af78e39..3ec4a9d 100644 --- a/backend/src/routes/index.ts +++ b/backend/src/routes/index.ts @@ -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) From f25830ce4537f421b17204b9b0e4fb2ac8c03330 Mon Sep 17 00:00:00 2001 From: S-Mubarak Date: Sat, 7 Mar 2026 11:28:36 +0100 Subject: [PATCH 3/3] test(api): cover foods discovery query validation and cursor pagination --- backend/README.md | 18 ++++++++ backend/test/foods-discover.test.ts | 67 +++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 backend/test/foods-discover.test.ts diff --git a/backend/README.md b/backend/README.md index f7aa4f4..54fdf10 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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 diff --git a/backend/test/foods-discover.test.ts b/backend/test/foods-discover.test.ts new file mode 100644 index 0000000..dba5ade --- /dev/null +++ b/backend/test/foods-discover.test.ts @@ -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) = 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) = 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 +})