Skip to content

Commit 70ee5bc

Browse files
authored
Merge pull request #1718 from Mephistic/move-next-digest-at
Make Profiles Source of Truth for Notification Settings
2 parents eb8e5d4 + 461a998 commit 70ee5bc

File tree

10 files changed

+92
-37
lines changed

10 files changed

+92
-37
lines changed

.prettierignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,4 @@ dist
1414
coverage
1515
storybook-static
1616
llm
17+
playwright-report

components/db/profile/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export type Profile = {
3535
senator?: ProfileMember
3636
public?: boolean
3737
notificationFrequency?: Frequency
38+
nextDigestAt?: FirebaseFirestore.Timestamp
3839
about?: string
3940
social?: SocialLinks
4041
profileImage?: string

firestore.rules

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ service cloud.firestore {
2929
function doesNotChangeRole() {
3030
return !request.resource.data.diff(resource.data).affectedKeys().hasAny(['role'])
3131
}
32+
function doesNotChangeNextDigestAt() {
33+
// Only admins/automatic processes should be able to change the
34+
// email digest notification times
35+
return !request.resource.data.diff(resource.data).affectedKeys().hasAny(['nextDigestAt'])
36+
}
3237
// either the change doesn't include the public field,
3338
// or the user is a base user (i.e. not an org)
3439
function validPublicChange() {
@@ -47,7 +52,7 @@ service cloud.firestore {
4752

4853
// Allow users to make updates except to delete their profile or set the role field.
4954
// Only admins can delete a user profile or set the user role field.
50-
allow update: if validUser() && doesNotChangeRole() && validPublicChange()
55+
allow update: if validUser() && doesNotChangeRole() && validPublicChange() && doesNotChangeNextDigestAt()
5156
}
5257
// Allow querying publications individually or with a collection group.
5358
match /{path=**}/publishedTestimony/{id} {

functions/src/notifications/deliverNotifications.ts

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import * as fs from "fs"
55
import { Timestamp } from "../firebase"
66
import { getNextDigestAt, getNotificationStartDate } from "./helpers"
77
import { startOfDay } from "date-fns"
8-
import { TestimonySubmissionNotificationFields, User } from "./types"
8+
import { TestimonySubmissionNotificationFields, Profile } from "./types"
99
import {
1010
BillDigest,
1111
NotificationEmailDigest,
@@ -44,38 +44,42 @@ const deliverEmailNotifications = async () => {
4444
prepareHandlebars()
4545
console.log("Handlebars helpers and partials prepared")
4646

47-
const usersSnapshot = await db
48-
.collection("users")
47+
const profilesSnapshot = await db
48+
.collection("profiles")
4949
.where("nextDigestAt", "<=", now)
5050
.get()
5151

52-
const emailPromises = usersSnapshot.docs.map(async userDoc => {
53-
const user = userDoc.data() as User
54-
if (!user || !user.notificationFrequency) {
55-
console.log(`User ${userDoc.id} has no notificationFrequency - skipping`)
52+
const emailPromises = profilesSnapshot.docs.map(async profileDoc => {
53+
const profile = profileDoc.data() as Profile
54+
if (!profile || !profile.notificationFrequency) {
55+
console.log(
56+
`User ${profileDoc.id} has no notificationFrequency - skipping`
57+
)
5658
return
5759
}
5860

59-
const verifiedEmail = await getVerifiedUserEmail(userDoc.id)
61+
const verifiedEmail = await getVerifiedUserEmail(profileDoc.id)
6062
if (!verifiedEmail) {
6163
console.log(
62-
`Skipping user ${userDoc.id} because they have no verified email address`
64+
`Skipping user ${profileDoc.id} because they have no verified email address`
6365
)
6466
return
6567
}
6668

6769
const digestData = await buildDigestData(
68-
userDoc.id,
70+
profileDoc.id,
6971
now,
70-
user.notificationFrequency
72+
profile.notificationFrequency
7173
)
7274

7375
// If there are no new notifications, don't send an email
7476
if (
7577
digestData.numBillsWithNewTestimony === 0 &&
7678
digestData.numUsersWithNewTestimony === 0
7779
) {
78-
console.log(`No new notifications for ${userDoc.id} - not sending email`)
80+
console.log(
81+
`No new notifications for ${profileDoc.id} - not sending email`
82+
)
7983
} else {
8084
const htmlString = renderToHtmlString(digestData)
8185

@@ -90,13 +94,13 @@ const deliverEmailNotifications = async () => {
9094
createdAt: Timestamp.now()
9195
})
9296

93-
console.log(`Saved email message to user ${userDoc.id}`)
97+
console.log(`Saved email message to user ${profileDoc.id}`)
9498
}
9599

96-
const nextDigestAt = getNextDigestAt(user.notificationFrequency)
97-
await userDoc.ref.update({ nextDigestAt })
100+
const nextDigestAt = getNextDigestAt(profile.notificationFrequency)
101+
await profileDoc.ref.update({ nextDigestAt })
98102

99-
console.log(`Updated nextDigestAt for ${userDoc.id} to ${nextDigestAt}`)
103+
console.log(`Updated nextDigestAt for ${profileDoc.id} to ${nextDigestAt}`)
100104
})
101105

102106
// Wait for all email documents to be created

functions/src/notifications/types.ts

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,6 @@ import { Frequency } from "../auth/types"
22
import { BillHistory } from "../bills/types"
33
import { Timestamp } from "../firebase"
44

5-
// This should probably live somewhere else once other code starts caring about this
6-
export interface User {
7-
email?: string
8-
notificationFrequency?: Frequency
9-
nextDigestAt?: Timestamp
10-
}
11-
125
export interface Notification {
136
type: string
147
updateTime: Timestamp
@@ -70,3 +63,8 @@ export interface BillHistoryUpdateNotificationFields {
7063
}
7164
createdAt: FirebaseFirestore.Timestamp
7265
}
66+
67+
export interface Profile {
68+
notificationFrequency?: Frequency
69+
nextDigestAt?: FirebaseFirestore.Timestamp
70+
}

functions/src/notifications/updateUserNotificationFrequency.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,13 @@ export const updateUserNotificationFrequency = functions.firestore
3535
return null
3636
}
3737

38-
// Update user document in the 'users' collection
38+
// Update the profile document to include the computed `nextDigestAt`
3939
await admin
4040
.firestore()
41-
.collection("users")
41+
.collection("profiles")
4242
.doc(userId)
4343
.set(
4444
{
45-
notificationFrequency: notificationFrequency,
4645
nextDigestAt: getNextDigestAt(notificationFrequency)
4746
},
4847
{ merge: true }
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/*
2+
This script is intended to backfill the `nextDigestAt` field
3+
into the `profiles` collection. This field is used to determine
4+
when the next notifications digest email should be sent to a user
5+
based on their set `notificationFrequency`.
6+
7+
This script can be safely removed after the `nextDigestAt` field
8+
has been added to the profiles collection.
9+
*/
10+
11+
import { getNextDigestAt } from "../../functions/src/notifications/helpers"
12+
import { Profile } from "../../components/db/profile/types"
13+
import { Script } from "./types"
14+
import { Boolean, Record } from "runtypes"
15+
16+
const Args = Record({
17+
dryRun: Boolean
18+
})
19+
20+
export const script: Script = async ({ db, auth, args }) => {
21+
const profilesSnapshot = await db.collection("profiles").get()
22+
23+
const updatePromises = profilesSnapshot.docs.map(async profileDoc => {
24+
const profile = profileDoc.data() as Profile
25+
26+
if (profile.notificationFrequency) {
27+
const nextDigestAt = getNextDigestAt(profile.notificationFrequency)
28+
29+
if (!args.dryRun) {
30+
await profileDoc.ref.update({ nextDigestAt })
31+
}
32+
console.log(
33+
`Updated nextDigestAt for ${profileDoc.id} to ${nextDigestAt}`
34+
)
35+
} else {
36+
console.log(
37+
`Profile ${profileDoc.id} does not have notificationFrequency - skipping`
38+
)
39+
}
40+
})
41+
42+
await Promise.all(updatePromises)
43+
console.log("Backfill complete")
44+
}

scripts/firebase-admin/backfillNotificationFrequency.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
// DEPRECATED - This will no longer be useful after we've moved notificationFrequency to profiles
2+
// This should no longer be run and will be removed in a future update
3+
14
import { Script } from "./types"
25
import { listAllUsers } from "./list-all-users"
36

@@ -9,7 +12,7 @@ export const script: Script = async ({ db, auth }) => {
912

1013
for (const user of allUsers) {
1114
// Get user document from Firestore
12-
const userDoc = db.collection("users").doc(user.uid)
15+
const userDoc = db.collection("profiles").doc(user.uid)
1316
const doc = await userDoc.get()
1417

1518
// If the user document exists in Firestore

scripts/firebase-admin/backfillUserEmails.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
// DEPRECATED - This will no longer be useful going forward
2+
// We plan to rely on the `profiles` collection for casual email use
3+
// and the firebase-auth API where we need verified email addresses
4+
// This should no longer be run and will be removed in a future update
5+
16
import { UserRecord } from "firebase-admin/auth"
27
import { Auth } from "functions/src/types"
38
import { Script } from "./types"

yarn.lock

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6369,15 +6369,10 @@ camelize@^1.0.0:
63696369
resolved "https://registry.yarnpkg.com/camelize/-/camelize-1.0.1.tgz#89b7e16884056331a35d6b5ad064332c91daa6c3"
63706370
integrity sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==
63716371

6372-
caniuse-lite@^1.0.30001406:
6373-
version "1.0.30001568"
6374-
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001568.tgz#53fa9297273c9a977a560663f48cbea1767518b7"
6375-
integrity sha512-vSUkH84HontZJ88MiNrOau1EBrCqEQYgkC5gIySiDlpsm8sGVrhU7Kx4V6h0tnqaHzIHZv08HlJIwPbL4XL9+A==
6376-
6377-
caniuse-lite@^1.0.30001565:
6378-
version "1.0.30001572"
6379-
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001572.tgz#1ccf7dc92d2ee2f92ed3a54e11b7b4a3041acfa0"
6380-
integrity sha512-1Pbh5FLmn5y4+QhNyJE9j3/7dK44dGB83/ZMjv/qJk86TvDbjk0LosiZo0i0WB0Vx607qMX9jYrn1VLHCkN4rw==
6372+
caniuse-lite@^1.0.30001406, caniuse-lite@^1.0.30001565:
6373+
version "1.0.30001700"
6374+
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001700.tgz"
6375+
integrity sha512-2S6XIXwaE7K7erT8dY+kLQcpa5ms63XlRkMkReXjle+kf6c5g38vyMl+Z5y8dSxOFDhcFe+nxnn261PLxBSQsQ==
63816376

63826377
cardinal@^2.1.1:
63836378
version "2.1.1"

0 commit comments

Comments
 (0)