Skip to content

Commit

Permalink
Adding Notification Email Data (#1693)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
Mephistic authored Feb 5, 2025
1 parent 61cfbd4 commit f5a188f
Show file tree
Hide file tree
Showing 15 changed files with 294 additions and 432 deletions.
31 changes: 31 additions & 0 deletions functions/src/email/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Frequency } from "../auth/types"

export type BillDigest = {
billId: string
billName: string
billCourt: string
endorseCount: number
neutralCount: number
opposeCount: number
}
export type Position = "endorse" | "neutral" | "oppose"
export type BillResult = {
billId: string
court: string
position: Position
}
export type UserDigest = {
userId: string
userName: string
bills: BillResult[]
newTestimonyCount: number // displayed bills are capped at 6
}
export type NotificationEmailDigest = {
notificationFrequency: Frequency
startDate: Date
endDate: Date
bills: BillDigest[] // cap of 4
numBillsWithNewTestimony: number
users: UserDigest[] // cap of 4
numUsersWithNewTestimony: number
}
3 changes: 1 addition & 2 deletions functions/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,7 @@ export {
httpsPublishNotifications,
httpsDeliverNotifications,
httpsCleanupNotifications,
updateUserNotificationFrequency,
updateNextDigestAt
updateUserNotificationFrequency
} from "./notifications"

export {
Expand Down
309 changes: 185 additions & 124 deletions functions/src/notifications/deliverNotifications.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,27 @@
// Path: functions/src/shared/deliverNotifications.ts
// Function that finds all notification feed documents that are ready to be digested and emails them to the user.
// Creates an email document in /notifications_mails to queue up the send, which is done by email/emailDelivery.ts

// 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.

// Import necessary Firebase modules and libraries
import * as functions from "firebase-functions"
import * as admin from "firebase-admin"
import * as handlebars from "handlebars"
import * as helpers from "../email/helpers"
import * as fs from "fs"
import { Timestamp } from "../firebase"
import { getNextDigestAt, getNotificationStartDate } from "./helpers"
import { startOfDay } from "date-fns"
import { TestimonySubmissionNotificationFields, User } from "./types"
import {
BillDigest,
NotificationEmailDigest,
Position,
UserDigest
} from "../email/types"

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

// Define Handlebars helper functions
handlebars.registerHelper("toLowerCase", helpers.toLowerCase)

handlebars.registerHelper("noUpdatesFormat", helpers.noUpdatesFormat)

handlebars.registerHelper("isDefined", helpers.isDefined)

// Function to register partials for the email template
function registerPartials(directoryPath: string) {
const filenames = fs.readdirSync(directoryPath)

Expand All @@ -42,84 +40,39 @@ function registerPartials(directoryPath: string) {
}
})
}

