Skip to content

Commit 3e2ae53

Browse files
committed
feat: Add notification scheduler and logic
1 parent 6c546e3 commit 3e2ae53

4 files changed

Lines changed: 435 additions & 18 deletions

File tree

prisma/send-notifications.ts

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

src/firebase.ts

Lines changed: 114 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,120 @@ import admin from 'firebase-admin';
22
import { readFileSync } from 'fs';
33
import { resolve } from 'path';
44

5-
if (!admin.apps.length) {
6-
try {
7-
const serviceAccountPath = resolve(
8-
process.env.FIREBASE_SERVICE_ACCOUNT_PATH ||
9-
'./firebase-service-account.json',
10-
);
11-
const serviceAccount = JSON.parse(readFileSync(serviceAccountPath, 'utf8'));
12-
13-
admin.initializeApp({
14-
credential: admin.credential.cert(serviceAccount as admin.ServiceAccount),
15-
});
16-
} catch (error) {
17-
console.error('Failed to initialize Firebase Admin:', error);
18-
throw new Error(
19-
'Firebase configuration failed. Please check your service account file.',
20-
);
5+
import { PrismaClient } from '@prisma/client';
6+
7+
export const prisma = new PrismaClient();
8+
9+
class FirebaseService {
10+
public messaging: admin.messaging.Messaging;
11+
12+
constructor() {
13+
this.initializeFirebase();
14+
this.messaging = admin.messaging();
15+
}
16+
17+
private initializeFirebase() {
18+
if (admin.apps.length === 0) {
19+
try {
20+
const serviceAccountPath = resolve(
21+
process.env.FIREBASE_SERVICE_ACCOUNT_PATH ||
22+
'./firebase-service-account.json',
23+
);
24+
const serviceAccount = JSON.parse(
25+
readFileSync(serviceAccountPath, 'utf8'),
26+
);
27+
28+
admin.initializeApp({
29+
credential: admin.credential.cert(
30+
serviceAccount as admin.ServiceAccount,
31+
),
32+
});
33+
console.log('Firebase Admin initialized');
34+
} catch (error) {
35+
console.error('Failed to initialize Firebase Admin:', error);
36+
throw new Error(
37+
'Firebase configuration failed. Please check your service account file.',
38+
);
39+
}
40+
}
41+
}
42+
43+
public async sendToTokens(
44+
tokens: string[],
45+
title: string,
46+
body: string,
47+
data: { [key: string]: string } = {},
48+
) {
49+
if (tokens.length === 0) {
50+
return;
51+
}
52+
53+
const message: admin.messaging.MulticastMessage = {
54+
tokens: tokens,
55+
notification: {
56+
title: title,
57+
body: body,
58+
},
59+
data: data,
60+
apns: {
61+
payload: {
62+
aps: {
63+
sound: 'default',
64+
},
65+
},
66+
},
67+
android: {
68+
notification: {
69+
sound: 'default',
70+
clickAction: 'FLUTTER_NOTIFICATION_CLICK',
71+
},
72+
},
73+
};
74+
75+
try {
76+
const response = await this.messaging.sendEachForMulticast(message);
77+
console.log(`Successfully sent ${response.successCount} messages.`);
78+
79+
if (response.failureCount > 0) {
80+
console.log(`Failed to send ${response.failureCount} messages.`);
81+
const failedTokens: string[] = [];
82+
83+
response.responses.forEach((resp, idx) => {
84+
if (!resp.success) {
85+
console.error(
86+
`Failure details for token [${tokens[idx]}]:`,
87+
JSON.stringify(resp, null, 2),
88+
);
89+
failedTokens.push(tokens[idx]);
90+
}
91+
});
92+
93+
await this.cleanupFailedTokens(failedTokens);
94+
}
95+
} catch (e) {
96+
console.error('Error sending multicast message:', e);
97+
}
98+
}
99+
100+
private async cleanupFailedTokens(tokens: string[]) {
101+
if (tokens.length === 0) {
102+
return;
103+
}
104+
105+
try {
106+
await prisma.fCMToken.deleteMany({
107+
where: {
108+
token: {
109+
in: tokens,
110+
},
111+
},
112+
});
113+
console.log('Failed tokens cleaned up.');
114+
} catch (e) {
115+
console.error('Error cleaning up failed tokens:', e);
116+
}
21117
}
22118
}
23119

24-
const firebaseAdmin = admin;
25-
export default firebaseAdmin;
120+
export const firebaseService = new FirebaseService();
121+
export const firebaseAdmin = admin;

0 commit comments

Comments
 (0)