|
| 1 | +import { completeContactLoad, failedContactLoad } from "../../../workers/jobs"; |
| 2 | +import { r } from "../../../server/models"; |
| 3 | +import { getConfig, hasConfig } from "../../../server/api/lib/config"; |
| 4 | + |
| 5 | +export const name = "bigquery"; |
| 6 | + |
| 7 | +export function displayName() { |
| 8 | + return "Bigquery integration for loading contacts from Bigquery"; |
| 9 | +} |
| 10 | + |
| 11 | +export function serverAdministratorInstructions() { |
| 12 | + return { |
| 13 | + environmentVariables: ["GCP_PROJECT_ID"], |
| 14 | + description: "Load contacts from Bigquery tables", |
| 15 | + setupInstructions: |
| 16 | + "Get an GCP Project ID and API key for your Bigquery table. Add them to your config. In most cases the defaults for the other environment variables will work." |
| 17 | + }; |
| 18 | +} |
| 19 | + |
| 20 | +export async function available(organization, user) { |
| 21 | + /// return an object with two keys: result: true/false |
| 22 | + /// these keys indicate if the ingest-contact-loader is usable |
| 23 | + /// Sometimes credentials need to be setup, etc. |
| 24 | + /// A second key expiresSeconds: should be how often this needs to be checked |
| 25 | + /// If this is instantaneous, you can have it be 0 (i.e. always), but if it takes time |
| 26 | + /// to e.g. verify credentials or test server availability, |
| 27 | + /// then it's better to allow the result to be cached |
| 28 | + const orgFeatures = JSON.parse(organization.features || "{}"); |
| 29 | + const result = |
| 30 | + (orgFeatures.service || getConfig("DEFAULT_SERVICE", organization)) === |
| 31 | + "fakeservice"; |
| 32 | + return { |
| 33 | + result, |
| 34 | + expiresSeconds: 0 |
| 35 | + }; |
| 36 | +} |
| 37 | + |
| 38 | +export function addServerEndpoints(expressApp) { |
| 39 | + /// If you need to create API endpoints for server-to-server communication |
| 40 | + /// this is where you would run e.g. app.post(....) |
| 41 | + /// Be mindful of security and make sure there's |
| 42 | + /// This is NOT where or how the client send or receive contact data |
| 43 | + return; |
| 44 | +} |
| 45 | + |
| 46 | +export function clientChoiceDataCacheKey(campaign, user) { |
| 47 | + /// returns a string to cache getClientChoiceData -- include items that relate to cacheability |
| 48 | + return `${campaign.id}`; |
| 49 | +} |
| 50 | + |
| 51 | +export async function getClientChoiceData(organization, campaign, user) { |
| 52 | + /// data to be sent to the admin client to present options to the component or similar |
| 53 | + /// The react-component will be sent this data as a property |
| 54 | + /// return a json object which will be cached for expiresSeconds long |
| 55 | + /// `data` should be a single string -- it can be JSON which you can parse in the client component |
| 56 | + return { |
| 57 | + data: `choice data from server`, |
| 58 | + expiresSeconds: 0 |
| 59 | + }; |
| 60 | +} |
| 61 | + |
| 62 | +export async function processContactLoad(job, maxContacts, organization) { |
| 63 | + /// Trigger processing -- this will likely be the most important part |
| 64 | + /// you should load contacts into the contact table with the job.campaign_id |
| 65 | + /// Since this might just *begin* the processing and other work might |
| 66 | + /// need to be completed asynchronously after this is completed (e.g. to distribute loads) |
| 67 | + /// After true contact-load completion, this (or another function) |
| 68 | + /// MUST call src/workers/jobs.js::completeContactLoad(job) |
| 69 | + /// The async function completeContactLoad(job) will |
| 70 | + /// * delete contacts that are in the opt_out table, |
| 71 | + /// * delete duplicate cells, |
| 72 | + /// * clear/update caching, etc. |
| 73 | + /// The organization parameter is an object containing the name and other |
| 74 | + /// details about the organization on whose behalf this contact load |
| 75 | + /// was initiated. It is included here so it can be passed as the |
| 76 | + /// second parameter of getConfig in order to retrieve organization- |
| 77 | + /// specific configuration values. |
| 78 | + /// Basic responsibilities: |
| 79 | + /// 1. Delete previous campaign contacts on a previous choice/upload |
| 80 | + /// 2. Set campaign_contact.campaign_id = job.campaign_id on all uploaded contacts |
| 81 | + /// 3. Set campaign_contact.message_status = "needsMessage" on all uploaded contacts |
| 82 | + /// 4. Ensure that campaign_contact.cell is in the standard phone format "+15551234567" |
| 83 | + /// -- do NOT trust your backend to ensure this |
| 84 | + /// 5. If your source doesn't have timezone offset info already, then you need to |
| 85 | + /// fill the campaign_contact.timezone_offset with getTimezoneByZip(contact.zip) (from "../../workers/jobs") |
| 86 | + /// Things to consider in your implementation: |
| 87 | + /// * Batching |
| 88 | + /// * Error handling |
| 89 | + /// * "Request of Doom" scenarios -- queries or jobs too big to complete |
| 90 | + |
| 91 | + const campaignId = job.campaign_id; |
| 92 | + |
| 93 | + await r |
| 94 | + .knex("campaign_contact") |
| 95 | + .where("campaign_id", campaignId) |
| 96 | + .delete(); |
| 97 | + |
| 98 | + const contactData = JSON.parse(job.payload); |
| 99 | + if (contactData.requestContactCount === 42) { |
| 100 | + await failedContactLoad( |
| 101 | + job, |
| 102 | + null, |
| 103 | + // a reference so you can persist user choices |
| 104 | + // This will be lastResult.reference in react-component property |
| 105 | + String(contactData.requestContactCount), |
| 106 | + // a place where you can save result messages based on the outcome |
| 107 | + // This will be lastResult.result in react-component property |
| 108 | + JSON.stringify({ |
| 109 | + message: |
| 110 | + "42 is life, the universe everything. Please choose a different number." |
| 111 | + }) |
| 112 | + ); |
| 113 | + return; // bail early |
| 114 | + } |
| 115 | + const areaCodes = ["213", "323", "212", "718", "646", "661"]; |
| 116 | + // add a bunch more area codes |
| 117 | + for (let ac = 200; ac < 1000; ac++) { |
| 118 | + areaCodes.push(String(ac)); |
| 119 | + } |
| 120 | + // FUTURE -- maybe based on campaign default use 'surrounding' offsets |
| 121 | + const timezones = [ |
| 122 | + "-12_1", |
| 123 | + "-11_0", |
| 124 | + "-5_1", |
| 125 | + "-4_1", |
| 126 | + "0_0", |
| 127 | + "5_0", |
| 128 | + "10_0", |
| 129 | + "" |
| 130 | + ]; |
| 131 | + const contactCount = Math.min( |
| 132 | + contactData.requestContactCount || 0, |
| 133 | + maxContacts ? maxContacts : areaCodes.length * 100, |
| 134 | + areaCodes.length * 100 |
| 135 | + ); |
| 136 | + function genCustomFields(i, campaignId) { |
| 137 | + return JSON.stringify({ |
| 138 | + campaignIndex: String(i), |
| 139 | + [`custom${campaignId}`]: String(Math.random()).slice(3, 8) |
| 140 | + }); |
| 141 | + } |
| 142 | + const newContacts = []; |
| 143 | + for (let i = 0; i < contactCount; i++) { |
| 144 | + const ac = areaCodes[parseInt(i / 100, 10)]; |
| 145 | + const suffix = String("00" + (i % 100)).slice(-2); |
| 146 | + newContacts.push({ |
| 147 | + first_name: `Foo${i}`, |
| 148 | + last_name: `Bar${i}`, |
| 149 | + // conform to Hollywood-reserved numbers |
| 150 | + // https://www.businessinsider.com/555-phone-number-tv-movies-telephone-exchange-names-ghostbusters-2018-3 |
| 151 | + cell: `+1${ac}55501${suffix}`, |
| 152 | + zip: "10011", |
| 153 | + external_id: "fake" + String(Math.random()).slice(3, 8), |
| 154 | + custom_fields: genCustomFields(i, campaignId), |
| 155 | + timezone_offset: |
| 156 | + timezones[parseInt(Math.random() * timezones.length, 10)], |
| 157 | + message_status: "needsMessage", |
| 158 | + campaign_id: campaignId |
| 159 | + }); |
| 160 | + } |
| 161 | + await r.knex.batchInsert("campaign_contact", newContacts, 100); |
| 162 | + |
| 163 | + await completeContactLoad( |
| 164 | + job, |
| 165 | + null, |
| 166 | + // see failedContactLoad above for descriptions |
| 167 | + String(contactData.requestContactCount), |
| 168 | + JSON.stringify({ finalCount: contactCount }) |
| 169 | + ); |
| 170 | +} |
0 commit comments