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
24 changes: 24 additions & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,30 @@ Health endpoint:
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

## Restaurant Menu CRUD

Protected routes (JWT required):
Expand Down
2 changes: 1 addition & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"format": "prettier --check \"src/**/*.ts\" \"scripts/**/*.ts\" \"test/**/*.ts\"",
"format:write": "prettier --write \"src/**/*.ts\" \"scripts/**/*.ts\" \"test/**/*.ts\"",
"typecheck": "tsc --noEmit",
"test": "NODE_ENV=test MONGODB_URI=mongodb://localhost:27017/discoverly_test JWT_SECRET=test_secret tsx --test test/**/*.test.ts",
"test": "NODE_ENV=test MONGODB_URI=mongodb://localhost:27017/discoverly_test JWT_SECRET=test_secret tsx --test test/*.test.ts",
"seed": "tsx scripts/seed.ts"
},
"dependencies": {
Expand Down
2 changes: 0 additions & 2 deletions backend/src/middleware/validate-request.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ export function validateRequest(schema: RequestSchema): RequestHandler {
if (schema.params) {
req.params = schema.params.parse(req.params)
}

next()
} catch (error) {
if (error instanceof ZodError) {
Expand All @@ -36,7 +35,6 @@ export function validateRequest(schema: RequestSchema): RequestHandler {
})
return
}

next(error)
}
}
Expand Down
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)
1 change: 1 addition & 0 deletions backend/src/models/food-item.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const foodItemSchema = new Schema(
is_active: {
type: Boolean,
default: true,
index: true,
},
},
{
Expand Down
2 changes: 2 additions & 0 deletions backend/src/models/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
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"
export { RestaurantModel, type RestaurantDocument } from "./restaurant.model.js"
export { UserModel, type UserDocument, type UserRole, userRoles } from "./user.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,12 +1,14 @@
import { Router } from "express"
import { authRouter } from "./auth.routes.js"
import { healthRouter } from "./health.routes.js"
import { swipeRouter } from "./swipe.routes.js"
import { restaurantFoodsRouter } from "./restaurant-foods.routes.js"
import { pingRouter } from "./ping.routes.js"

export const apiRouter = Router()

apiRouter.use("/health", healthRouter)
apiRouter.use("/swipe", swipeRouter)
apiRouter.use("/restaurant/foods", restaurantFoodsRouter)
apiRouter.use("/auth", authRouter)
apiRouter.use("/ping", pingRouter)
81 changes: 81 additions & 0 deletions backend/src/routes/swipe.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
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 userIdRaw = req.user?.id ?? req.header("x-user-id")
if (!userIdRaw) {
res.status(401).json({
error: "Unauthorized",
message: "Missing authenticated user id",
})
return
}

if (!Types.ObjectId.isValid(body.foodId)) {
res.status(400).json({
error: "Bad Request",
message: "Invalid foodId",
})
return
}

if (!Types.ObjectId.isValid(userIdRaw)) {
res.status(400).json({
error: "Bad Request",
message: "Invalid user id",
})
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(userIdRaw)
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)
}
})
91 changes: 91 additions & 0 deletions backend/test/swipe.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
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
})