https://d226lax1qjow5r.cloudfront.net/blog/blogposts/how-to-build-an-on-call-application-with-react-native-and-symfony/symfony-native_oncall_1200x600.png

Cómo crear una aplicación de guardia con React Native y Symfony

Publicado el March 2, 2021

Tiempo de lectura: 36 minutos

¿Es usted desarrollador? ¿Alguna vez has estado de guardia y has tenido que instalar una de esas molestas aplicaciones que te avisan cada vez que algo va un poco mal? ¿Se ha superado el umbral de errores o el servidor tarda demasiado en responder, por ejemplo? Si es así, ¿alguna vez has pensado: "Me gustaría construir yo mismo uno de esos servicios?" Bueno, con este tutorial, estás a punto de comenzar con los conceptos básicos para construir una de estas aplicaciones y usar Vonage para realizar las comunicaciones.

Este tutorial le ayudará a construir el principio de una API en PHP usando Symfony y la aplicación móvil usando React Native.

El código completo de este tutorial se puede encontrar en nuestro: Repositorio de la Comunidad. Asegúrese de hacer checkout en la rama end-tutorial rama.

Requisitos previos

Para completar este tutorial necesitarás lo siguiente:

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.

Clonar el repositorio

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

Creación de la API

Generar par de claves JWT

Este proyecto utilizará una aplicación móvil construida en React Native.
Necesitarás autenticar al usuario entre la aplicación móvil y la API. Este proyecto utiliza JWT para manejar la autenticación, por lo que es necesario generar certificados para hacer los tokens JWT.
En la raíz de tu proyecto, ejecuta los siguientes tres comandos:

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

Exponer su aplicación a Internet

Realizar una llamada telefónica con Vonage requiere un número de teléfono virtual. También querrás configurar un webhook para registrar los eventos que ocurren cada vez que se realiza, responde, rechaza o finaliza una llamada telefónica.
Para este tutorial, ngrok es el servicio elegido para exponer la aplicación a Internet. Instala ngrok, y ejecuta el siguiente comando en una nueva ventana de Terminal:

ngrok http 8080 # Creates an http tunnel to the Internet from your computer on port 8080

Asegúrate de copiar la URL HTTPS de ngrok, ya que la necesitarás más adelante cuando configures el proyecto.

Variables de entorno

Dentro del directorio Docker hay un archivo llamado .env.distcopie o cambie el nombre de este archivo a .env.

Los primeros campos a actualizar son las credenciales de tu base de datos. El ejemplo de abajo muestra las credenciales que he usado para este tutorial, pero por favor usa unas más seguras.

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

Actualice los valores de VONAGE_API_KEY= y VONAGE_API_SECRET=que puedes encontrar dentro del Panel para desarrolladores de Vonage.

A continuación, en el panel de control, vaya a "Sus Applications". Cree una nueva aplicación, asegurándose de descargar el archivo private.key en el directorio raíz del proyecto y asegúrate de que tu aplicación tiene funciones de voz.

Tienes que establecer la URL del webhook de eventos cuando utilices la Voice API. Ajústala a la URL HTTPS de ngrok que copiaste en la última sección.

Actualiza los dos siguientes:

VONAGE_APPLICATION_PRIVATE_KEY_PATH=/var/www/API/private.key VONAGE_APPLICATION_ID=...

Luego, vincula tu número virtual de Vonage previamente adquirido a tu aplicación. Luego, en tu código, actualiza lo siguiente dentro de tu archivo .env archivo dentro de Docker:

VONAGE_BRAND=OnCallAlerts VONAGE_NUMBER=... JWT_PASSPHRASE=...

Por último, busque ON_CALL_NUMBER= en el mismo archivo y añádele tu número de teléfono. Tendrá que ser un número real y capaz de recibir mensajes SMS y llamadas de voz.

Iniciar Docker

