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:

https://twitter.com/macdonst 

 

Slides available at:

http://tiny.cc/hockey-manager

 

Code at:

https://github.com/macdonst/FridayNightHockey

AUTOMATING HOCKEY TEAM MANAGEMENT WITH SERVERLESS

By Simon MacDonald

AUTOMATING HOCKEY TEAM MANAGEMENT WITH SERVERLESS

OttawaJS Dec 2019

  • 2,447