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: 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': 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..a44d870 --- /dev/null +++ b/prisma/migrations/20260422180538_add_item_preferences/migration.sql @@ -0,0 +1,12 @@ +-- AlterTable +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" ( + "itemKey" TEXT NOT NULL, + "numLikes" INTEGER NOT NULL DEFAULT 0, + "numDislikes" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "ItemPreferenceCounts_pkey" PRIMARY KEY ("itemKey") +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8c3605b..1c3c760 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -58,9 +58,12 @@ model User { reports Report[] favoritedEateries FavoritedEatery[] favoritedItemNames String[] + likedItemKeys String[] @default([]) // Each entry uses "|" + dislikedItemKeys String[] @default([]) // Each entry uses "|" userEventVotes UserEventVote[] - @@index(favoritedItemNames, type: Gin) // The performance magic + // Allows for efficient reverse lookup: find users by favorited item names + @@index([favoritedItemNames], type: Gin) } model FavoritedEatery { @@ -180,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/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); diff --git a/src/users/userController.ts b/src/users/userController.ts index 6efa7f3..4937072 100644 --- a/src/users/userController.ts +++ b/src/users/userController.ts @@ -1,10 +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!; @@ -17,6 +25,8 @@ export const getMe = async (req: Request, res: Response) => { reports: true, favoritedEateries: true, favoritedItemNames: true, + likedItemKeys: true, + dislikedItemKeys: true, userEventVotes: true, }, }); @@ -65,6 +75,112 @@ export const removeFcmToken = async ( } }; +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); + + 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, + }, + }); + + if (!user) { + throw new NotFoundError('User not found.'); + } + + 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), + }, + }); + + 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: { + 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) { + 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'), 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}`;