From b9f40694a7e08ce3ed992ff1ed5c4bd79b6d5183 Mon Sep 17 00:00:00 2001 From: rmsb-art Date: Thu, 5 Mar 2026 16:08:22 +0100 Subject: [PATCH 1/4] feat(api): implement swipe endpoint with like-pass persistence logic --- .../middleware/validate-request.middleware.ts | 33 ++++++++++ backend/src/models/cart-item.model.ts | 34 ++++++++++ backend/src/models/food-item.model.ts | 48 ++++++++++++++ backend/src/models/index.ts | 3 + backend/src/models/user-swipe.model.ts | 33 ++++++++++ backend/src/routes/index.ts | 2 + backend/src/routes/swipe.routes.ts | 65 +++++++++++++++++++ 7 files changed, 218 insertions(+) create mode 100644 backend/src/middleware/validate-request.middleware.ts create mode 100644 backend/src/models/cart-item.model.ts create mode 100644 backend/src/models/food-item.model.ts create mode 100644 backend/src/models/index.ts create mode 100644 backend/src/models/user-swipe.model.ts create mode 100644 backend/src/routes/swipe.routes.ts diff --git a/backend/src/middleware/validate-request.middleware.ts b/backend/src/middleware/validate-request.middleware.ts new file mode 100644 index 0000000..96bdfc0 --- /dev/null +++ b/backend/src/middleware/validate-request.middleware.ts @@ -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) + } + } +} 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 new file mode 100644 index 0000000..8e89d36 --- /dev/null +++ b/backend/src/models/food-item.model.ts @@ -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 +export const FoodItemModel = model("FoodItem", foodItemSchema) diff --git a/backend/src/models/index.ts b/backend/src/models/index.ts new file mode 100644 index 0000000..7f24906 --- /dev/null +++ b/backend/src/models/index.ts @@ -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" 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 af78e39..ebc5131 100644 --- a/backend/src/routes/index.ts +++ b/backend/src/routes/index.ts @@ -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) diff --git a/backend/src/routes/swipe.routes.ts b/backend/src/routes/swipe.routes.ts new file mode 100644 index 0000000..f8f8852 --- /dev/null +++ b/backend/src/routes/swipe.routes.ts @@ -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 + const userIdHeader = req.header("x-user-id") + if (!userIdHeader) { + 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) + } +}) From aad0097e9e8bbfe96ccf2fd392333701c618e07a Mon Sep 17 00:00:00 2001 From: rmsb-art Date: Sat, 7 Mar 2026 12:41:17 +0100 Subject: [PATCH 2/4] test(api): cover swipe like-pass and missing food behaviors --- backend/README.md | 24 ++++++++++ backend/test/swipe.test.ts | 90 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 backend/test/swipe.test.ts diff --git a/backend/README.md b/backend/README.md index f7aa4f4..0fd445a 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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 diff --git a/backend/test/swipe.test.ts b/backend/test/swipe.test.ts new file mode 100644 index 0000000..8ece96f --- /dev/null +++ b/backend/test/swipe.test.ts @@ -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) = 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 +}) From 61b68fb5dff19ce4905d524aa83b919dbcc1ac3a Mon Sep 17 00:00:00 2001 From: rmsb-art Date: Sat, 7 Mar 2026 15:01:45 +0100 Subject: [PATCH 3/4] fixes --- backend/test/swipe.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/test/swipe.test.ts b/backend/test/swipe.test.ts index 8ece96f..87b332f 100644 --- a/backend/test/swipe.test.ts +++ b/backend/test/swipe.test.ts @@ -76,7 +76,8 @@ 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 + ;(FoodItemModel.findById as unknown as (...args: unknown[]) => Promise) = async () => + null const response = await request(app) .post("/api/swipe") From b61c8743ef7cbb225da7818fdfd989988bcb49df Mon Sep 17 00:00:00 2001 From: rmsb-art Date: Sat, 7 Mar 2026 15:03:37 +0100 Subject: [PATCH 4/4] updated fixes --- backend/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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": {