diff --git a/backend/README.md b/backend/README.md index c999365..8b3b9a3 100644 --- a/backend/README.md +++ b/backend/README.md @@ -87,6 +87,27 @@ Health endpoint: 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 }` ## Discovery Endpoint `GET /api/foods/discover` diff --git a/backend/src/routes/foods.routes.ts b/backend/src/routes/foods.routes.ts index 9082aed..ffc47c4 100644 --- a/backend/src/routes/foods.routes.ts +++ b/backend/src/routes/foods.routes.ts @@ -1,12 +1,16 @@ import { Router } from "express" -import type { PipelineStage } from "mongoose" +import { Types, type PipelineStage } from "mongoose" import { z } from "zod" -import { RestaurantModel } from "../models/index.js" +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() + .refine((value) => Types.ObjectId.isValid(value), "user_id must be a valid ObjectId") + .optional(), }) const PAGE_SIZE = 10 @@ -49,6 +53,7 @@ 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) @@ -60,6 +65,10 @@ foodsRouter.get("/discover", async (req, res, next) => { return } + const swipedFoodIds = query.user_id + ? await UserSwipeModel.distinct("food_id", { user_id: new Types.ObjectId(query.user_id) }) + : [] + const pipeline: PipelineStage[] = [ { $geoNear: { @@ -81,14 +90,11 @@ foodsRouter.get("/discover", async (req, res, next) => { as: "foods", }, }, - { - $unwind: "$foods", - }, - { - $match: { - "foods.is_active": true, - }, - }, + { $unwind: "$foods" }, + { $match: { "foods.is_active": true } }, + ...(swipedFoodIds.length > 0 + ? ([{ $match: { "foods._id": { $nin: swipedFoodIds } } }] as PipelineStage[]) + : []), { $project: { food_id: "$foods._id", 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..b9f322d --- /dev/null +++ b/backend/test/foods-discover-swipe-filter.test.ts @@ -0,0 +1,88 @@ +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 +}) + +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") +})