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
24 changes: 24 additions & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,27 @@ Health endpoint:
```bash
curl http://localhost:5000/api/health
```

## Swipe Endpoint

`POST /api/swipe`

Payload:

```json
{
"foodId": "660000000000000000000100",
"action": "like"
}
```

Headers:

- `x-user-id`: current user id (temporary until full JWT auth integration)

Behavior:

- Always writes to `UserSwipe`
- `pass`: no cart mutation
- `like`: also creates an `active` `CartItem`
- Returns `404` if `foodId` does not exist
33 changes: 33 additions & 0 deletions backend/src/middleware/validate-request.middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { NextFunction, Request, RequestHandler, Response } from "express"
import { ZodError, type AnyZodObject } from "zod"

type RequestSchema = {
body?: AnyZodObject
query?: AnyZodObject
params?: AnyZodObject
}

export function validateRequest(schema: RequestSchema): RequestHandler {
return (req: Request, res: Response, next: NextFunction) => {
try {
if (schema.body) req.body = schema.body.parse(req.body)
if (schema.query) req.query = schema.query.parse(req.query)
if (schema.params) req.params = schema.params.parse(req.params)
next()
} catch (error) {
if (error instanceof ZodError) {
res.status(400).json({
error: "Bad Request",
message: "Validation failed",
details: error.issues.map((issue) => ({
path: issue.path.join("."),
message: issue.message,
code: issue.code,
})),
})
return
}
next(error)
}
}
}
34 changes: 34 additions & 0 deletions backend/src/models/cart-item.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { InferSchemaType, Schema, Types, model } from "mongoose"

const cartItemSchema = new Schema(
{
user_id: {
type: Types.ObjectId,
required: true,
index: true,
},
food_id: {
type: Types.ObjectId,
required: true,
index: true,
},
quantity: {
type: Number,
default: 1,
min: 1,
},
status: {
type: String,
enum: ["active", "removed", "ordered"],
default: "active",
index: true,
},
},
{
timestamps: true,
versionKey: false,
},
)

export type CartItemDocument = InferSchemaType<typeof cartItemSchema>
export const CartItemModel = model<CartItemDocument>("CartItem", cartItemSchema)
48 changes: 48 additions & 0 deletions backend/src/models/food-item.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { InferSchemaType, Schema, Types, model } from "mongoose"

const foodItemSchema = new Schema(
{
restaurant_id: {
type: Types.ObjectId,
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 { FoodItemModel, type FoodItemDocument } from "./food-item.model.js"
export { UserSwipeModel, type UserSwipeDocument } from "./user-swipe.model.js"
export { CartItemModel, type CartItemDocument } from "./cart-item.model.js"
33 changes: 33 additions & 0 deletions backend/src/models/user-swipe.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
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,
},
)

export type UserSwipeDocument = InferSchemaType<typeof userSwipeSchema>
export const UserSwipeModel = model<UserSwipeDocument>("UserSwipe", userSwipeSchema)
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 { healthRouter } from "./health.routes.js"
import { swipeRouter } from "./swipe.routes.js"

export const apiRouter = Router()

apiRouter.use("/health", healthRouter)
apiRouter.use("/swipe", swipeRouter)
65 changes: 65 additions & 0 deletions backend/src/routes/swipe.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { Router } from "express"
import { Types } from "mongoose"
import { z } from "zod"
import { validateRequest } from "../middleware/validate-request.middleware.js"
import { CartItemModel, FoodItemModel, UserSwipeModel } from "../models/index.js"

const swipeSchema = z.object({
foodId: z.string().min(1),
action: z.enum(["like", "pass"]),
})

export const swipeRouter = Router()

swipeRouter.post("/", validateRequest({ body: swipeSchema }), async (req, res, next) => {
try {
const body = req.body as z.infer<typeof swipeSchema>
const userIdHeader = req.header("x-user-id")
if (!userIdHeader) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please review the POST /api/swipe implementation for robustness and production safety.
Focus on:

  • input hardening for foodId and user identity values
  • failure behavior and status codes for malformed IDs
  • consistency of swipe/cart writes and possible partial-write scenarios
  • schema/index assumptions for high-volume swipe traffic
  • Flag any blocker-level issue before merge.

res.status(401).json({
error: "Unauthorized",
message: "Missing x-user-id header",
})
return
}

const food = await FoodItemModel.findById(body.foodId)
if (!food) {
res.status(404).json({
error: "Not Found",
message: "Food item not found",
})
return
}

const userId = new Types.ObjectId(userIdHeader)
const foodId = new Types.ObjectId(body.foodId)

const swipe = await UserSwipeModel.create({
user_id: userId,
food_id: foodId,
action: body.action,
timestamp: new Date(),
})

if (body.action === "like") {
await CartItemModel.create({
user_id: userId,
food_id: foodId,
quantity: 1,
status: "active",
})
}

res.status(201).json({
ok: true,
swipe: {
id: String(swipe._id),
foodId: body.foodId,
action: body.action,
},
})
} catch (error) {
next(error)
}
})
90 changes: 90 additions & 0 deletions backend/test/swipe.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import assert from "node:assert/strict"
import test from "node:test"
import request from "supertest"
import { CartItemModel, FoodItemModel, UserSwipeModel } from "../src/models/index.js"
import { createApp } from "../src/app.js"

