command for sending bulk sms with tests

This commit is contained in:
Julien Fastré 2022-06-14 01:15:58 +02:00
parent 4c0fef4f44
commit 28c952521f
15 changed files with 616 additions and 44 deletions

View File

@ -0,0 +1,41 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\Command;
use Chill\CalendarBundle\Service\ShortMessageNotification\BulkCalendarShortMessageSender;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class SendShortMessageOnEligibleCalendar extends Command
{
private BulkCalendarShortMessageSender $messageSender;
public function __construct(BulkCalendarShortMessageSender $messageSender)
{
parent::__construct();
$this->messageSender = $messageSender;
}
public function getName()
{
return 'chill:calendar:send-short-messages';
}
protected function execute(InputInterface $input, OutputInterface $output)
{
$this->messageSender->sendBulkMessageToEligibleCalendars();
return 0;
}
}

View File

@ -567,9 +567,11 @@ class Calendar implements TrackCreationInterface, TrackUpdateInterface
return $this;
}
public function setSmsStatus(string $smsStatus): void
public function setSmsStatus(string $smsStatus): self
{
$this->smsStatus = $smsStatus;
return $this;
}
public function setStartDate(DateTimeImmutable $startDate): self

View File

@ -100,17 +100,22 @@ class CalendarRepository implements ObjectRepository
$qb->where(
$qb->expr()->andX(
$qb->expr()->eq('c.sendSMS', "'TRUE'"),
$qb->expr()->eq('c.sendSMS', ':true'),
$qb->expr()->gte('c.startDate', ':startDate'),
$qb->expr()->lt('c.startDate', ':endDate'),
$qb->expr()->in('c.smsStatus', ':statuses')
$qb->expr()->orX(
$qb->expr()->eq('c.smsStatus', ':pending'),
$qb->expr()->eq('c.smsStatus', ':cancel_pending')
)
)
);
$qb->setParameters([
'true' => true,
'startDate' => $startDate,
'endDate' => $endDate,
'statuses' => [Calendar::SMS_PENDING, Calendar::SMS_CANCEL_PENDING],
'pending' => Calendar::SMS_PENDING,
'cancel_pending' => Calendar::SMS_CANCEL_PENDING,
]);
return $qb;

View File

@ -0,0 +1 @@
Votre RDV avec votre travailleur social {{ calendar.mainUser.label }} prévu le {{ calendar.startDate|format_date('short', locale='fr') }} à {{ calendar.startDate|format_time('short', locale='fr') }} est annulé. {% if calendar.mainUser.mainLocation is not null and calendar.mainUser.mainLocation.phonenumber1 is not null %} En cas de question, appelez-nous au {{ calendar.mainUser.mainLocation.phonenumber1|chill_format_phonenumber }}.{% endif %}

View File

@ -0,0 +1,64 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\Service\ShortMessageNotification;
use Chill\CalendarBundle\Entity\Calendar;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\MessageBusInterface;
class BulkCalendarShortMessageSender
{
private EntityManagerInterface $em;
private LoggerInterface $logger;
private MessageBusInterface $messageBus;
private ShortMessageForCalendarBuilderInterface $messageForCalendarBuilder;
private CalendarForShortMessageProvider $provider;
public function __construct(CalendarForShortMessageProvider $provider, EntityManagerInterface $em, LoggerInterface $logger, MessageBusInterface $messageBus, ShortMessageForCalendarBuilderInterface $messageForCalendarBuilder)
{
$this->provider = $provider;
$this->em = $em;
$this->logger = $logger;
$this->messageBus = $messageBus;
$this->messageForCalendarBuilder = $messageForCalendarBuilder;
}
public function sendBulkMessageToEligibleCalendars()
{
$countCalendars = 0;
$countSms = 0;
foreach ($this->provider->getCalendars(new DateTimeImmutable('now')) as $calendar) {
$smses = $this->messageForCalendarBuilder->buildMessageForCalendar($calendar);
foreach ($smses as $sms) {
$this->messageBus->dispatch($sms);
++$countSms;
}
$this->em
->createQuery('UPDATE ' . Calendar::class . ' c SET c.smsStatus = :smsStatus WHERE c.id = :id')
->setParameters(['smsStatus' => Calendar::SMS_SENT, 'id' => $calendar->getId()])
->execute();
++$countCalendars;
$this->em->refresh($calendar);
}
$this->logger->info(__CLASS__ . 'a bulk of messages was sent', ['count_calendars' => $countCalendars, 'count_sms' => $countSms]);
}
}

