DEV Community

loading...
Cover image for Build an On-Call Application With React Native and Symfony
Vonage

Build an On-Call Application With React Native and Symfony

gregholmes profile image Greg Holmes Originally published at learn.vonage.com on ・23 min read

Are you a developer? Have you ever been on call and had to install one of those pesky apps that notify you whenever something is a bit off? The threshold for errors has exceeded, or the server is taking too long to give responses, for example? If so, have you ever thought, "I'd like to build myself one of those services?" Well, with this tutorial, you're about to start the basics of building one of these applications and using Vonage to perform the communications.

This tutorial will help you build the beginning of an API in PHP using Symfony and the mobile application using React Native.

The complete code for this tutorial can be found at our: Community Repository. Be sure to checkout to the end-tutorial branch.

Prerequisites

To complete this tutorial you will need the following:

Vonage API Account

To complete this tutorial, you will need a Vonage API account. If you don’t have one already, you can sign up today and start building with free credit. Once you have an account, you can find your API Key and API Secret at the top of the Vonage API Dashboard.

This tutorial also uses a virtual phone number. To purchase one, go to Numbers > Buy Numbers and search for one that meets your needs.

Build with Vonage now

Clone the Repository

git clone https://github.com/nexmo-community/on-call-application-api
cd on-call-application-api
Enter fullscreen mode Exit fullscreen mode

Building The API

Generate JWT Keypair

This project will be using a mobile app built in React Native.

You'll need to authenticate the user between the mobile application and the API. This project uses JWT to handle authentication, so certificates need to be generated to make the JWT tokens.

In the root of your project, run the following three commands:

mkdir -p API/var/jwt # Creates a directory to store your private and public key files.
openssl genpkey -out API/var/jwt/private.pem -aes256 -algorithm rsa -pkeyopt rsa_keygen_bits:4096 # Generates your private key file
openssl pkey -in API/var/jwt/private.pem -out API/var/jwt/public.pem -pubout # Generates the public key file
Enter fullscreen mode Exit fullscreen mode

Exposing Your Application to the Internet

Making a phone call with Vonage requires a virtual phone number. You'll also want to set up a webhook to log the events that happen whenever a phone call is made, answered, rejected, or ended.

For this tutorial, ngrok is the service of choice to expose the application to the Internet. Install ngrok, and run the following command in a new Terminal window:

ngrok http 8080 # Creates an http tunnel to the Internet from your computer on port 8080
Enter fullscreen mode Exit fullscreen mode

Make sure to copy your ngrok HTTPS URL, as you'll need this later when configuring the project.

Environment Variables

Inside the Docker directory is a file called .env.dist; copy or rename this file to .env.

The first fields to update are your database credentials. The example below shows the credentials I've used for this tutorial, but please use more secure ones.

DATABASE_URL=mysql://db_user:db_password@mysql:3306/on_call?serverVersion=8.0

MYSQL_DATABASE=on_call
MYSQL_USER=db_user
MYSQL_PASSWORD=db_password
MYSQL_ROOT_PASSWORD=root_password
Enter fullscreen mode Exit fullscreen mode

Update the values for both VONAGE_API_KEY= and VONAGE_API_SECRET=, which you can find inside the Vonage Developer Dashboard.

Then, in the dashboard, navigate to "Your Applications". Create a new application, making sure to download the private.key file to the project's root directory, and ensuring your application has voice capabilities.

You need to set the Event webhook URL when using the Voice API. Set this to the ngrok HTTPS URL you copied in the last section.

Update the following two:

VONAGE_APPLICATION_PRIVATE_KEY_PATH=/var/www/API/private.key
VONAGE_APPLICATION_ID=<Vonage Application ID>
Enter fullscreen mode Exit fullscreen mode

Next, link your previously purchased Vonage virtual number to your application. Then in your code, update the following inside your .env file inside Docker:

VONAGE_BRAND=OnCallAlerts
VONAGE_NUMBER=<Your Vonage Virtual Number>

JWT_PASSPHRASE=<Your JWT Passphrase>
Enter fullscreen mode Exit fullscreen mode

Finally, find ON_CALL_NUMBER= in the same file, and add your phone number to this value. It will need to be a real number and able to receive SMS messages and voice calls.

Start Docker

Run the following five commands—the comments to the right of each describe what they do:

cd Docker
docker-compose up -d # To start all Docker containers for this project
docker-compose exec php bash # To create a tunnel into your PHP container
composer install # Installing all third-party libraries used in this project
php bin/console doctrine:migrations:migrate # Creates the user table already defined in `/API/migrations`
Enter fullscreen mode Exit fullscreen mode

Time to Build the API!

Make Database Entities

There are three new database tables for this project. Alerts, OnCall, and a table to link Alerts and Users together, UserAlerts.

To start, run the command below and follow the instructions for input below:

php bin/console make:entity
Enter fullscreen mode Exit fullscreen mode

For each field, please add the following:

  • Class name: Alert
  • Property name: title (String, 255, Not null)
  • Property name: description (String, 255, Not null)
  • Property name: status (String, 255, Not null)

When the command is complete, open the new file: src/Entity/Alert.php

