How to set a code redemption system in NodeJS & React app?

July 11, 2022

7 min read

How to set a code redemption system in NodeJS & React app?
Watch on YouTube

I'll show you how I made AppSumo code redemption at my app Increaser so that you can find some interesting bits for your project. Let's jump in.

AppSumo is a digital marketplace. It also sells software. When you buy it on AppSumo, you'll get a code that you can redeem to access the product. Once we get the coupon system working at our app, we can reuse it for other digital marketplaces that work similarly.

I'm using DynamoDB in my app. First, I'll create a table with Terraform.

resource "aws_dynamodb_table" "appSumoCodes" {
  name         = "app_sumo_codes"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "id"

  attribute {
    name = "id"
    type = "S"
  }
}

We'll use the UUID library to make a unique code. Here we'll make a list of 10000 codes. We'll use the code as an id for the item, and now we are ready to populate the table.

import uuid from "uuid/v4"

import { tableName } from "../src/shared/db/tableName"
import { documentClient } from "../src/shared/db"

const batchWriteItemMax = 25

const appSumoCodesNumber = 10000

const populateAppSumoCodes = async () => {
  const codes = Array.from({ length: appSumoCodesNumber }, () => uuid())
  const items = codes.map((id) => ({ id }))

  const itemsBatchesNumber = Math.ceil(items.length / batchWriteItemMax)
  await Promise.all(
    Array.from({ length: itemsBatchesNumber }, (_, i) => i).map(
      async (batchIndex) => {
        const batchItems = items.slice(
          batchWriteItemMax * batchIndex,
          batchWriteItemMax * (batchIndex + 1)
        )

        await documentClient
          .batchWrite({
            RequestItems: {
              [tableName.appSumoCodes]: batchItems.map((Item) => ({
                PutRequest: {
                  Item,
                },
              })),
            },
          })
          .promise()
      }
    )
  )
}

populateAppSumoCodes()

If we open AWS, we should see a populated table.

AppSumo takes a CSV file with codes, so let's write a command that will take every code from the table and export it into a CSV.

import { Key } from "aws-sdk/clients/dynamodb"
import fs from "fs"

import { tableName } from "../src/shared/db/tableName"
import { documentClient } from "../src/shared/db"

const getAllAppSumoCodeIds = async () => {
  const ids: string[] = []

  const recursiveProcess = async (lastEvaluatedKey?: Key) => {
    const { Items = [], LastEvaluatedKey } = await documentClient
      .scan({
        TableName: tableName.appSumoCodes,
        ExclusiveStartKey: lastEvaluatedKey,
        ExpressionAttributeNames: {
          "#id": "id",
        },
        ProjectionExpression: "#id",
      })
      .promise()

    ids.push(...Items.map((item) => item.id))

    if (LastEvaluatedKey) {
      await recursiveProcess(LastEvaluatedKey)
    }
  }

  await recursiveProcess()

  return ids
}

const outputDir = "./output"
const csvFileName = "appsumo_codes.csv"

const generateAppSumoCodes = async () => {
  const ids = await getAllAppSumoCodeIds()

  if (!fs.existsSync(outputDir)) {
    fs.mkdirSync(outputDir)
  }
  fs.writeFileSync([outputDir, csvFileName].join("/"), ids.join("\n"))
}

generateAppSumoCodes()

After a purchase, AppSumo will tell the user to go to the website and redeem the code for product access.

appsumo

On the AppSumo page, we have a card in the center. We'll ask the user to sign in first. Once they are authorized, we'll ask for an AppSumo code.

On the front-end, we check for user authorization status. If they didn't log in yet, we show the AppSumoAuth component.

import { useAuth } from "auth/hooks/useAuth"
import { Card } from "ui/Card"
import { VStack } from "ui/Stack"

import { AppSumoAuth } from "./AppSumoAuth"
import { AppSumoCodeRedemption } from "./AppSumoCodeRedemption"
import { AppSumoHeader } from "./AppSumoHeader"

export const AppSumoPage = () => {
  const { isUserLoggedIn } = useAuth()

  return (
    <VStack fullHeight fullWidth alignItems="center" justifyContent="center">
      <Card width={380}>
        <VStack fullWidth alignItems="center" gap={20}>
          <AppSumoHeader />
          {isUserLoggedIn ? <AppSumoCodeRedemption /> : <AppSumoAuth />}
        </VStack>
      </Card>
    </VStack>
  )
}

It leverages existing code for authorization. To make the user get back to the app sumo page, we pass the destination prop to the provider.

At the AppSumoCodeRedemption component, we show the user's email address and a form where he can submit the code.