View File

@ -0,0 +1,69 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\Service\ShortMessageNotification;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\CalendarBundle\Repository\CalendarRepository;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use function count;
class CalendarForShortMessageProvider
{
private CalendarRepository $calendarRepository;
private EntityManagerInterface $em;
private RangeGeneratorInterface $rangeGenerator;
public function __construct(
CalendarRepository $calendarRepository,
EntityManagerInterface $em,
RangeGeneratorInterface $rangeGenerator
) {
$this->calendarRepository = $calendarRepository;
$this->em = $em;
$this->rangeGenerator = $rangeGenerator;
}
/**
* Generate calendars instance.
*
* Warning: this method takes care of clearing the EntityManager at regular interval
*
* @return iterable|Calendar[]
*/
public function getCalendars(DateTimeImmutable $at): iterable
{
['startDate' => $startDate, 'endDate' => $endDate] = $this->rangeGenerator
->generateRange($at);
$offset = 0;
$batchSize = 10;
$calendars = $this->calendarRepository
->findByNotificationAvailable($startDate, $endDate, $batchSize, $offset);
do {
foreach ($calendars as $calendar) {
++$offset;
yield $calendar;
}
$this->em->clear();
$calendars = $this->calendarRepository
->findByNotificationAvailable($startDate, $endDate, $batchSize, $offset);
} while (count($calendars) === $batchSize);
}
}

View File

