├── addOn
├── .claspignore
├── src
│ ├── types.ts
│ ├── config.ts
│ ├── index.ts
│ ├── props.ts
│ ├── db.ts
│ ├── common.ts
│ └── gmail.ts
├── .babelrc
├── tsconfig.json
├── .eslintrc.json
├── appsscript.json
├── package.json
└── webpack.config.js
├── functions
├── globals.d.ts
├── config
│ ├── production.json
│ └── default.json
├── firebase.json
├── utils
│ ├── firebase.ts
│ └── stackDriverLogger.ts
├── src
│ ├── hashRouter.ts
│ ├── trialExtender.ts
│ ├── index.ts
│ └── subscriptionWebhook.ts
├── tsconfig.json
├── deploy.sh
├── package.json
└── webpack.config.js
├── .prettierrc
├── .gitignore
└── readme.md
/addOn/.claspignore:
--------------------------------------------------------------------------------
1 | main.js
2 | *-impl.html
--------------------------------------------------------------------------------
/functions/globals.d.ts:
--------------------------------------------------------------------------------
1 | declare const CONFIG: any
2 |
--------------------------------------------------------------------------------
/addOn/src/types.ts:
--------------------------------------------------------------------------------
1 | export interface AppState {
2 | user: any
3 | }
4 |
--------------------------------------------------------------------------------
/functions/config/production.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": "production"
3 | }
4 |
--------------------------------------------------------------------------------
/functions/firebase.json:
--------------------------------------------------------------------------------
1 | {
2 | "functions": {
3 | "source": "./dist"
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 120,
3 | "semi": false,
4 | "singleQuote": false,
5 | "trailingComma": "none",
6 | "tabWidth": 2
7 | }
8 |
--------------------------------------------------------------------------------
/functions/utils/firebase.ts:
--------------------------------------------------------------------------------
1 | import * as adminSDK from 'firebase-admin'
2 |
3 | adminSDK.initializeApp({
4 | credential: adminSDK.credential.cert(CONFIG.firebase)
5 | })
6 |
7 | export const storage = adminSDK.storage()
8 | export const auth = adminSDK.auth()
9 | export const firestore = adminSDK.firestore()
10 | export const admin = adminSDK
11 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # clasp files
2 | .clasp*
3 | creds.json
4 |
5 | # npm
6 | node_modules
7 |
8 | # vs code settings
9 | .vscode
10 |
11 | # caches
12 | *.cache
13 |
14 | # certs
15 | *.pem
16 | certs/
17 |
18 | # build
19 | **/dist/
20 | dist/
21 |
22 | # mac
23 | .DS_Store
24 |
25 | # misc
26 | .cache
27 | yarn-error.log
28 | .firebase
29 | .well-known/
30 | *.log
31 | *.lock
32 |
--------------------------------------------------------------------------------
/functions/src/hashRouter.ts:
--------------------------------------------------------------------------------
1 | import express from "express"
2 | import crypto from "crypto"
3 |
4 | const router = express.Router()
5 | router.get("/", (req, res) => {
6 | const { email }: any = req.query
7 | const token = crypto.createHmac("sha256", CONFIG.billing.secretKey).update(email).digest("hex")
8 | return res.status(200).send({ token })
9 | })
10 |
11 | export default router
12 |
--------------------------------------------------------------------------------
/addOn/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | [
4 | "@babel/env",
5 | {
6 | "modules": false
7 | }
8 | ]
9 | ],
10 | "plugins": [
11 | "transform-es3-property-literals",
12 | "transform-es3-member-expression-literals",
13 | "@babel/plugin-proposal-class-properties",
14 | "@babel/plugin-proposal-object-rest-spread",
15 | "@babel/plugin-transform-object-assign"
16 | ]
17 | }
18 |
--------------------------------------------------------------------------------
/addOn/src/config.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | firebaseSecret: {
3 | type: "service_account",
4 | project_id: "appname",
5 | private_key_id: "111",
6 | private_key: "111",
7 | client_email: "appname",
8 | client_id: "111",
9 | auth_uri: "https://appname",
10 | token_uri: "https://appname",
11 | auth_provider_x509_cert_url: "https://www.appname",
12 | client_x509_cert_url: "https://www.appname"
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/addOn/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "types": ["google-apps-script", "node"],
4 | "target": "es5",
5 | "module": "commonjs",
6 | "moduleResolution": "node",
7 | "resolveJsonModule": true,
8 | "allowJs": false,
9 | "sourceMap": true,
10 | "noImplicitAny": false,
11 | "experimentalDecorators": true,
12 | "allowSyntheticDefaultImports": true,
13 | "forceConsistentCasingInFileNames": false,
14 | "lib": ["es6", "dom"]
15 | },
16 | "exclude": ["node_modules", "dist"]
17 | }
18 |
--------------------------------------------------------------------------------
/functions/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es6",
4 | "module": "commonjs",
5 | "moduleResolution": "node",
6 | "resolveJsonModule": true,
7 | "jsx": "react",
8 | "allowJs": false,
9 | "sourceMap": true,
10 | "noImplicitAny": false,
11 | "experimentalDecorators": true,
12 | "allowSyntheticDefaultImports": true,
13 | "forceConsistentCasingInFileNames": false,
14 | "lib": ["es6", "dom"],
15 | "baseUrl": "./",
16 | "paths": {
17 | "src": ["src"],
18 | "utils": ["src/utils"]
19 | }
20 | },
21 | "exclude": ["node_modules", "dist"]
22 | }
23 |
--------------------------------------------------------------------------------
/functions/src/trialExtender.ts:
--------------------------------------------------------------------------------
1 | import { firestore } from "../utils/firebase"
2 |
3 | export default (app) => {
4 | app.post("/", (req, res) => {
5 | const { email } = req.body
6 | // Push recipient read event
7 | return firestore
8 | .collection("users")
9 | .doc(email)
10 | .get()
11 | .then((doc) => {
12 | if (!doc.exists) return res.sendStatus(400)
13 | firestore.collection("users")?.doc(email).update({
14 | trialExtended: Date.now()
15 | })
16 | return res.sendStatus(200)
17 | })
18 | .catch((e) => {
19 | console.error("Error:", e)
20 | })
21 | })
22 | }
23 |
--------------------------------------------------------------------------------
/functions/config/default.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": "development",
3 |
4 | "billing": {
5 | "secretKey": "billing_sk_test_xxxx"
6 | },
7 |
8 | "stripe": {
9 | "secretKey": "sk_test_xxxx",
10 | "webhookSecret": "whsec_xxxx"
11 | },
12 |
13 | "firebase": {
14 | "type": "service_account",
15 | "project_id": "xxxxx",
16 | "private_key_id": "0000",
17 | "private_key": "-----BEGIN PRIVATE KEY-----xxxxxxxx-----END PRIVATE KEY-----\n",
18 | "client_email": "firebase-xxxx@iam.gserviceaccount.com",
19 | "client_id": "11111",
20 | "auth_uri": "https://auth",
21 | "token_uri": "https://token",
22 | "auth_provider_x509_cert_url": "https://certs",
23 | "client_x509_cert_url": "https://www.googleapis.com/gserviceaccount.com"
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/addOn/src/index.ts:
--------------------------------------------------------------------------------
1 | import * as common from "./common"
2 | import * as gmail from "./gmail"
3 | import * as props from "./props"
4 | import * as db from "./db"
5 |
6 | declare const global: any
7 |
8 | // COMMON
9 | global.onOpen = (e: any) => common.createCatCard("good morning", true)
10 | global.createCatCard = common.createCatCard
11 | global.onChangeCat = common.onChangeCat
12 |
13 | // GMAIL
14 | global.onGmailMessage = gmail.onGmailMessage
15 | global.onGmailCompose = gmail.onGmailCompose
16 | global.onGmailInsertCat = gmail.onGmailInsertCat
17 |
18 | // PROPS
19 | global.setUserProperties = props.setUserProperties
20 | global.getUserProperties = props.getUserProperties
21 | global.deleteUserProperties = props.deleteUserProperties
22 | global.deleteScriptProperties = props.deleteScriptProperties
23 |
24 | // DB
25 | global.fetchUserData = db.fetchUserData
26 |
--------------------------------------------------------------------------------
/functions/deploy.sh:
--------------------------------------------------------------------------------
1 | BRANCH=$(git rev-parse --abbrev-ref HEAD)
2 |
3 | echo "🚀 Write 'prod' to deploy in production"
4 | read prod
5 |
6 | if [[ ! -z "$prod" ]] && [ $prod = "prod" ]; then
7 | echo "\n\n 🔥 🔥 🔥 🔥 🔥 🔥 🔥 🔥 🔥 🔥"
8 | echo "\n 🔥 Deploying in production 🔥\n"
9 | echo " 🔥 🔥 🔥 🔥 🔥 🔥 🔥 🔥 🔥 🔥\n\n"
10 | firebase use --add MyApp
11 | yarn run build
12 | # create a release tag and push it
13 | version=$(date '+cloudFunctions--%Y/%m/%d--%H.%M.%S')
14 | git tag -a $version -m "Cloud functions Production release version $version"
15 | echo "Release tagged $version"
16 | git push --tags
17 |
18 | else
19 | echo "Please enter prod to deploy in production"
20 | exit 1 # terminate and indicate error
21 | fi
22 |
23 | firebase deploy --only functions
24 |
25 | # send a notification when deploy is done
26 | terminal-notifier -group 'MyApp' -title 'MyApp CloudFunctions' -message 'Finished' || echo "done"
27 | say "Cloud functions deployed" -r 100 || echo "deployed"
28 |
--------------------------------------------------------------------------------
/functions/src/index.ts:
--------------------------------------------------------------------------------
1 | import cors from "cors"
2 | import * as express from "express"
3 | import * as functions from "firebase-functions"
4 | import { reportError } from "../utils/stackdriverLogger"
5 | import trackingLogger from "./trackingLogger"
6 |
7 | require("firebase-functions/lib/logger/compat") // Prettify logs in firebase
8 |
9 | /*************************************************
10 | * 🤖 API
11 | ************************************************/
12 | // 🔼 Express app can be called with an HTTP request https://firebase.google.com/docs/functions/http-events
13 | const app = express()
14 | const corsConfig = cors({ origin: true })
15 | // 1️⃣ set request handler:
16 | app.use(corsConfig)
17 | // 2️⃣ set the app controllers: [EDIT HERE ✍🏻]
18 | app.use("/trackingLogger", trackingLogger)
19 |
20 | // health check
21 | app.get("/", (req, res) => res.status(200).json({ status: "ok" }))
22 |
23 | // 3️⃣ set error handler:
24 | app.use((err, req, res, next) => {
25 | console.error(err)
26 | reportError(err, { request: req, details: "API Error" }, req)
27 | res.status(500).json({ error: err.message || "An unknown error occurred." })
28 | })
29 | // 4️⃣ export:
30 | exports.api = functions.https.onRequest(app)
31 |
--------------------------------------------------------------------------------
/addOn/src/props.ts:
--------------------------------------------------------------------------------
1 | // User properties & script properties are the equivalent of the local storage for Gscript add-ons:
2 |
3 | // USER PROPERTY:
4 | export const setUserProperty = (uid: string, property: any) =>
5 | PropertiesService?.getUserProperties()?.setProperty(uid, property)
6 | export const getUserProperty = (uid: string) => PropertiesService?.getUserProperties()?.getProperty(uid)
7 | export const deleteUserProperty = (uid: string) => {
8 | console.log("⏩ deleting user prop")
9 | PropertiesService?.getUserProperties()?.deleteProperty(uid)
10 | }
11 |
12 | // USER PROPERTIES:
13 | export const setUserProperties = (properties: any) => PropertiesService?.getUserProperties()?.setProperties(properties)
14 | export const getUserProperties = () => PropertiesService?.getUserProperties()?.getProperties()
15 | export const deleteUserProperties = () => {
16 | console.log("⏩ deleting user props")
17 | return PropertiesService?.getUserProperties()?.deleteAllProperties()
18 | }
19 |
20 | // SCRIPT PROPERTIES:
21 | export const setScriptProperty = (properties: any) =>
22 | PropertiesService?.getScriptProperties()?.setProperties(properties)
23 | export const deleteScriptProperties = () => {
24 | console.log("⏩ deleting script props")
25 | return PropertiesService?.getScriptProperties()?.deleteAllProperties()
26 | }
27 |
--------------------------------------------------------------------------------
/addOn/src/db.ts:
--------------------------------------------------------------------------------
1 | import config from "./config"
2 |
3 | declare const FirestoreApp: any
4 |
5 | const { client_email, private_key, project_id } = config.firebaseSecret
6 | const firestore = FirestoreApp.getFirestore(client_email, private_key, project_id)
7 | const getUserEmail = (): any => Session.getEffectiveUser().getEmail() // or Session.getActiveUser()
8 | const timestamp = Date.now()
9 |
10 | export const fetchUserData = (): any => {
11 | let userDocument = null
12 | // 1️⃣ TRY TO FETCH USER
13 | try {
14 | userDocument = firestore.getDocument(`users/${getUserEmail()}`)?.obj
15 | } catch (error) {
16 | console.error(`>> No user found, creating one. \n>>Email: ${getUserEmail()} \n>>Error: ${error}`)
17 | }
18 | if (userDocument) return userDocument
19 | // 2️⃣ IF DOESNT EXIST => CREATE NEW USER IN DB
20 | const newUserObject = {
21 | userEmail: getUserEmail(),
22 | createdAt: timestamp
23 | }
24 | firestore.createDocument(`users/${getUserEmail()}`, newUserObject)
25 | Utilities.sleep(1000)
26 | // 3️⃣ NOW TRY AGAIN WITH NEW CREATED USER
27 | try {
28 | userDocument = firestore.getDocument(`users/${getUserEmail()}`)?.obj
29 | } catch (error) {
30 | console.warn(`>> Error with ${getUserEmail() || "unknown"}:`)
31 | console.error(error)
32 | }
33 | if (userDocument) return userDocument
34 | // 4️⃣ FAILSAFE
35 | return newUserObject
36 | }
37 |
38 | export const fetchTrackingData = (): any => {
39 | const trackingData = firestore.getDocuments(`users/${getUserEmail()}/trackingData`).map((doc) => {
40 | const { events } = doc.obj
41 | const trackingPath = doc.path.split("/")
42 | const trackingId = trackingPath[trackingPath.length - 1]
43 | return { events, trackingId }
44 | })
45 | return trackingData || []
46 | }
47 |
--------------------------------------------------------------------------------
/functions/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "functions",
3 | "scripts": {
4 | "lint": "tslint --project tsconfig.json",
5 | "dev": "NODE_ENV='development' webpack --progress --colors --watch --display-error-details",
6 | "build": "NODE_ENV='production' webpack --progress --colors",
7 | "build_staging": "NODE_ENV='development' webpack --progress --colors",
8 | "serve": "firebase use --add gmailmerge && firebase serve --only functions",
9 | "shell": "firebase functions:shell",
10 | "start": "npm run shell",
11 | "deploy": "./deploy.sh",
12 | "logs": "firebase functions:log"
13 | },
14 | "engines": {
15 | "node": "16"
16 | },
17 | "main": "index.js",
18 | "dependencies": {
19 | "@google-cloud/error-reporting": "^1.1.3",
20 | "@google-cloud/logging-winston": "^3.0.6",
21 | "@google-cloud/storage": "^4.3.2",
22 | "algoliasearch": "^4.4.0",
23 | "atob": "^2.1.2",
24 | "axios": ">=0.21.1",
25 | "bluebird": "^3.7.2",
26 | "body-parser": "^1.19.0",
27 | "clean-webpack-plugin": "^3.0.0",
28 | "copy-webpack-plugin": "^5.1.1",
29 | "cors": "^2.8.5",
30 | "express": "^4.17.1",
31 | "firebase": "^7.15.4",
32 | "firebase-admin": "^9.1.1",
33 | "firebase-functions": "^3.11.0",
34 | "googleapis": "^46.0.0"
35 | },
36 | "devDependencies": {
37 | "@firebase/testing": "^0.20.4",
38 | "@types/node": "^16.6.2",
39 | "config": "^3.0.1",
40 | "config-webpack": "^1.0.4",
41 | "firebase-functions-test": "^0.2.1",
42 | "ts-loader": "^6.0.2",
43 | "tslint": "^5.17.0",
44 | "tslint-config-airbnb": "^5.11.1",
45 | "tslint-config-prettier": "^1.18.0",
46 | "typescript": "^3.7.2",
47 | "webpack": "^4.33.0",
48 | "webpack-cli": "^3.3.10",
49 | "webpack-node-externals": "^1.7.2"
50 | },
51 | "private": true
52 | }
53 |
--------------------------------------------------------------------------------
/addOn/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "root": true,
3 | "parser": "@typescript-eslint/parser",
4 | "extends": [
5 | "airbnb",
6 | "eslint:recommended",
7 | "prettier",
8 | "plugin:@typescript-eslint/recommended",
9 | "prettier/@typescript-eslint"
10 | ],
11 | "plugins": ["prettier", "@typescript-eslint", "jsx-a11y", "googleappsscript"],
12 | "settings": {
13 | "import/parsers": {
14 | "@typescript-eslint/parser": [".ts", ".tsx"]
15 | },
16 | "import/resolver": {
17 | "typescript": {}
18 | }
19 | },
20 |
21 | "rules": {
22 | "no-this-assignment": false,
23 | "import-name": false,
24 | "no-increment-decrement": false,
25 | "no-implicit-dependencies": false,
26 | "ordered-imports": false,
27 | "semi": "off",
28 | "no-use-before-define": "off",
29 | "comma-dangle": "off",
30 | "camelcase": "warn",
31 | "prefer-object-spread": "warn",
32 | "spaced-comment": "off",
33 | "no-else-return": "off",
34 | "no-console": "off",
35 | "prettier/prettier": "warn",
36 | "dot-notation": "off",
37 | "explicit-module-boundary-types": "off",
38 | "import/order": "off",
39 | "consistent-return": "off",
40 | "@typescript-eslint/ban-types": "off",
41 | "@typescript-eslint/no-var-requires": "off",
42 | "import/prefer-default-export": "off",
43 | "import/no-extraneous-dependencies": "warn",
44 | "import/extensions": [
45 | "warn",
46 | "never",
47 | {
48 | "js": "never",
49 | "jsx": "never",
50 | "ts": "never",
51 | "tsx": "never"
52 | }
53 | ],
54 | "@typescript-eslint/indent": [2, 2],
55 | "@typescript-eslint/no-use-before-define": ["off"],
56 | "eslintjsx-a11y/click-events-have-key-events": "off"
57 | },
58 | "env": {
59 | "googleappsscript/googleappsscript": true
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/functions/webpack.config.js:
--------------------------------------------------------------------------------
1 | const nodeExternals = require("webpack-node-externals")
2 | const path = require("path")
3 |
4 | const devMode = process.env.NODE_ENV === "development"
5 | const isProduction = process.env.NODE_ENV === "production"
6 |
7 | const SOURCE_DIR = path.resolve(__dirname, "src")
8 | const OUTPUT_DIR = path.resolve(__dirname, "dist")
9 |
10 | const ConfigWebpackPlugin = require("config-webpack")
11 | const CopyWebpackPlugin = require("copy-webpack-plugin")
12 | const { CleanWebpackPlugin } = require("clean-webpack-plugin")
13 |
14 | module.exports = {
15 | mode: devMode ? "development" : "production",
16 | entry: SOURCE_DIR,
17 | output: {
18 | path: OUTPUT_DIR,
19 | filename: "index.js", // <-- Important
20 | libraryTarget: "this" // <-- Important
21 | },
22 | target: "node", // <-- Important
23 | resolve: {
24 | extensions: [".ts", ".tsx", ".json"]
25 | },
26 | // Generate sourcemaps for proper error messages
27 | devtool: "source-map",
28 | // we exclude all node dependencies
29 | externals: [nodeExternals()],
30 | mode: isProduction ? "production" : "development",
31 | optimization: {
32 | // We no not want to minimize our code.
33 | minimize: false
34 | },
35 | performance: {
36 | // Turn off size warnings for entry points
37 | hints: false
38 | },
39 | module: {
40 | rules: [
41 | {
42 | test: /\.tsx?$/,
43 | loader: "ts-loader",
44 | options: {
45 | transpileOnly: true,
46 | compilerOptions: {
47 | module: "es2015"
48 | }
49 | },
50 | exclude: /node_modules/
51 | }
52 | ]
53 | },
54 | plugins: [
55 | new CleanWebpackPlugin({
56 | dry: !isProduction
57 | }),
58 | new ConfigWebpackPlugin(),
59 | new CopyWebpackPlugin([
60 | {
61 | from: "./package.json",
62 | to: OUTPUT_DIR
63 | }
64 | ])
65 | ]
66 | }
67 |
--------------------------------------------------------------------------------
/functions/utils/stackDriverLogger.ts:
--------------------------------------------------------------------------------
1 | import winston from 'winston'
2 | import * as Express from 'express'
3 | // Imports the Google Cloud client library
4 | import { ErrorReporting } from '@google-cloud/error-reporting'
5 |
6 | // Imports the Google Cloud client library for Winston
7 | import { LoggingWinston } from '@google-cloud/logging-winston'
8 |
9 | let errorReporting
10 | let loggingWinston
11 | let winstonLogger
12 |
13 | export const initStackDriverLogger = () => {
14 | // if (CONFIG.env !== "production") return
15 | try {
16 | // Instantiates a client
17 | errorReporting = new ErrorReporting({
18 | reportMode: 'production'
19 | })
20 | loggingWinston = new LoggingWinston()
21 |
22 | // Create a Winston logger that streams to Stackdriver Logging
23 | // Logs will be written to: "projects/YOUR_PROJECT_ID/logs/winston_log"
24 | winstonLogger = winston.createLogger({
25 | level: 'info',
26 | transports: [
27 | new winston.transports.Console(),
28 | // Add Stackdriver Logging
29 | loggingWinston
30 | ]
31 | })
32 | } catch (error) {
33 | console.error(error)
34 | }
35 | }
36 |
37 | // report error
38 | export const reportError = (
39 | error: Error,
40 | metadata?: Object,
41 | request?: Express.Request
42 | ) => {
43 | // if (CONFIG.env !== "production") return
44 | try {
45 | const event = errorReporting.event()
46 | event.setMessage(error.stack)
47 | if (request) {
48 | event.setUserAgent(request.get('User-Agent'))
49 | event.setHttpMethod(request.method)
50 | event.setUrl(request.originalUrl)
51 | }
52 | errorReporting.report(event)
53 | logError(error, metadata)
54 | } catch (error) {
55 | console.error(error)
56 | }
57 | }
58 |
59 | // log with full details
60 | export const logError = (error: Error, metadata?: Object) => {
61 | // if (CONFIG.env !== "production") return
62 | try {
63 | winstonLogger.error(error.message, metadata)
64 | } catch (error) {
65 | console.error(error)
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/addOn/src/common.ts:
--------------------------------------------------------------------------------
1 | export const createCatCard = (text: string, isHomepage?: boolean) => {
2 | // Explicitly set the value of isHomepage as false if null or undefined.
3 | if (!isHomepage) isHomepage = false
4 | const now = new Date()
5 | // Replace formward slashes in the text, as they break the CataaS API.
6 | const caption = text.replace(/\//g, " ")
7 | const imageUrl = Utilities.formatString(
8 | "https://cataas.com/cat/says/%s?time=%s",
9 | // @ts-ignore
10 | encodeURIComponent(caption),
11 | now.getTime()
12 | )
13 | const image = CardService.newImage().setImageUrl(imageUrl).setAltText("Meow")
14 | const action = CardService.newAction()
15 | .setFunctionName("onChangeCat")
16 | .setParameters({ text: text, isHomepage: isHomepage.toString() })
17 | const button = CardService.newTextButton()
18 | .setText("Change cat t")
19 | .setOnClickAction(action)
20 | .setTextButtonStyle(CardService.TextButtonStyle.FILLED)
21 | const buttonSet = CardService.newButtonSet().addButton(button)
22 | const footer = CardService.newFixedFooter().setPrimaryButton(
23 | CardService.newTextButton()
24 | .setText("Powered by cataas.com")
25 | .setOpenLink(CardService.newOpenLink().setUrl("https://cataas.com"))
26 | )
27 | const section = CardService.newCardSection().addWidget(image).addWidget(buttonSet)
28 | const card = CardService.newCardBuilder().addSection(section).setFixedFooter(footer)
29 | if (!isHomepage) {
30 | const peekHeader = CardService.newCardHeader()
31 | .setTitle("Contextual Cat")
32 | .setImageUrl("https://www.gstatic.com/images/icons/material/system/1x/pets_black_48dp.png")
33 | .setSubtitle(text)
34 | card.setPeekCardHeader(peekHeader)
35 | }
36 | return card.build()
37 | }
38 |
39 | export const onChangeCat = (e: { parameters: { text: any; isHomepage: string } }) => {
40 | console.log(e)
41 | const text = e.parameters.text
42 | const isHomepage = e.parameters.isHomepage === "true"
43 | const card = createCatCard(text, isHomepage)
44 | const navigation = CardService.newNavigation().updateCard(card)
45 | const actionResponse = CardService.newActionResponseBuilder().setNavigation(navigation)
46 | return actionResponse.build()
47 | }
48 |
--------------------------------------------------------------------------------
/addOn/appsscript.json:
--------------------------------------------------------------------------------
1 | {
2 | "addOns": {
3 | "common": {
4 | "name": "TEST ENV",
5 | "logoUrl": "https://www.gstatic.com/images/icons/material/system/1x/pets_black_48dp.png",
6 | "useLocaleFromApp": true,
7 | "universalActions": [
8 | {
9 | "label": "Learn more about Cataas",
10 | "openLink": "https://cataas.com"
11 | }
12 | ],
13 | "layoutProperties": {
14 | "primaryColor": "#2772ed"
15 | },
16 | "homepageTrigger": {
17 | "runFunction": "onOpen"
18 | }
19 | },
20 | "sheets": {},
21 | "docs": {},
22 | "slides": {},
23 | "drive": {},
24 | "calendar": {},
25 | "gmail": {
26 | "contextualTriggers": [
27 | {
28 | "unconditional": {},
29 | "onTriggerFunction": "onGmailMessage"
30 | }
31 | ],
32 | "composeTrigger": {
33 | "selectActions": [
34 | {
35 | "text": "Insert cat",
36 | "runFunction": "onGmailCompose"
37 | }
38 | ],
39 | "draftAccess": "METADATA"
40 | }
41 | }
42 | },
43 | "timeZone": "America/New_York",
44 | "dependencies": {
45 | "libraries": [
46 | {
47 | "userSymbol": "FirestoreApp",
48 | "libraryId": "1VUSl4b1r1eoNcRWotZM3e87ygkxvXltOgyDZhixqncz9lQ3MjfT1iKFw",
49 | "version": "33"
50 | }
51 | ]
52 | },
53 | "exceptionLogging": "STACKDRIVER",
54 | "oauthScopes": [
55 | "https://www.googleapis.com/auth/documents.currentonly",
56 | "https://www.googleapis.com/auth/spreadsheets.currentonly",
57 | "https://www.googleapis.com/auth/presentations.currentonly",
58 | "https://www.googleapis.com/auth/drive.addons.metadata.readonly",
59 |
60 | "https://www.googleapis.com/auth/gmail.modify",
61 | "https://www.googleapis.com/auth/gmail.addons.current.action.compose",
62 | "https://www.googleapis.com/auth/gmail.addons.current.message.readonly",
63 | "https://www.googleapis.com/auth/gmail.addons.execute",
64 | "https://www.googleapis.com/auth/gmail.addons.current.message.metadata",
65 |
66 | "https://www.googleapis.com/auth/script.locale",
67 | "https://www.googleapis.com/auth/script.external_request",
68 | "https://www.googleapis.com/auth/script.scriptapp",
69 | "https://www.googleapis.com/auth/userinfo.email"
70 | ],
71 | "runtimeVersion": "V8"
72 | }
73 |
--------------------------------------------------------------------------------
/addOn/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Google-Workspace-Addon-Boilerplate",
3 | "version": "2.1.0",
4 | "description": "Google Apps Script Boilerplate",
5 | "scripts": {
6 | "login": "clasp login",
7 | "setup": "rm -rf .clasp.json && clasp create --type standalone --title 'My Google Workspace Addon' && clasp setting rootDir ./dist",
8 | "build": "webpack",
9 | "deploy": "rm -rf dist && npm run build && npx clasp push"
10 | },
11 | "author": "nathgilson",
12 | "engines": {
13 | "node": ">=10.0.0",
14 | "npm": ">=6.0.0"
15 | },
16 | "dependencies": {
17 | "axios": "^0.21.1",
18 | "eslint-plugin-import": "^2.22.1",
19 | "firestore-parser": "^0.9.0",
20 | "gas-client": "^0.1.0",
21 | "typescript": "^4.0.5",
22 | "use-interval": "^1.3.0"
23 | },
24 | "devDependencies": {
25 | "@babel/cli": "^7.10.5",
26 | "@babel/core": "^7.11.1",
27 | "@babel/plugin-proposal-class-properties": "^7.10.4",
28 | "@babel/plugin-proposal-object-rest-spread": "^7.11.0",
29 | "@babel/plugin-transform-object-assign": "^7.10.4",
30 | "@babel/polyfill": "^7.10.4",
31 | "@babel/preset-env": "^7.11.0",
32 | "@google/clasp": "^2.3.0",
33 | "@types/node": "^14.14.21",
34 | "@typescript-eslint/eslint-plugin": "^4.8.1",
35 | "@typescript-eslint/parser": "^4.8.1",
36 | "@types/google-apps-script": "^1.0.41",
37 | "babel-eslint": "^10.1.0",
38 | "babel-loader": "^8.1.0",
39 | "babel-plugin-add-module-exports": "^1.0.2",
40 | "babel-plugin-transform-es3-member-expression-literals": "^6.22.0",
41 | "babel-plugin-transform-es3-property-literals": "^6.22.0",
42 | "config": "^3.3.1",
43 | "config-webpack": "^1.0.5",
44 | "copy-webpack-plugin": "^6.0.3",
45 | "dotenv": "^8.2.0",
46 | "dynamic-cdn-webpack-plugin": "^5.0.0",
47 | "eslint": "^7.13.0",
48 | "eslint-config-airbnb": "^18.2.1",
49 | "eslint-config-airbnb-base": "^14.2.0",
50 | "eslint-config-prettier": "^6.15.0",
51 | "eslint-config-standard": "^14.1.1",
52 | "eslint-import-resolver-typescript": "^2.3.0",
53 | "eslint-loader": "^3.0.4",
54 | "eslint-plugin-babel": "^5.3.1",
55 | "eslint-plugin-googleappsscript": "^1.0.3",
56 | "eslint-plugin-json": "^2.1.2",
57 | "eslint-plugin-jsx-a11y": "^6.4.1",
58 | "eslint-plugin-node": "^11.1.0",
59 | "eslint-plugin-prettier": "^3.1.4",
60 | "eslint-plugin-promise": "^4.2.1",
61 | "eslint-plugin-standard": "^4.0.1",
62 | "gas-lib": "^2.0.3",
63 | "gas-types-detailed": "^1.0.0",
64 | "gas-webpack-plugin": "^1.0.4",
65 | "google-apps-script-webpack-dev-server": "^1.0.0",
66 | "module-to-cdn": "^3.1.5",
67 | "prettier": "^2.1.2",
68 | "tern": "^0.24.3",
69 | "terser-webpack-plugin": "^2.3.7",
70 | "ts-loader": "^8.0.2",
71 | "webpack": "^4.44.1",
72 | "webpack-cli": "^3.3.12",
73 | "webpack-dev-server": "^3.11.0"
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/functions/src/subscriptionWebhook.ts:
--------------------------------------------------------------------------------
1 | import express from "express"
2 | import { firestore } from "../utils/firebase"
3 | import stripe from "../utils/stripe"
4 |
5 | const router = express.Router()
6 |
7 | router.get("/", (req, res) => {
8 | // health check endpoint
9 | res.status(200).send({ data: "up and running" })
10 | })
11 |
12 | router.post("/", (req: any, res: any) => {
13 | let event
14 | try {
15 | // check stripe event signature
16 | event = stripe.webhooks.constructEvent(req.rawBody, req.headers["stripe-signature"], CONFIG.stripe.webhookSecret)
17 | } catch (error) {
18 | console.error(error)
19 | return res.status(400).send({ error })
20 | }
21 |
22 | if (!event || !event.type || !event.data?.object) return res.status(400).send({ event, error: "Missing data" })
23 |
24 | const { status, customer: customerId, id: subscriptionId } = event.data.object
25 |
26 | const { id: plan } = event.data.object?.plan
27 |
28 | return stripe.customers
29 | .retrieve(customerId)
30 | .then((customer: any) => {
31 | if (customer.deleted) {
32 | throw Error(`Customer was deleted`)
33 | }
34 | return customer.email
35 | })
36 | .then((email) => {
37 | switch (event.type) {
38 | // CREATED
39 | case "customer.subscription.created":
40 | return firestore
41 | .collection("users")
42 | .doc(email)
43 | .set(
44 | {
45 | subscriptionId,
46 | status,
47 | plan
48 | },
49 | { merge: true }
50 | )
51 | .then(() => res.status(200).send({ event, status: "created" }))
52 |
53 | // UPDATED
54 | case "customer.subscription.updated":
55 | return firestore
56 | .collection("users")
57 | .doc(email)
58 | .set(
59 | {
60 | subscriptionId,
61 | status,
62 | plan
63 | },
64 | { merge: true }
65 | )
66 | .then(() => res.status(200).send({ event, status: "updated" }))
67 |
68 | // DELETED
69 | case "customer.subscription.deleted":
70 | return firestore
71 | .collection("users")
72 | .doc(email)
73 | .set(
74 | {
75 | subscriptionId,
76 | status,
77 | plan
78 | },
79 | { merge: true }
80 | )
81 | .then(() => res.status(200).send({ event, status: "deleted" }))
82 |
83 | default:
84 | return res.status(404).send({ event, error: "unknown event" })
85 | }
86 | })
87 | .catch((error) => {
88 | console.error(`>> WEBHOOK ERROR: ${error}`)
89 | return res.status(400).send({ event, error: error.message || "Customer not found" })
90 | })
91 | })
92 |
93 | export default router
94 |
--------------------------------------------------------------------------------
/addOn/src/gmail.ts:
--------------------------------------------------------------------------------
1 | import { createCatCard } from "./common"
2 |
3 | export const onGmailMessage = (e) => {
4 | console.log(e)
5 | // Get the ID of the message the user has open.
6 | const messageId = e.gmail.messageId
7 | // Get an access token scoped to the current message and use it for GmailApp
8 | // calls.
9 | const accessToken = e.gmail.accessToken
10 | GmailApp.setCurrentMessageAccessToken(accessToken)
11 | // Get the subject of the email.
12 | const message = GmailApp.getMessageById(messageId)
13 | let subject = message.getThread().getFirstMessageSubject()
14 | // Remove labels and prefixes.
15 | subject = subject.replace(/^([rR][eE]|[fF][wW][dD])\:\s*/, "").replace(/^\[.*?\]\s*/, "")
16 | return createCatCard(subject)
17 | }
18 |
19 | export const onGmailCompose = (e) => {
20 | console.log(e)
21 | const header = CardService.newCardHeader()
22 | .setTitle("Insert cat")
23 | .setSubtitle("Add a custom cat image to your email message.")
24 | // Create text input for entering the cat's message.
25 | const input = CardService.newTextInput()
26 | .setFieldName("text")
27 | .setTitle("Caption")
28 | .setHint("What do you want the cat to say?")
29 | // Create a button that inserts the cat image when pressed.
30 | const action = CardService.newAction().setFunctionName("onGmailInsertCat")
31 | const button = CardService.newTextButton()
32 | .setText("Insert cat")
33 | .setOnClickAction(action)
34 | .setTextButtonStyle(CardService.TextButtonStyle.FILLED)
35 | const buttonSet = CardService.newButtonSet().addButton(button)
36 | // Assemble the widgets and return the card.
37 | const section = CardService.newCardSection().addWidget(input).addWidget(buttonSet)
38 | const card = CardService.newCardBuilder().setHeader(header).addSection(section)
39 | return card.build()
40 | }
41 |
42 | export const onGmailInsertCat = (e) => {
43 | console.log(e)
44 | // Get the text that was entered by the user.
45 | const text = e.formInput.text
46 | // Use the "Cat as a service" API to get the cat image. Add a "time" URL
47 | // parameter to act as a cache buster.
48 | const now = new Date()
49 | let imageUrl = "https://cataas.com/cat"
50 | if (text) {
51 | // Replace formward slashes in the text, as they break the CataaS API.
52 | const caption = text.replace(/\//g, " ")
53 | imageUrl += Utilities.formatString(
54 | "/says/%s?time=%s",
55 | // @ts-ignore
56 | encodeURIComponent(caption),
57 | now.getTime()
58 | )
59 | }
60 | const imageHtmlContent = ''
61 | const response = CardService.newUpdateDraftActionResponseBuilder()
62 | .setUpdateDraftBodyAction(
63 | CardService.newUpdateDraftBodyAction()
64 | .addUpdateContent(imageHtmlContent, CardService.ContentType.MUTABLE_HTML)
65 | .setUpdateType(CardService.UpdateDraftBodyType.IN_PLACE_INSERT)
66 | )
67 | .build()
68 | return response
69 | }
70 |
--------------------------------------------------------------------------------
/addOn/webpack.config.js:
--------------------------------------------------------------------------------
1 | /*********************************
2 | * import webpack plugins
3 | ********************************/
4 | const path = require("path")
5 | const webpack = require("webpack")
6 | const CopyWebpackPlugin = require("copy-webpack-plugin")
7 | const GasPlugin = require("gas-webpack-plugin")
8 | const TerserPlugin = require("terser-webpack-plugin")
9 | const ConfigWebpackPlugin = require("config-webpack")
10 |
11 | /*********************************
12 | * set up environment variables
13 | ********************************/
14 | const dotenv = require("dotenv").config()
15 | const parsed = dotenv.error ? {} : dotenv.parsed
16 | const envVars = parsed || {}
17 |
18 | /*********************************
19 | * define entrypoints
20 | ********************************/
21 | // our destination directory
22 | const destination = path.resolve(__dirname, "dist")
23 | // define server paths
24 | const serverEntry = "./src/index.ts"
25 | // define appsscript.json file path
26 | const copyAppscriptEntry = "./appsscript.json"
27 |
28 | /*********************************
29 | * Declare settings
30 | ********************************/
31 | // webpack settings for copying files to the destination folder
32 | const copyFilesConfig = {
33 | name: "COPY FILES - appsscript.json",
34 | mode: "production", // unnecessary for this config, but removes console warning
35 | entry: copyAppscriptEntry,
36 | output: {
37 | path: destination
38 | },
39 | plugins: [
40 | new CopyWebpackPlugin({
41 | patterns: [
42 | {
43 | from: copyAppscriptEntry,
44 | to: destination
45 | }
46 | ]
47 | })
48 | ]
49 | }
50 |
51 | // webpack settings used by both client and server
52 | const sharedClientAndServerConfig = {
53 | context: __dirname
54 | }
55 |
56 | // webpack settings used by the server-side code
57 | const serverConfig = {
58 | ...sharedClientAndServerConfig,
59 | name: "SERVER",
60 | mode: "production",
61 | entry: serverEntry,
62 | output: {
63 | filename: "code.js",
64 | path: destination,
65 | libraryTarget: "this"
66 | },
67 | resolve: {
68 | extensions: [".ts", ".js", ".json"]
69 | },
70 | module: {
71 | rules: [
72 | // typescript config
73 | {
74 | test: /\.ts?$/,
75 | exclude: /node_modules/,
76 | use: [
77 | {
78 | loader: "babel-loader"
79 | },
80 | {
81 | loader: "ts-loader"
82 | }
83 | ]
84 | },
85 | {
86 | test: /\.js$/,
87 | exclude: /node_modules/,
88 | use: {
89 | loader: "babel-loader"
90 | }
91 | }
92 | ]
93 | },
94 | optimization: {
95 | minimize: true,
96 | minimizer: [
97 | new TerserPlugin({
98 | sourceMap: true,
99 | terserOptions: {
100 | // ecma 5 is needed to support Rhino "DEPRECATED_ES5" runtime
101 | // can use ecma 6 if V8 runtime is used
102 | ecma: 5,
103 | warnings: false,
104 | parse: {},
105 | compress: {
106 | properties: false
107 | },
108 | mangle: false,
109 | module: false,
110 | output: {
111 | beautify: true
112 | }
113 | }
114 | })
115 | ]
116 | },
117 | plugins: [
118 | new ConfigWebpackPlugin(),
119 | new webpack.DefinePlugin({
120 | "process.env": JSON.stringify(envVars),
121 | "process.env.NODE_ENV": JSON.stringify("production")
122 | }),
123 | new GasPlugin()
124 | ]
125 | }
126 |
127 | module.exports = [
128 | // 1. Copy appsscript.json to destination,
129 | // 2. Set up webpack dev server during development
130 | // Note: devServer settings are only read in the first element when module.exports is an array
131 | copyFilesConfig,
132 | // 3. Create the server bundle
133 | serverConfig
134 | ]
135 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Your boilerplate project for
developing Google Workspace add-ons
8 | *** 9 |
10 | 11 | [Google Workspace add-ons](https://developers.google.com/workspace/add-ons/how-tos/building-gsuite-addons) are the extensions that can be found in the side panel of most Google apps (Gmail, Google Drive, Sheets, Docs, Slides, etc), unlike the more complexe [Editor add-ons](https://developers.google.com/workspace/add-ons/how-tos/building-editor-addons) that can be found in the "Add-ons" tab of Google Sheets, Docs and Slides. 12 | 13 |
14 |
15 |
70 |
71 |
80 |
81 |
129 |
130 |
171 |
172 |