Skip to content

Commit f5a188f

Browse files
authored
Adding Notification Email Data (#1693)
* Adding type for email digest data * Extract deliverNotifications into shared function between cron and request * Remove extraneous comments * Moving nextDigestAt from individual activeTopicSubscriptions to the User record. We send digests at a user level, so it no longer makes sense to track digests individually by topic * Handling nextDigestAt edge case to prevent skipping notification days due to variance in digest runtimes * Modify deliverEmailNotifications to work off of a list of users that have pending digests, rather than by scanning the digest topics directly * Digesting user notifications into usable data for email notifications * Do not send email if there are no new notifications * fix: Removing unnecessary Timestamp import to prevent integration test error * change: Making the bills sort in the email digest computation more legible by grouping the counts by bill * Update cron job to only run on days we send out notifications
1 parent 61cfbd4 commit f5a188f

15 files changed

+294
-432
lines changed

functions/src/email/types.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { Frequency } from "../auth/types"
2+
3+
export type BillDigest = {
4+
billId: string
5+
billName: string
6+
billCourt: string
7+
endorseCount: number
8+
neutralCount: number
9+
opposeCount: number
10+
}
11+
export type Position = "endorse" | "neutral" | "oppose"
12+
export type BillResult = {
13+
billId: string
14+
court: string
15+
position: Position
16+
}
17+
export type UserDigest = {
18+
userId: string
19+
userName: string
20+
bills: BillResult[]
21+
newTestimonyCount: number // displayed bills are capped at 6
22+
}
23+
export type NotificationEmailDigest = {
24+
notificationFrequency: Frequency
25+
startDate: Date
26+
endDate: Date
27+
bills: BillDigest[] // cap of 4
28+
numBillsWithNewTestimony: number
29+
users: UserDigest[] // cap of 4
30+
numUsersWithNewTestimony: number
31+
}

functions/src/index.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,7 @@ export {
3939
httpsPublishNotifications,
4040
httpsDeliverNotifications,
4141
httpsCleanupNotifications,
42-
updateUserNotificationFrequency,
43-
updateNextDigestAt
42+
updateUserNotificationFrequency
4443
} from "./notifications"
4544

4645
export {
Lines changed: 185 additions & 124 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,27 @@
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
81
import * as functions from "firebase-functions"
92
import * as admin from "firebase-admin"
103
import * as handlebars from "handlebars"
114
import * as helpers from "../email/helpers"
125
import * as fs from "fs"
136
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"
1416

1517
// Get a reference to the Firestore database
1618
const db = admin.firestore()
1719
const path = require("path")
1820

1921
// Define Handlebars helper functions
2022
handlebars.registerHelper("toLowerCase", helpers.toLowerCase)
21-
2223
handlebars.registerHelper("noUpdatesFormat", helpers.noUpdatesFormat)
23-
2424
handlebars.registerHelper("isDefined", helpers.isDefined)
25-
26-
// Function to register partials for the email template
2725
function registerPartials(directoryPath: string) {
2826
const filenames = fs.readdirSync(directoryPath)
2927

@@ -42,84 +40,39 @@ function registerPartials(directoryPath: string) {
4240
}
4341
})
4442
}
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)
11972

12073
// Create an email document in /notifications_mails to queue up the send
12174
await db.collection("notifications_mails").add({
122-
to: [email],
75+
to: [user.email],
12376
message: {
12477
subject: "Your Notifications Digest",
12578
text: "", // blank because we're sending HTML
@@ -128,45 +81,153 @@ export const deliverNotifications = functions.pubsub
12881
createdAt: Timestamp.now()
12982
})
13083

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+
}
165142
}
143+
}
166144

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+
})
169165

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+
)
172173
})
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

Comments
 (0)