There are three other classes used within this new Entity file. Add these imports at the top of the file:

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Gedmo\Timestampable\Traits\TimestampableEntity;
Enter fullscreen mode Exit fullscreen mode

One of these new classes is the TimestampableEntity, which adds created_at and updated_at fields to the database. Add use TimestampableEntity; at the top of the class, as shown below:

class Alert
{
    use TimestampableEntity;
Enter fullscreen mode Exit fullscreen mode

We need to add some default values within the class, so create a new construct and default the values as shown below:

    public function __construct()
    {
        $this->status = 'raised';
        $this->createdAt = new \DateTime();
        $this->updatedAt = new \DateTime();
    }
Enter fullscreen mode Exit fullscreen mode

While we're in this class, add the two functions below.

The getUserAssigned() function determines which user is the current user responsible for the alert. The second function, toArray(), converts the values of the class into an array, ready for the API responses.

    public function getUserAssigned(): ?User
    {
        if ($this->getUserAlerts()->isEmpty()) {
            return null;
        }

        return $this
            ->getUserAlerts()
            ->first()
            ->getUser();
    }

    public function toArray()
    {
        return [
            'id' => $this->getId(),
            'title' => $this->getTitle(),
            'description' => $this->getDescription(),
            'status' => $this->getStatus(),
            'dateRaised' => $this->getCreatedAt()->format('Y-m-d H:i:s'),
            'assigned' => $this->getUserAssigned()->getName(),
            'incidentId' => $this->getId()
        ];
    }
Enter fullscreen mode Exit fullscreen mode

To make the OnCall entity, which we're using to store which person is on call each week, run the command below and follow the instructions for input as listed:

php bin/console make:entity
Enter fullscreen mode Exit fullscreen mode

For each field, please add the following:

  • Class name: OnCall
  • Property name: user (relation, User, ManyToOne, Not null, Add Property to User Yes)
  • Property name: startDate (datetime, Not null)
  • Property name: endDate (datetime, Not null)

When the command is complete, open the new file: src/Entity/OnCall.php

There is one other class used within this new Entity file. Add this import at the top of the file:

use Gedmo\Timestampable\Traits\TimestampableEntity;
Enter fullscreen mode Exit fullscreen mode

One of these new classes is the TimestampableEntity, which adds created_at and updated_at fields to the database, add use TimestampableEntity; at the top of the class as shown below:

class OnCall
{
    use TimestampableEntity;
Enter fullscreen mode Exit fullscreen mode

We need to add some default values within the class, so create a new construct and default the values as shown below:

    public function __construct()
    {
        $this->createdAt = new \DateTime();
        $this->updatedAt = new \DateTime();
    }
Enter fullscreen mode Exit fullscreen mode

To link your User and Alert entities together, you need to create a new Entity called UserAlert. Follow the instructions below:

php bin/console make:entity
Enter fullscreen mode Exit fullscreen mode
  • Class name: UserAlert
  • Property name: user (relation, User, ManyToOne, Not null, Add Property to User Yes)
  • Property name: alert (relation, Alert, ManyToOne, Not null, Add Property to Alert yes)
  • Property name: smsSentAt (datetime, null)
  • Property name: voiceSentAt (datetime, null)

When the command is complete, open the new file: src/Entity/UserAlert.php

There is one other class used within this new Entity file. Add this import at the top of the file:

use Gedmo\Timestampable\Traits\TimestampableEntity;
Enter fullscreen mode Exit fullscreen mode

One of these new classes is the TimestampableEntity, which adds created_at and updated_at fields to the database, add use TimestampableEntity; at the top of the class as shown below:

class UserAlert
{
    use TimestampableEntity;
Enter fullscreen mode Exit fullscreen mode

We need to add some default values within the class, so create a new construct and default the values as shown below:

    public function __construct()
    {
        $this->createdAt = new \DateTime();
        $this->updatedAt = new \DateTime();
    }
Enter fullscreen mode Exit fullscreen mode

Run the Migrations!

It's now time to make and run the migrations, creating new tables and columns in your database to reflect these newly created entities.

In your Terminal run:

php bin/console make:migration
php bin/console doctrine:migrations:migrate # If you wish to see what is being migrated, check the `API/migrations/` files for the SQL query
Enter fullscreen mode Exit fullscreen mode

Make DataFixtures

We need to make some predefined fixtures for the OnCall database table to determine who is on call at a certain time. To do so, run the following command and follow the instructions listed:

php bin/console make:fixture
Enter fullscreen mode Exit fullscreen mode

Entering the name OnCallFixtures will create a file inside API/src/DataFixtures called OnCallFixtures.php. Replace the contents of this file with the following:

<?php

namespace App\DataFixtures;

use App\Entity\OnCall;
use Carbon\CarbonImmutable;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Persistence\ObjectManager;

class OnCallFixtures extends Fixture implements DependentFixtureInterface
{
    public function load(ObjectManager $manager)
    {
        $currentWeek = CarbonImmutable::now();

        $onCall = new OnCall();
        $onCall
            ->setUser($this->getReference('user_1'))
            ->setStartDate($currentWeek->startOfWeek())
            ->setEndDate($currentWeek->endOfWeek());

        $manager->persist($onCall);

        $manager->flush();
    }

