Last year, because of the global pandemic, I was working from home since mid-March. By October, I realized that the chance of getting back to the office before end of the year was very slim. It got me worried — before 2020, we used to run Secret Santa each year since 2017. Is the global pandemic a signal for us to interrupt the Secret Santa tradition for my team in 2020?

The answer was no.

2020 was a tough year for a lot of us. In previous years, Secret Santa was an event carried out in office, where we met up face-to-face, we opened the present in front of everyone. In 2020, there are a lot of things changed — we couldn’t go to the office, we couldn’t even meet new teammates. This brought us new challenges to organize Secret Santa. But, I wanted to see Secret Santa bringing everyone together, especially in the holiday season, even we have an ongoing global pandemic.

What is Secret Santa?

If you don’t know it already, Secret Santa is a group activity where a number of people would put a piece of paper with their name on it into a pool. Then, after everyone completed this, each one of them would draw a piece of paper from the pool. If they picked themselves, they put it back to the pool. If they picked someone else, they would take out the paper and would prepare a Christmas gift for that person. In our version, we call the one who receives the present Secret Santee. Basically, it is a game which everyone sends a gift and receives a gift, the true identity of everyone’s Santa remain undisclosed, hence the name Secret Santa.

In order to run the online version of it, what we decided to do is announcing this activity at a certain point (obviously it was a secret), providing everyone a deadline for signing up. Then, when the countdown ends, it will trigger a shuffling algorithm to draw names for everyone and store it to a database. We also disable the sign-up feature here. Next, when people sign in again, they will be able to see the details of their Secret Santee. Then, everyone will prepare something for their Secret Santee from a shopping website of their choice, and deliver it to them directly via courier services. In previous years, we had a budget guidance of €20. We had followed this tradition also, with the condition that it can go over a little if adding the delivery fee.

Planning

I had this idea of building the online version of Secret Santa on AWS for few days. But I wasn’t sure if I want to build it by myself. I reached out to the organizer of previous years, my friend, Ciarán, to see if he is also interested — he was. Then, the brainstorm has begun.

Since we had collaborated in similar projects before, also the bare-bone idea is simple enough, I proposed that we could just use plain HTML+CSS+JavaScript for frontend. Then, use the usual services such as API Gateway, Lambda, DynamoDB, Cognito to build a serverless backend. It could even be a single-page web app, not very hard to maintain the frontend.

Ciarán didn’t agree with me. Maybe the frontend side is easy enough to be coded in plain HTML+CSS+JavaScript, but there is no staging on the backend side if we choose to do so. That means, maintaining environments would be a challenging task.

He was right. I had overlooked the complexity involved in deploying different stages for our application. Even it is a simple app, developing it without deployment support is such a rudimentary approach.

“What about Amplify?”

Ciarán asked me an unexpected question. To be honest, I wasn’t terribly familiar with Amplify at that time. I knew it is a convenient tool to kick-start a project, it creates CloudFormation stacks and managing IAM permissions for a project automatically, it also has built-in staging support. The problem I personally had (and possibly still have) is that it is easy to go wrong sometimes if a mistaken amplify command is executed. In those situations, it could be very time-consuming to find out what went wrong.

I agreed with Ciarán at the end. Since we don’t have too many components in our app, and we had a tight schedule, even if we have to rebuild it from the scratch, it wouldn’t take us very long. (Spoiler: We didn’t.) In return, we don’t need to worry about spending too much time on writing deployment scripts ourselves.

We revealed the app to my team on Nov. 5 on our weekly meeting. The deadline for registration we set was Nov. 15, 0:00 UTC. They reason why we chose to end registration in November is that we wanted to make sure everyone has sufficient time to pick a gift for their Secret Santee and to ship it to them well before all couriers get jammed due to the holiday season. This app instantly attracted everyone’s attention. In the end, we have 41 people joined this game from my team.

