The Aircall Labs team builds applications on top of the Aircall Public API to enrich customer experience.

The first released integration is a Weather app, Aircall Caller Insights will automatically display the weather, location and temperature based on callers' phone number.

We decided to open source its codebase, and to share every secret with developers.

Before getting started

Guide

As you can see, this page is split in two. Code explanation will be written here, in this left white pane. On the right side of the page is located a user interface allowing you to navigate in the Weather app codebase.

Technical stack used

The Weather app logic relies on a NodeJS web server. We used Express as a web framework, axios to send HTTP requests, winston as a logger and dotenv to load environment variables from a .env file.

As our main stack runs on AWS, we decided to use Docker and run the app with ECS. This part won't be covered in this guide.

As for the database, the only need we have is to store Company's authentication info (one API token and one webhook token). We decided to go with DynamoDB as it's fast, flexible and very easy to use!

Source tree

We followed a pretty straightforward source tree organization:

  • ~/scripts/: some scripts used to init database
  • ~/src/: where all the source code of the app is
    • ./controllers/: handling incoming HTTP requests' logic
    • ./libs/: everything that interacts with outside tools (like OpenWeatherMap)
    • ./models/: Company, Insight Cards and Regions models
    • ./models/: JavaScript helpers
    • ./public/: HTML, CSS and images
    • ./utils/: exports basic functions
  • ~/tests/: tests written with Jest

Install

Check the README.md file in the codebase and follow the steps described.

You'll need to:

  • Define all the ENV variables in a .env file
  • Get Aircall's OAuth credentials
  • Install Docker & DynamoDB
  • Run the app with Docker

Once the install is done and the stack is running, your webserver will be available on http://localhost:5000.

Public URL

Before deploying your app in production, you might want to test it locally. We recommend using ngrok to generate a public URL that you'll be able to submit to Aircall!

Once ngrok is installed, you can easily get your app behind a custom public uri:

./ngrok http 5000 --subdomain=my-weather-app-custom-url

We'll use this Public URL to implement the OAuth logic later.

Web server

App entry point

The src/server.js file is the main entry point of the app. That's where we'll define the NodeJS web server. In this app, we use Express as a web framework.

We need bodyParser middleware to handle req.body properties in POST requests.

Using the express.static middleware line 18 allows us to serve HTML and CSS files, located in the public/ folder. More information in express' documentation.

Define public endpoints

The src/routes.js file is where all public endpoints are defined.

  • [GET] / renders a basic JSON file. Via Main Controller.
  • [GET] /health renders a blank page, with a 200 HTTP Code. Via Main Controller.
  • [GET] /oauth/install redirects to Aircall's authorize URL (see the OAuth section below). Via OAuth Controller.
  • [GET] /oauth/callback is called by Aircall once the admin has authorized the application scope (see the OAuth section below). Via OAuth Controller.
  • [GET] /oauth/success redirects to Aircall' success page. Via OAuth Controller.
  • [POST] /webhooks will be called by Aircall, everytime a new Webhook event is emmited. Via Webhook Controller.
  • [GET|POST|PUT] * takes any other request to a 404 page.

We'll see in the following chapters how those endpoints are implemented.

OAuth

Implementing OAuth with Aircall has been documented in our API References and in the following tutorial:

๐Ÿ”‘
Get started with Aircall OAuth
Building an app for Aircall users? Learn how the Aircall OAuth flow works!
โ†’

Here's the ultimate flow we want to implement will look like this:

Aircall OAuth flow

The install_uri endpoint

In this section, we are going to implement steps 2 and 3 of the diagram above.

As seen in the public endpoint section above, the install_uri is defined in the src/routes.js file, line 35.

Thanks to express, we now have the following endpoint available publicly:

[GET] https://my-weather-app-custom-url.ngrok.io/oauth/install

This endpoint uses the install method, defined and exported from the OAuth Controller line 18.

Now that we have defined our GET endpoint (step 2), we need to redirect any incoming requests to Aircall's consent page (step 3). The _install method defines the full Aircall URL before redirecting the request to it.

Once interpolated, the URL will look like the following:

https://dashboard-v2.aircall.io/oauth/authorize?client_id=YOUR_OAUTH_ID&redirect_uri=YOUR_PUBLIC_ENDPOINT/oauth/callback&response_type=code&scope=public_api

The redirect_uri endpoint

In this section, we are going to implement step 5 of the diagram above.

Just as the install_uri, the redirect_uri is defined in the src/routes.js file, line 41.

The following endpoint is then available publicly:

[GET] https://my-weather-app-custom-url.ngrok.io/oauth/callback

This endpoint uses the callback method, defined and exported from the OAuth Controller line 35.

Aircall will request this endpoint with a GET request and a code will be sent along in the URL. The Company.createFromOauth method, line 57, then exchanges the temporary OAuth authorization code for a Public API access_token. We will explain how that works in the next section.

Exchange OAuth authorization code

In this section, we are going to implement steps 6 and 8 of the diagram above.

The goal of this step is to exchange an OAuth authorization code given by Aircall during the OAuth process into a Public API access_token.

The createFromOauth method is an async function, doing three things:

  1. CompanyClass.exchangeOauthCode(oauthCode):
    Exchange the OAuth Code (line 88)
  2. CompanyClass.createAircallWebhook(accessToken):
    Automatically create an Aircall Webhook, used to send Insight Cards (line 91)
  3. new CompanyClass():
    Storing in DynamoDB the Company's information (line 95)

1. CompanyClass.exchangeOauthCode(oauthCode)

To convert an OAuth authorization code into an access_token, we'll use the following POST endpoint, documented in the API References:

[POST] https://api.aircall.io/v1/oauth/token

Following the API References, a payload needs to be attached to this POST request with:

  • code: the OAuth authorization code
  • redirect_uri: the /oauth/callback URL built in previous step
  • client_id: given by Aircall
  • client_secret: given by Aircall
  • grant_type: should always be authorization_code

Once the request is sent, Aircall will give us back the access_token. This token can be used to make API calls to Aircall and retrieve users' information.

2. CompanyClass.createAircallWebhook(accessToken)

Once we have a Public API access_token, we can use it to make our first Public API request. This createAircallWebhook method automatically creates a Webhook, listening to call.created Webhook events:

[POST] https://api.aircall.io/v1/webhook

HTTP headers:
{ Authorization: `Bearer ${accessToken}` }

Body:
{
  custom_name: 'Aircall Weather',
  url: 'https://my-weather-app-custom-url.ngrok.io/webhooks',
  events: ['call.created'],
}

As seen in the API References, this POST request needs to be authenticated and sent with three body params.

  • To authenticate a request, we use the accessToken that we retrieved before and pass it as a Bearer Token.

  • As for the three body params:

    • custom_name: optional but useful to identify which webhook is ours later;
    • url: the URL to which Aircall will send Webhook events to. We'll define that POST endpoint later in this guide;
    • events: we'll just need the call.created webhook event (see documentation here).

Once the request is sent, we can retrieve the webhook unique token. We will use it later to identify from which Aircall account an event is sent from.

3. new CompanyClass()

We now have both the Public API accessToken and our Webhook token.

  • The Public API accessToken will be used to send Insight Card API requests.
  • The Webhook token will be used to identify from which Aircall company a Webhook event is sent from.

With new CompanyClass(), a new instance of CompanyClass is created, with the apiToken and the webhookToken.

The save() function, line 33, stores in DynamoDB those information.

Webhooks

The OAuth flow is handled. A webhook is automatically created at the end of it, listening to call.created events.

Now, each time a inbound or outbound call will happen on the user Aircall account, Aircall will send a POST request to our server, on the /webhooks endpoint. This endpoint is declared in the routes.js file, line 54:

Webhook controller

The index method handles any incoming POST request on the /webhooks endpoint.

Line 21, we set the HTTP code to 202 to avoid Webhooks' automatic deactivation.

Read more on automatic deactivation of Webhooks in Aircall API References.

Once we checked that the event sent by Aircall is included in the list of events authorized (line 29), the startFlow method is called and a JSON file is rendered.