@ -12,26 +12,22 @@ declare(strict_types=1);
namespace Chill\CalendarBundle\Service\ShortMessageNotification;
use Chill\CalendarBundle\Entity\Calendar;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Chill\MainBundle\Service\ShortMessage\ShortMessage;
use Symfony\Component\Templating\EngineInterface;
class DefaultShortMessageForCalendarBuilder implements ShortMessageForCalendarBuilderInterface
{
private ?array $config = null;
private EngineInterface $engine;
public function __construct(
ParameterBagInterface $parameterBag,
EngineInterface $engine
) {
$this->config = $parameterBag->get('chill_calendar.short_messages');
$this->engine = $engine;
}
public function buildMessageForCalendar(Calendar $calendar): array
{
if (null === $this->config || true !== $calendar->getSendSMS()) {
if (true !== $calendar->getSendSMS()) {
return [];
}
@ -42,10 +38,19 @@ class DefaultShortMessageForCalendarBuilder implements ShortMessageForCalendarBu
continue;
}
$toUsers[] = new \Chill\MainBundle\Service\ShortMessage\ShortMessage(
if (Calendar::SMS_PENDING === $calendar->getSmsStatus()) {
$toUsers[] = new ShortMessage(
$this->engine->render('@ChillCalendar/CalendarShortMessage/short_message.txt.twig', ['calendar' => $calendar]),
$person->getMobilenumber()
$person->getMobilenumber(),
ShortMessage::PRIORITY_LOW
);
} elseif (Calendar::SMS_CANCEL_PENDING === $calendar->getSmsStatus()) {
$toUsers[] = new ShortMessage(
$this->engine->render('@ChillCalendar/CalendarShortMessage/short_message_canceled.txt.twig', ['calendar' => $calendar]),
$person->getMobilenumber(),
ShortMessage::PRIORITY_LOW
);
}
}
return $toUsers;

View File

@ -1,28 +0,0 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\Service\ShortMessageNotification;
use Chill\CalendarBundle\Repository\CalendarRepository;
use Symfony\Component\Messenger\MessageBusInterface;
class Generator
{
private CalendarRepository $calendarRepository;
private MessageBusInterface $messageBus;
private RangeGeneratorInterface $rangeGenerator;
public function generateShortMessages(): void
{
}
}

View File

@ -0,0 +1,128 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\Tests\Service\ShortMessageNotification;
use ArrayIterator;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\CalendarBundle\Service\ShortMessageNotification\BulkCalendarShortMessageSender;
use Chill\CalendarBundle\Service\ShortMessageNotification\CalendarForShortMessageProvider;
use Chill\CalendarBundle\Service\ShortMessageNotification\ShortMessageForCalendarBuilderInterface;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Service\ShortMessage\ShortMessage;
use Chill\MainBundle\Test\PrepareUserTrait;
use Chill\PersonBundle\DataFixtures\Helper\PersonRandomHelper;
use DateInterval;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use libphonenumber\PhoneNumberUtil;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Psr\Log\NullLogger;
use stdClass;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
/**
* @internal
* @coversNothing
*/
final class BulkCalendarShortMessageSenderTest extends KernelTestCase
{
use PersonRandomHelper;
use PrepareUserTrait;
use ProphecyTrait;
private array $toDelete = [];
protected function setUp(): void
{
parent::setUp();
self::bootKernel();
}
protected function tearDown(): void
{
self::bootKernel();
$em = self::$container->get(EntityManagerInterface::class);
foreach ($this->toDelete as [$entity, $id]) {
$entity = $em->find($entity, $id);
$em->remove($entity);
}
$em->flush();
}
public function testSendBulkMessageToEligibleCalendar()
{
$em = self::$container->get(EntityManagerInterface::class);
$calendar = new Calendar();
$calendar
->addPerson($this->getRandomPerson($em))
->setMainUser($user = $this->prepareUser([]))
->setStartDate(new DateTimeImmutable('now'))
->setEndDate($calendar->getStartDate()->add(new DateInterval('PT30M')))
->setSendSMS(true);
$user->setUsername(uniqid());
$user->setEmail(uniqid() . '@gmail.com');
$calendar->getPersons()->first()->setAcceptSMS(true);
// hack to prevent side effect with messages
$calendar->preventEnqueueChanges = true;
$em->persist($user);
//$this->toDelete[] = [User::class, $user->getId()];
$em->persist($calendar);
//$this->toDelete[] = [Calendar::class, $calendar->getId()];
$em->flush();
$provider = $this->prophesize(CalendarForShortMessageProvider::class);
$provider->getCalendars(Argument::type(DateTimeImmutable::class))
->willReturn(new ArrayIterator([$calendar]));
$messageBuilder = $this->prophesize(ShortMessageForCalendarBuilderInterface::class);
$messageBuilder->buildMessageForCalendar(Argument::type(Calendar::class))
->willReturn(
[
new ShortMessage(
'content',
PhoneNumberUtil::getInstance()->parse('+32470123456', 'BE'),
ShortMessage::PRIORITY_MEDIUM
),
]
);
$bus = $this->prophesize(MessageBusInterface::class);
$bus->dispatch(Argument::type(ShortMessage::class))
->willReturn(new Envelope(new stdClass()))
->shouldBeCalledTimes(1);
$bulk = new BulkCalendarShortMessageSender(
$provider->reveal(),
$em,
new NullLogger(),
$bus->reveal(),
$messageBuilder->reveal()
);
$bulk->sendBulkMessageToEligibleCalendars();
$em->clear();
$calendar = $em->find(Calendar::class, $calendar->getId());
$this->assertEquals(Calendar::SMS_SENT, $calendar->getSmsStatus());
}
}

View File

@ -0,0 +1,103 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\Tests\Service\ShortMessageNotification;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\CalendarBundle\Repository\CalendarRepository;
use Chill\CalendarBundle\Service\ShortMessageNotification\CalendarForShortMessageProvider;
use Chill\CalendarBundle\Service\ShortMessageNotification\DefaultRangeGenerator;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use function count;
/**
* @internal
* @coversNothing
*/
final class CalendarForShortMessageProviderTest extends TestCase
{
use ProphecyTrait;
public function testGetCalendars()
{
$calendarRepository = $this->prophesize(CalendarRepository::class);
$calendarRepository->findByNotificationAvailable(
Argument::type(DateTimeImmutable::class),
Argument::type(DateTimeImmutable::class),
Argument::type('int'),
Argument::exact(0)
)->will(static function ($args) {
return array_fill(0, $args[2], new Calendar());
})->shouldBeCalledTimes(1);
$calendarRepository->findByNotificationAvailable(
Argument::type(DateTimeImmutable::class),
Argument::type(DateTimeImmutable::class),
Argument::type('int'),
Argument::not(0)
)->will(static function ($args) {
return array_fill(0, $args[2] - 1, new Calendar());
})->shouldBeCalledTimes(1);
$em = $this->prophesize(EntityManagerInterface::class);
$em->clear()->shouldBeCalled();
$provider = new CalendarForShortMessageProvider(
$calendarRepository->reveal(),
$em->reveal(),
new DefaultRangeGenerator()
);
$calendars = iterator_to_array($provider->getCalendars(new DateTimeImmutable('now')));
$this->assertGreaterThan(1, count($calendars));
$this->assertLessThan(100, count($calendars));
$this->assertContainsOnly(Calendar::class, $calendars);
}
public function testGetCalendarsWithOnlyOneCalendar()
{
$calendarRepository = $this->prophesize(CalendarRepository::class);
$calendarRepository->findByNotificationAvailable(
Argument::type(DateTimeImmutable::class),
Argument::type(DateTimeImmutable::class),
Argument::type('int'),
Argument::exact(0)
)->will(static function ($args) {
return array_fill(0, 1, new Calendar());
})->shouldBeCalledTimes(1);
$calendarRepository->findByNotificationAvailable(
Argument::type(DateTimeImmutable::class),
Argument::type(DateTimeImmutable::class),
Argument::type('int'),
Argument::not(0)
)->will(static function ($args) {
return [];
})->shouldBeCalledTimes(1);
$em = $this->prophesize(EntityManagerInterface::class);
$em->clear()->shouldBeCalled();
$provider = new CalendarForShortMessageProvider(
$calendarRepository->reveal(),
$em->reveal(),
new DefaultRangeGenerator()
);
$calendars = iterator_to_array($provider->getCalendars(new DateTimeImmutable('now')));
$this->assertEquals(1, count($calendars));
$this->assertContainsOnly(Calendar::class, $calendars);
}
}

View File

@ -83,7 +83,12 @@ final class DefaultRangeGeneratorTest extends TestCase
['startDate' => $actualStartDate, 'endDate' => $actualEndDate] = $generator->generateRange($date);
if (null === $startDate) {
$this->assertNull($actualStartDate);
$this->assertNull($actualEndDate);
} else {
$this->assertEquals($startDate->format(DateTimeImmutable::ATOM), $actualStartDate->format(DateTimeImmutable::ATOM));
$this->assertEquals($endDate->format(DateTimeImmutable::ATOM), $actualEndDate->format(DateTimeImmutable::ATOM));
}
}
}

