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+ }
0 commit comments