startFlow()

The startFlow() function is where the magic happens.

  1. It authenticates request, fetching from the DynamoDB database the associated company:

  2. Extract location information from the end-user's number:

  3. Fetch weather information from OpenWeatherMap's API:

  4. Once location has been extracted and weather data has been requested, we can send an Insight Card. Jump to the following section of this guide!

Insight Card

Read more about Insight Cards in our API References or in our dedicated tutorial!

If a region and its weather information are successfully extracted from a Webhook call.created event, an Insight Card object is created and sent.

Sending an Insight Card to Aircall consists in executing the following request:

[POST] https://api.aircall.io/v1/calls/:id/insight_cards

Body:
{
  "contents": [
    {
      "type": "title",
      "text": "Weather"
    },
    {
      "type": "shortText",
      "label": "City",
      "text": "New York City",
      "link": "https://en.wikipedia.org/w/index.php?search=New York City"
    },
    {
      "type": "shortText",
      "label": "Temperature",
      "text": "80โ„‰",
    },
    {
      "type": "shortText",
      "label": "Clouds",
      "text": "โ›…๏ธ"
    }
  ]
}

We are creating Insight Cards via a JavaScript object:

Once the Insight Card is created, we can then send it via Aircall Public API, by just passing the apiToken retrieved from the database earlier:

Conclusion

In this guide, we implemented two main flows of the Aircall Public API:

  • OAuth, allowing users to install the Weather app.
  • Webhooks, sending events to our web server each time a call is started.

We didn't cover the HTML files, our usage of Docker and Docker compose, tests implementation, DyanmoDB, the phone number to region matching and much more. Be curious and navigate the code base!

Enjoy,
The Aircall Labs team.

~/
    Folders
    • scripts
      • createDBTables.js
    • src
      • controllers
        • main_controller.js
        • oauth_controller.js
        • webhooks_controller.js
      • libs
        • openweathermap.js
      • models
        • region
          • australia.js
          • belgium.js
          • brazil.js
          • canada.js
          • france.js
          • generic.js
          • germany.js
          • index.js
          • ireland.js
          • italy.js
          • mexico.js
          • morocco.js
          • netherlands.js
          • spain.js
          • sweden.js
          • switzerland.js
          • united_kingdom.js
          • usa.js
        • company.js
        • insight_card.js
      • modules
        • api_requester.js
        • db.js
        • env.js
        • logger.js
      • public
        • error.html
        • install.html
        • style.css
      • utils
        • strings.js
      • routes.js
      • server.js
    • tests
      • models
        • region.test.js
      • utils
        • strings.test.js
    • .env_template
    • docker-compose-ci.yml
    • docker-compose.yml
    • Dockerfile
    • package.json
    • README.md
PORT=5000

DEBUG=false
LOCAL=false

WEATHER_URL="http://localhost:5000"

AIRCALL_API_URL="https://api.aircall.io"

AIRCALL_OAUTH_AUTHORIZE_URL="https://dashboard-v2.aircall.io/oauth/authorize"
AIRCALL_OAUTH_SUCCESS_URL="https://dashboard-v2.aircall.io/oauth/success"
AIRCALL_OAUTH_ID="abc123"
AIRCALL_OAUTH_SECRET="123abc"

OPEN_WEATHER_API_URL="https://api.openweathermap.org/data/2.5"
OPEN_WEATHER_API_KEY="ZZZ"

AWS_DEFAULT_REGION="us-east-1"
AWS_DYNAMO_ENDPOINT="http://dynamodb-local:8000"
DYNAMODB_TABLE="Companies"
/**
 *  Create all DynamoDB tables
 */

const AWS = require('aws-sdk'),
  Env = require('./../src/modules/env');

AWS.config.update({
  region: Env.fetch('AWS_DEFAULT_REGION'),
  endpoint: Env.fetch('AWS_DYNAMO_ENDPOINT'),
});

let table = Env.fetch('DYNAMODB_TABLE') || 'Companies';

let dynamodb = new AWS.DynamoDB();

let params = {
  TableName: 'Companies',
  KeySchema: [{ AttributeName: 'WebhookToken', KeyType: 'HASH' }],
  AttributeDefinitions: [{ AttributeName: 'WebhookToken', AttributeType: 'S' }],
  ProvisionedThroughput: {
    ReadCapacityUnits: 10,
    WriteCapacityUnits: 10,
  },
};

dynamodb.createTable(params, function (err, data) {
  if (err) {
    console.error(
      'Unable to create table. Error JSON:',
      JSON.stringify(err, null, 2)
    );
  } else {
    console.log(
      'Created table. Table description JSON:',
      JSON.stringify(data, null, 2)
    );
  }
});
{
  "name": "aircall-weather",
  "version": "0.0.3",
  "description": "Display customer's location information",
  "author": "Xavier Durand ",
  "main": "src/server.js",
  "scripts": {
    "start": "node_modules/nodemon/bin/nodemon.js -r dotenv/config src/server.js",
    "test": "jest --silent"
  },
  "dependencies": {
    "aws-sdk": "^2.688.0",
    "axios": "^0.19.2",
    "body-parser": "^1.19.0",
    "dotenv": "^8.2.0",
    "express": "^4.17.1",
    "libphonenumber-js": "^1.7.53",
    "winston": "^3.3.2"
  },
  "devDependencies": {
    "husky": "^4.2.5",
    "jest": "^26.0.1",
    "lint-staged": "^10.2.11",
    "nodemon": "^2.0.4",
    "prettier": "2.0.5"
  },
  "husky": {
    "hooks": {
      "pre-commit": "lint-staged"
    }
  },
  "lint-staged": {
    "*.{js,css,md}": "prettier --single-quote --write"
  }
}
/**
 *  Test the Strings util
 */

const StringUtils = require('../../src/utils/strings');

describe('Capitalize', () => {
  test('empty string', () => {
    let result = StringUtils.capitalize('');
    expect(result).toBe('');
  });

  test('when null', () => {
    let result = StringUtils.capitalize();
    expect(result).toBe('');
  });

  test('when not a string', () => {
    let result = StringUtils.capitalize({ hello: 'world' });
    expect(result).toBe('');
  });

  test('all lowercase', () => {
    let result = StringUtils.capitalize('hello world');
    expect(result).toBe('Hello world');
  });

  test('all uppercase', () => {
    let result = StringUtils.capitalize('HELLO WORLD');
    expect(result).toBe('HELLO WORLD');
  });

  test('already capitalized', () => {
    let result = StringUtils.capitalize('Hello world');
    expect(result).toBe('Hello world');
  });
});
/**
 *  Test the Region model
 */

const Region = require('../../src/models/region');

/**
 *  Not defined
 */
describe('Country: not defined', () => {
  test('no number given', () => {
    let region = new Region();
    expect(region.get()).toBe(undefined);
  });

  test('empty string given', () => {
    let region = new Region('');
    expect(region.get()).toBe(undefined);
  });

  test('alpha-string given', () => {
    let region = new Region('this is not a number');
    expect(region.get()).toBe(undefined);
  });

  test('A-Z string given', () => {
    let region = new Region('this is not a number');
    expect(region.get()).toBe(undefined);
  });
});

/**
 *  Australia
 */
describe('Country: Australia', () => {
  const cities = [
    { phoneNumber: '+61284172226', name: 'Sydney' },
    { phoneNumber: '+61738443236', name: 'Brisbane' },
    { phoneNumber: '+61 8 9444 8212', name: 'Perth' },
  ];

  cities.forEach((city) => {
    test(`testing ${city.name}`, () => {
      let region = new Region(city.phoneNumber);
      expect(region.get()).toEqual({ city: city.name, country: 'Australia' });
    });
  });

  test('mobile number', () => {
    let region = new Region('+61 413 538 474');
    expect(region.get()).toBe(undefined);
  });
});

/**
 *  Belgium
 */
