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