https://a.storyblok.com/f/270183/53746/419e171345/symfony-native_oncall_1200x600.png

Como criar uma aplicação de plantão com React Native e Symfony

Publicado em March 2, 2021

Tempo de leitura: 35 minutos

Você é desenvolvedor? Já esteve de plantão e precisou instalar um daqueles aplicativos chatos que te avisam sempre que algo está um pouco errado? O limite de erros foi ultrapassado ou o servidor está demorando demais para responder, por exemplo? Se sim, você já pensou: “Gostaria de criar um desses serviços”? Bem, com este tutorial, você está prestes a aprender o básico para criar um desses aplicativos e usar o Vonage para realizar as comunicações.

Este tutorial vai ajudá-lo a criar os primeiros passos de uma API em PHP usando Symfony e um aplicativo móvel com React Native.

O código completo deste tutorial pode ser encontrado em nosso: Repositório da Comunidade. Certifique-se de fazer o checkout no end-tutorial ramo.

Pré-requisitos

Para concluir este tutorial, você precisará do seguinte:

Clonar o repositório

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

Criação da API

Gerar par de chaves JWT

Este projeto utilizará um aplicativo móvel desenvolvido em React Native.
Você precisará autenticar o usuário entre o aplicativo móvel e a API. Este projeto usa JWT para lidar com a autenticação; portanto, é necessário gerar certificados para criar os tokens JWT.
Na raiz do seu projeto, execute os três comandos a seguir:

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

Expondo seu aplicativo à Internet

Para fazer uma ligação com o Vonage, é necessário um número de telefone virtual. Você também vai querer configurar um webhook para registrar os eventos que ocorrem sempre que uma ligação é feita, atendida, rejeitada ou encerrada.
Para este tutorial, o ngrok é o serviço escolhido para expor o aplicativo à Internet. Instale o ngrok e execute o seguinte comando em uma nova janela do Terminal:

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

Certifique-se de copiar sua URL HTTPS do ngrok, pois você precisará dela mais tarde, ao configurar o projeto.

Variáveis de ambiente

Dentro do Docker diretório há um arquivo chamado .env.dist; copie ou renomeie esse arquivo para .env.

Os primeiros campos a serem atualizados são as credenciais do seu banco de dados. O exemplo abaixo mostra as credenciais que utilizei para este tutorial, mas use outras mais 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

Atualize os valores de ambos VONAGE_API_KEY= e VONAGE_API_SECRET=, que você pode encontrar no Painel do Desenvolvedor da Vonage.

Em seguida, no painel, acesse “Seus Applications”. Crie um novo aplicativo, certificando-se de baixar o private.key arquivo para o diretório raiz do projeto e de que seu aplicativo tenha recursos de Voice.

É necessário definir a URL do webhook de evento ao usar a Voice API. Defina-a como a URL HTTPS do ngrok que você copiou na seção anterior.

Atualize os dois itens a seguir:

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

Em seguida, vincule o número virtual da Vonage que você adquiriu anteriormente ao seu aplicativo. Depois, no seu código, atualize o seguinte dentro do seu .env arquivo dentro de Docker:

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

Por fim, localize ON_CALL_NUMBER= no mesmo arquivo e adicione seu número de telefone a esse valor. Ele precisa ser um número válido e capaz de receber mensagens SMS e chamadas de voz.

Iniciar o Docker

