1
- // Path: functions/src/shared/deliverNotifications.ts
2
- // Function that finds all notification feed documents that are ready to be digested and emails them to the user.
3
- // Creates an email document in /notifications_mails to queue up the send, which is done by email/emailDelivery.ts
4
-
5
- // runs at least every 24 hours, but can be more or less frequent, depending on the value stored in the user's userNotificationFeed document, as well as a nextDigestTime value stored in the user's userNotificationFeed document.
6
-
7
- // Import necessary Firebase modules and libraries
8
1
import * as functions from "firebase-functions"
9
2
import * as admin from "firebase-admin"
10
3
import * as handlebars from "handlebars"
11
4
import * as helpers from "../email/helpers"
12
5
import * as fs from "fs"
13
6
import { Timestamp } from "../firebase"
7
+ import { getNextDigestAt , getNotificationStartDate } from "./helpers"
8
+ import { startOfDay } from "date-fns"
9
+ import { TestimonySubmissionNotificationFields , User } from "./types"
10
+ import {
11
+ BillDigest ,
12
+ NotificationEmailDigest ,
13
+ Position ,
14
+ UserDigest
15
+ } from "../email/types"
14
16
15
17
// Get a reference to the Firestore database
16
18
const db = admin . firestore ( )
17
19
const path = require ( "path" )
18
20
19
21
// Define Handlebars helper functions
20
22
handlebars . registerHelper ( "toLowerCase" , helpers . toLowerCase )
21
-
22
23
handlebars . registerHelper ( "noUpdatesFormat" , helpers . noUpdatesFormat )
23
-
24
24
handlebars . registerHelper ( "isDefined" , helpers . isDefined )
25
-
26
- // Function to register partials for the email template
27
25
function registerPartials ( directoryPath : string ) {
28
26
const filenames = fs . readdirSync ( directoryPath )
29
27
@@ -42,84 +40,39 @@ function registerPartials(directoryPath: string) {
42
40
}
43
41
} )
44
42
}
45
-
46
- // Define the deliverNotifications function
47
- export const deliverNotifications = functions . pubsub
48
- . schedule ( "every 24 hours" )
49
- . onRun ( async context => {
50
- // Get the current timestamp
51
- const now = Timestamp . fromDate ( new Date ( ) )
52
-
53
- // check if the nextDigestAt is less than the current timestamp, so that we know it's time to send the digest
54
- // if nextDigestAt does not equal null, then the user has a notification digest scheduled
55
- const subscriptionSnapshot = await db
56
- . collectionGroup ( "activeTopicSubscriptions" )
57
- . where ( "nextDigestAt" , "<" , now )
58
- . get ( )
59
-
60
- // Iterate through each feed, load up all undelivered notification documents, and process them into a digest
61
- const emailPromises = subscriptionSnapshot . docs . map ( async doc => {
62
- const subscriptions = doc . data ( )
63
-
64
- const { uid } = subscriptions
65
-
66
- interface User {
67
- notificationFrequency : string
68
- email : string
69
- }
70
-
71
- // Fetch the user document
72
- const userDoc = await db . collection ( "users" ) . doc ( uid ) . get ( )
73
-
74
- if ( ! userDoc . exists || ! userDoc . data ( ) ) {
75
- console . warn (
76
- `User document with id ${ uid } does not exist or has no data.`
77
- )
78
- return // Skip processing for this user
79
- }
80
-
81
- const userData : User = userDoc . data ( ) as User
82
-
83
- if ( ! ( "notificationFrequency" in userData ) || ! ( "email" in userData ) ) {
84
- console . warn (
85
- `User document with id ${ uid } does not have notificationFrequency and/or email property.`
86
- )
87
- return // Skip processing for this user
88
- }
89
-
90
- const { notificationFrequency, email } = userData
91
-
92
- // Get the undelivered notification documents
93
- const notificationsSnapshot = await db
94
- . collection ( `users/${ uid } /userNotificationFeed` )
95
- . where ( "delivered" , "==" , false )
96
- . get ( )
97
-
98
- // Process notifications into a digest type
99
- const digestData = notificationsSnapshot . docs . map ( notificationDoc => {
100
- const notification = notificationDoc . data ( )
101
- // Process and structure the notification data for display in the email template
102
- // ...
103
-
104
- return notification
105
- } )
106
-
107
- // Register partials for the email template
108
- const partialsDir = "/app/functions/lib/email/partials/"
109
- registerPartials ( partialsDir )
110
-
111
- // Render the email template using the digest data
112
- const emailTemplate = "/app/functions/lib/email/digestEmail.handlebars"
113
- const templateSource = fs . readFileSync (
114
- path . join ( __dirname , emailTemplate ) ,
115
- "utf8"
116
- )
117
- const compiledTemplate = handlebars . compile ( templateSource )
118
- const htmlString = compiledTemplate ( { digestData } )
43
+ const NUM_BILLS_TO_DISPLAY = 4
44
+ const NUM_USERS_TO_DISPLAY = 4
45
+ const NUM_TESTIMONIES_TO_DISPLAY = 6
46
+
47
+ const PARTIALS_DIR = "/app/functions/lib/email/partials/"
48
+ const EMAIL_TEMPLATE_PATH = "/app/functions/lib/email/digestEmail.handlebars"
49
+
50
+ // TODO: Batching (at both user + email level)?
51
+ // Going to wait until we have a better idea of the performance impact
52
+ const deliverEmailNotifications = async ( ) => {
53
+ const now = Timestamp . fromDate ( startOfDay ( new Date ( ) ) )
54
+
55
+ const usersSnapshot = await db
56
+ . collection ( "users" )
57
+ . where ( "nextDigestAt" , "<=" , now )
58
+ . get ( )
59
+
60
+ const emailPromises = usersSnapshot . docs . map ( async userDoc => {
61
+ const user = userDoc . data ( ) as User
62
+ const digestData = await buildDigestData ( user , userDoc . id , now )
63
+
64
+ // If there are no new notifications, don't send an email
65
+ if (
66
+ digestData . numBillsWithNewTestimony === 0 &&
67
+ digestData . numUsersWithNewTestimony === 0
68
+ ) {
69
+ console . log ( `No new notifications for ${ userDoc . id } - not sending email` )
70
+ } else {
71
+ const htmlString = renderToHtmlString ( digestData )
119
72
120
73
// Create an email document in /notifications_mails to queue up the send
121
74
await db . collection ( "notifications_mails" ) . add ( {
122
- to : [ email ] ,
75
+ to : [ user . email ] ,
123
76
message : {
124
77
subject : "Your Notifications Digest" ,
125
78
text : "" , // blank because we're sending HTML
@@ -128,45 +81,153 @@ export const deliverNotifications = functions.pubsub
128
81
createdAt : Timestamp . now ( )
129
82
} )
130
83
131
- // Mark the notifications as delivered
132
- const updatePromises = notificationsSnapshot . docs . map ( notificationDoc =>
133
- notificationDoc . ref . update ( { delivered : true } )
134
- )
135
- await Promise . all ( updatePromises )
136
-
137
- // Update nextDigestAt timestamp for the current feed
138
- let nextDigestAt
139
-
140
- // Get the amount of milliseconds for the notificationFrequency
141
- switch ( notificationFrequency ) {
142
- case "Daily" :
143
- nextDigestAt = Timestamp . fromMillis (
144
- now . toMillis ( ) + 24 * 60 * 60 * 1000
145
- )
146
- break
147
- case "Weekly" :
148
- nextDigestAt = Timestamp . fromMillis (
149
- now . toMillis ( ) + 7 * 24 * 60 * 60 * 1000
150
- )
151
- break
152
- case "Monthly" :
153
- const monthAhead = new Date ( now . toDate ( ) )
154
- monthAhead . setMonth ( monthAhead . getMonth ( ) + 1 )
155
- nextDigestAt = Timestamp . fromDate ( monthAhead )
156
- break
157
- case "None" :
158
- nextDigestAt = null
159
- break
160
- default :
161
- console . error (
162
- `Unknown notification frequency: ${ notificationFrequency } `
163
- )
164
- break
84
+ console . log ( `Saved email message to user ${ userDoc . id } ` )
85
+ }
86
+
87
+ const nextDigestAt = getNextDigestAt ( user . notificationFrequency )
88
+ await userDoc . ref . update ( { nextDigestAt } )
89
+
90
+ console . log ( `Updated nextDigestAt for ${ userDoc . id } to ${ nextDigestAt } ` )
91
+ } )
92
+
93
+ // Wait for all email documents to be created
94
+ await Promise . all ( emailPromises )
95
+ }
96
+
97
+ // TODO: Unit tests
98
+ const buildDigestData = async ( user : User , userId : string , now : Timestamp ) => {
99
+ const startDate = getNotificationStartDate ( user . notificationFrequency , now )
100
+
101
+ const notificationsSnapshot = await db
102
+ . collection ( `users/${ userId } /userNotificationFeed` )
103
+ . where ( "notification.type" , "==" , "testimony" ) // Email digest only cares about testimony
104
+ . where ( "notification.timestamp" , ">=" , startDate )
105
+ . where ( "notification.timestamp" , "<" , now )
106
+ . get ( )
107
+
108
+ const billsById : { [ billId : string ] : BillDigest } = { }
109
+ const usersById : { [ userId : string ] : UserDigest } = { }
110
+
111
+ notificationsSnapshot . docs . forEach ( notificationDoc => {
112
+ const { notification } =
113
+ notificationDoc . data ( ) as TestimonySubmissionNotificationFields
114
+
115
+ if ( notification . isBillMatch ) {
116
+ if ( billsById [ notification . billId ] ) {
117
+ const bill = billsById [ notification . billId ]
118
+
119
+ switch ( notification . position ) {
120
+ case "endorse" :
121
+ bill . endorseCount ++
122
+ break
123
+ case "neutral" :
124
+ bill . neutralCount ++
125
+ break
126
+ case "oppose" :
127
+ bill . opposeCount ++
128
+ break
129
+ default :
130
+ console . error ( `Unknown position: ${ notification . position } ` )
131
+ break
132
+ }
133
+ } else {
134
+ billsById [ notification . billId ] = {
135
+ billId : notification . billId ,
136
+ billName : notification . header ,
137
+ billCourt : notification . court ,
138
+ endorseCount : notification . position === "endorse" ? 1 : 0 ,
139
+ neutralCount : notification . position === "neutral" ? 1 : 0 ,
140
+ opposeCount : notification . position === "oppose" ? 1 : 0
141
+ }
165
142
}
143
+ }
166
144
167
- await doc . ref . update ( { nextDigestAt } )
168
- } )
145
+ if ( notification . isUserMatch ) {
146
+ const billResult = {
147
+ billId : notification . billId ,
148
+ court : notification . court ,
149
+ position : notification . position as Position
150
+ }
151
+ if ( usersById [ notification . authorUid ] ) {
152
+ const user = usersById [ notification . authorUid ]
153
+ user . bills . push ( billResult )
154
+ user . newTestimonyCount ++
155
+ } else {
156
+ usersById [ notification . authorUid ] = {
157
+ userId : notification . authorUid ,
158
+ userName : notification . subheader ,
159
+ bills : [ billResult ] ,
160
+ newTestimonyCount : 1
161
+ }
162
+ }
163
+ }
164
+ } )
169
165
170
- // Wait for all email documents to be created
171
- await Promise . all ( emailPromises )
166
+ const bills = Object . values ( billsById ) . sort ( ( a , b ) => {
167
+ return (
168
+ b . endorseCount +
169
+ b . neutralCount +
170
+ b . opposeCount -
171
+ ( a . endorseCount + a . neutralCount + a . opposeCount )
172
+ )
172
173
} )
174
+
175
+ const users = Object . values ( usersById )
176
+ . map ( userDigest => {
177
+ return {
178
+ ...userDigest ,
179
+ bills : userDigest . bills . slice ( 0 , NUM_TESTIMONIES_TO_DISPLAY )
180
+ }
181
+ } )
182
+ . sort ( ( a , b ) => b . newTestimonyCount - a . newTestimonyCount )
183
+
184
+ const digestData = {
185
+ notificationFrequency : user . notificationFrequency ,
186
+ startDate : startDate . toDate ( ) ,
187
+ endDate : now . toDate ( ) ,
188
+ bills : bills . slice ( 0 , NUM_BILLS_TO_DISPLAY ) ,
189
+ numBillsWithNewTestimony : bills . length ,
190
+ users : users . slice ( 0 , NUM_USERS_TO_DISPLAY ) ,
191
+ numUsersWithNewTestimony : users . length
192
+ }
193
+
194
+ return digestData
195
+ }
196
+
197
+ const renderToHtmlString = ( digestData : NotificationEmailDigest ) => {
198
+ // TODO: Can we register these earlier since they're shared across all notifs - maybe at startup?
199
+ registerPartials ( PARTIALS_DIR )
200
+
201
+ console . log ( "DEBUG: Working directory: " , process . cwd ( ) )
202
+ console . log (
203
+ "DEBUG: Digest template path: " ,
204
+ path . resolve ( EMAIL_TEMPLATE_PATH )
205
+ )
206
+
207
+ const templateSource = fs . readFileSync (
208
+ path . join ( __dirname , EMAIL_TEMPLATE_PATH ) ,
209
+ "utf8"
210
+ )
211
+ const compiledTemplate = handlebars . compile ( templateSource )
212
+ return compiledTemplate ( { digestData } )
213
+ }
214
+
215
+ // Firebase Functions
216
+ export const deliverNotifications = functions . pubsub
217
+ . schedule ( "47 9 1 * 2" ) // 9:47 AM on the first day of the month and on Tuesdays
218
+ . onRun ( deliverEmailNotifications )
219
+
220
+ export const httpsDeliverNotifications = functions . https . onRequest (
221
+ async ( request , response ) => {
222
+ try {
223
+ await deliverEmailNotifications ( )
224
+
225
+ console . log ( "DEBUG: deliverNotifications completed" )
226
+
227
+ response . status ( 200 ) . send ( "Successfully delivered notifications" )
228
+ } catch ( error ) {
229
+ console . error ( "Error in deliverNotifications:" , error )
230
+ response . status ( 500 ) . send ( "Internal server error" )
231
+ }
232
+ }
233
+ )
0 commit comments