From ff9f56e61d6dd5407cf72d19f12928e2f97af5d1 Mon Sep 17 00:00:00 2001 From: Benedicte Raae Date: Fri, 8 Oct 2021 14:31:25 +0200 Subject: [PATCH] refactor: gatsby functions --- netlify.toml | 5 +- src/api-utils/error-handler.js | 25 +++++++++ src/api-utils/services/stripe.js | 23 ++++++++ src/api-utils/services/userbase.js | 54 +++++++++++++++++++ src/api-utils/services/userlist.js | 37 +++++++++++++ src/api-utils/sync-settings.js | 50 ++++++++++++++++++ src/api-utils/sync-subscription.js | 85 ++++++++++++++++++++++++++++++ src/api/stripe-webhook.js | 65 +++++++++++++++++++++++ src/api/sync-settings.js | 45 ++++++++++++++++ src/api/sync-subscription.js | 45 ++++++++++++++++ 10 files changed, 433 insertions(+), 1 deletion(-) create mode 100644 src/api-utils/error-handler.js create mode 100644 src/api-utils/services/stripe.js create mode 100644 src/api-utils/services/userbase.js create mode 100644 src/api-utils/services/userlist.js create mode 100644 src/api-utils/sync-settings.js create mode 100644 src/api-utils/sync-subscription.js create mode 100644 src/api/stripe-webhook.js create mode 100644 src/api/sync-settings.js create mode 100644 src/api/sync-subscription.js diff --git a/netlify.toml b/netlify.toml index 797fde59..e0a7f3ef 100644 --- a/netlify.toml +++ b/netlify.toml @@ -6,4 +6,7 @@ [[plugins]] package = 'netlify-plugin-contextual-env' [plugins.inputs] - mode = 'suffix' \ No newline at end of file + mode = 'suffix' + +[[plugins]] +package = "netlify-plugin-inline-functions-env" \ No newline at end of file diff --git a/src/api-utils/error-handler.js b/src/api-utils/error-handler.js new file mode 100644 index 00000000..400d2235 --- /dev/null +++ b/src/api-utils/error-handler.js @@ -0,0 +1,25 @@ +import Joi from "joi" + +const errorHandler = (req, res, error) => { + // Error response present if it's an axios error + // statusCode present if it's a custom http-error + let status = error.response?.status || error.statusCode || 500 + const message = error.response?.data?.message || error.message + + // Check to see if it's a Joi error, + // and make sure to expose it + if (Joi.isError(error)) { + status = 422 + error.expose = true + } + + // Something went wrong, log it + console.error(`${status} -`, message) + + // Respond with error code and message + res.status(status).json({ + message: error.expose ? message : `Faulty ${req.baseUrl}`, + }) +} + +export default errorHandler diff --git a/src/api-utils/services/stripe.js b/src/api-utils/services/stripe.js new file mode 100644 index 00000000..5689a1e6 --- /dev/null +++ b/src/api-utils/services/stripe.js @@ -0,0 +1,23 @@ +import StripeAPI from "stripe" + +export const Stripe = (secretKey = process.env.STRIPE_SECRET_KEY) => { + const stripeApi = StripeAPI(secretKey) + + const log = (...args) => { + console.log("Stripe:", ...args) + } + + const getStripeSubscription = async ({ id }) => { + const subscription = await stripeApi.subscriptions.retrieve(id) + + log("Fetched subscription", { stripeSubscriptionId: subscription.id }) + + return subscription + } + + return { + getStripeSubscription, + } +} + +export default Stripe() diff --git a/src/api-utils/services/userbase.js b/src/api-utils/services/userbase.js new file mode 100644 index 00000000..4c3cff47 --- /dev/null +++ b/src/api-utils/services/userbase.js @@ -0,0 +1,54 @@ +import axios from "axios" + +export const Userbase = ( + accessToken = process.env.USERBASE_ADMIN_API_ACCESS_TOKEN +) => { + const userbaseApi = axios.create({ + baseURL: "https://v1.userbase.com/v1/admin", + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + + const log = (...args) => { + console.log("Userbase:", ...args) + } + + const verifyUserbaseAuthToken = async ({ + userbaseAuthToken, + userbaseUserId, + }) => { + const { data: user } = await userbaseApi.get( + "auth-tokens/" + userbaseAuthToken + ) + + log("Auth token verified", { + userbaseUserId: data.userId, + }) + + if (user.userId !== userbaseUserId) { + throw Error("userbaseAuthToken / userbaseUserId mismatch") + } + + return user + } + + const getUserbaseUser = async ({ userbaseUserId }) => { + let { data: user } = await userbaseApi.get("users/" + userbaseUserId) + + user.stripeData = user[process.env.USERBASE_STRIPE_ENV] || {} + user.protectedProfile = user.protectedProfile || {} + user.profile = user.profile || {} + + log("Fetched user", { userbaseUserId: user.userId, user }) + + return user + } + + return { + verifyUserbaseAuthToken, + getUserbaseUser, + } +} + +export default Userbase() diff --git a/src/api-utils/services/userlist.js b/src/api-utils/services/userlist.js new file mode 100644 index 00000000..e9960c9c --- /dev/null +++ b/src/api-utils/services/userlist.js @@ -0,0 +1,37 @@ +import axios from "axios" + +export const Userlist = (pushKey = process.env.USERLIST_PUSH_KEY) => { + const userlistApi = axios.create({ + baseURL: "https://push.userlist.com", + headers: { + Authorization: `Push ${pushKey}`, + }, + }) + + const log = (...args) => { + console.log("Userlist:", ...args) + } + + const upsertUserlistSubscriber = async ({ + identifier, + email, + signed_up_at, + properties, + }) => { + const { data, status } = await userlistApi.post(`users`, { + identifier, + email, + signed_up_at, + properties, + }) + + log("Subscriber updated", identifier, data) + log("Subscriber updated - status", status) + } + + return { + upsertUserlistSubscriber, + } +} + +export default Userlist() diff --git a/src/api-utils/sync-settings.js b/src/api-utils/sync-settings.js new file mode 100644 index 00000000..a0f97a8f --- /dev/null +++ b/src/api-utils/sync-settings.js @@ -0,0 +1,50 @@ +import createError from "http-errors" +import userbase from "./services/userbase" +import userlist from "./services/userlist" + +const syncSettings = async (userbaseUserId) => { + try { + // Get the Userbase User and pluck data wanted + const userbaseUser = await userbase.getUserbaseUser({ userbaseUserId }) + + const { + userId, + email, + creationDate, + profile: { newsletter }, + } = userbaseUser + + const props = { + newsletter_at: null, + test: null, + } + + if (newsletter == "1") { + // == Matches both 1 and "1" + // Signed up before using date + props.newsletter_at = new Date(0) + } else if (newsletter == "0") { + // == Matches both 0 and "0" + props.newsletter_at = null + } else if (newsletter) { + // Hopefully a date + props.newsletter_at = new Date(newsletter) + } + + // Push data on user into Userlist + await userlist.upsertUserlistSubscriber({ + identifier: userId, + email: email, + signed_up_at: creationDate, + properties: props, + }) + console.log("syncSettings - success", userId) + } catch (error) { + const { message } = error.response?.data || error.request?.data || error + throw new createError.InternalServerError( + "syncSettings - error: " + message + ) + } +} + +export default syncSettings diff --git a/src/api-utils/sync-subscription.js b/src/api-utils/sync-subscription.js new file mode 100644 index 00000000..2042d81c --- /dev/null +++ b/src/api-utils/sync-subscription.js @@ -0,0 +1,85 @@ +import createError from "http-errors" +import userbase from "./services/userbase" +import userlist from "./services/userlist" +import stripe from "./services/stripe" + +const timestampDate = (timestamp) => { + return timestamp ? new Date(timestamp * 1000) : null +} + +const syncSubscription = async (userbaseUserId) => { + try { + // Get the Userbase User and pluck data wanted + const userbaseUser = await userbase.getUserbaseUser({ userbaseUserId }) + + const { + userId, + email, + username, + creationDate, + size, + protectedProfile: { + stripeCustomerId: oldStripeCustomerId, + stripePlanId: oldStripePlanId, + }, + stripeData: { subscriptionPlanId, subscriptionId }, + } = userbaseUser + + const props = { + username: username, + size: size, + // Must use null to null stuff out in userlist, + // undefined does not work + subscription_id: subscriptionId || null, + subscription_plan_id: subscriptionPlanId || null, + subscription_status: null, + subscription_created_at: null, + subscription_start_date: null, + subscription_cancel_at: null, + subscription_canceled_at: null, + subscription_ended_at: null, + } + + if (subscriptionId) { + const { + status, + created, + start_date, + cancel_at, + canceled_at, + ended_at, + } = await stripe.getStripeSubscription({ + id: subscriptionId, + }) + + props.subscription_status = status + props.subscription_created_at = timestampDate(created) + props.subscription_start_date = timestampDate(start_date) + props.subscription_cancel_at = timestampDate(cancel_at) + props.subscription_canceled_at = timestampDate(canceled_at) + props.subscription_ended_at = timestampDate(ended_at) + } else if (oldStripeCustomerId) { + // Older users that cancelled before migrating to Userbase's integrated payment + props.subscription_id = null // Did not save subscription id + props.subscription_plan_id = oldStripePlanId + props.subscription_status = "canceled" // "canceled" is same as used by Stripe, no double "ll" + } + + // Push data on user into Userlist + await userlist.upsertUserlistSubscriber({ + identifier: userId, + email: email, + signed_up_at: creationDate, + properties: props, + }) + + console.log("syncSubscription - success", userId) + } catch (error) { + const { message } = error.response?.data || error.request?.data || error + throw new createError.InternalServerError( + "syncSubscription - error: " + message + ) + } +} + +export default syncSubscription diff --git a/src/api/stripe-webhook.js b/src/api/stripe-webhook.js new file mode 100644 index 00000000..5c482ff5 --- /dev/null +++ b/src/api/stripe-webhook.js @@ -0,0 +1,65 @@ +import createError from "http-errors" +import Joi from "joi" +import errorHandler from "../api-utils/error-handler" +import syncSubscription from "../api-utils/sync-subscription" + +/* + * Webhook used to keep the Stripe Subscription information + * in sync with Userlist on all subscription events. + * + * @todo add a secret as query param, to mimimize abuse? + * + */ + +export default async function handler(req, res) { + console.log(`${req.baseUrl} - ${req.method}`) + + try { + // Only handle POST requests for webhooks + if (req.method === "POST") { + await webhookHandler(req, res) + } else { + throw createError(405, `${req.method} not allowed`) + } + } catch (error) { + errorHandler(req, res, error) + } +} + +const webhookHandler = async (req, res) => { + // 1. Validate + + const bodySchema = Joi.object({ + type: Joi.string() + .valid( + "customer.subscription.created", + "customer.subscription.updated", + "customer.subscription.deleted" + ) + .required(), + data: Joi.object({ + object: Joi.object({ + metadata: { + __userbase_user_id: Joi.string().required(), + }, + }).required(), + }).required(), + }).options({ allowUnknown: true }) + + // Deconstruct and rename user id + const { + data: { + object: { + metadata: { __userbase_user_id: userbaseUserId }, + }, + }, + } = await bodySchema.validateAsync(req.body) + + // 2. Do the thing + + await syncSubscription(userbaseUserId) + + // 3. Respond + + res.send("OK") +} diff --git a/src/api/sync-settings.js b/src/api/sync-settings.js new file mode 100644 index 00000000..670da639 --- /dev/null +++ b/src/api/sync-settings.js @@ -0,0 +1,45 @@ +import createError from "http-errors" +import Joi from "joi" +import errorHandler from "../api-utils/error-handler" +import syncSettings from "../api-utils/sync-settings" + +/* + * Endpoint used to keep the settings like newsletter subscription + * in sync with Userlist. Intended to be used by admin scripts. + * + * @todo add a secret as query/body param, to mimimize abuse? + * + */ + +export default async function handler(req, res) { + console.log(`${req.baseUrl} - ${req.method}`) + + try { + if (req.method === "POST") { + await postHandler(req, res) + } else { + throw createError(405, `${req.method} not allowed`) + } + } catch (error) { + errorHandler(req, res, error) + } +} + +const postHandler = async (req, res) => { + // 1. Validate + + const bodySchema = Joi.object({ + userbaseUserId: Joi.string().required(), + }) + + // Deconstruct userbaseUserId from validated values + const { userbaseUserId } = await bodySchema.validateAsync(req.body) + + // 2. Do the thing + + await syncSettings(userbaseUserId) + + // 3. Respond + + res.json({ status: "synced", userbaseUserId: userbaseUserId }) +} diff --git a/src/api/sync-subscription.js b/src/api/sync-subscription.js new file mode 100644 index 00000000..70314cc9 --- /dev/null +++ b/src/api/sync-subscription.js @@ -0,0 +1,45 @@ +import createError from "http-errors" +import Joi from "joi" +import errorHandler from "../api-utils/error-handler" +import syncSubscription from "../api-utils/sync-subscription" + +/* + * Endpoint used to keep the Stripe Subscription information + * in sync with Userlist. Intended to be used by admin scripts. + * + * @todo add a secret as query param, to mimimize abuse? + * + */ + +export default async function handler(req, res) { + console.log(`${req.baseUrl} - ${req.method}`) + + try { + if (req.method === "POST") { + await postHandler(req, res) + } else { + throw createError(405, `${req.method} not allowed`) + } + } catch (error) { + errorHandler(req, res, error) + } +} + +const postHandler = async (req, res) => { + // 1. Validate + + const bodySchema = Joi.object({ + userbaseUserId: Joi.string().required(), + }) + + // Deconstruct userbaseUserId from validated values + const { userbaseUserId } = await bodySchema.validateAsync(req.body) + + // 2. Do the thing + + await syncSubscription(userbaseUserId) + + // 3. Respond + + res.json({ status: "synced", userbaseUserId: userbaseUserId }) +}