Skip to content

Commit 7d43e67

Browse files
authored
Merge pull request #7 from cuappdev/skye/notifications-infra
Add notifications infra and auth
2 parents 235f6fc + f50d2f2 commit 7d43e67

21 files changed

Lines changed: 1097 additions & 459 deletions

package-lock.json

Lines changed: 25 additions & 429 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
"format": "prettier --write '{src,test}/**/*.ts'",
1515
"scrape": "tsx prisma/scraper.ts",
1616
"scrape:scheduled": "SCHEDULED_MODE=true node dist/prisma/scraper.js",
17-
"studio": "prisma studio"
17+
"studio": "prisma studio",
18+
"send-notifications": "tsx prisma/notificationService.ts"
1819
},
1920
"repository": {
2021
"type": "git",
@@ -29,10 +30,12 @@
2930
"homepage": "https://github.com/cuappdev/eatery-blue-backend#readme",
3031
"dependencies": {
3132
"@prisma/client": "^6.16.2",
33+
"date-fns": "^4.1.0",
34+
"date-fns-tz": "^3.2.0",
3235
"dotenv": "^17.2.3",
3336
"express": "^5.1.0",
3437
"express-rate-limit": "^8.1.0",
35-
"firebase-admin": "^13.5.0",
38+
"firebase-admin": "^13.6.0",
3639
"helmet": "^8.1.0",
3740
"jsonwebtoken": "^9.0.2",
3841
"node-cache": "^5.1.2",

prisma/migrations/20251120071422_init/migration.sql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ CREATE TYPE "PaymentMethod" AS ENUM ('MEAL_SWIPE', 'CASH', 'CARD', 'BRB', 'FREE'
88
CREATE TYPE "EateryType" AS ENUM ('DINING_ROOM', 'CAFE', 'COFFEE_SHOP', 'FOOD_COURT', 'CONVENIENCE_STORE', 'CART', 'GENERAL');
99

1010
-- CreateEnum
11-
CREATE TYPE "EventType" AS ENUM ('AVAILABLE_ALL_DAY', 'BREAKFAST', 'BRUNCH', 'DINNER', 'EMPTY', 'LATE_LUNCH', 'LUNCH', 'OPEN', 'GENERAL', 'PANTS');
11+
CREATE TYPE "EventType" AS ENUM ('AVAILABLE_ALL_DAY', 'BREAKFAST', 'BRUNCH', 'DINNER', 'LATE_NIGHT', 'EMPTY', 'LATE_LUNCH', 'LUNCH', 'OPEN', 'GENERAL', 'PANTS');
1212

1313
-- CreateTable
1414
CREATE TABLE "User" (

prisma/notificationService.ts

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import cron from 'node-cron';
2+
3+
import { prisma } from '../src/prisma.js';
4+
import { sendToTokens } from '../src/utils/notifications.js';
5+
import { getQueryTimeWindow } from '../src/utils/time.js';
6+
7+
function buildMessage(
8+
matchesByEatery: Map<string, string[]>,
9+
): { title: string; body: string } {
10+
const title = 'Some of your favorites are being served today!';
11+
const eateryNames = Array.from(matchesByEatery.keys());
12+
13+
if (eateryNames.length === 1) {
14+
const eateryName = eateryNames[0];
15+
const items = matchesByEatery.get(eateryName)!;
16+
if (items.length === 1) {
17+
return { title, body: `${items[0]} is being served at ${eateryName} today.` };
18+
} else if (items.length === 2) {
19+
return {
20+
title,
21+
body: `${items[0]} and ${items[1]} are at ${eateryName} today.`,
22+
};
23+
} else {
24+
return { title, body: `Several favorites are at ${eateryName} today.` };
25+
}
26+
} else {
27+
const eateryListStr = eateryNames.join(', ');
28+
return {
29+
title,
30+
body: `Favorites found at ${eateryListStr} today. Check the app for details!`,
31+
};
32+
}
33+
}
34+
35+
export async function main() {
36+
const { windowStartUnix, windowEndUnix } = getQueryTimeWindow();
37+
38+
// build a map of { eateryName: Set<itemName> }
39+
const eateryMenuMap = new Map<string, Set<string>>();
40+
const allItemNamesToday = new Set<string>();
41+
42+
const eateries = await prisma.eatery.findMany({
43+
include: {
44+
events: {
45+
where: {
46+
startTimestamp: { lte: new Date(windowEndUnix * 1000) },
47+
endTimestamp: { gte: new Date(windowStartUnix * 1000) },
48+
},
49+
include: {
50+
menu: {
51+
include: {
52+
items: true,
53+
},
54+
},
55+
},
56+
},
57+
},
58+
});
59+
60+
for (const eatery of eateries) {
61+
if (!eateryMenuMap.has(eatery.name)) {
62+
eateryMenuMap.set(eatery.name, new Set<string>());
63+
}
64+
const itemSet = eateryMenuMap.get(eatery.name)!;
65+
for (const event of eatery.events) {
66+
for (const category of event.menu) {
67+
for (const item of category.items) {
68+
itemSet.add(item.name);
69+
allItemNamesToday.add(item.name);
70+
}
71+
}
72+
}
73+
}
74+
75+
if (allItemNamesToday.size === 0) {
76+
console.log('No items found');
77+
return;
78+
}
79+
80+
// Get all users with at least one favorite
81+
// item being served today (using the GIN index).
82+
const usersToNotify = await prisma.user.findMany({
83+
where: {
84+
favoritedItemNames: {
85+
hasSome: Array.from(allItemNamesToday),
86+
},
87+
fcmTokens: {
88+
some: {},
89+
},
90+
},
91+
include: {
92+
fcmTokens: true,
93+
},
94+
});
95+
96+
if (usersToNotify.length === 0) {
97+
console.log('No users to notify');
98+
return;
99+
}
100+
101+
// Loop through filtered users and build their aggregated notification
102+
for (const user of usersToNotify) {
103+
const userFavorites = new Set(user.favoritedItemNames);
104+
const userMatchesByEatery = new Map<string, string[]>();
105+
106+
for (const [eateryName, itemSet] of eateryMenuMap.entries()) {
107+
const matches = Array.from(itemSet).filter((itemName) =>
108+
userFavorites.has(itemName),
109+
);
110+
if (matches.length > 0) {
111+
userMatchesByEatery.set(eateryName, matches.sort());
112+
}
113+
}
114+
115+
if (userMatchesByEatery.size > 0) {
116+
const { title, body } = buildMessage(userMatchesByEatery);
117+
const tokens = user.fcmTokens.map((t) => t.token);
118+
119+
const dataPayload = {
120+
matches: JSON.stringify(Object.fromEntries(userMatchesByEatery)),
121+
};
122+
123+
try {
124+
await sendToTokens(tokens, title, body, dataPayload);
125+
} catch (e) {
126+
console.error(`Failed to send notification for user ${user.id}:`, e);
127+
}
128+
}
129+
}
130+
}
131+
132+
let isRunning = false;
133+
134+
async function runNotificationsSafely() {
135+
if (isRunning) {
136+
console.log('[Notifications] Job is already running, skipping.');
137+
return;
138+
}
139+
140+
isRunning = true;
141+
console.log(`[Notifications] Starting run at ${new Date().toISOString()}`);
142+
143+
try {
144+
await main();
145+
console.log('[Notifications] Completed successfully.');
146+
} catch (error) {
147+
console.error('[Notifications] Failed:', error);
148+
} finally {
149+
isRunning = false;
150+
}
151+
}
152+
153+
export function startNotificationScheduler() {
154+
const cronExpression = process.env.NOTI_CRON_SCHEDULE || '0 8,17 * * *';
155+
156+
console.log('[Notifications] Initializing scheduler...');
157+
console.log(`[Notifications] Schedule: ${cronExpression}`);
158+
159+
const task = cron.schedule(cronExpression, runNotificationsSafely, {
160+
timezone: 'America/New_York',
161+
});
162+
163+
console.log('[Notifications] Scheduler started.');
164+
165+
return task;
166+
}
167+
168+
169+
if (process.env.SCHEDULED_MODE === 'true') {
170+
startNotificationScheduler();
171+
console.log('[Notifications] Notification scheduler is running. Press Ctrl+C to stop.');
172+
const gracefulShutdown = async () => {
173+
console.log('[Notifications] Shutting down gracefully...');
174+
await prisma.$disconnect();
175+
process.exit(0);
176+
};
177+
178+
process.on('SIGTERM', gracefulShutdown);
179+
process.on('SIGINT', gracefulShutdown);
180+
} else {
181+
main()
182+
.catch((e) => {
183+
console.error('Error during scraping:', e);
184+
process.exit(1);
185+
})
186+
.finally(async () => {
187+
await prisma.$disconnect();
188+
});
189+
}

prisma/schema.prisma

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ model Report {
101101
model Eatery {
102102
id Int @id @default(autoincrement())
103103
cornellId Int? @unique
104-
announcements String[]
104+
announcements String[] @default([])
105105
name String
106106
shortName String
107107
about String
@@ -190,4 +190,4 @@ model DietaryPreference {
190190
model Allergen {
191191
name String @id @unique
192192
items Item[]
193-
}
193+
}

prisma/scraper.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -243,9 +243,9 @@ function transformStaticEatery(rawStaticEatery: RawStaticEatery) {
243243
menuSummary: menuSummary,
244244
imageUrl: imageUrl,
245245
campusArea: mapCampusArea(rawStaticEatery.campusArea),
246-
onlineOrderUrl: rawStaticEatery.onlineOrderUrl,
247-
contactPhone: rawStaticEatery.contactPhone,
248-
contactEmail: rawStaticEatery.contactEmail,
246+
onlineOrderUrl: rawStaticEatery.onlineOrderUrl ?? null,
247+
contactPhone: rawStaticEatery.contactPhone ?? null,
248+
contactEmail: rawStaticEatery.contactEmail ?? null,
249249
latitude: rawStaticEatery.latitude,
250250
longitude: rawStaticEatery.longitude,
251251
location: rawStaticEatery.location,
@@ -278,9 +278,9 @@ function transformEatery(rawEatery: RawEatery) {
278278
menuSummary: 'Cornell Eatery',
279279
imageUrl: imageUrl,
280280
campusArea: mapCampusArea(rawEatery.campusArea),
281-
onlineOrderUrl: rawEatery.onlineOrderUrl,
282-
contactPhone: rawEatery.contactPhone,
283-
contactEmail: rawEatery.contactEmail,
281+
onlineOrderUrl: rawEatery.onlineOrderUrl ?? null,
282+
contactPhone: rawEatery.contactPhone ?? null,
283+
contactEmail: rawEatery.contactEmail ?? null,
284284
latitude: rawEatery.latitude,
285285
longitude: rawEatery.longitude,
286286
location: rawEatery.location,

src/auth/auth.schema.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,22 @@ export const refreshAccessTokenSchema = z.object({
1111
refreshToken: z.string().nonempty('Refresh token is required'),
1212
}),
1313
});
14+
15+
export const linkCbordAccountSchema = z.object({
16+
body: z.object({
17+
pin: z
18+
.string()
19+
.nonempty('PIN is required')
20+
.regex(/^\d+$/, 'PIN must contain only numeric characters'),
21+
sessionId: z.string().nonempty('Session ID is required'),
22+
}),
23+
});
24+
25+
export const getCbordSessionSchema = z.object({
26+
body: z.object({
27+
pin: z
28+
.string()
29+
.nonempty('PIN is required')
30+
.regex(/^\d+$/, 'PIN must contain only numeric characters'),
31+
}),
32+
});

src/auth/authController.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Request, Response } from 'express';
22

3+
import { cbordService } from '../services/cbord.service.js';
34
import * as authService from './authService.js';
45

56
export const verifyDeviceUuid = async (req: Request, res: Response) => {
@@ -13,3 +14,30 @@ export const refreshAccessToken = async (req: Request, res: Response) => {
1314
const tokens = await authService.refreshAccessToken(refreshToken);
1415
return res.json(tokens);
1516
};
17+
18+
/**
19+
* @desc Links a GET/CBORD account to a deviceId via a PIN.
20+
* This is the one-time setup for the finance feature.
21+
*/
22+
export const linkCbordAccount = async (req: Request, res: Response) => {
23+
const { userId } = req.user!;
24+
const { pin, sessionId } = req.body;
25+
26+
await cbordService.createPin(String(userId), pin, sessionId);
27+
28+
// This route does NOT create the user, just links the PIN.
29+
return res.json({ message: 'GET account linked successfully.' });
30+
};
31+
32+
/**
33+
* @desc Exchanges a deviceId and PIN for a new GET/CBORD session_id.
34+
* This is the "persistent login" flow.
35+
*/
36+
export const getCbordSession = async (req: Request, res: Response) => {
37+
const { userId } = req.user!;
38+
const { pin } = req.body;
39+
40+
const newSessionId = await cbordService.authenticatePin(String(userId), pin);
41+
42+
return res.json({ sessionId: newSessionId });
43+
};

src/auth/authRouter.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
11
import { Router } from 'express';
22

3+
import { requireAuth } from '../middleware/authentication.js';
34
import { validateRequest } from '../middleware/validateRequest.js';
45
import {
6+
getCbordSessionSchema,
7+
linkCbordAccountSchema,
58
refreshAccessTokenSchema,
69
verifyDeviceUuidSchema,
710
} from './auth.schema.js';
8-
import { refreshAccessToken, verifyDeviceUuid } from './authController.js';
11+
import {
12+
getCbordSession,
13+
linkCbordAccount,
14+
refreshAccessToken,
15+
verifyDeviceUuid,
16+
} from './authController.js';
917

1018
const router = Router();
1119

@@ -19,5 +27,17 @@ router.post(
1927
validateRequest(refreshAccessTokenSchema),
2028
refreshAccessToken,
2129
);
30+
router.post(
31+
'/get/authorize',
32+
requireAuth,
33+
validateRequest(linkCbordAccountSchema),
34+
linkCbordAccount,
35+
);
36+
router.post(
37+
'/get/refresh',
38+
requireAuth,
39+
validateRequest(getCbordSessionSchema),
40+
getCbordSession,
41+
);
2242

2343
export default router;

src/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ export const EATERY_IMAGES_BASE_URL =
44
export const DEFAULT_IMAGE_URL =
55
'https://images-prod.healthline.com/hlcmsresource/images/AN_images/health-benefits-of-apples-1296x728-feature.jpg';
66

7+
/** How many hours ahead to look for events when sending notifications */
8+
export const NOTIFICATION_LOOKAHEAD_HOURS = 7;
79
export const ITUNES_LOOKUP_URL =
810
'https://itunes.apple.com/lookup?bundleId=org.cuappdev.eatery';
911

0 commit comments

Comments
 (0)