From a6377bfe87a0754f74990fcb568212e89306aec0 Mon Sep 17 00:00:00 2001 From: Alex Anderson Date: Thu, 23 May 2019 21:19:01 -0600 Subject: [PATCH 01/12] Added the backend for the Google Sheets connection. --- server/package-lock.json | 185 +++++++++++++++++++++++++- server/package.json | 1 + server/src/app.js | 4 +- server/src/bootstrap/logs.js | 2 + server/src/classes/googleSheets.js | 9 ++ server/src/classes/index.js | 1 + server/src/events/googleSheets.js | 139 +++++++++++++++++++ server/src/events/index.js | 1 + server/src/helpers/defaultSnapshot.js | 1 + server/src/helpers/mutationHelper.js | 2 +- server/src/typeDefs/googleSheets.js | 91 +++++++++++++ 11 files changed, 433 insertions(+), 3 deletions(-) create mode 100644 server/src/classes/googleSheets.js create mode 100644 server/src/events/googleSheets.js create mode 100644 server/src/typeDefs/googleSheets.js diff --git a/server/package-lock.json b/server/package-lock.json index 0cb9dbb13..50eeb944c 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,6 +1,6 @@ { "name": "thorium-server", - "version": "1.3.0", + "version": "1.4.1", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -1520,6 +1520,14 @@ "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", "dev": true }, + "abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "requires": { + "event-target-shim": "^5.0.0" + } + }, "accepts": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.5.tgz", @@ -2388,6 +2396,11 @@ "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-0.1.5.tgz", "integrity": "sha1-c5JncZI7Whl0etZmqlzUv5xunOg=" }, + "base64-js": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.0.tgz", + "integrity": "sha512-ccav/yGvoa80BQDljCxsmmQ3Xvx60/UpBIij5QN21W3wBi/hhIC9OoO+KLpu9IJTS9j4DRVJ3aDDF9cMSoa2lw==" + }, "base64id": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/base64id/-/base64id-1.0.0.tgz", @@ -2414,6 +2427,11 @@ "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==" }, + "bignumber.js": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-7.2.1.tgz", + "integrity": "sha512-S4XzBk5sMB+Rcb/LNcpzXr57VRTxgAvaAEDAl1AwRx27j00hT84O6OkteE7u8UB3NuaaygCRrEpqox4uDOrbdQ==" + }, "binary": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", @@ -2604,6 +2622,11 @@ "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", "integrity": "sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=" }, + "buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=" + }, "buffer-from": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", @@ -3471,6 +3494,14 @@ "safer-buffer": "^2.1.0" } }, + "ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "requires": { + "safe-buffer": "^5.0.1" + } + }, "ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -3676,6 +3707,11 @@ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=" }, + "event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==" + }, "eventemitter3": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.0.tgz", @@ -4004,6 +4040,11 @@ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=" }, + "fast-text-encoding": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fast-text-encoding/-/fast-text-encoding-1.0.0.tgz", + "integrity": "sha512-R9bHCvweUxxwkDwhjav5vxpFvdPGlVngtqmx4pIZfSUhM/Q4NiIUHB456BAf+Q1Nwu3HEZYONtu+Rya+af4jiQ==" + }, "faye-websocket": { "version": "0.11.1", "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.1.tgz", @@ -4823,6 +4864,26 @@ } } }, + "gaxios": { + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-1.8.4.tgz", + "integrity": "sha512-BoENMnu1Gav18HcpV9IleMPZ9exM+AvUjrAOV4Mzs/vfz2Lu/ABv451iEXByKiMPn2M140uul1txXCg83sAENw==", + "requires": { + "abort-controller": "^3.0.0", + "extend": "^3.0.2", + "https-proxy-agent": "^2.2.1", + "node-fetch": "^2.3.0" + } + }, + "gcp-metadata": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-1.0.0.tgz", + "integrity": "sha512-Q6HrgfrCQeEircnNP3rCcEgiDv7eF9+1B+1MMgpE190+/+0mjQR8PxeOaRgxZWmdDAF9EIryHB9g1moPiw1SbQ==", + "requires": { + "gaxios": "^1.0.2", + "json-bigint": "^0.3.0" + } + }, "get-caller-file": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-1.0.3.tgz", @@ -4948,6 +5009,67 @@ } } }, + "google-auth-library": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-3.1.2.tgz", + "integrity": "sha512-cDQMzTotwyWMrg5jRO7q0A4TL/3GWBgO7I7q5xGKNiiFf9SmGY/OJ1YsLMgI2MVHHsEGyrqYnbnmV1AE+Z6DnQ==", + "requires": { + "base64-js": "^1.3.0", + "fast-text-encoding": "^1.0.0", + "gaxios": "^1.2.1", + "gcp-metadata": "^1.0.0", + "gtoken": "^2.3.2", + "https-proxy-agent": "^2.2.1", + "jws": "^3.1.5", + "lru-cache": "^5.0.0", + "semver": "^5.5.0" + } + }, + "google-p12-pem": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/google-p12-pem/-/google-p12-pem-1.0.4.tgz", + "integrity": "sha512-SwLAUJqUfTB2iS+wFfSS/G9p7bt4eWcc2LyfvmUXe7cWp6p3mpxDo6LLI29MXdU6wvPcQ/up298X7GMC5ylAlA==", + "requires": { + "node-forge": "^0.8.0", + "pify": "^4.0.0" + }, + "dependencies": { + "pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==" + } + } + }, + "googleapis": { + "version": "39.2.0", + "resolved": "https://registry.npmjs.org/googleapis/-/googleapis-39.2.0.tgz", + "integrity": "sha512-66X8TG1B33zAt177sG1CoKoYHPP/B66tEpnnSANGCqotMuY5gqSQO8G/0gqHZR2jRgc5CHSSNOJCnpI0SuDxMQ==", + "requires": { + "google-auth-library": "^3.0.0", + "googleapis-common": "^0.7.0" + } + }, + "googleapis-common": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-0.7.2.tgz", + "integrity": "sha512-9DEJIiO4nS7nw0VE1YVkEfXEj8x8MxsuB+yZIpOBULFSN9OIKcUU8UuKgSZFU4lJmRioMfngktrbkMwWJcUhQg==", + "requires": { + "gaxios": "^1.2.2", + "google-auth-library": "^3.0.0", + "pify": "^4.0.0", + "qs": "^6.5.2", + "url-template": "^2.0.8", + "uuid": "^3.2.1" + }, + "dependencies": { + "pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==" + } + } + }, "got": { "version": "6.7.1", "resolved": "https://registry.npmjs.org/got/-/got-6.7.1.tgz", @@ -5208,6 +5330,30 @@ "resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz", "integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=" }, + "gtoken": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-2.3.3.tgz", + "integrity": "sha512-EaB49bu/TCoNeQjhCYKI/CurooBKkGxIqFHsWABW0b25fobBYVTMe84A8EBVVZhl8emiUdNypil9huMOTmyAnw==", + "requires": { + "gaxios": "^1.0.4", + "google-p12-pem": "^1.0.0", + "jws": "^3.1.5", + "mime": "^2.2.0", + "pify": "^4.0.0" + }, + "dependencies": { + "mime": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.4.3.tgz", + "integrity": "sha512-QgrPRJfE+riq5TPZMcHZOtm8c6K/yYrMbKIoRfapfiGLxS8OTeIfRhUGW5LU7MlRa52KOAGCfUNruqLrIBvWZw==" + }, + "pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==" + } + } + }, "gzip-size": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-5.0.0.tgz", @@ -6377,6 +6523,14 @@ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==" }, + "json-bigint": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-0.3.0.tgz", + "integrity": "sha1-DM2RLEuCcNBfBW+9E4FLU9OCWx4=", + "requires": { + "bignumber.js": "^7.0.0" + } + }, "json-parse-better-errors": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", @@ -6441,6 +6595,25 @@ "verror": "1.10.0" } }, + "jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "requires": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "requires": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "kind-of": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz", @@ -7129,6 +7302,11 @@ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.3.0.tgz", "integrity": "sha512-MOd8pV3fxENbryESLgVIeaGKrdl+uaYhCSSVkjeOb/31/njTpcis5aWfdqgNlHIrKOLRbMnfPINPOML2CIFeXA==" }, + "node-forge": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.8.4.tgz", + "integrity": "sha512-UOfdpxivIYY4g5tqp5FNRNgROVNxRACUxxJREntJLFaJr1E0UEqFtUIk0F/jYx/E+Y6sVXd0KDi/m5My0yGCVw==" + }, "node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -9769,6 +9947,11 @@ "prepend-http": "^1.0.1" } }, + "url-template": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", + "integrity": "sha1-/FZaPMy/93MMd19WQflVV5FDnyE=" + }, "use": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", diff --git a/server/package.json b/server/package.json index 7ab954ab9..5ff82fde3 100644 --- a/server/package.json +++ b/server/package.json @@ -35,6 +35,7 @@ "cors": "^2.8.5", "express": "^4.16.4", "express-status-monitor": "^1.2.3", + "googleapis": "^39.2.0", "graphql": "^14.1.1", "graphql-server-express": "^1.4.0", "graphql-subscriptions": "^1.0.0", diff --git a/server/src/app.js b/server/src/app.js index c362d3e6b..4b3f0a281 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -70,6 +70,7 @@ class Events extends EventEmitter { this.askedToTrack = false; this.addedTaskTemplates = false; this.spaceEdventuresToken = null; + this.googleSheetsTokens = {}; this.events = []; this.replaying = false; this.snapshotVersion = 0; @@ -103,7 +104,8 @@ class Events extends EventEmitter { key === "doTrack" || key === "askedToTrack" || key === "addedTaskTemplates" || - key === "spaceEdventuresToken" + key === "spaceEdventuresToken" || + key === "googleSheetsTokens" ) { this[key] = snapshot[key]; } diff --git a/server/src/bootstrap/logs.js b/server/src/bootstrap/logs.js index 4283fcf85..75c5e864c 100644 --- a/server/src/bootstrap/logs.js +++ b/server/src/bootstrap/logs.js @@ -2,6 +2,8 @@ import paths from "../helpers/paths"; import fs from "fs"; import mkdirp from "mkdirp"; +require("dotenv").config(); + // There is an error message freaking users out, and I // can't figure out how to turn it off. So monkey patching // so it doesn't show up anymore. diff --git a/server/src/classes/googleSheets.js b/server/src/classes/googleSheets.js new file mode 100644 index 000000000..7250954fb --- /dev/null +++ b/server/src/classes/googleSheets.js @@ -0,0 +1,9 @@ +import uuid from "uuid"; + +export default class GoogleSheets { + constructor(params) { + this.id = params.id || uuid.v4(); + this.class = "GoogleSheets"; + this.simulatorId = params.simulatorId || null; + } +} diff --git a/server/src/classes/index.js b/server/src/classes/index.js index 9eac6b256..f4d18faf4 100644 --- a/server/src/classes/index.js +++ b/server/src/classes/index.js @@ -58,3 +58,4 @@ export { default as Transwarp } from "./transwarp"; export { default as Interface, InterfaceDevice } from "./interface"; export { default as Crm } from "./crm"; export { default as Macro } from "./macro"; +export { default as GoogleSheets } from "./googleSheets"; diff --git a/server/src/events/googleSheets.js b/server/src/events/googleSheets.js new file mode 100644 index 000000000..7f711745a --- /dev/null +++ b/server/src/events/googleSheets.js @@ -0,0 +1,139 @@ +import App from "../app"; +import { google } from "googleapis"; + +import { pubsub } from "../helpers/subscriptionManager"; +import * as Classes from "../classes"; + +const SCOPES = [ + "https://www.googleapis.com/auth/spreadsheets", + "https://www.googleapis.com/auth/drive.metadata.readonly" +]; + +function getOAuthClient() { + // Authorize a client with credentials, then call the Google Sheets API. + const credentials = { + installed: { + client_id: process.env.GOOGLE_SHEETS_CLIENT_ID, + project_id: process.env.GOOGLE_SHEETS_PROJECT_ID, + auth_uri: "https://accounts.google.com/o/oauth2/auth", + token_uri: "https://oauth2.googleapis.com/token", + auth_provider_x509_cert_url: "https://www.googleapis.com/oauth2/v1/certs", + client_secret: process.env.GOOGLE_SHEETS_SECRET, + redirect_uris: ["urn:ietf:wg:oauth:2.0:oob", "http://localhost"] + } + }; + const { client_secret, client_id, redirect_uris } = credentials.installed; + const oAuth2Client = new google.auth.OAuth2( + client_id, + client_secret, + redirect_uris[0] + ); + oAuth2Client.on("tokens", tokens => { + if (tokens.refresh_token) { + // store the refresh_token in my database! + App.googleSheetsTokens.refresh_token = tokens.refresh_token; + } + }); + if (App.googleSheetsTokens && App.googleSheetsTokens.access_token) { + oAuth2Client.setCredentials(App.googleSheetsTokens); + } + return oAuth2Client; +} + +App.on("googleSheetsAuthorize", ({ cb }) => { + const oAuth2Client = getOAuthClient(); + const authUrl = oAuth2Client.generateAuthUrl({ + access_type: "offline", + scope: SCOPES + }); + cb && cb(authUrl); +}); +App.on("googleSheetsCompleteAuthorize", ({ token }) => { + const oAuth2Client = getOAuthClient(); + console.log(token); + oAuth2Client.getToken(token, (err, authToken) => { + if (err) { + console.log(err); + throw new Error("Error while trying to retrieve access token"); + } + // Store the token to disk for later program executions + App.googleSheetsTokens = authToken; + oAuth2Client.setCredentials(authToken); + }); +}); + +App.on("googleSheetsFileSearch", async ({ searchText, cb }) => { + async function doIt() { + const oAuth2Client = getOAuthClient(); + const drive = google.drive({ + version: "v3", + auth: oAuth2Client + }); + const response = await drive.files + .list({ + corpora: "user", + q: `mimeType = 'application/vnd.google-apps.spreadsheet' and name contains '${searchText}'` + }) + .catch(err => console.log(err)); + return response.data.files; + } + cb && cb(doIt()); +}); + +App.on("googleSheetsGetSpreadsheet", ({ cb, spreadsheetId }) => { + async function doIt() { + const auth = getOAuthClient(); + const sheets = google.sheets({ version: "v4", auth }); + const sheet = await sheets.spreadsheets.get({ spreadsheetId }); + return sheet; + } + cb && cb(doIt()); +}); + +App.on( + "googleSheetsAppendData", + async ({ + spreadsheetId = "155poKeKfLeSbuYY4CpFPOccFOw41Z_J-a9YqFDU7bWI", + sheetId = "Sheet1", + data + }) => { + const auth = getOAuthClient(); + const sheets = google.sheets({ version: "v4", auth }); + const range = await sheets.spreadsheets.values + .get({ + spreadsheetId, + range: `${sheetId}!1:1` + }) + .catch(err => console.error(err)); + + const dataHeaders = Object.keys(data); + const row = Object.values(data); + + if (!range.data.values) { + // There's nothing in the first line, which is enough + // to tell us that the spreadsheet is empty. + // Just add the header row and the next row + + const values = [dataHeaders, row]; + const resource = { values }; + return sheets.spreadsheets.values + .append({ + spreadsheetId, + range: sheetId, + resource, + valueInputOption: "USER_ENTERED" + }) + .catch(err => console.error(err)); + } + + // Don't worry about matching rows to headers. Just append + const values = [row]; + const resource = { values }; + return sheets.spreadsheets.values.append({ + spreadsheetId, + range: sheetId, + resource, + valueInputOption: "USER_ENTERED" + }); + } +); diff --git a/server/src/events/index.js b/server/src/events/index.js index 8ddc4fc39..bc76f9518 100644 --- a/server/src/events/index.js +++ b/server/src/events/index.js @@ -59,3 +59,4 @@ import "./transwarp.js"; import "./interface.js"; import "./crm"; import "./macro.js"; +import "./googleSheets.js"; diff --git a/server/src/helpers/defaultSnapshot.js b/server/src/helpers/defaultSnapshot.js index 242547340..3e6b0b116 100644 --- a/server/src/helpers/defaultSnapshot.js +++ b/server/src/helpers/defaultSnapshot.js @@ -13632,6 +13632,7 @@ export default { askedToTrack: false, addedTaskTemplates: false, spaceEdventuresToken: "", + googleSheetsTokens: {}, migrations: { assets: true }, diff --git a/server/src/helpers/mutationHelper.js b/server/src/helpers/mutationHelper.js index 69f53419f..6bf724bef 100644 --- a/server/src/helpers/mutationHelper.js +++ b/server/src/helpers/mutationHelper.js @@ -8,7 +8,7 @@ export default function mutationHelper(schema, exceptions = []) { .reduce( (prev, next) => ({ ...prev, - [next]: async (root, args, context) => { + [next]: (root, args, context) => { let timeout = null; return new Promise(resolve => { App.handleEvent( diff --git a/server/src/typeDefs/googleSheets.js b/server/src/typeDefs/googleSheets.js new file mode 100644 index 000000000..e02389e77 --- /dev/null +++ b/server/src/typeDefs/googleSheets.js @@ -0,0 +1,91 @@ +import App from "../app"; +import { gql, withFilter } from "apollo-server-express"; +import { pubsub } from "../helpers/subscriptionManager"; +const mutationHelper = require("../helpers/mutationHelper").default; +// We define a schema that encompasses all of the types +// necessary for the functionality in this file. +const schema = gql` + type GoogleSheets { + id: ID + simulatorId: ID + } + type GoogleSheetFile { + id: ID + name: String + } + type GoogleSpreadsheet { + id: ID + title: String + sheets: [GoogleSheet] + } + type GoogleSheet { + id: ID + title: String + } + extend type Query { + googleSheets(simulatorId: ID): [GoogleSheets] + } + extend type Mutation { + googleSheetsAuthorize: String + googleSheetsCompleteAuthorize(token: String!): String + googleSheetsFileSearch(searchText: String!): [GoogleSheetFile] + googleSheetsGetSpreadsheet(spreadsheetId: ID!): GoogleSpreadsheet + googleSheetsAppendData( + spreadsheetId: ID + sheetId: String + data: JSON + ): String + } + extend type Subscription { + googleSheetsUpdate(simulatorId: ID): [GoogleSheets] + } +`; + +const resolver = { + GoogleSpreadsheet: { + id(data) { + return data.data.spreadsheetId; + }, + title(data) { + return data.data.properties.title; + }, + sheets(data) { + return data.data.sheets; + } + }, + GoogleSheet: { + id(data) { + return data.properties.sheetId; + }, + title(data) { + return data.properties.title; + } + }, + Query: { + googleSheets(root, { simulatorId }) { + let returnVal = []; + if (simulatorId) + returnVal = returnVal.filter(i => i.simulatorId === simulatorId); + return returnVal; + } + }, + Mutation: mutationHelper(schema), + Subscription: { + googleSheetsUpdate: { + resolve(rootValue, { simulatorId }) { + if (simulatorId) { + return rootValue.filter(s => s.simulatorId === simulatorId); + } + return rootValue; + }, + subscribe: withFilter( + () => pubsub.asyncIterator("googleSheetsUpdate"), + (rootValue, args) => { + return true; + } + ) + } + } +}; + +export default { schema, resolver }; From 898f4233ee03fd96ac5c989cf63402b3349de08b Mon Sep 17 00:00:00 2001 From: Alex Anderson Date: Fri, 24 May 2019 22:10:52 -0600 Subject: [PATCH 02/12] Connects survey forms to Google Sheets. --- .../src/containers/FlightDirector/Settings.js | 80 +++++- .../FlightDirector/SurveyConfig/index.js | 228 +++++++++++++++++- server/src/classes/surveyform.js | 8 + server/src/events/googleSheets.js | 21 +- server/src/events/surveyform.js | 8 + server/src/typeDefs/googleSheets.js | 33 ++- server/src/typeDefs/surveyForm.js | 9 + 7 files changed, 352 insertions(+), 35 deletions(-) diff --git a/client/src/containers/FlightDirector/Settings.js b/client/src/containers/FlightDirector/Settings.js index 12f4792c3..c684fcd02 100644 --- a/client/src/containers/FlightDirector/Settings.js +++ b/client/src/containers/FlightDirector/Settings.js @@ -1,5 +1,5 @@ import React from "react"; -import { Container } from "reactstrap"; +import { Container, Button, Input } from "reactstrap"; import { Query, Mutation } from "react-apollo"; import gql from "graphql-tag.macro"; @@ -18,6 +18,12 @@ const QUERY = gql` } `; +const GoogleSheetsQuery = gql` + query HasToken { + googleSheets + } +`; + const Settings = () => ( {({ loading, data }) => @@ -105,7 +111,7 @@ const Settings = () => ( } /> )} - {" "} +
@@ -124,6 +130,76 @@ const Settings = () => (
)} +
+