Not only our team, but also neighboring teams were interested in our app. We managed to deploy this app to several teams to let them maintain their own version — with great help from Amplify. By the end of 2020, we had over 100 participants among several teams.

Note: If you are interested in the architecture and more technical stuff, please continue reading. Otherwise, you can directly skip to the Epilogue.

Architecture

The way we pictured this app, even after adding Amplify into the equation, is serverless. If we divide the app into backend and frontend, here are what we have planned:

Backend

  • Cognito   Handling user authentication and authorization. There would be a pre sign-up Lambda trigger to only allow people from a predefined email list to sign up our app. For us, the email list consists of the entire team.
  • DynamoDB   A table used to store data after name shuffling. The data include senderSub, senderEmail, receiverSub, receiverEmail, receiverAddr. At that time, we haven’t settled on the name Secret Santee yet, hence the term we used in our table for that is receiver. What is sub? It is a claim in JWT generated by Cognito user pool, short for “Subject”, the unique identifier for a Cognito user.
  • API Gateway   A single HTTP GET API to query the shuffling result. Amplify has built-in Signature Version 4 signing process. If the API created is only for authenticated user, it will be protected by AWS_IAM authorization method. When making an API request from the frontend, the metadata of the requester is carried over, we just need to extract that in a Lambda function.
  • Lambda   There are 3 Lambda functions in total:
    • Lambda function A acts as the pre sign-up trigger for the Cognito user pool as mentioned above.
    • Lambda function B is to fulfill the task given by the mentioned API above. It uses the sub value of the current user to query a DynamoDB table, to retrieve the Secret Santee and their contact details.
    • Lambda function C is to turn off sign-up feature of the Cognito user pool, retrieve all users from the user pool and carry out the name shuffling task. When this part is done, it stores the result to the DynamoDB table.

Frontend

  • The frontend is written in React. It is a single-page app with a spinning Santa wearing a Zorro Mask in the center of the page and a countdown timer below it. User can click the “Log in” button to log in or sign up. That page is the hosted UI page from our Cognito user pool.
  • After a user signed in, above the Santa, the user can change their address. This is also leveraging the access token from Cognito to make client side call to the Cognito service directly.
  • When the countdown ends, it will switch to the “after countdown” mode, where the user profile box will disappear, an API call will query the contact details of the Secret Santee and display it below the spinning Santa.

Amplify

How Amplify works is very interesting. First, I would like to clarify the terms we have in Amplify as it can be very confusing:

  • “Amplify CLI” refers to the command-line interface tool.
  • “Amplify Console” or “AWS Amplify” is the service you can find on AWS Console.
  • “Amplify Framework” is the AWS SDK for web and mobile platforms.

Using our app as an example, it is first initialized in our local computers by Amplify CLI. We made use of the APIs provided in Amplify Framework for our React app to communicate between itself and AWS backend. After local development, we pushed our backend resources via Amplify CLI, and linked the GitHub repository which contains frontend app to an Amplify Console app to create resources to host our website.

After installing Amplify CLI, you need to configure the environment for it:

1
$ amplify configure

Moving forward, you can create an Amplify app by running this command in an empty directory:

1
$ amplify init

After this, it will ask what type of app one want to create, such as iOS, Android, JavaScript, etc. Within JavaScript, it also supports several frameworks. For us, we selected React. If all configurations are set to default values, the command creates directories such as amplify and src. Also, an Amplify app will be created in the given AWS account. Under the hood of the Amplify app, it also creates a CloudFormation stack for each environment.

In our app, the amplify directory stores the resources for the backend, whereas the src directory stores the source code for the React app. A public folder is also there to store assets for the frontend, such as the image of the Secret Santa.

Secret Santa Image

Cognito

The first AWS component we created via Amplify is Cognito user pool and Cognito identity pool. Both are interlinked together to provide authentication and authorization functionalities respectively. In Amplify CLI, you can create them by this command:

1
$ amplify add auth