    public function getDependencies(): array
    {
        return [
            UserFixtures::class,
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

Let's run your fixtures so that we have a user and an on-call record! In your terminal run:

php bin/console doctrine:fixtures:load
Enter fullscreen mode Exit fullscreen mode

Make Form

When handling an API request for raising an alert, we need to validate the input to ensure it's what we expect. With Symfony, the easiest way to do this is by using a Form. With a Form, we can define what values we expect and any constraints on these values. Start by running the command below:

php bin/console make:form
Enter fullscreen mode Exit fullscreen mode

Follow the instructions, as shown in the image:

Creating an Alert Form Type

Now, open the newly created AlertType.php file found within src/Form/ and replace the contents of the file with:

<?php

namespace App\Form;

use App\Entity\Alert;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\Length;

class AlertType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('title', TextType::class, [
                'required' => true,
                'constraints' => [
                    new Length(['min' => 5]),
                    new NotBlank()
                ]
            ])
            ->add('description', TextType::class, [
                'required' => true,
                'constraints' => [
                    new Length(['min' => 5]),
                    new NotBlank()
                ]
            ])
        ;
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'data_class' => Alert::class,
            'csrf_protection' => false,
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

The new code you've added to the AlertType class adds further constraints and requirements on the two fields in this form, title and description, to ensure they have a minimum length and are not blank.

Build a Vonage Util

A Utility class is needed to handle Vonage API requests when sending SMS messages and making voice calls.

In API/src, create a new directory called Util, along with a new file within this new directory called VonageUtil.php

You've already stored your Vonage credentials in the .env file earlier in this tutorial, and you'll be making use of these in this new PHP class.

In the new file add the following code:

<?php

namespace App\Util;

use Vonage\Client;
use Vonage\SMS\Message\SMS;
use Vonage\Voice\Endpoint\Phone;
use Vonage\Voice\NCCO\NCCO;
use Vonage\Voice\NCCO\Action\Talk;
use Vonage\Voice\OutboundCall;

class VonageUtil
{
    /**
     * @var Client
     */
    protected $client;

    public function __construct(Client $client)
    {
        $this->client = $client;
    }
}
Enter fullscreen mode Exit fullscreen mode

Right now, this code initialises a new PHP Class and creates a new client for Vonage API, using the Vonage Symfony wrapper for the PHP SDK.

Next, within this class, you're going to want to add two new functions, which will handle making the request to the API to send an SMS or make a voice call. Add the following two:

    public function sendSms(string $to, string $from, string $text): bool
    {
        $response = $this->client->sms()->send(
            new SMS($to, $from, $text)
        );

        $message = $response->current();

        if ($message->getStatus() == 0) {
            return true;
        }

        return false;
    }

    public function makePhoneCall(string $to, string $from, string $text)
    {
        $outboundCall = new OutboundCall(
            new Phone($to),
            new Phone($from)
        );

        $ncco = new NCCO();
        $ncco->addAction(new Talk($text));
        $outboundCall->setNCCO($ncco);

        $this->client->voice()->createOutboundCall($outboundCall);
    }
Enter fullscreen mode Exit fullscreen mode

Build the Webhook Controller

Before making the controller, we're going to need a Repository function to pull specific data from the database. Open the OnCallRepository.php found within src/Repository. Inside the class below the __construct() function, add the new function findCurrentOnCall which will find the current user on call.

    public function findCurrentOnCall(\Carbon\Carbon $date)
    {
        return $this->createQueryBuilder('o')
            ->andWhere('o.startDate <= :date')
            ->andWhere('o.endDate >= :date')
            ->setParameter('date', $date->format('Y-m-d H:i:s'))
            ->getQuery()
            ->getOneOrNullResult();
    }
Enter fullscreen mode Exit fullscreen mode

We've created the functionality to pull the data. Next, let's create a controller to handle any requests and pull the data.

First, in your Terminal, run the following:

php bin/console make:controller
Enter fullscreen mode Exit fullscreen mode

When asked for the name of your controller, input WebhookController.

Open the newly created file: API/src/Controller/WebhookController.php.

We will be using all of the following classes, so let's make sure we include them from the beginning. At the top of the file, just below namespace App\Controller; add the following:

use App\Entity\Alert;
use App\Entity\OnCall;
use App\Entity\UserAlert;
use App\Form\AlertType;
use App\Util\VonageUtil;
use Carbon\Carbon;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\Form\Form;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
Enter fullscreen mode Exit fullscreen mode

Your class needs a construct for Symfony to inject the EntityManager and the VonageUtil classes. At the top of your class, add:

    /** @var VonageUtil */
    protected $vonageUtil;

    /** @var EntityManagerInterface */
    private $entityManager;

    public function __construct(
        VonageUtil $vonageUtil,
        EntityManagerInterface $entityManager
    ) {
        $this->vonageUtil = $vonageUtil;
        $this->entityManager = $entityManager;
    }
Enter fullscreen mode Exit fullscreen mode

Now replace the index() function with the code below to create new alerts. This new function handles the POST request body, creates this data as a new Alert, and passes that alert into the Form to validate the values. If all is as expected, it will then create a new UserAlert, with the person currently on call as the person receiving the alert.

    /**
     * @Route("/webhooks/raise_alert", name="raise_alert", methods={"POST"})
     */
    public function index(Request $request): JsonResponse
    {
        $data = json_decode($request->getContent(), true);

        // Create an alert.
        $alert = (new Alert())
            ->setStatus('raised');

        $form = $this->createForm(AlertType::class, $alert);
        $form->submit($data);

        if ($form->isSubmitted() && $form->isValid()) {
            $entityManager = $this->getDoctrine()->getManager();
            $entityManager->persist($alert);
            $entityManager->flush();

            // Get the on call user
            $onCall = $this->entityManager
                ->getRepository(OnCall::class)
                ->findCurrentOnCall(Carbon::now());

            if (!$onCall) {
                return new JsonResponse(['message' => 'No Alerts found.'], 400);
            }

            // Create a UserAlert
            $userAlert = (new UserAlert())
                ->setUser($onCall->getUser())
                ->setAlert($alert);
            $entityManager->persist($userAlert);

            // Notify the on call user
            $this->vonageUtil->sendSms(
                $onCall->getUser()->getPhoneNumber(),
                getenv('VONAGE_BRAND'),
                'A new alert has been raised, please log into the mobile app to investigate.'
            );

            // Save this update to the user alert
            $userAlert->setSmsSentAt(Carbon::now());

            $entityManager->flush();

            return new JsonResponse([], 201);
        }

        return new JsonResponse($this->getErrorMessages($form), 400);
    }
Enter fullscreen mode Exit fullscreen mode

You may have noticed that the function $this->getErrorMessages() is called at the bottom, but your class doesn't have it yet. You'll need to add this function next. It will retrieve all the form errors found when the endpoint is triggered, but some data is missing. Below your index() method, add the following:

    private function getErrorMessages(Form $form): array
    {
        $errors = [];

        foreach ($form->getErrors() as $key => $error) {
            if ($form->isRoot()) {
                $errors['#'][] = $error->getMessage();
            } else {
                $errors[] = $error->getMessage();
            }
        }

        foreach ($form->all() as $child) {
            if (!$child->isValid()) {
                $errors[$child->getName()] = $this->getErrorMessages($child);
            }
        }

        return $errors;
    }
Enter fullscreen mode Exit fullscreen mode

We're at a point where we can test this now!

Test the Authentication

There are two endpoints at this part of the tutorial that we can test with our API, so with Docker still running in the background, make a POST request to http://localhost:8080/api/login_check with the JSON body of:

{
    "username": "dev+1@company.com",
    "password": "test_pass"
}
Enter fullscreen mode Exit fullscreen mode

The response will be a JSON object with a key token, and the value is a JWT token.

The image below shows an example of doing this with Postman:

An example of authenticating with Postman

Test Raising an Alert

You don't need to be authenticated in this example to raise an alert, so no need to use the JWT from the previous example.

To raise an alert, update the URL field: http://localhost:8080/webhooks/raise_alert, keep the method as a POST request, and the JSON body of:

{
    "title": "ERRORRRRRR ASAP FIX NOW ITS BORKED",
    "description": "THE PAGE AINT LOADING TOP PRIORITY FIX ASAP."
}
Enter fullscreen mode Exit fullscreen mode

The response will be an empty array and the HTTP status code of 201 (created). You can see an example of this request in Postman in the image below:

An example of raising an alert with Postman

How to Handle an Alert

Symfony's Workflow component allows you to define a life cycle your object can go through with its statuses. Each step your object can go through is called a place, with the transitions defining the action the object needs to take to get from one place to another.

Workflows will allow you to define which places your alert can be in to get from being raised to the last step, which is either cancelled or completed.

Open the workflow.yaml file found within config/packages/ and replace the contents with the example below:

framework:
    workflows:
        alerts:
            type: 'state_machine'
            supports:
                - App\Entity\Alert
            marking_store:
                type: 'method'
                property: 'status'
            initial_marking: new
            places:
                - new
                - raised
                - accepted
                - cancelled
                - completed
            transitions:
                raise:
                    from: [new]
                    to: raised
                accept:
                    from: [raised]
                    to: accepted
                cancel:
                    from: [raised, accepted]
                    to: cancelled
                complete:
                    from: [accepted]
                    to: completed
Enter fullscreen mode Exit fullscreen mode

A controller is now needed to handle all of the API requests regarding Alerts. So, run the command below to start making our new AlertsApiController:

php bin/console make:controller
Enter fullscreen mode Exit fullscreen mode

When it asks for a Controller name, submit AlertsApiController. This command will create a new AlertsApiController.php file within src/Controllers. So open this new file.

We will be using all of the following classes, so let's make sure we include them from the beginning. At the top of the file, just below namespace App\Controller; add the following:

use App\Entity\Alert;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Workflow\Registry;
Enter fullscreen mode Exit fullscreen mode

Add your class based routing as shown in the example below, so that any routes within this class are prefixed with /api/alerts/:

/** 
 * @Route("/api/alerts")
 */
class AlertsApiController extends AbstractController
{
Enter fullscreen mode Exit fullscreen mode

This controller will use the $workflowRegistry and $entityManager in several places of this class, so to avoid rewriting code in several places, we'll place these inside the construct. Add the following code to the top of your class:

    /** Registry */
    private $workflowRegistry;

    /** EntityManagerInterface */
    private $entityManager;

    public function __construct(Registry $workflowRegistry, EntityManagerInterface $entityManager)
    {
        $this->workflowRegistry = $workflowRegistry;
        $this->entityManager = $entityManager;
    }
Enter fullscreen mode Exit fullscreen mode

When the controller was created, a function named index() was automatically added. We're not going to need it for this project, so delete that function.

Now we'll create our listAction() which will retrieve all alerts from the database and return them as a JSON response. Add the listAction() to your controller as shown below:

    /**
     * @Route("", methods={"GET"})
     */
    public function listAction(): JsonResponse
    {
        $data = $this->entityManager
            ->getRepository(Alert::class)
            ->findAll();

        $alerts = [];

        foreach ($data as $alert) {
            $alerts[] = $alert->toArray();
        }

        return new JsonResponse(
            $alerts, 
            JsonResponse::HTTP_OK
        );
    }
Enter fullscreen mode Exit fullscreen mode

Next, we'll create readAction() to retrieve one alert by ID from the database and return it as a JSON response. Add readAction() to your controller as shown below:

    /**
     * @Route("/{id}", methods={"GET"})
     */
    public function readAction(int $id): JsonResponse
    {
        $alert = $this->entityManager
            ->getRepository(Alert::class)
            ->findOneById($id);

        if (!$alert) {
            return new JsonResponse(
                null,
                JsonResponse::HTTP_NOT_FOUND
            );
        }

        return new JsonResponse(
            $alert->toArray(), 
            JsonResponse::HTTP_OK
        );
    }
Enter fullscreen mode Exit fullscreen mode

We'll create our acceptAction(), which will find an alert by ID from the database; if one is found, it will try to transition the status of this alert from pending to accepted. The response will be an empty JSON response with the HTTP status code of 200.

Add the acceptAction() to your controller as shown below:

    /**
     * @Route("/{id}/accept", methods={"POST"})
     */
    public function acceptAction(int $id): JsonResponse
    {
        $alert = $this->entityManager
            ->getRepository(Alert::class)
            ->findOneById($id);

        if (!$alert) {
            return new JsonResponse(null, JsonResponse::HTTP_NOT_FOUND);
        }

        $workflow = $this->workflowRegistry->get($alert);

        try {
            $workflow->apply($alert, 'accept');

            $this->entityManager->flush();
        } catch (LogicException $exception) {
            return new JsonResponse(['message' => $exception->getMessage()], 400);
        }

        return new JsonResponse([], 200);
    }
Enter fullscreen mode Exit fullscreen mode

Next, we'll create our completeAction(), which will find an alert by ID from the database; if one is found, it will try to transition the status of this alert from accepted to completed. The response will be an empty JSON response with the HTTP status code of 200.

Add the completeAction() to your controller as shown below:

    /**
     * @Route("/{id}/complete", methods={"POST"})
     */
    public function completeAction(int $id): JsonResponse
    {
        $alert = $this->entityManager
            ->getRepository(Alert::class)
            ->findOneById($id);

        if (!$alert) {
            return new JsonResponse(null, JsonResponse::HTTP_NOT_FOUND);
        }

        $workflow = $this->workflowRegistry->get($alert);

        try {
            $workflow->apply($alert, 'complete');

            $this->entityManager->flush();
        } catch (LogicException $exception) {
            return new JsonResponse(['message' => $exception->getMessage()], 400);
        }

        return new JsonResponse([], 200);
    }
Enter fullscreen mode Exit fullscreen mode

Finally, we'll create our cancelAction(), which will find an alert by ID from the database; if one is found, it will try to transition the status of this alert from accepted or pending to cancelled. The response will be an empty JSON response with the HTTP status code of 200.

Add the cancelAction() to your controller as shown below:

    /**
     * @Route("/{id}/cancel", methods={"POST"})
     */
    public function cancelAction(int $id): JsonResponse
    {
        $alert = $this->entityManager
            ->getRepository(Alert::class)
            ->findOneById($id);

        if (!$alert) {
            return new JsonResponse(null, JsonResponse::HTTP_NOT_FOUND);
        }

        $workflow = $this->workflowRegistry->get($alert);

        try {
            $workflow->apply($alert, 'cancel');

            $this->entityManager->flush();
        } catch (LogicException $exception) {
            return new JsonResponse(['message' => $exception->getMessage()], 400);
        }

        return new JsonResponse([], 200);
    }
Enter fullscreen mode Exit fullscreen mode

To summarise, we've added a configuration to our project that controls our alert's flow through its lifecycle. We've then created an API controller that will allow us to retrieve a list of our alerts, retrieve a specific alert, accept, decline, cancel or complete the alerts depending on their status.

Create the Escalation Command

What if the SMS hasn't been received? Or is it ignored?! Well, worry not! The next step is to implement a Symfony Command that will run as a time-based job scheduler (Cron job) and escalate all alerts older than 10 minutes.

Before creating this command, we'll need to add a repository method to retrieve alerts requiring escalation. Open your UserAlertRepository.php file within API/src/Repository/.

At the top of this file, add some more third party libraries for importing:

use App\Entity\Alert;
use App\Entity\UserAlert;
use Carbon\Carbon;
Enter fullscreen mode Exit fullscreen mode

Next, add the repository method to retrieve all alerts that have had an SMS sent over 10 minutes ago but are still in the status of raised:

    public function findRaisedUserAlerts()
    {
        $queryBuilder = $this->createQueryBuilder('ua');
        $lastAlertSent = (Carbon::now())
            ->sub('10 minutes');

        return $queryBuilder
            ->join(Alert::class, 'a', Join::WITH, $queryBuilder->expr()->andX(
                $queryBuilder->expr()->eq('a', 'ua.alert'),
                $queryBuilder->expr()->eq('a.status', ':alertStatus')
            ))
            ->where($queryBuilder->expr()->isNull('ua.voiceSentAt'))
            ->andWhere($queryBuilder->expr()->lte('ua.smsSentAt', ':smsSentAt'))
            ->setParameter('alertStatus', 'raised')
            ->setParameter('smsSentAt', $lastAlertSent->format('Y-m-d H:i:s'))
            ->getQuery()
            ->getResult();
    }
Enter fullscreen mode Exit fullscreen mode

This new Symfony Command will escalate all retrieved alerts. To create it, run the following command in your Terminal:

php bin/console make:command
Enter fullscreen mode Exit fullscreen mode

When asked for the command name, enter app:escalate-alert, which creates a new file called EscalateAlertCommand.php within API/src/Command. Open this new file.

We will be using all of the following classes, so let's make sure we include them from the beginning. At the top of the file, just below namespace App\Command; add the following:

use App\Entity\UserAlert;
use App\Util\VonageUtil;
use Carbon\Carbon;
use Doctrine\ORM\EntityManagerInterface;
Enter fullscreen mode Exit fullscreen mode

The class needs two objects injecting into it, the VonageUtil and EntityManagerInterface. With Symfony, the easiest way to do this is via the constructor. At the top of your class, add the following functionality:

    /** @var VonageUtil */
    protected $vonageUtil;

    /** @var EntityManagerInterface */
    private $entityManager;

    public function __construct(
        VonageUtil $vonageUtil,
        EntityManagerInterface $entityManager
    ) {
        $this->vonageUtil = $vonageUtil;
        $this->entityManager = $entityManager;

        parent::__construct();
    }
Enter fullscreen mode Exit fullscreen mode

Now it's time to write the functionality for this command. It will retrieve all Alerts with an SMS sent over 10 minutes ago, but still with raised status. If there are any of these, it will retrieve the user assigned to the alert and send them a Text-To-Speech voice call notification. Replace current functionality within protected function execute() with:

        $io = new SymfonyStyle($input, $output);

        $userAlertRepository = $this->entityManager->getRepository(UserAlert::class);
        $userAlerts = $userAlertRepository->findRaiseduserAlerts();

        if (!$userAlerts) {
            $io->warning('There are no alerts needing to be raised.');
        }

        /** @var UserAlert $userAlert */
        foreach ($userAlerts as $userAlert) {
            $this->vonageUtil->makePhoneCall(
                $userAlert->getUser()->getPhoneNumber(),
                getenv('VONAGE_NUMBER'),
                'A new alert has been raised, please log into the mobile app to investigate.'
            );

            $userAlert->setVoiceSentAt(Carbon::now());
            $this->entityManager->flush();
        }

        return Command::SUCCESS;
Enter fullscreen mode Exit fullscreen mode

Test the API

Your user needs authenticating to test these new endpoints.

First, make sure you get your JWT token by sending a POST request to http://localhost:8080/api/login_check with your fixtured users credentials.

Once you've copied your JWT, update the type to be a GET request and the URL to be http://localhost:8080/api/alerts. You need to provide a header with the key Authorisation and the value as Bearer <JWT> replacing <JWT> with your token.

The list Alerts endpoint returns a JSON array, which you can see in the Postman example below:

An example of listing alerts through Postman

Let's keep that alert in its current state and use it later when testing the mobile application.

You've built an API; it's now time to create the mobile application.

Build the Mobile App

Update config.json where the value of the APIURL is the ngrok URL you saved previously.

Open a new Terminal window and run the following commands:

cd MobileApp
npm install
expo start
Enter fullscreen mode Exit fullscreen mode

After a little while, a web browser opens. On the left-hand side, there are multiple options to run the application through, whether on your mobile device, iOS simulator or Android simulator. Choose the option that suits you, and when the application boots up, the Login Screen will be the first screen you see.

The fixtured user's credentials in the database are:

username: dev+1@company.com
password: test_pass
Enter fullscreen mode Exit fullscreen mode

As shown in the image below:

Example of a login screen on a mobile phone

A successful log in won't currently do anything! We need to implement more screens first, but to double-check your login was correct, check your Terminal where you ran expo start. You should see the line: You Successfully logged in!.

Alerts API

Showing a List of Alerts

Inside the API directory, create a new file called alerts.js.

Add the example below, which imports the client.js file to use the functionality from getClient().

This new function called getAlerts() makes a request to the API on the endpoint /api/alerts. We can add the other API calls, accept, complete, and cancel alerts while we're here.

import { getClient } from "./client.js";

export function getAlerts() {
  return getClient()
    .then(function(client) {
      return client.get("/api/alerts");
    });
};

export function acceptAlert(alertId) {
  return getClient()
    .then(function(client) {
      return client.post(`/api/alerts/${alertId}/accept`);
    });
};

export function cancelAlert(alertId) {
  return getClient()
    .then(function(client) {
      return client.post(`/api/alerts/${alertId}/cancel`);
    });
};

export function completeAlert(alertId) {
  return getClient()
    .then(function(client) {
      return client.post(`/api/alerts/${alertId}/complete`);
    })
};
Enter fullscreen mode Exit fullscreen mode

Now that we have the functionality to get the alerts, build the AlertsScreen component. Create a new file inside components/ called AlertsScreen.js.

import React, { Component } from 'react'
import { FlatList, Text, View, StyleSheet, StatusBar } from 'react-native'
import { TouchableOpacity } from 'react-native-gesture-handler';
import { getAlerts } from '../api/alerts.js'

class AlertsScreen extends Component {

}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    marginTop: StatusBar.currentHeight || 0,
  },
  header: {
    backgroundColor: '#03A5C9',
    padding: 10,
    borderTopLeftRadius: 20,
    borderTopRightRadius: 20,
  },
  body: {
    padding: 10,
    borderBottomLeftRadius: 20,
    borderBottomRightRadius: 20,
  },
  item: {
    marginVertical: 8,
    marginHorizontal: 16,
    paddingBottom: 10,
    borderWidth: 1,
    borderRadius: 20
  },
  title: {
    fontSize: 24,
  },
  incidentId: {
    textAlign: 'right'
  }
});

export default AlertsScreen;
Enter fullscreen mode Exit fullscreen mode

We now have an empty AlertsScreen class and some styling. Lets add to this class to show something:

