From 73a361d1a4343791045ca203ff07ca7917b225a0 Mon Sep 17 00:00:00 2001 From: Joey Zhou Date: Thu, 22 Aug 2024 15:29:12 -0700 Subject: [PATCH 01/26] feat: add subscribers email pref db functions --- src/db/tables/subscriber_email_preferences.ts | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 src/db/tables/subscriber_email_preferences.ts diff --git a/src/db/tables/subscriber_email_preferences.ts b/src/db/tables/subscriber_email_preferences.ts new file mode 100644 index 00000000000..90672f845bb --- /dev/null +++ b/src/db/tables/subscriber_email_preferences.ts @@ -0,0 +1,60 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import createDbConnection from "../connect.js"; +import { logger } from "../../app/functions/server/logging"; + +const knex = createDbConnection(); + +interface SubscriberEmailPreferences { + instant_breach_alert?: boolean; + all_emails_to_primary?: boolean; + monthly_monitor_report?: boolean; + monthly_monitor_report_at?: Date; +} + +async function addEmailPreferenceForSubscriber( + subscriberId: number, + preference: SubscriberEmailPreferences, +) { + logger.info("add_email_preference_for_subscriber", { + subscriberId, + preference, + }); + + let res; + try { + res = await knex("subscriber_email_preferences") + .insert({ + subscriber_id: subscriberId, + instant_breach_alert: preference.instant_breach_alert || true, + all_emails_to_primary: preference.all_emails_to_primary || true, + monthly_monitor_report: preference.monthly_monitor_report || true, + monthly_monitor_report_at: preference.monthly_monitor_report_at ?? "", + }) + .returning("*"); + } catch (e) { + logger.error("could_not_add_subscriber_email_preference", { + error: e as string, + }); + + throw e; + } + return res?.[0]; +} + +// async function updateEmailPreferenceForSubscriber( +// subscriberId: number, +// preference: Omit< +// SubscriberEmailPreferencesRow, +// SubscriberEmailPreferencesAutoInsertedColumns +// >, +// ) { + +// } + +export { + addEmailPreferenceForSubscriber, + // updateEmailPreferenceForSubscriber +}; From f212434b906bd071e49379e7ff2b53b4089e21aa Mon Sep 17 00:00:00 2001 From: Joey Zhou Date: Thu, 22 Aug 2024 17:11:07 -0700 Subject: [PATCH 02/26] feat: util crud for email pref table --- src/db/tables/subscriber_email_preferences.ts | 60 +++++++++++++++---- 1 file changed, 48 insertions(+), 12 deletions(-) diff --git a/src/db/tables/subscriber_email_preferences.ts b/src/db/tables/subscriber_email_preferences.ts index 90672f845bb..281098ac7cc 100644 --- a/src/db/tables/subscriber_email_preferences.ts +++ b/src/db/tables/subscriber_email_preferences.ts @@ -31,12 +31,12 @@ async function addEmailPreferenceForSubscriber( instant_breach_alert: preference.instant_breach_alert || true, all_emails_to_primary: preference.all_emails_to_primary || true, monthly_monitor_report: preference.monthly_monitor_report || true, - monthly_monitor_report_at: preference.monthly_monitor_report_at ?? "", + monthly_monitor_report_at: preference.monthly_monitor_report_at || null, }) .returning("*"); } catch (e) { - logger.error("could_not_add_subscriber_email_preference", { - error: e as string, + logger.error("error_add_subscriber_email_preference", { + exception: e as string, }); throw e; @@ -44,17 +44,53 @@ async function addEmailPreferenceForSubscriber( return res?.[0]; } -// async function updateEmailPreferenceForSubscriber( -// subscriberId: number, -// preference: Omit< -// SubscriberEmailPreferencesRow, -// SubscriberEmailPreferencesAutoInsertedColumns -// >, -// ) { +async function updateEmailPreferenceForSubscriber( + subscriberId: number, + preference: SubscriberEmailPreferences, +) { + logger.info("update_email_preference_for_subscriber", { + subscriberId, + preference, + }); + + let res; + try { + res = await knex("subscriber_email_preferences") + .where("subscriber_id", subscriberId) + .update({ ...preference }) + .returning(["*"]); + } catch (e) { + logger.error("error_update_subscriber_email_preference", { + exception: e as string, + }); + + throw e; + } + return res?.[0]; +} + +async function getEmailPreferenceForSubscriber(subscriberId: number) { + logger.info("get_email_preference_for_subscriber", { + subscriberId, + }); -// } + let res; + try { + res = await knex("subscriber_email_preferences") + .where("subscriber_id", subscriberId) + .returning(["*"]); + } catch (e) { + logger.error("error_get_subscriber_email_preference", { + exception: e as string, + }); + + throw e; + } + return res?.[0]; +} export { addEmailPreferenceForSubscriber, - // updateEmailPreferenceForSubscriber + updateEmailPreferenceForSubscriber, + getEmailPreferenceForSubscriber, }; From d34fc03000a90b51426ed1317b737b7dc79747b9 Mon Sep 17 00:00:00 2001 From: Joey Zhou Date: Fri, 23 Aug 2024 09:11:48 -0700 Subject: [PATCH 03/26] feat: migrate email pref script --- package.json | 1 + .../migrate_subscribers_email_preferences.ts | 57 +++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 src/scripts/oneoff/migrate_subscribers_email_preferences.ts diff --git a/package.json b/package.json index e40b0e23760..01f6d83e6e3 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "dev:cron:db-pull-breaches": "tsx --tsconfig tsconfig.cronjobs.json src/scripts/cronjobs/syncBreaches.ts", "dev:cron:remote-settings-pull-breaches": "tsx --tsconfig tsconfig.cronjobs.json src/scripts/cronjobs/updateBreachesInRemoteSettings.ts", "dev:cron:onerep-limits-alert": "tsx --tsconfig tsconfig.cronjobs.json src/scripts/cronjobs/onerepStatsAlert.ts", + "dev:oneoff:migrate-email-prefs": "tsx --tsconfig tsconfig.cronjobs.json src/scripts/oneoff/migrate_subscribers_email_preferences.ts", "dev:nimbus": "node --watch-path config/nimbus.yaml src/scripts/build/nimbusTypes.js", "build": "npm run get-location-data && npm run build-glean && npm run build-nimbus && next build && npm run build-cronjobs", "cloudrun": "npm run db:migrate && npm start", diff --git a/src/scripts/oneoff/migrate_subscribers_email_preferences.ts b/src/scripts/oneoff/migrate_subscribers_email_preferences.ts new file mode 100644 index 00000000000..33dee5e8234 --- /dev/null +++ b/src/scripts/oneoff/migrate_subscribers_email_preferences.ts @@ -0,0 +1,57 @@ +import createDbConnection from "../../db/connect"; +const knex = createDbConnection(); + +async function migrateData() { + const batchSize = 100; // Number of records to process at a time + let offset = 0; + let continueFetching = true; + + try { + while (continueFetching) { + // Fetch a batch of records + const subscribers = await knex("subscribers") + .select( + "id", + "all_emails_to_primary", + "monthly_monitor_report", + "monthly_monitor_report_at", + ) + .limit(batchSize) + .offset(offset); + + // Prepare batch for insertion + const batch = subscribers.map((subscriber) => ({ + subscriber_id: subscriber.id, + instant_breach_alert: + subscriber.all_emails_to_primary === null ? false : true, + all_emails_to_primary: + subscriber.all_emails_to_primary === null || + subscriber.all_emails_to_primary === true, + monthly_monitor_report: subscriber.monthly_monitor_report, + monthly_monitor_report_at: subscriber.monthly_monitor_report_at, + })); + + // Bulk insert the batch + await knex("subscriber_email_preferences").insert(batch); + console.log(`Inserted ${batch.length} records from offset ${offset}.`); + + // Increment offset to fetch the next batch + offset += batchSize; + + // If the number of records fetched is less than the batch size, stop fetching + if (subscribers.length < batchSize) { + continueFetching = false; + } + } + + console.log(`Data migration completed successfully. Total: ${offset}`); + } catch (error) { + console.error("Error during data migration:", error); + } finally { + // Close the database connection + await knex.destroy(); + } +} + +// Run the migration function +migrateData(); From 2b1cfe9acaf6ca30688583dabea2c716e5697137 Mon Sep 17 00:00:00 2001 From: Joey Zhou Date: Fri, 23 Aug 2024 09:52:57 -0700 Subject: [PATCH 04/26] fix: change script to only insert when no record exists --- src/scripts/oneoff/migrate_subscribers_email_preferences.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/scripts/oneoff/migrate_subscribers_email_preferences.ts b/src/scripts/oneoff/migrate_subscribers_email_preferences.ts index 33dee5e8234..5f89d084a85 100644 --- a/src/scripts/oneoff/migrate_subscribers_email_preferences.ts +++ b/src/scripts/oneoff/migrate_subscribers_email_preferences.ts @@ -32,7 +32,10 @@ async function migrateData() { })); // Bulk insert the batch - await knex("subscriber_email_preferences").insert(batch); + await knex("subscriber_email_preferences") + .insert(batch) + .onConflict("subscriber_id") + .ignore(); console.log(`Inserted ${batch.length} records from offset ${offset}.`); // Increment offset to fetch the next batch From 8484972b9b6112de005fbbbacc4ff9f72893a8ad Mon Sep 17 00:00:00 2001 From: Joey Zhou Date: Fri, 23 Aug 2024 13:57:13 -0700 Subject: [PATCH 05/26] fix: unsub emails util funcs --- src/app/api/utils/email.tsx | 46 ++++++++++++++++++ .../api/v1/user/unsubscribe-email/route.ts | 47 +++++++++++++++++++ src/utils/fxa.ts | 7 +++ 3 files changed, 100 insertions(+) create mode 100644 src/app/api/v1/user/unsubscribe-email/route.ts diff --git a/src/app/api/utils/email.tsx b/src/app/api/utils/email.tsx index b9e0194d216..c4cc465ef5d 100644 --- a/src/app/api/utils/email.tsx +++ b/src/app/api/utils/email.tsx @@ -10,6 +10,9 @@ import { VerifyEmailAddressEmail } from "../../../emails/templates/verifyEmailAd import { sanitizeSubscriberRow } from "../../functions/server/sanitize"; import { getL10n } from "../../functions/l10n/serverComponents"; import { BadRequestError } from "../../../utils/error"; +import { captureException } from "@sentry/node"; +import { logger } from "../../functions/server/logging.js"; +import { getSha2 } from "../../../utils/fxa.js"; export async function sendVerificationEmail( user: SubscriberRow, @@ -54,3 +57,46 @@ export async function sendVerificationEmail( ), ); } + +export function generateUnsubscribeLink(email: string) { + const secret = process.env.NEXTAUTH_SECRET; + + try { + if (!secret) { + throw new Error( + "generateUnsubscribeLink: env var NEXTAUTH_SECRET is not set", + ); + } + + const key = secret + email; + const unsubToken = getSha2(key); + return `{process.env.SERVER_URL}/api/v1/unsubscribe-email?email=${email}&token=${unsubToken}`; + } catch (e) { + logger.error("generate_unsubscribe_link", { + exception: e as string, + }); + captureException(e); + return null; + } +} + +export function verifyUnsubscribeToken(email: string, unsubToken: string) { + const secret = process.env.NEXTAUTH_SECRET; + + try { + if (!secret) { + throw new Error( + "verifyUnsubscribeToken: env var NEXTAUTH_SECRET is not set", + ); + } + + const key = secret + email; + return unsubToken === getSha2(key); + } catch (e) { + logger.error("verify_unsubscribe_token", { + exception: e as string, + }); + captureException(e); + return false; + } +} diff --git a/src/app/api/v1/user/unsubscribe-email/route.ts b/src/app/api/v1/user/unsubscribe-email/route.ts new file mode 100644 index 00000000000..3ee955d0300 --- /dev/null +++ b/src/app/api/v1/user/unsubscribe-email/route.ts @@ -0,0 +1,47 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; +import { logger } from "../../../../functions/server/logging"; +import { verifyUnsubscribeToken } from "../../../utils/email"; + +export function GET(req: NextRequest) { + try { + const { searchParams } = new URL(req.url); + const email = searchParams.get("email"); + const unsubToken = searchParams.get("token"); + + if (!email || !unsubToken) { + return NextResponse.json( + { + success: false, + message: "email and token are required url parameters.", + }, + { status: 400 }, + ); + } + + const tokenVerified = verifyUnsubscribeToken(email, unsubToken); + if (tokenVerified) { + // TODO: db function to mark email as opt out + logger.debug("unsubscribe_email_success"); + return NextResponse.json({ success: true }, { status: 200 }); + } else { + logger.warn("unsubscribe_email_unauthorized_token", { + email, + unsubToken, + }); + return NextResponse.json( + { success: false, message: "Unauthorized unsubscribe token" }, + { status: 401 }, + ); + } + } catch (e) { + logger.error("unsubscribe_email", { + exception: e as string, + }); + return NextResponse.json({ success: false }, { status: 500 }); + } +} diff --git a/src/utils/fxa.ts b/src/utils/fxa.ts index da7d8902c57..1b7cc40d826 100644 --- a/src/utils/fxa.ts +++ b/src/utils/fxa.ts @@ -332,11 +332,18 @@ function getSha1(email: crypto.BinaryLike) { return crypto.createHash("sha1").update(email).digest("hex"); } +// TODO: Add unit test when changing this code: +/* c8 ignore next 3 */ +function getSha2(str: crypto.BinaryLike) { + return crypto.createHash("sha256").update(str).digest("hex"); +} + export { refreshOAuthTokens, destroyOAuthToken, revokeOAuthTokens, getSha1, + getSha2, getSubscriptions, getBillingAndSubscriptions, deleteSubscription, From 61a623e3d172316289554d7eb4ce0a7c221000c1 Mon Sep 17 00:00:00 2001 From: Joey Zhou Date: Fri, 23 Aug 2024 14:17:28 -0700 Subject: [PATCH 06/26] fix: build --- src/app/api/utils/email.tsx | 13 ++++++++----- src/utils/fxa.ts | 7 ------- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/src/app/api/utils/email.tsx b/src/app/api/utils/email.tsx index c4cc465ef5d..7f25056d40f 100644 --- a/src/app/api/utils/email.tsx +++ b/src/app/api/utils/email.tsx @@ -11,8 +11,7 @@ import { sanitizeSubscriberRow } from "../../functions/server/sanitize"; import { getL10n } from "../../functions/l10n/serverComponents"; import { BadRequestError } from "../../../utils/error"; import { captureException } from "@sentry/node"; -import { logger } from "../../functions/server/logging.js"; -import { getSha2 } from "../../../utils/fxa.js"; +import crypto from "crypto"; export async function sendVerificationEmail( user: SubscriberRow, @@ -70,9 +69,9 @@ export function generateUnsubscribeLink(email: string) { const key = secret + email; const unsubToken = getSha2(key); - return `{process.env.SERVER_URL}/api/v1/unsubscribe-email?email=${email}&token=${unsubToken}`; + return `${process.env.SERVER_URL}/api/v1/unsubscribe-email?email=${email}&token=${unsubToken}`; } catch (e) { - logger.error("generate_unsubscribe_link", { + console.error("generate_unsubscribe_link", { exception: e as string, }); captureException(e); @@ -93,10 +92,14 @@ export function verifyUnsubscribeToken(email: string, unsubToken: string) { const key = secret + email; return unsubToken === getSha2(key); } catch (e) { - logger.error("verify_unsubscribe_token", { + console.error("verify_unsubscribe_token", { exception: e as string, }); captureException(e); return false; } } + +function getSha2(str: crypto.BinaryLike) { + return crypto.createHash("sha256").update(str).digest("hex"); +} diff --git a/src/utils/fxa.ts b/src/utils/fxa.ts index 1b7cc40d826..da7d8902c57 100644 --- a/src/utils/fxa.ts +++ b/src/utils/fxa.ts @@ -332,18 +332,11 @@ function getSha1(email: crypto.BinaryLike) { return crypto.createHash("sha1").update(email).digest("hex"); } -// TODO: Add unit test when changing this code: -/* c8 ignore next 3 */ -function getSha2(str: crypto.BinaryLike) { - return crypto.createHash("sha256").update(str).digest("hex"); -} - export { refreshOAuthTokens, destroyOAuthToken, revokeOAuthTokens, getSha1, - getSha2, getSubscriptions, getBillingAndSubscriptions, deleteSubscription, From 933ae406981e8d664a5c7bf283851f97bf9be249 Mon Sep 17 00:00:00 2001 From: Joey Zhou Date: Fri, 23 Aug 2024 15:02:39 -0700 Subject: [PATCH 07/26] feat: unsubscribe monthly report --- .../api/v1/user/unsubscribe-email/route.ts | 5 +- .../api/v1/user/update-comm-option/route.ts | 2 +- src/db/tables/subscribers.js | 117 ++++++++++-------- 3 files changed, 72 insertions(+), 52 deletions(-) diff --git a/src/app/api/v1/user/unsubscribe-email/route.ts b/src/app/api/v1/user/unsubscribe-email/route.ts index 3ee955d0300..23da7557b18 100644 --- a/src/app/api/v1/user/unsubscribe-email/route.ts +++ b/src/app/api/v1/user/unsubscribe-email/route.ts @@ -6,8 +6,9 @@ import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; import { logger } from "../../../../functions/server/logging"; import { verifyUnsubscribeToken } from "../../../utils/email"; +import { unsubscribeMonthlyMonitorReportForEmail } from "../../../../../db/tables/subscribers"; -export function GET(req: NextRequest) { +export async function GET(req: NextRequest) { try { const { searchParams } = new URL(req.url); const email = searchParams.get("email"); @@ -25,7 +26,7 @@ export function GET(req: NextRequest) { const tokenVerified = verifyUnsubscribeToken(email, unsubToken); if (tokenVerified) { - // TODO: db function to mark email as opt out + await unsubscribeMonthlyMonitorReportForEmail(email); logger.debug("unsubscribe_email_success"); return NextResponse.json({ success: true }, { status: 200 }); } else { diff --git a/src/app/api/v1/user/update-comm-option/route.ts b/src/app/api/v1/user/update-comm-option/route.ts index d82fcd3cd95..4b60c4a51d3 100644 --- a/src/app/api/v1/user/update-comm-option/route.ts +++ b/src/app/api/v1/user/update-comm-option/route.ts @@ -52,7 +52,7 @@ export async function POST(req: NextRequest) { await setAllEmailsToPrimary(subscriber, allEmailsToPrimary); } if (typeof monthlyMonitorReport === "boolean") { - await setMonthlyMonitorReport(subscriber, monthlyMonitorReport); + await setMonthlyMonitorReport(subscriber.id, monthlyMonitorReport); } return NextResponse.json({ diff --git a/src/db/tables/subscribers.js b/src/db/tables/subscribers.js index f04118a66fb..044665e11f6 100644 --- a/src/db/tables/subscribers.js +++ b/src/db/tables/subscribers.js @@ -14,7 +14,7 @@ const MONITOR_PREMIUM_CAPABILITY = "monitor"; */ // Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy /* c8 ignore start */ -async function getSubscribersByHashes (hashes) { +async function getSubscribersByHashes(hashes) { return await knex('subscribers').whereIn('primary_sha1', hashes).andWhere('primary_verified', '=', true) } /* c8 ignore stop */ @@ -24,7 +24,7 @@ async function getSubscribersByHashes (hashes) { */ // Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy /* c8 ignore start */ -async function getSubscriberById (id) { +async function getSubscriberById(id) { const [subscriber] = await knex('subscribers').where({ id }) @@ -39,7 +39,7 @@ async function getSubscriberById (id) { */ // Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy /* c8 ignore start */ -async function getSubscriberByFxaUid (uid) { +async function getSubscriberByFxaUid(uid) { const [subscriber] = await knex('subscribers').where({ fxa_uid: uid }) @@ -55,7 +55,7 @@ async function getSubscriberByFxaUid (uid) { */ // Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy /* c8 ignore start */ -async function getSubscriberByEmail (email) { +async function getSubscriberByEmail(email) { const [subscriber] = await knex('subscribers').where({ primary_email: email, primary_verified: true @@ -74,7 +74,7 @@ async function getSubscriberByEmail (email) { */ // Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy /* c8 ignore start */ -async function updatePrimaryEmail (subscriber, updatedEmail) { +async function updatePrimaryEmail(subscriber, updatedEmail) { const trx = await knex.transaction() let subscriberTableUpdated; try { @@ -126,7 +126,7 @@ async function updatePrimaryEmail (subscriber, updatedEmail) { */ // Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy /* c8 ignore start */ -async function updateFxAData (subscriber, fxaAccessToken, fxaRefreshToken, sessionExpiresAt, fxaProfileData) { +async function updateFxAData(subscriber, fxaAccessToken, fxaRefreshToken, sessionExpiresAt, fxaProfileData) { const fxaUID = JSON.parse(fxaProfileData).uid const updated = await knex('subscribers') .where('id', '=', subscriber.id) @@ -156,7 +156,7 @@ async function updateFxAData (subscriber, fxaAccessToken, fxaRefreshToken, sessi */ // Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy /* c8 ignore start */ -async function updateFxATokens (subscriber, fxaAccessToken, fxaRefreshToken, sessionExpiresAt) { +async function updateFxATokens(subscriber, fxaAccessToken, fxaRefreshToken, sessionExpiresAt) { const updateResp = await knex('subscribers') .where('id', '=', subscriber.id) .update({ @@ -168,7 +168,7 @@ async function updateFxATokens (subscriber, fxaAccessToken, fxaRefreshToken, ses updated_at: knex.fn.now(), }) .returning('*'); - return (Array.isArray(updateResp) && updateResp.length > 0) ? updateResp[0] : null; + return (Array.isArray(updateResp) && updateResp.length > 0) ? updateResp[0] : null; } /* c8 ignore stop */ @@ -179,7 +179,7 @@ async function updateFxATokens (subscriber, fxaAccessToken, fxaRefreshToken, ses */ // Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy /* c8 ignore start */ -async function getFxATokens (subscriberId) { +async function getFxATokens(subscriberId) { const res = await knex('subscribers') .first('fxa_access_token', 'fxa_refresh_token', 'fxa_session_expiry') .where('id', subscriberId) @@ -196,7 +196,7 @@ async function getFxATokens (subscriberId) { */ // Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy /* c8 ignore start */ -async function updateFxAProfileData (subscriber, fxaProfileData) { +async function updateFxAProfileData(subscriber, fxaProfileData) { await knex('subscribers').where('id', subscriber.id) .update({ // @ts-ignore Our old code is inconsistent about passing in objects or serialised strings, @@ -216,7 +216,7 @@ async function updateFxAProfileData (subscriber, fxaProfileData) { */ // Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy /* c8 ignore start */ -async function setAllEmailsToPrimary (subscriber, allEmailsToPrimary) { +async function setAllEmailsToPrimary(subscriber, allEmailsToPrimary) { const updated = await knex('subscribers') .where('id', subscriber.id) .update({ @@ -232,14 +232,14 @@ async function setAllEmailsToPrimary (subscriber, allEmailsToPrimary) { /* c8 ignore stop */ /** - * @param {import("knex/types/tables").SubscriberRow} subscriber + * @param {number} subscriberId * @param {boolean} monthlyMonitorReport */ // Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy /* c8 ignore start */ -async function setMonthlyMonitorReport (subscriber, monthlyMonitorReport) { +async function setMonthlyMonitorReport(subscriberId, monthlyMonitorReport) { const updated = await knex('subscribers') - .where('id', subscriber.id) + .where('id', subscriberId) .update({ monthly_monitor_report: monthlyMonitorReport, // @ts-ignore knex.fn.now() results in it being set to a date, @@ -261,7 +261,7 @@ async function setMonthlyMonitorReport (subscriber, monthlyMonitorReport) { */ // Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy /* c8 ignore start */ -async function setBreachResolution (user, updatedBreachesResolution) { +async function setBreachResolution(user, updatedBreachesResolution) { await knex('subscribers') .where('id', user.id) .update({ @@ -276,7 +276,7 @@ async function setBreachResolution (user, updatedBreachesResolution) { // Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy /* c8 ignore start */ -async function deleteUnverifiedSubscribers () { +async function deleteUnverifiedSubscribers() { // @ts-ignore DELETE_UNVERIFIED_SUBSCRIBERS_TIMER should not be undefined const expiredDateTime = new Date(Date.now() - DELETE_UNVERIFIED_SUBSCRIBERS_TIMER * 1000) const expiredTimeStamp = expiredDateTime.toISOString() @@ -296,7 +296,7 @@ async function deleteUnverifiedSubscribers () { */ // Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy /* c8 ignore start */ -async function deleteSubscriber (sub) { +async function deleteSubscriber(sub) { console.debug('deleteSubscriber', JSON.stringify(sub)) try { await knex('subscribers').returning('id').where('fxa_uid', sub.fxa_uid).del() @@ -313,7 +313,7 @@ async function deleteSubscriber (sub) { */ // Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy /* c8 ignore start */ -async function deleteResolutionsWithEmail (id, email) { +async function deleteResolutionsWithEmail(id, email) { /** @type {any} */ const [subscriber] = await knex('subscribers').where({ id @@ -346,11 +346,11 @@ async function getPotentialSubscribersWaitingForFirstDataBrokerRemovalFixedEmail // which currently still import from the subscribers module. Hence, we've // inlined this until https://mozilla-hub.atlassian.net/browse/MNTOR-3077 is fixed. const flag = (await knex("feature_flags") - .first() - .where("name", featureFlagName) - // The `.andWhereNull` alias doesn't seem to exist: - // https://github.com/knex/knex/issues/1881#issuecomment-275433906 - .whereNull("deleted_at")); + .first() + .where("name", featureFlagName) + // The `.andWhereNull` alias doesn't seem to exist: + // https://github.com/knex/knex/issues/1881#issuecomment-275433906 + .whereNull("deleted_at")); if (!flag?.is_enabled || !flag?.modified_at) { return []; @@ -390,7 +390,7 @@ async function getPotentialSubscribersWaitingForFirstDataBrokerRemovalFixedEmail */ // Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy /* c8 ignore start */ -async function getSubscribersWaitingForMonthlyEmail (options = {}) { +async function getSubscribersWaitingForMonthlyEmail(options = {}) { // I'm explicitly referencing the type here, so that these lines of code will // show up as errors when we remove it from the flag list: /** @type {import("./featureFlags.js").FeatureFlagName} */ @@ -401,11 +401,11 @@ async function getSubscribersWaitingForMonthlyEmail (options = {}) { // which currently still import from the subscribers module. Hence, we've // inlined this until https://mozilla-hub.atlassian.net/browse/MNTOR-3077 is fixed. const flag = (await knex("feature_flags") - .first() - .where("name", featureFlagName) - // The `.andWhereNull` alias doesn't seem to exist: - // https://github.com/knex/knex/issues/1881#issuecomment-275433906 - .whereNull("deleted_at")); + .first() + .where("name", featureFlagName) + // The `.andWhereNull` alias doesn't seem to exist: + // https://github.com/knex/knex/issues/1881#issuecomment-275433906 + .whereNull("deleted_at")); if (!flag?.is_enabled) { return []; @@ -455,7 +455,7 @@ async function getSubscribersWaitingForMonthlyEmail (options = {}) { */ // Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy /* c8 ignore start */ -async function updateMonthlyEmailTimestamp (email) { +async function updateMonthlyEmailTimestamp(email) { const res = await knex('subscribers') .update({ monthly_email_at: 'now', @@ -471,17 +471,36 @@ async function updateMonthlyEmailTimestamp (email) { /* c8 ignore stop */ /** - * Unsubscribe user from monthly unresolved breach emails + * Unsubscribe user from Monthly Monitor Report * - * @param {string} token User verification token - * @deprecated Delete as a part of MNTOR-3077 + * @param {string} email User email */ // Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy /* c8 ignore start */ -async function updateMonthlyEmailOptout (token) { - await knex('subscribers') - .update('monthly_email_optout', true) - .where('primary_verification_token', token) +async function unsubscribeMonthlyMonitorReportForEmail(email) { + const sub = await knex('subscribers') + .where({ "primary_email": email, "primary_verified": true }) + .first() + + // if we found email in "subscribers" table, opt out right away + // if not, attempt to look for it in "emails" table and opt out every subscriber that has that email linked as secondary + if (sub) { + await setMonthlyMonitorReport(sub.id, false) + } else { + const emailRows = await knex("email_addresses") + .where({ "email": email, "verified": true }) + .returning("subscriber_id") + + if (emailRows.length > 0) { + for (const r of emailRows) { + await setMonthlyMonitorReport(r.subscriber_id, false) + } + } else { + const errMsg = `unsubscribeMonthlyMonitorReportForEmail: Could not find email - ${email}` + console.error(errMsg) + throw new Error(errMsg) + } + } } /* c8 ignore stop */ @@ -490,7 +509,7 @@ async function updateMonthlyEmailOptout (token) { */ // Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy /* c8 ignore start */ -async function markFirstDataBrokerRemovalFixedEmailAsJustSent (subscriber) { +async function markFirstDataBrokerRemovalFixedEmailAsJustSent(subscriber) { const affectedSubscribers = await knex("subscribers") .update({ first_broker_removal_email_sent: true, @@ -513,7 +532,7 @@ async function markFirstDataBrokerRemovalFixedEmailAsJustSent (subscriber) { */ // Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy /* c8 ignore start */ -async function markMonthlyActivityEmailAsJustSent (subscriber) { +async function markMonthlyActivityEmailAsJustSent(subscriber) { const affectedSubscribers = await knex("subscribers") .update({ // @ts-ignore knex.fn.now() results in it being set to a date, @@ -538,7 +557,7 @@ async function markMonthlyActivityEmailAsJustSent (subscriber) { */ // Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy /* c8 ignore start */ -async function getOnerepProfileId (subscriberId) { +async function getOnerepProfileId(subscriberId) { const res = await knex('subscribers') .select('onerep_profile_id') .where('id', subscriberId) @@ -551,7 +570,7 @@ async function getOnerepProfileId (subscriberId) { */ // Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy /* c8 ignore start */ -function getSubscribersWithUnresolvedBreachesQuery () { +function getSubscribersWithUnresolvedBreachesQuery() { return knex('subscribers') .whereRaw('monthly_email_optout IS NOT TRUE') .whereRaw("greatest(created_at, monthly_email_at) < (now() - interval '1 month')") @@ -564,7 +583,7 @@ function getSubscribersWithUnresolvedBreachesQuery () { */ // Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy /* c8 ignore start */ -async function getSubscribersWithUnresolvedBreaches (limit = 0) { +async function getSubscribersWithUnresolvedBreaches(limit = 0) { let query = getSubscribersWithUnresolvedBreachesQuery() .select('primary_email', 'primary_verification_token', 'breach_stats', 'signup_language') if (limit) { @@ -579,7 +598,7 @@ async function getSubscribersWithUnresolvedBreaches (limit = 0) { */ // Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy /* c8 ignore start */ -async function getSubscribersWithUnresolvedBreachesCount () { +async function getSubscribersWithUnresolvedBreachesCount() { const query = getSubscribersWithUnresolvedBreachesQuery() // @ts-ignore This will return a string const count = parseInt((await query.count({ count: '*' }))[0].count) @@ -595,7 +614,7 @@ async function getSubscribersWithUnresolvedBreachesCount () { */ // Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy /* c8 ignore start */ -async function joinEmailAddressesToSubscriber (subscriber) { +async function joinEmailAddressesToSubscriber(subscriber) { if (subscriber) { const emailAddressRecords = await knex('email_addresses').where({ subscriber_id: subscriber.id @@ -614,7 +633,7 @@ async function joinEmailAddressesToSubscriber (subscriber) { */ // Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy /* c8 ignore start */ -async function deleteOnerepProfileId (subscriberId) { +async function deleteOnerepProfileId(subscriberId) { return await knex('subscribers') .where('id', subscriberId) .update({ @@ -632,7 +651,7 @@ async function deleteOnerepProfileId (subscriberId) { */ // Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy /* c8 ignore start */ -async function incrementSignInCountForEligibleFreeUser (fxaId) { +async function incrementSignInCountForEligibleFreeUser(fxaId) { return await knex('subscribers') .where('fxa_uid', fxaId) .whereNotNull('onerep_profile_id') @@ -646,7 +665,7 @@ async function incrementSignInCountForEligibleFreeUser (fxaId) { */ // Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy /* c8 ignore start */ -async function getSignInCount (subscriberId) { +async function getSignInCount(subscriberId) { const res = await knex('subscribers') .select('sign_in_count') .where('id', subscriberId) @@ -660,7 +679,7 @@ async function getSignInCount (subscriberId) { */ async function unresolveAllBreaches(oneRepProfileId) { const currentDate = new Date(); - await knex('subscribers').where('onerep_profile_id', oneRepProfileId).update({'breach_resolution': null, 'updated_at': currentDate}); + await knex('subscribers').where('onerep_profile_id', oneRepProfileId).update({ 'breach_resolution': null, 'updated_at': currentDate }); } /* c8 ignore stop */ @@ -684,7 +703,6 @@ export { getPotentialSubscribersWaitingForFirstDataBrokerRemovalFixedEmail, getSubscribersWaitingForMonthlyEmail, updateMonthlyEmailTimestamp, - updateMonthlyEmailOptout, markFirstDataBrokerRemovalFixedEmailAsJustSent, markMonthlyActivityEmailAsJustSent, deleteUnverifiedSubscribers, @@ -694,5 +712,6 @@ export { incrementSignInCountForEligibleFreeUser, getSignInCount, unresolveAllBreaches, + unsubscribeMonthlyMonitorReportForEmail, knex as knexSubscribers } From a59d99bbe96de94fa13dfb24badb226df02f4940 Mon Sep 17 00:00:00 2001 From: Joey Zhou Date: Tue, 27 Aug 2024 13:12:41 -0700 Subject: [PATCH 08/26] fix: add db utils for email pref table --- package.json | 1 - src/db/tables/subscriber_email_preferences.ts | 91 ++++++++++++++++--- .../migrate_subscribers_email_preferences.ts | 60 ------------ 3 files changed, 76 insertions(+), 76 deletions(-) delete mode 100644 src/scripts/oneoff/migrate_subscribers_email_preferences.ts diff --git a/package.json b/package.json index 168ac5c69ca..37dd2950475 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,6 @@ "dev:cron:db-pull-breaches": "tsx --tsconfig tsconfig.cronjobs.json src/scripts/cronjobs/syncBreaches.ts", "dev:cron:remote-settings-pull-breaches": "tsx --tsconfig tsconfig.cronjobs.json src/scripts/cronjobs/updateBreachesInRemoteSettings.ts", "dev:cron:onerep-limits-alert": "tsx --tsconfig tsconfig.cronjobs.json src/scripts/cronjobs/onerepStatsAlert.ts", - "dev:oneoff:migrate-email-prefs": "tsx --tsconfig tsconfig.cronjobs.json src/scripts/oneoff/migrate_subscribers_email_preferences.ts", "dev:nimbus": "node --watch-path config/nimbus.yaml src/scripts/build/nimbusTypes.js", "build": "npm run get-location-data && npm run build-glean && npm run build-nimbus && next build && npm run build-cronjobs", "cloudrun": "npm run db:migrate && npm start", diff --git a/src/db/tables/subscriber_email_preferences.ts b/src/db/tables/subscriber_email_preferences.ts index 281098ac7cc..77487172769 100644 --- a/src/db/tables/subscriber_email_preferences.ts +++ b/src/db/tables/subscriber_email_preferences.ts @@ -7,16 +7,24 @@ import { logger } from "../../app/functions/server/logging"; const knex = createDbConnection(); -interface SubscriberEmailPreferences { - instant_breach_alert?: boolean; - all_emails_to_primary?: boolean; +// NOTE: The "subscriber_email_preferences" table only has attributes for free reports +// TODO: modify the CRUD utils after MNTOR-3557 +interface SubscriberFreeEmailPreferences { + primary_email?: string; + monthly_monitor_report_free?: boolean; + monthly_monitor_report_free_at?: Date; +} + +interface SubscriberPlusEmailPreferences { monthly_monitor_report?: boolean; monthly_monitor_report_at?: Date; } +// TODO: modify after MNTOR-3557 - pref currently lives in two tables +// this function only adds email prefs for free reports async function addEmailPreferenceForSubscriber( subscriberId: number, - preference: SubscriberEmailPreferences, + preference: SubscriberFreeEmailPreferences, ) { logger.info("add_email_preference_for_subscriber", { subscriberId, @@ -28,12 +36,16 @@ async function addEmailPreferenceForSubscriber( res = await knex("subscriber_email_preferences") .insert({ subscriber_id: subscriberId, - instant_breach_alert: preference.instant_breach_alert || true, - all_emails_to_primary: preference.all_emails_to_primary || true, - monthly_monitor_report: preference.monthly_monitor_report || true, - monthly_monitor_report_at: preference.monthly_monitor_report_at || null, + primary_email: preference.primary_email || "", + monthly_monitor_report_free: + preference.monthly_monitor_report_free || true, + monthly_monitor_report_free_at: + preference.monthly_monitor_report_free_at || null, }) + .onConflict("subscriber_id") + .ignore() .returning("*"); + logger.debug("add_email_preference_for_subscriber_success"); } catch (e) { logger.error("error_add_subscriber_email_preference", { exception: e as string, @@ -46,19 +58,49 @@ async function addEmailPreferenceForSubscriber( async function updateEmailPreferenceForSubscriber( subscriberId: number, - preference: SubscriberEmailPreferences, + isFree: boolean, + preference: SubscriberFreeEmailPreferences | SubscriberPlusEmailPreferences, ) { logger.info("update_email_preference_for_subscriber", { subscriberId, + isFree, preference, }); let res; try { - res = await knex("subscriber_email_preferences") - .where("subscriber_id", subscriberId) - .update({ ...preference }) - .returning(["*"]); + if (isFree) { + res = await knex("subscriber_email_preferences") + .where("subscriber_id", subscriberId) + .update({ ...(preference as SubscriberFreeEmailPreferences) }) + .onConflict("subscriber_id") + .merge() + .returning(["*"]); + + if (res.length !== 1) { + throw new Error( + `Update subscriber ${subscriberId} failed, response: ${JSON.stringify(res)}`, + ); + } + } else { + // TODO: modify after MNTOR-3557 - pref currently lives in two tables + res = await knex("subscribers") + .where("id", subscriberId) + .update({ + ...(preference as SubscriberPlusEmailPreferences), + // @ts-ignore knex.fn.now() results in it being set to a date, + // even if it's not typed as a JS date object: + updated_at: knex.fn.now(), + }) + .returning("*"); + + if (res.length !== 1) { + throw new Error( + `Update subscriber ${subscriberId} failed, response: ${JSON.stringify(res)}`, + ); + } + } + logger.debug("update_email_preference_for_subscriber_success"); } catch (e) { logger.error("error_update_subscriber_email_preference", { exception: e as string, @@ -75,10 +117,29 @@ async function getEmailPreferenceForSubscriber(subscriberId: number) { }); let res; + // TODO: modify after MNTOR-3557 - pref currently lives in two tables, we have to join the tables try { - res = await knex("subscriber_email_preferences") - .where("subscriber_id", subscriberId) + res = await knex + .select( + "subscribers.id", + "primary_email", + "all_emails_to_primary", + "monthly_monitor_report", + "monthly_monitor_report_at", + "first_broker_removal_email_sent", + ) + .from("subscribers") + .where("subscribers.id", subscriberId) + .innerJoin( + "subscriber_email_preferences", + "subscribers.id", + "subscriber_email_preferences.subscriber_id", + ) .returning(["*"]); + logger.debug("get_email_preference_for_subscriber_success"); + logger.debug( + `getEmailPreferenceForSubscriber innerjoin: ${JSON.stringify(res)}`, + ); } catch (e) { logger.error("error_get_subscriber_email_preference", { exception: e as string, diff --git a/src/scripts/oneoff/migrate_subscribers_email_preferences.ts b/src/scripts/oneoff/migrate_subscribers_email_preferences.ts deleted file mode 100644 index 5f89d084a85..00000000000 --- a/src/scripts/oneoff/migrate_subscribers_email_preferences.ts +++ /dev/null @@ -1,60 +0,0 @@ -import createDbConnection from "../../db/connect"; -const knex = createDbConnection(); - -async function migrateData() { - const batchSize = 100; // Number of records to process at a time - let offset = 0; - let continueFetching = true; - - try { - while (continueFetching) { - // Fetch a batch of records - const subscribers = await knex("subscribers") - .select( - "id", - "all_emails_to_primary", - "monthly_monitor_report", - "monthly_monitor_report_at", - ) - .limit(batchSize) - .offset(offset); - - // Prepare batch for insertion - const batch = subscribers.map((subscriber) => ({ - subscriber_id: subscriber.id, - instant_breach_alert: - subscriber.all_emails_to_primary === null ? false : true, - all_emails_to_primary: - subscriber.all_emails_to_primary === null || - subscriber.all_emails_to_primary === true, - monthly_monitor_report: subscriber.monthly_monitor_report, - monthly_monitor_report_at: subscriber.monthly_monitor_report_at, - })); - - // Bulk insert the batch - await knex("subscriber_email_preferences") - .insert(batch) - .onConflict("subscriber_id") - .ignore(); - console.log(`Inserted ${batch.length} records from offset ${offset}.`); - - // Increment offset to fetch the next batch - offset += batchSize; - - // If the number of records fetched is less than the batch size, stop fetching - if (subscribers.length < batchSize) { - continueFetching = false; - } - } - - console.log(`Data migration completed successfully. Total: ${offset}`); - } catch (error) { - console.error("Error during data migration:", error); - } finally { - // Close the database connection - await knex.destroy(); - } -} - -// Run the migration function -migrateData(); From 43a699099355f5820cb2628525391d6511ed1b7f Mon Sep 17 00:00:00 2001 From: Joey Zhou Date: Tue, 27 Aug 2024 14:06:58 -0700 Subject: [PATCH 09/26] fix: move unsub func to pref util file --- .../api/v1/user/unsubscribe-email/route.ts | 2 +- src/db/tables/subscriber_email_preferences.ts | 64 +++++++++++++++++++ src/db/tables/subscribers.js | 35 ---------- 3 files changed, 65 insertions(+), 36 deletions(-) diff --git a/src/app/api/v1/user/unsubscribe-email/route.ts b/src/app/api/v1/user/unsubscribe-email/route.ts index 23da7557b18..2917ba20a3c 100644 --- a/src/app/api/v1/user/unsubscribe-email/route.ts +++ b/src/app/api/v1/user/unsubscribe-email/route.ts @@ -6,7 +6,7 @@ import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; import { logger } from "../../../../functions/server/logging"; import { verifyUnsubscribeToken } from "../../../utils/email"; -import { unsubscribeMonthlyMonitorReportForEmail } from "../../../../../db/tables/subscribers"; +import { unsubscribeMonthlyMonitorReportForEmail } from "../../../../../db/tables/subscriber_email_preferences"; export async function GET(req: NextRequest) { try { diff --git a/src/db/tables/subscriber_email_preferences.ts b/src/db/tables/subscriber_email_preferences.ts index 77487172769..a5cc73c654a 100644 --- a/src/db/tables/subscriber_email_preferences.ts +++ b/src/db/tables/subscriber_email_preferences.ts @@ -150,8 +150,72 @@ async function getEmailPreferenceForSubscriber(subscriberId: number) { return res?.[0]; } +async function getEmailPreferenceForPrimaryEmail(email: string) { + logger.info("get_email_preference_for_primary_email", { + email, + }); + + let res; + // TODO: modify after MNTOR-3557 - pref currently lives in two tables, we have to join the tables + try { + res = await knex + .select( + "subscribers.primary_email", + "id", + "all_emails_to_primary", + "monthly_monitor_report", + "monthly_monitor_report_at", + "first_broker_removal_email_sent", + ) + .from("subscribers") + .where("subscribers.primary_email", email) + .innerJoin( + "subscriber_email_preferences", + "subscribers.id", + "subscriber_email_preferences.subscriber_id", + ) + .returning(["*"]); + logger.debug("get_email_preference_for_subscriber_success"); + logger.debug( + `getEmailPreferenceForSubscriber innerjoin: ${JSON.stringify(res)}`, + ); + } catch (e) { + logger.error("error_get_subscriber_email_preference", { + exception: e as string, + }); + + throw e; + } + return res?.[0]; +} + +// Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy +/* c8 ignore start */ +async function unsubscribeMonthlyMonitorReportForEmail(email: string) { + let sub; + try { + sub = await getEmailPreferenceForPrimaryEmail(email); + if (sub.id && sub.monthly_monitor_report_free !== null) { + await updateEmailPreferenceForSubscriber(sub.id, true, { + monthly_monitor_report_free: false, + }); + } else if (sub.id) { + await addEmailPreferenceForSubscriber(sub.id, { + monthly_monitor_report_free: false, + }); + } + } catch (e) { + logger.error("error_unsubscribe_monthly_monitor_report_for_email", { + exception: e, + }); + } +} +/* c8 ignore stop */ + export { addEmailPreferenceForSubscriber, updateEmailPreferenceForSubscriber, getEmailPreferenceForSubscriber, + getEmailPreferenceForPrimaryEmail, + unsubscribeMonthlyMonitorReportForEmail, }; diff --git a/src/db/tables/subscribers.js b/src/db/tables/subscribers.js index 044665e11f6..0966d126ec9 100644 --- a/src/db/tables/subscribers.js +++ b/src/db/tables/subscribers.js @@ -470,40 +470,6 @@ async function updateMonthlyEmailTimestamp(email) { } /* c8 ignore stop */ -/** - * Unsubscribe user from Monthly Monitor Report - * - * @param {string} email User email - */ -// Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy -/* c8 ignore start */ -async function unsubscribeMonthlyMonitorReportForEmail(email) { - const sub = await knex('subscribers') - .where({ "primary_email": email, "primary_verified": true }) - .first() - - // if we found email in "subscribers" table, opt out right away - // if not, attempt to look for it in "emails" table and opt out every subscriber that has that email linked as secondary - if (sub) { - await setMonthlyMonitorReport(sub.id, false) - } else { - const emailRows = await knex("email_addresses") - .where({ "email": email, "verified": true }) - .returning("subscriber_id") - - if (emailRows.length > 0) { - for (const r of emailRows) { - await setMonthlyMonitorReport(r.subscriber_id, false) - } - } else { - const errMsg = `unsubscribeMonthlyMonitorReportForEmail: Could not find email - ${email}` - console.error(errMsg) - throw new Error(errMsg) - } - } -} -/* c8 ignore stop */ - /** * @param {import("knex/types/tables").SubscriberRow} subscriber */ @@ -712,6 +678,5 @@ export { incrementSignInCountForEligibleFreeUser, getSignInCount, unresolveAllBreaches, - unsubscribeMonthlyMonitorReportForEmail, knex as knexSubscribers } From 6e732113cfe2bf31f6fde3b21d60dc157a3e2368 Mon Sep 17 00:00:00 2001 From: Joey Zhou Date: Tue, 27 Aug 2024 15:23:06 -0700 Subject: [PATCH 10/26] fix: unsubscribe email util func --- src/app/api/utils/email.tsx | 2 +- src/db/tables/subscriber_email_preferences.ts | 59 ++++++++++++------- 2 files changed, 40 insertions(+), 21 deletions(-) diff --git a/src/app/api/utils/email.tsx b/src/app/api/utils/email.tsx index 7f25056d40f..de6c8a2a8c8 100644 --- a/src/app/api/utils/email.tsx +++ b/src/app/api/utils/email.tsx @@ -69,7 +69,7 @@ export function generateUnsubscribeLink(email: string) { const key = secret + email; const unsubToken = getSha2(key); - return `${process.env.SERVER_URL}/api/v1/unsubscribe-email?email=${email}&token=${unsubToken}`; + return `${process.env.SERVER_URL}/api/v1/user/unsubscribe-email?email=${email}&token=${unsubToken}`; } catch (e) { console.error("generate_unsubscribe_link", { exception: e as string, diff --git a/src/db/tables/subscriber_email_preferences.ts b/src/db/tables/subscriber_email_preferences.ts index a5cc73c654a..9892389c364 100644 --- a/src/db/tables/subscriber_email_preferences.ts +++ b/src/db/tables/subscriber_email_preferences.ts @@ -4,6 +4,7 @@ import createDbConnection from "../connect.js"; import { logger } from "../../app/functions/server/logging"; +import { captureException } from "@sentry/node"; const knex = createDbConnection(); @@ -38,9 +39,9 @@ async function addEmailPreferenceForSubscriber( subscriber_id: subscriberId, primary_email: preference.primary_email || "", monthly_monitor_report_free: - preference.monthly_monitor_report_free || true, + preference.monthly_monitor_report_free ?? true, monthly_monitor_report_free_at: - preference.monthly_monitor_report_free_at || null, + preference.monthly_monitor_report_free_at ?? null, }) .onConflict("subscriber_id") .ignore() @@ -121,16 +122,18 @@ async function getEmailPreferenceForSubscriber(subscriberId: number) { try { res = await knex .select( + "subscribers.primary_email", "subscribers.id", - "primary_email", - "all_emails_to_primary", - "monthly_monitor_report", - "monthly_monitor_report_at", - "first_broker_removal_email_sent", + "subscribers.all_emails_to_primary", + "subscribers.monthly_monitor_report", + "subscribers.monthly_monitor_report_at", + "subscribers.first_broker_removal_email_sent", + "subscriber_email_preferences.monthly_monitor_report_free", + "subscriber_email_preferences.monthly_monitor_report_free_at", ) .from("subscribers") .where("subscribers.id", subscriberId) - .innerJoin( + .leftJoin( "subscriber_email_preferences", "subscribers.id", "subscriber_email_preferences.subscriber_id", @@ -138,7 +141,7 @@ async function getEmailPreferenceForSubscriber(subscriberId: number) { .returning(["*"]); logger.debug("get_email_preference_for_subscriber_success"); logger.debug( - `getEmailPreferenceForSubscriber innerjoin: ${JSON.stringify(res)}`, + `getEmailPreferenceForSubscriber left join: ${JSON.stringify(res)}`, ); } catch (e) { logger.error("error_get_subscriber_email_preference", { @@ -161,23 +164,26 @@ async function getEmailPreferenceForPrimaryEmail(email: string) { res = await knex .select( "subscribers.primary_email", - "id", - "all_emails_to_primary", - "monthly_monitor_report", - "monthly_monitor_report_at", - "first_broker_removal_email_sent", + "subscribers.id", + "subscribers.all_emails_to_primary", + "subscribers.monthly_monitor_report", + "subscribers.monthly_monitor_report_at", + "subscribers.first_broker_removal_email_sent", + "subscriber_email_preferences.monthly_monitor_report_free", + "subscriber_email_preferences.monthly_monitor_report_free_at", ) .from("subscribers") .where("subscribers.primary_email", email) - .innerJoin( + .leftJoin( "subscriber_email_preferences", "subscribers.id", "subscriber_email_preferences.subscriber_id", ) .returning(["*"]); + logger.debug("get_email_preference_for_subscriber_success"); logger.debug( - `getEmailPreferenceForSubscriber innerjoin: ${JSON.stringify(res)}`, + `getEmailPreferenceForSubscriber left join: ${JSON.stringify(res)}`, ); } catch (e) { logger.error("error_get_subscriber_email_preference", { @@ -195,19 +201,32 @@ async function unsubscribeMonthlyMonitorReportForEmail(email: string) { let sub; try { sub = await getEmailPreferenceForPrimaryEmail(email); - if (sub.id && sub.monthly_monitor_report_free !== null) { - await updateEmailPreferenceForSubscriber(sub.id, true, { + + if (sub.id && sub.monthly_monitor_report_free === null) { + // NOTE: since the pref table is new/empty, we have to assume that majority of + // the subscribers do not have an record in this new table, in that case, append + await addEmailPreferenceForSubscriber(sub.id, { + primary_email: sub.primary_email, monthly_monitor_report_free: false, }); - } else if (sub.id) { - await addEmailPreferenceForSubscriber(sub.id, { + } else if (sub.id && !sub.monthly_monitor_report_free) { + logger.info( + "unsubscribe_monthly_monitor_report_for_email_already_unsubscribed", + ); + } else if (sub.id && sub.monthly_monitor_report_free) { + await updateEmailPreferenceForSubscriber(sub.id, true, { + primary_email: sub.primary_email, monthly_monitor_report_free: false, }); + } else { + throw new Error(`cannot find subscriber with primary email: ${email}`); } } catch (e) { logger.error("error_unsubscribe_monthly_monitor_report_for_email", { exception: e, }); + captureException(e); + throw e; } } /* c8 ignore stop */ From 3067d0496f6bc0869ca567d853c01206bcbf5034 Mon Sep 17 00:00:00 2001 From: Joey Zhou Date: Wed, 28 Aug 2024 14:55:43 -0700 Subject: [PATCH 11/26] feat: set monthly report back to true whenever user sub to plus --- src/app/api/v1/fxa-rp-events/route.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/app/api/v1/fxa-rp-events/route.ts b/src/app/api/v1/fxa-rp-events/route.ts index b20c732074e..2d6b40a945c 100644 --- a/src/app/api/v1/fxa-rp-events/route.ts +++ b/src/app/api/v1/fxa-rp-events/route.ts @@ -13,6 +13,7 @@ import { updateFxAProfileData, updatePrimaryEmail, getOnerepProfileId, + setMonthlyMonitorReport, } from "../../../../db/tables/subscribers.js"; import { activateProfile, @@ -295,6 +296,9 @@ export async function POST(request: NextRequest) { // any problems with activation. await changeSubscription(subscriber, true); + // Set monthly monitor report value back to true + await setMonthlyMonitorReport(subscriber.id, true); + // MNTOR-2103: if one rep profile id doesn't exist in the db, fail immediately if (!oneRepProfileId) { logger.error("onerep_profile_not_found", { From e3ad01944e1a67a10302f60daf5f10eccf1a7af3 Mon Sep 17 00:00:00 2001 From: Joey Zhou Date: Thu, 29 Aug 2024 14:12:00 -0700 Subject: [PATCH 12/26] feat: storing randomly generated unsub token --- src/app/api/utils/email.tsx | 48 +++++++++---------- .../api/v1/user/unsubscribe-email/route.ts | 2 +- src/db/tables/subscriber_email_preferences.ts | 41 +++++++++------- src/knex-tables.d.ts | 1 + 4 files changed, 51 insertions(+), 41 deletions(-) diff --git a/src/app/api/utils/email.tsx b/src/app/api/utils/email.tsx index de6c8a2a8c8..84bb5a14726 100644 --- a/src/app/api/utils/email.tsx +++ b/src/app/api/utils/email.tsx @@ -12,6 +12,11 @@ import { getL10n } from "../../functions/l10n/serverComponents"; import { BadRequestError } from "../../../utils/error"; import { captureException } from "@sentry/node"; import crypto from "crypto"; +import { + addEmailPreferenceForSubscriber, + getEmailPreferenceForPrimaryEmail, +} from "../../../db/tables/subscriber_email_preferences"; +import { SerializedSubscriber } from "../../../next-auth.js"; export async function sendVerificationEmail( user: SubscriberRow, @@ -57,19 +62,16 @@ export async function sendVerificationEmail( ); } -export function generateUnsubscribeLink(email: string) { - const secret = process.env.NEXTAUTH_SECRET; - +export async function generateUnsubscribeLinkForSubscriber( + subscriber: SerializedSubscriber, +) { try { - if (!secret) { - throw new Error( - "generateUnsubscribeLink: env var NEXTAUTH_SECRET is not set", - ); - } - - const key = secret + email; - const unsubToken = getSha2(key); - return `${process.env.SERVER_URL}/api/v1/user/unsubscribe-email?email=${email}&token=${unsubToken}`; + const unsubToken = randomString(); + const sub = await addEmailPreferenceForSubscriber(subscriber.id, { + primary_email: subscriber.primary_email, + unsubscribe_token: unsubToken, + }); + return `${process.env.SERVER_URL}/api/v1/user/unsubscribe-email?email=${sub.primary_email}&token=${sub.unsubscribe_token}`; } catch (e) { console.error("generate_unsubscribe_link", { exception: e as string, @@ -79,18 +81,16 @@ export function generateUnsubscribeLink(email: string) { } } -export function verifyUnsubscribeToken(email: string, unsubToken: string) { - const secret = process.env.NEXTAUTH_SECRET; - +export async function verifyUnsubscribeToken( + email: string, + unsubToken: string, +) { try { - if (!secret) { - throw new Error( - "verifyUnsubscribeToken: env var NEXTAUTH_SECRET is not set", - ); + const preference = await getEmailPreferenceForPrimaryEmail(email); + if (!preference) { + return false; } - - const key = secret + email; - return unsubToken === getSha2(key); + return unsubToken === preference.unsubscribe_token; } catch (e) { console.error("verify_unsubscribe_token", { exception: e as string, @@ -100,6 +100,6 @@ export function verifyUnsubscribeToken(email: string, unsubToken: string) { } } -function getSha2(str: crypto.BinaryLike) { - return crypto.createHash("sha256").update(str).digest("hex"); +function randomString(length: number = 64) { + return crypto.randomBytes(length).toString("hex"); } diff --git a/src/app/api/v1/user/unsubscribe-email/route.ts b/src/app/api/v1/user/unsubscribe-email/route.ts index 2917ba20a3c..ebbfee5bcf9 100644 --- a/src/app/api/v1/user/unsubscribe-email/route.ts +++ b/src/app/api/v1/user/unsubscribe-email/route.ts @@ -24,7 +24,7 @@ export async function GET(req: NextRequest) { ); } - const tokenVerified = verifyUnsubscribeToken(email, unsubToken); + const tokenVerified = await verifyUnsubscribeToken(email, unsubToken); if (tokenVerified) { await unsubscribeMonthlyMonitorReportForEmail(email); logger.debug("unsubscribe_email_success"); diff --git a/src/db/tables/subscriber_email_preferences.ts b/src/db/tables/subscriber_email_preferences.ts index 9892389c364..3f1d3802b28 100644 --- a/src/db/tables/subscriber_email_preferences.ts +++ b/src/db/tables/subscriber_email_preferences.ts @@ -10,13 +10,24 @@ const knex = createDbConnection(); // NOTE: The "subscriber_email_preferences" table only has attributes for free reports // TODO: modify the CRUD utils after MNTOR-3557 -interface SubscriberFreeEmailPreferences { +interface SubscriberFreeEmailPreferencesInput { primary_email?: string; + unsubscribe_token?: string; monthly_monitor_report_free?: boolean; monthly_monitor_report_free_at?: Date; } -interface SubscriberPlusEmailPreferences { +interface SubscriberPlusEmailPreferencesInput { + monthly_monitor_report?: boolean; + monthly_monitor_report_at?: Date; +} + +interface SubscriberEmailPreferencesOutput { + id?: number; + primary_email?: string; + unsubscribe_token?: string; + monthly_monitor_report_free?: boolean; + monthly_monitor_report_free_at?: Date; monthly_monitor_report?: boolean; monthly_monitor_report_at?: Date; } @@ -25,7 +36,7 @@ interface SubscriberPlusEmailPreferences { // this function only adds email prefs for free reports async function addEmailPreferenceForSubscriber( subscriberId: number, - preference: SubscriberFreeEmailPreferences, + preference: SubscriberFreeEmailPreferencesInput, ) { logger.info("add_email_preference_for_subscriber", { subscriberId, @@ -38,13 +49,14 @@ async function addEmailPreferenceForSubscriber( .insert({ subscriber_id: subscriberId, primary_email: preference.primary_email || "", + unsubscribe_token: preference.unsubscribe_token || "", monthly_monitor_report_free: preference.monthly_monitor_report_free ?? true, monthly_monitor_report_free_at: preference.monthly_monitor_report_free_at ?? null, }) .onConflict("subscriber_id") - .ignore() + .merge() .returning("*"); logger.debug("add_email_preference_for_subscriber_success"); } catch (e) { @@ -60,7 +72,9 @@ async function addEmailPreferenceForSubscriber( async function updateEmailPreferenceForSubscriber( subscriberId: number, isFree: boolean, - preference: SubscriberFreeEmailPreferences | SubscriberPlusEmailPreferences, + preference: + | SubscriberFreeEmailPreferencesInput + | SubscriberPlusEmailPreferencesInput, ) { logger.info("update_email_preference_for_subscriber", { subscriberId, @@ -73,7 +87,7 @@ async function updateEmailPreferenceForSubscriber( if (isFree) { res = await knex("subscriber_email_preferences") .where("subscriber_id", subscriberId) - .update({ ...(preference as SubscriberFreeEmailPreferences) }) + .update({ ...(preference as SubscriberFreeEmailPreferencesInput) }) .onConflict("subscriber_id") .merge() .returning(["*"]); @@ -88,7 +102,7 @@ async function updateEmailPreferenceForSubscriber( res = await knex("subscribers") .where("id", subscriberId) .update({ - ...(preference as SubscriberPlusEmailPreferences), + ...(preference as SubscriberPlusEmailPreferencesInput), // @ts-ignore knex.fn.now() results in it being set to a date, // even if it's not typed as a JS date object: updated_at: knex.fn.now(), @@ -130,6 +144,7 @@ async function getEmailPreferenceForSubscriber(subscriberId: number) { "subscribers.first_broker_removal_email_sent", "subscriber_email_preferences.monthly_monitor_report_free", "subscriber_email_preferences.monthly_monitor_report_free_at", + "subscriber_email_preferences.unsubscribe_token", ) .from("subscribers") .where("subscribers.id", subscriberId) @@ -171,6 +186,7 @@ async function getEmailPreferenceForPrimaryEmail(email: string) { "subscribers.first_broker_removal_email_sent", "subscriber_email_preferences.monthly_monitor_report_free", "subscriber_email_preferences.monthly_monitor_report_free_at", + "subscriber_email_preferences.unsubscribe_token", ) .from("subscribers") .where("subscribers.primary_email", email) @@ -192,7 +208,7 @@ async function getEmailPreferenceForPrimaryEmail(email: string) { throw e; } - return res?.[0]; + return res?.[0] as SubscriberEmailPreferencesOutput; } // Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy @@ -202,14 +218,7 @@ async function unsubscribeMonthlyMonitorReportForEmail(email: string) { try { sub = await getEmailPreferenceForPrimaryEmail(email); - if (sub.id && sub.monthly_monitor_report_free === null) { - // NOTE: since the pref table is new/empty, we have to assume that majority of - // the subscribers do not have an record in this new table, in that case, append - await addEmailPreferenceForSubscriber(sub.id, { - primary_email: sub.primary_email, - monthly_monitor_report_free: false, - }); - } else if (sub.id && !sub.monthly_monitor_report_free) { + if (sub.id && !sub.monthly_monitor_report_free) { logger.info( "unsubscribe_monthly_monitor_report_for_email_already_unsubscribed", ); diff --git a/src/knex-tables.d.ts b/src/knex-tables.d.ts index df160dd6710..d076773aee5 100644 --- a/src/knex-tables.d.ts +++ b/src/knex-tables.d.ts @@ -194,6 +194,7 @@ declare module "knex/types/tables" { id: number; subscriber_id: number; primary_email: string; + unsubscribe_token: string; monthly_monitor_report_free: boolean; monthly_monitor_report_free_at: Date | null; } From 6301849d153ec8bf3149529e3338a8a00db5f6bd Mon Sep 17 00:00:00 2001 From: Joey Zhou Date: Thu, 29 Aug 2024 14:23:30 -0700 Subject: [PATCH 13/26] fix: update API endpoint for monitor report to include the free reports --- src/app/api/v1/user/update-comm-option/route.ts | 13 +++++++++++-- src/db/tables/subscribers.js | 15 +++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/app/api/v1/user/update-comm-option/route.ts b/src/app/api/v1/user/update-comm-option/route.ts index 4b60c4a51d3..46d7612153c 100644 --- a/src/app/api/v1/user/update-comm-option/route.ts +++ b/src/app/api/v1/user/update-comm-option/route.ts @@ -10,8 +10,9 @@ import { logger } from "../../../../functions/server/logging"; import { getSubscriberByFxaUid, setAllEmailsToPrimary, - setMonthlyMonitorReport, + isSubscriberPlus, } from "../../../../../db/tables/subscribers"; +import { updateEmailPreferenceForSubscriber } from "../../../../../db/tables/subscriber_email_preferences"; export type EmailUpdateCommTypeOfOptions = "null" | "affected" | "primary"; @@ -52,7 +53,15 @@ export async function POST(req: NextRequest) { await setAllEmailsToPrimary(subscriber, allEmailsToPrimary); } if (typeof monthlyMonitorReport === "boolean") { - await setMonthlyMonitorReport(subscriber.id, monthlyMonitorReport); + const isFree = !(await isSubscriberPlus(subscriber.id)); + const preference = isFree + ? { monthly_monitor_report_free: monthlyMonitorReport } + : { monthly_monitor_report: monthlyMonitorReport }; + await updateEmailPreferenceForSubscriber( + subscriber.id, + isFree, + preference, + ); } return NextResponse.json({ diff --git a/src/db/tables/subscribers.js b/src/db/tables/subscribers.js index 0966d126ec9..bfd13ad7982 100644 --- a/src/db/tables/subscribers.js +++ b/src/db/tables/subscribers.js @@ -649,6 +649,20 @@ async function unresolveAllBreaches(oneRepProfileId) { } /* c8 ignore stop */ +/* c8 ignore start */ +/** + * @param {number} subscriberId + */ +async function isSubscriberPlus(subscriberId) { + const res = await knex('subscribers') + .select('fxa_profile_json') + .where('id', subscriberId) + .first(); + + return (res && res.fxa_profile_json?.subscriptions?.includes(MONITOR_PREMIUM_CAPABILITY)) +} +/* c8 ignore stop */ + export { getOnerepProfileId, getSubscribersByHashes, @@ -678,5 +692,6 @@ export { incrementSignInCountForEligibleFreeUser, getSignInCount, unresolveAllBreaches, + isSubscriberPlus, knex as knexSubscribers } From a6feab01105fee03cf64c6eb3ba567a226827715 Mon Sep 17 00:00:00 2001 From: Joey Zhou Date: Thu, 29 Aug 2024 14:26:26 -0700 Subject: [PATCH 14/26] fix: guard against no unsub token --- src/app/api/utils/email.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/api/utils/email.tsx b/src/app/api/utils/email.tsx index 84bb5a14726..3eb60c20d9b 100644 --- a/src/app/api/utils/email.tsx +++ b/src/app/api/utils/email.tsx @@ -87,7 +87,7 @@ export async function verifyUnsubscribeToken( ) { try { const preference = await getEmailPreferenceForPrimaryEmail(email); - if (!preference) { + if (!preference || !preference.unsubscribe_token) { return false; } return unsubToken === preference.unsubscribe_token; From 86b3f1af1dca74235571157b30cafbaf9987e208 Mon Sep 17 00:00:00 2001 From: Joey Zhou Date: Fri, 30 Aug 2024 08:38:06 -0700 Subject: [PATCH 15/26] fix: gurantee boolean value --- src/db/tables/subscribers.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/db/tables/subscribers.js b/src/db/tables/subscribers.js index bfd13ad7982..6cf13e3b950 100644 --- a/src/db/tables/subscribers.js +++ b/src/db/tables/subscribers.js @@ -365,7 +365,7 @@ async function getPotentialSubscribersWaitingForFirstDataBrokerRemovalFixedEmail ) // ...with an OneRep account... .whereNotNull("onerep_profile_id") - // ...who haven’t received the email... + // ...who haven't received the email... .andWhere("first_broker_removal_email_sent", false) // ...and signed up after the feature flag `FirstDataBrokerRemovalFixedEmail` // has been enabled last. @@ -659,7 +659,7 @@ async function isSubscriberPlus(subscriberId) { .where('id', subscriberId) .first(); - return (res && res.fxa_profile_json?.subscriptions?.includes(MONITOR_PREMIUM_CAPABILITY)) + return !!(res && res.fxa_profile_json?.subscriptions?.includes(MONITOR_PREMIUM_CAPABILITY)); } /* c8 ignore stop */ From 3e37de54fcc4828fa4c4dac2ca27617752578e49 Mon Sep 17 00:00:00 2001 From: Joey Zhou Date: Fri, 30 Aug 2024 10:23:00 -0700 Subject: [PATCH 16/26] fix: update should upsert too --- src/app/api/utils/email.tsx | 12 ++++++---- src/db/tables/subscriber_email_preferences.ts | 24 +++++++++---------- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/src/app/api/utils/email.tsx b/src/app/api/utils/email.tsx index 3eb60c20d9b..e6c90bceff5 100644 --- a/src/app/api/utils/email.tsx +++ b/src/app/api/utils/email.tsx @@ -67,10 +67,14 @@ export async function generateUnsubscribeLinkForSubscriber( ) { try { const unsubToken = randomString(); - const sub = await addEmailPreferenceForSubscriber(subscriber.id, { - primary_email: subscriber.primary_email, - unsubscribe_token: unsubToken, - }); + const sub = await addEmailPreferenceForSubscriber( + subscriber.id, + { + primary_email: subscriber.primary_email, + unsubscribe_token: unsubToken, + }, + ["primary_email", "unsubscribe_token"], + ); return `${process.env.SERVER_URL}/api/v1/user/unsubscribe-email?email=${sub.primary_email}&token=${sub.unsubscribe_token}`; } catch (e) { console.error("generate_unsubscribe_link", { diff --git a/src/db/tables/subscriber_email_preferences.ts b/src/db/tables/subscriber_email_preferences.ts index 3f1d3802b28..7fd3eb2405b 100644 --- a/src/db/tables/subscriber_email_preferences.ts +++ b/src/db/tables/subscriber_email_preferences.ts @@ -37,6 +37,7 @@ interface SubscriberEmailPreferencesOutput { async function addEmailPreferenceForSubscriber( subscriberId: number, preference: SubscriberFreeEmailPreferencesInput, + fieldsToOverwrite: (keyof SubscriberFreeEmailPreferencesInput)[], ) { logger.info("add_email_preference_for_subscriber", { subscriberId, @@ -56,7 +57,7 @@ async function addEmailPreferenceForSubscriber( preference.monthly_monitor_report_free_at ?? null, }) .onConflict("subscriber_id") - .merge() + .merge(fieldsToOverwrite) .returning("*"); logger.debug("add_email_preference_for_subscriber_success"); } catch (e) { @@ -85,14 +86,13 @@ async function updateEmailPreferenceForSubscriber( let res; try { if (isFree) { - res = await knex("subscriber_email_preferences") - .where("subscriber_id", subscriberId) - .update({ ...(preference as SubscriberFreeEmailPreferencesInput) }) - .onConflict("subscriber_id") - .merge() - .returning(["*"]); - - if (res.length !== 1) { + res = await addEmailPreferenceForSubscriber( + subscriberId, + preference as SubscriberFreeEmailPreferencesInput, + ["monthly_monitor_report_free"], + ); + + if (!res) { throw new Error( `Update subscriber ${subscriberId} failed, response: ${JSON.stringify(res)}`, ); @@ -107,9 +107,9 @@ async function updateEmailPreferenceForSubscriber( // even if it's not typed as a JS date object: updated_at: knex.fn.now(), }) - .returning("*"); + .first(); - if (res.length !== 1) { + if (!res) { throw new Error( `Update subscriber ${subscriberId} failed, response: ${JSON.stringify(res)}`, ); @@ -123,7 +123,7 @@ async function updateEmailPreferenceForSubscriber( throw e; } - return res?.[0]; + return res; } async function getEmailPreferenceForSubscriber(subscriberId: number) { From 94698117e6ad48d5b18769c53f386a18a295127f Mon Sep 17 00:00:00 2001 From: Joey Zhou Date: Fri, 30 Aug 2024 10:49:15 -0700 Subject: [PATCH 17/26] fix: remove dup primary_email from new table --- src/app/api/utils/email.tsx | 5 ++--- src/db/tables/subscriber_email_preferences.ts | 3 --- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/app/api/utils/email.tsx b/src/app/api/utils/email.tsx index e6c90bceff5..3846e59fece 100644 --- a/src/app/api/utils/email.tsx +++ b/src/app/api/utils/email.tsx @@ -70,12 +70,11 @@ export async function generateUnsubscribeLinkForSubscriber( const sub = await addEmailPreferenceForSubscriber( subscriber.id, { - primary_email: subscriber.primary_email, unsubscribe_token: unsubToken, }, - ["primary_email", "unsubscribe_token"], + ["unsubscribe_token"], ); - return `${process.env.SERVER_URL}/api/v1/user/unsubscribe-email?email=${sub.primary_email}&token=${sub.unsubscribe_token}`; + return `${process.env.SERVER_URL}/api/v1/user/unsubscribe-email?email=${subscriber.primary_email}&token=${sub.unsubscribe_token}`; } catch (e) { console.error("generate_unsubscribe_link", { exception: e as string, diff --git a/src/db/tables/subscriber_email_preferences.ts b/src/db/tables/subscriber_email_preferences.ts index 7fd3eb2405b..6b59fc50343 100644 --- a/src/db/tables/subscriber_email_preferences.ts +++ b/src/db/tables/subscriber_email_preferences.ts @@ -11,7 +11,6 @@ const knex = createDbConnection(); // NOTE: The "subscriber_email_preferences" table only has attributes for free reports // TODO: modify the CRUD utils after MNTOR-3557 interface SubscriberFreeEmailPreferencesInput { - primary_email?: string; unsubscribe_token?: string; monthly_monitor_report_free?: boolean; monthly_monitor_report_free_at?: Date; @@ -49,7 +48,6 @@ async function addEmailPreferenceForSubscriber( res = await knex("subscriber_email_preferences") .insert({ subscriber_id: subscriberId, - primary_email: preference.primary_email || "", unsubscribe_token: preference.unsubscribe_token || "", monthly_monitor_report_free: preference.monthly_monitor_report_free ?? true, @@ -224,7 +222,6 @@ async function unsubscribeMonthlyMonitorReportForEmail(email: string) { ); } else if (sub.id && sub.monthly_monitor_report_free) { await updateEmailPreferenceForSubscriber(sub.id, true, { - primary_email: sub.primary_email, monthly_monitor_report_free: false, }); } else { From 8d63a9bfcc4bc8c98e4793e4a08408045e43676f Mon Sep 17 00:00:00 2001 From: mansaj Date: Tue, 3 Sep 2024 08:45:57 -0700 Subject: [PATCH 18/26] fix: generate the link for the unsub page instead --- src/app/api/utils/email.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/api/utils/email.tsx b/src/app/api/utils/email.tsx index 3846e59fece..f10a9ee0a04 100644 --- a/src/app/api/utils/email.tsx +++ b/src/app/api/utils/email.tsx @@ -74,7 +74,7 @@ export async function generateUnsubscribeLinkForSubscriber( }, ["unsubscribe_token"], ); - return `${process.env.SERVER_URL}/api/v1/user/unsubscribe-email?email=${subscriber.primary_email}&token=${sub.unsubscribe_token}`; + return `${process.env.SERVER_URL}/unsubscribe-from-monthly-report?token=${sub.unsubscribe_token}`; } catch (e) { console.error("generate_unsubscribe_link", { exception: e as string, From 1c99123a35cd589719bdcb18da58a0aba1e51829 Mon Sep 17 00:00:00 2001 From: Vincent Date: Wed, 4 Sep 2024 14:48:38 +0200 Subject: [PATCH 19/26] Only require token when unsubscribing Since the user is not logged in, there is no extra information given by passing in the email address, but it does add PII to our request logs. Hence, just accepting the unsubscribe token might be enough. --- src/app/api/utils/email.tsx | 24 +------------- .../api/v1/user/unsubscribe-email/route.ts | 26 ++++----------- ...0904120545_email_preference_token_index.js | 20 +++++++++++ src/db/tables/subscriber_email_preferences.ts | 33 +++++++++++-------- 4 files changed, 47 insertions(+), 56 deletions(-) create mode 100644 src/db/migrations/20240904120545_email_preference_token_index.js diff --git a/src/app/api/utils/email.tsx b/src/app/api/utils/email.tsx index f10a9ee0a04..8816b45f978 100644 --- a/src/app/api/utils/email.tsx +++ b/src/app/api/utils/email.tsx @@ -12,10 +12,7 @@ import { getL10n } from "../../functions/l10n/serverComponents"; import { BadRequestError } from "../../../utils/error"; import { captureException } from "@sentry/node"; import crypto from "crypto"; -import { - addEmailPreferenceForSubscriber, - getEmailPreferenceForPrimaryEmail, -} from "../../../db/tables/subscriber_email_preferences"; +import { addEmailPreferenceForSubscriber } from "../../../db/tables/subscriber_email_preferences"; import { SerializedSubscriber } from "../../../next-auth.js"; export async function sendVerificationEmail( @@ -84,25 +81,6 @@ export async function generateUnsubscribeLinkForSubscriber( } } -export async function verifyUnsubscribeToken( - email: string, - unsubToken: string, -) { - try { - const preference = await getEmailPreferenceForPrimaryEmail(email); - if (!preference || !preference.unsubscribe_token) { - return false; - } - return unsubToken === preference.unsubscribe_token; - } catch (e) { - console.error("verify_unsubscribe_token", { - exception: e as string, - }); - captureException(e); - return false; - } -} - function randomString(length: number = 64) { return crypto.randomBytes(length).toString("hex"); } diff --git a/src/app/api/v1/user/unsubscribe-email/route.ts b/src/app/api/v1/user/unsubscribe-email/route.ts index ebbfee5bcf9..9ff950e39ba 100644 --- a/src/app/api/v1/user/unsubscribe-email/route.ts +++ b/src/app/api/v1/user/unsubscribe-email/route.ts @@ -5,40 +5,26 @@ import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; import { logger } from "../../../../functions/server/logging"; -import { verifyUnsubscribeToken } from "../../../utils/email"; -import { unsubscribeMonthlyMonitorReportForEmail } from "../../../../../db/tables/subscriber_email_preferences"; +import { unsubscribeMonthlyMonitorReportForUnsubscribeToken } from "../../../../../db/tables/subscriber_email_preferences"; export async function GET(req: NextRequest) { try { const { searchParams } = new URL(req.url); - const email = searchParams.get("email"); const unsubToken = searchParams.get("token"); - if (!email || !unsubToken) { + if (!unsubToken) { return NextResponse.json( { success: false, - message: "email and token are required url parameters.", + message: "token is a required url parameter.", }, { status: 400 }, ); } - const tokenVerified = await verifyUnsubscribeToken(email, unsubToken); - if (tokenVerified) { - await unsubscribeMonthlyMonitorReportForEmail(email); - logger.debug("unsubscribe_email_success"); - return NextResponse.json({ success: true }, { status: 200 }); - } else { - logger.warn("unsubscribe_email_unauthorized_token", { - email, - unsubToken, - }); - return NextResponse.json( - { success: false, message: "Unauthorized unsubscribe token" }, - { status: 401 }, - ); - } + await unsubscribeMonthlyMonitorReportForUnsubscribeToken(unsubToken); + logger.debug("unsubscribe_email_success"); + return NextResponse.json({ success: true }, { status: 200 }); } catch (e) { logger.error("unsubscribe_email", { exception: e as string, diff --git a/src/db/migrations/20240904120545_email_preference_token_index.js b/src/db/migrations/20240904120545_email_preference_token_index.js new file mode 100644 index 00000000000..4e75f84e2c7 --- /dev/null +++ b/src/db/migrations/20240904120545_email_preference_token_index.js @@ -0,0 +1,20 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +export async function up(knex) { + await knex.schema + .table('subscriber_email_preferences', function(table) { + table.index("unsubscribe_token"); + }) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +export async function down(knex) { + return knex.schema.table('subscriber_email_preferences', function(table) { + table.dropIndex("unsubscribe_token"); + }) +} diff --git a/src/db/tables/subscriber_email_preferences.ts b/src/db/tables/subscriber_email_preferences.ts index 6b59fc50343..4b408e9d760 100644 --- a/src/db/tables/subscriber_email_preferences.ts +++ b/src/db/tables/subscriber_email_preferences.ts @@ -166,9 +166,9 @@ async function getEmailPreferenceForSubscriber(subscriberId: number) { return res?.[0]; } -async function getEmailPreferenceForPrimaryEmail(email: string) { - logger.info("get_email_preference_for_primary_email", { - email, +async function getEmailPreferenceForUnsubscribeToken(unsubscribeToken: string) { + logger.info("get_email_preference_for_unsubscribe_token", { + token: unsubscribeToken, }); let res; @@ -187,12 +187,12 @@ async function getEmailPreferenceForPrimaryEmail(email: string) { "subscriber_email_preferences.unsubscribe_token", ) .from("subscribers") - .where("subscribers.primary_email", email) .leftJoin( "subscriber_email_preferences", "subscribers.id", "subscriber_email_preferences.subscriber_id", ) + .where("subscriber_email_preferences.unsubscribe_token", unsubscribeToken) .returning(["*"]); logger.debug("get_email_preference_for_subscriber_success"); @@ -211,26 +211,33 @@ async function getEmailPreferenceForPrimaryEmail(email: string) { // Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy /* c8 ignore start */ -async function unsubscribeMonthlyMonitorReportForEmail(email: string) { +async function unsubscribeMonthlyMonitorReportForUnsubscribeToken( + unsubscribeToken: string, +) { let sub; try { - sub = await getEmailPreferenceForPrimaryEmail(email); + sub = await getEmailPreferenceForUnsubscribeToken(unsubscribeToken); if (sub.id && !sub.monthly_monitor_report_free) { logger.info( - "unsubscribe_monthly_monitor_report_for_email_already_unsubscribed", + "unsubscribe_monthly_monitor_report_for_unsubscribe_token_already_unsubscribed", ); } else if (sub.id && sub.monthly_monitor_report_free) { await updateEmailPreferenceForSubscriber(sub.id, true, { monthly_monitor_report_free: false, }); } else { - throw new Error(`cannot find subscriber with primary email: ${email}`); + throw new Error( + `cannot find subscriber with unsubscribe token: ${unsubscribeToken}`, + ); } } catch (e) { - logger.error("error_unsubscribe_monthly_monitor_report_for_email", { - exception: e, - }); + logger.error( + "error_unsubscribe_monthly_monitor_report_for_unsubscribe_token", + { + exception: e, + }, + ); captureException(e); throw e; } @@ -241,6 +248,6 @@ export { addEmailPreferenceForSubscriber, updateEmailPreferenceForSubscriber, getEmailPreferenceForSubscriber, - getEmailPreferenceForPrimaryEmail, - unsubscribeMonthlyMonitorReportForEmail, + getEmailPreferenceForUnsubscribeToken, + unsubscribeMonthlyMonitorReportForUnsubscribeToken, }; From 5647bec8e9705df4649ead44524eccb27de98c8f Mon Sep 17 00:00:00 2001 From: Joey Zhou Date: Wed, 4 Sep 2024 13:08:41 -0700 Subject: [PATCH 20/26] feat: unique index set --- ...0904120545_email_preference_token_index.js | 20 ------------------- 1 file changed, 20 deletions(-) delete mode 100644 src/db/migrations/20240904120545_email_preference_token_index.js diff --git a/src/db/migrations/20240904120545_email_preference_token_index.js b/src/db/migrations/20240904120545_email_preference_token_index.js deleted file mode 100644 index 4e75f84e2c7..00000000000 --- a/src/db/migrations/20240904120545_email_preference_token_index.js +++ /dev/null @@ -1,20 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -export async function up(knex) { - await knex.schema - .table('subscriber_email_preferences', function(table) { - table.index("unsubscribe_token"); - }) -} - -/** - * @param { import("knex").Knex } knex - * @returns { Promise } - */ -export async function down(knex) { - return knex.schema.table('subscriber_email_preferences', function(table) { - table.dropIndex("unsubscribe_token"); - }) -} From cf8e8df3ef5bed3f8aebca4e60e5df261029fca7 Mon Sep 17 00:00:00 2001 From: Joey Zhou Date: Wed, 4 Sep 2024 13:27:46 -0700 Subject: [PATCH 21/26] fix: review comments --- .../api/v1/user/unsubscribe-email/route.ts | 9 +++--- src/db/tables/subscriber_email_preferences.ts | 29 ++++++------------- 2 files changed, 13 insertions(+), 25 deletions(-) diff --git a/src/app/api/v1/user/unsubscribe-email/route.ts b/src/app/api/v1/user/unsubscribe-email/route.ts index 9ff950e39ba..f5d6694c72d 100644 --- a/src/app/api/v1/user/unsubscribe-email/route.ts +++ b/src/app/api/v1/user/unsubscribe-email/route.ts @@ -7,12 +7,11 @@ import type { NextRequest } from "next/server"; import { logger } from "../../../../functions/server/logging"; import { unsubscribeMonthlyMonitorReportForUnsubscribeToken } from "../../../../../db/tables/subscriber_email_preferences"; -export async function GET(req: NextRequest) { +export async function POST(req: NextRequest) { try { - const { searchParams } = new URL(req.url); - const unsubToken = searchParams.get("token"); + const { token } = await req.json(); - if (!unsubToken) { + if (!token) { return NextResponse.json( { success: false, @@ -22,7 +21,7 @@ export async function GET(req: NextRequest) { ); } - await unsubscribeMonthlyMonitorReportForUnsubscribeToken(unsubToken); + await unsubscribeMonthlyMonitorReportForUnsubscribeToken(token); logger.debug("unsubscribe_email_success"); return NextResponse.json({ success: true }, { status: 200 }); } catch (e) { diff --git a/src/db/tables/subscriber_email_preferences.ts b/src/db/tables/subscriber_email_preferences.ts index 4b408e9d760..35e050199ca 100644 --- a/src/db/tables/subscriber_email_preferences.ts +++ b/src/db/tables/subscriber_email_preferences.ts @@ -174,31 +174,20 @@ async function getEmailPreferenceForUnsubscribeToken(unsubscribeToken: string) { let res; // TODO: modify after MNTOR-3557 - pref currently lives in two tables, we have to join the tables try { - res = await knex + res = await knex("subscriber_email_preferences") .select( - "subscribers.primary_email", - "subscribers.id", - "subscribers.all_emails_to_primary", - "subscribers.monthly_monitor_report", - "subscribers.monthly_monitor_report_at", - "subscribers.first_broker_removal_email_sent", - "subscriber_email_preferences.monthly_monitor_report_free", - "subscriber_email_preferences.monthly_monitor_report_free_at", - "subscriber_email_preferences.unsubscribe_token", - ) - .from("subscribers") - .leftJoin( - "subscriber_email_preferences", - "subscribers.id", - "subscriber_email_preferences.subscriber_id", + "subscriber_id AS id", + "monthly_monitor_report_free", + "monthly_monitor_report_free_at", + "unsubscribe_token", ) - .where("subscriber_email_preferences.unsubscribe_token", unsubscribeToken) + .where("unsubscribe_token", unsubscribeToken) .returning(["*"]); - logger.debug("get_email_preference_for_subscriber_success"); - logger.debug( - `getEmailPreferenceForSubscriber left join: ${JSON.stringify(res)}`, + logger.info( + `get_email_preference_for_unsubscriber_token: ${JSON.stringify(res)}`, ); + logger.debug("get_email_preference_for_unsubscriber_token_success"); } catch (e) { logger.error("error_get_subscriber_email_preference", { exception: e as string, From 4cbb7183be8cf5bb1755b25ac5814ced7635ffd0 Mon Sep 17 00:00:00 2001 From: Joey Zhou Date: Wed, 4 Sep 2024 13:33:28 -0700 Subject: [PATCH 22/26] revert: --- src/app/api/v1/user/unsubscribe-email/route.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/app/api/v1/user/unsubscribe-email/route.ts b/src/app/api/v1/user/unsubscribe-email/route.ts index f5d6694c72d..9ff950e39ba 100644 --- a/src/app/api/v1/user/unsubscribe-email/route.ts +++ b/src/app/api/v1/user/unsubscribe-email/route.ts @@ -7,11 +7,12 @@ import type { NextRequest } from "next/server"; import { logger } from "../../../../functions/server/logging"; import { unsubscribeMonthlyMonitorReportForUnsubscribeToken } from "../../../../../db/tables/subscriber_email_preferences"; -export async function POST(req: NextRequest) { +export async function GET(req: NextRequest) { try { - const { token } = await req.json(); + const { searchParams } = new URL(req.url); + const unsubToken = searchParams.get("token"); - if (!token) { + if (!unsubToken) { return NextResponse.json( { success: false, @@ -21,7 +22,7 @@ export async function POST(req: NextRequest) { ); } - await unsubscribeMonthlyMonitorReportForUnsubscribeToken(token); + await unsubscribeMonthlyMonitorReportForUnsubscribeToken(unsubToken); logger.debug("unsubscribe_email_success"); return NextResponse.json({ success: true }, { status: 200 }); } catch (e) { From 19b164336c58183684004f93c1a1de9104cf32ed Mon Sep 17 00:00:00 2001 From: Joey Zhou Date: Thu, 5 Sep 2024 07:58:44 -0700 Subject: [PATCH 23/26] fix: add getEmailPreferenceForPrimaryEmail back --- src/db/tables/subscriber_email_preferences.ts | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/db/tables/subscriber_email_preferences.ts b/src/db/tables/subscriber_email_preferences.ts index 35e050199ca..8057c25df08 100644 --- a/src/db/tables/subscriber_email_preferences.ts +++ b/src/db/tables/subscriber_email_preferences.ts @@ -233,10 +233,54 @@ async function unsubscribeMonthlyMonitorReportForUnsubscribeToken( } /* c8 ignore stop */ +async function getEmailPreferenceForPrimaryEmail(email: string) { + logger.info("get_email_preference_for_primary_email", { + email, + }); + + let res; + // TODO: modify after MNTOR-3557 - pref currently lives in two tables, we have to join the tables + try { + res = await knex + .select( + "subscribers.primary_email", + "subscribers.id", + "subscribers.all_emails_to_primary", + "subscribers.monthly_monitor_report", + "subscribers.monthly_monitor_report_at", + "subscribers.first_broker_removal_email_sent", + "subscriber_email_preferences.monthly_monitor_report_free", + "subscriber_email_preferences.monthly_monitor_report_free_at", + "subscriber_email_preferences.unsubscribe_token", + ) + .from("subscribers") + .where("subscribers.primary_email", email) + .leftJoin( + "subscriber_email_preferences", + "subscribers.id", + "subscriber_email_preferences.subscriber_id", + ) + .returning(["*"]); + + logger.debug("get_email_preference_for_subscriber_success"); + logger.debug( + `getEmailPreferenceForSubscriber left join: ${JSON.stringify(res)}`, + ); + } catch (e) { + logger.error("error_get_subscriber_email_preference", { + exception: e as string, + }); + + throw e; + } + return res?.[0] as SubscriberEmailPreferencesOutput; +} + export { addEmailPreferenceForSubscriber, updateEmailPreferenceForSubscriber, getEmailPreferenceForSubscriber, getEmailPreferenceForUnsubscribeToken, + getEmailPreferenceForPrimaryEmail, unsubscribeMonthlyMonitorReportForUnsubscribeToken, }; From 028b3cee20659caa1cfbc1f704783f0031f41d33 Mon Sep 17 00:00:00 2001 From: Joey Zhou Date: Thu, 5 Sep 2024 08:03:18 -0700 Subject: [PATCH 24/26] fix: logs --- src/db/tables/subscriber_email_preferences.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/db/tables/subscriber_email_preferences.ts b/src/db/tables/subscriber_email_preferences.ts index 8057c25df08..1152db2d54c 100644 --- a/src/db/tables/subscriber_email_preferences.ts +++ b/src/db/tables/subscriber_email_preferences.ts @@ -184,14 +184,17 @@ async function getEmailPreferenceForUnsubscribeToken(unsubscribeToken: string) { .where("unsubscribe_token", unsubscribeToken) .returning(["*"]); - logger.info( + logger.debug( `get_email_preference_for_unsubscriber_token: ${JSON.stringify(res)}`, ); logger.debug("get_email_preference_for_unsubscriber_token_success"); } catch (e) { - logger.error("error_get_subscriber_email_preference", { - exception: e as string, - }); + logger.error( + "error_get_subscriber_email_preference_for_unsubscribe_token", + { + exception: e as string, + }, + ); throw e; } @@ -262,12 +265,12 @@ async function getEmailPreferenceForPrimaryEmail(email: string) { ) .returning(["*"]); - logger.debug("get_email_preference_for_subscriber_success"); + logger.debug("get_email_preference_for_primary_email_success"); logger.debug( - `getEmailPreferenceForSubscriber left join: ${JSON.stringify(res)}`, + `getEmailPreferenceForPrimaryEmail left join: ${JSON.stringify(res)}`, ); } catch (e) { - logger.error("error_get_subscriber_email_preference", { + logger.error("error_get_subscriber_email_preference_for_primary_email", { exception: e as string, }); From cbf0e3c651bfe8a812594ab4f80750522776ff94 Mon Sep 17 00:00:00 2001 From: Joey Zhou Date: Thu, 5 Sep 2024 09:01:42 -0700 Subject: [PATCH 25/26] fix: setter --- src/app/api/v1/fxa-rp-events/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/api/v1/fxa-rp-events/route.ts b/src/app/api/v1/fxa-rp-events/route.ts index 909f0434635..8cd1b27c45f 100644 --- a/src/app/api/v1/fxa-rp-events/route.ts +++ b/src/app/api/v1/fxa-rp-events/route.ts @@ -297,7 +297,7 @@ export async function POST(request: NextRequest) { await changeSubscription(subscriber, true); // Set monthly monitor report value back to true - await setMonthlyMonitorReport(subscriber.id, true); + await setMonthlyMonitorReport(subscriber, true); // MNTOR-2103: if one rep profile id doesn't exist in the db, fail immediately if (!oneRepProfileId) { From a26ecb9066d935e84ece694bbfc14863b47d54c6 Mon Sep 17 00:00:00 2001 From: Joey Zhou Date: Thu, 5 Sep 2024 09:12:35 -0700 Subject: [PATCH 26/26] chore: review comments --- src/db/tables/subscriber_email_preferences.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/db/tables/subscriber_email_preferences.ts b/src/db/tables/subscriber_email_preferences.ts index 1152db2d54c..a817f9e8b26 100644 --- a/src/db/tables/subscriber_email_preferences.ts +++ b/src/db/tables/subscriber_email_preferences.ts @@ -33,6 +33,9 @@ interface SubscriberEmailPreferencesOutput { // TODO: modify after MNTOR-3557 - pref currently lives in two tables // this function only adds email prefs for free reports +// NOTE: this function is essentially an upsert where a preference object is provided and the fields to overwrite are passed in +// The third parameter is an array of the fields to overwrite, this is passed in to make sure only select fields are overwritten due to upsert +// see generateUnsubscribeLinkForSubscriber and updateEmailPreferenceForSubscriber for example of different uses of this function async function addEmailPreferenceForSubscriber( subscriberId: number, preference: SubscriberFreeEmailPreferencesInput,