describe('Country: Belgium', () => {
  const cities = [
    { phoneNumber: '+32 2 223 73 77', name: 'Bruxelles' },
    { phoneNumber: '+32 9 223 55 55', name: 'Gand' },
    { phoneNumber: '+32 494 61 75 79', name: 'Liege' },
  ];

  cities.forEach((city) => {
    test(`testing ${city.name}`, () => {
      let region = new Region(city.phoneNumber);
      expect(region.get()).toEqual({ city: city.name, country: 'Belgium' });
    });
  });
});

/**
 *  Brazil
 */
describe('Country: Brazil', () => {
  const cities = [
    { phoneNumber: '+55 11 3503-3333', name: 'Sao Paulo' },
    { phoneNumber: '+55 21 2523-3787', name: 'Rio de Janeiro' },
    { phoneNumber: '+55 61 2028-5000', name: 'Distrito Federal' },
  ];

  cities.forEach((city) => {
    test(`testing ${city.name}`, () => {
      let region = new Region(city.phoneNumber);
      expect(region.get()).toEqual({ city: city.name, country: 'Brazil' });
    });
  });
});

/**
 *  Canada
 */
describe('Country: Canada', () => {
  const cities = [
    { phoneNumber: '+1 514-937-7754', name: 'Montreal' },
    { phoneNumber: '+1 604-666-6655', name: 'Abbotsford' },
    { phoneNumber: '+1 416-901-4724', name: 'Toronto' },
  ];

  cities.forEach((city) => {
    test(`testing ${city.name}`, () => {
      let region = new Region(city.phoneNumber);
      expect(region.get()).toEqual({ city: city.name, country: 'Canada' });
    });
  });
});

/**
 *  France
 */
describe('Country: France', () => {
  const cities = [
    { phoneNumber: '+33176360036', name: 'Bobigny' },
    { phoneNumber: '+33563721234', name: 'Castres' },
  ];

  cities.forEach((city) => {
    test(`testing ${city.name}`, () => {
      let region = new Region(city.phoneNumber);
      expect(region.get()).toEqual({ city: city.name, country: 'France' });
    });
  });

  test('mobile number', () => {
    let region = new Region('+33645135390');
    expect(region.get()).toBe(undefined);
  });

  test('toll-free number', () => {
    let region = new Region('+33800123432');
    expect(region.get()).toBe(undefined);
  });
});

/**
 *  Germany
 */
describe('Country: Germany', () => {
  const cities = [
    { phoneNumber: '+49 30 2835293', name: 'Berlin' },
    { phoneNumber: '+49 511 1699680', name: 'Hannover' },
    { phoneNumber: '+49 521 98818467', name: 'Bielefeld' },
  ];

  cities.forEach((city) => {
    test(`testing ${city.name}`, () => {
      let region = new Region(city.phoneNumber);
      expect(region.get()).toEqual({ city: city.name, country: 'Germany' });
    });
  });
});

/**
 *  Ireland
 */
describe('Country: Ireland', () => {
  const cities = [
    { phoneNumber: '+353 21 431 6118', name: 'Cork' },
    { phoneNumber: '+353 1 455 6633', name: 'Dublin' },
    { phoneNumber: '+353 91 526 003', name: 'Galway' },
  ];

  cities.forEach((city) => {
    test(`testing ${city.name}`, () => {
      let region = new Region(city.phoneNumber);
      expect(region.get()).toEqual({ city: city.name, country: 'Ireland' });
    });
  });
});

/**
 *  Italy
 */
describe('Country: Italy', () => {
  const cities = [
    { phoneNumber: '+39 377 087 7360', name: 'Codogno' },
    { phoneNumber: '+39 393 825 3361', name: 'Monza' },
    { phoneNumber: '+39 351 644 4007', name: 'Bergamo' },
  ];

  cities.forEach((city) => {
    test(`testing ${city.name}`, () => {
      let region = new Region(city.phoneNumber);
      expect(region.get()).toEqual({ city: city.name, country: 'Italy' });
    });
  });
});

/**
 *  Mexico
 */
describe('Country: Mexico', () => {
  const cities = [
    { phoneNumber: '+52 55 5521 2048', name: 'Mexico City' },
    { phoneNumber: '+52 998 886 9891', name: 'Puerto Morelos' },
    { phoneNumber: '+52 662 212 2700', name: 'San Pedro El Saucito' },
  ];

  cities.forEach((city) => {
    test(`testing ${city.name}`, () => {
      let region = new Region(city.phoneNumber);
      expect(region.get()).toEqual({ city: city.name, country: 'Mexico' });
    });
  });
});

/**
 *  Morocco
 */
describe('Country: Morocco', () => {
  const cities = [
    { phoneNumber: '+212 5376-33333', name: 'Rabat' },
    { phoneNumber: '+212 5243-81609', name: 'Marrakech' },
  ];

  cities.forEach((city) => {
    test(`testing ${city.name}`, () => {
      let region = new Region(city.phoneNumber);
      expect(region.get()).toEqual({ city: city.name, country: 'Morocco' });
    });
  });
});

/**
 *  Netherlands
 */
describe('Country: Netherlands', () => {
  const cities = [
    { phoneNumber: '+31 10 414 4188', name: 'Rotterdam' },
    { phoneNumber: '+31 20 320 5700', name: 'Amsterdam' },
    { phoneNumber: '+31 70 388 2367', name: 'The Hague' },
  ];

  cities.forEach((city) => {
    test(`testing ${city.name}`, () => {
      let region = new Region(city.phoneNumber);
      expect(region.get()).toEqual({ city: city.name, country: 'Netherlands' });
    });
  });
});

/**
 *  Spain
 */
describe('Country: Spain', () => {
  const cities = [
    { phoneNumber: '+34 915 32 02 59', name: 'Madrid' },
    { phoneNumber: '+34 933 19 70 03', name: 'Barcelona' },
    { phoneNumber: '+34 963 91 06 68', name: 'Valencia' },
  ];

  cities.forEach((city) => {
    test(`testing ${city.name}`, () => {
      let region = new Region(city.phoneNumber);
      expect(region.get()).toEqual({ city: city.name, country: 'Spain' });
    });
  });
});

/**
 *  Switzerland
 */
describe('Country: Switzerland', () => {
  const cities = [
    { phoneNumber: '+41 31 312 33 00', name: 'Berne' },
    { phoneNumber: '+41 21 625 01 45', name: 'Lausanne' },
    { phoneNumber: '+41 44 225 33 00', name: 'Zurich' },
  ];

  cities.forEach((city) => {
    test(`testing ${city.name}`, () => {
      let region = new Region(city.phoneNumber);
      expect(region.get()).toEqual({ city: city.name, country: 'Switzerland' });
    });
  });
});

/**
 *  UK
 */
describe('Country: UK', () => {
  const cities = [
    { phoneNumber: '+44 20 7268 6565', name: 'London' },
    { phoneNumber: '+44 1603 305300', name: 'Norwich' },
    { phoneNumber: '+44 1603 305300', name: 'Norwich' },
    { phoneNumber: '+44 131 624 8624', name: 'Edinburgh' },
  ];

  cities.forEach((city) => {
    test(`testing ${city.name}`, () => {
      let region = new Region(city.phoneNumber);
      expect(region.get()).toEqual({
        city: city.name,
        country: 'United Kingdom',
      });
    });
  });
});

/**
 *  Sweden
 */
describe('Country: Sweden', () => {
  const cities = [
    { phoneNumber: '+46 8 446 870 01', name: 'Stockholm' },
    { phoneNumber: '+46 40 630 92 80', name: 'Malmรถ' },
    { phoneNumber: '+46 46 841 00 59', name: 'Lund' },
  ];

  cities.forEach((city) => {
    test(`testing ${city.name}`, () => {
      let region = new Region(city.phoneNumber);
      expect(region.get()).toEqual({ city: city.name, country: 'Sweden' });
    });
  });
});

/**
 *  USA
 */
describe('Country: USA', () => {
  const cities = [
    { phoneNumber: '+1 (212) 254-2246', name: 'New York,NY' },
    { phoneNumber: '+1 (415) 487-2600', name: 'San Francisco,CA' },
    { phoneNumber: '+1 (303) 830-6839', name: 'Denver,CO' },
    { phoneNumber: '+1 (713) 581-2337', name: 'Houston,TX' },
  ];

  cities.forEach((city) => {
    test(`testing ${city.name}`, () => {
      let region = new Region(city.phoneNumber);
      expect(region.get()).toEqual({ city: city.name, country: 'USA' });
    });
  });
});
FROM node:12-alpine as base
WORKDIR /app
COPY package.json .