import { yupResolver } from "@hookform/resolvers/yup"
import { MainApiError, useMainApi } from "api/hooks/useMainApi"
import { useAuth } from "auth/hooks/useAuth"
import { MembershipProvider } from "membership"
import { useState } from "react"
import { useForm } from "react-hook-form"
import { useMutation } from "react-query"
import { history } from "router/history"
import { Path } from "router/Path"
import { IconButton } from "ui/Button/IconButton"
import { SubmitFormButton } from "ui/Button/SubmitFormButton"
import { Form } from "ui/Form/Form"
import { EditIcon } from "ui/icons/EditIcon"
import { TextInput } from "ui/Input/TextInput"
import { HStack, VStack } from "ui/Stack"
import { Text } from "ui/Text"
import { useUserState } from "user/state/UserStateContext"
import * as yup from "yup"

interface RedeemCodeFormState {
  code: string
}

const schema = yup
  .object()
  .shape({
    code: yup.string().required(),
  })
  .required()

const redeemAppSumoCodeMutation = `
mutation redeemAppSumoCode($input: RedeemAppSumoCodeInput!) {
  redeemAppSumoCode(input: $input)
}
`

export const AppSumoCodeRedemption = () => {
  const [code, setCode] = useState("")

  const { updateState: updateUserState } = useUserState()

  const { query } = useMainApi()

  const {
    register,
    handleSubmit,
    formState: { errors },
    setError,
  } = useForm<RedeemCodeFormState>({
    mode: "onSubmit",
    resolver: yupResolver(schema),
  })

  const { mutate: redeemCode, isLoading } = useMutation(
    async ({ code }: RedeemCodeFormState) => {
      try {
        await query({
          query: redeemAppSumoCodeMutation,
          variables: {
            input: {
              code,
            },
          },
        })

        updateUserState({
          membership: { provider: MembershipProvider.AppSumo },
        })

        history.push(Path.TimePicker)
      } catch (error) {
        const { message } = error as MainApiError
        setError("code", { type: "custom", message })
      }
    }
  )

  const { state: userState } = useUserState()
  const { unauthorize } = useAuth()

  return (
    <VStack gap={20} fullWidth>
      <HStack alignItems="center" gap={12}>
        <Text>
          <Text as="span" color="supporting">
            Email:
          </Text>{" "}
          {userState?.email || ""}
        </Text>
        <IconButton size={18} icon={<EditIcon />} onClick={unauthorize} />
      </HStack>
      <Form
        gap={4}
        onSubmit={handleSubmit((data) => {
          redeemCode(data)
        })}
        content={
          <TextInput
            label="AppSumo code"
            formNoValidate
            value={code || ""}
            {...register("code")}
            onValueChange={(value: string) => setCode(value?.trim())}
            error={errors.code?.message}
            autoFocus
          />
        }
        actions={<SubmitFormButton isLoading={isLoading} text={"Continue"} />}
      />
    </VStack>
  )
}

On success, we update the state and start the onboarding.

To redeem the code, we send a request to our GraphQL API. Here we take the user's id from the JWT token. Then we get the AppSumo code from the DynamoDB table. If the code doesn't exist or another user has a claim on it, we throw an error.

import { OperationContext } from "../../../graphql/OperationContext"
import { assertUserId } from "../../../auth/assertUserId"
import * as appSumoCodesDb from "../db"
import * as usersDb from "../../../users/db"
import { UserInputError } from "apollo-server-lambda"

interface Input {
  code: string
}

export const redeemAppSumoCode = async (
  _: any,
  { input: { code } }: { input: Input },
  context: OperationContext
) => {
  const userId = assertUserId(context)

  const appSumoCode = await appSumoCodesDb.getAppSumoCodeById(code)
  if (!appSumoCode || appSumoCode.userId !== userId) {
    throw new UserInputError("Invalid code")
  }

  await appSumoCodesDb.updateAppSumoCode(code, { userId })
  await usersDb.updateUser(userId, { appSumo: { code } })
}

Since DynamoDB is a NoSQL database, it's OK to have duplication. Here we update the AppSumoCode and the User table to query data faster.

Some users will refund the app. To handle this, we want to get a CSV from AppSumo, go over each code, remove the access, delete the code, and send an email asking for feedback.

import { fillHtmlTemplate, getHTMLTemplate, sendEmail } from "../src/email"
import * as appSumoCodesDb from "../src/membership/appSumo/db"
import * as usersDb from "../src/users/db"

const processRefundedAppSumoCodes = async (codes: string[]) => {
  await Promise.all(
    codes.map(async (code) => {
      const appSumoCode = await appSumoCodesDb.getAppSumoCodeById(code)
      if (!appSumoCode) return

      await appSumoCodesDb.deleteAppSumoCode(code)

      const { userId } = appSumoCode
      if (!userId) return

      await usersDb.removeUserField(userId, "appSumo")
      const user = await usersDb.getUserById(userId, ["email", "name"])
      if (!user) return

      const { email, name = "Friend" } = user
      const html = await getHTMLTemplate("appsumo-refund")
      if (!email) return

      await sendEmail({
        email,
        body: fillHtmlTemplate(html, { name }),
        subject: "Does Increaser stink?",
        source: "Radzion <radzion@increaser.org>",
      })
    })
  )
}

const refundedCodes = `
`
  .split("\n")
  .filter(Boolean)

processRefundedAppSumoCodes(refundedCodes)