From a8e1b86fa081f887683f11748c4a59a359fed604 Mon Sep 17 00:00:00 2001 From: Fanhao Yu Date: Thu, 12 Mar 2026 18:11:06 -0400 Subject: [PATCH 01/13] Tweak docker compose --- .github/workflows/deploy-dev.yml | 2 +- docker-compose.yml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml index 0f43390..35d20da 100644 --- a/.github/workflows/deploy-dev.yml +++ b/.github/workflows/deploy-dev.yml @@ -1,4 +1,4 @@ -name: Docker Build & Push and Deploy to dev for eatery-backend +name: Docker Build & Push and Deploy to dev for eatery-dev on: push: diff --git a/docker-compose.yml b/docker-compose.yml index 062c4b1..99e3075 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,6 +24,7 @@ services: - eatery-network command: ['sh', '-c', 'npx prisma migrate deploy && npm run scrape:scheduled'] + restart: unless-stopped networks: eatery-network: From ea437c8c4a671f23a32d0bca80a15f70c5bcefaa Mon Sep 17 00:00:00 2001 From: Chris Voon Date: Wed, 15 Apr 2026 17:28:21 -0400 Subject: [PATCH 02/13] Add liked and disliked item names to User model in schema.prisma --- prisma/schema.prisma | 2 ++ 1 file changed, 2 insertions(+) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8c3605b..894a717 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -58,6 +58,8 @@ model User { reports Report[] favoritedEateries FavoritedEatery[] favoritedItemNames String[] + likedItemNames String[] + dislikedItemNames String[] userEventVotes UserEventVote[] @@index(favoritedItemNames, type: Gin) // The performance magic From c1feee88285757e9e56b7db9231653276d4c41b7 Mon Sep 17 00:00:00 2001 From: Chris Voon Date: Wed, 15 Apr 2026 17:52:10 -0400 Subject: [PATCH 03/13] Add GIN indexes for liked/disliked item names --- prisma/schema.prisma | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 894a717..67ef3bc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -62,7 +62,8 @@ model User { dislikedItemNames String[] userEventVotes UserEventVote[] - @@index(favoritedItemNames, type: Gin) // The performance magic + // Allows for efficient reverse lookup: find users by favorited/liked/disliked item names + @@index([favoritedItemNames, likedItemNames, dislikedItemNames], type: Gin) } model FavoritedEatery { From 619034d97b7c0f6c4b2b2bc34bb531a5e3cd19d4 Mon Sep 17 00:00:00 2001 From: Chris Voon Date: Thu, 16 Apr 2026 17:11:11 -0400 Subject: [PATCH 04/13] Add /preferences endpoint and for setting user item preferences (liked/disliked/none) --- prisma/schema.prisma | 4 +-- src/users/userController.ts | 54 +++++++++++++++++++++++++++++++++++++ src/users/userRouter.ts | 8 ++++++ src/users/users.schema.ts | 8 ++++++ 4 files changed, 72 insertions(+), 2 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 67ef3bc..0a07055 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -58,8 +58,8 @@ model User { reports Report[] favoritedEateries FavoritedEatery[] favoritedItemNames String[] - likedItemNames String[] - dislikedItemNames String[] + likedItemNames String[] // Each entry uses "|". + dislikedItemNames String[] // Each entry uses "|". userEventVotes UserEventVote[] // Allows for efficient reverse lookup: find users by favorited/liked/disliked item names diff --git a/src/users/userController.ts b/src/users/userController.ts index 6efa7f3..c11f1eb 100644 --- a/src/users/userController.ts +++ b/src/users/userController.ts @@ -17,6 +17,8 @@ export const getMe = async (req: Request, res: Response) => { reports: true, favoritedEateries: true, favoritedItemNames: true, + likedItemNames: true, + dislikedItemNames: true, userEventVotes: true, }, }); @@ -65,6 +67,58 @@ export const removeFcmToken = async ( } }; +const makeItemKey = (name: string, cornellId: number): string => { + return `${name}|${cornellId}`; +}; + +export const setItemPreference = async ( + req: Request, + res: Response, + next: NextFunction, +) => { + try { + const { name, cornellId, preference } = req.body as { + name: string; + cornellId: number; + preference: 'liked' | 'disliked' | 'none'; + }; + const { userId } = req.user!; + + const itemKey = makeItemKey(name, cornellId); + const user = await prisma.user.findUnique({ + where: { id: userId }, + }); + + if (!user) { + return res.status(404).json({ message: 'User not found.' }); + } + + const likedSet = new Set(user.likedItemNames); + const dislikedSet = new Set(user.dislikedItemNames); + + likedSet.delete(itemKey); + dislikedSet.delete(itemKey); + + if (preference === 'liked') { + likedSet.add(itemKey); + } else if (preference === 'disliked') { + dislikedSet.add(itemKey); + } + + await prisma.user.update({ + where: { id: user.id }, + data: { + likedItemNames: Array.from(likedSet), + dislikedItemNames: Array.from(dislikedSet), + }, + }); + + return res.status(200).json({ message: 'Item preference updated.' }); + } catch (error) { + return next(error); + } +}; + export const addFavoriteItem = async ( req: Request, res: Response, diff --git a/src/users/userRouter.ts b/src/users/userRouter.ts index b249227..e057a06 100644 --- a/src/users/userRouter.ts +++ b/src/users/userRouter.ts @@ -9,12 +9,14 @@ import { removeFavoriteEatery, removeFavoriteItem, removeFcmToken, + setItemPreference, } from './userController.js'; import { getMe } from './userController.js'; import { favoriteEaterySchema, favoriteItemSchema, fcmTokenSchema, + itemPreferenceSchema, } from './users.schema.js'; const router = Router(); @@ -23,6 +25,12 @@ router.get('/me', getMe); router.post('/fcm-token', validateRequest(fcmTokenSchema), addFcmToken); router.delete('/fcm-token', validateRequest(fcmTokenSchema), removeFcmToken); +router.post( + '/preferences', + validateRequest(itemPreferenceSchema), + setItemPreference, +); + router.post( '/favorites/items', validateRequest(favoriteItemSchema), diff --git a/src/users/users.schema.ts b/src/users/users.schema.ts index 2a7f3a9..7eced24 100644 --- a/src/users/users.schema.ts +++ b/src/users/users.schema.ts @@ -6,6 +6,14 @@ export const fcmTokenSchema = z.object({ }), }); +export const itemPreferenceSchema = z.object({ + body: z.object({ + name: z.string().nonempty('Item name is required'), + cornellId: z.number().int('cornellId must be an integer'), + preference: z.enum(['liked', 'disliked', 'none']), + }), +}); + export const favoriteItemSchema = z.object({ body: z.object({ name: z.string().nonempty('Item name is required'), From 42fc6939c34cba689214befbd0497c7ef489d8c6 Mon Sep 17 00:00:00 2001 From: Chris Voon Date: Tue, 21 Apr 2026 17:37:27 -0400 Subject: [PATCH 05/13] Add item preference counting and remove GIN indexing on liked and disliked items --- prisma/schema.prisma | 14 ++++++++--- src/users/userController.ts | 49 ++++++++++++++++++++++++++++++++----- 2 files changed, 53 insertions(+), 10 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0a07055..6d80b20 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -58,12 +58,12 @@ model User { reports Report[] favoritedEateries FavoritedEatery[] favoritedItemNames String[] - likedItemNames String[] // Each entry uses "|". - dislikedItemNames String[] // Each entry uses "|". + likedItemNames String[] // Each entry uses "|" + dislikedItemNames String[] // Each entry uses "|" userEventVotes UserEventVote[] - // Allows for efficient reverse lookup: find users by favorited/liked/disliked item names - @@index([favoritedItemNames, likedItemNames, dislikedItemNames], type: Gin) + // Allows for efficient reverse lookup: find users by favorited item names + @@index([favoritedItemNames], type: Gin) } model FavoritedEatery { @@ -183,6 +183,12 @@ model Item { @@index([name]) } +model ItemPreferenceCounts { + itemKey String @id // "|" + numLikes Int @default(0) + numDislikes Int @default(0) +} + model DietaryPreference { name String @id @unique items Item[] diff --git a/src/users/userController.ts b/src/users/userController.ts index c11f1eb..1336357 100644 --- a/src/users/userController.ts +++ b/src/users/userController.ts @@ -95,6 +95,13 @@ export const setItemPreference = async ( const likedSet = new Set(user.likedItemNames); const dislikedSet = new Set(user.dislikedItemNames); + const previousPreference: 'liked' | 'disliked' | 'none' = likedSet.has( + itemKey, + ) + ? 'liked' + : dislikedSet.has(itemKey) + ? 'disliked' + : 'none'; likedSet.delete(itemKey); dislikedSet.delete(itemKey); @@ -105,12 +112,42 @@ export const setItemPreference = async ( dislikedSet.add(itemKey); } - await prisma.user.update({ - where: { id: user.id }, - data: { - likedItemNames: Array.from(likedSet), - dislikedItemNames: Array.from(dislikedSet), - }, + const likeDelta = + (preference === 'liked' ? 1 : 0) - + (previousPreference === 'liked' ? 1 : 0); + const dislikeDelta = + (preference === 'disliked' ? 1 : 0) - + (previousPreference === 'disliked' ? 1 : 0); + + await prisma.$transaction(async (tx) => { + await tx.user.update({ + where: { id: user.id }, + data: { + likedItemNames: Array.from(likedSet), + dislikedItemNames: Array.from(dislikedSet), + }, + }); + + // Create item preference count for this item if it doesn't exist + await tx.itemPreferenceCounts.upsert({ + where: { itemKey }, + create: { + itemKey, + numLikes: 0, + numDislikes: 0, + }, + update: {}, + }); + + if (likeDelta !== 0 || dislikeDelta !== 0) { + await tx.itemPreferenceCounts.update({ + where: { itemKey }, + data: { + numLikes: { increment: likeDelta }, + numDislikes: { increment: dislikeDelta }, + }, + }); + } }); return res.status(200).json({ message: 'Item preference updated.' }); From 1097e6dd8216774650f31518661dad7b22f275bd Mon Sep 17 00:00:00 2001 From: Chris Voon Date: Tue, 21 Apr 2026 18:09:01 -0400 Subject: [PATCH 06/13] Move makeItemKey to a util file --- src/users/userController.ts | 5 +---- src/utils/itemKey.ts | 3 +++ 2 files changed, 4 insertions(+), 4 deletions(-) create mode 100644 src/utils/itemKey.ts diff --git a/src/users/userController.ts b/src/users/userController.ts index 1336357..821d56c 100644 --- a/src/users/userController.ts +++ b/src/users/userController.ts @@ -3,6 +3,7 @@ import type { EventType } from '@prisma/client'; import type { NextFunction, Request, Response } from 'express'; import { prisma } from '../prisma.js'; +import { makeItemKey } from '../utils/itemKey.js'; import { getTodayTimeWindow } from '../utils/time.js'; export const getMe = async (req: Request, res: Response) => { @@ -67,10 +68,6 @@ export const removeFcmToken = async ( } }; -const makeItemKey = (name: string, cornellId: number): string => { - return `${name}|${cornellId}`; -}; - export const setItemPreference = async ( req: Request, res: Response, diff --git a/src/utils/itemKey.ts b/src/utils/itemKey.ts new file mode 100644 index 0000000..2d63189 --- /dev/null +++ b/src/utils/itemKey.ts @@ -0,0 +1,3 @@ +/** Key for an item at a specific eatery (matches User liked/disliked arrays) */ +export const makeItemKey = (itemName: string, cornellId: number): string => + `${itemName}|${cornellId}`; From 643c1f68f18691ec6d6006c54ae58750608c899a Mon Sep 17 00:00:00 2001 From: Chris Voon Date: Wed, 22 Apr 2026 00:09:05 -0400 Subject: [PATCH 07/13] Add item preference counts API --- src/items/itemController.ts | 41 +++++++++++++++++++++++++++++++++++++ src/items/itemRouter.ts | 15 ++++++++++++++ src/items/items.schema.ts | 15 ++++++++++++++ src/server.ts | 2 ++ 4 files changed, 73 insertions(+) create mode 100644 src/items/itemController.ts create mode 100644 src/items/itemRouter.ts create mode 100644 src/items/items.schema.ts diff --git a/src/items/itemController.ts b/src/items/itemController.ts new file mode 100644 index 0000000..9ea0cd2 --- /dev/null +++ b/src/items/itemController.ts @@ -0,0 +1,41 @@ +import type { NextFunction, Request, Response } from 'express'; + +import { prisma } from '../prisma.js'; +import { makeItemKey } from '../utils/itemKey.js'; + +export const getItemPreferenceCounts = async ( + req: Request, + res: Response, + next: NextFunction, +) => { + try { + const { items } = req.body as { + items: { itemName: string; cornellId: number }[]; + }; + + const itemKeys = items.map(({ itemName, cornellId }) => + makeItemKey(itemName, cornellId), + ); + + const rows = await prisma.itemPreferenceCounts.findMany({ + where: { itemKey: { in: itemKeys } }, + }); + + const countsMap = new Map(rows.map((r) => [r.itemKey, r])); + + const counts = items.map(({ itemName, cornellId }) => { + const key = makeItemKey(itemName, cornellId); + const row = countsMap.get(key); + return { + itemName, + cornellId, + numLikes: row?.numLikes ?? 0, + numDislikes: row?.numDislikes ?? 0, + }; + }); + + return res.json(counts); + } catch (error) { + return next(error); + } +}; diff --git a/src/items/itemRouter.ts b/src/items/itemRouter.ts new file mode 100644 index 0000000..acb7b13 --- /dev/null +++ b/src/items/itemRouter.ts @@ -0,0 +1,15 @@ +import { Router } from 'express'; + +import { validateRequest } from '../middleware/validateRequest.js'; +import { getItemPreferenceCounts } from './itemController.js'; +import { getItemPreferenceCountsSchema } from './items.schema.js'; + +const router = Router(); + +router.post( + '/preference-counts', + validateRequest(getItemPreferenceCountsSchema), + getItemPreferenceCounts, +); + +export default router; diff --git a/src/items/items.schema.ts b/src/items/items.schema.ts new file mode 100644 index 0000000..019b777 --- /dev/null +++ b/src/items/items.schema.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +export const getItemPreferenceCountsSchema = z.object({ + body: z.object({ + items: z + .array( + z.object({ + itemName: z.string().nonempty('Item name is required'), + cornellId: z.number().int('cornellId must be an integer'), + }), + ) + .min(1) + .max(200), + }), +}); diff --git a/src/server.ts b/src/server.ts index 8efca70..655fac8 100644 --- a/src/server.ts +++ b/src/server.ts @@ -7,6 +7,7 @@ import type { Request, Response } from 'express'; import authRouter from './auth/authRouter.js'; import eateryRouter from './eateries/eateryRouter.js'; import financialRouter from './financials/financialsRouter.js'; +import itemRouter from './items/itemRouter.js'; import { requireAuth } from './middleware/authentication.js'; import { globalErrorHandler } from './middleware/errorHandler.js'; import { requestLogger } from './middleware/logger.js'; @@ -55,6 +56,7 @@ router.use('/auth', authRouter); router.use('/internal/cache', cacheRouter); router.use('/version', versionRouter); router.use('/eateries', eateryRouter); +router.use('/items', itemRouter); // Protected routes router.use(requireAuth); From da4f45a1d7c1fe6858c57cdf67e2c2fec13bdfc4 Mon Sep 17 00:00:00 2001 From: Chris Voon Date: Wed, 22 Apr 2026 14:06:41 -0400 Subject: [PATCH 08/13] Create migration for finalized database schema changes --- .../migration.sql | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 prisma/migrations/20260422180538_add_item_preferences/migration.sql diff --git a/prisma/migrations/20260422180538_add_item_preferences/migration.sql b/prisma/migrations/20260422180538_add_item_preferences/migration.sql new file mode 100644 index 0000000..3e4414a --- /dev/null +++ b/prisma/migrations/20260422180538_add_item_preferences/migration.sql @@ -0,0 +1,12 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "dislikedItemNames" TEXT[], +ADD COLUMN "likedItemNames" TEXT[]; + +-- CreateTable +CREATE TABLE "ItemPreferenceCounts" ( + "itemKey" TEXT NOT NULL, + "numLikes" INTEGER NOT NULL DEFAULT 0, + "numDislikes" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "ItemPreferenceCounts_pkey" PRIMARY KEY ("itemKey") +); From 4784e988bae2b9ac6fcc00c66fdfbe67ac31ede7 Mon Sep 17 00:00:00 2001 From: Chris Voon Date: Wed, 22 Apr 2026 16:19:49 -0400 Subject: [PATCH 09/13] Rename likedItemNames and dislikedItemNames to likedItemKeys and dislikedItemKeys --- .../migration.sql | 4 ++-- prisma/schema.prisma | 4 ++-- src/users/userController.ts | 12 ++++++------ 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/prisma/migrations/20260422180538_add_item_preferences/migration.sql b/prisma/migrations/20260422180538_add_item_preferences/migration.sql index 3e4414a..29ff11b 100644 --- a/prisma/migrations/20260422180538_add_item_preferences/migration.sql +++ b/prisma/migrations/20260422180538_add_item_preferences/migration.sql @@ -1,6 +1,6 @@ -- AlterTable -ALTER TABLE "User" ADD COLUMN "dislikedItemNames" TEXT[], -ADD COLUMN "likedItemNames" TEXT[]; +ALTER TABLE "User" ADD COLUMN "dislikedItemKeys" TEXT[], +ADD COLUMN "likedItemKeys" TEXT[]; -- CreateTable CREATE TABLE "ItemPreferenceCounts" ( diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6d80b20..f09b721 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -58,8 +58,8 @@ model User { reports Report[] favoritedEateries FavoritedEatery[] favoritedItemNames String[] - likedItemNames String[] // Each entry uses "|" - dislikedItemNames String[] // Each entry uses "|" + likedItemKeys String[] // Each entry uses "|" + dislikedItemKeys String[] // Each entry uses "|" userEventVotes UserEventVote[] // Allows for efficient reverse lookup: find users by favorited item names diff --git a/src/users/userController.ts b/src/users/userController.ts index 821d56c..2f85823 100644 --- a/src/users/userController.ts +++ b/src/users/userController.ts @@ -18,8 +18,8 @@ export const getMe = async (req: Request, res: Response) => { reports: true, favoritedEateries: true, favoritedItemNames: true, - likedItemNames: true, - dislikedItemNames: true, + likedItemKeys: true, + dislikedItemKeys: true, userEventVotes: true, }, }); @@ -90,8 +90,8 @@ export const setItemPreference = async ( return res.status(404).json({ message: 'User not found.' }); } - const likedSet = new Set(user.likedItemNames); - const dislikedSet = new Set(user.dislikedItemNames); + const likedSet = new Set(user.likedItemKeys); + const dislikedSet = new Set(user.dislikedItemKeys); const previousPreference: 'liked' | 'disliked' | 'none' = likedSet.has( itemKey, ) @@ -120,8 +120,8 @@ export const setItemPreference = async ( await tx.user.update({ where: { id: user.id }, data: { - likedItemNames: Array.from(likedSet), - dislikedItemNames: Array.from(dislikedSet), + likedItemKeys: Array.from(likedSet), + dislikedItemKeys: Array.from(dislikedSet), }, }); From ac4238399deb788549866f08dc6592d6f999292f Mon Sep 17 00:00:00 2001 From: Chris Voon Date: Wed, 22 Apr 2026 16:52:03 -0400 Subject: [PATCH 10/13] Make sure itemKeys arrays are [] by default --- .../20260422180538_add_item_preferences/migration.sql | 4 ++-- prisma/schema.prisma | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/prisma/migrations/20260422180538_add_item_preferences/migration.sql b/prisma/migrations/20260422180538_add_item_preferences/migration.sql index 29ff11b..a44d870 100644 --- a/prisma/migrations/20260422180538_add_item_preferences/migration.sql +++ b/prisma/migrations/20260422180538_add_item_preferences/migration.sql @@ -1,6 +1,6 @@ -- AlterTable -ALTER TABLE "User" ADD COLUMN "dislikedItemKeys" TEXT[], -ADD COLUMN "likedItemKeys" TEXT[]; +ALTER TABLE "User" ADD COLUMN "dislikedItemKeys" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], +ADD COLUMN "likedItemKeys" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[]; -- CreateTable CREATE TABLE "ItemPreferenceCounts" ( diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f09b721..1c3c760 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -58,8 +58,8 @@ model User { reports Report[] favoritedEateries FavoritedEatery[] favoritedItemNames String[] - likedItemKeys String[] // Each entry uses "|" - dislikedItemKeys String[] // Each entry uses "|" + likedItemKeys String[] @default([]) // Each entry uses "|" + dislikedItemKeys String[] @default([]) // Each entry uses "|" userEventVotes UserEventVote[] // Allows for efficient reverse lookup: find users by favorited item names From f23e03485be1d74c89f165603200831c7c614bf1 Mon Sep 17 00:00:00 2001 From: Chris Voon Date: Wed, 22 Apr 2026 17:34:24 -0400 Subject: [PATCH 11/13] Use serializable tx and P2034 retry for setItemPreference --- src/users/userController.ts | 146 +++++++++++++++++++++--------------- 1 file changed, 87 insertions(+), 59 deletions(-) diff --git a/src/users/userController.ts b/src/users/userController.ts index 2f85823..4146bf8 100644 --- a/src/users/userController.ts +++ b/src/users/userController.ts @@ -1,11 +1,18 @@ import type { EventType } from '@prisma/client'; +import { Prisma } from '@prisma/client'; import type { NextFunction, Request, Response } from 'express'; import { prisma } from '../prisma.js'; +import { NotFoundError } from '../utils/AppError.js'; import { makeItemKey } from '../utils/itemKey.js'; import { getTodayTimeWindow } from '../utils/time.js'; +const SERIALIZABLE_TX_MAX_ATTEMPTS = 5; + +const isSerializationFailure = (err: unknown): boolean => + err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2034'; + export const getMe = async (req: Request, res: Response) => { const { userId } = req.user!; @@ -82,70 +89,91 @@ export const setItemPreference = async ( const { userId } = req.user!; const itemKey = makeItemKey(name, cornellId); - const user = await prisma.user.findUnique({ - where: { id: userId }, - }); - if (!user) { - return res.status(404).json({ message: 'User not found.' }); - } + for (let attempt = 0; attempt < SERIALIZABLE_TX_MAX_ATTEMPTS; attempt++) { + try { + await prisma.$transaction( + async (tx) => { + const user = await tx.user.findUnique({ + where: { id: userId }, + select: { + id: true, + likedItemKeys: true, + dislikedItemKeys: true, + }, + }); - const likedSet = new Set(user.likedItemKeys); - const dislikedSet = new Set(user.dislikedItemKeys); - const previousPreference: 'liked' | 'disliked' | 'none' = likedSet.has( - itemKey, - ) - ? 'liked' - : dislikedSet.has(itemKey) - ? 'disliked' - : 'none'; - - likedSet.delete(itemKey); - dislikedSet.delete(itemKey); - - if (preference === 'liked') { - likedSet.add(itemKey); - } else if (preference === 'disliked') { - dislikedSet.add(itemKey); - } + if (!user) { + throw new NotFoundError('User not found.'); + } - const likeDelta = - (preference === 'liked' ? 1 : 0) - - (previousPreference === 'liked' ? 1 : 0); - const dislikeDelta = - (preference === 'disliked' ? 1 : 0) - - (previousPreference === 'disliked' ? 1 : 0); - - await prisma.$transaction(async (tx) => { - await tx.user.update({ - where: { id: user.id }, - data: { - likedItemKeys: Array.from(likedSet), - dislikedItemKeys: Array.from(dislikedSet), - }, - }); - - // Create item preference count for this item if it doesn't exist - await tx.itemPreferenceCounts.upsert({ - where: { itemKey }, - create: { - itemKey, - numLikes: 0, - numDislikes: 0, - }, - update: {}, - }); - - if (likeDelta !== 0 || dislikeDelta !== 0) { - await tx.itemPreferenceCounts.update({ - where: { itemKey }, - data: { - numLikes: { increment: likeDelta }, - numDislikes: { increment: dislikeDelta }, + const likedSet = new Set(user.likedItemKeys ?? []); + const dislikedSet = new Set(user.dislikedItemKeys ?? []); + const previousPreference: 'liked' | 'disliked' | 'none' = + likedSet.has(itemKey) + ? 'liked' + : dislikedSet.has(itemKey) + ? 'disliked' + : 'none'; + + likedSet.delete(itemKey); + dislikedSet.delete(itemKey); + + if (preference === 'liked') { + likedSet.add(itemKey); + } else if (preference === 'disliked') { + dislikedSet.add(itemKey); + } + + const likeDelta = + (preference === 'liked' ? 1 : 0) - + (previousPreference === 'liked' ? 1 : 0); + const dislikeDelta = + (preference === 'disliked' ? 1 : 0) - + (previousPreference === 'disliked' ? 1 : 0); + + await tx.user.update({ + where: { id: user.id }, + data: { + likedItemKeys: Array.from(likedSet), + dislikedItemKeys: Array.from(dislikedSet), + }, + }); + + // Create item preference count for this item if it doesn't exist + await tx.itemPreferenceCounts.upsert({ + where: { itemKey }, + create: { + itemKey, + numLikes: 0, + numDislikes: 0, + }, + update: {}, + }); + + if (likeDelta !== 0 || dislikeDelta !== 0) { + await tx.itemPreferenceCounts.update({ + where: { itemKey }, + data: { + numLikes: { increment: likeDelta }, + numDislikes: { increment: dislikeDelta }, + }, + }); + } }, - }); + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, + ); + break; + } catch (err) { + if ( + isSerializationFailure(err) && + attempt < SERIALIZABLE_TX_MAX_ATTEMPTS - 1 + ) { + continue; + } + throw err; } - }); + } return res.status(200).json({ message: 'Item preference updated.' }); } catch (error) { From a3c1b882a10bef85df851547d19709597ece66f0 Mon Sep 17 00:00:00 2001 From: Chris Voon Date: Wed, 22 Apr 2026 17:45:41 -0400 Subject: [PATCH 12/13] Do not create ItemPreferenceCounts row when count deltas are zero --- src/users/userController.ts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/users/userController.ts b/src/users/userController.ts index 4146bf8..4937072 100644 --- a/src/users/userController.ts +++ b/src/users/userController.ts @@ -140,18 +140,18 @@ export const setItemPreference = async ( }, }); - // Create item preference count for this item if it doesn't exist - await tx.itemPreferenceCounts.upsert({ - where: { itemKey }, - create: { - itemKey, - numLikes: 0, - numDislikes: 0, - }, - update: {}, - }); - if (likeDelta !== 0 || dislikeDelta !== 0) { + // Create count row if missing + await tx.itemPreferenceCounts.upsert({ + where: { itemKey }, + create: { + itemKey, + numLikes: 0, + numDislikes: 0, + }, + update: {}, + }); + // Apply deltas await tx.itemPreferenceCounts.update({ where: { itemKey }, data: { From 74c34da8ceac16ff5b332564dc66b5157d08f35e Mon Sep 17 00:00:00 2001 From: Stefanie Rivera-Osorio Date: Mon, 11 May 2026 11:41:59 -0400 Subject: [PATCH 13/13] fix: cash-to-card payment method --- prisma/mappers.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/prisma/mappers.ts b/prisma/mappers.ts index a02e169..7b1ecc2 100644 --- a/prisma/mappers.ts +++ b/prisma/mappers.ts @@ -45,6 +45,7 @@ export function mapPaymentMethod(method: RawPayMethod): PaymentMethod { case 'Cornell Card': return PaymentMethod.BRB; case 'Major Credit Cards': + case 'Cash-to-Card': case 'Mobile Payments': return PaymentMethod.CARD; case 'Cash':