FROM base as dependencies
RUN npm install --production

########## RELEASE ##########
FROM base as release
COPY --from=dependencies /app/node_modules node_modules
COPY . .

LABEL "com.datadoghq.ad.logs"='[{"source": "nodejs", "service": "weather"}]'

EXPOSE 5000
CMD ["node", "-r", "dotenv/config", "src/server.js"]

########## TEST ##########
FROM dependencies as test
COPY . .
RUN npm install --development
/**
 *  Env module
 *  Fetch information from process.env
 */
const Logger = require('./../modules/logger');

/**
 *  Fetch varName from process.env
 *  Logs an error if varName is not defined (and hideLog is false)
 */
const _fetch = (varName, hideLog) => {
  const value = process.env[varName];
  if (!value && !hideLog) {
    Logger.error(`[Env] ${varName} is not defined.`);
  }
  return value || '';
};

module.exports = {
  fetch: _fetch,
};
/**
 *  Database (DynamoDB)
 *  Handles db connection
 */
const AWS = require('aws-sdk'),
  Env = require('./env'),
  Logger = require('./logger.js');

let dynamoClient = null;

/**
 *  Init dynamo client with the proper aws config
 */
const _init = () => {
  if (!dynamoClient) {
    let awsConfig = { region: Env.fetch('AWS_DEFAULT_REGION') };

    // AWS_DYNAMO_ENDPOINT needs to be defined only when working in local
    if (!!Env.fetch('AWS_DYNAMO_ENDPOINT', true)) {
      awsConfig['endpoint'] = Env.fetch('AWS_DYNAMO_ENDPOINT');
    }

    AWS.config.update(awsConfig);

    dynamoClient = new AWS.DynamoDB.DocumentClient();
  }
  return dynamoClient;
};

module.exports = {
  init: _init,
};
/**
 *  Aircall API requester
 *  Sends HTTPS requests to Aircall's API
 */

const axios = require('axios'),
  Logger = require('./logger'),
  Env = require('./env');

/**
 *  Sends a POST request to Aircall Public API
 */
const _post = async (path, headers, body) => {
  const apiUrl = Env.fetch('AIRCALL_API_URL');
  if (!path) {
    throw new Error('[ApiRequester][_post] path is not defined.');
  }

  headers = headers || {};
  // Aircall's API works better with `application/json` type
  headers['Content-Type'] = 'application/json';

  const url = apiUrl + path;
  let httpOptions = { headers };

  try {
    let payload = await axios.post(url, body, httpOptions);
    return payload.data;
  } catch (e) {
    throw new Error(`[ApiRequester][_post][${url}] ${e.message}`);
  }
};

module.exports = {
  POST: _post,
};
/**
 *  Winston logger module
 *  https://github.com/winstonjs/winston
 */

const { createLogger, format, transports } = require('winston');

let _logger = null;

// Prettify the console output in LOCAL
let consoleOptions =
  process.env.LOCAL === 'true'
    ? { format: format.combine(format.colorize(), format.simple()) }
    : {};

let maxLoggingLevel = process.env.DEBUG === 'true' ? 'info' : 'warn';

module.exports =
  _logger ||
  createLogger({
    level: maxLoggingLevel,
    exitOnError: false,
    format: format.json(),
    transports: [new transports.Console(consoleOptions)],
  });
/**
 *  String helpers
 */

/**
 *  Capitalize a string
 */
let _capitalize = (text) => {
  if (!text || typeof text !== 'string') {
    return '';
  }

  return `${text[0].toUpperCase()}${text.slice(1, text.length)}`;
};

module.exports = {
  capitalize: _capitalize,
};
/**
 *  OpenWeatherMap API
 *  Fetching weather information
 */

const axios = require('axios'),
  Logger = require('./../modules/logger'),
  Env = require('./../modules/env'),
  StringUtils = require('./../utils/strings');

const IMPERIAL_COUNTRIES = ['usa'];
const WEATHER_EMOJIS = {
  thunderstorm: 'โ›ˆ',
  drizzle: '๐ŸŒง',
  rain: '๐ŸŒง',
  snow: '๐ŸŒจ',
  mist: '๐ŸŒง',
  smoke: 'โ˜๏ธ',
  haze: 'โ˜๏ธ',
  dust: 'โ˜๏ธ',
  fog: 'โ˜๏ธ',
  sand: 'โ˜๏ธ',
  ash: 'โ˜๏ธ',
  squall: 'โ˜๏ธ๐Ÿ’จ',
  tornado: '๐ŸŒช',
  clear: 'โ˜€๏ธ',
  clouds: 'โ˜๏ธ',
};

/**
 *  Takes a city and a country
 *  Returns location information
 */
const _getCityWeather = async (city, country) => {
  if (!city) {
    throw new Error('[OpenWeatherMap][_getCityWeather] city is not defined.');
  }

  const apiUrl = Env.fetch('OPEN_WEATHER_API_URL');
  const apiKey = Env.fetch('OPEN_WEATHER_API_KEY');

  let unit = getTemperatureUnit(country);
  let location = `${city}`;

  if (!!country) {
    location = `${location},${country}`;
  }

  const url = `${apiUrl}/weather?q=${encodeURI(location)}&units=${unit}`;

  Logger.info(
    `[OpenWeatherMap][_getCityWeather] requesting temperature for ${location}`
  );

  try {
    let payload = await axios.get(`${url}&APPID=${apiKey}`);
    const unitSymbol = unit === 'imperial' ? 'โ„‰' : 'โ„ƒ';
    const temperature =
      !!payload &&
      !!payload.data &&
      !!payload.data.main &&
      !!payload.data.main &&
      `${Math.round(payload.data.main.temp)}${unitSymbol}`;

    const weather =
      !!payload &&
      !!payload.data &&
      !!payload.data.weather &&
      !!payload.data.weather.length > 0 &&
      !!payload.data.weather[0] &&
      payload.data.weather[0];

    Logger.info(
      `[OpenWeatherMap][_getCityWeather] temperature found ${location}`
    );

    return {
      temps: temperature,
      emoji: getWeatherEmoji(weather.main),
      description: StringUtils.capitalize(weather.description),
    };
  } catch (e) {
    throw new Error(
      `[OpenWeatherMap][_getCityWeather] Error for URL ${url}: ${e.message}`
    );
  }

  return null;
};

/*********************************************************/
/*                   PRIVATE FUNCTIONS                   */
/*********************************************************/

/**
 *  Fetch emoji from WEATHER_EMOJIS
 */
const getWeatherEmoji = (main) => {
  if (!main) {
    return;
  }

  return WEATHER_EMOJIS[main.toLowerCase()]
    ? WEATHER_EMOJIS[main.toLowerCase()]
    : main;
};

/**
 *  Get temperature unit
 */
const getTemperatureUnit = (country) => {
  if (!!country && !!IMPERIAL_COUNTRIES.includes(country.toLowerCase())) {
    return 'imperial';
  }
  return 'metric';
};

module.exports = {
  getCityWeather: _getCityWeather,
};
/**
 *  Company model
 *
 *  Stores the following fields in DynamoDB
 *  - createdAt: UTC string of when the object was created
 *  - apiToken: string used to send API requests to Aircall
 *  - webhookToken: token sent in every Webhook payload Aircall sends
 */
const Env = require('./../modules/env'),
  Logger = require('./../modules/logger'),
  Requester = require('./../modules/api_requester'),
  dynamoClient = require('./../modules/db').init();

const DB_TABLE_NAME = Env.fetch('DYNAMODB_TABLE') || 'Companies';

/**
 *  Company JS class
 */
class CompanyClass {
  createdAt = null;
  apiToken = null;
  webhookToken = null;