Execute os cinco comandos a seguir — os comentários à direita de cada um descrevem o que eles fazem:

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`

É hora de criar a API!

Criar entidades de banco de dados

Existem três novas tabelas no banco de dados para este projeto. Alerts, OnCall, e uma tabela para vincular Alertas e Usuários, UserAlerts.
Para começar, execute o comando abaixo e siga as instruções de entrada a seguir:

php bin/console make:entity

Para cada campo, insira o seguinte:

  • Nome da classe: Alert

  • Nome da propriedade: título (String, 255, Não nulo)

  • Nome da propriedade: descrição (String, 255, Não nulo)

  • Nome da propriedade: status (String, 255, Não nulo)

Quando o comando for executado, abra o novo arquivo: src/Entity/Alert.php

Existem outras três classes utilizadas neste novo arquivo Entity. Adicione essas importações no início do arquivo:

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

Uma dessas novas classes é a TimestampableEntity, que adiciona created_at e updated_at campos ao banco de dados. Adicione use TimestampableEntity; no início da classe, conforme mostrado abaixo:

class Alert
{
    use TimestampableEntity;

Precisamos adicionar alguns valores padrão à classe; portanto, crie um novo construtor e defina os valores padrão conforme mostrado a seguir:

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

Já que estamos nesta aula, adicione as duas funções abaixo.
A getUserAssigned() função determina qual usuário é o usuário atual responsável pelo alerta. A segunda função, toArray(), converte os valores da classe em um array, pronto para as respostas da 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 criar a OnCall entidade, que estamos usando para armazenar quais pessoas estão de plantão a cada semana, execute o comando abaixo e siga as instruções de entrada listadas:

php bin/console make:entity

Para cada campo, insira o seguinte:

  • Nome da classe: OnCall

  • Nome da propriedade: user (relação, User, ManyToOne, Não nulo, Adicionar propriedade ao usuário: Sim)

  • Nome da propriedade: startDate (datetime, Não nulo)

  • Nome da propriedade: endDate (datetime, Não nulo)

Quando o comando for executado, abra o novo arquivo: src/Entity/OnCall.php

Há mais uma classe utilizada neste novo arquivo de Entidade. Adicione esta importação no início do arquivo:

use Gedmo\Timestampable\Traits\TimestampableEntity;

Uma dessas novas classes é a TimestampableEntity, que adiciona created_at e updated_at campos ao banco de dados; adicione use TimestampableEntity; no início da classe, conforme mostrado abaixo:

class OnCall
{
    use TimestampableEntity;

Precisamos adicionar alguns valores padrão à classe; portanto, crie um novo construtor e defina os valores padrão conforme mostrado a seguir:

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

Para vincular suas entidades “Usuário” e “Alerta”, você precisa criar uma nova entidade chamada UserAlert. Siga as instruções abaixo:

php bin/console make:entity
  • Nome da classe: UserAlert

  • Nome da propriedade: user (relação, User, ManyToOne, Não nulo, Adicionar propriedade ao usuário: Sim)

  • Nome da propriedade: alert (relação, Alert, Muitos para um, Não nulo, Adicionar propriedade ao Alert: sim)

  • Nome da propriedade: smsSentAt (datetime, null)

  • Nome da propriedade: voiceSentAt (datetime, null)

Quando o comando for executado, abra o novo arquivo: src/Entity/UserAlert.php

Há mais uma classe utilizada neste novo arquivo de Entidade. Adicione esta importação no início do arquivo:

use Gedmo\Timestampable\Traits\TimestampableEntity;

Uma dessas novas classes é a TimestampableEntity, que adiciona created_at e updated_at campos ao banco de dados; adicione use TimestampableEntity; no início da classe, conforme mostrado abaixo:

class UserAlert
{
    use TimestampableEntity;

Precisamos adicionar alguns valores padrão à classe; portanto, crie um novo construtor e defina os valores padrão conforme mostrado a seguir:

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

Execute as migrações!

Agora é hora de criar e executar as migrações, criando novas tabelas e colunas no seu banco de dados para refletir essas entidades recém-criadas.

No Terminal, execute:

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

Criar DataFixtures

Precisamos criar alguns dados de teste predefinidos para a OnCall tabela do banco de dados para determinar quem está de plantão em um determinado horário. Para isso, execute o comando a seguir e siga as instruções listadas:

php bin/console make:fixture

Ao digitar o nome OnCallFixtures criará um arquivo dentro de API/src/DataFixtures chamado OnCallFixtures.php. Substitua o conteúdo desse arquivo pelo seguinte:

<?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 executar seus scripts para que tenhamos um usuário e um registro de plantão! No seu terminal, digite:

php bin/console doctrine:fixtures:load

Criar formulário

Ao processar uma solicitação de API para gerar um alerta, precisamos validar os dados inseridos para garantir que correspondam ao esperado. No Symfony, a maneira mais fácil de fazer isso é usando um Form. Com um Form, podemos definir quais valores esperamos receber e quaisquer restrições a esses valores. Comece executando o comando abaixo:

php bin/console make:form

Siga as instruções, conforme mostrado na imagem:

Creating an Alert Form Type

Agora, abra o arquivo recém-criado AlertType.php arquivo localizado em src/Form/ e substitua o conteúdo do arquivo 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,
        ]);
    }
}

O novo código que você adicionou à AlertType classe acrescenta mais restrições e requisitos aos dois campos deste formulário, title e description, para garantir que tenham um comprimento mínimo e não estejam em branco.

Crie um aplicativo da Vonage

É necessária uma classe de utilitário para lidar com as solicitações da API da Vonage ao enviar mensagens SMS e fazer chamadas de voz.

Em API/src, crie um novo diretório chamado Util, juntamente com um novo arquivo dentro desse novo diretório chamado VonageUtil.php

Você já salvou suas credenciais da Vonage no .env arquivo apresentado anteriormente neste tutorial e vai utilizá-las nesta nova classe PHP.

No novo arquivo, adicione o seguinte 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;
    }
}

No momento, esse código inicializa uma nova classe PHP e cria um novo cliente para a API da Vonage, utilizando o wrapper Symfony da Vonage para o SDK do PHP.

Em seguida, dentro dessa classe, você vai precisar adicionar duas novas funções, que serão responsáveis por enviar a solicitação à API para enviar um SMS ou fazer uma chamada de voz. Adicione as duas seguintes:

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

Criar o controlador de webhook

Antes de criar o controlador, vamos precisar de uma função de Repositório para extrair dados específicos do banco de dados. Abra o OnCallRepository.php encontrada em src/Repository. Dentro da classe, abaixo da __construct() função, adicione a nova função findCurrentOnCall que localizará o usuário atual na chamada.

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

Criamos a funcionalidade para extrair os dados. A seguir, vamos criar um controlador para lidar com quaisquer solicitações e extrair os dados.
Primeiro, no Terminal, execute o seguinte:

php bin/console make:controller

Quando for solicitado o nome do seu controlador, digite WebhookController.

Abra o arquivo recém-criado: API/src/Controller/WebhookController.php.

Vamos usar todas as classes a seguir, então vamos nos certificar de incluí-las desde o início. No início do arquivo, logo abaixo de namespace App\Controller; , adicione o seguinte:

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;

Sua classe precisa de um construtor para que o Symfony injete as classes EntityManager e VonageUtil. No início da sua classe, adicione:

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

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

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

Agora substitua a index() função pelo código abaixo para criar novos alertas. Essa nova função processa o corpo da solicitação POST, cria esses dados como um novo Alert e passa esse alerta para o formulário para validar os valores. Se tudo estiver conforme o esperado, ela criará um novo UserAlert, com a pessoa que está de plantão no momento como a destinatária do 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);
    }

Você deve ter notado que a função $this->getErrorMessages() é chamada no final, mas sua classe ainda não a possui. Você precisará adicionar essa função a seguir. Ela recuperará todos os erros do formulário encontrados quando o endpoint for acionado, mas alguns dados estão faltando. Abaixo do seu index() método, adicione o seguinte:

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

Chegamos a um ponto em que já podemos testar isso!

Teste a autenticação

Nesta parte do tutorial, há dois endpoints que podemos testar com nossa API; portanto, com o Docker ainda em execução em segundo plano, faça uma POST solicitação para http://localhost:8080/api/login_check com o corpo JSON:

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

A resposta será um objeto JSON com uma chave token, e o valor é um token JWT.

A imagem abaixo mostra um exemplo de como fazer isso com o Postman:

An example of authenticating with Postman

Teste de geração de um alerta

Neste exemplo, não é necessário estar autenticado para gerar um alerta; portanto, não é preciso usar o JWT do exemplo anterior.

Para gerar um alerta, atualize o campo URL: http://localhost:8080/webhooks/raise_alert, mantenha o método como uma POST request e o corpo JSON com:

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

A resposta será um array vazio e o código de status HTTP 201 (criado). Você pode ver um exemplo dessa solicitação no Postman na imagem abaixo:

An example of raising an alert with Postman

Como lidar com um alerta

O componente Workflow do Symfony permite definir um ciclo de vida pelo qual seu objeto pode passar, com seus respectivos status. Cada etapa pela qual seu objeto pode passar é chamada de “place”, e as transições definem a ação que o objeto precisa realizar para passar de um “place” para outro.

Os fluxos de trabalho permitirão que você defina em quais etapas seu alerta pode se encontrar para passar raised até a última etapa, que pode ser cancelled ou completed.

Abra o workflow.yaml arquivo localizado em config/packages/ e substitua o conteúdo pelo exemplo abaixo:

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

Agora é necessário um controlador para lidar com todas as solicitações da API relacionadas a Alerts. Portanto, execute o comando abaixo para começar a criar nosso novo AlertsApiController:

php bin/console make:controller

Quando for solicitado o nome de um controlador, digite AlertsApiController. Esse comando criará um novo AlertsApiController.php arquivo dentro de src/Controllers. Então, abra esse novo arquivo.

Vamos usar todas as classes a seguir, então vamos nos certificar de incluí-las desde o início. No início do arquivo, logo abaixo de namespace App\Controller; , adicione o seguinte:

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

Adicione seu roteamento baseado em classe conforme mostrado no exemplo abaixo, de modo que todas as rotas dentro dessa classe sejam precedidas pelo prefixo /api/alerts/:

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

Este controlador utilizará o $workflowRegistry e $entityManager em vários pontos desta classe; portanto, para evitar reescrever o código em vários lugares, vamos colocá-los dentro do construtor. Adicione o código a seguir no início da sua classe:

    /** Registry */
    private $workflowRegistry;

    /** EntityManagerInterface */
    private $entityManager;

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

Quando o controlador foi criado, uma função chamada index() foi adicionada automaticamente. Não vamos precisar dela para este projeto, então exclua essa função.

Agora vamos criar nosso listAction() que irá recuperar todos os alertas do banco de dados e retorná-los como uma resposta JSON. Adicione o listAction() ao seu controlador, conforme mostrado abaixo:

    /**
     * @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 seguir, vamos criar readAction() para recuperar um alerta pelo ID do banco de dados e retorná-lo como uma resposta JSON. Adicione readAction() ao seu controlador, conforme mostrado abaixo:

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

Vamos criar nosso acceptAction(), que irá localizar um alerta pelo ID no banco de dados; se for encontrado, ele tentará alterar o status desse alerta de pending para accepted. A resposta será um JSON vazio com o código de status HTTP 200.
Adicione o acceptAction() ao seu controlador, conforme mostrado abaixo:

    /**
     * @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 seguir, vamos criar nosso completeAction(), que irá localizar um alerta pelo ID no banco de dados; se for encontrado, ele tentará alterar o status desse alerta de accepted para completed. A resposta será um JSON vazio com o código de status HTTP 200.
Adicione o completeAction() ao seu controlador, conforme mostrado abaixo:

    /**
     * @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 fim, vamos criar nosso cancelAction(), que irá localizar um alerta pelo ID no banco de dados; caso encontre um, tentará alterar o status desse alerta de accepted ou pending para cancelled. A resposta será um JSON vazio com o código de status HTTP 200.
Adicione o cancelAction() ao seu controlador, conforme mostrado abaixo:

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

Resumindo, adicionamos uma configuração ao nosso projeto que controla o fluxo dos nossos alertas ao longo de seu ciclo de vida. Em seguida, criamos um controlador de API que nos permitirá recuperar uma lista dos nossos alertas, recuperar um alerta específico, aceitar, recusar, cancelar ou concluir os alertas, dependendo de seu status.

Criar o comando de escalonamento

E se a mensagem SMS não tiver sido recebida? Ou se tiver sido ignorada?! Bem, não se preocupe! O próximo passo é implementar um comando do Symfony que será executado como um agendador de tarefas baseado em tempo (tarefa Cron) e escalará todos os alertas com mais de 10 minutos.

Antes de criar esse comando, precisaremos adicionar um método ao repositório para recuperar os alertas que exigem escalonamento. Abra seu UserAlertRepository.php arquivo dentro de API/src/Repository/.

No início deste arquivo, adicione mais algumas bibliotecas de terceiros para importação:

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

Em seguida, adicione o método do repositório para recuperar todos os alertas para os quais foi enviado um SMS há mais de 10 minutos, mas que ainda estão no status 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 novo comando do Symfony irá escalar todos os alertas recuperados. Para criá-lo, execute o seguinte comando no seu Terminal:

php bin/console make:command

Quando for solicitado o nome do comando, digite app:escalate-alert, o que cria um novo arquivo chamado EscalateAlertCommand.php dentro de API/src/Command. Abra esse novo arquivo.

Vamos usar todas as classes a seguir, então vamos nos certificar de incluí-las desde o início. No início do arquivo, logo abaixo de namespace App\Command; , adicione o seguinte:

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

A classe precisa que dois objetos sejam injetados nela: o VonageUtil e EntityManagerInterface. Com o Symfony, a maneira mais fácil de fazer isso é por meio do construtor. No início da sua classe, adicione a seguinte funcionalidade:

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

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

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

        parent::__construct();
    }

Agora é hora de implementar a funcionalidade desse comando. Ele irá recuperar todos os alertas para os quais um SMS foi enviado há mais de 10 minutos, mas que ainda estejam com raised status. Se houver algum desses, ele recuperará o usuário atribuído ao alerta e enviará a ele uma notificação por chamada de voz com conversão de texto em fala. Substitua a funcionalidade atual 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;

Teste a API

Seu usuário precisa se autenticar para testar esses novos endpoints.
Primeiro, certifique-se de obter seu token JWT enviando uma POST solicitação para http://localhost:8080/api/login_check com as credenciais dos usuários configurados.

Depois de copiar seu JWT, atualize o tipo para GET solicitação e a URL para http://localhost:8080/api/alerts. Você precisa fornecer um cabeçalho com a chave Authorisation e o valor como Bearer <JWT> substituindo <JWT> pelo seu token.

O endpoint “Alerts” da lista retorna um array JSON, que você pode ver no exemplo do Postman abaixo:

An example of listing alerts through Postman

Vamos deixar esse alerta como está e usá-lo mais tarde, ao testar o aplicativo móvel.

Você desenvolveu uma API; agora é hora de criar o aplicativo móvel.

Crie o aplicativo móvel

Atualização config.json onde o valor do APIURL é a URL do ngrok que você salvou anteriormente.

Abra uma nova janela do Terminal e execute os seguintes comandos:

cd MobileApp npm install expo start

Após alguns instantes, um navegador da web é aberto. No lado esquerdo, há várias opções para executar o aplicativo, seja no seu dispositivo móvel, no simulador do iOS ou no simulador do Android. Escolha a opção que for mais conveniente para você e, quando o aplicativo for iniciado, a tela de login será a primeira tela que você verá.

As credenciais do usuário predefinido no banco de dados são:

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

Conforme mostrado na imagem abaixo:

Example of a login screen on a mobile phone

No momento, um login bem-sucedido não faz nada! Precisamos implementar mais telas primeiro, mas para ter certeza de que seu login estava correto, verifique o Terminal onde você executou expo start. Você deve ver a linha: You Successfully logged in!.

API de alertas

Exibindo uma lista de alertas

Dentro do API diretório, crie um novo arquivo chamado alerts.js.
Adicione o exemplo abaixo, que importa o client.js arquivo para utilizar a funcionalidade de getClient().
Essa nova função chamada getAlerts() faz uma solicitação à API no endpoint /api/alerts. Já que estamos aqui, podemos adicionar as outras chamadas à API: aceitar, concluir e cancelar alertas.

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

Agora que já temos a funcionalidade para receber os alertas, vamos criar o componente AlertsScreen. Crie um novo arquivo dentro de components/ chamado 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;

Agora temos uma AlertsScreen e alguns estilos. Vamos adicionar algo a essa classe 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>
    );
  }

Ok, isso está exibindo nossa página. Mas não está recuperando nenhuma informação e não nos diz o que fazer a seguir!

Acima do seu renderItem() método, adicione o seguinte:

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

Exibindo um alerta específico

Crie um novo arquivo em components chamado AlertScreen.js, que mostra o alerta específico pelo 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;

No momento, seu aplicativo não possui nenhuma instrução sobre como exibir essas duas novas telas que você criou. Em navigation/MainStackNavigator.js abaixo import Login, adicione as duas linhas a seguir:

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

Em seguida, abaixo do Login Stack.Screen, adicione duas novas telas:

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

De volta ao seu LoginScreen.js arquivo, encontre a linha que mostra: console.log('You Successfully logged in!'); e adicione o trecho abaixo para redirecionar o usuário após um login bem-sucedido.

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

Testes

Para testar este aplicativo no Terminal, certifique-se de ter acessado o MobileApp e execute o seguinte comando:

expo start

Após alguns instantes, um navegador da web deve ser aberto. No lado esquerdo, há várias opções para executar o aplicativo, seja no seu dispositivo móvel, no simulador do iOS ou no simulador do Android. Escolha a opção que for mais conveniente para você. Quando o aplicativo for iniciado, a primeira tela que você verá é a tela de login.

As credenciais do usuário predefinido no banco de dados são:

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

Após o login bem-sucedido, a próxima tela exibida é a tela de Alertas. No entanto, ela estará vazia neste momento, pois não há nenhum alerta no banco de dados.

An example of raising an alert with Postman

Agora, tente novamente fazer login no seu aplicativo móvel. Você verá o novo alerta e também poderá clicar nele para ser direcionado a uma tela que exibe mais informações.

Você também pode dar continuidade a este alerta, seja para aceitá-lo ou cancelá-lo.

Conclusão

Neste tutorial, aprendemos como criar uma API usando um framework de PHP chamado Symfony. Também desenvolvemos um aplicativo móvel usando o React Native. As APIs da Vonage nos permitiram enviar notificações por SMS e chamadas de voz com a funcionalidade Text-To-Speech. Ao aplicar tudo isso em conjunto, criamos um aplicativo de plantão funcional para que desenvolvedores ou administradores de sistema sejam alertados quando algo der errado. Ter um webhook nos permite integrar nosso sistema de plantão a vários serviços para abranger o maior número possível deles.

A seguir, apresentamos alguns outros tutoriais que elaboramos sobre a implementação da Voice API da Vonage em projetos:

Como sempre, se você tiver alguma dúvida, sugestão ou ideia que gostaria de compartilhar com a comunidade, sinta-se à vontade para participar do nosso espaço de trabalho da Comunidade no Slack. Adoraria saber como foi sua experiência com este tutorial e como está o andamento do seu projeto.

Compartilhar:

https://a.storyblok.com/f/270183/250x250/b052219541/greg-holmes.png
Greg HolmesEx-funcionários da Vonage

Ex-educador de desenvolvedores na @Vonage. Tenho formação em PHP, mas não me limito a uma única linguagem. Sou um jogador ávido e entusiasta do Raspberry Pi. Costumo praticar escalada em rocha em centros de escalada indoor.