  state = {
    alerts: []
  }

  renderItem = ({ item }) => (
    <View style={styles.item}>
      <TouchableOpacity onPress={() => this.onPress(item)}>
        <View style={styles.header}>
          <Text style={styles.title}>
            {item.title}
          </Text>
        </View>
        <View style={styles.body}>
          <Text>
            {item.dateRaised}
          </Text>
          <Text>
            {item.assigned !== '' ? item.assigned : 'Unassigned'}
          </Text>
          <Text style={styles.incidentId}>
            #{item.incidentId}
          </Text>
        </View>
      </TouchableOpacity>
    </View>
  );

  render() {
    return (
      <View>
        <FlatList
          data={this.state.alerts}
          renderItem={this.renderItem}
          keyExtractor={item => item.id}
        />
      </View>
    );
  }
Enter fullscreen mode Exit fullscreen mode

Ok, this is showing us our page. But it isn't retrieving any information and doesn't tell us what to do next!

Above your renderItem() method, add the following:

  componentDidMount() {
    getAlerts()
      .then(response => {
        return response.data.map(alert => ({
          id: `${alert.id}`,
          title: `${alert.title}`,
          description: `${alert.description}`,
          dateRaised: `${alert.dateRaised}`,
          assigned: `${alert.assigned}`,
          incidentId: `${alert.incidentId}`,
          status: `${alert.status}`
        }))
      })
      .then(alerts => {
        this.setState({ alerts: alerts });
      })
      .catch((err) => console.log(err));
  }

