Wake-Up Call Service Part 2: Managing Users with MongoDB

September 01, 2023
Written by
Reviewed by

In the first part of this tutorial series, you learned how to emulate a wake-up call alarm using Twilio Programmable Voice and Cron job. You were able to create a wake-up call alarm for a specified number at a specified time and implement snooze and stop functionalities.

In this tutorial, you’ll learn how to leverage the power of MongoDB to handle alarms linked to multiple users. Additionally, you’ll discover how to make your call service persistent in the event of a server restart or crash. Let’s get started!

Throughout this tutorial, you’ll be editing the completed code from the first part of this tutorial so I strongly recommend that you finish the first part before jumping into this one..

However, if you’d like to jump into this tutorial without completing the first part, you can clone the completed code from the first part here: Wake-Up Call Service Part 1 GitHub Repository. Don’t forget to replace the environment variables placeholders with their actual values.

The completed source code for this tutorial (part 2) can be found in this repository: How to Make a Wake-Up Call Service Part 2: Managing Users with MongoDB.

Overview

Before you dive in, let's look at the two additional updates you’ll be implementing in the existing wake-up call service.

An alarms database

You’ll need a database to store all of your alarms. Yes, you can store them locally in your code, however this is not good practice. It will eat up memory quickly and can be easily wiped out after a server crash or restart.

Instead, in this tutorial, you’ll learn how to set up a MongoDB database and integrate it with the call service. You’ll also learn how to create an alarms model and add, delete, and find alarms in the MongoDB database.

Persistent Cron alarms

One thing we can’t store and have active in the database are the cron jobs. Once the server restarts or crashes, all of the alarm cron jobs that are running will be wiped. To combat this, you’ll create a start-up function that creates the cron jobs again by iterating through all of the alarms on the database. This will run every time your Node.js  application boots up, which will always occur after restarts and crashes.

Prerequisites

MongoDB Integration

Before you can create a database, you’ll need to have an organization and a project on MongoDB. If you don’t have them, follow the steps shown on the MongoDB Docs:

Setup Database

Navigate to your projects dashboard and you’ll be on the Database Deployments menu.

Database deployments section of MongoDB

