From ec16b51f294c52946d538a20694ec64519c58744 Mon Sep 17 00:00:00 2001 From: nurudeenmuzainat Date: Thu, 5 Mar 2026 15:44:19 +0100 Subject: [PATCH 1/3] feat(api): add swipe-aware foods discovery and user swipe model --- backend/src/models/food-item.model.ts | 49 ++++++++++ backend/src/models/index.ts | 3 + backend/src/models/restaurant.model.ts | 57 +++++++++++ backend/src/models/user-swipe.model.ts | 35 +++++++ backend/src/routes/foods.routes.ts | 125 +++++++++++++++++++++++++ backend/src/routes/index.ts | 2 + 6 files changed, 271 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 create mode 100644 backend/src/models/user-swipe.model.ts create mode 100644 backend/src/routes/foods.routes.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..43c412a --- /dev/null +++ b/backend/src/models/index.ts @@ -0,0 +1,3 @@ +export { RestaurantModel, type RestaurantDocument } from "./restaurant.model.js" +export { FoodItemModel, type FoodItemDocument } from "./food-item.model.js" +export { UserSwipeModel, type UserSwipeDocument } from "./user-swipe.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) diff --git a/backend/src/models/user-swipe.model.ts b/backend/src/models/user-swipe.model.ts new file mode 100644 index 0000000..59938dd --- /dev/null +++ b/backend/src/models/user-swipe.model.ts @@ -0,0 +1,35 @@ +import { InferSchemaType, Schema, Types, model } from "mongoose" + +const userSwipeSchema = new Schema( + { + user_id: { + type: Types.ObjectId, + required: true, + index: true, + }, + food_id: { + type: Types.ObjectId, + required: true, + index: true, + }, + action: { + type: String, + enum: ["like", "pass"], + required: true, + }, + timestamp: { + type: Date, + default: Date.now, + required: true, + }, + }, + { + timestamps: true, + versionKey: false, + }, +) + +userSwipeSchema.index({ user_id: 1, food_id: 1 }) + +export type UserSwipeDocument = InferSchemaType +export const UserSwipeModel = model("UserSwipe", userSwipeSchema) diff --git a/backend/src/routes/foods.routes.ts b/backend/src/routes/foods.routes.ts new file mode 100644 index 0000000..30b9941 --- /dev/null +++ b/backend/src/routes/foods.routes.ts @@ -0,0 +1,125 @@ +import { Router } from "express" +import { z } from "zod" +import { RestaurantModel, UserSwipeModel } from "../models/index.js" + +const querySchema = z.object({ + longitude: z.coerce.number(), + latitude: z.coerce.number(), + cursor: z.string().optional(), + user_id: 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) + return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0 + } 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 swipedFoodIds = query.user_id + ? await UserSwipeModel.distinct("food_id", { user_id: query.user_id }) + : [] + + 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 } }, + ...(swipedFoodIds.length > 0 ? [{ $match: { "foods._id": { $nin: swipedFoodIds } } }] : []), + { + $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 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 32c365576d3e40b7b99eb6e20bf4d846f623ecc3 Mon Sep 17 00:00:00 2001 From: nurudeenmuzainat Date: Fri, 6 Mar 2026 16:12:07 +0100 Subject: [PATCH 2/3] test(api): cover discovery swipe exclusion behavior and docs --- backend/README.md | 22 ++++++ .../test/foods-discover-swipe-filter.test.ts | 76 +++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 backend/test/foods-discover-swipe-filter.test.ts diff --git a/backend/README.md b/backend/README.md index f7aa4f4..6f2aac8 100644 --- a/backend/README.md +++ b/backend/README.md @@ -40,3 +40,25 @@ Health endpoint: ```bash curl http://localhost:5000/api/health ``` + +## Discovery Swipe Filtering + +`GET /api/foods/discover` supports filtering out previously swiped food items. + +Query params: + +- `longitude` (required) +- `latitude` (required) +- `cursor` (optional) +- `user_id` (optional; when present, excludes swiped food for that user) + +`UserSwipe` model fields: + +- `user_id` +- `food_id` +- `action` (`like` | `pass`) +- `timestamp` + +Index: + +- compound index on `{ user_id: 1, food_id: 1 }` diff --git a/backend/test/foods-discover-swipe-filter.test.ts b/backend/test/foods-discover-swipe-filter.test.ts new file mode 100644 index 0000000..857af4d --- /dev/null +++ b/backend/test/foods-discover-swipe-filter.test.ts @@ -0,0 +1,76 @@ +import assert from "node:assert/strict" +import test from "node:test" +import request from "supertest" +import { RestaurantModel, UserSwipeModel } from "../src/models/index.js" +import { createApp } from "../src/app.js" + +test("GET /api/foods/discover excludes swiped food ids when user_id is provided", async () => { + const app = createApp() + + const originalDistinct = UserSwipeModel.distinct + const originalAggregate = RestaurantModel.aggregate + + let capturedPipeline: Record[] = [] + + ;(UserSwipeModel.distinct as unknown as (...args: unknown[]) => Promise) = async () => [ + "660000000000000000000500", + ] + ;(RestaurantModel.aggregate as unknown as (...args: unknown[]) => Promise) = async ( + pipeline: Record[], + ) => { + capturedPipeline = pipeline + return [] + } + + const response = await request(app).get( + "/api/foods/discover?longitude=-73.99&latitude=40.73&user_id=660000000000000000000001", + ) + + assert.equal(response.status, 200) + assert.ok( + capturedPipeline.some((stage) => { + const match = stage.$match as Record | undefined + const foods = match?.["foods._id"] as { $nin?: unknown[] } | undefined + return Array.isArray(foods?.$nin) + }), + ) + + UserSwipeModel.distinct = originalDistinct + RestaurantModel.aggregate = originalAggregate +}) + +test("GET /api/foods/discover does not apply swipe exclusion without user_id", async () => { + const app = createApp() + + const originalDistinct = UserSwipeModel.distinct + const originalAggregate = RestaurantModel.aggregate + + let distinctCalls = 0 + let capturedPipeline: Record[] = [] + + ;(UserSwipeModel.distinct as unknown as (...args: unknown[]) => Promise) = async () => { + distinctCalls += 1 + return [] + } + ;(RestaurantModel.aggregate as unknown as (...args: unknown[]) => Promise) = async ( + pipeline: Record[], + ) => { + capturedPipeline = pipeline + return [] + } + + const response = await request(app).get("/api/foods/discover?longitude=-73.99&latitude=40.73") + + assert.equal(response.status, 200) + assert.equal(distinctCalls, 0) + assert.equal( + capturedPipeline.some((stage) => { + const match = stage.$match as Record | undefined + return Boolean(match?.["foods._id"]) + }), + false, + ) + + UserSwipeModel.distinct = originalDistinct + RestaurantModel.aggregate = originalAggregate +}) From c9871d3089bd5e039d1fccb3a3668e594025e2d6 Mon Sep 17 00:00:00 2001 From: muzainat Date: Sat, 7 Mar 2026 16:03:07 +0100 Subject: [PATCH 3/3] enforced validaity --- backend/src/routes/foods.routes.ts | 9 ++++++--- backend/test/foods-discover-swipe-filter.test.ts | 12 ++++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/backend/src/routes/foods.routes.ts b/backend/src/routes/foods.routes.ts index aadae57..df334f2 100644 --- a/backend/src/routes/foods.routes.ts +++ b/backend/src/routes/foods.routes.ts @@ -1,13 +1,16 @@ import { Router } from "express" import { z } from "zod" -import type { PipelineStage } from "mongoose" +import { Types, type PipelineStage } from "mongoose" import { RestaurantModel, UserSwipeModel } from "../models/index.js" const querySchema = z.object({ longitude: z.coerce.number(), latitude: z.coerce.number(), cursor: z.string().optional(), - user_id: z.string().optional(), + user_id: z + .string() + .refine((value) => Types.ObjectId.isValid(value), "user_id must be a valid ObjectId") + .optional(), }) const PAGE_SIZE = 10 @@ -47,7 +50,7 @@ foodsRouter.get("/discover", async (req, res, next) => { const skip = decodeCursor(query.cursor) const swipedFoodIds = query.user_id - ? await UserSwipeModel.distinct("food_id", { user_id: query.user_id }) + ? await UserSwipeModel.distinct("food_id", { user_id: new Types.ObjectId(query.user_id) }) : [] const pipeline: PipelineStage[] = [ diff --git a/backend/test/foods-discover-swipe-filter.test.ts b/backend/test/foods-discover-swipe-filter.test.ts index 857af4d..b9f322d 100644 --- a/backend/test/foods-discover-swipe-filter.test.ts +++ b/backend/test/foods-discover-swipe-filter.test.ts @@ -74,3 +74,15 @@ test("GET /api/foods/discover does not apply swipe exclusion without user_id", a UserSwipeModel.distinct = originalDistinct RestaurantModel.aggregate = originalAggregate }) + +test("GET /api/foods/discover returns 400 when user_id is not a valid ObjectId", async () => { + const app = createApp() + + const response = await request(app).get( + "/api/foods/discover?longitude=-73.99&latitude=40.73&user_id=invalid-id", + ) + + assert.equal(response.status, 400) + assert.equal(response.body.error, "Bad Request") + assert.equal(response.body.message, "Invalid query parameters") +})