  constructor(apiToken, webhookToken) {
    this.createdAt = new Date().toUTCString();
    this.apiToken = apiToken;
    this.webhookToken = webhookToken;
  }

  /**
   *  Save a company instance in DynamoDB
   */
  async save() {
    let params = {
      TableName: DB_TABLE_NAME,
      Item: {
        CreatedAt: this.createdAt,
        ApiToken: this.apiToken,
        WebhookToken: this.webhookToken,
      },
    };

    try {
      await dynamoClient.put(params).promise();
    } catch (e) {
      throw new Error('[Company][save] Error:' + e.message);
    }
  }

  /**
   *  Static methods
   */

  /**
   *  Retrieve Company in DyanoDB
   */
  static async getFromWebhookToken(webhookToken) {
    const params = {
      TableName: DB_TABLE_NAME,
      KeyConditionExpression: 'WebhookToken = :i',
      ExpressionAttributeValues: {
        ':i': webhookToken,
      },
    };

    let company = null;
    try {
      const { Items, Count } = await dynamoClient.query(params).promise();
      if (Count > 1) {
        Logger.warn(
          `[Company][getFromWebhookToken] More than 1 company found with webhookToken ${webhookToken}`
        );
      }
      company = Items[0];
    } catch (e) {
      throw new Error('[Company][getFromWebhookToken] Error:' + e.message);
    }
    return company;
  }

  /**
   *  Create a company by:
   *   1. creating an Aircall access token
   *   2. creating an Aircall Webhook
   */
  static async createFromOauth(oauthCode) {
    // 1. Create Aircall accessToken
    let accessToken = await CompanyClass.exchangeOauthCode(oauthCode);

    // 2. Create Aircall Webhook
    let webhookToken = await CompanyClass.createAircallWebhook(accessToken);

    // 3. Save Company object
    let company = new CompanyClass(accessToken, webhookToken);
    await company.save();

    return company;
  }

  /**
   *  Create an Access Token to be used with Aircall's API
   */
  static async exchangeOauthCode(oauthCode) {
    if (!oauthCode) {
      throw new Error('[Company][exchangeOauthCode] oauthCode is undefined');
    }

    const body = {
      code: oauthCode,
      redirect_uri: `${Env.fetch('WEATHER_URL')}/oauth/callback`,
      client_id: Env.fetch('AIRCALL_OAUTH_ID'),
      client_secret: Env.fetch('AIRCALL_OAUTH_SECRET'),
      grant_type: 'authorization_code',
    };
    try {
      let payload = await Requester.POST(`/v1/oauth/token`, null, body);
      return payload.access_token;
    } catch (e) {
      Logger.error(e.message);
    }
  }

  /**
   *  Create an Aircall Webhook
   *  and return the Webhook token
   */
  static async createAircallWebhook(accessToken) {
    if (!accessToken) {
      throw new Error(
        '[Company][createAircallWebhook] accessToken is undefined'
      );
    }

    const httpHeaders = { Authorization: `Bearer ${accessToken}` };

    const body = {
      custom_name: 'Aircall Weather',
      url: `${Env.fetch('WEATHER_URL')}/webhooks`,
      events: ['call.created'],
    };

    try {
      let payload = await Requester.POST(`/v1/webhooks`, httpHeaders, body);
      return payload.webhook.token;
    } catch (e) {
      Logger.error(e.message);
    }
  }
}

module.exports = CompanyClass;
const GenericCountry = require('./generic.js');

class MoroccoCountry extends GenericCountry {
  name = 'Morocco';
  type = 'city';
  codes = {
    '+212520': 'Casablanca',
    '+2125243': 'Marrakech',
    ...
    '+2125399': 'Al Hoceima',
  };
}

module.exports = MoroccoCountry;
const GenericCountry = require('./generic.js');

class SwitzerlandCountry extends GenericCountry {
  name = 'Switzerland';
  type = 'region_city';
  codes = {
    '+4121': 'Lausanne',
    '+4122': 'Geneve',
    ...
    '+4191': 'Bellinzona',
  };
}

module.exports = SwitzerlandCountry;
const GenericCountry = require('./generic.js');

class BrazilCountry extends GenericCountry {
  name = 'Canada';
  type = 'region_capital';
  codes = {
    '+1204': 'Winnipeg',
    '+1226': 'Brantford',
    ...
    '+1905': 'Brampton',
  };
}

module.exports = BrazilCountry;
const GenericCountry = require('./generic.js');

class FranceCountry extends GenericCountry {
  name = 'Ireland';
  type = 'city';
  codes = {
    '+3531': 'Dublin',
    '+35321': 'Cork',
    ...
    '+35399': 'Kilronan',
  };
}

module.exports = FranceCountry;
const GenericCountry = require('./generic.js');

class SpainCountry extends GenericCountry {
  name = 'Spain';
  type = 'city';
  codes = {
    '+3481': 'Madrid',
    '+34820': 'Avila',
    '+34821': 'Segovia',
    ...
    '+34988': 'Orense',
  };
}

module.exports = SpainCountry;
const GenericCountry = require('./generic.js');

class BrazilCountry extends GenericCountry {
  name = 'Brazil';
  type = 'region_capital';
  codes = {
    '+5511': 'Sao Paulo',
    '+5521': 'Rio de Janeiro',
    ...
    '+5599': 'Maranhao',
  };
}

module.exports = BrazilCountry;
const GenericCountry = require('./generic.js');

class NetherlandsCountry extends GenericCountry {
  name = 'Netherlands';
  type = 'city';
  codes = {
    '+3110': 'Rotterdam',
    '+31111': 'Zierikzee',
    ...
    '+3179': 'Zoetermeer',
  };
}

module.exports = NetherlandsCountry;
/**
 *  Parse a phone number and gets the city/country out of it
 */

const libPhoneNumber = require('libphonenumber-js/max'),
  Logger = require('../../modules/logger.js');

// Require all available countries
const countryClasses = {
  AU: require('./australia.js'),
  BE: require('./belgium.js'),
  BR: require('./brazil.js'),
  CA: require('./canada.js'),
  CH: require('./switzerland.js'),
  DE: require('./germany.js'),
  ES: require('./spain.js'),
  FR: require('./france.js'),
  GB: require('./united_kingdom.js'),
  IE: require('./ireland.js'),
  IT: require('./italy.js'),
  MA: require('./morocco.js'),
  MX: require('./mexico.js'),
  NL: require('./netherlands.js'),
  SE: require('./sweden.js'),
  US: require('./usa.js'),
};

class RegionClass {
  phoneNumber = null;
  country = null;

  constructor(phoneNumberString) {
    this.phoneNumber =
      !!phoneNumberString &&
      libPhoneNumber.parsePhoneNumberFromString(phoneNumberString);

    if (!this.phoneNumber) {
      Logger.warn(
        `[RegionClass][constructor] info of the following phoneNumber could not be extracted ${phoneNumberString}`
      );
      return;
    }

    if (!this.phoneNumber.country) {
      Logger.warn(
        `[RegionClass][constructor] countryCode is undefined for ${this.anonymizedPhoneNumber()}`
      );
      return;
    }

    this.country =
      !!countryClasses[this.phoneNumber.country] &&
      new countryClasses[this.phoneNumber.country]();
  }

  /**
   *  Returns an object with city and country names
   */
  get() {
    if (!this.phoneNumber || !this.country) {
      return;
    }
    let phoneNumberString = this.phoneNumber.number;

    let city = null;
    let codes = this.country.getCodes();

    while (!city && !!phoneNumberString.length) {
      if (!!codes[phoneNumberString]) {
        city = codes[phoneNumberString];
      }
      phoneNumberString = phoneNumberString.slice(0, -1);
    }

    let region = null;
    if (!city) {
      Logger.warn(
        `[RegionClass][get] region not found for number ${this.anonymizedPhoneNumber()} - type is ${this.phoneNumber.getType()}.`
      );
      return;
    }

    Logger.info(
      `[RegionClass][get] region found for number ${this.anonymizedPhoneNumber()}: ${city}`
    );

    return {
      city,
      country: this.country.getName(),
    };
  }