Click on the Build a Database button, where you’ll be prompted to configure your database. Configure your database to your liking; I selected the M0 tier (since it's free) and left the other settings with their defaults.

Deployment configuration section on MongoDB

Click the Create button, where the next step will be to set up security controls.

Security Quickstart section in database configuration

Enter a Username and Password (I recommend selecting Autogenerate Secure Password). Then, store these values in a safe place, since you’ll use them later in the tutorial.

Scroll further down to the Add entries to your IP Access List and add 0.0.0.0/0 as an IP address. This allows access to your database from any server whether you're hosting on your own local environment or a cloud server.

Adding 0.0.0.0/0 in your IP access list allows access from anywhere. Ensure that strong credentials (username and password) are used when allowing access from anywhere.

Finally, click Finish and Close at the bottom and your database will be ready to use!

On your Database Deployments dashboard where you’ll see the new database cluster that you just created.

New database cluster showing on MongoDB

Connect MongoDB to your Node.js service

The last thing you’ll need to do in your MongoDB Console is to grab your cluster's connection string, so you can connect to it from your Node.js application.

Next to your cluster name, click on Connect, then Drivers, and you’ll see your connection string on step 3.

Database connection menu

Copy the string and navigate to the .env file in your project directory. Paste in the following and replace the placeholder value with the connection string you just copied.

DATABASE_CONNECTION_STRING=XXXXX

Now, replace the <username> and <password> placeholders with the username and password you created earlier.

If you skipped the first part of this tutorial, don’t forget to replace the other XXXXX placeholders in the .env file with their respective values.

To connect your Node.js service to MongoDB, you’ll be using the Mongoose package which is a MongoDB object modeling tool for Node.js. This package also acts as a wrapper to the MongoDB API and simplifies how you can interact with your MongoDB database.

To install Mongoose, navigate to your project directory in your shell/terminal and execute the following command.

npm install mongoose

Now, navigate to your index.js file and add in the following lines at the top of the file where you’ve initialized all the other packages.

require('dotenv').config();
const mongoose = require('mongoose');
mongoose.connect(process.env.DATABASE_CONNECTION_STRING);

This will have Mongoose connect to your database using the connection string you provided in the .env file.

Create the alarms model

Using Mongoose, you’ll model a schema for your alarms collection in MongoDB. This schema will define the necessary attributes you’ll need for an alarm schema.

To create a schema for an alarm, create a folder named models within your project directory, then create a file within the new folder named alarm.js. The models folder can be used to store more models if you plan on creating multiple collections and models; a good example for this service can be users, which can store your users and their metadata.

Open up the alarm.js file you just created and add the following code to import the Mongoose library and Schema class from the library.

const mongoose = require('mongoose');
const { Schema, model } = mongoose;

Now, add the following object to define the Alarm schema:

const AlarmSchema = new Schema({
    phoneNumber: {type: String, required: true},
    currentState: {type: String, required: true},
    recurring: {type: Boolean, default: false},
    alarmTime: {type: Schema.Types.Mixed},
    createdAt: { type: Date, required: true, default: Date.now },
})

Let’s take a look at the attributes for the alarms schema:

  • phoneNumber will hold the phone number that the alarm is linked to. This will be stored as a string in E.164 format.
  • currentState will hold the state at which the alarm is at. This can either be on, off, or ready. When the currentState is on, the alarm will be in a trigger state, where the call cycle is ongoing. The off state means the alarm is off and the ready state means the alarm is set and ready to be turned on at the specified time.
  • recurring states whether the alarm state will reset to ready after it has been turned off.
  • alarmTime will hold a custom object defined by a recurrence rule from the Node Schedule package. This object will hold the hour, minute and tz (timezone) variables, which defines at what time the alarm will be triggered.
  • createdAt will hold a UTC date of when the alarm was created. Although you won’t use this attribute, it can be useful for debugging or future updates to your service.

Lastly, add the following code to create the model and export it so you can use it in your alarm functions.

const Alarm = model('Alarm', AlarmSchema);
module.exports = Alarm;

Update alarm functions to interact with the database

In this section, you’ll update all of the functions in services/alarm.js so that they’re compatible with multiple alarms rather than just one.

Within services/alarm.js, replace all of the code above module.exports with the following.

require('dotenv').config();
const schedule = require('node-schedule');
const twilio = require('twilio')(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN);
const Alarm = require('../models/alarm')

// default alarm settings
const maxAttempts = 5;
const snoozeInterval = 540000 // 9 minutes in milliseconds

The changes made here are that the Alarm model from the previous section is imported and some of the default alarm settings have been removed. The default alarm settings that were removed have been added as Alarm attributes since they are unique to a particular alarm.

Within module.exports, replace the createAlarm function with this updated version:

    createAlarm: async function(phoneNumber, alarmTime, recurring = true) {
        // Delete any existing alarms linked to the number
        await Alarm.deleteMany({phoneNumber: phoneNumber})

        // Store alarm in database
        const newAlarm = new Alarm({
            phoneNumber: phoneNumber,
            currentState: 'ready',
            recurring: recurring,
            alarmTime: alarmTime,
        })
        await newAlarm.save();

        // Create the cron job for the alarm
        createAlarmJob(newAlarm)
    },

This function now creates a new alarm model for the user and saves it to the database. This updated function also removes the cron job creation, and instead uses the new createAlarmJob function, which you’ll create later.

Replace the stopAlarm function with the following.

    stopAlarm: async function(phoneNumber) {
        const alarm = await Alarm.findOne({phoneNumber: phoneNumber});
        if(alarm.recurring){
            alarm.currentState = 'ready'
            alarm.save();
        }
        else {
            alarm.currentState = 'off'
            alarm.save();
            // Cancels the cron job so the alarm does not trigger for the next morning
            if(schedule.scheduledJobs[phoneNumber]){
                schedule.scheduledJobs[phoneNumber].cancel()
            }
        }
    },

This function will fetch the alarm from the database, change the currentState attribute to its default state, and then save the alarm back to the database.

If the alarm is not recurring, the function will use the schedule.scheduledJobs object provided by the node schedule package to find and cancel the cron job linked to the phoneNumber.

When creating jobs with the node schedule package, you can include a key so that you can fetch a specific job using the schedule.scheduledJobs object; you’ll be using the phone number that the alarm is linked to as the unique key for alarm jobs.

Outside module.exports, replace the executeCallCycle function with the following.

async function executeCallCycle(phoneNumber, attempt = 0) {
    const alarm = await Alarm.findOne({phoneNumber: phoneNumber});
    // If max attempts reached, stop the alarm
    if (attempt == maxAttempts) {
        return module.exports.stopAlarm(alarm.phoneNumber)
    }
    // If alarm has already been turned off, break the call cycle
    else if(alarm.currentState != 'on') return;

    // Call alarm phone number and route to /voiceResponse to detect user input
    await twilio.calls.create({
        url: process.env.SERVER_URL + '/voiceResponse',
        to: alarm.phoneNumber,
        from: process.env.TWILIO_PHONE_NUMBER,
    })

    // Wait for 5 minutes (or whatever snoozeInterval is set at) and then execute next call cycle
    setTimeout(function () {
        executeCallCycle(alarm.phoneNumber, attempt + 1);
    }, snoozeInterval);
}

This function will now fetch the updated alarm from the database after every call cycle, to check whether it has been stopped by the user.

The only new function you’ll create is the createAlarmJob function which takes in an alarm schema and creates a cron job for it.

Add the function to the bottom of the file.

async function createAlarmJob(alarm) {
    // Create alarm job which will run at the specified time
    schedule.scheduleJob(alarm.phoneNumber, alarm.alarmTime, async function () {
        // Find updated alarm in database and start call cycle if its ready
        alarm = await Alarm.findOne({phoneNumber: alarm.phoneNumber});
        if (alarm.currentState != 'ready') return;
        alarm.currentState = 'on';
        await alarm.save();
        executeCallCycle(alarm.phoneNumber);
    });
}

The first argument for the schedule.scheduleJob function will be the key identifier for the alarm job that we mentioned previously. The second is the recurrence rule, which will define when the alarm should go off. The third argument is a function that will execute once the cron job gets triggered.

The last thing you need to update is the call to the stopAlarm function in the /stopAlarm route. The stopAlarm function now takes in a phone number, so the service knows which alarm to stop.

Replace the stopAlarm function call and update the route with the highlighted lines.


app.post('/stopAlarm', async (req, res) => {
    if(req.body?.Digits) {
        // Stop the alarm
        const phoneNumber = req.body['To'];
        stopAlarm(phoneNumber);

        // Hang up call
        const twiml = new VoiceResponse();
        twiml.say("Stopping Alarm. Goodbye.")
        twiml.hangup()
        res.type('text/xml');
        res.send(twiml.toString());
    }
})

Now that you’ve updated all of the functions to handle multiple alarms using MongoDB, the next and last step is to make your cron alarm jobs persistent in the event of a crash or server restart.

Make cron jobs persistent

To make your cron jobs persistent, you need to:

  • Fetch all alarms in the database that are set to ’ready’ and ‘on’
  • Execute the createAlarmJob function for alarms that are ready
  • Run the executeCallCycle for alarms that were ongoing and triggered at the time of the server restart or crash.

Head back to the services/alarm.js file and add the following function within module.exports.


// Creates cron jobs for alarms that are 'ready' and restarts call cycle for alarms that are ongoing
    syncCronJobs: async function()  {
        const readyAlarms = await Alarm.find({currentState: 'ready'});
        const ongoingAlarms = await Alarm.find({currentState: 'on'});

        for(const alarm of readyAlarms) {
            createAlarmJob(alarm);
        }
        for(const alarm of ongoingAlarms) {
            executeCallCycle(alarm.phoneNumber);
        }
    },

Since the index.js file is the starting point of the Node.js application, it will always run once the server starts up. Because of this, we can import the syncCronJobs function to the file and call it from there since it will execute anytime after a server crash or restart.

Head over to the index.js file and replace the line where you imported the alarm functions from services/alarm.js with the following code.

const {createAlarm, stopAlarm, syncCronJobs} = require('./services/alarm')

// Turn on alarm jobs from the database
syncCronJobs();

This updates the import statement to include the syncCronJobs function and immediately calls the function to turn on all of the alarm jobs that are set to ready from the database.

Conclusion

With the power of MongoDB, your wake-up call service is now capable of handling multiple users and will stay persistent in the event of a server crash or restart! Feel free to test it out with multiple numbers by adding the following code snippet to the bottom of index.js.

const alarmTime = new schedule.RecurrenceRule();
alarmTime.hour = 14;
alarmTime.minute = 25;
alarmTime.tz = 'America/New_York';

createAlarm('<YOUR_NUMBER>’, alarmTime)

Replace the alarmTime.hour, alarmTime.minute, and alarmTime.tz values accordingly. Here’s a list of the acceptable tz (timezone) values for alarmTime.tz.

Before running node index in your terminal, ensure you’ve replaced all the placeholder environment variables in the .env file with their actual values. For SERVER_URL, you can run a ngrok tunnel to port 3000 and place the forwarding url as the value (refer to the testing section in part 1 if you need help).

The code for this part of the tutorial can be found in this Github repository.

Stay tuned for part 3 where you’ll learn how to use Twilio Programmable Messaging and OpenAI’s function calling to create and set alarms through natural language!

Dhruv Patel is a Developer on Twilio’s Developer Voices team. You can find Dhruv working in a coffee shop with a glass of cold brew. Alternatively, he can be reached at dhrpatel [at] twilio.com or LinkedIn.