diff --git a/backend/README.md b/backend/README.md index 9c4ff1a..58b9d7e 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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): diff --git a/backend/package.json b/backend/package.json index a0894d9..a868ef8 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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": { diff --git a/backend/src/middleware/validate-request.middleware.ts b/backend/src/middleware/validate-request.middleware.ts index 05ffb29..6403307 100644 --- a/backend/src/middleware/validate-request.middleware.ts +++ b/backend/src/middleware/validate-request.middleware.ts @@ -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) { @@ -36,7 +35,6 @@ export function validateRequest(schema: RequestSchema): RequestHandler { }) return } - next(error) } } diff --git a/backend/src/models/cart-item.model.ts b/backend/src/models/cart-item.model.ts new file mode 100644 index 0000000..f0737af --- /dev/null +++ b/backend/src/models/cart-item.model.ts @@ -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 +export const CartItemModel = model("CartItem", cartItemSchema) diff --git a/backend/src/models/food-item.model.ts b/backend/src/models/food-item.model.ts index f6ad17d..a554aa3 100644 --- a/backend/src/models/food-item.model.ts +++ b/backend/src/models/food-item.model.ts @@ -37,6 +37,7 @@ const foodItemSchema = new Schema( is_active: { type: Boolean, default: true, + index: true, }, }, { diff --git a/backend/src/models/index.ts b/backend/src/models/index.ts index 4db7d26..2851fcc 100644 --- a/backend/src/models/index.ts +++ b/backend/src/models/index.ts @@ -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" diff --git a/backend/src/models/user-swipe.model.ts b/backend/src/models/user-swipe.model.ts new file mode 100644 index 0000000..f77feaf --- /dev/null +++ b/backend/src/models/user-swipe.model.ts @@ -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 +export const UserSwipeModel = model("UserSwipe", userSwipeSchema) diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts index dc3f926..0f3f509 100644 --- a/backend/src/routes/index.ts +++ b/backend/src/routes/index.ts @@ -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) diff --git a/backend/src/routes/swipe.routes.ts b/backend/src/routes/swipe.routes.ts new file mode 100644 index 0000000..36ad725 --- /dev/null +++ b/backend/src/routes/swipe.routes.ts @@ -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 + 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) + } +}) diff --git a/backend/test/swipe.test.ts b/backend/test/swipe.test.ts new file mode 100644 index 0000000..87b332f --- /dev/null +++ b/backend/test/swipe.test.ts @@ -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) = async () => ({ + _id: "660000000000000000000100", + }) + ;(UserSwipeModel.create as unknown as (...args: unknown[]) => Promise) = async () => ({ + _id: "770000000000000000000001", + }) + ;(CartItemModel.create as unknown as (...args: unknown[]) => Promise) = 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) = async () => ({ + _id: "660000000000000000000100", + }) + ;(UserSwipeModel.create as unknown as (...args: unknown[]) => Promise) = async () => ({ + _id: "770000000000000000000001", + }) + ;(CartItemModel.create as unknown as (...args: unknown[]) => Promise) = 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) = 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 +})