  /**
   *  Replace the last 4 digits of the number by XXXX
   */
  anonymizedPhoneNumber() {
    if (!this.phoneNumber || !this.phoneNumber.number) {
      return '';
    }

    let phoneNumberString = this.phoneNumber.number || '';
    return phoneNumberString.length > 4
      ? phoneNumberString.slice(0, phoneNumberString.length - 4) + 'XXXX'
      : phoneNumberString;
  }
}

module.exports = RegionClass;
const GenericCountry = require('./generic.js');

class SwedenCountry extends GenericCountry {
  name = 'Sweden';
  type = 'city';
  codes = {
    '+4611': 'Norrkรถping',
    '+46120': 'ร…tvidaberg',
    ...
    '+46981': 'Vittangi',
  };
}

module.exports = SwedenCountry;
const GenericCountry = require('./generic.js');

class AustraliaCountry extends GenericCountry {
  name = 'Australia';
  type = 'city';
  codes = {
    '+61233': 'Gosford',
    ...
    '+61899': 'Geraldton',
  };
}

module.exports = AustraliaCountry;
const GenericCountry = require('./generic.js');

class GermanyCountry extends GenericCountry {
  name = 'Germany';
  type = 'region';
  codes = {
    '+49201': 'Essen',
    '+49202': 'Wuppertal',
    ...
    '+499978': 'Schonthal Oberpfalz',
  };
}

module.exports = GermanyCountry;
const GenericCountry = require('./generic.js');

class MexicoCountry extends GenericCountry {
  name = 'Mexico';
  type = 'city';
  codes = {
    '+5255': 'Mexico City',
    '+5233': 'Guadalajara',
    ...
    '+52747': 'Zumpango Del Rio',
  };
}

module.exports = MexicoCountry;
const GenericCountry = require('./generic.js');

class USACountry extends GenericCountry {
  name = 'USA';
  type = 'region_capital';
  codes = {
    '+1201': 'Jersey City,NJ',
    '+1202': 'District of Columbia',
    '+1203': 'Bridgeport,CT',
    ...
    '+1989': 'Saginaw,MI',
  };
}

module.exports = USACountry;
const GenericCountry = require('./generic.js');

class BelgiumCountry extends GenericCountry {
  name = 'Belgium';
  type = 'region';
  codes = {
    '+3210': 'Wavre',
    '+3211': 'Hasselt',
    ...
    '+329': 'Gand',
  };
}

module.exports = BelgiumCountry;
/**
 *  GenericCountry class implementing get functions
 *  Each country must extend this class
 *
 *  - name: full name of the country
 *  - type: can be city, region, region_capital
 *  - codes: a JSON object with prefixes as keys and city/region as values:
 *    { "+33105": "Ile de France",... }
 */
class GenericCountry {
  name = null;
  type = null;
  codes = {};

  getName() {
    return this.name;
  }

  getType() {
    return this.type;
  }

  getCodes() {
    return this.codes;
  }
}

module.exports = GenericCountry;
const GenericCountry = require('./generic.js');

class UKCountry extends GenericCountry {
  name = 'United Kingdom';
  type = 'city';
  codes = {
    '+44113': 'Leeds',
    '+44114': 'Sheffield',
    ...
    '+4499': 'Southend-on-Sea',
  };
}

module.exports = UKCountry;
const GenericCountry = require('./generic.js');

class FranceCountry extends GenericCountry {
  name = 'France';
  type = 'city';
  codes = {
    '+33105': 'Paris',
    '+3313021': 'Versailles',
    ...
    '+339769': 'Reunion',
  };
}

module.exports = FranceCountry;
const GenericCountry = require('./generic.js');

class ItalyCountry extends GenericCountry {
  name = 'Italy';
  type = 'city';
  codes = {
    '+3910': 'Genova',
    '+3911': 'Torino',
    ...
    '+3999': 'Taranto',
  };
}

module.exports = ItalyCountry;
/**
 *  Insight Card model
 *
 *  Check API documentation
 *  https://developer.aircall.io/api-references/#insight-cards
 */
const Env = require('./../modules/env'),
  Logger = require('./../modules/logger'),
  Requester = require('./../modules/api_requester');

/**
 *  InsightCard JS class
 */
class InsightCardClass {
  contents = [];

  /**
   *  Build the JSON `contents` object that has to be sent in Insight Cards
   */
  constructor(locationData, weatherData) {
    this.contents = [];

    // City/Region
    if (!!locationData && locationData.city) {
      this.contents.push({
        type: 'shortText',
        label: 'City',
        text: locationData.city,
        link: `https://en.wikipedia.org/w/index.php?search=${locationData.city}`,
      });
    }

    // Weather Data
    if (!!weatherData) {
      if (!!weatherData.temps) {
        this.contents.push({
          type: 'shortText',
          label: 'Temperature',
          text: weatherData.temps,
        });
      }
      if (!!weatherData.description) {
        this.contents.push({
          type: 'shortText',
          label: weatherData.description || 'Description',
          text: weatherData.emoji,
        });
      }
    }

    if (this.contents.length > 0) {
      this.contents.push({
        type: 'title',
        text: 'Weather',
      });
    }
  }

  /**
   *  Send an Insight Card request to Aircall asynchronously
   */
  send(callId, apiToken) {
    if (!this.contents.length) {
      return;
    }

    const path = `/v1/calls/${callId}/insight_cards`;
    const headers = { Authorization: `Bearer ${apiToken}` };
    Logger.info(
      `[InsightCard][send] Sending insight card for call ${callId}`,
      this.contents
    );
    try {
      Requester.POST(path, headers, { contents: this.contents });
    } catch (e) {
      Logger.error(e.message);
    }
  }
}

module.exports = InsightCardClass;
/**
 *  Main file, setup the app server
 *  Entry point of the Weather app
 */

const express = require('express'),
  bodyParser = require('body-parser'),
  Logger = require('./modules/logger'),
  path = require('path'),
  router = require('./routes');

const app = express();

app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json());

// Define public assets folder
app.use(express.static(path.join(__dirname, 'public')));

const PORT = process.env.PORT || 5000;
app.listen(PORT, () => {
  Logger.info('[server] server started');
  Logger.info('[server] visit http://localhost:' + PORT);
});

// Init routes module
router.init(app);
<!DOCTYPE html>
<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
    <title>Aircall Weather | Install</title>
    <link rel="stylesheet" href="/style.css" />
    <link rel="icon" type="image/png" sizes="96x96" href="/favicon-96x96.png">
    <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
    <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
  </head>
  <body>
    <div class="content">
      <div class="content-block">
        <header>
          <img src="/logo.svg" class="logo" />
          <div>
            <h1>Weather</h1>
            <small>by aircall</small>
          </div>
          <div class="header-success">
            Installed!
          </div>
        </header>

        <img src="/phone.png" class="phone" />

        <p>
          The <strong>Weather app</strong> is now installed on your Aircall phone!
        </p>
        <p>
          It will fetch weather information from Australia, Belgium, Brazil,
          Canada, France, Germany, Great Britain, Ireland, Itlay, Mexico, Morocco, Netherlands,
          Spain, Sweden, Switzerland and USA.
        </p>

        <p>
          <strong>Next step:</strong> configure the Numbers you want your integration to be linked on in your Aircall Dashboard! Only agents with the associated numbers will see weather insights.
        </p>

        <button onclick="onClose()">
          Link your Numbers now
        </button>

        <p class="note">
          <strong>Note:</strong> the Weather app will do its best to match a
          city/region with each of your contacts' phone number. If a phone
          number can't be geo-located (like mobile, toll-free...), the Weather
          card won't be displayed during the call. Available countries are
          listed above and below!
        </p>

        <p class="countries">
          ๐Ÿ‡ฆ๐Ÿ‡บ ๐Ÿ‡ง๐Ÿ‡ช ๐Ÿ‡ง๐Ÿ‡ท ๐Ÿ‡จ๐Ÿ‡ฆ ๐Ÿ‡ซ๐Ÿ‡ท ๐Ÿ‡ฉ๐Ÿ‡ช ๐Ÿ‡ฌ๐Ÿ‡ง ๐Ÿ‡ฎ๐Ÿ‡ช ๐Ÿ‡ฎ๐Ÿ‡น ๐Ÿ‡ฒ๐Ÿ‡ฝ ๐Ÿ‡ฒ๐Ÿ‡ฆ ๐Ÿ‡ณ๐Ÿ‡ฑ ๐Ÿ‡ช๐Ÿ‡ธ ๐Ÿ‡ธ๐Ÿ‡ช ๐Ÿ‡จ๐Ÿ‡ญ ๐Ÿ‡บ๐Ÿ‡ธ
        </p>
      </div>
    </div>

    <script type="text/javascript">
      const onClose = () => {
        !!window.close && window.close();
        window.location.href = '/oauth/success';
      };
    </script>
  </body>