As mentioned in the Architecture section above, we would use email as the username for our game participants, and we needed to limit the eligible emails within our team. Half of our requirement is already included in Amplify CLI. When the command prompts you the following question, select “Email Domain Filtering (whitelist)”:

1
2
3
4
5
6
7
8
Do you want to enable any of the following capabilities?
◯ Add Google reCaptcha Challenge
◯ Email Verification Link with Redirect
◯ Add User to Group
◯ Email Domain Filtering (blacklist)
◯ Email Domain Filtering (whitelist)
◯ Custom Auth Challenge Flow (basic scaffolding - not for production)
◯ Override ID Token Claims

Then, the next question would be that what domain name is allowed. We filled only the domain part (after the “@” sign) of allowed email addresses there for now.

This operation would automatically create a Lambda function in the /amplify/backend/function/{appName}PreSignup/ directory, which will be associated to the user pool as the pre sign-up trigger when the whole backend is deployed to the AWS account.

Next, we modified the email-filter-whitelist.js file in the src/ directory in that Lambda function to add the functionality of filtering the first half of the email address:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
exports.handler = (event, context, callback) => {
// whitelisted domains
const wld = process.env.DOMAINWHITELIST.split(',').map(d => d.trim());

const allowedAliasList = [];

const { email } = event.request.userAttributes;
const domain = email.substring(email.indexOf('@') + 1);
const alias = email.substring(0, email.indexOf('@'));

if (!wld.includes(domain)) {
callback(new Error(`Invalid email domain: ${domain}`), event);
} else {
if(allowedAliasList.includes(alias)){
callback(null, event);
} else {
callback(new Error(`Invalid alias: ${alias}`), event);
}
}
};

From the code above you can see the allowed domain name is obtained from an environment variable DOMAINWHITELIST. This is managed by the CloudFormation stack Amplify created. We manually added a separate logic to check if the part before “@” in an email address is in allowedAliasList after validating the email domain name. If it is not, the Cognito hosted UI returned an error message of “Invalid alias”.

DynamoDB

Next, we added a DynamoDB table for storing the shuffling result by this command:

1
$ amplify add storage

Since Secret Santa is the one sending requests to query this table, the primary key we set is senderSub. Other columns, such as senderEmail, receiverSub, receiverEmail, receiverAddr are used to store details of both parties.

Why do we store data like this in DynamoDB but not querying Cognito user pool directly? We made the decision of using a proper NoSQL database because Cognito is not a full-fledged database. Data retrieved from it should be cached where possible.

API Gateway

Then, we added an API for our app:

1
$ amplify add api

Amplify supports two types of API: GraphQL (via AppSync) and REST (via API Gateway). According to our architecture, we only needed a REST API. This command will ask a number of questions, such as path, Lambda function configuration, authorization, HTTP methods, etc. For our app, it’s a GET method on path /secretsanta and the API call is only available for authenticated users only.

When making a request from the React app to an AWS_IAM protected endpoint, the metadata of the requester is also forwarded to the Python Lambda function at the backend. I used the following line to extract the identity from the payload, a.k.a. the event:

1
identity = event['requestContext']['identity']['cognitoAuthenticationProvider']

The sub claim is enclosed in the identity variable.

Lambda

At the end, we added a Lambda function to run the shuffling algorithm at a given time:

1
$ amplify add function

We have decided that we would close the registration on Nov. 15, 2020 at 0:00 UTC. When running the above command, it will ask few questions from you. We would need to give it read and write access to the DynamoDB table we created in previous steps. We configured it to run on a custom schedule. The cron expression in our case, was:

1
0 0 15 11 ? 2020

In places like AWS CLI, this expression would be referred as cron(0 0 15 11 ? 2020), but from the questions the above Amplify CLI command asked, only values inside the round brackets are needed.

This operation creates a Lambda function along with a CloudWatch rule to invoke the Lambda function at that time.

