Skip to content

Commit

Permalink
Merge branch 'MNTOR-3919' into MNTOR-3918
Browse files Browse the repository at this point in the history
  • Loading branch information
mansaj committed Jan 16, 2025
2 parents f4027ec + 8d30d8d commit dcb50d7
Show file tree
Hide file tree
Showing 12 changed files with 298 additions and 0 deletions.
5 changes: 5 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ AWS_SECRET_ACCESS_KEY=
AWS_REGION=
S3_BUCKET=

# GCP bucket
GCP_STORAGE_SA_PATH=
GCP_STORAGE_PROJECT_ID=
GCP_BUCKET=

# Firefox Accounts OAuth
FXA_SETTINGS_URL=https://accounts.stage.mozaws.net/settings

Expand Down
12 changes: 12 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"canvas-confetti": "^1.9.3",
"csv-parser": "^3.1.0",
"dotenv-flow": "^4.1.0",
"eslint-config-next": "^14.2.15",
"ioredis": "^5.4.2",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ const mockedSubscriber: SubscriberRow = {
monthly_monitor_report: false,
sign_in_count: null,
first_broker_removal_email_sent: false,
churn_prevention_email_sent_at: null,
};

const mockedUser: Session["user"] = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ const mockedSubscriber: SubscriberRow = {
monthly_monitor_report: false,
sign_in_count: null,
first_broker_removal_email_sent: false,
churn_prevention_email_sent_at: null,
};