</html>
html,
body,
div,
span,
h1,
h2,
h3,
h4,
h5,
h6,
p,
blockquote,
pre,
a,
code,
img,
strong,
button,
ul,
li {
  margin: 0;
  padding: 0;
  border: 0;
  font-size: 100%;
  font: inherit;
  vertical-align: baseline;
}

body {
  margin: 0;
  padding: 0;
  font-family: 'Fellix', -apple-system, Helvetica Neue, Arial, sans-serif;
  font-size: 14px;
  line-height: 1;
}

html,
body {
  height: 100%;
  color: #666d73;
}

::-moz-selection {
  background: rgba(48, 113, 201, 0.1);
}
::selection {
  background: rgba(48, 113, 201, 0.1);
}

.content {
  display: flex;
  flex-direction: column;
  height: 100%;
  align-items: center;
}

.content-block {
  display: flex;
  flex-direction: column;
  justify-content: center;
  margin-top: 10px;
  background: white;
  border-radius: 4px;
  width: 80%;
  max-width: 500px;
}

h1 {
  font-size: 28px;
  font-weight: 600;
  color: #111;
}

header {
  display: flex;
  align-items: center;
  margin-bottom: 10px;
  user-select: none;
}
header div {
  display: flex;
  flex-direction: column;
  justify-content: center;
  flex: 1;
}
header img.logo {
  width: 40px;
  margin-right: 20px;
}
header small {
  margin-left: 0.4em;
  font-size: 0.7em;
}
header .header-success {
  text-align: right;
  font-size: 1.2em;
  font-weight: 600;
  color: #307fe2;
}
p {
  text-align: justify;
  margin-top: 10px;
  line-height: 1.4;
}
p.center {
  text-align: center;
}
p.note {
  font-size: 0.8em;
  color: #aaa;
}
p.countries {
  text-align: center;
  opacity: 0.8;
}
strong {
  font-weight: 700;
}

img.phone {
  min-height: 100px;
  width: 130px;
  margin: 0 auto;
  user-select: none;
}

button {
  display: block;
  background-color: #307fe2;
  margin: 20px auto;
  padding: 20px;
  border-radius: 4px;
  font-weight: 600;
  color: white;
  text-decoration: none;
  transition: background-color 0.1s ease-in-out;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
  user-select: none;
  cursor: pointer;
}

button:hover {
  background-color: #3071c9;
}

@font-face {
  font-family: 'Fellix';
  src: url('https://cdn.aircall.io/fonts/Fellix-Thin.otf');
  font-weight: 100;
  font-style: normal;
}
@font-face {
  font-family: 'Fellix';
  src: url('https://cdn.aircall.io/fonts/Fellix-Light.otf');
  font-weight: 300;
  font-style: normal;
}
@font-face {
  font-family: 'Fellix';
  src: url('https://cdn.aircall.io/fonts/Fellix-Regular.otf');
  font-weight: 500;
  font-style: normal;
}
@font-face {
  font-family: 'Fellix';
  src: url('https://cdn.aircall.io/fonts/Fellix-Medium.otf');
  font-weight: 600;
  font-style: normal;
}
@font-face {
  font-family: 'Fellix';
  src: url('https://cdn.aircall.io/fonts/Fellix-SemiBold.otf');
  font-weight: 700;
  font-style: normal;
}
@font-face {
  font-family: 'Fellix';
  src: url('https://cdn.aircall.io/fonts/Fellix-Bold.otf');
  font-weight: 900;
  font-style: normal;
}
<!DOCTYPE html>
<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
    <title>Aircall Weather | Install</title>
    <link rel="stylesheet" href="/style.css" />
    <link rel="icon" type="image/png" sizes="96x96" href="/favicon-96x96.png">
    <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
    <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
  </head>
  <body>
    <div class="content">
      <div class="content-block">
        <header>
          <img src="/logo.svg" class="logo" />
          <div>
            <h1>Weather</h1>
            <small>by aircall</small>
          </div>
        </header>

        <p class="center">Uh oh! Something went wrong...</p>

        <p class="center">
          Please try to install this app again in a few minutes.
        </p>
      </div>
    </div>
  </body>
</html>
/**
 *  Main router file
 *  Declare each endpoint and redirect requests to the right controller
 */

const mainCtrl = require('./controllers/main_controller'),
  oauthCtrl = require('./controllers/oauth_controller'),
  webhooksCtrl = require('./controllers/webhooks_controller'),
  Logger = require('./modules/logger');

const _init = (app) => {
  if (!app) {
    throw '[Router] app is not defined';
  }

  // Log each request
  app.use(logRequest);

  /**
   *  [GET] /
   *  Render the main page
   */
  app.get('/', mainCtrl.index);

  /**
   *  [GET] /health
   *  Healthcheck endpoint
   */
  app.get('/health', mainCtrl.health);

  /**
   *  [GET] /oauth/install
   *  Install page, redirects to Aircall authorize URL
   */
  app.get('/oauth/install', oauthCtrl.install);

  /**
   *  [GET] /oauth/callback
   *  Called by Aircall once the admin has authorized the application scope
   */
  app.get('/oauth/callback', oauthCtrl.callback);

  /**
   *  [GET] /oauth/success
   *  Success page, redirect to Aircall' success
   */
  app.get('/oauth/success', oauthCtrl.success);

  /**
   *  [POST] /webhooks
   *  Fetch weather information and post an Insight Card
   *  Called by Aircall each time a webhook event is posted
   */
  app.post('/webhooks', webhooksCtrl.index);

  /**
   *  [GET/POST/PUT] Handle 404
   */
  app.get('*', render404);
  app.post('*', render404);
  app.put('*', render404);
};

/**
 *  Render a HTTP 404 code with error message
 */
const render404 = (req, res) => {
  Logger.warn(`[routes][${req.method}][${req.url}] - 404 not found`);
  res.status(404).json({
    error: 'route not found',
  });
};

/**
 *  Log request's info
 */
const logRequest = (req, res, next) => {
  // Remove sensitive information from the URL before logging it
  const cleanedURL = !!req.url && req.url.split('?')[0];

  // Avoid logging each `[GET] /health` requests
  if (cleanedURL !== '/health') {
    Logger.info(`[Router][${req.method}]` + cleanedURL, {
      http_method: req.method,
      url: cleanedURL,
    });
  }

  next();
};

module.exports = {
  init: _init,
};
/**
 *  Main Controller
 */
const path = require('path'),
  Logger = require('./../modules/logger');

/**
 *  [GET] /
 *  Render main page
 */
const _index = (req, res) => {
  res.json({
    error: null,
    data: {
      message: 'Hello! ๐ŸŒค',
    },
  });
};

/**
 *  [GET] /health
 *  Render an empty 200 page
 *  Used by cron to check if server is up
 */
const _health = (req, res) => {
  res.status(200).json({ status: 'available' });
};

module.exports = {
  index: _index,
  health: _health,
};
/**
 *  Webhooks Controller
 *
 *  Read this tutorial for more information:
 *  https://developer.aircall.io/tutorials/send-insights-to-agents/
 */
const Logger = require('./../modules/logger'),
  OpenWeather = require('./../libs/openweathermap'),
  Company = require('./../models/company'),
  InsightCard = require('./../models/insight_card'),
  Region = require('./../models/region');