  onPress = (item) => {
    return this.props.navigation.navigate('Alert', {
      alert: item,
    })
  }
Enter fullscreen mode Exit fullscreen mode

Showing a Specific Alert

Create a new file in components called AlertScreen.js, which shows the specific alert by ID.

import React, { Component } from 'react'
import { Text, View, ScrollView, StyleSheet, StatusBar, TouchableOpacity } from 'react-native'
import { acceptAlert, cancelAlert, completeAlert } from '../api/alerts.js'

class AlertScreen extends Component {
  state = {
    alert: {}
  }

  const = this.state.alert = this.props.route.params.alert;

  onPressComplete = () => {
    completeAlert(this.state.alert.id)
      .then(() => {
        this.setState({ alert: { ...this.state.alert, status: 'completed'} });
      })
      .catch((err) => console.log(err));
  }

  onPressCancel = () => {
    cancelAlert(this.state.alert.id)
      .then(() => {
        this.setState({ alert: { ...this.state.alert, status: 'cancelled'} });
      })
      .catch((err) => console.log(err));
  }

  onPressAccept = () => {
    acceptAlert(this.state.alert.id)
      .then(() => {
        this.setState({ alert: { ...this.state.alert, status: 'accepted'} });
      })
      .catch((err) => console.log(err));
  }