Ejecute los cinco comandos siguientes: los comentarios a la derecha de cada uno describen lo que hacen:

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`

Es hora de construir la API

Crear entidades de base de datos

Hay tres nuevas tablas de base de datos para este proyecto. Alerts, OnCally una tabla para vincular las Alertas y los Usuarios, UserAlerts.
Para empezar, ejecute el comando que aparece a continuación y siga las instrucciones para la introducción de datos:

php bin/console make:entity

Para cada campo, añada lo siguiente:

  • Nombre de la clase: Alerta

  • Nombre de la propiedad: title (String, 255, Not null)

  • Nombre de la propiedad: descripción (String, 255, Not null)

  • Nombre de propiedad: status (String, 255, Not null)

Una vez completado el comando, abra el nuevo archivo: src/Entity/Alert.php

Hay otras tres clases utilizadas dentro de este nuevo archivo de Entidad. Añadir estas importaciones en la parte superior del archivo:

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Gedmo\Timestampable\Traits\TimestampableEntity;

Una de estas nuevas clases es la TimestampableEntity, que añade created_at y updated_at campos a la base de datos. Añade use TimestampableEntity; en la parte superior de la clase, como se muestra a continuación:

class Alert
{
    use TimestampableEntity;

Tenemos que añadir algunos valores por defecto dentro de la clase, por lo que crear una nueva construcción y por defecto los valores como se muestra a continuación:

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

Mientras estamos en esta clase, añade las dos funciones de abajo.
La función getUserAssigned() determina qué usuario es el responsable actual de la alerta. La segunda función, toArray()convierte los valores de la clase en un array, listo para las respuestas de la API.

    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()
        ];
    }

Para crear la entidad OnCall que vamos a utilizar para almacenar qué persona está de guardia cada semana, ejecute el siguiente comando y siga las instrucciones de entrada que se indican:

php bin/console make:entity

Para cada campo, añada lo siguiente:

  • Nombre de la clase: OnCall

  • Nombre de propiedad: user (relation, User, ManyToOne, Not null, Add Property to User Yes)

  • Nombre de la propiedad: startDate (datetime, Not null)

  • Nombre de la propiedad: endDate (datetime, Not null)

Una vez completado el comando, abra el nuevo archivo: src/Entity/OnCall.php

Hay otra clase usada dentro de este nuevo archivo de Entidad. Añadir esta importación en la parte superior del archivo:

use Gedmo\Timestampable\Traits\TimestampableEntity;

Una de estas nuevas clases es la TimestampableEntity, que añade created_at y updated_at a la base de datos, añade use TimestampableEntity; en la parte superior de la clase como se muestra a continuación:

class OnCall
{
    use TimestampableEntity;

Tenemos que añadir algunos valores por defecto dentro de la clase, por lo que crear una nueva construcción y por defecto los valores como se muestra a continuación:

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

Para vincular las entidades Usuario y Alerta, debe crear una nueva entidad denominada UserAlert. Siga las siguientes instrucciones:

php bin/console make:entity
  • Nombre de la clase: UserAlert

  • Nombre de propiedad: user (relation, User, ManyToOne, Not null, Add Property to User Yes)

  • Nombre de propiedad: alert (relation, Alert, ManyToOne, Not null, Add Property to Alert yes)

  • Nombre de la propiedad: smsSentAt (datetime, null)

  • Nombre de la propiedad: voiceSentAt (datetime, null)

Una vez completado el comando, abra el nuevo archivo: src/Entity/UserAlert.php

Hay otra clase usada dentro de este nuevo archivo de Entidad. Añadir esta importación en la parte superior del archivo:

use Gedmo\Timestampable\Traits\TimestampableEntity;

Una de estas nuevas clases es la TimestampableEntity, que añade created_at y updated_at a la base de datos, añade use TimestampableEntity; en la parte superior de la clase como se muestra a continuación:

class UserAlert
{
    use TimestampableEntity;

Tenemos que añadir algunos valores por defecto dentro de la clase, por lo que crear una nueva construcción y por defecto los valores como se muestra a continuación:

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

Ejecute las migraciones

Ahora es el momento de realizar y ejecutar las migraciones, creando nuevas tablas y columnas en su base de datos para reflejar estas entidades recién creadas.

En tu Terminal ejecuta:

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

Hacer DataFixtures

Tenemos que hacer algunos accesorios predefinidos para la OnCall para determinar quién está de guardia a una hora determinada. Para ello, ejecute el siguiente comando y siga las instrucciones que se indican:

php bin/console make:fixture

Al introducir el nombre OnCallFixtures se creará un archivo dentro de API/src/DataFixtures llamado OnCallFixtures.php. Sustituya el contenido de este archivo por lo siguiente:

<?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,
        ];
    }
}

¡Vamos a ejecutar tus fixtures para que tengamos un usuario y un registro de guardia! En tu terminal ejecuta:

php bin/console doctrine:fixtures:load

Hacer formulario

Cuando manejamos una petición API para lanzar una alerta, necesitamos validar la entrada para asegurarnos de que es lo que esperamos. Con Symfony, la forma más fácil de hacer esto es mediante el uso de un formulario. Con un formulario, podemos definir los valores que esperamos y las restricciones de estos valores. Comienza ejecutando el siguiente comando:

php bin/console make:form

Siga las instrucciones, como se muestra en la imagen:

Creating an Alert Form Type

Ahora, abra el archivo AlertType.php que se encuentra dentro de src/Form/ y sustituye el contenido del archivo por

<?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,
        ]);
    }
}

El nuevo código que ha añadido a la clase AlertType añade más restricciones y requisitos a los dos campos de este formulario, title y descriptionpara garantizar que tienen una longitud mínima y que no están en blanco.

Construye una Util de Vonage

Se necesita una clase de utilidad para manejar las solicitudes de la API de Vonage al enviar mensajes SMS y realizar llamadas de voz.

En API/srccree un nuevo directorio llamado Utiljunto con un nuevo archivo dentro de este nuevo directorio llamado VonageUtil.php

Ya has almacenado tus credenciales de Vonage en el archivo .env anteriormente en este tutorial, y las utilizarás en esta nueva clase PHP.

En el nuevo archivo añada el siguiente código:

<?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;
    }
}

En este momento, este código inicializa una nueva clase PHP y crea un nuevo cliente para la API de Vonage, utilizando la envoltura Symfony de Vonage para el SDK de PHP.

A continuación, dentro de esta clase, vas a querer añadir dos nuevas funciones, que se encargarán de hacer la petición a la API para enviar un SMS o hacer una llamada de voz. Añade las dos siguientes:

    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);
    }

Construir el controlador Webhook

Antes de hacer el controlador, vamos a necesitar una función Repository para extraer datos específicos de la base de datos. Abrimos la función OnCallRepository.php que se encuentra dentro de src/Repository. Dentro de la clase debajo de la función __construct() añade la nueva función findCurrentOnCall que encontrará al usuario actual en la llamada.

    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();
    }

Hemos creado la funcionalidad para extraer los datos. A continuación, vamos a crear un controlador para manejar las solicitudes y extraer los datos.
Primero, en tu Terminal, ejecuta lo siguiente:

php bin/console make:controller

Cuando se le pregunte el nombre de su controlador, introduzca WebhookController.

Abra el archivo recién creado: API/src/Controller/WebhookController.php.

Vamos a utilizar todas las clases siguientes, así que asegurémonos de incluirlas desde el principio. En la parte superior del archivo, justo debajo de namespace App\Controller; añade lo siguiente:

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;

Tu clase necesita una construcción para que Symfony inyecte las clases EntityManager y VonageUtil. En la parte superior de tu clase, añade:

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

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

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

Ahora reemplace la función index() por el siguiente código para crear nuevas alertas. Esta nueva función maneja el cuerpo de la solicitud POST, crea estos datos como una nueva alerta Alerty pasa la alerta al formulario para validar los valores. Si todo es como se espera, se creará una nueva alerta UserAlertcon la persona que está recibiendo la alerta.

    /**
     * @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);
    }

Te habrás dado cuenta de que la función $this->getErrorMessages() es llamada en la parte inferior, pero tu clase aún no la tiene. Tendrás que añadir esta función a continuación. Recuperará todos los errores de formulario encontrados cuando se dispara el endpoint, pero faltan algunos datos. Debajo de tu método index() añade lo siguiente:

    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;
    }

Ya podemos probarlo.

Probar la autenticación

Hay dos endpoints en esta parte del tutorial que podemos probar con nuestra API, así que con Docker aún ejecutándose en segundo plano, haz una POST petición a http://localhost:8080/api/login_check con el cuerpo JSON de:

{
    "username": "dev+1@company.com",
    "password": "test_pass"
}

La respuesta será un objeto JSON con una clave tokeny el valor será un token JWT.

La siguiente imagen muestra un ejemplo de cómo hacerlo con Postman:

An example of authenticating with Postman

Prueba de activación de una alerta

En este ejemplo no es necesario estar autenticado para lanzar una alerta, por lo que no es necesario utilizar el JWT del ejemplo anterior.

Para lanzar una alerta, actualice el campo URL: http://localhost:8080/webhooks/raise_alert, mantenga el método como POST request, y el cuerpo JSON de:

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

La respuesta será un array vacío y el código de estado HTTP 201 (creado). Puedes ver un ejemplo de esta petición en Postman en la imagen inferior:

An example of raising an alert with Postman

Cómo gestionar una alerta

El componente Workflow de Symfony te permite definir un ciclo de vida que tu objeto puede atravesar con sus estados. Cada paso por el que puede pasar tu objeto se denomina lugar, y las transiciones definen la acción que debe realizar el objeto para pasar de un lugar a otro.

Los flujos de trabajo le permitirán definir en qué lugares puede estar su alerta para pasar de ser raised hasta el último paso, que es cancelled o bien completed.

Abra el workflow.yaml que se encuentra en config/packages/ y sustituya el contenido por el ejemplo siguiente:

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

Ahora se necesita un controlador para manejar todas las solicitudes de la API en relación con Alerts. Por lo tanto, ejecuta el siguiente comando para empezar a crear nuestro nuevo AlertsApiController:

php bin/console make:controller

Cuando se le pida el nombre del controlador, introduzca AlertsApiController. Este comando creará un nuevo archivo AlertsApiController.php archivo dentro de src/Controllers. Así que abra este nuevo archivo.

Vamos a utilizar todas las clases siguientes, así que asegurémonos de incluirlas desde el principio. En la parte superior del archivo, justo debajo de namespace App\Controller; añade lo siguiente:

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

Añada su enrutamiento basado en clases como se muestra en el siguiente ejemplo, de modo que cualquier ruta dentro de esta clase lleve el prefijo /api/alerts/:

/** 
 * @Route("/api/alerts")
 */
class AlertsApiController extends AbstractController
{

Este controlador utilizará los parámetros $workflowRegistry y $entityManager en varios lugares de esta clase, así que para evitar reescribir código en varios lugares, los colocaremos dentro de la construcción. Añade el siguiente código a la parte superior de tu clase:

    /** Registry */
    private $workflowRegistry;

    /** EntityManagerInterface */
    private $entityManager;

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

Cuando se creó el controlador, se añadió automáticamente una función denominada index() fue añadida automáticamente. No la vamos a necesitar para este proyecto, así que elimina esa función.

Ahora crearemos nuestro listAction() que recuperará todas las alertas de la base de datos y las devolverá como respuesta JSON. Añade la directiva listAction() a tu controlador como se muestra a continuación:

    /**
     * @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
        );
    }

A continuación, crearemos readAction() para recuperar una alerta por ID de la base de datos y devolverla como respuesta JSON. Añade readAction() a tu controlador como se muestra a continuación:

    /**
     * @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
        );
    }

Crearemos nuestro acceptAction()que buscará una alerta por ID en la base de datos; si encuentra una, intentará pasar el estado de esta alerta de pending a accepted. La respuesta será una respuesta JSON vacía con el código de estado HTTP 200.
Añada la directiva acceptAction() a tu controlador como se muestra a continuación:

    /**
     * @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);
    }

A continuación, crearemos nuestro completeAction()que buscará una alerta por ID en la base de datos; si encuentra una, intentará cambiar el estado de esta alerta de accepted a completed. La respuesta será una respuesta JSON vacía con el código de estado HTTP 200.
Añada la directiva completeAction() a tu controlador como se muestra a continuación:

    /**
     * @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);
    }

Por último, crearemos nuestro cancelAction()que buscará una alerta por ID en la base de datos; si encuentra una, intentará cambiar el estado de esta alerta de accepted o pending a cancelled. La respuesta será una respuesta JSON vacía con el código de estado HTTP 200.
Añada la directiva cancelAction() a su controlador como se muestra a continuación:

    /**
     * @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);
    }

En resumen, hemos añadido una configuración a nuestro proyecto que controla el flujo de nuestras alertas a través de su ciclo de vida. A continuación, hemos creado un controlador de API que nos permitirá recuperar una lista de nuestras alertas, recuperar una alerta específica, aceptar, rechazar, cancelar o completar las alertas en función de su estado.

Crear el comando de escalada

¿Y si el SMS no se ha recibido? ¿O es ignorado? Bueno, ¡no te preocupes! El siguiente paso es implementar un comando Symfony que se ejecutará como un programador de tareas basado en el tiempo (Cron job) y escalará todas las alertas con más de 10 minutos de antigüedad.

Antes de crear este comando, necesitaremos añadir un método de repositorio para recuperar las alertas que requieren escalado. Abra su archivo UserAlertRepository.php dentro de API/src/Repository/.

En la parte superior de este archivo, añadir algunas bibliotecas más de terceros para la importación:

use App\Entity\Alert;
use App\Entity\UserAlert;
use Carbon\Carbon;

A continuación, añada el método de repositorio para recuperar todas las alertas a las que se haya enviado un SMS hace más de 10 minutos pero que aún se encuentren en el estado de 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();
    }

Este nuevo comando de Symfony escalará todas las alertas recuperadas. Para crearlo, ejecuta el siguiente comando en tu Terminal:

php bin/console make:command

Cuando se le pida el nombre del comando, introduzca app:escalate-alertque crea un nuevo archivo llamado EscalateAlertCommand.php dentro de API/src/Command. Abra este nuevo archivo.

Vamos a utilizar todas las clases siguientes, así que asegurémonos de incluirlas desde el principio. En la parte superior del archivo, justo debajo de namespace App\Command; añade lo siguiente:

use App\Entity\UserAlert;
use App\Util\VonageUtil;
use Carbon\Carbon;
use Doctrine\ORM\EntityManagerInterface;

La clase necesita que se le inyecten dos objetos, el VonageUtil y EntityManagerInterface. Con Symfony, la forma más fácil de hacerlo es a través del constructor. En la parte superior de tu clase, añade la siguiente funcionalidad:

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

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

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

        parent::__construct();
    }

Ahora es el momento de escribir la funcionalidad para este comando. Recuperará todas las Alertas con un SMS enviado hace más de 10 minutos, pero todavía con raised estado. Si hay alguna, recuperará el usuario asignado a la alerta y le enviará una notificación de llamada de voz de texto a voz. Sustituya la funcionalidad actual dentro de protected function execute() por:

        $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;

Probar la API

Tu usuario necesita autenticarse para probar estos nuevos endpoints.
En primer lugar, asegúrese de obtener su token JWT enviando una POST solicitud a http://localhost:8080/api/login_check con las credenciales de tu usuario fijo.

Una vez que hayas copiado tu JWT, actualiza el tipo para que sea una GET y la URL para que sea http://localhost:8080/api/alerts. Es necesario proporcionar un encabezado con la clave Authorisation y el valor como Bearer <JWT> sustituyendo <JWT> por tu token.

El endpoint list Alerts devuelve un array JSON, que puedes ver en el ejemplo de Postman a continuación:

An example of listing alerts through Postman

Mantengamos esa alerta en su estado actual y utilicémosla más tarde al probar la aplicación móvil.

Ha creado una API; ahora es el momento de crear la aplicación móvil.

Construir la aplicación móvil

Actualización config.json donde el valor de APIURL es la URL ngrok que guardó anteriormente.

Abra una nueva ventana de Terminal y ejecute los siguientes comandos:

cd MobileApp npm install expo start

Al cabo de un rato, se abre un navegador web. En la parte izquierda, hay varias opciones para ejecutar la aplicación, ya sea en tu dispositivo móvil, simulador iOS o simulador Android. Elige la opción que más te convenga y, cuando la aplicación arranque, la pantalla de inicio de sesión será la primera que veas.

Las credenciales del usuario fijo en la base de datos son:

username: dev+1@company.com
password: test_pass

Como se muestra en la siguiente imagen:

Example of a login screen on a mobile phone

Un inicio de sesión correcto no hará nada. Tenemos que implementar más pantallas primero, pero para comprobar que su inicio de sesión fue correcto, compruebe su Terminal donde ejecutó expo start. Deberías ver la línea : You Successfully logged in!.

API de alertas

Mostrar una lista de alertas

Dentro del directorio API cree un nuevo archivo llamado alerts.js.
Añade el siguiente ejemplo, que importa el archivo client.js para utilizar la funcionalidad de getClient().
Esta nueva función llamada getAlerts() realiza una petición a la API en el endpoint /api/alerts. Podemos añadir las otras llamadas a la API, aceptar, completar y cancelar alertas mientras estamos aquí.

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`);
    })
};