First thing in the Lambda function we have is to disable sign up functionality for the user pool using the UpdateUserPool API to change the value of AllowAdminCreateUserOnly. Then, we used the ListUsers API to save the information for all users locally for the important step — name shuffling.

Shuffling Algorithm

On my day-to-day work, Python is always my go-to programming language for solving small problems. You may have already noticed, the majority of the code in this project I wrote were in Python. In order to understand the shuffling algorithm, let’s only focus on the core problem: There is a list of names, how can I assign a random name to each of them, without assigning themselves or people have already got assigned?

Next, let’s go through what I have done step by step. First, assuming I sent the Lambda function a payload like this (the event):

1
2
3
4
5
6
7
8
9
10
{
"names": [
"John",
"Jane",
"David",
"Amy",
"George",
"Michelle"
]
}

Next, I would extract the names out and save the length of the list, and prepare the index for Secret Santee called receiverList and final response send back to API called response:

1
2
3
4
nameList = event['names']
length = len(nameList)
receiverList = []
response = []

In light of the very first question about the algorithm, I wrote a method to generate a random number within a set:

1
2
3
4
import random

def generateRandomNumber(minVal, maxVal, excludedList):
return random.choice([i for i in range(minVal, maxVal) if i not in excludedList])

Here, this method takes a range of values within minVal and maxVal, also excludes numbers in the list of excludedList. We can use this method to simulate a situation where name index 0 can draw a number within the range between 0 to 9, but exclude 0 (itself). To replace these variables to constants from our example, you can yield something like this:

1
2
>>> [i for i in range(0, 10) if i not in [0]]
[1, 2, 3, 4, 5, 6, 7, 8, 9]

Note that in Python, range(0, 10) includes 0 but excludes 10.

Next, random.choice(seq) is a method that generates a random number out of a given list. We can just keep doing this, but adding more and more indices to the excludedList. Eventually, we will draw a name for everyone:

1
2
3
4
5
for i in range(0, length):
receiverList.append(generateRandomNumber(0, length, [i] + receiverList))

for i in range(0, length):
response.append({'sender': nameList[i], 'receiver': nameList[receiverList[i]]})

From the code above, we have two loops. First one to generate the random index of the Secret Santee, second one to map names together into a new list for final response. Eventually, this function would return something like this:

1
2
3
4
5
6
7
8
[
{"sender": "John", "receiver": "Michelle"},
{"sender": "Jane", "receiver": "John"},
{"sender": "David", "receiver": "George"},
{"sender": "Amy", "receiver": "David"},
{"sender": "George", "receiver": "Jane"},
{"sender": "Michelle", "receiver": "Amy"}
]

Then, in our actual Lambda function, we just put all of them to the DynamoDB table we created earlier.

React App

As I mentioned before, Amplify will sign requests to API Gateway with Signature Version 4 signing process. It is actually very easy to achieve that — something like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { API } from 'aws-amplify';

const apiName = 'secretSantaApi';
const santaPath = '/secretsanta';
const queryingSantaPayload = {
headers: {},
response: true,
queryStringParameters: {
// empty
},
};
API
.get(apiName, santaPath, queryingSantaPayload)
.then(response => {
console.log(response);
})
.catch(error => {
console.log(error);
});

In addition to this, we also used a library called react-countdown for rendering countdown timer on out web app. We had something similar to this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import Countdown, {zeroPad} from 'react-countdown';

function App() {
const [timerCompleted, setTimerCompleted] = useState(false);

const Completionist = () => {
return (
<div>
<span>Sign up closed!</span>
<span>Please check later for more information.</span>
</div>
);
}

const renderer = ({ days, hours, minutes, seconds, completed }) => {
if (completed) {
if(!timerCompleted) {
setTimerCompleted(true);
}
return <Completionist/>;
} else {
if ({days} === 0) return <span className="countdown-timer">{zeroPad(hours)}:{zeroPad(minutes)}:{zeroPad(seconds)}</span>;
if ({days} === 1) return <span className="countdown-timer">{days} Day - {zeroPad(hours)}:{zeroPad(minutes)}:{zeroPad(seconds)}</span>;
return <span className="countdown-timer">{days} Days - {zeroPad(hours)}:{zeroPad(minutes)}:{zeroPad(seconds)}</span>;
}
};

return (
{!timerCompleted ?
<Countdown
date={new Date(1605398400000)} // Nov. 15 0:00 UTC
zeroPadTime={2}
renderer={renderer}
/> : <></>}
);
}