  render() {
    let buttons;

    if (this.state.alert.status === 'raised') {
      buttons = <View style={styles.buttonContainer}>
          <View style={styles.buttonView}>
            <TouchableOpacity
              style={styles.button}
              onPress={() => this.onPressAccept()}
              underlayColor='#fff'>
              <Text style={styles.actionText}>Accept</Text>
            </TouchableOpacity>
          </View>
          <View style={styles.buttonView}>
            <TouchableOpacity
              style={styles.button}
              onPress={() => this.onPressCancel()}
              underlayColor='#fff'>
              <Text style={styles.actionText}>Cancel</Text>
            </TouchableOpacity>
          </View>
        </View>
    } else if (this.state.alert.status === 'accepted') {
      buttons = <View style={styles.buttonContainer}>
          <View style={styles.buttonView}>
            <TouchableOpacity
              style={styles.button}
              onPress={() => this.onPressComplete()}
              underlayColor='#fff'>
              <Text style={styles.actionText}>Complete</Text>
            </TouchableOpacity>
          </View>
          <View style={styles.buttonView}>
            <TouchableOpacity
              style={styles.button}
              onPress={() => this.onPressCancel()}
              underlayColor='#fff'>
              <Text style={styles.actionText}>Cancel</Text>
            </TouchableOpacity>
          </View>
        </View>
    }

    return (
      <View style={styles.item}>
        <View style={styles.header}>
          <Text style={styles.title}>
            {this.state.alert.title}
          </Text>
        </View>
        <View style={styles.body}>
          <Text>
            Date Raised: {this.state.alert.raisedDate}
          </Text>
          <Text>
            Assignee: {this.state.alert.assigned !== '' ? this.state.alert.assigned : 'Unassigned'}
          </Text>
          <Text style={styles.incidentId}>
            Incident ID: #{this.state.alert.incidentId}
          </Text>
          <Text style={styles.status}>
            Status: {this.state.alert.status}
          </Text>
        </View>
        {buttons}
        <View style={styles.scrollView}>
          <ScrollView>
            <Text style={styles.text}>
              {this.state.alert.description}
            </Text>
          </ScrollView>
        </View>
      </View>
    );
  }
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    marginTop: StatusBar.currentHeight || 0,
  },
  header: {
    backgroundColor: '#03A5C9',
    padding: 10,
  },
  body: {
    padding: 10,
  },
  item: {
    paddingBottom: 10,
  },
  title: {
    fontSize: 24,
  },
  buttonContainer: {
    flex: 1,
    flexDirection: "row",
    alignItems: 'center',
    justifyContent: 'center',
    paddingBottom: 30
  },
  buttonView: {
    flex: 1,
    height: 10
  },
  button: {
    marginRight: 40,
    marginLeft: 40,
    marginTop: 10,
    paddingTop: 10,
    paddingBottom: 10,
    backgroundColor: '#1E6738',
    borderRadius: 10,
    borderWidth: 1,
    borderColor: '#fff'
  },
  actionText: {
      color: '#fff',
      textAlign: 'center',
      paddingLeft: 10,
      paddingRight: 10
  },
  text: {
    fontSize: 20,
  },
});

export default AlertScreen;
Enter fullscreen mode Exit fullscreen mode

Your application currently has no instruction on how to show these two new screens you've created. In navigation/MainStackNavigator.js below import Login, add the following two lines:

import Alert from '../components/AlertScreen';
import Alerts from '../components/AlertsScreen';
Enter fullscreen mode Exit fullscreen mode

Then below the Login Stack.Screen, add two new Screens:

        <Stack.Screen 
          name='Alerts' 
          component={Alerts} 
          options={{ title: 'Alerts Screen' }}
        />
        <Stack.Screen
          name='Alert'
          component={Alert}
          options={({route, navigation}) => (
            {headerTitle: 'Alert Screen', 
            route: {route}, 
            navigation: {navigation}}
          )}
        />
Enter fullscreen mode Exit fullscreen mode

Back in your LoginScreen.js file, find the line showing: console.log('You Successfully logged in!'); and add the snippet below to redirect the user on a successful login.

  return this.props.navigation.navigate('Alerts');
Enter fullscreen mode Exit fullscreen mode

Testing

To test this app in your Terminal, make sure you've navigated to the MobileApp directory, and run the following command:

expo start
Enter fullscreen mode Exit fullscreen mode

After a little while, a web browser should open. On the left-hand side, there are multiple options to run the application through, whether it's on your mobile device, iOS simulator or Android simulator. Choose the option that suits you. When the application starts, the first screen you see is the Login screen.

The fixtured user's credentials in the database are:

username: dev+1@company.com
password: test_pass
Enter fullscreen mode Exit fullscreen mode

On successful login, the next screen you see is the Alerts screen. However, this will be empty right now because, in the database, there aren't any alerts.

An example of raising an alert with Postman

Now, retry logging into your mobile application. You'll see the new alert, and you'll also be able to click on this alert to be taken to a screen that shows more information.

You can also transition this alert, whether it is to be accepted or cancelled.

Conclusion

In this tutorial, we've learned how to build an API using a PHP framework called Symfony. We've also built a mobile application using React Native. Vonage's APIs allowed us to send notifications via SMS and Text-To-Speech voice calls. By applying all of these together, we've built ourselves a functional on-call application for developers or system administrators to be alerted when anything goes wrong. Having a webhook allows us to integrate our on-call system with multiple services to cover as many as possible.

Below are a few other tutorials we've written implementing the Vonage Voice API into projects:

As always, if you have any questions, advice or ideas you’d like to share with the community, then please feel free to jump on our Community Slack workspace. I'd love to hear how you've gotten on with this tutorial and how your project works.

Discussion (0)

pic
Editor guide