Ahora que tenemos la funcionalidad para obtener las alertas, construye el componente AlertsScreen. Crea un nuevo archivo dentro de components/ llamado 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;

Ahora tenemos una clase AlertsScreen y algo de estilo. Vamos a añadir a esta clase para mostrar algo:

  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>
    );
  }

Bien, esto nos muestra nuestra página. Pero no recupera ninguna información y no nos dice qué hacer a continuación.

Encima de su método renderItem() añada lo siguiente:

  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,
    })
  }

Mostrar una alerta específica

Cree un nuevo archivo en components llamado AlertScreen.jsque muestra la alerta específica por 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;

Actualmente tu aplicación no tiene instrucciones sobre cómo mostrar estas dos nuevas pantallas que has creado. En navigation/MainStackNavigator.js debajo de import Loginañade las dos líneas siguientes:

import Alert from '../components/AlertScreen';
import Alerts from '../components/AlertsScreen';

A continuación, debajo de Login Stack.Screenañada dos nuevas pantallas:

        <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}}
          )}
        />

En su archivo LoginScreen.js encuentre la línea console.log('You Successfully logged in!'); y añada el siguiente fragmento de código para redirigir al usuario en caso de inicio de sesión correcto.

return this.props.navigation.navigate('Alerts');

Pruebas