View File

@ -0,0 +1,108 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\CalendarBundle\Tests\Service\ShortMessageNotification;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\CalendarBundle\Service\ShortMessageNotification\DefaultShortMessageForCalendarBuilder;
use Chill\MainBundle\Entity\Location;
use Chill\MainBundle\Entity\User;
use Chill\PersonBundle\Entity\Person;
use DateInterval;
use DateTimeImmutable;
use libphonenumber\PhoneNumberFormat;
use libphonenumber\PhoneNumberUtil;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\Templating\EngineInterface;
/**
* @internal
* @coversNothing
*/
final class DefaultShortMessageForCalendarBuilderTest extends TestCase
{
use ProphecyTrait;
private PhoneNumberUtil $phoneNumberUtil;
protected function setUp(): void
{
$this->phoneNumberUtil = PhoneNumberUtil::getInstance();
}
public function testBuildMessageForCalendar()
{
$calendar = new Calendar();
$calendar
->setStartDate(new DateTimeImmutable('now'))
->setEndDate($calendar->getStartDate()->add(new DateInterval('PT30M')))
->setMainUser($user = new User())
->addPerson($person = new Person())
->setSendSMS(false);
$user
->setLabel('Alex')
->setMainLocation($location = new Location());
$location->setName('LOCAMAT');
$person
->setMobilenumber($this->phoneNumberUtil->parse('+32470123456', 'BE'))
->setAcceptSMS(false);
$engine = $this->prophesize(EngineInterface::class);
$engine->render(Argument::exact('@ChillCalendar/CalendarShortMessage/short_message.txt.twig'), Argument::withKey('calendar'))
->willReturn('message content')
->shouldBeCalledTimes(1);
$engine->render(Argument::exact('@ChillCalendar/CalendarShortMessage/short_message_canceled.txt.twig'), Argument::withKey('calendar'))
->willReturn('message canceled')
->shouldBeCalledTimes(1);
$builder = new DefaultShortMessageForCalendarBuilder(
$engine->reveal()
);
// if the calendar should not send sms
$sms = $builder->buildMessageForCalendar($calendar);
$this->assertCount(0, $sms);
// if the person do not accept sms
$calendar->setSendSMS(true);
$sms = $builder->buildMessageForCalendar($calendar);
$this->assertCount(0, $sms);
// person accepts sms
$person->setAcceptSMS(true);
$sms = $builder->buildMessageForCalendar($calendar);
$this->assertCount(1, $sms);
$this->assertEquals(
'+32470123456',
$this->phoneNumberUtil->format($sms[0]->getPhoneNumber(), PhoneNumberFormat::E164)
);
$this->assertEquals('message content', $sms[0]->getContent());
$this->assertEquals('low', $sms[0]->getPriority());
// if the calendar is canceled
$calendar
->setSmsStatus(Calendar::SMS_SENT)
->setStatus(Calendar::STATUS_CANCELED);
$sms = $builder->buildMessageForCalendar($calendar);
$this->assertCount(1, $sms);
$this->assertEquals(
'+32470123456',
$this->phoneNumberUtil->format($sms[0]->getPhoneNumber(), PhoneNumberFormat::E164)
);
$this->assertEquals('message canceled', $sms[0]->getContent());
$this->assertEquals('low', $sms[0]->getPriority());
}
}

View File

@ -0,0 +1,33 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\Migrations\Calendar;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20220613202636 extends AbstractMigration
{
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_calendar.calendar DROP smsStatus');
}
public function getDescription(): string
{
return 'Add sms status on calendars';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_calendar.calendar ADD smsStatus TEXT DEFAULT \'sms_pending\' NOT NULL');
}
}

View File

@ -15,6 +15,10 @@ use libphonenumber\PhoneNumber;
class ShortMessage
{
public const PRIORITY_LOW = 'low';
public const PRIORITY_MEDIUM = 'medium';
private string $content;
private PhoneNumber $phoneNumber;

View File

@ -0,0 +1,32 @@
<?php
/**
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Chill\MainBundle\Service\ShortMessage;
use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
/**
* @AsMessageHandler
*/
class ShortMessageHandler implements MessageHandlerInterface
{
private ShortMessageTransporterInterface $messageTransporter;
public function __construct(ShortMessageTransporterInterface $messageTransporter)
{
$this->messageTransporter = $messageTransporter;
}
public function __invoke(ShortMessage $message): void
{
$this->messageTransporter->send($message);
}
}