const mockedUser: Session["user"] = {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/* 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/. */

/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
export function up (knex) {
return knex.schema.table("subscribers", table => {
table.timestamp("churn_prevention_email_sent_at");
table.index("churn_prevention_email_sent_at");
});
}

/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
export function down (knex) {
return knex.schema.table("subscribers", table => {
table.dropIndex("churn_prevention_email_sent_at")
table.dropColumn("churn_prevention_email_sent_at");
});
}
27 changes: 27 additions & 0 deletions src/db/migrations/20250115032133_subscriber_churns.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/* 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
.createTable('subscriber_churns', function(table) {
table.increments('id').primary();
table.string('userid').unique();
table.string('customer').unique();
table.string('plan_id');
table.string('product_id');
table.string('intervl');
table.string('nickname');
table.timestamp('created');
table.timestamp('current_period_end');
})
}

/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
export async function down(knex) {
return knex.schema
.dropTableIfExists("subscriber_churns")
}
35 changes: 35 additions & 0 deletions src/db/tables/subscriber_churns.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/* 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";
import { logger } from "../../app/functions/server/logging";
import { SubscriberChurnRow } from "knex/types/tables";

const knex = createDbConnection();

async function upsertSubscriberChurns(
churningSubscribers: SubscriberChurnRow[],
): Promise<SubscriberChurnRow[]> {
logger.info("upsert_subscriber_churns", {
count: churningSubscribers.length,
});

try {
const res = await knex("onerep_subscriber_churns")
.insert(churningSubscribers)
.onConflict("userid")
.merge(["intervl", "current_period_end"])
.returning("*");

logger.info("upsert_subscriber_churns_success", { count: res.length });
return res as SubscriberChurnRow[];
} catch (e) {
logger.error("upsert_subscriber_churns_error", {
error: JSON.stringify(e),
});
throw e;
}
}

export { upsertSubscriberChurns };
41 changes: 41 additions & 0 deletions src/db/tables/subscribers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,33 @@ async function markFirstDataBrokerRemovalFixedEmailAsJustSent(
}

/* c8 ignore stop */

// Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy
/* c8 ignore start */
async function markChurnPreventionEmailAsJustSent(
subscriberId: SubscriberRow["id"],
) {
const affectedSubscribers = await knex("subscribers")
.update({
// @ts-ignore knex.fn.now() results in it being set to a date,
// even if it's not typed as a JS date object:
churn_prevention_email_sent_at: knex.fn.now(),
// @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(),
})
.where("id", subscriberId)
.returning("*");

if (affectedSubscribers.length !== 1) {
throw new Error(
`Attempted to mark 1 user as having just been sent the churn prevention email, but instead found [${affectedSubscribers.length}] matching its ID.`,
);
}
}

/* c8 ignore stop */

// Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy
/* c8 ignore start */
async function markMonthlyActivityPlusEmailAsJustSent(
Expand Down Expand Up @@ -669,6 +696,18 @@ async function isSubscriberPlus(subscriberId: SubscriberRow["id"]) {
}
/* c8 ignore stop */

// Not covered by tests; mostly side-effects. See test-coverage.md#mock-heavy
/* c8 ignore start */
async function getChurnPreventionEmailSentAt(
subscriberId: SubscriberRow["id"],
) {
const res = await knex("subscribers")
.select("churn_prevention_email_sent_at")
.where("id", subscriberId);
return res?.[0]?.["churn_prevention_email_sent_at"] ?? null;
}
/* c8 ignore stop */

export {
getOnerepProfileId,
getSubscribersByHashes,
Expand All @@ -685,6 +724,7 @@ export {
getPotentialSubscribersWaitingForFirstDataBrokerRemovalFixedEmail,
getFreeSubscribersWaitingForMonthlyEmail,
getPlusSubscribersWaitingForMonthlyEmail,
markChurnPreventionEmailAsJustSent,
markFirstDataBrokerRemovalFixedEmailAsJustSent,
markMonthlyActivityPlusEmailAsJustSent,
deleteUnverifiedSubscribers,
Expand All @@ -693,6 +733,7 @@ export {
deleteOnerepProfileId,
incrementSignInCountForEligibleFreeUser,
getSignInCount,
getChurnPreventionEmailSentAt,
unresolveAllBreaches,
isSubscriberPlus,
knex as knexSubscribers,
Expand Down
12 changes: 12 additions & 0 deletions src/knex-tables.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,16 @@ declare module "knex/types/tables" {
updated_at: Date;
}

interface SubscriberChurnRow {
userid: string;
customer: string;
nickname: string;
intervl: string;
plan_id: string;
product_id: string;
current_period_end: string;
}

interface OnerepScanResultDataBrokerRow extends OnerepScanResultRow {
scan_result_status: RemovalStatus;
broker_status: DataBrokerRemovalStatus;
Expand Down Expand Up @@ -152,6 +162,7 @@ declare module "knex/types/tables" {
sign_in_count: null | number;
email_addresses: SubscriberEmail[];
first_broker_removal_email_sent: boolean;
churn_prevention_email_sent_at: null | Date;
}
type SubscriberOptionalColumns = Extract<
keyof SubscriberRow,
Expand All @@ -174,6 +185,7 @@ declare module "knex/types/tables" {
| "onerep_profile_id"
| "email_addresses"
| "first_broker_removal_email_sent"
| "churn_prevention_email_sent_at"
>;
type SubscriberAutoInsertedColumns = Extract<
keyof SubscriberRow,
Expand Down
131 changes: 131 additions & 0 deletions src/scripts/cronjobs/churnDiscount.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/* 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 {
getChurnPreventionEmailSentAt,
markChurnPreventionEmailAsJustSent,
} from "../../db/tables/subscribers";
// import { getFreeSubscribersWaitingForMonthlyEmail } from "../../db/tables/subscribers";
// import { getScanResultsWithBroker } from "../../db/tables/onerep_scans";
// import { updateEmailPreferenceForSubscriber } from "../../db/tables/subscriber_email_preferences";
// import { renderEmail } from "../../emails/renderEmail";
// import { MonthlyActivityFreeEmail } from "../../emails/templates/monthlyActivityFree/MonthlyActivityFreeEmail";
// import { getCronjobL10n } from "../../app/functions/l10n/cronjobs";
// import { sanitizeSubscriberRow } from "../../app/functions/server/sanitize";
// import { getDashboardSummary } from "../../app/functions/server/dashboard";
// import { getSubscriberBreaches } from "../../app/functions/server/getSubscriberBreaches";
// import { refreshStoredScanResults } from "../../app/functions/server/refreshStoredScanResults";
// import { getSignupLocaleCountry } from "../../emails/functions/getSignupLocaleCountry";
// import { getMonthlyActivityFreeUnsubscribeLink } from "../../app/functions/cronjobs/unsubscribeLinks";
// import { hasPremium } from "../../app/functions/universal/user";
// import { SubscriberRow } from "knex/types/tables";
import createDbConnection from "../../db/connect";
import { logger } from "../../app/functions/server/logging";
import { initEmail, sendEmail, closeEmailPool } from "../../utils/email";
// Imports the Google Cloud client library
import { Storage } from "@google-cloud/storage";
import csv from "csv-parser";

await run();
await createDbConnection().destroy();

interface FxaChurnSubscriber {
userid: string;
customer: string;
created: string;
nickname: string;
intervl: "monthly" | "yearly";
intervl_count: number;
plan_id: string;
product_id: string;
current_period_end: string;
}

async function readCSVFromBucket(
bucketName: string,
fileName: string,
): Promise<FxaChurnSubscriber[]> {
const storage = new Storage();
const bucket = storage.bucket(bucketName);
const file = bucket.file(fileName);

const results: FxaChurnSubscriber[] = [];

return new Promise((resolve, reject) => {
file
.createReadStream()
.pipe(csv())
.on("data", (row: FxaChurnSubscriber) => {
/**
* Verifies the interval is yearly
* Ensures current_period_end exists
* Checks if the time difference is less than or equal to 7 days (in milliseconds)
* Makes sure the date is in the future
*/
if (
row.intervl === "yearly" &&
row.current_period_end &&
new Date(row.current_period_end).getTime() - new Date().getTime() <=
7 * 24 * 60 * 60 * 1000 &&
new Date(row.current_period_end).getTime() > new Date().getTime()
) {
results.push(row);
}
})
.on("error", reject)
.on("end", () => {
logger.info(
`CSV file successfully processed. Num of rows: ${results.length}`,
);
resolve(results);
});
});
}

async function run() {
const bucketName = process.env.GCP_BUCKET;
if (!bucketName) {
throw `Bucket name isn't set ( process.env.GCP_BUCKET = ${process.env.GCP_BUCKET}), please set: 'GCP_BUCKET'`;
}
const fileName = "churningSubscribers.csv";
const subscribersToEmail = await readCSVFromBucket(bucketName, fileName);

await initEmail();

for (const subscriber of subscribersToEmail) {
try {
// we need to query our db to make sure the email wasn't sent in the past
const sentDate = await getChurnPreventionEmailSentAt(
parseInt(subscriber.userid, 10),
);
if (sentDate) {
logger.warn("send_churn_discount_email_warn", {
subscriberId: subscriber.userid,
message: `email already sent for the user at: ${sentDate}`,
});
continue;
}
// send email
await sendChurnDiscountEmail(subscriber);
logger.info("send_churn_discount_email_success", {
subscriberId: subscriber.userid,
});
} catch (error) {
logger.error("send_churn_discount_email_error", {
subscriberId: subscriber.userid,
error,
});
}
}

closeEmailPool();
logger.info(
`[${new Date(Date.now()).toISOString()}] Sent [${subscribersToEmail.length}] churn email to relevant subscribers.`,
);
}

async function sendChurnDiscountEmail(subscriber: FxaChurnSubscriber) {
logger.info(`sent email to: ${subscriber.userid}`);
// mark as sent
// await markChurnPreventionEmailAsJustSent(parseInt(subscriber.userid, 10))
}
Loading

0 comments on commit dcb50d7

Please sign in to comment.