// Define the deliverNotifications function
export const deliverNotifications = functions.pubsub
.schedule("every 24 hours")
.onRun(async context => {
// Get the current timestamp
const now = Timestamp.fromDate(new Date())

// check if the nextDigestAt is less than the current timestamp, so that we know it's time to send the digest
// if nextDigestAt does not equal null, then the user has a notification digest scheduled
const subscriptionSnapshot = await db
.collectionGroup("activeTopicSubscriptions")
.where("nextDigestAt", "<", now)
.get()

// Iterate through each feed, load up all undelivered notification documents, and process them into a digest
const emailPromises = subscriptionSnapshot.docs.map(async doc => {
const subscriptions = doc.data()

const { uid } = subscriptions

interface User {
notificationFrequency: string
email: string
}

// Fetch the user document
const userDoc = await db.collection("users").doc(uid).get()

if (!userDoc.exists || !userDoc.data()) {
console.warn(
`User document with id ${uid} does not exist or has no data.`
)
return // Skip processing for this user
}

const userData: User = userDoc.data() as User

if (!("notificationFrequency" in userData) || !("email" in userData)) {
console.warn(
`User document with id ${uid} does not have notificationFrequency and/or email property.`
)
return // Skip processing for this user
}

const { notificationFrequency, email } = userData

// Get the undelivered notification documents
const notificationsSnapshot = await db
.collection(`users/${uid}/userNotificationFeed`)
.where("delivered", "==", false)
.get()

// Process notifications into a digest type
const digestData = notificationsSnapshot.docs.map(notificationDoc => {
const notification = notificationDoc.data()
// Process and structure the notification data for display in the email template
// ...

return notification
})

// Register partials for the email template
const partialsDir = "/app/functions/lib/email/partials/"
registerPartials(partialsDir)

// Render the email template using the digest data
const emailTemplate = "/app/functions/lib/email/digestEmail.handlebars"
const templateSource = fs.readFileSync(
path.join(__dirname, emailTemplate),
"utf8"
)
const compiledTemplate = handlebars.compile(templateSource)
const htmlString = compiledTemplate({ digestData })
const NUM_BILLS_TO_DISPLAY = 4
const NUM_USERS_TO_DISPLAY = 4
const NUM_TESTIMONIES_TO_DISPLAY = 6

const PARTIALS_DIR = "/app/functions/lib/email/partials/"
const EMAIL_TEMPLATE_PATH = "/app/functions/lib/email/digestEmail.handlebars"

// TODO: Batching (at both user + email level)?
// Going to wait until we have a better idea of the performance impact
const deliverEmailNotifications = async () => {
const now = Timestamp.fromDate(startOfDay(new Date()))

const usersSnapshot = await db
.collection("users")
.where("nextDigestAt", "<=", now)
.get()

const emailPromises = usersSnapshot.docs.map(async userDoc => {
const user = userDoc.data() as User
const digestData = await buildDigestData(user, userDoc.id, now)

// If there are no new notifications, don't send an email
if (
digestData.numBillsWithNewTestimony === 0 &&
digestData.numUsersWithNewTestimony === 0
) {
console.log(`No new notifications for ${userDoc.id} - not sending email`)
} else {
const htmlString = renderToHtmlString(digestData)

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

// Mark the notifications as delivered
const updatePromises = notificationsSnapshot.docs.map(notificationDoc =>
notificationDoc.ref.update({ delivered: true })
)
await Promise.all(updatePromises)

// Update nextDigestAt timestamp for the current feed
let nextDigestAt

// Get the amount of milliseconds for the notificationFrequency
switch (notificationFrequency) {
case "Daily":
nextDigestAt = Timestamp.fromMillis(
now.toMillis() + 24 * 60 * 60 * 1000
)
break
case "Weekly":
nextDigestAt = Timestamp.fromMillis(
now.toMillis() + 7 * 24 * 60 * 60 * 1000
)
break
case "Monthly":
const monthAhead = new Date(now.toDate())
monthAhead.setMonth(monthAhead.getMonth() + 1)
nextDigestAt = Timestamp.fromDate(monthAhead)
break
case "None":
nextDigestAt = null
break
default:
console.error(
`Unknown notification frequency: ${notificationFrequency}`
)
break
console.log(`Saved email message to user ${userDoc.id}`)
}

const nextDigestAt = getNextDigestAt(user.notificationFrequency)
await userDoc.ref.update({ nextDigestAt })

console.log(`Updated nextDigestAt for ${userDoc.id} to ${nextDigestAt}`)
})

// Wait for all email documents to be created
await Promise.all(emailPromises)
}

// TODO: Unit tests
const buildDigestData = async (user: User, userId: string, now: Timestamp) => {
const startDate = getNotificationStartDate(user.notificationFrequency, now)

const notificationsSnapshot = await db
.collection(`users/${userId}/userNotificationFeed`)
.where("notification.type", "==", "testimony") // Email digest only cares about testimony
.where("notification.timestamp", ">=", startDate)
.where("notification.timestamp", "<", now)
.get()

const billsById: { [billId: string]: BillDigest } = {}
const usersById: { [userId: string]: UserDigest } = {}

notificationsSnapshot.docs.forEach(notificationDoc => {
const { notification } =
notificationDoc.data() as TestimonySubmissionNotificationFields

if (notification.isBillMatch) {
if (billsById[notification.billId]) {
const bill = billsById[notification.billId]

switch (notification.position) {
case "endorse":
bill.endorseCount++
break
case "neutral":
bill.neutralCount++
break
case "oppose":
bill.opposeCount++
break
default:
console.error(`Unknown position: ${notification.position}`)
break
}
} else {
billsById[notification.billId] = {
billId: notification.billId,
billName: notification.header,
billCourt: notification.court,
endorseCount: notification.position === "endorse" ? 1 : 0,
neutralCount: notification.position === "neutral" ? 1 : 0,
opposeCount: notification.position === "oppose" ? 1 : 0
}
}
}

await doc.ref.update({ nextDigestAt })
})
if (notification.isUserMatch) {
const billResult = {
billId: notification.billId,
court: notification.court,
position: notification.position as Position
}
if (usersById[notification.authorUid]) {
const user = usersById[notification.authorUid]
user.bills.push(billResult)
user.newTestimonyCount++
} else {
usersById[notification.authorUid] = {
userId: notification.authorUid,
userName: notification.subheader,
bills: [billResult],
newTestimonyCount: 1
}
}
}
})

// Wait for all email documents to be created
await Promise.all(emailPromises)
const bills = Object.values(billsById).sort((a, b) => {
return (
b.endorseCount +
b.neutralCount +
b.opposeCount -
(a.endorseCount + a.neutralCount + a.opposeCount)
)
})

const users = Object.values(usersById)
.map(userDigest => {
return {
...userDigest,
bills: userDigest.bills.slice(0, NUM_TESTIMONIES_TO_DISPLAY)
}
})
.sort((a, b) => b.newTestimonyCount - a.newTestimonyCount)

const digestData = {
notificationFrequency: user.notificationFrequency,
startDate: startDate.toDate(),
endDate: now.toDate(),
bills: bills.slice(0, NUM_BILLS_TO_DISPLAY),
numBillsWithNewTestimony: bills.length,
users: users.slice(0, NUM_USERS_TO_DISPLAY),
numUsersWithNewTestimony: users.length
}

return digestData
}

const renderToHtmlString = (digestData: NotificationEmailDigest) => {
// TODO: Can we register these earlier since they're shared across all notifs - maybe at startup?
registerPartials(PARTIALS_DIR)

console.log("DEBUG: Working directory: ", process.cwd())
console.log(
"DEBUG: Digest template path: ",
path.resolve(EMAIL_TEMPLATE_PATH)
)

const templateSource = fs.readFileSync(
path.join(__dirname, EMAIL_TEMPLATE_PATH),
"utf8"
)
const compiledTemplate = handlebars.compile(templateSource)
return compiledTemplate({ digestData })
}

// Firebase Functions
export const deliverNotifications = functions.pubsub
.schedule("47 9 1 * 2") // 9:47 AM on the first day of the month and on Tuesdays
.onRun(deliverEmailNotifications)

export const httpsDeliverNotifications = functions.https.onRequest(
async (request, response) => {
try {
await deliverEmailNotifications()

console.log("DEBUG: deliverNotifications completed")

response.status(200).send("Successfully delivered notifications")
} catch (error) {
console.error("Error in deliverNotifications:", error)
response.status(500).send("Internal server error")
}
}
)
Loading

0 comments on commit f5a188f

Please sign in to comment.