const WEBHOOK_EVENTS_HANDLED = ['call.created'];

/**
 *  [POST] /webhooks
 *  Endpoint request by Aircall each time a Webhook event is fired
 */
const _index = (req, res) => {
  // Avoid Aircall's webhook deactivation by setting HTTP Code to 202 Accepted
  res.status(202);

  if (!req.body || !req.body.event) {
    res.json({ error: 'Body or event fields not defined' });
    return;
  }

  // Only handle specifc Aircall events
  if (!!WEBHOOK_EVENTS_HANDLED.includes(req.body.event)) {
    if (!!req.body.data && !!req.body.data.id) {
      // Asynchronously triggers the flow
      Logger.info(
        `[WebhooksCtrl][_index] Webhook for call ${req.body.data.id} received.`
      );
      startFlow(req.body);
      res.json({
        message: `Processing request for callId ${req.body.data.id}...`,
      });
    } else {
      res.json({
        error: 'data or data.id not defined',
      });
    }
  } else {
    res.json({
      error: `'${req.body.event}' event not handled`,
    });
  }
};

/*********************************************************/
/*                   PRIVATE FUNCTIONS                   */
/*********************************************************/

/**
 *  Handles webhook payload logic:
 *  1. Authenticate request
 *  2. Extract location information from number
 *  3. Get weather
 *  4. Send Insight Card
 */
const startFlow = async (webhookPayload) => {
  const callData = !!webhookPayload && webhookPayload.data;
  if (!callData) {
    Logger.warn('[WebhooksCtrl][startFlow] callData undefined');
    return;
  }

  try {
    // 1. Authenticate request
    let companyData = await Company.getFromWebhookToken(webhookPayload.token);
    if (!companyData) {
      return;
    }

    // 2. Extract location information from number
    let region = new Region(callData.raw_digits);
    let location = region.get();

    if (!location) {
      return;
    }

    // 3. Get weather
    let weatherData = await OpenWeather.getCityWeather(
      location.city,
      location.country
    );

    if (!weatherData) {
      return;
    }

    // 4. Build & send Insight Card
    let card = new InsightCard(location, weatherData);
    card.send(callData.id, companyData.ApiToken);
  } catch (e) {
    Logger.error(e.message);
  }
};

module.exports = {
  index: _index,
};
/**
 *  OAuth Controller
 *
 *  Read this tutorial for more information:
 *  https://developer.aircall.io/tutorials/how-aircall-oauth-flow-works/
 */
const path = require('path'),
  Logger = require('./../modules/logger'),
  Env = require('./../modules/env'),
  Requester = require('./../modules/api_requester'),
  Company = require('./../models/company');

/**
 *  [GET] /oauth/install
 *  Redirect request to Aircall's consent page
 *  Second step of Aircall OAuth flow: https://developer.aircall.io/api-references/#oauth-flow
 */
const _install = (req, res) => {
  const weatherRedirectUri = `${Env.fetch('WEATHER_URL')}/oauth/callback`;
  const oauthUrl = Env.fetch('AIRCALL_OAUTH_AUTHORIZE_URL');
  const oauthId = Env.fetch('AIRCALL_OAUTH_ID');

  // Define Aircall's consent page url
  const url = `${oauthUrl}?client_id=${oauthId}&redirect_uri=${weatherRedirectUri}&response_type=code&scope=public_api`;

  res.redirect(url);
};

/**
 *  [GET] /oauth/callback
 *  Requested by Aircall once the user has authorized the application
 *  Aircall must send a `code` query params
 *  Fifth step of Aircall OAuth flow: https://developer.aircall.io/api-references/#oauth-flow
 */
const _callback = async (req, res) => {
  const errorPath = path.join(__dirname, '..', 'public', 'error.html');
  if (!req.query) {
    Logger.error('[OauthCtrl][_callback] No query params provided');
    res.sendFile(errorPath);
    return;
  }
  if (!!req.query.error) {
    Logger.error(
      '[OauthCtrl][_callback] Error:' + JSON.stringify(req.query.error)
    );
    res.sendFile(errorPath);
    return;
  }
  if (!req.query.code) {
    Logger.error('[OauthCtrl][_callback] No authorization code provided.');
    res.sendFile(errorPath);
    return;
  }

  try {
    // Create and save a Company from `code` sent by Aircall
    await Company.createFromOauth(req.query.code);
    const filePath = path.join(__dirname, '..', 'public', 'install.html');
    res.sendFile(filePath);
  } catch (e) {
    Logger.error(e.message);
    res.sendFile(errorPath);
  }
};

/**
 *  [GET] /oauth/success
 */
const _success = (req, res) => {
  const oauthSuccessUrl = Env.fetch('AIRCALL_OAUTH_SUCCESS_URL');
  res.redirect(oauthSuccessUrl);
};

module.exports = {
  install: _install,
  callback: _callback,
  success: _success,
};
# ๐ŸŒค Aircall Weather

An **Aircall Creative Lab** app, sending weather information to Aircall users during calls.

It is written in [**NodeJS**](https://nodejs.org/en/) (v12.0.0),
stores data in a [**DynamoDB**](https://aws.amazon.com/dynamodb/) database,
and runs in a [**Docker**](https://www.docker.com/) container.

## Development

### ๐Ÿ“ ENV variables

Make sure all ENV variables are defined in your `.env` file.
A template is available:

```bash
cp .env_template .env
```

You will need:

- OAuth credentials `AIRCALL_OAUTH_ID` and `AIRCALL_OAUTH_SECRET`. Please reach out to marketplace@aircall.io and we'll give you some!
- `OPEN_WEATHER_API_KEY`: get your free API key [here](https://openweathermap.org/price).

### ๐Ÿณ Install docker

This app is Docker compatible, both for local development and production environment.
Install the latest version of Docker CE for your OS:

- [macOS](https://docs.docker.com/docker-for-mac/install/)
- [Windows](https://docs.docker.com/docker-for-windows/install/)
- [Linux Ubuntu](https://docs.docker.com/install/linux/docker-ce/ubuntu/)

To have the full setup of Maestro with all the dependencies, use `docker-compose`.
Docker provides instructions [here](https://docs.docker.com/compose/install/).

### โš™๏ธ DynamoDB

This project requires you to have [DynamoDB](https://aws.amazon.com/dynamodb/) installed locally.
AWS provides instructions [here](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/DynamoDBLocal.html).

Once you downloaded this Docker's image [dynamodb-local](https://hub.docker.com/r/amazon/dynamodb-local)
and you launched it, you will need to locally create the `Company` table:

```bash
node -r dotenv/config scripts/createDBTables.js
```

### ๐Ÿš€ Run the app

```bash
docker-compose up --build
```

## Test

We use [Jest](https://jestjs.io/) as a testing framework. Launch the following command to run tests:

```bash
npm test
```

## Production

Aircall uses Docker in Production.

First, you'll have to build the Docker image:

```bash
docker build -t aircall-weather .
```

Once done, you can then run it with the following command:

```bash
docker run -p 5000:5000 -d aircall-weather
```

In production, the `DEBUG` environment variable must be set to `false`.

## MISC

### Phone number to region matching

This database was manually built out of the [ITU website](https://www.itu.int/oth/T0202.aspx?lang=en&parent=T0202),
representing each available country in a JavaScript class, located in the `./src/models/region/` directory.
---
version: '3.7'

services:
  api:
    build:
      context: .
      labels:
        app_version: "${CIRCLE_SHA1:-local}"
    image: weather
    env_file: .env
    tty: true
    stdin_open: true
    ports:
      - "5000:5000"
    depends_on: [dynamodb]
    command: |
      npm start

  dynamodb:
    image: amazon/dynamodb-local
    hostname: dynamodb-local
    container_name: dynamodb-local
    command: ["-jar", "DynamoDBLocal.jar", "-sharedDb", "-inMemory"]
    ports:
      - "8000:8000"
---
version: '3.7'

services:
  api:
    build:
      context: .
      labels:
        app_version: "${CIRCLE_SHA1}"
      target: test
    image: weather
    command: |
      npm test