Google Sheets Connections

+ + {({ loading, data: { googleSheets } }) => ( + + {(action, { data }) => + googleSheets ? ( +
+

Connected to Google Sheets: {googleSheets}

+ + {action => ( + + )} + +
+ ) : data && data.googleSheetsAuthorize ? ( +
+ +
+ ) : ( + + ) + } +
+ )} +
+
) } diff --git a/client/src/containers/FlightDirector/SurveyConfig/index.js b/client/src/containers/FlightDirector/SurveyConfig/index.js index 47910fb3c..2c908526d 100755 --- a/client/src/containers/FlightDirector/SurveyConfig/index.js +++ b/client/src/containers/FlightDirector/SurveyConfig/index.js @@ -8,10 +8,20 @@ import { Button } from "reactstrap"; import gql from "graphql-tag.macro"; -import { graphql, withApollo } from "react-apollo"; +import { Query, graphql, withApollo } from "react-apollo"; import SubscriptionHelper from "helpers/subscriptionHelper"; import Form from "./form"; +import ReactDOM from "react-dom"; +import { Input } from "reactstrap"; +import Measure from "react-measure"; + +import debounce from "helpers/debounce"; import "./style.scss"; +const GoogleSheetsQuery = gql` + query HasToken { + googleSheets + } +`; const SUB = gql` subscription SurveyFormsUpdate { @@ -32,10 +42,150 @@ const SUB = gql` } title simulatorId + googleSpreadsheet + googleSpreadsheetName + googleSheet } } `; +const Search = ({ location, items, select, boxStyle = {}, listStyle = {} }) => ( +
+ {items.length === 0 && ( +

+ No Results +

+ )} + {items.map(item => ( +

select(item)} + style={listStyle} + > + {item.name} +

+ ))} +
+); + +class SearchForm extends Component { + state = { searchQuery: this.props.defaultValue }; + setSearch = debounce((value, client) => { + if (!value) { + return this.setState({ + sheets: null, + searchQuery: value + }); + } + if (value.length < 3) return; + client + .mutate({ + mutation: gql` + mutation Search($searchText: String!) { + googleSheetsFileSearch(searchText: $searchText) { + id + name + } + } + `, + variables: { + searchText: value + } + }) + .then(({ data: { googleSheetsFileSearch } }) => + this.setState({ searchQuery: value, sheets: googleSheetsFileSearch }) + ); + }, 500); + render() { + const { select = () => {}, inputProps = {}, listProps = {} } = this.props; + return ( + <> + this.setState({ dimensions: d.bounds })}> + {({ measureRef }) => ( +
+ + this.setSearch(e.target.value, this.props.client) + } + value={this.state.searchQuery} + {...inputProps} + /> +
+ )} +
+ {this.state.sheets && + ReactDOM.createPortal( + { + select(item); + this.setState({ sheets: null }); + }} + {...listProps} + />, + document.body + )} + + ); + } +} + +const SheetPicker = ({ sheet, id, name, select }) => { + function handleChange(e) {} + return ( + + {({ loading, data }) => { + if (loading) return "Loading..."; + const { googleSheetsGetSpreadsheet } = data; + return ( + select({ id, name, sheet: e.target.value })} + > + + {googleSheetsGetSpreadsheet.sheets.map(s => ( + + ))} + + ); + return "hi"; + }} + + ); +}; + +const WrappedSearch = withApollo(SearchForm); class Surveys extends Component { state = {}; createForm = () => { @@ -115,12 +265,38 @@ class Surveys extends Component { variables }); }; + handleSpreadsheetPick = ({ id, name, sheet }) => { + this.props.client.mutate({ + mutation: gql` + mutation SetSpreadsheet( + $id: ID! + $spreadsheetId: ID + $spreadsheetName: String + $sheetId: ID + ) { + setSurveyFormGoogleSheet( + id: $id + spreadsheetId: $spreadsheetId + spreadsheetName: $spreadsheetName + sheetId: $sheetId + ) + } + `, + variables: { + id: this.state.selectedForm, + spreadsheetId: id, + spreadsheetName: name, + sheetId: sheet + } + }); + }; render() { const { data: { loading, surveyform } } = this.props; const { selectedForm } = this.state; if (loading || !surveyform) return null; + const form = surveyform.find(f => f.id === selectedForm); return ( )} - - {selectedForm && ( -
f.id === selectedForm) && - surveyform.find(f => f.id === selectedForm).form - } - /> - )} - + {form && ( + <> + + + {({ loading, data: { googleSheets } }) => + googleSheets ? ( +
+

Google Sheets Connection

+ + {form.googleSpreadsheetName && ( + + )} +

+ + This will transmit form responses to this Google + Sheet when the form is submitted. + +

+
+ ) : null + } +
+ + + + + + )} ); @@ -204,6 +405,9 @@ const QUERY = gql` } title simulatorId + googleSpreadsheet + googleSpreadsheetName + googleSheet } } `; diff --git a/server/src/classes/surveyform.js b/server/src/classes/surveyform.js index 6e6de0388..e93809db7 100644 --- a/server/src/classes/surveyform.js +++ b/server/src/classes/surveyform.js @@ -9,12 +9,20 @@ export default class SurveyForm { this.form = params.form || []; this.results = params.results || []; this.active = params.active || false; + this.googleSpreadsheet = params.googleSpreadsheet || null; + this.googleSpreadsheetName = params.googleSpreadsheetName || null; + this.googleSheet = params.googleSheet || null; } updateForm(form) { if (Array.isArray(form)) { this.form = form; } } + updateGoogleSheets(spreadsheedId, spreadsheetName, sheetId) { + this.googleSpreadsheet = spreadsheedId; + this.googleSpreadsheetName = spreadsheetName; + this.googleSheet = sheetId; + } addResults({ client, form }) { const clientObj = App.clients.find(c => c.id === client); const station = clientObj.station; diff --git a/server/src/events/googleSheets.js b/server/src/events/googleSheets.js index 7f711745a..0d848ab7f 100644 --- a/server/src/events/googleSheets.js +++ b/server/src/events/googleSheets.js @@ -6,10 +6,12 @@ import * as Classes from "../classes"; const SCOPES = [ "https://www.googleapis.com/auth/spreadsheets", - "https://www.googleapis.com/auth/drive.metadata.readonly" + "https://www.googleapis.com/auth/drive.metadata.readonly", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile" ]; -function getOAuthClient() { +export function getOAuthClient() { // Authorize a client with credentials, then call the Google Sheets API. const credentials = { installed: { @@ -50,7 +52,6 @@ App.on("googleSheetsAuthorize", ({ cb }) => { }); App.on("googleSheetsCompleteAuthorize", ({ token }) => { const oAuth2Client = getOAuthClient(); - console.log(token); oAuth2Client.getToken(token, (err, authToken) => { if (err) { console.log(err); @@ -61,7 +62,9 @@ App.on("googleSheetsCompleteAuthorize", ({ token }) => { oAuth2Client.setCredentials(authToken); }); }); - +App.on("googleSheetsRevoke", () => { + App.googleSheetsTokens = {}; +}); App.on("googleSheetsFileSearch", async ({ searchText, cb }) => { async function doIt() { const oAuth2Client = getOAuthClient(); @@ -80,16 +83,6 @@ App.on("googleSheetsFileSearch", async ({ searchText, cb }) => { cb && cb(doIt()); }); -App.on("googleSheetsGetSpreadsheet", ({ cb, spreadsheetId }) => { - async function doIt() { - const auth = getOAuthClient(); - const sheets = google.sheets({ version: "v4", auth }); - const sheet = await sheets.spreadsheets.get({ spreadsheetId }); - return sheet; - } - cb && cb(doIt()); -}); - App.on( "googleSheetsAppendData", async ({ diff --git a/server/src/events/surveyform.js b/server/src/events/surveyform.js index 4c3591ce5..4ca543309 100644 --- a/server/src/events/surveyform.js +++ b/server/src/events/surveyform.js @@ -46,4 +46,12 @@ App.on("surveyFormResponse", ({ id, response }) => { pubsub.publish("surveyformUpdate", App.surveyForms); }); +App.on( + "setSurveyFormGoogleSheet", + ({ id, spreadsheetId, spreadsheetName, sheetId }) => { + const form = App.surveyForms.find(s => s.id === id); + form && form.updateGoogleSheets(spreadsheetId, spreadsheetName, sheetId); + pubsub.publish("surveyformUpdate", App.surveyForms); + } +); App.on("endSurvey", ({ id }) => {}); diff --git a/server/src/typeDefs/googleSheets.js b/server/src/typeDefs/googleSheets.js index e02389e77..11655f7eb 100644 --- a/server/src/typeDefs/googleSheets.js +++ b/server/src/typeDefs/googleSheets.js @@ -1,7 +1,10 @@ import App from "../app"; import { gql, withFilter } from "apollo-server-express"; import { pubsub } from "../helpers/subscriptionManager"; +import { getOAuthClient } from "../events/googleSheets"; +import { google } from "googleapis"; const mutationHelper = require("../helpers/mutationHelper").default; + // We define a schema that encompasses all of the types // necessary for the functionality in this file. const schema = gql` @@ -23,13 +26,14 @@ const schema = gql` title: String } extend type Query { - googleSheets(simulatorId: ID): [GoogleSheets] + googleSheets: String + googleSheetsGetSpreadsheet(spreadsheetId: ID!): GoogleSpreadsheet } extend type Mutation { googleSheetsAuthorize: String googleSheetsCompleteAuthorize(token: String!): String + googleSheetsRevoke: String googleSheetsFileSearch(searchText: String!): [GoogleSheetFile] - googleSheetsGetSpreadsheet(spreadsheetId: ID!): GoogleSpreadsheet googleSheetsAppendData( spreadsheetId: ID sheetId: String @@ -62,11 +66,26 @@ const resolver = { } }, Query: { - googleSheets(root, { simulatorId }) { - let returnVal = []; - if (simulatorId) - returnVal = returnVal.filter(i => i.simulatorId === simulatorId); - return returnVal; + async googleSheets() { + if (App.googleSheetsTokens.access_token) { + const client = getOAuthClient(); + const people = google.people({ + version: "v1", + auth: client + }); + const res = await people.people.get({ + resourceName: "people/me", + personFields: "emailAddresses" + }); + return res.data.emailAddresses[0].value; + } + return null; + }, + async googleSheetsGetSpreadsheet(root, { spreadsheetId }) { + const auth = getOAuthClient(); + const sheets = google.sheets({ version: "v4", auth }); + const sheet = await sheets.spreadsheets.get({ spreadsheetId }); + return sheet; } }, Mutation: mutationHelper(schema), diff --git a/server/src/typeDefs/surveyForm.js b/server/src/typeDefs/surveyForm.js index b93ffc1db..2c7f75faf 100644 --- a/server/src/typeDefs/surveyForm.js +++ b/server/src/typeDefs/surveyForm.js @@ -10,6 +10,9 @@ const schema = gql` simulatorId: ID title: String active: Boolean + googleSpreadsheet: ID + googleSpreadsheetName: String + googleSheet: String form: [FormFields] results: [FormResults] } @@ -61,6 +64,12 @@ const schema = gql` extend type Mutation { createSurveyForm(name: String!): String removeSurveyForm(id: ID!): String + setSurveyFormGoogleSheet( + id: ID! + spreadsheetId: ID + spreadsheetName: String + sheetId: ID + ): String updateSurveyForm(id: ID!, form: [FormFieldsInput]!): String triggerSurvey(simulatorId: ID!, id: ID!): String surveyFormResponse(id: ID!, response: FormResultsInput): String From 772a0f02f60bf9bb7f7aa28516294133fec3a20e Mon Sep 17 00:00:00 2001 From: Alex Anderson Date: Sat, 25 May 2019 08:21:31 -0600 Subject: [PATCH 03/12] feat(Surveys): Adds the ability to transmit survey data to Google Sheets. See the documentation page on https://thoriumsim.com/docs/forms_google_sheets for more information. --- .../src/containers/FlightDirector/Settings.js | 2 +- .../FlightDirector/SurveyConfig/index.js | 145 +---------------- .../FlightDirector/SurveyConfig/sheets.js | 148 ++++++++++++++++++ server/src/classes/surveyform.js | 54 +++++++ server/src/events/googleSheets.js | 13 +- 5 files changed, 208 insertions(+), 154 deletions(-) create mode 100644 client/src/containers/FlightDirector/SurveyConfig/sheets.js diff --git a/client/src/containers/FlightDirector/Settings.js b/client/src/containers/FlightDirector/Settings.js index c684fcd02..728dab0ae 100644 --- a/client/src/containers/FlightDirector/Settings.js +++ b/client/src/containers/FlightDirector/Settings.js @@ -174,7 +174,7 @@ const Settings = () => ( > {complete => ( + onChange={e => complete({ variables: { token: e.target.value } }) diff --git a/client/src/containers/FlightDirector/SurveyConfig/index.js b/client/src/containers/FlightDirector/SurveyConfig/index.js index 2c908526d..c1f9d3fb4 100755 --- a/client/src/containers/FlightDirector/SurveyConfig/index.js +++ b/client/src/containers/FlightDirector/SurveyConfig/index.js @@ -11,12 +11,8 @@ import gql from "graphql-tag.macro"; import { Query, graphql, withApollo } from "react-apollo"; import SubscriptionHelper from "helpers/subscriptionHelper"; import Form from "./form"; -import ReactDOM from "react-dom"; -import { Input } from "reactstrap"; -import Measure from "react-measure"; - -import debounce from "helpers/debounce"; import "./style.scss"; +import Search, { SheetPicker } from "./sheets"; const GoogleSheetsQuery = gql` query HasToken { googleSheets @@ -49,143 +45,6 @@ const SUB = gql` } `; -const Search = ({ location, items, select, boxStyle = {}, listStyle = {} }) => ( -
- {items.length === 0 && ( -

- No Results -

- )} - {items.map(item => ( -

select(item)} - style={listStyle} - > - {item.name} -

- ))} -
-); - -class SearchForm extends Component { - state = { searchQuery: this.props.defaultValue }; - setSearch = debounce((value, client) => { - if (!value) { - return this.setState({ - sheets: null, - searchQuery: value - }); - } - if (value.length < 3) return; - client - .mutate({ - mutation: gql` - mutation Search($searchText: String!) { - googleSheetsFileSearch(searchText: $searchText) { - id - name - } - } - `, - variables: { - searchText: value - } - }) - .then(({ data: { googleSheetsFileSearch } }) => - this.setState({ searchQuery: value, sheets: googleSheetsFileSearch }) - ); - }, 500); - render() { - const { select = () => {}, inputProps = {}, listProps = {} } = this.props; - return ( - <> - this.setState({ dimensions: d.bounds })}> - {({ measureRef }) => ( -
- - this.setSearch(e.target.value, this.props.client) - } - value={this.state.searchQuery} - {...inputProps} - /> -
- )} -
- {this.state.sheets && - ReactDOM.createPortal( - { - select(item); - this.setState({ sheets: null }); - }} - {...listProps} - />, - document.body - )} - - ); - } -} - -const SheetPicker = ({ sheet, id, name, select }) => { - function handleChange(e) {} - return ( - - {({ loading, data }) => { - if (loading) return "Loading..."; - const { googleSheetsGetSpreadsheet } = data; - return ( - select({ id, name, sheet: e.target.value })} - > - - {googleSheetsGetSpreadsheet.sheets.map(s => ( - - ))} - - ); - return "hi"; - }} - - ); -}; - -const WrappedSearch = withApollo(SearchForm); class Surveys extends Component { state = {}; createForm = () => { @@ -352,7 +211,7 @@ class Surveys extends Component { googleSheets ? (

Google Sheets Connection

- diff --git a/client/src/containers/FlightDirector/SurveyConfig/sheets.js b/client/src/containers/FlightDirector/SurveyConfig/sheets.js new file mode 100644 index 000000000..199bd5a3d --- /dev/null +++ b/client/src/containers/FlightDirector/SurveyConfig/sheets.js @@ -0,0 +1,148 @@ +import React, { Component } from "react"; +import gql from "graphql-tag.macro"; +import { Query, withApollo } from "react-apollo"; + +import ReactDOM from "react-dom"; +import { Input } from "reactstrap"; +import Measure from "react-measure"; + +import debounce from "helpers/debounce"; + +const Search = ({ location, items, select, boxStyle = {}, listStyle = {} }) => ( +
+ {items.length === 0 && ( +

+ No Results +

+ )} + {items.map(item => ( +

select(item)} + style={listStyle} + > + {item.name} +

+ ))} +
+); + +class SearchForm extends Component { + state = { searchQuery: this.props.defaultValue }; + setSearch = debounce((value, client) => { + if (!value) { + return this.setState({ + sheets: null, + searchQuery: value + }); + } + if (value.length < 3) return; + client + .mutate({ + mutation: gql` + mutation Search($searchText: String!) { + googleSheetsFileSearch(searchText: $searchText) { + id + name + } + } + `, + variables: { + searchText: value + } + }) + .then(({ data: { googleSheetsFileSearch } }) => + this.setState({ searchQuery: value, sheets: googleSheetsFileSearch }) + ); + }, 500); + render() { + const { select = () => {}, inputProps = {}, listProps = {} } = this.props; + return ( + <> + this.setState({ dimensions: d.bounds })}> + {({ measureRef }) => ( +
+ { + this.setState({ searchQuery: e.target.value }); + this.setSearch(e.target.value, this.props.client); + }} + value={this.state.searchQuery} + {...inputProps} + /> +
+ )} +
+ {this.state.sheets && + ReactDOM.createPortal( + { + select(item); + this.setState({ sheets: null, searchQuery: item.name }); + }} + {...listProps} + />, + document.body + )} + + ); + } +} + +export const SheetPicker = ({ sheet, id, name, select }) => { + return ( + + {({ loading, data }) => { + if (loading) return "Loading..."; + const { googleSheetsGetSpreadsheet } = data; + return ( + select({ id, name, sheet: e.target.value })} + > + + {googleSheetsGetSpreadsheet.sheets.map(s => ( + + ))} + + ); + }} + + ); +}; + +const WrappedSearch = withApollo(SearchForm); + +export default WrappedSearch; diff --git a/server/src/classes/surveyform.js b/server/src/classes/surveyform.js index e93809db7..f5e91c009 100644 --- a/server/src/classes/surveyform.js +++ b/server/src/classes/surveyform.js @@ -1,5 +1,8 @@ import uuid from "uuid"; import App from "../app"; + +import { DateTime } from "luxon"; + export default class SurveyForm { constructor(params) { this.id = params.id || uuid.v4(); @@ -26,9 +29,60 @@ export default class SurveyForm { addResults({ client, form }) { const clientObj = App.clients.find(c => c.id === client); const station = clientObj.station; + const simulator = App.simulators.find(s => s.id === clientObj.simulatorId); + const mission = App.missions.find(m => m.id === simulator.mission); const name = clientObj.loginName; + if (Array.isArray(form)) { this.results = this.results.concat({ client, station, name, form }); } + + // Process the results for Google Sheets + if (this.googleSpreadsheet && this.googleSheet) { + const headers = [ + "Timestamp", + "Name", + "Simulator", + "Station", + "Client", + "Mission" + ].concat(this.form.map(f => f.title)); + + const values = [ + new DateTime("now").toFormat("D TT"), + name, + simulator.name, + station, + client, + mission ? mission.name : "" + ].concat( + form.map(f => { + if (this.form.find(m => m.id === f.id)) { + const option = this.form + .find(m => m.id === f.id) + .options.filter(o => f.value.split(",").includes(o.id)) + .map(o => o.label) + .join("; "); + if (option) { + return option; + } + } + if (parseInt(f.value, 10)) { + return parseInt(f.value, 10); + } + return f.value; + }) + ); + + App.handleEvent( + { + spreadsheetId: this.googleSpreadsheet, + sheetId: this.googleSheet, + headers, + row: values + }, + "googleSheetsAppendData" + ); + } } } diff --git a/server/src/events/googleSheets.js b/server/src/events/googleSheets.js index 0d848ab7f..b792ae2d7 100644 --- a/server/src/events/googleSheets.js +++ b/server/src/events/googleSheets.js @@ -85,11 +85,7 @@ App.on("googleSheetsFileSearch", async ({ searchText, cb }) => { App.on( "googleSheetsAppendData", - async ({ - spreadsheetId = "155poKeKfLeSbuYY4CpFPOccFOw41Z_J-a9YqFDU7bWI", - sheetId = "Sheet1", - data - }) => { + async ({ spreadsheetId, sheetId, headers, row }) => { const auth = getOAuthClient(); const sheets = google.sheets({ version: "v4", auth }); const range = await sheets.spreadsheets.values @@ -99,15 +95,12 @@ App.on( }) .catch(err => console.error(err)); - const dataHeaders = Object.keys(data); - const row = Object.values(data); - - if (!range.data.values) { + if (!range.data.values && headers) { // There's nothing in the first line, which is enough // to tell us that the spreadsheet is empty. // Just add the header row and the next row - const values = [dataHeaders, row]; + const values = [headers, row]; const resource = { values }; return sheets.spreadsheets.values .append({ From ff9df011c33ecfe6fea3e7aa4a8e1ec5c8d65fcf Mon Sep 17 00:00:00 2001 From: Alex Anderson Date: Sat, 25 May 2019 08:54:26 -0600 Subject: [PATCH 04/12] Fixes environment variables. --- package-lock.json | 187 ++++++++++++++++++++++++++++++++++++++++++- package.json | 11 ++- scripts/build-env.js | 12 +++ 3 files changed, 204 insertions(+), 6 deletions(-) create mode 100644 scripts/build-env.js diff --git a/package-lock.json b/package-lock.json index 0de98e227..8a5779499 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "thorium", - "version": "1.2.18", + "version": "1.5.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -2329,6 +2329,14 @@ "through": ">=2.2.7 <3" } }, + "abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "requires": { + "event-target-shim": "^5.0.0" + } + }, "accepts": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.5.tgz", @@ -3656,6 +3664,11 @@ "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-0.1.5.tgz", "integrity": "sha1-c5JncZI7Whl0etZmqlzUv5xunOg=" }, + "base64-js": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.0.tgz", + "integrity": "sha512-ccav/yGvoa80BQDljCxsmmQ3Xvx60/UpBIij5QN21W3wBi/hhIC9OoO+KLpu9IJTS9j4DRVJ3aDDF9cMSoa2lw==" + }, "base64id": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/base64id/-/base64id-1.0.0.tgz", @@ -3689,6 +3702,11 @@ "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==" }, + "bignumber.js": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-7.2.1.tgz", + "integrity": "sha512-S4XzBk5sMB+Rcb/LNcpzXr57VRTxgAvaAEDAl1AwRx27j00hT84O6OkteE7u8UB3NuaaygCRrEpqox4uDOrbdQ==" + }, "binary": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", @@ -3812,6 +3830,11 @@ "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", "integrity": "sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=" }, + "buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=" + }, "buffer-fill": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz", @@ -5058,6 +5081,14 @@ "safer-buffer": "^2.1.0" } }, + "ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "requires": { + "safe-buffer": "^5.0.1" + } + }, "ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -5593,6 +5624,11 @@ "through": "~2.3.1" } }, + "event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==" + }, "eventemitter3": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.0.tgz", @@ -6181,6 +6217,11 @@ "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", "dev": true }, + "fast-text-encoding": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fast-text-encoding/-/fast-text-encoding-1.0.0.tgz", + "integrity": "sha512-R9bHCvweUxxwkDwhjav5vxpFvdPGlVngtqmx4pIZfSUhM/Q4NiIUHB456BAf+Q1Nwu3HEZYONtu+Rya+af4jiQ==" + }, "faye-websocket": { "version": "0.11.1", "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.1.tgz", @@ -6798,6 +6839,26 @@ "simple-git": "^1.85.0" } }, + "gaxios": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-2.0.1.tgz", + "integrity": "sha512-c1NXovTxkgRJTIgB2FrFmOFg4YIV6N/bAa4f/FZ4jIw13Ql9ya/82x69CswvotJhbV3DiGnlTZwoq2NVXk2Irg==", + "requires": { + "abort-controller": "^3.0.0", + "extend": "^3.0.2", + "https-proxy-agent": "^2.2.1", + "node-fetch": "^2.3.0" + } + }, + "gcp-metadata": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-2.0.0.tgz", + "integrity": "sha512-BN6KUUWo6WLkDRst+Y7bqpXq1PYMrKUecNLRdZESp7oYtMjWcZdAM0UYvcip8wb0GXNO/j8Z8HTccK4iYtMvyQ==", + "requires": { + "gaxios": "^2.0.0", + "json-bigint": "^0.3.0" + } + }, "get-caller-file": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.1.tgz", @@ -6953,6 +7014,71 @@ "slash": "^1.0.0" } }, + "google-auth-library": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-4.0.0.tgz", + "integrity": "sha512-yyxl74G16GjKLevccXK3/DYEXphtI9Q2Qw3Eh7y8scjBKNL0IbAZF1mi999gC0tkfG6J23sCbd9tMEbNYeWfJQ==", + "requires": { + "arrify": "^2.0.0", + "base64-js": "^1.3.0", + "fast-text-encoding": "^1.0.0", + "gaxios": "^2.0.0", + "gcp-metadata": "^2.0.0", + "gtoken": "^3.0.0", + "jws": "^3.1.5", + "lru-cache": "^5.0.0", + "semver": "^6.0.0" + }, + "dependencies": { + "arrify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", + "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==" + }, + "semver": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.1.0.tgz", + "integrity": "sha512-kCqEOOHoBcFs/2Ccuk4Xarm/KiWRSLEX9CAZF8xkJ6ZPlIoTZ8V5f7J16vYLJqDbR7KrxTJpR2lqjIEm2Qx9cQ==" + } + } + }, + "google-p12-pem": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/google-p12-pem/-/google-p12-pem-2.0.0.tgz", + "integrity": "sha512-n8eGSKzWOb9/EmSBIh81sPvsQM939QlpHMXahTZDzuRIpCu09x3Oaqz+mXGjL4TeCvSbcnOC0YZRvjkJ9s9lnA==", + "requires": { + "node-forge": "^0.8.0" + } + }, + "googleapis": { + "version": "40.0.0", + "resolved": "https://registry.npmjs.org/googleapis/-/googleapis-40.0.0.tgz", + "integrity": "sha512-G4iUF6V141mbgbXmbXQDYP0pOYJAONvA8m+RzYfuVBcwfKm7Pn6Aes9LT0a6ddmW9CmydHmHdOgKZuWwkXueXg==", + "requires": { + "google-auth-library": "^4.0.0", + "googleapis-common": "^2.0.0" + } + }, + "googleapis-common": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-2.0.0.tgz", + "integrity": "sha512-RyUkadTbrTWOCMnKYVYg1pxeH6oFKDr8WHOesbjsgPY1tS10q8Wdmf3VUKL3MMqNEM5ue2IxdfM2FzpYUGHaxA==", + "requires": { + "gaxios": "^2.0.0", + "google-auth-library": "^4.0.0", + "pify": "^4.0.0", + "qs": "^6.5.2", + "url-template": "^2.0.8", + "uuid": "^3.2.1" + }, + "dependencies": { + "pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==" + } + } + }, "graceful-fs": { "version": "4.1.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.11.tgz", @@ -7248,6 +7374,30 @@ } } }, + "gtoken": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-3.0.0.tgz", + "integrity": "sha512-IY9HVi78D4ykVHn+ThI7rlcpdFtKyo9e9YLim9S9T3rp6fEnfeTexcrqzSpExVshPofsdauLKIa8dEnzX7ZLfQ==", + "requires": { + "gaxios": "^2.0.0", + "google-p12-pem": "^2.0.0", + "jws": "^3.1.5", + "mime": "^2.2.0", + "pify": "^4.0.0" + }, + "dependencies": { + "mime": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.4.3.tgz", + "integrity": "sha512-QgrPRJfE+riq5TPZMcHZOtm8c6K/yYrMbKIoRfapfiGLxS8OTeIfRhUGW5LU7MlRa52KOAGCfUNruqLrIBvWZw==" + }, + "pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==" + } + } + }, "gzip-size": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-5.0.0.tgz", @@ -8628,6 +8778,14 @@ "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=", "optional": true }, + "json-bigint": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-0.3.0.tgz", + "integrity": "sha1-DM2RLEuCcNBfBW+9E4FLU9OCWx4=", + "requires": { + "bignumber.js": "^7.0.0" + } + }, "json-parse-better-errors": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", @@ -8707,6 +8865,25 @@ "array-includes": "^3.0.3" } }, + "jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "requires": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "requires": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "kind-of": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", @@ -10031,6 +10208,11 @@ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.3.0.tgz", "integrity": "sha512-MOd8pV3fxENbryESLgVIeaGKrdl+uaYhCSSVkjeOb/31/njTpcis5aWfdqgNlHIrKOLRbMnfPINPOML2CIFeXA==" }, + "node-forge": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.8.4.tgz", + "integrity": "sha512-UOfdpxivIYY4g5tqp5FNRNgROVNxRACUxxJREntJLFaJr1E0UEqFtUIk0F/jYx/E+Y6sVXd0KDi/m5My0yGCVw==" + }, "node-plop": { "version": "0.17.4", "resolved": "https://registry.npmjs.org/node-plop/-/node-plop-0.17.4.tgz", @@ -17081,8 +17263,7 @@ "url-template": { "version": "2.0.8", "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", - "integrity": "sha1-/FZaPMy/93MMd19WQflVV5FDnyE=", - "dev": true + "integrity": "sha1-/FZaPMy/93MMd19WQflVV5FDnyE=" }, "use": { "version": "3.1.1", diff --git a/package.json b/package.json index 1ab8b5449..f29af22c4 100644 --- a/package.json +++ b/package.json @@ -23,9 +23,10 @@ "test": "npm-run-all --parallel test-client test-server", "test-server": "npm t --prefix server", "test-client": "npm t --prefix client", - "build": "NODE_ENV=production npm-run-all build-js build-server mk-build copy-files pkg", + "build": "NODE_ENV=production npm-run-all build-js build-server mk-build copy-files build-env pkg", "build-js": "npm run build --prefix client", "build-server": "npm run build --prefix server", + "build-env": "node ./scripts/build-env.js", "mk-build": "mkdir -p build", "copy-files": "npm-run-all copy-server copy-client copy-package", "copy-server": "cp -R ./server/build/. build", @@ -56,6 +57,7 @@ "cors": "^2.8.5", "express": "^4.16.4", "express-status-monitor": "^1.2.3", + "googleapis": "^40.0.0", "graphql": "^14.1.1", "graphql-server-express": "^1.4.0", "graphql-subscriptions": "^1.0.0", @@ -142,12 +144,15 @@ "viewscreen/**/*", "helpers/mutationHelper.js", "sciences.ogg", - "favicon.ico" + "favicon.ico", + ".env" ] }, "eslintConfig": { "extends": "react-app", - "plugins": ["react-hooks"], + "plugins": [ + "react-hooks" + ], "rules": { "jsx-a11y/href-no-hash": "off", "react-hooks/rules-of-hooks": "error" diff --git a/scripts/build-env.js b/scripts/build-env.js new file mode 100644 index 000000000..f131d40a6 --- /dev/null +++ b/scripts/build-env.js @@ -0,0 +1,12 @@ +const fs = require("fs"); + +const envVars = [ + "ENGINE_API_KEY", + "GOOGLE_SHEETS_CLIENT_ID", + "GOOGLE_SHEETS_PROJECT_ID", + "GOOGLE_SHEETS_SECRET" +]; + +const data = envVars.map(e => `${e}=${process.env[e]}`).join("\n"); + +fs.writeFileSync("./build/.env", data); From 16fda93ad7f89cea8aa2f0038238b4c4b9a0ed8a Mon Sep 17 00:00:00 2001 From: Alex Anderson Date: Sat, 25 May 2019 10:50:58 -0600 Subject: [PATCH 05/12] feat(Surveys): Adds ability to export and import surveys. --- .../FlightDirector/SurveyConfig/index.js | 42 +++++++++++++++++-- server/src/bootstrap/express.js | 8 +++- server/src/imports/surveys/export.js | 15 +++++++ server/src/imports/surveys/import.js | 15 +++++++ 4 files changed, 75 insertions(+), 5 deletions(-) create mode 100644 server/src/imports/surveys/export.js create mode 100644 server/src/imports/surveys/import.js diff --git a/client/src/containers/FlightDirector/SurveyConfig/index.js b/client/src/containers/FlightDirector/SurveyConfig/index.js index c1f9d3fb4..73cb4a754 100755 --- a/client/src/containers/FlightDirector/SurveyConfig/index.js +++ b/client/src/containers/FlightDirector/SurveyConfig/index.js @@ -149,6 +149,24 @@ class Surveys extends Component { } }); }; + handleImport = evt => { + const data = new FormData(); + Array.from(evt.target.files).forEach((f, index) => + data.append(`files[${index}]`, f) + ); + fetch( + `${window.location.protocol}//${window.location.hostname}:${parseInt( + window.location.port, + 10 + ) + 1}/importSurvey`, + { + method: "POST", + body: data + } + ).then(() => { + window.location.reload(); + }); + }; render() { const { data: { loading, surveyform } @@ -198,10 +216,28 @@ class Surveys extends Component { Create Form {selectedForm && ( - + <> + + + )} + {form && ( <> diff --git a/server/src/bootstrap/express.js b/server/src/bootstrap/express.js index e3193431a..fd997d38f 100644 --- a/server/src/bootstrap/express.js +++ b/server/src/bootstrap/express.js @@ -23,6 +23,8 @@ import exportFlight from "../imports/flights/export"; import importFlight from "../imports/flights/import"; import exportTrigger from "../imports/triggers/export"; import importTrigger from "../imports/triggers/import"; +import exportSurvey from "../imports/surveys/export"; +import importSurvey from "../imports/surveys/import"; const exports = { exportMission: exportMission, @@ -32,7 +34,8 @@ const exports = { exportLibrary: exportLibrary, exportSoftwarePanel: exportSoftwarePanel, exportFlight, - exportTrigger + exportTrigger, + exportSurvey }; const imports = { @@ -43,7 +46,8 @@ const imports = { importAssets: importAssets, importSoftwarePanel: importSoftwarePanel, importFlight, - importTrigger + importTrigger, + importSurvey }; export default () => { let appDir = "./"; diff --git a/server/src/imports/surveys/export.js b/server/src/imports/surveys/export.js new file mode 100644 index 000000000..8bfb4b573 --- /dev/null +++ b/server/src/imports/surveys/export.js @@ -0,0 +1,15 @@ +import App from "../../app"; + +export default function exportSurvey(id, res) { + const survey = App.surveyForms.find(s => s.id === id); + if (!survey) { + return res.end("No survey form"); + } + const { id: surveyId, ...surveyData } = survey; + + res.set({ + "Content-Disposition": `attachment; filename=${survey.title}.survey`, + "Content-Type": "application/octet-stream" + }); + res.end(JSON.stringify(surveyData)); +} diff --git a/server/src/imports/surveys/import.js b/server/src/imports/surveys/import.js new file mode 100644 index 000000000..2f882afda --- /dev/null +++ b/server/src/imports/surveys/import.js @@ -0,0 +1,15 @@ +import fs from "fs"; +import App from "../../app"; +import * as Classes from "../../classes"; + +export default function ImportSurvey(filepath, cb) { + console.log("Importing Survey"); + const file = fs.readFileSync(filepath, "utf8"); + try { + const data = JSON.parse(file); + if (data.class === "SurveyForm") { + App.surveyForms.push(new Classes.SurveyForm(data)); + } + } catch (err) {} + cb(null); +} From 92ce4799ceda31d8ee7365dc2add892849b3eb87 Mon Sep 17 00:00:00 2001 From: Alex Anderson Date: Sat, 25 May 2019 13:22:31 -0600 Subject: [PATCH 06/12] feat(Interfaces): Adds interfaces as an option for set configs. Closes #2163 --- .../containers/FlightDirector/SetConfig.js | 100 ++++++++++++++---- server/src/helpers/stationResolver.js | 23 ++++ 2 files changed, 102 insertions(+), 21 deletions(-) diff --git a/client/src/containers/FlightDirector/SetConfig.js b/client/src/containers/FlightDirector/SetConfig.js index ef465e6fc..b720c135e 100644 --- a/client/src/containers/FlightDirector/SetConfig.js +++ b/client/src/containers/FlightDirector/SetConfig.js @@ -359,7 +359,9 @@ class SetConfig extends Component { } return ( -
  • Keyboards
  • +
  • + Keyboards +
  • {keyboard.map(k => (
  • - -
  • Mobile
  • - {mobileScreens.map(k => ( -
  • - this.setState({ - selectedStation: `mobile:${k}` - }) + - {k} + interfaces { + id + name + } + } + `} + variables={{ id: selectedSimulator }} + > + {({ loading, data }) => { + if (!data.simulators) return null; + const interfaces = data.simulators[0].interfaces + .map(i => data.interfaces.find(ii => ii.id === i)) + .filter(Boolean); + if (loading || interfaces.length === 0) { + return null; + } + return ( + +
  • + Interfaces +
  • + {interfaces.map(k => ( +
  • + this.setState({ + selectedStation: `interface-id:${k.id}` + }) + } + > + {k.name} +
  • + ))} +
    + ); + }} + + {mobileScreens.length > 0 ? ( + +
  • + Mobile
  • - ))} -
    + {mobileScreens + .filter(k => k !== "Interfaces") + .map(k => ( +
  • + this.setState({ + selectedStation: `mobile:${k}` + }) + } + > + {k} +
  • + ))} + + ) : null} )} @@ -408,11 +464,13 @@ class SetConfig extends Component {
      {clients - .filter(s => - selectedStation.indexOf("mobile:") === -1 + .filter(s => { + if (selectedStation.indexOf("interface-id:") > -1) + return true; + return selectedStation.indexOf("mobile:") === -1 ? !s.mobile - : s.mobile - ) + : s.mobile; + }) .map(s => (