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
2 changes: 1 addition & 1 deletion .github/workflows/deploy-dev.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ services:
- eatery-network
command:
['sh', '-c', 'npx prisma migrate deploy && npm run scrape:scheduled']
restart: unless-stopped

networks:
eatery-network:
1 change: 1 addition & 0 deletions prisma/mappers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down
Original file line number Diff line number Diff line change
@@ -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")
);
11 changes: 10 additions & 1 deletion prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,12 @@ model User {
reports Report[]
favoritedEateries FavoritedEatery[]
favoritedItemNames String[]
likedItemKeys String[] @default([]) // Each entry uses "<itemName>|<eateryCornellId>"
dislikedItemKeys String[] @default([]) // Each entry uses "<itemName>|<eateryCornellId>"
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 {
Expand Down Expand Up @@ -180,6 +183,12 @@ model Item {
@@index([name])
}

model ItemPreferenceCounts {
itemKey String @id // "<itemName>|<eateryCornellId>"
numLikes Int @default(0)
numDislikes Int @default(0)
}

model DietaryPreference {
name String @id @unique
items Item[]
Expand Down
41 changes: 41 additions & 0 deletions src/items/itemController.ts
Original file line number Diff line number Diff line change
@@ -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);
}
};
15 changes: 15 additions & 0 deletions src/items/itemRouter.ts
Original file line number Diff line number Diff line change
@@ -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;
15 changes: 15 additions & 0 deletions src/items/items.schema.ts
Original file line number Diff line number Diff line change
@@ -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),
}),
});
2 changes: 2 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
116 changes: 116 additions & 0 deletions src/users/userController.ts
Original file line number Diff line number Diff line change
@@ -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!;

Expand All @@ -17,6 +25,8 @@ export const getMe = async (req: Request, res: Response) => {
reports: true,
favoritedEateries: true,
favoritedItemNames: true,
likedItemKeys: true,
dislikedItemKeys: true,
userEventVotes: true,
},
});
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions src/users/userRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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),
Expand Down
8 changes: 8 additions & 0 deletions src/users/users.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
3 changes: 3 additions & 0 deletions src/utils/itemKey.ts
Original file line number Diff line number Diff line change
@@ -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}`;
Loading