Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
21 changes: 21 additions & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
26 changes: 16 additions & 10 deletions backend/src/routes/foods.routes.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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) })
: []

Comment on lines +68 to +71
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.

user_id must be a valid Mongo ObjectId before querying UserSwipe, please fix the discovery swipe-filter implementation for correctness and safety with emphasis on

  • validation and sanitization of user_id query input
  • correctness of $nin filtering against swiped food_id values
  • index usage and query scalability for UserSwipe lookups
  • behavior when user_id is malformed or absent.

const pipeline: PipelineStage[] = [
{
$geoNear: {
Expand All @@ -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",
Expand Down
88 changes: 88 additions & 0 deletions backend/test/foods-discover-swipe-filter.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>[] = []

;(UserSwipeModel.distinct as unknown as (...args: unknown[]) => Promise<unknown>) = async () => [
"660000000000000000000500",
]
;(RestaurantModel.aggregate as unknown as (...args: unknown[]) => Promise<unknown>) = async (
pipeline: Record<string, unknown>[],
) => {
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<string, unknown> | 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<string, unknown>[] = []

;(UserSwipeModel.distinct as unknown as (...args: unknown[]) => Promise<unknown>) = async () => {
distinctCalls += 1
return []
}
;(RestaurantModel.aggregate as unknown as (...args: unknown[]) => Promise<unknown>) = async (
pipeline: Record<string, unknown>[],
) => {
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<string, unknown> | 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")
})