Skip to content

Commit ec357ff

Browse files
committed
start bigquery integration
1 parent f803d20 commit ec357ff

File tree

5 files changed

+18440
-17157
lines changed

5 files changed

+18440
-17157
lines changed

bigquery_sample.js

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// Copyright 2019 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
'use strict';
16+
17+
function main() {
18+
// [START bigquery_query]
19+
// [START bigquery_client_default_credentials]
20+
// Import the Google Cloud client library using default credentials
21+
const {BigQuery} = require('@google-cloud/bigquery');
22+
const bigquery = new BigQuery();
23+
// [END bigquery_client_default_credentials]
24+
async function query() {
25+
// Queries the U.S. given names dataset for the state of Texas.
26+
27+
const query = `SELECT name
28+
FROM \`bigquery-public-data.usa_names.usa_1910_2013\`
29+
WHERE state = 'TX'
30+
LIMIT 100`;
31+
32+
// For all options, see https://cloud.google.com/bigquery/docs/reference/rest/v2/jobs/query
33+
const options = {
34+
query: query,
35+
// Location must match that of the dataset(s) referenced in the query.
36+
location: 'US',
37+
};
38+
39+
// Run the query as a job
40+
const [job] = await bigquery.createQueryJob(options);
41+
console.log(`Job ${job.id} started.`);
42+
43+
// Wait for the query to finish
44+
const [rows] = await job.getQueryResults();
45+
46+
// Print the results
47+
console.log('Rows:');
48+
rows.forEach(row => console.log(row));
49+
}
50+
// [END bigquery_query]
51+
query();
52+
}
53+
main(...process.argv.slice(2));

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@
181181
},
182182
"devDependencies": {
183183
"@babel/eslint-parser": "^7.24.7",
184+
"@google-cloud/bigquery": "^8.0.0",
184185
"babel-jest": "^29.3.1",
185186
"babel-preset-es2017": "^6.24.1",
186187
"cypress": "5.6.0",
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
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+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import type from "prop-types";
2+
import React from "react";
3+
import Form from "react-formal";
4+
import * as yup from "yup";
5+
import List from "@material-ui/core/List";
6+
import ListItem from "@material-ui/core/ListItem";
7+
import ListItemText from "@material-ui/core/ListItemText";
8+
import ListItemIcon from "@material-ui/core/ListItemIcon";
9+
import GSForm from "../../../components/forms/GSForm";
10+
import GSSubmitButton from "../../../components/forms/GSSubmitButton";
11+
import GSTextField from "../../../components/forms/GSTextField";
12+
13+
export class CampaignContactsForm extends React.Component {
14+
state = {
15+
errorResult: null
16+
};
17+
18+
render() {
19+
const { clientChoiceData, lastResult } = this.props;
20+
let resultMessage = "";
21+
if (lastResult && lastResult.result) {
22+
const { message, finalCount } = JSON.parse(lastResult.result);
23+
resultMessage = message
24+
? message
25+
: `Final count was ${finalCount} when you chose ${lastResult.reference}`;
26+
}
27+
return (
28+
<GSForm
29+
schema={yup.object({
30+
requestContactCount: yup.number().integer()
31+
})}
32+
onChange={formValues => {
33+
this.setState({ ...formValues });
34+
this.props.onChange(JSON.stringify(formValues));
35+
}}
36+
onSubmit={formValues => {
37+
// sets values locally
38+
this.setState({ ...formValues });
39+
// triggers the parent to update values
40+
this.props.onChange(JSON.stringify(formValues));
41+
// and now do whatever happens when clicking 'Next'
42+
this.props.onSubmit();
43+
}}
44+
>
45+
<Form.Field
46+
as={GSTextField}
47+
fullWidth
48+
name="requestContactCount"
49+
label="How many fake contacts"
50+
/>
51+
<List>
52+
<ListItem>
53+
<ListItemIcon>{this.props.icons.check}</ListItemIcon>
54+
<ListItemText primary={clientChoiceData} />
55+
</ListItem>
56+
{resultMessage && (
57+
<ListItem>
58+
<ListItemIcon>{this.props.icons.warning}</ListItemIcon>
59+
<ListItemText primary={resultMessage} />
60+
</ListItem>
61+
)}
62+
</List>
63+
64+
<Form.Submit
65+
as={GSSubmitButton}
66+
disabled={this.props.saveDisabled}
67+
label={this.props.saveLabel}
68+
/>
69+
</GSForm>
70+
);
71+
}
72+
}
73+
74+
CampaignContactsForm.propTypes = {
75+
onChange: type.func,
76+
onSubmit: type.func,
77+
campaignIsStarted: type.bool,
78+
79+
icons: type.object,
80+
81+
saveDisabled: type.bool,
82+
saveLabel: type.string,
83+
84+
clientChoiceData: type.string,
85+
jobResultMessage: type.string,
86+
lastResult: type.object
87+
};

0 commit comments

Comments
 (0)