Para probar esta aplicación en tu Terminal, asegúrate de que has navegado hasta el directorio MobileApp y ejecuta el siguiente comando:

expo start

Al cabo de un rato, debería abrirse un navegador web. En la parte izquierda, hay varias opciones para ejecutar la aplicación, ya sea en tu dispositivo móvil, en el simulador de iOS o en el simulador de Android. Elige la opción que más te convenga. Cuando se inicie la aplicación, la primera pantalla que verá será la de inicio de sesión.

Las credenciales del usuario fijo en la base de datos son:

username: dev+1@company.com
password: test_pass

Una vez iniciada la sesión con éxito, la siguiente pantalla que verá es la de Alertas. Sin embargo, en este momento estará vacía porque, en la base de datos, no hay ninguna alerta.

An example of raising an alert with Postman

Ahora, vuelve a intentar iniciar sesión en tu aplicación móvil. Verás la nueva alerta y también podrás hacer clic en ella para acceder a una pantalla con más información.

También puede transicionar esta alerta, ya sea para aceptarla o cancelarla.

Conclusión

En este tutorial, hemos aprendido a construir una API usando un framework PHP llamado Symfony. También hemos construido una aplicación móvil usando React Native. Las API de Vonage nos permitieron enviar notificaciones a través de SMS y llamadas de voz de texto a voz. Aplicando todo esto junto, nos hemos construido una aplicación de guardia funcional para que los desarrolladores o administradores de sistemas reciban alertas cuando algo va mal. Disponer de un webhook nos permite integrar nuestro sistema de guardia con múltiples servicios para cubrir el mayor número posible.

A continuación, encontrarás otros tutoriales que hemos escrito para implementar Voice API de Vonage en proyectos:

Como siempre, si tienes alguna pregunta, consejo o idea que quieras compartir con la comunidad, no dudes en entrar en nuestro espacio de trabajo espacio de trabajo comunitario Slack. Me encantaría saber cómo te ha ido con este tutorial y cómo funciona tu proyecto.

Compartir:

https://a.storyblok.com/f/270183/250x250/b052219541/greg-holmes.png
Greg HolmesAntiguos alumnos de Vonage

Antiguo educador de desarrolladores @Vonage. Procedente de PHP, pero no limitado a un solo lenguaje. Un ávido jugador y un entusiasta de Raspberry pi. A menudo se le encuentra practicando escalada en rocódromo.