src/Products/NotificationsBundle/Service/ContactMonitor.php line 138

Open in your IDE?
  1. <?php
  2. namespace Products\NotificationsBundle\Service;
  3. use App\Util\Json;
  4. use Cms\CoreBundle\Util\DateTimeUtils;
  5. use Cms\CoreBundle\Util\Doctrine\EntityManager;
  6. use DateTimeInterface;
  7. use Doctrine\ORM\Mapping\ClassMetadata;
  8. use Doctrine\ORM\QueryBuilder;
  9. use PDO;
  10. use Platform\QueueBundle\Model\AsyncMessage;
  11. use Platform\QueueBundle\Service\AsyncQueueService;
  12. use Products\NotificationsBundle\Entity\AbstractRecipient;
  13. use Products\NotificationsBundle\Entity\ContactAttempts\AppContactAttempt;
  14. use Products\NotificationsBundle\Entity\ContactAttempts\EmailContactAttempt;
  15. use Products\NotificationsBundle\Entity\ContactAttempts\SmsContactAttempt;
  16. use Products\NotificationsBundle\Entity\ContactAttempts\VoiceContactAttempt;
  17. use Products\NotificationsBundle\Entity\Job;
  18. use Products\NotificationsBundle\Entity\Notifications\Channels\ChannelsInterface;
  19. use Products\NotificationsBundle\Entity\Notifications\Channels\TransactionalChannelsInterface;
  20. use Products\NotificationsBundle\Entity\Profile;
  21. use Products\NotificationsBundle\Entity\ProfileContact;
  22. use Products\NotificationsBundle\Entity\ProfileRelationship;
  23. use Products\NotificationsBundle\Entity\Student;
  24. use Products\NotificationsBundle\Util\Reachability;
  25. use Symfony\Component\HttpFoundation\Request;
  26. /**
  27.  * Class ContactMonitor
  28.  * @package Products\NotificationsBundle\Service
  29.  */
  30. final class ContactMonitor
  31. {
  32.     /**
  33.      * Mapping of notifications channel bits to their webhook counterparts.
  34.      */
  35.     public const CHANNEL_WEBHOOKS = [
  36.         ChannelsInterface::CHANNELS__EMAIL => self::EVENTS__WEBHOOKS__EMAIL,
  37.         ChannelsInterface::CHANNELS__SMS => self::EVENTS__WEBHOOKS__SMS,
  38.         ChannelsInterface::CHANNELS__VOICE => self::EVENTS__WEBHOOKS__VOICE,
  39.     ];
  40.     public const EVENTS__WEBHOOKS__EMAIL 'app.notifications.webhooks.email';
  41.     public const EVENTS__WEBHOOKS__SMS 'app.notifications.webhooks.sms';
  42.     public const EVENTS__WEBHOOKS__VOICE 'app.notifications.webhooks.voice';
  43.     /**
  44.      * @var EntityManager
  45.      */
  46.     protected EntityManager $em;
  47.     /**
  48.      * @var AsyncQueueService
  49.      */
  50.     protected AsyncQueueService $async;
  51.     /**
  52.      * @param EntityManager $em
  53.      * @param AsyncQueueService $async
  54.      */
  55.     public function __construct(EntityManager $emAsyncQueueService $async)
  56.     {
  57.         $this->em $em;
  58.         $this->async $async;
  59.     }
  60.     /**
  61.      * @param Request $request
  62.      * @return array
  63.      */
  64.     public function serializeRequest(Request $request): array
  65.     {
  66.         return [
  67.             'host' => $request->getHost(),
  68.             'path' => $request->getPathInfo(),
  69.             'method' => $request->getRealMethod(),
  70.             'content' => $request->getContent(),
  71.             'headers' => $request->headers->all(),
  72.             'query' => $request->query->all(),
  73.             'attributes' => $request->attributes->all(),
  74.             'request' => $request->request->all(),
  75.             'server' => $request->server->all(),
  76.         ];
  77.     }
  78.     /**
  79.      * @param int $channel
  80.      * @param Request $request
  81.      */
  82.     public function queueOrHandleWebhook(int $channelRequest $request): void
  83.     {
  84.         try {
  85.             $this->queueWebhook($channel$request);
  86.         } catch (\Exception) {
  87.             $this->handleWebhook($channel$request);
  88.         }
  89.     }
  90.     /**
  91.      * @param int $channel
  92.      * @param Request|array $request
  93.      */
  94.     public function handleWebhook(int $channelRequest|array $request): void
  95.     {
  96.         // make sure request is of expected type, normalize request object if passed
  97.         if ($request instanceof Request) {
  98.             $request $this->serializeRequest($request);
  99.         }
  100.         if ( ! is_array($request)) {
  101.             throw new \LogicException();
  102.         }
  103.         // branch on channel type
  104.         switch ($channel) {
  105.             case ChannelsInterface::CHANNELS__EMAIL:
  106.                 $this->handleEmail($request);
  107.                 break;
  108.             case ChannelsInterface::CHANNELS__SMS:
  109.                 $this->handleSms($request);
  110.                 break;
  111.             case ChannelsInterface::CHANNELS__VOICE:
  112.                 $this->handleVoice($request);
  113.                 break;
  114.             case ChannelsInterface::CHANNELS__APP:
  115.                 $this->handleApp($request);
  116.                 break;
  117.         }
  118.         throw new \LogicException();
  119.     }
  120.     /**
  121.      * @param int $channel
  122.      * @param Request $request
  123.      */
  124.     public function queueWebhook(int $channelRequest $request): void
  125.     {
  126.         $this->async->send(
  127.             null,
  128.             new AsyncMessage(
  129.                 null,
  130.                 self::CHANNEL_WEBHOOKS[$channel],
  131.                 $this->serializeRequest($request),
  132.                 AsyncMessage::PRIORITY__LOWER,
  133.             )
  134.         );
  135.     }
  136.     /**
  137.      * @param string $type
  138.      * @param string $externalId
  139.      * @return array{id: int, job: int, recipient: int}
  140.      */
  141.     protected function loadAttempt(string $typestring $externalId): array
  142.     {
  143.         $attempt $this->em->createQueryBuilder()
  144.             ->select('attempt.id AS id, IDENTITY(attempt.job) AS job, IDENTITY(attempt.recipient) AS recipient')
  145.             ->from($type'attempt')
  146.             ->andWhere('attempt.externalId = :id')
  147.             ->setParameter('id'$externalId)
  148.             ->getQuery()
  149.             ->getScalarResult();
  150.         if (count($attempt) !== 1) {
  151.             throw new \RuntimeException();
  152.         }
  153.         $attempt $attempt[0];
  154.         if ( ! $attempt['id'] || ! $attempt['job'] || ! $attempt['recipient']) {
  155.             throw new \RuntimeException();
  156.         }
  157.         return $attempt;
  158.     }
  159.     /**
  160.      * @param array $request
  161.      */
  162.     public function handleApp(array $request): void
  163.     {
  164.         // just double check that we have an id
  165.         if (empty($request['request']['name'])) {
  166.             throw new \RuntimeException();
  167.         }
  168.         // determine some basic attempt stuff
  169.         $attempt $this->loadAttempt(AppContactAttempt::class, $request['request']['name']);
  170.         // run the query
  171.         $this->em->createQueryBuilder()
  172.             ->update(AppContactAttempt::class, 'attempt')
  173.             // set the event name for the status
  174.             ->set('attempt.event'':event')
  175.             ->setParameter('event'sprintf('attempt.%s',  $request['request']['status']))
  176.             // handle the timestamp
  177.             // have to use the server time which is not accurate, but twilio does not give the exact timestamp in the callback
  178.             ->set('attempt.triggeredAt''FROM_UNIXTIME(:timestamp)')
  179.             ->setParameter('timestamp'$request['server']['REQUEST_TIME'])
  180.             // filter by the id of the call
  181.             ->andWhere('attempt.id = :id')
  182.             ->setParameter('id'$attempt['id'])
  183.             // make sure the sequence is after what has already come before
  184.             ->andWhere('(attempt.event IS NULL OR attempt.event IN (:statuses))')
  185.             ->setParameter('statuses'array_slice(
  186.                 AppContactAttempt::STATUSES,
  187.                 0,
  188.                 array_search(
  189.                     sprintf('attempt.%s',  $request['request']['status']),
  190.                     AppContactAttempt::STATUSES
  191.                 )
  192.             ))
  193.             ->getQuery()
  194.             ->execute();
  195.         // do only if our status is not a pending status
  196.         if (in_array(sprintf('attempt.%s',  $request['request']['status']), [...AppContactAttempt::SUCCESSFUL_STATUSES, ...AppContactAttempt::FAILED_STATUSES])) {
  197.             // attach stats update query
  198.             $this->trackItemDelivery(
  199.                 $attempt['job'],
  200.                 ChannelsInterface::CHANNELS__APP,
  201.                 in_array(sprintf('attempt.%s',  $request['request']['status']), AppContactAttempt::SUCCESSFUL_STATUSES)
  202.             );
  203.             // update reachability
  204.             $this->trackReachability(
  205.                 $attempt['recipient'],
  206.                 in_array(sprintf('sms.%s',  $request['request']['MessageStatus']), AppContactAttempt::SUCCESSFUL_STATUSES)
  207.             );
  208.         }
  209.     }
  210.     /**
  211.      * @param array $request
  212.      */
  213.     public function handleSms(array $request): void
  214.     {
  215.         // just double check that we have an id
  216.         if (empty($request['request']['MessageSid'])) {
  217.             throw new \RuntimeException();
  218.         }
  219.         // determine some basic attempt stuff
  220.         $attempt $this->loadAttempt(SmsContactAttempt::class, $request['request']['MessageSid']);
  221.         // run the query
  222.         $qb $this->em->createQueryBuilder()
  223.             ->update(SmsContactAttempt::class, 'attempt')
  224.             // set the event name for the status
  225.             ->set('attempt.event'':event')
  226.             ->setParameter('event'sprintf('sms.%s',  $request['request']['MessageStatus']))
  227.             // handle the timestamp
  228.             // have to use the server time which is not accurate, but twilio does not give the exact timestamp in the callback
  229.             ->set('attempt.triggeredAt''FROM_UNIXTIME(:timestamp)')
  230.             ->setParameter('timestamp'$request['server']['REQUEST_TIME'])
  231.             // filter by the id of the call
  232.             ->andWhere('attempt.id = :id')
  233.             ->setParameter('id'$attempt['id'])
  234.             // make sure the sequence is after what has already come before
  235.             ->andWhere('(attempt.event IS NULL OR attempt.event IN (:statuses))')
  236.             ->setParameter('statuses'array_slice(
  237.                 SmsContactAttempt::STATUSES,
  238.                 0,
  239.                 array_search(
  240.                     sprintf('sms.%s',  $request['request']['MessageStatus']),
  241.                     SmsContactAttempt::STATUSES
  242.                 )
  243.             ));
  244.         if ( ! empty($request['request']['ErrorCode'])) {
  245.             $qb
  246.                 ->set('attempt.code'':errorCode')
  247.                 ->setParameter('errorCode'$request['request']['ErrorCode']);
  248.         }
  249.         $qb
  250.             ->getQuery()
  251.             ->execute();
  252.         // do only if our status is not a pending status
  253.         if (in_array(sprintf('sms.%s',  $request['request']['MessageStatus']), [...SmsContactAttempt::SUCCESSFUL_STATUSES, ...SmsContactAttempt::FAILED_STATUSES])) {
  254.             // attach stats update query
  255.             $this->trackItemDelivery(
  256.                 $attempt['job'],
  257.                 ChannelsInterface::CHANNELS__SMS,
  258.                 in_array(sprintf('sms.%s',  $request['request']['MessageStatus']), SmsContactAttempt::SUCCESSFUL_STATUSES)
  259.             );
  260.             // update reachability
  261.             $this->trackReachability(
  262.                 $attempt['recipient'],
  263.                 in_array(sprintf('sms.%s',  $request['request']['MessageStatus']), SmsContactAttempt::SUCCESSFUL_STATUSES)
  264.             );
  265.         }
  266.     }
  267.     /**
  268.      * @param array $request
  269.      */
  270.     public function handleVoice(array $request): void
  271.     {
  272.         // mapping of callback data fields to entity properties
  273.         static $mapping = [
  274.             'sequenceNumber' => 'SequenceNumber',
  275.             'duration' => 'CallDuration',
  276.             'answer' => 'AnsweredBy',
  277.             'country' => 'ToCountry',
  278.             'state' => 'ToState',
  279.             'city' => 'ToCity',
  280.             'zip' => 'ToZip',
  281.         ];
  282.         // just double check that we have an id
  283.         if (empty($request['request']['CallSid'])) {
  284.             throw new \RuntimeException();
  285.         }
  286.         // determine some basic attempt stuff
  287.         $attempt $this->loadAttempt(VoiceContactAttempt::class, $request['request']['CallSid']);
  288.         // start a query builder and attach core stuff
  289.         $qb $this->em->createQueryBuilder()
  290.             ->update(VoiceContactAttempt::class, 'attempt')
  291.             // set the event name for the status
  292.             ->set('attempt.event'':event')
  293.             ->setParameter('event'sprintf('voice.%s'$request['request']['CallStatus']))
  294.             // handle the timestamp
  295.             ->set('attempt.triggeredAt'':timestamp')
  296.             ->setParameter('timestamp'DateTimeUtils::make(
  297.                 $request['request']['Timestamp'],
  298.                 DateTimeInterface::RFC2822
  299.             ))
  300.             // filter by the id of the call
  301.             ->andWhere('attempt.id = :id')
  302.             ->setParameter('id'$attempt['id'])
  303.             // make sure the sequence is after what has already come before
  304.             ->andWhere('(attempt.sequenceNumber IS NULL OR attempt.sequenceNumber < :sequence)')
  305.             ->setParameter('sequence'$request['request']['SequenceNumber']);
  306.         // handle the mapped fields
  307.         foreach ($mapping as $field => $index) {
  308.             if ( ! empty($request['request'][$index])) {
  309.                 $qb
  310.                     ->set(sprintf('attempt.%s'$field), sprintf(':%s'$field))
  311.                     ->setParameter($field$request['request'][$index]);
  312.             }
  313.         }
  314.         // run it
  315.         $qb->getQuery()->execute();
  316.         // do only if our status is not a pending status
  317.         if (in_array(sprintf('voice.%s'$request['request']['CallStatus']), [...VoiceContactAttempt::SUCCESSFUL_STATUSES, ...VoiceContactAttempt::FAILED_STATUSES])) {
  318.             // attach stats update query
  319.             $this->trackItemDelivery(
  320.                 $attempt['job'],
  321.                 ChannelsInterface::CHANNELS__VOICE,
  322.                 in_array(sprintf('voice.%s'$request['request']['CallStatus']), VoiceContactAttempt::SUCCESSFUL_STATUSES),
  323.                 function (QueryBuilder $qb) use ($request) {
  324.                     if (isset($request['request']['AnsweredBy']) && $request['request']['AnsweredBy'] === 'human') {
  325.                         $qb->set('job.voiceAnswered''job.voiceAnswered + 1');
  326.                     }
  327.                 }
  328.             );
  329.             // update reachability
  330.             $this->trackReachability(
  331.                 $attempt['recipient'],
  332.                 in_array(sprintf('voice.%s'$request['request']['CallStatus']), VoiceContactAttempt::SUCCESSFUL_STATUSES)
  333.             );
  334.         }
  335.     }
  336.     /**
  337.      * @param array $request
  338.      */
  339.     public function handleEmail(array $request): void
  340.     {
  341.         // webhook payload is in the content, this is an array of arrays
  342.         $hooks Json::decode($request['content'], true);
  343.         // need to loop over each, the webhook may have multiple events to handle
  344.         $data = [];
  345.         foreach ($hooks as $hook) {
  346.             // obtain the id being passed to us, will need to match this up in the db later
  347.             // also needs to be split up
  348.             // @see https://sendgrid.com/docs/glossary/message-id/
  349.             $id strtok($hook['sg_message_id'], '.');
  350.             // handle special cases
  351.             $params = [
  352.                 //'sg_event_id' => null,
  353.                 'event' => null,
  354.                 'reason' => null,
  355.                 'type' => null,
  356.                 'code' => null,
  357.                 'response' => null,
  358.                 'attempt' => null,
  359.                 'timestamp' => null,
  360.             ];
  361.             foreach (array_keys($params) as $param) {
  362.                 if (array_key_exists($param$hook)) {
  363.                     $params[$param] = $hook[$param];
  364.                 }
  365.             }
  366.             $params['_id'] = $id;
  367.             // save it onto the set of data for quicker access
  368.             array_unshift($data$params);
  369.         }
  370.         // loop over all the filtered events data
  371.         foreach ($data as $params) {
  372.             // determine some basic attempt stuff
  373.             $attempt $this->loadAttempt(EmailContactAttempt::class, $params['_id']);
  374.             // need to save off the event
  375.             $event $params['event'];
  376.             // branch on the event type
  377.             switch ($event) {
  378.                 // open event is handled specially as it is an async event not tied to sending status
  379.                 case 'open':
  380.                     // handle updating info on the attempt first
  381.                     $result $this->em->createQueryBuilder()
  382.                         ->update(EmailContactAttempt::class, 'attempt')
  383.                         // set the opened at timestamp, this is in unix time
  384.                         // do only if the opened timestamp has not been set yet
  385.                         ->set('attempt.openedAt''CASE WHEN attempt.openedAt IS NULL THEN FROM_UNIXTIME(:timestamp) ELSE attempt.openedAt END')
  386.                         ->setParameter('timestamp'$params['timestamp'])
  387.                         // be sure to filter by the external id
  388.                         ->andWhere('attempt.id = :id')
  389.                         ->setParameter('id'$attempt['id'])
  390.                         ->getQuery()
  391.                         ->execute();
  392.                     // update stats tracking on the job
  393.                     // only do if there is a result on the last query
  394.                     // if there is, that means that the last query modified a row
  395.                     // if no result, then the query did not run, meaning our stuff has already been tracked
  396.                     if ($result) {
  397.                         $this->em->createQueryBuilder()
  398.                             ->update(Job::class, 'job')
  399.                             // inc open tracking
  400.                             ->set('job.emailOpened''(job.emailOpened + 1)')
  401.                             // IMPORTANT: must always filter by message id since we are doing a bulk update query!
  402.                             ->andWhere('job.id = :job')
  403.                             ->setParameter('job'$attempt['job'])
  404.                             ->getQuery()
  405.                             ->execute();
  406.                     }
  407.                     break;
  408.                 // handle spam report
  409.                 case 'spamreport':
  410.                     // handle updating info on the attempt first
  411.                     $result $this->em->createQueryBuilder()
  412.                         ->update(EmailContactAttempt::class, 'attempt')
  413.                         // set the opened at timestamp, this is in unix time
  414.                         // do only if the opened timestamp has not been set yet
  415.                         ->set('attempt.spammedAt''CASE WHEN attempt.spammedAt IS NULL THEN FROM_UNIXTIME(:timestamp) ELSE attempt.spammedAt END')
  416.                         ->setParameter('timestamp'$params['timestamp'])
  417.                         // be sure to filter by the external id
  418.                         ->andWhere('attempt.id = :id')
  419.                         ->setParameter('id'$attempt['id'])
  420.                         ->getQuery()
  421.                         ->execute();
  422.                     // update stats tracking on the job
  423.                     // only do if there is a result on the last query
  424.                     // if there is, that means that the last query modified a row
  425.                     // if no result, then the query did not run, meaning our stuff has already been tracked
  426.                     if ($result) {
  427.                         $this->em->createQueryBuilder()
  428.                             ->update(Job::class, 'job')
  429.                             // inc spam tracking
  430.                             ->set('job.emailSpammed''(job.emailSpammed + 1)')
  431.                             // IMPORTANT: must always filter by message id since we are doing a bulk update query!
  432.                             ->andWhere('job.id = :job')
  433.                             ->setParameter('job'$attempt['job'])
  434.                             ->getQuery()
  435.                             ->execute();
  436.                     }
  437.                     break;
  438.                 // should be a sending status update
  439.                 default:
  440.                     // start the query builder and handle core things
  441.                     $qb $this->em->createQueryBuilder()
  442.                         ->update(EmailContactAttempt::class, 'attempt')
  443.                         // set the event name for the status
  444.                         ->set('attempt.event'':event')
  445.                         ->setParameter('event'sprintf('email.%s'$params['event']))
  446.                         // handle the timestamp
  447.                         ->set('attempt.triggeredAt''FROM_UNIXTIME(:timestamp)')
  448.                         ->setParameter('timestamp'$params['timestamp'])
  449.                         // be sure to filter by the external id
  450.                         ->andWhere('attempt.id = :id')
  451.                         ->setParameter('id'$attempt['id'])
  452.                         // make sure the sequence is after what has already come before
  453.                         ->andWhere('(attempt.event IS NULL OR attempt.event IN (:statuses))')
  454.                         ->setParameter('statuses'array_slice(
  455.                             EmailContactAttempt::STATUSES,
  456.                             0,
  457.                             array_search(
  458.                                 sprintf('email.%s',  $params['event']),
  459.                                 EmailContactAttempt::STATUSES
  460.                             )
  461.                         ))
  462.                     ;
  463.                     // need to remove ones we've already handled
  464.                     unset(
  465.                         $params['_id'],
  466.                         $params['event'],
  467.                         $params['timestamp'],
  468.                     );
  469.                     // pull all applicable vars and set them in the query
  470.                     foreach ($params as $key => $value) {
  471.                         if ( ! empty($value)) {
  472.                             $qb
  473.                                 ->set(sprintf('attempt.%s'$key), sprintf(':%s'$key))
  474.                                 ->setParameter($key$value);
  475.                         }
  476.                     }
  477.                     // attach the query to the set
  478.                     $qb->getQuery()->execute();
  479.                     // do only if our status is not a pending status
  480.                     if (in_array(sprintf('email.%s'$event), [...EmailContactAttempt::SUCCESSFUL_STATUSES, ...EmailContactAttempt::FAILED_STATUSES])) {
  481.                         // attach stats update query
  482.                         $this->trackItemDelivery(
  483.                             $attempt['job'],
  484.                             ChannelsInterface::CHANNELS__EMAIL,
  485.                             in_array(sprintf('email.%s'$event), EmailContactAttempt::SUCCESSFUL_STATUSES)
  486.                         );
  487.                         // update reachability
  488.                         $this->trackReachability(
  489.                             $attempt['recipient'],
  490.                             in_array(sprintf('email.%s'$event), EmailContactAttempt::SUCCESSFUL_STATUSES)
  491.                         );
  492.                     }
  493.             }
  494.         }
  495.     }
  496.     /**
  497.      * @param int $job
  498.      * @param int $channel
  499.      * @param bool $result
  500.      * @param callable|null $callback
  501.      * @return int
  502.      */
  503.     protected function trackItemDelivery(int $jobint $channelbool $result, ?callable $callback null): int
  504.     {
  505.         if ( ! in_array($channelTransactionalChannelsInterface::TRANSACTIONAL_CHANNELS)) {
  506.             throw new \LogicException();
  507.         }
  508.         $secondary array_search($channelChannelsInterface::USABLE_CHANNELS);
  509.         $field = ($result) ? 'Delivered' 'Undelivered';
  510.         $qb $this->em->createQueryBuilder()
  511.             ->update(Job::class, 'job')
  512.             // inc primary result
  513.             ->set(
  514.                 sprintf('job.messages%s'$field),
  515.                 sprintf('(job.messages%s + 1)'$field)
  516.             )
  517.             // inc secondary result
  518.             ->set(
  519.                 sprintf('job.%s%s'$secondary$field),
  520.                 sprintf('(job.%s%s + 1)'$secondary$field)
  521.             )
  522.             // set the last delivery timestamp
  523.             ->set('job.lastDeliveryAt''NOW()')
  524.             // IMPORTANT: must always filter by message id since we are doing a bulk update query!
  525.             ->andWhere('job.id = :job')
  526.             ->setParameter('job'$job);
  527.         if ($callback) {
  528.             $callback($qb);
  529.         }
  530.         return $qb->getQuery()->execute();
  531.     }
  532.     /**
  533.      * @param int $recipient
  534.      * @param bool $success
  535.      * @return void
  536.      */
  537.     protected function trackReachability(int $recipientbool $success): void
  538.     {
  539.         // obtain metadata we are going to need
  540.         /** @var array|ClassMetadata[] $metadatas */
  541.         $metadatas = [
  542.             Profile::class => $this->em->getClassMetadata(Profile::class),
  543.             ProfileContact::class => $this->em->getClassMetadata(ProfileContact::class),
  544.             AbstractRecipient::class => $this->em->getClassMetadata(AbstractRecipient::class),
  545.             Student::class => $this->em->getClassMetadata(Student::class),
  546.             ProfileRelationship::class => $this->em->getClassMetadata(ProfileRelationship::class),
  547.         ];
  548.         // calculate the reachability information
  549.         $standing = ($success) ? Reachability::STANDINGS__GOOD Reachability::STANDINGS__BAD;
  550.         $contactability Reachability::standingsToContactability($standing);
  551.         $reachability Reachability::contactabilitiesToReachability($contactability);
  552.         // update the reachability for the specific contact
  553.         $updated $this->em->createQueryBuilder()
  554.             ->update(AbstractRecipient::class, 'recipient')
  555.             // set the standing
  556.             ->set('recipient.standing'':standing')
  557.             ->setParameter('standing'$standing)
  558.             // set the contactability
  559.             ->set('recipient.contactability'':contactability')
  560.             ->setParameter('contactability'$contactability)
  561.             // set the reachability
  562.             ->set('recipient.reachability'':reachability')
  563.             ->setParameter('reachability'$reachability)
  564.             // IMPORTANT: must always filter by id since we are doing a bulk update query!
  565.             ->andWhere('recipient.id = :recipient')
  566.             ->setParameter('recipient'$recipient)
  567.             ->getQuery()
  568.             ->execute();
  569.         // when the above query results in no changes, that means that any profiles or students (in being related to profiles)
  570.         // tied to the current recipient would also not change.
  571.         // in that case, the native SQL queries that follow the Doctrine query don't need to be run.
  572.         if ($updated === 0) {
  573.             return;
  574.         }
  575.         // then, force an update of the profiles attached to the contact
  576.         // need to pull all contacts for the profiles and mash their standings together
  577.         // do a native query for this, as it is too complex for dql...
  578.         try {
  579.             $this->em->getConnection()->executeStatement(
  580.                 preg_replace('/\s+/'' 'trim(sprintf(
  581.                     '
  582.                         UPDATE
  583.                             %s profiles
  584.                         INNER JOIN
  585.                             (
  586.                                 SELECT
  587.                                     contacts.profile AS profile,
  588.                                     BIT_OR(recipients.standing) AS standing
  589.                                 FROM
  590.                                     %s contacts
  591.                                 LEFT JOIN
  592.                                     %s recipients ON recipients.id = contacts.recipient
  593.                                 WHERE
  594.                                     contacts.profile IN (
  595.                                         SELECT
  596.                                             contact_identities.profile
  597.                                         FROM
  598.                                             %s contact_identities
  599.                                         WHERE
  600.                                             contact_identities.recipient = ?
  601.                                     )
  602.                                 GROUP BY
  603.                                     contacts.profile
  604.                             ) tbl ON tbl.profile = profiles.id
  605.                         SET 
  606.                             profiles.standing = tbl.standing,
  607.                             profiles.contactability = %s,
  608.                             profiles.reachability = %s
  609.                     ',
  610.                     $metadatas[Profile::class]->getTableName(),
  611.                     $metadatas[ProfileContact::class]->getTableName(),
  612.                     $metadatas[AbstractRecipient::class]->getTableName(),
  613.                     $metadatas[ProfileContact::class]->getTableName(),
  614.                     // NOTE: the order of the fields in the set clause matters, as the later ones use the now-modified values of the ones before!
  615.                     Reachability::databaseStandingsToContactability(sprintf(
  616.                         'profiles.%s',
  617.                         'standing',
  618.                     )),
  619.                     Reachability::databaseContactabilityToReachability(sprintf(
  620.                         'profiles.%s',
  621.                         'contactability',
  622.                     )),
  623.                 ))),
  624.                 [$recipient],
  625.                 [PDO::PARAM_INT]
  626.             );
  627.         } catch (\Exception) {
  628.             // NOOP
  629.         }
  630.         // then, force an update of all students attached to the profiles
  631.         // need to pull all the profiles for a student and mash the standings together
  632.         // do a native query for this, as it is too complex for dql...
  633.         try {
  634.             // NOTE: this query attempts to (in order of deepest nested clause to outermost clause)
  635.             // 1. use the recipient information (passed into this method) to determine which profiles are affected (by way of the profile contacts)
  636.             // 2. use the affected profiles to find the affected students (by way of the profile relationships)
  637.             // 3. use the affected students to ensure that the outermost relationships query only relates to the affected students
  638.             // 4. use the outermost relationships query to pull the student id and to compress all related profile standings into one usable value
  639.             // 5. finally perform the updates on the given fields using the matched rows from the join
  640.             $this->em->getConnection()->executeStatement(
  641.                 preg_replace('/\s+/'' 'trim(sprintf(
  642.                     '
  643.                         UPDATE
  644.                             %s students
  645.                         INNER JOIN
  646.                             (
  647.                                 SELECT
  648.                                     relationships.student AS student,
  649.                                     BIT_OR(profiles.standing) AS standing
  650.                                 FROM
  651.                                     %s relationships
  652.                                 LEFT JOIN
  653.                                     %s profiles ON profiles.id = relationships.profile
  654.                                 WHERE
  655.                                     relationships.student IN (
  656.                                         SELECT
  657.                                             relationship_identities.student
  658.                                         FROM
  659.                                             %s relationship_identities
  660.                                         WHERE
  661.                                             relationship_identities.profile IN (
  662.                                                 SELECT
  663.                                                     contacts.profile
  664.                                                 FROM
  665.                                                     %s contacts
  666.                                                 WHERE
  667.                                                     contacts.recipient = ?
  668.                                             )
  669.                                     )
  670.                                 GROUP BY
  671.                                     relationships.student
  672.                             ) tbl ON tbl.student = students.id
  673.                         SET 
  674.                             students.standing = tbl.standing,
  675.                             students.contactability = %s,
  676.                             students.reachability = %s
  677.                     ',
  678.                     $metadatas[Student::class]->getTableName(),
  679.                     $metadatas[ProfileRelationship::class]->getTableName(),
  680.                     $metadatas[Profile::class]->getTableName(),
  681.                     $metadatas[ProfileRelationship::class]->getTableName(),
  682.                     $metadatas[ProfileContact::class]->getTableName(),
  683.                     // NOTE: the order of the fields in the set clause matters, as the later ones use the now-modified values of the ones before!
  684.                     Reachability::databaseStandingsToContactability(sprintf(
  685.                         'students.%s',
  686.                         'standing',
  687.                     )),
  688.                     Reachability::databaseContactabilityToReachability(sprintf(
  689.                         'students.%s',
  690.                         'contactability',
  691.                     )),
  692.                 ))),
  693.                 [$recipient],
  694.                 [PDO::PARAM_INT]
  695.             );
  696.         } catch (\Exception) {
  697.             // NOOP
  698.         }
  699.     }
  700. }