Skip to content

Commit 809c8bc

Browse files
authored
Merge pull request #26 from cuappdev/chris/reviews
2 parents a8e1b86 + a3c1b88 commit 809c8bc

10 files changed

Lines changed: 230 additions & 1 deletion

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
-- AlterTable
2+
ALTER TABLE "User" ADD COLUMN "dislikedItemKeys" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
3+
ADD COLUMN "likedItemKeys" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
4+
5+
-- CreateTable
6+
CREATE TABLE "ItemPreferenceCounts" (
7+
"itemKey" TEXT NOT NULL,
8+
"numLikes" INTEGER NOT NULL DEFAULT 0,
9+
"numDislikes" INTEGER NOT NULL DEFAULT 0,
10+
11+
CONSTRAINT "ItemPreferenceCounts_pkey" PRIMARY KEY ("itemKey")
12+
);

prisma/schema.prisma

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,12 @@ model User {
5858
reports Report[]
5959
favoritedEateries FavoritedEatery[]
6060
favoritedItemNames String[]
61+
likedItemKeys String[] @default([]) // Each entry uses "<itemName>|<eateryCornellId>"
62+
dislikedItemKeys String[] @default([]) // Each entry uses "<itemName>|<eateryCornellId>"
6163
userEventVotes UserEventVote[]
6264
63-
@@index(favoritedItemNames, type: Gin) // The performance magic
65+
// Allows for efficient reverse lookup: find users by favorited item names
66+
@@index([favoritedItemNames], type: Gin)
6467
}
6568

6669
model FavoritedEatery {
@@ -180,6 +183,12 @@ model Item {
180183
@@index([name])
181184
}
182185

186+
model ItemPreferenceCounts {
187+
itemKey String @id // "<itemName>|<eateryCornellId>"
188+
numLikes Int @default(0)
189+
numDislikes Int @default(0)
190+
}
191+
183192
model DietaryPreference {
184193
name String @id @unique
185194
items Item[]

src/items/itemController.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import type { NextFunction, Request, Response } from 'express';
2+
3+
import { prisma } from '../prisma.js';
4+
import { makeItemKey } from '../utils/itemKey.js';
5+
6+
export const getItemPreferenceCounts = async (
7+
req: Request,
8+
res: Response,
9+
next: NextFunction,
10+
) => {
11+
try {
12+
const { items } = req.body as {
13+
items: { itemName: string; cornellId: number }[];
14+
};
15+
16+
const itemKeys = items.map(({ itemName, cornellId }) =>
17+
makeItemKey(itemName, cornellId),
18+
);
19+
20+
const rows = await prisma.itemPreferenceCounts.findMany({
21+
where: { itemKey: { in: itemKeys } },
22+
});
23+
24+
const countsMap = new Map(rows.map((r) => [r.itemKey, r]));
25+
26+
const counts = items.map(({ itemName, cornellId }) => {
27+
const key = makeItemKey(itemName, cornellId);
28+
const row = countsMap.get(key);
29+
return {
30+
itemName,
31+
cornellId,
32+
numLikes: row?.numLikes ?? 0,
33+
numDislikes: row?.numDislikes ?? 0,
34+
};
35+
});
36+
37+
return res.json(counts);
38+
} catch (error) {
39+
return next(error);
40+
}
41+
};

src/items/itemRouter.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { Router } from 'express';
2+
3+
import { validateRequest } from '../middleware/validateRequest.js';
4+
import { getItemPreferenceCounts } from './itemController.js';
5+
import { getItemPreferenceCountsSchema } from './items.schema.js';
6+
7+
const router = Router();
8+
9+
router.post(
10+
'/preference-counts',
11+
validateRequest(getItemPreferenceCountsSchema),
12+
getItemPreferenceCounts,
13+
);
14+
15+
export default router;

src/items/items.schema.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { z } from 'zod';
2+
3+
export const getItemPreferenceCountsSchema = z.object({
4+
body: z.object({
5+
items: z
6+
.array(
7+
z.object({
8+
itemName: z.string().nonempty('Item name is required'),
9+
cornellId: z.number().int('cornellId must be an integer'),
10+
}),
11+
)
12+
.min(1)
13+
.max(200),
14+
}),
15+
});

src/server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { Request, Response } from 'express';
77
import authRouter from './auth/authRouter.js';
88
import eateryRouter from './eateries/eateryRouter.js';
99
import financialRouter from './financials/financialsRouter.js';
10+
import itemRouter from './items/itemRouter.js';
1011
import { requireAuth } from './middleware/authentication.js';
1112
import { globalErrorHandler } from './middleware/errorHandler.js';
1213
import { requestLogger } from './middleware/logger.js';
@@ -55,6 +56,7 @@ router.use('/auth', authRouter);
5556
router.use('/internal/cache', cacheRouter);
5657
router.use('/version', versionRouter);
5758
router.use('/eateries', eateryRouter);
59+
router.use('/items', itemRouter);
5860

5961
// Protected routes
6062
router.use(requireAuth);

src/users/userController.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
11
import type { EventType } from '@prisma/client';
2+
import { Prisma } from '@prisma/client';
23

34
import type { NextFunction, Request, Response } from 'express';
45

56
import { prisma } from '../prisma.js';
7+
import { NotFoundError } from '../utils/AppError.js';
8+
import { makeItemKey } from '../utils/itemKey.js';
69
import { getTodayTimeWindow } from '../utils/time.js';
710

11+
const SERIALIZABLE_TX_MAX_ATTEMPTS = 5;
12+
13+
const isSerializationFailure = (err: unknown): boolean =>
14+
err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2034';
15+
816
export const getMe = async (req: Request, res: Response) => {
917
const { userId } = req.user!;
1018

@@ -17,6 +25,8 @@ export const getMe = async (req: Request, res: Response) => {
1725
reports: true,
1826
favoritedEateries: true,
1927
favoritedItemNames: true,
28+
likedItemKeys: true,
29+
dislikedItemKeys: true,
2030
userEventVotes: true,
2131
},
2232
});
@@ -65,6 +75,112 @@ export const removeFcmToken = async (
6575
}
6676
};
6777