Deployment

We broke the app into dev and prod environments. As I discussed before, Amplify offers staging support where we can test it privately without publishing the changes to the prod environment. Also, for all Lambda functions we have created, other resources, such as Cognito user pool, DynamoDB table, can be referenced via environment variables. As long as the resource names are referenced via environment variables, developers can distinguish resources in different environments and never cross-referencing them.

Remember I mentioned before that we’ve deployed this app to several teams with great help offered by Amplify? After few of these teams reached out to us, we began to discover the possibility of deploying this app in different AWS accounts. A big win for us here by using Amplify was that as it creates the entire app as IaC (Infrastructure as Code), we were able to easily deploy the entire project — frontend and backend — into other AWS accounts. We composed an SOP for how to do accomplish this and shared it to those teams, they deployed their copy in no time.

What We Learned

At the beginning, we pictured this app as a little toy for ourselves and our teammates. Fun and exciting it was, we had very little time to pull this off. The original requirements for this app were tailored for our team only, and we hoped to get it done as quick as possible. Even though we used the experiences we gained in the past and have attempted to design it with best practices, there were still something we missed in our app — either they fell through the cracks or feature requests from our users. An observant reader like you may already have spotted some of them:

  • We have never asked the user to fill their first name and last name.
  • We have considered adding address as a user attribute, but forgot about adding telephone number as it is also a required field in a lot of shopping websites.
  • We have received feature requests such as wishlist, field for tracking number, and checkbox if parcel is delivered.
  • Due to the limit of ListUsers API, we can only retrieve 60 users in one API call. Since the number of participants we anticipated is smaller than this number, we didn’t add paginator to our app to accommodate use cases where there are over 60 persons would sign up to the app.
  • In our Cognito user pool, we used the default email feature to send our verification emails. This would only allow us to send 50 emails per day. In a production environment, we would need to use SES for this purpose.
  • The countdown timer on the web app is not linked with the CloudWatch rule, which means if we were to change the deadline, we would need to change it on those two different places. More ideally, we could have another API to read the cron expression from the CloudWatch rule, and interpret it to the UNIX time format. On the frontend, we can just request it periodically to check if there is any update on the deadline. It would also make the IaC deployment even easier.

Epilogue

This was a hell of a ride. Saying both developing and managing the Secret Santa game were fun is an understatement. It almost was a service we developed and maintained. Not only we took in feature requests of the service, also catered special requests from people. We can probably call it SaaS — Santa as a Service, huh?

On the week of Dec. 7, Ciarán and I began to ask participants to post unboxing videos and photos. We would like to have everyone sharing the joy with each other. At the end, we composed a video consists of everyone’s gifts. We completed Secret Santa of 2020 with surprises and happiness.

Around that time of a year, I often reflect one event happened in the past.

It was during the Christmas season of 2013, the first Christmas I had in Ireland. At that time, I lived in a college apartment. Prior to the holidays, the apartment management have emailed to me checking if I would stay on campus, I wasn’t too sure why then. Later, someone knocked the door and left some Christmas gift for each one stayed on campus. It was a great surprise for me — someone who just came to this country and haven’t properly experienced Christmas before. Because of this magical experience I have had, I hoped everyone participated Secret Santa would feel something like that.

In the end, I would like to express my gratitude to Ciarán. Without him, I wouldn’t have that much of fun unboxing presents I received from Secret Santa we had in the past few years, nor having this online version of Secret Santa developed.