Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
22 changes: 22 additions & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 }`
49 changes: 49 additions & 0 deletions backend/src/models/food-item.model.ts
Original file line number Diff line number Diff line change
@@ -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<typeof foodItemSchema>
export const FoodItemModel = model<FoodItemDocument>("FoodItem", foodItemSchema)
3 changes: 3 additions & 0 deletions backend/src/models/index.ts
Original file line number Diff line number Diff line change
@@ -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"
57 changes: 57 additions & 0 deletions backend/src/models/restaurant.model.ts
Original file line number Diff line number Diff line change
@@ -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<typeof restaurantSchema>
export const RestaurantModel = model<RestaurantDocument>("Restaurant", restaurantSchema)
35 changes: 35 additions & 0 deletions backend/src/models/user-swipe.model.ts
Original file line number Diff line number Diff line change
@@ -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<typeof userSwipeSchema>
export const UserSwipeModel = model<UserSwipeDocument>("UserSwipe", userSwipeSchema)
125 changes: 125 additions & 0 deletions backend/src/routes/foods.routes.ts
Original file line number Diff line number Diff line change
@@ -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 })
: []

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 = [
{
$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)
}
})
2 changes: 2 additions & 0 deletions backend/src/routes/index.ts
Original file line number Diff line number Diff line change
@@ -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)
76 changes: 76 additions & 0 deletions backend/test/foods-discover-swipe-filter.test.ts
Original file line number Diff line number Diff line change
@@ -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<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
})
Loading