From 80084c95ad1d0e73a464aef8659c43e0026e4beb Mon Sep 17 00:00:00 2001 From: Benjamin Chrobot Date: Thu, 22 Sep 2022 07:55:18 -0400 Subject: [PATCH] feat: send webhook on large campaign event (#1427) Reviewed-by: @hiemanshu Reviewed-by: @ajohn25 --- package.json | 1 + src/config.js | 8 ++ src/server/api/lib/alerts.spec.ts | 98 ++++++++++++++++++ src/server/api/lib/{alerts.js => alerts.ts} | 99 ++++++++++++++++--- src/server/api/schema.js | 17 +++- .../tasks/ngp-van/van-fetch-saved-list.ts | 6 ++ src/workers/jobs/index.js | 4 + yarn.lock | 15 +++ 8 files changed, 228 insertions(+), 20 deletions(-) create mode 100644 src/server/api/lib/alerts.spec.ts rename src/server/api/lib/{alerts.js => alerts.ts} (64%) diff --git a/package.json b/package.json index 25de5e854..909ece09d 100644 --- a/package.json +++ b/package.json @@ -278,6 +278,7 @@ "json2csv": "^3.6.2", "lint-staged": "^10.4.0", "mockdate": "^2.0.2", + "nock": "^13.2.9", "nodemon": "^2.0.7", "npm-run-all": "^4.1.5", "prettier": "^2.1.2", diff --git a/src/config.js b/src/config.js index c4bd9c394..e81f7b110 100644 --- a/src/config.js +++ b/src/config.js @@ -444,6 +444,14 @@ const validators = { desc: "When true, log each lambda event to the console.", default: false }), + LARGE_CAMPAIGN_THRESHOLD: num({ + desc: 'Threshold for what qualifies as a "large campaign"', + default: 100 * 1000 + }), + LARGE_CAMPAIGN_WEBHOOK: url({ + desc: "URL to send webhook to when large campaign is uploaded or started", + default: undefined + }), LOGGING_MONGODB_URI: url({ desc: "If present, requestLogger will send logs events to MongoDB.", default: undefined diff --git a/src/server/api/lib/alerts.spec.ts b/src/server/api/lib/alerts.spec.ts new file mode 100644 index 000000000..7b17588f8 --- /dev/null +++ b/src/server/api/lib/alerts.spec.ts @@ -0,0 +1,98 @@ +import { + createCampaign, + createCampaignContact +} from "__test__/testbed-preparation/core"; +import nock from "nock"; +import type { PoolClient } from "pg"; +import { Pool } from "pg"; +import supertest from "supertest"; + +import { createOrgAndSession } from "../../../../__test__/lib/session"; +import { UserRoleType } from "../../../api/organization-membership"; +import { config } from "../../../config"; +import { createApp } from "../../app"; +import { withClient } from "../../utils"; +import { notifyLargeCampaignEvent } from "./alerts"; + +describe("notifyLargeCampaignEvent", () => { + let pool: Pool; + let agent: supertest.SuperAgentTest; + + const TEST_WEBHOOK_URL = "https://poke.spokerewired.com"; + + beforeAll(async () => { + pool = new Pool({ connectionString: config.TEST_DATABASE_URL }); + const app = await createApp(); + agent = supertest.agent(app); + }); + + afterAll(async () => { + if (pool) await pool.end(); + }); + + const setUpCampaign = async (client: PoolClient) => { + const { organization } = await createOrgAndSession(client, { + agent, + role: UserRoleType.OWNER + }); + const campaign = await createCampaign(client, { + organizationId: organization.id + }); + await Promise.all( + [...new Array(10)].map(() => + createCampaignContact(client, { campaignId: campaign.id }) + ) + ); + return campaign; + }; + + it("notifies when contacts exceed threshold", async () => { + await withClient(pool, async (client) => { + const campaign = await setUpCampaign(client); + + let wasCalled = false; + + nock(TEST_WEBHOOK_URL) + .post("/webhook") + .reply(200, () => { + wasCalled = true; + }); + + await notifyLargeCampaignEvent( + campaign.id, + "upload", + 5, + `${TEST_WEBHOOK_URL}/webhook` + ); + + expect(wasCalled).toBe(true); + expect(nock.isDone()).toBe(true); + }); + }); + + it("does not notify when contacts do not exceed threshold", async () => { + await withClient(pool, async (client) => { + const campaign = await setUpCampaign(client); + + let wasCalled = false; + + const nockScope = nock(TEST_WEBHOOK_URL) + .post("/webhook") + .reply(200, () => { + wasCalled = true; + }); + + await notifyLargeCampaignEvent( + campaign.id, + "upload", + 15, + `${TEST_WEBHOOK_URL}/webhook` + ); + + expect(wasCalled).toBe(false); + expect(nockScope.isDone()).toBe(false); + expect(nock.isDone()).toBe(false); + nock.cleanAll(); + }); + }); +}); diff --git a/src/server/api/lib/alerts.js b/src/server/api/lib/alerts.ts similarity index 64% rename from src/server/api/lib/alerts.js rename to src/server/api/lib/alerts.ts index a30bae060..84ea7eabf 100644 --- a/src/server/api/lib/alerts.js +++ b/src/server/api/lib/alerts.ts @@ -4,10 +4,27 @@ import request from "superagent"; import { config } from "../../../config"; import logger from "../../../logger"; import { r } from "../../models"; +import { MessageSendStatus } from "../types"; const THRESHOLD = 0.2; -const notifyAssignmentCreated = async (options) => { +interface NotifyAssignmentCreatedOptions { + organizationId: number; + userId: number; + count: number; +} + +type NotifyAssignmentCreatedPayload = { + organizationId: number | string; + organizationName: string; + count: number | string; + email: string; + externalUserId?: number | string; +}; + +export const notifyAssignmentCreated = async ( + options: NotifyAssignmentCreatedOptions +) => { const { organizationId, userId, count } = options; if (!config.ASSIGNMENT_REQUESTED_URL) return; @@ -22,7 +39,12 @@ const notifyAssignmentCreated = async (options) => { .where({ id: organizationId }) .first(["name"]); - let payload = { organizationId, organizationName, count, email }; + let payload: NotifyAssignmentCreatedPayload = { + organizationId, + organizationName, + count, + email + }; if (["slack"].includes(config.PASSPORT_STRATEGY)) { payload.externalUserId = externalUserId; @@ -31,7 +53,7 @@ const notifyAssignmentCreated = async (options) => { if (config.WEBHOOK_PAYLOAD_ALL_STRINGS) { payload = Object.fromEntries( Object.entries(payload).map(([key, value]) => [key, `${value}`]) - ); + ) as NotifyAssignmentCreatedPayload; } const webhookRequest = request @@ -51,7 +73,13 @@ const notifyAssignmentCreated = async (options) => { }); }; -async function checkForBadDeliverability() { +type DeliverabilityRow = { + domain: string; + send_status: MessageSendStatus; + count: number; +}; + +export async function checkForBadDeliverability() { if (config.DELIVERABILITY_ALERT_ENDPOINT === undefined) return null; logger.info("Running deliverability check"); /* @@ -75,10 +103,13 @@ async function checkForBadDeliverability() { group by domain, link_message.send_status; `); - const byDomain = _.groupBy(results.rows, (x) => x.domain); + const byDomain = _.groupBy( + results.rows as DeliverabilityRow[], + (x) => x.domain + ); for (const domain of Object.keys(byDomain)) { - const fetchCountBySendStatus = (status) => { + const fetchCountBySendStatus = (status: string) => { for (const foundStatus of byDomain[domain]) { if (foundStatus.send_status === status) { return foundStatus.count; @@ -87,9 +118,9 @@ async function checkForBadDeliverability() { return 0; }; - const deliveredCount = fetchCountBySendStatus("DELIVERED"); - const sentCount = fetchCountBySendStatus("SENT"); - const errorCount = fetchCountBySendStatus("ERROR"); + const deliveredCount = fetchCountBySendStatus(MessageSendStatus.Delivered); + const sentCount = fetchCountBySendStatus(MessageSendStatus.Sent); + const errorCount = fetchCountBySendStatus(MessageSendStatus.Error); const errorPercent = errorCount / (deliveredCount + sentCount); if (errorPercent > THRESHOLD) { @@ -104,7 +135,11 @@ async function checkForBadDeliverability() { } } -async function notifyOnTagConversation(campaignContactId, userId, webhookUrls) { +export async function notifyOnTagConversation( + campaignContactId: string, + userId: string, + webhookUrls: string[] +) { const promises = { mostRecentlyReceivedMessage: (async () => { const message = await r @@ -169,8 +204,42 @@ async function notifyOnTagConversation(campaignContactId, userId, webhookUrls) { ); } -export { - checkForBadDeliverability, - notifyOnTagConversation, - notifyAssignmentCreated -}; +export async function notifyLargeCampaignEvent( + campaignId: number, + event: "upload" | "start", + threshold = config.LARGE_CAMPAIGN_THRESHOLD, + url = config.LARGE_CAMPAIGN_WEBHOOK +) { + if (!url) return; + + const campaignInfo = await r + .knex("campaign") + .join("organization", "organization.id", "=", "campaign.organization_id") + .where({ "campaign.id": campaignId }) + .first([ + "organization.id as org_id", + "organization.name as org_name", + "campaign.id as campaign_id", + "campaign.title as campaign_title" + ]); + + const [{ count }] = await r + .knex("campaign_contact") + .where({ campaign_id: campaignId }) + .count("id"); + + if (count < threshold) return; + + const payload = { + instance: config.BASE_URL, + organizationId: campaignInfo.org_id, + organizationName: campaignInfo.org_name, + campaignId, + campaignTitle: campaignInfo.campaign_title, + campaignUrl: `${config.BASE_URL}/admin/${campaignInfo.org_id}/campaigns/${campaignId}`, + event, + contactCount: count + }; + + await request.post(url).send(payload); +} diff --git a/src/server/api/schema.js b/src/server/api/schema.js index 0f18c8de1..17d816fa6 100644 --- a/src/server/api/schema.js +++ b/src/server/api/schema.js @@ -77,7 +77,11 @@ import { resolvers as externalSyncConfigResolvers } from "./external-sync-config import { resolvers as externalSystemResolvers } from "./external-system"; import { resolvers as interactionStepResolvers } from "./interaction-step"; import { resolvers as inviteResolvers } from "./invite"; -import { notifyAssignmentCreated, notifyOnTagConversation } from "./lib/alerts"; +import { + notifyAssignmentCreated, + notifyLargeCampaignEvent, + notifyOnTagConversation +} from "./lib/alerts"; import { getStepsToUpdate } from "./lib/bulk-script-editor"; import { copyCampaign, editCampaign } from "./lib/campaign"; import { saveNewIncomingMessage } from "./lib/message-sending"; @@ -1022,10 +1026,13 @@ const rootMutations = { .where({ id }) .returning("*"); - await sendUserNotification({ - type: Notifications.CAMPAIGN_STARTED, - campaignId: id - }); + await Promise.all([ + sendUserNotification({ + type: Notifications.CAMPAIGN_STARTED, + campaignId: id + }), + notifyLargeCampaignEvent(id, "start") + ]); return campaign; }, diff --git a/src/server/tasks/ngp-van/van-fetch-saved-list.ts b/src/server/tasks/ngp-van/van-fetch-saved-list.ts index a2e323edd..be61960dc 100644 --- a/src/server/tasks/ngp-van/van-fetch-saved-list.ts +++ b/src/server/tasks/ngp-van/van-fetch-saved-list.ts @@ -7,6 +7,7 @@ import type { SuperAgentRequest } from "superagent"; import { get, post } from "superagent"; import { config } from "../../../config"; +import { notifyLargeCampaignEvent } from "../../api/lib/alerts"; import type { VanSecretAuthPayload } from "../../lib/external-systems"; import { withVan } from "../../lib/external-systems"; import { withTransaction } from "../../utils"; @@ -168,6 +169,9 @@ interface FetchSavedListsPayload extends VanSecretAuthPayload { column_config: ColumnConfig; first_n_rows: number; extract_phone_type: VanPhoneType | null; + __context: { + campaign_id: number; + }; } export const fetchSavedList: Task = async ( @@ -244,4 +248,6 @@ export const fetchSavedList: Task = async ( }); await handleResult(helpers, payload, {}); + + await notifyLargeCampaignEvent(payload.__context.campaign_id, "upload"); }; diff --git a/src/workers/jobs/index.js b/src/workers/jobs/index.js index 3efe55e78..ed6863d6e 100644 --- a/src/workers/jobs/index.js +++ b/src/workers/jobs/index.js @@ -7,6 +7,7 @@ import { gunzip } from "../../lib"; import { getFormattedPhoneNumber } from "../../lib/phone-format"; import { isValidTimezone } from "../../lib/tz-helpers"; import logger from "../../logger"; +import { notifyLargeCampaignEvent } from "../../server/api/lib/alerts"; import { assignMissingMessagingServices, // eslint-disable-next-line import/named @@ -351,6 +352,8 @@ export async function uploadContacts(job) { .update({ result_message: message }); } + await notifyLargeCampaignEvent(campaignId, "upload"); + await cacheableData.campaign.reload(campaignId); } @@ -555,6 +558,7 @@ export async function loadContactsFromDataWarehouseFragment(jobEvent) { }); } await r.knex("job_request").where({ id: jobEvent.jobId }).del(); + await notifyLargeCampaignEvent(jobEvent.campaignId, "upload"); await cacheableData.campaign.reload(jobEvent.campaignId); return { completed: 1, validationStats }; } else if (jobEvent.part < jobEvent.totalParts - 1) { diff --git a/yarn.lock b/yarn.lock index bcde986fc..4f3a91868 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19552,6 +19552,16 @@ no-case@^3.0.4: lower-case "^2.0.2" tslib "^2.0.3" +nock@^13.2.9: + version "13.2.9" + resolved "https://registry.yarnpkg.com/nock/-/nock-13.2.9.tgz#4faf6c28175d36044da4cfa68e33e5a15086ad4c" + integrity sha512-1+XfJNYF1cjGB+TKMWi29eZ0b82QOvQs2YoLNzbpWGqFMtRQHTa57osqdGj4FrFPgkO4D4AZinzUJR9VvW3QUA== + dependencies: + debug "^4.1.0" + json-stringify-safe "^5.0.1" + lodash "^4.17.21" + propagate "^2.0.0" + node-cron@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/node-cron/-/node-cron-2.0.3.tgz#b9649784d0d6c00758410eef22fa54a10e3f602d" @@ -21873,6 +21883,11 @@ prop-types@^15.5.10, prop-types@^15.5.6, prop-types@^15.5.7, prop-types@^15.5.8, object-assign "^4.1.1" react-is "^16.8.1" +propagate@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/propagate/-/propagate-2.0.1.tgz#40cdedab18085c792334e64f0ac17256d38f9a45" + integrity sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag== + property-expr@^2.0.2, property-expr@^2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/property-expr/-/property-expr-2.0.4.tgz#37b925478e58965031bb612ec5b3260f8241e910"