78+
export const setItemPreference = async (
79+
req: Request,
80+
res: Response,
81+
next: NextFunction,
82+
) => {
83+
try {
84+
const { name, cornellId, preference } = req.body as {
85+
name: string;
86+
cornellId: number;
87+
preference: 'liked' | 'disliked' | 'none';
88+
};
89+
const { userId } = req.user!;
90+
91+
const itemKey = makeItemKey(name, cornellId);
92+
93+
for (let attempt = 0; attempt < SERIALIZABLE_TX_MAX_ATTEMPTS; attempt++) {
94+
try {
95+
await prisma.$transaction(
96+
async (tx) => {
97+
const user = await tx.user.findUnique({
98+
where: { id: userId },
99+
select: {
100+
id: true,
101+
likedItemKeys: true,
102+
dislikedItemKeys: true,
103+
},
104+
});
105+
106+
if (!user) {
107+
throw new NotFoundError('User not found.');
108+
}
109+
110+
const likedSet = new Set(user.likedItemKeys ?? []);
111+
const dislikedSet = new Set(user.dislikedItemKeys ?? []);
112+
const previousPreference: 'liked' | 'disliked' | 'none' =
113+
likedSet.has(itemKey)
114+
? 'liked'
115+
: dislikedSet.has(itemKey)
116+
? 'disliked'
117+
: 'none';
118+
119+
likedSet.delete(itemKey);
120+
dislikedSet.delete(itemKey);
121+
122+
if (preference === 'liked') {
123+
likedSet.add(itemKey);
124+
} else if (preference === 'disliked') {
125+
dislikedSet.add(itemKey);
126+
}
127+
128+
const likeDelta =
129+
(preference === 'liked' ? 1 : 0) -
130+
(previousPreference === 'liked' ? 1 : 0);
131+
const dislikeDelta =
132+
(preference === 'disliked' ? 1 : 0) -
133+
(previousPreference === 'disliked' ? 1 : 0);
134+
135+
await tx.user.update({
136+
where: { id: user.id },
137+
data: {
138+
likedItemKeys: Array.from(likedSet),
139+
dislikedItemKeys: Array.from(dislikedSet),
140+
},
141+
});
142+
143+
if (likeDelta !== 0 || dislikeDelta !== 0) {
144+
// Create count row if missing
145+
await tx.itemPreferenceCounts.upsert({
146+
where: { itemKey },
147+
create: {
148+
itemKey,
149+
numLikes: 0,
150+
numDislikes: 0,
151+
},
152+
update: {},
153+
});
154+
// Apply deltas
155+
await tx.itemPreferenceCounts.update({
156+
where: { itemKey },
157+
data: {
158+
numLikes: { increment: likeDelta },
159+
numDislikes: { increment: dislikeDelta },
160+
},
161+
});
162+
}
163+
},
164+
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
165+
);
166+
break;
167+
} catch (err) {
168+
if (
169+
isSerializationFailure(err) &&
170+
attempt < SERIALIZABLE_TX_MAX_ATTEMPTS - 1
171+
) {
172+
continue;
173+
}
174+
throw err;
175+
}
176+
}
177+
178+
return res.status(200).json({ message: 'Item preference updated.' });
179+
} catch (error) {
180+
return next(error);
181+
}
182+
};
183+
68184
export const addFavoriteItem = async (
69185
req: Request,
70186
res: Response,

src/users/userRouter.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@ import {
99
removeFavoriteEatery,
1010
removeFavoriteItem,
1111
removeFcmToken,
12+
setItemPreference,
1213
} from './userController.js';
1314
import { getMe } from './userController.js';
1415
import {
1516
favoriteEaterySchema,
1617
favoriteItemSchema,
1718
fcmTokenSchema,
19+
itemPreferenceSchema,
1820
} from './users.schema.js';
1921

2022
const router = Router();
@@ -23,6 +25,12 @@ router.get('/me', getMe);
2325
router.post('/fcm-token', validateRequest(fcmTokenSchema), addFcmToken);
2426
router.delete('/fcm-token', validateRequest(fcmTokenSchema), removeFcmToken);
2527

28+
router.post(
29+
'/preferences',
30+
validateRequest(itemPreferenceSchema),
31+
setItemPreference,
32+
);
33+
2634
router.post(
2735
'/favorites/items',
2836
validateRequest(favoriteItemSchema),

src/users/users.schema.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ export const fcmTokenSchema = z.object({
66
}),
77
});
88

9+
export const itemPreferenceSchema = z.object({
10+
body: z.object({
11+
name: z.string().nonempty('Item name is required'),
12+
cornellId: z.number().int('cornellId must be an integer'),
13+
preference: z.enum(['liked', 'disliked', 'none']),
14+
}),
15+
});
16+
917
export const favoriteItemSchema = z.object({
1018
body: z.object({
1119
name: z.string().nonempty('Item name is required'),

src/utils/itemKey.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
/** Key for an item at a specific eatery (matches User liked/disliked arrays) */
2+
export const makeItemKey = (itemName: string, cornellId: number): string =>
3+
`${itemName}|${cornellId}`;

0 commit comments

Comments
 (0)