AUTOMATING HOCKEY TEAM MANAGEMENT WITH SERVERLESS
Simon MacDonald
@macdonst
🇨🇦
Management Tasks
✉️ Create a reminder email with game time and location (10 minutes)
📥 Collect responses from those who can't play (20 minutes)
🔍 Find spares (20 minutes)
📋 Create a roster for the game (20 minutes)
⏳70 Minutes
📅Every Week
🗓️10 Years
Is It Worth the Time?
Randall Munroe's Books
70 minutes
-30 minutes
40 minutes
Saves Time
30 mins x 52 wks x 5 yr
Time saved over 5 years
130 hours
Budget:
5 days, 10 hours
How can I shave 30 minutes off these tasks
✉️ Automate reminder email with game time and location (-10 minutes)
🔍 Automate email to find spares (-10 minutes)
📋 Partially automate roster creation (-10 minutes)
Time to get to work
Keys
Game
To
The
🏒 Be able to send emails
🏒 Perform CRUD operations on a JSON Database
🏒 Create a spreadsheet
FaaS Options
Not all FaaS are Created Equal
✉️
- Email reminder
- Request for Spares
Getting Started
const nodemailer = require('nodemailer');
const { google } = require('googleapis');
const OAuth2 = google.auth.OAuth2;
exports.handler = async function(event, context) {
const clientId = process.env.CLIENT_ID;
const clientSecret = process.env.CLIENT_SECRET;
const refresh_token = process.env.REFRESH_TOKEN;
const mailUser = process.env.MAIL_USER;
const oauth2Client = new OAuth2(
clientId,
clientSecret,
'https://developers.google.com/oauthplayground'
);
oauth2Client.setCredentials({ refresh_token });
const tokens = await oauth2Client.refreshAccessToken();
const accessToken = tokens.credentials.access_token;
Sending Emails
const smtpTransport = nodemailer.createTransport({
service: 'gmail',
auth: {
type: 'OAuth2',
user: mailUser,
clientId,
clientSecret,
refreshToken: refresh_token,
accessToken: accessToken
}
});
return smtpTransport
.sendMail(event.template)
.then(response => {
smtpTransport.close();
return response;
})
.catch(error => {
smtpTransport.close();
return error;
});
};
Sending Emails
✉️
Because of the spam abuse that has historically been sent from people using EC2 instances, virtually ALL popular mail providers block the receipt of email from EC2 instances. The world of email and anti-spam measures is part-technical, part-political.
Remaining FaaS Options
const nodemailer = require('nodemailer');
const { google } = require('googleapis');
const OAuth2 = google.auth.OAuth2;
module.exports = async function(context, template) {
const clientId = process.env.CLIENT_ID;
const clientSecret = process.env.CLIENT_SECRET;
const refresh_token = process.env.REFRESH_TOKEN;
const mailUser = process.env.MAIL_USER;
context.log(
'JavaScript HTTP trigger function processed a request using environment vars.'
);
const oauth2Client = new OAuth2(
clientId,
clientSecret,
'https://developers.google.com/oauthplayground'
);
oauth2Client.setCredentials({ refresh_token });
const tokens = await oauth2Client.refreshAccessToken();
const accessToken = tokens.credentials.access_token;
Sending Emails Part II
const smtpTransport = nodemailer.createTransport({
service: 'gmail',
auth: {
type: 'OAuth2',
user: mailUser,
clientId,
clientSecret,
refreshToken: refresh_token,
accessToken: accessToken
}
});
return smtpTransport
.sendMail(template)
.then(response => {
smtpTransport.close();
context.done(null, response);
})
.catch(error => {
smtpTransport.close();
context.done(null, error);
});
};
Sending Emails Part II
Cloud Database
Enter CosmosDB
[
{
"facility": "Sensplex - Canadian Tire",
"date": "2019-05-03",
"time": "10:15 PM"
},
{
"facility": "Sensplex - Bradley's Insurance Arena",
"date": "2019-05-10",
"time": "9:30 PM"
},
{
"facility": "Sensplex - Bradley's Insurance Arena",
"date": "2019-05-17",
"time": "10:00 PM",
"spares": [
{ "name": "Byron Carrillo", "email": "bcd@nope.ca" },
{ "name": "Matthew Pham", "email": "matthewpham@mattpham.com" }
]
}
]
Sample Games DB
{
"bindings": [
{
"name": "template",
"type": "activityTrigger",
"direction": "in"
},
{
"name": "gamesDocument",
"type": "cosmosDB",
"databaseName": "Games",
"collectionName": "Items",
"createIfNotExists": true,
"connectionStringSetting": "CosmosDB",
"direction": "in"
},
…
],
"disabled": false
}
DB Binding
const dayjs = require('dayjs');
const Database = require('../utils/db');
module.exports = async function(context, req) {
const client = new Database();
const players = context.bindings.playersDocument;
const games = context.bindings.gamesDocument;
const {
facility: facility,
date: gameDate,
time: gameTime
} = await client.getNextGame(games);
// If there is no game this Friday skip sending a reminder
if (gameDate === null) {
return {
body: 'No Game'
};
}
const allSpares = context.bindings.sparesDocument;
const spares = allSpares.filter(spare => spare.playing.includes(gameDate));
const email = {
from: 'me@gmail.com',
to: generateEmailList(players),
cc: generateEmailList(spares),
subject: `Hockey: Friday ${dayjs(gameDate).format(
'MMMM D'
)} ${gameTime} ${facility}`,
html: generateBody(players, facility, gameDate, gameTime, spares),
generateTextFromHTML: true
};
context.log(email);
context.done(null, email);
};
Email Template Code
function Template
+
function Email
=
???
Chaining Functions Using Orchestration
const df = require('durable-functions');
module.exports = df.orchestrator(function*(context) {
const template = yield context.df.callActivity('template', 'test');
const email = yield context.df.callActivity('email', template);
return email;
});
Chaining
⏰
Cron
Jobs
{
"bindings": [
{
"name": "sheetTimer",
"type": "timerTrigger",
"direction": "in",
"schedule": "0 0 6 * * 5"
},
{
"name": "playersDocument",
"type": "cosmosDB",
"databaseName": "Players",
"collectionName": "Items",
"createIfNotExists": true,
"connectionStringSetting": "CosmosDB",
"direction": "in"
},
…
]
}
CRON Binding
const { google } = require('googleapis');
const OAuth2 = google.auth.OAuth2;
const dayjs = require('dayjs');
// read what database class we want from runtime vars
const Database = require('../utils/db');
module.exports = async function(context, req) {
const clientId = process.env.SHEETS_CLIENT_ID;
const clientSecret = process.env.SHEETS_CLIENT_SECRET;
const refresh_token = process.env.SHEETS_REFRESH_TOKEN;
const oauth2Client = new OAuth2(
clientId,
clientSecret,
'https://developers.google.com/oauthplayground'
);
oauth2Client.setCredentials({ refresh_token });
const tokens = await oauth2Client.refreshAccessToken();
const accessToken = tokens.credentials.access_token;
oauth2Client.credentials = {
access_token: accessToken
};
Create Spreadsheet
const client = new Database();
const players = context.bindings.playersDocument;
const games = context.bindings.gamesDocument;
const { date: gameDate } = await client.getNextGame(games);
const allSpares = context.bindings.sparesDocument;
const spares = allSpares.filter(spare => spare.playing.includes(gameDate));
const playing = generateAvailable(players, spares, gameDate);
const resource = {
properties: {
title: `Roster for ${dayjs(gameDate).format('MMMM D')}`
}
};
const sheets = google.sheets({ version: 'v4', auth: oauth2Client });
const spreadsheetId = await createSpreadsheet(resource, sheets);
const appendResult = await appendValues(spreadsheetId, playing, sheets);
const formattingResult = await conditionalFormatting(spreadsheetId, sheets);
console.log('we have success');
return {
body: formattingResult
};
};
Create Spreadsheet
Then The Bill Came
💵 July $144.84
💰 Aug $166.67
💸 Sept $173.55
Reducing CosmosDB Costs
- Minimize RU/s (400 minimum)
- Combine containers
- Disable Geo-Redundancy
- Disable Multi-Region Writes
What I Learned
- Not all FaaS support the same functionality
- Serverless Programming can accelerate your TTM
- Test in Production ASAP!
- Keep a Close Eye on Your Costs
- Experimentation is fun and I'd do it all over again!
🤔
Thank You
Continue the conversation with me on:
Slides available at:
Code at:
AUTOMATING HOCKEY TEAM MANAGEMENT WITH SERVERLESS
By Simon MacDonald
AUTOMATING HOCKEY TEAM MANAGEMENT WITH SERVERLESS
Code PaLOUsa
- 2,145