test("POST /api/swipe with action=pass stores swipe and does not create cart item", async () => {
const app = createApp()

const originalFindById = FoodItemModel.findById
const originalSwipeCreate = UserSwipeModel.create
const originalCartCreate = CartItemModel.create

let cartCreateCalls = 0

;(FoodItemModel.findById as unknown as (...args: unknown[]) => Promise<unknown>) = async () => ({
_id: "660000000000000000000100",
})
;(UserSwipeModel.create as unknown as (...args: unknown[]) => Promise<unknown>) = async () => ({
_id: "770000000000000000000001",
})
;(CartItemModel.create as unknown as (...args: unknown[]) => Promise<unknown>) = async () => {
cartCreateCalls += 1
return {}
}

const response = await request(app)
.post("/api/swipe")
.set("x-user-id", "660000000000000000000001")
.send({ foodId: "660000000000000000000100", action: "pass" })

assert.equal(response.status, 201)
assert.equal(response.body.swipe.action, "pass")
assert.equal(cartCreateCalls, 0)

FoodItemModel.findById = originalFindById
UserSwipeModel.create = originalSwipeCreate
CartItemModel.create = originalCartCreate
})

test("POST /api/swipe with action=like stores swipe and creates cart item", async () => {
const app = createApp()

const originalFindById = FoodItemModel.findById
const originalSwipeCreate = UserSwipeModel.create
const originalCartCreate = CartItemModel.create

let cartCreateCalls = 0

;(FoodItemModel.findById as unknown as (...args: unknown[]) => Promise<unknown>) = async () => ({
_id: "660000000000000000000100",
})
;(UserSwipeModel.create as unknown as (...args: unknown[]) => Promise<unknown>) = async () => ({
_id: "770000000000000000000001",
})
;(CartItemModel.create as unknown as (...args: unknown[]) => Promise<unknown>) = async () => {
cartCreateCalls += 1
return {}
}

const response = await request(app)
.post("/api/swipe")
.set("x-user-id", "660000000000000000000001")
.send({ foodId: "660000000000000000000100", action: "like" })

assert.equal(response.status, 201)
assert.equal(response.body.swipe.action, "like")
assert.equal(cartCreateCalls, 1)

FoodItemModel.findById = originalFindById
UserSwipeModel.create = originalSwipeCreate
CartItemModel.create = originalCartCreate
})

test("POST /api/swipe returns 404 when foodId does not exist", async () => {
const app = createApp()
const originalFindById = FoodItemModel.findById

;(FoodItemModel.findById as unknown as (...args: unknown[]) => Promise<unknown>) = async () => null

const response = await request(app)
.post("/api/swipe")
.set("x-user-id", "660000000000000000000001")
.send({ foodId: "660000000000000000000100", action: "like" })

assert.equal(response.status, 404)
assert.equal(response.body.error, "Not Found")

FoodItemModel.findById = originalFindById
})
Loading